@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
@@ -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;
@@ -18,7 +27,7 @@ export declare function listProfiles(): ProfileEntry[];
18
27
  * actually-enumerated dirs, never a blind join), then a unique manifest
19
28
  * `name` match. Ambiguous names fail listing every matching id; no match
20
29
  * fails naming `profile list`/`profile new` as recovery. This is the ONLY
21
- * function every command leaf and future runtime consumer (Phase 5/6) should
30
+ * function every command leaf and runtime consumer should
22
31
  * call to turn a `<profile>` operand or `CRTR_PROFILE_ID` into a concrete,
23
32
  * safe profile id. */
24
33
  export declare function loadProfileManifest(profileIdOrName: string): ProfileEntry;
@@ -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 });
@@ -110,7 +147,7 @@ export function listProfiles() {
110
147
  * actually-enumerated dirs, never a blind join), then a unique manifest
111
148
  * `name` match. Ambiguous names fail listing every matching id; no match
112
149
  * fails naming `profile list`/`profile new` as recovery. This is the ONLY
113
- * function every command leaf and future runtime consumer (Phase 5/6) should
150
+ * function every command leaf and runtime consumer should
114
151
  * call to turn a `<profile>` operand or `CRTR_PROFILE_ID` into a concrete,
115
152
  * safe profile id. */
116
153
  export function loadProfileManifest(profileIdOrName) {
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Internal review/comment/error vocabulary and constants.
3
- * Phase 4 implementation: daemon-owned review lifecycle and comment authority.
3
+ * Backs the daemon-owned review lifecycle and comment authority.
4
4
  * This module declares no HTTP status, DTOs, or I/O.
5
5
  */
6
6
  import type { FeedbackComment } from '../human/types.js';
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Internal review/comment/error vocabulary and constants.
3
- * Phase 4 implementation: daemon-owned review lifecycle and comment authority.
3
+ * Backs the daemon-owned review lifecycle and comment authority.
4
4
  * This module declares no HTTP status, DTOs, or I/O.
5
5
  */
6
6
  /** Limits */
@@ -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
@@ -620,8 +620,8 @@ export function createFrameDispatchContext(deps) {
620
620
  }
621
621
  };
622
622
  // -------------------------------------------------------------------------
623
- // Command-op helpers (T3, §2.3). The write guard is hoisted here (it was
624
- // inlined twice) and reused by all mutating ops; the ack/error replies
623
+ // Command-op helpers. The write guard is hoisted here and reused by all
624
+ // mutating ops; the ack/error replies
625
625
  // and a few resolvers keep the per-op cases one-liners.
626
626
  // -------------------------------------------------------------------------
627
627
  /** Reject a read-only client for a mutating op. The decision reads this
@@ -841,7 +841,7 @@ export function createFrameDispatchContext(deps) {
841
841
  };
842
842
  const handleEngineControlFrame = (client, frame) => {
843
843
  switch (frame.type) {
844
- // --- extended engine-command ops (T3, §1.2 floor set) ------------------
844
+ // --- extended engine-command ops -------------------------------------
845
845
  case 'set_model': {
846
846
  if (notWritable(client, 'set the model'))
847
847
  break;
@@ -1070,7 +1070,7 @@ export function createFrameDispatchContext(deps) {
1070
1070
  // engine, so it is not a controller-gated op. Terminal observer
1071
1071
  // connections use it to populate the command palette.
1072
1072
  // The merged command list rides in ack.detail as JSON (the foundation's
1073
- // AckFrame.detail field) — the viewer (T6) JSON.parses it when for ===
1073
+ // AckFrame.detail field) — the viewer JSON.parses it when for ===
1074
1074
  // 'get_commands'. Keeps every command op a uniform ack reply.
1075
1075
  try {
1076
1076
  ackTo(client, 'get_commands', true, JSON.stringify(buildCommandList(currentSession())));
@@ -1,6 +1,6 @@
1
1
  // broker-cli.ts — the headless broker's dedicated detached entry (plan T4 /
2
2
  // decision §1.13). Spawned directly via `spawn(execPath, [thisFile, nodeId], …)`
3
- // by HeadlessBrokerHost.launch (T6) — NEVER routed through src/cli.ts, which
3
+ // by HeadlessBrokerHost.launch — NEVER routed through src/cli.ts, which
4
4
  // would run the whole bootstrap chain (auto-update / scope-init / slash-template
5
5
  // rewrite) on every broker boot. Mirrors src/daemon/crtrd-cli.ts: parse the one
6
6
  // positional arg and hand off; keep this file a thin shim.
@@ -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();
@@ -47,7 +47,7 @@ export interface BrokerSnapshot {
47
47
  title: string | undefined;
48
48
  };
49
49
  /** Broker-owned summaries keyed by the first tool-call id in each group.
50
- * Structured objects with bullets, nodesSpawned, and filesEdited.
50
+ * Structured objects with bullets, nodesSpawned, and filesDeleted.
51
51
  * Optional so snapshots from an older live runtime remain readable. */
