Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/plugin-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,11 @@ export interface CanvasTTYDecisionRequest {
/** The agent's current folder, when its CLI reports it. */
agentCwd: string | null;
tool: { name: string; kind: "shell" | "edit" | "other"; command: string | null; paths: string[] };
/** The tool input as the agent sent it; null when it was over 40 KB (then `truncated`). */
/**
* The tool input as the agent sent it; null when it was over 40 KB (then `truncated`). OpenCode's tools come in Claude
* Code's names and shapes: bash {command, workdir}, Write {file_path, content}, Edit {file_path, old_string, new_string,
* replace_all}, MultiEdit {file_path, edits: [{old_string, new_string, replace_all}]}, apply_patch {patch}.
*/
input: unknown;
truncated: boolean;
/** How long the host waits for this answer (the service's `timeoutMs`). */
Expand Down
2 changes: 1 addition & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ Before a local agent's shell command or file write runs, CanvasTTY can ask a plu
}]
```

`pre-tool` is every shell and file-writing tool call, before it runs and in every permission mode, YOLO included: Claude Code, Codex and Qwen Code through their `PreToolUse` hook, OpenCode through CanvasTTY's OpenCode plugin (`tool.execute.before`). `appliesTo` limits the agents; all four when omitted. The host sends `canvastty.decide` (host-only) and waits at most `timeoutMs` (1000 to 60000; 3000 when omitted). The agent's call waits as long, so ask for more only when the answer needs it (for example a local model reading the command); CanvasTTY sizes each card's hook for the longest budget of the services that apply when the card starts, and a service trusted later gets no more than its card allows. The request carries the budget as `budgetMs`:
`pre-tool` is every shell and file-writing tool call, before it runs and in every permission mode, YOLO included: Claude Code, Codex and Qwen Code through their `PreToolUse` hook, OpenCode through CanvasTTY's OpenCode plugin (`tool.execute.before`). OpenCode's tools arrive under Claude Code's names and input shapes: `bash` `{command, workdir}`, `Write` `{file_path, content}` (the whole new file), `Edit` `{file_path, old_string, new_string, replace_all}`, `MultiEdit` `{file_path, edits: [{old_string, new_string, replace_all}]}` and `apply_patch` `{patch}`. `appliesTo` limits the agents; all four when omitted. The host sends `canvastty.decide` (host-only) and waits at most `timeoutMs` (1000 to 60000; 3000 when omitted). The agent's call waits as long, so ask for more only when the answer needs it (for example a local model reading the command); CanvasTTY sizes each card's hook for the longest budget of the services that apply when the card starts, and a service trusted later gets no more than its card allows. The request carries the budget as `budgetMs`:

```ts
interface DecisionRequest {
Expand Down
2 changes: 1 addition & 1 deletion docs/plugins.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ CanvasTTY владеет окном, PTY, сохранённой записью
}]
```

`pre-tool` — каждый вызов shell-инструмента и записи файла до выполнения, в любом режиме разрешений, включая YOLO: у Claude Code, Codex и Qwen Code через их хук `PreToolUse`, у OpenCode через плагин CanvasTTY для OpenCode (`tool.execute.before`). `appliesTo` ограничивает агентов; без него — все четыре. Хост отправляет `canvastty.decide` (только хост) и ждёт не больше `timeoutMs` (от 1000 до 60000; без него 3000). Вызов агента ждёт столько же, поэтому просите больше, только когда ответу это нужно (например, локальная модель читает команду); CanvasTTY настраивает хук каждого окна под самый долгий бюджет сервисов, которые к нему относятся на момент запуска, а сервис, которому доверились позже, получает не больше, чем позволяет окно. Бюджет приходит в запросе как `budgetMs`:
`pre-tool` — каждый вызов shell-инструмента и записи файла до выполнения, в любом режиме разрешений, включая YOLO: у Claude Code, Codex и Qwen Code через их хук `PreToolUse`, у OpenCode через плагин CanvasTTY для OpenCode (`tool.execute.before`). Инструменты OpenCode приходят с именами и формой ввода Claude Code: `bash` `{command, workdir}`, `Write` `{file_path, content}` (весь новый файл), `Edit` `{file_path, old_string, new_string, replace_all}`, `MultiEdit` `{file_path, edits: [{old_string, new_string, replace_all}]}` и `apply_patch` `{patch}`. `appliesTo` ограничивает агентов; без него — все четыре. Хост отправляет `canvastty.decide` (только хост) и ждёт не больше `timeoutMs` (от 1000 до 60000; без него 3000). Вызов агента ждёт столько же, поэтому просите больше, только когда ответу это нужно (например, локальная модель читает команду); CanvasTTY настраивает хук каждого окна под самый долгий бюджет сервисов, которые к нему относятся на момент запуска, а сервис, которому доверились позже, получает не больше, чем позволяет окно. Бюджет приходит в запросе как `budgetMs`:

```ts
interface DecisionRequest {
Expand Down
2 changes: 1 addition & 1 deletion docs/plugins.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ CanvasTTY 负责卡片、PTY、保存的记录和恢复顺序;服务回答五
}]
```

`pre-tool` 指每一次 shell 和写文件的工具调用,在运行之前,并且适用于所有权限模式(包括 YOLO):Claude Code、Codex 和 Qwen Code 通过它们的 `PreToolUse` hook,OpenCode 通过 CanvasTTY 的 OpenCode 插件(`tool.execute.before`)。`appliesTo` 限定 agent;省略时为全部四个。主机发送 `canvastty.decide`(仅主机可发),最多等待 `timeoutMs`(1000 到 60000;省略时 3000)。agent 的调用会等待同样长的时间,所以只有在回答确实需要时才申请更长时间(例如让本地模型阅读命令);CanvasTTY 在卡片启动时按适用服务中最长的预算设置该卡片的 hook,之后才被信任的服务所得时间不超过卡片允许的范围。请求以 `budgetMs` 携带该预算:
`pre-tool` 指每一次 shell 和写文件的工具调用,在运行之前,并且适用于所有权限模式(包括 YOLO):Claude Code、Codex 和 Qwen Code 通过它们的 `PreToolUse` hook,OpenCode 通过 CanvasTTY 的 OpenCode 插件(`tool.execute.before`)。OpenCode 的工具以 Claude Code 的名称和输入形状到达:`bash` `{command, workdir}`、`Write` `{file_path, content}`(整个新文件)、`Edit` `{file_path, old_string, new_string, replace_all}`、`MultiEdit` `{file_path, edits: [{old_string, new_string, replace_all}]}` 和 `apply_patch` `{patch}`。`appliesTo` 限定 agent;省略时为全部四个。主机发送 `canvastty.decide`(仅主机可发),最多等待 `timeoutMs`(1000 到 60000;省略时 3000)。agent 的调用会等待同样长的时间,所以只有在回答确实需要时才申请更长时间(例如让本地模型阅读命令);CanvasTTY 在卡片启动时按适用服务中最长的预算设置该卡片的 hook,之后才被信任的服务所得时间不超过卡片允许的范围。请求以 `budgetMs` 携带该预算:

```ts
interface DecisionRequest {
Expand Down
45 changes: 31 additions & 14 deletions src/agent-runtime/opencode-decisions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,13 @@ export function createOpenCodeDecisions({ client, env = process.env, send = exch
}

/**
* An OpenCode tool call in the shape the decision hook reads (the Claude/Codex hook tool names), or null for a tool
* that neither runs commands nor writes files. OpenCode's own names: bash {command, workdir}, write {filePath,
* content}, edit {filePath, oldString, newString}, multiedit {filePath, edits}, apply_patch {patchText}.
* An OpenCode tool call in the shape the decision hook reads (Claude Code's hook tool names and input shapes), or null
* for a tool that neither runs commands nor writes files. OpenCode's own names: bash {command, workdir}, write
* {filePath, content}, edit {filePath, oldString, newString, replaceAll}, multiedit {filePath, edits: [{filePath,
* oldString, newString, replaceAll}]}, apply_patch {patchText}. They go out as bash {command, workdir}, Write {file_path,
* content} (the whole new file), Edit {file_path, old_string, new_string, replace_all} (one replacement in the file),
* MultiEdit {file_path, edits: [{old_string, new_string, replace_all}]} and apply_patch {patch}, so a decision service
* can tell a whole-file write from a partial edit and sees the text an edit replaces.
*/
export function guardedCall(tool, args) {
if (!tool || !args || typeof args !== "object") return null;
Expand All @@ -74,13 +78,15 @@ export function guardedCall(tool, args) {
return { toolName: "bash", toolInput: { command: args.command, ...(workdir ? { workdir } : {}) } };
}
if (tool === "write" || tool === "edit" || tool === "multiedit") {
const filePath = typeof args.filePath === "string" && args.filePath.length > 0 ? args.filePath
: typeof args.file_path === "string" && args.file_path.length > 0 ? args.file_path : null;
const filePath = pathOf(args);
if (!filePath) return null;
const content = typeof args.content === "string" ? args.content
: typeof args.newString === "string" ? args.newString
: multieditText(args.edits);
return { toolName: "edit", toolInput: { file_path: filePath, ...(content !== null ? { content } : {}) } };
if (tool === "write") {
return { toolName: "Write", toolInput: { file_path: filePath, ...(typeof args.content === "string" ? { content: args.content } : {}) } };
}
if (tool === "edit") return { toolName: "Edit", toolInput: { file_path: filePath, ...replacement(args) } };
const edits = Array.isArray(args.edits) ? args.edits.filter((edit) => edit && typeof edit === "object").map(replacement)
.filter((edit) => Object.keys(edit).length > 0) : [];
return { toolName: "MultiEdit", toolInput: { file_path: filePath, edits } };
}
if (tool === "apply_patch" || tool === "patch") {
const patch = typeof args.patchText === "string" ? args.patchText : typeof args.patch === "string" ? args.patch : null;
Expand All @@ -89,11 +95,22 @@ export function guardedCall(tool, args) {
return null;
}

/** Every multiedit edit's new text, one per line (what the batch writes), or null. */
function multieditText(edits) {
if (!Array.isArray(edits)) return null;
const texts = edits.flatMap((edit) => edit && typeof edit === "object" && typeof edit.newString === "string" ? [edit.newString] : []);
return texts.length > 0 ? texts.join("\n") : null;
function pathOf(args) {
return typeof args.filePath === "string" && args.filePath.length > 0 ? args.filePath
: typeof args.file_path === "string" && args.file_path.length > 0 ? args.file_path : null;
}

/** One OpenCode replacement {oldString, newString, replaceAll} as Claude's Edit input (fields that are not text are left out). */
function replacement(edit) {
const text = (camel, snake) => typeof edit[camel] === "string" ? edit[camel] : typeof edit[snake] === "string" ? edit[snake] : null;
const oldString = text("oldString", "old_string");
const newString = text("newString", "new_string");
const replaceAll = typeof edit.replaceAll === "boolean" ? edit.replaceAll : typeof edit.replace_all === "boolean" ? edit.replace_all : null;
return {
...(oldString !== null ? { old_string: oldString } : {}),
...(newString !== null ? { new_string: newString } : {}),
...(replaceAll !== null ? { replace_all: replaceAll } : {})
};
}

/** POST /permission/{requestID}/reply with `once`. False on any failure (OpenCode's prompt stays for the person). */
Expand Down
5 changes: 4 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,8 @@ async function initializeServices(ipc: IpcRegistrar): Promise<void> {
providerClis,
runtimeDirectory,
hermesHomeDirectory,
kimiHomeDirectory
kimiHomeDirectory,
accountHomesRoot: join(userDataPath, "plugin-data")
});
// Off the startup path: the first Kimi launch then finds the probe answered instead of blocking on it.
const bridge = agentBrowserBridge;
Expand Down Expand Up @@ -588,6 +589,8 @@ async function initializeServices(ipc: IpcRegistrar): Promise<void> {
openCodePluginPath,
hermesHomeDirectory,
kimiHomeDirectory,
// Account homes a launch contributor starts Grok/Kimi with live in plugin data (ProviderRuntimeLaunch.ownAccountHome).
accountHomesRoot: join(userDataPath, "plugin-data"),
recoverOnStart: true,
coreHooksEnabled: settings.get().agentLifecycleHooksEnabled,
permissionGate: helpers.permissionGate,
Expand Down
12 changes: 11 additions & 1 deletion src/main/services/LaunchPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
import { claudeCoreSettingsKey, coreOwnedLaunchArgument, parseInlineSettings } from "./terminalLaunch.ts";
import { MAX_PLUGIN_SLOT_BYTES } from "./TerminalSessionStore.ts";
import { LaunchRefusal } from "./launchRefusal.ts";
import { contributedHookHome } from "./accountHomes.ts";
import { MAX_INSPECTED_CONFIG_BYTES, parseJsonc, readInspectedFile } from "./inspectedConfig.ts";

/** A trusted plugin service that declared launch options (PluginManager.launchContributors). */
Expand All @@ -22,6 +23,8 @@ export interface LaunchContributor {
launch: PluginServiceLaunch;
/** The plugin holds the `secrets` permission, so `secretEnv` may name its secrets. */
secrets: boolean;
/** `<userData>/plugin-data/<pluginId>`, the host's record (never the plugin's answer); account homes live below it. */
dataDir?: string;
}

export interface LaunchPipelineDependencies {
Expand Down Expand Up @@ -73,6 +76,11 @@ export type PreparedLaunch =
envSources: Record<string, string>;
/** A contributor runs the agent on another model than its vendor's: "auto" becomes accept-edits. */
thirdPartyModel: boolean;
/**
* Grok/Kimi: the home (`GROK_HOME` / `KIMI_CODE_HOME`) the contributor that set it handed this launch inside its own
* data folder (contributedHookHome); CanvasTTY writes that CLI's temporary configuration there.
*/
accountHome?: string;
cleanup(): Promise<void>;
}
| { ok: false; reason: string };
Expand Down Expand Up @@ -267,6 +275,7 @@ export class LaunchPipeline {
const args: string[] = [];
const secrets: string[] = [];
let thirdPartyModel = false;
let accountHome: string | undefined;
for (const answer of answers) {
if ("refuse" in answer) return refuse(answer.refuse);
}
Expand Down Expand Up @@ -313,6 +322,7 @@ export class LaunchPipeline {
const conflict = claim(key);
if (conflict) return refuse(conflict);
env[key] = expand(value);
accountHome = contributedHookHome(context.provider, contributor.dataDir, key, env[key]) ?? accountHome;
}
for (const [key, secretKey] of Object.entries(contribution.secretEnv)) {
const conflict = claim(key);
Expand All @@ -327,7 +337,7 @@ export class LaunchPipeline {
args.push(...contributedArgs.map(expand));
thirdPartyModel ||= contribution.thirdPartyModel === true;
}
return { ok: true, env, args, secrets, envSources, thirdPartyModel, cleanup };
return { ok: true, env, args, secrets, envSources, thirdPartyModel, ...(accountHome ? { accountHome } : {}), cleanup };
}

private async ask(
Expand Down
3 changes: 2 additions & 1 deletion src/main/services/PluginManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,8 @@ export class PluginManager {
pluginName: manifest.name,
serviceId: service.id,
launch: structuredClone(service.launch),
secrets: manifest.permissions.includes("secrets")
secrets: manifest.permissions.includes("secrets"),
dataDir: join(this.dataRoot, plugin.manifest.id)
});
}
return contributors;
Expand Down
7 changes: 7 additions & 0 deletions src/main/services/TerminalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
type ControlConnection
} from "./agent-control/controlCapabilities.ts";
import { codexTrustArguments, mergeOpenCodeLaunchEnvironment } from "./agent-runtime/ProviderRuntimeLaunch.ts";
import { accountHookHome } from "./accountHomes.ts";
import { openCodeProjectFolderEnvironment } from "./openCodeConfig.ts";
import { onDiskPath } from "./onDiskPath.ts";
import { processWorkingDirectory } from "./processWorkingDirectory.ts";
Expand Down Expand Up @@ -2071,10 +2072,15 @@ export class TerminalManager {
cwd
} : undefined;
const shellCli = provider === "terminal" && !environmentWrapped ? this.providerClis.get("codex") : undefined;
// Grok and Kimi read their hooks (Kimi also its MCP servers) from their home: the launch's own account home (inside
// the data folder of the plugin that set it) gets CanvasTTY's temporary configuration there.
const hookHome = accountHookHome(provider, contribution?.env);
const accountHome = hookHome !== undefined && hookHome === contribution?.accountHome ? hookHome : undefined;
const agentRuntime = provider === "terminal"
? shellCli?.state === "available" ? this.agentRuntime?.prepareShellLaunch?.({ terminalSessionId: id, cli: shellCli, environment: terminalEnvironment() }) ?? null : null
: this.agentRuntime?.prepareLaunch({ terminalSessionId: id, provider, cwd,
...(captureResult ? { captureResult: true } : {}),
...(accountHome ? { accountHome } : {}),
...(claudeHttp ? { claudeHttp } : {}),
...(answerCaptureGrantExpiresAt === undefined ? {} : { answerCaptureGrantExpiresAt }) }) ?? null;
let pluginTools: string[] = [];
Expand Down Expand Up @@ -2106,6 +2112,7 @@ export class TerminalManager {
terminalSessionId: id,
provider,
cwd,
...(accountHome ? { accountHome } : {}),
...(bridged ? {
includeOrchestration: true,
orchestrationTools: [...(role === "orchestrator" ? ORCHESTRATION_TOOL_NAMES : []), ...pluginTools]
Expand Down
Loading
Loading