@north-light/crouter 0.3.213 → 0.3.215

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 (113) hide show
  1. package/dist/api/dto/inbox.d.ts +19 -0
  2. package/dist/api/dto/nodes.d.ts +2 -1
  3. package/dist/api/dto/profiles.d.ts +13 -1
  4. package/dist/builtin-memory/00-runtime-base.md +12 -11
  5. package/dist/builtin-memory/01-spine/00-has-manager.md +1 -11
  6. package/dist/builtin-memory/02-lifecycle/00-terminal.md +4 -10
  7. package/dist/builtin-memory/04-orchestration-kernel.md +9 -35
  8. package/dist/builtin-memory/05-kinds/design/00-base.md +2 -2
  9. package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +1 -1
  10. package/dist/builtin-memory/05-kinds/developer/00-base.md +2 -2
  11. package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -1
  12. package/dist/builtin-memory/05-kinds/general/00-base.md +2 -0
  13. package/dist/builtin-memory/05-kinds/plan/00-base.md +1 -1
  14. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -1
  15. package/dist/builtin-memory/05-kinds/review/00-base.md +1 -3
  16. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
  17. package/dist/builtin-memory/05-kinds/review/security-findings.md +12 -0
  18. package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -1
  19. package/dist/clients/attach/__tests__/context-message.test.js +33 -5
  20. package/dist/clients/attach/chrome/canvas-panels.d.ts +0 -4
  21. package/dist/clients/attach/chrome/canvas-panels.js +5 -7
  22. package/dist/clients/attach/chrome/inbox-strip.d.ts +31 -0
  23. package/dist/clients/attach/chrome/inbox-strip.js +187 -0
  24. package/dist/clients/attach/chrome/roster.d.ts +6 -2
  25. package/dist/clients/attach/chrome/roster.js +13 -52
  26. package/dist/clients/attach/chrome/ticket-panel.d.ts +29 -0
  27. package/dist/clients/attach/chrome/ticket-panel.js +236 -0
  28. package/dist/clients/attach/render/card-presentation.d.ts +8 -3
  29. package/dist/clients/attach/render/card-presentation.js +48 -10
  30. package/dist/clients/attach/render/context-message.d.ts +5 -0
  31. package/dist/clients/attach/render/context-message.js +20 -14
  32. package/dist/clients/attach/session/context.d.ts +3 -0
  33. package/dist/clients/attach/session/frame.d.ts +4 -0
  34. package/dist/clients/attach/session/frame.js +8 -3
  35. package/dist/clients/attach/session/keys.d.ts +7 -0
  36. package/dist/clients/attach/session/keys.js +7 -0
  37. package/dist/clients/attach/session/layout.js +6 -3
  38. package/dist/clients/attach/session/pane-focus.d.ts +8 -0
  39. package/dist/clients/attach/session/pane-focus.js +42 -0
  40. package/dist/clients/attach/viewer.js +794 -790
  41. package/dist/clients/inbox/controller.d.ts +9 -0
  42. package/dist/clients/inbox/controller.js +57 -2
  43. package/dist/clients/inbox/surface.d.ts +2 -0
  44. package/dist/clients/inbox/surface.js +18 -3
  45. package/dist/clients/inbox/tui.d.ts +2 -0
  46. package/dist/clients/inbox/tui.js +11 -1
  47. package/dist/commands/node/create.js +3 -3
  48. package/dist/commands/profile/kind.d.ts +2 -0
  49. package/dist/commands/profile/kind.js +51 -0
  50. package/dist/commands/profile/list.js +5 -1
  51. package/dist/commands/profile/meta.d.ts +4 -0
  52. package/dist/commands/profile/meta.js +67 -0
  53. package/dist/commands/profile/new.js +33 -3
  54. package/dist/commands/profile/pause.d.ts +2 -0
  55. package/dist/commands/profile/pause.js +60 -0
  56. package/dist/commands/profile/show.js +9 -1
  57. package/dist/commands/profile.js +5 -8
  58. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +40 -0
  59. package/dist/core/__tests__/canvas-inbox-watcher.test.js +1 -1
  60. package/dist/core/__tests__/dead-node-policy-table.test.js +13 -0
  61. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +28 -0
  62. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -0
  63. package/dist/core/command.js +23 -23
  64. package/dist/core/feed/inbox.js +1 -6
  65. package/dist/core/help.d.ts +1 -1
  66. package/dist/core/human/component-docs.js +3 -2
  67. package/dist/core/human/page-schema.d.ts +28 -0
  68. package/dist/core/human/page-schema.js +35 -2
  69. package/dist/core/human/scan.js +7 -1
  70. package/dist/core/human/types.d.ts +4 -0
  71. package/dist/core/keybindings/attach-control.d.ts +3 -0
  72. package/dist/core/keybindings/attach-control.js +1 -0
  73. package/dist/core/keybindings/catalog.js +1 -0
  74. package/dist/core/profiles/deletion-reservation.d.ts +7 -3
  75. package/dist/core/profiles/deletion-reservation.js +9 -5
  76. package/dist/core/profiles/manifest.d.ts +27 -1
  77. package/dist/core/profiles/manifest.js +122 -4
  78. package/dist/core/runtime/boot-root.js +3 -3
  79. package/dist/core/runtime/broker/inbox.js +1 -5
  80. package/dist/core/runtime/close.js +10 -5
  81. package/dist/core/runtime/revive.js +2 -0
  82. package/dist/core/runtime/spawn-env.d.ts +9 -1
  83. package/dist/core/runtime/spawn-env.js +17 -1
  84. package/dist/core/substrate/on-read.js +16 -0
  85. package/dist/core/termrender/termrender.d.ts +7 -2
  86. package/dist/core/termrender/termrender.js +11 -6
  87. package/dist/core/termrender/version.d.ts +1 -1
  88. package/dist/core/termrender/version.js +1 -1
  89. package/dist/daemon/api/__tests__/profile-launch-gates.test.d.ts +1 -0
  90. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +111 -0
  91. package/dist/daemon/api/handlers/inbox.js +31 -41
  92. package/dist/daemon/api/handlers/messages.js +9 -2
  93. package/dist/daemon/api/handlers/nodes.d.ts +2 -0
  94. package/dist/daemon/api/handlers/nodes.js +15 -8
  95. package/dist/daemon/api/handlers/profiles.js +7 -2
  96. package/dist/daemon/api/map.js +3 -0
  97. package/dist/daemon/fleet.d.ts +8 -4
  98. package/dist/daemon/fleet.js +37 -9
  99. package/dist/daemon/reconcilers/broker-supervision.js +15 -25
  100. package/dist/daemon/reconcilers/dormant-inbox.js +10 -10
  101. package/dist/daemon/reconcilers/live-obligation.d.ts +14 -1
  102. package/dist/daemon/reconcilers/live-obligation.js +27 -18
  103. package/dist/pi-extensions/canvas-inbox-watcher.js +15 -0
  104. package/dist/shared/__tests__/generated-context-grammar.test.js +4 -6
  105. package/dist/shared/generated-context.d.ts +0 -4
  106. package/dist/shared/generated-context.js +6 -9
  107. package/dist/types.d.ts +9 -0
  108. package/package.json +1 -1
  109. package/runtime.lock.json +2 -2
  110. package/dist/builtin-memory/01-spine/01-no-manager.md +0 -11
  111. package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +0 -8
  112. /package/dist/builtin-memory/05-kinds/advisor/{00-base.md → advice-contract.md} +0 -0
  113. /package/dist/builtin-memory/05-kinds/plan/reviewers/{00-base.md → lens-contract.md} +0 -0
