@aeondave/pi-persona 1.15.2 → 1.16.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.
Files changed (44) hide show
  1. package/AGENTS.md +32 -6
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +27 -8
  4. package/SECURITY.md +42 -0
  5. package/docs/ARCHITECTURE.md +52 -7
  6. package/docs/EXPERIENCE.md +21 -5
  7. package/docs/README.md +2 -0
  8. package/docs/REFERENCE.md +84 -14
  9. package/docs/STRATEGIES.md +58 -31
  10. package/docs/TELEMETRY.md +14 -0
  11. package/package.json +18 -11
  12. package/src/bus/broker/paths.ts +3 -2
  13. package/src/core/brief.ts +1 -1
  14. package/src/core/nudge.ts +3 -4
  15. package/src/core/pi-compat.ts +3 -3
  16. package/src/engine/adapter.ts +11 -6
  17. package/src/engine/async.ts +12 -6
  18. package/src/engine/inproc.ts +14 -6
  19. package/src/extension/engine.ts +25 -4
  20. package/src/extension/hooks.ts +68 -21
  21. package/src/extension.ts +233 -57
  22. package/src/orchestration/model-retry.ts +32 -61
  23. package/src/orchestration/params.ts +27 -1
  24. package/src/orchestration/roster.ts +80 -3
  25. package/src/orchestration/sdk.ts +150 -36
  26. package/src/orchestration/strategies/compete.ts +14 -8
  27. package/src/orchestration/strategies/council-rounds.ts +8 -1
  28. package/src/orchestration/strategies/critic-loop.ts +25 -11
  29. package/src/orchestration/strategies/judge.ts +18 -9
  30. package/src/orchestration/strategies/magi.ts +42 -93
  31. package/src/orchestration/strategies/map.ts +41 -19
  32. package/src/orchestration/strategies/pipeline.ts +2 -2
  33. package/src/orchestration/strategies/synthesize.ts +18 -11
  34. package/src/orchestration/types.ts +15 -0
  35. package/src/persona/model-participants.ts +240 -0
  36. package/src/persona/orchestrate.ts +44 -2
  37. package/src/persona/persona.ts +86 -14
  38. package/src/telemetry/contract.ts +1 -1
  39. package/src/telemetry/producer.ts +241 -8
  40. package/src/tools/council.ts +36 -8
  41. package/src/tools/delegate-tool.ts +162 -24
  42. package/src/tools/intercom-tool.ts +119 -32
  43. package/src/ui/agent-overlay.ts +15 -6
  44. package/src/ui/agent-tree.ts +39 -2
package/docs/REFERENCE.md CHANGED
@@ -16,7 +16,7 @@ plus a file that calls `parallel` and `reduce.vote`.
16
16
 
17
17
  | Primitive | Does |
18
18
  |---|---|
19
- | `agent(spec)` | run one sub-agent and return a structured `AgentResult`; `spec` may carry `name`, model, tools, skills, or `outputContract` |
19
+ | `agent(spec, { reserveChildren? }?)` | run one sub-agent and return a structured `AgentResult`; `spec` may carry `name`, model, tools, skills, or `outputContract`; optional reservations protect mandatory later legs from model recovery |
20
20
  | `parallel(thunks, {concurrency})` | run many at once, bounded by the run limits; this also provides the basis of `map` |
21
21
  | `reduce.aggregate(results)` | merge N results into one, used by fan-out |
22
22
  | `reduce.vote(candidates, opts)` | tally the candidates' own votes and preserve `winner`, `tie`, `no_consensus`, `invalid_outputs`, and dissent |
@@ -35,8 +35,8 @@ hard-fail.
35
35
  | Surface | Does |
36
36
  |---|---|
37
37
  | `delegate` tool | spawn one or more sub-agents; interactive calls are background-first, while `sync: true` blocks the turn |
38
- | `council` tool | run the selected strategy over a roster and return its result; voting strategies preserve tally and dissent |
39
- | `intercom` tool | observe and control internal runs with `peek`, `result`, `wait`, `steer`, and `stop`; `list`, `inbox`, `message`, `reply`, and `send` drive the coaching bus |
38
+ | `council` tool | select any installed strategy over a named roster or temporary inline `members`; voting strategies preserve tally and dissent. Use when the topology adds value, not for routine work |
39
+ | `intercom` tool | observe and control internal runs with `peek`, `result`, `wait`, `steer`, and `stop`; `list`, `inbox`, `message`, `reply`, and `send` drive the coaching bus. `wait` snapshots without blocking by default with UI; `sync: true` opts into a bounded blocking join (headless defaults to blocking; use `sync: false` for a snapshot); queued steering does not preempt an explicit join |
40
40
  | `timer` tool | read the fresh clock with `now`, or schedule an idle wake with `arm`, `cancel`, and `list`; alarms are one-shot and in-memory per session |
41
41
  | `monitor` tool | run a bounded job (`mode: exit`) or event-producing program (`mode: output`) and wake the supervisor; see [`MONITORS.md`](./MONITORS.md) |
42
42
  | `flow` tool and `/flow` | run a journaled DAG of strategies; `gate: true` creates an approval checkpoint |
@@ -49,30 +49,70 @@ hard-fail.
49
49
  | `contracts/*.json` | define a hot-editable structured return requested through `outputContract` and pinned per run |
50
50
 
51
51
  The delegate tool's optional `name` is carried into `AgentRunSpec.name` and reaches both engine
