@dna113p/machines 0.3.2 → 0.3.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.
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.2.tgz` into a project:
179
+ Then install the generated `dna113p-machines-0.3.4.tgz` into a project:
180
180
 
181
181
  ```bash
182
- npm install /absolute/path/to/machines/dna113p-machines-0.3.2.tgz
182
+ npm install /absolute/path/to/machines/dna113p-machines-0.3.4.tgz
183
183
  npx machine list
184
184
  ```
185
185
 
@@ -193,12 +193,15 @@ installation script is required for the direct MCP setup.
193
193
  Hosts that only run a status-line command, such as Claude Code, can show running
194
194
  Machines through [run status publication](docs/integrations.md#run-status-for-host-status-lines).
195
195
 
196
- Publication is opt-in: set `MACHINES_RUN_STATUS_DIR` to a directory only you can
197
- read. A published run that is waiting for a Human can then be answered from
198
- outside its own terminal:
196
+ Publication is on by default, in a private per-user directory
197
+ (`~/.local/state/machines/runs`, or under `XDG_STATE_HOME`), so nothing has to be
198
+ configured. Set `MACHINES_RUN_STATUS_DIR` to `off` to turn it off, or to a
199
+ directory to move it. A published run that is waiting for a Human can be
200
+ answered from outside its own terminal:
199
201
 
200
202
  ```bash
201
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
202
205
  machine respond <run-id> <request-id> <response> # answer a waiting run
203
206
  machine watch [<run-id-or-label-start> | --dir <path>] # show other runs on this session's status line
204
207
  machine unwatch <run-id-or-label-start> | --dir <path> | --all
@@ -213,6 +216,10 @@ Runs hosted through `startMachineHost` publish too and are answered through thei
213
216
  host. A session shows the runs it started; `machine watch` adds runs it did not,
214
217
  such as a ticket a daemon runs. See
