@crouton-kit/crouter 0.3.15 → 0.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/builtin-personas/developer/orchestrator.md +1 -1
  2. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  3. package/dist/builtin-personas/plan/base.md +1 -1
  4. package/dist/builtin-personas/plan/orchestrator.md +1 -1
  5. package/dist/builtin-personas/spec/base.md +1 -1
  6. package/dist/commands/canvas-browse.d.ts +2 -0
  7. package/dist/commands/canvas-browse.js +45 -0
  8. package/dist/commands/canvas-prune.js +11 -2
  9. package/dist/commands/canvas.js +3 -2
  10. package/dist/commands/chord.js +1 -1
  11. package/dist/commands/human/shared.js +1 -1
  12. package/dist/commands/node.js +14 -2
  13. package/dist/commands/skill/author.js +2 -2
  14. package/dist/commands/tmux-spread.js +2 -3
  15. package/dist/core/__tests__/cascade-close.test.js +199 -0
  16. package/dist/core/__tests__/close.test.js +2 -2
  17. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  18. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  19. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  20. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  21. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  22. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  23. package/dist/core/__tests__/focuses.test.js +5 -68
  24. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  25. package/dist/core/__tests__/grace-clock.test.js +115 -0
  26. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  27. package/dist/core/__tests__/helpers/harness.js +406 -0
  28. package/dist/core/__tests__/home-session.test.js +1 -1
  29. package/dist/core/__tests__/lifecycle.test.js +6 -13
  30. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  31. package/dist/core/__tests__/live-mutation.test.js +341 -0
  32. package/dist/core/__tests__/placement-focus.test.js +106 -46
  33. package/dist/core/__tests__/placement-teardown.test.js +4 -9
  34. package/dist/core/__tests__/relaunch.test.js +22 -16
  35. package/dist/core/__tests__/reset.test.js +11 -6
  36. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  37. package/dist/core/__tests__/spike-harness.test.js +241 -0
  38. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  39. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  40. package/dist/core/__tests__/tmux-surface.test.js +8 -9
  41. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  42. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  43. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  44. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  45. package/dist/core/canvas/browse/app.d.ts +4 -0
  46. package/dist/core/canvas/browse/app.js +349 -0
  47. package/dist/core/canvas/browse/model.d.ts +97 -0
  48. package/dist/core/canvas/browse/model.js +258 -0
  49. package/dist/core/canvas/browse/render.d.ts +41 -0
  50. package/dist/core/canvas/browse/render.js +387 -0
  51. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  52. package/dist/core/canvas/browse/terminal.js +100 -0
  53. package/dist/core/canvas/canvas.d.ts +9 -2
  54. package/dist/core/canvas/canvas.js +41 -3
  55. package/dist/core/canvas/db.js +2 -3
  56. package/dist/core/canvas/focuses.d.ts +2 -2
  57. package/dist/core/canvas/focuses.js +4 -3
  58. package/dist/core/canvas/render.d.ts +10 -0
  59. package/dist/core/canvas/render.js +25 -1
  60. package/dist/core/canvas/types.d.ts +1 -1
  61. package/dist/core/feed/inbox.d.ts +0 -3
  62. package/dist/core/feed/inbox.js +1 -5
  63. package/dist/core/runtime/busy.d.ts +8 -0
  64. package/dist/core/runtime/busy.js +46 -0
  65. package/dist/core/runtime/close.js +2 -2
  66. package/dist/core/runtime/demote.js +2 -7
  67. package/dist/core/runtime/launch.d.ts +3 -1
  68. package/dist/core/runtime/launch.js +4 -1
  69. package/dist/core/runtime/lifecycle.d.ts +1 -1
  70. package/dist/core/runtime/lifecycle.js +12 -4
  71. package/dist/core/runtime/naming.d.ts +3 -3
  72. package/dist/core/runtime/naming.js +6 -6
  73. package/dist/core/runtime/nodes.d.ts +7 -0
  74. package/dist/core/runtime/nodes.js +10 -1
  75. package/dist/core/runtime/placement.d.ts +39 -10
  76. package/dist/core/runtime/placement.js +100 -44
  77. package/dist/core/runtime/reset.d.ts +11 -8
  78. package/dist/core/runtime/reset.js +36 -31
  79. package/dist/core/runtime/revive.d.ts +1 -1
  80. package/dist/core/runtime/revive.js +2 -2
  81. package/dist/core/runtime/spawn.js +3 -3
  82. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  83. package/dist/core/runtime/tmux-chrome.js +4 -0
  84. package/dist/core/runtime/tmux.d.ts +13 -6
  85. package/dist/core/runtime/tmux.js +21 -12
  86. package/dist/daemon/crtrd.js +43 -21
  87. package/dist/pi-extensions/canvas-nav.js +40 -28
  88. package/dist/pi-extensions/canvas-resume.d.ts +21 -0
  89. package/dist/pi-extensions/canvas-resume.js +82 -0
  90. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  91. package/dist/pi-extensions/canvas-stophook.js +21 -9
  92. package/dist/prompts/skill.js +6 -1
  93. package/package.json +2 -2
  94. package/dist/commands/__tests__/skill.test.js +0 -290
  95. package/dist/core/__tests__/pkg.test.js +0 -218
  96. package/dist/core/__tests__/sys.test.js +0 -208
  97. package/dist/core/runtime/presence.d.ts +0 -30
  98. package/dist/core/runtime/presence.js +0 -178
  99. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  100. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  101. /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
