@yeaft/webchat-agent 1.0.568 → 1.0.570

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.
@@ -5,7 +5,7 @@
5
5
  * - Constants TOOL_LOOP_REFLECTION_INTERVAL, TURN_SUMMARY_THRESHOLD,
6
6
  * DUP_TOOL_THRESHOLD
7
7
  * - Reflector helpers (T1 sync, T2 async, fallback stub)
8
- * - Helpers for collapsing message ranges into a single assistant
8
+ * - Helpers for collapsing message ranges into a single synthetic user
9
9
  * reflection message
10
10
  * - Duplicate-reminder text formatter
11
11
  *
@@ -42,13 +42,13 @@ export { buildFallbackStub } from './fallback-stub.js';
42
42
 
43
43
  /**
44
44
  * Collapse messages[startIdx..endIdx] (inclusive) into a single
45
- * `{ role: 'user', content }` reflection message. Returns a NEW array; does
46
- * not mutate the input.
45
+ * `{ role: 'user', content }` reflection message. Returns an explicit
46
+ * replacement record; does not mutate the input.
47
47
  *
48
48
  * The original assistant+tool sequence (the action arc) is replaced by ONE
49
49
  * synthetic user message carrying the reflection summary. User messages
50
- * that happened to appear inside the range stay put (defensive — the caller
51
- * normally passes a range that contains only assistant+tool).
50
+ * inside the range are preserved before the summary, including appended
51
+ * prompts, async completion notifications and duplicate-call reminders.
52
52
  *
53
53
  * Why role='user' (not 'assistant'):
54
54
  * The Anthropic Messages API requires the messages array to end with a
@@ -68,11 +68,13 @@ export { buildFallbackStub } from './fallback-stub.js';
68
68
  * @param {number} startIdx
69
69
  * @param {number} endIdx
70
70
  * @param {string} reflectionContent
71
- * @returns {Array}
71
+ * @returns {{messages: Array, reflection: object|null, foldedMessages: Array}}
72
72
  */
73
73
  export function collapseRangeToReflection(messages, startIdx, endIdx, reflectionContent) {
74
- if (!Array.isArray(messages)) return messages;
75
- if (startIdx < 0 || endIdx < startIdx || endIdx >= messages.length) return messages;
74
+ if (!Array.isArray(messages) || !Number.isInteger(startIdx) || !Number.isInteger(endIdx)
75
+ || startIdx < 0 || endIdx < startIdx || endIdx >= messages.length) {
76
+ return { messages, reflection: null, foldedMessages: [] };
77
+ }
76
78
  const before = messages.slice(0, startIdx);
77
79
  const collapsed = messages.slice(startIdx, endIdx + 1);
78
80
  const after = messages.slice(endIdx + 1);
@@ -101,7 +103,13 @@ export function collapseRangeToReflection(messages, startIdx, endIdx, reflection
101
103
  content: wrappedContent,
102
104
  _reflection: true,
103
105
  };
104
- return [...before, ...preservedUsers, reflectionMsg, ...after];
106
+ return {
107
+ messages: [...before, ...preservedUsers, reflectionMsg, ...after],
108
+ reflection: reflectionMsg,
109
+ // Only replaced rows may be tombstoned. Preserved users include real
110
+ // appended prompts and internal completion/reminder messages.
111
+ foldedMessages: collapsed.filter(m => m && m.role !== 'user'),
112
+ };
105
113
  }
106
114
 
107
115
  /**
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { buildReflectionPrompt } from './reflection-prompt.js';
19
+ import { normalizeTokenUsage } from '../llm/usage-accounting.js';
19
20
 
20
21
  /**
21
22
  * @param {{
@@ -26,22 +27,42 @@ import { buildReflectionPrompt } from './reflection-prompt.js';
26
27
  * assistantText?: string,
27
28
  * language?: string,
28
29
  * signal?: AbortSignal,
30
+ * onComplete?: (diagnostic: object) => void,
29
31
  * }} p
30
- * @returns {Promise<{ content: string, durationMs: number }>}
32
+ * @returns {Promise<{ content: string, durationMs: number, usage: object }>}
31
33
  */
