@howarewoo/pi-subagents 0.2.0 → 0.3.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.
@@ -34,6 +34,9 @@ export type ExecutionRecord = {
34
34
  timer?: ReturnType<typeof setTimeout>;
35
35
  run?: Promise<void>;
36
36
  session?: AgentSession;
37
+ /** Canonical entry tree survives runtime disposal; never substitute a result summary. */
38
+ sessionManager?: SessionManager;
39
+ resourcePrompt?: string;
37
40
  unsubscribe?: () => void;
38
41
  paused?: boolean;
39
42
  prompt?: (text: string) => Promise<void>;
@@ -89,11 +92,15 @@ export async function createChildExecution(
89
92
  ? entry.state.id
90
93
  : `${entry.state.id}-g${entry.state.generation}`,
91
94
  );
92
- await mkdir(taskDir, { mode: 0o700 }); // exclusive ownership: never reuse an existing task record
95
+ const restoring = !!entry.sessionManager;
96
+ if (!restoring) await mkdir(taskDir, { mode: 0o700 }); // exclusive initial ownership
93
97
  const agentDir = join(taskDir, "agent");
94
- await mkdir(agentDir, { mode: 0o700 });
98
+ if (!restoring) await mkdir(agentDir, { mode: 0o700 });
95
99
  const transcriptDir = join(taskDir, "transcripts");
96
- await mkdir(transcriptDir, { mode: 0o700 });
100
+ if (!restoring) await mkdir(transcriptDir, { mode: 0o700 });
101
+ // Each completed window publishes exclusively, leaving all earlier artifacts intact.
102
+ const outcomeDir = restoring ? join(taskDir, `window-${entry.state.window}`) : taskDir;
103
+ if (restoring) await mkdir(outcomeDir, { mode: 0o700 });
97
104
  const target =
98
105
  entry.state.prewalk?.status === "armed"
99
106
  ? validateModelSelection(input.context, entry.state.prewalk.target)
@@ -116,14 +123,15 @@ export async function createChildExecution(
116
123
  target ? [target] : [],
117
124
  );
118
125
  const resources = childResources(
119
- input.profile.prompt +
126
+ (entry.resourcePrompt ??=
127
+ input.profile.prompt +
120
128
  (target
121
129
  ? "\nPrewalk: investigate and plan using only granted context/tools. After the first completed edit or write the same conversation switches once to the explicitly granted implementation target; all normal budgets still apply."
122
130
  : "") +
123
131
  `\nYour delegation identity is ${entry.state.id} generation ${entry.state.generation}; parent ${entry.state.parentId}. Coordination, if granted, is limited to family identities explicitly supplied to you.` +
124
132
  (entry.validator
125
133
  ? "\nThis assignment requires a structured result. Call submit_result with the actual value; final prose is not a submission."
126
- : ""),
134
+ : "")),
127
135
  input.skills,
128
136
  );
129
137
  const loader = new DefaultResourceLoader({
@@ -316,9 +324,10 @@ export async function createChildExecution(
316
324
  compaction: { enabled: false },
317
325
  retry: { enabled: false },
318
326
  }),
319
- sessionManager: SessionManager.create(selection.cwd, transcriptDir),
327
+ sessionManager: entry.sessionManager ?? SessionManager.create(selection.cwd, transcriptDir),
320
328
  });
321
329
  entry.session = session;
330
+ entry.sessionManager = session.sessionManager;
322
331
  if (session.thinkingLevel !== selection.thinkingLevel)
323
332
  throw new Error("SDK did not preserve selected effort");
