@agentswf/engine 0.0.0-stage → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/package.json +39 -4
  2. package/src/accounting/format.ts +252 -0
  3. package/src/accounting/plans.ts +38 -0
  4. package/src/accounting/prices.ts +98 -0
  5. package/src/accounting/summary.ts +385 -0
  6. package/src/accounting/tokens.ts +34 -0
  7. package/src/agent-launcher.ts +127 -0
  8. package/src/allowance-command.ts +105 -0
  9. package/src/attempt-ending.ts +198 -0
  10. package/src/attempt-output.ts +132 -0
  11. package/src/attempt-view.ts +362 -0
  12. package/src/canonical-json.ts +12 -0
  13. package/src/control-plane.ts +424 -0
  14. package/src/deadlines.ts +106 -0
  15. package/src/decisions/directory.ts +432 -0
  16. package/src/decisions/fake.ts +60 -0
  17. package/src/decisions/openrouter.ts +161 -0
  18. package/src/decisions/seam.ts +70 -0
  19. package/src/display-path.ts +16 -0
  20. package/src/duration.ts +12 -0
  21. package/src/errors.ts +4 -0
  22. package/src/files.ts +78 -0
  23. package/src/herdr-placement.ts +182 -0
  24. package/src/herdr-run-session.ts +530 -0
  25. package/src/herdr-workspace-marks.ts +194 -0
  26. package/src/here.ts +289 -0
  27. package/src/index.ts +5 -0
  28. package/src/init-command.ts +240 -0
  29. package/src/init-template/AGENTS.md.template +16 -0
  30. package/src/init-template/hello/workflow.test.ts.template +17 -0
  31. package/src/init-template/hello/workflow.ts.template +26 -0
  32. package/src/init-template/tsconfig.json.template +11 -0
  33. package/src/jsonl.ts +74 -0
  34. package/src/logs-command.ts +605 -0
  35. package/src/machine.ts +27 -0
  36. package/src/message-operation.ts +245 -0
  37. package/src/messaging/limits.ts +35 -0
  38. package/src/messaging/messaging.ts +1303 -0
  39. package/src/messaging/routes.ts +79 -0
  40. package/src/messaging/text.ts +251 -0
  41. package/src/operation-events.ts +182 -0
  42. package/src/operation-liveness.ts +674 -0
  43. package/src/operator-aliases.ts +10 -0
  44. package/src/operator-cli.ts +600 -0
  45. package/src/operator-runtime.ts +503 -0
  46. package/src/pane-layout.ts +125 -0
  47. package/src/progress-view.ts +568 -0
  48. package/src/result-slots.ts +325 -0
  49. package/src/result-validation.ts +47 -0
  50. package/src/run-command.ts +242 -0
  51. package/src/run-commands.ts +215 -0
  52. package/src/run-event-log.ts +72 -0
  53. package/src/run-prepare.ts +178 -0
  54. package/src/run-progress.ts +137 -0
  55. package/src/run-read.ts +425 -0
  56. package/src/run-state.ts +120 -0
  57. package/src/run-usage.ts +393 -0
  58. package/src/run-view.ts +225 -0
  59. package/src/runs-command.ts +356 -0
  60. package/src/runs.ts +645 -0
  61. package/src/sandbox-homes.ts +211 -0
  62. package/src/sandboxes.ts +521 -0
  63. package/src/skills/fetch.ts +305 -0
  64. package/src/skills/run-skills.ts +181 -0
  65. package/src/skills/sources.ts +87 -0
  66. package/src/skills/tree.ts +60 -0
  67. package/src/stage-ledger.ts +535 -0
  68. package/src/stage-plan.ts +127 -0
  69. package/src/stage-schema.ts +88 -0
  70. package/src/stopped.ts +36 -0
  71. package/src/test-command.ts +110 -0
  72. package/src/testing/operation-events.ts +89 -0
  73. package/src/testing.ts +164 -0
  74. package/src/workflow-loader.ts +113 -0
  75. package/src/workflow-runner.ts +3666 -0
  76. package/src/workflow-test-preload.ts +13 -0
  77. package/src/workflow-testing/decisions.ts +129 -0
  78. package/src/workflow-testing/host.ts +811 -0
  79. package/src/workflow-testing/index.ts +420 -0
  80. package/src/workflow-testing/script.ts +447 -0
  81. package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
  82. package/README.md +0 -3
