@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
@@ -14,6 +14,7 @@ import { detectNerdFont, nerdFontInstallCommand, NERD_FONT_SELECT_HINT } from '.
14
14
  import { ensureRenderer, isRendererReady } from '../../core/termrender/termrender.js';
15
15
  import { installFromMarketplace } from '../pkg/plugin-manage.js';
16
16
  import { findPluginByName } from '../../core/resolver.js';
17
+ import { expandTilde } from '../../core/fs-utils.js';
17
18
  function isKeybindingsObject(value) {
18
19
  return value !== null && typeof value === 'object' && !Array.isArray(value);
19
20
  }
@@ -214,13 +215,6 @@ export function buildSetupManifest(resolveBuiltinPackageDir = builtinPiPackageDi
214
215
  };
215
216
  });
216
217
  }
217
- function expandTilde(pathValue, homeDir) {
218
- if (pathValue === '~')
219
- return homeDir;
220
- if (pathValue.startsWith('~/'))
221
- return join(homeDir, pathValue.slice(2));
222
- return pathValue;
223
- }
224
218
  function npmPackageName(source) {
225
219
  if (!source.startsWith('npm:'))
226
220
  return null;
@@ -32,13 +32,11 @@ import { parseFrontmatterGeneric } from '../../core/frontmatter.js';
32
32
  import { pathExists, readJsonIfExists, readText, walkFiles, writeText } from '../../core/fs-utils.js';
33
33
  import { serializeMemoryDoc } from '../memory/shared.js';
34
34
  import { routeFromDescription, scalarString } from './sync-shared.js';
35
+ import { isRecord } from '../../shared/predicates.js';
35
36
  /** Marker value written to the generated top INDEX's `generated-by` field —
36
37
  * the wipe guard: `deps` only ever deletes a deps/ dir whose INDEX carries
37
38
  * this marker. */
38
39
  export const DEPS_GENERATED_MARKER = 'crtr sys sync deps';
39
- function isRecord(value) {
40
- return typeof value === 'object' && value !== null && !Array.isArray(value);
41
- }
42
40
  function invalidDepsConfig(message) {
43
41
  throw usage(`--deps: invalid package.json crtr.sync.deps.exclude: ${message}`);
44
42
  }
@@ -3,7 +3,7 @@
3
3
  // `.crouter/memory/`. Each project guide becomes the root INDEX front door;
4
4
  // rules retain their explicit path routes, or use the workspace-open `.` route
5
5
  // when they are pathless.
6
- import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
6
+ import { existsSync, readdirSync, statSync } from 'node:fs';
7
7
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
8
8
  import { defineLeaf } from '../../core/command.js';
9
9
  import { parseFrontmatterGeneric } from '../../core/frontmatter.js';
@@ -13,6 +13,7 @@ import { loadProfileManifest } from '../../core/profiles/manifest.js';
13
13
  import { CRTR_DIR_NAME } from '../../types.js';
14
14
  import { memoryFilePath, serializeMemoryDoc } from '../memory/shared.js';
15
15
  import { normalizedName, routeFromDescription, scalarString } from './sync-shared.js';
16
+ import { realpathOrSelf } from '../../core/fs-utils.js';
16
17
  // Directories a project-doc walk never descends into — build/dependency trees
17
18
  // plus crouter's own state dir.
18
19
  const PROJECT_DOC_JUNK_DIRS = new Set([
@@ -136,16 +137,6 @@ function gitWorktreeRoot(dir) {
136
137
  const top = res.stdout.trim();
137
138
  return top === '' ? undefined : resolve(top);
138
139
  }
139
- /** `realpathSync`, tolerant of a path that doesn't exist or can't be resolved
140
- * (falls back to the path as given). */
141
- function realpathOrSelf(p) {
142
- try {
143
- return realpathSync(p);
144
- }
145
- catch {
146
- return p;
147
- }
148
- }
149
140
  /** First Markdown H1 heading in the doc body, heading marker stripped. */
150
141
  function h1Title(body) {
151
142
  for (const raw of body.split('\n')) {
@@ -7,9 +7,7 @@ import { jobDir } from '../../core/canvas/paths.js';
7
7
  import { openNodeWindow } from '../../core/runtime/placement-tmux.js';
8
8
  import { ApiError } from '../../api/index.js';
9
9
  import { cliClient } from '../api-client.js';
10
- function shellQuote(s) {
11
- return `'${s.replace(/'/g, `'\\''`)}'`;
12
- }
10
+ import { shellQuote } from '../../shared/shell-quote.js';
13
11
  export const sysSyspromptLeaf = defineLeaf({
14
12
  name: 'sysprompt',
15
13
  description: 'print a node\'s assembled system prompt',
@@ -35,7 +35,7 @@ test('concurrent processes reconcile one coherent branded host tuple', async ()
35
35
  assert.ok(deadOwner !== undefined);
36
36
  const abandonedLock = join(home, '.crouter', 'host.lock');
37
37
  mkdirSync(abandonedLock);
38
- writeFileSync(join(abandonedLock, 'owner.json'), JSON.stringify({ pid: deadOwner, token: 'abandoned' }));
38
+ writeFileSync(join(abandonedLock, `owner.${deadOwner}.abandoned`), '');
39
39
  try {
40
40
  const moduleUrl = pathToFileURL(join(process.cwd(), 'src/core/runtime/branded-host.ts')).href;
41
41
  const results = await Promise.all(Array.from({ length: 4 }, () => reconcileInChild(home, moduleUrl)));
@@ -139,7 +139,7 @@ export class SessionManager {
139
139
  }
140
140
  }
141
141
  // ---------------------------------------------------------------------------
142
- // AgentSessionEvent stream synthesis (T8 / G1, G3, G4, G8). The fake emits a
142
+ // AgentSessionEvent stream synthesis. The fake emits a
143
143
  // REALISTIC streaming assistant turn through session.subscribe — the SAME channel
144
144
  // the real pi engine feeds the broker's fan-out — so the frames a `crtr surface attach`
145
145
  // client receives are byte-for-byte real AgentSessionEvents. The EMITTED event
@@ -37,7 +37,7 @@ export interface HarnessOpts {
37
37
  * harness does NOT gate on tmux, does NOT eagerly create the isolated tmux
38
38
  * session, and skips the per-pane `set-environment` seeding — nothing in a
39
39
  * headless run opens a window. CRTR_NODE_SESSION/CRTR_ROOT_SESSION are still
40
- * scrubbed/restored for env hygiene, but no longer route placement: a managed
40
+ * scrubbed/restored for env hygiene, but do not route placement: a managed
41
41
  * child opens NO viewer (it boots a detached broker, supervised by pid), and a
42
42
  * --root opens one viewer in the caller's current session. Headless tests must
43
43
  * use `spawnHeadlessChild`, never `spawnChild`. See createHeadlessHarness(). */
@@ -312,7 +312,7 @@ export async function createHarness(opts = {}) {
312
312
  }
313
313
  // Pre-create the isolated session on the DEFAULT server so teardown always
314
314
  // has a kill-sessions target.
315
- // ISOLATION ASSUMPTION (see header + MINOR-6): isolation is by SESSION NAME on
315
+ // ISOLATION ASSUMPTION (see header): isolation is by SESSION NAME on
316
316
  // the DEFAULT tmux server only. The runtime CLI shells `tmux` with no `-L`, so
317
317
  // a custom-socket server (`tmux -L foo`) would be invisible to it; this harness
318
318
  // therefore assumes the default socket and only ever kill-sessions, never the
@@ -13,7 +13,7 @@ import { join } from 'node:path';
13
13
  import { closeDb } from '../canvas/db.js';
14
14
  import { resetScopeCache } from '../scope.js';
15
15
  import { spawnNode } from '../runtime/nodes.js';
16
- import { renderOnReadDocs } from '../substrate/on-read.js';
16
+ import { renderOnReadDocs } from '../substrate/on-read-node.js';
17
17
  let canvasHome;
18
18
  let fakeHome;
19
19
  let prevHomeEnv;
@@ -13,7 +13,7 @@ import { join } from 'node:path';
13
13
  import { closeDb } from '../canvas/db.js';
14
14
  import { resetScopeCache } from '../scope.js';
15
15
  import { spawnNode } from '../runtime/nodes.js';
16
- import { renderOnReadDocs } from '../substrate/on-read.js';
16
+ import { renderOnReadDocs } from '../substrate/on-read-node.js';
17
17
  import { loadInjectedDocs, saveInjectedDocs, clearInjectedDocs, } from '../substrate/injected-store.js';
18
18
  const FIXTURE_BODY = 'Fixture body that must surface exactly once.';
19
19
  let home;
@@ -148,7 +148,7 @@ export function reconcileBootLiveness(current = currentBootIdentity()) {
148
148
  const rows = db
149
149
  .prepare('SELECT node_id FROM nodes WHERE pi_pid IS NOT NULL')
150
150
  .all();
151
- // Clear the launch-time identity baseline (crouter#98 review finding 1)
151
+ // Clear the launch-time identity baseline
152
152
  // alongside `pi_pid` — a boot resets the OS process table, so any recorded
153
153
  // identity is as meaningless as the pid it was captured for; leaving it
154
154
  // behind would let a coincidentally-reused pid on the NEW boot compare
@@ -358,8 +358,8 @@ export async function runBrowse(opts) {
358
358
  // Coalesced search repaint. The expensive work per keystroke is recompute()
359
359
  // (a fuzzyMatch flatten over the whole corpus) + a full renderFrame. Doing it
360
360
  // synchronously on every key blocks the event loop, so fast typing batches into
361
- // the stdin reads — and batched chunks used to be dropped (see onKeySearch).
362
- // Instead, each key only mutates state.query (cheap) and marks the view dirty;
361
+ // the stdin reads, where a naive per-key handler drops batched chunks (see
362
+ // onKeySearch). Instead, each key only mutates state.query (cheap) and marks the view dirty;
363
363
  // a single setImmediate drains AFTER every queued stdin 'data' callback (poll
364
364
  // phase) has appended its chars, so N rapid keys collapse to ONE recompute+paint
365
365
  // and no keystroke is ignored. The sort may still take a moment — that's fine.
@@ -33,7 +33,7 @@ export declare function setPresence(nodeId: string, presence: {
33
33
  pane?: string | null;
34
34
  }): void;
35
35
  /** Record the live pi pid (daemon liveness signal) PLUS its launch-time
36
- * identity fingerprint, in one atomic write (crouter#98 review finding 1):
36
+ * identity fingerprint, in one atomic write:
37
37
  * `capturePidIdentities([pid])` is a signal-0-adjacent `ps -p <pid>` probe
38
38
  * taken RIGHT NOW, while `pid` is the process we just spawned/bound and
39
39
  * therefore definitely still exists — this is the LAUNCH-time baseline
@@ -242,7 +242,7 @@ export type TerminalGuardResult<T> = {
242
242
  } | {
243
243
  kind: 'missing';
244
244
  };
245
- /** #343: close the TOCTOU between checking a target has a natural cycle ahead
245
+ /** Close the TOCTOU between checking a target has a natural cycle ahead
246
246
  * of it and the write that depends on that (for example, a deferred inbox
247
247
  * append). Fresh-reads `nodeId`'s
248
248
  * (status, final_report) and either runs `body` (a write) or short-circuits
@@ -254,7 +254,7 @@ export type TerminalGuardResult<T> = {
254
254
  * write left open. Pass the shared `hasNoNaturalCycle` predicate rather than
255
255
  * a bespoke one so the two never drift.
256
256
  *
257
- * #343 review (Major): a dangling `nodeId` — the row deleted after the
257
+ * A dangling `nodeId` — the row deleted after the
258
258
  * caller's own selection but before this guard's fresh read — MUST NEVER run
259
259
  * `body`. Reporting `'missing'` distinctly from `'terminal'` (rather than
260
260
  * folding it into either the terminal branch or a fabricated "safe" empty
@@ -243,7 +243,7 @@ export function setPresence(nodeId, presence) {
243
243
  .run(presence.tmux_session ?? null, presence.window ?? null, presence.pane ?? null, nodeId);
244
244
  }
245
245
  /** Record the live pi pid (daemon liveness signal) PLUS its launch-time
246
- * identity fingerprint, in one atomic write (crouter#98 review finding 1):
246
+ * identity fingerprint, in one atomic write:
247
247
  * `capturePidIdentities([pid])` is a signal-0-adjacent `ps -p <pid>` probe
248
248
  * taken RIGHT NOW, while `pid` is the process we just spawned/bound and
249
249
  * therefore definitely still exists — this is the LAUNCH-time baseline
@@ -633,7 +633,7 @@ export function settleDeadMessageWait(nodeId, controller, deliver) {
633
633
  return result;
634
634
  });
635
635
  }
636
- /** #343: close the TOCTOU between checking a target has a natural cycle ahead
636
+ /** Close the TOCTOU between checking a target has a natural cycle ahead
637
637
  * of it and the write that depends on that (for example, a deferred inbox
638
638
  * append). Fresh-reads `nodeId`'s
639
639
  * (status, final_report) and either runs `body` (a write) or short-circuits
@@ -645,7 +645,7 @@ export function settleDeadMessageWait(nodeId, controller, deliver) {
645
645
  * write left open. Pass the shared `hasNoNaturalCycle` predicate rather than
646
646
  * a bespoke one so the two never drift.
647
647
  *
648
- * #343 review (Major): a dangling `nodeId` — the row deleted after the
648
+ * A dangling `nodeId` — the row deleted after the
649
649
  * caller's own selection but before this guard's fresh read — MUST NEVER run
650
650
  * `body`. Reporting `'missing'` distinctly from `'terminal'` (rather than
651
651
  * folding it into either the terminal branch or a fabricated "safe" empty
@@ -1,5 +1,4 @@
1
- // daemon-owner.ts — the canvas-layer authoritative daemon ownership primitive
2
- // (Phase 1 runtime-boundary, task D0).
1
+ // daemon-owner.ts — the canvas-layer authoritative daemon ownership primitive.
3
2
  //
4
3
  // A pidfile check followed by a later write is not singleton ownership: two
5
4
  // `crtrd` invocations racing a cold start can both read "no pidfile", both
@@ -58,10 +58,9 @@ function addNodeColumnIfMissing(db, columns, name, ddl) {
58
58
  db.exec(ddl);
59
59
  columns.add(name);
60
60
  }
61
- /** v2 — additive runtime columns the keystone (Phase 2) will make
62
- * authoritative: `intent, pi_pid, window, tmux_session`. `status` already
63
- * lives in the baseline row, so it is NOT re-added here. All four default to
64
- * NULL, so nothing observes a behavior change until a later phase reads them. */
61
+ /** v2 — the additive runtime columns: `intent, pi_pid, window, tmux_session`.
62
+ * `status` already lives in the baseline row, so it is NOT re-added here. All
63
+ * four default to NULL; v3 backfills them. */
65
64
  function addRuntimeColumns(db) {
66
65
  const columns = nodeColumns(db);
67
66
  addNodeColumnIfMissing(db, columns, 'intent', `ALTER TABLE nodes ADD COLUMN intent TEXT;`);
@@ -69,14 +68,14 @@ function addRuntimeColumns(db) {
69
68
  addNodeColumnIfMissing(db, columns, 'window', `ALTER TABLE nodes ADD COLUMN window TEXT;`);
70
69
  addNodeColumnIfMissing(db, columns, 'tmux_session', `ALTER TABLE nodes ADD COLUMN tmux_session TEXT;`);
71
70
  }
72
- /** v3 — DATA backfill (keystone, Phase 2). The runtime fields
71
+ /** v3 — DATA backfill. The runtime fields
73
72
  * (`intent, pi_pid, window, tmux_session`) become authoritative in the row;
74
73
  * copy each existing node's values out of its meta.json into the row columns
75
74
  * once, so the version boundary loses no live state. `status` already mirrors
76
75
  * the row, so it is not re-copied.
77
76
  *
78
- * LAYERING NOTE (explicitly sanctioned by the runtime-fix plan): a *data*
79
- * migration must read meta.json, which db.ts normally would not. Reading it
77
+ * LAYERING NOTE — a sanctioned exception: a *data* migration must read
78
+ * meta.json, which db.ts normally would not. Reading it
80
79
  * directly here — via paths.ts, a one-time, clearly-labeled boot-time data
81
80
  * migration — is the deliberate choice over splitting the `user_version`
82
81
  * counter across two modules. Idempotent and gated: it runs exactly once at the
@@ -367,7 +366,7 @@ function addProfileIdColumn(db) {
367
366
  * identity fingerprint (`pid.ts`'s `composeIdentity`) recorded alongside
368
367
  * `pi_pid` (v2) by `recordPid`, so `headlessBrokerHost.teardown()` can refuse
369
368
  * to signal a `pi_pid` the OS has since recycled for an unrelated process
370
- * (crouter#98 final review, Fix 1 — see `NodeRuntime.pi_pid_identity`).
369
+ * (see `NodeRuntime.pi_pid_identity`).
371
370
  * Defaults NULL ⇒ no baseline ⇒ the guard fails open, exactly like a node
372
371
  * booted before this column existed. No data backfill: an existing `pi_pid`
373
372
  * with no recorded identity simply has no launch-time baseline to compare
@@ -1,4 +1,4 @@
1
- export declare const CANVAS_EXTENSION_NAMES: readonly ["canvas-inbox-watcher", "canvas-review-boundary", "canvas-stophook", "canvas-recap", "canvas-goal-capture", "canvas-passive-context", "canvas-context-intro", "canvas-tool-guide", "canvas-doc-substrate", "canvas-structured-output", "canvas-bash-valve"];
1
+ export declare const CANVAS_EXTENSION_NAMES: readonly ["canvas-inbox-watcher", "canvas-review-boundary", "canvas-stophook", "canvas-recap", "canvas-goal-capture", "canvas-passive-context", "canvas-context-intro", "canvas-tool-guide", "canvas-doc-substrate", "canvas-structured-output", "canvas-bash-valve", "canvas-preview-result"];
2
2
  /** Structural shipped-hook identity: a canvas-hook stem directly under a
3
3
  * `pi-extensions` directory. A user's own extension elsewhere may share the
4
4
  * stem and is not shipped. */
@@ -7,9 +7,15 @@ export const CANVAS_EXTENSION_NAMES = [
7
7
  'canvas-inbox-watcher', 'canvas-review-boundary', 'canvas-stophook', 'canvas-recap',
8
8
  'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
9
9
  'canvas-tool-guide', 'canvas-doc-substrate', 'canvas-structured-output',
10
- 'canvas-bash-valve',
10
+ 'canvas-bash-valve', 'canvas-preview-result',
11
11
  ];
12
12
  const HISTORICAL_CANVAS_EXTENSION_PREFIXES = [
13
+ [
14
+ 'canvas-inbox-watcher', 'canvas-review-boundary', 'canvas-stophook', 'canvas-recap',
15
+ 'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
16
+ 'canvas-tool-guide', 'canvas-doc-substrate', 'canvas-structured-output',
17
+ 'canvas-bash-valve',
18
+ ],
13
19
  [
14
20
  'canvas-inbox-watcher', 'canvas-stophook', 'canvas-recap',
15
21
  'canvas-goal-capture', 'canvas-passive-context', 'canvas-review-boundary',
@@ -1,5 +1,5 @@
1
- // The canvas: one global graph of nodes + edges. Phase 0 of the pi-native
2
- // agent runtime. Topology in sqlite (WAL), node flesh on disk.
1
+ // The canvas: one global graph of nodes + edges.
2
+ // Topology in sqlite (WAL), node flesh on disk.
3
3
  export * from './types.js';
4
4
  export * from './labels.js';
5
5
  export * from './paths.js';
@@ -8,8 +8,7 @@ export declare function isPidAlive(pid: number | null | undefined): boolean;
8
8
  * `composeIdentity` fingerprints are compared (this module's own
9
9
  * `recordedPidLiveness`, `captureTeardownSnapshot`'s reuse check, and
10
10
  * `killProcessTreePids`'s reuse check) — centralized so no call site can
11
- * drift from this rule (review Major #1, Round 2 of the pid-reuse-liveness
12
- * fix).
11
+ * drift from this rule.
13
12
  *
14
13
  * Two hazards this closes, both in the false-DEAD direction (the dangerous
15
14
  * one — a false DEAD lets `reviveNode`'s double-launch guard relaunch a
@@ -94,7 +93,7 @@ export declare function recordedPidLiveness(pid: number | null | undefined, expe
94
93
  * swallowing ESRCH (already gone). Used by cron cancellation, timeout, and
95
94
  * stale-lease recovery. Lives beside isPidAlive: canvas/ is
96
95
  * the lowest shared layer every process-liveness/-teardown primitive sits at.
97
- * Guards `pid` before signaling (review nit, crouter#98): never hit pid 0
96
+ * Guards `pid` before signaling: never hit pid 0
98
97
  * (which means "my own group") or a negative/non-integer value. */
99
98
  export declare function killProcessGroup(pid: number, signal?: NodeJS.Signals): void;
100
99
  /** The kernel `boot_id` (`/proc/sys/kernel/random/boot_id`) is stable for the
@@ -155,8 +154,8 @@ export interface PsLivenessSnapshot {
155
154
  }
156
155
  export declare function captureLivenessSnapshot(): PsLivenessSnapshot | null;
157
156
  /** Every transitive descendant of `rootPid`, discovered by walking `ps`'s
158
- * pid/ppid table (BFS) — NOT process-GROUP membership. This is the fix for
159
- * crouton-labs/crouter#98: the pi SDK's bash tool spawns its shell child with
157
+ * pid/ppid table (BFS) — NOT process-GROUP membership. The pi SDK's bash tool
158
+ * spawns its shell child with
160
159
  * `detached: true` (verified in `@earendil-works/pi-agent-core`'s
161
160
  * `harness/env/nodejs.js`), which calls `setsid()` and puts that child in a
162
161
  * brand-new process GROUP of its own — so `kill(-rootPid)` never reaches it.
@@ -173,7 +172,7 @@ export declare function descendantPids(rootPid: number): number[];
173
172
  * batched `ps -p <list>` call. `lstart` is a standard format keyword on BOTH
174
173
  * BSD ps (macOS) and procps-ng (Linux), so this needs no `/proc` parsing or
175
174
  * other Linux-only machinery. Used to guard against PID REUSE during the
176
- * multi-second teardown escalation ladder (crouter#98 review finding 3): a
175
+ * multi-second teardown escalation ladder: a
177
176
  * captured pid can exit and the OS can recycle it for an unrelated process
178
177
  * before the SIGKILL rung fires, and a bare `kill(pid, 0)` / `kill(pid, sig)`
179
178
  * cannot tell the difference.
@@ -182,9 +181,9 @@ export declare function descendantPids(rootPid: number): number[];
182
181
  * could not be spawned, or was killed by the timeout): a probe failure means
183
182
  * we have NO information, which is a fundamentally different outcome from a
184
183
  * probe that SUCCEEDED and simply found no row for a given pid (that pid is
185
- * genuinely gone). Collapsing these two into the same empty map was the
186
- * final-review bug (crouter#98 signal-safety finding): `killProcessTreePids`
187
- * cannot tell "pid confirmed gone" from "we don't know" without this
184
+ * genuinely gone). Collapsing these two into the same empty map is unsafe:
185
+ * `killProcessTreePids` cannot tell "pid confirmed gone" from "we don't know"
186
+ * without this
188
187
  * distinction, and misreading a failed probe as positive proof of
189
188
  * absence/reuse either signals a reused stranger or — worse — silently skips
190
189
  * reaping a real live orphan. A successful call may still return a map with
@@ -210,7 +209,7 @@ export declare function capturePidIdentities(pids: readonly number[]): Map<numbe
210
209
  * signal. */
211
210
  export declare function capturePidCommand(pid: number): string | null;
212
211
  /** The full pre-signal snapshot `teardown()` needs, taken ONCE, from ONE `ps`
213
- * table (crouter#98 final review, Fix 1): the process TREE rooted at `rootPid`
212
+ * table: the process TREE rooted at `rootPid`
214
213
  * (itself + every transitive descendant) alongside each member's identity
215
214
  * fingerprint — PLUS, load-bearing, a launch-time-identity REUSE check on
216
215
  * `rootPid` itself before the tree is even walked.
@@ -259,21 +258,20 @@ export declare function captureTeardownSnapshot(rootPid: number, expectedIdentit
259
258
  * caller before sending any signal — see the doc on `isProcessTreeAlive` for
260
259
  * why re-deriving via `ps` after signaling starts is unsafe). Signals EACH
261
260
  * pid's OWN process group (a detached SDK-spawned descendant is its own
262
- * group leader — the pi bash tool's shell child, crouton-labs/crouter#98)
261
+ * group leader — the pi bash tool's shell child)
263
262
  * PLUS a plain `kill(pid)` on each (belt-and-suspenders for a pid that is
264
263
  * NOT its own group leader). Every signal is best-effort — `killProcessGroup`
265
264
  * swallows ESRCH and the plain `kill` here does too — so firing this against
266
265
  * an already-fully-dead snapshot is a harmless no-op.
267
266
  *
268
- * `identities`, when supplied (crouter#98 review finding 3), is a
267
+ * `identities`, when supplied, is a
269
268
  * `capturePidIdentities` snapshot taken alongside the pid list at the SAME
270
269
  * moment — `null` if that INITIAL capture's `ps` probe itself failed (see
271
270
  * `capturePidIdentities`'s doc). Before signaling EITHER the group or the
272
271
  * plain kill for a pid, this re-derives CURRENT identities in one fresh
273
272
  * batched call and compares — but ONLY skips on POSITIVE proof of reuse; any
274
273
  * failure or absence of evidence falls through to the normal signal path
275
- * (fail-open, matching this module's pre-guard behavior — final review,
276
- * crouter#98 signal-safety finding):
274
+ * (fail-open, matching this module's unguarded behavior):
277
275
  *
278
276
  * - baseline identity present AND the current probe SUCCEEDED AND the
279
277
  * current identity for this pid is present AND DIFFERENT — the OS has
@@ -4,9 +4,8 @@
4
4
  // LOWEST shared layer — so canvas/, runtime/, AND daemon/ can all import it
5
5
  // "down" without a reverse-layer violation (runtime/ sits above canvas/, daemon/
6
6
  // above runtime/). It is a pure process-existence utility, NOT data-model access,
7
- // so it is exempt from the "only canvas.ts touches the db" rule. Collapses the
8
- // four near-identical copies that used to live in canvas.ts, revive.ts,
9
- // placement.ts, and crtrd.ts into one (Phase 3 review reuse MINOR-1).
7
+ // so it is exempt from the "only canvas.ts touches the db" rule. canvas.ts,
8
+ // revive.ts, placement.ts, and crtrd.ts all probe liveness through it.
10
9
  import { spawnSync } from 'node:child_process';
11
10
  import { readFileSync } from 'node:fs';
12
11
  import { readKernelBootId } from './boot-id.js';
@@ -62,8 +61,7 @@ const BOOT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12
62
61
  * purely-numeric suffix counts as ticks (the shape `procStartTicks` and
63
62
  * `composeIdentity` guarantee); a malformed or empty suffix (`A#`, `A#bad`,
64
63
  * `A#1#2`) is treated as ABSENT ticks, so the base compare then applies — a
65
- * garbage suffix must never be trusted as a fine discriminator (review
66
- * Minor). */
64
+ * garbage suffix must never be trusted as a fine discriminator. */
67
65
  function splitIdentity(identity) {
68
66
  const i = identity.indexOf('#');
69
67
  if (i === -1)
@@ -75,8 +73,7 @@ function splitIdentity(identity) {
75
73
  * `composeIdentity` fingerprints are compared (this module's own
76
74
  * `recordedPidLiveness`, `captureTeardownSnapshot`'s reuse check, and
77
75
  * `killProcessTreePids`'s reuse check) — centralized so no call site can
78
- * drift from this rule (review Major #1, Round 2 of the pid-reuse-liveness
79
- * fix).
76
+ * drift from this rule.
80
77
  *
81
78
  * Two hazards this closes, both in the false-DEAD direction (the dangerous
82
79
  * one — a false DEAD lets `reviveNode`'s double-launch guard relaunch a
@@ -208,7 +205,7 @@ function logPidLivenessDecision(pid, isPidAliveResult, expectedIdentity, probeOu
208
205
  * swallowing ESRCH (already gone). Used by cron cancellation, timeout, and
209
206
  * stale-lease recovery. Lives beside isPidAlive: canvas/ is
210
207
  * the lowest shared layer every process-liveness/-teardown primitive sits at.
211
- * Guards `pid` before signaling (review nit, crouter#98): never hit pid 0
208
+ * Guards `pid` before signaling: never hit pid 0
212
209
  * (which means "my own group") or a negative/non-integer value. */
213
210
  export function killProcessGroup(pid, signal = 'SIGTERM') {
214
211
  if (!Number.isInteger(pid) || pid <= 0)
@@ -220,8 +217,8 @@ export function killProcessGroup(pid, signal = 'SIGTERM') {
220
217
  /* already gone */
221
218
  }
222
219
  }
223
- /** Linux-only higher-resolution discriminator layered onto `lstart` (crouter#98
224
- * review finding, minor): `ps lstart` is only second-granular, so a PID reused
220
+ /** Linux-only higher-resolution discriminator layered onto `lstart`:
221
+ * `ps lstart` is only second-granular, so a PID reused
225
222
  * within the same wall-clock second compares equal and slips past the reuse
226
223
  * guard. `/proc/<pid>/stat`'s `starttime` field is the kernel's own jiffies-
227
224
  * since-boot counter for that process — effectively unique per pid even for
@@ -395,8 +392,8 @@ function descendantPidsFrom(rootPid, childrenOf) {
395
392
  return out;
396
393
  }
397
394
  /** Every transitive descendant of `rootPid`, discovered by walking `ps`'s
398
- * pid/ppid table (BFS) — NOT process-GROUP membership. This is the fix for
399
- * crouton-labs/crouter#98: the pi SDK's bash tool spawns its shell child with
395
+ * pid/ppid table (BFS) — NOT process-GROUP membership. The pi SDK's bash tool
396
+ * spawns its shell child with
400
397
  * `detached: true` (verified in `@earendil-works/pi-agent-core`'s
401
398
  * `harness/env/nodejs.js`), which calls `setsid()` and puts that child in a
402
399
  * brand-new process GROUP of its own — so `kill(-rootPid)` never reaches it.
@@ -420,7 +417,7 @@ export function descendantPids(rootPid) {
420
417
  * batched `ps -p <list>` call. `lstart` is a standard format keyword on BOTH
421
418
  * BSD ps (macOS) and procps-ng (Linux), so this needs no `/proc` parsing or
422
419
  * other Linux-only machinery. Used to guard against PID REUSE during the
423
- * multi-second teardown escalation ladder (crouter#98 review finding 3): a
420
+ * multi-second teardown escalation ladder: a
424
421
  * captured pid can exit and the OS can recycle it for an unrelated process
425
422
  * before the SIGKILL rung fires, and a bare `kill(pid, 0)` / `kill(pid, sig)`
426
423
  * cannot tell the difference.
@@ -429,9 +426,9 @@ export function descendantPids(rootPid) {
429
426
  * could not be spawned, or was killed by the timeout): a probe failure means
430
427
  * we have NO information, which is a fundamentally different outcome from a
431
428
  * probe that SUCCEEDED and simply found no row for a given pid (that pid is
432
- * genuinely gone). Collapsing these two into the same empty map was the
433
- * final-review bug (crouter#98 signal-safety finding): `killProcessTreePids`
434
- * cannot tell "pid confirmed gone" from "we don't know" without this
429
+ * genuinely gone). Collapsing these two into the same empty map is unsafe:
430
+ * `killProcessTreePids` cannot tell "pid confirmed gone" from "we don't know"
431
+ * without this
435
432
  * distinction, and misreading a failed probe as positive proof of
436
433
  * absence/reuse either signals a reused stranger or — worse — silently skips
437
434
  * reaping a real live orphan. A successful call may still return a map with
@@ -529,21 +526,20 @@ export function captureTeardownSnapshot(rootPid, expectedIdentity) {
529
526
  * caller before sending any signal — see the doc on `isProcessTreeAlive` for
530
527
  * why re-deriving via `ps` after signaling starts is unsafe). Signals EACH
531
528
  * pid's OWN process group (a detached SDK-spawned descendant is its own
532
- * group leader — the pi bash tool's shell child, crouton-labs/crouter#98)
529
+ * group leader — the pi bash tool's shell child)
533
530
  * PLUS a plain `kill(pid)` on each (belt-and-suspenders for a pid that is
534
531
  * NOT its own group leader). Every signal is best-effort — `killProcessGroup`
535
532
  * swallows ESRCH and the plain `kill` here does too — so firing this against
536
533
  * an already-fully-dead snapshot is a harmless no-op.
537
534
  *
538
- * `identities`, when supplied (crouter#98 review finding 3), is a
535
+ * `identities`, when supplied, is a
539
536
  * `capturePidIdentities` snapshot taken alongside the pid list at the SAME
540
537
  * moment — `null` if that INITIAL capture's `ps` probe itself failed (see
541
538
  * `capturePidIdentities`'s doc). Before signaling EITHER the group or the
542
539
  * plain kill for a pid, this re-derives CURRENT identities in one fresh
543
540
  * batched call and compares — but ONLY skips on POSITIVE proof of reuse; any
544
541
  * failure or absence of evidence falls through to the normal signal path
545
- * (fail-open, matching this module's pre-guard behavior — final review,
546
- * crouter#98 signal-safety finding):
542
+ * (fail-open, matching this module's unguarded behavior):
547
543
  *
548
544
  * - baseline identity present AND the current probe SUCCEEDED AND the
549
545
  * current identity for this pid is present AND DIFFERENT — the OS has
@@ -21,8 +21,8 @@ export function createRemoteExec(previewEndpoint, relayToken, timeoutMs = DEFAUL
21
21
  // A malformed stored token (control chars / non-Latin1 bytes) would throw
22
22
  // from the Headers constructor with the invalid value IN the thrown
23
23
  // message — validate up front so that never happens, and fail with a
24
- // GENERIC message instead (Phase 3 review Minor 1: no token bytes may ever
25
- // reach returned stderr). HTTP header values are ISO-8859-1; reject
24
+ // GENERIC message instead: no token bytes may ever reach returned stderr.
25
+ // HTTP header values are ISO-8859-1; reject
26
26
  // anything outside `/^[\x20-\x7e]+$/` (printable ASCII, no CR/LF/control).
27
27
  const tokenValid = /^[\x20-\x7e]+$/.test(relayToken);
28
28
  return async function exec(bin, args) {
@@ -28,7 +28,7 @@ export declare function resolveNodeVisual(status: NodeStatus, opts: {
28
28
  * dashboard flags the node for the human. Exported so a recovery verb (e.g.
29
29
  * `node lifecycle revive --now`, src/commands/node-lifecycle-revive.ts) can gate on the SAME
30
30
  * predicate the dashboard advertises instead of drifting out of sync with it
31
- * (issue #115 — `--now` used to reject exactly the nodes shown as "needs you"). */
31
+ * and rejecting exactly the nodes shown as "needs you". */
32
32
  export declare function faultNeedsYou(fault: Pick<Fault, 'retry'>): boolean;
33
33
  /** Human-friendly active-fault disposition text. `auto` faults show the current
34
34
  * retry attempt plus the next scheduled retry; client-owned redials say
@@ -69,7 +69,7 @@ function retryCountdown(iso, now) {
69
69
  * dashboard flags the node for the human. Exported so a recovery verb (e.g.
70
70
  * `node lifecycle revive --now`, src/commands/node-lifecycle-revive.ts) can gate on the SAME
71
71
  * predicate the dashboard advertises instead of drifting out of sync with it
72
- * (issue #115 — `--now` used to reject exactly the nodes shown as "needs you"). */
72
+ * and rejecting exactly the nodes shown as "needs you". */
73
73
  export function faultNeedsYou(fault) {
74
74
  return fault.retry.disposition !== 'auto';
75
75
  }
@@ -191,7 +191,7 @@ export interface NodeRuntime {
191
191
  /** Stable process-identity fingerprint (`pid.ts`'s `composeIdentity`) captured
192
192
  * at the SAME moment `pi_pid` is recorded (`recordPid`) — the LAUNCH-time
193
193
  * baseline `headlessBrokerHost.teardown()` compares `pi_pid`'s CURRENT
194
- * identity against before signaling anything (crouter#98 review finding 1):
194
+ * identity against before signaling anything:
195
195
  * a mismatch proves the OS has already recycled `pi_pid` for an unrelated
196
196
  * process since this node launched it, and teardown must refuse to signal
197
197
  * that stranger. `null` for a node booted before this field existed, or if
@@ -8,14 +8,12 @@
8
8
  // This is pure validation: no fetch, no registration lookup, no file I/O beyond
9
9
  // the raw manifest bytes passed in.
10
10
  import { validateCommandNode } from './schema.js';
11
+ import { isRecord } from '../../shared/predicates.js';
11
12
  // ---------------------------------------------------------------------------
12
13
  // Validation helpers
13
14
  // ---------------------------------------------------------------------------
14
15
  const KEBAB = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
15
16
  const REST_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
16
- function isObject(v) {
17
- return typeof v === 'object' && v !== null && !Array.isArray(v);
18
- }
19
17
  function typeName(v) {
20
18
  if (v === null)
21
19
  return 'null';
@@ -38,7 +36,7 @@ export function validateCommandManifest(raw, options) {
38
36
  issues.push({ code, message, received, expected, next, ...(path ? { path } : {}) });
39
37
  };
40
38
  // Top-level structure check
41
- if (!isObject(raw)) {
39
+ if (!isRecord(raw)) {
42
40
  issue('command_manifest_invalid', 'manifest must be an object', typeName(raw), options.transport === 'http' ? '{ schemaVersion, baseUrl?, timeouts?, mounts }' : '{ schemaVersion, mounts }', 'Provide a valid JSON manifest object.');
43
41
  return { issues };
44
42
  }
@@ -126,7 +124,7 @@ export function validateCommandManifest(raw, options) {
126
124
  // Timeouts validation
127
125
  // ---------------------------------------------------------------------------
128
126
  function validateTimeouts(raw, issue) {
129
- if (!isObject(raw)) {
127
+ if (!isRecord(raw)) {
130
128
  issue('command_manifest_invalid', 'timeouts must be an object', typeName(raw), '{ connectMs?, requestMs?, streamIdleMs? }', 'Fix timeouts.', 'timeouts');
131
129
  return null;
132
130
  }
@@ -150,7 +148,7 @@ function validateTimeouts(raw, issue) {
150
148
  }
151
149
  function validateMount(raw, index, transport, issue) {
152
150
  const path = `mounts[${index}]`;
153
- if (!isObject(raw)) {
151
+ if (!isRecord(raw)) {
154
152
  issue('command_manifest_invalid', 'mount must be an object', typeName(raw), '{ parent, node }', 'Fix the mount.', path);
155
153
  return null;
156
154
  }