@north-light/crouter 0.3.157 → 0.3.159

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 (92) 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-memory/internal/storage-tiers.md +2 -0
  8. package/dist/builtin-views/chat/core.mjs +6 -51
  9. package/dist/builtin-views/chat/tui.mjs +6 -14
  10. package/dist/builtin-views/chat/web.jsx +2 -7
  11. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
  12. package/dist/clients/attach/chrome/bash-jobs.d.ts +0 -5
  13. package/dist/clients/attach/chrome/bash-jobs.js +5 -24
  14. package/dist/clients/attach/chrome/roster.d.ts +4 -3
  15. package/dist/clients/attach/chrome/roster.js +20 -36
  16. package/dist/clients/attach/command.js +5 -5
  17. package/dist/clients/attach/input/controller.d.ts +23 -8
  18. package/dist/clients/attach/input/controller.js +59 -29
  19. package/dist/clients/attach/overlays/dialogs.d.ts +1 -2
  20. package/dist/clients/attach/overlays/graph.d.ts +4 -1
  21. package/dist/clients/attach/overlays/graph.js +25 -7
  22. package/dist/clients/attach/session/context.d.ts +7 -0
  23. package/dist/clients/attach/session/frames.js +1 -8
  24. package/dist/clients/attach/session/input-wiring.d.ts +1 -1
  25. package/dist/clients/attach/session/input-wiring.js +21 -11
  26. package/dist/clients/attach/session/mode.d.ts +4 -3
  27. package/dist/clients/attach/session/mode.js +6 -1
  28. package/dist/clients/attach/session/reconnect.d.ts +3 -3
  29. package/dist/clients/attach/session/reconnect.js +6 -7
  30. package/dist/clients/attach/session/state-sync.d.ts +1 -1
  31. package/dist/clients/attach/session/state-sync.js +2 -2
  32. package/dist/clients/attach/slash/dispatch.d.ts +13 -1
  33. package/dist/clients/attach/slash/dispatch.js +65 -17
  34. package/dist/clients/attach/viewer.js +497 -499
  35. package/dist/clients/web/web-client/shared/protocol.d.ts +5 -3
  36. package/dist/commands/node.js +142 -4
  37. package/dist/commands/surface-inspect.js +4 -3
  38. package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
  39. package/dist/core/__tests__/canvas-inbox-watcher.test.js +5 -3
  40. package/dist/core/__tests__/chat-view-reconnect.test.js +44 -23
  41. package/dist/core/__tests__/full/broker-attach-limits.test.js +60 -36
  42. package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
  43. package/dist/core/__tests__/full/broker-dialogs.test.js +121 -62
  44. package/dist/core/__tests__/helpers/broker-clients.js +2 -2
  45. package/dist/core/__tests__/session-model.test.js +15 -26
  46. package/dist/core/bash-jobs.d.ts +11 -0
  47. package/dist/core/bash-jobs.js +41 -1
  48. package/dist/core/feed/inbox.d.ts +3 -3
  49. package/dist/core/feed/inbox.js +10 -6
  50. package/dist/core/inspector/core.d.ts +17 -2
  51. package/dist/core/inspector/core.js +172 -23
  52. package/dist/core/inspector/model.d.ts +30 -1
  53. package/dist/core/inspector/model.js +39 -0
  54. package/dist/core/inspector/text.js +14 -1
  55. package/dist/core/inspector/tui.js +71 -3
  56. package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
  57. package/dist/core/keybindings/catalog.d.ts +2 -2
  58. package/dist/core/keybindings/catalog.js +3 -4
  59. package/dist/core/runtime/auth-reload.d.ts +4 -4
  60. package/dist/core/runtime/auth-reload.js +9 -13
  61. package/dist/core/runtime/boot-root.d.ts +2 -2
  62. package/dist/core/runtime/boot-root.js +7 -7
  63. package/dist/core/runtime/broker-protocol.d.ts +23 -27
  64. package/dist/core/runtime/broker-protocol.js +1 -1
  65. package/dist/core/runtime/broker-request.js +5 -11
  66. package/dist/core/runtime/broker.d.ts +21 -13
  67. package/dist/core/runtime/broker.js +151 -186
  68. package/dist/core/runtime/interactive-deliver.js +4 -5
  69. package/dist/core/runtime/model-swap.d.ts +2 -3
  70. package/dist/core/runtime/model-swap.js +3 -4
  71. package/dist/core/runtime/node-read.d.ts +20 -0
  72. package/dist/core/runtime/node-read.js +34 -1
  73. package/dist/core/runtime/resume-root.d.ts +1 -1
  74. package/dist/core/runtime/resume-root.js +6 -6
  75. package/dist/core/runtime/spawn.js +3 -3
  76. package/dist/core/session-model/session-state.d.ts +8 -6
  77. package/dist/core/session-model/session-state.js +6 -16
  78. package/dist/core/tui/host.js +21 -6
  79. package/dist/daemon/api/handlers/nodes.js +18 -1
  80. package/dist/daemon/manage.js +2 -2
  81. package/dist/index.d.ts +1 -1
  82. package/dist/pi-extensions/canvas-bash-valve.js +9 -3
  83. package/dist/web-client/assets/{index-DJhQZoAj.css → index-CpEl9LTS.css} +1 -1
  84. package/dist/web-client/assets/{index--SsQYcKu.js → index-CsuwzlcQ.js} +19 -19
  85. package/dist/web-client/index.html +2 -2
  86. package/dist/web-client/sw.js +1 -1
  87. package/docs/compat/hearth-crtr-v5.md +175 -0
  88. package/docs/public-api.md +2 -2
  89. package/package.json +2 -2
  90. package/runtime.lock.json +6 -6
  91. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +0 -1
  92. package/dist/core/__tests__/full/broker-control-preempt.test.js +0 -61
