@aeondave/pi-persona 1.17.3 → 1.17.5

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 (93) hide show
  1. package/AGENTS.md +57 -36
  2. package/CHANGELOG.md +106 -1
  3. package/README.md +32 -16
  4. package/docs/ARCHITECTURE.md +199 -48
  5. package/docs/COLLABORATION.md +110 -0
  6. package/docs/EXPERIENCE.md +33 -8
  7. package/docs/README.md +12 -0
  8. package/docs/REFERENCE.md +65 -14
  9. package/docs/STRATEGIES.md +6 -0
  10. package/package.json +12 -6
  11. package/src/agents/agent.ts +6 -0
  12. package/src/bridge.ts +33 -23
  13. package/src/bus/broker/client.ts +151 -30
  14. package/src/bus/broker/host.ts +130 -11
  15. package/src/bus/broker/messages.ts +35 -5
  16. package/src/bus/contact-transport.ts +8 -0
  17. package/src/bus/contact.ts +118 -40
  18. package/src/bus/peers.ts +23 -6
  19. package/src/bus/result-reader.ts +88 -0
  20. package/src/core/brief.ts +14 -4
  21. package/src/core/capabilities.ts +1 -1
  22. package/src/core/companion.ts +64 -0
  23. package/src/core/leg-thinking.ts +57 -0
  24. package/src/core/ownership.ts +1 -2
  25. package/src/core/pi-compat.ts +5 -12
  26. package/src/core/session-runs.ts +1 -1
  27. package/src/core/types.ts +1 -1
  28. package/src/engine/adapter.ts +100 -20
  29. package/src/engine/async.ts +53 -11
  30. package/src/engine/child.ts +11 -2
  31. package/src/engine/inproc.ts +98 -47
  32. package/src/engine/result-grant.ts +62 -0
  33. package/src/engine/session-tools.ts +6 -0
  34. package/src/engine/stream.ts +6 -0
  35. package/src/engine/worktree.ts +17 -7
  36. package/src/exocom/collaboration-delivery.ts +29 -0
  37. package/src/exocom/collaboration.ts +148 -0
  38. package/src/exocom/envelope.ts +43 -4
  39. package/src/exocom/gate.ts +7 -2
  40. package/src/exocom/install.ts +45 -17
  41. package/src/exocom/ledger.ts +58 -8
  42. package/src/exocom/plane.ts +32 -1
  43. package/src/exocom/profile.ts +40 -0
  44. package/src/exocom/registry.ts +5 -0
  45. package/src/exocom/status.ts +11 -1
  46. package/src/exocom/write-guard.ts +7 -0
  47. package/src/extension/broker-host.ts +61 -9
  48. package/src/extension/companion.ts +38 -0
  49. package/src/extension/engine.ts +40 -29
  50. package/src/extension/hooks.ts +954 -931
  51. package/src/extension/identity.ts +41 -75
  52. package/src/extension/memory-owner.ts +54 -0
  53. package/src/extension/mind-worker.ts +129 -0
  54. package/src/extension/results.ts +138 -0
  55. package/src/extension/shared.ts +17 -0
  56. package/src/extension.ts +2922 -2849
  57. package/src/loader.ts +11 -3
  58. package/src/orchestration/agent-attempt.ts +15 -0
  59. package/src/orchestration/flow-journal.ts +7 -1
  60. package/src/orchestration/flow-run.ts +125 -19
  61. package/src/orchestration/flow.ts +11 -0
  62. package/src/orchestration/roster.ts +71 -23
  63. package/src/orchestration/sdk.ts +61 -19
  64. package/src/orchestration/types.ts +10 -0
  65. package/src/persona/model-participants.ts +20 -7
  66. package/src/persona/orchestrate.ts +6 -7
  67. package/src/persona/persona.ts +1 -1
  68. package/src/results/archive.ts +281 -0
  69. package/src/results/record.ts +166 -0
  70. package/src/results/retrieval.ts +142 -0
  71. package/src/results/types.ts +128 -0
  72. package/src/telemetry/contract.ts +1 -1
  73. package/src/tools/council.ts +40 -23
  74. package/src/tools/delegate-tool.ts +50 -14
  75. package/src/tools/delegate.ts +13 -6
  76. package/src/tools/exocom-collaboration-input.ts +168 -0
  77. package/src/tools/exocom-collaboration.ts +315 -0
  78. package/src/tools/exocom-profile.ts +102 -0
  79. package/src/tools/exocom.ts +20 -3
  80. package/src/tools/flow.ts +24 -8
  81. package/src/tools/intercom-tool.ts +98 -14
  82. package/src/tools/models.ts +15 -0
  83. package/src/tools/monitor.ts +20 -1
  84. package/src/tools/read-result.ts +52 -0
  85. package/src/tools/timer.ts +23 -2
  86. package/src/ui/activity-rows.ts +34 -0
  87. package/src/ui/agent-overlay.ts +23 -13
  88. package/src/ui/agent-tree.ts +67 -6
  89. package/src/ui/literal-tool-result.ts +1 -1
  90. package/src/ui/model-picker.ts +1 -1
  91. package/src/ui/orchestration-launch.ts +49 -0
  92. package/src/ui/session-cards.ts +191 -14
  93. package/src/ui/strategy-leg.ts +40 -0
