@north-light/crouter 0.3.157 → 0.3.158

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 (76) hide show
  1. package/dist/api/client.d.ts +4 -1
  2. package/dist/api/client.js +5 -0
  3. package/dist/api/dto/broker.d.ts +1 -1
  4. package/dist/api/dto/nodes.d.ts +15 -0
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-views/chat/core.mjs +6 -51
  8. package/dist/builtin-views/chat/tui.mjs +6 -14
  9. package/dist/builtin-views/chat/web.jsx +2 -7
  10. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
  11. package/dist/clients/attach/chrome/roster.d.ts +1 -1
  12. package/dist/clients/attach/chrome/roster.js +9 -1
  13. package/dist/clients/attach/command.js +5 -5
  14. package/dist/clients/attach/input/controller.d.ts +23 -8
  15. package/dist/clients/attach/input/controller.js +59 -29
  16. package/dist/clients/attach/overlays/dialogs.d.ts +1 -2
  17. package/dist/clients/attach/overlays/graph.d.ts +4 -1
  18. package/dist/clients/attach/overlays/graph.js +25 -7
  19. package/dist/clients/attach/session/context.d.ts +7 -0
  20. package/dist/clients/attach/session/frames.js +1 -8
  21. package/dist/clients/attach/session/input-wiring.d.ts +1 -1
  22. package/dist/clients/attach/session/input-wiring.js +21 -11
  23. package/dist/clients/attach/session/mode.d.ts +4 -3
  24. package/dist/clients/attach/session/mode.js +6 -1
  25. package/dist/clients/attach/session/reconnect.d.ts +3 -3
  26. package/dist/clients/attach/session/reconnect.js +6 -7
  27. package/dist/clients/attach/session/state-sync.d.ts +1 -1
  28. package/dist/clients/attach/session/state-sync.js +2 -2
  29. package/dist/clients/attach/slash/dispatch.d.ts +13 -1
  30. package/dist/clients/attach/slash/dispatch.js +65 -17
  31. package/dist/clients/attach/viewer.js +523 -523
  32. package/dist/clients/web/web-client/shared/protocol.d.ts +5 -3
  33. package/dist/commands/node.js +1 -1
  34. package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
  35. package/dist/core/__tests__/chat-view-reconnect.test.js +44 -23
  36. package/dist/core/__tests__/full/broker-attach-limits.test.js +60 -36
  37. package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
  38. package/dist/core/__tests__/full/broker-dialogs.test.js +121 -62
  39. package/dist/core/__tests__/helpers/broker-clients.js +2 -2
  40. package/dist/core/__tests__/session-model.test.js +15 -26
  41. package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
  42. package/dist/core/keybindings/catalog.d.ts +2 -2
  43. package/dist/core/keybindings/catalog.js +0 -2
  44. package/dist/core/runtime/auth-reload.d.ts +4 -4
  45. package/dist/core/runtime/auth-reload.js +9 -13
  46. package/dist/core/runtime/boot-root.d.ts +2 -2
  47. package/dist/core/runtime/boot-root.js +7 -7
  48. package/dist/core/runtime/broker-protocol.d.ts +23 -27
  49. package/dist/core/runtime/broker-protocol.js +1 -1
  50. package/dist/core/runtime/broker-request.js +5 -11
  51. package/dist/core/runtime/broker.d.ts +21 -13
  52. package/dist/core/runtime/broker.js +151 -186
  53. package/dist/core/runtime/interactive-deliver.js +4 -5
  54. package/dist/core/runtime/model-swap.d.ts +2 -3
  55. package/dist/core/runtime/model-swap.js +3 -4
  56. package/dist/core/runtime/node-read.d.ts +20 -0
  57. package/dist/core/runtime/node-read.js +34 -1
  58. package/dist/core/runtime/resume-root.d.ts +1 -1
  59. package/dist/core/runtime/resume-root.js +6 -6
  60. package/dist/core/runtime/spawn.js +3 -3
  61. package/dist/core/session-model/session-state.d.ts +8 -6
  62. package/dist/core/session-model/session-state.js +6 -16
  63. package/dist/daemon/api/handlers/nodes.js +18 -1
  64. package/dist/daemon/manage.js +2 -2
  65. package/dist/index.d.ts +1 -1
  66. package/dist/pi-extensions/canvas-bash-valve.js +4 -3
  67. package/dist/web-client/assets/{index-DJhQZoAj.css → index-CpEl9LTS.css} +1 -1
  68. package/dist/web-client/assets/{index--SsQYcKu.js → index-CsuwzlcQ.js} +19 -19
  69. package/dist/web-client/index.html +2 -2
  70. package/dist/web-client/sw.js +1 -1
  71. package/docs/compat/hearth-crtr-v5.md +175 -0
  72. package/docs/public-api.md +2 -2
  73. package/package.json +2 -2
  74. package/runtime.lock.json +6 -6
  75. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +0 -1
  76. package/dist/core/__tests__/full/broker-control-preempt.test.js +0 -61
@@ -78,10 +78,10 @@ export interface SessionStatsSummary {
78
78
  assistant_messages: number;
79
79
  cost?: number;
80
80
  }
81
- /** Viewer/controller presence for a node. */
81
+ /** Viewer presence for a node — a truthful count of attached views. There is
82
+ * no owner: every live interactive view is writable at the same time. */
82
83
  export interface Presence {
83
84
  viewers: number;
84
- controller: string | null;
85
85
  }
86
86
  /** Git working-tree change counts for the meta strip. */