52
- backends, keeping a caller-provided run label available in the live tree and runtime result.
52
+ backends, keeping a caller-provided run label available in the live tree and runtime result. Every
53
+ settled async run also emits an immediate UI banner with that name and its terminal outcome. The
54
+ model-facing report remains independently idle-gated; a UI notification failure cannot discard it.
55
+ An explicit `delegate` `async` flag overrides both `sync` and the session default: `async: true`
56
+ forces background execution; `async: false` forces synchronous execution.
57
+
58
+ ### Pi 1.0 codemode
59
+
60
+ Pi's native `codemode` can call callable pi-persona tools through the normal nested-tool pipeline.
61
+ Argument validation and persona permission gates still apply to every inner call; scripts do not
62
+ turn a denied tool into an allowed one. Native `model-only` and `hidden` tools are not callable.
63
+
64
+ Use scripts for independent batches or for filtering large results before returning them to the
65
+ model, not as a wrapper that prints every result in full. `Promise.allSettled()` preserves successful
66
+ siblings when a call rejects; a fulfilled `bash` call still requires checking `exit_code`.
67
+ Only printed/returned script output reaches the model. Nested results therefore do not contribute
68
+ raw bytes or invisible reminders to pi-persona's context-burn nudges; an actual successful nested
69
+ `delegate`/`council` still resets the hand-off streak.
70
+
71
+ Pi identifies calls made through codemode's `ctx.executeTool` with `parentToolCallId`. If a nested
72
+ delegation report is relayed with `[BLOCKED]` or `FLAG: UNKNOWN`, only the outer model-visible result
73
+ gets the persistence checkpoint and its expandable card. Discarding the report creates no reminder
74
+ and never alters the programmatic inner result. See [the nudge contract](./ARCHITECTURE.md#supervision--the-waiting-model)
75
+ for the differences between synchronous and background reports.
76
+
77
+ The outer code/result card belongs to Pi, not pi-persona's delegate renderer. Keep bulky details
78
+ expandable (`Ctrl+O` by default; `/hotkeys` shows active bindings). `Ctrl+T` toggles thinking blocks
79
+ separately. A smaller script output also reduces model context; merely collapsing its card does not.
80
+ No replacement sandbox or renderer is required for this integration.
53
81
 
54
82
  `outputContract` takes a contract **name**, not an inline shape — `/doctor` lists what's installed
55
- (the only built-in is `default`). Naming one that isn't installed is rejected before any worker
83
+ (the built-in `default` and bundled `finding` are available without custom files). Naming one that
84
+ isn't installed is rejected before any worker
56
85
  spawns, checked across every task in a parallel batch: `delegate: unknown output contract(s) "x" —
57
86
  nothing was spawned. Installed contracts: default, finding` (the list is capped at 16 names; a
58
87
  `, …` marker is appended only when more than 16 are installed).
59
88
 
60
89
  ### Supervising running sub-agents — the `intercom` plane
61
90
 
62
- The supervisor's internal communication with children has three layers:
91
+ The supervisor's internal communication with children exposes these surfaces. Tree/F9/peek distinguish
92
+ queued, running, waiting for supervisor, suspected stalled and terminal runs. Waiting is derived from
93
+ a live pending ask on the exact child handle, never inferred from its last activity text:
63
94
 
64
- | Layer | Needs | What you get |
95
+ | Surface | Needs | What you get |
65
96
  |---|---|---|
66
- | **Observe and control** | any persona | `peek` watches async sub-agents; `wait` joins them; `steer` sends a soft correction; `stop` requests cancellation. The `f9` overlay provides the same controls (`s`, `x`). Cancelling a worker does not undo existing changes |
97
+ | **Observe and control** | any persona | `peek` inspects async sub-agents; `wait` is nonblocking by default with UI (`sync: true` joins; headless defaults to joining, with `sync: false` for snapshots); `steer` sends a soft correction; `stop` requests cancellation. The `f9` overlay provides the same controls (`s`, `x`). Queued steering does not preempt an active join. Cancelling a worker does not undo existing changes |
67
98
  | **Retrieve** | any persona | `result { to }` returns one retained run payload; `message { messageId }` returns one retained bus message. History is bounded to 256 messages and 256,000 body characters, and retrieval never starts a worker |
68
99
  | **Message bus** | `coaching: true` | children get `contact_supervisor`; progress appears in results and `intercom inbox`, while a blocking `decision` wakes the supervisor for `intercom reply` |
69
100
  | **Sibling peer comm** | strategy opt-in | `debate` and `pair` members always get one-way `contact_peer`; `map` and `synthesize` add it with `params: { peers: true }` |
70
101
 
71
- Delegated output is untrusted and is fenced before it reaches the supervisor. Async runs report
72
- failures to the supervisor, and a repeated identical failed delegation is stopped by the runtime
73
- ledger before another child is spawned. A run whose model's provider fails at call time (auth,
74
- outage, 5xx) may retry the same model id through a fallback provider; each reroute surfaces to the
75
- supervisor as a warning toast, `<agent>: <from> failed, retrying on <to>`.
102
+ With coaching enabled, an async child's `decision` or `interview` waits for a supervisor reply.
103
+ A synchronous child posts the question one-way and continues instead: the supervisor is holding
104
+ that turn and cannot answer a blocking ask. Progress messages are always one-way.
105
+
106
+ Delegated output is untrusted and is fenced before it reaches the supervisor. Interactive async
107
+ work is background-first: continue independent work, and when only children remain, end the turn for
108
+ automatic completion follow-ups rather than monitoring or joining all children. Use `wait` only when
109
+ the next step depends on results. Async runs report failures to the supervisor, and a repeated
110
+ identical failed delegation is stopped by the runtime ledger before another child is spawned. A run whose model's provider fails at call time (auth,
111
+ outage, 5xx) may retry the same model id through a compatible fallback provider when its selection
112
+ is unpinned; explicit delegate provider pins remain strict. Strategies and flows instead recover a
113
+ model/provider failure once on the current main model, including unavailable saved or pinned choices.
114
+ They never substitute a peer's model or search arbitrary providers. Each recovery is visible and both
115
+ attempts consume usage/child budget; stop, timeout, contract and infrastructure failures do not retry.
76
116
 
77
117
  Any leg that does NOT run in `isolation: worktree` shares the real checkout, so its result gets a
78
118
  "what changed" block appended: `--- FILES CHANGED DURING THIS LEG (shared checkout; parallel legs
@@ -235,6 +275,36 @@ Convene the council before sign-off, then apply its merged findings yourself.
235
275
  council({ question: "cache this or recompute?", strategy: "debate", roster: "review", params: { bestOf: 2 } })
236
276
  ```
237
277
 