package/docs/REFERENCE.md CHANGED
@@ -34,7 +34,7 @@ hard-fail.
34
34
 
35
35
  | Surface | Does |
36
36
  |---|---|
37
- | `delegate` tool | spawn one or more sub-agents; interactive sessions always run it in the background (results return as completion follow-ups); headless `pi -p` always blocks and returns results inline. There is no `sync`/`async` parameter. A leg whose tool is silent longer than the idle window (180 s) fails with a message telling you to raise that leg's `timeoutMs`. A `tasks` fan-out's receipt pairs each run id with its call-sign, and one wake covers the whole batch |
37
+ | `delegate` tool | spawn one or more sub-agents; interactive sessions always run it in the background (results return as completion follow-ups); headless `pi -p` always blocks and returns results inline. There is no `sync`/`async` parameter. A leg whose tool is silent longer than the idle window (180 s) fails with a message telling you to raise that leg's `timeoutMs`. A `tasks` fan-out's receipt pairs each run id with its call-sign, and one wake covers the whole batch. An optional `thinking` picks that leg's effort level (off…max); omission keeps the agent's own setting and the configured/inherited child default, and results distinguish requested from observed effort |
38
38
  | `council` tool | select any installed strategy over a named roster or temporary inline `members`; voting strategies preserve tally and dissent. Interactive sessions run a council in the background, like `delegate`: a receipt with a run id now, ONE completion follow-up with the ruling (`intercom result { to: runId }` for the full payload); headless `pi -p` returns it inline. Use it when the method adds value (vote, arbiter's pick, critique loop, per-item sweep, rival builds), not for plain independent parallel work, which is `delegate`. The strategy list in the tool description is generated from the registry (`when`, positional `roster`, `*` for a required param); a call missing a required param (`judge` and `compete` need `params.judge`) fails at once with `council failed: "<strategy>" requires params.<key> — …` and launches nothing, while an unknown strategy or roster is reported by the run itself, in the completion follow-up. A persona's declared `council:` still resolves when strategy, roster and params are omitted. Per-strategy behaviour (section labels, MAGI reflection fallbacks) is in [`STRATEGIES.md`](./STRATEGIES.md) |
39
39
  | `intercom` tool | observe and control internal runs with `peek`, `result`, `steer`, and `stop`; `list`, `inbox`, `message`, `reply`, and `send` drive the coaching bus. There is no `wait`: a finished child wakes the supervisor through the completion follow-up |
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; an armed alarm shows as a row in the agent widget and F9 until it fires or is cancelled |
@@ -77,14 +77,15 @@ its lighter variants when present. The current provider is a default, not a user
77
77
  explicit worker-model request may select another configured provider. The LLM retains the choice.
78
78
 
79
79
  The delegate tool's optional `name` is carried into `AgentRunSpec.name` and reaches both engine
80
- backends, keeping a caller-provided run label available in the live tree and runtime result. Every
81
- settled async run also emits immediate UI feedback with that name and its terminal outcome. TUI
82
- failures persist an expandable card with the agent, model, elapsed time and a concise cause; expansion
83
- retains the full technical diagnostic. Other modes use a concise notification. These operator-only
84
- cards do not inject context or start a second turn. The model-facing report remains independently
85
- idle-gated; a UI notification failure cannot discard it.
80
+ backends, keeping a caller-provided run label available in the live tree and runtime result. A
81
+ successful or stopped run announces ONCE: the idle-gated completion follow-up is the single
82
+ settlement surface (the agent-tree row already settles immediately). A failure keeps an immediate
83
+ operator surface — in the TUI a durable expandable card with the agent, model, elapsed time and a
84
+ concise cause (expansion retains the full technical diagnostic); other modes use a concise
85
+ sanitized notification. These operator-only cards do not inject context or start a second turn.
86
+ The model-facing report remains independently idle-gated; a UI notification failure cannot discard it.
86
87
 
87
- ### Pi 1.0 codemode
88
+ ### Native codemode
88
89
 
89
90
  Pi's native `codemode` can call callable pi-persona tools through the normal nested-tool pipeline.
90
91
  Argument validation and persona permission gates still apply to every inner call; scripts do not
@@ -124,13 +125,18 @@ a live pending ask on the exact child handle, never inferred from its last activ
124
125
  | Surface | Needs | What you get |
125
126
  |---|---|---|
126
127
  | **Observe and control** | any persona | `peek` inspects async sub-agents; `steer` sends a soft correction; `stop` requests cancellation, and `stop { to: "all" }` cancels every running run, `council` and `flow` included (runs already stopping are skipped, never force-cleared in bulk; a slash or mandated run is stopped at shutdown, not by this). The `f9` overlay provides the same controls (`s`, `x`, `X`). Cancelling a worker does not undo existing changes; an unknown id on `result`, `steer` or `stop` returns a miss text listing the known runs |
127
- | **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 |
128
- | **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` |
128
+ | **Retrieve** | any persona | `result { to }` returns one retained run payload (`offset`/`limit` page a bounded view without collecting it; `collect: true` acknowledges whole-body collection of a live run); `result { ref, offset, limit }` reads a page of a retained terminal result after reload; `results` lists retained terminal results with immutable refs; `message { messageId }` returns one retained bus message. History is bounded to 256 messages and 256,000 body characters, terminal results to 128 entries / 8 MiB per session, and retrieval never starts a worker |
129
+ | **Message bus** | bus allowed; in-process children also require `coaching: true` | broker-connected children and coached in-process children get `contact_supervisor`, subject to explicit empty tool grants and exclusions; progress appears in results and `intercom inbox`, while a blocking `decision` wakes the supervisor for `intercom reply` |
129
130
  | **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 }` |
130
131
 
131
- With coaching enabled, an async child's `decision` or `interview` waits for a supervisor reply.
132
- A headless (inline) child posts the question one-way and continues instead: the supervisor is
133
- parked in that call and cannot answer a blocking ask. Progress messages are always one-way.
132
+ When supervisor contact is available, an async child's `decision` or `interview` waits for a supervisor reply.
133
+ A headless (inline) child posts the question one-way without waiting: the supervisor is parked
134
+ in that call and cannot answer. The decision/interview remains unresolved; the child may continue
135
+ only independent work already authorized, otherwise it returns a blocked/partial result. A timeout,
136
+ disconnection or failure is never permission to invent an answer. Results expose `outcome` as
137
+ `reported`, `answered`, `unavailable`, `timed-out`, `cancelled` or `failed`; `delivered` means only
138
+ confirmed delivery, not a reply or approval (false can mean unconfirmed). An already-cancelled call
139
+ sends nothing. Progress messages remain one-way.
134
140
 
135
141
  Delegated output is untrusted and is fenced before it reaches the supervisor. Interactive
136
142
  delegation always runs in the background: continue independent work, and when only children remain, end the turn for
@@ -200,6 +206,43 @@ and your own open asks. A claim's `write_set` is advisory, not enforced: a super
200
206
  coordinate (`exocom_ask`) or claim the path itself; calling the same tool again on the same path is
201
207
  then allowed through (one warning per claim + normalized path, not a standing lock).
202
208
 
209
+ ### Exocom collaboration tools
210
+
211
+ The external plane exposes fifteen call-time-gated tools: `exocom_list`, `exocom_send`,
212
+ `exocom_name`, `exocom_claim`, `exocom_ask`, `exocom_answer`, `exocom_decline`, `exocom_wait`,
213
+ `exocom_release`, `exocom_progress`, `exocom_status`, `exocom_task`, `exocom_offer`,
214
+ `exocom_publish`, and `exocom_profile`. Persona denials still apply during an active session; a
215
+ foreign-workspace member does not receive usable `exocom_claim` access.
216
+
217
+ | Tool | Contract |
218
+ |---|---|
219
+ | `exocom_list`, `exocom_send`, `exocom_name` | Discover session-qualified targets, send a one-way postcard, or set this session's display handle. A rename does not change the routing identity. |
220
+ | `exocom_claim`, `exocom_release`, `exocom_progress`, `exocom_status` | Declare advisory repository-relative ownership, release it, add progress, or inspect current ledger state. Claims do not enforce OS-level write access. |
221
+ | `exocom_ask`, `exocom_answer`, `exocom_decline`, `exocom_wait` | Send a targeted question that creates an obligation, settle an incoming ask, or arm a non-blocking wake for an answer. |
222
+ | `exocom_task` | Create a voluntary task (creation does not launch a worker), take it, report progress, finish it as `done` or `abandoned`, or inspect retained tasks. Use `list` then `get` for full details. |
223
+ | `exocom_offer` | Send an optional invitation for an open task to a peer or the pool. It expires after one hour by default and at most 24 hours; it never creates an ask or an obligation to answer. |
224
+ | `exocom_publish` | Share an explicit bounded evidence excerpt for a task, discover titles/message ids by `work_key`, and retrieve one excerpt by `msg_id`. This does not expose an `intercom` terminal-result archive. |
225
+ | `exocom_profile` | Read, set or clear this session's self-reported capability labels and availability. Up to eight labels of 48 characters each; availability is `available`, `busy`, `away` or unknown. It never grants permission or verifies skill. |
226
+
227
+ `exocom_task list` is discovery: omit `work_key` and `task_id`, then use the returned identifiers for
228
+ `get` or a task action. Summary pages default to 10 rows; `detail: true` defaults to 5, and an
229
+ explicit detailed limit above 5 is rejected. `exocom_task create` returns `task_id`, which equals
230
+ the creation message id. Carry it with `work_key` for take/progress/finish, offers and evidence
231
+ publication; the key alone is only a lookup name and can be reused after retention. Write-set pickup
232
+ and claim are committed atomically under the ledger lock. Read-only tasks can be taken from another
233
+ workspace scope. A task whose owner leaves becomes stale and cannot be taken over automatically.
234
+ Detailed paths are available through `exocom_task({ action: "get", work_key, task_id })`;
235
+ paginated list summaries may clip the write set.
236
+
237
+ While an incoming ask is pending, allowed actions are answer/decline, `exocom_name`, status, explicit
238
+ collaboration reads (`exocom_task` list/get, `exocom_publish` read, `exocom_profile` get), and
239
+ read-only filesystem tools. Profile edits and other mutations wait until the ask is settled. Identity
240
+ metadata does not settle it. Task, offer and evidence records are each capped at 64 and share a
241
+ 128 KiB state budget; terminal or stale tasks and expired offers are retained for up to an hour,
242
+ while evidence is retained for up to an hour after publication. Active tasks remain while their owner
243
+ lease is live. See the [cross-session collaboration guide](./COLLABORATION.md) for workflows and
244
+ bounded examples.
245
+
203
246
  ## Configuration and data
204
247
 
205
248
  Personas and agents are Markdown files with YAML frontmatter. Teams, flows, contracts, and presets
@@ -592,6 +635,14 @@ queued or later stages may still start.
592
635
  `/persona-settings [show]` · `/models [query]` · `/orchestrate <task>` · `/flow <name> <task>` ·
593
636
  `/peek [id]` · `/exocom` · `/doctor`.
594
637
 
638
+ `/persona off` is the deactivation command. `/persona restore` force-restores every bundled resource
639
+ into the user persona directory: personas and agents, flows, contracts, presets, `teams.yaml`, and
640
+ the shared prompt pair. Existing bundled destinations are overwritten; additional user files are
641
+ not pruned. For example, custom `agents/pippo.md` (a persona) and `agents/pluto.md` (an agent) survive
642
+ unchanged. Reusing a bundled destination filename means opting into its replacement. This is not a
643
+ reset of `settings.json`, credentials, sessions or project-local overrides. Definitions are reloaded
644
+ afterward, including any preserved custom persona that was active.
645
+
595
646
  `/doctor` reports the cross-process broker's lifecycle on one line: `broker: on — endpoint …` once
596
647
  the host is listening, `broker: on — (starting…)` mid-attempt, or
597
648
  `broker: on — (not started — no child-engine build yet)` before any child-engine build has run. A
@@ -602,7 +653,7 @@ build retries. A connected broker child's status shows `⇄ <handle>`; if that c
602
653
  status flips to `⇄ offline` and a fresh ask on it fails fast instead of hanging.
603
654
 
604
655
  `/doctor` also reports the installed host's version against this extension's floor: `pi: <version>
605
- (requires ≥ 1.0.0)`, with a trailing `— BELOW FLOOR` marker once an installed version fails the
656
+ (requires ≥ 1.4.0)`, with a trailing `— BELOW FLOOR` marker once an installed version fails the
606
657
  check (`src/core/pi-compat.ts`). `pi: unknown` means the host's own `package.json` could not be
607
658
  resolved (a non-standard install layout) — not a failed check.
608
659
 
@@ -80,6 +80,12 @@ Same-agent members are disambiguated in the live `f9` tree by a role hint (`revi
80
80
  `rosterNodeKeys`/`roleHint` (`roster.ts`) + the SDK's per-run key (`sdk.ts`). If you touch either, keep
81
81
  the seeding loop and the SDK key derivation in lockstep, and inject any per-run protocol via the TASK
82
82
  text, never `role` (or the derived tree key drifts from the seeded one).
83
+ The short hint is never used to recover a participant's model or effort. SDK lifecycle callbacks
84
+ carry a frozen `AgentAttemptIdentity` beside that key, containing only the actual agent, full role,
85
+ explicit model and requested thinking. This preserves distinct inline selections for repeated roles,
86
+ auxiliary actors and custom strategies that invoke members in a different order. Recovery updates the
87
+ same logical key with the new attempt's model; observed terminal metadata remains separate from the
88
+ request. Tasks and result grants are excluded from this display identity.
83
89
 
84
90
  ## The built-in strategies
85
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aeondave/pi-persona",
3
- "version": "1.17.3",
3
+ "version": "1.17.5",
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.",
@@ -42,6 +42,7 @@
42
42
  "docs/README.md",
43
43
  "docs/ARCHITECTURE.md",
44
44
  "docs/EXPERIENCE.md",
45
+ "docs/COLLABORATION.md",
45
46
  "docs/MONITORS.md",
46
47
  "docs/REFERENCE.md",
47
48
  "docs/SPINE.md",
@@ -94,18 +95,23 @@
94
95
  "node": ">=22.19.0"
95
96
  },