87
87
  export interface GitStatus {
@@ -276,7 +276,9 @@ export interface ResolveDeckResponse {
276
276
  * `isStreaming` (spec C.10).
277
277
  */
278
278
  export type SessionState = RpcSessionState;
279
- /** Web role of one browser tab's session connection. */
279
+ /** Fixed capability of one browser tab's session connection, stated by the
280
+ * broker's `welcome`: `controller` is writable, `observer` is read-only. It is
281
+ * a per-client capability, never ownership of a shared slot. */
280
282
  export type WebRole = 'observer' | 'controller';
281
283
  /** Upstream broker connectivity, surfaced to the tab (spec §6.2). */
282
284
  export type BrokerStatus = 'connected' | 'reconnecting' | 'down' | 'revived';
@@ -808,7 +808,7 @@ const nodeBashBackground = defineLeaf({
808
808
  { name: 'jobs', type: 'object[]', required: true, constraint: 'Per handed-off job {job_id, elapsed_ms} — the foreground elapsed time in milliseconds at the moment of handoff.' },
809
809
  ],
810
810
  outputKind: 'object',
811
- effects: ['Signals every foreground bash command currently running for the node to return from its tool call immediately while its detached supervisor keeps the command running.', 'Each handed-off command retains its log under the node context jobs directory and sends the node an inbox message when it exits.'],
811
+ effects: ['Signals every foreground bash command currently running for the node to return from its tool call immediately while its detached supervisor keeps the command running.', 'Each handed-off command retains its log under the node context jobs directory and sends the node an urgent inbox message when it exits, which steers the node mid-turn.'],
812
812
  },
813
813
  run: async (input) => {
814
814
  const pane = input['pane'] ?? process.env['TMUX_PANE'];
@@ -240,16 +240,16 @@ test('C3h — a cooling managed launch target boots model-less rather than falli
240
240
  // `<project_context>` block is environment-only. Covered by
241
241
  // context-intro.test.ts's environment-only regression.)
242
242
  // ===========================================================================
243
- // C2 — zero-viewer dialogs resolve to deny/cancel IMMEDIATELY (noOp), never
243
+ // C2 — zero-writable dialogs resolve to deny/cancel IMMEDIATELY (noOp), never
244
244
  // hanging the turn and never waiting on a per-dialog timeout. Drives the REAL
245
245
  // makeBrokerUiContext (the exact ExtensionUIContext the SDK hands extensions),
246
- // with a controller() that reports zero viewers.
246
+ // with a writable() snapshot that reports zero writable viewers.
247
247
  // ===========================================================================
248
- test('C2 — zero-viewer UI context resolves dialogs to deny/cancel immediately (noOp, no hang)', async () => {
248
+ test('C2 — zero-writable UI context resolves dialogs to deny/cancel immediately (noOp, no hang)', async () => {
249
249
  const ctx = makeBrokerUiContext({
250
- controller: () => null, // ZERO viewers attached
250
+ writable: () => [], // ZERO writable viewers attached
251
251
  forward: () => {
252
- throw new Error('C2: must NOT forward a dialog when no controller is attached');
252
+ throw new Error('C2: must NOT forward a dialog when no writable viewer is attached');
253
253
  },
254
254
  pending: new Map(),
255
255
  broadcast: () => { },
@@ -274,7 +274,7 @@ test('C2 — zero-viewer UI context resolves dialogs to deny/cancel immediately
274
274
  test('broker UI exposes the live provider pin policy to package extensions', () => {
275
275
  let pinned = false;
276
276
  const ctx = makeBrokerUiContext({
277
- controller: () => null,
277
+ writable: () => [],
278
278
  forward: () => { },
279
279
  pending: new Map(),
280
280
  broadcast: () => { },
@@ -291,36 +291,36 @@ test('broker UI exposes the live provider pin policy to package extensions', ()
291
291
  });
292
292
  // ===========================================================================
293
293
  // M2 (review mq5wkqep / T4) — REPLACES the Wave-0 M-1 cancel-on-detach. A dialog
294
- // forwarded to a controller that then DETACHES must NOT be cancelled: it stays
295
- // pending so a brief detach/reattach (or a handoff to another controller) does
296
- // not lose an answerable dialog. The broker-side default timeout is the ONLY
294
+ // forwarded to a writable viewer that then DETACHES must NOT be cancelled: it
295
+ // stays pending so a brief detach/reattach (or another writable viewer joining)
296
+ // does not lose an answerable dialog. The broker-side default timeout is the ONLY
297
297
  // non-answer resolution (proven here with a short per-dialog timeout standing in
298
298
  // for the 120s default). Guards against regressing to the over-eager cancel.
299
299
  // ===========================================================================
300
- test('M2 — a forwarded dialog stays pending on controller detach, resolving only on the broker-side timeout', async () => {
300
+ test('M2 — a forwarded dialog stays pending when its writable viewer detaches, resolving only on the broker-side timeout', async () => {
301
301
  const pending = new Map();
302
302
  let attached = true;
303
303
  const ctx = makeBrokerUiContext({
304
304
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
305
- controller: () => (attached ? { id: 'c1' } : null),
305
+ writable: () => (attached ? [{ id: 'c1' }] : []),
306
306
  forward: () => {
307
- /* a real controller would receive the request over its socket */
307
+ /* a real writable viewer would receive the request over its socket */
308
308
  },
309
309
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
310
310
  pending: pending,
311
311
  broadcast: () => { },
312
312
  });
313
- // Controller attached → confirm() forwards + registers a pending dialog (with the
314
- // request retained for welcome.pending_dialog / re-route, T4) and does NOT
315
- // resolve yet. A short per-dialog timeout stands in for the 120s broker default.
313
+ // A writable viewer attached → confirm() forwards + registers a pending dialog
314
+ // (with the request retained for welcome.pending_dialog / replay, T4) and does
315
+ // NOT resolve yet. A short per-dialog timeout stands in for the 120s broker default.
316
316
  const p = ctx.confirm('proceed?', 'really?', { timeout: 80 });
317
- assert.equal(pending.size, 1, 'M2: a forwarded dialog is pending while a controller is attached');
317
+ assert.equal(pending.size, 1, 'M2: a forwarded dialog is pending while a writable viewer is attached');
318
318
  const entry = [...pending.values()][0];
319
319
  assert.ok(entry.request, 'M2: the pending entry retains the request (welcome/re-route need it)');
320
320
  assert.equal(entry.cancel, undefined, 'M2: there is no cancel-on-detach path anymore');
321
- // Controller detaches → the dialog STAYS pending (the broker no longer cancels it).
321
+ // The writable viewer detaches → the dialog STAYS pending (the broker no longer cancels it).
322
322
  attached = false;
323
- assert.equal(pending.size, 1, 'M2: detach does NOT cancel the in-flight dialog');
323
+ assert.equal(pending.size, 1, 'M2: writable-viewer detach does NOT cancel the in-flight dialog');
324
324
  // Only the broker-side timeout resolves it, to the SAFE default (deny).
325
325
  const resolved = await Promise.race([
326
326
  p,
@@ -41,23 +41,16 @@ test('local invalid/no-node stream failures close asynchronously after the chann
41
41
  assert.equal(events[1]?.startsWith('close:'), true, `${target}: follows with streamClose`);
42
42
  }
43
43
  });
44
- test('control_changed demotion updates reconnect intent without clobbering an explicit controller request', () => {
45
- let current = {
46
- target: 'n1',
47
- desiredRole: 'controller',
48
- channel: null,
49
- clientId: 'client-1',
50
- conn: 'open',
51
- role: 'controller',
52
- controllerId: 'client-1',
53
- session: null,
54
- transcript: [],
55
- draft: '',
56
- notices: [],
57
- pendingDialog: null,
58
- queued: [],
59
- contextTokens: undefined,
60
- };
44
+ test('reconnect keeps the requested role while welcome and read_only remain authoritative', () => {
45
+ let current = core.init({ target: 'n1', role: 'controller' });
46
+ const sent = [];
47
+ const requestedRoles = [];
48
+ const closed = [];
49
+ const channels = [
50
+ { id: 'first-channel', send(frame) { sent.push(frame); }, close() { closed.push('first-channel'); } },
51
+ { id: 'replacement-channel', send(frame) { sent.push(frame); }, close() { closed.push('replacement-channel'); } },
52
+ ];
53
+ let nextChannel = 0;
61
54
  const ctx = {
62
55
  get state() {
63
56
  return current;
@@ -65,6 +58,13 @@ test('control_changed demotion updates reconnect intent without clobbering an ex
65
58
  set(next) {
66
59
  current = typeof next === 'function' ? next(current) : next;
67
60
  },
61
+ connect(_stream, opts) {
62
+ requestedRoles.push(opts.role);
63
+ return channels[nextChannel++];
64
+ },
65
+ dispatch(intent) {
66
+ return core.intents[intent](ctx);
67
+ },
68
68
  signal: {
69
69
  clearBanner() { },
70
70
  setBanner() { },
@@ -74,11 +74,32 @@ test('control_changed demotion updates reconnect intent without clobbering an ex
74
74
  quit() { },
75
75
  },
76
76
  };
77
- core.intents.streamFrame(ctx, { type: 'control_changed', controller_id: 'other-client' });
78
- assert.equal(current.role, 'observer');
79
- assert.equal(current.desiredRole, 'observer');
80
- current = { ...current, role: 'observer', desiredRole: 'controller' };
81
- core.intents.streamFrame(ctx, { type: 'control_changed', controller_id: 'other-client' });
82
- assert.equal(current.role, 'observer');
77
+ core.intents.refresh(ctx);
78
+ const firstChannel = current.channel;
79
+ assert.equal(current.desiredRole, 'controller');
80
+ assert.equal(current.conn, 'connecting');
81
+ assert.deepEqual(requestedRoles, ['controller']);
82
+ core.intents.streamOpen(ctx, firstChannel);
83
+ core.intents.streamFrame(ctx, { type: 'welcome', role: 'controller', snapshot: { state: {}, messages: [] } });
84
+ assert.equal(current.role, 'controller');
85
+ assert.equal(current.conn, 'open');
86
+ core.intents.reconnect(ctx);
87
+ assert.deepEqual(closed, ['first-channel']);
83
88
  assert.equal(current.desiredRole, 'controller');
89
+ assert.equal(current.channel?.id, 'replacement-channel');
90
+ assert.deepEqual(requestedRoles, ['controller', 'controller']);
91
+ const replacementChannel = current.channel;
92
+ core.intents.streamOpen(ctx, replacementChannel);
93
+ core.intents.streamFrame(ctx, { type: 'welcome', role: 'observer', snapshot: { state: {}, messages: [] } });
94
+ assert.equal(current.role, 'observer', 'welcome.role is authoritative for the active connection');
95
+ assert.equal(current.desiredRole, 'controller', 'the requested controller role survives reconnect');
96
+ assert.equal(current.conn, 'open');
97
+ core.intents.streamClose(ctx, { channelId: 'first-channel', reason: 'closed' });
98
+ assert.equal(current.channel, replacementChannel, 'a stale close cannot clear the replacement channel');
99
+ assert.equal(current.conn, 'open');
100
+ core.intents.streamFrame(ctx, { type: 'error', code: 'read_only' });
101
+ core.intents.setDraft(ctx, 'hello');
102
+ core.intents.submitDraft(ctx);
103
+ assert.deepEqual(sent, [], 'read-only welcome state prevents writes');
104
+ assert.match(current.notices.at(-1).text, /read-only — sending is disabled/);
84
105
  });
@@ -3,27 +3,32 @@
3
3
  // FULL TIER (real-boot-bound): tmux-free, but it boots a REAL broker process
4
4
  // (~5s pi-SDK load), so it lives in full/ (CI), not the fast local loop.
5
5
  //
6
- // T8 — the `crtr surface attach` acceptance gate for G4/G7/G8: controller
7
- // arbitration, decoder overflow isolation, and backpressure shedding.
6
+ // T8 — the `crtr surface attach` acceptance gate for G4/G7/G8: multi-writer
7
+ // admission, decoder overflow isolation, and backpressure shedding.
8
8
  //
9
9
  // 3-PART HEADER (headless):
10
- // (1) CONTRACT — the first client is the controller and a second client is an
11
- // admitted read-only observer; observer prompts are rejected while both
12
- // clients receive relay frames (G4). An oversized client line is capped and
13
- // dropped with frame_overflow while the broker and other clients continue
14
- // operating (G7). A stalled non-reading viewer is shed at the 32 MiB
15
- // backpressure HWM while the broker and fast viewers remain unaffected (G8).
16
- // (2) WHY BROKER/SOCKET-LEVEL, NOT PANE/WINDOW — arbitration, decoder limits, and
17
- // the backpressure HWM are broker-process and view.sock contracts. They are
18
- // exercised over a pure Unix socket using the production ViewSocketClient and
19
- // raw node:net peers, with a real broker process and no tmux pane or window.
10
+ // (1) CONTRACT — every client that hellos as `controller` is welcomed writable,
11
+ // simultaneously and independently; each writable client may submit, and
12
+ // every connected client (both writers plus a read-only observer) receives
13
+ // the merged stream of both submissions, while a client that hellos as
14
+ // `observer` is rejected with error{read_only} when it tries to drive (G4).
15
+ // An oversized client line is capped and dropped with frame_overflow while
16
+ // the broker and other clients continue operating (G7). A stalled
17
+ // non-reading viewer is shed at the 32 MiB backpressure HWM while the
18
+ // broker and fast viewers remain unaffected (G8).
19
+ // (2) WHY BROKER/SOCKET-LEVEL, NOT PANE/WINDOW — role admission, decoder limits,
20
+ // and the backpressure HWM are broker-process and view.sock contracts. They
21
+ // are exercised over a pure Unix socket using the production ViewSocketClient
22
+ // and raw node:net peers, with a real broker process and no tmux pane or
23
+ // window.
20
24
  // (3) HOW THE HEADLESS DRIVE ASSERTS THE CONTRACT — the real socket responses
21
- // provide the G4 welcome roles and relay frames, the G7 frame-overflow drop
22
- // and broker survival, and the G8 HWM-shed log line and broker survival.
25
+ // provide the G4 welcome roles, per-writer relay frames and the read-only
26
+ // rejection, the G7 frame-overflow drop and broker survival, and the G8
27
+ // HWM-shed log line and broker survival.
23
28
  //
24
- // One shared broker is booted in before() and reused across all three gates. Gates
25
- // whose first attach must hold control use attachUntil(controller) so a prior gate's
26
- // controller-detach handoff settles deterministically before they drive.
29
+ // One shared broker is booted in before() and reused across all three gates. There
30
+ // is no writer slot to hand off, so every gate simply attaches at the role it
31
+ // wants.
27
32
  import { test, before, after, afterEach } from 'node:test';
28
33
  import assert from 'node:assert/strict';
29
34
  import { createHeadlessHarness } from '../helpers/harness.js';
@@ -32,9 +37,7 @@ import { createAttachKit, delay, tok, frameHas, brokerLogText, } from '../helper
32
37
  let h;
33
38
  let id; // ONE shared broker, reused across G4/G7/G8 (1 real boot, not 3)
34
39
  const kit = createAttachKit(() => h);
35
- const { attach, attachUntil, connectRaw } = kit;
36
- // Admit a controller, waiting out any prior gate's controller-detach handoff.
37
- const ctrl = (cid) => attachUntil(id, 'controller', cid, (a) => a.welcome.role === 'controller', `${cid} admitted controller`);
40
+ const { attach, connectRaw } = kit;
38
41
  before(async () => {
39
42
  h = await createHeadlessHarness({ sessionPrefix: 'crtr-brklim' });
40
43
  const root = h.spawnRoot('broker-attach-limits suite root');
@@ -49,23 +52,44 @@ afterEach(() => {
49
52
  });
50
53
  const brokerPid = () => h.node(id).pi_pid;
51
54
  // ---------------------------------------------------------------------------
52
- // G4 — arbitration + observer read. Guards: 2nd client is admitted observer, an
53
- // observer prompt is rejected not_controller, BOTH clients receive the relay.
54
- // Failure mode: two controllers, or an observer driving the engine, or fan-out
55
- // that misses a viewer.
55
+ // G4 — multi-writer admission + read-only gating. Guards: two simultaneous
56
+ // `controller` hellos are BOTH welcomed writable, each one's own submission
57
+ // reaches the engine, and every connected client sees BOTH turns; an explicit
58
+ // observer that tries to drive is rejected with error{read_only}.
59
+ // Failure mode: a singleton writer slot demoting the second client, a writer
60
+ // whose prompt is silently dropped, fan-out that misses a viewer, or a
61
+ // read-only client that can drive the engine.
56
62
  // ---------------------------------------------------------------------------
57
- test('G4 — second client is observer; observer prompt → error{not_controller}; both receive the stream', async () => {
58
- const c1 = await ctrl('g4-ctrl');
59
- assert.equal(c1.welcome.role, 'controller', 'first client holds control');
60
- const c2 = await attach(id, 'controller', 'g4-second'); // requests control; held → observer
61
- assert.equal(c2.welcome.role, 'observer', 'second client is admitted read-only observer (first-attach-wins)');
62
- c2.send({ type: 'prompt', text: 'observer must not drive' });
63
- const err = await c2.waitFrame((f) => f.type === 'error', 'G4 observer prompt rejected');
64
- assert.equal(err.code, 'not_controller', 'G4: observer prompt → error{not_controller}');
65
- const token = tok('G4-BROADCAST');
66
- c1.send({ type: 'prompt', text: token });
67
- await c1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G4 controller received the stream');
68
- await c2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G4 observer ALSO received the stream');
63
+ test('G4 — two simultaneous clients are both welcomed controller, each submits, and both see both turns; an observer drive → error{read_only}', async () => {
64
+ const [w1, w2] = await Promise.all([
65
+ attach(id, 'controller', 'g4-writer-1'),
66
+ attach(id, 'controller', 'g4-writer-2'),
67
+ ]);
68
+ assert.equal(w1.welcome.role, 'controller', 'first client is welcomed writable');
69
+ assert.equal(w2.welcome.role, 'controller', 'second SIMULTANEOUS client is ALSO welcomed writable (no singleton slot)');
70
+ const observer = await attach(id, 'observer', 'g4-observer');
71
+ assert.equal(observer.welcome.role, 'observer', 'an explicit observer hello stays read-only');
72
+ // A read-only client may not drive the engine — the role is per-client and
73
+ // fixed by its own hello, so this rejection is independent of who else is
74
+ // attached.
75
+ observer.send({ type: 'prompt', text: 'a read-only client must not drive' });
76
+ const err = await observer.waitFrame((f) => f.type === 'error', 'G4 observer prompt rejected');
77
+ assert.equal(err.code, 'read_only', 'G4: observer prompt → error{read_only}');
78
+ // Each writer submits its OWN token. Submissions are sequenced (a prompt sent
79
+ // mid-stream routes as a steer and produces no second turn), so each turn is
80
+ // driven to its terminal agent_end before the next writer submits.
81
+ const tokenA = tok('G4-WRITER-1');
82
+ w1.send({ type: 'prompt', text: tokenA });
83
+ await w1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenA), 'G4 writer 1 saw its own turn');
84
+ const tokenB = tok('G4-WRITER-2');
85
+ w2.send({ type: 'prompt', text: tokenB });
86
+ await w2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenB), 'G4 writer 2 saw its own turn');
87
+ // The merged stream: EVERY connected client — both writers and the read-only
88
+ // observer — receives BOTH writers' turns.
89
+ await w2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenA), 'G4 writer 2 ALSO received writer 1 turn');
90
+ await w1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenB), 'G4 writer 1 ALSO received writer 2 turn');
91
+ await observer.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenA), 'G4 observer received writer 1 turn');
92
+ await observer.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenB), 'G4 observer received writer 2 turn');
69
93
  });
70
94
  // ---------------------------------------------------------------------------
71
95
  // G7 — decoder overflow (guards C5 OOM). A client line over BROKER_READ_CAPS is
@@ -32,9 +32,9 @@
32
32
  // The engine is hosted IN-PROCESS by the broker, so engine pid == broker pid ==
33
33
  // node.pi_pid == boot.pid; "engine pid unchanged" == broker pid unchanged + no new
34
34
  // boot. ONE shared broker is booted in before() and reused across all three gates
35
- // (each just attaches fresh clients) — 1 real boot total, not 3. A gate whose
36
- // first attach must hold control uses attachUntil(controller) so the prior gate's
37
- // controller-detach handoff settles deterministically before it drives.
35
+ // (each just attaches fresh clients) — 1 real boot total, not 3. Each gate attaches
36
+ // a fresh writable client; afterEach closes prior sockets before the next gate begins,
37
+ // and each client's role remains fixed for its socket.
38
38
  import { test, before, after, afterEach } from 'node:test';
39
39
  import assert from 'node:assert/strict';
40
40
  import { createHeadlessHarness } from '../helpers/harness.js';
@@ -44,7 +44,7 @@ let h;
44
44
  let id; // ONE shared broker, reused across G1/G1b/G2 (1 real boot, not 3)
45
45
  const kit = createAttachKit(() => h);
46
46
  const { attach, attachUntil } = kit;
47
- // Admit a controller, waiting out any prior gate's controller-detach handoff.
47
+ // Attach a writable client for the gate; its role remains fixed for this socket.
48
48
  const ctrl = (cid) => attachUntil(id, 'controller', cid, (a) => a.welcome.role === 'controller', `${cid} admitted controller`);
49
49
  before(async () => {
50
50
  h = await createHeadlessHarness({ sessionPrefix: 'crtr-brkstrm' });
@@ -3,31 +3,35 @@
3
3
  // FULL TIER (real-boot-bound): tmux-free, but it boots a REAL broker process
4
4
  // (~5s pi-SDK load), so it lives in full/ (CI), not the fast local loop.
5
5
  //
6
- // Broker dialog handling — the T8 attach gates G5/G5b/G6 cover retained answers,
7
- // controller handoff, and attended-dialog timeout behavior.
6
+ // Broker dialog handling under MULTI-WRITER attachments — the T8 attach gates
7
+ // G5/G5b/G6 cover fan-out + first-response settlement, mid-dialog attach replay,
8
+ // and attended-dialog timeout behavior.
8
9
  //
9
10
  // 3-PART HEADER (headless):
10
- // (1) CONTRACT — a controller receives a blocking dialog as
11
- // extension_ui_request, and extension_ui_response unblocks the engine with
12
- // the controller's answer rather than the default (G5). A dialog raised under
13
- // one controller remains pending through its detach and reaches the next
14
- // controller through welcome.pending_dialog (G5b). An attended dialog that is
15
- // not answered resolves on the short per-dialog timeout with the default answer
16
- // and a correlated extension_ui_dismiss (G6).
11
+ // (1) CONTRACT — every client whose `hello` claimed the writable role is fanned a
12
+ // blocking dialog as extension_ui_request; the FIRST extension_ui_response
13
+ // settles it with that answer (not the default), every fanned client receives
14
+ // the correlated extension_ui_dismiss, and a later peer response for the same
15
+ // id is a silent no-op (G5). A dialog stays pending through a peer's detach and
16
+ // is handed to any writable client attaching mid-dialog via
17
+ // welcome.pending_dialog; observers never receive an answerable dialog (G5b).
18
+ // An attended dialog nobody answers resolves on the short per-dialog timeout
19
+ // with the default answer and a correlated dismiss to EVERY writable peer (G6).
17
20
  // (2) WHY BROKER/SOCKET-LEVEL, NOT PANE/WINDOW — the dialog round-trip is an async
18
- // promise inside makeBrokerUiContext, forwarded and answered over view.sock to
21
+ // promise inside makeBrokerUiContext, fanned out and answered over view.sock to
19
22
  // the production ViewSocketClient. The real broker process provides the
20
23
  // uiContext and timeout machinery without a tmux pane, window, or pi-session
21
24
  // read.
22
25
  // (3) HOW THE HEADLESS DRIVE ASSERTS THE CONTRACT — the fake engine raises real
23
- // confirm() dialogs through the broker, and the tests assert on controller
24
- // frames plus the value and latency recorded in fake-pi.dialog.jsonl.
26
+ // confirm() dialogs through the broker, and the tests assert on the frames of
27
+ // several concurrently attached clients plus the value and latency recorded in
28
+ // fake-pi.dialog.jsonl.
25
29
  //
26
30
  // One shared broker is booted in before() and reused across all three gates. Because
27
31
  // fake-pi.dialog.jsonl is append-only across the shared broker, each gate snapshots
28
- // the count before it drives (`base`) and asserts on its newly appended entry. Gates
29
- // whose first attach must hold control use attachUntil(controller) to settle a prior
30
- // gate's controller-detach handoff.
32
+ // the count before it drives (`base`) and asserts on its newly appended entry. Role
33
+ // is fixed by `hello` and never reassigned, so every writable attach is a plain
34
+ // attach — there is no control handoff to wait out.
31
35
  import { test, before, after, afterEach } from 'node:test';
32
36
  import assert from 'node:assert/strict';
33
37
  import { createHeadlessHarness } from '../helpers/harness.js';
@@ -36,9 +40,29 @@ import { createAttachKit } from '../helpers/broker-clients.js';
36
40
  let h;
37
41
  let id; // ONE shared broker, reused across G5/G5b/G6 (1 real boot, not 3)
38
42
  const kit = createAttachKit(() => h);
39
- const { attachUntil } = kit;
40
- // Admit a controller, waiting out any prior gate's controller-detach handoff.
41
- const ctrl = (cid) => attachUntil(id, 'controller', cid, (a) => a.welcome.role === 'controller', `${cid} admitted controller`);
43
+ const { attach, attachUntil } = kit;
44
+ // Attach a writable client. Every `hello` claiming `controller` is admitted
45
+ // writable — attaching a peer never demotes an already-attached one.
46
+ const writer = async (cid) => {
47
+ const a = await attach(id, 'controller', cid);
48
+ assert.equal(a.welcome.role, 'controller', `${cid}: admitted writable, not demoted by an existing peer`);
49
+ return a;
50
+ };
51
+ const observer = async (cid) => {
52
+ const a = await attach(id, 'observer', cid);
53
+ assert.equal(a.welcome.role, 'observer', `${cid}: an observer hello stays read-only`);
54
+ return a;
55
+ };
56
+ // The broker handles one client's frames in order, so an `ack` for a get_commands
57
+ // sent AFTER some other frame proves that earlier frame was already processed.
58
+ // Used to make "a late peer answer changed nothing" a deterministic assertion
59
+ // instead of a sleep.
60
+ const barrier = async (c, label) => {
61
+ c.send({ type: 'get_commands' });
62
+ await c.waitFrame((f) => f.type === 'ack' && f.for === 'get_commands', label);
63
+ };
64
+ const isRequest = (f) => f.type === 'extension_ui_request';
65
+ const dismissFor = (reqId) => (f) => f.type === 'extension_ui_dismiss' && f.id === reqId;
42
66
  // dialog.jsonl is append-only across the shared broker — snapshot the count, then
43
67
  // wait for AT LEAST `want` new entries and return the full (cumulative) array.
44
68
  const dialogCount = () => h.dialogResults(id).length;
@@ -57,65 +81,100 @@ afterEach(() => {
57
81
  });
58
82
  const brokerPid = () => h.node(id).pi_pid;
59
83
  // ---------------------------------------------------------------------------
60
- // G5 — dialog forward + answer. Guards: a blocking dialog reaches the controller
61
- // as extension_ui_request and the controller's extension_ui_response unblocks the
62
- // engine with ITS answer (not the default). Failure mode: a dialog the controller
63
- // can't see/answer (silent deadlock).
84
+ // G5 — fan-out + first-response settlement. Guards: ONE blocking dialog reaches
85
+ // EVERY writable client (never a single owner), the first answer unblocks the
86
+ // engine with ITS value (not the default), every fanned client gets the correlated
87
+ // extension_ui_dismiss, and a peer answering afterwards is a no-op. Failure modes:
88
+ // a dialog only one viewer can see (the others sit blind), peers left holding an
89
+ // overlay nothing can answer, or a late peer answer re-settling a dead dialog.
64
90
  // ---------------------------------------------------------------------------
65
- test('G5 — controller receives an extension_ui_request, answers it, and the engine proceeds with that answer', async () => {
91
+ test('G5 — one dialog fans out to both writable clients, the first response settles it, both are dismissed, and a late peer response is a no-op', async () => {
66
92
  const base = dialogCount();
67
- const c = await ctrl('g5-ctrl');
68
- h.fakeCmd(id, { cmd: 'dialog', timeout: 20_000 }); // generous: the controller answers first
69
- const req = await c.waitFrame((f) => f.type === 'extension_ui_request', 'G5 dialog forwarded to controller');
70
- assert.equal(req.method, 'confirm', 'G5: the forwarded dialog is the confirm() the engine raised');
71
- const reqId = req.id;
72
- c.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
73
- const results = await awaitDialogs(base + 1, 'G5 dialog resolved by the controller');
74
- assert.equal(results[base].resolved, true, 'G5: the engine proceeded with the controller answer (true), not the default (false)');
93
+ const a = await writer('g5-A');
94
+ const b = await writer('g5-B');
95
+ const obs = await observer('g5-obs'); // must never be fanned an answerable dialog
96
+ h.fakeCmd(id, { cmd: 'dialog', timeout: 20_000 }); // generous: a writable client answers first
97
+ const reqA = await a.waitFrame(isRequest, 'G5 dialog fanned out to writable A');
98
+ const reqB = await b.waitFrame(isRequest, 'G5 dialog fanned out to writable B');
99
+ const reqId = reqA.id;
100
+ assert.equal(reqB.id, reqId, 'G5: both writable clients were fanned the SAME dialog id, not two dialogs');
101
+ assert.equal(reqA.method, 'confirm', 'G5: the fanned dialog is the confirm() the engine raised');
102
+ // A answers first — the engine must proceed with A's true, not the false default.
103
+ a.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
104
+ await a.waitFrame(dismissFor(reqId), 'G5 answerer A receives the correlated dismiss');
105
+ await b.waitFrame(dismissFor(reqId), 'G5 peer B receives the correlated dismiss for the dialog it never answered');
106
+ const results = await awaitDialogs(base + 1, 'G5 dialog settled by the first response');
107
+ assert.equal(results[base].resolved, true, 'G5: the engine proceeded with the first answer (true), not the default (false)');
108
+ // The observer was attached the whole time: it saw the broadcast dismiss but was
109
+ // never handed the answerable request.
110
+ await obs.waitFrame(dismissFor(reqId), 'G5 observer sees the broadcast dismiss (ordering barrier)');
111
+ assert.equal(obs.frames.filter(isRequest).length, 0, 'G5: an observer is never fanned an answerable dialog');
112
+ assert.equal(obs.welcome.pending_dialog, null, 'G5: an observer welcome never carries a pending dialog');
113
+ // B answers the already-settled dialog. The broker deleted the entry on the first
114
+ // response, so this finds nothing and is a silent no-op: no second engine
115
+ // resolution, and the recorded answer stays A's.
116
+ b.send({ type: 'extension_ui_response', id: reqId, confirmed: false });
117
+ await barrier(b, 'G5 broker processed B late response (ordered ack)');
118
+ const settled = h.dialogResults(id);
119
+ assert.equal(settled.length, base + 1, 'G5: the late peer response resolved nothing — no second dialog entry');
120
+ assert.equal(settled[base].resolved, true, "G5: the late peer response did not overwrite the first answerer's value");
121
+ assert.equal(isPidAlive(brokerPid()), true, 'G5: the late no-op response did not fault the broker');
75
122
  });
76
123
  // ---------------------------------------------------------------------------
77
- // G5 (mid-dialog attach) — Guards: a dialog raised under a prior controller stays
78
- // pending across that controller's detach (M2) and is delivered to whoever takes
79
- // control next via welcome.pending_dialog. Failure mode: a pending dialog lost on
80
- // controller handoff.
124
+ // G5b (mid-dialog attach) — Guards: a pending dialog survives a writable peer's
125
+ // detach (M2) and is handed to a writable client that attaches MID-dialog via
126
+ // welcome.pending_dialog, which makes it a full peer in the race to answer. An
127
+ // observer attaching mid-dialog gets nothing answerable. Failure modes: a pending
128
+ // dialog lost when the raising viewer detaches, a late-joining writable viewer
129
+ // blind to an in-flight dialog, or an observer handed one it cannot answer.
81
130
  // ---------------------------------------------------------------------------
82
- test('G5 — a controller attaching MID-dialog receives the pending dialog via welcome.pending_dialog', async () => {
131
+ test('G5b — a writable client attaching MID-dialog receives the pending dialog; an observer does not', async () => {
83
132
  const base = dialogCount();
84
- const a = await ctrl('g5b-A');
85
- h.fakeCmd(id, { cmd: 'dialog', timeout: 30_000 }); // stays pending long enough for the handoff
86
- const reqA = await a.waitFrame((f) => f.type === 'extension_ui_request', 'G5b dialog forwarded to controller A');
133
+ const a = await writer('g5b-A');
134
+ h.fakeCmd(id, { cmd: 'dialog', timeout: 30_000 }); // stays pending long enough for the late attach
135
+ const reqA = await a.waitFrame(isRequest, 'G5b dialog fanned out to writable A');
87
136
  const reqId = reqA.id;
88
137
  a.send({ type: 'bye' });
89
- a.close(); // M2: detach frees control but does NOT cancel the pending dialog
90
- // Controller B takes control (retry covers the close→controllerId=null beat) and
91
- // its welcome carries the still-pending dialog.
92
- const b = await attachUntil(id, 'controller', 'g5b-B', (x) => x.welcome.role === 'controller' && x.welcome.pending_dialog != null, 'G5b controller B takes control with the pending dialog');
93
- assert.equal(b.welcome.pending_dialog.id, reqId, 'G5b: welcome.pending_dialog is the same dialog raised under A');
138
+ a.close(); // M2: a writable peer detaching does NOT cancel the pending dialog
139
+ // An observer attaching mid-dialog is never handed it.
140
+ const obs = await observer('g5b-obs');
141
+ assert.equal(obs.welcome.pending_dialog, null, 'G5b: an observer attaching mid-dialog gets no pending dialog');
142
+ // A writable client attaching mid-dialog IS handed it (retry covers the beat
143
+ // between the raise and this attach) and can settle it.
144
+ const b = await attachUntil(id, 'controller', 'g5b-B', (x) => x.welcome.role === 'controller' && x.welcome.pending_dialog != null, 'G5b writable B attaches mid-dialog and is handed the pending dialog');
145
+ assert.equal(b.welcome.pending_dialog.id, reqId, 'G5b: welcome.pending_dialog is the same dialog raised while A was attached');
94
146
  assert.equal(b.welcome.pending_dialog.method, 'confirm', 'G5b: the pending dialog is the confirm()');
95
147
  b.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
96
- const results = await awaitDialogs(base + 1, 'G5b dialog resolved by controller B');
97
- assert.equal(results[base].resolved, true, 'G5b: controller B answered the handed-off dialog and the engine proceeded');
148
+ const results = await awaitDialogs(base + 1, 'G5b dialog settled by the mid-dialog joiner');
149
+ assert.equal(results[base].resolved, true, 'G5b: the mid-dialog joiner answered and the engine proceeded');
150
+ await obs.waitFrame(dismissFor(reqId), 'G5b dismiss broadcast follows the settlement (ordering barrier)');
151
+ assert.equal(obs.frames.filter(isRequest).length, 0, 'G5b: the observer was never fanned the dialog it saw dismissed');
98
152
  });
99
153
  // ---------------------------------------------------------------------------
100
- // G6 — anti-deadlock. An ATTENDED dialog the controller never answers resolves on
101
- // a SHORT per-dialog broker timeout. Guards: the engine never hangs on a dialog
102
- // with no answerer. Failure mode: a forever-blocked turn.
154
+ // G6 — anti-deadlock. An ATTENDED dialog NO writable client answers resolves on a
155
+ // SHORT per-dialog broker timeout, and every fanned peer is dismissed by id.
156
+ // Guards: the engine never hangs on a dialog whose viewers all stay silent.
157
+ // Failure mode: a forever-blocked turn, or overlays left up on every viewer.
103
158
  // ---------------------------------------------------------------------------
104
- test('G6 — an unanswered attended dialog resolves on the broker timeout', async () => {
159
+ test('G6 — an attended dialog no writable client answers resolves on the broker timeout and dismisses every peer', async () => {
105
160
  const pid = brokerPid();
106
161
  const base = dialogCount();
107
- // A controller attached but silent → the broker resolves on the SHORT explicit
108
- // per-dialog timeout (800ms), never the 120s default. On timeout the broker also
109
- // broadcasts a correlated `extension_ui_dismiss` keyed to the dialog's id, so the
110
- // controller's overlay (which has no independent timer) is torn down by id.
111
- const c = await ctrl('g6-ctrl');
162
+ // Two writable clients attached but silent → the broker resolves on the SHORT
163
+ // explicit per-dialog timeout (800ms), never the 120s default. On timeout it
164
+ // broadcasts a correlated `extension_ui_dismiss` keyed to the dialog's id, so
165
+ // EVERY peer's overlay (none has an independent timer) is torn down by id.
166
+ const a = await writer('g6-A');
167
+ const b = await writer('g6-B');
112
168
  h.fakeCmd(id, { cmd: 'dialog', timeout: 800 });
113
- const req = await c.waitFrame((f) => f.type === 'extension_ui_request', 'G6b dialog forwarded to controller'); // received, deliberately NOT answered
114
- const reqId = req.id;
115
- const dismiss = await c.waitFrame((f) => f.type === 'extension_ui_dismiss' && f.id === reqId, 'G6b broker emits a correlated extension_ui_dismiss on timeout');
116
- assert.equal(dismiss.id, reqId, 'G6b: the dismiss frame carries the timed-out dialog id, not another');
117
- const results = await awaitDialogs(base + 1, 'G6b attended dialog resolved on timeout');
118
- assert.equal(results[base].resolved, false, 'G6b: an unanswered attended dialog resolves to the default (deny)');
119
- assert.ok(results[base].ms >= 600 && results[base].ms < 5000, `G6b: resolved on the ~800ms per-dialog timeout, not instantly and not the 120s default — got ${results[base].ms}ms`);
169
+ const reqA = await a.waitFrame(isRequest, 'G6 dialog fanned out to writable A'); // received, deliberately NOT answered
170
+ const reqB = await b.waitFrame(isRequest, 'G6 dialog fanned out to writable B'); // received, deliberately NOT answered
171
+ const reqId = reqA.id;
172
+ assert.equal(reqB.id, reqId, 'G6: both silent peers hold the SAME dialog id');
173
+ const dismissA = await a.waitFrame(dismissFor(reqId), 'G6 broker emits a correlated dismiss to A on timeout');
174
+ await b.waitFrame(dismissFor(reqId), 'G6 broker emits the same correlated dismiss to peer B');
175
+ assert.equal(dismissA.id, reqId, 'G6: the dismiss frame carries the timed-out dialog id, not another');
176
+ const results = await awaitDialogs(base + 1, 'G6 attended dialog resolved on timeout');
177
+ assert.equal(results[base].resolved, false, 'G6: a dialog no writable client answered resolves to the default (deny)');
178
+ assert.ok(results[base].ms >= 600 && results[base].ms < 5000, `G6: resolved on the ~800ms per-dialog timeout, not instantly and not the 120s default — got ${results[base].ms}ms`);
120
179
  assert.equal(isPidAlive(pid), true, 'G6: the engine made forward progress (still alive)');
121
180
  });