@north-light/crouter 0.3.154 → 0.3.157

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 (80) hide show
  1. package/README.md +2 -1
  2. package/dist/api/client.d.ts +1 -4
  3. package/dist/api/client.js +0 -5
  4. package/dist/api/dto/broker.d.ts +1 -1
  5. package/dist/api/dto/nodes.d.ts +0 -15
  6. package/dist/api/routes.d.ts +0 -1
  7. package/dist/api/routes.js +0 -1
  8. package/dist/builtin-views/chat/core.mjs +51 -6
  9. package/dist/builtin-views/chat/tui.mjs +14 -6
  10. package/dist/builtin-views/chat/web.jsx +7 -2
  11. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
  12. package/dist/clients/attach/chrome/roster.d.ts +1 -1
  13. package/dist/clients/attach/chrome/roster.js +1 -9
  14. package/dist/clients/attach/command.js +5 -5
  15. package/dist/clients/attach/input/controller.d.ts +8 -23
  16. package/dist/clients/attach/input/controller.js +29 -59
  17. package/dist/clients/attach/overlays/dialogs.d.ts +2 -1
  18. package/dist/clients/attach/overlays/graph.d.ts +1 -4
  19. package/dist/clients/attach/overlays/graph.js +7 -25
  20. package/dist/clients/attach/session/context.d.ts +0 -7
  21. package/dist/clients/attach/session/frames.js +8 -1
  22. package/dist/clients/attach/session/input-wiring.d.ts +1 -1
  23. package/dist/clients/attach/session/input-wiring.js +11 -21
  24. package/dist/clients/attach/session/mode.d.ts +3 -4
  25. package/dist/clients/attach/session/mode.js +1 -6
  26. package/dist/clients/attach/session/reconnect.d.ts +3 -3
  27. package/dist/clients/attach/session/reconnect.js +7 -6
  28. package/dist/clients/attach/session/state-sync.d.ts +1 -1
  29. package/dist/clients/attach/session/state-sync.js +2 -2
  30. package/dist/clients/attach/slash/dispatch.d.ts +1 -13
  31. package/dist/clients/attach/slash/dispatch.js +17 -65
  32. package/dist/clients/attach/viewer.js +523 -523
  33. package/dist/clients/web/web-client/shared/protocol.d.ts +3 -5
  34. package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
  35. package/dist/core/__tests__/chat-view-reconnect.test.js +23 -44
  36. package/dist/core/__tests__/full/broker-attach-limits.test.js +36 -60
  37. package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
  38. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  39. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  40. package/dist/core/__tests__/full/broker-dialogs.test.js +62 -121
  41. package/dist/core/__tests__/helpers/broker-clients.js +2 -2
  42. package/dist/core/__tests__/session-model.test.js +26 -15
  43. package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
  44. package/dist/core/keybindings/catalog.d.ts +2 -2
  45. package/dist/core/keybindings/catalog.js +2 -0
  46. package/dist/core/runtime/auth-reload.d.ts +4 -4
  47. package/dist/core/runtime/auth-reload.js +13 -9
  48. package/dist/core/runtime/boot-root.d.ts +2 -2
  49. package/dist/core/runtime/boot-root.js +7 -7
  50. package/dist/core/runtime/broker-protocol.d.ts +27 -23
  51. package/dist/core/runtime/broker-protocol.js +1 -1
  52. package/dist/core/runtime/broker-request.js +11 -5
  53. package/dist/core/runtime/broker.d.ts +13 -21
  54. package/dist/core/runtime/broker.js +186 -151
  55. package/dist/core/runtime/interactive-deliver.js +5 -4
  56. package/dist/core/runtime/model-swap.d.ts +3 -2
  57. package/dist/core/runtime/model-swap.js +4 -3
  58. package/dist/core/runtime/node-read.d.ts +0 -20
  59. package/dist/core/runtime/node-read.js +1 -34
  60. package/dist/core/runtime/resume-root.d.ts +1 -1
  61. package/dist/core/runtime/resume-root.js +6 -6
  62. package/dist/core/runtime/spawn.js +3 -3
  63. package/dist/core/session-model/session-state.d.ts +6 -8
  64. package/dist/core/session-model/session-state.js +16 -6
  65. package/dist/daemon/api/handlers/nodes.js +1 -18
  66. package/dist/daemon/manage.js +2 -2
  67. package/dist/index.d.ts +1 -1
  68. package/dist/web-client/assets/index--SsQYcKu.js +79 -0
  69. package/dist/web-client/assets/{index-CpEl9LTS.css → index-DJhQZoAj.css} +1 -1
  70. package/dist/web-client/index.html +2 -2
  71. package/dist/web-client/sw.js +1 -1
  72. package/docs/compat/hearth-crtr-v1.md +1 -1
  73. package/docs/compat/hearth-crtr-v2.md +1 -1
  74. package/docs/compat/hearth-crtr-v3.md +1 -1
  75. package/docs/compat/hearth-crtr-v4.md +3 -1
  76. package/docs/public-api.md +2 -2
  77. package/package.json +4 -4
  78. package/runtime.lock.json +2 -2
  79. package/dist/web-client/assets/index-BpyZGBhI.js +0 -79
  80. package/docs/compat/hearth-crtr-v5.md +0 -175
