@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.
- package/dist/api/client.d.ts +5 -1
- package/dist/api/client.js +6 -0
- package/dist/api/dto/broker.d.ts +11 -2
- package/dist/api/dto/broker.js +2 -2
- package/dist/api/dto/canvas.d.ts +53 -2
- package/dist/api/dto/nodes.d.ts +10 -2
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +6 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +7 -2
- package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
- package/dist/clients/attach/session/whip-achievements.js +145 -0
- package/dist/clients/attach/slash/dispatch.js +5 -3
- package/dist/clients/attach/viewer.js +607 -606
- package/dist/commands/canvas-history/grep.js +3 -22
- package/dist/commands/canvas-history/read.js +5 -3
- package/dist/commands/canvas-history/search.js +3 -22
- package/dist/commands/canvas-history/shared.d.ts +8 -0
- package/dist/commands/canvas-history/shared.js +64 -1
- package/dist/commands/canvas-history/stats.d.ts +1 -0
- package/dist/commands/canvas-history/stats.js +68 -0
- package/dist/commands/canvas-history.js +4 -3
- package/dist/commands/node/create.js +2 -2
- package/dist/commands/pkg/plugin-inspect.js +2 -2
- package/dist/commands/pkg/plugin-manage.js +3 -3
- package/dist/commands/sys/config.js +19 -2
- package/dist/commands/sys/doctor.js +4 -3
- package/dist/commands/sys/panels/models-panel.js +3 -1
- package/dist/core/__tests__/human-deliver.test.js +1 -1
- package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
- package/dist/core/__tests__/integration/revive.test.js +19 -17
- package/dist/core/__tests__/model-pin-durability.test.js +25 -1
- package/dist/core/__tests__/push-final-guard.test.js +1 -1
- package/dist/core/__tests__/relaunch-root.test.js +17 -0
- package/dist/core/__tests__/revive-capacity.test.js +20 -20
- package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +19 -4
- package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
- package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
- package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
- package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
- package/dist/core/broker-client/transport-relay.js +5 -1
- package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +1 -1
- package/dist/core/canvas/browse/app.js +13 -10
- package/dist/core/canvas/browse/model.js +16 -1
- package/dist/core/canvas/browse/render.d.ts +23 -3
- package/dist/core/canvas/browse/render.js +429 -64
- package/dist/core/canvas/canvas.js +12 -8
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +7 -1
- package/dist/core/canvas/history.d.ts +36 -1
- package/dist/core/canvas/history.js +328 -7
- package/dist/core/canvas/labels.d.ts +2 -2
- package/dist/core/canvas/migrations.js +32 -0
- package/dist/core/canvas/node-recap.d.ts +23 -0
- package/dist/core/canvas/node-recap.js +67 -0
- package/dist/core/canvas/render-source.d.ts +33 -1
- package/dist/core/canvas/render-source.js +80 -22
- package/dist/core/canvas/types.d.ts +9 -0
- package/dist/core/command-hooks/discovery.d.ts +27 -1
- package/dist/core/command-hooks/discovery.js +52 -0
- package/dist/core/command-hooks/index.d.ts +4 -3
- package/dist/core/command-hooks/index.js +2 -1
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
- package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
- package/dist/core/command-hooks/report.d.ts +11 -2
- package/dist/core/command-hooks/report.js +10 -1
- package/dist/core/command-hooks/schema.d.ts +16 -1
- package/dist/core/command-hooks/schema.js +128 -40
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
- package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
- package/dist/core/config.js +13 -5
- package/dist/core/human/feedback-companion.js +1 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +2 -1
- package/dist/core/model-routes.js +1 -1
- package/dist/core/review/realize.js +1 -1
- package/dist/core/runtime/broker/auth-reload.js +4 -1
- package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
- package/dist/core/runtime/broker/engine-drive.js +29 -1
- package/dist/core/runtime/broker/fault-retry.js +7 -2
- package/dist/core/runtime/broker/frame-client.js +3 -0
- package/dist/core/runtime/broker/frame-dispatch.js +7 -0
- package/dist/core/runtime/broker/inbox.d.ts +12 -2
- package/dist/core/runtime/broker/inbox.js +68 -38
- package/dist/core/runtime/broker/rebind.js +7 -0
- package/dist/core/runtime/broker-protocol.d.ts +16 -1
- package/dist/core/runtime/broker.d.ts +1 -1
- package/dist/core/runtime/broker.js +27 -3
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/interactive-deliver.d.ts +8 -2
- package/dist/core/runtime/interactive-deliver.js +12 -3
- package/dist/core/runtime/launch-target.js +1 -1
- package/dist/core/runtime/launch.d.ts +12 -6
- package/dist/core/runtime/launch.js +19 -17
- package/dist/core/runtime/model-swap.d.ts +5 -4
- package/dist/core/runtime/model-swap.js +7 -5
- package/dist/core/runtime/node-read.d.ts +2 -0
- package/dist/core/runtime/node-read.js +18 -9
- package/dist/core/runtime/promote.js +2 -1
- package/dist/core/runtime/reset.js +4 -2
- package/dist/core/runtime/revive-all.d.ts +1 -1
- package/dist/core/runtime/revive-all.js +2 -2
- package/dist/core/runtime/revive.d.ts +2 -2
- package/dist/core/runtime/revive.js +71 -13
- package/dist/core/runtime/session-visibility.d.ts +10 -14
- package/dist/core/runtime/session-visibility.js +43 -30
- package/dist/core/runtime/spawn.d.ts +3 -3
- package/dist/core/runtime/spawn.js +20 -3
- package/dist/core/runtime/stamp/channel.d.ts +22 -0
- package/dist/core/runtime/stamp/channel.js +83 -0
- package/dist/core/runtime/stamp/protocol.d.ts +90 -0
- package/dist/core/runtime/stamp/protocol.js +113 -0
- package/dist/core/runtime/tmux-driver.d.ts +11 -1
- package/dist/core/runtime/tmux-driver.js +27 -11
- package/dist/core/runtime/turn-visibility.d.ts +10 -0
- package/dist/core/runtime/turn-visibility.js +31 -0
- package/dist/core/self-update.js +2 -5
- package/dist/core/tui/draw.d.ts +1 -0
- package/dist/core/tui/draw.js +5 -1
- package/dist/core/tui/markdown.d.ts +17 -0
- package/dist/core/tui/markdown.js +407 -0
- package/dist/core/user-settings.d.ts +4 -0
- package/dist/core/user-settings.js +1 -0
- package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
- package/dist/daemon/api/bridge.d.ts +3 -2
- package/dist/daemon/api/bridge.js +34 -9
- package/dist/daemon/api/handlers/attach.js +4 -4
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/canvas.js +62 -16
- package/dist/daemon/api/handlers/messages.js +14 -3
- package/dist/daemon/api/handlers/nodes.js +17 -7
- package/dist/daemon/api/map.js +2 -0
- package/dist/daemon/cron/sinks.js +3 -3
- package/dist/daemon/fleet.js +1 -1
- package/dist/daemon/messaging/node-message.js +16 -2
- package/dist/daemon/profile-delete.js +1 -1
- package/dist/daemon/reconcilers/broker-supervision.js +26 -9
- package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
- package/dist/daemon/reconcilers/live-obligation.js +17 -0
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
- package/dist/hook-authoring.d.ts +31 -1
- package/dist/hook-authoring.js +42 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
- package/dist/pi-extensions/canvas-recap.js +4 -0
- package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
- package/dist/pi-extensions/canvas-stamp.js +77 -0
- package/dist/pi-extensions/canvas-stophook.js +3 -0
- package/dist/shared/__tests__/env-boundary.test.js +4 -2
- package/dist/shared/crtr-version.d.ts +2 -0
- package/dist/shared/crtr-version.js +18 -0
- package/dist/shared/env.d.ts +10 -0
- package/dist/shared/env.js +19 -4
- package/dist/shared/generated-context.d.ts +1 -2
- package/dist/shared/generated-context.js +3 -4
- package/dist/types.d.ts +10 -0
- package/dist/types.js +4 -0
- package/package.json +1 -1
- 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
|
+
}
|
|
@@ -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.
|
|
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
|
-
* `
|
|
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
|
|
11
|
-
// `
|
|
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`;
|
|
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 {
|
|
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
|
-
* `
|
|
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
|
-
|
|
367
|
-
|
|
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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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
|
}
|