@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,610 @@
1
+ /**
2
+ * The harness axis — pure argv-building + naming for whichever coding-agent
3
+ * CLI a host opens a session in.
4
+ *
5
+ * `Harness` is independent of `HostName` (`contract.ts`): every host composes
6
+ * `harnessArgv` to run whichever harness the caller asked for.
7
+ *
8
+ * Not `@osdlc/agent-call`'s planner: that plans HEADLESS calls (`claude -p`,
9
+ * `codex exec`) whose output a program reads. A session is the opposite — a
10
+ * person sits in it — so the argv is the bare CLI with the first message,
11
+ * plus the Claude-only flags in {@link claudeSessionArgv}.
12
+ *
13
+ * This module also carries harness-specific machinery: codex's own on-disk
14
+ * session-index lookup (`codexSessionsDir` et al. — harness-specific, not
15
+ * host-specific, so it belongs on this axis), `wrapperSession`, the one body
16
+ * every wrapper host's `session()` shares ("build argv, run it, best-effort
17
+ * resolve a session id"), and `harnessNativeSend`, a per-harness
18
+ * message-injection mechanism independent of any host (`contract.ts`'s
19
+ * `Host.send` is the host-level counterpart).
20
+ */
21
+ import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
22
+ import { homedir } from 'node:os';
23
+ import { basename, join } from 'node:path';
24
+ import { z } from 'zod';
25
+ import { claudePluginArgv, claudeResumeFlag, modelArgs, } from '@osdlc/agent-call';
26
+ import { harnessId } from '@osdlc/detect-harness';
27
+ import { ran, resolveRunner } from '@osdlc/command-seam';
28
+ import { isRecord, parseJsonOrNull, realResolve, sleepSync, strOrNull, which } from "./util.js";
29
+ // `contract.ts` imports only types from this file (`Harness` for
30
+ // `SessionRequest.harness`), so the value imported back here makes no runtime cycle.
31
+ import { hostCanWait, } from "./contract.js";
32
+ /** A tool id: a lowercase letter, then lowercase letters, digits and hyphens. */
33
+ const HARNESS_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
34
+ /**
35
+ * The schema a harness field validates against. A tool id, not a closed list,
36
+ * since a caller may run a tool of its own.
37
+ */
38
+ export const HarnessSchema = z
39
+ .string()
40
+ .regex(HARNESS_ID_PATTERN, 'must be a tool id: lowercase letters, digits and hyphens');
41
+ /**
42
+ * `value` (what a host reports running in a pane) as a built-in harness id, or null when
43
+ * it names none. An alias or binary name maps to its id (`cursor-agent` → `cursor`), by
44
+ * `harnessId` from `@osdlc/detect-harness`.
45
+ */
46
+ export function harnessOrNull(value) {
47
+ return typeof value === 'string' ? (harnessId(value) ?? null) : null;
48
+ }
49
+ /** `<bin> <model flags> <injected> -- <prompt>`: the shape of every CLI whose positional is the first message. */
50
+ const guarded = (p) => [
51
+ p.bin,
52
+ ...p.model,
53
+ ...p.injected,
54
+ '--',
55
+ p.prompt,
56
+ ];
57
+ /**
58
+ * The interactive argv builders, keyed by tool id: the bare CLI with `prompt`
59
+ * as its first message, guarded behind a `--` end-of-options marker, so a
60
+ * prompt starting with `-` (or naming a subcommand) is never read as one.
61
+ * A tool missing here has no interactive launch (antigravity, a declared tool).
62
+ *
63
+ * - `claude`: its CLI parser (commander.js) treats `--` as the end of
64
+ * options. Its session flags ({@link claudeSessionArgv}) sit before the guard.
65
+ * - `codex`: its CLI parser (clap) honours `--`; without it a prompt starting
66
+ * with `-` is parsed as a flag, and one naming a subcommand (`login`) runs
67
+ * that subcommand. Checked against a live codex.
68
+ * - `cursor`: taken from Cursor's published CLI docs, not a live run; guarded
69
+ * the same way, since any argv-parsing CLI has the same ambiguity.
70
+ * - `pi`: `pi --help` documents `pi [options] [--] [@files...] [messages...]`,
71
+ * and a live run with `--` opened the session on the prompt.
72
+ * - `opencode`: its bare positional is the PROJECT directory, so the first
73
+ * message goes in `--prompt=<message>` (bound with `=` so a message starting
74
+ * with `-` is not read as a flag), per `opencode --help`.
75
+ */
76
+ const INTERACTIVE_ARGV = {
77
+ claude: (p) => [p.bin, ...p.model, ...claudeSessionArgv(p.claude), ...p.injected, '--', p.prompt],
78
+ codex: guarded,
79
+ cursor: guarded,
80
+ pi: guarded,
81
+ opencode: (p) => [p.bin, ...p.model, ...p.injected, `--prompt=${p.prompt}`],
82
+ };
83
+ /** Whether an interactive argv can be built for `tool`. */
84
+ export function hasInteractiveArgv(tool) {
85
+ return Object.hasOwn(INTERACTIVE_ARGV, tool);
86
+ }
87
+ /**
88
+ * The argv that opens `harness`, run as `binary` (a name on PATH or an
89
+ * absolute path), interactively with `prompt` as its first message
90
+ * ({@link INTERACTIVE_ARGV}). A resolved `choice` adds its launch mode,
91
+ * model and effort flags (`modelArgs`, `@osdlc/agent-call`) before the `--` guard; a
92
+ * tool with no model or effort flags wired throws on a choice rather than
93
+ * dropping it. Throws for a tool with no interactive launch.
94
+ *
95
+ * `injected` is the argv a per-launch observation adds (pi's
96
+ * `--session-id` and `-e <extension>`); it sits before the prompt.
97
+ */
98
+ export function harnessArgv(harness, binary, prompt, claude = {}, choice = {}, injected = []) {
99
+ const build = Object.hasOwn(INTERACTIVE_ARGV, harness) ? INTERACTIVE_ARGV[harness] : undefined;
100
+ if (build === undefined)
101
+ throw new Error(`'${harness}' has no interactive launch`);
102
+ return build({
103
+ bin: binary,
104
+ prompt,
105
+ model: modelArgs(harness, choice),
106
+ claude,
107
+ injected,
108
+ });
109
+ }
110
+ /** Session flags only `claude` takes; a caller can store them to resume with the same flags. */
111
+ export const ClaudeSessionFlagsSchema = z.object({
112
+ /** Text for `claude --append-system-prompt`. */
113
+ appendSystemPrompt: z.string().optional(),
114
+ /** Session-only plugins (`claude --plugin-dir` / `--settings`). */
115
+ plugins: z.object({
116
+ dirs: z.array(z.string()),
117
+ disable: z.array(z.string()),
118
+ }).optional(),
119
+ /**
120
+ * The shell command Claude's hooks run (`<caller> hook --harness claude ...`);
121
+ * set, it adds the hook settings of {@link claudeHookSettings} to every launch and
122
+ * resume, since `--settings` is not persisted with the session.
123
+ */
124
+ hookCommand: z.string().optional(),
125
+ });
126
+ /**
127
+ * The Claude Code hook events the caller listens to. Everything is async except
128
+ * `SessionEnd`, which has to finish its write before Claude exits (its budget
129
+ * is the hook's own `timeout`). `PreToolUse` is matched to `AskUserQuestion`
130
+ * alone: the question is what puts the agent in `waiting_for_input` there, and
131
+ * a hook on every tool call would cost a process each. `PermissionRequest` and
132
+ * `StopFailure` mark a tool-permission prompt and a turn that ended on an API error.
133
+ */
134
+ export const CLAUDE_HOOK_EVENTS = {
135
+ SessionStart: { async: true },
136
+ UserPromptSubmit: { async: true },
137
+ PreToolUse: { async: true, matcher: 'AskUserQuestion' },
138
+ PermissionRequest: { async: true },
139
+ PostToolUse: { async: true },
140
+ PostToolUseFailure: { async: true },
141
+ Stop: { async: true },
142
+ StopFailure: { async: true },
143
+ SubagentStart: { async: true },
144
+ SubagentStop: { async: true },
145
+ Notification: { async: true },
146
+ PreCompact: { async: true },
147
+ PostModelSwitch: { async: true },
148
+ SessionEnd: { async: false },
149
+ };
150
+ /** Seconds `SessionEnd`'s hook may run; Claude's default budget for it is 1.5s. */
151
+ const SESSION_END_HOOK_TIMEOUT_S = 10;
152
+ /** The `hooks` settings object that runs `command` for every {@link CLAUDE_HOOK_EVENTS} event. */
153
+ export function claudeHookSettings(command) {
154
+ const hooks = {};
155
+ for (const [name, spec] of Object.entries(CLAUDE_HOOK_EVENTS)) {
156
+ hooks[name] = [
157
+ {
158
+ ...(spec.matcher === undefined ? {} : { matcher: spec.matcher }),
159
+ hooks: [
160
+ spec.async
161
+ ? { type: 'command', command, async: true }
162
+ : { type: 'command', command, timeout: SESSION_END_HOOK_TIMEOUT_S },
163
+ ],
164
+ },
165
+ ];
166
+ }
167
+ return { hooks };
168
+ }
169
+ /**
170
+ * The plugin argv with the hook settings merged into its `--settings` value, or
171
+ * a `--settings` of their own when the plugins brought none: Claude is handed
172
+ * one `--settings`, never two that could shadow each other.
173
+ */
174
+ function withHookSettings(pluginArgv, command) {
175
+ if (command === undefined)
176
+ return pluginArgv;
177
+ const hooks = claudeHookSettings(command);
178
+ const at = pluginArgv.indexOf('--settings');
179
+ if (at === -1)
180
+ return [...pluginArgv, '--settings', JSON.stringify(hooks)];
181
+ const existing = JSON.parse(pluginArgv[at + 1]);
182
+ const merged = [...pluginArgv];
183
+ // The plugin settings are only `enabledPlugins`, so the spread cannot lose a
184
+ // user key; if they ever carry `hooks`, ours replace them.
185
+ merged[at + 1] = JSON.stringify({ ...existing, ...hooks });
186
+ return merged;
187
+ }
188
+ /** The argv for {@link ClaudeLaunchFlags}, shared by the launches and {@link claudeResumeArgv}. */
189
+ export function claudeSessionArgv(flags) {
190
+ const pluginArgv = flags.plugins === undefined ? [] : claudePluginArgv(flags.plugins);
191
+ return [
192
+ ...(flags.harnessSessionId === undefined ? [] : ['--session-id', flags.harnessSessionId]),
193
+ ...withHookSettings(pluginArgv, flags.hookCommand),
194
+ ...(flags.appendSystemPrompt === undefined
195
+ ? []
196
+ : ['--append-system-prompt', flags.appendSystemPrompt]),
197
+ ];
198
+ }
199
+ /**
200
+ * `claude --resume=<id>` plus the launch's session flags, so a resumed session
201
+ * keeps its plugins and prompt pin. Does not validate `id`; `claudeResumeFlag`
202
+ * (`@osdlc/agent-call`) binds it with `=`.
203
+ */
204
+ export function claudeResumeArgv(id, flags = {}) {
205
+ return [claudeResumeFlag(id), ...claudeSessionArgv(flags)];
206
+ }
207
+ /**
208
+ * Whether `harness`'s CLI, `binary` (a name on PATH or an absolute path), is
209
+ * on PATH. A synchronous lookup that spawns nothing. Only a PATH hit counts: an
210
+ * installed app bundle (Cursor.app) gives a session nothing to run.
211
+ */
212
+ export function harnessAvailable(_harness, binary) {
213
+ return which(binary) !== null;
214
+ }
215
+ /**
216
+ * `codex queue --thread <id> --message <text>` — the FLAGS are verified (`codex queue --help`-shaped syntax), but
217
+ * whether this actually reaches a session that is CURRENTLY OPEN in a live
218
+ * TUI, versus only queuing for a future `codex resume`, is
219
+ * unresolved — no bundled source to check, no live test run. Through
220
+ * the command seam, `opts.runner` injectable so a test never shells out for
221
+ * real (`cwd: process.cwd()` — `queue` addresses by `--thread <id>`, not by
222
+ * working directory, so any real directory does). `ack` is always
223
+ * false regardless of exit code: a clean exit only proves the daemon
224
+ * ACCEPTED the queue request, never that a live session consumed it — the
225
+ * same honesty `hosts/tmux.ts`'s `send` applies for a different reason (no
226
+ * confirmation mechanism at all, versus this one's confirmed-but-unresolved
227
+ * mechanism).
228
+ */
229
+ function codexNativeSend(sessionId, text, opts) {
230
+ const runner = resolveRunner('codex', opts.runner);
231
+ const res = runner.run(['queue', '--thread', sessionId, '--message', text], {
232
+ cwd: process.cwd(),
233
+ timeoutMs: 30_000,
234
+ });
235
+ return { accepted: ran(res), ack: false };
236
+ }
237
+ /**
238
+ * Per-harness native message injection, independent of any host — a caller's
239
+ * "native send" step (`docs/planning/research/orca.md`'s "Recommended
240
+ * shape" section), distinct from `Host.send` (`contract.ts`'s `SendRequest`):
241
+ * this reaches the harness's OWN process/daemon directly, never through
242
+ * whichever terminal happens to host it. Only `codex` gets a real entry here
243
+ * — every other harness is simply ABSENT from this map, meaning "no known
244
+ * native-send mechanism," not "not implemented yet": `claude` has no way to
245
+ * inject into a running session at all (it has its own separate `--bg`/`attach` background-session mechanism
246
+ * instead, unrelated to sending into an interactive one); `cursor`'s
247
+ * published docs describe no such surface; `pi`'s only send-like channel,
248
+ * `--mode rpc`, requires the dispatcher to have spawned and held that
249
+ * process's own stdin from the very start — not a way to reach an already-running independent `pi` TUI process, and
250
+ * building that headless-RPC surface is out of scope here besides.
251
+ *
252
+ * Deciding when to PREFER native-send over host `send` is left to the caller;
253
+ * `harnessNativeSend` just needs to exist and be callable.
254
+ */
255
+ const NATIVE_SEND = {
256
+ codex: codexNativeSend,
257
+ };
258
+ /** `NATIVE_SEND[harness]`, called if present; `null` — not a thrown error — when the harness has no known native-send mechanism at all (see the map's own doc for which). */
259
+ export function harnessNativeSend(harness, sessionId, text, opts = {}) {
260
+ const fn = Object.hasOwn(NATIVE_SEND, harness) ? NATIVE_SEND[harness] : undefined;
261
+ return fn === undefined ? null : fn(sessionId, text, opts);
262
+ }
263
+ /** `~/.codex/sessions` (or under an injected `codexHome`, for tests). */
264
+ export function codexSessionsDir(codexHome) {
265
+ return join(codexHome ?? join(homedir(), '.codex'), 'sessions');
266
+ }
267
+ /**
268
+ * Recursively finds every `rollout-*.jsonl` file under `dir`. Missing/
269
+ * unreadable dirs yield `[]`. Exported so a caller that needs the transcript
270
+ * path can reuse this walk; {@link findCodexSessionId} returns only the id.
271
+ */
272
+ export function walkRolloutFiles(dir) {
273
+ let entries;
274
+ try {
275
+ entries = readdirSync(dir, { withFileTypes: true });
276
+ }
277
+ catch {
278
+ return [];
279
+ }
280
+ const files = [];
281
+ for (const entry of entries) {
282
+ const full = join(dir, entry.name);
283
+ if (entry.isDirectory()) {
284
+ files.push(...walkRolloutFiles(full));
285
+ }
286
+ else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name)) {
287
+ files.push(full);
288
+ }
289
+ }
290
+ return files;
291
+ }
292
+ /** The `cwd`/session-id pair recorded in a rollout file's first (`session_meta`) line, or null if unparsable. */
293
+ export function parseRolloutMeta(firstLine) {
294
+ const parsed = parseJsonOrNull(firstLine.trim());
295
+ const payload = isRecord(parsed) ? parsed['payload'] : null;
296
+ if (!isRecord(payload))
297
+ return null;
298
+ const cwd = strOrNull(payload['cwd']);
299
+ const sessionId = strOrNull(payload['session_id']) ?? strOrNull(payload['id']);
300
+ return cwd === null || sessionId === null ? null : { cwd, sessionId };
301
+ }
302
+ /**
303
+ * How far before the launch instant a rollout's creation may fall and still
304
+ * count as this launch's: the file name has one-second resolution, and file
305
+ * timestamps come from a coarser clock than `Date.now()`, so a file created
306
+ * just after the launch can read a little before it.
307
+ */
308
+ export const ROLLOUT_CREATED_MARGIN_MS = 2_000;
309
+ const ROLLOUT_NAME_TIME = /^rollout-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-/;
310
+ /**
311
+ * How far a rollout's name time can sit from its real creation instant: codex
312
+ * stamps the name from its own local clock, and two UTC offsets differ by at
313
+ * most 26 hours (UTC-12 to UTC+14).
314
+ */
315
+ const ROLLOUT_NAME_ZONE_SLACK_MS = 26 * 3_600_000;
316
+ /**
317
+ * The instant in a rollout file name, `rollout-<YYYY-MM-DDTHH-MM-SS>-<uuid>.jsonl`,
318
+ * or null when the name has none. Read as LOCAL time: codex stamps the name
319
+ * (and the `yyyy/mm/dd` buckets) from its local clock, so this is exact only
320
+ * when codex ran in the same time zone as this process.
321
+ */
322
+ export function rolloutNameTimeMs(path) {
323
+ const m = ROLLOUT_NAME_TIME.exec(basename(path));
324
+ if (m === null)
325
+ return null;
326
+ const fields = m.slice(1);
327
+ const [y = 0, mo = 0, d = 0, h = 0, mi = 0, s = 0] = fields.map(Number);
328
+ const date = new Date(y, mo - 1, d, h, mi, s);
329
+ const pad = (n, width = 2) => String(n).padStart(width, '0');
330
+ const back = [
331
+ pad(date.getFullYear(), 4),
332
+ pad(date.getMonth() + 1),
333
+ pad(date.getDate()),
334
+ pad(date.getHours()),
335
+ pad(date.getMinutes()),
336
+ pad(date.getSeconds()),
337
+ ];
338
+ // `Date` rolls an out-of-range field over (month 13, hour 25); such a name has no instant.
339
+ return back.join('-') === fields.join('-') ? date.getTime() : null;
340
+ }
341
+ /**
342
+ * When a rollout file was created: its birthtime when the file system records
343
+ * one (a birthtime of 0 means it does not), else the time in its name, else its
344
+ * mtime. The name is read in this process's time zone ({@link rolloutNameTimeMs}),
345
+ * so it is off by the zone difference when codex ran elsewhere; a name time
346
+ * later than the file's last write (beyond the margin) cannot be right and
347
+ * falls through to the mtime.
348
+ */
349
+ export function rolloutCreatedMs(path, stats) {
350
+ if (stats.birthtimeMs > 0)
351
+ return stats.birthtimeMs;
352
+ const named = rolloutNameTimeMs(path);
353
+ return named !== null && named <= stats.mtimeMs + ROLLOUT_CREATED_MARGIN_MS
354
+ ? named
355
+ : stats.mtimeMs;
356
+ }
357
+ /** Whether two paths name the same place once symlinks are resolved. */
358
+ function samePlace(a, b) {
359
+ return a === b || realResolve(a) === realResolve(b);
360
+ }
361
+ /**
362
+ * The session id of the newest candidate (by creation instant) created no more
363
+ * than {@link ROLLOUT_CREATED_MARGIN_MS} before `sinceMs` whose rollout `cwd`
364
+ * names the same directory as `opts.cwd` (compared as real paths, so a cwd
365
+ * reached through a symlink matches), or null when none qualifies. Takes
366
+ * canned candidates, so a test needs no rollout files.
367
+ */
368
+ export function newestRolloutSessionIdSince(candidates, opts) {
369
+ const cutoff = opts.sinceMs - ROLLOUT_CREATED_MARGIN_MS;
370
+ let best = null;
371
+ for (const c of candidates) {
372
+ if (c.createdMs < cutoff)
373
+ continue;
374
+ const meta = parseRolloutMeta(c.firstLine);
375
+ if (meta === null || !samePlace(meta.cwd, opts.cwd))
376
+ continue;
377
+ if (best === null || c.createdMs > best.createdMs)
378
+ best = { sessionId: meta.sessionId, createdMs: c.createdMs };
379
+ }
380
+ return best?.sessionId ?? null;
381
+ }
382
+ /** The first line of the file at `path` (without its `\n`), read no further than it. */
383
+ function readFirstLine(path) {
384
+ const fd = openSync(path, 'r');
385
+ try {
386
+ let buf = Buffer.alloc(4096);
387
+ let len = 0;
388
+ for (;;) {
389
+ const n = readSync(fd, buf, len, buf.length - len, len);
390
+ const end = buf.indexOf(0x0a, len);
391
+ len += n;
392
+ if (end !== -1 && end < len)
393
+ return buf.toString('utf-8', 0, end);
394
+ if (n === 0)
395
+ return buf.toString('utf-8', 0, len);
396
+ if (len === buf.length)
397
+ buf = Buffer.concat([buf, Buffer.alloc(buf.length)]);
398
+ }
399
+ }
400
+ finally {
401
+ closeSync(fd);
402
+ }
403
+ }
404
+ /**
405
+ * The `yyyy/mm/dd` folders under `sessionsDir` a rollout created between
406
+ * `fromMs` and `toMs` can sit in. codex names a folder by its own local date,
407
+ * which can differ from this process's by up to {@link ROLLOUT_NAME_ZONE_SLACK_MS},
408
+ * so the range is widened by that much on each side (one or two days).
409
+ */
410
+ export function rolloutDayDirs(sessionsDir, fromMs, toMs) {
411
+ const from = new Date(fromMs - ROLLOUT_NAME_ZONE_SLACK_MS);
412
+ const to = new Date(toMs + ROLLOUT_NAME_ZONE_SLACK_MS);
413
+ const last = new Date(to.getFullYear(), to.getMonth(), to.getDate()).getTime();
414
+ const pad = (n) => String(n).padStart(2, '0');
415
+ const dirs = [];
416
+ for (let day = new Date(from.getFullYear(), from.getMonth(), from.getDate()); day.getTime() <= last; day = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1)) {
417
+ dirs.push(join(sessionsDir, String(day.getFullYear()), pad(day.getMonth() + 1), pad(day.getDate())));
418
+ }
419
+ return dirs;
420
+ }
421
+ /**
422
+ * The codex session started in `cwd` at or after `sinceMs`
423
+ * ({@link newestRolloutSessionIdSince}), or null.
424
+ *
425
+ * Reads codex's own on-disk session index. `~/.codex/session_index.jsonl` is
426
+ * NOT used: it only records threads that were given a name. Instead this scans
427
+ * the rollout files, `~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-*.jsonl`, in
428
+ * the date folders from the launch through now ({@link rolloutDayDirs}), for
429
+ * the session created at or after the launch whose first line, a
430
+ * `session_meta` record
431
+ * (`{"type":"session_meta","payload":{"session_id":"<uuid>","cwd":"<path>",…}}`),
432
+ * names `cwd`. The cwds are compared as real paths: codex records its
433
+ * process's directory with symlinks resolved.
434
+ *
435
+ * Selection is by the rollout's creation instant ({@link rolloutCreatedMs}),
436
+ * not its mtime: another codex session in the same cwd keeps appending to its
437
+ * own rollout, so its mtime would pass a launch-time cutoff. This is still
438
+ * best-effort: two codex sessions launched from the same cwd within a couple of
439
+ * seconds of each other (or one launched in that cwd while a waiting host holds
440
+ * ours) can be mistaken for each other. No local signal tells them apart.
441
+ *
442
+ * With `waitMs`, keeps looking that long for a rollout file codex has not
443
+ * written yet; a file ruled out once (created too early, or another cwd) is not
444
+ * read again. `listFiles` and `codexHome` are injectable so a test needs no
445
+ * real `~/.codex/`.
446
+ */
447
+ export function findCodexSessionId(sinceMs, cwd, opts = {}) {
448
+ const now = opts.now ?? Date.now;
449
+ const listFiles = opts.listFiles ??
450
+ (() => rolloutDayDirs(codexSessionsDir(opts.codexHome), sinceMs, now()).flatMap(walkRolloutFiles));
451
+ const cutoff = sinceMs - ROLLOUT_CREATED_MARGIN_MS;
452
+ const realCwd = realResolve(cwd);
453
+ const ruledOut = new Set();
454
+ const look = () => {
455
+ const candidates = [];
456
+ for (const path of listFiles()) {
457
+ if (ruledOut.has(path))
458
+ continue;
459
+ // The name time can be off by a time-zone difference (`rolloutCreatedMs`), so
460
+ // only a name earlier than that rules the file out unread.
461
+ const named = rolloutNameTimeMs(path);
462
+ if (named !== null && named < cutoff - ROLLOUT_NAME_ZONE_SLACK_MS) {
463
+ ruledOut.add(path);
464
+ continue;
465
+ }
466
+ try {
467
+ const createdMs = rolloutCreatedMs(path, statSync(path));
468
+ if (createdMs < cutoff) {
469
+ ruledOut.add(path);
470
+ continue;
471
+ }
472
+ const firstLine = readFirstLine(path);
473
+ // A first line not yet complete parses as null and is read again on the next look.
474
+ const meta = parseRolloutMeta(firstLine);
475
+ if (meta !== null && !samePlace(meta.cwd, realCwd))
476
+ ruledOut.add(path);
477
+ else
478
+ candidates.push({ firstLine, createdMs });
479
+ }
480
+ catch {
481
+ // Raced away (deleted) or unreadable — skip, not fatal.
482
+ }
483
+ }
484
+ return newestRolloutSessionIdSince(candidates, { sinceMs, cwd: realCwd });
485
+ };
486
+ return pollFor(look, opts);
487
+ }
488
+ /** `look()` until it gives a value or `opts.waitMs` runs out; null then. */
489
+ function pollFor(look, opts) {
490
+ const now = opts.now ?? Date.now;
491
+ const sleep = opts.sleep ?? sleepSync;
492
+ const pollMs = opts.pollMs ?? 250;
493
+ const deadline = now() + (opts.waitMs ?? 0);
494
+ for (;;) {
495
+ const found = look();
496
+ if (found !== null)
497
+ return found;
498
+ const left = deadline - now();
499
+ if (left <= 0)
500
+ return null;
501
+ sleep(Math.min(pollMs, left));
502
+ }
503
+ }
504
+ /**
505
+ * Only `codex` has a resolver. `claude` needs none: a caller presets the
506
+ * session id with `claude --session-id` (`ClaudeLaunchFlags.harnessSessionId`)
507
+ * and so knows it already. `cursor` has no local session index or rollout file
508
+ * to look an id up in.
509
+ */
510
+ const SESSION_ID_RESOLVERS = {
511
+ codex: findCodexSessionId,
512
+ };
513
+ /** How long {@link wrapperSession} waits for the agent to record its session after a host returned early. */
514
+ export const SESSION_ID_WAIT_MS = 3_000;
515
+ /**
516
+ * `argv` carrying `env`, so the vars reach the process whatever terminal runs it.
517
+ * POSIX: prefixed with `env [-u K ...] [K=V ...]`. win32 has no `env`, so the
518
+ * argv becomes a `powershell -Command` that sets (`$env:K='V'`) or clears
519
+ * (`Remove-Item Env:K`) each variable and then runs the original command.
520
+ *
521
+ * Windows limits (not run on Windows): `powershell` is Windows PowerShell 5.1,
522
+ * which passes an argument containing `"` to a native program without escaping
523
+ * it (the program sees it split, or with the quotes gone) and drops an empty
524
+ * argument; and `-Command` turns the program's exit code into 0 or 1. Every
525
+ * word of the command is single-quoted, even one that needs no quoting: Windows
526
+ * PowerShell 5.1 drops a bare `--` (the guard `harnessArgv` puts before the
527
+ * prompt) and splits a bare `-name.value`, which a quoted word avoids.
528
+ */
529
+ export function withEnv(argv, env, platform = process.platform) {
530
+ const entries = Object.entries(env ?? {});
531
+ if (entries.length === 0)
532
+ return argv;
533
+ if (platform === 'win32') {
534
+ for (const [k] of entries) {
535
+ // The name goes into a PowerShell command unquoted (set branch), so it must be a plain identifier.
536
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k))
537
+ throw new Error(`invalid environment variable name: ${k}`);
538
+ }
539
+ const sets = entries.map(([k, v]) => v === undefined
540
+ ? `Remove-Item -ErrorAction SilentlyContinue ${powershellLiteral(`Env:${k}`)}`
541
+ : `$env:${k} = ${powershellLiteral(v)}`);
542
+ return [
543
+ 'powershell',
544
+ '-NoProfile',
545
+ '-Command',
546
+ `${sets.join('; ')}; & ${argv.map(powershellLiteral).join(' ')}`,
547
+ ];
548
+ }
549
+ const unset = [];
550
+ const pairs = [];
551
+ for (const [k, v] of entries) {
552
+ if (v === undefined)
553
+ unset.push('-u', k);
554
+ else
555
+ pairs.push(`${k}=${v}`);
556
+ }
557
+ return ['env', ...unset, ...pairs, ...argv];
558
+ }
559
+ /** `word` as a single-quoted PowerShell literal, each single quote (typographic ones too) doubled. */
560
+ function powershellLiteral(word) {
561
+ return `'${word.replace(/['‘’‚‛]/g, (q) => q + q)}'`;
562
+ }
563
+ /**
564
+ * The shared body of every wrapper host's `session()`: build `harnessArgv`
565
+ * under `req.env`, run it, best-effort resolve a session id.
566
+ *
567
+ * Most hosts return as soon as the pane exists, before the agent has recorded
568
+ * its session, so after a clean launch the lookup keeps trying for
569
+ * `opts.waitMs` (default {@link SESSION_ID_WAIT_MS}). With `opts.hostWaited`
570
+ * (the host held the launch until the agent exited) it looks once.
571
+ *
572
+ * @param terminal The calling host's own `terminal()`, which opens its
573
+ * terminal or workspace.
574
+ */
575
+ export function wrapperSession(terminal, req, opts = {}) {
576
+ const sinceMs = (opts.now ?? Date.now)();
577
+ const argv = [
578
+ ...(req.wrap ?? []),
579
+ ...harnessArgv(req.harness, req.binary, req.prompt, req, req, req.injectedArgv),
580
+ ];
581
+ const exitCode = terminal({
582
+ cwd: req.cwd,
583
+ argv: withEnv(argv, req.env),
584
+ title: req.title,
585
+ sessionId: req.sessionId,
586
+ ...(req.wait !== undefined ? { wait: req.wait } : {}),
587
+ });
588
+ const resolver = Object.hasOwn(SESSION_ID_RESOLVERS, req.harness)
589
+ ? SESSION_ID_RESOLVERS[req.harness]
590
+ : undefined;
591
+ if (resolver === undefined)
592
+ return { exitCode, harnessSessionId: null };
593
+ // A failed launch started no agent, and a held one has written its file: look once.
594
+ const waitMs = exitCode !== 0 || opts.hostWaited === true ? 0 : (opts.waitMs ?? SESSION_ID_WAIT_MS);
595
+ return { exitCode, harnessSessionId: resolver(sinceMs, req.cwd, { ...opts, waitMs }) };
596
+ }
597
+ /**
598
+ * The `session` part of a host whose `terminal` part opens the harness: assign
599
+ * it as `session: sessionViaTerminal`. `this` is the host the part is called on,
600
+ * so the host's own `terminal` runs; a host that has none throws rather than
601
+ * start nothing. A host honouring `req.wait` returns only once the agent
602
+ * exited, so the session-id lookup does not wait.
603
+ */
604
+ export function sessionViaTerminal(req) {
605
+ const terminal = this.terminal;
606
+ if (terminal === undefined)
607
+ throw new Error('host has no terminal part to open a session with');
608
+ const hostWaited = req.wait !== undefined && this.name !== undefined && hostCanWait(this.name);
609
+ return wrapperSession((r) => terminal.call(this, r), req, { hostWaited });
610
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * cmux: a new workspace on `cwd` whose first terminal runs a command, plus
3
+ * send/find/focus/capture. Research: `docs/planning/research/cmux.md`
4
+ * (cmux 0.64.17).
5
+ *
6
+ * `workspace create` is the canonical spelling; `new-workspace` is the legacy
7
+ * alias and prints a deprecation notice on every call.
8
+ *
9
+ * cmux cannot bind a pane to a caller session id: `workspace create` has no
10
+ * tag the list commands read back (the shapes are docs-only), so every part
11
+ * resolves by `cwd` and ignores `sessionId`. `close` therefore acts only when
12
+ * exactly one workspace is at the cwd.
13
+ *
14
+ * Socket access: send/focus/capture and the workspace-list half of `find` use
15
+ * the cmux control socket, which by default (`automation.socketControlMode`
16
+ * "cmuxOnly") rejects callers not started inside cmux ("Access denied - only
17
+ * processes started inside cmux can connect"; observed). Published docs
18
+ * (the cmux.json schema and `cmux --help` "Socket Auth"): set
19
+ * `automation.socketControlMode` in ~/.config/cmux/cmux.json to `automation`,
20
+ * `allowAll` or `password`; in password mode export `CMUX_SOCKET_PASSWORD` (or
21
+ * pass `--password`). When denied these parts report "not accepted / false /
22
+ * null" without throwing; `find` still answers from the hook store.
23
+ */
24
+ import { type CommandRunner } from '@osdlc/command-seam';
25
+ import { type CommandRequest, type FoundSession, type Host } from '../contract.ts';
26
+ /** Argv for `cmux workspace create` — pure, so a test can pin it. */
27
+ export declare function cmuxCreateArgv(req: CommandRequest): string[];
28
+ /** Argv for `cmux sessions list` (hook store, no socket). `--cwd` is a text filter, so results are re-filtered exactly. */
29
+ export declare function cmuxSessionsArgv(cwd: string): string[];
30
+ /** Argv for `cmux --json workspace list` (needs the socket). */
31
+ export declare function cmuxWorkspaceListArgv(): string[];
32
+ /**
33
+ * `cmux send` types the text (its `\n`, `\r` and `\t` escape sequences are
34
+ * interpreted as keys, so `send` refuses text containing them,
35
+ * {@link cmuxTextIsUnsafe}); a separate `send-key enter` submits.
36
+ */
37
+ export declare function cmuxSendArgv(handle: string, text: string): string[];
38
+ export declare function cmuxSendEnterArgv(handle: string): string[];
39
+ export declare function cmuxFocusArgv(handle: string): string[];
40
+ /** `cmux workspace close <workspace>` (per `cmux workspace --help`); closes the whole workspace. */
41
+ export declare function cmuxCloseArgv(handle: string): string[];
42
+ export declare function cmuxReadScreenArgv(handle: string): string[];
43
+ /**
44
+ * Whether `text` would be typed as a key press rather than as text: a real
45
+ * newline, or a backslash-n / -r / -t sequence, which `cmux send` turns into
46
+ * Enter or Tab. cmux's `set-buffer` / `paste-buffer` exist, but whether a
47
+ * paste is bracketed is not observable without the control socket, so such text
48
+ * is refused rather than risking a partial prompt being submitted.
49
+ */
50
+ export declare function cmuxTextIsUnsafe(text: string): boolean;
51
+ /**
52
+ * Parses `cmux sessions list --json`: `{sessions: [{agent, cwd, workspace_id,
53
+ * surface_id, ...}]}`. DOCS-ONLY field names (the store was empty when
54
+ * observed; only the envelope was seen). Records are saved hook state, so a
55
+ * match can be stale; a failed send then falls through to the next route.
56
+ */
57
+ export declare function parseCmuxSessions(stdout: string, cwd: string): FoundSession[];
58
+ /** Parses `cmux --json workspace list`: an array (or `{workspaces}`) of `{ref|id, cwd|current_directory}`. DOCS-ONLY shape. */
59
+ export declare function parseCmuxWorkspaces(stdout: string, cwd: string): FoundSession[];
60
+ /**
61
+ * The cmux host: drives the cmux app through the `cmux` binary, which must be on PATH.
62
+ * darwin only. Keeps no state of its own; every part resolves by `cwd`.
63
+ */
64
+ export declare function makeCmuxHost(opts: {
65
+ runner?: CommandRunner | undefined;
66
+ available?: (() => boolean) | undefined;
67
+ }): Host;