@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 +7 -5
- package/claude/statusline.mjs +32 -7
- package/dist/mcp/server.js +4 -5
- package/dist/src/cli.js +5 -2
- package/dist/src/discovery.d.ts +1 -1
- package/dist/src/discovery.js +2 -2
- package/dist/src/host.d.ts +7 -2
- package/dist/src/host.js +4 -2
- package/dist/src/run-status.d.ts +23 -5
- package/dist/src/run-status.js +93 -19
- package/dist/src/run-watch.js +7 -6
- package/docs/architecture.md +7 -2
- package/docs/integrations.md +77 -23
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
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
|
package/claude/statusline.mjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// Claude Code status line for Machine runs
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// watch list and its matching mirror
|
|
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 =
|
|
241
|
-
if (directory === undefined ||
|
|
265
|
+
const directory = runStatusDirectory();
|
|
266
|
+
if (directory === undefined || process.stdin.isTTY) return;
|
|
242
267
|
|
|
243
268
|
let text = "";
|
|
244
269
|
process.stdin.setEncoding("utf8");
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/discovery.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/discovery.js
CHANGED
|
@@ -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 =
|
|
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))
|
package/dist/src/host.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
327
|
+
...(recorded ? { runId: id } : {}),
|
|
326
328
|
...(publishes ? { reportStatus: true } : {}),
|
|
327
329
|
};
|
|
328
330
|
void sendChild(child, launch).catch((cause) => fail(asError(cause)));
|
package/dist/src/run-status.d.ts
CHANGED
|
@@ -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 `<
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
101
|
-
*
|
|
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
|
/**
|
package/dist/src/run-status.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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 {
|
package/dist/src/run-watch.js
CHANGED
|
@@ -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
|
-
/**
|
|
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 (!
|
|
103
|
+
if (!watchListName.test(name) || file === kept)
|
|
103
104
|
continue;
|
|
104
105
|
try {
|
|
105
|
-
if (now - statSync(file).mtimeMs >
|
|
106
|
+
if (now - statSync(file).mtimeMs > watchRetentionMilliseconds)
|
|
106
107
|
rmSync(file, { force: true });
|
|
107
108
|
}
|
|
108
109
|
catch {
|
package/docs/architecture.md
CHANGED
|
@@ -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.
|
|
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
|
|
package/docs/integrations.md
CHANGED
|
@@ -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
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
203
|
-
|
|
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:
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
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.
|
|
271
|
-
whatever answers
|
|
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.
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
339
|
-
|
|
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,
|
|
348
|
-
|
|
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'
|
|
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
|