215
218
  [watching other runs](docs/integrations.md#watching-other-runs).
219
+ `machine runs --for` and the `@dna113p/machines/run-status` export answer which
220
+ runs one session shows, so any application can
221
+ [draw its own widgets](docs/integrations.md#drawing-your-own-widgets); `watch`
222
+ and `unwatch` take `--for <session>` first to manage that session's list.
216
223
 
217
224
  ## Execution model and limitations
218
225
 
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // Claude Code status line for Machine runs published through
4
- // MACHINES_RUN_STATUS_DIR (see docs/integrations.md). It runs on every status
5
- // refresh, so it imports only node: modules and never loads the runtime; the
6
- // record shape below mirrors RunStatusRecord in src/run-status.ts, and the
7
- // watch list and its matching mirror src/run-watch.ts.
3
+ // Claude Code status line for published Machine runs (see
4
+ // docs/integrations.md). It runs on every status refresh, so it imports only
5
+ // node: modules and never loads the runtime; the directory rule and the record
6
+ // shape below mirror runStatusDirectory and RunStatusRecord in
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.
8
10
 
9
11
  import { readdirSync, readFileSync, realpathSync } from "node:fs";
12
+ import { homedir } from "node:os";
10
13
  import { isAbsolute, join, resolve, sep } from "node:path";
11
14
  import { pathToFileURL } from "node:url";
12
15
 
@@ -20,6 +23,29 @@ const zeroWidth = /[\p{Mn}\p{Me}\p{Cf}\u1160-\u11ff]/u;
20
23
  // East Asian Wide and Fullwidth characters, and emoji drawn as pictures, take two columns.
21
24
  const wide = /[\u1100-\u115f\u2e80-\u303e\u3041-\ua4cf\ua960-\ua97f\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6\u{20000}-\u{3fffd}\p{Emoji_Presentation}]/u;
22
25
 
26
+ /**
27
+ * The directory runs publish to, or undefined while publication is off: the
28
+ * same answer as runStatusDirectory in src/run-status.ts for the same
29
+ * environment, which tests/run-status.test.ts checks.
30
+ */
31
+ export function runStatusDirectory(environment = process.env) {
32
+ const configured = environment.MACHINES_RUN_STATUS_DIR;
33
+ if (configured === "" || configured === "off") return undefined;
34
+ if (configured !== undefined) return resolve(configured);
35
+ // XDG_STATE_HOME describes the real home. A process given another Machines
36
+ // home keeps its runs under that one, and out of the real one.
37
+ const state = environment.XDG_STATE_HOME;
38
+ const machinesHome = environment.MACHINES_USER_HOME;
39
+ if (machinesHome === undefined && state !== undefined && isAbsolute(state)) return join(state, "machines", "runs");
40
+ try {
41
+ const home = machinesHome ?? homedir();
42
+ return isAbsolute(home) ? join(home, ".local", "state", "machines", "runs") : undefined;
43
+ } catch {
44
+ // Without a usable home there is nowhere standard to publish.
45
+ return undefined;
46
+ }
47
+ }
48
+
23
49
  /**
24
50
  * Builds the status lines for the runs one Claude Code session should see.
25
51
  * Pure apart from the default process-liveness probe, which callers may replace.
@@ -237,8 +263,8 @@ function readWatches(directory, sessionId) {
237
263
  }
238
264
 
239
265
  async function main() {
240
- const directory = process.env.MACHINES_RUN_STATUS_DIR;
241
- if (directory === undefined || directory === "" || process.stdin.isTTY) return;
266
+ const directory = runStatusDirectory();
267
+ if (directory === undefined || process.stdin.isTTY) return;
242
268
 
243
269
  let text = "";
244
270
  process.stdin.setEncoding("utf8");
@@ -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, runStatusDirectory } from "../src/run-status.js";
13
+ import { createRunStatusPublisher } 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"));
@@ -212,7 +212,7 @@ export class MachineSession {
212
212
  this.#published.set(run.id, publisher);
213
213
  }
214
214
  const human = run.status === "waiting" ? run.human : undefined;
215
- this.#watchInbox(run.id, human);
215
+ this.#watchInbox(run.id, human, publisher.directory);
216
216
  if (run.status === "running" || run.status === "waiting") {
217
217
  publisher.update({
218
218
  status: run.status, state: run.state, agent: run.agent, limit: run.limit, human, delivery: this.#taking,
@@ -223,13 +223,12 @@ export class MachineSession {
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`. */
226
- #watchInbox(runId, request) {
226
+ #watchInbox(runId, request, directory) {
227
227
  const watched = this.#inboxes.get(runId);
228
228
  if (watched?.requestId === request?.requestId)
229
229
  return;
230
230
  watched?.stop();
231
231
  this.#inboxes.delete(runId);
232
- const directory = runStatusDirectory();
233
232
  if (request === undefined || directory === undefined)
234
233
  return;
235
234
  const stop = watchRunInbox(directory, runId, request, (response, delivery) => {
package/dist/src/cli.js CHANGED
@@ -12,11 +12,15 @@ import { listAgentPresets, listMachines, prepareMachineRun, } from "./launcher.j
12
12
  import { inboxHuman, respondToRun } from "./run-inbox.js";
13
13
  import { createRunStatusPublisher, isProcessAlive, readRunStatuses, 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";
15
16
  const [command, selector, ...argumentsAfterSelector] = process.argv.slice(2);
16
17
  const runsOptions = selector === undefined ? [] : [selector, ...argumentsAfterSelector];
17
18
  const watchUsage = "machine watch [<run-id-or-label-start> | --dir <path>]";
18
19
  const unwatchUsage = "machine unwatch <run-id-or-label-start> | --dir <path> | --all";
20
+ const watchForUsage = "machine watch --for <session> [<run-id-or-label-start> | --dir <path>]";
21
+ const unwatchForUsage = "machine unwatch --for <session> <run-id-or-label-start> | --dir <path> | --all";
19
22
  try {
23
+ const listing = command === "runs" ? parseRunsOptions(runsOptions) : undefined;
20
24
  if (command === "list" && selector === undefined) {
21
25
  const machines = await listMachines({ cwd: process.cwd() });
22
26
  if (machines.length === 0)
@@ -43,9 +47,11 @@ try {
43
47
  else if (command === "run" && selector !== undefined) {
44
48
  await runMachine(await resolveMachine(selector), argumentsAfterSelector);
45
49
  }
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"));
50
+ else if (listing !== undefined) {
51
+ if (listing.viewer === undefined)
52
+ listRuns(listing.json, listing.all);
53
+ else
54
+ listViewerRuns(listing.json, listing.all, listing.viewer);
49
55
  }
50
56
  else if (command === "respond" && selector !== undefined) {
51
57
  await respond([selector, ...argumentsAfterSelector]);
@@ -64,9 +70,12 @@ try {
64
70
  " machine show <name-or-file>",
65
71
  " machine run <name-or-file> [--agent role=preset]... [--input-file <file|->] [--] [input]",
66
72
  " machine runs [--json] [--all]",
73
+ " machine runs --for [<session>] [--project <dir>] [--json] [--all]",
67
74
  " machine respond [--question] <run-id> <request-id> [--] <response>",
68
75
  ` ${watchUsage}`,
69
76
  ` ${unwatchUsage}`,
77
+ ` ${watchForUsage}`,
78
+ ` ${unwatchForUsage}`,
70
79
  ].join("\n"));
71
80
  }
72
81
  }
@@ -97,7 +106,7 @@ async function runMachine(machinePath, args) {
97
106
  const status = createRunStatus(machinePath);
98
107
  const runId = randomUUID();
99
108
  const published = createRunStatusPublisher({ id: runId, machine: prepared.name, path: prepared.path, cwd: process.cwd() });
100
- const statusDirectory = runStatusDirectory();
109
+ const statusDirectory = published.directory;
101
110
  // Machines started by this run's Operations and Agents are its sub-runs.
102
111
  process.env.MACHINES_RUN_PARENT = runId;
103
112
  const observed = runStatusObserver((change) => published.update(change));
@@ -164,56 +173,136 @@ function listRuns(json, all) {
164
173
  printRunListing(record, index > 0, watches.some((watched) => runWatchMatches(watched, record)));
165
174
  });
166
175
  }
176
+ /** What `machine runs` was asked; undefined when the options are not ones it takes. */
177
+ function parseRunsOptions(args) {
178
+ let json = false;
179
+ let all = false;
180
+ let forViewer = false;
181
+ let session;
182
+ let project;
183
+ for (let index = 0; index < args.length; index += 1) {
184
+ const option = args[index];
185
+ const next = args[index + 1];
186
+ // What follows an option is its value unless it is another option.
187
+ const value = next === undefined || next.startsWith("--") ? undefined : next;
188
+ if (option === "--json" && !json)
189
+ json = true;
190
+ else if (option === "--all" && !all)
191
+ all = true;
192
+ else if (option === "--for" && !forViewer) {
193
+ forViewer = true;
194
+ // Without a session id the viewer is this process's own session.
195
+ session = value;
196
+ if (value !== undefined)
197
+ index += 1;
198
+ }
199
+ else if (option === "--project" && project === undefined && value !== undefined) {
200
+ project = value;
201
+ index += 1;
202
+ }
203
+ else
204
+ return undefined;
205
+ }
206
+ // A project is a viewer's: without --for the listing is of every session and project.
207
+ if (!forViewer)
208
+ return project === undefined ? { json, all } : undefined;
209
+ const viewer = { ...(session === undefined ? {} : { session }), ...(project === undefined ? {} : { project }) };
210
+ return { json, all, viewer };
211
+ }
212
+ /** The runs one viewer shows, by the rule its status line applies. */
213
+ function listViewerRuns(json, all, viewer) {
214
+ if (viewer.project === "")
215
+ throw new Error("Expected a directory after --project");
216
+ const { directory, session } = requireWatchList(viewer.session);
217
+ const watches = readRunWatches(directory, session);
218
+ // As they are read, not by start time: runs updated in the same millisecond
219
+ // keep that order, here as in the library and on the status line.
220
+ const runs = runsForViewer(readRunStatuses(directory), {
221
+ sessionId: session,
222
+ // Compared absolute, wherever the command ran and however the path was written.
223
+ ...(viewer.project === undefined ? {} : { projectDir: resolve(viewer.project) }),
224
+ watches,
225
+ }, { all });
226
+ if (json) {
227
+ console.log(JSON.stringify(runs, null, 2));
228
+ return;
229
+ }
230
+ if (runs.length === 0)
231
+ console.log(`Session ${session} shows no Machine runs.`);
232
+ runs.forEach((run, index) => {
233
+ printRunListing(run, index > 0, watches.some((watched) => runWatchMatches(watched, run)));
234
+ });
235
+ }
167
236
  /** Every record in the directory, including runs that finished or lost their process and are still recorded. */
168
237
  function publishedRuns(directory) {
169
238
  return readRunStatuses(directory).sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt));
170
239
  }
171
240
  /** A run that stopped reporting without finishing has lost its process, as on the status line. */
172
241
  function runStatus(record) {
242
+ // A viewer's run already has the status its process leaves it.
243
+ if ("shown" in record)
244
+ return record.status;
173
245
  return record.status !== "completed" && record.status !== "failed" && !isProcessAlive(record.pid) ? "lost" : record.status;
174
246
  }
175
247
  function isActiveRun(record) {
176
248
  const status = runStatus(record);
177
249
  return status === "running" || status === "waiting";
178
250
  }
179
- function watch(args) {
180
- const { directory, session } = requireWatchList();
251
+ function watch(given) {
252
+ const { named, args, usage, who } = watchSession(given, watchUsage, watchForUsage);
253
+ const { directory, session } = requireWatchList(named);
181
254
  const watches = readRunWatches(directory, session);
182
255
  const runs = publishedRuns(directory);
183
256
  if (args.length === 0) {
184
257
  if (watches.length === 0)
185
- console.log("This session watches no other runs.");
258
+ console.log(`${who} watches no other runs.`);
186
259
  for (const watched of watches) {
187
260
  console.log(describeWatch(watched));
188
261
  printWatchedRuns(watched, runs);
189
262
  }
190
263
  return;
191
264
  }
192
- const added = parseWatch(args, watchUsage);
265
+ const added = parseWatch(args, usage);
193
266
  if (!watches.some((watched) => sameRunWatch(watched, added)))
194
267
  writeRunWatches(directory, session, [...watches, added]);
195
268
  console.log(`Watching ${describeWatch(added)}`);
196
269
  printWatchedRuns(added, runs);
197
270
  }
198
- function unwatch(args) {
199
- const { directory, session } = requireWatchList();
271
+ function unwatch(given) {
272
+ const { named, args, usage, who } = watchSession(given, unwatchUsage, unwatchForUsage);
273
+ if (args.length === 0)
274
+ throw new Error(`Usage: ${usage}`);
275
+ const { directory, session } = requireWatchList(named);
200
276
  if (args[0] === "--all") {
201
277
  if (args.length !== 1)
202
- throw new Error(`Usage: ${unwatchUsage}`);
278
+ throw new Error(`Usage: ${usage}`);
203
279
  writeRunWatches(directory, session, []);
204
- console.log("This session watches no other runs.");
280
+ console.log(`${who} watches no other runs.`);
205
281
  return;
206
282
  }
207
- const removed = parseWatch(args, unwatchUsage);
283
+ const removed = parseWatch(args, usage);
208
284
  const watches = readRunWatches(directory, session);
209
285
  const kept = watches.filter((watched) => !sameRunWatch(watched, removed));
210
286
  if (kept.length === watches.length) {
211
- console.log(`This session was not watching ${describeWatch(removed)}`);
287
+ console.log(`${who} was not watching ${describeWatch(removed)}`);
212
288
  return;
213
289
  }
214
290
  writeRunWatches(directory, session, kept);
215
291
  console.log(`No longer watching ${describeWatch(removed)}`);
216
292
  }
293
+ /**
294
+ * Takes a leading `--for <session>` from what `machine watch` and
295
+ * `machine unwatch` were given: the list to change is then that session's and
296
+ * not this process's own.
297
+ */
298
+ function watchSession(given, usage, forUsage) {
299
+ if (given[0] !== "--for")
300
+ return { named: undefined, args: given, usage, who: "This session" };
301
+ const named = given[1];
302
+ if (named === undefined || named.startsWith("--"))
303
+ throw new Error(`Usage: ${forUsage}`);
304
+ return { named, args: given.slice(2), usage: forUsage, who: `Session ${named}` };
305
+ }
217
306
  function parseWatch(args, usage) {
218
307
  if (args[0] === "--dir") {
219
308
  if (args.length !== 2)
@@ -234,10 +323,10 @@ function printWatchedRuns(watched, runs) {
234
323
  for (const record of matched)
235
324
  console.log(` ${runColumns(record).join("\t")}`);
236
325
  }
237
- /** The session whose watch list this process changes, and where the list is kept. */
238
- function requireWatchList() {
326
+ /** The session whose watch list is read or changed, this process's own unless one is named, and where the list is kept. */
327
+ function requireWatchList(named) {
239
328
  const directory = requireStatusDirectory();
240
- const session = runSessionId();
329
+ const session = named ?? runSessionId();
241
330
  if (session === undefined) {
242
331
  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
332
  }
@@ -265,7 +354,10 @@ async function respond(args) {
265
354
  function requireStatusDirectory() {
266
355
  const directory = runStatusDirectory();
267
356
  if (directory === undefined) {
268
- throw new Error("Run status publication is off: set MACHINES_RUN_STATUS_DIR to the directory the runs publish to.");
357
+ // Unset, the variable leaves publication on, so only a home that cannot hold the standard directory turns it off.
358
+ throw new Error(process.env.MACHINES_RUN_STATUS_DIR === undefined
359
+ ? "Run status publication is off: there is no absolute home directory to keep the standard directory in. Set MACHINES_RUN_STATUS_DIR to the directory the runs publish to."
360
+ : "Run status publication is off: unset MACHINES_RUN_STATUS_DIR to use the standard directory, or set it to the directory the runs publish to.");
269
361
  }
270
362
  return directory;
271
363
  }
@@ -1,5 +1,5 @@
1
1
  /** Select a separate global catalog without changing the process's real home. */
2
- export declare function machinesUserHome(): string;
2
+ export declare function machinesUserHome(environment?: NodeJS.ProcessEnv): string;
3
3
  export interface DiscoveredMachine {
4
4
  readonly name: string;
5
5
  readonly path: string;
@@ -2,8 +2,8 @@ import { readdir, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join } from "node:path";
4
4
  /** Select a separate global catalog without changing the process's real home. */
5
- export function machinesUserHome() {
6
- const override = process.env.MACHINES_USER_HOME;
5
+ export function machinesUserHome(environment = process.env) {
6
+ const override = environment.MACHINES_USER_HOME;
7
7
  if (override === undefined)
8
8
  return homedir();
9
9
  if (!isAbsolute(override))
@@ -1,6 +1,6 @@
1
1
  import type { StateValue } from "xstate";
2
2
  import type { AgentUpdate } from "./index.ts";
3
- /** One published run, stored as `<MACHINES_RUN_STATUS_DIR>/<id>.json`. */
3
+ /** One published run, stored as `<id>.json` in the run-status directory. */
4
4
  export interface RunStatusRecord {
5
5
  /** Version 1 records carry only `human.prompt`. */
6
6
  readonly schemaVersion: 1 | 2;
@@ -81,12 +81,29 @@ export type RunStatusOutcome = {
81
81
  readonly error: string;
82
82
  };
83
83
  export interface RunStatusPublisher {
84
+ /**
85
+ * Where the run is published. Undefined when it is not: publication is off,
86
+ * or the directory could not take the run's first record.
87
+ */
88
+ readonly directory: string | undefined;
84
89
  update(change: RunStatusChange): void;
85
90
  finish(outcome: RunStatusOutcome): void;
86
91
  }
87
92
  /** How long a record goes on naming a response its run took: longer than a sender waits to hear of it. */
88
93
  export declare const deliveryRetentionMilliseconds = 60000;
89
- /** The directory runs publish to, or undefined while publication is off. */
94
+ /** How long a session's watch list is kept after its last change: sessions end without saying so. */
95
+ export declare const watchRetentionMilliseconds: number;
96
+ /** The name of a session's watch list, which `src/run-watch.ts` keeps beside the records. */
97
+ export declare const watchListName: RegExp;
98
+ /**
99
+ * The directory runs publish to, or undefined while publication is off.
100
+ * `MACHINES_RUN_STATUS_DIR` moves it, and turns publication off when it is
101
+ * `off` or empty. Unset, it is the user's own state directory, so every
102
+ * process of one user agrees on it without being told.
103
+ *
104
+ * `claude/statusline.mjs` cannot load this module and carries a copy of the
105
+ * rule; tests/run-status.test.ts keeps the two in step.
106
+ */
90
107
  export declare function runStatusDirectory(environment?: NodeJS.ProcessEnv): string | undefined;
91
108
  /** The one pending outside response for a run, kept beside its record. */
92
109
  export declare function runInboxPath(directory: string, id: string): string;
@@ -96,9 +113,10 @@ export declare function runInboxPath(directory: string, id: string): string;
96
113
  */
97
114
  export declare function runDeliveryPath(directory: string, id: string, delivery: string): string;
98
115
  /**
99
- * Publishes one run for hosts that can only read files, such as a status line.
100
- * Opt-in through MACHINES_RUN_STATUS_DIR. Publication is presentation: it never
101
- * throws, and it records neither Machine input nor output.
116
+ * Publishes one run for hosts that can only read files, such as a status line,
117
+ * in `runStatusDirectory`. Publication is presentation: it never throws, and it
118
+ * records neither Machine input nor output. A run whose first record cannot be
119
+ * written, as in a read-only home, is not published at all.
102
120
  */
103
121
  export declare function createRunStatusPublisher(run: RunStatusRun, environment?: NodeJS.ProcessEnv): RunStatusPublisher;
104
122
  /**
@@ -111,7 +129,9 @@ export declare function runStatusObserver(update: (change: RunStatusChange) => v
111
129
  };
112
130
  /**
113
131
  * The session a process belongs to: the owner of the runs it starts, and the
114
- * session whose watch list `machine watch` changes.
132
+ * session whose watch list `machine watch` changes. `MACHINES_RUN_OWNER` is how
133
+ * any host names it; the others are what Claude Code, Codex, and Pi give the
134
+ * commands they start.
115
135
  */
116
136
  export declare function runSessionId(environment?: NodeJS.ProcessEnv): string | undefined;
117
137
  /** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
@@ -1,15 +1,49 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { basename, join, resolve } from "node:path";
2
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, isAbsolute, join, resolve } from "node:path";
4
+ import { machinesUserHome } from "./discovery.js";
4
5
  const staleAfterMilliseconds = 60 * 60 * 1_000;
5
6
  /** How long a record goes on naming a response its run took: longer than a sender waits to hear of it. */
6
7
  export const deliveryRetentionMilliseconds = 60_000;
7
- const disabled = { update() { }, finish() { } };
8
+ /** How long a session's watch list is kept after its last change: sessions end without saying so. */
9
+ export const watchRetentionMilliseconds = 30 * 24 * 60 * 60 * 1_000;
10
+ /** The name of a session's watch list, which `src/run-watch.ts` keeps beside the records. */
11
+ export const watchListName = /^\.watch\..+\.json$/u;
12
+ // A record being written, and the second names of a response: each lasts moments unless its process was stopped.
13
+ const leftoverName = /^\.[A-Za-z0-9._-]+\.(?:\d+\.\d+\.tmp|[A-Za-z0-9_-]+\.inbox\.(?:sent|taken))$/u;
14
+ // A run's inbox, which goes with its record: one without a record belongs to a run that was pruned.
15
+ const inboxName = /^[A-Za-z0-9_-][A-Za-z0-9._-]*\.inbox$/u;
16
+ const disabled = { directory: undefined, update() { }, finish() { } };
8
17
  let temporaryFiles = 0;
9
- /** The directory runs publish to, or undefined while publication is off. */
18
+ /**
19
+ * The directory runs publish to, or undefined while publication is off.
20
+ * `MACHINES_RUN_STATUS_DIR` moves it, and turns publication off when it is
21
+ * `off` or empty. Unset, it is the user's own state directory, so every
22
+ * process of one user agrees on it without being told.
23
+ *
24
+ * `claude/statusline.mjs` cannot load this module and carries a copy of the
25
+ * rule; tests/run-status.test.ts keeps the two in step.
26
+ */
10
27
  export function runStatusDirectory(environment = process.env) {
11
28
  const configured = environment.MACHINES_RUN_STATUS_DIR;
12
- return configured === undefined || configured === "" ? undefined : resolve(configured);
29
+ if (configured === "" || configured === "off")
30
+ return undefined;
31
+ if (configured !== undefined)
32
+ return resolve(configured);
33
+ // XDG_STATE_HOME describes the real home. A process given another Machines
34
+ // home keeps its runs under that one, and out of the real one.
35
+ const state = environment.XDG_STATE_HOME;
36
+ if (environment.MACHINES_USER_HOME === undefined && state !== undefined && isAbsolute(state)) {
37
+ return join(state, "machines", "runs");
38
+ }
39
+ try {
40
+ const home = machinesUserHome(environment);
41
+ return isAbsolute(home) ? join(home, ".local", "state", "machines", "runs") : undefined;
42
+ }
43
+ catch {
44
+ // Without a usable home there is nowhere standard to publish.
45
+ return undefined;
46
+ }
13
47
  }
14
48
  /** The one pending outside response for a run, kept beside its record. */
15
49
  export function runInboxPath(directory, id) {
@@ -23,9 +57,10 @@ export function runDeliveryPath(directory, id, delivery) {
23
57
  return join(directory, `.${id}.${delivery}.inbox.sent`);
24
58
  }
25
59
  /**
26
- * Publishes one run for hosts that can only read files, such as a status line.
27
- * Opt-in through MACHINES_RUN_STATUS_DIR. Publication is presentation: it never
28
- * throws, and it records neither Machine input nor output.
60
+ * Publishes one run for hosts that can only read files, such as a status line,
61
+ * in `runStatusDirectory`. Publication is presentation: it never throws, and it
62
+ * records neither Machine input nor output. A run whose first record cannot be
63
+ * written, as in a read-only home, is not published at all.
29
64
  */
30
65
  export function createRunStatusPublisher(run, environment = process.env) {
31
66
  const directory = runStatusDirectory(environment);
@@ -79,9 +114,11 @@ export function createRunStatusPublisher(run, environment = process.env) {
79
114
  rmSync(temporary, { force: true });
80
115
  throw cause;
81
116
  }
117
+ return true;
82
118
  }
83
119
  catch {
84
120
  // A status line is never worth a run: an unwritable directory is ignored.
121
+ return false;
85
122
  }
86
123
  };
87
124
  try {
@@ -90,8 +127,12 @@ export function createRunStatusPublisher(run, environment = process.env) {
90
127
  catch {
91
128
  // Pruning is best effort for the same reason.
92
129
  }
93
- publish();
130
+ // A directory that cannot take the first record is not tried again at every
131
+ // change, and the run goes on exactly as one that publication is off for.
132
+ if (!publish())
133
+ return disabled;
94
134
  return {
135
+ directory,
95
136
  update(change) {
96
137
  if (status === "completed" || status === "failed")
97
138
  return;
@@ -187,10 +228,16 @@ export function runStatusObserver(update) {
187
228
  }
188
229
  /**
189
230
  * The session a process belongs to: the owner of the runs it starts, and the
190
- * session whose watch list `machine watch` changes.
231
+ * session whose watch list `machine watch` changes. `MACHINES_RUN_OWNER` is how
232
+ * any host names it; the others are what Claude Code, Codex, and Pi give the
233
+ * commands they start.
191
234
  */
192
235
  export function runSessionId(environment = process.env) {
193
- return environment.MACHINES_RUN_OWNER || environment.CLAUDE_CODE_SESSION_ID || undefined;
236
+ return environment.MACHINES_RUN_OWNER
237
+ || environment.CLAUDE_CODE_SESSION_ID
238
+ || environment.CODEX_THREAD_ID
239
+ || environment.PI_SESSION_ID
240
+ || undefined;
194
241
  }
195
242
  /** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
196
243
  export function readRunStatuses(directory) {
@@ -198,27 +245,60 @@ export function readRunStatuses(directory) {
198
245
  }
199
246
  function pruneRunStatuses(directory) {
200
247
  const now = Date.now();
248
+ const remove = (file) => {
249
+ try {
250
+ rmSync(file, { force: true });
251
+ }
252
+ catch {
253
+ // Not a file, or not ours to remove: nothing else waits on it.
254
+ }
255
+ };
201
256
  for (const { file, record } of readRecordFiles(directory)) {
202
257
  const settled = record.status === "completed" || record.status === "failed" || !isProcessAlive(record.pid);
203
258
  if (!settled || now - Date.parse(record.updatedAt) <= staleAfterMilliseconds)
204
259
  continue;
260
+ // A response nobody collected goes with the run it was meant for, under each
261
+ // of its names. The record goes last: a publisher stopped part way leaves it
262
+ // for the next one to prune whole.
263
+ const id = basename(file, ".json");
264
+ remove(runInboxPath(directory, id));
265
+ for (const name of listNames(directory)) {
266
+ if (!name.startsWith(`.${id}.`))
267
+ continue;
268
+ if (/^[^.]+\.inbox\.(?:sent|taken)$/u.test(name.slice(id.length + 2)))
269
+ remove(join(directory, name));
270
+ }
271
+ remove(file);
272
+ }
273
+ // What a stopped process left half done has no record to go with, nor has an
274
+ // inbox whose run was pruned without it, and a session that ended never
275
+ // removes its watch list. Other files are not ours.
276
+ const names = new Set(listNames(directory));
277
+ for (const name of names) {
278
+ const orphaned = inboxName.test(name) && !names.has(`${basename(name, ".inbox")}.json`);
279
+ const kept = leftoverName.test(name) || orphaned
280
+ ? staleAfterMilliseconds
281
+ : watchListName.test(name) ? watchRetentionMilliseconds : undefined;
282
+ if (kept === undefined)
283
+ continue;
284
+ const file = join(directory, name);
205
285
  try {
206
- rmSync(file, { force: true });
207
- // A response nobody collected goes with the run it was meant for, under each of its names.
208
- const id = basename(file, ".json");
209
- rmSync(runInboxPath(directory, id), { force: true });
210
- for (const name of readdirSync(directory)) {
211
- if (!name.startsWith(`.${id}.`))
212
- continue;
213
- if (/^[^.]+\.inbox\.(?:sent|taken)$/u.test(name.slice(id.length + 2)))
214
- rmSync(join(directory, name), { force: true });
215
- }
286
+ if (now - statSync(file).mtimeMs > kept)
287
+ rmSync(file, { force: true });
216
288
  }
217
289
  catch {
218
290
  // Another publisher may have pruned it first.
219
291
  }
220
292
  }
221
293
  }
294
+ function listNames(directory) {
295
+ try {
296
+ return readdirSync(directory);
297
+ }
298
+ catch {
299
+ return [];
300
+ }
301
+ }
222
302
  function readRecordFiles(directory) {
223
303
  let names;
224
304
  try {
@@ -0,0 +1,55 @@
1
+ import { type RunStatusRecord } from "./run-status.ts";
2
+ import { type RunWatch } from "./run-watch.ts";
3
+ export { readRunStatuses, runSessionId, runStatusDirectory } from "./run-status.ts";
4
+ export type { RunStatusAgent, RunStatusHuman, RunStatusLimit, RunStatusRecord } from "./run-status.ts";
5
+ export { readRunWatches } from "./run-watch.ts";
6
+ export type { RunWatch } from "./run-watch.ts";
7
+ /** How long a settled or lost run stays with its viewers before it drops out. */
8
+ export declare const settledRunVisibleMilliseconds = 30000;
9
+ /** One surface that shows runs: a coding-agent session, a status line, a browser tab. */
10
+ export interface RunViewer {
11
+ /** Shows the runs this session owns. Its watch list is kept under the same id. */
12
+ readonly sessionId: string;
13
+ /** Absolute project directory; shows unowned runs working in it or below it. */
14
+ readonly projectDir?: string;
15
+ /** What the session asked to watch; shows the top-level runs an entry names, whoever owns them. */
16
+ readonly watches?: readonly RunWatch[];
17
+ }
18
+ export interface ViewerRunOptions {
19
+ readonly now?: number;
20
+ /** Keeps settled and lost runs however long ago they stopped. */
21
+ readonly all?: boolean;
22
+ readonly isAlive?: (pid: number) => boolean;
23
+ }
24
+ /**
25
+ * How a waiting run's Human request is answered: by `machine respond` with
26
+ * these two ids, or only through the program that hosts the run.
27
+ */
28
+ export type RunAnswer = {
29
+ readonly via: "respond";
30
+ readonly runId: string;
31
+ readonly requestId: string;
32
+ } | {
33
+ readonly via: "host";
34
+ };
35
+ /** A published record as one viewer shows it. */
36
+ export interface ViewerRun extends Omit<RunStatusRecord, "status"> {
37
+ /** `lost` when the record says running or waiting and its process is gone. */
38
+ readonly status: RunStatusRecord["status"] | "lost";
39
+ /** Why the viewer shows it; the first that holds of owned, project, and watched. */
40
+ readonly shown: "owned" | "project" | "watched";
41
+ /** Present while the run waits on a request that something can answer. */
42
+ readonly answer?: RunAnswer;
43
+ }
44
+ /**
45
+ * The runs one viewer shows: the runs its session owns, unowned runs working
46
+ * inside its project, and runs it watches. Sub-runs are never shown, and a
47
+ * settled or lost run drops out after `settledRunVisibleMilliseconds`. Runs
48
+ * that can still change come first, then the most recently updated; records
49
+ * that tie keep the order they were given in.
50
+ *
51
+ * Pure apart from the default process-liveness probe. `claude/statusline.mjs`
52
+ * cannot load this module and carries a copy of the selection;
53
+ * tests/run-viewer.test.ts keeps the two in step.
54
+ */
55
+ export declare function runsForViewer(records: readonly RunStatusRecord[], viewer: RunViewer, options?: ViewerRunOptions): ViewerRun[];
@@ -0,0 +1,65 @@
1
+ import { isProcessAlive } from "./run-status.js";
2
+ import { isWithinDirectory, runWatchMatches } from "./run-watch.js";
3
+ // The package exports this module as `@dna113p/machines/run-status`: what an
4
+ // application needs to draw its own widgets for published runs. It only reads;
5
+ // the publisher and the watch-list writer stay in the runtime.
6
+ export { readRunStatuses, runSessionId, runStatusDirectory } from "./run-status.js";
7
+ export { readRunWatches } from "./run-watch.js";
8
+ /** How long a settled or lost run stays with its viewers before it drops out. */
9
+ export const settledRunVisibleMilliseconds = 30_000;
10
+ /**
11
+ * The runs one viewer shows: the runs its session owns, unowned runs working
12
+ * inside its project, and runs it watches. Sub-runs are never shown, and a
13
+ * settled or lost run drops out after `settledRunVisibleMilliseconds`. Runs
14
+ * that can still change come first, then the most recently updated; records
15
+ * that tie keep the order they were given in.
16
+ *
17
+ * Pure apart from the default process-liveness probe. `claude/statusline.mjs`
18
+ * cannot load this module and carries a copy of the selection;
19
+ * tests/run-viewer.test.ts keeps the two in step.
20
+ */
21
+ export function runsForViewer(records, viewer, options = {}) {
22
+ const { now = Date.now(), all = false, isAlive = isProcessAlive } = options;
23
+ const visible = [];
24
+ for (const record of records) {
25
+ // Sub-runs belong to the run that started them, not to the session.
26
+ if (record.parent !== undefined)
27
+ continue;
28
+ const shown = shownBecause(record, viewer);
29
+ if (shown === undefined)
30
+ continue;
31
+ const updatedAt = Date.parse(record.updatedAt);
32
+ const recorded = record.status === "running" || record.status === "waiting";
33
+ // A run that stopped reporting without finishing has lost its process.
34
+ const lost = recorded && !isAlive(record.pid);
35
+ const active = recorded && !lost;
36
+ if (!active && !all && now - updatedAt > settledRunVisibleMilliseconds)
37
+ continue;
38
+ const status = lost ? "lost" : record.status;
39
+ const answer = status === "waiting" ? runAnswer(record) : undefined;
40
+ visible.push({ run: { ...record, status, shown, ...(answer === undefined ? {} : { answer }) }, active, updatedAt });
41
+ }
42
+ visible.sort((left, right) => Number(right.active) - Number(left.active) || right.updatedAt - left.updatedAt);
43
+ return visible.map(({ run }) => run);
44
+ }
45
+ function shownBecause(record, viewer) {
46
+ // An owned run is its owner's: the project it works in shows it to nobody else.
47
+ if (record.owner !== undefined) {
48
+ if (record.owner === viewer.sessionId)
49
+ return "owned";
50
+ }
51
+ else if (viewer.projectDir !== undefined && isWithinDirectory(record.cwd, viewer.projectDir)) {
52
+ return "project";
53
+ }
54
+ // A run the session asked to watch is shown whoever owns it and wherever it works.
55
+ return viewer.watches?.some((watch) => runWatchMatches(watch, record)) === true ? "watched" : undefined;
56
+ }
57
+ function runAnswer(record) {
58
+ if (record.human === undefined)
59
+ return undefined;
60
+ if (record.answeredBy === "host")
61
+ return { via: "host" };
62
+ // A record from before requests had ids says nothing about how it is answered.
63
+ const { requestId } = record.human;
64
+ return requestId === undefined ? undefined : { via: "respond", runId: record.id, requestId };
65
+ }
@@ -18,7 +18,9 @@ export declare function readRunWatches(directory: string, sessionId: string): Ru
18
18
  /** Replaces a session's watch list; an empty one removes its file. */
19
19
  export declare function writeRunWatches(directory: string, sessionId: string, watches: readonly RunWatch[]): void;
20
20
  /** Whether a watch names a run. A sub-run is never watched: it is its parent's detail. */
21
- export declare function runWatchMatches(watch: RunWatch, record: RunStatusRecord): boolean;
21
+ export declare function runWatchMatches(watch: RunWatch, record: Pick<RunStatusRecord, "id" | "label" | "cwd" | "parent">): boolean;
22
+ /** Whether a path is a directory or inside it. Only absolute paths say where they are. */
23
+ export declare function isWithinDirectory(path: string, directory: string): boolean;
22
24
  /** Checks and normalizes what `machine watch` was given. */
23
25
  export declare function runWatch(input: RunWatch): RunWatch;
24
26
  export declare function sameRunWatch(left: RunWatch, right: RunWatch): boolean;
@@ -1,8 +1,6 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { isAbsolute, join, resolve, sep } from "node:path";
3
- import { isFileSafeId } from "./run-status.js";
4
- const forgottenAfterMilliseconds = 30 * 24 * 60 * 60 * 1_000;
5
- const watchFile = /^\.watch\..+\.json$/u;
3
+ import { isFileSafeId, watchListName, watchRetentionMilliseconds } from "./run-status.js";
6
4
  let temporaryFiles = 0;
7
5
  /**
8
6
  * A session's watch list, kept beside the records under a name their readers
@@ -54,18 +52,22 @@ export function writeRunWatches(directory, sessionId, watches) {
54
52
  export function runWatchMatches(watch, record) {
55
53
  if (record.parent !== undefined)
56
54
  return false;
57
- if ("dir" in watch) {
58
- if (!isAbsolute(record.cwd))
59
- return false;
60
- const cwd = resolve(record.cwd);
61
- return cwd === watch.dir || cwd.startsWith(watch.dir.endsWith(sep) ? watch.dir : `${watch.dir}${sep}`);
62
- }
55
+ if ("dir" in watch)
56
+ return isWithinDirectory(record.cwd, watch.dir);
63
57
  if (record.id.startsWith(watch.text))
64
58
  return true;
65
59
  // The label must start with the whole text, not with a longer word that begins like it.
66
60
  const next = record.label?.startsWith(watch.text) ? record.label.slice(watch.text.length) : undefined;
67
61
  return next !== undefined && !/^[\p{L}\p{N}_-]/u.test(next);
68
62
  }
63
+ /** Whether a path is a directory or inside it. Only absolute paths say where they are. */
64
+ export function isWithinDirectory(path, directory) {
65
+ if (!isAbsolute(path) || !isAbsolute(directory))
66
+ return false;
67
+ const outer = resolve(directory);
68
+ const inner = resolve(path);
69
+ return inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : `${outer}${sep}`);
70
+ }
69
71
  /** Checks and normalizes what `machine watch` was given. */
70
72
  export function runWatch(input) {
71
73
  if ("dir" in input) {
@@ -94,15 +96,18 @@ function pickWatch(value) {
94
96
  return { dir: resolve(dir) };
95
97
  return undefined;
96
98
  }
97
- /** Sessions end without saying so; a list nobody has changed for a month belongs to one that did. */
99
+ /**
100
+ * Sessions end without saying so; a list nobody has changed for a month belongs
101
+ * to one that did. A run that starts prunes them by the same rule.
102
+ */
98
103
  function pruneRunWatches(directory, kept) {
99
104
  const now = Date.now();
100
105
  for (const name of readdirSync(directory)) {
101
106
  const file = join(directory, name);
102
- if (!watchFile.test(name) || file === kept)
107
+ if (!watchListName.test(name) || file === kept)
103
108
  continue;
104
109
  try {
105
- if (now - statSync(file).mtimeMs > forgottenAfterMilliseconds)
110
+ if (now - statSync(file).mtimeMs > watchRetentionMilliseconds)
106
111
  rmSync(file, { force: true });
107
112
  }
108
113
  catch {
@@ -61,6 +61,8 @@ MCP server ────┘ ↑
61
61
  | `src/discovery-worker.ts` | Fresh metadata imports in disposable workers |
62
62
  | `src/host.ts` | One child run, IPC, Human request identity, owned process termination, run-status publication for the run |
63
63
  | `src/session.ts` | Shared Pi/MCP run lifecycle, snapshots, response handling, retention, shutdown |
64
+ | `src/run-status.ts`, `src/run-watch.ts` | The run-status directory rule, one record per published run, session identity, and each session's watch list |
65
+ | `src/run-viewer.ts` | Which published runs one viewer shows; exported with the readers as `@dna113p/machines/run-status` |
64
66
  | `src/cli.ts` | Command parsing, terminal progress, source inspection |
65
67
  | `pi-extension/index.ts` | Pi tools, widgets, notifications, native input presentation |
66
68
  | `mcp/server.ts` | MCP schemas, tool results, and the optional HTML resource |
@@ -100,7 +102,12 @@ and that request. Claiming a request before asynchronous delivery prevents two
100
102
  answers from being accepted, and the identity prevents a stale card or dialog from
101
103
  answering a later prompt. Restricted choices are validated for every interface.
102
104
 
103
- `machine run` asks on its own terminal. With run-status publication on it also
105
+ `machine run` asks on its own terminal. Run-status publication is on unless
106
+ `MACHINES_RUN_STATUS_DIR` turns it off, in a private per-user directory that
107
+ every reader and writer finds by one rule (`runStatusDirectory`; the standalone
108
+ status line carries a tested copy). It is presentation and never changes a run:
109
+ a run that cannot write its first record is simply not published. With
110
+ publication on, `machine run` also
104
111
  publishes the pending request and its request ID, and takes the first valid
105
112
  response from either the terminal or a private per-run inbox file written by
106
113
  `machine respond`; the MCP session takes inbox responses through the same
@@ -115,6 +122,14 @@ only way to answer it, its record says so, and `machine respond` refuses it. See
115
122
  A session's watch list changes only which published runs its status line shows.
116
123
  It gives no ownership of a run and no way to answer one.
117
124
 
125
+ Which runs a viewer shows is one rule, `runsForViewer` in `src/run-viewer.ts`:
126
+ `machine runs --for` prints its answer, and `@dna113p/machines/run-status`
127
+ exports it with the readers, so that an application draws its own widgets
128
+ without a second copy. It selects and orders; presentation stays with whatever
129
+ draws. The standalone status line cannot load it and carries a tested copy, as
130
+ it does of the directory rule. See
131
+ [Integrations](integrations.md#drawing-your-own-widgets).
132
+
118
133
  Closing a session terminates active and starting hosts. Host termination includes
119
134
  ordinary descendants in the host's owned process group on POSIX and process-tree
120
135
  termination on Windows. Deliberately detached processes can escape normal ownership;
@@ -170,12 +170,31 @@ Pi exit, session replacement, and reload terminate its session-owned runs.
170
170
 
171
171
  Pi and MCP Apps clients render run snapshots in-process. A host that can only run
172
172
  a command, such as Claude Code's status line, reads published run status instead.
173
- Publication is opt-in: set `MACHINES_RUN_STATUS_DIR` to an absolute directory.
174
- When it is unset nothing is written.
173
+ Publication is on by default and needs no setup: every process of one user
174
+ publishes to and reads from the same private directory, so a run started
175
+ anywhere, by a coding agent, a terminal, or a daemon, is on the status line, in
176
+ `machine runs`, and within reach of `machine respond`.
175
177
 
176
- `machine run`, the MCP server, and every run hosted through `startMachineHost`
177
- then keep one file per run, `<dir>/<runId>.json`, replaced atomically on each
178
- change (directory `0700`, file `0600`):
178
+ One variable refines it:
179
+
180
+ | `MACHINES_RUN_STATUS_DIR` | Run status |
181
+ | --- | --- |
182
+ | unset | Published in `$XDG_STATE_HOME/machines/runs` when `XDG_STATE_HOME` is an absolute path, otherwise in `~/.local/state/machines/runs` |
183
+ | `off`, or empty | Off: nothing is written, and the commands that read run status say so |
184
+ | a directory | Published there instead; a relative path is resolved against the working directory |
185
+
186
+ To move the directory, give the same absolute path to every process that starts
187
+ runs or reads them: the coding agent, a daemon that hosts runs, and the status
188
+ line. The same holds for `XDG_STATE_HOME`: a service that does not inherit the
189
+ one your shell sets uses `~/.local/state`. A process given its own Machines home
190
+ through `MACHINES_USER_HOME` publishes under that home, in
191
+ `<home>/.local/state/machines/runs`, whatever `XDG_STATE_HOME` says, so an
192
+ isolated catalog also keeps its runs out of your own directory. Where no
193
+ absolute home can be found, publication is off.
194
+
195
+ `machine run`, the MCP server, and a host that asks for it (below) keep one file
196
+ per run, `<dir>/<runId>.json`, replaced atomically on each change. The
197
+ directory is created by the first write, as `0700`, and each file is `0600`:
179
198
 
180
199
  ```json
181
200
  {
@@ -190,8 +209,11 @@ change (directory `0700`, file `0600`):
190
209
  `status` is `running`, `waiting`, `completed`, or `failed`. `state` is the current
191
210
  state as a string (JSON for a nested state). `agent` is present while an Agent
192
211
  state has reported its identity, and `error` after a failure. `owner` is
193
- `MACHINES_RUN_OWNER` when set, otherwise `CLAUDE_CODE_SESSION_ID`, otherwise
194
- absent. `stateSince` is when the current state was entered. `label` is the first
212
+ `MACHINES_RUN_OWNER` when set, otherwise the session id the coding agent gives
213
+ the commands it starts (`CLAUDE_CODE_SESSION_ID`, then `CODEX_THREAD_ID`, then
214
+ `PI_SESSION_ID`), otherwise absent; see
215
+ [how a host names its session](#drawing-your-own-widgets). `stateSince` is when
216
+ the current state was entered. `label` is the first
195
217
  line of `MACHINES_RUN_LABEL` (at most 160 characters) when a launcher sets it to
196
218
  say what the run is for, such as a ticket id and title; it is the launcher's
197
219
  choice to publish that text. `machine run` exports its run id as
@@ -199,11 +221,11 @@ choice to publish that text. `machine run` exports its run id as
199
221
  records that id as `parent`; the status line shows only top-level runs.
200
222
 
201
223
  A run hosted through `startMachineHost` from `@dna113p/machines/host` publishes
202
- the same record when its host asks with `publishRunStatus: true` and the
203
- variable is set in the hosting process, as a daemon that runs tickets does. A
224
+ the same record when its host asks with `publishRunStatus: true` and
225
+ publication is on in the hosting process, as a daemon that runs tickets does. A
204
226
  host that does not ask publishes nothing, whatever its environment says: a
205
227
  program that presents its runs itself, such as Pi with the extension loaded, is
206
- not changed by the variable. Two options replace the environment for that one run:
228
+ not changed by publication being on. Two options replace the environment for that one run:
207
229
  `label`, shortened as `MACHINES_RUN_LABEL` is, and `owner`. When an option is
208
230
  absent the environment rule above applies; an empty one publishes the run
209
231
  without that field. The run exports its id as `MACHINES_RUN_PARENT` to its
@@ -262,17 +284,37 @@ after it, and never contains the response.
262
284
 
263
285
  Machine input, Machine output, and Human responses are never recorded. A Human
264
286
  prompt and its choices are written to the `0600` file while the request is
265
- waiting, so choose a directory only you can read. Publication cannot change a
266
- run: a missing or unwritable directory is ignored. Starting a run prunes records
267
- that finished, or whose process is gone, more than an hour ago.
287
+ waiting. The standard directory is private to you; one you name instead should
288
+ be a directory only you can read and write.
289
+
290
+ Publication cannot change a run. Where the directory cannot be created or
291
+ written, as in a read-only home, a sandbox, or a container, a run goes on
292
+ exactly as one with publication off: it is not published, nothing is reported,
293
+ and the directory is not tried again at each state. Reading a directory that
294
+ does not exist yet finds no runs; it is not an error.
295
+
296
+ The directory does not grow without bound. Each run that starts removes:
297
+
298
+ - the record of a run that finished, or whose process is gone, more than an hour
299
+ ago, with its inbox and any response left for it;
300
+ - what an interrupted write or delivery left behind more than an hour ago;
301
+ - an inbox with no record beside it, left when a run was pruned without it,
302
+ more than an hour after it was last written;
303
+ - a [watch list](#watching-other-runs) that has not changed for 30 days.
304
+
305
+ A running or waiting run keeps its record for as long as its process lives.
306
+ Files Machines did not name are never removed, and nothing is removed while no
307
+ run starts. A record whose process id now belongs to another process counts as
308
+ running until that process ends.
268
309
 
269
310
  ### Answering a waiting run from outside
270
311
 
271
312
  With publication on, a waiting run can be seen and answered by any process
272
313
  running as the same user: a terminal, a coordinating agent, a script, or a
273
314
  presentation surface. They all read the same request, and the first valid
274
- response wins. Set the same `MACHINES_RUN_STATUS_DIR` for the run and for
275
- whatever answers it.
315
+ response wins. With the standard directory there is nothing to set; if you move
316
+ it, set the same `MACHINES_RUN_STATUS_DIR` for the run and for whatever answers
317
+ it. With publication off these commands fail and say how to turn it on.
276
318
 
277
319
  ```bash
278
320
  machine runs # running and waiting runs
@@ -287,7 +329,8 @@ waiting run its prompt, request id, and choices (tab-separated when its
287
329
  output is not a terminal). A run's label follows as a fifth column when it has
288
330
  one, and a sixth says `watched` for a run this session
289
331
  [watches](#watching-other-runs). It lists the running and waiting runs of every
290
- session and project. `--all`, with or without `--json`, lists every published
332
+ session and project; `--for` narrows it to
333
+ [what one viewer shows](#drawing-your-own-widgets). `--all`, with or without `--json`, lists every published
291
334
  run: also the ones whose records remain after they finished, as `completed` or
292
335
  `failed`, or after their process disappeared, as `lost` (in JSON such a record
293
336
  keeps the status it last published). A run answered through its host shows
@@ -330,17 +373,25 @@ waits, it holds the same file under a second name of its own,
330
373
  response and the sender removes it to take the response back, so exactly one of
331
374
  them succeeds and neither can act on a response that arrived later. A response
332
375
  that was taken back stays in the inbox, emptied, until the run next reads its
333
- inbox or finishes, and a new response is refused until then. **Anything that can write to the directory as you can answer a waiting
334
- run**, including approvals, so do not point `MACHINES_RUN_STATUS_DIR` at a
335
- shared directory.
376
+ inbox or finishes, and a new response is refused until then.
377
+
378
+ **Anything that can write to the directory as you can answer a waiting run**,
379
+ including approvals. That is why the standard directory is a private per-user
380
+ one, created `0700`: no other user can read a request or leave a response. It
381
+ is also all that protects a waiting run, so do not loosen its permissions and do
382
+ not point `MACHINES_RUN_STATUS_DIR` at a shared directory. Set it to `off`
383
+ where no process of yours should be able to answer a run from outside it.
336
384
 
337
385
  `machine run` still asks on its own terminal, and whichever of the terminal and
338
386
  the inbox gives the first valid response is used. Terminal input outside a
339
387
  request's `choices` is reported and asked for again instead of failing the run.
340
388
  A published run started without terminal input, for example by a coordinating
341
389
  agent or a script, waits for `machine respond` instead of failing, and prints
342
- the exact command to its standard error. With publication off it fails at its
343
- first Human state with "Terminal input closed before a response", as before.
390
+ the exact command to its standard error. With publication on by default this is
391
+ what a script or a CI job gets as well. With publication off, or where the run
392
+ could not be published, it fails at its first Human state with "Terminal input
393
+ closed before a response": set `MACHINES_RUN_STATUS_DIR=off` for a run that
394
+ should fail there instead of waiting.
344
395
  Runs hosted in any other program through `startMachineHost` are answered only
345
396
  through that program.
346
397
  Runs hosted by the MCP server take a response from their own `machine_respond`
@@ -348,12 +399,13 @@ tool or from the inbox. That tool is answer-only, so a question to a hosted
348
399
  request that allows discussion goes through the inbox with
349
400
  `machine respond --question`.
350
401
 
351
- For Claude Code, set the variable in the `env` of its settings so the runs it
352
- launches publish there, and call the dependency-free renderer from `statusLine`:
402
+ For Claude Code, call the dependency-free renderer from `statusLine`. That is
403
+ the whole setup: the runs Claude Code launches, a daemon that hosts runs as the
404
+ same user, and the status line all use the standard directory, so there is no
405
+ `env` block to add and no path to repeat anywhere.
353
406
 
354
407
  ```json
355
408
  {
356
- "env": { "MACHINES_RUN_STATUS_DIR": "/home/you/.cache/machines/run-status" },
357
409
  "statusLine": {
358
410
  "type": "command",
359
411
  "command": "node /path/to/machines/claude/statusline.mjs",
@@ -397,8 +449,8 @@ to change the 120-column truncation.
397
449
  A session can pull in runs it did not start: a ticket that a daemon runs, or a
398
450
  run started by another session or under another project. Nothing is configured.
399
451
  The session is identified as a run's `owner` is, by `MACHINES_RUN_OWNER` and
400
- otherwise `CLAUDE_CODE_SESSION_ID`, so an agent inside Claude Code only runs the
401
- command.
452
+ otherwise by the session id its coding agent gives it, so an agent inside Claude
453
+ Code, Codex, or Pi only runs the command.
402
454
 
403
455
  ```bash
404
456
  machine runs # running and waiting runs, with their labels
@@ -407,6 +459,7 @@ machine watch org-9 # runs whose id or label starts with org-9
407
459
  machine watch --dir ../other # runs working in that directory or below it
408
460
  machine watch # what this session watches, and the published runs each entry matches now
409
461
  machine unwatch org-9 # also: unwatch --dir <path>, unwatch --all
462
+ machine watch --for <session> org-9 # the same commands for another session's list
410
463
  ```
411
464
 
412
465
  - `machine watch <text>` names every top-level run whose id starts with the
@@ -420,6 +473,10 @@ machine unwatch org-9 # also: unwatch --dir <path>, unwatch --all
420
473
  before its run exists. `machine watch` lists a matched run for as long as its
421
474
  record remains, with the status `machine runs --all` gives it, so it also
422
475
  says how a watched run ended.
476
+ - `--for <session>`, first, makes `machine watch` and `machine unwatch` read
477
+ and change the list of the session it names instead of this process's own.
478
+ A program that draws [its own widgets](#drawing-your-own-widgets) for a
479
+ viewer manages that viewer's list this way, without setting a variable.
423
480
 
424
481
  A watched run is shown even when another session owns it or it works outside
425
482
  the project. Everything else is as for any run: sub-runs stay hidden, a run is
@@ -435,10 +492,125 @@ status line reads itself with the `session_id` Claude Code gives it:
435
492
 
436
493
  Record readers skip it, as they skip every name that starts with a dot. A
437
494
  session id that is not safe as a file name is refused. Removing the last entry
438
- removes the file, and writing a list removes other sessions' lists that have
439
- not changed for 30 days. A missing, unreadable, or malformed list watches
495
+ removes the file, and writing a list or starting a run removes other sessions'
496
+ lists that have not changed for 30 days. A missing, unreadable, or malformed list watches
440
497
  nothing; it is never an error on the status line. The commands fail with a
441
- message when publication is off or when no variable names the session.
498
+ message when publication is off or when nothing names the session.
499
+
500
+ ### Drawing your own widgets
501
+
502
+ Any application can show Machine runs in its own way from what is already
503
+ published: an extension for Codex or Pi, a web app, an editor panel. The Claude
504
+ Code status line above is one such consumer. Nothing is configured and nothing
505
+ new runs; there are three ways to read the same directory:
506
+
507
+ | Read with | You get |
508
+ | --- | --- |
509
+ | `machine runs --for <session> --json` | The runs one viewer shows, each ready to draw |
510
+ | `@dna113p/machines/run-status` | The same rule and the readers, in your own process |
511
+ | The files | The [records](#run-status-for-host-status-lines) and [watch lists](#watching-other-runs) themselves; the rule is then yours to apply |
512
+
513
+ A viewer is one surface that shows runs: a session of a coding agent, a browser
514
+ tab, a status line. It is named by a session id and may have a project
515
+ directory. The rule that decides what it shows is the status line's:
516
+
517
+ - only top-level runs: a run started from inside another Machine is never shown;
518
+ - the runs its session owns, unowned runs working in its project directory or
519
+ below it, and the runs its session [watches](#watching-other-runs), whoever
520
+ owns them;
521
+ - a run recorded as running or waiting whose process is gone is `lost`;
522
+ - a finished or lost run stays for 30 seconds and then drops out;
523
+ - each run once, the ones that can still change first, then the most recently
524
+ updated. Runs updated in the same millisecond keep the order they were read
525
+ in from the directory, for the command, the library, and the status line
526
+ alike.
527
+
528
+ Both ways below take a snapshot. Read again to refresh, as the status line does
529
+ every few seconds, and read where the runs' processes can be seen, or every run
530
+ looks `lost`.
531
+
532
+ ```bash
533
+ machine runs --for "$SESSION" --project "$PWD" --json # what that viewer shows
534
+ machine runs --for --json # for this process's own session
535
+ machine runs --for "$SESSION" --all # as text, and with older finished and lost runs
536
+ machine watch --for "$SESSION" org-9 # change what that viewer watches
537
+ ```
538
+
539
+ `--for` takes the viewer's session id; with no id after it, the viewer is this
540
+ process's own session, and the command fails when nothing names one.
541
+ `--project` is the viewer's project directory, compared as an absolute path;
542
+ without it only owned and watched runs are selected. `--all` also keeps the
543
+ finished and lost runs that the 30 seconds would have dropped, for as long as
544
+ their records remain. Without `--json` the runs are printed in the columns of
545
+ `machine runs`. Without `--for`, `machine runs` is unchanged: every session's
546
+ running and waiting runs, as plain records.
547
+
548
+ Each item is the published record with three additions:
549
+
550
+ ```json
551
+ {
552
+ "schemaVersion": 2, "id": "1a2b3c4d-…", "pid": 4242, "owner": "<session id>",
553
+ "machine": "release", "path": "/work/.machines/release.ts", "cwd": "/work",
554
+ "status": "waiting", "state": "approve",
555
+ "human": { "prompt": "Publish version 0.4.0?", "requestId": "9f8e7d6c-…", "choices": ["publish", "hold"] },
556
+ "startedAt": "2026-03-04T05:06:07.000Z", "updatedAt": "2026-03-04T05:08:12.000Z",
557
+ "shown": "owned",
558
+ "answer": { "via": "respond", "runId": "1a2b3c4d-…", "requestId": "9f8e7d6c-…" }
559
+ }
560
+ ```
561
+
562
+ - `status` is the effective one: what the record says, or `lost`.
563
+ - `shown` says why the viewer shows the run: `owned`, `project`, or `watched`,
564
+ the first of them that holds.
565
+ - `answer` is present while the run waits on a Human request that something
566
+ can answer. `"via": "respond"` carries the two ids for
567
+ `machine respond <runId> <requestId> <response>`; `"via": "host"` means only
568
+ the program hosting the run can answer it, so a widget shows the prompt and
569
+ offers no input. `human` still has the prompt, choices, and suggestions.
570
+
571
+ Nothing is added for presentation: no colour, layout, or shortening, and no
572
+ limit on how many runs are returned. Recorded text is the run's own, so escape
573
+ it for your surface as the status line strips control characters.
574
+
575
+ The library gives the same answer without starting a process:
576
+
577
+ ```ts
578
+ import {
579
+ readRunStatuses, readRunWatches, runStatusDirectory, runsForViewer,
580
+ } from "@dna113p/machines/run-status";
581
+
582
+ const directory = runStatusDirectory(); // undefined while publication is off
583
+ const runs = directory === undefined ? [] : runsForViewer(readRunStatuses(directory), {
584
+ sessionId,
585
+ projectDir: "/work/project",
586
+ watches: readRunWatches(directory, sessionId),
587
+ });
588
+ for (const run of runs) draw(run.label ?? run.id, run.status, run.shown, run.answer);
589
+ ```
590
+
591
+ `runsForViewer(records, viewer, options)` is pure apart from probing whether
592
+ each process is alive; `options` takes `now`, `all`, and an `isAlive` of your
593
+ own. The module also exports `runSessionId`, the types of a record, a watch, a
594
+ viewer, and an item, and `settledRunVisibleMilliseconds`. It only reads:
595
+ publishing stays with the runtime, and a viewer's watch list is changed with
596
+ `machine watch --for`.
597
+
598
+ **Naming the session.** A viewer shows the runs whose `owner` is its session
599
+ id, so the host and the runs it starts must agree on that id. Any host can set
600
+ `MACHINES_RUN_OWNER` in the environment of the commands it starts and pass the
601
+ same value to `--for`; the id may use letters, digits, `.`, `_`, and `-`. Where
602
+ it is not set, the id is the one the coding agent already gives its commands:
603
+
604
+ | Host | Variable | Given to |
605
+ | --- | --- | --- |
606
+ | Any | `MACHINES_RUN_OWNER` | Whatever the host starts with it set; takes precedence |
607
+ | Claude Code | `CLAUDE_CODE_SESSION_ID` | The commands a session runs; also the `session_id` its status line receives |
608
+ | Codex | `CODEX_THREAD_ID` | The commands a thread runs |
609
+ | Pi | `PI_SESSION_ID` | The commands its `bash` and `powershell` tools run, not `!` commands typed by the user |
610
+
611
+ The first one that is set names the session, in that order. A run started from
612
+ inside one of these agents is therefore owned by that session: it is shown
613
+ there, and in another session only when that session watches it.
442
614
 
443
615
  ## Troubleshooting
444
616
 
@@ -446,6 +618,8 @@ For an isolated global catalog, set `MACHINES_USER_HOME` to an absolute director
446
618
  Machines reads global definitions and presets from that directory's `.machines/`.
447
619
  Explicit programmatic `home` options take precedence. This is useful for tests or
448
620
  separate workflow collections without changing the operating-system home directory.
621
+ A process with that variable also publishes its
622
+ [run status](#run-status-for-host-status-lines) under that directory.
449
623
 
450
624
  - **Node cannot load TypeScript from a dependency:** install the built tarball;
451
625
  source exports are for checkout development. Keep workflow definitions outside
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dna113p/machines",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Reusable workflows coordinating agents, local operations, and human decisions.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,7 +36,8 @@
36
36
  "./host": { "types": "./dist/src/host.d.ts", "import": "./dist/src/host.js" },
37
37
  "./launcher": { "types": "./dist/src/launcher.d.ts", "import": "./dist/src/launcher.js" },
38
38
  "./mcp": { "types": "./dist/mcp/server.d.ts", "import": "./dist/mcp/server.js" },
39
- "./pi-extension": { "types": "./dist/pi-extension/index.d.ts", "import": "./dist/pi-extension/index.js" }
39
+ "./pi-extension": { "types": "./dist/pi-extension/index.d.ts", "import": "./dist/pi-extension/index.js" },
40
+ "./run-status": { "types": "./dist/src/run-viewer.d.ts", "import": "./dist/src/run-viewer.js" }
40
41
  },
41
42
  "files": ["dist", "claude/statusline.mjs", "docs/architecture.md", "docs/authoring.md", "docs/integrations.md", "docs/local-decisions.md", "services/laya/server.py", "skills", "LICENSE", "README.md"],
42
43
  "scripts": {