@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,100 @@
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
+ import { closeSync, fstatSync, openSync, readdirSync, readFileSync, statSync, } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { isWithin, parseJsonOrNull, realResolve } from "../util.js";
11
+ /** Where host `name` keeps its state under `runtimeDir`: `<runtimeDir>/hosts/<name>`. */
12
+ export function hostRegistryDir(runtimeDir, name) {
13
+ return join(runtimeDir, 'hosts', name);
14
+ }
15
+ /** Host `name`'s state directory under `opts`. */
16
+ export function registryDirOf(name, opts) {
17
+ return opts.registryDir ?? hostRegistryDir(opts.runtimeDir, name);
18
+ }
19
+ /** Why `stats` cannot be trusted under `uid`: owned by another user, or writable by group or others. */
20
+ function untrusted(stats, uid) {
21
+ if (stats.uid !== uid)
22
+ return `is owned by uid ${stats.uid}, not ${uid}`;
23
+ if ((stats.mode & 0o022) !== 0)
24
+ return 'is writable by group or others';
25
+ return null;
26
+ }
27
+ /**
28
+ * Entries in `dir` that `parse` accepts and whose process is alive. A missing
29
+ * directory, an unreadable or malformed file, and a dead pid are all skipped.
30
+ *
31
+ * An entry carries an endpoint and a token, so a planted one would receive
32
+ * every session's argv and sent text. On POSIX the whole directory is skipped
33
+ * unless it belongs to the current user and is not group- or world-writable,
34
+ * and so is each file; `opts.onSkip` hears why. Windows has no uid to check.
35
+ */
36
+ export function readHostRegistry(dir, parse, alive, opts = {}) {
37
+ const uid = opts.uid === undefined ? (process.getuid?.() ?? null) : opts.uid;
38
+ let names;
39
+ try {
40
+ if (uid !== null) {
41
+ const why = untrusted(statSync(dir), uid);
42
+ if (why !== null) {
43
+ opts.onSkip?.(`registry directory ${dir} ${why}`);
44
+ return [];
45
+ }
46
+ }
47
+ names = readdirSync(dir).filter((n) => n.endsWith('.json'));
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ const entries = [];
53
+ for (const name of names) {
54
+ const path = join(dir, name);
55
+ let text;
56
+ let fd = null;
57
+ try {
58
+ // Checked on the open descriptor, so the file cannot be swapped between check and read.
59
+ fd = openSync(path, 'r');
60
+ const why = uid === null ? null : untrusted(fstatSync(fd), uid);
61
+ if (why !== null) {
62
+ opts.onSkip?.(`registry file ${path} ${why}`);
63
+ continue;
64
+ }
65
+ text = readFileSync(fd, 'utf8');
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ finally {
71
+ if (fd !== null)
72
+ closeSync(fd);
73
+ }
74
+ const entry = parse(parseJsonOrNull(text));
75
+ if (entry !== null && alive(entry.pid))
76
+ entries.push(entry);
77
+ }
78
+ return entries;
79
+ }
80
+ /**
81
+ * The candidate with the deepest folder containing `cwd` (depth = length of the
82
+ * real path), the newest `startedAt` winning a tie, then the earliest in list
83
+ * order. Null when no folder contains `cwd`. How both IDE hosts pick a window.
84
+ */
85
+ export function deepestContaining(cwd, candidates, folders, startedAt) {
86
+ let best = null;
87
+ for (const c of candidates) {
88
+ for (const folder of folders(c)) {
89
+ if (!isWithin(cwd, folder))
90
+ continue;
91
+ const depth = realResolve(folder).length;
92
+ if (best === null ||
93
+ depth > best.depth ||
94
+ (depth === best.depth && startedAt(c) > startedAt(best.c))) {
95
+ best = { c, depth };
96
+ }
97
+ }
98
+ }
99
+ return best?.c ?? null;
100
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A host's memory of which pane belongs to which session:
3
+ * `<runtime>/hosts/<name>/sessions/<slug>.json` ({@link sessionIdSlug}), holding the session id,
4
+ * the host's own pane identifier (WezTerm's pane id, Orca's terminal handle, herdr's pane id)
5
+ * and the cwd it was opened in. The host writes it when it opens a pane for a session and resolves a
6
+ * later `send`/`find`/`focus`/`capture`/`close` for that session through it,
7
+ * so two sessions in one worktree reach two different panes.
8
+ *
9
+ * The identifier is only a hint: a host checks it against its own live
10
+ * listing before acting, because panes close and a restarted app reissues ids.
11
+ */
12
+ /** What a host remembers about the pane it opened for a session. */
13
+ export interface RememberedPane {
14
+ /** The host's own pane identifier. */
15
+ pane: string;
16
+ /** The working directory the pane was opened in, so a reissued id can be told from the original. */
17
+ cwd: string;
18
+ }
19
+ /** The pane remembered for `sessionId`, or null. A file that names another id is ignored. */
20
+ export declare function recallSessionPane(dir: string, sessionId: string): RememberedPane | null;
21
+ /** The pane remembered for the request's session id, or null when it carries none or none was remembered. */
22
+ export declare function boundPane(dir: string, target: {
23
+ sessionId?: string | undefined;
24
+ }): string | null;
25
+ /**
26
+ * Remembers `paneId` (opened in `cwd`) for `sessionId`. A failure is reported on stderr and
27
+ * not thrown: the session still runs, and later requests for it resolve by `cwd`.
28
+ */
29
+ export declare function rememberSessionPane(dir: string, sessionId: string, paneId: string, cwd: string): void;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A host's memory of which pane belongs to which session:
3
+ * `<runtime>/hosts/<name>/sessions/<slug>.json` ({@link sessionIdSlug}), holding the session id,
4
+ * the host's own pane identifier (WezTerm's pane id, Orca's terminal handle, herdr's pane id)
5
+ * and the cwd it was opened in. The host writes it when it opens a pane for a session and resolves a
6
+ * later `send`/`find`/`focus`/`capture`/`close` for that session through it,
7
+ * so two sessions in one worktree reach two different panes.
8
+ *
9
+ * The identifier is only a hint: a host checks it against its own live
10
+ * listing before acting, because panes close and a restarted app reissues ids.
11
+ */
12
+ import { join } from 'node:path';
13
+ import { ensureDir, isRecord, readJsonOrNull, sessionIdSlug, writeJsonAtomic } from "../util.js";
14
+ /**
15
+ * `<dir>/sessions/<slug>.json`: one file per session, so concurrent launches never overwrite each
16
+ * other. The name is a slug with a 128-bit hash, not the id itself, so it is unique on
17
+ * case-insensitive file systems and valid on Windows.
18
+ */
19
+ function filePath(dir, sessionId) {
20
+ return join(dir, 'sessions', `${sessionIdSlug(sessionId, 32)}.json`);
21
+ }
22
+ /** The pane remembered for `sessionId`, or null. A file that names another id is ignored. */
23
+ export function recallSessionPane(dir, sessionId) {
24
+ const data = readJsonOrNull(filePath(dir, sessionId));
25
+ if (!isRecord(data) || data['id'] !== sessionId)
26
+ return null;
27
+ const { pane, cwd } = data;
28
+ return typeof pane === 'string' && typeof cwd === 'string' ? { pane, cwd } : null;
29
+ }
30
+ /** The pane remembered for the request's session id, or null when it carries none or none was remembered. */
31
+ export function boundPane(dir, target) {
32
+ return target.sessionId === undefined
33
+ ? null
34
+ : (recallSessionPane(dir, target.sessionId)?.pane ?? null);
35
+ }
36
+ /**
37
+ * Remembers `paneId` (opened in `cwd`) for `sessionId`. A failure is reported on stderr and
38
+ * not thrown: the session still runs, and later requests for it resolve by `cwd`.
39
+ */
40
+ export function rememberSessionPane(dir, sessionId, paneId, cwd) {
41
+ try {
42
+ ensureDir(join(dir, 'sessions'));
43
+ writeJsonAtomic(filePath(dir, sessionId), { id: sessionId, pane: paneId, cwd });
44
+ }
45
+ catch (err) {
46
+ const detail = err instanceof Error ? err.message : String(err);
47
+ process.stderr.write(`[host] could not remember the pane of session ${sessionId}: ${detail}\n`);
48
+ }
49
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The current terminal: run it right here and wait for it.
3
+ *
4
+ * An interactive process is the one thing that must NOT be captured — it
5
+ * owns the TTY until the person leaves it — so this runs through the command
6
+ * seam's STREAMING door (`streamRunner`; stdio fully
7
+ * inherited, resolves to `{exitCode}`) rather than a capture door. `req.argv`
8
+ * names a different binary on every call, so the injected dependency is a
9
+ * runner FACTORY — `(binary) => StreamRunner`, the same shape `streamRunner`
10
+ * itself has — not a runner bound to one binary the way `hosts/tmux.ts` and
11
+ * `hosts/cmux.ts` inject theirs (their binary, `tmux`/`cmux`, is fixed).
12
+ */
13
+ import { type StreamRunner } from '@osdlc/command-seam';
14
+ import type { Host } from '../contract.ts';
15
+ /**
16
+ * The current-terminal host: runs the command right here with inherited stdio and waits for it.
17
+ * Needs no binary or app, runs on every platform, and is always available.
18
+ */
19
+ export declare function makeTerminalHost(opts?: {
20
+ streamRunner?: ((binary: string) => StreamRunner) | undefined;
21
+ }): Host;
22
+ /** The production terminal host — `streamRunner` as its runner factory. */
23
+ export declare const terminalHost: Host;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The current terminal: run it right here and wait for it.
3
+ *
4
+ * An interactive process is the one thing that must NOT be captured — it
5
+ * owns the TTY until the person leaves it — so this runs through the command
6
+ * seam's STREAMING door (`streamRunner`; stdio fully
7
+ * inherited, resolves to `{exitCode}`) rather than a capture door. `req.argv`
8
+ * names a different binary on every call, so the injected dependency is a
9
+ * runner FACTORY — `(binary) => StreamRunner`, the same shape `streamRunner`
10
+ * itself has — not a runner bound to one binary the way `hosts/tmux.ts` and
11
+ * `hosts/cmux.ts` inject theirs (their binary, `tmux`/`cmux`, is fixed).
12
+ */
13
+ import { streamRunner } from '@osdlc/command-seam';
14
+ import { onPath } from "../contract.js";
15
+ import { wrapperSession } from "../harness.js";
16
+ /**
17
+ * The current-terminal host: runs the command right here with inherited stdio and waits for it.
18
+ * Needs no binary or app, runs on every platform, and is always available.
19
+ */
20
+ export function makeTerminalHost(opts = {}) {
21
+ const makeRunner = opts.streamRunner ?? streamRunner;
22
+ const terminal = (req) => {
23
+ const [bin, ...rest] = req.argv;
24
+ if (bin === undefined)
25
+ throw new Error('empty argv');
26
+ if (!onPath(bin))
27
+ throw new Error(`'${bin}' is not on PATH`);
28
+ return makeRunner(bin).run(rest, { cwd: req.cwd }).exitCode;
29
+ };
30
+ return {
31
+ name: 'terminal',
32
+ platforms: 'all',
33
+ available() {
34
+ return true;
35
+ },
36
+ terminal,
37
+ // `terminal` returns only once the agent exited, so the session-id lookup looks once. The
38
+ // host's own `terminal` part runs, so a host spread from this one with another
39
+ // `terminal` opens the session through that one.
40
+ session(req) {
41
+ const run = this.terminal ?? terminal;
42
+ return wrapperSession((r) => run.call(this, r), req, { hostWaited: true });
43
+ },
44
+ };
45
+ }
46
+ /** The production terminal host — `streamRunner` as its runner factory. */
47
+ export const terminalHost = makeTerminalHost();
@@ -0,0 +1,145 @@
1
+ /**
2
+ * tmux: a detached session on `cwd` running a command, found again by a name
3
+ * bound to the caller's session id; see `docs/planning/research/tmux.md`.
4
+ *
5
+ * Unlike orca/cmux, tmux has no app-level worktree registry and no `--name`
6
+ * the caller supplies — a session name is chosen here, once, and must be
7
+ * unique server-wide. A launch carries the caller's session id
8
+ * (`CommandRequest.sessionId`), so the name is derived from it
9
+ * ({@link tmuxIdSessionName}) and two sessions in one worktree get two tmux
10
+ * sessions. `send`/`find`/`focus`/`capture`/`close` take the same id and
11
+ * target that name; a request without an id (or a session opened before this
12
+ * binding existed) falls back to the name derived from `cwd`
13
+ * ({@link tmuxSessionName}).
14
+ */
15
+ import { type CommandRunner } from '@osdlc/command-seam';
16
+ import type { CommandRequest, Host } from '../contract.ts';
17
+ /**
18
+ * The cwd-derived tmux session name: stable across repeated calls with the same
19
+ * `cwd`, and the name every session had before addressing by id. tmux session
20
+ * names must be unique server-wide and match `[A-Za-z0-9_-]` —
21
+ * `docs/planning/research/tmux.md`'s "Naming rules" verified that `.`/`:` are
22
+ * silently rewritten to `_`, which can then cause a LATER create to fail with
23
+ * `duplicate session` against the rewritten name instead of the one actually
24
+ * requested. Hashing `cwd` sidesteps that class of surprise entirely rather
25
+ * than trying to sanitize an arbitrary path into the safe character set.
26
+ *
27
+ * Used for a request with no session id and as the fallback for a session
28
+ * opened before names were bound to ids. Two launches WITHOUT an id for the
29
+ * SAME `cwd` still collide — the second `tmux new-session` fails with its own
30
+ * real "duplicate session" error, surfaced verbatim below.
31
+ *
32
+ * @experimental an `attach` part is planned to replace the tmux-specific attach helpers.
33
+ */
34
+ export declare function tmuxSessionName(cwd: string): string;
35
+ /**
36
+ * The tmux session name bound to a caller session id: `sd-` and {@link sessionIdSlug}, the
37
+ * id's first 12 characters with anything outside `[A-Za-z0-9_-]` rewritten to `_` (so tmux
38
+ * never rewrites it for us), then `-` and 8 hex digits of the sha256 of the whole id. The hash
39
+ * keeps apart ids that share their first 12 characters (`build-step-001`, `build-step-002`) or
40
+ * differ only in a rewritten character (`a.b`, `a_b`).
41
+ *
42
+ * The single source of the name: an attach must call this rather than rebuild it.
43
+ *
44
+ * @experimental an `attach` part is planned to replace the tmux-specific attach helpers.
45
+ */
46
+ export declare function tmuxIdSessionName(sessionId: string): string;
47
+ /**
48
+ * The name of a visible run's session: `sd-` and the run id (anything outside `[A-Za-z0-9_-]`
49
+ * rewritten to `_`), then, when there is a caller session id (a run's several agent steps each
50
+ * get a session), `-` and the same id part as {@link tmuxIdSessionName}. A person finds it by
51
+ * the run id (`tmux ls`).
52
+ */
53
+ export declare function tmuxRunSessionName(runId: string, sessionId?: string): string;
54
+ /**
55
+ * Argv for `tmux new-session` — pure, so a test can pin it. `-d` (detached)
56
+ * is what makes this hands-off, the same category as orca/cmux's own managed
57
+ * window: `new-session` returns almost immediately once the session exists,
58
+ * long before whatever it runs finishes. `--` terminates tmux's own option
59
+ * parsing before `req.argv` (the harness's argv, possibly under an `env` prefix —
60
+ * see `wrapperSession` in `../harness.ts`) starts, the same guard `harnessArgv` itself applies one
61
+ * level down for the harness binary's own flags.
62
+ */
63
+ export declare function tmuxCreateArgv(req: CommandRequest): string[];
64
+ /** Argv for the wait poll: prints `<pane_dead> <pane_dead_status>` for the session's pane. */
65
+ export declare function tmuxPaneStatusArgv(name: string): string[];
66
+ /**
67
+ * Argv for `load-buffer`, the first of the three invocations `send` makes, per
68
+ * `docs/planning/research/tmux.md`'s "Sending input" section (verified): `-`
69
+ * reads the buffer's content from stdin (`req.text`, passed as
70
+ * `CommandRunOptions.input`), so embedded newlines survive the paste instead of
71
+ * each arriving as an Enter, and a `text` starting with `-` is never parsed as
72
+ * a flag.
73
+ */
74
+ export declare function tmuxLoadBufferArgv(name: string): string[];
75
+ /**
76
+ * Argv for `paste-buffer`, the second invocation: `-p` pastes as if typed
77
+ * (bracketed paste, matching Orca's own send behavior), `-d` deletes the
78
+ * buffer immediately after so it never lingers for a later, unrelated send
79
+ * to collide with. `-t` addresses the pane by session `name`.
80
+ */
81
+ export declare function tmuxPasteBufferArgv(name: string): string[];
82
+ /** The third, separate `send-keys` call that submits what {@link tmuxPasteBufferArgv} just pasted. */
83
+ export declare function tmuxSendEnterArgv(name: string): string[];
84
+ /**
85
+ * Argv for `tmux has-session -t <name>` — per `docs/planning/research/tmux.md`'s
86
+ * "Presence checks" section (verified): exits 0 when the session exists, 1
87
+ * when it does not. Simpler than `list-panes` and sufficient here, since a
88
+ * session name maps 1:1 to a pane.
89
+ */
90
+ export declare function tmuxHasSessionArgv(name: string): string[];
91
+ /**
92
+ * Argv for `tmux attach-session -t <name>` — the real, interactive attach
93
+ * a caller uses for a tmux-hosted session `find` (below) confirms is still
94
+ * live, instead of falling through to an engine-native resume that would risk
95
+ * opening a SECOND, competing session on top of a still-live one. Unlike orca/cmux, tmux has a genuine
96
+ * interactive attach primitive of its own — this is that mechanism (`attach-
97
+ * session` itself is standard, well-documented tmux behavior, not something
98
+ * this campaign's own research docs independently verified).
99
+ *
100
+ * Only correct when the attaching process is OUTSIDE any tmux client already
101
+ * — a caller reaches for {@link tmuxSwitchClientArgv} instead
102
+ * when `$TMUX` is set, since `attach-session` refuses to nest one client
103
+ * inside another.
104
+ *
105
+ * @experimental an `attach` part is planned to replace the tmux-specific attach helpers.
106
+ */
107
+ export declare function tmuxAttachArgv(name: string): string[];
108
+ /**
109
+ * Argv for `tmux switch-client -t <name>` — what a caller uses
110
+ * instead of {@link tmuxAttachArgv} when the attaching process is ITSELF
111
+ * already inside a tmux client (`$TMUX` set, the common case for a tmux
112
+ * user): `attach-session` there refuses with "sessions should be nested with
113
+ * care, unset $TMUX to force" and exits 1 without attaching anything, while
114
+ * `switch-client` is tmux's own documented mechanism for moving the current
115
+ * client to a different session instead of nesting one inside another.
116
+ *
117
+ * @experimental an `attach` part is planned to replace the tmux-specific attach helpers.
118
+ */
119
+ export declare function tmuxSwitchClientArgv(name: string): string[];
120
+ /** Argv for `tmux list-sessions`, one session name per line. */
121
+ export declare function tmuxListSessionsArgv(): string[];
122
+ /** Argv for `tmux capture-pane -p -t <name>`: prints the pane's visible text to stdout. */
123
+ export declare function tmuxCapturePaneArgv(name: string): string[];
124
+ /** Argv for `tmux kill-session -t <name>`: ends the session and signals what runs in it. */
125
+ export declare function tmuxKillSessionArgv(name: string): string[];
126
+ /**
127
+ * Whether this process runs inside a tmux client (the `TMUX` environment variable is set).
128
+ *
129
+ * @experimental an `attach` part is planned to replace the tmux-specific attach helpers.
130
+ */
131
+ export declare function insideTmux(): boolean;
132
+ /**
133
+ * The tmux host: drives a detached tmux session per launch through the `tmux` binary, which must be on PATH.
134
+ * Runs on darwin and linux only (on Windows it is unavailable; run it inside WSL). Keeps no state of its own;
135
+ * sessions are found again by a name derived from the caller's session id.
136
+ */
137
+ export declare function makeTmuxHost(opts: {
138
+ runner?: CommandRunner | undefined;
139
+ available?: (() => boolean) | undefined;
140
+ /** Defaults to {@link insideTmux}. */
141
+ inside?: (() => boolean) | undefined;
142
+ /** The wait poll's pause and clock (epoch ms); tests replace them. */
143
+ sleep?: ((ms: number) => void) | undefined;
144
+ now?: (() => number) | undefined;
145
+ }): Host;