@arhen/pi-core-subagent 1.3.51 → 1.3.53

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.md CHANGED
@@ -282,6 +282,8 @@ Background (default) + intercom — the run returns a runId immediately; you sta
282
282
 
283
283
  **Steering a running child:** while a background run is active the leader stays responsive, and you can push a message into a live child's session mid-run with `steer_subagent` — e.g. `steer_subagent({ runId, taskId, message: "Ignore tests/, only audit runtime deps" })`. The message queues as a steer if the child is mid-turn and lands at its next model boundary. Omit `taskId` to steer every still-running task in the run. Combined with `notifyPerTask`, this makes a background run feel like a live team you can redirect, not a fire-and-forget blob.
284
284
 
285
+ **Resuming a failed child:** a child that dies mid-work (provider rate limit, timeout, network error) keeps its session JSONL and its worktree branch. `resume_subagent({ runId, taskId, model?: "openai/gpt-5", message? })` reopens that session with full context, re-attaches the branch, and prompts it to recap and continue — no respawn, no lost tokens. `model` swaps provider when the original one is exhausted. Refused for tasks that never started (no session file); those you respawn. Wait for the run to settle before resuming (the tool tells you if it hasn't).
286
+
285
287
  ## Tools
286
288
 
287
289
  | Tool | Purpose |
@@ -292,6 +294,7 @@ Background (default) + intercom — the run returns a runId immediately; you sta
292
294
  | `await_subagent` | block until a run finishes (optional `timeoutMs`) |
293
295
  | `reply_subagent` | answer a child's `ask_parent` question |
294
296
  | `steer_subagent` | inject a steering message into a running child's session (queues as steer if mid-turn; lands at its next model boundary) |
297
+ | `resume_subagent` | revive a failed/aborted task in its original session (context + branch preserved); optional `model` swap and custom `message` |
295
298
  | `subagent_cancel` | abort a running/queued run |
296
299
 
297
300
  ### Per-task fields
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.51",
3
+ "version": "1.3.53",
4
4
  "type": "module",
5
5
  "description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
6
6
  "license": "MIT",
package/src/format.ts CHANGED
@@ -228,7 +228,9 @@ export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: strin
228
228
  `Goal: ${goal}${src}${swap}${tools}`,
229
229
  isStartupFailure(task, kind)
230
230
  ? "Never started — stop and diagnose before spawning anything else: a config-level error (model, plan, auth, agent file) fails identically on every respawn."
231
- : `Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`,
231
+ : kind === "completed"
232
+ ? `Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`
233
+ : `Session file kept — resume_subagent(runId: "${run.id}", taskId: "${task.id}", model?: ...) revives it with full context. subagent_result for what it produced so far.`,
232
234
  ].join("\n");
233
235
  }
234
236
  export function makeNotice(run: RunSnapshot, kind: string): string {
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  AwaitParam,
9
9
  ReplyParam,
10
10
  ResultParam,
11
+ ResumeParam,
11
12
  RunIdParam,
12
13
  SteerParam,
13
14
  SubagentParams,
@@ -140,6 +141,7 @@ export default function (pi: ExtensionAPI) {
140
141
  "Right after spawning, call subagent_status(runId) ONCE before any other work — a child that died on spawn (or never started) is invisible until far later otherwise. If it shows a task failed/never started, fix or respawn immediately.",
141
142
  "Never block with nothing to do: if you have no work left after spawning, end your turn — completion notifies you and wakes a fresh turn with the results. await_subagent/autoAwait while idle only burns time and tokens.",
142
143
  "autoAwait:true only when this SAME turn must consume the result immediately. await_subagent is for syncing with your own parallel work — not the default follow-up to a spawn.",
144
+ "A task that failed mid-work (provider error, rate limit, timeout) keeps its session file and branch: resume_subagent(runId, taskId, model?) revives it with full context — prefer that over respawning. Respawn only when it never started (no session file).",
143
145
  ],
144
146
  parameters: SubagentParams,
145
147
  executionMode: "parallel",
@@ -182,7 +184,7 @@ export default function (pi: ExtensionAPI) {
182
184
  content: [
183
185
  {
184
186
  type: "text",
185
- text: `Background run started: ${details.run.id} (${details.run.mode}, ${details.run.tasks.length} task${details.run.tasks.length > 1 ? "s" : ""}).\nNext: call subagent_status("${details.run.id}") now to confirm the tasks actually started before doing anything else.\nAfter that, completion will notify you — if you have no other work, end your turn instead of waiting.\nOther tools: subagent_result / reply_subagent / steer_subagent / subagent_cancel.`,
187
+ text: `Background run started: ${details.run.id} (${details.run.mode}, ${details.run.tasks.length} task${details.run.tasks.length > 1 ? "s" : ""}).\nNext: call subagent_status("${details.run.id}") now to confirm the tasks actually started before doing anything else.\nAfter that, completion will notify you — if you have no other work, end your turn instead of waiting.\nOther tools: subagent_result / reply_subagent / steer_subagent / resume_subagent / subagent_cancel.`,
186
188
  },
187
189
  ],
188
190
  details,
@@ -373,6 +375,34 @@ export default function (pi: ExtensionAPI) {
373
375
  },
374
376
  });
