@aeondave/pi-persona 1.15.2 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/AGENTS.md +30 -6
  2. package/CHANGELOG.md +82 -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 +8 -0
  11. package/package.json +11 -9
  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 +209 -49
  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
@@ -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
@@ -6,4 +6,12 @@ The pi-persona producer writes semantic projections under `telemetry/v2/<workspa
6
6
 
7
7
  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
8
 
9
+ 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.
10
+
11
+ 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.
12
+
13
+ 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.
14
+
15
+ 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.
16
+
9
17
  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,6 +1,6 @@
1
1
  {
2
2
  "name": "@aeondave/pi-persona",
3
- "version": "1.15.2",
3
+ "version": "1.16.0",
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.",
@@ -50,6 +50,7 @@
50
50
  "README.md",
51
51
  "AGENTS.md",
52
52
  "CHANGELOG.md",
53
+ "SECURITY.md",
53
54
  "LICENSE"
54
55
  ],
55
56
  "scripts": {
@@ -84,20 +85,21 @@
84
85
  }
85
86
  },
86
87
  "engines": {
87
- "node": ">=22"
88
+ "node": ">=22.19.0"
88
89
  },
89
90
  "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",
91
+ "@earendil-works/pi-agent-core": "1.0.0",
92
+ "@earendil-works/pi-ai": "1.0.0",
93
+ "@earendil-works/pi-coding-agent": "1.0.0",
94
+ "@earendil-works/pi-tui": "1.0.0",
94
95
  "@types/node": "^22.20.4",
96
+ "fast-check": "^4.10.2",
95
97
  "tsx": "^4.23.13",
96
- "typescript": "^5.9.3",
97
- "typebox": "1.3.34"
98
+ "typebox": "1.3.34",
99
+ "typescript": "^7.0.2"
98
100
  },
99
101
  "pi": {
100
- "image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.15.2/assets/banner.png",
102
+ "image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.16.0/assets/banner.png",
101
103
  "video": "https://cdn.jsdelivr.net/gh/AeonDave/pi-persona@6655d281ce7965a75303dd704bd29db73db04070/assets/demo1.mp4",
102
104
  "extensions": [
103
105
  "./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 " +
@@ -30,7 +30,7 @@ import { assignedIdentityPrompt } from "../core/naming.ts";
30
30
  import { attributeInbound } from "../core/fence.ts";
31
31
  import { isThinkingLevel, type ThinkingLevel } from "../core/types.ts";
32
32
  import { roleHint } from "../orchestration/roster.ts";
33
- import { type AgentRunSpec, isPositiveFiniteMs, type StrategyEngine } from "../orchestration/sdk.ts";
33
+ import { type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
34
34
  import type { AgentResult } from "../orchestration/types.ts";
35
35
  import { looksLikeProviderError } from "./errors.ts";
36
36
  import { nextChildHandle } from "./handles.ts";
@@ -94,8 +94,10 @@ export interface InProcessDeps {
94
94
  cwd: string;
95
95
  /** Pi's global agent dir (~/.pi/agent); defaults to cwd if omitted (tests). */
96
96
  agentDir?: string;
97
- /** Per-agent model override (e.g. a persona's configured ensemble models). */
98
- modelFor?: (agent: string) => string | undefined;
97
+ /** Per-agent model override (e.g. a persona's configured ensemble models). `role` is the
98
+ * on-the-fly role this run carries, when it has one, so a member's own assignment key
99
+ * (`agent#hash`) resolves ahead of the bare agent's. */
100
+ modelFor?: (agent: string, role?: string) => string | undefined;
99
101
  /** The session/loader model — the last-resort fallback when an agent declares none
100
102
  * (the child engine gets this for free from `pi -p`'s default; inproc must pass it). */
101
103
  defaultModel?: string;
@@ -319,10 +321,11 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
319
321
  const requestedDef = requestedContract ? pinnedDef(requestedContract) : undefined;
320
322
  if (requestedContract && !requestedDef) return unknownContractFailure(spec.agent, requestedContract, deps.listContracts?.() ?? []);
321
323
 
322
- const ref = spec.model ?? deps.modelFor?.(spec.agent) ?? cfg.model ?? deps.defaultModel;
324
+ const picked = spec.model ? undefined : deps.modelFor?.(spec.agent, spec.role);
325
+ const ref = spec.model ?? picked ?? cfg.model ?? deps.defaultModel;
323
326
  const model = resolveModel(deps.modelRegistry, ref);
