@agentswf/engine 0.1.0-beta.2 → 0.1.0-beta.4

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 +5 -6
  2. package/src/agent-cli/cli.ts +571 -0
  3. package/src/agent-cli/client.ts +115 -0
  4. package/src/agent-launcher.ts +15 -25
  5. package/src/allowance-command.ts +31 -21
  6. package/src/attempt-ending.ts +23 -5
  7. package/src/attempt-output.ts +48 -18
  8. package/src/attempt-view.ts +25 -15
  9. package/src/commands.ts +176 -0
  10. package/src/control-plane.ts +73 -22
  11. package/src/coordinator-port.ts +29 -0
  12. package/src/coordinator-prompt.ts +237 -0
  13. package/src/coordinator-socket.ts +236 -0
  14. package/src/deadlines.ts +47 -10
  15. package/src/decisions/directory.ts +8 -6
  16. package/src/duration.ts +29 -0
  17. package/src/emit-command.ts +119 -0
  18. package/src/emit.ts +246 -0
  19. package/src/files.ts +11 -1
  20. package/src/help/allowance.txt +13 -0
  21. package/src/help/emit.txt +13 -0
  22. package/src/help/inbox.txt +12 -0
  23. package/src/help/init.txt +12 -0
  24. package/src/help/logs.txt +13 -0
  25. package/src/help/reply.txt +18 -0
  26. package/src/help/run.txt +44 -0
  27. package/src/help/runs.txt +11 -0
  28. package/src/help/test.txt +9 -0
  29. package/src/help/tick.txt +12 -0
  30. package/src/here.ts +9 -3
  31. package/src/hold-run-lock.fixture.ts +8 -0
  32. package/src/holder-command.ts +279 -0
  33. package/src/inbox-command.ts +715 -0
  34. package/src/init-command.ts +29 -11
  35. package/src/init-template/AGENTS.md.template +4 -4
  36. package/src/init-template/hello/workflow.test.ts.template +2 -2
  37. package/src/init-template/hello/workflow.ts.template +16 -20
  38. package/src/keep-alive.ts +40 -0
  39. package/src/logs-command.ts +106 -35
  40. package/src/machine.ts +12 -1
  41. package/src/mailbox.ts +990 -0
  42. package/src/message-operation.ts +26 -3
  43. package/src/messaging/limits.ts +1 -1
  44. package/src/messaging/messaging.ts +478 -85
  45. package/src/messaging/routes.ts +11 -4
  46. package/src/messaging/text.ts +25 -22
  47. package/src/operation-liveness.ts +55 -9
  48. package/src/operator-cli.ts +290 -119
  49. package/src/operator-runtime.ts +8 -8
  50. package/src/progress-view.ts +49 -7
  51. package/src/projects.ts +57 -0
  52. package/src/result-slots.ts +43 -19
  53. package/src/result-validation.ts +18 -27
  54. package/src/run-command.ts +29 -66
  55. package/src/run-commands.ts +13 -2
  56. package/src/run-lock.ts +123 -0
  57. package/src/run-prepare.ts +94 -22
  58. package/src/run-state.ts +125 -6
  59. package/src/run-usage.ts +2 -2
  60. package/src/run-view.ts +0 -9
  61. package/src/runs-command.ts +43 -25
  62. package/src/runs.ts +142 -23
  63. package/src/sandboxes.ts +11 -4
  64. package/src/simple-input.ts +268 -0
  65. package/src/socket-exchange.ts +26 -0
  66. package/src/sources.ts +70 -0
  67. package/src/stage-ledger.ts +89 -14
  68. package/src/stage-plan.ts +23 -0
  69. package/src/stop-crashed.ts +39 -0
  70. package/src/test-command.ts +34 -11
  71. package/src/testing.ts +72 -4
  72. package/src/tick-command.ts +76 -0
  73. package/src/tools.ts +645 -0
  74. package/src/wake.ts +264 -0
  75. package/src/workflow-loader.ts +32 -22
  76. package/src/workflow-runner.ts +1277 -97
  77. package/src/workflow-testing/coordinator.typecheck.ts +54 -0
  78. package/src/workflow-testing/coordinators.ts +137 -0
  79. package/src/workflow-testing/host.ts +67 -7
  80. package/src/workflow-testing/index.ts +488 -34
  81. package/src/workflow-testing/script.ts +74 -4
  82. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
