@osdlc/agent-seat 0.0.0-stage → 0.0.1-stage
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 +127 -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,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orca (com.stablyai.orca): a terminal tab on `cwd` running a command.
|
|
3
|
+
*
|
|
4
|
+
* Orca knows a worktree by path once its repository is registered, so an
|
|
5
|
+
* unknown-worktree failure is retried after `orca repo add --path <repoRoot>`; any
|
|
6
|
+
* other failure, and a second one, is reported verbatim.
|
|
7
|
+
*
|
|
8
|
+
* `terminal create --json` returns the new terminal's handle, which is
|
|
9
|
+
* remembered against the caller's session id (`./session-panes.ts`); a later
|
|
10
|
+
* request for that session targets that terminal among the worktree's, so two
|
|
11
|
+
* sessions in one worktree reach two terminals. A request without a remembered
|
|
12
|
+
* handle (no id, or a terminal opened before handles were remembered) targets the
|
|
13
|
+
* worktree's one live, writable agent terminal, and none when there are several.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { commandString, failureText, ran, resolveRunner, } from '@osdlc/command-seam';
|
|
17
|
+
import { isRecord, parseJsonOrNull } from "../util.js";
|
|
18
|
+
import { onPath, supportsPlatform, } from "../contract.js";
|
|
19
|
+
import { harnessOrNull, sessionViaTerminal } from "../harness.js";
|
|
20
|
+
import { boundPane, rememberSessionPane } from "./session-panes.js";
|
|
21
|
+
import { registryDirOf } from "./registry.js";
|
|
22
|
+
/** The CLI the app bundle ships when `orca` is not on PATH. */
|
|
23
|
+
export const ORCA_BUNDLED_CLI = '/Applications/Orca.app/Contents/Resources/bin/orca';
|
|
24
|
+
export function orcaBinary() {
|
|
25
|
+
if (onPath('orca'))
|
|
26
|
+
return 'orca';
|
|
27
|
+
return existsSync(ORCA_BUNDLED_CLI) ? ORCA_BUNDLED_CLI : null;
|
|
28
|
+
}
|
|
29
|
+
/** Argv for `orca terminal create` — pure, so a test can pin it. */
|
|
30
|
+
export function orcaCreateArgv(req) {
|
|
31
|
+
return [
|
|
32
|
+
'terminal',
|
|
33
|
+
'create',
|
|
34
|
+
'--worktree',
|
|
35
|
+
`path:${req.cwd}`,
|
|
36
|
+
'--title',
|
|
37
|
+
req.title,
|
|
38
|
+
'--command',
|
|
39
|
+
commandString(req.argv),
|
|
40
|
+
'--focus',
|
|
41
|
+
'--json',
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
/** The new terminal's handle in `orca terminal create --json`: `result.terminal.handle`. */
|
|
45
|
+
export function parseOrcaCreatedHandle(stdout) {
|
|
46
|
+
const handle = field(field(field(parseJsonOrNull(stdout), 'result'), 'terminal'), 'handle');
|
|
47
|
+
return typeof handle === 'string' && handle !== '' ? handle : null;
|
|
48
|
+
}
|
|
49
|
+
/** Seconds `orca terminal send` waits for `turn_started`; Orca's own request timeout is this plus 10s. */
|
|
50
|
+
const ORCA_SEND_WAIT_SUBMIT_SECS = 10;
|
|
51
|
+
export function orcaSendArgv(handle, text) {
|
|
52
|
+
return [
|
|
53
|
+
'terminal',
|
|
54
|
+
'send',
|
|
55
|
+
'--terminal',
|
|
56
|
+
handle,
|
|
57
|
+
'--text',
|
|
58
|
+
text,
|
|
59
|
+
'--enter',
|
|
60
|
+
'--wait-submit',
|
|
61
|
+
String(ORCA_SEND_WAIT_SUBMIT_SECS),
|
|
62
|
+
'--json',
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
export function orcaSwitchArgv(handle) {
|
|
66
|
+
return ['terminal', 'switch', '--terminal', handle, '--json'];
|
|
67
|
+
}
|
|
68
|
+
/** `orca terminal close --terminal <handle>`: closes that terminal and kills what runs in it. */
|
|
69
|
+
export function orcaCloseArgv(handle) {
|
|
70
|
+
return ['terminal', 'close', '--terminal', handle, '--json'];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Parses `orca terminal send --json`, which wraps its receipt as
|
|
74
|
+
* `{ok, result: {send: {accepted, prompt: {stages}}}}`. `accepted` alone only
|
|
75
|
+
* means the input reached the terminal; a `turn_started` stage confirms the
|
|
76
|
+
* agent took it. Unparseable output is `{accepted: false, ack: false}`.
|
|
77
|
+
*/
|
|
78
|
+
export function parseOrcaSendResponse(stdout) {
|
|
79
|
+
const send = field(field(parseJsonOrNull(stdout), 'result'), 'send');
|
|
80
|
+
const accepted = field(send, 'accepted') === true;
|
|
81
|
+
const stages = field(field(send, 'prompt'), 'stages');
|
|
82
|
+
const ack = accepted && Array.isArray(stages) && stages.includes('turn_started');
|
|
83
|
+
// Input accepted but no `turn_started`: Orca itself warns "inspect the terminal before
|
|
84
|
+
// retrying" (`docs/planning/research/orca.md`, "send"), so the text may be in the session.
|
|
85
|
+
return accepted && !ack ? { accepted, ack, uncertain: true } : { accepted, ack };
|
|
86
|
+
}
|
|
87
|
+
/** True when Orca rejected a handle issued before its last restart. */
|
|
88
|
+
export function isOrcaHandleStale(stdout) {
|
|
89
|
+
return field(field(parseJsonOrNull(stdout), 'error'), 'code') === 'terminal_handle_stale';
|
|
90
|
+
}
|
|
91
|
+
function field(value, key) {
|
|
92
|
+
return isRecord(value) ? value[key] : undefined;
|
|
93
|
+
}
|
|
94
|
+
/** Argv for `orca terminal list --worktree path:<cwd> --json` — pure, so a test can pin it. */
|
|
95
|
+
export function orcaFindArgv(query) {
|
|
96
|
+
return ['terminal', 'list', '--worktree', `path:${query.cwd}`, '--json'];
|
|
97
|
+
}
|
|
98
|
+
/** The terminal entries of `orca terminal list --json`: `result.terminals`, `terminals`, or a bare array. */
|
|
99
|
+
function orcaFindResponseArray(parsed) {
|
|
100
|
+
if (Array.isArray(parsed))
|
|
101
|
+
return parsed;
|
|
102
|
+
const terminals = field(parsed, 'terminals') ?? field(field(parsed, 'result'), 'terminals');
|
|
103
|
+
return Array.isArray(terminals) ? terminals : null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Parses `orca terminal list --json` into {@link FoundSession}s. An entry's
|
|
107
|
+
* `agentIdentity` is absent or null for a plain shell and a harness name
|
|
108
|
+
* otherwise, so `alive` means "an agent runs there". `lastOutputAt` is epoch
|
|
109
|
+
* milliseconds. Every entry gets the query's `cwd`, since `--worktree`
|
|
110
|
+
* already scoped the list. Unparseable output is `[]`.
|
|
111
|
+
*/
|
|
112
|
+
export function parseOrcaFindResponse(stdout, cwd) {
|
|
113
|
+
return parseOrcaTerminals(stdout, cwd).map(toFoundSession);
|
|
114
|
+
}
|
|
115
|
+
function toFoundSession(terminal) {
|
|
116
|
+
const { writable, ...session } = terminal;
|
|
117
|
+
void writable; // a found session does not report whether it accepts input
|
|
118
|
+
return session;
|
|
119
|
+
}
|
|
120
|
+
function parseOrcaTerminals(stdout, cwd) {
|
|
121
|
+
const entries = orcaFindResponseArray(parseJsonOrNull(stdout));
|
|
122
|
+
if (entries === null)
|
|
123
|
+
return [];
|
|
124
|
+
const found = [];
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
if (!isRecord(entry))
|
|
127
|
+
continue;
|
|
128
|
+
const agentIdentity = entry['agentIdentity'];
|
|
129
|
+
const harness = harnessOrNull(agentIdentity);
|
|
130
|
+
const alive = agentIdentity !== null && agentIdentity !== undefined;
|
|
131
|
+
found.push({
|
|
132
|
+
cwd,
|
|
133
|
+
harness,
|
|
134
|
+
alive,
|
|
135
|
+
lastOutputAt: isoTimestamp(entry['lastOutputAt']),
|
|
136
|
+
handle: typeof entry['handle'] === 'string' ? entry['handle'] : null,
|
|
137
|
+
writable: entry['writable'] === true,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return found;
|
|
141
|
+
}
|
|
142
|
+
function isoTimestamp(value) {
|
|
143
|
+
if (typeof value === 'string')
|
|
144
|
+
return value;
|
|
145
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
146
|
+
return new Date(value).toISOString();
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
function listOrcaTerminals(runner, cwd) {
|
|
150
|
+
const res = runner.run(orcaFindArgv({ cwd }), { cwd, timeoutMs: 30_000 });
|
|
151
|
+
return ran(res) ? parseOrcaTerminals(res.stdout, cwd) : [];
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The handle of the terminal for `cwd` to act on, or null. With a remembered handle
|
|
155
|
+
* (`bound`) that is that terminal if Orca still lists it live and writable, and nothing
|
|
156
|
+
* otherwise: another session's terminal at the same worktree is not this one's. Without
|
|
157
|
+
* one it is the one live, writable agent terminal on `cwd` (of `harness` when given), or
|
|
158
|
+
* null when there are none or several: delivering into the wrong session is worse than no
|
|
159
|
+
* delivery. Handles change when Orca restarts, so resolve one right before using it.
|
|
160
|
+
*/
|
|
161
|
+
export function resolveOrcaHandle(runner, cwd, bound = null, harness) {
|
|
162
|
+
const terminals = listOrcaTerminals(runner, cwd);
|
|
163
|
+
if (bound !== null) {
|
|
164
|
+
return terminals.some((t) => t.handle === bound && t.writable) ? bound : null;
|
|
165
|
+
}
|
|
166
|
+
const matches = terminals.filter((t) => t.alive && t.writable && t.handle && (harness === undefined || t.harness === harness));
|
|
167
|
+
return matches.length === 1 ? (matches[0]?.handle ?? null) : null;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Whether a failed `terminal create` says Orca does not know the worktree. The exact
|
|
171
|
+
* wording is not recorded (`docs/planning/research/orca.md`), so this matches any
|
|
172
|
+
* mention of a worktree in its output.
|
|
173
|
+
*/
|
|
174
|
+
function isOrcaUnknownWorktree(res) {
|
|
175
|
+
return /worktree/i.test(res.stderr) || /worktree/i.test(res.stdout);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The Orca host: opens terminal tabs in Orca through its CLI, found as `orca` on PATH or the app's bundled CLI.
|
|
179
|
+
* darwin only. Remembers the terminal handle of each session under `runtimeDir`/`registryDir` ({@link HostStateOptions}).
|
|
180
|
+
*/
|
|
181
|
+
export function makeOrcaHost(opts) {
|
|
182
|
+
const binary = () => (opts.binary === undefined ? orcaBinary() : opts.binary);
|
|
183
|
+
const registryDir = registryDirOf('orca', opts);
|
|
184
|
+
const boundHandle = (target) => boundPane(registryDir, target);
|
|
185
|
+
return {
|
|
186
|
+
name: 'orca',
|
|
187
|
+
acksSend: true,
|
|
188
|
+
// Observed on macOS only; a platform that was not run is left out.
|
|
189
|
+
platforms: ['darwin'],
|
|
190
|
+
available() {
|
|
191
|
+
if (opts.binary === undefined && !supportsPlatform(this))
|
|
192
|
+
return false;
|
|
193
|
+
return binary() !== null;
|
|
194
|
+
},
|
|
195
|
+
terminal(req) {
|
|
196
|
+
const bin = binary();
|
|
197
|
+
if (bin === null)
|
|
198
|
+
throw new Error(`orca is not on PATH and ${ORCA_BUNDLED_CLI} is absent`);
|
|
199
|
+
const runner = resolveRunner(bin, opts.runner);
|
|
200
|
+
const argv = orcaCreateArgv(req);
|
|
201
|
+
let res = runner.run(argv, { cwd: req.cwd, timeoutMs: 60_000 });
|
|
202
|
+
// Only an unknown worktree is retried. Any other failure, or a create that never exited
|
|
203
|
+
// (timed out, killed), may have opened the terminal already: a second create would start
|
|
204
|
+
// the agent twice.
|
|
205
|
+
if (!ran(res) &&
|
|
206
|
+
res.neverExited !== true &&
|
|
207
|
+
opts.repoRoot !== undefined &&
|
|
208
|
+
isOrcaUnknownWorktree(res)) {
|
|
209
|
+
runner.run(['repo', 'add', '--path', opts.repoRoot], { cwd: req.cwd, timeoutMs: 60_000 });
|
|
210
|
+
res = runner.run(argv, { cwd: req.cwd, timeoutMs: 60_000 });
|
|
211
|
+
}
|
|
212
|
+
if (!ran(res)) {
|
|
213
|
+
throw new Error(`orca terminal create failed: ${failureText(res)}\n` +
|
|
214
|
+
`If Orca does not know this worktree, open it there first: orca worktree list`);
|
|
215
|
+
}
|
|
216
|
+
const handle = parseOrcaCreatedHandle(res.stdout);
|
|
217
|
+
if (handle !== null && req.sessionId !== undefined) {
|
|
218
|
+
rememberSessionPane(registryDir, req.sessionId, handle, req.cwd);
|
|
219
|
+
}
|
|
220
|
+
return 0;
|
|
221
|
+
},
|
|
222
|
+
// Orca's own `terminal create` call carries no session identity of its
|
|
223
|
+
// own to hand back, but `wrapperSession` (`../harness.ts`) can still
|
|
224
|
+
// resolve one independently of Orca, when the harness itself exposes a
|
|
225
|
+
// way to (codex's own rollout-file scan) — see its doc for why
|
|
226
|
+
// claude/cursor/pi currently always come back null regardless of host.
|
|
227
|
+
session: sessionViaTerminal,
|
|
228
|
+
send(req) {
|
|
229
|
+
const bin = binary();
|
|
230
|
+
if (bin === null)
|
|
231
|
+
return { accepted: false, ack: false };
|
|
232
|
+
const runner = resolveRunner(bin, opts.runner);
|
|
233
|
+
const trySend = () => {
|
|
234
|
+
const handle = resolveOrcaHandle(runner, req.cwd, boundHandle(req), req.harness);
|
|
235
|
+
if (handle === null)
|
|
236
|
+
return null;
|
|
237
|
+
try {
|
|
238
|
+
return runner.run(orcaSendArgv(handle, req.text), { cwd: req.cwd, timeoutMs: 30_000 });
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// The runner throws on text it cannot pass at all (a NUL byte); `send` never throws.
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
let res = trySend();
|
|
246
|
+
if (res !== null && isOrcaHandleStale(res.stdout))
|
|
247
|
+
res = trySend();
|
|
248
|
+
if (res === null || !ran(res))
|
|
249
|
+
return { accepted: false, ack: false };
|
|
250
|
+
return parseOrcaSendResponse(res.stdout);
|
|
251
|
+
},
|
|
252
|
+
find(query) {
|
|
253
|
+
const bin = binary();
|
|
254
|
+
if (bin === null)
|
|
255
|
+
return [];
|
|
256
|
+
// A find failure means "nothing found", not an exception.
|
|
257
|
+
const found = listOrcaTerminals(resolveRunner(bin, opts.runner), query.cwd);
|
|
258
|
+
const bound = boundHandle(query);
|
|
259
|
+
return (bound === null ? found : found.filter((t) => t.handle === bound)).map(toFoundSession);
|
|
260
|
+
},
|
|
261
|
+
focus(query) {
|
|
262
|
+
const bin = binary();
|
|
263
|
+
if (bin === null)
|
|
264
|
+
return false;
|
|
265
|
+
const runner = resolveRunner(bin, opts.runner);
|
|
266
|
+
const handle = resolveOrcaHandle(runner, query.cwd, boundHandle(query));
|
|
267
|
+
if (handle === null)
|
|
268
|
+
return false;
|
|
269
|
+
return ran(runner.run(orcaSwitchArgv(handle), { cwd: query.cwd, timeoutMs: 30_000 }));
|
|
270
|
+
},
|
|
271
|
+
close(query) {
|
|
272
|
+
const bin = binary();
|
|
273
|
+
if (bin === null)
|
|
274
|
+
return false;
|
|
275
|
+
try {
|
|
276
|
+
// Closing is destructive: a session whose terminal was never remembered (opened before
|
|
277
|
+
// handles were) is left open rather than closing whichever agent terminal is first there.
|
|
278
|
+
const bound = boundHandle(query);
|
|
279
|
+
if (query.sessionId !== undefined && bound === null)
|
|
280
|
+
return false;
|
|
281
|
+
const runner = resolveRunner(bin, opts.runner);
|
|
282
|
+
const handle = resolveOrcaHandle(runner, query.cwd, bound);
|
|
283
|
+
if (handle === null)
|
|
284
|
+
return false;
|
|
285
|
+
return (runner.run(orcaCloseArgv(handle), { cwd: query.cwd, timeoutMs: 30_000 }).exitCode === 0);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Otty (darwin only): a tab on `cwd` running a command, found again by a tag
|
|
3
|
+
* in its title: derived from the caller's session id when the launch has one, else
|
|
4
|
+
* from `cwd`. Research: `docs/planning/research/otty.md`
|
|
5
|
+
* (otty 1.5.4; the app was not running, so every JSON shape here is DOCS-ONLY:
|
|
6
|
+
* `--help` names the flags but `pane list --json` output was never observed).
|
|
7
|
+
*
|
|
8
|
+
* Otty's control CLI needs the running app; without it every command exits
|
|
9
|
+
* nonzero with "Cannot connect to Otty ... Is Otty running?" (observed). So
|
|
10
|
+
* send/find/focus/capture treat any nonzero exit as "no session" and never throw.
|
|
11
|
+
*
|
|
12
|
+
* `--help` does not show whether `pane list` carries a cwd, so a pane matches when a
|
|
13
|
+
* cwd-like field equals `cwd` OR any string field contains {@link ottyTag}, which
|
|
14
|
+
* `terminal()` appends to the tab title. A request with a session id matches by the
|
|
15
|
+
* id's tag alone (or, for a tab opened before ids were tagged, the cwd's tag), never by
|
|
16
|
+
* a bare cwd field: that would reach another session's pane in the same directory. Whether `pane list` exposes the title is also
|
|
17
|
+
* unverified (`docs/planning/research/otty.md`). `send` goes only to a pane carrying the tag (one this host
|
|
18
|
+
* opened) AND showing an agent in the foreground: the tag stays in the tab title
|
|
19
|
+
* after the agent exits, and the tab then holds a plain shell, where the pointer
|
|
20
|
+
* would run as a command. A pane that does not name its foreground process is
|
|
21
|
+
* therefore never sent to and `find` reports it not alive, so a caller falls
|
|
22
|
+
* back to another route.
|
|
23
|
+
*/
|
|
24
|
+
import { type CommandRunner } from '@osdlc/command-seam';
|
|
25
|
+
import { type CommandRequest, type Host } from '../contract.ts';
|
|
26
|
+
/**
|
|
27
|
+
* A tag appended to the tab title so a later `find` can match it: stable for the
|
|
28
|
+
* caller's session id when there is one (`[sd-i-<slug>]`, {@link sessionIdSlug}: the id's first
|
|
29
|
+
* 12 characters, rewritten to `[A-Za-z0-9_-]`, and 8 hex digits of a hash of the whole id, so
|
|
30
|
+
* ids sharing a prefix get different tags), else for `cwd` (`[sd-<hash>]`).
|
|
31
|
+
*/
|
|
32
|
+
export declare function ottyTag(cwd: string, sessionId?: string): string;
|
|
33
|
+
/** Argv for `otty tab new` — pure, so a test can pin it. */
|
|
34
|
+
export declare function ottyCreateArgv(req: CommandRequest): string[];
|
|
35
|
+
export declare function ottyListArgv(): string[];
|
|
36
|
+
/**
|
|
37
|
+
* Argv for `otty pane send-keys`: the text pasted bracketed (so embedded
|
|
38
|
+
* newlines are not Enters), then a separate `key:Enter` part. `--` keeps a
|
|
39
|
+
* `-`-prefixed text from being read as a flag.
|
|
40
|
+
*/
|
|
41
|
+
export declare function ottySendArgv(paneId: string, text: string): string[];
|
|
42
|
+
export declare function ottyFocusArgv(paneId: string): string[];
|
|
43
|
+
export declare function ottyCaptureArgv(paneId: string): string[];
|
|
44
|
+
/**
|
|
45
|
+
* Argv for `otty pane close`; `--yes` skips the destructive-action prompt (the global
|
|
46
|
+
* flag of `otty pane close --help`). Closing a tab's last pane closes the tab.
|
|
47
|
+
*/
|
|
48
|
+
export declare function ottyCloseArgv(paneId: string): string[];
|
|
49
|
+
/**
|
|
50
|
+
* The pane ids in `otty pane list --json` that belong to `cwd`: a bare array
|
|
51
|
+
* or `{panes: [...]}` of objects with an `id` (or `pane_id`). Unparseable
|
|
52
|
+
* output is `[]`. Field names are DOCS-ONLY guesses; see the module doc.
|
|
53
|
+
* `taggedOnly` drops the panes matched by a cwd field alone; `agentOnly` keeps
|
|
54
|
+
* only panes whose foreground process is a known agent.
|
|
55
|
+
*/
|
|
56
|
+
export declare function parseOttyPanes(stdout: string, cwd: string, opts?: {
|
|
57
|
+
taggedOnly?: boolean;
|
|
58
|
+
agentOnly?: boolean;
|
|
59
|
+
sessionId?: string | undefined;
|
|
60
|
+
/** With a session id, only its own tab: never the cwd-tagged fallback. */
|
|
61
|
+
idOnly?: boolean;
|
|
62
|
+
}): string[];
|
|
63
|
+
/**
|
|
64
|
+
* The Otty host: opens tabs in the Otty app through the `otty` binary, which must be on PATH; the app must be running.
|
|
65
|
+
* darwin only. Keeps no state of its own; tabs are found again by a tag in their title.
|
|
66
|
+
*/
|
|
67
|
+
export declare function makeOttyHost(opts?: {
|
|
68
|
+
runner?: CommandRunner | undefined;
|
|
69
|
+
available?: (() => boolean) | undefined;
|
|
70
|
+
}): Host;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Otty (darwin only): a tab on `cwd` running a command, found again by a tag
|
|
3
|
+
* in its title: derived from the caller's session id when the launch has one, else
|
|
4
|
+
* from `cwd`. Research: `docs/planning/research/otty.md`
|
|
5
|
+
* (otty 1.5.4; the app was not running, so every JSON shape here is DOCS-ONLY:
|
|
6
|
+
* `--help` names the flags but `pane list --json` output was never observed).
|
|
7
|
+
*
|
|
8
|
+
* Otty's control CLI needs the running app; without it every command exits
|
|
9
|
+
* nonzero with "Cannot connect to Otty ... Is Otty running?" (observed). So
|
|
10
|
+
* send/find/focus/capture treat any nonzero exit as "no session" and never throw.
|
|
11
|
+
*
|
|
12
|
+
* `--help` does not show whether `pane list` carries a cwd, so a pane matches when a
|
|
13
|
+
* cwd-like field equals `cwd` OR any string field contains {@link ottyTag}, which
|
|
14
|
+
* `terminal()` appends to the tab title. A request with a session id matches by the
|
|
15
|
+
* id's tag alone (or, for a tab opened before ids were tagged, the cwd's tag), never by
|
|
16
|
+
* a bare cwd field: that would reach another session's pane in the same directory. Whether `pane list` exposes the title is also
|
|
17
|
+
* unverified (`docs/planning/research/otty.md`). `send` goes only to a pane carrying the tag (one this host
|
|
18
|
+
* opened) AND showing an agent in the foreground: the tag stays in the tab title
|
|
19
|
+
* after the agent exits, and the tab then holds a plain shell, where the pointer
|
|
20
|
+
* would run as a command. A pane that does not name its foreground process is
|
|
21
|
+
* therefore never sent to and `find` reports it not alive, so a caller falls
|
|
22
|
+
* back to another route.
|
|
23
|
+
*/
|
|
24
|
+
import { commandString, failureText, ran, resolveRunner, } from '@osdlc/command-seam';
|
|
25
|
+
import { BUILTIN_HARNESSES, HARNESS_ALIASES } from '@osdlc/detect-harness';
|
|
26
|
+
import { isRecord, parseJsonOrNull, sessionIdSlug, shortHash } from "../util.js";
|
|
27
|
+
import { onPath, quietRun, supportsPlatform, } from "../contract.js";
|
|
28
|
+
import { sessionViaTerminal } from "../harness.js";
|
|
29
|
+
/**
|
|
30
|
+
* A tag appended to the tab title so a later `find` can match it: stable for the
|
|
31
|
+
* caller's session id when there is one (`[sd-i-<slug>]`, {@link sessionIdSlug}: the id's first
|
|
32
|
+
* 12 characters, rewritten to `[A-Za-z0-9_-]`, and 8 hex digits of a hash of the whole id, so
|
|
33
|
+
* ids sharing a prefix get different tags), else for `cwd` (`[sd-<hash>]`).
|
|
34
|
+
*/
|
|
35
|
+
export function ottyTag(cwd, sessionId) {
|
|
36
|
+
if (sessionId !== undefined)
|
|
37
|
+
return `[sd-i-${sessionIdSlug(sessionId)}]`;
|
|
38
|
+
return `[sd-${shortHash(cwd, 12)}]`;
|
|
39
|
+
}
|
|
40
|
+
/** Argv for `otty tab new` — pure, so a test can pin it. */
|
|
41
|
+
export function ottyCreateArgv(req) {
|
|
42
|
+
return [
|
|
43
|
+
'tab',
|
|
44
|
+
'new',
|
|
45
|
+
'--cwd',
|
|
46
|
+
req.cwd,
|
|
47
|
+
'--command',
|
|
48
|
+
commandString(req.argv),
|
|
49
|
+
'--title',
|
|
50
|
+
`${req.title} ${ottyTag(req.cwd, req.sessionId)}`,
|
|
51
|
+
'--no-focus',
|
|
52
|
+
'--json',
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
export function ottyListArgv() {
|
|
56
|
+
return ['pane', 'list', '--json'];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Argv for `otty pane send-keys`: the text pasted bracketed (so embedded
|
|
60
|
+
* newlines are not Enters), then a separate `key:Enter` part. `--` keeps a
|
|
61
|
+
* `-`-prefixed text from being read as a flag.
|
|
62
|
+
*/
|
|
63
|
+
export function ottySendArgv(paneId, text) {
|
|
64
|
+
return ['pane', 'send-keys', '--pane', paneId, '--bracketed-paste', '--', text, 'key:Enter'];
|
|
65
|
+
}
|
|
66
|
+
export function ottyFocusArgv(paneId) {
|
|
67
|
+
return ['pane', 'focus', paneId];
|
|
68
|
+
}
|
|
69
|
+
export function ottyCaptureArgv(paneId) {
|
|
70
|
+
return ['pane', 'capture', '--pane', paneId];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Argv for `otty pane close`; `--yes` skips the destructive-action prompt (the global
|
|
74
|
+
* flag of `otty pane close --help`). Closing a tab's last pane closes the tab.
|
|
75
|
+
*/
|
|
76
|
+
export function ottyCloseArgv(paneId) {
|
|
77
|
+
return ['pane', 'close', '--pane', paneId, '--yes'];
|
|
78
|
+
}
|
|
79
|
+
const CWD_KEYS = ['cwd', 'working_directory', 'current_directory'];
|
|
80
|
+
/** Pane fields that may name the foreground process (DOCS-ONLY guesses, like the rest). */
|
|
81
|
+
const PROCESS_KEYS = ['foreground_process', 'process', 'running_command', 'command', 'agent'];
|
|
82
|
+
/** Executable names that mean an agent is running: each built-in harness id and alias (`cursor-agent`, `agy`). */
|
|
83
|
+
const AGENT_BINS = new Set([
|
|
84
|
+
...BUILTIN_HARNESSES,
|
|
85
|
+
...Object.keys(HARNESS_ALIASES),
|
|
86
|
+
]);
|
|
87
|
+
/** Whether the pane entry names a known agent as its foreground process. */
|
|
88
|
+
function showsAgent(entry) {
|
|
89
|
+
return PROCESS_KEYS.some((k) => {
|
|
90
|
+
const v = entry[k];
|
|
91
|
+
if (typeof v !== 'string')
|
|
92
|
+
return false;
|
|
93
|
+
const bin = v.trim().split(/\s+/)[0]?.split(/[\\/]/).pop() ?? '';
|
|
94
|
+
return AGENT_BINS.has(bin);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The pane ids in `otty pane list --json` that belong to `cwd`: a bare array
|
|
99
|
+
* or `{panes: [...]}` of objects with an `id` (or `pane_id`). Unparseable
|
|
100
|
+
* output is `[]`. Field names are DOCS-ONLY guesses; see the module doc.
|
|
101
|
+
* `taggedOnly` drops the panes matched by a cwd field alone; `agentOnly` keeps
|
|
102
|
+
* only panes whose foreground process is a known agent.
|
|
103
|
+
*/
|
|
104
|
+
export function parseOttyPanes(stdout, cwd, opts = {}) {
|
|
105
|
+
const parsed = parseJsonOrNull(stdout);
|
|
106
|
+
const entries = Array.isArray(parsed) ? parsed : isRecord(parsed) ? parsed['panes'] : null;
|
|
107
|
+
if (!Array.isArray(entries))
|
|
108
|
+
return [];
|
|
109
|
+
const cwdTag = ottyTag(cwd);
|
|
110
|
+
const idTag = opts.sessionId === undefined ? null : ottyTag(cwd, opts.sessionId);
|
|
111
|
+
const byIdTag = [];
|
|
112
|
+
const byCwdTag = [];
|
|
113
|
+
const byBareCwd = [];
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (!isRecord(entry))
|
|
116
|
+
continue;
|
|
117
|
+
const id = entry['id'] ?? entry['pane_id'];
|
|
118
|
+
if (typeof id !== 'string' && typeof id !== 'number')
|
|
119
|
+
continue;
|
|
120
|
+
if (opts.agentOnly === true && !showsAgent(entry))
|
|
121
|
+
continue;
|
|
122
|
+
const tagged = (tag) => Object.values(entry).some((v) => typeof v === 'string' && v.includes(tag));
|
|
123
|
+
if (idTag !== null && tagged(idTag))
|
|
124
|
+
byIdTag.push(String(id));
|
|
125
|
+
else if (tagged(cwdTag))
|
|
126
|
+
byCwdTag.push(String(id));
|
|
127
|
+
else if (CWD_KEYS.some((k) => entry[k] === cwd))
|
|
128
|
+
byBareCwd.push(String(id));
|
|
129
|
+
}
|
|
130
|
+
// With a session id: its own tab, else (a tab opened before ids were tagged) the cwd's tag.
|
|
131
|
+
// A bare cwd field never counts there: it could be another session's tab or a plain shell.
|
|
132
|
+
if (idTag !== null)
|
|
133
|
+
return byIdTag.length > 0 || opts.idOnly === true ? byIdTag : byCwdTag;
|
|
134
|
+
return opts.taggedOnly === true ? byCwdTag : [...byCwdTag, ...byBareCwd];
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The Otty host: opens tabs in the Otty app through the `otty` binary, which must be on PATH; the app must be running.
|
|
138
|
+
* darwin only. Keeps no state of its own; tabs are found again by a tag in their title.
|
|
139
|
+
*/
|
|
140
|
+
export function makeOttyHost(opts = {}) {
|
|
141
|
+
const runner = () => resolveRunner('otty', opts.runner);
|
|
142
|
+
/** `pane list --json` output for `cwd`, or null (also when the app is not running). */
|
|
143
|
+
const listOutput = (cwd) => quietRun(runner(), ottyListArgv(), { cwd, timeoutMs: 10_000 });
|
|
144
|
+
/** The first pane for the request (tagged ones only when `taggedOnly`), or null. */
|
|
145
|
+
const resolvePane = (target, pick = {}) => {
|
|
146
|
+
const out = listOutput(target.cwd);
|
|
147
|
+
return out === null
|
|
148
|
+
? null
|
|
149
|
+
: (parseOttyPanes(out, target.cwd, { ...pick, sessionId: target.sessionId })[0] ?? null);
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
name: 'otty',
|
|
153
|
+
platforms: ['darwin'],
|
|
154
|
+
available() {
|
|
155
|
+
if (opts.available !== undefined)
|
|
156
|
+
return opts.available();
|
|
157
|
+
return supportsPlatform(this) && onPath('otty');
|
|
158
|
+
},
|
|
159
|
+
terminal(req) {
|
|
160
|
+
const res = runner().run(ottyCreateArgv(req), { cwd: req.cwd, timeoutMs: 60_000 });
|
|
161
|
+
if (!ran(res))
|
|
162
|
+
throw new Error(`otty tab new failed: ${failureText(res)}`);
|
|
163
|
+
return 0;
|
|
164
|
+
},
|
|
165
|
+
session: sessionViaTerminal,
|
|
166
|
+
send(req) {
|
|
167
|
+
// Otty has no delivery confirmation, so ack is always false.
|
|
168
|
+
try {
|
|
169
|
+
const pane = resolvePane(req, { taggedOnly: true, agentOnly: true });
|
|
170
|
+
if (pane === null)
|
|
171
|
+
return { accepted: false, ack: false };
|
|
172
|
+
const res = runner().run(ottySendArgv(pane, req.text), { cwd: req.cwd, timeoutMs: 15_000 });
|
|
173
|
+
return { accepted: ran(res), ack: false };
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return { accepted: false, ack: false };
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
find(query) {
|
|
180
|
+
const out = listOutput(query.cwd);
|
|
181
|
+
if (out === null)
|
|
182
|
+
return [];
|
|
183
|
+
const pick = { sessionId: query.sessionId };
|
|
184
|
+
// No harness identity and no output-recency signal are documented. A pane
|
|
185
|
+
// is alive only while it shows an agent in the foreground: the tab outlives
|
|
186
|
+
// the agent, as a shell. The agent pane is returned when there is one, so
|
|
187
|
+
// `alive` describes the pane whose handle is returned.
|
|
188
|
+
const agentPane = parseOttyPanes(out, query.cwd, { ...pick, agentOnly: true })[0];
|
|
189
|
+
const pane = agentPane ?? parseOttyPanes(out, query.cwd, pick)[0];
|
|
190
|
+
if (pane === undefined)
|
|
191
|
+
return [];
|
|
192
|
+
return [
|
|
193
|
+
{
|
|
194
|
+
cwd: query.cwd,
|
|
195
|
+
harness: null,
|
|
196
|
+
alive: agentPane !== undefined,
|
|
197
|
+
lastOutputAt: null,
|
|
198
|
+
handle: pane,
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
},
|
|
202
|
+
focus(query) {
|
|
203
|
+
const pane = resolvePane(query);
|
|
204
|
+
if (pane === null)
|
|
205
|
+
return false;
|
|
206
|
+
return quietRun(runner(), ottyFocusArgv(pane), { cwd: query.cwd, timeoutMs: 10_000 }) !== null;
|
|
207
|
+
},
|
|
208
|
+
capture(query) {
|
|
209
|
+
const pane = resolvePane(query);
|
|
210
|
+
if (pane === null)
|
|
211
|
+
return null;
|
|
212
|
+
return quietRun(runner(), ottyCaptureArgv(pane), { cwd: query.cwd, timeoutMs: 10_000 });
|
|
213
|
+
},
|
|
214
|
+
close(query) {
|
|
215
|
+
// Closing is destructive: with a session id only that session's own tab closes. The
|
|
216
|
+
// cwd-tagged tab may belong to another launch at the same cwd that carried no id.
|
|
217
|
+
// Without an id, every id-less launch at the cwd carries the same tag, so close only
|
|
218
|
+
// when exactly one tab does: any pick among several could close another session's.
|
|
219
|
+
const out = listOutput(query.cwd);
|
|
220
|
+
if (out === null)
|
|
221
|
+
return false;
|
|
222
|
+
const panes = parseOttyPanes(out, query.cwd, {
|
|
223
|
+
taggedOnly: true,
|
|
224
|
+
idOnly: true,
|
|
225
|
+
sessionId: query.sessionId,
|
|
226
|
+
});
|
|
227
|
+
const pane = query.sessionId !== undefined || panes.length === 1 ? panes[0] : undefined;
|
|
228
|
+
if (pane === undefined)
|
|
229
|
+
return false;
|
|
230
|
+
return quietRun(runner(), ottyCloseArgv(pane), { cwd: query.cwd, timeoutMs: 10_000 }) !== null;
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared reader for a host's registry: `<runtime>/hosts/<name>/*.json`,
|
|
3
|
+
* one file per running IDE or window, written by the extension or plugin that
|
|
4
|
+
* owns it. `<runtime>` is the caller's runtime directory (`runtimeDir`); the
|
|
5
|
+
* package picks none of its own, because the IDE companions must write to the
|
|
6
|
+
* same place.
|
|
7
|
+
*/
|
|
8
|
+
/** Where host `name` keeps its state under `runtimeDir`: `<runtimeDir>/hosts/<name>`. */
|
|
9
|
+
export declare function hostRegistryDir(runtimeDir: string, name: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Where a stateful host keeps its files. Give one of the two: `registryDir` is
|
|
12
|
+
* used as is; otherwise the host uses `<runtimeDir>/hosts/<name>`.
|
|
13
|
+
*/
|
|
14
|
+
export type HostStateOptions = {
|
|
15
|
+
/** The caller's runtime directory; the host uses `<runtimeDir>/hosts/<name>`. */
|
|
16
|
+
runtimeDir: string;
|
|
17
|
+
/** The host's own directory, used as is. Wins over `runtimeDir`. */
|
|
18
|
+
registryDir?: string | undefined;
|
|
19
|
+
} | {
|
|
20
|
+
runtimeDir?: string | undefined;
|
|
21
|
+
/** The host's own directory, used as is. */
|
|
22
|
+
registryDir: string;
|
|
23
|
+
};
|
|
24
|
+
/** Host `name`'s state directory under `opts`. */
|
|
25
|
+
export declare function registryDirOf(name: string, opts: HostStateOptions): string;
|
|
26
|
+
/** How {@link readHostRegistry} decides whom to trust; injectable for tests. */
|
|
27
|
+
export interface RegistryTrustOptions {
|
|
28
|
+
/** The uid entries must belong to; null skips the check (Windows). Default `process.getuid()`. */
|
|
29
|
+
uid?: number | null | undefined;
|
|
30
|
+
/** Told why an entry or the whole directory was skipped as untrusted. */
|
|
31
|
+
onSkip?: ((reason: string) => void) | undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Entries in `dir` that `parse` accepts and whose process is alive. A missing
|
|
35
|
+
* directory, an unreadable or malformed file, and a dead pid are all skipped.
|
|
36
|
+
*
|
|
37
|
+
* An entry carries an endpoint and a token, so a planted one would receive
|
|
38
|
+
* every session's argv and sent text. On POSIX the whole directory is skipped
|
|
39
|
+
* unless it belongs to the current user and is not group- or world-writable,
|
|
40
|
+
* and so is each file; `opts.onSkip` hears why. Windows has no uid to check.
|
|
41
|
+
*/
|
|
42
|
+
export declare function readHostRegistry<T extends {
|
|
43
|
+
pid: number;
|
|
44
|
+
}>(dir: string, parse: (raw: unknown) => T | null, alive: (pid: number) => boolean, opts?: RegistryTrustOptions): T[];
|
|
45
|
+
/**
|
|
46
|
+
* The candidate with the deepest folder containing `cwd` (depth = length of the
|
|
47
|
+
* real path), the newest `startedAt` winning a tie, then the earliest in list
|
|
48
|
+
* order. Null when no folder contains `cwd`. How both IDE hosts pick a window.
|
|
49
|
+
*/
|
|
50
|
+
export declare function deepestContaining<T>(cwd: string, candidates: readonly T[], folders: (c: T) => readonly string[], startedAt: (c: T) => string): T | null;
|