278
+ A one-off group can be composed without editing a team file. The external arbiter is also an
279
+ agent specification, independent of the panel:
280
+
281
+ ```js
282
+ council({
283
+ question: "Choose between these designs; prioritize correctness and operational cost.",
284
+ strategy: "judge",
285
+ members: [
286
+ { agent: "reviewer", role: "Evaluate correctness and failure boundaries." },
287
+ { agent: "reviewer", role: "Evaluate operating cost and maintainability." }
288
+ ],
289
+ params: { judge: { agent: "reviewer", role: "Choose impartially from the anonymised options." } }
290
+ })
291
+ ```
292
+
293
+ `members` overrides the named roster for this invocation only. Roles/skills do not grant tools or
294
+ bypass capability gates. Model selection covers roster members and declared auxiliary actors such
295
+ as `judge` and `synthesizer`; supported agent-valued params accept inline member specs too. Model
296
+ precedence is inline `model`, saved role assignment, saved agent assignment, agent frontmatter,
297
+ then the session model (`src/persona/model-participants.ts`).
298
+
299
+ With an active persona, UI, and at least two configured models, unassigned participants can be
300
+ picked and saved for the active persona, even when borrowing another persona's council profile.
301
+ Dismissing a picker suppresses another prompt for that participant during the session, not for
302
+ later teams with different participants. Headless runs never prompt.
303
+ An unavailable choice is not overwritten: a provider or unknown-model failure may recover once
304
+ on the main model, with the switch reported and both attempts accounted. This does not relax
305
+ ordinary delegate provider pins. The MAGI persona retains its canonical core team; the Judge
306
+ persona retains impartial arbitration. Other personas choose whichever strategy fits the task.
307
+
238
308
  **A delegation policy on any persona** — fields are generic and are never keyed to a persona name.
239
309
  The runtime rejects incomplete briefs before a model call, supplies a missing contract, rejects
240
310
  overlapping or missing ownership for parallel writers, and refuses a declared verifier that would
@@ -360,7 +430,7 @@ build retries. A connected broker child's status shows `⇄ <handle>`; if that c
360
430
  status flips to `⇄ offline` and a fresh ask on it fails fast instead of hanging.
361
431
 
