@agentswf/engine 0.1.0-beta.5 → 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 (122) 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 +28 -55
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +4 -4
  11. package/src/attempt-output.ts +13 -31
  12. package/src/attempt-view.ts +33 -32
  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 +4 -6
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +27 -28
  25. package/src/deadlines.ts +6 -40
  26. package/src/decisions/directory.ts +50 -37
  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 +19 -33
  32. package/src/emit-command.ts +38 -22
  33. package/src/emit.ts +148 -59
  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 +11 -7
  39. package/src/help/inbox.txt +5 -3
  40. package/src/help/logs.txt +7 -5
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +13 -8
  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 +5 -13
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +54 -136
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +42 -56
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.ts.template +3 -2
  55. package/src/jsonl.ts +10 -6
  56. package/src/keep-alive.ts +7 -19
  57. package/src/logs-command.ts +134 -53
  58. package/src/message-operation.ts +6 -3
  59. package/src/messaging/messaging.ts +8 -10
  60. package/src/messaging/text.ts +2 -2
  61. package/src/open-command.ts +770 -0
  62. package/src/operation-events.ts +4 -6
  63. package/src/operation-liveness.ts +19 -4
  64. package/src/operator-cli.ts +206 -90
  65. package/src/operator-runtime.ts +24 -2
  66. package/src/ops.ts +270 -0
  67. package/src/pane-layout.ts +49 -96
  68. package/src/progress-view.ts +2 -2
  69. package/src/project-engine.ts +79 -0
  70. package/src/projects.ts +13 -2
  71. package/src/refusals.ts +119 -0
  72. package/src/result-slots.ts +11 -31
  73. package/src/result-validation.ts +2 -2
  74. package/src/run-command.ts +27 -31
  75. package/src/run-commands.ts +42 -114
  76. package/src/run-event-log.ts +1 -1
  77. package/src/run-lines.ts +159 -0
  78. package/src/run-lock.ts +57 -1
  79. package/src/run-policy/stage-plan.ts +154 -0
  80. package/src/run-policy/stages.ts +113 -0
  81. package/src/run-policy/waits.ts +452 -0
  82. package/src/run-prepare.ts +165 -41
  83. package/src/run-read.ts +109 -41
  84. package/src/run-state.ts +170 -50
  85. package/src/run-text.ts +54 -0
  86. package/src/run-unreachable.ts +18 -0
  87. package/src/run-usage.ts +17 -11
  88. package/src/run-view.ts +73 -14
  89. package/src/runs-command.ts +81 -63
  90. package/src/runs.ts +284 -222
  91. package/src/sandbox-homes.ts +65 -13
  92. package/src/sandboxes.ts +182 -28
  93. package/src/session-lines.ts +93 -0
  94. package/src/skills/run-skills.ts +8 -1
  95. package/src/sources.ts +2 -39
  96. package/src/spend.ts +333 -0
  97. package/src/stage-ledger.ts +111 -153
  98. package/src/stop-crashed.ts +6 -5
  99. package/src/stopped.ts +4 -4
  100. package/src/test-command.ts +37 -19
  101. package/src/testing/operation-events.ts +3 -23
  102. package/src/testing.ts +66 -18
  103. package/src/tick-command.ts +145 -15
  104. package/src/tools.ts +11 -30
  105. package/src/turn-ends.ts +37 -0
  106. package/src/waits.ts +651 -0
  107. package/src/wake.ts +84 -54
  108. package/src/workflow-loader.ts +49 -2
  109. package/src/workflow-runner.ts +525 -618
  110. package/src/workflow-test-preload.ts +4 -1
  111. package/src/workflow-testing/coordinators.ts +1 -1
  112. package/src/workflow-testing/fake-client.ts +387 -0
  113. package/src/workflow-testing/host.ts +28 -29
  114. package/src/workflow-testing/index.ts +9 -27
  115. package/src/workflow-testing/run-test.ts +201 -286
  116. package/src/workflow-testing/script.ts +7 -39
  117. package/src/deprecations.ts +0 -177
  118. package/src/hold-run-lock.fixture.ts +0 -8
  119. package/src/mailbox.ts +0 -972
  120. package/src/stage-plan.ts +0 -186
  121. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  122. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -67
