@dna113p/machines 0.3.0 → 0.3.2

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.0.tgz` into a project:
179
+ Then install the generated `dna113p-machines-0.3.2.tgz` into a project:
180
180
 
181
181
  ```bash
182
- npm install /absolute/path/to/machines/dna113p-machines-0.3.0.tgz
182
+ npm install /absolute/path/to/machines/dna113p-machines-0.3.2.tgz
183
183
  npx machine list
184
184
  ```
185
185
 
@@ -198,8 +198,10 @@ read. A published run that is waiting for a Human can then be answered from
198
198
  outside its own terminal:
199
199
 
200
200
  ```bash
201
- machine runs [--json] # running and waiting runs
201
+ machine runs [--json] [--all] # running and waiting runs; --all adds finished and lost ones
202
202
  machine respond <run-id> <request-id> <response> # answer a waiting run
203
+ machine watch [<run-id-or-label-start> | --dir <path>] # show other runs on this session's status line
204
+ machine unwatch <run-id-or-label-start> | --dir <path> | --all
203
205
  ```
204
206
 
205
207
  `machine runs` shows each waiting run's prompt, request id, and choices. A run
@@ -207,6 +209,10 @@ started without a terminal, for example by a coordinating agent or a script,
207
209
  waits for `machine respond` instead of failing. Anything running as the same user
208
210
  can answer a waiting run, including an approval; see
209
211
  [answering a waiting run from outside](docs/integrations.md#answering-a-waiting-run-from-outside).
212
+ Runs hosted through `startMachineHost` publish too and are answered through their
213
+ host. A session shows the runs it started; `machine watch` adds runs it did not,
214
+ such as a ticket a daemon runs. See
215
+ [watching other runs](docs/integrations.md#watching-other-runs).
210
216
 
211
217
  ## Execution model and limitations
212
218
 
@@ -280,18 +286,23 @@ npm run smoke:plugin # exercise a relocated standalone plugin
280
286
 
281
287
  The [publishing workflow](.github/workflows/publish.yml) uses a standard GitHub
282
288
  runner and npm trusted publishing, without an npm token stored in GitHub.
283
- Pushing a `vX.Y.Z` tag runs the checks above and publishes to npm if they pass.
284
- The tag must match the stable version in `package.json`.
289
+ A release is a change to the version in `package.json`. When `main` receives a
290
+ stable version that npm does not have yet, the workflow runs the checks above,
291
+ publishes to npm if they pass, and tags the commit `vX.Y.Z`. Every other push to
292
+ `main` publishes nothing.
285
293
 
286
- For example, to release the next minor version from an up-to-date, clean `main`:
294
+ For example, to release the next minor version, bump it on a branch and merge
295
+ that branch:
287
296
 
288
297
  ```bash
