@agentswf/engine 0.1.0-beta.4 → 0.1.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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +35 -22
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +5 -7
  11. package/src/attempt-output.ts +15 -32
  12. package/src/attempt-view.ts +34 -33
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +41 -11
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +41 -33
  25. package/src/deadlines.ts +40 -7
  26. package/src/decisions/directory.ts +51 -40
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +43 -27
  32. package/src/emit-command.ts +88 -58
  33. package/src/emit.ts +254 -56
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +18 -6
  39. package/src/help/inbox.txt +6 -4
  40. package/src/help/logs.txt +9 -6
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +30 -11
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +7 -15
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +56 -133
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +40 -57
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.test.ts.template +3 -3
  55. package/src/init-template/hello/workflow.ts.template +5 -4
  56. package/src/jsonl.ts +10 -6
  57. package/src/keep-alive.ts +15 -12
  58. package/src/logs-command.ts +128 -47
  59. package/src/message-operation.ts +6 -3
  60. package/src/messaging/limits.ts +4 -4
  61. package/src/messaging/messaging.ts +31 -28
  62. package/src/messaging/routes.ts +1 -1
  63. package/src/messaging/text.ts +9 -7
  64. package/src/open-command.ts +770 -0
  65. package/src/operation-events.ts +4 -6
  66. package/src/operation-liveness.ts +19 -4
  67. package/src/operator-cli.ts +224 -94
  68. package/src/operator-runtime.ts +29 -7
  69. package/src/ops.ts +270 -0
  70. package/src/pane-layout.ts +105 -59
  71. package/src/progress-view.ts +10 -10
  72. package/src/project-engine.ts +79 -0
  73. package/src/projects.ts +13 -2
  74. package/src/refusals.ts +119 -0
  75. package/src/result-slots.ts +11 -31
  76. package/src/result-validation.ts +2 -2
  77. package/src/run-command.ts +45 -28
  78. package/src/run-commands.ts +43 -115
  79. package/src/run-event-log.ts +1 -1
  80. package/src/run-lines.ts +159 -0
  81. package/src/run-lock.ts +57 -1
  82. package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
  83. package/src/run-policy/stages.ts +113 -0
  84. package/src/run-policy/waits.ts +452 -0
  85. package/src/run-prepare.ts +205 -43
  86. package/src/run-read.ts +109 -41
  87. package/src/run-state.ts +170 -50
  88. package/src/run-text.ts +54 -0
  89. package/src/run-unreachable.ts +18 -0
  90. package/src/run-usage.ts +18 -12
  91. package/src/run-view.ts +81 -17
  92. package/src/runs-command.ts +83 -63
  93. package/src/runs.ts +310 -212
  94. package/src/sandbox-homes.ts +65 -13
  95. package/src/sandboxes.ts +182 -28
  96. package/src/session-lines.ts +93 -0
  97. package/src/skills/run-skills.ts +8 -1
  98. package/src/sources.ts +28 -29
  99. package/src/spend.ts +333 -0
  100. package/src/stage-ledger.ts +119 -131
  101. package/src/stop-crashed.ts +6 -5
  102. package/src/stopped.ts +4 -4
  103. package/src/test-command.ts +36 -14
  104. package/src/testing/operation-events.ts +3 -23
  105. package/src/testing.ts +73 -26
  106. package/src/tick-command.ts +145 -15
  107. package/src/tools.ts +25 -16
  108. package/src/turn-ends.ts +37 -0
  109. package/src/waits.ts +651 -0
  110. package/src/wake.ts +84 -54
  111. package/src/workflow-loader.ts +54 -4
  112. package/src/workflow-runner.ts +805 -580
  113. package/src/workflow-test-preload.ts +4 -1
  114. package/src/workflow-testing/coordinators.ts +3 -3
  115. package/src/workflow-testing/fake-client.ts +387 -0
  116. package/src/workflow-testing/host.ts +121 -53
  117. package/src/workflow-testing/index.ts +33 -842
  118. package/src/workflow-testing/run-test.ts +1128 -0
  119. package/src/workflow-testing/script.ts +74 -45
  120. package/src/hold-run-lock.fixture.ts +0 -8
  121. package/src/mailbox.ts +0 -990
  122. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  123. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
@@ -2,12 +2,13 @@
2
2
  import { existsSync, realpathSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join, relative, resolve } from "node:path";
