@north-light/crouter 0.3.263 → 0.3.270

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 (171) hide show
  1. package/dist/api/client.d.ts +5 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/broker.d.ts +11 -2
  4. package/dist/api/dto/broker.js +2 -2
  5. package/dist/api/dto/canvas.d.ts +53 -2
  6. package/dist/api/dto/nodes.d.ts +10 -2
  7. package/dist/api/routes.d.ts +1 -0
  8. package/dist/api/routes.js +1 -0
  9. package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +6 -2
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +7 -2
  14. package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
  15. package/dist/clients/attach/session/whip-achievements.js +145 -0
  16. package/dist/clients/attach/slash/dispatch.js +5 -3
  17. package/dist/clients/attach/viewer.js +607 -606
  18. package/dist/commands/canvas-history/grep.js +3 -22
  19. package/dist/commands/canvas-history/read.js +5 -3
  20. package/dist/commands/canvas-history/search.js +3 -22
  21. package/dist/commands/canvas-history/shared.d.ts +8 -0
  22. package/dist/commands/canvas-history/shared.js +64 -1
  23. package/dist/commands/canvas-history/stats.d.ts +1 -0
  24. package/dist/commands/canvas-history/stats.js +68 -0
  25. package/dist/commands/canvas-history.js +4 -3
  26. package/dist/commands/node/create.js +2 -2
  27. package/dist/commands/pkg/plugin-inspect.js +2 -2
  28. package/dist/commands/pkg/plugin-manage.js +3 -3
  29. package/dist/commands/sys/config.js +19 -2
  30. package/dist/commands/sys/doctor.js +4 -3
  31. package/dist/commands/sys/panels/models-panel.js +3 -1
  32. package/dist/core/__tests__/human-deliver.test.js +1 -1
  33. package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
  34. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
  35. package/dist/core/__tests__/integration/revive.test.js +19 -17
  36. package/dist/core/__tests__/model-pin-durability.test.js +25 -1
  37. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  38. package/dist/core/__tests__/relaunch-root.test.js +17 -0
  39. package/dist/core/__tests__/revive-capacity.test.js +20 -20
  40. package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
  41. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
  42. package/dist/core/__tests__/seam/broker-provider-retry.test.js +19 -4
  43. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
  44. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
  45. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
  46. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
  47. package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
  48. package/dist/core/broker-client/transport-relay.js +5 -1
  49. package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
  50. package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
  51. package/dist/core/canvas/browse/app.d.ts +1 -1
  52. package/dist/core/canvas/browse/app.js +13 -10
  53. package/dist/core/canvas/browse/model.js +16 -1
  54. package/dist/core/canvas/browse/render.d.ts +23 -3
  55. package/dist/core/canvas/browse/render.js +429 -64
  56. package/dist/core/canvas/canvas.js +12 -8
  57. package/dist/core/canvas/extensions.d.ts +1 -1
  58. package/dist/core/canvas/extensions.js +7 -1
  59. package/dist/core/canvas/history.d.ts +36 -1
  60. package/dist/core/canvas/history.js +328 -7
  61. package/dist/core/canvas/labels.d.ts +2 -2
  62. package/dist/core/canvas/migrations.js +32 -0
  63. package/dist/core/canvas/node-recap.d.ts +23 -0
  64. package/dist/core/canvas/node-recap.js +67 -0
  65. package/dist/core/canvas/render-source.d.ts +33 -1
  66. package/dist/core/canvas/render-source.js +80 -22
  67. package/dist/core/canvas/types.d.ts +9 -0
  68. package/dist/core/command-hooks/discovery.d.ts +27 -1
  69. package/dist/core/command-hooks/discovery.js +52 -0
  70. package/dist/core/command-hooks/index.d.ts +4 -3
  71. package/dist/core/command-hooks/index.js +2 -1
  72. package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
  73. package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
  74. package/dist/core/command-hooks/report.d.ts +11 -2
  75. package/dist/core/command-hooks/report.js +10 -1
  76. package/dist/core/command-hooks/schema.d.ts +16 -1
  77. package/dist/core/command-hooks/schema.js +128 -40
  78. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
  79. package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
  80. package/dist/core/config.js +13 -5
  81. package/dist/core/human/feedback-companion.js +1 -1
  82. package/dist/core/keybindings/catalog.d.ts +2 -2
  83. package/dist/core/keybindings/catalog.js +2 -1
  84. package/dist/core/model-routes.js +1 -1
  85. package/dist/core/review/realize.js +1 -1
  86. package/dist/core/runtime/broker/auth-reload.js +4 -1
  87. package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
  88. package/dist/core/runtime/broker/engine-drive.js +29 -1
  89. package/dist/core/runtime/broker/fault-retry.js +7 -2
  90. package/dist/core/runtime/broker/frame-client.js +3 -0
  91. package/dist/core/runtime/broker/frame-dispatch.js +7 -0
  92. package/dist/core/runtime/broker/inbox.d.ts +12 -2
  93. package/dist/core/runtime/broker/inbox.js +68 -38
  94. package/dist/core/runtime/broker/rebind.js +7 -0
  95. package/dist/core/runtime/broker-protocol.d.ts +16 -1
  96. package/dist/core/runtime/broker.d.ts +1 -1
  97. package/dist/core/runtime/broker.js +27 -3
  98. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  99. package/dist/core/runtime/canvas-extensions.js +2 -0
  100. package/dist/core/runtime/interactive-deliver.d.ts +8 -2
  101. package/dist/core/runtime/interactive-deliver.js +12 -3
  102. package/dist/core/runtime/launch-target.js +1 -1
  103. package/dist/core/runtime/launch.d.ts +12 -6
  104. package/dist/core/runtime/launch.js +19 -17
  105. package/dist/core/runtime/model-swap.d.ts +5 -4
  106. package/dist/core/runtime/model-swap.js +7 -5
  107. package/dist/core/runtime/node-read.d.ts +2 -0
  108. package/dist/core/runtime/node-read.js +18 -9
  109. package/dist/core/runtime/promote.js +2 -1
  110. package/dist/core/runtime/reset.js +4 -2
  111. package/dist/core/runtime/revive-all.d.ts +1 -1
  112. package/dist/core/runtime/revive-all.js +2 -2
  113. package/dist/core/runtime/revive.d.ts +2 -2
  114. package/dist/core/runtime/revive.js +71 -13
  115. package/dist/core/runtime/session-visibility.d.ts +10 -14
  116. package/dist/core/runtime/session-visibility.js +43 -30
  117. package/dist/core/runtime/spawn.d.ts +3 -3
  118. package/dist/core/runtime/spawn.js +20 -3
  119. package/dist/core/runtime/stamp/channel.d.ts +22 -0
  120. package/dist/core/runtime/stamp/channel.js +83 -0
  121. package/dist/core/runtime/stamp/protocol.d.ts +90 -0
  122. package/dist/core/runtime/stamp/protocol.js +113 -0
  123. package/dist/core/runtime/tmux-driver.d.ts +11 -1
  124. package/dist/core/runtime/tmux-driver.js +27 -11
  125. package/dist/core/runtime/turn-visibility.d.ts +10 -0
  126. package/dist/core/runtime/turn-visibility.js +31 -0
  127. package/dist/core/self-update.js +2 -5
  128. package/dist/core/tui/draw.d.ts +1 -0
  129. package/dist/core/tui/draw.js +5 -1
  130. package/dist/core/tui/markdown.d.ts +17 -0
  131. package/dist/core/tui/markdown.js +407 -0
  132. package/dist/core/user-settings.d.ts +4 -0
  133. package/dist/core/user-settings.js +1 -0
  134. package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
  135. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  136. package/dist/daemon/api/bridge.d.ts +3 -2
  137. package/dist/daemon/api/bridge.js +34 -9
  138. package/dist/daemon/api/handlers/attach.js +4 -4
  139. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  140. package/dist/daemon/api/handlers/canvas.js +62 -16
  141. package/dist/daemon/api/handlers/messages.js +14 -3
  142. package/dist/daemon/api/handlers/nodes.js +17 -7
  143. package/dist/daemon/api/map.js +2 -0
  144. package/dist/daemon/cron/sinks.js +3 -3
  145. package/dist/daemon/fleet.js +1 -1
  146. package/dist/daemon/messaging/node-message.js +16 -2
  147. package/dist/daemon/profile-delete.js +1 -1
  148. package/dist/daemon/reconcilers/broker-supervision.js +26 -9
  149. package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
  150. package/dist/daemon/reconcilers/live-obligation.js +17 -0
  151. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
  152. package/dist/hook-authoring.d.ts +31 -1
  153. package/dist/hook-authoring.js +42 -5
  154. package/dist/index.d.ts +1 -0
  155. package/dist/index.js +3 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
  157. package/dist/pi-extensions/canvas-recap.js +4 -0
  158. package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
  159. package/dist/pi-extensions/canvas-stamp.js +77 -0
  160. package/dist/pi-extensions/canvas-stophook.js +3 -0
  161. package/dist/shared/__tests__/env-boundary.test.js +4 -2
  162. package/dist/shared/crtr-version.d.ts +2 -0
  163. package/dist/shared/crtr-version.js +18 -0
  164. package/dist/shared/env.d.ts +10 -0
  165. package/dist/shared/env.js +19 -4
  166. package/dist/shared/generated-context.d.ts +1 -2
  167. package/dist/shared/generated-context.js +3 -4
  168. package/dist/types.d.ts +10 -0
  169. package/dist/types.js +4 -0
  170. package/package.json +1 -1
  171. package/runtime.lock.json +2 -2