362
432
  `/doctor` also reports the installed host's version against this extension's floor: `pi: <version>
363
- (requires ≥ 0.83.0)`, with a trailing `— BELOW FLOOR` marker once an installed version fails the
433
+ (requires ≥ 1.0.0)`, with a trailing `— BELOW FLOOR` marker once an installed version fails the
364
434
  check (`src/core/pi-compat.ts`). `pi: unknown` means the host's own `package.json` could not be
365
435
  resolved (a non-standard install layout) — not a failed check.
366
436
 
@@ -17,7 +17,7 @@ a strategy is backend-agnostic and unit-testable against a stub engine.
17
17
 
18
18
  | Primitive | Contract |
19
19
  |---|---|
20
- | `sdk.agent(spec)` | Run ONE sub-agent → `AgentResult`. `spec`: `{ agent, task, model?, tools?, skills?, role?, outputContract?, isolation?, mcp?, timeoutMs?, peers? }`. |
20
+ | `sdk.agent(spec, { reserveChildren? }?)` | Run ONE sub-agent → `AgentResult`. `spec`: `{ agent, task, name?, model?, tools?, skills?, role?, outputContract?, isolation?, mcp?, timeoutMs?, peers? }`. Optional `reserveChildren` protects mandatory later legs from spending their slots on recovery. |
21
21
  | `sdk.parallel(thunks, {concurrency?})` | Run many at once, capped at `limits.maxConcurrency`. The basis of every fan-out (`parallel(items.map(…))`). |
22
22
  | `sdk.reduce.aggregate(results)` | Concatenate N results into one (fan-out's merge). |
23
23
  | `sdk.reduce.vote(candidates, opts)` | Tally the candidates' OWN votes → a `ReducerResult` (`voting.ts`). |
@@ -27,7 +27,8 @@ a strategy is backend-agnostic and unit-testable against a stub engine.
27
27
  | series & loops | Plain `await` / `for` — a strategy is TypeScript, so `pipeline` and `critic-loop` are just native control flow. |
28
28
 
29
29
  Run limits (`RUN_LIMITS`) are enforced inside `makeSDK` regardless of how a strategy calls `agent()`:
30
- `maxChildren`, `maxConcurrency`, `budgetTokens`, `timeoutMs` (idle window). Nesting depth is not a
30
+ `maxChildren`, `maxConcurrency`, `budgetTokens`, `timeoutMs` (idle window). Model recovery attempts
31
+ consume child slots and usage too; they are not free legs hidden outside these limits. Nesting depth is not a
31
32
  numeric knob on this list — it is structurally 1: a spawned child's whole pi-persona extension
32
33
  disables itself under `PI_PERSONA_DISABLE=1`, so it has no `delegate`/`council`/`orchestrate`/`flow`
33
34
  tool to call however a strategy or persona tries to nest it (the in-process engine additionally
@@ -39,6 +40,13 @@ indefinitely; the idle watchdog catches silence. Safety comes from these runtime
39
40
  structural depth-1 guarantee, not from sandboxing the strategy (see the I2 invariant in
40
41
  [ARCHITECTURE.md](ARCHITECTURE.md)).
41
42
 
43
+ Optional main-model recovery leaves admission room for queued logical legs and parallel thunks.
44
+ `agent(spec, { reserveChildren })` additionally reserves slots for a mandatory later phase;
45
+ `judge`, `compete`, and `synthesize` protect their final arbiter/merge this way. `map` reserves the
46
+ worker/verifier waves at splitting and the remaining verifier wave during workers; `pipeline`,
47
+ `critic-loop`, and `council-rounds` reserve their remaining required steps/rounds. Recovery must not
48
+ turn a usable partial panel into a run-fatal child-budget overflow.
49
+
42
50
  Every `agent()` call in one SDK instance shares a concurrency semaphore, including direct
43
51
  `Promise.all` calls and overlapping `parallel()` batches. A requested batch concurrency may lower
44
52
  the ceiling, never raise it. Cancellation removes queued legs before they reach an engine.
@@ -55,6 +63,13 @@ through `rosterSpec` to honour the specialisation — **`critic-loop` is the one
55
63
  `generator`/`critic` to full specs so a role-carrying roster works, while still accepting a bare-name
56
64
  `params.generator`/`critic` override.
57
65
 
66
+ The `council` tool also accepts temporary `members`, with the same member shape. These override the
67
+ named roster for that invocation only: no team or persona file is created or changed. An empty or
68
+ invalid list is an error, not a request to silently fall back to MAGI. External actors such as
69
+ `params.judge`, `params.synthesizer`, `params.generator`, `params.critic`, and `map`'s `params.verify`
70
+ accept a bare agent name
71
+ or an inline member specification, so the arbiter can have its own role and model too.
72
+
58
73
  Same-agent members are disambiguated in the live `f9` tree by a role hint (`reviewer · SECURITY`) via
59
74
  `rosterNodeKeys`/`roleHint` (`roster.ts`) + the SDK's per-run key (`sdk.ts`). If you touch either, keep
60
75
  the seeding loop and the SDK key derivation in lockstep, and inject any per-run protocol via the TASK
@@ -66,7 +81,7 @@ text, never `role` (or the derived tree key drifts from the seeded one).
66
81
  |---|---|---|---|
67
82
  | `fanout` | Every roster agent on the same task in parallel, then `aggregate`. | — | roster-role |
68
83
  | `pipeline` | Roster in SEQUENCE, each builds on the prior output; answer = last step. | — | roster-role |
69
- | `map` | A splitter breaks the task into a runtime list; a worker runs once per item in parallel, then `aggregate`. | `maxItems` (default AND ceiling: maxChildren − 1, the splitter takes a slot; a larger value is clamped and the drop is noted in the output), `peers` (false), `ownership` ("off" — "off"/"declare"/"enforce": ignore, record, or gate on the splitter's per-item `writeSet`; always exposed as `structured.items`, a per-item status ledger), `verify` ("" — agent that re-checks each COMPLETED item read-only; one extra child per completed item, empty = off) | roster-role, opt-in peers |
84
+ | `map` | A splitter breaks the task into a runtime list; a worker runs once per item in parallel, then `aggregate`. | `maxItems` (default AND ceiling: maxChildren − 1 without verification, halved and rounded down with `verify`, minimum 1; the splitter takes a slot; a larger value is clamped and the drop is noted in the output), `peers` (false), `ownership` ("off" — "off"/"declare"/"enforce": ignore, record, or gate on the splitter's per-item `writeSet`; always exposed as `structured.items`, a per-item status ledger), `verify` ("" — agent that re-checks each COMPLETED item read-only; one extra child per completed item, empty = off) | roster-role, opt-in peers |
70
85
  | `critic-loop` | Generator proposes, critic attacks; `reject`/`revise` triggers another draft, and only explicit `approve` succeeds. Exhaustion fails closed with the last reviewed draft + unresolved critique (never an unreviewed tail revision). | `generator` (roster[0]), `critic` (roster[1]), `rounds` (positive integer, 3) | roster-role, `outputContract` |
71
86
  | `magi` | Parallel INDEPENDENT votes from distinct-persona cores → majority/unanimity, tally + minority report; one anonymised reflection round by default. | `aggregate` ("majority"), `reflect` (true) | vote reducer |
72
87
  | `council-rounds` | Multi-round `magi`, best-of-X: the whole roster re-deliberates carrying the debate forward until a supermajority, else best-by-confidence on the last round. | `rounds` (3), `bestOf` (majority), `aggregate` ("majority") | vote reducer |
@@ -82,10 +97,12 @@ text, never `role` (or the derived tree key drifts from the seeded one).
82
97
  single read-only review pass per completed unit of work, not swarm autonomy for its own sake. It
83
98
  runs one extra child per COMPLETED item (a failed item has nothing to re-check), through the same
84
99
  `sdk.parallel` wave as the workers, so `maxChildren`/`maxConcurrency`/`budgetTokens` still bound it
85
- — sizing `maxItems` with headroom for roughly double the child count is the caller's job when
86
- `verify` is set. It pays for a batch where a wrong answer is expensive and hard to eyeball after
87
- the fact (security-sensitive edits, long-tail correctness); it costs more than it is worth for a
88
- low-risk sweep a supervisor can spot-check itself. A verifier's stance is read via the SAME
100
+ — `map` automatically reduces the item ceiling to leave one verifier slot per item. With the default
101
+ `maxChildren: 64`, that allows 31 items (1 splitter + 31 workers + 31 verifiers); a lower `maxItems`
102
+ can leave more room for optional model recovery. It pays for a batch where a wrong answer is
103
+ expensive and hard to eyeball after the fact (security-sensitive edits, long-tail correctness);
104
+ it costs more than it is worth for a low-risk sweep a supervisor can spot-check itself.
105
+ A verifier's stance is read via the SAME
89
106
  `outputContract: "default"` machinery `critic-loop` uses for its critic (`structured.stance`):
90
107
  `"approve"` passes, anything else — an explicit reject/revise, a missing stance, or a verifier leg
91
108
  that itself failed to run — flips that item's `structured.items` ledger entry to `status: "failed"`
@@ -134,22 +151,18 @@ council. They are enforced structurally, not by convention.
134
151
  failures stay excluded) — so `magi`/`council-rounds`/`debate` degrade to the strongest single
135
152
  response rather than returning `ok: false`. The "N invalid excluded" footer counts only
136
153
  genuinely-dropped candidates, not the surfaced prose.
137
- - **A broken MODEL costs a member, not its vote.** A core that fails because its model broke —
138
- `failureKind: "provider"` (the provider rejected or is down) or `"unknown-model"` (the ref does not
139
- resolve) — is re-run ONCE on a model this same run proved works: a healthy peer's, preferring the
140
- session's own when a peer used it, and falling back to the session model when the failing core is
141
- the first or the only one left (`orchestration/model-retry.ts`, applied by `magi`). Every other
142
- failure passes straight through: an `abort`, `timeout`, `contract` or agent error reproduces on any
143
- model, and a retried abort would be a stop that does not stop. This is a roster-level complement to
144
- `engine/fallback.ts`, which reroutes the SAME model id across providers and knows nothing about the
145
- other members; the engine exhausts provider routes first, and only a still-broken run reaches here.
146
- It is deliberately a recovery and never a preference — a council's value is UNCORRELATED errors from
147
- distinct reasoners, so each recovery is named in the ruling's footer (`· 1 core recovered on
148
- <model>`) and usage bills every attempt. It is also bounded by the run's own `maxChildren` and
149
- observed token spend: recovery attempts run serially so each completion can close the token gate
150
- before another billed leg starts, and reflection starts only when the remaining observable budget
151
- can fund the whole panel. A retry or reflection it cannot afford is stated rather than silently
152
- skipped.
154
+ - **A broken MODEL recovers only on the main model.** All strategies use the same SDK rule:
155
+ `failureKind: "provider"` or `"unknown-model"` permits ONE recovery attempt on the supervisor's
156
+ current model. This is explicitly authorized even when the initial member model was pinned or
157
+ saved earlier. It never borrows a peer's model or searches arbitrary providers, and skips recovery
158
+ when the main model is absent, already failed on that leg, or cannot fit the remaining child/token
159
+ budget. Other failures (`abort`, `timeout`, `contract`, agent/infrastructure errors) are terminal;
160
+ a stopped child never restarts. Strategy/flow engine construction disables the ordinary provider
161
+ reroute decorator so this is the first and only model switch. Every attempt contributes its usage
162
+ and child count; recovery is reported through `modelRecovery` and a visible warning. MAGI discloses
163
+ recovered cores in its ruling and keeps the recovered model during reflection rather than sending
164
+ a core back to its broken assignment. Recovery is a degraded-mode safeguard, not a preference:
165
+ sharing the main model can reduce reasoner diversity.
153
166
  - **The contract instructs as well as validates.** An engine that receives `outputContract` appends
154
167
  the format block (`contractInstructions`, derived from the same pinned def it validates against)
155
168
  to the member's task — a bare generic agent votes as reliably as one whose `.md` spells the JSON
@@ -167,8 +180,11 @@ nothing. Vote keys are normalised (`json-first`, `JSON_First`, `json first` →
167
180
  ## Param schema (declaration · validation · discovery)
168
181
 
169
182
  Each strategy declares its params as `params?: Record<string, StrategyParam>` where `StrategyParam =