289
- npm version minor
290
- git push origin main --follow-tags
298
+ npm version minor --no-git-tag-version
299
+ git commit -am "Release X.Y.0"
291
300
  ```
292
301
 
293
- Running **Publish to npm** manually in GitHub Actions performs the checks and a
294
- publishing dry run; it does not publish a package.
302
+ Pushing a `vX.Y.Z` tag yourself still publishes that version if npm lacks it;
303
+ the tag must match the stable version in `package.json`. Running **Publish to
304
+ npm** manually in GitHub Actions performs the checks and a publishing dry run;
305
+ it does not publish a package.
295
306
 
296
307
  Licensed under the [MIT License](LICENSE).
297
308
 
@@ -3,7 +3,8 @@
3
3
  // Claude Code status line for Machine runs published through
4
4
  // MACHINES_RUN_STATUS_DIR (see docs/integrations.md). It runs on every status
5
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.
6
+ // record shape below mirrors RunStatusRecord in src/run-status.ts, and the
7
+ // watch list and its matching mirror src/run-watch.ts.
7
8
 
8
9
  import { readdirSync, readFileSync, realpathSync } from "node:fs";
9
10
  import { isAbsolute, join, resolve, sep } from "node:path";
@@ -27,6 +28,7 @@ export function runStatusLines(records, options = {}) {
27
28
  const {
28
29
  sessionId,
29
30
  projectDir,
31
+ watches = [],
30
32
  now = Date.now(),
31
33
  columns = defaultColumns,
32
34
  color = true,
@@ -38,7 +40,9 @@ export function runStatusLines(records, options = {}) {
38
40
  const visible = [];
39
41
  for (const record of records) {
40
42
  // Sub-runs belong to the run that started them, not to the session.
41
- if (!isRecord(record) || record.parent !== undefined || !isVisibleTo(record, sessionId, projectDir)) continue;
43
+ if (!isRecord(record) || record.parent !== undefined) continue;
44
+ // A run the session asked to watch is shown whoever owns it and wherever it works.
45
+ if (!isVisibleTo(record, sessionId, projectDir) && !watches.some((watch) => isWatched(record, watch))) continue;
42
46
  const updatedAt = Date.parse(record.updatedAt);
43
47
  const recorded = record.status === "running" || record.status === "waiting";
44
48
  // A run that stopped reporting without finishing has lost its process.
@@ -91,9 +95,25 @@ export function runStatusLines(records, options = {}) {
91
95
  function isVisibleTo(record, sessionId, projectDir) {
92
96
  if (record.owner !== undefined) return typeof sessionId === "string" && record.owner === sessionId;
93
97
  if (typeof projectDir !== "string" || !isAbsolute(projectDir) || !isAbsolute(record.cwd)) return false;
94
- const project = resolve(projectDir);
95
- const cwd = resolve(record.cwd);
96
- return cwd === project || cwd.startsWith(project.endsWith(sep) ? project : `${project}${sep}`);
98
+ return isInside(record.cwd, projectDir);
99
+ }
100
+
101
+ function isInside(path, directory) {
102
+ const outer = resolve(directory);
103
+ const inner = resolve(path);
104
+ return inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : `${outer}${sep}`);
105
+ }
106
+
107
+ function isWatched(record, watch) {
108
+ if (watch === null || typeof watch !== "object") return false;
109
+ const { text, dir } = watch;
110
+ if (typeof dir === "string" && text === undefined) {
111
+ return isAbsolute(dir) && isAbsolute(record.cwd) && isInside(record.cwd, dir);
112
+ }
113
+ if (typeof text !== "string" || text === "" || dir !== undefined) return false;
114
+ if (record.id.startsWith(text)) return true;
115
+ // The label must start with the whole text, not with a longer word that begins like it.
116
+ return record.label?.startsWith(text) === true && !/^[\p{L}\p{N}_-]/u.test(record.label.slice(text.length));
97
117
  }
98
118
 
99
119
  function isRecord(value) {
@@ -204,6 +224,18 @@ function readRecords(directory) {
204
224
  return records;
205
225
  }
206
226
 
227
+ /** What the session asked to watch with `machine watch`; nothing when it has no readable list. */
228
+ function readWatches(directory, sessionId) {
229
+ // The id is part of the file name, so it must not be able to leave the directory.
230
+ if (typeof sessionId !== "string" || !/^[A-Za-z0-9_-][A-Za-z0-9._-]*$/u.test(sessionId)) return [];
231
+ try {
232
+ const { watches } = JSON.parse(readFileSync(join(directory, `.watch.${sessionId}.json`), "utf8"));
233
+ return Array.isArray(watches) ? watches : [];
234
+ } catch {
235
+ return [];
236
+ }
237
+ }
238
+
207
239
  async function main() {
208
240
  const directory = process.env.MACHINES_RUN_STATUS_DIR;
209
241
  if (directory === undefined || directory === "" || process.stdin.isTTY) return;
@@ -217,6 +249,7 @@ async function main() {
217
249
  const columns = /^[0-9]+$/u.test(process.env.COLUMNS ?? "") ? Number(process.env.COLUMNS) : defaultColumns;
218
250
  const lines = runStatusLines(readRecords(directory), {
219
251
  sessionId: input.session_id,
252
+ watches: readWatches(directory, input.session_id),
220
253
  projectDir: [input.workspace?.project_dir, input.workspace?.current_dir, input.cwd]
221
254
  .find((path) => typeof path === "string" && path !== ""),
222
255
  columns,
@@ -176,7 +176,8 @@ export class MachineSession {
176
176
  return result(text, { agents });
177
177
  }
178
178
  async start(input) {
179
- const run = await this.#session.start(parse(startInput, input));
179
+ // This server publishes its runs itself, so that they also take responses through their inbox.
180
+ const run = await this.#session.start({ ...parse(startInput, input), publishRunStatus: "caller" });
180
181
  return result(`Started ${run.machine}.\nRun id: ${run.id}\nStatus: ${run.status}`, { run });
181
182
  }
182
183
  status(input) {
package/dist/src/cli.js CHANGED
@@ -10,8 +10,12 @@ import { assertJsonValue, isJsonValue } from "./json.js";
10
10
  import { discoverMachines, machinesUserHome } from "./discovery.js";
11
11
  import { listAgentPresets, listMachines, prepareMachineRun, } from "./launcher.js";
12
12
  import { inboxHuman, respondToRun } from "./run-inbox.js";
13
- import { createRunStatusPublisher, isProcessAlive, readRunStatuses, runStatusDirectory, } from "./run-status.js";
13
+ import { createRunStatusPublisher, isProcessAlive, readRunStatuses, runSessionId, runStatusDirectory, runStatusObserver, } from "./run-status.js";
14
+ import { readRunWatches, runWatch, runWatchMatches, runWatchPath, sameRunWatch, writeRunWatches, } from "./run-watch.js";
14
15
  const [command, selector, ...argumentsAfterSelector] = process.argv.slice(2);
16
+ const runsOptions = selector === undefined ? [] : [selector, ...argumentsAfterSelector];
17
+ const watchUsage = "machine watch [<run-id-or-label-start> | --dir <path>]";
18
+ const unwatchUsage = "machine unwatch <run-id-or-label-start> | --dir <path> | --all";
15
19
  try {
16
20
  if (command === "list" && selector === undefined) {
17
21
  const machines = await listMachines({ cwd: process.cwd() });
@@ -39,12 +43,19 @@ try {
39
43
  else if (command === "run" && selector !== undefined) {
40
44
  await runMachine(await resolveMachine(selector), argumentsAfterSelector);
41
45
  }
42
- else if (command === "runs" && (selector === undefined || selector === "--json") && argumentsAfterSelector.length === 0) {
43
- listRuns(selector === "--json");
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"));
44
49
  }
45
50
  else if (command === "respond" && selector !== undefined) {
46
51
  await respond([selector, ...argumentsAfterSelector]);
47
52
  }
53
+ else if (command === "watch") {
54
+ watch(selector === undefined ? [] : [selector, ...argumentsAfterSelector]);
55
+ }
56
+ else if (command === "unwatch" && selector !== undefined) {
57
+ unwatch([selector, ...argumentsAfterSelector]);
58
+ }
48
59
  else {
49
60
  throw new Error([
50
61
  "Usage:",
@@ -52,8 +63,10 @@ try {
52
63
  " machine agents",
53
64
  " machine show <name-or-file>",
54
65
  " machine run <name-or-file> [--agent role=preset]... [--input-file <file|->] [--] [input]",
55
- " machine runs [--json]",
66
+ " machine runs [--json] [--all]",
56
67
  " machine respond [--question] <run-id> <request-id> [--] <response>",
68
+ ` ${watchUsage}`,
69
+ ` ${unwatchUsage}`,
57
70
  ].join("\n"));
58
71
  }
59
72
  }
@@ -87,31 +100,11 @@ async function runMachine(machinePath, args) {
87
100
  const statusDirectory = runStatusDirectory();
88
101
  // Machines started by this run's Operations and Agents are its sub-runs.
89
102
  process.env.MACHINES_RUN_PARENT = runId;
90
- let publishedState;
91
- // A runner reports its identity as it starts. For a state entered by a
92
- // transition that is before the state is announced, so an identity is held
93
- // for the rest of the tick and published with the state it belongs to.
94
- let arriving;
103
+ const observed = runStatusObserver((change) => published.update(change));
95
104
  try {
96
105
  const result = await prepared.start({
97
106
  onAgentUpdate(update) {
98
- if (update.type === "identity") {
99
- arriving = update;
100
- queueMicrotask(() => {
101
- if (arriving !== update)
102
- return;
103
- arriving = undefined;
104
- published.update({ agent: update });
105
- });
106
- }
107
- else if (update.type === "limit" && update.status !== "swapped") {
108
- // A swap while every preset is being tried again leaves the wait as it is published.
109
- published.update({
110
- limit: update.status === "resumed"
111
- ? undefined
112
- : { text: update.text, ...(update.until === undefined ? {} : { until: update.until }) },
113
- });
114
- }
107
+ observed.onAgentUpdate(update);
115
108
  status.onAgentUpdate(update);
116
109
  },
117
110
  // A published run can also be answered through its inbox by `machine respond`.
@@ -134,14 +127,7 @@ async function runMachine(machinePath, args) {
134
127
  status.onHumanInput(active);
135
128
  },
136
129
  onState(state) {
137
- const entered = formatState(state);
138
- if (entered === publishedState)
139
- published.update({ state });
140
- else {
141
- published.update({ state, agent: arriving });
142
- arriving = undefined;
143
- }
144
- publishedState = entered;
130
+ observed.onState(state);
145
131
  status.onState(state);
146
132
  },
147
133
  });
@@ -161,20 +147,104 @@ async function runMachine(machinePath, args) {
161
147
  status.close();
162
148
  }
163
149
  }
164
- function listRuns(json) {
165
- const runs = readRunStatuses(requireStatusDirectory())
166
- .filter((record) => (record.status === "running" || record.status === "waiting") && isProcessAlive(record.pid))
167
- .sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt));
150
+ function listRuns(json, all) {
151
+ const directory = requireStatusDirectory();
152
+ const published = publishedRuns(directory);
153
+ // Without --all, only the runs that can still change or be answered.
154
+ const runs = all ? published : published.filter(isActiveRun);
168
155
  if (json) {
169
156
  console.log(JSON.stringify(runs, null, 2));
170
157
  return;
171
158
  }
172
159
  if (runs.length === 0)
173
- console.log("No Machine runs are running or waiting.");
160
+ console.log(all ? "No Machine runs are published." : "No Machine runs are running or waiting.");
161
+ const session = runSessionId();
162
+ const watches = session === undefined ? [] : readRunWatches(directory, session);
174
163
  runs.forEach((record, index) => {
175
- printRunListing(record, index > 0);
164
+ printRunListing(record, index > 0, watches.some((watched) => runWatchMatches(watched, record)));
176
165
  });
177
166
  }
167
+ /** Every record in the directory, including runs that finished or lost their process and are still recorded. */
168
+ function publishedRuns(directory) {
169
+ return readRunStatuses(directory).sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt));
170
+ }
171
+ /** A run that stopped reporting without finishing has lost its process, as on the status line. */
172
+ function runStatus(record) {
173
+ return record.status !== "completed" && record.status !== "failed" && !isProcessAlive(record.pid) ? "lost" : record.status;
174
+ }
175
+ function isActiveRun(record) {
176
+ const status = runStatus(record);
177
+ return status === "running" || status === "waiting";
178
+ }
179
+ function watch(args) {
180
+ const { directory, session } = requireWatchList();
181
+ const watches = readRunWatches(directory, session);
182
+ const runs = publishedRuns(directory);
183
+ if (args.length === 0) {
184
+ if (watches.length === 0)
185
+ console.log("This session watches no other runs.");
186
+ for (const watched of watches) {
187
+ console.log(describeWatch(watched));
188
+ printWatchedRuns(watched, runs);
189
+ }
190
+ return;
191
+ }
192
+ const added = parseWatch(args, watchUsage);
193
+ if (!watches.some((watched) => sameRunWatch(watched, added)))
194
+ writeRunWatches(directory, session, [...watches, added]);
195
+ console.log(`Watching ${describeWatch(added)}`);
196
+ printWatchedRuns(added, runs);
197
+ }
198
+ function unwatch(args) {
199
+ const { directory, session } = requireWatchList();
200
+ if (args[0] === "--all") {
201
+ if (args.length !== 1)
202
+ throw new Error(`Usage: ${unwatchUsage}`);
203
+ writeRunWatches(directory, session, []);
204
+ console.log("This session watches no other runs.");
205
+ return;
206
+ }
207
+ const removed = parseWatch(args, unwatchUsage);
208
+ const watches = readRunWatches(directory, session);
209
+ const kept = watches.filter((watched) => !sameRunWatch(watched, removed));
210
+ if (kept.length === watches.length) {
211
+ console.log(`This session was not watching ${describeWatch(removed)}`);
212
+ return;
213
+ }
214
+ writeRunWatches(directory, session, kept);
215
+ console.log(`No longer watching ${describeWatch(removed)}`);
216
+ }
217
+ function parseWatch(args, usage) {
218
+ if (args[0] === "--dir") {
219
+ if (args.length !== 2)
220
+ throw new Error(`Usage: ${usage}`);
221
+ return runWatch({ dir: args[1] });
222
+ }
223
+ if (args[0] !== "--" && args[0]?.startsWith("--") === true)
224
+ throw new Error(`Usage: ${usage}`);
225
+ return runWatch({ text: (args[0] === "--" ? args.slice(1) : args).join(" ") });
226
+ }
227
+ function describeWatch(watched) {
228
+ return "dir" in watched ? `--dir ${watched.dir}` : watched.text;
229
+ }
230
+ function printWatchedRuns(watched, runs) {
231
+ const matched = runs.filter((record) => runWatchMatches(watched, record));
232
+ if (matched.length === 0)
233
+ console.log(" no published run matches yet");
234
+ for (const record of matched)
235
+ console.log(` ${runColumns(record).join("\t")}`);
236
+ }
237
+ /** The session whose watch list this process changes, and where the list is kept. */
238
+ function requireWatchList() {
239
+ const directory = requireStatusDirectory();
240
+ const session = runSessionId();
241
+ if (session === undefined) {
242
+ 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
+ }
244
+ // Refuses a session id that cannot name a file.
245
+ runWatchPath(directory, session);
246
+ return { directory, session };
247
+ }
178
248
  async function respond(args) {
179
249
  const question = args[0] === "--question";
180
250
  const [runId, requestId, ...words] = question ? args.slice(1) : args;
@@ -301,30 +371,48 @@ function printAgentListing(preset, separate) {
301
371
  console.log(` fallback: ${preset.fallback.join(", ") || "none"}`);
302
372
  console.log(` ${dim(preset.source)}`);
303
373
  }
304
- function printRunListing(record, separate) {
374
+ /** Recorded text must not be able to move the cursor or restyle the terminal. */
375
+ function plain(text) {
376
+ return text.replace(/[\u0000-\u001f\u007f-\u009f]+/gu, " ").trim();
377
+ }
378
+ /** A run as tab-separated columns: its label follows the four that were always there. */
379
+ function runColumns(record) {
380
+ const label = record.label === undefined ? "" : plain(record.label);
381
+ return [record.id, record.machine, runStatus(record), record.state ?? "starting"].map(plain).concat(label === "" ? [] : [label]);
382
+ }
383
+ function printRunListing(record, separate, watched) {
305
384
  const state = record.state ?? "starting";
306
- const human = record.status === "waiting" ? record.human : undefined;
307
- // Recorded text must not be able to move the cursor or restyle the terminal.
308
- const plain = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f]+/gu, " ").trim();
309
- const details = human === undefined ? (record.limit === undefined ? [] : [plain(record.limit.text)]) : [
385
+ const recorded = runStatus(record);
386
+ const human = recorded === "waiting" ? record.human : undefined;
387
+ // A run that finished or lost its process is no longer waiting for anything.
388
+ const limit = recorded === "running" || recorded === "waiting" ? record.limit : undefined;
389
+ const details = human === undefined ? (limit === undefined ? [] : [plain(limit.text)]) : [
310
390
  ...human.prompt.split("\n").map(plain).filter((line) => line !== ""),
311
391
  ...(human.requestId === undefined ? [] : [`request: ${plain(human.requestId)}`]),
392
+ ...(record.answeredBy === "host" ? ["answered through its host"] : []),
312
393
  ...(human.choices === undefined ? [] : [`choices: ${human.choices.map(plain).join(", ")}`]),
313
394
  ...(human.suggestions === undefined ? [] : [`suggestions: ${human.suggestions.map(plain).join(", ")}`]),
314
395
  ...(human.discussion === true ? ["discussion: questions allowed"] : []),
315
396
  ];
316
397
  if (!process.stdout.isTTY) {
317
- console.log([record.id, record.machine, record.status, state].map(plain).join("\t"));
398
+ const columns = runColumns(record);
399
+ // The mark has its own column, after the label's, whether or not the run has a label.
400
+ console.log((watched ? [...columns, ...(columns.length === 4 ? [""] : []), "watched"] : columns).join("\t"));
318
401
  for (const line of details)
319
402
  console.log(` ${line}`);
320
403
  return;
321
404
  }
322
405
  if (separate)
323
406
  console.log();
324
- const status = record.status === "waiting" ? yellow("waiting") : cyan("running");
325
- console.log(`${bold(plain(record.id))} ${bold(cyan(plain(record.machine)))} › ${status} · ${plain(state)}`);
326
- for (const line of details)
327
- console.log(` ${line.startsWith("request: ") ? dim(line) : line}`);
407
+ const status = recorded === "waiting"
408
+ ? yellow(recorded)
409
+ : recorded === "running" ? cyan(recorded) : recorded === "completed" ? green(recorded) : red(recorded);
410
+ console.log(`${bold(plain(record.id))} ${bold(cyan(plain(record.machine)))} › ${status} · ${plain(state)}${watched ? ` · ${dim("watched")}` : ""}`);
411
+ if (record.label !== undefined && plain(record.label) !== "")
412
+ console.log(` ${plain(record.label)}`);
413
+ for (const line of details) {
414
+ console.log(` ${line.startsWith("request: ") || line === "answered through its host" ? dim(line) : line}`);
415
+ }
328
416
  }
329
417
  function printMachineHeader(machinePath) {
330
418
  if (!process.stdout.isTTY) {
@@ -7,6 +7,17 @@ export interface HostedHumanRequest extends HumanRequest {
7
7
  }
8
8
  export interface MachineHostOptions extends PrepareMachineRunOptions {
9
9
  readonly signal?: AbortSignal;
10
+ /** What the run is for, in its published run status. `MACHINES_RUN_LABEL` applies when absent. */
11
+ readonly label?: string;
12
+ /** The session that owns the run in its published run status. The environment's owner applies when absent. */
13
+ readonly owner?: string;
14
+ /**
15
+ * Asks for the run's status to be published where run-status publication is on.
16
+ * `true`: this host publishes it. `"caller"`: the caller publishes it itself,
17
+ * under the run's id, as the MCP server does. Absent: nothing about the run is
18
+ * published or handed down, and the caller presents the run in its own way.
19
+ */
20
+ readonly publishRunStatus?: boolean | "caller";
10
21
  readonly onAgentUpdate?: (update: AgentUpdate) => void;
11
22
  readonly onHumanRequest?: (request: HostedHumanRequest) => void;
12
23
  readonly onState?: (state: StateValue) => void;
@@ -16,6 +27,8 @@ export interface MachineHostResult {
16
27
  readonly output?: JsonValue;
17
28
  }
18
29
  export interface MachineHostRun {
30
+ /** Names the run. Its published run status, when there is one, is recorded under this id. */
31
+ readonly id: string;
19
32
  readonly name: string;
20
33
  readonly description: string;
21
34
  readonly path: string;
package/dist/src/host.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { execFileSync, fork } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { resolve as resolvePath } from "node:path";
2
4
  import { fileURLToPath } from "node:url";
3
5
  import * as v from "valibot";
4
6
  import { assertJsonValue, isJsonValue } from "./json.js";
5
7
  import { prepareMachineRun, } from "./launcher.js";
8
+ import { createRunStatusPublisher, runStatusDirectory, runStatusObserver, } from "./run-status.js";
6
9
  const childArgument = "--machines-child-host";
7
10
  const humanRequestSchema = v.strictObject({
8
11
  discussion: v.optional(v.boolean()),
@@ -10,13 +13,13 @@ const humanRequestSchema = v.strictObject({
10
13
  choices: v.optional(v.array(v.string())),
11
14
  suggestions: v.optional(v.array(v.string())),
12
15
  });
16
+ const agentIdentity = {
17
+ harness: v.string(),
18
+ model: v.optional(v.string()),
19
+ thinking: v.optional(v.string()),
20
+ };
13
21
  const agentUpdateSchema = v.variant("type", [
14
- v.strictObject({
15
- type: v.literal("identity"),
16
- harness: v.string(),
17
- model: v.optional(v.string()),
18
- thinking: v.optional(v.string()),
19
- }),
22
+ v.strictObject({ type: v.literal("identity"), ...agentIdentity }),
20
23
  v.strictObject({
21
24
  type: v.literal("output"),
22
25
  text: v.string(),
@@ -48,6 +51,10 @@ const parentMessageSchema = v.variant("type", [
48
51
  agents: v.optional(v.record(v.string(), v.string())),
49
52
  cwd: v.string(),
50
53
  home: v.optional(v.string()),
54
+ // Present while run status is published, by the host or by its caller, under this id.
55
+ runId: v.optional(v.string()),
56
+ // The host publishes the record itself and needs each change to it.
57
+ reportStatus: v.optional(v.boolean()),
51
58
  }),
52
59
  v.strictObject({
53
60
  type: v.literal("ask"),
@@ -70,6 +77,15 @@ const childMessageSchema = v.variant("type", [
70
77
  }),
71
78
  v.strictObject({ type: v.literal("state"), state: stateValueSchema }),
72
79
  v.strictObject({ type: v.literal("agentUpdate"), update: agentUpdateSchema }),
80
+ v.strictObject({
81
+ type: v.literal("status"),
82
+ // A change to the published record. Null clears a field: IPC drops a key that holds undefined.
83
+ change: v.strictObject({
84
+ state: v.optional(stateValueSchema),
85
+ agent: v.optional(v.nullable(v.object(agentIdentity))),
86
+ limit: v.optional(v.nullable(v.strictObject({ text: v.string(), until: v.optional(v.string()) }))),
87
+ }),
88
+ }),
73
89
  v.strictObject({
74
90
  type: v.literal("humanRequest"),
75
91
  requestId: v.string(),
@@ -94,6 +110,18 @@ export function startMachineHost(options) {
94
110
  }
95
111
  }
96
112
  const cwd = options.cwd ?? process.cwd();
113
+ // Run status is published here, by the host, and not by the hosted child.
114
+ // Only the host sees every ending: it stops the child with SIGKILL, and a
115
+ // child that crashed cannot record its own death. The record therefore
116
+ // carries this process's pid. While the host lives it settles the record
117
+ // itself; when the host dies the child stops with it (see "disconnect"
118
+ // below), so a reader that finds the pid gone rightly shows the run as lost.
119
+ const id = randomUUID();
120
+ const publication = runStatusDirectory() !== undefined;
121
+ // An embedding program has to ask: its environment alone must not make its runs appear elsewhere.
122
+ const publishes = publication && options.publishRunStatus === true;
123
+ const recorded = publishes || (publication && options.publishRunStatus === "caller");
124
+ let published;
97
125
  const child = fork(fileURLToPath(import.meta.url), [childArgument], {
98
126
  cwd,
99
127
  execArgv: [],
@@ -135,6 +163,7 @@ export function startMachineHost(options) {
135
163
  const wasStarting = phase === "starting";
136
164
  phase = "failed";
137
165
  pendingHuman = undefined;
166
+ published?.finish({ error: error.message });
138
167
  rejectResult(error);
139
168
  if (wasStarting)
140
169
  rejectStarted(error);
@@ -158,7 +187,20 @@ export function startMachineHost(options) {
158
187
  return;
159
188
  }
160
189
  phase = "running";
190
+ if (publishes) {
191
+ published = createRunStatusPublisher({
192
+ id,
193
+ machine: message.name,
194
+ path: message.path,
195
+ cwd: resolvePath(cwd),
196
+ ...(options.label === undefined ? {} : { label: options.label }),
197
+ ...(options.owner === undefined ? {} : { owner: options.owner }),
198
+ // Responses reach this run through `respond` and `ask` below, never through an inbox.
199
+ answeredBy: "host",
200
+ });
201
+ }
161
202
  resolveStarted({
203
+ id,
162
204
  name: message.name,
163
205
  description: message.description,
164
206
  path: message.path,
@@ -177,6 +219,7 @@ export function startMachineHost(options) {
177
219
  }
178
220
  // Claim before yielding: no second caller can send this request again.
179
221
  pendingHuman = undefined;
222
+ published?.update({ status: "running", human: undefined });
180
223
  try {
181
224
  await sendChild(child, { type: "respond", requestId, response });
182
225
  }
@@ -195,6 +238,7 @@ export function startMachineHost(options) {
195
238
  throw new Error("Question must contain 1–8000 characters");
196
239
  }
197
240
  pendingHuman = undefined;
241
+ published?.update({ status: "running", human: undefined });
198
242
  try {
199
243
  await sendChild(child, { type: "ask", requestId, question });
200
244
  }
@@ -218,6 +262,14 @@ export function startMachineHost(options) {
218
262
  else if (message.type === "agentUpdate") {
219
263
  observe(() => options.onAgentUpdate?.(message.update));
220
264
  }
265
+ else if (message.type === "status") {
266
+ const { state, agent, limit } = message.change;
267
+ published?.update({
268
+ ...(state === undefined ? {} : { state: state }),
269
+ ...(agent === undefined ? {} : { agent: agent ?? undefined }),
270
+ ...(limit === undefined ? {} : { limit: limit ?? undefined }),
271
+ });
272
+ }
221
273
  else if (message.type === "humanRequest") {
222
274
  if (phase !== "running" || pendingHuman !== undefined) {
223
275
  fail(new Error("Machine host sent an unexpected Human request"));
@@ -227,6 +279,8 @@ export function startMachineHost(options) {
227
279
  requestId: message.requestId,
228
280
  request: message.request,
229
281
  };
282
+ // Published without its request id, which only this host can use.
283
+ published?.update({ status: "waiting", human: message.request });
230
284
  observe(() => options.onHumanRequest?.({ ...message.request, requestId: message.requestId }));
231
285
  }
232
286
  else if (message.type === "completed") {
@@ -236,6 +290,7 @@ export function startMachineHost(options) {
236
290
  }
237
291
  phase = "completed";
238
292
  pendingHuman = undefined;
293
+ published?.finish({ state: message.state });
239
294
  dispose();
240
295
  resolveResult({
241
296
  state: message.state,
@@ -268,6 +323,9 @@ export function startMachineHost(options) {
268
323
  ...(options.input === undefined ? {} : { input: options.input }),
269
324
  ...(options.agents === undefined ? {} : { agents: options.agents }),
270
325
  ...(options.home === undefined ? {} : { home: options.home }),
326
+ // A caller that publishes the record itself still publishes it under this id.
327
+ ...(recorded ? { runId: id } : {}),
328
+ ...(publishes ? { reportStatus: true } : {}),
271
329
  };
272
330
  void sendChild(child, launch).catch((cause) => fail(asError(cause)));
273
331
  });
@@ -335,7 +393,8 @@ function runChildHost() {
335
393
  });
336
394
  async function launch(message) {
337
395
  try {
338
- const prepared = await prepareMachineRun(message);
396
+ const { runId, reportStatus, ...launched } = message;
397
+ const prepared = await prepareMachineRun(launched);
339
398
  send({
340
399
  type: "started",
341
400
  name: prepared.name,
@@ -343,9 +402,28 @@ function runChildHost() {
343
402
  path: prepared.path,
344
403
  agentRoles: prepared.agentRoles,
345
404
  });
405
+ // Machines started by this run's Operations and Agents are its sub-runs.
406
+ if (runId !== undefined)
407
+ process.env.MACHINES_RUN_PARENT = runId;
408
+ // Only this process can tell which state an Agent identity belongs to, so
409
+ // it works out each change to the record and the host publishes it.
410
+ const observed = reportStatus !== true ? undefined : runStatusObserver((change) => send({
411
+ type: "status",
412
+ change: {
413
+ ...(change.state === undefined ? {} : { state: change.state }),
414
+ ...("agent" in change ? { agent: change.agent ?? null } : {}),
415
+ ...("limit" in change ? { limit: change.limit ?? null } : {}),
416
+ },
417
+ }));
346
418
  const result = await prepared.start({
347
- onAgentUpdate: (update) => send({ type: "agentUpdate", update }),
348
- onState: (state) => send({ type: "state", state }),
419
+ onAgentUpdate(update) {
420
+ send({ type: "agentUpdate", update });
421
+ observed?.onAgentUpdate(update);
422
+ },
423
+ onState(state) {
424
+ send({ type: "state", state });
425
+ observed?.onState(state);
426
+ },
349
427
  human: (request) => new Promise((resolve) => {
350
428
  if (pendingHuman !== undefined) {
351
429
  throw new Error("Machine host cannot wait for two Human responses at once");
@@ -20,6 +20,10 @@ export async function respondToRun(options) {
20
20
  const record = published();
21
21
  if (record === undefined)
22
22
  throw new Error(`Machine run "${runId}" is not published in ${directory}`);
23
+ // Such a run reads no inbox: a response left there would only look delivered.
24
+ if (record.answeredBy === "host") {
25
+ throw new Error(`Machine run "${runId}" is answered through the program hosting it, not by machine respond: answer it there`);
26
+ }
23
27
  const request = pendingRequest(record);
24
28
  if (request === undefined)
25
29
  throw new Error(`Machine run "${runId}" is not waiting for Human input`);
@@ -1,4 +1,5 @@
1
1
  import type { StateValue } from "xstate";
2
+ import type { AgentUpdate } from "./index.ts";
2
3
  /** One published run, stored as `<MACHINES_RUN_STATUS_DIR>/<id>.json`. */
3
4
  export interface RunStatusRecord {
4
5
  /** Version 1 records carry only `human.prompt`. */
@@ -21,6 +22,11 @@ export interface RunStatusRecord {
21
22
  /** Present while the current Agent state waits for a usage limit to reset; `status` stays `running`. */
22
23
  readonly limit?: RunStatusLimit;
23
24
  readonly human?: RunStatusHuman;
25
+ /**
26
+ * `host` when the program hosting the run answers its Human requests itself:
27
+ * the run has no inbox, and `machine respond` refuses it. Absent otherwise.
28
+ */
29
+ readonly answeredBy?: "host";
24
30
  /** Names the inbox responses the run took lately, so that each sender can tell; never a response. */
25
31
  readonly deliveries?: readonly string[];
26
32
  readonly error?: string;
@@ -52,6 +58,11 @@ export interface RunStatusRun {
52
58
  readonly path: string;
53
59
  readonly cwd: string;
54
60
  readonly startedAt?: string;
61
+ /** Replaces the owner the environment names; an empty one publishes the run unowned. */
62
+ readonly owner?: string;
63
+ /** Replaces `MACHINES_RUN_LABEL`, and is shortened as it is. */
64
+ readonly label?: string;
65
+ readonly answeredBy?: "host";
55
66
  }
56
67
  /** A present key replaces its field; a present key holding `undefined` clears it. */
57
68
  export interface RunStatusChange {
@@ -90,6 +101,19 @@ export declare function runDeliveryPath(directory: string, id: string, delivery:
90
101
  * throws, and it records neither Machine input nor output.
91
102
  */
92
103
  export declare function createRunStatusPublisher(run: RunStatusRun, environment?: NodeJS.ProcessEnv): RunStatusPublisher;
104
+ /**
105
+ * Turns what a run reports as it executes into the changes to its record, for
106
+ * `machine run` and for a hosted run alike.
107
+ */
108
+ export declare function runStatusObserver(update: (change: RunStatusChange) => void): {
109
+ onAgentUpdate(update: AgentUpdate): void;
110
+ onState(state: StateValue): void;
111
+ };
112
+ /**
113
+ * The session a process belongs to: the owner of the runs it starts, and the
114
+ * session whose watch list `machine watch` changes.
115
+ */
116
+ export declare function runSessionId(environment?: NodeJS.ProcessEnv): string | undefined;
93
117
  /** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
94
118
  export declare function readRunStatuses(directory: string): RunStatusRecord[];
95
119
  /** The id becomes a file name, so it must not be able to leave the directory. */
@@ -32,9 +32,9 @@ export function createRunStatusPublisher(run, environment = process.env) {
32
32
  const id = run.id ?? randomUUID();
33
33
  if (directory === undefined || !isFileSafeId(id))
34
34
  return disabled;
35
- const owner = environment.MACHINES_RUN_OWNER || environment.CLAUDE_CODE_SESSION_ID || undefined;
35
+ const owner = (run.owner ?? runSessionId(environment)) || undefined;
36
36
  const parent = environment.MACHINES_RUN_PARENT || undefined;
37
- const label = formatLabel(environment.MACHINES_RUN_LABEL);
37
+ const label = formatLabel(run.label ?? environment.MACHINES_RUN_LABEL);
38
38
  const startedAt = run.startedAt ?? new Date().toISOString();
39
39
  let status = "running";
40
40
  let state;
@@ -61,6 +61,7 @@ export function createRunStatusPublisher(run, environment = process.env) {
61
61
  ...(agent === undefined ? {} : { agent }),
62
62
  ...(limit === undefined ? {} : { limit }),
63
63
  ...(human === undefined ? {} : { human }),
64
+ ...(run.answeredBy === undefined ? {} : { answeredBy: run.answeredBy }),
64
65
  ...(deliveries.length === 0 ? {} : { deliveries: deliveries.map((delivery) => delivery.id) }),
65
66
  ...(error === undefined ? {} : { error }),
66
67
  startedAt,
@@ -142,6 +143,55 @@ export function createRunStatusPublisher(run, environment = process.env) {
142
143
  },
143
144
  };
144
145
  }
146
+ /**
147
+ * Turns what a run reports as it executes into the changes to its record, for
148
+ * `machine run` and for a hosted run alike.
149
+ */
150
+ export function runStatusObserver(update) {
151
+ let publishedState;
152
+ // A runner reports its identity as it starts. For a state entered by a
153
+ // transition that is before the state is announced, so an identity is held
154
+ // for the rest of the tick and published with the state it belongs to.
155
+ let arriving;
156
+ return {
157
+ onAgentUpdate(agent) {
158
+ if (agent.type === "identity") {
159
+ arriving = agent;
160
+ queueMicrotask(() => {
161
+ if (arriving !== agent)
162
+ return;
163
+ arriving = undefined;
164
+ update({ agent });
165
+ });
166
+ }
167
+ else if (agent.type === "limit" && agent.status !== "swapped") {
168
+ // A swap while every preset is being tried again leaves the wait as it is published.
169
+ update({
170
+ limit: agent.status === "resumed"
171
+ ? undefined
172
+ : { text: agent.text, ...(agent.until === undefined ? {} : { until: agent.until }) },
173
+ });
174
+ }
175
+ },
176
+ onState(state) {
177
+ const entered = formatState(state);
178
+ if (entered === publishedState)
179
+ update({ state });
180
+ else {
181
+ update({ state, agent: arriving });
182
+ arriving = undefined;
183
+ }
184
+ publishedState = entered;
185
+ },
186
+ };
187
+ }
188
+ /**
189
+ * The session a process belongs to: the owner of the runs it starts, and the
190
+ * session whose watch list `machine watch` changes.
191
+ */
192
+ export function runSessionId(environment = process.env) {
193
+ return environment.MACHINES_RUN_OWNER || environment.CLAUDE_CODE_SESSION_ID || undefined;
194
+ }
145
195
  /** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
146
196
  export function readRunStatuses(directory) {
147
197
  return readRecordFiles(directory).map(({ record }) => record);
@@ -220,6 +270,7 @@ function isRunStatusRecord(value) {
220
270
  && (human.discussion === undefined || typeof human.discussion === "boolean")))
221
271
  && (limit === undefined || (limit !== null && typeof limit === "object" && typeof limit.text === "string"
222
272
  && isOptionalString(limit.until)))
273
+ && (record.answeredBy === undefined || record.answeredBy === "host")
223
274
  && isOptionalStrings(record.deliveries)
224
275
  && isOptionalString(record.error)
225
276
  && isTimestamp(record.startedAt)
@@ -0,0 +1,24 @@
1
+ import { type RunStatusRecord } from "./run-status.ts";
2
+ /**
3
+ * One thing a session asked to see on its status line besides its own runs:
4
+ * runs named by the start of their id or label, or runs working in a directory.
5
+ */
6
+ export type RunWatch = {
7
+ readonly text: string;
8
+ } | {
9
+ readonly dir: string;
10
+ };
11
+ /**
12
+ * A session's watch list, kept beside the records under a name their readers
13
+ * skip. `claude/statusline.mjs` reads the same file without loading this module.
14
+ */
15
+ export declare function runWatchPath(directory: string, sessionId: string): string;
16
+ /** What a session watches. A missing, unreadable, or malformed list watches nothing. */
17
+ export declare function readRunWatches(directory: string, sessionId: string): RunWatch[];
18
+ /** Replaces a session's watch list; an empty one removes its file. */
19
+ export declare function writeRunWatches(directory: string, sessionId: string, watches: readonly RunWatch[]): void;
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;
22
+ /** Checks and normalizes what `machine watch` was given. */
23
+ export declare function runWatch(input: RunWatch): RunWatch;
24
+ export declare function sameRunWatch(left: RunWatch, right: RunWatch): boolean;
@@ -0,0 +1,112 @@
1
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
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;
6
+ let temporaryFiles = 0;
7
+ /**
8
+ * A session's watch list, kept beside the records under a name their readers
9
+ * skip. `claude/statusline.mjs` reads the same file without loading this module.
10
+ */
11
+ export function runWatchPath(directory, sessionId) {
12
+ if (!isFileSafeId(sessionId)) {
13
+ throw new Error(`Session id "${sessionId}" cannot name a watch list: use only letters, digits, ".", "_", and "-"`);
14
+ }
15
+ return join(directory, `.watch.${sessionId}.json`);
16
+ }
17
+ /** What a session watches. A missing, unreadable, or malformed list watches nothing. */
18
+ export function readRunWatches(directory, sessionId) {
19
+ try {
20
+ const list = JSON.parse(readFileSync(runWatchPath(directory, sessionId), "utf8"));
21
+ const watches = list?.watches;
22
+ return Array.isArray(watches) ? watches.flatMap((watch) => pickWatch(watch) ?? []) : [];
23
+ }
24
+ catch {
25
+ return [];
26
+ }
27
+ }
28
+ /** Replaces a session's watch list; an empty one removes its file. */
29
+ export function writeRunWatches(directory, sessionId, watches) {
30
+ const path = runWatchPath(directory, sessionId);
31
+ if (watches.length === 0) {
32
+ rmSync(path, { force: true });
33
+ return;
34
+ }
35
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
36
+ // Readers only ever see a whole list: write beside it, then rename over it.
37
+ const temporary = join(directory, `.watch.${sessionId}.${process.pid}.${temporaryFiles += 1}.tmp`);
38
+ try {
39
+ writeFileSync(temporary, `${JSON.stringify({ schemaVersion: 1, watches })}\n`, { mode: 0o600 });
40
+ renameSync(temporary, path);
41
+ }
42
+ catch (cause) {
43
+ rmSync(temporary, { force: true });
44
+ throw cause;
45
+ }
46
+ try {
47
+ pruneRunWatches(directory, path);
48
+ }
49
+ catch {
50
+ // Pruning is best effort.
51
+ }
52
+ }
53
+ /** Whether a watch names a run. A sub-run is never watched: it is its parent's detail. */
54
+ export function runWatchMatches(watch, record) {
55
+ if (record.parent !== undefined)
56
+ 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
+ }
63
+ if (record.id.startsWith(watch.text))
64
+ return true;
65
+ // The label must start with the whole text, not with a longer word that begins like it.
66
+ const next = record.label?.startsWith(watch.text) ? record.label.slice(watch.text.length) : undefined;
67
+ return next !== undefined && !/^[\p{L}\p{N}_-]/u.test(next);
68
+ }
69
+ /** Checks and normalizes what `machine watch` was given. */
70
+ export function runWatch(input) {
71
+ if ("dir" in input) {
72
+ if (input.dir === "")
73
+ throw new Error("Expected a directory after --dir");
74
+ return { dir: resolve(input.dir) };
75
+ }
76
+ const text = input.text.trim();
77
+ if (text === "")
78
+ throw new Error("Expected the start of a run id or label to watch");
79
+ // Checked before the ends are trimmed: a line break there is still a second line.
80
+ if (/[\r\n]/u.test(input.text))
81
+ throw new Error("A watch is one line: the start of a run id or label");
82
+ return { text };
83
+ }
84
+ export function sameRunWatch(left, right) {
85
+ return "dir" in left ? "dir" in right && left.dir === right.dir : "text" in right && left.text === right.text;
86
+ }
87
+ function pickWatch(value) {
88
+ if (value === null || typeof value !== "object")
89
+ return undefined;
90
+ const { text, dir } = value;
91
+ if (typeof text === "string" && text !== "" && dir === undefined)
92
+ return { text };
93
+ if (typeof dir === "string" && isAbsolute(dir) && text === undefined)
94
+ return { dir: resolve(dir) };
95
+ return undefined;
96
+ }
97
+ /** Sessions end without saying so; a list nobody has changed for a month belongs to one that did. */
98
+ function pruneRunWatches(directory, kept) {
99
+ const now = Date.now();
100
+ for (const name of readdirSync(directory)) {
101
+ const file = join(directory, name);
102
+ if (!watchFile.test(name) || file === kept)
103
+ continue;
104
+ try {
105
+ if (now - statSync(file).mtimeMs > forgottenAfterMilliseconds)
106
+ rmSync(file, { force: true });
107
+ }
108
+ catch {
109
+ // Another session may have pruned it first.
110
+ }
111
+ }
112
+ }
@@ -1,6 +1,6 @@
1
1
  import type { StateValue } from "xstate";
2
2
  import type { JsonValue } from "./json.ts";
3
- import { type HostedHumanRequest } from "./host.ts";
3
+ import { type HostedHumanRequest, type MachineHostOptions } from "./host.ts";
4
4
  import type { AgentUpdate } from "./index.ts";
5
5
  import type { PrepareMachineRunOptions } from "./launcher.ts";
6
6
  export type RunStatus = "running" | "waiting" | "completed" | "failed";
@@ -35,7 +35,7 @@ export interface SessionEvent {
35
35
  export declare class MachineSession {
36
36
  #private;
37
37
  subscribe(listener: (event: SessionEvent) => void): () => void;
38
- start(options: PrepareMachineRunOptions): Promise<RunSnapshot>;
38
+ start(options: PrepareMachineRunOptions & Pick<MachineHostOptions, "label" | "owner" | "publishRunStatus">): Promise<RunSnapshot>;
39
39
  status(runId?: string): RunSnapshot[];
40
40
  respond(input: {
41
41
  readonly runId: string;
@@ -1,4 +1,3 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { startMachineHost } from "./host.js";
3
2
  /** Owns runs from startup to disposal. Presentation belongs to its subscribers. */
4
3
  export class MachineSession {
@@ -15,10 +14,11 @@ export class MachineSession {
15
14
  throw new Error("Machine session is closed");
16
15
  const cancellation = new AbortController();
17
16
  this.#starting.add(cancellation);
18
- const id = randomUUID();
19
17
  const startedAt = Date.now();
18
+ // The run is known by its host's id, which is also the id of its published run status.
19
+ let started;
20
20
  const update = (type, change) => {
21
- const run = this.#runs.get(id);
21
+ const run = started === undefined ? undefined : this.#runs.get(started.id);
22
22
  if (run === undefined || !isActive(run.status))
23
23
  return;
24
24
  change(run);
@@ -63,14 +63,15 @@ export class MachineSession {
63
63
  throw new Error("Machine session is closed");
64
64
  }
65
65
  const run = {
66
- id,
66
+ id: host.id,
67
67
  cwd: options.cwd ?? process.cwd(),
68
68
  host,
69
69
  startedAt,
70
70
  updatedAt: Date.now(),
71
71
  status: "running",
72
72
  };
73
- this.#runs.set(id, run);
73
+ started = run;
74
+ this.#runs.set(run.id, run);
74
75
  void host.result.then((result) => this.#finish(run, result), (cause) => this.#finish(run, { error: errorMessage(cause) }));
75
76
  this.#emit("updated", run);
76
77
  return snapshot(run);
@@ -59,7 +59,7 @@ MCP server ────┘ ↑
59
59
  | `src/discovery.ts` | Project/global definition locations and precedence |
60
60
  | `src/launcher.ts` | Definition metadata, Agent bindings, preflight, single-use launch preparation |
61
61
  | `src/discovery-worker.ts` | Fresh metadata imports in disposable workers |
62
- | `src/host.ts` | One child run, IPC, Human request identity, owned process termination |
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
64
  | `src/cli.ts` | Command parsing, terminal progress, source inspection |
65
65
  | `pi-extension/index.ts` | Pi tools, widgets, notifications, native input presentation |
@@ -107,9 +107,14 @@ response from either the terminal or a private per-run inbox file written by
107
107
  session `respond` path as its tool, and inbox questions through the session's
108
108
  `ask`. The inbox is a second way to deliver a response to the owning run, not a
109
109
  second owner: the run still validates the request ID and choices, and a response
110
- to any other request is discarded. See
110
+ to any other request is discarded. A run hosted directly through
111
+ `startMachineHost` publishes its status only when its host asks, and without an inbox: its host remains the
112
+ only way to answer it, its record says so, and `machine respond` refuses it. See
111
113
  [Integrations](integrations.md#answering-a-waiting-run-from-outside).
112
114
 
115
+ A session's watch list changes only which published runs its status line shows.
116
+ It gives no ownership of a run and no way to answer one.
117
+
113
118
  Closing a session terminates active and starting hosts. Host termination includes
114
119
  ordinary descendants in the host's owned process group on POSIX and process-tree
115
120
  termination on Windows. Deliberately detached processes can escape normal ownership;
@@ -167,8 +172,9 @@ Their phase ordering and manual proof gates describe past work.
167
172
 
168
173
  ## External execution owners
169
174
 
170
- The supported `@dna113p/machines/host` export exposes `startMachineHost`, its
171
- observations, correlated Human responses, termination, and final state/output.
175
+ The supported `@dna113p/machines/host` export exposes `startMachineHost`, the
176
+ run's id, its observations, correlated Human responses, termination, and final
177
+ state/output.
172
178
  External applications can own hosted runs without importing private modules or
173
179
  parsing terminal output. Hosted input and optional output are JSON values.
174
180
 
@@ -173,9 +173,9 @@ a command, such as Claude Code's status line, reads published run status instead
173
173
  Publication is opt-in: set `MACHINES_RUN_STATUS_DIR` to an absolute directory.
174
174
  When it is unset nothing is written.
175
175
 
176
- `machine run` and the MCP server then keep one file per run,
177
- `<dir>/<runId>.json`, replaced atomically on each change (directory `0700`, file
178
- `0600`):
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`):
179
179
 
