@north-light/crouter 0.3.175 → 0.3.176

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 (63) hide show
  1. package/dist/api/dto/nodes.d.ts +7 -1
  2. package/dist/api/dto/profiles.d.ts +3 -0
  3. package/dist/clients/attach/render/crtr-output.d.ts +3 -3
  4. package/dist/clients/attach/render/crtr-output.js +25 -13
  5. package/dist/clients/attach/viewer.js +531 -561
  6. package/dist/commands/node/create.js +7 -3
  7. package/dist/commands/profile/list.js +3 -1
  8. package/dist/commands/profile/new.js +4 -1
  9. package/dist/commands/profile/project.js +23 -9
  10. package/dist/commands/profile/show.js +3 -1
  11. package/dist/commands/profile.js +2 -2
  12. package/dist/commands/sys/logs.js +4 -2
  13. package/dist/core/__tests__/branded-host-concurrency.test.js +1 -1
  14. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +1 -1
  15. package/dist/core/__tests__/on-read-dedup-resume.test.js +1 -1
  16. package/dist/core/canvas/extensions.d.ts +1 -1
  17. package/dist/core/canvas/extensions.js +7 -1
  18. package/dist/core/command-plugins/transport/http-invoke.js +10 -2
  19. package/dist/core/command.js +3 -1
  20. package/dist/core/config.js +4 -27
  21. package/dist/core/exclusive-lock.d.ts +14 -0
  22. package/dist/core/exclusive-lock.js +124 -0
  23. package/dist/core/human/claim.js +2 -1
  24. package/dist/core/human/convention.d.ts +0 -10
  25. package/dist/core/human/convention.js +1 -123
  26. package/dist/core/io.d.ts +23 -0
  27. package/dist/core/io.js +60 -0
  28. package/dist/core/preview-registry.d.ts +10 -5
  29. package/dist/core/preview-registry.js +136 -2537
  30. package/dist/core/profiles/manifest.d.ts +12 -1
  31. package/dist/core/profiles/manifest.js +57 -48
  32. package/dist/core/review/store.d.ts +4 -1
  33. package/dist/core/review/store.js +10 -0
  34. package/dist/core/runtime/branded-host.js +6 -84
  35. package/dist/core/runtime/broker-extension-render.js +1 -1
  36. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  37. package/dist/core/runtime/canvas-extensions.js +2 -0
  38. package/dist/core/runtime/tmux-bindings.js +1 -1
  39. package/dist/core/runtime/warm-pool.d.ts +9 -8
  40. package/dist/core/runtime/warm-pool.js +58 -62
  41. package/dist/core/substrate/index.d.ts +2 -1
  42. package/dist/core/substrate/index.js +2 -1
  43. package/dist/core/substrate/on-read-node.d.ts +7 -0
  44. package/dist/core/substrate/on-read-node.js +37 -0
  45. package/dist/core/substrate/on-read.d.ts +7 -5
  46. package/dist/core/substrate/on-read.js +15 -23
  47. package/dist/core/termrender/termrender.js +16 -96
  48. package/dist/core/worktree.js +11 -100
  49. package/dist/daemon/api/handlers/nodes.js +32 -9
  50. package/dist/daemon/api/handlers/review-comments.js +5 -44
  51. package/dist/daemon/api/handlers/reviews.js +5 -53
  52. package/dist/daemon/api/handlers/validate.d.ts +22 -0
  53. package/dist/daemon/api/handlers/validate.js +59 -0
  54. package/dist/daemon/api/map.js +1 -0
  55. package/dist/daemon/review/finish.js +1 -10
  56. package/dist/pi-extensions/canvas-doc-substrate.js +1 -1
  57. package/dist/pi-extensions/canvas-preview-result.d.ts +22 -0
  58. package/dist/pi-extensions/canvas-preview-result.js +60 -0
  59. package/dist/types.d.ts +4 -0
  60. package/package.json +1 -1
  61. package/runtime.lock.json +2 -2
  62. package/dist/core/substrate/on-read-pure.d.ts +0 -7
  63. package/dist/core/substrate/on-read-pure.js +0 -272