@@ -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,24 +30,29 @@ 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
57
  import {
51
58
  claimNext,
@@ -56,10 +63,11 @@ import {
56
63
  } from "./run-prepare";
57
64
  import { findRun } from "./run-state";
58
65
  import { type Attempt, createRun, discardRun, endAttempt, type Run, RunRefused } from "./runs";
59
- import { runsCommand } from "./runs-command";
66
+ import { RUNS_V, runsCommand } from "./runs-command";
67
+ import { readSpent } from "./spend";
60
68
  import { stopCrashed } from "./stop-crashed";
61
69
  import { testCommand } from "./test-command";
62
- import { tickCommand } from "./tick-command";
70
+ import { TICK_V, tickCommand } from "./tick-command";
63
71
  import type { Launch } from "./wake";
64
72
  import type { EventWorkflow, LoadedWorkflow } from "./workflow-loader";
65
73
  import {
@@ -102,6 +110,11 @@ export type OperatorEnvironment = {
102
110
  readAllowance?: (harness: Harness) => Promise<HarnessAllowance>;
103
111
  /** Starts or continues an event run's attempt for `awf emit`; absent, a detached `awf run`. */
104
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;
105
118
  /**
106
119
  * Where agents may `--await-reply`, past the engine's list: for the live checks that decide
107
120
  * what goes on it (story 029's M2 gate).
@@ -118,6 +131,12 @@ export type OperatorEnvironment = {
118
131
  stdin?: () => Promise<string>;
119
132
  };
120
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
+
121
140
  type Output = {
122
141
  stdout: (text: string) => void;
123
142
  stderr: (text: string) => void;
@@ -127,11 +146,38 @@ type Output = {
127
146
  now: () => number;
128
147
  };
129
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
+
130
165
  export async function runOperatorCli(
131
166
  argv: readonly string[],
132
- environment: OperatorEnvironment = {},
167
+ given: OperatorEnvironment = {},
133
168
  ): Promise<number> {
134
- 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
+ };
135
181
  const write = (text: string) => void process.stderr.write(text);
136
182
  const stderr =
137
183
  environment.stderr ??
@@ -158,16 +204,17 @@ export async function runOperatorCli(
158
204
  stderr,
159
205
  stdin: environment.stdin ?? (() => new Response(Bun.stdin.stream()).text()),
160
206
  ...(environment.stdin ? {} : { stdinIsTerminal: process.stdin.isTTY === true }),
161
- ...(environment.environment ? { environment: environment.environment } : {}),
162
- ...(environment.home ? { home: environment.home } : {}),
207
+ environment: environment.environment,
208
+ home: environment.home,
163
209
  });
164
210
  const wakes = {
165
- home: environment.home ?? homedir(),
166
- environment: environment.environment ?? process.env,
211
+ home: environment.home,
212
+ environment: environment.environment,
167
213
  ...(environment.launch ? { launch: environment.launch } : {}),
168
214
  };
215
+ const output = { stdout, stderr, notice, terminal, now };
169
216
  const handlers = {
170
- run: () => runCommand(argv, environment, { stdout, stderr, notice, terminal, now }),
217
+ run: () => runCommand(argv, environment, output),
171
218
  test: () => {
172
219
  const captured = environment.stdout || environment.stderr ? { stdout, stderr } : undefined;
173
220
  return testCommand(argv.slice(1), cwd, {
@@ -185,14 +232,36 @@ export async function runOperatorCli(
185
232
  }),
186
233
  emit: () => emitCommand(argv.slice(1), cwd, { stdout, stderr }, wakes),
187
234
  tick: () => tickCommand(argv.slice(1), cwd, { stdout, stderr }, wakes),
188
- runs: () => runsCommand(argv.slice(1), cwd, { stdout, stderr }, Date.now(), wakes.home),
189
- 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
+ ),
190
259
  inbox: () =>
191
260
  inboxCommand(
192
261
  argv.slice(1),
193
262
  cwd,
194
263
  { stdout, stderr },
195
- { home: wakes.home, ...(environment.signal ? { signal: environment.signal } : {}) },
264
+ { home: environment.home, ...(environment.signal ? { signal: environment.signal } : {}) },
196
265
  ),
197
266
  reply: () => holder(),
198
267
  send: () => holder(),
@@ -202,12 +271,7 @@ export async function runOperatorCli(
202
271
  stderr,
203
272
  read:
204
273
  environment.readAllowance ??
205
- allowanceReader(
206
- environment.environment ?? process.env,
207
- undefined,
208
- environment.signal,
209
- environment.home ?? homedir(),
210
- ),
274
+ allowanceReader(environment.environment, undefined, environment.signal, environment.home),
211
275
  now,
212
276
  }),
213
277
  } satisfies Record<CommandName, () => Promise<number>>;
@@ -222,14 +286,56 @@ export async function runOperatorCli(
222
286
  return 2;
223
287
  }
224
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}`);
225
292
  return 0;
226
293
  }
227
294
  if (word === "help" || word === "-h" || word === "--help") return help(rest, stdout, stderr);
228
- 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
+ }
229
312
  stderr(unknownCommand(word));
230
313
  return 2;
231
314
  }
232
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
+
233
339
  /** `awf help`, `awf help {command}` and `awf help reference`; `-h` and `--help` are `help`. */
234
340
  function help(
235
341
  words: readonly string[],
@@ -254,14 +360,13 @@ function help(
254
360
  return 0;
255
361
  }
256
362
 
257
- /** `awf run`: parses it, then starts or continues the run, here or in a tab. */
258
363
  /**
259
364
  * `awf run --id` for a workflow with events: the run is made with no event and started detached, as
260
365
  * an event starts one, so it runs to its first wait and parks; one already there is only woken.
261
366
  */
262
367
  async function openRun(
263
368
  { loaded, id, argv, panes }: OpensEventRun,
264
- environment: OperatorEnvironment,
369
+ environment: Resolved,
265
370
  { stdout, stderr }: Output,
266
371
  ): Promise<number> {
267
372
  try {
@@ -270,53 +375,42 @@ async function openRun(
270
375
  id,
271
376
  argv,
272
377
  {
273
- home: environment.home ?? homedir(),
274
- environment: environment.environment ?? process.env,
378
+ home: environment.home,
379
+ environment: environment.environment,
275
380
  ...(environment.launch ? { launch: environment.launch } : {}),
276
381
  },
277
382
  panes,
278
383
  );
279
- const did = opened.did === "none" ? opened.why : opened.did;
280
- stdout(
281
- `${opened.run}${opened.created ? "" : " was there"}${did === undefined ? "" : `: ${did}`}`,
282
- );
283
- if (opened.why !== "launch-failed") return 0;
284
- stderr(`awf: the run's attempt did not start: ${opened.error}`);
285
- return 1;
384
+ stdout(`${opened.run}${opened.created ? "" : " was there"}${didOf(opened)}`);
385
+ return launchExit(opened, { stdout, stderr });
286
386
  } catch (error) {
287
- stderr(`awf: ${messageOf(error)}`);
387
+ printRefusal({ stdout, stderr }, NO_JSON, error, readFailureCode(error));
288
388
  return error instanceof EmitError ? 2 : 1;
289
389
  }
290
390
  }
291
391
 
392
+ /** `awf run`: parses it, then starts or continues the run, here or in a tab. */
292
393
  async function runCommand(
293
394
  argv: readonly string[],
294
- environment: OperatorEnvironment,
395
+ environment: Resolved,
295
396
  output: Output,
296
397
  ): Promise<number> {
297
- const { stdout, stderr, notice, terminal, now } = output;
298
- let parsed: RunCommand | "help";
398
+ const { stdout, stderr, notice, now } = output;
399
+ let parsed: RunCommand;
299
400
  try {
300
- parsed = parseCommand(argv, environment.cwd ?? process.cwd(), stderr);
401
+ parsed = parseCommand(argv, environment.cwd ?? process.cwd());
301
402
  } catch (error) {
302
- stderr(`awf: ${messageOf(error)}\n\n${usage}`);
403
+ printRefusal({ stdout, stderr }, { json: asksJson(argv), v: RUN_V }, error, "usage", usage);
303
404
  return 2;
304
405
  }
305
- if (parsed === "help") {
306
- stdout(usage);
307
- return 0;
308
- }
309
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);
310
410
  const meant = await runNamed(command);
311
411
  if (meant) {
312
- const look = shellLine([
313
- "awf",
314
- "runs",
315
- meant,
316
- ...(command.cwdGiven ? ["--cwd", command.cwd] : []),
317
- ...(command.runRootGiven ? ["--run-root", command.runRoot] : []),
318
- ]);
319
- 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}`);
320
414
  return 2;
321
415
  }
322
416
  if (command.here) return startHere(argv, command, environment, stdout, stderr, notice);
@@ -326,19 +420,20 @@ async function runCommand(
326
420
  if (command.session !== undefined) await showOwnTab(environment);
327
421
  return exitCode;
328
422
  };
329
- const home = environment.home ?? homedir();
423
+ const { home } = environment;
330
424
  const { sandboxes, callers } = machinePaths(home);
331
425
  const ready = await loadAndPrepare(command, now(), home);
332
426
  if ("refused" in ready) {
333
- stderr(`awf: ${ready.refused}`);
427
+ refused(ready.code, ready.refused);
334
428
  return unstarted(2);
335
429
  }
336
430
  if ("opens" in ready) return openRun(ready.opens, environment, output);
337
431
  // After loading: an event run's root is its project's, which this checks too.
338
432
  command = ready.command;
339
- if (!relative(command.runRoot, sandboxes).startsWith("..")) {
340
- stderr(
341
- `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`,
342
437
  );
343
438
  return unstarted(2);
344
439
  }
@@ -347,28 +442,24 @@ async function runCommand(
347
442
  // Loading the workflow imports operator-supplied code before anything listens to the signal, so
348
443
  // a Ctrl-C in it would otherwise be swallowed and have to be pressed again.
349
444
  if (environment.signal?.aborted) {
445
+ if (command.json) stdout(refusalLine(RUN_V, "not-started", CANCELLED));
350
446
  return unstarted(cancelledBeforeStart(environment.signal, stderr));
351
447
  }
352
448
  let calling: { caller: Caller; release: () => void } | undefined;
353
449
  if (command.session !== undefined) {
354
450
  const taken = await takeCaller(command.session, callers, environment);
355
451
  if (typeof taken === "string") {
356
- stderr(`awf: --session: ${taken}`);
452
+ refused("not-started", `--session: ${taken}`);
357
453
  return 1;
358
454
  }
359
455
  calling = taken;
360
456
  if (environment.signal?.aborted) {
361
457
  calling.release();
458
+ if (command.json) stdout(refusalLine(RUN_V, "not-started", CANCELLED));
362
459
  return cancelledBeforeStart(environment.signal, stderr);
363
460
  }
364
461
  }
365
- return runAttempt(command, ready, calling, environment, home, {
366
- stdout,
367
- stderr,
368
- notice,
369
- terminal,
370
- now,
371
- });
462
+ return runAttempt(command, ready, calling, environment, output);
372
463
  }
373
464
 
374
465
  /** The run `awf run {ref}` names where `{ref}` is no file: one meant for `awf runs`. */
@@ -386,11 +477,11 @@ async function runAttempt(
386
477
  command: RunCommand,
387
478
  { loaded, prepared }: { loaded: LoadedWorkflow; prepared: PreparedRun },
388
479
  calling: { caller: Caller; release: () => void } | undefined,
389
- environment: OperatorEnvironment,
390
- home: string,
480
+ environment: Resolved,
391
481
  output: Output,
392
482
  ): Promise<number> {
393
483
  const { stdout, stderr, notice, terminal, now } = output;
484
+ const { home } = environment;
394
485
  const { workflow } = loaded;
395
486
  const { meta } = workflow;
396
487
  /** The run, once it is claimed. */
@@ -410,21 +501,21 @@ async function runAttempt(
410
501
  if (failed) stderr(`awf: the calling session was not told the run ended: ${failed}`);
411
502
  };
412
503
 
413
- const refuse = async (why: string) => {
414
- stderr(`awf: ${why}`);
504
+ const refuse = async (why: string, code: RefusalCode) => {
505
+ printRefusal({ stdout, stderr }, { json: command.json, v: RUN_V }, why, code);
415
506
  await handOver(`did not start: ${why}`);
416
507
  return 2;
417
508
  };
418
509
  // The operator's, never where the workflow's agents work.
419
- const coordinatorRoot = coordinatorDir(home, environment.environment ?? process.env);
510
+ const coordinatorRoot = coordinatorDir(home, environment.environment);
420
511
  try {
421
512
  await prepareCoordinatorDir(
422
513
  coordinatorRoot,
423
514
  command.cwd,
424
- Boolean((environment.environment ?? process.env).AWF_COORDINATOR_DIR),
515
+ Boolean(environment.environment.AWF_COORDINATOR_DIR),
425
516
  );
426
517
  } catch (error) {
427
- return refuse(messageOf(error));
518
+ return refuse(messageOf(error), "internal-error");
428
519
  }
429
520
  try {
430
521
  run =
@@ -440,15 +531,16 @@ async function runAttempt(
440
531
  } catch (error) {
441
532
  return refuse(
442
533
  `${error instanceof RunRefused ? "" : `${command.runRoot}: `}${messageOf(error)}`,
534
+ readFailureCode(error),
443
535
  );
444
536
  }
445
537
  let attempt: Attempt;
446
538
  try {
447
- attempt = await claimNext(run, loaded, command, notice, now());
539
+ attempt = await claimNext(run, loaded, command, notice, now(), environment.launchId);
448
540
  } catch (error) {
449
541
  // A run this call created and never ran is not left to hold its id.
450
542
  if (!prepared.continued) await discardRun(run).catch(() => undefined);
451
- return refuse(messageOf(error));
543
+ return refuse(messageOf(error), readFailureCode(error));
452
544
  }
453
545
  const { id, cwd } = run.record;
454
546
  const locked = <T>(work: () => Promise<T>) =>
@@ -462,7 +554,7 @@ async function runAttempt(
462
554
  if (run.record.events && previous && previous.ended === undefined)
463
555
  await stopCrashed(previous, workspaceLabel(meta.name, run.record.id, previous.attempt), {
464
556
  home,
465
- environment: environment.environment ?? process.env,
557
+ environment: environment.environment,
466
558
  }).catch((error: unknown) =>
467
559
  notice(
468
560
  `awf: what attempt ${previous.attempt} left running was not all stopped: ${messageOf(error)}`,
@@ -473,14 +565,26 @@ async function runAttempt(
473
565
  attempt,
474
566
  loaded,
475
567
  stderr,
568
+ now,
476
569
  ...(!run.record.events ? {} : { locked }),
477
570
  };
478
- const close = (end: AttemptEnd, kept: Kept, cleanupAlso?: string) =>
479
- 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, {
480
583
  workflow,
481
584
  run: { id, dir: run.dir },
482
585
  n,
483
- earlier: prepared.continued?.attempts ?? [],
586
+ earlier,
587
+ ...(spentBefore ? { spentBefore } : {}),
484
588
  json: command.json,
485
589
  fromStage: command.fromStage !== undefined,
486
590
  now: now(),
@@ -492,6 +596,7 @@ async function runAttempt(
492
596
  stderr,
493
597
  notice,
494
598
  });
599
+ };
495
600
  // Timed from here: finding the calling session can take minutes the run itself never had.
496
601
  const startedAt = now();
497
602
  // Every way out from here writes the attempt's ending, before the hand-back: a session told the
@@ -510,7 +615,12 @@ async function runAttempt(
510
615
  const said = cancelled
511
616
  ? "run cancelled before it started"
512
617
  : `${end.ending.reason}; the run did not start`;
513
- 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
+ );
514
624
  await handOver(`did not start: ${cancelled ? "cancelled" : end.ending.reason}`);
515
625
  return end.exitCode;
516
626
  }
@@ -556,7 +666,7 @@ async function runAttempt(
556
666
  placed = (session) => {
557
667
  progress.placedIn(session.name);
558
668
  const label = workspaceLabel(meta.name, id, n);
559
- const insideHerdr = (environment.environment ?? process.env).HERDR_ENV === "1";
669
+ const insideHerdr = environment.environment.HERDR_ENV === "1";
560
670
  for (const line of describeRunSession(session, label, insideHerdr)) progress.log(line);
561
671
  };
562
672
  // Interactive: a coordinator's questions are asked here, unless the output is for a program.
@@ -590,7 +700,8 @@ async function runAttempt(
590
700
  run: {
591
701
  dir: run.dir,
592
702
  id,
593
- attempt: n,
703
+ lines: attempt.lines,
704
+ ops: attempt.ops,
594
705
  label: workspaceLabel(meta.name, id, n),
595
706
  ...(command.fromStage === undefined ? {} : { fromStage: command.fromStage }),
596
707
  ...(command.values === undefined ? {} : { values: command.values.stages }),
@@ -617,7 +728,7 @@ async function runAttempt(
617
728
  await emitEvent(target, event, {
618
729
  from,
619
730
  home,
620
- environment: environment.environment ?? process.env,
731
+ environment: environment.environment,
621
732
  ...(environment.launch ? { launch: environment.launch } : {}),
622
733
  })
623
734
  ).outcome,
@@ -698,8 +809,10 @@ export function stderrLines(
698
809
  };
699
810
  }
700
811
 
812
+ const CANCELLED = "run cancelled before it started";
813
+
701
814
  function cancelledBeforeStart(signal: AbortSignal, stderr: (line: string) => void): number {
702
- stderr("awf: run cancelled before it started");
815
+ stderr(`awf: ${CANCELLED}`);
703
816
  return signalExitCode(signal.reason);
704
817
  }
705
818
 
@@ -738,16 +851,19 @@ const EXIT_GRACE_MS = 2_000;
738
851
  * Cancels `controller` at the first signal that asks the run to stop, and stops the process at
739
852
  * once at a second the operator sends, without the run's cleanup. Ctrl-C reaches the whole
740
853
  * process group and `bun awf` forwards it once more, so a repeat within `REPEAT_MS` is ignored:
741
- * taken as the second, it would kill the run mid-cleanup and leave its sandboxes behind. Returns
742
- * 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.
743
857
  */
744
858
  export function cancelOnSignals(
745
859
  controller: AbortController,
746
860
  exit: (code: number) => void = (code) => process.exit(code),
747
861
  now: () => number = Date.now,
862
+ attached: () => boolean = terminalAttached,
748
863
  ): () => void {
749
864
  let first: number | undefined;
750
865
  const cancel = (signal: NodeJS.Signals) => {
866
+ if (signal === "SIGINT" && attached()) return;
751
867
  if (first === undefined) {
752
868
  first = now();
753
869
  controller.abort(signal);
@@ -23,6 +23,7 @@ import {
23
23
  readClaudeBilling,
24
24
  readCodexBilling,
25
25
  readCursorLogin,
26
+ runAttached,
26
27
  runProcess,
27
28
  withholding,
28
29
  } from "@agentswf/harness";
@@ -37,7 +38,7 @@ import { createSrtProvider, findSrt } from "@agentswf/sandbox/srt";
37
38
  import { createOpenRouterProvider } from "./decisions/openrouter";
38
39
  import type { DecisionInstallation } from "./decisions/seam";
39
40
  import { messageOf } from "./errors";
40
- import { findOrigin, namedSession, workspaceLock } from "./herdr-placement";
41
+ import { findOrigin, workspaceLock } from "./herdr-placement";
41
42
  import {
42
43
  ensureRunSession,
43
44
  parseSessions,
@@ -48,6 +49,7 @@ import { markWorkspace } from "./herdr-workspace-marks";
48
49
 
49
50
  export type { RunSession } from "./herdr-run-session";
50
51
 
52
+ import type { Attach } from "./open-command";
51
53
  import { OPERATOR_ALIASES } from "./operator-aliases";
52
54
 
53
55
  export type OperatorRuntimeInstallation = {
@@ -129,7 +131,6 @@ export async function installOperatorRuntime(
129
131
  const config: HerdrConfig = {
130
132
  ...runConfig(name),
131
133
  onRunWorkspace: mark.bind,
132
- sessionFor: (named) => namedSession(named, { run, environment, home }),
133
134
  origin: () =>
134
135
  findOrigin({
135
136
  run,
@@ -424,6 +425,27 @@ const WITHHELD_ENVIRONMENT = [
424
425
  "AWF_CALLER_SESSIONS",
425
426
  ];
426
427
 
428
+ /**
429
+ * How `awf open` runs a harness on an agent's session: its fork and `-p` turn, and its TUI on this
430
+ * terminal, each withholding what no agent inherits (story 042).
431
+ */
432
+ export function openRuntime(environment: Readonly<Record<string, string | undefined>>): {
433
+ run: RunProcess;
434
+ attach: Attach;
435
+ providers: () => Promise<SandboxProviders>;
436
+ } {
437
+ const withheld = Object.fromEntries(WITHHELD_ENVIRONMENT.map((name) => [name, undefined]));
438
+ return {
439
+ run: withholding(runProcess, WITHHELD_ENVIRONMENT),
440
+ // A sandbox's command has exactly its own environment; on the host, the operator's less these.
441
+ attach: (input) =>
442
+ input.exact
443
+ ? runAttached(input, {})
444
+ : runAttached({ ...input, env: { ...input.env, ...withheld } }, environment),
445
+ providers: () => installSandboxes(environment),
446
+ };
447
+ }
448
+
427
449
  /**
428
450
  * Unset in a run's panes besides: the config the run session's server starts with, which a `herdr`
429
451
  * started in a pane would otherwise read in place of the operator's.