@@ -0,0 +1,187 @@
1
+ // chrome/inbox-strip.ts — the pending-request strip pinned at the top of the
2
+ // pane, below the recap goal line.
3
+ //
4
+ // One row per pending human ticket raised by THIS node or any node in its
5
+ // subtree (self + descendants, convo children excluded), newest first. A
6
+ // dedicated binding moves the highlight up into it (roster-style — no real
7
+ // focus steal); ↑/↓ and j/k move, Enter opens the ticket's page inline in the
8
+ // cockpit (chrome/ticket-panel.ts), Esc / ↓ off the bottom / typing returns to
9
+ // the editor. Zero pending → zero rows, invisible.
10
+ //
11
+ // The strip owns the SUBTREE ticket scan and caches it: the viewer's inbox
12
+ // surface merges `pendingTickets()` into its own scan so an Enter-opened
13
+ // child ticket is present in the surface it seats.
14
+ import { Container, Text, decodeKittyPrintable, matchesKey } from '@earendil-works/pi-tui';
15
+ import { DynamicBorder } from '@earendil-works/pi-coding-agent';
16
+ import { scanInbox, scanPageHistory } from '../../../core/human/scan.js';
17
+ import { fillBar, fillWidth, navLabel, stripSgr, truncate, REVERSE, YELLOW, RESET } from '../../../core/canvas/nav-render.js';
18
+ /** Ticket rows shown at once; the window follows the selection. */
19
+ const MAX_ROWS = 3;
20
+ function ticketTime(t) {
21
+ return t.kind === 'page' ? t.emittedAt : t.blockedSince;
22
+ }
23
+ function ageLabel(iso) {
24
+ const ms = Date.now() - Date.parse(iso);
25
+ if (!Number.isFinite(ms) || ms < 0)
26
+ return 'now';
27
+ const minutes = Math.floor(ms / 60_000);
28
+ if (minutes < 1)
29
+ return 'now';
30
+ if (minutes < 60)
31
+ return `${minutes}m`;
32
+ const hours = Math.floor(minutes / 60);
33
+ if (hours < 24)
34
+ return `${hours}h`;
35
+ return `${Math.floor(hours / 24)}d`;
36
+ }
37
+ export function createInboxStrip(s, hooks) {
38
+ const { nodeId, remote, canvasSource, tui, pal } = s;
39
+ const component = new Container();
40
+ let tickets = [];
41
+ let nodes = new Map();
42
+ let selDir; // undefined ⇒ strip inactive (editor owns the cursor)
43
+ let top = 0; // first visible ticket row
44
+ const rowLine = (t) => {
45
+ const origin = t.source.nodeId;
46
+ const from = origin !== undefined && origin !== nodeId
47
+ ? ` · ${navLabel(nodes.get(origin) ?? null, origin)}`
48
+ : '';
49
+ return truncate(` ${YELLOW}⚑${RESET} ${t.title}${pal.faint(` · ${ageLabel(ticketTime(t))}${from}`)}`);
50
+ };
51
+ const paint = () => {
52
+ hooks.syncCursor();
53
+ component.clear();
54
+ if (tickets.length > 0 && !hooks.suppressed()) {
55
+ const i = selDir === undefined ? -1 : tickets.findIndex((t) => t.dir === selDir);
56
+ if (i >= 0)
57
+ top = Math.min(Math.max(top, i - MAX_ROWS + 1), i);
58
+ top = Math.max(0, Math.min(top, Math.max(0, tickets.length - MAX_ROWS)));
59
+ for (const t of tickets.slice(top, top + MAX_ROWS)) {
60
+ const line = rowLine(t);
61
+ component.addChild(new Text(t.dir === selDir ? fillBar(stripSgr(line), fillWidth(), REVERSE) : line, 1, 0));
62
+ }
63
+ const hidden = tickets.length - Math.min(MAX_ROWS, tickets.length - top);
64
+ if (hidden > 0)
65
+ component.addChild(new Text(pal.faint(` +${hidden} more`), 1, 0));
66
+ component.addChild(new DynamicBorder(pal.border));
67
+ }
68
+ tui.requestRender();
69
+ };
70
+ /** Self + descendants through subscriptions, skipping convo (human-kind) children. */
71
+ const subtreeNodes = async () => {
72
+ const out = new Map();
73
+ const visit = async (id) => {
74
+ if (out.has(id))
75
+ return;
76
+ const meta = await canvasSource.getNode(id).catch(() => null);
77
+ if (id !== nodeId && meta?.kind === 'human')
78
+ return;
79
+ out.set(id, meta);
80
+ const refs = await canvasSource.subscriptionsOf(id).catch(() => []);
81
+ for (const ref of refs)
82
+ await visit(ref.node_id);
83
+ };
84
+ await visit(nodeId);
85
+ return out;
86
+ };
87
+ const refresh = async () => {
88
+ if (remote)
89
+ return; // a remote attach cannot read this canvas's ticket dirs
90
+ nodes = await subtreeNodes();
91
+ // Same pending merge the inbox surface does — inbox-projected tickets plus
92
+ // reply-bearing no-inbox pages — but for every subtree node, then scoped to
93
+ // tickets whose origin is in the subtree.
94
+ const merged = new Map();
95
+ for (const item of scanInbox())
96
+ merged.set(item.dir, item);
97
+ for (const id of nodes.keys()) {
98
+ for (const page of scanPageHistory(id)) {
99
+ if (page.state !== 'pending' || !page.awaitsResponse || page.inbox)
100
+ continue;
101
+ if (!merged.has(page.dir))
102
+ merged.set(page.dir, page);
103
+ }
104
+ }
105
+ tickets = [...merged.values()]
106
+ .filter((t) => t.source.nodeId !== undefined && nodes.has(t.source.nodeId))
107
+ .sort((a, b) => ticketTime(b).localeCompare(ticketTime(a)) || a.id.localeCompare(b.id));
108
+ // The selected row may have vanished (answered, canceled) — drop back to
109
+ // the editor so no ghost highlight lingers.
110
+ if (selDir !== undefined && !tickets.some((t) => t.dir === selDir))
111
+ selDir = undefined;
112
+ paint();
113
+ };
114
+ const exit = () => {
115
+ if (selDir !== undefined) {
116
+ selDir = undefined;
117
+ paint();
118
+ }
119
+ };
120
+ const toggle = () => {
121
+ if (remote)
122
+ return;
123
+ if (selDir !== undefined) {
124
+ exit();
125
+ return;
126
+ }
127
+ if (tickets.length === 0) {
128
+ hooks.setNotice('No pending human requests in this subtree');
129
+ return;
130
+ }
131
+ hooks.exitRoster();
132
+ selDir = tickets[0].dir;
133
+ top = 0;
134
+ paint();
135
+ };
136
+ const openSelected = () => {
137
+ const ticket = tickets.find((t) => t.dir === selDir);
138
+ if (ticket === undefined)
139
+ return;
140
+ exit();
141
+ hooks.openTicket(ticket);
142
+ };
143
+ const handleKey = (data) => {
144
+ if (selDir === undefined)
145
+ return false;
146
+ const i = tickets.findIndex((t) => t.dir === selDir);
147
+ // j/k are printable nav keys here, like the graph and browse lists — decode
148
+ // them so they move the highlight instead of falling to the typed-key exit.
149
+ const ch = decodeKittyPrintable(data) ?? (data.length === 1 ? data : undefined);
150
+ if (matchesKey(data, 'up') || ch === 'k') {
151
+ if (i > 0) {
152
+ selDir = tickets[i - 1].dir;
153
+ paint();
154
+ }
155
+ return true;
156
+ }
157
+ if (matchesKey(data, 'down') || ch === 'j') {
158
+ if (i >= 0 && i < tickets.length - 1) {
159
+ selDir = tickets[i + 1].dir;
160
+ paint();
161
+ }
162
+ else
163
+ exit(); // ↓ off the bottom returns to the editor
164
+ return true;
165
+ }
166
+ if (matchesKey(data, 'enter')) {
167
+ openSelected();
168
+ return true;
169
+ }
170
+ if (matchesKey(data, 'escape')) {
171
+ exit();
172
+ return true;
173
+ }
174
+ // Any other key (the user started typing) returns to the editor AND passes
175
+ // through, so the keystroke lands in the input.
176
+ exit();
177
+ return false;
178
+ };
179
+ return {
180
+ component,
181
+ refresh,
182
+ handleKey,
183
+ toggle,
184
+ active: () => selDir !== undefined,
185
+ pendingTickets: () => [...tickets],
186
+ };
187
+ }
@@ -19,6 +19,8 @@ export interface RosterHooks {
19
19
  * the cron wait row routes to `schedule` and the bash row to `jobs`; the
20
20
  * native Inspector owns details for both. */
21
21
  openInspector: (section: 'overview' | 'schedule' | 'jobs') => void;
22
+ /** Recompute the editor's hollow-cursor state (session/pane-focus.ts). */
23
+ syncCursor: () => void;
22
24
  }
