@yeaft/webchat-agent 1.0.512 → 1.0.514

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.
@@ -25,7 +25,7 @@
25
25
  * @param {'en'|'zh'} [args.language='en']
26
26
  * @returns {string} preamble block (already ## headed, ready to concat)
27
27
  */
28
- export function buildSpawnedPreamble({ parentName, parentVpId, agentName, mission, expectedOutput, presetPrompt, budget, language = 'en' } = {}) {
28
+ export function buildSpawnedPreamble({ parentName, parentVpId, agentName, mission, expectedOutput, presetPrompt, budget, allowTools = [], language = 'en' } = {}) {
29
29
  // Resolve template markers before embedding: the outer VP renderer treats
30
30
  // markers as sections of the whole soul and would drop the parent + contract.
31
31
  const locale = language === 'zh' || language === 'zh-CN' ? 'zh' : 'en';
@@ -35,8 +35,9 @@ export function buildSpawnedPreamble({ parentName, parentVpId, agentName, missio
35
35
  : presetPrompt;
36
36
  const contract = [
37
37
  rolePrompt || '',
38
+ `## Tool authority\nDefault persona tools plus explicit parent grants: ${JSON.stringify(allowTools)}. Parent grants override a default read-only role only within the mission's scope. Bash permits arbitrary shell/writes; it is not a sandbox. You cannot grant yourself tools or budget; report blockers to the parent. UpdateAgent is parent-only.`,
38
39
  expectedOutput ? `## expected_output\nReturn the requested structure; mark unverified facts and blockers honestly.\n${JSON.stringify(expectedOutput)}` : '',
39
- budget ? `## Execution budget\n${JSON.stringify(budget)}\nLimits are ceilings, not targets. Stop once the mission is answered. Return partial findings before exhausting the budget; do not automatically restart the same work.` : '',
40
+ budget ? `## Execution budget\n${JSON.stringify(budget)}\nLimits are ceilings, not targets. Complete the assigned result, then stop; do not stop with a plan or promise to continue. If a tool or prerequisite is unavailable, return the evidence and blocker instead of searching for unavailable capabilities. Near the tool limit, prioritize a supported conclusion. At the limit, one tool-free report may be requested within the remaining time/token budget; do not automatically restart the work.` : '',
40
41
  ].filter(Boolean).join('\n\n');
41
42
  const m = [(mission || '').trim(), contract].filter(Boolean).join('\n\n');
42
43
  if (language === 'zh') {
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Child tool authorization policy.
3
+ *
4
+ * Persona tools are the baseline. `agent.allowTools` is an additional,
5
+ * replaceable allowlist of canonical tools from the parent registry. Bash is
6
+ * deliberately not wrapped or narrowed here: granting Bash grants the actual
7
+ * parent shell tool, including its write capabilities.
8
+ */
9
+ import { getPersona } from '../personas.js';
10
+
11
+ const MAX_TOOL_GRANTS = 32;
12
+ const TOOL_NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,127}$/;
13
+
14
+ export const RESTRICTED_TOOLS = new Set([
15
+ 'SpawnAgent',
16
+ 'Agent', // legacy alias
17
+ 'UpdateAgent',
18
+ 'PromptAgent',
19
+ 'SendMessage', // legacy alias
20
+ 'WaitAgent',
21
+ 'CloseAgent',
22
+ 'ListAgents',
23
+ 'RouteForward',
24
+ 'AskUser',
25
+ 'CreateWorkItem',
26
+ ]);
27
+
28
+ function canonicalTool(parentRegistry, name) {
29
+ if (!parentRegistry || typeof parentRegistry.get !== 'function') return null;
30
+ const tool = parentRegistry.get(name);
31
+ return tool && typeof tool.name === 'string' ? tool : null;
32
+ }
33
+
34
+ function isRestricted(tool, requestedName = null) {
35
+ return RESTRICTED_TOOLS.has(tool?.name) || (requestedName && RESTRICTED_TOOLS.has(requestedName));
36
+ }
37
+
38
+ /**
39
+ * Validate and canonicalize explicit child grants.
40
+ *
41
+ * @param {unknown} names
42
+ * @param {import('../tools/registry.js').ToolRegistry|null} parentRegistry
43
+ * @returns {{ ok: true, tools: string[] }|{ ok: false, error: string }}
44
+ */
45
+ export function validateToolGrants(names, parentRegistry) {
46
+ if (!Array.isArray(names)) {
47
+ return { ok: false, error: 'allow_tools must be an array of tool names' };
48
+ }
49
+ if (names.length > MAX_TOOL_GRANTS) {
50
+ return { ok: false, error: `allow_tools may contain at most ${MAX_TOOL_GRANTS} names` };
51
+ }
52
+ if (!parentRegistry || typeof parentRegistry.get !== 'function') {
53
+ return names.length === 0
54
+ ? { ok: true, tools: [] }
55
+ : { ok: false, error: 'parent tool registry is unavailable' };
56
+ }
57
+
58
+ const tools = [];
59
+ const seen = new Set();
60
+ for (const value of names) {
61
+ if (typeof value !== 'string' || !value.trim()) {
62
+ return { ok: false, error: 'allow_tools entries must be non-empty tool names' };
63
+ }
64
+ const name = value.trim();
65
+ if (!TOOL_NAME_RE.test(name)) {
66
+ return { ok: false, error: `Invalid tool name: ${name}` };
67
+ }
68
+ const tool = canonicalTool(parentRegistry, name);
69
+ if (!tool) return { ok: false, error: `Parent tool is not available: ${name}` };
70
+ if (isRestricted(tool, name)) {
71
+ return { ok: false, error: `Tool cannot be granted to a child agent: ${tool.name}` };
72
+ }
73
+ if (!seen.has(tool.name)) {
74
+ seen.add(tool.name);
75
+ tools.push(tool.name);
76
+ }
77
+ }
78
+ return { ok: true, tools };
79
+ }
80
+
81
+ function unregisterTool(registry, tool) {
82
+ registry.unregister(tool.name);
83
+ for (const alias of tool.aliases || []) registry.unregister(alias);
84
+ }
85
+
86
+ /**
87
+ * Create a live policy for one child. `refresh()` reconciles only the child
88
+ * registry; the parent registry is used as the source of canonical ToolDef
89
+ * objects and is never mutated.
90
+ *
91
+ * @param {import('../tools/registry.js').ToolRegistry|null} parentRegistry
92
+ * @param {object|null} agent
93
+ * @returns {{ allows(tool: object): boolean, refresh(childRegistry: import('../tools/registry.js').ToolRegistry): void }}
94
+ */
95
+ export function createChildToolPolicy(parentRegistry, agent) {
96
+ const preset = agent?.personaData || getPersona(agent?.persona);
97
+ const baseline = preset && preset.id !== 'implementer'
98
+ ? new Set([
99
+ ...preset.tools.map(name => canonicalTool(parentRegistry, name)?.name || (name === 'Read' ? 'FileRead' : name)),
100
+ 'DiscoverTools',
101
+ ])
102
+ : null;
103
+
104
+ const allows = (tool) => {
105
+ if (!tool || typeof tool.name !== 'string' || isRestricted(tool)) return false;
106
+
107
+ // Require the exact ToolDef owned by the parent. This prevents aliases or a
108
+ // child-local/MCP hot registration from manufacturing an allowed name.
109
+ if (canonicalTool(parentRegistry, tool.name) !== tool) return false;
110
+ if (baseline === null || baseline.has(tool.name)) return true;
111
+
112
+ for (const name of agent?.allowTools || []) {
113
+ const granted = canonicalTool(parentRegistry, name);
114
+ if (granted === tool && !isRestricted(granted, name)) return true;
115
+ }
116
+ return false;
117
+ };
118
+
119
+ const refresh = (childRegistry) => {
120
+ if (!childRegistry
121
+ || typeof childRegistry.getAllTools !== 'function'
122
+ || typeof childRegistry.register !== 'function'
123
+ || typeof childRegistry.unregister !== 'function') return;
124
+
125
+ for (const tool of childRegistry.getAllTools()) {
126
+ if (!allows(tool)) unregisterTool(childRegistry, tool);
127
+ }
128
+ if (!parentRegistry || typeof parentRegistry.getAllTools !== 'function') return;
129
+ for (const tool of parentRegistry.getAllTools()) {
130
+ if (!allows(tool)) continue;
131
+ const current = childRegistry.get(tool.name);
132
+ if (current && current !== tool) unregisterTool(childRegistry, current);
133
+ if (childRegistry.get(tool.name) !== tool) childRegistry.register(tool);
134
+ }
135
+ };
136
+
137
+ return { allows, refresh };
138
+ }
@@ -4,6 +4,7 @@ name: Reviewer
4
4
  description: Critical read-only reviewer for code changes and designs
5
5
  modelTier: primary
6
6
  tools:
7
+ - GitRead
7
8
  - Read
8
9
  - Grep
9
10
  - Glob
@@ -18,7 +19,8 @@ You are a **Reviewer** sub-agent. Your job is to audit code or designs and surfa
18
19
 
19
20
  ## Operating Principles
20
21
 
21
- - **Read-only**: Never modify files.
22
+ - **Read-only by default**: Do not modify files unless the parent explicitly grants the necessary tools for a scoped edit/verification task. Bash is not a read-only sandbox; keep it within the assigned scope.
23
+ - **Diff first**: Start with `GitRead` status/diff to establish the actual change set, then inspect only the relevant files and lines.
22
24
  - **Evidence-based**: Every finding must cite `path:line`.
23
25
  - **Severity-tagged**: Label each finding `blocker | major | minor | nit`.
24
26
  - **Constructive**: Suggest fixes, not just complaints.
@@ -35,7 +37,8 @@ Structured list of findings. For each: severity, location, description, suggeste
35
37
 
36
38
  ## 操作原则
37
39
 
38
- - **只读**:不要修改文件。
40
+ - **默认只读**:只有父级为明确的编辑/验证任务显式授予必要工具后,才可在该范围内写入。Bash 并非只读沙箱,不得扩大任务范围。
41
+ - **先读 diff**:先用 `GitRead` 的 status/diff 确认实际改动范围,再只检查相关文件和行段。
39
42
  - **证据优先**:每个 finding 都必须引用 `path:line`。
40
43
  - **标注严重度**:每个 finding 标为 `blocker | major | minor | nit`。
41
44
  - **建设性**:不仅指出问题,也要给出修复建议。
@@ -31,6 +31,7 @@ export const BACKGROUND_TASK_TOOL_NAMES = Object.freeze([
31
31
  ]);
32
32
 
33
33
  export const SUB_AGENT_MANAGEMENT_TOOL_NAMES = Object.freeze([
34
+ 'UpdateAgent',
34
35
  'PromptAgent',
35
36
  'WaitAgent',
36
37
  'CloseAgent',
@@ -45,6 +46,7 @@ export const CONDITIONAL_BUILTIN_TOOL_NAMES = new Set([
45
46
  'ReadTaskLog',
46
47
  'CancelTask',
47
48
  'SpawnAgent',
49
+ 'UpdateAgent',
48
50
  'PromptAgent',
49
51
  'WaitAgent',
50
52
  'CloseAgent',
@@ -14,8 +14,11 @@
14
14
  * budget?: {
15
15
  * max_tokens?: number,
16
16
  * max_turns?: number,
17
+ * max_tool_calls?: number,
18
+ * max_llm_calls?: number,
17
19
  * wall_time_ms?: number
18
20
  * },
21
+ * allow_tools?: string[], // extra parent tool grants beyond persona defaults
19
22
  * cwd?: string
20
23
  * }
21
24
  */
@@ -24,7 +27,8 @@ import { defineTool } from './types.js';
24
27
  import { randomUUID } from 'crypto';
25
28
  import { getPersona, listPersonaIds } from '../personas.js';
26
29
  import { startSubAgent } from '../sub-agent/runner.js';
27
- import { resolveSubAgentBudget } from '../sub-agent/execution-control.js';
30
+ import { resolveSubAgentBudget, validateBudget } from '../sub-agent/execution-control.js';
31
+ import { validateToolGrants } from '../sub-agent/tool-access.js';
28
32
  import { STATUS, isTerminalAgentStatus } from '../sub-agent/status.js';
29
33
  import { diagnoseAgentLiveness, makeLiveness } from '../sub-agent/liveness.js';
30
34
  import { TASK_RESULT_DELIVERY } from '../tasks/store.js';
@@ -108,15 +112,8 @@ export function validateSpec(input) {
108
112
  return { ok: false, error: 'expected_output must be a JSON schema object' };
109
113
  }
110
114
  if (budget !== undefined) {
111
- if (typeof budget !== 'object' || budget === null) {
112
- return { ok: false, error: 'budget must be an object' };
113
- }
114
- for (const k of ['max_tokens', 'max_turns', 'wall_time_ms', 'max_tool_calls']) {
115
- if (budget[k] !== undefined && (!Number.isFinite(budget[k]) || budget[k] <= 0
116
- || (['max_turns', 'max_tool_calls'].includes(k) && !Number.isSafeInteger(budget[k])))) {
117
- return { ok: false, error: `budget.${k} must be finite and positive (counts must be integers)` };
118
- }
119
- }
115
+ const error = validateBudget(budget);
116
+ if (error) return { ok: false, error };
120
117
  }
121
118
  return {
122
119
  ok: true,
@@ -237,8 +234,10 @@ Pick a preset persona to pre-wire a tool subset and model tier:
237
234
  Guidelines:
238
235
  - Give a clear, focused mission — what "done" looks like
239
236
  - Use expected_output when the return shape matters
240
- - Delegate only a bounded independent result; do simple work directly. Set scope, evidence and stopping conditions in mission.
241
- - Inspect execution counters and partial evidence before extending budgets; do not respawn the same exhausted mission automatically.
237
+ - Delegate one clear result with the workspace/base and completion evidence. Let the child choose its steps; do simple work directly. Split unrelated goals, not individual reads.
238
+ - Usually omit budget: defaults are safety ceilings, not targets. Do not impose a tiny tool limit on a multi-file review. Tool exhaustion reserves one tool-free handoff within the remaining time/token limits; unfinished work stays budget_exceeded.
239
+ - Persona tools are defaults, not task boundaries: reviewer has GitRead; grant Bash or write tools explicitly via allow_tools only when needed. Bash is not a read-only sandbox; isolate concurrent writable tasks.
240
+ - Use UpdateAgent to adjust a live child's time/tool/LLM ceilings or extra grants after inspecting evidence; counters and context are retained. Do not extend stalled work blindly or respawn the same exhausted mission automatically.
242
241
 
243
242
  Async orchestration:
244
243
  1. SpawnAgent — starts the sub-agent as a background task and returns immediately.
@@ -268,8 +267,10 @@ max_tokens 在 provider usage 到达时检查;max_turns 是 query turn 数,
268
267
  使用指南:
269
268
  - 给出清晰聚焦的 mission——"完成"是什么样子
270
269
  - 当返回结构重要时使用 expected_output
271
- - 只委派有界且独立的结果;简单工作直接做。在 mission 中写清范围、证据和停止条件。
272
- - 扩大预算前检查实际执行计数和已有证据;不要自动重启同一个耗尽预算的任务。
270
+ - 一次只委派一个明确结果,提供工作目录/基线和完成证据,让子 Agent 自主选择步骤;简单工作直接做。拆分不相关目标,不要拆成逐个读取任务。
271
+ - 通常省略 budget:默认值是安全上限,不是执行目标。不要给多文件 review 人为设置极小的工具额度。工具耗尽后会在剩余时间/token预算内留一次无工具交付机会,未完成仍返回 budget_exceeded。
272
+ - Persona 是默认工具集,不是任务死边界:reviewer 有 GitRead;按需用 allow_tools 显式授予 Bash/写工具。Bash 并非只读沙箱;并行写任务应隔离 workspace。
273
+ - 检查已有证据后,用 UpdateAgent 原地调整活跃子任务的时间/工具/LLM 上限或额外授权,保留计数与上下文;不要盲目扩额停滞任务,也不要自动重启同一个耗尽任务。
273
274
 
274
275
  异步编排流程:
275
276
  1. SpawnAgent — 启动子 Agent 作为后台任务并立即返回。
@@ -333,6 +334,10 @@ liveness,不要盲目循环。`
333
334
  max_turns: { type: 'number', description: {
334
335
  en: 'Optional turn ceiling; no default limit is applied',
335
336
  zh: '可选 turn 上限;默认不设限制',
337
+ } },
338
+ max_llm_calls: { type: 'integer', minimum: 1, description: {
339
+ en: 'Optional actual Engine provider-dispatch ceiling (including retries); distinct from query turns. One extra tool-free reporting request is reserved and separately counted.',
340
+ zh: '可选实际 Engine 模型请求上限(含重试),不同于 query turn;另保留并单独统计一次无工具报告请求。',
336
341
  } },
337
342
  max_tool_calls: { type: 'integer', minimum: 1, description: {
338
343
  en: 'Actual tool execution ceiling; default 64, or 128 for implementer. Includes parallel and discovered tools.',
@@ -348,6 +353,13 @@ liveness,不要盲目循环。`
348
353
  zh: '覆盖默认工具/时间安全上限;token/turn 限制可选。截止时返回 { status: "budget_exceeded", partial_output, reason },不代表任务成功。',
349
354
  },
350
355
  },
356
+ allow_tools: {
357
+ type: 'array', items: { type: 'string' }, maxItems: 32,
358
+ description: {
359
+ en: 'Explicit extra parent tools beyond persona defaults (e.g. Bash, FileEdit). Bash permits arbitrary shell/writes, not a read-only sandbox. Grant only necessary tools and isolate writable workspaces; child orchestration stays forbidden.',
360
+ zh: '在 persona 默认工具之外显式授予父级工具(如 Bash、FileEdit)。Bash 可执行任意 Shell/写入,并非只读沙箱。仅授予必要工具并隔离写入 workspace;子级编排仍禁止。',
361
+ },
362
+ },
351
363
  cwd: {
352
364
  type: 'string',
353
365
  description: {
@@ -379,6 +391,8 @@ liveness,不要盲目循环。`
379
391
  return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: validation.error });
380
392
  }
381
393
  const spec = validation.spec;
394
+ const grants = validateToolGrants(input.allow_tools === undefined ? [] : input.allow_tools, ctx?.parentEngineDeps?.parentToolRegistry);
395
+ if (!grants.ok) return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: grants.error });
382
396
  const { name, cwd } = input;
383
397
  const callerScope = getCallerAgentScope(ctx);
384
398
 
@@ -411,6 +425,7 @@ liveness,不要盲目循环。`
411
425
  // Capture at the tool boundary, before fire-and-forget startup can yield.
412
426
  parentEffortDecision: captureParentEffortDecision(ctx),
413
427
  budget: spec.budget,
428
+ allowTools: grants.tools,
414
429
  cwd: cwd || ctx?.cwd || process.cwd(),
415
430
  status: STATUS.CREATED,
416
431
  messages: [],
@@ -499,6 +514,7 @@ liveness,不要盲目循环。`
499
514
  name,
500
515
  persona: spec.persona || null,
501
516
  budget: spec.budget || null,
517
+ allow_tools: agent.allowTools,
502
518
  status: agent.status,
503
519
  outputFile: agent.outputFile || null,
504
520
  taskId: agent.taskId || null,
@@ -0,0 +1,266 @@
1
+ import { resolve } from 'node:path';
2
+ import { defineTool } from './types.js';
3
+ import { runProcess } from './process-runner.js';
4
+
5
+ const MAX_RESULT_BYTES = 30 * 1024;
6
+ const MAX_CAPTURE_BYTES = 64 * 1024;
7
+ const MAX_PATHS = 50;
8
+ const MAX_VALUE_LENGTH = 4096;
9
+ const DEFAULT_LOG_LIMIT = 20;
10
+ const MAX_LOG_LIMIT = 50;
11
+ const TIMEOUT_MS = 30_000;
12
+ const RUN_PROCESS_OVERRIDE = Symbol('runProcessOverride');
13
+
14
+ const COMMON_ARGS = Object.freeze([
15
+ '--no-pager',
16
+ '--no-optional-locks',
17
+ '--literal-pathspecs',
18
+ '--no-replace-objects',
19
+ '-c', 'color.ui=false',
20
+ '-c', 'core.fsmonitor=false',
21
+ '-c', 'log.showSignature=false',
22
+ '-c', 'submodule.recurse=false',
23
+ '-c', 'diff.submodule=short',
24
+ '-c', 'protocol.allow=never',
25
+ ]);
26
+
27
+ // Worktree status/diff may invoke clean/process filters even with textconv and
28
+ // external diff disabled. Discover their keys, never values, and override them
29
+ // for this process only. Incomplete discovery fails closed.
30
+ async function filterOverrides(run, options) {
31
+ const result = await run('git', [
32
+ ...COMMON_ARGS, 'config', '--null', '--name-only', '--get-regexp',
33
+ '^filter\\..*\\.(clean|smudge|process|required)$',
34
+ ], options);
35
+ if (result.truncated || result.timedOut || (result.code !== 0 && result.code !== 1)) {
36
+ throw new Error('Cannot safely inspect Git content filters');
37
+ }
38
+ if (result.code === 1) return [];
39
+ const keys = [...new Set(result.stdout.split('\0').filter(Boolean))];
40
+ if (keys.length > 200) throw new Error('Too many Git content filters for a bounded read');
41
+ return keys.flatMap(key => {
42
+ if (!/^filter\..*\.(clean|smudge|process|required)$/.test(key) || /[=\r\n]/.test(key)) {
43
+ throw new Error('Unsupported Git filter key; refusing an unsafe read');
44
+ }
45
+ return ['-c', `${key}=${key.endsWith('.required') ? 'false' : ''}`];
46
+ });
47
+ }
48
+
49
+ function errorOutput(message) {
50
+ return JSON.stringify({ error: message });
51
+ }
52
+
53
+ function validateValue(value, name) {
54
+ if (typeof value !== 'string' || !value) return `${name} must be a non-empty string`;
55
+ if (value.length > MAX_VALUE_LENGTH) return `${name} must be at most ${MAX_VALUE_LENGTH} characters`;
56
+ if (value.startsWith('-')) return `${name} must not start with "-"`;
57
+ if (/\0|[\r\n]/u.test(value)) return `${name} must not contain NUL or newlines`;
58
+ return null;
59
+ }
60
+
61
+ function validatePaths(paths) {
62
+ if (paths === undefined) return null;
63
+ if (!Array.isArray(paths)) return 'paths must be an array of strings';
64
+ if (paths.length > MAX_PATHS) return `paths must contain at most ${MAX_PATHS} entries`;
65
+ for (let index = 0; index < paths.length; index += 1) {
66
+ const error = validateValue(paths[index], `paths[${index}]`);
67
+ if (error) return error;
68
+ if (paths[index].includes(':(attr:')) return `paths[${index}] must not use pathspec attributes`;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function unexpectedInput(input, allowed) {
74
+ const unexpected = Object.keys(input).filter(key => !allowed.has(key));
75
+ return unexpected.length > 0 ? `Unexpected parameter(s) for ${input.operation}: ${unexpected.join(', ')}` : null;
76
+ }
77
+
78
+ export function buildGitReadArgs(input) {
79
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
80
+ return { error: 'input must be an object' };
81
+ }
82
+
83
+ const { operation } = input;
84
+ if (!['status', 'diff', 'show', 'log'].includes(operation)) {
85
+ return { error: 'operation must be one of: status, diff, show, log' };
86
+ }
87
+
88
+ if (operation === 'status') {
89
+ const error = unexpectedInput(input, new Set(['operation']));
90
+ if (error) return { error };
91
+ return { args: [...COMMON_ARGS, 'status', '--short', '--branch', '--untracked-files=normal', '--ignore-submodules=all'] };
92
+ }
93
+
94
+ if (operation === 'diff') {
95
+ const error = unexpectedInput(input, new Set(['operation', 'base', 'head', 'paths']))
96
+ || validatePaths(input.paths)
97
+ || (input.base !== undefined ? validateValue(input.base, 'base') : null)
98
+ || (input.head !== undefined ? validateValue(input.head, 'head') : null);
99
+ if (error) return { error };
100
+ if (input.head !== undefined && input.base === undefined) {
101
+ return { error: 'head requires base' };
102
+ }
103
+ const revision = input.base === undefined
104
+ ? 'HEAD'
105
+ : `${input.base}...${input.head || 'HEAD'}`;
106
+ return {
107
+ args: [
108
+ ...COMMON_ARGS,
109
+ 'diff', '--no-ext-diff', '--no-textconv', '--no-color', '--ignore-submodules=dirty',
110
+ revision, '--', ...(input.paths || []),
111
+ ],
112
+ };
113
+ }
114
+
115
+ if (operation === 'show') {
116
+ const error = unexpectedInput(input, new Set(['operation', 'revision', 'paths']))
117
+ || validatePaths(input.paths)
118
+ || (input.revision !== undefined ? validateValue(input.revision, 'revision') : null);
119
+ if (error) return { error };
120
+ return {
121
+ args: [
122
+ ...COMMON_ARGS,
123
+ 'show', '--no-ext-diff', '--no-textconv', '--no-color', '--format=fuller',
124
+ input.revision || 'HEAD', '--', ...(input.paths || []),
125
+ ],
126
+ };
127
+ }
128
+
129
+ const error = unexpectedInput(input, new Set(['operation', 'revision', 'limit']))
130
+ || (input.revision !== undefined ? validateValue(input.revision, 'revision') : null);
131
+ if (error) return { error };
132
+ const limit = input.limit === undefined ? DEFAULT_LOG_LIMIT : input.limit;
133
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LOG_LIMIT) {
134
+ return { error: `limit must be an integer between 1 and ${MAX_LOG_LIMIT}` };
135
+ }
136
+ return {
137
+ args: [
138
+ ...COMMON_ARGS,
139
+ 'log', '--no-color', `--max-count=${limit}`, '--date=iso-strict',
140
+ '--format=%H%x09%ad%x09%an%x09%s', input.revision || 'HEAD', '--',
141
+ ],
142
+ };
143
+ }
144
+
145
+ function takeUtf8(text, maxBytes) {
146
+ const buffer = Buffer.from(String(text), 'utf8');
147
+ if (buffer.length <= maxBytes) return String(text);
148
+ let end = maxBytes;
149
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80) end -= 1;
150
+ return buffer.subarray(0, end).toString('utf8');
151
+ }
152
+
153
+ export function formatGitReadResult(operation, result) {
154
+ const timedOut = Boolean(result.timedOut);
155
+ const runnerTruncated = Boolean(result.truncated);
156
+ const sections = [];
157
+ if (result.stdout) sections.push(`STDOUT:\n${result.stdout}`);
158
+ if (result.stderr) sections.push(`STDERR:\n${result.stderr}`);
159
+ const body = sections.join('\n');
160
+ const baseHeader = truncated => [
161
+ `operation: ${operation}`,
162
+ `exitCode: ${runnerTruncated ? 'not observed (output limit reached)' : result.code}`,
163
+ `timedOut: ${timedOut}`,
164
+ `truncated: ${truncated}`,
165
+ ].join('\n');
166
+ const initial = `${baseHeader(runnerTruncated)}\n\n${body || '(no output)'}`;
167
+ if (!runnerTruncated && Buffer.byteLength(initial, 'utf8') <= MAX_RESULT_BYTES) return initial;
168
+
169
+ const marker = '\n\n[Output truncated by GitRead; narrow the revision or paths.]';
170
+ const header = `${baseHeader(true)}\n\n`;
171
+ const bodyBudget = Math.max(
172
+ 0,
173
+ MAX_RESULT_BYTES - Buffer.byteLength(header, 'utf8') - Buffer.byteLength(marker, 'utf8'),
174
+ );
175
+ return header + takeUtf8(body || '(no output)', bodyBudget) + marker;
176
+ }
177
+
178
+ const gitReadTool = defineTool({
179
+ name: 'GitRead',
180
+ description: {
181
+ en: `Read bounded local Git evidence without a shell or network access.
182
+
183
+ Supported operations are intentionally limited:
184
+ - status: compact branch and working-tree status.
185
+ - diff: tracked changes against HEAD by default, or an explicit base...head range; optional paths narrow the result.
186
+ - show: one commit (HEAD by default), optionally narrowed by paths.
187
+ - log: a compact bounded commit list (20 entries by default, maximum 50).
188
+
189
+ GitRead never fetches, writes Git state, or creates worktrees. It disables pagers, external diff, textconv, content filters, optional locks, fsmonitor, and submodule traversal. Filter-normalized files (such as LFS) show raw worktree bytes; submodule status needs separate inspection. Revisions and paths beginning with "-" are rejected. Output reports whether it was truncated.`,
190
+ zh: `有界读取本地 Git 证据,不使用 shell,也不访问网络。
191
+
192
+ 操作范围刻意限制为:
193
+ - status:紧凑显示分支和工作区状态。
194
+ - diff:默认显示相对 HEAD 的已跟踪改动,也可指定 base...head;可用 paths 缩小范围。
195
+ - show:显示一个提交(默认 HEAD),可用 paths 缩小范围。
196
+ - log:紧凑且有界的提交列表(默认 20 条,最多 50 条)。
197
+
198
+ GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、external diff、textconv、内容 filter、optional locks、fsmonitor 和子模块遍历。LFS 等 filter 文件显示原始工作区字节,子模块状态需单独检查。拒绝以 "-" 开头的 revision 与路径;结果明确标识是否截断。`,
199
+ },
200
+ parameters: {
201
+ type: 'object',
202
+ additionalProperties: false,
203
+ properties: {
204
+ operation: { type: 'string', enum: ['status', 'diff', 'show', 'log'] },
205
+ base: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'Diff base revision; omitted for working-tree changes against HEAD' },
206
+ head: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'Diff head revision; requires base and defaults to HEAD' },
207
+ revision: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'Revision for show or log (default: HEAD)' },
208
+ paths: {
209
+ type: 'array',
210
+ maxItems: MAX_PATHS,
211
+ items: { type: 'string', minLength: 1, maxLength: MAX_VALUE_LENGTH },
212
+ description: 'Optional repository-relative paths for diff or show',
213
+ },
214
+ limit: { type: 'integer', minimum: 1, maximum: MAX_LOG_LIMIT, description: 'Maximum log entries' },
215
+ },
216
+ required: ['operation'],
217
+ },
218
+ timeoutMs: 0,
219
+ isConcurrencySafe: () => true,
220
+ isReadOnly: () => true,
221
+ async execute(input, ctx) {
222
+ const built = buildGitReadArgs(input);
223
+ if (built.error) return errorOutput(built.error);
224
+ const cwd = resolve(ctx?.cwd || process.cwd());
225
+ try {
226
+ const run = ctx?.[RUN_PROCESS_OVERRIDE] || runProcess;
227
+ const startedAt = Date.now();
228
+ const options = {
229
+ cwd,
230
+ signal: ctx?.signal,
231
+ timeoutMs: TIMEOUT_MS,
232
+ maxBytes: MAX_CAPTURE_BYTES,
233
+ env: {
234
+ ...process.env,
235
+ GIT_PAGER: 'cat',
236
+ PAGER: 'cat',
237
+ GIT_EXTERNAL_DIFF: '',
238
+ GIT_NO_LAZY_FETCH: '1',
239
+ GIT_OPTIONAL_LOCKS: '0',
240
+ GIT_TERMINAL_PROMPT: '0',
241
+ NO_COLOR: '1',
242
+ },
243
+ };
244
+ const overrides = await filterOverrides(run, options);
245
+ const result = await run('git', [...overrides, ...built.args], {
246
+ ...options, timeoutMs: Math.max(1, TIMEOUT_MS - (Date.now() - startedAt)),
247
+ });
248
+ return formatGitReadResult(input.operation, result);
249
+ } catch (error) {
250
+ if (error?.name === 'AbortError') throw error;
251
+ return errorOutput(`GitRead failed: ${error?.message || String(error)}`);
252
+ }
253
+ },
254
+ });
255
+
256
+ export function createGitReadTool({ runProcessImpl = runProcess } = {}) {
257
+ return {
258
+ ...gitReadTool,
259
+ execute(input, ctx) {
260
+ return gitReadTool.execute(input, { ...ctx, [RUN_PROCESS_OVERRIDE]: runProcessImpl });
261
+ },
262
+ };
263
+ }
264
+
265
+ export { MAX_RESULT_BYTES };
266
+ export default gitReadTool;
@@ -33,6 +33,7 @@ import discoverTools from './discover-tools.js';
33
33
 
34
34
  // --- P0 File tools ---
35
35
  import bash from './bash.js';
36
+ import gitRead from './git-read.js';
36
37
  import fileRead from './file-read.js';
37
38
  import fileWrite from './file-write.js';
38
39
  import fileEdit from './file-edit.js';
@@ -47,6 +48,7 @@ import cancelTask from './cancel-task.js';
47
48
 
48
49
  // --- P1 Agent tools ---
49
50
  import agentTool from './agent.js';
51
+ import updateAgent from './update-agent.js';
50
52
  import sendMessage from './send-message.js';
51
53
  import waitAgent from './wait-agent.js';
52
54
  import closeAgent from './close-agent.js';
@@ -101,6 +103,7 @@ export const allTools = [
101
103
 
102
104
  // P0 File
103
105
  bash,
106
+ gitRead,
104
107
  fileRead,
105
108
  fileWrite,
106
109
  fileEdit,
@@ -115,6 +118,7 @@ export const allTools = [
115
118
 
116
119
  // P1 Agent
117
120
  agentTool,
121
+ updateAgent,
118
122
  sendMessage,
119
123
  waitAgent,
120
124
  closeAgent,