@crouton-kit/crouter 0.3.15 → 0.3.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtin-personas/developer/orchestrator.md +1 -1
- package/dist/builtin-personas/orchestration-kernel.md +6 -6
- package/dist/builtin-personas/plan/base.md +1 -1
- package/dist/builtin-personas/plan/orchestrator.md +1 -1
- package/dist/builtin-personas/spec/base.md +1 -1
- package/dist/commands/canvas-browse.d.ts +2 -0
- package/dist/commands/canvas-browse.js +45 -0
- package/dist/commands/canvas-prune.js +11 -2
- package/dist/commands/canvas.js +3 -2
- package/dist/commands/chord.js +1 -1
- package/dist/commands/human/shared.js +1 -1
- package/dist/commands/node.js +14 -2
- package/dist/commands/skill/author.js +2 -2
- package/dist/commands/tmux-spread.js +2 -3
- package/dist/core/__tests__/cascade-close.test.js +199 -0
- package/dist/core/__tests__/close.test.js +2 -2
- package/dist/core/__tests__/daemon-boot.test.js +7 -0
- package/dist/core/__tests__/daemon-liveness.test.js +59 -4
- package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
- package/dist/core/__tests__/focuses.test.js +5 -68
- package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
- package/dist/core/__tests__/grace-clock.test.js +115 -0
- package/dist/core/__tests__/helpers/harness.d.ts +78 -0
- package/dist/core/__tests__/helpers/harness.js +406 -0
- package/dist/core/__tests__/home-session.test.js +1 -1
- package/dist/core/__tests__/lifecycle.test.js +6 -13
- package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation.test.js +341 -0
- package/dist/core/__tests__/placement-focus.test.js +106 -46
- package/dist/core/__tests__/placement-teardown.test.js +4 -9
- package/dist/core/__tests__/relaunch.test.js +22 -16
- package/dist/core/__tests__/reset.test.js +11 -6
- package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
- package/dist/core/__tests__/spike-harness.test.js +241 -0
- package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/subscription-delivery.test.js +233 -0
- package/dist/core/__tests__/tmux-surface.test.js +8 -9
- package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
- package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +4 -0
- package/dist/core/canvas/browse/app.js +349 -0
- package/dist/core/canvas/browse/model.d.ts +97 -0
- package/dist/core/canvas/browse/model.js +258 -0
- package/dist/core/canvas/browse/render.d.ts +41 -0
- package/dist/core/canvas/browse/render.js +387 -0
- package/dist/core/canvas/browse/terminal.d.ts +23 -0
- package/dist/core/canvas/browse/terminal.js +100 -0
- package/dist/core/canvas/canvas.d.ts +9 -2
- package/dist/core/canvas/canvas.js +41 -3
- package/dist/core/canvas/db.js +2 -3
- package/dist/core/canvas/focuses.d.ts +2 -2
- package/dist/core/canvas/focuses.js +4 -3
- package/dist/core/canvas/render.d.ts +10 -0
- package/dist/core/canvas/render.js +25 -1
- package/dist/core/canvas/types.d.ts +1 -1
- package/dist/core/feed/inbox.d.ts +0 -3
- package/dist/core/feed/inbox.js +1 -5
- package/dist/core/runtime/busy.d.ts +8 -0
- package/dist/core/runtime/busy.js +46 -0
- package/dist/core/runtime/close.js +2 -2
- package/dist/core/runtime/demote.js +2 -7
- package/dist/core/runtime/launch.d.ts +3 -1
- package/dist/core/runtime/launch.js +4 -1
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +12 -4
- package/dist/core/runtime/naming.d.ts +3 -3
- package/dist/core/runtime/naming.js +6 -6
- package/dist/core/runtime/nodes.d.ts +7 -0
- package/dist/core/runtime/nodes.js +10 -1
- package/dist/core/runtime/placement.d.ts +39 -10
- package/dist/core/runtime/placement.js +100 -44
- package/dist/core/runtime/reset.d.ts +11 -8
- package/dist/core/runtime/reset.js +36 -31
- package/dist/core/runtime/revive.d.ts +1 -1
- package/dist/core/runtime/revive.js +2 -2
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/runtime/tmux-chrome.d.ts +1 -0
- package/dist/core/runtime/tmux-chrome.js +4 -0
- package/dist/core/runtime/tmux.d.ts +13 -6
- package/dist/core/runtime/tmux.js +21 -12
- package/dist/daemon/crtrd.js +43 -21
- package/dist/pi-extensions/canvas-nav.js +40 -28
- package/dist/pi-extensions/canvas-resume.d.ts +21 -0
- package/dist/pi-extensions/canvas-resume.js +82 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +21 -9
- package/dist/prompts/skill.js +6 -1
- package/package.json +2 -2
- package/dist/commands/__tests__/skill.test.js +0 -290
- package/dist/core/__tests__/pkg.test.js +0 -218
- package/dist/core/__tests__/sys.test.js +0 -208
- package/dist/core/runtime/presence.d.ts +0 -30
- package/dist/core/runtime/presence.js +0 -178
- /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
|
@@ -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
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|