52
52
  toolGroupSummaries?: Record<string, ToolGroupSummary>;
53
53
  /** The broker-selected normal activity label for the current turn. Optional so
@@ -68,7 +68,7 @@ export interface HelloFrame {
68
68
  };
69
69
  }
70
70
  /** Drive the engine — writable (`controller`) clients only. Map 1:1 to session.prompt/steer/followUp/abort.
71
- * `images` carries pasted/attached images to the engine (review M1): pi accepts
71
+ * `images` carries pasted/attached images to the engine: pi accepts
72
72
  * `prompt(text,{images})` / `steer(text,images)` / `followUp(text,images)` at
73
73
  * 0.78.1. The wire TYPE lives here; T3/T6 wire the runtime side. The BROKER read
74
74
  * cap (`BROKER_READ_CAPS`) is sized to hold a resizeImage-bounded PNG's base64. */
@@ -612,15 +612,14 @@ export declare class FrameOverflowError extends Error {
612
612
  }
613
613
  /** Caps the CLIENT uses reading BROKER frames. The `welcome.snapshot` carries the
614
614
  * full message history and can be many MiB, so these are generous. Covers
615
- * realistic long sessions; snapshot CHUNKING is deferred (plan §1.1 known
616
- * limitation, review m3 — the long-lived broker makes a big welcome a realistic,
617
- * not pathological, trigger, but the fixed cap is accepted for Phase 4). */
615
+ * realistic long sessions; snapshot CHUNKING is deferred — the long-lived
616
+ * broker makes a big welcome a realistic, not pathological, trigger, but the
617
+ * fixed cap is accepted. */
618
618
  export declare const CLIENT_READ_CAPS: FrameDecoderCaps;
619
- /** Caps the BROKER uses reading CLIENT frames. Plan §1.1 specified a tight
620
- * 4/16 MiB, but review M1 requires image-paste frames to fit: a resizeImage-
621
- * bounded PNG's base64 is a few MiB and would clip a 4 MiB line cap. So these are
622
- * RAISED above the plan's 4/16 to hold an image-bearing `prompt`/`steer`/
623
- * `follow_up` frame (M1). Still bounded, so a malicious/buggy client→broker frame
619
+ /** Caps the BROKER uses reading CLIENT frames. Sized so an image-paste frame
620
+ * fits: a resizeImage-bounded PNG's base64 is a few MiB and would clip a
621
+ * tighter 4 MiB line cap, so these hold an image-bearing `prompt`/`steer`/
622
+ * `follow_up` frame. Still bounded, so a malicious/buggy client→broker frame
624
623
  * is cap-and-dropped, never grow-to-OOM (C5). */
625
624
  export declare const BROKER_READ_CAPS: FrameDecoderCaps;
626
625
  /** Incremental newline-delimited JSON reader: feed raw socket chunks, get back
@@ -1,7 +1,7 @@
1
1
  // broker-protocol.ts — the client↔broker wire protocol for the headless node
2
2
  // Surface (design §5.2). Pure types + a newline-delimited JSON codec; no I/O, no
3
- // SDK construction. Consumed by the broker (src/core/runtime/broker.ts) and, in
4
- // Phase 4, by the `crtr surface attach` terminal client and crtrd's attach bridge.
3
+ // SDK construction. Consumed by the broker (src/core/runtime/broker.ts), the
4
+ // `crtr surface attach` terminal client, and crtrd's attach bridge.
5
5
  //
6
6
  // The transport is one unix socket per node (`nodeDir(id)/view.sock`) speaking
7
7
  // newline-delimited JSON frames. Live engine events are relayed VERBATIM — the
@@ -43,18 +43,17 @@ export class FrameOverflowError extends Error {
43
43
  }
44
44
  /** Caps the CLIENT uses reading BROKER frames. The `welcome.snapshot` carries the
45
45
  * full message history and can be many MiB, so these are generous. Covers
46
- * realistic long sessions; snapshot CHUNKING is deferred (plan §1.1 known
47
- * limitation, review m3 — the long-lived broker makes a big welcome a realistic,
48
- * not pathological, trigger, but the fixed cap is accepted for Phase 4). */
46
+ * realistic long sessions; snapshot CHUNKING is deferred — the long-lived
47
+ * broker makes a big welcome a realistic, not pathological, trigger, but the
48
+ * fixed cap is accepted. */
49
49
  export const CLIENT_READ_CAPS = {
50
50
  maxLineBytes: 256 * 1024 * 1024,
51
51
  maxTotalBytes: 256 * 1024 * 1024,
52
52
  };
