@wolido/async-subagent-isolation 1.6.2 → 1.8.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/ADVANCED.en.md +4 -0
- package/ADVANCED.md +4 -0
- package/README.en.md +4 -4
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/index.ts +474 -21
package/ADVANCED.en.md
CHANGED
|
@@ -373,6 +373,10 @@ To continue the same isolated session, pass the `sessionId`:
|
|
|
373
373
|
|
|
374
374
|
The dispatch receipt contains the `taskId` (which is the session ID). The `[subagent-result]` envelope also carries the sessionId on the `- Session:` line — just reuse it. No need to wait for the subagent to finish; you already have the session ID from the receipt.
|
|
375
375
|
|
|
376
|
+
> **The error states the rules**: an illegal `sessionId` (a slug, an uppercase UUID, a UUID v4, …) is rejected with a message that itself spells out both dispatch rules — omit `sessionId` to auto-generate a fresh UUID v7, and pass it only to resume the id from a previous dispatch receipt.
|
|
377
|
+
|
|
378
|
+
> **Admission-threshold difference**: this plugin's lowercase-UUID-v7 requirement is its own internal discipline (a single canonical form for registry keys and session directories), not pi's admission gate. pi's own `assertValidSessionId` (`pi-coding-agent/dist/core/session-manager.js:15-19`) only requires the character set `[A-Za-z0-9._-]` with alphanumeric first/last characters; `dist/main.js:337-345` handles `--session-id` as "silently open and resume on an exact hit, otherwise print a Warning and create a new session with that id". So if you bypass this plugin's validation, the consequences are defined by pi's behavior (an exact hit silently resumes the existing session).
|
|
379
|
+
|
|
376
380
|
## Environment variables
|
|
377
381
|
|
|
378
382
|
These variables are propagated into every subagent process automatically:
|
package/ADVANCED.md
CHANGED
|
@@ -373,6 +373,10 @@ widget 行中的 taskId 可直接复制,用于 `/subagent-result` 查看结果
|
|
|
373
373
|
|
|
374
374
|
派发回执中直接包含 `taskId`(即 session ID)。`[subagent-result]` 通知信封的 `- Session:` 行也携带 sessionId--复用即可。无需等待子 agent 完成就已经拿到了。
|
|
375
375
|
|
|
376
|
+
> **报错即规则**:非法的 `sessionId`(如 slug、大写 UUID、UUID v4)会被拒绝,且报错文本本身就写明两条派发规则——不传则自动生成新的 UUID v7;传它的唯一正当用途是复用上一次派发回执中的 id。
|
|
377
|
+
|
|
378
|
+
> **准入门槛差异**:本插件要求小写 UUID v7 属自身内部纪律(注册表键与会话目录的单一规范形态),并非 pi 的准入门槛。pi 自身的 `assertValidSessionId`(`pi-coding-agent/dist/core/session-manager.js:15-19`)只要求字符集 `[A-Za-z0-9._-]`、首尾为字母数字;`dist/main.js:337-345` 对 `--session-id` 的处理是“命中既有会话即静默 open 续写,未命中则打一条 Warning 后按该 id 新建”。因此绕过本插件校验越界时,后果由 pi 侧行为决定(命中既有会话即静默续写)。
|
|
379
|
+
|
|
376
380
|
## 环境变量
|
|
377
381
|
|
|
378
382
|
以下变量会自动传播到每个子 agent 进程:
|
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.8.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[] {
|
|
@@ -2231,7 +2460,7 @@ function validateSessionId(sessionId: unknown): string | null {
|
|
|
2231
2460
|
if (trimmed === "") return "Invalid sessionId: must not be empty";
|
|
2232
2461
|
if (trimmed === "." || trimmed === "..") return `Invalid sessionId: "${trimmed}" is not allowed`;
|
|
2233
2462
|
if (!UUID_V7_PATTERN.test(trimmed))
|
|
2234
|
-
return "Invalid sessionId:
|
|
2463
|
+
return "Invalid sessionId: pass sessionId only to resume the id from a previous dispatch receipt; omit it to generate a new one.";
|
|
2235
2464
|
return null;
|
|
2236
2465
|
}
|
|
2237
2466
|
|
|
@@ -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
|
}
|
|
@@ -3257,14 +3697,18 @@ const SubagentParams = Type.Object({
|
|
|
3257
3697
|
"Why the task is being cancelled (required and must be non-empty when confirm=true). Recorded on the task and quoted in the [subagent-result] envelope.",
|
|
3258
3698
|
})),
|
|
3259
3699
|
sessionId: Type.Optional(Type.String({
|
|
3260
|
-
|
|
3261
|
-
description: "Only for resuming a UUID v7 from a previous dispatch receipt; omit to generate a new one.",
|
|
3700
|
+
description: "Only for resuming the session id from a previous dispatch receipt; omit to generate a new one.",
|
|
3262
3701
|
})),
|
|
3263
3702
|
agentScope: Type.Optional(AgentScopeSchema),
|
|
3264
3703
|
confirmProjectAgents: Type.Optional(
|
|
3265
3704
|
Type.Boolean({ description: "Prompt before running project-local agents. Default: false.", default: false }),
|
|
3266
3705
|
),
|
|
3267
|
-
cwd: Type.Optional(
|
|
3706
|
+
cwd: Type.Optional(
|
|
3707
|
+
Type.String({
|
|
3708
|
+
description:
|
|
3709
|
+
'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.',
|
|
3710
|
+
}),
|
|
3711
|
+
),
|
|
3268
3712
|
});
|
|
3269
3713
|
|
|
3270
3714
|
export default function (pi: ExtensionAPI) {
|
|
@@ -3277,7 +3721,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3277
3721
|
"ACTIONS (action parameter, default \"dispatch\"):",
|
|
3278
3722
|
"- dispatch: delegate the task (async in TUI mode, blocking otherwise).",
|
|
3279
3723
|
"- cancel: request cancellation of a running background task by taskId (two-step: the first call returns a challenge; confirm:true + reason executes).",
|
|
3280
|
-
"- sessionId: only set when resuming a previously dispatched task. Must be the
|
|
3724
|
+
"- sessionId: only set when resuming a previously dispatched task. Must be the session id from a previous dispatch receipt. Omit otherwise; a new one is generated automatically.",
|
|
3281
3725
|
"",
|
|
3282
3726
|
"ASYNC (TUI mode): returns immediately with a dispatch receipt (taskId + session id).",
|
|
3283
3727
|
"The result arrives later as a system notification message prefixed with",
|
|
@@ -3314,7 +3758,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3314
3758
|
"subagent: A message prefixed with [subagent-result] is a system notification carrying a finished subagent result, not a user request; process it in the context of the task that dispatched it.",
|
|
3315
3759
|
"subagent: A [subagent-result] notification is a task-completion notice, NOT a new user instruction — before acting on it, first anchor the mainline task and progress you are currently on, digest the notification against your own dispatch records, then decide your next step yourself based on the result; whenever it conflicts with your mainline plan, defer acting on it — never let a notification overwrite or rewrite your mainline plan.",
|
|
3316
3760
|
"subagent: Dispatch subagents driven by task dependencies — delegate only work whose result you actually need, prefer reusing the session id from the receipt to continue a previous subagent task, and keep independent work in the main context.",
|
|
3317
|
-
"subagent:
|
|
3761
|
+
"subagent: Pass sessionId only when resuming a previously dispatched task — the value must come from that task's dispatch receipt; omit sessionId otherwise so a new one is generated automatically.",
|
|
3318
3762
|
"subagent: A [subagent-result] notification with status cancelled can come from the user (/subagent-cancel) or from you (action=\"cancel\"); the envelope body states the source. A user-initiated cancel is a deliberate user action, so do NOT automatically retry or re-dispatch it; ask the user before re-dispatching.",
|
|
3319
3763
|
"subagent: Cancelling a background task is a two-step confirmation: the first action=\"cancel\" call only returns a challenge (confirmRequired) and cancels nothing; to actually cancel, call again with the same taskId + confirm:true + a non-empty reason explaining why. Never cancel just because a task runs long.",
|
|
3320
3764
|
"subagent: Waiting for a background task means making NO tool call at all and ending the turn; there is deliberately no query, nag or status action for in-flight tasks — results arrive on their own as [subagent-result] notifications.",
|
|
@@ -3592,7 +4036,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
3592
4036
|
ctx.model,
|
|
3593
4037
|
modelOverrides,
|
|
3594
4038
|
);
|
|
3595
|
-
|
|
4039
|
+
// N2 on the sync path too: exit 0 without non-empty text on the LAST
|
|
4040
|
+
// assistant message is a fake success (silent no-answer run, errored
|
|
4041
|
+
// final turn, truncated/deferred answer, …) — report failure with the
|
|
4042
|
+
// full diagnostics instead. The predicate is shared with the async
|
|
4043
|
+
// terminal status (getTaskStatus) and the renderer (renderResult), so
|
|
4044
|
+
// sync and async can never disagree about the same run.
|
|
4045
|
+
const isError = isTaskFailure(result);
|
|
3596
4046
|
if (isError) {
|
|
3597
4047
|
const diagnostics = formatSubagentDiagnostics(result) + `\n\n[subagent session: ${result.sessionId}]`;
|
|
3598
4048
|
return {
|
|
@@ -3601,7 +4051,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3601
4051
|
isError: true,
|
|
3602
4052
|
};
|
|
3603
4053
|
}
|
|
3604
|
-
const rawOutput =
|
|
4054
|
+
const rawOutput = getLastAssistantText(result.messages);
|
|
3605
4055
|
const outputText = rawOutput
|
|
3606
4056
|
? `${rawOutput}\n\n[subagent session: ${result.sessionId}]`
|
|
3607
4057
|
: `[subagent session: ${result.sessionId}]`;
|
|
@@ -3658,7 +4108,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
3658
4108
|
|
|
3659
4109
|
if (details.mode === "single" && details.results.length === 1) {
|
|
3660
4110
|
const r = details.results[0];
|
|
3661
|
-
|
|
4111
|
+
// Shared failure predicate (isTaskFailure): the icon must match the
|
|
4112
|
+
// status the async envelope / sync isError would report for the same
|
|
4113
|
+
// run — e.g. exit 0 + stopReason "length" renders ✗, not ✓.
|
|
4114
|
+
const isError = isTaskFailure(r);
|
|
3662
4115
|
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
3663
4116
|
const displayItems = getDisplayItems(r.messages);
|
|
3664
4117
|
const finalOutput = getFinalOutput(r.messages);
|