@north-light/crouter 0.3.157 → 0.3.158
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/client.d.ts +4 -1
- package/dist/api/client.js +5 -0
- package/dist/api/dto/broker.d.ts +1 -1
- package/dist/api/dto/nodes.d.ts +15 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-views/chat/core.mjs +6 -51
- package/dist/builtin-views/chat/tui.mjs +6 -14
- package/dist/builtin-views/chat/web.jsx +2 -7
- 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 +9 -1
- package/dist/clients/attach/command.js +5 -5
- package/dist/clients/attach/input/controller.d.ts +23 -8
- package/dist/clients/attach/input/controller.js +59 -29
- package/dist/clients/attach/overlays/dialogs.d.ts +1 -2
- package/dist/clients/attach/overlays/graph.d.ts +4 -1
- package/dist/clients/attach/overlays/graph.js +25 -7
- package/dist/clients/attach/session/context.d.ts +7 -0
- package/dist/clients/attach/session/frames.js +1 -8
- package/dist/clients/attach/session/input-wiring.d.ts +1 -1
- package/dist/clients/attach/session/input-wiring.js +21 -11
- package/dist/clients/attach/session/mode.d.ts +4 -3
- package/dist/clients/attach/session/mode.js +6 -1
- package/dist/clients/attach/session/reconnect.d.ts +3 -3
- package/dist/clients/attach/session/reconnect.js +6 -7
- 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 +13 -1
- package/dist/clients/attach/slash/dispatch.js +65 -17
- package/dist/clients/attach/viewer.js +523 -523
- package/dist/clients/web/web-client/shared/protocol.d.ts +5 -3
- package/dist/commands/node.js +1 -1
- package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
- package/dist/core/__tests__/chat-view-reconnect.test.js +44 -23
- package/dist/core/__tests__/full/broker-attach-limits.test.js +60 -36
- package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
- package/dist/core/__tests__/full/broker-dialogs.test.js +121 -62
- package/dist/core/__tests__/helpers/broker-clients.js +2 -2
- package/dist/core/__tests__/session-model.test.js +15 -26
- 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 +0 -2
- package/dist/core/runtime/auth-reload.d.ts +4 -4
- package/dist/core/runtime/auth-reload.js +9 -13
- 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 +23 -27
- package/dist/core/runtime/broker-protocol.js +1 -1
- package/dist/core/runtime/broker-request.js +5 -11
- package/dist/core/runtime/broker.d.ts +21 -13
- package/dist/core/runtime/broker.js +151 -186
- package/dist/core/runtime/interactive-deliver.js +4 -5
- package/dist/core/runtime/model-swap.d.ts +2 -3
- package/dist/core/runtime/model-swap.js +3 -4
- package/dist/core/runtime/node-read.d.ts +20 -0
- package/dist/core/runtime/node-read.js +34 -1
- 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 +8 -6
- package/dist/core/session-model/session-state.js +6 -16
- package/dist/daemon/api/handlers/nodes.js +18 -1
- package/dist/daemon/manage.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/pi-extensions/canvas-bash-valve.js +4 -3
- package/dist/web-client/assets/{index-DJhQZoAj.css → index-CpEl9LTS.css} +1 -1
- package/dist/web-client/assets/{index--SsQYcKu.js → index-CsuwzlcQ.js} +19 -19
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/docs/compat/hearth-crtr-v5.md +175 -0
- package/docs/public-api.md +2 -2
- package/package.json +2 -2
- package/runtime.lock.json +6 -6
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +0 -1
- package/dist/core/__tests__/full/broker-control-preempt.test.js +0 -61
package/dist/api/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DaemonRestartDTO, HealthDTO, StatusDTO } from './dto/health.js';
|
|
2
|
-
import type { ArtifactListDTO, ArtifactsQuery, ContextListDTO, CreateNodeRequest, ListNodesQuery, NodeDetailDTO, NodeSnapshotDTO, NodeSummaryDTO, TranscriptDTO, TranscriptQuery } from './dto/nodes.js';
|
|
2
|
+
import type { ArtifactListDTO, ArtifactsQuery, ContextListDTO, CreateNodeRequest, ListNodesQuery, NodeDetailDTO, NodeSessionDTO, NodeSnapshotDTO, NodeSummaryDTO, TranscriptDTO, TranscriptQuery } from './dto/nodes.js';
|
|
3
3
|
import type { InterruptResultDTO, MessageResultDTO, SendMessageRequest } from './dto/messages.js';
|
|
4
4
|
import type { PushReportRequest, PushReportResultDTO, ReportDTO, ReportsQuery } from './dto/reports.js';
|
|
5
5
|
import type { CloseRequest, CloseResultDTO, PromoteRequest, RelaunchRootResultDTO, ReviveRequest, ReviveResultDTO, WaitRequest, YieldRequest } from './dto/lifecycle.js';
|
|
@@ -125,6 +125,9 @@ export declare class CrtrClient {
|
|
|
125
125
|
getReports(id: string, q?: ReportsQuery): Promise<ReportDTO[]>;
|
|
126
126
|
getTranscript(id: string, q?: TranscriptQuery): Promise<TranscriptDTO>;
|
|
127
127
|
getSnapshot(id: string): Promise<NodeSnapshotDTO>;
|
|
128
|
+
/** The node's conversation exactly as it ran — raw `.jsonl` bytes plus the
|
|
129
|
+
* assembled system prompt. For exports; `getSnapshot` is for renderers. */
|
|
130
|
+
getSession(id: string): Promise<NodeSessionDTO>;
|
|
128
131
|
getArtifacts(id: string, q?: ArtifactsQuery): Promise<ArtifactListDTO>;
|
|
129
132
|
getContext(id: string): Promise<ContextListDTO>;
|
|
130
133
|
/** Read an absolute host path as UTF-8 (capped, `truncated` when clipped) for
|
package/dist/api/client.js
CHANGED
|
@@ -219,6 +219,11 @@ export class CrtrClient {
|
|
|
219
219
|
getSnapshot(id) {
|
|
220
220
|
return this.request('GET', routes.nodeSnapshot(this.nodePath(id)));
|
|
221
221
|
}
|
|
222
|
+
/** The node's conversation exactly as it ran — raw `.jsonl` bytes plus the
|
|
223
|
+
* assembled system prompt. For exports; `getSnapshot` is for renderers. */
|
|
224
|
+
getSession(id) {
|
|
225
|
+
return this.request('GET', routes.nodeSession(this.nodePath(id)));
|
|
226
|
+
}
|
|
222
227
|
getArtifacts(id, q) {
|
|
223
228
|
return this.request('GET', withQuery(routes.nodeArtifacts(this.nodePath(id)), q));
|
|
224
229
|
}
|
package/dist/api/dto/broker.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface BrokerWelcomeFrame<M = unknown> {
|
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
33
|
* The broker-control frames a relay consumer reads: `welcome` (catch-up snapshot)
|
|
34
|
-
* and `error`. The broker interleaves others (display_*,
|
|
34
|
+
* and `error`. The broker interleaves others (display_*, ack, …)
|
|
35
35
|
* under non-colliding `type` discriminants; a relay mapper drops those through its
|
|
36
36
|
* `default` arm untyped, so they are not enumerated here.
|
|
37
37
|
*/
|
package/dist/api/dto/nodes.d.ts
CHANGED
|
@@ -145,6 +145,21 @@ export interface NodeSnapshotDTO {
|
|
|
145
145
|
}[];
|
|
146
146
|
captured_at: IsoTime;
|
|
147
147
|
}
|
|
148
|
+
/** `GET /v1/nodes/{id}/session` — the node's conversation exactly as it ran:
|
|
149
|
+
* the raw session `.jsonl` bytes plus the assembled system prompt. Unlike
|
|
150
|
+
* `NodeSnapshotDTO` nothing is reconstructed or flattened, so every
|
|
151
|
+
* session-tree entry, cycle, branch, and custom message role survives. This is
|
|
152
|
+
* what an export/download wants; `/snapshot` is what a renderer wants. */
|
|
153
|
+
export interface NodeSessionDTO {
|
|
154
|
+
node_id: NodeIdDTO;
|
|
155
|
+
/** Absolute path (inside the node's host) of the file the bytes came from. */
|
|
156
|
+
session_file: string;
|
|
157
|
+
/** Verbatim `.jsonl` contents. */
|
|
158
|
+
session_jsonl: string;
|
|
159
|
+
/** Assembled system prompt as last captured, or `null` if never written. */
|
|
160
|
+
system_prompt: string | null;
|
|
161
|
+
captured_at: IsoTime;
|
|
162
|
+
}
|
|
148
163
|
/** `GET /v1/nodes/{id}/transcript` query. */
|
|
149
164
|
export interface TranscriptQuery {
|
|
150
165
|
limit?: number;
|
package/dist/api/routes.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare const routes: {
|
|
|
9
9
|
readonly reviveAll: () => string;
|
|
10
10
|
readonly node: (id: string) => string;
|
|
11
11
|
readonly nodeSnapshot: (id: string) => string;
|
|
12
|
+
readonly nodeSession: (id: string) => string;
|
|
12
13
|
readonly nodeTranscript: (id: string) => string;
|
|
13
14
|
readonly nodeContext: (id: string) => string;
|
|
14
15
|
readonly nodeArtifacts: (id: string) => string;
|
package/dist/api/routes.js
CHANGED
|
@@ -25,6 +25,7 @@ export const routes = {
|
|
|
25
25
|
node: (id) => `${V}/nodes/${id}`,
|
|
26
26
|
// Node reads
|
|
27
27
|
nodeSnapshot: (id) => `${V}/nodes/${id}/snapshot`,
|
|
28
|
+
nodeSession: (id) => `${V}/nodes/${id}/session`,
|
|
28
29
|
nodeTranscript: (id) => `${V}/nodes/${id}/transcript`,
|
|
29
30
|
nodeContext: (id) => `${V}/nodes/${id}/context`,
|
|
30
31
|
nodeArtifacts: (id) => `${V}/nodes/${id}/artifacts`,
|
|
@@ -45,10 +45,8 @@
|
|
|
45
45
|
* @property {string|null} target
|
|
46
46
|
* @property {ChatRole} desiredRole
|
|
47
47
|
* @property {any|null} channel
|
|
48
|
-
* @property {string|null} clientId
|
|
49
48
|
* @property {ChatConn} conn
|
|
50
49
|
* @property {ChatRole} role
|
|
51
|
-
* @property {string|null} controllerId
|
|
52
50
|
* @property {ChatSession|null} session
|
|
53
51
|
* @property {ChatTranscript} transcript
|
|
54
52
|
* @property {string} draft
|
|
@@ -374,12 +372,10 @@ const core = {
|
|
|
374
372
|
const target = typeof opts.target === 'string' && opts.target.trim() !== '' ? opts.target : null;
|
|
375
373
|
return {
|
|
376
374
|
target,
|
|
377
|
-
desiredRole: 'controller',
|
|
375
|
+
desiredRole: opts.role === 'observer' ? 'observer' : 'controller',
|
|
378
376
|
channel: null,
|
|
379
|
-
clientId: null,
|
|
380
377
|
conn: 'idle',
|
|
381
378
|
role: 'observer',
|
|
382
|
-
controllerId: null,
|
|
383
379
|
session: null,
|
|
384
380
|
transcript: initialTranscript(),
|
|
385
381
|
draft: '',
|
|
@@ -418,7 +414,6 @@ const core = {
|
|
|
418
414
|
ctx.set((prev) => ({
|
|
419
415
|
...prev,
|
|
420
416
|
channel,
|
|
421
|
-
clientId: channel.id,
|
|
422
417
|
conn: 'connecting',
|
|
423
418
|
}));
|
|
424
419
|
} catch (err) {
|
|
@@ -436,7 +431,6 @@ const core = {
|
|
|
436
431
|
ctx.set((prev) => ({
|
|
437
432
|
...prev,
|
|
438
433
|
channel: prev.channel ?? channel,
|
|
439
|
-
clientId: prev.clientId ?? asMaybeText(channel.id),
|
|
440
434
|
conn: 'connecting',
|
|
441
435
|
}));
|
|
442
436
|
ctx.signal.setStatus('connected — waiting for welcome');
|
|
@@ -506,8 +500,7 @@ const core = {
|
|
|
506
500
|
ctx.set((prev) => ({
|
|
507
501
|
...prev,
|
|
508
502
|
conn: 'open',
|
|
509
|
-
|
|
510
|
-
role: frame.controller_id === prev.clientId ? 'controller' : (frame.role ?? 'observer'),
|
|
503
|
+
role: frame.role ?? prev.desiredRole,
|
|
511
504
|
session,
|
|
512
505
|
transcript,
|
|
513
506
|
pendingDialog: frame.pending_dialog ?? null,
|
|
@@ -519,34 +512,14 @@ const core = {
|
|
|
519
512
|
return;
|
|
520
513
|
}
|
|
521
514
|
|
|
522
|
-
case 'control_changed': {
|
|
523
|
-
ctx.set((prev) => {
|
|
524
|
-
const nextRole = frame.controller_id === prev.clientId ? 'controller' : 'observer';
|
|
525
|
-
const changed = nextRole !== prev.role;
|
|
526
|
-
const demoted = prev.role === 'controller' && nextRole === 'observer';
|
|
527
|
-
let next = {
|
|
528
|
-
...prev,
|
|
529
|
-
controllerId: asMaybeText(frame.controller_id),
|
|
530
|
-
role: nextRole,
|
|
531
|
-
desiredRole: demoted ? 'observer' : prev.desiredRole,
|
|
532
|
-
};
|
|
533
|
-
if (changed) {
|
|
534
|
-
next = notice(next, nextRole === 'controller' ? 'control changed — you are controller' : 'control changed — read-only', 'info');
|
|
535
|
-
}
|
|
536
|
-
return next;
|
|
537
|
-
});
|
|
538
|
-
return;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
515
|
case 'model_changed': {
|
|
542
516
|
ctx.set((prev) => patchSession(prev, { model: asMaybeText(frame.model) }));
|
|
543
517
|
return;
|
|
544
518
|
}
|
|
545
519
|
|
|
546
520
|
case 'error': {
|
|
547
|
-
if (frame.code === '
|
|
548
|
-
const text = 'read-only —
|
|
549
|
-
ctx.set((prev) => ({ ...prev, role: 'observer' }));
|
|
521
|
+
if (frame.code === 'read_only') {
|
|
522
|
+
const text = 'read-only — sending is disabled';
|
|
550
523
|
ctx.set((prev) => notice(prev, text, 'action'));
|
|
551
524
|
ctx.signal.setBanner(text, 'action');
|
|
552
525
|
return;
|
|
@@ -659,7 +632,7 @@ const core = {
|
|
|
659
632
|
return;
|
|
660
633
|
}
|
|
661
634
|
if (ctx.state.role !== 'controller') {
|
|
662
|
-
const text = 'read-only —
|
|
635
|
+
const text = 'read-only — sending is disabled';
|
|
663
636
|
ctx.set((prev) => notice(prev, text, 'action'));
|
|
664
637
|
ctx.signal.setBanner(text, 'action');
|
|
665
638
|
return;
|
|
@@ -672,7 +645,7 @@ const core = {
|
|
|
672
645
|
/** @param {any} ctx */
|
|
673
646
|
abort(ctx) {
|
|
674
647
|
if (ctx.state.role !== 'controller') {
|
|
675
|
-
const text = 'read-only —
|
|
648
|
+
const text = 'read-only — sending is disabled';
|
|
676
649
|
ctx.set((prev) => notice(prev, text, 'action'));
|
|
677
650
|
ctx.signal.setBanner(text, 'action');
|
|
678
651
|
return;
|
|
@@ -686,24 +659,6 @@ const core = {
|
|
|
686
659
|
ctx.state.channel.send({ type: 'abort' });
|
|
687
660
|
},
|
|
688
661
|
|
|
689
|
-
/** @param {any} ctx */
|
|
690
|
-
requestControl(ctx) {
|
|
691
|
-
ctx.set((prev) => ({ ...prev, desiredRole: 'controller' }));
|
|
692
|
-
if (ctx.state.channel && ctx.state.conn === 'open') ctx.state.channel.send({ type: 'request_control' });
|
|
693
|
-
},
|
|
694
|
-
|
|
695
|
-
/** @param {any} ctx */
|
|
696
|
-
releaseControl(ctx) {
|
|
697
|
-
ctx.set((prev) => ({ ...prev, desiredRole: 'observer' }));
|
|
698
|
-
const channel = ctx.state.channel;
|
|
699
|
-
if (channel && ctx.state.conn === 'open') {
|
|
700
|
-
channel.send({ type: 'release_control' });
|
|
701
|
-
}
|
|
702
|
-
// Close and reopen as observer so transport-level auto-reconnect cannot
|
|
703
|
-
// silently reclaim controller role (transports capture role at open time).
|
|
704
|
-
void ctx.dispatch('reconnect');
|
|
705
|
-
},
|
|
706
|
-
|
|
707
662
|
/** @param {any} ctx @param {string|undefined} id */
|
|
708
663
|
clearNotice(ctx, id) {
|
|
709
664
|
ctx.set((prev) => {
|
|
@@ -165,12 +165,6 @@ function statusSpans(state) {
|
|
|
165
165
|
spans.push({ text: ' · role ', style: { dim: true } });
|
|
166
166
|
spans.push({ text: state.role, style: state.role === 'controller' ? { fg: '32', bold: true } : { fg: '33', bold: true } });
|
|
167
167
|
|
|
168
|
-
spans.push({ text: ' · ctrl ', style: { dim: true } });
|
|
169
|
-
spans.push({
|
|
170
|
-
text: state.controllerId ? (state.controllerId === state.clientId ? 'you' : shortId(state.controllerId) || 'other') : '—',
|
|
171
|
-
style: state.controllerId === state.clientId ? { fg: '32', bold: true } : { fg: '36', bold: true },
|
|
172
|
-
});
|
|
173
|
-
|
|
174
168
|
const model = maybeText(state.session?.model);
|
|
175
169
|
const sessionName = maybeText(state.session?.sessionName);
|
|
176
170
|
if (model || sessionName) {
|
|
@@ -332,7 +326,9 @@ export function render(state, draw, content) {
|
|
|
332
326
|
paintTail(draw, noticeRect, noticeLines(state).slice(-noticeCount));
|
|
333
327
|
|
|
334
328
|
const draft = tailClip(state.draft, Math.max(0, content.width - 2));
|
|
335
|
-
draw.spans(composerRow, content.col, [
|
|
329
|
+
draw.spans(composerRow, content.col, state.role === 'observer' ? [
|
|
330
|
+
{ text: 'Read-only', style: { fg: '33', bold: true } },
|
|
331
|
+
] : [
|
|
336
332
|
{ text: '> ', style: { fg: '33', bold: true } },
|
|
337
333
|
{ text: draft, style: undefined },
|
|
338
334
|
{ text: draft ? '█' : '', style: { fg: '33' } },
|
|
@@ -346,11 +342,9 @@ const hasNotice = (state) => state.notices.length > 0;
|
|
|
346
342
|
/** @param {ChatState} state */
|
|
347
343
|
const hasDraft = (state) => state.draft.length > 0;
|
|
348
344
|
/** @param {ChatState} state */
|
|
349
|
-
const
|
|
350
|
-
/** @param {ChatState} state */
|
|
351
|
-
const isController = (state) => state.role === 'controller';
|
|
345
|
+
const canConnect = (state) => state.target != null && state.conn !== 'no-broker' && state.conn !== 'no-node';
|
|
352
346
|
/** @param {ChatState} state */
|
|
353
|
-
const canCompose = (state) => state
|
|
347
|
+
const canCompose = (state) => canConnect(state) && state.role === 'controller';
|
|
354
348
|
|
|
355
349
|
/** @type {import('../../core/view/contract.js').KeyBinding<ChatState>[]} */
|
|
356
350
|
export const keymap = [
|
|
@@ -359,9 +353,7 @@ export const keymap = [
|
|
|
359
353
|
{ bindingId: 'crtr.view.chat.contextual-cancel', intent: 'abort', when: isControllerStreaming },
|
|
360
354
|
{ bindingId: 'crtr.view.chat.contextual-cancel', intent: 'clearNotice', when: hasNotice, payload: (state) => state.notices[state.notices.length - 1]?.id },
|
|
361
355
|
{ bindingId: 'crtr.view.chat.contextual-cancel', intent: 'setDraft', when: hasDraft, payload: () => '' },
|
|
362
|
-
{ bindingId: 'crtr.view.chat.reconnect', intent: 'reconnect', when:
|
|
363
|
-
{ bindingId: 'crtr.view.chat.request-control', intent: 'requestControl', when: isObserver, hint: { label: 'control' } },
|
|
364
|
-
{ bindingId: 'crtr.view.chat.release-control', intent: 'releaseControl', when: isController, hint: { label: 'yield' } },
|
|
356
|
+
{ bindingId: 'crtr.view.chat.reconnect', intent: 'reconnect', when: canConnect, hint: { label: 'reconnect' } },
|
|
365
357
|
{ bindingId: 'crtr.view.chat.quit', intent: 'quit', hint: { label: 'quit' } },
|
|
366
358
|
];
|
|
367
359
|
|
|
@@ -260,12 +260,10 @@ export default function Chat({ state, dispatch }) {
|
|
|
260
260
|
const streaming = state.session?.isStreaming === true;
|
|
261
261
|
const canSend = state.channel != null && state.conn === 'open' && state.role === 'controller' && state.draft.trim() !== '';
|
|
262
262
|
const canAbort = state.channel != null && state.conn === 'open' && state.role === 'controller' && streaming;
|
|
263
|
-
const controlLabel = state.role === 'controller' ? 'Release control' : 'Request control';
|
|
264
|
-
const controlIntent = state.role === 'controller' ? 'releaseControl' : 'requestControl';
|
|
265
263
|
const placeholder = state.conn !== 'open'
|
|
266
264
|
? 'Waiting for connection…'
|
|
267
265
|
: state.role === 'observer'
|
|
268
|
-
? '
|
|
266
|
+
? 'Read-only'
|
|
269
267
|
: streaming
|
|
270
268
|
? 'Type a steer…'
|
|
271
269
|
: 'Type a message…';
|
|
@@ -278,7 +276,6 @@ export default function Chat({ state, dispatch }) {
|
|
|
278
276
|
<Pill label="role" value={state.role} cls={state.role === 'controller' ? 'border-cyan-200 bg-cyan-50 text-cyan-700' : 'border-slate-200 bg-slate-50 text-slate-700'} />
|
|
279
277
|
{model ? <Pill label="model" value={model} cls="border-violet-200 bg-violet-50 text-violet-700" /> : null}
|
|
280
278
|
{sessionName ? <Pill label="session" value={sessionName} cls="border-slate-200 bg-slate-50 text-slate-700" /> : null}
|
|
281
|
-
{state.controllerId ? <Pill label="controller" value={state.controllerId} cls="border-slate-200 bg-slate-50 text-slate-700" /> : null}
|
|
282
279
|
{typeof state.contextTokens === 'number' ? <Pill label="tokens" value={String(state.contextTokens)} cls="border-slate-200 bg-slate-50 text-slate-700" /> : null}
|
|
283
280
|
{state.transcript.activity ? <Pill label="activity" value={state.transcript.activity} cls="border-amber-200 bg-amber-50 text-amber-800" /> : null}
|
|
284
281
|
{state.queued.length ? <Pill label="queued" value={state.queued.join(' · ')} cls="border-slate-200 bg-slate-50 text-slate-700" /> : null}
|
|
@@ -286,9 +283,6 @@ export default function Chat({ state, dispatch }) {
|
|
|
286
283
|
<button type="button" className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50" onClick={() => dispatch('reconnect')}>
|
|
287
284
|
Reconnect
|
|
288
285
|
</button>
|
|
289
|
-
<button type="button" className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50" onClick={() => dispatch(controlIntent)}>
|
|
290
|
-
{controlLabel}
|
|
291
|
-
</button>
|
|
292
286
|
</div>
|
|
293
287
|
</div>
|
|
294
288
|
|
|
@@ -331,6 +325,7 @@ export default function Chat({ state, dispatch }) {
|
|
|
331
325
|
<div className="rounded-2xl border border-slate-200 bg-white p-3 shadow-sm">
|
|
332
326
|
<textarea
|
|
333
327
|
value={state.draft}
|
|
328
|
+
readOnly={state.role === 'observer'}
|
|
334
329
|
onChange={(e) => dispatch('setDraft', e.target.value)}
|
|
335
330
|
onKeyDown={(e) => {
|
|
336
331
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
@@ -120,7 +120,7 @@ test('extension_ui_dismiss tears down ONLY the overlay whose request id matches'
|
|
|
120
120
|
ic.dismissDialog('oauth-manual-1');
|
|
121
121
|
assert.equal(hides.length, 1, 'a repeat dismiss must not double-tear-down');
|
|
122
122
|
});
|
|
123
|
-
test('a new blocking dialog still supersedes the previous one (
|
|
123
|
+
test('a new blocking dialog still supersedes the previous one (correlated dismissal remains isolated)', () => {
|
|
124
124
|
const { ic, hides } = buildController();
|
|
125
125
|
ic.attachDialog(inputDialog('dialog-a'));
|
|
126
126
|
ic.attachDialog(inputDialog('dialog-b'));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AttachSession } from '../session/context.js';
|
|
2
2
|
/** The slice of the session the roster reads. */
|
|
3
|
-
export type RosterSession = Pick<AttachSession, 'nodeId' | 'remote' | 'canvasSource' | 'tui' | 'pal' | 'editor' | 'containers'>;
|
|
3
|
+
export type RosterSession = Pick<AttachSession, 'nodeId' | 'remote' | 'role' | 'canvasSource' | 'tui' | 'pal' | 'editor' | 'containers'>;
|
|
4
4
|
export interface RosterHooks {
|
|
5
5
|
/** Live per-node human-ticket counts — read fresh on every rebuild, since the
|
|
6
6
|
* viewer's attention poll reassigns the map behind us. */
|
|
@@ -55,7 +55,7 @@ function initialPaneActive() {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
export function createRoster(s, hooks) {
|
|
58
|
-
const { editor, tui, pal, nodeId, remote, canvasSource } = s;
|
|
58
|
+
const { editor, tui, pal, nodeId, remote, role, canvasSource } = s;
|
|
59
59
|
const reports = s.containers.reports;
|
|
60
60
|
let items = [];
|
|
61
61
|
let selKey; // undefined ⇒ roster inactive (editor owns the cursor)
|
|
@@ -144,6 +144,10 @@ export function createRoster(s, hooks) {
|
|
|
144
144
|
if (item === undefined || remote)
|
|
145
145
|
return;
|
|
146
146
|
if (item.kind === 'human') {
|
|
147
|
+
if (role !== 'controller') {
|
|
148
|
+
hooks.setNotice('Read-only observer attach — cannot open the human inbox');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
147
151
|
exit();
|
|
148
152
|
execFile('hl', ['inbox', 'open'], () => { });
|
|
149
153
|
return;
|
|
@@ -163,6 +167,10 @@ export function createRoster(s, hooks) {
|
|
|
163
167
|
exit();
|
|
164
168
|
return;
|
|
165
169
|
} // already viewing this one
|
|
170
|
+
if (role !== 'controller') {
|
|
171
|
+
hooks.setNotice('Read-only observer attach — cannot focus nodes');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
166
174
|
const pane = process.env['TMUX_PANE'];
|
|
167
175
|
if (pane === undefined || pane === '') {
|
|
168
176
|
hooks.setNotice('cannot swap — no tmux pane');
|
|
@@ -13,10 +13,10 @@ import { mark } from '../../core/timing.js';
|
|
|
13
13
|
const attachToLeaf = defineLeaf({
|
|
14
14
|
name: 'to',
|
|
15
15
|
description: 'attach an interactive terminal viewer to a node (reviving it if dormant)',
|
|
16
|
-
whenToUse: 'you want to WATCH or DRIVE a headless node live in this pane — it connects over the node\'s unix socket to the broker hosting the engine and renders the same chat stream
|
|
16
|
+
whenToUse: 'you want to WATCH or DRIVE a headless node live in this pane — it connects over the node\'s unix socket to the broker hosting the engine and renders the same chat stream. Normal local attaches are writable; use --observer for read-only, or --canvas for a remote read-only view. A DORMANT node is revived first: crtrd runs reviveNode server-side, then the viewer streams over the broker\'s socket. The configured detach shortcuts leave the engine running',
|
|
17
17
|
help: {
|
|
18
18
|
name: 'attach to',
|
|
19
|
-
summary: 'attach a terminal viewer to a headless node\'s running broker (
|
|
19
|
+
summary: 'attach a terminal viewer to a headless node\'s running broker (writable by default, --observer for read-only); configured detach shortcuts leave the engine running',
|
|
20
20
|
params: [
|
|
21
21
|
{
|
|
22
22
|
kind: 'positional',
|
|
@@ -30,7 +30,7 @@ const attachToLeaf = defineLeaf({
|
|
|
30
30
|
type: 'bool',
|
|
31
31
|
required: false,
|
|
32
32
|
default: false,
|
|
33
|
-
constraint: 'Attach READ-ONLY:
|
|
33
|
+
constraint: 'Attach READ-ONLY: request the observer role. Default local attaches request the writable controller role.',
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
kind: 'flag',
|
|
@@ -51,7 +51,7 @@ const attachToLeaf = defineLeaf({
|
|
|
51
51
|
outputKind: 'object',
|
|
52
52
|
effects: [
|
|
53
53
|
'Takes over the current pane in raw mode and renders the node\'s live engine stream until you use a configured detach shortcut or the broker exits.',
|
|
54
|
-
'As controller: sends prompts/steers/dialog answers to the engine over the socket. As observer: read-only.',
|
|
54
|
+
'As a writable controller: sends prompts/steers/dialog answers to the engine over the socket. As observer: read-only. Multiple writable local viewers may drive the same broker.',
|
|
55
55
|
'A dormant node is revived server-side (crtrd runs reviveNode) before the viewer connects; this LOCAL process NEVER spawns pi and NEVER writes the session — it holds only a socket to the broker.',
|
|
56
56
|
'Outside a TTY (piped): prints a short notice and exits 0 — attach is an interactive program, not a pipe stage.',
|
|
57
57
|
],
|
|
@@ -89,7 +89,7 @@ export function registerAttach() {
|
|
|
89
89
|
help: {
|
|
90
90
|
name: 'attach',
|
|
91
91
|
summary: 'attach a terminal viewer to a headless node\'s running broker',
|
|
92
|
-
model: 'The branch opens a viewer in the current pane. A TTY is required; piped invocation prints a notice and exits.
|
|
92
|
+
model: 'The branch opens a viewer in the current pane. A TTY is required; piped invocation prints a notice and exits. Normal local attaches request the writable controller role; --observer and --canvas are read-only. Multiple local writable viewers may watch and drive one broker. Configured detach shortcuts detach cleanly and the engine runs on. A dormant node is revived server-side (crtrd runs reviveNode) before the viewer connects. If the broker exits, the viewer holds the pane and auto-reconnects — through a yield/refresh relaunch, and across dormancy until the node wakes again — reporting "broker gone" and exiting only when the node is terminal (done/dead/canceled) or deleted. This local process NEVER spawns pi or writes the session — it holds only a socket to the broker. --canvas <name> is the escape hatch for a node hosted on a different machine\'s canvas — see the leaf\'s own -h.',
|
|
93
93
|
},
|
|
94
94
|
children: [attachToLeaf],
|
|
95
95
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CustomEditor } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import type { Component, KeybindingsManager, TUI } from '@earendil-works/pi-tui';
|
|
3
|
-
import { type BrokerDataFrame, type BrokerSnapshot, type ClientToBroker, type RpcExtensionUIRequest, type RpcExtensionUIResponse } from '../../../core/runtime/broker-protocol.js';
|
|
3
|
+
import { type BrokerDataFrame, type ClientRole, type BrokerSnapshot, type ClientToBroker, type RpcExtensionUIRequest, type RpcExtensionUIResponse } from '../../../core/runtime/broker-protocol.js';
|
|
4
4
|
import type { ReadOpRequest } from '../../../core/broker-client/index.js';
|
|
5
5
|
import { type Picker, type PickerControls } from '../overlays/pickers.js';
|
|
6
6
|
export interface InputControllerHooks {
|
|
@@ -21,9 +21,15 @@ export interface InputControllerHooks {
|
|
|
21
21
|
profileId?: string;
|
|
22
22
|
/** OPTIONAL: true for a REMOTE attach (`crtr surface attach --canvas`) —
|
|
23
23
|
* forwarded to the slash context so it can structurally omit/no-op the
|
|
24
|
-
* local-only native canvas commands (`/promote`, `/resume-node`, `/context`)
|
|
24
|
+
* local-only native canvas commands (`/promote`, `/resume-node`, `/context`),
|
|
25
|
+
* and (with {@link InputControllerHooks.role}) one half of the drive gate.
|
|
25
26
|
* Defaults to `false` (local, unchanged) when absent. */
|
|
26
27
|
remote?: boolean;
|
|
28
|
+
/** OPTIONAL: this viewer's FIXED capability, resolved once for the hello
|
|
29
|
+
* handshake (`resolveHelloRole`) and never renegotiated. `'observer'` makes
|
|
30
|
+
* the viewer read-only: no drive frame is emitted, while reads (`onRequest`,
|
|
31
|
+
* `get_commands`, the pickers' fetches) stay live. Absent → `'controller'`. */
|
|
32
|
+
role?: ClientRole;
|
|
27
33
|
/** OPTIONAL: toggle the GRAPH overlay — forwarded to the slash context so
|
|
28
34
|
* `/graph` opens/closes it (Unit Q wires it from runAttach). */
|
|
29
35
|
onGraph?: () => void;
|
|
@@ -99,16 +105,26 @@ export declare class InputController {
|
|
|
99
105
|
/** Monotonic paste counter — the `N` in `[Image #N]`. */
|
|
100
106
|
private pasteSeq;
|
|
101
107
|
constructor(tui: TUI, editor: CustomEditor, keybindings: KeybindingsManager, hooks: InputControllerHooks);
|
|
108
|
+
/** Why this viewer may not mutate the engine, or `undefined` when it may. The
|
|
109
|
+
* ONE capability gate: a remote attach and an `--observer` attach are both
|
|
110
|
+
* read-only, and the role is fixed at hello, so this answer cannot change
|
|
111
|
+
* mid-session. */
|
|
112
|
+
private readOnlyReason;
|
|
102
113
|
/** Central choke point for every command frame the input layer emits (direct
|
|
103
|
-
* keybindings, emitDrive, and the slash-command/picker `send` sink).
|
|
104
|
-
*
|
|
114
|
+
* keybindings, emitDrive, and the slash-command/picker `send` sink). Returns
|
|
115
|
+
* false when the gate refused the frame, so callers stop without emitting
|
|
116
|
+
* it. A writable local attach is byte-for-byte unchanged. */
|
|
105
117
|
private emitCommand;
|
|
118
|
+
/** The model-ladder keybinding (alt+m / alt+shift+m), which viewer.ts's global
|
|
119
|
+
* key listener owns rather than the editor's action table. Public so that
|
|
120
|
+
* binding rides the gate above instead of going straight to the socket. */
|
|
121
|
+
cycleLadder(direction: 'forward' | 'backward'): void;
|
|
106
122
|
/** Render extension UI requests from the broker. Blocking dialogs route their
|
|
107
123
|
* answer back to the broker; non-blocking notify requests become the viewer's
|
|
108
124
|
* normal notice line and NOTHING else — a notify must never tear down an
|
|
109
125
|
* unrelated blocking dialog whose broker request is still pending. A dialog is
|
|
110
|
-
* torn down only by (a) a NEW blocking dialog superseding it (
|
|
111
|
-
*
|
|
126
|
+
* torn down only by (a) a NEW blocking dialog superseding it, (b) its own
|
|
127
|
+
* answer, or (c) a correlated {@link dismissDialog}
|
|
112
128
|
* from the broker (the request was aborted/timed out out-of-band). */
|
|
113
129
|
attachDialog(req: RpcExtensionUIRequest): void;
|
|
114
130
|
/** Broker-driven correlated dismissal: the broker resolved this request itself
|
|
@@ -182,8 +198,7 @@ export declare class InputController {
|
|
|
182
198
|
private handleDequeue;
|
|
183
199
|
/** Send a drive frame iff the WHOLE encoded frame fits under MAX_FRAME_BYTES
|
|
184
200
|
* (the broker destroys the socket on any line over its 24 MiB read cap). Over
|
|
185
|
-
* the ceiling → notify + refuse
|
|
186
|
-
* images intact for the user to trim, never a socket-destroying overflow. */
|
|
201
|
+
* the ceiling → notify + refuse without sending a socket-destroying overflow. */
|
|
187
202
|
private emitDrive;
|
|
188
203
|
private handlePaste;
|
|
189
204
|
/** Register an image path in the paste registry and splice a clean `[Image #N]`
|