@agentswf/engine 0.0.0-stage → 0.1.0-beta.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.
Files changed (82) 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/init-command.ts +240 -0
  29. package/src/init-template/AGENTS.md.template +16 -0
  30. package/src/init-template/hello/workflow.test.ts.template +17 -0
  31. package/src/init-template/hello/workflow.ts.template +26 -0
  32. package/src/init-template/tsconfig.json.template +11 -0
  33. package/src/jsonl.ts +74 -0
  34. package/src/logs-command.ts +605 -0
  35. package/src/machine.ts +27 -0
  36. package/src/message-operation.ts +245 -0
  37. package/src/messaging/limits.ts +35 -0
  38. package/src/messaging/messaging.ts +1303 -0
  39. package/src/messaging/routes.ts +79 -0
  40. package/src/messaging/text.ts +251 -0
  41. package/src/operation-events.ts +182 -0
  42. package/src/operation-liveness.ts +674 -0
  43. package/src/operator-aliases.ts +10 -0
  44. package/src/operator-cli.ts +600 -0
  45. package/src/operator-runtime.ts +503 -0
  46. package/src/pane-layout.ts +125 -0
  47. package/src/progress-view.ts +568 -0
  48. package/src/result-slots.ts +325 -0
  49. package/src/result-validation.ts +47 -0
  50. package/src/run-command.ts +242 -0
  51. package/src/run-commands.ts +215 -0
  52. package/src/run-event-log.ts +72 -0
  53. package/src/run-prepare.ts +178 -0
  54. package/src/run-progress.ts +137 -0
  55. package/src/run-read.ts +425 -0
  56. package/src/run-state.ts +120 -0
  57. package/src/run-usage.ts +393 -0
  58. package/src/run-view.ts +225 -0
  59. package/src/runs-command.ts +356 -0
  60. package/src/runs.ts +645 -0
  61. package/src/sandbox-homes.ts +211 -0
  62. package/src/sandboxes.ts +521 -0
  63. package/src/skills/fetch.ts +305 -0
  64. package/src/skills/run-skills.ts +181 -0
  65. package/src/skills/sources.ts +87 -0
  66. package/src/skills/tree.ts +60 -0
  67. package/src/stage-ledger.ts +535 -0
  68. package/src/stage-plan.ts +127 -0
  69. package/src/stage-schema.ts +88 -0
  70. package/src/stopped.ts +36 -0
  71. package/src/test-command.ts +110 -0
  72. package/src/testing/operation-events.ts +89 -0
  73. package/src/testing.ts +164 -0
  74. package/src/workflow-loader.ts +113 -0
  75. package/src/workflow-runner.ts +3666 -0
  76. package/src/workflow-test-preload.ts +13 -0
  77. package/src/workflow-testing/decisions.ts +129 -0
  78. package/src/workflow-testing/host.ts +811 -0
  79. package/src/workflow-testing/index.ts +420 -0
  80. package/src/workflow-testing/script.ts +447 -0
  81. package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
  82. package/README.md +0 -3
