@steerable/agent-shell 0.6.51 → 0.6.53

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.
Files changed (43) hide show
  1. package/dist/brand.d.ts +5 -0
  2. package/dist/goal-store.d.ts +43 -0
  3. package/dist/goal-store.js +155 -0
  4. package/dist/host/runtime.js +15 -3
  5. package/dist/host-tools.d.ts +1 -1
  6. package/dist/host-tools.js +6 -0
  7. package/dist/local-backend/portable-bundle.d.ts +16 -2
  8. package/dist/local-backend/portable-bundle.js +21 -9
  9. package/dist/local-backend/portable-service.js +25 -11
  10. package/dist/local-backend/router.js +71 -27
  11. package/dist/local-backend/subagent-profiles.js +4 -0
  12. package/dist/local-backend/task-service.d.ts +6 -0
  13. package/dist/local-backend/task-service.js +47 -2
  14. package/dist/local-executor.d.ts +36 -2
  15. package/dist/local-executor.js +171 -19
  16. package/dist/mcp-executor.d.ts +42 -10
  17. package/dist/mcp-executor.js +248 -41
  18. package/dist/mcp-server-registry.d.ts +56 -8
  19. package/dist/mcp-server-registry.js +191 -26
  20. package/dist/product-config.d.ts +14 -0
  21. package/dist/project-home.d.ts +1 -1
  22. package/dist/project-home.js +1 -1
  23. package/dist/project-registry.d.ts +4 -4
  24. package/dist/project-registry.js +2 -2
  25. package/dist/shell-exec.d.ts +62 -0
  26. package/dist/shell-exec.js +147 -0
  27. package/dist/shell-process.d.ts +3 -0
  28. package/dist/shell-process.js +137 -0
  29. package/dist/sidecar/boot.d.ts +5 -2
  30. package/dist/sidecar/boot.js +5 -0
  31. package/dist/sidecar/reverse-approval.d.ts +2 -0
  32. package/dist/sidecar/reverse-approval.js +2 -0
  33. package/dist/sidecar/reverse-tools.d.ts +6 -2
  34. package/dist/sidecar/reverse-tools.js +9 -1
  35. package/dist/sidecar/types.d.ts +10 -1
  36. package/dist/storage/write-lease-error.js +4 -1
  37. package/dist/tool-presentation.d.ts +23 -0
  38. package/dist/tool-presentation.js +266 -0
  39. package/dist/tool-router.d.ts +26 -8
  40. package/dist/tool-router.js +275 -24
  41. package/dist/workspace-search.d.ts +35 -0
  42. package/dist/workspace-search.js +186 -0
  43. package/package.json +5 -5
@@ -7,6 +7,7 @@ import type { WorktreeService } from './local-backend/worktree-service.js';
7
7
  import type { ToolContribution, ToolMode, ToolExposure } from './scenario/pack.js';
8
8
  export type { ToolMode, ToolExposure } from './scenario/pack.js';
9
9
  import { type AgentToolPolicy } from './local-backend/agent-capability.js';
10
+ import { GoalStore } from './goal-store.js';
10
11
  /** 已注册 MCP 服务的动态工具名前缀:`mcp__<serverKey>__<toolName>`。 */
11
12
  export declare const MCP_DYNAMIC_TOOL_PREFIX = "mcp__";
12
13
  /**
@@ -27,17 +28,23 @@ export interface ToolCall {
27
28
  * 单次工具执行的调用上下文(按 chat 解析,随每次 execute 传入)。
28
29
  *
29
30
  * `projectRoot` 是项目模式的硬沙箱根:非空时
30
- * - local_read_file / local_write_file 的路径必须落在根内(LocalExecutor 围栏)
31
- * - local_exec_shell 的 cwd 默认改为项目根;显式给的 cwd 越界则直接拒绝
31
+ * - 读路径必须落在根内,或落在 additionalReadRoots 内(含各自子目录)
32
+ * - 写路径必须落在根内,或落在 additionalWriteRoots 内(含各自子目录)
33
+ * - local_exec_shell 的 cwd 默认改为项目根;显式 cwd 必须落在上述可写根内
32
34
  * 无项目对话(null/缺省)行为与之前完全一致。
33
35
  */