@@ -0,0 +1,176 @@
1
+ import { allowanceUsage } from "./allowance-command";
2
+ import { emitUsage } from "./emit-command";
3
+ import { replyUsage } from "./holder-command";
4
+ import { inboxUsage } from "./inbox-command";
5
+ import { initUsage } from "./init-command";
6
+ import { logsUsage } from "./logs-command";
7
+ import { usage as runUsage } from "./run-command";
8
+ import { shellLine } from "./run-commands";
9
+ import { runsUsage } from "./runs-command";
10
+ import { testUsage } from "./test-command";
11
+ import { tickUsage } from "./tick-command";
12
+
13
+ /**
14
+ * Every `awf` command: what the index, `awf help` and the suggestion read, and what the dispatch is
15
+ * keyed by. An entry's commands share its help; each has its own line in the index.
16
+ */
17
+ export const COMMANDS = [
18
+ {
19
+ group: "Run a workflow",
20
+ commands: { run: "Start a run of a workflow file, or continue one" },
21
+ help: runUsage,
22
+ },
23
+ { group: "Run a workflow", commands: { test: "Run a workflow's tests" }, help: testUsage },
24
+ {
25
+ group: "Run a workflow",
26
+ commands: { init: "Set up a directory for writing workflows" },
27
+ help: initUsage,
28
+ },
29
+ {
30
+ group: "Run a workflow",
31
+ commands: { emit: "Send an event to a workflow with events, starting or waking its run" },
32
+ help: emitUsage,
33
+ },
34
+ {
35
+ group: "Run a workflow",
36
+ commands: { tick: "Wake the runs of workflows with events that are due" },
37
+ help: tickUsage,
38
+ },
39
+ {
40
+ group: "Look at runs",
41
+ commands: { runs: "The runs here, or one run in depth" },
42
+ help: runsUsage,
43
+ },
44
+ {
45
+ group: "Look at runs",
46
+ commands: { logs: "What a run did and is doing, merged by time" },
47
+ help: logsUsage,
48
+ },
49
+ {
50
+ group: "Coordinate a run from outside",
51
+ commands: { inbox: "What agents said to a participant outside the run, and what it said back" },
52
+ help: inboxUsage,
53
+ },
54
+ {
55
+ group: "Coordinate a run from outside",
56
+ commands: { reply: "Answer a question an agent asked", send: "Message an agent first" },
57
+ help: replyUsage,
58
+ },
59
+ {
60
+ group: "This machine",
61
+ commands: { allowance: "What is left of each harness's plan" },
62
+ help: allowanceUsage,
63
+ },
64
+ ] as const;
65
+
66
+ type Entry = (typeof COMMANDS)[number];
67
+ type NamesOf<Each> = Each extends { commands: infer Commands } ? keyof Commands & string : never;
68
+ export type CommandName = NamesOf<Entry>;
69
+
70
+ const NAMES = COMMANDS.flatMap((entry) => Object.keys(entry.commands)) as CommandName[];
71
+
72
+ export function isCommand(word: string): word is CommandName {
73
+ return (NAMES as string[]).includes(word);
74
+ }
75
+
76
+ /** The help `awf help {name}` and `awf {name} -h` print. */
77
+ export function helpOf(name: CommandName): string {
78
+ return COMMANDS.find((entry) => name in entry.commands)!.help;
79
+ }
80
+
81
+ const WIDTH = Math.max(...NAMES.map((name) => name.length)) + 2;
82
+
83
+ function grouped(): [group: string, entries: Entry[]][] {
84
+ const groups = new Map<string, Entry[]>();
85
+ for (const entry of COMMANDS)
86
+ groups.set(entry.group, [...(groups.get(entry.group) ?? []), entry]);
87
+ return [...groups];
88
+ }
89
+
90
+ const lines = (entry: Entry) =>
91
+ Object.entries(entry.commands).map(([name, summary]) => `${name.padEnd(WIDTH)}${summary}`);
92
+
93
+ /** What `awf`, `awf -h` and `awf help` print. */
94
+ export function index(): string {
95
+ return [
96
+ "awf runs workflows of coding agents.",
97
+ "",
98
+ "usage: awf <command> [arguments]",
99
+ "",
100
+ ...grouped().flatMap(([group, entries]) => [
101
+ group,
102
+ ...entries.flatMap(lines).map((line) => ` ${line}`),
103
+ "",
104
+ ]),
105
+ "awf <command> -h a command's usage, options and exit codes",
106
+ "awf help reference every command's usage, on one page",
107
+ "awf --version",
108
+ ].join("\n");
109
+ }
110
+
111
+ /** A command's usage lines: its help up to the first blank line. */
112
+ export function usageLines(help: string): string[] {
113
+ const end = help.indexOf("\n\n");
114
+ return (end < 0 ? help : help.slice(0, end)).split("\n");
115
+ }
116
+
117
+ /** What `awf help reference` prints: each command's summary and usage lines, grouped as the index. */
118
+ export function reference(): string {
119
+ return [
120
+ ...grouped().flatMap(([group, entries]) => [
121
+ group,
122
+ ...entries.flatMap((entry) => [
123
+ ...lines(entry).map((line) => ` ${line}`),
124
+ ...usageLines(entry.help).map((line) => ` ${line}`),
125
+ "",
126
+ ]),
127
+ ]),
128
+ "awf <command> -h for what each does, and its exit codes",
129
+ ].join("\n");
130
+ }
131
+
132
+ const WORKFLOW_FILE = /\.(ts|mts|js|mjs)$/;
133
+
134
+ /** The one line `awf {word}` gets when no command is named `word`. */
135
+ export function unknownCommand(word: string): string {
136
+ const said = `awf: no command ${JSON.stringify(word)}`;
137
+ if (WORKFLOW_FILE.test(word)) return `${said}; did you mean: ${shellLine(["awf", "run", word])}`;
138
+ const meant = suggest(word);
139
+ const named =
140
+ meant.length === 0
141
+ ? ""
142
+ : ` did you mean ${meant.length === 1 ? meant[0] : `${meant.slice(0, -1).join(", ")} or ${meant.at(-1)}`}?`;
143
+ return `${said};${named} awf -h lists the commands`;
144
+ }
145
+
146
+ /**
147
+ * The commands `word` is the start of, else the closest within two edits; every one tied for
148
+ * closest.
149
+ */
150
+ export function suggest(word: string): CommandName[] {
151
+ if (word === "") return [];
152
+ const started = NAMES.filter((name) => name.startsWith(word));
153
+ if (started.length > 0) return started;
154
+ const near = NAMES.map((name) => ({ name, distance: distance(word, name) })).filter(
155
+ (each) => each.distance <= 2,
156
+ );
157
+ const closest = Math.min(...near.map((each) => each.distance));
158
+ return near.filter((each) => each.distance === closest).map((each) => each.name);
159
+ }
160
+
161
+ /** Levenshtein distance. */
162
+ function distance(a: string, b: string): number {
163
+ let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
164
+ for (let i = 1; i <= a.length; i += 1) {
165
+ const current = [i];
166
+ for (let j = 1; j <= b.length; j += 1) {
167
+ current[j] = Math.min(
168
+ previous[j]! + 1,
169
+ current[j - 1]! + 1,
170
+ previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
171
+ );
172
+ }
173
+ previous = current;
174
+ }
175
+ return previous[b.length]!;
176
+ }
@@ -1,10 +1,19 @@
1
1
  import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { type ControlResponse, decodeControlRequest, WIRE_VERSION } from "@agentswf/contract/wire";
