@crouton-kit/crouter 0.3.44 → 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 (55) 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/05-kinds/advisor/00-base.md +3 -2
  4. package/dist/builtin-memory/05-kinds/advisor/01-orchestrator.md +26 -0
  5. package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
  6. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
  7. package/dist/builtin-views/chat/web.jsx +1 -1
  8. package/dist/clients/attach/__tests__/crtr-output-render.test.js +11 -9
  9. package/dist/clients/attach/attach-cmd.js +557 -546
  10. package/dist/clients/attach/chat-view.d.ts +4 -0
  11. package/dist/clients/attach/chat-view.js +13 -2
  12. package/dist/clients/attach/mermaid-render.d.ts +23 -0
  13. package/dist/clients/attach/mermaid-render.js +124 -0
  14. package/dist/clients/web/source-cache.js +1 -1
  15. package/dist/commands/__tests__/revive-now-gate.test.js +2 -2
  16. package/dist/commands/canvas-history/read.js +3 -3
  17. package/dist/commands/canvas-history/search.js +1 -1
  18. package/dist/commands/canvas-history.js +11 -12
  19. package/dist/commands/canvas-rebuild-index.js +2 -2
  20. package/dist/commands/canvas.js +4 -4
  21. package/dist/commands/node-inspect-artifacts.d.ts +1 -0
  22. package/dist/commands/{canvas-history/show.js → node-inspect-artifacts.js} +7 -7
  23. package/dist/commands/node-lifecycle-revive.d.ts +12 -0
  24. package/dist/commands/node-lifecycle-revive.js +166 -0
  25. package/dist/commands/node-worktree.d.ts +2 -0
  26. package/dist/commands/{worktree.js → node-worktree.js} +15 -16
  27. package/dist/commands/node.js +27 -15
  28. package/dist/commands/push.js +1 -1
  29. package/dist/commands/revive.d.ts +0 -10
  30. package/dist/commands/revive.js +31 -181
  31. package/dist/core/__tests__/kickoff.test.js +2 -2
  32. package/dist/core/__tests__/review-model-floor.test.js +16 -0
  33. package/dist/core/canvas/boot.js +1 -1
  34. package/dist/core/canvas/browse/app.js +6 -6
  35. package/dist/core/canvas/browse/render.js +1 -1
  36. package/dist/core/canvas/status-glyph.d.ts +1 -1
  37. package/dist/core/canvas/status-glyph.js +1 -1
  38. package/dist/core/preview-registry.js +27 -26
  39. package/dist/core/profiles/select.d.ts +12 -4
  40. package/dist/core/profiles/select.js +182 -22
  41. package/dist/core/runtime/bearings.js +1 -1
  42. package/dist/core/runtime/close.js +1 -1
  43. package/dist/core/runtime/kickoff.js +3 -3
  44. package/dist/core/runtime/revive.js +2 -2
  45. package/dist/core/runtime/spawn.d.ts +6 -3
  46. package/dist/core/view/stream-local.js +1 -1
  47. package/dist/core/worktree.js +4 -4
  48. package/dist/daemon/crtrd.js +3 -3
  49. package/dist/web/transport-stream.js +1 -1
  50. package/dist/web-client/assets/{index-B00YpRQ1.js → index-Di-gSsVn.js} +2 -2
  51. package/dist/web-client/index.html +1 -1
  52. package/package.json +2 -2
  53. package/scripts/postinstall.mjs +73 -0
  54. package/dist/commands/canvas-history/show.d.ts +0 -1
  55. package/dist/commands/worktree.d.ts +0 -2
@@ -1,3 +1,7 @@
1
+ // `crtr node worktree` — the node-owned managed-worktree lifecycle. Birth is
2
+ // `node new --worktree` (a flag on spawn); this branch holds the single close
3
+ // verb, symmetric with birth, so a git worktree's whole lifecycle sits under
4
+ // `node`.
1
5
  import { defineBranch, defineLeaf } from '../core/command.js';
2
6
  import { InputError } from '../core/io.js';
3
7
  import { closeManagedWorktree, WorktreeError } from '../core/worktree.js';