180
180
  ```json
181
181
  {
@@ -198,6 +198,30 @@ choice to publish that text. `machine run` exports its run id as
198
198
  `MACHINES_RUN_PARENT`, so a Machine started by one of its Operations or Agents
199
199
  records that id as `parent`; the status line shows only top-level runs.
200
200
 
201
+ 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
204
+ host that does not ask publishes nothing, whatever its environment says: a
205
+ 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:
207
+ `label`, shortened as `MACHINES_RUN_LABEL` is, and `owner`. When an option is
208
+ absent the environment rule above applies; an empty one publishes the run
209
+ without that field. The run exports its id as `MACHINES_RUN_PARENT` to its
210
+ Operations and Agents, as `machine run` does. The host publishes, not the hosted
211
+ process, so `pid` is the host's: a hosted process that is terminated or exits is
212
+ recorded as `failed` with the reason, and a run whose host is gone shows as
213
+ `lost`. The value `startMachineHost` resolves to carries the run's `id`, which
214
+ is the id of its record; a session built on it, such as Pi's, reports that same
215
+ id for the run, so it is the id to give `machine watch`.
216
+
217
+ Such a record carries `"answeredBy": "host"`. Its Human requests are answered
218
+ only through the host's own `respond` and `ask`: the run reads no inbox, its
219
+ `human` has no `requestId`, and `machine respond` refuses it. The field is absent
220
+ from every other record and did not exist in earlier version 2 records. A caller
221
+ that publishes its own record for the run, as the MCP server does so that its
222
+ runs keep their inbox, passes `publishRunStatus: "caller"`: the host then writes
223
+ nothing, and the run still hands its id down to the runs it starts.
224
+
201
225
  While an Agent state waits for a [usage limit](authoring.md#usage-limits) to
202
226
  reset, the record stays `running` and carries the wait in `limit`:
203
227
 
@@ -253,13 +277,21 @@ whatever answers it.
253
277
  ```bash
