@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
@@ -93,6 +93,10 @@ export declare class ChatView {
93
93
  private makeToolComponent;
94
94
  /** Append a child while keeping the activity/status area pinned to the bottom. */
95
95
  private append;
96
+ /** Approximate render width for inline mermaid diagrams. Only affects
97
+ * termrender's trailing padding (stripped in the transform), so the terminal
98
+ * column count is close enough without threading the live pane width here. */
99
+ private renderWidth;
96
100
  /** Number of chat children excluding the pinned status container. */
97
101
  private chatChildCount;
98
102
  /** Clear all chat content and re-pin an empty status container. */
@@ -25,6 +25,7 @@ import { AssistantMessageComponent, BashExecutionComponent, BranchSummaryMessage
25
25
  import { ContextMessageComponent } from './context-message.js';
26
26
  import { CRTR_OUTPUT_CUSTOM_TYPE, CrtrOutputMessageComponent, createCrtrBashToolDefinition, } from './crtr-output-render.js';
27
27
  import { CRTR_CYCLE_DIVIDER_CUSTOM_TYPE } from '../../core/runtime/session-cycles.js';
28
+ import { transformMessageMermaid } from './mermaid-render.js';
28
29
  /** customType stamped on the injected `<crtr-context>` bearings block (mirrors
29
30
  * CONTEXT_INTRO_CUSTOM_TYPE in canvas-context-intro.ts — kept as a local literal
30
31
  * so the viewer never imports the in-process extension). */
@@ -286,7 +287,10 @@ export class ChatView {
286
287
  // updateContent) so the rendered message shows the annotation.
287
288
  event.message.errorMessage = errorMessage;
288
289
  }
289
- this.streamingComponent.updateContent(event.message);
290
+ // Render any complete ```mermaid fence in the settled reply to a
291
+ // diagram before it reaches the markdown renderer (no-op when absent).
292
+ const finalMessage = transformMessageMermaid(event.message, this.renderWidth());
293
+ this.streamingComponent.updateContent(finalMessage);
290
294
  if (stopReason === 'aborted' || stopReason === 'error') {
291
295
  const text = errorMessage ?? 'Error';
292
296
  for (const component of this.pendingTools.values()) {
@@ -490,7 +494,8 @@ export class ChatView {
490
494
  break;
491
495
  }
492
496
  case 'assistant': {
493
- this.append(new AssistantMessageComponent(message, this.hideThinking, getMarkdownTheme(), this.hiddenThinkingLabel));
497
+ // Settled history message render its ```mermaid fences inline too.
498
+ this.append(new AssistantMessageComponent(transformMessageMermaid(message, this.renderWidth()), this.hideThinking, getMarkdownTheme(), this.hiddenThinkingLabel));
494
499
  break;
495
500
  }
496
501
  // toolResult is rendered inline with its tool call (handled in applySnapshot).
@@ -518,6 +523,12 @@ export class ChatView {
518
523
  this.container.addChild(child);
519
524
  this.container.addChild(this.statusContainer);
520
525
  }
526
+ /** Approximate render width for inline mermaid diagrams. Only affects
527
+ * termrender's trailing padding (stripped in the transform), so the terminal
528
+ * column count is close enough without threading the live pane width here. */
529
+ renderWidth() {
530
+ return Math.max(40, process.stdout.columns ?? 80);
531
+ }
521
532
  /** Number of chat children excluding the pinned status container. */
522
533
  chatChildCount() {
523
534
  return this.container.children.filter((c) => c !== this.statusContainer).length;
@@ -0,0 +1,23 @@
1
+ /** Cheap predicate: does this text contain what looks like a mermaid fence at
2
+ * all? Lets callers skip the transform (and the message rebuild) entirely for
3
+ * the common no-diagram reply. */
4
+ export declare function textHasMermaid(text: string): boolean;
5
+ /** Replace each complete ```mermaid fence in `text` with the rendered diagram,
6
+ * wrapped in a plain ``` code fence so pi's Markdown preserves its alignment.
7
+ * Leaves the fence untouched when the renderer is unavailable (degrades to a
8
+ * normal code block, exactly as before this feature) or when a render yields
9
+ * nothing usable. `width` only affects termrender's trailing padding (mermaid
10
+ * diagrams have no width control and overflow rather than truncate), which is
11
+ * stripped here, so an approximate pane width is fine. */
12
+ export declare function transformMermaidFences(text: string, width: number): string;
13
+ /** True when any text part of an assistant message carries a mermaid fence. */
14
+ export declare function messageHasMermaid(message: {
15
+ content?: unknown;
16
+ }): boolean;
17
+ /** Return a shallow clone of an assistant message with every text part's
18
+ * mermaid fences rendered inline. Non-text parts (thinking, toolCall) are
19
+ * passed through untouched, so thinking blocks and tool cards render exactly as
20
+ * before. A no-mermaid message is returned unchanged (same reference). */
21
+ export declare function transformMessageMermaid<T extends {
22
+ content?: unknown;
23
+ }>(message: T, width: number): T;
@@ -0,0 +1,124 @@
1
+ // mermaid-render.ts — inline mermaid rendering for assistant messages in the
2
+ // `crtr surface attach` viewer.
3
+ //
4
+ // When an agent writes a ```mermaid fence in its reply, the raw source is
5
+ // useless to a human watching the chat. This transforms each COMPLETE mermaid
6
+ // fence into the diagram itself (box-drawing ASCII) before the text reaches
7
+ // pi's markdown renderer, so the viewer shows the picture instead of the code.
8
+ //
9
+ // How it stays simple:
10
+ // • termrender renders mermaid to MONOCHROME box-drawing lines with zero ANSI
11
+ // escapes (verified: `--color on` output for a pure mermaid fence is
12
+ // byte-identical to `--color off`). So the rendered lines are safe to splice
13
+ // back into markdown inside a plain ``` code fence — pi's Markdown then shows
14
+ // them verbatim in monospace, no escape-code leakage, no re-wrapping.
15
+ // • `renderMarkdown` (humanloop — the sole org-wide termrender caller) is
16
+ // synchronous and memoized, so this is a plain string→string transform with
17
+ // no async swap or component lifecycle.
18
+ //
19
+ // Scope (v1): only ```mermaid fences. The wider termrender directive vocabulary
20
+ // is deliberately out of scope — see the base prompt's Diagrams note.
21
+ import { isRendererReady, renderMarkdown } from '@crouton-kit/humanloop';
22
+ /** A complete mermaid fence: ```mermaid (opening info string) ... closing ```.
23
+ * Line-anchored (m flag), non-greedy body (captured). An UNCLOSED fence (still
24
+ * streaming) does not match, so mid-stream text is left raw and only settles to
25
+ * a diagram once the closing ``` arrives. Indented / 4-backtick fences are out
26
+ * of scope — the base prompt tells agents to write a bare ```mermaid fence. */
27
+ const MERMAID_FENCE = /^```mermaid[^\n]*\n([\s\S]*?)\n```[ \t]*$/gm;
28
+ /** Box-drawing, block-element, and geometric-shape ranges — the glyphs a real
29
+ * mermaid-ascii render is made of. Their PRESENCE distinguishes a rendered
30
+ * diagram from termrender's silent plaintext fallback, which echoes the mermaid
31
+ * SOURCE verbatim (pure ASCII: `-->`, `[ ]`, `{ }`) when mermaid-ascii cannot
32
+ * parse the diagram (e.g. an indented body, an unsupported node shape). */
33
+ const DIAGRAM_GLYPH = /[\u2500-\u259F\u25A0-\u25FF]/;
34
+ /** Strip the common leading indentation shared by every non-blank body line.
35
+ * Agents routinely indent a fenced block to sit under a list item or heading,
36
+ * and mermaid-ascii FAILS on a leading-indented `flowchart`/`graph` line —
37
+ * dedenting first is what turns that common case from a fallback back into a
38
+ * real diagram. */
39
+ function dedent(body) {
40
+ const lines = body.split('\n');
41
+ let min = Infinity;
42
+ for (const line of lines) {
43
+ if (line.trim() === '')
44
+ continue;
45
+ min = Math.min(min, line.length - line.replace(/^[ \t]+/, '').length);
46
+ }
47
+ if (!Number.isFinite(min) || min === 0)
48
+ return body;
49
+ return lines.map((line) => (line.trim() === '' ? '' : line.slice(min))).join('\n');
50
+ }
51
+ /** Cheap predicate: does this text contain what looks like a mermaid fence at
52
+ * all? Lets callers skip the transform (and the message rebuild) entirely for
53
+ * the common no-diagram reply. */
54
+ export function textHasMermaid(text) {
55
+ return text.includes('```mermaid');
56
+ }
57
+ function stripBlankEdges(lines) {
58
+ let start = 0;
59
+ let end = lines.length;
60
+ while (start < end && lines[start].trim() === '')
61
+ start++;
62
+ while (end > start && lines[end - 1].trim() === '')
63
+ end--;
64
+ return lines.slice(start, end);
65
+ }
66
+ /** Replace each complete ```mermaid fence in `text` with the rendered diagram,
67
+ * wrapped in a plain ``` code fence so pi's Markdown preserves its alignment.
68
+ * Leaves the fence untouched when the renderer is unavailable (degrades to a
69
+ * normal code block, exactly as before this feature) or when a render yields
70
+ * nothing usable. `width` only affects termrender's trailing padding (mermaid
71
+ * diagrams have no width control and overflow rather than truncate), which is
72
+ * stripped here, so an approximate pane width is fine. */
73
+ export function transformMermaidFences(text, width) {
74
+ if (!textHasMermaid(text) || !isRendererReady())
75
+ return text;
76
+ return text.replace(MERMAID_FENCE, (whole, body) => {
77
+ try {
78
+ const doc = `\`\`\`mermaid\n${dedent(body)}\n\`\`\``;
79
+ const rendered = stripBlankEdges(renderMarkdown(doc, width).map((l) => l.replace(/\s+$/, '')));
80
+ // No diagram glyphs → mermaid-ascii could not render it and termrender
81
+ // echoed the source. Leave the original fence so pi renders it as a normal
82
+ // (mermaid-tagged) code block, never a stripped plain block of raw source.
83
+ if (rendered.length === 0 || !rendered.some((l) => DIAGRAM_GLYPH.test(l)))
84
+ return whole;
85
+ return ['```', ...rendered, '```'].join('\n');
86
+ }
87
+ catch {
88
+ return whole;
89
+ }
90
+ });
91
+ }
92
+ /** True when any text part of an assistant message carries a mermaid fence. */
93
+ export function messageHasMermaid(message) {
94
+ const content = message.content;
95
+ if (typeof content === 'string')
96
+ return textHasMermaid(content);
97
+ if (!Array.isArray(content))
98
+ return false;
99
+ return content.some((p) => p?.type === 'text' && textHasMermaid(p.text ?? ''));
100
+ }
101
+ /** Return a shallow clone of an assistant message with every text part's
102
+ * mermaid fences rendered inline. Non-text parts (thinking, toolCall) are
103
+ * passed through untouched, so thinking blocks and tool cards render exactly as
104
+ * before. A no-mermaid message is returned unchanged (same reference). */
105
+ export function transformMessageMermaid(message, width) {
106
+ const content = message.content;
107
+ if (typeof content === 'string') {
108
+ if (!textHasMermaid(content))
109
+ return message;
110
+ return { ...message, content: transformMermaidFences(content, width) };
111
+ }
112
+ if (!Array.isArray(content) || !messageHasMermaid(message))
113
+ return message;
114
+ return {
115
+ ...message,
116
+ content: content.map((part) => {
117
+ const p = part;
118
+ if (p?.type === 'text' && typeof p.text === 'string' && textHasMermaid(p.text)) {
119
+ return { ...p, text: transformMermaidFences(p.text, width) };
120
+ }
121
+ return part;
122
+ }),
123
+ };
124
+ }
@@ -9,7 +9,7 @@
9
9
  // which requests are safe to share/cache within a short TTL.
10
10
  //
11
11
  // ONLY idempotent, side-effect-free `crtr` reads belong here. Writes
12
- // (node new / canvas revive / node msg), file reads, http requests, and any
12
+ // (node new / node lifecycle revive / node msg), file reads, http requests, and any
13
13
  // request carrying stdin or an overridden cwd are NEVER cacheable — caching
14
14
  // those would drop a mutation or serve a stale cross-cwd read. Target-dependent
15
15
  // reads (node inspect snapshot/show for a specific id) ARE cacheable because the id
@@ -1,11 +1,11 @@
1
- // Regression for issue #115: `crtr canvas revive --now` rejected exactly the
1
+ // Regression for issue #115: `crtr node lifecycle revive --now` rejected exactly the
2
2
  // provider-fault nodes the canvas dashboard flags as ⚠ "needs you"
3
3
  // (status-glyph.ts `faultDispositionText`), because the old gate required
4
4
  // `retry.disposition === 'auto'` — the one disposition "needs you" NEVER is.
5
5
  // Run with: node --import tsx/esm --test 'src/commands/__tests__/revive-now-gate.test.ts'
6
6
  import { test } from 'node:test';
7
7
  import assert from 'node:assert/strict';
8
- import { isNowEligibleFault } from '../revive.js';
8
+ import { isNowEligibleFault } from '../node-lifecycle-revive.js';
9
9
  import { faultNeedsYou } from '../../core/canvas/status-glyph.js';
10
10
  function fault(over) {
11
11
  return {
@@ -4,7 +4,7 @@ import { resolveRef } from '../../core/canvas/index.js';
4
4
  export const readLeaf = defineLeaf({
5
5
  name: 'read',
6
6
  description: 'read one search hit\'s full body by its ref',
7
- whenToUse: 'you picked a hit from `canvas history search` (or `canvas history show`) and want its full content — pass the `<node-id>:<relpath>` ref verbatim. Strips frontmatter by default; add --frontmatter to keep the YAML header.',
7
+ whenToUse: 'you picked a hit from `canvas history search` (or `node inspect artifacts`) and want its full content — pass the `<node-id>:<relpath>` ref verbatim. Strips frontmatter by default; add --frontmatter to keep the YAML header.',
8
8
  help: {
9
9
  name: 'canvas history read',
10
10
  summary: 'resolve a <node-id>:<relpath> ref to its full artifact body',
@@ -28,7 +28,7 @@ export const readLeaf = defineLeaf({
28
28
  if (!ref.includes(':')) {
29
29
  throw usage(`ref must be <node-id>:<relpath>; received: ${ref}`, {
30
30
  received: ref,
31
- next: 'Get a valid ref from `canvas history search` or `canvas history show <node-id>`.',
31
+ next: 'Get a valid ref from `canvas history search` or `node inspect artifacts <node-id>`.',
32
32
  });
33
33
  }
34
34
  let resolved;
@@ -41,7 +41,7 @@ export const readLeaf = defineLeaf({
41
41
  if (resolved === null) {
42
42
  throw notFound(`no artifact for ref: ${ref}`, {
43
43
  ref,
44
- next: 'The node or file may not exist. Re-run `canvas history search` for a current ref, or `canvas history show <node-id>` to list a node\'s artifacts.',
44
+ next: 'The node or file may not exist. Re-run `canvas history search` for a current ref, or `node inspect artifacts <node-id>` to list a node\'s artifacts.',
45
45
  });
46
46
  }
47
47
  const source = resolved.source === 'report' && resolved.reportKind ? `report:${resolved.reportKind}` : resolved.source;
@@ -76,7 +76,7 @@ function sortArtifacts(arts, mode, scored) {
76
76
  export const searchLeaf = defineLeaf({
77
77
  name: 'search',
78
78
  description: 'ranked content search across the cwd\'s node history',
79
- whenToUse: 'you want to find past work by what it SAYS — a design, a final report, a roadmap, a finding — across every node that ran in this cwd, ranked by relevance. Omit the query to browse the record by recency ("what happened here lately"). Narrow with --type/--kind/--status/--since, scope elsewhere with --cwd/--all-cwds/--under/--node, or switch to --grep for an exact regex over bodies. Use `canvas history read <ref>` to read a hit in full, `canvas history show <node-id>` to list one node\'s artifacts, and `canvas revive <id>` to reopen a node you found.',
79
+ whenToUse: 'you want to find past work by what it SAYS — a design, a final report, a roadmap, a finding — across every node that ran in this cwd, ranked by relevance. Omit the query to browse the record by recency ("what happened here lately"). Narrow with --type/--kind/--status/--since, scope elsewhere with --cwd/--all-cwds/--under/--node, or switch to --grep for an exact regex over bodies. Use `canvas history read <ref>` to read a hit in full, `node inspect artifacts <node-id>` to list one node\'s artifacts, and `node lifecycle revive <id>` to reopen a node you found.',
80
80
  help: {
81
81
  name: 'canvas history search',
82
82
  summary: 'ranked/filtered/sorted content search over the per-cwd episodic record (reports + context docs + meta)',
@@ -1,14 +1,13 @@
1
- // `canvas history` — search and recall the canvas's record of past work in a
2
- // cwd. The accumulated episodic record of every node that ran here and the
3
- // artifacts it left: its reports (final/update/urgent outcome summaries) and
4
- // context docs (specs, designs, roadmaps, findings). Distinct from `crtr
1
+ // `canvas history` — search and recall the cwd-wide content corpus: ranked
2
+ // search over every node's reports/context docs, plus reading one hit's full
3
+ // body by ref. One node's own artifact listing lives at `node inspect
4
+ // artifacts` (a single-node read, not a graph-wide one). Distinct from `crtr
5
5
  // memory`, which is curated semantic knowledge held independent of any episode.
6
- // This branch owns CONTENT; it never duplicates the graph (use `canvas revive`
7
- // to reopen a found node, `node inspect show` for its topology).
6
+ // This branch owns CONTENT; it never duplicates the graph (use `node lifecycle
7
+ // revive` to reopen a found node, `node inspect show` for its topology).
8
8
  import { defineBranch } from '../core/command.js';
9
9
  import { searchLeaf } from './canvas-history/search.js';
10
10
  import { readLeaf } from './canvas-history/read.js';
11
- import { showLeaf } from './canvas-history/show.js';
12
11
  /** A tiny per-cwd `<corpus>` marker for branch -h — just enough context to
13
12
  * anchor the help text without scanning nodes or files. Uses only the injected
14
13
  * node cwd env (or the process cwd outside a node), so help stays cheap. */
@@ -18,13 +17,13 @@ function corpusBlock() {
18
17
  }
19
18
  export const historyBranch = defineBranch({
20
19
  name: 'history',
21
- description: 'search and recall the canvas\'s record of past work in this cwd',
22
- whenToUse: 'you want to find or re-read what was DONE in a cwd — a past design, a final report, a roadmap, a finding — across every node that ran there. Use it when picking up prior work ("that caching work from last week"), recovering an artifact, or surveying a project\'s history. Distinct from `crtr memory` (curated semantic knowledge, not episodes) and from `canvas dashboard`/`node inspect` (graph topology, not content).',
20
+ description: 'search and recall the cwd-wide content corpus — every node\'s reports and context docs',
21
+ whenToUse: 'you want to find or re-read what was DONE in a cwd — a past design, a final report, a roadmap, a finding — across every node that ran there, WITHOUT already knowing which node. Use it when picking up prior work ("that caching work from last week"), recovering an artifact, or surveying a project\'s history. Already have the node id and just want everything IT left behind? Use `node inspect artifacts <id>` instead — this branch is the cwd-wide search corpus, not a per-node listing. Distinct from `crtr memory` (curated semantic knowledge, not episodes) and from `canvas dashboard`/`node inspect` (graph topology, not content).',
23
22
  help: {
24
23
  name: 'canvas history',
25
- summary: 'search and recall the canvas\'s record of past work in this cwd',
26
- model: 'The accumulated record of every node that ran in a cwd and the artifacts it left — its reports (final/update outcome summaries) and context docs (specs, designs, roadmaps, findings). `search` finds by content (ranked, filtered, sorted; omit the query to browse by recency); `read` pulls one hit\'s full body by its <node-id>:<relpath> ref; `show` lists everything one node left. This is the episodic record of what was DONE — distinct from `crtr memory`, which is curated semantic knowledge. A node\'s raw inbox history (`--type inbox`) is a separate, opt-in diagnostic corpus — the full cursor-independent inbox.jsonl for one node, including un-clipped direct-message bodies; reach for it only when the curated reports/docs above don\'t already answer the question. To reopen a node you found, use `canvas revive`; for its place in the graph, `node inspect show`.',
24
+ summary: 'search and recall the cwd-wide content corpus — every node\'s reports and context docs in this cwd',
25
+ model: 'The accumulated record of every node that ran in a cwd and the artifacts it left — its reports (final/update outcome summaries) and context docs (specs, designs, roadmaps, findings). `search` finds by content (ranked, filtered, sorted; omit the query to browse by recency); `read` pulls one hit\'s full body by its <node-id>:<relpath> ref. This is the episodic record of what was DONE — distinct from `crtr memory`, which is curated semantic knowledge. Already have a node id and want everything IT left behind (not a cwd-wide search)? Use `node inspect artifacts <node-id>` instead — that single-node listing moved out of this branch. A node\'s raw inbox history (`--type inbox`) is a separate, opt-in diagnostic corpus — the full cursor-independent inbox.jsonl for one node, including un-clipped direct-message bodies; reach for it only when the curated reports/docs above don\'t already answer the question. To reopen a node you found, use `node lifecycle revive`; for its place in the graph, `node inspect show`.',
27
26
  dynamicState: corpusBlock,
28
27
  },
29
- children: [searchLeaf, readLeaf, showLeaf],
28
+ children: [searchLeaf, readLeaf],
30
29
  });
@@ -5,7 +5,7 @@
5
5
  // deployment's recreate-from-template, where node data lives on a durable Volume
6
6
  // but canvas.db lives on local disk that the recreate wipes — this verb rebuilds
7
7
  // the rows (identity columns) from the surviving metas via `rebuildIndex()`, so a
8
- // subsequent `canvas revive` finds its node again.
8
+ // subsequent `node lifecycle revive` finds its node again.
9
9
  //
10
10
  // Fails loud if metas are present but the rebuild yields zero rows: a silent
11
11
  // empty db there means the revive would no-op against nothing.
@@ -29,7 +29,7 @@ function countOnDiskMetas() {
29
29
  export const canvasRebuildIndexLeaf = defineLeaf({
30
30
  name: 'rebuild-index',
31
31
  description: 're-derive node rows in canvas.db from on-disk nodes/<id>/meta.json',
32
- whenToUse: 'canvas.db is missing or stale relative to the on-disk node metas and you need its node rows re-derived — most concretely after a remote/guest deployment\'s recreate-from-template, where node data survives on a durable Volume but canvas.db (local disk) was wiped, so a `canvas revive` would otherwise find no row. Identity columns are rebuilt from each meta; runtime columns (status/intent/pi_pid/window/tmux) take their quiescent defaults, and subscribes_to edges are left intact',
32
+ whenToUse: 'canvas.db is missing or stale relative to the on-disk node metas and you need its node rows re-derived — most concretely after a remote/guest deployment\'s recreate-from-template, where node data survives on a durable Volume but canvas.db (local disk) was wiped, so a `node lifecycle revive` would otherwise find no row. Identity columns are rebuilt from each meta; runtime columns (status/intent/pi_pid/window/tmux) take their quiescent defaults, and subscribes_to edges are left intact',
33
33
  help: {
34
34
  name: 'canvas rebuild-index',
35
35
  summary: 're-derive canvas.db node rows from on-disk metas; fails loud if metas exist but yield no rows',
@@ -3,7 +3,7 @@
3
3
  // Where `node` operates on one node and `push` is a node's own spine write
4
4
  // verb, `canvas` is the bird's-eye / supervisor surface over the entire canvas:
5
5
  // render the subscription forest (`dashboard`), see who is blocked on a human
6
- // (`attention`), and bring a node back (`revive`). It assembles leaves/branches
6
+ // (`attention`), and mass-reconnect disconnected nodes (`revive --all`). It assembles leaves/branches
7
7
  // the sibling command files own, so each piece declares its own help one level
8
8
  // down.
9
9
  import { defineBranch } from '../core/command.js';
@@ -33,14 +33,14 @@ export function registerCanvas() {
33
33
  return defineBranch({
34
34
  name: 'canvas',
35
35
  rootEntry: {
36
- concept: 'the whole agent graph at a glance — render it, see who is blocked, revive a node',
36
+ concept: 'the whole agent graph at a glance — render it, see who is blocked, mass-revive disconnected nodes',
37
37
  desc: 'bird\'s-eye view and supervision of the entire canvas',
38
- useWhen: 'surveying the full graph, finding blocked agents, or reviving a node',
38
+ useWhen: 'surveying the full graph, finding blocked agents, or mass-reviving every disconnected node at once',
39
39
  },
40
40
  help: {
41
41
  name: 'canvas',
42
42
  summary: 'observe and supervise the whole agent graph',
43
- model: 'Canvas-wide operations, distinct from per-node work (`node`) and a node\'s own spine write verb (`push`). `dashboard` renders the subscription forest as a tree; `browse` opens an interactive full-screen navigator (tabs/tree/search) over the whole canvas and resumes the chosen node; `attention` aggregates pending human asks across the graph; `revive` reopens a window for a done/idle/dead/canceled node; `history` searches and recalls the content record (reports + context docs) of past work in a cwd; `prune` bounds growth by deleting terminal nodes past a TTL.',
43
+ model: 'Canvas-wide operations, distinct from per-node work (`node`) and a node\'s own spine write verb (`push`). `dashboard` renders the subscription forest as a tree; `browse` opens an interactive full-screen navigator (tabs/tree/search) over the whole canvas and resumes the chosen node; `attention` aggregates pending human asks across the graph; `revive --all` mass-reconnects every disconnected node (bringing back ONE named node is `node lifecycle revive <id>` instead); `history` searches and recalls the content record (reports + context docs) of past work in a cwd; `prune` bounds growth by deleting terminal nodes past a TTL.',
44
44
  },
45
45
  children: [dashboardLeaf, canvasSnapshotLeaf, browseLeaf, attentionBranch, reviveLeaf, historyBranch, chordLeaf, issueBranch, canvasPruneLeaf, canvasRebuildIndexLeaf],
46
46
  });
@@ -0,0 +1 @@
1
+ export declare const nodeArtifactsLeaf: import("../core/command.js").LeafDef;
@@ -1,14 +1,14 @@
1
- import { defineLeaf } from '../../core/command.js';
2
- import { notFound } from '../../core/errors.js';
3
- import { getNode, nodeArtifacts } from '../../core/canvas/index.js';
1
+ import { defineLeaf } from '../core/command.js';
2
+ import { notFound } from '../core/errors.js';
3
+ import { getNode, nodeArtifacts } from '../core/canvas/index.js';
4
4
  import { statSync } from 'node:fs';
5
5
  const TYPES = ['report', 'doc', 'roadmap', 'inbox'];
6
- export const showLeaf = defineLeaf({
7
- name: 'show',
6
+ export const nodeArtifactsLeaf = defineLeaf({
7
+ name: 'artifacts',
8
8
  description: 'enumerate one node\'s artifacts as refs',
9
9
  whenToUse: 'you have a node id and want to see everything it left behind — its reports and context docs as refs ready for `canvas history read`. The bridge from "found the node" to "read its artifacts". Use `node inspect show` instead for the node\'s topology/neighbors, not its content.',
10
10
  help: {
11
- name: 'canvas history show',
11
+ name: 'node inspect artifacts',
12
12
  summary: 'list one node\'s artifacts (reports + context docs) as refs ready for `canvas history read`',
13
13
  params: [
14
14
  { kind: 'positional', name: 'node-id', required: true, constraint: 'The node whose artifacts to list.' },
@@ -55,7 +55,7 @@ export const showLeaf = defineLeaf({
55
55
  return {
56
56
  node: `${node?.name} (${nodeId})`,
57
57
  artifacts,
58
- follow_up: 'Read one with `canvas history read <ref>`; reopen the node with `canvas revive ' + nodeId + '`.',
58
+ follow_up: 'Read one with `canvas history read <ref>`; reopen the node with `node lifecycle revive ' + nodeId + '`.',
59
59
  };
60
60
  },
61
61
  render: (r) => {
@@ -0,0 +1,12 @@
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
+ export declare const nodeReviveLeaf: LeafDef;
@@ -0,0 +1,166 @@
1
+ // `crtr node lifecycle revive` — bring back ONE named node.
2
+ //
3
+ // Bypasses the daemon: directly relaunches the broker engine for a node that is
4
+ // done, idle, dead, or canceled. Default behavior resumes the saved pi
5
+ // conversation by its absolute session-file path (--session <path>) when that
6
+ // .jsonl still exists, else falls through to a fresh launch. `--fresh` skips
7
+ // the true resume: if the `.jsonl` already exists it CYCLES in place instead
8
+ // (same file, a fresh `crtr-cycle` branch — `resumed:false`), and only starts a
9
+ // genuinely clean session when no `.jsonl` exists yet (also `resumed:false`).
10
+ //
11
+ // `--now` is the on-demand kick for a hanging node (live broker parked on an
12
+ // eligible pi→provider fault): SIGTERM the broker so the daemon's own
13
+ // crash→grace→resume path brings it back, instead of waiting out the schedule.
14
+ //
15
+ // The mass-recovery form — reconnect EVERY disconnected node at once after a
16
+ // reboot/crash — is genuinely graph-wide and stays at `canvas revive --all`
17
+ // (src/commands/revive.ts).
18
+ import { defineLeaf } from '../core/command.js';
19
+ import { InputError } from '../core/io.js';
20
+ import { reviveNode } from '../core/runtime/revive.js';
21
+ import { waitForBrokerViewSocket } from '../core/runtime/placement.js';
22
+ import { getNode, fullName } from '../core/canvas/index.js';
23
+ import { isPidAlive } from '../core/canvas/pid.js';
24
+ import { readFault } from '../core/runtime/fault.js';
25
+ import { isBusy } from '../core/runtime/busy.js';
26
+ import { faultNeedsYou } from '../core/canvas/status-glyph.js';
27
+ /** --now eligibility for a live, non-busy fault: a daemon-owned AUTO retry
28
+ * (kick it early instead of waiting out the schedule), or any fault the
29
+ * canvas dashboard flags "needs you" (`faultNeedsYou`, status-glyph.ts) — the
30
+ * disposition that otherwise has NO sanctioned recovery verb (issue #115).
31
+ * Both are scoped to `pi→provider`: refuses a client-owned auto retry
32
+ * (redialing) already in flight, and refuses any OTHER link — notably a
33
+ * `daemon→node` wedge fault, which --now can't clear (SIGTERM doesn't touch
34
+ * the runaway subprocess; see `wedged-child-on-runaway-bash`). */
35
+ export function isNowEligibleFault(fault) {
36
+ if (fault === null || fault.link !== 'pi→provider')
37
+ return false;
38
+ if (faultNeedsYou(fault))
39
+ return true;
40
+ return fault.retry.by === 'daemon';
41
+ }
42
+ export const nodeReviveLeaf = defineLeaf({
43
+ name: 'revive',
44
+ description: 'reopen a window for a done/idle/dead/canceled node',
45
+ 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. 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 kick a hanging one early (--now). To mass-reconnect EVERY disconnected node at once (after a reboot, a killed login/tmux session, or the daemon being down a while), use `canvas revive --all` instead',
46
+ help: {
47
+ name: 'node lifecycle revive',
48
+ summary: 'relaunch a node\'s broker engine (resuming its saved conversation), or kick a hanging one (--now)',
49
+ params: [
50
+ {
51
+ kind: 'positional',
52
+ name: 'node',
53
+ required: true,
54
+ constraint: 'Node id to revive.',
55
+ },
56
+ {
57
+ kind: 'flag',
58
+ name: 'fresh',
59
+ type: 'bool',
60
+ required: false,
61
+ default: false,
62
+ 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`).',
63
+ },
64
+ {
65
+ kind: 'flag',
66
+ name: 'now',
67
+ type: 'bool',
68
+ required: false,
69
+ default: false,
70
+ 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 --fresh.',
71
+ },
72
+ ],
73
+ output: [
74
+ { name: 'window', type: 'string', required: false, constraint: 'Always null — the revived broker is headless and opens no tmux window. Kept for caller back-compat.' },
75
+ { 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.' },
76
+ { 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). Absent for a --now kick.' },
77
+ { 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. Absent for a --now kick.' },
78
+ { name: 'kicked', type: 'boolean', required: false, constraint: 'True when --now SIGTERM\'d the hanging node\'s live broker (the daemon resumes it on the saved session within ~20s). Absent for an ordinary revive.' },
79
+ ],
80
+ outputKind: 'object',
81
+ effects: [
82
+ 'Launches the node\'s detached headless broker engine (no tmux window).',
83
+ 'Updates the node\'s canvas record: status=active, intent=null.',
84
+ 'Blocks until the broker\'s view.sock accepts a connection (up to ~30s), so a caller can attach/dial immediately on return.',
85
+ '--now: SIGTERMs the live broker instead of launching anything (the daemon\'s own crash-grace path resumes it).',
86
+ ],
87
+ },
88
+ run: async (input) => {
89
+ const now = input['now'] ?? false;
90
+ const nodeId = input['node'];
91
+ const fresh = input['fresh'] ?? false;
92
+ if (now && fresh) {
93
+ throw new InputError({
94
+ error: 'conflicting_args',
95
+ message: '`--now` kicks a live hanging broker (SIGTERM → daemon resumes the SAVED session); `--fresh` is meaningless there.',
96
+ next: 'Run `crtr node lifecycle revive <node> --now` (resumes), or `crtr node lifecycle revive <node> --fresh` (no --now) to relaunch clean.',
97
+ });
98
+ }
99
+ // Validate the node exists before attempting revival.
100
+ const meta = getNode(nodeId);
101
+ if (meta === null) {
102
+ throw new InputError({
103
+ error: 'not_found',
104
+ message: `no node: ${nodeId}`,
105
+ next: 'List nodes with `crtr node inspect list`.',
106
+ });
107
+ }
108
+ // --now: the on-demand kick for an active fault. The broker is ALIVE (so
109
+ // reviveNode would no-op the double-launch guard) — SIGTERM it so the daemon's
110
+ // ordinary crash→grace→resume path recovers it on the saved session, the same
111
+ // thing the daemon does at the auto-recovery grace, just on demand. Gated on
112
+ // a live pid and an eligible pi→provider fault (`isNowEligibleFault` above) so
113
+ // it can't be used to nuke a healthy node.
114
+ if (now) {
115
+ const pid = meta.pi_pid;
116
+ const stall = readFault(nodeId);
117
+ if (pid == null || !isPidAlive(pid)) {
118
+ throw new InputError({
119
+ error: 'not_hanging',
120
+ message: `${nodeId} has no live broker — nothing to kick. --now is for a HANGING node (live broker parked on an active fault).`,
121
+ next: 'Revive a dormant node with `crtr node lifecycle revive ' + nodeId + '` (no --now).',
122
+ });
123
+ }
124
+ if (!isNowEligibleFault(stall)) {
125
+ throw new InputError({
126
+ error: 'not_hanging',
127
+ 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.`,
128
+ next: 'Use --now only on a node the canvas shows as hanging (⚠) with a `pi→provider` fault. For a routine relaunch, omit --now.',
129
+ });
130
+ }
131
+ // The daemon never kills a BUSY engine (its verdict paths all return 'leave'
132
+ // on a live busy marker): a node mid-turn is making progress, not wedged —
133
+ // refuse rather than SIGTERM it mid-flight.
134
+ if (isBusy(nodeId)) {
135
+ throw new InputError({
136
+ error: 'busy',
137
+ message: `${nodeId} is mid-turn right now (busy) — --now refuses to SIGTERM a working engine; it has recovered on its own.`,
138
+ next: 'Re-check the canvas — if it\'s no longer marked hanging (⚠), nothing to do.',
139
+ });
140
+ }
141
+ try {
142
+ process.kill(pid, 'SIGTERM');
143
+ }
144
+ catch {
145
+ /* best-effort: the broker may have just died; the daemon owns the resume */
146
+ }
147
+ return {
148
+ window: null,
149
+ session: meta.tmux_session ?? null,
150
+ kicked: true,
151
+ message: `Sent SIGTERM to ${fullName(meta)} (${nodeId}, pid ${pid}). The daemon will resume it on the saved session within ~20s (crash→grace→resume).`,
152
+ };
153
+ }
154
+ const result = reviveNode(nodeId, { resume: !fresh });
155
+ // Revive returns once the broker process is launched, which can be seconds
156
+ // before its view.sock listens. Callers (attach, the web shell's Wake) dial
157
+ // immediately on return, so block here until the socket accepts.
158
+ const ready = await waitForBrokerViewSocket(nodeId, result.launch?.exited);
159
+ return {
160
+ window: result.window ?? undefined,
161
+ session: result.session,
162
+ resumed: result.resumed,
163
+ ready,
164
+ };
165
+ },
166
+ });
@@ -0,0 +1,2 @@
1
+ import { type BranchDef } from '../core/command.js';
2
+ export declare const nodeWorktreeBranch: BranchDef;