@dna113p/machines 0.3.3 → 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.3.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.3.tgz
182
+ npm install /absolute/path/to/machines/dna113p-machines-0.3.4.tgz
183
183
  npx machine list
184
184
  ```
185
185
 
@@ -201,6 +201,7 @@ 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
@@ -215,6 +216,10 @@ Runs hosted through `startMachineHost` publish too and are answered through thei
215
216
  host. A session shows the runs it started; `machine watch` adds runs it did not,
216
217
  such as a ticket a daemon runs. See
217
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.
218
223
 
219
224
  ## Execution model and limitations
220
225
 
@@ -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";
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
  }
@@ -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
  }
@@ -129,7 +129,9 @@ export declare function runStatusObserver(update: (change: RunStatusChange) => v
129
129
  };
130
130
  /**
131
131
  * The session a process belongs to: the owner of the runs it starts, and the
132
- * 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.
133
135
  */
134
136
  export declare function runSessionId(environment?: NodeJS.ProcessEnv): string | undefined;
135
137
  /** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
@@ -228,10 +228,16 @@ export function runStatusObserver(update) {
228
228
  }
229
229
  /**
230
230
  * The session a process belongs to: the owner of the runs it starts, and the
231
- * 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.
232
234
  */
233
235
  export function runSessionId(environment = process.env) {
234
- 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;
235
241
  }
236
242
  /** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
237
243
  export function readRunStatuses(directory) {
@@ -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;
@@ -52,18 +52,22 @@ export function writeRunWatches(directory, sessionId, watches) {
52
52
  export function runWatchMatches(watch, record) {
53
53
  if (record.parent !== undefined)
54
54
  return false;
55
- if ("dir" in watch) {
56
- if (!isAbsolute(record.cwd))
57
- return false;
58
- const cwd = resolve(record.cwd);
59
- return cwd === watch.dir || cwd.startsWith(watch.dir.endsWith(sep) ? watch.dir : `${watch.dir}${sep}`);
60
- }
55
+ if ("dir" in watch)
56
+ return isWithinDirectory(record.cwd, watch.dir);
61
57
  if (record.id.startsWith(watch.text))
62
58
  return true;
63
59
  // The label must start with the whole text, not with a longer word that begins like it.
64
60
  const next = record.label?.startsWith(watch.text) ? record.label.slice(watch.text.length) : undefined;
65
61
  return next !== undefined && !/^[\p{L}\p{N}_-]/u.test(next);
66
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
+ }
67
71
  /** Checks and normalizes what `machine watch` was given. */
68
72
  export function runWatch(input) {
69
73
  if ("dir" in input) {
@@ -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 |
@@ -120,6 +122,14 @@ only way to answer it, its record says so, and `machine respond` refuses it. See
120
122
  A session's watch list changes only which published runs its status line shows.
121
123
  It gives no ownership of a run and no way to answer one.
122
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
+
123
133
  Closing a session terminates active and starting hosts. Host termination includes
124
134
  ordinary descendants in the host's owned process group on POSIX and process-tree
125
135
  termination on Windows. Deliberately detached processes can escape normal ownership;
@@ -209,8 +209,11 @@ directory is created by the first write, as `0700`, and each file is `0600`:
209
209
  `status` is `running`, `waiting`, `completed`, or `failed`. `state` is the current
210
210
  state as a string (JSON for a nested state). `agent` is present while an Agent
211
211
  state has reported its identity, and `error` after a failure. `owner` is
212
- `MACHINES_RUN_OWNER` when set, otherwise `CLAUDE_CODE_SESSION_ID`, otherwise
213
- 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
214
217
  line of `MACHINES_RUN_LABEL` (at most 160 characters) when a launcher sets it to
215
218
  say what the run is for, such as a ticket id and title; it is the launcher's
216
219
  choice to publish that text. `machine run` exports its run id as
@@ -326,7 +329,8 @@ waiting run its prompt, request id, and choices (tab-separated when its
326
329
  output is not a terminal). A run's label follows as a fifth column when it has
327
330
  one, and a sixth says `watched` for a run this session
328
331
  [watches](#watching-other-runs). It lists the running and waiting runs of every
329
- 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
330
334
  run: also the ones whose records remain after they finished, as `completed` or
331
335
  `failed`, or after their process disappeared, as `lost` (in JSON such a record
332
336
  keeps the status it last published). A run answered through its host shows
@@ -445,8 +449,8 @@ to change the 120-column truncation.
445
449
  A session can pull in runs it did not start: a ticket that a daemon runs, or a
446
450
  run started by another session or under another project. Nothing is configured.
447
451
  The session is identified as a run's `owner` is, by `MACHINES_RUN_OWNER` and
448
- otherwise `CLAUDE_CODE_SESSION_ID`, so an agent inside Claude Code only runs the
449
- 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.
450
454
 
451
455
  ```bash
452
456
  machine runs # running and waiting runs, with their labels
@@ -455,6 +459,7 @@ machine watch org-9 # runs whose id or label starts with org-9
455
459
  machine watch --dir ../other # runs working in that directory or below it
456
460
  machine watch # what this session watches, and the published runs each entry matches now
457
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
458
463
  ```
459
464
 
460
465
  - `machine watch <text>` names every top-level run whose id starts with the
@@ -468,6 +473,10 @@ machine unwatch org-9 # also: unwatch --dir <path>, unwatch --all
468
473
  before its run exists. `machine watch` lists a matched run for as long as its
469
474
  record remains, with the status `machine runs --all` gives it, so it also
470
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.
471
480
 
472
481
  A watched run is shown even when another session owns it or it works outside
473
482
  the project. Everything else is as for any run: sub-runs stay hidden, a run is
@@ -486,7 +495,122 @@ session id that is not safe as a file name is refused. Removing the last entry
486
495
  removes the file, and writing a list or starting a run removes other sessions'
487
496
  lists that have not changed for 30 days. A missing, unreadable, or malformed list watches
488
497
  nothing; it is never an error on the status line. The commands fail with a
489
- 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.
490
614
 
491
615
  ## Troubleshooting
492
616
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dna113p/machines",
3
- "version": "0.3.3",
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": {