@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
@@ -1,13 +1,14 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join, relative, resolve } from "node:path";
3
3
  import manifest from "../package.json" with { type: "json" };
4
+ import { type Out, shellWord } from "./command-line";
4
5
  import { messageOf } from "./errors";
5
6
  import help from "./help/init.txt" with { type: "text" };
6
- import type { Out } from "./run-commands";
7
+ import { NO_JSON, printRefusal } from "./refusals";
7
8
 
8
9
  export const initUsage = help.trimEnd();
9
10
 
10
- export type InitCommand = { dir: string; install: boolean };
11
+ type InitCommand = { dir: string; install: boolean };
11
12
 
12
13
  /** `awf init`: a folder set up for writing workflows. */
13
14
  export async function initCommand(
@@ -15,17 +16,13 @@ export async function initCommand(
15
16
  cwd: string,
16
17
  io: Out & { install?: (dir: string) => Promise<number> },
17
18
  ): Promise<number> {
18
- let command: InitCommand | "help";
19
+ let command: InitCommand;
19
20
  try {
20
21
  command = parseInitCommand(argv, cwd);
21
22
  } catch (error) {
22
- io.stderr(`awf: ${messageOf(error)}\n\n${initUsage}`);
23
+ printRefusal(io, NO_JSON, error, "usage", initUsage);
23
24
  return 2;
24
25
  }
25
- if (command === "help") {
26
- io.stdout(initUsage);
27
- return 0;
28
- }
29
26
  try {
30
27
  return await runInit(command, cwd, io, io.install);
31
28
  } catch (error) {
@@ -34,11 +31,10 @@ export async function initCommand(
34
31
  }
35
32
  }
36
33
 
37
- export function parseInitCommand(argv: readonly string[], cwd: string): InitCommand | "help" {
34
+ export function parseInitCommand(argv: readonly string[], cwd: string): InitCommand {
38
35
  let dir: string | undefined;
39
36
  let install = true;
40
37
  for (const option of argv) {
41
- if (option === "-h" || option === "--help") return "help";
42
38
  if (option === "--no-install") install = false;
43
39
  else if (option.startsWith("-")) throw new Error(`unknown option: ${option}`);
44
40
  else if (dir !== undefined) throw new Error(`one directory only, not ${dir} and ${option}`);
@@ -86,7 +82,7 @@ function targetOf(dir: string, path: string): Target {
86
82
  * Writes what `awf init` writes into `dir`, and says what it did to each file. Everything that
87
83
  * would stop it, such as a package.json that isn't a JSON object, is found before it writes.
88
84
  */
89
- export function initFolder(dir: string): Change[] {
85
+ function initFolder(dir: string): Change[] {
90
86
  for (const parent of new Set(COPIED.map(dirname))) {
91
87
  const stat = lstatSync(join(dir, parent), { throwIfNoEntry: false });
92
88
  if (stat !== undefined && !stat.isDirectory()) {
@@ -213,13 +209,8 @@ function indentOf(text: string): string | number {
213
209
  return /^\{\s*\r?\n([ \t]+)"/.exec(text)?.[1] ?? 2;
214
210
  }
215
211
 
216
- /** As a shell reads it: bare when that's safe, otherwise single-quoted. */
217
- function shellWord(word: string): string {
218
- return /^[\w./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
219
- }
220
-
221
212
  /** `awf init`: writes the folder, installs unless told not to, and says what to run next. */
222
- export async function runInit(
213
+ async function runInit(
223
214
  command: InitCommand,
224
215
  cwd: string,
225
216
  output: { stdout(text: string): void; stderr(text: string): void },
@@ -1,9 +1,9 @@
1
1
  import { expect, test } from "bun:test";
2
- import { answer, reply, testWorkflow } from "agentswf/testing";
2
+ import { answer, noAnswer, testWorkflow } from "agentswf/testing";
3
3
  import hello, { ANSWER } from "./workflow";
4
4
 
5
5
  test("returns the agent's answer", async () => {
6
- const run = await testWorkflow(hello, null, {
6
+ const run = await testWorkflow(hello, {
7
7
  agents: { hello: answer(ANSWER, { answer: 391 }) },
8
8
  });
9
9
  expect(run.value).toEqual({ answer: 391 });
@@ -11,7 +11,7 @@ test("returns the agent's answer", async () => {
11
11
  });
12
12
 
13
13
  test("an agent that never answers is nudged once, then the result says why", async () => {
14
- const run = await testWorkflow(hello, null, { agents: { hello: reply.silent() } });
14
+ const run = await testWorkflow(hello, { agents: { hello: noAnswer.silent() } });
15
15
  expect(run.value).toHaveProperty("unanswered");
16
16
  expect(run.turnsOf("hello").map((turn) => turn.nudge)).toEqual([false, true]);
17
17
  });
@@ -1,21 +1,22 @@
1
+ import { typed } from "agentswf/typebox";
1
2
  import { defineWorkflow } from "agentswf/workflow";
2
3
  import Type from "typebox";
3
4
 
4
- export const ANSWER = Type.Object({ answer: Type.Integer() }, { additionalProperties: false });
5
+ export const ANSWER = typed(Type.Object({ answer: Type.Integer() }, { additionalProperties: false }));
5
6
 
6
7
  export default defineWorkflow({
7
8
  meta: { name: "hello", description: "Ask one agent a question with a known answer." },
8
9
  async run(wf) {
9
10
  // A headless agent needs neither Herdr nor a sandbox. Swap in any harness and model your
10
- // account has: "claude" in a pane, the default placement, runs on a subscription.
11
+ // account has: "claude" in its TUI, the default placement, runs on a subscription.
11
12
  const agent = await wf.agents.open({
12
13
  key: "hello",
13
14
  runtime: { harness: "codex", model: "gpt-6-luna", placement: "headless" },
14
15
  });
15
- const { outcome } = await agent.run<Type.Static<typeof ANSWER>>({
16
+ const { outcome } = await agent.run({
16
17
  prompt: "What is 17 multiplied by 23? Work it out yourself; do not run any code.",
17
18
  schema: ANSWER,
18
- timeoutMs: 3 * 60_000,
19
+ timeout: "3m",
19
20
  });
20
21
  return outcome.answered ? outcome.value : { unanswered: outcome.reason };
21
22
  },
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
@@ -1,7 +1,11 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import type { EventAgentOpenSpec } from "@agentswf/contract/workflow";
2
+ import { type AgentOpenSpec, positiveDurationMilliseconds } from "@agentswf/contract/workflow";
3
3
 
4
- export type KeepAlive = NonNullable<EventAgentOpenSpec["keepAlive"]>;
4
+ /** `keepAlive` as the engine keeps it: its window in milliseconds. */
5
+ export type KeepAlive = {
6
+ windowMs?: number;
7
+ onTimeout?: NonNullable<AgentOpenSpec["keepAlive"]>["onTimeout"];
8
+ };
5
9
 
6
10
  /** Claude's prompt cache lives an hour for the main conversation on a subscription. */
7
11
  export const DEFAULT_WINDOW_MS = 60 * 60_000;
@@ -20,21 +24,20 @@ export function asTimeoutHandler<T>(fn: () => T): T {
20
24
  return inHandler.run(true, fn);
21
25
  }
22
26
 
23
- /** `keepAlive` as an open gave it, checked: what a typed caller can't get wrong, a plain one can. */
27
+ /** `keepAlive` as an open gave it, checked: a plain caller can get wrong what a typed one can't. */
24
28
  export function checkedKeepAlive(key: string, given: unknown): KeepAlive {
25
29
  if (typeof given !== "object" || given === null)
26
- throw new Error(`agent ${key}: keepAlive is { timeoutMs?, onTimeout? }`);
27
- const { timeoutMs, onTimeout } = given as Record<string, unknown>;
28
- if (
29
- timeoutMs !== undefined &&
30
- (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0)
31
- )
32
- throw new Error(`agent ${key}: keepAlive's timeoutMs is a positive number of milliseconds`);
30
+ throw new Error(`agent ${key}: keepAlive is { window?, onTimeout? }`);
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`);
33
36
  if (onTimeout !== undefined && typeof onTimeout !== "function")
34
37
  throw new Error(`agent ${key}: keepAlive's onTimeout is a function`);
35
38
  // A copy: a later change to the caller's object would skip this check.
36
39
  return {
37
- ...(timeoutMs === undefined ? {} : { timeoutMs }),
40
+ ...(windowMs === undefined ? {} : { windowMs }),
38
41
  ...(onTimeout === undefined ? {} : { onTimeout: onTimeout as KeepAlive["onTimeout"] }),
39
- } as KeepAlive;
42
+ };
40
43
  }
@@ -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 { duration } 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;
@@ -88,7 +86,7 @@ export async function logsCommand(
88
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,18 +106,25 @@ 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
 
@@ -147,11 +153,7 @@ function parseLogs(rest: readonly string[]): LogsOptions {
147
153
  throw new Error("--limit needs a whole number");
148
154
  options.limit = limit;
149
155
  } else if (arg === "--wait") {
150
- const seconds = Number(value());
151
- if (!Number.isFinite(seconds) || seconds < 0 || seconds > MAX_WAIT_SECONDS) {
152
- throw new Error(`--wait needs seconds from 0 to ${MAX_WAIT_SECONDS}`);
153
- }
154
- options.waitSeconds = seconds;
156
+ options.waitSeconds = parseWait(value(), MAX_WAIT_SECONDS);
155
157
  waitGiven = true;
156
158
  } else if (arg === "--follow") options.follow = true;
157
159
  else if (arg === "--full") options.full = true;
@@ -228,8 +230,9 @@ function operationPlaces(lines: readonly { line: RunLogLine }[]): Places {
228
230
 
229
231
  function agentOf(event: ReadEvent, places: Places): string | undefined {
230
232
  if (event.source === "activity") return event.agent;
233
+ if (event.source === "run") return event.line.agent;
231
234
  if ("agent" in event.line && event.line.agent) return event.line.agent;
232
- // 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.
233
236
  return "operationId" in event.line && event.line.operationId
234
237
  ? places.agent.get(event.line.operationId)
235
238
  : undefined;
@@ -239,12 +242,15 @@ function stageOf(event: ReadEvent, places: Places): string | undefined {
239
242
  if (event.source === "activity") {
240
243
  return event.operation ? places.stage.get(event.operation) : undefined;
241
244
  }
245
+ if (event.source === "run") {
246
+ return event.line.operation ? places.stage.get(event.line.operation) : undefined;
247
+ }
242
248
  return "stage" in event.line ? event.line.stage : undefined;
243
249
  }
244
250
 
245
251
  /** The agents a messaging line is between, so `--agent` follows it from either side. */
246
252
  function sidesOf(event: ReadEvent): readonly string[] {
247
- if (event.source === "activity") return [];
253
+ if (event.source !== "log") return [];
248
254
  const { line } = event;
249
255
  switch (line.kind) {
250
256
  case "route-opened":
@@ -262,14 +268,32 @@ function sidesOf(event: ReadEvent): readonly string[] {
262
268
  }
263
269
  }
264
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
+
265
287
  function kept(read: Read, options: LogsOptions, json = false) {
266
288
  const stages = operationPlaces(read.log.lines);
289
+ const quiet = json ? new Set<number>() : quietSessions(read);
267
290
  const events = read.events.filter(
268
291
  (event) =>
292
+ !(event.source === "run" && quiet.has(decodeHandleLine(event.handle))) &&
269
293
  // A call that has ended shows on its end's line, with its input; `--json` keeps both.
270
294
  !(
271
295
  !json &&
272
- event.source !== "activity" &&
296
+ event.source === "log" &&
273
297
  event.line.kind === "tool-called" &&
274
298
  stages.calls.get(event.line.call)?.ended
275
299
  ) &&
@@ -281,6 +305,11 @@ function kept(read: Read, options: LogsOptions, json = false) {
281
305
  return { events, stages };
282
306
  }
283
307
 
308
+ function decodeHandleLine(handle: string): number {
309
+ const decoded = decodeHandle(handle);
310
+ return decoded?.kind === "run" ? decoded.line : 0;
311
+ }
312
+
284
313
  function sameLine(a: ReadEvent, b: ReadEvent): boolean {
285
314
  const first = decodeHandle(a.handle);
286
315
  const second = decodeHandle(b.handle);
@@ -348,6 +377,7 @@ async function page(
348
377
  }
349
378
  }
350
379
  const stage = currentStage(status, read.log.lines);
380
+ const screens = await keptScreens(status.run.dir, shown);
351
381
  const filters = [
352
382
  ...(options.agent ? ["--agent", options.agent] : []),
353
383
  ...(options.stage ? ["--stage", options.stage] : []),
@@ -359,7 +389,7 @@ async function page(
359
389
  if (json) {
360
390
  const { started, ended } = span(status, clock.now());
361
391
  const document: LogsJson = {
362
- v: 1,
392
+ v: LOGS_V,
363
393
  run: {
364
394
  ref: status.ref,
365
395
  state: status.state,
@@ -368,7 +398,7 @@ async function page(
368
398
  ambiguous: alsoRunning.length > 0,
369
399
  ...(status.state !== "running" && ended !== undefined ? { duration: ended - started } : {}),
370
400
  },
371
- events: shown.map((event) => publish(event, stages, PAGE_TEXT)),
401
+ events: shown.map((event) => publish(event, stages, screens, PAGE_TEXT)),
372
402
  cursor: encodeCursor(cursor),
373
403
  more: { ...(older ? { older: true } : {}), ...(newer ? { newer: true } : {}) },
374
404
  ...(next ? { next } : {}),
@@ -380,6 +410,11 @@ async function page(
380
410
  const lines: string[] = [
381
411
  header(status, stage, lastAt ? Date.parse(timeOf(lastAt.line)) : undefined),
382
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
+ }
383
418
  if (alsoRunning.length > 0) {
384
419
  const count = alsoRunning.length + (status.state === "running" ? 1 : 0);
385
420
  lines.push(`note: ${count} runs running; also ${alsoRunning.join(", ")}`);
@@ -388,12 +423,12 @@ async function page(
388
423
  const of = options.agent ? ` for ${options.agent}` : "";
389
424
  // A cut read has more than any --limit reaches: paging from the start reads it all.
390
425
  const more = read.cut
391
- ? `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)])}`
392
427
  : "raise --limit for more";
393
428
  const total = read.cut ? `the last ${events.length} read` : `${events.length}`;
394
429
  lines.push(`(showing ${shown.length} of ${total}${of}; ${more})`);
395
430
  }
396
- lines.push(...shown.map((event) => describeEvent(event, stages)));
431
+ lines.push(...shown.map((event) => describeEvent(event, stages, screens)));
397
432
  lines.push(
398
433
  trailer(
399
434
  status,
@@ -422,7 +457,8 @@ async function follow(
422
457
  const { events, stages } = kept(read, options);
423
458
  // Without a cursor it starts from the last `--limit`, as a page would.
424
459
  const shown = first && !reader.from ? events.slice(-options.limit) : events;
425
- 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));
426
462
  cursor = read.cursor;
427
463
  // A read that stopped short goes on at once.
428
464
  if (!read.more) {
@@ -449,34 +485,38 @@ async function oneEvent(
449
485
  const handle = decodeHandle(options.handle!);
450
486
  if (!handle) throw missing;
451
487
  // Read from where the handle points, and only its file.
488
+ const none = Number.MAX_SAFE_INTEGER;
452
489
  const read = await readRun(
453
490
  status.run.dir,
454
491
  status.run.record.cwd,
455
492
  handle.kind === "event"
456
- ? { log: handle.line - 1, files: {} }
457
- : { 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 } },
458
497
  {
459
498
  ended: status.state !== "running",
460
499
  full: options.full || json,
461
- only: handle.kind === "event" ? "" : handle.file,
500
+ only: handle.kind === "activity" ? handle.file : "",
462
501
  },
463
502
  );
464
503
  const stages = operationPlaces(read.log.lines);
465
504
  const event = read.events.find((each) => each.handle === encodeHandle(handle));
466
505
  if (!event) throw missing;
506
+ const screens = await keptScreens(status.run.dir, [event]);
467
507
  if (json) {
468
508
  const document: LogsJson = {
469
- v: 1,
509
+ v: LOGS_V,
470
510
  run: { ref: status.ref, state: status.state, checked: status.checked },
471
- events: [publish(event, stages)],
511
+ events: [publish(event, stages, screens)],
472
512
  };
473
513
  out.stdout(JSON.stringify(document, null, 2));
474
514
  return 0;
475
515
  }
476
- const lines = [describeEvent(event, stages)];
516
+ const lines = [describeEvent(event, stages, screens)];
477
517
  if (options.full) {
478
518
  lines.unshift(
479
- `(${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)`,
480
520
  );
481
521
  const text =
482
522
  event.source === "activity" ? event.text : JSON.stringify(masked(event.line), null, 2);
@@ -516,7 +556,12 @@ function trailer(
516
556
  }
517
557
 
518
558
  /** An event as `--json` publishes it: the line's own fields, or the activity, with its keys. */
519
- 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 {
520
565
  if (event.source === "activity") {
521
566
  const stage = event.operation ? stages.stage.get(event.operation) : undefined;
522
567
  return {
@@ -530,24 +575,60 @@ function publish(event: ReadEvent, stages: Places, limit = Number.POSITIVE_INFIN
530
575
  ...(event.text === undefined ? {} : { text: masked(event.text, limit) as string }),
531
576
  };
532
577
  }
578
+ if (event.source === "run") {
579
+ return { handle: event.handle, ...(masked(event.line, limit) as typeof event.line) };
580
+ }
533
581
  const { version: _version, ...fields } = event.line;
534
582
  return {
535
583
  handle: event.handle,
536
584
  at: event.at,
537
585
  ...(masked(fields, limit) as typeof fields),
538
586
  ...("operationId" in fields ? { operation: fields.operationId } : {}),
587
+ ...screenOf(event, screens),
539
588
  };
540
589
  }
541
590
 
542
- /** `@e216 09:27:02 fix fixer turn ended answered 1m48s`. */
543
- 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 {
544
620
  const agent = agentOf(event, stages);
545
621
  const where = [stageOf(event, stages), agent && said(agent, 60)].filter(Boolean).join(" ");
546
622
  const what =
547
623
  event.source === "activity"
548
624
  ? describeActivity(event.activity)
549
- : describeLine(event.line, stages);
550
- 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
+ }`;
551
632
  }
552
633
 
553
634
  function describeLine(line: RunLogLine, places: Places): string {
@@ -573,8 +654,8 @@ function describeLine(line: RunLogLine, places: Places): string {
573
654
  : "";
574
655
  return `${operation(line.kind)} ended ${line.outcome}${took}`;
575
656
  }
576
- case "session-known":
577
- return `session ${line.session.harness} ${line.session.id}`;
657
+ case "sandbox-opened":
658
+ return `sandbox ${said(line.key, 60)} opened ${line.provider}`;
578
659
  case "agent-opened":
579
660
  return `opened ${line.harness}${line.caller ? " (the calling session)" : ""}`;
580
661
  case "agent-stopped":
@@ -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),
@@ -12,14 +12,14 @@ export const MESSAGE_LIMITS: MessageLimits = {
12
12
 
13
13
  /**
14
14
  * Where an asker may wait for an answer, by harness and placement: on where M2 passed
15
- * (`docs/findings/messaging-m2.md`), off elsewhere. A pane that is `confirmed` waits only where it
15
+ * (`docs/findings/messaging-m2.md`), off elsewhere. A TUI that is `confirmed` waits only where it
16
16
  * confirms what it was sent, as `wfa waiting` needs. The fake harness always may. cursor is off:
17
17
  * its turns ran past their deadline in M2.
18
18
  */
19
- export const AWAIT_REPLY: Readonly<Record<string, { headless?: true; pane?: true | "confirmed" }>> =
19
+ export const AWAIT_REPLY: Readonly<Record<string, { headless?: true; tui?: true | "confirmed" }>> =
20
20
  {
21
- fake: { headless: true, pane: true },
22
- claude: { headless: true, pane: "confirmed" },
21
+ fake: { headless: true, tui: true },
22
+ claude: { headless: true, tui: "confirmed" },
23
23
  codex: { headless: true },
24
24
  pi: { headless: true },
25
25
  };