@dna113p/machines 0.3.1 → 0.3.3

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.1.tgz` into a project:
179
+ Then install the generated `dna113p-machines-0.3.3.tgz` into a project:
180
180
 
181
181
  ```bash
182
- npm install /absolute/path/to/machines/dna113p-machines-0.3.1.tgz
182
+ npm install /absolute/path/to/machines/dna113p-machines-0.3.3.tgz
183
183
  npx machine list
184
184
  ```
185
185
 
@@ -193,9 +193,11 @@ installation script is required for the direct MCP setup.
193
193
  Hosts that only run a status-line command, such as Claude Code, can show running
194
194
  Machines through [run status publication](docs/integrations.md#run-status-for-host-status-lines).
195
195
 
196
- Publication is opt-in: set `MACHINES_RUN_STATUS_DIR` to a directory only you can
197
- read. A published run that is waiting for a Human can then be answered from
198
- outside its own terminal:
196
+ Publication is on by default, in a private per-user directory
197
+ (`~/.local/state/machines/runs`, or under `XDG_STATE_HOME`), so nothing has to be
198
+ configured. Set `MACHINES_RUN_STATUS_DIR` to `off` to turn it off, or to a
199
+ directory to move it. A published run that is waiting for a Human can be
200
+ answered from outside its own terminal:
199
201
 
200
202
  ```bash
201
203
  machine runs [--json] [--all] # running and waiting runs; --all adds finished and lost ones
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // Claude Code status line for Machine runs published through
4
- // MACHINES_RUN_STATUS_DIR (see docs/integrations.md). It runs on every status
5
- // refresh, so it imports only node: modules and never loads the runtime; the
6
- // record shape below mirrors RunStatusRecord in src/run-status.ts, and the
7
- // watch list and its matching mirror src/run-watch.ts.
3
+ // Claude Code status line for published Machine runs (see
4
+ // docs/integrations.md). It runs on every status refresh, so it imports only
5
+ // node: modules and never loads the runtime; the directory rule and the record
6
+ // shape below mirror runStatusDirectory and RunStatusRecord in
7
+ // src/run-status.ts, and the watch list and its matching mirror
8
+ // src/run-watch.ts.
8
9
 
9
10
  import { readdirSync, readFileSync, realpathSync } from "node:fs";
11
+ import { homedir } from "node:os";
10
12
  import { isAbsolute, join, resolve, sep } from "node:path";
11
13
  import { pathToFileURL } from "node:url";
12
14
 
@@ -20,6 +22,29 @@ const zeroWidth = /[\p{Mn}\p{Me}\p{Cf}\u1160-\u11ff]/u;
20
22
  // East Asian Wide and Fullwidth characters, and emoji drawn as pictures, take two columns.
21
23
  const wide = /[\u1100-\u115f\u2e80-\u303e\u3041-\ua4cf\ua960-\ua97f\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6\u{20000}-\u{3fffd}\p{Emoji_Presentation}]/u;
22
24
 
