@north-light/crouter 0.3.170 → 0.3.171

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 (121) hide show
  1. package/dist/build-root.d.ts +1 -16
  2. package/dist/build-root.js +1 -25
  3. package/dist/builtin-memory/insights/capture.md +66 -0
  4. package/dist/builtin-memory/insights/init.md +45 -0
  5. package/dist/builtin-memory/internal/plugins.md +12 -11
  6. package/dist/cli.js +2 -6
  7. package/dist/clients/attach/__tests__/attach-keybindings.test.js +7 -1
  8. package/dist/clients/attach/__tests__/context-message.test.js +50 -3
  9. package/dist/clients/attach/__tests__/diagram.test.js +1 -1
  10. package/dist/clients/attach/chrome/header.d.ts +10 -0
  11. package/dist/clients/attach/chrome/header.js +35 -0
  12. package/dist/clients/attach/chrome/status-line.d.ts +3 -0
  13. package/dist/clients/attach/chrome/status-line.js +5 -0
  14. package/dist/clients/attach/chrome/widgets.js +7 -18
  15. package/dist/clients/attach/input/controller.d.ts +10 -0
  16. package/dist/clients/attach/input/controller.js +3 -0
  17. package/dist/clients/attach/overlays/help.d.ts +33 -0
  18. package/dist/clients/attach/overlays/help.js +204 -0
  19. package/dist/clients/attach/render/chat-view.d.ts +81 -9
  20. package/dist/clients/attach/render/chat-view.js +434 -90
  21. package/dist/clients/attach/render/context-message.js +3 -3
  22. package/dist/clients/attach/render/diagram.js +2 -2
  23. package/dist/clients/attach/render/measured-container.d.ts +94 -0
  24. package/dist/clients/attach/render/measured-container.js +307 -0
  25. package/dist/clients/attach/render/transcript-copy.d.ts +9 -0
  26. package/dist/clients/attach/render/transcript-copy.js +81 -0
  27. package/dist/clients/attach/render/viewport.d.ts +29 -0
  28. package/dist/clients/attach/render/viewport.js +160 -0
  29. package/dist/clients/attach/session/bindings.d.ts +5 -1
  30. package/dist/clients/attach/session/bindings.js +16 -0
  31. package/dist/clients/attach/session/context.d.ts +5 -3
  32. package/dist/clients/attach/session/frame.d.ts +34 -0
  33. package/dist/clients/attach/session/frame.js +173 -0
  34. package/dist/clients/attach/session/frames.d.ts +2 -0
  35. package/dist/clients/attach/session/frames.js +3 -0
  36. package/dist/clients/attach/session/input-wiring.d.ts +10 -4
  37. package/dist/clients/attach/session/input-wiring.js +13 -13
  38. package/dist/clients/attach/session/keys.d.ts +4 -1
  39. package/dist/clients/attach/session/keys.js +9 -4
  40. package/dist/clients/attach/session/layout.d.ts +10 -8
  41. package/dist/clients/attach/session/layout.js +32 -31
  42. package/dist/clients/attach/session/mouse.d.ts +15 -0
  43. package/dist/clients/attach/session/mouse.js +61 -0
  44. package/dist/clients/attach/slash/dispatch.d.ts +10 -0
  45. package/dist/clients/attach/slash/dispatch.js +35 -2
  46. package/dist/clients/attach/viewer.js +605 -581
  47. package/dist/clients/web/dev-server.d.ts +4 -1
  48. package/dist/clients/web/dev-server.js +8 -19
  49. package/dist/clients/web/web-cmd.js +1 -1
  50. package/dist/commands/human/prompts.js +2 -1
  51. package/dist/commands/pkg/plugin-manage.js +143 -68
  52. package/dist/commands/sys/__tests__/setup-core.test.js +4 -2
  53. package/dist/commands/sys/setup-core.js +21 -16
  54. package/dist/core/clipboard-text.d.ts +8 -0
  55. package/dist/core/clipboard-text.js +18 -0
  56. package/dist/core/command-manifests/registry.d.ts +1 -3
  57. package/dist/core/command-plugins/bundle.d.ts +29 -0
  58. package/dist/core/command-plugins/bundle.js +198 -0
  59. package/dist/core/command-plugins/discovery.d.ts +0 -8
  60. package/dist/core/command-plugins/discovery.js +2 -39
  61. package/dist/core/command-plugins/transport/http-fetch.d.ts +4 -30
  62. package/dist/core/command-plugins/transport/http-fetch.js +17 -95
  63. package/dist/core/command.d.ts +1 -13
  64. package/dist/core/command.js +1 -16
  65. package/dist/core/config.js +2 -1
  66. package/dist/core/keybindings/__tests__/resolve.test.js +4 -0
  67. package/dist/core/keybindings/attach-control.d.ts +3 -0
  68. package/dist/core/keybindings/attach-control.js +1 -0
  69. package/dist/core/keybindings/catalog.d.ts +2 -2
  70. package/dist/core/keybindings/catalog.js +8 -0
  71. package/dist/core/runtime/broker-protocol.d.ts +13 -1
  72. package/dist/core/runtime/broker.js +213 -5
  73. package/dist/core/runtime/canvas-extensions.d.ts +3 -0
  74. package/dist/core/runtime/canvas-extensions.js +3 -0
  75. package/dist/core/runtime/front-door.js +4 -4
  76. package/dist/core/runtime/kickoff.d.ts +1 -5
  77. package/dist/core/runtime/kickoff.js +1 -5
  78. package/dist/core/runtime/naming.d.ts +7 -0
  79. package/dist/core/runtime/naming.js +5 -2
  80. package/dist/core/runtime/node-read.js +15 -0
  81. package/dist/core/runtime/package-health.d.ts +6 -21
  82. package/dist/core/runtime/package-health.js +12 -123
  83. package/dist/core/runtime/stop-guard.d.ts +1 -1
  84. package/dist/core/runtime/stop-guard.js +3 -3
  85. package/dist/core/runtime/tool-group-summary.d.ts +19 -0
  86. package/dist/core/runtime/tool-group-summary.js +117 -0
  87. package/dist/core/termrender/code-doc.d.ts +12 -0
  88. package/dist/core/termrender/code-doc.js +91 -0
  89. package/dist/core/termrender/display.d.ts +12 -0
  90. package/dist/core/termrender/display.js +19 -0
  91. package/dist/core/termrender/termrender.d.ts +73 -0
  92. package/dist/core/termrender/termrender.js +795 -0
  93. package/dist/core/termrender/version.d.ts +1 -0
  94. package/dist/core/termrender/version.js +1 -0
  95. package/dist/core/user-settings.d.ts +14 -0
  96. package/dist/core/user-settings.js +20 -0
  97. package/dist/daemon/crtrd.js +5 -6
  98. package/dist/index.d.ts +2 -0
  99. package/dist/index.js +1 -0
  100. package/dist/pi-extensions/__tests__/canvas-structured-output.test.d.ts +1 -0
  101. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +63 -0
  102. package/dist/pi-extensions/canvas-stophook.js +2 -2
  103. package/dist/pi-extensions/canvas-structured-output.js +52 -8
  104. package/dist/pi-extensions/summary-tool.d.ts +33 -0
  105. package/dist/pi-extensions/summary-tool.js +62 -0
  106. package/dist/shared/generated-context.d.ts +19 -4
  107. package/dist/shared/generated-context.js +98 -7
  108. package/dist/shared/tool-groups.d.ts +23 -0
  109. package/dist/shared/tool-groups.js +60 -0
  110. package/dist/types.d.ts +11 -0
  111. package/dist/types.js +1 -0
  112. package/dist/web-client/assets/{index-U_NZ66VE.js → index-D7I3gHCL.js} +26 -19
  113. package/dist/web-client/index.html +1 -1
  114. package/dist/web-client/sw.js +1 -1
  115. package/package.json +3 -1
  116. package/runtime.lock.json +62 -3
  117. package/scripts/postinstall.mjs +14 -1
  118. package/dist/core/command-plugins/store.d.ts +0 -16
  119. package/dist/core/command-plugins/store.js +0 -64
  120. package/dist/core/runtime/fault-recovery-nudge.d.ts +0 -3
  121. package/dist/core/runtime/fault-recovery-nudge.js +0 -3
