@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
@@ -7,4 +7,5 @@ export { gatePasses } from './gate.js';
7
7
  export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveSystemPromptRung, } from './ceiling.js';
8
8
  export { renderPreferencesForSubject, renderKnowledgeForSubject } from './render.js';
9
9
  export { renderPreferencesSection, renderKnowledgeBlock } from './render-node.js';
10
- export { renderOnReadDocs, renderWorkspaceOpenDocs } from './on-read.js';
10
+ export { renderOnReadDocsForSubject, renderWorkspaceOpenDocsForSubject } from './on-read.js';
11
+ export { renderOnReadDocs, renderWorkspaceOpenDocs } from './on-read-node.js';
@@ -22,4 +22,5 @@ export { gatePasses } from './gate.js';
22
22
  export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveSystemPromptRung, } from './ceiling.js';
23
23
  export { renderPreferencesForSubject, renderKnowledgeForSubject } from './render.js';
24
24
  export { renderPreferencesSection, renderKnowledgeBlock } from './render-node.js';
25
- export { renderOnReadDocs, renderWorkspaceOpenDocs } from './on-read.js';
25
+ export { renderOnReadDocsForSubject, renderWorkspaceOpenDocsForSubject } from './on-read.js';
26
+ export { renderOnReadDocs, renderWorkspaceOpenDocs } from './on-read-node.js';
@@ -0,0 +1,7 @@
1
+ /** Surface docs matched by a successful read tool call. An unknown node id or
2
+ * an unreadable canvas renders nothing. */
3
+ export declare function renderOnReadDocs(nodeId: string, readFilePath: string, seen?: Set<string>): string;
4
+ /** Surface workspace-wide docs during first-message assembly, using the node's
5
+ * recorded cwd and selected profile. `.` is a reserved applies-to target
6
+ * meaning “when this project store is mounted by cwd/profile”. */
7
+ export declare function renderWorkspaceOpenDocs(nodeId: string): string;
@@ -0,0 +1,37 @@
1
+ // on-read-node.ts — the CANVAS-DB wrappers over the pure on-read functions.
2
+ // Both take a node id, assemble its subject from the canvas-db, then delegate
3
+ // to on-read.ts. Split out for the same reason as render-node.ts: a consumer
4
+ // that already holds a subject imports on-read.js without subject.js's
5
+ // canvas-db reach.
6
+ import { getNode } from '../canvas/index.js';
7
+ import { assembleNodeSubject } from './subject.js';
8
+ import { renderOnReadDocsForSubject, renderWorkspaceOpenDocsForSubject } from './on-read.js';
9
+ /** Surface docs matched by a successful read tool call. An unknown node id or
10
+ * an unreadable canvas renders nothing. */
11
+ export function renderOnReadDocs(nodeId, readFilePath, seen = new Set()) {
12
+ let subject;
13
+ try {
14
+ subject = assembleNodeSubject(nodeId);
15
+ }
16
+ catch {
17
+ return '';
18
+ }
19
+ if (subject === null)
20
+ return '';
21
+ return renderOnReadDocsForSubject(subject, readFilePath, seen);
22
+ }
23
+ /** Surface workspace-wide docs during first-message assembly, using the node's
24
+ * recorded cwd and selected profile. `.` is a reserved applies-to target
25
+ * meaning “when this project store is mounted by cwd/profile”. */
26
+ export function renderWorkspaceOpenDocs(nodeId) {
27
+ try {
28
+ const subject = assembleNodeSubject(nodeId);
29
+ const node = getNode(nodeId);
30
+ if (subject === null || node === null)
31
+ return '';
32
+ return renderWorkspaceOpenDocsForSubject(subject, node.cwd, node.profile_id ?? null);
33
+ }
34
+ catch {
35
+ return '';
36
+ }
37
+ }
@@ -1,5 +1,7 @@
1
- /** Surface docs matched by a successful read tool call. */
2
- export declare function renderOnReadDocs(nodeId: string, readFilePath: string, seen?: Set<string>): string;
3
- /** Surface workspace-wide docs during first-message assembly. `.` is a reserved
4
- * applies-to target meaning “when this project store is mounted by cwd/profile”. */
5
- export declare function renderWorkspaceOpenDocs(nodeId: string): string;
1
+ import type { NodeConfigSubject } from './subject-fields.js';
2
+ /** Surface docs matched by a successful read tool call using the caller-supplied
3
+ * daemon snapshot subject. */
4
+ export declare function renderOnReadDocsForSubject(subject: NodeConfigSubject, readFilePath: string, seen?: Set<string>): string;
5
+ /** Workspace boot docs using the snapshot's cwd/profile instead of a canvas
6
+ * lookup. */
7
+ export declare function renderWorkspaceOpenDocsForSubject(subject: NodeConfigSubject, cwd: string, profileId: string | null): string;
@@ -1,4 +1,8 @@
1
- // on-read.ts — file-context delivery for the document substrate.
1
+ // on-read.ts — the pure file-context delivery functions for the document
2
+ // substrate. Both take a caller-supplied subject; the canvas-db wrappers that
3
+ // resolve one from a node id live in on-read-node.ts, so a consumer that
4
+ // already holds a subject imports this module WITHOUT pulling subject.js's
5
+ // canvas-db reach (the same split as render.ts / render-node.ts).
2
6
  //