170
- { type: "string" | "number" | "boolean"; default?; doc }`. `knownParams(name)` (`strategy.ts`) exposes
171
- them. Two consumers:
183
+ { type: "string" | "number" | "boolean" | "agent"; default?; rosterIndex?; inheritRoster?; doc }`.
184
+ Agent-valued defaults name real agents; `rosterIndex` identifies an omitted selector's roster default,
185
+ and `inheritRoster` preserves a named selector's roster specialization where that strategy uses it.
186
+ Picker preflight reads these declarations instead of guessing participants from prose. `knownParams(name)`
187
+ (`strategy.ts`) exposes them. Two consumers:
172
188
 
173
189
  - The **`council` tool** warns (via `ui.notify`, never hard-fails — I2 lenient) when a call passes a
174
190
  key the active strategy doesn't declare, e.g. `ignoring unknown param "reflct" for magi (known:
@@ -184,19 +200,30 @@ The schema is for discovery and typo-catching, not enforcement: a strategy still
184
200
  Options reach a strategy's `input.params` from four surfaces, all overridable by the supervisor
185
201
  per-call:
186
202
 
187
- 1. **Author default (static)** — a persona's `council: { strategy, roster, params }` (tool-driven) or
203
+ 1. **Author default (static)** — a persona's `council: { strategy?, roster?, members?, params? }` (tool-driven) or
188
204
  mandatory `orchestration: { mode, strategy, roster, params }` both carry a `params` map
189
205
  (`persona.ts` → `orchestrate.ts`). E.g. `council: { strategy: magi, params: { reflect: false } }`.
190
206
  2. **Borrowed persona profile (dynamic)** — `council({ persona: "magi", question: … })` resolves that
191
207
  installed persona's already-expanded `council:` block. The caller remains active and retains its
192
- prompt, model, tools, and capability gates; only strategy/roster/params are borrowed. An unknown
208
+ prompt, model, tools, and capability gates; only strategy/roster/members/params are borrowed. An unknown
193
209
  persona or one without a usable council is a hard error, never a silent MAGI fallback.
194
- 3. **Supervisor override (dynamic)** — the `council` tool accepts a per-call `strategy`, `roster`, and
195
- `params`; the params **merge OVER** the selected (or active) persona's (`extension.ts`), so the LLM can pick
196
- `{ aggregate: "unanimity" }` for one invocation, or switch strategy entirely
197
- (`council({ strategy: "debate", … })`), without editing the persona.
210
+ 3. **Supervisor override (dynamic)** — the `council` tool accepts a per-call `strategy`, named
211
+ `roster`, temporary `members`, and `params`. Params merge over the selected (or active) profile when the strategy stays the same.
212
+ Switching strategy starts from that strategy's own defaults; only explicitly supplied per-call
213
+ params carry across the switch. For example, `{ aggregate: "unanimity" }` overrides the active
214
+ strategy for one invocation, while `council({ strategy: "debate", … })` does not inherit unrelated
215
+ profile params. Explicit unknown keys still produce a warning (leniently ignored), never a hard
216
+ failure.
198
217
  4. **Preset (reusable bundle)** — `council: { preset: <name> }` expands `presets/<name>.preset.json`
199
218
  into `{ strategy, roster, params }`; authored fields win, params merge (`expandCouncilPreset`).
219
+ Strategy/roster names are trimmed, and only those three preset fields are copied. Inline `members`
220
+ belong in the persona's validated council declaration or the tool call, not in preset JSON.
221
+
222
+ An authored members-only declaration such as `council: { members: [reviewer, research] }` is valid
223
+ and defaults to `magi`. Its params remain in effect when a call explicitly selects that same default
224
+ strategy. Empty or malformed authored `members` are a configuration error, not an absent roster:
225
+ the parser retains the diagnostic and council resolution fails instead of choosing a fallback panel.
226
+ Correct or remove the field before calling that persona's council, even with per-call overrides.
200
227
 
201
228
  The mandatory `orchestration:` path fires pre-turn on the raw user text, so it takes no dynamic
202
229
  per-call params (author params are threaded intact) — that is the difference between the two modes:
package/docs/TELEMETRY.md CHANGED
@@ -1,9 +1,23 @@
1
1
  # Telemetry wire contract
2
2
 
3
+ Agent `waiting` is an explicit projection of a pending supervisor ask, read through the live
4
+ engine steer handle. Ask delivery and settlement (reply, timeout or cancellation) refresh the
5
+ projection; overlapping asks keep it waiting until the last one settles. Queued and terminal
6
+ statuses retain precedence. The producer deduplicates unchanged projections and never infers
7
+ waiting from tool arguments, output text or a quiet clock.
8
+
3
9
  Telemetry is a vendor-neutral v2 envelope. `producerId` and `producerVersion` identify the emitting plugin; `version` identifies the wire schema. `seq` is monotonic only within `(producerId, sessionId)`, and `id` is deduplicated within that same scope.
4
10
 
5
11
  The pi-persona producer writes semantic projections under `telemetry/v2/<workspace>/<producerId>/`. `<workspace>` always identifies the Pi process's actual cwd; joining another Exocom scope does not rewrite telemetry identity. It never persists task/prompt text, tool arguments/activity, model output, paths, secrets, or message bodies. Known pi-persona event payloads are allowlisted; an unreviewed producer event retains only its envelope/type (payload `{}`) at the producer and consumer boundary. Future producers must add a reviewed allowlist/schema before publishing data intended for a consumer.
6
12
 
