@crouton-kit/crouter 0.3.13 → 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/__tests__/human.test.js +73 -2
- 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.d.ts +1 -0
- package/dist/commands/human/queue.js +105 -2
- package/dist/commands/human/shared.d.ts +28 -18
- package/dist/commands/human/shared.js +53 -60
- package/dist/commands/human.js +6 -14
- package/dist/commands/node.d.ts +11 -0
- package/dist/commands/node.js +381 -87
- 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.d.ts +1 -0
- package/dist/core/__tests__/passive-subscription.test.js +164 -0
- 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.d.ts +1 -0
- package/dist/core/__tests__/subcommand-tier.test.js +99 -0
- 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/paths.d.ts +4 -0
- package/dist/core/canvas/paths.js +6 -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 +48 -7
- package/dist/core/config.js +36 -2
- package/dist/core/feed/feed.js +14 -12
- package/dist/core/feed/inbox.d.ts +3 -1
- package/dist/core/feed/inbox.js +45 -5
- package/dist/core/feed/passive.d.ts +17 -0
- package/dist/core/feed/passive.js +92 -0
- package/dist/core/help.d.ts +59 -13
- package/dist/core/help.js +73 -28
- 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.d.ts +14 -0
- package/dist/core/runtime/demote.js +120 -0
- package/dist/core/runtime/front-door.js +1 -1
- package/dist/core/runtime/kickoff.d.ts +32 -6
- package/dist/core/runtime/kickoff.js +111 -37
- package/dist/core/runtime/launch.d.ts +29 -6
- package/dist/core/runtime/launch.js +85 -13
- 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 -32
- package/dist/core/runtime/presence.js +90 -110
- package/dist/core/runtime/promote.d.ts +18 -7
- package/dist/core/runtime/promote.js +70 -65
- 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/roadmap.d.ts +5 -4
- package/dist/core/runtime/roadmap.js +9 -16
- package/dist/core/runtime/spawn.d.ts +20 -5
- package/dist/core/runtime/spawn.js +169 -44
- 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 +106 -21
- package/dist/core/runtime/tmux.js +249 -45
- 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.d.ts +34 -0
- package/dist/pi-extensions/canvas-commands.js +103 -0
- 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 +21 -0
- package/dist/pi-extensions/canvas-goal-capture.js +67 -0
- 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-passive-context.d.ts +32 -0
- package/dist/pi-extensions/canvas-passive-context.js +114 -0
- 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
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
// placement.ts — the Placement MODEL layer (Steps 3–5).
|
|
2
|
+
//
|
|
3
|
+
// Above tmux.ts (the Surface/driver), below the daemon and the runtime ops. This
|
|
4
|
+
// is the first module under the §2.1 rule: "only placement.ts / tmux-chrome.ts
|
|
5
|
+
// import the tmux driver." (The import-lint is warn-only until Step 8, so the
|
|
6
|
+
// other direct importers staying is fine for now.)
|
|
7
|
+
//
|
|
8
|
+
// Responsibilities, all keyed on the durable tmux `%pane_id` (§1.2/§2.4, Q6):
|
|
9
|
+
//
|
|
10
|
+
// • reconcile(nodeId) — resolve a node's CURRENT window/session from its
|
|
11
|
+
// durable pane id and FOLLOW any manual move; null
|
|
12
|
+
// the LOCATION when the pane is truly gone; lazily
|
|
13
|
+
// backfill a legacy row's pane from its live window.
|
|
14
|
+
// • reconcileFocus(focusId) — the focus-row analogue: follow a manual move of a
|
|
15
|
+
// FOCUS pane so a resume-into-focus lands in its
|
|
16
|
+
// CURRENT session (§2.4, Q4).
|
|
17
|
+
// • isNodePaneAlive(row) — the primary, PURE liveness probe: pane-existence
|
|
18
|
+
// (window-existence only as a legacy/no-pane fallback).
|
|
19
|
+
// • reviveIntoPlacement — Step 5 (§1.4): THE bug-kill. The single decision
|
|
20
|
+
// (+ reviveTarget, pure) that replaces revive.ts's blind new-window into
|
|
21
|
+
// `meta.tmux_session`: a node on a live focus resumes
|
|
22
|
+
// IN PLACE in that pane; otherwise it opens a window
|
|
23
|
+
// in its home_session ONLY (never a user session).
|
|
24
|
+
//
|
|
25
|
+
// The robustness contract: a manual `move-pane`/`join-pane`/`break-pane` must
|
|
26
|
+
// NEVER read as a node death. Liveness is pane-existence, not window-existence,
|
|
27
|
+
// and reconcile makes crtr follow a move instead of fighting it.
|
|
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, nodeSession, splitWindow, swapPaneInPlace, setRemainOnExit, closePane, currentTmux, joinPane, selectLayout, setWindowOption, switchClient, selectWindow, } from './tmux.js';
|
|
30
|
+
import { homeSessionOf, newNodeId } from './nodes.js';
|
|
31
|
+
import { setFocus, getFocus } from './presence.js';
|
|
32
|
+
// Re-export the durable REVIVE-HOME read so placement is the one front door for
|
|
33
|
+
// "where does this node live." Step 1 put the implementation in nodes.ts; the
|
|
34
|
+
// presence.ts→placement.ts consolidation (Steps 6/8) is where it physically
|
|
35
|
+
// moves — until then placement just re-exports it (no churn).
|
|
36
|
+
export { homeSessionOf };
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Focus reads (Step 4) — COMPOSE over the canvas focuses table (§2.3/§4).
|
|
39
|
+
//
|
|
40
|
+
// placement is the front door for "which nodes are on a viewport"; the SQL lives
|
|
41
|
+
// in the canvas layer (canvas/focuses.ts) and placement just reads it, the same
|
|
42
|
+
// way it composes setPresence. A node occupies at most one focus (UNIQUE
|
|
43
|
+
// node_id, Q5), so focusOf returns a single row.
|
|
44
|
+
//
|
|
45
|
+
// NOTE on openFocus: §2.3/§4 list `openFocus` (split-window + setRemainOnExit +
|
|
46
|
+
// openFocusRow) under Step 4, but it is NOT CALLED until Step 6 (root-boot focus
|
|
47
|
+
// #1 + `node focus --new-pane`). The tmux-composing half is therefore DEFERRED
|
|
48
|
+
// to Step 6; Step 4 ships only the canvas setter `openFocusRow` + these reads +
|
|
49
|
+
// the focus.ptr dual-write bridge (presence.ts). retargetFocus /
|
|
50
|
+
// reviveIntoPlacement are likewise Steps 5/6, not here.
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
/** The focus a node occupies, or null. UNIQUE(node_id) ⇒ at most one. */
|
|
53
|
+
export function focusOf(nodeId) {
|
|
54
|
+
return getFocusByNode(nodeId);
|
|
55
|
+
}
|
|
56
|
+
/** Is this node on a viewport? */
|
|
57
|
+
export function isFocused(nodeId) {
|
|
58
|
+
return getFocusByNode(nodeId) !== null;
|
|
59
|
+
}
|
|
60
|
+
/** The focus realized by a given pane (`%id`), or null. */
|
|
61
|
+
export function focusByPane(pane) {
|
|
62
|
+
return getFocusByPane(pane);
|
|
63
|
+
}
|
|
64
|
+
/** The set of node ids currently on some focus. */
|
|
65
|
+
export function focusedNodes() {
|
|
66
|
+
return new Set(listFocusRows().map((f) => f.node_id));
|
|
67
|
+
}
|
|
68
|
+
/** Every focus row (every live viewport). */
|
|
69
|
+
export function listFocuses() {
|
|
70
|
+
return listFocusRows();
|
|
71
|
+
}
|
|
72
|
+
/** PURE reconciliation decision (§2.4) — unit-testable without a live tmux.
|
|
73
|
+
* Given the cached row LOCATION and what tmux currently reports, decide the
|
|
74
|
+
* presence patch. Mirrors the pure-core/impure-shell split (cf. `livenessVerdict`
|
|
75
|
+
* vs `handleLiveWindow`): this is the decision, `reconcile` wires it to the
|
|
76
|
+
* driver reads + `setPresence`. */
|
|
77
|
+
export function reconcileDecision(cached, live) {
|
|
78
|
+
if (cached.pane == null) {
|
|
79
|
+
// Legacy / no-pane row: lazily backfill the durable pane from the live
|
|
80
|
+
// window's active pane. Requires a complete, live window to anchor on.
|
|
81
|
+
if (cached.tmux_session != null && cached.window != null && live.windowPane != null) {
|
|
82
|
+
return {
|
|
83
|
+
kind: 'backfill',
|
|
84
|
+
pane: live.windowPane,
|
|
85
|
+
tmux_session: cached.tmux_session,
|
|
86
|
+
window: cached.window,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { kind: 'none' };
|
|
90
|
+
}
|
|
91
|
+
// Pane-anchored row: resolve the pane's CURRENT location.
|
|
92
|
+
if (live.paneLoc === null) {
|
|
93
|
+
// The pane itself is gone — the node's pane truly closed. Null the LOCATION.
|
|
94
|
+
return { kind: 'gone' };
|
|
95
|
+
}
|
|
96
|
+
if (live.paneLoc.session !== cached.tmux_session || live.paneLoc.window !== cached.window) {
|
|
97
|
+
// The pane drifted (a manual move-pane/join-pane/break-pane). FOLLOW it:
|
|
98
|
+
// same pane id, new derived window/session.
|
|
99
|
+
return {
|
|
100
|
+
kind: 'follow',
|
|
101
|
+
pane: cached.pane,
|
|
102
|
+
tmux_session: live.paneLoc.session,
|
|
103
|
+
window: live.paneLoc.window,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Cache already matches the pane's reality.
|
|
107
|
+
return { kind: 'none' };
|
|
108
|
+
}
|
|
109
|
+
/** Reconcile a node's LOCATION against tmux reality (§2.4) — the impure shell.
|
|
110
|
+
* Reads `row.pane`, resolves its CURRENT session/window via the driver, and
|
|
111
|
+
* writes the resulting presence patch through `setPresence` (never a raw UPDATE):
|
|
112
|
+
* - pane moved → FOLLOW (re-point window/session, keep the pane id)
|
|
113
|
+
* - pane gone → null the whole LOCATION
|
|
114
|
+
* - legacy/no pane + live window → backfill the pane from `paneOfWindow`
|
|
115
|
+
* A no-op when there's nothing to resolve (genuinely no pane, or the cache is
|
|
116
|
+
* already current). Call this before any swap/kill/focus/revive so the act lands
|
|
117
|
+
* on the pane's current window, never a stale one. */
|
|
118
|
+
export function reconcile(nodeId) {
|
|
119
|
+
const row = getRow(nodeId);
|
|
120
|
+
if (row === null)
|
|
121
|
+
return;
|
|
122
|
+
const cached = {
|
|
123
|
+
pane: row.pane,
|
|
124
|
+
tmux_session: row.tmux_session,
|
|
125
|
+
window: row.window,
|
|
126
|
+
};
|
|
127
|
+
// Only the read the decision needs: paneLocation when anchored on a pane, else
|
|
128
|
+
// paneOfWindow for the legacy backfill. Skip the driver call that can't apply.
|
|
129
|
+
const paneLoc = cached.pane != null ? paneLocation(cached.pane) : null;
|
|
130
|
+
const windowPane = cached.pane == null && cached.tmux_session != null && cached.window != null
|
|
131
|
+
? paneOfWindow(cached.tmux_session, cached.window)
|
|
132
|
+
: null;
|
|
133
|
+
const decision = reconcileDecision(cached, { paneLoc, windowPane });
|
|
134
|
+
switch (decision.kind) {
|
|
135
|
+
case 'none':
|
|
136
|
+
return;
|
|
137
|
+
case 'gone':
|
|
138
|
+
setPresence(nodeId, { pane: null, tmux_session: null, window: null });
|
|
139
|
+
return;
|
|
140
|
+
case 'follow':
|
|
141
|
+
case 'backfill':
|
|
142
|
+
setPresence(nodeId, {
|
|
143
|
+
pane: decision.pane,
|
|
144
|
+
tmux_session: decision.tmux_session,
|
|
145
|
+
window: decision.window,
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Focus reconciliation — follow a manual move of a FOCUS pane (§2.4, Q4)
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
/** Reconcile a FOCUS's derived `session` cache against tmux reality (§2.4, Q4) —
|
|
154
|
+
* the focus-row analogue of `reconcile`. A focus is anchored on its durable
|
|
155
|
+
* `%pane_id`; `session` is a derived cache. If the user moved the focus pane to
|
|
156
|
+
* another session, re-point the cache so a resume-into-focus lands in the pane's
|
|
157
|
+
* CURRENT session. A no-op when the focus has no pane, the cache is already
|
|
158
|
+
* current, or the pane is GONE — in the gone case reconcileFocus does NOT null
|
|
159
|
+
* the row; the caller (reviveIntoPlacement) instead falls to the backstage
|
|
160
|
+
* branch via `paneExists(pane)` being false. */
|
|
161
|
+
export function reconcileFocus(focusId) {
|
|
162
|
+
const f = getFocusById(focusId);
|
|
163
|
+
if (f === null || f.pane === null)
|
|
164
|
+
return;
|
|
165
|
+
const live = paneLocation(f.pane);
|
|
166
|
+
if (live === null)
|
|
167
|
+
return; // pane gone — backstage fall-through handles it
|
|
168
|
+
if (live.session !== f.session) {
|
|
169
|
+
setFocusPane(f.focus_id, f.pane, live.session);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Liveness — pane-existence (§1.2, Q6)
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
/** Is this node's pane (its LOCATION) alive? The v3 PRIMARY liveness probe,
|
|
176
|
+
* PURE / non-mutating so the daemon can gate on it without side effects:
|
|
177
|
+
* - `pane != null` → `paneExists(pane)` (display-message on the `%id`), so a
|
|
178
|
+
* user moving the pane to another window/session never reads as "gone".
|
|
179
|
+
* - `pane == null` → window-keyed FALLBACK (`windowAlive`) for legacy/no-pane
|
|
180
|
+
* rows that haven't been backfilled yet.
|
|
181
|
+
* Accepts a node id (re-reads the row) or a `NodeRow` already in hand. */
|
|
182
|
+
export function isNodePaneAlive(node) {
|
|
183
|
+
const row = typeof node === 'string' ? getRow(node) : node;
|
|
184
|
+
if (row === null)
|
|
185
|
+
return false;
|
|
186
|
+
return row.pane != null
|
|
187
|
+
? paneExists(row.pane)
|
|
188
|
+
: windowAlive(row.tmux_session, row.window);
|
|
189
|
+
}
|
|
190
|
+
export function reviveTarget(focus, focusPaneAlive, homeSession) {
|
|
191
|
+
if (focus !== null && focus.pane !== null && focusPaneAlive) {
|
|
192
|
+
return { kind: 'focus-pane', pane: focus.pane, session: focus.session ?? homeSession };
|
|
193
|
+
}
|
|
194
|
+
return { kind: 'backstage', session: homeSession };
|
|
195
|
+
}
|
|
196
|
+
/** Place a reviving node into its CORRECT location (§1.4) — the single decision
|
|
197
|
+
* that replaces revive.ts's old `session = meta.tmux_session ?? nodeSession()` +
|
|
198
|
+
* `openNodeWindow`. Reconcile first (§2.4), then dispatch on `reviveTarget`:
|
|
199
|
+
* - the node occupies a LIVE focus → `reconcileFocus` (resolve the pane's
|
|
200
|
+
* CURRENT session, Q4) and `respawn-pane -k` the pi INTO that focus pane —
|
|
201
|
+
* no new window (F3 resume-in-place).
|
|
202
|
+
* - otherwise → the node is NOT focused (or its focus pane already collapsed,
|
|
203
|
+
* the Step-5 limitation: remain-on-exit lands in Step 6), so it may ONLY
|
|
204
|
+
* (re)appear in its durable REVIVE-HOME: a fresh window in `homeSession`.
|
|
205
|
+
* **There is NO code path here by which a non-focused node's new-window
|
|
206
|
+
* targets a user session** — `openNodeWindow`'s session is `homeSession` and
|
|
207
|
+
* nothing else. That is the structural bug-kill.
|
|
208
|
+
*
|
|
209
|
+
* `setPresence` (the one atomic LOCATION write) records where the node landed.
|
|
210
|
+
* CRTR_ROOT_SESSION is forced to `homeSession` in BOTH branches so the node's
|
|
211
|
+
* children always flow to the backstage, never into the focus session. */
|
|
212
|
+
export function reviveIntoPlacement(nodeId, launch) {
|
|
213
|
+
// §2.4 — follow any manual pane move before acting.
|
|
214
|
+
reconcile(nodeId);
|
|
215
|
+
const focus = focusOf(nodeId);
|
|
216
|
+
const focusPaneAlive = focus !== null && focus.pane !== null && paneExists(focus.pane);
|
|
217
|
+
const homeSession = homeSessionOf(nodeId);
|
|
218
|
+
const decision = reviveTarget(focus, focusPaneAlive, homeSession);
|
|
219
|
+
// The node's children always spawn into the backstage (homeSession), never the
|
|
220
|
+
// focus session — force it regardless of which branch the node itself takes.
|
|
221
|
+
const env = { ...launch.env, CRTR_ROOT_SESSION: homeSession };
|
|
222
|
+
if (decision.kind === 'focus-pane') {
|
|
223
|
+
// F3: resume the pi INTO the live focus pane, in its CURRENT session (Q4 —
|
|
224
|
+
// reconcileFocus follows a user move of the focus pane). No new window.
|
|
225
|
+
reconcileFocus(focus.focus_id);
|
|
226
|
+
const f = focusOf(nodeId) ?? focus;
|
|
227
|
+
const pane = f.pane;
|
|
228
|
+
respawnPaneSync({ pane, cwd: launch.cwd, env, command: launch.command });
|
|
229
|
+
const window = windowOfPane(pane);
|
|
230
|
+
const session = f.session ?? homeSession;
|
|
231
|
+
setPresence(nodeId, { pane, tmux_session: session, window });
|
|
232
|
+
return { window, session, pane };
|
|
233
|
+
}
|
|
234
|
+
// Backstage branch — the ONLY new-window target is `homeSession` (the
|
|
235
|
+
// backstage `crtr` for a child). A non-focused node has NO path to a user
|
|
236
|
+
// session here: the bug is structurally unreachable.
|
|
237
|
+
const session = decision.session; // === homeSession
|
|
238
|
+
ensureSession(session, launch.cwd);
|
|
239
|
+
const opened = openNodeWindow({
|
|
240
|
+
session,
|
|
241
|
+
name: launch.name,
|
|
242
|
+
cwd: launch.cwd,
|
|
243
|
+
env,
|
|
244
|
+
command: launch.command,
|
|
245
|
+
});
|
|
246
|
+
const window = opened?.window ?? null;
|
|
247
|
+
const pane = opened?.pane ?? null;
|
|
248
|
+
setPresence(nodeId, { pane, window, tmux_session: session });
|
|
249
|
+
return { window, session, pane };
|
|
250
|
+
}
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Detach to background — send a still-running agent off the foreground pane into
|
|
253
|
+
// the backstage `crtr` session WITHOUT ending it (the `node lifecycle --detach`
|
|
254
|
+
// / Alt+C → D half).
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
/** Relocate a node's still-running agent to the background `crtr` session,
|
|
257
|
+
* freeing the foreground pane WITHOUT killing the pi. `break-pane` moves the
|
|
258
|
+
* pane out of the foreground window into a fresh window in the shared backstage
|
|
259
|
+
* (the pi keeps generating); the node becomes a background window — switchable
|
|
260
|
+
* but not rendered, like any other node. Reconcile first (act on the pane's
|
|
261
|
+
* CURRENT location, §2.4) and again after (presence FOLLOWS the move). No-op
|
|
262
|
+
* (false) when there is no live pane to relocate or tmux refuses the break.
|
|
263
|
+
* `pane` is the authoritative node pane the caller acts on (the Alt+C menu's
|
|
264
|
+
* `#{pane_id}`); falls back to the node's durable handle. */
|
|
265
|
+
export function detachToBackground(nodeId, pane) {
|
|
266
|
+
reconcile(nodeId);
|
|
267
|
+
const row = getRow(nodeId);
|
|
268
|
+
if (row === null)
|
|
269
|
+
return false;
|
|
270
|
+
const target = pane ?? row.pane;
|
|
271
|
+
if (target === null || !paneExists(target))
|
|
272
|
+
return false;
|
|
273
|
+
// Anchor the durable handle on the pane we relocate so the post-move reconcile
|
|
274
|
+
// follows the right pane.
|
|
275
|
+
if (row.pane !== target)
|
|
276
|
+
setPresence(nodeId, { pane: target });
|
|
277
|
+
const session = nodeSession();
|
|
278
|
+
ensureSession(session, row.cwd);
|
|
279
|
+
const ok = breakPaneToSession(target, session);
|
|
280
|
+
reconcile(nodeId); // presence now points at the crtr window
|
|
281
|
+
return ok;
|
|
282
|
+
}
|
|
283
|
+
/** A reserved, non-node occupant for a freshly-opened viewport that has no node
|
|
284
|
+
* yet: openFocus splits a HOLDER pane, but `node_id` is NOT NULL, so the row
|
|
285
|
+
* needs a placeholder until retargetFocus swaps a real node in. retargetFocus
|
|
286
|
+
* REAPS a holder pane (getRow(holder) === null ⇒ not generating ⇒ kill) instead
|
|
287
|
+
* of backstaging it. */
|
|
288
|
+
function holderId(focusId) {
|
|
289
|
+
return `__hold_${focusId}__`;
|
|
290
|
+
}
|
|
291
|
+
function newFocusId() {
|
|
292
|
+
return `f-${newNodeId()}`;
|
|
293
|
+
}
|
|
294
|
+
/** signal-0 liveness probe for a pi pid (mirrors the daemon's isPidAlive). */
|
|
295
|
+
function pidAlive(pid) {
|
|
296
|
+
if (pid == null)
|
|
297
|
+
return false;
|
|
298
|
+
try {
|
|
299
|
+
process.kill(pid, 0);
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
return e.code === 'EPERM';
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/** Is a focus's OUTGOING occupant still GENERATING (a live pi doing work)? A
|
|
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 pane reaped
|
|
309
|
+
* (Invariant P). A holder or vanished node (row null) is never generating. */
|
|
310
|
+
function isGenerating(nodeId) {
|
|
311
|
+
const row = getRow(nodeId);
|
|
312
|
+
if (row === null)
|
|
313
|
+
return false;
|
|
314
|
+
if (row.status !== 'active' && row.status !== 'idle')
|
|
315
|
+
return false;
|
|
316
|
+
return pidAlive(row.pi_pid);
|
|
317
|
+
}
|
|
318
|
+
export function outgoingDisposition(o) {
|
|
319
|
+
if (!o.exists)
|
|
320
|
+
return { kind: 'kill' };
|
|
321
|
+
return o.generating ? { kind: 'backstage' } : { kind: 'kill' };
|
|
322
|
+
}
|
|
323
|
+
/** The node's pane iff it is a LIVE pane (a generating-unfocused backstage pane,
|
|
324
|
+
* or a still-live focus pane), else null. The retarget swaps THIS pane into the
|
|
325
|
+
* viewport; null means the node is dormant and must be revived first. */
|
|
326
|
+
function livePinPane(nodeId) {
|
|
327
|
+
const row = getRow(nodeId);
|
|
328
|
+
return row?.pane != null && paneExists(row.pane) ? row.pane : null;
|
|
329
|
+
}
|
|
330
|
+
/** remain-on-exit on a focus's viewport window (F3 freeze/resume) — best-effort. */
|
|
331
|
+
function armRemainOnExit(window) {
|
|
332
|
+
if (window != null && window !== '')
|
|
333
|
+
setRemainOnExit(window, true);
|
|
334
|
+
}
|
|
335
|
+
/** Open a NEW viewport (§2.3, F4) — the ONLY path a new pane appears in a user
|
|
336
|
+
* session. Default: `splitWindow(callerPane)` beside (Q3); `newWindow` opens a
|
|
337
|
+
* fresh window in the caller pane's session instead. Arms `remain-on-exit` on
|
|
338
|
+
* the new pane's window (F3) and inserts a focuses row anchored on it, occupied
|
|
339
|
+
* by a HOLDER until retargetFocus swaps a real node in. A benign long-sleep
|
|
340
|
+
* holds the pane open until the swap; retargetFocus reaps it. Returns the row,
|
|
341
|
+
* or null if tmux failed. */
|
|
342
|
+
export function openFocus(callerPane, opts = {}) {
|
|
343
|
+
const HOLD = 'sleep 2147483647';
|
|
344
|
+
let pane;
|
|
345
|
+
let session;
|
|
346
|
+
if (opts.newWindow === true) {
|
|
347
|
+
const sess = paneLocation(callerPane)?.session;
|
|
348
|
+
if (sess === undefined)
|
|
349
|
+
return null;
|
|
350
|
+
const opened = openNodeWindow({ session: sess, name: 'focus', cwd: process.cwd(), env: {}, command: HOLD });
|
|
351
|
+
if (opened === null)
|
|
352
|
+
return null;
|
|
353
|
+
pane = opened.pane;
|
|
354
|
+
session = sess;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
pane = splitWindow(callerPane, { cwd: process.cwd(), env: {}, command: HOLD });
|
|
358
|
+
if (pane === null)
|
|
359
|
+
return null;
|
|
360
|
+
session = paneLocation(pane)?.session ?? null;
|
|
361
|
+
}
|
|
362
|
+
armRemainOnExit(paneLocation(pane)?.window);
|
|
363
|
+
const focusId = newFocusId();
|
|
364
|
+
openFocusRow(focusId, pane, session, holderId(focusId));
|
|
365
|
+
return getFocusById(focusId);
|
|
366
|
+
}
|
|
367
|
+
/** Register the FOREGROUND root's pane as focus #1 at boot (§2.6). The inline
|
|
368
|
+
* root owns the user's viewport, so its own pane becomes a durable focus — with
|
|
369
|
+
* `remain-on-exit` so a clean exit FREEZES the pane rather than detaching the
|
|
370
|
+
* terminal (F1). A background `--root` does NOT call this (§6): it stays a plain
|
|
371
|
+
* window until the user `node focus`es it. No-op when the pane or this node is
|
|
372
|
+
* already a focus. Mirrors focus.ptr via setFocus (the transitional bridge). */
|
|
373
|
+
export function registerRootFocus(nodeId, pane, session, window) {
|
|
374
|
+
const byPane = getFocusByPane(pane);
|
|
375
|
+
if (byPane !== null)
|
|
376
|
+
return byPane;
|
|
377
|
+
const byNode = getFocusByNode(nodeId);
|
|
378
|
+
if (byNode !== null)
|
|
379
|
+
return byNode;
|
|
380
|
+
const focusId = newFocusId();
|
|
381
|
+
openFocusRow(focusId, pane, session, nodeId);
|
|
382
|
+
armRemainOnExit(window);
|
|
383
|
+
setFocus(nodeId);
|
|
384
|
+
return getFocusById(focusId);
|
|
385
|
+
}
|
|
386
|
+
/** retargetFocus — the unified hot-swap (§2.5, Invariant P + Q5). Swap `incoming`
|
|
387
|
+
* onto focus `focusId`'s viewport, keeping the screen position invariant (no new
|
|
388
|
+
* window). One sqlite txn updates the focus row + BOTH nodes' presence:
|
|
389
|
+
* - Q5: if `incoming` already occupies ANOTHER focus, VACATE it first (close
|
|
390
|
+
* its row + kill its pane — the node MOVES here, no auto-retarget).
|
|
391
|
+
* - resolve `incoming`'s live pin pane (a backstage pane), else `revive` it
|
|
392
|
+
* into the backstage and read back its pane.
|
|
393
|
+
* - `swapPaneInPlace(pin, focusPane)`: incoming → the viewport slot; the
|
|
394
|
+
* outgoing occupant → incoming's old (backstage) slot, %ids preserved
|
|
395
|
+
* (cross-session swap confirmed by the spike).
|
|
396
|
+
* - outgoing still generating → backstage (F2); else reap its now-backstage
|
|
397
|
+
* pane (Invariant P). A holder occupant (no node row) is always reaped.
|
|
398
|
+
* Arms remain-on-exit on the viewport (F3) and mirrors focus.ptr (setFocus). */
|
|
399
|
+
export function retargetFocus(focusId, incoming, revive) {
|
|
400
|
+
let f = getFocusById(focusId);
|
|
401
|
+
if (f === null)
|
|
402
|
+
return { focused: false, session: null, inPlace: false, revived: false };
|
|
403
|
+
reconcileFocus(f.focus_id);
|
|
404
|
+
reconcile(incoming);
|
|
405
|
+
f = getFocusById(focusId) ?? f;
|
|
406
|
+
const outgoing = f.node_id;
|
|
407
|
+
// Already showing this node — a no-op (focusing yourself / the live occupant).
|
|
408
|
+
if (outgoing === incoming) {
|
|
409
|
+
return { focused: true, session: f.session, inPlace: true, revived: false };
|
|
410
|
+
}
|
|
411
|
+
// Q5 vacate: incoming occupies a DIFFERENT focus — close it + kill its pane;
|
|
412
|
+
// the node moves here. reconcile then nulls its now-dead LOCATION.
|
|
413
|
+
const other = getFocusByNode(incoming);
|
|
414
|
+
if (other !== null && other.focus_id !== f.focus_id) {
|
|
415
|
+
if (other.pane !== null)
|
|
416
|
+
closePane(other.pane);
|
|
417
|
+
closeFocusRow(other.focus_id);
|
|
418
|
+
reconcile(incoming);
|
|
419
|
+
}
|
|
420
|
+
// Resolve incoming's live pin pane; revive into backstage if dormant.
|
|
421
|
+
let revived = false;
|
|
422
|
+
let pin = livePinPane(incoming);
|
|
423
|
+
if (pin === null) {
|
|
424
|
+
revive(incoming);
|
|
425
|
+
revived = true;
|
|
426
|
+
reconcile(incoming);
|
|
427
|
+
pin = livePinPane(incoming);
|
|
428
|
+
}
|
|
429
|
+
if (pin === null) {
|
|
430
|
+
return { focused: false, session: f.session, inPlace: false, revived };
|
|
431
|
+
}
|
|
432
|
+
const focusPane = f.pane;
|
|
433
|
+
// The focus has no physical pane yet (an unplaced/bridge row) OR incoming is
|
|
434
|
+
// already in it — adopt pin directly, no swap.
|
|
435
|
+
if (focusPane === null || focusPane === pin) {
|
|
436
|
+
const loc = paneLocation(pin);
|
|
437
|
+
commitFocusTxn(f.focus_id, incoming, pin, loc, outgoing, { kind: 'kill' }, null, null);
|
|
438
|
+
armRemainOnExit(loc?.window);
|
|
439
|
+
setFocus(incoming);
|
|
440
|
+
return { focused: true, session: loc?.session ?? f.session, inPlace: true, revived };
|
|
441
|
+
}
|
|
442
|
+
// The hot-swap: incoming's pane → the viewport slot; outgoing's pane →
|
|
443
|
+
// incoming's old (backstage) slot. %ids survive (spike-confirmed).
|
|
444
|
+
if (!swapPaneInPlace(pin, focusPane)) {
|
|
445
|
+
return { focused: false, session: f.session, inPlace: false, revived };
|
|
446
|
+
}
|
|
447
|
+
const pinLoc = paneLocation(pin); // now the viewport
|
|
448
|
+
const outLoc = paneLocation(focusPane); // now backstage (outgoing's new home)
|
|
449
|
+
const action = outgoingDisposition({ exists: getRow(outgoing) !== null, generating: isGenerating(outgoing) });
|
|
450
|
+
commitFocusTxn(f.focus_id, incoming, pin, pinLoc, outgoing, action, outLoc, focusPane);
|
|
451
|
+
// Reap the outgoing/holder pane (now backstage) when not generating — AFTER
|
|
452
|
+
// commit (a tmux side effect, outside the txn).
|
|
453
|
+
if (action.kind === 'kill')
|
|
454
|
+
closePane(focusPane);
|
|
455
|
+
armRemainOnExit(pinLoc?.window);
|
|
456
|
+
setFocus(incoming);
|
|
457
|
+
return { focused: true, session: pinLoc?.session ?? f.session, inPlace: true, revived };
|
|
458
|
+
}
|
|
459
|
+
/** The ONE atomic txn (§2.5): point the focus row at `pin`, set its occupant to
|
|
460
|
+
* `incoming`, and write BOTH nodes' presence — incoming into the viewport, the
|
|
461
|
+
* outgoing either backstaged (still generating) or null (its pane is reaped by
|
|
462
|
+
* the caller). A holder/vanished outgoing (no row) gets no presence write. */
|
|
463
|
+
function commitFocusTxn(focusId, incoming, pin, pinLoc, outgoing, action, outLoc, outgoingPane) {
|
|
464
|
+
const db = openDb();
|
|
465
|
+
db.exec('BEGIN');
|
|
466
|
+
try {
|
|
467
|
+
setFocusPane(focusId, pin, pinLoc?.session ?? null);
|
|
468
|
+
setFocusOccupant(focusId, incoming);
|
|
469
|
+
setPresence(incoming, { pane: pin, tmux_session: pinLoc?.session ?? null, window: pinLoc?.window ?? null });
|
|
470
|
+
if (getRow(outgoing) !== null) {
|
|
471
|
+
if (action.kind === 'backstage') {
|
|
472
|
+
// The outgoing pi kept its pane id (`outgoingPane`), now in the backstage.
|
|
473
|
+
setPresence(outgoing, { pane: outgoingPane, tmux_session: outLoc?.session ?? null, window: outLoc?.window ?? null });
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
setPresence(outgoing, { pane: null, tmux_session: null, window: null });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
db.exec('COMMIT');
|
|
480
|
+
}
|
|
481
|
+
catch (e) {
|
|
482
|
+
db.exec('ROLLBACK');
|
|
483
|
+
throw e;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/** The front door for `node focus` / `node cycle` (§2.3): resolve which focus the
|
|
487
|
+
* caller's pane acts on, then retarget `nodeId` onto it.
|
|
488
|
+
* - `newPane` → `openFocus` a fresh viewport beside the caller (F4), then
|
|
489
|
+
* retarget into it.
|
|
490
|
+
* - else → retarget the caller pane's focus IN PLACE (`focusByPane`); if the
|
|
491
|
+
* caller's pane is not yet a viewport, adopt it as one (occupied by whatever
|
|
492
|
+
* node sits there now — `callerNode`, else resolved by pane).
|
|
493
|
+
* - no caller pane (not in tmux) → best-effort: mirror focus.ptr, report
|
|
494
|
+
* not-in-place. */
|
|
495
|
+
export function focus(nodeId, opts) {
|
|
496
|
+
const meta = getNode(nodeId);
|
|
497
|
+
if (meta === null)
|
|
498
|
+
return { focused: false, session: null, inPlace: false, revived: false };
|
|
499
|
+
const callerPane = opts.pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
|
|
500
|
+
if (callerPane === undefined || callerPane === '') {
|
|
501
|
+
// Not in tmux — no viewport to swap into. Mirror the pointer; report status.
|
|
502
|
+
setFocus(nodeId);
|
|
503
|
+
reconcile(nodeId);
|
|
504
|
+
return { focused: isNodePaneAlive(nodeId), session: meta.tmux_session ?? null, inPlace: false, revived: false };
|
|
505
|
+
}
|
|
506
|
+
if (opts.newPane === true) {
|
|
507
|
+
const opened = openFocus(callerPane, {});
|
|
508
|
+
if (opened === null)
|
|
509
|
+
return { focused: false, session: null, inPlace: false, revived: false };
|
|
510
|
+
return retargetFocus(opened.focus_id, nodeId, opts.revive);
|
|
511
|
+
}
|
|
512
|
+
let f = focusByPane(callerPane);
|
|
513
|
+
if (f === null)
|
|
514
|
+
f = ensureFocusAtPane(callerPane, opts.callerNode);
|
|
515
|
+
if (f === null) {
|
|
516
|
+
setFocus(nodeId);
|
|
517
|
+
return { focused: false, session: meta.tmux_session ?? null, inPlace: false, revived: false };
|
|
518
|
+
}
|
|
519
|
+
return retargetFocus(f.focus_id, nodeId, opts.revive);
|
|
520
|
+
}
|
|
521
|
+
/** Register the caller's CURRENT pane as a focus so a `node focus`/`cycle` from a
|
|
522
|
+
* pane that isn't yet a viewport retargets IN PLACE. Occupied by whatever node
|
|
523
|
+
* sits in the pane now (`callerNode`, else resolved by pane→row), or a HOLDER
|
|
524
|
+
* when none is resolvable / it is already focused elsewhere (UNIQUE node_id). */
|
|
525
|
+
function ensureFocusAtPane(pane, callerNode) {
|
|
526
|
+
const existing = getFocusByPane(pane);
|
|
527
|
+
if (existing !== null)
|
|
528
|
+
return existing;
|
|
529
|
+
const loc = paneLocation(pane);
|
|
530
|
+
const focusId = newFocusId();
|
|
531
|
+
const resolved = callerNode ?? getRowByPane(pane)?.node_id;
|
|
532
|
+
const occupant = resolved !== undefined && resolved !== '' && getFocusByNode(resolved) === null
|
|
533
|
+
? resolved
|
|
534
|
+
: holderId(focusId);
|
|
535
|
+
openFocusRow(focusId, pane, loc?.session ?? null, occupant);
|
|
536
|
+
armRemainOnExit(loc?.window);
|
|
537
|
+
return getFocusById(focusId);
|
|
538
|
+
}
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
// Teardown / recycle / lifecycle-successor verbs (§2.3/§1.6, Step 7) — the
|
|
541
|
+
// close/demote/reset entry points + the truly-done focus successor.
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
/** Tear a node off its placement (close/reset teardown, §2.3, flow (e)).
|
|
544
|
+
* Reconcile first (follow a manual move / backfill a legacy pane), close the
|
|
545
|
+
* focus row it occupies (if any), kill its pane (pane-keyed via the durable
|
|
546
|
+
* `%id` — the window collapses once its last pane goes), and null its LOCATION.
|
|
547
|
+
* Mirrors focus.ptr when this node was the current focus. Best-effort tmux; the
|
|
548
|
+
* DB writes always land. The pane kill is the sole teardown unit (Q1/§6: a
|
|
549
|
+
* split-pane focus returns its space to the surviving split; a standalone-window
|
|
550
|
+
* focus closes the window). */
|
|
551
|
+
export function tearDownNode(nodeId) {
|
|
552
|
+
reconcile(nodeId);
|
|
553
|
+
const f = focusOf(nodeId);
|
|
554
|
+
if (f !== null)
|
|
555
|
+
closeFocusRow(f.focus_id);
|
|
556
|
+
const row = getRow(nodeId);
|
|
557
|
+
const pane = row?.pane ?? f?.pane ?? null;
|
|
558
|
+
if (pane !== null && paneExists(pane))
|
|
559
|
+
closePane(pane);
|
|
560
|
+
setPresence(nodeId, { pane: null, tmux_session: null, window: null });
|
|
561
|
+
if (getFocus() === nodeId)
|
|
562
|
+
setFocus('');
|
|
563
|
+
}
|
|
564
|
+
/** Demote's in-pane relaunch (§2.3, flow (e)): respawn `nodeId`'s launch into an
|
|
565
|
+
* EXISTING `pane`, keeping the durable `%id` (respawn-pane -k), and record its
|
|
566
|
+
* presence keyed on that pane. The session/window are DERIVED from the pane
|
|
567
|
+
* itself (paneLocation), so the recycled node's LOCATION follows the pane it was
|
|
568
|
+
* recycled into. `launch.env` is passed through verbatim — the caller (demote)
|
|
569
|
+
* already sets CRTR_ROOT_SESSION (children → backstage) + FRONT_DOOR. Detached
|
|
570
|
+
* respawn, since the pane is often the caller's own. Returns whether the respawn
|
|
571
|
+
* dispatched. */
|
|
572
|
+
export function recycleFocusPane(nodeId, pane, launch) {
|
|
573
|
+
reconcile(nodeId);
|
|
574
|
+
const loc = paneLocation(pane);
|
|
575
|
+
const session = loc?.session ?? homeSessionOf(nodeId);
|
|
576
|
+
const ok = respawnPaneDetached({ pane, cwd: launch.cwd, env: launch.env, command: launch.command });
|
|
577
|
+
if (ok)
|
|
578
|
+
setPresence(nodeId, { pane, tmux_session: session, window: loc?.window ?? windowOfPane(pane) });
|
|
579
|
+
return ok;
|
|
580
|
+
}
|
|
581
|
+
/** §1.6 lifecycle successor — hand a truly-done focused node's viewport to its
|
|
582
|
+
* manager. Repoints the focus row `focusId` to `managerId` (a DB swap of the
|
|
583
|
+
* occupant). Two takeover realizations, split on the manager's liveness:
|
|
584
|
+
* - DORMANT manager (dead pi): the row repoint is all this does; the manager,
|
|
585
|
+
* woken by the finished node's `push final` landing in its inbox, is revived
|
|
586
|
+
* by the external daemon INTO this node's now-frozen focus pane
|
|
587
|
+
* (remain-on-exit), where reviveIntoPlacement's focus-pane branch resumes it
|
|
588
|
+
* in place — no new window, no taint. (UNCHANGED — the canonical takeover.)
|
|
589
|
+
* - LIVE manager (pi alive in the backstage, the normal multi-child state):
|
|
590
|
+
* the daemon never revives it (it only respawns dead-pi nodes), so we must
|
|
591
|
+
* bring it into the viewport SYNCHRONOUSLY here — swap its backstage pane
|
|
592
|
+
* into the focus slot (MAJOR 1). Otherwise the manager runs off-screen
|
|
593
|
+
* forever while %m sits orphaned in the viewport and the focus row lies
|
|
594
|
+
* about LOCATION.
|
|
595
|
+
* Returns false — the caller closes the focus (Q1) — when there is no manager,
|
|
596
|
+
* the manager IS this node, or the manager already occupies another viewport
|
|
597
|
+
* (UNIQUE node_id: do NOT move it, §1.6 edge).
|
|
598
|
+
*
|
|
599
|
+
* Why the live swap is NOT the forbidden self-saw: `swap-pane -d` only EXCHANGES
|
|
600
|
+
* two panes' slot positions; it never respawns or kills the finishing node's own
|
|
601
|
+
* pi. The forbidden move is a synchronous `respawn-pane -k %m` from inside %m —
|
|
602
|
+
* we never do that here. After the swap, %m (the dying node's pane) sits in the
|
|
603
|
+
* manager's old backstage slot; the caller nulls this node's presence so nothing
|
|
604
|
+
* tracks the corpse. */
|
|
605
|
+
export function handFocusToManager(focusId, managerId) {
|
|
606
|
+
if (managerId === null)
|
|
607
|
+
return false;
|
|
608
|
+
const f = getFocusById(focusId);
|
|
609
|
+
if (f === null || managerId === f.node_id)
|
|
610
|
+
return false;
|
|
611
|
+
if (getFocusByNode(managerId) !== null)
|
|
612
|
+
return false; // manager already focused elsewhere
|
|
613
|
+
setFocusOccupant(focusId, managerId);
|
|
614
|
+
setFocus(managerId);
|
|
615
|
+
// MAJOR 1 — LIVE backstage manager → swap it into the focus slot now. DORMANT
|
|
616
|
+
// managers (no live pane / dead pi) fall through unchanged: the daemon revives
|
|
617
|
+
// them into the frozen %m async.
|
|
618
|
+
const mgr = getRow(managerId);
|
|
619
|
+
if (mgr !== null && mgr.pane != null && isNodePaneAlive(mgr) && pidAlive(mgr.pi_pid) && f.pane != null) {
|
|
620
|
+
const focusLoc = paneLocation(f.pane); // F2's window/session — the slot mgr swaps INTO (%m is currently there)
|
|
621
|
+
if (swapPaneInPlace(mgr.pane, f.pane) && focusLoc !== null) {
|
|
622
|
+
setFocusPane(f.focus_id, mgr.pane, focusLoc.session); // re-anchor the focus row to mgr's pane (now in F2)
|
|
623
|
+
setPresence(managerId, { pane: mgr.pane, tmux_session: focusLoc.session, window: focusLoc.window });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return true; // still "took focus" — caller doesn't close
|
|
627
|
+
}
|
|
628
|
+
/** Join each of `childIds`' live panes into `targetId`'s window, lay them out
|
|
629
|
+
* (target wide on the left, children stacked right), and focus it. Reconcile
|
|
630
|
+
* drives both the target resolution and the per-join fix-up (a joined pane keeps
|
|
631
|
+
* its `%id` but changes window, so its LOCATION must FOLLOW — else the daemon
|
|
632
|
+
* reads it dormant). Caller revives dormant nodes first so they have live panes.
|
|
633
|
+
* No-op result when the target has no live pane. */
|
|
634
|
+
export function spreadNode(targetId, childIds, opts = {}) {
|
|
635
|
+
reconcile(targetId);
|
|
636
|
+
const trow = getRow(targetId);
|
|
637
|
+
if (trow === null || trow.pane === null || !paneExists(trow.pane)) {
|
|
638
|
+
return { window: null, session: null, joined: [], focused: false };
|
|
639
|
+
}
|
|
640
|
+
const tloc = paneLocation(trow.pane);
|
|
641
|
+
if (tloc === null)
|
|
642
|
+
return { window: null, session: null, joined: [], focused: false };
|
|
643
|
+
const { window: targetWindow, session: targetSession } = tloc;
|
|
644
|
+
const targetPane = trow.pane;
|
|
645
|
+
const joined = [];
|
|
646
|
+
for (const cid of childIds) {
|
|
647
|
+
reconcile(cid);
|
|
648
|
+
const crow = getRow(cid);
|
|
649
|
+
if (crow === null || crow.pane === null || !paneExists(crow.pane) || crow.pane === targetPane)
|
|
650
|
+
continue;
|
|
651
|
+
if (!joinPane(crow.pane, targetWindow))
|
|
652
|
+
continue;
|
|
653
|
+
reconcile(cid); // fix-up: presence FOLLOWS the joined pane to the target window
|
|
654
|
+
joined.push(cid);
|
|
655
|
+
}
|
|
656
|
+
if (joined.length > 0) {
|
|
657
|
+
setWindowOption(targetWindow, 'main-pane-width', opts.mainPaneWidth ?? '60%');
|
|
658
|
+
selectLayout(targetWindow, 'main-vertical');
|
|
659
|
+
}
|
|
660
|
+
const focused = switchClient(targetSession) && selectWindow(targetSession, targetWindow);
|
|
661
|
+
setFocus(targetId);
|
|
662
|
+
return { window: targetWindow, session: targetSession, joined, focused };
|
|
663
|
+
}
|