@@ -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
  });
@@ -91,8 +91,8 @@ export function createAttachKit(getH) {
91
91
  return { client, frames, welcome, send: (f) => client.send(f), waitFrame, close: () => client.close() };
92
92
  }
93
93
  // Attach (as `role`) and retry until the welcome satisfies `pred` — used where the
94
- // observable lands a beat after a prior action (G3 snapshot accrual, G5b control
95
- // handoff after a controller detaches). Deterministic: it polls an observable.
94
+ // observable lands a beat after a prior action, such as snapshot accrual or
95
+ // persisted presence after a socket closes. Deterministic: it polls an observable.
96
96
  async function attachUntil(id, role, clientId, pred, label) {
97
97
  for (let attempt = 0;; attempt++) {
98
98
  const a = await attach(id, role, `${clientId}-${attempt}`);
@@ -16,7 +16,6 @@
16
16
  import { test } from 'node:test';
17
17
  import assert from 'node:assert/strict';
18
18
  import { applySnapshot, initialSessionState, reduce, } from '../session-model/session-state.js';
19
- const CLIENT = 'viewer-1';
20
19
  const engine = (patch = {}) => ({
21
20
  model: undefined,
22
21
  thinkingLevel: 'off',
@@ -35,7 +34,6 @@ const engine = (patch = {}) => ({
35
34
  const welcome = (over = {}, state = engine(), display) => ({
36
35
  type: 'welcome',
37
36
  role: 'controller',
38
- controller_id: CLIENT,
39
37
  agentDir: '/home/u/.pi/agent',
40
38
  snapshot: {
41
39
  messages: [],
@@ -45,7 +43,7 @@ const welcome = (over = {}, state = engine(), display) => ({
45
43
  },
46
44
  ...over,
47
45
  });
48
- const fold = (from, frames) => frames.reduce((s, f) => reduce(s, f, CLIENT), from);
46
+ const fold = (from, frames) => frames.reduce((s, f) => reduce(s, f), from);
49
47
  test('applySnapshot(welcome) equals folding the edges that produced the same broker state', () => {
50
48
  // The live path: a viewer attached from t=0 watches these edges go by.
51
49
  const edges = [
@@ -75,42 +73,42 @@ test('applySnapshot(welcome) equals folding the edges that produced the same bro
75
73
  assert.deepEqual(late, live, 'the two ways to learn a session must agree');
76
74
  });
77
75
  test('agent_settled clears isStreaming (the frozen-Working… fix, now shared by both viewers)', () => {
78
- const s0 = reduce(initialSessionState(), welcome(), CLIENT);
79
- const streaming = reduce(s0, { type: 'agent_start' }, CLIENT);
76
+ const s0 = reduce(initialSessionState(), welcome());
77
+ const streaming = reduce(s0, { type: 'agent_start' });
80
78
  assert.equal(streaming.engine?.isStreaming, true);
81
79
  // agent_settled ALONE — no agent_end — must settle it. A dormant orchestrator
82
80
  // between wakes showed a frozen spinner when this was missed.
83
- const settled = reduce(streaming, { type: 'agent_settled' }, CLIENT);
81
+ const settled = reduce(streaming, { type: 'agent_settled' });
84
82
  assert.equal(settled.engine?.isStreaming, false);
85
83
  });
86
84
  test('a notify is never a dialog, and never disturbs one that is pending', () => {
87
- const s0 = reduce(initialSessionState(), welcome(), CLIENT);
88
- const asked = reduce(s0, { type: 'extension_ui_request', id: 'ask-1', method: 'input', title: 'Paste the code' }, CLIENT);
85
+ const s0 = reduce(initialSessionState(), welcome());
86
+ const asked = reduce(s0, { type: 'extension_ui_request', id: 'ask-1', method: 'input', title: 'Paste the code' });
89
87
  assert.equal(asked.dialog?.id, 'ask-1');
90
- const notified = reduce(asked, { type: 'extension_ui_request', id: 'n-1', method: 'notify', message: 'background progress' }, CLIENT);
88
+ const notified = reduce(asked, { type: 'extension_ui_request', id: 'n-1', method: 'notify', message: 'background progress' });
91
89
  assert.equal(notified, asked, 'a notify carries no session state — same reference, dialog untouched');
92
90
  // A display paint is equally inert with respect to the pending dialog.
93
- const painted = reduce(asked, { type: 'extension_ui_request', id: 's-1', method: 'setStatus', statusKey: 'k', statusText: 'v' }, CLIENT);
91
+ const painted = reduce(asked, { type: 'extension_ui_request', id: 's-1', method: 'setStatus', statusKey: 'k', statusText: 'v' });
94
92
  assert.equal(painted.dialog?.id, 'ask-1', 'a chrome paint must not clear a pending dialog');
95
93
  // And only the MATCHING correlated dismiss clears it.
96
- assert.equal(reduce(asked, { type: 'extension_ui_dismiss', id: 'other' }, CLIENT), asked);
97
- assert.equal(reduce(asked, { type: 'extension_ui_dismiss', id: 'ask-1' }, CLIENT).dialog, null);
94
+ assert.equal(reduce(asked, { type: 'extension_ui_dismiss', id: 'other' }), asked);
95
+ assert.equal(reduce(asked, { type: 'extension_ui_dismiss', id: 'ask-1' }).dialog, null);
98
96
  });
99
97
  test('frames that carry no session state return the SAME state reference', () => {
100
- const s0 = reduce(initialSessionState(), welcome(), CLIENT);
98
+ const s0 = reduce(initialSessionState(), welcome());
101
99
  const inert = [
102
- { type: 'error', code: 'not_controller', message: 'read-only' },
100
+ { type: 'error', code: 'read_only', message: 'read-only' },
103
101
  { type: 'ack', for: 'get_commands', ok: true, detail: '[]' },
104
102
  { type: 'bash_start', command: 'ls' },
105
103
  { type: 'bash_output', chunk: 'a\n' },
106
104
  { type: 'bash_end', exitCode: 0, cancelled: false, truncated: false },
107
105
  ];
108
106
  for (const f of inert) {
109
- assert.equal(reduce(s0, f, CLIENT), s0, `${f.type} must not allocate a new state`);
107
+ assert.equal(reduce(s0, f), s0, `${f.type} must not allocate a new state`);
110
108
  }
111
109
  // Slice identity is what viewers repaint off: an engine-only change must leave
112
110
  // the display slice untouched by reference.
113
- const started = reduce(s0, { type: 'agent_start' }, CLIENT);
111
+ const started = reduce(s0, { type: 'agent_start' });
114
112
  assert.notEqual(started, s0);
115
113
  assert.equal(started.display, s0.display, 'an engine change must not dirty the display slice');
116
114
  });
@@ -125,16 +123,7 @@ test('a welcome is a COMPLETE catch-up: a re-welcome drops a dead broker s level
125
123
  assert.equal(s0.engine?.pendingMessageCount, 1);
126
124
  // A fresh broker (post-revive) welcomes with its OWN levels and an empty queue,
127
125
  // and emits no queue_update to overwrite the panel — so the welcome must.
128
- const s1 = reduce(s0, welcome(), CLIENT);
126
+ const s1 = reduce(s0, welcome());
129
127
  assert.deepEqual(s1.display.statuses, {}, 'a dead broker s status keys must not survive');
130
128
  assert.deepEqual(s1.queued, [], 'a dead broker s queued rows must not survive');
131
129
  });
132
- test('role follows the broker: control_changed re-derives it from our own client id', () => {
133
- const s0 = reduce(initialSessionState(), welcome({ role: 'controller', controller_id: CLIENT }), CLIENT);
134
- assert.equal(s0.role, 'controller');
135
- const demoted = reduce(s0, { type: 'control_changed', controller_id: 'someone-else' }, CLIENT);
136
- assert.equal(demoted.role, 'observer');
137
- assert.equal(demoted.controllerId, 'someone-else');
138
- const back = reduce(demoted, { type: 'control_changed', controller_id: CLIENT }, CLIENT);
139
- assert.equal(back.role, 'controller');
140
- });
@@ -5,9 +5,20 @@ export interface BashJobPaths {
5
5
  jobLog: string;
6
6
  jobExit: string;
7
7
  jobBg: string;
8
+ /** Process-group id of the job's detached supervisor, written at spawn. It is
9
+ * what makes a job stoppable from outside the agent that started it (the
10
+ * Inspector's cancel) — without it on disk, only the agent's own handoff
11
+ * notice knows the group to signal. */
12
+ jobPgid: string;
8
13
  }
9
14
  export declare function bashJobsDir(contextDir: string): string;
10
15
  export declare function bashJobPaths(contextDir: string, jobId: string): BashJobPaths;
16
+ /** The job's supervisor process group, or undefined for a job started before
17
+ * pgid was persisted (or a partially-written dir). */
18
+ export declare function readJobPgid(contextDir: string, jobId: string): number | undefined;
19
+ /** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
20
+ * rather than the whole file, which for a long-running job can be enormous. */
21
+ export declare function tailJobLog(logPath: string, count: number): string[];
11
22
  /** Mint a fresh job id — `${Date.now()}-${hex}`. The sole minting site; the
12
23
  * valve calls this instead of building the literal inline so the epoch-ms
13
24
  * prefix is a real contract shared with its parser (jobStartedAtMs), not an
@@ -3,7 +3,7 @@
3
3
  // the prefix menu requests backgrounding by creating job.bg. Keeping that signal
4
4
  // on disk means the command can outlive its pi process and still report its exit.
5
5
  import { randomBytes } from 'node:crypto';
6
- import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  export function bashJobsDir(contextDir) {
9
9
  return join(contextDir, 'jobs');
@@ -17,8 +17,48 @@ export function bashJobPaths(contextDir, jobId) {
17
17
  jobLog: join(dir, 'job.log'),
18
18
  jobExit: join(dir, 'job.exit'),
19
19
  jobBg: join(dir, 'job.bg'),
20
+ jobPgid: join(dir, 'job.pgid'),
20
21
  };
21
22
  }
23
+ /** The job's supervisor process group, or undefined for a job started before
24
+ * pgid was persisted (or a partially-written dir). */
25
+ export function readJobPgid(contextDir, jobId) {
26
+ try {
27
+ const n = Number(readFileSync(bashJobPaths(contextDir, jobId).jobPgid, 'utf8').trim());
28
+ return Number.isInteger(n) && n > 1 ? n : undefined;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ /** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
35
+ * rather than the whole file, which for a long-running job can be enormous. */
36
+ export function tailJobLog(logPath, count) {
37
+ if (count <= 0)
38
+ return [];
39
+ const WINDOW = 64 * 1024;
40
+ let fd;
41
+ try {
42
+ const size = statSync(logPath).size;
43
+ const start = Math.max(0, size - WINDOW);
44
+ const buf = Buffer.alloc(Math.min(size, WINDOW));
45
+ fd = openSync(logPath, 'r');
46
+ readSync(fd, buf, 0, buf.length, start);
47
+ const text = buf.toString('utf8');
48
+ // A partial first line (we cut mid-line) is dropped, not shown truncated.
49
+ const lines = (start > 0 ? text.slice(text.indexOf('\n') + 1) : text).split('\n');
50
+ if (lines[lines.length - 1] === '')
51
+ lines.pop();
52
+ return lines.slice(-count);
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ finally {
58
+ if (fd !== undefined)
59
+ closeSync(fd);
60
+ }
61
+ }
22
62
  /** Mint a fresh job id — `${Date.now()}-${hex}`. The sole minting site; the
23
63
  * valve calls this instead of building the literal inline so the epoch-ms
24
64
  * prefix is a real contract shared with its parser (jobStartedAtMs), not an
@@ -75,9 +75,9 @@ export declare function clipBody(body: string): {
75
75
  *
76
76
  * Format (per sender group):
77
77
  * From <sender> — <N> update(s):
78
- * [<kind>] <label> at <absolute report path>:
79
- *
80
- * <short report body>
78
+ * [<kind>]
79
+ * <short report body>
80
+ * (report: <absolute report path>)
81
81
  *
82
82
  * Large reports remain canonical pointers and add one shared dereference hint.
83
83
  */
@@ -330,15 +330,19 @@ function inlineReport(e) {
330
330
  /**
331
331
  * Render one entry's digest line(s).
332
332
  *
333
- * Short pushed reports are inlined with their absolute report path. Larger
334
- * reports stay canonical history pointers. Direct messages retain their bounded
333
+ * A short pushed report is inlined as its own body, followed by a pointer to
334
+ * the durable artifact. The label is NOT repeated above it: a report's label is
335
+ * just its first line (feed.ts `firstLine`), so printing both reads as a
336
+ * truncated preview stacked on the full text it previews. Larger reports stay
337
+ * canonical history pointers. Direct messages retain their bounded
335
338
  * inline-body/spill behavior.
336
339
  */
337
340
  function renderEntry(e) {
338
341
  const report = inlineReport(e);
339
342
  if (report !== null) {
343
+ const indented = report.body.trimEnd().split('\n').map((l) => ` ${l}`).join('\n');
340
344
  return {
341
- text: ` [${e.kind}] ${e.label} at ${report.path}:\n\n${report.body}`,
345
+ text: ` [${e.kind}]\n${indented}\n (report: ${report.path})`,
342
346
  hasCanonicalRef: false,
343
347
  };
344
348
  }
@@ -364,9 +368,9 @@ function renderEntry(e) {
364
368
  *
365
369
  * Format (per sender group):
366
370
  * From <sender> — <N> update(s):
367
- * [<kind>] <label> at <absolute report path>:
368
- *
369
- * <short report body>
371
+ * [<kind>]
372
+ * <short report body>
373
+ * (report: <absolute report path>)
370
374
  *
371
375
  * Large reports remain canonical pointers and add one shared dereference hint.
372
376
  */
@@ -1,6 +1,14 @@
1
1
  import type { CronDTO } from '../../api/dto/crons.js';
2
2
  import type { SourceError, ViewCore } from '../view/contract.js';
3
- import { scheduledItems, type InspectorSection } from './model.js';
3
+ import { scheduledItems, type BashJobRow, type InspectorSection } from './model.js';
4
+ /** What a `y` confirmation will act on. Both sections arm cancellation the same
5
+ * way, so the pending action carries its own kind rather than the view guessing
6
+ * from whichever section happens to be open when the key lands. */
7
+ export type PendingAction = {
8
+ kind: 'cron' | 'job';
9
+ id: string;
10
+ label: string;
11
+ };
4
12
  export interface InspectorState {
5
13
  target: string;
6
14
  section: InspectorSection;
@@ -14,10 +22,17 @@ export interface InspectorState {
14
22
  error: SourceError | null;
15
23
  fetchedAt: number;
16
24
  };
25
+ jobs: {
26
+ data: BashJobRow[] | null;
27
+ error: SourceError | null;
28
+ fetchedAt: number;
29
+ };
17
30
  selectedCronId: string | null;
31
+ selectedJobId: string | null;
18
32
  expanded: boolean;
19
- pendingCancel: string | null;
33
+ pendingCancel: PendingAction | null;
20
34
  scroll: Record<InspectorSection, number>;
21
35
  }
22
36
  export declare const inspectorCore: ViewCore<InspectorState>;
37
+ export declare function selectedJob(state: InspectorState): BashJobRow | undefined;
23
38
  export { scheduledItems };