@crouton-kit/crouter 0.3.15 → 0.3.17
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/builtin-personas/developer/orchestrator.md +1 -1
- package/dist/builtin-personas/orchestration-kernel.md +6 -6
- package/dist/builtin-personas/plan/base.md +1 -1
- package/dist/builtin-personas/plan/orchestrator.md +1 -1
- package/dist/builtin-personas/spec/base.md +1 -1
- package/dist/commands/canvas-browse.d.ts +2 -0
- package/dist/commands/canvas-browse.js +45 -0
- package/dist/commands/canvas-prune.js +11 -2
- package/dist/commands/canvas.js +3 -2
- package/dist/commands/chord.js +1 -1
- package/dist/commands/human/shared.js +1 -1
- package/dist/commands/node.js +14 -2
- package/dist/commands/skill/author.js +2 -2
- package/dist/commands/tmux-spread.js +2 -3
- package/dist/core/__tests__/cascade-close.test.js +199 -0
- package/dist/core/__tests__/close.test.js +2 -2
- package/dist/core/__tests__/daemon-boot.test.js +7 -0
- package/dist/core/__tests__/daemon-liveness.test.js +59 -4
- package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
- package/dist/core/__tests__/focuses.test.js +5 -68
- package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
- package/dist/core/__tests__/grace-clock.test.js +115 -0
- package/dist/core/__tests__/helpers/harness.d.ts +78 -0
- package/dist/core/__tests__/helpers/harness.js +406 -0
- package/dist/core/__tests__/home-session.test.js +1 -1
- package/dist/core/__tests__/lifecycle.test.js +6 -13
- package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation.test.js +341 -0
- package/dist/core/__tests__/placement-focus.test.js +106 -46
- package/dist/core/__tests__/placement-teardown.test.js +4 -9
- package/dist/core/__tests__/relaunch.test.js +22 -16
- package/dist/core/__tests__/reset.test.js +11 -6
- package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
- package/dist/core/__tests__/spike-harness.test.js +241 -0
- package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/subscription-delivery.test.js +233 -0
- package/dist/core/__tests__/tmux-surface.test.js +8 -9
- package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
- package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +4 -0
- package/dist/core/canvas/browse/app.js +349 -0
- package/dist/core/canvas/browse/model.d.ts +97 -0
- package/dist/core/canvas/browse/model.js +258 -0
- package/dist/core/canvas/browse/render.d.ts +41 -0
- package/dist/core/canvas/browse/render.js +387 -0
- package/dist/core/canvas/browse/terminal.d.ts +23 -0
- package/dist/core/canvas/browse/terminal.js +100 -0
- package/dist/core/canvas/canvas.d.ts +9 -2
- package/dist/core/canvas/canvas.js +41 -3
- package/dist/core/canvas/db.js +2 -3
- package/dist/core/canvas/focuses.d.ts +2 -2
- package/dist/core/canvas/focuses.js +4 -3
- package/dist/core/canvas/render.d.ts +10 -0
- package/dist/core/canvas/render.js +25 -1
- package/dist/core/canvas/types.d.ts +1 -1
- package/dist/core/feed/inbox.d.ts +0 -3
- package/dist/core/feed/inbox.js +1 -5
- package/dist/core/runtime/busy.d.ts +8 -0
- package/dist/core/runtime/busy.js +46 -0
- package/dist/core/runtime/close.js +2 -2
- package/dist/core/runtime/demote.js +2 -7
- package/dist/core/runtime/launch.d.ts +3 -1
- package/dist/core/runtime/launch.js +4 -1
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +12 -4
- package/dist/core/runtime/naming.d.ts +3 -3
- package/dist/core/runtime/naming.js +6 -6
- package/dist/core/runtime/nodes.d.ts +7 -0
- package/dist/core/runtime/nodes.js +10 -1
- package/dist/core/runtime/placement.d.ts +39 -10
- package/dist/core/runtime/placement.js +100 -44
- package/dist/core/runtime/reset.d.ts +11 -8
- package/dist/core/runtime/reset.js +36 -31
- package/dist/core/runtime/revive.d.ts +1 -1
- package/dist/core/runtime/revive.js +2 -2
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/runtime/tmux-chrome.d.ts +1 -0
- package/dist/core/runtime/tmux-chrome.js +4 -0
- package/dist/core/runtime/tmux.d.ts +13 -6
- package/dist/core/runtime/tmux.js +21 -12
- package/dist/daemon/crtrd.js +43 -21
- package/dist/pi-extensions/canvas-nav.js +40 -28
- package/dist/pi-extensions/canvas-resume.d.ts +21 -0
- package/dist/pi-extensions/canvas-resume.js +82 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +21 -9
- package/dist/prompts/skill.js +6 -1
- package/package.json +2 -2
- package/dist/commands/__tests__/skill.test.js +0 -290
- package/dist/core/__tests__/pkg.test.js +0 -218
- package/dist/core/__tests__/sys.test.js +0 -208
- package/dist/core/runtime/presence.d.ts +0 -30
- package/dist/core/runtime/presence.js +0 -178
- /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
|
@@ -14,11 +14,20 @@
|
|
|
14
14
|
// is also recorded.
|
|
15
15
|
import { randomBytes } from 'node:crypto';
|
|
16
16
|
import { createNode, getNode, subscribe, recordSpawn, } from '../canvas/index.js';
|
|
17
|
-
import { nodeSession } from './tmux.js';
|
|
18
17
|
/** Generate a node id in the same shape as job ids (time-sortable + random). */
|
|
19
18
|
export function newNodeId() {
|
|
20
19
|
return `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
|
|
21
20
|
}
|
|
21
|
+
/** The single, shared tmux session that ALL canvas node windows live in.
|
|
22
|
+
* Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
|
|
23
|
+
* child opens a window here rather than cluttering the user's own working
|
|
24
|
+
* session — switch to it to browse the whole live graph, ignore it otherwise.
|
|
25
|
+
* Pure policy (env only, no tmux call), so it lives in the node layer, not the
|
|
26
|
+
* driver; the tmux driver imports it from here for installMenuBinding's use. */
|
|
27
|
+
export function nodeSession() {
|
|
28
|
+
const v = process.env['CRTR_NODE_SESSION'];
|
|
29
|
+
return v !== undefined && v !== '' ? v : 'crtr';
|
|
30
|
+
}
|
|
22
31
|
// ---------------------------------------------------------------------------
|
|
23
32
|
// REVIVE-HOME (home_session) — the durable session a node is (re)opened into
|
|
24
33
|
// ---------------------------------------------------------------------------
|
|
@@ -2,6 +2,9 @@ import { type NodeRow, type FocusRow } from '../canvas/index.js';
|
|
|
2
2
|
import { homeSessionOf } from './nodes.js';
|
|
3
3
|
export { homeSessionOf };
|
|
4
4
|
export type { FocusRow };
|
|
5
|
+
export { piCommand, paneLocation, currentTmux, inTmux, ensureSession, openNodeWindow, focusWindow, windowAlive, windowOfPane, respawnPane, } from './tmux.js';
|
|
6
|
+
export type { RespawnPaneOpts } from './tmux.js';
|
|
7
|
+
export { nodeSession } from './nodes.js';
|
|
5
8
|
/** The focus a node occupies, or null. UNIQUE(node_id) ⇒ at most one. */
|
|
6
9
|
export declare function focusOf(nodeId: string): FocusRow | null;
|
|
7
10
|
/** Is this node on a viewport? */
|
|
@@ -168,16 +171,32 @@ export interface FocusResult {
|
|
|
168
171
|
revived: boolean;
|
|
169
172
|
}
|
|
170
173
|
/** PURE disposition of a focus's outgoing occupant after a retarget swap (§2.5/
|
|
171
|
-
* §1.3)
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
+
* §1.3). Four signals decide one of three fates; unit-testable in isolation:
|
|
175
|
+
* - `kill` — a holder pane (no row) or a done/dead/canceled node: reap the
|
|
176
|
+
* (now-backstage) pane (Invariant P: not-focused + not-live ⇒
|
|
177
|
+
* no pane).
|
|
178
|
+
* - `backstage`— a human-driven RESIDENT node (editor/root/orchestrator — NEVER
|
|
179
|
+
* despawned on focus-away), or a terminal worker that is
|
|
180
|
+
* genuinely MID-TURN (Invariant F2 — keeps running off-screen).
|
|
181
|
+
* - `release` — a PARKED terminal viewer (live but not mid-turn): a node
|
|
182
|
+
* revived only for inspection. Despawn it back to dormant
|
|
183
|
+
* (transition `release` → idle/idle-release) and reap its pane;
|
|
184
|
+
* the daemon revives it on its inbox, or the user re-focuses.
|
|
185
|
+
* This is the bug fix: such a node was misclassified as
|
|
186
|
+
* generating and left stuck active forever.
|
|
187
|
+
* Order matters: `resident` is checked BEFORE `generating` so a resident node is
|
|
188
|
+
* always kept warm regardless of whether it happens to be mid-turn. */
|
|
174
189
|
export type OutgoingAction = {
|
|
175
190
|
kind: 'backstage';
|
|
176
191
|
} | {
|
|
177
192
|
kind: 'kill';
|
|
193
|
+
} | {
|
|
194
|
+
kind: 'release';
|
|
178
195
|
};
|
|
179
196
|
export declare function outgoingDisposition(o: {
|
|
180
197
|
exists: boolean;
|
|
198
|
+
live: boolean;
|
|
199
|
+
resident: boolean;
|
|
181
200
|
generating: boolean;
|
|
182
201
|
}): OutgoingAction;
|
|
183
202
|
/** Open a NEW viewport (§2.3, F4) — the ONLY path a new pane appears in a user
|
|
@@ -195,7 +214,7 @@ export declare function openFocus(callerPane: string, opts?: {
|
|
|
195
214
|
* `remain-on-exit` so a clean exit FREEZES the pane rather than detaching the
|
|
196
215
|
* terminal (F1). A background `--root` does NOT call this (§6): it stays a plain
|
|
197
216
|
* window until the user `node focus`es it. No-op when the pane or this node is
|
|
198
|
-
* already a focus.
|
|
217
|
+
* already a focus. The focus row IS the record — no pointer to mirror. */
|
|
199
218
|
export declare function registerRootFocus(nodeId: string, pane: string, session: string | null, window: string | null): FocusRow | null;
|
|
200
219
|
/** retargetFocus — the unified hot-swap (§2.5, Invariant P + Q5). Swap `incoming`
|
|
201
220
|
* onto focus `focusId`'s viewport, keeping the screen position invariant (no new
|
|
@@ -207,9 +226,10 @@ export declare function registerRootFocus(nodeId: string, pane: string, session:
|
|
|
207
226
|
* - `swapPaneInPlace(pin, focusPane)`: incoming → the viewport slot; the
|
|
208
227
|
* outgoing occupant → incoming's old (backstage) slot, %ids preserved
|
|
209
228
|
* (cross-session swap confirmed by the spike).
|
|
210
|
-
* - outgoing still
|
|
211
|
-
*
|
|
212
|
-
*
|
|
229
|
+
* - outgoing resident OR still mid-turn → backstage (kept warm / F2); a parked
|
|
230
|
+
* terminal viewer → RELEASE (status → idle, pane reaped); a holder or
|
|
231
|
+
* done/dormant occupant → reap its now-backstage pane (Invariant P).
|
|
232
|
+
* Arms remain-on-exit on the viewport (F3); the focus row is the record. */
|
|
213
233
|
export declare function retargetFocus(focusId: string, incoming: string, revive: Reviver): FocusResult;
|
|
214
234
|
/** The front door for `node focus` / `node cycle` (§2.3): resolve which focus the
|
|
215
235
|
* caller's pane acts on, then retarget `nodeId` onto it.
|
|
@@ -218,8 +238,8 @@ export declare function retargetFocus(focusId: string, incoming: string, revive:
|
|
|
218
238
|
* - else → retarget the caller pane's focus IN PLACE (`focusByPane`); if the
|
|
219
239
|
* caller's pane is not yet a viewport, adopt it as one (occupied by whatever
|
|
220
240
|
* node sits there now — `callerNode`, else resolved by pane).
|
|
221
|
-
* - no caller pane (not in tmux) → best-effort:
|
|
222
|
-
* not-in-place. */
|
|
241
|
+
* - no caller pane (not in tmux) → best-effort: reconcile + report status,
|
|
242
|
+
* not-in-place (no viewport to swap into). */
|
|
223
243
|
export declare function focus(nodeId: string, opts: {
|
|
224
244
|
pane?: string;
|
|
225
245
|
newPane?: boolean;
|
|
@@ -230,7 +250,7 @@ export declare function focus(nodeId: string, opts: {
|
|
|
230
250
|
* Reconcile first (follow a manual move / backfill a legacy pane), close the
|
|
231
251
|
* focus row it occupies (if any), kill its pane (pane-keyed via the durable
|
|
232
252
|
* `%id` — the window collapses once its last pane goes), and null its LOCATION.
|
|
233
|
-
*
|
|
253
|
+
* The focus row close is the record. Best-effort tmux; the
|
|
234
254
|
* DB writes always land. The pane kill is the sole teardown unit (Q1/§6: a
|
|
235
255
|
* split-pane focus returns its space to the surviving split; a standalone-window
|
|
236
256
|
* focus closes the window). */
|
|
@@ -269,6 +289,15 @@ export declare function recycleFocusPane(nodeId: string, pane: string, launch: R
|
|
|
269
289
|
* manager's old backstage slot; the caller nulls this node's presence so nothing
|
|
270
290
|
* tracks the corpse. */
|
|
271
291
|
export declare function handFocusToManager(focusId: string, managerId: string | null): boolean;
|
|
292
|
+
/** Q1 close-to-shell for a truly-done focused node with no successor (§1.6 /
|
|
293
|
+
* flow (b)): close its focus row and DISARM the pane's
|
|
294
|
+
* freeze (`remain-on-exit` off) so it reaps when the finishing pi exits. The
|
|
295
|
+
* stophook calls this instead of `tearDownNode` because it runs INSIDE the pane
|
|
296
|
+
* it is closing: it cannot `closePane` its own pane (self-saw), but it is still
|
|
297
|
+
* alive to disarm the freeze, so the pane closes on exit (return-to-shell)
|
|
298
|
+
* rather than freezing into an orphan. (Keeps the stophook off the tmux driver,
|
|
299
|
+
* §2.1.) */
|
|
300
|
+
export declare function closeFocusToShell(focusId: string, nodeId: string): void;
|
|
272
301
|
export interface SpreadResult {
|
|
273
302
|
window: string | null;
|
|
274
303
|
session: string | null;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// placement.ts — the Placement MODEL layer (Steps 3–5).
|
|
2
2
|
//
|
|
3
3
|
// Above tmux.ts (the Surface/driver), below the daemon and the runtime ops. This
|
|
4
|
-
// is the
|
|
5
|
-
// import the tmux driver
|
|
6
|
-
// other
|
|
4
|
+
// is the sanctioned model-over-driver under the §2.1 rule: "only placement.ts /
|
|
5
|
+
// tmux-chrome.ts import the tmux driver" (enforced by the §5.1 import-lint). Every
|
|
6
|
+
// other runtime/command module reaches the driver through placement's re-exports.
|
|
7
7
|
//
|
|
8
8
|
// Responsibilities, all keyed on the durable tmux `%pane_id` (§1.2/§2.4, Q6):
|
|
9
9
|
//
|
|
@@ -26,14 +26,18 @@
|
|
|
26
26
|
// NEVER read as a node death. Liveness is pane-existence, not window-existence,
|
|
27
27
|
// and reconcile makes crtr follow a move instead of fighting it.
|
|
28
28
|
import { getRow, getRowByPane, getNode, setPresence, openDb, openFocusRow, setFocusOccupant, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, setFocusPane, listFocuses as listFocusRows, } from '../canvas/index.js';
|
|
29
|
-
import { paneExists, paneLocation, paneOfWindow, windowAlive, windowOfPane, ensureSession, openNodeWindow, respawnPaneSync, respawnPaneDetached, breakPaneToSession,
|
|
30
|
-
import { homeSessionOf, newNodeId } from './nodes.js';
|
|
31
|
-
import {
|
|
29
|
+
import { paneExists, paneLocation, paneOfWindow, windowAlive, windowOfPane, ensureSession, openNodeWindow, respawnPaneSync, respawnPaneDetached, breakPaneToSession, splitWindow, swapPaneInPlace, setRemainOnExit, closePane, currentTmux, joinPane, selectLayout, setWindowOption, switchClient, selectWindow, } from './tmux.js';
|
|
30
|
+
import { homeSessionOf, nodeSession, newNodeId } from './nodes.js';
|
|
31
|
+
import { isBusy } from './busy.js';
|
|
32
|
+
import { transition } from './lifecycle.js';
|
|
32
33
|
// Re-export the durable REVIVE-HOME read so placement is the one front door for
|
|
33
|
-
// "where does this node live."
|
|
34
|
-
// presence.ts→placement.ts consolidation (Steps 6/8) is where it physically
|
|
35
|
-
// moves — until then placement just re-exports it (no churn).
|
|
34
|
+
// "where does this node live."
|
|
36
35
|
export { homeSessionOf };
|
|
36
|
+
// Placement is the sanctioned model-over-driver (§2.1): non-placement runtime /
|
|
37
|
+
// command modules that legitimately need a raw driver verb get it from here, so
|
|
38
|
+
// the §5.1 lint can hold "only placement.ts / tmux-chrome.ts import tmux.ts".
|
|
39
|
+
export { piCommand, paneLocation, currentTmux, inTmux, ensureSession, openNodeWindow, focusWindow, windowAlive, windowOfPane, respawnPane, } from './tmux.js';
|
|
40
|
+
export { nodeSession } from './nodes.js';
|
|
37
41
|
// ---------------------------------------------------------------------------
|
|
38
42
|
// Focus reads (Step 4) — COMPOSE over the canvas focuses table (§2.3/§4).
|
|
39
43
|
//
|
|
@@ -42,12 +46,10 @@ export { homeSessionOf };
|
|
|
42
46
|
// way it composes setPresence. A node occupies at most one focus (UNIQUE
|
|
43
47
|
// node_id, Q5), so focusOf returns a single row.
|
|
44
48
|
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// the focus.ptr dual-write bridge (presence.ts). retargetFocus /
|
|
50
|
-
// reviveIntoPlacement are likewise Steps 5/6, not here.
|
|
49
|
+
// The `focuses` table (canvas/focuses.ts) is the CANONICAL focus store — there is
|
|
50
|
+
// no focus.ptr file and no dual-write bridge. openFocus / retargetFocus /
|
|
51
|
+
// registerRootFocus / handFocusToManager write focus rows directly; these reads
|
|
52
|
+
// compose over them.
|
|
51
53
|
// ---------------------------------------------------------------------------
|
|
52
54
|
/** The focus a node occupies, or null. UNIQUE(node_id) ⇒ at most one. */
|
|
53
55
|
export function focusOf(nodeId) {
|
|
@@ -277,6 +279,17 @@ export function detachToBackground(nodeId, pane) {
|
|
|
277
279
|
const session = nodeSession();
|
|
278
280
|
ensureSession(session, row.cwd);
|
|
279
281
|
const ok = breakPaneToSession(target, session);
|
|
282
|
+
if (ok) {
|
|
283
|
+
// The node left its viewport for the backstage: it is now generating but
|
|
284
|
+
// UNFOCUSED (Invariant P / §1.3 "evicted from a focus … generating →
|
|
285
|
+
// backstage, no focus"). Close any focus row it held so it does not linger
|
|
286
|
+
// as a phantom viewport — the pane %id survives the break, so the row would
|
|
287
|
+
// otherwise keep resolving (`focusByPane`/`listFocuses`). A pure detach has
|
|
288
|
+
// no successor, so it closes the row (cf. `demote`, which hands it off).
|
|
289
|
+
const f = focusOf(nodeId);
|
|
290
|
+
if (f !== null)
|
|
291
|
+
closeFocusRow(f.focus_id);
|
|
292
|
+
}
|
|
280
293
|
reconcile(nodeId); // presence now points at the crtr window
|
|
281
294
|
return ok;
|
|
282
295
|
}
|
|
@@ -303,22 +316,37 @@ function pidAlive(pid) {
|
|
|
303
316
|
return e.code === 'EPERM';
|
|
304
317
|
}
|
|
305
318
|
}
|
|
306
|
-
/** Is a focus's OUTGOING occupant still GENERATING (a live pi
|
|
307
|
-
* still-generating node is moved to backstage by a retarget (F2 — it keeps
|
|
308
|
-
* running off-screen); a holder / done / dormant node has its
|
|
309
|
-
* (Invariant P). A holder or vanished node (row null) is
|
|
319
|
+
/** Is a focus's OUTGOING occupant still GENERATING (a live pi actually MID-TURN)?
|
|
320
|
+
* A still-generating node is moved to backstage by a retarget (F2 — it keeps
|
|
321
|
+
* running off-screen); a holder / done / dormant / merely-parked node has its
|
|
322
|
+
* pane reaped or released (Invariant P). A holder or vanished node (row null) is
|
|
323
|
+
* never generating.
|
|
324
|
+
*
|
|
325
|
+
* The signal is the mid-turn `busy` marker AND a live pid — NOT pid-alive
|
|
326
|
+
* alone. A node revived only for VIEWING is parked at its prompt with a live
|
|
327
|
+
* pid between turns; pid-alive would misclassify it as "doing work" and leave
|
|
328
|
+
* it stuck backstaged-active forever. `isBusy` is true only inside a turn, so a
|
|
329
|
+
* parked viewer reads as not-generating and is released to dormant on
|
|
330
|
+
* focus-away. The AND with `pidAlive` makes a stale marker (a pi that crashed
|
|
331
|
+
* mid-turn) harmless. */
|
|
310
332
|
function isGenerating(nodeId) {
|
|
311
333
|
const row = getRow(nodeId);
|
|
312
334
|
if (row === null)
|
|
313
335
|
return false;
|
|
314
336
|
if (row.status !== 'active' && row.status !== 'idle')
|
|
315
337
|
return false;
|
|
316
|
-
return pidAlive(row.pi_pid);
|
|
338
|
+
return isBusy(nodeId) && pidAlive(row.pi_pid);
|
|
317
339
|
}
|
|
318
340
|
export function outgoingDisposition(o) {
|
|
319
341
|
if (!o.exists)
|
|
320
|
-
return { kind: 'kill' };
|
|
321
|
-
|
|
342
|
+
return { kind: 'kill' }; // holder pane
|
|
343
|
+
if (!o.live)
|
|
344
|
+
return { kind: 'kill' }; // done/dead/canceled — reap the pane
|
|
345
|
+
if (o.resident)
|
|
346
|
+
return { kind: 'backstage' }; // human-driven node: keep warm
|
|
347
|
+
if (o.generating)
|
|
348
|
+
return { kind: 'backstage' }; // mid-turn terminal worker (F2)
|
|
349
|
+
return { kind: 'release' }; // parked terminal viewer → dormant
|
|
322
350
|
}
|
|
323
351
|
/** The node's pane iff it is a LIVE pane (a generating-unfocused backstage pane,
|
|
324
352
|
* or a still-live focus pane), else null. The retarget swaps THIS pane into the
|
|
@@ -369,7 +397,7 @@ export function openFocus(callerPane, opts = {}) {
|
|
|
369
397
|
* `remain-on-exit` so a clean exit FREEZES the pane rather than detaching the
|
|
370
398
|
* terminal (F1). A background `--root` does NOT call this (§6): it stays a plain
|
|
371
399
|
* window until the user `node focus`es it. No-op when the pane or this node is
|
|
372
|
-
* already a focus.
|
|
400
|
+
* already a focus. The focus row IS the record — no pointer to mirror. */
|
|
373
401
|
export function registerRootFocus(nodeId, pane, session, window) {
|
|
374
402
|
const byPane = getFocusByPane(pane);
|
|
375
403
|
if (byPane !== null)
|
|
@@ -380,7 +408,6 @@ export function registerRootFocus(nodeId, pane, session, window) {
|
|
|
380
408
|
const focusId = newFocusId();
|
|
381
409
|
openFocusRow(focusId, pane, session, nodeId);
|
|
382
410
|
armRemainOnExit(window);
|
|
383
|
-
setFocus(nodeId);
|
|
384
411
|
return getFocusById(focusId);
|
|
385
412
|
}
|
|
386
413
|
/** retargetFocus — the unified hot-swap (§2.5, Invariant P + Q5). Swap `incoming`
|
|
@@ -393,9 +420,10 @@ export function registerRootFocus(nodeId, pane, session, window) {
|
|
|
393
420
|
* - `swapPaneInPlace(pin, focusPane)`: incoming → the viewport slot; the
|
|
394
421
|
* outgoing occupant → incoming's old (backstage) slot, %ids preserved
|
|
395
422
|
* (cross-session swap confirmed by the spike).
|
|
396
|
-
* - outgoing still
|
|
397
|
-
*
|
|
398
|
-
*
|
|
423
|
+
* - outgoing resident OR still mid-turn → backstage (kept warm / F2); a parked
|
|
424
|
+
* terminal viewer → RELEASE (status → idle, pane reaped); a holder or
|
|
425
|
+
* done/dormant occupant → reap its now-backstage pane (Invariant P).
|
|
426
|
+
* Arms remain-on-exit on the viewport (F3); the focus row is the record. */
|
|
399
427
|
export function retargetFocus(focusId, incoming, revive) {
|
|
400
428
|
let f = getFocusById(focusId);
|
|
401
429
|
if (f === null)
|
|
@@ -436,7 +464,6 @@ export function retargetFocus(focusId, incoming, revive) {
|
|
|
436
464
|
const loc = paneLocation(pin);
|
|
437
465
|
commitFocusTxn(f.focus_id, incoming, pin, loc, outgoing, { kind: 'kill' }, null, null);
|
|
438
466
|
armRemainOnExit(loc?.window);
|
|
439
|
-
setFocus(incoming);
|
|
440
467
|
return { focused: true, session: loc?.session ?? f.session, inPlace: true, revived };
|
|
441
468
|
}
|
|
442
469
|
// The hot-swap: incoming's pane → the viewport slot; outgoing's pane →
|
|
@@ -446,14 +473,25 @@ export function retargetFocus(focusId, incoming, revive) {
|
|
|
446
473
|
}
|
|
447
474
|
const pinLoc = paneLocation(pin); // now the viewport
|
|
448
475
|
const outLoc = paneLocation(focusPane); // now backstage (outgoing's new home)
|
|
449
|
-
const
|
|
476
|
+
const oRow = getRow(outgoing);
|
|
477
|
+
const action = outgoingDisposition({
|
|
478
|
+
exists: oRow !== null,
|
|
479
|
+
live: oRow?.status === 'active' || oRow?.status === 'idle',
|
|
480
|
+
resident: oRow?.lifecycle === 'resident',
|
|
481
|
+
generating: isGenerating(outgoing),
|
|
482
|
+
});
|
|
450
483
|
commitFocusTxn(f.focus_id, incoming, pin, pinLoc, outgoing, action, outLoc, focusPane);
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
|
|
484
|
+
// Crash-safety: flip a released (parked terminal viewer) node to dormant
|
|
485
|
+
// (idle + intent='idle-release') BEFORE reaping its pane, so the daemon never
|
|
486
|
+
// sees a window-gone live node and races to revive it. Then reap the
|
|
487
|
+
// outgoing/holder pane (now backstage) for both kill (done/dormant/holder) and
|
|
488
|
+
// release (parked viewer) — AFTER commit (a tmux side effect, outside the txn).
|
|
489
|
+
// A still-generating worker or a resident node is backstaged, untouched here.
|
|
490
|
+
if (action.kind === 'release')
|
|
491
|
+
transition(outgoing, 'release');
|
|
492
|
+
if (action.kind === 'kill' || action.kind === 'release')
|
|
454
493
|
closePane(focusPane);
|
|
455
494
|
armRemainOnExit(pinLoc?.window);
|
|
456
|
-
setFocus(incoming);
|
|
457
495
|
return { focused: true, session: pinLoc?.session ?? f.session, inPlace: true, revived };
|
|
458
496
|
}
|
|
459
497
|
/** The ONE atomic txn (§2.5): point the focus row at `pin`, set its occupant to
|
|
@@ -473,6 +511,7 @@ function commitFocusTxn(focusId, incoming, pin, pinLoc, outgoing, action, outLoc
|
|
|
473
511
|
setPresence(outgoing, { pane: outgoingPane, tmux_session: outLoc?.session ?? null, window: outLoc?.window ?? null });
|
|
474
512
|
}
|
|
475
513
|
else {
|
|
514
|
+
// kill | release — the pane is reaped by the caller; null the LOCATION.
|
|
476
515
|
setPresence(outgoing, { pane: null, tmux_session: null, window: null });
|
|
477
516
|
}
|
|
478
517
|
}
|
|
@@ -490,16 +529,15 @@ function commitFocusTxn(focusId, incoming, pin, pinLoc, outgoing, action, outLoc
|
|
|
490
529
|
* - else → retarget the caller pane's focus IN PLACE (`focusByPane`); if the
|
|
491
530
|
* caller's pane is not yet a viewport, adopt it as one (occupied by whatever
|
|
492
531
|
* node sits there now — `callerNode`, else resolved by pane).
|
|
493
|
-
* - no caller pane (not in tmux) → best-effort:
|
|
494
|
-
* not-in-place. */
|
|
532
|
+
* - no caller pane (not in tmux) → best-effort: reconcile + report status,
|
|
533
|
+
* not-in-place (no viewport to swap into). */
|
|
495
534
|
export function focus(nodeId, opts) {
|
|
496
535
|
const meta = getNode(nodeId);
|
|
497
536
|
if (meta === null)
|
|
498
537
|
return { focused: false, session: null, inPlace: false, revived: false };
|
|
499
538
|
const callerPane = opts.pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
|
|
500
539
|
if (callerPane === undefined || callerPane === '') {
|
|
501
|
-
// Not in tmux — no viewport to swap into.
|
|
502
|
-
setFocus(nodeId);
|
|
540
|
+
// Not in tmux — no viewport to swap into. Reconcile and report status.
|
|
503
541
|
reconcile(nodeId);
|
|
504
542
|
return { focused: isNodePaneAlive(nodeId), session: meta.tmux_session ?? null, inPlace: false, revived: false };
|
|
505
543
|
}
|
|
@@ -507,13 +545,21 @@ export function focus(nodeId, opts) {
|
|
|
507
545
|
const opened = openFocus(callerPane, {});
|
|
508
546
|
if (opened === null)
|
|
509
547
|
return { focused: false, session: null, inPlace: false, revived: false };
|
|
510
|
-
|
|
548
|
+
const res = retargetFocus(opened.focus_id, nodeId, opts.revive);
|
|
549
|
+
// Failed to place the incoming node — reap the just-opened HOLDER pane + its
|
|
550
|
+
// focus row so a failed `--new-pane` leaves no orphan viewport (F4 / Invariant
|
|
551
|
+
// P). The success path already reaps the holder via the hot-swap.
|
|
552
|
+
if (!res.focused) {
|
|
553
|
+
if (opened.pane !== null)
|
|
554
|
+
closePane(opened.pane);
|
|
555
|
+
closeFocusRow(opened.focus_id);
|
|
556
|
+
}
|
|
557
|
+
return res;
|
|
511
558
|
}
|
|
512
559
|
let f = focusByPane(callerPane);
|
|
513
560
|
if (f === null)
|
|
514
561
|
f = ensureFocusAtPane(callerPane, opts.callerNode);
|
|
515
562
|
if (f === null) {
|
|
516
|
-
setFocus(nodeId);
|
|
517
563
|
return { focused: false, session: meta.tmux_session ?? null, inPlace: false, revived: false };
|
|
518
564
|
}
|
|
519
565
|
return retargetFocus(f.focus_id, nodeId, opts.revive);
|
|
@@ -544,7 +590,7 @@ function ensureFocusAtPane(pane, callerNode) {
|
|
|
544
590
|
* Reconcile first (follow a manual move / backfill a legacy pane), close the
|
|
545
591
|
* focus row it occupies (if any), kill its pane (pane-keyed via the durable
|
|
546
592
|
* `%id` — the window collapses once its last pane goes), and null its LOCATION.
|
|
547
|
-
*
|
|
593
|
+
* The focus row close is the record. Best-effort tmux; the
|
|
548
594
|
* DB writes always land. The pane kill is the sole teardown unit (Q1/§6: a
|
|
549
595
|
* split-pane focus returns its space to the surviving split; a standalone-window
|
|
550
596
|
* focus closes the window). */
|
|
@@ -558,8 +604,6 @@ export function tearDownNode(nodeId) {
|
|
|
558
604
|
if (pane !== null && paneExists(pane))
|
|
559
605
|
closePane(pane);
|
|
560
606
|
setPresence(nodeId, { pane: null, tmux_session: null, window: null });
|
|
561
|
-
if (getFocus() === nodeId)
|
|
562
|
-
setFocus('');
|
|
563
607
|
}
|
|
564
608
|
/** Demote's in-pane relaunch (§2.3, flow (e)): respawn `nodeId`'s launch into an
|
|
565
609
|
* EXISTING `pane`, keeping the durable `%id` (respawn-pane -k), and record its
|
|
@@ -611,7 +655,6 @@ export function handFocusToManager(focusId, managerId) {
|
|
|
611
655
|
if (getFocusByNode(managerId) !== null)
|
|
612
656
|
return false; // manager already focused elsewhere
|
|
613
657
|
setFocusOccupant(focusId, managerId);
|
|
614
|
-
setFocus(managerId);
|
|
615
658
|
// MAJOR 1 — LIVE backstage manager → swap it into the focus slot now. DORMANT
|
|
616
659
|
// managers (no live pane / dead pi) fall through unchanged: the daemon revives
|
|
617
660
|
// them into the frozen %m async.
|
|
@@ -625,6 +668,20 @@ export function handFocusToManager(focusId, managerId) {
|
|
|
625
668
|
}
|
|
626
669
|
return true; // still "took focus" — caller doesn't close
|
|
627
670
|
}
|
|
671
|
+
/** Q1 close-to-shell for a truly-done focused node with no successor (§1.6 /
|
|
672
|
+
* flow (b)): close its focus row and DISARM the pane's
|
|
673
|
+
* freeze (`remain-on-exit` off) so it reaps when the finishing pi exits. The
|
|
674
|
+
* stophook calls this instead of `tearDownNode` because it runs INSIDE the pane
|
|
675
|
+
* it is closing: it cannot `closePane` its own pane (self-saw), but it is still
|
|
676
|
+
* alive to disarm the freeze, so the pane closes on exit (return-to-shell)
|
|
677
|
+
* rather than freezing into an orphan. (Keeps the stophook off the tmux driver,
|
|
678
|
+
* §2.1.) */
|
|
679
|
+
export function closeFocusToShell(focusId, nodeId) {
|
|
680
|
+
closeFocusRow(focusId);
|
|
681
|
+
const win = getNode(nodeId)?.window;
|
|
682
|
+
if (win != null && win !== '')
|
|
683
|
+
setRemainOnExit(win, false);
|
|
684
|
+
}
|
|
628
685
|
/** Join each of `childIds`' live panes into `targetId`'s window, lay them out
|
|
629
686
|
* (target wide on the left, children stacked right), and focus it. Reconcile
|
|
630
687
|
* drives both the target resolution and the per-join fix-up (a joined pane keeps
|
|
@@ -658,6 +715,5 @@ export function spreadNode(targetId, childIds, opts = {}) {
|
|
|
658
715
|
selectLayout(targetWindow, 'main-vertical');
|
|
659
716
|
}
|
|
660
717
|
const focused = switchClient(targetSession) && selectWindow(targetSession, targetWindow);
|
|
661
|
-
setFocus(targetId);
|
|
662
718
|
return { window: targetWindow, session: targetSession, joined, focused };
|
|
663
719
|
}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
/** Reap the descendant sub-DAG of `rootId`: mark each **
|
|
2
|
-
* on — a clean teardown, NOT a fault) + clear intent FIRST, then kill its
|
|
1
|
+
/** Reap the descendant sub-DAG of `rootId`: mark each **canceled** (the user
|
|
2
|
+
* moved on — a clean teardown, NOT a fault) + clear intent FIRST, then kill its
|
|
3
3
|
* window (closes the daemon revive race). Edges are LEFT INTACT — descendants
|
|
4
4
|
* keep parent=rootId. No wipe. Returns the reaped ids.
|
|
5
5
|
*
|
|
6
|
-
* Why `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* Why `canceled` (A5, human-confirmed 2026-06-06): an externally-reaped node —
|
|
7
|
+
* whether via `node close` OR a root reset/relaunch — did not finish its OWN
|
|
8
|
+
* work, so it unifies on `canceled`; `done` is reserved for finalize. Why
|
|
9
|
+
* marking is STILL explicit: a `closeWindow`/`respawn-pane -k` kill is abrupt
|
|
10
|
+
* and fires NO clean `session_shutdown`, so the general quit→done rule does NOT
|
|
11
|
+
* auto-resolve a force-killed descendant — we mark it `canceled` here via the
|
|
12
|
+
* same `cancel` event the close cascade uses. Shared by relaunchRoot (option C)
|
|
13
|
+
* and resetRoot's in-place fallback, so both leave their descendants `canceled`. */
|
|
11
14
|
export declare function reapDescendants(rootId: string): string[];
|
|
12
15
|
export interface ResetRootResult {
|
|
13
|
-
/** Descendant node ids torn down (window killed + marked
|
|
16
|
+
/** Descendant node ids torn down (window killed + marked canceled). */
|
|
14
17
|
reaped: string[];
|
|
15
18
|
/** Direct subscriptions dropped off the root. */
|
|
16
19
|
detached: string[];
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// behave like re-running `crtr` we have two strategies:
|
|
7
7
|
//
|
|
8
8
|
// • relaunchRoot (option C) — for a ROOT in a tmux pane: PARK the old root
|
|
9
|
-
// (mark
|
|
9
|
+
// (mark canceled, keep its id/edges/pi_session_id intact as history), mint a
|
|
10
10
|
// FRESH node id, and re-exec pi in the current pane bound to the new id.
|
|
11
11
|
// The old id never changes meaning; external refs stay valid.
|
|
12
12
|
// • resetRoot (fallback) — for a non-root child (session-id refresh only) or
|
|
@@ -15,42 +15,44 @@
|
|
|
15
15
|
// Termination semantics: a pi that ends cleanly resolves its node to `done`
|
|
16
16
|
// (markCleanExitDone); only a true crash leaves it `dead`. A force-kill
|
|
17
17
|
// (closeWindow / respawn-pane -k) fires NO clean session_shutdown, so reaped
|
|
18
|
-
// descendants are marked `
|
|
18
|
+
// descendants are marked `canceled` explicitly here (A5: an externally-reaped
|
|
19
|
+
// node did not finish its own work — done is reserved for finalize).
|
|
19
20
|
//
|
|
20
21
|
// Best-effort throughout: a tmux/fs failure on one node never aborts the reset.
|
|
21
22
|
import { existsSync, rmSync } from 'node:fs';
|
|
22
|
-
import { getNode, updateNode, setPresence, clearPid, subscriptionsOf, unsubscribe, view, reportsDir, inboxPath, nodeDir, openDb, } from '../canvas/index.js';
|
|
23
|
+
import { getNode, updateNode, setPresence, clearPid, setFocusOccupant, subscriptionsOf, unsubscribe, view, reportsDir, inboxPath, nodeDir, openDb, } from '../canvas/index.js';
|
|
23
24
|
import { transition } from './lifecycle.js';
|
|
24
|
-
import { paneLocation,
|
|
25
|
-
import { tearDownNode } from './placement.js';
|
|
25
|
+
import { paneLocation, tearDownNode, focusOf } from './placement.js';
|
|
26
26
|
import { buildLaunchSpec } from './launch.js';
|
|
27
27
|
import { roadmapPath } from './roadmap.js';
|
|
28
|
-
import { spawnNode, newNodeId } from './nodes.js';
|
|
29
|
-
import { setFocus } from './presence.js';
|
|
28
|
+
import { spawnNode, newNodeId, nodeSession } from './nodes.js';
|
|
30
29
|
import { relaunchRootInPane } from './revive.js';
|
|
31
30
|
// ---------------------------------------------------------------------------
|
|
32
31
|
// reapDescendants — tear down a root's descendant sub-DAG (shared helper)
|
|
33
32
|
// ---------------------------------------------------------------------------
|
|
34
|
-
/** Reap the descendant sub-DAG of `rootId`: mark each **
|
|
35
|
-
* on — a clean teardown, NOT a fault) + clear intent FIRST, then kill its
|
|
33
|
+
/** Reap the descendant sub-DAG of `rootId`: mark each **canceled** (the user
|
|
34
|
+
* moved on — a clean teardown, NOT a fault) + clear intent FIRST, then kill its
|
|
36
35
|
* window (closes the daemon revive race). Edges are LEFT INTACT — descendants
|
|
37
36
|
* keep parent=rootId. No wipe. Returns the reaped ids.
|
|
38
37
|
*
|
|
39
|
-
* Why `
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
38
|
+
* Why `canceled` (A5, human-confirmed 2026-06-06): an externally-reaped node —
|
|
39
|
+
* whether via `node close` OR a root reset/relaunch — did not finish its OWN
|
|
40
|
+
* work, so it unifies on `canceled`; `done` is reserved for finalize. Why
|
|
41
|
+
* marking is STILL explicit: a `closeWindow`/`respawn-pane -k` kill is abrupt
|
|
42
|
+
* and fires NO clean `session_shutdown`, so the general quit→done rule does NOT
|
|
43
|
+
* auto-resolve a force-killed descendant — we mark it `canceled` here via the
|
|
44
|
+
* same `cancel` event the close cascade uses. Shared by relaunchRoot (option C)
|
|
45
|
+
* and resetRoot's in-place fallback, so both leave their descendants `canceled`. */
|
|
44
46
|
export function reapDescendants(rootId) {
|
|
45
47
|
const reaped = [];
|
|
46
48
|
for (const id of view(rootId)) {
|
|
47
49
|
try {
|
|
48
50
|
// Reap BEFORE tearing down the placement (the crash-safety invariant the
|
|
49
|
-
// `
|
|
51
|
+
// `cancel` event encodes): a non-supervised status + cleared intent first, so
|
|
50
52
|
// the daemon can't revive a descendant mid-teardown. tearDownNode then
|
|
51
53
|
// closes any focus row it held, kills its pane (pane-keyed), and nulls its
|
|
52
54
|
// LOCATION.
|
|
53
|
-
transition(id, '
|
|
55
|
+
transition(id, 'cancel');
|
|
54
56
|
tearDownNode(id);
|
|
55
57
|
reaped.push(id);
|
|
56
58
|
}
|
|
@@ -79,7 +81,7 @@ export function resetRoot(nodeId, newSessionId, newSessionFile) {
|
|
|
79
81
|
}
|
|
80
82
|
return { reaped: [], detached: [], reset: false };
|
|
81
83
|
}
|
|
82
|
-
// 1) Reap the descendant sub-DAG (mark
|
|
84
|
+
// 1) Reap the descendant sub-DAG (mark canceled + kill windows; shared helper).
|
|
83
85
|
const reaped = reapDescendants(nodeId);
|
|
84
86
|
// 2) Detach the root's own subscriptions so its view is empty.
|
|
85
87
|
const detached = [];
|
|
@@ -153,7 +155,6 @@ export function handleNewSession(nodeId, newSessionId, pane, deps = {}, newSessi
|
|
|
153
155
|
return { path: 'relaunch', newNodeId: result.newNodeId };
|
|
154
156
|
}
|
|
155
157
|
catch {
|
|
156
|
-
setFocus(nodeId);
|
|
157
158
|
resetRoot(nodeId, newSessionId, newSessionFile);
|
|
158
159
|
return { path: 'reset-root' };
|
|
159
160
|
}
|
|
@@ -167,7 +168,7 @@ export function relaunchRoot(oldId, pane, deps = {}) {
|
|
|
167
168
|
const oldMeta = getNode(oldId);
|
|
168
169
|
if (oldMeta === null || oldMeta.parent != null)
|
|
169
170
|
return null; // defensive: not a root
|
|
170
|
-
if (oldMeta.status === '
|
|
171
|
+
if (oldMeta.status === 'canceled')
|
|
171
172
|
return null; // defensive: already parked (rapid double /new)
|
|
172
173
|
const respawn = deps.relaunchRootInPane ?? relaunchRootInPane;
|
|
173
174
|
// Resolve where the new pi will live (pane authoritative; fall back to old
|
|
@@ -184,11 +185,12 @@ export function relaunchRoot(oldId, pane, deps = {}) {
|
|
|
184
185
|
// back, leaving the old root EXACTLY as it was (no hand-rolled compensation).
|
|
185
186
|
// Only the *detached* respawn (the async pane kill) lands outside the txn — it
|
|
186
187
|
// must, since it kills this caller, and by then COMMIT has made the new state
|
|
187
|
-
// durable.
|
|
188
|
+
// durable. The focus repoint (step 4) is INSIDE the txn, so a ROLLBACK undoes
|
|
189
|
+
// it automatically — there is no file to restore.
|
|
188
190
|
const db = openDb();
|
|
189
191
|
db.exec('BEGIN');
|
|
190
192
|
try {
|
|
191
|
-
// 1) Reap descendants (mark
|
|
193
|
+
// 1) Reap descendants (mark canceled + kill windows, keep edges, no wipe).
|
|
192
194
|
reapDescendants(oldId);
|
|
193
195
|
// 2) Create the fresh root node (new id, empty context dir via
|
|
194
196
|
// ensureNodeDirs) seeded active; `yield` adds the refresh safety net so
|
|
@@ -210,13 +212,18 @@ export function relaunchRoot(oldId, pane, deps = {}) {
|
|
|
210
212
|
// of the pane it is respawned into (same pane-recycle rule as demote).
|
|
211
213
|
updateNode(newId, { home_session: loc.session ?? nodeSession() });
|
|
212
214
|
clearPid(newId); // no pi yet → daemon 'leave' until boot records the pid
|
|
213
|
-
// 3) Park the old root:
|
|
214
|
-
// it never claims the pane, but KEEP pi_session_id (resumable),
|
|
215
|
-
// parent=null, and all edges.
|
|
216
|
-
|
|
215
|
+
// 3) Park the old root: cancel (canceled + intent cleared) and detach its
|
|
216
|
+
// window so it never claims the pane, but KEEP pi_session_id (resumable),
|
|
217
|
+
// parent=null, and all edges. A5: a superseded old root did not finish its
|
|
218
|
+
// own work — it unifies on `canceled` like every other external reap.
|
|
219
|
+
transition(oldId, 'cancel');
|
|
217
220
|
setPresence(oldId, { window: null, tmux_session: null });
|
|
218
|
-
// 4) Focus follows content
|
|
219
|
-
|
|
221
|
+
// 4) Focus follows content: repoint the old root's focus row to the new root
|
|
222
|
+
// (same pane — respawn-pane -k below keeps the %id). Inside the txn → the
|
|
223
|
+
// ROLLBACK path restores the old occupant automatically (no file to restore).
|
|
224
|
+
const oldFocus = focusOf(oldId);
|
|
225
|
+
if (oldFocus !== null)
|
|
226
|
+
setFocusOccupant(oldFocus.focus_id, newId);
|
|
220
227
|
// 5) Re-exec pi in this pane bound to newId; the dispatch is the LAST thing
|
|
221
228
|
// inside the txn. If it throws the txn rolls back (old root untouched); on
|
|
222
229
|
// success we COMMIT and the async detached kill of this pane lands after.
|
|
@@ -237,10 +244,8 @@ export function relaunchRoot(oldId, pane, deps = {}) {
|
|
|
237
244
|
rmSync(nodeDir(newId), { recursive: true, force: true });
|
|
238
245
|
}
|
|
239
246
|
catch { /* */ }
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
catch { /* */ } // focus is a file op, outside the txn
|
|
247
|
+
// The focus repoint was inside the txn; ROLLBACK already restored the old
|
|
248
|
+
// occupant — nothing to undo here.
|
|
244
249
|
throw err instanceof Error ? err : new Error(String(err));
|
|
245
250
|
}
|
|
246
251
|
return { newNodeId: newId };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type NodeMeta } from '../canvas/index.js';
|
|
2
|
-
import { type RespawnPaneOpts } from './
|
|
2
|
+
import { type RespawnPaneOpts } from './placement.js';
|
|
3
3
|
/** Pick the `--session` source for a revive. resume=true prefers the absolute
|
|
4
4
|
* session-file path (immune to cwd; pi opens it directly) and keeps the bare
|
|
5
5
|
* session id as the fallback for older nodes booted before pi_session_file was
|
|
@@ -19,8 +19,8 @@ import { transition } from './lifecycle.js';
|
|
|
19
19
|
import { buildPiArgv } from './launch.js';
|
|
20
20
|
import { buildReviveKickoff, drainBearings } from './kickoff.js';
|
|
21
21
|
import { FRONT_DOOR_ENV } from './front-door.js';
|
|
22
|
-
import { piCommand, respawnPane,
|
|
23
|
-
import {
|
|
22
|
+
import { reviveIntoPlacement, reconcile, isNodePaneAlive, homeSessionOf, piCommand, respawnPane, } from './placement.js';
|
|
23
|
+
import { nodeSession } from './nodes.js';
|
|
24
24
|
/** signal-0 liveness probe for a pi pid (mirrors the daemon's isPidAlive). A
|
|
25
25
|
* null pid (legacy / never-booted) reads dead. */
|
|
26
26
|
function pidAlive(pid) {
|