@north-light/crouter 0.3.175 → 0.3.176

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 (63) hide show
  1. package/dist/api/dto/nodes.d.ts +7 -1
  2. package/dist/api/dto/profiles.d.ts +3 -0
  3. package/dist/clients/attach/render/crtr-output.d.ts +3 -3
  4. package/dist/clients/attach/render/crtr-output.js +25 -13
  5. package/dist/clients/attach/viewer.js +531 -561
  6. package/dist/commands/node/create.js +7 -3
  7. package/dist/commands/profile/list.js +3 -1
  8. package/dist/commands/profile/new.js +4 -1
  9. package/dist/commands/profile/project.js +23 -9
  10. package/dist/commands/profile/show.js +3 -1
  11. package/dist/commands/profile.js +2 -2
  12. package/dist/commands/sys/logs.js +4 -2
  13. package/dist/core/__tests__/branded-host-concurrency.test.js +1 -1
  14. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +1 -1
  15. package/dist/core/__tests__/on-read-dedup-resume.test.js +1 -1
  16. package/dist/core/canvas/extensions.d.ts +1 -1
  17. package/dist/core/canvas/extensions.js +7 -1
  18. package/dist/core/command-plugins/transport/http-invoke.js +10 -2
  19. package/dist/core/command.js +3 -1
  20. package/dist/core/config.js +4 -27
  21. package/dist/core/exclusive-lock.d.ts +14 -0
  22. package/dist/core/exclusive-lock.js +124 -0
  23. package/dist/core/human/claim.js +2 -1
  24. package/dist/core/human/convention.d.ts +0 -10
  25. package/dist/core/human/convention.js +1 -123
  26. package/dist/core/io.d.ts +23 -0
  27. package/dist/core/io.js +60 -0
  28. package/dist/core/preview-registry.d.ts +10 -5
  29. package/dist/core/preview-registry.js +136 -2537
  30. package/dist/core/profiles/manifest.d.ts +12 -1
  31. package/dist/core/profiles/manifest.js +57 -48
  32. package/dist/core/review/store.d.ts +4 -1
  33. package/dist/core/review/store.js +10 -0
  34. package/dist/core/runtime/branded-host.js +6 -84
  35. package/dist/core/runtime/broker-extension-render.js +1 -1
  36. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  37. package/dist/core/runtime/canvas-extensions.js +2 -0
  38. package/dist/core/runtime/tmux-bindings.js +1 -1
  39. package/dist/core/runtime/warm-pool.d.ts +9 -8
  40. package/dist/core/runtime/warm-pool.js +58 -62
  41. package/dist/core/substrate/index.d.ts +2 -1
  42. package/dist/core/substrate/index.js +2 -1
  43. package/dist/core/substrate/on-read-node.d.ts +7 -0
  44. package/dist/core/substrate/on-read-node.js +37 -0
  45. package/dist/core/substrate/on-read.d.ts +7 -5
  46. package/dist/core/substrate/on-read.js +15 -23
  47. package/dist/core/termrender/termrender.js +16 -96
  48. package/dist/core/worktree.js +11 -100
  49. package/dist/daemon/api/handlers/nodes.js +32 -9
  50. package/dist/daemon/api/handlers/review-comments.js +5 -44
  51. package/dist/daemon/api/handlers/reviews.js +5 -53
  52. package/dist/daemon/api/handlers/validate.d.ts +22 -0
  53. package/dist/daemon/api/handlers/validate.js +59 -0
  54. package/dist/daemon/api/map.js +1 -0
  55. package/dist/daemon/review/finish.js +1 -10
  56. package/dist/pi-extensions/canvas-doc-substrate.js +1 -1
  57. package/dist/pi-extensions/canvas-preview-result.d.ts +22 -0
  58. package/dist/pi-extensions/canvas-preview-result.js +60 -0
  59. package/dist/types.d.ts +4 -0
  60. package/package.json +1 -1
  61. package/runtime.lock.json +2 -2
  62. package/dist/core/substrate/on-read-pure.d.ts +0 -7
  63. package/dist/core/substrate/on-read-pure.js +0 -272
@@ -4,6 +4,15 @@ export declare function profilesRoot(): string;
4
4
  export declare function profileRoot(profileId: string): string;
5
5
  export declare function profileManifestPath(profileId: string): string;
6
6
  export declare function profileMemoryDir(profileId: string): string;
