@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.
- package/CHANGELOG.md +127 -0
- package/CONTRIBUTING.md +44 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +181 -2
- package/SECURITY.md +41 -0
- package/dist/contract.d.ts +297 -0
- package/dist/contract.js +102 -0
- package/dist/harness.d.ts +305 -0
- package/dist/harness.js +610 -0
- package/dist/hosts/cmux.d.ts +67 -0
- package/dist/hosts/cmux.js +252 -0
- package/dist/hosts/herdr.d.ts +78 -0
- package/dist/hosts/herdr.js +324 -0
- package/dist/hosts/jetbrains.d.ts +98 -0
- package/dist/hosts/jetbrains.js +353 -0
- package/dist/hosts/orca.d.ts +67 -0
- package/dist/hosts/orca.js +292 -0
- package/dist/hosts/otty.d.ts +70 -0
- package/dist/hosts/otty.js +233 -0
- package/dist/hosts/registry.d.ts +50 -0
- package/dist/hosts/registry.js +100 -0
- package/dist/hosts/session-panes.d.ts +29 -0
- package/dist/hosts/session-panes.js +49 -0
- package/dist/hosts/terminal.d.ts +23 -0
- package/dist/hosts/terminal.js +47 -0
- package/dist/hosts/tmux.d.ts +145 -0
- package/dist/hosts/tmux.js +409 -0
- package/dist/hosts/vscode-client.d.ts +81 -0
- package/dist/hosts/vscode-client.js +124 -0
- package/dist/hosts/vscode.d.ts +35 -0
- package/dist/hosts/vscode.js +127 -0
- package/dist/hosts/wezterm.d.ts +78 -0
- package/dist/hosts/wezterm.js +405 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +61 -0
- package/dist/open-workspace.d.ts +9 -0
- package/dist/open-workspace.js +12 -0
- package/dist/util.d.ts +50 -0
- package/dist/util.js +108 -0
- package/package.json +76 -3
|
@@ -0,0 +1,252 @@
|
|
|
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 { commandString, failureText, ran, resolveRunner, } from '@osdlc/command-seam';
|
|
25
|
+
import { isRecord, nonEmptyString, parseJsonOrNull, realResolve } from "../util.js";
|
|
26
|
+
import { onPath, quietRun, supportsPlatform, } from "../contract.js";
|
|
27
|
+
import { harnessOrNull, sessionViaTerminal } from "../harness.js";
|
|
28
|
+
/** Argv for `cmux workspace create` — pure, so a test can pin it. */
|
|
29
|
+
export function cmuxCreateArgv(req) {
|
|
30
|
+
return [
|
|
31
|
+
'workspace',
|
|
32
|
+
'create',
|
|
33
|
+
'--name',
|
|
34
|
+
req.title,
|
|
35
|
+
'--cwd',
|
|
36
|
+
req.cwd,
|
|
37
|
+
'--command',
|
|
38
|
+
commandString(req.argv),
|
|
39
|
+
// Per `cmux new-workspace --help` the default is already false; explicit so the person's view is never taken.
|
|
40
|
+
'--focus',
|
|
41
|
+
'false',
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
/** Argv for `cmux sessions list` (hook store, no socket). `--cwd` is a text filter, so results are re-filtered exactly. */
|
|
45
|
+
export function cmuxSessionsArgv(cwd) {
|
|
46
|
+
return ['sessions', 'list', '--cwd', cwd, '--json'];
|
|
47
|
+
}
|
|
48
|
+
/** Argv for `cmux --json workspace list` (needs the socket). */
|
|
49
|
+
export function cmuxWorkspaceListArgv() {
|
|
50
|
+
return ['--json', 'workspace', 'list'];
|
|
51
|
+
}
|
|
52
|
+
/** `workspace/surface` handle -> flags targeting that terminal explicitly (never the caller's own). */
|
|
53
|
+
function targetFlags(handle) {
|
|
54
|
+
const [workspace, surface] = handle.split('/');
|
|
55
|
+
return [
|
|
56
|
+
'--workspace',
|
|
57
|
+
workspace ?? '',
|
|
58
|
+
...(surface !== undefined && surface !== '' ? ['--surface', surface] : []),
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `cmux send` types the text (its `\n`, `\r` and `\t` escape sequences are
|
|
63
|
+
* interpreted as keys, so `send` refuses text containing them,
|
|
64
|
+
* {@link cmuxTextIsUnsafe}); a separate `send-key enter` submits.
|
|
65
|
+
*/
|
|
66
|
+
export function cmuxSendArgv(handle, text) {
|
|
67
|
+
return ['send', ...targetFlags(handle), '--', text];
|
|
68
|
+
}
|
|
69
|
+
export function cmuxSendEnterArgv(handle) {
|
|
70
|
+
return ['send-key', ...targetFlags(handle), '--', 'enter'];
|
|
71
|
+
}
|
|
72
|
+
export function cmuxFocusArgv(handle) {
|
|
73
|
+
return ['workspace', 'select', handle.split('/')[0] ?? ''];
|
|
74
|
+
}
|
|
75
|
+
/** `cmux workspace close <workspace>` (per `cmux workspace --help`); closes the whole workspace. */
|
|
76
|
+
export function cmuxCloseArgv(handle) {
|
|
77
|
+
return ['workspace', 'close', handle.split('/')[0] ?? ''];
|
|
78
|
+
}
|
|
79
|
+
export function cmuxReadScreenArgv(handle) {
|
|
80
|
+
return ['read-screen', ...targetFlags(handle)];
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Whether `text` would be typed as a key press rather than as text: a real
|
|
84
|
+
* newline, or a backslash-n / -r / -t sequence, which `cmux send` turns into
|
|
85
|
+
* Enter or Tab. cmux's `set-buffer` / `paste-buffer` exist, but whether a
|
|
86
|
+
* paste is bracketed is not observable without the control socket, so such text
|
|
87
|
+
* is refused rather than risking a partial prompt being submitted.
|
|
88
|
+
*/
|
|
89
|
+
export function cmuxTextIsUnsafe(text) {
|
|
90
|
+
return /[\r\n]|\\[nrt]/.test(text);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Parses `cmux sessions list --json`: `{sessions: [{agent, cwd, workspace_id,
|
|
94
|
+
* surface_id, ...}]}`. DOCS-ONLY field names (the store was empty when
|
|
95
|
+
* observed; only the envelope was seen). Records are saved hook state, so a
|
|
96
|
+
* match can be stale; a failed send then falls through to the next route.
|
|
97
|
+
*/
|
|
98
|
+
export function parseCmuxSessions(stdout, cwd) {
|
|
99
|
+
const want = realResolve(cwd);
|
|
100
|
+
const parsed = parseJsonOrNull(stdout);
|
|
101
|
+
const rows = isRecord(parsed) ? parsed['sessions'] : null;
|
|
102
|
+
if (!Array.isArray(rows))
|
|
103
|
+
return [];
|
|
104
|
+
const found = [];
|
|
105
|
+
for (const row of rows) {
|
|
106
|
+
if (!isRecord(row))
|
|
107
|
+
continue;
|
|
108
|
+
const rowCwd = nonEmptyString(row['cwd']) ?? nonEmptyString(row['launch_cwd']);
|
|
109
|
+
if (rowCwd === null || realResolve(rowCwd) !== want)
|
|
110
|
+
continue;
|
|
111
|
+
const workspace = nonEmptyString(row['workspace_id']);
|
|
112
|
+
if (workspace === null)
|
|
113
|
+
continue;
|
|
114
|
+
const agent = row['agent'];
|
|
115
|
+
const surface = nonEmptyString(row['surface_id']);
|
|
116
|
+
found.push({
|
|
117
|
+
cwd,
|
|
118
|
+
harness: harnessOrNull(agent),
|
|
119
|
+
alive: true,
|
|
120
|
+
lastOutputAt: null,
|
|
121
|
+
handle: surface === null ? workspace : `${workspace}/${surface}`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return found;
|
|
125
|
+
}
|
|
126
|
+
/** Parses `cmux --json workspace list`: an array (or `{workspaces}`) of `{ref|id, cwd|current_directory}`. DOCS-ONLY shape. */
|
|
127
|
+
export function parseCmuxWorkspaces(stdout, cwd) {
|
|
128
|
+
const want = realResolve(cwd);
|
|
129
|
+
const parsed = parseJsonOrNull(stdout);
|
|
130
|
+
const rows = Array.isArray(parsed) ? parsed : isRecord(parsed) ? parsed['workspaces'] : null;
|
|
131
|
+
if (!Array.isArray(rows))
|
|
132
|
+
return [];
|
|
133
|
+
const found = [];
|
|
134
|
+
for (const row of rows) {
|
|
135
|
+
if (!isRecord(row))
|
|
136
|
+
continue;
|
|
137
|
+
const rowCwd = nonEmptyString(row['cwd']) ?? nonEmptyString(row['current_directory']);
|
|
138
|
+
const ref = nonEmptyString(row['ref']) ?? nonEmptyString(row['id']);
|
|
139
|
+
if (rowCwd === null || realResolve(rowCwd) !== want || ref === null)
|
|
140
|
+
continue;
|
|
141
|
+
found.push({ cwd, harness: null, alive: true, lastOutputAt: null, handle: ref });
|
|
142
|
+
}
|
|
143
|
+
return found;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The cmux host: drives the cmux app through the `cmux` binary, which must be on PATH.
|
|
147
|
+
* darwin only. Keeps no state of its own; every part resolves by `cwd`.
|
|
148
|
+
*/
|
|
149
|
+
export function makeCmuxHost(opts) {
|
|
150
|
+
return {
|
|
151
|
+
name: 'cmux',
|
|
152
|
+
platforms: ['darwin'],
|
|
153
|
+
available() {
|
|
154
|
+
if (opts.available !== undefined)
|
|
155
|
+
return opts.available();
|
|
156
|
+
return supportsPlatform(this) && onPath('cmux');
|
|
157
|
+
},
|
|
158
|
+
terminal(req) {
|
|
159
|
+
const runner = resolveRunner('cmux', opts.runner);
|
|
160
|
+
const res = runner.run(cmuxCreateArgv(req), { cwd: req.cwd, timeoutMs: 60_000 });
|
|
161
|
+
if (!ran(res)) {
|
|
162
|
+
throw new Error(`cmux workspace create failed: ${failureText(res)}`);
|
|
163
|
+
}
|
|
164
|
+
return 0;
|
|
165
|
+
},
|
|
166
|
+
session: sessionViaTerminal,
|
|
167
|
+
find(query) {
|
|
168
|
+
const runner = resolveRunner('cmux', opts.runner);
|
|
169
|
+
const run = (argv) => quietRun(runner, argv, { cwd: query.cwd, timeoutMs: 10_000 });
|
|
170
|
+
const stored = run(cmuxSessionsArgv(query.cwd));
|
|
171
|
+
const fromStore = stored === null ? [] : parseCmuxSessions(stored, query.cwd);
|
|
172
|
+
if (fromStore.length > 0)
|
|
173
|
+
return fromStore;
|
|
174
|
+
// The workspace list needs the socket; when it is denied the command exits nonzero and `run` returns null.
|
|
175
|
+
const listed = run(cmuxWorkspaceListArgv());
|
|
176
|
+
return listed === null ? [] : parseCmuxWorkspaces(listed, query.cwd);
|
|
177
|
+
},
|
|
178
|
+
send(req) {
|
|
179
|
+
// No delivery confirmation exists, so ack is always false.
|
|
180
|
+
if (cmuxTextIsUnsafe(req.text))
|
|
181
|
+
return { accepted: false, ack: false };
|
|
182
|
+
try {
|
|
183
|
+
// Only a workspace whose saved session names an agent: one found by cwd alone
|
|
184
|
+
// (the workspace-list fallback) may be a shell the agent has exited, and the
|
|
185
|
+
// pointer would run as a command. With `req.harness`, only that harness's row, and
|
|
186
|
+
// only when exactly one row is left: any pick among several could reach another session.
|
|
187
|
+
const agents = this.find({ cwd: req.cwd }).filter((s) => s.harness !== null && (req.harness === undefined || s.harness === req.harness));
|
|
188
|
+
const handle = agents.length === 1 ? agents[0]?.handle : undefined;
|
|
189
|
+
if (handle === undefined || handle === null)
|
|
190
|
+
return { accepted: false, ack: false };
|
|
191
|
+
const runner = resolveRunner('cmux', opts.runner);
|
|
192
|
+
const typed = runner.run(cmuxSendArgv(handle, req.text), {
|
|
193
|
+
cwd: req.cwd,
|
|
194
|
+
timeoutMs: 15_000,
|
|
195
|
+
});
|
|
196
|
+
if (!ran(typed))
|
|
197
|
+
return { accepted: false, ack: false };
|
|
198
|
+
const enter = runner.run(cmuxSendEnterArgv(handle), { cwd: req.cwd, timeoutMs: 15_000 });
|
|
199
|
+
// The text is already in the input box if Enter failed; retyping would double it.
|
|
200
|
+
return ran(enter)
|
|
201
|
+
? { accepted: true, ack: false }
|
|
202
|
+
: { accepted: true, ack: false, uncertain: true };
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return { accepted: false, ack: false };
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
focus(query) {
|
|
209
|
+
try {
|
|
210
|
+
const handle = this.find(query)[0]?.handle;
|
|
211
|
+
if (handle === undefined || handle === null)
|
|
212
|
+
return false;
|
|
213
|
+
const runner = resolveRunner('cmux', opts.runner);
|
|
214
|
+
return ran(runner.run(cmuxFocusArgv(handle), { cwd: query.cwd, timeoutMs: 10_000 }));
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
capture(query) {
|
|
221
|
+
try {
|
|
222
|
+
const handle = this.find(query)[0]?.handle;
|
|
223
|
+
if (handle === undefined || handle === null)
|
|
224
|
+
return null;
|
|
225
|
+
const runner = resolveRunner('cmux', opts.runner);
|
|
226
|
+
return quietRun(runner, cmuxReadScreenArgv(handle), { cwd: query.cwd, timeoutMs: 10_000 });
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
close(query) {
|
|
233
|
+
try {
|
|
234
|
+
// Only a workspace whose saved session names an agent, as in `send`: one found by
|
|
235
|
+
// cwd alone (the workspace-list fallback) may be one the person opened. No session
|
|
236
|
+
// id can be matched here, so close only when the cwd names one such workspace: with
|
|
237
|
+
// several, any pick could close another session's.
|
|
238
|
+
const workspaces = new Set(this.find(query).flatMap((s) => s.harness !== null && s.handle ? [s.handle.split('/')[0]] : []));
|
|
239
|
+
const [workspace] = [...workspaces];
|
|
240
|
+
if (workspaces.size !== 1 || workspace === undefined)
|
|
241
|
+
return false;
|
|
242
|
+
return (quietRun(resolveRunner('cmux', opts.runner), cmuxCloseArgv(workspace), {
|
|
243
|
+
cwd: query.cwd,
|
|
244
|
+
timeoutMs: 10_000,
|
|
245
|
+
}) !== null);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* herdr: a workspace on `cwd` running a command in its root pane, found again
|
|
3
|
+
* by listing panes and matching their cwd. Behaviour follows
|
|
4
|
+
* `docs/planning/research/herdr.md` (herdr 0.9.3, driven live).
|
|
5
|
+
*
|
|
6
|
+
* herdr is a server (a unix socket per named session) that the `herdr` CLI
|
|
7
|
+
* talks to. This host never starts one: with no server, `session`/`terminal`
|
|
8
|
+
* throw a clear error and `send`/`find`/`focus`/`capture` report "nothing
|
|
9
|
+
* there". herdr itself honours `HERDR_SESSION`; `opts.session` is the explicit
|
|
10
|
+
* equivalent (`--session <name>` ahead of every subcommand).
|
|
11
|
+
*
|
|
12
|
+
* Every id is opaque and read from a JSON response, never derived: workspace
|
|
13
|
+
* `w1`, tab `w1:t1`, pane `w1:p1`. A session launched with a caller session id has
|
|
14
|
+
* its root pane id remembered (`./session-panes.ts`) and later requests for it
|
|
15
|
+
* target that pane if herdr still lists it; otherwise `cwd` becomes a pane at call
|
|
16
|
+
* time, as in `./tmux.ts` and `./orca.ts`.
|
|
17
|
+
*/
|
|
18
|
+
import { type CommandResult, type CommandRunner } from '@osdlc/command-seam';
|
|
19
|
+
import { type CommandRequest, type Host, type SendReceipt } from '../contract.ts';
|
|
20
|
+
import { type HostStateOptions } from './registry.ts';
|
|
21
|
+
/** One pane of `herdr pane list`, reduced to what the host reads. */
|
|
22
|
+
export interface HerdrPane {
|
|
23
|
+
paneId: string;
|
|
24
|
+
tabId: string;
|
|
25
|
+
workspaceId: string;
|
|
26
|
+
cwd: string;
|
|
27
|
+
foregroundCwd: string;
|
|
28
|
+
/** Agent kind herdr recognised in the pane (`claude`, `codex`, ...), or null for a plain shell. */
|
|
29
|
+
agent: string | null;
|
|
30
|
+
agentStatus: string | null;
|
|
31
|
+
}
|
|
32
|
+
/** Argv for `herdr workspace create` — pure, so a test can pin it. `--no-focus` keeps the person's own pane in front. */
|
|
33
|
+
export declare function herdrWorkspaceCreateArgv(req: CommandRequest, session?: string): string[];
|
|
34
|
+
/** Argv for `herdr pane run <pane> <command>`, which sends the text and Enter in one call. */
|
|
35
|
+
export declare function herdrPaneRunArgv(paneId: string, argv: string[], session?: string): string[];
|
|
36
|
+
export declare function herdrPaneListArgv(session?: string): string[];
|
|
37
|
+
/**
|
|
38
|
+
* Argv for `herdr agent prompt`. `--wait --until working --until blocked`
|
|
39
|
+
* returns as soon as the agent reacts (about half a second against a live
|
|
40
|
+
* claude) instead of waiting for the whole turn to settle, which is the
|
|
41
|
+
* default and can take minutes. Do not put `--` before `text`: herdr treats it
|
|
42
|
+
* as literal text. `text` is a bare positional argument, so herdr reads one
|
|
43
|
+
* starting with `-` as a flag (`--help` prints usage and exits 0); `send`
|
|
44
|
+
* refuses such text without running anything.
|
|
45
|
+
*/
|
|
46
|
+
export declare function herdrAgentPromptArgv(paneId: string, text: string, session?: string): string[];
|
|
47
|
+
export declare function herdrWorkspaceFocusArgv(workspaceId: string, session?: string): string[];
|
|
48
|
+
export declare function herdrTabFocusArgv(tabId: string, session?: string): string[];
|
|
49
|
+
/** `herdr pane close <pane_id>` (per `herdr pane close --help`): closes the pane and what runs in it. */
|
|
50
|
+
export declare function herdrPaneCloseArgv(paneId: string, session?: string): string[];
|
|
51
|
+
export declare function herdrPaneReadArgv(paneId: string, session?: string): string[];
|
|
52
|
+
/** The `error.code` of a failed herdr CLI call; errors arrive as JSON on stderr (and, on some paths, stdout). */
|
|
53
|
+
export declare function herdrErrorCode(res: Pick<CommandResult, 'stdout' | 'stderr'>): string | null;
|
|
54
|
+
/** Parses `herdr pane list`: `result.panes`. Unparseable output is `[]`. */
|
|
55
|
+
export declare function parseHerdrPanes(stdout: string): HerdrPane[];
|
|
56
|
+
/** The root pane id of `herdr workspace create`: `result.root_pane.pane_id`. */
|
|
57
|
+
export declare function parseHerdrRootPane(stdout: string): string | null;
|
|
58
|
+
/**
|
|
59
|
+
* Panes whose cwd (or foreground process's cwd) is `cwd`, agent panes first so
|
|
60
|
+
* a send never prefers a plain shell beside a live agent.
|
|
61
|
+
*/
|
|
62
|
+
export declare function herdrPanesAt(panes: HerdrPane[], cwd: string): HerdrPane[];
|
|
63
|
+
/**
|
|
64
|
+
* Turns an `agent prompt` result into a receipt. Exit 0: herdr saw the agent react, so
|
|
65
|
+
* `ack`. `timeout` and `agent_prompt_stalled` do not prove non-delivery, so they are
|
|
66
|
+
* `uncertain`. Every other error, including `agent_blocked`, means nothing was written.
|
|
67
|
+
*/
|
|
68
|
+
export declare function herdrPromptReceipt(res: CommandResult): SendReceipt;
|
|
69
|
+
/**
|
|
70
|
+
* The herdr host: drives panes in herdr through the `herdr` binary, which must be on PATH.
|
|
71
|
+
* darwin and linux. Remembers the root pane id of each session under `runtimeDir`/`registryDir` ({@link HostStateOptions}).
|
|
72
|
+
*/
|
|
73
|
+
export declare function makeHerdrHost(opts: {
|
|
74
|
+
runner?: CommandRunner | undefined;
|
|
75
|
+
available?: (() => boolean) | undefined;
|
|
76
|
+
/** A named herdr session; defaults to herdr's own choice (`HERDR_SESSION`, else the default session). */
|
|
77
|
+
session?: string | undefined;
|
|
78
|
+
} & HostStateOptions): Host;
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* herdr: a workspace on `cwd` running a command in its root pane, found again
|
|
3
|
+
* by listing panes and matching their cwd. Behaviour follows
|
|
4
|
+
* `docs/planning/research/herdr.md` (herdr 0.9.3, driven live).
|
|
5
|
+
*
|
|
6
|
+
* herdr is a server (a unix socket per named session) that the `herdr` CLI
|
|
7
|
+
* talks to. This host never starts one: with no server, `session`/`terminal`
|
|
8
|
+
* throw a clear error and `send`/`find`/`focus`/`capture` report "nothing
|
|
9
|
+
* there". herdr itself honours `HERDR_SESSION`; `opts.session` is the explicit
|
|
10
|
+
* equivalent (`--session <name>` ahead of every subcommand).
|
|
11
|
+
*
|
|
12
|
+
* Every id is opaque and read from a JSON response, never derived: workspace
|
|
13
|
+
* `w1`, tab `w1:t1`, pane `w1:p1`. A session launched with a caller session id has
|
|
14
|
+
* its root pane id remembered (`./session-panes.ts`) and later requests for it
|
|
15
|
+
* target that pane if herdr still lists it; otherwise `cwd` becomes a pane at call
|
|
16
|
+
* time, as in `./tmux.ts` and `./orca.ts`.
|
|
17
|
+
*/
|
|
18
|
+
import { commandString, failureText, ran, resolveRunner, } from '@osdlc/command-seam';
|
|
19
|
+
import { isRecord, parseJsonOrNull, realResolve, strOrNull } from "../util.js";
|
|
20
|
+
import { onPath, quietRun, supportsPlatform, } from "../contract.js";
|
|
21
|
+
import { harnessOrNull, sessionViaTerminal } from "../harness.js";
|
|
22
|
+
import { recallSessionPane, rememberSessionPane } from "./session-panes.js";
|
|
23
|
+
import { registryDirOf } from "./registry.js";
|
|
24
|
+
/** Milliseconds `agent prompt --wait` may wait for the agent to react. */
|
|
25
|
+
const HERDR_PROMPT_TIMEOUT_MS = 30_000;
|
|
26
|
+
/** Lines of recent output `capture` asks for. */
|
|
27
|
+
const HERDR_CAPTURE_LINES = 200;
|
|
28
|
+
function sessionPrefix(session) {
|
|
29
|
+
return session === undefined ? [] : ['--session', session];
|
|
30
|
+
}
|
|
31
|
+
/** Argv for `herdr workspace create` — pure, so a test can pin it. `--no-focus` keeps the person's own pane in front. */
|
|
32
|
+
export function herdrWorkspaceCreateArgv(req, session) {
|
|
33
|
+
return [
|
|
34
|
+
...sessionPrefix(session),
|
|
35
|
+
'workspace',
|
|
36
|
+
'create',
|
|
37
|
+
'--cwd',
|
|
38
|
+
req.cwd,
|
|
39
|
+
'--label',
|
|
40
|
+
req.title,
|
|
41
|
+
'--no-focus',
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
/** Argv for `herdr pane run <pane> <command>`, which sends the text and Enter in one call. */
|
|
45
|
+
export function herdrPaneRunArgv(paneId, argv, session) {
|
|
46
|
+
return [...sessionPrefix(session), 'pane', 'run', paneId, commandString(argv)];
|
|
47
|
+
}
|
|
48
|
+
export function herdrPaneListArgv(session) {
|
|
49
|
+
return [...sessionPrefix(session), 'pane', 'list'];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Argv for `herdr agent prompt`. `--wait --until working --until blocked`
|
|
53
|
+
* returns as soon as the agent reacts (about half a second against a live
|
|
54
|
+
* claude) instead of waiting for the whole turn to settle, which is the
|
|
55
|
+
* default and can take minutes. Do not put `--` before `text`: herdr treats it
|
|
56
|
+
* as literal text. `text` is a bare positional argument, so herdr reads one
|
|
57
|
+
* starting with `-` as a flag (`--help` prints usage and exits 0); `send`
|
|
58
|
+
* refuses such text without running anything.
|
|
59
|
+
*/
|
|
60
|
+
export function herdrAgentPromptArgv(paneId, text, session) {
|
|
61
|
+
return [
|
|
62
|
+
...sessionPrefix(session),
|
|
63
|
+
'agent',
|
|
64
|
+
'prompt',
|
|
65
|
+
paneId,
|
|
66
|
+
text,
|
|
67
|
+
'--wait',
|
|
68
|
+
'--until',
|
|
69
|
+
'working',
|
|
70
|
+
'--until',
|
|
71
|
+
'blocked',
|
|
72
|
+
'--timeout',
|
|
73
|
+
String(HERDR_PROMPT_TIMEOUT_MS),
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
export function herdrWorkspaceFocusArgv(workspaceId, session) {
|
|
77
|
+
return [...sessionPrefix(session), 'workspace', 'focus', workspaceId];
|
|
78
|
+
}
|
|
79
|
+
export function herdrTabFocusArgv(tabId, session) {
|
|
80
|
+
return [...sessionPrefix(session), 'tab', 'focus', tabId];
|
|
81
|
+
}
|
|
82
|
+
/** `herdr pane close <pane_id>` (per `herdr pane close --help`): closes the pane and what runs in it. */
|
|
83
|
+
export function herdrPaneCloseArgv(paneId, session) {
|
|
84
|
+
return [...sessionPrefix(session), 'pane', 'close', paneId];
|
|
85
|
+
}
|
|
86
|
+
export function herdrPaneReadArgv(paneId, session) {
|
|
87
|
+
return [...sessionPrefix(session), 'pane', 'read', paneId, '--lines', String(HERDR_CAPTURE_LINES)];
|
|
88
|
+
}
|
|
89
|
+
/** The `error.code` of a failed herdr CLI call; errors arrive as JSON on stderr (and, on some paths, stdout). */
|
|
90
|
+
export function herdrErrorCode(res) {
|
|
91
|
+
for (const text of [res.stderr, res.stdout]) {
|
|
92
|
+
const parsed = parseJsonOrNull(text);
|
|
93
|
+
const error = isRecord(parsed) ? parsed['error'] : null;
|
|
94
|
+
if (isRecord(error) && typeof error['code'] === 'string')
|
|
95
|
+
return error['code'];
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/** Parses `herdr pane list`: `result.panes`. Unparseable output is `[]`. */
|
|
100
|
+
export function parseHerdrPanes(stdout) {
|
|
101
|
+
const parsed = parseJsonOrNull(stdout);
|
|
102
|
+
const result = isRecord(parsed) ? parsed['result'] : null;
|
|
103
|
+
const panes = isRecord(result) ? result['panes'] : null;
|
|
104
|
+
if (!Array.isArray(panes))
|
|
105
|
+
return [];
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const pane of panes) {
|
|
108
|
+
if (!isRecord(pane))
|
|
109
|
+
continue;
|
|
110
|
+
const paneId = strOrNull(pane['pane_id']);
|
|
111
|
+
const tabId = strOrNull(pane['tab_id']);
|
|
112
|
+
const workspaceId = strOrNull(pane['workspace_id']);
|
|
113
|
+
const cwd = strOrNull(pane['cwd']);
|
|
114
|
+
if (paneId === null || tabId === null || workspaceId === null || cwd === null)
|
|
115
|
+
continue;
|
|
116
|
+
out.push({
|
|
117
|
+
paneId,
|
|
118
|
+
tabId,
|
|
119
|
+
workspaceId,
|
|
120
|
+
cwd,
|
|
121
|
+
foregroundCwd: strOrNull(pane['foreground_cwd']) ?? cwd,
|
|
122
|
+
agent: strOrNull(pane['agent']),
|
|
123
|
+
agentStatus: strOrNull(pane['agent_status']),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
/** The root pane id of `herdr workspace create`: `result.root_pane.pane_id`. */
|
|
129
|
+
export function parseHerdrRootPane(stdout) {
|
|
130
|
+
const parsed = parseJsonOrNull(stdout);
|
|
131
|
+
const result = isRecord(parsed) ? parsed['result'] : null;
|
|
132
|
+
const root = isRecord(result) ? result['root_pane'] : null;
|
|
133
|
+
return isRecord(root) ? strOrNull(root['pane_id']) : null;
|
|
134
|
+
}
|
|
135
|
+
/** herdr reports the resolved path (`/private/tmp/...` for `/tmp/...`), so compare real paths. */
|
|
136
|
+
function samePath(a, b) {
|
|
137
|
+
return realResolve(a) === realResolve(b);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Panes whose cwd (or foreground process's cwd) is `cwd`, agent panes first so
|
|
141
|
+
* a send never prefers a plain shell beside a live agent.
|
|
142
|
+
*/
|
|
143
|
+
export function herdrPanesAt(panes, cwd) {
|
|
144
|
+
const here = panes.filter((p) => samePath(p.cwd, cwd) || samePath(p.foregroundCwd, cwd));
|
|
145
|
+
return [...here.filter((p) => p.agent !== null), ...here.filter((p) => p.agent === null)];
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Turns an `agent prompt` result into a receipt. Exit 0: herdr saw the agent react, so
|
|
149
|
+
* `ack`. `timeout` and `agent_prompt_stalled` do not prove non-delivery, so they are
|
|
150
|
+
* `uncertain`. Every other error, including `agent_blocked`, means nothing was written.
|
|
151
|
+
*/
|
|
152
|
+
export function herdrPromptReceipt(res) {
|
|
153
|
+
if (ran(res))
|
|
154
|
+
return { accepted: true, ack: true };
|
|
155
|
+
const code = herdrErrorCode(res);
|
|
156
|
+
if (code === 'timeout' || code === 'agent_prompt_stalled') {
|
|
157
|
+
return { accepted: true, ack: false, uncertain: true };
|
|
158
|
+
}
|
|
159
|
+
return { accepted: false, ack: false };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The herdr host: drives panes in herdr through the `herdr` binary, which must be on PATH.
|
|
163
|
+
* darwin and linux. Remembers the root pane id of each session under `runtimeDir`/`registryDir` ({@link HostStateOptions}).
|
|
164
|
+
*/
|
|
165
|
+
export function makeHerdrHost(opts) {
|
|
166
|
+
const runner = () => resolveRunner('herdr', opts.runner);
|
|
167
|
+
const registryDir = registryDirOf('herdr', opts);
|
|
168
|
+
/** The pane remembered for the request's session id, or null. */
|
|
169
|
+
const recall = (target) => target.sessionId === undefined ? null : recallSessionPane(registryDir, target.sessionId);
|
|
170
|
+
/**
|
|
171
|
+
* The panes a request addresses: the pane remembered for its session id if herdr still
|
|
172
|
+
* lists it at the cwd it was opened in (as the pane's or its foreground process's cwd) and
|
|
173
|
+
* none otherwise, else the panes at its cwd. herdr reissues ids after a restart, so the
|
|
174
|
+
* same id at another cwd is not the pane this session opened. `bound` is the remembered
|
|
175
|
+
* pane when the caller already read it.
|
|
176
|
+
*/
|
|
177
|
+
const livePanes = (target, bound = recall(target)) => {
|
|
178
|
+
const out = quietRun(runner(), herdrPaneListArgv(opts.session), {
|
|
179
|
+
cwd: target.cwd,
|
|
180
|
+
timeoutMs: 10_000,
|
|
181
|
+
});
|
|
182
|
+
if (out === null)
|
|
183
|
+
return [];
|
|
184
|
+
const panes = parseHerdrPanes(out);
|
|
185
|
+
return bound === null
|
|
186
|
+
? herdrPanesAt(panes, target.cwd)
|
|
187
|
+
: panes.filter((p) => p.paneId === bound.pane &&
|
|
188
|
+
(samePath(p.cwd, bound.cwd) || samePath(p.foregroundCwd, bound.cwd)));
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
name: 'herdr',
|
|
192
|
+
acksSend: true,
|
|
193
|
+
platforms: ['darwin', 'linux'],
|
|
194
|
+
available() {
|
|
195
|
+
if (opts.available !== undefined)
|
|
196
|
+
return opts.available();
|
|
197
|
+
return supportsPlatform(this) && onPath('herdr');
|
|
198
|
+
},
|
|
199
|
+
terminal(req) {
|
|
200
|
+
const create = runner().run(herdrWorkspaceCreateArgv(req, opts.session), {
|
|
201
|
+
cwd: req.cwd,
|
|
202
|
+
timeoutMs: 60_000,
|
|
203
|
+
});
|
|
204
|
+
if (!ran(create)) {
|
|
205
|
+
if (herdrErrorCode(create) === 'server_not_running') {
|
|
206
|
+
throw new Error('herdr server is not running — start herdr first');
|
|
207
|
+
}
|
|
208
|
+
throw new Error(`herdr workspace create failed: ${failureText(create)}`);
|
|
209
|
+
}
|
|
210
|
+
const paneId = parseHerdrRootPane(create.stdout);
|
|
211
|
+
if (paneId === null) {
|
|
212
|
+
throw new Error(`herdr workspace create returned no root pane: ${create.stdout.trim()}`);
|
|
213
|
+
}
|
|
214
|
+
const run = runner().run(herdrPaneRunArgv(paneId, req.argv, opts.session), {
|
|
215
|
+
cwd: req.cwd,
|
|
216
|
+
timeoutMs: 60_000,
|
|
217
|
+
});
|
|
218
|
+
if (!ran(run)) {
|
|
219
|
+
// Best effort: close the new workspace's only pane so a failed launch leaves no
|
|
220
|
+
// empty workspace behind.
|
|
221
|
+
quietRun(runner(), herdrPaneCloseArgv(paneId, opts.session), {
|
|
222
|
+
cwd: req.cwd,
|
|
223
|
+
timeoutMs: 10_000,
|
|
224
|
+
});
|
|
225
|
+
throw new Error(`herdr pane run failed: ${failureText(run)}`);
|
|
226
|
+
}
|
|
227
|
+
if (req.sessionId !== undefined)
|
|
228
|
+
rememberSessionPane(registryDir, req.sessionId, paneId, req.cwd);
|
|
229
|
+
return 0;
|
|
230
|
+
},
|
|
231
|
+
session: sessionViaTerminal,
|
|
232
|
+
send(req) {
|
|
233
|
+
// herdr would read the text as a flag (see `herdrAgentPromptArgv`) and could exit 0.
|
|
234
|
+
if (req.text.startsWith('-'))
|
|
235
|
+
return { accepted: false, ack: false };
|
|
236
|
+
try {
|
|
237
|
+
// Only a pane herdr recognises as an agent receives text. A plain shell
|
|
238
|
+
// (or an agent still starting) would run the message as a command, so
|
|
239
|
+
// it is refused and the caller moves on to its next route. By cwd,
|
|
240
|
+
// with `req.harness`, only that harness's pane, and only when exactly
|
|
241
|
+
// one pane is left: any pick among several could reach another
|
|
242
|
+
// session. A bound pane is this session's own, so its agent name is
|
|
243
|
+
// not checked (herdr may report one that is no builtin harness).
|
|
244
|
+
const bound = recall(req);
|
|
245
|
+
const targets = livePanes(req, bound).filter((p) => p.agent !== null &&
|
|
246
|
+
(bound !== null || req.harness === undefined || harnessOrNull(p.agent) === req.harness));
|
|
247
|
+
const target = targets.length === 1 ? targets[0] : undefined;
|
|
248
|
+
if (target === undefined)
|
|
249
|
+
return { accepted: false, ack: false };
|
|
250
|
+
const res = runner().run(herdrAgentPromptArgv(target.paneId, req.text, opts.session), {
|
|
251
|
+
cwd: req.cwd,
|
|
252
|
+
timeoutMs: HERDR_PROMPT_TIMEOUT_MS + 10_000,
|
|
253
|
+
});
|
|
254
|
+
return herdrPromptReceipt(res);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return { accepted: false, ack: false };
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
find(query) {
|
|
261
|
+
return livePanes(query).map((p) => ({
|
|
262
|
+
cwd: query.cwd,
|
|
263
|
+
harness: harnessOrNull(p.agent),
|
|
264
|
+
alive: p.agent !== null,
|
|
265
|
+
lastOutputAt: null,
|
|
266
|
+
handle: p.paneId,
|
|
267
|
+
}));
|
|
268
|
+
},
|
|
269
|
+
focus(query) {
|
|
270
|
+
try {
|
|
271
|
+
const target = livePanes(query)[0];
|
|
272
|
+
if (target === undefined)
|
|
273
|
+
return false;
|
|
274
|
+
const run = { cwd: query.cwd, timeoutMs: 10_000 };
|
|
275
|
+
const ws = runner().run(herdrWorkspaceFocusArgv(target.workspaceId, opts.session), run);
|
|
276
|
+
if (!ran(ws))
|
|
277
|
+
return false;
|
|
278
|
+
return ran(runner().run(herdrTabFocusArgv(target.tabId, opts.session), run));
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
capture(query) {
|
|
285
|
+
const target = livePanes(query)[0];
|
|
286
|
+
if (target === undefined)
|
|
287
|
+
return null;
|
|
288
|
+
return quietRun(runner(), herdrPaneReadArgv(target.paneId, opts.session), {
|
|
289
|
+
cwd: query.cwd,
|
|
290
|
+
timeoutMs: 10_000,
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
close(query) {
|
|
294
|
+
try {
|
|
295
|
+
// Closing is destructive: a session whose pane was never remembered is left open
|
|
296
|
+
// rather than closing whichever pane is first at its cwd. `livePanes` drops a
|
|
297
|
+
// remembered id that now sits at another cwd (herdr reissued it). Without a session
|
|
298
|
+
// id, close only when exactly one pane at the cwd runs an agent: the first pane
|
|
299
|
+
// there may be a person's own shell, and any pick among several agents could close
|
|
300
|
+
// another session's.
|
|
301
|
+
let target;
|
|
302
|
+
if (query.sessionId === undefined) {
|
|
303
|
+
const agents = livePanes(query).filter((p) => p.agent !== null);
|
|
304
|
+
target = agents.length === 1 ? agents[0] : undefined;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
const bound = recall(query);
|
|
308
|
+
if (bound === null)
|
|
309
|
+
return false;
|
|
310
|
+
target = livePanes(query, bound)[0];
|
|
311
|
+
}
|
|
312
|
+
if (target === undefined)
|
|
313
|
+
return false;
|
|
314
|
+
return (runner().run(herdrPaneCloseArgv(target.paneId, opts.session), {
|
|
315
|
+
cwd: query.cwd,
|
|
316
|
+
timeoutMs: 10_000,
|
|
317
|
+
}).exitCode === 0);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|