@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
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Install
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A private package in the `@north-light` npm org, so the install needs a token
|
|
6
|
+
with read access to that scope on your npm account or in `.npmrc`.
|
|
6
7
|
|
|
7
8
|
```bash
|
|
8
9
|
npm install -g @north-light/crouter
|
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,
|
|
2
|
+
import type { ArtifactListDTO, ArtifactsQuery, ContextListDTO, CreateNodeRequest, ListNodesQuery, NodeDetailDTO, 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,9 +125,6 @@ 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>;
|
|
131
128
|
getArtifacts(id: string, q?: ArtifactsQuery): Promise<ArtifactListDTO>;
|
|
132
129
|
getContext(id: string): Promise<ContextListDTO>;
|
|
133
130
|
/** Read an absolute host path as UTF-8 (capped, `truncated` when clipped) for
|
package/dist/api/client.js
CHANGED
|
@@ -219,11 +219,6 @@ 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
|
-
}
|
|
227
222
|
getArtifacts(id, q) {
|
|
228
223
|
return this.request('GET', withQuery(routes.nodeArtifacts(this.nodePath(id)), q));
|
|
229
224
|
}
|
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_*, ack, …)
|
|
34
|
+
* and `error`. The broker interleaves others (display_*, control_changed, 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,21 +145,6 @@ 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
|
-
}
|
|
163
148
|
/** `GET /v1/nodes/{id}/transcript` query. */
|
|
164
149
|
export interface TranscriptQuery {
|
|
165
150
|
limit?: number;
|
package/dist/api/routes.d.ts
CHANGED
|
@@ -9,7 +9,6 @@ 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;
|
|
13
12
|
readonly nodeTranscript: (id: string) => string;
|
|
14
13
|
readonly nodeContext: (id: string) => string;
|
|
15
14
|
readonly nodeArtifacts: (id: string) => string;
|
package/dist/api/routes.js
CHANGED
|
@@ -25,7 +25,6 @@ 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`,
|
|
29
28
|
nodeTranscript: (id) => `${V}/nodes/${id}/transcript`,
|
|
30
29
|
nodeContext: (id) => `${V}/nodes/${id}/context`,
|
|
31
30
|
nodeArtifacts: (id) => `${V}/nodes/${id}/artifacts`,
|
|
@@ -45,8 +45,10 @@
|
|
|
45
45
|
* @property {string|null} target
|
|
46
46
|
* @property {ChatRole} desiredRole
|
|
47
47
|
* @property {any|null} channel
|
|
48
|
+
* @property {string|null} clientId
|
|
48
49
|
* @property {ChatConn} conn
|
|
49
50
|
* @property {ChatRole} role
|
|
51
|
+
* @property {string|null} controllerId
|
|
50
52
|
* @property {ChatSession|null} session
|
|
51
53
|
* @property {ChatTranscript} transcript
|
|
52
54
|
* @property {string} draft
|
|
@@ -372,10 +374,12 @@ const core = {
|
|
|
372
374
|
const target = typeof opts.target === 'string' && opts.target.trim() !== '' ? opts.target : null;
|
|
373
375
|
return {
|
|
374
376
|
target,
|
|
375
|
-
desiredRole:
|
|
377
|
+
desiredRole: 'controller',
|
|
376
378
|
channel: null,
|
|
379
|
+
clientId: null,
|
|
377
380
|
conn: 'idle',
|
|
378
381
|
role: 'observer',
|
|
382
|
+
controllerId: null,
|
|
379
383
|
session: null,
|
|
380
384
|
transcript: initialTranscript(),
|
|
381
385
|
draft: '',
|
|
@@ -414,6 +418,7 @@ const core = {
|
|
|
414
418
|
ctx.set((prev) => ({
|
|
415
419
|
...prev,
|
|
416
420
|
channel,
|
|
421
|
+
clientId: channel.id,
|
|
417
422
|
conn: 'connecting',
|
|
418
423
|
}));
|
|
419
424
|
} catch (err) {
|
|
@@ -431,6 +436,7 @@ const core = {
|
|
|
431
436
|
ctx.set((prev) => ({
|
|
432
437
|
...prev,
|
|
433
438
|
channel: prev.channel ?? channel,
|
|
439
|
+
clientId: prev.clientId ?? asMaybeText(channel.id),
|
|
434
440
|
conn: 'connecting',
|
|
435
441
|
}));
|
|
436
442
|
ctx.signal.setStatus('connected — waiting for welcome');
|
|
@@ -500,7 +506,8 @@ const core = {
|
|
|
500
506
|
ctx.set((prev) => ({
|
|
501
507
|
...prev,
|
|
502
508
|
conn: 'open',
|
|
503
|
-
|
|
509
|
+
controllerId: asMaybeText(frame.controller_id),
|
|
510
|
+
role: frame.controller_id === prev.clientId ? 'controller' : (frame.role ?? 'observer'),
|
|
504
511
|
session,
|
|
505
512
|
transcript,
|
|
506
513
|
pendingDialog: frame.pending_dialog ?? null,
|
|
@@ -512,14 +519,34 @@ const core = {
|
|
|
512
519
|
return;
|
|
513
520
|
}
|
|
514
521
|
|
|
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
|
+
|
|
515
541
|
case 'model_changed': {
|
|
516
542
|
ctx.set((prev) => patchSession(prev, { model: asMaybeText(frame.model) }));
|
|
517
543
|
return;
|
|
518
544
|
}
|
|
519
545
|
|
|
520
546
|
case 'error': {
|
|
521
|
-
if (frame.code === '
|
|
522
|
-
const text = 'read-only —
|
|
547
|
+
if (frame.code === 'not_controller') {
|
|
548
|
+
const text = 'read-only — take control before sending';
|
|
549
|
+
ctx.set((prev) => ({ ...prev, role: 'observer' }));
|
|
523
550
|
ctx.set((prev) => notice(prev, text, 'action'));
|
|
524
551
|
ctx.signal.setBanner(text, 'action');
|
|
525
552
|
return;
|
|
@@ -632,7 +659,7 @@ const core = {
|
|
|
632
659
|
return;
|
|
633
660
|
}
|
|
634
661
|
if (ctx.state.role !== 'controller') {
|
|
635
|
-
const text = 'read-only —
|
|
662
|
+
const text = 'read-only — take control before sending';
|
|
636
663
|
ctx.set((prev) => notice(prev, text, 'action'));
|
|
637
664
|
ctx.signal.setBanner(text, 'action');
|
|
638
665
|
return;
|
|
@@ -645,7 +672,7 @@ const core = {
|
|
|
645
672
|
/** @param {any} ctx */
|
|
646
673
|
abort(ctx) {
|
|
647
674
|
if (ctx.state.role !== 'controller') {
|
|
648
|
-
const text = 'read-only —
|
|
675
|
+
const text = 'read-only — take control before sending';
|
|
649
676
|
ctx.set((prev) => notice(prev, text, 'action'));
|
|
650
677
|
ctx.signal.setBanner(text, 'action');
|
|
651
678
|
return;
|
|
@@ -659,6 +686,24 @@ const core = {
|
|
|
659
686
|
ctx.state.channel.send({ type: 'abort' });
|
|
660
687
|
},
|
|
661
688
|
|
|
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
|
+
|
|
662
707
|
/** @param {any} ctx @param {string|undefined} id */
|
|
663
708
|
clearNotice(ctx, id) {
|
|
664
709
|
ctx.set((prev) => {
|
|
@@ -165,6 +165,12 @@ 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
|
+
|
|
168
174
|
const model = maybeText(state.session?.model);
|
|
169
175
|
const sessionName = maybeText(state.session?.sessionName);
|
|
170
176
|
if (model || sessionName) {
|
|
@@ -326,9 +332,7 @@ export function render(state, draw, content) {
|
|
|
326
332
|
paintTail(draw, noticeRect, noticeLines(state).slice(-noticeCount));
|
|
327
333
|
|
|
328
334
|
const draft = tailClip(state.draft, Math.max(0, content.width - 2));
|
|
329
|
-
draw.spans(composerRow, content.col,
|
|
330
|
-
{ text: 'Read-only', style: { fg: '33', bold: true } },
|
|
331
|
-
] : [
|
|
335
|
+
draw.spans(composerRow, content.col, [
|
|
332
336
|
{ text: '> ', style: { fg: '33', bold: true } },
|
|
333
337
|
{ text: draft, style: undefined },
|
|
334
338
|
{ text: draft ? '█' : '', style: { fg: '33' } },
|
|
@@ -342,9 +346,11 @@ const hasNotice = (state) => state.notices.length > 0;
|
|
|
342
346
|
/** @param {ChatState} state */
|
|
343
347
|
const hasDraft = (state) => state.draft.length > 0;
|
|
344
348
|
/** @param {ChatState} state */
|
|
345
|
-
const
|
|
349
|
+
const isObserver = (state) => state.role === 'observer';
|
|
350
|
+
/** @param {ChatState} state */
|
|
351
|
+
const isController = (state) => state.role === 'controller';
|
|
346
352
|
/** @param {ChatState} state */
|
|
347
|
-
const canCompose = (state) =>
|
|
353
|
+
const canCompose = (state) => state.target != null && state.conn !== 'no-broker' && state.conn !== 'no-node';
|
|
348
354
|
|
|
349
355
|
/** @type {import('../../core/view/contract.js').KeyBinding<ChatState>[]} */
|
|
350
356
|
export const keymap = [
|
|
@@ -353,7 +359,9 @@ export const keymap = [
|
|
|
353
359
|
{ bindingId: 'crtr.view.chat.contextual-cancel', intent: 'abort', when: isControllerStreaming },
|
|
354
360
|
{ bindingId: 'crtr.view.chat.contextual-cancel', intent: 'clearNotice', when: hasNotice, payload: (state) => state.notices[state.notices.length - 1]?.id },
|
|
355
361
|
{ bindingId: 'crtr.view.chat.contextual-cancel', intent: 'setDraft', when: hasDraft, payload: () => '' },
|
|
356
|
-
{ bindingId: 'crtr.view.chat.reconnect', intent: 'reconnect', when:
|
|
362
|
+
{ bindingId: 'crtr.view.chat.reconnect', intent: 'reconnect', when: canCompose, hint: { label: 'reconnect' } },
|
|
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' } },
|
|
357
365
|
{ bindingId: 'crtr.view.chat.quit', intent: 'quit', hint: { label: 'quit' } },
|
|
358
366
|
];
|
|
359
367
|
|
|
@@ -260,10 +260,12 @@ 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';
|
|
263
265
|
const placeholder = state.conn !== 'open'
|
|
264
266
|
? 'Waiting for connection…'
|
|
265
267
|
: state.role === 'observer'
|
|
266
|
-
? '
|
|
268
|
+
? 'Take control before sending…'
|
|
267
269
|
: streaming
|
|
268
270
|
? 'Type a steer…'
|
|
269
271
|
: 'Type a message…';
|
|
@@ -276,6 +278,7 @@ export default function Chat({ state, dispatch }) {
|
|
|
276
278
|
<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'} />
|
|
277
279
|
{model ? <Pill label="model" value={model} cls="border-violet-200 bg-violet-50 text-violet-700" /> : null}
|
|
278
280
|
{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}
|
|
279
282
|
{typeof state.contextTokens === 'number' ? <Pill label="tokens" value={String(state.contextTokens)} cls="border-slate-200 bg-slate-50 text-slate-700" /> : null}
|
|
280
283
|
{state.transcript.activity ? <Pill label="activity" value={state.transcript.activity} cls="border-amber-200 bg-amber-50 text-amber-800" /> : null}
|
|
281
284
|
{state.queued.length ? <Pill label="queued" value={state.queued.join(' · ')} cls="border-slate-200 bg-slate-50 text-slate-700" /> : null}
|
|
@@ -283,6 +286,9 @@ export default function Chat({ state, dispatch }) {
|
|
|
283
286
|
<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')}>
|
|
284
287
|
Reconnect
|
|
285
288
|
</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>
|
|
286
292
|
</div>
|
|
287
293
|
</div>
|
|
288
294
|
|
|
@@ -325,7 +331,6 @@ export default function Chat({ state, dispatch }) {
|
|
|
325
331
|
<div className="rounded-2xl border border-slate-200 bg-white p-3 shadow-sm">
|
|
326
332
|
<textarea
|
|
327
333
|
value={state.draft}
|
|
328
|
-
readOnly={state.role === 'observer'}
|
|
329
334
|
onChange={(e) => dispatch('setDraft', e.target.value)}
|
|
330
335
|
onKeyDown={(e) => {
|
|
331
336
|
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 (control-handoff re-route unaffected)', () => {
|
|
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' | '
|
|
3
|
+
export type RosterSession = Pick<AttachSession, 'nodeId' | 'remote' | '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,
|
|
58
|
+
const { editor, tui, pal, nodeId, remote, 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,10 +144,6 @@ 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
|
-
}
|
|
151
147
|
exit();
|
|
152
148
|
execFile('hl', ['inbox', 'open'], () => { });
|
|
153
149
|
return;
|
|
@@ -167,10 +163,6 @@ export function createRoster(s, hooks) {
|
|
|
167
163
|
exit();
|
|
168
164
|
return;
|
|
169
165
|
} // already viewing this one
|
|
170
|
-
if (role !== 'controller') {
|
|
171
|
-
hooks.setNotice('Read-only observer attach — cannot focus nodes');
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
166
|
const pane = process.env['TMUX_PANE'];
|
|
175
167
|
if (pane === undefined || pane === '') {
|
|
176
168
|
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, letting you type prompts (as the controller) or follow read-only (as an observer). A DORMANT node is revived first: crtrd runs reviveNode server-side, then the viewer streams over the broker\'s socket. One controller drives; extra viewers are read-only. 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 (controller 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: never claim control even if it is free. Default: drive (claim control if available, else fall back to read-only).',
|
|
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
|
|
54
|
+
'As controller: sends prompts/steers/dialog answers to the engine over the socket. As observer: read-only.',
|
|
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. By default it claims control and drives the engine; --observer follows read-only. One controller plus any number of observers may watch a node. 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
|
|
3
|
+
import { type BrokerDataFrame, 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,15 +21,9 @@ 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`)
|
|
25
|
-
* and (with {@link InputControllerHooks.role}) one half of the drive gate.
|
|
24
|
+
* local-only native canvas commands (`/promote`, `/resume-node`, `/context`).
|
|
26
25
|
* Defaults to `false` (local, unchanged) when absent. */
|
|
27
26
|
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;
|
|
33
27
|
/** OPTIONAL: toggle the GRAPH overlay — forwarded to the slash context so
|
|
34
28
|
* `/graph` opens/closes it (Unit Q wires it from runAttach). */
|
|
35
29
|
onGraph?: () => void;
|
|
@@ -105,26 +99,16 @@ export declare class InputController {
|
|
|
105
99
|
/** Monotonic paste counter — the `N` in `[Image #N]`. */
|
|
106
100
|
private pasteSeq;
|
|
107
101
|
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;
|
|
113
102
|
/** Central choke point for every command frame the input layer emits (direct
|
|
114
|
-
* keybindings, emitDrive, and the slash-command/picker `send` sink).
|
|
115
|
-
*
|
|
116
|
-
* it. A writable local attach is byte-for-byte unchanged. */
|
|
103
|
+
* keybindings, emitDrive, and the slash-command/picker `send` sink). Local
|
|
104
|
+
* attach (`hooks.remote` false/absent) is byte-for-byte unchanged. */
|
|
117
105
|
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;
|
|
122
106
|
/** Render extension UI requests from the broker. Blocking dialogs route their
|
|
123
107
|
* answer back to the broker; non-blocking notify requests become the viewer's
|
|
124
108
|
* normal notice line and NOTHING else — a notify must never tear down an
|
|
125
109
|
* unrelated blocking dialog whose broker request is still pending. A dialog is
|
|
126
|
-
* torn down only by (a) a NEW blocking dialog superseding it
|
|
127
|
-
* answer, or (c) a correlated {@link dismissDialog}
|
|
110
|
+
* torn down only by (a) a NEW blocking dialog superseding it (control handoff
|
|
111
|
+
* re-route), (b) its own answer, or (c) a correlated {@link dismissDialog}
|
|
128
112
|
* from the broker (the request was aborted/timed out out-of-band). */
|
|
129
113
|
attachDialog(req: RpcExtensionUIRequest): void;
|
|
130
114
|
/** Broker-driven correlated dismissal: the broker resolved this request itself
|
|
@@ -198,7 +182,8 @@ export declare class InputController {
|
|
|
198
182
|
private handleDequeue;
|
|
199
183
|
/** Send a drive frame iff the WHOLE encoded frame fits under MAX_FRAME_BYTES
|
|
200
184
|
* (the broker destroys the socket on any line over its 24 MiB read cap). Over
|
|
201
|
-
* the ceiling → notify + refuse
|
|
185
|
+
* the ceiling → notify + refuse so the caller leaves the editor + pending
|
|
186
|
+
* images intact for the user to trim, never a socket-destroying overflow. */
|
|
202
187
|
private emitDrive;
|
|
203
188
|
private handlePaste;
|
|
204
189
|
/** Register an image path in the paste registry and splice a clean `[Image #N]`
|