7
13
  JSONL compaction is atomic and hysteretic. It preserves the initial `instance.started` anchor and complete records; the configured byte limit is therefore a soft bound for an anchor plus one complete event. The `.previous` backup is recovered after an interrupted swap.
8
14
 
15
+ Producer shutdown flushes its terminal event before releasing its writer lease. It does not join the shared background retention sweep, so unrelated stale sessions cannot delay this shutdown or reload. `flushRetention()` remains the explicit await for maintenance completion; cleanup errors still reach the non-fatal diagnostic sink.
16
+
17
+ The pi-persona producer also applies a default 30-day age retention to its own valid v2 session groups; `retentionMs: 0` disables cleanup only, not writer leases. Cleanup is asynchronous after successful initialization and can be awaited with the producer's retention test seam. It strictly considers regular single-link logs plus recognized `.previous`/compaction scratch files beneath valid workspace IDs and this producer directory. Legacy data, foreign producers, malformed names, links, and unknown files are retained. Orphan backups/scratch and dead-marker-only groups are considered too; empty owned lease/producer/workspace directories are removed nonrecursively. The cutoff is exclusive and cleanup runs on activation, not on a wall-clock deletion schedule.
18
+
19
+ Retention and writers coordinate cooperatively on the same host using unique empty `0600` marker files in `<session>.jsonl.leases`. Writers claim before recovery/sequence reads and keep the claim through terminal publication and flush. A pruner claims before checking writers and revalidates the whole old session group before unlinking. Marker names are immutable and unique to avoid stale-lock ABA. PID liveness is conservative: only `ESRCH` proves death; current, live, permission-denied, and unknown states protect data. This protocol cannot protect against historic writers that predate leases, nor non-cooperating processes; it is not an OS-level lock. A pre-upgrade process has no marker: its log is protected only while normal writes/heartbeats keep its mtime newer than the cutoff. That incidental mtime grace is not active-writer protection; let old sessions settle before reloading/upgrading when possible.
20
+
21
+ Admission fails before recovery, sequence reads or emission when a live prune claim, unknown or unsafe lease marker, linked namespace/artifact, or filesystem error prevents a safe writer claim. The host warns and continues without telemetry for that activation. A failed attempt removes only its own marker and empty directories it created, nonrecursively; pre-existing data and leases remain intact. Cleanup and writer errors reach an always-enabled, non-fatal `onError` sink: a sanitized warning in interactive/RPC sessions, or stderr headless. A failing diagnostic renderer cannot interrupt the session. Retention deletes the sequence source: resuming an expired log starts a new retained stream at sequence 1, so it does not promise continuity with previously exported expired history.
22
+
9
23
  The flow consumer is a compatibility reader for v1 logs, which sit under `persona/flow` on an install the storage-root consolidation has migrated and under `pi-persona/flow` on one that predates it; the consumer reads both roots, because no producer writes v1 any more and dropping either name would silently lose history. It re-implements the parser rather than importing this one, and that duplication is deliberate: a consumer that ran the producer's parser could not validate against a buggy or hostile producer, and a vendor-neutral contract cannot make every future producer depend on pi-persona. What keeps the two honest is a conformance corpus — one byte-identical block of wire cases (accept, reject, and the exact projected payload) carried by `test/unit/telemetry/parity.test.ts` here and `test/parity.test.ts` there. The flow suite enforces that identity, comparing its block against the one in a sibling pi-persona checkout (or `PI_PERSONA_REPO`) and naming the first line that differs; it lives there and not here because flow may depend on pi-persona and never the reverse, and it skips — never fails — when no such checkout resolves, so flow stays testable standalone. A divergence therefore surfaces as a named line rather than as a silently dropped event. `TELEMETRY_PRODUCER_VERSION` is asserted against `package.json` in the same test: a release bumps both or fails. Any future shared package must preserve v1 read compatibility, v2 producer/session scoping, and the corpus.
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@aeondave/pi-persona",
3
- "version": "1.15.2",
3
+ "version": "1.16.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Supervised multi-agent orchestration for Pi — async workers, live steering, cross-session collaboration, and switchable personas.",
7
+ "author": "AeonDave",
7
8
  "repository": {
8
9
  "type": "git",
9
10
  "url": "git+https://github.com/AeonDave/pi-persona.git"
@@ -18,12 +19,16 @@
18
19
  "keywords": [
19
20
  "pi-package",
20
21
  "pi-extension",
22
+ "pi-coding-agent",
21
23
  "pi",
22
- "persona",
23
24
  "subagents",
24
25
  "orchestration",
25
26
  "multi-agent",
26
- "supervisor"
27
+ "supervisor",
28
+ "async-agents",
29
+ "live-steering",
30
+ "cross-session",
31
+ "persona"
27
32
  ],
28
33
  "license": "MIT",
29
34
  "files": [
@@ -50,6 +55,7 @@
50
55
  "README.md",
51
56
  "AGENTS.md",
52
57
  "CHANGELOG.md",
58
+ "SECURITY.md",
53
59
  "LICENSE"
54
60
  ],
55
61
  "scripts": {
@@ -84,20 +90,21 @@
84
90
  }
85
91
  },
86
92
  "engines": {
87
- "node": ">=22"
93
+ "node": ">=22.19.0"
88
94
  },
89
95
  "devDependencies": {
90
- "@earendil-works/pi-agent-core": "0.85.1",
91
- "@earendil-works/pi-ai": "0.85.1",
92
- "@earendil-works/pi-coding-agent": "0.85.1",
93
- "@earendil-works/pi-tui": "0.85.1",
96
+ "@earendil-works/pi-agent-core": "1.0.0",
97
+ "@earendil-works/pi-ai": "1.0.0",
98
+ "@earendil-works/pi-coding-agent": "1.0.0",
99
+ "@earendil-works/pi-tui": "1.0.0",
94
100
  "@types/node": "^22.20.4",
101
+ "fast-check": "^4.10.2",
95
102
  "tsx": "^4.23.13",
96
- "typescript": "^5.9.3",
97
- "typebox": "1.3.34"
103
+ "typebox": "1.3.34",
104
+ "typescript": "^7.0.2"
98
105
  },
