@north-light/crouter 0.3.176 → 0.3.178

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 (152) 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/reports.d.ts +1 -1
  5. package/dist/clients/attach/__tests__/frame-selection.test.d.ts +1 -0
  6. package/dist/clients/attach/__tests__/frame-selection.test.js +84 -0
  7. package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
  8. package/dist/clients/attach/ansi-cells.d.ts +31 -0
  9. package/dist/clients/attach/ansi-cells.js +120 -0
  10. package/dist/clients/attach/config.js +5 -5
  11. package/dist/clients/attach/input/clipboard-image.js +2 -2
  12. package/dist/clients/attach/input/ref-autocomplete.js +1 -1
  13. package/dist/clients/attach/input/titled-editor.d.ts +12 -13
  14. package/dist/clients/attach/input/titled-editor.js +14 -15
  15. package/dist/clients/attach/overlays/dialogs.d.ts +1 -1
  16. package/dist/clients/attach/overlays/dialogs.js +2 -2
  17. package/dist/clients/attach/overlays/help.js +2 -1
  18. package/dist/clients/attach/overlays/mcp.js +5 -12
  19. package/dist/clients/attach/render/chat-view.d.ts +1 -1
  20. package/dist/clients/attach/render/chat-view.js +2 -2
  21. package/dist/clients/attach/render/group-recap.js +7 -0
  22. package/dist/clients/attach/session/context.js +3 -3
  23. package/dist/clients/attach/session/editor-inventory.js +6 -6
  24. package/dist/clients/attach/session/file-links.js +15 -50
  25. package/dist/clients/attach/session/frame.d.ts +5 -1
  26. package/dist/clients/attach/session/frame.js +19 -4
  27. package/dist/clients/attach/session/input-wiring.js +2 -2
  28. package/dist/clients/attach/session/mouse.d.ts +4 -0
  29. package/dist/clients/attach/session/mouse.js +14 -3
  30. package/dist/clients/attach/session/selection.d.ts +28 -0
  31. package/dist/clients/attach/session/selection.js +108 -0
  32. package/dist/clients/attach/session/teardown.js +7 -9
  33. package/dist/clients/attach/slash/dispatch.d.ts +1 -2
  34. package/dist/clients/attach/slash/dispatch.js +5 -9
  35. package/dist/clients/attach/viewer.js +552 -550
  36. package/dist/clients/inbox/controller.js +3 -2
  37. package/dist/clients/inbox/deck-adapter.js +3 -2
  38. package/dist/clients/surfaces/host.d.ts +1 -1
  39. package/dist/commands/api-client.d.ts +12 -13
  40. package/dist/commands/api-client.js +15 -16
  41. package/dist/commands/chord.js +1 -2
  42. package/dist/commands/human/queue.js +6 -5
  43. package/dist/commands/memory/find.js +3 -3
  44. package/dist/commands/node/lifecycle.js +1 -1
  45. package/dist/commands/node-lifecycle-revive.d.ts +1 -1
  46. package/dist/commands/node-lifecycle-revive.js +3 -3
  47. package/dist/commands/push.js +1 -1
  48. package/dist/commands/surface/node/placement.d.ts +1 -1
  49. package/dist/commands/surface/node/placement.js +1 -1
  50. package/dist/commands/surface-edit.js +1 -4
  51. package/dist/commands/sys/panels/panel.d.ts +3 -3
  52. package/dist/commands/sys/panels/panel.js +3 -3
  53. package/dist/commands/sys/setup-core.js +1 -7
  54. package/dist/commands/sys/sync-deps.js +1 -3
  55. package/dist/commands/sys/sync-project-guidance.js +2 -11
  56. package/dist/commands/sys/sysprompt.js +1 -3
  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/canvas/boot.js +1 -1
  61. package/dist/core/canvas/browse/app.js +2 -2
  62. package/dist/core/canvas/canvas.d.ts +3 -3
  63. package/dist/core/canvas/canvas.js +3 -3
  64. package/dist/core/canvas/daemon-owner.js +1 -2
  65. package/dist/core/canvas/db.js +7 -8
  66. package/dist/core/canvas/index.js +2 -2
  67. package/dist/core/canvas/pid.d.ts +12 -14
  68. package/dist/core/canvas/pid.js +16 -20
  69. package/dist/core/canvas/remote-transport.js +2 -2
  70. package/dist/core/canvas/status-glyph.d.ts +1 -1
  71. package/dist/core/canvas/status-glyph.js +1 -1
  72. package/dist/core/canvas/types.d.ts +1 -1
  73. package/dist/core/command-manifests/manifest.js +4 -6
  74. package/dist/core/command-manifests/schema.js +10 -12
  75. package/dist/core/command-plugins/transport/exec-invoke.js +5 -7
  76. package/dist/core/command-plugins/transport/http-invoke.js +4 -6
  77. package/dist/core/config.d.ts +1 -1
  78. package/dist/core/config.js +2 -2
  79. package/dist/core/events/read.js +1 -3
  80. package/dist/core/fault-classifier.js +1 -3
  81. package/dist/core/fs-utils.d.ts +7 -0
  82. package/dist/core/fs-utils.js +29 -2
  83. package/dist/core/host-exports/export.d.ts +4 -5
  84. package/dist/core/host-exports/export.js +4 -5
  85. package/dist/core/human/claim.js +3 -2
  86. package/dist/core/human/convention.d.ts +0 -1
  87. package/dist/core/human/convention.js +1 -9
  88. package/dist/core/human/scan.js +5 -4
  89. package/dist/core/profiles/manifest.d.ts +1 -1
  90. package/dist/core/profiles/manifest.js +1 -1
  91. package/dist/core/review/types.d.ts +1 -1
  92. package/dist/core/review/types.js +1 -1
  93. package/dist/core/runtime/broker/frame-dispatch.js +4 -4
  94. package/dist/core/runtime/broker-cli.js +1 -1
  95. package/dist/core/runtime/broker-protocol.d.ts +9 -10
  96. package/dist/core/runtime/broker-protocol.js +9 -10
  97. package/dist/core/runtime/broker-sdk.d.ts +1 -1
  98. package/dist/core/runtime/broker.js +6 -6
  99. package/dist/core/runtime/host.js +14 -14
  100. package/dist/core/runtime/launch.d.ts +1 -1
  101. package/dist/core/runtime/launch.js +1 -1
  102. package/dist/core/runtime/lifecycle.js +16 -21
  103. package/dist/core/runtime/node-read.js +2 -3
  104. package/dist/core/runtime/package-health.js +1 -8
  105. package/dist/core/runtime/persona.js +2 -2
  106. package/dist/core/runtime/pi-vendored.d.ts +4 -2
  107. package/dist/core/runtime/pi-vendored.js +5 -16
  108. package/dist/core/runtime/promote.js +1 -1
  109. package/dist/core/runtime/reopen.js +2 -2
  110. package/dist/core/runtime/revive-all.d.ts +3 -3
  111. package/dist/core/runtime/revive-all.js +6 -6
  112. package/dist/core/runtime/revive.js +1 -1
  113. package/dist/core/runtime/spawn.d.ts +1 -2
  114. package/dist/core/runtime/spawn.js +2 -3
  115. package/dist/core/runtime/structured-output.js +4 -6
  116. package/dist/core/runtime/tmux-driver.d.ts +2 -2
  117. package/dist/core/runtime/tmux-driver.js +3 -5
  118. package/dist/core/runtime/tool-group-summary.js +5 -4
  119. package/dist/core/runtime/warm-pool.js +1 -1
  120. package/dist/core/scope.js +2 -9
  121. package/dist/core/self-update.js +3 -3
  122. package/dist/core/spawn.d.ts +0 -1
  123. package/dist/core/spawn.js +1 -4
  124. package/dist/core/substrate/on-read.js +1 -9
  125. package/dist/core/wake.js +2 -2
  126. package/dist/core/worktree.d.ts +3 -3
  127. package/dist/core/worktree.js +5 -7
  128. package/dist/daemon/api/bridge.js +1 -1
  129. package/dist/daemon/api/handlers/broker-ops.js +1 -2
  130. package/dist/daemon/api/handlers/messages.js +2 -2
  131. package/dist/daemon/api/handlers/nodes.js +1 -1
  132. package/dist/daemon/api/handlers/reports.js +1 -1
  133. package/dist/daemon/api/handlers/validate.d.ts +2 -1
  134. package/dist/daemon/api/handlers/validate.js +2 -3
  135. package/dist/daemon/cron-run.js +7 -7
  136. package/dist/daemon/human/finish.js +3 -2
  137. package/dist/daemon/manage.d.ts +6 -6
  138. package/dist/daemon/manage.js +15 -16
  139. package/dist/pi-extensions/canvas-context-intro.js +1 -14
  140. package/dist/pi-extensions/canvas-doc-substrate.js +1 -1
  141. package/dist/pi-extensions/canvas-review-boundary.js +1 -14
  142. package/dist/pi-extensions/truncate.d.ts +5 -0
  143. package/dist/pi-extensions/truncate.js +14 -0
  144. package/dist/shared/predicates.d.ts +2 -0
  145. package/dist/shared/predicates.js +4 -0
  146. package/dist/shared/shell-quote.d.ts +3 -0
  147. package/dist/shared/shell-quote.js +5 -0
  148. package/dist/shared/tool-groups.d.ts +4 -2
  149. package/dist/shared/tool-groups.js +3 -3
  150. package/dist/types.d.ts +4 -5
  151. package/package.json +1 -1
  152. package/runtime.lock.json +2 -2