@@ -1,208 +0,0 @@
1
- // Tests for the sys subtree after argv migration.
2
- // Exercises: positional key, --value coercion, enum validation (--scope, --target),
3
- // bool presence flags (--fix, --remote, --check).
4
- // Run with: node --import tsx/esm --test 'src/core/__tests__/**/*.test.ts'
5
- import { test, describe } from 'node:test';
6
- import assert from 'node:assert/strict';
7
- import { parseArgv } from '../command.js';
8
- // ---------------------------------------------------------------------------
9
- // Shared param schemas (mirror sys.ts definitions)
10
- // ---------------------------------------------------------------------------
11
- const configGetParams = [
12
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path.' },
13
- { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project', 'all'], required: false, constraint: 'Scope.' },
14
- ];
15
- const configSetParams = [
16
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path.' },
17
- { kind: 'flag', name: 'value', type: 'string', required: true, constraint: 'Value to write.' },
18
- { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope.' },
19
- ];
20
- const configPathParams = [
21
- { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project', 'all'], required: false, constraint: 'Scope.' },
22
- ];
23
- const sysDoctorParams = [
24
- { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope.' },
25
- { kind: 'flag', name: 'fix', type: 'bool', required: false, constraint: '' },
26
- { kind: 'flag', name: 'remote', type: 'bool', required: false, constraint: '' },
27
- ];
28
- const sysUpdateParams = [
29
- { kind: 'flag', name: 'target', type: 'enum', choices: ['self', 'content', 'all'], required: false, constraint: '' },
30
- { kind: 'flag', name: 'check', type: 'bool', required: false, constraint: '' },
31
- ];
32
- // ---------------------------------------------------------------------------
33
- // sys config get
34
- // ---------------------------------------------------------------------------
35
- describe('sys config get: argv parsing', () => {
36
- test('positional key is required — missing throws', async () => {
37
- await assert.rejects(() => parseArgv(configGetParams, []), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
38
- });
39
- test('positional key is captured', async () => {
40
- const result = await parseArgv(configGetParams, ['auto_update.crtr']);
41
- assert.equal(result['key'], 'auto_update.crtr');
42
- });
43
- test('--scope valid enum passes', async () => {
44
- const result = await parseArgv(configGetParams, ['some.key', '--scope', 'project']);
45
- assert.equal(result['scope'], 'project');
46
- });
47
- test('--scope "all" is valid', async () => {
48
- const result = await parseArgv(configGetParams, ['some.key', '--scope', 'all']);
49
- assert.equal(result['scope'], 'all');
50
- });
51
- test('--scope invalid enum throws', async () => {
52
- await assert.rejects(() => parseArgv(configGetParams, ['some.key', '--scope', 'global']), (err) => { assert.match(err.message, /must be one of/); return true; });
53
- });
54
- test('--scope absent leaves field undefined', async () => {
55
- const result = await parseArgv(configGetParams, ['some.key']);
56
- assert.equal(result['scope'], undefined);
57
- });
58
- });
59
- // ---------------------------------------------------------------------------
60
- // sys config set — value coercion
61
- // ---------------------------------------------------------------------------
62
- describe('sys config set: --value coercion', () => {
63
- test('string value passed through unchanged', async () => {
64
- const result = await parseArgv(configSetParams, ['some.key', '--value', 'hello']);
65
- assert.equal(result['value'], 'hello');
66
- });
67
- test('"true" string arrives as string (handler coerces)', async () => {
68
- const result = await parseArgv(configSetParams, ['some.key', '--value', 'true']);
69
- // The argv layer keeps it as a string; the handler calls parseConfigValue
70
- assert.equal(result['value'], 'true');
71
- });
72
- test('"false" string arrives as string', async () => {
73
- const result = await parseArgv(configSetParams, ['some.key', '--value', 'false']);
74
- assert.equal(result['value'], 'false');
75
- });
76
- test('integer string arrives as string', async () => {
77
- const result = await parseArgv(configSetParams, ['some.key', '--value', '42']);
78
- assert.equal(result['value'], '42');
79
- });
80
- test('--value is required — missing throws', async () => {
81
- await assert.rejects(() => parseArgv(configSetParams, ['some.key']), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
82
- });
83
- test('key positional is required — missing throws', async () => {
84
- await assert.rejects(() => parseArgv(configSetParams, ['--value', 'x']), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
85
- });
86
- test('--scope valid enum passes', async () => {
87
- const result = await parseArgv(configSetParams, ['some.key', '--value', 'x', '--scope', 'user']);
88
- assert.equal(result['scope'], 'user');
89
- });
90
- test('--scope "all" rejected (not in choices)', async () => {
91
- await assert.rejects(() => parseArgv(configSetParams, ['some.key', '--value', 'x', '--scope', 'all']), (err) => { assert.match(err.message, /must be one of/); return true; });
92
- });
93
- });
94
- // ---------------------------------------------------------------------------
95
- // parseConfigValue coercion (via a thin harness)
96
- // ---------------------------------------------------------------------------
97
- // We re-implement parseConfigValue's logic inline here to keep the test
98
- // self-contained (it's a private helper in sys.ts).
99
- function parseConfigValue(raw) {
100
- if (raw === 'true')
101
- return true;
102
- if (raw === 'false')
103
- return false;
104
- if (/^-?\d+$/.test(raw))
105
- return parseInt(raw, 10);
106
- return raw;
107
- }
108
- describe('parseConfigValue coercion', () => {
109
- test('"true" → boolean true', () => { assert.strictEqual(parseConfigValue('true'), true); });
110
- test('"false" → boolean false', () => { assert.strictEqual(parseConfigValue('false'), false); });
111
- test('"42" → number 42', () => { assert.strictEqual(parseConfigValue('42'), 42); });
112
- test('"-1" → number -1', () => { assert.strictEqual(parseConfigValue('-1'), -1); });
113
- test('"notify" → string', () => { assert.strictEqual(parseConfigValue('notify'), 'notify'); });
114
- test('"3.14" → string (non-integer float stays string)', () => { assert.strictEqual(parseConfigValue('3.14'), '3.14'); });
115
- test('empty string → string', () => { assert.strictEqual(parseConfigValue(''), ''); });
116
- });
117
- // ---------------------------------------------------------------------------
118
- // sys config path
119
- // ---------------------------------------------------------------------------
120
- describe('sys config path: argv parsing', () => {
121
- test('no args parses cleanly', async () => {
122
- const result = await parseArgv(configPathParams, []);
123
- assert.equal(result['scope'], undefined);
124
- });
125
- test('--scope user', async () => {
126
- const result = await parseArgv(configPathParams, ['--scope', 'user']);
127
- assert.equal(result['scope'], 'user');
128
- });
129
- test('--scope all', async () => {
130
- const result = await parseArgv(configPathParams, ['--scope', 'all']);
131
- assert.equal(result['scope'], 'all');
132
- });
133
- test('invalid --scope throws', async () => {
134
- await assert.rejects(() => parseArgv(configPathParams, ['--scope', 'bogus']), (err) => { assert.match(err.message, /must be one of/); return true; });
135
- });
136
- });
137
- // ---------------------------------------------------------------------------
138
- // sys doctor — bool presence flags
139
- // ---------------------------------------------------------------------------
140
- describe('sys doctor: bool presence flags', () => {
141
- test('no args: fix=false, remote=false, scope=undefined', async () => {
142
- const result = await parseArgv(sysDoctorParams, []);
143
- assert.equal(result['fix'], false);
144
- assert.equal(result['remote'], false);
145
- assert.equal(result['scope'], undefined);
146
- });
147
- test('--fix sets fix=true', async () => {
148
- const result = await parseArgv(sysDoctorParams, ['--fix']);
149
- assert.equal(result['fix'], true);
150
- });
151
- test('--remote sets remote=true', async () => {
152
- const result = await parseArgv(sysDoctorParams, ['--remote']);
153
- assert.equal(result['remote'], true);
154
- });
155
- test('--fix --remote both set', async () => {
156
- const result = await parseArgv(sysDoctorParams, ['--fix', '--remote']);
157
- assert.equal(result['fix'], true);
158
- assert.equal(result['remote'], true);
159
- });
160
- test('--scope user is valid', async () => {
161
- const result = await parseArgv(sysDoctorParams, ['--scope', 'user']);
162
- assert.equal(result['scope'], 'user');
163
- });
164
- test('--scope project is valid', async () => {
165
- const result = await parseArgv(sysDoctorParams, ['--scope', 'project']);
166
- assert.equal(result['scope'], 'project');
167
- });
168
- test('--scope all rejected (not in choices for doctor)', async () => {
169
- await assert.rejects(() => parseArgv(sysDoctorParams, ['--scope', 'all']), (err) => { assert.match(err.message, /must be one of/); return true; });
170
- });
171
- test('--fix=true rejected (bool takes no value)', async () => {
172
- await assert.rejects(() => parseArgv(sysDoctorParams, ['--fix=true']), (err) => { assert.match(err.message, /takes no value/); return true; });
173
- });
174
- });
175
- // ---------------------------------------------------------------------------
176
- // sys update — enum target + --check bool
177
- // ---------------------------------------------------------------------------
178
- describe('sys update: argv parsing', () => {
179
- test('no args: check=false, target=undefined', async () => {
180
- const result = await parseArgv(sysUpdateParams, []);
181
- assert.equal(result['check'], false);
182
- assert.equal(result['target'], undefined);
183
- });
184
- test('--check sets check=true', async () => {
185
- const result = await parseArgv(sysUpdateParams, ['--check']);
186
- assert.equal(result['check'], true);
187
- });
188
- test('--target self is valid', async () => {
189
- const result = await parseArgv(sysUpdateParams, ['--target', 'self']);
190
- assert.equal(result['target'], 'self');
191
- });
192
- test('--target content is valid', async () => {
193
- const result = await parseArgv(sysUpdateParams, ['--target', 'content']);
194
- assert.equal(result['target'], 'content');
195
- });
196
- test('--target all is valid', async () => {
197
- const result = await parseArgv(sysUpdateParams, ['--target', 'all']);
198
- assert.equal(result['target'], 'all');
199
- });
200
- test('--target bogus throws invalid_type', async () => {
201
- await assert.rejects(() => parseArgv(sysUpdateParams, ['--target', 'bogus']), (err) => { assert.match(err.message, /must be one of/); return true; });
202
- });
203
- test('--check --target self combined', async () => {
204
- const result = await parseArgv(sysUpdateParams, ['--check', '--target', 'self']);
205
- assert.equal(result['check'], true);
206
- assert.equal(result['target'], 'self');
207
- });
208
- });
@@ -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
- }