@agentswf/engine 0.0.0-stage → 0.1.0-beta.0

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.
Files changed (77) hide show
  1. package/package.json +39 -4
  2. package/src/accounting/format.ts +252 -0
  3. package/src/accounting/plans.ts +38 -0
  4. package/src/accounting/prices.ts +98 -0
  5. package/src/accounting/summary.ts +385 -0
  6. package/src/accounting/tokens.ts +34 -0
  7. package/src/agent-launcher.ts +127 -0
  8. package/src/allowance-command.ts +105 -0
  9. package/src/attempt-ending.ts +198 -0
  10. package/src/attempt-output.ts +132 -0
  11. package/src/attempt-view.ts +362 -0
  12. package/src/canonical-json.ts +12 -0
  13. package/src/control-plane.ts +424 -0
  14. package/src/deadlines.ts +106 -0
  15. package/src/decisions/directory.ts +432 -0
  16. package/src/decisions/fake.ts +60 -0
  17. package/src/decisions/openrouter.ts +161 -0
  18. package/src/decisions/seam.ts +70 -0
  19. package/src/display-path.ts +16 -0
  20. package/src/duration.ts +12 -0
  21. package/src/errors.ts +4 -0
  22. package/src/files.ts +78 -0
  23. package/src/herdr-placement.ts +182 -0
  24. package/src/herdr-run-session.ts +530 -0
  25. package/src/herdr-workspace-marks.ts +194 -0
  26. package/src/here.ts +289 -0
  27. package/src/index.ts +5 -0
  28. package/src/jsonl.ts +74 -0
  29. package/src/logs-command.ts +605 -0
  30. package/src/machine.ts +27 -0
  31. package/src/message-operation.ts +245 -0
  32. package/src/messaging/limits.ts +35 -0
  33. package/src/messaging/messaging.ts +1303 -0
  34. package/src/messaging/routes.ts +79 -0
  35. package/src/messaging/text.ts +251 -0
  36. package/src/operation-events.ts +182 -0
  37. package/src/operation-liveness.ts +674 -0
  38. package/src/operator-aliases.ts +10 -0
  39. package/src/operator-cli.ts +569 -0
  40. package/src/operator-runtime.ts +503 -0
  41. package/src/pane-layout.ts +125 -0
  42. package/src/progress-view.ts +568 -0
  43. package/src/result-slots.ts +325 -0
  44. package/src/result-validation.ts +47 -0
  45. package/src/run-command.ts +241 -0
  46. package/src/run-commands.ts +215 -0
  47. package/src/run-event-log.ts +72 -0
  48. package/src/run-prepare.ts +178 -0
  49. package/src/run-progress.ts +137 -0
  50. package/src/run-read.ts +425 -0
  51. package/src/run-state.ts +120 -0
  52. package/src/run-usage.ts +393 -0
  53. package/src/run-view.ts +225 -0
  54. package/src/runs-command.ts +356 -0
  55. package/src/runs.ts +645 -0
  56. package/src/sandbox-homes.ts +211 -0
  57. package/src/sandboxes.ts +516 -0
  58. package/src/skills/fetch.ts +305 -0
  59. package/src/skills/run-skills.ts +181 -0
  60. package/src/skills/sources.ts +87 -0
  61. package/src/skills/tree.ts +60 -0
  62. package/src/stage-ledger.ts +535 -0
  63. package/src/stage-plan.ts +127 -0
  64. package/src/stage-schema.ts +88 -0
  65. package/src/stopped.ts +36 -0
  66. package/src/test-command.ts +110 -0
  67. package/src/testing/operation-events.ts +89 -0
  68. package/src/testing.ts +164 -0
  69. package/src/workflow-loader.ts +113 -0
  70. package/src/workflow-runner.ts +3665 -0
  71. package/src/workflow-test-preload.ts +13 -0
  72. package/src/workflow-testing/decisions.ts +129 -0
  73. package/src/workflow-testing/host.ts +811 -0
  74. package/src/workflow-testing/index.ts +420 -0
  75. package/src/workflow-testing/script.ts +447 -0
  76. package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
  77. package/README.md +0 -3
