@aeondave/pi-persona 1.17.4 → 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.
package/AGENTS.md CHANGED
@@ -268,6 +268,19 @@ the shared behavioral prompt layer: [`docs/SPINE.md`](docs/SPINE.md).
268
268
  option (a terminal event is observed before the permission gate). A countdown replaces the stall clock; `x`
269
269
  cancels one via the stop registry; `X`, the `N agents` count and telemetry exclude them.
270
270
 
271
+ - Loaded companions: `core/companion.ts` defines the public local presence, live memory-owner and
272
+ activity contracts; `extension/{companion,memory-owner}.ts` bind them to the current native owner.
273
+ Mind requires a compatible loaded partner, never a settings-file inference. Actual selection is
274
+ authoritative with persistence off; both engines freeze it for delegated legs (private per-child
275
+ factory in-process, spawn-local environment for child processes). `ui/activity-rows.ts` projects
276
+ bounded local Mind work without agent counts, controls, wakes, model usage or agent telemetry.
277
+ Automatic semantic review instead uses `pi-persona:mind-worker:v1`: one real tracked Mind worker,
278
+ with the current session model/effort, no tools/fallback/bus, normal shared concurrency and F9
279
+ controls, a 180s deadline, and a terminal archive. Memory/delegate/target permissions still apply.
280
+ Its completion belongs to Mind; it never wakes the supervisor. See the companion architecture contract.
281
+ These cooperative contracts are not authentication; see the loaded-companion section
282
+ in `docs/ARCHITECTURE.md`.
283
+
271
284
  - `src/core/` — pure kernel: frontmatter, permissions, contract (+`parseContract`), config, discovery, fence (`fenceUntrusted`), brief (`buildDelegationBrief` — the per-turn roster + standing hand-off default; `buildExocomBrief` — the per-turn exocom peer roster, the peer-vs-sub-agent split, and the relevance bound on a peer exchange), timer (`TimerScheduler` — the alarm engine behind the `timer` tool), session-runs (pure restore-notice helper: what a reload/resume lost + the run-id counter), types.
272
285
  - `src/engine/` — `child.ts`, `inproc.ts` (default), `adapter.ts`, `async.ts` (async tracker/peek), `worktree.ts` (git-worktree isolation), `stream.ts` (event→state).
273
286
  - `src/orchestration/` — `sdk.ts` (`agent`/`parallel`/`reduce`), `strategy.ts` (registry), `strategies/*.ts`, `voting.ts`, `flow*.ts` (DAG + JSONL journal + checkpoint gates), `roster.ts` (teams + `rosterSpec`: a roster member is a bare name OR an inline `{ agent, role, model, skills }` that specialises one agent — every strategy runs members through `rosterSpec`).
