@north-light/crouter 0.3.175 → 0.3.177

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 (185) hide show
  1. package/dist/api/client.d.ts +4 -4
  2. package/dist/api/client.js +2 -2
  3. package/dist/api/dto/lifecycle.d.ts +1 -1
  4. package/dist/api/dto/nodes.d.ts +7 -1
  5. package/dist/api/dto/profiles.d.ts +3 -0
  6. package/dist/api/dto/reports.d.ts +1 -1
  7. package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
  8. package/dist/clients/attach/config.js +5 -5
  9. package/dist/clients/attach/input/clipboard-image.js +2 -2
  10. package/dist/clients/attach/input/ref-autocomplete.js +1 -1
  11. package/dist/clients/attach/input/titled-editor.d.ts +12 -13
  12. package/dist/clients/attach/input/titled-editor.js +14 -15
  13. package/dist/clients/attach/overlays/dialogs.d.ts +1 -1
  14. package/dist/clients/attach/overlays/dialogs.js +2 -2
  15. package/dist/clients/attach/overlays/mcp.js +5 -12
  16. package/dist/clients/attach/render/chat-view.d.ts +1 -1
  17. package/dist/clients/attach/render/chat-view.js +2 -2
  18. package/dist/clients/attach/render/crtr-output.d.ts +3 -3
  19. package/dist/clients/attach/render/crtr-output.js +25 -13
  20. package/dist/clients/attach/render/group-recap.js +7 -0
  21. package/dist/clients/attach/session/context.js +3 -3
  22. package/dist/clients/attach/session/editor-inventory.js +6 -6
  23. package/dist/clients/attach/session/input-wiring.js +1 -1
  24. package/dist/clients/attach/session/teardown.js +7 -9
  25. package/dist/clients/attach/slash/dispatch.d.ts +1 -2
  26. package/dist/clients/attach/slash/dispatch.js +4 -8
  27. package/dist/clients/attach/viewer.js +536 -566
  28. package/dist/clients/inbox/controller.js +3 -2
  29. package/dist/clients/inbox/deck-adapter.js +3 -2
  30. package/dist/clients/surfaces/host.d.ts +1 -1
  31. package/dist/commands/api-client.d.ts +12 -13
  32. package/dist/commands/api-client.js +15 -16
  33. package/dist/commands/chord.js +1 -2
  34. package/dist/commands/human/queue.js +6 -5
  35. package/dist/commands/memory/find.js +3 -3
  36. package/dist/commands/node/create.js +7 -3
  37. package/dist/commands/node/lifecycle.js +1 -1
  38. package/dist/commands/node-lifecycle-revive.d.ts +1 -1
  39. package/dist/commands/node-lifecycle-revive.js +3 -3
  40. package/dist/commands/profile/list.js +3 -1
  41. package/dist/commands/profile/new.js +4 -1
  42. package/dist/commands/profile/project.js +23 -9
  43. package/dist/commands/profile/show.js +3 -1
  44. package/dist/commands/profile.js +2 -2
  45. package/dist/commands/push.js +1 -1
  46. package/dist/commands/surface/node/placement.d.ts +1 -1
  47. package/dist/commands/surface/node/placement.js +1 -1
  48. package/dist/commands/surface-edit.js +1 -4
  49. package/dist/commands/sys/logs.js +4 -2
  50. package/dist/commands/sys/panels/panel.d.ts +3 -3
  51. package/dist/commands/sys/panels/panel.js +3 -3
  52. package/dist/commands/sys/setup-core.js +1 -7
  53. package/dist/commands/sys/sync-deps.js +1 -3
  54. package/dist/commands/sys/sync-project-guidance.js +2 -11
  55. package/dist/commands/sys/sysprompt.js +1 -3
  56. package/dist/core/__tests__/branded-host-concurrency.test.js +1 -1
  57. package/dist/core/__tests__/fixtures/fake-engine.js +1 -1
  58. package/dist/core/__tests__/helpers/harness.d.ts +1 -1
  59. package/dist/core/__tests__/helpers/harness.js +1 -1
  60. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +1 -1
  61. package/dist/core/__tests__/on-read-dedup-resume.test.js +1 -1
  62. package/dist/core/canvas/boot.js +1 -1
  63. package/dist/core/canvas/browse/app.js +2 -2
  64. package/dist/core/canvas/canvas.d.ts +3 -3
  65. package/dist/core/canvas/canvas.js +3 -3
  66. package/dist/core/canvas/daemon-owner.js +1 -2
  67. package/dist/core/canvas/db.js +7 -8
  68. package/dist/core/canvas/extensions.d.ts +1 -1
  69. package/dist/core/canvas/extensions.js +7 -1
  70. package/dist/core/canvas/index.js +2 -2
  71. package/dist/core/canvas/pid.d.ts +12 -14
  72. package/dist/core/canvas/pid.js +16 -20
  73. package/dist/core/canvas/remote-transport.js +2 -2
  74. package/dist/core/canvas/status-glyph.d.ts +1 -1
  75. package/dist/core/canvas/status-glyph.js +1 -1
  76. package/dist/core/canvas/types.d.ts +1 -1
  77. package/dist/core/command-manifests/manifest.js +4 -6
  78. package/dist/core/command-manifests/schema.js +10 -12
  79. package/dist/core/command-plugins/transport/exec-invoke.js +5 -7
  80. package/dist/core/command-plugins/transport/http-invoke.js +14 -8
  81. package/dist/core/command.js +3 -1
  82. package/dist/core/config.d.ts +1 -1
  83. package/dist/core/config.js +6 -29
  84. package/dist/core/events/read.js +1 -3
  85. package/dist/core/exclusive-lock.d.ts +14 -0
  86. package/dist/core/exclusive-lock.js +124 -0
  87. package/dist/core/fault-classifier.js +1 -3
  88. package/dist/core/fs-utils.d.ts +7 -0
  89. package/dist/core/fs-utils.js +29 -2
  90. package/dist/core/host-exports/export.d.ts +4 -5
  91. package/dist/core/host-exports/export.js +4 -5
  92. package/dist/core/human/claim.js +4 -2
  93. package/dist/core/human/convention.d.ts +0 -11
  94. package/dist/core/human/convention.js +1 -131
  95. package/dist/core/human/scan.js +5 -4
  96. package/dist/core/io.d.ts +23 -0
  97. package/dist/core/io.js +60 -0
  98. package/dist/core/preview-registry.d.ts +10 -5
  99. package/dist/core/preview-registry.js +136 -2537
  100. package/dist/core/profiles/manifest.d.ts +13 -2
  101. package/dist/core/profiles/manifest.js +58 -49
  102. package/dist/core/review/store.d.ts +4 -1
  103. package/dist/core/review/store.js +10 -0
  104. package/dist/core/review/types.d.ts +1 -1
  105. package/dist/core/review/types.js +1 -1
  106. package/dist/core/runtime/branded-host.js +6 -84
  107. package/dist/core/runtime/broker/frame-dispatch.js +4 -4
  108. package/dist/core/runtime/broker-cli.js +1 -1
  109. package/dist/core/runtime/broker-extension-render.js +1 -1
  110. package/dist/core/runtime/broker-protocol.d.ts +9 -10
  111. package/dist/core/runtime/broker-protocol.js +9 -10
  112. package/dist/core/runtime/broker-sdk.d.ts +1 -1
  113. package/dist/core/runtime/broker.js +6 -6
  114. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  115. package/dist/core/runtime/canvas-extensions.js +2 -0
  116. package/dist/core/runtime/host.js +14 -14
  117. package/dist/core/runtime/launch.d.ts +1 -1
  118. package/dist/core/runtime/launch.js +1 -1
  119. package/dist/core/runtime/lifecycle.js +16 -21
  120. package/dist/core/runtime/node-read.js +2 -3
  121. package/dist/core/runtime/package-health.js +1 -8
  122. package/dist/core/runtime/persona.js +2 -2
  123. package/dist/core/runtime/pi-vendored.d.ts +4 -2
  124. package/dist/core/runtime/pi-vendored.js +5 -16
  125. package/dist/core/runtime/promote.js +1 -1
  126. package/dist/core/runtime/reopen.js +2 -2
  127. package/dist/core/runtime/revive-all.d.ts +3 -3
  128. package/dist/core/runtime/revive-all.js +6 -6
  129. package/dist/core/runtime/revive.js +1 -1
  130. package/dist/core/runtime/spawn.d.ts +1 -2
  131. package/dist/core/runtime/spawn.js +2 -3
  132. package/dist/core/runtime/structured-output.js +4 -6
  133. package/dist/core/runtime/tmux-bindings.js +1 -1
  134. package/dist/core/runtime/tmux-driver.d.ts +2 -2
  135. package/dist/core/runtime/tmux-driver.js +3 -5
  136. package/dist/core/runtime/tool-group-summary.js +5 -4
  137. package/dist/core/runtime/warm-pool.d.ts +9 -8
  138. package/dist/core/runtime/warm-pool.js +58 -62
  139. package/dist/core/scope.js +2 -9
  140. package/dist/core/self-update.js +3 -3
  141. package/dist/core/spawn.d.ts +0 -1
  142. package/dist/core/spawn.js +1 -4
  143. package/dist/core/substrate/index.d.ts +2 -1
  144. package/dist/core/substrate/index.js +2 -1
  145. package/dist/core/substrate/on-read-node.d.ts +7 -0
  146. package/dist/core/substrate/on-read-node.js +37 -0
  147. package/dist/core/substrate/on-read.d.ts +7 -5
  148. package/dist/core/substrate/on-read.js +16 -32
  149. package/dist/core/termrender/termrender.js +16 -96
  150. package/dist/core/wake.js +2 -2
  151. package/dist/core/worktree.d.ts +3 -3
  152. package/dist/core/worktree.js +16 -107
  153. package/dist/daemon/api/bridge.js +1 -1
  154. package/dist/daemon/api/handlers/broker-ops.js +1 -2
  155. package/dist/daemon/api/handlers/messages.js +2 -2
  156. package/dist/daemon/api/handlers/nodes.js +33 -10
  157. package/dist/daemon/api/handlers/reports.js +1 -1
  158. package/dist/daemon/api/handlers/review-comments.js +5 -44
  159. package/dist/daemon/api/handlers/reviews.js +5 -53
  160. package/dist/daemon/api/handlers/validate.d.ts +23 -0
  161. package/dist/daemon/api/handlers/validate.js +58 -0
  162. package/dist/daemon/api/map.js +1 -0
  163. package/dist/daemon/cron-run.js +7 -7
  164. package/dist/daemon/human/finish.js +3 -2
  165. package/dist/daemon/manage.d.ts +6 -6
  166. package/dist/daemon/manage.js +15 -16
  167. package/dist/daemon/review/finish.js +1 -10
  168. package/dist/pi-extensions/canvas-context-intro.js +1 -14
  169. package/dist/pi-extensions/canvas-doc-substrate.js +2 -2
  170. package/dist/pi-extensions/canvas-preview-result.d.ts +22 -0
  171. package/dist/pi-extensions/canvas-preview-result.js +60 -0
  172. package/dist/pi-extensions/canvas-review-boundary.js +1 -14
  173. package/dist/pi-extensions/truncate.d.ts +5 -0
  174. package/dist/pi-extensions/truncate.js +14 -0
  175. package/dist/shared/predicates.d.ts +2 -0
  176. package/dist/shared/predicates.js +4 -0
  177. package/dist/shared/shell-quote.d.ts +3 -0
  178. package/dist/shared/shell-quote.js +5 -0
  179. package/dist/shared/tool-groups.d.ts +4 -2
  180. package/dist/shared/tool-groups.js +3 -3
  181. package/dist/types.d.ts +8 -5
  182. package/package.json +1 -1
  183. package/runtime.lock.json +2 -2
  184. package/dist/core/substrate/on-read-pure.d.ts +0 -7
  185. package/dist/core/substrate/on-read-pure.js +0 -272
