@crouton-kit/crouter 0.3.39 → 0.3.41

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 (188) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
  6. package/dist/clients/attach/attach-cmd.js +706 -733
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/web/server.js +5 -28
  9. package/dist/clients/web/web-cmd.js +1 -1
  10. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  11. package/dist/commands/canvas-rebuild-index.js +5 -5
  12. package/dist/commands/node-snapshot.js +9 -1
  13. package/dist/commands/node.js +37 -17
  14. package/dist/commands/push.js +8 -0
  15. package/dist/commands/revive.d.ts +10 -0
  16. package/dist/commands/revive.js +28 -10
  17. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  18. package/dist/commands/sys/doctor.js +1 -1
  19. package/dist/commands/sys/setup-core.js +3 -2
  20. package/dist/commands/sys/setup.js +1 -1
  21. package/dist/commands/worktree.d.ts +2 -0
  22. package/dist/commands/worktree.js +94 -0
  23. package/dist/core/__tests__/boot.test.js +4 -4
  24. package/dist/core/__tests__/canvas.test.js +19 -7
  25. package/dist/core/__tests__/child-followup.test.js +15 -5
  26. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  27. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  28. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  31. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  32. package/dist/core/__tests__/grace-clock.test.js +18 -2
  33. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  34. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  35. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  36. package/dist/core/__tests__/revive.test.js +18 -4
  37. package/dist/core/__tests__/session-cycles.test.js +77 -0
  38. package/dist/core/__tests__/worktree.test.js +85 -0
  39. package/dist/core/canvas/boot.js +12 -7
  40. package/dist/core/canvas/canvas.d.ts +27 -8
  41. package/dist/core/canvas/canvas.js +54 -26
  42. package/dist/core/canvas/db.js +14 -0
  43. package/dist/core/canvas/history.js +1 -0
  44. package/dist/core/canvas/paths.d.ts +10 -9
  45. package/dist/core/canvas/paths.js +10 -9
  46. package/dist/core/canvas/pid.d.ts +155 -1
  47. package/dist/core/canvas/pid.js +306 -1
  48. package/dist/core/canvas/status-glyph.d.ts +7 -0
  49. package/dist/core/canvas/status-glyph.js +10 -1
  50. package/dist/core/canvas/types.d.ts +34 -0
  51. package/dist/core/fault-classifier.js +5 -1
  52. package/dist/core/runtime/bearings.js +4 -0
  53. package/dist/core/runtime/branded-host.d.ts +7 -0
  54. package/dist/core/runtime/branded-host.js +44 -17
  55. package/dist/core/runtime/broker-sdk.js +28 -68
  56. package/dist/core/runtime/broker.js +35 -4
  57. package/dist/core/runtime/host.d.ts +3 -3
  58. package/dist/core/runtime/host.js +148 -33
  59. package/dist/core/runtime/launch.d.ts +15 -15
  60. package/dist/core/runtime/launch.js +58 -4
  61. package/dist/core/runtime/naming.js +3 -2
  62. package/dist/core/runtime/nodes.d.ts +3 -1
  63. package/dist/core/runtime/nodes.js +1 -0
  64. package/dist/core/runtime/pi-cli.d.ts +2 -0
  65. package/dist/core/runtime/pi-cli.js +51 -0
  66. package/dist/core/runtime/placement.d.ts +1 -1
  67. package/dist/core/runtime/placement.js +5 -2
  68. package/dist/core/runtime/recap.js +2 -1
  69. package/dist/core/runtime/revive.d.ts +21 -15
  70. package/dist/core/runtime/revive.js +87 -52
  71. package/dist/core/runtime/session-cycles.d.ts +30 -0
  72. package/dist/core/runtime/session-cycles.js +77 -0
  73. package/dist/core/runtime/spawn.d.ts +4 -0
  74. package/dist/core/runtime/spawn.js +152 -100
  75. package/dist/core/runtime/tmux.d.ts +7 -10
  76. package/dist/core/runtime/tmux.js +9 -11
  77. package/dist/core/worktree.d.ts +35 -0
  78. package/dist/core/worktree.js +158 -0
  79. package/dist/daemon/crtrd.d.ts +17 -8
  80. package/dist/daemon/crtrd.js +191 -40
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +7 -0
  83. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  84. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  85. package/dist/pi-extensions/canvas-stophook.js +11 -5
  86. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  87. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  88. package/dist/web-client/index.html +2 -2
  89. package/docs/compat/hearth-crtr-v1.md +191 -0
  90. package/docs/public-api.md +75 -0
  91. package/package.json +3 -4
  92. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  93. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  94. package/dist/core/hearth/config.d.ts +0 -3
  95. package/dist/core/hearth/config.js +0 -108
  96. package/dist/core/hearth/guest-env.d.ts +0 -9
  97. package/dist/core/hearth/guest-env.js +0 -27
  98. package/dist/core/hearth/index.d.ts +0 -4
  99. package/dist/core/hearth/index.js +0 -4
  100. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  101. package/dist/core/hearth/model-auth-guest.js +0 -430
  102. package/dist/core/hearth/provider.d.ts +0 -36
  103. package/dist/core/hearth/provider.js +0 -10
  104. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -254
  105. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  106. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  107. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -54
  108. package/dist/core/hearth/providers/blaxel-home.js +0 -386
  109. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  110. package/dist/core/hearth/providers/blaxel.js +0 -364
  111. package/dist/core/hearth/providers/types.d.ts +0 -93
  112. package/dist/core/hearth/types.d.ts +0 -155
  113. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  114. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  115. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  116. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  117. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  118. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  119. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  120. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  121. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  122. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  123. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  124. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  125. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  126. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  127. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  128. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  129. package/dist/hearth/control-plane/config.d.ts +0 -21
  130. package/dist/hearth/control-plane/config.js +0 -77
  131. package/dist/hearth/control-plane/db.d.ts +0 -30
  132. package/dist/hearth/control-plane/db.js +0 -561
  133. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  134. package/dist/hearth/control-plane/hearth-target.js +0 -68
  135. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  136. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  137. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  138. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  139. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  140. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  141. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  142. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  143. package/dist/hearth/control-plane/main.d.ts +0 -1
  144. package/dist/hearth/control-plane/main.js +0 -88
  145. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  146. package/dist/hearth/control-plane/node-message.js +0 -98
  147. package/dist/hearth/control-plane/register.d.ts +0 -15
  148. package/dist/hearth/control-plane/register.js +0 -34
  149. package/dist/hearth/control-plane/registry.d.ts +0 -22
  150. package/dist/hearth/control-plane/registry.js +0 -168
  151. package/dist/hearth/control-plane/relay.d.ts +0 -44
  152. package/dist/hearth/control-plane/relay.js +0 -711
  153. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  154. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  155. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  156. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  157. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  158. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  159. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  160. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  161. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  162. package/dist/hearth/control-plane/secrets.js +0 -134
  163. package/dist/hearth/control-plane/server.d.ts +0 -27
  164. package/dist/hearth/control-plane/server.js +0 -482
  165. package/dist/hearth/control-plane/serving.d.ts +0 -15
  166. package/dist/hearth/control-plane/serving.js +0 -106
  167. package/dist/hearth/control-plane/session.d.ts +0 -68
  168. package/dist/hearth/control-plane/session.js +0 -273
  169. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  170. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  171. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  172. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  173. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  174. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  175. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  176. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  177. package/dist/hearth/control-plane/types.d.ts +0 -86
  178. package/dist/hearth/control-plane/types.js +0 -1
  179. package/dist/hearth/control-plane/wake.d.ts +0 -86
  180. package/dist/hearth/control-plane/wake.js +0 -550
  181. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  182. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  183. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  184. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  185. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  186. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  187. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  188. /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
