@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.
- package/README.md +2 -1
- package/dist/api/client.d.ts +1 -4
- package/dist/api/client.js +0 -5
- package/dist/api/dto/broker.d.ts +1 -1
- package/dist/api/dto/nodes.d.ts +0 -15
- package/dist/api/routes.d.ts +0 -1
- package/dist/api/routes.js +0 -1
- package/dist/builtin-views/chat/core.mjs +51 -6
- package/dist/builtin-views/chat/tui.mjs +14 -6
- package/dist/builtin-views/chat/web.jsx +7 -2
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
- package/dist/clients/attach/chrome/roster.d.ts +1 -1
- package/dist/clients/attach/chrome/roster.js +1 -9
- package/dist/clients/attach/command.js +5 -5
- package/dist/clients/attach/input/controller.d.ts +8 -23
- package/dist/clients/attach/input/controller.js +29 -59
- package/dist/clients/attach/overlays/dialogs.d.ts +2 -1
- package/dist/clients/attach/overlays/graph.d.ts +1 -4
- package/dist/clients/attach/overlays/graph.js +7 -25
- package/dist/clients/attach/session/context.d.ts +0 -7
- package/dist/clients/attach/session/frames.js +8 -1
- package/dist/clients/attach/session/input-wiring.d.ts +1 -1
- package/dist/clients/attach/session/input-wiring.js +11 -21
- package/dist/clients/attach/session/mode.d.ts +3 -4
- package/dist/clients/attach/session/mode.js +1 -6
- package/dist/clients/attach/session/reconnect.d.ts +3 -3
- package/dist/clients/attach/session/reconnect.js +7 -6
- package/dist/clients/attach/session/state-sync.d.ts +1 -1
- package/dist/clients/attach/session/state-sync.js +2 -2
- package/dist/clients/attach/slash/dispatch.d.ts +1 -13
- package/dist/clients/attach/slash/dispatch.js +17 -65
- package/dist/clients/attach/viewer.js +523 -523
- package/dist/clients/web/web-client/shared/protocol.d.ts +3 -5
- package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
- package/dist/core/__tests__/chat-view-reconnect.test.js +23 -44
- package/dist/core/__tests__/full/broker-attach-limits.test.js +36 -60
- package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
- package/dist/core/__tests__/full/broker-dialogs.test.js +62 -121
- package/dist/core/__tests__/helpers/broker-clients.js +2 -2
- package/dist/core/__tests__/session-model.test.js +26 -15
- package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +2 -0
- package/dist/core/runtime/auth-reload.d.ts +4 -4
- package/dist/core/runtime/auth-reload.js +13 -9
- package/dist/core/runtime/boot-root.d.ts +2 -2
- package/dist/core/runtime/boot-root.js +7 -7
- package/dist/core/runtime/broker-protocol.d.ts +27 -23
- package/dist/core/runtime/broker-protocol.js +1 -1
- package/dist/core/runtime/broker-request.js +11 -5
- package/dist/core/runtime/broker.d.ts +13 -21
- package/dist/core/runtime/broker.js +186 -151
- package/dist/core/runtime/interactive-deliver.js +5 -4
- package/dist/core/runtime/model-swap.d.ts +3 -2
- package/dist/core/runtime/model-swap.js +4 -3
- package/dist/core/runtime/node-read.d.ts +0 -20
- package/dist/core/runtime/node-read.js +1 -34
- package/dist/core/runtime/resume-root.d.ts +1 -1
- package/dist/core/runtime/resume-root.js +6 -6
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/session-model/session-state.d.ts +6 -8
- package/dist/core/session-model/session-state.js +16 -6
- package/dist/daemon/api/handlers/nodes.js +1 -18
- package/dist/daemon/manage.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/web-client/assets/index--SsQYcKu.js +79 -0
- package/dist/web-client/assets/{index-CpEl9LTS.css → index-DJhQZoAj.css} +1 -1
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/docs/compat/hearth-crtr-v1.md +1 -1
- package/docs/compat/hearth-crtr-v2.md +1 -1
- package/docs/compat/hearth-crtr-v3.md +1 -1
- package/docs/compat/hearth-crtr-v4.md +3 -1
- package/docs/public-api.md +2 -2
- package/package.json +4 -4
- package/runtime.lock.json +2 -2
- package/dist/web-client/assets/index-BpyZGBhI.js +0 -79
- package/docs/compat/hearth-crtr-v5.md +0 -175
|
@@ -3,35 +3,31 @@
|
|
|
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
|
|
7
|
-
//
|
|
8
|
-
// and attended-dialog timeout behavior.
|
|
6
|
+
// Broker dialog handling — the T8 attach gates G5/G5b/G6 cover retained answers,
|
|
7
|
+
// controller handoff, and attended-dialog timeout behavior.
|
|
9
8
|
//
|
|
10
9
|
// 3-PART HEADER (headless):
|
|
11
|
-
// (1) CONTRACT —
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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).
|
|
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).
|
|
20
17
|
// (2) WHY BROKER/SOCKET-LEVEL, NOT PANE/WINDOW — the dialog round-trip is an async
|
|
21
|
-
// promise inside makeBrokerUiContext,
|
|
18
|
+
// promise inside makeBrokerUiContext, forwarded and answered over view.sock to
|
|
22
19
|
// the production ViewSocketClient. The real broker process provides the
|
|
23
20
|
// uiContext and timeout machinery without a tmux pane, window, or pi-session
|
|
24
21
|
// read.
|
|
25
22
|
// (3) HOW THE HEADLESS DRIVE ASSERTS THE CONTRACT — the fake engine raises real
|
|
26
|
-
// confirm() dialogs through the broker, and the tests assert on
|
|
27
|
-
//
|
|
28
|
-
// fake-pi.dialog.jsonl.
|
|
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.
|
|
29
25
|
//
|
|
30
26
|
// One shared broker is booted in before() and reused across all three gates. Because
|
|
31
27
|
// fake-pi.dialog.jsonl is append-only across the shared broker, each gate snapshots
|
|
32
|
-
// the count before it drives (`base`) and asserts on its newly appended entry.
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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.
|
|
35
31
|
import { test, before, after, afterEach } from 'node:test';
|
|
36
32
|
import assert from 'node:assert/strict';
|
|
37
33
|
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
@@ -40,29 +36,9 @@ import { createAttachKit } from '../helpers/broker-clients.js';
|
|
|
40
36
|
let h;
|
|
41
37
|
let id; // ONE shared broker, reused across G5/G5b/G6 (1 real boot, not 3)
|
|
42
38
|
const kit = createAttachKit(() => h);
|
|
43
|
-
const {
|
|
44
|
-
//
|
|
45
|
-
|
|
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;
|
|
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`);
|
|
66
42
|
// dialog.jsonl is append-only across the shared broker — snapshot the count, then
|
|
67
43
|
// wait for AT LEAST `want` new entries and return the full (cumulative) array.
|
|
68
44
|
const dialogCount = () => h.dialogResults(id).length;
|
|
@@ -81,100 +57,65 @@ afterEach(() => {
|
|
|
81
57
|
});
|
|
82
58
|
const brokerPid = () => h.node(id).pi_pid;
|
|
83
59
|
// ---------------------------------------------------------------------------
|
|
84
|
-
// G5 —
|
|
85
|
-
//
|
|
86
|
-
// engine with ITS
|
|
87
|
-
//
|
|
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.
|
|
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).
|
|
90
64
|
// ---------------------------------------------------------------------------
|
|
91
|
-
test('G5 —
|
|
65
|
+
test('G5 — controller receives an extension_ui_request, answers it, and the engine proceeds with that answer', async () => {
|
|
92
66
|
const base = dialogCount();
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
assert.equal(
|
|
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');
|
|
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)');
|
|
122
75
|
});
|
|
123
76
|
// ---------------------------------------------------------------------------
|
|
124
|
-
//
|
|
125
|
-
// detach (M2) and is
|
|
126
|
-
// welcome.pending_dialog
|
|
127
|
-
//
|
|
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.
|
|
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.
|
|
130
81
|
// ---------------------------------------------------------------------------
|
|
131
|
-
test('
|
|
82
|
+
test('G5 — a controller attaching MID-dialog receives the pending dialog via welcome.pending_dialog', async () => {
|
|
132
83
|
const base = dialogCount();
|
|
133
|
-
const a = await
|
|
134
|
-
h.fakeCmd(id, { cmd: 'dialog', timeout: 30_000 }); // stays pending long enough for the
|
|
135
|
-
const reqA = await a.waitFrame(
|
|
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');
|
|
136
87
|
const reqId = reqA.id;
|
|
137
88
|
a.send({ type: 'bye' });
|
|
138
|
-
a.close(); // M2:
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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');
|
|
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');
|
|
146
94
|
assert.equal(b.welcome.pending_dialog.method, 'confirm', 'G5b: the pending dialog is the confirm()');
|
|
147
95
|
b.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
|
|
148
|
-
const results = await awaitDialogs(base + 1, 'G5b dialog
|
|
149
|
-
assert.equal(results[base].resolved, true, 'G5b: the
|
|
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');
|
|
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');
|
|
152
98
|
});
|
|
153
99
|
// ---------------------------------------------------------------------------
|
|
154
|
-
// G6 — anti-deadlock. An ATTENDED dialog
|
|
155
|
-
// SHORT per-dialog broker timeout
|
|
156
|
-
//
|
|
157
|
-
// Failure mode: a forever-blocked turn, or overlays left up on every viewer.
|
|
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.
|
|
158
103
|
// ---------------------------------------------------------------------------
|
|
159
|
-
test('G6 — an attended dialog
|
|
104
|
+
test('G6 — an unanswered attended dialog resolves on the broker timeout', async () => {
|
|
160
105
|
const pid = brokerPid();
|
|
161
106
|
const base = dialogCount();
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// broadcasts a correlated `extension_ui_dismiss` keyed to the dialog's id, so
|
|
165
|
-
//
|
|
166
|
-
const
|
|
167
|
-
const b = await writer('g6-B');
|
|
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');
|
|
168
112
|
h.fakeCmd(id, { cmd: 'dialog', timeout: 800 });
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
assert.equal(
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
assert.
|
|
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`);
|
|
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`);
|
|
179
120
|
assert.equal(isPidAlive(pid), true, 'G6: the engine made forward progress (still alive)');
|
|
180
121
|
});
|
|
@@ -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
|
|
95
|
-
//
|
|
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.
|
|
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,6 +16,7 @@
|
|
|
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';
|
|
19
20
|
const engine = (patch = {}) => ({
|
|
20
21
|
model: undefined,
|
|
21
22
|
thinkingLevel: 'off',
|
|
@@ -34,6 +35,7 @@ const engine = (patch = {}) => ({
|
|
|
34
35
|
const welcome = (over = {}, state = engine(), display) => ({
|
|
35
36
|
type: 'welcome',
|
|
36
37
|
role: 'controller',
|
|
38
|
+
controller_id: CLIENT,
|
|
37
39
|
agentDir: '/home/u/.pi/agent',
|
|
38
40
|
snapshot: {
|
|
39
41
|
messages: [],
|
|
@@ -43,7 +45,7 @@ const welcome = (over = {}, state = engine(), display) => ({
|
|
|
43
45
|
},
|
|
44
46
|
...over,
|
|
45
47
|
});
|
|
46
|
-
const fold = (from, frames) => frames.reduce((s, f) => reduce(s, f), from);
|
|
48
|
+
const fold = (from, frames) => frames.reduce((s, f) => reduce(s, f, CLIENT), from);
|
|
47
49
|
test('applySnapshot(welcome) equals folding the edges that produced the same broker state', () => {
|
|
48
50
|
// The live path: a viewer attached from t=0 watches these edges go by.
|
|
49
51
|
const edges = [
|
|
@@ -73,42 +75,42 @@ test('applySnapshot(welcome) equals folding the edges that produced the same bro
|
|
|
73
75
|
assert.deepEqual(late, live, 'the two ways to learn a session must agree');
|
|
74
76
|
});
|
|
75
77
|
test('agent_settled clears isStreaming (the frozen-Working… fix, now shared by both viewers)', () => {
|
|
76
|
-
const s0 = reduce(initialSessionState(), welcome());
|
|
77
|
-
const streaming = reduce(s0, { type: 'agent_start' });
|
|
78
|
+
const s0 = reduce(initialSessionState(), welcome(), CLIENT);
|
|
79
|
+
const streaming = reduce(s0, { type: 'agent_start' }, CLIENT);
|
|
78
80
|
assert.equal(streaming.engine?.isStreaming, true);
|
|
79
81
|
// agent_settled ALONE — no agent_end — must settle it. A dormant orchestrator
|
|
80
82
|
// between wakes showed a frozen spinner when this was missed.
|
|
81
|
-
const settled = reduce(streaming, { type: 'agent_settled' });
|
|
83
|
+
const settled = reduce(streaming, { type: 'agent_settled' }, CLIENT);
|
|
82
84
|
assert.equal(settled.engine?.isStreaming, false);
|
|
83
85
|
});
|
|
84
86
|
test('a notify is never a dialog, and never disturbs one that is pending', () => {
|
|
85
|
-
const s0 = reduce(initialSessionState(), welcome());
|
|
86
|
-
const asked = reduce(s0, { type: 'extension_ui_request', id: 'ask-1', method: 'input', title: 'Paste the code' });
|
|
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);
|
|
87
89
|
assert.equal(asked.dialog?.id, 'ask-1');
|
|
88
|
-
const notified = reduce(asked, { type: 'extension_ui_request', id: 'n-1', method: 'notify', message: 'background progress' });
|
|
90
|
+
const notified = reduce(asked, { type: 'extension_ui_request', id: 'n-1', method: 'notify', message: 'background progress' }, CLIENT);
|
|
89
91
|
assert.equal(notified, asked, 'a notify carries no session state — same reference, dialog untouched');
|
|
90
92
|
// A display paint is equally inert with respect to the pending dialog.
|
|
91
|
-
const painted = reduce(asked, { type: 'extension_ui_request', id: 's-1', method: 'setStatus', statusKey: 'k', statusText: 'v' });
|
|
93
|
+
const painted = reduce(asked, { type: 'extension_ui_request', id: 's-1', method: 'setStatus', statusKey: 'k', statusText: 'v' }, CLIENT);
|
|
92
94
|
assert.equal(painted.dialog?.id, 'ask-1', 'a chrome paint must not clear a pending dialog');
|
|
93
95
|
// And only the MATCHING correlated dismiss clears it.
|
|
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);
|
|
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);
|
|
96
98
|
});
|
|
97
99
|
test('frames that carry no session state return the SAME state reference', () => {
|
|
98
|
-
const s0 = reduce(initialSessionState(), welcome());
|
|
100
|
+
const s0 = reduce(initialSessionState(), welcome(), CLIENT);
|
|
99
101
|
const inert = [
|
|
100
|
-
{ type: 'error', code: '
|
|
102
|
+
{ type: 'error', code: 'not_controller', message: 'read-only' },
|
|
101
103
|
{ type: 'ack', for: 'get_commands', ok: true, detail: '[]' },
|
|
102
104
|
{ type: 'bash_start', command: 'ls' },
|
|
103
105
|
{ type: 'bash_output', chunk: 'a\n' },
|
|
104
106
|
{ type: 'bash_end', exitCode: 0, cancelled: false, truncated: false },
|
|
105
107
|
];
|
|
106
108
|
for (const f of inert) {
|
|
107
|
-
assert.equal(reduce(s0, f), s0, `${f.type} must not allocate a new state`);
|
|
109
|
+
assert.equal(reduce(s0, f, CLIENT), s0, `${f.type} must not allocate a new state`);
|
|
108
110
|
}
|
|
109
111
|
// Slice identity is what viewers repaint off: an engine-only change must leave
|
|
110
112
|
// the display slice untouched by reference.
|
|
111
|
-
const started = reduce(s0, { type: 'agent_start' });
|
|
113
|
+
const started = reduce(s0, { type: 'agent_start' }, CLIENT);
|
|
112
114
|
assert.notEqual(started, s0);
|
|
113
115
|
assert.equal(started.display, s0.display, 'an engine change must not dirty the display slice');
|
|
114
116
|
});
|
|
@@ -123,7 +125,16 @@ test('a welcome is a COMPLETE catch-up: a re-welcome drops a dead broker s level
|
|
|
123
125
|
assert.equal(s0.engine?.pendingMessageCount, 1);
|
|
124
126
|
// A fresh broker (post-revive) welcomes with its OWN levels and an empty queue,
|
|
125
127
|
// and emits no queue_update to overwrite the panel — so the welcome must.
|
|
126
|
-
const s1 = reduce(s0, welcome());
|
|
128
|
+
const s1 = reduce(s0, welcome(), CLIENT);
|
|
127
129
|
assert.deepEqual(s1.display.statuses, {}, 'a dead broker s status keys must not survive');
|
|
128
130
|
assert.deepEqual(s1.queued, [], 'a dead broker s queued rows must not survive');
|
|
129
131
|
});
|
|
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
|
+
});
|
|
@@ -25,7 +25,7 @@ const REQUIRED_IDS = [
|
|
|
25
25
|
...ids('crtr.graph.confirm', ['accept', 'cancel']),
|
|
26
26
|
...ids('crtr.view.host', ['force-quit', 'editor.backspace']),
|
|
27
27
|
...ids('crtr.view.canvas', ['down', 'up', 'refresh', 'quit']),
|
|
28
|
-
...ids('crtr.view.chat', ['submit', 'contextual-cancel', 'reconnect', 'quit']),
|
|
28
|
+
...ids('crtr.view.chat', ['submit', 'contextual-cancel', 'reconnect', 'request-control', 'release-control', 'quit']),
|
|
29
29
|
...ids('crtr.view.git-pr', ['down', 'up', 'refresh', 'quit']),
|
|
30
30
|
...ids('crtr.view.inbox', ['down', 'up', 'open', 'reply', 'react', 'filter', 'refresh', 'connect-linkedin', 'connect-gmail', 'quit', 'submit-reply', 'cancel', 'reaction-previous', 'reaction-next', 'submit-reaction']),
|
|
31
31
|
...ids('crtr.view.linkedin', ['down', 'up', 'open', 'reply', 'react', 'refresh', 'quit', 'submit-reply', 'cancel', 'reaction-previous', 'reaction-next', 'submit-reaction']),
|
|
@@ -8,8 +8,8 @@ type ProfileMenuAction = 'up' | 'down' | 'select' | 'accept-default' | 'abort' |
|
|
|
8
8
|
type SetupAction = 'cancel' | 'tab-next' | 'tab-previous' | 'open' | 'list-down' | 'list-up' | 'toggle' | 'details' | 'advance' | 'cell-previous' | 'cell-next';
|
|
9
9
|
type SetupKeybindingAction = 'edit' | 'commit-edit' | 'cancel-edit' | 'unbind' | 'reset-row' | 'reset-all' | 'save' | 'discard' | 'page-up' | 'page-down' | 'filter' | 'filter-delete' | 'filter-clear';
|
|
10
10
|
type SetupModelsAction = 'open' | 'commit' | 'cancel' | 'scope-next';
|
|
11
|
-
export type BindingId = `crtr.tmux.menu.${TmuxMenuAction}` | `crtr.tmux.menu.issue.${'idea' | 'bug'}` | `crtr.tmux.node.${'next' | 'previous'}` | `crtr.tmux.view.${'next' | 'previous'}` | 'crtr.tmux.inbox.toggle' | `crtr.attach.${'detach' | 'clear-or-detach' | 'graph.toggle' | 'model-ladder.next' | 'model-ladder.previous' | 'command.inspect' | 'file-review'}` | 'crtr.mode.cycle' | `crtr.graph.${GraphAction}` | `crtr.graph.confirm.${'accept' | 'cancel'}` | `crtr.view.host.${'force-quit' | 'editor.backspace'}` | `crtr.view.canvas.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.chat.${'submit' | 'contextual-cancel' | 'reconnect' | 'quit'}` | `crtr.view.git-pr.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.inbox.${InboxAction}` | `crtr.view.linkedin.${LinkedinAction}` | `crtr.view.prompt-review.${'down' | 'up' | 'compare-previous' | 'compare-next' | 'toggle-mode' | 'expand' | 'export' | 'refresh' | 'quit'}` | `crtr.view.workspace-sidebar.${'down' | 'up' | 'open' | 'refresh' | 'quit'}` | `crtr.view.inspector.${'down' | 'up' | 'section-next' | 'section-prev' | 'page-down' | 'page-up' | 'toggle-detail' | 'cancel-item' | 'confirm' | 'dismiss' | 'retarget' | 'refresh' | 'quit'}` | `crtr.view-picker.${'down' | 'up' | 'run' | 'quit'}` | `crtr.browse.${BrowseAction}` | `crtr.browse.search.${'cancel' | 'commit' | 'delete' | 'quit'}` | `crtr.profile.menu.${ProfileMenuAction}` | `crtr.profile.search.${'up' | 'down' | 'pick' | 'back' | 'delete' | 'abort'}` | `crtr.setup.${SetupAction}` | `crtr.setup.confirm.${'accept' | 'back'}` | `crtr.setup.keybindings.${SetupKeybindingAction}` | `crtr.setup.models.${SetupModelsAction}`;
|
|
12
|
-
export declare const BINDING_CATALOG: readonly [BindingDefinition<"crtr.tmux.menu.open">, BindingDefinition<"crtr.tmux.menu.promote">, BindingDefinition<"crtr.tmux.menu.resume">, BindingDefinition<"crtr.tmux.menu.fork">, BindingDefinition<"crtr.tmux.menu.demote">, BindingDefinition<"crtr.tmux.menu.detach">, BindingDefinition<"crtr.tmux.menu.close-subtree">, BindingDefinition<"crtr.tmux.menu.background-bash">, BindingDefinition<"crtr.tmux.menu.file-review">, BindingDefinition<"crtr.tmux.menu.pin">, BindingDefinition<"crtr.tmux.menu.show">, BindingDefinition<"crtr.tmux.menu.metadata">, BindingDefinition<"crtr.tmux.menu.context">, BindingDefinition<"crtr.tmux.menu.providers">, BindingDefinition<"crtr.tmux.menu.graph">, BindingDefinition<"crtr.tmux.menu.focus-manager">, BindingDefinition<"crtr.tmux.menu.copy-id">, BindingDefinition<"crtr.tmux.menu.reload-viewer">, BindingDefinition<"crtr.tmux.menu.issues">, BindingDefinition<"crtr.tmux.menu.issue.idea">, BindingDefinition<"crtr.tmux.menu.issue.bug">, BindingDefinition<"crtr.tmux.node.next">, BindingDefinition<"crtr.tmux.node.previous">, BindingDefinition<"crtr.tmux.inbox.toggle">, BindingDefinition<"crtr.tmux.view.next">, BindingDefinition<"crtr.tmux.view.previous">, BindingDefinition<"crtr.attach.detach">, BindingDefinition<"crtr.attach.clear-or-detach">, BindingDefinition<"crtr.attach.graph.toggle">, BindingDefinition<"crtr.attach.model-ladder.next">, BindingDefinition<"crtr.attach.model-ladder.previous">, BindingDefinition<"crtr.attach.command.inspect">, BindingDefinition<"crtr.attach.file-review">, BindingDefinition<"crtr.mode.cycle">, BindingDefinition<"crtr.graph.down">, BindingDefinition<"crtr.graph.up">, BindingDefinition<"crtr.graph.first">, BindingDefinition<"crtr.graph.last">, BindingDefinition<"crtr.graph.collapse-or-manager">, BindingDefinition<"crtr.graph.expand-or-child">, BindingDefinition<"crtr.graph.focus">, BindingDefinition<"crtr.graph.revive">, BindingDefinition<"crtr.graph.focus-manager">, BindingDefinition<"crtr.graph.close-subtree">, BindingDefinition<"crtr.graph.dismiss">, BindingDefinition<"crtr.graph.confirm.accept">, BindingDefinition<"crtr.graph.confirm.cancel">, BindingDefinition<"crtr.view.host.force-quit">, BindingDefinition<"crtr.view.host.editor.backspace">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<"crtr.view.chat.submit">, BindingDefinition<"crtr.view.chat.contextual-cancel">, BindingDefinition<"crtr.view.chat.reconnect">, BindingDefinition<"crtr.view.chat.quit">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, ...(BindingDefinition<BindingId> | BindingDefinition<"crtr.view.prompt-review.down"> | BindingDefinition<"crtr.view.prompt-review.up"> | BindingDefinition<"crtr.view.prompt-review.compare-previous"> | BindingDefinition<"crtr.view.prompt-review.compare-next"> | BindingDefinition<"crtr.view.prompt-review.toggle-mode"> | BindingDefinition<"crtr.view.prompt-review.expand"> | BindingDefinition<"crtr.view.prompt-review.export"> | BindingDefinition<"crtr.view.prompt-review.refresh"> | BindingDefinition<"crtr.view.prompt-review.quit"> | BindingDefinition<"crtr.view.workspace-sidebar.down"> | BindingDefinition<"crtr.view.workspace-sidebar.up"> | BindingDefinition<"crtr.view.workspace-sidebar.open"> | BindingDefinition<"crtr.view.workspace-sidebar.refresh"> | BindingDefinition<"crtr.view.workspace-sidebar.quit"> | BindingDefinition<"crtr.view.inspector.down"> | BindingDefinition<"crtr.view.inspector.up"> | BindingDefinition<"crtr.view.inspector.section-next"> | BindingDefinition<"crtr.view.inspector.section-prev"> | BindingDefinition<"crtr.view.inspector.page-down"> | BindingDefinition<"crtr.view.inspector.page-up"> | BindingDefinition<"crtr.view.inspector.toggle-detail"> | BindingDefinition<"crtr.view.inspector.cancel-item"> | BindingDefinition<"crtr.view.inspector.confirm"> | BindingDefinition<"crtr.view.inspector.dismiss"> | BindingDefinition<"crtr.view.inspector.retarget"> | BindingDefinition<"crtr.view.inspector.refresh"> | BindingDefinition<"crtr.view.inspector.quit"> | BindingDefinition<"crtr.view-picker.down"> | BindingDefinition<"crtr.view-picker.up"> | BindingDefinition<"crtr.view-picker.run"> | BindingDefinition<"crtr.view-picker.quit"> | BindingDefinition<"crtr.browse.quit"> | BindingDefinition<"crtr.browse.down"> | BindingDefinition<"crtr.browse.up"> | BindingDefinition<"crtr.browse.first"> | BindingDefinition<"crtr.browse.last"> | BindingDefinition<"crtr.browse.expand-or-child"> | BindingDefinition<"crtr.browse.collapse-or-parent"> | BindingDefinition<"crtr.browse.toggle-fold"> | BindingDefinition<"crtr.browse.tab-next"> | BindingDefinition<"crtr.browse.tab-previous"> | BindingDefinition<"crtr.browse.tab.2" | "crtr.browse.tab.1" | "crtr.browse.tab.3" | "crtr.browse.tab.4" | "crtr.browse.tab.5"> | BindingDefinition<"crtr.browse.close-subtree"> | BindingDefinition<"crtr.browse.confirm-close"> | BindingDefinition<"crtr.browse.sort"> | BindingDefinition<"crtr.browse.scope"> | BindingDefinition<"crtr.browse.profile"> | BindingDefinition<"crtr.browse.residents"> | BindingDefinition<"crtr.browse.preview"> | BindingDefinition<"crtr.browse.search"> | BindingDefinition<"crtr.browse.revive-now"> | BindingDefinition<"crtr.browse.focus"> | BindingDefinition<"crtr.browse.focus-background"> | BindingDefinition<"crtr.browse.search.cancel"> | BindingDefinition<"crtr.browse.search.commit"> | BindingDefinition<"crtr.browse.search.delete"> | BindingDefinition<"crtr.browse.search.quit"> | BindingDefinition<"crtr.profile.menu.up"> | BindingDefinition<"crtr.profile.menu.down"> | BindingDefinition<"crtr.profile.menu.select"> | BindingDefinition<"crtr.profile.menu.accept-default"> | BindingDefinition<"crtr.profile.menu.abort"> | BindingDefinition<"crtr.profile.menu.slot.2" | "crtr.profile.menu.slot.1" | "crtr.profile.menu.slot.3" | "crtr.profile.menu.slot.4" | "crtr.profile.menu.slot.5" | "crtr.profile.menu.slot.9" | "crtr.profile.menu.slot.8" | "crtr.profile.menu.slot.7" | "crtr.profile.menu.slot.6">)[], BindingDefinition<"crtr.profile.menu.create">, BindingDefinition<"crtr.profile.menu.search-existing">, BindingDefinition<"crtr.profile.menu.use-root">, BindingDefinition<"crtr.profile.menu.set-default">, BindingDefinition<"crtr.profile.menu.add-directory">, BindingDefinition<"crtr.profile.menu.unpin">, BindingDefinition<"crtr.profile.search.up">, BindingDefinition<"crtr.profile.search.down">, BindingDefinition<"crtr.profile.search.pick">, BindingDefinition<"crtr.profile.search.back">, BindingDefinition<"crtr.profile.search.delete">, BindingDefinition<"crtr.profile.search.abort">, BindingDefinition<"crtr.setup.cancel">, BindingDefinition<"crtr.setup.tab-next">, BindingDefinition<"crtr.setup.tab-previous">, BindingDefinition<"crtr.setup.open">, BindingDefinition<"crtr.setup.list-down">, BindingDefinition<"crtr.setup.list-up">, BindingDefinition<"crtr.setup.toggle">, BindingDefinition<"crtr.setup.details">, BindingDefinition<"crtr.setup.advance">, BindingDefinition<"crtr.setup.confirm.accept">, BindingDefinition<"crtr.setup.confirm.back">, BindingDefinition<"crtr.setup.keybindings.edit">, BindingDefinition<"crtr.setup.keybindings.commit-edit">, BindingDefinition<"crtr.setup.keybindings.cancel-edit">, BindingDefinition<"crtr.setup.keybindings.unbind">, BindingDefinition<"crtr.setup.keybindings.reset-row">, BindingDefinition<"crtr.setup.keybindings.reset-all">, BindingDefinition<"crtr.setup.keybindings.save">, BindingDefinition<"crtr.setup.keybindings.discard">, BindingDefinition<"crtr.setup.keybindings.page-up">, BindingDefinition<"crtr.setup.keybindings.page-down">, BindingDefinition<"crtr.setup.keybindings.filter">, BindingDefinition<"crtr.setup.keybindings.filter-delete">, BindingDefinition<"crtr.setup.keybindings.filter-clear">, BindingDefinition<"crtr.setup.cell-previous">, BindingDefinition<"crtr.setup.cell-next">, BindingDefinition<"crtr.setup.models.open">, BindingDefinition<"crtr.setup.models.scope-next">, BindingDefinition<"crtr.setup.models.commit">, BindingDefinition<"crtr.setup.models.cancel">];
|
|
11
|
+
export type BindingId = `crtr.tmux.menu.${TmuxMenuAction}` | `crtr.tmux.menu.issue.${'idea' | 'bug'}` | `crtr.tmux.node.${'next' | 'previous'}` | `crtr.tmux.view.${'next' | 'previous'}` | 'crtr.tmux.inbox.toggle' | `crtr.attach.${'detach' | 'clear-or-detach' | 'graph.toggle' | 'model-ladder.next' | 'model-ladder.previous' | 'command.inspect' | 'file-review'}` | 'crtr.mode.cycle' | `crtr.graph.${GraphAction}` | `crtr.graph.confirm.${'accept' | 'cancel'}` | `crtr.view.host.${'force-quit' | 'editor.backspace'}` | `crtr.view.canvas.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.chat.${'submit' | 'contextual-cancel' | 'reconnect' | 'request-control' | 'release-control' | 'quit'}` | `crtr.view.git-pr.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.inbox.${InboxAction}` | `crtr.view.linkedin.${LinkedinAction}` | `crtr.view.prompt-review.${'down' | 'up' | 'compare-previous' | 'compare-next' | 'toggle-mode' | 'expand' | 'export' | 'refresh' | 'quit'}` | `crtr.view.workspace-sidebar.${'down' | 'up' | 'open' | 'refresh' | 'quit'}` | `crtr.view.inspector.${'down' | 'up' | 'section-next' | 'section-prev' | 'page-down' | 'page-up' | 'toggle-detail' | 'cancel-item' | 'confirm' | 'dismiss' | 'retarget' | 'refresh' | 'quit'}` | `crtr.view-picker.${'down' | 'up' | 'run' | 'quit'}` | `crtr.browse.${BrowseAction}` | `crtr.browse.search.${'cancel' | 'commit' | 'delete' | 'quit'}` | `crtr.profile.menu.${ProfileMenuAction}` | `crtr.profile.search.${'up' | 'down' | 'pick' | 'back' | 'delete' | 'abort'}` | `crtr.setup.${SetupAction}` | `crtr.setup.confirm.${'accept' | 'back'}` | `crtr.setup.keybindings.${SetupKeybindingAction}` | `crtr.setup.models.${SetupModelsAction}`;
|
|
12
|
+
export declare const BINDING_CATALOG: readonly [BindingDefinition<"crtr.tmux.menu.open">, BindingDefinition<"crtr.tmux.menu.promote">, BindingDefinition<"crtr.tmux.menu.resume">, BindingDefinition<"crtr.tmux.menu.fork">, BindingDefinition<"crtr.tmux.menu.demote">, BindingDefinition<"crtr.tmux.menu.detach">, BindingDefinition<"crtr.tmux.menu.close-subtree">, BindingDefinition<"crtr.tmux.menu.background-bash">, BindingDefinition<"crtr.tmux.menu.file-review">, BindingDefinition<"crtr.tmux.menu.pin">, BindingDefinition<"crtr.tmux.menu.show">, BindingDefinition<"crtr.tmux.menu.metadata">, BindingDefinition<"crtr.tmux.menu.context">, BindingDefinition<"crtr.tmux.menu.providers">, BindingDefinition<"crtr.tmux.menu.graph">, BindingDefinition<"crtr.tmux.menu.focus-manager">, BindingDefinition<"crtr.tmux.menu.copy-id">, BindingDefinition<"crtr.tmux.menu.reload-viewer">, BindingDefinition<"crtr.tmux.menu.issues">, BindingDefinition<"crtr.tmux.menu.issue.idea">, BindingDefinition<"crtr.tmux.menu.issue.bug">, BindingDefinition<"crtr.tmux.node.next">, BindingDefinition<"crtr.tmux.node.previous">, BindingDefinition<"crtr.tmux.inbox.toggle">, BindingDefinition<"crtr.tmux.view.next">, BindingDefinition<"crtr.tmux.view.previous">, BindingDefinition<"crtr.attach.detach">, BindingDefinition<"crtr.attach.clear-or-detach">, BindingDefinition<"crtr.attach.graph.toggle">, BindingDefinition<"crtr.attach.model-ladder.next">, BindingDefinition<"crtr.attach.model-ladder.previous">, BindingDefinition<"crtr.attach.command.inspect">, BindingDefinition<"crtr.attach.file-review">, BindingDefinition<"crtr.mode.cycle">, BindingDefinition<"crtr.graph.down">, BindingDefinition<"crtr.graph.up">, BindingDefinition<"crtr.graph.first">, BindingDefinition<"crtr.graph.last">, BindingDefinition<"crtr.graph.collapse-or-manager">, BindingDefinition<"crtr.graph.expand-or-child">, BindingDefinition<"crtr.graph.focus">, BindingDefinition<"crtr.graph.revive">, BindingDefinition<"crtr.graph.focus-manager">, BindingDefinition<"crtr.graph.close-subtree">, BindingDefinition<"crtr.graph.dismiss">, BindingDefinition<"crtr.graph.confirm.accept">, BindingDefinition<"crtr.graph.confirm.cancel">, BindingDefinition<"crtr.view.host.force-quit">, BindingDefinition<"crtr.view.host.editor.backspace">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<"crtr.view.chat.submit">, BindingDefinition<"crtr.view.chat.contextual-cancel">, BindingDefinition<"crtr.view.chat.reconnect">, BindingDefinition<"crtr.view.chat.request-control">, BindingDefinition<"crtr.view.chat.release-control">, BindingDefinition<"crtr.view.chat.quit">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, ...(BindingDefinition<BindingId> | BindingDefinition<"crtr.view.prompt-review.down"> | BindingDefinition<"crtr.view.prompt-review.up"> | BindingDefinition<"crtr.view.prompt-review.compare-previous"> | BindingDefinition<"crtr.view.prompt-review.compare-next"> | BindingDefinition<"crtr.view.prompt-review.toggle-mode"> | BindingDefinition<"crtr.view.prompt-review.expand"> | BindingDefinition<"crtr.view.prompt-review.export"> | BindingDefinition<"crtr.view.prompt-review.refresh"> | BindingDefinition<"crtr.view.prompt-review.quit"> | BindingDefinition<"crtr.view.workspace-sidebar.down"> | BindingDefinition<"crtr.view.workspace-sidebar.up"> | BindingDefinition<"crtr.view.workspace-sidebar.open"> | BindingDefinition<"crtr.view.workspace-sidebar.refresh"> | BindingDefinition<"crtr.view.workspace-sidebar.quit"> | BindingDefinition<"crtr.view.inspector.down"> | BindingDefinition<"crtr.view.inspector.up"> | BindingDefinition<"crtr.view.inspector.section-next"> | BindingDefinition<"crtr.view.inspector.section-prev"> | BindingDefinition<"crtr.view.inspector.page-down"> | BindingDefinition<"crtr.view.inspector.page-up"> | BindingDefinition<"crtr.view.inspector.toggle-detail"> | BindingDefinition<"crtr.view.inspector.cancel-item"> | BindingDefinition<"crtr.view.inspector.confirm"> | BindingDefinition<"crtr.view.inspector.dismiss"> | BindingDefinition<"crtr.view.inspector.retarget"> | BindingDefinition<"crtr.view.inspector.refresh"> | BindingDefinition<"crtr.view.inspector.quit"> | BindingDefinition<"crtr.view-picker.down"> | BindingDefinition<"crtr.view-picker.up"> | BindingDefinition<"crtr.view-picker.run"> | BindingDefinition<"crtr.view-picker.quit"> | BindingDefinition<"crtr.browse.quit"> | BindingDefinition<"crtr.browse.down"> | BindingDefinition<"crtr.browse.up"> | BindingDefinition<"crtr.browse.first"> | BindingDefinition<"crtr.browse.last"> | BindingDefinition<"crtr.browse.expand-or-child"> | BindingDefinition<"crtr.browse.collapse-or-parent"> | BindingDefinition<"crtr.browse.toggle-fold"> | BindingDefinition<"crtr.browse.tab-next"> | BindingDefinition<"crtr.browse.tab-previous"> | BindingDefinition<"crtr.browse.tab.2" | "crtr.browse.tab.1" | "crtr.browse.tab.3" | "crtr.browse.tab.4" | "crtr.browse.tab.5"> | BindingDefinition<"crtr.browse.close-subtree"> | BindingDefinition<"crtr.browse.confirm-close"> | BindingDefinition<"crtr.browse.sort"> | BindingDefinition<"crtr.browse.scope"> | BindingDefinition<"crtr.browse.profile"> | BindingDefinition<"crtr.browse.residents"> | BindingDefinition<"crtr.browse.preview"> | BindingDefinition<"crtr.browse.search"> | BindingDefinition<"crtr.browse.revive-now"> | BindingDefinition<"crtr.browse.focus"> | BindingDefinition<"crtr.browse.focus-background"> | BindingDefinition<"crtr.browse.search.cancel"> | BindingDefinition<"crtr.browse.search.commit"> | BindingDefinition<"crtr.browse.search.delete"> | BindingDefinition<"crtr.browse.search.quit"> | BindingDefinition<"crtr.profile.menu.up"> | BindingDefinition<"crtr.profile.menu.down"> | BindingDefinition<"crtr.profile.menu.select"> | BindingDefinition<"crtr.profile.menu.accept-default"> | BindingDefinition<"crtr.profile.menu.abort"> | BindingDefinition<"crtr.profile.menu.slot.2" | "crtr.profile.menu.slot.1" | "crtr.profile.menu.slot.3" | "crtr.profile.menu.slot.4" | "crtr.profile.menu.slot.5" | "crtr.profile.menu.slot.9" | "crtr.profile.menu.slot.8" | "crtr.profile.menu.slot.7" | "crtr.profile.menu.slot.6">)[], BindingDefinition<"crtr.profile.menu.create">, BindingDefinition<"crtr.profile.menu.search-existing">, BindingDefinition<"crtr.profile.menu.use-root">, BindingDefinition<"crtr.profile.menu.set-default">, BindingDefinition<"crtr.profile.menu.add-directory">, BindingDefinition<"crtr.profile.menu.unpin">, BindingDefinition<"crtr.profile.search.up">, BindingDefinition<"crtr.profile.search.down">, BindingDefinition<"crtr.profile.search.pick">, BindingDefinition<"crtr.profile.search.back">, BindingDefinition<"crtr.profile.search.delete">, BindingDefinition<"crtr.profile.search.abort">, BindingDefinition<"crtr.setup.cancel">, BindingDefinition<"crtr.setup.tab-next">, BindingDefinition<"crtr.setup.tab-previous">, BindingDefinition<"crtr.setup.open">, BindingDefinition<"crtr.setup.list-down">, BindingDefinition<"crtr.setup.list-up">, BindingDefinition<"crtr.setup.toggle">, BindingDefinition<"crtr.setup.details">, BindingDefinition<"crtr.setup.advance">, BindingDefinition<"crtr.setup.confirm.accept">, BindingDefinition<"crtr.setup.confirm.back">, BindingDefinition<"crtr.setup.keybindings.edit">, BindingDefinition<"crtr.setup.keybindings.commit-edit">, BindingDefinition<"crtr.setup.keybindings.cancel-edit">, BindingDefinition<"crtr.setup.keybindings.unbind">, BindingDefinition<"crtr.setup.keybindings.reset-row">, BindingDefinition<"crtr.setup.keybindings.reset-all">, BindingDefinition<"crtr.setup.keybindings.save">, BindingDefinition<"crtr.setup.keybindings.discard">, BindingDefinition<"crtr.setup.keybindings.page-up">, BindingDefinition<"crtr.setup.keybindings.page-down">, BindingDefinition<"crtr.setup.keybindings.filter">, BindingDefinition<"crtr.setup.keybindings.filter-delete">, BindingDefinition<"crtr.setup.keybindings.filter-clear">, BindingDefinition<"crtr.setup.cell-previous">, BindingDefinition<"crtr.setup.cell-next">, BindingDefinition<"crtr.setup.models.open">, BindingDefinition<"crtr.setup.models.scope-next">, BindingDefinition<"crtr.setup.models.commit">, BindingDefinition<"crtr.setup.models.cancel">];
|
|
13
13
|
export declare const BINDING_IDS: ReadonlySet<string>;
|
|
14
14
|
export declare const SAFETY_REQUIREMENTS: readonly SafetyRequirement[];
|
|
15
15
|
export {};
|
|
@@ -79,6 +79,8 @@ export const BINDING_CATALOG = Object.freeze([
|
|
|
79
79
|
b('crtr.view.chat.submit', 'Views', 'Submit chat', 'Submit the current chat input.', ['enter'], 'terminal', ['view.chat']),
|
|
80
80
|
b('crtr.view.chat.contextual-cancel', 'Views', 'Cancel chat action', 'Cancel the active chat action.', ['escape'], 'terminal', ['view.chat']),
|
|
81
81
|
b('crtr.view.chat.reconnect', 'Views', 'Reconnect chat', 'Reconnect the chat stream.', ['r'], 'terminal', ['view.chat']),
|
|
82
|
+
b('crtr.view.chat.request-control', 'Views', 'Request control', 'Request control of the chat.', ['c'], 'terminal', ['view.chat']),
|
|
83
|
+
b('crtr.view.chat.release-control', 'Views', 'Release control', 'Release control of the chat.', ['shift+c', 'ctrl+r'], 'terminal', ['view.chat']),
|
|
82
84
|
b('crtr.view.chat.quit', 'Views', 'Quit chat', 'Leave the chat view.', ['q'], 'terminal', ['view.chat'], 'leave-screen'),
|
|
83
85
|
...viewNavigation('git-pr', 'Git PR view'),
|
|
84
86
|
...listView('inbox', 'Inbox', [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
/** Reload a LIVE node's credentials over its view.sock. Connects as an
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Rejects on the broker's `error` frame,
|
|
1
|
+
/** Reload a LIVE node's credentials over its view.sock. Connects as an OBSERVER
|
|
2
|
+
* (hello only — NO request_control, so an attached human keeps control through
|
|
3
|
+
* the fan; the broker's reload_auth handler is open to any client), sends
|
|
4
|
+
* `reload_auth`, and resolves on its ack. Rejects on the broker's `error` frame,
|
|
5
5
|
* connect failure, a premature close, or timeout — the caller (the daemon fan)
|
|
6
6
|
* isolates each broker so one failure never aborts the rest. */
|
|
7
7
|
export declare function reloadAuthLive(nodeId: string): Promise<void>;
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
// live broker, so the whole canvas follows one account switch with no per-broker
|
|
10
10
|
// re-login.
|
|
11
11
|
//
|
|
12
|
-
// connect →
|
|
13
|
-
// handler is open to any client (it
|
|
14
|
-
// steer the conversation), so
|
|
12
|
+
// connect → hello → reload_auth → await ack. NO request_control: the broker's
|
|
13
|
+
// reload_auth handler is open to any client (it's an idempotent local re-read
|
|
14
|
+
// that doesn't steer the conversation), so the fan must NOT claim controller —
|
|
15
|
+
// request_control ALWAYS preempts, which would silently demote any human
|
|
16
|
+
// attached-and-driving a node every time /login fans canvas-wide.
|
|
15
17
|
import { randomUUID } from 'node:crypto';
|
|
16
18
|
import { BrokerClient } from '../broker-client/index.js';
|
|
17
19
|
/** How long to wait for the broker's `reload_auth` ack before giving up. The
|
|
@@ -19,10 +21,10 @@ import { BrokerClient } from '../broker-client/index.js';
|
|
|
19
21
|
* supervision loop awaits this on a real change — so the timeout is kept tight
|
|
20
22
|
* to cap the worst-case stall if a broker connects but never acks. */
|
|
21
23
|
const ACK_TIMEOUT_MS = 3_000;
|
|
22
|
-
/** Reload a LIVE node's credentials over its view.sock. Connects as an
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* Rejects on the broker's `error` frame,
|
|
24
|
+
/** Reload a LIVE node's credentials over its view.sock. Connects as an OBSERVER
|
|
25
|
+
* (hello only — NO request_control, so an attached human keeps control through
|
|
26
|
+
* the fan; the broker's reload_auth handler is open to any client), sends
|
|
27
|
+
* `reload_auth`, and resolves on its ack. Rejects on the broker's `error` frame,
|
|
26
28
|
* connect failure, a premature close, or timeout — the caller (the daemon fan)
|
|
27
29
|
* isolates each broker so one failure never aborts the rest. */
|
|
28
30
|
export function reloadAuthLive(nodeId) {
|
|
@@ -49,8 +51,10 @@ export function reloadAuthLive(nodeId) {
|
|
|
49
51
|
client.on('error', (err) => finish(() => reject(err)));
|
|
50
52
|
client.on('close', () => finish(() => reject(new Error('broker connection closed before it acked the auth reload'))));
|
|
51
53
|
client.on('connect', () => {
|
|
52
|
-
// Hello as OBSERVER — reload_auth is
|
|
53
|
-
//
|
|
54
|
+
// Hello as OBSERVER — reload_auth is open to any client, and an observer
|
|
55
|
+
// hello never touches controllerId, so a human driving this broker keeps
|
|
56
|
+
// control through the fan. (A `controller` hello also only takes control
|
|
57
|
+
// when none is held, which is safe; observer is the more honest signal.)
|
|
54
58
|
client.send({ type: 'hello', role: 'observer', client_id: clientId });
|
|
55
59
|
client.send({ type: 'reload_auth' });
|
|
56
60
|
});
|
|
@@ -17,8 +17,8 @@ export interface BootRootOpts {
|
|
|
17
17
|
}
|
|
18
18
|
/** Boot the front-door root: create the resident root through crtrd (broker
|
|
19
19
|
* launched + view socket ready server-side), register THIS terminal's pane as
|
|
20
|
-
* its
|
|
21
|
-
* becomes
|
|
20
|
+
* its one viewer, then exec `crtr surface attach` inline so this terminal
|
|
21
|
+
* becomes the broker's controller-viewer. Does not return — it process.exit()s
|
|
22
22
|
* when the inline attach exits (detach leaves the resident broker running, to
|
|
23
23
|
* be reconnected later). */
|
|
24
24
|
export declare function bootRoot(opts: BootRootOpts): Promise<never>;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// bootRoot creates the resident root through crtrd (`createNode({root:true})`,
|
|
4
4
|
// which the daemon serves at readiness 'await' — node birth, broker launch, and
|
|
5
5
|
// view-socket readiness ALL happen server-side), then execs `crtr surface
|
|
6
|
-
// attach` inline so THIS terminal becomes the broker's
|
|
6
|
+
// attach` inline so THIS terminal becomes the broker's controller-viewer.
|
|
7
7
|
//
|
|
8
8
|
// crtrd is the sole owner of canvas state (see crtrd-sole-state-owner), so this
|
|
9
9
|
// module opens NO canvas.db: node creation and the viewer-focus row both route
|
|
@@ -18,13 +18,13 @@ import { cliClient } from '../../commands/api-client.js';
|
|
|
18
18
|
import { mark } from '../timing.js';
|
|
19
19
|
/** Boot the front-door root: create the resident root through crtrd (broker
|
|
20
20
|
* launched + view socket ready server-side), register THIS terminal's pane as
|
|
21
|
-
* its
|
|
22
|
-
* becomes
|
|
21
|
+
* its one viewer, then exec `crtr surface attach` inline so this terminal
|
|
22
|
+
* becomes the broker's controller-viewer. Does not return — it process.exit()s
|
|
23
23
|
* when the inline attach exits (detach leaves the resident broker running, to
|
|
24
24
|
* be reconnected later). */
|
|
25
25
|
export async function bootRoot(opts) {
|
|
26
26
|
// crtr is tmux-only: the front door execs `crtr surface attach` inline in THIS
|
|
27
|
-
// pane to become the root's
|
|
27
|
+
// pane to become the root's controller-viewer, so there must be a tmux pane to
|
|
28
28
|
// anchor on. Outside tmux there is no viewer surface — throw with a friendly
|
|
29
29
|
// message rather than boot a root nobody can see.
|
|
30
30
|
if (!inTmux()) {
|
|
@@ -71,8 +71,8 @@ export async function bootRoot(opts) {
|
|
|
71
71
|
// so its window stays where the user already is. The node row carries NO
|
|
72
72
|
// presence — a broker node's pane/window/tmux_session stay NULL; the viewer
|
|
73
73
|
// pane lives in the focuses table. Register the FOREGROUND front-door pane as
|
|
74
|
-
//
|
|
75
|
-
// it. Best-effort — attach works regardless.
|
|
74
|
+
// the node's one viewer focus (canvas write, routed through crtrd) so focus/nav
|
|
75
|
+
// can find it. Best-effort — attach works regardless.
|
|
76
76
|
const here = currentTmux();
|
|
77
77
|
if (here !== null) {
|
|
78
78
|
try {
|
|
@@ -82,7 +82,7 @@ export async function bootRoot(opts) {
|
|
|
82
82
|
}
|
|
83
83
|
// Broker is the only host: the root runs as a detached broker engine (already
|
|
84
84
|
// launched above), and THIS terminal execs `crtr surface attach` inline to
|
|
85
|
-
// become its
|
|
85
|
+
// become its controller-viewer. The attach's own ensureAttach is an idempotent
|
|
86
86
|
// no-op on the already-live broker. CRTR_ATTACH_WAIT_MS tells the viewer to
|
|
87
87
|
// poll view.sock until it accepts instead of failing fast on a not-yet-bound
|
|
88
88
|
// socket. CRTR_ATTACH_BORROWED_PANE tells the viewer that its pane is the
|