@@ -0,0 +1,530 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { link, mkdir, unlink, writeFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import type { RunProcess } from "@agentswf/harness";
6
+ import { messageOf } from "./errors";
7
+ import {
8
+ liveness,
9
+ type MarkedPane,
10
+ readAllMarks,
11
+ readMarks,
12
+ removeMark,
13
+ rewriteMark,
14
+ type WorkspaceMark,
15
+ } from "./herdr-workspace-marks";
16
+ import { machinePaths } from "./machine";
17
+ import { SESSION_NAME } from "./pane-layout";
18
+ import type { ProcessProbe } from "./runs";
19
+
20
+ /** Where a run's pane agents open when `AWF_HERDR_SESSION` names nowhere else. */
21
+ const DEFAULT_RUN_SESSION = "awf";
22
+
23
+ /** What may start: the shared session is awf's by its name, since which run created it is not knowable. */
24
+ const OWN_SESSION = /^awf(-|$)/;
25
+
26
+ /**
27
+ * The operator's variables a server starts with; a pane's login shell builds the rest. `HERDR_HOME`
28
+ * and `XDG_*` move Herdr's own directory, which awf's calls to the session look in too.
29
+ */
30
+ const SERVER_ENVIRONMENT = [
31
+ "HOME",
32
+ "USER",
33
+ "LOGNAME",
34
+ "SHELL",
35
+ "TERM",
36
+ "LANG",
37
+ "TMPDIR",
38
+ "HERDR_HOME",
39
+ ];
40
+ const SERVER_ENVIRONMENT_PREFIXES = ["LC_", "XDG_"];
41
+ const SYSTEM_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
42
+
43
+ /** Only what makes noise; a headless server draws nothing, so the rest is Herdr's default. */
44
+ const QUIET_CONFIG = `# Written by awf for the Herdr sessions it starts; awf never overwrites it.
45
+ [ui.toast]
46
+ delivery = "off"
47
+
48
+ [ui.sound]
49
+ enabled = false
50
+ `;
51
+
52
+ const READY_POLL_MS = 200;
53
+ const READY_WITHIN_MS = 10_000;
54
+
55
+ /** Starts a server that outlives this process; settles once it has started, or could not. */
56
+ export type StartServer = (
57
+ argv: readonly string[],
58
+ options: { cwd: string; env: Record<string, string> },
59
+ ) => Promise<void>;
60
+
61
+ export type RunSessionDeps = {
62
+ run: RunProcess;
63
+ environment: Readonly<Record<string, string | undefined>>;
64
+ home: string;
65
+ start?: StartServer;
66
+ sleep?: (ms: number) => Promise<void>;
67
+ now?: () => number;
68
+ /** A process's start time, which tells a run's process from another that took its pid since. */
69
+ probe?: ProcessProbe;
70
+ /** Whether a pid is a process at all. */
71
+ exists?: (pid: number) => boolean;
72
+ };
73
+
74
+ export type RunSession = {
75
+ name: string;
76
+ /** Whether this call started its server. */
77
+ started: boolean;
78
+ /** The Herdr it ran, when this call restarted it on the one installed. */
79
+ restartedFrom?: string;
80
+ /** The server's Herdr and the one installed, when they differ and it was not restarted. */
81
+ stale?: { server: string; installed: string };
82
+ /** Labels of `awf` workspaces whose run had ended, closed. */
83
+ closed: string[];
84
+ /** `awf` workspaces no run's mark names, made by hand or by an older awf, left open. */
85
+ unclaimed: { id: string; label: string }[];
86
+ /** Panes runs that ended kept, still open, in whatever session they are in. */
87
+ kept?: KeptPane[];
88
+ };
89
+
90
+ export type KeptPane = { run: string; session: string; paneId: string };
91
+
92
+ /** `AWF_HERDR_SESSION`, else `awf`; refused when Herdr could not use it as a session name. */
93
+ export function runSessionName(environment: Readonly<Record<string, string | undefined>>): string {
94
+ const name = environment.AWF_HERDR_SESSION || DEFAULT_RUN_SESSION;
95
+ if (!SESSION_NAME.test(name)) {
96
+ throw new Error(
97
+ `AWF_HERDR_SESSION ${JSON.stringify(name)} is not a Herdr session name awf uses: lowercase letters, digits and -, up to 32, not starting with -`,
98
+ );
99
+ }
100
+ return name;
101
+ }
102
+
103
+ /**
104
+ * The Herdr session a run's pane agents open in, running and kept: started detached when down and
105
+ * named `awf` or `awf-…`, from a minimal environment and a quiet config, so it outlives this run
106
+ * for the next. Any other name that is down is refused: it may be an operator's stopped session,
107
+ * and bringing it back headless is not awf's call. Then the workspaces of runs that ended are
108
+ * closed, and a server older than the Herdr installed is restarted if nothing runs in it.
109
+ */
110
+ export async function ensureRunSession(name: string, deps: RunSessionDeps): Promise<RunSession> {
111
+ const { started, workspaces, sessionDir } = await runningSession(name, deps);
112
+ const panes = await sweepPanes(deps);
113
+ const kept = panes.kept.length > 0 ? { kept: panes.kept } : {};
114
+ // Herdr closes a workspace with its last pane, so one the panes' sweep emptied is gone.
115
+ const current = panes.closedAny ? await listWorkspaces(name, deps) : workspaces;
116
+ // A list that can't be read is no session found empty: nothing is closed or restarted.
117
+ const swept = current
118
+ ? await sweepWorkspaces(name, current, deps, panes.keptIn)
119
+ : { closed: [], unclaimed: [], open: Number.POSITIVE_INFINITY };
120
+ const { closed, unclaimed } = swept;
121
+ const version = await serverVersion(name, deps);
122
+ if (!version?.stale) return { name, started, closed, unclaimed, ...kept };
123
+ const stale = { server: version.server, installed: version.installed };
124
+ if (!OWN_SESSION.test(name) || swept.open > 0 || !(await stillUnused(name, deps))) {
125
+ return { name, started, stale, closed, unclaimed, ...kept };
126
+ }
127
+ const stopped = await deps.run({ argv: ["herdr", "session", "stop", name], timeoutMs: 10_000 });
128
+ if (stopped.exitCode !== 0) return { name, started, stale, closed, unclaimed, ...kept };
129
+ await startServer(name, sessionDir, deps);
130
+ return { name, started: true, restartedFrom: version.server, closed, unclaimed, ...kept };
131
+ }
132
+
133
+ /** `name`'s server, answering: started if down and awf's; its workspaces, if they could be read. */
134
+ async function runningSession(
135
+ name: string,
136
+ deps: RunSessionDeps,
137
+ ): Promise<{
138
+ started: boolean;
139
+ workspaces: ListedWorkspace[] | undefined;
140
+ sessionDir: string | undefined;
141
+ }> {
142
+ const listed = await deps.run({
143
+ argv: ["herdr", "session", "list", "--json"],
144
+ timeoutMs: 10_000,
145
+ });
146
+ if (listed.exitCode !== 0) {
147
+ throw new Error(`herdr session list failed: ${(listed.stderr || listed.stdout).trim()}`);
148
+ }
149
+ const sessionDir = parseSessions(listed.stdout).find((session) => session.name === name);
150
+ // Listed running may be another run's server, not yet serving.
151
+ if (sessionDir?.running === true) {
152
+ return {
153
+ started: false,
154
+ workspaces: await answering(name, sessionDir.sessionDir, deps),
155
+ sessionDir: sessionDir.sessionDir,
156
+ };
157
+ }
158
+ if (!OWN_SESSION.test(name)) {
159
+ throw new Error(
160
+ `Herdr session ${name} is not running, and awf starts only sessions named awf or awf-…; start it with \`herdr --session ${name} server\``,
161
+ );
162
+ }
163
+ // Two runs starting it at once both say so: the one that lost the race can't tell.
164
+ return {
165
+ started: true,
166
+ workspaces: await startServer(name, sessionDir?.sessionDir, deps),
167
+ sessionDir: sessionDir?.sessionDir,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Closes each workspace a dead run's mark names by id, and drops dead marks that name none still
173
+ * open, or none at all. An `awf` workspace no mark names is left, and named. What stays open,
174
+ * counted.
175
+ */
176
+ async function sweepWorkspaces(
177
+ name: string,
178
+ workspaces: readonly ListedWorkspace[],
179
+ deps: RunSessionDeps,
180
+ /** Workspaces, as `{session}/{id}`, that hold a pane a run kept. */
181
+ keptIn: ReadonlySet<string>,
182
+ ): Promise<{ closed: string[]; unclaimed: { id: string; label: string }[]; open: number }> {
183
+ const marks = await readMarks(deps.home, name);
184
+ const dead = (mark: WorkspaceMark) =>
185
+ mark.ended === true || liveness(mark, deps.probe, deps.exists) === "dead";
186
+ /** A mark that still names a pane outlives the workspace it named. */
187
+ const holds = (mark: WorkspaceMark) =>
188
+ Object.values(mark.panes ?? {}).some((panes) => panes.length > 0);
189
+ const closed: string[] = [];
190
+ const unclaimed: { id: string; label: string }[] = [];
191
+ let open = 0;
192
+ for (const workspace of workspaces) {
193
+ const own = marks.filter((mark) => mark.workspaceId === workspace.id);
194
+ // A run between its mark and its workspace's id names it by label alone.
195
+ const opening = marks.some(
196
+ (mark) => mark.workspaceId === undefined && mark.label === workspace.label,
197
+ );
198
+ if (own.length === 0 && !opening && workspace.label.startsWith("awf "))
199
+ unclaimed.push(workspace);
200
+ if (own.length === 0 || !own.every(dead) || keptIn.has(`${name}/${workspace.id}`)) {
201
+ open += 1;
202
+ continue;
203
+ }
204
+ const closing = await deps.run({
205
+ argv: ["herdr", "--session", name, "workspace", "close", workspace.id],
206
+ timeoutMs: 10_000,
207
+ });
208
+ if (closing.exitCode !== 0) {
209
+ open += 1;
210
+ continue;
211
+ }
212
+ closed.push(workspace.label);
213
+ for (const mark of own) {
214
+ if (!holds(mark)) await removeMark(mark.file);
215
+ }
216
+ }
217
+ const listed = new Set(workspaces.map((workspace) => workspace.id));
218
+ for (const mark of marks) {
219
+ const named = mark.workspaceId !== undefined && listed.has(mark.workspaceId);
220
+ if (!named && dead(mark) && !holds(mark)) await removeMark(mark.file);
221
+ }
222
+ return { closed, unclaimed, open };
223
+ }
224
+
225
+ /**
226
+ * Closes the panes of every run that ended, in each session its mark names, and leaves those it
227
+ * kept, listed. A pane is closed only when its id and its terminal's both match, as Herdr's pane
228
+ * ids repeat after a restart; one not there any more, or in a session not running, leaves its mark.
229
+ * One the sweep can't tell, in a session it can't read or recorded without its terminal, is left in
230
+ * the mark, and a kept one still keeps its workspace open. Whether anything was closed.
231
+ */
232
+ async function sweepPanes(
233
+ deps: RunSessionDeps,
234
+ ): Promise<{ kept: KeptPane[]; keptIn: Set<string>; closedAny: boolean }> {
235
+ const kept: KeptPane[] = [];
236
+ const keptIn = new Set<string>();
237
+ let closedAny = false;
238
+ const marks = (await readAllMarks(deps.home)).filter(
239
+ (mark) =>
240
+ mark.panes !== undefined &&
241
+ (mark.ended === true || liveness(mark, deps.probe, deps.exists) === "dead"),
242
+ );
243
+ const holding = () => {
244
+ for (const mark of marks) {
245
+ for (const [session, panes] of Object.entries(mark.panes ?? {})) {
246
+ for (const pane of panes) if (pane.kept) keptIn.add(`${session}/${pane.workspaceId}`);
247
+ }
248
+ }
249
+ return { kept, keptIn, closedAny };
250
+ };
251
+ if (marks.length === 0) return holding();
252
+ const sessions = await deps.run({
253
+ argv: ["herdr", "session", "list", "--json"],
254
+ timeoutMs: 10_000,
255
+ });
256
+ // A list that can't be read is no session found gone.
257
+ if (sessions.exitCode !== 0) return holding();
258
+ const running = new Set(
259
+ parseSessions(sessions.stdout)
260
+ .filter((session) => session.running)
261
+ .map((session) => session.name),
262
+ );
263
+ const listings = new Map<string, Promise<ListedPane[] | undefined>>();
264
+ const listed = (session: string) => {
265
+ let listing = listings.get(session);
266
+ if (!listing) {
267
+ listing = deps
268
+ .run({ argv: ["herdr", "--session", session, "pane", "list"], timeoutMs: 10_000 })
269
+ .then((result) => (result.exitCode === 0 ? parsePanes(result.stdout) : undefined));
270
+ listings.set(session, listing);
271
+ }
272
+ return listing;
273
+ };
274
+ for (const mark of marks) {
275
+ const left: Record<string, MarkedPane[]> = {};
276
+ for (const [session, panes] of Object.entries(mark.panes ?? {})) {
277
+ if (!running.has(session)) continue;
278
+ const open = await listed(session);
279
+ if (!open) {
280
+ left[session] = panes;
281
+ continue;
282
+ }
283
+ const still: MarkedPane[] = [];
284
+ for (const pane of panes) {
285
+ const there = open.find((candidate) => candidate.id === pane.paneId);
286
+ if (!there) continue;
287
+ const same = pane.terminalId !== undefined && there.terminalId === pane.terminalId;
288
+ if (pane.terminalId === undefined) {
289
+ // Nothing tells it from another that took its id: left for the operator, and named.
290
+ still.push(pane);
291
+ continue;
292
+ }
293
+ if (!same) continue;
294
+ if (pane.kept) {
295
+ still.push(pane);
296
+ kept.push({ run: mark.label, session, paneId: pane.paneId });
297
+ continue;
298
+ }
299
+ const closing = await deps.run({
300
+ argv: ["herdr", "--session", session, "pane", "close", pane.paneId],
301
+ timeoutMs: 10_000,
302
+ });
303
+ if (closing.exitCode === 0) closedAny = true;
304
+ else still.push(pane);
305
+ }
306
+ if (still.length > 0) left[session] = still;
307
+ }
308
+ if (JSON.stringify(left) !== JSON.stringify(mark.panes)) {
309
+ const { file, session: _session, ...rest } = mark;
310
+ mark.panes = left;
311
+ await rewriteMark(file, { ...rest, panes: left });
312
+ }
313
+ }
314
+ return holding();
315
+ }
316
+
317
+ type ListedPane = { id: string; terminalId?: string; workspaceId?: string };
318
+
319
+ /** `pane list`'s panes, undefined when unreadable. */
320
+ function parsePanes(stdout: string): ListedPane[] | undefined {
321
+ let panes: unknown;
322
+ try {
323
+ panes = JSON.parse(stdout).result?.panes;
324
+ } catch {
325
+ return undefined;
326
+ }
327
+ if (!Array.isArray(panes)) return undefined;
328
+ return panes.flatMap((pane) =>
329
+ typeof pane?.pane_id === "string"
330
+ ? [
331
+ {
332
+ id: pane.pane_id,
333
+ ...(typeof pane.terminal_id === "string" ? { terminalId: pane.terminal_id } : {}),
334
+ ...(typeof pane.workspace_id === "string" ? { workspaceId: pane.workspace_id } : {}),
335
+ },
336
+ ]
337
+ : [],
338
+ );
339
+ }
340
+
341
+ async function listWorkspaces(
342
+ name: string,
343
+ deps: RunSessionDeps,
344
+ ): Promise<ListedWorkspace[] | undefined> {
345
+ const listed = await deps.run({
346
+ argv: ["herdr", "--session", name, "workspace", "list"],
347
+ timeoutMs: 5_000,
348
+ });
349
+ return listed.exitCode === 0 ? parseWorkspaces(listed.stdout) : undefined;
350
+ }
351
+
352
+ /**
353
+ * Just before a restart: still no workspace, and no run that has not ended holding a mark. A mark
354
+ * is written before its workspace, so a run opening one in the meantime is seen.
355
+ */
356
+ async function stillUnused(name: string, deps: RunSessionDeps): Promise<boolean> {
357
+ const listed = await deps.run({
358
+ argv: ["herdr", "--session", name, "workspace", "list"],
359
+ timeoutMs: 5_000,
360
+ });
361
+ const workspaces = listed.exitCode === 0 ? parseWorkspaces(listed.stdout) : undefined;
362
+ if (workspaces === undefined || workspaces.length > 0) return false;
363
+ const marks = await readMarks(deps.home, name);
364
+ return marks.every((mark) => liveness(mark, deps.probe, deps.exists) === "dead");
365
+ }
366
+
367
+ /** Starts `name`'s server, as awf's own, and waits until it answers; its workspaces. */
368
+ async function startServer(
369
+ name: string,
370
+ sessionDir: string | undefined,
371
+ deps: RunSessionDeps,
372
+ ): Promise<ListedWorkspace[] | undefined> {
373
+ const herdr = Bun.which("herdr", { PATH: deps.environment.PATH ?? "" });
374
+ if (!herdr) throw new Error("herdr is not on PATH");
375
+ const config = await quietConfig(deps.home);
376
+ await (deps.start ?? startDetached)([herdr, "--session", name, "server"], {
377
+ cwd: deps.home,
378
+ env: { ...serverEnvironment(deps.environment), HERDR_CONFIG_PATH: config },
379
+ }).catch((error: unknown) => {
380
+ throw new Error(`could not start Herdr session ${name}: ${messageOf(error)}`);
381
+ });
382
+ return answering(name, sessionDir, deps);
383
+ }
384
+
385
+ /**
386
+ * Whether the server runs a Herdr other than the one installed, as after `herdr update`: Herdr
387
+ * says so itself. Undefined when it can't be read, which is no reason to stop a run.
388
+ */
389
+ async function serverVersion(
390
+ name: string,
391
+ deps: RunSessionDeps,
392
+ ): Promise<{ stale: boolean; server: string; installed: string } | undefined> {
393
+ const status = await deps.run({
394
+ argv: ["herdr", "--session", name, "status", "server", "--json"],
395
+ timeoutMs: 10_000,
396
+ });
397
+ if (status.exitCode !== 0) return undefined;
398
+ let read: { version?: unknown; server_binary_stale?: unknown; restart_needed?: unknown };
399
+ try {
400
+ read = JSON.parse(status.stdout);
401
+ } catch {
402
+ return undefined;
403
+ }
404
+ const stale = read.server_binary_stale === true || read.restart_needed === true;
405
+ if (!stale) return { stale, server: String(read.version), installed: String(read.version) };
406
+ const cli = await deps.run({ argv: ["herdr", "--version"], timeoutMs: 10_000 });
407
+ const installed = /\d+\.\d+\.\d+\S*/.exec(cli.stdout)?.[0] ?? "unknown";
408
+ return { stale, server: typeof read.version === "string" ? read.version : "unknown", installed };
409
+ }
410
+
411
+ /** The operator's allowlisted variables and a system `PATH`: nothing of the starting shell's own. */
412
+ export function serverEnvironment(
413
+ environment: Readonly<Record<string, string | undefined>>,
414
+ ): Record<string, string> {
415
+ const kept: Record<string, string> = { PATH: SYSTEM_PATH };
416
+ for (const [key, value] of Object.entries(environment)) {
417
+ const allowed =
418
+ SERVER_ENVIRONMENT.includes(key) ||
419
+ SERVER_ENVIRONMENT_PREFIXES.some((prefix) => key.startsWith(prefix));
420
+ if (value !== undefined && allowed) {
421
+ kept[key] = value;
422
+ }
423
+ }
424
+ return kept;
425
+ }
426
+
427
+ /**
428
+ * `~/.awf/herdr/config.toml`, written when absent and otherwise kept, so an operator may edit it.
429
+ * Linked in whole, so a concurrent run never starts its server from a half-written one.
430
+ */
431
+ async function quietConfig(home: string): Promise<string> {
432
+ const file = join(machinePaths(home).herdr, "config.toml");
433
+ await mkdir(dirname(file), { recursive: true });
434
+ const temporary = `${file}.${randomBytes(4).toString("hex")}.tmp`;
435
+ try {
436
+ await writeFile(temporary, QUIET_CONFIG, { flag: "wx" });
437
+ await link(temporary, file);
438
+ } catch (error) {
439
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
440
+ } finally {
441
+ await unlink(temporary).catch(() => undefined);
442
+ }
443
+ return file;
444
+ }
445
+
446
+ /**
447
+ * Until the session answers. A start that lost a race to a concurrent run fails to bind, and this
448
+ * finds the winner's server.
449
+ */
450
+ async function answering(
451
+ name: string,
452
+ sessionDir: string | undefined,
453
+ deps: RunSessionDeps,
454
+ ): Promise<ListedWorkspace[] | undefined> {
455
+ const now = deps.now ?? Date.now;
456
+ const sleep = deps.sleep ?? ((ms: number) => Bun.sleep(ms));
457
+ const by = now() + READY_WITHIN_MS;
458
+ for (;;) {
459
+ const listed = await deps.run({
460
+ argv: ["herdr", "--session", name, "workspace", "list"],
461
+ timeoutMs: Math.min(5_000, Math.max(1, by - now())),
462
+ });
463
+ if (listed.exitCode === 0) return parseWorkspaces(listed.stdout);
464
+ if (now() >= by) {
465
+ const log = sessionDir
466
+ ? `its log is under ${sessionDir}`
467
+ : "`herdr session list` says where its log is";
468
+ throw new Error(
469
+ `Herdr session ${name} did not answer within ${READY_WITHIN_MS / 1000}s; ${log}, and \`herdr session attach ${name}\` shows it`,
470
+ );
471
+ }
472
+ await sleep(READY_POLL_MS);
473
+ }
474
+ }
475
+
476
+ const startDetached: StartServer = (argv, options) =>
477
+ new Promise((resolve, reject) => {
478
+ const child = spawn(argv[0]!, argv.slice(1), {
479
+ cwd: options.cwd,
480
+ env: options.env,
481
+ detached: true,
482
+ stdio: "ignore",
483
+ });
484
+ child.once("spawn", () => resolve());
485
+ child.once("error", reject);
486
+ child.unref();
487
+ });
488
+
489
+ type ListedWorkspace = { id: string; label: string };
490
+
491
+ /** `workspace list`'s workspaces, undefined when unreadable; one without an id or label is skipped. */
492
+ function parseWorkspaces(stdout: string): ListedWorkspace[] | undefined {
493
+ let workspaces: unknown;
494
+ try {
495
+ workspaces = JSON.parse(stdout).result?.workspaces;
496
+ } catch {
497
+ return undefined;
498
+ }
499
+ if (!Array.isArray(workspaces)) return undefined;
500
+ return workspaces.flatMap((workspace) =>
501
+ typeof workspace?.workspace_id === "string" && typeof workspace.label === "string"
502
+ ? [{ id: workspace.workspace_id, label: workspace.label }]
503
+ : [],
504
+ );
505
+ }
506
+
507
+ type ListedSession = { name: string; running: boolean; socketPath?: string; sessionDir?: string };
508
+
509
+ /** `herdr session list --json`, read leniently: an entry without a name is skipped. */
510
+ export function parseSessions(stdout: string): ListedSession[] {
511
+ let sessions: unknown;
512
+ try {
513
+ sessions = JSON.parse(stdout).sessions;
514
+ } catch {
515
+ return [];
516
+ }
517
+ if (!Array.isArray(sessions)) return [];
518
+ return sessions.flatMap((session) =>
519
+ typeof session?.name === "string"
520
+ ? [
521
+ {
522
+ name: session.name,
523
+ running: session.running === true,
524
+ ...(typeof session.socket_path === "string" ? { socketPath: session.socket_path } : {}),
525
+ ...(typeof session.session_dir === "string" ? { sessionDir: session.session_dir } : {}),
526
+ },
527
+ ]
528
+ : [],
529
+ );
530
+ }