@dna113p/machines 0.3.2 → 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.2.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.2.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"));
@@ -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))
@@ -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
@@ -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,11 +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 its host asks with `publishRunStatus: true` and the
203
- variable is set in the hosting process, as a daemon that runs tickets does. A
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
204
223
  host that does not ask publishes nothing, whatever its environment says: a
205
224
  program that presents its runs itself, such as Pi with the extension loaded, is
206
- not changed by the variable. Two options replace the environment for that one run:
225
+ not changed by publication being on. Two options replace the environment for that one run:
207
226
  `label`, shortened as `MACHINES_RUN_LABEL` is, and `owner`. When an option is
208
227
  absent the environment rule above applies; an empty one publishes the run
209
228
  without that field. The run exports its id as `MACHINES_RUN_PARENT` to its
@@ -262,17 +281,37 @@ after it, and never contains the response.
262
281
 
263
282
  Machine input, Machine output, and Human responses are never recorded. A Human
264
283
  prompt and its choices are written to the `0600` file while the request is
265
- waiting, so choose a directory only you can read. Publication cannot change a
266
- run: a missing or unwritable directory is ignored. Starting a run prunes records
267
- that finished, or whose process is gone, more than an hour ago.
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.
268
306
 
269
307
  ### Answering a waiting run from outside
270
308
 
271
309
  With publication on, a waiting run can be seen and answered by any process
272
310
  running as the same user: a terminal, a coordinating agent, a script, or a
273
311
  presentation surface. They all read the same request, and the first valid
274
- response wins. Set the same `MACHINES_RUN_STATUS_DIR` for the run and for
275
- whatever answers it.
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.
276
315
 
277
316
  ```bash
278
317
  machine runs # running and waiting runs
@@ -330,17 +369,25 @@ waits, it holds the same file under a second name of its own,
330
369
  response and the sender removes it to take the response back, so exactly one of
331
370
  them succeeds and neither can act on a response that arrived later. A response
332
371
  that was taken back stays in the inbox, emptied, until the run next reads its
333
- inbox or finishes, and a new response is refused until then. **Anything that can write to the directory as you can answer a waiting
334
- run**, including approvals, so do not point `MACHINES_RUN_STATUS_DIR` at a
335
- shared directory.
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.
336
380
 
337
381
  `machine run` still asks on its own terminal, and whichever of the terminal and
338
382
  the inbox gives the first valid response is used. Terminal input outside a
339
383
  request's `choices` is reported and asked for again instead of failing the run.
340
384
  A published run started without terminal input, for example by a coordinating
341
385
  agent or a script, waits for `machine respond` instead of failing, and prints
342
- the exact command to its standard error. With publication off it fails at its
343
- first Human state with "Terminal input closed before a response", as before.
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.
344
391
  Runs hosted in any other program through `startMachineHost` are answered only
345
392
  through that program.
346
393
  Runs hosted by the MCP server take a response from their own `machine_respond`
@@ -348,12 +395,13 @@ tool or from the inbox. That tool is answer-only, so a question to a hosted
348
395
  request that allows discussion goes through the inbox with
349
396
  `machine respond --question`.
350
397
 
351
- For Claude Code, set the variable in the `env` of its settings so the runs it
352
- launches publish there, and call the dependency-free renderer from `statusLine`:
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.
353
402
 
354
403
  ```json
355
404
  {
356
- "env": { "MACHINES_RUN_STATUS_DIR": "/home/you/.cache/machines/run-status" },
357
405
  "statusLine": {
358
406
  "type": "command",
359
407
  "command": "node /path/to/machines/claude/statusline.mjs",
@@ -435,8 +483,8 @@ status line reads itself with the `session_id` Claude Code gives it:
435
483
 
436
484
  Record readers skip it, as they skip every name that starts with a dot. A
437
485
  session id that is not safe as a file name is refused. Removing the last entry
438
- removes the file, and writing a list removes other sessions' lists that have
439
- not changed for 30 days. A missing, unreadable, or malformed list watches
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
440
488
  nothing; it is never an error on the status line. The commands fail with a
441
489
  message when publication is off or when no variable names the session.
442
490
 
@@ -446,6 +494,8 @@ For an isolated global catalog, set `MACHINES_USER_HOME` to an absolute director
446
494
  Machines reads global definitions and presets from that directory's `.machines/`.
447
495
  Explicit programmatic `home` options take precedence. This is useful for tests or
448
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.
449
499
 
450
500
  - **Node cannot load TypeScript from a dependency:** install the built tarball;
451
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.2",
3
+ "version": "0.3.3",
4
4
  "description": "Reusable workflows coordinating agents, local operations, and human decisions.",
5
5
  "license": "MIT",
6
6
  "repository": {