@@ -1,10 +1,12 @@
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';
9
+ import { shellQuote } from '../shared/shell-quote.js';
8
10
  export class WorktreeError extends Error {
9
11
  code;
10
12
  next;
@@ -67,113 +69,23 @@ function checkoutPathForBranch(repoRoot, branch) {
67
69
  return null;
68
70
  }
69
71
  const WORKTREE_LOCK_WAIT_MS = 30_000;
70
- const WORKTREE_LOCK_POLL_MS = 20;
71
72
  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
73
  function commonGitDir(repoRoot) {
79
74
  const commonDir = runGit(repoRoot, ['rev-parse', '--git-common-dir'], 'git_common_dir_failed', 'Inspect the repository git metadata, then retry.');
80
75
  return realpathSync(resolve(repoRoot, commonDir));
81
76
  }
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
- }
77
+ /** Serialize worktree mutation for one repository across crtr processes. Git's
78
+ * own worktree metadata lock is intentionally short-lived and rejects a second
79
+ * add instead of waiting, so callers take this transaction lock first. */
126
80
  function withRepositoryWorktreeLock(repoRoot, action) {
127
81
  const commonDir = commonGitDir(repoRoot);
128
82
  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
83
  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
- }
84
+ const lockPath = `${lockRoot}/${createHash('sha256').update(commonDir).digest('hex')}`;
85
+ return withExclusiveDirectoryLock(lockPath, action, {
86
+ timeoutMs: WORKTREE_LOCK_WAIT_MS,
87
+ 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.'),
88
+ });
177
89
  }
