@wassname2/pi-supervise 0.0.1

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/src/index.ts ADDED
@@ -0,0 +1,982 @@
1
+ /**
2
+ * intercom-supervisor: a supervisor pi session watches a worker pi session and steers it.
3
+ *
4
+ * Load this extension in both sessions. Type /supervise <worker> in the supervisor.
5
+ *
6
+ * Wire: the pi-intercom extension channel carries data without starting a turn, so each side
7
+ * triggers its own turn locally with pi.sendUserMessage. The broker stamps fromSessionId from its
8
+ * own registry, so pairing on that ID cannot be forged by a payload.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { Type } from "typebox";
13
+ import type {
14
+ IntercomExtensionChannel,
15
+ IntercomExtensionEvent,
16
+ } from "pi-intercom/extension-api.ts";
17
+
18
+ /**
19
+ * Copied from pi-intercom/extension-api.ts, because a git install gets no node_modules/pi-intercom
20
+ * and a value import from it fails at load. The types above are erased, so they cost nothing.
21
+ */
22
+ const INTERCOM_EXTENSION_REGISTER_EVENT = "intercom:extension-register";
23
+ import { age, buildView, progressKey, sinceLastTurn, turnsSince } from "./view.ts";
24
+ import { childPiProcesses } from "./subagents.ts";
25
+ import {
26
+ EMPTY_STATE,
27
+ NAMESPACE,
28
+ OVERLAP_WARN,
29
+ STATE_ENTRY,
30
+ STEER_MEMORY,
31
+ isWire,
32
+ overlap,
33
+ restoreState,
34
+ type SuperviseState,
35
+ type Wire,
36
+ } from "./protocol.ts";
37
+ import {
38
+ BRIEF,
39
+ DONE_BLOCKED,
40
+ GOAL_CHANGED,
41
+ NO_GOAL,
42
+ REANCHOR,
43
+ REVIEW_NUDGE,
44
+ TOOL_DONE,
45
+ TOOL_STEER,
46
+ TOOL_LET_IT_RUN,
47
+ LET_IT_RUN_ACK,
48
+ LET_IT_RUN_AGAIN,
49
+ STEER_ACK,
50
+ VIEW_PRUNED,
51
+ isViewText,
52
+ loadSupervisorPrompt,
53
+ } from "./prompts.ts";
54
+
55
+ /**
56
+ * The worker's model and context use, for the view header, from its own broker presence record.
57
+ *
58
+ * contextPct is missing right after a compaction and on a session with no model selected, so it is
59
+ * left out rather than printed as 0, which would read as an empty context.
60
+ */
61
+ function workerModel(info: { model: string; contextPct?: number }): string {
62
+ return info.contextPct === undefined ? info.model : `${info.model}, ${info.contextPct}% of its context used`;
63
+ }
64
+
65
+ /**
66
+ * A goal that is one word containing a slash or a dot is a path, and the file is the goal.
67
+ *
68
+ * A goal worth grading against runs to paragraphs of acceptance evidence, and retyping it into a
69
+ * prompt each run is how the copy you steer by drifts from the copy you grade by. A missing file
70
+ * throws, with the path in the message.
71
+ */
72
+ function readGoal(cwd: string, goal: string): string {
73
+ if (!/^\S+$/.test(goal) || !/[/.]/.test(goal)) return goal;
74
+ return readFileSync(resolve(cwd, goal), "utf8").trim();
75
+ }
76
+
77
+ /** A goal on one line, for a notice or a picker title. The goal itself is never cut. */
78
+ function firstLine(goal: string, width = 60): string {
79
+ const line = goal.trim().split("\n")[0].trim();
80
+ return line.length > width ? `${line.slice(0, width)}...` : line;
81
+ }
82
+
83
+ /**
84
+ * The footer line, kept to two words plus a glyph.
85
+ *
86
+ * pi-powerline-footer appends extension statuses to the end of its own line, so this costs footer
87
+ * width for the whole session. The oracle's status can afford to be long because it shows only
88
+ * while a run is going. The paired session id is left out: it helps only with three sessions open,
89
+ * and you already know which terminal you are looking at.
90
+ */
91
+ const STATUS_ID = "intercom-supervisor";
92
+ const EYE = "\u{1F441}";
93
+
94
+ /** How long the supervisor waits for the worker to acknowledge a pair before giving up on it. */
95
+ const PAIR_ACK_TIMEOUT_MS = 10_000;
96
+
97
+ /**
98
+ * How long /supervise waits for the roll call. One round trip over a local unix socket, so this is
99
+ * mostly slack for a session busy in the middle of a turn.
100
+ */
101
+ const ROLL_CALL_MS = 500;
102
+
103
+ /**
104
+ * Set in every session pi-subagents starts (pi-args.ts:622, unconditional), so a child run can
105
+ * recognise itself and stay out of the roll call.
106
+ */
107
+ const SUBAGENT_ENV = "PI_SUBAGENT_CHILD";
108
+
109
+ /**
110
+ * How often the supervisor looks at a worker that is still working. It also looks when the worker
111
+ * stops, whatever this is set to, so a short turn is never missed.
112
+ *
113
+ * One supervisor turn per interval per busy worker, so this is the token bill of watching. Half an
114
+ * hour is the wandering-past rate a human keeps; shorten it if you want a closer eye.
115
+ */
116
+ const WATCH_INTERVAL_MS = 1_800_000;
117
+
118
+ /** How often the timer checks whether a look is due. Sets how late a look can be, nothing else. */
119
+ const WATCH_POLL_MS = 30_000;
120
+
121
+ /** A multi-line goal is reinserted before this many supervisor reviews can pass without it. */
122
+ const GOAL_REVIEW_INTERVAL = 5;
123
+
124
+ /** How many identical timer looks are skipped before the supervisor is shown one anyway. */
125
+ const LOOKS_SKIPPED_MAX = 3;
126
+
127
+ /**
128
+ * A view without the one line that changes on its own.
129
+ *
130
+ * The status line carries seconds into the turn, so two views of a worker that has done nothing are
131
+ * never byte-identical. Everything else in a view comes from the worker's branch.
132
+ */
133
+ const bodyOf = (view: string) => view.split("\n").filter((line) => !line.startsWith("status: ")).join("\n");
134
+
135
+ /**
136
+ * Tools taken away from a supervising session, and given back when supervision ends.
137
+ *
138
+ * The supervisor shares a working directory with the worker, so a supervisor that can write is a
139
+ * second agent editing the same files at the same time. It keeps read, grep and the rest, because
140
+ * checking a claim against a file is its job. If it wants a command run, it steers the worker.
141
+ *
142
+ * A deny list, not an allow list: a read tool named something unexpected stays available rather
143
+ * than silently disappearing. The kept list is printed, so a writer this misses is visible.
144
+ */
145
+ const WRITER_TOOLS = new Set([
146
+ "bash", "edit", "write", "multi_edit", "multiedit", "apply_patch", "notebook_edit",
147
+ "edit_file", "write_file", "quick_edit", "target_edit",
148
+ ]);
149
+
150
+ /**
151
+ * The tools only a supervisor should have. Hidden in every session that is not supervising.
152
+ *
153
+ * Both sessions load this extension and registration happens at load, so without this a plain
154
+ * worker is offered worker_view, steer, let_it_run and done. Observed 2026-08-12: a worker given an
155
+ * ordinary coding task spent twelve turns reasoning "these are supervisor tools ... so I might be
156
+ * the supervisor for a worker session", called worker_view, and read "the worker has not stopped
157
+ * since pairing" as proof of a pairing it never had.
158
+ */
159
+ const SUPERVISOR_TOOLS = ["worker_view", "set_goal", "steer", "let_it_run", "done"];
160
+
161
+ export default function (pi: any) {
162
+ let channel: IntercomExtensionChannel | undefined;
163
+ let state: SuperviseState = { ...EMPTY_STATE };
164
+ let latestView = "";
165
+ /** Supervisor side: whether the worker was stopped in that view, which changes what let_it_run costs. */
166
+ let workerStopped = false;
167
+ let ctx: any;
168
+ let ownId = "";
169
+ /** Supervisor side: review views since the full multi-line goal was last inserted. */
170
+ let reviewsSinceGoal = 0;
171
+ /** Worker side: the last progressKey, and how many reviews in a row have matched it. */
172
+ let lastProgress = "";
173
+ let staleReviews = 0;
174
+ /** Worker side: how many turns the supervisor has already been sent, so views carry only the new ones. */
175
+ let sentTurns = 0;
176
+ /** Worker side: the routine look, and when the last view of any kind went out. */
177
+ let watchTimer: ReturnType<typeof setInterval> | undefined;
178
+ let lastLook = 0;
179
+ /** Worker side: the last view sent, less its status line, and how many timer looks matched it. */
180
+ let lastViewBody = "";
181
+ let looksSkipped = 0;
182
+ /** Supervisor side: cleared when the worker acknowledges the pair. */
183
+ let pairTimer: ReturnType<typeof setTimeout> | undefined;
184
+ /** Supervisor side: the writer tools this session had, so reset gives back exactly those. */
185
+ let removedWriters: string[] = [];
186
+ /** Supervisor side: who answered the roll call, collected only while /supervise is waiting. */
187
+ let rollCall: Set<string> | undefined;
188
+
189
+ /** PI_SUPERVISOR_DEBUG=1 traces the wire to stderr. The channel is invisible in transcripts. */
190
+ const debug = (event: string, detail: unknown = {}) => {
191
+ if (process.env.PI_SUPERVISOR_DEBUG) console.error(`[intercom-supervisor] ${event} ${JSON.stringify(detail)}`);
192
+ };
193
+
194
+ function save() {
195
+ pi.appendEntry(STATE_ENTRY, state);
196
+ showStatus();
197
+ }
198
+
199
+ /**
200
+ * One footer line while a pairing is live, so both terminals say what they are.
201
+ *
202
+ * A pairing is otherwise invisible after the notice at the top scrolls away. wassname started a
203
+ * supervisor, saw it sitting at the prompt, and could not tell that it had stopped supervising.
204
+ * Mechanism borrowed from @diegopetrucci/pi-oracle, which puts its run in the same place.
205
+ */
206
+ function showStatus() {
207
+ if (!ctx?.hasUI) return;
208
+ if (state.role === "none") {
209
+ ctx.ui.setStatus(STATUS_ID, undefined);
210
+ return;
211
+ }
212
+ const text = state.role === "supervisor" ? `watching ${state.steerRounds}` : "watched";
213
+ ctx.ui.setStatus(STATUS_ID, ctx.ui.theme.fg("accent", `${EYE} `) + ctx.ui.theme.fg("dim", text));
214
+ }
215
+
216
+ function send(message: Wire) {
217
+ if (!channel) throw new Error("intercom-supervisor: intercom channel is not ready");
218
+ channel.publish(message, { audience: "capable" });
219
+ }
220
+
221
+ /**
222
+ * Put words in the supervisor's own context without starting a turn.
223
+ *
224
+ * The brief, the reanchor and a goal change all say "a view follows", and the view is the thing
225
+ * to judge. A user message always starts a turn (pi types.d.ts:754), so the supervisor answered
226
+ * these with a verdict before any view existed: twice on 2026-08-13, and 98 times an hour before
227
+ * that. No wording stops it, because the turn should not be there at all. A custom message with
228
+ * triggerTurn false joins the context and waits for the view to wake it (agent-session.d.ts:398).
229
+ * display true keeps it on screen, where a user message used to be. - CLAUDE
230
+ */
231
+ function tellSupervisor(text: string) {
232
+ pi.sendMessage({ customType: "supervisor_brief", content: text, display: true }, { triggerTurn: false });
233
+ }
234
+
235
+ function tellGoal() {
236
+ if (!state.goal.includes("\n")) return;
237
+ tellSupervisor(`<goal>\n${state.goal}\n</goal>`);
238
+ reviewsSinceGoal = 0;
239
+ }
240
+
241
+ /**
242
+ * Ask every session whether it can be supervised, and collect the ones that say yes.
243
+ *
244
+ * The broker roster is not the candidate list. It carries child runs from pi-subagents, sessions
245
+ * that do not load this extension, sessions already paired, and registrations whose process has
246
+ * gone. Observed 2026-08-13: five of them at once in one directory, and /supervise refused to
247
+ * pick between them because every filter here worked by guessing from the outside.
248
+ *
249
+ * Each session knows its own answer, so it gives it. A child run reads SUBAGENT_ENV in its own
250
+ * environment; a paired one knows it is paired; a dead one cannot answer at all. No process tree,
251
+ * no naming convention.
252
+ */
253
+ async function askWhoIsFree(): Promise<Set<string>> {
254
+ const found = new Set<string>();
255
+ rollCall = found;
256
+ send({ t: "who", to: "*" });
257
+ await new Promise((resolve) => setTimeout(resolve, ROLL_CALL_MS));
258
+ rollCall = undefined;
259
+ debug("roll call", { answered: [...found].map((id) => id.slice(0, 8)) });
260
+ return found;
261
+ }
262
+
263
+ /**
264
+ * Our own record in the broker registry. The broker records pid at registration, so we match on
265
+ * it. Read fresh every time: presence keeps model and context use up to date in here.
266
+ */
267
+ async function ownSession(): Promise<any> {
268
+ const sessions = await channel!.listSessions();
269
+ const mine = sessions.find((s: any) => s.pid === process.pid);
270
+ if (!mine) throw new Error("intercom-supervisor: this session is not registered with the intercom broker");
271
+ ownId = mine.id;
272
+ return mine;
273
+ }
274
+
275
+ /** Our own intercom session ID, which never changes, so the first answer is kept. */
276
+ async function resolveOwnId(): Promise<string> {
277
+ return ownId || (await ownSession()).id;
278
+ }
279
+
280
+ /**
281
+ * Show or hide the supervisor tools. setActiveTools ignores names it does not know, so the list
282
+ * is read back: a hide that leaves them visible is the confusion this exists to stop.
283
+ */
284
+ function showSupervisorTools(on: boolean) {
285
+ const rest = pi.getActiveTools().filter((t: string) => !SUPERVISOR_TOOLS.includes(t));
286
+ pi.setActiveTools(on ? [...rest, ...SUPERVISOR_TOOLS] : rest);
287
+ const now = pi.getActiveTools().filter((t: string) => SUPERVISOR_TOOLS.includes(t));
288
+ if (on ? now.length !== SUPERVISOR_TOOLS.length : now.length > 0) {
289
+ throw new Error(
290
+ `intercom-supervisor: setActiveTools did not ${on ? "add" : "remove"} the supervisor tools, left ${now.join(", ") || "none"}`,
291
+ );
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Take the writing tools off this session. Supervising is a read-only job in a directory
297
+ * another agent is writing to. reset() hands them back.
298
+ *
299
+ * These live on the pi API, not on the command context. Reaching for the context is what let a
300
+ * supervisor make 36 bash calls in the session that ran /supervise: the old code wrote
301
+ * context.getActiveTools?.() ?? [], got undefined, kept an empty list, and skipped in silence.
302
+ * Unguarded now, and the list is read back, so a strip that does nothing throws instead.
303
+ */
304
+ function stripWriters(): string[] {
305
+ const before: string[] = pi.getActiveTools();
306
+ const kept = before.filter((t: string) => !WRITER_TOOLS.has(t.toLowerCase()));
307
+ // Remember the names removed, not the whole list. Other extensions add and remove their own
308
+ // tools while supervision runs (pi-context-prune adds context_prune, pi-telegram suspends its
309
+ // own), so restoring a snapshot taken hours ago would silently undo their decisions.
310
+ removedWriters = before.filter((t: string) => WRITER_TOOLS.has(t.toLowerCase()));
311
+ pi.setActiveTools(kept);
312
+ const still = pi.getActiveTools().filter((t: string) => WRITER_TOOLS.has(t.toLowerCase()));
313
+ if (still.length) throw new Error(`intercom-supervisor: setActiveTools did not remove ${still.join(", ")}`);
314
+ return kept;
315
+ }
316
+
317
+ /**
318
+ * What a restored pairing means on the wire, checked once the channel exists.
319
+ *
320
+ * restoreState reads the transcript, which is right for the goal and the round count and wrong
321
+ * for pairedId: that addresses a live process, and after a resume or a /reload the process on
322
+ * the other end may be gone. A resumed session then sits idle claiming a pairing, says nothing
323
+ * on screen, and refuses /supervise until you work out it needs /supervise stop.
324
+ *
325
+ * The broker's registry is the truth about who exists now, so ask it. Either way this prints,
326
+ * because "supervising, waiting" and "the other session is gone" look identical otherwise.
327
+ */
328
+ async function rejoinOrDrop() {
329
+ if (!state.role || !state.pairedId) return;
330
+ const live = await channel!.listSessions();
331
+ if (!live.some((s: any) => s.id === state.pairedId)) {
332
+ reset(`intercom-supervisor: ${state.pairedId.slice(0, 8)} is gone, so the pairing is dropped. Run /supervise to start again.`);
333
+ return;
334
+ }
335
+ if (state.role !== "supervisor") {
336
+ // A worker reloaded at the prompt takes no turn, so this is its only chance to start watching.
337
+ startWatch();
338
+ ctx?.ui?.notify?.(`intercom-supervisor: still supervised by ${state.pairedId.slice(0, 8)}`, "info");
339
+ return;
340
+ }
341
+ // The strip lives in the /supervise handler and savedTools is memory, so a resumed supervisor
342
+ // has bash and the edit tools back in a directory the worker is writing to.
343
+ stripWriters();
344
+ // Say the goal and the answer shape again. A /reload is how a changed prompt reaches a running
345
+ // session, and the brief goes out only at pairing, so without this a wording fix needs a fresh
346
+ // pairing and loses the supervisor's memory of its own steers.
347
+ tellSupervisor(REANCHOR(state.goal, state.steerRounds));
348
+ reviewsSinceGoal = 0;
349
+ // Ask for a view rather than wait for one. A supervisor that came back from a crash, a credit
350
+ // failure or a /reload holds a stale picture, and answering from a stale picture is how it
351
+ // invents a fact. Only the worker makes views, so it has to ask, and nobody should have to
352
+ // know that: restarting the session is the whole recovery.
353
+ send({ t: "look", to: state.pairedId });
354
+ ctx?.ui?.notify?.(`intercom-supervisor: still supervising ${state.pairedId.slice(0, 8)}, asked it for a view`, "info");
355
+ }
356
+
357
+ /** Everything that ends a pairing goes through here, so no stale view or timer survives it. */
358
+ function reset(note: string) {
359
+ clearTimeout(pairTimer);
360
+ pairTimer = undefined;
361
+ clearInterval(watchTimer);
362
+ watchTimer = undefined;
363
+ if (removedWriters.length) {
364
+ pi.setActiveTools([...pi.getActiveTools(), ...removedWriters]); // give back what we took
365
+ removedWriters = [];
366
+ }
367
+ showSupervisorTools(false);
368
+ state = { ...EMPTY_STATE };
369
+ latestView = "";
370
+ reviewsSinceGoal = 0;
371
+ lastProgress = "";
372
+ staleReviews = 0;
373
+ sentTurns = 0;
374
+ save();
375
+ ctx?.ui?.notify?.(note, "info");
376
+ }
377
+
378
+ // ---- inbound, one branch per role ------------------------------------------------------
379
+
380
+ async function onWire(from: string, wire: Wire) {
381
+ const me = await resolveOwnId();
382
+ debug("wire in", { t: wire.t, from: from.slice(0, 8), forUs: wire.to === me, role: state.role });
383
+
384
+ // Answered before the addressed-to-us check below, because a roll call goes to everyone.
385
+ if (wire.t === "who") {
386
+ if (from !== me && state.role === "none" && !process.env[SUBAGENT_ENV]) send({ t: "here", to: from });
387
+ return;
388
+ }
389
+ if (wire.to !== me) return;
390
+
391
+ // Collected only while /supervise is waiting, so a late answer lands nowhere and is dropped.
392
+ if (wire.t === "here") {
393
+ rollCall?.add(from);
394
+ return;
395
+ }
396
+
397
+ if (wire.t === "pair") {
398
+ // Last pair wins. First-wins left a worker bound forever to a supervisor that had died, and
399
+ // told nobody. The loser is told, so neither side waits on a pairing it does not have.
400
+ if (state.pairedId && state.pairedId !== from) send({ t: "unpair", to: state.pairedId });
401
+ // We are a worker now, so any pair we sent as a supervisor is void. Leaving that timer armed
402
+ // would kill this healthy pairing ten seconds later, blaming a session no longer involved.
403
+ clearTimeout(pairTimer);
404
+ pairTimer = undefined;
405
+ state = { ...EMPTY_STATE, role: "worker", pairedId: from, goal: wire.goal };
406
+ latestView = "";
407
+ lastProgress = "";
408
+ staleReviews = 0;
409
+ save();
410
+ send({ t: "paired", to: from });
411
+ ctx?.ui?.notify?.(`supervised by ${from.slice(0, 8)}: ${firstLine(wire.goal)}`, "info");
412
+ // A first view goes with the acknowledgement. Without it the supervisor's opening turn has
413
+ // nothing to read, and it answers anyway: on 2026-08-13 it called let_it_run 98 times over
414
+ // "waiting for the first view". A worker paired while idle never settles, so waiting for
415
+ // agent_settled can mean waiting for ever.
416
+ await publishView(ctx, "sent at pairing");
417
+ startWatch(); // paired while sitting at the prompt is the common case, and turn_start may never come
418
+ return;
419
+ }
420
+
421
+ if (from !== state.pairedId) return; // ignore anything from a session we are not paired with
422
+
423
+ if (wire.t === "paired" && state.role === "supervisor") {
424
+ clearTimeout(pairTimer);
425
+ pairTimer = undefined;
426
+ return;
427
+ }
428
+
429
+ if (wire.t === "goal" && state.role === "worker") {
430
+ // The supervisor inferred a goal. The worker holds the copy the view header is built from,
431
+ // so without this the header says "not set" for the rest of the run.
432
+ state = { ...state, goal: wire.goal };
433
+ save();
434
+ ctx?.ui?.notify?.(`goal set by the supervisor: ${wire.goal}`, "info");
435
+ return;
436
+ }
437
+
438
+ if (wire.t === "look" && state.role === "worker") {
439
+ // Only the worker can make a view, so a supervisor that lost its place has to ask. It loses
440
+ // its place whenever its own turn ends without one: a crash, a credit failure, a /reload.
441
+ // Its answer to a stale context is to invent, so give it real data instead.
442
+ // From turn 0, not the diff. A supervisor only asks after a crash or a /reload, when its own
443
+ // copy is gone, and answering that with "0 new turns since your last look" leaves it inventing.
444
+ await publishView(ctx, "sent because you asked for a view", 0);
445
+ return;
446
+ }
447
+
448
+ if (wire.t === "directive" && state.role === "worker") {
449
+ // Busy worker gets "steer": delivered after the current tool calls, before the next LLM call.
450
+ // "followUp" would make it finish the whole task first, so a correction arrives too late to
451
+ // correct anything. Idle worker takes no option, so the message starts a turn.
452
+ pi.sendUserMessage(`[supervisor] ${wire.text}`, ctx?.isIdle() ? undefined : { deliverAs: "steer" });
453
+ return;
454
+ }
455
+
456
+ if (wire.t === "view" && state.role === "supervisor") {
457
+ latestView = wire.view;
458
+ workerStopped = wire.stopped;
459
+ if (reviewsSinceGoal >= GOAL_REVIEW_INTERVAL - 1) tellGoal();
460
+ else reviewsSinceGoal += 1;
461
+ // A new view is a new look, so it gets its own verdict. agent_start alone is not enough: a
462
+ // followUp is consumed inside the running agent loop, so no second agent_start fires and the
463
+ // count carries over. That aborted the second honest verdict of a busy night. - CLAUDE
464
+ verdictsThisLook = 0;
465
+ // followUp, not steer: let the supervisor finish the decision it is making, then look again.
466
+ // With no option at all pi throws "Agent is already processing" and the view is lost, which
467
+ // on a half hour look means the supervisor skips a whole look for no visible reason.
468
+ pi.sendUserMessage(
469
+ REVIEW_NUDGE(wire.view, state.steerRounds, wire.stopped),
470
+ ctx?.isIdle() ? undefined : { deliverAs: "followUp" },
471
+ );
472
+ return;
473
+ }
474
+
475
+ if (wire.t === "done" || wire.t === "unpair") {
476
+ reset(`supervision ended: ${wire.t === "done" ? wire.reason : "unpaired"}`);
477
+ }
478
+ }
479
+
480
+ // ---- lifecycle -------------------------------------------------------------------------
481
+
482
+ // The other reset. This one covers a turn the human starts by typing; the view branch above
483
+ // covers a view, which is the usual way a look begins.
484
+ pi.on("agent_start", () => {
485
+ verdictsThisLook = 0;
486
+ });
487
+
488
+ /**
489
+ * A finished look keeps its verdict and loses the raw material behind it.
490
+ *
491
+ * Every look brings a view of about 1.4k tokens and a block of thinking about as long, and neither
492
+ * means anything once the verdict is written. Session 019ffa73, 16 hours and 75 looks: the context
493
+ * ran 37.9k -> 234.7k with no compaction, and was 56% view bodies and 32% old thinking by
494
+ * character count. What the supervisor actually needs is the 7% that is its own verdicts, e.g.
495
+ * "job 26 Evidence-b partially landed: post rho 0.726 > prompted 0.718 (was tied before rebuild),
496
+ * probe d flipped +0.059". Those are its running notes on the worker, and better memory than the
497
+ * turns it read to write them.
498
+ *
499
+ * Cache reads are $0.004/Mtok, so this was never about the bill. It is that a model judging one
500
+ * view should not be reading 234k tokens to do it.
501
+ *
502
+ * Views are incremental, each covering what is new since the last, so the newest few stay whole
503
+ * in case the current one reads against them. scripts/measure-prune.ts replays a real session.
504
+ */
505
+ const LOOKS_KEPT = 3;
506
+ pi.on("context", (event: any) => {
507
+ if (state.role !== "supervisor") return;
508
+ const isView = (m: any) =>
509
+ m.role === "user" && m.content?.some?.((c: any) => c.type === "text" && isViewText(c.text));
510
+
511
+ // Everything before the oldest look we keep whole is history.
512
+ const views = event.messages.flatMap((m: any, i: number) => (isView(m) ? [i] : []));
513
+ if (views.length <= LOOKS_KEPT) return;
514
+ const cut = views[views.length - LOOKS_KEPT];
515
+
516
+ // New objects, never a mutation: event.messages is the live branch the session also reads from.
517
+ return {
518
+ messages: event.messages.flatMap((m: any, i: number) => {
519
+ if (i >= cut) return [m];
520
+ if (isView(m)) return [{ ...m, content: [{ type: "text", text: VIEW_PRUNED }] }];
521
+ if (m.role !== "assistant") return [m];
522
+ const kept = (m.content ?? []).filter((c: any) => c.type !== "thinking");
523
+ // A message that was only thinking has nothing left to say, and holds no tool call to orphan.
524
+ return kept.length ? [{ ...m, content: kept }] : [];
525
+ }),
526
+ };
527
+ });
528
+
529
+ pi.on("session_compact", async (event: { willRetry?: boolean }, context: any) => {
530
+ ctx = context;
531
+ // Pi retries overflow recovery immediately after this event. Queue the rubric for its next
532
+ // ordinary turn, so the failed assistant remains final and can be removed.
533
+ if (state.role === "supervisor") {
534
+ if (event.willRetry) reviewsSinceGoal = GOAL_REVIEW_INTERVAL - 1;
535
+ else tellGoal();
536
+ }
537
+ });
538
+
539
+ pi.on("session_start", async (_event: unknown, context: any) => {
540
+ ctx = context;
541
+ state = restoreState(context.sessionManager.getEntries());
542
+ // Before anything else, because a worker that can see steer and worker_view starts guessing
543
+ // that it is a supervisor. rejoinOrDrop below may still drop the pairing and hide them again.
544
+ showSupervisorTools(state.role === "supervisor");
545
+ showStatus(); // a resumed pairing has no notice to read, so the footer is all you get
546
+ pi.events.emit(INTERCOM_EXTENSION_REGISTER_EVENT, {
547
+ namespace: NAMESPACE,
548
+ ownerEligible: false,
549
+ onReady: (value: IntercomExtensionChannel) => {
550
+ channel = value;
551
+ rejoinOrDrop().catch((err: Error) => debug("rejoin failed", { error: err.message }));
552
+ },
553
+ onEvent: (event: IntercomExtensionEvent) => {
554
+ if (event.type === "message" && isWire(event.payload)) {
555
+ // Not awaited by the caller, so a rejection here would be an unhandled rejection with no
556
+ // notice. resolveOwnId throws during a startup race.
557
+ onWire(event.fromSessionId, event.payload).catch((err: Error) => {
558
+ debug("wire dropped", { error: err.message });
559
+ ctx?.ui?.notify?.(`intercom-supervisor: dropped a message, ${err.message}`, "error");
560
+ });
561
+ }
562
+ },
563
+ });
564
+ });
565
+
566
+ /**
567
+ * Publish what the worker looks like right now. Used at pairing, on a timer and when asked.
568
+ * `since` is the turn the view starts at: the diff for a routine look, 0 when the supervisor
569
+ * needs the whole picture again.
570
+ */
571
+ async function publishView(context: any, why: string, since = sentTurns, onlyIfChanged = false) {
572
+ // Claimed before the await, not after. ps takes long enough that a second timer tick would
573
+ // otherwise start its own look while this one is still waiting.
574
+ lastLook = Date.now();
575
+ const entries = context.sessionManager.getBranch() as any;
576
+ // Checked here too. This view replaces the one done reads, so leaving it out would report
577
+ // "child pi processes still running: none" and unblock done while a subagent is running.
578
+ const subagents = await childPiProcesses();
579
+ // Asked at pairing and on a rejoin, and the worker is often sitting at the prompt then. Saying
580
+ // "working" there sends the supervisor a check-in nudge about a worker that is waiting on it.
581
+ const idle = context.isIdle();
582
+ // One clock for both ways of being stuck: at the prompt, and inside a command that never
583
+ // returns. It goes in the status line because bodyOf strips that line, so a number that moves
584
+ // on its own cannot defeat the unchanged-view skip below.
585
+ const view = buildView({
586
+ goal: state.goal,
587
+ status: `${idle ? "stopped" : "working"}, ${why}, no new turn for ${age(sinceLastTurn(entries))}`,
588
+ entries,
589
+ since,
590
+ subagents,
591
+ model: workerModel(await ownSession()),
592
+ });
593
+ // A timer look at a worker that has done nothing since the last one wakes the supervisor to read
594
+ // a view it has already read. Session 019ffa73: 13 of 92 verdicts were "check-in with no new
595
+ // turns ... nothing to judge". The supervisor loses nothing by not being asked, because the view
596
+ // is the same view. It is asked anyway after LOOKS_SKIPPED_MAX, since a worker that has not moved
597
+ // for hours is itself worth seeing, and the elapsed seconds in the status line say so.
598
+ if (onlyIfChanged) {
599
+ if (bodyOf(view) === lastViewBody && looksSkipped < LOOKS_SKIPPED_MAX) {
600
+ looksSkipped += 1;
601
+ debug("look skipped, nothing new since the last view", { looksSkipped });
602
+ return;
603
+ }
604
+ looksSkipped = 0;
605
+ }
606
+ lastViewBody = bodyOf(view);
607
+
608
+ sentTurns = turnsSince(entries);
609
+ send({ t: "view", to: state.pairedId, view, stopped: idle });
610
+ debug("published view", { why, to: state.pairedId, sentTurns, idle });
611
+ }
612
+
613
+ /**
614
+ * The timer look. A human supervising does not read every token; they wander past every few
615
+ * minutes and interrupt if the work has gone somewhere wrong. This is that, so the supervisor
616
+ * keeps roughly the perspective you would have with the two windows side by side.
617
+ *
618
+ * It runs whether or not the worker is working, and from the moment the worker is paired rather
619
+ * than from its next turn. Both of those are the same bug twice, an idle worker nobody is looking
620
+ * at. It used to stop on agent_settled, since a stopped worker cannot change, and on 2026-08-14
621
+ * that left a stopped worker and a supervisor that had answered let_it_run sitting silent for two
622
+ * and a half hours. It used to start only on turn_start, so a worker that paired or reloaded while
623
+ * sitting at the prompt had no timer at all, which is the same silence reached from the other end.
624
+ */
625
+ function startWatch() {
626
+ if (state.role !== "worker" || !channel || watchTimer) return;
627
+ watchTimer = setInterval(() => {
628
+ if (Date.now() - lastLook < WATCH_INTERVAL_MS) return;
629
+ // A stopped worker is always reported, never skipped as unchanged: an unchanged stopped worker
630
+ // is the state that needs a steer, and nothing else is going to bring it up.
631
+ const idle = ctx.isIdle();
632
+ publishView(ctx, idle ? "looked at again" : "routine check in", sentTurns, !idle).catch((err: Error) => {
633
+ debug("timer look failed", { error: err.message });
634
+ });
635
+ }, WATCH_POLL_MS);
636
+ // Watching is not a reason for a process to stay alive. It ran from turn_start before, which no
637
+ // test reaches, so an interval from pairing time held node's test runner open for ever.
638
+ watchTimer.unref();
639
+ }
640
+
641
+ pi.on("turn_start", async (_event: unknown, context: any) => {
642
+ ctx = context;
643
+ // Set again, and before the role check, because a status set during session_start does not
644
+ // survive: the footer had not mounted yet, and nothing redraws it until the next save().
645
+ showStatus();
646
+ startWatch();
647
+ });
648
+
649
+ /** Fires only when no retry, compaction, or queued continuation will run, so the worker is truly done. */
650
+ pi.on("agent_settled", async (_event: unknown, context: any) => {
651
+ ctx = context;
652
+ showStatus();
653
+ debug("agent_settled", { role: state.role, hasChannel: Boolean(channel) });
654
+ if (state.role !== "worker" || !channel) return;
655
+ // The timer keeps running. See the turn_start comment: stopping it here is what let the pairing
656
+ // go silent for two and a half hours after the supervisor answered let_it_run to a stopped worker.
657
+ try {
658
+ // A subagent runs as its own process and leaves no unanswered tool call, so a settled worker
659
+ // can still be spending. Report it and let the supervisor steer; do not wait here.
660
+ const subagents = await childPiProcesses();
661
+ const entries = context.sessionManager.getBranch() as any;
662
+ const progress = progressKey(entries);
663
+ staleReviews = progress === lastProgress ? staleReviews + 1 : 0;
664
+ lastProgress = progress;
665
+ const view = buildView({
666
+ goal: state.goal,
667
+ status: "stopped, just now, no new turn for 0s",
668
+ entries,
669
+ since: sentTurns,
670
+ stale: staleReviews,
671
+ subagents,
672
+ model: workerModel(await ownSession()),
673
+ });
674
+ sentTurns = turnsSince(entries);
675
+ send({ t: "view", to: state.pairedId, view, stopped: true });
676
+ lastLook = Date.now();
677
+ debug("published view", { bytes: Buffer.byteLength(view), to: state.pairedId, stale: staleReviews, subagents });
678
+ } catch (err) {
679
+ // Nothing awaits this handler, so rethrowing would be an unhandled rejection nobody sees,
680
+ // and the supervisor would silently never wake again.
681
+ debug("view publish failed", { error: (err as Error).message });
682
+ context.ui?.notify?.(`intercom-supervisor: could not send the view, ${(err as Error).message}`, "error");
683
+ }
684
+ });
685
+
686
+ // ---- supervisor side: one command and three tools ---------------------------------------
687
+
688
+ pi.registerCommand("supervise", {
689
+ description: "Supervise the other pi session here: /supervise [goal or path to a goal file], /supervise @name [goal], /supervise goal <new goal>, /supervise look, /supervise stop",
690
+ handler: async (args: string, context: any) => {
691
+ ctx = context;
692
+ const text = args.trim();
693
+ if (!channel) {
694
+ context.ui?.notify?.("intercom-supervisor: intercom is not connected", "error");
695
+ return;
696
+ }
697
+ if (text === "stop") {
698
+ if (state.pairedId) send({ t: "unpair", to: state.pairedId });
699
+ reset("supervision stopped");
700
+ return;
701
+ }
702
+ if (text === "look") {
703
+ if (state.role !== "supervisor") {
704
+ context.ui?.notify?.("intercom-supervisor: not supervising, so there is nothing to look at", "error");
705
+ return;
706
+ }
707
+ send({ t: "look", to: state.pairedId });
708
+ context.ui?.notify?.(`asked ${state.pairedId.slice(0, 8)} for a view`, "info");
709
+ return;
710
+ }
711
+ // Change the goal without breaking the pairing. Stopping and pairing again is the only other
712
+ // way, and that throws away the supervisor's memory of its own steers.
713
+ if (text === "goal" || text.startsWith("goal ")) {
714
+ const goal = readGoal(context.cwd, text.slice(4).trim());
715
+ if (state.role !== "supervisor") {
716
+ context.ui?.notify?.("intercom-supervisor: not supervising, so there is no goal to change", "error");
717
+ return;
718
+ }
719
+ if (!goal) {
720
+ context.ui?.notify?.(`intercom-supervisor: the goal now is: ${state.goal || "not set"}`, "info");
721
+ return;
722
+ }
723
+ state = { ...state, goal };
724
+ save();
725
+ send({ t: "goal", to: state.pairedId, goal }); // the worker heads every view with it
726
+ // Tell the supervisor now, and ask for a view, so it judges the new goal at once instead of
727
+ // waiting up to half an hour for the next look.
728
+ tellSupervisor(GOAL_CHANGED(goal));
729
+ reviewsSinceGoal = 0;
730
+ send({ t: "look", to: state.pairedId });
731
+ context.ui?.notify?.(`goal changed: ${goal}`, "info");
732
+ return;
733
+ }
734
+ if (state.role !== "none") {
735
+ context.ui?.notify?.(
736
+ `intercom-supervisor: already paired with ${state.pairedId.slice(0, 8)} as ${state.role}. Run /supervise stop first.`,
737
+ "error",
738
+ );
739
+ return;
740
+ }
741
+
742
+ const me = await resolveOwnId();
743
+ const listed = (await channel.listSessions()).filter((s: any) => s.id !== me);
744
+ // The id prefix is the start of the "pi --session <id>" line pi prints in every terminal at
745
+ // startup, so it is something you can match against a window.
746
+ const describe = (rows: any[]) =>
747
+ rows.map((s: any) => `${s.name ?? "(unnamed)"} ${s.id.slice(0, 8)} in ${s.cwd}`).join(", ") || "none";
748
+ const first = text.split(/\s+/, 1)[0];
749
+
750
+ // A target is written @name, so nothing has to be guessed from a goal that has spaces in it.
751
+ // Before this, a first word that matched no session was silently swallowed into the goal:
752
+ // "/supervise LUCID do the thing" set the goal to "LUCID do the thing" and said nothing.
753
+ let worker: any;
754
+ let goal: string;
755
+ if (first.startsWith("@")) {
756
+ // Matched against every session, and no roll call: you named it, so it is the target, and
757
+ // the pair acknowledgement below is the test of whether it can take the job.
758
+ const want = first.slice(1);
759
+ const match = listed.filter((s: any) => s.name === want || s.id === want || s.id.startsWith(want));
760
+ if (match.length !== 1) {
761
+ context.ui?.notify?.(
762
+ `intercom-supervisor: ${match.length} sessions match @${want}. Seen: ${describe(listed)}`,
763
+ "error",
764
+ );
765
+ return;
766
+ }
767
+ worker = match[0];
768
+ goal = readGoal(context.cwd, text.slice(first.length).trim());
769
+ } else {
770
+ // Nothing named, so the whole line is the goal and this has to find the worker.
771
+ goal = readGoal(context.cwd, text);
772
+ const here = listed.filter((s: any) => s.cwd === context.cwd);
773
+ if (!here.length) {
774
+ context.ui?.notify?.(`intercom-supervisor: no other session in ${context.cwd}`, "error");
775
+ return;
776
+ }
777
+ // The roll call answers who can actually take the job. One free session is the ordinary
778
+ // case, and it pairs with no question asked.
779
+ const free = await askWhoIsFree();
780
+ const open = here.filter((s: any) => free.has(s.id));
781
+ if (open.length === 1) {
782
+ worker = open[0];
783
+ } else {
784
+ // Otherwise you pick. Everything here is on the list, including the sessions that stayed
785
+ // quiet, because "0 free sessions" is a dead end and a quiet session is sometimes the one
786
+ // you want: a worker that has not been reloaded since this extension changed cannot
787
+ // answer a roll call it does not know about.
788
+ const ordered = [...open, ...here.filter((s: any) => !free.has(s.id))];
789
+ const labels = ordered.map(
790
+ (s: any) => `${s.name ?? "(unnamed)"} ${s.id.slice(0, 8)}${free.has(s.id) ? "" : " (no answer: child run, paired, gone, or not reloaded)"}`,
791
+ );
792
+ // The goal is a title here, not the goal itself. Yours run to paragraphs of acceptance
793
+ // evidence, and the whole thing above a three-line picker is a wall to read past.
794
+ const picked = await context.ui.select(`which session works on "${firstLine(goal)}"`, labels);
795
+ if (picked === undefined) return; // cancelled, and the notice would say nothing new
796
+ worker = ordered[labels.indexOf(picked)];
797
+ }
798
+ }
799
+ const target = worker.name ?? worker.id.slice(0, 8);
800
+
801
+ state = { ...EMPTY_STATE, role: "supervisor", pairedId: worker.id, goal };
802
+ latestView = "";
803
+ reviewsSinceGoal = 0;
804
+ save();
805
+ const kept = stripWriters();
806
+ showSupervisorTools(true);
807
+
808
+ const { prompt, source } = loadSupervisorPrompt(context.cwd);
809
+ // The worker publishes its first view while handling pair. Add this non-turn message first,
810
+ // or that view can wake the supervisor without the rubric it is meant to judge against.
811
+ tellSupervisor(BRIEF(prompt, goal, target));
812
+ send({ t: "pair", to: state.pairedId, goal });
813
+ // No acknowledgment means the target does not load this extension, or it went away between
814
+ // listSessions and now. Without this the supervisor waits forever for a view and says nothing.
815
+ pairTimer = setTimeout(() => {
816
+ // Unpair first, in case the worker did pair and only the acknowledgment went missing.
817
+ // Otherwise it would keep publishing views to a supervisor that has already given up.
818
+ send({ t: "unpair", to: state.pairedId });
819
+ reset(`intercom-supervisor: ${target} never acknowledged. It probably does not load this extension.`);
820
+ }, PAIR_ACK_TIMEOUT_MS);
821
+
822
+ context.ui?.notify?.(`supervising ${target} (policy: ${source}, tools: ${kept.join(", ")})`, "info");
823
+ },
824
+ });
825
+
826
+ /**
827
+ * How many verdicts this look has had, and the cut for a real runaway.
828
+ *
829
+ * Session 019ffa73, every look: let_it_run with a true reason, then a second let_it_run with a
830
+ * different true reason, then "This operation was aborted", sixteen times before 11:05Z. The
831
+ * second call is the model signing off, not a loop, and cutting it made an ordinary look end in
832
+ * an error line. So a repeat is answered instead (LET_IT_RUN_AGAIN), and abort() waits for a
833
+ * count no sign-off explains. Session 019ffa5f reached 645 calls, so the cut stays.
834
+ *
835
+ * Only let_it_run reaches nobody, so only it is safe to answer twice. steer and done act every
836
+ * time they are called, and the repeat warning in steer is what covers a duplicate there.
837
+ */
838
+ const RUNAWAY_VERDICTS = 5;
839
+ let verdictsThisLook = 0;
840
+ const endLook = (context: any) => {
841
+ verdictsThisLook += 1;
842
+ if (verdictsThisLook > RUNAWAY_VERDICTS) context.abort();
843
+ return verdictsThisLook === 1;
844
+ };
845
+
846
+ pi.registerTool({
847
+ name: "worker_view",
848
+ label: "Worker view",
849
+ description: "Read the latest view of the worker session: goal, status, files touched, and recent turns.",
850
+ parameters: Type.Object({}),
851
+ // Guarded like the rest. Unguarded it answered "the worker has not stopped since pairing" to a
852
+ // session with no pairing at all, which read as confirmation to a worker that had wondered
853
+ // whether it was the supervisor.
854
+ execute: async () => {
855
+ if (state.role !== "supervisor") {
856
+ return { content: [{ type: "text", text: "Not supervising, so there is no worker and no view." }], isError: true };
857
+ }
858
+ return { content: [{ type: "text", text: latestView || "No view received yet. The worker has not stopped since pairing." }] };
859
+ },
860
+ });
861
+
862
+ pi.registerTool({
863
+ name: "set_goal",
864
+ label: "Set the goal",
865
+ description:
866
+ "Set the goal when the human did not give one. Infer it from the worker's view. This is announced to the human, who can override it.",
867
+ parameters: Type.Object({ goal: Type.String({ description: "One sentence, the outcome the worker must reach." }) }),
868
+ execute: async (_id: string, params: { goal: string }) => {
869
+ if (state.role !== "supervisor") {
870
+ return { content: [{ type: "text", text: "Not supervising." }], isError: true };
871
+ }
872
+ state = { ...state, goal: params.goal };
873
+ reviewsSinceGoal = 0;
874
+ save();
875
+ // The tool result records this inferred goal in the supervisor context. The worker needs it too.
876
+ send({ t: "goal", to: state.pairedId, goal: params.goal });
877
+ // Announced, not silent: the supervisor's own reply is what reaches the human's phone.
878
+ ctx?.ui?.notify?.(`goal set by the supervisor: ${params.goal}`, "info");
879
+ return {
880
+ content: [{
881
+ type: "text",
882
+ text: `Goal set to:
883
+
884
+ <goal>
885
+ ${params.goal}
886
+ </goal>
887
+
888
+ This is a goal you inferred, not one the human gave you.
889
+ Tell them in your reply, quoting it, so they can correct it.`,
890
+ }],
891
+ };
892
+ },
893
+ });
894
+
895
+ pi.registerTool({
896
+ name: "steer",
897
+ label: "Steer worker",
898
+ description: TOOL_STEER,
899
+ parameters: Type.Object({ message: Type.String({ description: "One concrete next action, 1 to 3 sentences." }) }),
900
+ execute: async (_id: string, params: { message: string }, _signal: unknown, _update: unknown, context: any) => {
901
+ if (state.role !== "supervisor") {
902
+ return { content: [{ type: "text", text: "Not supervising. Run /supervise <worker> first." }], isError: true };
903
+ }
904
+ // No goal means no basis to steer. Inventing work is the observed failure, so ask instead.
905
+ if (!state.goal.trim()) {
906
+ return { content: [{ type: "text", text: NO_GOAL }], isError: true };
907
+ }
908
+ // Sent either way. Refusing a repeat would be a stopping rule, and a repeat is sometimes
909
+ // right; the supervisor gets told so it can change approach on the next round.
910
+ // Numbered from the run's total, not from the position in the window, so the number here
911
+ // means the same thing as the one in the review nudge and in "that is instruction N".
912
+ const first = state.steerRounds - state.recentSteers.length + 1;
913
+ const repeat = state.recentSteers
914
+ .map((old, i) => ({ old, n: first + i, score: overlap(old, params.message) }))
915
+ .sort((a, b) => b.score - a.score)[0];
916
+
917
+ send({ t: "directive", to: state.pairedId, text: params.message });
918
+ state = {
919
+ ...state,
920
+ steerRounds: state.steerRounds + 1,
921
+ recentSteers: [...state.recentSteers, params.message].slice(-STEER_MEMORY),
922
+ };
923
+ save();
924
+
925
+ const warning = repeat && repeat.score >= OVERLAP_WARN
926
+ ? `\nThis says much the same as instruction ${repeat.n}: "${repeat.old}"\nIf the next view shows nothing new, say what evidence makes repeating it worth another round, or change approach.`
927
+ : "";
928
+ endLook(context);
929
+ return { content: [{ type: "text", text: `${STEER_ACK(state.steerRounds, state.pairedId)}${warning}` }] };
930
+ },
931
+ });
932
+
933
+ /**
934
+ * The third verdict, and the one that stops a fabricated steer.
935
+ *
936
+ * Observed 2026-08-12: with only steer and done on offer, a supervisor with nothing to say wrote
937
+ * "the harness demands a tool call ... the least-bad option is a steer that adds something new",
938
+ * ran a command that printed nothing, then reported a job was at "turn 47 of 80". The real log
939
+ * said 75 of 80. Forcing a verdict every look is what bought that number.
940
+ */
941
+ pi.registerTool({
942
+ name: "let_it_run",
943
+ label: "Let the worker run",
944
+ description: TOOL_LET_IT_RUN,
945
+ parameters: Type.Object({ reason: Type.String({ description: "Quote the exact worker-view text supporting no instruction. Do not infer future work or worker state." }) }),
946
+ execute: async (_id: string, params: { reason: string }, _signal: unknown, _update: unknown, context: any) => {
947
+ if (state.role !== "supervisor") {
948
+ return { content: [{ type: "text", text: "Not supervising." }], isError: true };
949
+ }
950
+ const first = endLook(context);
951
+ return { content: [{ type: "text", text: first ? LET_IT_RUN_ACK(params.reason, workerStopped) : LET_IT_RUN_AGAIN }] };
952
+ },
953
+ });
954
+
955
+ pi.registerTool({
956
+ name: "done",
957
+ label: "Finish supervision",
958
+ description: TOOL_DONE,
959
+ parameters: Type.Object({ reason: Type.String({ description: "The artifact path and the quoted line that proves it." }) }),
960
+ execute: async (_id: string, params: { reason: string }, _signal: unknown, _update: unknown, context: any) => {
961
+ if (state.role !== "supervisor") {
962
+ return { content: [{ type: "text", text: "Not supervising." }], isError: true };
963
+ }
964
+ // "done" while a delegated tool call has no result is a false completion: the worker settled
965
+ // but its subagent or background job is still spending. This proves only that no tracked
966
+ // tool result is missing. A detached process is invisible to it.
967
+ const pending = latestView.match(/^tool calls with no result: (?!none)(.+)$/m)
968
+ ?? latestView.match(/^child pi processes still running: (?!none)(.+)$/m);
969
+ if (pending) {
970
+ return {
971
+ content: [{ type: "text", text: DONE_BLOCKED(pending[1]) }],
972
+ isError: true,
973
+ };
974
+ }
975
+ send({ t: "done", to: state.pairedId, reason: params.reason });
976
+ const rounds = state.steerRounds;
977
+ reset(`supervision finished: ${params.reason}`);
978
+ endLook(context);
979
+ return { content: [{ type: "text", text: `Supervision finished after ${rounds} instructions.` }] };
980
+ },
981
+ });
982
+ }