25
+ /**
26
+ * The directory runs publish to, or undefined while publication is off: the
27
+ * same answer as runStatusDirectory in src/run-status.ts for the same
28
+ * environment, which tests/run-status.test.ts checks.
29
+ */
30
+ export function runStatusDirectory(environment = process.env) {
31
+ const configured = environment.MACHINES_RUN_STATUS_DIR;
32
+ if (configured === "" || configured === "off") return undefined;
33
+ if (configured !== undefined) return resolve(configured);
34
+ // XDG_STATE_HOME describes the real home. A process given another Machines
35
+ // home keeps its runs under that one, and out of the real one.
36
+ const state = environment.XDG_STATE_HOME;
37
+ const machinesHome = environment.MACHINES_USER_HOME;
38
+ if (machinesHome === undefined && state !== undefined && isAbsolute(state)) return join(state, "machines", "runs");
39
+ try {
40
+ const home = machinesHome ?? homedir();
41
+ return isAbsolute(home) ? join(home, ".local", "state", "machines", "runs") : undefined;
42
+ } catch {
43
+ // Without a usable home there is nowhere standard to publish.
44
+ return undefined;
45
+ }
46
+ }
47
+
23
48
  /**
24
49
  * Builds the status lines for the runs one Claude Code session should see.
25
50
  * Pure apart from the default process-liveness probe, which callers may replace.
@@ -237,8 +262,8 @@ function readWatches(directory, sessionId) {
237
262
  }
238
263
 
239
264
  async function main() {
240
- const directory = process.env.MACHINES_RUN_STATUS_DIR;
241
- if (directory === undefined || directory === "" || process.stdin.isTTY) return;
265
+ const directory = runStatusDirectory();
266
+ if (directory === undefined || process.stdin.isTTY) return;
242
267
 
243
268
  let text = "";
244
269
  process.stdin.setEncoding("utf8");
@@ -10,7 +10,7 @@ import { isJsonValue } from "../src/json.js";
10
10
  import { MachineSession as RunSession } from "../src/session.js";
11
11
  import { listAgentPresets, listMachines } from "../src/launcher.js";
12
12
  import { watchRunInbox } from "../src/run-inbox.js";
13
- import { createRunStatusPublisher, runStatusDirectory } from "../src/run-status.js";
13
+ import { createRunStatusPublisher } from "../src/run-status.js";
14
14
  import { machineCoordinatorGuidelines, machineDiscoveryDescription, machineSupervisionGuidelines } from "../src/coordinator-guidance.js";
15
15
  const widgetUri = "ui://machines/run-v1.html";
16
16
  const cwd = v.pipe(v.string(), v.nonEmpty("cwd must be a non-empty absolute path"), v.check(isAbsolute, "cwd must be an absolute path"));
@@ -177,7 +177,7 @@ export class MachineSession {
177
177
  }
178
178
  async start(input) {
179
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
+ const run = await this.#session.start({ ...parse(startInput, input), publishRunStatus: "caller" });
181
181
  return result(`Started ${run.machine}.\nRun id: ${run.id}\nStatus: ${run.status}`, { run });
182
182
  }
183
183
  status(input) {
@@ -212,7 +212,7 @@ export class MachineSession {
212
212
  this.#published.set(run.id, publisher);
213
213
  }
214
214
  const human = run.status === "waiting" ? run.human : undefined;
215
- this.#watchInbox(run.id, human);
215
+ this.#watchInbox(run.id, human, publisher.directory);
216
216
  if (run.status === "running" || run.status === "waiting") {
217
217
  publisher.update({
218
218
  status: run.status, state: run.state, agent: run.agent, limit: run.limit, human, delivery: this.#taking,
@@ -223,13 +223,12 @@ export class MachineSession {
223
223
  this.#published.delete(run.id);
224
224
  }
225
225
  /** A published request can also be answered, or asked about, through the run's inbox by `machine respond`. */
226
- #watchInbox(runId, request) {
226
+ #watchInbox(runId, request, directory) {
227
227
  const watched = this.#inboxes.get(runId);
228
228
  if (watched?.requestId === request?.requestId)
229
229
  return;
230
230
  watched?.stop();
231
231
  this.#inboxes.delete(runId);
232
- const directory = runStatusDirectory();
233
232
  if (request === undefined || directory === undefined)
234
233
  return;