@@ -27,7 +31,7 @@ const worktreeClose = defineLeaf({
27
31
  description: 'land and close the current node-managed worktree',
28
32
  whenToUse: 'you are inside the node that owns an open managed worktree and are ready to rebase it onto origin/main, push it to main, and (best-effort) remove the worktree and delete the branch before finishing',
29
33
  help: {
30
- name: 'worktree close',
34
+ name: 'node worktree close',
31
35
  summary: 'land and close the current node-managed worktree',
32
36
  params: [],
33
37
  output: [
@@ -77,18 +81,13 @@ const worktreeClose = defineLeaf({
77
81
  return base;
78
82
  },
79
83
  });
80
- export function registerWorktree() {
81
- return defineBranch({
82
- name: 'worktree',
83
- rootEntry: {
84
- concept: 'crouter-managed git worktrees tied to node lifecycle',
85
- desc: 'close a node-owned managed worktree',
86
- useWhen: 'landing completed work from inside the node that owns the worktree',
87
- },
88
- help: {
89
- name: 'worktree',
90
- summary: 'manage a node-owned git worktree',
91
- },
92
- children: [worktreeClose],
93
- });
94
- }
84
+ export const nodeWorktreeBranch = defineBranch({
85
+ name: 'worktree',
86
+ description: 'manage a node-owned git worktree — close the one born by `node new --worktree`',
87
+ whenToUse: 'you are inside a node that was spawned with `node new --worktree` and are ready to land it: rebase onto origin/main, push, and close the worktree. Symmetric with `node new --worktree`, which births it',
88
+ help: {
89
+ name: 'node worktree',
90
+ summary: 'manage a node-owned git worktree birth is `node new --worktree`; this branch closes it',
91
+ },
92
+ children: [worktreeClose],
93
+ });
@@ -19,6 +19,9 @@ import { readRoadmap, hasRoadmap, seedRoadmap } from '../core/runtime/roadmap.js
19
19
  import { parseWhen, parseCadence, cadenceDisplay } from '../core/wake.js';
20
20
  import { nodeSnapshotLeaf } from './node-snapshot.js';
21
21
  import { nodeContextLeaf } from './node-context.js';
22
+ import { nodeArtifactsLeaf } from './node-inspect-artifacts.js';
23
+ import { nodeWorktreeBranch } from './node-worktree.js';
24
+ import { nodeReviveLeaf } from './node-lifecycle-revive.js';
22
25
  import { recycleNode } from '../core/runtime/recycle.js';
23
26
  import { readFaultAsync } from '../core/runtime/fault.js';
24
27
  import { activeFaultForDisplay } from '../core/canvas/render.js';
@@ -128,9 +131,9 @@ const nodeNew = defineLeaf({
128
131
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name (tmux window + resume picker). Defaults to the kind.' },
129
132
  { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node (CRTR_NODE_ID).' },
130
133
  { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an INDEPENDENT root instead of a managed child: no parent (top-level on the canvas), NO subscription back to you (you are NOT woken by it), resident lifecycle. It records spawned_by=you for provenance and is brought forefront so it can be driven directly. Use for a node you hand off and do not manage (e.g. a sub-orchestrator a human will discuss with). Mutually exclusive with --worktree.' },
131
- { kind: 'flag', name: 'worktree', type: 'bool', required: false, constraint: 'Use when the child will COMMIT to the repo while you or other agents may also be editing it — the worktree isolates its work so nothing collides; skip it for read-only work or when the child is the sole writer. Creates a crouter-managed git worktree for the spawned child, keyed by the child node id, at `~/.crouter/canvas/worktrees/<node-id>/` on branch `crtr/<node-id>` based on `origin/main`; the child\'s cwd is pinned there, and it lands the work serially onto origin/main later with `crtr worktree close`. Mutually exclusive with --root.' },
134
+ { kind: 'flag', name: 'worktree', type: 'bool', required: false, constraint: 'Use when the child will COMMIT to the repo while you or other agents may also be editing it — the worktree isolates its work so nothing collides; skip it for read-only work or when the child is the sole writer. Creates a crouter-managed git worktree for the spawned child, keyed by the child node id, at `~/.crouter/canvas/worktrees/<node-id>/` on branch `crtr/<node-id>` based on `origin/main`; the child\'s cwd is pinned there, and it lands the work serially onto origin/main later with `crtr node worktree close`. Mutually exclusive with --root.' },
132
135
  { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'FORK the new node from an existing pi conversation instead of starting it fresh: pass a node id (forks from that node\'s session), an absolute session `.jsonl` path, or a partial pi session uuid. pi copies that whole history into a NEW session for the child (the source is untouched), then the prompt is delivered as the next message — i.e. the child wakes up as a continuation of that conversation. Use to branch exploratory work off a node that already built up the context you need, instead of re-deriving it. One-shot at birth: the fork resumes its own session thereafter.' },
133
- { kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong', 'medium', 'light'], required: false, constraint: 'Override the model the node runs on, by capability TIER: ultra (frontier), strong (opus), medium (sonnet), light (haiku). Omit to use the kind\'s persona default (advisor=ultra, explore=light, most other builtins=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
136
+ { kind: 'flag', name: 'model', type: 'string', required: false, constraint: 'Override the model the node runs on: exact `provider/id` (e.g. openai-codex/gpt-5.5), a `provider/tier` (anthropic|openai + ultra|strong|medium|light, e.g. openai/ultra — pin a specific model family, useful for spawning cross-family children), a bare capability TIER (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku). Must resolve to a concrete model OFFLINE — unlike `node config --model`, no live broker exists yet at spawn to free-text substring-search a registry. Omit to use the kind\'s persona default (advisor=ultra, explore=light, most other builtins=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
134
137
  { kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Select the profile this node runs under — an exact profile id or a unique manifest name (see the <profiles> list below, or `crtr profile list`). Omit to INHERIT the calling node\'s current profile (root has none, so an uninherited child has none either). --root does NOT reset this — it only means top-level, still inherits/uses the selected profile unless this overrides it.' },
135
138
  { kind: 'flag', name: 'output-schema', type: 'string', required: false, constraint: 'Path to a JSON Schema file, or inline JSON beginning with `{`. The spawned node gets a `submit` tool shaped by this schema, must call it to finish, and the submitted JSON is written to the final report and context/result.json. Rejected with trigger flags in this version.' },
136
139
  { kind: 'flag', name: 'at', type: 'string', required: false, constraint: 'DEFER the birth to a one-shot clock trigger at this future time instead of spawning now — a duration ("5m","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", host-local or in --tz). Required unless --every is given (the cadence then sets the first fire). Mutually exclusive with --when.' },
@@ -157,7 +160,7 @@ const nodeNew = defineLeaf({
157
160
  dynamicState: () => [kindsStateBlock(), profilesStateBlock()].join('\n'),
158
161
  outputKind: 'object',
159
162
  effects: [
160
- 'Spawning now: creates a node under ~/.crouter/canvas/nodes/<id>/ and indexes it in canvas.db. Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only. With --worktree: also creates a managed git worktree at ~/.crouter/canvas/worktrees/<node-id>/ on branch crtr/<node-id> based on origin/main; the child closes it later with `crtr worktree close` before finishing. With --output-schema: writes nodes/<id>/output-schema.json in terminal mode; the node must call submit, which writes context/result.json and pushes final. Launches the node\'s engine as a detached broker (the only host); a managed child opens NO viewer, a --root opens one in your current tmux session.',
163
+ 'Spawning now: creates a node under ~/.crouter/canvas/nodes/<id>/ and indexes it in canvas.db. Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only. With --worktree: also creates a managed git worktree at ~/.crouter/canvas/worktrees/<node-id>/ on branch crtr/<node-id> based on origin/main; the child closes it later with `crtr node worktree close` before finishing. With --output-schema: writes nodes/<id>/output-schema.json in terminal mode; the node must call submit, which writes context/result.json and pushes final. Launches the node\'s engine as a detached broker (the only host); a managed child opens NO viewer, a --root opens one in your current tmux session.',
161
164
  'Armed (--at/--every/--when): inserts one detached `new`/`node_birth` triggers row (node_id NULL, owner=you, parent/cwd resolved now); NO node and NO window exist until fire time. The daemon spawns from the stored recipe at fire time, re-deriving the launch spec live.',
162
165
  ],
163
166
  },
@@ -174,7 +177,16 @@ const nodeNew = defineLeaf({
174
177
  const root = input['root'] === true;
175
178
  const worktree = input['worktree'] === true;
176
179
  const forkFrom = input['forkFrom'];
177
- const model = input['model'];
180
+ const modelSpec = input['model']?.trim();
181
+ if (modelSpec !== undefined) {
182
+ if (modelSpec === '') {
183
+ throw new InputError({ error: 'empty_spec', message: 'a model spec is required', field: 'model', next: 'Pass a `provider/id`, a `provider/tier` (e.g. openai/ultra), a tier (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku).' });
184
+ }
185
+ if (!normalizeModel(modelSpec).includes('/')) {
186
+ throw new InputError({ error: 'unresolvable_model', message: `'${modelSpec}' does not resolve to a concrete model`, field: 'model', next: 'Pass an exact `provider/id` (e.g. openai-codex/gpt-5.5), a `provider/tier` (e.g. openai/ultra), a tier (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku) — a live-broker substring search (like `node config --model`) is not available at spawn.' });
187
+ }
188
+ }
189
+ const model = modelSpec;
178
190
  const profile = input['profile'];
179
191
  const outputSchema = parseOutputSchemaValue(input['outputSchema']);
180
192
  const at = input['at']?.trim();
@@ -461,13 +473,13 @@ const nodeShow = defineLeaf({
461
473
  // ---------------------------------------------------------------------------
462
474
  const nodeInspect = defineBranch({
463
475
  name: 'inspect',
464
- description: 'read the graph (list, show, context)',
465
- whenToUse: 'reading the graph: enumerate nodes on the canvas (`list`), inspect one and its spine neighbors (`show`), or browse on-disk context dirs with `node inspect context` while reading a node. Use `canvas dashboard` instead to render the tree SHAPE, or `canvas attention` to find which nodes are blocked on a human',
476
+ description: 'read one node — its graph position, context, and artifacts',
477
+ whenToUse: 'reading everything about ONE node you can name: enumerate the roster (`list`), inspect a node and its spine neighbors (`show`), browse its on-disk context dir (`context`), or list its reports/docs as refs (`artifacts`). Use `canvas dashboard` instead to render the whole tree SHAPE, or `canvas attention` to find which nodes are blocked on a human',
466
478
  help: {
467
479
  name: 'node inspect',
468
- summary: 'read the canvas graph enumerate nodes, inspect one, or browse on-disk context dirs for a node',
480
+ summary: 'read one node (or the roster) topology, context dir, and artifacts',
469
481
  },
470
- children: [nodeList, nodeShow, nodeSnapshotLeaf, nodeContextLeaf],
482
+ children: [nodeList, nodeShow, nodeSnapshotLeaf, nodeContextLeaf, nodeArtifactsLeaf],
471
483
  });
472
484
  // ---------------------------------------------------------------------------
473
485
  // node focus — bring a node's viewer forefront (across roots if needed)
@@ -626,10 +638,10 @@ const nodeRecycle = defineLeaf({
626
638
  const nodeClose = defineLeaf({
627
639
  name: 'close',
628
640
  description: 'close a node + cascade-cancel its exclusive subtree (revivable)',
629
- whenToUse: 'you want to tear a node down WITHOUT finishing it, cascade-cancelling every descendant it exclusively owns: abandoning a line of work, killing a stuck or wrong-turn subtree, clearing a branch you no longer need. Windows die but nothing is deleted — each closed node keeps its pi session and can be revived later (`canvas revive`). Use `node lifecycle recycle` instead to FINISH the agent in your pane with a final report, and `push final` when a worker should end its own work normally (Alt+C → x)',
641
+ whenToUse: 'you want to tear a node down WITHOUT finishing it, cascade-cancelling every descendant it exclusively owns: abandoning a line of work, killing a stuck or wrong-turn subtree, clearing a branch you no longer need. Windows die but nothing is deleted — each closed node keeps its pi session and can be revived later (`node lifecycle revive`). Use `node lifecycle recycle` instead to FINISH the agent in your pane with a final report, and `push final` when a worker should end its own work normally (Alt+C → x)',
630
642
  help: {
631
643
  name: 'node lifecycle close',
632
- summary: 'close a node and cascade-cancel its subtree — kill its tmux window plus those of every descendant it EXCLUSIVELY owns (down the subscribes_to spine), mark them all canceled, and leave each a notice it reads on resume. A descendant still subscribed to by a manager outside the subtree is left running. Nothing is deleted: every closed node keeps its pi session and can be revived later (`crtr canvas revive`)',
644
+ summary: 'close a node and cascade-cancel its subtree — kill its tmux window plus those of every descendant it EXCLUSIVELY owns (down the subscribes_to spine), mark them all canceled, and leave each a notice it reads on resume. A descendant still subscribed to by a manager outside the subtree is left running. Nothing is deleted: every closed node keeps its pi session and can be revived later (`crtr node lifecycle revive`)',
633
645
  params: [
634
646
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to close. Defaults to the node occupying --pane (or your current pane).' },
635
647
  { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id whose node to close. Defaults to $TMUX_PANE / your current pane. The Alt+C menu passes this for you.' },
@@ -1530,13 +1542,13 @@ const nodeDemote = defineLeaf({
1530
1542
  // ---------------------------------------------------------------------------
1531
1543
  const nodeLifecycle = defineBranch({
1532
1544
  name: 'lifecycle',
1533
- description: 'lifecycle actions — recycle, close, demote a node',
1534
- whenToUse: 'you are ending or transitioning a node\'s life: finish the agent in your pane and reboot a fresh root (`recycle`), tear a node + its exclusive subtree down without finishing (`close`), or flip a node onto a finishing track in place (`demote`). To merely SET the terminal/resident attribute headlessly use `node config --lifecycle`; to FINISH from inside a node\'s own turn use `crtr push final`.',
1545
+ description: 'lifecycle actions — recycle, close, demote, or revive a node',
1546
+ whenToUse: 'you are ending, transitioning, or bringing back one node you can name: finish the agent in your pane and reboot a fresh root (`recycle`), tear a node + its exclusive subtree down without finishing (`close`), flip a node onto a finishing track in place (`demote`), or bring a dormant node back (`revive`). To merely SET the terminal/resident attribute headlessly use `node config --lifecycle`; to FINISH from inside a node\'s own turn use `crtr push final`; to mass-reconnect EVERY disconnected node at once use `canvas revive --all`.',
1535
1547
  help: {
1536
1548
  name: 'node lifecycle',
1537
- summary: 'end or transition a node — recycle the pane, close + cascade-cancel a subtree, or demote to terminal in place',
1549
+ summary: 'end, transition, or revive a node — recycle the pane, close + cascade-cancel a subtree, demote to terminal in place, or revive a dormant node',
1538
1550
  },
1539
- children: [nodeRecycle, nodeClose, nodeDemote],
1551
+ children: [nodeRecycle, nodeClose, nodeDemote, nodeReviveLeaf],
1540
1552
  });
1541
1553
  // ---------------------------------------------------------------------------
1542
1554
  // node yield — refresh: discard context, revive fresh against the roadmap
@@ -1811,6 +1823,6 @@ export function registerNode() {
1811
1823
  'HOW: the new leaf returns a node id immediately and runs the worker as a detached headless broker; no viewer opens unless a surface asks for one. Match the kind to the work and read the leaf schema before invoking. You are woken when a child finishes — the wake message ALREADY IS the coalesced digest, so dereference the report paths in that digest that matter instead of acting on a one-line label. Integrate, then either delegate the next units or finish.\n\n' +
1812
1824
  'FINISH: a worker ends its own work with a final push, which writes the canonical result, marks done, and closes the window; stopping without it is not finishing. For a job too big for one context window, promote to an orchestrator; when context fills, yield to refresh against that roadmap.',
1813
1825
  },
1814
- children: [nodeNew, nodeMsg, nodeSubscribe, nodeUnsubscribe, nodePromote, nodeYield, nodeFocus, nodeCycle, nodeInspect, nodeLifecycle, nodeConfig, nodeTriggers],
1826
+ children: [nodeNew, nodeMsg, nodeSubscribe, nodeUnsubscribe, nodePromote, nodeYield, nodeFocus, nodeCycle, nodeInspect, nodeLifecycle, nodeConfig, nodeTriggers, nodeWorktreeBranch],
1815
1827
  });
1816
1828
  }
@@ -101,7 +101,7 @@ function makeTierLeaf(tier) {
101
101
  throw new InputError({
102
102
  error: 'open_managed_worktree',
103
103
  message: 'This node still owns an open managed worktree — `push final` is blocked until it is closed.',
104
- next: 'Commit the remaining work in the worktree, run `crtr worktree close`, then rerun `crtr push final`.',
104
+ next: 'Commit the remaining work in the worktree, run `crtr node worktree close`, then rerun `crtr push final`.',
105
105
  });
106
106
  }
107
107
  // A RESIDENT node with no subscribers is human-driven and has no one to
@@ -1,12 +1,2 @@
1
1
  import type { LeafDef } from '../core/command.js';
2
- import type { Fault } from '../core/runtime/fault.js';
3
- /** --now eligibility for a live, non-busy fault: a daemon-owned AUTO retry
4
- * (kick it early instead of waiting out the schedule), or any fault the
5
- * canvas dashboard flags "needs you" (`faultNeedsYou`, status-glyph.ts) — the
6
- * disposition that otherwise has NO sanctioned recovery verb (issue #115).
7
- * Both are scoped to `pi→provider`: refuses a client-owned auto retry
8
- * (redialing) already in flight, and refuses any OTHER link — notably a
9
- * `daemon→node` wedge fault, which --now can't clear (SIGTERM doesn't touch
10
- * the runaway subprocess; see `wedged-child-on-runaway-bash`). */
11
- export declare function isNowEligibleFault(fault: Fault | null): boolean;
12
2
  export declare const reviveLeaf: LeafDef;
@@ -1,90 +1,42 @@
1
- // `crtr canvas revive` — explicit node revival.
1
+ // `crtr canvas revive` — mass node recovery.
2
2
  //
3
- // Bypasses the daemon: directly relaunches the broker engine for a node that is
4
- // done, idle, or dead. Default behavior resumes the saved pi conversation by its
5
- // absolute session-file path (--session <path>) when that .jsonl still exists,
6
- // else falls through to a fresh launch. `--fresh` skips the true resume: if the
7
- // `.jsonl` already exists it CYCLES in place instead (same file, a fresh
8
- // `crtr-cycle` branch — `resumed:false`), and only starts a genuinely clean
9
- // session when no `.jsonl` exists yet (also `resumed:false`).
3
+ // Sweeps EVERY disconnected node (engine not running, resumable session) in
4
+ // one shot the recovery for a reboot / mass-crash / daemon-down-a-while
5
+ // event. It is a two-step gate: run it once to PREVIEW the candidates
6
+ // (nothing is launched), then confirm to actually revive them.
10
7
  //
11
- // `--all` sweeps EVERY disconnected node (engine not running, resumable session)
12
- // in one shot the recovery for a reboot / mass-crash / daemon-down-a-while
13
- // event. It is a two-step gate: run it once to PREVIEW the candidates (nothing is
14
- // launched), then confirm to actually revive them.
8
+ // Reviving ONE named node is a per-node lifecycle action and lives instead at
9
+ // `crtr node lifecycle revive <id>` (src/commands/node-lifecycle-revive.ts).
15
10
  import { defineLeaf } from '../core/command.js';
16
11
  import { InputError } from '../core/io.js';
17
- import { reviveNode } from '../core/runtime/revive.js';
18
12
  import { listDisconnected, reviveAll } from '../core/runtime/revive-all.js';
19
- import { waitForBrokerViewSocket } from '../core/runtime/placement.js';
20
13
  import { getNode, fullName } from '../core/canvas/index.js';
21
- import { isPidAlive } from '../core/canvas/pid.js';
22
- import { readFault } from '../core/runtime/fault.js';
23
- import { isBusy } from '../core/runtime/busy.js';
24
- import { faultNeedsYou } from '../core/canvas/status-glyph.js';
25
- /** --now eligibility for a live, non-busy fault: a daemon-owned AUTO retry
26
- * (kick it early instead of waiting out the schedule), or any fault the
27
- * canvas dashboard flags "needs you" (`faultNeedsYou`, status-glyph.ts) — the
28
- * disposition that otherwise has NO sanctioned recovery verb (issue #115).
29
- * Both are scoped to `pi→provider`: refuses a client-owned auto retry
30
- * (redialing) already in flight, and refuses any OTHER link — notably a
31
- * `daemon→node` wedge fault, which --now can't clear (SIGTERM doesn't touch
32
- * the runaway subprocess; see `wedged-child-on-runaway-bash`). */
33
- export function isNowEligibleFault(fault) {
34
- if (fault === null || fault.link !== 'pi→provider')
35
- return false;
36
- if (faultNeedsYou(fault))
37
- return true;
38
- return fault.retry.by === 'daemon';
39
- }
40
14
  // ---------------------------------------------------------------------------
41
- // revive node
15
+ // revive --all
42
16
  // ---------------------------------------------------------------------------
43
17
  export const reviveLeaf = defineLeaf({
44
18
  name: 'revive',
45
- description: 'reopen a window for a done/idle/dead/canceled node, or --all disconnected nodes',
46
- whenToUse: 'you want to bring a dormant node back yourself — reopen a window for one that is done, idle, dead, or canceled: resume a node you closed with `node lifecycle close`, reopen a finished worker for a follow-up, or restart a crashed one now instead of waiting. It resumes the saved conversation by default, or can restart the node clean. Pass `--all` instead of a node id to bring back EVERY disconnected node at once (engine not running but a saved session intact) — the recovery after a reboot, a killed login/tmux session, a mass crash, or the daemon being down a while. You rarely need this for crashes — the daemon auto-revives those; reach for it to bring a node back on demand, to revive a canceled node the daemon will never touch on its own, or to mass-reconnect survivors after a disconnect event',
19
+ description: 'mass-reconnect every disconnected node',
20
+ whenToUse: 'you need to bring back EVERY disconnected node at once (engine not running but a saved session intact) — the recovery after a reboot, a killed login/tmux session, a mass crash, or the daemon being down a while. You rarely need this for an ordinary crash — the daemon auto-revives those. To bring back ONE named node (a follow-up on a finished worker, a canceled node, or an on-demand kick of a hanging one), use `node lifecycle revive <id>` instead',
47
21
  help: {
48
22
  name: 'canvas revive',
49
- summary: 'relaunch a node\'s broker engine (resuming its saved conversation), or --all disconnected nodes',
23
+ summary: 'mass-reconnect every disconnected node (engine not running, resumable saved session)',
50
24
  params: [
51
- {
52
- kind: 'positional',
53
- name: 'node',
54
- required: false,
55
- constraint: 'Node id to revive. Omit when using --all.',
56
- },
57
25
  {
58
26
  kind: 'flag',
59
27
  name: 'all',
60
28
  type: 'bool',
61
29
  required: false,
62
30
  default: false,
63
- constraint: 'Revive EVERY disconnected node (engine not running, resumable saved session) excludes done/canceled (terminal-by-choice). Run it once to PREVIEW the candidates; nothing is launched until you confirm. Mutually exclusive with a node id.',
64
- },
65
- {
66
- kind: 'flag',
67
- name: 'fresh',
68
- type: 'bool',
69
- required: false,
70
- default: false,
71
- constraint: 'When set, do NOT do a true resume. If the node already has a session `.jsonl`, this instead CYCLES it in place — reuses that SAME file, resets the tree leaf, and roots a fresh `crtr-cycle` branch (a clean-context restart that still carries the file\'s history, output `resumed:false`). Only when no `.jsonl` exists yet does this start a genuinely brand-new session (also `resumed:false`). Default (no --fresh): a true resume — replays the existing `.jsonl` as-is (`resumed:true`). Ignored with --all (which always resumes).',
72
- },
73
- {
74
- kind: 'flag',
75
- name: 'now',
76
- type: 'bool',
77
- required: false,
78
- default: false,
79
- constraint: 'On-demand kick of an active-fault node (parked on a `pi→provider` fault, broker still alive). Its broker pid is alive, so an ordinary revive no-ops (double-launch guard) — --now SIGTERMs the live broker instead, so the daemon\'s crash→grace→resume path brings it back in ~20s. Valid for a live `pi→provider` fault that is either a daemon-owned auto retry (kicks it early), or ANY fault the canvas dashboard flags "needs you" (e.g. exhausted retries, an auth fault you just fixed) — that disposition otherwise has no recovery verb. Mutually exclusive with --all/--fresh.',
31
+ constraint: 'Required (rejected without it) — confirms this is the graph-wide sweep, not a typo for a single-node revive (that lives at `node lifecycle revive <id>`). Excludes done/canceled (terminal-by-choice). Run it once to PREVIEW the candidates; nothing is launched until you confirm.',
80
32
  },
81
- // --force is a DELIBERATELY UNDOCUMENTED confirmation gate for --all (hidden
82
- // from -h; see FlagParam.hidden). Silas's intent (2026-06-13): a mass revive
83
- // can have large, unforeseen consequences, so the agent must NOT be able to
84
- // discover a one-shot bypass by reading the schema. The unforced --all path
85
- // PREVIEWS the candidates and instructs the agent to double-check with the
86
- // USER before re-running; only then, having been told the flag, does it pass
87
- // --force. Documenting it here would defeat that gate.
33
+ // --force is a DELIBERATELY UNDOCUMENTED confirmation gate (hidden from
34
+ // -h; see FlagParam.hidden). Silas's intent (2026-06-13): a mass revive
35
+ // can have large, unforeseen consequences, so the agent must NOT be able
36
+ // to discover a one-shot bypass by reading the schema. The unforced --all
37
+ // path PREVIEWS the candidates and instructs the agent to double-check
38
+ // with the USER before re-running; only then, having been told the flag,
39
+ // does it pass --force. Documenting it here would defeat that gate.
88
40
  {
89
41
  kind: 'flag',
90
42
  name: 'force',
@@ -92,135 +44,33 @@ export const reviveLeaf = defineLeaf({
92
44
  required: false,
93
45
  default: false,
94
46
  hidden: true,
95
- constraint: 'Confirm a --all sweep (internal gate; see code comment).',
47
+ constraint: 'Confirm the sweep (internal gate; see code comment).',
96
48
  },
97
49
  ],
98
50
  output: [
99
- { name: 'window', type: 'string', required: false, constraint: 'Always null — the revived broker is headless and opens no tmux window. Kept for caller back-compat.' },
100
- { name: 'session', type: 'string', required: false, constraint: 'The node\'s last live location session, or null the headless broker has no tmux session of its own.' },
101
- { name: 'resumed', type: 'boolean', required: false, constraint: 'True ONLY for a true resume (the existing `.jsonl` replayed as-is). False for a --fresh revive, whether that landed as an in-place CYCLE (an existing `.jsonl` reused with a fresh `crtr-cycle` branch — pi is still told --session, but this is not a resume) or a genuinely fresh launch (no `.jsonl` existed). Single-node revive only.' },
102
- { name: 'ready', type: 'boolean', required: false, constraint: 'True when the revived broker\'s view.sock accepted a connection before return — the node is immediately attachable/drivable. Single-node revive only.' },
103
- { name: 'mode', type: 'string', required: false, constraint: '"preview" (the --all candidate list, nothing launched), "revived" (the --all sweep ran), or absent for a single-node revive.' },
104
- { name: 'candidates', type: 'string', required: false, constraint: '--all preview: the node ids that WOULD be revived (newline-joined). Empty when none are disconnected.' },
105
- { name: 'revived', type: 'string', required: false, constraint: '--all sweep: the node ids whose broker engine was relaunched (newline-joined).' },
106
- { name: 'failed', type: 'string', required: false, constraint: '--all sweep: node ids whose relaunch threw, with the error (newline-joined). Absent when none failed.' },
107
- { name: 'kicked', type: 'boolean', required: false, constraint: '--now only: true when the hanging node\'s live broker was SIGTERM\'d (the daemon resumes it on the saved session within ~20s). Absent for an ordinary revive.' },
51
+ { name: 'mode', type: 'string', required: true, constraint: '"preview" (the candidate list, nothing launched) or "revived" (the sweep ran).' },
52
+ { name: 'candidates', type: 'string', required: false, constraint: 'Preview: the node ids that WOULD be revived (newline-joined). Empty when none are disconnected.' },
53
+ { name: 'revived', type: 'string', required: false, constraint: 'Sweep: the node ids whose broker engine was relaunched (newline-joined).' },
54
+ { name: 'failed', type: 'string', required: false, constraint: 'Sweep: node ids whose relaunch threw, with the error (newline-joined). Absent when none failed.' },
108
55
  ],
109
56
  outputKind: 'object',
110
57
  effects: [
111
- 'Launches the node\'s detached headless broker engine (no tmux window).',
112
- 'Updates the node\'s canvas record: status=active, intent=null.',
113
- 'Blocks until the broker\'s view.sock accepts a connection (up to ~30s), so a caller can attach/dial immediately on return.',
114
- '--all without confirmation launches NOTHING — it only previews the disconnected candidates.',
58
+ 'Launches the detached headless broker engine (no tmux window) for every disconnected node.',
59
+ 'Updates each revived node\'s canvas record: status=active, intent=null.',
60
+ 'Without --force: launches NOTHING only previews the disconnected candidates.',
115
61
  ],
116
62
  },
117
63
  run: async (input) => {
118
- const all = input['all'] ?? false;
119
- const now = input['now'] ?? false;
120
- const nodeId = input['node'];
121
- if (all) {
122
- if (now) {
123
- throw new InputError({
124
- error: 'conflicting_args',
125
- message: '`--now` kicks ONE hanging node; it cannot combine with `--all`.',
126
- next: 'Run `crtr canvas revive <node> --now` for the hanging node, or `crtr canvas revive --all` (no --now).',
127
- });
128
- }
129
- if (nodeId !== undefined) {
130
- throw new InputError({
131
- error: 'conflicting_args',
132
- message: '`--all` reconnects every disconnected node; do not also pass a node id.',
133
- next: 'Run `crtr canvas revive --all` (no node id), or `crtr canvas revive <node>` for one.',
134
- });
135
- }
136
- return runReviveAll(input);
137
- }
138
- if (nodeId === undefined) {
64
+ if (input['all'] !== true) {
139
65
  throw new InputError({
140
66
  error: 'missing_parameter',
141
- message: 'pass a node id to revive, or `--all` to reconnect every disconnected node.',
142
- next: 'Run `crtr canvas revive <node>` or `crtr canvas revive --all`. List nodes with `crtr node inspect list`.',
143
- });
144
- }
145
- const fresh = input['fresh'] ?? false;
146
- if (now && fresh) {
147
- throw new InputError({
148
- error: 'conflicting_args',
149
- message: '`--now` kicks a live hanging broker (SIGTERM → daemon resumes the SAVED session); `--fresh` is meaningless there.',
150
- next: 'Run `crtr canvas revive <node> --now` (resumes), or `crtr canvas revive <node> --fresh` (no --now) to relaunch clean.',
67
+ message: '`canvas revive` is the mass-recovery sweep and requires --all.',
68
+ next: 'Run `crtr canvas revive --all` to reconnect every disconnected node, or `crtr node lifecycle revive <id>` to bring back one named node.',
151
69
  });
152
70
  }
153
- // Validate the node exists before attempting revival.
154
- const meta = getNode(nodeId);
155
- if (meta === null) {
156
- throw new InputError({
157
- error: 'not_found',
158
- message: `no node: ${nodeId}`,
159
- next: 'List nodes with `crtr node inspect list`.',
160
- });
161
- }
162
- // --now: the on-demand kick for an active fault. The broker is ALIVE (so
163
- // reviveNode would no-op the double-launch guard) — SIGTERM it so the daemon's
164
- // ordinary crash→grace→resume path recovers it on the saved session, the same
165
- // thing the daemon does at the auto-recovery grace, just on demand. Gated on
166
- // a live pid and an eligible pi→provider fault (`isNowEligibleFault` above) so
167
- // it can't be used to nuke a healthy node.
168
- if (now) {
169
- const pid = meta.pi_pid;
170
- const stall = readFault(nodeId);
171
- if (pid == null || !isPidAlive(pid)) {
172
- throw new InputError({
173
- error: 'not_hanging',
174
- message: `${nodeId} has no live broker — nothing to kick. --now is for a HANGING node (live broker parked on an active fault).`,
175
- next: 'Revive a dormant node with `crtr canvas revive ' + nodeId + '` (no --now).',
176
- });
177
- }
178
- if (!isNowEligibleFault(stall)) {
179
- throw new InputError({
180
- error: 'not_hanging',
181
- message: `${nodeId} is live but has no pi→provider fault eligible for --now (either healthy, or a client-owned retry / non-provider fault already being handled elsewhere) — --now refuses to SIGTERM a node that isn't stuck on a kickable provider fault.`,
182
- next: 'Use --now only on a node the canvas shows as hanging (⚠) with a `pi→provider` fault. For a routine relaunch, omit --now.',
183
- });
184
- }
185
- // The daemon never kills a BUSY engine (its verdict paths all return 'leave'
186
- // on a live busy marker): a node mid-turn is making progress, not wedged —
187
- // refuse rather than SIGTERM it mid-flight.
188
- if (isBusy(nodeId)) {
189
- throw new InputError({
190
- error: 'busy',
191
- message: `${nodeId} is mid-turn right now (busy) — --now refuses to SIGTERM a working engine; it has recovered on its own.`,
192
- next: 'Re-check the canvas — if it\'s no longer marked hanging (⚠), nothing to do.',
193
- });
194
- }
195
- try {
196
- process.kill(pid, 'SIGTERM');
197
- }
198
- catch {
199
- /* best-effort: the broker may have just died; the daemon owns the resume */
200
- }
201
- return {
202
- window: null,
203
- session: meta.tmux_session ?? null,
204
- kicked: true,
205
- message: `Sent SIGTERM to ${fullName(meta)} (${nodeId}, pid ${pid}). The daemon will resume it on the saved session within ~20s (crash→grace→resume).`,
206
- };
207
- }
208
- const result = reviveNode(nodeId, { resume: !fresh });
209
- // Revive returns once the broker process is launched, which can be seconds
210
- // before its view.sock listens. Callers (attach, the web shell's Wake) dial
211
- // immediately on return, so block here until the socket accepts.
212
- const ready = await waitForBrokerViewSocket(nodeId, result.launch?.exited);
213
- return {
214
- window: result.window ?? undefined,
215
- session: result.session,
216
- resumed: result.resumed,
217
- ready,
218
- };
71
+ return runReviveAll(input);
219
72
  },
220
73
  });
221
- // ---------------------------------------------------------------------------
222
- // revive --all
223
- // ---------------------------------------------------------------------------
224
74
  /** One line per candidate: `<id> <full name> [<status>]`. */
225
75
  function describe(ids) {
226
76
  return ids
@@ -100,8 +100,8 @@ test('a fresh revive is pointed at its subscriptions\' on-disk report history (c
100
100
  assert.ok(!msg.includes(older), 'absolute report paths not rendered');
101
101
  assert.ok(!msg.includes(newer), 'absolute report paths not rendered');
102
102
  assert.ok(/canvas history read <ref>/.test(msg), 'points at one-report expansion');
103
- assert.ok(/canvas history show <node-id> --type report/.test(msg), 'points at full per-node expansion');
104
- assert.ok(/canvas history show <node-id> --type inbox/.test(msg), 'points at the cursor-independent full-history replay');
103
+ assert.ok(/node inspect artifacts <node-id> --type report/.test(msg), 'points at full per-node expansion');
104
+ assert.ok(/node inspect artifacts <node-id> --type inbox/.test(msg), 'points at the cursor-independent full-history replay');
105
105
  });
106
106
  test('buildReviveKickoff is pure — building twice eats nothing', () => {
107
107
  const id = 'n2';
@@ -30,3 +30,19 @@ test('explore remains cheap by default and may explicitly run light', () => {
30
30
  assert.equal(spec('explore'), normalizeModel('anthropic/light'));
31
31
  assert.equal(spec('explore', 'light'), normalizeModel('light'));
32
32
  });
33
+ // `node new --model` widened to the full spec grammar (provider/tier, exact
34
+ // provider/id, family alias) alongside the bare tier — the review floor must
35
+ // still hold no matter which shape the requested model arrives in.
36
+ test('review honors an explicit provider/tier override at or above its floor', () => {
37
+ assert.equal(spec('review', 'openai/ultra'), normalizeModel('openai/ultra'));
38
+ });
39
+ test('review still floors a provider/tier override below its floor', () => {
40
+ assert.equal(spec('review', 'openai/light'), normalizeModel('openai/strong'));
41
+ });
42
+ test('review passes an unrecognized concrete provider/id straight through, unfloored', () => {
43
+ // A fully concrete id that matches no ladder cell resolves to no known
44
+ // strength, so floorReviewModel trusts the explicit pin as-given rather than
45
+ // guessing — same trust boundary as `node config --model` on a dormant node.
46
+ const concrete = 'anthropic/claude-does-not-exist-9000';
47
+ assert.equal(spec('review', concrete), concrete);
48
+ });
@@ -10,7 +10,7 @@
10
10
  // broker it once named is long gone. Left unchecked this fools BOTH the
11
11
  // daemon's own liveness poll (crtrd.ts) and reviveNode's double-launch guard
12
12
  // (revive.ts) into believing a dead broker is still running — the node then
13
- // stays permanently unrevivable until a human runs `canvas revive --fresh`.
13
+ // stays permanently unrevivable until a human runs `node lifecycle revive --fresh`.
14
14
  //
15
15
  // The signal (primary): the Linux kernel's per-boot identity at
16
16
  // `/proc/sys/kernel/random/boot_id` — a fresh random UUID minted on every
@@ -80,12 +80,12 @@ export async function runBrowse(opts = {}) {
80
80
  // Safety net: an uncaught throw in the (un-unit-tested) keystroke path must
81
81
  // never strand the tty in raw + alt-screen + hidden-cursor.
82
82
  process.once('exit', cleanup);
83
- /** On-demand kick of a hanging node: shell `canvas revive <id> --now` (which
84
- * SIGTERMs the live broker — the daemon resumes it). Synchronous + quiet
85
- * (stdio ignored) so it never takes over the TUI; best-effort. */
83
+ /** On-demand kick of a hanging node: shell `node lifecycle revive <id> --now`
84
+ * (which SIGTERMs the live broker — the daemon resumes it). Synchronous +
85
+ * quiet (stdio ignored) so it never takes over the TUI; best-effort. */
86
86
  const reviveNow = (id) => {
87
87
  try {
88
- execFileSync('crtr', ['canvas', 'revive', id, '--now'], { stdio: 'ignore' });
88
+ execFileSync('crtr', ['node', 'lifecycle', 'revive', id, '--now'], { stdio: 'ignore' });
89
89
  }
90
90
  catch {
91
91
  /* best-effort — the SIGTERM is what matters; the daemon owns the resume */
@@ -471,8 +471,8 @@ export async function runBrowse(opts = {}) {
471
471
  }
472
472
  // Revive now (on-demand kick): for a HANGING node, SIGTERM the live broker so
473
473
  // the daemon's crash→grace→resume path brings it back in ~20s instead of
474
- // waiting out the 5-min auto-revive grace. Shells `canvas revive <id> --now`
475
- // (browse goes through commands, never touching the runtime directly). 'k' is
474
+ // waiting out the 5-min auto-revive grace. Shells `node lifecycle revive <id>
475
+ // --now` (browse goes through commands, never touching the runtime directly). 'k' is
476
476
  // vim-up here, so the kick is on SHIFT-K.
477
477
  if (input === 'K') {
478
478
  if (row !== undefined) {
@@ -472,7 +472,7 @@ export function renderFrame(state, size, caps = { color: false, color256: false
472
472
  }
473
473
  else {
474
474
  // When the cursor row has an active fault, lead the footer with the on-demand kick
475
- // hint (K shells `canvas revive <id> --now` — immediate revive instead of
475
+ // hint (K shells `node lifecycle revive <id> --now` — immediate revive instead of
476
476
  // waiting for the daemon's retry schedule).
477
477
  const selRow = state.visible[state.cursor];
478
478
  const cursorRow = selRow !== undefined ? state.tree.nodes.get(selRow.id)?.row : undefined;
@@ -26,7 +26,7 @@ export declare function resolveNodeVisual(status: NodeStatus, opts: {
26
26
  /** True for exactly the fault dispositions `faultDispositionText` renders as
27
27
  * "needs you" — nothing (sdk/daemon/client) is auto-driving recovery, so the
28
28
  * dashboard flags the node for the human. Exported so a recovery verb (e.g.
29
- * `canvas revive --now`, src/commands/revive.ts) can gate on the SAME
29
+ * `node lifecycle revive --now`, src/commands/node-lifecycle-revive.ts) can gate on the SAME
30
30
  * predicate the dashboard advertises instead of drifting out of sync with it
31
31
  * (issue #115 — `--now` used to reject exactly the nodes shown as "needs you"). */
32
32
  export declare function faultNeedsYou(fault: Pick<Fault, 'retry'>): boolean;
@@ -73,7 +73,7 @@ function retryCountdown(iso, now) {
73
73
  /** True for exactly the fault dispositions `faultDispositionText` renders as
74
74
  * "needs you" — nothing (sdk/daemon/client) is auto-driving recovery, so the
75
75
  * dashboard flags the node for the human. Exported so a recovery verb (e.g.
76
- * `canvas revive --now`, src/commands/revive.ts) can gate on the SAME
76
+ * `node lifecycle revive --now`, src/commands/node-lifecycle-revive.ts) can gate on the SAME
77
77
  * predicate the dashboard advertises instead of drifting out of sync with it
78
78
  * (issue #115 — `--now` used to reject exactly the nodes shown as "needs you"). */
79
79
  export function faultNeedsYou(fault) {