@osdlc/agent-seat 0.0.0-stage → 0.0.1-stage

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,297 @@
1
+ /**
2
+ * The host contract — somewhere on this machine a person's work can be put
3
+ * in front of them: a terminal tab, a workspace in a multiplexer, a desktop
4
+ * agent app.
5
+ *
6
+ * A host fulfils the contract in PARTS. Each part is an optional method, so
7
+ * an app that can open an agent session but not an arbitrary command (a
8
+ * desktop Claude or Codex) is still a host; a caller asks for the part it
9
+ * needs through {@link requirePart} and gets a named error when the host
10
+ * lacks it, never a silent fallback to a different host — the person chose
11
+ * this one.
12
+ */
13
+ import { type CommandRunner, type CommandRunOptions, type EnvOverrides } from '@osdlc/command-seam';
14
+ import { z } from 'zod';
15
+ import type { ClaudeLaunchFlags, Harness } from './harness.ts';
16
+ /**
17
+ * Every host in {@link HOST_NAMES} is a WRAPPER: it opens a terminal or
18
+ * workspace and runs `harnessArgv(req.harness, req.binary, req.prompt, req)`
19
+ * (`./harness.ts`) in it, so any host opens any {@link Harness}. `HostName`
20
+ * names WHERE; `Harness` (`./harness.ts`) names WHICH.
21
+ */
22
+ export type HostName = (typeof HOST_NAMES)[number];
23
+ /**
24
+ * Every {@link HostName}, in a fixed order, for building the host registry and the
25
+ * `z.enum` schema below.
26
+ */
27
+ export declare const HOST_NAMES: readonly ["terminal", "orca", "cmux", "tmux", "herdr", "otty", "wezterm", "vscode", "jetbrains"];
28
+ /**
29
+ * `z.enum` over {@link HOST_NAMES}: the one schema a `host`-shaped config
30
+ * field or CLI flag validates against, so a new host is added in exactly one
31
+ * place.
32
+ */
33
+ export declare const HostNameSchema: z.ZodEnum<{
34
+ terminal: "terminal";
35
+ orca: "orca";
36
+ cmux: "cmux";
37
+ tmux: "tmux";
38
+ herdr: "herdr";
39
+ otty: "otty";
40
+ wezterm: "wezterm";
41
+ vscode: "vscode";
42
+ jetbrains: "jetbrains";
43
+ }>;
44
+ /**
45
+ * Open an interactive coding-agent session with `prompt` as its first
46
+ * message. Other harnesses ignore the Claude-only flags; `via`, `model` and `effort` apply to
47
+ * claude, codex and pi.
48
+ */
49
+ export interface SessionRequest extends ClaudeLaunchFlags {
50
+ cwd: string;
51
+ harness: Harness;
52
+ /** The harness's executable: a name on PATH or an absolute path. */
53
+ binary: string;
54
+ prompt: string;
55
+ /** Tab / workspace title for hosts that show one. */
56
+ title: string;
57
+ /** Env vars set on the harness process; an `undefined` value unsets that var. */
58
+ env?: EnvOverrides | undefined;
59
+ /**
60
+ * A command prefix the harness argv runs under (`wrapperSession`): `wrap` then
61
+ * the harness's own argv, e.g. `my-wrapper run --id <id> --`. Env applies to
62
+ * the whole command, so the wrapper sees the session tags too.
63
+ */
64
+ wrap?: string[] | undefined;
65
+ /**
66
+ * The caller's id for this session. A host binds the pane it opens to it, so a
67
+ * later `send`/`find`/`focus`/`capture`/`close` with the same `sessionId` finds
68
+ * THIS pane even when another session shares the working directory. The
69
+ * harness's own id is {@link ClaudeLaunchFlags.harnessSessionId}.
70
+ */
71
+ sessionId?: string | undefined;
72
+ /** The resolved model passed as the harness's model flag (claude, codex, pi; another harness refuses one). */
73
+ model?: string | undefined;
74
+ /** The resolved effort passed as the harness's effort flag (claude, codex, pi; another harness refuses one). */
75
+ effort?: string | undefined;
76
+ /** The launch mode the model needs, as flags before the model flag (codex `--oss --local-provider ollama`). */
77
+ via?: readonly string[] | undefined;
78
+ /** Argv a per-launch observation adds before the prompt (pi: `--session-id`, `-e <extension>`). */
79
+ injectedArgv?: readonly string[] | undefined;
80
+ /** A visible run's wait ({@link WaitRequest}); only a host that can wait ({@link hostCanWait}) honours it. */
81
+ wait?: WaitRequest | undefined;
82
+ }
83
+ /**
84
+ * Hold the launch until what it opened exits (a visible scheduled run): the host
85
+ * returns the process's own exit code, or {@link WAIT_TIMEOUT_EXIT} after killing the session
86
+ * at `timeoutMs`. `runId` names the session so a person can find it (`tmux attach -t …`).
87
+ */
88
+ export interface WaitRequest {
89
+ runId?: string | undefined;
90
+ /** The time left, milliseconds; unset waits as long as the process runs. */
91
+ timeoutMs?: number | undefined;
92
+ /**
93
+ * Called by the host when it killed the session because `timeoutMs` ran out, before it
94
+ * returns. The only reliable sign of a deadline kill: a process may itself exit
95
+ * {@link WAIT_TIMEOUT_EXIT}.
96
+ */
97
+ onDeadline?: (() => void) | undefined;
98
+ }
99
+ /** The exit code a waiting host reports for a session it killed at the deadline (the `timeout(1)` convention). */
100
+ export declare const WAIT_TIMEOUT_EXIT = 124;
101
+ /**
102
+ * Hosts that can hold a launch until the session exits, when asked to ({@link WaitRequest}).
103
+ *
104
+ * @experimental to become a declaration on the host, like `acksSend`.
105
+ */
106
+ export declare function hostCanWait(host: HostName): boolean;
107
+ /** Run a command in a terminal the person can see. */
108
+ export interface CommandRequest {
109
+ cwd: string;
110
+ argv: string[];
111
+ title: string;
112
+ /** The caller's session id to bind the pane to; see {@link SessionRequest.sessionId}. */
113
+ sessionId?: string | undefined;
114
+ /** See {@link SessionRequest.wait}. */
115
+ wait?: WaitRequest | undefined;
116
+ }
117
+ /**
118
+ * Deliver `text` into a session that is ALREADY OPEN at `cwd`. Each host
119
+ * turns the request into its own target at send time. With `sessionId` it
120
+ * resolves the pane bound to that session at launch (tmux names its
121
+ * session from the id, otty tags the title, wezterm/orca/herdr remember the pane
122
+ * id, vscode tracks the terminal); without one, or on a host that cannot bind
123
+ * an id (cmux, jetbrains), it falls back to `cwd`: Orca lists the worktree's
124
+ * terminals and sends to a live agent's runtime handle; otherwise tmux derives
125
+ * the fallback session name from `cwd` (`tmuxSessionName`).
126
+ */
127
+ export interface SendRequest extends FindQuery {
128
+ text: string;
129
+ /**
130
+ * The harness of the session being addressed.
131
+ * A host that can attribute terminals to a harness narrows to the one matching
132
+ * terminal and refuses when there is not exactly one, rather than risk typing
133
+ * into a different session.
134
+ */
135
+ harness?: Harness;
136
+ }
137
+ /**
138
+ * A query for live sessions, for deciding whether a recorded session is still
139
+ * open. `sessionId` is the caller's session id when the caller has one
140
+ * (a session launched before ids were bound has no pane bound to its id, so the
141
+ * host falls back to `cwd`).
142
+ */
143
+ export interface FindQuery {
144
+ cwd: string;
145
+ sessionId?: string | undefined;
146
+ }
147
+ /** One live-or-dead session a host found at a queried cwd. */
148
+ export interface FoundSession {
149
+ cwd: string;
150
+ /** Best-known harness identity, or null when the host cannot tell (e.g. tmux — see `hosts/tmux.ts`). */
151
+ harness: Harness | null;
152
+ alive: boolean;
153
+ /** ISO timestamp of last output, or null when the host has no such signal (tmux always null). */
154
+ lastOutputAt: string | null;
155
+ /** The host's own identifier for the pane: Orca's terminal handle (`term_…`), tmux's session name, a pane id elsewhere; null for hosts without one. */
156
+ handle?: string | null;
157
+ }
158
+ /** Ensure a workspace containing `cwd` is open in the host (see {@link HostParts.openWorkspace}). */
159
+ export interface OpenWorkspaceQuery {
160
+ cwd: string;
161
+ /** Told, before a possibly slow open starts, what is about to happen (e.g. "opening /repo in WebStorm…"). */
162
+ notice?: ((line: string) => void) | undefined;
163
+ }
164
+ /** Which workspace holds `cwd`, and whether this call had to open it. */
165
+ export interface OpenedWorkspace {
166
+ /** The workspace root that contains `cwd`. */
167
+ root: string;
168
+ /** False when a workspace already contained `cwd`, so nothing was opened. */
169
+ opened: boolean;
170
+ }
171
+ /** What a `send` attempt learned about whether the message actually landed. */
172
+ export interface SendReceipt {
173
+ /** False for "nothing to send to" or "the host rejected/failed the send" — the caller tries the next route, same as a missing `send` part. */
174
+ accepted: boolean;
175
+ /**
176
+ * True only when the HOST ITSELF confirmed the harness CONSUMED the input
177
+ * (Orca's `turn_started` stage, per `docs/planning/research/orca.md`'s
178
+ * "send" section). False for "sent, but the host has no confirmation
179
+ * mechanism" (tmux) or "sent, but the receiving process gave no stronger
180
+ * signal than input-accepted". Other evidence (hook logs, session state)
181
+ * is gathered by the caller, not reported here. A caller can treat `accepted && ack` as delivered and
182
+ * `accepted && !ack` as unconfirmed.
183
+ */
184
+ ack: boolean;
185
+ /**
186
+ * True when the host accepted the send but ITSELF says it cannot tell
187
+ * whether the text landed (herdr's `timeout` / `agent_prompt_stalled`, Orca's
188
+ * input-accepted without a `turn_started` stage). The text may well be in the
189
+ * session, so a caller must not send it again.
190
+ */
191
+ uncertain?: boolean;
192
+ }
193
+ /**
194
+ * The parts a host may fulfil. `terminal` returns the exit code to report:
195
+ * the process's own for a host that waits on it, 0 for one that hands the
196
+ * work to another app. `session` returns that same exit code alongside
197
+ * `harnessSessionId`, the harness's own id for the session it just started
198
+ * when one can be found (`wrapperSession` resolves codex's), so a caller can
199
+ * look a launch back up by it; `null` when there is no such id or a
200
+ * best-effort lookup came up empty. `send`
201
+ * delivers into an already-open session — `terminal`/`session`
202
+ * both OPEN something new, `send` never does. `find` answers
203
+ * "what's live at this cwd right now", so a caller can tell whether a
204
+ * recorded hands-off session is still open — it never opens
205
+ * or delivers anything. Each throws `Error` carrying the host's stderr on
206
+ * failure — except `send`, `find`, `focus`, `capture` and `close`, which report a failed attempt
207
+ * through their own return value (`SendReceipt.accepted: false`, an empty
208
+ * `FoundSession[]`, `false`) rather than throwing, since "the target session is
209
+ * gone/unreachable" and "nothing is running there" are both ordinary,
210
+ * expected outcomes for a caller trying the next delivery route or
211
+ * reconciling stale state, not exceptional ones.
212
+ */
213
+ export interface HostParts {
214
+ session(req: SessionRequest): {
215
+ exitCode: number;
216
+ harnessSessionId: string | null;
217
+ };
218
+ terminal(req: CommandRequest): number;
219
+ send(req: SendRequest): SendReceipt;
220
+ find(query: FindQuery): FoundSession[];
221
+ /** Brings the live session (`query.sessionId`, else `query.cwd`) forward; false when there is none or the host call failed. */
222
+ focus(query: FindQuery): boolean;
223
+ /** Recent screen text of the session (`query.sessionId`, else `query.cwd`); null when there is none or the host call failed. Never throws. */
224
+ capture(query: FindQuery): string | null;
225
+ /**
226
+ * Closes the pane of the session (`query.sessionId`, else `query.cwd`) and kills what runs in
227
+ * it. True when the host reported the close succeeded; false when there is none or the host
228
+ * call failed. Never throws. Only hosts with a real close command fulfil it.
229
+ */
230
+ close(query: FindQuery): boolean;
231
+ /**
232
+ * OPTIONAL: for a host whose terminals live inside a workspace (an IDE
233
+ * project). Makes sure one containing `query.cwd` is open, opening the cwd's
234
+ * git root when none is; idempotent. Throws `Error` on failure. Not called by
235
+ * `send`/`find`/`focus`, which target sessions that already exist. The one
236
+ * policy for when to call it is `ensureWorkspace` (`./open-workspace.ts`):
237
+ * when its `enabled` argument is true, it runs before `session`/`terminal`.
238
+ */
239
+ openWorkspace(query: OpenWorkspaceQuery): OpenedWorkspace;
240
+ }
241
+ /**
242
+ * The name of one operation a host may fulfil (`terminal`, `session`, `send`, ...).
243
+ */
244
+ export type HostPart = keyof HostParts;
245
+ /**
246
+ * One place a session can be opened: a name, the platforms it runs on, an availability
247
+ * probe, and whichever {@link HostParts} it can fulfil.
248
+ */
249
+ export interface Host extends Partial<HostParts> {
250
+ readonly name: HostName;
251
+ /**
252
+ * The OSes this host can run on, or `'all'`. Off-platform, `available()` is
253
+ * false and the host is reported as "not supported on <os>".
254
+ */
255
+ readonly platforms: readonly NodeJS.Platform[] | 'all';
256
+ /**
257
+ * Per unsupported OS, how to get this host anyway (e.g. `win32: 'run inside WSL'`).
258
+ * It is appended to "not supported on <os>". Only a hint: nothing probes for the
259
+ * route it names.
260
+ */
261
+ readonly platformHint?: Partial<Record<NodeJS.Platform, string>>;
262
+ /**
263
+ * True when this host's `send` normally reports `ack: true`, i.e. it can
264
+ * confirm the harness consumed the input itself. A caller's delivery
265
+ * receipt can treat a missing ack from such a host as
266
+ * evidence the message was not taken in, unless the receipt is flagged
267
+ * `uncertain` (the host itself cannot tell).
268
+ */
269
+ readonly acksSend?: boolean;
270
+ /** False when the host does not support this OS, or the binary or app it drives is not on this machine. */
271
+ available(): boolean;
272
+ }
273
+ /** Every {@link HostName} mapped to its {@link Host}. */
274
+ export type HostRegistry = Record<HostName, Host>;
275
+ /** Thrown when a host is asked for a part it does not fulfil. */
276
+ export declare class HostPartMissing extends Error {
277
+ /** The host that lacks the part. */
278
+ readonly host: HostName;
279
+ /** The part it lacks. */
280
+ readonly part: HostPart;
281
+ constructor(host: HostName, part: HostPart);
282
+ }
283
+ /** Whether `host` supports `platform` (default: this OS). */
284
+ export declare function supportsPlatform(host: Pick<Host, 'platforms'>, platform?: NodeJS.Platform): boolean;
285
+ /** The named part of `host`, or throw {@link HostPartMissing}. */
286
+ export declare function requirePart<P extends HostPart>(host: Host, part: P): HostParts[P];
287
+ /**
288
+ * Whether `bin` is on PATH, through {@link which} (`@osdlc/detect-harness`'s
289
+ * lookup; PATHEXT-aware on Windows). Spawns nothing.
290
+ */
291
+ export declare function onPath(bin: string): boolean;
292
+ /**
293
+ * Run `argv` and return its stdout, or null when it exits nonzero or the runner
294
+ * throws. The shared body of the hosts' "never throws" parts (`find`, `focus`,
295
+ * `capture`): a failed host call there is an ordinary "no session", not an error.
296
+ */
297
+ export declare function quietRun(runner: CommandRunner, argv: string[], opts: CommandRunOptions): string | null;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The host contract — somewhere on this machine a person's work can be put
3
+ * in front of them: a terminal tab, a workspace in a multiplexer, a desktop
4
+ * agent app.
5
+ *
6
+ * A host fulfils the contract in PARTS. Each part is an optional method, so
7
+ * an app that can open an agent session but not an arbitrary command (a
8
+ * desktop Claude or Codex) is still a host; a caller asks for the part it
9
+ * needs through {@link requirePart} and gets a named error when the host
10
+ * lacks it, never a silent fallback to a different host — the person chose
11
+ * this one.
12
+ */
13
+ import { ran, } from '@osdlc/command-seam';
14
+ import { which } from "./util.js";
15
+ import { z } from 'zod';
16
+ /**
17
+ * Every {@link HostName}, in a fixed order, for building the host registry and the
18
+ * `z.enum` schema below.
19
+ */
20
+ export const HOST_NAMES = [
21
+ 'terminal',
22
+ 'orca',
23
+ 'cmux',
24
+ 'tmux',
25
+ 'herdr',
26
+ 'otty',
27
+ 'wezterm',
28
+ 'vscode',
29
+ 'jetbrains',
30
+ ];
31
+ /**
32
+ * `z.enum` over {@link HOST_NAMES}: the one schema a `host`-shaped config
33
+ * field or CLI flag validates against, so a new host is added in exactly one
34
+ * place.
35
+ */
36
+ export const HostNameSchema = z.enum(HOST_NAMES);
37
+ /** The exit code a waiting host reports for a session it killed at the deadline (the `timeout(1)` convention). */
38
+ export const WAIT_TIMEOUT_EXIT = 124;
39
+ /**
40
+ * Hosts that can hold a launch until the session exits, when asked to ({@link WaitRequest}).
41
+ *
42
+ * @experimental to become a declaration on the host, like `acksSend`.
43
+ */
44
+ export function hostCanWait(host) {
45
+ return host === 'tmux';
46
+ }
47
+ /** What {@link HostPartMissing}'s message calls each part's action. */
48
+ const HOST_PART_ACTION = {
49
+ session: 'open an agent session',
50
+ terminal: 'run a command',
51
+ send: 'deliver a message into an already-open session',
52
+ find: 'find live sessions at a cwd',
53
+ focus: 'bring a session forward',
54
+ capture: 'read the recent screen text of a session',
55
+ openWorkspace: 'open a workspace for a directory',
56
+ close: 'close the pane of a session',
57
+ };
58
+ /** Thrown when a host is asked for a part it does not fulfil. */
59
+ export class HostPartMissing extends Error {
60
+ /** The host that lacks the part. */
61
+ host;
62
+ /** The part it lacks. */
63
+ part;
64
+ constructor(host, part) {
65
+ super(`host '${host}' cannot ${HOST_PART_ACTION[part]}`);
66
+ this.name = 'HostPartMissing';
67
+ this.host = host;
68
+ this.part = part;
69
+ }
70
+ }
71
+ /** Whether `host` supports `platform` (default: this OS). */
72
+ export function supportsPlatform(host, platform = process.platform) {
73
+ return host.platforms === 'all' || host.platforms.includes(platform);
74
+ }
75
+ /** The named part of `host`, or throw {@link HostPartMissing}. */
76
+ export function requirePart(host, part) {
77
+ const fn = host[part];
78
+ if (fn === undefined)
79
+ throw new HostPartMissing(host.name, part);
80
+ return fn.bind(host);
81
+ }
82
+ /**
83
+ * Whether `bin` is on PATH, through {@link which} (`@osdlc/detect-harness`'s
84
+ * lookup; PATHEXT-aware on Windows). Spawns nothing.
85
+ */
86
+ export function onPath(bin) {
87
+ return which(bin) !== null;
88
+ }
89
+ /**
90
+ * Run `argv` and return its stdout, or null when it exits nonzero or the runner
91
+ * throws. The shared body of the hosts' "never throws" parts (`find`, `focus`,
92
+ * `capture`): a failed host call there is an ordinary "no session", not an error.
93
+ */
94
+ export function quietRun(runner, argv, opts) {
95
+ try {
96
+ const res = runner.run(argv, opts);
97
+ return ran(res) ? res.stdout : null;
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }