@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 +7 -5
- package/claude/statusline.mjs +32 -7
- package/dist/mcp/server.js +3 -4
- 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/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 +6 -1
- package/docs/integrations.md +73 -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"));
|
|
@@ -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/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
|
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,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
|
|
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
|
|
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
|
|
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
|
|
266
|
-
|
|
267
|
-
|
|
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.
|
|
275
|
-
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.
|
|
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.
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
343
|
-
|
|
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,
|
|
352
|
-
|
|
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'
|
|
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
|