53
- /** Caps the BROKER uses reading CLIENT frames. Plan §1.1 specified a tight
54
- * 4/16 MiB, but review M1 requires image-paste frames to fit: a resizeImage-
55
- * bounded PNG's base64 is a few MiB and would clip a 4 MiB line cap. So these are
56
- * RAISED above the plan's 4/16 to hold an image-bearing `prompt`/`steer`/
57
- * `follow_up` frame (M1). Still bounded, so a malicious/buggy client→broker frame
53
+ /** Caps the BROKER uses reading CLIENT frames. Sized so an image-paste frame
54
+ * fits: a resizeImage-bounded PNG's base64 is a few MiB and would clip a
55
+ * tighter 4 MiB line cap, so these hold an image-bearing `prompt`/`steer`/
56
+ * `follow_up` frame. Still bounded, so a malicious/buggy client→broker frame
58
57
  * is cap-and-dropped, never grow-to-OOM (C5). */
59
58
  export const BROKER_READ_CAPS = {
60
59
  maxLineBytes: 24 * 1024 * 1024,
@@ -15,7 +15,7 @@ export interface BrokerEngine {
15
15
  createAgentSessionServices: typeof createAgentSessionServices;
16
16
  createAgentSessionFromServices: typeof createAgentSessionFromServices;
17
17
  /**
18
- * The session-replacement runtime factory (T3 new_session/switch_session/fork).
18
+ * The session-replacement runtime factory (new_session/switch_session/fork).
19
19
  * OPTIONAL: the real SDK (0.78.1) exposes it, so production gets full session
20
20
  * replacement; the `fake-engine` test fixture does NOT provide it, so the
21
21
  * broker degrades those three ops to an `error{engine_error}` reply rather than
@@ -1,4 +1,4 @@
1
- // broker.ts — the headless node broker (design §4, §5; plan T4).
1
+ // broker.ts — the headless node broker (design §4, §5).
2
2
  //
3
3
  // One broker process per node — the SOLE host. It hosts ONE pi engine IN-PROCESS
4
4
  // via the SDK (createAgentSession), is the SOLE writer of the node's session
@@ -81,9 +81,9 @@ function formatExactModelSpec(model, thinkingLevel) {
81
81
  return base === null ? null : `${base}:${thinkingLevel === undefined || thinkingLevel === '' ? 'off' : thinkingLevel}`;
82
82
  }
83
83
  // ---------------------------------------------------------------------------
84
- // Tunables (T3 backpressure / T4 dialog anti-deadlock)
84
+ // Tunables (backpressure / dialog anti-deadlock)
85
85
  // ---------------------------------------------------------------------------
86
- /** Broker-side default dialog timeout (C2 anti-deadlock, T4). When an extension
86
+ /** Broker-side default dialog timeout (anti-deadlock). When an extension
87
87
  * dialog is forwarded to every writable client, the broker ALWAYS arms a timeout
88
88
  * (this default, or a shorter per-dialog `opts.timeout` if the extension passed
89
89
  * one) so unanswered dialogs — including ones whose writable viewers all detach
@@ -167,7 +167,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
167
167
  };
168
168
  // Defensive: any `crtr` child the engine/extensions spawn must inherit the
169
169
  // front-door recursion guard (see
170
- // src/core/runtime/.crouter/memory/INDEX.md). The host (T6) also sets this;
170
+ // src/core/runtime/.crouter/memory/INDEX.md). The host also sets this;
171
171
  // setting it here keeps the broker self-sufficient.
172
172
  process.env[FRONT_DOOR_ENV] = '1';
173
173
  // A successful in-turn crtr command stamps this process id into its stop
@@ -203,7 +203,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
203
203
  // 2–4. Build the engine session via the SERVICES path (C3) — see
204
204
  // buildBrokerSession below. Register it so the FATAL exit path (M3) can
205
205
  // dispose it and reap detached bash children.
206
- // `session` + `services` are MUTABLE holders (T3 session-rebind): when the
206
+ // `session` + `services` are MUTABLE holders (session-rebind): when the
207
207
  // controller drives new_session/switch_session/fork, the AgentSessionRuntime
208
208
  // tears down the old session and builds the next one, then runs our rebind
209
209
  // callback which reassigns these. Every closure below (buildSnapshot,
@@ -411,7 +411,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
411
411
  registry.forEachHelloed(sendWelcome);
412
412
  };
413
413
  // -------------------------------------------------------------------------
414
- // The single exit helper (plan §0): dispose the engine, close + unlink the
414
+ // The single exit helper: dispose the engine, close + unlink the
415
415
  // socket, exit 0. Idempotent — every trigger converges here.
416
416
  // -------------------------------------------------------------------------
417
417
  const completeExit = (status) => {
@@ -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
  *