@@ -5,10 +5,7 @@ import type { BrokerErrorFrame } from '../../api/dto/broker.js';
5
5
  * import site for the wire union; the shapes themselves are pi's, versioned by
6
6
  * pi, so a pi change is a compile error here instead of silent drift. */
7
7
  export type { RpcExtensionUIRequest, RpcExtensionUIResponse, RpcSessionState, } from '@earendil-works/pi-coding-agent';
8
- /** A socket's fixed, per-client capability, stated once in its `hello` and never
9
- * reassigned: `controller` may drive the engine, `observer` is read-only. This is
10
- * a CAPABILITY, not ownership of a singleton slot — any number of clients may be
11
- * controllers at the same time and all of them may write concurrently. */
8
+ /** Single controller (drives the engine) + N read-only observers (§5.3). */
12
9
  export type ClientRole = 'controller' | 'observer';
13
10
  /** The full state a (re)attaching client needs to catch up instantly — the
14
11
  * broker's authoritative in-memory view (design §5.2: get_messages +
@@ -46,8 +43,6 @@ export interface BrokerSnapshot {
46
43
  title: string | undefined;
47
44
  };
48
45
  }
49
- /** Opens the connection and FIXES this client's role for its lifetime. A repeated
50
- * `hello` on an already-helloed socket never changes the established role. */
51
46
  export interface HelloFrame {
52
47
  type: 'hello';
53
48
  role: ClientRole;
@@ -58,7 +53,7 @@ export interface HelloFrame {
58
53
  rows: number;
59
54
  };
60
55
  }
61
- /** Drive the engine — writable (`controller`) clients only. Map 1:1 to session.prompt/steer/followUp/abort.
56
+ /** Drive the engine — controller only. Map 1:1 to session.prompt/steer/followUp/abort.
62
57
  * `images` carries pasted/attached images to the engine (review M1): pi accepts
63
58
  * `prompt(text,{images})` / `steer(text,images)` / `followUp(text,images)` at
64
59
  * 0.78.1. The wire TYPE lives here; T3/T6 wire the runtime side. The BROKER read
@@ -92,7 +87,7 @@ export interface AbortFrame {
92
87
  * `POST /v1/nodes/{id}/messages` with `delivery:'interactive'`). The broker
93
88
  * routes it ITSELF — turn in flight → steer, idle → prompt — because only the
94
89
  * broker authoritatively knows streaming state (viewers track it client-side;
95
- * a one-shot client can't). Writable-only, like the other drive frames.
90
+ * a one-shot client can't). Controller-only, like the other drive frames.
96
91
  * Acked once routed (`ack{for:'deliver', id}`, `detail` = 'prompt' | 'steer')
97
92
  * so the daemon handler can return deterministically. */