3
+ import {
4
+ type ControlResponse,
5
+ decodeControlRequest,
6
+ type GivenInput,
7
+ WIRE_VERSION,
8
+ } from "@agentswf/contract/wire";
4
9
  import { isCode } from "./files";
5
10
  import type { Messaging } from "./messaging/messaging";
6
11
  import { UNRECORDED } from "./messaging/text";
7
12
  import type { ResultSlotRegistry } from "./result-slots";
13
+ import type { ToolCalls } from "./tools";
14
+
15
+ /** What the control plane asks of the run's tools, by the agent whose socket asked. */
16
+ export type ToolHandler = Pick<ToolCalls<unknown>, "call" | "running" | "accepted">;
8
17
 
9
18
  /** What the control plane asks of the run's messaging, by the agent whose socket asked. */
10
19
  export type MessagingHandler = Pick<
@@ -12,10 +21,11 @@ export type MessagingHandler = Pick<
12
21
  "checkFinish" | "peers" | "send" | "reply" | "owing"
13
22
  >;
14
23
 
15
- /** `wf` accepts a 1 MiB value; escaped into the request's JSON it can double, plus the envelope. */
24
+ /** `wfa` accepts a 1 MiB value; escaped into the request's JSON it can double, plus the envelope. */
16
25
  const MAX_RESULT_REQUEST_BYTES = 2 * 1024 * 1024 + 4 * 1024;
