@steerable/agent-shell 0.6.50 → 0.6.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -180,11 +180,53 @@ export function isPathWithinRoot(resolvedPath, resolvedRoot) {
180
180
  const rel = path.relative(resolvedRoot, resolvedPath);
181
181
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
182
182
  }
183
+ /** `~` 展开后 resolve。围栏比较前路径与根都要过这一步。 */
184
+ export function expandFencePath(inputPath) {
185
+ const expanded = inputPath.startsWith('~')
186
+ ? path.join(os.homedir(), inputPath.slice(1))
187
+ : inputPath;
188
+ return path.resolve(expanded);
189
+ }
190
+ /**
191
+ * 可写根:项目家目录(或对话工作区)加上额外可写根(源文件夹)。
192
+ * 每个根都包含它自己和全部子目录。空列表表示不设围栏。
193
+ */
194
+ export function writableFenceRoots(projectRoot, additionalWriteRoots) {
195
+ const roots = [];
196
+ const push = (raw) => {
197
+ if (!raw)
198
+ return;
199
+ const trimmed = raw.trim();
200
+ if (!trimmed)
201
+ return;
202
+ const resolved = expandFencePath(trimmed);
203
+ if (!roots.includes(resolved))
204
+ roots.push(resolved);
205
+ };
206
+ push(projectRoot);
207
+ for (const extra of additionalWriteRoots ?? [])
208
+ push(extra);
209
+ return roots;
210
+ }
183
211
  /** 越界错误消息(给模型看,引导它把操作收回项目目录内)。 */
184
212
  export function buildProjectRootViolation(resolvedPath, resolvedRoot) {
185
213
  return (`路径越界:${resolvedPath} 不在当前对话绑定的项目目录 ${resolvedRoot} 内。` +
186
- `项目模式下文件操作被限制在项目目录中——请改用项目内的相对/绝对路径;` +
187
- `如确需访问目录外文件,请告知用户该限制并请其把文件放进项目目录。`);
214
+ `项目模式下文件操作被限制在项目目录及其子目录中——请改用项目内的相对/绝对路径;` +
215
+ `如确需访问目录外文件,请告知用户该限制并请其把目录附加为源文件夹。`);
216
+ }
217
+ /**
218
+ * 路径不在任一可写根(含其子目录)内时返回给模型的错误;在围栏内或未设围栏时返回 null。
219
+ * `resolvedPath` 与 `roots` 都应已 resolve。
220
+ */
221
+ export function writableFenceViolation(resolvedPath, roots) {
222
+ if (roots.length === 0)
223
+ return null;
224
+ if (roots.some((root) => isPathWithinRoot(resolvedPath, root)))
225
+ return null;
226
+ if (roots.length === 1)
227
+ return buildProjectRootViolation(resolvedPath, roots[0]);
228
+ return (`路径越界:${resolvedPath} 不在可写目录内(${roots.join('、')})。` +
229
+ `可写范围是这些目录及其子目录。请改用其中的路径。`);
188
230
  }
189
231
  const BUILTIN_DANGEROUS = [
190
232
  { id: 'rm_rf_root', pattern: 'rm\\s+-rf\\s+\\/(?:\\s|$)', platform: 'unix' },
@@ -480,14 +522,11 @@ export class LocalExecutor {
480
522
  return { success: false, error: message };
481
523
  }
482
524
  }
