@north-light/crouter 0.3.175 → 0.3.177

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/dist/api/client.d.ts +4 -4
  2. package/dist/api/client.js +2 -2
  3. package/dist/api/dto/lifecycle.d.ts +1 -1
  4. package/dist/api/dto/nodes.d.ts +7 -1
  5. package/dist/api/dto/profiles.d.ts +3 -0
  6. package/dist/api/dto/reports.d.ts +1 -1
  7. package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
  8. package/dist/clients/attach/config.js +5 -5
  9. package/dist/clients/attach/input/clipboard-image.js +2 -2
  10. package/dist/clients/attach/input/ref-autocomplete.js +1 -1
  11. package/dist/clients/attach/input/titled-editor.d.ts +12 -13
  12. package/dist/clients/attach/input/titled-editor.js +14 -15
  13. package/dist/clients/attach/overlays/dialogs.d.ts +1 -1
  14. package/dist/clients/attach/overlays/dialogs.js +2 -2
  15. package/dist/clients/attach/overlays/mcp.js +5 -12
  16. package/dist/clients/attach/render/chat-view.d.ts +1 -1
  17. package/dist/clients/attach/render/chat-view.js +2 -2
  18. package/dist/clients/attach/render/crtr-output.d.ts +3 -3
  19. package/dist/clients/attach/render/crtr-output.js +25 -13
  20. package/dist/clients/attach/render/group-recap.js +7 -0
  21. package/dist/clients/attach/session/context.js +3 -3
  22. package/dist/clients/attach/session/editor-inventory.js +6 -6
  23. package/dist/clients/attach/session/input-wiring.js +1 -1
  24. package/dist/clients/attach/session/teardown.js +7 -9
  25. package/dist/clients/attach/slash/dispatch.d.ts +1 -2
  26. package/dist/clients/attach/slash/dispatch.js +4 -8
  27. package/dist/clients/attach/viewer.js +536 -566
  28. package/dist/clients/inbox/controller.js +3 -2
  29. package/dist/clients/inbox/deck-adapter.js +3 -2
  30. package/dist/clients/surfaces/host.d.ts +1 -1
  31. package/dist/commands/api-client.d.ts +12 -13
  32. package/dist/commands/api-client.js +15 -16
  33. package/dist/commands/chord.js +1 -2
  34. package/dist/commands/human/queue.js +6 -5
  35. package/dist/commands/memory/find.js +3 -3
  36. package/dist/commands/node/create.js +7 -3
  37. package/dist/commands/node/lifecycle.js +1 -1
  38. package/dist/commands/node-lifecycle-revive.d.ts +1 -1
  39. package/dist/commands/node-lifecycle-revive.js +3 -3
  40. package/dist/commands/profile/list.js +3 -1
  41. package/dist/commands/profile/new.js +4 -1
  42. package/dist/commands/profile/project.js +23 -9
  43. package/dist/commands/profile/show.js +3 -1
  44. package/dist/commands/profile.js +2 -2
  45. package/dist/commands/push.js +1 -1
  46. package/dist/commands/surface/node/placement.d.ts +1 -1
  47. package/dist/commands/surface/node/placement.js +1 -1
  48. package/dist/commands/surface-edit.js +1 -4
  49. package/dist/commands/sys/logs.js +4 -2
  50. package/dist/commands/sys/panels/panel.d.ts +3 -3
  51. package/dist/commands/sys/panels/panel.js +3 -3
  52. package/dist/commands/sys/setup-core.js +1 -7
  53. package/dist/commands/sys/sync-deps.js +1 -3
  54. package/dist/commands/sys/sync-project-guidance.js +2 -11
  55. package/dist/commands/sys/sysprompt.js +1 -3
  56. package/dist/core/__tests__/branded-host-concurrency.test.js +1 -1
  57. package/dist/core/__tests__/fixtures/fake-engine.js +1 -1
  58. package/dist/core/__tests__/helpers/harness.d.ts +1 -1
  59. package/dist/core/__tests__/helpers/harness.js +1 -1
  60. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +1 -1
  61. package/dist/core/__tests__/on-read-dedup-resume.test.js +1 -1
  62. package/dist/core/canvas/boot.js +1 -1
  63. package/dist/core/canvas/browse/app.js +2 -2
  64. package/dist/core/canvas/canvas.d.ts +3 -3
  65. package/dist/core/canvas/canvas.js +3 -3
  66. package/dist/core/canvas/daemon-owner.js +1 -2
  67. package/dist/core/canvas/db.js +7 -8
  68. package/dist/core/canvas/extensions.d.ts +1 -1
  69. package/dist/core/canvas/extensions.js +7 -1
  70. package/dist/core/canvas/index.js +2 -2
  71. package/dist/core/canvas/pid.d.ts +12 -14
  72. package/dist/core/canvas/pid.js +16 -20
  73. package/dist/core/canvas/remote-transport.js +2 -2
  74. package/dist/core/canvas/status-glyph.d.ts +1 -1
  75. package/dist/core/canvas/status-glyph.js +1 -1
  76. package/dist/core/canvas/types.d.ts +1 -1
  77. package/dist/core/command-manifests/manifest.js +4 -6
  78. package/dist/core/command-manifests/schema.js +10 -12
  79. package/dist/core/command-plugins/transport/exec-invoke.js +5 -7
  80. package/dist/core/command-plugins/transport/http-invoke.js +14 -8
  81. package/dist/core/command.js +3 -1
  82. package/dist/core/config.d.ts +1 -1
  83. package/dist/core/config.js +6 -29
  84. package/dist/core/events/read.js +1 -3
  85. package/dist/core/exclusive-lock.d.ts +14 -0
  86. package/dist/core/exclusive-lock.js +124 -0
  87. package/dist/core/fault-classifier.js +1 -3
  88. package/dist/core/fs-utils.d.ts +7 -0
  89. package/dist/core/fs-utils.js +29 -2
  90. package/dist/core/host-exports/export.d.ts +4 -5
  91. package/dist/core/host-exports/export.js +4 -5
  92. package/dist/core/human/claim.js +4 -2
  93. package/dist/core/human/convention.d.ts +0 -11
  94. package/dist/core/human/convention.js +1 -131
  95. package/dist/core/human/scan.js +5 -4
  96. package/dist/core/io.d.ts +23 -0
  97. package/dist/core/io.js +60 -0
  98. package/dist/core/preview-registry.d.ts +10 -5
  99. package/dist/core/preview-registry.js +136 -2537
  100. package/dist/core/profiles/manifest.d.ts +13 -2
  101. package/dist/core/profiles/manifest.js +58 -49
  102. package/dist/core/review/store.d.ts +4 -1
  103. package/dist/core/review/store.js +10 -0
  104. package/dist/core/review/types.d.ts +1 -1
  105. package/dist/core/review/types.js +1 -1
  106. package/dist/core/runtime/branded-host.js +6 -84
  107. package/dist/core/runtime/broker/frame-dispatch.js +4 -4
  108. package/dist/core/runtime/broker-cli.js +1 -1
  109. package/dist/core/runtime/broker-extension-render.js +1 -1
  110. package/dist/core/runtime/broker-protocol.d.ts +9 -10
  111. package/dist/core/runtime/broker-protocol.js +9 -10
  112. package/dist/core/runtime/broker-sdk.d.ts +1 -1
  113. package/dist/core/runtime/broker.js +6 -6
  114. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  115. package/dist/core/runtime/canvas-extensions.js +2 -0
  116. package/dist/core/runtime/host.js +14 -14
  117. package/dist/core/runtime/launch.d.ts +1 -1
  118. package/dist/core/runtime/launch.js +1 -1
  119. package/dist/core/runtime/lifecycle.js +16 -21
  120. package/dist/core/runtime/node-read.js +2 -3
  121. package/dist/core/runtime/package-health.js +1 -8
  122. package/dist/core/runtime/persona.js +2 -2
  123. package/dist/core/runtime/pi-vendored.d.ts +4 -2
  124. package/dist/core/runtime/pi-vendored.js +5 -16
  125. package/dist/core/runtime/promote.js +1 -1
  126. package/dist/core/runtime/reopen.js +2 -2
  127. package/dist/core/runtime/revive-all.d.ts +3 -3
  128. package/dist/core/runtime/revive-all.js +6 -6
  129. package/dist/core/runtime/revive.js +1 -1
  130. package/dist/core/runtime/spawn.d.ts +1 -2
  131. package/dist/core/runtime/spawn.js +2 -3
  132. package/dist/core/runtime/structured-output.js +4 -6
  133. package/dist/core/runtime/tmux-bindings.js +1 -1
  134. package/dist/core/runtime/tmux-driver.d.ts +2 -2
  135. package/dist/core/runtime/tmux-driver.js +3 -5
  136. package/dist/core/runtime/tool-group-summary.js +5 -4
  137. package/dist/core/runtime/warm-pool.d.ts +9 -8
  138. package/dist/core/runtime/warm-pool.js +58 -62
  139. package/dist/core/scope.js +2 -9
  140. package/dist/core/self-update.js +3 -3
  141. package/dist/core/spawn.d.ts +0 -1
  142. package/dist/core/spawn.js +1 -4
  143. package/dist/core/substrate/index.d.ts +2 -1
  144. package/dist/core/substrate/index.js +2 -1
  145. package/dist/core/substrate/on-read-node.d.ts +7 -0
  146. package/dist/core/substrate/on-read-node.js +37 -0
  147. package/dist/core/substrate/on-read.d.ts +7 -5
  148. package/dist/core/substrate/on-read.js +16 -32
  149. package/dist/core/termrender/termrender.js +16 -96
  150. package/dist/core/wake.js +2 -2
  151. package/dist/core/worktree.d.ts +3 -3
  152. package/dist/core/worktree.js +16 -107
  153. package/dist/daemon/api/bridge.js +1 -1
  154. package/dist/daemon/api/handlers/broker-ops.js +1 -2
  155. package/dist/daemon/api/handlers/messages.js +2 -2
  156. package/dist/daemon/api/handlers/nodes.js +33 -10
  157. package/dist/daemon/api/handlers/reports.js +1 -1
  158. package/dist/daemon/api/handlers/review-comments.js +5 -44
  159. package/dist/daemon/api/handlers/reviews.js +5 -53
  160. package/dist/daemon/api/handlers/validate.d.ts +23 -0
  161. package/dist/daemon/api/handlers/validate.js +58 -0
  162. package/dist/daemon/api/map.js +1 -0
  163. package/dist/daemon/cron-run.js +7 -7
  164. package/dist/daemon/human/finish.js +3 -2
  165. package/dist/daemon/manage.d.ts +6 -6
  166. package/dist/daemon/manage.js +15 -16
  167. package/dist/daemon/review/finish.js +1 -10
  168. package/dist/pi-extensions/canvas-context-intro.js +1 -14
  169. package/dist/pi-extensions/canvas-doc-substrate.js +2 -2
  170. package/dist/pi-extensions/canvas-preview-result.d.ts +22 -0
  171. package/dist/pi-extensions/canvas-preview-result.js +60 -0
  172. package/dist/pi-extensions/canvas-review-boundary.js +1 -14
  173. package/dist/pi-extensions/truncate.d.ts +5 -0
  174. package/dist/pi-extensions/truncate.js +14 -0
  175. package/dist/shared/predicates.d.ts +2 -0
  176. package/dist/shared/predicates.js +4 -0
  177. package/dist/shared/shell-quote.d.ts +3 -0
  178. package/dist/shared/shell-quote.js +5 -0
  179. package/dist/shared/tool-groups.d.ts +4 -2
  180. package/dist/shared/tool-groups.js +3 -3
  181. package/dist/types.d.ts +8 -5
  182. package/package.json +1 -1
  183. package/runtime.lock.json +2 -2
  184. package/dist/core/substrate/on-read-pure.d.ts +0 -7
  185. package/dist/core/substrate/on-read-pure.js +0 -272