98
93
  export interface DeliverFrame {
@@ -101,7 +96,7 @@ export interface DeliverFrame {
101
96
  text: string;
102
97
  images?: ImageContent[];
103
98
  }
104
- /** Run a `!` bash command — writable clients only. Maps to `session.executeBash()`,
99
+ /** Run a `!` bash command — controller only. Maps to `session.executeBash()`,
105
100
  * which runs the command, records a `bashExecution` message in context, and
106
101
  * starts NO agent turn (pi's interactive `!`/`!!` semantics). `command` is the
107
102
  * text AFTER the leading `!`/`!!`; `excludeFromContext` is the `!!` form (output
@@ -112,6 +107,13 @@ export interface BashFrame {
112
107
  command: string;
113
108
  excludeFromContext?: boolean;
114
109
  }
110
+ /** Controller arbitration (§5.3). */
111
+ export interface RequestControlFrame {
112
+ type: 'request_control';
113
+ }
114
+ export interface ReleaseControlFrame {
115
+ type: 'release_control';
116
+ }
115
117
  /** Detach this client — the engine runs on (distinct from `shutdown`). */
116
118
  export interface ByeFrame {
117
119
  type: 'bye';
@@ -297,13 +299,13 @@ export interface ListMemoryRefsFrame {
297
299
  }
298
300
  /** Answer a blocking extension dialog — pi's public RPC response type. */
299
301
  export type ExtensionUIResponseFrame = RpcExtensionUIResponse;
300
- /** Clone the current session to a new branch (`/clone`). Writable-only.
302
+ /** Clone the current session to a new branch (`/clone`). Controller-only.
301
303
  * Broker handler: reads current leaf from sessionManager, creates a branched
302
304
  * session file, switches to it via runReplacement. */
303
305
  export interface CloneFrame {
304
306
  type: 'clone';
305
307
  }
306
- /** Share the session as a secret GitHub gist (`/share`). Writable-only.
308
+ /** Share the session as a secret GitHub gist (`/share`). Controller-only.
307
309
  * Broker handler: exports session to a temp HTML file, shells `gh gist create --secret`,
308
310
  * returns the URL in ack.detail. */
309
311
  export interface ShareFrame {
@@ -312,8 +314,8 @@ export interface ShareFrame {
312
314
  /** Reload credentials + refresh model registry after a viewer-side auth change.
313
315
  * Open to any client — reload_auth is an idempotent local re-read that doesn't
314
316
  * steer the conversation, so the daemon's canvas-wide fan (one /login → every
315
- * live broker) can trigger it from an observer connection. Broker handler:
316
- * services.authStorage.reload() +
317
+ * live broker) can trigger it without claiming controller and demoting an
318
+ * attached human. Broker handler: services.authStorage.reload() +
317
319
  * services.modelRegistry.refresh(). */
318
320
  export interface ReloadAuthFrame {
319
321
  type: 'reload_auth';
@@ -325,22 +327,25 @@ export interface GetHumanForkCoordinatesFrame {
325
327
  type: 'get_human_fork_coordinates';
326
328
  id: string;
327
329
  }
328
- export type ClientToBroker = HelloFrame | PromptFrame | SteerFrame | FollowUpFrame | AbortFrame | DeliverFrame | BashFrame | ByeFrame | ShutdownFrame | SetModelFrame | DeliverCustomMessageFrame | CycleModelFrame | CycleLadderFrame | CycleThinkingFrame | SetThinkingLevelFrame | DequeueFrame | SetAutoRetryFrame | SetAutoCompactionFrame | CompactFrame | NewSessionFrame | SwitchSessionFrame | ForkFrame | SetSessionNameFrame | GetCommandsFrame | NavigateTreeFrame | ReloadFrame | ExportFrame | ListModelsFrame | ListSessionsFrame | GetTreeFrame | GetSettingsFrame | ListScopedModelsFrame | ListMemoryRefsFrame | ExtensionUIResponseFrame | CloneFrame | ShareFrame | ReloadAuthFrame | GetHumanForkCoordinatesFrame;
330
+ export type ClientToBroker = HelloFrame | PromptFrame | SteerFrame | FollowUpFrame | AbortFrame | DeliverFrame | BashFrame | RequestControlFrame | ReleaseControlFrame | ByeFrame | ShutdownFrame | SetModelFrame | DeliverCustomMessageFrame | CycleModelFrame | CycleLadderFrame | CycleThinkingFrame | SetThinkingLevelFrame | DequeueFrame | SetAutoRetryFrame | SetAutoCompactionFrame | CompactFrame | NewSessionFrame | SwitchSessionFrame | ForkFrame | SetSessionNameFrame | GetCommandsFrame | NavigateTreeFrame | ReloadFrame | ExportFrame | ListModelsFrame | ListSessionsFrame | GetTreeFrame | GetSettingsFrame | ListScopedModelsFrame | ListMemoryRefsFrame | ExtensionUIResponseFrame | CloneFrame | ShareFrame | ReloadAuthFrame | GetHumanForkCoordinatesFrame;
329
331
  export interface WelcomeFrame {
330
332
  type: 'welcome';
331
333
  snapshot: BrokerSnapshot;
332
- /** The COMPLETE statement of this client's authority — there is no other
333
- * role/ownership field. Fixed at `hello` and never changed afterwards. */
334
334
  role: ClientRole;
335
- /** A dialog already in-flight when this client attached. Populated only for a
336
- * writable client (an observer can never answer one), so a viewer joining
337
- * mid-dialog can answer it alongside its peers. */
335
+ controller_id: string | null;
336
+ /** A dialog already in-flight when this client attached (Phase 4
337
+ * attach-mid-dialog). The field exists now; it is only ever populated in
338
+ * Phase 4 — Phase 3 always sends it absent/null. */
338
339
  pending_dialog?: RpcExtensionUIRequest | null;
339
340
  /** The pi agent dir (`~/.pi/agent`) from the broker's process — so the viewer
340
341
  * can construct an AuthStorage/ModelRegistry pointing at the SAME auth.json.
341
342
  * crtr surface attach is tmux-local only: broker + viewer share a filesystem. */
342
343
  agentDir?: string;
343
344
  }
345
+ export interface ControlChangedFrame {
346
+ type: 'control_changed';
347
+ controller_id: string | null;
348
+ }
344
349
  /** Broadcast to EVERY client after a successful `set_model`/`cycle_model`. pi
345
350
  * emits no AgentSessionEvent for a model switch, so without this the new model
346
351
  * reaches no viewer at all (the requester gets only a bare ack) and footers
@@ -370,7 +375,7 @@ export interface ModelChangedFrame {
370
375
  * promise instead of hanging it — absent on uncorrelated errors (engine drive
371
376
  * errors, command-op failures, frame_overflow). There is NO `retryable` field. */
372
377
  export type ErrorFrame = BrokerErrorFrame;
373
- /** Result of a command op (§1.3): `for` echoes the op name, `ok` the
378
+ /** Result of a controller command op (§1.3): `for` echoes the op name, `ok` the
374
379
  * outcome, `detail` an optional human-readable note. */
375
380
  export interface AckFrame {
376
381
  type: 'ack';
@@ -555,8 +560,7 @@ export type ExtensionUIRequestFrame = RpcExtensionUIRequest;
555
560
  * WITHOUT a client answer. The broker sends this exactly when it resolves a pending
556
561
  * dialog ITSELF rather than the client answering it — the extension aborted the
557
562
  * request out-of-band (e.g. a local OAuth loopback callback won the race against a
558
- * still-open manual-paste dialog), the broker-side timeout fired, or ANOTHER writable
559
- * client answered the fan-out first and this one must close its copy. The client tears
563
+ * still-open manual-paste dialog) or the broker-side timeout fired. The client tears
560
564
  * down ONLY the overlay whose `id` matches; every other dialog stays exactly as it
561
565
  * was, so an unrelated request can never dismiss an unrelated blocking dialog. */
562
566
  export interface ExtensionUIDismissFrame {
@@ -566,7 +570,7 @@ export interface ExtensionUIDismissFrame {
566
570
  /** Everything the broker can send. Live `AgentSessionEvent`s are relayed
567
571
  * verbatim (the broker adds nothing); the broker's own control frames carry
568
572
  * non-colliding `type` discriminants. */
569
- export type BrokerToClient = WelcomeFrame | ModelChangedFrame | ErrorFrame | AckFrame | BrokerDataFrame | BashStartFrame | BashOutputFrame | BashEndFrame | ExtensionUIRequestFrame | ExtensionUIDismissFrame | AgentSessionEvent;
573
+ export type BrokerToClient = WelcomeFrame | ControlChangedFrame | ModelChangedFrame | ErrorFrame | AckFrame | BrokerDataFrame | BashStartFrame | BashOutputFrame | BashEndFrame | ExtensionUIRequestFrame | ExtensionUIDismissFrame | AgentSessionEvent;
570
574
  /** Encode one frame as a single newline-terminated JSON line. */
571
575
  export declare function encodeFrame(frame: ClientToBroker | BrokerToClient): string;
572
576
  /** Byte bounds for a {@link FrameDecoder} (C5). */
@@ -6,7 +6,7 @@
6
6
  // The transport is one unix socket per node (`nodeDir(id)/view.sock`) speaking
7
7
  // newline-delimited JSON frames. Live engine events are relayed VERBATIM — the
8
8
  // broker is a transparent multiplexer, so a broker→client frame is either one of
9
- // the broker's own control frames (welcome/error) or a raw pi
9
+ // the broker's own control frames (welcome/control_changed/error) or a raw pi
10
10
  // `AgentSessionEvent` / `extension_ui_request`. The `type` namespaces never
11
11
  // collide, so the union below stays a clean discriminated union.
12
12
  //
@@ -1,6 +1,6 @@
1
- // broker-request.ts — the ONE one-shot writable round-trip against a LIVE
2
- // node broker over its view.sock: connect → `hello` as controller → send a
3
- // single frame → await its ack.
1
+ // broker-request.ts — the ONE one-shot controller round-trip against a LIVE
2
+ // node broker over its view.sock: connect → `hello` + `request_control` →
3
+ // send a single frame → await its ack.
4
4
  //
5
5
  // Every host-side "tell a booted engine something" path rides this: the model
6
6
  // swap (model-swap.ts), interactive deliver/interrupt (interactive-deliver.ts),
@@ -8,6 +8,11 @@
8
8
  // They differ only in the frame sent, the ack awaited, and what they read off
9
9
  // the ack — so the dance itself lives here once instead of being re-copied per
10
10
  // caller.
11
+ //
12
+ // `request_control` is sent unconditionally: `hello` admits us as controller
13
+ // only if none is held, and `request_control` preempts an attached viewer
14
+ // (idempotent when we already hold it). The broker processes both in order
15
+ // before the payload frame.
11
16
  import { randomUUID } from 'node:crypto';
12
17
  import { BrokerClient } from '../broker-client/index.js';
13
18
  /** How long to wait for the broker's ack. Every frame this carries is local
@@ -62,6 +67,7 @@ export function oneShotControllerRequest(opts) {
62
67
  client.on('connect', () => {
63
68
  connected = true;
64
69
  client.send({ type: 'hello', role: 'controller', client_id: clientId });
70
+ client.send({ type: 'request_control' });
65
71
  client.send(opts.frame(frameId));
66
72
  });
67
73
  client.on('frame', (frame) => {
@@ -72,8 +78,8 @@ export function oneShotControllerRequest(opts) {
72
78
  finish(() => reject(new Error(frame.detail ?? `broker rejected ${opts.what}`)));
73
79
  }
74
80
  else if (frame.type === 'error') {
75
- // This connection only ever sent hello + one payload frame, so any
76
- // error frame here is a response to ours.
81
+ // This connection only ever sent hello/request_control + one payload
82
+ // frame, so any error frame here is a response to ours.
77
83
  finish(() => reject(new Error(frame.message)));
78
84
  }
79
85
  else if (opts.observe !== undefined) {
@@ -22,8 +22,8 @@ export declare function isUnknownModel(model: {
22
22
  api?: string;
23
23
  } | null | undefined): boolean;
24
24
  /**
25
- * Route a writable client's `prompt`/`follow_up` frame against the LIVE session
26
- * state. The client picks its frame type off a possibly-STALE `isStreaming`
25
+ * Route a controller `prompt`/`follow_up` frame against the LIVE session state.
26
+ * The controller picks its frame type off a possibly-STALE `isStreaming`
27
27
  * snapshot, so the broker is authoritative and the client's choice is a HINT:
28
28
  *
29
29
  * - m-B (streaming-safe prompt): a `prompt` arriving mid-stream needs
@@ -186,19 +186,15 @@ interface BrokerClient {
186
186
  * high-water mark). */
187
187
  queuedFrames: number;
188
188
  }
189
- /** A blocking dialog awaiting a response from ANY writable client, the
190
- * broker-side default timeout, or the engine's abort. The dialog is fanned out
191
- * to every writable client at raise time; this entry is the single settlement
192
- * point they race for. */
189
+ /** A blocking dialog awaiting the controller's response, the broker-side default
190
+ * timeout, or the engine's abort. */
193
191
  interface PendingDialog {
194
- /** The original request — retained so `welcome.pending_dialog` and the
195
- * attach-mid-dialog replay can hand a still-pending dialog to a writable
196
- * client that attaches after the fan-out. */
192
+ /** The original request (T4) — retained so `welcome.pending_dialog` and the
193
+ * re-route-on-become-controller path can re-deliver a still-pending dialog to
194
+ * a (new) controller. The Wave-0 shape stored only the resolver. */
197
195
  request: RpcExtensionUIRequest;
198
- /** Some writable client answered — resolve with its parsed response. Settling
199
- * clears the broker-side timeout, removes the entry from the registry (so a
200
- * later answer for the same id finds nothing and is a no-op), and broadcasts
201
- * `extension_ui_dismiss` so every peer closes its copy. */
196
+ /** The controller answered — resolve with its parsed response (also clears the
197
+ * broker-side timeout and removes the entry from the registry). */
202
198
  resolve: (response: RpcExtensionUIResponse) => void;
203
199
  }
204
200
  /** Dispose the live engine session if one exists (idempotent). Called by
@@ -218,15 +214,11 @@ export declare function buildBrokerSession(engine: BrokerEngine, cfg: BrokerSdkC
218
214
  }>;
219
215
  /** Broker-side hooks the UI context needs to route (or noOp) extension dialogs. */
220
216
  export interface BrokerDialogDeps {
221
- /** Every client that may answer a dialog right now — empty when no writable
222
- * viewer is attached, which is the noOp fallback path. */
223
- writable: () => BrokerClient[];
224
- /** Forward a dialog request to one writable client (called once per client in
225
- * the fan-out). */
217
+ /** The controller client, or null when ZERO viewers are attached. */
218
+ controller: () => BrokerClient | null;
219
+ /** Forward a dialog request to the (non-null) controller. */
226
220
  forward: (client: BrokerClient, request: RpcExtensionUIRequest) => void;
227
- /** Pending-dialog registry, keyed by request id. ONE entry per dialog no matter
228
- * how many clients were fanned it; the first `extension_ui_response` settles
229
- * and removes it, so later answers for the same id are no-ops. */
221
+ /** Pending-dialog registry, keyed by request id (answered via extension_ui_response). */
230
222
  pending: Map<string, PendingDialog>;
231
223
  /** Broadcast a non-blocking display frame (setStatus/setWidget/setTitle) to ALL
232
224
  * viewers — the relay path for pi's fire-and-forget extension-UI surface. */