@osdlc/agent-seat 0.0.0-stage → 0.7.1
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/CHANGELOG.md +133 -0
- package/CONTRIBUTING.md +44 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +181 -2
- package/SECURITY.md +41 -0
- package/dist/contract.d.ts +297 -0
- package/dist/contract.js +102 -0
- package/dist/harness.d.ts +305 -0
- package/dist/harness.js +610 -0
- package/dist/hosts/cmux.d.ts +67 -0
- package/dist/hosts/cmux.js +252 -0
- package/dist/hosts/herdr.d.ts +78 -0
- package/dist/hosts/herdr.js +324 -0
- package/dist/hosts/jetbrains.d.ts +98 -0
- package/dist/hosts/jetbrains.js +353 -0
- package/dist/hosts/orca.d.ts +67 -0
- package/dist/hosts/orca.js +292 -0
- package/dist/hosts/otty.d.ts +70 -0
- package/dist/hosts/otty.js +233 -0
- package/dist/hosts/registry.d.ts +50 -0
- package/dist/hosts/registry.js +100 -0
- package/dist/hosts/session-panes.d.ts +29 -0
- package/dist/hosts/session-panes.js +49 -0
- package/dist/hosts/terminal.d.ts +23 -0
- package/dist/hosts/terminal.js +47 -0
- package/dist/hosts/tmux.d.ts +145 -0
- package/dist/hosts/tmux.js +409 -0
- package/dist/hosts/vscode-client.d.ts +81 -0
- package/dist/hosts/vscode-client.js +124 -0
- package/dist/hosts/vscode.d.ts +35 -0
- package/dist/hosts/vscode.js +127 -0
- package/dist/hosts/wezterm.d.ts +78 -0
- package/dist/hosts/wezterm.js +405 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +61 -0
- package/dist/open-workspace.d.ts +9 -0
- package/dist/open-workspace.js +12 -0
- package/dist/util.d.ts +50 -0
- package/dist/util.js +108 -0
- package/package.json +76 -3
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux: a detached session on `cwd` running a command, found again by a name
|
|
3
|
+
* bound to the caller's session id; see `docs/planning/research/tmux.md`.
|
|
4
|
+
*
|
|
5
|
+
* Unlike orca/cmux, tmux has no app-level worktree registry and no `--name`
|
|
6
|
+
* the caller supplies — a session name is chosen here, once, and must be
|
|
7
|
+
* unique server-wide. A launch carries the caller's session id
|
|
8
|
+
* (`CommandRequest.sessionId`), so the name is derived from it
|
|
9
|
+
* ({@link tmuxIdSessionName}) and two sessions in one worktree get two tmux
|
|
10
|
+
* sessions. `send`/`find`/`focus`/`capture`/`close` take the same id and
|
|
11
|
+
* target that name; a request without an id (or a session opened before this
|
|
12
|
+
* binding existed) falls back to the name derived from `cwd`
|
|
13
|
+
* ({@link tmuxSessionName}).
|
|
14
|
+
*/
|
|
15
|
+
import { failureText, ran, resolveRunner, shellJoin } from '@osdlc/command-seam';
|
|
16
|
+
import { safeName, sessionIdSlug, shortHash, sleepSync } from "../util.js";
|
|
17
|
+
import { onPath, quietRun, supportsPlatform, WAIT_TIMEOUT_EXIT } from "../contract.js";
|
|
18
|
+
import { sessionViaTerminal } from "../harness.js";
|
|
19
|
+
/**
|
|
20
|
+
* The cwd-derived tmux session name: stable across repeated calls with the same
|
|
21
|
+
* `cwd`, and the name every session had before addressing by id. tmux session
|
|
22
|
+
* names must be unique server-wide and match `[A-Za-z0-9_-]` —
|
|
23
|
+
* `docs/planning/research/tmux.md`'s "Naming rules" verified that `.`/`:` are
|
|
24
|
+
* silently rewritten to `_`, which can then cause a LATER create to fail with
|
|
25
|
+
* `duplicate session` against the rewritten name instead of the one actually
|
|
26
|
+
* requested. Hashing `cwd` sidesteps that class of surprise entirely rather
|
|
27
|
+
* than trying to sanitize an arbitrary path into the safe character set.
|
|
28
|
+
*
|
|
29
|
+
* Used for a request with no session id and as the fallback for a session
|
|
30
|
+
* opened before names were bound to ids. Two launches WITHOUT an id for the
|
|
31
|
+
* SAME `cwd` still collide — the second `tmux new-session` fails with its own
|
|
32
|
+
* real "duplicate session" error, surfaced verbatim below.
|
|
33
|
+
*
|
|
34
|
+
* @experimental an `attach` part is planned to replace the tmux-specific attach helpers.
|
|
35
|
+
*/
|
|
36
|
+
export function tmuxSessionName(cwd) {
|
|
37
|
+
return `sd-${shortHash(cwd, 12)}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The tmux session name bound to a caller session id: `sd-` and {@link sessionIdSlug}, the
|
|
41
|
+
* id's first 12 characters with anything outside `[A-Za-z0-9_-]` rewritten to `_` (so tmux
|
|
42
|
+
* never rewrites it for us), then `-` and 8 hex digits of the sha256 of the whole id. The hash
|
|
43
|
+
* keeps apart ids that share their first 12 characters (`build-step-001`, `build-step-002`) or
|
|
44
|
+
* differ only in a rewritten character (`a.b`, `a_b`).
|
|
45
|
+
*
|
|
46
|
+
* The single source of the name: an attach must call this rather than rebuild it.
|
|
47
|
+
*
|
|
48
|
+
* @experimental an `attach` part is planned to replace the tmux-specific attach helpers.
|
|
49
|
+
*/
|
|
50
|
+
export function tmuxIdSessionName(sessionId) {
|
|
51
|
+
return `sd-${sessionIdSlug(sessionId)}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The name of a visible run's session: `sd-` and the run id (anything outside `[A-Za-z0-9_-]`
|
|
55
|
+
* rewritten to `_`), then, when there is a caller session id (a run's several agent steps each
|
|
56
|
+
* get a session), `-` and the same id part as {@link tmuxIdSessionName}. A person finds it by
|
|
57
|
+
* the run id (`tmux ls`).
|
|
58
|
+
*/
|
|
59
|
+
export function tmuxRunSessionName(runId, sessionId) {
|
|
60
|
+
return `sd-${safeName(runId)}${sessionId === undefined ? '' : `-${sessionIdSlug(sessionId)}`}`;
|
|
61
|
+
}
|
|
62
|
+
/** Where a request points: a waiting run's name, else its session id's name when it has an id, else the cwd's. */
|
|
63
|
+
function requestedName(target) {
|
|
64
|
+
if (target.wait?.runId !== undefined)
|
|
65
|
+
return tmuxRunSessionName(target.wait.runId, target.sessionId);
|
|
66
|
+
return target.sessionId === undefined
|
|
67
|
+
? tmuxSessionName(target.cwd)
|
|
68
|
+
: tmuxIdSessionName(target.sessionId);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A `-t` target that names session `name` exactly. A bare name also matches a session whose name
|
|
72
|
+
* starts with it (tmux tries the exact name, then a prefix, then a pattern), so `sd-s1` would
|
|
73
|
+
* reach `sd-s10` once `sd-s1` has ended.
|
|
74
|
+
*/
|
|
75
|
+
function sessionTarget(name) {
|
|
76
|
+
return `=${name}`;
|
|
77
|
+
}
|
|
78
|
+
/** A `-t` target for the active pane of the session named exactly `name` (see {@link sessionTarget}). */
|
|
79
|
+
function paneTarget(name) {
|
|
80
|
+
return `=${name}:`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The command a session runs, as tmux arguments. tmux splits its command line on any argument
|
|
84
|
+
* that ends in `;`, even after `--`, and escaping it as `\;` corrupts an argument that already
|
|
85
|
+
* ends in `\;`. So when an argument ends in `;`, the whole command goes as one shell string,
|
|
86
|
+
* `exec <quoted argv>`, which tmux runs with its shell and which ends in a quote.
|
|
87
|
+
*/
|
|
88
|
+
function sessionCommand(argv) {
|
|
89
|
+
return argv.some((a) => a.endsWith(';')) ? [`exec ${shellJoin([...argv])}`] : [...argv];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Argv for `tmux new-session` — pure, so a test can pin it. `-d` (detached)
|
|
93
|
+
* is what makes this hands-off, the same category as orca/cmux's own managed
|
|
94
|
+
* window: `new-session` returns almost immediately once the session exists,
|
|
95
|
+
* long before whatever it runs finishes. `--` terminates tmux's own option
|
|
96
|
+
* parsing before `req.argv` (the harness's argv, possibly under an `env` prefix —
|
|
97
|
+
* see `wrapperSession` in `../harness.ts`) starts, the same guard `harnessArgv` itself applies one
|
|
98
|
+
* level down for the harness binary's own flags.
|
|
99
|
+
*/
|
|
100
|
+
export function tmuxCreateArgv(req) {
|
|
101
|
+
const create = [
|
|
102
|
+
'new-session',
|
|
103
|
+
'-d',
|
|
104
|
+
'-s',
|
|
105
|
+
requestedName(req),
|
|
106
|
+
'-c',
|
|
107
|
+
req.cwd,
|
|
108
|
+
'--',
|
|
109
|
+
...sessionCommand(req.argv),
|
|
110
|
+
];
|
|
111
|
+
if (req.wait === undefined)
|
|
112
|
+
return create;
|
|
113
|
+
// A waiting launch keeps the dead pane so its exit status can be read. The option is set in the
|
|
114
|
+
// SAME tmux invocation (`;` chains commands), so a process that exits at once cannot take the
|
|
115
|
+
// session with it before the option lands.
|
|
116
|
+
return [...create, ';', 'set-option', '-t', requestedName(req), 'remain-on-exit', 'on'];
|
|
117
|
+
}
|
|
118
|
+
/** Argv for the wait poll: prints `<pane_dead> <pane_dead_status>` for the session's pane. */
|
|
119
|
+
export function tmuxPaneStatusArgv(name) {
|
|
120
|
+
return ['display-message', '-p', '-t', paneTarget(name), '#{pane_dead} #{pane_dead_status}'];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The `load-buffer`/`paste-buffer` name for a send to tmux session `name` — so
|
|
124
|
+
* two concurrent sends to DIFFERENT sessions never share a buffer. Two
|
|
125
|
+
* concurrent sends to the SAME session still share this name and can race (a
|
|
126
|
+
* second `load-buffer` can overwrite the first's content before its own
|
|
127
|
+
* `paste-buffer` runs) — an accepted gap, not something this derivation rules
|
|
128
|
+
* out.
|
|
129
|
+
*/
|
|
130
|
+
function tmuxBufferName(name) {
|
|
131
|
+
return `${name}-buf`;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Argv for `load-buffer`, the first of the three invocations `send` makes, per
|
|
135
|
+
* `docs/planning/research/tmux.md`'s "Sending input" section (verified): `-`
|
|
136
|
+
* reads the buffer's content from stdin (`req.text`, passed as
|
|
137
|
+
* `CommandRunOptions.input`), so embedded newlines survive the paste instead of
|
|
138
|
+
* each arriving as an Enter, and a `text` starting with `-` is never parsed as
|
|
139
|
+
* a flag.
|
|
140
|
+
*/
|
|
141
|
+
export function tmuxLoadBufferArgv(name) {
|
|
142
|
+
return ['load-buffer', '-b', tmuxBufferName(name), '-'];
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Argv for `paste-buffer`, the second invocation: `-p` pastes as if typed
|
|
146
|
+
* (bracketed paste, matching Orca's own send behavior), `-d` deletes the
|
|
147
|
+
* buffer immediately after so it never lingers for a later, unrelated send
|
|
148
|
+
* to collide with. `-t` addresses the pane by session `name`.
|
|
149
|
+
*/
|
|
150
|
+
export function tmuxPasteBufferArgv(name) {
|
|
151
|
+
return ['paste-buffer', '-p', '-d', '-b', tmuxBufferName(name), '-t', paneTarget(name)];
|
|
152
|
+
}
|
|
153
|
+
/** The third, separate `send-keys` call that submits what {@link tmuxPasteBufferArgv} just pasted. */
|
|
154
|
+
export function tmuxSendEnterArgv(name) {
|
|
155
|
+
return ['send-keys', '-t', paneTarget(name), 'Enter'];
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Argv for `tmux has-session -t <name>` — per `docs/planning/research/tmux.md`'s
|
|
159
|
+
* "Presence checks" section (verified): exits 0 when the session exists, 1
|
|
160
|
+
* when it does not. Simpler than `list-panes` and sufficient here, since a
|
|
161
|
+
* session name maps 1:1 to a pane.
|
|
162
|
+
*/
|
|
163
|
+
export function tmuxHasSessionArgv(name) {
|
|
164
|
+
return ['has-session', '-t', sessionTarget(name)];
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Argv for `tmux attach-session -t <name>` — the real, interactive attach
|
|
168
|
+
* a caller uses for a tmux-hosted session `find` (below) confirms is still
|
|
169
|
+
* live, instead of falling through to an engine-native resume that would risk
|
|
170
|
+
* opening a SECOND, competing session on top of a still-live one. Unlike orca/cmux, tmux has a genuine
|
|
171
|
+
* interactive attach primitive of its own — this is that mechanism (`attach-
|
|
172
|
+
* session` itself is standard, well-documented tmux behavior, not something
|
|
173
|
+
* this campaign's own research docs independently verified).
|
|
174
|
+
*
|
|
175
|
+
* Only correct when the attaching process is OUTSIDE any tmux client already
|
|
176
|
+
* — a caller reaches for {@link tmuxSwitchClientArgv} instead
|
|
177
|
+
* when `$TMUX` is set, since `attach-session` refuses to nest one client
|
|
178
|
+
* inside another.
|
|
179
|
+
*
|
|
180
|
+
* @experimental an `attach` part is planned to replace the tmux-specific attach helpers.
|
|
181
|
+
*/
|
|
182
|
+
export function tmuxAttachArgv(name) {
|
|
183
|
+
return ['attach-session', '-t', sessionTarget(name)];
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Argv for `tmux switch-client -t <name>` — what a caller uses
|
|
187
|
+
* instead of {@link tmuxAttachArgv} when the attaching process is ITSELF
|
|
188
|
+
* already inside a tmux client (`$TMUX` set, the common case for a tmux
|
|
189
|
+
* user): `attach-session` there refuses with "sessions should be nested with
|
|
190
|
+
* care, unset $TMUX to force" and exits 1 without attaching anything, while
|
|
191
|
+
* `switch-client` is tmux's own documented mechanism for moving the current
|
|
192
|
+
* client to a different session instead of nesting one inside another.
|
|
193
|
+
*
|
|
194
|
+
* @experimental an `attach` part is planned to replace the tmux-specific attach helpers.
|
|
195
|
+
*/
|
|
196
|
+
export function tmuxSwitchClientArgv(name) {
|
|
197
|
+
return ['switch-client', '-t', sessionTarget(name)];
|
|
198
|
+
}
|
|
199
|
+
/** Argv for `tmux list-sessions`, one session name per line. */
|
|
200
|
+
export function tmuxListSessionsArgv() {
|
|
201
|
+
return ['list-sessions', '-F', '#{session_name}'];
|
|
202
|
+
}
|
|
203
|
+
/** Argv for `tmux capture-pane -p -t <name>`: prints the pane's visible text to stdout. */
|
|
204
|
+
export function tmuxCapturePaneArgv(name) {
|
|
205
|
+
return ['capture-pane', '-p', '-t', paneTarget(name)];
|
|
206
|
+
}
|
|
207
|
+
/** Argv for `tmux kill-session -t <name>`: ends the session and signals what runs in it. */
|
|
208
|
+
export function tmuxKillSessionArgv(name) {
|
|
209
|
+
return ['kill-session', '-t', sessionTarget(name)];
|
|
210
|
+
}
|
|
211
|
+
/** How long the wait poll pauses between looks at the pane. */
|
|
212
|
+
const POLL_MS = 500;
|
|
213
|
+
/** Status polls in a row that may fail while the session still exists before a waiting launch gives up. */
|
|
214
|
+
const MAX_FAILED_POLLS = 3;
|
|
215
|
+
/**
|
|
216
|
+
* Whether this process runs inside a tmux client (the `TMUX` environment variable is set).
|
|
217
|
+
*
|
|
218
|
+
* @experimental an `attach` part is planned to replace the tmux-specific attach helpers.
|
|
219
|
+
*/
|
|
220
|
+
export function insideTmux() {
|
|
221
|
+
return process.env['TMUX'] !== undefined;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The tmux host: drives a detached tmux session per launch through the `tmux` binary, which must be on PATH.
|
|
225
|
+
* Runs on darwin and linux only (on Windows it is unavailable; run it inside WSL). Keeps no state of its own;
|
|
226
|
+
* sessions are found again by a name derived from the caller's session id.
|
|
227
|
+
*/
|
|
228
|
+
export function makeTmuxHost(opts) {
|
|
229
|
+
const sleep = opts.sleep ?? sleepSync;
|
|
230
|
+
const now = opts.now ?? Date.now;
|
|
231
|
+
const runner = () => resolveRunner('tmux', opts.runner);
|
|
232
|
+
const hasSession = (name, cwd) => ran(runner().run(tmuxHasSessionArgv(name), { cwd, timeoutMs: 10_000 }));
|
|
233
|
+
/**
|
|
234
|
+
* The live session a waited run opened for `sessionId` ({@link tmuxRunSessionName}): the one
|
|
235
|
+
* listed session named `sd-<run>-<id>`, or null. The run id is not in the request, so the
|
|
236
|
+
* name is matched by its id suffix.
|
|
237
|
+
*/
|
|
238
|
+
const runSessionName = (sessionId, cwd) => {
|
|
239
|
+
const idName = tmuxIdSessionName(sessionId);
|
|
240
|
+
const suffix = `-${idName.slice('sd-'.length)}`;
|
|
241
|
+
const out = quietRun(runner(), tmuxListSessionsArgv(), { cwd, timeoutMs: 10_000 });
|
|
242
|
+
const names = (out ?? '')
|
|
243
|
+
.split('\n')
|
|
244
|
+
.map((n) => n.trim())
|
|
245
|
+
.filter((n) => n !== idName && n.startsWith('sd-') && n.endsWith(suffix));
|
|
246
|
+
return names.length === 1 ? (names[0] ?? null) : null;
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* The live tmux session for a request: the one bound to its session id (named from the id,
|
|
250
|
+
* or from a waited run and the id), else (a session opened before names were bound to ids)
|
|
251
|
+
* the cwd's; null when none exists. A request without an id only ever has the cwd's name.
|
|
252
|
+
* `cwdFallback: false` (`close`) never falls back for a request with an id: the cwd's name
|
|
253
|
+
* could belong to a different session in that directory, and killing it is not undoable.
|
|
254
|
+
*/
|
|
255
|
+
const liveName = (target, cwdFallback = true) => {
|
|
256
|
+
if (target.sessionId !== undefined) {
|
|
257
|
+
const idName = tmuxIdSessionName(target.sessionId);
|
|
258
|
+
if (hasSession(idName, target.cwd))
|
|
259
|
+
return idName;
|
|
260
|
+
const runName = runSessionName(target.sessionId, target.cwd);
|
|
261
|
+
if (runName !== null)
|
|
262
|
+
return runName;
|
|
263
|
+
if (!cwdFallback)
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const cwdName = tmuxSessionName(target.cwd);
|
|
267
|
+
return hasSession(cwdName, target.cwd) ? cwdName : null;
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Hold until the session's process exits and return its exit status, the way `terminal` does.
|
|
271
|
+
* At `req.wait.timeoutMs` the session is killed (`kill-session` signals what runs in it) and
|
|
272
|
+
* {@link WAIT_TIMEOUT_EXIT} comes back. A session that vanishes (someone closed it) reports 1.
|
|
273
|
+
* The dead pane is killed on the way out so a finished run leaves no session behind.
|
|
274
|
+
*
|
|
275
|
+
* Synchronous on purpose: `Host.terminal` returns the exit code and every part is sync, so
|
|
276
|
+
* the poll blocks this process until the session ends. A caller that waits is usually a
|
|
277
|
+
* scheduled run with nothing else to do meanwhile; an async wait would change the `Host`
|
|
278
|
+
* contract for every host.
|
|
279
|
+
*/
|
|
280
|
+
const waitForExit = (name, req) => {
|
|
281
|
+
const limit = req.wait?.timeoutMs;
|
|
282
|
+
const deadline = limit === undefined ? undefined : now() + limit;
|
|
283
|
+
const kill = () => {
|
|
284
|
+
runner().run(tmuxKillSessionArgv(name), { cwd: req.cwd, timeoutMs: 10_000 });
|
|
285
|
+
};
|
|
286
|
+
let failedPolls = 0;
|
|
287
|
+
for (;;) {
|
|
288
|
+
const res = runner().run(tmuxPaneStatusArgv(name), { cwd: req.cwd, timeoutMs: 10_000 });
|
|
289
|
+
if (!ran(res)) {
|
|
290
|
+
// A session someone closed reports 1. A poll that failed while the session still exists
|
|
291
|
+
// (a slow tmux under load) is retried; after MAX_FAILED_POLLS in a row the session is
|
|
292
|
+
// killed rather than left running unwatched.
|
|
293
|
+
if (!ran(runner().run(tmuxHasSessionArgv(name), { cwd: req.cwd, timeoutMs: 10_000 }))) {
|
|
294
|
+
return 1;
|
|
295
|
+
}
|
|
296
|
+
if (++failedPolls >= MAX_FAILED_POLLS) {
|
|
297
|
+
kill();
|
|
298
|
+
return 1;
|
|
299
|
+
}
|
|
300
|
+
sleep(POLL_MS);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
failedPolls = 0;
|
|
304
|
+
const [dead, status] = res.stdout.trim().split(/\s+/);
|
|
305
|
+
if (dead === '1') {
|
|
306
|
+
kill();
|
|
307
|
+
const code = Number(status);
|
|
308
|
+
return Number.isFinite(code) ? code : 1;
|
|
309
|
+
}
|
|
310
|
+
if (deadline !== undefined && now() >= deadline) {
|
|
311
|
+
kill();
|
|
312
|
+
req.wait?.onDeadline?.();
|
|
313
|
+
return WAIT_TIMEOUT_EXIT;
|
|
314
|
+
}
|
|
315
|
+
sleep(POLL_MS);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
return {
|
|
319
|
+
name: 'tmux',
|
|
320
|
+
// tmux has no native Windows build; it runs under WSL.
|
|
321
|
+
platforms: ['darwin', 'linux'],
|
|
322
|
+
platformHint: { win32: 'run inside WSL' },
|
|
323
|
+
available() {
|
|
324
|
+
if (opts.available !== undefined)
|
|
325
|
+
return opts.available();
|
|
326
|
+
return supportsPlatform(this) && onPath('tmux');
|
|
327
|
+
},
|
|
328
|
+
terminal(req) {
|
|
329
|
+
const res = runner().run(tmuxCreateArgv(req), { cwd: req.cwd, timeoutMs: 60_000 });
|
|
330
|
+
if (!ran(res)) {
|
|
331
|
+
throw new Error(`tmux new-session failed: ${failureText(res)}`);
|
|
332
|
+
}
|
|
333
|
+
return req.wait === undefined ? 0 : waitForExit(requestedName(req), req);
|
|
334
|
+
},
|
|
335
|
+
session: sessionViaTerminal,
|
|
336
|
+
send(req) {
|
|
337
|
+
// tmux has no delivery-confirmation mechanism at all (no `turn_started`
|
|
338
|
+
// equivalent — `docs/planning/research/tmux.md`'s "Reading output" section:
|
|
339
|
+
// no app-aware idle/consumption signal exists), so `ack` is
|
|
340
|
+
// always false regardless of how the sends went.
|
|
341
|
+
//
|
|
342
|
+
// Three invocations: `load-buffer` (text arrives via stdin, never in
|
|
343
|
+
// argv), then `paste-buffer` (pastes it, preserving embedded newlines;
|
|
344
|
+
// see `tmuxLoadBufferArgv`), then `send-keys ... Enter` to submit.
|
|
345
|
+
// Accepted gap: if the
|
|
346
|
+
// paste succeeds but this last Enter call fails or times out, the text
|
|
347
|
+
// is already sitting typed in the pane's input — `accepted: false`
|
|
348
|
+
// here, so a caller tries its next route, and a LATER
|
|
349
|
+
// retry's own paste can land on top of it, submitted together as one
|
|
350
|
+
// message. Same class of documented limitation as this host's
|
|
351
|
+
// duplicate-session gap above (`tmuxSessionName`'s doc); fixing it
|
|
352
|
+
// would need either a single chained tmux invocation (unverified
|
|
353
|
+
// against a live server) or a read-back-before-retry check.
|
|
354
|
+
const name = liveName(req);
|
|
355
|
+
if (name === null)
|
|
356
|
+
return { accepted: false, ack: false };
|
|
357
|
+
const load = runner().run(tmuxLoadBufferArgv(name), {
|
|
358
|
+
cwd: req.cwd,
|
|
359
|
+
timeoutMs: 15_000,
|
|
360
|
+
input: req.text,
|
|
361
|
+
});
|
|
362
|
+
if (!ran(load))
|
|
363
|
+
return { accepted: false, ack: false };
|
|
364
|
+
const paste = runner().run(tmuxPasteBufferArgv(name), { cwd: req.cwd, timeoutMs: 15_000 });
|
|
365
|
+
if (!ran(paste)) {
|
|
366
|
+
// `paste-buffer -d` only deletes on success; do not leave the text loaded in the server.
|
|
367
|
+
runner().run(['delete-buffer', '-b', tmuxBufferName(name)], {
|
|
368
|
+
cwd: req.cwd,
|
|
369
|
+
timeoutMs: 15_000,
|
|
370
|
+
});
|
|
371
|
+
return { accepted: false, ack: false };
|
|
372
|
+
}
|
|
373
|
+
const enter = runner().run(tmuxSendEnterArgv(name), { cwd: req.cwd, timeoutMs: 15_000 });
|
|
374
|
+
return { accepted: ran(enter), ack: false };
|
|
375
|
+
},
|
|
376
|
+
find(query) {
|
|
377
|
+
const name = liveName(query);
|
|
378
|
+
if (name === null)
|
|
379
|
+
return [];
|
|
380
|
+
// tmux cannot identify which harness runs inside the pane (no tagging is
|
|
381
|
+
// written anywhere in this codebase today), and has no output-recency
|
|
382
|
+
// signal (docs/planning/research/tmux.md: "no idle signal comparable to
|
|
383
|
+
// Orca's") — both come back as the "can't tell" values. The handle is the
|
|
384
|
+
// tmux session name, which a caller can attach to.
|
|
385
|
+
return [{ cwd: query.cwd, harness: null, alive: true, lastOutputAt: null, handle: name }];
|
|
386
|
+
},
|
|
387
|
+
capture(query) {
|
|
388
|
+
const name = liveName(query);
|
|
389
|
+
if (name === null)
|
|
390
|
+
return null;
|
|
391
|
+
return quietRun(runner(), tmuxCapturePaneArgv(name), { cwd: query.cwd, timeoutMs: 10_000 });
|
|
392
|
+
},
|
|
393
|
+
focus(query) {
|
|
394
|
+
// switch-client needs an attached client, which exists only when $TMUX is set.
|
|
395
|
+
if (!(opts.inside ?? insideTmux)())
|
|
396
|
+
return false;
|
|
397
|
+
const name = liveName(query);
|
|
398
|
+
if (name === null)
|
|
399
|
+
return false;
|
|
400
|
+
return ran(runner().run(tmuxSwitchClientArgv(name), { cwd: query.cwd, timeoutMs: 10_000 }));
|
|
401
|
+
},
|
|
402
|
+
close(query) {
|
|
403
|
+
const name = liveName(query, false);
|
|
404
|
+
if (name === null)
|
|
405
|
+
return false;
|
|
406
|
+
return ran(runner().run(tmuxKillSessionArgv(name), { cwd: query.cwd, timeoutMs: 10_000 }));
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The caller's side of the companion extension (`sdlc-host`) protocol; design in
|
|
3
|
+
* `docs/planning/research/vscode.md`. The wire types are restated
|
|
4
|
+
* here, not imported, so this package does not depend on the extension
|
|
5
|
+
* package; a consumer that ships the extension pins both sides with a parity
|
|
6
|
+
* test.
|
|
7
|
+
*
|
|
8
|
+
* The host contract is synchronous, and a Node socket is not. The request
|
|
9
|
+
* therefore runs in a tiny one-shot child (`<runtime> -e CLIENT_SOURCE`) that
|
|
10
|
+
* opens the socket, sends one line and prints the reply. The talk to the
|
|
11
|
+
* window is IPC, not a shell-out to a foreign tool, but the child itself is a
|
|
12
|
+
* subprocess, so it goes through the command seam like every other one.
|
|
13
|
+
*/
|
|
14
|
+
import { type CommandRunner } from '@osdlc/command-seam';
|
|
15
|
+
import { type RegistryTrustOptions } from './registry.ts';
|
|
16
|
+
/**
|
|
17
|
+
* One window's registry entry, as the extension wrote it.
|
|
18
|
+
*
|
|
19
|
+
* @experimental needs the `sdlc-host` companion extension, which is not published yet.
|
|
20
|
+
*/
|
|
21
|
+
export interface VscodeWindow {
|
|
22
|
+
pid: number;
|
|
23
|
+
app: string;
|
|
24
|
+
endpoint: string;
|
|
25
|
+
token: string;
|
|
26
|
+
folders: string[];
|
|
27
|
+
startedAt: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A reply from the window: its result object, or the error it sent.
|
|
31
|
+
*
|
|
32
|
+
* @experimental needs the `sdlc-host` companion extension, which is not published yet.
|
|
33
|
+
*/
|
|
34
|
+
export type VscodeReply = {
|
|
35
|
+
ok: true;
|
|
36
|
+
result: Record<string, unknown>;
|
|
37
|
+
} | {
|
|
38
|
+
ok: false;
|
|
39
|
+
error: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The transport: one request body in, one reply out. Tests substitute a fake.
|
|
43
|
+
*
|
|
44
|
+
* @experimental needs the `sdlc-host` companion extension, which is not published yet.
|
|
45
|
+
*/
|
|
46
|
+
export type VscodeTransport = (window: VscodeWindow, body: Record<string, unknown>,
|
|
47
|
+
/** Per-call limit; default {@link REQUEST_TIMEOUT_MS}. */
|
|
48
|
+
timeoutMs?: number) => VscodeReply;
|
|
49
|
+
/**
|
|
50
|
+
* Program run by the one-shot child. stdin: `{endpoint, line, timeoutMs}`;
|
|
51
|
+
* stdout: the window's reply line, or `{"ok":false,"error":...}` on a
|
|
52
|
+
* connect/timeout failure. Plain CommonJS so `node -e` and `bun -e` both run it.
|
|
53
|
+
*/
|
|
54
|
+
export declare const CLIENT_SOURCE = "\nconst net = require('node:net')\nlet input = ''\nprocess.stdin.setEncoding('utf8')\nprocess.stdin.on('data', (d) => (input += d))\nprocess.stdin.on('end', () => {\n const { endpoint, line, timeoutMs } = JSON.parse(input)\n const out = (o) => { process.stdout.write(JSON.stringify(o) + '\\n'); process.exit(0) }\n const s = net.connect(endpoint)\n let buf = ''\n s.setEncoding('utf8')\n const t = setTimeout(() => out({ ok: false, error: 'timed out' }), timeoutMs)\n s.on('connect', () => s.write(line + '\\n'))\n s.on('data', (d) => { buf += d; const i = buf.indexOf('\\n'); if (i >= 0) { clearTimeout(t); process.stdout.write(buf.slice(0, i) + '\\n'); process.exit(0) } })\n s.on('error', (e) => out({ ok: false, error: e.message }))\n})\n";
|
|
55
|
+
/** `available()` / discovery pings: a hung window must not stall host detection for 10s each. */
|
|
56
|
+
export declare const PING_TIMEOUT_MS = 1500;
|
|
57
|
+
/**
|
|
58
|
+
* The JS runtime that runs {@link CLIENT_SOURCE}. From source or the Node bundle
|
|
59
|
+
* that is the live runtime (`process.execPath`). Under a `bun build --compile`
|
|
60
|
+
* binary `process.execPath` IS the caller's binary, which would take `-e` as one of
|
|
61
|
+
* its own arguments, so a `node` (else `bun`) on `PATH` is used instead; `null`
|
|
62
|
+
* when there is none.
|
|
63
|
+
*/
|
|
64
|
+
export declare function clientRuntime(compiled?: boolean, find?: (bin: string) => string | null): string | null;
|
|
65
|
+
/**
|
|
66
|
+
* The production transport: sync, through the command seam.
|
|
67
|
+
*
|
|
68
|
+
* @experimental needs the `sdlc-host` companion extension, which is not published yet.
|
|
69
|
+
*/
|
|
70
|
+
export declare function subprocessTransport(runner?: CommandRunner | undefined): VscodeTransport;
|
|
71
|
+
export declare function parseReply(stdout: string): VscodeReply;
|
|
72
|
+
/**
|
|
73
|
+
* Registry entries whose process is alive. Unreadable or malformed files are
|
|
74
|
+
* skipped, and so, on POSIX, are a file or directory another user owns or can
|
|
75
|
+
* write ({@link readHostRegistry}).
|
|
76
|
+
*
|
|
77
|
+
* @experimental needs the `sdlc-host` companion extension, which is not published yet.
|
|
78
|
+
*/
|
|
79
|
+
export declare function liveWindows(dir: string, alive?: (pid: number) => boolean, trust?: RegistryTrustOptions): VscodeWindow[];
|
|
80
|
+
/** The window whose folder contains `cwd`: deepest folder wins, then the newest window. Null when none. */
|
|
81
|
+
export declare function windowForCwd(windows: readonly VscodeWindow[], cwd: string): VscodeWindow | null;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The caller's side of the companion extension (`sdlc-host`) protocol; design in
|
|
3
|
+
* `docs/planning/research/vscode.md`. The wire types are restated
|
|
4
|
+
* here, not imported, so this package does not depend on the extension
|
|
5
|
+
* package; a consumer that ships the extension pins both sides with a parity
|
|
6
|
+
* test.
|
|
7
|
+
*
|
|
8
|
+
* The host contract is synchronous, and a Node socket is not. The request
|
|
9
|
+
* therefore runs in a tiny one-shot child (`<runtime> -e CLIENT_SOURCE`) that
|
|
10
|
+
* opens the socket, sends one line and prints the reply. The talk to the
|
|
11
|
+
* window is IPC, not a shell-out to a foreign tool, but the child itself is a
|
|
12
|
+
* subprocess, so it goes through the command seam like every other one.
|
|
13
|
+
*/
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { failureText, ran, resolveRunner } from '@osdlc/command-seam';
|
|
16
|
+
import { isCompiledBinary, isRecord, parseJsonOrNull, pidAlive, which } from "../util.js";
|
|
17
|
+
import { deepestContaining, readHostRegistry } from "./registry.js";
|
|
18
|
+
/**
|
|
19
|
+
* Program run by the one-shot child. stdin: `{endpoint, line, timeoutMs}`;
|
|
20
|
+
* stdout: the window's reply line, or `{"ok":false,"error":...}` on a
|
|
21
|
+
* connect/timeout failure. Plain CommonJS so `node -e` and `bun -e` both run it.
|
|
22
|
+
*/
|
|
23
|
+
export const CLIENT_SOURCE = `
|
|
24
|
+
const net = require('node:net')
|
|
25
|
+
let input = ''
|
|
26
|
+
process.stdin.setEncoding('utf8')
|
|
27
|
+
process.stdin.on('data', (d) => (input += d))
|
|
28
|
+
process.stdin.on('end', () => {
|
|
29
|
+
const { endpoint, line, timeoutMs } = JSON.parse(input)
|
|
30
|
+
const out = (o) => { process.stdout.write(JSON.stringify(o) + '\\n'); process.exit(0) }
|
|
31
|
+
const s = net.connect(endpoint)
|
|
32
|
+
let buf = ''
|
|
33
|
+
s.setEncoding('utf8')
|
|
34
|
+
const t = setTimeout(() => out({ ok: false, error: 'timed out' }), timeoutMs)
|
|
35
|
+
s.on('connect', () => s.write(line + '\\n'))
|
|
36
|
+
s.on('data', (d) => { buf += d; const i = buf.indexOf('\\n'); if (i >= 0) { clearTimeout(t); process.stdout.write(buf.slice(0, i) + '\\n'); process.exit(0) } })
|
|
37
|
+
s.on('error', (e) => out({ ok: false, error: e.message }))
|
|
38
|
+
})
|
|
39
|
+
`;
|
|
40
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
41
|
+
/** `available()` / discovery pings: a hung window must not stall host detection for 10s each. */
|
|
42
|
+
export const PING_TIMEOUT_MS = 1_500;
|
|
43
|
+
/**
|
|
44
|
+
* The JS runtime that runs {@link CLIENT_SOURCE}. From source or the Node bundle
|
|
45
|
+
* that is the live runtime (`process.execPath`). Under a `bun build --compile`
|
|
46
|
+
* binary `process.execPath` IS the caller's binary, which would take `-e` as one of
|
|
47
|
+
* its own arguments, so a `node` (else `bun`) on `PATH` is used instead; `null`
|
|
48
|
+
* when there is none.
|
|
49
|
+
*/
|
|
50
|
+
export function clientRuntime(compiled = isCompiledBinary(import.meta.url), find = (bin) => which(bin)) {
|
|
51
|
+
if (!compiled)
|
|
52
|
+
return process.execPath;
|
|
53
|
+
return find('node') ?? find('bun');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The production transport: sync, through the command seam.
|
|
57
|
+
*
|
|
58
|
+
* @experimental needs the `sdlc-host` companion extension, which is not published yet.
|
|
59
|
+
*/
|
|
60
|
+
export function subprocessTransport(runner) {
|
|
61
|
+
if (runner === undefined) {
|
|
62
|
+
const runtime = clientRuntime();
|
|
63
|
+
if (runtime === null) {
|
|
64
|
+
// No runtime to run the client in: every request fails with the reason, so
|
|
65
|
+
// `available()` is false and an explicit open names what is missing.
|
|
66
|
+
return () => ({
|
|
67
|
+
ok: false,
|
|
68
|
+
error: 'a compiled binary needs node or bun on PATH to reach a VS Code window',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
runner = resolveRunner(runtime);
|
|
72
|
+
}
|
|
73
|
+
const run = runner;
|
|
74
|
+
return (window, body, timeoutMs = REQUEST_TIMEOUT_MS) => {
|
|
75
|
+
const line = JSON.stringify({ id: '1', token: window.token, ...body });
|
|
76
|
+
const res = run.run(['-e', CLIENT_SOURCE], {
|
|
77
|
+
cwd: tmpdir(),
|
|
78
|
+
input: JSON.stringify({ endpoint: window.endpoint, line, timeoutMs }),
|
|
79
|
+
timeoutMs: timeoutMs + 5_000,
|
|
80
|
+
});
|
|
81
|
+
if (!ran(res))
|
|
82
|
+
return { ok: false, error: failureText(res) };
|
|
83
|
+
return parseReply(res.stdout);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function parseReply(stdout) {
|
|
87
|
+
const raw = parseJsonOrNull(stdout.trim().split('\n')[0] ?? '');
|
|
88
|
+
if (!isRecord(raw))
|
|
89
|
+
return { ok: false, error: 'unparseable reply' };
|
|
90
|
+
if (raw['ok'] === true && isRecord(raw['result']))
|
|
91
|
+
return { ok: true, result: raw['result'] };
|
|
92
|
+
return { ok: false, error: typeof raw['error'] === 'string' ? raw['error'] : 'request failed' };
|
|
93
|
+
}
|
|
94
|
+
function parseWindow(raw) {
|
|
95
|
+
if (!isRecord(raw))
|
|
96
|
+
return null;
|
|
97
|
+
const { pid, app, endpoint, token, folders, startedAt } = raw;
|
|
98
|
+
if (typeof pid !== 'number' || typeof endpoint !== 'string' || typeof token !== 'string')
|
|
99
|
+
return null;
|
|
100
|
+
if (!Array.isArray(folders) || !folders.every((f) => typeof f === 'string'))
|
|
101
|
+
return null;
|
|
102
|
+
return {
|
|
103
|
+
pid,
|
|
104
|
+
endpoint,
|
|
105
|
+
token,
|
|
106
|
+
folders: folders,
|
|
107
|
+
app: typeof app === 'string' ? app : 'unknown',
|
|
108
|
+
startedAt: typeof startedAt === 'string' ? startedAt : '',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Registry entries whose process is alive. Unreadable or malformed files are
|
|
113
|
+
* skipped, and so, on POSIX, are a file or directory another user owns or can
|
|
114
|
+
* write ({@link readHostRegistry}).
|
|
115
|
+
*
|
|
116
|
+
* @experimental needs the `sdlc-host` companion extension, which is not published yet.
|
|
117
|
+
*/
|
|
118
|
+
export function liveWindows(dir, alive = pidAlive, trust = {}) {
|
|
119
|
+
return readHostRegistry(dir, parseWindow, alive, trust);
|
|
120
|
+
}
|
|
121
|
+
/** The window whose folder contains `cwd`: deepest folder wins, then the newest window. Null when none. */
|
|
122
|
+
export function windowForCwd(windows, cwd) {
|
|
123
|
+
return deepestContaining(cwd, windows, (w) => w.folders, (w) => w.startedAt);
|
|
124
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VS Code, Cursor and Windsurf through the companion extension (`sdlc-host`;
|
|
3
|
+
* design in `docs/planning/research/vscode.md`). Each window runs a private
|
|
4
|
+
* socket server and registers it; this host picks the window whose workspace
|
|
5
|
+
* folder contains `cwd`.
|
|
6
|
+
*
|
|
7
|
+
* A window is never opened unasked: when none has `cwd`, `terminal`/`session`
|
|
8
|
+
* throw telling the person to open it. `send` cannot know whether the input
|
|
9
|
+
* was consumed (`sendText` reports nothing), so a receipt is `accepted` and
|
|
10
|
+
* never `ack`. There is no `capture` part: the published extension API has no
|
|
11
|
+
* read-back for arbitrary terminals.
|
|
12
|
+
*
|
|
13
|
+
* `open` carries the caller's session id and the extension binds the terminal to it;
|
|
14
|
+
* `send`, `focus`, `find` and `close` pass it back, so two sessions in one folder
|
|
15
|
+
* reach two terminals. The extension answers a request for a session id no terminal
|
|
16
|
+
* is bound to from terminals bound to none (opened before ids were passed), never
|
|
17
|
+
* another session's.
|
|
18
|
+
*/
|
|
19
|
+
import type { Host } from '../contract.ts';
|
|
20
|
+
import { type HostStateOptions } from './registry.ts';
|
|
21
|
+
import { type VscodeTransport, type VscodeWindow } from './vscode-client.ts';
|
|
22
|
+
/**
|
|
23
|
+
* The VS Code host (also Cursor and Windsurf): opens terminals through the companion extension (`sdlc-host`)
|
|
24
|
+
* running in the window that has `cwd` open. All platforms; available when a registered window answers a ping.
|
|
25
|
+
* Reads the windows the extension registered under `runtimeDir`/`registryDir` ({@link HostStateOptions}).
|
|
26
|
+
*
|
|
27
|
+
* @experimental needs the `sdlc-host` companion extension, which is not published yet.
|
|
28
|
+
*/
|
|
29
|
+
export declare function makeVscodeHost(opts: {
|
|
30
|
+
/** Replaces the registry scan + reachability probe in `available()`. */
|
|
31
|
+
available?: (() => boolean) | undefined;
|
|
32
|
+
/** Replaces the registry read. */
|
|
33
|
+
windows?: (() => VscodeWindow[]) | undefined;
|
|
34
|
+
transport?: VscodeTransport | undefined;
|
|
35
|
+
} & HostStateOptions): Host;
|