@d3ara1n/pi-subagent 2.1.0 → 3.0.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/README.md +7 -7
- package/package.json +1 -1
- package/src/index.ts +59 -97
- package/src/reminder.test.ts +101 -52
- package/src/reminder.ts +15 -9
- package/src/render-async.ts +49 -0
- package/src/roles.ts +9 -4
- package/src/types.ts +11 -9
- package/src/utils.test.ts +38 -0
- package/src/utils.ts +42 -5
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ This means:
|
|
|
32
32
|
|
|
33
33
|
| Role | Model Role | Timeout | Tools | Can Delegate To | Description |
|
|
34
34
|
|------|-----------|---------|-------|-----------------|-------------|
|
|
35
|
-
| `explorer` | fast | 900s | read, find, grep | — | Fast code exploration (read-only) |
|
|
35
|
+
| `explorer` | fast | 900s | read, find, grep, bash | — | Fast code exploration incl. git history inspection (read-only) |
|
|
36
36
|
| `reviewer` | heavy | 3600s | read, bash, grep, find | — | Deep code review, runs git/tests for evidence (read-only) |
|
|
37
37
|
| `worker` | default | 2400s | all (no whitelist) | explorer, researcher | Implementation — the only role that can modify files; full tool access (web, MCP, everything) |
|
|
38
38
|
| `researcher` | fast | 2400s | web_search, fetch_content, source_check, get_search_content, read, bash, edit, write, delegate | explorer | Web research + GitHub repo analysis; writes artifacts only inside its temp dir |
|
|
@@ -56,7 +56,7 @@ This means:
|
|
|
56
56
|
|---------|-------------|
|
|
57
57
|
| `/subagent:view` | Open the live view: a tabbed overlay with a per-run activity feed and a brief detail page (inputs, files, stats), plus modal steer input for the focused run |
|
|
58
58
|
| `/subagent:doctor` | Diagnose pi invocation, model-role resolution, configuration, and role references |
|
|
59
|
-
| `/subagent:status` | List background runs
|
|
59
|
+
| `/subagent:status` | List background runs and their current state |
|
|
60
60
|
| `/subagent:cancel <id\|all> [reason]` | Cancel a live background run (or every live run); the optional reason is recorded with the run |
|
|
61
61
|
|
|
62
62
|
### Live view (`/subagent:view`)
|
|
@@ -229,15 +229,15 @@ Typical flow:
|
|
|
229
229
|
|
|
230
230
|
Semantics worth knowing:
|
|
231
231
|
|
|
232
|
-
- **Results are pull-only for the model.** A purple completion notice is shown to the user, but nothing delivers the result to the model or wakes it up. The notice is a pure notification in the same visual family as pi's `[compaction]` card — a `[subagent] id (role) outcome` header with the bare task preview beneath, each line truncated to the terminal width — and deliberately unlike the tool rows, so it never reads as model behavior; the result itself never appears in the notice, only in `subagent_check` (model) or `/subagent:status` (user). The model owns the collection point: `subagent_wait`, then `subagent_check` each run. The inbox reminder (below) lists
|
|
232
|
+
- **Results are pull-only for the model.** A purple completion notice is shown to the user, but nothing delivers the result to the model or wakes it up. The notice is a pure notification in the same visual family as pi's `[compaction]` card — a `[subagent] id (role) outcome` header with the bare task preview beneath, each line truncated to the terminal width — and deliberately unlike the tool rows, so it never reads as model behavior; the result itself never appears in the notice, only in `subagent_check` (model) or `/subagent:status` (user). The model owns the collection point: `subagent_wait`, then `subagent_check` each run. The inbox reminder (below) lists runs not yet checked on the active branch on every request, but it never pushes results.
|
|
233
233
|
- **Background runs survive turn cancellation** and are unaffected by a cancelled `subagent_wait` — cancelling the wait never cancels the runs; call `subagent_wait` or `subagent_check` again later.
|
|
234
|
-
- **
|
|
235
|
-
- **Cancellation keeps the partial output.** `subagent_cancel(id, reason?)` kills the child (SIGTERM, escalating to SIGKILL) and settles the run as `cancelled` — its own stop reason in the same family as `timeout`/`budget_exceeded` (TUI warning styling ⏹, not the error-red ✗ of real failures) — with whatever it had produced. The `reason` becomes the error message verbatim, so whoever reads the partial output later via `subagent_check` — or the audit history — sees `cancelled — <reason>`; the source is distinguishable too (`user: ...` for `/subagent:cancel`, the model's own words for the tool, `session shutdown` for reaping). Cancelling does not
|
|
236
|
-
- **Inbox reminder:** every LLM call carries a `[background subagent runs]` system reminder listing the
|
|
234
|
+
- **Idempotent check, session-tree delivery state:** `subagent_check` re-delivers the same terminal snapshot on every call — runs stay in the registry for the whole session, so no result can ever be stranded by branch navigation or compaction. Whether a run still needs collecting is not tracked in the registry: it derives from the session tree itself. The session is append-only, so branching back past a check entry drops it from the active path — the inbox reminder re-arms and the model simply checks again (the id still resolves; the run is still there). Branching forward to the original branch restores the check entry and silences the reminder again.
|
|
235
|
+
- **Cancellation keeps the partial output.** `subagent_cancel(id, reason?)` kills the child (SIGTERM, escalating to SIGKILL) and settles the run as `cancelled` — its own stop reason in the same family as `timeout`/`budget_exceeded` (TUI warning styling ⏹, not the error-red ✗ of real failures) — with whatever it had produced. The `reason` becomes the error message verbatim, so whoever reads the partial output later via `subagent_check` — or the audit history — sees `cancelled — <reason>`; the source is distinguishable too (`user: ...` for `/subagent:cancel`, the model's own words for the tool, `session shutdown` for reaping). Cancelling does not remove the run: `subagent_check` still returns the partial output, and `subagent_wait` reports the run as `cancelled (partial output kept)`.
|
|
236
|
+
- **Inbox reminder:** every LLM call carries a `[background subagent runs]` system reminder listing the runs not yet checked on the active branch (queued, running, and finished-but-unchecked alike, including cancelled ones — shown as `cancelled — <reason>`), injected at a cache-stable head position. Runs missing from the list were already checked on this branch — so a finished run the model forgot to check keeps surfacing until it does. Branch navigation keeps this honest: the list derives from the session tree, not registry bookkeeping.
|
|
237
237
|
- **`timeout_ms` is optional.** Without it, `subagent_wait` blocks until every run finishes; each run is still bounded by its own role timeout.
|
|
238
238
|
- Background runs share the global `maxConcurrency` gate — extra runs show up as `queued` in wait/check views.
|
|
239
239
|
- **Top-level only:** nested subagents cannot delegate in the background (a subagent process exits when its task finishes, which would orphan the run).
|
|
240
|
-
- The run registry lives in the pi process: a `/reload` or restart orphans in-flight background runs (their ids stop resolving). `/subagent:status` lists every registered run
|
|
240
|
+
- The run registry lives in the pi process: a `/reload` or restart orphans in-flight background runs (their ids stop resolving). `/subagent:status` lists every registered run and its current state.
|
|
241
241
|
|
|
242
242
|
### Steering a running subagent
|
|
243
243
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-subagent",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
17
17
|
import { Type } from "typebox";
|
|
18
18
|
import { getModelRolesAPI } from "@d3ara1n/pi-model-roles";
|
|
19
19
|
import type {
|
|
20
|
-
CollectedRun,
|
|
21
20
|
SubagentConfig,
|
|
22
21
|
SubagentResult,
|
|
23
22
|
SubagentRole,
|
|
@@ -28,6 +27,7 @@ import { BUILTIN_ROLES } from "./roles.ts";
|
|
|
28
27
|
import { getPiInvocation } from "./spawn.ts";
|
|
29
28
|
import {
|
|
30
29
|
AsyncSemaphore,
|
|
30
|
+
collectDeliveredIds,
|
|
31
31
|
createThrottler,
|
|
32
32
|
describeCurrentActivity,
|
|
33
33
|
formatBudgetNote,
|
|
@@ -47,6 +47,7 @@ import { buildInboxReminder, injectReminder } from "./reminder.ts";
|
|
|
47
47
|
import { renderDelegateCall, renderDelegateResult } from "./render.ts";
|
|
48
48
|
import { createViewPanel } from "./view.ts";
|
|
49
49
|
import {
|
|
50
|
+
createSteerCallRender,
|
|
50
51
|
renderBackgroundDelegateCall,
|
|
51
52
|
renderBackgroundDelegateResult,
|
|
52
53
|
renderCancelCall,
|
|
@@ -54,6 +55,7 @@ import {
|
|
|
54
55
|
renderCheckCall,
|
|
55
56
|
renderCheckResult,
|
|
56
57
|
renderCompletionNotice,
|
|
58
|
+
renderSteerResult,
|
|
57
59
|
renderWaitCall,
|
|
58
60
|
renderWaitResult,
|
|
59
61
|
} from "./render-async.ts";
|
|
@@ -101,14 +103,15 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
101
103
|
refreshAvailableRoles();
|
|
102
104
|
|
|
103
105
|
// ── Background run registry ────────────────────────────────────
|
|
104
|
-
// Process-lifetime map of
|
|
106
|
+
// Process-lifetime map of background runs (queued, running, and
|
|
105
107
|
// finished/failed alike). Foreground delegate runs are NOT registered —
|
|
106
|
-
// their lifecycle is the tool call itself.
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
108
|
+
// their lifecycle is the tool call itself. Runs stay registered for the
|
|
109
|
+
// whole session: subagent_check is idempotent and re-delivers the terminal
|
|
110
|
+
// snapshot on every call, so branch navigation or compaction can never
|
|
111
|
+
// strand a result outside the model's reach. Whether a run still needs
|
|
112
|
+
// reminding is NOT tracked here — it derives from the session tree (see
|
|
113
|
+
// collectDeliveredIds + the context handler), the single source of truth.
|
|
110
114
|
const backgroundRuns = new Map<string, RunHandle>();
|
|
111
|
-
const collectedRuns = new Map<string, CollectedRun>();
|
|
112
115
|
let runCounter = 0;
|
|
113
116
|
let sessionGeneration = 0;
|
|
114
117
|
|
|
@@ -250,7 +253,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
250
253
|
rebuildGuidelines(availableRoles);
|
|
251
254
|
});
|
|
252
255
|
|
|
253
|
-
pi.on("context", async (event) => {
|
|
256
|
+
pi.on("context", async (event, ctx) => {
|
|
254
257
|
// Completion notices are persisted custom messages so the user can see
|
|
255
258
|
// them in the transcript, but they are deliberately UI-only. Keep the
|
|
256
259
|
// model on the reminder/check path instead of duplicating the notice in
|
|
@@ -260,10 +263,16 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
260
263
|
message.role !== "custom" || message.customType !== BACKGROUND_COMPLETION_MESSAGE_TYPE,
|
|
261
264
|
);
|
|
262
265
|
|
|
263
|
-
// The model's inbox: every
|
|
264
|
-
// cache-stable head position before every provider
|
|
265
|
-
//
|
|
266
|
-
|
|
266
|
+
// The model's inbox: every background run not yet checked on the active
|
|
267
|
+
// branch, injected at a cache-stable head position before every provider
|
|
268
|
+
// call. Delivery state is derived from the session tree (append-only:
|
|
269
|
+
// branching away drops the check entry, branching back restores it), so
|
|
270
|
+
// the inbox re-arms itself after tree navigation. Empty inbox and no
|
|
271
|
+
// filtered notices → context stays untouched, cache fully stable.
|
|
272
|
+
const reminder = buildInboxReminder(
|
|
273
|
+
backgroundRuns.values(),
|
|
274
|
+
collectDeliveredIds(ctx.sessionManager.buildContextEntries()),
|
|
275
|
+
);
|
|
267
276
|
if (!reminder && messages.length === event.messages.length) return;
|
|
268
277
|
return { messages: reminder ? injectReminder(messages, reminder) : messages };
|
|
269
278
|
});
|
|
@@ -386,9 +395,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
386
395
|
backgroundRuns.set(run.id, run);
|
|
387
396
|
const runGeneration = sessionGeneration;
|
|
388
397
|
void run.promise.then((result) => {
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
398
|
+
// Never publish completions from an old session. Within a session
|
|
399
|
+
// every completion emits its UI-only notice card once — check
|
|
400
|
+
// results never suppress it (they are not LLM-visible either way).
|
|
401
|
+
if (runGeneration !== sessionGeneration) return;
|
|
392
402
|
|
|
393
403
|
const outcome = isFailedResult(result)
|
|
394
404
|
? "failed"
|
|
@@ -543,16 +553,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
543
553
|
const unknown = ids.filter((id) => !backgroundRuns.has(id));
|
|
544
554
|
if (unknown.length > 0) {
|
|
545
555
|
const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
546
|
-
|
|
547
|
-
.
|
|
548
|
-
|
|
549
|
-
const trulyUnknown = unknown.filter((id) => !collectedRuns.has(id));
|
|
550
|
-
const parts = [
|
|
551
|
-
`Unknown subagent id(s): ${trulyUnknown.length > 0 ? trulyUnknown.join(", ") : "(none)"}.`,
|
|
552
|
-
collectedNotes.length > 0 ? `${collectedNotes.join("; ")}.` : "",
|
|
553
|
-
`Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
554
|
-
].filter(Boolean);
|
|
555
|
-
throw new Error(parts.join(" "));
|
|
556
|
+
throw new Error(
|
|
557
|
+
`Unknown subagent id(s): ${unknown.join(", ")}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
558
|
+
);
|
|
556
559
|
}
|
|
557
560
|
const runs = ids.map((id) => backgroundRuns.get(id)!);
|
|
558
561
|
const timeoutMs =
|
|
@@ -659,7 +662,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
659
662
|
name: "subagent_check",
|
|
660
663
|
label: "Check a background subagent",
|
|
661
664
|
description:
|
|
662
|
-
"Get an instant snapshot of ONE background subagent run: queued / running (with current activity) / finished (with the full output as the run result) / failed (with reason and partial output). Does not wait — use subagent_wait for that.
|
|
665
|
+
"Get an instant snapshot of ONE background subagent run: queued / running (with current activity) / finished (with the full output as the run result) / failed (with reason and partial output). Does not wait — use subagent_wait for that. Idempotent: checking a terminal run again re-delivers the same snapshot, so the result stays reachable even after branch navigation or compaction. One id per call because results can be large.",
|
|
663
666
|
promptSnippet: "Inspect a background subagent run",
|
|
664
667
|
parameters: Type.Object({
|
|
665
668
|
id: Type.String({ description: "Run id returned by a background delegate call" }),
|
|
@@ -668,12 +671,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
668
671
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
669
672
|
const run = backgroundRuns.get(params.id);
|
|
670
673
|
if (!run) {
|
|
671
|
-
const collected = collectedRuns.get(params.id);
|
|
672
|
-
if (collected) {
|
|
673
|
-
throw new Error(
|
|
674
|
-
`${params.id} (${collected.role}) was already collected — its result is in your conversation history. Check the remaining runs or delegate new ones.`,
|
|
675
|
-
);
|
|
676
|
-
}
|
|
677
674
|
const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
678
675
|
throw new Error(
|
|
679
676
|
`Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
@@ -683,19 +680,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
683
680
|
// Freeze live frames so the snapshot's elapsed time stays static.
|
|
684
681
|
const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
|
|
685
682
|
|
|
686
|
-
// Read-once collection: a terminal check returns the result AND frees
|
|
687
|
-
// the run — the output now lives in the conversation history, so the
|
|
688
|
-
// registry keeps only a lightweight tombstone for id resolution.
|
|
689
|
-
if (run.state === "finished" || run.state === "failed") {
|
|
690
|
-
backgroundRuns.delete(run.id);
|
|
691
|
-
collectedRuns.set(run.id, {
|
|
692
|
-
id: run.id,
|
|
693
|
-
role: run.role,
|
|
694
|
-
task: taskPreview(run.task),
|
|
695
|
-
state: run.state,
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
|
|
699
683
|
return {
|
|
700
684
|
content: [{ type: "text", text: formatCheckText(run.id, run.role, snap) }],
|
|
701
685
|
details: { id: run.id, role: run.role, result: snap },
|
|
@@ -710,7 +694,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
710
694
|
name: "subagent_steer",
|
|
711
695
|
label: "Steer a running background subagent",
|
|
712
696
|
description:
|
|
713
|
-
"Queue a mid-run correction into ONE running background subagent — typically right after subagent_check showed it heading down a wrong path. The message is delivered after the child finishes its current tool batch, before its next LLM call; the run keeps its progress (unlike cancel). Only running runs accept steering; queued runs reject it, and
|
|
697
|
+
"Queue a mid-run correction into ONE running background subagent — typically right after subagent_check showed it heading down a wrong path. The message is delivered after the child finishes its current tool batch, before its next LLM call; the run keeps its progress (unlike cancel). Only running runs accept steering; queued runs reject it, and check is the tool for terminal runs. Typical flow: check → steer → check again later.",
|
|
714
698
|
promptSnippet: "Send a mid-run correction to a background subagent",
|
|
715
699
|
parameters: Type.Object({
|
|
716
700
|
id: Type.String({ description: "Run id returned by a background delegate call" }),
|
|
@@ -723,12 +707,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
723
707
|
async execute(_toolCallId, params) {
|
|
724
708
|
const run = backgroundRuns.get(params.id);
|
|
725
709
|
if (!run) {
|
|
726
|
-
const collected = collectedRuns.get(params.id);
|
|
727
|
-
if (collected) {
|
|
728
|
-
throw new Error(
|
|
729
|
-
`${params.id} (${collected.role}) was already collected — nothing left to steer. Delegate a new run if a correction is still needed.`,
|
|
730
|
-
);
|
|
731
|
-
}
|
|
732
710
|
const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
733
711
|
throw new Error(
|
|
734
712
|
`Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
@@ -752,16 +730,19 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
752
730
|
text: `Steer queued for ${params.id} (${run.role}) — delivered after its current tool batch. Verify the effect with subagent_check later.`,
|
|
753
731
|
},
|
|
754
732
|
],
|
|
755
|
-
details: { id: params.id, role: run.role },
|
|
733
|
+
details: { id: params.id, role: run.role, message: params.message },
|
|
756
734
|
};
|
|
757
735
|
},
|
|
736
|
+
|
|
737
|
+
renderCall: createSteerCallRender((id) => backgroundRuns.get(id)?.role),
|
|
738
|
+
renderResult: renderSteerResult,
|
|
758
739
|
});
|
|
759
740
|
|
|
760
741
|
pi.registerTool({
|
|
761
742
|
name: "subagent_cancel",
|
|
762
743
|
label: "Cancel a background subagent",
|
|
763
744
|
description:
|
|
764
|
-
"Cancel ONE background subagent run (queued or running): the child process is killed and the run settles as cancelled (its own stop reason, same family as timeout — partial output kept), NOT as a plain failure. The reason is recorded with the run: whoever reads the partial output later via subagent_check sees why it was stopped. Cancelling does not
|
|
745
|
+
"Cancel ONE background subagent run (queued or running): the child process is killed and the run settles as cancelled (its own stop reason, same family as timeout — partial output kept), NOT as a plain failure. The reason is recorded with the run: whoever reads the partial output later via subagent_check sees why it was stopped. Cancelling does not remove the run — check still returns the partial output. A finished/failed run cannot be cancelled; check it instead.",
|
|
765
746
|
promptSnippet: "Cancel a background subagent run",
|
|
766
747
|
parameters: Type.Object({
|
|
767
748
|
id: Type.String({ description: "Run id returned by a background delegate call" }),
|
|
@@ -776,19 +757,13 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
776
757
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
777
758
|
const run = backgroundRuns.get(params.id);
|
|
778
759
|
if (!run) {
|
|
779
|
-
const collected = collectedRuns.get(params.id);
|
|
780
|
-
if (collected) {
|
|
781
|
-
throw new Error(
|
|
782
|
-
`${params.id} (${collected.role}) was already collected — its result is in your conversation history. There is nothing left to cancel.`,
|
|
783
|
-
);
|
|
784
|
-
}
|
|
785
760
|
const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
786
761
|
throw new Error(
|
|
787
762
|
`Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
788
763
|
);
|
|
789
764
|
}
|
|
790
765
|
|
|
791
|
-
// Terminal runs cannot be cancelled — point at
|
|
766
|
+
// Terminal runs cannot be cancelled — point at check instead.
|
|
792
767
|
if (run.state === "finished" || run.state === "failed") {
|
|
793
768
|
const what = run.state === "finished" ? "its result" : "the failure reason and partial output";
|
|
794
769
|
const text =
|
|
@@ -822,8 +797,8 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
822
797
|
pi.registerCommand("subagent:view", {
|
|
823
798
|
description: "Open the live subagent activity view (watch progress, steer runs)",
|
|
824
799
|
handler: async (_args, ctx) => {
|
|
825
|
-
// Union of every known run: background registry
|
|
826
|
-
//
|
|
800
|
+
// Union of every known run: the background registry plus live
|
|
801
|
+
// in-flight runs (foreground delegate calls included). Dedupe by id —
|
|
827
802
|
// background runs appear in both.
|
|
828
803
|
const runsProvider = () => {
|
|
829
804
|
const seen = new Set<string>();
|
|
@@ -942,40 +917,31 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
942
917
|
pi.registerCommand("subagent:status", {
|
|
943
918
|
description: "List background subagent runs and their current state",
|
|
944
919
|
handler: async (_args, ctx) => {
|
|
945
|
-
if (backgroundRuns.size === 0
|
|
920
|
+
if (backgroundRuns.size === 0) {
|
|
946
921
|
ctx.ui.notify("No background runs.", "info");
|
|
947
922
|
return;
|
|
948
923
|
}
|
|
949
924
|
const lines: string[] = [];
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
detail = `running — ${describeCurrentActivity(snap)}`;
|
|
969
|
-
}
|
|
970
|
-
lines.push(`${icon} ${run.id} (${run.role}): ${detail}`);
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
if (collectedRuns.size > 0) {
|
|
974
|
-
if (lines.length > 0) lines.push("");
|
|
975
|
-
lines.push("Collected (result already returned via subagent_check):");
|
|
976
|
-
for (const c of collectedRuns.values()) {
|
|
977
|
-
lines.push(`\u2713 ${c.id} (${c.role}): ${c.state} — "${c.task}"`);
|
|
925
|
+
lines.push("Runs:");
|
|
926
|
+
for (const run of backgroundRuns.values()) {
|
|
927
|
+
// Freeze live frames so elapsed-dependent details don't drift in the listing.
|
|
928
|
+
const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
|
|
929
|
+
let icon: string;
|
|
930
|
+
let detail: string;
|
|
931
|
+
if (run.state === "failed") {
|
|
932
|
+
icon = "\u2717";
|
|
933
|
+
detail = snap.errorMessage || "unknown error";
|
|
934
|
+
} else if (run.state === "finished") {
|
|
935
|
+
icon = "\u2713";
|
|
936
|
+
detail = snap.summary || taskPreview(snap.output) || "(no output)";
|
|
937
|
+
} else if (run.state === "queued") {
|
|
938
|
+
icon = "\u23F8";
|
|
939
|
+
detail = "queued — waiting for a concurrency slot";
|
|
940
|
+
} else {
|
|
941
|
+
icon = "\u23F3";
|
|
942
|
+
detail = `running — ${describeCurrentActivity(snap)}`;
|
|
978
943
|
}
|
|
944
|
+
lines.push(`${icon} ${run.id} (${run.role}): ${detail}`);
|
|
979
945
|
}
|
|
980
946
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
981
947
|
},
|
|
@@ -1018,13 +984,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1018
984
|
}
|
|
1019
985
|
|
|
1020
986
|
if (target !== "all" && !backgroundRuns.has(target)) {
|
|
1021
|
-
const collected = collectedRuns.get(target);
|
|
1022
987
|
const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
1023
988
|
ctx.ui.notify(
|
|
1024
|
-
(
|
|
1025
|
-
? `${target} (${collected.role}) was already collected — nothing to cancel.`
|
|
1026
|
-
: `Unknown subagent id: ${target}.`) +
|
|
1027
|
-
` Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
989
|
+
`Unknown subagent id: ${target}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
1028
990
|
"error",
|
|
1029
991
|
);
|
|
1030
992
|
return;
|
package/src/reminder.test.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* node --test packages/pi-subagent/src/reminder.test.ts
|
|
6
6
|
*
|
|
7
7
|
* Coverage: row formatting per state (queued/running/finished/failed/budget),
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* delivery-derived row removal (terminal rows checked on the active branch
|
|
9
|
+
* drop out; live rows never do), byte-stability between calls (the
|
|
10
|
+
* cache-prefix contract), empty-inbox no-op, and cache-stable head injection
|
|
11
|
+
* (string content, block content, non-user first message, empty transcript).
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { test, describe } from "node:test";
|
|
@@ -40,19 +41,22 @@ function entry(partial: Partial<InboxEntry> & Pick<InboxEntry, "id" | "state">):
|
|
|
40
41
|
|
|
41
42
|
describe("buildInboxReminder", () => {
|
|
42
43
|
test("empty inbox returns undefined (zero injection)", () => {
|
|
43
|
-
assert.equal(buildInboxReminder([]), undefined);
|
|
44
|
+
assert.equal(buildInboxReminder([], new Set()), undefined);
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
test("queued and running rows carry no time-derived detail", () => {
|
|
47
|
-
const text = buildInboxReminder(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
48
|
+
const text = buildInboxReminder(
|
|
49
|
+
[
|
|
50
|
+
entry({ id: "sub-3", state: "queued", snapshot: frame({ exitCode: -1, queued: true }) }),
|
|
51
|
+
entry({
|
|
52
|
+
id: "sub-2",
|
|
53
|
+
state: "running",
|
|
54
|
+
// Live frame: startTime present — a naive formatter would derive elapsed from it.
|
|
55
|
+
snapshot: frame({ exitCode: -1, startTime: Date.now() - 60_000 }),
|
|
56
|
+
}),
|
|
57
|
+
],
|
|
58
|
+
new Set(),
|
|
59
|
+
)!;
|
|
56
60
|
assert.match(text, /\n- sub-3 \(worker\) — queued — "Investigate flaky tests/);
|
|
57
61
|
assert.match(text, /\n- sub-2 \(worker\) — running — "Investigate flaky tests/);
|
|
58
62
|
// No seconds anywhere on live rows — byte-stability contract.
|
|
@@ -60,56 +64,68 @@ describe("buildInboxReminder", () => {
|
|
|
60
64
|
});
|
|
61
65
|
|
|
62
66
|
test("finished row freezes duration from elapsedMs", () => {
|
|
63
|
-
const text = buildInboxReminder(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
const text = buildInboxReminder(
|
|
68
|
+
[
|
|
69
|
+
entry({
|
|
70
|
+
id: "sub-1",
|
|
71
|
+
state: "finished",
|
|
72
|
+
snapshot: frame({ exitCode: 0, elapsedMs: 192_000 }),
|
|
73
|
+
}),
|
|
74
|
+
],
|
|
75
|
+
new Set(),
|
|
76
|
+
)!;
|
|
70
77
|
assert.match(text, /\n- sub-1 \(worker\) — finished \(ran 3m12s\) — "Investigate flaky tests/);
|
|
71
78
|
});
|
|
72
79
|
|
|
73
80
|
test("budget-stopped finished row is flagged partial", () => {
|
|
74
|
-
const text = buildInboxReminder(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
const text = buildInboxReminder(
|
|
82
|
+
[
|
|
83
|
+
entry({
|
|
84
|
+
id: "sub-1",
|
|
85
|
+
state: "finished",
|
|
86
|
+
snapshot: frame({ exitCode: 0, elapsedMs: 300_000, stopReason: "budget_exceeded" }),
|
|
87
|
+
}),
|
|
88
|
+
],
|
|
89
|
+
new Set(),
|
|
90
|
+
)!;
|
|
81
91
|
assert.match(text, /— finished, partial — budget exceeded \(ran 5m\) —/);
|
|
82
92
|
});
|
|
83
93
|
|
|
84
94
|
test("failed row carries the error preview, first line only", () => {
|
|
85
|
-
const text = buildInboxReminder(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
const text = buildInboxReminder(
|
|
96
|
+
[
|
|
97
|
+
entry({
|
|
98
|
+
id: "sub-4",
|
|
99
|
+
state: "failed",
|
|
100
|
+
snapshot: frame({
|
|
101
|
+
exitCode: 1,
|
|
102
|
+
elapsedMs: 5_000,
|
|
103
|
+
errorMessage: "provider timeout\nretry hint: check quota",
|
|
104
|
+
}),
|
|
93
105
|
}),
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
],
|
|
107
|
+
new Set(),
|
|
108
|
+
)!;
|
|
96
109
|
assert.match(text, /— failed — provider timeout \(ran 5s\) —/);
|
|
97
110
|
assert.doesNotMatch(text, /retry hint/);
|
|
98
111
|
});
|
|
99
112
|
|
|
100
113
|
test("cancelled row carries the cancel label, not failed", () => {
|
|
101
|
-
const text = buildInboxReminder(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
const text = buildInboxReminder(
|
|
115
|
+
[
|
|
116
|
+
entry({
|
|
117
|
+
id: "sub-5",
|
|
118
|
+
state: "failed",
|
|
119
|
+
snapshot: frame({
|
|
120
|
+
exitCode: 1,
|
|
121
|
+
stopReason: "cancelled",
|
|
122
|
+
elapsedMs: 7_000,
|
|
123
|
+
errorMessage: "user: wrong direction after review",
|
|
124
|
+
}),
|
|
110
125
|
}),
|
|
111
|
-
|
|
112
|
-
|
|
126
|
+
],
|
|
127
|
+
new Set(),
|
|
128
|
+
)!;
|
|
113
129
|
assert.match(text, /— cancelled — user: wrong direction after review \(ran 7s\) —/);
|
|
114
130
|
});
|
|
115
131
|
|
|
@@ -118,19 +134,52 @@ describe("buildInboxReminder", () => {
|
|
|
118
134
|
entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
|
|
119
135
|
entry({ id: "sub-2", state: "running", snapshot: frame({ exitCode: -1, startTime: 123 }) }),
|
|
120
136
|
];
|
|
121
|
-
assert.equal(buildInboxReminder(entries), buildInboxReminder(entries));
|
|
137
|
+
assert.equal(buildInboxReminder(entries, new Set()), buildInboxReminder(entries, new Set()));
|
|
122
138
|
});
|
|
123
139
|
|
|
124
140
|
test("long task text is truncated to the shared 70-char preview cap", () => {
|
|
125
141
|
const long = "x".repeat(120);
|
|
126
|
-
const text = buildInboxReminder([entry({ id: "sub-9", state: "running", task: long })])!;
|
|
142
|
+
const text = buildInboxReminder([entry({ id: "sub-9", state: "running", task: long })], new Set())!;
|
|
127
143
|
assert.ok(text.includes(`"${"x".repeat(70)}..."`));
|
|
128
144
|
});
|
|
129
145
|
|
|
130
146
|
test("header explains pull-only collection semantics", () => {
|
|
131
|
-
const text = buildInboxReminder([entry({ id: "sub-1", state: "running" })])!;
|
|
147
|
+
const text = buildInboxReminder([entry({ id: "sub-1", state: "running" })], new Set())!;
|
|
132
148
|
assert.match(text, /^\[background subagent runs — results are pull-only for the model/);
|
|
133
|
-
assert.match(text, /already
|
|
149
|
+
assert.match(text, /already checked on this branch\]/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("terminal rows in the delivered set drop out of the inbox", () => {
|
|
153
|
+
const text = buildInboxReminder(
|
|
154
|
+
[
|
|
155
|
+
entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
|
|
156
|
+
entry({ id: "sub-2", state: "failed", snapshot: frame({ exitCode: 1, elapsedMs: 5_000 }) }),
|
|
157
|
+
],
|
|
158
|
+
new Set(["sub-1"]),
|
|
159
|
+
)!;
|
|
160
|
+
assert.doesNotMatch(text, /sub-1/);
|
|
161
|
+
assert.match(text, /sub-2 \(worker\) — failed/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("live rows stay listed even when their id is in the delivered set", () => {
|
|
165
|
+
// A live frame checked mid-run does not count as delivery — the result
|
|
166
|
+
// was not final yet, so the run keeps nagging until a terminal check.
|
|
167
|
+
const text = buildInboxReminder(
|
|
168
|
+
[
|
|
169
|
+
entry({ id: "sub-1", state: "queued", snapshot: frame({ exitCode: -1, queued: true }) }),
|
|
170
|
+
entry({ id: "sub-2", state: "running", snapshot: frame({ exitCode: -1, startTime: 99 }) }),
|
|
171
|
+
],
|
|
172
|
+
new Set(["sub-1", "sub-2"]),
|
|
173
|
+
)!;
|
|
174
|
+
assert.match(text, /sub-1 \(worker\) — queued/);
|
|
175
|
+
assert.match(text, /sub-2 \(worker\) — running/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("all terminal rows delivered returns undefined (zero injection)", () => {
|
|
179
|
+
const entries = [
|
|
180
|
+
entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
|
|
181
|
+
];
|
|
182
|
+
assert.equal(buildInboxReminder(entries, new Set(["sub-1"])), undefined);
|
|
134
183
|
});
|
|
135
184
|
});
|
|
136
185
|
|
package/src/reminder.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The model's inbox of
|
|
2
|
+
* The model's inbox of background subagent runs.
|
|
3
3
|
*
|
|
4
4
|
* Injected into the LLM context before every provider call via the `context`
|
|
5
|
-
* event. The reminder lists every delegated run
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* event. The reminder lists every delegated run not yet delivered by a
|
|
6
|
+
* subagent_check on the active branch — queued, running, and
|
|
7
|
+
* finished/failed alike — so the model cannot forget about them. Delivery
|
|
8
|
+
* state is derived from the session tree (see collectDeliveredIds), not
|
|
9
|
+
* tracked in the registry: branching past a check re-arms the inbox,
|
|
10
|
+
* branching back silences it, and compaction un-delivers naturally.
|
|
9
11
|
*
|
|
10
12
|
* Cache discipline: the reminder is prepended to the FIRST user message, so
|
|
11
13
|
* it sits at a stable position in the message prefix. Its content must stay
|
|
@@ -28,7 +30,7 @@ export interface InboxEntry {
|
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
const INBOX_HEADER =
|
|
31
|
-
"[background subagent runs — results are pull-only for the model: no completion notice wakes you. subagent_wait, then subagent_check to collect each run; a terminal check removes it from this list; runs missing here were already
|
|
33
|
+
"[background subagent runs — results are pull-only for the model: no completion notice wakes you. subagent_wait, then subagent_check to collect each run; a terminal check removes it from this list; runs missing here were already checked on this branch]";
|
|
32
34
|
|
|
33
35
|
/** `42s`, `3m12s`, `4m` — whole seconds, no live clocks. */
|
|
34
36
|
function formatDuration(totalSec: number): string {
|
|
@@ -61,12 +63,16 @@ function inboxStatus(entry: InboxEntry): string {
|
|
|
61
63
|
|
|
62
64
|
/**
|
|
63
65
|
* Build the inbox reminder text, or undefined when every delegated run has
|
|
64
|
-
* been
|
|
65
|
-
* untouched and the provider cache fully
|
|
66
|
+
* already been checked on the active branch (nothing to remind about —
|
|
67
|
+
* inject nothing, keep the context untouched and the provider cache fully
|
|
68
|
+
* stable). Queued/running runs are always listed regardless of delivery
|
|
69
|
+
* state — their result is not final yet, so a past check (of a live frame)
|
|
70
|
+
* never counts as delivered.
|
|
66
71
|
*/
|
|
67
|
-
export function buildInboxReminder(entries: Iterable<InboxEntry>): string | undefined {
|
|
72
|
+
export function buildInboxReminder(entries: Iterable<InboxEntry>, delivered: Set<string>): string | undefined {
|
|
68
73
|
const rows: string[] = [];
|
|
69
74
|
for (const entry of entries) {
|
|
75
|
+
if (entry.state !== "queued" && entry.state !== "running" && delivered.has(entry.id)) continue;
|
|
70
76
|
rows.push(`- ${entry.id} (${entry.role}) — ${inboxStatus(entry)} — "${taskPreview(entry.task)}"`);
|
|
71
77
|
}
|
|
72
78
|
if (rows.length === 0) return undefined;
|
package/src/render-async.ts
CHANGED
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
CheckDetails,
|
|
34
34
|
CompletionNoticeDetails,
|
|
35
35
|
RunViewEntry,
|
|
36
|
+
SteerDetails,
|
|
36
37
|
SubagentResult,
|
|
37
38
|
WaitDetails,
|
|
38
39
|
} from "./types.ts";
|
|
@@ -387,6 +388,54 @@ export const renderCheckResult: RenderResultFn = (result, { expanded }, theme, _
|
|
|
387
388
|
return collapsedText(checkEntryCollapsedText(details.result, fg));
|
|
388
389
|
};
|
|
389
390
|
|
|
391
|
+
// ── steer: correction echo (check verifies the effect) ──
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Factory: the call line shows the run's role, but args carry only id +
|
|
395
|
+
* message — the registry lookup is injected. Unknown id (e.g. re-rendering a
|
|
396
|
+
* persisted session where the registry is empty) degrades to the bare id.
|
|
397
|
+
*/
|
|
398
|
+
export function createSteerCallRender(roleOf: (id: string) => string | undefined): RenderCallFn {
|
|
399
|
+
return (args, theme) => {
|
|
400
|
+
const id = (args as any).id || "...";
|
|
401
|
+
const role = roleOf(id);
|
|
402
|
+
const label = role ? `${id} (${role})` : id;
|
|
403
|
+
const text = theme.fg("toolTitle", theme.bold("subagent_steer ")) + theme.fg("accent", label);
|
|
404
|
+
return new Text(text, 0, 0);
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export const renderSteerResult: RenderResultFn = (result, { expanded }, theme) => {
|
|
409
|
+
const details = result.details as SteerDetails | undefined;
|
|
410
|
+
if (!details) return collapsedText(contentText(result));
|
|
411
|
+
|
|
412
|
+
const fg = theme.fg.bind(theme) as Fg;
|
|
413
|
+
const icon = fg("accent", "\u21a9"); // ↩ — same marker as steer entries in the activity stream
|
|
414
|
+
const message = details.message.trim() || "(empty message)";
|
|
415
|
+
|
|
416
|
+
// Body carries the correction only — no id/role prefix; the target lives in
|
|
417
|
+
// the call line and the delivery hint below.
|
|
418
|
+
if (!expanded) {
|
|
419
|
+
const firstLine = message.split("\n")[0];
|
|
420
|
+
return collapsedText(`${icon} ${fg("text", firstLine)}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const container = new Container();
|
|
424
|
+
container.addChild(new Text(`${icon} ${fg("text", message)}`, 0, 0));
|
|
425
|
+
container.addChild(new Spacer(1));
|
|
426
|
+
container.addChild(
|
|
427
|
+
new Text(
|
|
428
|
+
fg(
|
|
429
|
+
"dim",
|
|
430
|
+
`${details.id} (${details.role}) — delivered after the current tool batch, verify with subagent_check later`,
|
|
431
|
+
),
|
|
432
|
+
0,
|
|
433
|
+
0,
|
|
434
|
+
),
|
|
435
|
+
);
|
|
436
|
+
return container;
|
|
437
|
+
};
|
|
438
|
+
|
|
390
439
|
// ── cancel: confirmation-only view (check is the result-fetcher) ──
|
|
391
440
|
|
|
392
441
|
export const renderCancelCall: RenderCallFn = (args, theme) => {
|
package/src/roles.ts
CHANGED
|
@@ -15,13 +15,18 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
15
15
|
fallbackRole: "default",
|
|
16
16
|
timeout: 900,
|
|
17
17
|
description:
|
|
18
|
-
"READ-ONLY codebase exploration — locate files, grep symbols, trace imports, explain structures.",
|
|
19
|
-
examples: [
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
"READ-ONLY codebase exploration — locate files, grep symbols, trace imports, explain structures, inspect git history (log/diff/blame).",
|
|
19
|
+
examples: [
|
|
20
|
+
"Find where auth middleware is implemented",
|
|
21
|
+
"Map the routing structure",
|
|
22
|
+
"Summarize what the uncommitted diff changes, file by file",
|
|
23
|
+
],
|
|
24
|
+
decisionTrigger: "Task finds or maps code (including git history) without touch?",
|
|
25
|
+
tools: ["read", "find", "grep", "bash"],
|
|
22
26
|
systemPrompt: [
|
|
23
27
|
"Code explorer. READ-ONLY — locate code, understand it, and report findings; never modify anything.",
|
|
24
28
|
"Search to locate → read the files relevant to the task → trace imports, identify types, interfaces, functions.",
|
|
29
|
+
"Bash for read-only inspection only: git log/show/blame/diff, ls, wc. Never run a command that modifies files or state (sed, tee, echo >, git checkout/commit, installs).",
|
|
25
30
|
"Skip noise: lockfiles, vendored, minified, and generated files.",
|
|
26
31
|
"",
|
|
27
32
|
"Output format:",
|
package/src/types.ts
CHANGED
|
@@ -230,15 +230,6 @@ export interface WaitDetails {
|
|
|
230
230
|
timedOut?: boolean;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
/** Lightweight tombstone kept in the registry after a run's result was claimed via subagent_check — /subagent:status history without the full state machine. */
|
|
234
|
-
export interface CollectedRun {
|
|
235
|
-
id: string;
|
|
236
|
-
role: string;
|
|
237
|
-
/** First-line task preview (same 70-char cap as the inbox reminder). */
|
|
238
|
-
task: string;
|
|
239
|
-
state: "finished" | "failed";
|
|
240
|
-
}
|
|
241
|
-
|
|
242
233
|
/** Details for a check tool result — a frozen one-shot snapshot of a single run. */
|
|
243
234
|
export interface CheckDetails {
|
|
244
235
|
id: string;
|
|
@@ -253,6 +244,17 @@ export interface CheckDetails {
|
|
|
253
244
|
*/
|
|
254
245
|
export type CancelDetails = CheckDetails;
|
|
255
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Details for a steer tool result — the echoed correction. Collapsed shows
|
|
249
|
+
* icon + first line; expanded shows the full message plus the delivery hint
|
|
250
|
+
* (check is the result-fetcher for the effect, never this row).
|
|
251
|
+
*/
|
|
252
|
+
export interface SteerDetails {
|
|
253
|
+
id: string;
|
|
254
|
+
role: string;
|
|
255
|
+
message: string;
|
|
256
|
+
}
|
|
257
|
+
|
|
256
258
|
/**
|
|
257
259
|
* Details for the background-run completion notice (custom message
|
|
258
260
|
* `subagent-completion`). The renderer lays these out as a structured notice
|
package/src/utils.test.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
completionNoticeLines,
|
|
45
45
|
formatToolCall,
|
|
46
46
|
briefFilesUsed,
|
|
47
|
+
collectDeliveredIds,
|
|
47
48
|
} from "./utils.ts";
|
|
48
49
|
import type { ActivityEntry, SubagentResult, SubagentRole } from "./types.ts";
|
|
49
50
|
|
|
@@ -853,3 +854,40 @@ describe("briefFilesUsed", () => {
|
|
|
853
854
|
assert.equal(used.get(F1), false);
|
|
854
855
|
});
|
|
855
856
|
});
|
|
857
|
+
|
|
858
|
+
describe("collectDeliveredIds", () => {
|
|
859
|
+
const checkEntry = (id: string) => ({
|
|
860
|
+
type: "message",
|
|
861
|
+
message: { role: "toolResult", toolName: "subagent_check", details: { id, role: "worker" } },
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test("collects ids from subagent_check tool results only", () => {
|
|
865
|
+
const entries = [
|
|
866
|
+
{ type: "message", message: { role: "user", content: "hi" } },
|
|
867
|
+
{ type: "message", message: { role: "assistant", content: [] } },
|
|
868
|
+
{ type: "message", message: { role: "toolResult", toolName: "read", details: { id: "sub-9" } } },
|
|
869
|
+
{ type: "message", message: { role: "toolResult", toolName: "subagent_wait", details: { entries: [] } } },
|
|
870
|
+
checkEntry("sub-1"),
|
|
871
|
+
{ type: "message", message: { role: "custom", customType: "subagent-completion" } },
|
|
872
|
+
{ type: "compaction" },
|
|
873
|
+
];
|
|
874
|
+
assert.deepEqual(collectDeliveredIds(entries), new Set(["sub-1"]));
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test("dedupes repeated checks of the same id", () => {
|
|
878
|
+
assert.deepEqual(collectDeliveredIds([checkEntry("sub-1"), checkEntry("sub-1")]), new Set(["sub-1"]));
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
test("empty path means nothing delivered (branch rewound past the check)", () => {
|
|
882
|
+
assert.equal(collectDeliveredIds([]).size, 0);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
test("ignores malformed details", () => {
|
|
886
|
+
const entries = [
|
|
887
|
+
{ type: "message", message: { role: "toolResult", toolName: "subagent_check" } },
|
|
888
|
+
{ type: "message", message: { role: "toolResult", toolName: "subagent_check", details: {} } },
|
|
889
|
+
{ type: "message", message: { role: "toolResult", toolName: "subagent_check", details: { id: 42 } } },
|
|
890
|
+
];
|
|
891
|
+
assert.equal(collectDeliveredIds(entries).size, 0);
|
|
892
|
+
});
|
|
893
|
+
});
|
package/src/utils.ts
CHANGED
|
@@ -638,9 +638,9 @@ export function formatCheckText(id: string, role: string, r: SubagentResult): st
|
|
|
638
638
|
}
|
|
639
639
|
|
|
640
640
|
/**
|
|
641
|
-
* Cancel confirmation text: short, no output
|
|
642
|
-
* check's job to return
|
|
643
|
-
* the
|
|
641
|
+
* Cancel confirmation text for the /subagent:cancel command: short, no output
|
|
642
|
+
* dump — the partial output is check's job to return. Always points at check
|
|
643
|
+
* so the user knows where the partial output lives.
|
|
644
644
|
*/
|
|
645
645
|
/**
|
|
646
646
|
* Compact stop summary shared by the cancel tool text and its TUI row:
|
|
@@ -657,8 +657,8 @@ export function cancelStopSummary(r: SubagentResult): string {
|
|
|
657
657
|
|
|
658
658
|
/**
|
|
659
659
|
* Cancel confirmation text: short, no output dump — the partial output is
|
|
660
|
-
* check's job to return
|
|
661
|
-
*
|
|
660
|
+
* check's job to return. Always points at check so the model fetches the
|
|
661
|
+
* partial output it is entitled to.
|
|
662
662
|
*/
|
|
663
663
|
export function formatCancelText(id: string, role: string, r: SubagentResult): string {
|
|
664
664
|
const head = `${id} (${role})`;
|
|
@@ -819,3 +819,40 @@ export function truncateOutput(t: string): string {
|
|
|
819
819
|
const tail = t.slice(-(MAX_OUTPUT_CHARS - 30_050));
|
|
820
820
|
return `[Output truncated — ${t.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
|
|
821
821
|
}
|
|
822
|
+
|
|
823
|
+
// ── Session-tree delivery derivation ────────────────────────
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Minimal structural slice of a session entry — the only fields
|
|
827
|
+
* collectDeliveredIds reads. The real SessionEntry from
|
|
828
|
+
* ctx.sessionManager.buildContextEntries() satisfies this shape structurally;
|
|
829
|
+
* keeping it local preserves this module's zero pi-API-dependency rule and
|
|
830
|
+
* lets tests build plain fakes.
|
|
831
|
+
*/
|
|
832
|
+
interface SessionEntryLike {
|
|
833
|
+
type: string;
|
|
834
|
+
message?: {
|
|
835
|
+
role?: string;
|
|
836
|
+
toolName?: string;
|
|
837
|
+
details?: unknown;
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Derive the set of background-run ids already delivered by subagent_check
|
|
843
|
+
* on the given session entries. The session tree is the single source of
|
|
844
|
+
* truth for delivery state: it is append-only and branch navigation rebuilds
|
|
845
|
+
* the active path, so branching past a check entry un-delivers (the inbox
|
|
846
|
+
* re-arms) while branching back re-delivers — no mirrored state to sync.
|
|
847
|
+
*/
|
|
848
|
+
export function collectDeliveredIds(entries: Iterable<SessionEntryLike>): Set<string> {
|
|
849
|
+
const ids = new Set<string>();
|
|
850
|
+
for (const entry of entries) {
|
|
851
|
+
if (entry.type !== "message") continue;
|
|
852
|
+
const message = entry.message;
|
|
853
|
+
if (!message || message.role !== "toolResult" || message.toolName !== "subagent_check") continue;
|
|
854
|
+
const id = (message.details as { id?: unknown } | undefined)?.id;
|
|
855
|
+
if (typeof id === "string" && id) ids.add(id);
|
|
856
|
+
}
|
|
857
|
+
return ids;
|
|
858
|
+
}
|