@@ -1,4 +1,5 @@
1
1
  // Unified command-manifest schema. Transport is the sole leaf-dialect discriminator.
2
+ import { isRecord } from '../../shared/predicates.js';
2
3
  // ---------------------------------------------------------------------------
3
4
  // Validation helpers
4
5
  // ---------------------------------------------------------------------------
@@ -41,9 +42,6 @@ const OUTPUT_KEYS = new Set(['name', 'type', 'required', 'constraint']);
41
42
  * may name, so a typo'd lowercase key fails at authoring time rather than
42
43
  * silently never resolving. */
43
44
  const ENV_VAR_NAME = /^[A-Z][A-Z0-9_]*$/;
44
- function isObject(v) {
45
- return typeof v === 'object' && v !== null && !Array.isArray(v);
46
- }
47
45
  function typeName(v) {
48
46
  if (v === null)
49
47
  return 'null';
@@ -60,7 +58,7 @@ function checkKeys(raw, allowed, path, issue) {
60
58
  return true;
61
59
  }
62
60
  function validateRootEntry(raw, path, issue) {
63
- if (!isObject(raw)) {
61
+ if (!isRecord(raw)) {
64
62
  issue('command_node_invalid', `rootEntry must be an object`, typeName(raw), 'a { concept, description, whenToUse } object', 'Fix rootEntry.', path);
65
63
  return null;
66
64
  }
@@ -91,7 +89,7 @@ function validateParams(raw, path, issue) {
91
89
  for (let i = 0; i < raw.length; i++) {
92
90
  const p = raw[i];
93
91
  const pp = `${path}[${i}]`;
94
- if (!isObject(p)) {
92
+ if (!isRecord(p)) {
95
93
  issue('command_node_invalid', `param must be an object`, typeName(p), 'a param object', 'Fix the param.', pp);
96
94
  return null;
97
95
  }
@@ -289,7 +287,7 @@ function validateOutput(raw, path, issue) {
289
287
  for (let i = 0; i < raw.length; i++) {
290
288
  const f = raw[i];
291
289
  const fp = `${path}[${i}]`;
292
- if (!isObject(f)) {
290
+ if (!isRecord(f)) {
293
291
  issue('command_node_invalid', `output field must be an object`, typeName(f), 'a { name, type, required, constraint } object', 'Fix the output field.', fp);
294
292
  return null;
295
293
  }
@@ -337,7 +335,7 @@ function checkCommon(raw, path, issue) {
337
335
  }
338
336
  export function validateCommandNode(raw, path, topLevel, transport, issue) {
339
337
  const pathString = path.join('.');
340
- if (!isObject(raw)) {
338
+ if (!isRecord(raw)) {
341
339
  issue('command_node_invalid', 'node must be an object', typeName(raw), 'a branch or leaf node object', 'Fix the node.', pathString);
342
340
  return null;
343
341
  }
@@ -424,7 +422,7 @@ function validateBranch(raw, path, topLevel, transport, issue) {
424
422
  }
425
423
  /** `passthrough: { bin, installHint }` — both required non-empty strings. */
426
424
  function validatePassthrough(raw, path, issue) {
427
- if (!isObject(raw)) {
425
+ if (!isRecord(raw)) {
428
426
  issue('command_node_invalid', 'passthrough must be an object', typeName(raw), 'an object { bin, installHint }', 'Fix or remove passthrough.', path);
429
427
  return null;
430
428
  }
@@ -482,7 +480,7 @@ const HTTP_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
482
480
  const REST_PARAM_PLACEMENT_SET = new Set(['path', 'query', 'body', 'header']);
483
481
  function validateRestMapping(raw, basePath, params, issue) {
484
482
  const path = `${basePath}.rest`;
485
- if (!isObject(raw)) {
483
+ if (!isRecord(raw)) {
486
484
  issue('command_rest_invalid', `rest must be an object`, typeName(raw), '{ method, path, streaming?, params }', 'Provide a valid rest mapping.', path);
487
485
  return null;
488
486
  }
@@ -523,7 +521,7 @@ function validateRestMapping(raw, basePath, params, issue) {
523
521
  }
524
522
  // Validate params mapping (required object)
525
523
  const restParams = raw['params'];
526
- if (!isObject(restParams)) {
524
+ if (!isRecord(restParams)) {
527
525
  issue('command_rest_invalid', `params must be an object`, typeName(restParams), 'a mapping of param names to placement specs', 'Provide params.', `${path}.params`);
528
526
  return null;
529
527
  }
@@ -542,7 +540,7 @@ function validateRestMapping(raw, basePath, params, issue) {
542
540
  return null;
543
541
  }
544
542
  declaredNames.add(paramName);
545
- if (!isObject(mapping)) {
543
+ if (!isRecord(mapping)) {
546
544
  issue('command_rest_invalid', `param mapping must be an object`, typeName(mapping), '{ in, as? }', 'Fix the param mapping.', `${path}.params.${paramName}`);
547
545
  return null;
548
546
  }
@@ -644,7 +642,7 @@ function validateRestMapping(raw, basePath, params, issue) {
644
642
  return null;
645
643
  }
646
644
  const rawBody = raw['body'];
647
- if (!isObject(rawBody)) {
645
+ if (!isRecord(rawBody)) {
648
646
  issue('command_rest_invalid', `body must be an object`, typeName(rawBody), 'a map of constant literal body fields (string | number | boolean)', 'Fix body.', `${path}.body`);
649
647
  return null;
650
648
  }
@@ -11,6 +11,7 @@ import { spawnSync } from 'node:child_process';
11
11
  import { CrtrError } from '../../errors.js';
12
12
  import { diag } from '../../io.js';
13
13
  import { ExitCode } from '../../../types.js';
14
+ import { isRecord } from '../../../shared/predicates.js';
14
15
  const MAX_STDOUT = 10 * 1024 * 1024; // 10 MiB hard cap on plugin stdout.
15
16
  const RESERVED_CODES = new Set(['internal', 'unknown_path', 'command_collision', 'plugin_protocol_error']);
16
17
  // Strict snake_case: no leading/trailing underscore, no consecutive underscores.
@@ -77,7 +78,7 @@ export function executeExternalLeaf(spec, input) {
77
78
  catch {
78
79
  throw protocolError(spec, `stdout was not a single JSON envelope`, 'exactly one JSON envelope on stdout');
79
80
  }
80
- if (!isObject(envelope)) {
81
+ if (!isRecord(envelope)) {
81
82
  throw protocolError(spec, `stdout was not a JSON object`, 'a { protocolVersion: 1, ok: boolean, ... } envelope');
82
83
  }
83
84
  if (envelope['protocolVersion'] !== 1) {
@@ -96,7 +97,7 @@ export function executeExternalLeaf(spec, input) {
96
97
  }
97
98
  if (ok) {
98
99
  const result = envelope['result'];
99
- if (!isObject(result)) {
100
+ if (!isRecord(result)) {
100
101
  throw protocolError(spec, `success envelope has no result object`, 'result: an object matching the declared output');
101
102
  }
102
103
  validateResult(spec, result);
@@ -104,7 +105,7 @@ export function executeExternalLeaf(spec, input) {
104
105
  }
105
106
  // Recoverable command failure → structured CrtrError with the plugin's code.
106
107
  const error = envelope['error'];
107
- if (!isObject(error) || typeof error['code'] !== 'string' || typeof error['message'] !== 'string') {
108
+ if (!isRecord(error) || typeof error['code'] !== 'string' || typeof error['message'] !== 'string') {
108
109
  throw protocolError(spec, `error envelope is missing a { code, message }`, 'error: { code, message, ... }');
109
110
  }
110
111
  const code = error['code'];
@@ -146,7 +147,7 @@ function typeMatches(type, v) {
146
147
  if (t === 'array' || t.endsWith('[]'))
147
148
  return Array.isArray(v);
148
149
  if (t === 'object')
149
- return isObject(v);
150
+ return isRecord(v);
150
151
  return true;
151
152
  }
152
153
  function protocolError(spec, received, expected) {
@@ -155,6 +156,3 @@ function protocolError(spec, received, expected) {
155
156
  function label(spec) {
156
157
  return `plugin "${spec.plugin.name}" command \`${spec.commandPath.join(' ')}\``;
157
158
  }
158
- function isObject(v) {
159
- return typeof v === 'object' && v !== null && !Array.isArray(v);
160
- }
@@ -17,8 +17,9 @@ import { request as httpRequest } from 'node:http';
17
17
  import { request as httpsRequest } from 'node:https';
18
18
  import { validateDeclaredResult } from './exec-invoke.js';
19
19
  import { CrtrError } from '../../errors.js';
20
- import { diag, writeStdout } from '../../io.js';
20
+ import { diag, recordPreviewError, recordPreviewJsonLine, writeStdout } from '../../io.js';
21
21
  import { ExitCode } from '../../../types.js';
22
+ import { isRecord } from '../../../shared/predicates.js';
22
23
  // ---------------------------------------------------------------------------
23
24
  // Constants
24
25
  // ---------------------------------------------------------------------------
@@ -320,7 +321,7 @@ function mapUnary2xx(spec, body) {
320
321
  catch {
321
322
  throw protocolError(spec, 'response body was not valid JSON', 'a JSON object matching the declared output');
322
323
  }
323
- if (!isObject(parsed)) {
324
+ if (!isRecord(parsed)) {
324
325
  throw protocolError(spec, `response body was not a JSON object (got ${typeName(parsed)})`, 'a JSON object matching the declared output');
325
326
  }
326
327
  const issue = validateDeclaredResult(spec.output, parsed);
@@ -420,6 +421,7 @@ async function requestStreaming(spec, plan) {
420
421
  if (line.trim() === '')
421
422
  continue; // drop blank records
422
423
  framesEmitted = true;
424
+ recordPreviewJsonLine(line);
423
425
  const ok = await writeStdout(line + '\n');
424
426
  if (!ok) {
425
427
  // Consumer closed the pipe (EPIPE) — stop cleanly, nothing to report.
@@ -434,6 +436,7 @@ async function requestStreaming(spec, plan) {
434
436
  // backend sent no trailing delimiter, so none is added).
435
437
  if (partial.trim() !== '') {
436
438
  framesEmitted = true;
439
+ recordPreviewJsonLine(partial);
437
440
  await writeStdout(partial);
438
441
  }
439
442
  return; // clean EOF → SUCCESS
@@ -444,7 +447,13 @@ async function requestStreaming(spec, plan) {
444
447
  if (framesEmitted) {
445
448
  // After a frame has been emitted: one concise stderr diagnostic + NETWORK
446
449
  // exit, and DO NOT throw (that would append an <error> to stdout).
447
- diag(`crtr: ${label(spec)}: stream interrupted (${detail})`);
450
+ const message = `${label(spec)}: stream interrupted (${detail})`;
451
+ diag(`crtr: ${message}`);
452
+ recordPreviewError({
453
+ error: 'stream_interrupted',
454
+ message,
455
+ next: 'Retry the command to begin a fresh stream.',
456
+ });
448
457
  process.exitCode = ExitCode.NETWORK;
449
458
  return;
450
459
  }
@@ -474,9 +483,9 @@ function throwNon2xx(spec, status, body) {
474
483
  catch {
475
484
  parsed = undefined;
476
485
  }
477
- if (isObject(parsed)) {
486
+ if (isRecord(parsed)) {
478
487
  const err = parsed['error'];
479
- if (isObject(err) && typeof err['code'] === 'string' && typeof err['message'] === 'string') {
488
+ if (isRecord(err) && typeof err['code'] === 'string' && typeof err['message'] === 'string') {
480
489
  const code = err['code'];
481
490
  const message = err['message'];
482
491
  const field = typeof err['field'] === 'string' ? err['field'] : undefined;
@@ -535,9 +544,6 @@ function readAll(res) {
535
544
  res.on('error', () => resolve(Buffer.concat(chunks).toString('utf8'))); // best-effort
536
545
  });
537
546
  }
538
- function isObject(v) {
539
- return typeof v === 'object' && v !== null && !Array.isArray(v);
540
- }
541
547
  function typeName(v) {
542
548
  if (v === null)
543
549
  return 'null';
@@ -5,7 +5,7 @@
5
5
  // A plain array walk + a small flag parser is ~120 lines and has no surprising
6
6
  // edge cases.
7
7
  import { renderRoot, renderBranch, renderLeafArgv } from './help.js';
8
- import { readStdinRaw, peekStdinRaw, emit, handle, setJsonOutput, isJsonOutput } from './io.js';
8
+ import { beginPreview, publishPreviewResult, readStdinRaw, peekStdinRaw, emit, handle, setJsonOutput, isJsonOutput } from './io.js';
9
9
  import { renderResult } from './render.js';
10
10
  import { CrtrError } from './errors.js';
11
11
  import { operationIdContext } from './events/operation-id.js';
@@ -614,6 +614,7 @@ export async function runCli(root, argv) {
614
614
  // below: `node` is now a reassignable `let` (the §5.1 retry re-walks it),
615
615
  // which TS re-widens to the node union across an `await`.
616
616
  const leaf = node;
617
+ beginPreview(path.join(' '));
617
618
  const params = leaf.help.params !== undefined ? leaf.help.params : [];
618
619
  let providedParams = new Set();
619
620
  let contextFilePaths = new Map();
@@ -628,6 +629,7 @@ export async function runCli(root, argv) {
628
629
  });
629
630
  const context = { providedParams, contextFilePaths };
630
631
  const result = await operationIdContext.fresh(() => leaf.run(input, context));
632
+ publishPreviewResult(result);
631
633
  if (result !== undefined && result !== null) {
632
634
  if (isJsonOutput()) {
633
635
  emit(result);
@@ -81,7 +81,7 @@ export declare function readMergedLaunchConfig(targetCwd?: string, targetProfile
81
81
  * `developer`, or sub-kind, e.g. `plan/reviewers/security`), across
82
82
  * project > user > builtin precedence. Returns `undefined` for a kind no
83
83
  * scope registers — existence is deliberately NOT validated here (kind
84
- * existence/launch-menu enumeration is a Phase 3 caller concern); this only
84
+ * existence/launch-menu enumeration is a caller concern); this only
85
85
  * resolves the config for a kind the caller already knows about. */
86
86
  export declare function resolveKindConfig(kind: string): KindConfig | undefined;
87
87
  /** The sub-kinds available to spawn FROM a given top-level kind — every
@@ -1,4 +1,3 @@
1
- import { mkdirSync, rmSync } from 'node:fs';
2
1
  import { dirname, join } from 'node:path';
3
2
  import { isDeepStrictEqual } from 'node:util';
4
3
  import { CONFIG_FILE, STATE_FILE, CONDENSED_HISTORY_MODES, MOUSE_MODE_DEFAULTS, DEFAULT_WHIP_MESSAGES, defaultScopeConfig, defaultScopeState, defaultModelLaddersConfig, defaultKindsConfig, defaultRemoteCanvasConfig } from '../types.js';
@@ -7,6 +6,7 @@ import { emitEvent } from './events/emit.js';
7
6
  import { atomicWriteJson, readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
8
7
  import { scopeRoot, requireScopeRoot, findProjectScopeRoots } from './scope.js';
9
8
  import { profileRoot } from './profiles/manifest.js';
9
+ import { withExclusiveDirectoryLock } from './exclusive-lock.js';
10
10
  import { SCOPE_CONFIG_KEYS } from './user-settings.js';
11
11
  import { normalizeWorkingGerunds } from '../shared/working-activity.js';
12
12
  function configPathFor(root) {
@@ -54,34 +54,11 @@ export function readState(scope) {
54
54
  activeCanvas: existing.activeCanvas ?? null,
55
55
  };
56
56
  }
57
- const CONFIG_LOCK_TIMEOUT_MS = 5000;
58
- const CONFIG_LOCK_POLL_MS = 25;
59
- function sleepSync(ms) {
60
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
61
- }
62
57
  function withConfigPathLock(path, run) {
63
58
  ensureDir(dirname(path));
64
- const lockPath = `${path}.lock`;
65
- const deadline = Date.now() + CONFIG_LOCK_TIMEOUT_MS;
66
- for (;;) {
67
- try {
68
- mkdirSync(lockPath);
69
- break;
70
- }
71
- catch (error) {
72
- if (error.code !== 'EEXIST')
73
- throw error;
74
- if (Date.now() > deadline)
75
- throw new Error(`timed out waiting for config lock: ${path}`);
76
- sleepSync(CONFIG_LOCK_POLL_MS);
77
- }
78
- }
79
- try {
80
- return run(path);
81
- }
82
- finally {
83
- rmSync(lockPath, { recursive: true, force: true });
84
- }
59
+ return withExclusiveDirectoryLock(`${path}.lock`, () => run(path), {
60
+ timeoutError: () => new Error(`timed out waiting for config lock: ${path}`),
61
+ });
85
62
  }
86
63
  function withConfigLock(scope, run) {
87
64
  const root = requireScopeRoot(scope);
@@ -583,7 +560,7 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
583
560
  * `developer`, or sub-kind, e.g. `plan/reviewers/security`), across
584
561
  * project > user > builtin precedence. Returns `undefined` for a kind no
585
562
  * scope registers — existence is deliberately NOT validated here (kind
586
- * existence/launch-menu enumeration is a Phase 3 caller concern); this only
563
+ * existence/launch-menu enumeration is a caller concern); this only
587
564
  * resolves the config for a kind the caller already knows about. */
588
565
  export function resolveKindConfig(kind) {
589
566
  return readMergedLaunchConfig().kinds[kind];
@@ -645,7 +622,7 @@ function sparseModelLaddersToPersist(rawLadders, baseline, mutated) {
645
622
  * sparse override stays sparse), and only keys the mutation actually changed
646
623
  * are written. `modelLadders` gets sub-field-granular sparse handling via
647
624
  * `sparseModelLaddersToPersist`. Result: a fresh bootstrap or any unrelated
648
- * `sys config set` no longer freezes a full concrete `modelLadders` to disk,
625
+ * `sys config set` never freezes a full concrete `modelLadders` to disk,
649
626
  * and a direct ladder-rung set still persists only the touched rung -- all
650
627
  * through one lock-protected atomic update path. */
651
628
  function mutateConfigAtPath(path, mutate) {
@@ -4,6 +4,7 @@ import { crtrHome, isSafeNodeId, nodesRoot } from '../canvas/paths.js';
4
4
  import { validateOperationId } from './operation-id.js';
5
5
  import { MAX_EVENT_RECORD_BYTES } from './serialize.js';
6
6
  import { errorClassCodes, } from './types.js';
7
+ import { isRecord } from '../../shared/predicates.js';
7
8
  const SNAPSHOT_ITEMS = 200;
8
9
  const MAX_CANONICAL_SOURCE_BYTES = 6 * 1024 * 1024;
9
10
  const MAX_RAW_SOURCE_BYTES = 256 * 1024;
@@ -41,9 +42,6 @@ const RETRY_DISPOSITIONS = new Set(['auto', 'manual', 'fatal']);
41
42
  const ERROR_CLASSES = new Set(errorClassCodes);
42
43
  const LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
43
44
  const GENERATION_RANK = { 2: 0, 1: 1, 0: 2 };
44
- function isRecord(value) {
45
- return typeof value === 'object' && value !== null && !Array.isArray(value);
46
- }
47
45
  function hasOwn(value, key) {
48
46
  return Object.prototype.hasOwnProperty.call(value, key);
49
47
  }
@@ -0,0 +1,14 @@
1
+ export interface ExclusiveLockOptions {
2
+ /** Grace before a lock that names no owner at all is cleared. */
3
+ staleMs?: number;
4
+ timeoutMs?: number;
5
+ /** The error thrown when the wait runs out, so callers keep their own remediation. */
6
+ timeoutError?: () => Error;
7
+ }
8
+ /**
9
+ * Run a short filesystem transition under a token-checked, crash-reclaimable
10
+ * directory lock. A holder that dies releases immediately — the next contender
11
+ * sees a dead pid in the marker name — while a live holder is never stolen from,
12
+ * however long its operation runs.
13
+ */
14
+ export declare function withExclusiveDirectoryLock<T>(path: string, operation: () => T, options?: ExclusiveLockOptions): T;
@@ -0,0 +1,124 @@
1
+ import { existsSync, mkdirSync, readdirSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ const MARKER_PREFIX = 'owner.';
4
+ const POLL_MS = 10;
5
+ const DEFAULT_STALE_MS = 30_000;
6
+ const DEFAULT_TIMEOUT_MS = 5_000;
7
+ function pause(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
8
+ function markerPath(path, token) { return `${path}/${MARKER_PREFIX}${token}`; }
9
+ function observedMarkerToken(path) {
10
+ try {
11
+ return readdirSync(path).find((entry) => entry.startsWith(MARKER_PREFIX))?.slice(MARKER_PREFIX.length) ?? null;
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ /** The token opens with the owner's pid, so liveness reads off the name. */
18
+ function ownerIsAlive(token) {
19
+ const pid = Number(token.split('.', 1)[0]);
20
+ if (!Number.isSafeInteger(pid) || pid <= 0)
21
+ return false;
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ }
26
+ catch (error) {
27
+ return error.code === 'EPERM';
28
+ }
29
+ }
30
+ function isEmptyDirectory(path) {
31
+ try {
32
+ return readdirSync(path).length === 0;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ function ageMs(path) {
39
+ try {
40
+ return Date.now() - statSync(path).mtimeMs;
41
+ }
42
+ catch {
43
+ return 0;
44
+ }
45
+ }
46
+ // Reclamation is bound to the exact instance it observed: it unlinks ONLY that
47
+ // token's marker, then removes the now-empty directory with an empty-guarded
48
+ // rmdir. A successor holds a different random token — a different marker name —
49
+ // so a lagging reclaimer that saw the old dead lock can never strip a live
50
+ // successor: its unlink targets a name that no longer exists.
51
+ function reclaimIfOwnerGone(path, staleMs) {
52
+ const token = observedMarkerToken(path);
53
+ if (token !== null) {
54
+ if (ownerIsAlive(token))
55
+ return;
56
+ try {
57
+ unlinkSync(markerPath(path, token));
58
+ }
59
+ catch (error) {
60
+ if (error.code === 'ENOENT')
61
+ return;
62
+ throw error;
63
+ }
64
+ }
65
+ else {
66
+ // Nothing names an owner. A live holder always has its marker, so this is a
67
+ // crash between mkdir and the marker write, or a lock left in some older
68
+ // shape; age is the only evidence, and an empty-guarded rmdir still cannot
69
+ // take a successor that has since claimed the name.
70
+ if (ageMs(path) <= staleMs)
71
+ return;
72
+ if (!isEmptyDirectory(path)) {
73
+ rmSync(path, { recursive: true, force: true });
74
+ return;
75
+ }
76
+ }
77
+ try {
78
+ rmdirSync(path);
79
+ }
80
+ catch (error) {
81
+ const code = error.code;
82
+ if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST')
83
+ throw error;
84
+ }
85
+ }
86
+ function tryAcquire(path, staleMs) {
87
+ const token = `${process.pid}.${randomUUID()}`;
88
+ try {
89
+ mkdirSync(path, { mode: 0o700 });
90
+ writeFileSync(markerPath(path, token), '', { flag: 'wx', mode: 0o600 });
91
+ return { path, token };
92
+ }
93
+ catch (error) {
94
+ if (error.code !== 'EEXIST')
95
+ throw error;
96
+ reclaimIfOwnerGone(path, staleMs);
97
+ return null;
98
+ }
99
+ }
100
+ /**
101
+ * Run a short filesystem transition under a token-checked, crash-reclaimable
102
+ * directory lock. A holder that dies releases immediately — the next contender
103
+ * sees a dead pid in the marker name — while a live holder is never stolen from,
104
+ * however long its operation runs.
105
+ */
106
+ export function withExclusiveDirectoryLock(path, operation, options = {}) {
107
+ const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
108
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
109
+ let lock = tryAcquire(path, staleMs);
110
+ while (lock === null) {
111
+ if (Date.now() >= deadline) {
112
+ throw options.timeoutError?.() ?? new Error(`timed out waiting for the exclusive lock: ${path}`);
113
+ }
114
+ pause(POLL_MS);
115
+ lock = tryAcquire(path, staleMs);
116
+ }
117
+ try {
118
+ return operation();
119
+ }
120
+ finally {
121
+ if (existsSync(markerPath(lock.path, lock.token)))
122
+ rmSync(lock.path, { recursive: true, force: true });
123
+ }
124
+ }
@@ -1,4 +1,5 @@
1
1
  import { hasManagedProviderCoolingDiagnostic } from './runtime/managed-provider-cooling.js';
2
+ import { isRecord } from '../shared/predicates.js';
2
3
  const errorClassByFaultKind = {
3
4
  'rate-limit': 'rate_limit',
4
5
  overloaded: 'overloaded',
@@ -16,9 +17,6 @@ export function errorClassFromFault(fault) {
16
17
  retry_disposition: fault.retry.disposition,
17
18
  };
18
19
  }
19
- function isRecord(value) {
20
- return typeof value === 'object' && value !== null;
21
- }
22
20
  function text(value) {
23
21
  return typeof value === 'string' ? value : '';
24
22
  }
@@ -1,9 +1,16 @@
1
+ /** `realpathSync`, tolerant of a path that doesn't exist or can't be resolved. */
2
+ export declare function realpathOrSelf(p: string): string;
3
+ /** Expand a leading `~` against `homeDir` (pi's own convention). Other forms
4
+ * — absolute, relative, `~user` — pass through as pi leaves them. */
5
+ export declare function expandTilde(p: string, homeDir?: string): string;
1
6
  export declare function ensureDir(dir: string): void;
2
7
  export declare function writeJson(path: string, data: unknown): void;
3
8
  export declare function atomicWriteJson(path: string, data: unknown): void;
4
9
  export declare function atomicWriteText(path: string, content: string | Uint8Array): void;
5
10
  export declare function readJson<T = unknown>(path: string): T;
6
11
  export declare function readJsonIfExists<T = unknown>(path: string): T | null;
12
+ /** Missing or corrupt reads as absent, so a crash artifact never throws. */
13
+ export declare function readJsonOrNull<T = unknown>(path: string): T | null;
7
14
  export declare function readTextIfExists(path: string): string | null;
8
15
  export declare function readText(path: string): string;
9
16
  export declare function writeText(path: string, content: string): void;
@@ -1,6 +1,24 @@
1
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync, readlinkSync, renameSync, chmodSync, } from 'node:fs';
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync, readlinkSync, renameSync, chmodSync, realpathSync, } from 'node:fs';
2
2
  import { dirname, join, relative } from 'node:path';
3
- import { platform } from 'node:os';
3
+ import { homedir, platform } from 'node:os';
4
+ /** `realpathSync`, tolerant of a path that doesn't exist or can't be resolved. */
5
+ export function realpathOrSelf(p) {
6
+ try {
7
+ return realpathSync(p);
8
+ }
9
+ catch {
10
+ return p;
11
+ }
12
+ }
13
+ /** Expand a leading `~` against `homeDir` (pi's own convention). Other forms
14
+ * — absolute, relative, `~user` — pass through as pi leaves them. */
15
+ export function expandTilde(p, homeDir = homedir()) {
16
+ if (p === '~')
17
+ return homeDir;
18
+ if (p.startsWith('~/') || (platform() === 'win32' && p.startsWith('~\\')))
19
+ return join(homeDir, p.slice(2));
20
+ return p;
21
+ }
4
22
  export function ensureDir(dir) {
5
23
  mkdirSync(dir, { recursive: true });
6
24
  }
@@ -44,6 +62,15 @@ export function readJsonIfExists(path) {
44
62
  return null;
45
63
  return readJson(path);
46
64
  }
65
+ /** Missing or corrupt reads as absent, so a crash artifact never throws. */
66
+ export function readJsonOrNull(path) {
67
+ try {
68
+ return JSON.parse(readFileSync(path, 'utf8'));
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
47
74
  export function readTextIfExists(path) {
48
75
  if (!existsSync(path))
49
76
  return null;
@@ -3,12 +3,11 @@
3
3
  * generated slash-command templates to `~/.claude/commands` and
4
4
  * `~/.pi/agent/prompts`.
5
5
  *
6
- * crouter no longer exports Agent Skills / SKILL.md bundles. The export pass
7
- * still prunes old marker-bearing `crtr-skills` bundles so hosts stop surfacing
8
- * pi/Claude native skills after the memory cutover. Markerless files are
9
- * user-owned and never removed.
6
+ * crouter exports no Agent Skills / SKILL.md bundles. The export pass prunes
7
+ * marker-bearing `crtr-skills` bundles so hosts stop surfacing pi/Claude native
8
+ * skills. Markerless files are user-owned and never removed.
10
9
  *
11
- * Triggered per-run by cli.ts (argv-gated, exactly as the old writers were) so
10
+ * Triggered per-run by cli.ts (argv-gated) so
12
11
  * first-run provisioning is preserved. Best-effort: a provisioning fault must
13
12
  * never break an unrelated `crtr` command (the deliberate, pre-existing
14
13
  * behavior of the deleted writers).
@@ -3,12 +3,11 @@
3
3
  * generated slash-command templates to `~/.claude/commands` and
4
4
  * `~/.pi/agent/prompts`.
5
5
  *
6
- * crouter no longer exports Agent Skills / SKILL.md bundles. The export pass
7
- * still prunes old marker-bearing `crtr-skills` bundles so hosts stop surfacing
8
- * pi/Claude native skills after the memory cutover. Markerless files are
9
- * user-owned and never removed.
6
+ * crouter exports no Agent Skills / SKILL.md bundles. The export pass prunes
7
+ * marker-bearing `crtr-skills` bundles so hosts stop surfacing pi/Claude native
8
+ * skills. Markerless files are user-owned and never removed.
10
9
  *
11
- * Triggered per-run by cli.ts (argv-gated, exactly as the old writers were) so
10
+ * Triggered per-run by cli.ts (argv-gated) so
12
11
  * first-run provisioning is preserved. Best-effort: a provisioning fault must
13
12
  * never break an unrelated `crtr` command (the deliberate, pre-existing
14
13
  * behavior of the deleted writers).
@@ -1,7 +1,9 @@
1
1
  import { existsSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { hostname } from 'node:os';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { claimPath, deckPath, readJson, responsePath, reviewPath, withExclusiveDirectoryLock } from './convention.js';
4
+ import { claimPath, deckPath, responsePath, reviewPath } from './convention.js';
5
+ import { readJsonOrNull } from '../fs-utils.js';
6
+ import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
5
7
  const REMOTE_STALE_MS = 30_000;
6
8
  function parseClaim(raw) {
7
9
  if (typeof raw !== 'object' || raw === null)
@@ -11,7 +13,7 @@ function parseClaim(raw) {
11
13
  return null;
12
14
  return { token: claim.token, host: claim.host, pid: claim.pid, claimedAt: claim.claimedAt, heartbeatAt: claim.heartbeatAt, ...(typeof claim.tmuxClient === 'string' ? { tmuxClient: claim.tmuxClient } : {}) };
13
15
  }
14
- export function readTicketClaim(dir) { return parseClaim(readJson(claimPath(dir))); }
16
+ export function readTicketClaim(dir) { return parseClaim(readJsonOrNull(claimPath(dir))); }
15
17
  function isLiveLocalPid(pid) { try {
16
18
  process.kill(pid, 0);
17
19
  return true;
@@ -14,17 +14,6 @@ export declare function stampCanvasNode(deck: Deck): void;
14
14
  export declare function atomicWriteJson(path: string, value: unknown): void;
15
15
  /** Publish one immutable JSON record without replacing an existing winner. */
16
16
  export declare function publishJsonExclusive(path: string, value: unknown): boolean;
17
- export declare function readJson<T>(path: string): T | null;
18
- /** Runs a short filesystem transition under a token-checked, crash-reclaimable directory lock. */
19
- export declare function withExclusiveDirectoryLock<T>(path: string, operation: () => T, options?: {
20
- staleMs?: number;
21
- timeoutMs?: number;
22
- }): T;
23
- /** Async counterpart heartbeats while its operation runs, so a valid long handler is never stolen. */
24
- export declare function withExclusiveDirectoryLockAsync<T>(path: string, operation: () => Promise<T>, options?: {
25
- staleMs?: number;
26
- timeoutMs?: number;
27
- }): Promise<T>;
28
17
  export declare function writeResponse(dir: string, responses: InteractionResponse[], completedAt: string, deck?: Deck): string;
29
18
  export declare function writeProgress(dir: string, responses: InteractionResponse[]): void;
30
19
  export declare function clearProgress(dir: string): void;