@crouton-kit/crouter 0.3.35 → 0.3.37

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 (71) hide show
  1. package/dist/build-root.js +2 -6
  2. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  3. package/dist/builtin-personas/runtime-base.md +1 -1
  4. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +4 -4
  5. package/dist/builtin-views/canvas/core.mjs +3 -2
  6. package/dist/clients/attach/__tests__/crtr-output-render.test.js +21 -205
  7. package/dist/clients/attach/attach-cmd.js +383 -386
  8. package/dist/clients/web/web-cmd.js +7 -7
  9. package/dist/commands/__tests__/human.test.js +1 -15
  10. package/dist/commands/attention.js +9 -6
  11. package/dist/commands/canvas-history/search.js +2 -2
  12. package/dist/commands/canvas-history/show.js +3 -3
  13. package/dist/commands/canvas-history.js +1 -1
  14. package/dist/commands/canvas-issue.js +1 -0
  15. package/dist/commands/canvas-snapshot.js +1 -0
  16. package/dist/commands/canvas.js +10 -11
  17. package/dist/commands/chord.js +1 -0
  18. package/dist/commands/human/prompts.js +0 -1
  19. package/dist/commands/human/queue.js +1 -0
  20. package/dist/commands/node-snapshot.js +1 -0
  21. package/dist/commands/node.js +12 -11
  22. package/dist/commands/push.d.ts +0 -1
  23. package/dist/commands/push.js +2 -228
  24. package/dist/commands/revive.js +2 -2
  25. package/dist/commands/{canvas-tmux-spread.js → surface-tmux-spread.js} +9 -6
  26. package/dist/commands/surface.js +3 -2
  27. package/dist/commands/sys/daemon.d.ts +2 -0
  28. package/dist/commands/{daemon.js → sys/daemon.js} +12 -12
  29. package/dist/commands/sys/doctor.js +1 -0
  30. package/dist/commands/sys.js +4 -3
  31. package/dist/core/__tests__/daemon-boot.test.js +1 -1
  32. package/dist/core/__tests__/daemon-wedge.test.d.ts +1 -0
  33. package/dist/core/__tests__/daemon-wedge.test.js +87 -0
  34. package/dist/core/__tests__/hearth-bootstrap.test.js +2 -2
  35. package/dist/core/__tests__/history-inbox.test.d.ts +1 -0
  36. package/dist/core/__tests__/history-inbox.test.js +105 -0
  37. package/dist/core/__tests__/kickoff.test.js +3 -3
  38. package/dist/core/__tests__/revive.test.js +39 -1
  39. package/dist/core/canvas/history.d.ts +1 -1
  40. package/dist/core/canvas/history.js +90 -3
  41. package/dist/core/canvas/paths.d.ts +6 -0
  42. package/dist/core/canvas/paths.js +9 -0
  43. package/dist/core/canvas/status-glyph.js +1 -0
  44. package/dist/core/fault-classifier.d.ts +2 -2
  45. package/dist/core/feed/inbox.d.ts +7 -12
  46. package/dist/core/feed/inbox.js +45 -29
  47. package/dist/core/hearth/providers/blaxel-bootstrap.js +1 -1
  48. package/dist/core/preview-registry.js +14 -112
  49. package/dist/core/render.js +1 -1
  50. package/dist/core/runtime/busy.d.ts +15 -0
  51. package/dist/core/runtime/busy.js +32 -1
  52. package/dist/core/runtime/close.d.ts +10 -0
  53. package/dist/core/runtime/close.js +18 -13
  54. package/dist/core/runtime/fault-recovery.js +4 -0
  55. package/dist/core/runtime/kickoff.js +5 -7
  56. package/dist/core/runtime/placement.js +1 -1
  57. package/dist/core/runtime/revive.js +9 -1
  58. package/dist/core/runtime/tmux.d.ts +2 -2
  59. package/dist/core/runtime/tmux.js +2 -2
  60. package/dist/daemon/crtrd-cli.js +1 -1
  61. package/dist/daemon/crtrd.d.ts +24 -0
  62. package/dist/daemon/crtrd.js +213 -5
  63. package/dist/daemon/manage.js +2 -2
  64. package/dist/pi-extensions/canvas-nav.js +2 -2
  65. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  66. package/dist/pi-extensions/canvas-stophook.js +59 -2
  67. package/dist/web-client/assets/{index-IAJVtuVe.js → index-MSKSWwV0.js} +1 -1
  68. package/dist/web-client/index.html +1 -1
  69. package/package.json +1 -1
  70. package/dist/commands/daemon.d.ts +0 -2
  71. /package/dist/commands/{canvas-tmux-spread.d.ts → surface-tmux-spread.d.ts} +0 -0
