@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
@@ -23,6 +23,7 @@ import {
23
23
  readClaudeBilling,
24
24
  readCodexBilling,
25
25
  readCursorLogin,
26
+ runAttached,
26
27
  runProcess,
27
28
  withholding,
28
29
  } from "@agentswf/harness";
@@ -37,7 +38,7 @@ import { createSrtProvider, findSrt } from "@agentswf/sandbox/srt";
37
38
  import { createOpenRouterProvider } from "./decisions/openrouter";
38
39
  import type { DecisionInstallation } from "./decisions/seam";
39
40
  import { messageOf } from "./errors";
40
- import { findOrigin, namedSession, workspaceLock } from "./herdr-placement";
41
+ import { findOrigin, workspaceLock } from "./herdr-placement";
41
42
  import {
42
43
  ensureRunSession,
43
44
  parseSessions,
@@ -48,6 +49,7 @@ import { markWorkspace } from "./herdr-workspace-marks";
48
49
 
49
50
  export type { RunSession } from "./herdr-run-session";
50
51
 
52
+ import type { Attach } from "./open-command";
51
53
  import { OPERATOR_ALIASES } from "./operator-aliases";
52
54
 
53
55
  export type OperatorRuntimeInstallation = {
@@ -75,12 +77,12 @@ export type OperatorRuntimeOptions = {
75
77
  shellCwd?: string;
76
78
  /** The operator's home. */
77
79
  home?: string;
78
- /** Told once, when the first pane agent's session is ready. */
80
+ /** Told once, when the first TUI agent's session is ready. */
79
81
  onRunSession?: (session: RunSession) => void;
80
82
  };
81
83
 
82
84
  /**
83
- * One run host for every agent: a Herdr tab for a pane agent, a subprocess per turn for a
85
+ * One run host for every agent: a Herdr tab for a TUI agent, a subprocess per turn for a
84
86
  * headless one. `WITHHELD_ENVIRONMENT` is cleared from both.
85
87
  */
86
88
  export async function installOperatorRuntime(
@@ -98,7 +100,7 @@ export async function installOperatorRuntime(
98
100
  } = options;
99
101
  const unmetered = withholding(run, WITHHELD_ENVIRONMENT);
100
102
  refuseMeteredCredentials(environment);
101
- // Checked now, before any stage spends anything; made ready only for the first pane agent.
103
+ // Checked now, before any stage spends anything; made ready only for the first TUI agent.
102
104
  const name = runSessionName(environment);
103
105
  const session = runSessionOnce(() => name, { run, environment, home }, onRunSession);
104
106
  const runConfig = (session: string): HerdrConfig => ({
@@ -119,9 +121,9 @@ export async function installOperatorRuntime(
119
121
  const host = loginChecked(
120
122
  unmetered,
121
123
  createPlacementHostFactory({
122
- // The session is made ready when the first pane agent opens, so an all-headless run never
124
+ // The session is made ready when the first TUI agent opens, so an all-headless run never
123
125
  // calls Herdr.
124
- pane: {
126
+ tui: {
125
127
  accounting: createSessionAccounting(unmetered),
126
128
  openRun: async (spec) => {
127
129
  const { name } = await session();
@@ -129,7 +131,6 @@ export async function installOperatorRuntime(
129
131
  const config: HerdrConfig = {
130
132
  ...runConfig(name),
131
133
  onRunWorkspace: mark.bind,
132
- sessionFor: (named) => namedSession(named, { run, environment, home }),
133
134
  origin: () =>
134
135
  findOrigin({
135
136
  run,
@@ -424,6 +425,27 @@ const WITHHELD_ENVIRONMENT = [
424
425
  "AWF_CALLER_SESSIONS",
425
426
  ];
426
427
 
428
+ /**
429
+ * How `awf open` runs a harness on an agent's session: its fork and `-p` turn, and its TUI on this
430
+ * terminal, each withholding what no agent inherits (story 042).
431
+ */
432
+ export function openRuntime(environment: Readonly<Record<string, string | undefined>>): {
433
+ run: RunProcess;
434
+ attach: Attach;
435
+ providers: () => Promise<SandboxProviders>;
436
+ } {
437
+ const withheld = Object.fromEntries(WITHHELD_ENVIRONMENT.map((name) => [name, undefined]));
438
+ return {
439
+ run: withholding(runProcess, WITHHELD_ENVIRONMENT),
440
+ // A sandbox's command has exactly its own environment; on the host, the operator's less these.
441
+ attach: (input) =>
442
+ input.exact
443
+ ? runAttached(input, {})
444
+ : runAttached({ ...input, env: { ...input.env, ...withheld } }, environment),
445
+ providers: () => installSandboxes(environment),
446
+ };
447
+ }
448
+
427
449
  /**
428
450
  * Unset in a run's panes besides: the config the run session's server starts with, which a `herdr`
429
451
  * started in a pane would otherwise read in place of the operator's.
package/src/ops.ts ADDED
@@ -0,0 +1,270 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import {
5
+ type Candidate,
6
+ isKnownOpFile,
7
+ OP_FILE_VERSIONS,
8
+ type OpFile,
9
+ type OpRecord,
10
+ type ResultRecord,
11
+ } from "@agentswf/contract/records";
12
+ import { nameProblem } from "@agentswf/contract/workflow";
13
+ import { messageOf } from "./errors";
14
+ import { isCode, linkNew, writeWholeSync } from "./files";
15
+ import { appendLine, readLines } from "./jsonl";
16
+
17
+ /**
18
+ * A run's `ops/`: one folder per operation, `ops/{key}.{n}/`, the `n`th turn, message operation or
19
+ * compaction of the agent keyed `{key}`, or the `n`th asking of the decision keyed `{key}`. It holds
20
+ * `op.json`, `answers.jsonl`, `result.json`, `decision.json`, `spend.json`, `live.jsonl` and
21
+ * `screen.txt`. The only module that knows where they are.
22
+ */
23
+
24
+ /** An operation's folder. */
25
+ function opDir(runDir: string, op: string): string {
26
+ return join(runDir, "ops", op);
27
+ }
28
+
29
+ /** What the operation is: `OpRecord`. */
30
+ export function opFile(runDir: string, op: string): string {
31
+ return join(opDir(runDir, op), "op.json");
32
+ }
33
+
34
+ /** Its settled usage: billing, spend per model, reported cost. */
35
+ export function spendFile(runDir: string, op: string): string {
36
+ return join(opDir(runDir, op), "spend.json");
37
+ }
38
+
39
+ /** Its delivery and nudge diagnostics, `OperationLivenessRecord` lines. */
40
+ export function liveFile(runDir: string, op: string): string {
41
+ return join(opDir(runDir, op), "live.jsonl");
42
+ }
43
+
44
+ /** A decision's `DecisionArtifact`, relative to the run's folder, as `DecisionRecord.artifact` names it. */
45
+ export function decisionArtifact(op: string): string {
46
+ return join("ops", op, "decision.json");
47
+ }
48
+
49
+ /**
50
+ * The keys earlier attempts numbered operations under: each one's last `n`, and whose it is when its
51
+ * `op.json` says.
52
+ */
53
+ export type EarlierOps = ReadonlyMap<string, { last: number; of?: "agent" | "decision" }>;
54
+
55
+ /**
56
+ * Lists `ops/` once, as an attempt claims its run: `n` is the digits after a folder's last `.`.
57
+ * Whose a key is is read from its highest-numbered `op.json` that can be read.
58
+ */
59
+ export async function listOps(runDir: string): Promise<EarlierOps> {
60
+ let names: string[];
61
+ try {
62
+ names = await readdir(join(runDir, "ops"));
63
+ } catch (error) {
64
+ if (isCode(error, "ENOENT")) return new Map();
65
+ throw error;
66
+ }
67
+ const numbers = new Map<string, number[]>();
68
+ for (const name of names) {
69
+ const match = /^(.+)\.(\d+)$/.exec(name);
70
+ if (!match) continue;
71
+ const list = numbers.get(match[1]!) ?? [];
72
+ list.push(Number(match[2]));
73
+ numbers.set(match[1]!, list);
74
+ }
75
+ const earlier = new Map<string, { last: number; of?: "agent" | "decision" }>();
76
+ await Promise.all(
77
+ [...numbers].map(async ([key, list]) => {
78
+ const descending = list.sort((a, b) => b - a);
79
+ let kind: OpRecord["kind"] | undefined;
80
+ for (const n of descending) {
81
+ kind = (await readOpFile<OpRecord>(opFile(runDir, `${key}.${n}`), "op"))?.kind;
82
+ if (kind !== undefined) break;
83
+ }
84
+ earlier.set(key, {
85
+ last: descending[0]!,
86
+ ...(kind === undefined ? {} : { of: kind === "decision" ? "decision" : "agent" }),
87
+ });
88
+ }),
89
+ );
90
+ return earlier;
91
+ }
92
+
93
+ /**
94
+ * Why `key` can't be an agent's or a decision's: it names folders, so it is a name, as an event's
95
+ * or stage's is, and has no colon, which awf's own event names, `{key}:ended`, split at.
96
+ */
97
+ function keyProblem(key: string): string | undefined {
98
+ if (key.includes(":"))
99
+ return `a colon is kept for awf's own event names, such as {key}:ended; take another key, such as ${key.replaceAll(":", "-")}`;
100
+ return nameProblem(key);
101
+ }
102
+
103
+ /**
104
+ * An attempt's operation ids, `{key}.{n}`, numbered on from the last `n` its claim listed, so no
105
+ * id is reused across attempts. Agents and decisions share the keys: `{key}.{n}` is one operation.
106
+ */
107
+ export class OpIds {
108
+ readonly #last = new Map<string, number>();
109
+ readonly #agents = new Set<string>();
110
+ readonly #decisions = new Set<string>();
111
+
112
+ constructor(
113
+ readonly runDir: string,
114
+ readonly attempt: number,
115
+ earlier: EarlierOps,
116
+ ) {
117
+ for (const [key, { last, of }] of earlier) {
118
+ this.#last.set(key, last);
119
+ if (of) (of === "decision" ? this.#decisions : this.#agents).add(key);
120
+ }
121
+ }
122
+
123
+ /** Refuses a key that isn't a name, or that the other kind already holds in the run. */
124
+ claim(key: string, as: "agent" | "decision"): void {
125
+ const problem = keyProblem(key);
126
+ if (problem) throw new Error(`${as} key ${key} is not a name: ${problem}`);
127
+ const [mine, other] =
128
+ as === "agent" ? [this.#agents, this.#decisions] : [this.#decisions, this.#agents];
129
+ if (other.has(key))
130
+ throw new Error(
131
+ `${key} is ${as === "agent" ? "a decision's" : "an agent's"} key: agents and decisions share one set of names, as each operation's folder is ops/{key}.{n}; ${as === "agent" ? "an agent" : "a decision"} takes another key`,
132
+ );
133
+ mine.add(key);
134
+ }
135
+
136
+ /**
137
+ * The key's next operation id, its folder and `op.json` written at once, before any line names it,
138
+ * so a later claim numbers past it however the operation ends. Synchronous, so nothing waits
139
+ * between an operation's start and its cancellation being registered. `lost` says why `op.json`
140
+ * wasn't written, which fails the operation.
141
+ */
142
+ draw(key: string, what: OpStart): { id: string; lost?: string } {
143
+ const n = (this.#last.get(key) ?? 0) + 1;
144
+ this.#last.set(key, n);
145
+ const id = `${key}.${n}`;
146
+ const record = {
147
+ version: OP_FILE_VERSIONS.op,
148
+ id,
149
+ attempt: this.attempt,
150
+ at: new Date().toISOString(),
151
+ ...what,
152
+ } as OpRecord;
153
+ try {
154
+ mkdirSync(opDir(this.runDir, id), { recursive: true });
155
+ writeWholeSync(opFile(this.runDir, id), `${JSON.stringify(record, null, 2)}\n`);
156
+ return { id };
157
+ } catch (error) {
158
+ return { id, lost: `op.json not written: ${messageOf(error)}` };
159
+ }
160
+ }
161
+ }
162
+
163
+ /** What an operation is, as it is drawn: its `op.json` but its version, id, attempt and time. */
164
+ type OpStart = OpRecord extends infer Each
165
+ ? Each extends OpRecord
166
+ ? Omit<Each, "version" | "id" | "attempt" | "at">
167
+ : never
168
+ : never;
169
+
170
+ /** Appends an answer offered, accepted or not, to its operation's `answers.jsonl`. */
171
+ export async function recordAnswer(runDir: string, op: string, answer: Candidate): Promise<void> {
172
+ await mkdir(opDir(runDir, op), { recursive: true });
173
+ await appendLine(answersFile(runDir, op), JSON.stringify(answer));
174
+ }
175
+
176
+ /** An operation's answers, as tests check them; a run never reads them back. */
177
+ export async function readAnswers(runDir: string, op: string): Promise<Candidate[]> {
178
+ return readLines<Candidate>(answersFile(runDir, op));
179
+ }
180
+
181
+ /** Claims an operation's one accepted result with `value`; false when another holds it. */
182
+ export async function writeAcceptedExclusive(
183
+ runDir: string,
184
+ op: string,
185
+ value: unknown,
186
+ ): Promise<boolean> {
187
+ await mkdir(opDir(runDir, op), { recursive: true });
188
+ const record = {
189
+ version: OP_FILE_VERSIONS.result,
190
+ value,
191
+ at: new Date().toISOString(),
192
+ } as ResultRecord;
193
+ return linkNew(join(opDir(runDir, op), "result.json"), record);
194
+ }
195
+
196
+ /**
197
+ * An operation's accepted result, as tests check it; a run never reads it back. Null
198
+ * distinguishes "no value yet" from an operation whose accepted value happens to be null. A
199
+ * result.json of another version throws: unlike a cost, an answer has no unknown to fall back on.
200
+ */
201
+ export async function readAccepted(runDir: string, op: string): Promise<{ value: unknown } | null> {
202
+ let text: string;
203
+ try {
204
+ text = await readFile(join(opDir(runDir, op), "result.json"), "utf8");
205
+ } catch (error) {
206
+ if (isCode(error, "ENOENT")) return null;
207
+ throw error;
208
+ }
209
+ const record: unknown = JSON.parse(text);
210
+ if (!isKnownOpFile("result", record))
211
+ throw new Error(`${op}'s result.json is not version ${OP_FILE_VERSIONS.result}`);
212
+ return { value: (record as ResultRecord).value };
213
+ }
214
+
215
+ /**
216
+ * An operation's JSON `file` at `path`, or undefined when it is missing, can't be parsed, or has no
217
+ * version or one this reader doesn't know: each is read as a file that isn't there. With `refuse`,
218
+ * a file that parses at another version throws instead, for a reader whose figures it would
219
+ * otherwise leave looking complete.
220
+ */
221
+ export async function readOpFile<T>(
222
+ path: string,
223
+ file: OpFile,
224
+ options: { refuse?: boolean } = {},
225
+ ): Promise<T | undefined> {
226
+ let value: unknown;
227
+ try {
228
+ value = JSON.parse(await readFile(path, "utf8"));
229
+ } catch {
230
+ return undefined;
231
+ }
232
+ if (isKnownOpFile(file, value)) return value as T;
233
+ if (options.refuse && typeof value === "object" && value !== null) {
234
+ const version = (value as { version?: unknown }).version;
235
+ throw new Error(
236
+ `${path} is ${version === undefined ? "of no version" : `version ${JSON.stringify(version)}`}; this awf reads version ${OP_FILE_VERSIONS[file]}`,
237
+ );
238
+ }
239
+ return undefined;
240
+ }
241
+
242
+ /** The most of a screen kept: its last lines, where a pane's scrollback came with it. */
243
+ const SCREEN_BYTES = 64 * 1024;
244
+
245
+ /** An operation's `screen.txt`: what its pane showed as it ended without an answer. */
246
+ export function screenFile(runDir: string, op: string): string {
247
+ return join(opDir(runDir, op), "screen.txt");
248
+ }
249
+
250
+ export async function writeScreen(runDir: string, op: string, screen: string): Promise<void> {
251
+ const bytes = Buffer.from(screen);
252
+ const kept = bytes.length <= SCREEN_BYTES ? screen : tailOf(bytes);
253
+ await mkdir(opDir(runDir, op), { recursive: true });
254
+ await writeFile(screenFile(runDir, op), kept, { mode: 0o600 });
255
+ }
256
+
257
+ /** Its last whole lines within the bound, or its last bytes where one line is longer. */
258
+ function tailOf(bytes: Buffer): string {
259
+ const tail = bytes.subarray(bytes.length - SCREEN_BYTES);
260
+ const line = tail.indexOf(10);
261
+ const whole = line === -1 || line === tail.length - 1 ? tail : tail.subarray(line + 1);
262
+ // A cut inside a character drops its continuation bytes.
263
+ let start = 0;
264
+ while (start < whole.length && (whole[start]! & 0xc0) === 0x80) start += 1;
265
+ return whole.subarray(start).toString();
266
+ }
267
+
268
+ function answersFile(runDir: string, op: string): string {
269
+ return join(opDir(runDir, op), "answers.jsonl");
270
+ }
@@ -1,58 +1,69 @@
1
- import type { KeepPane, PaneLayout, TurnOutcome } from "@agentswf/contract/workflow";
1
+ import type { RunPanes } from "@agentswf/contract/records";
2
+ import type {
3
+ KeepPane,
4
+ PaneLayout,
5
+ PaneSpec,
6
+ PaneWorkspace,
7
+ TurnOutcome,
8
+ } from "@agentswf/contract/workflow";
2
9
 
3
10
  /** A terminal session's name: Herdr makes `sessions/{name}` from it, and a leading `-` reads as an option. */
4
11
  export const SESSION_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
5
12
 
6
13
  const SHARE = { min: 0.2, max: 0.8 };
7
- const NEW_TAB_FIELDS = new Set(["session", "workspace", "tab"]);
8
14
  const BESIDE_FIELDS = new Set(["beside", "side", "share"]);
9
- const KEEP: readonly KeepPane[] = ["never", "on-failure", "always"];
15
+ export const KEEP_PANES: readonly KeepPane[] = ["never", "on-failure", "always"];
10
16
 
11
17
  /**
12
- * Refuses, before anything is placed, what the agent's own spec fixes about its pane: what a
13
- * workflow, untyped at run time, could get wrong in `layout` or `keepPane`, and either on an agent
14
- * with no pane of awf's to place (docs/design/pane-layout.md, "Refused at open").
18
+ * Refuses, before anything is placed, what a workflow, untyped at run time, could get wrong in an
19
+ * agent's `pane`, and a `pane` on an agent with no pane of awf's to place
20
+ * (docs/design/pane-layout.md, "Refused at open").
15
21
  */
16
22
  export function checkPaneOptions(
17
23
  key: string,
18
- spec: { layout?: unknown; keepPane?: unknown },
24
+ spec: { pane?: unknown },
19
25
  agent: { headless: boolean; sandboxed: boolean },
20
26
  ): void {
21
27
  const refuse = (why: string): never => {
22
28
  throw new Error(`agent ${key}: ${why}`);
23
29
  };
24
- if (spec.layout === undefined && spec.keepPane === undefined) return;
25
- if (agent.headless) refuse("layout and keepPane place a pane, and a headless agent has none");
26
- if (agent.sandboxed) {
30
+ // Removed in 0.1.0-beta.6; refused, as they would place or keep a pane otherwise than written.
31
+ const given = spec as { layout?: unknown; keepPane?: unknown };
32
+ if (given.layout !== undefined)
27
33
  refuse(
28
- "layout and keepPane are not yet for an agent in a sandbox, whose pane is in its own Herdr",
34
+ "layout was removed: give pane: { tab } or pane: { beside, side }; the workspace is awf run --workspace",
29
35
  );
36
+ if (given.keepPane !== undefined)
37
+ refuse("keepPane was removed: keeping panes is the operator's, awf run --keep-panes");
38
+ const { pane } = spec;
39
+ if (pane === undefined) return;
40
+ if (agent.headless) refuse("pane places a pane, and a headless agent has none");
41
+ if (agent.sandboxed) {
42
+ refuse("pane is not yet for an agent in a sandbox, whose pane is in its own Herdr");
30
43
  }
31
- if (spec.keepPane !== undefined && !KEEP.includes(spec.keepPane as KeepPane)) {
32
- refuse(`keepPane is never, on-failure or always, not ${JSON.stringify(spec.keepPane)}`);
33
- }
34
- if (spec.layout === undefined) return;
35
- const layout = spec.layout;
36
- if (typeof layout !== "object" || layout === null || Array.isArray(layout)) {
37
- refuse(`a layout is an object, not ${JSON.stringify(layout)}`);
44
+ if (typeof pane !== "object" || pane === null || Array.isArray(pane)) {
45
+ refuse(`a pane is an object, not ${JSON.stringify(pane)}`);
38
46
  }
39
- const fields = Object.keys(layout as object).filter(
40
- (field) => (layout as Record<string, unknown>)[field] !== undefined,
47
+ const shape = pane as Record<string, unknown>;
48
+ const fields = Object.keys(shape).filter(
49
+ (field) => shape[field] !== undefined && field !== "front",
41
50
  );
42
- const given = layout as Record<string, unknown>;
43
- if (given.beside !== undefined) {
51
+ if (shape.front !== undefined && typeof shape.front !== "boolean") {
52
+ refuse(`front is true or false, not ${JSON.stringify(shape.front)}`);
53
+ }
54
+ if (shape.beside !== undefined) {
44
55
  const others = fields.filter((field) => !BESIDE_FIELDS.has(field));
45
56
  if (others.length > 0) {
46
- refuse(`a layout beside another pane takes side and share, not ${others.join(", ")}`);
57
+ refuse(`a pane beside another pane takes side and share, not ${others.join(", ")}`);
47
58
  }
48
- if (typeof given.beside !== "string" || given.beside === "") {
49
- refuse(`beside names an agent's key, not ${JSON.stringify(given.beside)}`);
59
+ if (typeof shape.beside !== "string" || shape.beside === "") {
60
+ refuse(`beside names an agent's key, not ${JSON.stringify(shape.beside)}`);
50
61
  }
51
- if (given.beside === key) refuse("a pane cannot be placed beside itself");
52
- if (given.side !== "right" && given.side !== "below") {
53
- refuse(`side is right or below, not ${JSON.stringify(given.side)}`);
62
+ if (shape.beside === key) refuse("a pane cannot be placed beside itself");
63
+ if (shape.side !== "right" && shape.side !== "below") {
64
+ refuse(`side is right or below, not ${JSON.stringify(shape.side)}`);
54
65
  }
55
- const share = given.share;
66
+ const share = shape.share;
56
67
  if (
57
68
  share !== undefined &&
58
69
  (typeof share !== "number" || !(share >= SHARE.min && share <= SHARE.max))
@@ -61,46 +72,29 @@ export function checkPaneOptions(
61
72
  }
62
73
  return;
63
74
  }
64
- const others = fields.filter((field) => !NEW_TAB_FIELDS.has(field));
75
+ const others = fields.filter((field) => field !== "tab");
65
76
  if (others.length > 0) {
66
77
  refuse(
67
- `a layout takes session, workspace and tab, or beside and side; not ${others.join(", ")}`,
68
- );
69
- }
70
- const { session, workspace, tab } = given;
71
- if (session !== undefined && (typeof session !== "string" || !SESSION_NAME.test(session))) {
72
- refuse(
73
- `session ${JSON.stringify(session)} is not a session name: lowercase letters, digits and -, up to 32, not starting with -`,
78
+ `a pane takes tab, or beside and side, and front; not ${others.join(", ")}${others.some((field) => field === "workspace" || field === "session") ? ": the workspace is the operator's, awf run --workspace" : ""}`,
74
79
  );
75
80
  }
76
- if (session !== undefined && workspace === "origin") {
77
- refuse('"origin" is a workspace in its own session, so it takes no session');
78
- }
79
- if (
80
- workspace !== undefined &&
81
- workspace !== "run" &&
82
- workspace !== "origin" &&
83
- !(
84
- typeof workspace === "object" &&
85
- workspace !== null &&
86
- !Array.isArray(workspace) &&
87
- Object.keys(workspace).join() === "name" &&
88
- nonEmpty((workspace as { name?: unknown }).name)
89
- )
90
- ) {
91
- refuse(`workspace is "run", "origin" or { name }, not ${JSON.stringify(workspace)}`);
81
+ if (shape.tab !== undefined && !nonEmpty(shape.tab)) {
82
+ refuse(`tab is a label, not ${JSON.stringify(shape.tab)}`);
92
83
  }
93
- if (tab !== undefined && !nonEmpty(tab)) refuse(`tab is a label, not ${JSON.stringify(tab)}`);
94
84
  }
95
85
 
96
86
  /**
97
- * A checked layout as it is kept and compared: fields given as `undefined` dropped, so a reopen
98
- * that leaves one out matches.
87
+ * A checked `pane` as it is kept and compared: fields given as `undefined` dropped, and
88
+ * `front: false`, which is its absence, so a reopen that leaves one out matches.
99
89
  */
100
- export function storedLayout(layout: PaneLayout): PaneLayout {
90
+ export function storedPane(pane: PaneSpec): PaneSpec {
91
+ // Not an object, it is left for `checkPaneOptions` to refuse by name.
92
+ if (typeof pane !== "object" || pane === null || Array.isArray(pane)) return pane;
101
93
  return Object.fromEntries(
102
- Object.entries(structuredClone(layout)).filter(([, value]) => value !== undefined),
103
- ) as PaneLayout;
94
+ Object.entries(structuredClone(pane)).filter(
95
+ ([field, value]) => value !== undefined && !(field === "front" && value === false),
96
+ ),
97
+ ) as PaneSpec;
104
98
  }
105
99
 
106
100
  function nonEmpty(value: unknown): value is string {
@@ -112,6 +106,52 @@ export function besideOf(layout: PaneLayout | undefined): string | undefined {
112
106
  return layout && "beside" in layout ? layout.beside : undefined;
113
107
  }
114
108
 
109
+ /** What the host is asked for one agent's pane. */
110
+ export type PaneAsked = {
111
+ layout?: PaneLayout;
112
+ front?: true;
113
+ keepPane?: KeepPane;
114
+ /** For a pane beside another: where its own tab opens if its target has no pane. */
115
+ fallbackWorkspace?: PaneWorkspace;
116
+ };
117
+
118
+ /**
119
+ * What the host is asked for an agent's pane: the workflow's tab or neighbour and `front`, in the
120
+ * operator's workspace and kept as the operator says. An agent with no pane of awf's, headless or
121
+ * in a sandbox, is asked nothing.
122
+ */
123
+ export function paneAsked(
124
+ pane: PaneSpec | undefined,
125
+ run: RunPanes | undefined,
126
+ hasPane: boolean,
127
+ ): PaneAsked {
128
+ if (!hasPane) return {};
129
+ const keepPane = run?.keep;
130
+ const workspace = run?.workspace;
131
+ const layout = placedLayout(pane, workspace);
132
+ return {
133
+ ...(layout === undefined ? {} : { layout }),
134
+ ...(pane?.front ? { front: true as const } : {}),
135
+ ...(keepPane === undefined ? {} : { keepPane }),
136
+ ...(besideOf(layout) !== undefined && workspace !== undefined
137
+ ? { fallbackWorkspace: workspace }
138
+ : {}),
139
+ };
140
+ }
141
+
142
+ function placedLayout(
143
+ pane: PaneSpec | undefined,
144
+ workspace: PaneWorkspace | undefined,
145
+ ): PaneLayout | undefined {
146
+ if (pane?.beside !== undefined) {
147
+ const { beside, side, share } = pane;
148
+ return { beside, side, ...(share === undefined ? {} : { share }) };
149
+ }
150
+ const tab = pane?.tab;
151
+ if (workspace !== undefined) return { workspace, ...(tab === undefined ? {} : { tab }) };
152
+ return tab === undefined ? undefined : { tab };
153
+ }
154
+
115
155
  /**
116
156
  * Whether a done agent's pane stays, by `keepPane` and how its last operation ended: `on-failure`
117
157
  * keeps it unless that was answered, or there was none.
@@ -123,3 +163,9 @@ export function keepsPane(
123
163
  if (keepPane === "always") return true;
124
164
  return keepPane === "on-failure" && last !== undefined && last !== "answered";
125
165
  }
166
+
167
+ /** `--workspace`'s value: the two keywords, else a workspace's label. */
168
+ export function parseWorkspace(value: string): PaneWorkspace {
169
+ if (value === "run" || value === "origin") return value;
170
+ return { name: value };
171
+ }
@@ -40,7 +40,7 @@ export function renderProgress(
40
40
  paint: Paint;
41
41
  /** Once the run is over: what each stage's agents cost, by stage. */
42
42
  figures?: ReadonlyMap<string, string>;
43
- /** The Herdr session pane agents opened in, once one has. */
43
+ /** The Herdr session TUI agents opened in, once one has. */
44
44
  herdrSession?: string;
45
45
  },
46
46
  ): string[] {
@@ -168,7 +168,7 @@ export function progressEvents(
168
168
  turn.phase === "waiting"
169
169
  ? `waiting (agent-reported): ${oneLine(turn.waitingReason ?? "")}${turn.checkInAt === undefined ? "" : ` · check-in at ${clock(turn.checkInAt - view.startedAt)}`}`
170
170
  : turn.phase === "awaiting-reply"
171
- ? `awaiting ${oneLine(turn.waitingReason ?? "a reply")}${turn.checkInAt === undefined ? "" : ` · until ${clock(turn.checkInAt - view.startedAt)}`}`
171
+ ? `awaiting-reply${turn.waitingReason === undefined ? "" : ` from ${oneLine(turn.waitingReason)}`}${turn.checkInAt === undefined ? "" : ` · until ${clock(turn.checkInAt - view.startedAt)}`}`
172
172
  : oneLine(turn.phase.replaceAll("-", " "));
173
173
  add(view.now, "agent phase", `… ${oneLine(agent.key)} · ${note}`);
174
174
  }
@@ -316,7 +316,7 @@ const GAP = " ";
316
316
 
317
317
  const HOUR_MS = 60 * 60_000;
318
318
 
319
- /** The attempt that recorded a reused stage, and its record's age once that may make it stale. */
319
+ /** The attempt that ran a reused stage, and its line's age once that may make it stale. */
320
320
  function reusedTail(stage: StageProgress, now: number): string {
321
321
  const age = stage.recordedAt === undefined ? 0 : now - stage.recordedAt;
322
322
  return [
@@ -400,11 +400,11 @@ function agentState(
400
400
  function awaitingNote(turn: NonNullable<Agent["turn"]>, now: number): string {
401
401
  const since = turn.phaseAt ?? turn.startedAt;
402
402
  const of = turn.checkInAt === undefined ? "" : ` of ${duration(turn.checkInAt - since)}`;
403
- return `awaiting ${oneLine(turn.waitingReason ?? "a reply")} · ${duration(Math.max(0, now - since))}${of}`;
403
+ return `awaiting-reply${turn.waitingReason === undefined ? "" : ` from ${oneLine(turn.waitingReason)}`} · ${duration(Math.max(0, now - since))}${of}`;
404
404
  }
405
405
 
406
406
  /** A message admitted: who to whom, its size, and whether it asks or answers. */
407
- export function messageLine(message: WorkflowRunSnapshot["messages"][number]): string {
407
+ function messageLine(message: WorkflowRunSnapshot["messages"][number]): string {
408
408
  const size =
409
409
  message.bytes < 1024 ? `${message.bytes} B` : `${(message.bytes / 1024).toFixed(1)} KB`;
410
410
  const kind = [message.answers ? "reply" : "", message.asks ? "reply owed" : ""].filter(Boolean);
@@ -412,20 +412,20 @@ export function messageLine(message: WorkflowRunSnapshot["messages"][number]): s
412
412
  }
413
413
 
414
414
  /**
415
- * Where to see a pane agent Herdr read as blocked on a prompt: its session plays no sound, so this
415
+ * Where to see a TUI agent Herdr read as blocked on a prompt: its session plays no sound, so this
416
416
  * is the one sign. Not the calling session's, which is the operator's own pane.
417
417
  */
418
418
  function whereBlocked(agent: Agent, herdrSession: string | undefined): string {
419
419
  return agent.turn?.outcome === "blocked" &&
420
420
  herdrSession !== undefined &&
421
- placementOf(agent.execution) === "pane" &&
421
+ placementOf(agent.execution) === "tui" &&
422
422
  !agent.execution.caller
423
423
  ? ` · herdr session attach ${herdrSession}`
424
424
  : "";
425
425
  }
426
426
 
427
427
  /**
428
- * Where a run's pane agents are, said once as the first opens; the workspace closes with the run.
428
+ * Where a run's TUI agents are, said once as the first opens; the workspace closes with the run.
429
429
  * A client inside Herdr can't attach to another session, so from there it says where to.
430
430
  */
431
431
  export function describeRunSession(
@@ -519,7 +519,7 @@ export function watchProgress(
519
519
  let handle: WorkflowRunHandle<JsonValue> | undefined;
520
520
  let last: WorkflowRunSnapshot | undefined;
521
521
  let herdrSession: string | undefined;
522
- // A pane agent's session can be ready after the run has ended, as a cancel lands while it starts:
522
+ // A TUI agent's session can be ready after the run has ended, as a cancel lands while it starts:
523
523
  // nothing is said under the closing block.
524
524
  let stopped = false;
525
525
  let drawn = 0;
@@ -594,7 +594,7 @@ export function watchProgress(
594
594
  if (under) clear();
595
595
  else tick();
596
596
  },
597
- /** The Herdr session pane agents opened in, which a blocked one's line names. */
597
+ /** The Herdr session TUI agents opened in, which a blocked one's line names. */
598
598
  placedIn(session: string) {
599
599
  if (!stopped) herdrSession = session;
600
600
  },