@north-light/crouter 0.3.157 → 0.3.159

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/api/client.d.ts +4 -1
  2. package/dist/api/client.js +5 -0
  3. package/dist/api/dto/broker.d.ts +1 -1
  4. package/dist/api/dto/nodes.d.ts +15 -0
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-memory/internal/storage-tiers.md +2 -0
  8. package/dist/builtin-views/chat/core.mjs +6 -51
  9. package/dist/builtin-views/chat/tui.mjs +6 -14
  10. package/dist/builtin-views/chat/web.jsx +2 -7
  11. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
  12. package/dist/clients/attach/chrome/bash-jobs.d.ts +0 -5
  13. package/dist/clients/attach/chrome/bash-jobs.js +5 -24
  14. package/dist/clients/attach/chrome/roster.d.ts +4 -3
  15. package/dist/clients/attach/chrome/roster.js +20 -36
  16. package/dist/clients/attach/command.js +5 -5
  17. package/dist/clients/attach/input/controller.d.ts +23 -8
  18. package/dist/clients/attach/input/controller.js +59 -29
  19. package/dist/clients/attach/overlays/dialogs.d.ts +1 -2
  20. package/dist/clients/attach/overlays/graph.d.ts +4 -1
  21. package/dist/clients/attach/overlays/graph.js +25 -7
  22. package/dist/clients/attach/session/context.d.ts +7 -0
  23. package/dist/clients/attach/session/frames.js +1 -8
  24. package/dist/clients/attach/session/input-wiring.d.ts +1 -1
  25. package/dist/clients/attach/session/input-wiring.js +21 -11
  26. package/dist/clients/attach/session/mode.d.ts +4 -3
  27. package/dist/clients/attach/session/mode.js +6 -1
  28. package/dist/clients/attach/session/reconnect.d.ts +3 -3
  29. package/dist/clients/attach/session/reconnect.js +6 -7
  30. package/dist/clients/attach/session/state-sync.d.ts +1 -1
  31. package/dist/clients/attach/session/state-sync.js +2 -2
  32. package/dist/clients/attach/slash/dispatch.d.ts +13 -1
  33. package/dist/clients/attach/slash/dispatch.js +65 -17
  34. package/dist/clients/attach/viewer.js +497 -499
  35. package/dist/clients/web/web-client/shared/protocol.d.ts +5 -3
  36. package/dist/commands/node.js +142 -4
  37. package/dist/commands/surface-inspect.js +4 -3
  38. package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
  39. package/dist/core/__tests__/canvas-inbox-watcher.test.js +5 -3
  40. package/dist/core/__tests__/chat-view-reconnect.test.js +44 -23
  41. package/dist/core/__tests__/full/broker-attach-limits.test.js +60 -36
  42. package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
  43. package/dist/core/__tests__/full/broker-dialogs.test.js +121 -62
  44. package/dist/core/__tests__/helpers/broker-clients.js +2 -2
  45. package/dist/core/__tests__/session-model.test.js +15 -26
  46. package/dist/core/bash-jobs.d.ts +11 -0
  47. package/dist/core/bash-jobs.js +41 -1
  48. package/dist/core/feed/inbox.d.ts +3 -3
  49. package/dist/core/feed/inbox.js +10 -6
  50. package/dist/core/inspector/core.d.ts +17 -2
  51. package/dist/core/inspector/core.js +172 -23
  52. package/dist/core/inspector/model.d.ts +30 -1
  53. package/dist/core/inspector/model.js +39 -0
  54. package/dist/core/inspector/text.js +14 -1
  55. package/dist/core/inspector/tui.js +71 -3
  56. package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
  57. package/dist/core/keybindings/catalog.d.ts +2 -2
  58. package/dist/core/keybindings/catalog.js +3 -4
  59. package/dist/core/runtime/auth-reload.d.ts +4 -4
  60. package/dist/core/runtime/auth-reload.js +9 -13
  61. package/dist/core/runtime/boot-root.d.ts +2 -2
  62. package/dist/core/runtime/boot-root.js +7 -7
  63. package/dist/core/runtime/broker-protocol.d.ts +23 -27
  64. package/dist/core/runtime/broker-protocol.js +1 -1
  65. package/dist/core/runtime/broker-request.js +5 -11
  66. package/dist/core/runtime/broker.d.ts +21 -13
  67. package/dist/core/runtime/broker.js +151 -186
  68. package/dist/core/runtime/interactive-deliver.js +4 -5
  69. package/dist/core/runtime/model-swap.d.ts +2 -3
  70. package/dist/core/runtime/model-swap.js +3 -4
  71. package/dist/core/runtime/node-read.d.ts +20 -0
  72. package/dist/core/runtime/node-read.js +34 -1
  73. package/dist/core/runtime/resume-root.d.ts +1 -1
  74. package/dist/core/runtime/resume-root.js +6 -6
  75. package/dist/core/runtime/spawn.js +3 -3
  76. package/dist/core/session-model/session-state.d.ts +8 -6
  77. package/dist/core/session-model/session-state.js +6 -16
  78. package/dist/core/tui/host.js +21 -6
  79. package/dist/daemon/api/handlers/nodes.js +18 -1
  80. package/dist/daemon/manage.js +2 -2
  81. package/dist/index.d.ts +1 -1
  82. package/dist/pi-extensions/canvas-bash-valve.js +9 -3
  83. package/dist/web-client/assets/{index-DJhQZoAj.css → index-CpEl9LTS.css} +1 -1
  84. package/dist/web-client/assets/{index--SsQYcKu.js → index-CsuwzlcQ.js} +19 -19
  85. package/dist/web-client/index.html +2 -2
  86. package/dist/web-client/sw.js +1 -1
  87. package/docs/compat/hearth-crtr-v5.md +175 -0
  88. package/docs/public-api.md +2 -2
  89. package/package.json +2 -2
  90. package/runtime.lock.json +6 -6
  91. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +0 -1
  92. package/dist/core/__tests__/full/broker-control-preempt.test.js +0 -61