23
25
  export interface AttachRoster {
24
26
  /** Rebuild the rows from the canvas source, reconcile the selection, repaint. */
@@ -26,7 +28,9 @@ export interface AttachRoster {
26
28
  /** Roster key routing, called BEFORE the editor sees the key. Returns true
27
29
  * when the roster consumed it (the editor must not see it). */
28
30
  handleKey(data: string): boolean;
29
- /** Report real tmux-pane focus (DECSET 1004). Repaints the editor cursor. */
30
- setPaneFocused(focused: boolean): void;
31
+ /** True while the roster holds the highlight. */
32
+ active(): boolean;
33
+ /** Drop the highlight back to the editor (the inbox strip activating). */
34
+ exit(): void;
31
35
  }
32
36
  export declare function createRoster(s: RosterSession, hooks: RosterHooks): AttachRoster;
@@ -4,53 +4,31 @@
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: a human row opens the inbox, a cron
8
- // row the Inspector's schedule section, a bash row the Inspector's jobs section,
9
- // a review row the review's companion viewer),
7
+ // detail surface for a cron/bash/review row: a cron row the Inspector's
8
+ // schedule section, a bash row the Inspector's jobs section, a review row the
9
+ // review's companion viewer),
10
10
  // Esc / any typed key returns to the editor. Disabled entirely for a REMOTE
11
11
  // attach: a remote graph id has no local pane to swap into and no local
12
12
  // jobs/crons to inspect.
13
13
  //
14
- // TWO EDITOR COUPLINGS ARE OWNED HERE, deliberately and exclusively:
15
- // • `editor.onCursorDownAtBottom` — the boundary handoff. The editor owns
16
- // native ↓ navigation and calls this only after both its visual cursor and
17
- // its prompt history are exhausted, which makes the handoff a direct
18
- // boundary transition rather than a global-listener replay.
19
- // • `editor.paneFocused` — the hollow-cursor state. The cursor is dimmed
20
- // whenever the tmux pane is blurred OR the roster holds the cursor: while
21
- // scrolling the list the editor is "away", so it must read exactly like an
22
- // unfocused pane. The viewer feeds pane focus in through `setPaneFocused`;
23
- // the roster half of that conjunction is private to this module.
14
+ // ONE EDITOR COUPLING IS OWNED HERE, deliberately and exclusively:
15
+ // `editor.onCursorDownAtBottom` — the boundary handoff. The editor owns native
16
+ // ↓ navigation and calls this only after both its visual cursor and its prompt
17
+ // history are exhausted, which makes the handoff a direct boundary transition
18
+ // rather than a global-listener replay. The hollow-cursor state lives in
19
+ // session/pane-focus.ts; the roster reports its held highlight through
20
+ // `active()` and pokes `hooks.syncCursor` on every repaint.
24
21
  //
25
22
  // Repainting a selection is purely local: canvas reads can traverse the whole
26
23
  // graph, so `refresh()` rebuilds the cached rows asynchronously while arrow
27
24
  // movement only reverses the selected cached row and renders immediately.
28
- import { execFile, execFileSync } from 'node:child_process';
25
+ import { execFile } from 'node:child_process';
29
26
  import { Text, decodeKittyPrintable, matchesKey } from '@earendil-works/pi-tui';
30
27
  import { cliClient } from '../../../commands/api-client.js';
31
28
  import { activeBackgroundBashJobs } from '../../../core/bash-jobs.js';
32
29
  import { contextDir } from '../../../core/canvas/paths.js';
33
30
  import { fillBar, fillWidth, stripSgr, REVERSE } from '../../../core/canvas/nav-render.js';
34
31
  import { buildCanvasPanelLines } from './canvas-panels.js';
35
- /** Seed pane focus from the pane's REAL active state, not an unconditional
36
- * `true`. tmux only emits DECSET 1004 focus events on a TRANSITION, so a viewer
37
- * spawned into a non-active pane (a `split -d` beside another node — the
38
- * auto-viewer for a background child) starts unfocused and NEVER receives a
39
- * FOCUS_OUT, leaving a solid cursor drawn until the user swaps in and back out.
40
- * Query `#{pane_active}` for our own pane once at startup so an unfocused split
41
- * renders hollow immediately; default true when not in tmux or the query fails. */
42
- function initialPaneActive() {
43
- const pane = process.env['TMUX_PANE'];
44
- if (pane === undefined || pane === '')
45
- return true;
46
- try {
47
- const out = execFileSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_active}'], { encoding: 'utf8' }).trim();
48
- return out !== '0';
49
- }
50
- catch {
51
- return true;
52
- }
53
- }
54
32
  /** How long an armed `x` waits for its `y`. */
