@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.
- package/CHANGELOG.md +39 -0
- package/README.md +7 -7
- package/docs/delegation-contract.md +8 -8
- package/docs/images/subagent-hub.svg +1 -1
- package/docs/images/subagent-widget.svg +1 -1
- package/docs/verification.md +5 -1
- package/package.json +1 -1
- package/src/child-execution.ts +17 -8
- package/src/coordinator.ts +110 -21
- package/src/delegation-types.ts +7 -0
- package/src/hub.ts +55 -47
- package/src/pi-extended-subagents.ts +5 -19
- package/src/subagent-widget.ts +44 -9
- package/src/task-presentation.ts +31 -0
package/src/child-execution.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
+
outcomeDir,
|
|
402
411
|
"structured.json",
|
|
403
412
|
JSON.stringify(entry.state.outcome.value),
|
|
404
413
|
);
|
package/src/coordinator.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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,
|
|
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
|
-
|
|
725
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
999
|
-
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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,
|
package/src/delegation-types.ts
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
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) /
|
|
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
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
identity(item) === selected ? "accent" : "text",
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
450
|
-
"accent",
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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.
|
|
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;
|
|
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)!));
|
package/src/subagent-widget.ts
CHANGED
|
@@ -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 {
|
|
9
|
+
import {
|
|
10
|
+
active,
|
|
11
|
+
activity,
|
|
12
|
+
assignmentHeadline,
|
|
13
|
+
lineText,
|
|
14
|
+
statuses,
|
|
15
|
+
taskTree,
|
|
16
|
+
} from "./task-presentation.ts";
|
|
10
17
|
|
|
11
|
-
/**
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
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
|
|
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));
|
package/src/task-presentation.ts
CHANGED
|
@@ -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",
|