34
36
  export interface ToolExecContext {
35
37
  projectRoot?: string | null;
36
38
  /**
37
- * 项目模式之外额外放行的只读根(会话附件目录等)。只放宽
38
- * local_read_file 的读取范围,写入/编辑仍只受 projectRoot 围栏约束。
39
+ * 额外只读根(会话附件目录、源文件夹)。只放宽 local_read_file。
40
+ * 每个根包含其全部子目录。
39
41
  */
40
42
  additionalReadRoots?: string[] | null;
43
+ /**
44
+ * 额外可写根(项目源文件夹)。放宽写入、编辑和命令 cwd。
45
+ * 每个根包含其全部子目录。附件目录不在这里。
46
+ */
47
+ additionalWriteRoots?: string[] | null;
41
48
  /**
42
49
  * 4.6a:调用发生的 chat。task_run / task_status / task_result 需要它把
43
50
  * 任务绑定到来源对话(任务表按 chatId 归组);reverse-tools 从
@@ -141,6 +148,10 @@ export declare class ToolRouter {
141
148
  taskService: TaskService;
142
149
  worktreeService: WorktreeService;
143
150
  } | null): void;
151
+ /** 会话目标的持久化。缺省写到用户数据目录;测试注入临时文件。 */
152
+ private goalStore;
153
+ setGoalStore(store: GoalStore | null): void;
154
+ private goals;
144
155
  setWebTools(delegate: WebToolDelegate | null, names?: readonly string[]): void;
145
156
  setHostedWebSearch(handler: ((args: Record<string, unknown>) => Promise<unknown>) | null): void;
146
157
  /**
@@ -168,6 +179,10 @@ export declare class ToolRouter {
168
179
  * destructive,走宿主审批链。
169
180
  */
170
181
  private listPluginToolSchemas;
182
+ /**
183
+ * 内容搜索、PowerShell、会话目标。后台任务的 job_* 跟 task 服务一起出场。
184
+ */
185
+ private listParityToolSchemas;
171
186
  /**
172
187
  * 4.6a/4.6b 任务与 worktree 工具的 schema。task_run 立即返回
173
188
  * (任务在后台独立流里跨 turn 跑完);worktree_* 操作绑定项目的
@@ -204,6 +219,8 @@ export declare class ToolRouter {
204
219
  * 项目围栏按 chat 解析)。schema 出场 ⇒ 服务已接线,这里只缺 chatId 时
205
220
  * 抛错(reverse-tools 总是透传;缺了就是接线 bug,响亮失败)。
206
221
  */
222
+ private executeGoalTool;
223
+ private executeJobTool;
207
224
  private requireTaskContext;
208
225
  /**
209
226
  * Agent 写出 `…/skills/<name>/SKILL.md` 且该目录还不在已知 skill root 上时,
@@ -211,12 +228,13 @@ export declare class ToolRouter {
211
228
  */
212
229
  private autoInstallSkillOnSuccess;
213
230
  /**
214
- * 项目模式硬沙箱(cwd 维度):projectRoot 非空时,cwd 默认收到项目根;
215
- * 模型显式给的 cwd 越界则返回错误结果。相对 cwd 按项目根解析(而不是
216
- * 进程 cwd),符合"在项目里干活"的直觉。
231
+ * 项目模式硬沙箱(cwd 维度):有可写根时,cwd 默认收到项目家目录;
232
+ * 模型显式给的 cwd 必须落在家目录或额外可写根(源文件夹)内,含各自子目录。
233
+ * 相对 cwd 按家目录解析(而不是进程 cwd)。
217
234
  *
218
235
  * 注意边界:shell 命令文本里内嵌的绝对路径无法可靠解析,围栏只覆盖
219
- * cwd 与文件读写工具——系统提示里已告知模型只在项目目录内操作。
236
+ * cwd 与文件读写工具。命令实际写盘还受 exec 沙箱的 writableRoots 约束,
237
+ * 那些根同样包含子目录。
220
238
  *
221
239
  * local_exec_shell 与 local_run_snippet 共用本 helper。
222
240
  */
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
- import { buildProjectRootViolation, isPathWithinRoot, } from './local-executor.js';
3
+ import { buildProjectRootViolation, expandFencePath, isPathWithinRoot, writableFenceRoots, writableFenceViolation, } from './local-executor.js';
4
4
  import { mcpExecutor } from './mcp-executor.js';
5
5
  import { rankTools, resolveMaxResults, TOOL_SEARCH_DEFAULT_MAX_RESULTS, } from './tool-search-rank.js';
6
6
  import { maybeAutoInstallWrittenSkill } from './local-backend/skill-install.js';
@@ -9,6 +9,8 @@ import { processViewImage } from './image-attachment.js';
9
9
  import { PRESENT_FILES_SCHEMA, PRESENT_FILES_TOOL_NAME, executePresentFiles, } from './present-files.js';
10
10
  import { isHostToolCapabilityEnabled } from './host-tools.js';
11
11
  import { getResolvedHostTools } from './host-tools-runtime.js';
12
+ import { GoalStore, GOAL_ACTIONS } from './goal-store.js';
13
+ import { searchGlob, searchGrep } from './workspace-search.js';
12
14
  /** 已注册 MCP 服务的动态工具名前缀:`mcp__<serverKey>__<toolName>`。 */
13
15
  export const MCP_DYNAMIC_TOOL_PREFIX = 'mcp__';
14
16
  /**
@@ -97,6 +99,16 @@ export class ToolRouter {
97
99
  this.taskService = services?.taskService ?? null;
98
100
  this.worktreeService = services?.worktreeService ?? null;
99
101
  }
102
+ /** 会话目标的持久化。缺省写到用户数据目录;测试注入临时文件。 */
103
+ goalStore = null;
104
+ setGoalStore(store) {
105
+ this.goalStore = store;
106
+ }
107
+ goals() {
108
+ if (!this.goalStore)
109
+ this.goalStore = GoalStore.default();
110
+ return this.goalStore;
111
+ }
100
112
  setWebTools(delegate, names = []) {
101
113
  this.webToolDelegate = delegate;
102
114
  this.webToolNames = new Set(names);
@@ -160,7 +172,8 @@ export class ToolRouter {
160
172
  return [
161
173
  {
162
174
  name: 'local_exec_shell',
163
- description: 'Execute a local shell command',
175
+ description: 'Execute a shell command in a pseudo-terminal. Set pty to false for a plain pipe. ' +
176
+ 'When this host is configured with a remote endpoint, the command runs on that host instead of this machine.',
164
177
  mode: 'destructive',
165
178
  inputSchema: {
166
179
  type: 'object',
@@ -168,6 +181,7 @@ export class ToolRouter {
168
181
  command: { type: 'string' },
169
182
  cwd: { type: 'string' },
170
183
  timeout: { type: 'number', description: 'Timeout in milliseconds (e.g. 30000). If a small number like 15 or 30 is provided, it is assumed to be in seconds and automatically multiplied by 1000.' },
184
+ pty: { type: 'boolean', description: 'Run in a pseudo-terminal. Defaults to true.' },
171
185
  },
172
186
  required: ['command'],
173
187
  },
@@ -365,32 +379,35 @@ export class ToolRouter {
365
379
  },
366
380
  {
367
381
  name: 'mcp_list_tools',
368
- description: 'List tools from one MCP server',
382
+ description: 'List tools from one MCP server. Pass command for stdio or url for unauthenticated Streamable HTTP.',
369
383
  mode: 'external',
370
384
  inputSchema: {
371
385
  type: 'object',
372
386
  properties: {
373
387
  command: { type: 'string' },
388
+ url: { type: 'string' },
374
389
  args: { type: 'array', items: { type: 'string' } },
375
390
  cwd: { type: 'string' },
376
391
  },
377
- required: ['command'],
392
+ additionalProperties: false,
378
393
  },
379
394
  },
380
395
  {
381
396
  name: 'mcp_tool_exec',
382
- description: 'Execute one MCP tool',
397
+ description: 'Execute one MCP tool. Pass command for stdio or url for unauthenticated Streamable HTTP.',
383
398
  mode: 'external',
384
399
  inputSchema: {
385
400
  type: 'object',
386
401
  properties: {
387
402
  command: { type: 'string' },
403
+ url: { type: 'string' },
388
404
  args: { type: 'array', items: { type: 'string' } },
389
405
  cwd: { type: 'string' },
390
406
  toolName: { type: 'string' },
391
407
  toolArgs: { type: 'object' },
392
408
  },
393
- required: ['command', 'toolName'],
409
+ required: ['toolName'],
410
+ additionalProperties: false,
394
411
  },
395
412
  },
396
413
  // deferred 层的发现缝(Wave 2 工具分层):MCP 动态工具不占每轮
@@ -415,6 +432,7 @@ export class ToolRouter {
415
432
  additionalProperties: false,
416
433
  },
417
434
  },
435
+ ...this.listParityToolSchemas(),
418
436
  // ─── 4.6a/4.6b 跨 turn 后台任务 + git worktree 隔离 ─────────────
419
437
  // 服务未接线(CLI/test)时一个 schema 都不出——模型看不到不可用的工具。
420
438
  ...this.listTaskAndWorktreeSchemas(),
@@ -482,6 +500,105 @@ export class ToolRouter {
482
500
  },
483
501
  ];
484
502
  }
503
+ /**
504
+ * 内容搜索、PowerShell、会话目标。后台任务的 job_* 跟 task 服务一起出场。
505
+ */
506
+ listParityToolSchemas() {
507
+ return [
508
+ {
509
+ name: 'grep',
510
+ description: 'Search file contents under the project (or an optional subdirectory). '
511
+ + 'pattern is a JavaScript regular expression. Optional glob limits which relative paths are scanned. '
512
+ + 'Skips dependency and VCS directories. Returns {path, line, text} hits. Prefer this over shell rg/grep.',
513
+ mode: 'read',
514
+ inputSchema: {
515
+ type: 'object',
516
+ properties: {
517
+ pattern: { type: 'string', description: 'JavaScript regular expression matched per line.' },
518
+ path: { type: 'string', description: 'Directory to search. Defaults to the project root.' },
519
+ glob: { type: 'string', description: 'Optional fnmatch, e.g. "*.ts" or "src/**/*.py".' },
520
+ },
521
+ required: ['pattern'],
522
+ additionalProperties: false,
523
+ },
524
+ },
525
+ {
526
+ name: 'glob',
527
+ description: 'List project files whose relative path matches a glob. A pattern without "/" matches any directory. '
528
+ + 'Prefer this over shell find or ls.',
529
+ mode: 'read',
530
+ inputSchema: {
531
+ type: 'object',
532
+ properties: {
533
+ pattern: { type: 'string', description: 'Glob, e.g. "*.ts" or "src/**/*.py".' },
534
+ path: { type: 'string', description: 'Directory to search. Defaults to the project root.' },
535
+ },
536
+ required: ['pattern'],
537
+ additionalProperties: false,
538
+ },
539
+ },
540
+ {
541
+ name: 'pwsh',
542
+ description: 'Run a PowerShell command. On Windows this is powershell.exe; elsewhere it runs pwsh '
543
+ + '(PowerShell 7) and fails clearly when that binary is not installed. '
544
+ + 'Use local_exec_shell for the platform default shell.',
545
+ mode: 'destructive',
546
+ inputSchema: {
547
+ type: 'object',
548
+ properties: {
549
+ command: { type: 'string' },
550
+ cwd: { type: 'string' },
551
+ timeout: {
552
+ type: 'number',
553
+ description: 'Timeout in milliseconds. A small number like 30 is treated as seconds.',
554
+ },
555
+ },
556
+ required: ['command'],
557
+ additionalProperties: false,
558
+ },
559
+ },
560
+ {
561
+ name: 'get_goal',
562
+ description: 'Read this chat\'s persisted goal, including the id and revision that update_goal requires. '
563
+ + 'goal is null when none exists.',
564
+ mode: 'read',
565
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
566
+ },
567
+ {
568
+ name: 'create_goal',
569
+ description: 'Create a persisted goal for this chat when the user\'s request is a long-running objective. '
570
+ + 'Not for single-turn work. Fails if an unfinished goal already exists — update that one instead.',
571
+ mode: 'safe_write',
572
+ inputSchema: {
573
+ type: 'object',
574
+ properties: {
575
+ objective: { type: 'string', description: 'What done looks like, as a result rather than an activity.' },
576
+ },
577
+ required: ['objective'],
578
+ additionalProperties: false,
579
+ },
580
+ },
581
+ {
582
+ name: 'update_goal',
583
+ description: 'Change the current goal. Pass the id and revision from get_goal. '
584
+ + 'action is edit (needs objective), pause, resume, complete, or blocked (needs reason). '
585
+ + 'A stale revision is rejected; re-read and retry.',
586
+ mode: 'safe_write',
587
+ inputSchema: {
588
+ type: 'object',
589
+ properties: {
590
+ id: { type: 'string' },
591
+ revision: { type: 'integer' },
592
+ action: { type: 'string', enum: [...GOAL_ACTIONS] },
593
+ objective: { type: 'string', description: 'Required for action=edit.' },
594
+ reason: { type: 'string', description: 'Required for action=blocked.' },
595
+ },
596
+ required: ['id', 'revision', 'action'],
597
+ additionalProperties: false,
598
+ },
599
+ },
600
+ ];
601
+ }
485
602
  /**
486
603
  * 4.6a/4.6b 任务与 worktree 工具的 schema。task_run 立即返回
487
604
  * (任务在后台独立流里跨 turn 跑完);worktree_* 操作绑定项目的
@@ -594,6 +711,46 @@ export class ToolRouter {
594
711
  additionalProperties: false,
595
712
  },
596
713
  },
714
+ {
715
+ name: 'job_list',
716
+ description: 'List this chat\'s background jobs (the same records task_run creates). '
717
+ + 'Each row includes job_id (the taskId), status, and the task text.',
718
+ mode: 'read',
719
+ inputSchema: {
720
+ type: 'object',
721
+ properties: {},
722
+ additionalProperties: false,
723
+ },
724
+ },
725
+ {
726
+ name: 'job_output',
727
+ description: 'Read the final answer of a background job. job_id is the taskId from task_run or job_list. '
728
+ + 'Fails with needsFollowup while the job is still running.',
729
+ mode: 'read',
730
+ inputSchema: {
731
+ type: 'object',
732
+ properties: {
733
+ job_id: { type: 'string', description: 'taskId of the background job.' },
734
+ },
735
+ required: ['job_id'],
736
+ additionalProperties: false,
737
+ },
738
+ },
739
+ {
740
+ name: 'job_kill',
741
+ description: 'Stop a running or blocked background job. A finished job cannot be killed. '
742
+ + 'job_id is the taskId from task_run or job_list.',
743
+ mode: 'destructive',
744
+ inputSchema: {
745
+ type: 'object',
746
+ properties: {
747
+ job_id: { type: 'string', description: 'taskId of the background job.' },
748
+ reason: { type: 'string', description: 'Optional note stored on the job.' },
749
+ },
750
+ required: ['job_id'],
751
+ additionalProperties: false,
752
+ },
753
+ },
597
754
  {
598
755
  name: 'worktree_remove',
599
756
  description: 'Remove a managed worktree and delete its branch — the DISCARD path, any unmerged changes are lost. '
@@ -738,7 +895,8 @@ export class ToolRouter {
738
895
  command: String(args.command || ''),
739
896
  cwd: typeof args.cwd === 'string' ? args.cwd : undefined,
740
897
  timeout: typeof args.timeout === 'number' ? args.timeout : undefined,
741
- }, projectRoot);
898
+ pty: args.pty !== false,
899
+ }, projectRoot, context?.additionalWriteRoots ?? null);
742
900
  case 'local_read_file':
743
901
  return await this.localExecutor.readLocalFile({
744
902
  path: String(args.path || ''),
@@ -776,7 +934,7 @@ export class ToolRouter {
776
934
  content: String(args.content || ''),
777
935
  createDirs: Boolean(args.createDirs),
778
936
  expectedVersion: typeof args.expectedVersion === 'string' ? args.expectedVersion : undefined,
779
- }, projectRoot);
937
+ }, projectRoot, context?.additionalWriteRoots ?? null);
780
938
  this.autoInstallSkillOnSuccess(written, args.path);
781
939
  return written;
782
940
  }
@@ -793,7 +951,7 @@ export class ToolRouter {
793
951
  path: String(args.path || ''),
794
952
  edits,
795
953
  expectedVersion: typeof args.expectedVersion === 'string' ? args.expectedVersion : undefined,
796
- }, projectRoot);
954
+ }, projectRoot, context?.additionalWriteRoots ?? null);
797
955
  this.autoInstallSkillOnSuccess(edited, args.path);
798
956
  return edited;
799
957
  }
@@ -805,7 +963,7 @@ export class ToolRouter {
805
963
  });
806
964
  case 'local_run_snippet': {
807
965
  // 与 local_exec_shell 同款项目沙箱:cwd 默认项目根,显式越界拒绝。
808
- const sandboxed = this.applyProjectCwdSandbox({ cwd: typeof args.cwd === 'string' ? args.cwd : undefined }, projectRoot);
966
+ const sandboxed = this.applyProjectCwdSandbox({ cwd: typeof args.cwd === 'string' ? args.cwd : undefined }, projectRoot, context?.additionalWriteRoots ?? null);
809
967
  if ('error' in sandboxed)
810
968
  return sandboxed.error;
811
969
  return await this.localExecutor.runCode({
@@ -859,6 +1017,47 @@ export class ToolRouter {
859
1017
  await this.refreshPluginTools();
860
1018
  return result;
861
1019
  }
1020
+ case 'grep':
1021
+ case 'glob': {
1022
+ const requested = typeof args.path === 'string' ? args.path.trim() : '';
1023
+ const start = requested
1024
+ ? this.resolveReadablePath(requested, projectRoot, context?.additionalReadRoots ?? null)
1025
+ : { path: projectRoot ? expandFencePath(projectRoot) : process.cwd() };
1026
+ if ('error' in start) {
1027
+ return { success: false, error: start.error, needsFollowup: true };
1028
+ }
1029
+ if (call.name === 'grep') {
1030
+ return searchGrep({
1031
+ root: start.path,
1032
+ pattern: String(args.pattern || ''),
1033
+ glob: typeof args.glob === 'string' ? args.glob : undefined,
1034
+ });
1035
+ }
1036
+ return searchGlob({ root: start.path, pattern: String(args.pattern || '') });
1037
+ }
1038
+ case 'pwsh': {
1039
+ const script = String(args.command || '');
1040
+ if (!script.trim()) {
1041
+ return { success: false, error: 'command is required', needsFollowup: true };
1042
+ }
1043
+ const command = process.platform === 'win32'
1044
+ ? script
1045
+ : `pwsh -NoProfile -NonInteractive -Command ${JSON.stringify(script)}`;
1046
+ return await this.executeShell({
1047
+ command,
1048
+ cwd: typeof args.cwd === 'string' ? args.cwd : undefined,
1049
+ timeout: typeof args.timeout === 'number' ? args.timeout : undefined,
1050
+ shell: process.platform === 'win32' ? 'powershell' : undefined,
1051
+ }, projectRoot, context?.additionalWriteRoots ?? null);
1052
+ }
1053
+ case 'get_goal':
1054
+ case 'create_goal':
1055
+ case 'update_goal':
1056
+ return this.executeGoalTool(call.name, args, context);
1057
+ case 'job_list':
1058
+ case 'job_output':
1059
+ case 'job_kill':
1060
+ return this.executeJobTool(call.name, args, context);
862
1061
  case 'task_run': {
863
1062
  const chatId = this.requireTaskContext(context);
864
1063
  const dependsOn = Array.isArray(args.dependsOn)
@@ -927,6 +1126,46 @@ export class ToolRouter {
927
1126
  * 项目围栏按 chat 解析)。schema 出场 ⇒ 服务已接线,这里只缺 chatId 时
928
1127
  * 抛错(reverse-tools 总是透传;缺了就是接线 bug,响亮失败)。
929
1128
  */
1129
+ async executeGoalTool(name, args, context) {
1130
+ const chatId = context?.chatId;
1131
+ if (!chatId) {
1132
+ return { success: false, error: '目标工具需要 chatId 调用上下文', needsFollowup: true };
1133
+ }
1134
+ const store = this.goals();
1135
+ if (name === 'get_goal')
1136
+ return store.get(chatId);
1137
+ if (name === 'create_goal')
1138
+ return store.create(chatId, String(args.objective || ''));
1139
+ return store.update({
1140
+ chatId,
1141
+ id: String(args.id || ''),
1142
+ revision: typeof args.revision === 'number' ? args.revision : Number.NaN,
1143
+ action: String(args.action || ''),
1144
+ objective: typeof args.objective === 'string' ? args.objective : undefined,
1145
+ reason: typeof args.reason === 'string' ? args.reason : undefined,
1146
+ });
1147
+ }
1148
+ async executeJobTool(name, args, context) {
1149
+ const chatId = this.requireTaskContext(context);
1150
+ const jobId = String(args.job_id || '');
1151
+ if (name === 'job_list') {
1152
+ const listed = await this.taskService.status(chatId);
1153
+ const tasks = Array.isArray(listed.tasks) ? listed.tasks : [];
1154
+ return {
1155
+ ...listed,
1156
+ jobs: tasks.map((task) => {
1157
+ const row = task;
1158
+ return { ...row, job_id: row.taskId };
1159
+ }),
1160
+ };
1161
+ }
1162
+ if (!jobId)
1163
+ return { success: false, error: 'job_id 不能为空', needsFollowup: true };
1164
+ if (name === 'job_output')
1165
+ return this.taskService.result(chatId, jobId);
1166
+ const reason = typeof args.reason === 'string' && args.reason.trim() ? args.reason.trim() : undefined;
1167
+ return this.taskService.kill(chatId, jobId, reason);
1168
+ }
930
1169
  requireTaskContext(context) {
931
1170
  if (!this.taskService || !this.worktreeService) {
932
1171
  throw new Error('任务/worktree 服务未接线(main.ts setTaskServices)');
@@ -955,41 +1194,44 @@ export class ToolRouter {
955
1194
  }
956
1195
  }
957
1196
  /**
958
- * 项目模式硬沙箱(cwd 维度):projectRoot 非空时,cwd 默认收到项目根;
959
- * 模型显式给的 cwd 越界则返回错误结果。相对 cwd 按项目根解析(而不是
960
- * 进程 cwd),符合"在项目里干活"的直觉。
1197
+ * 项目模式硬沙箱(cwd 维度):有可写根时,cwd 默认收到项目家目录;
1198
+ * 模型显式给的 cwd 必须落在家目录或额外可写根(源文件夹)内,含各自子目录。
1199
+ * 相对 cwd 按家目录解析(而不是进程 cwd)。
961
1200
  *
962
1201
  * 注意边界:shell 命令文本里内嵌的绝对路径无法可靠解析,围栏只覆盖
963
- * cwd 与文件读写工具——系统提示里已告知模型只在项目目录内操作。
1202
+ * cwd 与文件读写工具。命令实际写盘还受 exec 沙箱的 writableRoots 约束,
1203
+ * 那些根同样包含子目录。
964
1204
  *
965
1205
  * local_exec_shell 与 local_run_snippet 共用本 helper。
966
1206
  */
967
- applyProjectCwdSandbox(request, projectRoot) {
968
- if (!projectRoot)
1207
+ applyProjectCwdSandbox(request, projectRoot, additionalWriteRoots) {
1208
+ const roots = writableFenceRoots(projectRoot, additionalWriteRoots);
1209
+ if (roots.length === 0)
969
1210
  return { request };
970
- const root = path.resolve(projectRoot.startsWith('~') ? path.join(os.homedir(), projectRoot.slice(1)) : projectRoot);
1211
+ const home = projectRoot ? expandFencePath(projectRoot) : roots[0];
971
1212
  if (request.cwd) {
972
1213
  const expanded = request.cwd.startsWith('~')
973
1214
  ? path.join(os.homedir(), request.cwd.slice(1))
974
1215
  : request.cwd;
975
1216
  const resolvedCwd = path.isAbsolute(expanded)
976
1217
  ? path.resolve(expanded)
977
- : path.resolve(root, expanded);
978
- if (!isPathWithinRoot(resolvedCwd, root)) {
1218
+ : path.resolve(home, expanded);
1219
+ const violation = writableFenceViolation(resolvedCwd, roots);
1220
+ if (violation) {
979
1221
  return {
980
1222
  error: {
981
1223
  success: false,
982
- error: buildProjectRootViolation(resolvedCwd, root),
1224
+ error: violation,
983
1225
  platform: process.platform,
984
1226
  },
985
1227
  };
986
1228
  }
987
1229
  return { request: { ...request, cwd: resolvedCwd } };
988
1230
  }
989
- return { request: { ...request, cwd: root } };
1231
+ return { request: { ...request, cwd: home } };
990
1232
  }
991
- async executeShell(request, projectRoot) {
992
- const sandboxed = this.applyProjectCwdSandbox(request, projectRoot);
1233
+ async executeShell(request, projectRoot, additionalWriteRoots) {
1234
+ const sandboxed = this.applyProjectCwdSandbox(request, projectRoot, additionalWriteRoots);
993
1235
  if ('error' in sandboxed)
994
1236
  return sandboxed.error;
995
1237
  if (this.shellExecutor) {
@@ -1021,8 +1263,17 @@ export class ToolRouter {
1021
1263
  return { path: resolved };
1022
1264
  }
1023
1265
  resolveMcpConfig(args) {
1266
+ const command = typeof args.command === 'string' ? args.command.trim() : '';
1267
+ const url = typeof args.url === 'string' ? args.url.trim() : '';
1268
+ if (command && url)
1269
+ throw new Error('MCP 调用只能提供 command 或 url 之一');
1270
+ if (url)
1271
+ return { transport: 'streamable-http', url };
1272
+ if (!command)
1273
+ throw new Error('MCP 调用需要 command(stdio)或 url(Streamable HTTP)');
1024
1274
  return {
1025
- command: String(args.command || ''),
1275
+ transport: 'stdio',
1276
+ command,
1026
1277
  args: Array.isArray(args.args) ? args.args.map(item => String(item)) : undefined,
1027
1278
  cwd: typeof args.cwd === 'string' ? args.cwd : undefined,
1028
1279
  };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 宿主侧 grep / glob。
3
+ *
4
+ * 调用方先把起点收进项目围栏。这里只在该目录内遍历,跳过符号链接,
5
+ * 并丢掉依赖目录和超大文件。命中数有上限。
6
+ */
7
+ export interface SearchHit {
8
+ path: string;
9
+ line: number;
10
+ text: string;
11
+ }
12
+ export interface GrepResult {
13
+ success: boolean;
14
+ matches?: SearchHit[];
15
+ truncated?: boolean;
16
+ filesScanned?: number;
17
+ error?: string;
18
+ needsFollowup?: boolean;
19
+ }
20
+ export interface GlobResult {
21
+ success: boolean;
22
+ files?: string[];
23
+ truncated?: boolean;
24
+ error?: string;
25
+ needsFollowup?: boolean;
26
+ }
27
+ export declare function searchGrep(input: {
28
+ root: string;
29
+ pattern: string;
30
+ glob?: string;
31
+ }): Promise<GrepResult>;
32
+ export declare function searchGlob(input: {
33
+ root: string;
34
+ pattern: string;
35
+ }): Promise<GlobResult>;