@@ -0,0 +1,503 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { HarnessAllowance } from "@agentswf/contract/records";
5
+ import type { SandboxEnvironmentKey } from "@agentswf/contract/workflow";
6
+ import {
7
+ type Absent,
8
+ type CallerPane,
9
+ callingSession,
10
+ createCallerHostFactory,
11
+ createHeadlessRunHostFactory,
12
+ createHerdrRunHostFactory,
13
+ createPlacementHostFactory,
14
+ createSessionAccounting,
15
+ findSession,
16
+ HARNESSES,
17
+ type Harness,
18
+ type HerdrConfig,
19
+ harnessState,
20
+ isAbsent,
21
+ type RunProcess,
22
+ readAllowance,
23
+ readClaudeBilling,
24
+ readCodexBilling,
25
+ readCursorLogin,
26
+ runProcess,
27
+ withholding,
28
+ } from "@agentswf/harness";
29
+ import type {
30
+ AgentRunHostFactory,
31
+ AgentRuntimeConfig,
32
+ CallingSession,
33
+ } from "@agentswf/harness/adapter";
34
+ import type { SandboxProviders } from "@agentswf/sandbox";
35
+ import { createDockerProvider, findDocker } from "@agentswf/sandbox/docker";
36
+ import { createSrtProvider, findSrt } from "@agentswf/sandbox/srt";
37
+ import { createOpenRouterProvider } from "./decisions/openrouter";
38
+ import type { DecisionInstallation } from "./decisions/seam";
39
+ import { messageOf } from "./errors";
40
+ import { findOrigin, namedSession, workspaceLock } from "./herdr-placement";
41
+ import {
42
+ ensureRunSession,
43
+ parseSessions,
44
+ type RunSession,
45
+ runSessionName,
46
+ } from "./herdr-run-session";
47
+ import { markWorkspace } from "./herdr-workspace-marks";
48
+
49
+ export type { RunSession } from "./herdr-run-session";
50
+
51
+ import { OPERATOR_ALIASES } from "./operator-aliases";
52
+
53
+ export type OperatorRuntimeInstallation = {
54
+ config: AgentRuntimeConfig;
55
+ /** The sandbox providers this machine has, and the one a spec naming none runs in. */
56
+ sandboxes?: SandboxProviders;
57
+ /** The decision models workflows may ask, and their aliases. */
58
+ decisions?: DecisionInstallation;
59
+ cleanup(): Promise<void>;
60
+ };
61
+
62
+ export type OperatorRuntimeOptions = {
63
+ run?: RunProcess;
64
+ environment?: Readonly<Record<string, string | undefined>>;
65
+ /** A tab in the run's workspace for each sandbox's own Herdr. On unless false. */
66
+ watchSandboxes?: boolean;
67
+ /** The session `awf run --here` was started from, found in the Herdr session named (ADR 0010). */
68
+ caller?: { pane: CallerPane; session: string };
69
+ /**
70
+ * Where `awf run` was typed: without `--here`, the session it was started from is looked for in
71
+ * its environment, for a workflow to fork (story 027).
72
+ */
73
+ shellCwd?: string;
74
+ /** The operator's home. */
75
+ home?: string;
76
+ /** Told once, when the first pane agent's session is ready. */
77
+ onRunSession?: (session: RunSession) => void;
78
+ };
79
+
80
+ /**
81
+ * One run host for every agent: a Herdr tab for a pane agent, a subprocess per turn for a
82
+ * headless one. `WITHHELD_ENVIRONMENT` is cleared from both.
83
+ */
84
+ export async function installOperatorRuntime(
85
+ timeoutMilliseconds: number,
86
+ options: OperatorRuntimeOptions = {},
87
+ ): Promise<OperatorRuntimeInstallation> {
88
+ const {
89
+ run = runProcess,
90
+ environment = process.env,
91
+ watchSandboxes = true,
92
+ caller,
93
+ shellCwd,
94
+ home = homedir(),
95
+ onRunSession,
96
+ } = options;
97
+ const unmetered = withholding(run, WITHHELD_ENVIRONMENT);
98
+ refuseMeteredCredentials(environment);
99
+ // Checked now, before any stage spends anything; made ready only for the first pane agent.
100
+ const name = runSessionName(environment);
101
+ const session = runSessionOnce(() => name, { run, environment, home }, onRunSession);
102
+ const runConfig = (session: string): HerdrConfig => ({
103
+ ...herdrConfig(session),
104
+ commandTimeoutMs: Math.min(timeoutMilliseconds, 150_000),
105
+ emptyEnvironment: PANE_WITHHELD_ENVIRONMENT,
106
+ acceptWorkspaceTrust: true,
107
+ watchSandboxes,
108
+ });
109
+ const found = caller && (await handedOver(caller.pane, environment));
110
+ const calling = caller
111
+ ? typeof found === "string"
112
+ ? found
113
+ : undefined
114
+ : shellCwd === undefined
115
+ ? undefined
116
+ : await startedFrom(environment, shellCwd, unmetered);
117
+ const host = loginChecked(
118
+ unmetered,
119
+ createPlacementHostFactory({
120
+ // The session is made ready when the first pane agent opens, so an all-headless run never
121
+ // calls Herdr.
122
+ pane: {
123
+ accounting: createSessionAccounting(unmetered),
124
+ openRun: async (spec) => {
125
+ const { name } = await session();
126
+ const mark = await markWorkspace(home, name, spec.label ?? spec.runId);
127
+ const config: HerdrConfig = {
128
+ ...runConfig(name),
129
+ onRunWorkspace: mark.bind,
130
+ sessionFor: (named) => namedSession(named, { run, environment, home }),
131
+ origin: () =>
132
+ findOrigin({
133
+ run,
134
+ environment,
135
+ runSession: name,
136
+ ...(caller ? { callerPane: caller.pane.paneId } : {}),
137
+ }),
138
+ lockWorkspace: workspaceLock(home),
139
+ onPanes: (session, panes) => void mark.panes(session, panes),
140
+ };
141
+ const host = await createHerdrRunHostFactory(config, run)
142
+ .openRun(spec)
143
+ .catch(async (error: unknown) => {
144
+ await mark.release();
145
+ throw error;
146
+ });
147
+ // Kept when the workspace would not close: a dead run's mark is what lets a later run
148
+ // close it.
149
+ const close: typeof host.close = async (reason) => {
150
+ await host.close(reason);
151
+ await mark.release();
152
+ };
153
+ return {
154
+ openAgent: (request) => host.openAgent(request),
155
+ inspect: () => host.inspect(),
156
+ close,
157
+ };
158
+ },
159
+ },
160
+ headless: createHeadlessRunHostFactory({}, unmetered),
161
+ ...(caller
162
+ ? {
163
+ caller: createCallerHostFactory(
164
+ runConfig(caller.session),
165
+ caller.pane,
166
+ run,
167
+ typeof found === "object" ? found : undefined,
168
+ ),
169
+ }
170
+ : {}),
171
+ ...(calling === undefined ? {} : { calling }),
172
+ }),
173
+ );
174
+ return {
175
+ config: {
176
+ aliases: OPERATOR_ALIASES,
177
+ host,
178
+ },
179
+ sandboxes: await installSandboxes(environment),
180
+ decisions: installDecisions({ OPENROUTER_API_KEY: await openRouterKey(environment) }),
181
+ // Nothing to undo: the agent's `wf` is a launcher the control plane installs beside its own
182
+ // socket, and the control plane removes both when the run closes.
183
+ cleanup: async () => undefined,
184
+ };
185
+ }
186
+
187
+ /** A session file written longer ago than this names a session that is not the one waiting. */
188
+ const CALLING_QUIET_MS = 10 * 60_000;
189
+
190
+ /**
191
+ * The session `awf run` was started from, as a tool call of an agent's: each harness's session
192
+ * variable this process has, kept where that session's file was written lately, since a terminal
193
+ * or a Herdr server started from an agent's shell passes on a variable long after its session.
194
+ * The newest, where more than one is; why none, where none is.
195
+ */
196
+ async function startedFrom(
197
+ environment: Readonly<Record<string, string | undefined>>,
198
+ shellCwd: string,
199
+ run: RunProcess,
200
+ ): Promise<CallingSession | string> {
201
+ const named = (Object.keys(HARNESSES) as Harness[]).flatMap((harness) => {
202
+ const variable = HARNESSES[harness].sessionEnv;
203
+ const session = variable ? environment[variable]?.trim() : undefined;
204
+ return variable && session ? [{ harness, session, variable }] : [];
205
+ });
206
+ if (named.length === 0) return "awf run was not started from an agent's shell";
207
+ const found = await Promise.all(
208
+ named.map(async (each) => ({
209
+ ...each,
210
+ file: await findSession(each.harness, each.session, shellCwd, environment),
211
+ })),
212
+ );
213
+ const live = found
214
+ .flatMap(({ file, ...each }) =>
215
+ file && Date.now() - file.writtenAt <= CALLING_QUIET_MS ? [{ ...each, file }] : [],
216
+ )
217
+ .sort((left, right) => right.file.writtenAt - left.file.writtenAt)[0];
218
+ if (!live) {
219
+ const names = named.map(({ variable, session }) => `${variable}=${session}`).join(", ");
220
+ return `no session ${names} names was written in the last ${CALLING_QUIET_MS / 60_000} minutes`;
221
+ }
222
+ if (!live.file.model) return noModel(live.harness, live.session);
223
+ return callingSession(
224
+ { harness: live.harness, session: live.session, cwd: live.file.cwd },
225
+ run,
226
+ undefined,
227
+ environment,
228
+ );
229
+ }
230
+
231
+ /**
232
+ * Why a session whose files name no model is not forked: in its first turn it holds only the prompt
233
+ * that ran awf, and an operator's cursor chat records none.
234
+ */
235
+ function noModel(harness: Harness, session: string): string {
236
+ return `${harness} session ${session} has nothing to fork: its files hold no request naming a model, as in its first turn`;
237
+ }
238
+
239
+ /** The id and directory of the session `--here` handed over, where `--here` passed its id. */
240
+ async function handedOver(
241
+ pane: CallerPane,
242
+ environment: Readonly<Record<string, string | undefined>>,
243
+ ): Promise<{ session: string; cwd: string } | string> {
244
+ if (!pane.session) return "awf run --here passed no id for the calling session";
245
+ const file = await findSession(pane.harness, pane.session, pane.cwd, environment);
246
+ if (!file) return `${pane.harness} has no session ${pane.session} in the operator's home`;
247
+ if (!file.model) return noModel(pane.harness, pane.session);
248
+ return { session: pane.session, cwd: file.cwd };
249
+ }
250
+
251
+ /**
252
+ * How `awf allowance` reads each harness's plan: run as its agents are, so it reads the login they
253
+ * get, and a usage screen opens in the Herdr session their panes would.
254
+ */
255
+ export function allowanceReader(
256
+ environment: Readonly<Record<string, string | undefined>> = process.env,
257
+ run: RunProcess = runProcess,
258
+ signal?: AbortSignal,
259
+ home: string = homedir(),
260
+ ): (harness: Harness) => Promise<HarnessAllowance> {
261
+ const unmetered = withholding(run, WITHHELD_ENVIRONMENT);
262
+ const session = runSessionOnce(() => runSessionName(environment), { run, environment, home });
263
+ return (harness) =>
264
+ readAllowance(harness, {
265
+ run: unmetered,
266
+ now: Date.now,
267
+ herdr: async () => ({
268
+ ...herdrConfig((await session()).name),
269
+ emptyEnvironment: PANE_WITHHELD_ENVIRONMENT,
270
+ }),
271
+ ...(signal ? { signal } : {}),
272
+ });
273
+ }
274
+
275
+ /** Herdr in session `session`, where awf's runs open their tabs. */
276
+ export function herdrConfig(session: string): HerdrConfig {
277
+ return { session, workspaceLabel: "awf run", commandTimeoutMs: 10_000 };
278
+ }
279
+
280
+ /**
281
+ * The sandbox providers this machine has: srt when its CLI is on `PATH`, docker when its CLI is;
282
+ * its daemon is first asked when a sandbox opens. srt is the default when installed, as it runs
283
+ * the project's own toolchain, which an image lacks (story 004), and when it is there but cannot
284
+ * be set up: a spec naming none then fails saying why, rather than moving to docker unsaid.
285
+ */
286
+ export async function installSandboxes(
287
+ environment: Readonly<Record<string, string | undefined>>,
288
+ ): Promise<SandboxProviders> {
289
+ // A provider that cannot be set up fails only the specs that name it, not every run.
290
+ const unavailable: Partial<Record<SandboxEnvironmentKey, string>> = {};
291
+ const found = <T>(key: SandboxEnvironmentKey, finding: Promise<T | undefined>) =>
292
+ finding.catch((error: unknown) => {
293
+ unavailable[key] = messageOf(error);
294
+ return undefined;
295
+ });
296
+ const [srt, docker] = await Promise.all([
297
+ found("srt", findSrt(environment, Object.values(harnessState(environment)))),
298
+ found("docker", findDocker(environment)),
299
+ ]);
300
+ const installed = {
301
+ ...(srt ? { srt: createSrtProvider(srt) } : {}),
302
+ ...(docker ? { docker: createDockerProvider(docker) } : {}),
303
+ };
304
+ const fallback = srt || unavailable.srt ? "srt" : docker ? "docker" : undefined;
305
+ return {
306
+ installed,
307
+ ...(fallback ? { default: fallback } : {}),
308
+ ...(Object.keys(unavailable).length > 0 ? { unavailable } : {}),
309
+ };
310
+ }
311
+
312
+ /**
313
+ * `OPENROUTER_API_KEY` from the environment, else from `.env` in `cwd`. Only that name is read:
314
+ * awf runs bun with `--no-env-file`, and bunfig says the same here, because `.env` can hold a
315
+ * Claude token that would change how every agent logs in.
316
+ */
317
+ export async function openRouterKey(
318
+ environment: Readonly<Record<string, string | undefined>>,
319
+ cwd = process.cwd(),
320
+ ): Promise<string | undefined> {
321
+ if (environment.OPENROUTER_API_KEY?.trim()) return environment.OPENROUTER_API_KEY;
322
+ const text = await readFile(join(cwd, ".env"), "utf8").catch(() => "");
323
+ for (const line of text.split("\n")) {
324
+ const found = /^\s*(?:export\s+)?OPENROUTER_API_KEY\s*=\s*(.*?)\s*$/.exec(line);
325
+ if (found) return found[1]!.replace(/^(["'])(.*)\1$/, "$2");
326
+ }
327
+ return undefined;
328
+ }
329
+
330
+ /**
331
+ * Jev through OpenRouter, as `jev`, when `OPENROUTER_API_KEY` is set. The engine holds the key;
332
+ * no agent's environment has it. Without a usable key, asking for `jev` says why.
333
+ */
334
+ export function installDecisions(
335
+ environment: Readonly<Record<string, string | undefined>>,
336
+ ): DecisionInstallation {
337
+ const apiKey = environment.OPENROUTER_API_KEY?.trim();
338
+ const refused = (reason: string) => ({
339
+ providers: {},
340
+ aliases: {},
341
+ unavailable: { jev: reason },
342
+ });
343
+ if (!apiKey) return refused("OPENROUTER_API_KEY is not set");
344
+ // A key with a space or line break in it would be quoted back by the request that failed on it.
345
+ if (!/^[\x21-\x7e]+$/.test(apiKey)) {
346
+ return refused("OPENROUTER_API_KEY is not one token of printable characters");
347
+ }
348
+ return {
349
+ providers: { openrouter: createOpenRouterProvider({ apiKey }) },
350
+ aliases: { jev: { provider: "openrouter", model: "typesafe/jev-1.13" } },
351
+ };
352
+ }
353
+
354
+ /**
355
+ * The run session, made ready once for everything that asks. A failure is not kept: a run host
356
+ * opened later, or the next harness's usage screen, asks again.
357
+ */
358
+ function runSessionOnce(
359
+ name: () => string,
360
+ deps: Parameters<typeof ensureRunSession>[1],
361
+ onReady?: (session: RunSession) => void,
362
+ ): () => Promise<RunSession> {
363
+ let ready: Promise<RunSession> | undefined;
364
+ return () => {
365
+ ready ??= Promise.resolve()
366
+ .then(() => ensureRunSession(name(), deps))
367
+ .then((session) => {
368
+ // Saying where the agents are must never fail the agent.
369
+ try {
370
+ onReady?.(session);
371
+ } catch {}
372
+ return session;
373
+ })
374
+ .catch((error: unknown) => {
375
+ ready = undefined;
376
+ throw error;
377
+ });
378
+ return ready;
379
+ };
380
+ }
381
+
382
+ /**
383
+ * The Herdr session this process's pane is in, which `awf run --here` drives: the one owning
384
+ * `$HERDR_SOCKET_PATH`. Never `AWF_HERDR_SESSION`, which places a run's agents, not its caller.
385
+ */
386
+ export async function callerSession(
387
+ run: RunProcess,
388
+ environment: Readonly<Record<string, string | undefined>>,
389
+ ): Promise<string> {
390
+ const socket = environment.HERDR_SOCKET_PATH;
391
+ if (!socket) throw new Error("not in a Herdr pane: HERDR_SOCKET_PATH is not set");
392
+ const listed = await run({ argv: ["herdr", "session", "list", "--json"], timeoutMs: 10_000 });
393
+ if (listed.exitCode !== 0) {
394
+ throw new Error(`herdr session list failed: ${(listed.stderr || listed.stdout).trim()}`);
395
+ }
396
+ const name = parseSessions(listed.stdout).find((session) => session.socketPath === socket)?.name;
397
+ // Guessing would drive a session nobody is looking at.
398
+ if (name === undefined) {
399
+ throw new Error(`no Herdr session owns ${socket} (\`herdr session list --json\`)`);
400
+ }
401
+ return name;
402
+ }
403
+
404
+ const METERED_CREDENTIAL_ENVIRONMENT = [
405
+ ...new Set(Object.values(HARNESSES).flatMap((spec) => spec.meteredCredentials)),
406
+ ];
407
+
408
+ /** What every harness's session sets for the commands it runs, `awf` among them. */
409
+ const CALLING_SESSION_ENVIRONMENT = [
410
+ ...new Set(
411
+ Object.values(HARNESSES).flatMap((spec) => [...spec.callingSessionEnv, ...spec.settingsEnv]),
412
+ ),
413
+ ];
414
+
415
+ /**
416
+ * Unset for every agent: the metered credentials, which also refuse the run, the engine's own,
417
+ * which a harness such as pi would otherwise bill against, the calling session's markers and the
418
+ * ids `--here` passed on, and what would override the settings an agent is launched at.
419
+ */
420
+ const WITHHELD_ENVIRONMENT = [
421
+ ...METERED_CREDENTIAL_ENVIRONMENT,
422
+ "OPENROUTER_API_KEY",
423
+ ...CALLING_SESSION_ENVIRONMENT,
424
+ "AWF_CALLER_SESSIONS",
425
+ ];
426
+
427
+ /**
428
+ * Unset in a run's panes besides: the config the run session's server starts with, which a `herdr`
429
+ * started in a pane would otherwise read in place of the operator's.
430
+ */
431
+ const PANE_WITHHELD_ENVIRONMENT = [...WITHHELD_ENVIRONMENT, "HERDR_CONFIG_PATH"];
432
+
433
+ function refuseMeteredCredentials(environment: Readonly<Record<string, string | undefined>>): void {
434
+ const configured = METERED_CREDENTIAL_ENVIRONMENT.filter((name) => environment[name]?.trim());
435
+ if (configured.length > 0) {
436
+ throw new Error(
437
+ `subscription runtime refused metered credential environment: ${configured.join(", ")}`,
438
+ );
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Each harness's subscription login is checked when its first agent opens, once a run: a run that
444
+ * opens only codex agents needs no claude login, which is what lets one run in a box holding
445
+ * codex's credential alone.
446
+ */
447
+ function loginChecked(run: RunProcess, factory: AgentRunHostFactory): AgentRunHostFactory {
448
+ const checks = new Map<string, Promise<void>>();
449
+ const check = (harness: string): Promise<void> => {
450
+ const login = Object.hasOwn(LOGINS, harness) ? LOGINS[harness as Harness] : undefined;
451
+ if (!login || isAbsent(login)) return Promise.resolve();
452
+ let checking = checks.get(harness);
453
+ if (!checking) {
454
+ checking = login(run);
455
+ checks.set(harness, checking);
456
+ }
457
+ return checking;
458
+ };
459
+ return {
460
+ ...factory,
461
+ async openRun(spec) {
462
+ const host = await factory.openRun(spec);
463
+ return {
464
+ ...host,
465
+ async openAgent(request) {
466
+ // The calling session is logged in as the operator logged it in; awf did not start it.
467
+ if (!request.execution.caller) await check(request.execution.harness);
468
+ return host.openAgent(request);
469
+ },
470
+ inspect: () => host.inspect(),
471
+ close: (reason) => host.close(reason),
472
+ };
473
+ },
474
+ };
475
+ }
476
+
477
+ /** The subscription login each harness's agents need, or why none is checked. */
478
+ const LOGINS: Readonly<Record<Harness, ((run: RunProcess) => Promise<void>) | Absent>> = {
479
+ async claude(run) {
480
+ const claude = await readClaudeBilling(run);
481
+ if (claude !== "subscription") {
482
+ throw new Error(
483
+ `Claude subscription authentication is required (claude.ai login or \`claude setup-token\`); \`claude auth status\` reads as ${claude}`,
484
+ );
485
+ }
486
+ },
487
+ async codex(run) {
488
+ const codex = await readCodexBilling(run);
489
+ if (codex !== "subscription") {
490
+ throw new Error(
491
+ `Codex subscription authentication is required (ChatGPT login); \`codex login status\` reads as ${codex}`,
492
+ );
493
+ }
494
+ },
495
+ pi: { absent: "pi's login is per provider, and each agent's model names its own" },
496
+ async cursor(run) {
497
+ if (!(await readCursorLogin(run))) {
498
+ throw new Error(
499
+ "Cursor authentication is required (`cursor-agent login`); `cursor-agent status` reads as logged out",
500
+ );
501
+ }
502
+ },
503
+ };
@@ -0,0 +1,125 @@
1
+ import type { KeepPane, PaneLayout, TurnOutcome } from "@agentswf/contract/workflow";
2
+
3
+ /** A terminal session's name: Herdr makes `sessions/{name}` from it, and a leading `-` reads as an option. */
4
+ export const SESSION_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
5
+
6
+ const SHARE = { min: 0.2, max: 0.8 };
7
+ const NEW_TAB_FIELDS = new Set(["session", "workspace", "tab"]);
8
+ const BESIDE_FIELDS = new Set(["beside", "side", "share"]);
9
+ const KEEP: readonly KeepPane[] = ["never", "on-failure", "always"];
10
+
11
+ /**
12
+ * Refuses, before anything is placed, what the agent's own spec fixes about its pane: what a
13
+ * workflow, untyped at run time, could get wrong in `layout` or `keepPane`, and either on an agent
14
+ * with no pane of awf's to place (docs/design/pane-layout.md, "Refused at open").
15
+ */
16
+ export function checkPaneOptions(
17
+ key: string,
18
+ spec: { layout?: unknown; keepPane?: unknown },
19
+ agent: { headless: boolean; sandboxed: boolean },
20
+ ): void {
21
+ const refuse = (why: string): never => {
22
+ throw new Error(`agent ${key}: ${why}`);
23
+ };
24
+ if (spec.layout === undefined && spec.keepPane === undefined) return;
25
+ if (agent.headless) refuse("layout and keepPane place a pane, and a headless agent has none");
26
+ if (agent.sandboxed) {
27
+ refuse(
28
+ "layout and keepPane are not yet for an agent in a sandbox, whose pane is in its own Herdr",
29
+ );
30
+ }
31
+ if (spec.keepPane !== undefined && !KEEP.includes(spec.keepPane as KeepPane)) {
32
+ refuse(`keepPane is never, on-failure or always, not ${JSON.stringify(spec.keepPane)}`);
33
+ }
34
+ if (spec.layout === undefined) return;
35
+ const layout = spec.layout;
36
+ if (typeof layout !== "object" || layout === null || Array.isArray(layout)) {
37
+ refuse(`a layout is an object, not ${JSON.stringify(layout)}`);
38
+ }
39
+ const fields = Object.keys(layout as object).filter(
40
+ (field) => (layout as Record<string, unknown>)[field] !== undefined,
41
+ );
42
+ const given = layout as Record<string, unknown>;
43
+ if (given.beside !== undefined) {
44
+ const others = fields.filter((field) => !BESIDE_FIELDS.has(field));
45
+ if (others.length > 0) {
46
+ refuse(`a layout beside another pane takes side and share, not ${others.join(", ")}`);
47
+ }
48
+ if (typeof given.beside !== "string" || given.beside === "") {
49
+ refuse(`beside names an agent's key, not ${JSON.stringify(given.beside)}`);
50
+ }
51
+ if (given.beside === key) refuse("a pane cannot be placed beside itself");
52
+ if (given.side !== "right" && given.side !== "below") {
53
+ refuse(`side is right or below, not ${JSON.stringify(given.side)}`);
54
+ }
55
+ const share = given.share;
56
+ if (
57
+ share !== undefined &&
58
+ (typeof share !== "number" || !(share >= SHARE.min && share <= SHARE.max))
59
+ ) {
60
+ refuse(`share is from ${SHARE.min} to ${SHARE.max}, not ${JSON.stringify(share)}`);
61
+ }
62
+ return;
63
+ }
64
+ const others = fields.filter((field) => !NEW_TAB_FIELDS.has(field));
65
+ if (others.length > 0) {
66
+ refuse(
67
+ `a layout takes session, workspace and tab, or beside and side; not ${others.join(", ")}`,
68
+ );
69
+ }
70
+ const { session, workspace, tab } = given;
71
+ if (session !== undefined && (typeof session !== "string" || !SESSION_NAME.test(session))) {
72
+ refuse(
73
+ `session ${JSON.stringify(session)} is not a session name: lowercase letters, digits and -, up to 32, not starting with -`,
74
+ );
75
+ }
76
+ if (session !== undefined && workspace === "origin") {
77
+ refuse('"origin" is a workspace in its own session, so it takes no session');
78
+ }
79
+ if (
80
+ workspace !== undefined &&
81
+ workspace !== "run" &&
82
+ workspace !== "origin" &&
83
+ !(
84
+ typeof workspace === "object" &&
85
+ workspace !== null &&
86
+ !Array.isArray(workspace) &&
87
+ Object.keys(workspace).join() === "name" &&
88
+ nonEmpty((workspace as { name?: unknown }).name)
89
+ )
90
+ ) {
91
+ refuse(`workspace is "run", "origin" or { name }, not ${JSON.stringify(workspace)}`);
92
+ }
93
+ if (tab !== undefined && !nonEmpty(tab)) refuse(`tab is a label, not ${JSON.stringify(tab)}`);
94
+ }
95
+
96
+ /**
97
+ * A checked layout as it is kept and compared: fields given as `undefined` dropped, so a reopen
98
+ * that leaves one out matches.
99
+ */
100
+ export function storedLayout(layout: PaneLayout): PaneLayout {
101
+ return Object.fromEntries(
102
+ Object.entries(structuredClone(layout)).filter(([, value]) => value !== undefined),
103
+ ) as PaneLayout;
104
+ }
105
+
106
+ function nonEmpty(value: unknown): value is string {
107
+ return typeof value === "string" && value.trim() !== "";
108
+ }
109
+
110
+ /** The key a layout splits the pane of; absent for a new tab. */
111
+ export function besideOf(layout: PaneLayout | undefined): string | undefined {
112
+ return layout && "beside" in layout ? layout.beside : undefined;
113
+ }
114
+
115
+ /**
116
+ * Whether a done agent's pane stays, by `keepPane` and how its last operation ended: `on-failure`
117
+ * keeps it unless that was answered, or there was none.
118
+ */
119
+ export function keepsPane(
120
+ keepPane: KeepPane | undefined,
121
+ last: TurnOutcome<never>["kind"] | undefined,
122
+ ): boolean {
123
+ if (keepPane === "always") return true;
124
+ return keepPane === "on-failure" && last !== undefined && last !== "answered";
125
+ }