@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
package/dist/harness.js
ADDED
|
@@ -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;
|