254
278
  machine runs # running and waiting runs
255
279
  machine runs --json # the same runs as their records
280
+ machine runs --all # every published run, also finished and lost ones still recorded
256
281
  machine respond <run-id> <request-id> <response>
257
282
  machine respond --question <run-id> <request-id> <text>
258
283
  ```
259
284
 
260
285
  `machine runs` prints each run's id, Machine, status, and state, and for a
261
- waiting run its prompt, request id, and choices (tab-separated when its output
262
- is not a terminal):
286
+ waiting run its prompt, request id, and choices (tab-separated when its
287
+ output is not a terminal). A run's label follows as a fifth column when it has
288
+ one, and a sixth says `watched` for a run this session
289
+ [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
291
+ run: also the ones whose records remain after they finished, as `completed` or
292
+ `failed`, or after their process disappeared, as `lost` (in JSON such a record
293
+ keeps the status it last published). A run answered through its host shows
294
+ `answered through its host` in place of a request id:
263
295
 
264
296
  ```text
265
297
  1a2b3c4d-… release waiting approve
@@ -269,9 +301,9 @@ is not a terminal):
269
301
  ```
270
302
 
271
303
  `machine respond` checks the response against the current record before it
272
- delivers anything. It fails with a nonzero exit when the run is unknown, is not
273
- waiting, is waiting on a different request id, or restricts `choices` to values
274
- that do not include the response. `--question` sends a discussion question
304
+ delivers anything. It fails with a nonzero exit when the run is unknown, is
305
+ answered through its host, is not waiting, is waiting on a different request id,
306
+ or restricts `choices` to values that do not include the response. `--question` sends a discussion question
275
307
  instead of an answer and is refused unless the request has `discussion: true`.
276
308
  Words after the request id are joined into one response; put `--` before a
277
309
  response that starts with a dash.
@@ -309,6 +341,8 @@ A published run started without terminal input, for example by a coordinating
309
341
  agent or a script, waits for `machine respond` instead of failing, and prints
310
342
  the exact command to its standard error. With publication off it fails at its
311
343
  first Human state with "Terminal input closed before a response", as before.
344
+ Runs hosted in any other program through `startMachineHost` are answered only
345
+ through that program.
312
346
  Runs hosted by the MCP server take a response from their own `machine_respond`
313
347
  tool or from the inbox. That tool is answer-only, so a question to a hosted
314
348
  request that allows discussion goes through the inbox with
@@ -350,13 +384,62 @@ prints one to three lines per run and nothing when there is nothing to show:
350
384
  Publish version 0.4.0?
351
385
  ```