483
- async writeLocalFile(request, projectRoot) {
525
+ async writeLocalFile(request, projectRoot, additionalWriteRoots) {
484
526
  const filePath = this.resolvePath(request.path);
485
- if (projectRoot) {
486
- const root = this.resolvePath(projectRoot);
487
- if (!isPathWithinRoot(filePath, root)) {
488
- return { success: false, error: buildProjectRootViolation(filePath, root) };
489
- }
490
- }
527
+ const violation = writableFenceViolation(filePath, writableFenceRoots(projectRoot, additionalWriteRoots));
528
+ if (violation)
529
+ return { success: false, error: violation };
491
530
  // 同文件串行化:读-改-写必须排队,否则并发写会互相覆盖(pi file-mutation-queue)。
492
531
  return this.serializeFileOp(filePath, async () => {
493
532
  try {
@@ -544,14 +583,11 @@ export class LocalExecutor {
544
583
  * read-before-write 冲突检测,再在原文上做三级模糊定位 + 逆序替换,最后
545
584
  * 原子落盘。返回新版本令牌与统一 diff。
546
585
  */
547
- async editLocalFile(request, projectRoot) {
586
+ async editLocalFile(request, projectRoot, additionalWriteRoots) {
548
587
  const filePath = this.resolvePath(request.path);
549
- if (projectRoot) {
550
- const root = this.resolvePath(projectRoot);
551
- if (!isPathWithinRoot(filePath, root)) {
552
- return { success: false, error: buildProjectRootViolation(filePath, root) };
553
- }
554
- }
588
+ const violation = writableFenceViolation(filePath, writableFenceRoots(projectRoot, additionalWriteRoots));
589
+ if (violation)
590
+ return { success: false, error: violation };
555
591
  return this.serializeFileOp(filePath, async () => {
556
592
  try {
557
593
  const encoding = request.encoding ?? 'utf-8';
@@ -910,8 +946,7 @@ export class LocalExecutor {
910
946
  });
911
947
  }
912
948
  resolvePath(inputPath) {
913
- const expanded = inputPath.startsWith('~') ? path.join(os.homedir(), inputPath.slice(1)) : inputPath;
914
- return path.resolve(expanded);
949
+ return expandFencePath(inputPath);
915
950
  }
916
951
  resolveCwd(cwd, shellType) {
917
952
  const resolved = this.resolvePath(cwd || os.homedir());
@@ -106,6 +106,16 @@ export interface ProductConfig {
106
106
  cwd?: string;
107
107
  enabled?: boolean;
108
108
  }>;
109
+ /**
110
+ * P3.1 多智能体编排六件套 (agent_spawn / agent_wait / agent_send / agent_interrupt / agent_close / agent_list)。
111
+ * 缺省关(仅开 delegate_subagent);设为 true 或配置对象时在回合中启用。
112
+ * STEERABLE_ORCHESTRATION 环境变量('1' 开启,'0' 显式关闭)优先级更高。
113
+ */
114
+ orchestration?: boolean | {
115
+ enabled?: boolean;
116
+ maxDepth?: number;
117
+ maxParallel?: number;
118
+ };
109
119
  }
110
120
  /** shell 内置智能体:只有产品显式 `true` 才开。 */
111
121
  export declare function isShellBuiltinAgentEnabled(id: 'local-assistant' | 'all-round-assistant', config?: ProductConfig): boolean;
@@ -2,7 +2,7 @@
2
2
  * 项目默认家目录:`Documents/<应用名>/<项目名>/`。
3
3
  *
4
4
  * 新建项目不再等于「选一个已有文件夹」——项目是带名字的容器,
5
- * 家目录由本模块分配并创建。用户另加的源文件夹是附加只读根,
5
+ * 家目录由本模块分配并创建。用户另加的源文件夹是额外可写根(含子目录),
6
6
  * 不替代这个家目录。
7
7
  */
8
8
  /** 把 `~` / `~/…` 展开成绝对路径;其它输入原样(已 trim)。 */
@@ -2,7 +2,7 @@
2
2
  * 项目默认家目录:`Documents/<应用名>/<项目名>/`。
3
3
  *
4
4
  * 新建项目不再等于「选一个已有文件夹」——项目是带名字的容器,
5
- * 家目录由本模块分配并创建。用户另加的源文件夹是附加只读根,
5
+ * 家目录由本模块分配并创建。用户另加的源文件夹是额外可写根(含子目录),
6
6
  * 不替代这个家目录。
7
7
  */
8
8
  import fs from 'node:fs';
@@ -4,8 +4,8 @@
4
4
  * 项目 = 名字 + 托管家目录 + 可选源文件夹。家目录默认建在
5
5
  * `Documents/<应用名>/<项目名>/`(见 project-home.ts)。chat 通过
6
6
  * `chat_sessions.project_id` 绑定项目;绑定后文件读写与命令执行被硬限制
7
- * 在家目录内(见 tool-router.ts 的 ToolExecContext.projectRoot);源文件夹
8
- * 只放宽 local_read_file。
7
+ * 在家目录及其子目录内(见 tool-router.ts 的 ToolExecContext.projectRoot);
8
+ * 源文件夹及其子目录同样可写。
9
9
  *
10
10
  * 持久化在 userData/agent-projects.json。存储通过 {@link ProjectKvStore}
11
11
  * 接口注入:main.ts 用 electron-store 实现,单测用内存实现——本模块不
@@ -18,8 +18,8 @@ export interface ProjectRecord {
18
18
  /** 托管家目录(绝对路径)。默认 `Documents/<应用名>/<项目名>/`。 */
19
19
  folderPath: string;
20
20
  /**
21
- * 附加源文件夹(已有代码目录)。只放宽读取,不替代家目录,也不放宽写入。
22
- * 旧记录没有此字段,读取时按空列表处理。
21
+ * 附加源文件夹(已有代码目录)。与家目录一样可读写,范围含各自子目录。
22
+ * 不替代家目录。旧记录没有此字段,读取时按空列表处理。
23
23
  */
24
24
  sourceFolders?: string[];
25
25
  /**
@@ -4,8 +4,8 @@
4
4
  * 项目 = 名字 + 托管家目录 + 可选源文件夹。家目录默认建在
5
5
  * `Documents/<应用名>/<项目名>/`(见 project-home.ts)。chat 通过
6
6
  * `chat_sessions.project_id` 绑定项目;绑定后文件读写与命令执行被硬限制
7
- * 在家目录内(见 tool-router.ts 的 ToolExecContext.projectRoot);源文件夹
8
- * 只放宽 local_read_file。
7
+ * 在家目录及其子目录内(见 tool-router.ts 的 ToolExecContext.projectRoot);
8
+ * 源文件夹及其子目录同样可写。
9
9
  *
10
10
  * 持久化在 userData/agent-projects.json。存储通过 {@link ProjectKvStore}
11
11
  * 接口注入:main.ts 用 electron-store 实现,单测用内存实现——本模块不
@@ -9,10 +9,13 @@ export interface HostSidecarDeps {
9
9
  /** W4-2 围栏:chatId → 项目家目录或无项目对话工作区。 */
10
10
  resolveProjectRoot: (chatId: string) => Promise<string | null>;
11
11
  /**
12
- * 项目模式之外额外放行的只读根(会话附件目录等)。文件写入仍只受
13
- * projectRoot 围栏约束;这些根只放宽 local_read_file 的读取范围。
12
+ * 额外只读根(会话附件目录、源文件夹)。只放宽 local_read_file,含子目录。
14
13
  */
15
14
  resolveAdditionalReadRoots?: (chatId: string) => string[] | Promise<string[]>;
15
+ /**
16
+ * 额外可写根(项目源文件夹)。放宽写入、编辑和命令 cwd,含各自子目录。
17
+ */
18
+ resolveAdditionalWriteRoots?: (chatId: string) => string[] | Promise<string[]>;
16
19
  /** W4-1 审批反向通道处理器(宿主审批弹窗的应答入口)。 */
17
20
  approvalHandler: ReturnType<typeof createApprovalBridge>['handler'];
18
21
  /**
@@ -214,6 +214,7 @@ export async function startHostSidecar(deps) {
214
214
  // root so ToolRouter's cwd/path confinement applies.
215
215
  resolveProjectRoot: deps.resolveProjectRoot,
216
216
  resolveAdditionalReadRoots: deps.resolveAdditionalReadRoots,
217
+ resolveAdditionalWriteRoots: deps.resolveAdditionalWriteRoots,
217
218
  }));
218
219
  // W4-1: approval algebra reverse channel — the sidecar's
219
220
  // ApprovalExecutor(HostApprover) asks here; the renderer modal answers
@@ -29,6 +29,8 @@ export interface ApprovalPromptRequest {
29
29
  mode: string;
30
30
  category: string;
31
31
  round: number;
32
+ /** Chat that asked. The renderer only shows the card in that composer. */
33
+ chatId?: string;
32
34
  }
33
35
  export interface ApprovalBridgeDeps {
34
36
  /**
@@ -43,6 +43,7 @@ export function createApprovalBridge(deps) {
43
43
  return deny('no renderer window available to approve the call');
44
44
  }
45
45
  const requestId = randomUUID();
46
+ const chatId = typeof p.chatId === 'string' && p.chatId ? p.chatId : undefined;
46
47
  const prompt = {
47
48
  requestId,
48
49
  toolName,
@@ -52,6 +53,7 @@ export function createApprovalBridge(deps) {
52
53
  mode: typeof p.mode === 'string' ? p.mode : 'other',
53
54
  category: typeof p.category === 'string' ? p.category : toolName,
54
55
  round: typeof p.round === 'number' ? p.round : 0,
56
+ ...(chatId ? { chatId } : {}),
55
57
  };
56
58
  deps.onLog?.(`approval: prompting for ${toolName} (${prompt.mode}/${prompt.category})`);
57
59
  return await new Promise((resolve) => {
@@ -21,9 +21,13 @@ export interface ReverseToolDeps {
21
21
  */
22
22
  resolveProjectRoot?: (chatId: string) => Promise<string | null>;
23
23
  /**
24
- * 额外放行的只读根(会话附件目录等)。写入仍只受 projectRoot 围栏约束,
25
- * 这里只放宽 local_read_file 的读取范围。
24
+ * 额外只读根(会话附件目录、源文件夹)。只放宽读取,含各自子目录。
26
25
  */
27
26
  resolveAdditionalReadRoots?: (chatId: string) => string[] | Promise<string[]>;
27
+ /**
28
+ * 额外可写根(项目源文件夹)。放宽写入、编辑和命令 cwd,含各自子目录。
29
+ * 附件目录不进这里。
30
+ */
31
+ resolveAdditionalWriteRoots?: (chatId: string) => string[] | Promise<string[]>;
28
32
  }
29
33
  export declare function createToolInvokeHandler(deps: ReverseToolDeps): SidecarReverseHandler;
@@ -74,6 +74,9 @@ export function createToolInvokeHandler(deps) {
74
74
  const additionalReadRoots = p.context?.chatId
75
75
  ? await Promise.resolve(deps.resolveAdditionalReadRoots?.(p.context.chatId) ?? [])
76
76
  : [];
77
+ const additionalWriteRoots = p.context?.chatId
78
+ ? await Promise.resolve(deps.resolveAdditionalWriteRoots?.(p.context.chatId) ?? [])
79
+ : [];
77
80
  if (name === 'local_exec_shell') {
78
81
  const classification = classifyShellCommand(String(toolArgs.command ?? ''));
79
82
  if (classification.severity === 'critical') {
@@ -94,7 +97,11 @@ export function createToolInvokeHandler(deps) {
94
97
  ? normalizeToolPolicy(p.context.toolPolicy)
95
98
  : null;
96
99
  const toolPolicy = normalizedPolicy && normalizedPolicy.mode !== 'all' ? normalizedPolicy : null;
97
- const execContext = projectRoot || additionalReadRoots.length > 0 || p.context?.chatId || toolPolicy
100
+ const execContext = projectRoot ||
101
+ additionalReadRoots.length > 0 ||
102
+ additionalWriteRoots.length > 0 ||
103
+ p.context?.chatId ||
104
+ toolPolicy
98
105
  ? {
99
106
  projectRoot,
100
107
  chatId: p.context?.chatId,
@@ -102,6 +109,7 @@ export function createToolInvokeHandler(deps) {
102
109
  // worktree_* 工具按它把任务绑定到来源对话。
103
110
  ...(toolPolicy ? { toolPolicy } : {}),
104
111
  ...(additionalReadRoots.length > 0 ? { additionalReadRoots } : {}),
112
+ ...(additionalWriteRoots.length > 0 ? { additionalWriteRoots } : {}),
105
113
  }
106
114
  : undefined;
107
115
  try {
@@ -333,12 +333,15 @@ export interface SidecarChatStreamRequest {
333
333
  * executor with the six-tool orchestration family (agent_spawn /
334
334
  * agent_send / agent_wait / agent_close / agent_list / agent_interrupt).
335
335
  * When delegation is also on (it is by default), both surfaces share one
336
- * agent pool — one maxParallel budget, one lineage space. */
336
+ * agent pool — one maxParallel budget, one lineage space.
337
+ * `childMaxRounds` / `childMaxToolErrors` default to the parent loop's
338
+ * limits. */
337
339
  orchestration?: {
338
340
  enabled?: boolean;
339
341
  maxDepth?: number;
340
342
  maxParallel?: number;
341
343
  childMaxRounds?: number;
344
+ childMaxToolErrors?: number;
342
345
  };
343
346
  /**
344
347
  * Sub-agent delegation (delegate-on-pool). ON BY DEFAULT on the sidecar —
@@ -451,6 +454,12 @@ export interface SidecarChatStreamRequest {
451
454
  mode: 'host' | 'auto';
452
455
  timeoutMs?: number;
453
456
  storePath?: string;
457
+ /**
458
+ * Project home, source folders, and other configured write roots.
459
+ * File tools whose targets all sit inside these directories skip the
460
+ * host prompt. Independent of whether the OS exec sandbox is on.
461
+ */
462
+ writableRoots?: string[];
454
463
  };
455
464
  }
456
465
  /**
@@ -6,9 +6,12 @@ export function acquireWriteLeaseOrExit(acquire, loadElectron = () => import('el
6
6
  }
7
7
  catch (error) {
8
8
  if (error instanceof StoreAlreadyOwnedError) {
9
+ console.error(`[bs] 本地数据库正在被另一个进程使用(${error.lockPath})。\n` +
10
+ '[bs] 再打开一次桌面应用会回到已经打开的窗口。\n' +
11
+ '[bs] 如果终端里的 pnpm dev:bs 占着数据库,请先在那个终端按 Ctrl+C 停掉,再重新打开。');
9
12
  void loadElectron()
10
13
  .then(({ app, dialog }) => {
11
- dialog.showErrorBox('无法启动', '另一个实例正在运行并占用本地数据库。请先关闭该实例,再重新打开应用。');
14
+ dialog.showErrorBox('无法再开一个', '再打开一次应用时,会回到已经打开的窗口。\n\n这次没有回到那个窗口,是因为本地数据库正被另一个进程占用。如果终端里还在跑 pnpm dev:bs,先停掉它,再重新打开。');
12
15
  app.exit(1);
13
16
  })
14
17
  .catch(() => exitProcess(1));
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 一等工具的展示声明。
3
+ *
4
+ * 卡片种类和标题从这里按工具名取,不从名字前缀猜测。
5
+ * 未声明的名字(MCP、插件、场景包)落到 generic / other,`declared: false`。
6
+ * 宿主在 executed_actions 上盖 `view`,卡片读这份声明。
7
+ */
8
+ export declare const TOOL_CARDS: readonly ["generic", "terminal", "diff", "search", "read", "web"];
9
+ export type ToolCard = (typeof TOOL_CARDS)[number];
10
+ export declare const TOOL_KINDS: readonly ["read", "edit", "delete", "move", "search", "execute", "fetch", "other"];
11
+ export type ToolKind = (typeof TOOL_KINDS)[number];
12
+ export interface ToolCallView {
13
+ card: ToolCard;
14
+ kind: ToolKind;
15
+ title: string;
16
+ /** 名字在声明表里。false 表示通用回退,不是从名字前缀推断出来的。 */
17
+ declared: boolean;
18
+ }
19
+ /**
20
+ * 取一次调用的展示。`result` 缺省时只根据参数出标题;结果到达后用同一声明补计数和状态。
21
+ */
22
+ export declare function presentToolCall(name: string, args: unknown, result?: unknown): ToolCallView;
23
+ export declare function declaredToolNames(): readonly string[];
@@ -0,0 +1,266 @@
1
+ /**
2
+ * 一等工具的展示声明。
3
+ *
4
+ * 卡片种类和标题从这里按工具名取,不从名字前缀猜测。
5
+ * 未声明的名字(MCP、插件、场景包)落到 generic / other,`declared: false`。
6
+ * 宿主在 executed_actions 上盖 `view`,卡片读这份声明。
7
+ */
8
+ export const TOOL_CARDS = ['generic', 'terminal', 'diff', 'search', 'read', 'web'];
9
+ export const TOOL_KINDS = [
10
+ 'read',
11
+ 'edit',
12
+ 'delete',
13
+ 'move',
14
+ 'search',
15
+ 'execute',
16
+ 'fetch',
17
+ 'other',
18
+ ];
19
+ function clip(text, max = 60) {
20
+ const trimmed = text.replace(/\s+/g, ' ').trim();
21
+ if (!trimmed)
22
+ return '';
23
+ return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed;
24
+ }
25
+ function str(args, key) {
26
+ const value = args[key];
27
+ return typeof value === 'string' ? value : '';
28
+ }
29
+ function dataOf(result) {
30
+ if (!result || typeof result !== 'object')
31
+ return null;
32
+ const data = result.data;
33
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
34
+ return data;
35
+ }
36
+ return result;
37
+ }
38
+ function failed(result) {
39
+ return Boolean(result && typeof result === 'object' && result.success === false);
40
+ }
41
+ function commandTitle(args) {
42
+ return clip(str(args, 'command')) || 'shell';
43
+ }
44
+ function pathTitle(args, fallback) {
45
+ return clip(str(args, 'path') || str(args, 'file') || str(args, 'target'), 72) || fallback;
46
+ }
47
+ function webSearchTitle(args, result) {
48
+ const query = str(args, 'query');
49
+ const head = `搜索“${clip(query, 40) || '…'}”`;
50
+ if (result === undefined || failed(result))
51
+ return head;
52
+ const data = dataOf(result);
53
+ const count = data && typeof data.result_count === 'number' ? data.result_count : null;
54
+ return count === null ? head : `${head} → ${count} 条结果`;
55
+ }
56
+ function shortUrl(raw) {
57
+ if (!raw)
58
+ return '未知地址';
59
+ try {
60
+ const url = new URL(raw);
61
+ const path = url.pathname === '/' ? '' : url.pathname;
62
+ return clip(`${url.host}${path}`, 48);
63
+ }
64
+ catch {
65
+ return clip(raw, 48);
66
+ }
67
+ }
68
+ function webFetchTitle(args, result) {
69
+ const head = `抓取 ${shortUrl(str(args, 'url'))}`;
70
+ if (result === undefined || failed(result))
71
+ return head;
72
+ const data = dataOf(result);
73
+ if (!data)
74
+ return head;
75
+ const parts = [];
76
+ if (typeof data.status === 'number')
77
+ parts.push(String(data.status));
78
+ if (typeof data.bytes === 'number' && Number.isFinite(data.bytes)) {
79
+ parts.push(data.bytes < 1024 ? `${data.bytes} B` : `${(data.bytes / 1024).toFixed(1)} KB`);
80
+ }
81
+ if (data.truncated === true)
82
+ parts.push('已截断');
83
+ return parts.length > 0 ? `${head} → ${parts.join(' · ')}` : head;
84
+ }
85
+ function taskRunTitle(args, result) {
86
+ const task = clip(str(args, 'task'));
87
+ if (!task)
88
+ return '后台任务';
89
+ const status = dataOf(result)?.status;
90
+ if (status === 'blocked')
91
+ return `${task} · 等待依赖`;
92
+ if (args.worktree === true)
93
+ return `${task} · 隔离工作区`;
94
+ return task;
95
+ }
96
+ function jobIdTitle(args, fallback) {
97
+ const id = str(args, 'job_id') || str(args, 'taskId');
98
+ return id ? id.slice(0, 8) : fallback;
99
+ }
100
+ function runCodeTitle(args, result) {
101
+ const description = clip(str(args, 'description'), 40) || '程序';
102
+ if (failed(result))
103
+ return `程序「${description}」失败`;
104
+ const calls = dataOf(result)?.calls;
105
+ const count = Array.isArray(calls) ? calls.length : 0;
106
+ return count > 0 ? `程序「${description}」· ${count} 个内层工具` : `程序「${description}」`;
107
+ }
108
+ function goalTitle(args, fallback) {
109
+ return clip(str(args, 'objective'), 72) || fallback;
110
+ }
111
+ const PRESENTERS = {
112
+ local_exec_shell: { card: 'terminal', kind: 'execute', title: commandTitle },
113
+ pwsh: { card: 'terminal', kind: 'execute', title: commandTitle },
114
+ bash: { card: 'terminal', kind: 'execute', title: commandTitle },
115
+ bash_session: { card: 'terminal', kind: 'execute', title: commandTitle },
116
+ write_stdin: {
117
+ card: 'terminal',
118
+ kind: 'execute',
119
+ title: (args) => clip(str(args, 'chars'), 40) || str(args, 'sessionId').slice(0, 8) || 'stdin',
120
+ },
121
+ local_run_snippet: {
122
+ card: 'terminal',
123
+ kind: 'execute',
124
+ title: (args) => clip(str(args, 'code'), 48) || str(args, 'language') || 'snippet',
125
+ },
126
+ local_run_script: {
127
+ card: 'terminal',
128
+ kind: 'execute',
129
+ title: (args) => str(args, 'scriptId') || 'script',
130
+ },
131
+ run_code: { card: 'generic', kind: 'execute', title: runCodeTitle },
132
+ run_js: { card: 'generic', kind: 'execute', title: runCodeTitle },
133
+ wait_js: {
134
+ card: 'generic',
135
+ kind: 'execute',
136
+ title: (args) => str(args, 'id').slice(0, 8) || 'wait_js',
137
+ },
138
+ local_read_file: { card: 'read', kind: 'read', title: (args) => pathTitle(args, 'read') },
139
+ read_file: { card: 'read', kind: 'read', title: (args) => pathTitle(args, 'read') },
140
+ view_image: { card: 'read', kind: 'read', title: (args) => pathTitle(args, 'image') },
141
+ capture_display: {
142
+ card: 'read',
143
+ kind: 'read',
144
+ title: (args) => clip(str(args, 'target'), 48) || 'display',
145
+ },
146
+ local_list_scripts: { card: 'read', kind: 'read', title: () => '已保存的脚本' },
147
+ local_write_file: { card: 'diff', kind: 'edit', title: (args) => pathTitle(args, 'write') },
148
+ write_file: { card: 'diff', kind: 'edit', title: (args) => pathTitle(args, 'write') },
149
+ local_edit_file: { card: 'diff', kind: 'edit', title: (args) => pathTitle(args, 'edit') },
150
+ edit_file: { card: 'diff', kind: 'edit', title: (args) => pathTitle(args, 'edit') },
151
+ apply_patch: { card: 'diff', kind: 'edit', title: () => 'apply_patch' },
152
+ grep: {
153
+ card: 'search',
154
+ kind: 'search',
155
+ title: (args) => clip(str(args, 'pattern')) || 'grep',
156
+ },
157
+ glob: {
158
+ card: 'search',
159
+ kind: 'search',
160
+ title: (args) => clip(str(args, 'pattern')) || 'glob',
161
+ },
162
+ tool_search: {
163
+ card: 'search',
164
+ kind: 'search',
165
+ title: (args) => clip(str(args, 'query')) || 'tool_search',
166
+ },
167
+ web_search: { card: 'web', kind: 'search', title: webSearchTitle },
168
+ web_fetch: { card: 'web', kind: 'fetch', title: webFetchTitle },
169
+ task_run: { card: 'generic', kind: 'other', title: taskRunTitle },
170
+ task_send: {
171
+ card: 'generic',
172
+ kind: 'other',
173
+ title: (args) => clip(str(args, 'message')) || '转达任务',
174
+ },
175
+ task_status: {
176
+ card: 'generic',
177
+ kind: 'read',
178
+ title: (args, result) => {
179
+ const id = str(args, 'taskId');
180
+ if (id)
181
+ return id.slice(0, 8);
182
+ const total = dataOf(result)?.total;
183
+ return typeof total === 'number' ? `${total} 个任务` : '本会话';
184
+ },
185
+ },
186
+ task_result: {
187
+ card: 'generic',
188
+ kind: 'read',
189
+ title: (args) => jobIdTitle(args, '收取结果'),
190
+ },
191
+ job_list: {
192
+ card: 'generic',
193
+ kind: 'read',
194
+ title: (_args, result) => {
195
+ const total = dataOf(result)?.total;
196
+ return typeof total === 'number' ? `${total} 个任务` : '后台任务';
197
+ },
198
+ },
199
+ job_output: { card: 'generic', kind: 'read', title: (args) => jobIdTitle(args, '任务输出') },
200
+ job_kill: { card: 'generic', kind: 'delete', title: (args) => jobIdTitle(args, '结束任务') },
201
+ worktree_create: {
202
+ card: 'generic',
203
+ kind: 'other',
204
+ title: (args) => str(args, 'name') || '新建工作区',
205
+ },
206
+ worktree_list: { card: 'read', kind: 'read', title: () => '工作区列表' },
207
+ worktree_remove: {
208
+ card: 'generic',
209
+ kind: 'delete',
210
+ title: (args) => str(args, 'name') || '移除工作区',
211
+ },
212
+ get_goal: { card: 'generic', kind: 'read', title: () => '当前目标' },
213
+ create_goal: { card: 'generic', kind: 'other', title: (args) => goalTitle(args, '新建目标') },
214
+ update_goal: {
215
+ card: 'generic',
216
+ kind: 'other',
217
+ title: (args) => {
218
+ const action = str(args, 'action');
219
+ const objective = clip(str(args, 'objective'), 48);
220
+ return objective ? `${action || 'update'} ${objective}` : action || '更新目标';
221
+ },
222
+ },
223
+ present_files: { card: 'generic', kind: 'other', title: () => '交付文件' },
224
+ local_open_path: {
225
+ card: 'generic',
226
+ kind: 'other',
227
+ title: (args) => pathTitle(args, 'open'),
228
+ },
229
+ todo_write: { card: 'generic', kind: 'other', title: () => '待办' },
230
+ ask_user: {
231
+ card: 'generic',
232
+ kind: 'other',
233
+ title: (args) => clip(str(args, 'intro') || str(args, 'question')) || '提问',
234
+ },
235
+ skill: { card: 'generic', kind: 'read', title: (args) => str(args, 'name') || 'skill' },
236
+ delegate_subagent: {
237
+ card: 'generic',
238
+ kind: 'other',
239
+ title: (args) => clip(str(args, 'task')) || str(args, 'subagent_type') || '子代理',
240
+ },
241
+ mcp_list_tools: { card: 'generic', kind: 'fetch', title: () => 'MCP 工具' },
242
+ mcp_tool_exec: {
243
+ card: 'generic',
244
+ kind: 'fetch',
245
+ title: (args) => str(args, 'toolName') || 'MCP',
246
+ },
247
+ plugin_list: { card: 'generic', kind: 'read', title: () => '插件' },
248
+ plugin_enable: { card: 'generic', kind: 'other', title: (args) => str(args, 'name') || '启用插件' },
249
+ plugin_disable: { card: 'generic', kind: 'delete', title: (args) => str(args, 'name') || '停用插件' },
250
+ plugin_reload: { card: 'generic', kind: 'other', title: (args) => str(args, 'name') || '重载插件' },
251
+ };
252
+ /**
253
+ * 取一次调用的展示。`result` 缺省时只根据参数出标题;结果到达后用同一声明补计数和状态。
254
+ */
255
+ export function presentToolCall(name, args, result) {
256
+ const record = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
257
+ const presenter = PRESENTERS[name];
258
+ if (!presenter) {
259
+ return { card: 'generic', kind: 'other', title: name, declared: false };
260
+ }
261
+ const title = presenter.title(record, result).trim() || name;
262
+ return { card: presenter.card, kind: presenter.kind, title, declared: true };
263
+ }
264
+ export function declaredToolNames() {
265
+ return Object.keys(PRESENTERS);
266
+ }