@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.
Files changed (80) hide show
  1. package/README.md +2 -1
  2. package/dist/api/client.d.ts +1 -4
  3. package/dist/api/client.js +0 -5
  4. package/dist/api/dto/broker.d.ts +1 -1
  5. package/dist/api/dto/nodes.d.ts +0 -15
  6. package/dist/api/routes.d.ts +0 -1
  7. package/dist/api/routes.js +0 -1
  8. package/dist/builtin-views/chat/core.mjs +51 -6
  9. package/dist/builtin-views/chat/tui.mjs +14 -6
  10. package/dist/builtin-views/chat/web.jsx +7 -2
  11. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
  12. package/dist/clients/attach/chrome/roster.d.ts +1 -1
  13. package/dist/clients/attach/chrome/roster.js +1 -9
  14. package/dist/clients/attach/command.js +5 -5
  15. package/dist/clients/attach/input/controller.d.ts +8 -23
  16. package/dist/clients/attach/input/controller.js +29 -59
  17. package/dist/clients/attach/overlays/dialogs.d.ts +2 -1
  18. package/dist/clients/attach/overlays/graph.d.ts +1 -4
  19. package/dist/clients/attach/overlays/graph.js +7 -25
  20. package/dist/clients/attach/session/context.d.ts +0 -7
  21. package/dist/clients/attach/session/frames.js +8 -1
  22. package/dist/clients/attach/session/input-wiring.d.ts +1 -1
  23. package/dist/clients/attach/session/input-wiring.js +11 -21
  24. package/dist/clients/attach/session/mode.d.ts +3 -4
  25. package/dist/clients/attach/session/mode.js +1 -6
  26. package/dist/clients/attach/session/reconnect.d.ts +3 -3
  27. package/dist/clients/attach/session/reconnect.js +7 -6
  28. package/dist/clients/attach/session/state-sync.d.ts +1 -1
  29. package/dist/clients/attach/session/state-sync.js +2 -2
  30. package/dist/clients/attach/slash/dispatch.d.ts +1 -13
  31. package/dist/clients/attach/slash/dispatch.js +17 -65
  32. package/dist/clients/attach/viewer.js +523 -523
  33. package/dist/clients/web/web-client/shared/protocol.d.ts +3 -5
  34. package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
  35. package/dist/core/__tests__/chat-view-reconnect.test.js +23 -44
  36. package/dist/core/__tests__/full/broker-attach-limits.test.js +36 -60
  37. package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
  38. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  39. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  40. package/dist/core/__tests__/full/broker-dialogs.test.js +62 -121
  41. package/dist/core/__tests__/helpers/broker-clients.js +2 -2
  42. package/dist/core/__tests__/session-model.test.js +26 -15
  43. package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
  44. package/dist/core/keybindings/catalog.d.ts +2 -2
  45. package/dist/core/keybindings/catalog.js +2 -0
  46. package/dist/core/runtime/auth-reload.d.ts +4 -4
  47. package/dist/core/runtime/auth-reload.js +13 -9
  48. package/dist/core/runtime/boot-root.d.ts +2 -2
  49. package/dist/core/runtime/boot-root.js +7 -7
  50. package/dist/core/runtime/broker-protocol.d.ts +27 -23
  51. package/dist/core/runtime/broker-protocol.js +1 -1
  52. package/dist/core/runtime/broker-request.js +11 -5
  53. package/dist/core/runtime/broker.d.ts +13 -21
  54. package/dist/core/runtime/broker.js +186 -151
  55. package/dist/core/runtime/interactive-deliver.js +5 -4
  56. package/dist/core/runtime/model-swap.d.ts +3 -2
  57. package/dist/core/runtime/model-swap.js +4 -3
  58. package/dist/core/runtime/node-read.d.ts +0 -20
  59. package/dist/core/runtime/node-read.js +1 -34
  60. package/dist/core/runtime/resume-root.d.ts +1 -1
  61. package/dist/core/runtime/resume-root.js +6 -6
  62. package/dist/core/runtime/spawn.js +3 -3
  63. package/dist/core/session-model/session-state.d.ts +6 -8
  64. package/dist/core/session-model/session-state.js +16 -6
  65. package/dist/daemon/api/handlers/nodes.js +1 -18
  66. package/dist/daemon/manage.js +2 -2
  67. package/dist/index.d.ts +1 -1
  68. package/dist/web-client/assets/index--SsQYcKu.js +79 -0
  69. package/dist/web-client/assets/{index-CpEl9LTS.css → index-DJhQZoAj.css} +1 -1
  70. package/dist/web-client/index.html +2 -2
  71. package/dist/web-client/sw.js +1 -1
  72. package/docs/compat/hearth-crtr-v1.md +1 -1
  73. package/docs/compat/hearth-crtr-v2.md +1 -1
  74. package/docs/compat/hearth-crtr-v3.md +1 -1
  75. package/docs/compat/hearth-crtr-v4.md +3 -1
  76. package/docs/public-api.md +2 -2
  77. package/package.json +4 -4
  78. package/runtime.lock.json +2 -2
  79. package/dist/web-client/assets/index-BpyZGBhI.js +0 -79
  80. 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
- `@north-light/crouter` is a private package in the `north-light` npm org, so installing it requires npm credentials with read access to that org (`npm login`, or an `//registry.npmjs.org/:_authToken=` line in `~/.npmrc`).
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
@@ -1,5 +1,5 @@
1
1
  import type { DaemonRestartDTO, HealthDTO, StatusDTO } from './dto/health.js';
2
- import type { ArtifactListDTO, ArtifactsQuery, ContextListDTO, CreateNodeRequest, ListNodesQuery, NodeDetailDTO, NodeSessionDTO, NodeSnapshotDTO, NodeSummaryDTO, TranscriptDTO, TranscriptQuery } from './dto/nodes.js';
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
@@ -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
  }
@@ -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
  */
@@ -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;
@@ -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;
@@ -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: opts.role === 'observer' ? 'observer' : 'controller',
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
- role: frame.role ?? prev.desiredRole,
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 === 'read_only') {
522
- const text = 'read-only — sending is disabled';
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 — sending is disabled';
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 — sending is disabled';
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, state.role === 'observer' ? [
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 canConnect = (state) => state.target != null && state.conn !== 'no-broker' && state.conn !== 'no-node';
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) => canConnect(state) && state.role === 'controller';
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: canConnect, hint: { label: 'reconnect' } },
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
- ? 'Read-only'
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 (correlated dismissal remains isolated)', () => {
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' | 'role' | 'canvasSource' | 'tui' | 'pal' | 'editor' | 'containers'>;
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, role, canvasSource } = s;
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. 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',
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 (writable by default, --observer for read-only); configured detach shortcuts leave the engine running',
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: request the observer role. Default local attaches request the writable controller role.',
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 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.',
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. 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.',
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 ClientRole, type BrokerSnapshot, type ClientToBroker, type RpcExtensionUIRequest, type RpcExtensionUIResponse } from '../../../core/runtime/broker-protocol.js';
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). 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. */
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, (b) its own
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 without sending a socket-destroying overflow. */
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]`