352
386
 
353
- A session sees the runs it owns; runs without an owner appear in sessions whose
354
- project directory contains the run's working directory. Finished runs stay for
387
+ A session sees the runs it owns and the runs it
388
+ [watches](#watching-other-runs); runs without an owner also appear in sessions
389
+ whose project directory contains the run's working directory. Finished runs stay for
355
390
  30 seconds. A run whose process disappeared without finishing is shown as `lost`
356
391
  for the same time, so the status line must run where it can see that process.
357
392
  Set `NO_COLOR` (to any value, even an empty one) for plain text and `COLUMNS`
358
393
  to change the 120-column truncation.
359
394
 
395
+ ### Watching other runs
396
+
397
+ A session can pull in runs it did not start: a ticket that a daemon runs, or a
398
+ run started by another session or under another project. Nothing is configured.
399
+ 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.
402
+
403
+ ```bash
404
+ machine runs # running and waiting runs, with their labels
405
+ machine runs --all # every published run, also finished and lost ones
406
+ machine watch org-9 # runs whose id or label starts with org-9
407
+ machine watch --dir ../other # runs working in that directory or below it
408
+ machine watch # what this session watches, and the published runs each entry matches now
409
+ machine unwatch org-9 # also: unwatch --dir <path>, unwatch --all
410
+ ```
411
+
412
+ - `machine watch <text>` names every top-level run whose id starts with the
413
+ text, or whose label starts with it as a whole leading part: the label ends
414
+ there or goes on with something other than a letter, a digit, `-`, or `_`.
415
+ `org-9` matches the labels `org-9 → machines: …` and `org-9: …`, not
416
+ `org-90 …`. Matching is case-sensitive, and the text is one non-empty line.
417
+ - `machine watch --dir <path>` names every top-level run whose working
418
+ directory is that path or inside it. The path is kept absolute.
419
+ - An entry keeps matching runs that start later, so a ticket can be watched
420
+ before its run exists. `machine watch` lists a matched run for as long as its
421
+ record remains, with the status `machine runs --all` gives it, so it also
422
+ says how a watched run ended.
423
+
424
+ A watched run is shown even when another session owns it or it works outside
425
+ the project. Everything else is as for any run: sub-runs stay hidden, a run is
426
+ listed once however many entries name it, and at most five runs are shown.
427
+
428
+ The list is one file per session beside the records,
429
+ `<dir>/.watch.<session id>.json` (`0600`, replaced atomically), which the
430
+ status line reads itself with the `session_id` Claude Code gives it:
431
+
432
+ ```json
433
+ { "schemaVersion": 1, "watches": [{ "text": "org-9" }, { "dir": "/work/other" }] }
434
+ ```
435
+
436
+ Record readers skip it, as they skip every name that starts with a dot. A
437
+ 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
440
+ 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.
442
+
360
443
  ## Troubleshooting
361
444
 
362
445
  For an isolated global catalog, set `MACHINES_USER_HOME` to an absolute directory;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dna113p/machines",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Reusable workflows coordinating agents, local operations, and human decisions.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -103,6 +103,11 @@ polling continuously. Interpret the current snapshot:
103
103
  Preserve existing user authorization when answering Human requests, and surface a
104
104
  new decision when the request requires one. A desire to finish is not an answer.
105
105
 
106
+ To follow a run this session did not start, such as a ticket a daemon runs, use
107
+ `machine watch <ticket-or-run-id>` where run status is published (`machine runs`
108
+ lists what can be watched; `--all` adds runs that already ended). Watching only
109
+ shows the run on this session's status line: it neither owns nor answers it.
110
+
106
111
  The CLI fallback is `machine run <name> [--agent role=preset] -- <input>` in the
107
112
  target workspace. It waits for completion and uses terminal Human input; retain
108
113
  the shell session if running it asynchronously. It does not return an MCP run ID.