@@ -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
@@ -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
  }
@@ -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_*, control_changed, ack, …)
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
  */
@@ -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;
@@ -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;
@@ -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`,
@@ -3,6 +3,8 @@ name: internal/storage-tiers
3
3
  kind: knowledge
4
4
  description: Where crouter state belongs
5
5
  when-and-why-to-read: When locating crtr state or deciding where a new file belongs, this reference should be read so files are found or placed in the storage tier with the right ownership and durability.
6
+ system-prompt-visibility: name
7
+ file-read-visibility: none
6
8
  short-form: The two crtr storage tiers — scope root for durable user/repo content and canvas home for node-graph runtime state, node artifacts, and human tickets.
7
9
  ---
8
10
 
@@ -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
- controllerId: asMaybeText(frame.controller_id),
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 === 'not_controller') {
548
- const text = 'read-only — take control before sending';
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 — take control before sending';
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 — take control before sending';
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 isObserver = (state) => state.role === 'observer';
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.target != null && state.conn !== 'no-broker' && state.conn !== 'no-node';
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: 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' } },
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
- ? 'Take control before sending…'
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 (control-handoff re-route unaffected)', () => {
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'));
@@ -2,8 +2,3 @@ import { type BashJobStatus } from '../../../core/bash-jobs.js';
2
2
  /** Compact cockpit row: the running command (or job count) plus elapsed time.
3
3
  * `undefined` for an empty list — the caller omits the row entirely. */
4
4
  export declare function bashJobsWaitSummary(jobs: readonly BashJobStatus[], nowMs?: number): string | undefined;
5
- /** Readable drill-in document for the tmux detail surface. Watch-only — no
6
- * cancel line: the owning agent already received the exact `kill -- -<pgid>`
7
- * in its handoff notice, and persisting pgid to add a human stop affordance is
8
- * a separable future addition (measuring-stick restraint). */
9
- export declare function renderBashJobsDetails(jobs: readonly BashJobStatus[], nowMs?: number): string;
@@ -1,8 +1,8 @@
1
- // chrome/bash-jobs.ts — the attach-side formatter pair for live backgrounded bash
2
- // jobs (R3 of the Alt+C -> b bash handoff refinements design), mirroring
3
- // chrome/cron-wait.ts: a compact one-row cockpit summary and a read-only markdown
4
- // detail document. Pure — takes the already-scanned job list, never touches
5
- // disk itself (that's activeBackgroundBashJobs' job, in core/bash-jobs.ts).
1
+ // chrome/bash-jobs.ts — the attach-side formatter for live backgrounded bash jobs,
2
+ // mirroring chrome/cron-wait.ts: the compact one-row cockpit summary. Pure — takes
3
+ // the already-scanned job list, never touches disk itself (that's
4
+ // activeBackgroundBashJobs' job, in core/bash-jobs.ts). Enter on that row opens
5
+ // the Inspector's `jobs` section, which owns the drill-in rendering.
6
6
  import { formatBashElapsed } from '../../../core/bash-jobs.js';
7
7
  const CMD_CELL_CAP = 26;
8
8
  /** First line of `command`, whitespace-collapsed and capped to ~26 chars. */
@@ -22,22 +22,3 @@ export function bashJobsWaitSummary(jobs, nowMs = Date.now()) {
22
22
  const oldest = jobs[0]; // activeBackgroundBashJobs returns oldest-first
23
23
  return `${jobs.length} bash jobs · oldest ${formatBashElapsed(nowMs - oldest.startedAtMs)}`;
24
24
  }
25
- /** Readable drill-in document for the tmux detail surface. Watch-only — no
26
- * cancel line: the owning agent already received the exact `kill -- -<pgid>`
27
- * in its handoff notice, and persisting pgid to add a human stop affordance is
28
- * a separable future addition (measuring-stick restraint). */
29
- export function renderBashJobsDetails(jobs, nowMs = Date.now()) {
30
- const lines = [
31
- '# Background bash jobs',
32
- '',
33
- `${jobs.length} bash job${jobs.length === 1 ? '' : 's'} running in the background.`,
34
- ];
35
- for (const job of jobs) {
36
- lines.push('', `## ⚙ ${cmdCell(job.command)}`, '');
37
- lines.push(`- **Elapsed:** ${formatBashElapsed(nowMs - job.startedAtMs)} · started ${new Date(job.startedAtMs).toISOString()}`);
38
- lines.push(`- **Log:** \`${job.logPath}\``);
39
- lines.push(`- **Watch:** \`tail -f ${job.logPath}\``);
40
- lines.push('', '### Command', '', '```bash', job.command, '```');
41
- }
42
- return `${lines.join('\n')}\n`;
43
- }
@@ -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. */
@@ -11,8 +11,9 @@ export interface RosterHooks {
11
11
  * on close so a wait row that resolved while the popup was open disappears. */
12
12
  requestChromeRefresh: () => void;
13
13
  /** Open the viewer-owned Inspector popup (`surface inspect`) on a section —
14
- * the cron wait row routes to `schedule`; the native Inspector owns details. */
15
- openInspector: (section: 'overview' | 'schedule') => void;
14
+ * the cron wait row routes to `schedule` and the bash row to `jobs`; the
15
+ * native Inspector owns details for both. */
16
+ openInspector: (section: 'overview' | 'schedule' | 'jobs') => void;
16
17
  }
