@arhen/pi-core-subagent 1.1.5 → 1.1.7
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 +7 -7
- package/package.json +2 -2
- package/src/index.ts +78 -27
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# @arhen/pi-subagent
|
|
1
|
+
# @arhen/pi-core-subagent
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@arhen/pi-subagent)
|
|
3
|
+
[](https://www.npmjs.com/package/@arhen/pi-core-subagent)
|
|
4
4
|
[](./LICENSE)
|
|
5
5
|
[](https://github.com/earendil-works/pi)
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@ Built for one job: delegate work to isolated subagents **without bloating the pa
|
|
|
10
10
|
|
|
11
11
|
## Design principles
|
|
12
12
|
|
|
13
|
-
- **No agent files
|
|
13
|
+
- **No agent files, no discovery.** The leader defines every subagent inline per call — name, system prompt, toolset. Nothing is read from or written to disk.
|
|
14
14
|
- **Two toolsets only.** Read-only (`read, grep, find, ls` — default) or write (`read, grep, find, ls, bash, edit, write` — `write: true`). No per-agent tool config surface.
|
|
15
15
|
- **In-process** — children are `AgentSession`s in the same runtime. No process spawn, no context bleed.
|
|
16
16
|
- **Zero parent-context injection.** No catalog, no context hook. 6 slim tools total.
|
|
@@ -20,13 +20,13 @@ Built for one job: delegate work to isolated subagents **without bloating the pa
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
22
22
|
```sh
|
|
23
|
-
pi install npm:@arhen/pi-subagent
|
|
23
|
+
pi install npm:@arhen/pi-core-subagent
|
|
24
24
|
# or locally: pi install /path/to/pi-subagents
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
## Usage — the leader invents the agents
|
|
28
28
|
|
|
29
|
-
Define agents inline
|
|
29
|
+
Define agents inline per call — never creates or reads agent files. Model resolution: explicit `provider/model-id` (or bare id) via the pi model registry → agent-file `model` → the parent's current model → settings default.
|
|
30
30
|
|
|
31
31
|
```json
|
|
32
32
|
{
|
|
@@ -84,7 +84,7 @@ Background + intercom:
|
|
|
84
84
|
|
|
85
85
|
### Per-task fields
|
|
86
86
|
|
|
87
|
-
`agent` (name you invent
|
|
87
|
+
`agent` (name you invent — required), `task` (required), `prompt` (system prompt, optional — minimal default used), `write` (toolset, default read-only), plus optional `model` (`provider/model-id`), `thinking` (validated enum: `off|minimal|low|medium|high|xhigh|max`), `tools` (explicit allowlist), `cwd`, `maxRuntimeMs`, `id`. Top-level only: `background`, `notifyPerTask`, `allowIntercom`, `concurrency`.
|
|
88
88
|
|
|
89
89
|
### Child talk tools (when `allowIntercom: true`)
|
|
90
90
|
|
|
@@ -99,7 +99,7 @@ Background + intercom:
|
|
|
99
99
|
|
|
100
100
|
- Parent tools: 6 schemas with short descriptions. **No catalog, no context hook** — nothing injected per request.
|
|
101
101
|
- Background completion: 3-line notice. Full text only via `subagent_result`.
|
|
102
|
-
- Children: isolated sessions; talk tools injected only when `allowIntercom`; each child's prompt states its own task id and its siblings' so mailbox addressing works.
|
|
102
|
+
- Children: isolated sessions; talk tools injected only when `allowIntercom`; each child's prompt states its own task id and its siblings' so mailbox addressing works. Model resolution: explicit `provider/model-id` or bare id via the pi model registry → the parent's current model → settings default. Thinking levels validated against the resolved model's `thinkingLevelMap`.
|
|
103
103
|
|
|
104
104
|
## Development
|
|
105
105
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arhen/pi-core-subagent",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "pi extension: fast in-process subagents with single/parallel/chain, background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
|
39
|
-
"url": "git+https://github.com/arhen/pi-subagent.git"
|
|
39
|
+
"url": "git+https://github.com/arhen/pi-core-subagent.git"
|
|
40
40
|
},
|
|
41
41
|
"publishConfig": {
|
|
42
42
|
"access": "public",
|
package/src/index.ts
CHANGED
|
@@ -98,7 +98,7 @@ interface RunSnapshot {
|
|
|
98
98
|
concurrency: number;
|
|
99
99
|
/** True once the parent awaited this run — completion notices are redundant then. */
|
|
100
100
|
awaited?: boolean;
|
|
101
|
-
/** Wake the parent (queued follow-up turn) as each task completes. Default
|
|
101
|
+
/** Wake the parent (queued follow-up turn) as each task completes. Default false. */
|
|
102
102
|
notifyPerTask: boolean;
|
|
103
103
|
tasks: TaskSnapshot[];
|
|
104
104
|
aggregateUsage: UsageStats;
|
|
@@ -135,7 +135,9 @@ function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
|
|
|
135
135
|
}
|
|
136
136
|
function truncateText(text: string, max = FINAL_OUTPUT_CAP): string {
|
|
137
137
|
if (Buffer.byteLength(text, "utf8") <= max) return text;
|
|
138
|
-
|
|
138
|
+
let out = text.slice(0, max);
|
|
139
|
+
while (Buffer.byteLength(out, "utf8") > max) out = out.slice(0, -1); // multibyte-safe
|
|
140
|
+
return `${out}\n\n[Output truncated. Full child session is available in the session file.]`;
|
|
139
141
|
}
|
|
140
142
|
function getFirstText(message: AssistantMessage): string {
|
|
141
143
|
for (const part of message?.content ?? []) {
|
|
@@ -396,7 +398,7 @@ class SubagentManager {
|
|
|
396
398
|
private liveChildren = new Map<string, { abort: () => void; dispose: () => void; touchWatchdog: () => void }>();
|
|
397
399
|
private mailboxes: Mailbox = createMailbox();
|
|
398
400
|
private runControllers = new Map<string, AbortController>();
|
|
399
|
-
private
|
|
401
|
+
private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>(); // per-run stream throttle
|
|
400
402
|
private widgetRuns: RunSnapshot[] = [];
|
|
401
403
|
|
|
402
404
|
turnActivity = false;
|
|
@@ -431,14 +433,19 @@ class SubagentManager {
|
|
|
431
433
|
return runId ? this.runs.get(runId) : undefined;
|
|
432
434
|
}
|
|
433
435
|
clearRuns(): void {
|
|
436
|
+
for (const child of this.liveChildren.values()) {
|
|
437
|
+
child.abort();
|
|
438
|
+
child.dispose();
|
|
439
|
+
}
|
|
440
|
+
this.liveChildren.clear();
|
|
434
441
|
this.runs.clear();
|
|
435
442
|
this.settlers.clear();
|
|
436
443
|
this.pendingReplies.clear();
|
|
437
444
|
this.runControllers.clear();
|
|
438
445
|
this.mailboxes = createMailbox();
|
|
439
446
|
this.widgetTui = null; // force re-registration on the next session
|
|
440
|
-
|
|
441
|
-
this.
|
|
447
|
+
for (const t of this.widgetTimers.values()) clearTimeout(t);
|
|
448
|
+
this.widgetTimers.clear();
|
|
442
449
|
this.widgetRuns = [];
|
|
443
450
|
}
|
|
444
451
|
|
|
@@ -455,9 +462,17 @@ class SubagentManager {
|
|
|
455
462
|
if (!Array.isArray(raw)) return;
|
|
456
463
|
runs = (raw as RunSnapshot[]).map((run) => {
|
|
457
464
|
const interrupted = run.tasks.some((t) => !TERMINAL.includes(t.status));
|
|
465
|
+
// A persisted "running" run whose tasks are all terminal (crash between
|
|
466
|
+
// task end and run end) must not stay "running" forever.
|
|
467
|
+
let status = interrupted ? ("aborted" as RunStatus) : run.status;
|
|
468
|
+
if (!TERMINAL.includes(status)) {
|
|
469
|
+
const anyFailed = run.tasks.some((t) => t.status === "failed");
|
|
470
|
+
const anyAborted = run.tasks.some((t) => t.status === "aborted");
|
|
471
|
+
status = anyFailed ? "failed" : anyAborted ? "aborted" : "completed";
|
|
472
|
+
}
|
|
458
473
|
return {
|
|
459
474
|
...run,
|
|
460
|
-
status
|
|
475
|
+
status,
|
|
461
476
|
endedAt: interrupted ? Date.now() : run.endedAt,
|
|
462
477
|
tasks: run.tasks.map((t) => (TERMINAL.includes(t.status) ? t : { ...t, status: "aborted" as TaskStatus, error: t.error || "Interrupted by session reload" })),
|
|
463
478
|
};
|
|
@@ -481,7 +496,7 @@ class SubagentManager {
|
|
|
481
496
|
const parentFile = getParentSessionFile(ctx);
|
|
482
497
|
if (!parentFile) return;
|
|
483
498
|
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
484
|
-
import("fs").then(({ writeFileSync }) => writeFileSync(sidecar, JSON.stringify(this.listRuns().map(cloneRun), null, 2)));
|
|
499
|
+
import("fs").then(({ writeFileSync }) => writeFileSync(sidecar, JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2)));
|
|
485
500
|
} catch {
|
|
486
501
|
/* ignore */
|
|
487
502
|
}
|
|
@@ -531,22 +546,23 @@ class SubagentManager {
|
|
|
531
546
|
}
|
|
532
547
|
private scheduleWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
|
|
533
548
|
this.upsertWidgetRun(run);
|
|
534
|
-
if (
|
|
535
|
-
this.
|
|
536
|
-
this.
|
|
537
|
-
if (this.widgetRuns.length === 0) return;
|
|
538
|
-
const targets = [...this.widgetRuns]; // read at fire: never render stale runs
|
|
549
|
+
if (!run || this.widgetTimers.has(run.id)) return;
|
|
550
|
+
this.widgetTimers.set(run.id, setTimeout(() => {
|
|
551
|
+
this.widgetTimers.delete(run.id);
|
|
539
552
|
if (ctx?.hasUI) {
|
|
540
553
|
this.ensureWidget(ctx);
|
|
541
554
|
this.widgetTui?.requestRender();
|
|
542
555
|
}
|
|
543
|
-
onUpdate?.({ content: [{ type: "text", text: compactLines(run
|
|
544
|
-
}, WIDGET_THROTTLE_MS);
|
|
556
|
+
onUpdate?.({ content: [{ type: "text", text: compactLines(run).join("\n") }] });
|
|
557
|
+
}, WIDGET_THROTTLE_MS));
|
|
545
558
|
}
|
|
546
559
|
private flushWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
|
|
547
|
-
if (
|
|
548
|
-
|
|
549
|
-
|
|
560
|
+
if (run) {
|
|
561
|
+
const t = this.widgetTimers.get(run.id);
|
|
562
|
+
if (t) {
|
|
563
|
+
clearTimeout(t);
|
|
564
|
+
this.widgetTimers.delete(run.id);
|
|
565
|
+
}
|
|
550
566
|
}
|
|
551
567
|
if (!run || this.widgetRuns.length === 0) return;
|
|
552
568
|
if (ctx?.hasUI) {
|
|
@@ -586,6 +602,13 @@ class SubagentManager {
|
|
|
586
602
|
this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
|
|
587
603
|
this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
|
|
588
604
|
this.notifyParent(run, "asked", { taskId: task.id, question });
|
|
605
|
+
// A blocking run's parent can't reply mid-tool (followUp only fires after the
|
|
606
|
+
// tool returns) — only background runs can truly wait for the answer.
|
|
607
|
+
if (!run.background) {
|
|
608
|
+
this.updateTask(run, task, { status: "running" }, ctx);
|
|
609
|
+
this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
|
|
610
|
+
return "Parent cannot answer while this run is blocking. Continue autonomously with your best judgment.";
|
|
611
|
+
}
|
|
589
612
|
const reply = await this.awaitParentReply(run.id, task.id);
|
|
590
613
|
this.updateTask(run, task, { status: "running" }, ctx);
|
|
591
614
|
this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
|
|
@@ -593,10 +616,24 @@ class SubagentManager {
|
|
|
593
616
|
},
|
|
594
617
|
onNotifyParent: (_taskId, message, level) => {
|
|
595
618
|
this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
|
|
619
|
+
if (!run.awaited) {
|
|
620
|
+
try {
|
|
621
|
+
this.pi.sendUserMessage(`[Subagent ${task.agent}] ${message}`, { deliverAs: "followUp" });
|
|
622
|
+
} catch {
|
|
623
|
+
/* parent mid-stream */
|
|
624
|
+
}
|
|
625
|
+
}
|
|
596
626
|
},
|
|
597
627
|
onSendMessage: (_taskId, to, text) => {
|
|
598
628
|
if (to === "leader") {
|
|
599
629
|
this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level: "info", message: text });
|
|
630
|
+
if (!run.awaited) {
|
|
631
|
+
try {
|
|
632
|
+
this.pi.sendUserMessage(`[Subagent ${task.agent}] ${text}`, { deliverAs: "followUp" });
|
|
633
|
+
} catch {
|
|
634
|
+
/* parent mid-stream */
|
|
635
|
+
}
|
|
636
|
+
}
|
|
600
637
|
return true;
|
|
601
638
|
}
|
|
602
639
|
// Run-scoped keys: sibling ids are run-local; cross-run task_1 can never collide.
|
|
@@ -706,7 +743,7 @@ class SubagentManager {
|
|
|
706
743
|
});
|
|
707
744
|
|
|
708
745
|
unsubscribe = child.subscribe((event: AgentSessionEvent) => {
|
|
709
|
-
const active = event.type === "message_update" || event.type === "message_end" || event.type === "tool_execution_start" || event.type === "tool_execution_end" || event.type === "agent_settled";
|
|
746
|
+
const active = event.type === "message_update" || event.type === "message_end" || event.type === "tool_execution_start" || event.type === "tool_execution_update" || event.type === "tool_execution_end" || event.type === "bash_execution_update" || event.type === "agent_settled";
|
|
710
747
|
if (active) {
|
|
711
748
|
watchdog.touch();
|
|
712
749
|
this.emit("subagent:session-event", { runId: run.id, taskId: task.id, seq: eventSeq++, event: { type: event.type } });
|
|
@@ -745,7 +782,10 @@ class SubagentManager {
|
|
|
745
782
|
}
|
|
746
783
|
});
|
|
747
784
|
|
|
748
|
-
const abortChild = () =>
|
|
785
|
+
const abortChild = () => {
|
|
786
|
+
void child?.abort();
|
|
787
|
+
this.runControllers.get(run.id)?.abort(); // parent abort kills ALL siblings, not just this child
|
|
788
|
+
};
|
|
749
789
|
const runController = this.runControllers.get(run.id);
|
|
750
790
|
if (signal) signal.addEventListener("abort", abortChild, { once: true });
|
|
751
791
|
if (runController) runController.signal.addEventListener("abort", abortChild, { once: true });
|
|
@@ -820,6 +860,13 @@ class SubagentManager {
|
|
|
820
860
|
? params.tasks!
|
|
821
861
|
: params.chain!;
|
|
822
862
|
if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
|
|
863
|
+
const ids = new Set<string>();
|
|
864
|
+
for (const input of inputs) {
|
|
865
|
+
if (input.id !== undefined) {
|
|
866
|
+
if (ids.has(input.id)) throw new Error(`Duplicate task id: ${input.id}`);
|
|
867
|
+
ids.add(input.id);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
823
870
|
|
|
824
871
|
const run: RunSnapshot = {
|
|
825
872
|
id: newId("run"),
|
|
@@ -879,7 +926,7 @@ class SubagentManager {
|
|
|
879
926
|
const input = { ...inputs[i]!, task: next };
|
|
880
927
|
task.task = input.task;
|
|
881
928
|
await this.runChild(run, task, input, ctx, signal, onUpdate);
|
|
882
|
-
if (run.notifyPerTask && TERMINAL.includes(task.status)) {
|
|
929
|
+
if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
|
|
883
930
|
this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
|
|
884
931
|
}
|
|
885
932
|
if (task.status !== "completed") break;
|
|
@@ -889,7 +936,7 @@ class SubagentManager {
|
|
|
889
936
|
await mapWithConcurrency(run.tasks, run.mode === "single" ? 1 : run.concurrency, async (task) => {
|
|
890
937
|
const index = run.tasks.indexOf(task);
|
|
891
938
|
await this.runChild(run, task, inputs[index]!, ctx, signal, onUpdate);
|
|
892
|
-
if (run.notifyPerTask && TERMINAL.includes(task.status)) {
|
|
939
|
+
if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
|
|
893
940
|
this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
|
|
894
941
|
}
|
|
895
942
|
});
|
|
@@ -939,7 +986,10 @@ class SubagentManager {
|
|
|
939
986
|
}
|
|
940
987
|
this.settleRun(run.id, run);
|
|
941
988
|
this.runControllers.delete(run.id);
|
|
989
|
+
for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
|
|
990
|
+
this.emit("subagent:run-completed", { runId: run.id, status: "failed", run: cloneRun(run) });
|
|
942
991
|
this.notifyParent(run, "failed");
|
|
992
|
+
this.persist(ctx);
|
|
943
993
|
});
|
|
944
994
|
return { run: cloneRun(run), background: true };
|
|
945
995
|
}
|
|
@@ -947,6 +997,7 @@ class SubagentManager {
|
|
|
947
997
|
cancelRun(runId: string): { aborted: number } {
|
|
948
998
|
const run = this.runs.get(runId);
|
|
949
999
|
if (!run) return { aborted: 0 };
|
|
1000
|
+
if (TERMINAL.includes(run.status)) return { aborted: 0 }; // never corrupt a finished run
|
|
950
1001
|
let aborted = 0;
|
|
951
1002
|
this.runControllers.get(runId)?.abort();
|
|
952
1003
|
for (const [key, child] of this.liveChildren) {
|
|
@@ -957,7 +1008,7 @@ class SubagentManager {
|
|
|
957
1008
|
for (const task of run.tasks) {
|
|
958
1009
|
if (TERMINAL.includes(task.status)) continue;
|
|
959
1010
|
task.status = "aborted";
|
|
960
|
-
task.error = task.error || "Canceled by subagent_cancel";
|
|
1011
|
+
task.error = task.error || "Canceled by subagent_cancel"; // never overwrite a real error
|
|
961
1012
|
task.endedAt = Date.now();
|
|
962
1013
|
aborted += 1;
|
|
963
1014
|
}
|
|
@@ -995,9 +1046,10 @@ class SubagentManager {
|
|
|
995
1046
|
if (!timeoutMs) return settled;
|
|
996
1047
|
return Promise.race([
|
|
997
1048
|
settled,
|
|
998
|
-
new Promise<RunSnapshot | undefined>((resolve) =>
|
|
999
|
-
setTimeout(() => resolve(this.runs.get(runId) ? cloneRun(this.runs.get(runId)!) : undefined), timeoutMs)
|
|
1000
|
-
|
|
1049
|
+
new Promise<RunSnapshot | undefined>((resolve) => {
|
|
1050
|
+
const timer = setTimeout(() => resolve(this.runs.get(runId) ? cloneRun(this.runs.get(runId)!) : undefined), timeoutMs);
|
|
1051
|
+
settled.then(() => clearTimeout(timer));
|
|
1052
|
+
}),
|
|
1001
1053
|
]);
|
|
1002
1054
|
}
|
|
1003
1055
|
}
|
|
@@ -1082,8 +1134,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1082
1134
|
ctx.ui.notify("No subagent runs in this session.", "info");
|
|
1083
1135
|
return;
|
|
1084
1136
|
}
|
|
1085
|
-
ctx.ui.
|
|
1086
|
-
ctx.ui.notify(`Showing ${runs.length} subagent run(s).`, "info");
|
|
1137
|
+
ctx.ui.notify(runs.flatMap((run) => compactLines(run).concat("")).join("\n") || "No subagent runs in this session.", "info");
|
|
1087
1138
|
},
|
|
1088
1139
|
});
|
|
1089
1140
|
|