@crouton-kit/crouter 0.3.45 → 0.3.46

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 (51) hide show
  1. package/dist/build-root.js +0 -1
  2. package/dist/builtin-memory/00-runtime-base.md +3 -0
  3. package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
  4. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
  5. package/dist/builtin-views/chat/web.jsx +1 -1
  6. package/dist/clients/attach/__tests__/crtr-output-render.test.js +11 -9
  7. package/dist/clients/attach/attach-cmd.js +557 -546
  8. package/dist/clients/attach/chat-view.d.ts +4 -0
  9. package/dist/clients/attach/chat-view.js +13 -2
  10. package/dist/clients/attach/mermaid-render.d.ts +23 -0
  11. package/dist/clients/attach/mermaid-render.js +124 -0
  12. package/dist/clients/web/source-cache.js +1 -1
  13. package/dist/commands/__tests__/revive-now-gate.test.js +2 -2
  14. package/dist/commands/canvas-history/read.js +3 -3
  15. package/dist/commands/canvas-history/search.js +1 -1
  16. package/dist/commands/canvas-history.js +11 -12
  17. package/dist/commands/canvas-rebuild-index.js +2 -2
  18. package/dist/commands/canvas.js +4 -4
  19. package/dist/commands/node-inspect-artifacts.d.ts +1 -0
  20. package/dist/commands/{canvas-history/show.js → node-inspect-artifacts.js} +7 -7
  21. package/dist/commands/node-lifecycle-revive.d.ts +12 -0
  22. package/dist/commands/node-lifecycle-revive.js +166 -0
  23. package/dist/commands/node-worktree.d.ts +2 -0
  24. package/dist/commands/{worktree.js → node-worktree.js} +15 -16
  25. package/dist/commands/node.js +16 -13
  26. package/dist/commands/push.js +1 -1
  27. package/dist/commands/revive.d.ts +0 -10
  28. package/dist/commands/revive.js +31 -181
  29. package/dist/core/__tests__/kickoff.test.js +2 -2
  30. package/dist/core/canvas/boot.js +1 -1
  31. package/dist/core/canvas/browse/app.js +6 -6
  32. package/dist/core/canvas/browse/render.js +1 -1
  33. package/dist/core/canvas/status-glyph.d.ts +1 -1
  34. package/dist/core/canvas/status-glyph.js +1 -1
  35. package/dist/core/preview-registry.js +27 -26
  36. package/dist/core/profiles/select.d.ts +12 -4
  37. package/dist/core/profiles/select.js +182 -22
  38. package/dist/core/runtime/bearings.js +1 -1
  39. package/dist/core/runtime/close.js +1 -1
  40. package/dist/core/runtime/kickoff.js +3 -3
  41. package/dist/core/runtime/revive.js +2 -2
  42. package/dist/core/view/stream-local.js +1 -1
  43. package/dist/core/worktree.js +4 -4
  44. package/dist/daemon/crtrd.js +3 -3
  45. package/dist/web/transport-stream.js +1 -1
  46. package/dist/web-client/assets/{index-B00YpRQ1.js → index-Di-gSsVn.js} +2 -2
  47. package/dist/web-client/index.html +1 -1
  48. package/package.json +2 -2
  49. package/scripts/postinstall.mjs +73 -0
  50. package/dist/commands/canvas-history/show.d.ts +0 -1
  51. package/dist/commands/worktree.d.ts +0 -2