17
26
  const MAX_CONTROL_CONNECTIONS = 64;
18
27
  const CONNECTION_TIMEOUT_SECONDS = 30;
28
+ const MAX_TIMER_MS = 2_147_483_647;
19
29
 
20
30
  export type ResultChannel = {
21
31
  /** The socket this agent's launcher connects to. */
@@ -36,6 +46,8 @@ export type ResultControlPlane = {
36
46
  openChannel(agentId: string, onSession?: (id: string) => void): Promise<ResultChannel>;
37
47
  /** The run's messaging, once it exists; until then `peers`, `send` and `reply` are refused. */
38
48
  attach(messaging: MessagingHandler): void;
49
+ /** The run's tools, once it has them; until then `tool` is refused. */
50
+ attachTools(tools: ToolHandler): void;
39
51
  close(): Promise<void>;
40
52
  };
41
53
 
@@ -91,6 +103,7 @@ export async function startResultControlPlane(options: {
91
103
  await chmod(directory, 0o300);
92
104
 
93
105
  let messaging: MessagingHandler | undefined;
106
+ let tools: ToolHandler | undefined;
94
107
  // Shared across channels: the budget protects the engine, not any one agent.
95
108
  let activeConnections = 0;
96
109
  let accepting = true;
@@ -175,13 +188,22 @@ export async function startResultControlPlane(options: {
175
188
  const { promise: written, resolve } = Promise.withResolvers<void>();
176
189
  socket.data.responded = resolve;
177
190
  const handling = Promise.race([
178
- handleFrame(
179
- Buffer.concat(socket.data.chunks).toString("utf8"),
180
- options.slots,
191
+ handleFrame(Buffer.concat(socket.data.chunks).toString("utf8"), {
192
+ slots: options.slots,
181
193
  messaging,
194
+ tools,
182
195
  agentId,
183
196
  onSession,
184
- ),
197
+ // A tool call lasts as long as its budget, not the connection's 30 s.
198
+ lifetime: (milliseconds) => {
199
+ if (socket.data.lifetime) clearTimeout(socket.data.lifetime);
200
+ // One past a timer's range is as good as none: the call's own end closes it.
201
+ socket.data.lifetime =
202
+ milliseconds === undefined || milliseconds > MAX_TIMER_MS
203
+ ? undefined
204
+ : setTimeout(() => socket.terminate(), milliseconds);
205
+ },
206
+ }),
185
207
  written.then(() => undefined),
186
208
  ])
187
209
  .then((response) => {
@@ -249,6 +271,9 @@ export async function startResultControlPlane(options: {
249
271
  attach(handler) {
250
272
  messaging = handler;
251
273
  },
274
+ attachTools(handler) {
275
+ tools = handler;
276
+ },
252
277
  close() {
253
278
  closing ??= (async () => {
254
279
  accepting = false;
@@ -287,14 +312,18 @@ function emptyConnection(): ConnectionState {
287
312
  };
288
313
  }
289
314
 
290
- async function handleFrame(
291
- frame: string,
292
- slots: ResultSlotRegistry,
293
- messaging: MessagingHandler | undefined,
294
- agentId: string,
295
- onSession: ((id: string) => void) | undefined,
296
- ): Promise<ControlResponse> {
297
- const response = await answerFrame(frame, slots, messaging, agentId, onSession);
315
+ type Handlers = {
316
+ slots: ResultSlotRegistry;
317
+ messaging: MessagingHandler | undefined;
318
+ tools: ToolHandler | undefined;
319
+ agentId: string;
320
+ onSession: ((id: string) => void) | undefined;
321
+ lifetime: (milliseconds: number | undefined) => void;
322
+ };
323
+
324
+ async function handleFrame(frame: string, handlers: Handlers): Promise<ControlResponse> {
325
+ const { messaging, agentId } = handlers;
326
+ const response = await answerFrame(frame, handlers);
298
327
  // Leads every response, so what is owed comes back after a compaction; not one that already
299
328
  // says it, about the wait it began or the answer it is refused for.
300
329
  const repeats =
@@ -308,10 +337,7 @@ async function handleFrame(
308
337
 
309
338
  async function answerFrame(
310
339
  frame: string,
311
- slots: ResultSlotRegistry,
312
- messaging: MessagingHandler | undefined,
313
- agentId: string,
314
- onSession: ((id: string) => void) | undefined,
340
+ { slots, messaging, tools, agentId, onSession, lifetime }: Handlers,
315
341
  ): Promise<ControlResponse> {
316
342
  if (!frame.endsWith("\n") || frame.slice(0, -1).includes("\n")) {
317
343
  return rejected("invalid-request", "expected exactly one newline-delimited JSON request");
@@ -329,6 +355,17 @@ async function answerFrame(
329
355
  if (decoded.value.session) onSession?.(decoded.value.session);
330
356
 
331
357
  const request = decoded.value;
358
+ if (request.command === "tool") {
359
+ if (!tools) return rejected("unsupported-command", "tools are not available in this run");
360
+ let answer: Awaited<ReturnType<ToolHandler["call"]>>;
361
+ try {
362
+ answer = await tools.call(agentId, request, lifetime);
363
+ } catch {
364
+ return rejected("internal-error", "the tool call failed internally; it may have run");
365
+ }
366
+ if (answer.kind === "refused") return rejected(answer.code, answer.error);
367
+ return { version: WIRE_VERSION, ...answer };
368
+ }
332
369
  if (request.command !== "result" && request.command !== "waiting") {
333
370
  if (!messaging)
334
371
  return rejected("unsupported-command", "messaging is not available in this run");
@@ -358,6 +395,15 @@ async function answerFrame(
358
395
  }
359
396
  // Before the slot is asked: a message operation has none, and an answer owed comes first.
360
397
  const gate = messaging?.checkFinish(agentId, request.operationId, request.command);
398
+ const running = tools?.running(agentId);
399
+ if (running && gate?.kind !== "refused") {
400
+ const by =
401
+ running.settledBy === undefined ? "" : ` by ${new Date(running.settledBy).toISOString()}`;
402
+ return rejected(
403
+ "tool-running",
404
+ `your call of ${running.tool} is still running; it will have answered${by || " when it ends"}. Run this again then.`,
405
+ );
406
+ }
361
407
  if (gate?.kind === "refused") return rejected(gate.code, gate.error);
362
408
  if (gate?.kind === "awaiting" && request.command === "waiting") {
363
409
  const { peer, until, notice } = gate;
@@ -378,17 +424,22 @@ async function answerFrame(
378
424
  const result = await slots.submit({
379
425
  operationId: request.operationId,
380
426
  agentId,
381
- raw: request.raw,
427
+ ...givenOf(request),
382
428
  source: "control-plane",
383
429
  });
384
- return result.kind === "accepted"
385
- ? { version: WIRE_VERSION, kind: "accepted" }
386
- : rejected(result.code, result.error);
430
+ if (result.kind !== "accepted") return rejected(result.code, result.error);
431
+ tools?.accepted(request.operationId);
432
+ return { version: WIRE_VERSION, kind: "accepted" };
387
433
  } catch {
388
434
  return rejected("internal-error", "control request failed internally");
389
435
  }
390
436
  }
391
437
 
438
+ /** The input as the wire decoded it, without the request's other fields. */
439
+ function givenOf({ raw, args }: GivenInput): GivenInput {
440
+ return raw !== undefined ? { raw } : args !== undefined ? { args } : {};
441
+ }
442
+
392
443
  function rejected(
393
444
  code: Extract<ControlResponse, { kind: "rejected" }>["code"],
394
445
  error: string,
@@ -0,0 +1,29 @@
1
+ import type {
2
+ HolderRequest,
3
+ HolderResult,
4
+ MessagingEventBody,
5
+ OpenQuestion,
6
+ } from "./messaging/messaging";
7
+
8
+ /**
9
+ * Answers for a run's outside participants, as a holder does with `awf reply` and `awf send`. Its
10
+ * message numbers are the attempt's; `awf inbox` names them `{attempt}.{n}`.
11
+ */
12
+ export type CoordinatorHolder = {
13
+ reply(request: HolderRequest): Promise<HolderResult>;
14
+ send(request: HolderRequest): Promise<HolderResult>;
15
+ /** Questions open to outside participants now, oldest first. */
16
+ open(): OpenQuestion[];
17
+ };
18
+
19
+ /**
20
+ * What answers for a run's outside participants plugs in here: `awf run`'s prompt, the socket, or a
21
+ * test's scripts. Told each messaging event once the run's log has it, and handed the holder before
22
+ * the workflow runs. Reachable through `RunWorkflowOptions`, as `awaitReply` is, but not a
23
+ * published surface: holders use `awf inbox`, `awf reply` and `awf send`.
24
+ * @internal
25
+ */
26
+ export type CoordinatorPort = {
27
+ attach(holder: CoordinatorHolder): void;
28
+ recorded(event: MessagingEventBody): void;
29
+ };
@@ -0,0 +1,237 @@
1
+ import { createInterface, type Interface } from "node:readline/promises";
2
+ import type { CoordinatorHolder, CoordinatorPort } from "./coordinator-port";
3
+ import type { MessagingEventBody } from "./messaging/messaging";
4
+ import { shellLine } from "./run-commands";
5
+
6
+ /** Where the prompt reads a line: `awf run`'s terminal, or a test's script. */
7
+ export type LineReader = {
8
+ /** The line typed after `prompt`; undefined when `signal` aborts first, or input ended. */
9
+ read(prompt: string, signal: AbortSignal): Promise<string | undefined>;
10
+ /** Writes a line above the prompt open, if any, and draws it again under it. */
11
+ above(line: string): void;
12
+ close(): void;
13
+ };
14
+
15
+ /** What the prompt shows beside the progress view, which holds its block while a prompt is open. */
16
+ export type PromptOutput = {
17
+ /** A line in the run's log, above the progress block. */
18
+ log(line: string): void;
19
+ /** The progress block stops redrawing while a prompt waits for a line, and lines go above it. */
20
+ hold(above: ((line: string) => void) | undefined): void;
21
+ };
22
+
23
+ type Question = {
24
+ coordinator: string;
25
+ message: number;
26
+ asker: string;
27
+ text: string;
28
+ /** Asked after others, its text shown when it came in may have scrolled away: shown again. */
29
+ again: boolean;
30
+ };
31
+
32
+ /** What an agent wrote, safe on a terminal: no escape sequences or other control characters. */
33
+ function shown(text: string): string[] {
34
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what it strips.
35
+ return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "").split("\n");
36
+ }
37
+
38
+ const indented = (text: string) => shown(text).map((line) => ` ${line}`);
39
+
40
+ /**
41
+ * `awf run`'s interactive mode: each message to a coordinator is a line in the run's log, and each
42
+ * question is asked at the terminal, oldest first, for every coordinator. An empty line leaves it
43
+ * open for another holder; one another holder answers, or that closes, is passed over.
44
+ */
45
+ export function promptCoordinator(
46
+ reader: LineReader,
47
+ output: PromptOutput,
48
+ ): CoordinatorPort & { close(): void; shows(message: { from: string; to: string }): boolean } {
49
+ let holder: CoordinatorHolder | undefined;
50
+ const connected = new Set<string>();
51
+ const queue: Question[] = [];
52
+ let asking: { question: Question; stop: AbortController } | undefined;
53
+ /** Questions this prompt answered: their answers are what was typed, shown already. */
54
+ const typed = new Set<number>();
55
+ /** Questions still open, by number: an end names its asker. */
56
+ const open = new Map<number, Question>();
57
+ let closed = false;
58
+
59
+ const ask = async (question: Question): Promise<void> => {
60
+ if (question.again)
61
+ output.log([`● ${question.asker} asked:`, ...indented(question.text)].join("\n"));
62
+ const stop = new AbortController();
63
+ asking = { question, stop };
64
+ output.hold((line) => reader.above(line));
65
+ let line: string | undefined;
66
+ try {
67
+ const as = connected.size > 1 ? ` as ${question.coordinator}` : "";
68
+ line = (await reader.read(`reply to ${question.asker}${as} › `, stop.signal))?.trim();
69
+ } finally {
70
+ // Released before the reply: what it records comes as lines, not under a prompt answered.
71
+ asking = undefined;
72
+ output.hold(undefined);
73
+ }
74
+ if (!line || !holder) return;
75
+ // Before the reply: its record arrives while it is being made.
76
+ typed.add(question.message);
77
+ const result = await holder.reply({
78
+ coordinator: question.coordinator,
79
+ agent: question.asker,
80
+ to: question.message,
81
+ text: line,
82
+ via: "terminal",
83
+ });
84
+ if (result.code !== "delivered") {
85
+ typed.delete(question.message);
86
+ output.log(`awf: not delivered: ${result.error}`);
87
+ }
88
+ };
89
+
90
+ const next = async (): Promise<void> => {
91
+ while (!asking && !closed && holder) {
92
+ const question = queue.shift();
93
+ if (!question) return;
94
+ // One that fails is said and passed over; the others are still asked.
95
+ await ask(question).catch((error: unknown) =>
96
+ output.log(`awf: the prompt failed: ${error instanceof Error ? error.message : error}`),
97
+ );
98
+ }
99
+ };
100
+
101
+ const ended = (message: number) => {
102
+ const at = queue.findIndex((each) => each.message === message);
103
+ if (at >= 0) queue.splice(at, 1);
104
+ if (asking?.question.message === message) asking.stop.abort();
105
+ };
106
+
107
+ return {
108
+ attach(given) {
109
+ holder = given;
110
+ },
111
+ recorded(event: MessagingEventBody) {
112
+ if (event.kind === "participant-connected") connected.add(event.participant);
113
+ if (event.kind === "request-ended" && connected.has(event.owedBy)) {
114
+ ended(event.message);
115
+ const question = open.get(event.message);
116
+ open.delete(event.message);
117
+ if (question && event.end !== "replied")
118
+ output.log(`● ${question.asker}'s question closed unanswered: ${event.end}`);
119
+ return;
120
+ }
121
+ if (event.kind !== "message-sent") return;
122
+ // The coordinator's answers and sends that came another way: a holder's, or a script's.
123
+ if (connected.has(event.from)) {
124
+ if (event.replyTo !== undefined && typed.has(event.replyTo)) return;
125
+ const how = "via" in event && event.via ? ` (${event.via})` : "";
126
+ output.log([`● ${event.from} → ${event.to}${how}:`, ...indented(event.text)].join("\n"));
127
+ return;
128
+ }
129
+ if (!connected.has(event.to)) return;
130
+ const asks = event.awaitsReply !== undefined;
131
+ output.log(
132
+ [`● ${event.from} ${asks ? "asks" : `→ ${event.to}`}:`, ...indented(event.text)].join("\n"),
133
+ );
134
+ if (!asks) return;
135
+ const question = {
136
+ coordinator: event.to,
137
+ message: event.message,
138
+ asker: event.from,
139
+ text: event.text,
140
+ again: asking !== undefined || queue.length > 0,
141
+ };
142
+ open.set(event.message, question);
143
+ queue.push(question);
144
+ void next();
145
+ },
146
+ shows: ({ from, to }) => connected.has(from) || connected.has(to),
147
+ close() {
148
+ closed = true;
149
+ queue.length = 0;
150
+ asking?.stop.abort();
151
+ reader.close();
152
+ },
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Reads lines from this process's terminal, writing on stderr beside the progress view. It holds
158
+ * stdin only while a question is asked, so typing at any other time reaches nothing, and a run
159
+ * that asks none leaves the terminal as it found it.
160
+ */
161
+ export function terminalReader(): LineReader {
162
+ let lines: Interface | undefined;
163
+ let closed = false;
164
+ const clearLine = () => process.stderr.write("\r\x1b[2K");
165
+ return {
166
+ async read(prompt, signal) {
167
+ if (closed) return undefined;
168
+ const current = createInterface({
169
+ input: process.stdin,
170
+ output: process.stderr,
171
+ terminal: true,
172
+ });
173
+ lines = current;
174
+ // Ctrl-C at the prompt asks the run to stop, as it does anywhere else.
175
+ current.on("SIGINT", () => process.kill(process.pid, "SIGINT"));
176
+ // Ctrl-D ends the input: the question is left open, as an empty line leaves it.
177
+ const ended = new Promise<undefined>((resolve) =>
178
+ current.once("close", () => resolve(undefined)),
179
+ );
180
+ // The progress view hides the cursor and clips long lines; typing needs both back.
181
+ process.stderr.write("\x1b[?7h\x1b[?25h");
182
+ try {
183
+ return await Promise.race([current.question(prompt, { signal }), ended]);
184
+ } catch (error) {
185
+ if (signal.aborted) return undefined;
186
+ throw error;
187
+ } finally {
188
+ if (signal.aborted || closed) clearLine();
189
+ lines = undefined;
190
+ current.close();
191
+ process.stderr.write("\x1b[?25l\x1b[?7l");
192
+ }
193
+ },
194
+ above(line) {
195
+ clearLine();
196
+ process.stderr.write(`${line}\n`);
197
+ lines?.prompt(true);
198
+ },
199
+ close() {
200
+ closed = true;
201
+ if (lines) clearLine();
202
+ lines?.close();
203
+ },
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Without a prompt (`--json`, or no terminal): each question to a coordinator is a line with its
209
+ * text and the command that answers it, so a run awaiting one does not look stuck.
210
+ */
211
+ export function replyHints(
212
+ run: string,
213
+ /** What finds the run from another command, such as `--run-root`. */
214
+ place: readonly string[],
215
+ attempt: number,
216
+ log: (line: string) => void,
217
+ ): CoordinatorPort & { shows(message: { to: string; asks?: boolean }): boolean } {
218
+ const connected = new Set<string>();
219
+ return {
220
+ attach() {},
221
+ recorded(event: MessagingEventBody) {
222
+ if (event.kind === "participant-connected") connected.add(event.participant);
223
+ if (event.kind !== "message-sent" || !connected.has(event.to) || !event.awaitsReply) return;
224
+ const id = `${attempt}.${event.message}`;
225
+ const as = connected.size > 1 ? ["--as", event.to] : [];
226
+ const command = ["awf", "reply", run, event.from, "--to", id, ...as, ...place];
227
+ log(
228
+ [
229
+ `● ${event.from} asks ${event.to} (${id}):`,
230
+ ...indented(event.text),
231
+ ` answer: ${shellLine(command)} '…'`,
232
+ ].join("\n"),
233
+ );
234
+ },
235
+ shows: ({ to, asks }) => asks === true && connected.has(to),
236
+ };
237
+ }