@north-light/crouter 0.3.193 → 0.3.195

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 (40) hide show
  1. package/dist/api/dto/bash-jobs.d.ts +2 -0
  2. package/dist/builtin-memory/02-lifecycle/01-resident.md +1 -1
  3. package/dist/builtin-memory/internal/examples/imessage-assistant.md +1 -1
  4. package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
  5. package/dist/clients/attach/viewer.js +377 -377
  6. package/dist/commands/node/bash.js +3 -2
  7. package/dist/commands/node/lifecycle.js +2 -2
  8. package/dist/core/__tests__/seam/dormancy-release.test.js +64 -10
  9. package/dist/core/bash-jobs.d.ts +11 -0
  10. package/dist/core/bash-jobs.js +24 -1
  11. package/dist/core/canvas/types.d.ts +2 -1
  12. package/dist/core/inspector/model.d.ts +5 -0
  13. package/dist/core/inspector/model.js +5 -0
  14. package/dist/core/inspector/text.js +2 -2
  15. package/dist/core/inspector/tui.js +10 -3
  16. package/dist/core/keybindings/catalog.d.ts +1 -1
  17. package/dist/core/keybindings/catalog.js +4 -2
  18. package/dist/core/runtime/persona.js +2 -1
  19. package/dist/core/runtime/tmux-bindings.js +8 -1
  20. package/dist/core/termrender/version.d.ts +1 -1
  21. package/dist/core/termrender/version.js +1 -1
  22. package/dist/daemon/api/handlers/bash-jobs.js +1 -0
  23. package/dist/daemon/reconcilers/broker-supervision.js +7 -1
  24. package/dist/daemon/reconcilers/dormant-inbox.js +8 -0
  25. package/dist/daemon/reconcilers/live-obligation.d.ts +2 -0
  26. package/dist/daemon/reconcilers/live-obligation.js +11 -0
  27. package/dist/pages/bundle.js +9 -9
  28. package/dist/pages/comments.d.ts +9 -0
  29. package/dist/pages/comments.js +53 -0
  30. package/dist/pages/elements/cards.js +19 -2
  31. package/dist/pages/elements/options.js +27 -2
  32. package/dist/pages/elements/table.js +31 -2
  33. package/dist/pages/elements/text.js +19 -2
  34. package/dist/pages/host.d.ts +26 -0
  35. package/dist/pages/host.js +33 -0
  36. package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -2
  37. package/dist/pi-extensions/canvas-bash-valve.js +25 -7
  38. package/package.json +1 -1
  39. package/runtime.lock.json +2 -2
  40. package/scripts/install-runtime.mjs +172 -5
@@ -18,8 +18,8 @@
18
18
  */
19
19
  import { registerElement } from '../register.js';
20
20
  import { readSlotConfig, readSlotId } from '../slot-config.js';
21
- import { artifactData, respond } from '../host.js';
22
- import { anchorLabel, findComment, setComment } from '../comments.js';
21
+ import { artifactData, respond, savedIds, savedResponse } from '../host.js';
22
+ import { adoptComments, anchorLabel, findComment, setComment } from '../comments.js';
23
23
  const COMMENT_BUBBLE = 'M3.25 3.5h9.5A1.75 1.75 0 0 1 14.5 5.25v5A1.75 1.75 0 0 1 12.75 12H7.4l-3.15 2.36A.5.5 0 0 1 3.5 14v-2H3.25A1.75 1.75 0 0 1 1.5 10.25v-5A1.75 1.75 0 0 1 3.25 3.5Z';