@@ -57,7 +57,6 @@ export const HELP_ICON = String.fromCodePoint(HELP_ICON_CP);
57
57
  * private-use-area icons chosen per family concept (see command-icons.md). */
58
58
  const FAMILY_ICON_CP = {
59
59
  canvas: 0xf0e8,
60
- feed: 0xf09e,
61
60
  human: 0xf007,
62
61
  memory: 0xf02d,
63
62
  node: 0xf192,
@@ -80,17 +79,14 @@ const LEAF_ICON_CP = {
80
79
  'canvas history search': 0xf002,
81
80
  'canvas history read': 0xf02d,
82
81
  'canvas history show': 0xf1da,
83
- 'canvas daemon start': 0xf04b,
84
- 'canvas daemon status': 0xf111,
85
- 'canvas daemon stop': 0xf04d,
82
+ 'sys daemon start': 0xf04b,
83
+ 'sys daemon status': 0xf111,
84
+ 'sys daemon stop': 0xf04d,
86
85
  'canvas chord': 0xf001,
87
- 'canvas tmux-spread': 0xf009,
86
+ 'surface tmux-spread': 0xf009,
88
87
  'canvas issue new': 0xf188,
89
88
  'canvas prune': 0xf0c4,
90
89
  'canvas rebuild-index': 0xf0ad,
91
- 'feed read': 0xf02d,
92
- 'feed message': 0xf0e0,
93
- 'feed peek': 0xf06e,
94
90
  'human ask': 0xf059,
95
91
  'human review': 0xf044,
96
92
  'human notify': 0xf0f3,
@@ -262,7 +258,7 @@ function summarizeMemoryLint(text) {
262
258
  return null;
263
259
  }
264
260
  // ---------------------------------------------------------------------------
265
- // Shared table/count parsing helpers for the canvas/node/feed runtime cockpit
261
+ // Shared table/count parsing helpers for the canvas/node runtime cockpit
266
262
  // batch below. These parse the exact markdown shapes core/render.ts's generic
267
263
  // renderArray/renderResult produce (a `- field: N` bullet for a scalar count,
268
264
  // an `N <label>:` count lead-in followed by a GFM table for an array of
@@ -738,7 +734,7 @@ function summarizeCanvasHistoryShow(text) {
738
734
  const digest = summarizeTopCounts(typeCounts, 5, (name, count) => `${name}s×${count}`);
739
735
  return [{ text: `${countMatch[1]} artifacts` }, { text: digest, tone: 'muted' }];
740
736
  }
741
- /** `crtr canvas daemon start` (src/commands/daemon.ts:40, `spawnDaemon()` in
737
+ /** `crtr sys daemon start` (src/commands/sys/daemon.ts:40, `spawnDaemon()` in
742
738
  * src/daemon/manage.ts:89) has no custom `render`; the generic renderer
743
739
  * bullets `{started:true,pid}` or `{started:false,existing_pid}`. `pid` is
744
740
  * unconditionally set alongside `started:true` (manage.ts:125) — a
@@ -747,7 +743,7 @@ function summarizeCanvasHistoryShow(text) {
747
743
  * false branch (a `readPidfile() ?? undefined` race), so that one stays
748
744
  * optional. Returns null when `started` isn't a plain boolean bullet, or
749
745
  * is `true` without a `pid` bullet. */
750
- function summarizeCanvasDaemonStart(text) {
746
+ function summarizeSysDaemonStart(text) {
751
747
  const m = /^- started:\s*(true|false)\s*$/m.exec(text);
752
748
  if (m === null)
753
749
  return null;
@@ -760,14 +756,14 @@ function summarizeCanvasDaemonStart(text) {
760
756
  const pid = /^- existing_pid:\s*(\d+)\s*$/m.exec(text)?.[1];
761
757
  return [{ text: `already running${pid !== undefined ? ` · pid ${pid}` : ''}`, tone: 'muted' }];
762
758
  }
763
- /** `crtr canvas daemon status` (src/commands/daemon.ts:65) has no custom
759
+ /** `crtr sys daemon status` (src/commands/sys/daemon.ts:65) has no custom
764
760
  * `render`; the generic renderer bullets `{running:true,pid}` or
765
761
  * `{running:false}`. `pid` is unconditionally set alongside `running:true`
766
762
  * (daemon.ts:70 — the true branch only fires once `readPidfile()` already
767
763
  * returned a pid), so a true-with-no-pid shape is unrecognized. Returns
768
764
  * null when `running` isn't a plain boolean bullet, or is `true` without a
769
765
  * `pid` bullet. */
770
- function summarizeCanvasDaemonStatus(text) {
766
+ function summarizeSysDaemonStatus(text) {
771
767
  const m = /^- running:\s*(true|false)\s*$/m.exec(text);
772
768
  if (m === null)
773
769
  return null;
@@ -779,13 +775,13 @@ function summarizeCanvasDaemonStatus(text) {
779
775
  }
780
776
  return [{ text: 'not running', tone: 'error' }];
781
777
  }
782
- /** `crtr canvas daemon stop` (src/commands/daemon.ts:93) has no custom
778
+ /** `crtr sys daemon stop` (src/commands/sys/daemon.ts:93) has no custom
783
779
  * `render`; the generic renderer bullets `{stopped:true,pid}` or
784
780
  * `{stopped:false}`. `pid` is unconditionally set alongside `stopped:true`
785
781
  * (daemon.ts:98 — the signaled pid is echoed back in the same return), so a
786
782
  * true-with-no-pid shape is unrecognized. Returns null when `stopped` isn't
787
783
  * a plain boolean bullet, or is `true` without a `pid` bullet. */
788
- function summarizeCanvasDaemonStop(text) {
784
+ function summarizeSysDaemonStop(text) {
789
785
  const m = /^- stopped:\s*(true|false)\s*$/m.exec(text);
790
786
  if (m === null)
791
787
  return null;
@@ -982,97 +978,6 @@ function summarizeNodeTriggersList(text) {
982
978
  out.push({ text: `… ${remaining} more`, tone: 'muted' });
983
979
  return out;
984
980
  }
985
- /** `crtr feed read` (src/commands/push.ts:209) has two shapes: drained
986
- * (`${n} unread pointer(s) drained from ${node_id}'s inbox.\n\n${digest}`,
987
- * where the digest — `coalesce()`, src/core/feed/inbox.ts:225 — groups by
988
- * sender as `From ${sender} — ${N} update(s):` sections) or empty (`No
989
- * unread pointers for ${node_id} (unread=0).\n\n${explanation}`).
990
- * `coalesce()` always groups drained entries under at least one
991
- * From-section, so a positive drained count with zero parsed senders is a
992
- * shape mismatch, not a 0-sender success. Returns null on any other
993
- * shape. */
994
- function summarizeFeedRead(text) {
995
- const lines = text.split('\n');
996
- const first = lines[0] ?? '';
997
- const drained = /^(\d+) unread pointers? drained from (.+)'s inbox\.$/.exec(first);
998
- if (drained !== null) {
999
- const n = Number(drained[1]);
1000
- const senders = [...new Set([...text.matchAll(/^From (.+?) — \d+ update/gm)].map((m) => m[1]))];
1001
- if (n > 0 && senders.length === 0)
1002
- return null;
1003
- const out = [{ text: `${n} pointer${n === 1 ? '' : 's'} drained from ${senders.length} node${senders.length === 1 ? '' : 's'}` }];
1004
- if (senders.length > 0) {
1005
- const shown = senders.slice(0, 5);
1006
- const remaining = senders.length - shown.length;
1007
- out.push({ text: remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', '), tone: 'muted' });
1008
- }
1009
- return out;
1010
- }
1011
- if (/^No unread pointers for .+ \(unread=0\)\.$/.test(first)) {
1012
- return [{ text: 'nothing unread', tone: 'muted' }];
1013
- }
1014
- return null;
1015
- }
1016
- /** Teaser cap for `feed message`'s body preview — mirrors
1017
- * MEMORY_READ_CONTENT_LINES's role for a different leaf. */
1018
- const FEED_MESSAGE_TEASER_LINES = 3;
1019
- /** `crtr feed message` (src/commands/push.ts:264) has two shapes: found
1020
- * (`Full message ${id} (from ${from}):\n\n${body}`) or not-found (a short
1021
- * line — left as null, the generic fallback is fine for one line). */
1022
- function summarizeFeedMessage(text) {
1023
- const lines = text.split('\n');
1024
- const head = /^Full message (\S+) \(from (.+)\):$/.exec(lines[0] ?? '');
1025
- if (head === null)
1026
- return null;
1027
- const [, id, from] = head;
1028
- const bodyLines = lines.slice(1);
1029
- let idx = 0;
1030
- while (idx < bodyLines.length && bodyLines[idx].trim() === '')
1031
- idx++;
1032
- const content = bodyLines.slice(idx);
1033
- const teaser = content.slice(0, FEED_MESSAGE_TEASER_LINES);
1034
- const hasMore = content.slice(FEED_MESSAGE_TEASER_LINES).some((l) => l.trim() !== '');
1035
- if (hasMore)
1036
- teaser.push('…');
1037
- const out = [{ text: `${id} · from ${from}` }];
1038
- if (teaser.some((l) => l.trim() !== '')) {
1039
- out.push({ text: '' });
1040
- for (const l of teaser)
1041
- out.push({ text: l });
1042
- }
1043
- return out;
1044
- }
1045
- /** `crtr feed peek` (src/commands/push.ts:351) has two shapes: zero-kids
1046
- * (`No nodes below you (0 subscriptions, ${unread} unread). ${tail}` — the
1047
- * "0" is a hardcoded literal, not a variable) or nonzero (`${verdictLine}
1048
- * \n\n${N} node(s) below you — ${working} working, ${done} done, ${dead}
1049
- * dead, ${unread} unread:\n${rows}`). Returns null on any other shape. */
1050
- function summarizeFeedPeek(text) {
1051
- const zero = /^No nodes below you \(0 subscriptions, (\d+) unread\)\./.exec(text.split('\n')[0] ?? '');
1052
- if (zero !== null) {
1053
- const unread = Number(zero[1]);
1054
- return unread > 0
1055
- ? [{ text: `0 below · ${unread} unread`, tone: 'warning' }]
1056
- : [{ text: 'PASS · nothing below', tone: 'success' }];
1057
- }
1058
- const m = /(\d+) nodes? below you — (\d+) working, (\d+) done, (\d+) dead, (\d+) unread:/.exec(text);
1059
- if (m === null)
1060
- return null;
1061
- const [, , workingStr, doneStr, deadStr, unreadStr] = m;
1062
- const working = Number(workingStr);
1063
- const done = Number(doneStr);
1064
- const dead = Number(deadStr);
1065
- const unread = Number(unreadStr);
1066
- const parts = [`${working} working`, `${done} done`];
1067
- if (dead > 0)
1068
- parts.push(`${dead} dead`);
1069
- const lines = [{ text: parts.join(' · ') }];
1070
- if (dead > 0)
1071
- lines.push({ text: `${dead} dead — will not wake you`, tone: 'warning' });
1072
- else if (unread > 0)
1073
- lines.push({ text: `${unread} unread`, tone: 'warning' });
1074
- return lines;
1075
- }
1076
981
  /** `crtr push final` (makeTierLeaf's shared render, src/commands/push.ts:161)
1077
982
  * leads with a fixed sentence ('Result recorded — node finished; ...')
1078
983
  * followed unconditionally by `\n\n- report: ${report_path}` (`report_path`
@@ -1996,18 +1901,15 @@ const SUMMARIZERS = [
1996
1901
  { path: 'canvas history search', run: summarizeCanvasHistorySearch },
1997
1902
  { path: 'canvas history read', run: summarizeCanvasHistoryRead },
1998
1903
  { path: 'canvas history show', run: summarizeCanvasHistoryShow },
1999
- { path: 'canvas daemon start', run: summarizeCanvasDaemonStart },
2000
- { path: 'canvas daemon status', run: summarizeCanvasDaemonStatus },
2001
- { path: 'canvas daemon stop', run: summarizeCanvasDaemonStop },
1904
+ { path: 'sys daemon start', run: summarizeSysDaemonStart },
1905
+ { path: 'sys daemon status', run: summarizeSysDaemonStatus },
1906
+ { path: 'sys daemon stop', run: summarizeSysDaemonStop },
2002
1907
  { path: 'canvas issue new', run: summarizeCanvasIssueNew },
2003
1908
  { path: 'node new', run: summarizeNodeNew },
2004
1909
  { path: 'node msg', run: summarizeNodeMsg },
2005
1910
  { path: 'node yield', run: summarizeNodeYield },
2006
1911
  { path: 'node lifecycle close', run: summarizeNodeLifecycleClose },
2007
1912
  { path: 'node triggers list', run: summarizeNodeTriggersList },
2008
- { path: 'feed read', run: summarizeFeedRead },
2009
- { path: 'feed message', run: summarizeFeedMessage },
2010
- { path: 'feed peek', run: summarizeFeedPeek },
2011
1913
  { path: 'push urgent', run: summarizePushUrgent },
2012
1914
  { path: 'push final', run: summarizePushFinal },
2013
1915
  { path: 'human ask', run: summarizeHumanAsk },
@@ -6,7 +6,7 @@
6
6
  // The raw JSON object is still available behind the `--json` global for tooling.
7
7
  //
8
8
  // A leaf may hand `defineLeaf` a bespoke `render(result)` for instruction-shaped
9
- // output that leads with the outcome (see `node new`, `push`, `feed read`).
9
+ // output that leads with the outcome (see `node new`, `push`).
10
10
  // Everything else falls through to the schema-driven generic renderer here, so
11
11
  // every command obeys the markdown paradigm without a hand-written renderer.
12
12
  // `renderError` is the one sanctioned exception: a failure is a different domain
@@ -6,3 +6,18 @@ export declare function clearBusy(nodeId: string): void;
6
6
  * site — a stale marker from a crashed pi is harmless because the dead pid
7
7
  * fails the AND. */
8
8
  export declare function isBusy(nodeId: string): boolean;
9
+ /** Re-touch the mid-turn marker's mtime — the daemon's wedge-detection
10
+ * heartbeat (crtrd.ts `handleWedgeDetection`). Called from canvas-stophook on
11
+ * every engine-level sign of life WITHIN a turn (a tool starting/streaming/
12
+ * ending, a streamed assistant token, a turn ending) so the marker's mtime
13
+ * always reflects the last moment the engine visibly did something, not just
14
+ * when the turn began. No-ops when the node isn't currently busy — a touch
15
+ * arriving after `clearBusy` (e.g. a late/queued event racing agent_end) must
16
+ * never resurrect a stale marker. Best-effort. */
17
+ export declare function touchBusy(nodeId: string): void;
18
+ /** The mid-turn marker's mtime: the last moment ANY engine-level progress was
19
+ * observed while the node was busy (see `touchBusy`). Null when the node isn't
20
+ * busy or the marker is unreadable. This is the sole heartbeat the daemon's
21
+ * wedge detector reads — it needs no new instrumentation beyond re-touching an
22
+ * already-existing file. */
23
+ export declare function busySince(nodeId: string): number | null;
@@ -13,7 +13,7 @@
13
13
  // read site, so a stale marker (process crashed mid-turn without firing
14
14
  // agent_end) is harmless — the dead pid fails the AND and the node is reaped.
15
15
  // No db migration, atomic touch/unlink, best-effort (never throws).
16
- import { existsSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
16
+ import { existsSync, writeFileSync, rmSync, mkdirSync, statSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
18
  import { jobDir } from '../canvas/index.js';
19
19
  function busyPath(nodeId) {
@@ -44,3 +44,34 @@ export function clearBusy(nodeId) {
44
44
  export function isBusy(nodeId) {
45
45
  return existsSync(busyPath(nodeId));
46
46
  }
47
+ /** Re-touch the mid-turn marker's mtime — the daemon's wedge-detection
48
+ * heartbeat (crtrd.ts `handleWedgeDetection`). Called from canvas-stophook on
49
+ * every engine-level sign of life WITHIN a turn (a tool starting/streaming/
50
+ * ending, a streamed assistant token, a turn ending) so the marker's mtime
51
+ * always reflects the last moment the engine visibly did something, not just
52
+ * when the turn began. No-ops when the node isn't currently busy — a touch
53
+ * arriving after `clearBusy` (e.g. a late/queued event racing agent_end) must
54
+ * never resurrect a stale marker. Best-effort. */
55
+ export function touchBusy(nodeId) {
56
+ try {
57
+ if (!existsSync(busyPath(nodeId)))
58
+ return;
59
+ writeFileSync(busyPath(nodeId), '');
60
+ }
61
+ catch {
62
+ /* best-effort */
63
+ }
64
+ }
65
+ /** The mid-turn marker's mtime: the last moment ANY engine-level progress was
66
+ * observed while the node was busy (see `touchBusy`). Null when the node isn't
67
+ * busy or the marker is unreadable. This is the sole heartbeat the daemon's
68
+ * wedge detector reads — it needs no new instrumentation beyond re-touching an
69
+ * already-existing file. */
70
+ export function busySince(nodeId) {
71
+ try {
72
+ return statSync(busyPath(nodeId)).mtimeMs;
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
@@ -1,3 +1,13 @@
1
+ import { type SubscriptionRef } from '../canvas/index.js';
2
+ /** The doctrine wake: fan a system notice to every given subscriber of a node
3
+ * whose lifecycle just ended out from under it (closed, or crashed to dead) —
4
+ * the runtime guarantee that a manager waiting dormant on a child always
5
+ * learns of its terminal outcome instead of hanging forever. Active subscriber
6
+ * → inbox (wakes a dormant manager via its live watcher / the daemon's
7
+ * dormant-revive pass); passive → the passive accumulator (delivered, not
8
+ * woken). Shared by closeNode (step 4, below) and reviveNode's launch-refusal
9
+ * crash path (revive.ts). */
10
+ export declare function fanDoctrineWake(fromId: string, subscribers: readonly SubscriptionRef[], label: string, data: Record<string, unknown>): void;
1
11
  export interface CloseNodeResult {
2
12
  /** The focused node that was closed — the cascade root. */
3
13
  root: string;
@@ -36,6 +36,23 @@ import { headlessBrokerHost } from './host.js';
36
36
  import { tearDownNode, reapIfEmpty } from './placement.js';
37
37
  import { appendInbox } from '../feed/inbox.js';
38
38
  import { appendPassive } from '../feed/passive.js';
39
+ /** The doctrine wake: fan a system notice to every given subscriber of a node
40
+ * whose lifecycle just ended out from under it (closed, or crashed to dead) —
41
+ * the runtime guarantee that a manager waiting dormant on a child always
42
+ * learns of its terminal outcome instead of hanging forever. Active subscriber
43
+ * → inbox (wakes a dormant manager via its live watcher / the daemon's
44
+ * dormant-revive pass); passive → the passive accumulator (delivered, not
45
+ * woken). Shared by closeNode (step 4, below) and reviveNode's launch-refusal
46
+ * crash path (revive.ts). */
47
+ export function fanDoctrineWake(fromId, subscribers, label, data) {
48
+ for (const sub of subscribers) {
49
+ const notice = { from: fromId, tier: 'normal', kind: 'message', label, data };
50
+ if (sub.active)
51
+ appendInbox(sub.node_id, notice);
52
+ else
53
+ appendPassive(sub.node_id, notice);
54
+ }
55
+ }
39
56
  /** The set of nodes to close: the root plus every descendant reachable down the
40
57
  * subscriptions spine, all of whose managers are themselves in the set. Grown
41
58
  * to a fixpoint — a node added this pass can qualify its own children next
@@ -186,19 +203,7 @@ export function closeNode(rootId, opts = {}) {
186
203
  // never fires inside a self-contained cascade. Active → inbox (wakes a
187
204
  // dormant manager via its live watcher / the daemon's dormant-revive
188
205
  // second pass); passive → passive accumulator (delivered, not woken).
189
- for (const sub of survivors) {
190
- const notice = {
191
- from: id,
192
- tier: 'normal',
193
- kind: 'message',
194
- label: `Child closed — ${m.name} (${id}) was closed from the canvas and is no longer running.`,
195
- data: { reason: 'child-closed', child: id },
196
- };
197
- if (sub.active)
198
- appendInbox(sub.node_id, notice);
199
- else
200
- appendPassive(sub.node_id, notice);
201
- }
206
+ fanDoctrineWake(id, survivors, `Child closed — ${m.name} (${id}) was closed from the canvas and is no longer running.`, { reason: 'child-closed', child: id });
202
207
  closed.push(id);
203
208
  }
204
209
  catch {
@@ -5,6 +5,10 @@ const POLICIES = {
5
5
  auth: { kind: 'auth', gate: 'none', max: 0, baseDelayMs: 0, capDelayMs: 0, fatal: true },
6
6
  protocol: { kind: 'protocol', gate: 'none', max: 0, baseDelayMs: 0, capDelayMs: 0, fatal: true },
7
7
  other: { kind: 'other', gate: 'none', max: 0, baseDelayMs: 0, capDelayMs: 0, fatal: true },
8
+ // A wedge is a daemon-detected stall, never auto-retried — recovery is a
9
+ // deliberate agent/operator decision (kill the runaway subprocess), not a
10
+ // provider back-off.
11
+ wedged: { kind: 'wedged', gate: 'none', max: 0, baseDelayMs: 0, capDelayMs: 0, fatal: true },
8
12
  };
9
13
  export function policyFor(kind) {
10
14
  return POLICIES[kind];
@@ -107,8 +107,8 @@ export function drainBearings(meta) {
107
107
  // Consume the one-shot yield note (deleted on read) BEFORE the kickoff lists
108
108
  // the context dir, so it never shows up there.
109
109
  const yieldMsg = consumeYieldMessage(nodeId);
110
- // Drain the feed: read unread since the cursor and advance it past them, so a
111
- // later `crtr feed read` shows only what arrives afterward.
110
+ // Drain the feed: read unread since the cursor and advance it past them, so
111
+ // only entries that arrive after this point still count as unread.
112
112
  const cursor = readCursor(nodeId);
113
113
  const entries = readInboxSince(nodeId, cursor);
114
114
  let unreadDigest = null;
@@ -178,10 +178,8 @@ function feedBlock(nodeId, unreadDigest) {
178
178
  const subj = n === 1 ? 'it is' : 'they are';
179
179
  const pron = n === 1 ? 'it' : 'they';
180
180
  const verb = n === 1 ? 'pushes' : 'push';
181
- // State aliveness + the automatic wake at the source. Bare status ("— active")
182
- // left earlier revives unsure whether the worker was really live, so they
183
- // burned a turn on `feed read`/`feed peek` to confirm. Asserting it here
184
- // removes the reason to check.
181
+ // State aliveness + the automatic wake at the source explicitly, so there is
182
+ // no reason to check, poll, or verify separately.
185
183
  lines.push(`Awaiting ${n} node${n === 1 ? '' : 's'} — ${subj} alive and running right now, and will wake you the moment ${pron} ${verb}. The wake is automatic; nothing to check, poll, or verify.`);
186
184
  for (const m of awaiting)
187
185
  lines.push(` - ${m.name} (${m.node_id}) — ${m.status}`);
@@ -203,7 +201,7 @@ function feedBlock(nodeId, unreadDigest) {
203
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) => {
204
202
  const ref = `${h.nodeId}:reports/${h.latest}`;
205
203
  return ` - ${h.name} (${h.nodeId}): ${h.count} report${h.count === 1 ? '' : 's'}; latest ${reportKind(h.latest)}: ${ref}`;
206
- }), ...(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 feed read --all` for cursor-independent inbox history.');
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.');
207
205
  }
208
206
  return `<feed>\n${lines.join('\n')}\n</feed>`;
209
207
  }
@@ -38,7 +38,7 @@ import { isPidAlive } from '../canvas/pid.js';
38
38
  // verbs — respawnPane, respawnPaneDetached, swapPaneInPlace, breakPaneToSession,
39
39
  // joinPane, setWindowOption, setRemainOnExit — are deleted). `breakPane` +
40
40
  // `selectLayout` are FRESH minimal viewer-chrome helpers (not the deleted
41
- // engine-in-pane `breakPaneToSession`): `canvas tmux-spread` breaks the caller's
41
+ // engine-in-pane `breakPaneToSession`): `surface tmux-spread` breaks the caller's
42
42
  // viewer pane into its own window and tiles sibling viewers beside it.
43
43
  // `piCommand` + `paneCurrentPath` are KEPT: the sessionless surface-view system
44
44
  // (view-cycle.ts / view-run.ts) builds its pane commands with them, and the §5.1
@@ -17,8 +17,9 @@
17
17
  //
18
18
  // reviveNode remains the ONLY sanctioned launcher of the node engine.
19
19
  import { existsSync } from 'node:fs';
20
- import { getNode, updateNode, clearPid, fullName, cancelDeadlinesFor, } from '../canvas/index.js';
20
+ import { getNode, updateNode, clearPid, fullName, cancelDeadlinesFor, subscribersOf, } from '../canvas/index.js';
21
21
  import { transition } from './lifecycle.js';
22
+ import { fanDoctrineWake } from './close.js';
22
23
  import { buildPiArgv } from './launch.js';
23
24
  import { buildReviveKickoff, drainBearings } from './kickoff.js';
24
25
  import { headlessBrokerHost } from './host.js';
@@ -163,6 +164,13 @@ export function reviveNode(nodeId, opts) {
163
164
  catch (error) {
164
165
  try {
165
166
  transition(nodeId, 'crash');
167
+ // Doctrine wake: a launch-refusal here marks the node dead with NOTHING
168
+ // else fanning that out — the daemon's own auto-revive is the most common
169
+ // caller (superviseTick, best-effort try/catch, stderr-only on failure), so
170
+ // without this a manager waiting dormant on this child hangs forever,
171
+ // never told it will not come back on its own. Mirrors closeNode's step-4
172
+ // fan-out (same subscribers table, same active/passive split).
173
+ fanDoctrineWake(nodeId, subscribersOf(nodeId), `Child crashed — ${fullName(meta)} (${nodeId}) failed to relaunch and is now dead. It will NOT resume on its own.`, { reason: 'child-crashed', child: nodeId });
166
174
  }
167
175
  catch {
168
176
  /* best-effort cleanup */
@@ -78,7 +78,7 @@ export declare function renameWindow(window: string, name: string): boolean;
78
78
  * the break never falls back to tmux's global current pane. The pane keeps its
79
79
  * durable `%id` across the move (only its window changes), so callers can keep
80
80
  * using the same pane handle. Returns the new `{window, pane}` location, or null
81
- * if tmux fails. Used by `canvas tmux-spread` to lift the caller's viewer into a
81
+ * if tmux fails. Used by `surface tmux-spread` to lift the caller's viewer into a
82
82
  * fresh window before tiling sibling viewers beside it. */
83
83
  export declare function breakPane(pane: string): {
84
84
  window: string;
@@ -187,7 +187,7 @@ export declare function windowAlive(session: string | null | undefined, window:
187
187
  * to `tmux select-window -t <session>:<window>`. Best-effort; never throws. */
188
188
  export declare function selectWindow(session: string, window: string): boolean;
189
189
  /** Apply a named tmux layout to a window (`tmux select-layout -t <window>
190
- * <layout>`). `canvas tmux-spread` calls it with `tiled` to evenly grid every
190
+ * <layout>`). `surface tmux-spread` calls it with `tiled` to evenly grid every
191
191
  * viewer pane in the spread window (and between splits, to redistribute space so
192
192
  * the next split has room). Best-effort; false if tmux fails. */
193
193
  export declare function selectLayout(window: string, layout: string): boolean;
@@ -176,7 +176,7 @@ export function renameWindow(window, name) {
176
176
  * the break never falls back to tmux's global current pane. The pane keeps its
177
177
  * durable `%id` across the move (only its window changes), so callers can keep
178
178
  * using the same pane handle. Returns the new `{window, pane}` location, or null
179
- * if tmux fails. Used by `canvas tmux-spread` to lift the caller's viewer into a
179
+ * if tmux fails. Used by `surface tmux-spread` to lift the caller's viewer into a
180
180
  * fresh window before tiling sibling viewers beside it. */
181
181
  export function breakPane(pane) {
182
182
  const r = tmux([
@@ -369,7 +369,7 @@ export function selectWindow(session, window) {
369
369
  return tmux(['select-window', '-t', `${session}:${window}`]).ok;
370
370
  }
371
371
  /** Apply a named tmux layout to a window (`tmux select-layout -t <window>
372
- * <layout>`). `canvas tmux-spread` calls it with `tiled` to evenly grid every
372
+ * <layout>`). `surface tmux-spread` calls it with `tiled` to evenly grid every
373
373
  * viewer pane in the spread window (and between splits, to redistribute space so
374
374
  * the next split has room). Best-effort; false if tmux fails. */
375
375
  export function selectLayout(window, layout) {
@@ -1,4 +1,4 @@
1
- // crtrd entry point — spawned detached by `crtr canvas daemon start` and by bin/crtrd.
1
+ // crtrd entry point — spawned detached by `crtr sys daemon start` and by bin/crtrd.
2
2
  // Calls runDaemon() and never returns (the loop drives via setTimeout).
3
3
  import { runDaemon } from './crtrd.js';
4
4
  runDaemon();
@@ -19,6 +19,30 @@ export declare const YIELD_STALL_GRACE_MS: number;
19
19
  * after running `node yield` mid-turn, and a working engine must never be
20
20
  * killed), and the state has persisted past YIELD_STALL_GRACE_MS. */
21
21
  export declare function yieldStallVerdict(piPidAlive: boolean | null, intent: NodeRow['intent'], busy: boolean, stalledFor: number | null): 'leave' | 'pending' | 'kill';
22
+ export declare const WEDGE_QUIET_MS: number;
23
+ export declare const WEDGE_CPU_MAX_PERCENT = 2;
24
+ export type WedgeVerdict = 'leave' | 'pending' | 'wedged';
25
+ /** Pure wedge decision — mirrors livenessVerdict/yieldStallVerdict: a total
26
+ * function of the observed signals, no clock or process access of its own.
27
+ * `busy` false — the turn already ended (however it routed) — is never a
28
+ * wedge, regardless of how stale the marker looks. `quietForMs` null means
29
+ * the heartbeat marker couldn't be read (treated as “not enough evidence”, not
30
+ * “wedged”). `cpuPercent` null (sample failed/unavailable) or above the
31
+ * ceiling — EITHER keeps the verdict at 'pending' (keep watching next tick)
32
+ * rather than firing on an unconfirmed or contradicted signal. */
33
+ export declare function wedgeVerdict(input: {
34
+ busy: boolean;
35
+ quietForMs: number | null;
36
+ cpuPercent: number | null;
37
+ }): WedgeVerdict;
38
+ /** Pure: sum CPU% across a process's entire tree (itself + every descendant).
39
+ * Parses `ps -ax -o pid=,ppid=,pcpu=` output, builds a ppid→children map, and
40
+ * DFS's from `rootPid` collecting root + all descendants; malformed/blank
41
+ * lines are skipped. Returns `null` if `rootPid` never appears in the output
42
+ * (can't measure at all — treated as UNKNOWN by wedgeVerdict, never as
43
+ * “low”) or the output has no parsable rows. Exported for direct unit testing
44
+ * — no process access of its own, mirroring wedgeVerdict's purity. */
45
+ export declare function sumTreeCpu(psOutput: string, rootPid: number): number | null;
22
46
  export type LivenessVerdict = 'leave' | 'pending' | 'revive';
23
47
  /** Decide what to do with a node whose engine pid is DEAD, from how long it's
24
48
  * been dead. Pure — the time/revive side effects live in handleNodeLiveness;