@@ -0,0 +1,393 @@
1
+ import type { Billing, SettledOperation } from "@agentswf/contract/records";
2
+ import type {
3
+ AbsoluteDeadline,
4
+ AgentExecution,
5
+ AgentKey,
6
+ NativeSessionRef,
7
+ OperationRecord,
8
+ } from "@agentswf/contract/workflow";
9
+ import type { SessionAccounting, SessionRead, UsageRecord } from "@agentswf/harness";
10
+ import type { HarnessTurnOutcome } from "@agentswf/harness/adapter";
11
+ import { spendOf } from "./accounting/tokens";
12
+ import { abortableSleep, deadlineWithin, runUntilStopped, waitForDeadline } from "./deadlines";
13
+
14
+ /**
15
+ * How long a finished run waits for turns still ending after their answer. Closing the host kills
16
+ * them, and a request cut off that way is never logged; a closing message takes a few seconds.
17
+ * It bounds the run's end, so it is shorter than the host's own grace for a turn left finishing
18
+ * while the run goes on.
19
+ */
20
+ const FINISHING_GRACE_MILLISECONDS = 10_000;
21
+ /** Reading spend waits for files a released turn is still writing; it gives up after this. */
22
+ const ACCOUNTING_GRACE_MILLISECONDS = 20_000;
23
+ /** Beyond that, the host's bound on a billing status command, and this much more. */
24
+ const STATUS_SLACK_MILLISECONDS = 2_000;
25
+
26
+ /** One logical agent, as the run knows it. */
27
+ export type AccountedAgent = {
28
+ key: AgentKey;
29
+ execution: AgentExecution;
30
+ /** Where it ran; a sandboxed agent's is set once its sandbox resolves it. */
31
+ cwd: string;
32
+ /** A sandboxed agent's own harness home, where its sessions are. */
33
+ home?: string;
34
+ /** Every native session id seen for the agent so far, from its `wf` calls and its adapter. */
35
+ sessions(): readonly string[];
36
+ /** When its host first prompted it, where that waits on the agent first; see `window`. */
37
+ promptedAt?(): number | undefined;
38
+ /** The caller handed back early by agents.stop. */
39
+ releasedAt?(): number | undefined;
40
+ /** When a fork's session was made: what it holds from before is its parent's (ADR 0009). */
41
+ forkedAt?(): number | undefined;
42
+ };
43
+
44
+ /** One operation's place in the run, held from before its dispatch so records keep that order. */
45
+ type OperationEntry = {
46
+ /**
47
+ * Records what is known when the operation settles; spend and billing wait for the run's end.
48
+ * `finishing` is an answered turn the host left finishing, whose charges are added when it ends.
49
+ */
50
+ settle(
51
+ times: { deliveredAt?: number; settledAt: number },
52
+ charges: readonly number[],
53
+ finishing?: Promise<HarnessTurnOutcome>,
54
+ ): OperationRecord;
55
+ };
56
+
57
+ /**
58
+ * Where an operation ran: its workflow stage, absent between stages, its turn's label, and what
59
+ * caused it when the workflow did not.
60
+ */
61
+ export type OperationTags = Pick<OperationRecord, "stage" | "label" | "cause">;
62
+
63
+ /** One agent's part of the run's ledger. */
64
+ export type AgentLedger = {
65
+ /** `execution` is read as the operation settles: the agent's settings it ran at. */
66
+ reserve(
67
+ operationId: string,
68
+ execution: () => AgentExecution,
69
+ tags?: OperationTags,
70
+ ): OperationEntry;
71
+ /** An operation's record before it settles, with the agent's sessions as of now. */
72
+ unsettled(
73
+ operationId: string,
74
+ execution: AgentExecution,
75
+ tags: OperationTags,
76
+ deliveredAt?: number,
77
+ ): Omit<OperationRecord, "settledAt">;
78
+ };
79
+
80
+ export type RunLedger = {
81
+ /** Called in open order, so which agent keeps a request two of them report does not vary. */
82
+ agent(agent: AccountedAgent): AgentLedger;
83
+ /** Every settled operation, with the sessions its agent has been seen with so far. */
84
+ records(): OperationRecord[];
85
+ /** Best effort: a turn still going at the deadline, or when the run is stopped, is killed. */
86
+ letFinish(runDeadline: AbsoluteDeadline, signal: AbortSignal): Promise<void>;
87
+ /**
88
+ * Every record, completed with what its agent spent. Called once the host has closed, because a
89
+ * released turn still writes its last request after the engine has moved on (story 002,
90
+ * experiment 14). Best effort, and after the run's own deadline: a read or status command that
91
+ * fails, overruns its grace or is stopped leaves each record as it settled.
92
+ */
93
+ settle(signal: AbortSignal): Promise<SettledOperation[]>;
94
+ };
95
+
96
+ type Operation = {
97
+ agent: AccountedAgent;
98
+ usage?: Omit<OperationRecord, "sessions">;
99
+ costs: number[];
100
+ };
101
+ type Settled = Operation & { usage: Omit<OperationRecord, "sessions"> };
102
+
103
+ export function createRunLedger({
104
+ accounting,
105
+ startedAt,
106
+ }: {
107
+ accounting: SessionAccounting | undefined;
108
+ startedAt: number;
109
+ }): RunLedger {
110
+ const agents: AccountedAgent[] = [];
111
+ const operations: Operation[] = [];
112
+ const late = new Set<Promise<void>>();
113
+ const settled = () =>
114
+ operations.filter((operation): operation is Settled => operation.usage !== undefined);
115
+ /** Until no turn is left finishing, including one that began finishing during the wait. */
116
+ const lateEnded = async () => {
117
+ let waited = 0;
118
+ while (late.size > waited) {
119
+ waited = late.size;
120
+ await Promise.allSettled([...late]);
121
+ }
122
+ };
123
+
124
+ return {
125
+ agent(agent) {
126
+ agents.push(agent);
127
+ return {
128
+ reserve(operationId, execution, tags = {}) {
129
+ const operation: Operation = { agent, costs: [] };
130
+ operations.push(operation);
131
+ return {
132
+ settle(times, charges, finishing) {
133
+ operation.usage = {
134
+ ...recordOf(agent, operationId, execution(), tags, times.deliveredAt),
135
+ settledAt: new Date(times.settledAt).toISOString(),
136
+ };
137
+ operation.costs = [...charges];
138
+ if (finishing) {
139
+ late.add(
140
+ finishing.then(
141
+ ({ chargesUsd }) => {
142
+ operation.costs.push(...chargesUsd);
143
+ },
144
+ () => undefined,
145
+ ),
146
+ );
147
+ }
148
+ return withSessions(operation as Settled);
149
+ },
150
+ };
151
+ },
152
+ unsettled(operationId, execution, tags, deliveredAt) {
153
+ const record = recordOf(agent, operationId, execution, tags, deliveredAt);
154
+ return { ...record, sessions: sessionsOf(agent, execution) };
155
+ },
156
+ };
157
+ },
158
+
159
+ records: () => settled().map(withSessions),
160
+
161
+ async letFinish(runDeadline, signal) {
162
+ await runUntilStopped(
163
+ lateEnded,
164
+ signal,
165
+ deadlineWithin(FINISHING_GRACE_MILLISECONDS, runDeadline),
166
+ ).catch(() => undefined);
167
+ },
168
+
169
+ async settle(signal) {
170
+ const endedAt = Date.now();
171
+ const deadline = endedAt + ACCOUNTING_GRACE_MILLISECONDS;
172
+ // The bound below answers without stopping the work; this stops it, so no status command
173
+ // starts once the records are returned.
174
+ const settling = new AbortController();
175
+ try {
176
+ return await runUntilStopped(
177
+ async () => {
178
+ // Closing the host ended every turn left finishing; what each charged lands here.
179
+ await waitForDeadline(lateEnded(), { unixMilliseconds: deadline }).catch(
180
+ () => undefined,
181
+ );
182
+ return await settleUsage(settled(), agents, accounting, {
183
+ startedAt,
184
+ endedAt,
185
+ deadline,
186
+ signal: AbortSignal.any([signal, settling.signal]),
187
+ });
188
+ },
189
+ signal,
190
+ // The grace paces the re-reads; this bounds a read or status command that never returns.
191
+ { unixMilliseconds: deadline + (accounting?.statusMs ?? 0) + STATUS_SLACK_MILLISECONDS },
192
+ );
193
+ } catch {
194
+ return unread(settled());
195
+ } finally {
196
+ settling.abort();
197
+ }
198
+ },
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Reads what every agent spent and gives each operation its share. While any session's turn is
204
+ * still open the files are read again, until that turn closes, it stops changing for `stalledMs`,
205
+ * `deadline` passes or `signal` aborts. Whatever the last read found stands.
206
+ */
207
+ async function settleUsage(
208
+ operations: readonly Settled[],
209
+ agents: readonly AccountedAgent[],
210
+ accounting: SessionAccounting | undefined,
211
+ options: { startedAt: number; endedAt: number; deadline: number; signal: AbortSignal },
212
+ ): Promise<SettledOperation[]> {
213
+ if (!accounting) return unread(operations);
214
+ // An agent opened but never asked anything is not read, so it cannot claim another's requests.
215
+ const asked = agents.flatMap((agent) => {
216
+ const own = operations.filter((operation) => operation.agent === agent);
217
+ return own.length === 0 ? [] : [{ agent, own }];
218
+ });
219
+
220
+ const reads = await readUntilSettled(
221
+ asked.map(({ agent }) => agent),
222
+ accounting,
223
+ options,
224
+ );
225
+ // The caller has already answered with the records as they settled; no status command starts.
226
+ if (options.signal.aborted) return unread(operations);
227
+ const claimed = new Set<string>();
228
+ const counted = new Map<AccountedAgent, UsageRecord[] | undefined>();
229
+ for (const { agent } of asked) {
230
+ const { from, until } = window(agent, options);
231
+ counted.set(
232
+ agent,
233
+ reads.get(agent)?.records.filter((record) => {
234
+ const claim = `${agent.execution.harness}\u0000${record.key}`;
235
+ const at = Date.parse(record.at);
236
+ // A resumed session carries requests from before the run, and two agents can name one.
237
+ if (claimed.has(claim) || !(at >= from && at <= until)) return false;
238
+ claimed.add(claim);
239
+ return true;
240
+ }),
241
+ );
242
+ }
243
+ const settledAgents = new Map(
244
+ await Promise.all(
245
+ asked.map(async ({ agent, own }) => {
246
+ const records = counted.get(agent);
247
+ const billing: Billing =
248
+ (await attempt(() => accounting.billing(agent.execution, records))) ?? "unknown";
249
+ return [agent, { billing, shares: records && share(own, records) }] as const;
250
+ }),
251
+ ),
252
+ );
253
+
254
+ return operations.map((operation) => {
255
+ const { billing, shares } = settledAgents.get(operation.agent)!;
256
+ const { costs } = operation;
257
+ const charged = billing === "metered" && costs.length > 0 ? sum(costs) : undefined;
258
+ return {
259
+ ...withSessions(operation),
260
+ billing,
261
+ ...(shares ? { spend: spendOf(shares.get(operation.usage.operationId) ?? []) } : {}),
262
+ ...(charged === undefined ? {} : { charged: { amount: charged, currency: "USD" } }),
263
+ };
264
+ });
265
+ }
266
+
267
+ /**
268
+ * When an agent's records are the run's. A calling session's are from the run's first prompt to it
269
+ * until the run's own work ended: before, its turns were the operator's, the one that replied with
270
+ * the run's code included, and so are its turns after the hand-back (ADR 0010). That prompt is the
271
+ * host's, not the operation's delivery, which comes before the host waits for the operator's turn.
272
+ */
273
+ function window(
274
+ agent: AccountedAgent,
275
+ options: { startedAt: number; endedAt: number },
276
+ ): { from: number; until: number } {
277
+ // Copied rows are its parent's even when the parent does not claim them: a calling session
278
+ // never asked anything, or asked only after them.
279
+ const forkedAt = agent.forkedAt?.();
280
+ if (forkedAt !== undefined) return { from: forkedAt, until: Number.POSITIVE_INFINITY };
281
+ if (!agent.execution.caller) return { from: options.startedAt, until: Number.POSITIVE_INFINITY };
282
+ return {
283
+ from: agent.promptedAt?.() ?? Number.POSITIVE_INFINITY,
284
+ until: agent.releasedAt?.() ?? options.endedAt,
285
+ };
286
+ }
287
+
288
+ async function readUntilSettled(
289
+ agents: readonly AccountedAgent[],
290
+ accounting: SessionAccounting,
291
+ options: { deadline: number; signal: AbortSignal },
292
+ ): Promise<Map<AccountedAgent, SessionRead | undefined>> {
293
+ const readAll = async () =>
294
+ new Map(
295
+ await Promise.all(
296
+ agents.map(async (agent) => {
297
+ const ids = agent.sessions();
298
+ const read =
299
+ // A sandboxed agent's home names its sessions even when nothing else did.
300
+ ids.length === 0 && !agent.home
301
+ ? undefined
302
+ : await attempt(() => accounting.read(agent.execution, ids, agent.cwd, agent.home));
303
+ return [agent, read] as const;
304
+ }),
305
+ ),
306
+ );
307
+ let latest = await readAll();
308
+ let changedAt = Date.now();
309
+ while (
310
+ [...latest.values()].some((read) => read?.open) &&
311
+ Date.now() - changedAt < accounting.stalledMs &&
312
+ Date.now() + accounting.pollMs < options.deadline &&
313
+ !options.signal.aborted
314
+ ) {
315
+ await abortableSleep(accounting.pollMs, options.signal);
316
+ const next = await readAll();
317
+ if (JSON.stringify([...next.values()]) !== JSON.stringify([...latest.values()])) {
318
+ changedAt = Date.now();
319
+ }
320
+ latest = next;
321
+ }
322
+ return latest;
323
+ }
324
+
325
+ /**
326
+ * An operation owns what its agent logged from its delivery until the next operation's. What came
327
+ * before the first delivery is the first operation's, and so is everything when none was
328
+ * delivered: a share never drops a record.
329
+ */
330
+ function share(
331
+ operations: readonly Settled[],
332
+ records: readonly UsageRecord[],
333
+ ): Map<string, UsageRecord[]> {
334
+ const delivered = operations
335
+ .flatMap(({ usage }) =>
336
+ usage.deliveredAt === undefined
337
+ ? []
338
+ : [{ id: usage.operationId, from: Date.parse(usage.deliveredAt) }],
339
+ )
340
+ .sort((left, right) => left.from - right.from);
341
+ const first = delivered[0]?.id ?? operations[0]!.usage.operationId;
342
+ const shares = new Map<string, UsageRecord[]>();
343
+ for (const record of records) {
344
+ const at = Date.parse(record.at);
345
+ const owner = delivered.findLast((operation) => operation.from <= at)?.id ?? first;
346
+ shares.set(owner, [...(shares.get(owner) ?? []), record]);
347
+ }
348
+ return shares;
349
+ }
350
+
351
+ function recordOf(
352
+ agent: AccountedAgent,
353
+ operationId: string,
354
+ execution: AgentExecution,
355
+ tags: OperationTags,
356
+ deliveredAt: number | undefined,
357
+ ): Omit<OperationRecord, "sessions" | "settledAt"> {
358
+ return {
359
+ callPath: [],
360
+ agent: agent.key,
361
+ operationId,
362
+ execution,
363
+ ...(deliveredAt === undefined ? {} : { deliveredAt: new Date(deliveredAt).toISOString() }),
364
+ ...(tags.stage === undefined ? {} : { stage: tags.stage }),
365
+ ...(tags.label === undefined ? {} : { label: tags.label }),
366
+ ...(tags.cause === undefined ? {} : { cause: tags.cause }),
367
+ };
368
+ }
369
+
370
+ function sessionsOf(agent: AccountedAgent, execution: AgentExecution): NativeSessionRef[] {
371
+ return agent.sessions().map((id) => ({ harness: execution.harness, id }));
372
+ }
373
+
374
+ function withSessions({ usage, agent }: Settled): OperationRecord {
375
+ return { ...usage, sessions: sessionsOf(agent, usage.execution) };
376
+ }
377
+
378
+ function unread(operations: readonly Settled[]): SettledOperation[] {
379
+ return operations.map((operation) => ({ ...withSessions(operation), billing: "unknown" }));
380
+ }
381
+
382
+ /** A reader or status command is another package's code: a throw of any kind is "unknown". */
383
+ async function attempt<T>(call: () => Promise<T>): Promise<T | undefined> {
384
+ try {
385
+ return await call();
386
+ } catch {
387
+ return undefined;
388
+ }
389
+ }
390
+
391
+ function sum(values: readonly number[]): number {
392
+ return values.reduce((total, value) => total + value, 0);
393
+ }
@@ -0,0 +1,225 @@
1
+ import { join } from "node:path";
2
+ import type {
3
+ Activity,
4
+ AttemptRecord,
5
+ OperationLivenessRecord,
6
+ RunLogLine,
7
+ } from "@agentswf/contract/records";
8
+ import type { AgentKey, HarnessKind, StageOutcome } from "@agentswf/contract/workflow";
9
+ import { readLines } from "./jsonl";
10
+ import { currentStage, estimateOf } from "./run-commands";
11
+ import { type Cursor, type FileCache, readRun, timeOf } from "./run-read";
12
+ import type { RunStatus } from "./run-state";
13
+
14
+ /** A stage of the attempt shown: running, reused or given from an earlier one, or how it ended. */
15
+ export type StageView = {
16
+ stage: string;
17
+ state: "running" | "interrupted" | "reused" | "provided" | StageOutcome;
18
+ startedAt?: number;
19
+ endedAt?: number;
20
+ };
21
+
22
+ /**
23
+ * An agent of the attempt shown, as its operations leave it: working on one, or its last one's
24
+ * outcome. What its liveness file and native files say of it is best effort, and kept apart.
25
+ */
26
+ export type AgentView = {
27
+ key: AgentKey;
28
+ harness?: HarnessKind;
29
+ caller: boolean;
30
+ /** The stage its last operation ran in. */
31
+ stage?: string;
32
+ state: string;
33
+ turns: number;
34
+ /** When its current operation, or its last, started. */
35
+ since?: number;
36
+ /** Its operation's latest phase, where it is waiting within it, or awaiting `peer`'s reply. */
37
+ live?:
38
+ | { phase: "waiting"; reason?: string; until?: number }
39
+ | { phase: "awaiting-reply"; peer: string; until?: number };
40
+ last?: { activity: Activity; at: string };
41
+ sessions: { harness: HarnessKind; id: string; files: string[] }[];
42
+ };
43
+
44
+ export type DecisionView = { decision: number; key: string; askedAt: number; outcome?: string };
45
+
46
+ export type RunView = {
47
+ status: RunStatus;
48
+ /** The attempt shown: the last. */
49
+ attempt?: AttemptRecord;
50
+ stages: StageView[];
51
+ /** The stage running now, or the one the attempt ended in. */
52
+ stage?: string;
53
+ agents: AgentView[];
54
+ decisions: DecisionView[];
55
+ /** Over the attempts that kept one, at list prices: never what was billed. */
56
+ estimate?: number;
57
+ lastEventAt?: number;
58
+ lines: { number: number; line: RunLogLine }[];
59
+ cursor: Cursor;
60
+ };
61
+
62
+ export async function viewRun(status: RunStatus): Promise<RunView> {
63
+ const { run, attempts } = status;
64
+ const attempt = attempts.at(-1);
65
+ const files: FileCache = new Map();
66
+ const read = await readRun(run.dir, run.record.cwd, undefined, {
67
+ ended: status.state !== "running",
68
+ files,
69
+ tail: true,
70
+ });
71
+ const own = read.log.lines
72
+ .map(({ line }) => line)
73
+ .filter((line) => attempt === undefined || line.attempt === attempt.attempt);
74
+
75
+ const stages: StageView[] = [];
76
+ const decisions: DecisionView[] = [];
77
+ const agents = new Map<AgentKey, AgentView>();
78
+ const open = new Map<string, AgentKey>();
79
+ const agent = (key: AgentKey) => {
80
+ const found = agents.get(key) ?? {
81
+ key,
82
+ caller: read.log.agents.get(key)?.caller ?? false,
83
+ state: "idle",
84
+ turns: 0,
85
+ sessions: [],
86
+ };
87
+ agents.set(key, found);
88
+ return found;
89
+ };
90
+ for (const line of own) {
91
+ const at = Date.parse(timeOf(line));
92
+ switch (line.kind) {
93
+ case "stage-started":
94
+ stages.push({ stage: line.stage, state: "running", startedAt: at });
95
+ break;
96
+ case "stage-ended": {
97
+ const started = stages.find((each) => each.stage === line.stage);
98
+ const state = line.source ?? line.outcome;
99
+ if (started) Object.assign(started, { state, endedAt: at });
100
+ else stages.push({ stage: line.stage, state, startedAt: at, endedAt: at });
101
+ break;
102
+ }
103
+ case "agent-opened":
104
+ agent(line.agent).harness = line.harness;
105
+ break;
106
+ case "operation-started": {
107
+ const started = agent(line.agent);
108
+ Object.assign(started, { state: "working", since: at, harness: line.execution.harness });
109
+ if (line.stage !== undefined) started.stage = line.stage;
110
+ open.set(line.operationId, line.agent);
111
+ break;
112
+ }
113
+ case "turn":
114
+ case "compact":
115
+ case "operation-failed": {
116
+ const settled = agent(line.agent);
117
+ open.delete(line.operationId);
118
+ if (line.stage !== undefined) settled.stage = line.stage;
119
+ settled.harness ??= line.execution.harness;
120
+ settled.state = line.kind === "operation-failed" ? "failed" : line.outcome;
121
+ if (line.kind === "turn") settled.turns += 1;
122
+ break;
123
+ }
124
+ case "agent-stopped":
125
+ if (agent(line.agent).state === "working") agent(line.agent).state = "stopped";
126
+ break;
127
+ case "decision-asked":
128
+ decisions.push({ decision: line.decision, key: line.key, askedAt: at });
129
+ break;
130
+ case "decision-ended": {
131
+ const asked = decisions.find((each) => each.decision === line.decision);
132
+ if (asked) asked.outcome = line.outcome;
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ const stage = currentStage(status, read.log.lines);
138
+ // A run whose process is gone left what it was doing undone.
139
+ if (status.state === "interrupted") {
140
+ for (const each of agents.values()) if (each.state === "working") each.state = "was working";
141
+ for (const each of stages) if (each.state === "running") each.state = "interrupted";
142
+ }
143
+ if (status.state === "running") {
144
+ for (const [id, key] of open) {
145
+ const phase = await latestPhase(run.dir, id);
146
+ const until = phase?.until === undefined ? {} : { until: phase.until };
147
+ if (phase?.kind === "waiting") {
148
+ agent(key).live = {
149
+ phase: "waiting",
150
+ ...(phase.reason === undefined ? {} : { reason: phase.reason }),
151
+ ...until,
152
+ };
153
+ } else if (phase?.kind === "awaiting-reply" && phase.peer !== undefined) {
154
+ agent(key).live = { phase: "awaiting-reply", peer: phase.peer, ...until };
155
+ }
156
+ }
157
+ }
158
+ const since = attempt ? Date.parse(attempt.started) : Number.NEGATIVE_INFINITY;
159
+ for (const event of read.events) {
160
+ if (event.source !== "activity" || !event.agent || event.time < since) continue;
161
+ const seen = agents.get(event.agent);
162
+ if (seen) seen.last = { activity: event.activity, at: event.at };
163
+ }
164
+ for (const session of read.log.sessions) {
165
+ const seen = session.agent ? agents.get(session.agent) : undefined;
166
+ if (!seen) continue;
167
+ seen.sessions.push({
168
+ harness: session.harness,
169
+ id: session.id,
170
+ files: (files.get(session.n)?.files ?? []).map((file) => file.path),
171
+ });
172
+ }
173
+ const estimate = estimateOf(attempts);
174
+ const lastLine = read.log.lines.at(-1)?.line;
175
+ return {
176
+ status,
177
+ ...(attempt ? { attempt } : {}),
178
+ stages,
179
+ ...(stage === undefined ? {} : { stage }),
180
+ agents: [...agents.values()],
181
+ decisions,
182
+ ...(estimate === undefined ? {} : { estimate }),
183
+ ...(lastLine ? { lastEventAt: Date.parse(timeOf(lastLine)) } : {}),
184
+ lines: read.log.lines,
185
+ cursor: read.cursor,
186
+ };
187
+ }
188
+
189
+ /** The latest phase a call's liveness file holds, which is diagnostics, best effort. */
190
+ async function latestPhase(
191
+ runDir: string,
192
+ operationId: string,
193
+ ): Promise<OperationLivenessRecord | undefined> {
194
+ const file = join(runDir, "calls", operationId, "liveness.jsonl");
195
+ return (await readLines<OperationLivenessRecord>(file).catch(() => [])).at(-1);
196
+ }
197
+
198
+ /** `45s`, `6m40s`, `1h02m`. */
199
+ export function duration(milliseconds: number): string {
200
+ const seconds = Math.max(0, Math.round(milliseconds / 1000));
201
+ if (seconds < 60) return `${seconds}s`;
202
+ const minutes = Math.floor(seconds / 60);
203
+ if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
204
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
205
+ }
206
+
207
+ /** An activity in a few words: `tool Bash bun test → exit 0`, `message 412 chars`. */
208
+ export function describeActivity(activity: Activity): string {
209
+ switch (activity.type) {
210
+ case "message":
211
+ return `${activity.from === "user" ? "prompt" : "message"} ${activity.chars.toLocaleString("en-US")} chars`;
212
+ case "tool":
213
+ return `tool ${activity.tool}${activity.input ? ` ${activity.input}` : ""}${activity.background ? " (background)" : ""}`;
214
+ case "tool-result":
215
+ return activity.exit !== undefined
216
+ ? `→ exit ${activity.exit}`
217
+ : activity.error
218
+ ? "→ error"
219
+ : "→ ok";
220
+ case "background-ended":
221
+ return "background task ended";
222
+ case "compaction":
223
+ return "compacted";
224
+ }
225
+ }