32
- export async function runT1Reflection({ adapter, model, originalUserMsg, toolPairs, assistantText, language, signal }) {
34
+ export async function runT1Reflection({ adapter, model, originalUserMsg, toolPairs, assistantText, language, signal, onComplete }) {
33
35
  const t0 = Date.now();
34
36
  const prompt = buildReflectionPrompt({ originalUserMsg, toolPairs, assistantText, language });
35
- const result = await adapter.call({
36
- model,
37
- system: prompt,
38
- messages: [{ role: 'user', content: 'Produce the reflection now.' }],
39
- maxTokens: 2048,
40
- signal,
41
- });
42
- const content = (result && typeof result.text === 'string') ? result.text.trim() : '';
43
- if (!content) {
44
- throw new Error('T1 reflection returned empty content');
37
+ let result;
38
+ let failure;
39
+ try {
40
+ result = await adapter.call({
41
+ model,
42
+ system: prompt,
43
+ messages: [{ role: 'user', content: 'Produce the reflection now.' }],
44
+ maxTokens: 2048,
45
+ signal,
46
+ });
47
+ const content = (result && typeof result.text === 'string') ? result.text.trim() : '';
48
+ if (!content) {
49
+ throw new Error('T1 reflection returned empty content');
50
+ }
51
+ return { content, durationMs: Date.now() - t0, usage: normalizeTokenUsage(result?.usage) };
52
+ } catch (error) {
53
+ failure = error;
54
+ throw error;
55
+ } finally {
56
+ // Diagnostic only; the adapter owns billing/usage accounting. Never
57
+ // double-charge it or fail a completed reflection on a logging error.
58
+ try {
59
+ onComplete?.({
60
+ status: failure ? 'error' : 'ready',
61
+ durationMs: Date.now() - t0,
62
+ usage: normalizeTokenUsage(result?.usage),
63
+ usageReported: !!result?.usage,
64
+ ...(failure ? { error: String(failure.message || failure).slice(0, 512) } : {}),
65
+ });
66
+ } catch { /* best-effort diagnostics */ }
45
67
  }
46
- return { content, durationMs: Date.now() - t0 };
47
68
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { buildReflectionPrompt } from './reflection-prompt.js';
12
+ import { normalizeTokenUsage } from '../llm/usage-accounting.js';
12
13
 
13
14
  /**
14
15
  * @param {{
@@ -19,22 +20,42 @@ import { buildReflectionPrompt } from './reflection-prompt.js';
19
20
  * assistantText?: string,
20
21
  * language?: string,
21
22
  * signal?: AbortSignal,
23
+ * onComplete?: (diagnostic: object) => void,
22
24
  * }} p
23
- * @returns {Promise<{ content: string, durationMs: number }>}
25
+ * @returns {Promise<{ content: string, durationMs: number, usage: object }>}
24
26
  */
25
- export async function runT2Reflection({ adapter, model, originalUserMsg, toolPairs, assistantText, language, signal }) {
27
+ export async function runT2Reflection({ adapter, model, originalUserMsg, toolPairs, assistantText, language, signal, onComplete }) {
26
28
  const t0 = Date.now();
27
29
  const prompt = buildReflectionPrompt({ originalUserMsg, toolPairs, assistantText, language });
28
- const result = await adapter.call({
29
- model,
30
- system: prompt,
31
- messages: [{ role: 'user', content: 'Produce the reflection now.' }],
32
- maxTokens: 2048,
33
- signal,
34
- });
35
- const content = (result && typeof result.text === 'string') ? result.text.trim() : '';
36
- if (!content) {
37
- throw new Error('T2 reflection returned empty content');
30
+ let result;
31
+ let failure;
32
+ try {
33
+ result = await adapter.call({
34
+ model,
35
+ system: prompt,
36
+ messages: [{ role: 'user', content: 'Produce the reflection now.' }],
37
+ maxTokens: 2048,
38
+ signal,
39
+ });
40
+ const content = (result && typeof result.text === 'string') ? result.text.trim() : '';
41
+ if (!content) {
42
+ throw new Error('T2 reflection returned empty content');
43
+ }
44
+ return { content, durationMs: Date.now() - t0, usage: normalizeTokenUsage(result?.usage) };
45
+ } catch (error) {
46
+ failure = error;
47
+ throw error;
48
+ } finally {
49
+ // Diagnostic only; the adapter owns billing/usage accounting. Never
50
+ // double-charge it or fail a completed reflection on a logging error.
51
+ try {
52
+ onComplete?.({
53
+ status: failure ? 'error' : 'ready',
54
+ durationMs: Date.now() - t0,
55
+ usage: normalizeTokenUsage(result?.usage),
56
+ usageReported: !!result?.usage,
57
+ ...(failure ? { error: String(failure.message || failure).slice(0, 512) } : {}),
58
+ });
59
+ } catch { /* best-effort diagnostics */ }
38
60
  }
39
- return { content, durationMs: Date.now() - t0 };
40
61
  }
@@ -26,6 +26,7 @@ export const ALWAYS_VISIBLE_TOOL_NAMES = Object.freeze([
26
26
  export const BACKGROUND_TASK_TOOL_NAMES = Object.freeze([
27
27
  'ListTasks',
28
28
  'ReadTaskLog',
29
+ 'WaitTask',
29
30
  'CancelTask',
30
31
  ]);
31
32
 
@@ -44,6 +45,7 @@ export const CONDITIONAL_BUILTIN_TOOL_NAMES = new Set([
44
45
  'ApplyPatch',
45
46
  'ListTasks',
46
47
  'ReadTaskLog',
48
+ 'WaitTask',
47
49
  'CancelTask',
48
50
  'SpawnAgent',
49
51
  'UpdateAgent',
@@ -159,6 +159,8 @@ export function checkBudget(agent, now = Date.now()) {
159
159
  export function budgetExceededResult(agent, reason) {
160
160
  return {
161
161
  status: 'budget_exceeded',
162
+ outcome: 'incomplete',
163
+ complete: false,
162
164
  partial_output: agent.partial_output || agent.result || '',
163
165
  reason,
164
166
  usage: { ...(agent.usage || {}) },
@@ -168,7 +170,8 @@ export function budgetExceededResult(agent, reason) {
168
170
  /**
169
171
  * Apply an incremental delta to an agent's usage, then check budget.
170
172
  * If exceeded: abort the agent's signal, set result to the budget envelope,
171
- * flip status to 'completed', and return the envelope. Otherwise returns null.
173
+ * end the lifecycle as 'completed' with an explicitly incomplete outcome, and
174
+ * return the envelope. Otherwise returns null.
172
175
  *
173
176
  * Call this at each turn boundary inside the sub-agent's execution loop.
174
177
  *
@@ -166,7 +166,7 @@ Guidelines:
166
166
  },
167
167
  required: ['command'],
168
168
  },
169
- errorOutput: null,
169
+ errorOutput: 'json-error-envelope',
170
170
  // Foreground Bash owns a bounded timeout and process-tree cleanup state
171
171
  // machine. A second ToolRegistry timer can preempt that cleanup and turn an
172
172
  // owned exit 124 into a fatal orphan, so it must stay disabled for this tool.
@@ -217,7 +217,7 @@ Guidelines:
217
217
  threadId: ctx.threadId || 'main',
218
218
  },
219
219
  });
220
- return `Started background task ${task.id}.\nWorking directory: ${cwd}\nStatus: ${task.status}\nLog: ${task.log?.path || ''}\nThe task is detached from this turn. Use ListTasks, ReadTaskLog, or CancelTask to inspect or control it.`;
220
+ return `Started background task ${task.id}.\nWorking directory: ${cwd}\nStatus: ${task.status}\nLog: ${task.log?.path || ''}\nThe task is detached from this turn. Use WaitTask for bounded waiting, ReadTaskLog for output, ListTasks for active status, or CancelTask when cancellation is intended.`;
221
221
  } catch (err) {
222
222
  throw new Error(err?.message || String(err));
223
223
  }
@@ -258,10 +258,35 @@ Guidelines:
258
258
  }
259
259
 
260
260
  const output = parts.join('\n');
261
- if (result.exitCode !== 0) {
262
- return `Exit code: ${result.exitCode}\nWorking directory: ${cwd}\n${output}`;
261
+ if (result.exitCode !== 0 || result.timedOut || result.terminationError) {
262
+ const failureType = result.timedOut
263
+ ? (result.terminationError ? 'timeout_unconfirmed' : 'timeout_confirmed')
264
+ : result.terminationError || result.exitCode === null ? 'exit_unconfirmed' : 'exit_nonzero';
265
+ return JSON.stringify({
266
+ error: result.timedOut
267
+ ? `Command timed out after ${timeout}ms${result.terminationError ? '; process-tree termination was not confirmed' : ''}.`
268
+ : `Command exited with code ${result.exitCode}.`,
269
+ code: `bash_${failureType}`,
270
+ errorEffect: 'unknown',
271
+ failureType,
272
+ exitCode: Number.isInteger(result.exitCode) ? result.exitCode : null,
273
+ status: `Exit code: ${result.exitCode}`,
274
+ workingDirectory: `Working directory: ${cwd}`,
275
+ timedOut: result.timedOut === true,
276
+ terminationConfirmed: result.timedOut ? !result.terminationError : null,
277
+ terminationError: result.terminationError || null,
278
+ cwd,
279
+ output: output || '(no output)',
280
+ replaySafe: false,
281
+ });
263
282
  }
264
- return output || '(no output)';
283
+ // A successful command may print application JSON containing `error`.
284
+ // Do not let it masquerade as a tool-level failure envelope.
285
+ let successfulOutput = output || '(no output)';
286
+ try {
287
+ if (JSON.parse(successfulOutput)?.error) successfulOutput = `Exit code: 0\n${successfulOutput}`;
288
+ } catch { /* ordinary command output */ }
289
+ return successfulOutput;
265
290
  } catch (err) {
266
291
  err.message = `${err.message} (working directory: ${cwd})`;
267
292
  if (err?.name === 'ProcessTerminationError') err.fatalToolTimeout = true;
@@ -22,9 +22,12 @@ export default defineTool({
22
22
  isReadOnly: () => false,
23
23
  async execute(input = {}, ctx = {}) {
24
24
  if (!ctx.taskManager) return JSON.stringify({ error: 'task manager unavailable' });
25
+ if (ctx.sessionId && input.sessionId && input.sessionId !== ctx.sessionId) {
26
+ return JSON.stringify({ error: 'Task access is limited to the current Session', errorEffect: 'none' });
27
+ }
25
28
  const taskId = input.taskId;
26
29
  if (!taskId) return JSON.stringify({ error: 'taskId is required' });
27
30
  const sessionId = input.sessionId || ctx.sessionId || 'default';
28
- return JSON.stringify(ctx.taskManager.cancelTask(sessionId, taskId), null, 2);
31
+ return JSON.stringify(ctx.taskManager.cancelTask(sessionId, taskId, ctx.currentVpId || null), null, 2);
29
32
  },
30
33
  });
@@ -12,6 +12,7 @@ import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
12
12
  import { isTerminalAgentStatus, STATUS } from '../sub-agent/status.js';
13
13
  import { consumeNotificationForAgent, enqueueTerminalNotification } from '../sub-agent/notifications.js';
14
14
  import { snapshotLiveness } from '../sub-agent/liveness.js';
15
+ import { describeAgentLifecycle, describeAgentOutcome } from '../sub-agent/outcome.js';
15
16
 
16
17
  export default defineTool({
17
18
  name: 'CloseAgent',
@@ -73,7 +74,7 @@ Do NOT end your turn silently right after CloseAgent.`,
73
74
  return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent not found: ${agent_id}` });
74
75
  }
75
76
 
76
- if (result) {
77
+ if (result && !isTerminalAgentStatus(agent.status)) {
77
78
  agent.result = result;
78
79
  }
79
80
 
@@ -86,7 +87,7 @@ Do NOT end your turn silently right after CloseAgent.`,
86
87
 
87
88
  const finalResult = (typeof agent.result === 'string' && agent.result)
88
89
  ? agent.result
89
- : (agent.lastResult || '');
90
+ : (agent.result?.partial_output || agent.lastResult || '');
90
91
 
91
92
  // If the agent had already gone terminal (e.g. failed) before we got
92
93
  // here, preserve that status; otherwise mark closed. Either way drain
@@ -139,6 +140,10 @@ Do NOT end your turn silently right after CloseAgent.`,
139
140
  agentId: agent_id,
140
141
  name: agent.name,
141
142
  status: agent.status,
143
+ lifecycle: describeAgentLifecycle(agent),
144
+ outcome: describeAgentOutcome(agent),
145
+ incomplete: !describeAgentOutcome(agent).complete,
146
+ final_report: agent.result?.final_report || agent.finalReport || null,
142
147
  result: finalResult,
143
148
  outputFile: agent.outputFile || null,
144
149
  liveness: snapshotLiveness(agent.liveness),
@@ -32,7 +32,8 @@ development without file conflicts. Useful for:
32
32
  - Parallel feature development
33
33
 
34
34
  The worktree is created in .yeaft/worktrees/ with a new branch based on HEAD.
35
- Returns the worktree path and branch name.`,
35
+ Returns the worktree path and branch name. Does NOT switch the execution cwd.
36
+ Use the returned path explicitly for Bash/GitRead cwd, file paths, and child-agent cwd.`,
36
37
  zh: `创建一个独立的 git worktree 用于开发。
37
38
 
38
39
  创建带独立分支的新 git worktree,允许并行开发无文件冲突。适用于:
@@ -40,7 +41,7 @@ Returns the worktree path and branch name.`,
40
41
  - 合并前隔离测试改动
41
42
  - 并行功能开发
42
43
 
43
- Worktree 创建在 .yeaft/worktrees/ 中,基于 HEAD 创建新分支。返回 worktree 路径和分支名。`
44
+ Worktree 创建在 .yeaft/worktrees/ 中,基于 HEAD 创建新分支。返回 worktree 路径和分支名,不切换执行 cwd;后续 Bash/GitRead、文件路径及子 Agent cwd 必须显式使用返回路径。`
44
45
  },
45
46
  parameters: {
46
47
  type: 'object',
@@ -107,6 +108,9 @@ Worktree 创建在 .yeaft/worktrees/ 中,基于 HEAD 创建新分支。返回
107
108
  branch: branchName,
108
109
  baseRef,
109
110
  name,
111
+ executionCwd: resolve(cwd),
112
+ cwdChanged: false,
113
+ nextStep: 'Use path explicitly as Bash/GitRead/SpawnAgent cwd or as the base for absolute file paths.',
110
114
  message: `Created worktree "${name}" at ${worktreeDir} on branch ${branchName}`,
111
115
  });
112
116
  } catch (err) {
@@ -1,4 +1,5 @@
1
1
  import { resolve } from 'node:path';
2
+ import { realpath } from 'node:fs/promises';
2
3
  import { defineTool } from './types.js';
3
4
  import { runProcess } from './process-runner.js';
4
5
 
@@ -58,9 +59,9 @@ function errorOutput(message, operation) {
58
59
  // arguments belonging to another operation.
59
60
  function normalizeInput(input) {
60
61
  const result = { ...input };
61
- for (const key of ['base', 'head', 'revision', 'paths', 'limit']) {
62
+ for (const key of ['cwd', 'base', 'head', 'revision', 'paths', 'limit']) {
62
63
  if (result[key] === null || result[key] === undefined
63
- || (['base', 'head', 'revision'].includes(key) && result[key] === '')
64
+ || (['cwd', 'base', 'head', 'revision'].includes(key) && result[key] === '')
64
65
  || (key === 'paths' && Array.isArray(result[key]) && result[key].length === 0)
65
66
  || (key === 'limit' && result.operation !== 'log' && result[key] === DEFAULT_LOG_LIMIT)) {
66
67
  delete result[key];
@@ -101,18 +102,22 @@ export function buildGitReadArgs(input) {
101
102
 
102
103
  input = normalizeInput(input);
103
104
  const { operation } = input;
105
+ if (input.cwd !== undefined && (typeof input.cwd !== 'string'
106
+ || input.cwd.length > MAX_VALUE_LENGTH || /[\0\r\n]/u.test(input.cwd))) {
107
+ return { error: 'cwd must be a path of at most 4096 characters without NUL or newlines' };
108
+ }
104
109
  if (!['status', 'diff', 'show', 'log'].includes(operation)) {
105
110
  return { error: 'operation must be one of: status, diff, show, log' };
106
111
  }
107
112
 
108
113
  if (operation === 'status') {
109
- const error = unexpectedInput(input, new Set(['operation']));
114
+ const error = unexpectedInput(input, new Set(['operation', 'cwd']));
110
115
  if (error) return { error };
111
116
  return { args: [...COMMON_ARGS, 'status', '--short', '--branch', '--untracked-files=normal', '--ignore-submodules=all'] };
112
117
  }
113
118
 
114
119
  if (operation === 'diff') {
115
- const error = unexpectedInput(input, new Set(['operation', 'base', 'head', 'paths']))
120
+ const error = unexpectedInput(input, new Set(['operation', 'cwd', 'base', 'head', 'paths']))
116
121
  || validatePaths(input.paths)
117
122
  || (input.base !== undefined ? validateValue(input.base, 'base') : null)
118
123
  || (input.head !== undefined ? validateValue(input.head, 'head') : null);
@@ -133,7 +138,7 @@ export function buildGitReadArgs(input) {
133
138
  }
134
139
 
135
140
  if (operation === 'show') {
136
- const error = unexpectedInput(input, new Set(['operation', 'revision', 'paths']))
141
+ const error = unexpectedInput(input, new Set(['operation', 'cwd', 'revision', 'paths']))
137
142
  || validatePaths(input.paths)
138
143
  || (input.revision !== undefined ? validateValue(input.revision, 'revision') : null);
139
144
  if (error) return { error };
@@ -146,7 +151,7 @@ export function buildGitReadArgs(input) {
146
151
  };
147
152
  }
148
153
 
149
- const error = unexpectedInput(input, new Set(['operation', 'revision', 'limit']))
154
+ const error = unexpectedInput(input, new Set(['operation', 'cwd', 'revision', 'limit']))
150
155
  || (input.revision !== undefined ? validateValue(input.revision, 'revision') : null);
151
156
  if (error) return { error };
152
157
  const limit = input.limit === undefined ? DEFAULT_LOG_LIMIT : input.limit;
@@ -170,13 +175,15 @@ function takeUtf8(text, maxBytes) {
170
175
  return buffer.subarray(0, end).toString('utf8');
171
176
  }
172
177
 
173
- function formatSuccess(operation, result) {
178
+ function formatSuccess(operation, result, resolvedCwd) {
174
179
  const sections = [];
175
180
  if (result.stdout) sections.push(`STDOUT:\n${result.stdout}`);
176
181
  if (result.stderr) sections.push(`STDERR:\n${result.stderr}`);
177
182
  const body = sections.join('\n');
178
183
  const baseHeader = truncated => [
179
184
  `operation: ${operation}`,
185
+ `resolvedCwd: ${JSON.stringify(takeUtf8(resolvedCwd ?? '', 1024))}`,
186
+ ...(Buffer.byteLength(resolvedCwd || '', 'utf8') > 1024 ? ['cwdTruncated: true'] : []),
180
187
  `exitCode: ${result.code}`,
181
188
  'timedOut: false',
182
189
  `truncated: ${truncated}`,
@@ -226,7 +233,7 @@ function boundedFailure(fields, output = '') {
226
233
 
227
234
  export function formatGitReadResult(operation, result, { resolvedCwd, stage = operation } = {}) {
228
235
  if (result.code === 0 && !result.timedOut && !result.truncated && !result.terminationError) {
229
- return formatSuccess(operation, result);
236
+ return formatSuccess(operation, result, resolvedCwd);
230
237
  }
231
238
  const code = result.terminationError ? 'git_exit_unconfirmed'
232
239
  : result.timedOut ? 'git_timeout'
@@ -260,6 +267,8 @@ Supported operations are intentionally limited:
260
267
  - show: exactly one commit (HEAD by default; tags are peeled to commits), optionally narrowed by paths. Ranges and non-commit objects are rejected.
261
268
  - log: a compact bounded commit list (20 entries by default, maximum 50).
262
269
 
270
+ cwd defaults to the execution directory; an override must resolve to the same repository (including linked worktrees). Creation does not switch cwd. Every result identifies the resolved directory.
271
+
263
272
  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 truncation. Git failures, timeouts and capture-limit stops return an error with bounded diagnostics.`,
264
273
  zh: `有界读取本地 Git 证据,不使用 shell,也不访问网络。
265
274
 
@@ -269,12 +278,15 @@ GitRead never fetches, writes Git state, or creates worktrees. It disables pager
269
278
  - show:显示唯一提交(默认 HEAD;tag 解析到 commit),可用 paths 缩小范围。拒绝范围及非 commit 对象。
270
279
  - log:紧凑且有界的提交列表(默认 20 条,最多 50 条)。
271
280
 
281
+ cwd 默认执行目录;指定目录必须属于同一仓库(可为关联 worktree)。创建 worktree 不切换 cwd,需显式指定。结果始终标明实际目录。
282
+
272
283
  GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、external diff、textconv、内容 filter、optional locks、fsmonitor 和子模块遍历。LFS 等 filter 文件显示原始工作区字节,子模块状态需单独检查。拒绝以 "-" 开头的 revision 与路径;结果明确标识是否截断;Git 失败、超时及捕获上限终止返回含有界诊断的错误。`,
273
284
  },
274
285
  parameters: {
275
286
  type: 'object',
276
287
  additionalProperties: false,
277
288
  properties: {
289
+ cwd: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'Optional repository/worktree directory; relative to execution cwd. Must belong to the same repository. Empty means execution cwd.' },
278
290
  operation: { type: 'string', enum: ['status', 'diff', 'show', 'log'] },
279
291
  base: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'diff only; empty/omitted means working-tree changes against HEAD' },
280
292
  head: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'diff only; requires base, empty/omitted defaults to HEAD' },
@@ -296,11 +308,16 @@ GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、e
296
308
  const built = buildGitReadArgs(input);
297
309
  if (built.error) return errorOutput(built.error, input?.operation);
298
310
  input = normalizeInput(input);
299
- const cwd = resolve(ctx?.cwd || process.cwd());
311
+ const contextCwd = resolve(ctx?.cwd || process.cwd());
312
+ let cwd = resolve(contextCwd, input.cwd || '.');
300
313
  let stage = input.operation;
301
314
  try {
302
315
  const run = ctx?.[RUN_PROCESS_OVERRIDE] || runProcess;
303
316
  const startedAt = Date.now();
317
+ // Ambient Git location overrides must not redirect evidence elsewhere.
318
+ const env = { ...process.env };
319
+ for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE',
320
+ 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE']) delete env[key];
304
321
  const options = {
305
322
  cwd,
306
323
  signal: ctx?.signal,
@@ -308,7 +325,7 @@ GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、e
308
325
  maxBytes: MAX_CAPTURE_BYTES,
309
326
  requireExitConfirmation: true,
310
327
  env: {
311
- ...process.env,
328
+ ...env,
312
329
  GIT_PAGER: 'cat',
313
330
  PAGER: 'cat',
314
331
  GIT_EXTERNAL_DIFF: '',
@@ -318,13 +335,38 @@ GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、e
318
335
  NO_COLOR: '1',
319
336
  },
320
337
  };
321
- const read = (command, args) => {
338
+ const read = (command, args, readCwd = cwd) => {
322
339
  const remaining = TIMEOUT_MS - (Date.now() - startedAt);
323
340
  if (remaining <= 0) {
324
341
  throw Object.assign(new Error('Git read timed out'), { code: 'git_timeout', timedOut: true });
325
342
  }
326
- return run(command, args, { ...options, timeoutMs: remaining });
343
+ return run(command, args, { ...options, cwd: readCwd, timeoutMs: remaining });
327
344
  };
345
+ if (cwd !== contextCwd) {
346
+ stage = 'resolve_cwd';
347
+ cwd = await realpath(cwd);
348
+ const commonDirectory = async directory => {
349
+ const result = await read('git', [
350
+ ...COMMON_ARGS, 'rev-parse', '--git-common-dir',
351
+ ], directory);
352
+ if (result.code !== 0 || result.truncated || result.timedOut || result.terminationError) {
353
+ throw Object.assign(new Error('Cannot resolve Git repository identity'), { result });
354
+ }
355
+ const value = result.stdout.replace(/\r?\n$/, '');
356
+ if (!value) throw new Error('Missing Git common directory');
357
+ // --git-common-dir may be relative on older Git. Avoid
358
+ // --path-format (unsupported versions echo it as a path).
359
+ return realpath(resolve(directory, value));
360
+ };
361
+ const owner = await commonDirectory(contextCwd);
362
+ if (await commonDirectory(cwd) !== owner) {
363
+ return boundedFailure({
364
+ error: 'cwd must belong to the execution repository or one of its linked worktrees',
365
+ errorEffect: 'none', code: 'git_cwd_outside_repository', operation: input.operation,
366
+ stage, resolvedCwd: cwd,
367
+ });
368
+ }
369
+ }
328
370
  // Only these operations inspect worktree bytes. Object-only reads must
329
371
  // not pay for filter discovery or fail on an unusable worktree filter.
330
372
  const readsWorktree = input.operation === 'status'
@@ -43,6 +43,7 @@ import diskUsage from './disk-usage.js';
43
43
  import applyPatch from './apply-patch.js';
44
44
  import listTasks from './list-tasks.js';
45
45
  import readTaskLog from './read-task-log.js';
46
+ import waitTask from './wait-task.js';
46
47
  import cancelTask from './cancel-task.js';
47
48
 
48
49
  // --- P1 Agent tools ---
@@ -109,6 +110,7 @@ export const allTools = [
109
110
  applyPatch,
110
111
  listTasks,
111
112
  readTaskLog,
113
+ waitTask,
112
114
  cancelTask,
113
115
 
114
116
  // P1 Agent
@@ -4,6 +4,7 @@ import { defineTool } from './types.js';
4
4
  import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
5
5
  import { isTerminalAgentStatus, STATUS } from '../sub-agent/status.js';
6
6
  import { diagnoseAgentLiveness } from '../sub-agent/liveness.js';
7
+ import { describeAgentLifecycle, describeAgentOutcome } from '../sub-agent/outcome.js';
7
8
 
8
9
  function nextStepFor(agent, liveness) {
9
10
  if (isTerminalAgentStatus(agent.status)) {
@@ -69,6 +70,8 @@ By default terminal agents are omitted. Pass include_closed=true to include them
69
70
  id,
70
71
  name: agent.name,
71
72
  status: agent.status,
73
+ lifecycle: describeAgentLifecycle(agent),
74
+ outcome: describeAgentOutcome(agent),
72
75
  task: typeof agent.task === 'string' ? agent.task.slice(0, 200) : null,
73
76
  outputFile: agent.outputFile || null,
74
77
  activity: {
@@ -44,14 +44,21 @@ export default defineTool({
44
44
  cacheWithinQuery: false,
45
45
  async execute(input = {}, ctx = {}) {
46
46
  if (!ctx.taskManager) return JSON.stringify({ error: 'task manager unavailable' });
47
+ if (ctx.sessionId && input.sessionId && input.sessionId !== ctx.sessionId) {
48
+ return JSON.stringify({ error: 'Task access is limited to the current Session', errorEffect: 'none' });
49
+ }
47
50
  const sessionId = input.sessionId || ctx.sessionId || null;
48
- const tasks = ctx.taskManager.listActiveTasks(sessionId)
51
+ const ownerVpId = ctx.currentVpId || null;
52
+ const activeTasks = ownerVpId
53
+ ? ctx.taskManager.listActiveTasks(sessionId, ownerVpId)
54
+ : ctx.taskManager.listActiveTasks(sessionId);
55
+ const tasks = activeTasks
49
56
  .map(compactTaskSnapshot)
50
57
  .filter(Boolean);
51
58
  return JSON.stringify({
52
59
  tasks,
53
60
  next_steps: tasks.length > 0
54
- ? 'ReadTaskLog reads output by task id. For sub_agent tasks use WaitAgent/CloseAgent with agentId to collect/cancel; for shell tasks use CancelTask only when cancellation is intended. cancelPending is not proof the process has stopped.'
61
+ ? 'WaitTask waits by task id without reading the log. ReadTaskLog reads output by task id. For sub_agent tasks use WaitAgent/CloseAgent with agentId to collect/cancel; for shell tasks use CancelTask only when cancellation is intended. cancelPending is not proof the process has stopped.'
55
62
  : 'No active tasks require follow-up.',
56
63
  });
57
64
  },
@@ -27,6 +27,9 @@ export default defineTool({
27
27
  duplicateCallPolicy: () => 'allow',
28
28
  async execute(input = {}, ctx = {}) {
29
29
  if (!ctx.taskManager) return JSON.stringify({ error: 'task manager unavailable' });
30
+ if (ctx.sessionId && input.sessionId && input.sessionId !== ctx.sessionId) {
31
+ return JSON.stringify({ error: 'Task access is limited to the current Session', errorEffect: 'none' });
32
+ }
30
33
  const taskId = input.taskId;
31
34
  if (!taskId) return JSON.stringify({ error: 'taskId is required' });
32
35
  const sessionId = input.sessionId || ctx.sessionId || 'default';
@@ -35,7 +38,7 @@ export default defineTool({
35
38
  offset: input.offset,
36
39
  maxBytes: input.maxBytes,
37
40
  tail: typeof input.tail === 'boolean' ? input.tail : !hasOffset,
38
- });
39
- return JSON.stringify(result, null, 2);
41
+ }, ctx.currentVpId || null);
42
+ return JSON.stringify(result || { error: `Unknown task: ${taskId}` }, null, 2);
40
43
  },
41
44
  });