@wolido/async-subagent-isolation 1.6.2 → 1.7.0

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.
package/README.en.md CHANGED
@@ -241,13 +241,13 @@ User runs /subagent-result <taskId> to read the full output
241
241
 
242
242
  ## Example agents
243
243
 
244
- The GitHub repo ships three ready-to-reference agents in [`examples/pi/agent/agents/`](https://github.com/Wolido/subagent-isolation/tree/main/examples/pi/agent/agents):
244
+ The GitHub repo ships three ready-to-reference agents in [`examples/pi/agent/agents/`](https://github.com/Wolido/async-subagent-isolation/tree/main/examples/pi/agent/agents):
245
245
 
246
246
  | Agent | Purpose | Tools | Skill |
247
247
  |-------|---------|-------|-------|
248
- | [`coder`](https://github.com/Wolido/subagent-isolation/blob/main/examples/pi/agent/agents/coder.md) | Write, modify, and validate code | `read, write, edit, bash, grep, find, ls` | `systematic-debugging` |
249
- | [`reviewer`](https://github.com/Wolido/subagent-isolation/blob/main/examples/pi/agent/agents/reviewer.md) | Read-only review with actionable feedback | `read, grep, find, ls` | _(none)_ |
250
- | [`writer`](https://github.com/Wolido/subagent-isolation/blob/main/examples/pi/agent/agents/writer.md) | Write docs, READMEs, commit messages | `read, write, edit, grep, find, ls` | `writing-clearly-and-concisely` |
248
+ | [`coder`](https://github.com/Wolido/async-subagent-isolation/blob/main/examples/pi/agent/agents/coder.md) | Write, modify, and validate code | `read, write, edit, bash, grep, find, ls` | `systematic-debugging` |
249
+ | [`reviewer`](https://github.com/Wolido/async-subagent-isolation/blob/main/examples/pi/agent/agents/reviewer.md) | Read-only review with actionable feedback | `read, grep, find, ls` | _(none)_ |
250
+ | [`writer`](https://github.com/Wolido/async-subagent-isolation/blob/main/examples/pi/agent/agents/writer.md) | Write docs, READMEs, commit messages | `read, write, edit, grep, find, ls` | `writing-clearly-and-concisely` |
251
251
 
252
252
  Copy the ones you need into `~/.pi/agent/agents/` (user-scoped) or `.pi/agents/` (project-scoped; project overrides user on name collisions). Feel free to modify them or create your own. After modifying or adding agent files, run `/reload` to refresh the subagent roster injected into the main agent's prompt (see "Configuration management").
253
253
 
package/README.md CHANGED
@@ -241,13 +241,13 @@ Dispatched coder. taskId: 01912345-6789-7abc-8def-0123456789ab
241
241
 
242
242
  ## 示例 agents
243
243
 
244
- 仓库 [`examples/pi/agent/agents/`](https://github.com/Wolido/subagent-isolation/tree/main/examples/pi/agent/agents) 提供三个可直接参考的 agent:
244
+ 仓库 [`examples/pi/agent/agents/`](https://github.com/Wolido/async-subagent-isolation/tree/main/examples/pi/agent/agents) 提供三个可直接参考的 agent:
245
245
 
246
246
  | Agent | 作用 | 可用工具 | 加载的 skill |
247
247
  |-------|------|----------|-------------|
248
- | [`coder`](https://github.com/Wolido/subagent-isolation/blob/main/examples/pi/agent/agents/coder.md) | 写代码、改代码、跑验证 | `read, write, edit, bash, grep, find, ls` | `systematic-debugging` |
249
- | [`reviewer`](https://github.com/Wolido/subagent-isolation/blob/main/examples/pi/agent/agents/reviewer.md) | 只读评审,输出可操作的反馈 | `read, grep, find, ls` | _(无)_ |
250
- | [`writer`](https://github.com/Wolido/subagent-isolation/blob/main/examples/pi/agent/agents/writer.md) | 写文档、改 README、生成 commit message | `read, write, edit, grep, find, ls` | `writing-clearly-and-concisely` |
248
+ | [`coder`](https://github.com/Wolido/async-subagent-isolation/blob/main/examples/pi/agent/agents/coder.md) | 写代码、改代码、跑验证 | `read, write, edit, bash, grep, find, ls` | `systematic-debugging` |
249
+ | [`reviewer`](https://github.com/Wolido/async-subagent-isolation/blob/main/examples/pi/agent/agents/reviewer.md) | 只读评审,输出可操作的反馈 | `read, grep, find, ls` | _(无)_ |
250
+ | [`writer`](https://github.com/Wolido/async-subagent-isolation/blob/main/examples/pi/agent/agents/writer.md) | 写文档、改 README、生成 commit message | `read, write, edit, grep, find, ls` | `writing-clearly-and-concisely` |
251
251
 
252
252
  复制到 `~/.pi/agent/agents/`(用户级)或 `.pi/agents/`(项目级,同名时 project 覆盖 user)即可使用,可按需修改或新建。修改或新建 agent 文件后运行 `/reload`,刷新注入主 agent 提示词的子 agent 清单(见“配置管理”一节)。
253
253
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wolido/async-subagent-isolation",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "description": "Fix context rot and context pollution in long AI agent sessions. Subagents run in isolated processes; the main agent stays read-only and context stays clean.",
5
5
  "license": "MIT",
6
6
  "author": "Wolido",
package/src/index.ts CHANGED
@@ -161,6 +161,213 @@ function isDirectory(p: string): boolean {
161
161
  }
162
162
  }
163
163
 
164
+ // ===== Subagent spawn preflight (cwd / command validation) =====
165
+
166
+ export type PreflightCode = "CWD_MISSING" | "CWD_NOT_DIR" | "EXEC_MISSING" | "CWD_INACCESSIBLE";
167
+
168
+ export interface PreflightResult {
169
+ ok: boolean;
170
+ code?: PreflightCode | null;
171
+ message?: string;
172
+ fields?: {
173
+ command?: string;
174
+ cwd?: string;
175
+ cwdExists?: boolean;
176
+ source?: "param" | "session";
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Normalize the requested subagent cwd. Lenient on spelling, strict on
182
+ * existence (existence is checked separately by preflightSpawn):
183
+ * - "~/x" -> homedir/x, "~" -> homedir (injected for testability)
184
+ * - relative -> resolved against sessionCwd (the session cwd, NOT process.cwd())
185
+ * - absolute -> path.normalize
186
+ * - no paramCwd (or empty / whitespace-only) -> sessionCwd verbatim (byte-identical to legacy behavior)
187
+ */
188
+ export function resolveAgentCwd(opts: {
189
+ paramCwd?: string;
190
+ sessionCwd: string;
191
+ homedir: string;
192
+ }): { cwd: string; source: "param" | "session" } {
193
+ const { paramCwd, sessionCwd, homedir } = opts;
194
+ // 空串与纯空白一律视同"未传参数":不谎报 source=param,也不把空白拼成真实目录名。
195
+ if (paramCwd === undefined || paramCwd.trim() === "") return { cwd: sessionCwd, source: "session" };
196
+ let cwd: string;
197
+ if (paramCwd === "~") cwd = homedir;
198
+ else if (paramCwd.startsWith("~/")) cwd = path.join(homedir, paramCwd.slice(2));
199
+ else if (path.isAbsolute(paramCwd)) cwd = path.normalize(paramCwd);
200
+ else cwd = path.resolve(sessionCwd, paramCwd);
201
+ return { cwd, source: "param" };
202
+ }
203
+
204
+ /** Facts gathered by (a)sync fs probes; decision itself is pure. */
205
+ interface PreflightFacts {
206
+ command: string;
207
+ cwd: string;
208
+ source?: "param" | "session";
209
+ /** true = 任一 cwd 探针(checkExists/isDir)抛错。"探测失败"与"探测为否"
210
+ * 是两种不同事实,必须独立记录——压成同一个(cwdErrno=undefined)正是
211
+ * ENOENT-vs-EACCES 误判与 fail-open 的根因。 */
212
+ cwdProbeFailed?: boolean;
213
+ /** errno of a failed cwd probe, recorded verbatim (e.g. "ENOENT", "EACCES");
214
+ * "EUNKNOWN" when the throw carried no `.code` — 可辨识占位值,不冒充任何
215
+ * 真实 errno。 */
216
+ cwdErrno?: string;
217
+ cwdExists: boolean;
218
+ /** undefined when the isDir probe was not reached or itself threw
219
+ * (未探测/探测失败 ≠ 探针确定为 false)。 */
220
+ cwdIsDir?: boolean;
221
+ /** undefined when the exec check does not apply (non-absolute command). */
222
+ executable?: boolean;
223
+ /** errno of the failed exec probe, surfaced verbatim in the EXEC_MISSING message. */
224
+ execErrno?: string;
225
+ }
226
+
227
+ function decidePreflight(f: PreflightFacts): PreflightResult {
228
+ // 判定优先级是刻意的:cwd 先判、command 后判——两者同时异常时只报 cwd。
229
+ const fields = { command: f.command, cwd: f.cwd, source: f.source };
230
+ if (f.cwdProbeFailed === true) {
231
+ // 探针抛错 = "我无法判定",绝不能读作"探针确定为否"。errno 分类(全局
232
+ // 仅此一处):ENOENT/ENOTDIR 带原生语义(路径上无此物 / 父级分量是文件)
233
+ // ——checkExists 先 true、随后 isDir 抛 ENOENT 属两次探测之间目录被删
234
+ // (TOCTOU),此时"不存在"才是诚实且可行动的答案;其余 errno
235
+ // (EACCES/ELOOP/ENAMETOOLONG/…)与"无 errno 的抛错"(记作 EUNKNOWN)=
236
+ // 无法判定存在性,绝不谎称"不存在"。
237
+ if (f.cwdErrno === "ENOENT" || f.cwdErrno === "ENOTDIR") {
238
+ return {
239
+ ok: false,
240
+ code: "CWD_MISSING",
241
+ message: `[CWD_MISSING] 子代理工作目录不存在: ${f.cwd}(来源: ${f.source === "session" ? "session cwd" : "agent cwd 参数"})。不会自动创建该目录;如确需使用,请先创建该目录后重试。`,
242
+ fields: { ...fields, cwdExists: false },
243
+ };
244
+ }
245
+ // CWD_INACCESSIBLE 语义 = "目录存在但无法访问",故 fields.cwdExists 恒为 true。
246
+ return {
247
+ ok: false,
248
+ code: "CWD_INACCESSIBLE",
249
+ message: `[CWD_INACCESSIBLE] 子代理工作目录存在但无法访问: ${f.cwd}(errno=${f.cwdErrno ?? "EUNKNOWN"})。请检查目录权限/链接环路后重试。`,
250
+ fields: { ...fields, cwdExists: true },
251
+ };
252
+ }
253
+ if (!f.cwdExists) {
254
+ // checkExists 未抛错且返回 false = 探针确定"不存在"(非探测失败)。
255
+ return {
256
+ ok: false,
257
+ code: "CWD_MISSING",
258
+ message: `[CWD_MISSING] 子代理工作目录不存在: ${f.cwd}(来源: ${f.source === "session" ? "session cwd" : "agent cwd 参数"})。不会自动创建该目录;如确需使用,请先创建该目录后重试。`,
259
+ fields: { ...fields, cwdExists: false },
260
+ };
261
+ }
262
+ if (f.cwdIsDir === false) {
263
+ return {
264
+ ok: false,
265
+ code: "CWD_NOT_DIR",
266
+ message: `[CWD_NOT_DIR] 子代理工作目录已存在但不是目录: ${f.cwd}。请改为传入一个目录路径。`,
267
+ fields: { ...fields, cwdExists: true },
268
+ };
269
+ }
270
+ if (f.executable === false) {
271
+ return {
272
+ ok: false,
273
+ code: "EXEC_MISSING",
274
+ message: `[EXEC_MISSING] 子代理启动命令不可执行: ${f.command}(errno=${f.execErrno ?? "EUNKNOWN"})。长生命周期 pi 进程持有的 node 路径可能已因 Homebrew 升级被删除,请重启 pi 后重试。`,
275
+ fields: { ...fields, cwdExists: true },
276
+ };
277
+ }
278
+ // 正向判定:ok:true 只在 cwdIsDir === true(探针确定"是目录")时成立。
279
+ // `!== false` 是 bug——探针抛错留下的 undefined 不是"是"。
280
+ if (f.cwdIsDir === true) {
281
+ return { ok: true };
282
+ }
283
+ // 兜底(按采集逻辑不可达:cwdExists 为 true 且探针未抛错时 isDir 必写下
284
+ // boolean)。存在仅为保证任何事实组合都 fail-closed,绝不静默放行。
285
+ return {
286
+ ok: false,
287
+ code: "CWD_INACCESSIBLE",
288
+ message: `[CWD_INACCESSIBLE] 子代理工作目录存在但无法访问: ${f.cwd}(errno=${f.cwdErrno ?? "EUNKNOWN"})。请检查目录权限/链接环路后重试。`,
289
+ fields: { ...fields, cwdExists: true },
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Pre-spawn validation (async contract form). Returns a structured result
295
+ * instead of throwing so callers can propagate code/fields through the
296
+ * normal result channel. fs checks are injected for testability.
297
+ *
298
+ * 公共 API:注入式事实采集的官方接缝,供需要自定义探针的调用方(含测
299
+ * 试)使用。纯适配器:只负责"await 注入的探针采集事实、如实记录 errno 与
300
+ * 探测失败 → 交给 decidePreflight → 返回",内部无任何独立判定分支(判定逻
301
+ * 辑全局仅 decidePreflight 一处)。生产路径走同步入口 preflightSpawnSync
302
+ * 以保证 spawn 时序(异步入口曾因引入 microtask 导致 107 个既有用例失败)。
303
+ * The executable check applies only to absolute command paths — bare names
304
+ * like "pi" resolve via PATH at spawn time and must not be access()-checked.
305
+ */
306
+ export async function preflightSpawn(opts: {
307
+ command: string;
308
+ cwd: string;
309
+ source?: "param" | "session";
310
+ checkExists: (p: string) => Promise<boolean>;
311
+ isDir: (p: string) => Promise<boolean>;
312
+ hasExec: (p: string) => Promise<boolean>;
313
+ }): Promise<PreflightResult> {
314
+ const facts: PreflightFacts = { command: opts.command, cwd: opts.cwd, source: opts.source, cwdExists: false };
315
+ try {
316
+ facts.cwdExists = await opts.checkExists(opts.cwd);
317
+ } catch (err) {
318
+ // 探针抛错 = 探测失败(独立事实),无 .code 时记可辨识占位值 EUNKNOWN。
319
+ facts.cwdProbeFailed = true;
320
+ facts.cwdErrno = (err as NodeJS.ErrnoException).code ?? "EUNKNOWN";
321
+ }
322
+ if (facts.cwdExists) {
323
+ try {
324
+ facts.cwdIsDir = await opts.isDir(opts.cwd);
325
+ } catch (err) {
326
+ facts.cwdProbeFailed = true;
327
+ facts.cwdErrno = (err as NodeJS.ErrnoException).code ?? "EUNKNOWN";
328
+ }
329
+ }
330
+ if (facts.cwdExists && facts.cwdIsDir === true && path.isAbsolute(opts.command)) {
331
+ try {
332
+ facts.executable = await opts.hasExec(opts.command);
333
+ } catch (err) {
334
+ facts.executable = false;
335
+ facts.execErrno = (err as NodeJS.ErrnoException).code;
336
+ }
337
+ }
338
+ return decidePreflight(facts);
339
+ }
340
+
341
+ /**
342
+ * Synchronous preflight for runSingleAgent: spawn must stay reachable
343
+ * within the same synchronous segment as before the preflight existed
344
+ * (async fs probes would delay spawn past callers that assert immediately).
345
+ * 纯适配器:statSync/accessSync 采集事实(如实记录 errno)→ decidePreflight
346
+ * 统一判定,内部无独立判定分支。
347
+ */
348
+ function preflightSpawnSync(input: { command: string; cwd: string; source?: "param" | "session" }): PreflightResult {
349
+ const facts: PreflightFacts = { command: input.command, cwd: input.cwd, source: input.source, cwdExists: false };
350
+ try {
351
+ const stat = fs.statSync(input.cwd);
352
+ facts.cwdExists = true;
353
+ facts.cwdIsDir = stat.isDirectory();
354
+ } catch (err) {
355
+ // 与异步入口同一纪律:抛错 = 探测失败,无 .code 时记 EUNKNOWN。
356
+ facts.cwdProbeFailed = true;
357
+ facts.cwdErrno = (err as NodeJS.ErrnoException).code ?? "EUNKNOWN";
358
+ }
359
+ if (facts.cwdExists && facts.cwdIsDir === true && path.isAbsolute(input.command)) {
360
+ try {
361
+ fs.accessSync(input.command, fs.constants.X_OK);
362
+ facts.executable = true;
363
+ } catch (err) {
364
+ facts.executable = false;
365
+ facts.execErrno = (err as NodeJS.ErrnoException).code;
366
+ }
367
+ }
368
+ return decidePreflight(facts);
369
+ }
370
+
164
371
  function findNearestProjectAgentsDir(cwd: string): string | null {
165
372
  let currentDir = cwd;
166
373
  while (true) {
@@ -1706,6 +1913,10 @@ interface SingleResult {
1706
1913
  startedAt: number;
1707
1914
  /** Wall-clock finish, set when the run resolves; absent while running. */
1708
1915
  finishedAt?: number;
1916
+ /** Preflight failure code, set when the run was rejected before spawn. */
1917
+ preflightCode?: PreflightCode;
1918
+ /** Structured preflight failure context (command/cwd/cwdExists/source). */
1919
+ preflightFields?: PreflightResult["fields"];
1709
1920
  }
1710
1921
 
1711
1922
  interface SubagentDetails {
@@ -1727,6 +1938,24 @@ function getFinalOutput(messages: Message[]): string {
1727
1938
  return "";
1728
1939
  }
1729
1940
 
1941
+ /**
1942
+ * N2(c): the result answer is the LAST assistant message's non-empty text —
1943
+ * never an earlier turn's. When the final assistant turn errored or produced
1944
+ * only thinking/tool calls, earlier text is stale and must not masquerade as
1945
+ * the answer. Returns "" when the run ended without final text.
1946
+ */
1947
+ function getLastAssistantText(messages: Message[]): string {
1948
+ for (let i = messages.length - 1; i >= 0; i--) {
1949
+ const msg = messages[i];
1950
+ if (msg.role !== "assistant") continue;
1951
+ for (const part of msg.content) {
1952
+ if (part.type === "text" && part.text.trim()) return part.text;
1953
+ }
1954
+ return "";
1955
+ }
1956
+ return "";
1957
+ }
1958
+
1730
1959
  type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
1731
1960
 
1732
1961
  function getDisplayItems(messages: Message[]): DisplayItem[] {
@@ -2308,9 +2537,11 @@ async function runSingleAgent(
2308
2537
  if (effectiveThinking) args.push("--thinking", effectiveThinking);
2309
2538
  if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
2310
2539
 
2311
- // Effective working directory: agent-specific cwd > session default.
2312
- // Used both for resolving relative skill paths and as the spawned process cwd.
2313
- const effectiveCwd = cwd ?? defaultCwd;
2540
+ // Effective working directory: agent-specific cwd (normalized) > session
2541
+ // default. Used both for resolving relative skill paths and as the spawned
2542
+ // process cwd. When no cwd param is given this is defaultCwd verbatim.
2543
+ const cwdResolution = resolveAgentCwd({ paramCwd: cwd, sessionCwd: defaultCwd, homedir: os.homedir() });
2544
+ const effectiveCwd = cwdResolution.cwd;
2314
2545
 
2315
2546
  // MODIFIED: inject per-agent skill isolation
2316
2547
  const skillWarnings: string[] = [];
@@ -2398,6 +2629,28 @@ async function runSingleAgent(
2398
2629
  }
2399
2630
 
2400
2631
  args.push(`Task: ${task}`);
2632
+
2633
+ // Preflight: validate the normalized cwd and the executable BEFORE
2634
+ // spawning, so a missing cwd surfaces as a structured CWD_MISSING error
2635
+ // instead of the misleading raw `spawn <execPath> ENOENT`.
2636
+ const invocation = getPiInvocation(args);
2637
+ // Synchronous on purpose: keeps spawn reachable within the same
2638
+ // synchronous segment as before this preflight existed.
2639
+ const preflight = preflightSpawnSync({
2640
+ command: invocation.command,
2641
+ cwd: effectiveCwd,
2642
+ source: cwdResolution.source,
2643
+ });
2644
+ if (!preflight.ok) {
2645
+ currentResult.exitCode = 1;
2646
+ currentResult.errorMessage = preflight.message;
2647
+ currentResult.stderr += `${preflight.message}\n`;
2648
+ currentResult.preflightCode = preflight.code ?? undefined;
2649
+ currentResult.preflightFields = preflight.fields;
2650
+ currentResult.finishedAt = Date.now();
2651
+ return currentResult;
2652
+ }
2653
+
2401
2654
  let wasAborted = false;
2402
2655
 
2403
2656
  const POST_EXIT_GRACE_MS = 500;
@@ -2406,7 +2659,6 @@ async function runSingleAgent(
2406
2659
  const DEFAULT_HARD_TIMEOUT_MS = 0;
2407
2660
 
2408
2661
  const exitCode = await new Promise<number>((resolve) => {
2409
- const invocation = getPiInvocation(args);
2410
2662
  const currentDepth = parseEnvInt(process.env.PI_SUBAGENT_DEPTH, 0);
2411
2663
  const proc = spawn(invocation.command, invocation.args, {
2412
2664
  cwd: effectiveCwd,
@@ -2582,8 +2834,41 @@ async function runSingleAgent(
2582
2834
  ) {
2583
2835
  currentResult.stopReason = msg.stopReason;
2584
2836
  }
2585
- if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
2586
2837
  if (msg.stopReason === "error" || msg.errorMessage) {
2838
+ // N3: a failed turn is not terminal. pi may auto-retry inside
2839
+ // this same process (agent_end{willRetry:true} ->
2840
+ // auto_retry_start -> ...), so only record the cause and keep
2841
+ // waiting — no kill, no finalize. The activity timer stays
2842
+ // armed (resetActivityTimer above + the stdout data handler),
2843
+ // so a process that goes silent after the error still ends as
2844
+ // activity_timeout, never as a permanent "running".
2845
+ if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
2846
+ } else {
2847
+ // A healthy assistant turn after a recovered retry clears the
2848
+ // recorded error, so a retried run can still finalize as
2849
+ // success (finalize maps a lingering errorMessage to exit 1).
2850
+ currentResult.errorMessage = undefined;
2851
+ }
2852
+ }
2853
+ emitProgress();
2854
+ }
2855
+
2856
+ if (event.type === "agent_end") {
2857
+ if (event.willRetry) {
2858
+ // willRetry === true: pi is about to auto-retry inside this
2859
+ // same process — nothing is final yet. The retry's own delay
2860
+ // may exceed the activity window, so the inactivity net is
2861
+ // suspended (not re-armed) until genuine stdout/stderr
2862
+ // activity resumes; a provisional activity_timeout recorded
2863
+ // while the failed turn awaited this decision is revoked.
2864
+ suspendActivityTimerForRetry();
2865
+ } else {
2866
+ resetActivityTimer();
2867
+ // willRetry === false is NOT a failure signal by itself (it
2868
+ // also fires on normal success), so only an already-recorded
2869
+ // error state finalizes as failure here; a clean agent_end is
2870
+ // left for the natural process exit.
2871
+ if (currentResult.stopReason === "error" || currentResult.errorMessage) {
2587
2872
  try {
2588
2873
  proc.kill("SIGKILL");
2589
2874
  } catch {
@@ -2594,7 +2879,36 @@ async function runSingleAgent(
2594
2879
  return;
2595
2880
  }
2596
2881
  }
2597
- emitProgress();
2882
+ }
2883
+
2884
+ if (event.type === "auto_retry_start") {
2885
+ // Retry cycle starting; the process stays alive — wait for the
2886
+ // retried turn. The retry delay (delayMs) can exceed the
2887
+ // activity window, so the inactivity net is suspended (not
2888
+ // re-armed); genuine activity re-arms it via the stdout/stderr
2889
+ // data handlers.
2890
+ suspendActivityTimerForRetry();
2891
+ }
2892
+
2893
+ if (event.type === "auto_retry_end") {
2894
+ if (event.success === false) {
2895
+ resetActivityTimer();
2896
+ // Retries exhausted: finalError is the authoritative cause.
2897
+ if (event.finalError) currentResult.errorMessage = String(event.finalError);
2898
+ try {
2899
+ proc.kill("SIGKILL");
2900
+ } catch {
2901
+ /* ignore ESRCH */
2902
+ }
2903
+ emitProgress();
2904
+ finalize(1);
2905
+ return;
2906
+ }
2907
+ // success === true: the run continues; the recovered turn's
2908
+ // message_end clears the recorded error. Suspend the inactivity
2909
+ // net until that activity arrives (same reasoning as
2910
+ // auto_retry_start).
2911
+ suspendActivityTimerForRetry();
2598
2912
  }
2599
2913
  };
2600
2914
  const processLine = (line: string) => {
@@ -2614,6 +2928,18 @@ async function runSingleAgent(
2614
2928
  );
2615
2929
  if (activityMs > 0) {
2616
2930
  activityTimer = setTimeout(() => {
2931
+ // A failed turn still awaiting pi's retry decision
2932
+ // (stopReason === "error" / errorMessage recorded, no
2933
+ // agent_end or auto_retry_end yet) makes the timeout
2934
+ // PROVISIONAL: SIGKILL still reclaims a genuinely hung
2935
+ // process (and the exit it guarantees finalizes the task as
2936
+ // timed out), but finalization is deferred to that exit so
2937
+ // a retry-lifecycle event observed before it can revoke the
2938
+ // provisional stopReason (see suspendActivityTimerForRetry).
2939
+ // Any other silence finalizes immediately — the process is
2940
+ // hung mid-work and must never run forever.
2941
+ const awaitingRetryDecision =
2942
+ currentResult.stopReason === "error" || !!currentResult.errorMessage;
2617
2943
  currentResult.stopReason = "activity_timeout";
2618
2944
  const elapsed = Date.now() - lastActivityAt;
2619
2945
  const phase = currentResult.phase;
@@ -2624,11 +2950,33 @@ async function runSingleAgent(
2624
2950
  } catch {
2625
2951
  /* ignore ESRCH */
2626
2952
  }
2627
- finalize(1);
2953
+ if (!awaitingRetryDecision) finalize(1);
2628
2954
  }, activityMs);
2629
2955
  }
2630
2956
  };
2631
2957
 
2958
+ /**
2959
+ * Retry-lifecycle events (agent_end{willRetry:true},
2960
+ * auto_retry_start, auto_retry_end{success:true}) prove the process
2961
+ * is alive but are followed by pi's own retry delay, which may exceed
2962
+ * the activity window. Suspend the inactivity net until genuine
2963
+ * stdout/stderr activity re-arms it (the data handlers call
2964
+ * resetActivityTimer), and revoke a provisional activity_timeout
2965
+ * stopReason recorded while the failed turn awaited pi's retry
2966
+ * decision.
2967
+ */
2968
+ const suspendActivityTimerForRetry = () => {
2969
+ // Same guard as resetActivityTimer: after resolution, once abort
2970
+ // started teardown, or after the process exited, touching the
2971
+ // timer/stopReason is meaningless (abort 后不得再动计时器).
2972
+ if (resolved || wasAborted || exitCodeValue !== null) return;
2973
+ if (activityTimer) {
2974
+ clearTimeout(activityTimer);
2975
+ activityTimer = undefined;
2976
+ }
2977
+ if (currentResult.stopReason === "activity_timeout") currentResult.stopReason = undefined;
2978
+ };
2979
+
2632
2980
  const setupHardTimer = () => {
2633
2981
  // Don't arm after resolution, once abort started teardown, or after
2634
2982
  // the process exited (same guard as resetActivityTimer): a hard
@@ -2695,7 +3043,11 @@ async function runSingleAgent(
2695
3043
  });
2696
3044
 
2697
3045
  proc.on("error", (err) => {
2698
- currentResult.stderr += `[async-subagent-isolation] process error: ${err?.message ?? String(err)}\n`;
3046
+ // A raw spawn ENOENT blames the executable path even when the real
3047
+ // cause is a missing cwd — attach the structured context so the
3048
+ // message can no longer be misread as "node is broken".
3049
+ const cwdExists = isDirectory(effectiveCwd);
3050
+ currentResult.stderr += `[async-subagent-isolation] process error: ${err?.message ?? String(err)} (command: ${invocation.command}, cwd: ${effectiveCwd}, cwdExists: ${cwdExists})\n`;
2699
3051
  finalize(1);
2700
3052
  });
2701
3053
 
@@ -2992,8 +3344,34 @@ function getTaskStatus(result: SingleResult): SubagentTaskStatus {
2992
3344
  const stopReason = result.stopReason;
2993
3345
  if (stopReason === "aborted" || stopReason === "killed_on_shutdown") return "cancelled";
2994
3346
  if (stopReason === "activity_timeout" || stopReason === "hard_timeout") return "timeout";
2995
- if (result.exitCode !== 0 || stopReason === "error") return "failure";
2996
- return "success";
3347
+ return isTaskFailure(result) ? "failure" : "success";
3348
+ }
3349
+
3350
+ /**
3351
+ * The single "did this run fail" predicate (N2 three-layer success criteria,
3352
+ * inverted): (a) a clean exit with no recorded error, (b) a normal terminal
3353
+ * stopReason ("error"/abort/timeout are terminal failures; "length"/"deferred"
3354
+ * mean the answer was cut off or postponed), (c) non-empty text on the LAST
3355
+ * assistant message. Exit 0 alone never proves success.
3356
+ *
3357
+ * Shared by all three success/failure call sites — the async terminal status
3358
+ * (getTaskStatus), the sync execute() isError flag and the renderResult icon —
3359
+ * so they can never drift into disagreeing about the same run.
3360
+ */
3361
+ function isTaskFailure(result: SingleResult): boolean {
3362
+ const stopReason = result.stopReason;
3363
+ if (result.exitCode !== 0) return true;
3364
+ if (
3365
+ stopReason === "error" ||
3366
+ stopReason === "aborted" ||
3367
+ stopReason === "killed_on_shutdown" ||
3368
+ stopReason === "activity_timeout" ||
3369
+ stopReason === "hard_timeout"
3370
+ )
3371
+ return true;
3372
+ if (result.errorMessage) return true;
3373
+ if (stopReason === "length" || stopReason === "deferred") return true;
3374
+ return !getLastAssistantText(result.messages);
2997
3375
  }
2998
3376
 
2999
3377
  /** Structured payload carried by the subagent-result message's details field. */
@@ -3015,6 +3393,13 @@ export interface SubagentResultDetails {
3015
3393
  usage: UsageStats;
3016
3394
  sessionId: string;
3017
3395
  output: string;
3396
+ /**
3397
+ * Truncated failure cause (ERROR_MESSAGE_MAX_CHARS), present only on failed
3398
+ * tasks whose run recorded an errorMessage (message_end(error) or
3399
+ * auto_retry_end{finalError}). Also inlined as the envelope's `- Error:`
3400
+ * meta line.
3401
+ */
3402
+ errorMessage?: string;
3018
3403
  }
3019
3404
 
3020
3405
  /**
@@ -3024,6 +3409,38 @@ export interface SubagentResultDetails {
3024
3409
  */
3025
3410
  const DETAILS_OUTPUT_MAX_CHARS = 16 * 1024;
3026
3411
 
3412
+ /**
3413
+ * Cap for the failure cause surfaced through the result envelope (meta `- Error:`
3414
+ * line and details.errorMessage). Long provider payloads are truncated with a
3415
+ * visible marker; the prefix is preserved so the error stays identifiable.
3416
+ */
3417
+ export const ERROR_MESSAGE_MAX_CHARS = 2000;
3418
+
3419
+ /** Truncate an over-long error message, keeping the identifying prefix. */
3420
+ function truncateErrorMessage(message: string): string {
3421
+ if (message.length <= ERROR_MESSAGE_MAX_CHARS) return message;
3422
+ return `${message.slice(0, ERROR_MESSAGE_MAX_CHARS)}\n... (truncated)`;
3423
+ }
3424
+
3425
+ /**
3426
+ * Cap for the stderr tail surfaced through the envelope's `- Stderr:` meta
3427
+ * line on otherwise clueless failures (no errorMessage, no output).
3428
+ */
3429
+ const STDERR_TAIL_MAX_CHARS = 1000;
3430
+
3431
+ /**
3432
+ * Compact stderr tail for the envelope's `- Stderr:` meta line: trimmed and
3433
+ * capped to the last STDERR_TAIL_MAX_CHARS. The stderr text is presented
3434
+ * verbatim — no content rewriting of any kind (no prefix stripping, no
3435
+ * log-level normalization), so the main agent sees exactly what the
3436
+ * subprocess wrote.
3437
+ */
3438
+ function summarizeStderrTail(stderr: string): string {
3439
+ const trimmed = stderr.trim();
3440
+ if (!trimmed) return "";
3441
+ return trimmed.length > STDERR_TAIL_MAX_CHARS ? trimmed.slice(-STDERR_TAIL_MAX_CHARS) : trimmed;
3442
+ }
3443
+
3027
3444
  /**
3028
3445
  * Fixed trigger line inserted into every [subagent-result] envelope right
3029
3446
  * after the title line (before the in-flight block). Steer delivery injects
@@ -3068,7 +3485,10 @@ export function buildResultEnvelope(
3068
3485
  errorMessage?: string,
3069
3486
  ): { content: string; details: SubagentResultDetails } {
3070
3487
  const statusWord = STATUS_WORDS[status];
3071
- const output = result ? getFinalOutput(result.messages) : "";
3488
+ // details.output / body carry only the real final-turn assistant text
3489
+ // (N2/N4): never an earlier turn's stale text, never the errorMessage,
3490
+ // never raw stderr — those must not masquerade as the answer.
3491
+ const output = result ? getLastAssistantText(result.messages) : "";
3072
3492
  const usage: UsageStats =
3073
3493
  result?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
3074
3494
  const sessionId = result?.sessionId ?? task.taskId;
@@ -3078,8 +3498,23 @@ export function buildResultEnvelope(
3078
3498
  const durationMs = result
3079
3499
  ? Math.max(0, (result.finishedAt ?? Date.now()) - result.startedAt)
3080
3500
  : Math.max(0, Date.now() - task.startedAt);
3501
+ // N1: surface the recorded failure cause (message_end errorMessage or
3502
+ // auto_retry_end finalError, both stored on result.errorMessage) in the
3503
+ // meta-info area and in details.errorMessage, truncated with a marker.
3504
+ const exposedError =
3505
+ status === "failure" && result?.errorMessage ? truncateErrorMessage(result.errorMessage) : undefined;
3506
+ // N4 follow-up: a failure with neither a recorded cause nor final text
3507
+ // would otherwise end in a bare "(no output)" with zero clues (e.g. a
3508
+ // subprocess that wrote a fatal error to stderr and died without any
3509
+ // message_end). Surface the stderr TAIL as a labelled `- Stderr:` meta
3510
+ // line — never as the body, never in details.output (stderr must not
3511
+ // masquerade as the answer, N4), and never next to a `- Error:` line (a
3512
+ // recorded errorMessage takes precedence, N1).
3513
+ const stderrTail =
3514
+ status === "failure" && !exposedError && !output && result?.stderr
3515
+ ? summarizeStderrTail(result.stderr)
3516
+ : "";
3081
3517
  let body = output;
3082
- if (!body && result) body = result.errorMessage || result.stderr.trim();
3083
3518
  // Only genuine failures are labelled "Internal error"; a user cancel or
3084
3519
  // session shutdown rejection is an expected abort, so it gets a note
3085
3520
  // carrying the abort's origin (user cancel vs session shutdown).
@@ -3092,6 +3527,10 @@ export function buildResultEnvelope(
3092
3527
  `- Status: ${statusWord}`,
3093
3528
  `- Task: ${truncateTaskDescription(task.task)}`,
3094
3529
  `- Duration: ${formatDuration(durationMs)} · Usage: ${formatUsageStats(usage, result?.model) || "-"}`,
3530
+ // The `- Error:` line sits inside the meta-info area, after Status and
3531
+ // before the `- Session:` anchor the in-flight block extraction relies on.
3532
+ ...(exposedError ? [`- Error: ${exposedError}`] : []),
3533
+ ...(stderrTail ? [`- Stderr: ${stderrTail}`] : []),
3095
3534
  `- Session: ${sessionId}`,
3096
3535
  "",
3097
3536
  // 在途 block: completeAsyncTask deletes this task from the registry
@@ -3117,6 +3556,7 @@ export function buildResultEnvelope(
3117
3556
  output.length > DETAILS_OUTPUT_MAX_CHARS
3118
3557
  ? `${output.slice(0, DETAILS_OUTPUT_MAX_CHARS)}\n... (truncated; full output in content)`
3119
3558
  : output,
3559
+ errorMessage: exposedError,
3120
3560
  },
3121
3561
  };
3122
3562
  }
@@ -3264,7 +3704,12 @@ const SubagentParams = Type.Object({
3264
3704
  confirmProjectAgents: Type.Optional(
3265
3705
  Type.Boolean({ description: "Prompt before running project-local agents. Default: false.", default: false }),
3266
3706
  ),
3267
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
3707
+ cwd: Type.Optional(
3708
+ Type.String({
3709
+ description:
3710
+ 'Working directory for the agent process. Must be an existing directory: a nonexistent path is a hard error (reported as [CWD_MISSING]) and is never auto-created — create it first if needed. Only "~/…" and bare "~" are expanded to the home directory; "~user/…" is not supported and errors as CWD_MISSING. Relative paths resolve against the session cwd.',
3711
+ }),
3712
+ ),
3268
3713
  });
3269
3714
 
3270
3715
  export default function (pi: ExtensionAPI) {
@@ -3592,7 +4037,13 @@ export default function (pi: ExtensionAPI) {
3592
4037
  ctx.model,
3593
4038
  modelOverrides,
3594
4039
  );
3595
- const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
4040
+ // N2 on the sync path too: exit 0 without non-empty text on the LAST
4041
+ // assistant message is a fake success (silent no-answer run, errored
4042
+ // final turn, truncated/deferred answer, …) — report failure with the
4043
+ // full diagnostics instead. The predicate is shared with the async
4044
+ // terminal status (getTaskStatus) and the renderer (renderResult), so
4045
+ // sync and async can never disagree about the same run.
4046
+ const isError = isTaskFailure(result);
3596
4047
  if (isError) {
3597
4048
  const diagnostics = formatSubagentDiagnostics(result) + `\n\n[subagent session: ${result.sessionId}]`;
3598
4049
  return {
@@ -3601,7 +4052,7 @@ export default function (pi: ExtensionAPI) {
3601
4052
  isError: true,
3602
4053
  };
3603
4054
  }
3604
- const rawOutput = getFinalOutput(result.messages);
4055
+ const rawOutput = getLastAssistantText(result.messages);
3605
4056
  const outputText = rawOutput
3606
4057
  ? `${rawOutput}\n\n[subagent session: ${result.sessionId}]`
3607
4058
  : `[subagent session: ${result.sessionId}]`;
@@ -3658,7 +4109,10 @@ export default function (pi: ExtensionAPI) {
3658
4109
 
3659
4110
  if (details.mode === "single" && details.results.length === 1) {
3660
4111
  const r = details.results[0];
3661
- const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
4112
+ // Shared failure predicate (isTaskFailure): the icon must match the
4113
+ // status the async envelope / sync isError would report for the same
4114
+ // run — e.g. exit 0 + stopReason "length" renders ✗, not ✓.
4115
+ const isError = isTaskFailure(r);
3662
4116
  const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
3663
4117
  const displayItems = getDisplayItems(r.messages);
3664
4118
  const finalOutput = getFinalOutput(r.messages);