235
234
  const stop = watchRunInbox(directory, runId, request, (response, delivery) => {
package/dist/src/cli.js CHANGED
@@ -97,7 +97,7 @@ async function runMachine(machinePath, args) {
97
97
  const status = createRunStatus(machinePath);
98
98
  const runId = randomUUID();
99
99
  const published = createRunStatusPublisher({ id: runId, machine: prepared.name, path: prepared.path, cwd: process.cwd() });
100
- const statusDirectory = runStatusDirectory();
100
+ const statusDirectory = published.directory;
101
101
  // Machines started by this run's Operations and Agents are its sub-runs.
102
102
  process.env.MACHINES_RUN_PARENT = runId;
103
103
  const observed = runStatusObserver((change) => published.update(change));
@@ -265,7 +265,10 @@ async function respond(args) {
265
265
  function requireStatusDirectory() {
266
266
  const directory = runStatusDirectory();
267
267
  if (directory === undefined) {
268
- throw new Error("Run status publication is off: set MACHINES_RUN_STATUS_DIR to the directory the runs publish to.");
268
+ // Unset, the variable leaves publication on, so only a home that cannot hold the standard directory turns it off.
269
+ throw new Error(process.env.MACHINES_RUN_STATUS_DIR === undefined
270
+ ? "Run status publication is off: there is no absolute home directory to keep the standard directory in. Set MACHINES_RUN_STATUS_DIR to the directory the runs publish to."
271
+ : "Run status publication is off: unset MACHINES_RUN_STATUS_DIR to use the standard directory, or set it to the directory the runs publish to.");
269
272
  }
270
273
  return directory;
271
274
  }
@@ -1,5 +1,5 @@
1
1
  /** Select a separate global catalog without changing the process's real home. */
2
- export declare function machinesUserHome(): string;
2
+ export declare function machinesUserHome(environment?: NodeJS.ProcessEnv): string;
3
3
  export interface DiscoveredMachine {
4
4
  readonly name: string;
5
5
  readonly path: string;
@@ -2,8 +2,8 @@ import { readdir, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join } from "node:path";
4
4
  /** Select a separate global catalog without changing the process's real home. */
5
- export function machinesUserHome() {
6
- const override = process.env.MACHINES_USER_HOME;
5
+ export function machinesUserHome(environment = process.env) {
6
+ const override = environment.MACHINES_USER_HOME;
7
7
  if (override === undefined)
8
8
  return homedir();
9
9
  if (!isAbsolute(override))
@@ -11,8 +11,13 @@ export interface MachineHostOptions extends PrepareMachineRunOptions {
11
11
  readonly label?: string;
12
12
  /** The session that owns the run in its published run status. The environment's owner applies when absent. */
13
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;
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";
16
21
  readonly onAgentUpdate?: (update: AgentUpdate) => void;
17
22
  readonly onHumanRequest?: (request: HostedHumanRequest) => void;
18
23
  readonly onState?: (state: StateValue) => void;
package/dist/src/host.js CHANGED
@@ -118,7 +118,9 @@ export function startMachineHost(options) {
118
118
  // below), so a reader that finds the pid gone rightly shows the run as lost.
119
119
  const id = randomUUID();
120
120
  const publication = runStatusDirectory() !== undefined;
121
- const publishes = publication && options.publishRunStatus !== false;
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");
122
124
  let published;
123
125
  const child = fork(fileURLToPath(import.meta.url), [childArgument], {
124
126
  cwd,
@@ -322,7 +324,7 @@ export function startMachineHost(options) {
322
324
  ...(options.agents === undefined ? {} : { agents: options.agents }),
323
325
  ...(options.home === undefined ? {} : { home: options.home }),
324
326
  // A caller that publishes the record itself still publishes it under this id.
325
- ...(publication ? { runId: id } : {}),
327
+ ...(recorded ? { runId: id } : {}),
326
328
  ...(publishes ? { reportStatus: true } : {}),
327
329
  };
328
330
  void sendChild(child, launch).catch((cause) => fail(asError(cause)));
@@ -1,6 +1,6 @@
1
1
  import type { StateValue } from "xstate";
2
2
  import type { AgentUpdate } from "./index.ts";
3
- /** One published run, stored as `<MACHINES_RUN_STATUS_DIR>/<id>.json`. */
3
+ /** One published run, stored as `<id>.json` in the run-status directory. */
4
4
  export interface RunStatusRecord {
5
5
  /** Version 1 records carry only `human.prompt`. */
6
6
  readonly schemaVersion: 1 | 2;
@@ -81,12 +81,29 @@ export type RunStatusOutcome = {
81
81
  readonly error: string;
82
82
  };
83
83
  export interface RunStatusPublisher {
84
+ /**
85
+ * Where the run is published. Undefined when it is not: publication is off,
86
+ * or the directory could not take the run's first record.
87
+ */
88
+ readonly directory: string | undefined;
84
89
  update(change: RunStatusChange): void;
85
90
  finish(outcome: RunStatusOutcome): void;
86
91
  }
87
92
  /** How long a record goes on naming a response its run took: longer than a sender waits to hear of it. */
88
93
  export declare const deliveryRetentionMilliseconds = 60000;
89
- /** The directory runs publish to, or undefined while publication is off. */
94
+ /** How long a session's watch list is kept after its last change: sessions end without saying so. */
95
+ export declare const watchRetentionMilliseconds: number;
96
+ /** The name of a session's watch list, which `src/run-watch.ts` keeps beside the records. */
97
+ export declare const watchListName: RegExp;
98
+ /**
99
+ * The directory runs publish to, or undefined while publication is off.
100
+ * `MACHINES_RUN_STATUS_DIR` moves it, and turns publication off when it is
101
+ * `off` or empty. Unset, it is the user's own state directory, so every
102
+ * process of one user agrees on it without being told.
103
+ *
104
+ * `claude/statusline.mjs` cannot load this module and carries a copy of the
105
+ * rule; tests/run-status.test.ts keeps the two in step.
106
+ */
90
107
  export declare function runStatusDirectory(environment?: NodeJS.ProcessEnv): string | undefined;
91
108
  /** The one pending outside response for a run, kept beside its record. */
92
109
  export declare function runInboxPath(directory: string, id: string): string;
@@ -96,9 +113,10 @@ export declare function runInboxPath(directory: string, id: string): string;
96
113
  */
97
114
  export declare function runDeliveryPath(directory: string, id: string, delivery: string): string;
98
115
  /**
99
- * Publishes one run for hosts that can only read files, such as a status line.
100
- * Opt-in through MACHINES_RUN_STATUS_DIR. Publication is presentation: it never
101
- * throws, and it records neither Machine input nor output.
116
+ * Publishes one run for hosts that can only read files, such as a status line,
117
+ * in `runStatusDirectory`. Publication is presentation: it never throws, and it
118
+ * records neither Machine input nor output. A run whose first record cannot be
119
+ * written, as in a read-only home, is not published at all.
102
120
  */
103
121
  export declare function createRunStatusPublisher(run: RunStatusRun, environment?: NodeJS.ProcessEnv): RunStatusPublisher;
104
122
  /**
@@ -1,15 +1,49 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { basename, join, resolve } from "node:path";
2
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, isAbsolute, join, resolve } from "node:path";
4
+ import { machinesUserHome } from "./discovery.js";
4
5
  const staleAfterMilliseconds = 60 * 60 * 1_000;
5
6
  /** How long a record goes on naming a response its run took: longer than a sender waits to hear of it. */
6
7
  export const deliveryRetentionMilliseconds = 60_000;
7
- const disabled = { update() { }, finish() { } };
8
+ /** How long a session's watch list is kept after its last change: sessions end without saying so. */
9
+ export const watchRetentionMilliseconds = 30 * 24 * 60 * 60 * 1_000;
10
+ /** The name of a session's watch list, which `src/run-watch.ts` keeps beside the records. */
11
+ export const watchListName = /^\.watch\..+\.json$/u;
12
+ // A record being written, and the second names of a response: each lasts moments unless its process was stopped.
13
+ const leftoverName = /^\.[A-Za-z0-9._-]+\.(?:\d+\.\d+\.tmp|[A-Za-z0-9_-]+\.inbox\.(?:sent|taken))$/u;
14
+ // A run's inbox, which goes with its record: one without a record belongs to a run that was pruned.
15
+ const inboxName = /^[A-Za-z0-9_-][A-Za-z0-9._-]*\.inbox$/u;
16
+ const disabled = { directory: undefined, update() { }, finish() { } };
8
17
  let temporaryFiles = 0;
9
- /** The directory runs publish to, or undefined while publication is off. */
18
+ /**
19
+ * The directory runs publish to, or undefined while publication is off.
20
+ * `MACHINES_RUN_STATUS_DIR` moves it, and turns publication off when it is
21
+ * `off` or empty. Unset, it is the user's own state directory, so every
22
+ * process of one user agrees on it without being told.
23
+ *
24
+ * `claude/statusline.mjs` cannot load this module and carries a copy of the
25
+ * rule; tests/run-status.test.ts keeps the two in step.
26
+ */
10
27
  export function runStatusDirectory(environment = process.env) {
11
28
  const configured = environment.MACHINES_RUN_STATUS_DIR;
12
- return configured === undefined || configured === "" ? undefined : resolve(configured);
29
+ if (configured === "" || configured === "off")
30
+ return undefined;
31
+ if (configured !== undefined)
32
+ return resolve(configured);
33
+ // XDG_STATE_HOME describes the real home. A process given another Machines
34
+ // home keeps its runs under that one, and out of the real one.
35
+ const state = environment.XDG_STATE_HOME;
36
+ if (environment.MACHINES_USER_HOME === undefined && state !== undefined && isAbsolute(state)) {
37
+ return join(state, "machines", "runs");
38
+ }
39
+ try {
40
+ const home = machinesUserHome(environment);
41
+ return isAbsolute(home) ? join(home, ".local", "state", "machines", "runs") : undefined;
42
+ }
43
+ catch {
44
+ // Without a usable home there is nowhere standard to publish.
45
+ return undefined;
46
+ }
13
47
  }
14
48
  /** The one pending outside response for a run, kept beside its record. */
15
49
  export function runInboxPath(directory, id) {
@@ -23,9 +57,10 @@ export function runDeliveryPath(directory, id, delivery) {
23
57
  return join(directory, `.${id}.${delivery}.inbox.sent`);
24
58
  }
25
59
  /**
26
- * Publishes one run for hosts that can only read files, such as a status line.
27
- * Opt-in through MACHINES_RUN_STATUS_DIR. Publication is presentation: it never
28
- * throws, and it records neither Machine input nor output.
60
+ * Publishes one run for hosts that can only read files, such as a status line,
61
+ * in `runStatusDirectory`. Publication is presentation: it never throws, and it
62
+ * records neither Machine input nor output. A run whose first record cannot be
63
+ * written, as in a read-only home, is not published at all.
29
64
  */
30
65
  export function createRunStatusPublisher(run, environment = process.env) {
31
66
  const directory = runStatusDirectory(environment);
@@ -79,9 +114,11 @@ export function createRunStatusPublisher(run, environment = process.env) {
79
114
  rmSync(temporary, { force: true });
80
115
  throw cause;
81
116
  }
117
+ return true;
82
118
  }
83
119
  catch {
84
120
  // A status line is never worth a run: an unwritable directory is ignored.
121
+ return false;
85
122
  }
86
123
  };
87
124
  try {
@@ -90,8 +127,12 @@ export function createRunStatusPublisher(run, environment = process.env) {
90
127
  catch {
91
128
  // Pruning is best effort for the same reason.
92
129
  }
93
- publish();
130
+ // A directory that cannot take the first record is not tried again at every
131
+ // change, and the run goes on exactly as one that publication is off for.
132
+ if (!publish())
133
+ return disabled;
94
134
  return {
135
+ directory,
95
136
  update(change) {
96
137
  if (status === "completed" || status === "failed")
97
138
  return;
@@ -198,27 +239,60 @@ export function readRunStatuses(directory) {
198
239
  }
199
240
  function pruneRunStatuses(directory) {
200
241
  const now = Date.now();
242
+ const remove = (file) => {
243
+ try {
244
+ rmSync(file, { force: true });
245
+ }
246
+ catch {
247
+ // Not a file, or not ours to remove: nothing else waits on it.
248
+ }
249
+ };
201
250
  for (const { file, record } of readRecordFiles(directory)) {
202
251
  const settled = record.status === "completed" || record.status === "failed" || !isProcessAlive(record.pid);
203
252
  if (!settled || now - Date.parse(record.updatedAt) <= staleAfterMilliseconds)
204
253
  continue;
254
+ // A response nobody collected goes with the run it was meant for, under each
255
+ // of its names. The record goes last: a publisher stopped part way leaves it
256
+ // for the next one to prune whole.
257
+ const id = basename(file, ".json");
258
+ remove(runInboxPath(directory, id));
259
+ for (const name of listNames(directory)) {
260
+ if (!name.startsWith(`.${id}.`))
261
+ continue;
262
+ if (/^[^.]+\.inbox\.(?:sent|taken)$/u.test(name.slice(id.length + 2)))
263
+ remove(join(directory, name));
264
+ }
265
+ remove(file);
266
+ }
267
+ // What a stopped process left half done has no record to go with, nor has an
268
+ // inbox whose run was pruned without it, and a session that ended never
269
+ // removes its watch list. Other files are not ours.
270
+ const names = new Set(listNames(directory));
271
+ for (const name of names) {
272
+ const orphaned = inboxName.test(name) && !names.has(`${basename(name, ".inbox")}.json`);
273
+ const kept = leftoverName.test(name) || orphaned
274
+ ? staleAfterMilliseconds
275
+ : watchListName.test(name) ? watchRetentionMilliseconds : undefined;
276
+ if (kept === undefined)
277
+ continue;
278
+ const file = join(directory, name);
205
279
  try {
206
- rmSync(file, { force: true });
207
- // A response nobody collected goes with the run it was meant for, under each of its names.
208
- const id = basename(file, ".json");
209
- rmSync(runInboxPath(directory, id), { force: true });
210
- for (const name of readdirSync(directory)) {
211
- if (!name.startsWith(`.${id}.`))
212
- continue;
213
- if (/^[^.]+\.inbox\.(?:sent|taken)$/u.test(name.slice(id.length + 2)))
214
- rmSync(join(directory, name), { force: true });
215
- }
280
+ if (now - statSync(file).mtimeMs > kept)
281
+ rmSync(file, { force: true });
216
282
  }
217
283
  catch {
218
284
  // Another publisher may have pruned it first.
219
285
  }
220
286
  }
221
287
  }
288
+ function listNames(directory) {
289
+ try {
290
+ return readdirSync(directory);
291
+ }
292
+ catch {
293
+ return [];
294
+ }
295
+ }
222
296
  function readRecordFiles(directory) {
223
297
  let names;
224
298
  try {
@@ -1,8 +1,6 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { isAbsolute, join, resolve, sep } from "node:path";
3
- import { isFileSafeId } from "./run-status.js";
4
- const forgottenAfterMilliseconds = 30 * 24 * 60 * 60 * 1_000;
5
- const watchFile = /^\.watch\..+\.json$/u;
3
+ import { isFileSafeId, watchListName, watchRetentionMilliseconds } from "./run-status.js";
6
4
  let temporaryFiles = 0;
7
5
  /**
8
6
  * A session's watch list, kept beside the records under a name their readers
@@ -94,15 +92,18 @@ function pickWatch(value) {
94
92
  return { dir: resolve(dir) };
95
93
  return undefined;
96
94
  }
97
- /** Sessions end without saying so; a list nobody has changed for a month belongs to one that did. */
95
+ /**
96
+ * Sessions end without saying so; a list nobody has changed for a month belongs
97
+ * to one that did. A run that starts prunes them by the same rule.
98
+ */
98
99
  function pruneRunWatches(directory, kept) {
99
100
  const now = Date.now();
100
101
  for (const name of readdirSync(directory)) {
101
102
  const file = join(directory, name);
102
- if (!watchFile.test(name) || file === kept)
103
+ if (!watchListName.test(name) || file === kept)
103
104
  continue;
104
105
  try {
105
- if (now - statSync(file).mtimeMs > forgottenAfterMilliseconds)
106
+ if (now - statSync(file).mtimeMs > watchRetentionMilliseconds)
106
107
  rmSync(file, { force: true });
107
108
  }
108
109
  catch {
@@ -100,7 +100,12 @@ and that request. Claiming a request before asynchronous delivery prevents two
100
100
  answers from being accepted, and the identity prevents a stale card or dialog from
101
101
  answering a later prompt. Restricted choices are validated for every interface.
102
102
 
103
- `machine run` asks on its own terminal. With run-status publication on it also
103
+ `machine run` asks on its own terminal. Run-status publication is on unless
104
+ `MACHINES_RUN_STATUS_DIR` turns it off, in a private per-user directory that
105
+ every reader and writer finds by one rule (`runStatusDirectory`; the standalone
106
+ status line carries a tested copy). It is presentation and never changes a run:
107
+ a run that cannot write its first record is simply not published. With
108
+ publication on, `machine run` also
104
109
  publishes the pending request and its request ID, and takes the first valid
105
110
  response from either the terminal or a private per-run inbox file written by
106
111
  `machine respond`; the MCP session takes inbox responses through the same
@@ -108,7 +113,7 @@ session `respond` path as its tool, and inbox questions through the session's
108
113
  `ask`. The inbox is a second way to deliver a response to the owning run, not a
109
114
  second owner: the run still validates the request ID and choices, and a response
110
115
  to any other request is discarded. A run hosted directly through
111
- `startMachineHost` publishes its status without an inbox: its host remains the
116
+ `startMachineHost` publishes its status only when its host asks, and without an inbox: its host remains the
112
117
  only way to answer it, its record says so, and `machine respond` refuses it. See
113
118
  [Integrations](integrations.md#answering-a-waiting-run-from-outside).
114
119
 
@@ -170,12 +170,31 @@ Pi exit, session replacement, and reload terminate its session-owned runs.
170
170
 
171
171
  Pi and MCP Apps clients render run snapshots in-process. A host that can only run
172
172
  a command, such as Claude Code's status line, reads published run status instead.
173
- Publication is opt-in: set `MACHINES_RUN_STATUS_DIR` to an absolute directory.
174
- When it is unset nothing is written.
173
+ Publication is on by default and needs no setup: every process of one user
174
+ publishes to and reads from the same private directory, so a run started
175
+ anywhere, by a coding agent, a terminal, or a daemon, is on the status line, in
176
+ `machine runs`, and within reach of `machine respond`.
175
177
 
176
- `machine run`, the MCP server, and every run hosted through `startMachineHost`
177
- then keep one file per run, `<dir>/<runId>.json`, replaced atomically on each
178
- change (directory `0700`, file `0600`):
178
+ One variable refines it:
179
+
180
+ | `MACHINES_RUN_STATUS_DIR` | Run status |
181
+ | --- | --- |
182
+ | unset | Published in `$XDG_STATE_HOME/machines/runs` when `XDG_STATE_HOME` is an absolute path, otherwise in `~/.local/state/machines/runs` |
183
+ | `off`, or empty | Off: nothing is written, and the commands that read run status say so |
184
+ | a directory | Published there instead; a relative path is resolved against the working directory |
185
+
186
+ To move the directory, give the same absolute path to every process that starts
187
+ runs or reads them: the coding agent, a daemon that hosts runs, and the status
188
+ line. The same holds for `XDG_STATE_HOME`: a service that does not inherit the
189
+ one your shell sets uses `~/.local/state`. A process given its own Machines home
190
+ through `MACHINES_USER_HOME` publishes under that home, in
191
+ `<home>/.local/state/machines/runs`, whatever `XDG_STATE_HOME` says, so an
192
+ isolated catalog also keeps its runs out of your own directory. Where no
193
+ absolute home can be found, publication is off.
194
+
195
+ `machine run`, the MCP server, and a host that asks for it (below) keep one file
196
+ per run, `<dir>/<runId>.json`, replaced atomically on each change. The
197
+ directory is created by the first write, as `0700`, and each file is `0600`:
179
198
 
180
199
  ```json
181
200
  {
@@ -199,8 +218,11 @@ choice to publish that text. `machine run` exports its run id as
199
218
  records that id as `parent`; the status line shows only top-level runs.
200
219
 
201
220
  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:
221
+ the same record when its host asks with `publishRunStatus: true` and
222
+ publication is on in the hosting process, as a daemon that runs tickets does. A
223
+ host that does not ask publishes nothing, whatever its environment says: a
224
+ program that presents its runs itself, such as Pi with the extension loaded, is
225
+ not changed by publication being on. Two options replace the environment for that one run:
204
226
  `label`, shortened as `MACHINES_RUN_LABEL` is, and `owner`. When an option is
205
227
  absent the environment rule above applies; an empty one publishes the run
206
228
  without that field. The run exports its id as `MACHINES_RUN_PARENT` to its
@@ -216,7 +238,8 @@ only through the host's own `respond` and `ask`: the run reads no inbox, its
216
238
  `human` has no `requestId`, and `machine respond` refuses it. The field is absent
217
239
  from every other record and did not exist in earlier version 2 records. A caller
218
240
  that publishes its own record for the run, as the MCP server does so that its
219
- runs keep their inbox, passes `publishRunStatus: false`.
241
+ runs keep their inbox, passes `publishRunStatus: "caller"`: the host then writes
242
+ nothing, and the run still hands its id down to the runs it starts.
220
243
 
221
244
  While an Agent state waits for a [usage limit](authoring.md#usage-limits) to
222
245
  reset, the record stays `running` and carries the wait in `limit`:
@@ -258,17 +281,37 @@ after it, and never contains the response.
258
281
 
259
282
  Machine input, Machine output, and Human responses are never recorded. A Human
260
283
  prompt and its choices are written to the `0600` file while the request is
261
- waiting, so choose a directory only you can read. Publication cannot change a
262
- run: a missing or unwritable directory is ignored. Starting a run prunes records
263
- that finished, or whose process is gone, more than an hour ago.
284
+ waiting. The standard directory is private to you; one you name instead should
285
+ be a directory only you can read and write.
286
+
287
+ Publication cannot change a run. Where the directory cannot be created or
288
+ written, as in a read-only home, a sandbox, or a container, a run goes on
289
+ exactly as one with publication off: it is not published, nothing is reported,
290
+ and the directory is not tried again at each state. Reading a directory that
291
+ does not exist yet finds no runs; it is not an error.
292
+
293
+ The directory does not grow without bound. Each run that starts removes:
294
+
295
+ - the record of a run that finished, or whose process is gone, more than an hour
296
+ ago, with its inbox and any response left for it;
297
+ - what an interrupted write or delivery left behind more than an hour ago;
298
+ - an inbox with no record beside it, left when a run was pruned without it,
299
+ more than an hour after it was last written;
300
+ - a [watch list](#watching-other-runs) that has not changed for 30 days.
301
+
302
+ A running or waiting run keeps its record for as long as its process lives.
303
+ Files Machines did not name are never removed, and nothing is removed while no
304
+ run starts. A record whose process id now belongs to another process counts as
305
+ running until that process ends.
264
306
 
265
307
  ### Answering a waiting run from outside
266
308
 
267
309
  With publication on, a waiting run can be seen and answered by any process
268
310
  running as the same user: a terminal, a coordinating agent, a script, or a
269
311
  presentation surface. They all read the same request, and the first valid
270
- response wins. Set the same `MACHINES_RUN_STATUS_DIR` for the run and for
271
- whatever answers it.
312
+ response wins. With the standard directory there is nothing to set; if you move
313
+ it, set the same `MACHINES_RUN_STATUS_DIR` for the run and for whatever answers
314
+ it. With publication off these commands fail and say how to turn it on.
272
315
 
273
316
  ```bash
274
317
  machine runs # running and waiting runs
@@ -326,17 +369,25 @@ waits, it holds the same file under a second name of its own,
326
369
  response and the sender removes it to take the response back, so exactly one of
327
370
  them succeeds and neither can act on a response that arrived later. A response
328
371
  that was taken back stays in the inbox, emptied, until the run next reads its
329
- inbox or finishes, and a new response is refused until then. **Anything that can write to the directory as you can answer a waiting
330
- run**, including approvals, so do not point `MACHINES_RUN_STATUS_DIR` at a
331
- shared directory.
372
+ inbox or finishes, and a new response is refused until then.
373
+
374
+ **Anything that can write to the directory as you can answer a waiting run**,
375
+ including approvals. That is why the standard directory is a private per-user
376
+ one, created `0700`: no other user can read a request or leave a response. It
377
+ is also all that protects a waiting run, so do not loosen its permissions and do
378
+ not point `MACHINES_RUN_STATUS_DIR` at a shared directory. Set it to `off`
379
+ where no process of yours should be able to answer a run from outside it.
332
380
 
333
381
  `machine run` still asks on its own terminal, and whichever of the terminal and
334
382
  the inbox gives the first valid response is used. Terminal input outside a
335
383
  request's `choices` is reported and asked for again instead of failing the run.
336
384
  A published run started without terminal input, for example by a coordinating
337
385
  agent or a script, waits for `machine respond` instead of failing, and prints
338
- the exact command to its standard error. With publication off it fails at its
339
- first Human state with "Terminal input closed before a response", as before.
386
+ the exact command to its standard error. With publication on by default this is
387
+ what a script or a CI job gets as well. With publication off, or where the run
388
+ could not be published, it fails at its first Human state with "Terminal input
389
+ closed before a response": set `MACHINES_RUN_STATUS_DIR=off` for a run that
390
+ should fail there instead of waiting.
340
391
  Runs hosted in any other program through `startMachineHost` are answered only
341
392
  through that program.
342
393
  Runs hosted by the MCP server take a response from their own `machine_respond`
@@ -344,12 +395,13 @@ tool or from the inbox. That tool is answer-only, so a question to a hosted
344
395
  request that allows discussion goes through the inbox with
345
396
  `machine respond --question`.
346
397
 
347
- For Claude Code, set the variable in the `env` of its settings so the runs it
348
- launches publish there, and call the dependency-free renderer from `statusLine`:
398
+ For Claude Code, call the dependency-free renderer from `statusLine`. That is
399
+ the whole setup: the runs Claude Code launches, a daemon that hosts runs as the
400
+ same user, and the status line all use the standard directory, so there is no
401
+ `env` block to add and no path to repeat anywhere.
349
402
 
350
403
  ```json
351
404
  {
352
- "env": { "MACHINES_RUN_STATUS_DIR": "/home/you/.cache/machines/run-status" },
353
405
  "statusLine": {
354
406
  "type": "command",
355
407
  "command": "node /path/to/machines/claude/statusline.mjs",
@@ -431,8 +483,8 @@ status line reads itself with the `session_id` Claude Code gives it:
431
483
 
432
484
  Record readers skip it, as they skip every name that starts with a dot. A
433
485
  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
486
+ removes the file, and writing a list or starting a run removes other sessions'
487
+ lists that have not changed for 30 days. A missing, unreadable, or malformed list watches
436
488
  nothing; it is never an error on the status line. The commands fail with a
437
489
  message when publication is off or when no variable names the session.
438
490
 
@@ -442,6 +494,8 @@ For an isolated global catalog, set `MACHINES_USER_HOME` to an absolute director
442
494
  Machines reads global definitions and presets from that directory's `.machines/`.
443
495
  Explicit programmatic `home` options take precedence. This is useful for tests or
444
496
  separate workflow collections without changing the operating-system home directory.
497
+ A process with that variable also publishes its
498
+ [run status](#run-status-for-host-status-lines) under that directory.
445
499
 
446
500
  - **Node cannot load TypeScript from a dependency:** install the built tarball;
447
501
  source exports are for checkout development. Keep workflow definitions outside
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dna113p/machines",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Reusable workflows coordinating agents, local operations, and human decisions.",
5
5
  "license": "MIT",
6
6
  "repository": {