24
24
  const STYLE = `
25
25
  :host {
@@ -421,6 +421,7 @@ class CrtrTableElement extends HTMLElement {
421
421
  return;
422
422
  }
423
423
  this.#rows = config.rows;
424
+ this.#hydrate();
424
425
  this.#renderTable();
425
426
  }
426
427
  disconnectedCallback() {
@@ -440,8 +441,36 @@ class CrtrTableElement extends HTMLElement {
440
441
  return;
441
442
  }
442
443
  this.#rows = rows.rows;
444
+ this.#hydrate();
443
445
  this.#renderTable();
444
446
  }
447
+ /**
448
+ * Seed local state from the answer the host is already holding, so a reopened page shows
449
+ * the rows and columns that were picked. It runs once the rows are known — after a `source`
450
+ * load, not before — because a saved id is only honoured while it still names something on
451
+ * screen. The select modes are re-imposed for the same reason.
452
+ */
453
+ #hydrate() {
454
+ if (this.#slotId === undefined)
455
+ return;
456
+ const saved = savedResponse(this.#slotId);
457
+ if (saved === undefined)
458
+ return;
459
+ const rows = this.#orderRows(savedIds(saved.selectedRowIds));
460
+ const columns = this.#orderColumns(savedIds(saved.selectedColumnIds));
461
+ this.#selectedRowIds = this.#rowSelect === 'none' ? [] : this.#rowSelect === 'single' ? rows.slice(0, 1) : rows;
462
+ this.#selectedColumnIds =
463
+ this.#columnSelect === 'none' ? [] : this.#columnSelect === 'single' ? columns.slice(0, 1) : columns;
464
+ const rowIds = new Set(this.#rows.map((row) => row.id));
465
+ const columnIds = new Set((this.#config?.columns ?? []).map((column) => column.id));
466
+ this.#comments = adoptComments(saved.comments).filter((comment) => {
467
+ if (comment.anchor.kind === 'row')
468
+ return rowIds.has(comment.anchor.rowId);
469
+ if (comment.anchor.kind === 'column')
470
+ return columnIds.has(comment.anchor.columnId);
471
+ return true;
472
+ });
473
+ }
445
474
  // ── the response ───────────────────────────────────────────────────────────────────────
446
475
  get #rowSelect() {
447
476
  return this.#config?.rowSelect ?? 'none';
@@ -24,8 +24,8 @@
24
24
  */
25
25
  import { registerElement } from '../register.js';
26
26
  import { readSlotConfig, readSlotId } from '../slot-config.js';
27
- import { respond } from '../host.js';
28
- import { findComment, setComment } from '../comments.js';
27
+ import { respond, savedResponse } from '../host.js';
28
+ import { adoptComments, findComment, setComment } from '../comments.js';
29
29
  const WHOLE = { kind: 'whole' };
30
30
  const WHOLE_TARGET = 'the whole passage';
31
31
  const RANGE_TARGET = 'the selected text';
@@ -622,6 +622,7 @@ class CrtrTextElement extends HTMLElement {
622
622
  this.#config = read.config;
623
623
  this.#slotId = readSlotId(this);
624
624
  this.#text = read.config.initialText;
625
+ this.#hydrate();
625
626
  this.#editing = read.config.editable === true && this.#text.length === 0;
626
627
  this.#renderToolbar();
627
628
  this.#renderSurface();
@@ -630,6 +631,22 @@ class CrtrTextElement extends HTMLElement {
630
631
  disconnectedCallback() {
631
632
  document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
632
633
  }
634
+ /**
635
+ * Seed local state from the answer the host is already holding, so a reopened page shows
636
+ * the edited words and the comments on them rather than the agent's original text. An
637
+ * uneditable slot keeps its authored text — only comments carry over — because a saved
638
+ * edit there could not have come from this control.
639
+ */
640
+ #hydrate() {
641
+ if (this.#slotId === undefined)
642
+ return;
643
+ const saved = savedResponse(this.#slotId);
644
+ if (saved === undefined)
645
+ return;
646
+ if (this.#config.editable === true && typeof saved.text === 'string')
647
+ this.#text = saved.text;
648
+ this.#comments = adoptComments(saved.comments);
649
+ }
633
650
  /** The complete current response — what every change hands `respond()`. */
634
651
  #response() {
635
652
  return { text: this.#text, edited: this.#text !== this.#config.initialText, comments: this.#comments };
@@ -16,6 +16,12 @@
16
16
  * the host autosave the partial map; it does not resolve the ticket. Only the pager's
17
17
  * submit affordance calls `submit()`, which posts the complete map once.
18
18
  *
19
+ * `respond()` has one counterpart, `savedResponse()`: the host hands back the response it is
20
+ * currently holding for a slot, so a page reopened after a partial answer re-mounts SHOWING
21
+ * that answer instead of blank. Every response-bearing element reads it at mount and seeds
22
+ * its visible state from it — that is what keeps "what submit sends" and "what the screen
23
+ * shows" the same thing.
24
+ *
19
25
  * The host is optional at the type level (`window.crtr?`) because a page can be opened
20
26
  * bare — dropped in a browser, or rendered by a host that installs nothing. Elements read
21
27
  * the bridge through the accessors below rather than touching `window.crtr` directly, so
@@ -42,6 +48,12 @@ export interface CrtrHost {
42
48
  * partial map. Called on every user change. NOT ticket resolution.
43
49
  */
44
50
  respond(slotId: string, response: SlotResponse): void;
51
+ /**
52
+ * The response the host currently holds for this slot — an autosaved partial answer on a
53
+ * reopened page, or the published answer on a resolved one. `undefined` when the host has
54
+ * nothing for the slot. Elements read it at mount; it is the inbound half of `respond`.
55
+ */
56
+ savedResponse(slotId: string): SlotResponse | undefined;
45
57
  /** Posts the complete response map once. Only the pager's submit affordance calls it. */
46
58
  submit(): Promise<void>;
47
59
  /** Flushes the pending autosave of the partial map. */
@@ -98,6 +110,20 @@ export declare function host(): CrtrHost | undefined;
98
110
  * so an unavailable host only means the answer is not being persisted.
99
111
  */
100
112
  export declare function respond(slotId: string, response: SlotResponse): HostCall;
113
+ /**
114
+ * The response the host already holds for this slot, or `undefined` when it holds none — a
115
+ * bare page, a first open, or a host without the channel. Elements call this once at mount
116
+ * and seed their visible state from it, so a reopened page shows the answers the host would
117
+ * submit rather than an empty form beside a full response map.
118
+ *
119
+ * `T` is asserted, not verified, exactly like `readSlotConfig`'s config: the host's saved map
120
+ * was validated against this slot's kind before it was stored. What is not guaranteed is that
121
+ * a host implements the channel at all, which is what the `undefined` covers — so seed with
122
+ * the same defensiveness you would give any outside value (`savedIds`, `adoptComments`).
123
+ */
124
+ export declare function savedResponse<T extends SlotResponse = SlotResponse>(slotId: string): T | undefined;
125
+ /** The string ids of a saved selection field, with anything else in it dropped. */
126
+ export declare function savedIds(value: unknown): string[];
101
127
  /**
102
128
  * Posts the complete response map once, resolving the ticket. Only the pager's submit
103
129
  * affordance calls this.
@@ -16,6 +16,12 @@
16
16
  * the host autosave the partial map; it does not resolve the ticket. Only the pager's
17
17
  * submit affordance calls `submit()`, which posts the complete map once.
18
18
  *
19
+ * `respond()` has one counterpart, `savedResponse()`: the host hands back the response it is
20
+ * currently holding for a slot, so a page reopened after a partial answer re-mounts SHOWING
21
+ * that answer instead of blank. Every response-bearing element reads it at mount and seeds
22
+ * its visible state from it — that is what keeps "what submit sends" and "what the screen
23
+ * shows" the same thing.
24
+ *
19
25
  * The host is optional at the type level (`window.crtr?`) because a page can be opened
20
26
  * bare — dropped in a browser, or rendered by a host that installs nothing. Elements read
21
27
  * the bridge through the accessors below rather than touching `window.crtr` directly, so
@@ -52,6 +58,33 @@ export function respond(slotId, response) {
52
58
  return { status: 'failed', reason: reasonOf(error) };
53
59
  }
54
60
  }
61
+ /**
62
+ * The response the host already holds for this slot, or `undefined` when it holds none — a
63
+ * bare page, a first open, or a host without the channel. Elements call this once at mount
64
+ * and seed their visible state from it, so a reopened page shows the answers the host would
65
+ * submit rather than an empty form beside a full response map.
66
+ *
67
+ * `T` is asserted, not verified, exactly like `readSlotConfig`'s config: the host's saved map
68
+ * was validated against this slot's kind before it was stored. What is not guaranteed is that
69
+ * a host implements the channel at all, which is what the `undefined` covers — so seed with
70
+ * the same defensiveness you would give any outside value (`savedIds`, `adoptComments`).
71
+ */
72
+ export function savedResponse(slotId) {
73
+ const bridge = host();
74
+ if (typeof bridge?.savedResponse !== 'function')
75
+ return undefined;
76
+ try {
77
+ const saved = bridge.savedResponse(slotId);
78
+ return typeof saved === 'object' && saved !== null && !Array.isArray(saved) ? saved : undefined;
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }
84
+ /** The string ids of a saved selection field, with anything else in it dropped. */
85
+ export function savedIds(value) {
86
+ return Array.isArray(value) ? value.filter((id) => typeof id === 'string') : [];
87
+ }
55
88
  /**
56
89
  * Posts the complete response map once, resolving the ticket. Only the pager's submit
57
90
  * affordance calls this.
@@ -3,6 +3,8 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
3
3
  /** Seconds a leading `sleep ...` will block for, or null when the command does
4
4
  * not open with a sleep (or its duration isn't statically knowable). */
5
5
  export declare function leadingSleepSeconds(command: string): number | null;
6
- /** The valve's BashOperations backend. */
7
- export declare function createValveOperations(nodeId: string, contextDir: string): BashOperations;
6
+ /** The valve's BashOperations backend. Each tool execution closes over its own
7
+ * `takePurpose`, so concurrent calls cannot exchange labels. It is consumed even
8
+ * when the command is invalid or refused, leaving no state to leak later. */
9
+ export declare function createValveOperations(nodeId: string, contextDir: string, takePurpose?: () => string | null): BashOperations;
8
10
  export default function (pi: ExtensionAPI): void;
@@ -27,7 +27,7 @@
27
27
  import { spawn } from 'node:child_process';
28
28
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from 'node:fs';
29
29
  import { homedir } from 'node:os';
30
- import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId } from '../core/bash-jobs.js';
30
+ import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId, normalizeBashJobPurpose } from '../core/bash-jobs.js';
31
31
  import { Type } from 'typebox';
32
32
  import { createBashToolDefinition } from '@earendil-works/pi-coding-agent';
33
33
  // ---------------------------------------------------------------------------
@@ -203,11 +203,14 @@ function resolveExecutionCwd(cwd) {
203
203
  warning: `[working directory no longer exists: ${cwd}; running from ${fallback}]\n`,
204
204
  };
205
205
  }
206
- /** The valve's BashOperations backend. */
207
- export function createValveOperations(nodeId, contextDir) {
206
+ /** The valve's BashOperations backend. Each tool execution closes over its own
207
+ * `takePurpose`, so concurrent calls cannot exchange labels. It is consumed even
208
+ * when the command is invalid or refused, leaving no state to leak later. */
209
+ export function createValveOperations(nodeId, contextDir, takePurpose = () => null) {
208
210
  return {
209
211
  exec: (command, cwd, { onData, signal, timeout, env }) => {
210
212
  return new Promise((resolve, reject) => {
213
+ const purpose = takePurpose();
211
214
  if (signal?.aborted) {
212
215
  reject(new Error('aborted'));
213
216
  return;
@@ -222,6 +225,8 @@ export function createValveOperations(nodeId, contextDir) {
222
225
  writeFileSync(paths.cmdSh, command);
223
226
  writeFileSync(paths.jobLog, '');
224
227
  writeFileSync(paths.jobRun, '');
228
+ if (purpose !== null)
229
+ writeFileSync(paths.jobPurpose, purpose);
225
230
  let offset = 0;
226
231
  let settled = false;
227
232
  let pgid;
@@ -357,8 +362,10 @@ export function createValveOperations(nodeId, contextDir) {
357
362
  // parsing partial JSON can show the label while the command is still being
358
363
  // written, rather than after the call is complete and about to finish.
359
364
  //
360
- // The execute path ignores it entirely — this is a labelling channel, not an
361
- // input. Optional, so a model that omits it behaves exactly as before.
365
+ // The command execution path ignores it as shell input — this remains a
366
+ // labelling channel, not an execution parameter. The valve only persists the
367
+ // label beside a background job. Optional, so a model that omits it behaves
368
+ // exactly as before.
362
369
  //
363
370
  // Upstream ask: pi's own bash tool should carry this (Claude Code's bash tool
364
371
  // has shipped an equivalent `description` field for years). Until it does, the
@@ -386,6 +393,18 @@ function withPurpose(definition) {
386
393
  }),
387
394
  };
388
395
  }
396
+ /** Bind each purpose directly to its own tool execution. Pi may preflight a
397
+ * batch before starting its calls, so a shared "next purpose" slot could give
398
+ * a concurrent call the wrong label. */
399
+ function createPurposeValveToolDefinition(nodeId, contextDir, cwd) {
400
+ const schemaDefinition = withPurpose(createBashToolDefinition(cwd, { operations: createValveOperations(nodeId, contextDir) }));
401
+ const execute = (toolCallId, params, signal, onUpdate, ctx) => {
402
+ const purpose = normalizeBashJobPurpose(params['purpose']);
403
+ const definition = createBashToolDefinition(cwd, { operations: createValveOperations(nodeId, contextDir, () => purpose) });
404
+ return definition.execute(toolCallId, params, signal, onUpdate, ctx);
405
+ };
406
+ return { ...schemaDefinition, execute };
407
+ }
389
408
  export default function (pi) {
390
409
  const nodeId = process.env['CRTR_NODE_ID'];
391
410
  if (nodeId === undefined || nodeId.trim() === '')
@@ -399,7 +418,6 @@ export default function (pi) {
399
418
  // is built against. registerTool replaces the builtin by name; re-firing on
400
419
  // every session_start is idempotent (last registration wins).
401
420
  pi.on('session_start', (_event, ctx) => {
402
- const operations = createValveOperations(nodeId, contextDir);
403
- pi.registerTool(withPurpose(createBashToolDefinition(ctx.cwd, { operations })));
421
+ pi.registerTool(createPurposeValveToolDefinition(nodeId, contextDir, ctx.cwd));
404
422
  });
405
423
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.193",
3
+ "version": "0.3.195",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.193",
3
+ "version": "0.3.195",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.193",
9
+ "version": "0.3.195",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
@@ -2,6 +2,7 @@
2
2
  import { spawn } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises';
5
+ import { hostname } from 'node:os';
5
6
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { ENTRY_NAMES, GENERATION_ID, MANIFEST_KEYS, PACKAGE_NAME, MANIFEST_SCHEMA, contained, isSealed, publishedMarkerPath, runtimeHome, sortInventory, validateManifestShape } from '../bin/runtime-generation-manifest.mjs';
@@ -11,9 +12,20 @@ const PACKAGE_ROOT = process.argv[2] ? resolve(process.argv[2]) : resolve(dirnam
11
12
  const RUNTIME_HOME = runtimeHome();
12
13
  const GENERATIONS = join(RUNTIME_HOME, 'generations');
13
14
  const LOCK = join(RUNTIME_HOME, 'install.lock');
15
+ const LOCK_OWNER_FILE = 'owner.json';
14
16
  const RUNTIME_LOCK = 'runtime.lock.json';
15
17
  const KEEP_RECENT_GENERATIONS = 3;
16
18
  const FS_CONCURRENCY = 64;
19
+ // How long an install.lock with no readable owner record is given the
20
+ // benefit of the doubt (the owner is mid-write, or on an ancient version of
21
+ // this script that never wrote one) before it is treated as abandoned.
22
+ const STALE_LOCK_RECORD_THRESHOLD_MS = 15 * 60 * 1000;
23
+ // How long a `.staging-*`/`.archive-*` temp dir sits before the sweep in
24
+ // step 4 treats it as wreckage from an earlier killed install rather than
25
+ // work in progress under a lock we don't hold (impossible while we hold the
26
+ // lock, but a conservative margin costs nothing).
27
+ const STALE_TEMP_DIR_THRESHOLD_MS = 60 * 60 * 1000;
28
+ const LOCK_ACQUIRE_MAX_ATTEMPTS = 50;
17
29
 
18
30
  function run(command, args, cwd, capture = false, env = process.env) {
19
31
  return new Promise((resolveRun, reject) => {
@@ -235,14 +247,162 @@ async function pruneGenerations() {
235
247
  }
236
248
  }
237
249
 
238
- async function install() {
239
- await mkdir(GENERATIONS, { recursive: true });
250
+ /** A process's start time as `ps` reports it — the cheap, boot-unique-enough
251
+ * fellow-traveler of a pid that lets a reused pid be told apart from the
252
+ * process that actually wrote a lock owner record. Empty string when the
253
+ * pid does not exist (or `ps` cannot be run), never rejects. */
254
+ async function pidStartTime(pid) {
255
+ const output = await probe('ps', ['-o', 'lstart=', '-p', String(pid)], RUNTIME_HOME);
256
+ return output.trim();
257
+ }
258
+
259
+ /** Whether `pid` currently identifies a live process, from this user's
260
+ * vantage point. `EPERM` means the pid exists but is owned by someone
261
+ * else — still alive, just not ours to signal. */
262
+ function pidAlive(pid) {
263
+ try {
264
+ process.kill(pid, 0);
265
+ return true;
266
+ } catch (error) {
267
+ return error?.code === 'EPERM';
268
+ }
269
+ }
270
+
271
+ /** Write the lock owner record via temp-file + rename so a concurrent reader
272
+ * never observes a half-written file. Must be called only once `LOCK` is
273
+ * known to be ours (immediately after `mkdir(LOCK)` succeeds). */
274
+ async function writeLockOwner(lockDir) {
275
+ const owner = { pid: process.pid, startTime: await pidStartTime(process.pid), hostname: hostname(), startedAt: new Date().toISOString() };
276
+ const temporary = join(lockDir, `.${LOCK_OWNER_FILE}.${process.pid}-${Date.now()}.tmp`);
277
+ await writeFile(temporary, JSON.stringify(owner));
278
+ await rename(temporary, join(lockDir, LOCK_OWNER_FILE));
279
+ }
280
+
281
+ async function readLockOwner(lockDir) {
282
+ try {
283
+ const owner = JSON.parse(await readFile(join(lockDir, LOCK_OWNER_FILE), 'utf8'));
284
+ return Number.isInteger(owner?.pid) ? owner : null;
285
+ } catch {
286
+ return null;
287
+ }
288
+ }
289
+
290
+ /** True only when `owner`'s pid is both alive AND (when a start time was
291
+ * recorded) still the same process that wrote the record — a recycled pid
292
+ * must not masquerade as a live owner. */
293
+ async function ownerIsAlive(owner) {
294
+ if (!owner || !pidAlive(owner.pid)) return false;
295
+ if (!owner.startTime) return true;
296
+ const currentStartTime = await pidStartTime(owner.pid);
297
+ return currentStartTime === '' || currentStartTime === owner.startTime;
298
+ }
299
+
300
+ /** Take `LOCK`, reclaiming it first if its owner is provably dead. A dead
301
+ * owner is reclaimed unconditionally; a missing/corrupt owner record is
302
+ * reclaimed only once the lock dir is older than a conservative threshold
303
+ * (it may belong to a process that has not written its record yet). The
304
+ * reclaim itself is race-safe: the stale dir is renamed to a private,
305
+ * unique path first, and only the process whose rename wins takes the
306
+ * fresh lock — a loser simply retries the ordinary contended path rather
307
+ * than deleting a dir a concurrent winner might still be reading. */
308
+ async function acquireLock() {
309
+ for (let attempt = 0; attempt < LOCK_ACQUIRE_MAX_ATTEMPTS; attempt++) {
310
+ try {
311
+ await mkdir(LOCK);
312
+ await writeLockOwner(LOCK);
313
+ return;
314
+ } catch (error) {
315
+ if (error?.code !== 'EEXIST') throw error;
316
+ const owner = await readLockOwner(LOCK);
317
+ if (owner && (await ownerIsAlive(owner))) {
318
+ throw new Error(`runtime installer lock is held by a live process: pid=${owner.pid} started=${owner.startTime || 'unknown'} host=${owner.hostname || 'unknown'}; refusing to install concurrently`);
319
+ }
320
+ if (!owner) {
321
+ let stat;
322
+ try {
323
+ stat = await lstat(LOCK);
324
+ } catch (statError) {
325
+ if (statError?.code === 'ENOENT') continue; // lock vanished between mkdir and lstat; retry
326
+ throw statError;
327
+ }
328
+ const ageMs = Date.now() - stat.mtimeMs;
329
+ if (ageMs < STALE_LOCK_RECORD_THRESHOLD_MS) {
330
+ throw new Error(`runtime installer lock exists without a readable owner record and is only ${Math.round(ageMs / 1000)}s old: ${LOCK}; inspect and remove only after confirming no installation is running`);
331
+ }
332
+ }
333
+ const graveyard = join(RUNTIME_HOME, `.lock-stale-${process.pid}-${Date.now()}`);
334
+ try {
335
+ await rename(LOCK, graveyard);
336
+ } catch (renameError) {
337
+ if (renameError?.code === 'ENOENT') continue; // another process already reclaimed it; fall back to the contended path
338
+ throw renameError;
339
+ }
340
+ process.stderr.write(`runtime installer: reclaimed a stale lock (dead owner pid=${owner?.pid ?? 'unknown'})\n`);
341
+ await removeStage(graveyard);
342
+ // Loop back and take the now-vacant lock.
343
+ }
344
+ }
345
+ throw new Error(`runtime installer lock: gave up after ${LOCK_ACQUIRE_MAX_ATTEMPTS} contended attempts: ${LOCK}`);
346
+ }
347
+
348
+ /** Sweep `.staging-*`/`.archive-*` temp dirs left behind by an install that
349
+ * was killed before its own `finally` (or signal handler) could run. Only
350
+ * called once this process holds `LOCK`, so any dir old enough to clear
351
+ * the threshold cannot belong to a still-running install. */
352
+ async function sweepOrphanTempDirs() {
353
+ let entries;
240
354
  try {
241
- await mkdir(LOCK);
355
+ entries = await readdir(RUNTIME_HOME, { withFileTypes: true });
356
+ } catch {
357
+ return;
358
+ }
359
+ for (const entry of entries) {
360
+ if (!entry.isDirectory() || !/^\.(?:staging|archive)-/.test(entry.name)) continue;
361
+ const absolute = join(RUNTIME_HOME, entry.name);
362
+ let stat;
363
+ try {
364
+ stat = await lstat(absolute);
365
+ } catch {
366
+ continue;
367
+ }
368
+ if (Date.now() - stat.mtimeMs < STALE_TEMP_DIR_THRESHOLD_MS) continue;
369
+ process.stderr.write(`runtime installer: sweeping orphan temp dir from an earlier aborted install: ${absolute}\n`);
370
+ await removeStage(absolute);
371
+ }
372
+ }
373
+
374
+ // Populated as install() proceeds so a SIGINT/SIGTERM handler can undo
375
+ // exactly what the normal `finally` below would have undone. SIGKILL
376
+ // remains uncatchable, which is exactly why acquireLock() can reclaim.
377
+ const cleanupState = { lockTaken: false, stage: undefined, archiveDir: undefined, publishedRoot: undefined, committed: false, temporary: undefined, id: undefined, exiting: false };
378
+
379
+ async function cleanupOnSignal(signal) {
380
+ if (cleanupState.exiting) return;
381
+ cleanupState.exiting = true;
382
+ process.stderr.write(`runtime installer: received ${signal}, cleaning up before exit\n`);
383
+ try {
384
+ if (cleanupState.temporary) await rm(cleanupState.temporary, { force: true });
385
+ if (cleanupState.stage) await removeStage(cleanupState.stage);
386
+ if (cleanupState.publishedRoot && !cleanupState.committed) {
387
+ await removeStage(cleanupState.publishedRoot);
388
+ if (cleanupState.id) await rm(publishedMarkerPath(GENERATIONS, cleanupState.id), { force: true });
389
+ }
390
+ if (cleanupState.archiveDir) await rm(cleanupState.archiveDir, { recursive: true, force: true });
391
+ if (cleanupState.lockTaken) await rm(LOCK, { recursive: true, force: true });
242
392
  } catch (error) {
243
- if (error?.code === 'EEXIST') throw new Error(`runtime installer lock exists: ${LOCK}; inspect and remove only after confirming no installation is running`);
244
- throw error;
393
+ process.stderr.write(`runtime installer: cleanup on ${signal} failed: ${error?.message ?? error}\n`);
245
394
  }
395
+ process.exit(1);
396
+ }
397
+
398
+ process.on('SIGINT', () => { cleanupOnSignal('SIGINT'); });
399
+ process.on('SIGTERM', () => { cleanupOnSignal('SIGTERM'); });
400
+
401
+ async function install() {
402
+ await mkdir(GENERATIONS, { recursive: true });
403
+ await acquireLock();
404
+ cleanupState.lockTaken = true;
405
+ await sweepOrphanTempDirs();
246
406
  let stage;
247
407
  let archiveDir;
248
408
  let publishedRoot;
@@ -251,9 +411,11 @@ async function install() {
251
411
  let id;
252
412
  try {
253
413
  archiveDir = await mkdtemp(join(RUNTIME_HOME, '.archive-'));
414
+ cleanupState.archiveDir = archiveDir;
254
415
  const archive = await pack(archiveDir);
255
416
  const archiveHash = digest(await readFile(archive));
256
417
  stage = await mkdtemp(join(RUNTIME_HOME, '.staging-'));
418
+ cleanupState.stage = stage;
257
419
  await run('tar', ['-xzf', archive, '--strip-components=1', '-C', stage], PACKAGE_ROOT);
258
420
  await copyFile(join(stage, RUNTIME_LOCK), join(stage, 'package-lock.json'));
259
421
  // Humanloop's postinstall only prewarms its external termrender cache; runtime rendering retries lazily with a plaintext fallback.
@@ -265,6 +427,7 @@ async function install() {
265
427
  const pkg = JSON.parse(await readFile(join(stage, 'package.json'), 'utf8'));
266
428
  const files = sortInventory(await inventory(stage));
267
429
  id = digest(`${archiveHash}\n${digest(await readFile(join(stage, 'package-lock.json')))}\n${digest(JSON.stringify(files))}\n${process.version}\n${process.platform}\n${process.arch}\ninstaller-schema-1`);
430
+ cleanupState.id = id;
268
431
  const finalRoot = join(GENERATIONS, id);
269
432
  const manifest = {
270
433
  schema: MANIFEST_SCHEMA,
@@ -294,8 +457,10 @@ async function install() {
294
457
  await chmod(stage, 0o755);
295
458
  await rename(stage, finalRoot);
296
459
  publishedRoot = finalRoot;
460
+ cleanupState.publishedRoot = publishedRoot;
297
461
  await chmod(finalRoot, 0o555);
298
462
  stage = undefined;
463
+ cleanupState.stage = undefined;
299
464
  await validatePublished(finalRoot, id);
300
465
  }
301
466
  // Only after the full-hash check above has succeeded for this exact id —
@@ -303,9 +468,11 @@ async function install() {
303
468
  await markPublished(GENERATIONS, id);
304
469
  const selected = join(RUNTIME_HOME, 'selected');
305
470
  temporary = join(RUNTIME_HOME, `.selected-${process.pid}-${Date.now()}`);
471
+ cleanupState.temporary = temporary;
306
472
  await symlink(`generations/${id}`, temporary);
307
473
  await rename(temporary, selected);
308
474
  committed = true;
475
+ cleanupState.committed = true;
309
476
  try {
310
477
  await pruneGenerations();
311
478
  } catch (error) {