99
106
  "pi": {
100
- "image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.15.2/assets/banner.png",
107
+ "image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.16.1/assets/banner.png",
101
108
  "video": "https://cdn.jsdelivr.net/gh/AeonDave/pi-persona@6655d281ce7965a75303dd704bd29db73db04070/assets/demo1.mp4",
102
109
  "extensions": [
103
110
  "./src/extension.ts"
@@ -25,7 +25,8 @@ export function brokerEndpoint(sessionId: string, home: string = homedir(), plat
25
25
  // Session ids are uuidv7 (36 chars), so an ordinary-but-longish $HOME already pushes the
26
26
  // readable name past sun_path; a hashed one is short and still derived purely from the
27
27
  // session id, so host and client compute the same endpoint independently. The hashed form
28
- // costs `$HOME` + 41 bytes, so a $HOME past 62 bytes stays out of reach either way —
28
+ // costs `$HOME` + 38 bytes, so a $HOME past 65 bytes stays out of reach either way —
29
29
  // nothing under the user's own dir would fit.
30
- return join(dir, `b-${createHash("sha1").update(sid).digest("hex").slice(0, 12)}.sock`);
30
+ // base64url packs 72 SHA-256 bits into the same 12 path bytes as the old hex suffix.
31
+ return join(dir, `b-${createHash("sha256").update(sid).digest("base64url").slice(0, 12)}.sock`);
31
32
  }
package/src/core/brief.ts CHANGED
@@ -148,7 +148,7 @@ export function buildDelegationBrief(input: BriefInput): string | undefined {
148
148
 
149
149
  const example = input.agents.some((a) => a.name === "operator") ? "operator" : (listed[0]?.name ?? "operator");
150
150
  const delivery = input.asyncDefault
151
- ? "they run in the background and each result returns to you automatically as a follow-up — don't watch or poll a healthy leg; you're woken if one stalls or messages you, plus an occasional check-in to catch drift"
151
+ ? "they run in the background and each result returns to you automatically as a follow-up — do useful independent work while children run; when only children remain, end your turn and let completion wake you. Do not monitor, poll, or automatically wait for all children (don't watch or poll a healthy leg). Join intentionally only when a result is needed before your next step; you're also woken if a child stalls or messages you, plus an occasional check-in to catch drift"
152
152
  : "the call returns when they finish";
153
153
  const callFields = [`agent: "${example}"`, `task: "<self-contained bounded task + success signal>"`];
154
154
  if (input.requireBrief) {
package/src/core/nudge.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  /** Tools that mean "the operator handed work off" — they end (reset) a by-hand run. */
29
- const HANDOFF_TOOLS = new Set(["delegate", "council"]);
29
+ export const HANDOFF_TOOL_NAMES: ReadonlySet<string> = new Set(["delegate", "council"]);
30
30
  /** Supervision glue: collecting a delegated result (`intercom wait`/`result`) or arming a
31
31
  * `timer` is neither hands-on work nor a hand-off. Their payloads are large by nature, so
32
32
  * counting them would punish the exact behavior the nudge exists to encourage. They neither
@@ -138,7 +138,7 @@ export class DelegationNudge {
138
138
  */
139
139
  observe(toolName: string, size: number, handoffSucceeded = true, failureText?: string): string | undefined {
140
140
  if (GLUE_TOOLS.has(toolName)) return undefined;
141
- if (HANDOFF_TOOLS.has(toolName)) {
141
+ if (HANDOFF_TOOL_NAMES.has(toolName)) {
142
142
  if (!handoffSucceeded) {
143
143
  const normalizedSize = Math.max(0, Math.floor(size));
144
144
  const identity = failureText === undefined ? `size:${normalizedSize}` : fingerprintFailure(failureText);
@@ -204,7 +204,6 @@ export class DelegationNudge {
204
204
  */
205
205
 
206
206
  /** Tools whose result is a delegated child's report (where a surrender can surface). */
207
- const REPORT_TOOLS = new Set(["delegate", "council"]);
208
207
 
209
208
  /** Explicit surrender/blocked markers the operator + CTF protocols emit (operator.md). */
210
209
  const SURRENDER_MARKERS: readonly RegExp[] = [/\[BLOCKED\b/i, /\bFLAG:\s*UNKNOWN\b/i];
@@ -222,7 +221,7 @@ export class PersistenceNudge {
222
221
  * The SYNC path (a `delegate`/`council` tool_result the supervisor sees inline).
223
222
  */
224
223
  observe(toolName: string, text: string): string | undefined {
225
- if (!REPORT_TOOLS.has(toolName)) return undefined;
224
+ if (!HANDOFF_TOOL_NAMES.has(toolName)) return undefined;
226
225
  return this.scan(text);
227
226
  }
228
227
 
@@ -3,10 +3,10 @@ import { createRequire } from "node:module";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
- /** Oldest Pi this extension is written against. 0.83 made `message_update` delta-only (src/engine/stream.ts);
7
- * older hosts are feature-detected where cheap but are not supported. Keep in sync with README.md;
6
+ /** Oldest supported Pi host. The release is qualified against Pi 1.0's SDK and tool lifecycle;
7
+ * pre-1.0 hosts are unsupported. Keep in sync with README.md;
8
8
  * npm host peers use "*" because Pi supplies them through its extension loader. */
9
- export const MIN_PI_VERSION = "0.83.0";
9
+ export const MIN_PI_VERSION = "1.0.0";
10
10
 
11
11
  function parse(v: string): [number, number, number] | undefined {
12
12
  const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
@@ -12,7 +12,7 @@ import { type ContractDef, contractInstructions, parseAndValidate, pinContract,
12
12
  import { sanitizeDisplayLabel } from "../core/display-label.ts";
13
13
  import { assignedIdentityPrompt } from "../core/naming.ts";
14
14
  import { roleHint } from "../orchestration/roster.ts";
15
- import { type AgentRunSpec, isPositiveFiniteMs, type StrategyEngine } from "../orchestration/sdk.ts";
15
+ import { type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
16
16
  import type { AgentResult } from "../orchestration/types.ts";
17
17
  import { type ChildEngineOptions, type ChildRunSpec, runChildAgent } from "./child.ts";
18
18
  import { looksLikeProviderError } from "./errors.ts";
@@ -51,8 +51,10 @@ export interface EngineAdapterDeps {
51
51
  /** Installed output-contract names (built-in `default` + files) for the self-correcting hint. */
52
52
  listContracts?: () => string[];
53
53
  /** Per-agent model override (e.g. a persona's configured ensemble models).
54
- * Precedence: explicit spec.model > modelFor(agent) > the agent's own default. */
55
- modelFor?: (agent: string) => string | undefined;
54
+ * Precedence: explicit spec.model > modelFor(agent, role) > the agent's own default. `role` is
55
+ * the on-the-fly role this run carries, when it has one, so a member's own assignment key
56
+ * (`agent#hash`) resolves ahead of the bare agent's. */
57
+ modelFor?: (agent: string, role?: string) => string | undefined;
56
58
  /** Explicit thinking level appended to the child model (`model:level`) so it can't
57
59
  * fall into a model's default "adaptive" mode, which some models reject. */
58
60
  childThinking?: string;
@@ -107,7 +109,7 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
107
109
  onProgress?: (p: { output: string; tokens?: number; activity?: string; toolEvent?: ToolEvent }) => void,
108
110
  callSignal?: AbortSignal,
109
111
  /** Called once with a steer handle when the broker is on (in-process parity). */
110
- onSteerable?: (steer: (text: string) => void) => void,
112
+ onSteerable?: (steer: SteerFn) => void,
111
113
  ): Promise<AgentResult> {
112
114
  const cfg = deps.resolveAgent(spec.agent);
113
115
  if (!cfg) return unknownAgentFailure(spec.agent, deps.listAgents?.() ?? []);
@@ -125,7 +127,7 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
125
127
  : spec.task;
126
128
  const task = contractDef ? `${withSkills}\n\n${contractInstructions(contractDef)}` : withSkills;
127
129
  const childSpec: ChildRunSpec = { task };
128
- let model = spec.model ?? deps.modelFor?.(spec.agent) ?? cfg.model;
130
+ let model = spec.model ?? deps.modelFor?.(spec.agent, spec.role) ?? cfg.model;
129
131
  // Append an explicit thinking level (model:level) unless one is already present —
130
132
  // a child without it defaults to "adaptive", which some models reject.
131
133
  if (model && deps.childThinking && !model.includes(":")) model = `${model}:${deps.childThinking}`;
@@ -178,7 +180,10 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
178
180
  childOptions.isBlocked = () => deps.broker!.hasPendingAskFrom(handle!);
179
181
  const broker = deps.broker;
180
182
  const h = handle;
181
- onSteerable?.((text) => broker.steerFrame(h, text));
183
+ const steer: SteerFn = Object.assign((text: string) => broker.steerFrame(h, text), {
184
+ isWaitingForSupervisor: () => broker.hasPendingAskFrom(h),
185
+ });
186
+ onSteerable?.(steer);
182
187
  }
183
188
 
184
189
  let child: Awaited<ReturnType<typeof runChildAgent>>;
@@ -320,7 +320,7 @@ export function dedupeRunsById(runs: AsyncRun[]): AsyncRun[] {
320
320
 
321
321
  /** One identity everywhere a tracked run is rendered: the launcher's codename when present,
322
322
  * otherwise the agent type, with the already-shortened model appended once. */
323
- function runDisplayName(run: AsyncRun): string {
323
+ export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model">): string {
324
324
  const model = run.model === undefined ? undefined : sanitizeDisplayLabel(run.model, "model", 24);
325
325
  if (model === undefined) return sanitizeDisplayLabel(run.label ?? run.agent);
326
326
  const suffix = ` · ${model}`;
@@ -445,7 +445,7 @@ export function clipRunOutput(text: string, id: string, maxChars: number, label
445
445
  return `${text.slice(0, headChars)}${marker}${tailChars > 0 ? text.slice(-tailChars) : ""}`;
446
446
  }
447
447
 
448
- export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs?: number }): string {
448
+ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs?: number; waitingForSupervisor?: ReadonlySet<string> }): string {
449
449
  if (runs.length === 0) return "No async runs.";
450
450
  const running = runs.filter((r) => r.status === "running").length;
451
451
  const now = opts?.now;
@@ -460,7 +460,8 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
460
460
  const head = `[${r.id}] ${name} — ${r.status}`;
461
461
  if (r.status === "running") {
462
462
  let line = `${head} (${r.progress.turns} turns, ${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
463
- if (now !== undefined && stallMs !== undefined && stallMs > 0 && r.lastAdvanceAt !== undefined) {
463
+ if (opts?.waitingForSupervisor?.has(r.id)) line += " · waiting for supervisor";
464
+ else if (now !== undefined && stallMs !== undefined && stallMs > 0 && r.lastAdvanceAt !== undefined) {
464
465
  const stalledFor = now - r.lastAdvanceAt;
465
466
  if (stalledFor >= stallMs) line += ` ⚠ possibly stuck (no progress for ${Math.round(stalledFor / 1000)}s)`;
466
467
  }
@@ -493,12 +494,17 @@ export class PeekWatcher {
493
494
  private readonly reportedAt = new Map<string, number>();
494
495
 
495
496
  /** The running legs that have NEWLY crossed the stall window since we last reported them. */
496
- poll(runs: AsyncRun[], now: number, stallMs: number): AsyncRun[] {
497
+ poll(runs: AsyncRun[], now: number, stallMs: number, waitingForSupervisor: ReadonlySet<string> = new Set()): AsyncRun[] {
497
498
  const live = new Set<string>();
498
499
  const newlyStuck: AsyncRun[] = [];
499
500
  for (const r of runs) {
500
- if (r.status !== "running" || r.lastAdvanceAt === undefined) continue;
501
+ if (r.status !== "running") continue;
501
502
  live.add(r.id);
503
+ if (waitingForSupervisor.has(r.id)) {
504
+ this.reportedAt.delete(r.id); // a live ask is not a stall; re-evaluate immediately when it settles
505
+ continue;
506
+ }
507
+ if (r.lastAdvanceAt === undefined) continue;
502
508
  const prev = this.reportedAt.get(r.id);
503
509
  if (prev !== undefined && r.lastAdvanceAt > prev) this.reportedAt.delete(r.id); // advanced ⇒ re-arm
504
510
  if (stallMs > 0 && now - r.lastAdvanceAt >= stallMs && !this.reportedAt.has(r.id)) {
@@ -548,7 +554,7 @@ export function buildPeekAlert(stuck: AsyncRun[], opts: { now: number }): string
548
554
  * fast {@link buildPeekAlert} stall signal: it catches a leg going the WRONG way (not stalled, just
549
555
  * wrong) before it burns the budget, without waking the supervisor every tick.
550
556
  */
551
- export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number }): string {
557
+ export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number; waitingForSupervisor?: ReadonlySet<string> }): string {
552
558
  return (
553
559
  `${buildPeekDigest(runs, opts)}\n\n` +
554
560
  "Routine check-in — internal status update, not a new task: don't reply just to acknowledge unchanged progress. Continue your current " +