5
- import type { HarnessAllowance } from "@agentswf/contract/records";
5
+ import type { HarnessAllowance, RefusalCode } from "@agentswf/contract/records";
6
6
  import type { JsonObject, JsonValue } from "@agentswf/contract/workflow";
7
- import { type Harness, handBack, type RunProcess } from "@agentswf/harness";
7
+ import { type Harness, handBack, type RunProcess, terminalAttached } from "@agentswf/harness";
8
8
  import manifest from "../package.json" with { type: "json" };
9
9
  import { stageFigures } from "./accounting/format";
10
- import { allowanceCommand } from "./allowance-command";
10
+ import { earlierSpent, unreadEarlier } from "./accounting/spent";
11
+ import { ALLOWANCE_V, allowanceCommand } from "./allowance-command";
11
12
  import {
12
13
  type AttemptEnd,
13
14
  cleanupFailed,
@@ -19,6 +20,7 @@ import {
19
20
  } from "./attempt-ending";
20
21
  import { type Kept, keepRecords } from "./attempt-output";
21
22
  import { continueCommand, printEnding, toldOf } from "./attempt-view";
23
+ import { placeFlags, shellLine } from "./command-line";
22
24
  import { type CommandName, helpOf, index, isCommand, reference, unknownCommand } from "./commands";
23
25
  import {
24
26
  type LineReader,
@@ -28,32 +30,44 @@ import {
28
30
  } from "./coordinator-prompt";
29
31
  import { coordinatorSocket, prepareCoordinatorDir } from "./coordinator-socket";
30
32
  import { EmitError, emitEvent, openEventRun } from "./emit";
31
- import { emitCommand } from "./emit-command";
33
+ import { didOf, EMIT_V, emitCommand, launchExit } from "./emit-command";
32
34
  import { messageOf } from "./errors";
35
+ import { isInside } from "./files";
36
+ import { installedEngine } from "./hand-over";
33
37
  import { type Caller, showOwnTab, startHere, takeCaller } from "./here";
34
- import { holderCommand } from "./holder-command";
35
- import { inboxCommand } from "./inbox-command";
38
+ import { HOLDER_V, holderCommand } from "./holder-command";
39
+ import { INBOX_V, inboxCommand } from "./inbox-command";
36
40
  import { initCommand } from "./init-command";
37
- import { logsCommand } from "./logs-command";
41
+ import { LOGS_V, logsCommand } from "./logs-command";
38
42
  import { coordinatorDir, machinePaths, sandboxesDirOf } from "./machine";
43
+ import { OPEN_V, openCommand } from "./open-command";
39
44
  import {
40
45
  allowanceReader,
41
46
  herdrConfig,
42
47
  installOperatorRuntime,
43
48
  type OperatorRuntimeInstallation,
49
+ openRuntime,
44
50
  type RunSession,
45
51
  } from "./operator-runtime";
46
52
  import { ANSI, describeRunSession, type Terminal, watchProgress } from "./progress-view";
47
- import { parseCommand, type RunCommand, usage } from "./run-command";
48
- import { placeFlags, shellLine, startedLine } from "./run-commands";
53
+ import { asksJson, NO_JSON, printRefusal, readFailureCode, refusalLine } from "./refusals";
54
+ import { parseCommand, RUN_V, type RunCommand, usage } from "./run-command";
55
+ import { startedLine } from "./run-commands";
49
56
  import { runLockFile, withRunLock } from "./run-lock";
50
- import { claimNext, loadAndPrepare, type PreparedRun, workspaceLabel } from "./run-prepare";
57
+ import {
58
+ claimNext,
59
+ loadAndPrepare,
60
+ type OpensEventRun,
61
+ type PreparedRun,
62
+ workspaceLabel,
63
+ } from "./run-prepare";
51
64
  import { findRun } from "./run-state";
52
65
  import { type Attempt, createRun, discardRun, endAttempt, type Run, RunRefused } from "./runs";
53
- import { runsCommand } from "./runs-command";
66
+ import { RUNS_V, runsCommand } from "./runs-command";
67
+ import { readSpent } from "./spend";
54
68
  import { stopCrashed } from "./stop-crashed";
55
69
  import { testCommand } from "./test-command";
56
- import { tickCommand } from "./tick-command";
70
+ import { TICK_V, tickCommand } from "./tick-command";
57
71
  import type { Launch } from "./wake";
58
72
  import type { EventWorkflow, LoadedWorkflow } from "./workflow-loader";
59
73
  import {
@@ -96,6 +110,11 @@ export type OperatorEnvironment = {
96
110
  readAllowance?: (harness: Harness) => Promise<HarnessAllowance>;
97
111
  /** Starts or continues an event run's attempt for `awf emit`; absent, a detached `awf run`. */
98
112
  launch?: Launch;
113
+ /**
114
+ * The background launch that started this process, which its attempt records; absent, the
115
+ * environment's `AWF_LAUNCH`.
116
+ */
117
+ launchId?: string;
99
118
  /**
100
119
  * Where agents may `--await-reply`, past the engine's list: for the live checks that decide
101
120
  * what goes on it (story 029's M2 gate).
@@ -112,6 +131,12 @@ export type OperatorEnvironment = {
112
131
  stdin?: () => Promise<string>;
113
132
  };
114
133
 
134
+ /** An operator environment with the operator's home and environment variables resolved. */
135
+ type Resolved = OperatorEnvironment & {
136
+ home: string;
137
+ environment: Readonly<Record<string, string | undefined>>;
138
+ };
139
+
115
140
  type Output = {
116
141
  stdout: (text: string) => void;
117
142
  stderr: (text: string) => void;
@@ -121,11 +146,38 @@ type Output = {
121
146
  now: () => number;
122
147
  };
123
148
 
149
+ /**
150
+ * `given`, its launch taken: the launch names this process's attempt only, so it is read once and
151
+ * kept from anything the process spawns, `process.env` and an injected environment alike, and a run
152
+ * an agent starts doesn't record it.
153
+ */
154
+ export function takeLaunch(given: OperatorEnvironment): OperatorEnvironment {
155
+ const launchId = given.launchId ?? (given.environment ?? process.env).AWF_LAUNCH;
156
+ delete process.env.AWF_LAUNCH;
157
+ const { AWF_LAUNCH: _, ...kept } = given.environment ?? {};
158
+ return {
159
+ ...given,
160
+ ...(given.environment ? { environment: kept } : {}),
161
+ ...(launchId === undefined ? {} : { launchId }),
162
+ };
163
+ }
164
+
124
165
  export async function runOperatorCli(
125
166
  argv: readonly string[],
126
- environment: OperatorEnvironment = {},
167
+ given: OperatorEnvironment = {},
127
168
  ): Promise<number> {
128
- const stdout = environment.stdout ?? ((text) => console.log(text));
169
+ const taken = takeLaunch(given);
170
+ const environment: Resolved = {
171
+ ...taken,
172
+ home: taken.home ?? homedir(),
173
+ environment: taken.environment ?? process.env,
174
+ };
175
+ const print = environment.stdout ?? ((text) => console.log(text));
176
+ let printed = false;
177
+ const stdout = (text: string) => {
178
+ printed = true;
179
+ print(text);
180
+ };
129
181
  const write = (text: string) => void process.stderr.write(text);
130
182
  const stderr =
131
183
  environment.stderr ??
@@ -152,16 +204,17 @@ export async function runOperatorCli(
152
204
  stderr,
153
205
  stdin: environment.stdin ?? (() => new Response(Bun.stdin.stream()).text()),
154
206
  ...(environment.stdin ? {} : { stdinIsTerminal: process.stdin.isTTY === true }),
155
- ...(environment.environment ? { environment: environment.environment } : {}),
156
- ...(environment.home ? { home: environment.home } : {}),
207
+ environment: environment.environment,
208
+ home: environment.home,
157
209
  });
158
210
  const wakes = {
159
- home: environment.home ?? homedir(),
160
- environment: environment.environment ?? process.env,
211
+ home: environment.home,
212
+ environment: environment.environment,
161
213
  ...(environment.launch ? { launch: environment.launch } : {}),
162
214
  };
215
+ const output = { stdout, stderr, notice, terminal, now };
163
216
  const handlers = {
164
- run: () => runCommand(argv, environment, { stdout, stderr, notice, terminal, now }),
217
+ run: () => runCommand(argv, environment, output),
165
218
  test: () => {
166
219
  const captured = environment.stdout || environment.stderr ? { stdout, stderr } : undefined;
167
220
  return testCommand(argv.slice(1), cwd, {
@@ -179,14 +232,36 @@ export async function runOperatorCli(
179
232
  }),
180
233
  emit: () => emitCommand(argv.slice(1), cwd, { stdout, stderr }, wakes),
181
234
  tick: () => tickCommand(argv.slice(1), cwd, { stdout, stderr }, wakes),
182
- runs: () => runsCommand(argv.slice(1), cwd, { stdout, stderr }, Date.now(), wakes.home),
183
- logs: () => logsCommand(argv.slice(1), cwd, { stdout, stderr }, undefined, wakes.home),
235
+ runs: () => runsCommand(argv.slice(1), cwd, { stdout, stderr }, now(), environment.home),
236
+ logs: () =>
237
+ logsCommand(
238
+ argv.slice(1),
239
+ cwd,
240
+ { stdout, stderr },
241
+ { now, sleep: (ms) => Bun.sleep(ms) },
242
+ environment.home,
243
+ ),
244
+ open: () =>
245
+ openCommand(
246
+ argv.slice(1),
247
+ cwd,
248
+ { stdout, stderr },
249
+ {
250
+ home: environment.home,
251
+ environment: environment.environment,
252
+ ...openRuntime(environment.environment),
253
+ ...(environment.signal ? { signal: environment.signal } : {}),
254
+ ...(environment.stdin
255
+ ? { stdin: environment.stdin }
256
+ : { stdinIsTerminal: process.stdin.isTTY === true }),
257
+ },
258
+ ),
184
259
  inbox: () =>
185
260
  inboxCommand(
186
261
  argv.slice(1),
187
262
  cwd,
188
263
  { stdout, stderr },
189
- { home: wakes.home, ...(environment.signal ? { signal: environment.signal } : {}) },
264
+ { home: environment.home, ...(environment.signal ? { signal: environment.signal } : {}) },
190
265
  ),
191
266
  reply: () => holder(),
192
267
  send: () => holder(),
@@ -196,12 +271,7 @@ export async function runOperatorCli(
196
271
  stderr,
197
272
  read:
198
273
  environment.readAllowance ??
199
- allowanceReader(
200
- environment.environment ?? process.env,
201
- undefined,
202
- environment.signal,
203
- environment.home ?? homedir(),
204
- ),
274
+ allowanceReader(environment.environment, undefined, environment.signal, environment.home),
205
275
  now,
206
276
  }),
207
277
  } satisfies Record<CommandName, () => Promise<number>>;
@@ -216,14 +286,56 @@ export async function runOperatorCli(
216
286
  return 2;
217
287
  }
218
288
  stdout(describeVersion());
289
+ // Never handed over: what the project here would run on is said beside it.
290
+ const project = installedEngine(cwd);
291
+ if (project) stdout(`this project's agentswf ${project.version}, in ${project.dir}`);
219
292
  return 0;
220
293
  }
221
294
  if (word === "help" || word === "-h" || word === "--help") return help(rest, stdout, stderr);
222
- if (isCommand(word)) return handlers[word]();
295
+ if (isCommand(word)) {
296
+ if (asksHelp(rest)) {
297
+ stdout(helpOf(word));
298
+ return 0;
299
+ }
300
+ const v = JSON_VERSION[word as keyof typeof JSON_VERSION];
301
+ if (v === undefined || !asksJson(rest)) return handlers[word]();
302
+ // What a command didn't foresee still ends its stdout with a document, unless it printed one.
303
+ try {
304
+ return await handlers[word]();
305
+ } catch (error) {
306
+ if (!printed) printRefusal({ stdout, stderr }, { json: true, v }, error, "internal-error");
307
+ // The stack is the only clue to a failure no command foresaw.
308
+ if (error instanceof Error && error.stack) stderr(error.stack);
309
+ return 1;
310
+ }
311
+ }
223
312
  stderr(unknownCommand(word));
224
313
  return 2;
225
314
  }
226
315
 
316
+ /** Each `--json` command's `"v"`, which a refusal carries: `awf runs`'s list's for `awf runs`. */
317
+ const JSON_VERSION = {
318
+ run: RUN_V,
319
+ emit: EMIT_V,
320
+ tick: TICK_V,
321
+ runs: RUNS_V,
322
+ logs: LOGS_V,
323
+ inbox: INBOX_V,
324
+ reply: HOLDER_V,
325
+ send: HOLDER_V,
326
+ allowance: ALLOWANCE_V,
327
+ open: OPEN_V,
328
+ } as const;
329
+
330
+ /**
331
+ * One rule for every command: `-h` or `--help` anywhere before `--` prints its help. A word that
332
+ * must be either, a message's text say, goes after `--`.
333
+ */
334
+ function asksHelp(words: readonly string[]): boolean {
335
+ const end = words.indexOf("--");
336
+ return (end < 0 ? words : words.slice(0, end)).some((word) => word === "-h" || word === "--help");
337
+ }
338
+
227
339
  /** `awf help`, `awf help {command}` and `awf help reference`; `-h` and `--help` are `help`. */
228
340
  function help(
229
341
  words: readonly string[],
@@ -248,63 +360,57 @@ function help(
248
360
  return 0;
249
361
  }
250
362
 
251
- /** `awf run`: parses it, then starts or continues the run, here or in a tab. */
252
363
  /**
253
364
  * `awf run --id` for a workflow with events: the run is made with no event and started detached, as
254
365
  * an event starts one, so it runs to its first wait and parks; one already there is only woken.
255
366
  */
256
367
  async function openRun(
257
- { loaded, id, argv }: { loaded: LoadedWorkflow; id: string; argv: readonly string[] },
258
- environment: OperatorEnvironment,
368
+ { loaded, id, argv, panes }: OpensEventRun,
369
+ environment: Resolved,
259
370
  { stdout, stderr }: Output,
260
371
  ): Promise<number> {
261
372
  try {
262
- const opened = await openEventRun(loaded.workflow as EventWorkflow, id, argv, {
263
- home: environment.home ?? homedir(),
264
- environment: environment.environment ?? process.env,
265
- ...(environment.launch ? { launch: environment.launch } : {}),
266
- });
267
- const did = opened.did === "none" ? opened.why : opened.did;
268
- stdout(
269
- `${opened.run}${opened.created ? "" : " was there"}${did === undefined ? "" : `: ${did}`}`,
373
+ const opened = await openEventRun(
374
+ loaded.workflow as EventWorkflow,
375
+ id,
376
+ argv,
377
+ {
378
+ home: environment.home,
379
+ environment: environment.environment,
380
+ ...(environment.launch ? { launch: environment.launch } : {}),
381
+ },
382
+ panes,
270
383
  );
271
- if (opened.why !== "launch-failed") return 0;
272
- stderr(`awf: the run's attempt did not start: ${opened.error}`);
273
- return 1;
384
+ stdout(`${opened.run}${opened.created ? "" : " was there"}${didOf(opened)}`);
385
+ return launchExit(opened, { stdout, stderr });
274
386
  } catch (error) {
275
- stderr(`awf: ${messageOf(error)}`);
387
+ printRefusal({ stdout, stderr }, NO_JSON, error, readFailureCode(error));
276
388
  return error instanceof EmitError ? 2 : 1;
277
389
  }
278
390
  }
279
391
 
392
+ /** `awf run`: parses it, then starts or continues the run, here or in a tab. */
280
393
  async function runCommand(
281
394
  argv: readonly string[],
282
- environment: OperatorEnvironment,
395
+ environment: Resolved,
283
396
  output: Output,
284
397
  ): Promise<number> {
285
- const { stdout, stderr, notice, terminal, now } = output;
286
- let parsed: RunCommand | "help";
398
+ const { stdout, stderr, notice, now } = output;
399
+ let parsed: RunCommand;
287
400
  try {
288
401
  parsed = parseCommand(argv, environment.cwd ?? process.cwd());
289
402
  } catch (error) {
290
- stderr(`awf: ${messageOf(error)}\n\n${usage}`);
403
+ printRefusal({ stdout, stderr }, { json: asksJson(argv), v: RUN_V }, error, "usage", usage);
291
404
  return 2;
292
405
  }
293
- if (parsed === "help") {
294
- stdout(usage);
295
- return 0;
296
- }
297
406
  let command: RunCommand = parsed;
407
+ // With --json, what makes no run is a refusal, as its sentence on stderr says.
408
+ const refused = (code: RefusalCode, error: string) =>
409
+ printRefusal({ stdout, stderr }, { json: command.json, v: RUN_V }, error, code);
298
410
  const meant = await runNamed(command);
299
411
  if (meant) {
300
- const look = shellLine([
301
- "awf",
302
- "runs",
303
- meant,
304
- ...(command.cwdGiven ? ["--cwd", command.cwd] : []),
305
- ...(command.runRootGiven ? ["--run-root", command.runRoot] : []),
306
- ]);
307
- stderr(`awf: ${command.workflowFile} is not a workflow file; did you mean: ${look}`);
412
+ const look = shellLine(["awf", "runs", meant, ...placeFlags(command)]);
413
+ refused("file", `${command.workflowFile} is not a workflow file; did you mean: ${look}`);
308
414
  return 2;
309
415
  }
310
416
  if (command.here) return startHere(argv, command, environment, stdout, stderr, notice);
@@ -314,19 +420,20 @@ async function runCommand(
314
420
  if (command.session !== undefined) await showOwnTab(environment);
315
421
  return exitCode;
316
422
  };
317
- const home = environment.home ?? homedir();
423
+ const { home } = environment;
318
424
  const { sandboxes, callers } = machinePaths(home);
319
425
  const ready = await loadAndPrepare(command, now(), home);
320
426
  if ("refused" in ready) {
321
- stderr(`awf: ${ready.refused}`);
427
+ refused(ready.code, ready.refused);
322
428
  return unstarted(2);
323
429
  }
324
430
  if ("opens" in ready) return openRun(ready.opens, environment, output);
325
431
  // After loading: an event run's root is its project's, which this checks too.
326
432
  command = ready.command;
327
- if (!relative(command.runRoot, sandboxes).startsWith("..")) {
328
- stderr(
329
- `awf: --run-root ${command.runRoot} holds ${sandboxes}, which every sandbox must reach and none may reach the run root`,
433
+ if (relative(command.runRoot, sandboxes) === "" || isInside(command.runRoot, sandboxes)) {
434
+ refused(
435
+ "usage",
436
+ `--run-root ${command.runRoot} holds ${sandboxes}, which every sandbox must reach and none may reach the run root`,
330
437
  );
331
438
  return unstarted(2);
332
439
  }
@@ -335,28 +442,24 @@ async function runCommand(
335
442
  // Loading the workflow imports operator-supplied code before anything listens to the signal, so
336
443
  // a Ctrl-C in it would otherwise be swallowed and have to be pressed again.
337
444
  if (environment.signal?.aborted) {
445
+ if (command.json) stdout(refusalLine(RUN_V, "not-started", CANCELLED));
338
446
  return unstarted(cancelledBeforeStart(environment.signal, stderr));
339
447
  }
340
448
  let calling: { caller: Caller; release: () => void } | undefined;
341
449
  if (command.session !== undefined) {
342
450
  const taken = await takeCaller(command.session, callers, environment);
343
451
  if (typeof taken === "string") {
344
- stderr(`awf: --session: ${taken}`);
452
+ refused("not-started", `--session: ${taken}`);
345
453
  return 1;
346
454
  }
347
455
  calling = taken;
348
456
  if (environment.signal?.aborted) {
349
457
  calling.release();
458
+ if (command.json) stdout(refusalLine(RUN_V, "not-started", CANCELLED));
350
459
  return cancelledBeforeStart(environment.signal, stderr);
351
460
  }
352
461
  }
353
- return runAttempt(command, ready, calling, environment, home, {
354
- stdout,
355
- stderr,
356
- notice,
357
- terminal,
358
- now,
359
- });
462
+ return runAttempt(command, ready, calling, environment, output);
360
463
  }
361
464
 
362
465
  /** The run `awf run {ref}` names where `{ref}` is no file: one meant for `awf runs`. */
@@ -374,11 +477,11 @@ async function runAttempt(
374
477
  command: RunCommand,
375
478
  { loaded, prepared }: { loaded: LoadedWorkflow; prepared: PreparedRun },
376
479
  calling: { caller: Caller; release: () => void } | undefined,
377
- environment: OperatorEnvironment,
378
- home: string,
480
+ environment: Resolved,
379
481
  output: Output,
380
482
  ): Promise<number> {
381
483
  const { stdout, stderr, notice, terminal, now } = output;
484
+ const { home } = environment;
382
485
  const { workflow } = loaded;
383
486
  const { meta } = workflow;
384
487
  /** The run, once it is claimed. */
@@ -398,21 +501,21 @@ async function runAttempt(
398
501
  if (failed) stderr(`awf: the calling session was not told the run ended: ${failed}`);
399
502
  };
400
503
 
401
- const refuse = async (why: string) => {
402
- stderr(`awf: ${why}`);
504
+ const refuse = async (why: string, code: RefusalCode) => {
505
+ printRefusal({ stdout, stderr }, { json: command.json, v: RUN_V }, why, code);
403
506
  await handOver(`did not start: ${why}`);
404
507
  return 2;
405
508
  };
406
509
  // The operator's, never where the workflow's agents work.
407
- const coordinatorRoot = coordinatorDir(home, environment.environment ?? process.env);
510
+ const coordinatorRoot = coordinatorDir(home, environment.environment);
408
511
  try {
409
512
  await prepareCoordinatorDir(
410
513
  coordinatorRoot,
411
514
  command.cwd,
412
- Boolean((environment.environment ?? process.env).AWF_COORDINATOR_DIR),
515
+ Boolean(environment.environment.AWF_COORDINATOR_DIR),
413
516
  );
414
517
  } catch (error) {
415
- return refuse(messageOf(error));
518
+ return refuse(messageOf(error), "internal-error");
416
519
  }
417
520
  try {
418
521
  run =
@@ -423,19 +526,21 @@ async function runAttempt(
423
526
  argv: command.workflowArgs,
424
527
  cwd: command.cwd,
425
528
  sandbox: (command.sandbox ?? null) as JsonValue,
529
+ ...(command.panes ? { panes: command.panes } : {}),
426
530
  }));
427
531
  } catch (error) {
428
532
  return refuse(
429
533
  `${error instanceof RunRefused ? "" : `${command.runRoot}: `}${messageOf(error)}`,
534
+ readFailureCode(error),
430
535
  );
431
536
  }
432
537
  let attempt: Attempt;
433
538
  try {
434
- attempt = await claimNext(run, loaded, command, notice, now());
539
+ attempt = await claimNext(run, loaded, command, notice, now(), environment.launchId);
435
540
  } catch (error) {
436
541
  // A run this call created and never ran is not left to hold its id.
437
542
  if (!prepared.continued) await discardRun(run).catch(() => undefined);
438
- return refuse(messageOf(error));
543
+ return refuse(messageOf(error), readFailureCode(error));
439
544
  }
440
545
  const { id, cwd } = run.record;
441
546
  const locked = <T>(work: () => Promise<T>) =>
@@ -449,7 +554,7 @@ async function runAttempt(
449
554
  if (run.record.events && previous && previous.ended === undefined)
450
555
  await stopCrashed(previous, workspaceLabel(meta.name, run.record.id, previous.attempt), {
451
556
  home,
452
- environment: environment.environment ?? process.env,
557
+ environment: environment.environment,
453
558
  }).catch((error: unknown) =>
454
559
  notice(
455
560
  `awf: what attempt ${previous.attempt} left running was not all stopped: ${messageOf(error)}`,
@@ -460,14 +565,26 @@ async function runAttempt(
460
565
  attempt,
461
566
  loaded,
462
567
  stderr,
568
+ now,
463
569
  ...(!run.record.events ? {} : { locked }),
464
570
  };
465
- const close = (end: AttemptEnd, kept: Kept, cleanupAlso?: string) =>
466
- printEnding(end, kept, {
571
+ const close = async (end: AttemptEnd, kept: Kept, cleanupAlso?: string) => {
572
+ const earlier = prepared.continued?.attempts ?? [];
573
+ // Priced now, from what each earlier attempt's operations recorded, its claim's settling included.
574
+ const spentBefore =
575
+ n > 1
576
+ ? await readSpent(run.dir, earlier).then(
577
+ (spent) => earlierSpent(spent, earlier, n),
578
+ // Still a total, with every earlier attempt's cost unknown.
579
+ () => unreadEarlier(earlier, n),
580
+ )
581
+ : undefined;
582
+ return printEnding(end, kept, {
467
583
  workflow,
468
584
  run: { id, dir: run.dir },
469
585
  n,
470
- earlier: prepared.continued?.attempts ?? [],
586
+ earlier,
587
+ ...(spentBefore ? { spentBefore } : {}),
471
588
  json: command.json,
472
589
  fromStage: command.fromStage !== undefined,
473
590
  now: now(),
@@ -479,6 +596,7 @@ async function runAttempt(
479
596
  stderr,
480
597
  notice,
481
598
  });
599
+ };
482
600
  // Timed from here: finding the calling session can take minutes the run itself never had.
483
601
  const startedAt = now();
484
602
  // Every way out from here writes the attempt's ending, before the hand-back: a session told the
@@ -497,7 +615,12 @@ async function runAttempt(
497
615
  const said = cancelled
498
616
  ? "run cancelled before it started"
499
617
  : `${end.ending.reason}; the run did not start`;
500
- stderr(`awf: ${said}, and nothing of it is kept`);
618
+ printRefusal(
619
+ { stdout, stderr },
620
+ { json: command.json, v: RUN_V },
621
+ `${said}, and nothing of it is kept`,
622
+ "not-started",
623
+ );
501
624
  await handOver(`did not start: ${cancelled ? "cancelled" : end.ending.reason}`);
502
625
  return end.exitCode;
503
626
  }
@@ -505,7 +628,7 @@ async function runAttempt(
505
628
  await handOver(toldOf(end, kept));
506
629
  return close(end, kept, cleanupAlso);
507
630
  };
508
- // Relied on: the session is told only from a pane agent's `openRun`, inside `startWorkflow`, by
631
+ // Relied on: the session is told only from a TUI agent's `openRun`, inside `startWorkflow`, by
509
632
  // when `placed` says it on the progress.
510
633
  let placed: (session: RunSession) => void = () => undefined;
511
634
  let installed: OperatorRuntimeInstallation;
@@ -543,7 +666,7 @@ async function runAttempt(
543
666
  placed = (session) => {
544
667
  progress.placedIn(session.name);
545
668
  const label = workspaceLabel(meta.name, id, n);
546
- const insideHerdr = (environment.environment ?? process.env).HERDR_ENV === "1";
669
+ const insideHerdr = environment.environment.HERDR_ENV === "1";
547
670
  for (const line of describeRunSession(session, label, insideHerdr)) progress.log(line);
548
671
  };
549
672
  // Interactive: a coordinator's questions are asked here, unless the output is for a program.
@@ -577,10 +700,12 @@ async function runAttempt(
577
700
  run: {
578
701
  dir: run.dir,
579
702
  id,
580
- attempt: n,
703
+ lines: attempt.lines,
704
+ ops: attempt.ops,
581
705
  label: workspaceLabel(meta.name, id, n),
582
706
  ...(command.fromStage === undefined ? {} : { fromStage: command.fromStage }),
583
707
  ...(command.values === undefined ? {} : { values: command.values.stages }),
708
+ ...(run.record.panes ? { panes: run.record.panes } : {}),
584
709
  ...(!events
585
710
  ? {}
586
711
  : {
@@ -603,7 +728,7 @@ async function runAttempt(
603
728
  await emitEvent(target, event, {
604
729
  from,
605
730
  home,
606
- environment: environment.environment ?? process.env,
731
+ environment: environment.environment,
607
732
  ...(environment.launch ? { launch: environment.launch } : {}),
608
733
  })
609
734
  ).outcome,
@@ -684,8 +809,10 @@ export function stderrLines(
684
809
  };
685
810
  }
686
811
 
812
+ const CANCELLED = "run cancelled before it started";
813
+
687
814
  function cancelledBeforeStart(signal: AbortSignal, stderr: (line: string) => void): number {
688
- stderr("awf: run cancelled before it started");
815
+ stderr(`awf: ${CANCELLED}`);
689
816
  return signalExitCode(signal.reason);
690
817
  }
691
818
 
@@ -724,16 +851,19 @@ const EXIT_GRACE_MS = 2_000;
724
851
  * Cancels `controller` at the first signal that asks the run to stop, and stops the process at
725
852
  * once at a second the operator sends, without the run's cleanup. Ctrl-C reaches the whole
726
853
  * process group and `bun awf` forwards it once more, so a repeat within `REPEAT_MS` is ignored:
727
- * taken as the second, it would kill the run mid-cleanup and leave its sandboxes behind. Returns
728
- * what removes the handlers.
854
+ * taken as the second, it would kill the run mid-cleanup and leave its sandboxes behind. While a
855
+ * person's TUI has the terminal, Ctrl-C is the TUI's: ending awf then would leave it writing a
856
+ * session nothing holds. Returns what removes the handlers.
729
857
  */
730
858
  export function cancelOnSignals(
731
859
  controller: AbortController,
732
860
  exit: (code: number) => void = (code) => process.exit(code),
733
861
  now: () => number = Date.now,
862
+ attached: () => boolean = terminalAttached,
734
863
  ): () => void {
735
864
  let first: number | undefined;
736
865
  const cancel = (signal: NodeJS.Signals) => {
866
+ if (signal === "SIGINT" && attached()) return;
737
867
  if (first === undefined) {
738
868
  first = now();
739
869
  controller.abort(signal);