@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
package/src/jsonl.ts CHANGED
@@ -1,16 +1,20 @@
1
1
  import { open, readFile } from "node:fs/promises";
2
2
  import { isCode } from "./files";
3
3
 
4
- const appendQueues = new Map<string, Promise<void>>();
4
+ const queues = new Map<string, Promise<void>>();
5
5
 
6
- export async function appendLine(path: string, line: string): Promise<void> {
7
- const previous = appendQueues.get(path) ?? Promise.resolve();
8
- const current = previous.catch(() => undefined).then(() => appendCompleteLine(path, line));
9
- appendQueues.set(path, current);
6
+ export function appendLine(path: string, line: string): Promise<void> {
7
+ return queued(path, () => appendCompleteLine(path, line));
8
+ }
9
+
10
+ /** Runs `work` once every earlier write queued on `path` has settled, failed or not. */
11
+ export async function queued(path: string, work: () => Promise<void>): Promise<void> {
12
+ const current = (queues.get(path) ?? Promise.resolve()).catch(() => undefined).then(work);
13
+ queues.set(path, current);
10
14
  try {
11
15
  await current;
12
16
  } finally {
13
- if (appendQueues.get(path) === current) appendQueues.delete(path);
17
+ if (queues.get(path) === current) queues.delete(path);
14
18
  }
15
19
  }
16
20
 
package/src/keep-alive.ts CHANGED
@@ -24,27 +24,15 @@ export function asTimeoutHandler<T>(fn: () => T): T {
24
24
  return inHandler.run(true, fn);
25
25
  }
26
26
 
27
- /**
28
- * `keepAlive` as an open gave it, checked: what a typed caller can't get wrong, a plain one can.
29
- * Its deprecated `timeoutMs` is reported, and refused beside `window`.
30
- */
31
- export function checkedKeepAlive(key: string, given: unknown, deprecated: () => void): KeepAlive {
27
+ /** `keepAlive` as an open gave it, checked: a plain caller can get wrong what a typed one can't. */
28
+ export function checkedKeepAlive(key: string, given: unknown): KeepAlive {
32
29
  if (typeof given !== "object" || given === null)
33
30
  throw new Error(`agent ${key}: keepAlive is { window?, onTimeout? }`);
34
- const { window, timeoutMs, onTimeout } = given as Record<string, unknown>;
35
- let windowMs: number | undefined;
36
- if (window != null) {
37
- if (timeoutMs != null)
38
- throw new Error(
39
- `agent ${key}: keepAlive's window is given alone: timeoutMs is its deprecated form`,
40
- );
41
- windowMs = positiveDurationMilliseconds(window, `agent ${key}: keepAlive's window`);
42
- } else if (timeoutMs != null) {
43
- if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0)
44
- throw new Error(`agent ${key}: keepAlive's timeoutMs is a positive number of milliseconds`);
45
- deprecated();
46
- windowMs = timeoutMs;
47
- }
31
+ const { window, onTimeout } = given as Record<string, unknown>;
32
+ const windowMs =
33
+ window == null
34
+ ? undefined
35
+ : positiveDurationMilliseconds(window, `agent ${key}: keepAlive's window`);
48
36
  if (onTimeout !== undefined && typeof onTimeout !== "function")
49
37
  throw new Error(`agent ${key}: keepAlive's onTimeout is a function`);
50
38
  // A copy: a later change to the caller's object would skip this check.
@@ -1,22 +1,21 @@
1
1
  import { homedir } from "node:os";
2
2
  import type { LogEventJson, LogsJson, RouteEnds, RunLogLine } from "@agentswf/contract/records";
3
3
  import type { JsonObject } from "@agentswf/contract/workflow";
4
- import { formatDuration, parseWait } from "./duration";
4
+ import { duration } from "./accounting/format";
5
+ import { command, type Out, parsePlace, type ReadPlace, shellLine } from "./command-line";
6
+ import { parseWait } from "./duration";
5
7
  import { messageOf } from "./errors";
8
+ import { exists } from "./files";
6
9
  import help from "./help/logs.txt" with { type: "text" };
10
+ import { screenFile } from "./ops";
11
+ import { asksJson, printRefusal, refusalLine } from "./refusals";
7
12
  import {
8
- clockTime,
9
- command,
10
13
  continueOf,
11
14
  currentStage,
12
15
  failed,
13
- masked,
16
+ MAX_WAIT_SECONDS,
14
17
  nextLogs,
15
- type Out,
16
- parsePlace,
17
- type ReadPlace,
18
- said,
19
- shellLine,
18
+ POLL_MS,
20
19
  span,
21
20
  stateWord,
22
21
  } from "./run-commands";
@@ -30,11 +29,16 @@ import {
30
29
  type FileCache,
31
30
  type ReadEvent,
32
31
  readRun,
32
+ START,
33
33
  timeOf,
34
34
  } from "./run-read";
35
- import { findRun, findRunByRef, type RunStatus } from "./run-state";
35
+ import { checkRunEngine, findRun, findRunByRef, type RunStatus } from "./run-state";
36
+ import { clockTime, masked, said } from "./run-text";
36
37
  import { describeActivity } from "./run-view";
37
38
 
39
+ /** `awf logs --json`'s `"v"`, which its refusals carry too. */
40
+ export const LOGS_V = 1 satisfies LogsJson["v"];
41
+
38
42
  export const logsUsage = help.trimEnd();
39
43
 
40
44
  const DEFAULT_LIMIT = 40;
@@ -43,10 +47,8 @@ const DEFAULT_LIMIT = 40;
43
47
  * result or a message whole, short of a dumped file. `@{handle}` gives it whole.
44
48
  */
45
49
  const PAGE_TEXT = 50_000;
46
- const MAX_WAIT_SECONDS = 300;
47
50
  /** After a first new event, how long to wait for the rest of its burst. */
48
51
  const SETTLE_MS = 1_000;
49
- const POLL_MS = 1_000;
50
52
 
51
53
  type LogsOptions = {
52
54
  ref?: string;
@@ -74,10 +76,6 @@ export async function logsCommand(
74
76
  clock: LogsClock = REAL_CLOCK,
75
77
  home: string = homedir(),
76
78
  ): Promise<number> {
77
- if (argv.includes("-h") || argv.includes("--help")) {
78
- out.stdout(logsUsage);
79
- return 0;
80
- }
81
79
  let place: ReadPlace;
82
80
  let json: boolean;
83
81
  let options: LogsOptions;
@@ -85,10 +83,10 @@ export async function logsCommand(
85
83
  const parsed = parsePlace(argv, shellCwd);
86
84
  place = parsed.place;
87
85
  json = parsed.json;
88
- options = parseLogs(parsed.rest, (line) => out.stderr(line));
86
+ options = parseLogs(parsed.rest);
89
87
  if (options.follow && json) throw new Error("--follow is for reading; --json takes --wait");
90
88
  } catch (error) {
91
- out.stderr(`awf: ${messageOf(error)}\n\n${logsUsage}`);
89
+ printRefusal(out, { json: asksJson(argv), v: LOGS_V }, error, "usage", logsUsage);
92
90
  return 2;
93
91
  }
94
92
  try {
@@ -96,6 +94,7 @@ export async function logsCommand(
96
94
  options.ref === undefined
97
95
  ? await findRun(place.runRoot, undefined)
98
96
  : { status: await findRunByRef(options.ref, place, home), alsoRunning: [] };
97
+ await checkRunEngine(status, home);
99
98
  const again = shellLine(command(place, "awf", "logs", status.ref));
100
99
  let after: Cursor | undefined;
101
100
  if (options.after !== undefined) {
@@ -107,22 +106,29 @@ export async function logsCommand(
107
106
  }
108
107
  const read = new Reader(status, after, clock, options.limit * 2, json);
109
108
  // Lines are numbered by their place in the file, past any that were skipped.
110
- if (after && after.log > ((await read.next()).log.lines.at(-1)?.number ?? 0)) {
111
- throw new DoesNotFit(`cursor ${options.after} does not fit this run; start again: ${again}`);
109
+ if (after) {
110
+ const { log } = await read.next();
111
+ const past = after.run > log.runLines && log.unreadSessions === undefined;
112
+ if (after.log > (log.lines.at(-1)?.number ?? 0) || past) {
113
+ throw new DoesNotFit(
114
+ `cursor ${options.after} does not fit this run; start again: ${again}`,
115
+ );
116
+ }
112
117
  }
113
118
  if (options.handle !== undefined) return await oneEvent(status, options, json, out, again);
114
119
  if (options.follow) return await follow(read, options, place, out, clock);
115
120
  return await page(read, alsoRunning, options, place, json, out, clock);
116
121
  } catch (error) {
117
122
  if (error instanceof DoesNotFit) {
123
+ if (json) out.stdout(refusalLine(LOGS_V, "cursor", error.message));
118
124
  out.stderr(error.message);
119
125
  return 1;
120
126
  }
121
- return failed(error, place, out, messageOf(error));
127
+ return failed(error, place, out, messageOf(error), { json, v: LOGS_V });
122
128
  }
123
129
  }
124
130
 
125
- function parseLogs(rest: readonly string[], deprecated: (line: string) => void): LogsOptions {
131
+ function parseLogs(rest: readonly string[]): LogsOptions {
126
132
  const options: LogsOptions = {
127
133
  limit: DEFAULT_LIMIT,
128
134
  waitSeconds: 0,
@@ -147,7 +153,7 @@ function parseLogs(rest: readonly string[], deprecated: (line: string) => void):
147
153
  throw new Error("--limit needs a whole number");
148
154
  options.limit = limit;
149
155
  } else if (arg === "--wait") {
150
- options.waitSeconds = parseWait(value(), MAX_WAIT_SECONDS, deprecated);
156
+ options.waitSeconds = parseWait(value(), MAX_WAIT_SECONDS);
151
157
  waitGiven = true;
152
158
  } else if (arg === "--follow") options.follow = true;
153
159
  else if (arg === "--full") options.full = true;
@@ -224,8 +230,9 @@ function operationPlaces(lines: readonly { line: RunLogLine }[]): Places {
224
230
 
225
231
  function agentOf(event: ReadEvent, places: Places): string | undefined {
226
232
  if (event.source === "activity") return event.agent;
233
+ if (event.source === "run") return event.line.agent;
227
234
  if ("agent" in event.line && event.line.agent) return event.line.agent;
228
- // A session a headless turn named names its operation, and so its agent.
235
+ // A line that names only its operation, such as a message's delivery, is its operation's agent's.
229
236
  return "operationId" in event.line && event.line.operationId
230
237
  ? places.agent.get(event.line.operationId)
231
238
  : undefined;
@@ -235,12 +242,15 @@ function stageOf(event: ReadEvent, places: Places): string | undefined {
235
242
  if (event.source === "activity") {
236
243
  return event.operation ? places.stage.get(event.operation) : undefined;
237
244
  }
245
+ if (event.source === "run") {
246
+ return event.line.operation ? places.stage.get(event.line.operation) : undefined;
247
+ }
238
248
  return "stage" in event.line ? event.line.stage : undefined;
239
249
  }
240
250
 
241
251
  /** The agents a messaging line is between, so `--agent` follows it from either side. */
242
252
  function sidesOf(event: ReadEvent): readonly string[] {
243
- if (event.source === "activity") return [];
253
+ if (event.source !== "log") return [];
244
254
  const { line } = event;
245
255
  switch (line.kind) {
246
256
  case "route-opened":
@@ -258,14 +268,32 @@ function sidesOf(event: ReadEvent): readonly string[] {
258
268
  }
259
269
  }
260
270
 
271
+ /**
272
+ * The `run.jsonl` lines text leaves out: an agent's first session, which its opening and first turn
273
+ * already stand for, unless it is a fork's, made apart from any turn.
274
+ */
275
+ function quietSessions(read: Read): Set<number> {
276
+ const forked = new Set<string>();
277
+ for (const { line } of read.log.lines) if (line.kind === "agent-forked") forked.add(line.agent);
278
+ const quiet = new Set<number>();
279
+ const seen = new Set<string>();
280
+ for (const { number, line } of read.log.named) {
281
+ if (!seen.has(line.agent) && !forked.has(line.agent)) quiet.add(number);
282
+ seen.add(line.agent);
283
+ }
284
+ return quiet;
285
+ }
286
+
261
287
  function kept(read: Read, options: LogsOptions, json = false) {
262
288
  const stages = operationPlaces(read.log.lines);
289
+ const quiet = json ? new Set<number>() : quietSessions(read);
263
290
  const events = read.events.filter(
264
291
  (event) =>
292
+ !(event.source === "run" && quiet.has(decodeHandleLine(event.handle))) &&
265
293
  // A call that has ended shows on its end's line, with its input; `--json` keeps both.
266
294
  !(
267
295
  !json &&
268
- event.source !== "activity" &&
296
+ event.source === "log" &&
269
297
  event.line.kind === "tool-called" &&
270
298
  stages.calls.get(event.line.call)?.ended
271
299
  ) &&
@@ -277,6 +305,11 @@ function kept(read: Read, options: LogsOptions, json = false) {
277
305
  return { events, stages };
278
306
  }
279
307
 
308
+ function decodeHandleLine(handle: string): number {
309
+ const decoded = decodeHandle(handle);
310
+ return decoded?.kind === "run" ? decoded.line : 0;
311
+ }
312
+
280
313
  function sameLine(a: ReadEvent, b: ReadEvent): boolean {
281
314
  const first = decodeHandle(a.handle);
282
315
  const second = decodeHandle(b.handle);
@@ -344,6 +377,7 @@ async function page(
344
377
  }
345
378
  }
346
379
  const stage = currentStage(status, read.log.lines);
380
+ const screens = await keptScreens(status.run.dir, shown);
347
381
  const filters = [
348
382
  ...(options.agent ? ["--agent", options.agent] : []),
349
383
  ...(options.stage ? ["--stage", options.stage] : []),
@@ -355,7 +389,7 @@ async function page(
355
389
  if (json) {
356
390
  const { started, ended } = span(status, clock.now());
357
391
  const document: LogsJson = {
358
- v: 1,
392
+ v: LOGS_V,
359
393
  run: {
360
394
  ref: status.ref,
361
395
  state: status.state,
@@ -364,7 +398,7 @@ async function page(
364
398
  ambiguous: alsoRunning.length > 0,
365
399
  ...(status.state !== "running" && ended !== undefined ? { duration: ended - started } : {}),
366
400
  },
367
- events: shown.map((event) => publish(event, stages, PAGE_TEXT)),
401
+ events: shown.map((event) => publish(event, stages, screens, PAGE_TEXT)),
368
402
  cursor: encodeCursor(cursor),
369
403
  more: { ...(older ? { older: true } : {}), ...(newer ? { newer: true } : {}) },
370
404
  ...(next ? { next } : {}),
@@ -376,6 +410,11 @@ async function page(
376
410
  const lines: string[] = [
377
411
  header(status, stage, lastAt ? Date.parse(timeOf(lastAt.line)) : undefined),
378
412
  ];
413
+ if (read.log.unreadSessions !== undefined) {
414
+ lines.push(
415
+ `note: run.jsonl could not be read, so no agent's activity shows: ${said(read.log.unreadSessions)}`,
416
+ );
417
+ }
379
418
  if (alsoRunning.length > 0) {
380
419
  const count = alsoRunning.length + (status.state === "running" ? 1 : 0);
381
420
  lines.push(`note: ${count} runs running; also ${alsoRunning.join(", ")}`);
@@ -384,12 +423,12 @@ async function page(
384
423
  const of = options.agent ? ` for ${options.agent}` : "";
385
424
  // A cut read has more than any --limit reaches: paging from the start reads it all.
386
425
  const more = read.cut
387
- ? `from the start: ${shellLine([...command(place, "awf", "logs", status.ref), ...filters, "--after", encodeCursor({ log: 0, files: {} })])}`
426
+ ? `from the start: ${shellLine([...command(place, "awf", "logs", status.ref), ...filters, "--after", encodeCursor(START)])}`
388
427
  : "raise --limit for more";
389
428
  const total = read.cut ? `the last ${events.length} read` : `${events.length}`;
390
429
  lines.push(`(showing ${shown.length} of ${total}${of}; ${more})`);
391
430
  }
392
- lines.push(...shown.map((event) => describeEvent(event, stages)));
431
+ lines.push(...shown.map((event) => describeEvent(event, stages, screens)));
393
432
  lines.push(
394
433
  trailer(
395
434
  status,
@@ -418,7 +457,8 @@ async function follow(
418
457
  const { events, stages } = kept(read, options);
419
458
  // Without a cursor it starts from the last `--limit`, as a page would.
420
459
  const shown = first && !reader.from ? events.slice(-options.limit) : events;
421
- for (const event of shown) out.stdout(describeEvent(event, stages));
460
+ const screens = await keptScreens(reader.status.run.dir, shown);
461
+ for (const event of shown) out.stdout(describeEvent(event, stages, screens));
422
462
  cursor = read.cursor;
423
463
  // A read that stopped short goes on at once.
424
464
  if (!read.more) {
@@ -445,34 +485,38 @@ async function oneEvent(
445
485
  const handle = decodeHandle(options.handle!);
446
486
  if (!handle) throw missing;
447
487
  // Read from where the handle points, and only its file.
488
+ const none = Number.MAX_SAFE_INTEGER;
448
489
  const read = await readRun(
449
490
  status.run.dir,
450
491
  status.run.record.cwd,
451
492
  handle.kind === "event"
452
- ? { log: handle.line - 1, files: {} }
453
- : { log: Number.MAX_SAFE_INTEGER, files: { [handle.file]: handle.offset } },
493
+ ? { log: handle.line - 1, run: none, files: {} }
494
+ : handle.kind === "run"
495
+ ? { log: none, run: handle.line - 1, files: {} }
496
+ : { log: none, run: none, files: { [handle.file]: handle.offset } },
454
497
  {
455
498
  ended: status.state !== "running",
456
499
  full: options.full || json,
457
- only: handle.kind === "event" ? "" : handle.file,
500
+ only: handle.kind === "activity" ? handle.file : "",
458
501
  },
459
502
  );
460
503
  const stages = operationPlaces(read.log.lines);
461
504
  const event = read.events.find((each) => each.handle === encodeHandle(handle));
462
505
  if (!event) throw missing;
506
+ const screens = await keptScreens(status.run.dir, [event]);
463
507
  if (json) {
464
508
  const document: LogsJson = {
465
- v: 1,
509
+ v: LOGS_V,
466
510
  run: { ref: status.ref, state: status.state, checked: status.checked },
467
- events: [publish(event, stages)],
511
+ events: [publish(event, stages, screens)],
468
512
  };
469
513
  out.stdout(JSON.stringify(document, null, 2));
470
514
  return 0;
471
515
  }
472
- const lines = [describeEvent(event, stages)];
516
+ const lines = [describeEvent(event, stages, screens)];
473
517
  if (options.full) {
474
518
  lines.unshift(
475
- `(${event.source === "activity" ? "native text" : "the log's line"}; common credential shapes are masked)`,
519
+ `(${{ activity: "native text", log: "the log's line", run: "run.jsonl's line" }[event.source]}; common credential shapes are masked)`,
476
520
  );
477
521
  const text =
478
522
  event.source === "activity" ? event.text : JSON.stringify(masked(event.line), null, 2);
@@ -507,12 +551,17 @@ function trailer(
507
551
  return `run interrupted${go ? ` · continue: ${go}` : ""}`;
508
552
  }
509
553
  const { ended, started } = span(status, Date.now());
510
- const took = ended === undefined ? "" : ` in ${formatDuration(ended - started)}`;
554
+ const took = ended === undefined ? "" : ` in ${duration(ended - started)}`;
511
555
  return `run ended: ${status.state}${took} · result: ${shellLine(command(place, "awf", "runs", status.ref))}`;
512
556
  }
513
557
 
514
558
  /** An event as `--json` publishes it: the line's own fields, or the activity, with its keys. */
515
- function publish(event: ReadEvent, stages: Places, limit = Number.POSITIVE_INFINITY): LogEventJson {
559
+ function publish(
560
+ event: ReadEvent,
561
+ stages: Places,
562
+ screens: Screens,
563
+ limit = Number.POSITIVE_INFINITY,
564
+ ): LogEventJson {
516
565
  if (event.source === "activity") {
517
566
  const stage = event.operation ? stages.stage.get(event.operation) : undefined;
518
567
  return {
@@ -526,24 +575,60 @@ function publish(event: ReadEvent, stages: Places, limit = Number.POSITIVE_INFIN
526
575
  ...(event.text === undefined ? {} : { text: masked(event.text, limit) as string }),
527
576
  };
528
577
  }
578
+ if (event.source === "run") {
579
+ return { handle: event.handle, ...(masked(event.line, limit) as typeof event.line) };
580
+ }
529
581
  const { version: _version, ...fields } = event.line;
530
582
  return {
531
583
  handle: event.handle,
532
584
  at: event.at,
533
585
  ...(masked(fields, limit) as typeof fields),
534
586
  ...("operationId" in fields ? { operation: fields.operationId } : {}),
587
+ ...screenOf(event, screens),
535
588
  };
536
589
  }
537
590
 
538
- /** `@e216 09:27:02 fix fixer turn ended answered 1m48s`. */
539
- function describeEvent(event: ReadEvent, stages: Places): string {
591
+ /** The screens kept for the operations of the events shown, by operation id. */
592
+ type Screens = ReadonlyMap<string, string>;
593
+
594
+ /** A turn or compaction that ended without an answer, and the file its screen would be kept in. */
595
+ function screenOf(event: ReadEvent, screens: Screens): { screen?: string } {
596
+ if (event.source !== "log") return {};
597
+ const { line } = event;
598
+ if (line.kind !== "turn" && line.kind !== "compact") return {};
599
+ const screen = screens.get(line.operationId);
600
+ return screen ? { screen } : {};
601
+ }
602
+
603
+ async function keptScreens(runDir: string, events: readonly ReadEvent[]): Promise<Screens> {
604
+ const screens = new Map<string, string>();
605
+ await Promise.all(
606
+ events.map(async (event) => {
607
+ if (event.source !== "log") return;
608
+ const { line } = event;
609
+ if (line.kind !== "turn" && line.kind !== "compact") return;
610
+ if (line.outcome === "answered" || line.outcome === "cancelled") return;
611
+ const file = screenFile(runDir, line.operationId);
612
+ if (await exists(file)) screens.set(line.operationId, file);
613
+ }),
614
+ );
615
+ return screens;
616
+ }
617
+
618
+ /** `@e216 09:27:02 fix fixer turn ended answered 1m 48s`. */
619
+ function describeEvent(event: ReadEvent, stages: Places, screens: Screens): string {
540
620
  const agent = agentOf(event, stages);
541
621
  const where = [stageOf(event, stages), agent && said(agent, 60)].filter(Boolean).join(" ");
542
622
  const what =
543
623
  event.source === "activity"
544
624
  ? describeActivity(event.activity)
545
- : describeLine(event.line, stages);
546
- return `@${event.handle} ${clockTime(event.at)} ${where ? `${where} ` : ""}${what}`;
625
+ : event.source === "run"
626
+ ? `session ${event.line.harness} ${event.line.session}`
627
+ : describeLine(event.line, stages);
628
+ const { screen } = screenOf(event, screens);
629
+ return `@${event.handle} ${clockTime(event.at)} ${where ? `${where} ` : ""}${what}${
630
+ screen ? ` screen: ${screen}` : ""
631
+ }`;
547
632
  }
548
633
 
549
634
  function describeLine(line: RunLogLine, places: Places): string {
@@ -565,12 +650,12 @@ function describeLine(line: RunLogLine, places: Places): string {
565
650
  case "compact": {
566
651
  const took =
567
652
  line.deliveredAt && line.settledAt
568
- ? ` ${formatDuration(Date.parse(line.settledAt) - Date.parse(line.deliveredAt))}`
653
+ ? ` ${duration(Date.parse(line.settledAt) - Date.parse(line.deliveredAt))}`
569
654
  : "";
570
655
  return `${operation(line.kind)} ended ${line.outcome}${took}`;
571
656
  }
572
- case "session-known":
573
- return `session ${line.session.harness} ${line.session.id}`;
657
+ case "sandbox-opened":
658
+ return `sandbox ${said(line.key, 60)} opened ${line.provider}`;
574
659
  case "agent-opened":
575
660
  return `opened ${line.harness}${line.caller ? " (the calling session)" : ""}`;
576
661
  case "agent-stopped":
@@ -587,9 +672,7 @@ function describeLine(line: RunLogLine, places: Places): string {
587
672
  return `decision ${line.decision} ${line.outcome}${line.error ? ` ${said(line.error)}` : ""}`;
588
673
  case "route-opened":
589
674
  return `route ${routeEnds(line)} opened "${said(line.purpose)}" ${line.maxMessages} messages${
590
- line.timeoutMs === undefined
591
- ? ""
592
- : `, a question closes after ${formatDuration(line.timeoutMs)}`
675
+ line.timeoutMs === undefined ? "" : `, a question closes after ${duration(line.timeoutMs)}`
593
676
  }`;
594
677
  case "route-closed":
595
678
  return `route ${routeEnds(line)} closed ${line.reason}`;
@@ -638,9 +721,7 @@ function describeLine(line: RunLogLine, places: Places): string {
638
721
  line.tool,
639
722
  places.calls.get(line.call)?.input,
640
723
  // Most calls take under a second; a duration says something only past one.
641
- line.durationMs < 1000
642
- ? line.outcome
643
- : `${line.outcome} ${formatDuration(line.durationMs)}`,
724
+ line.durationMs < 1000 ? line.outcome : `${line.outcome} ${duration(line.durationMs)}`,
644
725
  answered,
645
726
  );
646
727
  }
@@ -31,12 +31,14 @@ export async function superviseMessageOperation(options: {
31
31
  event?(event: LivenessEvent): void;
32
32
  phase?(phase: OperationPhase, reason?: string, until?: number): void;
33
33
  delivered?(at: number): void;
34
+ /** Keeps what its turn shows when it ends without an answer, before the turn is released. */
35
+ keepScreen?(turn: HarnessTurn): Promise<void>;
34
36
  policy?: LivenessPolicy;
35
37
  now?: () => number;
36
38
  }): Promise<SupervisedOutcome> {
37
39
  const now = options.now ?? Date.now;
38
40
  const policy = options.policy ?? DEFAULT_LIVENESS_POLICY;
39
- const charges: number[] = [];
41
+ const reportedCosts: number[] = [];
40
42
  let sequence = 0;
41
43
  let deliveredAt: number | undefined;
42
44
  let stop: OperationStop | undefined;
@@ -63,7 +65,7 @@ export async function superviseMessageOperation(options: {
63
65
  kind,
64
66
  reason,
65
67
  cleanupUnresolved: false,
66
- charges,
68
+ reportedCosts,
67
69
  ...(deliveredAt === undefined ? {} : { deliveredAt }),
68
70
  settledAt:
69
71
  kind === "timed-out"
@@ -139,7 +141,7 @@ export async function superviseMessageOperation(options: {
139
141
  void turn.settled.then(
140
142
  (value) => {
141
143
  outcome = value;
142
- charges.push(...value.chargesUsd);
144
+ reportedCosts.push(...value.reportedCostsUsd);
143
145
  changed();
144
146
  },
145
147
  (error: unknown) => {
@@ -251,6 +253,7 @@ export async function superviseMessageOperation(options: {
251
253
  emit("releasing");
252
254
  if (turn) {
253
255
  const answered = result.kind === "answered";
256
+ if (!answered && result.kind !== "cancelled") await options.keepScreen?.(turn);
254
257
  const until = bound(answered);
255
258
  const release = await Promise.race([
256
259
  turn.release(stop?.reason ?? result.reason, until, { answered }).catch(() => undefined),
@@ -9,7 +9,7 @@ import type {
9
9
  import type { ParticipantCode, PeerView, ReplyCode, SendCode } from "@agentswf/contract/wire";
10
10
  import type { AgentKey, JsonObject } from "@agentswf/contract/workflow";
11
11
  import type { AuthoredMessage } from "@agentswf/harness/adapter";
12
- import { formatDuration } from "../duration";
12
+ import { duration } from "../accounting/format";
13
13
  import { MESSAGE_LIMITS } from "./limits";
14
14
  import {
15
15
  directions,
@@ -53,6 +53,8 @@ export interface MessagingHost {
53
53
  changed(key: AgentKey): void;
54
54
  schedule(at: number, action: () => void): () => void;
55
55
  now(): number;
56
+ /** Question `n`'s id as `awf inbox` prints it, the attempt before it: what a refusal names. */
57
+ question(n: number): string;
56
58
  /** Written to the run's event log; `message-sent` is awaited before a send is acknowledged. */
57
59
  record(event: MessagingEventBody): Promise<void>;
58
60
  }
@@ -117,7 +119,7 @@ export type HolderRequest = {
117
119
  /** A holder's result: a reply's `message` is the question's number, a send's the new message's. */
118
120
  export type HolderResult =
119
121
  | { code: "delivered"; message: number }
120
- | { code: Exclude<ParticipantCode, "not-found">; error: string; choices?: string[] };
122
+ | { code: ParticipantCode; error: string; choices?: string[] };
121
123
 
122
124
  /** A question open to an outside participant, and when it closes unanswered if it has a limit. */
123
125
  export type OpenQuestion = {
@@ -444,7 +446,7 @@ export class Messaging {
444
446
  return holderRefusal(
445
447
  "ambiguous",
446
448
  `${agent} has ${open.length} questions open to ${as.key}; name one with --to.`,
447
- open.map((each) => String(each.request.n)),
449
+ open.map((each) => this.#host.question(each.request.n)),
448
450
  );
449
451
  owed = open[0];
450
452
  if (!owed) return holderRefusal("expired", `${agent} has no question open to ${as.key}.`);
@@ -459,7 +461,7 @@ export class Messaging {
459
461
  if (!owed)
460
462
  return holderRefusal(
461
463
  "expired",
462
- `${request.to} is not a question open from ${agent}${as.key === undefined ? "" : ` to ${as.key}`}.`,
464
+ `${this.#host.question(request.to)} is not a question open from ${agent}${as.key === undefined ? "" : ` to ${as.key}`}.`,
463
465
  );
464
466
  }
465
467
  const route = owed.request.route;
@@ -1085,7 +1087,7 @@ export class Messaging {
1085
1087
  obligation,
1086
1088
  "timed-out",
1087
1089
  obligation.closes?.by === "route"
1088
- ? `the question closed unanswered after ${formatDuration(obligation.closes.ms)}`
1090
+ ? `the question closed unanswered after ${duration(obligation.closes.ms)}`
1089
1091
  : `your --timeout passed at ${clock(closesAt, this.#host.now())}`,
1090
1092
  );
1091
1093
  }
@@ -1681,11 +1683,7 @@ function refuse<C extends SendCode | ReplyCode>(code: C, error: string) {
1681
1683
  return { kind: "refused" as const, code, error };
1682
1684
  }
1683
1685
 
1684
- function holderRefusal(
1685
- code: Exclude<ParticipantCode, "not-found">,
1686
- error: string,
1687
- choices?: string[],
1688
- ): HolderResult {
1686
+ function holderRefusal(code: ParticipantCode, error: string, choices?: string[]): HolderResult {
1689
1687
  return { code, error, ...(choices ? { choices } : {}) };
1690
1688
  }
1691
1689
 
@@ -31,7 +31,7 @@ export type Delivered =
31
31
  }
32
32
  | { kind: "no-reply"; from: AgentKey; reason: string };
33
33
 
34
- export function quoted(purpose: string): string {
34
+ function quoted(purpose: string): string {
35
35
  return `“${purpose}”`;
36
36
  }
37
37
 
@@ -53,7 +53,7 @@ export function clock(at: number, now: number): string {
53
53
  }
54
54
 
55
55
  /** At least eight hex characters, as the design asks; a fresh one per delivery. */
56
- export function nonce(): string {
56
+ function nonce(): string {
57
57
  return randomBytes(4).toString("hex");
58
58
  }
59
59