96
97
  "devDependencies": {
97
- "@earendil-works/pi-agent-core": "1.3.0",
98
- "@earendil-works/pi-ai": "1.3.0",
99
- "@earendil-works/pi-coding-agent": "1.3.0",
100
- "@earendil-works/pi-tui": "1.3.0",
98
+ "@earendil-works/pi-agent-core": "1.4.0",
99
+ "@earendil-works/pi-ai": "1.4.0",
100
+ "@earendil-works/pi-coding-agent": "1.4.0",
101
+ "@earendil-works/pi-tui": "1.4.0",
101
102
  "@types/node": "^22.20.4",
102
103
  "fast-check": "^4.10.2",
103
104
  "tsx": "^4.23.13",
104
105
  "typebox": "1.4.2",
105
106
  "typescript": "^7.0.2"
106
107
  },
108
+ "overrides": {
109
+ "@earendil-works/pi-coding-agent@1.4.0": {
110
+ "undici": "8.12.3"
111
+ }
112
+ },
107
113
  "pi": {
108
- "image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.17.3/assets/banner.png",
114
+ "image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.17.5/assets/banner.png",
109
115
  "video": "https://cdn.jsdelivr.net/gh/AeonDave/pi-persona@6655d281ce7965a75303dd704bd29db73db04070/assets/demo1.mp4",
110
116
  "extensions": [
111
117
  "./src/extension.ts"
@@ -10,6 +10,8 @@
10
10
 
11
11
  import { sanitizeStatusLabel } from "../core/display-label.ts";
12
12
  import { asBoolean, asPermission, parseYamlSubset, splitFrontmatter } from "../core/frontmatter.ts";
13
+ import { parseLegThinking } from "../core/leg-thinking.ts";
14
+ import type { ThinkingLevel } from "../core/types.ts";
13
15
  import { writeSetPathError } from "../core/ownership.ts";
14
16
 
15
17
  export interface AgentConfig {
@@ -21,6 +23,8 @@ export interface AgentConfig {
21
23
  * prompt text: the body already carries the behaviour, this only labels it. */
22
24
  purpose?: string;
23
25
  model?: string;
26
+ /** Default requested effort for this agent; an explicit leg override wins. Pi clamps it. */
27
+ thinking?: ThinkingLevel;
24
28
  /** Tool allowlist passed to Pi. Absent means the session default tool set. */
25
29
  tools?: string[];
26
30
  /** Tool denylist applied after the allowlist/session default. */
@@ -73,6 +77,8 @@ export function parseAgent(content: string, source: string): AgentConfig | null
73
77
  if (purpose && purpose !== "agent") agent.purpose = purpose;
74
78
  }
75
79
  if (typeof fm.model === "string" && fm.model.trim()) agent.model = fm.model.trim();
80
+ const thinking = parseLegThinking(fm.thinking);
81
+ if (thinking !== undefined) agent.thinking = thinking;
76
82
  const tools = asPermission(fm.tools);
77
83
  if (tools?.allow) agent.tools = tools.allow;
78
84
  if (tools?.deny) agent.excludeTools = tools.deny;
package/src/bridge.ts CHANGED
@@ -20,9 +20,13 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
20
20
 
21
21
  import { type BrokerClient, type DeliverEvent, makeBrokerClient, type MakeBrokerClientDeps } from "./bus/broker/client.ts";
22
22
  import { makeContactSupervisorTool } from "./bus/contact.ts";
23
- import { InProcessBus, type MsgKind } from "./bus/inproc.ts";
23
+ import type { MsgKind } from "./bus/inproc.ts";
24
+ import type { ContactTransport } from "./bus/contact-transport.ts";
24
25
  import { makeContactPeerTool, type PeerInfo } from "./bus/peers.ts";
25
26
  import { attributeInbound } from "./core/fence.ts";
27
+ import { isReaderToken } from "./bus/broker/messages.ts";
28
+ import { readFailure } from "./bus/result-reader.ts";
29
+ import { makeReadResultTool } from "./tools/read-result.ts";
26
30
 
27
31
  type Env = Record<string, string | undefined>;
28
32
 
@@ -46,30 +50,22 @@ export function isBridgeMode(env: Env = process.env): boolean {
46
50
  return !!env.PI_PERSONA_BUS;
47
51
  }
48
52
 
49
- /** Routes `InProcessBus.send`/`ask` over a `BrokerClient` instead of a local mailbox — the
50
- * thin adapter the bus-bound `contact_supervisor`/`contact_peer` factories need to run
51
- * unmodified against a remote target. A real `InProcessBus` subclass (not a duck-typed
52
- * object) so it satisfies the factories' concrete parameter type without touching them.
53
- * `connected` gates both overrides so the tools' existing "no supervisor reachable" /
54
- * "dropped" wording stays honest before the handshake completes — the client's own
55
- * `send`/`ask` can't report that (a write to a not-yet-open socket silently no-ops, per
56
- * spec's "sends report undelivered"). */
57
- class BrokerBus extends InProcessBus {
53
+ /** Contact transport over the broker. A connected socket is not delivery evidence:
54
+ * send resolves only after the host acknowledges admission to its local bus. */
55
+ class BrokerBus implements ContactTransport {
58
56
  private readonly client: BrokerClient;
59
57
  connected = false;
60
58
 
61
59
  constructor(client: BrokerClient) {
62
- super();
63
60
  this.client = client;
64
61
  }
65
62
 
66
- override send(_from: string, to: string, text: string, kind: MsgKind = "progress"): boolean {
63
+ send(_from: string, to: string, text: string, kind: MsgKind = "progress", signal?: AbortSignal): boolean | Promise<boolean> {
67
64
  if (!this.connected) return false;
68
- this.client.send(to, kind, text);
69
- return true;
65
+ return this.client.send(to, kind, text, signal);
70
66
  }
71
67
 
72
- override ask(
68
+ ask(
73
69
  _from: string,
74
70
  to: string,
75
71
  text: string,
@@ -90,15 +86,22 @@ function sendFollowUp(pi: ExtensionAPI, content: string): void {
90
86
  * `isBridgeMode()` is true. Connects the broker client (capped retries; a failed connect
91
87
  * degrades to a mute executor, never a crash — spec §4), registers the two wire-bound
92
88
  * comm-plane tools, surfaces inbound `deliver`/`steer` frames as follow-ups, and closes the
93
- * client on `session_shutdown`. */
89
+ * client on `session_shutdown`. An explicit private reader token also installs read_result;
90
+ * its handshake/read failures are runtime errors, never ordinary mute degradation. */
94
91
  export function installBridge(pi: ExtensionAPI, ctx: ExtensionContext, deps: InstallBridgeDeps = {}): void {
95
92
  const env = deps.env ?? process.env;
96
93
  const endpoint = env.PI_PERSONA_BUS;
97
94
  const handle = env.PI_PERSONA_HANDLE;
95
+ const readerToken = env.PI_PERSONA_RESULT_READER_TOKEN;
96
+ if (readerToken !== undefined && (!endpoint || !handle || !isReaderToken(readerToken))) {
97
+ throw new Error("Invalid result reader environment.");
98
+ }
98
99
  if (!endpoint || !handle) return; // isBridgeMode() gates the real caller; malformed env degrades to a no-op
99
100
 
100
- const wantsPeers = env.PI_PERSONA_PEERS === "1";
101
- const clientDeps: MakeBrokerClientDeps = { endpoint, handle, ...(wantsPeers ? { peers: true } : {}) };
101
+ const canUseBus = env.PI_PERSONA_BUS_TOOLS !== "off";
102
+ const wantsPeers = canUseBus && env.PI_PERSONA_PEERS === "1";
103
+ const clientDeps: MakeBrokerClientDeps = { endpoint, handle, ...(wantsPeers ? { peers: true } : {}),
104
+ ...(readerToken !== undefined ? { readerToken } : {}) };
102
105
  const client = (deps.makeClient ?? makeBrokerClient)(clientDeps);
103
106
 
104
107
  // Blocking asks are honoured only when the spawning run is async (`PI_PERSONA_ALLOW_BLOCKING`,
@@ -108,7 +111,11 @@ export function installBridge(pi: ExtensionAPI, ctx: ExtensionContext, deps: Ins
108
111
  // (the same safety default the in-process engine applies; see contact.ts / spec §4.9).
109
112
  const allowBlocking = env.PI_PERSONA_ALLOW_BLOCKING === "1";
110
113
  const bus = new BrokerBus(client);
111
- pi.registerTool(makeContactSupervisorTool(bus, handle, SUPERVISOR_HANDLE, { allowBlocking }));
114
+ if (canUseBus) pi.registerTool(makeContactSupervisorTool(bus, handle, SUPERVISOR_HANDLE, { allowBlocking }));
115
+ if (readerToken !== undefined) {
116
+ pi.registerTool(makeReadResultTool({ read: (request, signal) => bus.connected
117
+ ? client.read(request, signal) : Promise.resolve(readFailure("unavailable")) }));
118
+ }
112
119
 
113
120
  // The engine-scoped peer roster (B7): fetched from the host's `list` frame and cached —
114
121
  // `contact_peer` accepts an awaitable provider, so the first call can wait for the initial
@@ -147,7 +154,7 @@ export function installBridge(pi: ExtensionAPI, ctx: ExtensionContext, deps: Ins
147
154
  peerRefresh = shared;
148
155
  return shared;
149
156
  };
150
- pi.registerTool(
157
+ if (wantsPeers) pi.registerTool(
151
158
  makeContactPeerTool(bus, handle, {
152
159
  listPeers: () => refreshPeers(),
153
160
  }),
@@ -193,9 +200,11 @@ export function installBridge(pi: ExtensionAPI, ctx: ExtensionContext, deps: Ins
193
200
  }
194
201
  },
195
202
  () => {
196
- // Capped retries exhausted (host gone, or a race on a just-spawned child) — degrade to
197
- // a mute executor and keep running; never crash, never hang (spec §4).
198
- if (env.PI_PERSONA_DEBUG) process.stderr.write(`[pi-persona] bridge: could not connect to ${endpoint}\n`);
203
+ // Ordinary comms degrade to a mute executor. An installed read_result remains
204
+ // fail-closed: a failed handshake cannot expose a fallback body.
205
+ if (env.PI_PERSONA_DEBUG) process.stderr.write(readerToken !== undefined
206
+ ? "[pi-persona] bridge: result reader unavailable\n"
207
+ : `[pi-persona] bridge: could not connect to ${endpoint}\n`);
199
208
  try {
200
209
  ctx.ui.setStatus("persona-bridge", "⇄ offline");
201
210
  } catch {
@@ -205,6 +214,7 @@ export function installBridge(pi: ExtensionAPI, ctx: ExtensionContext, deps: Ins
205
214
  );
206
215
 
207
216
  pi.on("session_shutdown", () => {
217
+ bus.connected = false;
208
218
  client.close();
209
219
  });
210
220
  }