7
+ /** The directory a node running under `profileId` is pinned to — the profile's
8
+ * home, but only while it still exists on disk (a node pinned to a deleted dir
9
+ * cannot boot). Null for the root profile, a profile with no projects, a home
10
+ * that has since been deleted, and an unresolvable id.
11
+ *
12
+ * This is the whole of the profile→cwd direction: cwd SELECTS a profile at the
13
+ * front door, and the selected profile then SUPPLIES the node's cwd. Never
14
+ * throws — it sits on the node-create path. */
15
+ export declare function profileHome(profileId: string | null): string | null;
7
16
  export interface ProfileEntry {
8
17
  profileId: string;
9
18
  manifest: ProfileManifest;
@@ -30,6 +39,8 @@ export declare function ensureRootProfile(): ProfileEntry;
30
39
  export declare function createProfile(name: string, projects?: string[]): ProfileEntry;
31
40
  export declare function updateProfileLastUsed(profileId: string): ProfileEntry;
32
41
  export declare function renameProfile(profileId: string, name: string): ProfileEntry;
33
- export declare function addProfileProject(profileId: string, dir: string): ProfileEntry;
42
+ export declare function addProfileProject(profileId: string, dir: string, opts?: {
43
+ home?: boolean;
44
+ }): ProfileEntry;
34
45
  export declare function removeProfileProject(profileId: string, dir: string): ProfileEntry;
35
46
  export declare function deleteProfile(profileId: string): void;
@@ -6,12 +6,13 @@
6
6
  // here, so no caller ever passes a raw CLI operand or `CRTR_PROFILE_ID`
7
7
  // straight into `join(profilesRoot(), value)`.
8
8
  import { randomUUID } from 'node:crypto';
9
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
9
+ import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
10
10
  import { dirname, join, relative, resolve as resolvePath, sep } from 'node:path';
11
11
  import { SCHEMA_VERSION, PROFILE_DIR } from '../../types.js';
12
12
  import { userScopeRoot, resetScopeCache } from '../scope.js';
13
13
  import { ensureDir, nowIso } from '../fs-utils.js';
14
14
  import { usage, notFound, ambiguous, general } from '../errors.js';
15
+ import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
15
16
  // ---------------------------------------------------------------------------
16
17
  // Path safety — the only code in the tree that builds a path under the
17
18
  // profiles root. Every other module reaches profile paths through this file.
@@ -73,11 +74,47 @@ export function profileMemoryDir(profileId) {
73
74
  // a profile id. Both walk immediate children of the canonical root; neither
74
75
  // ever joins an unvalidated operand into a path.
75
76
  // ---------------------------------------------------------------------------
76
- function readManifestFile(path) {
77
+ /** A profile's `home` DEFAULTS to its first project dir. Normalizing here (the
78
+ * single read path) rather than at write time means a manifest written before
79
+ * homes existed, and one whose explicit home was later removed from purview,
80
+ * both still answer with a usable home — no migration pass.
81
+ *
82
+ * The root profile is the exception and never takes the default: it is the
83
+ * identity a directory nothing covers falls back to, so its nodes belong where
84
+ * the user started them, not in whatever dir was last added to its purview. */
85
+ function normalizeHome(profileId, manifest) {
86
+ const stored = typeof manifest.home === 'string' && manifest.home !== '' ? manifest.home : null;
87
+ if (profileId === ROOT_PROFILE_ID)
88
+ return { ...manifest, home: stored };
89
+ return { ...manifest, home: stored ?? manifest.projects[0] ?? null };
90
+ }
91
+ function readManifestFile(path, profileId) {
77
92
  if (!existsSync(path))
78
93
  return null;
79
94
  try {
80
- return JSON.parse(readFileSync(path, 'utf8'));
95
+ return normalizeHome(profileId, JSON.parse(readFileSync(path, 'utf8')));
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ /** The directory a node running under `profileId` is pinned to — the profile's
102
+ * home, but only while it still exists on disk (a node pinned to a deleted dir
103
+ * cannot boot). Null for the root profile, a profile with no projects, a home
104
+ * that has since been deleted, and an unresolvable id.
105
+ *
106
+ * This is the whole of the profile→cwd direction: cwd SELECTS a profile at the
107
+ * front door, and the selected profile then SUPPLIES the node's cwd. Never
108
+ * throws — it sits on the node-create path. */
109
+ export function profileHome(profileId) {
110
+ if (profileId === null || profileId === '')
111
+ return null;
112
+ try {
113
+ const { manifest } = loadProfileManifest(profileId);
114
+ const home = manifest.home;
115
+ if (home === null)
116
+ return null;
117
+ return existsSync(home) && statSync(home).isDirectory() ? home : null;
81
118
  }
82
119
  catch {
83
120
  return null;
@@ -99,7 +136,7 @@ export function listProfiles() {
99
136
  const profileId = dirent.name;
100
137
  if (!ID_SHAPE.test(profileId))
101
138
  continue;
102
- const manifest = readManifestFile(join(root, profileId, 'profile.json'));
139
+ const manifest = readManifestFile(join(root, profileId, 'profile.json'), profileId);
103
140
  if (manifest === null)
104
141
  continue;
105
142
  out.push({ profileId, manifest });
@@ -142,55 +179,20 @@ export function loadProfileManifest(profileIdOrName) {
142
179
  : 'No profiles exist on this host yet — create one with `crtr profile new --name <name>`.',
143
180
  });
144
181
  }
145
- // ---------------------------------------------------------------------------
146
- // Locking — a plain mkdir-based mutex under `profilesRoot()/.locks/`. mkdir is
147
- // atomic (EEXIST when already held), so this needs no extra dependency. The
148
- // blocking wait uses Atomics.wait on a throwaway SharedArrayBuffer to stay
149
- // synchronous, matching every other sync fs call in this module.
150
- // ---------------------------------------------------------------------------
151
- const LOCK_TIMEOUT_MS = 5000;
152
- const LOCK_POLL_MS = 25;
153
182
  function locksDir() {
154
183
  return join(profilesRoot(), '.locks');
155
184
  }
156
- function sleepSync(ms) {
157
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
158
- }
159
185
  /** Hold the per-profile manifest lock for the duration of `fn`. ALL manifest
160
186
  * mutations (create/rename/add-project/remove-project/delete/last-used)
161
187
  * below run inside this. */
162
188
  export function withProfileManifestLock(profileId, fn) {
163
189
  assertSafeProfileId(profileId);
164
190
  ensureDir(locksDir());
165
- const lockPath = join(locksDir(), `${profileId}.lock`);
166
- const deadline = Date.now() + LOCK_TIMEOUT_MS;
167
- for (;;) {
168
- try {
169
- mkdirSync(lockPath);
170
- break;
171
- }
172
- catch (err) {
173
- if (err.code !== 'EEXIST')
174
- throw err;
175
- if (Date.now() > deadline) {
176
- throw general(`timed out waiting for the profile lock: ${profileId}`, {
177
- next: 'Retry — another `crtr profile` command may be running concurrently on this profile.',
178
- });
179
- }
180
- sleepSync(LOCK_POLL_MS);
181
- }
182
- }
183
- try {
184
- return fn();
185
- }
186
- finally {
187
- try {
188
- rmSync(lockPath, { recursive: true, force: true });
189
- }
190
- catch {
191
- /* best-effort release */
192
- }
193
- }
191
+ return withExclusiveDirectoryLock(join(locksDir(), `${profileId}.lock`), fn, {
192
+ timeoutError: () => general(`timed out waiting for the profile lock: ${profileId}`, {
193
+ next: 'Retry — another `crtr profile` command may be running concurrently on this profile.',
194
+ }),
195
+ });
194
196
  }
195
197
  function writeManifestAtomic(path, manifest) {
196
198
  ensureDir(dirname(path));
@@ -254,7 +256,7 @@ export function ensureRootProfile() {
254
256
  return withProfileManifestLock(ROOT_PROFILE_ID, () => {
255
257
  const manifestPath = profileManifestPath(ROOT_PROFILE_ID);
256
258
  const manifestExists = existsSync(manifestPath);
257
- const manifest = manifestExists ? readManifestFile(manifestPath) : null;
259
+ const manifest = manifestExists ? readManifestFile(manifestPath, ROOT_PROFILE_ID) : null;
258
260
  if (manifestExists && manifest === null) {
259
261
  throw general(`root profile manifest is unreadable: ${manifestPath}`);
260
262
  }
@@ -267,6 +269,7 @@ export function ensureRootProfile() {
267
269
  schema_version: SCHEMA_VERSION,
268
270
  name: ROOT_PROFILE_NAME,
269
271
  projects: [],
272
+ home: null,
270
273
  created_at: nowIso(),
271
274
  last_used_at: nowIso(),
272
275
  };
@@ -286,6 +289,7 @@ export function createProfile(name, projects = []) {
286
289
  schema_version: SCHEMA_VERSION,
287
290
  name: trimmed,
288
291
  projects: resolvedProjects,
292
+ home: resolvedProjects[0] ?? null,
289
293
  created_at: nowIso(),
290
294
  last_used_at: null,
291
295
  };
@@ -307,7 +311,7 @@ function mutateManifest(profileId, mutate) {
307
311
  requireExistingProfile(profileId);
308
312
  return withProfileManifestLock(profileId, () => {
309
313
  const path = profileManifestPath(profileId);
310
- const current = readManifestFile(path);
314
+ const current = readManifestFile(path, profileId);
311
315
  if (current === null) {
312
316
  throw notFound(`profile not found: ${profileId}`, { received: profileId });
313
317
  }
@@ -327,11 +331,12 @@ export function renameProfile(profileId, name) {
327
331
  resetScopeCache();
328
332
  return result;
329
333
  }
330
- export function addProfileProject(profileId, dir) {
334
+ export function addProfileProject(profileId, dir, opts = {}) {
331
335
  const resolved = resolveExistingProjectDir(dir);
332
336
  const result = mutateManifest(profileId, (m) => ({
333
337
  ...m,
334
338
  projects: dedupeOrdered([...m.projects, resolved]),
339
+ ...(opts.home === true ? { home: resolved } : {}),
335
340
  }));
336
341
  resetScopeCache();
337
342
  return result;
@@ -351,13 +356,17 @@ export function removeProfileProject(profileId, dir) {
351
356
  }
352
357
  const result = mutateManifest(profileId, (m) => {
353
358
  const filtered = m.projects.filter((p) => p !== real && p !== abs);
359
+ // Dropping the home dir from purview falls the home back to the first
360
+ // remaining project, exactly as `normalizeHome` would for a manifest that
361
+ // never named one — never leaves the profile pointing outside its purview.
362
+ const home = m.home === real || m.home === abs ? filtered[0] ?? null : m.home;
354
363
  if (filtered.length === m.projects.length) {
355
364
  throw notFound(`project not found on profile: ${dir}`, {
356
365
  received: dir,
357
366
  next: `Current projects: ${m.projects.length > 0 ? m.projects.join(', ') : '(none)'}.`,
358
367
  });
359
368
  }
360
- return { ...m, projects: filtered };
369
+ return { ...m, projects: filtered, home };
361
370
  });
362
371
  resetScopeCache();
363
372
  return result;
@@ -1,5 +1,5 @@
1
1
  import type { DatabaseSync } from 'node:sqlite';
2
- import type { NewReviewRow, ReviewRecord, ReviewState } from './types.js';
2
+ import { type NewReviewRow, type ReviewRecord, type ReviewState } from './types.js';
3
3
  /** Fields written when an open review is conditionally approved. */
4
4
  export interface ApproveReviewArgs {
5
5
  review_id: string;
@@ -17,6 +17,9 @@ export declare function commitBindingReview(row: NewReviewRow): {
17
17
  won: boolean;
18
18
  };
19
19
  export declare function getReview(reviewId: string): ReviewRecord | null;
20
+ /** The review `reviewId` names, for a caller that cannot proceed without one.
21
+ * A `binding` row is still mid-creation and reads as absent. */
22
+ export declare function requireVisibleReview(reviewId: string): ReviewRecord;
20
23
  export declare function getReviewByIdempotencyKey(key: string): ReviewRecord | null;
21
24
  export declare function getReviewByCompanion(nodeId: string): ReviewRecord | null;
22
25
  export declare function getReviewByBridge(nodeId: string): ReviewRecord | null;
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { newNodeId } from '../runtime/nodes.js';
3
3
  import { openDb, withCanvasWrite } from '../canvas/db.js';
4
+ import { ReviewOperationError, } from './types.js';
4
5
  function recordFromRow(raw) {
5
6
  return {
6
7
  review_id: raw['review_id'],
@@ -79,6 +80,15 @@ export function commitBindingReview(row) {
79
80
  export function getReview(reviewId) {
80
81
  return readRecord(openDb(), 'review_id = ?', reviewId);
81
82
  }
83
+ /** The review `reviewId` names, for a caller that cannot proceed without one.
84
+ * A `binding` row is still mid-creation and reads as absent. */
85
+ export function requireVisibleReview(reviewId) {
86
+ const review = getReview(reviewId);
87
+ if (review === null || review.state === 'binding') {
88
+ throw new ReviewOperationError('unknown_review', 'review was not found', { received: reviewId });
89
+ }
90
+ return review;
91
+ }
82
92
  export function getReviewByIdempotencyKey(key) {
83
93
  return readRecord(openDb(), 'idempotency_key = ?', key);
84
94
  }
@@ -21,12 +21,13 @@
21
21
  // (a `brew upgrade node` moves realpath(execPath) to a new Cellar version) or the
22
22
  // copy is missing. The drift check is a cheap marker compare — no subprocess, no
23
23
  // copy — on the happy path.
24
- import { copyFileSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, readdirSync, renameSync, realpathSync, unlinkSync, rmSync, statSync, linkSync, } from 'node:fs';
24
+ import { copyFileSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, readdirSync, renameSync, realpathSync, unlinkSync, rmSync, linkSync, } from 'node:fs';
25
25
  import { homedir } from 'node:os';
26
26
  import { dirname, join } from 'node:path';
27
27
  import { spawnSync } from 'node:child_process';
28
28
  import { CRTR_DIR_NAME } from '../../types.js';
29
29
  import { emitEvent } from '../events/emit.js';
30
+ import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
30
31
  /** The filename the FDA / TCC entry is shown under. */
31
32
  const BRANDED_BIN_NAME = 'crouter';
32
33
  /** Machine-global host dir. Deliberately NOT under `CRTR_HOME` (tests override
@@ -75,95 +76,16 @@ function readMarker(path = markerPath()) {
75
76
  }
76
77
  }
77
78
  const HOST_LOCK_TIMEOUT_MS = 30_000;
78
- const HOST_LOCK_POLL_MS = 25;
79
- const UNOWNED_LOCK_GRACE_MS = 1_000;
80
- function sleepSync(ms) {
81
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
82
- }
83
- function processIsAlive(pid) {
84
- try {
85
- process.kill(pid, 0);
86
- return true;
87
- }
88
- catch (error) {
89
- return error.code === 'EPERM';
90
- }
91
- }
92
- function reclaimDeadHostLock(lockPath) {
93
- let reclaimable = false;
94
- try {
95
- const owner = JSON.parse(readFileSync(join(lockPath, 'owner.json'), 'utf8'));
96
- reclaimable = typeof owner.pid === 'number' && !processIsAlive(owner.pid);
97
- }
98
- catch {
99
- try {
100
- reclaimable = Date.now() - statSync(lockPath).mtimeMs > UNOWNED_LOCK_GRACE_MS;
101
- }
102
- catch {
103
- return true;
104
- }
105
- }
106
- if (!reclaimable)
107
- return false;
108
- const stalePath = `${lockPath}.stale.${process.pid}.${Math.random().toString(36).slice(2)}`;
109
- try {
110
- renameSync(lockPath, stalePath);
111
- }
112
- catch (error) {
113
- if (error.code === 'ENOENT')
114
- return true;
115
- return false;
116
- }
117
- rmSync(stalePath, { recursive: true, force: true });
118
- return true;
119
- }
120
79
  /** Serialize reconciliation of the one machine-global host tuple. This lock is
121
80
  * deliberately separate from the immutable runtime-generation install lock. */
122
81
  function withBrandedHostLock(run) {
123
82
  const root = join(homedir(), CRTR_DIR_NAME);
124
83
  const lockPath = join(root, 'host.lock');
125
- const token = `${process.pid}.${Math.random().toString(36).slice(2)}`;
126
- const candidatePath = `${lockPath}.candidate.${token}`;
127
84
  mkdirSync(root, { recursive: true });
128
- mkdirSync(candidatePath);
129
- writeFileSync(join(candidatePath, 'owner.json'), JSON.stringify({ pid: process.pid, token }));
130
- const deadline = Date.now() + HOST_LOCK_TIMEOUT_MS;
131
- for (;;) {
132
- try {
133
- // The owner record and lock become visible together, so another process
134
- // can never mistake an in-progress live acquisition for an abandoned lock.
135
- renameSync(candidatePath, lockPath);
136
- break;
137
- }
138
- catch (error) {
139
- const code = error.code;
140
- if (code !== 'EEXIST' && code !== 'ENOTEMPTY') {
141
- rmSync(candidatePath, { recursive: true, force: true });
142
- throw error;
143
- }
144
- if (reclaimDeadHostLock(lockPath))
145
- continue;
146
- if (Date.now() > deadline) {
147
- rmSync(candidatePath, { recursive: true, force: true });
148
- throw new Error(`timed out waiting for branded host lock: ${lockPath}`);
149
- }
150
- sleepSync(HOST_LOCK_POLL_MS);
151
- }
152
- }
153
- try {
154
- return run();
155
- }
156
- finally {
157
- try {
158
- const owner = JSON.parse(readFileSync(join(lockPath, 'owner.json'), 'utf8'));
159
- if (owner.token === token)
160
- rmSync(lockPath, { recursive: true, force: true });
161
- }
162
- catch {
163
- /* A reclaimed/replaced lock is not ours to remove. */
164
- }
165
- rmSync(candidatePath, { recursive: true, force: true });
166
- }
85
+ return withExclusiveDirectoryLock(lockPath, run, {
86
+ timeoutMs: HOST_LOCK_TIMEOUT_MS,
87
+ timeoutError: () => new Error(`timed out waiting for branded host lock: ${lockPath}`),
88
+ });
167
89
  }
168
90
  /** macOS keys FDA grants on the Mach-O cdhash. A real developer identity
169
91
  * keeps that hash stable across branded-host rebuilds; ad-hoc signing is the
@@ -7,7 +7,7 @@ import { hostname } from 'node:os';
7
7
  import { interpolateNodePaths } from '../canvas/paths.js';
8
8
  import { fullName } from '../canvas/labels.js';
9
9
  import { renderKnowledgeForSubject, renderPreferencesForSubject } from '../substrate/render.js';
10
- import { renderWorkspaceOpenDocsForSubject } from '../substrate/on-read-pure.js';
10
+ import { renderWorkspaceOpenDocsForSubject } from '../substrate/on-read.js';
11
11
  import { loadProfileManifest } from '../profiles/manifest.js';
12
12
  export function editorLabelForBrokerNode(node) {
13
13
  const icon = (node.icon ?? '').trim();
@@ -9,6 +9,7 @@ export declare const CANVAS_TOOL_GUIDE_PATH: string;
9
9
  export declare const CANVAS_DOC_SUBSTRATE_PATH: string;
10
10
  export declare const CANVAS_STRUCTURED_OUTPUT_PATH: string;
11
11
  export declare const CANVAS_BASH_VALVE_PATH: string;
12
+ export declare const CANVAS_PREVIEW_RESULT_PATH: string;
12
13
  /** The canvas extensions every node loads, in order. The inbox watcher commits
13
14
  * its cursor at agent_settled before the stophook checks it for idle release.
14
15
  * The review boundary must run before the stophook commits companion session
@@ -21,6 +21,7 @@ export const CANVAS_TOOL_GUIDE_PATH = resolveExtension('canvas-tool-guide');
21
21
  export const CANVAS_DOC_SUBSTRATE_PATH = resolveExtension('canvas-doc-substrate');
22
22
  export const CANVAS_STRUCTURED_OUTPUT_PATH = resolveExtension('canvas-structured-output');
23
23
  export const CANVAS_BASH_VALVE_PATH = resolveExtension('canvas-bash-valve');
24
+ export const CANVAS_PREVIEW_RESULT_PATH = resolveExtension('canvas-preview-result');
24
25
  /** The canvas extensions every node loads, in order. The inbox watcher commits
25
26
  * its cursor at agent_settled before the stophook checks it for idle release.
26
27
  * The review boundary must run before the stophook commits companion session
@@ -37,6 +38,7 @@ export const CANVAS_EXTENSIONS = [
37
38
  CANVAS_DOC_SUBSTRATE_PATH,
38
39
  CANVAS_STRUCTURED_OUTPUT_PATH,
39
40
  CANVAS_BASH_VALVE_PATH,
41
+ CANVAS_PREVIEW_RESULT_PATH,
40
42
  ];
41
43
  /** Preserve the invariant: mandatory extensions are always present exactly once.
42
44
  *
@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os';
7
7
  import { join } from 'node:path';
8
8
  import { ATTACH_CONTROL_BINDINGS, BINDING_CATALOG, BINDING_IDS, encodeAttachControlInput, formatGesture, isAttachPaneBinding, resolveUserKeybindingSettings, } from '../keybindings/index.js';
9
9
  import { normalizeTmuxPassthrough } from '../config.js';
10
- import { withExclusiveDirectoryLock } from '../human/convention.js';
10
+ import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
11
11
  import { surfaceTmuxStyleArgs } from './surface-bg.js';
12
12
  import { runTmux as tmux, shellQuote } from './tmux-driver.js';
13
13
  // ---------------------------------------------------------------------------
@@ -16,10 +16,11 @@ export declare function warmPoolEnabled(): boolean;
16
16
  export interface WarmRequest {
17
17
  kind: string;
18
18
  mode: Mode;
19
+ /** Where the node runs — already resolved by the create handler (pin >
20
+ * spawner > profile home), never a raw caller directory. */
19
21
  cwd: string;
20
- /** The RAW `--profile` operand: a string to pin, `null` for explicitly no
21
- * profile, `undefined` to let the daemon resolve one for the cwd. */
22
- profile: string | null | undefined;
22
+ /** The RESOLVED profile id, or null for explicitly no profile. */
23
+ profileId: string | null;
23
24
  model: string | null;
24
25
  situationalContext: string | null;
25
26
  name: string | null;
@@ -43,9 +44,9 @@ export declare function reapStaleSpares(): string[];
43
44
  * throws and never blocks a caller: a failed mint just means the next create
44
45
  * for this key is cold again. */
45
46
  export declare function refillWarmPool(request: WarmRequest): void;
46
- /** Mint a spare for the most recently used project recipes so the FIRST create
47
- * after crtrd boots is warm too, not only the second. The recipe is sourced
48
- * from real recent roots — (cwd, profile) is the whole of it, since everything
49
- * else a claim reshapes live. Fire-and-forget: returns as soon as the mints
50
- * are scheduled, never blocking the daemon's work lane. */
47
+ /** Mint a spare for the most recently used profiles so the FIRST create after
48
+ * crtrd boots is warm too, not only the second. The profile is the whole
49
+ * recipe: it supplies the cwd, and everything else a claim reshapes live.
50
+ * Fire-and-forget — returns as soon as the mints are scheduled, never blocking
51
+ * the daemon's work lane. */
51
52
  export declare function prewarmRecentRecipes(): void;
@@ -44,8 +44,13 @@
44
44
  //
45
45
  // Pool depth is 1 per key. It is populated lazily (the first `prefer_warm`
46
46
  // create for a key falls back to a cold spawn and mints the spare behind it)
47
- // and at daemon start for the most recent live roots, so the FIRST node after
48
- // sitting down is warm too.
47
+ // and at daemon start for the most recently used profiles, so the FIRST node
48
+ // after sitting down is warm too.
49
+ //
50
+ // The pool no longer fans out per directory: a node runs in its profile's home
51
+ // (`resolveNodeCwd` in the create handler), so the cwd a spare freezes is a
52
+ // function of its profile, and "which spare serves this create" is in practice
53
+ // a question about the profile alone.
49
54
  //
50
55
  // Nothing ever RESUMES a spare: the daemon's recovery sweep reads `listNodes`,
51
56
  // which hides the pool, so a spare whose broker dies (every daemon handover
@@ -57,7 +62,8 @@ import { createHash } from 'node:crypto';
57
62
  import { claimWarmSpare, deleteNode, getNode, listNodes, listWarmSpares, registerWarmSpare, unregisterWarmSpare, updateNode, warmSpareCount, } from '../canvas/index.js';
58
63
  import { recordedPidLiveness } from '../canvas/pid.js';
59
64
  import { nowIso } from '../fs-utils.js';
60
- import { resolveProfileId, spawnChildPrepared } from './spawn.js';
65
+ import { spawnChildPrepared } from './spawn.js';
66
+ import { profileHome } from '../profiles/manifest.js';
61
67
  import { buildLaunchSpec } from './launch.js';
62
68
  import { setModelLive } from './model-swap.js';
63
69
  import { setSituationalContextLive } from './situational-live.js';
@@ -94,31 +100,23 @@ function warmRecipeKey(cwd, profileId, launch) {
94
100
  ]);
95
101
  return createHash('sha256').update(canonical).digest('hex').slice(0, 32);
96
102
  }
97
- /** Resolve a request's frozen half. `resolveProfileId(explicit, null, cwd)` is
98
- * the SAME call the cold create path makes for an API-created root (no spawner
99
- * — a warm-eligible create is parentless by construction), so keying the
100
- * resolved id costs nothing extra and keys what actually matters. */
101
- async function resolveRecipe(request) {
102
- const profileId = await resolveProfileId(request.profile, null, request.cwd);
103
+ /** Resolve a request's frozen half. Both cwd and profile arrive already
104
+ * resolved from the create handler, so this is pure launch-spec construction. */
105
+ function resolveRecipe(request) {
103
106
  const { launch } = buildLaunchSpec(request.kind, request.mode, {
104
107
  lifecycle: 'resident',
105
108
  hasManager: false,
106
109
  ...(request.model !== null ? { model: request.model } : {}),
107
110
  cwd: request.cwd,
108
- profileId,
111
+ profileId: request.profileId,
109
112
  });
110
- return {
111
- key: warmRecipeKey(request.cwd, profileId, launch),
112
- launch,
113
- profileId,
114
- request,
115
- };
113
+ return { key: warmRecipeKey(request.cwd, request.profileId, launch), launch, request };
116
114
  }
117
115
  /** Claim a spare for `request`, reshaped onto its persona — or null when the
118
116
  * pool cannot serve it. The caller falls back to a cold spawn; a claim never
119
117
  * fails the create. */
120
118
  export async function claimWarmNode(request) {
121
- const recipe = await resolveRecipe(request);
119
+ const recipe = resolveRecipe(request);
122
120
  const created = nowIso();
123
121
  // The row's stamp moves inside the claim transaction; meta.json (the identity
124
122
  // source of truth `rebuildIndex` re-derives the row from) follows with the
@@ -261,18 +259,15 @@ const minting = new Set();
261
259
  export function refillWarmPool(request) {
262
260
  if (!warmPoolEnabled())
263
261
  return;
264
- void (async () => {
265
- try {
266
- // Mint at the kind's DEFAULT model and with NO situational context: both
267
- // are claim-time properties, so baking either in would only narrow who
268
- // the spare can serve.
269
- const recipe = await resolveRecipe({ ...request, model: null, situationalContext: null });
270
- mint(recipe);
271
- }
272
- catch (err) {
273
- console.error(`[warm-pool] recipe resolution failed: ${err.message}`);
274
- }
275
- })();
262
+ try {
263
+ // Mint at the kind's DEFAULT model and with NO situational context: both
264
+ // are claim-time properties, so baking either in would only narrow who the
265
+ // spare can serve.
266
+ mint(resolveRecipe({ ...request, model: null, situationalContext: null }));
267
+ }
268
+ catch (err) {
269
+ console.error(`[warm-pool] recipe resolution failed: ${err.message}`);
270
+ }
276
271
  }
277
272
  /** Mint one spare for an already-resolved recipe, if the pool is short. */
278
273
  function mint(recipe) {
@@ -298,7 +293,7 @@ function mint(recipe) {
298
293
  // Mint at the kind's DEFAULT model: `model_override` then stays unset on
299
294
  // the spare, so a claim only ever SETS it (updateNode cannot clear a
300
295
  // field) and a no-model claim gets a clean row.
301
- ...(request.profile !== undefined ? { profile: request.profile } : {}),
296
+ profile: request.profileId,
302
297
  // No situational context either: the claimant's is delivered live at
303
298
  // claim time, so a spare that baked one in would serve nobody else.
304
299
  // Mark it unclaimed the instant its row exists and BEFORE the broker
@@ -317,14 +312,14 @@ function mint(recipe) {
317
312
  // ---------------------------------------------------------------------------
318
313
  // Daemon-start pre-warm
319
314
  // ---------------------------------------------------------------------------
320
- /** How many distinct (cwd, profile) recipes get a spare at daemon start. Each
321
- * spare is a resident broker at ~150-300 MB, so this is deliberately tiny. */
322
- const PREWARM_RECIPES = 2;
323
- /** Mint a spare for the most recently used project recipes so the FIRST create
324
- * after crtrd boots is warm too, not only the second. The recipe is sourced
325
- * from real recent roots — (cwd, profile) is the whole of it, since everything
326
- * else a claim reshapes live. Fire-and-forget: returns as soon as the mints
327
- * are scheduled, never blocking the daemon's work lane. */
315
+ /** How many profiles get a spare at daemon start. Each spare is a resident
316
+ * broker at ~150-300 MB, so this is deliberately tiny. */
317
+ const PREWARM_PROFILES = 2;
318
+ /** Mint a spare for the most recently used profiles so the FIRST create after
319
+ * crtrd boots is warm too, not only the second. The profile is the whole
320
+ * recipe: it supplies the cwd, and everything else a claim reshapes live.
321
+ * Fire-and-forget — returns as soon as the mints are scheduled, never blocking
322
+ * the daemon's work lane. */
328
323
  export function prewarmRecentRecipes() {
329
324
  if (!warmPoolEnabled())
330
325
  return;
@@ -336,12 +331,15 @@ export function prewarmRecentRecipes() {
336
331
  for (const row of listNodes({ status: ['active', 'idle'] }).reverse()) {
337
332
  if (row.parent !== null || row.kind === 'human')
338
333
  continue;
339
- const dedupe = `${row.cwd}\u0000${row.profile_id ?? ''}`;
340
- if (seen.has(dedupe))
334
+ const profileId = row.profile_id ?? null;
335
+ if (seen.has(profileId ?? ''))
341
336
  continue;
342
- seen.add(dedupe);
343
- candidates.push({ cwd: row.cwd, profileId: row.profile_id ?? null });
344
- if (candidates.length >= PREWARM_RECIPES)
337
+ seen.add(profileId ?? '');
338
+ // The profile's home is where its next node will land. A profile without
339
+ // one (root, or a home since deleted) still gets a spare, keyed on the
340
+ // directory its last root actually ran in.
341
+ candidates.push({ cwd: profileHome(profileId) ?? row.cwd, profileId });
342
+ if (candidates.length >= PREWARM_PROFILES)
345
343
  break;
346
344
  }
347
345
  }
@@ -350,24 +348,22 @@ export function prewarmRecentRecipes() {
350
348
  return;
351
349
  }
352
350
  for (const candidate of candidates) {
353
- void (async () => {
354
- try {
355
- // `general`/`base` is only the shape the spare BOOTS as — the key it is
356
- // filed under excludes everything a claim can reshape, so any kind can
357
- // claim it.
358
- mint(await resolveRecipe({
359
- kind: 'general',
360
- mode: 'base',
361
- cwd: candidate.cwd,
362
- profile: candidate.profileId,
363
- model: null,
364
- situationalContext: null,
365
- name: null,
366
- }));
367
- }
368
- catch (err) {
369
- console.error(`[warm-pool] prewarm failed for ${candidate.cwd}: ${err.message}`);
370
- }
371
- })();
351
+ try {
352
+ // `general`/`base` is only the shape the spare BOOTS as — the key it is
353
+ // filed under excludes everything a claim can reshape, so any kind can
354
+ // claim it.
355
+ mint(resolveRecipe({
356
+ kind: 'general',
357
+ mode: 'base',
358
+ cwd: candidate.cwd,
359
+ profileId: candidate.profileId,
360
+ model: null,
361
+ situationalContext: null,
362
+ name: null,
363
+ }));
364
+ }
365
+ catch (err) {
366
+ console.error(`[warm-pool] prewarm failed for ${candidate.cwd}: ${err.message}`);
367
+ }
372
368
  }
373
369
  }