@@ -76,9 +76,10 @@ const LEAF_ICON_CP = {
76
76
  'canvas attention list': 0xf03a,
77
77
  'canvas attention map': 0xf279,
78
78
  'canvas revive': 0xf021,
79
+ 'node lifecycle revive': 0xf021,
79
80
  'canvas history search': 0xf002,
80
81
  'canvas history read': 0xf02d,
81
- 'canvas history show': 0xf1da,
82
+ 'node inspect artifacts': 0xf1da,
82
83
  'sys daemon start': 0xf04b,
83
84
  'sys daemon status': 0xf111,
84
85
  'sys daemon stop': 0xf04d,
@@ -558,10 +559,10 @@ function summarizeCanvasAttentionMap(text) {
558
559
  }
559
560
  /** Find the first argv token after `skipWords` that isn't a boolean flag
560
561
  * (`--foo`) — recovers a leaf's own positional id when its render() output
561
- * carries no id of its own (see summarizeCanvasRevive below). Safe here
562
- * specifically because every `canvas revive` flag is a boolean with no
563
- * separate value token, so a naive forward scan can't mistake a flag's
564
- * value for the positional. */
562
+ * carries no id of its own (see summarizeNodeLifecycleRevive below). Safe
563
+ * here specifically because every `node lifecycle revive` flag is a boolean
564
+ * with no separate value token, so a naive forward scan can't mistake a
565
+ * flag's value for the positional. */
565
566
  function firstPositionalAfter(tokens, skipWords) {
566
567
  for (let i = skipWords; i < tokens.length; i++) {
567
568
  if (!tokens[i].startsWith('--'))
@@ -569,31 +570,24 @@ function firstPositionalAfter(tokens, skipWords) {
569
570
  }
570
571
  return undefined;
571
572
  }
572
- /** `crtr canvas revive` (src/commands/revive.ts) has no custom `render`, so
573
- * its object output falls through to generic renderResult. Four shapes
574
- * depending on flags:
575
- * - `--all` (preview or executed) carries a `- mode: preview|revived`
576
- * bullet skipped (null): `candidates`/`revived`/`failed` are
577
- * prose-or-bullet depending on whether they contain a newline (multi- vs
578
- * single-id), which core/render.ts's `isProse` makes genuinely ambiguous
579
- * to parse strictly, and this is a rare admin path.
580
- * - `--now` kick: the only shape with `- kicked: true` (revive.ts:192).
581
- * - plain single-node revive: the only shape with `- resumed: true|false`.
582
- * `window` (revive.ts:83) is documented "Always null", so it's never
583
- * bulleted — and there is no node id anywhere in the output, so it's
584
- * recovered from argv (the leaf's own positional) via
573
+ /** `crtr node lifecycle revive` (src/commands/node-lifecycle-revive.ts) has no
574
+ * custom `render`, so its object output falls through to generic
575
+ * renderResult. Two shapes depending on flags:
576
+ * - `--now` kick: the only shape with `- kicked: true`.
577
+ * - plain single-node revive (default or `--fresh`): the only shape with
578
+ * `- resumed: true|false`. `window` is documented "Always null", so it's
579
+ * never bulleted — and there is no node id anywhere in the output, so
580
+ * it's recovered from argv (the leaf's own positional) via
585
581
  * firstPositionalAfter.
586
582
  * Returns null on any other/unrecognized shape. */
587
- function summarizeCanvasRevive(text, tokens) {
588
- if (/^- mode:\s*\S+\s*$/m.test(text))
589
- return null;
583
+ function summarizeNodeLifecycleRevive(text, tokens) {
590
584
  if (/^- kicked:\s*true\s*$/m.test(text)) {
591
585
  return [{ text: 'kicked — daemon resumes in ~20s', tone: 'warning' }];
592
586
  }
593
587
  const resumedMatch = /^- resumed:\s*(true|false)\s*$/m.exec(text);
594
588
  if (resumedMatch === null)
595
589
  return null;
596
- const id = firstPositionalAfter(tokens, 2); // 'canvas','revive' = 2 words
590
+ const id = firstPositionalAfter(tokens, 3); // 'node','lifecycle','revive' = 3 words
597
591
  const lines = [{ text: id !== undefined ? `revived ${id}` : 'revived', tone: 'success' }];
598
592
  const readyMatch = /^- ready:\s*(true|false)\s*$/m.exec(text);
599
593
  if (readyMatch !== null && readyMatch[1] === 'false') {
@@ -601,6 +595,13 @@ function summarizeCanvasRevive(text, tokens) {
601
595
  }
602
596
  return lines;
603
597
  }
598
+ // `crtr canvas revive --all` (src/commands/revive.ts) has no custom `render`,
599
+ // so its object output falls through to generic renderResult; both the
600
+ // preview and executed shapes carry a `- mode: preview|revived` bullet, which
601
+ // core/render.ts's `isProse` makes genuinely ambiguous to parse strictly
602
+ // (`candidates`/`revived`/`failed` are prose-or-bullet depending on whether
603
+ // they contain a newline). Rare admin path — no summarizer registered; the
604
+ // generic truncated render is fine.
604
605
  /** `crtr canvas history search` (renderSearch, src/commands/canvas-history/
605
606
  * search.ts:246) has three shapes: `0 hits.`; `--full` (### heading blocks
606
607
  * per hit, not tabular — skipped, returns null); or the normal `N of M
@@ -685,7 +686,7 @@ function summarizeCanvasHistoryRead(text) {
685
686
  }
686
687
  return lines;
687
688
  }
688
- /** `crtr canvas history show` (src/commands/canvas-history/show.ts:66):
689
+ /** `crtr node inspect artifacts` (src/commands/node-inspect-artifacts.ts):
689
690
  * `node: <name (id)>\n\n0 artifacts. | N artifacts:\n\n<table
690
691
  * cols=ref,source,ts,detail>\n\n<follow_up>`. `source` values are
691
692
  * colon-prefixed types (`report:final`, `report:update`, `doc`,
@@ -695,7 +696,7 @@ function summarizeCanvasHistoryRead(text) {
695
696
  * match — or when the parsed row count doesn't match the leading `N
696
697
  * artifacts:` count (this render has no pagination, so the two must
697
698
  * agree exactly). */
698
- function summarizeCanvasHistoryShow(text) {
699
+ function summarizeNodeInspectArtifacts(text) {
699
700
  const nodeMatch = /^node: (.+)$/m.exec(text);
700
701
  if (nodeMatch === null)
701
702
  return null;
@@ -1897,10 +1898,10 @@ const SUMMARIZERS = [
1897
1898
  { path: 'canvas attention count', run: summarizeCanvasAttentionCount },
1898
1899
  { path: 'canvas attention list', run: summarizeCanvasAttentionList },
1899
1900
  { path: 'canvas attention map', run: summarizeCanvasAttentionMap },
1900
- { path: 'canvas revive', run: summarizeCanvasRevive },
1901
+ { path: 'node lifecycle revive', run: summarizeNodeLifecycleRevive },
1901
1902
  { path: 'canvas history search', run: summarizeCanvasHistorySearch },
1902
1903
  { path: 'canvas history read', run: summarizeCanvasHistoryRead },
1903
- { path: 'canvas history show', run: summarizeCanvasHistoryShow },
1904
+ { path: 'node inspect artifacts', run: summarizeNodeInspectArtifacts },
1904
1905
  { path: 'sys daemon start', run: summarizeSysDaemonStart },
1905
1906
  { path: 'sys daemon status', run: summarizeSysDaemonStatus },
1906
1907
  { path: 'sys daemon stop', run: summarizeSysDaemonStop },
@@ -4,10 +4,18 @@
4
4
  * its manifest does not already cover `cwd` and the session is interactive,
5
5
  * offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
6
6
  * return the id.
7
- * 2. Else, among profiles whose project list COVERS `cwd`, pick the
8
- * most-recently-used (nulls oldest), bump its `last_used_at`, return it.
9
- * 3. Else nothing covers cwd. Interactive session: prompt synchronously to
10
- * create a profile here or proceed as root, BEFORE pi boots. Non-
7
+ * 2. Else, if EXACTLY ONE profile's project dir IS `cwd` (you're at a project
8
+ * root, unambiguously one owner), auto-pick it with no prompt — the
9
+ * strongest signal. If SEVERAL profiles claim `cwd` exactly, it's genuinely
10
+ * ambiguous: interactive menu among them + create; headless MRU.
11
+ * 3. Else, among profiles whose project list otherwise COVERS `cwd` (cwd sits
12
+ * in a subdir of, or a workspace above, one of their project dirs):
13
+ * - Interactive: prompt with a MENU of those covering profiles + "create
14
+ * a new profile here", BEFORE pi boots (no root option — the directory
15
+ * is covered). Default is the MRU covering profile.
16
+ * - Non-interactive: auto-pick the MRU covering profile, no prompt.
17
+ * 4. Else — nothing covers cwd walking all the way up. Interactive: prompt to
18
+ * create a profile here or proceed as root (root lives ONLY here). Non-
11
19
  * interactive (no TTY): default to root (null) and print the recovery
12
20
  * instruction to STDERR — never stdout, which the caller may be piping. */
13
21
  export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null): Promise<string | null>;
@@ -1,11 +1,15 @@
1
1
  // Startup profile selection — the CTO-decided order applied at the front door
2
- // and at any non-inherited `crtr node new`: explicit `--profile` > MRU profile
3
- // covering cwd > synchronous create-or-root decision. This is the ONLY place
4
- // that decision runs; front-door.ts/spawn.ts call it, never re-derive it.
2
+ // and at any non-inherited `crtr node new`: explicit `--profile` > a profile
3
+ // whose project dir IS cwd exactly (auto) > an interactive menu of profiles
4
+ // covering cwd from a parent/workspace dir + create (MRU auto when headless) >
5
+ // synchronous create-or-root decision when nothing covers. This is the ONLY
6
+ // place that decision runs; front-door.ts/spawn.ts call it, never re-derive it.
5
7
  import { existsSync, realpathSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
6
9
  import { basename, resolve as resolvePath, sep } from 'node:path';
7
10
  import { createInterface } from 'node:readline/promises';
8
11
  import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, addProfileProject, } from './manifest.js';
12
+ import { stdoutColor } from '../output.js';
9
13
  /** Resolve cwd to an absolute, realpath'd form so it compares against the
10
14
  * realpath'd project dirs `createProfile`/`addProfileProject` already store
11
15
  * (manifest.ts resolves every project dir through `realpathSync`). Falls back
@@ -52,6 +56,74 @@ function pickMostRecent(entries) {
52
56
  function isInteractive() {
53
57
  return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
54
58
  }
59
+ // ---------------------------------------------------------------------------
60
+ // Pre-boot prompt presentation. These prompts print to the raw controlling
61
+ // terminal BEFORE pi takes it over (not the attach viewer), so styling is
62
+ // plain ANSI via the house `stdoutColor` helper (NO_COLOR / non-TTY aware) and
63
+ // portable glyphs only — no Nerd Font (the pre-boot terminal may lack it).
64
+ // One shared visual language across all four gates: a bold title + optional
65
+ // dim subtitle, then accent-keyed option rows. We deliberately do NOT echo the
66
+ // user's own cwd back at them (they know where they are) — the only paths
67
+ // worth showing are OTHER dirs: which project root a covering profile owns.
68
+ // ---------------------------------------------------------------------------
69
+ const dim = stdoutColor.dim;
70
+ const bold = stdoutColor.bold;
71
+ const accent = stdoutColor.cyan;
72
+ /** Selectable key ([1], [c], [r]) — the one thing the eye hunts for. */
73
+ function key(k) {
74
+ return accent(bold(k));
75
+ }
76
+ /** Collapse the home prefix to `~` so project paths read short. */
77
+ function tildify(p) {
78
+ const home = homedir();
79
+ if (p === home)
80
+ return '~';
81
+ if (p.startsWith(home + sep))
82
+ return '~' + p.slice(home.length);
83
+ return p;
84
+ }
85
+ /** Coarse "last used" for disambiguating profiles that otherwise look alike
86
+ * (notably several profiles claiming the SAME dir). null → never used. */
87
+ function relativeUsed(iso) {
88
+ if (iso === null)
89
+ return 'never used';
90
+ const then = Date.parse(iso);
91
+ if (Number.isNaN(then))
92
+ return 'used recently';
93
+ const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
94
+ if (secs < 60)
95
+ return 'used just now';
96
+ const mins = Math.floor(secs / 60);
97
+ if (mins < 60)
98
+ return `used ${mins}m ago`;
99
+ const hours = Math.floor(mins / 60);
100
+ if (hours < 24)
101
+ return `used ${hours}h ago`;
102
+ const days = Math.floor(hours / 24);
103
+ if (days < 30)
104
+ return `used ${days}d ago`;
105
+ const months = Math.floor(days / 30);
106
+ if (months < 12)
107
+ return `used ${months}mo ago`;
108
+ return `used ${Math.floor(months / 12)}y ago`;
109
+ }
110
+ /** Blank-line-padded bold title with an optional dim subtitle beneath it. No
111
+ * cwd echo — the user already knows their own directory. */
112
+ function writeHeader(title, subtitle) {
113
+ const sub = subtitle !== undefined ? ` ${dim(subtitle)}\n` : '';
114
+ process.stdout.write(`\n ${bold(title)}\n${sub}\n`);
115
+ }
116
+ /** One option row: ` <key> <label> <dim detail> · default`. `label` is
117
+ * passed pre-styled; `detail` is dimmed here; the default row gets a marker. */
118
+ function optionRow(k, label, detail, isDefault) {
119
+ const det = detail !== '' ? ' ' + dim(detail) : '';
120
+ const def = isDefault ? ' ' + accent('\u00b7 default') : '';
121
+ return ` ${key(k)} ${label}${det}${def}\n`;
122
+ }
123
+ /** The trailing readline prompt: ` › <dim hint> `. */
124
+ function caret(hint) {
125
+ return ` ${accent('\u203a')} ${dim(hint)} `;
126
+ }
55
127
  function recoveryMessage(cwd) {
56
128
  return (`crtr: no profile covers ${cwd}, and this session is non-interactive — starting without a profile (root).\n` +
57
129
  `Run \`crtr profile new --name <name> --project ${cwd}\` to create one, or pass --profile <id-or-name> next time.\n`);
@@ -75,8 +147,10 @@ function recoveryMessage(cwd) {
75
147
  async function promptAddDirToProfile(entry, cwd) {
76
148
  const rl = createInterface({ input: process.stdin, output: process.stdout });
77
149
  try {
78
- process.stdout.write(`\nProfile "${entry.manifest.name}" does not cover this directory:\n ${cwd}\n\n`);
79
- const choice = (await rl.question('Add it to the profile? [Y/n]: ')).trim().toLowerCase();
150
+ writeHeader(`Profile "${entry.manifest.name}" doesn't cover this directory`);
151
+ const choice = (await rl.question(caret('add it to the profile? [Y/n]')))
152
+ .trim()
153
+ .toLowerCase();
80
154
  if (choice === 'n' || choice === 'no')
81
155
  return;
82
156
  addProfileProject(entry.profileId, cwd);
@@ -85,19 +159,77 @@ async function promptAddDirToProfile(entry, cwd) {
85
159
  rl.close();
86
160
  }
87
161
  }
162
+ /** Ask for a name (defaulting to cwd's basename) and create a profile pointing
163
+ * at cwd on the SAME open readline — shared by both interactive create paths
164
+ * so the name prompt reads identically whether reached via the covering menu
165
+ * or the no-coverage fallback. */
166
+ async function createProfileHere(rl, cwd) {
167
+ const defaultName = basename(cwd) || 'profile';
168
+ const nameInput = (await rl.question(caret(`name the new profile [${defaultName}]`))).trim();
169
+ const name = nameInput !== '' ? nameInput : defaultName;
170
+ const profileId = createProfile(name, [cwd]).profileId;
171
+ process.stdout.write(` ${accent('\u2713')} ${dim(`created profile "${name}"`)}\n`);
172
+ return profileId;
173
+ }
88
174
  async function promptCreateOrRoot(cwd) {
89
175
  const rl = createInterface({ input: process.stdin, output: process.stdout });
90
176
  try {
91
- process.stdout.write(`\nNo profile covers this directory:\n ${cwd}\n\n`);
92
- const choice = (await rl.question('Create a profile here, or run as root? [c]reate / [r]oot: '))
93
- .trim()
94
- .toLowerCase();
177
+ writeHeader('No profile covers this directory');
178
+ process.stdout.write(optionRow('c', 'create a profile here', '', false));
179
+ process.stdout.write(optionRow('r', dim('run without a profile (root)'), '', true));
180
+ process.stdout.write('\n');
181
+ const choice = (await rl.question(caret('[c/r]'))).trim().toLowerCase();
95
182
  if (choice !== 'c' && choice !== 'create')
96
183
  return null;
97
- const defaultName = basename(cwd) || 'profile';
98
- const nameInput = (await rl.question(`Profile name [${defaultName}]: `)).trim();
99
- const name = nameInput !== '' ? nameInput : defaultName;
100
- return createProfile(name, [cwd]).profileId;
184
+ return await createProfileHere(rl, cwd);
185
+ }
186
+ finally {
187
+ rl.close();
188
+ }
189
+ }
190
+ /** Choose among a set of candidate profiles for `cwd`, or create a new one.
191
+ * Reached in two ambiguous cases: MULTIPLE profiles claim `cwd` as their exact
192
+ * project dir (`exact` true), or `cwd` merely sits within one or more profiles'
193
+ * project dirs from a parent/workspace dir (`exact` false). Present them
194
+ * most-recently-used first (choice 1 / the bare-Enter default) plus "create a
195
+ * new profile here", and block until answered. Root is deliberately NOT offered
196
+ * here: a claimed/covered directory always has at least a covering profile as a
197
+ * sane identity, so `[r]oot` belongs only to the no-coverage prompt
198
+ * (`promptCreateOrRoot`). Same raw-readline / boot-gating rationale. */
199
+ async function promptPickProfileOrCreate(candidates, cwd, exact) {
200
+ // MRU-first so the most-recently-used candidate is choice 1 / the bare-Enter
201
+ // default — the same profile the non-interactive path auto-picks.
202
+ const ordered = [...candidates].sort((a, b) => isMoreRecent(a.manifest.last_used_at, b.manifest.last_used_at) ? -1 : 1);
203
+ // Pad names to a shared column so the dim detail lines up down the menu.
204
+ const nameWidth = Math.max(...ordered.map((e) => e.manifest.name.length));
205
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
206
+ try {
207
+ writeHeader(exact ? 'Choose a profile' : 'Select a profile', exact
208
+ ? 'Several profiles claim this directory as their project'
209
+ : 'This directory falls under existing profile(s)');
210
+ ordered.forEach((entry, i) => {
211
+ // For exact ties the covered path is identical, so surface last-used to
212
+ // tell them apart; for parent/workspace coverage, WHICH dir covers cwd
213
+ // is the useful differentiator.
214
+ const detail = exact
215
+ ? relativeUsed(entry.manifest.last_used_at)
216
+ : `covers ${tildify(entry.manifest.projects.find((p) => projectCovers(cwd, p)) ?? entry.manifest.projects[0])}`;
217
+ const label = bold(entry.manifest.name.padEnd(nameWidth));
218
+ process.stdout.write(optionRow(String(i + 1), label, detail, i === 0));
219
+ });
220
+ process.stdout.write(optionRow('c', dim('create a new profile here'), '', false));
221
+ process.stdout.write('\n');
222
+ const answer = (await rl.question(caret('[1]'))).trim().toLowerCase();
223
+ if (answer === 'c' || answer === 'create')
224
+ return await createProfileHere(rl, cwd);
225
+ if (answer === '')
226
+ return ordered[0].profileId;
227
+ const n = Number.parseInt(answer, 10);
228
+ if (Number.isInteger(n) && n >= 1 && n <= ordered.length)
229
+ return ordered[n - 1].profileId;
230
+ // Unrecognized input → the safe default (the MRU candidate); never loop,
231
+ // since this prompt is gating pi's boot on the shared TTY.
232
+ return ordered[0].profileId;
101
233
  }
102
234
  finally {
103
235
  rl.close();
@@ -109,10 +241,18 @@ async function promptCreateOrRoot(cwd) {
109
241
  * its manifest does not already cover `cwd` and the session is interactive,
110
242
  * offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
111
243
  * return the id.
112
- * 2. Else, among profiles whose project list COVERS `cwd`, pick the
113
- * most-recently-used (nulls oldest), bump its `last_used_at`, return it.
114
- * 3. Else nothing covers cwd. Interactive session: prompt synchronously to
115
- * create a profile here or proceed as root, BEFORE pi boots. Non-
244
+ * 2. Else, if EXACTLY ONE profile's project dir IS `cwd` (you're at a project
245
+ * root, unambiguously one owner), auto-pick it with no prompt — the
246
+ * strongest signal. If SEVERAL profiles claim `cwd` exactly, it's genuinely
247
+ * ambiguous: interactive menu among them + create; headless MRU.
248
+ * 3. Else, among profiles whose project list otherwise COVERS `cwd` (cwd sits
249
+ * in a subdir of, or a workspace above, one of their project dirs):
250
+ * - Interactive: prompt with a MENU of those covering profiles + "create
251
+ * a new profile here", BEFORE pi boots (no root option — the directory
252
+ * is covered). Default is the MRU covering profile.
253
+ * - Non-interactive: auto-pick the MRU covering profile, no prompt.
254
+ * 4. Else — nothing covers cwd walking all the way up. Interactive: prompt to
255
+ * create a profile here or proceed as root (root lives ONLY here). Non-
116
256
  * interactive (no TTY): default to root (null) and print the recovery
117
257
  * instruction to STDERR — never stdout, which the caller may be piping. */
118
258
  export async function selectProfileForCwd(cwd, explicitProfile) {
@@ -128,14 +268,34 @@ export async function selectProfileForCwd(cwd, explicitProfile) {
128
268
  return entry.profileId;
129
269
  }
130
270
  const covering = listProfiles().filter((p) => profileCoversCwd(p, resolvedCwd));
131
- if (covering.length > 0) {
132
- const winner = pickMostRecent(covering);
133
- updateProfileLastUsed(winner.profileId);
134
- return winner.profileId;
271
+ const exact = covering.filter((p) => p.manifest.projects.some((proj) => proj === resolvedCwd));
272
+ // Exactly one profile's project dir IS cwd — unambiguous project root, use it
273
+ // silently, never a prompt. (Several exact claims fall through to the menu.)
274
+ if (exact.length === 1) {
275
+ updateProfileLastUsed(exact[0].profileId);
276
+ return exact[0].profileId;
135
277
  }
278
+ // Non-interactive: cannot prompt. Prefer an exact claimant, else any covering
279
+ // profile (both MRU), else root.
136
280
  if (!isInteractive()) {
281
+ const pool = exact.length > 0 ? exact : covering;
282
+ if (pool.length > 0) {
283
+ const winner = pickMostRecent(pool);
284
+ updateProfileLastUsed(winner.profileId);
285
+ return winner.profileId;
286
+ }
137
287
  process.stderr.write(recoveryMessage(resolvedCwd));
138
288
  return null;
139
289
  }
140
- return await promptCreateOrRoot(resolvedCwd);
290
+ // Interactive. Several exact claimants → choose among them. Else covered from
291
+ // a parent/workspace dir → menu of covering profiles. Else nothing covers →
292
+ // the create-or-root prompt (root is only ever offered there).
293
+ const chosen = exact.length > 1
294
+ ? await promptPickProfileOrCreate(exact, resolvedCwd, true)
295
+ : covering.length > 0
296
+ ? await promptPickProfileOrCreate(covering, resolvedCwd, false)
297
+ : await promptCreateOrRoot(resolvedCwd);
298
+ if (chosen !== null)
299
+ updateProfileLastUsed(chosen);
300
+ return chosen;
141
301
  }
@@ -448,7 +448,7 @@ export function buildContextBearings(nodeId) {
448
448
  bearings.push('', profileSection);
449
449
  const wt = node?.managed_worktree;
450
450
  if (wt?.state === 'open') {
451
- bearings.push('', '## Your managed worktree', `Branch: \`${wt.branch}\`\nWorktree: \`${wt.path}\`\nBase: \`${wt.base_ref}\` @ \`${wt.base_sha}\`\nCommit work here. Land it with \`crtr worktree close\` before finishing; \`crtr push final\` is blocked until this worktree is closed.`);
451
+ bearings.push('', '## Your managed worktree', `Branch: \`${wt.branch}\`\nWorktree: \`${wt.path}\`\nBase: \`${wt.base_ref}\` @ \`${wt.base_sha}\`\nCommit work here. Land it with \`crtr node worktree close\` before finishing; \`crtr push final\` is blocked until this worktree is closed.`);
452
452
  }
453
453
  // Orchestrator-only: the across-cycles framing (a terminal has no future cycle).
454
454
  if (node?.mode === 'orchestrator')
@@ -4,7 +4,7 @@
4
4
  // EXCLUSIVELY owns, walking DOWN the subscribes_to spine (subscriptionsOf = a
5
5
  // node's reports/children). Nothing is deleted: pi_session_id, the canvas
6
6
  // edges, and all on-disk state persist, so any closed node can later be revived
7
- // (`crtr canvas revive` / focus → `pi --session <id>`). A close is a pause, not a reap.
7
+ // (`crtr node lifecycle revive` / focus → `pi --session <id>`). A close is a pause, not a reap.
8
8
  //
9
9
  // Per node, in this order — the order matters twice:
10
10
  //
@@ -1,5 +1,5 @@
1
1
  // The revive kickoff — the message auto-injected as a node's first turn when it
2
- // comes back FRESH (a refresh-yield, or `canvas revive --fresh`). The node's
2
+ // comes back FRESH (a refresh-yield, or `node lifecycle revive --fresh`). The node's
3
3
  // in-memory context is gone, so this message IS its bearings: everything is
4
4
  // read from disk and framed so the node can rebuild and continue without a
5
5
  // round-trip. Resuming a saved conversation needs none of this (the
@@ -140,7 +140,7 @@ function reportKind(filename) {
140
140
  /** Condensed report-history index for each ACTIVE subscription of `nodeId` —
141
141
  * independent of publisher liveness, so a finished worker's history still
142
142
  * surfaces for catch-up. Each source carries only its count and newest report
143
- * ref; older refs stay one command away via `canvas history show`. */
143
+ * ref; older refs stay one command away via `node inspect artifacts`. */
144
144
  function reportHistorySources(nodeId) {
145
145
  const out = [];
146
146
  for (const sub of subscriptionsOf(nodeId).filter((s) => s.active)) {
@@ -201,7 +201,7 @@ function feedBlock(nodeId, unreadDigest) {
201
201
  lines.push('', `Report history on disk (collapsed): ${total} report${total === 1 ? '' : 's'} across ${history.length} subscribed node${history.length === 1 ? '' : 's'}. This is a catch-up index for reports your cursor already passed; read only what matters.`, ...visible.map((h) => {
202
202
  const ref = `${h.nodeId}:reports/${h.latest}`;
203
203
  return ` - ${h.name} (${h.nodeId}): ${h.count} report${h.count === 1 ? '' : 's'}; latest ${reportKind(h.latest)}: ${ref}`;
204
- }), ...(hidden > 0 ? [` - … ${hidden} more subscribed node${hidden === 1 ? '' : 's'} with reports hidden`] : []), 'Expand: `crtr canvas history read <ref>` for one listed report; `crtr canvas history show <node-id> --type report` for that node\'s full report list; `crtr canvas history show <node-id> --type inbox` for cursor-independent inbox history including full message bodies.');
204
+ }), ...(hidden > 0 ? [` - … ${hidden} more subscribed node${hidden === 1 ? '' : 's'} with reports hidden`] : []), 'Expand: `crtr canvas history read <ref>` for one listed report; `crtr node inspect artifacts <node-id> --type report` for that node\'s full report list; `crtr node inspect artifacts <node-id> --type inbox` for cursor-independent inbox history including full message bodies.');
205
205
  }
206
206
  return `<feed>\n${lines.join('\n')}\n</feed>`;
207
207
  }
@@ -1,6 +1,6 @@
1
1
  // The revive primitive — restores a node to active status by relaunching its
2
2
  // broker engine. Used by both the supervisor daemon (on crash/refresh
3
- // detection) and the explicit `crtr canvas revive` command.
3
+ // detection) and the explicit `crtr node lifecycle revive` command (or `crtr canvas revive --all`).
4
4
  //
5
5
  // A revive replays the node's persisted LaunchSpec + cwd (the canonical recipe)
6
6
  // and launches the headless broker host (the only host after the
@@ -87,7 +87,7 @@ export function reviveNode(nodeId, opts) {
87
87
  // right below into believing a dead broker is still alive. Idempotent and
88
88
  // cheap once reconciled (a single small file read) except the one time it
89
89
  // actually matters, so it's safe to call on every revive regardless of
90
- // entry point (daemon tick, or a direct/manual `canvas revive`).
90
+ // entry point (daemon tick, or a direct/manual `node lifecycle revive`).
91
91
  reconcileBootLiveness();
92
92
  // Double-revive guard: the broker's isAlive IS isPidAlive(pi_pid), so a node
93
93
  // whose broker pid is still running was already revived by another path —
@@ -31,7 +31,7 @@ function makeNoBrokerError(target) {
31
31
  display: {
32
32
  headline: `node ${target} has no running broker`,
33
33
  explanation: 'The stream transport connects only to an already-running broker.',
34
- nextStep: `Run crtr node focus ${target} or crtr canvas revive ${target}, then reconnect.`,
34
+ nextStep: `Run crtr node focus ${target} or crtr node lifecycle revive ${target}, then reconnect.`,
35
35
  level: 'error',
36
36
  blocking: false,
37
37
  },
@@ -86,22 +86,22 @@ export function closeManagedWorktree(nodeId) {
86
86
  throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, 'Ask the human with `crtr human ask` before changing node state; the checkout disappeared outside crouter.');
87
87
  }
88
88
  if (isRebaseInProgress(wt.path)) {
89
- throw new WorktreeError('rebase_in_progress', 'this managed worktree is mid-rebase', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr worktree close\`.`);
89
+ throw new WorktreeError('rebase_in_progress', 'this managed worktree is mid-rebase', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr node worktree close\`.`);
90
90
  }
91
91
  const dirty = statusPorcelain(wt.path);
92
92
  if (dirty !== '') {
93
- throw new WorktreeError('dirty_worktree', 'managed worktree has uncommitted changes', `Commit the work in ${wt.path} (or ask the human with \`crtr human ask\` if you do not know whether to keep it), then rerun \`crtr worktree close\`.`);
93
+ throw new WorktreeError('dirty_worktree', 'managed worktree has uncommitted changes', `Commit the work in ${wt.path} (or ask the human with \`crtr human ask\` if you do not know whether to keep it), then rerun \`crtr node worktree close\`.`);
94
94
  }
95
95
  const branch = currentBranch(wt.path);
96
96
  if (branch !== wt.branch) {
97
97
  const found = branch === 'HEAD' ? 'detached HEAD' : branch;
98
- throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}, found ${found})`, `Check out ${wt.branch} in ${wt.path} before closing, or ask the human with \`crtr human ask\` if you are unsure why it changed, then rerun \`crtr worktree close\`.`);
98
+ throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}, found ${found})`, `Check out ${wt.branch} in ${wt.path} before closing, or ask the human with \`crtr human ask\` if you are unsure why it changed, then rerun \`crtr node worktree close\`.`);
99
99
  }
100
100
  runGit(wt.path, ['fetch', 'origin', 'main'], 'fetch_failed', 'Network/remote state blocked landing. Ask the human with `crtr human ask` if this is not a transient git/network issue.');
101
101
  const rebase = gitSync(['rebase', 'origin/main'], wt.path);
102
102
  if (rebase.status !== 0) {
103
103
  const detail = (rebase.stderr.trim() || rebase.stdout.trim()).trim();
104
- throw new WorktreeError('rebase_failed', 'rebase onto origin/main did not complete', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr worktree close\`. If the blocker is not mechanical, ask the human with \`crtr human ask\`.`, detail);
104
+ throw new WorktreeError('rebase_failed', 'rebase onto origin/main did not complete', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr node worktree close\`. If the blocker is not mechanical, ask the human with \`crtr human ask\`.`, detail);
105
105
  }
106
106
  const push = gitSync(['push', 'origin', 'HEAD:main'], wt.path);
107
107
  if (push.status !== 0) {
@@ -43,7 +43,7 @@
43
43
  // out-of-band launch.
44
44
  // dead-pid crash path grace-revives RESUME on the saved session (or, for a
45
45
  // crash mid-cycle, retries AS a cycle — see cycle_pending above) — the manual
46
- // recovery (kill broker + canvas revive) that fixes these by hand.
46
+ // recovery (kill broker + node lifecycle revive) that fixes these by hand.
47
47
  //
48
48
  // Single-instance guarantee
49
49
  // A PID file prevents double-runs. On start, if the file exists and the
@@ -235,7 +235,7 @@ function handleYieldStall(row, pid, now) {
235
235
  // • no descendants (the broker pid stands alone — e.g. the engine itself
236
236
  // stalled after a model_change, with nothing to kill) → the daemon
237
237
  // performs the ONLY remaining remediation itself: SIGTERM the broker, the
238
- // same on-demand kick `canvas revive --now` does, so its own dead-pid
238
+ // same on-demand kick `node lifecycle revive --now` does, so its own dead-pid
239
239
  // crash-grace path (REVIVE_GRACE_MS) resumes it on the saved session with
240
240
  // no lost context.
241
241
  //
@@ -451,7 +451,7 @@ function handleWedgeDetection(id, pid, now) {
451
451
  else {
452
452
  // No descendant to kill — the engine itself stalled (e.g. mid model_change)
453
453
  // with nothing a human could kill. The daemon performs the same kick
454
- // `canvas revive --now` does on demand: SIGTERM the broker, then its own
454
+ // `node lifecycle revive --now` does on demand: SIGTERM the broker, then its own
455
455
  // dead-pid crash-grace path (REVIVE_GRACE_MS) resumes it on the saved
456
456
  // session (issue #119).
457
457
  label =
@@ -23,7 +23,7 @@ function makeNoBrokerError(target) {
23
23
  display: {
24
24
  headline: `node ${target} is asleep or has no running broker`,
25
25
  explanation: 'This browser stream connects only to an already-running local broker.',
26
- nextStep: `Run crtr node focus ${target} or crtr canvas revive ${target}, then reconnect.`,
26
+ nextStep: `Run crtr node focus ${target} or crtr node lifecycle revive ${target}, then reconnect.`,
27
27
  level: 'error',
28
28
  blocking: false,
29
29
  },