17
18
  export interface AttachRoster {
18
19
  /** Rebuild the rows from the canvas source, reconcile the selection, repaint. */
@@ -4,9 +4,11 @@
4
4
  // editor's visual bottom drops the cursor into the list; within it ↑/↓ move the
5
5
  // highlight, ↑ off the TOP row returns to the editor, ↓ CLAMPS at the bottom,
6
6
  // Enter opens the row (SWAPS this pane in place onto a node, or opens the wait
7
- // detail surface for a human/cron/bash row), Esc / any typed key returns to the
8
- // editor. Disabled entirely for a REMOTE attach: a remote graph id has no local
9
- // pane to swap into and no local jobs/crons to inspect.
7
+ // detail surface for a human/cron/bash row: a human row opens the inbox, a cron
8
+ // row the Inspector's schedule section, a bash row the Inspector's jobs section),
9
+ // Esc / any typed key returns to the editor. Disabled entirely for a REMOTE
10
+ // attach: a remote graph id has no local pane to swap into and no local
11
+ // jobs/crons to inspect.
10
12
  //
11
13
  // TWO EDITOR COUPLINGS ARE OWNED HERE, deliberately and exclusively:
12
14
  // • `editor.onCursorDownAtBottom` — the boundary handoff. The editor owns
@@ -23,18 +25,12 @@
23
25
  // graph, so `refresh()` rebuilds the cached rows asynchronously while arrow
24
26
  // movement only reverses the selected cached row and renders immediately.
25
27
  import { execFile, execFileSync } from 'node:child_process';
26
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
27
- import { tmpdir } from 'node:os';
28
- import { join } from 'node:path';
29
28
  import { Text, matchesKey } from '@earendil-works/pi-tui';
30
29
  import { cliClient } from '../../../commands/api-client.js';
31
30
  import { activeBackgroundBashJobs } from '../../../core/bash-jobs.js';
32
31
  import { contextDir } from '../../../core/canvas/paths.js';
33
32
  import { fillBar, fillWidth, REVERSE } from '../../../core/canvas/nav-render.js';
34
- import { inTmux, shellQuote } from '../../../core/runtime/placement-tmux.js';
35
- import { surfaceTmuxStyleArgs } from '../../../core/runtime/surface-bg.js';
36
33
  import { buildCanvasPanelLines } from './canvas-panels.js';
37
- import { renderBashJobsDetails } from './bash-jobs.js';
38
34
  /** Seed pane focus from the pane's REAL active state, not an unconditional
39
35
  * `true`. tmux only emits DECSET 1004 focus events on a TRANSITION, so a viewer
40
36
  * spawned into a non-active pane (a `split -d` beside another node — the
@@ -55,7 +51,7 @@ function initialPaneActive() {
55
51
  }
56
52
  }
57
53
  export function createRoster(s, hooks) {
58
- const { editor, tui, pal, nodeId, remote, canvasSource } = s;
54
+ const { editor, tui, pal, nodeId, remote, role, canvasSource } = s;
59
55
  const reports = s.containers.reports;
60
56
  let items = [];
61
57
  let selKey; // undefined ⇒ roster inactive (editor owns the cursor)
@@ -114,36 +110,15 @@ export function createRoster(s, hooks) {
114
110
  selKey = items[0].key;
115
111
  paint();
116
112
  };
117
- /** Open a read-only markdown detail popup in nvim, deleting the temp file when
118
- * the popup closes and re-running the chrome refresh (background-bash wait row). */
119
- const openDetailsPopup = (prefix, file, title, missingNvim, body) => {
120
- const dir = mkdtempSync(join(tmpdir(), prefix));
121
- const path = join(dir, file);
122
- writeFileSync(path, body, 'utf8');
123
- const cmd = `if command -v nvim >/dev/null 2>&1; then exec nvim -R -c 'setlocal filetype=markdown' -- ${shellQuote(path)}; else printf '${missingNvim}\\n'; printf 'Press Enter to close.'; read _; fi`;
124
- execFile('tmux', ['display-popup', '-E', '-T', title, '-w', '90%', '-h', '85%', ...surfaceTmuxStyleArgs(), cmd], () => {
125
- rmSync(dir, { recursive: true, force: true });
126
- hooks.requestChromeRefresh();
127
- });
128
- };
129
- const openBashJobsDetails = () => {
130
- const current = activeBackgroundBashJobs(contextDir(nodeId));
131
- if (current.length === 0) {
132
- hooks.setNotice('Background job already finished');
133
- hooks.requestChromeRefresh();
134
- return;
135
- }
136
- if (!inTmux()) {
137
- hooks.setNotice('Background job details require tmux');
138
- return;
139
- }
140
- openDetailsPopup('crtr-bash-jobs-', 'background-bash-jobs.md', ' background bash jobs ', 'nvim is required to inspect background bash jobs. Install Neovim and retry.', renderBashJobsDetails(current));
141
- };
142
113
  const openSelected = () => {
143
114
  const item = items.find((candidate) => candidate.key === selKey);
144
115
  if (item === undefined || remote)
145
116
  return;
146
117
  if (item.kind === 'human') {
118
+ if (role !== 'controller') {
119
+ hooks.setNotice('Read-only observer attach — cannot open the human inbox');
120
+ return;
121
+ }
147
122
  exit();
148
123
  execFile('hl', ['inbox', 'open'], () => { });
149
124
  return;
@@ -154,8 +129,13 @@ export function createRoster(s, hooks) {
154
129
  return;
155
130
  }
156
131
  if (item.kind === 'bash') {
132
+ if (activeBackgroundBashJobs(contextDir(nodeId)).length === 0) {
133
+ hooks.setNotice('Background job already finished');
134
+ hooks.requestChromeRefresh();
135
+ return;
136
+ }
157
137
  exit();
158
- openBashJobsDetails();
138
+ hooks.openInspector('jobs');
159
139
  return;
160
140
  }
161
141
  const id = item.nodeId;
@@ -163,6 +143,10 @@ export function createRoster(s, hooks) {
163
143
  exit();
164
144
  return;
165
145
  } // already viewing this one
146
+ if (role !== 'controller') {
147
+ hooks.setNotice('Read-only observer attach — cannot focus nodes');
148
+ return;
149
+ }
166
150
  const pane = process.env['TMUX_PANE'];
167
151
  if (pane === undefined || pane === '') {
168
152
  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, 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',
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 (controller 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 (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: never claim control even if it is free. Default: drive (claim control if available, else fall back to 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. 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.',
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). Local
104
- * attach (`hooks.remote` false/absent) is byte-for-byte unchanged. */
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 (control handoff
111
- * re-route), (b) its own answer, or (c) a correlated {@link dismissDialog}
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 so the caller leaves the editor + pending
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]`