@@ -6,5 +6,159 @@ export declare function isPidAlive(pid: number | null | undefined): boolean;
6
6
  * swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
7
7
  * an in-flight predicate evaluation) and daemon/predicate-eval.ts (per-eval
8
8
  * timeout + stale-lease recovery kills). Lives beside isPidAlive: canvas/ is
9
- * the lowest shared layer every process-liveness/-teardown primitive sits at. */
9
+ * the lowest shared layer every process-liveness/-teardown primitive sits at.
10
+ * Guards `pid` before signaling (review nit, crouter#98): never hit pid 0
11
+ * (which means "my own group") or a negative/non-integer value. */
10
12
  export declare function killProcessGroup(pid: number, signal?: NodeJS.Signals): void;
13
+ /** Every transitive descendant of `rootPid`, discovered by walking `ps`'s
14
+ * pid/ppid table (BFS) — NOT process-GROUP membership. This is the fix for
15
+ * crouton-labs/crouter#98: the pi SDK's bash tool spawns its shell child with
16
+ * `detached: true` (verified in `@earendil-works/pi-agent-core`'s
17
+ * `harness/env/nodejs.js`), which calls `setsid()` and puts that child in a
18
+ * brand-new process GROUP of its own — so `kill(-rootPid)` never reaches it.
19
+ * `setsid()` changes only SID/PGID, NEVER ppid, so the child (and its own
20
+ * descendants) is still discoverable by walking parentage from `rootPid`, as
21
+ * long as `rootPid`'s OS process is still alive when we walk (a fully-dead
22
+ * parent is reparented away by the kernel immediately on exit, severing this
23
+ * link — the wedged-but-alive broker this fix targets never hits that). Best-
24
+ * effort: returns `[]` (never throws) on any `ps` failure, exactly like
25
+ * `killProcessGroup`'s ESRCH-swallowing. */
26
+ export declare function descendantPids(rootPid: number): number[];
27
+ /** Capture a portable process-identity fingerprint — `ps`'s `lstart` column,
28
+ * the process's full wall-clock start time — for each of `pids`, in ONE
29
+ * batched `ps -p <list>` call. `lstart` is a standard format keyword on BOTH
30
+ * BSD ps (macOS) and procps-ng (Linux), so this needs no `/proc` parsing or
31
+ * other Linux-only machinery. Used to guard against PID REUSE during the
32
+ * multi-second teardown escalation ladder (crouter#98 review finding 3): a
33
+ * captured pid can exit and the OS can recycle it for an unrelated process
34
+ * before the SIGKILL rung fires, and a bare `kill(pid, 0)` / `kill(pid, sig)`
35
+ * cannot tell the difference.
36
+ *
37
+ * Returns `null` — NOT an empty map — when the `ps` PROBE ITSELF failed (spawn
38
+ * error, non-zero exit, non-string stdout): a probe failure means we have NO
39
+ * information, which is a fundamentally different outcome from a probe that
40
+ * SUCCEEDED and simply found no row for a given pid (that pid is genuinely
41
+ * gone). Collapsing these two into the same empty map was the final-review
42
+ * bug (crouter#98 signal-safety finding): `killProcessTreePids` cannot tell
43
+ * "pid confirmed gone" from "we don't know" without this distinction, and
44
+ * misreading a failed probe as positive proof of absence/reuse either signals
45
+ * a reused stranger or — worse — silently skips reaping a real live orphan.
46
+ * A successful call may still return a map with no entry for some/all of
47
+ * `pids` (those pids have no current row — genuinely gone); that is the
48
+ * valid empty-or-partial case and is NOT a failure. */
49
+ export declare function capturePidIdentities(pids: readonly number[]): Map<number, string> | null;
50
+ /** The full pre-signal snapshot `teardown()` needs, taken ONCE, from ONE `ps`
51
+ * table (crouter#98 final review, Fix 1): the process TREE rooted at `rootPid`
52
+ * (itself + every transitive descendant) alongside each member's identity
53
+ * fingerprint — PLUS, load-bearing, a launch-time-identity REUSE check on
54
+ * `rootPid` itself before the tree is even walked.
55
+ *
56
+ * The hazard this closes: the PID-reuse guard elsewhere in this module (see
57
+ * `killProcessTreePids`) only ever compared identities captured across the
58
+ * teardown ESCALATION window (a few seconds) — it had no idea whether
59
+ * `rootPid` (the broker pid recorded on the node row at LAUNCH time, possibly
60
+ * hours or days earlier) had ALREADY been recycled for an unrelated process
61
+ * before `teardown()` was ever called. In that case the naive snapshot would
62
+ * capture the STRANGER's current identity as the "expected" baseline and
63
+ * cheerfully SIGTERM/SIGKILL it and whatever the stranger's OWN process tree
64
+ * turns out to be — exactly the wrong-victim risk finding 1 targets.
65
+ *
66
+ * `expectedIdentity` is the LAUNCH-time identity recorded on the node row
67
+ * (`recordPid`'s `capturePidIdentities([pid])` snapshot, taken the instant the
68
+ * broker was spawned/bound) — `null` for a node booted before this field
69
+ * existed, or if that original capture itself failed. When `expectedIdentity`
70
+ * is present AND the CURRENT probe for `rootPid` succeeds AND finds a DIFFERENT
71
+ * identity, `rootPid` is provably a different OS process than the broker this
72
+ * node ever launched: `reused: true`, `tree: []`, `identities: null` — the
73
+ * caller must treat this exactly like "nothing to tear down", and must NOT
74
+ * walk `descendantPids` from this poisoned root (those "descendants" would be
75
+ * the STRANGER's children, not this node's). Every other case (no baseline,
76
+ * the pid is simply gone, the probe itself failed) falls through to the
77
+ * normal walk — fail-open, matching every other guard in this module: a
78
+ * MISMATCH is the only thing that skips, never an absence of evidence. */
79
+ export interface TeardownSnapshot {
80
+ /** `[rootPid, ...descendants]`, or `[]` when `reused` is true (nothing safe
81
+ * to signal) or `rootPid` itself is invalid. */
82
+ tree: number[];
83
+ /** Identity fingerprint per pid in `tree`, from the SAME `ps` table read as
84
+ * the tree walk — or `null` when that probe failed outright (fail-open: the
85
+ * caller's downstream `killProcessTreePids` signals unguarded in that case,
86
+ * same as an omitted `identities` arg). Always `null` when `reused` is true
87
+ * (there is no tree to guard). */
88
+ identities: Map<number, string> | null;
89
+ /** True iff `rootPid`'s CURRENT identity is POSITIVELY known to differ from
90
+ * `expectedIdentity` — the pid was already reused by an unrelated process
91
+ * before this snapshot was taken. Callers must skip ALL signaling when true. */
92
+ reused: boolean;
93
+ }
94
+ export declare function captureTeardownSnapshot(rootPid: number, expectedIdentity: string | null): TeardownSnapshot;
95
+ /** Best-effort signal against every pid in an ALREADY-CAPTURED tree snapshot
96
+ * (typically `[rootPid, ...descendantPids(rootPid)]`, taken ONCE by the
97
+ * caller before sending any signal — see the doc on `isProcessTreeAlive` for
98
+ * why re-deriving via `ps` after signaling starts is unsafe). Signals EACH
99
+ * pid's OWN process group (a detached SDK-spawned descendant is its own
100
+ * group leader — the pi bash tool's shell child, crouton-labs/crouter#98)
101
+ * PLUS a plain `kill(pid)` on each (belt-and-suspenders for a pid that is
102
+ * NOT its own group leader). Every signal is best-effort — `killProcessGroup`
103
+ * swallows ESRCH and the plain `kill` here does too — so firing this against
104
+ * an already-fully-dead snapshot is a harmless no-op.
105
+ *
106
+ * `identities`, when supplied (crouter#98 review finding 3), is a
107
+ * `capturePidIdentities` snapshot taken alongside the pid list at the SAME
108
+ * moment — `null` if that INITIAL capture's `ps` probe itself failed (see
109
+ * `capturePidIdentities`'s doc). Before signaling EITHER the group or the
110
+ * plain kill for a pid, this re-derives CURRENT identities in one fresh
111
+ * batched call and compares — but ONLY skips on POSITIVE proof of reuse; any
112
+ * failure or absence of evidence falls through to the normal signal path
113
+ * (fail-open, matching this module's pre-guard behavior — final review,
114
+ * crouter#98 signal-safety finding):
115
+ *
116
+ * - baseline identity present AND the current probe SUCCEEDED AND the
117
+ * current identity for this pid is present AND DIFFERENT — the OS has
118
+ * recycled this pid for an unrelated process since the snapshot — SKIP
119
+ * (the sole skip condition; this is the actual reused-pid hazard finding
120
+ * 3 targets).
121
+ * - baseline identity present but the pid is now simply GONE (current
122
+ * probe succeeded, no row for this pid) — it died naturally in the
123
+ * meantime, not reused — SIGNAL as normal (harmless no-op on the plain
124
+ * kill; load-bearing for the group kill, which can still reach a
125
+ * surviving process that shares this now-dead pid's process GROUP).
126
+ * - baseline had NO identity at all for this pid (the pid was ALREADY dead
127
+ * at snapshot time — e.g. a broker that crashed before `teardown()` ever
128
+ * ran) — SIGNAL as normal regardless of the current probe: this is
129
+ * exactly how `killProcessGroup` on an already-dead root still reaches a
130
+ * live process sharing its process group (the pgid persists after the
131
+ * group leader exits; see `host-teardown-process-group.test.ts`'s "dead
132
+ * broker" case).
133
+ * - the INITIAL capture's probe failed outright (`identities === null`) —
134
+ * we have NO baseline to compare against for ANY pid — SIGNAL as normal
135
+ * for the whole list; the CURRENT probe is not even attempted (nothing
136
+ * to compare it to).
137
+ * - the baseline succeeded but the CURRENT probe (taken here) fails — we
138
+ * have NO current information — SIGNAL as normal for the whole list; a
139
+ * failed probe must never be read as "gone" (would risk reaching a
140
+ * reused stranger, the residual risk this guard cannot close) NOR as
141
+ * "reused" (would wrongly disable the reap for every real live pid).
142
+ *
143
+ * Callers that omit `identities` entirely (pre-existing call sites) keep the
144
+ * prior unguarded behavior. */
145
+ export declare function killProcessTreePids(pids: readonly number[], signal?: NodeJS.Signals, identities?: ReadonlyMap<number, string> | null): void;
146
+ /** Is `rootPid`'s process TREE — itself or any CURRENT descendant — still
147
+ * alive, re-deriving the descendant set fresh via `ps` on every call? Safe
148
+ * ONLY before any signal has been sent to this tree. Once a signal kills the
149
+ * root (it's typically its own process-group leader — see `launch()` in
150
+ * host.ts), the kernel reparents any surviving child away from `rootPid`
151
+ * IMMEDIATELY (standard orphan handling, confirmed empirically: a killed
152
+ * parent's child shows `ppid=1` within the same tick, not lazily) —
153
+ * silently "losing" that child from a fresh `descendantPids` walk even
154
+ * though it is still very much alive. Use this ONLY to watch a tree you have
155
+ * not touched yet (e.g. waiting out a graceful-shutdown grace window); once
156
+ * you start signaling, capture the pid list ONCE and check it with
157
+ * `isAnyPidAlive` instead — see `killProcessTreePids`. */
158
+ export declare function isProcessTreeAlive(rootPid: number): boolean;
159
+ /** Is any pid in an ALREADY-CAPTURED pid list (see `killProcessTreePids`)
160
+ * still alive? Pure signal-0 check against the fixed list — NEVER re-walks
161
+ * `ps` — the correct liveness check once you've started signaling a tree
162
+ * (see `isProcessTreeAlive`'s doc for why re-deriving after that point would
163
+ * silently drop a still-alive orphan). */
164
+ export declare function isAnyPidAlive(pids: readonly number[]): boolean;
@@ -7,6 +7,8 @@
7
7
  // so it is exempt from the "only canvas.ts touches the db" rule. Collapses the
8
8
  // four near-identical copies that used to live in canvas.ts, revive.ts,
9
9
  // placement.ts, and crtrd.ts into one (Phase 3 review reuse MINOR-1).
10
+ import { spawnSync } from 'node:child_process';
11
+ import { readFileSync } from 'node:fs';
10
12
  /** True if a process with `pid` is currently alive (signal-0 probe). `kill(pid,
11
13
  * 0)` throws ESRCH when the process is gone; EPERM means it exists but isn't
12
14
  * ours — still alive. A null/undefined pid (legacy / never-booted) reads dead. */
@@ -25,8 +27,12 @@ export function isPidAlive(pid) {
25
27
  * swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
26
28
  * an in-flight predicate evaluation) and daemon/predicate-eval.ts (per-eval
27
29
  * timeout + stale-lease recovery kills). Lives beside isPidAlive: canvas/ is
28
- * the lowest shared layer every process-liveness/-teardown primitive sits at. */
30
+ * the lowest shared layer every process-liveness/-teardown primitive sits at.
31
+ * Guards `pid` before signaling (review nit, crouter#98): never hit pid 0
32
+ * (which means "my own group") or a negative/non-integer value. */
29
33
  export function killProcessGroup(pid, signal = 'SIGTERM') {
34
+ if (!Number.isInteger(pid) || pid <= 0)
35
+ return;
30
36
  try {
31
37
  process.kill(-pid, signal);
32
38
  }
@@ -34,3 +40,302 @@ export function killProcessGroup(pid, signal = 'SIGTERM') {
34
40
  /* already gone */
35
41
  }
36
42
  }
43
+ /** Linux-only higher-resolution discriminator layered onto `lstart` (crouter#98
44
+ * review finding, minor): `ps lstart` is only second-granular, so a PID reused
45
+ * within the same wall-clock second compares equal and slips past the reuse
46
+ * guard. `/proc/<pid>/stat`'s `starttime` field is the kernel's own jiffies-
47
+ * since-boot counter for that process — effectively unique per pid even for
48
+ * same-second reuse. `comm` (field 2) is parenthesized and may itself contain
49
+ * spaces/parens, so this splits on the LAST `)` rather than counting fields
50
+ * from the front. Returns `null` (never throws) on any read/parse failure —
51
+ * no `/proc` (macOS, containers without procfs), a raced-away pid, or a
52
+ * malformed line — so this is purely additive precision, never a hard
53
+ * dependency: `composeIdentity` falls back to plain `lstart` in every such
54
+ * case, exactly matching this module's pre-existing cross-platform coarse
55
+ * behavior. */
56
+ function procStartTicks(pid) {
57
+ try {
58
+ const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
59
+ const closeParen = raw.lastIndexOf(')');
60
+ if (closeParen === -1)
61
+ return null;
62
+ // Fields after `(comm)`, 0-indexed from `state` (proc(5) field 3): state(0)
63
+ // ppid(1) pgrp(2) session(3) tty_nr(4) tpgid(5) flags(6) minflt(7)
64
+ // cminflt(8) majflt(9) cmajflt(10) utime(11) stime(12) cutime(13)
65
+ // cstime(14) priority(15) nice(16) num_threads(17) itrealvalue(18)
66
+ // starttime(19) — proc(5) field 22.
67
+ const rest = raw.slice(closeParen + 2).trim().split(/\s+/);
68
+ const starttime = rest[19];
69
+ return starttime !== undefined && /^\d+$/.test(starttime) ? starttime : null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ /** Compose the stable process-identity fingerprint used everywhere in this
76
+ * module: `ps lstart` (portable, BSD + Linux) plus the Linux-only jiffies
77
+ * discriminator when available (best-effort — see `procStartTicks`). ALWAYS
78
+ * used to build an identity string, never `lstart` alone directly, so every
79
+ * identity captured anywhere (launch-time `recordPid`, the teardown
80
+ * snapshot, and the escalation-window re-check) is comparable apples-to-
81
+ * apples. */
82
+ function composeIdentity(pid, lstart) {
83
+ const ticks = procStartTicks(pid);
84
+ return ticks !== null ? `${lstart}#${ticks}` : lstart;
85
+ }
86
+ /** One combined `ps` probe over the WHOLE process table — `pid`, `ppid`, AND
87
+ * `lstart` per row — parsed into BOTH a ppid → children-pid[] map (for
88
+ * descendant-tree walks) and a pid → identity map (`composeIdentity`), from a
89
+ * SINGLE `spawnSync` call. Shared by `descendantPids` and
90
+ * `captureTeardownSnapshot` so tree membership and identity are always read
91
+ * off the SAME table snapshot — closing the small race (crouter#98 final
92
+ * review) where two separate `ps` calls could see the process table change
93
+ * between them. Returns `null` (never throws) on any `ps` failure. */
94
+ function capturePsTable() {
95
+ let stdout;
96
+ try {
97
+ const r = spawnSync('ps', ['-Ao', 'pid=,ppid=,lstart='], { encoding: 'utf8', timeout: 2000 });
98
+ if (r.status !== 0 || typeof r.stdout !== 'string')
99
+ return null;
100
+ stdout = r.stdout;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ const childrenOf = new Map();
106
+ const identities = new Map();
107
+ for (const line of stdout.split('\n')) {
108
+ const trimmed = line.trim();
109
+ if (trimmed === '')
110
+ continue;
111
+ // pid and ppid are the two leading whitespace-delimited numeric tokens;
112
+ // `lstart` (a human-readable timestamp that itself CONTAINS spaces, e.g.
113
+ // "Wed Jul 3 10:23:45 2026") is everything after them, kept verbatim.
114
+ const m = /^(\d+)\s+(\d+)\s+(.+)$/.exec(trimmed);
115
+ if (m === null)
116
+ continue;
117
+ const pid = Number(m[1]);
118
+ const ppid = Number(m[2]);
119
+ const lstart = m[3].trim();
120
+ if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(ppid) || ppid <= 0 || lstart === '')
121
+ continue;
122
+ identities.set(pid, composeIdentity(pid, lstart));
123
+ const siblings = childrenOf.get(ppid);
124
+ if (siblings === undefined)
125
+ childrenOf.set(ppid, [pid]);
126
+ else
127
+ siblings.push(pid);
128
+ }
129
+ return { childrenOf, identities };
130
+ }
131
+ /** BFS over an already-parsed ppid → children-pid[] map, starting from
132
+ * `rootPid`'s children. Pure (no `ps` spawn) — split out of `descendantPids`
133
+ * so `captureTeardownSnapshot` can walk the SAME `capturePsTable()` result it
134
+ * already paid for, rather than re-spawning `ps`. */
135
+ function descendantPidsFrom(rootPid, childrenOf) {
136
+ const out = [];
137
+ const seen = new Set([rootPid]);
138
+ const queue = [...(childrenOf.get(rootPid) ?? [])];
139
+ while (queue.length > 0) {
140
+ const pid = queue.shift();
141
+ if (seen.has(pid))
142
+ continue; // guard against a malformed/cyclic ps snapshot
143
+ seen.add(pid);
144
+ out.push(pid);
145
+ const kids = childrenOf.get(pid);
146
+ if (kids !== undefined)
147
+ queue.push(...kids);
148
+ }
149
+ return out;
150
+ }
151
+ /** Every transitive descendant of `rootPid`, discovered by walking `ps`'s
152
+ * pid/ppid table (BFS) — NOT process-GROUP membership. This is the fix for
153
+ * crouton-labs/crouter#98: the pi SDK's bash tool spawns its shell child with
154
+ * `detached: true` (verified in `@earendil-works/pi-agent-core`'s
155
+ * `harness/env/nodejs.js`), which calls `setsid()` and puts that child in a
156
+ * brand-new process GROUP of its own — so `kill(-rootPid)` never reaches it.
157
+ * `setsid()` changes only SID/PGID, NEVER ppid, so the child (and its own
158
+ * descendants) is still discoverable by walking parentage from `rootPid`, as
159
+ * long as `rootPid`'s OS process is still alive when we walk (a fully-dead
160
+ * parent is reparented away by the kernel immediately on exit, severing this
161
+ * link — the wedged-but-alive broker this fix targets never hits that). Best-
162
+ * effort: returns `[]` (never throws) on any `ps` failure, exactly like
163
+ * `killProcessGroup`'s ESRCH-swallowing. */
164
+ export function descendantPids(rootPid) {
165
+ if (!Number.isInteger(rootPid) || rootPid <= 0)
166
+ return [];
167
+ const table = capturePsTable();
168
+ if (table === null)
169
+ return [];
170
+ return descendantPidsFrom(rootPid, table.childrenOf);
171
+ }
172
+ /** Capture a portable process-identity fingerprint — `ps`'s `lstart` column,
173
+ * the process's full wall-clock start time — for each of `pids`, in ONE
174
+ * batched `ps -p <list>` call. `lstart` is a standard format keyword on BOTH
175
+ * BSD ps (macOS) and procps-ng (Linux), so this needs no `/proc` parsing or
176
+ * other Linux-only machinery. Used to guard against PID REUSE during the
177
+ * multi-second teardown escalation ladder (crouter#98 review finding 3): a
178
+ * captured pid can exit and the OS can recycle it for an unrelated process
179
+ * before the SIGKILL rung fires, and a bare `kill(pid, 0)` / `kill(pid, sig)`
180
+ * cannot tell the difference.
181
+ *
182
+ * Returns `null` — NOT an empty map — when the `ps` PROBE ITSELF failed (spawn
183
+ * error, non-zero exit, non-string stdout): a probe failure means we have NO
184
+ * information, which is a fundamentally different outcome from a probe that
185
+ * SUCCEEDED and simply found no row for a given pid (that pid is genuinely
186
+ * gone). Collapsing these two into the same empty map was the final-review
187
+ * bug (crouter#98 signal-safety finding): `killProcessTreePids` cannot tell
188
+ * "pid confirmed gone" from "we don't know" without this distinction, and
189
+ * misreading a failed probe as positive proof of absence/reuse either signals
190
+ * a reused stranger or — worse — silently skips reaping a real live orphan.
191
+ * A successful call may still return a map with no entry for some/all of
192
+ * `pids` (those pids have no current row — genuinely gone); that is the
193
+ * valid empty-or-partial case and is NOT a failure. */
194
+ export function capturePidIdentities(pids) {
195
+ const valid = pids.filter((pid) => Number.isInteger(pid) && pid > 0);
196
+ const out = new Map();
197
+ if (valid.length === 0)
198
+ return out;
199
+ try {
200
+ const r = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', valid.join(',')], { encoding: 'utf8', timeout: 2000 });
201
+ if (r.status !== 0 || typeof r.stdout !== 'string')
202
+ return null;
203
+ for (const line of r.stdout.split('\n')) {
204
+ const trimmed = line.trim();
205
+ if (trimmed === '')
206
+ continue;
207
+ // Only the FIRST token (pid) is split on whitespace — `lstart` is a
208
+ // human-readable timestamp that itself CONTAINS spaces (e.g. "Wed Jul
209
+ // 3 10:23:45 2026"), so everything after the first space is kept
210
+ // verbatim as the identity string. Exact string equality later is
211
+ // sufficient; nothing here needs to parse it as a real date.
212
+ const spaceIdx = trimmed.indexOf(' ');
213
+ if (spaceIdx === -1)
214
+ continue;
215
+ const pid = Number(trimmed.slice(0, spaceIdx));
216
+ const startedAt = trimmed.slice(spaceIdx + 1).trim();
217
+ if (!Number.isInteger(pid) || pid <= 0 || startedAt === '')
218
+ continue;
219
+ out.set(pid, composeIdentity(pid, startedAt));
220
+ }
221
+ return out;
222
+ }
223
+ catch {
224
+ return null;
225
+ }
226
+ }
227
+ export function captureTeardownSnapshot(rootPid, expectedIdentity) {
228
+ if (!Number.isInteger(rootPid) || rootPid <= 0)
229
+ return { tree: [], identities: null, reused: false };
230
+ const table = capturePsTable();
231
+ if (table === null)
232
+ return { tree: [rootPid], identities: null, reused: false };
233
+ const currentRootIdentity = table.identities.get(rootPid);
234
+ const reused = expectedIdentity != null && currentRootIdentity !== undefined && currentRootIdentity !== expectedIdentity;
235
+ if (reused)
236
+ return { tree: [], identities: null, reused: true };
237
+ const tree = [rootPid, ...descendantPidsFrom(rootPid, table.childrenOf)];
238
+ const identities = new Map();
239
+ for (const pid of tree) {
240
+ const id = table.identities.get(pid);
241
+ if (id !== undefined)
242
+ identities.set(pid, id);
243
+ }
244
+ return { tree, identities, reused: false };
245
+ }
246
+ /** Best-effort signal against every pid in an ALREADY-CAPTURED tree snapshot
247
+ * (typically `[rootPid, ...descendantPids(rootPid)]`, taken ONCE by the
248
+ * caller before sending any signal — see the doc on `isProcessTreeAlive` for
249
+ * why re-deriving via `ps` after signaling starts is unsafe). Signals EACH
250
+ * pid's OWN process group (a detached SDK-spawned descendant is its own
251
+ * group leader — the pi bash tool's shell child, crouton-labs/crouter#98)
252
+ * PLUS a plain `kill(pid)` on each (belt-and-suspenders for a pid that is
253
+ * NOT its own group leader). Every signal is best-effort — `killProcessGroup`
254
+ * swallows ESRCH and the plain `kill` here does too — so firing this against
255
+ * an already-fully-dead snapshot is a harmless no-op.
256
+ *
257
+ * `identities`, when supplied (crouter#98 review finding 3), is a
258
+ * `capturePidIdentities` snapshot taken alongside the pid list at the SAME
259
+ * moment — `null` if that INITIAL capture's `ps` probe itself failed (see
260
+ * `capturePidIdentities`'s doc). Before signaling EITHER the group or the
261
+ * plain kill for a pid, this re-derives CURRENT identities in one fresh
262
+ * batched call and compares — but ONLY skips on POSITIVE proof of reuse; any
263
+ * failure or absence of evidence falls through to the normal signal path
264
+ * (fail-open, matching this module's pre-guard behavior — final review,
265
+ * crouter#98 signal-safety finding):
266
+ *
267
+ * - baseline identity present AND the current probe SUCCEEDED AND the
268
+ * current identity for this pid is present AND DIFFERENT — the OS has
269
+ * recycled this pid for an unrelated process since the snapshot — SKIP
270
+ * (the sole skip condition; this is the actual reused-pid hazard finding
271
+ * 3 targets).
272
+ * - baseline identity present but the pid is now simply GONE (current
273
+ * probe succeeded, no row for this pid) — it died naturally in the
274
+ * meantime, not reused — SIGNAL as normal (harmless no-op on the plain
275
+ * kill; load-bearing for the group kill, which can still reach a
276
+ * surviving process that shares this now-dead pid's process GROUP).
277
+ * - baseline had NO identity at all for this pid (the pid was ALREADY dead
278
+ * at snapshot time — e.g. a broker that crashed before `teardown()` ever
279
+ * ran) — SIGNAL as normal regardless of the current probe: this is
280
+ * exactly how `killProcessGroup` on an already-dead root still reaches a
281
+ * live process sharing its process group (the pgid persists after the
282
+ * group leader exits; see `host-teardown-process-group.test.ts`'s "dead
283
+ * broker" case).
284
+ * - the INITIAL capture's probe failed outright (`identities === null`) —
285
+ * we have NO baseline to compare against for ANY pid — SIGNAL as normal
286
+ * for the whole list; the CURRENT probe is not even attempted (nothing
287
+ * to compare it to).
288
+ * - the baseline succeeded but the CURRENT probe (taken here) fails — we
289
+ * have NO current information — SIGNAL as normal for the whole list; a
290
+ * failed probe must never be read as "gone" (would risk reaching a
291
+ * reused stranger, the residual risk this guard cannot close) NOR as
292
+ * "reused" (would wrongly disable the reap for every real live pid).
293
+ *
294
+ * Callers that omit `identities` entirely (pre-existing call sites) keep the
295
+ * prior unguarded behavior. */
296
+ export function killProcessTreePids(pids, signal = 'SIGTERM', identities) {
297
+ const current = identities != null ? capturePidIdentities(pids) : undefined;
298
+ for (const pid of pids) {
299
+ if (identities != null && current != null) {
300
+ const expected = identities.get(pid);
301
+ const actual = current.get(pid);
302
+ const reused = expected !== undefined && actual !== undefined && actual !== expected;
303
+ if (reused)
304
+ continue;
305
+ }
306
+ killProcessGroup(pid, signal);
307
+ if (!Number.isInteger(pid) || pid <= 0)
308
+ continue;
309
+ try {
310
+ process.kill(pid, signal);
311
+ }
312
+ catch {
313
+ /* already gone */
314
+ }
315
+ }
316
+ }
317
+ /** Is `rootPid`'s process TREE — itself or any CURRENT descendant — still
318
+ * alive, re-deriving the descendant set fresh via `ps` on every call? Safe
319
+ * ONLY before any signal has been sent to this tree. Once a signal kills the
320
+ * root (it's typically its own process-group leader — see `launch()` in
321
+ * host.ts), the kernel reparents any surviving child away from `rootPid`
322
+ * IMMEDIATELY (standard orphan handling, confirmed empirically: a killed
323
+ * parent's child shows `ppid=1` within the same tick, not lazily) —
324
+ * silently "losing" that child from a fresh `descendantPids` walk even
325
+ * though it is still very much alive. Use this ONLY to watch a tree you have
326
+ * not touched yet (e.g. waiting out a graceful-shutdown grace window); once
327
+ * you start signaling, capture the pid list ONCE and check it with
328
+ * `isAnyPidAlive` instead — see `killProcessTreePids`. */
329
+ export function isProcessTreeAlive(rootPid) {
330
+ if (isPidAlive(rootPid))
331
+ return true;
332
+ return descendantPids(rootPid).some((pid) => isPidAlive(pid));
333
+ }
334
+ /** Is any pid in an ALREADY-CAPTURED pid list (see `killProcessTreePids`)
335
+ * still alive? Pure signal-0 check against the fixed list — NEVER re-walks
336
+ * `ps` — the correct liveness check once you've started signaling a tree
337
+ * (see `isProcessTreeAlive`'s doc for why re-deriving after that point would
338
+ * silently drop a still-alive orphan). */
339
+ export function isAnyPidAlive(pids) {
340
+ return pids.some((pid) => isPidAlive(pid));
341
+ }
@@ -23,6 +23,13 @@ export declare function resolveNodeVisual(status: NodeStatus, opts: {
23
23
  kind: FaultKind;
24
24
  } | null;
25
25
  }): NodeVisual;
26
+ /** True for exactly the fault dispositions `faultDispositionText` renders as
27
+ * "needs you" — nothing (sdk/daemon/client) is auto-driving recovery, so the
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
30
+ * predicate the dashboard advertises instead of drifting out of sync with it
31
+ * (issue #115 — `--now` used to reject exactly the nodes shown as "needs you"). */
32
+ export declare function faultNeedsYou(fault: Pick<Fault, 'retry'>): boolean;
26
33
  /** Human-friendly active-fault disposition text. `auto` faults show the current
27
34
  * retry attempt plus the next scheduled retry; client-owned redials say
28
35
  * `redialing`; everything else says `needs you`. */
@@ -70,12 +70,21 @@ function retryCountdown(iso, now) {
70
70
  return `${Math.max(1, Math.round(remaining / 60_000))}m`;
71
71
  return `${Math.max(1, Math.ceil(remaining / 1000))}s`;
72
72
  }
73
+ /** True for exactly the fault dispositions `faultDispositionText` renders as
74
+ * "needs you" — nothing (sdk/daemon/client) is auto-driving recovery, so the
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
77
+ * predicate the dashboard advertises instead of drifting out of sync with it
78
+ * (issue #115 — `--now` used to reject exactly the nodes shown as "needs you"). */
79
+ export function faultNeedsYou(fault) {
80
+ return fault.retry.disposition !== 'auto';
81
+ }
73
82
  /** Human-friendly active-fault disposition text. `auto` faults show the current
74
83
  * retry attempt plus the next scheduled retry; client-owned redials say
75
84
  * `redialing`; everything else says `needs you`. */
76
85
  export function faultDispositionText(fault, now = Date.now()) {
77
86
  const retry = fault.retry;
78
- if (retry.disposition === 'auto') {
87
+ if (!faultNeedsYou(fault)) {
79
88
  if (retry.by === 'client')
80
89
  return 'redialing';
81
90
  const progress = typeof retry.attempt === 'number' && typeof retry.max === 'number'
@@ -35,6 +35,16 @@ export interface LaunchSpec {
35
35
  /** Extra env injected into the pi process. */
36
36
  env: Record<string, string>;
37
37
  }
38
+ export interface ManagedWorktree {
39
+ state: 'open' | 'closed';
40
+ path: string;
41
+ branch: string;
42
+ repo_root: string;
43
+ base_ref: string;
44
+ base_sha: string;
45
+ created: string;
46
+ closed?: string;
47
+ }
38
48
  /** A node's DURABLE IDENTITY — the subset that `meta.json` persists on disk.
39
49
  * Written rarely (birth, polymorph, session-id capture, naming); never touched
40
50
  * by a status flip, an intent change, a focus swap, or a pid stamp. The db row
@@ -106,6 +116,15 @@ export interface NodeIdentity {
106
116
  * PATH is opened directly — immune to any cwd discrepancy. Null for older
107
117
  * nodes booted before this field existed → revive falls back to the bare id. */
108
118
  pi_session_file?: string | null;
119
+ /** True while a cycling (refresh-yield, `--crtr-new-cycle`) launch attempt is
120
+ * IN FLIGHT and has not yet reached `session_start` — set by reviveNode right
121
+ * before it launches a cycling engine, cleared by canvas-stophook's
122
+ * session_start handler the instant that boot is confirmed. Lets a pre-
123
+ * session_start crash-retry (crtrd.ts) detect "the dead attempt I'm retrying
124
+ * was itself a cycle" and retry AS a cycle (resume:false) instead of a strict
125
+ * resume — otherwise the retry stops after the first crash-loop attempt and
126
+ * strands the node in an empty marker branch with no kickoff. */
127
+ cycle_pending?: boolean | null;
109
128
  /** A caller-pinned model TIER (ultra/strong/medium/light) that overrides the
110
129
  * persona's declared default. Durable so it survives a polymorph: every
111
130
  * spec-rebuild path (promote/demote/lifecycle/reset) re-passes it to
@@ -114,6 +133,8 @@ export interface NodeIdentity {
114
133
  model_override?: string | null;
115
134
  /** Full pi launch recipe; rewritten on every polymorph. */
116
135
  launch?: LaunchSpec;
136
+ /** Managed git worktree owned by this node (if any). */
137
+ managed_worktree?: ManagedWorktree | null;
117
138
  }
118
139
  /** A node's LIVE RUNTIME state — authoritative in the WAL'd `nodes` row, each
119
140
  * field mutated by exactly one atomic single-statement `UPDATE` (setStatus /
@@ -134,6 +155,17 @@ export interface NodeRuntime {
134
155
  * reviveNode until the fresh broker re-records it, so the relaunch gap reads as
135
156
  * a dead pid (guarded by the daemon's revive grace, not a double-spawn). */
136
157
  pi_pid?: number | null;
158
+ /** Stable process-identity fingerprint (`pid.ts`'s `composeIdentity`) captured
159
+ * at the SAME moment `pi_pid` is recorded (`recordPid`) — the LAUNCH-time
160
+ * baseline `headlessBrokerHost.teardown()` compares `pi_pid`'s CURRENT
161
+ * identity against before signaling anything (crouter#98 review finding 1):
162
+ * a mismatch proves the OS has already recycled `pi_pid` for an unrelated
163
+ * process since this node launched it, and teardown must refuse to signal
164
+ * that stranger. `null` for a node booted before this field existed, or if
165
+ * the launch-time identity capture itself failed (fail-open — no baseline
166
+ * means no guard, not a false mismatch). Cleared to null alongside `pi_pid`
167
+ * by `clearPid` and the boot-liveness sweep (a new boot invalidates both). */
168
+ pi_pid_identity?: string | null;
137
169
  /** Presence: the tmux session (its root's home) and window this node renders
138
170
  * in while active. Cleared when the node goes done/dead and its window closes.
139
171
  * The row IS the presence registry (one atomic setPresence per move).
@@ -176,6 +208,8 @@ export interface NodeRow {
176
208
  /** Authoritative runtime columns (see NodeRuntime). */
177
209
  intent: ExitIntent;
178
210
  pi_pid: number | null;
211
+ /** See `NodeRuntime.pi_pid_identity`. */
212
+ pi_pid_identity: string | null;
179
213
  window: string | null;
180
214
  tmux_session: string | null;
181
215
  /** The durable LOCATION handle (the tmux `%pane_id`); see NodeRuntime.pane. */
@@ -6,11 +6,15 @@ function text(value) {
6
6
  }
7
7
  function classifyProviderText(message) {
8
8
  const m = message ?? '';
9
- // ORDER MATTERS: rate-limit → overloaded → connection → other.
9
+ // ORDER MATTERS: rate-limit → overloaded → auth → connection → other.
10
+ // auth precedes connection so "refresh token not found" (a dead OAuth grant)
11
+ // reads as fatal auth, not a transient not-found/connection blip.
10
12
  if (/rate.?limit|\b429\b|too many requests|quota/i.test(m))
11
13
  return 'rate-limit';
12
14
  if (/overloaded|\b529\b|\b503\b|capacity|server.{0,3}busy|temporarily unavailable/i.test(m))
13
15
  return 'overloaded';
16
+ if (/invalid_grant|refresh token|unauthori[sz]ed|invalid.?api.?key|authentication failed|\b401\b|\b403\b/i.test(m))
17
+ return 'auth';
14
18
  if (/connection|econnreset|etimedout|enotfound|econnrefused|network|fetch failed|socket hang|timed? out|timeout/i.test(m))
15
19
  return 'connection';
16
20
  return 'other';
@@ -446,6 +446,10 @@ export function buildContextBearings(nodeId) {
446
446
  const profileSection = profileNote(nodeId);
447
447
  if (profileSection !== '')
448
448
  bearings.push('', profileSection);
449
+ const wt = node?.managed_worktree;
450
+ if (wt?.state === 'open') {
451
+ bearings.push('', '## Your managed worktree', `Branch: \`${wt.branch}\`\nWorktree: \`${wt.path}\`\nBase: \`${wt.base_ref}\` @ \`${wt.base_sha}\`\nCommit work here. Land it with \`crtr worktree close\` before finishing; \`crtr push final\` is blocked until this worktree is closed.`);
452
+ }
449
453
  // Orchestrator-only: the across-cycles framing (a terminal has no future cycle).
450
454
  if (node?.mode === 'orchestrator')
451
455
  bearings.push('', orchestratorContextNote(nodeId));
@@ -3,6 +3,13 @@
3
3
  * to the installed node, shared across every canvas home. */
4
4
  export declare function brandedHostDir(): string;
5
5
  export declare function brandedHostBin(): string;
6
+ /** macOS keys FDA grants on the Mach-O cdhash. A real developer identity
7
+ * keeps that hash stable across branded-host rebuilds; ad-hoc signing is the
8
+ * portable default for machines without a local signing certificate. */
9
+ export declare function codesignIdentitiesForBrandedHost(opts?: {
10
+ configured?: string | null;
11
+ discovered?: string | null;
12
+ }): string[];
6
13
  export declare function signBrandedHostArtifacts(binPath: string, libnodePath: string | null, sign?: (path: string) => void): void;
7
14
  export declare function brandedHostWorks(binPath: string): boolean;
8
15
  /** Ensure the branded host binary exists and matches the live node; return its