@@ -0,0 +1,795 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, writeFileSync, statSync, mkdirSync, openSync, closeSync, unlinkSync, renameSync, accessSync, realpathSync, constants, } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import stringWidth from 'string-width';
6
+ import { TERMRENDER_VERSION } from './version.js';
7
+ // ── The sole org-wide termrender binding ─────────────────────────────────────
8
+ //
9
+ // termrender is crouter's sole org-wide renderer binding: a pure-Python tool
10
+ // pinned to TERMRENDER_VERSION and installed into a venv crouter owns. The binary is
11
+ // resolved by ABSOLUTE PATH inside that venv — never `$PATH` — so a user's
12
+ // own `pip install termrender` can never shadow or break the pin.
13
+ const RENDERER_CACHE_DIR = join(resolve(process.env.XDG_CACHE_HOME || join(homedir(), '.cache')), 'crouter', 'termrender', TERMRENDER_VERSION);
14
+ const VENV_DIR = join(RENDERER_CACHE_DIR, 'venv');
15
+ const VENV_BIN = join(VENV_DIR, 'bin/termrender');
16
+ const VENV_PYTHON = join(VENV_DIR, 'bin/python');
17
+ // Readiness marker written by the single authoritative provisioning transition
18
+ // after a verified install. It fingerprints the ACTUAL verified environment —
19
+ // launcher + interpreter (mtime, size, mode) and the interpreter's realpath —
20
+ // so steady-state validation is a handful of cheap fs stats (no subprocess),
21
+ // yet a stripped exec bit, a swapped venv Python, or a rewritten launcher all
22
+ // invalidate it. Deeper corruption an fs stat can't see (e.g. mangled
23
+ // site-packages under an unchanged launcher) is caught by the other half of
24
+ // the contract: any `ready` invocation that fails to RUN invalidates this
25
+ // marker (see invalidateRenderer), so the next process repairs. Together these
26
+ // remove the ~149ms `termrender -h` + `importlib.metadata` spawn tax from the
27
+ // steady path without letting a stale marker trust a broken renderer forever.
28
+ const VENV_STAMP = join(VENV_DIR, '.crtr-termrender-stamp.json');
29
+ // Provisioning lock — lives OUTSIDE .venv (which `uv venv --clear` wipes) so it
30
+ // survives a reinstall. The user cache stays writable when crouter is installed
31
+ // in a read-only runtime image, and serializes venv mutation + stamp publication
32
+ // across processes: a stamp can never certify a concurrently-changing venv.
33
+ const VENV_LOCK = join(RENDERER_CACHE_DIR, '.crtr-termrender.lock');
34
+ // A lock older than this is from a crashed process and may be stolen. Set
35
+ // comfortably above the worst-case held path (uv probe 5s + venv 60s + install
36
+ // 120s + re-verify ~10s ≈ 195s) so a slow-but-alive holder is never judged
37
+ // stale while it still holds.
38
+ const LOCK_STALE_MS = 300_000;
39
+ // Absolute cap on how long a waiter spins before giving up to plaintext for
40
+ // this session (the next launch retries) — a safety valve so a wedged holder
41
+ // can never hang a process, WITHOUT ever stealing a lock we can't prove stale.
42
+ const LOCK_GIVE_UP_MS = LOCK_STALE_MS + 60_000;
43
+ let rendererState = 'unchecked';
44
+ function isFp(x) {
45
+ const e = x;
46
+ return !!e && typeof e.mtimeMs === 'number' && typeof e.size === 'number' && typeof e.mode === 'number';
47
+ }
48
+ function fingerprint(path) {
49
+ try {
50
+ const s = statSync(path);
51
+ return { mtimeMs: s.mtimeMs, size: s.size, mode: s.mode };
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ function fpMatch(a, b) {
58
+ return !!a && a.mtimeMs === b.mtimeMs && a.size === b.size && a.mode === b.mode;
59
+ }
60
+ function readStamp() {
61
+ try {
62
+ const p = JSON.parse(readFileSync(VENV_STAMP, 'utf8'));
63
+ if (p && typeof p.version === 'string' && isFp(p.bin) && isFp(p.python) && typeof p.pythonRealpath === 'string') {
64
+ return p;
65
+ }
66
+ return null;
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ // Publish the readiness marker for the state we just verified. Failure to
73
+ // persist is surfaced explicitly (not swallowed): the renderer works for THIS
74
+ // process, but every future launch re-verifies the slow way until the marker
75
+ // can be written — the operator should know why launches stay slow.
76
+ function publishStamp() {
77
+ const bin = fingerprint(VENV_BIN);
78
+ const python = fingerprint(VENV_PYTHON);
79
+ if (!bin || !python) {
80
+ process.stderr.write('[crtr] termrender stamp skipped: venv files vanished immediately after verify\n');
81
+ return;
82
+ }
83
+ let pythonRealpath;
84
+ try {
85
+ pythonRealpath = realpathSync(VENV_PYTHON);
86
+ }
87
+ catch {
88
+ pythonRealpath = VENV_PYTHON;
89
+ }
90
+ const stamp = { version: TERMRENDER_VERSION, bin, python, pythonRealpath };
91
+ // Atomic publish: write a temp then rename, so a crash mid-write can't leave
92
+ // a half-written stamp and a concurrent reader never observes a torn file.
93
+ const tmp = `${VENV_STAMP}.${process.pid}.tmp`;
94
+ try {
95
+ writeFileSync(tmp, JSON.stringify(stamp));
96
+ renameSync(tmp, VENV_STAMP);
97
+ }
98
+ catch (err) {
99
+ try {
100
+ unlinkSync(tmp);
101
+ }
102
+ catch { /* nothing to clean up */ }
103
+ process.stderr.write(`[crtr] termrender ready but stamp not persisted (${err instanceof Error ? err.message : String(err)}); ` +
104
+ 'future launches will re-verify the slow way\n');
105
+ }
106
+ }
107
+ // Invalidate the readiness marker when a supposedly-ready renderer misbehaves
108
+ // in a way that implicates the environment (spawn fault, or a `doc render`
109
+ // failure — render is best-effort by contract, so ANY failure means the tool,
110
+ // not the input, is broken; this catches site-packages corruption an fs stat
111
+ // can't see). Removes the on-disk marker so the next process repairs, and
112
+ // downgrades THIS process to plaintext to avoid retry thrash within the session.
113
+ function invalidateRenderer(reason) {
114
+ let removed = true;
115
+ try {
116
+ unlinkSync(VENV_STAMP);
117
+ }
118
+ catch (err) {
119
+ // ENOENT means it's already gone (still invalidated); any other error means
120
+ // the marker SURVIVES and will keep certifying — say so honestly rather
121
+ // than promising a repair that can't happen until the file is removable.
122
+ if (err.code !== 'ENOENT')
123
+ removed = false;
124
+ }
125
+ rendererState = 'unavailable';
126
+ if (removed) {
127
+ process.stderr.write(`[crtr] termrender invocation failed (${reason}); invalidated readiness, future launches will repair\n`);
128
+ }
129
+ else {
130
+ process.stderr.write(`[crtr] termrender invocation failed (${reason}) but its readiness marker could not be removed; ` +
131
+ `future launches may keep trusting a broken renderer until ${VENV_STAMP} is deleted\n`);
132
+ }
133
+ }
134
+ // True when a spawn was killed by its own timeout rather than failing to run —
135
+ // usually a slow/large document, not a broken environment. Excluded from
136
+ // invalidation so a reliably-slow render can't oscillate a healthy renderer
137
+ // (invalidate → slow re-verify → re-stamp) every session.
138
+ function isTimeout(err) {
139
+ const e = err;
140
+ return e?.code === 'ETIMEDOUT' || (!!e?.killed && !!e?.signal);
141
+ }
142
+ // Cheap steady-state trust — all fs stats, no subprocess. The pinned launcher
143
+ // is present, executable, and byte-for-byte the one the stamp verified; the
144
+ // interpreter it targets is the same file at the same realpath. Any of these
145
+ // drifting (version bump, stripped exec bit, rewritten launcher, swapped venv
146
+ // Python) forces the authoritative re-provision transition.
147
+ function stampValid() {
148
+ const stamp = readStamp();
149
+ if (!stamp || stamp.version !== TERMRENDER_VERSION)
150
+ return false;
151
+ try {
152
+ accessSync(VENV_BIN, constants.X_OK);
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ if (!fpMatch(fingerprint(VENV_BIN), stamp.bin))
158
+ return false;
159
+ if (!fpMatch(fingerprint(VENV_PYTHON), stamp.python))
160
+ return false;
161
+ try {
162
+ if (realpathSync(VENV_PYTHON) !== stamp.pythonRealpath)
163
+ return false;
164
+ }
165
+ catch {
166
+ return false;
167
+ }
168
+ return true;
169
+ }
170
+ function sleepSync(ms) {
171
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
172
+ }
173
+ function lockIsStale() {
174
+ try {
175
+ const at = JSON.parse(readFileSync(VENV_LOCK, 'utf8')).at;
176
+ if (typeof at === 'number')
177
+ return Date.now() - at > LOCK_STALE_MS;
178
+ }
179
+ catch {
180
+ // Unreadable/malformed lock — fall through to mtime.
181
+ }
182
+ try {
183
+ return Date.now() - statSync(VENV_LOCK).mtimeMs > LOCK_STALE_MS;
184
+ }
185
+ catch {
186
+ return true; // vanished — the retry loop re-acquires
187
+ }
188
+ }
189
+ // Run `provision` while holding the exclusive provisioning lock. If another
190
+ // live process holds it, wait for that process to publish (re-checking the
191
+ // stamp) rather than mutating the venv concurrently. Stale locks are stolen.
192
+ // Steal a lock judged stale by renaming it to a unique name first: rename is
193
+ // atomic, so if two waiters race only the one whose rename succeeds owns (and
194
+ // deletes) it — the loser gets ENOENT and re-loops. This can never delete a
195
+ // lock a peer has freshly re-acquired (that peer holds a DIFFERENT inode at the
196
+ // same path; our rename of the old name either already happened or fails).
197
+ function stealStaleLock() {
198
+ const tmp = `${VENV_LOCK}.steal.${process.pid}.${Date.now()}`;
199
+ try {
200
+ renameSync(VENV_LOCK, tmp);
201
+ }
202
+ catch {
203
+ return; // lost the steal race or already gone — caller re-loops
204
+ }
205
+ try {
206
+ unlinkSync(tmp);
207
+ }
208
+ catch { /* best effort */ }
209
+ }
210
+ function withProvisionLock(provision) {
211
+ try {
212
+ mkdirSync(RENDERER_CACHE_DIR, { recursive: true });
213
+ }
214
+ catch (err) {
215
+ rendererState = 'unavailable';
216
+ process.stderr.write(`[crtr] termrender unavailable — renderer cache cannot be created (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
217
+ return;
218
+ }
219
+ const giveUpAt = Date.now() + LOCK_GIVE_UP_MS;
220
+ for (;;) {
221
+ let fd;
222
+ try {
223
+ fd = openSync(VENV_LOCK, 'wx'); // O_CREAT | O_EXCL — atomic acquire
224
+ }
225
+ catch (err) {
226
+ if (err.code !== 'EEXIST') {
227
+ rendererState = 'unavailable';
228
+ process.stderr.write(`[crtr] termrender unavailable — renderer cache cannot be locked (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
229
+ return;
230
+ }
231
+ if (lockIsStale()) {
232
+ stealStaleLock();
233
+ continue;
234
+ }
235
+ // A live process is provisioning — give it a chance, then adopt its result.
236
+ // Never steal a lock we can't prove stale; if we wait too long, give up to
237
+ // plaintext this session rather than break mutual exclusion.
238
+ if (Date.now() > giveUpAt) {
239
+ rendererState = 'unavailable';
240
+ return;
241
+ }
242
+ sleepSync(200);
243
+ if (stampValid()) {
244
+ rendererState = 'ready';
245
+ return;
246
+ }
247
+ continue;
248
+ }
249
+ try {
250
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
251
+ }
252
+ catch { /* lock held regardless of whether the marker body wrote */ }
253
+ try {
254
+ provision();
255
+ }
256
+ finally {
257
+ try {
258
+ closeSync(fd);
259
+ }
260
+ catch { /* already closed */ }
261
+ try {
262
+ unlinkSync(VENV_LOCK);
263
+ }
264
+ catch { /* stolen as stale by another process */ }
265
+ }
266
+ return;
267
+ }
268
+ }
269
+ function binaryOk() {
270
+ if (!existsSync(VENV_BIN))
271
+ return false;
272
+ try {
273
+ // v2 contract: no --version flag; use -h (exit 0) as a liveness check.
274
+ execFileSync(VENV_BIN, ['-h'], {
275
+ encoding: 'utf8',
276
+ stdio: ['ignore', 'pipe', 'pipe'],
277
+ timeout: 5000,
278
+ });
279
+ return true;
280
+ }
281
+ catch {
282
+ return false;
283
+ }
284
+ }
285
+ // Returns the termrender version installed in the managed venv (via
286
+ // importlib.metadata), or null if the venv is missing/broken or termrender is
287
+ // not installed. Used by ensureRenderer() to detect drift from the pin and
288
+ // trigger a reinstall — otherwise a venv provisioned at an older pin sticks
289
+ // forever (binaryOk passes for any working binary, regardless of version).
290
+ function installedVersion() {
291
+ if (!existsSync(VENV_PYTHON))
292
+ return null;
293
+ try {
294
+ const out = execFileSync(VENV_PYTHON, ['-c', 'import importlib.metadata as m; print(m.version("termrender"))'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
295
+ return out.trim() || null;
296
+ }
297
+ catch {
298
+ return null;
299
+ }
300
+ }
301
+ function uvAvailable() {
302
+ try {
303
+ execFileSync('uv', ['--version'], { stdio: 'pipe', timeout: 5000 });
304
+ return true;
305
+ }
306
+ catch {
307
+ return false;
308
+ }
309
+ }
310
+ /**
311
+ * Memoized, self-healing. Guarantees at most one authoritative renderer
312
+ * lifecycle per process: trust a valid stamp outright (zero subprocess spawns),
313
+ * else run ONE provision/verify/publish transition under the exclusive lock.
314
+ * There is no permanent legacy-verifier fallback — the spawn-based verify
315
+ * (`binaryOk` + `installedVersion`) is a step INSIDE that single transition,
316
+ * always followed by publishing the stamp, never a lasting alternate path.
317
+ *
318
+ * Single degradation path: `uv` absent → one stderr remediation line + plaintext.
319
+ * win32 → plaintext (no renderer).
320
+ *
321
+ * Invoked at postinstall AND lazily on the first render/check/display call,
322
+ * so `npm ci --ignore-scripts` consumers still self-heal on first use.
323
+ */
324
+ export function ensureRenderer() {
325
+ if (rendererState !== 'unchecked')
326
+ return;
327
+ if (process.platform === 'win32') {
328
+ rendererState = 'unavailable';
329
+ return;
330
+ }
331
+ // Steady state: trust the stamp — zero subprocess spawns.
332
+ if (stampValid()) {
333
+ rendererState = 'ready';
334
+ return;
335
+ }
336
+ // No/invalid stamp → the single authoritative transition, serialized so no
337
+ // two processes mutate the venv (or certify it) concurrently.
338
+ provisionAndPublish();
339
+ }
340
+ // The one invalid-stamp transition. Under the exclusive lock: adopt a stamp a
341
+ // racing process just published; else verify the current venv (a healthy venv
342
+ // with no/stale stamp — an interrupted publish, or a peer
343
+ // that finished the venv but not the stamp — needs only re-verification, not a
344
+ // reinstall); reinstall via `uv` only when genuinely missing/drifted; then
345
+ // verify and publish. Every success path ends by publishing the stamp.
346
+ function provisionAndPublish() {
347
+ withProvisionLock(() => {
348
+ // A peer may have published between our unlocked stampValid() in
349
+ // ensureRenderer and our acquiring the lock here — adopt it, don't reinstall.
350
+ if (stampValid()) {
351
+ rendererState = 'ready';
352
+ return;
353
+ }
354
+ let verified = binaryOk() && installedVersion() === TERMRENDER_VERSION;
355
+ if (!verified) {
356
+ if (!uvAvailable()) {
357
+ process.stderr.write('[crtr] termrender unavailable — install uv to enable rich rendering:\n' +
358
+ ' curl -LsSf https://astral.sh/uv/install.sh | sh\n');
359
+ rendererState = 'unavailable';
360
+ return;
361
+ }
362
+ try {
363
+ // (Re)create the venv whenever the interpreter is missing — covers both
364
+ // "directory absent" and "directory present but bin/python stripped"
365
+ // (seen when pnpm rebuilds/dedupes node_modules or uv rotates its
366
+ // managed Python store). `--clear` wipes any partial state rather than
367
+ // refusing on the existing dir. Intact interpreter → reuse the venv.
368
+ if (!existsSync(VENV_PYTHON)) {
369
+ execFileSync('uv', ['venv', '--clear', VENV_DIR], { stdio: 'pipe', timeout: 60000 });
370
+ }
371
+ // `--reinstall` forces uv to rebuild the package and rewrite its entry
372
+ // point even when the pinned version already appears satisfied — so this
373
+ // path actually REPAIRS a corrupt-but-present install (the case that
374
+ // drove us here via invalidation), not just a clean version drift.
375
+ execFileSync('uv', ['pip', 'install', '--reinstall', '--python', VENV_PYTHON, `termrender==${TERMRENDER_VERSION}`], { stdio: 'pipe', timeout: 120000 });
376
+ }
377
+ catch (err) {
378
+ process.stderr.write(`[crtr] termrender install failed (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
379
+ rendererState = 'unavailable';
380
+ return;
381
+ }
382
+ verified = binaryOk() && installedVersion() === TERMRENDER_VERSION;
383
+ }
384
+ if (!verified) {
385
+ rendererState = 'unavailable';
386
+ process.stderr.write('[crtr] termrender install completed but health check failed; using plaintext fallback\n');
387
+ return;
388
+ }
389
+ publishStamp();
390
+ rendererState = 'ready';
391
+ });
392
+ }
393
+ /** Cheap predicate — true when the pinned managed binary is verified ready. Does not install or spawn. */
394
+ export function isRendererReady() {
395
+ if (rendererState === 'ready')
396
+ return true;
397
+ if (rendererState === 'unavailable')
398
+ return false;
399
+ return process.platform !== 'win32' && stampValid();
400
+ }
401
+ // ── Plaintext fallback helpers (kept here so this is the only termrender site) ─
402
+ const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
403
+ function sanitize(text) {
404
+ if (typeof text !== 'string')
405
+ return '';
406
+ return text.replace(CONTROL_CHARS_RE, '');
407
+ }
408
+ function sliceByWidth(s, maxWidth) {
409
+ let w = 0;
410
+ let out = '';
411
+ for (const ch of s) {
412
+ const cw = stringWidth(ch);
413
+ if (w + cw > maxWidth)
414
+ break;
415
+ out += ch;
416
+ w += cw;
417
+ }
418
+ if (out === '' && s.length > 0)
419
+ out = [...s][0];
420
+ return out;
421
+ }
422
+ function wrap(text, maxWidth) {
423
+ if (maxWidth < 1)
424
+ return [text];
425
+ const out = [];
426
+ const paragraphs = text.split('\n');
427
+ for (let p = 0; p < paragraphs.length; p++) {
428
+ const para = paragraphs[p];
429
+ if (para === '') {
430
+ out.push('');
431
+ continue;
432
+ }
433
+ const words = para.split(/[ \t]+/).filter(Boolean);
434
+ let current = '';
435
+ for (let word of words) {
436
+ while (stringWidth(word) > maxWidth) {
437
+ if (current) {
438
+ out.push(current);
439
+ current = '';
440
+ }
441
+ const piece = sliceByWidth(word, maxWidth);
442
+ out.push(piece);
443
+ word = word.slice(piece.length);
444
+ }
445
+ const candidate = current ? `${current} ${word}` : word;
446
+ if (stringWidth(candidate) <= maxWidth) {
447
+ current = candidate;
448
+ }
449
+ else {
450
+ if (current)
451
+ out.push(current);
452
+ current = word;
453
+ }
454
+ }
455
+ if (current)
456
+ out.push(current);
457
+ }
458
+ return out.length > 0 ? out : [''];
459
+ }
460
+ // ── Render surface ───────────────────────────────────────────────────────────
461
+ const _bodyCache = new Map();
462
+ /** Render markdown to terminal lines via the pinned binary; plaintext fallback. */
463
+ export function renderMarkdown(md, width) {
464
+ const key = `${md}\0${width}`;
465
+ const cached = _bodyCache.get(key);
466
+ if (cached)
467
+ return cached;
468
+ ensureRenderer();
469
+ if (rendererState === 'ready') {
470
+ try {
471
+ const out = execFileSync(VENV_BIN, ['doc', 'render', '--width', String(width), '--color', 'on'], {
472
+ input: md,
473
+ encoding: 'utf-8',
474
+ timeout: 5000,
475
+ stdio: ['pipe', 'pipe', 'pipe'],
476
+ });
477
+ const lines = out.split('\n');
478
+ if (lines.length > 0 && lines[lines.length - 1] === '')
479
+ lines.pop();
480
+ _bodyCache.set(key, lines);
481
+ return lines;
482
+ }
483
+ catch (err) {
484
+ // `doc render` is best-effort, so a non-timeout failure implicates the
485
+ // tool, not the markdown: invalidate so the next process repairs. A
486
+ // timeout is a slow/large doc, not corruption — just fall to plaintext.
487
+ if (!isTimeout(err))
488
+ invalidateRenderer('doc render');
489
+ }
490
+ }
491
+ const fallback = wrap(sanitize(md), width);
492
+ _bodyCache.set(key, fallback);
493
+ return fallback;
494
+ }
495
+ /** Block types allowed to use the full pane width instead of the prose cap:
496
+ * a diagram is a picture, not prose, so a readability column limit only
497
+ * shrinks it below what the pane could show. */
498
+ const WIDE_BLOCK_TYPES = new Set(['mermaid']);
499
+ /** Validate + normalize `doc render --line-map` JSON into a RenderedDoc.
500
+ * Null block bounds (a scanner edge case) are filled from neighbors and
501
+ * clamped into the source range; an empty block list gets one whole-doc
502
+ * block so callers can always anchor. Malformed shape → null (tool fault). */
503
+ function parseRenderedDoc(out, source) {
504
+ let parsed;
505
+ try {
506
+ parsed = JSON.parse(out);
507
+ }
508
+ catch {
509
+ return null;
510
+ }
511
+ const p = parsed;
512
+ if (!Array.isArray(p.lines) || !Array.isArray(p.rows) || !Array.isArray(p.spans) || !Array.isArray(p.blocks))
513
+ return null;
514
+ if (p.rows.length !== p.lines.length || p.spans.length !== p.lines.length)
515
+ return null;
516
+ if (!p.lines.every((l) => typeof l === 'string'))
517
+ return null;
518
+ const blockCount = p.blocks.length;
519
+ if (!p.rows.every((r) => r === null || (typeof r === 'number' && Number.isInteger(r) && r >= 0 && r < blockCount)))
520
+ return null;
521
+ const totalSource = Math.max(1, source.split('\n').length);
522
+ const blocks = [];
523
+ let prevEnd = 0;
524
+ for (const raw of p.blocks) {
525
+ if (typeof raw !== 'object' || raw === null)
526
+ return null;
527
+ // The block type is what lets callers render diagrams differently from
528
+ // prose; a map without it cannot drive block-aware rendering at all.
529
+ if (typeof raw.type !== 'string' || raw.type === '')
530
+ return null;
531
+ // Bounds are integers or null — a fractional bound would otherwise flow
532
+ // into a recorded comment's line/endLine instead of tripping tool-fault.
533
+ if (raw.start != null && !Number.isInteger(raw.start))
534
+ return null;
535
+ if (raw.end != null && !Number.isInteger(raw.end))
536
+ return null;
537
+ const start = typeof raw.start === 'number' ? raw.start : prevEnd + 1;
538
+ const end = typeof raw.end === 'number' ? Math.max(raw.end, start) : start;
539
+ const s = Math.max(1, Math.min(start, totalSource));
540
+ const e = Math.max(s, Math.min(end, totalSource));
541
+ blocks.push({ type: raw.type, start: s, end: e });
542
+ prevEnd = e;
543
+ }
544
+ if (blocks.length === 0)
545
+ blocks.push({ type: 'paragraph', start: 1, end: totalSource });
546
+ const spans = [];
547
+ for (let r = 0; r < p.spans.length; r++) {
548
+ const raw = p.spans[r];
549
+ if (raw === null) {
550
+ spans.push(null);
551
+ continue;
552
+ }
553
+ // Same strictness as rows: a span is [start, end], 1-indexed inclusive
554
+ // integers in order, inside the source — these values flow straight into
555
+ // a recorded comment's line/endLine, so an out-of-range endpoint is tool
556
+ // fault to reject, never something to clamp into the wrong line.
557
+ if (!Array.isArray(raw) || raw.length !== 2)
558
+ return null;
559
+ const [s, e] = raw;
560
+ if (!Number.isInteger(s) || !Number.isInteger(e))
561
+ return null;
562
+ if (s < 1 || e < s || e > totalSource)
563
+ return null;
564
+ // Relational contract: a separator row (rows[r] === null) carries no
565
+ // span, and a leaf span stays inside its owning block's source range. A
566
+ // map that disagrees with its own rows/blocks would let a comment on a
567
+ // visible row record against unrelated source text.
568
+ const rowBlock = p.rows[r];
569
+ if (rowBlock === null)
570
+ return null;
571
+ const owner = blocks[rowBlock];
572
+ if (s < owner.start || e > owner.end)
573
+ return null;
574
+ spans.push([s, e]);
575
+ }
576
+ return { lines: p.lines, rows: p.rows, spans, blocks };
577
+ }
578
+ /** Renderer-free mapped render: group consecutive non-blank source lines into
579
+ * paragraph blocks and word-wrap each — the same shape as the termrender map,
580
+ * with degraded (plaintext) rendering. Each source line is wrapped on its own
581
+ * and spans exactly itself, so leaf anchoring degrades to true line-by-line. */
582
+ function fallbackDocWithMap(md, width) {
583
+ const src = sanitize(md).split('\n');
584
+ const blocks = [];
585
+ let openAt = null;
586
+ for (let i = 0; i < src.length; i++) {
587
+ if (src[i].trim() !== '') {
588
+ if (openAt === null)
589
+ openAt = i;
590
+ }
591
+ else if (openAt !== null) {
592
+ blocks.push({ type: 'paragraph', start: openAt + 1, end: i });
593
+ openAt = null;
594
+ }
595
+ }
596
+ if (openAt !== null)
597
+ blocks.push({ type: 'paragraph', start: openAt + 1, end: src.length });
598
+ if (blocks.length === 0)
599
+ blocks.push({ type: 'paragraph', start: 1, end: Math.max(1, src.length) });
600
+ const lines = [];
601
+ const rows = [];
602
+ const spans = [];
603
+ blocks.forEach((b, bi) => {
604
+ if (bi > 0) {
605
+ lines.push('');
606
+ rows.push(null);
607
+ spans.push(null);
608
+ }
609
+ // wrap() treats each '\n'-separated line as its own paragraph, so wrapping
610
+ // line-by-line emits the exact rows the joined-block wrap produced.
611
+ for (let ln = b.start; ln <= b.end; ln++) {
612
+ for (const l of wrap(src[ln - 1] ?? '', width)) {
613
+ lines.push(l);
614
+ rows.push(bi);
615
+ spans.push([ln, ln]);
616
+ }
617
+ }
618
+ });
619
+ return { lines, rows, spans, blocks };
620
+ }
621
+ const _mapCache = new Map();
622
+ /** Render markdown with a row→source-line map via the pinned binary
623
+ * (`doc render --line-map`); plaintext paragraph-block fallback. */
624
+ export function renderMarkdownWithMap(md, width) {
625
+ const key = `${md}\0${width}`;
626
+ const cached = _mapCache.get(key);
627
+ if (cached)
628
+ return cached;
629
+ ensureRenderer();
630
+ if (rendererState === 'ready') {
631
+ try {
632
+ const out = execFileSync(VENV_BIN, ['doc', 'render', '--width', String(width), '--color', 'on', '--line-map'], {
633
+ input: md,
634
+ encoding: 'utf-8',
635
+ timeout: 5000,
636
+ stdio: ['pipe', 'pipe', 'pipe'],
637
+ // JSON-escaped ANSI inflates well past execFileSync's 1MB default.
638
+ maxBuffer: 64 * 1024 * 1024,
639
+ });
640
+ const doc = parseRenderedDoc(out.trim(), md);
641
+ if (doc !== null) {
642
+ _mapCache.set(key, doc);
643
+ return doc;
644
+ }
645
+ // A working renderer never emits a malformed map — tool fault.
646
+ invalidateRenderer('doc render --line-map (malformed output)');
647
+ }
648
+ catch (err) {
649
+ // Same contract as renderMarkdown: non-timeout failure implicates the
650
+ // tool; a timeout is a slow/large doc — just fall to the local map.
651
+ if (!isTimeout(err))
652
+ invalidateRenderer('doc render --line-map');
653
+ }
654
+ }
655
+ // The fallback is cheap to recompute and deliberately NOT cached: a
656
+ // transient failure (e.g. a timeout) must not pin degraded block anchoring
657
+ // for the rest of the session when a later re-render could succeed.
658
+ return fallbackDocWithMap(md, width);
659
+ }
660
+ /**
661
+ * The shared block-aware mapped render: prose blocks keep the readability cap
662
+ * (`proseWidth`), while diagram blocks are re-rendered at the pane's full
663
+ * `paneWidth` and spliced back in by block index. Both surfaces (terminal
664
+ * review and the ask deck) go through this, so "which blocks may be wide" is
665
+ * decided once, from the renderer's own block types, never by heuristics on
666
+ * the emitted rows.
667
+ *
668
+ * Row order, block list and source spans are the narrow render's; only the
669
+ * rows OF a wide block are replaced, so anchoring stays source-based and
670
+ * unchanged.
671
+ */
672
+ export function renderMarkdownBlockAware(md, proseWidth, paneWidth) {
673
+ const base = renderMarkdownWithMap(md, proseWidth);
674
+ if (paneWidth <= proseWidth)
675
+ return base;
676
+ if (!base.blocks.some((b) => WIDE_BLOCK_TYPES.has(b.type)))
677
+ return base;
678
+ const wide = renderMarkdownWithMap(md, paneWidth);
679
+ // Same source, so the two renders describe the same top-level blocks. A
680
+ // disagreement means one of the two maps is degraded (renderer failure mid
681
+ // session) — render the narrow one rather than splice mismatched rows.
682
+ if (wide.blocks.length !== base.blocks.length)
683
+ return base;
684
+ const lines = [];
685
+ const rows = [];
686
+ const spans = [];
687
+ for (let r = 0; r < base.lines.length; r++) {
688
+ const bi = base.rows[r] ?? null;
689
+ if (bi === null || !WIDE_BLOCK_TYPES.has(base.blocks[bi].type)) {
690
+ lines.push(base.lines[r]);
691
+ rows.push(bi);
692
+ spans.push(base.spans[r] ?? null);
693
+ continue;
694
+ }
695
+ // First row of this block's run: emit the wide render's rows for the same
696
+ // block, then skip the narrow run (a block's rows are contiguous).
697
+ if (r === 0 || base.rows[r - 1] !== bi) {
698
+ for (let q = 0; q < wide.lines.length; q++) {
699
+ if (wide.rows[q] !== bi)
700
+ continue;
701
+ lines.push(wide.lines[q]);
702
+ rows.push(bi);
703
+ spans.push(wide.spans[q] ?? null);
704
+ }
705
+ }
706
+ }
707
+ return { lines, rows, spans, blocks: base.blocks };
708
+ }
709
+ /** Block-aware render as plain rows, for surfaces that do not anchor. */
710
+ export function renderMarkdownBlockAwareLines(md, proseWidth, paneWidth) {
711
+ return renderMarkdownBlockAware(md, proseWidth, paneWidth).lines;
712
+ }
713
+ /** Validate markdown via `termrender doc check`. */
714
+ export function checkMarkdown(md) {
715
+ ensureRenderer();
716
+ // Renderer unavailable → don't block validation; the body just renders as
717
+ // plaintext later. Bricking deck validation here would be the wrong default.
718
+ if (rendererState !== 'ready')
719
+ return { ok: true };
720
+ const result = spawnSync(VENV_BIN, ['doc', 'check'], {
721
+ input: md,
722
+ encoding: 'utf-8',
723
+ timeout: 5000,
724
+ });
725
+ if (result.error) {
726
+ // A timeout (slow doc) is not an environment fault; anything else is a
727
+ // spawn fault → invalidate so the next process repairs.
728
+ const timedOut = result.error.code === 'ETIMEDOUT' || result.signal === 'SIGTERM';
729
+ if (!timedOut)
730
+ invalidateRenderer('doc check');
731
+ return { ok: false, error: `termrender: invocation failed: ${result.error.message}` };
732
+ }
733
+ let parsed = null;
734
+ const rawStdout = typeof result.stdout === 'string' ? result.stdout : '';
735
+ if (rawStdout) {
736
+ try {
737
+ parsed = JSON.parse(rawStdout.trim());
738
+ }
739
+ catch {
740
+ // stdout not parseable — fall through to exit-code handling
741
+ }
742
+ }
743
+ if (parsed !== null) {
744
+ if (parsed.ok)
745
+ return { ok: true };
746
+ const first = Array.isArray(parsed.errors) ? parsed.errors[0] : undefined;
747
+ const msg = (first && typeof first.message === 'string' && first.message) ? first.message : 'invalid markdown';
748
+ return { ok: false, error: `termrender: ${msg}` };
749
+ }
750
+ // exit code 2 = invalid per contract; any non-zero is an error
751
+ if (result.status !== 0) {
752
+ return { ok: false, error: `termrender: doc check exited ${result.status}` };
753
+ }
754
+ return { ok: true };
755
+ }
756
+ /**
757
+ * Spawn termrender into a live tmux pane. The pane-budget policy (whether to
758
+ * split vs open a new window) is decided by the caller (`src/surfaces/
759
+ * display.ts`); this is the thin managed-binary spawn it delegates to.
760
+ */
761
+ export function displayInPane(path, opts = {}) {
762
+ ensureRenderer();
763
+ if (rendererState !== 'ready')
764
+ return {};
765
+ // Always watch: a displayed pane is a live view of the file by definition.
766
+ const args = ['pane', 'open', path, '--watch'];
767
+ args.push('--window', opts.newWindow ? 'new' : 'split');
768
+ const result = spawnSync(VENV_BIN, args, {
769
+ encoding: 'utf-8',
770
+ stdio: ['pipe', 'pipe', 'pipe'],
771
+ });
772
+ // A spawn fault (binary broke) invalidates readiness; a non-zero exit (e.g.
773
+ // tmux refused, bad path) is not an environment fault and must not.
774
+ if (result.error) {
775
+ invalidateRenderer('pane open');
776
+ return {};
777
+ }
778
+ if (result.status !== 0)
779
+ return {};
780
+ // `encoding: 'utf-8'` makes spawnSync return stdout as a string.
781
+ const rawStdout = result.stdout;
782
+ if (!rawStdout)
783
+ return {};
784
+ let parsed = null;
785
+ try {
786
+ parsed = JSON.parse(rawStdout.trim());
787
+ }
788
+ catch {
789
+ return {};
790
+ }
791
+ if (parsed && typeof parsed.pane_id === 'string' && parsed.pane_id) {
792
+ return { paneId: parsed.pane_id };
793
+ }
794
+ return {};
795
+ }