55
33
  const ARM_TIMEOUT_MS = 15_000;
56
34
  export function createRoster(s, hooks) {
@@ -63,18 +41,14 @@ export function createRoster(s, hooks) {
63
41
  let autoSelect = process.env['CRTR_ATTACH_ROSTER'];
64
42
  if (autoSelect !== undefined)
65
43
  delete process.env['CRTR_ATTACH_ROSTER'];
66
- let paneFocused = initialPaneActive();
67
44
  // Armed job cancellation: `x` on the bash row names the job, `y` confirms.
68
45
  // Held here (not in the item list) so any other key, a selection move, or a
69
46
  // rebuild that retires the job drops it — a stale confirmation must never
70
47
  // kill a job the user is no longer looking at.
71
48
  let pendingKill;
72
49
  let killTimer;
73
- const syncCursorDim = () => {
74
- editor.paneFocused = paneFocused && selKey === undefined;
75
- };
76
50
  const paint = () => {
77
- syncCursorDim();
51
+ hooks.syncCursor();
78
52
  reports.clear();
79
53
  for (const item of items) {
80
54
  const selected = item.key === selKey;
@@ -191,15 +165,6 @@ export function createRoster(s, hooks) {
191
165
  const item = items.find((candidate) => candidate.key === selKey);
192
166
  if (item === undefined || remote)
193
167
  return;
194
- if (item.kind === 'human') {
195
- if (role !== 'controller') {
196
- hooks.setNotice('Read-only observer attach — cannot open the human inbox');
197
- return;
198
- }
199
- exit();
200
- execFile('hl', ['inbox', 'open'], () => { });
201
- return;
202
- }
203
168
  if (item.kind === 'crons') {
204
169
  exit();
205
170
  hooks.openInspector('schedule');
@@ -306,9 +271,5 @@ export function createRoster(s, hooks) {
306
271
  exit();
307
272
  return false;
308
273
  };
309
- const setPaneFocused = (focused) => {
310
- paneFocused = focused;
311
- syncCursorDim();
312
- };
313
- return { refresh, handleKey, setPaneFocused };
274
+ return { refresh, handleKey, active: () => selKey !== undefined, exit };
314
275
  }
@@ -0,0 +1,29 @@
1
+ import type { Component } from '@earendil-works/pi-tui';
2
+ import type { TicketSummary } from '../../../core/human/types.js';
3
+ import type { AttachSession } from '../session/context.js';
4
+ export type PanelSession = Pick<AttachSession, 'tui' | 'pal'>;
5
+ export interface TicketPanelHooks {
6
+ /** Recompute the editor's hollow-cursor state (session/pane-focus.ts). */
7
+ syncCursor: () => void;
8
+ /** Repaint the strip — its rows hide while the panel is their expansion. */
9
+ refreshStrip: () => void;
10
+ setNotice: (msg: string) => void;
11
+ /** Fallback: the full-pane inbox surface seated on this ticket dir. */
12
+ openInbox: (dir: string) => void;
13
+ /** Release the terminal to $EDITOR for the comment roundtrip. */
14
+ suspendForChild: () => () => void;
15
+ }
16
+ export interface TicketPanel {
17
+ /** Mounted once into the frame's `ticketPanel` slot; empty while closed. */
18
+ readonly component: Component;
19
+ /** Open a ticket's page inline; review/unreadable tickets fall back. */
20
+ open(ticket: TicketSummary): void;
21
+ /** Panel key routing — consumes EVERY chunk while open. */
22
+ handleKey(data: string): boolean;
23
+ close(): void;
24
+ /** True while a page is open (the editor's cursor hollows). */
25
+ active(): boolean;
26
+ /** The open ticket's dir, for escalating to the full inbox surface. */
27
+ currentDir(): string | undefined;
28
+ }
29
+ export declare function createTicketPanel(s: PanelSession, hooks: TicketPanelHooks): TicketPanel;
@@ -0,0 +1,236 @@
1
+ // chrome/ticket-panel.ts — the inline ticket page at the top of the pane.
2
+ //
3
+ // Enter on an inbox-strip row opens the ticket's page HERE, directly beneath
4
+ // the recap header where the strip's notification row sat — the row expands
5
+ // into the full page. It takes only the rows the page needs, growing all the
6
+ // way down to the cockpit for a tall page (the transcript compresses to zero
7
+ // before the editor gives a row), instead of the full-pane inbox surface.
8
+ // While open it owns every keystroke — pages carry text inputs — so the
9
+ // global key listener routes chunks to `handleKey` ahead of the strip and
10
+ // roster; Esc at the page top closes back to the editor.
11
+ //
12
+ // The claim lifecycle mirrors the inbox surface's detail screen: claim on
13
+ // open, heartbeat every 10s, watch the ticket dir (page.json → reload, other
14
+ // activity → close if resolved elsewhere), resolve through the daemon on
15
+ // complete, release on close. Review tickets and unreadable pages fall back
16
+ // to the full-pane inbox seated on the row.
17
+ import { watch } from 'node:fs';
18
+ import { claimTicket, heartbeatClaim, releaseClaim } from '../../../core/human/claim.js';
19
+ import { readPageTicket, readTicketResult } from '../../../core/human/tickets.js';
20
+ import { resolvePageComponents } from '../../../core/config.js';
21
+ import { fillBar, stripSgr, truncate, REVERSE } from '../../../core/canvas/nav-render.js';
22
+ import { visibleWidth } from '../../inbox/tui/ansi.js';
23
+ import { PageAdapter } from '../../inbox/page-adapter.js';
24
+ import { decodeInboxKey } from '../../inbox/tui/keys.js';
25
+ import { daemonErrorMessage, resolveFailureKind, resolveTicketPage } from '../../inbox/resolve.js';
26
+ import { editBufferInEditor } from '../../inbox/review/roundtrip.js';
27
+ export function createTicketPanel(s, hooks) {
28
+ const { tui, pal } = s;
29
+ let session;
30
+ let submitting = false;
31
+ let lastCols = 0;
32
+ let lastRows = 0;
33
+ /** Content rows offered to the page: everything but the recap header and the
34
+ * cockpit's minimum (activity · padding · editor · status) plus our own
35
+ * title bar and closing rule. The frame clips to the rows actually free. */
36
+ const capRows = () => Math.max(3, tui.terminal.rows - 10);
37
+ const closeSession = (release) => {
38
+ const open = session;
39
+ if (open === undefined)
40
+ return;
41
+ session = undefined;
42
+ clearInterval(open.heartbeat);
43
+ open.watcher?.close();
44
+ open.adapter.close();
45
+ if (release)
46
+ releaseClaim(open.dir, open.token);
47
+ hooks.syncCursor();
48
+ hooks.refreshStrip();
49
+ tui.requestRender();
50
+ };
51
+ const complete = async (open, responses) => {
52
+ submitting = true;
53
+ try {
54
+ await resolveTicketPage(open.dir, responses);
55
+ closeSession(false);
56
+ hooks.setNotice('Response sent');
57
+ }
58
+ catch (error) {
59
+ const failure = resolveFailureKind(error);
60
+ if (failure === 'unreachable' || failure === 'other') {
61
+ hooks.setNotice(daemonErrorMessage(error));
62
+ return; // keep the page open — nothing was submitted
63
+ }
64
+ closeSession(false);
65
+ hooks.setNotice(failure === 'already_resolved' ? resolvedElsewhereNotice(open.dir) : daemonErrorMessage(error));
66
+ }
67
+ finally {
68
+ submitting = false;
69
+ }
70
+ };
71
+ const reload = (open) => {
72
+ try {
73
+ open.adapter.reload(readPageTicket(open.dir));
74
+ }
75
+ catch {
76
+ hooks.setNotice('Page could not be read');
77
+ }
78
+ };
79
+ const editPageInput = (open) => {
80
+ const buffer = open.adapter.inputBuffer();
81
+ if (buffer === undefined)
82
+ return;
83
+ const resume = hooks.suspendForChild();
84
+ let result = { text: buffer };
85
+ try {
86
+ result = editBufferInEditor(buffer);
87
+ }
88
+ finally {
89
+ resume();
90
+ open.adapter.setInputBuffer(result.text);
91
+ if (result.error !== undefined)
92
+ hooks.setNotice(result.error);
93
+ tui.requestRender();
94
+ }
95
+ };
96
+ const open = (ticket) => {
97
+ if (session !== undefined)
98
+ closeSession(true);
99
+ if (ticket.kind !== 'page') {
100
+ hooks.openInbox(ticket.dir);
101
+ return;
102
+ }
103
+ const claim = claimTicket(ticket.dir);
104
+ if (claim === null) {
105
+ hooks.setNotice('Ticket is being edited by another inbox');
106
+ return;
107
+ }
108
+ let loaded;
109
+ try {
110
+ loaded = readPageTicket(ticket.dir);
111
+ }
112
+ catch {
113
+ releaseClaim(ticket.dir, claim.token);
114
+ hooks.openInbox(ticket.dir);
115
+ return;
116
+ }
117
+ lastCols = Math.max(20, tui.terminal.columns - 2);
118
+ lastRows = capRows();
119
+ const entry = {
120
+ dir: ticket.dir,
121
+ title: ticket.title,
122
+ token: claim.token,
123
+ adapter: new PageAdapter({
124
+ dir: ticket.dir,
125
+ ticket: loaded,
126
+ productKinds: resolvePageComponents(),
127
+ cols: lastCols,
128
+ rows: lastRows,
129
+ onDirty: () => tui.requestRender(),
130
+ onBack: () => closeSession(true),
131
+ onComplete: (responses) => { void complete(entry, responses); },
132
+ onEditorRequest: () => editPageInput(entry),
133
+ }),
134
+ watcher: undefined,
135
+ heartbeat: setInterval(() => {
136
+ if (session === entry)
137
+ heartbeatClaim(entry.dir, entry.token);
138
+ }, 10_000),
139
+ };
140
+ try {
141
+ entry.watcher = watch(ticket.dir, (_event, file) => {
142
+ if (session !== entry || submitting)
143
+ return;
144
+ if (file === 'page.json') {
145
+ reload(entry);
146
+ return;
147
+ }
148
+ const result = readTicketResult(entry.dir);
149
+ if (result !== null) {
150
+ closeSession(true);
151
+ hooks.setNotice(result.kind === 'canceled' ? 'Ticket was canceled by its requester' : 'Ticket was resolved elsewhere');
152
+ }
153
+ });
154
+ }
155
+ catch {
156
+ entry.watcher = undefined;
157
+ }
158
+ session = entry;
159
+ hooks.syncCursor();
160
+ hooks.refreshStrip();
161
+ tui.requestRender();
162
+ };
163
+ const handleKey = (data) => {
164
+ const entry = session;
165
+ if (entry === undefined)
166
+ return false;
167
+ const { input, key } = decodeInboxKey(data);
168
+ entry.adapter.handleKey(input, key);
169
+ tui.requestRender();
170
+ return true;
171
+ };
172
+ const component = {
173
+ render(width) {
174
+ const entry = session;
175
+ if (entry === undefined)
176
+ return [];
177
+ const cols = Math.max(20, width - 2);
178
+ const rows = capRows();
179
+ if (cols !== lastCols || rows !== lastRows) {
180
+ lastCols = cols;
181
+ lastRows = rows;
182
+ entry.adapter.resize(cols, rows);
183
+ }
184
+ // The strip row's highlight carries into the open page: the same
185
+ // reversed full-width bar, now a title bar, with a closing rule beneath
186
+ // the content so the page reads as one bounded region over the chat.
187
+ const header = fillBar(stripSgr(truncate(` ⚑ ${entry.title} · Esc closes`, width)), width, REVERSE);
188
+ // The body sits on the same background the crouter context cards use, so
189
+ // the open ticket reads as one solid panel distinct from the chat.
190
+ const bg = contextCardBg();
191
+ const body = trimTrailingBlank(entry.adapter.render()).map((line) => paintBg(` ${line}`, width, bg));
192
+ return [header, ...body, pal.border('─'.repeat(Math.max(0, width)))];
193
+ },
194
+ invalidate() { },
195
+ };
196
+ return {
197
+ component,
198
+ open,
199
+ handleKey,
200
+ close: () => closeSession(true),
201
+ active: () => session !== undefined,
202
+ currentDir: () => session?.dir,
203
+ };
204
+ }
205
+ function resolvedElsewhereNotice(dir) {
206
+ return readTicketResult(dir)?.kind === 'canceled' ? 'Ticket was already canceled' : 'Ticket was already resolved';
207
+ }
208
+ /** The SGR opening the theme's `customMessageBg` — the background the crouter
209
+ * context cards use. pi shares its live theme across module loaders on this
210
+ * well-known global (it exports no direct handle); a missing or incomplete
211
+ * theme just means no fill, never a throw. */
212
+ function contextCardBg() {
213
+ const t = globalThis[Symbol.for('@earendil-works/pi-coding-agent:theme')];
214
+ try {
215
+ return t?.getBgAnsi?.('customMessageBg');
216
+ }
217
+ catch {
218
+ return undefined;
219
+ }
220
+ }
221
+ /** Fill one row with `bg` edge to edge, re-asserting it across the line's own
222
+ * full resets and background resets so interior styling cannot punch holes. */
223
+ function paintBg(line, width, bg) {
224
+ if (bg === undefined)
225
+ return line;
226
+ const kept = line.replaceAll('\x1b[0m', `\x1b[0m${bg}`).replaceAll('\x1b[49m', bg);
227
+ const pad = ' '.repeat(Math.max(0, width - visibleWidth(line)));
228
+ return `${bg}${kept}${pad}\x1b[49m`;
229
+ }
230
+ /** Short pages take only their rows: the adapter pads to its rectangle. */
231
+ function trimTrailingBlank(lines) {
232
+ let end = lines.length;
233
+ while (end > 0 && stripSgr(lines[end - 1]).trim() === '')
234
+ end--;
235
+ return lines.slice(0, end);
236
+ }
@@ -1,6 +1,8 @@
1
1
  import { type GeneratedCard } from '../../../shared/generated-context.js';
2
2
  /** What the one global detail toggle reveals for this kind.
3
- * `section` names an element in the body; everything outside it stays folded. */
3
+ * `section` names an element in the body; everything outside it stays folded.
4
+ * `render` builds Markdown from the parsed card, for a body whose raw markup
5
+ * is not what a person should read. */
4
6
  export type CardExpandedPolicy = {
5
7
  readonly disclose: 'never';
6
8
  } | {
@@ -8,6 +10,9 @@ export type CardExpandedPolicy = {
8
10
  } | {
9
11
  readonly disclose: 'section';
10
12
  readonly section: string;
13
+ } | {
14
+ readonly disclose: 'render';
15
+ readonly render: (card: GeneratedCard) => string;
11
16
  };
12
17
  /** What is left of the card once its turn folds into one recap line. A boundary
13
18
  * kind never reaches this field — `isGroupBoundaryKind` removes it from the
@@ -24,8 +29,8 @@ export interface CardPresentation {
24
29
  export declare function cardLabel(subject: string): string;
25
30
  /** The disclosure a card actually offers, given its policy and body. A kind
26
31
  * whose section is missing from an old transcript offers nothing to expand. */
27
- export declare function cardDisclosure(presentation: CardPresentation, body: string): string;
28
- export declare function cardIsExpandable(presentation: CardPresentation, body: string): boolean;
32
+ export declare function cardDisclosure(presentation: CardPresentation, card: GeneratedCard): string;
33
+ export declare function cardIsExpandable(presentation: CardPresentation, card: GeneratedCard): boolean;
29
34
  /** The one lookup. Every display surface deciding how a card behaves resolves it
30
35
  * here, so row, disclosure, and recap can never disagree about a kind. */
31
36
  export declare function presentationFor(kind: string): CardPresentation;