package/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows the version scheme
5
5
  used in its [GitHub releases](https://github.com/AeonDave/pi-persona/releases).
6
6
 
7
+ ## [1.17.5](https://github.com/AeonDave/pi-persona/releases/tag/v1.17.5) - 2026-10-25
8
+
9
+ ### Added
10
+
11
+ - A visible asynchronous Mind maintenance worker, pinned to the current session model and effort.
12
+ It shares normal concurrency, F9 controls and result archival, has no tools or model fallback,
13
+ and returns its completion to Mind without starting another supervisor turn.
14
+ - Loaded-companion presence, live memory ownership and bounded `Mind` activity rows. Mind can
15
+ require the actual loaded Persona partner and follow persona changes even with persistence off;
16
+ both engines freeze that owner for delegated legs. Activity stays separate from agents, controls,
17
+ wakes and model usage. These paired contracts require the updated Mind and Persona together.
18
+
19
+ ### Fixed
20
+
21
+ - Council, MAGI, auxiliary actors and flow legs keep their own model and effort in the live
22
+ tree and F9 when abbreviated roles collide or identical roles use different inline pins.
23
+ Runtime callbacks carry exact attempt identity; retries clear stale observations and terminal
24
+ rows use the engine's observed model. Saved model assignments remain keyed by the full role.
25
+ - Terminal-result capture survives reactivation of the same extension and tracker. Shutdown
26
+ revokes the old archive owner without removing the capture observers or changing their order
27
+ before completion notifications; late results cannot enter another session's archive.
28
+ - Child broker contacts confirm one-way delivery only after admission to the host bus.
29
+ Full inboxes, disconnects, cancellation and missing receipts no longer report `delivered: true`.
30
+ Receipts are bounded and never count as a supervisor answer or authorization.
31
+ Unconfirmed sends and errors after admission no longer claim that the message was dropped;
32
+ the supervisor may already have received it.
33
+ - Reference-flow diagnostics fence worker excerpts even on the defensive missing-reference path.
34
+ - A worker-provided patch remains usable after worktree capture fails, with an explicit warning
35
+ that the patch was not verified against the final worktree state.
36
+
7
37
  ## [1.17.4](https://github.com/AeonDave/pi-persona/releases/tag/v1.17.4) - 2026-10-25
8
38
 
9
39
  ### Added
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # pi-persona
2
2
 
3
3
  <p align="center">
4
- <img src="assets/banner.png" alt="pi-persona — supervised multi-agent orchestration for Pi">
4
+ <img src="https://raw.githubusercontent.com/AeonDave/pi-persona/v1.17.5/assets/banner.png" alt="pi-persona — supervised multi-agent orchestration for Pi">
5
5
  </p>
6
6
 
7
7
  [![CI](https://github.com/AeonDave/pi-persona/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/AeonDave/pi-persona/actions/workflows/test.yml) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/AeonDave/pi-persona/badge)](https://scorecard.dev/viewer/?uri=github.com/AeonDave/pi-persona) [![npm version](https://img.shields.io/npm/v/@aeondave/pi-persona?logo=npm)](https://www.npmjs.com/package/@aeondave/pi-persona) [![License](https://img.shields.io/github/license/AeonDave/pi-persona)](LICENSE)
@@ -40,9 +40,9 @@ your customizations.
40
40
  | Install option | Command |
41
41
  |---|---|
42
42
  | This project only | `pi install -l npm:@aeondave/pi-persona` |
43
- | Pin this release | `pi install npm:@aeondave/pi-persona@1.17.4` |
43
+ | Pin this release | `pi install npm:@aeondave/pi-persona@1.17.5` |
44
44
  | Update an unpinned install | `pi update npm:@aeondave/pi-persona` |
45
- | Git tag 1.17.4 | `pi install git:github.com/AeonDave/pi-persona@v1.17.4` |
45
+ | Git tag 1.17.5 | `pi install git:github.com/AeonDave/pi-persona@v1.17.5` |
46
46
 
47
47
  Restart Pi after updating. For a pinned install, install the new version or tag explicitly.
48
48
  If switching from a Git install, remove that source first with
@@ -180,14 +180,20 @@ to those files. It does not delete additional custom files: a persona `pippo.md`
180
180
  `pluto.md` remains untouched. A custom file using the same destination as a bundled default is
181
181
  replaced. Settings, credentials, sessions and project-local overrides are not reset.
182
182
 
183
- For optional long-term memory, add [pi-persona-mind](https://github.com/AeonDave/pi-persona-mind).
184
-
185
183
  `/persona-settings` is separate from Pi's `/settings`: it edits pi-persona preferences and saved
186
184
  persona model assignments, not native Pi or compaction settings. In the TUI, `/` filters settings,
187
185
  `r` resets the selected saved override, and `m` opens model assignment editing. Scalar preference
188
186
  changes apply after restarting Pi or Pi's native `/reload`, not `/new`; `/persona reload` only
189
187
  refreshes persona definitions. Model changes apply to future runs. See [configuration and settings](docs/REFERENCE.md#configuration-and-data).
190
188
 
189
+ ## Optional memory companion
190
+
191
+ [pi-persona-mind 0.8.4](https://github.com/AeonDave/pi-persona-mind/releases/tag/v0.8.4) adds durable
192
+ project/persona memory, session notes and a visible automatic consolidation worker. It requires
193
+ pi-persona 1.17.5 or later. Install it with `pi install npm:@aeondave/pi-persona-mind`, then restart
194
+ Pi or run `/reload`. See its [README](https://github.com/AeonDave/pi-persona-mind#readme) for scope,
195
+ automatic-review limits and undo.
196
+
191
197
  ## Documentation
192
198
 
193
199
  | Guide | What's inside |
@@ -288,13 +288,18 @@ up to 16 names. Same idea, two layers, two caps — see docs/REFERENCE.md's exam
288
288
 
289
289
  `isolation: worktree` is fail-closed. It requires a clean Git checkout so the detached `HEAD` view
290
290
  cannot silently omit staged/unstaged/untracked supervisor work; a non-repository, dirty checkout,
291
- worktree creation failure, missing diff artifact, or an artifact over the return limit fails the leg —
292
- the base engine is never invoked against the real tree as a fallback. The created worktree's initial
293
- commit is pinned before the worker starts. Export compares the final file state against that immutable
294
- commit, so worker commits, later unstaged changes and new files are included together. The diff is
295
- captured before the temporary tree is removed and then returned to the supervisor; commit history is
296
- not applied to the main checkout. Every git call is asynchronous so the extension host keeps rendering,
297
- ticking timers, and serving broker sockets while a large checkout runs.
291
+ worktree creation failure, or a successful leg with neither a captured diff nor a returned unified
292
+ patch fails the leg — the base engine is never invoked against the real tree as a fallback. The
293
+ created worktree starts from the committed `HEAD` only: local ignored files are not copied into it,
294
+ and untracked ignored outputs are not exported. The original checkout's ignored files remain
295
+ untouched; tasks that need local-only inputs must recreate or provide them explicitly. The
296
+ worktree's initial commit is pinned before the worker starts. Export compares the final file state
297
+ against that immutable commit, so worker commits, later unstaged changes and new files are included
298
+ together. The diff is captured before the temporary tree is removed, then returned to the supervisor;
299
+ commit history is not applied to the main checkout. If Git cannot capture the diff but the worker
300
+ returned a unified patch, that patch is retained with a warning that it was worker-provided and not
301
+ verified against the worktree state. Every git call is asynchronous so the extension host keeps
302
+ rendering, ticking timers, and serving broker sockets while a large checkout runs.
298
303
 
299
304
  Transient retries inside one agent session belong to the host **Pi runtime**'s `retry.*` settings, not
300
305
  a persona contract — pi-persona neither parses retry notation nor schedules its own backoff. Both
@@ -317,10 +322,19 @@ models, repeated same-model attempts, stop/timeout/contract/agent/infrastructure
317
322
  child/token budgets do not recover. Both attempts are accounted, and a warning plus result metadata
318
323
  identify the switch. A per-role model assignment can supplement the legacy per-agent assignment;
319
324
  preflight model selection includes external arbiters and other declared agent-valued parameters.
320
- Live tree and telemetry labels use those participant identities for auxiliary actors too, with
321
- inline model pins taking precedence. If distinct full roles share a short display hint, the display
322
- keeps the hint (with `#N` suffixes for repeated node keys) but omits the uncertain model label rather
323
- than borrowing another role's choice. Routing still resolves the full role key, not the display hint.
325
+ Live tree and telemetry labels resolve auxiliary actors and every repeated roster occurrence from
326
+ the SDK's exact, immutable attempt identity (`agent`, full `role`, explicit `model` and `thinking`),
327
+ alongside its unique node key. Short role hints and `#N` suffixes are presentation only: colliding
328
+ hints never discard a known model or borrow another occurrence's inline pins. Model assignments
329
+ and agent defaults are copied after setup when the engine is constructed, so a queued leg's display
330
+ cannot change with later persona/settings edits. Seeds use each actual roster member; lifecycle
331
+ updates use the actual attempt, including custom strategy calls and auxiliary actors in any order.
332
+ A recovery keeps the node key, changes its requested model, and clears the preceding attempt's Pi
333
+ effort observation. Terminal `modelUsed` and validated effort observations override intent only when
334
+ reported; an absent observation retains the current attempt's intent. Initial effort remains the
335
+ explicit spec request, never inferred provider cognition. Clearing an unknown model emits an empty
336
+ `model` string in a telemetry update, preserving the existing string patch contract without leaving
337
+ a stale previous model in consumers.
324
338
 
325
339
  **Model assignment repair is configuration, not execution recovery.** `/persona models [profile]`
326
340
  is an explicit user edit in the Pi adapter: it reuses `resolveCouncilInvocation`,
@@ -497,8 +511,9 @@ never simultaneously a telemetry event, a routed message, and a UI source of tru
497
511
  usage`). Feeds derived UI; never routed as an agent message.
498
512
  - **Bus Msg** — semantic agent↔agent communication (`progress · decision · interview`, and sibling
499
513
  peer sends). `src/bus`. `progress_update` is a Bus message only, not an EngineEvent.
500
- - **ProgressView** — a derived, read-only UI view computed *from* EngineEvents. Never authored, never
501
- a source of truth. The `f9` agent tree and `peek` digest are ProgressViews.
514
+ - **ProgressView** — a derived, read-only UI view computed from observed runtime lifecycles:
515
+ EngineEvents, wake-source lifecycles, or the bounded local companion lifecycle below. Never a
516
+ source of truth. The `f9` agent tree and `peek` digest are ProgressViews.
502
517
 
503
518
  Steering is a supervisor-to-child control action, distinct from semantic Bus messages; requesting
504
519
  steering does not establish that a blocking headless delegate call has been interrupted. The peek digest is
@@ -508,6 +523,69 @@ External telemetry is not a fourth communication plane: it is a generic observer
508
523
  future plugins may consume or produce, not an exclusive pi-persona protocol. It observes projected
509
524
  events and cannot route, reply, steer, or otherwise control agents.
510
525
 
526
+ ### Loaded companions and local activity
527
+
528
+ The local EventBus request `pi-persona:companion:v1` accepts `{ version: 1, respond }` and calls
529
+ `respond` synchronously with `{ version: 1, provider: "pi-persona", role, capabilities }`.
530
+ `role` is `supervisor`, `leg`, or `disabled`; `capabilities.memory` is `1` for a loaded supervisor or
531
+ delegated leg and `false` for a disabled supervisor. `capabilities.activity` is `1` only for an enabled
532
+ supervisor. Persona registers this responder before its bridge and disable guards. Pi owns the
533
+ subscription lifetime and revokes it on disposal/reload. Companions query the loaded extension;
534
+ settings files, package installation, and environment flags cannot establish its presence. This is
535
+ a cooperative local extension contract, not authentication or an OS permission boundary. The
536
+ contract is available from Persona 1.17.5, paired with Mind 0.8.4 or later; qualify both together.
537
+
538
+ Memory ownership is queried through `pi-persona:memory-owner:v1` with
539
+ `{ version: 1, sessionId, respond }`. Its synchronous response is
540
+ `{ version: 1, provider: "pi-persona", sessionId, ready, persona }`, where `persona` is the actual
541
+ live selection or `null` for Persona off. The native manager/id/file must still match, and `ready`
542
+ remains false until Persona's session startup finishes. `pi-persona:memory-owner-changed:v1`
543
+ broadcasts that same descriptor on readiness, selection and shutdown. A companion loaded first
544
+ must not wait inside its startup hook for a later extension's hook; it can activate on the readiness
545
+ broadcast or its next fresh query. Canonical settings retain startup preferences, not live ownership:
546
+ `persist: false` selections still change this descriptor. Unavailable ownership never authorizes a
547
+ fallback to another persona's saved selection.
548
+
549
+ Each engine freezes the parent's memory persona when it is built. In-process children receive a
550
+ private ResourceLoader factory and their own native manager/EventBus responder; child processes
551
+ receive the versioned `PI_PERSONA_MEMORY_PERSONA` record only in that spawn's environment. Inherited
552
+ values are scrubbed before spawning. Neither route mutates a shared process-wide persona value.
553
+ Delegated legs expose this frozen owner while retaining their lean memory-only role, including an
554
+ explicit `null` owner when the parent had Persona off.
555
+
556
+ `pi-persona:activity:v1` contributes operator-only local-work rows through a closed record:
557
+ `{ version: 1, producerId: "pi-persona-mind", activityId, sessionId, phase, state, target }`.
558
+ `phase` is `inspect|stage|apply`, `state` is `running|staged|done|failed`, and `target` is
559
+ `project|persona|shared`; `staged` is valid only for `stage`. No memory text, paths, models, token
560
+ counts, or free-form output are accepted. Ingress matches the active native session and checks its
561
+ manager's current id/file. The producer also captures its native owner and scope across awaits.
562
+ The resulting `Mind` row uses `kind: "activity"`: it has no agent count, steer/stop handle, wake,
563
+ stall badge, or `agent.*` telemetry. The F9 drill-in explains the local phase. Up to eight rows are
564
+ retained, with completed/staged rows evicted before live work; teardown and explicit persona
565
+ selection revoke old rows without cancelling workers. A staged row states that a proposal was
566
+ prepared and requires explicit apply; it does not claim that the plan remains valid indefinitely or
567
+ that model review is running.
568
+ Receipts remain in Mind's session-native cards; local activity events are not replayed on restart.
569
+
570
+ Mind may request one real async maintenance worker through `pi-persona:mind-worker:v1` with
571
+ the closed record `{ version: 1, sessionId, persona, task, respond }` (task at most 48,000 characters).
572
+ Persona checks the current native session and ready memory owner, actual persona, delegation and
573
+ memory permissions, the persona's raw delegate target rules for `mind`, and idle/no-pending-message
574
+ state. A strict target allowlist must include `mind`; explicit memory/delegate/target denies win.
575
+ No installed agent is required. Accepted
576
+ responses are synchronous `{ ok: true, runId, model, thinking, completion, cancel }`; rejection is
577
+ `{ ok: false, reason }`. `completion` resolves with `{ ok, output, error?, resultRef?, model?, thinking? }`.
578
+ Output remains untrusted data for Mind's bounded proposal parser, never executable code.
579
+
580
+ This is an ordinary tracked `Mind` sub-agent in the tree and F9, sharing the launch pool, usage,
581
+ progress, stop/steer controls and terminal-result archive. Its private definition has an explicit empty
582
+ tool allowlist and no worker spine. Provider/model and thinking are pinned to the accepting session;
583
+ provider fallback is disabled. The worker has a 180-second cancellation ceiling; owner readiness or
584
+ selection changes cancel it. Mind can also cancel through its accepted handle. Settlement returns
585
+ to the requesting companion and retains operator failure reporting, but never wakes a supervisor
586
+ model turn. Native session teardown cancels it through the ordinary stop registry. Local inspect,
587
+ stage and apply operations continue to use activity rows; model work uses the real agent view.
588
+
511
589
  ## The comm plane in practice
512
590
 
513
591
  - **In-process bus** (`bus/inproc.ts`) — a handle-based mailbox: `send` (one-way), `ask` (blocks for a
@@ -539,10 +617,14 @@ events and cannot route, reply, steer, or otherwise control agents.
539
617
  notifier, f9, peek) is unchanged by construction. Off ⇒ the host never starts and the child spawns
540
618
  byte-identical to pre-broker pi-persona. Ask failures echo the originating `msgId` so the client
541
619
  rejects the right request immediately, and a client cancellation propagates only to that connection's
542
- own host ask. `extension/broker-host.ts`'s `SupervisorBroker` owns the lifecycle: it starts the host
543
- lazily on the first child-engine build, warns once on a failed bind (children built while it's down
544
- spawn without a bus endpoint, so they never burn connect backoff against a dead socket), and the next
545
- build retries; `/doctor` surfaces its state on one line. A dropped client connection flips the
620
+ own host ask. A one-way send gets a UUID-correlated host receipt; `delivered: true` means admitted
621
+ to the bus, not read or acted on by the peer. Each child is limited to 64 pending receipts with a
622
+ maximum 10-second wait. Abort, disconnect, a full or missing inbox, or a lost receipt reports
623
+ `delivered: false`. It never waits for a recipient answer or approval and does not replay.
624
+ `extension/broker-host.ts`'s `SupervisorBroker` owns the lifecycle: it starts
625
+ the host lazily on the first child-engine build and warns once on a failed bind. Children built while
626
+ it's down spawn without a bus endpoint, so they never burn connect backoff against a dead socket;
627
+ the next build retries; `/doctor` surfaces its state on one line. A dropped client connection flips the
546
628
  child-side bridge to `⇄ offline` and fails a fresh ask fast rather than hanging.
547
629
  A connected child exposes `contact_supervisor` when `canUseBus` is allowed; `contact_peer` also
548
630
  requires the run's peer opt-in. Granted custom tool names join a nonempty plain allowlist so Pi's
@@ -659,7 +741,16 @@ retention is labelled as such. Retrieval is read-only: `intercom results` lists,
659
741
  { ref, offset, limit }` pages (8,000 default / 16,000 max units, surrogate-safe), with no respawn,
660
742
  rebilling, collection or extra wake; reading a page never returns the whole run hidden in metadata.
661
743
  A saved result is not a live worker: the restore notice says runtime handles are gone while retained
662
- reports remain readable.
744
+ reports remain readable. Archive listeners belong to the extension factory/tracker lifetime and are
745
+ installed exactly once. `session_shutdown` revokes that session's archive owner and clears its
746
+ captures. A later activation creates a fresh owner and capture context before the ordinary completion
747
+ listener observes terminal results for that activation; a late callback bound to the old owner cannot
748
+ append into the new session.
749
+
750
+ Flow reference handoff needs durable session-entry storage when a phase's result feeds a dependent
751
+ phase. In a no-session run, a source phase can finish with a memory-only receipt, but the dependent's
752
+ reader preflight rejects it before that dependent is spawned. Use a persisted session for reference
753
+ handoff across phases, or use inline handoff when no durable reader is available.
663
754
 
664
755
  This is a UI invariant only: truncating a collapsed card must never be confused with truncating the
665
756
  underlying result or changing a strategy's contract.
@@ -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.4",
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.",
@@ -111,7 +111,7 @@
111
111
  }
112
112
  },
113
113
  "pi": {
114
- "image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.17.4/assets/banner.png",
114
+ "image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.17.5/assets/banner.png",
115
115
  "video": "https://cdn.jsdelivr.net/gh/AeonDave/pi-persona@6655d281ce7965a75303dd704bd29db73db04070/assets/demo1.mp4",
116
116
  "extensions": [
117
117
  "./src/extension.ts"
package/src/bridge.ts CHANGED
@@ -20,7 +20,8 @@ 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";
26
27
  import { isReaderToken } from "./bus/broker/messages.ts";
@@ -49,30 +50,22 @@ export function isBridgeMode(env: Env = process.env): boolean {
49
50
  return !!env.PI_PERSONA_BUS;
50
51
  }
51
52
 
52
- /** Routes `InProcessBus.send`/`ask` over a `BrokerClient` instead of a local mailbox — the
53
- * thin adapter the bus-bound `contact_supervisor`/`contact_peer` factories need to run
54
- * unmodified against a remote target. A real `InProcessBus` subclass (not a duck-typed
55
- * object) so it satisfies the factories' concrete parameter type without touching them.
56
- * `connected` gates both overrides so the tools' existing "no supervisor reachable" /
57
- * "dropped" wording stays honest before the handshake completes — the client's own
58
- * `send`/`ask` can't report that (a write to a not-yet-open socket silently no-ops, per
59
- * spec's "sends report undelivered"). */
60
- 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 {
61
56
  private readonly client: BrokerClient;
62
57
  connected = false;
63
58
 
64
59
  constructor(client: BrokerClient) {
65
- super();
66
60
  this.client = client;
67
61
  }
68
62
 
69
- 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> {
70
64
  if (!this.connected) return false;
71
- this.client.send(to, kind, text);
72
- return true;
65
+ return this.client.send(to, kind, text, signal);
73
66
  }
74
67
 
75
- override ask(
68
+ ask(
76
69
  _from: string,
77
70
  to: string,
78
71
  text: string,
@@ -2,7 +2,7 @@
2
2
  * Broker client — the child-side half of the relay (spec B3/B6): connects to the
3
3
  * session-scoped endpoint (`paths.ts`), performs the `register`/`registered` handshake,
4
4
  * and exposes the same shape a local `contact_supervisor`/`contact_peer` binding needs —
5
- * `send` (fire-and-forget), `ask` (a blocking `send{expectsReply}` correlated to a
5
+ * `send` (one-way with a bounded host-admission receipt), `ask` (a blocking `send{expectsReply}` correlated to a
6
6
  * `replied{askId}`, 10-minute cap, mirrors `bus.ask`, and sends `cancel{msgId}` when
7
7
  * aborted), `reply` (answers a host-issued
8
8
  * blocking `deliver`), `list` (the engine-scoped peer roster, B7), and `onDeliver`/`onSteer`
@@ -14,7 +14,7 @@
14
14
  * exponential backoff (250ms–5s) on the INITIAL connect only (comtac's presence tracking,
15
15
  * roster cache, and endless reconnect-after-disconnect "storm" are dropped — YAGNI here,
16
16
  * see spec B6/Task 4 brief). An unexpected disconnect (host died
17
- * mid-run) is NOT auto-reconnected: sends silently go nowhere, while in-flight asks/lists
17
+ * mid-run) is NOT auto-reconnected: sends resolve false, while in-flight asks/lists
18
18
  * reject promptly and clean up their timers. Authenticated result readers are stricter:
19
19
  * one connect attempt, bounded handshake/reads, closed failure replies, never mute success.
20
20
  *
@@ -46,8 +46,9 @@ export interface BrokerClient {
46
46
  register(): Promise<void>;
47
47
  /** Authenticated bounded read. Failures are fixed closed replies; never a mute success. */
48
48
  read(request: ResultReadRequest, signal?: AbortSignal): Promise<ReadReply>;
49
- /** Fire-and-forget; a dead connection silently drops it (spec: "sends report undelivered"). */
50
- send(to: string, kind: MsgKind, text: string): void;
49
+ /** One-way message; true only after host bus admission, never a recipient answer.
50
+ * Missing receipt, cancellation, saturation or disconnect resolve false without replay. */
51
+ send(to: string, kind: MsgKind, text: string, signal?: AbortSignal): Promise<boolean>;
51
52
  /** Blocking request; resolves on a matching `replied`, rejects on `signal` abort or the
52
53
  * 10-minute cap (mirrors `bus.ask`'s default). */
53
54
  ask(to: string, kind: MsgKind, text: string, signal?: AbortSignal): Promise<string>;
@@ -81,6 +82,8 @@ const ASK_TIMEOUT_MS = 600_000;
81
82
  // A roster lookup is one local socket/pipe round-trip, unlike a blocking supervisor ask. Do not
82
83
  // let a half-open broker pin `contact_peer list/send` until the child idle watchdog fires.
83
84
  const LIST_TIMEOUT_MS = 10_000;
85
+ export const SEND_TIMEOUT_MS = 10_000;
86
+ export const MAX_PENDING_SENDS = 64;
84
87
 
85
88
  export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
86
89
  const netImpl = deps.net ?? nodeNet;
@@ -93,6 +96,7 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
93
96
  let cancelConnect: (() => void) | undefined;
94
97
  let cancelBackoff: (() => void) | undefined;
95
98
  const pendingReads = new Map<string, { request: ResultReadRequest; resolve: (result: ReadReply) => void }>();
99
+ const pendingSends = new Map<string, (delivered: boolean) => void>();
96
100
  let registerSettle: { resolve: () => void; reject: (err: Error) => void } | undefined;
97
101
 
98
102
  const deliverListeners = new Set<(evt: DeliverEvent) => void>();
@@ -101,9 +105,7 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
101
105
  const pendingAsks = new Map<string, { resolve: (text: string) => void; reject: (err: Error) => void }>();
102
106
  const pendingLists = new Map<string, { resolve: (peers: Array<{ handle: string; label: string }>) => void; reject: (err: Error) => void }>();
103
107
 
104
- /** Best-effort write: a frame sent while disconnected is silently dropped — matches
105
- * the design's "sends report undelivered" (no throw the caller could even act on,
106
- * `send`/`reply` are `void`). */
108
+ /** Best-effort control frame write. Confirmed sends use their own guarded write and receipt. */
107
109
  function write(frame: Frame): void {
108
110
  if (!socket) return;
109
111
  try {
@@ -126,6 +128,9 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
126
128
 
127
129
  function handleFrame(frame: Frame): void {
128
130
  switch (frame.t) {
131
+ case "sent":
132
+ pendingSends.get(frame.msgId)?.(frame.delivered);
133
+ return;
129
134
  case "registered":
130
135
  if (!registerSettle) return;
131
136
  if (frame.handle !== deps.handle || (deps.readerToken !== undefined && (!frame.readerToken
@@ -146,6 +151,7 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
146
151
  // A correlated error belongs to one request on this connection. Do not let an
147
152
  // unknown/stale correlation id affect the register handshake or another ask.
148
153
  if (frame.msgId !== undefined) {
154
+ pendingSends.get(frame.msgId)?.(false);
149
155
  pendingAsks.get(frame.msgId)?.reject(new Error(frame.reason));
150
156
  return;
151
157
  }
@@ -254,6 +260,7 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
254
260
  }
255
261
 
256
262
  function rejectAllPending(reason: string): void {
263
+ for (const settle of pendingSends.values()) settle(false);
257
264
  for (const p of pendingReads.values()) p.resolve(readFailure("unavailable"));
258
265
  for (const p of pendingAsks.values()) p.reject(new Error(reason));
259
266
  pendingAsks.clear();
@@ -354,8 +361,24 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
354
361
  });
355
362
  }
356
363
 
357
- function send(to: string, kind: MsgKind, text: string): void {
358
- write({ t: "send", to, kind, text, msgId: randomUUID(), expectsReply: false });
364
+ function send(to: string, kind: MsgKind, text: string, signal?: AbortSignal): Promise<boolean> {
365
+ if (signal?.aborted || closed || disconnected || !registered || !socket || pendingSends.size >= MAX_PENDING_SENDS) return Promise.resolve(false);
366
+ const msgId = randomUUID();
367
+ return new Promise((resolve) => {
368
+ const finish = (delivered: boolean): void => {
369
+ if (!pendingSends.delete(msgId)) return;
370
+ clearTimeout(timer);
371
+ signal?.removeEventListener("abort", onAbort);
372
+ resolve(delivered);
373
+ };
374
+ const onAbort = (): void => finish(false);
375
+ const timer = setTimeout(() => finish(false), SEND_TIMEOUT_MS);
376
+ timer.unref?.();
377
+ pendingSends.set(msgId, finish);
378
+ signal?.addEventListener("abort", onAbort, { once: true });
379
+ try { socket!.write(encodeFrame({ t: "send", to, kind, text, msgId, expectsReply: false })); }
380
+ catch { finish(false); }
381
+ });
359
382
  }
360
383
 
361
384
  /** Why `ask`/`list` refuse to send right now — distinguishes a disposed client from one that
@@ -373,7 +373,7 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
373
373
  }
374
374
  case "send": {
375
375
  if (!handle) {
376
- write({ t: "error", reason: "send before register" });
376
+ write({ t: "error", reason: "send before register", msgId: frame.msgId });
377
377
  return;
378
378
  }
379
379
  const from = handle;
@@ -395,8 +395,12 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
395
395
  forgetAsk(frame.msgId, ac);
396
396
  write({ t: "error", reason: errorReason(error), msgId: frame.msgId });
397
397
  }
398
- } else if (!bus.send(from, frame.to, frame.text, frame.kind)) {
399
- write({ t: "error", reason: `unknown peer: ${frame.to}` });
398
+ } else {
399
+ // Admission is not an answer. Unknown/full inboxes both return false;
400
+ // never label a full inbox "unknown peer" or a socket write "delivered".
401
+ let delivered = false;
402
+ try { delivered = bus.send(from, frame.to, frame.text, frame.kind); } catch { /* no confirmation */ }
403
+ write({ t: "sent", msgId: frame.msgId, delivered });
400
404
  }
401
405
  return;
402
406
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Slimmed wire-frame catalog + validators (spec B6) — comtac's protocol trimmed to the
3
3
  * frames pi-persona actually needs: register/send/cancel/reply/list/bye (client→host) and
4
- * registered/deliver/steer/peers/replied/error (host→client). Mirrors comtac's
4
+ * registered/sent/deliver/steer/peers/replied/error (host→client). Mirrors comtac's
5
5
  * `messages.ts` validation STYLE (structural per-field checks, one explicit assert per
6
6
  * case) but drops presence/sessions/attachments/broadcasts entirely (YAGNI here).
7
7
  *
@@ -28,6 +28,7 @@ export type Frame =
28
28
  | { t: "result_read_reply"; reqId: string; result: ReadReply }
29
29
  | { t: "result_read_cancel"; reqId: string }
30
30
  | { t: "send"; to: string; kind: MsgKind; text: string; msgId: string; expectsReply: boolean }
31
+ | { t: "sent"; msgId: string; delivered: boolean }
31
32
  | { t: "cancel"; msgId: string }
32
33
  | { t: "deliver"; from: string; fromLabel?: string; kind: MsgKind; text: string; msgId: string; expectsReply: boolean }
33
34
  | { t: "reply"; askId: string; text: string }
@@ -84,6 +85,8 @@ export function isFrame(value: unknown): value is Frame {
84
85
  );
85
86
  case "cancel":
86
87
  return typeof value.msgId === "string";
88
+ case "sent":
89
+ return closedRecord(value, ["t", "msgId", "delivered"]) && typeof value.msgId === "string" && typeof value.delivered === "boolean";
87
90
  case "deliver": {
88
91
  if (typeof value.from !== "string") return false;
89
92
  if (value.fromLabel !== undefined && typeof value.fromLabel !== "string") return false;
@@ -0,0 +1,8 @@
1
+ import type { InProcessBus, MsgKind } from "./inproc.ts";
2
+
3
+ /** Child contact tools need delivery admission, not a concrete mailbox implementation.
4
+ * A remote send awaits only the host's bounded receipt, never the recipient's answer. */
5
+ export interface ContactTransport {
6
+ send(from: string, to: string, text: string, kind?: MsgKind, signal?: AbortSignal): boolean | Promise<boolean>;
7
+ ask: InProcessBus["ask"];
8
+ }
@@ -21,7 +21,8 @@ import { Text } from "@earendil-works/pi-tui";
21
21
 
22
22
  import { compactInlineText } from "../ui/presentation.ts";
23
23
  import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
24
- import type { InProcessBus, MsgKind } from "./inproc.ts";
24
+ import type { MsgKind } from "./inproc.ts";
25
+ import type { ContactTransport } from "./contact-transport.ts";
25
26
 
26
27
  const ContactParams = Type.Object({
27
28
  kind: Type.Union([Type.Literal("progress"), Type.Literal("decision"), Type.Literal("interview")], {
@@ -94,7 +95,7 @@ function result(t: string, details: ContactDetails): { content: [{ type: "text";
94
95
  * the supervisor is busy (a sync run holds the turn), so the question is posted one-way and
95
96
  * returns unresolved. This prevents a sync delegate/council from deadlocking on a child ask. */
96
97
  export function makeContactSupervisorTool(
97
- bus: InProcessBus,
98
+ bus: ContactTransport,
98
99
  fromHandle: string,
99
100
  supervisorHandle: string,
100
101
  opts: { allowBlocking?: boolean } = {},
@@ -125,13 +126,14 @@ export function makeContactSupervisorTool(
125
126
  const message = boundContactMessage(params.message);
126
127
  if (kind === "progress") {
127
128
  try {
128
- const delivered = bus.send(fromHandle, supervisorHandle, message, "progress");
129
+ const delivered = await bus.send(fromHandle, supervisorHandle, message, "progress", signal);
130
+ if (signal?.aborted) return result("The progress update was cancelled; delivery could not be confirmed.", { kind, delivered: false, outcome: "cancelled" });
129
131
  return result(
130
- delivered ? "Progress reported to the supervisor." : "(no supervisor listening or progress delivery could not be confirmed; progress dropped)",
132
+ delivered ? "Progress reported to the supervisor." : "Progress delivery could not be confirmed. The supervisor may already have received the update.",
131
133
  { kind, delivered, outcome: delivered ? "reported" : "unavailable" },
132
134
  );
133
135
  } catch {
134
- return result("(progress could not be sent; delivery could not be confirmed)", { kind, delivered: false, outcome: "failed" });
136
+ return result("Progress delivery could not be confirmed. The supervisor may already have received the update.", { kind, delivered: false, outcome: "failed" });
135
137
  }
136
138
  }
137
139
 
@@ -140,13 +142,14 @@ export function makeContactSupervisorTool(
140
142
  if (!allowBlocking) {
141
143
  let delivered: boolean;
142
144
  try {
143
- delivered = bus.send(fromHandle, supervisorHandle, message, kind);
145
+ delivered = await bus.send(fromHandle, supervisorHandle, message, kind, signal);
144
146
  } catch {
145
147
  return result(
146
- `The question could not be sent; no answer is possible in this path. ${unresolvedGuidance(kind)}`,
148
+ `Delivery of the question could not be confirmed. The supervisor may already have received it; no answer is possible in this path. ${unresolvedGuidance(kind)}`,
147
149
  { kind, delivered: false, outcome: "failed" },
148
150
  );
149
151
  }
152
+ if (signal?.aborted) return result(`The request was cancelled. ${unresolvedGuidance(kind)}`, { kind, delivered: false, outcome: "cancelled" });
150
153
  const delivery = delivered ? "question sent one-way" : "question delivery could not be confirmed";
151
154
  return result(
152
155
  `The supervisor is busy right now; ${delivery}, and no answer is possible in this path. ${unresolvedGuidance(kind)}`,