@@ -13,18 +13,18 @@ export declare const TERMINAL_BY_CHOICE: readonly NodeStatus[];
13
13
  * inbox watcher's own hold-and-flush) ever brings such a node back on its
14
14
  * own — only an explicit revive/reopen does. Shared by `isDisconnected`
15
15
  * (mass-revive scope, below) and `node message send --tier deferred`'s terminal-edge
16
- * rejection (gh #341) — a deferred entry appended to such a target would
16
+ * rejection — a deferred entry appended to such a target would
17
17
  * never be delivered, so that doorway rejects instead of silently holding
18
18
  * it forever. */
19
19
  export declare function hasNoNaturalCycle(meta: Pick<NodeMeta, 'status' | 'final_report'>): boolean;
20
20
  /** True when `meta`'s engine is NOT running but it has a resumable saved session
21
- * — the precise "disconnected" predicate (gh #9).
21
+ * — the precise "disconnected" predicate.
22
22
  *
23
23
  * - engine not running: no live `pi_pid` (a dead/absent broker pid).
24
24
  * - reconnectable: a captured pi session marker (`pi_session_file` or
25
25
  * `pi_session_id`) — without one there is nothing to reopen. reviveNode
26
26
  * resumes by the `.jsonl` path when it still exists, else relaunches fresh (a
27
- * bare `pi_session_id` alone no longer resumes — the broker preflight rejects
27
+ * bare `pi_session_id` alone does not resume — the broker preflight rejects
28
28
  * it — so such a node comes back fresh).
29
29
  * - `human`-kind rows are the `crtr human` bridge, never a pi engine, so they
30
30
  * are never "disconnected" (mirrors the daemon's superviseTick carve-out).
@@ -1,4 +1,4 @@
1
- // revive-all — resume EVERY disconnected node in one shot (gh issue #9).
1
+ // revive-all — resume EVERY disconnected node in one shot.
2
2
  //
3
3
  // After a mass-disconnect event (a reboot, a killed login/tmux session, a mass
4
4
  // crash, or the daemon being down a while) many nodes end up with their
@@ -30,20 +30,20 @@ export const TERMINAL_BY_CHOICE = ['done', 'canceled'];
30
30
  * inbox watcher's own hold-and-flush) ever brings such a node back on its
31
31
  * own — only an explicit revive/reopen does. Shared by `isDisconnected`
32
32
  * (mass-revive scope, below) and `node message send --tier deferred`'s terminal-edge
33
- * rejection (gh #341) — a deferred entry appended to such a target would
33
+ * rejection — a deferred entry appended to such a target would
34
34
  * never be delivered, so that doorway rejects instead of silently holding
35
35
  * it forever. */
36
36
  export function hasNoNaturalCycle(meta) {
37
37
  return meta.final_report != null || TERMINAL_BY_CHOICE.includes(meta.status);
38
38
  }
39
39
  /** True when `meta`'s engine is NOT running but it has a resumable saved session
40
- * — the precise "disconnected" predicate (gh #9).
40
+ * — the precise "disconnected" predicate.
41
41
  *
42
42
  * - engine not running: no live `pi_pid` (a dead/absent broker pid).
43
43
  * - reconnectable: a captured pi session marker (`pi_session_file` or
44
44
  * `pi_session_id`) — without one there is nothing to reopen. reviveNode
45
45
  * resumes by the `.jsonl` path when it still exists, else relaunches fresh (a
46
- * bare `pi_session_id` alone no longer resumes — the broker preflight rejects
46
+ * bare `pi_session_id` alone does not resume — the broker preflight rejects
47
47
  * it — so such a node comes back fresh).
48
48
  * - `human`-kind rows are the `crtr human` bridge, never a pi engine, so they
49
49
  * are never "disconnected" (mirrors the daemon's superviseTick carve-out).
@@ -78,8 +78,8 @@ export function listDisconnected() {
78
78
  // IDENTITY-aware AND requires CONFIRMED alive: a bare isPidAlive would
79
79
  // misread a recycled pi_pid as "engine running" and SKIP the node, so it
80
80
  // never reaches reviveNode's own identity-aware double-launch guard
81
- // (Round 2 of the pid-reuse-liveness fix, fold-in Major). `dead` OR
82
- // `indeterminate` both count as disconnected/eligible here — only a
81
+ // guard. `dead` OR `indeterminate` both count as disconnected/eligible
82
+ // here — only a
83
83
  // positively confirmed-alive read is treated as "still connected".
84
84
  if (meta !== null && isDisconnected(meta, (pid) => recordedPidLiveness(pid, meta.pi_pid_identity) === 'alive'))
85
85
  out.push(meta);
@@ -100,7 +100,7 @@ export function reviveNode(nodeId, opts) {
100
100
  }
101
101
  // Fresh durable snapshot immediately before the lifecycle guard.
102
102
  meta = getNode(nodeId) ?? meta;
103
- // #339/#340: refuse to revive a LATCHED node — the reopen gate at every
103
+ // Refuse to revive a LATCHED node — the reopen gate at every
104
104
  // legitimate caller (node message send, node lifecycle revive) already checked this
105
105
  // before calling in, but a target can finalize in the gap between that
106
106
  // check and this call. This authoritative snapshot closes that race;
@@ -109,8 +109,7 @@ export interface SpawnChildResult {
109
109
  * (explicit `--parent` or the calling node's CRTR_NODE_ID). A --root spawn
110
110
  * does not: it is top-level by definition and the spawner identity is
111
111
  * provenance only — a human shell with no CRTR_NODE_ID is a legitimate root
112
- * spawner (regression: `crtr node new --root` from outside a node used to
113
- * throw here). */
112
+ * spawner, so `crtr node new --root` from outside a node must not throw. */
114
113
  export declare function resolveSpawner(parent: string | undefined, ctxNodeId: string | null, root: boolean): string | null;
115
114
  /** Spawn a node from a live node. By default a managed terminal worker in a
116
115
  * background window, with the spawner auto-subscribed (active) via spawnNode.
@@ -187,8 +187,7 @@ export async function assertLaunchModelRegistered(request, options, registry) {
187
187
  * (explicit `--parent` or the calling node's CRTR_NODE_ID). A --root spawn
188
188
  * does not: it is top-level by definition and the spawner identity is
189
189
  * provenance only — a human shell with no CRTR_NODE_ID is a legitimate root
190
- * spawner (regression: `crtr node new --root` from outside a node used to
191
- * throw here). */
190
+ * spawner, so `crtr node new --root` from outside a node must not throw. */
192
191
  export function resolveSpawner(parent, ctxNodeId, root) {
193
192
  const spawner = parent ?? ctxNodeId ?? null;
194
193
  if (!root && spawner === null) {
@@ -218,7 +217,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
218
217
  const attendedRoot = root && lifecycle === 'resident';
219
218
  // Born WITHOUT a name — the canvas-goal-capture extension names it async from
220
219
  // its first message (the kickoff task) inside its own pi process, so spawn
221
- // never blocks on the LLM naming round-trip (the 2-3s freeze it used to cost).
220
+ // never blocks on the LLM naming round-trip (a 2-3s freeze).
222
221
  const wantsWorktree = opts.worktree === true;
223
222
  const nodeId = wantsWorktree ? (opts.nodeId ?? newNodeId()) : opts.nodeId;
224
223
  // Preflight: resolve every fallible input BEFORE creating the worktree or the
@@ -3,15 +3,13 @@ import { dirname, resolve } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { nodeDir, contextDir } from '../canvas/paths.js';
5
5
  import { InputError } from '../io.js';
6
+ import { isRecord } from '../../shared/predicates.js';
6
7
  export function outputSchemaPath(nodeId) {
7
8
  return `${nodeDir(nodeId)}/output-schema.json`;
8
9
  }
9
10
  export function outputResultPath(nodeId) {
10
11
  return `${contextDir(nodeId)}/result.json`;
11
12
  }
12
- function isObject(value) {
13
- return value !== null && typeof value === 'object' && !Array.isArray(value);
14
- }
15
13
  export function parseOutputSchemaValue(value) {
16
14
  const raw = value?.trim();
17
15
  if (raw === undefined || raw === '')
@@ -49,7 +47,7 @@ export function parseOutputSchemaValue(value) {
49
47
  next: 'Pass valid JSON whose top-level value is an object.',
50
48
  });
51
49
  }
52
- if (!isObject(parsed)) {
50
+ if (!isRecord(parsed)) {
53
51
  throw new InputError({
54
52
  error: 'bad_output_schema',
55
53
  message: `output schema from ${source} must be a JSON object`,
@@ -91,7 +89,7 @@ export function readOutputRequest(nodeId) {
91
89
  catch (err) {
92
90
  return { state: 'invalid', error: `request file is not valid JSON: ${err.message}` };
93
91
  }
94
- if (!isObject(parsed)) {
92
+ if (!isRecord(parsed)) {
95
93
  return { state: 'invalid', error: 'request file top-level value must be an object' };
96
94
  }
97
95
  const mode = parsed['mode'];
@@ -99,7 +97,7 @@ export function readOutputRequest(nodeId) {
99
97
  if (mode !== 'terminal' && mode !== 'oneoff') {
100
98
  return { state: 'invalid', error: `mode must be 'terminal' or 'oneoff', got ${JSON.stringify(mode)}` };
101
99
  }
102
- if (!isObject(schema)) {
100
+ if (!isRecord(schema)) {
103
101
  return { state: 'invalid', error: `schema must be an object, got ${typeof schema}` };
104
102
  }
105
103
  return { state: 'valid', request: { mode, schema } };
@@ -1,6 +1,6 @@
1
1
  import { type AttachBindingId } from '../keybindings/index.js';
2
- /** POSIX single-quote escaping for one shell word. */
3
- export declare function shellQuote(s: string): string;
2
+ import { shellQuote } from '../../shared/shell-quote.js';
3
+ export { shellQuote };
4
4
  declare function tmux(args: string[]): {
5
5
  ok: boolean;
6
6
  stdout: string;
@@ -6,13 +6,11 @@
6
6
  import { execFile, spawnSync } from 'node:child_process';
7
7
  import { encodeAttachControlInput, } from '../keybindings/index.js';
8
8
  import { bundledPiCommandForShell } from './pi-cli.js';
9
+ import { shellQuote } from '../../shared/shell-quote.js';
9
10
  // ---------------------------------------------------------------------------
10
- // Shell quoting + tmux invocation
11
+ // tmux invocation
11
12
  // ---------------------------------------------------------------------------
12
- /** POSIX single-quote escaping for one shell word. */
13
- export function shellQuote(s) {
14
- return `'${s.replace(/'/g, `'\\''`)}'`;
15
- }
13
+ export { shellQuote };
16
14
  function tmux(args) {
17
15
  const r = spawnSync('tmux', args, { encoding: 'utf8' });
18
16
  return {
@@ -49,9 +49,10 @@ const SUMMARY_SYSTEM_PROMPT = 'Create a structured recap of this coding-agent to
49
49
  'Omit inbox receipt, delivery, and queueing; node-response and report bookkeeping; help lookups; status checks; and summary-generation activity. ' +
50
50
  'A node response may support a substantive outcome, but its arrival or resummarization is never itself an outcome. ' +
51
51
  'If no meaningful outcome remains after those omissions, submit the single bullet "No meaningful changes." ' +
52
- 'Count nodes created and distinct files created, modified, or deleted only when evidenced by the supplied tool arguments or results; otherwise use zero, even when the corresponding mechanics are omitted from bullets. ' +
52
+ 'Count nodes created, and separately count distinct files removed from disk, only when the supplied tool arguments or results evidence them; otherwise use zero, even when the corresponding mechanics are omitted from bullets. ' +
53
+ 'A deleted file is one that no longer exists after the segment, never a file that was merely edited or overwritten, and a recursive or wildcard removal of an unknown number of files counts as one. ' +
53
54
  'Do not invent success when a result shows failure. ' +
54
- 'Call submit_summary exactly once with bullets, nodesSpawned, and filesEdited, then stop.';
55
+ 'Call submit_summary exactly once with bullets, nodesSpawned, and filesDeleted, then stop.';
55
56
  const SUBMIT_SUMMARY_SCHEMA = Type.Object({
56
57
  bullets: Type.Array(Type.String({
57
58
  minLength: 1,
@@ -62,9 +63,9 @@ const SUBMIT_SUMMARY_SCHEMA = Type.Object({
62
63
  minimum: 0,
63
64
  description: 'Count of nodes created during the supplied segment, or zero when none are evidenced.',
64
65
  }),
65
- filesEdited: Type.Integer({
66
+ filesDeleted: Type.Integer({
66
67
  minimum: 0,
67
- description: 'Count of distinct files created, modified, or deleted during the supplied segment, or zero when none are evidenced.',
68
+ description: 'Count of distinct files removed from disk during the supplied segment, or zero when none are evidenced.',
68
69
  }),
69
70
  }, { additionalProperties: false });
70
71
  /** Generate a structured summary object, or null on every failure. */
@@ -47,7 +47,7 @@
47
47
  // and at daemon start for the most recently used profiles, so the FIRST node
48
48
  // after sitting down is warm too.
49
49
  //
50
- // The pool no longer fans out per directory: a node runs in its profile's home
50
+ // The pool does not fan out per directory: a node runs in its profile's home
51
51
  // (`resolveNodeCwd` in the create handler), so the cwd a spare freezes is a
52
52
  // function of its profile, and "which spare serves this create" is in practice
53
53
  // a question about the profile alone.
@@ -1,10 +1,11 @@
1
1
  import { homedir } from 'node:os';
2
- import { existsSync, statSync, realpathSync } from 'node:fs';
2
+ import { existsSync, statSync } from 'node:fs';
3
3
  import { join, resolve, dirname } from 'node:path';
4
4
  import { CRTR_DIR_NAME } from '../types.js';
5
5
  import { assetRoot } from './asset-root.js';
6
6
  import { usage } from './errors.js';
7
7
  import { loadProfileManifest } from './profiles/manifest.js';
8
+ import { realpathOrSelf } from './fs-utils.js';
8
9
  // Cache of resolved project-scope-root STACKS, keyed by (resolved cwd, profile
9
10
  // id). A profile widens the pointer set beyond the bare cwd (see
10
11
  // findProjectScopeRoots below), so the cache key must carry both — the same
@@ -31,14 +32,6 @@ export function builtinPiPackageDir(name) {
31
32
  export function userScopeRoot() {
32
33
  return join(homedir(), CRTR_DIR_NAME);
33
34
  }
34
- function realpathOrSelf(p) {
35
- try {
36
- return realpathSync(p);
37
- }
38
- catch {
39
- return p;
40
- }
41
- }
42
35
  function isProjectScopeDir(candidate, userRoot) {
43
36
  if (!existsSync(candidate))
44
37
  return false;
@@ -1,6 +1,6 @@
1
- // Self-update and content-check primitives extracted from commands/update.ts.
2
- // Moved here to break the core→commands import inversion: auto-update.ts
3
- // (core) previously imported from commands/update.ts (commands layer).
1
+ // Self-update and content-check primitives. They live in core/ so auto-update.ts
2
+ // (core) reaches them without importing commands/update.ts, which would invert
3
+ // the core→commands layering.
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { join, dirname } from 'node:path';
@@ -13,4 +13,3 @@ export declare function isInTmux(): boolean;
13
13
  * no server is running — the true "no tmux to surface into" case that should
14
14
  * degrade to the inbox-drain follow-up. */
15
15
  export declare function tmuxServerReachable(): boolean;
16
- export declare function shellQuote(s: string): string;
@@ -1,4 +1,4 @@
1
- // Tmux reachability + quoting helpers for the `human` command tree.
1
+ // Tmux reachability for the `human` command tree.
2
2
  //
3
3
  // The human surface gates on whether the canvas tmux server is reachable
4
4
  // (tmuxServerReachable) so it can decide whether to surface a prompt or degrade
@@ -25,6 +25,3 @@ export function tmuxServerReachable() {
25
25
  const r = spawnSync('tmux', ['list-clients', '-F', '#{client_name}'], { encoding: 'utf8' });
26
26
  return r.status === 0;
27
27
  }
28
- export function shellQuote(s) {
29
- return `'${s.replace(/'/g, "'\\''")}'`;
30
- }
@@ -24,7 +24,6 @@
24
24
  // Every candidate renders at its own fileReadVisibility rung. Explicit routing
25
25
  // is the whole on-read model, so directory INDEX ceilings remain a boot-catalog
26
26
  // concern and do not cap a deliberately matched document.
27
- import { realpathSync } from 'node:fs';
28
27
  import { homedir } from 'node:os';
29
28
  import { basename, dirname, matchesGlob, parse, relative, sep } from 'node:path';
30
29
  import { CRTR_DIR_NAME } from '../../types.js';
@@ -37,15 +36,8 @@ import { displayName } from './ceiling.js';
37
36
  import { gatePasses } from './gate.js';
38
37
  import { normalizeDocName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, resolveDocName } from './schema.js';
39
38
  import { cachedSubstrateDocs } from './session-cache.js';
39
+ import { realpathOrSelf } from '../fs-utils.js';
40
40
  const JUNK_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '.yalc']);
41
- function realpathOrSelf(p) {
42
- try {
43
- return realpathSync(p);
44
- }
45
- catch {
46
- return p;
47
- }
48
- }
49
41
  function attr(s) {
50
42
  return s
51
43
  .replace(/&/g, '&amp;')
package/dist/core/wake.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // This is the ONE place that owns cron time logic. Both the CLI surface and the
4
4
  // daemon's firing pass import from here, so there is no duplicated cron usage
5
5
  // and no surface<->daemon inverted dependency:
6
- // - parseWhen / parseCadence — arm-time grammar (surface, T7)
7
- // - nextSlotAfter — per-tick advance / coalescing (daemon, T4)
6
+ // - parseWhen / parseCadence — arm-time grammar (surface)
7
+ // - nextSlotAfter — per-tick advance / coalescing (daemon)
8
8
  //
9
9
  // Design refs: surface-design §4 (grammar), §5 (timezone/DST ruling),
10
10
  // design §5.3 + D7 (engine evaluates UTC; the surface bakes the IANA zone
@@ -95,11 +95,11 @@ export interface AutoDroppedWorktree {
95
95
  * branch and the live checkout stay fully recoverable and the caller's
96
96
  * `open_managed_worktree` guard fires. */
97
97
  export declare function autoDropCleanManagedWorktreeLocked(nodeId: string, wt: ManagedWorktree, proof: CleanEmptyProof): AutoDroppedWorktree | null;
98
- /** `push final`'s escape hatch (#327, #333): a managed worktree that has
98
+ /** `push final`'s escape hatch: a managed worktree that has
99
99
  * nothing to land is auto-dropped instead of blocking on an explicit
100
100
  * `node worktree close` — one the caller may have been told NOT to run
101
- * because a manager already landed the same change another way (#327), or
102
- * that never had anything to close in the first place (#333, a read-only
101
+ * because a manager already landed the same change another way, or
102
+ * that never had anything to close in the first place (a read-only
103
103
  * review node). Returns null and changes nothing when the worktree is not
104
104
  * PROVABLY empty-and-clean by `proveCleanEmptyManagedWorktree`, OR when a
105
105
  * concurrent mutation invalidates that proof before the destructive delete
@@ -6,6 +6,7 @@ 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
8
  import { withExclusiveDirectoryLock } from './exclusive-lock.js';
9
+ import { shellQuote } from '../shared/shell-quote.js';
9
10
  export class WorktreeError extends Error {
10
11
  code;
11
12
  next;
@@ -137,7 +138,7 @@ function rollbackManagedWorktreeLocked(wt, pathWasAbsent) {
137
138
  export function rollbackManagedWorktree(wt) {
138
139
  withRepositoryWorktreeLock(wt.repo_root, () => rollbackManagedWorktreeLocked(wt, false));
139
140
  }
140
- // Fix #330/#331: every crtr process auto-initializes a project scope's
141
+ // Every crtr process auto-initializes a project scope's
141
142
  // `.crouter/config.json` (`ensureProjectScope` → `ensureScopeInitialized`)
142
143
  // the first time it runs against a project root that has no ancestor
143
144
  // `.crouter/` yet — which every fresh managed worktree checkout is, unless
@@ -357,9 +358,6 @@ let worktreeCleanupRunner = defaultWorktreeCleanupRunner;
357
358
  export function setWorktreeCleanupRunnerForTest(runner) {
358
359
  worktreeCleanupRunner = runner ?? defaultWorktreeCleanupRunner;
359
360
  }
360
- function shellQuote(value) {
361
- return `'${value.replaceAll("'", "'\\''")}'`;
362
- }
363
361
  function isValidRegisteredCheckout(output, wt) {
364
362
  let path = null;
365
363
  let prunable = false;
@@ -607,11 +605,11 @@ export function autoDropCleanManagedWorktreeLocked(nodeId, wt, proof) {
607
605
  branch_deleted: true,
608
606
  };
609
607
  }
610
- /** `push final`'s escape hatch (#327, #333): a managed worktree that has
608
+ /** `push final`'s escape hatch: a managed worktree that has
611
609
  * nothing to land is auto-dropped instead of blocking on an explicit
612
610
  * `node worktree close` — one the caller may have been told NOT to run
613
- * because a manager already landed the same change another way (#327), or
614
- * 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
615
613
  * review node). Returns null and changes nothing when the worktree is not
616
614
  * PROVABLY empty-and-clean by `proveCleanEmptyManagedWorktree`, OR when a
617
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.
@@ -357,7 +357,7 @@ function handleRevive(ctx) {
357
357
  const body = ctx.body;
358
358
  const resume = body?.resume ?? true;
359
359
  const reopen = body?.reopen ?? false;
360
- // #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
361
361
  // caller cannot reach): --reopen CAS-clears a spent `final_report`; the
362
362
  // no-reopen path refuses a still-latched node (`node_finalized`). reviveNode
363
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
@@ -1,4 +1,6 @@
1
+ import { isRecord } from '../../../shared/predicates.js';
1
2
  import type { RequestContext } from '../router.js';
3
+ export { isRecord };
2
4
  export type Violation = {
3
5
  field: string;
4
6
  problem: string;
@@ -7,7 +9,6 @@ export type Violation = {
7
9
  /** Reject the request with every violation collected so far. `subject` names
8
10
  * what was being parsed, e.g. "review comment request". */
9
11
  export declare function invalidRequest(subject: string, violations: Violation[]): never;
10
- export declare function isRecord(value: unknown): value is Record<string, unknown>;
11
12
  /** Narrow a request body to an object and reject any field outside `allowed`.
12
13
  * A non-object body yields `{}` so the caller's field reads all miss and
13
14
  * report their own violations rather than throwing. */
@@ -4,14 +4,13 @@
4
4
  // `invalidRequest` once so a caller sees every problem with its body in a
5
5
  // single 400 rather than one per round trip.
6
6
  import { ApiError } from '../../../api/index.js';
7
+ import { isRecord } from '../../../shared/predicates.js';
8
+ export { isRecord };
7
9
  /** Reject the request with every violation collected so far. `subject` names
8
10
  * what was being parsed, e.g. "review comment request". */
9
11
  export function invalidRequest(subject, violations) {
10
12
  throw new ApiError(400, 'invalid_request', `${subject} is invalid`, { violations });
11
13
  }
12
- export function isRecord(value) {
13
- return typeof value === 'object' && value !== null && !Array.isArray(value);
14
- }
15
14
  /** Narrow a request body to an object and reject any field outside `allowed`.
16
15
  * A non-object body yields `{}` so the caller's field reads all miss and
17
16
  * report their own violations rather than throwing. */
@@ -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);
@@ -78,12 +78,12 @@ export declare function waitForDaemonExit(pid: number, timeoutMs?: number, deps?
78
78
  /** Stop the RECORDED daemon: SIGTERM, and if it does not exit within the wait
79
79
  * window, escalate to SIGKILL exactly as `sweepStrayDaemons` does for a stray.
80
80
  *
81
- * The escalation is the point. `stop` used to send SIGTERM, wait, and throw on
82
- * timeout — before ever reaching the stray sweep — so the one daemon `stop` is
83
- * actually aimed at was the only crtrd process exempt from the SIGKILL backstop.
84
- * A daemon whose shutdown hung (or outran the window) then became permanently
85
- * unstoppable from the CLI: every retry re-sent a SIGTERM it was already
86
- * ignoring, and `start` kept refusing as "already running".
81
+ * The escalation is the point. Without it — SIGTERM, wait, throw on timeout,
82
+ * never reaching the stray sweep — the one daemon `stop` is actually aimed at
83
+ * would be the only crtrd process exempt from the SIGKILL backstop, and a
84
+ * daemon whose shutdown hangs (or outruns the window) becomes permanently
85
+ * unstoppable from the CLI: every retry re-sends a SIGTERM it is already
86
+ * ignoring, and `start` keeps refusing as "already running".
87
87
  *
88
88
  * A SIGKILL'd daemon runs no exit handler, so it leaves its pidfile behind
89
89
  * pointing at a dead pid. That is a stale record, not an owner: `readPidfile`
@@ -193,15 +193,14 @@ export function sweepStrayDaemons(keepPid) {
193
193
  // Daemon lifecycle transitions are bounded so callers never claim a state
194
194
  // change before the pidfile and process liveness agree.
195
195
  //
196
- // #508: a cold boot right after `install-runtime` (empty module cache, fresh
197
- // generation, possible pending db migration) can take several seconds to
198
- // reach writePidfile() — a fixed 5s window produced false "did not become
199
- // ready" failures on a daemon that was in fact starting up fine. Widened to
200
- // tolerate that slow-but-valid cold start; a process exit (signal/non-zero
201
- // code) still fails immediately regardless of this window.
196
+ // A cold boot right after `install-runtime` (empty module cache, fresh
197
+ // generation, possible pending db migration) can take several seconds to reach
198
+ // writePidfile(), so the window is wide enough to tolerate that slow-but-valid
199
+ // cold start rather than report a false "did not become ready"; a process exit
200
+ // (signal/non-zero code) still fails immediately regardless of this window.
202
201
  // Exported so `commands/api-client.ts` can align the CLI-side `CrtrClient`'s
203
202
  // cold-start `/healthz` poll window with this authoritative valid-startup bound
204
- // (issue #508 follow-up) instead of the two windows drifting independently.
203
+ // instead of the two windows drifting independently.
205
204
  export const DAEMON_VERIFY_WINDOW_MS = 20_000;
206
205
  const DAEMON_VERIFY_POLL_MS = 10;
207
206
  function sleepMs(ms) {
@@ -272,12 +271,12 @@ const DAEMON_KILL_WINDOW_MS = 2_000;
272
271
  /** Stop the RECORDED daemon: SIGTERM, and if it does not exit within the wait
273
272
  * window, escalate to SIGKILL exactly as `sweepStrayDaemons` does for a stray.
274
273
  *
275
- * The escalation is the point. `stop` used to send SIGTERM, wait, and throw on
276
- * timeout — before ever reaching the stray sweep — so the one daemon `stop` is
277
- * actually aimed at was the only crtrd process exempt from the SIGKILL backstop.
278
- * A daemon whose shutdown hung (or outran the window) then became permanently
279
- * unstoppable from the CLI: every retry re-sent a SIGTERM it was already
280
- * ignoring, and `start` kept refusing as "already running".
274
+ * The escalation is the point. Without it — SIGTERM, wait, throw on timeout,
275
+ * never reaching the stray sweep — the one daemon `stop` is actually aimed at
276
+ * would be the only crtrd process exempt from the SIGKILL backstop, and a
277
+ * daemon whose shutdown hangs (or outruns the window) becomes permanently
278
+ * unstoppable from the CLI: every retry re-sends a SIGTERM it is already
279
+ * ignoring, and `start` keeps refusing as "already running".
281
280
  *
282
281
  * A SIGKILL'd daemon runs no exit handler, so it leaves its pidfile behind
283
282
  * pointing at a dead pid. That is a stale record, not an owner: `readPidfile`
@@ -332,8 +331,8 @@ export async function spawnDaemon() {
332
331
  // output the daemon doesn't control, plus the narrow set of authorized raw
333
332
  // `[crtrd] …` bootstrap/fatal lines that by construction predate or escape
334
333
  // canonical event emission (the pre-bind losing-claim line and a cleanup-
335
- // fatal write in crtrd.ts): all used to go to /dev/null for any manually-
336
- // started daemon, the common case (only a launchd-owned daemon had a
334
+ // fatal write in crtrd.ts). Without this fd all of it is discarded for any
335
+ // manually-started daemon, the common case (only a launchd-owned daemon has a
337
336
  // logging plist). Every other daemon diagnostic — including per-node
338
337
  // supervise/relaunch failures — is a canonical `emitEvent` write, not raw
339
338
  // stderr; this file is not where those live. Mirrors host.ts's broker.log
@@ -344,7 +343,7 @@ export async function spawnDaemon() {
344
343
  bindDaemonEventSource();
345
344
  // `--canvas-home` declares this daemon's canvas ownership in argv so
346
345
  // findDaemonPids()/sweepStrayDaemons (above) can match its own daemon
347
- // exactly instead of scanning the whole machine (#518). crtrd-cli.ts's argv
346
+ // exactly instead of scanning the whole machine. crtrd-cli.ts's argv
348
347
  // parsing only looks for `--tcp`, so this extra pair of tokens is inert
349
348
  // there — it exists purely for `ps` visibility.
350
349
  const child = spawn(hostExecPath(), [...launchArgs, '--canvas-home', crtrHome()], {