@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.
@@ -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;