@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 +4 -4
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/index.ts +470 -16
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.
|
|
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
|
|
2312
|
-
// Used both for resolving relative skill paths and as the spawned
|
|
2313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2996
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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);
|