@dna113p/machines 0.3.0 → 0.3.1

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.1.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.1.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: false });
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,12 @@ 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
+ /** Set to false by a caller that publishes the run's status itself, as the MCP server does. */
15
+ readonly publishRunStatus?: boolean;
10
16
  readonly onAgentUpdate?: (update: AgentUpdate) => void;
11
17
  readonly onHumanRequest?: (request: HostedHumanRequest) => void;
12
18
  readonly onState?: (state: StateValue) => void;
@@ -16,6 +22,8 @@ export interface MachineHostResult {
16
22
  readonly output?: JsonValue;
17
23
  }
18
24
  export interface MachineHostRun {
25
+ /** Names the run. Its published run status, when there is one, is recorded under this id. */
26
+ readonly id: string;
19
27
  readonly name: string;
20
28
  readonly description: string;
21
29
  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,16 @@ 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
+ const publishes = publication && options.publishRunStatus !== false;
122
+ let published;
97
123
  const child = fork(fileURLToPath(import.meta.url), [childArgument], {
98
124
  cwd,
99
125
  execArgv: [],
@@ -135,6 +161,7 @@ export function startMachineHost(options) {
135
161
  const wasStarting = phase === "starting";
136
162
  phase = "failed";
137
163
  pendingHuman = undefined;
164
+ published?.finish({ error: error.message });
138
165
  rejectResult(error);
139
166
  if (wasStarting)
140
167
  rejectStarted(error);
@@ -158,7 +185,20 @@ export function startMachineHost(options) {
158
185
  return;
159
186
  }
160
187
  phase = "running";
188
+ if (publishes) {
189
+ published = createRunStatusPublisher({
190
+ id,
191
+ machine: message.name,
192
+ path: message.path,
193
+ cwd: resolvePath(cwd),
194
+ ...(options.label === undefined ? {} : { label: options.label }),
195
+ ...(options.owner === undefined ? {} : { owner: options.owner }),
196
+ // Responses reach this run through `respond` and `ask` below, never through an inbox.
197
+ answeredBy: "host",
198
+ });
199
+ }
161
200
  resolveStarted({
201
+ id,
162
202
  name: message.name,
163
203
  description: message.description,
164
204
  path: message.path,
@@ -177,6 +217,7 @@ export function startMachineHost(options) {
177
217
  }
178
218
  // Claim before yielding: no second caller can send this request again.
179
219
  pendingHuman = undefined;
220
+ published?.update({ status: "running", human: undefined });
180
221
  try {
181
222
  await sendChild(child, { type: "respond", requestId, response });
182
223
  }
@@ -195,6 +236,7 @@ export function startMachineHost(options) {
195
236
  throw new Error("Question must contain 1–8000 characters");
196
237
  }
197
238
  pendingHuman = undefined;
239
+ published?.update({ status: "running", human: undefined });
198
240
  try {
199
241
  await sendChild(child, { type: "ask", requestId, question });
200
242
  }
@@ -218,6 +260,14 @@ export function startMachineHost(options) {
218
260
  else if (message.type === "agentUpdate") {
219
261
  observe(() => options.onAgentUpdate?.(message.update));
220
262
  }
263
+ else if (message.type === "status") {
264
+ const { state, agent, limit } = message.change;
265
+ published?.update({
266
+ ...(state === undefined ? {} : { state: state }),
267
+ ...(agent === undefined ? {} : { agent: agent ?? undefined }),
268
+ ...(limit === undefined ? {} : { limit: limit ?? undefined }),
269
+ });
270
+ }
221
271
  else if (message.type === "humanRequest") {
222
272
  if (phase !== "running" || pendingHuman !== undefined) {
223
273
  fail(new Error("Machine host sent an unexpected Human request"));
@@ -227,6 +277,8 @@ export function startMachineHost(options) {
227
277
  requestId: message.requestId,
228
278
  request: message.request,
229
279
  };
280
+ // Published without its request id, which only this host can use.
281
+ published?.update({ status: "waiting", human: message.request });
230
282
  observe(() => options.onHumanRequest?.({ ...message.request, requestId: message.requestId }));
231
283
  }
232
284
  else if (message.type === "completed") {
@@ -236,6 +288,7 @@ export function startMachineHost(options) {
236
288
  }
237
289
  phase = "completed";
238
290
  pendingHuman = undefined;
291
+ published?.finish({ state: message.state });
239
292
  dispose();
240
293
  resolveResult({
241
294
  state: message.state,
@@ -268,6 +321,9 @@ export function startMachineHost(options) {
268
321
  ...(options.input === undefined ? {} : { input: options.input }),
269
322
  ...(options.agents === undefined ? {} : { agents: options.agents }),
270
323
  ...(options.home === undefined ? {} : { home: options.home }),
324
+ // A caller that publishes the record itself still publishes it under this id.
325
+ ...(publication ? { runId: id } : {}),
326
+ ...(publishes ? { reportStatus: true } : {}),
271
327
  };
272
328
  void sendChild(child, launch).catch((cause) => fail(asError(cause)));
273
329
  });
@@ -335,7 +391,8 @@ function runChildHost() {
335
391
  });
336
392
  async function launch(message) {
337
393
  try {
338
- const prepared = await prepareMachineRun(message);
394
+ const { runId, reportStatus, ...launched } = message;
395
+ const prepared = await prepareMachineRun(launched);
339
396
  send({
340
397
  type: "started",
341
398
  name: prepared.name,
@@ -343,9 +400,28 @@ function runChildHost() {
343
400
  path: prepared.path,
344
401
  agentRoles: prepared.agentRoles,
345
402
  });
403
+ // Machines started by this run's Operations and Agents are its sub-runs.
404
+ if (runId !== undefined)
405
+ process.env.MACHINES_RUN_PARENT = runId;
406
+ // Only this process can tell which state an Agent identity belongs to, so
407
+ // it works out each change to the record and the host publishes it.
408
+ const observed = reportStatus !== true ? undefined : runStatusObserver((change) => send({
409
+ type: "status",
410
+ change: {
411
+ ...(change.state === undefined ? {} : { state: change.state }),
412
+ ...("agent" in change ? { agent: change.agent ?? null } : {}),
413
+ ...("limit" in change ? { limit: change.limit ?? null } : {}),
414
+ },
415
+ }));
346
416
  const result = await prepared.start({
347
- onAgentUpdate: (update) => send({ type: "agentUpdate", update }),
348
- onState: (state) => send({ type: "state", state }),
417
+ onAgentUpdate(update) {
418
+ send({ type: "agentUpdate", update });
419
+ observed?.onAgentUpdate(update);
420
+ },
421
+ onState(state) {
422
+ send({ type: "state", state });
423
+ observed?.onState(state);
424
+ },
349
425
  human: (request) => new Promise((resolve) => {
350
426
  if (pendingHuman !== undefined) {
351
427
  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 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,26 @@ 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 the variable is set in the hosting process, for example a
203
+ daemon that runs tickets, or Pi with the extension loaded. Two options replace the environment for that one run:
204
+ `label`, shortened as `MACHINES_RUN_LABEL` is, and `owner`. When an option is
205
+ absent the environment rule above applies; an empty one publishes the run
206
+ without that field. The run exports its id as `MACHINES_RUN_PARENT` to its
207
+ Operations and Agents, as `machine run` does. The host publishes, not the hosted
208
+ process, so `pid` is the host's: a hosted process that is terminated or exits is
209
+ recorded as `failed` with the reason, and a run whose host is gone shows as
210
+ `lost`. The value `startMachineHost` resolves to carries the run's `id`, which
211
+ is the id of its record; a session built on it, such as Pi's, reports that same
212
+ id for the run, so it is the id to give `machine watch`.
213
+
214
+ Such a record carries `"answeredBy": "host"`. Its Human requests are answered
215
+ only through the host's own `respond` and `ask`: the run reads no inbox, its
216
+ `human` has no `requestId`, and `machine respond` refuses it. The field is absent
217
+ from every other record and did not exist in earlier version 2 records. A caller
218
+ that publishes its own record for the run, as the MCP server does so that its
219
+ runs keep their inbox, passes `publishRunStatus: false`.
220
+
201
221
  While an Agent state waits for a [usage limit](authoring.md#usage-limits) to
202
222
  reset, the record stays `running` and carries the wait in `limit`:
203
223
 
@@ -253,13 +273,21 @@ whatever answers it.
253
273
  ```bash
254
274
  machine runs # running and waiting runs
255
275
  machine runs --json # the same runs as their records
276
+ machine runs --all # every published run, also finished and lost ones still recorded
256
277
  machine respond <run-id> <request-id> <response>
257
278
  machine respond --question <run-id> <request-id> <text>
258
279
  ```
259
280
 
260
281
  `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):
282
+ waiting run its prompt, request id, and choices (tab-separated when its
283
+ output is not a terminal). A run's label follows as a fifth column when it has
284
+ one, and a sixth says `watched` for a run this session
285
+ [watches](#watching-other-runs). It lists the running and waiting runs of every
286
+ session and project. `--all`, with or without `--json`, lists every published
287
+ run: also the ones whose records remain after they finished, as `completed` or
288
+ `failed`, or after their process disappeared, as `lost` (in JSON such a record
289
+ keeps the status it last published). A run answered through its host shows
290
+ `answered through its host` in place of a request id:
263
291
 
264
292
  ```text
265
293
  1a2b3c4d-… release waiting approve
@@ -269,9 +297,9 @@ is not a terminal):
269
297
  ```
270
298
 
271
299
  `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
300
+ delivers anything. It fails with a nonzero exit when the run is unknown, is
301
+ answered through its host, is not waiting, is waiting on a different request id,
302
+ or restricts `choices` to values that do not include the response. `--question` sends a discussion question
275
303
  instead of an answer and is refused unless the request has `discussion: true`.
276
304
  Words after the request id are joined into one response; put `--` before a
277
305
  response that starts with a dash.
@@ -309,6 +337,8 @@ A published run started without terminal input, for example by a coordinating
309
337
  agent or a script, waits for `machine respond` instead of failing, and prints
310
338
  the exact command to its standard error. With publication off it fails at its
311
339
  first Human state with "Terminal input closed before a response", as before.
340
+ Runs hosted in any other program through `startMachineHost` are answered only
341
+ through that program.
312
342
  Runs hosted by the MCP server take a response from their own `machine_respond`
313
343
  tool or from the inbox. That tool is answer-only, so a question to a hosted
314
344
  request that allows discussion goes through the inbox with
@@ -350,13 +380,62 @@ prints one to three lines per run and nothing when there is nothing to show:
350
380
  Publish version 0.4.0?
351
381
  ```
352
382
 
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
383
+ A session sees the runs it owns and the runs it
384
+ [watches](#watching-other-runs); runs without an owner also appear in sessions
385
+ whose project directory contains the run's working directory. Finished runs stay for
355
386
  30 seconds. A run whose process disappeared without finishing is shown as `lost`
356
387
  for the same time, so the status line must run where it can see that process.
357
388
  Set `NO_COLOR` (to any value, even an empty one) for plain text and `COLUMNS`
358
389
  to change the 120-column truncation.
359
390
 
391
+ ### Watching other runs
392
+
393
+ A session can pull in runs it did not start: a ticket that a daemon runs, or a
394
+ run started by another session or under another project. Nothing is configured.
395
+ The session is identified as a run's `owner` is, by `MACHINES_RUN_OWNER` and
396
+ otherwise `CLAUDE_CODE_SESSION_ID`, so an agent inside Claude Code only runs the
397
+ command.
398
+
399
+ ```bash
400
+ machine runs # running and waiting runs, with their labels
401
+ machine runs --all # every published run, also finished and lost ones
402
+ machine watch org-9 # runs whose id or label starts with org-9
403
+ machine watch --dir ../other # runs working in that directory or below it
404
+ machine watch # what this session watches, and the published runs each entry matches now
405
+ machine unwatch org-9 # also: unwatch --dir <path>, unwatch --all
406
+ ```
407
+
408
+ - `machine watch <text>` names every top-level run whose id starts with the
409
+ text, or whose label starts with it as a whole leading part: the label ends
410
+ there or goes on with something other than a letter, a digit, `-`, or `_`.
411
+ `org-9` matches the labels `org-9 → machines: …` and `org-9: …`, not
412
+ `org-90 …`. Matching is case-sensitive, and the text is one non-empty line.
413
+ - `machine watch --dir <path>` names every top-level run whose working
414
+ directory is that path or inside it. The path is kept absolute.
415
+ - An entry keeps matching runs that start later, so a ticket can be watched
416
+ before its run exists. `machine watch` lists a matched run for as long as its
417
+ record remains, with the status `machine runs --all` gives it, so it also
418
+ says how a watched run ended.
419
+
420
+ A watched run is shown even when another session owns it or it works outside
421
+ the project. Everything else is as for any run: sub-runs stay hidden, a run is
422
+ listed once however many entries name it, and at most five runs are shown.
423
+
424
+ The list is one file per session beside the records,
425
+ `<dir>/.watch.<session id>.json` (`0600`, replaced atomically), which the
426
+ status line reads itself with the `session_id` Claude Code gives it:
427
+
428
+ ```json
429
+ { "schemaVersion": 1, "watches": [{ "text": "org-9" }, { "dir": "/work/other" }] }
430
+ ```
431
+
432
+ Record readers skip it, as they skip every name that starts with a dot. A
433
+ session id that is not safe as a file name is refused. Removing the last entry
434
+ removes the file, and writing a list removes other sessions' lists that have
435
+ not changed for 30 days. A missing, unreadable, or malformed list watches
436
+ nothing; it is never an error on the status line. The commands fail with a
437
+ message when publication is off or when no variable names the session.
438
+
360
439
  ## Troubleshooting
361
440
 
362
441
  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.1",
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.