375
377
 
378
+ pi.registerTool<typeof ResumeParam, { run?: RunSnapshot }>({
379
+ name: "resume_subagent",
380
+ label: "Resume Subagent",
381
+ description:
382
+ "Revive a failed/aborted task in its original session (full context + worktree branch preserved). Optional `model` swaps provider (e.g. after a rate limit); optional `message` replaces the default 'recap and continue' prompt. Refuses tasks that never started — respawn those.",
383
+ parameters: ResumeParam,
384
+ async execute(_id, params, _signal, _onUpdate, ctx) {
385
+ const { runId, taskId, message, model } = params as {
386
+ runId: string;
387
+ taskId: string;
388
+ message?: string;
389
+ model?: string;
390
+ };
391
+ const res = manager.resumeTask(runId, taskId, ctx, { message, model });
392
+ if (!res.ok) return { content: [{ type: "text", text: res.reason }], isError: true, details: {} };
393
+ const run = manager.getRun(runId);
394
+ return {
395
+ content: [
396
+ {
397
+ type: "text",
398
+ text: `Resumed ${runId}/${taskId} (${res.task.agent})${model ? ` on ${model}` : ""} from ${res.task.sessionFile}${res.task.branch ? `, branch ${res.task.branch}` : ""}.\nNext: subagent_status("${runId}") to confirm it is running; completion will notify you.`,
399
+ },
400
+ ],
401
+ details: { run: run ? cloneRun(run) : undefined },
402
+ };
403
+ },
404
+ });
405
+
376
406
  pi.registerTool<typeof RunIdParam, { aborted?: number }>({
377
407
  name: "subagent_cancel",
378
408
  label: "Subagent Cancel",
package/src/manager.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  type UsageStats,
45
45
  } from "./types.ts";
46
46
  import {
47
+ attachWorktree,
47
48
  branchDiff,
48
49
  claimWorktree,
49
50
  cleanupMerged,
@@ -226,6 +227,12 @@ export function validateThinking(model: Model<Api> | undefined, level: string |
226
227
  }
227
228
  }
228
229
 
230
+ interface ResumeInput {
231
+ sessionFile: string;
232
+ branch?: string;
233
+ message: string;
234
+ }
235
+
229
236
  interface ChildEventState {
230
237
  pendingFailure?: ReturnType<typeof classifyFailure>;
231
238
  failChildEnd?: (error: Error) => void;
@@ -434,7 +441,11 @@ export class SubagentManager {
434
441
  }
435
442
 
436
443
  private notifyTask(run: RunSnapshot, task: TaskSnapshot, kind: "completed" | "failed" | "aborted"): void {
437
- const body = makeTaskNotice(run, task, kind);
444
+ // ponytail: child already pushed its summary via notify_parent — pointer only, no second copy. Upgrade: drop notice entirely if the pointer noise also proves useless.
445
+ const body =
446
+ kind === "completed" && task.notifiedParent
447
+ ? `Task ${task.agent} (${task.id}) completed in run ${run.id} — summary already reported. Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`
448
+ : makeTaskNotice(run, task, kind);
438
449
 
439
450
  if (this.collectParked(run.id, { kind: "done", taskId: task.id, agent: task.agent, text: body })) {
440
451
  this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body });
@@ -452,6 +463,8 @@ export class SubagentManager {
452
463
  extra?: { taskId?: string; question?: string },
453
464
  ): void {
454
465
  if (kind !== "asked" && run.awaited) return;
466
+ // single-task completed run: the task notice already said everything (failure paths may not have notified per-task)
467
+ if (kind === "completed" && run.tasks.length === 1 && run.notifyPerTask) return;
455
468
  const body =
456
469
  kind === "asked"
457
470
  ? `A subagent is asking you a question (task ${extra?.taskId}): ${extra?.question ?? ""}\nReply with reply_subagent(runId: "${run.id}", taskId: "${extra?.taskId}", message: ...).`
@@ -576,6 +589,7 @@ export class SubagentManager {
576
589
  },
577
590
  onNotifyParent: (_taskId, message, level) => {
578
591
  this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
592
+ task.notifiedParent = true;
579
593
 
580
594
  if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text: message })) return;
581
595
  try {
@@ -702,6 +716,7 @@ export class SubagentManager {
702
716
  ctx: ExtensionContext,
703
717
  signal: AbortSignal | undefined,
704
718
  onUpdate?: (partial: any) => void,
719
+ resume?: ResumeInput,
705
720
  ): Promise<void> {
706
721
  if (TERMINAL.includes(task.status)) return;
707
722
 
@@ -750,7 +765,14 @@ export class SubagentManager {
750
765
 
751
766
  let wt: Worktree | undefined;
752
767
  let isolationReason: string | undefined;
753
- if (canWrite) {
768
+ if (canWrite && resume?.branch) {
769
+ try {
770
+ wt = attachWorktree(task.cwd, resume.branch);
771
+ if (!wt) isolationReason = `branch ${resume.branch} no longer exists`;
772
+ } catch (err) {
773
+ isolationReason = `git worktree add failed: ${err instanceof Error ? err.message : String(err)}`;
774
+ }
775
+ } else if (canWrite) {
754
776
  try {
755
777
  const upstream = (task.needs ?? [])
756
778
  .map((id) => run.tasks.find((t) => t.id === id))
@@ -843,7 +865,9 @@ export class SubagentManager {
843
865
  agentDir: getAgentDir(),
844
866
  modelRuntime: await createChildModelRuntime(ctx),
845
867
  resourceLoader: loader,
846
- sessionManager: SessionManager.create(childCwd, undefined, { parentSession: getParentSessionFile(ctx) }),
868
+ sessionManager: resume
869
+ ? SessionManager.open(resume.sessionFile, undefined, childCwd)
870
+ : SessionManager.create(childCwd, undefined, { parentSession: getParentSessionFile(ctx) }),
847
871
  model,
848
872
  thinkingLevel: thinking as ThinkingLevel | undefined,
849
873
  tools,
@@ -899,7 +923,7 @@ export class SubagentManager {
899
923
  });
900
924
 
901
925
  const maxRuntimeMs = input.maxRuntimeMs ?? (this.autoLimit ? DEFAULT_RUNTIME_MS : UNLIMITED_RUNTIME_MS);
902
- const promptPromise = child.prompt(task.task, { source: "extension" });
926
+ const promptPromise = child.prompt(resume?.message ?? task.task, { source: "extension" });
903
927
  const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise];
904
928
  if (maxRuntimeMs > 0) {
905
929
  races.push(
@@ -1297,6 +1321,107 @@ export class SubagentManager {
1297
1321
  return { run: cloneRun(run) };
1298
1322
  }
1299
1323
 
1324
+ resumeTask(
1325
+ runId: string,
1326
+ taskId: string,
1327
+ ctx: ExtensionContext,
1328
+ opts: { message?: string; model?: string } = {},
1329
+ ): { ok: true; task: TaskSnapshot } | { ok: false; reason: string } {
1330
+ const run = this.runs.get(runId);
1331
+ const task = run?.tasks.find((t) => t.id === taskId);
1332
+ if (!run || !task) return { ok: false, reason: `Unknown ${runId}/${taskId}.` };
1333
+ if (!TERMINAL.includes(task.status))
1334
+ return { ok: false, reason: `${taskId} is still ${task.status} — use steer_subagent.` };
1335
+ if (task.status === "completed") return { ok: false, reason: `${taskId} completed — spawn a new task instead.` };
1336
+ if (!task.sessionFile || !existsSync(task.sessionFile)) {
1337
+ return { ok: false, reason: `${taskId} has no session file to resume (never started) — respawn it.` };
1338
+ }
1339
+ if (this.liveChildren.has(`${runId}:${taskId}`)) return { ok: false, reason: `${taskId} is already live.` };
1340
+ // ponytail: resume only into a settled run — executeTasks' final sweep would abort a task revived mid-run. Upgrade: make the sweep skip tasks with a live child.
1341
+ if (!TERMINAL.includes(run.status)) {
1342
+ return { ok: false, reason: `Run ${runId} is still ${run.status} — wait for it to settle before resuming.` };
1343
+ }
1344
+
1345
+ const tools = task.tools?.filter((t) => !(CHILD_TALK_TOOLS as readonly string[]).includes(t));
1346
+ const write = tools?.some((t) => WRITE_CAPABLE.includes(t)) ?? false;
1347
+ const input: TaskInput = {
1348
+ id: task.id,
1349
+ agent: task.agent,
1350
+ task: task.task,
1351
+ cwd: task.cwd,
1352
+ write,
1353
+ tools: tools?.length ? tools : undefined,
1354
+ model: opts.model ?? task.model,
1355
+ thinking: task.thinking as TaskInput["thinking"],
1356
+ needs: task.needs,
1357
+ };
1358
+ const resume: ResumeInput = {
1359
+ sessionFile: task.sessionFile,
1360
+ branch: task.branch,
1361
+ message:
1362
+ opts.message?.trim() ||
1363
+ `Your previous turn ended with an error (${task.error ?? "unknown"}). Resume where you left off: briefly recap what you already did and what remains, then continue and finish the original task.`,
1364
+ };
1365
+
1366
+ this.cleared = false;
1367
+ this.turnActivity = true;
1368
+ Object.assign(task, {
1369
+ status: "queued" as TaskStatus,
1370
+ error: undefined,
1371
+ endedAt: undefined,
1372
+ finalText: undefined,
1373
+ notifiedParent: false,
1374
+ diffStat: undefined,
1375
+ changedFiles: undefined,
1376
+ worktreeError: undefined,
1377
+ });
1378
+ run.status = "running";
1379
+ run.endedAt = undefined;
1380
+ run.awaited = false;
1381
+ this.settlers.set(run.id, true);
1382
+ this.runControllers.set(run.id, new AbortController());
1383
+ for (const t of run.tasks) this.mailboxes.open(`${run.id}:${t.id}`);
1384
+ this.updateRun(run, ctx);
1385
+ this.emit("subagent:task-resumed", { runId: run.id, taskId: task.id });
1386
+
1387
+ void this.runChild(run, task, input, task.task, ctx, undefined, undefined, resume)
1388
+ .catch((err) => {
1389
+ if (!TERMINAL.includes(task.status)) {
1390
+ this.updateTask(
1391
+ run,
1392
+ task,
1393
+ { status: "failed", error: err instanceof Error ? err.message : String(err), endedAt: Date.now() },
1394
+ ctx,
1395
+ );
1396
+ }
1397
+ })
1398
+ .then(() => {
1399
+ if (run.notifyPerTask) this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
1400
+ this.finishRunIfSettled(run, ctx);
1401
+ });
1402
+ return { ok: true, task };
1403
+ }
1404
+
1405
+ private finishRunIfSettled(run: RunSnapshot, ctx: ExtensionContext): void {
1406
+ if (run.tasks.some((t) => !TERMINAL.includes(t.status))) return;
1407
+ const failed = run.tasks.some((t) => t.status === "failed");
1408
+ const aborted = run.tasks.some((t) => t.status === "aborted");
1409
+ run.status = aborted ? "aborted" : failed ? "failed" : "completed";
1410
+ run.endedAt = Date.now();
1411
+ this.flushWidget(run, ctx);
1412
+ this.emit("subagent:run-completed", {
1413
+ runId: run.id,
1414
+ status: run.status,
1415
+ run: cloneRun(run),
1416
+ aggregateUsage: run.aggregateUsage,
1417
+ });
1418
+ this.settleRun(run.id, run);
1419
+ this.runControllers.delete(run.id);
1420
+ for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
1421
+ this.persist(ctx);
1422
+ this.notifyParent(run, run.status === "completed" ? "completed" : run.status === "aborted" ? "aborted" : "failed");
1423
+ }
1424
+
1300
1425
  steerTask(runId: string, taskId: string | undefined, message: string): boolean {
1301
1426
  const run = this.runs.get(runId);
1302
1427
  if (!run) return false;
package/src/schemas.ts CHANGED
@@ -77,6 +77,19 @@ export const ReplyParam = Type.Object({
77
77
  taskId: Type.String(),
78
78
  message: Type.String({ description: "Answer for the child" }),
79
79
  });
80
+ export const ResumeParam = Type.Object({
81
+ runId: Type.String(),
82
+ taskId: Type.String({ description: "Failed/aborted task to revive" }),
83
+ message: Type.Optional(
84
+ Type.String({ description: "Prompt delivered on resume (default: recap state, then continue the original task)" }),
85
+ ),
86
+ model: Type.Optional(
87
+ Type.String({
88
+ description:
89
+ "Model override for the resumed session (provider/model-id) — use when the original provider is rate-limited",
90
+ }),
91
+ ),
92
+ });
80
93
  export const SteerParam = Type.Object({
81
94
  runId: Type.String(),
82
95
  taskId: Type.Optional(Type.String({ description: "Specific task id; defaults to all still-running tasks" })),
package/src/types.ts CHANGED
@@ -30,6 +30,7 @@ export interface TaskSnapshot {
30
30
  toolCalls: number;
31
31
  lastActivity?: string;
32
32
  finalText?: string;
33
+ notifiedParent?: boolean;
33
34
  error?: string;
34
35
  model?: string;
35
36
  modelNote?: string;
package/src/worktree.ts CHANGED
@@ -98,6 +98,33 @@ export function createWorktree(cwd: string, runId: string, taskId: string, baseR
98
98
  return { root, path, branch, base };
99
99
  }
100
100
 
101
+ export function attachWorktree(cwd: string, branch: string): Worktree | undefined {
102
+ if (!branch.startsWith(BRANCH_PREFIX)) return undefined;
103
+ const root = repoRoot(cwd);
104
+ if (!root) return undefined;
105
+ const container = subagentsDir(root);
106
+ if (!container) return undefined;
107
+ if (!gitOk(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`])) return undefined;
108
+ const path = join(container, branch.slice(BRANCH_PREFIX.length));
109
+ if (!worktreePaths(root)?.some((p) => samePath(p, path))) {
110
+ if (existsSync(path)) rmSync(path, { recursive: true, force: true });
111
+ git(root, ["worktree", "add", path, branch]);
112
+ }
113
+ let base: string;
114
+ try {
115
+ base = git(root, ["merge-base", "HEAD", branch]);
116
+ } catch {
117
+ base = git(root, ["rev-parse", "HEAD"]);
118
+ }
119
+ const nm = join(root, "node_modules");
120
+ if (existsSync(nm) && !existsSync(join(path, "node_modules"))) {
121
+ try {
122
+ symlinkSync(nm, join(path, "node_modules"));
123
+ } catch {}
124
+ }
125
+ return { root, path, branch, base };
126
+ }
127
+
101
128
  export function commitWorktree(wt: Worktree, message: string): "committed" | "empty" {
102
129
  return commitIn(wt.path, message, wt.branch);
103
130
  }