@dna113p/machines 0.3.3 → 0.3.5

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.
package/README.md CHANGED
@@ -176,10 +176,10 @@ npm ci
176
176
  npm pack
177
177
  ```
178
178
 
179
- Then install the generated `dna113p-machines-0.3.3.tgz` into a project:
179
+ Then install the generated `dna113p-machines-0.3.5.tgz` into a project:
180
180
 
181
181
  ```bash
182
- npm install /absolute/path/to/machines/dna113p-machines-0.3.3.tgz
182
+ npm install /absolute/path/to/machines/dna113p-machines-0.3.5.tgz
183
183
  npx machine list
184
184
  ```
185
185
 
@@ -201,9 +201,11 @@ answered from outside its own terminal:
201
201
 
202
202
  ```bash
203
203
  machine runs [--json] [--all] # running and waiting runs; --all adds finished and lost ones
204
+ machine runs --for [<session>] [--project <dir>] [--json] [--all] # the runs one viewer shows
204
205
  machine respond <run-id> <request-id> <response> # answer a waiting run
205
206
  machine watch [<run-id-or-label-start> | --dir <path>] # show other runs on this session's status line
206
207
  machine unwatch <run-id-or-label-start> | --dir <path> | --all
208
+ machine ui [--port <port>] [--project <dir>] # a live page of every run, with its Machine drawn as a graph
207
209
  ```
208
210
 
209
211
  `machine runs` shows each waiting run's prompt, request id, and choices. A run
@@ -215,6 +217,14 @@ Runs hosted through `startMachineHost` publish too and are answered through thei
215
217
  host. A session shows the runs it started; `machine watch` adds runs it did not,
216
218
  such as a ticket a daemon runs. See
217
219
  [watching other runs](docs/integrations.md#watching-other-runs).
220
+ `machine runs --for` and the `@dna113p/machines/run-status` export answer which
221
+ runs one session shows, so any application can
222
+ [draw its own widgets](docs/integrations.md#drawing-your-own-widgets); `watch`
223
+ and `unwatch` take `--for <session>` first to manage that session's list.
224
+ `machine ui` serves a local page of every published run, drawing each run's
225
+ Machine with its current state and the path it took, answering waiting runs, and
226
+ showing a project's own panels; see
227
+ [watching runs in a browser](docs/integrations.md#watching-runs-in-a-browser).
218
228
 
219
229
  ## Execution model and limitations
220
230
 
@@ -4,8 +4,9 @@
4
4
  // docs/integrations.md). It runs on every status refresh, so it imports only
5
5
  // node: modules and never loads the runtime; the directory rule and the record
6
6
  // shape below mirror runStatusDirectory and RunStatusRecord in
7
- // src/run-status.ts, and the watch list and its matching mirror
8
- // src/run-watch.ts.
7
+ // src/run-status.ts, the watch list and its matching mirror src/run-watch.ts,
8
+ // and which runs a session sees, and in what order, mirrors runsForViewer in
9
+ // src/run-viewer.ts, which tests/run-viewer.test.ts checks.
9
10
 
10
11
  import { readdirSync, readFileSync, realpathSync } from "node:fs";
11
12
  import { homedir } from "node:os";
@@ -15,6 +16,7 @@ import { pathToFileURL } from "node:url";
15
16
  const terminalVisibleMilliseconds = 30_000;
16
17
  const maximumRuns = 5;
17
18
  const defaultColumns = 120;
19
+ // oxlint-disable-next-line no-control-regex -- the escape character is the point: this strips terminal styling.
18
20
  const ansiSequence = /\x1b\[[0-9;]*m/uy;
19
21
  // Combining marks, format characters such as joiners, and Hangul medial and
20
22
  // final jamo are drawn on the character before them.
@@ -195,6 +197,7 @@ function formatElapsed(startedAt, endedAt) {
195
197
 
196
198
  /** Recorded text must not be able to move the cursor or restyle the status line. */
197
199
  function plain(text) {
200
+ // oxlint-disable-next-line no-control-regex -- control characters are what a status line must not carry.
198
201
  return text.replace(/[\u0000-\u001f\u007f-\u009f]+/gu, " ").trim();
199
202
  }
200
203
 
@@ -10,7 +10,7 @@ import { isJsonValue } from "../src/json.js";
10
10
  import { MachineSession as RunSession } from "../src/session.js";
11
11
  import { listAgentPresets, listMachines } from "../src/launcher.js";
12
12
  import { watchRunInbox } from "../src/run-inbox.js";
13
- import { createRunStatusPublisher } from "../src/run-status.js";
13
+ import { createRunStatusPublisher, runOutputSummary } from "../src/run-status.js";
14
14
  import { machineCoordinatorGuidelines, machineDiscoveryDescription, machineSupervisionGuidelines } from "../src/coordinator-guidance.js";
15
15
  const widgetUri = "ui://machines/run-v1.html";
16
16
  const cwd = v.pipe(v.string(), v.nonEmpty("cwd must be a non-empty absolute path"), v.check(isAbsolute, "cwd must be an absolute path"));
@@ -219,7 +219,7 @@ export class MachineSession {
219
219
  });
220
220
  return;
221
221
  }
222
- publisher.finish(run.status === "completed" ? { state: run.state } : { error: run.error ?? "Machine run failed" });
222
+ publisher.finish(run.status === "completed" ? { state: run.state, note: runOutputSummary(run.output) } : { error: run.error ?? "Machine run failed" });
223
223
  this.#published.delete(run.id);
224
224
  }
225
225
  /** A published request can also be answered, or asked about, through the run's inbox by `machine respond`. */
package/dist/src/cli.js CHANGED
@@ -10,13 +10,19 @@ import { assertJsonValue, isJsonValue } from "./json.js";
10
10
  import { discoverMachines, machinesUserHome } from "./discovery.js";
11
11
  import { listAgentPresets, listMachines, prepareMachineRun, } from "./launcher.js";
12
12
  import { inboxHuman, respondToRun } from "./run-inbox.js";
13
- import { createRunStatusPublisher, isProcessAlive, readRunStatuses, runSessionId, runStatusDirectory, runStatusObserver, } from "./run-status.js";
13
+ import { createRunStatusPublisher, isProcessAlive, readRunStatuses, runOutputSummary, runSessionId, runStatusDirectory, runStatusObserver, } from "./run-status.js";
14
14
  import { readRunWatches, runWatch, runWatchMatches, runWatchPath, sameRunWatch, writeRunWatches, } from "./run-watch.js";
15
+ import { runsForViewer } from "./run-viewer.js";
16
+ import { startRunUi, uiProjectDir } from "./run-ui.js";
15
17
  const [command, selector, ...argumentsAfterSelector] = process.argv.slice(2);
16
18
  const runsOptions = selector === undefined ? [] : [selector, ...argumentsAfterSelector];
17
19
  const watchUsage = "machine watch [<run-id-or-label-start> | --dir <path>]";
18
20
  const unwatchUsage = "machine unwatch <run-id-or-label-start> | --dir <path> | --all";
21
+ const watchForUsage = "machine watch --for <session> [<run-id-or-label-start> | --dir <path>]";
22
+ const unwatchForUsage = "machine unwatch --for <session> <run-id-or-label-start> | --dir <path> | --all";
23
+ const uiUsage = "machine ui [--port <port>] [--project <dir>]";
19
24
  try {
25
+ const listing = command === "runs" ? parseRunsOptions(runsOptions) : undefined;
20
26
  if (command === "list" && selector === undefined) {
21
27
  const machines = await listMachines({ cwd: process.cwd() });
22
28
  if (machines.length === 0)
@@ -43,9 +49,11 @@ try {
43
49
  else if (command === "run" && selector !== undefined) {
44
50
  await runMachine(await resolveMachine(selector), argumentsAfterSelector);
45
51
  }
46
- else if (command === "runs"
47
- && runsOptions.every((option, index) => (option === "--json" || option === "--all") && runsOptions.indexOf(option) === index)) {
48
- listRuns(runsOptions.includes("--json"), runsOptions.includes("--all"));
52
+ else if (listing !== undefined) {
53
+ if (listing.viewer === undefined)
54
+ listRuns(listing.json, listing.all);
55
+ else
56
+ listViewerRuns(listing.json, listing.all, listing.viewer);
49
57
  }
50
58
  else if (command === "respond" && selector !== undefined) {
51
59
  await respond([selector, ...argumentsAfterSelector]);
@@ -56,6 +64,9 @@ try {
56
64
  else if (command === "unwatch" && selector !== undefined) {
57
65
  unwatch([selector, ...argumentsAfterSelector]);
58
66
  }
67
+ else if (command === "ui") {
68
+ await ui(selector === undefined ? [] : [selector, ...argumentsAfterSelector]);
69
+ }
59
70
  else {
60
71
  throw new Error([
61
72
  "Usage:",
@@ -64,9 +75,13 @@ try {
64
75
  " machine show <name-or-file>",
65
76
  " machine run <name-or-file> [--agent role=preset]... [--input-file <file|->] [--] [input]",
66
77
  " machine runs [--json] [--all]",
78
+ " machine runs --for [<session>] [--project <dir>] [--json] [--all]",
67
79
  " machine respond [--question] <run-id> <request-id> [--] <response>",
68
80
  ` ${watchUsage}`,
69
81
  ` ${unwatchUsage}`,
82
+ ` ${watchForUsage}`,
83
+ ` ${unwatchForUsage}`,
84
+ ` ${uiUsage}`,
70
85
  ].join("\n"));
71
86
  }
72
87
  }
@@ -96,7 +111,13 @@ async function runMachine(machinePath, args) {
96
111
  });
97
112
  const status = createRunStatus(machinePath);
98
113
  const runId = randomUUID();
99
- const published = createRunStatusPublisher({ id: runId, machine: prepared.name, path: prepared.path, cwd: process.cwd() });
114
+ const published = createRunStatusPublisher({
115
+ id: runId,
116
+ machine: prepared.name,
117
+ path: prepared.path,
118
+ cwd: process.cwd(),
119
+ ...(prepared.graph === undefined ? {} : { graph: prepared.graph }),
120
+ });
100
121
  const statusDirectory = published.directory;
101
122
  // Machines started by this run's Operations and Agents are its sub-runs.
102
123
  process.env.MACHINES_RUN_PARENT = runId;
@@ -131,7 +152,7 @@ async function runMachine(machinePath, args) {
131
152
  status.onState(state);
132
153
  },
133
154
  });
134
- published.finish({ state: result.value });
155
+ published.finish({ state: result.value, note: runOutputSummary(result.output) });
135
156
  status.succeed();
136
157
  console.log(`--> ${String(result.value)}`);
137
158
  if (result.output !== undefined) {
@@ -164,56 +185,136 @@ function listRuns(json, all) {
164
185
  printRunListing(record, index > 0, watches.some((watched) => runWatchMatches(watched, record)));
165
186
  });
166
187
  }
188
+ /** What `machine runs` was asked; undefined when the options are not ones it takes. */
189
+ function parseRunsOptions(args) {
190
+ let json = false;
191
+ let all = false;
192
+ let forViewer = false;
193
+ let session;
194
+ let project;
195
+ for (let index = 0; index < args.length; index += 1) {
196
+ const option = args[index];
197
+ const next = args[index + 1];
198
+ // What follows an option is its value unless it is another option.
199
+ const value = next === undefined || next.startsWith("--") ? undefined : next;
200
+ if (option === "--json" && !json)
201
+ json = true;
202
+ else if (option === "--all" && !all)
203
+ all = true;
204
+ else if (option === "--for" && !forViewer) {
205
+ forViewer = true;
206
+ // Without a session id the viewer is this process's own session.
207
+ session = value;
208
+ if (value !== undefined)
209
+ index += 1;
210
+ }
211
+ else if (option === "--project" && project === undefined && value !== undefined) {
212
+ project = value;
213
+ index += 1;
214
+ }
215
+ else
216
+ return undefined;
217
+ }
218
+ // A project is a viewer's: without --for the listing is of every session and project.
219
+ if (!forViewer)
220
+ return project === undefined ? { json, all } : undefined;
221
+ const viewer = { ...(session === undefined ? {} : { session }), ...(project === undefined ? {} : { project }) };
222
+ return { json, all, viewer };
223
+ }
224
+ /** The runs one viewer shows, by the rule its status line applies. */
225
+ function listViewerRuns(json, all, viewer) {
226
+ if (viewer.project === "")
227
+ throw new Error("Expected a directory after --project");
228
+ const { directory, session } = requireWatchList(viewer.session);
229
+ const watches = readRunWatches(directory, session);
230
+ // As they are read, not by start time: runs updated in the same millisecond
231
+ // keep that order, here as in the library and on the status line.
232
+ const runs = runsForViewer(readRunStatuses(directory), {
233
+ sessionId: session,
234
+ // Compared absolute, wherever the command ran and however the path was written.
235
+ ...(viewer.project === undefined ? {} : { projectDir: resolve(viewer.project) }),
236
+ watches,
237
+ }, { all });
238
+ if (json) {
239
+ console.log(JSON.stringify(runs, null, 2));
240
+ return;
241
+ }
242
+ if (runs.length === 0)
243
+ console.log(`Session ${session} shows no Machine runs.`);
244
+ runs.forEach((run, index) => {
245
+ printRunListing(run, index > 0, watches.some((watched) => runWatchMatches(watched, run)));
246
+ });
247
+ }
167
248
  /** Every record in the directory, including runs that finished or lost their process and are still recorded. */
168
249
  function publishedRuns(directory) {
169
250
  return readRunStatuses(directory).sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt));
170
251
  }
171
252
  /** A run that stopped reporting without finishing has lost its process, as on the status line. */
172
253
  function runStatus(record) {
254
+ // A viewer's run already has the status its process leaves it.
255
+ if ("shown" in record)
256
+ return record.status;
173
257
  return record.status !== "completed" && record.status !== "failed" && !isProcessAlive(record.pid) ? "lost" : record.status;
174
258
  }
175
259
  function isActiveRun(record) {
176
260
  const status = runStatus(record);
177
261
  return status === "running" || status === "waiting";
178
262
  }
179
- function watch(args) {
180
- const { directory, session } = requireWatchList();
263
+ function watch(given) {
264
+ const { named, args, usage, who } = watchSession(given, watchUsage, watchForUsage);
265
+ const { directory, session } = requireWatchList(named);
181
266
  const watches = readRunWatches(directory, session);
182
267
  const runs = publishedRuns(directory);
183
268
  if (args.length === 0) {
184
269
  if (watches.length === 0)
185
- console.log("This session watches no other runs.");
270
+ console.log(`${who} watches no other runs.`);
186
271
  for (const watched of watches) {
187
272
  console.log(describeWatch(watched));
188
273
  printWatchedRuns(watched, runs);
189
274
  }
190
275
  return;
191
276
  }
192
- const added = parseWatch(args, watchUsage);
277
+ const added = parseWatch(args, usage);
193
278
  if (!watches.some((watched) => sameRunWatch(watched, added)))
194
279
  writeRunWatches(directory, session, [...watches, added]);
195
280
  console.log(`Watching ${describeWatch(added)}`);
196
281
  printWatchedRuns(added, runs);
197
282
  }
198
- function unwatch(args) {
199
- const { directory, session } = requireWatchList();
283
+ function unwatch(given) {
284
+ const { named, args, usage, who } = watchSession(given, unwatchUsage, unwatchForUsage);
285
+ if (args.length === 0)
286
+ throw new Error(`Usage: ${usage}`);
287
+ const { directory, session } = requireWatchList(named);
200
288
  if (args[0] === "--all") {
201
289
  if (args.length !== 1)
202
- throw new Error(`Usage: ${unwatchUsage}`);
290
+ throw new Error(`Usage: ${usage}`);
203
291
  writeRunWatches(directory, session, []);
204
- console.log("This session watches no other runs.");
292
+ console.log(`${who} watches no other runs.`);
205
293
  return;
206
294
  }
207
- const removed = parseWatch(args, unwatchUsage);
295
+ const removed = parseWatch(args, usage);
208
296
  const watches = readRunWatches(directory, session);
209
297
  const kept = watches.filter((watched) => !sameRunWatch(watched, removed));
210
298
  if (kept.length === watches.length) {
211
- console.log(`This session was not watching ${describeWatch(removed)}`);
299
+ console.log(`${who} was not watching ${describeWatch(removed)}`);
212
300
  return;
213
301
  }
214
302
  writeRunWatches(directory, session, kept);
215
303
  console.log(`No longer watching ${describeWatch(removed)}`);
216
304
  }
305
+ /**
306
+ * Takes a leading `--for <session>` from what `machine watch` and
307
+ * `machine unwatch` were given: the list to change is then that session's and
308
+ * not this process's own.
309
+ */
310
+ function watchSession(given, usage, forUsage) {
311
+ if (given[0] !== "--for")
312
+ return { named: undefined, args: given, usage, who: "This session" };
313
+ const named = given[1];
314
+ if (named === undefined || named.startsWith("--"))
315
+ throw new Error(`Usage: ${forUsage}`);
316
+ return { named, args: given.slice(2), usage: forUsage, who: `Session ${named}` };
317
+ }
217
318
  function parseWatch(args, usage) {
218
319
  if (args[0] === "--dir") {
219
320
  if (args.length !== 2)
@@ -231,13 +332,17 @@ function printWatchedRuns(watched, runs) {
231
332
  const matched = runs.filter((record) => runWatchMatches(watched, record));
232
333
  if (matched.length === 0)
233
334
  console.log(" no published run matches yet");
234
- for (const record of matched)
335
+ for (const record of matched) {
235
336
  console.log(` ${runColumns(record).join("\t")}`);
337
+ const note = finishedRunNote(record);
338
+ if (note !== undefined)
339
+ console.log(` ${note}`);
340
+ }
236
341
  }
237
- /** The session whose watch list this process changes, and where the list is kept. */
238
- function requireWatchList() {
342
+ /** The session whose watch list is read or changed, this process's own unless one is named, and where the list is kept. */
343
+ function requireWatchList(named) {
239
344
  const directory = requireStatusDirectory();
240
- const session = runSessionId();
345
+ const session = named ?? runSessionId();
241
346
  if (session === undefined) {
242
347
  throw new Error("No session to watch for: set MACHINES_RUN_OWNER to the id of the session whose status line shows the runs.");
243
348
  }
@@ -262,6 +367,35 @@ async function respond(args) {
262
367
  });
263
368
  console.log(`Machine run ${runId} took the ${question ? "question" : "response"}.`);
264
369
  }
370
+ /** Serves the live page of published runs until interrupted. */
371
+ async function ui(args) {
372
+ let port;
373
+ let project;
374
+ for (let index = 0; index < args.length; index += 1) {
375
+ const argument = args[index];
376
+ const value = args[index + 1];
377
+ if (argument === "--port" && value !== undefined && /^\d+$/u.test(value) && Number(value) <= 65_535)
378
+ port = Number(value);
379
+ else if (argument === "--project" && value !== undefined)
380
+ project = resolve(value);
381
+ else
382
+ throw new Error(`Usage: ${uiUsage}`);
383
+ index += 1;
384
+ }
385
+ const page = await startRunUi({
386
+ directory: requireStatusDirectory(),
387
+ projectDir: project ?? uiProjectDir(process.cwd()),
388
+ ...(port === undefined ? {} : { port }),
389
+ });
390
+ console.log(`Machines UI: ${page.url}`);
391
+ console.log("Press Ctrl-C to stop.");
392
+ await new Promise((resolveStop) => {
393
+ const stop = () => resolveStop();
394
+ process.once("SIGINT", stop);
395
+ process.once("SIGTERM", stop);
396
+ });
397
+ await page.close();
398
+ }
265
399
  function requireStatusDirectory() {
266
400
  const directory = runStatusDirectory();
267
401
  if (directory === undefined) {
@@ -376,6 +510,7 @@ function printAgentListing(preset, separate) {
376
510
  }
377
511
  /** Recorded text must not be able to move the cursor or restyle the terminal. */
378
512
  function plain(text) {
513
+ // oxlint-disable-next-line no-control-regex -- control characters are what this removes.
379
514
  return text.replace(/[\u0000-\u001f\u007f-\u009f]+/gu, " ").trim();
380
515
  }
381
516
  /** A run as tab-separated columns: its label follows the four that were always there. */
@@ -383,6 +518,11 @@ function runColumns(record) {
383
518
  const label = record.label === undefined ? "" : plain(record.label);
384
519
  return [record.id, record.machine, runStatus(record), record.state ?? "starting"].map(plain).concat(label === "" ? [] : [label]);
385
520
  }
521
+ function finishedRunNote(record) {
522
+ if (record.status !== "completed" && record.status !== "failed")
523
+ return undefined;
524
+ return record.note?.split(/\r?\n/u).map(plain).find((line) => line !== "");
525
+ }
386
526
  function printRunListing(record, separate, watched) {
387
527
  const state = record.state ?? "starting";
388
528
  const recorded = runStatus(record);
@@ -397,6 +537,9 @@ function printRunListing(record, separate, watched) {
397
537
  ...(human.suggestions === undefined ? [] : [`suggestions: ${human.suggestions.map(plain).join(", ")}`]),
398
538
  ...(human.discussion === true ? ["discussion: questions allowed"] : []),
399
539
  ];
540
+ const note = finishedRunNote(record);
541
+ if (note !== undefined)
542
+ details.push(note);
400
543
  if (!process.stdout.isTTY) {
401
544
  const columns = runColumns(record);
402
545
  // The mark has its own column, after the label's, whether or not the run has a label.
@@ -5,6 +5,8 @@ export interface CodexAgentOptions {
5
5
  readonly model?: string;
6
6
  /** A reasoning effort supported by the selected Codex model. */
7
7
  readonly effort?: string;
8
+ /** Codex service tier, e.g. "fast" or "ultrafast"; omitted values inherit the CLI configuration. */
9
+ readonly serviceTier?: string;
8
10
  /** Defaults to read-only; writes and unrestricted access require an explicit choice. */
9
11
  readonly sandbox?: "read-only" | "workspace-write" | "danger-full-access";
10
12
  readonly skipGitRepoCheck?: boolean;
package/dist/src/codex.js CHANGED
@@ -17,6 +17,7 @@ export function codexAgent(command = "codex", args = [], options = {}) {
17
17
  const env = { ...process.env, ...options.env };
18
18
  const model = options.model ?? env.CODEX_MODEL ?? env.MACHINES_AGENT_MODEL;
19
19
  const effort = options.effort ?? env.CODEX_EFFORT ?? env.MACHINES_AGENT_EFFORT;
20
+ const serviceTier = options.serviceTier ?? env.CODEX_SERVICE_TIER;
20
21
  const outputMode = options.output ?? "stream";
21
22
  report?.({
22
23
  type: "identity",
@@ -37,6 +38,7 @@ export function codexAgent(command = "codex", args = [], options = {}) {
37
38
  ...(options.ephemeral === false ? [] : ["--ephemeral"]),
38
39
  ...(model === undefined ? [] : ["--model", model]),
39
40
  ...(effort === undefined ? [] : ["--config", `model_reasoning_effort=${JSON.stringify(effort)}`]),
41
+ ...(serviceTier === undefined ? [] : ["--config", `service_tier=${JSON.stringify(serviceTier)}`]),
40
42
  "--output-last-message", finalPath, "-",
41
43
  ], { cwd: request.cwd, env, stdio: ["pipe", "pipe", "pipe"] });
42
44
  const exited = new Promise((resolve, reject) => {
package/dist/src/host.js CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import * as v from "valibot";
6
6
  import { assertJsonValue, isJsonValue } from "./json.js";
7
7
  import { prepareMachineRun, } from "./launcher.js";
8
- import { createRunStatusPublisher, runStatusDirectory, runStatusObserver, } from "./run-status.js";
8
+ import { createRunStatusPublisher, runOutputSummary, runStatusDirectory, runStatusObserver, } from "./run-status.js";
9
9
  const childArgument = "--machines-child-host";
10
10
  const humanRequestSchema = v.strictObject({
11
11
  discussion: v.optional(v.boolean()),
@@ -74,6 +74,8 @@ const childMessageSchema = v.variant("type", [
74
74
  description: v.string(),
75
75
  path: v.string(),
76
76
  agentRoles: v.record(v.string(), v.string()),
77
+ // Checked as the record is read back; here it only has to be an object.
78
+ graph: v.optional(v.custom((value) => value !== null && typeof value === "object")),
77
79
  }),
78
80
  v.strictObject({ type: v.literal("state"), state: stateValueSchema }),
79
81
  v.strictObject({ type: v.literal("agentUpdate"), update: agentUpdateSchema }),
@@ -193,6 +195,7 @@ export function startMachineHost(options) {
193
195
  machine: message.name,
194
196
  path: message.path,
195
197
  cwd: resolvePath(cwd),
198
+ ...(message.graph === undefined ? {} : { graph: message.graph }),
196
199
  ...(options.label === undefined ? {} : { label: options.label }),
197
200
  ...(options.owner === undefined ? {} : { owner: options.owner }),
198
201
  // Responses reach this run through `respond` and `ask` below, never through an inbox.
@@ -290,7 +293,7 @@ export function startMachineHost(options) {
290
293
  }
291
294
  phase = "completed";
292
295
  pendingHuman = undefined;
293
- published?.finish({ state: message.state });
296
+ published?.finish({ state: message.state, note: runOutputSummary(message.output) });
294
297
  dispose();
295
298
  resolveResult({
296
299
  state: message.state,
@@ -401,6 +404,7 @@ function runChildHost() {
401
404
  description: prepared.description,
402
405
  path: prepared.path,
403
406
  agentRoles: prepared.agentRoles,
407
+ ...(prepared.graph === undefined ? {} : { graph: prepared.graph }),
404
408
  });
405
409
  // Machines started by this run's Operations and Agents are its sub-runs.
406
410
  if (runId !== undefined)
@@ -3,6 +3,7 @@ import { type JsonValue } from "./json.ts";
3
3
  import { type LauncherLocation, type MachineSummary, type AgentPresetSummary } from "./catalog.ts";
4
4
  export type { LauncherLocation, MachineSummary, AgentPresetSummary } from "./catalog.ts";
5
5
  import { type RunOptions } from "./index.ts";
6
+ import { type RunGraph } from "./run-graph.ts";
6
7
  export interface PrepareMachineRunOptions extends LauncherLocation {
7
8
  readonly machine: string;
8
9
  readonly input?: JsonValue;
@@ -22,6 +23,8 @@ export interface PreparedMachineRun {
22
23
  readonly description: string;
23
24
  readonly path: string;
24
25
  readonly agentRoles: Readonly<Record<string, string>>;
26
+ /** The definition's states and transitions, for run status; undefined when it is too large to draw. */
27
+ readonly graph?: RunGraph;
25
28
  start(options?: Omit<RunOptions, "agents">): Promise<SnapshotFrom<AnyStateMachine>>;
26
29
  }
27
30
  export declare function listMachines(location?: LauncherLocation): Promise<readonly MachineSummary[]>;
@@ -7,6 +7,7 @@ import { queryCatalog } from "./discovery-worker.js";
7
7
  import { loadMachineModule, readAgentRoles, requireMachineDescription } from "./machine-module.js";
8
8
  import { discoverMachines, machinesUserHome } from "./discovery.js";
9
9
  import { agent, final, human, machine, operation, requiredAgentNames, run, } from "./index.js";
10
+ import { machineGraph } from "./run-graph.js";
10
11
  export async function listMachines(location = {}) {
11
12
  return queryCatalog("machines", location);
12
13
  }
@@ -34,11 +35,13 @@ export async function prepareMachineRun(options) {
34
35
  preflightAgentRoles(path, usedAgentRoles, agentRoles, agents);
35
36
  const limitAware = limitAwareAgents(requiredRoles, overrides, configured, limitPolicy(options.agentLimits));
36
37
  let started = false;
38
+ const graph = machineGraph(definition);
37
39
  return {
38
40
  name: machineName(path),
39
41
  description,
40
42
  path,
41
43
  agentRoles,
44
+ ...(graph === undefined ? {} : { graph }),
42
45
  start(runOptions = {}) {
43
46
  if (started)
44
47
  throw new Error(`Prepared Machine "${path}" has already started`);
@@ -0,0 +1,39 @@
1
+ import type { AnyStateMachine } from "xstate";
2
+ /**
3
+ * A Machine's states and transitions as data, for a surface that draws them.
4
+ * Ids are state paths joined by dots, with the root left out: `plan`, or
5
+ * `review.round` for a state nested in `review`.
6
+ */
7
+ export interface RunGraph {
8
+ readonly initial?: string;
9
+ readonly states: readonly RunGraphState[];
10
+ readonly edges: readonly RunGraphEdge[];
11
+ }
12
+ export interface RunGraphState {
13
+ readonly id: string;
14
+ /** The compound state this one is nested in. */
15
+ readonly parent?: string;
16
+ /** What runs in the state. `group` holds nested states; `state` runs nothing itself. */
17
+ readonly kind: "agent" | "human" | "operation" | "final" | "group" | "state";
18
+ /** The Agent role an Agent state runs. */
19
+ readonly role?: string;
20
+ }
21
+ export interface RunGraphEdge {
22
+ readonly from: string;
23
+ readonly to: string;
24
+ /** The event the transition takes: an outcome, `done` when a group finishes, or `always`/`after`. */
25
+ readonly event: string;
26
+ }
27
+ /** One state the run entered, and when. */
28
+ export interface RunTrailStep {
29
+ readonly state: string;
30
+ readonly at: string;
31
+ }
32
+ /**
33
+ * Reads the graph out of a Machine definition, or returns undefined for one too
34
+ * large to draw or one whose shape this cannot read. Never throws: a picture of
35
+ * the workflow is never worth the run.
36
+ */
37
+ export declare function machineGraph(definition: AnyStateMachine): RunGraph | undefined;
38
+ /** A state value as the dotted paths of its active leaves: `{"review":"round"}` is `review.round`. */
39
+ export declare function statePaths(value: unknown): string[];
@@ -0,0 +1,91 @@
1
+ /** Past this a Machine is not drawn: its record would grow past what a reader can take in at once. */
2
+ const maxStates = 300;
3
+ const maxEdges = 1_000;
4
+ /**
5
+ * Reads the graph out of a Machine definition, or returns undefined for one too
6
+ * large to draw or one whose shape this cannot read. Never throws: a picture of
7
+ * the workflow is never worth the run.
8
+ */
9
+ export function machineGraph(definition) {
10
+ try {
11
+ const root = definition.root;
12
+ const states = [];
13
+ const edges = [];
14
+ const id = (node) => node.path.join(".");
15
+ const visit = (node, parent) => {
16
+ const using = node.meta?.["machines.agent"]?.using;
17
+ const src = node.invoke[0]?.src;
18
+ const kind = node.type === "final" ? "final"
19
+ : node.type === "compound" || node.type === "parallel" ? "group"
20
+ : typeof using === "string" ? "agent"
21
+ : src === "human" ? "human"
22
+ : src !== undefined ? "operation"
23
+ : "state";
24
+ states.push({
25
+ id: id(node),
26
+ ...(parent === undefined ? {} : { parent }),
27
+ kind,
28
+ ...(kind === "agent" ? { role: using } : {}),
29
+ });
30
+ const add = (event, targets) => {
31
+ for (const target of targets ?? []) {
32
+ // A target outside the drawn states is the root itself, which nothing draws.
33
+ if (target.path.length > 0)
34
+ edges.push({ from: id(node), to: id(target), event });
35
+ }
36
+ };
37
+ for (const [event, definitions] of node.transitions) {
38
+ // The runtime's own actor events only run actions; the outcome they raise is the transition.
39
+ // A plain XState state can target from them directly, through `onDone` and `onError`.
40
+ const label = event.startsWith("xstate.done.state.") || event.startsWith("xstate.done.actor.") ? "done"
41
+ : event.startsWith("xstate.error.actor.") ? "error"
42
+ : event.startsWith("xstate.after.") ? "after"
43
+ : event;
44
+ for (const transition of definitions)
45
+ add(label, transition.target);
46
+ }
47
+ for (const transition of node.always ?? [])
48
+ add("always", transition.target);
49
+ for (const child of Object.values(node.states))
50
+ visit(child, kind === "group" ? id(node) : parent);
51
+ };
52
+ for (const child of Object.values(root.states))
53
+ visit(child, undefined);
54
+ if (states.length === 0 || states.length > maxStates || edges.length > maxEdges)
55
+ return undefined;
56
+ const initial = root.initial?.target?.[0];
57
+ return { ...(initial === undefined ? {} : { initial: id(initial) }), states, edges: dedupe(edges) };
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ /** A state value as the dotted paths of its active leaves: `{"review":"round"}` is `review.round`. */
64
+ export function statePaths(value) {
65
+ if (typeof value === "string") {
66
+ if (!value.startsWith("{"))
67
+ return [value];
68
+ try {
69
+ return statePaths(JSON.parse(value));
70
+ }
71
+ catch {
72
+ return [value];
73
+ }
74
+ }
75
+ if (value === null || typeof value !== "object")
76
+ return [];
77
+ return Object.entries(value).flatMap(([key, child]) => {
78
+ const below = statePaths(child);
79
+ return below.length === 0 ? [key] : below.map((path) => `${key}.${path}`);
80
+ });
81
+ }
82
+ function dedupe(edges) {
83
+ const seen = new Set();
84
+ return edges.filter((edge) => {
85
+ const key = `${edge.from}\0${edge.to}\0${edge.event}`;
86
+ if (seen.has(key))
87
+ return false;
88
+ seen.add(key);
89
+ return true;
90
+ });
91
+ }