@osdlc/agent-seat 0.0.0-stage → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -0
- package/CONTRIBUTING.md +44 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +181 -2
- package/SECURITY.md +41 -0
- package/dist/contract.d.ts +297 -0
- package/dist/contract.js +102 -0
- package/dist/harness.d.ts +305 -0
- package/dist/harness.js +610 -0
- package/dist/hosts/cmux.d.ts +67 -0
- package/dist/hosts/cmux.js +252 -0
- package/dist/hosts/herdr.d.ts +78 -0
- package/dist/hosts/herdr.js +324 -0
- package/dist/hosts/jetbrains.d.ts +98 -0
- package/dist/hosts/jetbrains.js +353 -0
- package/dist/hosts/orca.d.ts +67 -0
- package/dist/hosts/orca.js +292 -0
- package/dist/hosts/otty.d.ts +70 -0
- package/dist/hosts/otty.js +233 -0
- package/dist/hosts/registry.d.ts +50 -0
- package/dist/hosts/registry.js +100 -0
- package/dist/hosts/session-panes.d.ts +29 -0
- package/dist/hosts/session-panes.js +49 -0
- package/dist/hosts/terminal.d.ts +23 -0
- package/dist/hosts/terminal.js +47 -0
- package/dist/hosts/tmux.d.ts +145 -0
- package/dist/hosts/tmux.js +409 -0
- package/dist/hosts/vscode-client.d.ts +81 -0
- package/dist/hosts/vscode-client.js +124 -0
- package/dist/hosts/vscode.d.ts +35 -0
- package/dist/hosts/vscode.js +127 -0
- package/dist/hosts/wezterm.d.ts +78 -0
- package/dist/hosts/wezterm.js +405 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +61 -0
- package/dist/open-workspace.d.ts +9 -0
- package/dist/open-workspace.js +12 -0
- package/dist/util.d.ts +50 -0
- package/dist/util.js +108 -0
- package/package.json +76 -3
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VS Code, Cursor and Windsurf through the companion extension (`sdlc-host`;
|
|
3
|
+
* design in `docs/planning/research/vscode.md`). Each window runs a private
|
|
4
|
+
* socket server and registers it; this host picks the window whose workspace
|
|
5
|
+
* folder contains `cwd`.
|
|
6
|
+
*
|
|
7
|
+
* A window is never opened unasked: when none has `cwd`, `terminal`/`session`
|
|
8
|
+
* throw telling the person to open it. `send` cannot know whether the input
|
|
9
|
+
* was consumed (`sendText` reports nothing), so a receipt is `accepted` and
|
|
10
|
+
* never `ack`. There is no `capture` part: the published extension API has no
|
|
11
|
+
* read-back for arbitrary terminals.
|
|
12
|
+
*
|
|
13
|
+
* `open` carries the caller's session id and the extension binds the terminal to it;
|
|
14
|
+
* `send`, `focus`, `find` and `close` pass it back, so two sessions in one folder
|
|
15
|
+
* reach two terminals. The extension answers a request for a session id no terminal
|
|
16
|
+
* is bound to from terminals bound to none (opened before ids were passed), never
|
|
17
|
+
* another session's.
|
|
18
|
+
*/
|
|
19
|
+
import { isWithin } from "../util.js";
|
|
20
|
+
import { sessionViaTerminal } from "../harness.js";
|
|
21
|
+
import { registryDirOf } from "./registry.js";
|
|
22
|
+
import { liveWindows, PING_TIMEOUT_MS, subprocessTransport, windowForCwd, } from "./vscode-client.js";
|
|
23
|
+
/** The request field naming the session, when the caller has one. */
|
|
24
|
+
function sessionField(target) {
|
|
25
|
+
return target.sessionId === undefined ? {} : { sessionId: target.sessionId };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The VS Code host (also Cursor and Windsurf): opens terminals through the companion extension (`sdlc-host`)
|
|
29
|
+
* running in the window that has `cwd` open. All platforms; available when a registered window answers a ping.
|
|
30
|
+
* Reads the windows the extension registered under `runtimeDir`/`registryDir` ({@link HostStateOptions}).
|
|
31
|
+
*
|
|
32
|
+
* @experimental needs the `sdlc-host` companion extension, which is not published yet.
|
|
33
|
+
*/
|
|
34
|
+
export function makeVscodeHost(opts) {
|
|
35
|
+
const registryDir = registryDirOf('vscode', opts);
|
|
36
|
+
const windows = () => (opts.windows ?? (() => liveWindows(registryDir)))();
|
|
37
|
+
let defaultTransport;
|
|
38
|
+
const transport = () => opts.transport ?? (defaultTransport ??= subprocessTransport());
|
|
39
|
+
return {
|
|
40
|
+
name: 'vscode',
|
|
41
|
+
platforms: 'all',
|
|
42
|
+
available() {
|
|
43
|
+
if (opts.available !== undefined)
|
|
44
|
+
return opts.available();
|
|
45
|
+
const send = transport();
|
|
46
|
+
// The transport is synchronous, so windows are pinged one after another, each on a short limit.
|
|
47
|
+
return windows().some((w) => send(w, { op: 'ping' }, PING_TIMEOUT_MS).ok);
|
|
48
|
+
},
|
|
49
|
+
terminal(req) {
|
|
50
|
+
const w = windowForCwd(windows(), req.cwd);
|
|
51
|
+
if (w === null) {
|
|
52
|
+
throw new Error(`no VS Code window has ${req.cwd} open — open it, with the sdlc-host extension installed`);
|
|
53
|
+
}
|
|
54
|
+
const reply = transport()(w, {
|
|
55
|
+
op: 'open',
|
|
56
|
+
cwd: req.cwd,
|
|
57
|
+
title: req.title,
|
|
58
|
+
argv: req.argv,
|
|
59
|
+
...(req.sessionId === undefined ? {} : { sessionId: req.sessionId }),
|
|
60
|
+
});
|
|
61
|
+
if (!reply.ok)
|
|
62
|
+
throw new Error(`vscode window (${w.app}, pid ${w.pid}) refused open: ${reply.error}`);
|
|
63
|
+
return 0;
|
|
64
|
+
},
|
|
65
|
+
session: sessionViaTerminal,
|
|
66
|
+
send(req) {
|
|
67
|
+
const w = windowForCwd(windows(), req.cwd);
|
|
68
|
+
if (w === null)
|
|
69
|
+
return { accepted: false, ack: false };
|
|
70
|
+
const reply = transport()(w, {
|
|
71
|
+
op: 'send',
|
|
72
|
+
cwd: req.cwd,
|
|
73
|
+
text: req.text,
|
|
74
|
+
...sessionField(req),
|
|
75
|
+
});
|
|
76
|
+
return { accepted: reply.ok && reply.result['accepted'] === true, ack: false };
|
|
77
|
+
},
|
|
78
|
+
find(query) {
|
|
79
|
+
const w = windowForCwd(windows(), query.cwd);
|
|
80
|
+
if (w === null)
|
|
81
|
+
return [];
|
|
82
|
+
const reply = transport()(w, { op: 'list' });
|
|
83
|
+
if (!reply.ok || !Array.isArray(reply.result['terminals']))
|
|
84
|
+
return [];
|
|
85
|
+
const found = [];
|
|
86
|
+
const ofSession = [];
|
|
87
|
+
for (const t of reply.result['terminals']) {
|
|
88
|
+
if (typeof t !== 'object' || t === null)
|
|
89
|
+
continue;
|
|
90
|
+
const { id, cwd, sessionId } = t;
|
|
91
|
+
if (typeof id !== 'string' || typeof cwd !== 'string' || !isWithin(cwd, query.cwd))
|
|
92
|
+
continue;
|
|
93
|
+
const bound = typeof sessionId === 'string' ? sessionId : null;
|
|
94
|
+
// Bound to another session: not the one asked about.
|
|
95
|
+
if (bound !== null && query.sessionId !== undefined && bound !== query.sessionId)
|
|
96
|
+
continue;
|
|
97
|
+
// `alive` is "a terminal is open there"; the API cannot tell an agent from a shell.
|
|
98
|
+
const session = {
|
|
99
|
+
cwd: query.cwd,
|
|
100
|
+
harness: null,
|
|
101
|
+
alive: true,
|
|
102
|
+
lastOutputAt: null,
|
|
103
|
+
handle: id,
|
|
104
|
+
};
|
|
105
|
+
found.push(session);
|
|
106
|
+
if (bound !== null)
|
|
107
|
+
ofSession.push(session);
|
|
108
|
+
}
|
|
109
|
+
// The session's own terminal when there is one; else (opened before ids were passed) those at the cwd.
|
|
110
|
+
return query.sessionId !== undefined && ofSession.length > 0 ? ofSession : found;
|
|
111
|
+
},
|
|
112
|
+
focus(query) {
|
|
113
|
+
const w = windowForCwd(windows(), query.cwd);
|
|
114
|
+
if (w === null)
|
|
115
|
+
return false;
|
|
116
|
+
const reply = transport()(w, { op: 'focus', cwd: query.cwd, ...sessionField(query) });
|
|
117
|
+
return reply.ok && reply.result['focused'] === true;
|
|
118
|
+
},
|
|
119
|
+
close(query) {
|
|
120
|
+
const w = windowForCwd(windows(), query.cwd);
|
|
121
|
+
if (w === null)
|
|
122
|
+
return false;
|
|
123
|
+
const reply = transport()(w, { op: 'close', cwd: query.cwd, ...sessionField(query) });
|
|
124
|
+
return reply.ok && reply.result['closed'] === true;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WezTerm: panes driven through `wezterm cli`, which talks to a running
|
|
3
|
+
* GUI or mux server. Findings are docs-only (WezTerm was not installed when
|
|
4
|
+
* this was written) — see `docs/planning/research/wezterm.md`.
|
|
5
|
+
*
|
|
6
|
+
* Panes are addressed by the numeric pane id. A session launched with a
|
|
7
|
+
* caller session id has its pane id remembered (`./session-panes.ts`), and every part
|
|
8
|
+
* resolves that pane first; otherwise (a request without an id, or a pane
|
|
9
|
+
* opened before ids were remembered) `find` maps a cwd to panes via
|
|
10
|
+
* `cli list --format json` and every other part resolves its pane through that
|
|
11
|
+
* lookup at call time. Every argv is passed directly (no shell string),
|
|
12
|
+
* so it behaves the same on Windows. When no GUI/mux is running the cli
|
|
13
|
+
* commands fail; that reads as "no session",
|
|
14
|
+
* except `terminal`/`session`, which throw because they cannot create one.
|
|
15
|
+
*/
|
|
16
|
+
import { type CommandRunner } from '@osdlc/command-seam';
|
|
17
|
+
import type { CommandRequest, Host } from '../contract.ts';
|
|
18
|
+
import { type HostStateOptions } from './registry.ts';
|
|
19
|
+
/** Argv for `wezterm cli spawn`: a new tab running `req.argv` in `req.cwd`. `--` guards the program's own flags. */
|
|
20
|
+
export declare function weztermSpawnArgv(req: CommandRequest, newWindow?: boolean): string[];
|
|
21
|
+
export declare function weztermListArgv(): string[];
|
|
22
|
+
/**
|
|
23
|
+
* `send-text` argv. With `noPaste` the bytes go as typed (used for the Enter);
|
|
24
|
+
* without it the text is a bracketed paste, so embedded newlines stay in the
|
|
25
|
+
* input instead of submitting. Pasted text is not on argv: omitted TEXT is read
|
|
26
|
+
* from stdin, which also keeps a leading `-` from parsing as a flag (the docs
|
|
27
|
+
* show no `--` separator).
|
|
28
|
+
*/
|
|
29
|
+
export declare function weztermSendTextArgv(paneId: number, noPaste: boolean, text?: string): string[];
|
|
30
|
+
/** The pane id `cli spawn` prints on stdout, or null. */
|
|
31
|
+
export declare function parseSpawnedPane(stdout: string): number | null;
|
|
32
|
+
export declare function weztermActivateArgv(paneId: number): string[];
|
|
33
|
+
export declare function weztermGetTextArgv(paneId: number): string[];
|
|
34
|
+
/** `wezterm cli kill-pane`: kills the pane and what runs in it, without asking. */
|
|
35
|
+
export declare function weztermKillPaneArgv(paneId: number): string[];
|
|
36
|
+
/**
|
|
37
|
+
* A path in comparable form: forward slashes, no trailing slash, and
|
|
38
|
+
* case-folded on Windows (case-insensitive file systems).
|
|
39
|
+
*/
|
|
40
|
+
export declare function normalizePath(p: string, platform?: NodeJS.Platform): string;
|
|
41
|
+
/**
|
|
42
|
+
* The local path inside a pane's `cwd` field, a `file://` URL
|
|
43
|
+
* (`file://foo/home/wez/`: host `foo`, path `/home/wez/`). On POSIX the host
|
|
44
|
+
* must be empty, `localhost` or this machine's hostname, else null (a remote
|
|
45
|
+
* pane). Percent-encoding is decoded (a malformed escape gives null); on Windows `file:///C:/Users/x` becomes `C:/Users/x` (drive letter
|
|
46
|
+
* kept, leading slash dropped) and `file://server/share/x` a UNC path.
|
|
47
|
+
* Null when the value is not a file URL.
|
|
48
|
+
*/
|
|
49
|
+
export declare function weztermCwdPath(url: string, platform?: NodeJS.Platform, localHost?: string): string | null;
|
|
50
|
+
interface WeztermPane {
|
|
51
|
+
paneId: number;
|
|
52
|
+
cwd: string;
|
|
53
|
+
/** The pane's `tty_name` from `cli list`, when it reports one. */
|
|
54
|
+
ttyName?: string;
|
|
55
|
+
}
|
|
56
|
+
interface WeztermRow {
|
|
57
|
+
paneId: number;
|
|
58
|
+
/** The local path of the pane's cwd URL; null for a remote pane or an unparseable one. */
|
|
59
|
+
path: string | null;
|
|
60
|
+
ttyName?: string;
|
|
61
|
+
}
|
|
62
|
+
/** Every pane in `cli list --format json`; unparseable output yields []. */
|
|
63
|
+
export declare function parseWeztermRows(stdout: string, platform?: NodeJS.Platform, localHost?: string): WeztermRow[];
|
|
64
|
+
/** Panes from `cli list --format json` whose cwd is `cwd`; unparseable output yields []. */
|
|
65
|
+
export declare function parseWeztermList(stdout: string, cwd: string, platform?: NodeJS.Platform, localHost?: string): WeztermPane[];
|
|
66
|
+
/**
|
|
67
|
+
* The WezTerm host: drives panes through `wezterm cli` (`wezterm.exe` on Windows), with `wezterm` on PATH
|
|
68
|
+
* and a running GUI or mux server. All platforms. Remembers the pane id of each session under `runtimeDir`/`registryDir` ({@link HostStateOptions}).
|
|
69
|
+
*/
|
|
70
|
+
export declare function makeWeztermHost(opts: {
|
|
71
|
+
runner?: CommandRunner | undefined;
|
|
72
|
+
available?: (() => boolean) | undefined;
|
|
73
|
+
/** Defaults to `process.platform`; decides cwd-URL handling and the binary name. */
|
|
74
|
+
platform?: NodeJS.Platform | undefined;
|
|
75
|
+
/** This machine's hostname for file-URL cwds (default `os.hostname()`). */
|
|
76
|
+
localHost?: string | undefined;
|
|
77
|
+
} & HostStateOptions): Host;
|
|
78
|
+
export {};
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WezTerm: panes driven through `wezterm cli`, which talks to a running
|
|
3
|
+
* GUI or mux server. Findings are docs-only (WezTerm was not installed when
|
|
4
|
+
* this was written) — see `docs/planning/research/wezterm.md`.
|
|
5
|
+
*
|
|
6
|
+
* Panes are addressed by the numeric pane id. A session launched with a
|
|
7
|
+
* caller session id has its pane id remembered (`./session-panes.ts`), and every part
|
|
8
|
+
* resolves that pane first; otherwise (a request without an id, or a pane
|
|
9
|
+
* opened before ids were remembered) `find` maps a cwd to panes via
|
|
10
|
+
* `cli list --format json` and every other part resolves its pane through that
|
|
11
|
+
* lookup at call time. Every argv is passed directly (no shell string),
|
|
12
|
+
* so it behaves the same on Windows. When no GUI/mux is running the cli
|
|
13
|
+
* commands fail; that reads as "no session",
|
|
14
|
+
* except `terminal`/`session`, which throw because they cannot create one.
|
|
15
|
+
*/
|
|
16
|
+
import { readdirSync, rmSync, statSync } from 'node:fs';
|
|
17
|
+
import { hostname } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { failureText, ran, resolveRunner } from '@osdlc/command-seam';
|
|
20
|
+
import { ensureDir, isRecord, parseJsonOrNull, readJsonOrNull, writeJsonAtomic } from "../util.js";
|
|
21
|
+
import { onPath, quietRun, supportsPlatform } from "../contract.js";
|
|
22
|
+
import { sessionViaTerminal } from "../harness.js";
|
|
23
|
+
import { registryDirOf } from "./registry.js";
|
|
24
|
+
import { boundPane, recallSessionPane, rememberSessionPane } from "./session-panes.js";
|
|
25
|
+
/** Argv for `wezterm cli spawn`: a new tab running `req.argv` in `req.cwd`. `--` guards the program's own flags. */
|
|
26
|
+
export function weztermSpawnArgv(req, newWindow = false) {
|
|
27
|
+
return [
|
|
28
|
+
'cli',
|
|
29
|
+
'spawn',
|
|
30
|
+
'--cwd',
|
|
31
|
+
req.cwd,
|
|
32
|
+
...(newWindow ? ['--new-window'] : []),
|
|
33
|
+
'--',
|
|
34
|
+
...req.argv,
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
export function weztermListArgv() {
|
|
38
|
+
return ['cli', 'list', '--format', 'json'];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* `send-text` argv. With `noPaste` the bytes go as typed (used for the Enter);
|
|
42
|
+
* without it the text is a bracketed paste, so embedded newlines stay in the
|
|
43
|
+
* input instead of submitting. Pasted text is not on argv: omitted TEXT is read
|
|
44
|
+
* from stdin, which also keeps a leading `-` from parsing as a flag (the docs
|
|
45
|
+
* show no `--` separator).
|
|
46
|
+
*/
|
|
47
|
+
export function weztermSendTextArgv(paneId, noPaste, text) {
|
|
48
|
+
return [
|
|
49
|
+
'cli',
|
|
50
|
+
'send-text',
|
|
51
|
+
'--pane-id',
|
|
52
|
+
String(paneId),
|
|
53
|
+
...(noPaste ? ['--no-paste'] : []),
|
|
54
|
+
...(text !== undefined ? [text] : []),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
/** The pane id `cli spawn` prints on stdout, or null. */
|
|
58
|
+
export function parseSpawnedPane(stdout) {
|
|
59
|
+
const id = Number(stdout.trim());
|
|
60
|
+
return Number.isInteger(id) && id >= 0 && stdout.trim() !== '' ? id : null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* `<dir>/spawned/<paneId>.json`: one file per pane this host spawned, so two concurrent spawns
|
|
64
|
+
* never overwrite each other's entry.
|
|
65
|
+
*/
|
|
66
|
+
function spawnedDir(dir) {
|
|
67
|
+
return join(dir, 'spawned');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The files of panes this host spawned: the only panes `send` types into. Pane ids restart
|
|
71
|
+
* from 0 when the GUI/mux restarts, so an id alone proves nothing; the tty recorded at spawn
|
|
72
|
+
* is the identity (see {@link liveSpawned}).
|
|
73
|
+
*/
|
|
74
|
+
function readSpawnedFiles(dir) {
|
|
75
|
+
let names;
|
|
76
|
+
try {
|
|
77
|
+
names = readdirSync(spawnedDir(dir));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
if (!name.endsWith('.json'))
|
|
85
|
+
continue;
|
|
86
|
+
const path = join(spawnedDir(dir), name);
|
|
87
|
+
let mtimeMs;
|
|
88
|
+
try {
|
|
89
|
+
mtimeMs = statSync(path).mtimeMs;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const data = readJsonOrNull(path);
|
|
95
|
+
const { paneId, ttyName } = isRecord(data) ? data : {};
|
|
96
|
+
const pane = Number.isInteger(paneId) && typeof ttyName === 'string' && ttyName !== ''
|
|
97
|
+
? { paneId: paneId, ttyName }
|
|
98
|
+
: null;
|
|
99
|
+
out.push({ pane, path, mtimeMs });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
/** Records `pane` in its own file. A pane that cannot be remembered is simply never sent to. */
|
|
104
|
+
function rememberSpawned(dir, pane) {
|
|
105
|
+
try {
|
|
106
|
+
ensureDir(spawnedDir(dir));
|
|
107
|
+
writeJsonAtomic(join(spawnedDir(dir), `${pane.paneId}.json`), pane);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Not remembered: never sent to.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Whether `live` still shows `pane` on the tty recorded when it was spawned. */
|
|
114
|
+
function stillSpawned(live, pane) {
|
|
115
|
+
return live.some((r) => r.paneId === pane.paneId && r.ttyName === pane.ttyName);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* How long a file written before a listing is spared from pruning by it: covers coarse file
|
|
119
|
+
* time stamps, so a pane a concurrent `terminal()` recorded after the listing is never pruned.
|
|
120
|
+
*/
|
|
121
|
+
const PRUNE_GRACE_MS = 5_000;
|
|
122
|
+
/**
|
|
123
|
+
* The spawned panes `live` (a `cli list` taken at `listedAt`, epoch ms) still shows on their
|
|
124
|
+
* recorded tty. Every other file is deleted (ids are reused after a mux restart and the reused
|
|
125
|
+
* id may be a plain shell), unless it was written after the listing began, less the grace.
|
|
126
|
+
*/
|
|
127
|
+
function liveSpawned(dir, live, listedAt) {
|
|
128
|
+
const kept = [];
|
|
129
|
+
for (const f of readSpawnedFiles(dir)) {
|
|
130
|
+
if (f.pane !== null && stillSpawned(live, f.pane))
|
|
131
|
+
kept.push(f.pane);
|
|
132
|
+
else if (f.mtimeMs < listedAt - PRUNE_GRACE_MS) {
|
|
133
|
+
try {
|
|
134
|
+
rmSync(f.path, { force: true });
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Left for a later listing.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return kept;
|
|
142
|
+
}
|
|
143
|
+
export function weztermActivateArgv(paneId) {
|
|
144
|
+
return ['cli', 'activate-pane', '--pane-id', String(paneId)];
|
|
145
|
+
}
|
|
146
|
+
export function weztermGetTextArgv(paneId) {
|
|
147
|
+
return ['cli', 'get-text', '--pane-id', String(paneId)];
|
|
148
|
+
}
|
|
149
|
+
/** `wezterm cli kill-pane`: kills the pane and what runs in it, without asking. */
|
|
150
|
+
export function weztermKillPaneArgv(paneId) {
|
|
151
|
+
return ['cli', 'kill-pane', '--pane-id', String(paneId)];
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A path in comparable form: forward slashes, no trailing slash, and
|
|
155
|
+
* case-folded on Windows (case-insensitive file systems).
|
|
156
|
+
*/
|
|
157
|
+
export function normalizePath(p, platform = process.platform) {
|
|
158
|
+
let out = p.replace(/\\/g, '/');
|
|
159
|
+
if (out.length > 1)
|
|
160
|
+
out = out.replace(/\/+$/, '');
|
|
161
|
+
return platform === 'win32' ? out.toLowerCase() : out;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The local path inside a pane's `cwd` field, a `file://` URL
|
|
165
|
+
* (`file://foo/home/wez/`: host `foo`, path `/home/wez/`). On POSIX the host
|
|
166
|
+
* must be empty, `localhost` or this machine's hostname, else null (a remote
|
|
167
|
+
* pane). Percent-encoding is decoded (a malformed escape gives null); on Windows `file:///C:/Users/x` becomes `C:/Users/x` (drive letter
|
|
168
|
+
* kept, leading slash dropped) and `file://server/share/x` a UNC path.
|
|
169
|
+
* Null when the value is not a file URL.
|
|
170
|
+
*/
|
|
171
|
+
export function weztermCwdPath(url, platform = process.platform, localHost = hostname()) {
|
|
172
|
+
let u;
|
|
173
|
+
try {
|
|
174
|
+
u = new URL(url);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
if (u.protocol !== 'file:')
|
|
180
|
+
return null;
|
|
181
|
+
let path;
|
|
182
|
+
try {
|
|
183
|
+
path = decodeURIComponent(u.pathname);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
if (platform !== 'win32') {
|
|
189
|
+
// OSC 7 from an ssh'd pane names the remote machine; its path is not ours.
|
|
190
|
+
const host = u.hostname.toLowerCase();
|
|
191
|
+
const local = localHost.toLowerCase();
|
|
192
|
+
if (host !== '' && host !== 'localhost' && host !== local && host !== local.split('.')[0]) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
if (/^\/[A-Za-z]:/.test(path))
|
|
198
|
+
path = path.slice(1);
|
|
199
|
+
else if (u.hostname !== '' && u.hostname !== 'localhost')
|
|
200
|
+
path = `//${u.hostname}${path}`;
|
|
201
|
+
}
|
|
202
|
+
return path;
|
|
203
|
+
}
|
|
204
|
+
/** Every pane in `cli list --format json`; unparseable output yields []. */
|
|
205
|
+
export function parseWeztermRows(stdout, platform = process.platform, localHost = hostname()) {
|
|
206
|
+
const data = parseJsonOrNull(stdout);
|
|
207
|
+
if (!Array.isArray(data))
|
|
208
|
+
return [];
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const row of data) {
|
|
211
|
+
if (typeof row !== 'object' || row === null)
|
|
212
|
+
continue;
|
|
213
|
+
const { pane_id: paneId, cwd: url, tty_name: tty, } = row;
|
|
214
|
+
if (typeof paneId !== 'number' || typeof url !== 'string')
|
|
215
|
+
continue;
|
|
216
|
+
out.push({
|
|
217
|
+
paneId,
|
|
218
|
+
path: weztermCwdPath(url, platform, localHost),
|
|
219
|
+
...(typeof tty === 'string' && tty !== '' ? { ttyName: tty } : {}),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
/** The panes among `rows` whose cwd is `cwd`. */
|
|
225
|
+
function panesIn(rows, cwd, platform) {
|
|
226
|
+
const want = normalizePath(cwd, platform);
|
|
227
|
+
return rows
|
|
228
|
+
.filter((r) => r.path !== null && normalizePath(r.path, platform) === want)
|
|
229
|
+
.map((r) => ({
|
|
230
|
+
paneId: r.paneId,
|
|
231
|
+
cwd,
|
|
232
|
+
...(r.ttyName !== undefined ? { ttyName: r.ttyName } : {}),
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
/** Panes from `cli list --format json` whose cwd is `cwd`; unparseable output yields []. */
|
|
236
|
+
export function parseWeztermList(stdout, cwd, platform = process.platform, localHost = hostname()) {
|
|
237
|
+
return panesIn(parseWeztermRows(stdout, platform, localHost), cwd, platform);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The WezTerm host: drives panes through `wezterm cli` (`wezterm.exe` on Windows), with `wezterm` on PATH
|
|
241
|
+
* and a running GUI or mux server. All platforms. Remembers the pane id of each session under `runtimeDir`/`registryDir` ({@link HostStateOptions}).
|
|
242
|
+
*/
|
|
243
|
+
export function makeWeztermHost(opts) {
|
|
244
|
+
const platform = opts.platform ?? process.platform;
|
|
245
|
+
const runner = () => resolveRunner(platform === 'win32' ? 'wezterm.exe' : 'wezterm', opts.runner);
|
|
246
|
+
const registryDir = registryDirOf('wezterm', opts);
|
|
247
|
+
/** `cli list` output, or null when no GUI/mux answers. */
|
|
248
|
+
const listOutput = (cwd) => quietRun(runner(), weztermListArgv(), { cwd, timeoutMs: 10_000 });
|
|
249
|
+
/** A `cli list`: its rows and when it was asked for (epoch ms); null when no GUI/mux answers. */
|
|
250
|
+
const listing = (cwd) => {
|
|
251
|
+
const at = Date.now();
|
|
252
|
+
const out = listOutput(cwd);
|
|
253
|
+
return out === null ? null : { rows: parseWeztermRows(out, platform, opts.localHost), at };
|
|
254
|
+
};
|
|
255
|
+
function panesAt(cwd) {
|
|
256
|
+
const out = listOutput(cwd);
|
|
257
|
+
return out === null ? [] : parseWeztermList(out, cwd, platform, opts.localHost);
|
|
258
|
+
}
|
|
259
|
+
/** Whether the listing shows pane `id` on the tty this host recorded when it spawned it. */
|
|
260
|
+
const spawnedAndLive = (list, id) => liveSpawned(registryDir, list.rows, list.at).some((e) => e.paneId === id);
|
|
261
|
+
/**
|
|
262
|
+
* The pane for `target`. A session id with a remembered pane resolves to that pane if it is
|
|
263
|
+
* still listed on the tty recorded at spawn (wherever its cwd has moved to) and to nothing
|
|
264
|
+
* otherwise: falling back to the cwd would reach another session's pane. Without a remembered
|
|
265
|
+
* pane it is the pane this host spawned at `cwd`, or undefined; `any` falls back to the first
|
|
266
|
+
* pane there. One `cli list`. `remembered` is the bound pane when the caller already read it.
|
|
267
|
+
*/
|
|
268
|
+
const paneFor = (target, any, remembered = boundPane(registryDir, target)) => {
|
|
269
|
+
if (remembered !== null) {
|
|
270
|
+
const list = listing(target.cwd);
|
|
271
|
+
const id = Number(remembered);
|
|
272
|
+
return list !== null && spawnedAndLive(list, id) ? { paneId: id, cwd: target.cwd } : undefined;
|
|
273
|
+
}
|
|
274
|
+
return paneAt(target.cwd, any);
|
|
275
|
+
};
|
|
276
|
+
/**
|
|
277
|
+
* The pane this host spawned at `cwd`, or undefined; `any` falls back to the
|
|
278
|
+
* first pane there. One `cli list`. A registry entry counts only while the
|
|
279
|
+
* live list still shows that pane id on the tty recorded at spawn, since ids
|
|
280
|
+
* are reused after a mux restart and the reused id may be a plain shell;
|
|
281
|
+
* {@link liveSpawned} prunes the stale entries.
|
|
282
|
+
*/
|
|
283
|
+
const paneAt = (cwd, any) => {
|
|
284
|
+
const list = listing(cwd);
|
|
285
|
+
if (list === null)
|
|
286
|
+
return undefined;
|
|
287
|
+
const panes = panesIn(list.rows, cwd, platform);
|
|
288
|
+
const spawned = new Set(liveSpawned(registryDir, list.rows, list.at).map((e) => e.paneId));
|
|
289
|
+
return panes.find((p) => spawned.has(p.paneId)) ?? (any ? panes[0] : undefined);
|
|
290
|
+
};
|
|
291
|
+
const kill = (paneId, cwd) => quietRun(runner(), weztermKillPaneArgv(paneId), { cwd, timeoutMs: 10_000 }) !== null;
|
|
292
|
+
return {
|
|
293
|
+
name: 'wezterm',
|
|
294
|
+
platforms: 'all',
|
|
295
|
+
available() {
|
|
296
|
+
if (opts.available !== undefined)
|
|
297
|
+
return opts.available();
|
|
298
|
+
return supportsPlatform(this) && onPath('wezterm');
|
|
299
|
+
},
|
|
300
|
+
terminal(req) {
|
|
301
|
+
const res = runner().run(weztermSpawnArgv(req), { cwd: req.cwd, timeoutMs: 60_000 });
|
|
302
|
+
if (!ran(res)) {
|
|
303
|
+
throw new Error(`wezterm cli spawn failed (is a WezTerm window or mux server running?): ${failureText(res)}`);
|
|
304
|
+
}
|
|
305
|
+
// Remember the pane with its tty: `send` types only into panes spawned here
|
|
306
|
+
// (still on that tty), never a plain shell. Without a tty it is not remembered.
|
|
307
|
+
const paneId = parseSpawnedPane(res.stdout);
|
|
308
|
+
if (paneId !== null) {
|
|
309
|
+
const list = listing(req.cwd);
|
|
310
|
+
const ttyName = list?.rows.find((r) => r.paneId === paneId)?.ttyName;
|
|
311
|
+
if (ttyName !== undefined)
|
|
312
|
+
rememberSpawned(registryDir, { paneId, ttyName });
|
|
313
|
+
if (list !== null)
|
|
314
|
+
liveSpawned(registryDir, list.rows, list.at);
|
|
315
|
+
if (req.sessionId !== undefined) {
|
|
316
|
+
rememberSessionPane(registryDir, req.sessionId, String(paneId), req.cwd);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return 0;
|
|
320
|
+
},
|
|
321
|
+
session: sessionViaTerminal,
|
|
322
|
+
send(req) {
|
|
323
|
+
// No delivery-confirmation signal exists, so ack is always false. The text
|
|
324
|
+
// is a bracketed paste (embedded newlines do not submit) and Enter is a
|
|
325
|
+
// separate `--no-paste` call, as if the key were pressed. Only a pane this
|
|
326
|
+
// host spawned receives text: a plain shell would execute it.
|
|
327
|
+
const pane = paneFor(req, false);
|
|
328
|
+
if (pane === undefined)
|
|
329
|
+
return { accepted: false, ack: false };
|
|
330
|
+
try {
|
|
331
|
+
const r = runner();
|
|
332
|
+
const text = r.run(weztermSendTextArgv(pane.paneId, false), {
|
|
333
|
+
cwd: req.cwd,
|
|
334
|
+
timeoutMs: 15_000,
|
|
335
|
+
input: req.text,
|
|
336
|
+
});
|
|
337
|
+
if (!ran(text))
|
|
338
|
+
return { accepted: false, ack: false };
|
|
339
|
+
const enter = r.run(weztermSendTextArgv(pane.paneId, true, '\r'), {
|
|
340
|
+
cwd: req.cwd,
|
|
341
|
+
timeoutMs: 15_000,
|
|
342
|
+
});
|
|
343
|
+
// The text is in the pane even if Enter failed; do not retype it.
|
|
344
|
+
return ran(enter)
|
|
345
|
+
? { accepted: true, ack: false }
|
|
346
|
+
: { accepted: true, ack: false, uncertain: true };
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return { accepted: false, ack: false };
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
find(query) {
|
|
353
|
+
// The harness is not discoverable and `cli list` has no output-recency field.
|
|
354
|
+
const remembered = boundPane(registryDir, query);
|
|
355
|
+
const one = remembered === null ? undefined : paneFor(query, true, remembered);
|
|
356
|
+
const panes = remembered === null ? panesAt(query.cwd) : one === undefined ? [] : [one];
|
|
357
|
+
return panes.map((p) => ({
|
|
358
|
+
cwd: query.cwd,
|
|
359
|
+
harness: null,
|
|
360
|
+
alive: true,
|
|
361
|
+
lastOutputAt: null,
|
|
362
|
+
handle: String(p.paneId),
|
|
363
|
+
}));
|
|
364
|
+
},
|
|
365
|
+
focus(query) {
|
|
366
|
+
// activate-pane selects the pane within its tab/window; the docs do not
|
|
367
|
+
// say it raises the OS window, so a backgrounded WezTerm may stay behind.
|
|
368
|
+
const pane = paneFor(query, true);
|
|
369
|
+
if (pane === undefined)
|
|
370
|
+
return false;
|
|
371
|
+
return (quietRun(runner(), weztermActivateArgv(pane.paneId), {
|
|
372
|
+
cwd: query.cwd,
|
|
373
|
+
timeoutMs: 10_000,
|
|
374
|
+
}) !== null);
|
|
375
|
+
},
|
|
376
|
+
capture(query) {
|
|
377
|
+
const pane = paneFor(query, true);
|
|
378
|
+
if (pane === undefined)
|
|
379
|
+
return null;
|
|
380
|
+
return quietRun(runner(), weztermGetTextArgv(pane.paneId), {
|
|
381
|
+
cwd: query.cwd,
|
|
382
|
+
timeoutMs: 10_000,
|
|
383
|
+
});
|
|
384
|
+
},
|
|
385
|
+
close(query) {
|
|
386
|
+
// Killing is destructive. The pane must be the one remembered for the session, one this
|
|
387
|
+
// host spawned and still on the tty recorded then, and still listed at the cwd it was
|
|
388
|
+
// opened in: after a mux restart WezTerm reissues pane ids, and the old id may now be a
|
|
389
|
+
// person's own shell.
|
|
390
|
+
if (query.sessionId === undefined) {
|
|
391
|
+
const pane = paneFor(query, false);
|
|
392
|
+
return pane !== undefined && kill(pane.paneId, query.cwd);
|
|
393
|
+
}
|
|
394
|
+
const remembered = recallSessionPane(registryDir, query.sessionId);
|
|
395
|
+
if (remembered === null)
|
|
396
|
+
return false;
|
|
397
|
+
const id = Number(remembered.pane);
|
|
398
|
+
const list = listing(query.cwd);
|
|
399
|
+
if (list === null || !spawnedAndLive(list, id))
|
|
400
|
+
return false;
|
|
401
|
+
const here = panesIn(list.rows, remembered.cwd, platform);
|
|
402
|
+
return here.some((p) => p.paneId === id) && kill(id, query.cwd);
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@osdlc/agent-seat`: put an interactive coding-agent session in front of a
|
|
3
|
+
* person, in the terminal app, multiplexer or editor they already use.
|
|
4
|
+
*
|
|
5
|
+
* A {@link Host} is one place a session can open: a terminal tab, a tmux
|
|
6
|
+
* session, a WezTerm pane, a VS Code terminal. A host fulfils the contract in
|
|
7
|
+
* parts (open a session, run a command, send text, find, focus, capture,
|
|
8
|
+
* close); a caller asks for the part it needs with {@link requirePart} and gets
|
|
9
|
+
* a {@link HostPartMissing} error when the host lacks it, never a silent
|
|
10
|
+
* fallback to another host.
|
|
11
|
+
*
|
|
12
|
+
* A {@link Harness} is the agent CLI the session runs (claude, codex, ...).
|
|
13
|
+
* Every host runs any harness through {@link harnessArgv}, so where a session
|
|
14
|
+
* opens and which agent runs in it are independent choices.
|
|
15
|
+
*
|
|
16
|
+
* {@link hostRegistry} builds every built-in host. Hosts that remember panes or
|
|
17
|
+
* read an IDE companion's registry keep their files under the caller's
|
|
18
|
+
* `runtimeDir`.
|
|
19
|
+
*/
|
|
20
|
+
import type { HostRegistry } from './contract.ts';
|
|
21
|
+
import { type JetbrainsSettings } from './hosts/jetbrains.ts';
|
|
22
|
+
export { HOST_NAMES, HostNameSchema, HostPartMissing, WAIT_TIMEOUT_EXIT, hostCanWait, onPath, requirePart, supportsPlatform, type CommandRequest, type FindQuery, type FoundSession, type Host, type HostName, type HostPart, type HostParts, type HostRegistry, type OpenedWorkspace, type OpenWorkspaceQuery, type SendReceipt, type SendRequest, type SessionRequest, type WaitRequest, } from './contract.ts';
|
|
23
|
+
export { CLAUDE_HOOK_EVENTS, ClaudeSessionFlagsSchema, HarnessSchema, claudeHookSettings, claudeResumeArgv, claudeSessionArgv, codexSessionsDir, findCodexSessionId, harnessArgv, harnessAvailable, harnessNativeSend, harnessOrNull, hasInteractiveArgv, parseRolloutMeta, walkRolloutFiles, withEnv, wrapperSession, type ClaudeLaunchFlags, type ClaudeSessionFlags, type Harness, type NativeSendResult, } from './harness.ts';
|
|
24
|
+
export { ensureWorkspace } from './open-workspace.ts';
|
|
25
|
+
export { makeCmuxHost } from './hosts/cmux.ts';
|
|
26
|
+
export { makeHerdrHost } from './hosts/herdr.ts';
|
|
27
|
+
export { JETBRAINS_PROTOCOL, makeJetbrainsHost, type JetbrainsSettings } from './hosts/jetbrains.ts';
|
|
28
|
+
export { ORCA_BUNDLED_CLI, makeOrcaHost } from './hosts/orca.ts';
|
|
29
|
+
export { makeOttyHost } from './hosts/otty.ts';
|
|
30
|
+
export { hostRegistryDir, type HostStateOptions } from './hosts/registry.ts';
|
|
31
|
+
export { makeTerminalHost, terminalHost } from './hosts/terminal.ts';
|
|
32
|
+
export { insideTmux, makeTmuxHost, tmuxAttachArgv, tmuxIdSessionName, tmuxSessionName, tmuxSwitchClientArgv, } from './hosts/tmux.ts';
|
|
33
|
+
export { makeVscodeHost } from './hosts/vscode.ts';
|
|
34
|
+
export { liveWindows, subprocessTransport, type VscodeReply, type VscodeTransport, type VscodeWindow, } from './hosts/vscode-client.ts';
|
|
35
|
+
export { makeWeztermHost } from './hosts/wezterm.ts';
|
|
36
|
+
/** What {@link hostRegistry} needs. */
|
|
37
|
+
export interface HostRegistryOptions {
|
|
38
|
+
/**
|
|
39
|
+
* The caller's runtime directory. Hosts that keep state use
|
|
40
|
+
* `<runtimeDir>/hosts/<name>`; the VS Code and JetBrains companions register
|
|
41
|
+
* there too, so they must be given the same directory.
|
|
42
|
+
*/
|
|
43
|
+
runtimeDir: string;
|
|
44
|
+
/** Lets Orca register this repository when it does not know the worktree. */
|
|
45
|
+
repoRoot?: string | undefined;
|
|
46
|
+
/** Which JetBrains IDEs may host a session; none means any. */
|
|
47
|
+
jetbrains?: JetbrainsSettings | undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every built-in host, by name. Each one is a wrapper: it opens a terminal or
|
|
51
|
+
* workspace and runs whichever harness the request names, so none hardcodes an
|
|
52
|
+
* agent CLI. A consumer that wants a fake builds its own `HostRegistry`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function hostRegistry(opts: HostRegistryOptions): HostRegistry;
|