@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,98 @@
1
+ /**
2
+ * JetBrains IDEs: a terminal tab in the IDE window that has the project open,
3
+ * driven through a companion plugin
4
+ * (`docs/planning/research/jetbrains.md` records why the bundled MCP
5
+ * Server is not enough).
6
+ *
7
+ * The plugin serves `/api/sdlc/...` on the IDE's built-in web server and
8
+ * registers each running IDE at `<runtime dir>/hosts/jetbrains/<pid>.json`
9
+ * with its port and a per-IDE bearer
10
+ * token. This host reads those files (skipping, on POSIX, any another user
11
+ * owns or can write), picks the IDE whose open project contains
12
+ * `cwd`, and calls it over loopback HTTP through `curl` on the command seam
13
+ * (the host parts are synchronous, so `fetch` is out). When no IDE has a
14
+ * project containing `cwd`, the optional `openWorkspace` part asks an IDE to
15
+ * open the git root of `cwd` as a project (the caller's launch
16
+ * policy decides when; the IDE still asks the person to trust it); `terminal`/`session`/`send`/`find`/`focus`
17
+ * never open one themselves.
18
+ *
19
+ * Wire format (JSON; every call carries `Authorization: Bearer <token>`; the
20
+ * golden request/response pairs
21
+ * are read by this host's tests and the plugin's):
22
+ * - `GET /api/sdlc/ping` -> `{protocol, pid, product, projects[]}`
23
+ * - `POST /api/sdlc/project/open` `{path}` -> `{ok, project}` or `{ok: false, error}`
24
+ * - `POST /api/sdlc/terminal/open` `{cwd, title, argv}` -> `{ok}`
25
+ * - `POST /api/sdlc/terminal/send` `{cwd, text}` -> `{accepted}`
26
+ * - `POST /api/sdlc/terminal/list` `{cwd}` -> `{terminals: [{id, title, cwd, alive}]}`
27
+ * - `POST /api/sdlc/terminal/focus` `{cwd}` -> `{ok}`
28
+ * Errors are `{ok: false, error}` with HTTP 400 (bad request), 401 (token), 404
29
+ * (unknown endpoint), 500 (the plugin threw), 503 (the IDE's UI thread was busy or a dialog is
30
+ * open: nothing was done) or 504 (the UI thread began the work but did not finish: the outcome
31
+ * is unknown); the plugin bounds its wait for the UI thread (3 s, then 2 s more) so a stuck IDE
32
+ * answers instead of hanging. The host surfaces that text.
33
+ * The registry entry and the ping reply both carry `protocol`; an IDE speaking
34
+ * another version is skipped with a stated reason. The ping reply also carries the IDE's `pid`,
35
+ * which must equal the registry file's: a mismatch is a stale entry whose port another process
36
+ * now holds, and that IDE is skipped.
37
+ * `send` never acks: the terminal API reports no consumption. A 504 on `send` is accepted
38
+ * and uncertain, since the text may still land.
39
+ *
40
+ * Provenance (`docs/planning/research/jetbrains.md`): the wire format is pinned by
41
+ * golden fixtures, and was observed in one `runIde` sandbox run.
42
+ */
43
+ import { type CommandRunner } from '@osdlc/command-seam';
44
+ import { type Host } from '../contract.ts';
45
+ import { type HostStateOptions } from './registry.ts';
46
+ /** What one registry file holds: how to reach one running IDE. */
47
+ export interface JetbrainsEndpoint {
48
+ pid: number;
49
+ port: number;
50
+ token: string;
51
+ /** Wire protocol the plugin speaks; absent in files written before it existed. */
52
+ protocol: number | null;
53
+ /** ISO start time of the IDE process; '' in a registry file written before the plugin recorded it. */
54
+ startedAt: string;
55
+ }
56
+ /**
57
+ * The wire protocol this host speaks.
58
+ *
59
+ * @experimental needs the companion JetBrains plugin, which is not published yet.
60
+ */
61
+ export declare const JETBRAINS_PROTOCOL = 2;
62
+ /**
63
+ * The JetBrains host's settings: `settings` of {@link makeJetbrainsHost}, `jetbrains` of `hostRegistry`.
64
+ *
65
+ * @experimental needs the companion JetBrains plugin, which is not published yet.
66
+ */
67
+ export interface JetbrainsSettings {
68
+ /** Which IDE opens a workspace when several run: matched case-insensitively and exactly against the product name. */
69
+ ide?: string | undefined;
70
+ }
71
+ /** Validates one parsed registry file; null when it is not a usable endpoint. */
72
+ export declare function parseRegistryEntry(parsed: unknown): JetbrainsEndpoint | null;
73
+ /**
74
+ * `curl` argv and stdin for one call. Token and body go in a config read from
75
+ * stdin, so neither shows in the process list.
76
+ */
77
+ export declare function curlRequest(ide: JetbrainsEndpoint, path: string, body: unknown, timeoutSec?: number): {
78
+ argv: string[];
79
+ input: string;
80
+ };
81
+ /**
82
+ * The JetBrains host: opens terminal tabs in a running JetBrains IDE through the companion plugin,
83
+ * reached over loopback HTTP with `curl`, which must be on PATH. darwin only; available when an IDE with the plugin is running.
84
+ * Reads the IDEs the plugin registered under `runtimeDir`/`registryDir` ({@link HostStateOptions}).
85
+ *
86
+ * @experimental needs the companion JetBrains plugin, which is not published yet.
87
+ */
88
+ export declare function makeJetbrainsHost(opts: {
89
+ available?: (() => boolean) | undefined;
90
+ runner?: CommandRunner | undefined;
91
+ pidAlive?: ((pid: number) => boolean) | undefined;
92
+ /** The clock, in ms, for the answering-IDE cache. */
93
+ now?: (() => number) | undefined;
94
+ /** The host's settings ({@link JetbrainsSettings}); none means any IDE. */
95
+ settings?: JetbrainsSettings | undefined;
96
+ /** The git top-level of `cwd`, or null outside a repository. Injectable for tests. */
97
+ gitRoot?: ((cwd: string) => string | null) | undefined;
98
+ } & HostStateOptions): Host;
@@ -0,0 +1,353 @@
1
+ /**
2
+ * JetBrains IDEs: a terminal tab in the IDE window that has the project open,
3
+ * driven through a companion plugin
4
+ * (`docs/planning/research/jetbrains.md` records why the bundled MCP
5
+ * Server is not enough).
6
+ *
7
+ * The plugin serves `/api/sdlc/...` on the IDE's built-in web server and
8
+ * registers each running IDE at `<runtime dir>/hosts/jetbrains/<pid>.json`
9
+ * with its port and a per-IDE bearer
10
+ * token. This host reads those files (skipping, on POSIX, any another user
11
+ * owns or can write), picks the IDE whose open project contains
12
+ * `cwd`, and calls it over loopback HTTP through `curl` on the command seam
13
+ * (the host parts are synchronous, so `fetch` is out). When no IDE has a
14
+ * project containing `cwd`, the optional `openWorkspace` part asks an IDE to
15
+ * open the git root of `cwd` as a project (the caller's launch
16
+ * policy decides when; the IDE still asks the person to trust it); `terminal`/`session`/`send`/`find`/`focus`
17
+ * never open one themselves.
18
+ *
19
+ * Wire format (JSON; every call carries `Authorization: Bearer <token>`; the
20
+ * golden request/response pairs
21
+ * are read by this host's tests and the plugin's):
22
+ * - `GET /api/sdlc/ping` -> `{protocol, pid, product, projects[]}`
23
+ * - `POST /api/sdlc/project/open` `{path}` -> `{ok, project}` or `{ok: false, error}`
24
+ * - `POST /api/sdlc/terminal/open` `{cwd, title, argv}` -> `{ok}`
25
+ * - `POST /api/sdlc/terminal/send` `{cwd, text}` -> `{accepted}`
26
+ * - `POST /api/sdlc/terminal/list` `{cwd}` -> `{terminals: [{id, title, cwd, alive}]}`
27
+ * - `POST /api/sdlc/terminal/focus` `{cwd}` -> `{ok}`
28
+ * Errors are `{ok: false, error}` with HTTP 400 (bad request), 401 (token), 404
29
+ * (unknown endpoint), 500 (the plugin threw), 503 (the IDE's UI thread was busy or a dialog is
30
+ * open: nothing was done) or 504 (the UI thread began the work but did not finish: the outcome
31
+ * is unknown); the plugin bounds its wait for the UI thread (3 s, then 2 s more) so a stuck IDE
32
+ * answers instead of hanging. The host surfaces that text.
33
+ * The registry entry and the ping reply both carry `protocol`; an IDE speaking
34
+ * another version is skipped with a stated reason. The ping reply also carries the IDE's `pid`,
35
+ * which must equal the registry file's: a mismatch is a stale entry whose port another process
36
+ * now holds, and that IDE is skipped.
37
+ * `send` never acks: the terminal API reports no consumption. A 504 on `send` is accepted
38
+ * and uncertain, since the text may still land.
39
+ *
40
+ * Provenance (`docs/planning/research/jetbrains.md`): the wire format is pinned by
41
+ * golden fixtures, and was observed in one `runIde` sandbox run.
42
+ */
43
+ import { homedir } from 'node:os';
44
+ import { parse } from 'node:path';
45
+ import { resolveRunner } from '@osdlc/command-seam';
46
+ import { isRecord, isWithin, parseJsonOrNull, pidAlive, realResolve, which } from "../util.js";
47
+ import { Git } from '@osdlc/typed-git';
48
+ import { supportsPlatform, } from "../contract.js";
49
+ import { sessionViaTerminal } from "../harness.js";
50
+ import { deepestContaining, readHostRegistry, registryDirOf, } from "./registry.js";
51
+ /**
52
+ * The wire protocol this host speaks.
53
+ *
54
+ * @experimental needs the companion JetBrains plugin, which is not published yet.
55
+ */
56
+ export const JETBRAINS_PROTOCOL = 2;
57
+ /** How long the plugin may take to open a project (it waits up to 60s for the trust prompt). */
58
+ const OPEN_PROJECT_TIMEOUT_SEC = 70;
59
+ /** A ping's time limit (s): a hung IDE must not stall host detection or a send for 10s (the VS Code host's limit too). */
60
+ const PING_TIMEOUT_SEC = 1.5;
61
+ /** How long {@link makeJetbrainsHost} reuses the list of answering IDEs. */
62
+ const IDES_TTL_MS = 5_000;
63
+ /** Validates one parsed registry file; null when it is not a usable endpoint. */
64
+ export function parseRegistryEntry(parsed) {
65
+ if (!isRecord(parsed))
66
+ return null;
67
+ const { pid, port, token } = parsed;
68
+ if (typeof pid !== 'number' || !Number.isInteger(pid))
69
+ return null;
70
+ if (typeof port !== 'number' || !Number.isInteger(port) || port <= 0 || port > 65535)
71
+ return null;
72
+ if (typeof token !== 'string' || token === '')
73
+ return null;
74
+ return {
75
+ pid,
76
+ port,
77
+ token,
78
+ protocol: typeof parsed['protocol'] === 'number' ? parsed['protocol'] : null,
79
+ startedAt: typeof parsed['startedAt'] === 'string' ? parsed['startedAt'] : '',
80
+ };
81
+ }
82
+ /** curl's `--config` quoting: JSON text has no raw newlines, so `\` and `"` are all to escape. */
83
+ function curlQuote(s) {
84
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
85
+ }
86
+ /**
87
+ * `curl` argv and stdin for one call. Token and body go in a config read from
88
+ * stdin, so neither shows in the process list.
89
+ */
90
+ export function curlRequest(ide, path, body, timeoutSec = 10) {
91
+ const lines = [
92
+ `url = ${curlQuote(`http://127.0.0.1:${ide.port}${path}`)}`,
93
+ `header = ${curlQuote(`Authorization: Bearer ${ide.token}`)}`,
94
+ ];
95
+ if (body !== undefined) {
96
+ lines.push(`header = ${curlQuote('Content-Type: application/json')}`);
97
+ lines.push(`data-binary = ${curlQuote(JSON.stringify(body))}`);
98
+ }
99
+ return {
100
+ // `-q` (first, or curl ignores it) skips the user's `~/.curlrc`, whose options could change
101
+ // the reply's shape. `--noproxy '*'` keeps the call and its bearer token off any
102
+ // `http_proxy`: the IDE is on loopback. `-w` appends the status after the body so a 4xx/5xx
103
+ // body is not lost to `--fail`.
104
+ argv: [
105
+ '-q',
106
+ '--noproxy',
107
+ '*',
108
+ '-sS',
109
+ '-m',
110
+ String(timeoutSec),
111
+ '-w',
112
+ '\n%{http_code}',
113
+ '-K',
114
+ '-',
115
+ ],
116
+ input: lines.join('\n') + '\n',
117
+ };
118
+ }
119
+ /**
120
+ * The JetBrains host: opens terminal tabs in a running JetBrains IDE through the companion plugin,
121
+ * reached over loopback HTTP with `curl`, which must be on PATH. darwin only; available when an IDE with the plugin is running.
122
+ * Reads the IDEs the plugin registered under `runtimeDir`/`registryDir` ({@link HostStateOptions}).
123
+ *
124
+ * @experimental needs the companion JetBrains plugin, which is not published yet.
125
+ */
126
+ export function makeJetbrainsHost(opts) {
127
+ const curl = () => {
128
+ if (opts.runner !== undefined)
129
+ return opts.runner;
130
+ return which('curl') === null ? null : resolveRunner('curl');
131
+ };
132
+ /** One call: the HTTP status and parsed body, or null when curl could not connect. */
133
+ const call = (ide, path, body, timeoutSec) => {
134
+ const runner = curl();
135
+ if (runner === null)
136
+ return null;
137
+ const { argv, input } = curlRequest(ide, path, body, timeoutSec);
138
+ const res = runner.run(argv, {
139
+ cwd: process.cwd(),
140
+ timeoutMs: ((timeoutSec ?? 10) + 5) * 1000,
141
+ input,
142
+ });
143
+ if (res.exitCode !== 0)
144
+ return null;
145
+ const cut = res.stdout.lastIndexOf('\n');
146
+ const status = Number(res.stdout.slice(cut + 1));
147
+ if (cut < 0 || !Number.isInteger(status))
148
+ return null;
149
+ return { status, body: parseJsonOrNull(res.stdout.slice(0, cut)) };
150
+ };
151
+ /** The body of a 2xx reply, or why the call failed. */
152
+ const callChecked = (ide, path, body, timeoutSec) => {
153
+ const res = call(ide, path, body, timeoutSec);
154
+ if (res === null)
155
+ return { problem: `IDE pid ${ide.pid} did not answer on port ${ide.port}` };
156
+ if (res.status === 401) {
157
+ return { problem: `IDE pid ${ide.pid} rejected the token (stale registry file?)` };
158
+ }
159
+ if (res.status === 404) {
160
+ return { problem: `plugin endpoint ${path} missing in IDE pid ${ide.pid} (old plugin?)` };
161
+ }
162
+ if (res.status < 200 || res.status >= 300 || !isRecord(res.body)) {
163
+ const detail = isRecord(res.body) ? String(res.body['error'] ?? '') : '';
164
+ return { problem: `host call ${path} failed (HTTP ${res.status}) ${detail}`.trim() };
165
+ }
166
+ return { body: res.body };
167
+ };
168
+ /** Every registered IDE whose process is alive and which answers `ping`, and why the rest were skipped. */
169
+ const now = opts.now ?? Date.now;
170
+ let idesCache = null;
171
+ const runningIdes = () => {
172
+ if (idesCache !== null && now() - idesCache.at < IDES_TTL_MS)
173
+ return idesCache;
174
+ const dir = registryDirOf('jetbrains', opts);
175
+ const ides = [];
176
+ const problems = [];
177
+ const endpoints = readHostRegistry(dir, parseRegistryEntry, opts.pidAlive ?? pidAlive, {
178
+ onSkip: (reason) => problems.push(reason),
179
+ });
180
+ for (const endpoint of endpoints) {
181
+ if (endpoint.protocol !== JETBRAINS_PROTOCOL) {
182
+ problems.push(`IDE pid ${endpoint.pid} speaks plugin protocol ${endpoint.protocol ?? 'none'}, not ${JETBRAINS_PROTOCOL}`);
183
+ continue;
184
+ }
185
+ const pong = callChecked(endpoint, '/api/sdlc/ping', undefined, PING_TIMEOUT_SEC);
186
+ if ('problem' in pong) {
187
+ problems.push(pong.problem);
188
+ continue;
189
+ }
190
+ if (pong.body['protocol'] !== JETBRAINS_PROTOCOL) {
191
+ problems.push(`IDE pid ${endpoint.pid} answered ping with protocol ${String(pong.body['protocol'])}`);
192
+ continue;
193
+ }
194
+ if (pong.body['pid'] !== endpoint.pid) {
195
+ problems.push(`IDE pid ${endpoint.pid}'s registry file is stale: port ${endpoint.port} answered${pong.body['pid'] === undefined ? '' : ` as pid ${String(pong.body['pid'])}`}`);
196
+ continue;
197
+ }
198
+ const projects = Array.isArray(pong.body['projects']) ? pong.body['projects'] : [];
199
+ ides.push({
200
+ ...endpoint,
201
+ product: typeof pong.body['product'] === 'string' ? pong.body['product'] : '',
202
+ projects: projects.filter((p) => typeof p === 'string'),
203
+ });
204
+ }
205
+ idesCache = { at: now(), ides, problems };
206
+ return idesCache;
207
+ };
208
+ /** The IDE and project root of the deepest open project containing `cwd`, or null; every reachable IDE; why others were skipped. */
209
+ const resolveIde = (cwd) => {
210
+ const { ides, problems } = runningIdes();
211
+ const ide = deepestContaining(cwd, ides, (i) => i.projects, (i) => i.startedAt);
212
+ let root = null;
213
+ for (const base of ide?.projects ?? []) {
214
+ if (!isWithin(cwd, base))
215
+ continue;
216
+ const real = realResolve(base);
217
+ if (root === null || real.length > root.length)
218
+ root = real;
219
+ }
220
+ return { ide, root, ides, problems };
221
+ };
222
+ const noIde = (cwd, problems, why) => new Error(`no running JetBrains IDE with the companion plugin has a project containing ${cwd}. ${why}` +
223
+ (problems.length > 0 ? ` Skipped: ${problems.join('; ')}.` : ''));
224
+ /** Which reachable IDE should open a workspace: the only one, else the one `JetbrainsSettings.ide` names. */
225
+ const pickIde = (cwd, ides, problems) => {
226
+ if (ides.length === 0) {
227
+ throw noIde(cwd, problems, 'No IDE is reachable to open it in; start one with the companion plugin.');
228
+ }
229
+ if (ides.length === 1)
230
+ return ides[0];
231
+ const list = ides.map((i) => `${i.product || 'unknown'} (pid ${i.pid})`).join(', ');
232
+ const pref = opts.settings?.ide?.toLowerCase();
233
+ if (pref === undefined) {
234
+ throw noIde(cwd, problems, `Several IDEs are running (${list}); set JetbrainsSettings.ide (makeJetbrainsHost's settings, hostRegistry's jetbrains option) to the product name of the one to use.`);
235
+ }
236
+ // Exact, not substring: "IDEA" must not pick both IntelliJ IDEA and a hypothetical "IDEA Edu".
237
+ const hits = ides.filter((i) => i.product !== '' && i.product.toLowerCase() === pref);
238
+ if (hits.length !== 1) {
239
+ throw noIde(cwd, problems, `JetbrainsSettings.ide "${opts.settings?.ide}" names ${hits.length === 0 ? 'none' : 'more than one'} of the running IDEs (${list}); it must equal a product name, ignoring case.`);
240
+ }
241
+ return hits[0];
242
+ };
243
+ /**
244
+ * The folder to open for `cwd`: its git top-level, else `cwd` itself when
245
+ * git fails, `cwd` is outside a repository, or the top-level is the home
246
+ * directory or filesystem root (a dotfiles repo must not become the project).
247
+ */
248
+ const workspaceRoot = (cwd) => {
249
+ let top;
250
+ try {
251
+ top = (opts.gitRoot ?? ((c) => new Git(c).topLevel()))(cwd);
252
+ }
253
+ catch {
254
+ top = null;
255
+ }
256
+ if (top === null)
257
+ return cwd;
258
+ const real = realResolve(top);
259
+ return real === realResolve(homedir()) || real === parse(real).root ? cwd : top;
260
+ };
261
+ /** The IDE holding a project that contains `cwd`; the error names what to do when none does. */
262
+ const ideFor = (cwd) => {
263
+ const found = resolveIde(cwd);
264
+ if (found.ide !== null)
265
+ return found.ide;
266
+ throw noIde(cwd, found.problems, 'Open that folder as a project in the IDE, or launch with workspace opening on (`ensureWorkspace`).');
267
+ };
268
+ return {
269
+ name: 'jetbrains',
270
+ // Verified in the gradle runIde sandbox on macOS only (a platform that was not run is left out).
271
+ platforms: ['darwin'],
272
+ available() {
273
+ if (opts.available !== undefined)
274
+ return opts.available();
275
+ if (!supportsPlatform(this))
276
+ return false;
277
+ return runningIdes().ides.length > 0;
278
+ },
279
+ openWorkspace(query) {
280
+ const found = resolveIde(query.cwd);
281
+ if (found.ide !== null && found.root !== null)
282
+ return { root: found.root, opened: false };
283
+ const ide = pickIde(query.cwd, found.ides, found.problems);
284
+ const path = workspaceRoot(query.cwd);
285
+ query.notice?.(`opening ${path} in ${ide.product || 'the JetBrains IDE'}… (approve the trust prompt in the IDE if it asks; waiting up to a minute)`);
286
+ const res = callChecked(ide, '/api/sdlc/project/open', { path }, OPEN_PROJECT_TIMEOUT_SEC);
287
+ if ('problem' in res)
288
+ throw new Error(`JetBrains could not open ${path}: ${res.problem}`);
289
+ if (res.body['ok'] !== true) {
290
+ throw new Error(`JetBrains could not open ${path}: ${String(res.body['error'] ?? 'unknown error')}`);
291
+ }
292
+ idesCache = null; // the IDE now has one more project open
293
+ const project = res.body['project'];
294
+ return { root: typeof project === 'string' ? project : path, opened: true };
295
+ },
296
+ terminal(req) {
297
+ const ide = ideFor(req.cwd);
298
+ const res = callChecked(ide, '/api/sdlc/terminal/open', {
299
+ cwd: req.cwd,
300
+ title: req.title,
301
+ argv: req.argv,
302
+ });
303
+ if ('problem' in res)
304
+ throw new Error(`JetBrains terminal open failed for ${req.cwd}: ${res.problem}`);
305
+ if (res.body['ok'] !== true) {
306
+ const said = typeof res.body['error'] === 'string' ? `: ${res.body['error']}` : '';
307
+ throw new Error(`JetBrains terminal open failed for ${req.cwd}${said}`);
308
+ }
309
+ return 0;
310
+ },
311
+ session: sessionViaTerminal,
312
+ send(req) {
313
+ const { ide } = resolveIde(req.cwd);
314
+ if (ide === null)
315
+ return { accepted: false, ack: false };
316
+ const res = call(ide, '/api/sdlc/terminal/send', { cwd: req.cwd, text: req.text });
317
+ // 504: the UI thread began typing, so the text may land; retyping it would double it.
318
+ if (res?.status === 504)
319
+ return { accepted: true, ack: false, uncertain: true };
320
+ const ok = res !== null && res.status >= 200 && res.status < 300;
321
+ return { accepted: ok && isRecord(res.body) && res.body['accepted'] === true, ack: false };
322
+ },
323
+ find(query) {
324
+ const { ide } = resolveIde(query.cwd);
325
+ if (ide === null)
326
+ return [];
327
+ const res = callChecked(ide, '/api/sdlc/terminal/list', { cwd: query.cwd });
328
+ const terminals = 'body' in res ? res.body['terminals'] : undefined;
329
+ if (!Array.isArray(terminals))
330
+ return [];
331
+ const found = [];
332
+ for (const t of terminals) {
333
+ if (!isRecord(t))
334
+ continue;
335
+ found.push({
336
+ cwd: typeof t['cwd'] === 'string' ? t['cwd'] : query.cwd,
337
+ harness: null,
338
+ alive: t['alive'] === true,
339
+ lastOutputAt: null,
340
+ handle: typeof t['id'] === 'string' ? t['id'] : null,
341
+ });
342
+ }
343
+ return found;
344
+ },
345
+ focus(query) {
346
+ const { ide } = resolveIde(query.cwd);
347
+ if (ide === null)
348
+ return false;
349
+ const res = callChecked(ide, '/api/sdlc/terminal/focus', { cwd: query.cwd });
350
+ return 'body' in res && res.body['ok'] === true;
351
+ },
352
+ };
353
+ }
@@ -0,0 +1,67 @@
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 { type CommandRunner } from '@osdlc/command-seam';
16
+ import { type CommandRequest, type FindQuery, type FoundSession, type Host, type SendReceipt } from '../contract.ts';
17
+ import { type Harness } from '../harness.ts';
18
+ import { type HostStateOptions } from './registry.ts';
19
+ /** The CLI the app bundle ships when `orca` is not on PATH. */
20
+ export declare const ORCA_BUNDLED_CLI = "/Applications/Orca.app/Contents/Resources/bin/orca";
21
+ export declare function orcaBinary(): string | null;
22
+ /** Argv for `orca terminal create` — pure, so a test can pin it. */
23
+ export declare function orcaCreateArgv(req: CommandRequest): string[];
24
+ /** The new terminal's handle in `orca terminal create --json`: `result.terminal.handle`. */
25
+ export declare function parseOrcaCreatedHandle(stdout: string): string | null;
26
+ export declare function orcaSendArgv(handle: string, text: string): string[];
27
+ export declare function orcaSwitchArgv(handle: string): string[];
28
+ /** `orca terminal close --terminal <handle>`: closes that terminal and kills what runs in it. */
29
+ export declare function orcaCloseArgv(handle: string): string[];
30
+ /**
31
+ * Parses `orca terminal send --json`, which wraps its receipt as
32
+ * `{ok, result: {send: {accepted, prompt: {stages}}}}`. `accepted` alone only
33
+ * means the input reached the terminal; a `turn_started` stage confirms the
34
+ * agent took it. Unparseable output is `{accepted: false, ack: false}`.
35
+ */
36
+ export declare function parseOrcaSendResponse(stdout: string): SendReceipt;
37
+ /** True when Orca rejected a handle issued before its last restart. */
38
+ export declare function isOrcaHandleStale(stdout: string): boolean;
39
+ /** Argv for `orca terminal list --worktree path:<cwd> --json` — pure, so a test can pin it. */
40
+ export declare function orcaFindArgv(query: FindQuery): string[];
41
+ /**
42
+ * Parses `orca terminal list --json` into {@link FoundSession}s. An entry's
43
+ * `agentIdentity` is absent or null for a plain shell and a harness name
44
+ * otherwise, so `alive` means "an agent runs there". `lastOutputAt` is epoch
45
+ * milliseconds. Every entry gets the query's `cwd`, since `--worktree`
46
+ * already scoped the list. Unparseable output is `[]`.
47
+ */
48
+ export declare function parseOrcaFindResponse(stdout: string, cwd: string): FoundSession[];
49
+ /**
50
+ * The handle of the terminal for `cwd` to act on, or null. With a remembered handle
51
+ * (`bound`) that is that terminal if Orca still lists it live and writable, and nothing
52
+ * otherwise: another session's terminal at the same worktree is not this one's. Without
53
+ * one it is the one live, writable agent terminal on `cwd` (of `harness` when given), or
54
+ * null when there are none or several: delivering into the wrong session is worse than no
55
+ * delivery. Handles change when Orca restarts, so resolve one right before using it.
56
+ */
57
+ export declare function resolveOrcaHandle(runner: CommandRunner, cwd: string, bound?: string | null, harness?: Harness): string | null;
58
+ /**
59
+ * The Orca host: opens terminal tabs in Orca through its CLI, found as `orca` on PATH or the app's bundled CLI.
60
+ * darwin only. Remembers the terminal handle of each session under `runtimeDir`/`registryDir` ({@link HostStateOptions}).
61
+ */
62
+ export declare function makeOrcaHost(opts: {
63
+ binary?: string | null | undefined;
64
+ runner?: CommandRunner | undefined;
65
+ /** Registered on an unknown-worktree miss, then the create is retried once. */
66
+ repoRoot?: string | undefined;
67
+ } & HostStateOptions): Host;