@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.
Files changed (40) hide show
  1. package/dist/builtin-personas/developer/orchestrator.md +1 -1
  2. package/dist/builtin-personas/plan/orchestrator.md +1 -1
  3. package/dist/commands/chord.js +1 -1
  4. package/dist/commands/human/shared.js +1 -1
  5. package/dist/commands/node.js +1 -2
  6. package/dist/commands/tmux-spread.js +2 -3
  7. package/dist/core/__tests__/close.test.js +2 -2
  8. package/dist/core/__tests__/focuses.test.js +5 -68
  9. package/dist/core/__tests__/home-session.test.js +1 -1
  10. package/dist/core/__tests__/placement-focus.test.js +54 -32
  11. package/dist/core/__tests__/placement-teardown.test.js +4 -9
  12. package/dist/core/__tests__/relaunch.test.js +10 -4
  13. package/dist/core/__tests__/tmux-surface.test.js +8 -9
  14. package/dist/core/canvas/db.js +2 -3
  15. package/dist/core/canvas/focuses.d.ts +2 -2
  16. package/dist/core/canvas/focuses.js +4 -3
  17. package/dist/core/canvas/types.d.ts +1 -1
  18. package/dist/core/runtime/close.js +2 -2
  19. package/dist/core/runtime/demote.js +2 -7
  20. package/dist/core/runtime/launch.d.ts +3 -1
  21. package/dist/core/runtime/launch.js +4 -1
  22. package/dist/core/runtime/nodes.d.ts +7 -0
  23. package/dist/core/runtime/nodes.js +10 -1
  24. package/dist/core/runtime/placement.d.ts +17 -5
  25. package/dist/core/runtime/placement.js +56 -31
  26. package/dist/core/runtime/reset.js +13 -13
  27. package/dist/core/runtime/revive.d.ts +1 -1
  28. package/dist/core/runtime/revive.js +2 -2
  29. package/dist/core/runtime/spawn.js +3 -3
  30. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  31. package/dist/core/runtime/tmux-chrome.js +4 -0
  32. package/dist/core/runtime/tmux.d.ts +13 -6
  33. package/dist/core/runtime/tmux.js +21 -12
  34. package/dist/pi-extensions/canvas-nav.js +11 -3
  35. package/dist/pi-extensions/canvas-resume.d.ts +22 -0
  36. package/dist/pi-extensions/canvas-resume.js +173 -0
  37. package/dist/pi-extensions/canvas-stophook.js +5 -9
  38. package/package.json +2 -2
  39. package/dist/core/runtime/presence.d.ts +0 -30
  40. 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, closeFocusRow, setPresence } from '../core/canvas/index.js';
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 { setFocus } from '../core/runtime/presence.js';
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
- closeFocusRow(f.focus_id);
427
- setFocus('');
428
- const win = getNode(nodeId)?.window; // %m's window
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.15",
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.14",
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
- }