324
327
  if (!model) {
325
- const src = spec.model ? "spec" : deps.modelFor?.(spec.agent) ? "agent picker" : cfg.model ? "agent config" : "default";
328
+ const src = spec.model ? "spec" : picked ? "agent picker" : cfg.model ? "agent config" : "default";
326
329
  // Make the failure self-correcting: name a few real registry refs (nearest
327
330
  // matches first) so the supervisor's retry can pick a valid model at once.
328
331
  const all = deps.modelRegistry.getAll().map((m) => `${m.provider}/${m.id}`);
@@ -681,7 +684,9 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
681
684
 
682
685
  // Steering: the in-process engine can inject a user message into the running
683
686
  // agent — the v0.4 payoff the one-shot child engine can't do.
684
- onSteerable?.((text) => {
687
+ const waitingHandle = childHandle;
688
+ const waitingBus = deps.bus;
689
+ const steer: SteerFn = Object.assign((text: string): boolean => {
685
690
  if (!steerable) return false;
686
691
  const trimmed = text.trim();
687
692
  if (!trimmed) return false;
@@ -691,7 +696,10 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
691
696
  } catch {
692
697
  return false;
693
698
  }
699
+ }, {
700
+ isWaitingForSupervisor: () => waitingHandle !== undefined && waitingBus?.hasPendingAskFrom(waitingHandle) === true,
694
701
  });
702
+ onSteerable?.(steer);
695
703
 
696
704
  // Delivery bridge: bus messages addressed to this child are steered into its live
697
705
  // session as fenced, attributed user turns — attribution stays OUTSIDE the fence so
@@ -9,6 +9,7 @@ import type { RunLimits } from "../core/capabilities.ts";
9
9
  import type { PiPersonaConfig } from "../core/config.ts";
10
10
  import { isThinkingLevel } from "../core/types.ts";
11
11
  import { personaModels, type PersonaConfigStore } from "../persona/config-store.ts";
12
+ import { participantKey } from "../persona/model-participants.ts";
12
13
  import type { PersonaController, PersonaHost } from "../persona/controller.ts";
13
14
  import { type EngineAdapterBroker, type EngineAdapterDeps, makeEngine } from "../engine/adapter.ts";
14
15
  import { captureStatus, diffStatus, findGitRoot, renderChangeReport } from "../engine/change-report.ts";
@@ -50,7 +51,17 @@ export interface BuildEngineDeps {
50
51
  gitExec?: GitExec;
51
52
  }
52
53
 
53
- export type BuildEngine = (signal?: AbortSignal, onProgress?: (s: ProgressSnapshot) => void, engOpts?: { async?: boolean }) => StrategyEngine;
54
+ export type BuildEngine = (
55
+ signal?: AbortSignal,
56
+ onProgress?: (s: ProgressSnapshot) => void,
57
+ engOpts?: {
58
+ async?: boolean;
59
+ /** Wrap the engine in `withModelFallback` (default true). Strategy/flow builds pass
60
+ * `false`: there the SDK's own main-model recovery is the ONE reroute, so an arbitrary
61
+ * provider must not be substituted underneath a member the user pinned. */
62
+ providerFallback?: boolean;
63
+ },
64
+ ) => StrategyEngine;
54
65
 
55
66
  export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
56
67
  return (signal, onProgress, engOpts) => {
@@ -63,9 +74,18 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
63
74
  // A named contract file (contracts/<name>.contract.json) wins; "default" is the built-in.
64
75
  const contracts = (n: string): ContractDef | undefined => contractDefs[n] ?? (n === "default" ? DEFAULT_CONTRACT : undefined);
65
76
  const contractNames = (): string[] => installedContractNames(contractDefs);
66
- const modelFor = (agent: string): string | undefined => {
67
- const persona = controller.activePersona?.name;
68
- return persona ? personaModels(personaConfigs, persona)[agent] : undefined;
77
+ // A per-persona model assignment. The lookup key MUST match the one the picker saved
78
+ // (`persona/model-participants.ts`): a role member's own key first, then the bare agent
79
+ // key, so assignments saved before roles existed keep applying.
80
+ const persona = controller.activePersona?.name;
81
+ const savedModels = persona ? personaModels(personaConfigs, persona) : undefined;
82
+ const modelFor = (agent: string, role?: string): string | undefined => {
83
+ const trimmed = role?.trim();
84
+ if (trimmed) {
85
+ const own = savedModels?.[participantKey(agent, trimmed)];
86
+ if (own) return own;
87
+ }
88
+ return savedModels?.[agent];
69
89
  };
70
90
  // The main model thinks adaptively (it picks effort by difficulty); a spawned child
71
91
  // can't inherit "adaptive" if its model doesn't support it, so give children an
@@ -184,6 +204,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
184
204
  // switch on error". No ctx (no registry) ⇒ pass through. Each attempt still runs through
185
205
  // worktree isolation + steering below.
186
206
  const wrapFallback = (eng: StrategyEngine): StrategyEngine => {
207
+ if (engOpts?.providerFallback === false) return eng; // the SDK owns recovery there
187
208
  if (!lastCtx) return eng;
188
209
  const prefer = lastCtx.model?.provider;
189
210
  return withModelFallback(eng, {