178
90
  function registeredWorktree(repoRoot, path, branch) {
179
91
  const list = gitSync(['worktree', 'list', '--porcelain'], repoRoot);
@@ -226,7 +138,7 @@ function rollbackManagedWorktreeLocked(wt, pathWasAbsent) {
226
138
  export function rollbackManagedWorktree(wt) {
227
139
  withRepositoryWorktreeLock(wt.repo_root, () => rollbackManagedWorktreeLocked(wt, false));
228
140
  }
229
- // Fix #330/#331: every crtr process auto-initializes a project scope's
141
+ // Every crtr process auto-initializes a project scope's
230
142
  // `.crouter/config.json` (`ensureProjectScope` → `ensureScopeInitialized`)
231
143
  // the first time it runs against a project root that has no ancestor
232
144
  // `.crouter/` yet — which every fresh managed worktree checkout is, unless
@@ -446,9 +358,6 @@ let worktreeCleanupRunner = defaultWorktreeCleanupRunner;
446
358
  export function setWorktreeCleanupRunnerForTest(runner) {
447
359
  worktreeCleanupRunner = runner ?? defaultWorktreeCleanupRunner;
448
360
  }
449
- function shellQuote(value) {
450
- return `'${value.replaceAll("'", "'\\''")}'`;
451
- }
452
361
  function isValidRegisteredCheckout(output, wt) {
453
362
  let path = null;
454
363
  let prunable = false;
@@ -696,11 +605,11 @@ export function autoDropCleanManagedWorktreeLocked(nodeId, wt, proof) {
696
605
  branch_deleted: true,
697
606
  };
698
607
  }
699
- /** `push final`'s escape hatch (#327, #333): a managed worktree that has
608
+ /** `push final`'s escape hatch: a managed worktree that has
700
609
  * nothing to land is auto-dropped instead of blocking on an explicit
701
610
  * `node worktree close` — one the caller may have been told NOT to run
702
- * because a manager already landed the same change another way (#327), or
703
- * that never had anything to close in the first place (#333, a read-only
611
+ * because a manager already landed the same change another way, or
612
+ * that never had anything to close in the first place (a read-only
704
613
  * review node). Returns null and changes nothing when the worktree is not
705
614
  * PROVABLY empty-and-clean by `proveCleanEmptyManagedWorktree`, OR when a
706
615
  * concurrent mutation invalidates that proof before the destructive delete
@@ -49,7 +49,7 @@ function refuseUpgrade(socket, status, statusText, code, message) {
49
49
  `connection: close\r\n\r\n` +
50
50
  body;
51
51
  // `end(payload)` flushes the full body THEN sends FIN — a bare
52
- // `write`+`destroy` can truncate the body for a slow remote peer (review M1).
52
+ // `write`+`destroy` can truncate the body for a slow remote peer.
53
53
  try {
54
54
  socket.end(payload);
55
55
  }
@@ -254,8 +254,7 @@ function settleBroker(nodeId, request) {
254
254
  return node.intent === 'refresh' ? { action: 'shutdown' } : { action: 'stay_dormant' };
255
255
  }
256
256
  const signals = { pushedFinal: request.pushedFinal, askedHuman: request.askedHuman };
257
- // These two current-state outcomes were previously duplicated around the
258
- // stop guard. They are terminal broker consequences, not Pi decisions.
257
+ // Two current-state outcomes: terminal broker consequences, not Pi decisions.
259
258
  if (signals.pushedFinal || node.status === 'done') {
260
259
  clearFault(nodeId);
261
260
  if (focusOf(nodeId) !== null)
@@ -86,7 +86,7 @@ function parseSchema(raw) {
86
86
  function label(text) {
87
87
  return text.split('\n')[0].slice(0, 120);
88
88
  }
89
- /** The #341/#343 fail-loud rejection for a deferred immediate message aimed at a
89
+ /** The fail-loud rejection for a deferred immediate message aimed at a
90
90
  * target with no natural cycle ahead of it (done/canceled/finalized). Thrown as
91
91
  * an InputError so `toErrorBody` round-trips the FULL structured payload
92
92
  * (code+message+next) into `ApiError.details`, and the CLI's shared
@@ -207,7 +207,7 @@ async function handleMessage(ctx) {
207
207
  data: { situational: true, situationalOnly: true },
208
208
  });
209
209
  };
210
- // #341/#343 deferred guard: a done/canceled/finalized target has no natural
210
+ // Deferred guard: a done/canceled/finalized target has no natural
211
211
  // cycle ahead of it, so a deferred entry would sit unseen forever — reject
212
212
  // fail-loud BEFORE any side effect, atomically with the append (the read and
213
213
  // the write share one canvas write-lock). Non-deferred tiers deliver directly.
@@ -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)
@@ -334,7 +357,7 @@ function handleRevive(ctx) {
334
357
  const body = ctx.body;
335
358
  const resume = body?.resume ?? true;
336
359
  const reopen = body?.reopen ?? false;
337
- // #339 gate, enforced server-side (the latch clear is a canvas write the CLI
360
+ // Reopen gate, enforced server-side (the latch clear is a canvas write the CLI
338
361
  // caller cannot reach): --reopen CAS-clears a spent `final_report`; the
339
362
  // no-reopen path refuses a still-latched node (`node_finalized`). reviveNode
340
363
  // keeps its own belt-and-suspenders latch guard under the launch lock.
@@ -42,7 +42,7 @@ async function handlePush(ctx) {
42
42
  const before = req.tier === 'final' ? getNode(id)?.status : undefined;
43
43
  // `final` with an OPEN managed worktree, BEFORE the push/transition (spec
44
44
  // §6.2 — worktree fs ops are server-side). A provably-clean, zero-commit
45
- // worktree is auto-dropped (#327/#333) so a read-only or already-landed node
45
+ // worktree is auto-dropped so a read-only or already-landed node
46
46
  // can finish without an explicit `node worktree close`; any real unlanded
47
47
  // work instead blocks with the parity `open_managed_worktree` error,
48
48
  // round-tripped via CrtrError.details so the CLI renders the same InputError
@@ -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);
@@ -7,70 +7,22 @@ import { isSafeNodeId, reviewResultPath } from '../../../core/canvas/paths.js';
7
7
  import { responsePath, reviewPath } from '../../../core/human/convention.js';
8
8
  import { ticketDir } from '../../../core/human/root.js';
9
9
  import { createReview } from '../../../core/review/birth.js';
10
- import { readAnchorBase, getReview, getReviewByBridge, listReviews } from '../../../core/review/store.js';
10
+ import { readAnchorBase, getReviewByBridge, listReviews, requireVisibleReview } from '../../../core/review/store.js';
11
11
  import { ReviewOperationError } from '../../../core/review/types.js';
12
12
  import { cancelReview, submitReviewApproval } from '../../review/finish.js';
13
13
  import { toFeedbackResultDTO, toReviewDTO } from '../map.js';
14
+ import { invalidRequest as rejectRequest, objectBody, optionalString, requireParam, requiredString, } from './validate.js';
14
15
  const REVIEW_STATES = new Set(['open', 'approved', 'canceled']);
15
16
  const CREATE_FIELDS = ['origin_kind', 'origin_node_id', 'file', 'idempotency_key', 'subtitle', 'output_path'];
16
17
  const CANCEL_FIELDS = ['reason', 'actor'];
17
18
  function invalidRequest(violations) {
18
- throw new ApiError(400, 'invalid_request', 'review request is invalid', { violations });
19
- }
20
- function isRecord(value) {
21
- return typeof value === 'object' && value !== null && !Array.isArray(value);
22
- }
23
- function objectBody(body, allowed, violations) {
24
- if (!isRecord(body)) {
25
- violations.push({ field: 'body', problem: 'must be an object', received: body });
26
- return {};
27
- }
28
- for (const key of Object.keys(body)) {
29
- if (!allowed.includes(key))
30
- violations.push({ field: key, problem: 'is not allowed', received: body[key] });
31
- }
32
- return body;
33
- }
34
- function requiredString(body, field, violations) {
35
- const value = body[field];
36
- if (typeof value !== 'string' || value === '') {
37
- violations.push({ field, problem: 'must be a non-empty string', ...(value === undefined ? {} : { received: value }) });
38
- return undefined;
39
- }
40
- return value;
41
- }
42
- function optionalString(body, field, violations) {
43
- const value = body[field];
44
- if (value === undefined)
45
- return undefined;
46
- if (typeof value !== 'string' || value === '') {
47
- violations.push({ field, problem: 'must be a non-empty string', received: value });
48
- return undefined;
49
- }
50
- return value;
19
+ rejectRequest('review request', violations);
51
20
  }
52
21
  function requireReviewId(ctx, violations) {
53
- const reviewId = ctx.params['review_id'];
54
- if (reviewId === undefined || !isSafeNodeId(reviewId)) {
55
- violations.push({ field: 'review_id', problem: 'must be a safe review id', ...(reviewId === undefined ? {} : { received: reviewId }) });
56
- return undefined;
57
- }
58
- return reviewId;
22
+ return requireParam(ctx, 'review_id', 'must be a safe review id', isSafeNodeId, violations);
59
23
  }
60
24
  function requireBridgeNodeId(ctx, violations) {
61
- const bridgeNodeId = ctx.params['bridge_node_id'];
62
- if (bridgeNodeId === undefined || !isSafeNodeId(bridgeNodeId)) {
63
- violations.push({ field: 'bridge_node_id', problem: 'must be a safe node id', ...(bridgeNodeId === undefined ? {} : { received: bridgeNodeId }) });
64
- return undefined;
65
- }
66
- return bridgeNodeId;
67
- }
68
- function requireVisibleReview(reviewId) {
69
- const review = getReview(reviewId);
70
- if (review === null || review.state === 'binding') {
71
- throw new ReviewOperationError('unknown_review', 'review was not found', { received: reviewId });
72
- }
73
- return review;
25
+ return requireParam(ctx, 'bridge_node_id', 'must be a safe node id', isSafeNodeId, violations);
74
26
  }
75
27
  function approvedResultPath(review) {
76
28
  if (review.origin_kind === 'inline')
@@ -0,0 +1,23 @@
1
+ import { isRecord } from '../../../shared/predicates.js';
2
+ import type { RequestContext } from '../router.js';
3
+ export { isRecord };
4
+ export type Violation = {
5
+ field: string;
6
+ problem: string;
7
+ received?: unknown;
8
+ };
9
+ /** Reject the request with every violation collected so far. `subject` names
10
+ * what was being parsed, e.g. "review comment request". */
11
+ export declare function invalidRequest(subject: string, violations: Violation[]): never;
12
+ /** Narrow a request body to an object and reject any field outside `allowed`.
13
+ * A non-object body yields `{}` so the caller's field reads all miss and
14
+ * report their own violations rather than throwing. */
15
+ export declare function objectBody(body: unknown, allowed: readonly string[], violations: Violation[]): Record<string, unknown>;
16
+ /** A required field that must be a string carrying at least one non-whitespace
17
+ * character — whitespace alone is not a value. */
18
+ export declare function requiredString(body: Record<string, unknown>, field: string, violations: Violation[]): string | undefined;
19
+ /** `requiredString`'s rule when present; absence is allowed. */
20
+ export declare function optionalString(body: Record<string, unknown>, field: string, violations: Violation[]): string | undefined;
21
+ /** A path parameter that must be present and satisfy `accepts`. `problem`
22
+ * describes the shape the caller failed to supply. */
23
+ export declare function requireParam(ctx: RequestContext, field: string, problem: string, accepts: (value: string) => boolean, violations: Violation[]): string | undefined;
@@ -0,0 +1,58 @@
1
+ // Shared wire-request validation for the daemon's `/v1` handlers.
2
+ //
3
+ // A handler collects `Violation`s while parsing a request, then calls
4
+ // `invalidRequest` once so a caller sees every problem with its body in a
5
+ // single 400 rather than one per round trip.
6
+ import { ApiError } from '../../../api/index.js';
7
+ import { isRecord } from '../../../shared/predicates.js';
8
+ export { isRecord };
9
+ /** Reject the request with every violation collected so far. `subject` names
10
+ * what was being parsed, e.g. "review comment request". */
11
+ export function invalidRequest(subject, violations) {
12
+ throw new ApiError(400, 'invalid_request', `${subject} is invalid`, { violations });
13
+ }
14
+ /** Narrow a request body to an object and reject any field outside `allowed`.
15
+ * A non-object body yields `{}` so the caller's field reads all miss and
16
+ * report their own violations rather than throwing. */
17
+ export function objectBody(body, allowed, violations) {
18
+ if (!isRecord(body)) {
19
+ violations.push({ field: 'body', problem: 'must be an object', received: body });
20
+ return {};
21
+ }
22
+ for (const key of Object.keys(body)) {
23
+ if (!allowed.includes(key))
24
+ violations.push({ field: key, problem: 'is not allowed', received: body[key] });
25
+ }
26
+ return body;
27
+ }
28
+ /** A required field that must be a string carrying at least one non-whitespace
29
+ * character — whitespace alone is not a value. */
30
+ export function requiredString(body, field, violations) {
31
+ const value = body[field];
32
+ if (typeof value !== 'string' || value.trim() === '') {
33
+ violations.push({ field, problem: 'must be a non-empty string', ...(value === undefined ? {} : { received: value }) });
34
+ return undefined;
35
+ }
36
+ return value;
37
+ }
38
+ /** `requiredString`'s rule when present; absence is allowed. */
39
+ export function optionalString(body, field, violations) {
40
+ const value = body[field];
41
+ if (value === undefined)
42
+ return undefined;
43
+ if (typeof value !== 'string' || value.trim() === '') {
44
+ violations.push({ field, problem: 'must be a non-empty string', received: value });
45
+ return undefined;
46
+ }
47
+ return value;
48
+ }
49
+ /** A path parameter that must be present and satisfy `accepts`. `problem`
50
+ * describes the shape the caller failed to supply. */
51
+ export function requireParam(ctx, field, problem, accepts, violations) {
52
+ const value = ctx.params[field];
53
+ if (value === undefined || !accepts(value)) {
54
+ violations.push({ field, problem, ...(value === undefined ? {} : { received: value }) });
55
+ return undefined;
56
+ }
57
+ return value;
58
+ }
@@ -259,6 +259,7 @@ export function toProfileDTO(entry) {
259
259
  id: entry.profileId,
260
260
  name: entry.manifest.name,
261
261
  projects: [...entry.manifest.projects],
262
+ home: entry.manifest.home,
262
263
  default_dir: null,
263
264
  };
264
265
  }
@@ -377,8 +377,7 @@ async function disposeSettledRun(c, ctx, outOfBand, selfCanceled = false) {
377
377
  }
378
378
  /** The shared delivering tail of `always` and a CHANGED `on-change`: push
379
379
  * stdout to the armed sink, or take the failure path when the sink is
380
- * undeliverable. (The two cases used to share a commented, lint-suppressed
381
- * switch fallthrough.) */
380
+ * undeliverable. */
382
381
  async function deliverOrEscalate(c, ctx, outOfBand, prefix) {
383
382
  if (ctx.stdoutHead.trim() === '') {
384
383
  return { delivered: `${prefix}empty stdout — nothing delivered`, paused: false };
@@ -464,8 +463,9 @@ export function recoverStaleCronLeases(now, options = {}) {
464
463
  }
465
464
  /** Wait for a SIGTERMed stale-lease run to actually die, escalating to SIGKILL
466
465
  * if it does not — the same bound `killWithEscalation` gives a live timeout.
467
- * Recovery used to record the run as finished the instant it signaled, while a
468
- * signal-trapping command was still running and still producing side effects. */
466
+ * Recovery must not record the run as finished the instant it signals: a
467
+ * signal-trapping command can still be running and still producing side
468
+ * effects. */
469
469
  async function awaitLeasedRunDeath(c) {
470
470
  const deadline = Date.now() + CRON_KILL_GRACE_MS;
471
471
  while (Date.now() < deadline) {
@@ -477,7 +477,7 @@ async function awaitLeasedRunDeath(c) {
477
477
  }
478
478
  /** Settle a stale lease whose process can no longer be observed: the daemon
479
479
  * life that owned this run ended (SIGKILL, host reboot) and nothing recorded
480
- * the outcome. Releasing the lease — all this used to do — is wrong twice.
480
+ * the outcome. Releasing the lease and nothing else is wrong twice.
481
481
  *
482
482
  * 1. The run must leave a RECORD. The run log's whole justification is "it
483
483
  * silently stopped working Tuesday"; a hole exactly where the crash
@@ -799,8 +799,8 @@ export function executeCron(c, opts) {
799
799
  }
800
800
  catch (err) {
801
801
  // Unparseable recurrence. This row can never be scheduled again as
802
- // written — but DELETING it (what this used to do) destroys the user's
803
- // config AND its whole run history with no record and no escalation, the
802
+ // written — but DELETING it would destroy the user's config AND its
803
+ // whole run history with no record and no escalation, the
804
804
  // one outcome the spec has no vocabulary for. Take the failure path
805
805
  // instead: kill the just-started run and hand `close` a process error, so
806
806
  // the row PAUSES and an escalation node arrives holding the parse error.
@@ -3,7 +3,8 @@ import { ApiError } from '../../api/index.js';
3
3
  import { getNode, subscribersOf } from '../../core/canvas/canvas.js';
4
4
  import { ticketDir } from '../../core/human/root.js';
5
5
  import { cancelTicket, projectReviewOutput, readTicketResult, takeoverAndCompleteDeck, } from '../../core/human/tickets.js';
6
- import { readJson, responsePath } from '../../core/human/convention.js';
6
+ import { responsePath } from '../../core/human/convention.js';
7
+ import { readJsonOrNull } from '../../core/fs-utils.js';
7
8
  import { appendInbox } from '../../core/feed/inbox.js';
8
9
  import { FinalDeliveryError, pushFinal } from '../../core/feed/feed.js';
9
10
  import { transition } from '../../core/runtime/lifecycle.js';
@@ -67,7 +68,7 @@ export async function cancelHumanTicket(nodeId, opts) {
67
68
  */
68
69
  export async function deliverTerminalResult(nodeId) {
69
70
  const dir = ticketDir(nodeId);
70
- const run = readJson(join(dir, 'run.json'));
71
+ const run = readJsonOrNull(join(dir, 'run.json'));
71
72
  if (run?.mode === 'notify')
72
73
  return false;
73
74
  const result = readTicketResult(dir);