3
7
  // A memory with file-read visibility participates only through an explicit
4
8
  // `applies-to` path glob (authoring lint requires one). Two events use that one
@@ -28,9 +32,10 @@ import { pathExists, readText, walkFiles } from '../fs-utils.js';
28
32
  import { parseFrontmatterGeneric } from '../frontmatter.js';
29
33
  import { evalCondition } from '../predicate.js';
30
34
  import { listAllMemoryDocs, listProjectMemoryDocs } from '../memory-resolver.js';
31
- import { getNode } from '../canvas/index.js';
32
35
  import { userScopeRoot } from '../scope.js';
33
- import { assembleNodeSubject, displayName, gatePasses, normalizeDocName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, resolveDocName, } from './index.js';
36
+ import { displayName } from './ceiling.js';
37
+ import { gatePasses } from './gate.js';
38
+ import { normalizeDocName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, resolveDocName } from './schema.js';
34
39
  import { cachedSubstrateDocs } from './session-cache.js';
35
40
  const JUNK_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '.yalc']);
36
41
  function realpathOrSelf(p) {
@@ -237,17 +242,9 @@ function renderCandidates(subject, docs, seen) {
237
242
  ? ''
238
243
  : `<auto-loaded-context>\n${rendered.join('\n')}\n</auto-loaded-context>`;
239
244
  }
240
- /** Surface docs matched by a successful read tool call. */
241
- export function renderOnReadDocs(nodeId, readFilePath, seen = new Set()) {
242
- let subject;
243
- try {
244
- subject = assembleNodeSubject(nodeId);
245
- }
246
- catch {
247
- return '';
248
- }
249
- if (subject === null)
250
- return '';
245
+ /** Surface docs matched by a successful read tool call using the caller-supplied
246
+ * daemon snapshot subject. */
247
+ export function renderOnReadDocsForSubject(subject, readFilePath, seen = new Set()) {
251
248
  const absReadFile = realpathOrSelf(readFilePath);
252
249
  const readFrontmatter = readFileFrontmatter(absReadFile);
253
250
  const docs = dedupeByPhysicalPath([...enclosingProjectDocs(absReadFile), ...resolvedDocs()])
@@ -255,17 +252,12 @@ export function renderOnReadDocs(nodeId, readFilePath, seen = new Set()) {
255
252
  .filter((doc) => matchesReadEvent(doc, absReadFile, readFrontmatter));
256
253
  return renderCandidates(subject, docs, seen);
257
254
  }
258
- /** Surface workspace-wide docs during first-message assembly. `.` is a reserved
259
- * applies-to target meaning “when this project store is mounted by cwd/profile”. */
260
- export function renderWorkspaceOpenDocs(nodeId) {
261
- let subject;
255
+ /** Workspace boot docs using the snapshot's cwd/profile instead of a canvas
256
+ * lookup. */
257
+ export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId) {
262
258
  let docs;
263
259
  try {
264
- subject = assembleNodeSubject(nodeId);
265
- const node = getNode(nodeId);
266
- if (subject === null || node === null)
267
- return '';
268
- docs = listProjectMemoryDocs(node.cwd, node.profile_id ?? null)
260
+ docs = listProjectMemoryDocs(cwd, profileId)
269
261
  .map(parseSubstrateDoc)
270
262
  .filter((doc) => doc !== null);
271
263
  }
@@ -1,8 +1,9 @@
1
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';
2
+ import { existsSync, readFileSync, writeFileSync, statSync, mkdirSync, unlinkSync, renameSync, accessSync, realpathSync, constants, } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join, resolve } from 'node:path';
5
5
  import stringWidth from 'string-width';
6
+ import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
6
7
  import { TERMRENDER_VERSION } from './version.js';
7
8
  // ── The sole org-wide termrender binding ─────────────────────────────────────
8
9
  //
@@ -31,15 +32,11 @@ const VENV_STAMP = join(VENV_DIR, '.crtr-termrender-stamp.json');
31
32
  // in a read-only runtime image, and serializes venv mutation + stamp publication
32
33
  // across processes: a stamp can never certify a concurrently-changing venv.
33
34
  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
35
  // Absolute cap on how long a waiter spins before giving up to plaintext for
40
36
  // 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;
37
+ // can never hang a process. Set comfortably above the worst-case held path
38
+ // (uv probe 5s + venv 60s + install 120s + re-verify ~10s ≈ 195s).
39
+ const LOCK_GIVE_UP_MS = 360_000;
43
40
  let rendererState = 'unchecked';
44
41
  function isFp(x) {
45
42
  const e = x;
@@ -167,46 +164,12 @@ function stampValid() {
167
164
  }
168
165
  return true;
169
166
  }
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
- }
167
+ // Run `provision` holding the exclusive provisioning lock, so no two processes
168
+ // mutate the venv (or certify it) at once. A crashed holder is reclaimed at
169
+ // once; a live one is waited on, and if it outlasts the give-up window this
170
+ // process falls back to plaintext for the session rather than break mutual
171
+ // exclusion. `provision` re-checks the stamp first, so a waiter that finally
172
+ // acquires adopts the peer's published result instead of reinstalling.
210
173
  function withProvisionLock(provision) {
211
174
  try {
212
175
  mkdirSync(RENDERER_CACHE_DIR, { recursive: true });
@@ -216,54 +179,11 @@ function withProvisionLock(provision) {
216
179
  process.stderr.write(`[crtr] termrender unavailable — renderer cache cannot be created (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
217
180
  return;
218
181
  }
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;
182
+ try {
183
+ withExclusiveDirectoryLock(VENV_LOCK, provision, { timeoutMs: LOCK_GIVE_UP_MS });
184
+ }
185
+ catch {
186
+ rendererState = 'unavailable';
267
187
  }
268
188
  }
269
189
  function binaryOk() {
@@ -1,10 +1,11 @@
1
- import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import { createHash, randomUUID } from 'node:crypto';
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { gitSync } from './git.js';
5
5
  import { crtrHome } from './canvas/paths.js';
6
6
  import { getNode, updateNode } from './canvas/index.js';
7
7
  import { CONFIG_FILE, CRTR_DIR_NAME, STATE_FILE } from '../types.js';
8
+ import { withExclusiveDirectoryLock } from './exclusive-lock.js';
8
9
  export class WorktreeError extends Error {
9
10
  code;
10
11
  next;
@@ -67,113 +68,23 @@ function checkoutPathForBranch(repoRoot, branch) {
67
68
  return null;
68
69
  }
69
70
  const WORKTREE_LOCK_WAIT_MS = 30_000;
70
- const WORKTREE_LOCK_POLL_MS = 20;
71
71
  const WORKTREE_CLEANUP_TIMEOUT_MS = 5_000;
72
- function sleepSync(ms) {
73
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
74
- }
75
- /** Serialize worktree mutation for one repository across crtr processes. Git's
76
- * own worktree metadata lock is intentionally short-lived and rejects a second
77
- * add instead of waiting, so callers take this transaction lock first. */
78
72
  function commonGitDir(repoRoot) {
79
73
  const commonDir = runGit(repoRoot, ['rev-parse', '--git-common-dir'], 'git_common_dir_failed', 'Inspect the repository git metadata, then retry.');
80
74
  return realpathSync(resolve(repoRoot, commonDir));
81
75
  }
82
- function ownerIsAlive(owner) {
83
- const pid = Number(owner.split(':', 1)[0]);
84
- if (!Number.isSafeInteger(pid) || pid <= 0)
85
- return false;
86
- try {
87
- process.kill(pid, 0);
88
- return true;
89
- }
90
- catch (err) {
91
- return err.code !== 'ESRCH';
92
- }
93
- }
94
- function reclaimDeadLock(lockPath, incumbent) {
95
- // A hard-link claim elects exactly one reclaimer without moving lockPath.
96
- // Its inode remains the stale lock even if a new owner appears later.
97
- const claimPath = `${lockPath}.reclaim`;
98
- try {
99
- linkSync(lockPath, claimPath);
100
- }
101
- catch {
102
- // A crashed reclaimer can leave its claim behind. Removing that claim is
103
- // safe: it names only the already-dead incumbent, and any live reclaimer
104
- // then loses authority because its inode check below fails.
105
- try {
106
- unlinkSync(claimPath);
107
- }
108
- catch { /* claimant is still resolving it */ }
109
- return;
110
- }
111
- try {
112
- const lock = statSync(lockPath);
113
- const claim = statSync(claimPath);
114
- if (lock.ino === claim.ino && readFileSync(claimPath, 'utf8') === incumbent && !ownerIsAlive(incumbent)) {
115
- unlinkSync(lockPath);
116
- }
117
- }
118
- catch { /* owner or another reclaimer changed state; leave it alone */ }
119
- finally {
120
- try {
121
- unlinkSync(claimPath);
122
- }
123
- catch { /* a later reclaimer will clear this stale claim */ }
124
- }
125
- }
76
+ /** Serialize worktree mutation for one repository across crtr processes. Git's
77
+ * own worktree metadata lock is intentionally short-lived and rejects a second
78
+ * add instead of waiting, so callers take this transaction lock first. */
126
79
  function withRepositoryWorktreeLock(repoRoot, action) {
127
80
  const commonDir = commonGitDir(repoRoot);
128
81
  const lockRoot = `${crtrHome()}/worktree-locks`;
129
- const lockName = createHash('sha256').update(commonDir).digest('hex');
130
- const lockPath = `${lockRoot}/${lockName}`;
131
- const owner = `${process.pid}:${randomUUID()}`;
132
- const tempPath = `${lockPath}.${owner}.tmp`;
133
82
  mkdirSync(lockRoot, { recursive: true });
134
- const deadline = Date.now() + WORKTREE_LOCK_WAIT_MS;
135
- for (;;) {
136
- // Write the owner before atomically linking it into the contention name;
137
- // a killed creator leaves only an ignored temp file, never a partial lock.
138
- writeFileSync(tempPath, owner, { flag: 'wx' });
139
- try {
140
- linkSync(tempPath, lockPath);
141
- unlinkSync(tempPath);
142
- break;
143
- }
144
- catch (err) {
145
- try {
146
- unlinkSync(tempPath);
147
- }
148
- catch { /* process crash leaves harmless temp */ }
149
- if (err.code !== 'EEXIST')
150
- throw err;
151
- let incumbent;
152
- try {
153
- incumbent = readFileSync(lockPath, 'utf8');
154
- }
155
- catch {
156
- continue;
157
- }
158
- if (!ownerIsAlive(incumbent))
159
- reclaimDeadLock(lockPath, incumbent);
160
- if (Date.now() >= deadline) {
161
- throw new WorktreeError('worktree_lock_timeout', `timed out waiting to create a managed worktree in ${commonDir}`, 'Wait for the other managed-worktree operation to finish, then retry.');
162
- }
163
- sleepSync(WORKTREE_LOCK_POLL_MS);
164
- }
165
- }
166
- try {
167
- return action();
168
- }
169
- finally {
170
- // Token-checking release cannot unlock a replacement owner's lock.
171
- try {
172
- if (readFileSync(lockPath, 'utf8') === owner)
173
- unlinkSync(lockPath);
174
- }
175
- catch { /* already reclaimed after a process-level failure */ }
176
- }
83
+ const lockPath = `${lockRoot}/${createHash('sha256').update(commonDir).digest('hex')}`;
84
+ return withExclusiveDirectoryLock(lockPath, action, {
85
+ timeoutMs: WORKTREE_LOCK_WAIT_MS,
86
+ timeoutError: () => new WorktreeError('worktree_lock_timeout', `timed out waiting to create a managed worktree in ${commonDir}`, 'Wait for the other managed-worktree operation to finish, then retry.'),
87
+ });
177
88
  }
178
89
  function registeredWorktree(repoRoot, path, branch) {
179
90
  const list = gitSync(['worktree', 'list', '--porcelain'], repoRoot);
@@ -7,6 +7,7 @@
7
7
  //
8
8
  // `{id}` is the target node.
9
9
  import { readdirSync } from 'node:fs';
10
+ import { resolve } from 'node:path';
10
11
  import { appendSituationalContext, closeNode, getNode, listNodes, nowIso, reviveNode, } from '../../../index.js';
11
12
  import { assertLaunchModelRegistered, forkNode, resolveProfileId, spawnChild } from '../../../core/runtime/spawn.js';
12
13
  import { childrenOf, subscribersOf, subscriptionsOf, setMessageWait, updateNode } from '../../../core/canvas/canvas.js';
@@ -29,6 +30,7 @@ import { BrokerUnreachableError } from '../../../core/runtime/broker-request.js'
29
30
  import { isBrokerLive, persistDormantModel, setModelLive } from '../../../core/runtime/model-swap.js';
30
31
  import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
31
32
  import { claimWarmNode, refillWarmPool, warmPoolEnabled } from '../../../core/runtime/warm-pool.js';
33
+ import { profileHome } from '../../../core/profiles/manifest.js';
32
34
  import { fullName } from '../../../core/canvas/labels.js';
33
35
  import { getCron } from '../../../core/canvas/crons.js';
34
36
  import { cronWakeOrigin } from '../../../core/runtime/bearings.js';
@@ -85,11 +87,31 @@ function parseCreateBody(body) {
85
87
  }
86
88
  return b;
87
89
  }
90
+ /** The directory a new node is pinned to. An explicit `pin_cwd` wins; else a
91
+ * managed child inherits its spawner's cwd (which is what keeps a worktree
92
+ * node's whole subtree inside that worktree); else the node runs in its
93
+ * profile's home. The request's own `cwd` — the caller's directory — is only
94
+ * the last resort, for a profile that has no home (notably root).
95
+ *
96
+ * This is the profile→cwd direction: `cwd` selected the profile a moment ago,
97
+ * and the profile now supplies where the node runs. Collapsing every create
98
+ * under a profile onto one directory is also what keeps the warm pool to one
99
+ * spare per profile instead of one per directory anyone happened to spawn from. */
100
+ function resolveNodeCwd(req, parent, profileId, contextCwd) {
101
+ if (req.pin_cwd !== undefined && req.pin_cwd !== '')
102
+ return resolve(req.pin_cwd);
103
+ if (parent != null) {
104
+ const spawner = getNode(parent);
105
+ if (spawner !== null)
106
+ return spawner.cwd;
107
+ }
108
+ return profileHome(profileId) ?? contextCwd;
109
+ }
88
110
  /** Build the immediate spawn recipe. */
89
- function buildRecipe(req, parent) {
111
+ function buildRecipe(req, parent, cwd) {
90
112
  const recipe = {
91
113
  kind: req.kind,
92
- cwd: req.cwd ?? process.cwd(),
114
+ cwd,
93
115
  prompt: req.prompt ?? '',
94
116
  };
95
117
  if (req.mode !== undefined)
@@ -122,7 +144,7 @@ function buildRecipe(req, parent) {
122
144
  * forked conversation, a managed worktree, a caller-pinned node id, a spine
123
145
  * parent — disqualifies it. `kind`, `mode`, `model`, `name`, `situational_context`
124
146
  * and `output_schema` are all applied at claim time and so do not. */
125
- function warmRequestFor(req) {
147
+ function warmRequestFor(req, cwd, profileId) {
126
148
  if (!warmPoolEnabled())
127
149
  return null;
128
150
  if (req.prefer_warm !== true)
@@ -136,8 +158,8 @@ function warmRequestFor(req) {
136
158
  return {
137
159
  kind: req.kind,
138
160
  mode: (req.mode ?? 'base'),
139
- cwd: req.cwd ?? process.cwd(),
140
- profile: req.profile,
161
+ cwd,
162
+ profileId,
141
163
  model: req.model ?? null,
142
164
  situationalContext: req.situational_context ?? null,
143
165
  name: req.name ?? null,
@@ -184,8 +206,9 @@ export async function handleCreate(ctx, deps = {}) {
184
206
  // Resolve and validate the exact launch recipe before the warm pool can claim
185
207
  // a spare (or the cold path can create a worktree/node). The registry belongs
186
208
  // to this daemon, never to the possibly-remote CLI caller.
187
- const cwd = req.cwd ?? process.cwd();
188
- const profileId = await resolveProfileId(req.profile, parent ?? null, cwd);
209
+ const contextCwd = req.cwd ?? process.cwd();
210
+ const profileId = await resolveProfileId(req.profile, parent ?? null, contextCwd);
211
+ const cwd = resolveNodeCwd(req, parent, profileId, contextCwd);
189
212
  const launch = buildLaunchSpec(req.kind, (req.mode ?? 'base'), {
190
213
  lifecycle: req.root === true ? 'resident' : 'terminal',
191
214
  hasManager: req.root !== true,
@@ -197,7 +220,7 @@ export async function handleCreate(ctx, deps = {}) {
197
220
  // --- Warm path: hand out an already-booted spare instead of paying a full
198
221
  // engine boot. A miss is not a failure — it falls through to the cold
199
222
  // spawn below and mints the spare that serves the NEXT create. ---
200
- const warm = warmRequestFor(req);
223
+ const warm = warmRequestFor(req, cwd, profileId);
201
224
  if (warm !== null) {
202
225
  const claimed = await claimWarmNode(warm);
203
226
  refillWarmPool(warm);
@@ -207,7 +230,7 @@ export async function handleCreate(ctx, deps = {}) {
207
230
  return { status: 201, body: detailOf(claimed) };
208
231
  }
209
232
  }
210
- const recipe = buildRecipe(req, parent);
233
+ const recipe = buildRecipe(req, parent, cwd);
211
234
  recipe.readiness = req.root === true ? 'await' : 'launch';
212
235
  const result = await spawnChild(recipe);
213
236
  if (schema !== null)
@@ -2,45 +2,23 @@
2
2
  // input and derive authorship; review/comment state remains in the review
3
3
  // services and comment delivery remains a post-commit best effort.
4
4
  import { randomUUID } from 'node:crypto';
5
- import { ApiError } from '../../../api/index.js';
6
5
  import { getNode } from '../../../core/canvas/canvas.js';
7
6
  import { isSafeNodeId } from '../../../core/canvas/paths.js';
8
7
  import { createComment, deleteComment, editComment, getComment, listCommentEvents, listComments, listReviewEvents, reopenComment, resolveComment, } from '../../../core/review/comments.js';
9
8
  import { withCanvasWrite } from '../../../core/canvas/db.js';
10
9
  import { captureAnchorText, requireCurrentDocumentLocked } from '../../../core/review/document.js';
11
10
  import { applyRangeBatch } from '../../../core/review/remap.js';
12
- import { getReview, getReviewByCompanion } from '../../../core/review/store.js';
11
+ import { getReviewByCompanion, requireVisibleReview } from '../../../core/review/store.js';
13
12
  import { ReviewOperationError } from '../../../core/review/types.js';
14
13
  import { notifyComment } from '../../review/comment-notify.js';
15
14
  import { toReviewCommentDTO, toReviewCommentEventDTO } from '../map.js';
15
+ import { invalidRequest as rejectRequest, isRecord, objectBody, requireParam, requiredString, } from './validate.js';
16
16
  const COMMENT_ID = /^[a-f0-9]{32}$/;
17
17
  const SHA256 = /^[a-f0-9]{64}$/;
18
18
  const COMMENT_STATUSES = new Set(['open', 'resolved', 'deleted', 'all']);
19
19
  const DELIVER_MODES = new Set(['wake', 'quiet']);
20
20
  function invalidRequest(violations) {
21
- throw new ApiError(400, 'invalid_request', 'review comment request is invalid', { violations });
22
- }
23
- function isRecord(value) {
24
- return typeof value === 'object' && value !== null && !Array.isArray(value);
25
- }
26
- function objectBody(body, allowed, violations) {
27
- if (!isRecord(body)) {
28
- violations.push({ field: 'body', problem: 'must be an object', received: body });
29
- return {};
30
- }
31
- for (const key of Object.keys(body)) {
32
- if (!allowed.includes(key))
33
- violations.push({ field: key, problem: 'is not allowed', received: body[key] });
34
- }
35
- return body;
36
- }
37
- function requiredString(body, field, violations) {
38
- const value = body[field];
39
- if (typeof value !== 'string' || value.trim() === '') {
40
- violations.push({ field, problem: 'must be a non-empty string', ...(value === undefined ? {} : { received: value }) });
41
- return undefined;
42
- }
43
- return value;
21
+ rejectRequest('review comment request', violations);
44
22
  }
45
23
  function optionalSha256(body, field, violations) {
46
24
  const value = body[field];
@@ -135,27 +113,10 @@ function anchorFrom(value, field, violations) {
135
113
  };
136
114
  }
137
115
  function requireReviewId(ctx, violations) {
138
- const reviewId = ctx.params['review_id'];
139
- if (reviewId === undefined || !isSafeNodeId(reviewId)) {
140
- violations.push({ field: 'review_id', problem: 'must be a safe review id', ...(reviewId === undefined ? {} : { received: reviewId }) });
141
- return undefined;
142
- }
143
- return reviewId;
116
+ return requireParam(ctx, 'review_id', 'must be a safe review id', isSafeNodeId, violations);
144
117
  }
145
118
  function requireCommentId(ctx, violations) {
146
- const commentId = ctx.params['comment_id'];
147
- if (commentId === undefined || !COMMENT_ID.test(commentId)) {
148
- violations.push({ field: 'comment_id', problem: 'must be a 32-character lowercase hexadecimal id', ...(commentId === undefined ? {} : { received: commentId }) });
149
- return undefined;
150
- }
151
- return commentId;
152
- }
153
- function requireVisibleReview(reviewId) {
154
- const review = getReview(reviewId);
155
- if (review === null || review.state === 'binding') {
156
- throw new ReviewOperationError('unknown_review', 'review was not found', { received: reviewId });
157
- }
158
- return review;
119
+ return requireParam(ctx, 'comment_id', 'must be a 32-character lowercase hexadecimal id', (v) => COMMENT_ID.test(v), violations);
159
120
  }
160
121
  function requireOpenReview(reviewId) {
161
122
  const review = requireVisibleReview(reviewId);