@crouton-kit/crouter 0.3.14 → 0.3.15
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.
- package/dist/build-root.d.ts +8 -0
- package/dist/build-root.js +30 -0
- package/dist/builtin-personas/design/base.md +3 -7
- package/dist/builtin-personas/design/orchestrator.md +4 -3
- package/dist/builtin-personas/developer/base.md +3 -7
- package/dist/builtin-personas/developer/orchestrator.md +5 -4
- package/dist/builtin-personas/explore/base.md +3 -7
- package/dist/builtin-personas/explore/orchestrator.md +1 -5
- package/dist/builtin-personas/general/base.md +2 -4
- package/dist/builtin-personas/general/orchestrator.md +2 -4
- package/dist/builtin-personas/lifecycle/resident.md +2 -0
- package/dist/builtin-personas/lifecycle/terminal.md +6 -0
- package/dist/builtin-personas/orchestration-kernel.md +42 -3
- package/dist/builtin-personas/plan/base.md +3 -5
- package/dist/builtin-personas/plan/orchestrator.md +5 -4
- package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
- package/dist/builtin-personas/review/base.md +3 -5
- package/dist/builtin-personas/review/orchestrator.md +2 -6
- package/dist/builtin-personas/runtime-base.md +3 -19
- package/dist/builtin-personas/spec/base.md +3 -5
- package/dist/builtin-personas/spec/orchestrator.md +4 -3
- package/dist/builtin-personas/spine/has-manager.md +10 -0
- package/dist/builtin-personas/spine/no-manager.md +2 -0
- package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
- package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
- package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
- package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
- package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
- package/dist/cli.js +6 -29
- package/dist/commands/attention.js +76 -7
- package/dist/commands/canvas-prune.d.ts +2 -0
- package/dist/commands/canvas-prune.js +66 -0
- package/dist/commands/canvas.js +5 -8
- package/dist/commands/chord.d.ts +2 -0
- package/dist/commands/chord.js +143 -0
- package/dist/commands/daemon.js +8 -5
- package/dist/commands/dashboard.js +2 -0
- package/dist/commands/human/prompts.js +28 -27
- package/dist/commands/human/queue.js +30 -14
- package/dist/commands/human/shared.d.ts +26 -21
- package/dist/commands/human/shared.js +44 -66
- package/dist/commands/human.js +4 -14
- package/dist/commands/node.d.ts +11 -0
- package/dist/commands/node.js +221 -98
- package/dist/commands/pkg/market-inspect.js +6 -4
- package/dist/commands/pkg/market-manage.js +10 -6
- package/dist/commands/pkg/market.js +2 -4
- package/dist/commands/pkg/plugin-inspect.js +6 -4
- package/dist/commands/pkg/plugin-manage.js +12 -7
- package/dist/commands/pkg/plugin.js +2 -4
- package/dist/commands/pkg.js +0 -4
- package/dist/commands/push.js +178 -15
- package/dist/commands/revive.js +5 -3
- package/dist/commands/skill/author.js +6 -4
- package/dist/commands/skill/find.js +8 -5
- package/dist/commands/skill/read.js +2 -0
- package/dist/commands/skill/state.js +6 -4
- package/dist/commands/skill.js +0 -6
- package/dist/commands/sys/config.js +21 -7
- package/dist/commands/sys/doctor.js +2 -0
- package/dist/commands/sys/update.js +4 -0
- package/dist/commands/sys.js +0 -6
- package/dist/commands/tmux-spread.d.ts +2 -0
- package/dist/commands/tmux-spread.js +130 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
- package/dist/core/__tests__/child-followup.test.d.ts +1 -0
- package/dist/core/__tests__/child-followup.test.js +83 -0
- package/dist/core/__tests__/close.test.d.ts +1 -0
- package/dist/core/__tests__/close.test.js +148 -0
- package/dist/core/__tests__/context-intro.test.d.ts +1 -0
- package/dist/core/__tests__/context-intro.test.js +196 -0
- package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-boot.test.js +93 -0
- package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-liveness.test.js +223 -0
- package/dist/core/__tests__/focuses.test.d.ts +1 -0
- package/dist/core/__tests__/focuses.test.js +259 -0
- package/dist/core/__tests__/fork.test.d.ts +1 -0
- package/dist/core/__tests__/fork.test.js +91 -0
- package/dist/core/__tests__/home-session.test.d.ts +1 -0
- package/dist/core/__tests__/home-session.test.js +153 -0
- package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
- package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
- package/dist/core/__tests__/keystone.test.d.ts +1 -0
- package/dist/core/__tests__/keystone.test.js +185 -0
- package/dist/core/__tests__/kickoff.test.d.ts +1 -0
- package/dist/core/__tests__/kickoff.test.js +89 -0
- package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
- package/dist/core/__tests__/lifecycle.test.js +178 -0
- package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
- package/dist/core/__tests__/listing-completeness.test.js +31 -0
- package/dist/core/__tests__/memory.test.d.ts +1 -0
- package/dist/core/__tests__/memory.test.js +152 -0
- package/dist/core/__tests__/migration.test.d.ts +1 -0
- package/dist/core/__tests__/migration.test.js +238 -0
- package/dist/core/__tests__/pane-column.test.d.ts +1 -0
- package/dist/core/__tests__/pane-column.test.js +153 -0
- package/dist/core/__tests__/passive-subscription.test.js +24 -1
- package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
- package/dist/core/__tests__/persona-compose.test.js +53 -0
- package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
- package/dist/core/__tests__/persona-subkind.test.js +62 -0
- package/dist/core/__tests__/persona.test.d.ts +1 -0
- package/dist/core/__tests__/persona.test.js +107 -0
- package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
- package/dist/core/__tests__/placement-focus.test.js +244 -0
- package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
- package/dist/core/__tests__/placement-reconcile.test.js +212 -0
- package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
- package/dist/core/__tests__/placement-revive.test.js +238 -0
- package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/placement-teardown.test.js +183 -0
- package/dist/core/__tests__/prune.test.d.ts +1 -0
- package/dist/core/__tests__/prune.test.js +116 -0
- package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
- package/dist/core/__tests__/push-final-guard.test.js +71 -0
- package/dist/core/__tests__/relaunch.test.d.ts +1 -0
- package/dist/core/__tests__/relaunch.test.js +328 -0
- package/dist/core/__tests__/reset.test.js +26 -7
- package/dist/core/__tests__/revive.test.d.ts +1 -0
- package/dist/core/__tests__/revive.test.js +217 -0
- package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
- package/dist/core/__tests__/spawn-root.test.js +73 -0
- package/dist/core/__tests__/steer-note.test.d.ts +1 -0
- package/dist/core/__tests__/steer-note.test.js +39 -0
- package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
- package/dist/core/__tests__/stop-guard.test.js +82 -0
- package/dist/core/__tests__/subcommand-tier.test.js +35 -33
- package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
- package/dist/core/__tests__/tmux-surface.test.js +106 -0
- package/dist/core/__tests__/unknown-path.test.js +8 -2
- package/dist/core/canvas/attention.d.ts +10 -0
- package/dist/core/canvas/attention.js +40 -0
- package/dist/core/canvas/canvas.d.ts +66 -7
- package/dist/core/canvas/canvas.js +209 -21
- package/dist/core/canvas/db.d.ts +8 -0
- package/dist/core/canvas/db.js +206 -4
- package/dist/core/canvas/focuses.d.ts +22 -0
- package/dist/core/canvas/focuses.js +80 -0
- package/dist/core/canvas/index.d.ts +3 -0
- package/dist/core/canvas/index.js +3 -0
- package/dist/core/canvas/labels.d.ts +27 -0
- package/dist/core/canvas/labels.js +36 -0
- package/dist/core/canvas/render.js +25 -10
- package/dist/core/canvas/telemetry.d.ts +14 -0
- package/dist/core/canvas/telemetry.js +35 -0
- package/dist/core/canvas/types.d.ts +115 -12
- package/dist/core/command.d.ts +25 -1
- package/dist/core/command.js +23 -15
- package/dist/core/config.js +36 -2
- package/dist/core/feed/feed.js +3 -3
- package/dist/core/feed/inbox.d.ts +3 -1
- package/dist/core/feed/inbox.js +45 -5
- package/dist/core/feed/passive.js +24 -11
- package/dist/core/help.d.ts +26 -13
- package/dist/core/help.js +44 -37
- package/dist/core/personas/index.d.ts +1 -1
- package/dist/core/personas/index.js +1 -1
- package/dist/core/personas/loader.d.ts +40 -1
- package/dist/core/personas/loader.js +63 -1
- package/dist/core/personas/resolve.d.ts +13 -6
- package/dist/core/personas/resolve.js +46 -34
- package/dist/core/runtime/bearings.d.ts +20 -0
- package/dist/core/runtime/bearings.js +92 -0
- package/dist/core/runtime/close.d.ts +14 -0
- package/dist/core/runtime/close.js +151 -0
- package/dist/core/runtime/demote.js +27 -10
- package/dist/core/runtime/front-door.js +1 -1
- package/dist/core/runtime/kickoff.d.ts +23 -6
- package/dist/core/runtime/kickoff.js +92 -36
- package/dist/core/runtime/launch.d.ts +24 -12
- package/dist/core/runtime/launch.js +75 -19
- package/dist/core/runtime/lifecycle.d.ts +13 -0
- package/dist/core/runtime/lifecycle.js +86 -0
- package/dist/core/runtime/memory.d.ts +43 -0
- package/dist/core/runtime/memory.js +165 -0
- package/dist/core/runtime/naming.d.ts +22 -0
- package/dist/core/runtime/naming.js +166 -0
- package/dist/core/runtime/nodes.d.ts +32 -1
- package/dist/core/runtime/nodes.js +60 -10
- package/dist/core/runtime/persona.d.ts +25 -0
- package/dist/core/runtime/persona.js +139 -0
- package/dist/core/runtime/placement.d.ts +287 -0
- package/dist/core/runtime/placement.js +663 -0
- package/dist/core/runtime/presence.d.ts +7 -15
- package/dist/core/runtime/presence.js +90 -66
- package/dist/core/runtime/promote.d.ts +14 -7
- package/dist/core/runtime/promote.js +57 -67
- package/dist/core/runtime/reset.d.ts +47 -4
- package/dist/core/runtime/reset.js +223 -52
- package/dist/core/runtime/revive.d.ts +26 -2
- package/dist/core/runtime/revive.js +166 -39
- package/dist/core/runtime/spawn.d.ts +20 -5
- package/dist/core/runtime/spawn.js +163 -43
- package/dist/core/runtime/stop-guard.d.ts +1 -1
- package/dist/core/runtime/stop-guard.js +18 -8
- package/dist/core/runtime/tmux.d.ts +100 -14
- package/dist/core/runtime/tmux.js +201 -28
- package/dist/core/spawn.js +15 -0
- package/dist/daemon/crtrd.d.ts +12 -1
- package/dist/daemon/crtrd.js +152 -34
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
- package/dist/pi-extensions/canvas-commands.js +16 -13
- package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
- package/dist/pi-extensions/canvas-context-intro.js +164 -0
- package/dist/pi-extensions/canvas-goal-capture.d.ts +3 -0
- package/dist/pi-extensions/canvas-goal-capture.js +15 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
- package/dist/pi-extensions/canvas-nav.d.ts +12 -4
- package/dist/pi-extensions/canvas-nav.js +586 -262
- package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
- package/dist/pi-extensions/canvas-stophook.js +344 -228
- package/dist/types.d.ts +28 -0
- package/dist/types.js +16 -0
- package/package.json +1 -1
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// root) or switch-client + select-window (across roots). done/dead nodes close
|
|
9
9
|
// their window; reviving opens a fresh one.
|
|
10
10
|
import { spawn, spawnSync } from 'node:child_process';
|
|
11
|
+
import { readConfig } from '../config.js';
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
12
13
|
// Shell quoting + tmux invocation
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
@@ -69,7 +70,8 @@ function envFlags(env) {
|
|
|
69
70
|
}
|
|
70
71
|
/** Open a background window for a node and run `command` in it. `-d` keeps it
|
|
71
72
|
* detached so it doesn't steal focus or become the current window. Returns the
|
|
72
|
-
* new window id
|
|
73
|
+
* new window id AND the pane id it created (the durable `%pane_id`, LOCATION's
|
|
74
|
+
* anchor) — callers that only need the window destructure `.window`.
|
|
73
75
|
*
|
|
74
76
|
* Target is `${session}:` (trailing colon = the session, no window index) plus
|
|
75
77
|
* `-a` (insert after the current window) so tmux allocates the next free index.
|
|
@@ -78,7 +80,8 @@ function envFlags(env) {
|
|
|
78
80
|
* "create window failed: index N in use" whenever the active window is not the
|
|
79
81
|
* last one (common when base-index is 0 but the live window sits at index 1).
|
|
80
82
|
* `-a` also keeps node windows off index 0, which is reserved for the optional
|
|
81
|
-
* dashboard.
|
|
83
|
+
* dashboard. The explicit `-t ${session}:` target is the §2.2 HARD DRIVER
|
|
84
|
+
* INVARIANT — never let new-window fall back to tmux's global current session. */
|
|
82
85
|
export function openNodeWindow(opts) {
|
|
83
86
|
const r = tmux([
|
|
84
87
|
'new-window',
|
|
@@ -86,7 +89,7 @@ export function openNodeWindow(opts) {
|
|
|
86
89
|
'-a',
|
|
87
90
|
'-P',
|
|
88
91
|
'-F',
|
|
89
|
-
'#{window_id}',
|
|
92
|
+
'#{window_id}\t#{pane_id}',
|
|
90
93
|
'-t',
|
|
91
94
|
`${opts.session}:`,
|
|
92
95
|
'-n',
|
|
@@ -96,7 +99,40 @@ export function openNodeWindow(opts) {
|
|
|
96
99
|
...envFlags(opts.env),
|
|
97
100
|
opts.command,
|
|
98
101
|
]);
|
|
99
|
-
|
|
102
|
+
if (!r.ok)
|
|
103
|
+
return null;
|
|
104
|
+
const [window, pane] = r.stdout.split('\t');
|
|
105
|
+
if (window === undefined || window === '' || pane === undefined || pane === '') {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return { window, pane };
|
|
109
|
+
}
|
|
110
|
+
/** Split `targetPane`'s window, opening a NEW pane beside it running `command`,
|
|
111
|
+
* and return the new pane id (the durable `%id`). The ONLY new-pane-beside verb
|
|
112
|
+
* (Q3: a focus opened side-by-side). `-d` keeps the caller's pane active; `-h`
|
|
113
|
+
* makes the split side-by-side (left/right), the default for a focus viewport.
|
|
114
|
+
*
|
|
115
|
+
* §2.2 HARD DRIVER INVARIANT: `targetPane` is REQUIRED — a bare `split-window`
|
|
116
|
+
* would split tmux's global current pane, which can leak a pane into an
|
|
117
|
+
* unrelated user session (the exact bug this design kills). The explicit
|
|
118
|
+
* `-t <targetPane>` makes the destination structurally un-leakable. Returns
|
|
119
|
+
* null if tmux fails. */
|
|
120
|
+
export function splitWindow(targetPane, opts) {
|
|
121
|
+
const r = tmux([
|
|
122
|
+
'split-window',
|
|
123
|
+
'-d',
|
|
124
|
+
...(opts.vertical === true ? [] : ['-h']),
|
|
125
|
+
'-P',
|
|
126
|
+
'-F',
|
|
127
|
+
'#{pane_id}',
|
|
128
|
+
'-t',
|
|
129
|
+
targetPane,
|
|
130
|
+
'-c',
|
|
131
|
+
opts.cwd,
|
|
132
|
+
...envFlags(opts.env),
|
|
133
|
+
opts.command,
|
|
134
|
+
]);
|
|
135
|
+
return r.ok && r.stdout !== '' ? r.stdout : null;
|
|
100
136
|
}
|
|
101
137
|
/** Bring a node's window forefront. Switches client across roots when needed. */
|
|
102
138
|
export function focusWindow(session, window) {
|
|
@@ -112,6 +148,14 @@ export function focusWindow(session, window) {
|
|
|
112
148
|
export function closeWindow(window) {
|
|
113
149
|
return tmux(['kill-window', '-t', window]).ok;
|
|
114
150
|
}
|
|
151
|
+
/** Close a single PANE. Its window closes automatically once this was the last
|
|
152
|
+
* pane, but sibling panes survive — so co-located nodes (several agents sharing
|
|
153
|
+
* one window via swap-pane focus) are torn down one at a time instead of all
|
|
154
|
+
* at once by a window kill. Pane ids are the stable vehicle handle; windows
|
|
155
|
+
* shift under swap-pane focus, so pane-granular teardown is the correct unit. */
|
|
156
|
+
export function closePane(pane) {
|
|
157
|
+
return tmux(['kill-pane', '-t', pane]).ok;
|
|
158
|
+
}
|
|
115
159
|
/** The active pane id of a window. Node windows are single-pane, so this is the
|
|
116
160
|
* node's pane. Returns null if the window is gone or tmux fails. */
|
|
117
161
|
export function paneOfWindow(session, window) {
|
|
@@ -126,8 +170,11 @@ export function windowOfPane(pane) {
|
|
|
126
170
|
const r = tmux(['display-message', '-p', '-t', pane, '#{window_id}']);
|
|
127
171
|
return r.ok && r.stdout !== '' ? r.stdout : null;
|
|
128
172
|
}
|
|
129
|
-
/** The session + window a pane currently lives in
|
|
130
|
-
*
|
|
173
|
+
/** The session + window a pane currently lives in (`display-message -p -t %id`).
|
|
174
|
+
* The §2.4 reconciliation read-back: resolve a node's/focus's CURRENT
|
|
175
|
+
* window/session from its durable pane id before any act, so crtr follows a
|
|
176
|
+
* manual `move-pane`/`join-pane`/`break-pane` instead of fighting it. Null if
|
|
177
|
+
* the pane is gone or tmux fails. */
|
|
131
178
|
export function paneLocation(pane) {
|
|
132
179
|
const r = tmux(['display-message', '-p', '-t', pane, '#{session_name}\t#{window_id}']);
|
|
133
180
|
if (!r.ok)
|
|
@@ -137,6 +184,31 @@ export function paneLocation(pane) {
|
|
|
137
184
|
return null;
|
|
138
185
|
return { session, window };
|
|
139
186
|
}
|
|
187
|
+
/** Does this pane id still exist? A `display-message` probe on the `%id` — the
|
|
188
|
+
* v3 PRIMARY liveness probe (§1.2/§2.2), replacing window-existence so a user
|
|
189
|
+
* moving a pane to another window/session never reads as "gone". True iff tmux
|
|
190
|
+
* knows the pane.
|
|
191
|
+
*
|
|
192
|
+
* NOTE: `display-message -p -t <gone-pane>` EXITS 0 with EMPTY output (it does
|
|
193
|
+
* not error on an unresolvable pane target) — so an `.ok` check alone would
|
|
194
|
+
* report a dead pane as alive, defeating the whole point of pane-existence
|
|
195
|
+
* liveness. We therefore require the echoed `#{pane_id}` to equal the requested
|
|
196
|
+
* pane: a live pane echoes its own id, a gone/bogus one yields empty. */
|
|
197
|
+
export function paneExists(pane) {
|
|
198
|
+
const r = tmux(['display-message', '-p', '-t', pane, '#{pane_id}']);
|
|
199
|
+
return r.ok && r.stdout === pane;
|
|
200
|
+
}
|
|
201
|
+
/** Relocate a pane into another session as its own window WITHOUT killing the
|
|
202
|
+
* process in it — `break-pane -d` moves the pane out of its current window (the
|
|
203
|
+
* pi keeps generating) into a fresh window in `session`; `-d` leaves the caller's
|
|
204
|
+
* client where it is rather than following the pane to the background, and `-a`
|
|
205
|
+
* allocates the next free window index (same dodge as openNodeWindow). The
|
|
206
|
+
* "detach to background" driver behind `node lifecycle --detach`. Best-effort;
|
|
207
|
+
* false if tmux refuses (e.g. the pane is gone). The caller reconciles presence
|
|
208
|
+
* so the canvas follows the move. */
|
|
209
|
+
export function breakPaneToSession(pane, session) {
|
|
210
|
+
return tmux(['break-pane', '-d', '-a', '-s', pane, '-t', `${session}:`]).ok;
|
|
211
|
+
}
|
|
140
212
|
/** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
|
|
141
213
|
* caller's window active, so the target's pane appears where the caller is
|
|
142
214
|
* rather than navigating the client off to the target's window. The caller's
|
|
@@ -147,26 +219,34 @@ export function swapPaneInPlace(targetPane, callerPane) {
|
|
|
147
219
|
return true;
|
|
148
220
|
return tmux(['swap-pane', '-d', '-s', targetPane, '-t', callerPane]).ok;
|
|
149
221
|
}
|
|
150
|
-
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
222
|
+
/** The `respawn-pane -k` argv for `opts`. `-k` kills the pane's current process
|
|
223
|
+
* (e.g. a yielding pi) and re-execs `command` in the SAME pane, preserving its
|
|
224
|
+
* `%id` (§1.5 F3: a frozen focus pane resumes in place, no new window). The
|
|
225
|
+
* explicit `-t opts.pane` is the §2.2 HARD DRIVER INVARIANT — respawn must name
|
|
226
|
+
* its target pane, never tmux's global current pane. */
|
|
227
|
+
function respawnPaneArgs(opts) {
|
|
228
|
+
return [
|
|
229
|
+
'respawn-pane',
|
|
230
|
+
'-k',
|
|
231
|
+
'-c',
|
|
232
|
+
opts.cwd,
|
|
233
|
+
...envFlags(opts.env),
|
|
234
|
+
'-t',
|
|
235
|
+
opts.pane,
|
|
236
|
+
opts.command,
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
/** Re-exec a command in an EXISTING pane, in place — DETACHED. Spawned in its own
|
|
240
|
+
* process group (unref'd) so the request reaches the tmux server even though
|
|
241
|
+
* `-k` tears down the caller's own pi mid-flight. Used when a node respawns ITS
|
|
242
|
+
* OWN pane (refresh-yield): the dispatch can't be awaited because it kills the
|
|
243
|
+
* awaiter. Returns true once the request was dispatched. */
|
|
244
|
+
export function respawnPaneDetached(opts) {
|
|
159
245
|
try {
|
|
160
|
-
const child = spawn('tmux',
|
|
161
|
-
|
|
162
|
-
'
|
|
163
|
-
|
|
164
|
-
opts.cwd,
|
|
165
|
-
...envFlags(opts.env),
|
|
166
|
-
'-t',
|
|
167
|
-
opts.pane,
|
|
168
|
-
opts.command,
|
|
169
|
-
], { detached: true, stdio: 'ignore' });
|
|
246
|
+
const child = spawn('tmux', respawnPaneArgs(opts), {
|
|
247
|
+
detached: true,
|
|
248
|
+
stdio: 'ignore',
|
|
249
|
+
});
|
|
170
250
|
child.unref();
|
|
171
251
|
return true;
|
|
172
252
|
}
|
|
@@ -174,6 +254,19 @@ export function respawnPane(opts) {
|
|
|
174
254
|
return false;
|
|
175
255
|
}
|
|
176
256
|
}
|
|
257
|
+
/** Re-exec a command in an EXISTING pane, in place — SYNCHRONOUS. Runs the
|
|
258
|
+
* `respawn-pane` to completion and reports the real exit status. Used when the
|
|
259
|
+
* caller is NOT the pane being respawned (e.g. the daemon resuming a frozen
|
|
260
|
+
* focus pane), so it can confirm the respawn landed. Returns true on success. */
|
|
261
|
+
export function respawnPaneSync(opts) {
|
|
262
|
+
return tmux(respawnPaneArgs(opts)).ok;
|
|
263
|
+
}
|
|
264
|
+
/** @deprecated Use respawnPaneDetached. Retained so existing refresh-yield
|
|
265
|
+
* callers stay green while the placement layer migrates onto the explicit
|
|
266
|
+
* sync/detached split. */
|
|
267
|
+
export function respawnPane(opts) {
|
|
268
|
+
return respawnPaneDetached(opts);
|
|
269
|
+
}
|
|
177
270
|
// ---------------------------------------------------------------------------
|
|
178
271
|
// pi command assembly
|
|
179
272
|
// ---------------------------------------------------------------------------
|
|
@@ -216,12 +309,55 @@ export function switchClient(session) {
|
|
|
216
309
|
return tmux(['switch-client', '-t', session]).ok;
|
|
217
310
|
}
|
|
218
311
|
// ---------------------------------------------------------------------------
|
|
312
|
+
// Multi-pane layout (used by `canvas tmux-spread`)
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
/** Move a source pane into a destination window (`tmux join-pane`). The source
|
|
315
|
+
* pane's running process (e.g. a child's live pi) is preserved; its now-empty
|
|
316
|
+
* source window auto-closes. Best-effort; false if tmux fails. */
|
|
317
|
+
export function joinPane(srcPane, dstWindow) {
|
|
318
|
+
return tmux(['join-pane', '-s', srcPane, '-t', dstWindow]).ok;
|
|
319
|
+
}
|
|
320
|
+
/** Apply a named tmux layout to a window (`tmux select-layout`). Use
|
|
321
|
+
* `main-vertical` for one wide pane on the left + the rest stacked right.
|
|
322
|
+
* Best-effort; never throws. */
|
|
323
|
+
export function selectLayout(window, layout) {
|
|
324
|
+
return tmux(['select-layout', '-t', window, layout]).ok;
|
|
325
|
+
}
|
|
326
|
+
/** Set a tmux window option (`tmux set-window-option`). Used to size the main
|
|
327
|
+
* pane (`main-pane-width`) before a main-vertical layout. Best-effort. */
|
|
328
|
+
export function setWindowOption(window, name, value) {
|
|
329
|
+
return tmux(['set-window-option', '-t', window, name, value]).ok;
|
|
330
|
+
}
|
|
331
|
+
/** Toggle `remain-on-exit` on a window (§1.5 F3). `on` keeps a focus pane on
|
|
332
|
+
* screen after its pi exits — the viewport survives (F1), the final transcript
|
|
333
|
+
* is preserved, and `respawn-pane -k` can resurrect the node into the SAME pane
|
|
334
|
+
* id. NOTE (§1.5/§2.5, spike-confirmed): a dead/frozen pane is reaped only by
|
|
335
|
+
* `kill-pane`/`respawn-pane`, NEVER by toggling this off — the toggle does not
|
|
336
|
+
* reap an already-dead pane. Best-effort; never throws. */
|
|
337
|
+
export function setRemainOnExit(window, on) {
|
|
338
|
+
return tmux(['set-window-option', '-t', window, 'remain-on-exit', on ? 'on' : 'off']).ok;
|
|
339
|
+
}
|
|
340
|
+
/** Type a literal (e.g. a `/graph` slash command) into a pane and press Enter
|
|
341
|
+
* (`tmux send-keys -t <pane> '<text>' Enter`). Requires the pane's editor be
|
|
342
|
+
* empty, same limitation as the menu's `/promote` item. Best-effort. */
|
|
343
|
+
export function sendKeysEnter(pane, text) {
|
|
344
|
+
return tmux(['send-keys', '-t', pane, text, 'Enter']).ok;
|
|
345
|
+
}
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
219
347
|
// Prefix menu — Alt+C opens a which-key-style tmux display-menu of crouter
|
|
220
348
|
// actions. Installed on the running server at root boot; idempotent (a re-bind
|
|
221
349
|
// overwrites the previous one). Items shell out to `crtr`, passing the active
|
|
222
350
|
// pane so an action targets the agent currently in front of you.
|
|
223
351
|
// ---------------------------------------------------------------------------
|
|
224
|
-
/**
|
|
352
|
+
/** Reserved mnemonic keys owned by the built-in menu items below — a custom
|
|
353
|
+
* `prefixBind` may not claim these (the built-in item wins). */
|
|
354
|
+
const RESERVED_MENU_KEYS = new Set(['o', 'd', 'D', 'x', 'b']);
|
|
355
|
+
/** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails.
|
|
356
|
+
* The built-in items (promote/demote/detach/close/browse) are static; the canvas-nav
|
|
357
|
+
* chords (graph/manager/expand/report-N + any custom prefixBind) are appended
|
|
358
|
+
* from `canvasNav.prefixBinds`, each routed through `crtr canvas chord` (or, for
|
|
359
|
+
* the `__graph__` sentinel, a `send-keys '/graph'`) so the menu stays static
|
|
360
|
+
* while behaviour is config-driven. */
|
|
225
361
|
export function installMenuBinding() {
|
|
226
362
|
const sess = nodeSession();
|
|
227
363
|
const title = ' crtr ';
|
|
@@ -230,9 +366,46 @@ export function installMenuBinding() {
|
|
|
230
366
|
// the slash command delivers the orchestration guidance into the node's
|
|
231
367
|
// context, which a bare `run-shell` (output discarded) could not.
|
|
232
368
|
{ name: 'promote to orchestrator', key: 'o', cmd: `send-keys -t '#{pane_id}' '/promote' Enter` },
|
|
233
|
-
|
|
234
|
-
|
|
369
|
+
// `d` demotes the agent to TERMINAL in place: no finalize, no kill — it keeps
|
|
370
|
+
// running where it is, and because it is now terminal it is forced to push a
|
|
371
|
+
// final up the spine when it finishes. `D` ALSO detaches it to the background
|
|
372
|
+
// `crtr` session (frees the pane; the pi keeps generating). Neither ends it.
|
|
373
|
+
{ name: 'demote to terminal', key: 'd', cmd: `run-shell "crtr node lifecycle terminal --pane '#{pane_id}' >/dev/null 2>&1"` },
|
|
374
|
+
{ name: 'detach to background', key: 'D', cmd: `run-shell "crtr node lifecycle terminal --pane '#{pane_id}' --detach >/dev/null 2>&1"` },
|
|
375
|
+
// Close cascades down the subscribes_to spine (kills the subtree's windows,
|
|
376
|
+
// marks them canceled); revivable. Output discarded — the keypress just acts.
|
|
377
|
+
{ name: 'close agent + subtree', key: 'x', cmd: `run-shell "crtr node close --pane '#{pane_id}' >/dev/null 2>&1"` },
|
|
378
|
+
// Re-keyed g→b so `g` is free for the canvas-nav GRAPH toggle (below).
|
|
379
|
+
{ name: 'browse background agents', key: 'b', cmd: `switch-client -t ${sess}` },
|
|
235
380
|
];
|
|
381
|
+
// Canvas-nav chords from config (default: g→graph, m→manager, e→expand). The
|
|
382
|
+
// `__graph__` sentinel toggles the in-pi GRAPH modal via send-keys; every
|
|
383
|
+
// other bind shells the chord dispatcher, which resolves the pane's node and
|
|
384
|
+
// interpolates the bind at popup time. Keys colliding with the built-ins are
|
|
385
|
+
// skipped (the built-in wins).
|
|
386
|
+
let prefixBinds = {};
|
|
387
|
+
try {
|
|
388
|
+
prefixBinds = readConfig('user').canvasNav.prefixBinds;
|
|
389
|
+
}
|
|
390
|
+
catch { /* defaults below */ }
|
|
391
|
+
for (const [key, bind] of Object.entries(prefixBinds)) {
|
|
392
|
+
if (key.length !== 1 || RESERVED_MENU_KEYS.has(key))
|
|
393
|
+
continue;
|
|
394
|
+
const name = bind.desc !== undefined && bind.desc !== '' ? bind.desc : `chord ${key}`;
|
|
395
|
+
const cmd = bind.run === '__graph__'
|
|
396
|
+
? `send-keys -t '#{pane_id}' '/graph' Enter`
|
|
397
|
+
: `run-shell "crtr canvas chord --pane '#{pane_id}' --key ${key} >/dev/null 2>&1"`;
|
|
398
|
+
items.push({ name, key, cmd });
|
|
399
|
+
}
|
|
400
|
+
// Focus report N: nine generated chord items (1..9), each resolved by the
|
|
401
|
+
// dispatcher to subscriptionsOf(self)[N-1] at popup time.
|
|
402
|
+
for (let n = 1; n <= 9; n++) {
|
|
403
|
+
items.push({
|
|
404
|
+
name: `focus report ${n}`,
|
|
405
|
+
key: `${n}`,
|
|
406
|
+
cmd: `run-shell "crtr canvas chord --pane '#{pane_id}' --key ${n} >/dev/null 2>&1"`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
236
409
|
// tmux's -x sets the menu's LEFT edge. To sit the box INSIDE the pane's
|
|
237
410
|
// top-right corner, shift x left by the box width (longest line + tmux chrome:
|
|
238
411
|
// borders + padding + the right-aligned mnemonic-key column) via format math.
|
package/dist/core/spawn.js
CHANGED
|
@@ -78,6 +78,21 @@ export function spawnAndDetach(opts) {
|
|
|
78
78
|
return { status: 'spawn-failed', message: msg };
|
|
79
79
|
}
|
|
80
80
|
const paneId = split.stdout.trim();
|
|
81
|
+
// Force `remain-on-exit off` at PANE scope on the new pane. remain-on-exit is
|
|
82
|
+
// a pane option (tmux 3.x) inherited from the window-scoped value, and the
|
|
83
|
+
// canvas runtime arms `remain-on-exit on` on a node's vehicle/focus WINDOW
|
|
84
|
+
// (F3 freeze, see runtime/tmux.ts setRemainOnExit). A split-window pane opened
|
|
85
|
+
// into that window inherits the `on`, so the humanloop TUI pane would linger
|
|
86
|
+
// as a dead pane ("pane is dead (status 0, …)") when `crtr human _run` exits 0
|
|
87
|
+
// instead of closing. Overriding at pane scope destroys this pane on clean
|
|
88
|
+
// exit WITHOUT touching the window's value (focus freeze still works) or the
|
|
89
|
+
// user's global config. Best-effort: harmless no-op on tmux where the option
|
|
90
|
+
// is window-only.
|
|
91
|
+
if (paneId !== '') {
|
|
92
|
+
spawnSync('tmux', ['set-option', '-p', '-t', paneId, 'remain-on-exit', 'off'], {
|
|
93
|
+
stdio: 'ignore',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
81
96
|
// Schedule self-kill of the originating pane.
|
|
82
97
|
scheduleKillCurrentPane(opts.killAfterSeconds);
|
|
83
98
|
return {
|
package/dist/daemon/crtrd.d.ts
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
+
export type LivenessVerdict = 'leave' | 'pending' | 'revive';
|
|
2
|
+
/** Decide what to do with a node whose tmux pane is alive, from its pi
|
|
3
|
+
* liveness and how long it's been dead. Pure — the time-and-tmux side effects
|
|
4
|
+
* live in handleLiveWindow; this is the unit-testable core.
|
|
5
|
+
* piPidAlive: true=alive, false=dead, null=no pid recorded (legacy node, or a
|
|
6
|
+
* relaunch in flight) — leave those to the pane-gone pass.
|
|
7
|
+
* deadFor: ms since first observed dead, or null on the first observation. */
|
|
8
|
+
export declare function livenessVerdict(piPidAlive: boolean | null, deadFor: number | null): LivenessVerdict;
|
|
1
9
|
/** Read the pid stored in the pidfile, or null if absent / malformed. */
|
|
2
10
|
export declare function readPidfile(): number | null;
|
|
3
|
-
/** True if a process with `pid` is currently alive (signal-0 probe).
|
|
11
|
+
/** True if a process with `pid` is currently alive (signal-0 probe). `kill(pid,
|
|
12
|
+
* 0)` throws ESRCH when the process is gone; EPERM means it exists but isn't
|
|
13
|
+
* ours — still alive. */
|
|
4
14
|
export declare function isPidAlive(pid: number): boolean;
|
|
5
15
|
/** True when a crtrd process is already running (pidfile exists + pid alive). */
|
|
6
16
|
export declare function isDaemonRunning(): boolean;
|
|
17
|
+
export declare function superviseTick(now?: number): Promise<void>;
|
|
7
18
|
export interface DaemonOpts {
|
|
8
19
|
/** Milliseconds between supervision polls. Default 2000. */
|
|
9
20
|
intervalMs?: number;
|
package/dist/daemon/crtrd.js
CHANGED
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
// Sole responsibility: supervise tmux window exit and revive nodes. No
|
|
4
4
|
// orchestration logic lives here. The daemon is a process-lifecycle watcher.
|
|
5
5
|
//
|
|
6
|
-
// Model
|
|
6
|
+
// Model (v3: liveness is PANE-existence, not window-existence — a manual
|
|
7
|
+
// move-pane/join-pane/break-pane must never read as a node death)
|
|
7
8
|
// • Poll every intervalMs (default 2000ms).
|
|
8
|
-
// • For each active|idle node: check whether its tmux
|
|
9
|
-
//
|
|
10
|
-
// •
|
|
11
|
-
//
|
|
9
|
+
// • For each active|idle node: check whether its tmux PANE is still alive
|
|
10
|
+
// (isNodePaneAlive; window-existence is only a legacy/no-pane fallback).
|
|
11
|
+
// • Pane alive → reconcile its LOCATION (follow any manual move; lazy-backfill
|
|
12
|
+
// a legacy row's pane), then judge pi liveness — healthy, skip otherwise.
|
|
13
|
+
// • Pane gone + intent==='refresh' → fresh respawn (node asked to yield).
|
|
14
|
+
// • Pane gone + intent==='idle-release' → node freed its own pane while
|
|
12
15
|
// dormant; clear the stale window ref and revive (resume) when its inbox
|
|
13
16
|
// gains an unseen entry.
|
|
14
|
-
// •
|
|
17
|
+
// • Pane gone + any other intent → crash: mark 'dead'.
|
|
15
18
|
// • Nodes with no tmux placement (inline roots) are skipped.
|
|
16
19
|
//
|
|
17
20
|
// Single-instance guarantee
|
|
@@ -21,11 +24,91 @@
|
|
|
21
24
|
import { writeFileSync, readFileSync, rmSync, existsSync, mkdirSync, } from 'node:fs';
|
|
22
25
|
import { join } from 'node:path';
|
|
23
26
|
import { crtrHome } from '../core/canvas/paths.js';
|
|
24
|
-
import { listNodes,
|
|
25
|
-
import {
|
|
27
|
+
import { listNodes, getRow, setPresence, getNode, } from '../core/canvas/index.js';
|
|
28
|
+
import { transition } from '../core/runtime/lifecycle.js';
|
|
29
|
+
import { isNodePaneAlive, reconcile } from '../core/runtime/placement.js';
|
|
26
30
|
import { reviveNode } from '../core/runtime/revive.js';
|
|
31
|
+
import { pushUrgent } from '../core/feed/feed.js';
|
|
27
32
|
import { readInboxSince, readCursor } from '../core/feed/inbox.js';
|
|
33
|
+
/** Surface a vehicle that never booted.
|
|
34
|
+
*
|
|
35
|
+
* `spawnChild` returns status="active" the instant the tmux window opens — it
|
|
36
|
+
* does NOT wait for pi to come up, because boot is inherently slow (and slower
|
|
37
|
+
* under load) and racing it would either block the spawner or false-fail a
|
|
38
|
+
* slow-but-healthy launch. The cost of that optimism: a pi that dies before its
|
|
39
|
+
* first session_start (so `pi_session_id` was never recorded) is invisible —
|
|
40
|
+
* the parent believes its child is running. When the daemon later finds the
|
|
41
|
+
* pane gone with no session ever bound, it errors LOUDLY up the spine: an
|
|
42
|
+
* urgent push so the parent learns the child failed to launch instead of just
|
|
43
|
+
* seeing a silent `dead`. */
|
|
44
|
+
async function surfaceBootFailure(meta) {
|
|
45
|
+
const body = `⚠ Spawn failed — \`${meta.name}\` (${meta.kind}) never started.\n\n` +
|
|
46
|
+
`Its pi vehicle exited before the session came up (no pi_session_id was ever ` +
|
|
47
|
+
`recorded), so the node produced no output. This is almost always a transient ` +
|
|
48
|
+
`launch failure — e.g. resource pressure when several nodes boot at once — not ` +
|
|
49
|
+
`a fault in the task itself.\n\n` +
|
|
50
|
+
`If the work still needs doing, re-spawn it; if spawns keep dying, spawn fewer at a time.`;
|
|
51
|
+
await pushUrgent(meta.node_id, body, { from: meta.node_id });
|
|
52
|
+
}
|
|
28
53
|
const DEFAULT_INTERVAL_MS = 2000;
|
|
54
|
+
// How long a node's pi may be observed dead-while-its-window-lives before the
|
|
55
|
+
// daemon revives it. MUST exceed worst-case pi boot time: a normal in-place
|
|
56
|
+
// refresh (reviveInPlace) transiently shows a dead OLD pid for the gap between
|
|
57
|
+
// the old pi dying and the fresh pi booting + re-recording its pid, and we must
|
|
58
|
+
// not double-spawn into that gap.
|
|
59
|
+
const REVIVE_GRACE_MS = 20_000;
|
|
60
|
+
// Per-node first-observed-dead timestamps, for the grace window above. In-memory
|
|
61
|
+
// only — a daemon restart resets it (worst case: one extra grace interval).
|
|
62
|
+
const unhealthySince = new Map();
|
|
63
|
+
/** Decide what to do with a node whose tmux pane is alive, from its pi
|
|
64
|
+
* liveness and how long it's been dead. Pure — the time-and-tmux side effects
|
|
65
|
+
* live in handleLiveWindow; this is the unit-testable core.
|
|
66
|
+
* piPidAlive: true=alive, false=dead, null=no pid recorded (legacy node, or a
|
|
67
|
+
* relaunch in flight) — leave those to the pane-gone pass.
|
|
68
|
+
* deadFor: ms since first observed dead, or null on the first observation. */
|
|
69
|
+
export function livenessVerdict(piPidAlive, deadFor) {
|
|
70
|
+
if (piPidAlive !== false)
|
|
71
|
+
return 'leave';
|
|
72
|
+
if (deadFor === null || deadFor < REVIVE_GRACE_MS)
|
|
73
|
+
return 'pending';
|
|
74
|
+
return 'revive';
|
|
75
|
+
}
|
|
76
|
+
/** A node whose tmux PANE is alive: pane-existence does NOT prove pi is
|
|
77
|
+
* alive (an inline root runs pi under a persistent login shell that survives
|
|
78
|
+
* pi's death), so gauge liveness on the recorded pid and revive a dead pi once
|
|
79
|
+
* it's been dead past the grace window. */
|
|
80
|
+
function handleLiveWindow(row, now) {
|
|
81
|
+
const id = row.node_id;
|
|
82
|
+
// A deliberately-frozen focused-dormant node (intent=idle-release) keeps its
|
|
83
|
+
// pane alive via remain-on-exit (F3, §3c). Do NOT grace-revive it here — it is
|
|
84
|
+
// waiting for a worker's inbox push, which the second pass delivers. Grace-
|
|
85
|
+
// reviving would pre-empt that and churn the frozen focus pane.
|
|
86
|
+
if (row.intent === 'idle-release') {
|
|
87
|
+
unhealthySince.delete(id);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const pid = row.pi_pid;
|
|
91
|
+
const piPidAlive = pid == null ? null : isPidAlive(pid);
|
|
92
|
+
if (piPidAlive !== false) {
|
|
93
|
+
unhealthySince.delete(id); // alive, or no pid to judge — nothing pending
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const since = unhealthySince.get(id);
|
|
97
|
+
const verdict = livenessVerdict(piPidAlive, since === undefined ? null : now - since);
|
|
98
|
+
if (verdict === 'pending') {
|
|
99
|
+
if (since === undefined)
|
|
100
|
+
unhealthySince.set(id, now);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// 'revive' — pi has been dead past the grace window while its window lived on.
|
|
104
|
+
unhealthySince.delete(id);
|
|
105
|
+
// A refresh-yield wants fresh context (re-read the roadmap); any other death
|
|
106
|
+
// resumes the saved conversation. reviveNode opens a fresh window and clears
|
|
107
|
+
// pi_pid, so the next tick won't re-fire on this stale pid.
|
|
108
|
+
const resume = row.intent !== 'refresh';
|
|
109
|
+
process.stderr.write(`[crtrd] revive ${id} (pi dead, pane alive, intent=${String(row.intent)})\n`);
|
|
110
|
+
reviveNode(id, { resume });
|
|
111
|
+
}
|
|
29
112
|
// ---------------------------------------------------------------------------
|
|
30
113
|
// Pidfile
|
|
31
114
|
// ---------------------------------------------------------------------------
|
|
@@ -54,14 +137,16 @@ export function readPidfile() {
|
|
|
54
137
|
const n = Number(raw);
|
|
55
138
|
return Number.isFinite(n) && n > 0 ? n : null;
|
|
56
139
|
}
|
|
57
|
-
/** True if a process with `pid` is currently alive (signal-0 probe).
|
|
140
|
+
/** True if a process with `pid` is currently alive (signal-0 probe). `kill(pid,
|
|
141
|
+
* 0)` throws ESRCH when the process is gone; EPERM means it exists but isn't
|
|
142
|
+
* ours — still alive. */
|
|
58
143
|
export function isPidAlive(pid) {
|
|
59
144
|
try {
|
|
60
145
|
process.kill(pid, 0);
|
|
61
146
|
return true;
|
|
62
147
|
}
|
|
63
|
-
catch {
|
|
64
|
-
return
|
|
148
|
+
catch (e) {
|
|
149
|
+
return e.code === 'EPERM';
|
|
65
150
|
}
|
|
66
151
|
}
|
|
67
152
|
/** True when a crtrd process is already running (pidfile exists + pid alive). */
|
|
@@ -72,7 +157,7 @@ export function isDaemonRunning() {
|
|
|
72
157
|
// ---------------------------------------------------------------------------
|
|
73
158
|
// Supervisor tick
|
|
74
159
|
// ---------------------------------------------------------------------------
|
|
75
|
-
async function superviseTick() {
|
|
160
|
+
export async function superviseTick(now = Date.now()) {
|
|
76
161
|
let rows;
|
|
77
162
|
try {
|
|
78
163
|
rows = listNodes({ status: ['active', 'idle'] });
|
|
@@ -83,33 +168,62 @@ async function superviseTick() {
|
|
|
83
168
|
}
|
|
84
169
|
for (const row of rows) {
|
|
85
170
|
try {
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
if (
|
|
171
|
+
// Runtime (tmux_session, window, intent, pi_pid) is now authoritative IN
|
|
172
|
+
// the row — no per-node getNode re-read. Only the boot-failure split below
|
|
173
|
+
// still needs identity (pi_session_id), read on demand there.
|
|
174
|
+
// Nodes with no tmux placement at all are inline roots — not daemon-
|
|
175
|
+
// managed. Pane-anchored: a node still counts as placed if it has a pane
|
|
176
|
+
// even when its derived window/session cache is null.
|
|
177
|
+
if (row.tmux_session == null && row.window == null && row.pane == null)
|
|
93
178
|
continue;
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
179
|
+
if (isNodePaneAlive(row)) {
|
|
180
|
+
// The pane is up — but that alone doesn't mean pi is. Reconcile first
|
|
181
|
+
// (follow any manual pane move, and lazy-backfill a legacy row's pane
|
|
182
|
+
// from its live window), then judge pi liveness off the fresh row. The
|
|
183
|
+
// alive-gate means reconcile here only ever FOLLOWS/backfills — never
|
|
184
|
+
// nulls the LOCATION out from under the gone-branches below.
|
|
185
|
+
reconcile(row.node_id);
|
|
186
|
+
handleLiveWindow(getRow(row.node_id) ?? row, now);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
// The pane is gone. Branch on why.
|
|
190
|
+
unhealthySince.delete(row.node_id); // pane-gone path owns it now
|
|
191
|
+
if (row.intent === 'refresh') {
|
|
98
192
|
// The node set intent=refresh before stopping — a clean yield. Respawn
|
|
99
193
|
// fresh so it re-reads its roadmap/context dir.
|
|
100
194
|
process.stderr.write(`[crtrd] revive ${row.node_id} (refresh-yield)\n`);
|
|
101
195
|
reviveNode(row.node_id, { resume: false });
|
|
102
196
|
}
|
|
103
|
-
else if (
|
|
197
|
+
else if (row.intent === 'idle-release') {
|
|
104
198
|
// The node freed its own window on purpose while dormant. Drop the stale
|
|
105
199
|
// window ref and keep it 'idle'; the inbox-poll pass below revives it
|
|
106
200
|
// (resume) the moment a subscribed worker delivers.
|
|
107
|
-
|
|
201
|
+
setPresence(row.node_id, { tmux_session: row.tmux_session, window: null });
|
|
108
202
|
}
|
|
109
203
|
else {
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
204
|
+
// The pane vanished without the node completing or refreshing. Split the
|
|
205
|
+
// two ways that happens: a vehicle that NEVER BOOTED (pi exited before
|
|
206
|
+
// its first session_start, so pi_session_id is still null) versus a
|
|
207
|
+
// genuine mid-run CRASH (it had booted, so pi_session_id is set). Both
|
|
208
|
+
// are dead, but a never-booted node is a spawn failure the parent was
|
|
209
|
+
// never told about — surface it up the spine instead of dying quietly.
|
|
210
|
+
transition(row.node_id, 'crash');
|
|
211
|
+
// Boot-failed vs crashed turns on pi_session_id, an IDENTITY field — the
|
|
212
|
+
// one place this pass still reads meta. surfaceBootFailure also wants the
|
|
213
|
+
// full meta (name/kind) for its message.
|
|
214
|
+
const meta = getNode(row.node_id);
|
|
215
|
+
if (meta !== null && meta.pi_session_id == null) {
|
|
216
|
+
process.stderr.write(`[crtrd] boot-failed ${row.node_id} (pane gone before pi ever started)\n`);
|
|
217
|
+
try {
|
|
218
|
+
await surfaceBootFailure(meta);
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
process.stderr.write(`[crtrd] surfaceBootFailure ${row.node_id} error: ${err.message}\n`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
process.stderr.write(`[crtrd] dead ${row.node_id} (pane gone, intent=${String(row.intent)})\n`);
|
|
226
|
+
}
|
|
113
227
|
}
|
|
114
228
|
}
|
|
115
229
|
catch (err) {
|
|
@@ -123,15 +237,19 @@ async function superviseTick() {
|
|
|
123
237
|
// exit; any entry past it is undelivered work — resume the node to handle it.
|
|
124
238
|
for (const row of rows) {
|
|
125
239
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
240
|
+
// Re-read the ROW for fresh runtime (the first pass may have mutated it);
|
|
241
|
+
// no meta needed — status/intent/window/tmux_session all live in the row.
|
|
242
|
+
const r = getRow(row.node_id);
|
|
243
|
+
if (r === null)
|
|
128
244
|
continue;
|
|
129
|
-
if (
|
|
245
|
+
if (r.status !== 'idle' || r.intent !== 'idle-release')
|
|
130
246
|
continue;
|
|
131
|
-
//
|
|
132
|
-
|
|
247
|
+
// The in-process inbox-watcher only owns delivery while pi is actually LIVE.
|
|
248
|
+
// A frozen focused-dormant pane (remain-on-exit, F3) is pane-ALIVE but
|
|
249
|
+
// pi-DEAD — no watcher — so the daemon must wake it. Gate the skip on pi
|
|
250
|
+
// liveness, NOT pane presence (which would skip a frozen pane forever, §3c).
|
|
251
|
+
if (r.pi_pid != null && isPidAlive(r.pi_pid))
|
|
133
252
|
continue;
|
|
134
|
-
}
|
|
135
253
|
const entries = readInboxSince(row.node_id, readCursor(row.node_id));
|
|
136
254
|
if (entries.length > 0) {
|
|
137
255
|
process.stderr.write(`[crtrd] revive ${row.node_id} (idle-release, inbox)\n`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|