324
333
  if (
@@ -389,7 +398,7 @@ export async function createChildExecution(
389
398
  const artifacts: NonNullable<ChildSnapshot["artifacts"]> = {};
390
399
  try {
391
400
  artifacts.text = await saveOutcomeArtifact(
392
- taskDir,
401
+ outcomeDir,
393
402
  "result.txt",
394
403
  last?.content
395
404
  .filter((block) => block.type === "text")
@@ -398,7 +407,7 @@ export async function createChildExecution(
398
407
  );
399
408
  if (entry.state.outcome?.validation === "submitted")
400
409
  artifacts.structured = await saveOutcomeArtifact(
401
- taskDir,
410
+ outcomeDir,
402
411
  "structured.json",
403
412
  JSON.stringify(entry.state.outcome.value),
404
413
  );
@@ -1,6 +1,7 @@
1
1
  import { requireSupportedHost } from "./supported-host.ts";
2
2
  import { leafDenial, toolProvenance, type CapabilityDenial } from "./capabilities.ts";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { readFileSync } from "node:fs";
4
5
  import {
5
6
  SessionManager,
6
7
  type AgentSession,
@@ -110,12 +111,14 @@ export class DelegationCoordinator {
110
111
  presence: this.presence.get(state.id),
111
112
  }));
112
113
  }
113
- generations(id: string): ChildSnapshot[] {
114
+ /** Full parent-lifetime session roster, including retained historical generations. */
115
+ sessions(): ChildSnapshot[] {
114
116
  return [...this.history.values()]
115
- .map((record) => record.state)
116
- .concat(this.list())
117
- .filter((state) => state.id === id)
118
- .map((state) => structuredClone(state));
117
+ .map((record) => structuredClone(record.state))
118
+ .concat(this.list());
119
+ }
120
+ generations(id: string): ChildSnapshot[] {
121
+ return this.sessions().filter((state) => state.id === id);
119
122
  }
120
123
  private listeners = new Set<(event: RegistryEvent) => void>();
121
124
  private parentMutations = new Set<string>();
@@ -224,6 +227,15 @@ export class DelegationCoordinator {
224
227
  throw new Error("Prewalk target requires an exact model grant");
225
228
  validateModelSelection(selection, input.profile.prewalk);
226
229
  }
230
+ this.validateAdmission(input, effective, input.model);
231
+ return selection;
232
+ }
233
+ /** Shared writer/lineage checks; resume supplies its retained selection, not admission defaults. */
234
+ private validateAdmission(
235
+ input: StartTask,
236
+ effective: ModelSelection,
237
+ explicit?: ModelSelection,
238
+ ): void {
227
239
  const writer = this.writer ? this.entries.get(this.writer) : undefined;
228
240
  if (
229
241
  isWriter(input.profile) &&
@@ -234,7 +246,7 @@ export class DelegationCoordinator {
234
246
  const parent = this.entry(input.parent);
235
247
  if (parent.cancelled || parent.paused || parent.state.disposed)
236
248
  throw new Error("Delegating parent is no longer active");
237
- this.assertDescendantModels(parent, input.profile, effective, input.model);
249
+ this.assertDescendantModels(parent, input.profile, effective, explicit);
238
250
  if (!parent.input.profile.spawn?.includes(input.profile.name))
239
251
  throw new Error("Ancestor spawn role denied");
240
252
  if (parent.state.depth >= Math.min(this.policy.depth, parent.state.limits.depth))
@@ -248,7 +260,6 @@ export class DelegationCoordinator {
248
260
  ancestor = this.entries.get(ancestor.state.parentId);
249
261
  }
250
262
  }
251
- return selection;
252
263
  }
253
264
  private assertDescendantModels(
254
265
  parent: Entry,
@@ -721,15 +732,56 @@ export class DelegationCoordinator {
721
732
  async resume(ref: ChildRef, text: string): Promise<ChildRef> {
722
733
  requireSupportedHost();
723
734
  const entry = this.entry(ref);
724
- if (entry.resuming || entry.state.status !== "paused" || entry.cancelled || this.closing)
725
- throw new Error("Resume requires a drained paused assignment; duplicate resume rejected");
735
+ const completed = entry.state.status === "settled" && entry.state.disposed;
736
+ const eligible = entry.state.status === "paused" || completed;
737
+ if (
738
+ entry.resuming ||
739
+ !eligible ||
740
+ entry.cancelled ||
741
+ this.closing ||
742
+ this.presence.get(ref.id) === "stopped"
743
+ )
744
+ throw new Error(
745
+ "Resume requires a drained paused or completed conversation; stopped/failed identities and duplicate resume rejected",
746
+ );
747
+ if ((entry.state.window ?? 1) >= MAX_GENERATIONS)
748
+ throw new Error("Resume window limit (64) reached");
749
+ if (
750
+ completed &&
751
+ (!entry.sessionManager ||
752
+ !entry.finalizedMessages?.length ||
753
+ JSON.stringify(entry.sessionManager.buildSessionContext().messages) !==
754
+ JSON.stringify(entry.finalizedMessages))
755
+ )
756
+ throw new Error("Resume unavailable: canonical conversation state was not retained");
757
+ if (completed) {
758
+ // SessionManager.open() can silently initialize missing/empty files. Never use that
759
+ // fallback here: the retained tree AND its original persisted transcript must agree.
760
+ try {
761
+ const persisted = readFileSync(entry.state.transcript!, "utf8")
762
+ .trim()
763
+ .split("\n")
764
+ .map((line) => JSON.parse(line));
765
+ const manager = entry.sessionManager!;
766
+ if (
767
+ JSON.stringify(persisted) !==
768
+ JSON.stringify([manager.getHeader(), ...manager.getEntries()])
769
+ )
770
+ throw new Error("transcript differs from retained canonical entries");
771
+ } catch (error) {
772
+ throw new Error(
773
+ `Resume unavailable: original transcript cannot be restored: ${String(error)}`,
774
+ );
775
+ }
776
+ } else if (entry.prompted && !entry.session) {
777
+ throw new Error("Resume unavailable: paused SDK conversation was not retained");
778
+ }
726
779
  if (!text.trim()) throw new Error("Nonempty continuation text is required");
727
780
  entry.resuming = true;
728
781
  try {
729
782
  const parent = this.renewalParent(ref);
730
783
  const input = entry.input;
731
784
  const policy = await resolveScheduling(input.context.cwd, input.context.isProjectTrusted());
732
- this.configure(policy);
733
785
  const limits = { ...entry.state.limits };
734
786
  for (const key of Object.keys(limits) as (keyof ExecutionLimits)[])
735
787
  limits[key] = Math.min(
@@ -810,21 +862,52 @@ export class DelegationCoordinator {
810
862
  throw new Error("Resume denied: delegation depth limit");
811
863
  // Reuse the original admission's writer and nested no-lending checks, without
812
864
  // admitting another task or charging another spawn.
813
- this.validate(input);
865
+ this.validateAdmission(
866
+ input,
867
+ selected,
868
+ entry.state.prewalk?.status === "armed" ? undefined : selected,
869
+ );
814
870
  this.renewalParent(ref);
815
871
  const available =
816
872
  this.active().length < policy.concurrency &&
817
873
  (!isWriter(input.profile) || (!this.writer && !this.parentMutations.size));
818
874
  if (parent && (this.queue.size || !available))
819
875
  throw new Error("Nested capacity unavailable; no permit lending");
820
- this.assertQueueCapacity([input]);
876
+ const previousPolicy = this.policy;
877
+ this.policy = policy;
878
+ try {
879
+ this.assertQueueCapacity([input]);
880
+ } finally {
881
+ this.policy = previousPolicy;
882
+ }
821
883
  if (
822
884
  entry.cancelled ||
823
885
  this.closing ||
824
- entry.state.disposed ||
825
- entry.state.status !== "paused"
886
+ this.presence.get(ref.id) === "stopped" ||
887
+ (completed ? entry.state.status !== "settled" : entry.state.status !== "paused")
826
888
  )
827
889
  throw new Error("Resume cancelled by stop or shutdown");
890
+ if (completed) {
891
+ const { window, status, result, outcome, artifacts, error, endedAt } = entry.state;
892
+ entry.state = {
893
+ ...entry.state,
894
+ completedWindows: [
895
+ ...(entry.state.completedWindows ?? []),
896
+ structuredClone({ window, status, result, outcome, artifacts, error, endedAt }),
897
+ ],
898
+ disposed: false,
899
+ endedAt: null,
900
+ outcome: entry.validator
901
+ ? { validation: "awaiting", attempts: 0, errors: [], finalized: false }
902
+ : undefined,
903
+ artifacts: undefined,
904
+ result: "",
905
+ progress: "",
906
+ };
907
+ entry.selection.model = validateModelSelection(input.context, selected);
908
+ entry.selection.thinkingLevel = selected.effort;
909
+ }
910
+ this.policy = policy;
828
911
  entry.done = new Promise<void>((resolve) => {
829
912
  entry.finish = resolve;
830
913
  });
@@ -989,24 +1072,27 @@ export class DelegationCoordinator {
989
1072
  dispose: () => this.dispose(entry),
990
1073
  });
991
1074
  }
992
- private closeDescendantPresence(entry: Entry): void {
1075
+ private closeDescendantPresence(entry: Entry, completed = false): void {
993
1076
  for (const child of this.entries.values())
994
1077
  if (
995
1078
  child.state.parentId === entry.state.id &&
996
1079
  child.state.parentGeneration === entry.state.generation
997
1080
  ) {
998
- this.presence.set(child.state.id, "stopped");
999
- this.closeDescendantPresence(child);
1081
+ // Ancestor completion closes live/paused descendants, but does not retire a
1082
+ // successful retained conversation. Explicit stop remains sticky.
1083
+ if (!(completed && child.state.disposed && child.state.status === "settled"))
1084
+ this.presence.set(child.state.id, "stopped");
1085
+ this.closeDescendantPresence(child, completed);
1000
1086
  }
1001
1087
  }
1002
1088
  private dispose(entry: Entry): void {
1003
1089
  if (entry.state.disposed) return;
1004
1090
  this.queue.delete(entry);
1005
1091
  clearTimeout(entry.timer);
1006
- entry.finalizedMessages = structuredClone(entry.session?.messages ?? []);
1092
+ if (entry.session) entry.finalizedMessages = structuredClone(entry.session.messages);
1007
1093
  entry.unsubscribe?.();
1008
1094
  entry.session?.dispose();
1009
- // Retain immutable evidence, not the disposed SDK session graph, while idle/parked.
1095
+ // Retain the authoritative SessionManager and immutable evidence, not the SDK runtime graph.
1010
1096
  entry.unsubscribe = undefined;
1011
1097
  entry.session = undefined;
1012
1098
  entry.prompt = undefined;
@@ -1015,11 +1101,14 @@ export class DelegationCoordinator {
1015
1101
  entry.run = undefined;
1016
1102
  entry.pauseDrain = undefined;
1017
1103
  if (this.writer === entry.state.id) this.writer = undefined;
1018
- this.closeDescendantPresence(entry);
1104
+ this.closeDescendantPresence(
1105
+ entry,
1106
+ entry.state.status === "settled" && !entry.cancelled && !this.closing,
1107
+ );
1019
1108
  this.presence.set(entry.state.id, entry.cancelled || this.closing ? "stopped" : "idle");
1020
1109
  this.update(entry, {
1021
1110
  disposed: true,
1022
- retainedContext: false,
1111
+ retainedContext: !!entry.sessionManager && entry.state.status === "settled",
1023
1112
  endedAt: Date.now(),
1024
1113
  writerLease: false,
1025
1114
  outcome: entry.state.outcome ? { ...entry.state.outcome, finalized: true } : undefined,
@@ -42,6 +42,13 @@ export type ChildSnapshot = Readonly<
42
42
  pauseReason?: string;
43
43
  retainedContext?: boolean;
44
44
  window?: number;
45
+ /** Immutable completion evidence from earlier windows of this same conversation. */
46
+ completedWindows?: ReadonlyArray<
47
+ Pick<
48
+ ChildSnapshot,
49
+ "window" | "status" | "result" | "outcome" | "artifacts" | "error" | "endedAt"
50
+ >
51
+ >;
45
52
  status:
46
53
  | "queued"
47
54
  | "setup"
package/src/hub.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  assignmentHeadline,
28
28
  lineText,
29
29
  taskDetails,
30
+ taskTree,
30
31
  transcriptLines,
31
32
  } from "./task-presentation.ts";
32
33
 
@@ -82,26 +83,27 @@ export function createHub(
82
83
  });
83
84
  const identity = (ref: ChildRef) => `${ref.id}/${ref.generation}`;
84
85
  const roster = () =>
85
- coordinator
86
- .list()
87
- .flatMap((child) => coordinator.generations(child.id))
88
- .filter((child) =>
89
- lineText(
90
- [
91
- child.id,
92
- child.parentId,
93
- child.profile,
94
- child.status,
95
- child.assignment,
96
- child.progress,
97
- child.result,
98
- child.error,
99
- child.model.id,
100
- ].join(" "),
101
- )
102
- .toLowerCase()
103
- .includes(query.toLowerCase()),
104
- );
86
+ taskTree(
87
+ coordinator
88
+ .sessions()
89
+ .filter((child) =>
90
+ lineText(
91
+ [
92
+ child.id,
93
+ child.parentId,
94
+ child.profile,
95
+ child.status,
96
+ child.assignment,
97
+ child.progress,
98
+ child.result,
99
+ child.error,
100
+ child.model.id,
101
+ ].join(" "),
102
+ )
103
+ .toLowerCase()
104
+ .includes(query.toLowerCase()),
105
+ ),
106
+ ).map(({ child }) => child);
105
107
  const selection = () => {
106
108
  const children = roster();
107
109
  if (!children.some((child) => identity(child) === selected)) {
@@ -205,7 +207,7 @@ export function createHub(
205
207
  paint(
206
208
  interactionTarget
207
209
  ? "Resize · Esc cancels"
208
- : "Subagent Hub " + coordinator.list().length,
210
+ : "Subagent Hub " + coordinator.sessions().length,
209
211
  ),
210
212
  ];
211
213
  const inner = Math.max(0, width - 4);
@@ -397,25 +399,27 @@ export function createHub(
397
399
  const position = `${tab === "thread" ? (following ? "LIVE · " : "READING · ") : ""}${offset + 1}–${Math.min(wrapped.length, offset + viewport)}/${wrapped.length} lines${tab === "thread" ? (following ? " · k/PgUp read" : " · End: live") : offset < maxOffset ? " · more ↓" : ""}`;
398
400
  const pane: string[] = [];
399
401
  if (wide) {
400
- const slots = Math.max(1, Math.floor((paneHeight - 2) / 3));
402
+ const slots = Math.max(1, Math.floor((paneHeight - 2) / 2));
401
403
  const start = Math.max(0, index - slots + 1);
402
404
  const left = [
403
405
  theme.fg("accent", `SUBAGENTS · ${children.length} generations`),
404
- ...children.slice(start, start + slots).flatMap((item) => {
405
- const indent = " ".repeat(Math.min(4, Math.max(0, item.depth - 1)));
406
- const prefix = `${identity(item) === selected ? ">" : " "} ${indent}└ `;
407
- return [
408
- theme.fg(
409
- identity(item) === selected ? "accent" : "text",
410
- `${prefix}${assignmentHeadline(item)}${historical(item) ? " · history" : ""}`,
411
- ),
412
- theme.fg(
413
- statusColor(item),
414
- `${" ".repeat(visibleWidth(prefix))}${lineText(item.profile)} · ${item.status}`,
415
- ),
416
- "", // Separate agents without exposing internal generation numbers.
417
- ];
418
- }),
406
+ ...taskTree(children)
407
+ .slice(start, start + slots)
408
+ .flatMap(({ child: item, indent, last, continuation }) => {
409
+ const prefix = `${identity(item) === selected ? ">" : " "} ${indent}${last ? "└" : "├"} `;
410
+ return [
411
+ theme.fg(identity(item) === selected ? "accent" : "text", prefix[0]) +
412
+ theme.fg("text", prefix.slice(1)) +
413
+ theme.fg(statusColor(item), "● ") +
414
+ theme.fg(
415
+ identity(item) === selected ? "accent" : "text",
416
+ `${assignmentHeadline(item)}${historical(item) ? " · history" : ""}`,
417
+ ),
418
+ theme.fg("text", ` ${continuation}`) +
419
+ theme.fg(statusColor(item), lineText(item.profile)) +
420
+ theme.fg("text", ` · ${item.status}`),
421
+ ];
422
+ }),
419
423
  ];
420
424
  const right = [
421
425
  theme.fg("accent", lineText(selectedLabel)),
@@ -446,19 +450,23 @@ export function createHub(
446
450
  } else {
447
451
  const suffix = child ? ` · ${index + 1}/${children.length}` : "";
448
452
  pane.push(
449
- theme.fg(
450
- "accent",
451
- child
452
- ? `> └ ${truncateToWidth(agentLabel(child), Math.max(0, inner - 4 - visibleWidth(suffix)))}${suffix}`
453
- : "No selection",
454
- ),
453
+ child
454
+ ? theme.fg("accent", ">") +
455
+ theme.fg("text", " └ ") +
456
+ theme.fg(statusColor(child), "● ") +
457
+ theme.fg(
458
+ "accent",
459
+ `${truncateToWidth(agentLabel(child), Math.max(0, inner - 6 - visibleWidth(suffix)))}${suffix}`,
460
+ )
461
+ : theme.fg("accent", "No selection"),
455
462
  );
456
463
  if (paneHeight > 1)
457
464
  pane.push(
458
- theme.fg(
459
- child ? statusColor(child) : "muted",
460
- `${tab.toUpperCase()}${child ? ` · ${child.profile} · ${child.status}` : ""}`,
461
- ),
465
+ child
466
+ ? theme.fg("text", `${tab.toUpperCase()} · `) +
467
+ theme.fg(statusColor(child), lineText(child.profile)) +
468
+ theme.fg("text", ` · ${child.status}`)
469
+ : theme.fg("muted", tab.toUpperCase()),
462
470
  );
463
471
  if (paneHeight > 2)
464
472
  pane.push(
@@ -107,7 +107,7 @@ export default function delegation(pi: ExtensionAPI) {
107
107
  (tui, theme) => {
108
108
  renderWidget = () => tui.requestRender();
109
109
  return createChildWidget(
110
- () => coordinator.list(),
110
+ () => coordinator.sessions(),
111
111
  theme,
112
112
  () => tui.terminal.rows,
113
113
  );
@@ -373,7 +373,7 @@ export default function delegation(pi: ExtensionAPI) {
373
373
  label: "Coordinate delegation",
374
374
  exposure: "model-only",
375
375
  description:
376
- "Live-parent identity-pinned coordination. message targets a running family agent (omit target for root inbox, direct children only); inbox reads own bounded mailbox; wait targets an outcome or own inbox with required 1–60000ms timeout, retaining capacity. No transcript access. Child actions require explicit operator grants. Root additionally has status/stop/park, paused resume (assignment is the appended continuation), and idle followup/parked revive. Resume retains identity and full context, resets only the deadline window, and requires explicit child resumes after parent resume. Followup/revive create a fresh generation/session with current model/auth and narrowed current grants, no old tool replay or historical recovery. Message accepted/queued is NOT model acknowledgment; delivered means transcript/inbox visibility only. 4096-byte messages, 64 mailbox/waiters, 512 messages and 64 generations per live parent.",
376
+ "Live-parent identity-pinned coordination. message targets a running family agent (omit target for root inbox, direct children only); inbox reads own bounded mailbox; wait targets an outcome or own inbox with required 1–60000ms timeout, retaining capacity. No transcript access. Child actions require explicit operator grants. Root additionally has status/stop/park, paused/completed resume (assignment is the appended continuation), and idle followup/parked revive. Resume retains identity and full context, resets only the deadline window, and requires explicit child resumes after parent resume. Followup/revive create a fresh generation/session with current model/auth and narrowed current grants, no old tool replay or historical recovery. Message accepted/queued is NOT model acknowledgment; delivered means transcript/inbox visibility only. 4096-byte messages, 64 mailbox/waiters, 512 messages and 64 generations per live parent.",
377
377
  parameters: coordinationParameters,
378
378
  async execute(callerId, args, signal, _update, ctx) {
379
379
  if (["resume", "followup", "revive"].includes(args.action)) requireSupportedHost();
@@ -493,10 +493,10 @@ export default function delegation(pi: ExtensionAPI) {
493
493
  promptGuidelines: [
494
494
  "Use subagent for native worker delegation requested by the user or an active workflow; no extension-specific user instruction or enable call is needed. Use agent worker for implementation, scout for research, review for review, or general for restricted inherited capabilities.",
495
495
  "Pass subagent the approved task handoff and existing workspace cwd. Preserve selected model routing; no model override is needed to inherit the parent. Pass accessible skill/reference paths in the task; general inherits available skills and callable builtins, plus operator-reviewed leaf tools only; parent instructions/history and project trust are not inherited.",
496
- "subagent launches are async by default and wake this session after actual drain. Continue independent work or yield; do not poll or sleep. Use the exact returned id (UUID@generation) for status, steer, stop or resume. Resume continues a paused conversation with its full context and cumulative budgets; completed idle/parked work starts a fresh generation.",
496
+ "subagent launches are async by default and wake this session after actual drain. Continue independent work or yield; do not poll or sleep. Use the exact returned id (UUID@generation) for status, steer, stop or resume. Resume continues a drained paused or completed conversation with its same identity/generation, full SDK context and cumulative budgets; unavailable context and stopped/failed identities reject. Use explicit delegate_coordinate followup/revive for fresh completed work.",
497
497
  ],
498
498
  description:
499
- "Native delegation for orchestration and coding task owners. Launch {agent,task,cwd?,model?,async?} or an ordered {tasks:[...],async?,cwd?} batch. worker maps explicitly to the implementation profile; unknown/custom agent names reject. Model strings require exact provider/id[:effort] and operator grants. Actions: agents, list, status/stop with exact returned id, steer/resume with id and message. Uses the same registry, parent permissions, limits and global writer lease as delegate_task. Existing workspaces only: file paths and shell cwd are explicitly adapted through root builtin hooks. No automatic worktree creation/commits/apply/deletion, detached survival, script workflows or blanket competing API parity. Do not load another provider of the subagent tool alongside this extension.",
499
+ "Native delegation for orchestration and coding task owners. Launch {agent,task,cwd?,model?,async?} or an ordered {tasks:[...],async?,cwd?} batch. worker maps explicitly to the implementation profile; unknown/custom agent names reject. Model strings require exact provider/id[:effort] and operator grants. Actions: agents, list, status/stop with exact returned id, steer/resume with id and message. Resume requires a drained paused or completed session and restores its full conversation with the same identity/generation and session count; unavailable context and stopped/failed identities reject. Uses the same registry, parent permissions, limits and global writer lease as delegate_task. Existing workspaces only: file paths and shell cwd are explicitly adapted through root builtin hooks. No automatic worktree creation/commits/apply/deletion, detached survival, script workflows or blanket competing API parity. Do not load another provider of the subagent tool alongside this extension.",
500
500
  parameters: compatibleParameters,
501
501
  async execute(callerId, args, signal, onUpdate, ctx) {
502
502
  if (!("action" in args) || args.action === "resume" || args.action === "steer")
@@ -564,20 +564,10 @@ export default function delegation(pi: ExtensionAPI) {
564
564
  if (args.action === "stop") await owner.stop(target);
565
565
  else if (args.action === "steer") await owner.steer(target, args.message);
566
566
  else if ("message" in args) {
567
- const presence = owner
568
- .agents()
569
- .find(
570
- (state) => state.id === target.id && state.generation === target.generation,
571
- )?.presence;
572
567
  const response = await coordinationTool(undefined, undefined, true).execute(
573
568
  callerId,
574
569
  {
575
- action:
576
- owner.snapshot(target)?.status === "paused"
577
- ? "resume"
578
- : presence === "parked"
579
- ? "revive"
580
- : "followup",
570
+ action: "resume",
581
571
  target,
582
572
  assignment: args.message,
583
573
  },
@@ -586,10 +576,6 @@ export default function delegation(pi: ExtensionAPI) {
586
576
  ctx,
587
577
  );
588
578
  const next = response.details as ChildSnapshot;
589
- if (next.generation !== target.generation)
590
- void owner.wait(next).then((state) => {
591
- if (!closing && coordinator === owner) show(compatible(state), state.transcript, true);
592
- });
593
579
  return result(compatible(next));
594
580
  }
595
581
  return result(compatible(owner.snapshot(target)!));
@@ -6,9 +6,16 @@ import {
6
6
  } from "@earendil-works/pi-tui";
7
7
  import type { Theme } from "@earendil-works/pi-coding-agent";
8
8
  import type { ChildSnapshot } from "./coordinator.ts";
9
- import { active, activity, assignmentHeadline, lineText, statuses } from "./task-presentation.ts";
9
+ import {
10
+ active,
11
+ activity,
12
+ assignmentHeadline,
13
+ lineText,
14
+ statuses,
15
+ taskTree,
16
+ } from "./task-presentation.ts";
10
17
 
11
- /** Event-driven, bounded current-agent tree; history and full metrics remain in the Hub. */
18
+ /** Full session totals with an event-driven, bounded active-agent tree; details remain in the Hub. */
12
19
  export function createChildWidget(
13
20
  snapshot: () => ChildSnapshot[],
14
21
  theme: Pick<Theme, "fg"> & Partial<Pick<Theme, "bold">>,
@@ -55,15 +62,31 @@ export function createChildWidget(
55
62
  const activityRows = live.length && room >= (live.length > 1 ? 3 : 2) ? 1 : 0;
56
63
  const slots = room - activityRows;
57
64
  const overflow = live.length > slots;
58
- const shown = live.slice(0, Math.max(0, slots - (overflow ? 1 : 0)));
59
- shown.forEach((child, index) => {
60
- const last = index === shown.length - 1 && shown.length === live.length;
65
+ const visible = taskTree(live)
66
+ .slice(0, Math.max(0, slots - (overflow ? 1 : 0)))
67
+ .map(({ child }) => child);
68
+ // Close the last visible sibling; the root overflow row represents hidden tasks.
69
+ const shown = taskTree(visible, { laneWidth: 3, rootContinues: overflow });
70
+ const lead = visible[0];
71
+ const nestedUnderLead = (child: ChildSnapshot) =>
72
+ lead &&
73
+ child.parentId === lead.id &&
74
+ (child.parentGeneration ?? child.generation) === lead.generation;
75
+ const hiddenRunning = activityRows
76
+ ? live.filter(
77
+ (child) =>
78
+ nestedUnderLead(child) && child.status === "running" && !visible.includes(child),
79
+ ).length
80
+ : 0;
81
+ shown.forEach(({ child, indent, last, continuation }, index) => {
61
82
  const color =
62
83
  child.status === "draining" || child.status === "pausing" ? "warning" : "accent";
63
84
  const role = lineText(child.profile);
64
- const indent = " ".repeat(Math.min(3, Math.max(0, child.depth - 1)));
65
85
  const prefix =
66
- theme.fg("muted", `${indent}${last ? "└─" : "├─"} `) +
86
+ theme.fg(
87
+ "muted",
88
+ `${indent}${last && !(hiddenRunning && nestedUnderLead(child)) ? "└─" : "├─"} `,
89
+ ) +
67
90
  theme.fg(color, "● ") +
68
91
  (theme.bold?.(role) ?? role) +
69
92
  " ";
@@ -92,8 +115,12 @@ export function createChildWidget(
92
115
  ) +
93
116
  theme.fg("muted", " · " + metric),
94
117
  );
95
- if (!index && activityRows) {
96
- const prefix = `${indent}${last ? " " : "│ "} └ `;
118
+ if (!index && activityRows && !hiddenRunning) {
119
+ const next = shown[index + 1]?.child;
120
+ const hasVisibleChild =
121
+ next?.parentId === child.id &&
122
+ (next.parentGeneration ?? next.generation) === child.generation;
123
+ const prefix = `${continuation}${hasVisibleChild ? "├" : "└"} `;
97
124
  const text = lineText(activity(child));
98
125
  const columns = Math.max(0, width - visibleWidth(prefix));
99
126
  const length = visibleWidth(text);
@@ -106,6 +133,14 @@ export function createChildWidget(
106
133
  rows.push(theme.fg("muted", prefix + preview));
107
134
  }
108
135
  });
136
+ if (hiddenRunning) {
137
+ rows.push(
138
+ theme.fg(
139
+ "muted",
140
+ `${shown[0].continuation}└─ ${hiddenRunning} more nested subagent${hiddenRunning === 1 ? "" : "s"} running`,
141
+ ),
142
+ );
143
+ }
109
144
  if (shown.length < live.length && room)
110
145
  rows.push(theme.fg("muted", `└─ ${live.length - shown.length} more · /subagent-hub`));
111
146
  return rows.slice(0, budget).map((line) => truncateToWidth(line, width));
@@ -20,6 +20,37 @@ export const activity = (child: ChildSnapshot) =>
20
20
  child.progress ||
21
21
  "Waiting for SDK activity";
22
22
  export const writerWarning = "Model-tool writer only: no conflicting manual !/!! or external edits";
23
+ /** Presentation-only preorder, keyed by exact parent generation. */
24
+ export function taskTree(children: ChildSnapshot[], { laneWidth = 4, rootContinues = false } = {}) {
25
+ const byIdentity = new Map(children.map((child) => [`${child.id}/${child.generation}`, child]));
26
+ const siblings = new Map<ChildSnapshot | undefined, ChildSnapshot[]>();
27
+ for (const child of children) {
28
+ const candidate = byIdentity.get(
29
+ `${child.parentId}/${child.parentGeneration ?? child.generation}`,
30
+ );
31
+ const parent = candidate && candidate.depth < child.depth ? candidate : undefined;
32
+ const group = siblings.get(parent) ?? [];
33
+ group.push(child);
34
+ siblings.set(parent, group);
35
+ }
36
+ const rows: { child: ChildSnapshot; indent: string; last: boolean; continuation: string }[] = [];
37
+ const visit = (parent: ChildSnapshot | undefined, lanes: string) => {
38
+ const group = siblings.get(parent) ?? [];
39
+ group.forEach((child, index) => {
40
+ const last = index === group.length - 1 && (parent !== undefined || !rootContinues);
41
+ // Missing/filtered ancestors retain depth without inventing continuation pipes.
42
+ const indent = parent
43
+ ? lanes.slice(0, laneWidth * 3)
44
+ : " ".repeat(laneWidth * Math.min(3, Math.max(0, child.depth - 1)));
45
+ const continuation = indent + (last ? " " : "│") + " ".repeat(laneWidth - 1);
46
+ rows.push({ child, indent, last, continuation });
47
+ visit(child, continuation);
48
+ });
49
+ };
50
+ visit(undefined, "");
51
+ return rows;
52
+ }
53
+
23
54
  export const statuses = [
24
55
  "queued",
25
56
  "setup",