@@ -71,7 +71,7 @@ function nodeNewParams() {
71
71
  { kind: 'stdin', name: 'prompt', required: false, constraint: 'First user message. Required unless --no-kickoff is given.' },
72
72
  { kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind. The <kinds> list below names every top-level installable kind.' },
73
73
  { kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. base for hands-on work; orchestrator when the unit itself needs decomposition across children.' },
74
- { kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Directory the node is pinned to. Defaults to the caller cwd.' },
74
+ { kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Pin the node to this directory. Defaults to the spawner\u2019s directory for a managed child, and to the profile home for a root (`crtr profile show <profile>`).' },
75
75
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name. Defaults to the kind.' },
76
76
  { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node.' },
77
77
  { kind: 'flag', name: 'node-id', type: 'string', required: false, constraint: 'Exact id for the new node; lowercase letters, digits, and hyphens only, at most 128 characters. Rejected if already present.' },
@@ -106,7 +106,7 @@ async function runNodeCreation(input) {
106
106
  }
107
107
  const kind = input['kind'] ?? 'general';
108
108
  const mode = (input['mode'] ?? 'base');
109
- const cwd = input['cwd'] ?? process.cwd();
109
+ const pinCwd = input['cwd']?.trim();
110
110
  const name = input['name'];
111
111
  const parent = input['parent'];
112
112
  const root = input['root'] === true;
@@ -140,7 +140,11 @@ async function runNodeCreation(input) {
140
140
  const detail = await cliClient().createNode({
141
141
  kind,
142
142
  mode,
143
- cwd,
143
+ // The caller's directory is the profile-SELECTION context; where the node
144
+ // actually lands is resolved server-side (pin > spawner's cwd > profile
145
+ // home). Only `--cwd` pins.
146
+ cwd: process.cwd(),
147
+ ...(pinCwd !== undefined && pinCwd !== '' ? { pin_cwd: pinCwd } : {}),
144
148
  ...(name !== undefined ? { name } : {}),
145
149
  prompt,
146
150
  ...(root ? { root: true } : { parent: parentId ?? null }),
@@ -4,7 +4,7 @@ import { listProfiles } from '../../core/profiles/manifest.js';
4
4
  export const listLeaf = defineLeaf({
5
5
  name: 'list',
6
6
  description: 'inventory every profile',
7
- whenToUse: 'browse every profile that exists — one line each with its id, name, project count, and last-used time. Reach for `profile show` once you have picked one, for its full project list.',
7
+ whenToUse: 'browse every profile that exists — one line each with its id, name, project count, home directory, and last-used time. Reach for `profile show` once you have picked one, for its full project list.',
8
8
  help: {
9
9
  name: 'profile list',
10
10
  summary: 'every profile as JSONL, one line each, sorted by name then profile_id',
@@ -12,6 +12,7 @@ export const listLeaf = defineLeaf({
12
12
  { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
13
13
  { name: 'name', type: 'string', required: true, constraint: 'Manifest name.' },
14
14
  { name: 'projects_count', type: 'int', required: true, constraint: 'Number of project directories on the manifest.' },
15
+ { name: 'home', type: 'string | null', required: true, constraint: 'Directory the profile\u2019s nodes are pinned to, or null when it owns no projects.' },
15
16
  { name: 'last_used_at', type: 'string | null', required: true, constraint: 'ISO 8601 timestamp of the most recent selection, or null if never selected.' },
16
17
  ],
17
18
  outputKind: 'jsonl',
@@ -27,6 +28,7 @@ export const listLeaf = defineLeaf({
27
28
  profile_id: profileId,
28
29
  name: manifest.name,
29
30
  projects_count: manifest.projects.length,
31
+ home: manifest.home,
30
32
  last_used_at: manifest.last_used_at,
31
33
  });
32
34
  }
@@ -28,7 +28,7 @@ export const newLeaf = defineLeaf({
28
28
  name: 'project',
29
29
  type: 'path',
30
30
  required: false,
31
- constraint: 'One initial project directory. Must already exist and be a directory; resolved to its absolute real path before storing. Singular — add more with `crtr profile project add` after creation.',
31
+ constraint: "One initial project directory. Must already exist and be a directory; resolved to its absolute real path before storing. Also becomes the profile's home — where its nodes are pinned. Singular — add more with `crtr profile project add` after creation.",
32
32
  },
33
33
  ],
34
34
  output: [
@@ -36,6 +36,7 @@ export const newLeaf = defineLeaf({
36
36
  { name: 'name', type: 'string', required: true, constraint: 'The manifest name as given.' },
37
37
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the profile directory.' },
38
38
  { name: 'projects', type: 'string[]', required: true, constraint: 'Resolved absolute project directories, in manifest order. Empty when --project was omitted.' },
39
+ { name: 'home', type: 'string | null', required: true, constraint: 'Directory the profile\u2019s nodes are pinned to — the initial project, or null when none was given.' },
39
40
  { name: 'created_at', type: 'string', required: true, constraint: 'ISO 8601 creation timestamp.' },
40
41
  { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
41
42
  ],
@@ -56,6 +57,7 @@ export const newLeaf = defineLeaf({
56
57
  name: manifest.name,
57
58
  path: profileRoot(profileId),
58
59
  projects: manifest.projects,
60
+ home: manifest.home,
59
61
  created_at: manifest.created_at,
60
62
  follow_up: `Root profile ensured. Spawn nodes under it with \`--profile ${profileId}\`, or widen its purview with \`crtr profile project add ${profileId} --dir <path>\`.`,
61
63
  };
@@ -68,6 +70,7 @@ export const newLeaf = defineLeaf({
68
70
  name: manifest.name,
69
71
  path: profileRoot(profileId),
70
72
  projects: manifest.projects,
73
+ home: manifest.home,
71
74
  created_at: manifest.created_at,
72
75
  follow_up: `Show it with \`crtr profile show ${profileId}\`, add another project with \`crtr profile project add ${profileId} --dir <path>\`, or list every profile with \`crtr profile list\`.`,
73
76
  };
@@ -1,13 +1,14 @@
1
1
  // `crtr profile project` — the project-purview sub-noun of `profile`. A
2
- // profile's `projects` list is what it can SEE (memory + config resolution).
3
- // These two verbs widen or narrow that list after creation; `crtr profile
4
- // show` reads it back.
2
+ // profile's `projects` list is what it can SEE (memory + config resolution),
3
+ // and its `home` — one of those dirs — is where its nodes actually run. These
4
+ // two verbs widen or narrow that list after creation and re-point the home;
5
+ // `crtr profile show` reads both back.
5
6
  import { defineLeaf, defineBranch } from '../../core/command.js';
6
7
  import { addProfileProject, removeProfileProject, loadProfileManifest } from '../../core/profiles/manifest.js';
7
8
  const addLeaf = defineLeaf({
8
9
  name: 'add',
9
10
  description: "add a project directory to a profile's purview",
10
- whenToUse: "a profile needs to see (memory + config from) another project directory — extend its purview after creation, including mid-session from a node already running under that profile.",
11
+ whenToUse: "a profile needs to see (memory + config from) another project directory — extend its purview after creation, including mid-session from a node already running under that profile. Also the way to re-point the profile's home, with --home.",
11
12
  help: {
12
13
  name: 'profile project add',
13
14
  summary: "append a project directory to a profile's manifest, deduped by real path",
@@ -25,21 +26,32 @@ const addLeaf = defineLeaf({
25
26
  required: true,
26
27
  constraint: 'Directory to add. Must exist and be a directory; resolved to its absolute real path before storing. A path already on the manifest is a no-op, not an error.',
27
28
  },
29
+ {
30
+ kind: 'flag',
31
+ name: 'home',
32
+ type: 'bool',
33
+ required: false,
34
+ constraint: "Also make this directory the profile's home — where every node under it is pinned unless the create passes an explicit --cwd. Defaults to the first project dir; pass this on an already-listed dir to re-point it.",
35
+ },
28
36
  ],
29
37
  output: [
30
38
  { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
31
39
  { name: 'projects', type: 'string[]', required: true, constraint: 'The updated, deduped project list in manifest order.' },
40
+ { name: 'home', type: 'string | null', required: true, constraint: "The profile's home directory after the change." },
32
41
  { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
33
42
  ],
34
43
  outputKind: 'object',
35
- effects: ["Appends to the profile manifest's `projects` array. Invalidates the process scope cache."],
44
+ effects: ["Appends to the profile manifest's `projects` array, and rewrites `home` when --home is given. Invalidates the process scope cache."],
36
45
  },
37
46
  run: async (input) => {
38
47
  const { profileId } = loadProfileManifest(input['profile']);
39
- const { manifest } = addProfileProject(profileId, input['dir']);
48
+ const { manifest } = addProfileProject(profileId, input['dir'], {
49
+ home: input['home'] === true,
50
+ });
40
51
  return {
41
52
  profile_id: profileId,
42
53
  projects: manifest.projects,
54
+ home: manifest.home,
43
55
  follow_up: `Show the full manifest with \`crtr profile show ${profileId}\`.`,
44
56
  };
45
57
  },
@@ -63,16 +75,17 @@ const removeLeaf = defineLeaf({
63
75
  name: 'dir',
64
76
  type: 'path',
65
77
  required: true,
66
- constraint: 'Directory to remove, matched by its resolved real path against the manifest. Does not require the directory to still exist on disk.',
78
+ constraint: 'Directory to remove, matched by its resolved real path against the manifest. Does not require the directory to still exist on disk. Removing the home dir re-points the home at the first remaining project.',
67
79
  },
68
80
  ],
69
81
  output: [
70
82
  { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
71
83
  { name: 'projects', type: 'string[]', required: true, constraint: 'The updated project list in manifest order.' },
84
+ { name: 'home', type: 'string | null', required: true, constraint: "The profile's home directory after the change." },
72
85
  { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
73
86
  ],
74
87
  outputKind: 'object',
75
- effects: ["Removes an entry from the profile manifest's `projects` array. Invalidates the process scope cache."],
88
+ effects: ["Removes an entry from the profile manifest's `projects` array, re-pointing `home` when that entry was it. Invalidates the process scope cache."],
76
89
  },
77
90
  run: async (input) => {
78
91
  const { profileId } = loadProfileManifest(input['profile']);
@@ -80,6 +93,7 @@ const removeLeaf = defineLeaf({
80
93
  return {
81
94
  profile_id: profileId,
82
95
  projects: manifest.projects,
96
+ home: manifest.home,
83
97
  follow_up: `Show the full manifest with \`crtr profile show ${profileId}\`.`,
84
98
  };
85
99
  },
@@ -91,7 +105,7 @@ export const projectBranch = defineBranch({
91
105
  help: {
92
106
  name: 'profile project',
93
107
  summary: "manage a profile's project purview — the directories it resolves memory and config from",
94
- model: "A profile's `projects` list is the set of directories it inherits memory and config from. `add` widens that purview by one directory (deduped by real path); `remove` narrows it. Read the current list with `crtr profile show`. Purview is independent of a per-cwd default pin (`crtr profile default`) — widening purview never changes which profile a directory defaults to.",
108
+ model: "A profile's `projects` list is the set of directories it inherits memory and config from, and its `home` — the first project unless `add --home` says otherwise — is the directory its nodes are pinned to. `add` widens that purview by one directory (deduped by real path) and optionally re-points the home; `remove` narrows it, re-pointing the home when it removed it. Read both back with `crtr profile show`. Purview is independent of a per-cwd default pin (`crtr profile default`) — widening purview never changes which profile a directory defaults to.",
95
109
  },
96
110
  children: [addLeaf, removeLeaf],
97
111
  });
@@ -6,7 +6,7 @@ import { CONFIG_FILE } from '../../types.js';
6
6
  export const showLeaf = defineLeaf({
7
7
  name: 'show',
8
8
  description: "one profile's manifest in full",
9
- whenToUse: "you need a profile's full project list, id, and timestamps — after `profile list` narrows to a name, or to inspect what a node's bearings named as its profile.",
9
+ whenToUse: "you need a profile's full project list, home directory, id, and timestamps — after `profile list` narrows to a name, to find where its nodes will run, or to inspect what a node's bearings named as its profile.",
10
10
  help: {
11
11
  name: 'profile show',
12
12
  summary: "print one profile's manifest plus its on-disk path and config.json location",
@@ -22,6 +22,7 @@ export const showLeaf = defineLeaf({
22
22
  { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
23
23
  { name: 'name', type: 'string', required: true, constraint: 'Manifest name.' },
24
24
  { name: 'projects', type: 'string[]', required: true, constraint: 'Absolute project directories, in manifest order.' },
25
+ { name: 'home', type: 'string | null', required: true, constraint: 'The directory every node under this profile is pinned to unless its create passes an explicit --cwd. Defaults to the first project; re-point it with `profile project add --home`. Null when the profile owns no projects (the root profile), in which case nodes fall back to the creating directory.' },
25
26
  { name: 'created_at', type: 'string', required: true, constraint: 'ISO 8601 creation timestamp.' },
26
27
  { name: 'last_used_at', type: 'string | null', required: true, constraint: 'ISO 8601 timestamp of the most recent selection, or null if never selected.' },
27
28
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the profile directory.' },
@@ -39,6 +40,7 @@ export const showLeaf = defineLeaf({
39
40
  profile_id: profileId,
40
41
  name: manifest.name,
41
42
  projects: manifest.projects,
43
+ home: manifest.home,
42
44
  created_at: manifest.created_at,
43
45
  last_used_at: manifest.last_used_at,
44
46
  path: profileRoot(profileId),
@@ -23,14 +23,14 @@ export function registerProfile() {
23
23
  return defineBranch({
24
24
  name: 'profile',
25
25
  rootEntry: {
26
- concept: 'an agent identity defined at the user root — a manifest naming project directories plus its own memory store',
26
+ concept: 'an agent identity defined at the user root — a manifest naming project directories and the home its nodes run in, plus its own memory store',
27
27
  desc: 'create, inspect, and manage profiles',
28
28
  useWhen: 'creating a new agent identity, inspecting or listing existing ones, changing a profile\u2019s project purview, pinning which profile a directory defaults to at startup, or managing a profile\u2019s stored env vars. Selecting a profile for a one-off run (`--profile`) lives elsewhere.',
29
29
  },
30
30
  help: {
31
31
  name: 'profile',
32
32
  summary: 'create, inspect, and manage profiles \u2014 agent identities with their own project purview and memory store',
33
- model: '`new` creates one from a name (+ at most one initial `--project`). `list` inventories every profile as JSONL. `show` prints one manifest in full. `rename` changes only the manifest name \u2014 the profile_id is stable across a rename. `delete` removes a profile\u2019s directory (manifest + its own memory store) entirely and does not touch the project directories it pointed at. `project` widens/narrows a profile\u2019s project purview (the dirs it resolves memory + config from). `default` pins which profile a directory defaults to at startup so the chooser stops asking \u2014 directory-scoped, independent of purview. `env` manages a profile-scoped 0600 store of env vars injected directly into every broker launched under that profile \u2014 set on stdin, list names only (never values), or remove one; no `spawnEnv.allow` entry needed. Every manifest verb but `new`/`list` takes <profile> as an exact id or a unique manifest name; an ambiguous name fails listing every matching id.',
33
+ model: 'A profile owns a `home` — the first of its project dirs unless `project add --home` re-points it — and every node created under the profile is pinned there, so cwd selects the profile and the profile then supplies where its nodes run. Only `node new --cwd` (or inheriting a spawner already elsewhere, notably inside a managed worktree) overrides that; the root profile has no home, so nodes under it stay in the directory that created them. `new` creates one from a name (+ at most one initial `--project`, which becomes its home). `list` inventories every profile as JSONL. `show` prints one manifest in full. `rename` changes only the manifest name \u2014 the profile_id is stable across a rename. `delete` removes a profile\u2019s directory (manifest + its own memory store) entirely and does not touch the project directories it pointed at. `project` widens/narrows a profile\u2019s project purview (the dirs it resolves memory + config from). `default` pins which profile a directory defaults to at startup so the chooser stops asking \u2014 directory-scoped, independent of purview. `env` manages a profile-scoped 0600 store of env vars injected directly into every broker launched under that profile \u2014 set on stdin, list names only (never values), or remove one; no `spawnEnv.allow` entry needed. Every manifest verb but `new`/`list` takes <profile> as an exact id or a unique manifest name; an ambiguous name fails listing every matching id.',
34
34
  },
35
35
  children: [newLeaf, listLeaf, showLeaf, renameLeaf, deleteLeaf, projectBranch, defaultBranch, envBranch],
36
36
  });
@@ -3,7 +3,7 @@ import { isSafeNodeId } from '../../core/canvas/paths.js';
3
3
  import { usage } from '../../core/errors.js';
4
4
  import { validateOperationId } from '../../core/events/operation-id.js';
5
5
  import { readLogSnapshot } from '../../core/events/read.js';
6
- import { emitLine, isJsonOutput } from '../../core/io.js';
6
+ import { emitLine, isJsonOutput, recordPreviewLine } from '../../core/io.js';
7
7
  const ISO_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/u;
8
8
  const MAX_PATTERN_BYTES = 4_096;
9
9
  function invalidFlag(flag, received, expected) {
@@ -116,8 +116,10 @@ export const sysLogsLeaf = defineLeaf({
116
116
  if (item.kind === 'event') {
117
117
  if (json)
118
118
  emitLine({ ...item.envelope });
119
- else
119
+ else {
120
+ recordPreviewLine({ ...item.envelope });
120
121
  process.stdout.write(`${renderEvent(item.envelope)}\n`);
122
+ }
121
123
  }
122
124
  else if (!json) {
123
125
  process.stdout.write(`RAW ${item.source} ${new Date(item.effectiveTsMs).toISOString()} ${JSON.stringify(item.content)}\n`);
@@ -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)));
@@ -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;
@@ -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',
@@ -17,7 +17,7 @@ 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
22
  // ---------------------------------------------------------------------------
23
23
  // Constants
@@ -420,6 +420,7 @@ async function requestStreaming(spec, plan) {
420
420
  if (line.trim() === '')
421
421
  continue; // drop blank records
422
422
  framesEmitted = true;
423
+ recordPreviewJsonLine(line);
423
424
  const ok = await writeStdout(line + '\n');
424
425
  if (!ok) {
425
426
  // Consumer closed the pipe (EPIPE) — stop cleanly, nothing to report.
@@ -434,6 +435,7 @@ async function requestStreaming(spec, plan) {
434
435
  // backend sent no trailing delimiter, so none is added).
435
436
  if (partial.trim() !== '') {
436
437
  framesEmitted = true;
438
+ recordPreviewJsonLine(partial);
437
439
  await writeStdout(partial);
438
440
  }
439
441
  return; // clean EOF → SUCCESS
@@ -444,7 +446,13 @@ async function requestStreaming(spec, plan) {
444
446
  if (framesEmitted) {
445
447
  // After a frame has been emitted: one concise stderr diagnostic + NETWORK
446
448
  // exit, and DO NOT throw (that would append an <error> to stdout).
447
- diag(`crtr: ${label(spec)}: stream interrupted (${detail})`);
449
+ const message = `${label(spec)}: stream interrupted (${detail})`;
450
+ diag(`crtr: ${message}`);
451
+ recordPreviewError({
452
+ error: 'stream_interrupted',
453
+ message,
454
+ next: 'Retry the command to begin a fresh stream.',
455
+ });
448
456
  process.exitCode = ExitCode.NETWORK;
449
457
  return;
450
458
  }
@@ -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);
@@ -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);
@@ -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,7 +1,8 @@
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, readJson, responsePath, reviewPath } from './convention.js';
5
+ import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
5
6
  const REMOTE_STALE_MS = 30_000;
6
7
  function parseClaim(raw) {
7
8
  if (typeof raw !== 'object' || raw === null)
@@ -15,16 +15,6 @@ 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
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
18
  export declare function writeResponse(dir: string, responses: InteractionResponse[], completedAt: string, deck?: Deck): string;
29
19
  export declare function writeProgress(dir: string, responses: InteractionResponse[]): void;
30
20
  export declare function clearProgress(dir: string): void;