@crouton-kit/crouter 0.3.15 → 0.3.16
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/plan/orchestrator.md +1 -1
- package/dist/commands/chord.js +1 -1
- package/dist/commands/human/shared.js +1 -1
- package/dist/commands/node.js +1 -2
- package/dist/commands/tmux-spread.js +2 -3
- package/dist/core/__tests__/close.test.js +2 -2
- package/dist/core/__tests__/focuses.test.js +5 -68
- package/dist/core/__tests__/home-session.test.js +1 -1
- package/dist/core/__tests__/placement-focus.test.js +54 -32
- package/dist/core/__tests__/placement-teardown.test.js +4 -9
- package/dist/core/__tests__/relaunch.test.js +10 -4
- package/dist/core/__tests__/tmux-surface.test.js +8 -9
- 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/types.d.ts +1 -1
- 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/nodes.d.ts +7 -0
- package/dist/core/runtime/nodes.js +10 -1
- package/dist/core/runtime/placement.d.ts +17 -5
- package/dist/core/runtime/placement.js +56 -31
- package/dist/core/runtime/reset.js +13 -13
- 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/pi-extensions/canvas-nav.js +11 -3
- package/dist/pi-extensions/canvas-resume.d.ts +22 -0
- package/dist/pi-extensions/canvas-resume.js +173 -0
- package/dist/pi-extensions/canvas-stophook.js +5 -9
- package/package.json +2 -2
- package/dist/core/runtime/presence.d.ts +0 -30
- package/dist/core/runtime/presence.js +0 -178
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// canvas-resume.ts — pi extension registering the /resume-node canvas command.
|
|
2
|
+
//
|
|
3
|
+
// /resume-node — open a TREE-SHAPED picker over the WHOLE canvas (every root,
|
|
4
|
+
// INCLUDING DORMANT nodes: done / idle / dead / canceled) rendered with tree
|
|
5
|
+
// glyphs (├─ / └─) + a status tag + name + short id, then revive the chosen
|
|
6
|
+
// node by shelling `crtr node focus <id>` (fire-and-forget). Reviving dormant
|
|
7
|
+
// nodes is the entire point, so — unlike the BASE/GRAPH chrome and
|
|
8
|
+
// renderForest()'s live-only (active|idle) filter — this walks ALL roots
|
|
9
|
+
// and ALL statuses.
|
|
10
|
+
//
|
|
11
|
+
// The name is literally `resume-node`, NOT `resume`, to avoid clashing with
|
|
12
|
+
// pi's built-in /resume.
|
|
13
|
+
//
|
|
14
|
+
// ⚠ DESYNC — why `crtr node focus` is the ONLY sanctioned open
|
|
15
|
+
// `crtr node focus <id>` routes through reviveNode() (src/core/runtime/
|
|
16
|
+
// revive.ts), the ONLY sanctioned launcher of `pi --session <file>`: it sets
|
|
17
|
+
// CRTR_NODE_ID + the `-e` canvas extensions and runs transition('revive').
|
|
18
|
+
// A RAW `pi --session <file>` has NEITHER → every canvas hook is inert: the
|
|
19
|
+
// stophook never records pi_pid / clears intent / marks done, no inbox-watcher
|
|
20
|
+
// wakes it, and transition('revive') never runs so the row stays dormant.
|
|
21
|
+
// Worst case (idle + intent=idle-release) the daemon can't see the raw pi (no
|
|
22
|
+
// pi_pid) and DOUBLE-SPAWNS a second pi on the same .jsonl, corrupting the
|
|
23
|
+
// conversation. A UI must therefore NEVER spawn `pi --session` directly — it
|
|
24
|
+
// opens nodes via `crtr node focus` / `crtr canvas revive`.
|
|
25
|
+
//
|
|
26
|
+
// INERT when CRTR_NODE_ID is absent (a plain pi session, not a canvas node).
|
|
27
|
+
//
|
|
28
|
+
// Plain TS-with-types — no imports from @earendil-works/* so this compiles
|
|
29
|
+
// inside crouter's own tsc build without a dep on the pi packages (mirrors
|
|
30
|
+
// canvas-nav.ts / canvas-commands.ts).
|
|
31
|
+
import { execFile } from 'node:child_process';
|
|
32
|
+
import { getNode, listNodes, subscriptionsOf, fullName } from '../core/canvas/index.js';
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Forest rendering — one line per node across the WHOLE canvas, with a parallel
|
|
35
|
+
// ids[] array so the chosen line maps back to its node_id. Plain unicode glyphs
|
|
36
|
+
// (no ANSI) so the line renders cleanly inside pi's select dialog.
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
const STATUS_GLYPH = {
|
|
39
|
+
active: '●',
|
|
40
|
+
idle: '○',
|
|
41
|
+
done: '✓',
|
|
42
|
+
dead: '✗',
|
|
43
|
+
canceled: '⊘',
|
|
44
|
+
};
|
|
45
|
+
function shortId(id) {
|
|
46
|
+
return id.slice(0, 8);
|
|
47
|
+
}
|
|
48
|
+
/** `<glyph> <status> <name> [<kind>/<mode>] (<shortid>)` — a status TAG + name
|
|
49
|
+
* + short id, prefixed with the tree branch. Best-effort on a missing meta. */
|
|
50
|
+
function nodeLabel(nodeId, branch) {
|
|
51
|
+
const node = getNode(nodeId);
|
|
52
|
+
if (node === null)
|
|
53
|
+
return `${branch}? <missing ${shortId(nodeId)}>`;
|
|
54
|
+
const glyph = STATUS_GLYPH[node.status] ?? '?';
|
|
55
|
+
return `${branch}${glyph} ${node.status} ${fullName(node)} [${node.kind}/${node.mode}] (${shortId(nodeId)})`;
|
|
56
|
+
}
|
|
57
|
+
/** Sort rank for roots — live first (active, then idle), dormant after. Keeps
|
|
58
|
+
* the picker oriented while still listing every dormant root. */
|
|
59
|
+
function statusRank(status) {
|
|
60
|
+
switch (status) {
|
|
61
|
+
case 'active': return 0;
|
|
62
|
+
case 'idle': return 1;
|
|
63
|
+
case 'done': return 2;
|
|
64
|
+
case 'canceled': return 3;
|
|
65
|
+
case 'dead': return 4;
|
|
66
|
+
default: return 5;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Recursively render the subscription subtree rooted at `nodeId` into the
|
|
70
|
+
* parallel lines/ids arrays. Mirrors render.ts walkTree but keeps lines and
|
|
71
|
+
* ids strictly 1:1 (a cycle back-ref still maps to its real node, so selecting
|
|
72
|
+
* it just focuses that node — harmless). Cycle-safe via `visited`. */
|
|
73
|
+
function walkSubtree(nodeId, indent, connector, visited, out) {
|
|
74
|
+
if (visited.has(nodeId)) {
|
|
75
|
+
out.lines.push(`${indent}${connector}↺ ${shortId(nodeId)} (cycle)`);
|
|
76
|
+
out.ids.push(nodeId);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
visited.add(nodeId);
|
|
80
|
+
out.lines.push(nodeLabel(nodeId, `${indent}${connector}`));
|
|
81
|
+
out.ids.push(nodeId);
|
|
82
|
+
const children = subscriptionsOf(nodeId);
|
|
83
|
+
// Root rows carry no connector; children of a last-child get clear space, of a
|
|
84
|
+
// mid-child a continued spine — exactly render.ts walkTree's prefix math.
|
|
85
|
+
const childIndent = indent + (connector === '' ? '' : connector === '└─ ' ? ' ' : '│ ');
|
|
86
|
+
for (let i = 0; i < children.length; i++) {
|
|
87
|
+
const isLast = i === children.length - 1;
|
|
88
|
+
walkSubtree(children[i].node_id, childIndent, isLast ? '└─ ' : '├─ ', visited, out);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** The whole-canvas forest: EVERY root (parent === null, ANY status) and its
|
|
92
|
+
* subtree, flattened to parallel label/id arrays. */
|
|
93
|
+
function buildForest() {
|
|
94
|
+
const out = { lines: [], ids: [] };
|
|
95
|
+
const visited = new Set();
|
|
96
|
+
const roots = listNodes()
|
|
97
|
+
.filter((n) => n.parent === null)
|
|
98
|
+
.sort((a, b) => statusRank(a.status) - statusRank(b.status));
|
|
99
|
+
for (const r of roots)
|
|
100
|
+
walkSubtree(r.node_id, '', '', visited, out);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Extension
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
/**
|
|
107
|
+
* Register the /resume-node command on `pi`.
|
|
108
|
+
*
|
|
109
|
+
* Returns immediately when CRTR_NODE_ID is absent — the extension is fully
|
|
110
|
+
* inert in a non-canvas pi session.
|
|
111
|
+
*/
|
|
112
|
+
export function registerCanvasResume(pi) {
|
|
113
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
114
|
+
if (nodeId === undefined || nodeId.trim() === '')
|
|
115
|
+
return; // not a canvas node
|
|
116
|
+
if (typeof pi.registerCommand !== 'function')
|
|
117
|
+
return;
|
|
118
|
+
pi.registerCommand('resume-node', {
|
|
119
|
+
description: 'Resume a node — pick from the whole canvas (incl. dormant) and revive it',
|
|
120
|
+
handler: async (_args, ctx) => {
|
|
121
|
+
// select() is a terminal-only dialog — guard the run mode before it.
|
|
122
|
+
if (ctx.mode !== 'tui') {
|
|
123
|
+
try {
|
|
124
|
+
ctx.ui.notify('/resume-node needs the interactive TUI', 'warning');
|
|
125
|
+
}
|
|
126
|
+
catch { /* best-effort */ }
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
let forest;
|
|
130
|
+
try {
|
|
131
|
+
forest = buildForest();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
try {
|
|
135
|
+
ctx.ui.notify('resume: could not read the canvas', 'error');
|
|
136
|
+
}
|
|
137
|
+
catch { /* best-effort */ }
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (forest.lines.length === 0) {
|
|
141
|
+
try {
|
|
142
|
+
ctx.ui.notify('No nodes on the canvas to resume.', 'info');
|
|
143
|
+
}
|
|
144
|
+
catch { /* best-effort */ }
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const choice = await ctx.ui.select('Resume which node?', forest.lines);
|
|
148
|
+
if (choice === undefined)
|
|
149
|
+
return; // cancelled / timed out
|
|
150
|
+
const idx = forest.lines.indexOf(choice);
|
|
151
|
+
const targetId = idx >= 0 ? forest.ids[idx] : undefined;
|
|
152
|
+
if (targetId === undefined)
|
|
153
|
+
return;
|
|
154
|
+
// The ONLY sync-safe open: route through reviveNode via `crtr node focus`.
|
|
155
|
+
// Fire-and-forget — `node focus` swaps the target into THIS pane, replacing
|
|
156
|
+
// the current pi, so the callback may never run (best-effort notify only).
|
|
157
|
+
try {
|
|
158
|
+
execFile('crtr', ['node', 'focus', targetId], (err) => {
|
|
159
|
+
if (err != null) {
|
|
160
|
+
try {
|
|
161
|
+
ctx.ui.notify(`resume failed: focus ${shortId(targetId)}`, 'error');
|
|
162
|
+
}
|
|
163
|
+
catch { /* best-effort */ }
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
/* best-effort */
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
export default registerCanvasResume;
|
|
@@ -28,15 +28,13 @@
|
|
|
28
28
|
// crouter's own tsc build without a dep on the pi packages.
|
|
29
29
|
import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
|
|
30
30
|
import { join } from 'node:path';
|
|
31
|
-
import { getNode, jobDir, updateNode, recordPid, subscribersOf,
|
|
31
|
+
import { getNode, jobDir, updateNode, recordPid, subscribersOf, setPresence } from '../core/canvas/index.js';
|
|
32
32
|
import { transition } from '../core/runtime/lifecycle.js';
|
|
33
33
|
import { evaluateStop } from '../core/runtime/stop-guard.js';
|
|
34
34
|
import { personaDrift, commitPersonaAck } from '../core/runtime/persona.js';
|
|
35
35
|
import { reviveInPlace } from '../core/runtime/revive.js';
|
|
36
36
|
import { handleNewSession, markCleanExitDone } from '../core/runtime/reset.js';
|
|
37
|
-
import {
|
|
38
|
-
import { focusOf, handFocusToManager, tearDownNode } from '../core/runtime/placement.js';
|
|
39
|
-
import { setRemainOnExit } from '../core/runtime/tmux.js';
|
|
37
|
+
import { focusOf, handFocusToManager, tearDownNode, closeFocusToShell } from '../core/runtime/placement.js';
|
|
40
38
|
/**
|
|
41
39
|
* Merge accumulated token counts into nodes/<nodeId>/job/telemetry.json.
|
|
42
40
|
* Creates the directory when it doesn't yet exist. Best-effort; never throws.
|
|
@@ -423,11 +421,9 @@ export function registerCanvasStophook(pi) {
|
|
|
423
421
|
if (f !== null) {
|
|
424
422
|
const managerId = node.parent ?? subscribersOf(nodeId)[0]?.node_id ?? null;
|
|
425
423
|
if (!handFocusToManager(f.focus_id, managerId)) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (win)
|
|
430
|
-
setRemainOnExit(win, false); // Q1 return-to-shell
|
|
424
|
+
// Q1 return-to-shell, self-saw-safe: close the focus row + disarm the
|
|
425
|
+
// pane's freeze so it reaps on exit (we can't closePane our own pane).
|
|
426
|
+
closeFocusToShell(f.focus_id, nodeId);
|
|
431
427
|
}
|
|
432
428
|
}
|
|
433
429
|
setPresence(nodeId, { pane: null, tmux_session: null, window: null }); // M done → owns no pane
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crouton-kit/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.16",
|
|
4
4
|
"description": "crtr — fast access to skills, plugins, and marketplaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"license": "MIT",
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@crouton-kit/humanloop": "^0.3.
|
|
39
|
+
"@crouton-kit/humanloop": "^0.3.15",
|
|
40
40
|
"commander": "^13.0.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import type { NodeMeta } from '../canvas/index.js';
|
|
2
|
-
/** Persist `nodeId` as the currently focused node. Best-effort; never throws.
|
|
3
|
-
* Also maintains the transitional focus.ptr↔focuses-table dual-write bridge
|
|
4
|
-
* (see below) so Step 6 can flip reads to the table with no data gap. */
|
|
5
|
-
export declare function setFocus(nodeId: string): void;
|
|
6
|
-
/** Read the currently focused node id, or null if there is no active focus.
|
|
7
|
-
* Reads `focus.ptr` first; FALLS BACK to the canonical focuses row (the bridge,
|
|
8
|
-
* below) when the pointer is absent/empty — so a reader sees the same focus
|
|
9
|
-
* whichever store a writer reached. Best-effort; never throws. */
|
|
10
|
-
export declare function getFocus(): string | null;
|
|
11
|
-
/** True when the node's tmux window is alive. A falsy tmux_session/window
|
|
12
|
-
* always returns false so callers don't need to null-guard. */
|
|
13
|
-
export declare function nodeLive(meta: NodeMeta): boolean;
|
|
14
|
-
/** Bring a node's tmux window to the foreground and record it as focused.
|
|
15
|
-
*
|
|
16
|
-
* Strategy:
|
|
17
|
-
* - If the node has no live window (`nodeLive` is false), still write the
|
|
18
|
-
* focus pointer — the caller (e.g. revive logic) uses `focused:false` to
|
|
19
|
-
* know it needs to open a window first.
|
|
20
|
-
* - Otherwise call `switchClient` (lands us in the right session) then
|
|
21
|
-
* `selectWindow` (picks the right window within it). Both calls are
|
|
22
|
-
* best-effort; the focus pointer is always written regardless.
|
|
23
|
-
*
|
|
24
|
-
* Returns:
|
|
25
|
-
* focused — whether the tmux focus actually succeeded.
|
|
26
|
-
* session — the tmux session name if one was attempted, null otherwise. */
|
|
27
|
-
export declare function focusNode(nodeId: string): {
|
|
28
|
-
focused: boolean;
|
|
29
|
-
session: string | null;
|
|
30
|
-
};
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
// presence.ts — focus pointer + per-node liveness helpers.
|
|
2
|
-
//
|
|
3
|
-
// The focus pointer (`<crtrHome>/focus.ptr`) is a plain-text file holding the
|
|
4
|
-
// node id that currently "has focus" — meaning the user's terminal is showing
|
|
5
|
-
// that node's tmux window. It is written on every explicit `focusNode()` call
|
|
6
|
-
// and read by the dashboard / status-line to highlight the active node.
|
|
7
|
-
//
|
|
8
|
-
// This is intentionally a simple file-based pointer rather than a database
|
|
9
|
-
// column: focus is transient UI state, not durable business data. A crash
|
|
10
|
-
// leaves a stale pointer that the next focusNode() clobbers — harmless.
|
|
11
|
-
//
|
|
12
|
-
// focusNode() does two things:
|
|
13
|
-
// 1. Ensures the user's terminal lands on the right tmux window by calling
|
|
14
|
-
// switchClient (cross-session) then selectWindow (in-session). Both are
|
|
15
|
-
// best-effort; we set the pointer regardless so the dashboard stays in sync.
|
|
16
|
-
// 2. Persists the node id to focus.ptr so any process can quickly read "what
|
|
17
|
-
// is the user looking at?".
|
|
18
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
-
import { dirname } from 'node:path';
|
|
20
|
-
import { join } from 'node:path';
|
|
21
|
-
import { crtrHome, getNode, getRow, openFocusRow, closeFocusRow, getFocusById, getFocusByNode, } from '../canvas/index.js';
|
|
22
|
-
import { selectWindow, switchClient, windowAlive, currentTmux, inTmux } from './tmux.js';
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// Focus pointer
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
/** Absolute path to the focus pointer file. */
|
|
27
|
-
function focusPtrPath() {
|
|
28
|
-
return join(crtrHome(), 'focus.ptr');
|
|
29
|
-
}
|
|
30
|
-
/** Persist `nodeId` as the currently focused node. Best-effort; never throws.
|
|
31
|
-
* Also maintains the transitional focus.ptr↔focuses-table dual-write bridge
|
|
32
|
-
* (see below) so Step 6 can flip reads to the table with no data gap. */
|
|
33
|
-
export function setFocus(nodeId) {
|
|
34
|
-
try {
|
|
35
|
-
const p = focusPtrPath();
|
|
36
|
-
mkdirSync(dirname(p), { recursive: true });
|
|
37
|
-
writeFileSync(p, nodeId, 'utf8');
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
/* focus pointer is best-effort; never surface */
|
|
41
|
-
}
|
|
42
|
-
syncBridgeFocusRow(nodeId); // Step-4 dual-write bridge (REMOVED in Step 8)
|
|
43
|
-
}
|
|
44
|
-
/** Read the currently focused node id, or null if there is no active focus.
|
|
45
|
-
* Reads `focus.ptr` first; FALLS BACK to the canonical focuses row (the bridge,
|
|
46
|
-
* below) when the pointer is absent/empty — so a reader sees the same focus
|
|
47
|
-
* whichever store a writer reached. Best-effort; never throws. */
|
|
48
|
-
export function getFocus() {
|
|
49
|
-
try {
|
|
50
|
-
const raw = readFileSync(focusPtrPath(), 'utf8').trim();
|
|
51
|
-
if (raw !== '')
|
|
52
|
-
return raw;
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
/* pointer absent — fall through to the table */
|
|
56
|
-
}
|
|
57
|
-
// Bridge fallback: the canonical focus row's occupant (Step-8 removal).
|
|
58
|
-
try {
|
|
59
|
-
return getFocusById(BRIDGE_FOCUS_ID)?.node_id ?? null;
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
// Transitional focus.ptr ↔ focuses-table dual-write bridge.
|
|
67
|
-
//
|
|
68
|
-
// THROWAWAY — DELETED IN STEP 8. Today `focus.ptr` owns the single "current"
|
|
69
|
-
// focus. Step 4 stands up the plural `focuses` table but nothing reads it as
|
|
70
|
-
// authority yet (that switch is Step 6). To populate it in lockstep WITHOUT a
|
|
71
|
-
// behavior change, every `setFocus` ALSO writes one canonical focus row that
|
|
72
|
-
// mirrors `focus.ptr`, and `getFocus` falls back to it. Step 6 replaces
|
|
73
|
-
// focusNodeInPlace with retargetFocus/openFocus, which write pane-correct focus
|
|
74
|
-
// rows directly — then this bridge (and focus.ptr) is removed.
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
/** The fixed focus_id of the one canonical row that mirrors `focus.ptr`. */
|
|
77
|
-
const BRIDGE_FOCUS_ID = '__focus_ptr__';
|
|
78
|
-
/** Best-effort pane/session for the canonical focus row. A bare `setFocus(id)`
|
|
79
|
-
* only carries a node id, but a focus row wants pane+session. Resolve them
|
|
80
|
-
* READ-ONLY from the node's already-stored LOCATION (`row.pane`/`tmux_session`),
|
|
81
|
-
* else from the caller's current tmux pane (`currentTmux`).
|
|
82
|
-
*
|
|
83
|
-
* DELIBERATE DEVIATION from the design's "run reconcile(nodeId) first": reconcile
|
|
84
|
-
* WRITES node presence via setPresence, and `setFocus` has many non-focus callers
|
|
85
|
-
* (reset/close/demote/tmux-spread). Reconciling on every setFocus would mutate
|
|
86
|
-
* their nodes' LOCATION as an invisible side-effect of a dual-write that is
|
|
87
|
-
* supposed to change NOTHING this step. So the bridge reads, never reconciles;
|
|
88
|
-
* best-effort is fine THIS step (nothing reads the row as authority until Step 6,
|
|
89
|
-
* which replaces these writers with pane-correct retargetFocus/openFocus). */
|
|
90
|
-
function resolveBridgePaneSession(nodeId) {
|
|
91
|
-
try {
|
|
92
|
-
const row = getRow(nodeId);
|
|
93
|
-
if (row?.pane != null && row.pane !== '') {
|
|
94
|
-
return { pane: row.pane, session: row.tmux_session ?? null };
|
|
95
|
-
}
|
|
96
|
-
if (inTmux()) {
|
|
97
|
-
const cur = currentTmux();
|
|
98
|
-
if (cur)
|
|
99
|
-
return { pane: cur.pane, session: cur.session };
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
/* best-effort */
|
|
104
|
-
}
|
|
105
|
-
return { pane: null, session: null };
|
|
106
|
-
}
|
|
107
|
-
/** Mirror the current focus into the single canonical focuses row. `''` closes
|
|
108
|
-
* it (focus cleared). Otherwise re-point the row at `nodeId`: drop the prior
|
|
109
|
-
* canonical row and any row already holding `nodeId` (UNIQUE(node_id) safety)
|
|
110
|
-
* before re-inserting. All best-effort — a failure here must never break a
|
|
111
|
-
* setFocus caller or the build. */
|
|
112
|
-
function syncBridgeFocusRow(nodeId) {
|
|
113
|
-
try {
|
|
114
|
-
if (nodeId === '') {
|
|
115
|
-
closeFocusRow(BRIDGE_FOCUS_ID);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
// Step 6: retargetFocus/openFocus now write REAL (pane-correct) focus rows.
|
|
119
|
-
// If one already shows this node, the table is already authoritative —
|
|
120
|
-
// focus.ptr (the file, written above) names the node and getFocus's fallback
|
|
121
|
-
// reads the real row. Drop any stale bridge row and PIGGYBACK on the real
|
|
122
|
-
// one; never duplicate-insert (UNIQUE node_id) or clobber it.
|
|
123
|
-
const real = getFocusByNode(nodeId);
|
|
124
|
-
if (real !== null && real.focus_id !== BRIDGE_FOCUS_ID) {
|
|
125
|
-
closeFocusRow(BRIDGE_FOCUS_ID);
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
const { pane, session } = resolveBridgePaneSession(nodeId);
|
|
129
|
-
closeFocusRow(BRIDGE_FOCUS_ID);
|
|
130
|
-
openFocusRow(BRIDGE_FOCUS_ID, pane, session, nodeId);
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
/* dual-write is best-effort; never surface */
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
// ---------------------------------------------------------------------------
|
|
137
|
-
// Liveness
|
|
138
|
-
// ---------------------------------------------------------------------------
|
|
139
|
-
/** True when the node's tmux window is alive. A falsy tmux_session/window
|
|
140
|
-
* always returns false so callers don't need to null-guard. */
|
|
141
|
-
export function nodeLive(meta) {
|
|
142
|
-
return windowAlive(meta.tmux_session, meta.window);
|
|
143
|
-
}
|
|
144
|
-
// ---------------------------------------------------------------------------
|
|
145
|
-
// Focus
|
|
146
|
-
// ---------------------------------------------------------------------------
|
|
147
|
-
/** Bring a node's tmux window to the foreground and record it as focused.
|
|
148
|
-
*
|
|
149
|
-
* Strategy:
|
|
150
|
-
* - If the node has no live window (`nodeLive` is false), still write the
|
|
151
|
-
* focus pointer — the caller (e.g. revive logic) uses `focused:false` to
|
|
152
|
-
* know it needs to open a window first.
|
|
153
|
-
* - Otherwise call `switchClient` (lands us in the right session) then
|
|
154
|
-
* `selectWindow` (picks the right window within it). Both calls are
|
|
155
|
-
* best-effort; the focus pointer is always written regardless.
|
|
156
|
-
*
|
|
157
|
-
* Returns:
|
|
158
|
-
* focused — whether the tmux focus actually succeeded.
|
|
159
|
-
* session — the tmux session name if one was attempted, null otherwise. */
|
|
160
|
-
export function focusNode(nodeId) {
|
|
161
|
-
const meta = getNode(nodeId);
|
|
162
|
-
// Always write the pointer so the dashboard reflects intent even when focus
|
|
163
|
-
// fails (e.g. we're not currently inside tmux).
|
|
164
|
-
setFocus(nodeId);
|
|
165
|
-
if (meta === null || !nodeLive(meta)) {
|
|
166
|
-
// Node not found or window is gone — caller may need to revive.
|
|
167
|
-
return { focused: false, session: meta?.tmux_session ?? null };
|
|
168
|
-
}
|
|
169
|
-
// Both fields are non-null thanks to nodeLive() returning true.
|
|
170
|
-
const session = meta.tmux_session;
|
|
171
|
-
const window = meta.window;
|
|
172
|
-
// Cross-session hop first, then window selection within the session.
|
|
173
|
-
// switchClient may be a no-op when already in the same session but is
|
|
174
|
-
// always safe to call — tmux handles it gracefully.
|
|
175
|
-
const clientOk = switchClient(session);
|
|
176
|
-
const windowOk = selectWindow(session, window);
|
|
177
|
-
return { focused: clientOk && windowOk, session };
|
|
178
|
-
}
|