@@ -0,0 +1,407 @@
1
+ // markdown.ts — a tiny, pure markdown → styled-spans renderer for crtr's
2
+ // raw-ANSI TUIs.
3
+ //
4
+ // WHY THIS EXISTS instead of the managed termrender subprocess: `renderFrame`
5
+ // (canvas browse) is a pure function of state/size/caps called on EVERY
6
+ // keystroke. termrender's `renderMarkdownBlockAwareLines` shells out to a python
7
+ // venv per call, and pi-tui's Markdown component needs a component tree — both
8
+ // are unusable inside a synchronous frame paint. So this module supports the
9
+ // subset of markdown that actually shows up in a preview (headings, lists, code,
10
+ // quotes, rules, tables, and inline emphasis) with no dependencies at all.
11
+ //
12
+ // It returns Span[][] — one span group per screen line, already wrapped to the
13
+ // caller's width and already carrying its own prefixes (list markers, quote
14
+ // bars). The caller assembles each line with draw.ts's `assemble`, so hue stays
15
+ // gated on `caps.color` exactly like every other line in the frame.
16
+ import { clip } from './draw.js';
17
+ const FG_CYAN = '36';
18
+ // ── Style composition ─────────────────────────────────────────────────────────
19
+ /** Layer `add` over `base`. Structural flags OR together (a bold heading keeps
20
+ * its bold inside an italic run); hue is last-one-wins. */
21
+ function extend(base, add) {
22
+ const out = {};
23
+ const fg = add.fg ?? base.fg;
24
+ const bg = add.bg ?? base.bg;
25
+ if (fg !== undefined)
26
+ out.fg = fg;
27
+ if (bg !== undefined)
28
+ out.bg = bg;
29
+ if (add.bold || base.bold)
30
+ out.bold = true;
31
+ if (add.dim || base.dim)
32
+ out.dim = true;
33
+ if (add.italic || base.italic)
34
+ out.italic = true;
35
+ if (add.reverse || base.reverse)
36
+ out.reverse = true;
37
+ return out;
38
+ }
39
+ const RE_FENCE = /^\s{0,3}(```|~~~)/;
40
+ const RE_HEAD = /^\s{0,3}(#{1,6})\s+(.*)$/;
41
+ const RE_BULLET = /^(\s*)[-*+]\s+(.*)$/;
42
+ const RE_ORDER = /^(\s*)(\d{1,9})[.)]\s+(.*)$/;
43
+ const RE_QUOTE = /^\s{0,3}>\s?(.*)$/;
44
+ const RE_RULE = /^\s{0,3}(?:(?:-\s*){3,}|(?:\*\s*){3,}|(?:_\s*){3,})$/;
45
+ const RE_TABLE = /^\s*\|/;
46
+ /** Does this line open a new block? Used to decide whether a following line is a
47
+ * lazy continuation of the paragraph / list item / quote being accumulated. */
48
+ function isBlockStart(line) {
49
+ return RE_FENCE.test(line) || RE_HEAD.test(line) || RE_RULE.test(line)
50
+ || RE_BULLET.test(line) || RE_ORDER.test(line) || RE_QUOTE.test(line) || RE_TABLE.test(line);
51
+ }
52
+ function parseBlocks(src) {
53
+ const lines = src.replace(/\r\n?/g, '\n').split('\n');
54
+ const out = [];
55
+ let i = 0;
56
+ while (i < lines.length) {
57
+ const line = lines[i];
58
+ if (line.trim() === '') {
59
+ i++;
60
+ continue;
61
+ }
62
+ const fence = RE_FENCE.exec(line);
63
+ if (fence !== null) {
64
+ const tok = fence[1];
65
+ i++;
66
+ const body = [];
67
+ while (i < lines.length && !lines[i].trimStart().startsWith(tok)) {
68
+ body.push(lines[i]);
69
+ i++;
70
+ }
71
+ if (i < lines.length)
72
+ i++; // consume the closing fence
73
+ out.push({ k: 'code', lines: body });
74
+ continue;
75
+ }
76
+ // Rule before bullet: `---` and `***` would otherwise look like list markers.
77
+ if (RE_RULE.test(line)) {
78
+ out.push({ k: 'rule' });
79
+ i++;
80
+ continue;
81
+ }
82
+ const head = RE_HEAD.exec(line);
83
+ if (head !== null) {
84
+ out.push({ k: 'head', level: head[1].length, text: head[2].replace(/\s+#+\s*$/, '').trim() });
85
+ i++;
86
+ continue;
87
+ }
88
+ if (RE_TABLE.test(line)) {
89
+ const body = [];
90
+ while (i < lines.length && RE_TABLE.test(lines[i])) {
91
+ body.push(lines[i].trim());
92
+ i++;
93
+ }
94
+ out.push({ k: 'table', lines: body });
95
+ continue;
96
+ }
97
+ const bullet = RE_BULLET.exec(line);
98
+ const order = bullet === null ? RE_ORDER.exec(line) : null;
99
+ if (bullet !== null || order !== null) {
100
+ const indent = (bullet !== null ? bullet[1] : order[1]).length;
101
+ const marker = bullet !== null ? '• ' : `${order[2]}. `;
102
+ let text = bullet !== null ? bullet[2] : order[3];
103
+ i++;
104
+ while (i < lines.length && lines[i].trim() !== '' && !isBlockStart(lines[i])) {
105
+ text += ` ${lines[i].trim()}`;
106
+ i++;
107
+ }
108
+ out.push({ k: 'item', depth: Math.min(4, Math.floor(indent / 2)), marker, text: text.trim() });
109
+ continue;
110
+ }
111
+ const quote = RE_QUOTE.exec(line);
112
+ if (quote !== null) {
113
+ let text = quote[1];
114
+ i++;
115
+ for (;;) {
116
+ if (i >= lines.length)
117
+ break;
118
+ const more = RE_QUOTE.exec(lines[i]);
119
+ if (more !== null) {
120
+ text += ` ${more[1]}`;
121
+ i++;
122
+ continue;
123
+ }
124
+ if (lines[i].trim() !== '' && !isBlockStart(lines[i])) {
125
+ text += ` ${lines[i].trim()}`;
126
+ i++;
127
+ continue;
128
+ }
129
+ break;
130
+ }
131
+ out.push({ k: 'quote', text: text.trim() });
132
+ continue;
133
+ }
134
+ let text = line.trim();
135
+ i++;
136
+ while (i < lines.length && lines[i].trim() !== '' && !isBlockStart(lines[i])) {
137
+ text += ` ${lines[i].trim()}`;
138
+ i++;
139
+ }
140
+ out.push({ k: 'para', text });
141
+ }
142
+ return out;
143
+ }
144
+ // ── Inline grammar ────────────────────────────────────────────────────────────
145
+ const ESCAPABLE = /[\\`*_~[\]()#>-]/;
146
+ /** Parse inline markup left to right, recursing on delimited content with the
147
+ * base style extended. Unclosed or implausible delimiters stay literal, which is
148
+ * the right failure mode for a preview: never eat the user's text. */
149
+ export function inlineSpans(src, base = {}) {
150
+ const out = [];
151
+ let buf = '';
152
+ const flush = () => { if (buf !== '') {
153
+ out.push({ text: buf, style: base });
154
+ buf = '';
155
+ } };
156
+ const emit = (spans) => { flush(); for (const s of spans)
157
+ out.push(s); };
158
+ const n = src.length;
159
+ let i = 0;
160
+ while (i < n) {
161
+ const c = src[i];
162
+ if (c === '\\' && i + 1 < n && ESCAPABLE.test(src[i + 1])) {
163
+ buf += src[i + 1];
164
+ i += 2;
165
+ continue;
166
+ }
167
+ if (c === '`') {
168
+ const end = src.indexOf('`', i + 1);
169
+ if (end > i + 1) {
170
+ emit([{ text: src.slice(i + 1, end), style: extend(base, { fg: FG_CYAN }) }]);
171
+ i = end + 1;
172
+ continue;
173
+ }
174
+ }
175
+ // [label](url) — keep the label in cyan, drop the URL (a preview has no room
176
+ // for it and nothing here is clickable).
177
+ if (c === '[') {
178
+ const close = src.indexOf(']', i + 1);
179
+ if (close > i + 1 && src[close + 1] === '(') {
180
+ const paren = src.indexOf(')', close + 2);
181
+ if (paren > close) {
182
+ emit(inlineSpans(src.slice(i + 1, close), extend(base, { fg: FG_CYAN })));
183
+ i = paren + 1;
184
+ continue;
185
+ }
186
+ }
187
+ }
188
+ if (c === '~' && src[i + 1] === '~') {
189
+ const end = src.indexOf('~~', i + 2);
190
+ if (end > i + 1) {
191
+ emit(inlineSpans(src.slice(i + 2, end), extend(base, { dim: true })));
192
+ i = end + 2;
193
+ continue;
194
+ }
195
+ }
196
+ if (c === '*' || c === '_') {
197
+ const run = src[i + 1] === c ? (src[i + 2] === c ? 3 : 2) : 1;
198
+ const delim = c.repeat(run);
199
+ // `_` must not open mid-word, or every snake_case identifier turns italic.
200
+ const wordBefore = c === '_' && i > 0 && /\w/.test(src[i - 1]);
201
+ const opens = !wordBefore && i + run < n && !/\s/.test(src[i + run]);
202
+ if (opens) {
203
+ let end = -1;
204
+ let from = i + run;
205
+ for (;;) {
206
+ const at = src.indexOf(delim, from);
207
+ if (at < 0)
208
+ break;
209
+ const tightRight = !/\s/.test(src[at - 1] ?? ' ');
210
+ const wordAfter = c === '_' && /\w/.test(src[at + run] ?? '');
211
+ if (tightRight && !wordAfter) {
212
+ end = at;
213
+ break;
214
+ }
215
+ from = at + 1;
216
+ }
217
+ if (end > i) {
218
+ const add = run === 3 ? { bold: true, italic: true } : run === 2 ? { bold: true } : { italic: true };
219
+ emit(inlineSpans(src.slice(i + run, end), extend(base, add)));
220
+ i = end + run;
221
+ continue;
222
+ }
223
+ }
224
+ }
225
+ buf += c;
226
+ i++;
227
+ }
228
+ flush();
229
+ return out;
230
+ }
231
+ // ── Wrapping ──────────────────────────────────────────────────────────────────
232
+ function spanWidth(spans) {
233
+ let n = 0;
234
+ for (const s of spans)
235
+ n += [...s.text].length;
236
+ return n;
237
+ }
238
+ function sameStyle(a, b) {
239
+ if (a === b)
240
+ return true;
241
+ if (a === undefined || b === undefined)
242
+ return false;
243
+ return a.fg === b.fg && a.bg === b.bg && !!a.bold === !!b.bold
244
+ && !!a.dim === !!b.dim && !!a.italic === !!b.italic && !!a.reverse === !!b.reverse;
245
+ }
246
+ /** Merge neighbouring same-style spans. Word-wrapping emits one span per word, and
247
+ * `assemble` wraps EVERY span in its own SGR pair — so without this a styled
248
+ * sentence ships as a stutter of escape sequences. */
249
+ function coalesce(spans) {
250
+ const out = [];
251
+ for (const s of spans) {
252
+ const last = out[out.length - 1];
253
+ if (last !== undefined && sameStyle(last.style, s.style))
254
+ last.text += s.text;
255
+ else
256
+ out.push({ text: s.text, ...(s.style === undefined ? {} : { style: s.style }) });
257
+ }
258
+ return out;
259
+ }
260
+ /** Greedy word-wrap over a span stream, with a first-line prefix and a separate
261
+ * continuation prefix so list markers and quote bars hang correctly. A word
262
+ * longer than the line is hard-sliced rather than dropped. */
263
+ function wrapSpans(spans, width, first, cont) {
264
+ const lines = [];
265
+ const limitFor = (idx) => Math.max(1, width - spanWidth(idx === 0 ? first : cont));
266
+ let line = [];
267
+ let used = 0;
268
+ let limit = limitFor(0);
269
+ const flushLine = () => {
270
+ lines.push(coalesce([...(lines.length === 0 ? first : cont), ...line]));
271
+ line = [];
272
+ used = 0;
273
+ limit = limitFor(lines.length);
274
+ };
275
+ let pendingSpace = false;
276
+ for (const span of spans) {
277
+ for (const part of span.text.split(/(\s+)/)) {
278
+ if (part === '')
279
+ continue;
280
+ if (/^\s+$/.test(part)) {
281
+ if (used > 0)
282
+ pendingSpace = true;
283
+ continue;
284
+ }
285
+ const w = [...part].length;
286
+ const extra = pendingSpace ? 1 : 0;
287
+ if (used + extra + w <= limit) {
288
+ // The break space carries the word's style so a styled run stays one span.
289
+ if (pendingSpace) {
290
+ line.push({ text: ' ', style: span.style });
291
+ used++;
292
+ }
293
+ line.push({ text: part, style: span.style });
294
+ used += w;
295
+ pendingSpace = false;
296
+ continue;
297
+ }
298
+ if (used > 0)
299
+ flushLine();
300
+ pendingSpace = false;
301
+ let rest = [...part];
302
+ while (rest.length > limit) {
303
+ line.push({ text: rest.slice(0, limit).join(''), style: span.style });
304
+ rest = rest.slice(limit);
305
+ flushLine();
306
+ }
307
+ if (rest.length > 0) {
308
+ line.push({ text: rest.join(''), style: span.style });
309
+ used = rest.length;
310
+ }
311
+ }
312
+ }
313
+ if (line.length > 0)
314
+ flushLine();
315
+ return lines;
316
+ }
317
+ // ── Blocks → lines ────────────────────────────────────────────────────────────
318
+ function blockToLines(b, width, base) {
319
+ switch (b.k) {
320
+ case 'rule':
321
+ return [[{ text: '─'.repeat(Math.max(1, width)), style: { dim: true } }]];
322
+ case 'code':
323
+ return b.lines.map((l) => [{ text: clip(l.replace(/\t/g, ' '), width), style: extend(base, { fg: FG_CYAN }) }]);
324
+ case 'table':
325
+ return b.lines.map((l) => [{ text: clip(l, width), style: extend(base, { dim: true }) }]);
326
+ case 'head': {
327
+ // Level 3+ is a sub-sub-heading — bold alone would out-shout the body it
328
+ // introduces, so it goes dim-bold.
329
+ const st = extend(base, b.level >= 3 ? { bold: true, dim: true } : { bold: true });
330
+ return wrapSpans(inlineSpans(b.text, st), width, [], []);
331
+ }
332
+ case 'item': {
333
+ const pad = ' '.repeat(b.depth * 2);
334
+ const first = [{ text: pad }, { text: b.marker, style: extend(base, { dim: true }) }];
335
+ const cont = [{ text: pad + ' '.repeat([...b.marker].length) }];
336
+ return wrapSpans(inlineSpans(b.text, base), width, first, cont);
337
+ }
338
+ case 'quote': {
339
+ const bar = [{ text: '▏ ', style: { dim: true } }];
340
+ return wrapSpans(inlineSpans(b.text, extend(base, { dim: true })), width, bar, bar);
341
+ }
342
+ case 'para':
343
+ return wrapSpans(inlineSpans(b.text, base), width, [], []);
344
+ }
345
+ }
346
+ /** Trim the last line to make room, then mark the cut. Without this a truncated
347
+ * reply reads as the end of the text — exactly the wrong impression for "where
348
+ * did this node leave off?". */
349
+ function appendEllipsis(lines, width) {
350
+ const last = lines[lines.length - 1];
351
+ if (last === undefined)
352
+ return;
353
+ let w = spanWidth(last);
354
+ while (w > width - 1 && last.length > 0) {
355
+ const tail = last[last.length - 1];
356
+ const chars = [...tail.text];
357
+ if (chars.length <= 1) {
358
+ last.pop();
359
+ w -= chars.length;
360
+ }
361
+ else {
362
+ tail.text = chars.slice(0, -1).join('');
363
+ w--;
364
+ }
365
+ }
366
+ last.push({ text: '…', style: { dim: true } });
367
+ }
368
+ /**
369
+ * Render `src` as markdown into at most `maxLines` span groups, each already
370
+ * wrapped to `width` visible columns.
371
+ *
372
+ * `spacing` inserts a blank line between blocks — true where the panel has room
373
+ * to breathe, false in a cramped one where every line counts. Consecutive list
374
+ * items never get one, so a tight list stays tight.
375
+ *
376
+ * `base` is the style every rendered run inherits (the caller's block style,
377
+ * e.g. the dim of a report body); markdown's own styles layer over it.
378
+ */
379
+ export function renderMarkdownSpans(src, width, maxLines, spacing = false, base = {}) {
380
+ if (width <= 0 || maxLines <= 0)
381
+ return [];
382
+ const blocks = parseBlocks(src);
383
+ const out = [];
384
+ let truncated = false;
385
+ let prev;
386
+ for (const b of blocks) {
387
+ if (out.length >= maxLines) {
388
+ truncated = true;
389
+ break;
390
+ }
391
+ if (prev !== undefined && spacing && !(prev.k === 'item' && b.k === 'item'))
392
+ out.push([]);
393
+ for (const line of blockToLines(b, width, base)) {
394
+ if (out.length >= maxLines) {
395
+ truncated = true;
396
+ break;
397
+ }
398
+ out.push(line);
399
+ }
400
+ prev = b;
401
+ }
402
+ while (out.length > 0 && out[out.length - 1].length === 0)
403
+ out.pop();
404
+ if (truncated)
405
+ appendEllipsis(out, width);
406
+ return out;
407
+ }
@@ -113,6 +113,10 @@ export declare const CONFIG_SETTINGS_COVERAGE: {
113
113
  surface: "specialized";
114
114
  owner: string;
115
115
  };
116
+ lifecycle: {
117
+ surface: "specialized";
118
+ owner: string;
119
+ };
116
120
  completion_bell: {
117
121
  surface: "settings";
118
122
  setting: {
@@ -149,6 +149,7 @@ export const CONFIG_SETTINGS_COVERAGE = {
149
149
  },
150
150
  },
151
151
  brokerThresholds: { surface: 'specialized', owner: 'Broker limits tab' },
152
+ lifecycle: { surface: 'specialized', owner: 'sys config set lifecycle.unattendedParkMs' },
152
153
  completion_bell: {
153
154
  surface: 'settings',
154
155
  setting: {
@@ -103,6 +103,26 @@ test('promote and yield reject unregistered model recipes before reshaping the n
103
103
  assert.equal(after?.mode, before?.mode, `${operation} did not reshape the node`);
104
104
  }
105
105
  });
106
+ test('promote and yield reject an unregistered resolved portable override before reshaping the node', async () => {
107
+ const cwd = join(home, 'target-project');
108
+ mkdirSync(join(cwd, '.crouter'), { recursive: true });
109
+ writeFileSync(join(cwd, '.crouter', 'config.json'), JSON.stringify({
110
+ modelLadders: { openai: { strong: 'target-openai/missing-strong' } },
111
+ }));
112
+ const registry = { find: () => undefined, hasConfiguredAuth: () => false };
113
+ for (const operation of ['promote', 'yield']) {
114
+ const id = `${operation}-portable-model`;
115
+ createNode(node(id, cwd));
116
+ const before = getNode(id);
117
+ const request = ctx('POST', `/v1/nodes/${id}/${operation}`, id, { model: 'openai/strong' });
118
+ await assert.rejects(() => operation === 'promote'
119
+ ? handlePromote(request, { modelRegistry: registry })
120
+ : handleYield(request, { modelRegistry: registry }), (error) => error instanceof CrtrError && error.code === 'usage' && /target-openai\/missing-strong/.test(error.message));
121
+ const after = getNode(id);
122
+ assert.deepEqual(after?.launch, before?.launch, `${operation} did not rewrite the launch recipe`);
123
+ assert.equal(after?.mode, before?.mode, `${operation} did not reshape the node`);
124
+ }
125
+ });
106
126
  test('config normalizes portable models against the target node scope', async () => {
107
127
  const cwd = join(home, 'target-project');
108
128
  mkdirSync(join(cwd, '.crouter'), { recursive: true });
@@ -131,4 +151,5 @@ test('config normalizes portable models against the target node scope', async ()
131
151
  await handleConfig(ctx('PATCH', `/v1/nodes/${id}/config`, id, { model: 'openai/strong' }), { modelRegistry: registry });
132
152
  assert.equal(getNode(id)?.model_override, 'target-openai/o-strong');
133
153
  assert.equal(getNode(id)?.launch?.model, 'target-openai/o-strong');
154
+ assert.equal(getNode(id)?.launch?.modelExact, true, 'a config override cannot route away on revive');
134
155
  });
@@ -62,7 +62,7 @@ test('create rejects a paused profile before a node exists', async () => {
62
62
  });
63
63
  assert.equal(listNodes().length, beforeRows);
64
64
  });
65
- test('revive refuses a paused profile before launching a broker', () => {
65
+ test('revive refuses a paused profile before launching a broker', async () => {
66
66
  const profile = createProfile('paused revive', [{ path: cwd, memory: 'content' }]);
67
67
  pauseProfile(profile.profileId);
68
68
  const nodeId = 'paused-revive-node';
@@ -78,7 +78,7 @@ test('revive refuses a paused profile before launching a broker', () => {
78
78
  parent: null,
79
79
  profile_id: profile.profileId,
80
80
  });
81
- assert.throws(() => reviveNode(nodeId, { resume: true, capacity: 'freeze' }), (error) => {
81
+ await assert.rejects(reviveNode(nodeId, { resume: true, capacity: 'freeze' }), (error) => {
82
82
  assert.ok(error instanceof CrtrError);
83
83
  assert.equal(error.code, 'usage');
84
84
  assert.match(error.details?.['next'], new RegExp(`crtr profile resume ${profile.profileId}`));
@@ -49,9 +49,10 @@ export declare function startRemoteAttachHeartbeat(ws: WebSocket, intervalMs?: n
49
49
  * 1. Guard `isSafeNodeId` (→ 400) and existence (→ 404) BEFORE building any
50
50
  * fs path or reviving — an unsafe id must never reach `viewSocketPath`.
51
51
  * 2. Unless revive is opted out (`?revive=0` / `X-Crtr-Attach-Revive: 0`):
52
- * `reviveNode(id, { resume:true })` then
52
+ * `decideAttachRevive(id, { resume:true }, wakeParked)` then
53
53
  * `waitForBrokerViewSocket` (bounded). Revive throw OR socket-not-listenable
54
- * → refuse 409, WS never opens.
54
+ * → refuse 409, WS never opens. Without `?wakeParked=1` an exact parked
55
+ * resident is left parked and refused 409 `node_dormant` — see the call site.
55
56
  * 3. Revive opted out: a single connect probe. Dormant (socket refuses) →
56
57
  * refuse 409 (spec §5.3 asymmetry: a dead broker can't stream over WS).
57
58
  * 4. Only then upgrade and hand the live WS to {@link bridgeConnection}. */
@@ -7,8 +7,9 @@
7
7
  // Teardown is idempotent, and each WebSocket owns one `view.sock` connection;
8
8
  // the broker fans out to its clients natively.
9
9
  //
10
- // Attach implies revive. Before opening WebSocket, the upgrade handler calls
11
- // `reviveNode(id, { resume: true })` unless `?revive=0` or
10
+ // Attach implies revive, EXCEPT for an exact parked resident, which only
11
+ // `?wakeParked=1` wakes. Before opening WebSocket, the upgrade handler calls
12
+ // `decideAttachRevive(id, { resume: true }, wakeParked)` unless `?revive=0` or
12
13
  // `X-Crtr-Attach-Revive: 0` opts out, then waits for `view.sock` to become
13
14
  // listenable. A failed revive or an unready socket refuses the upgrade with 409.
14
15
  // With revive opted out, a dormant node also refuses with 409. Pre-upgrade
@@ -16,8 +17,8 @@
16
17
  // faults use WebSocket close codes (1008/1009/1011).
17
18
  //
18
19
  // Once open, the bridge only relays sockets. It never touches SessionManager,
19
- // spawns `pi --session`, or writes a `.jsonl`; `reviveNode()` is its sole engine
20
- // launch path. It records a fault only when the broker socket fails.
20
+ // spawns `pi --session`, or writes a `.jsonl`; the revive boundary is its sole
21
+ // engine launch path. It records a fault only when the broker socket fails.
21
22
  import { createConnection } from 'node:net';
22
23
  import { WebSocketServer } from 'ws';
23
24
  import { isSafeNodeId, viewSocketPath } from '../../core/canvas/paths.js';
@@ -25,7 +26,7 @@ import { getNode } from '../../core/canvas/index.js';
25
26
  import { BROKER_READ_CAPS, CLIENT_READ_CAPS, FrameDecoder, FrameOverflowError, } from '../../core/runtime/broker-protocol.js';
26
27
  import { classify } from '../../core/fault-classifier.js';
27
28
  import { clearFault, recordFault } from '../../core/runtime/fault.js';
28
- import { reviveNode } from '../../core/runtime/revive.js';
29
+ import { decideAttachRevive } from '../../core/runtime/revive.js';
29
30
  import { probeViewSocket, waitForBrokerViewSocket } from '../../core/runtime/view-socket.js';
30
31
  /** Our own noServer WS server: A-9 routes matched attach upgrades to
31
32
  * {@link handleAttachUpgrade}, which performs the handshake through this. Bounds
@@ -147,6 +148,16 @@ function reviveOptedOut(req) {
147
148
  const value = Array.isArray(header) ? header[0] : header;
148
149
  return value === '0';
149
150
  }
151
+ /** `?wakeParked=1` — this attach is a person deliberately opening a node, so an
152
+ * exact parked resident should be woken for it, exactly as the LOCAL attach
153
+ * ensure does for a direct attach. crouter's own remote viewer
154
+ * (`RelayTransport`) sends it. Without it the caller is a viewport reconnecting
155
+ * — a product page re-dialling after its socket dropped — and a parked node
156
+ * stays parked. */
157
+ function wakeParkedRequested(req) {
158
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
159
+ return url.searchParams.get('wakeParked') === '1';
160
+ }
150
161
  /** Relay one remote WS ⇄ one node broker `view.sock`. Opens the socket on
151
162
  * connect; forwards each complete frame socket→WS (bounded decode, one WS
152
163
  * message per frame) and re-frames each WS message WS→socket (trailing
@@ -314,9 +325,10 @@ function bridgeConnection(ws, nodeId) {
314
325
  * 1. Guard `isSafeNodeId` (→ 400) and existence (→ 404) BEFORE building any
315
326
  * fs path or reviving — an unsafe id must never reach `viewSocketPath`.
316
327
  * 2. Unless revive is opted out (`?revive=0` / `X-Crtr-Attach-Revive: 0`):
317
- * `reviveNode(id, { resume:true })` then
328
+ * `decideAttachRevive(id, { resume:true }, wakeParked)` then
318
329
  * `waitForBrokerViewSocket` (bounded). Revive throw OR socket-not-listenable
319
- * → refuse 409, WS never opens.
330
+ * → refuse 409, WS never opens. Without `?wakeParked=1` an exact parked
331
+ * resident is left parked and refused 409 `node_dormant` — see the call site.
320
332
  * 3. Revive opted out: a single connect probe. Dormant (socket refuses) →
321
333
  * refuse 409 (spec §5.3 asymmetry: a dead broker can't stream over WS).
322
334
  * 4. Only then upgrade and hand the live WS to {@link bridgeConnection}. */
@@ -363,8 +375,21 @@ export async function handleAttachUpgrade(req, socket, head, nodeId) {
363
375
  // live broker publishes: at the cap, refuse the upgrade with the real
364
376
  // reason instead of freezing and then timing out on a socket that was
365
377
  // never going to appear.
366
- const result = reviveNode(nodeId, { resume: true, capacity: 'refuse' });
367
- launchExited = result.launch?.exited;
378
+ //
379
+ // An unqualified remote attach is a viewport, not a reason to run.
380
+ // Waking an exact parked resident here restarted its park clock on
381
+ // every browser reconnect, so a watched conversation churned park →
382
+ // revive → park. Nothing observes a parked node in the meantime: it has
383
+ // no engine and appends nothing, so a transcript served from the
384
+ // offline messages route cannot go stale, and a message sent to the
385
+ // node still revives it by the ordinary path. A person attaching
386
+ // deliberately says so with `?wakeParked=1`.
387
+ const decision = await decideAttachRevive(nodeId, { resume: true, capacity: 'refuse' }, wakeParkedRequested(req));
388
+ if (decision.parked) {
389
+ refuseUpgrade(socket, 409, 'Conflict', 'node_dormant', `node ${nodeId} is parked; attach with ?wakeParked=1 to wake it`);
390
+ return;
391
+ }
392
+ launchExited = decision.result.launch?.exited;
368
393
  }
369
394
  catch (err) {
370
395
  const message = err instanceof Error ? err.message : String(err);
@@ -37,10 +37,10 @@ async function handleAttach(ctx) {
37
37
  let resumed = false;
38
38
  let parked = false;
39
39
  if (revive) {
40
- // This decision and reviveNode's fleet guard share one synchronous launch
41
- // boundary. Focus may stop at an exact parked resident; ordinary/direct
42
- // attach keeps wakeParked=true and preserves attach-implies-revive.
43
- const decision = decideAttachRevive(id, { resume, capacity: 'refuse' }, wakeParked);
40
+ // This decision and reviveNode's fleet claim share one launch boundary.
41
+ // Focus may stop at an exact parked resident; ordinary/direct attach keeps
42
+ // wakeParked=true and preserves attach-implies-revive.
43
+ const decision = await decideAttachRevive(id, { resume, capacity: 'refuse' }, wakeParked);
44
44
  parked = decision.parked;
45
45
  if (!decision.parked) {
46
46
  revived = decision.result.launch !== undefined;
@@ -79,7 +79,7 @@ async function handleStop(ctx) {
79
79
  const node = getNode(nodeId);
80
80
  if (node !== null && !isBrokerLive(node)) {
81
81
  try {
82
- reviveNode(nodeId, { resume: true, capacity: 'freeze' });
82
+ await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
83
83
  }
84
84
  catch { /* the notice remains durable */ }
85
85
  }