@dna113p/machines 0.3.2 → 0.3.4
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 +12 -5
- package/claude/statusline.mjs +33 -7
- package/dist/mcp/server.js +3 -4
- package/dist/src/cli.js +110 -18
- package/dist/src/discovery.d.ts +1 -1
- package/dist/src/discovery.js +2 -2
- package/dist/src/run-status.d.ts +26 -6
- package/dist/src/run-status.js +101 -21
- package/dist/src/run-viewer.d.ts +55 -0
- package/dist/src/run-viewer.js +65 -0
- package/dist/src/run-watch.d.ts +3 -1
- package/dist/src/run-watch.js +17 -12
- package/docs/architecture.md +16 -1
- package/docs/integrations.md +203 -29
- package/package.json +3 -2
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.4.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.4.tgz
|
|
183
183
|
npx machine list
|
|
184
184
|
```
|
|
185
185
|
|
|
@@ -193,12 +193,15 @@ 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
|
|
204
|
+
machine runs --for [<session>] [--project <dir>] [--json] [--all] # the runs one viewer shows
|
|
202
205
|
machine respond <run-id> <request-id> <response> # answer a waiting run
|
|
203
206
|
machine watch [<run-id-or-label-start> | --dir <path>] # show other runs on this session's status line
|
|
204
207
|
machine unwatch <run-id-or-label-start> | --dir <path> | --all
|
|
@@ -213,6 +216,10 @@ Runs hosted through `startMachineHost` publish too and are answered through thei
|
|
|
213
216
|
host. A session shows the runs it started; `machine watch` adds runs it did not,
|
|
214
217
|
such as a ticket a daemon runs. See
|
|
215
218
|
[watching other runs](docs/integrations.md#watching-other-runs).
|
|
219
|
+
`machine runs --for` and the `@dna113p/machines/run-status` export answer which
|
|
220
|
+
runs one session shows, so any application can
|
|
221
|
+
[draw its own widgets](docs/integrations.md#drawing-your-own-widgets); `watch`
|
|
222
|
+
and `unwatch` take `--for <session>` first to manage that session's list.
|
|
216
223
|
|
|
217
224
|
## Execution model and limitations
|
|
218
225
|
|
package/claude/statusline.mjs
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
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 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, the watch list and its matching mirror src/run-watch.ts,
|
|
8
|
+
// and which runs a session sees, and in what order, mirrors runsForViewer in
|
|
9
|
+
// src/run-viewer.ts, which tests/run-viewer.test.ts checks.
|
|
8
10
|
|
|
9
11
|
import { readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
10
13
|
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
11
14
|
import { pathToFileURL } from "node:url";
|
|
12
15
|
|
|
@@ -20,6 +23,29 @@ const zeroWidth = /[\p{Mn}\p{Me}\p{Cf}\u1160-\u11ff]/u;
|
|
|
20
23
|
// East Asian Wide and Fullwidth characters, and emoji drawn as pictures, take two columns.
|
|
21
24
|
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
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The directory runs publish to, or undefined while publication is off: the
|
|
28
|
+
* same answer as runStatusDirectory in src/run-status.ts for the same
|
|
29
|
+
* environment, which tests/run-status.test.ts checks.
|
|
30
|
+
*/
|
|
31
|
+
export function runStatusDirectory(environment = process.env) {
|
|
32
|
+
const configured = environment.MACHINES_RUN_STATUS_DIR;
|
|
33
|
+
if (configured === "" || configured === "off") return undefined;
|
|
34
|
+
if (configured !== undefined) return resolve(configured);
|
|
35
|
+
// XDG_STATE_HOME describes the real home. A process given another Machines
|
|
36
|
+
// home keeps its runs under that one, and out of the real one.
|
|
37
|
+
const state = environment.XDG_STATE_HOME;
|
|
38
|
+
const machinesHome = environment.MACHINES_USER_HOME;
|
|
39
|
+
if (machinesHome === undefined && state !== undefined && isAbsolute(state)) return join(state, "machines", "runs");
|
|
40
|
+
try {
|
|
41
|
+
const home = machinesHome ?? homedir();
|
|
42
|
+
return isAbsolute(home) ? join(home, ".local", "state", "machines", "runs") : undefined;
|
|
43
|
+
} catch {
|
|
44
|
+
// Without a usable home there is nowhere standard to publish.
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
23
49
|
/**
|
|
24
50
|
* Builds the status lines for the runs one Claude Code session should see.
|
|
25
51
|
* Pure apart from the default process-liveness probe, which callers may replace.
|
|
@@ -237,8 +263,8 @@ function readWatches(directory, sessionId) {
|
|
|
237
263
|
}
|
|
238
264
|
|
|
239
265
|
async function main() {
|
|
240
|
-
const directory =
|
|
241
|
-
if (directory === undefined ||
|
|
266
|
+
const directory = runStatusDirectory();
|
|
267
|
+
if (directory === undefined || process.stdin.isTTY) return;
|
|
242
268
|
|
|
243
269
|
let text = "";
|
|
244
270
|
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
|
@@ -12,11 +12,15 @@ import { listAgentPresets, listMachines, prepareMachineRun, } from "./launcher.j
|
|
|
12
12
|
import { inboxHuman, respondToRun } from "./run-inbox.js";
|
|
13
13
|
import { createRunStatusPublisher, isProcessAlive, readRunStatuses, runSessionId, runStatusDirectory, runStatusObserver, } from "./run-status.js";
|
|
14
14
|
import { readRunWatches, runWatch, runWatchMatches, runWatchPath, sameRunWatch, writeRunWatches, } from "./run-watch.js";
|
|
15
|
+
import { runsForViewer } from "./run-viewer.js";
|
|
15
16
|
const [command, selector, ...argumentsAfterSelector] = process.argv.slice(2);
|
|
16
17
|
const runsOptions = selector === undefined ? [] : [selector, ...argumentsAfterSelector];
|
|
17
18
|
const watchUsage = "machine watch [<run-id-or-label-start> | --dir <path>]";
|
|
18
19
|
const unwatchUsage = "machine unwatch <run-id-or-label-start> | --dir <path> | --all";
|
|
20
|
+
const watchForUsage = "machine watch --for <session> [<run-id-or-label-start> | --dir <path>]";
|
|
21
|
+
const unwatchForUsage = "machine unwatch --for <session> <run-id-or-label-start> | --dir <path> | --all";
|
|
19
22
|
try {
|
|
23
|
+
const listing = command === "runs" ? parseRunsOptions(runsOptions) : undefined;
|
|
20
24
|
if (command === "list" && selector === undefined) {
|
|
21
25
|
const machines = await listMachines({ cwd: process.cwd() });
|
|
22
26
|
if (machines.length === 0)
|
|
@@ -43,9 +47,11 @@ try {
|
|
|
43
47
|
else if (command === "run" && selector !== undefined) {
|
|
44
48
|
await runMachine(await resolveMachine(selector), argumentsAfterSelector);
|
|
45
49
|
}
|
|
46
|
-
else if (
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
else if (listing !== undefined) {
|
|
51
|
+
if (listing.viewer === undefined)
|
|
52
|
+
listRuns(listing.json, listing.all);
|
|
53
|
+
else
|
|
54
|
+
listViewerRuns(listing.json, listing.all, listing.viewer);
|
|
49
55
|
}
|
|
50
56
|
else if (command === "respond" && selector !== undefined) {
|
|
51
57
|
await respond([selector, ...argumentsAfterSelector]);
|
|
@@ -64,9 +70,12 @@ try {
|
|
|
64
70
|
" machine show <name-or-file>",
|
|
65
71
|
" machine run <name-or-file> [--agent role=preset]... [--input-file <file|->] [--] [input]",
|
|
66
72
|
" machine runs [--json] [--all]",
|
|
73
|
+
" machine runs --for [<session>] [--project <dir>] [--json] [--all]",
|
|
67
74
|
" machine respond [--question] <run-id> <request-id> [--] <response>",
|
|
68
75
|
` ${watchUsage}`,
|
|
69
76
|
` ${unwatchUsage}`,
|
|
77
|
+
` ${watchForUsage}`,
|
|
78
|
+
` ${unwatchForUsage}`,
|
|
70
79
|
].join("\n"));
|
|
71
80
|
}
|
|
72
81
|
}
|
|
@@ -97,7 +106,7 @@ async function runMachine(machinePath, args) {
|
|
|
97
106
|
const status = createRunStatus(machinePath);
|
|
98
107
|
const runId = randomUUID();
|
|
99
108
|
const published = createRunStatusPublisher({ id: runId, machine: prepared.name, path: prepared.path, cwd: process.cwd() });
|
|
100
|
-
const statusDirectory =
|
|
109
|
+
const statusDirectory = published.directory;
|
|
101
110
|
// Machines started by this run's Operations and Agents are its sub-runs.
|
|
102
111
|
process.env.MACHINES_RUN_PARENT = runId;
|
|
103
112
|
const observed = runStatusObserver((change) => published.update(change));
|
|
@@ -164,56 +173,136 @@ function listRuns(json, all) {
|
|
|
164
173
|
printRunListing(record, index > 0, watches.some((watched) => runWatchMatches(watched, record)));
|
|
165
174
|
});
|
|
166
175
|
}
|
|
176
|
+
/** What `machine runs` was asked; undefined when the options are not ones it takes. */
|
|
177
|
+
function parseRunsOptions(args) {
|
|
178
|
+
let json = false;
|
|
179
|
+
let all = false;
|
|
180
|
+
let forViewer = false;
|
|
181
|
+
let session;
|
|
182
|
+
let project;
|
|
183
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
184
|
+
const option = args[index];
|
|
185
|
+
const next = args[index + 1];
|
|
186
|
+
// What follows an option is its value unless it is another option.
|
|
187
|
+
const value = next === undefined || next.startsWith("--") ? undefined : next;
|
|
188
|
+
if (option === "--json" && !json)
|
|
189
|
+
json = true;
|
|
190
|
+
else if (option === "--all" && !all)
|
|
191
|
+
all = true;
|
|
192
|
+
else if (option === "--for" && !forViewer) {
|
|
193
|
+
forViewer = true;
|
|
194
|
+
// Without a session id the viewer is this process's own session.
|
|
195
|
+
session = value;
|
|
196
|
+
if (value !== undefined)
|
|
197
|
+
index += 1;
|
|
198
|
+
}
|
|
199
|
+
else if (option === "--project" && project === undefined && value !== undefined) {
|
|
200
|
+
project = value;
|
|
201
|
+
index += 1;
|
|
202
|
+
}
|
|
203
|
+
else
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
// A project is a viewer's: without --for the listing is of every session and project.
|
|
207
|
+
if (!forViewer)
|
|
208
|
+
return project === undefined ? { json, all } : undefined;
|
|
209
|
+
const viewer = { ...(session === undefined ? {} : { session }), ...(project === undefined ? {} : { project }) };
|
|
210
|
+
return { json, all, viewer };
|
|
211
|
+
}
|
|
212
|
+
/** The runs one viewer shows, by the rule its status line applies. */
|
|
213
|
+
function listViewerRuns(json, all, viewer) {
|
|
214
|
+
if (viewer.project === "")
|
|
215
|
+
throw new Error("Expected a directory after --project");
|
|
216
|
+
const { directory, session } = requireWatchList(viewer.session);
|
|
217
|
+
const watches = readRunWatches(directory, session);
|
|
218
|
+
// As they are read, not by start time: runs updated in the same millisecond
|
|
219
|
+
// keep that order, here as in the library and on the status line.
|
|
220
|
+
const runs = runsForViewer(readRunStatuses(directory), {
|
|
221
|
+
sessionId: session,
|
|
222
|
+
// Compared absolute, wherever the command ran and however the path was written.
|
|
223
|
+
...(viewer.project === undefined ? {} : { projectDir: resolve(viewer.project) }),
|
|
224
|
+
watches,
|
|
225
|
+
}, { all });
|
|
226
|
+
if (json) {
|
|
227
|
+
console.log(JSON.stringify(runs, null, 2));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (runs.length === 0)
|
|
231
|
+
console.log(`Session ${session} shows no Machine runs.`);
|
|
232
|
+
runs.forEach((run, index) => {
|
|
233
|
+
printRunListing(run, index > 0, watches.some((watched) => runWatchMatches(watched, run)));
|
|
234
|
+
});
|
|
235
|
+
}
|
|
167
236
|
/** Every record in the directory, including runs that finished or lost their process and are still recorded. */
|
|
168
237
|
function publishedRuns(directory) {
|
|
169
238
|
return readRunStatuses(directory).sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt));
|
|
170
239
|
}
|
|
171
240
|
/** A run that stopped reporting without finishing has lost its process, as on the status line. */
|
|
172
241
|
function runStatus(record) {
|
|
242
|
+
// A viewer's run already has the status its process leaves it.
|
|
243
|
+
if ("shown" in record)
|
|
244
|
+
return record.status;
|
|
173
245
|
return record.status !== "completed" && record.status !== "failed" && !isProcessAlive(record.pid) ? "lost" : record.status;
|
|
174
246
|
}
|
|
175
247
|
function isActiveRun(record) {
|
|
176
248
|
const status = runStatus(record);
|
|
177
249
|
return status === "running" || status === "waiting";
|
|
178
250
|
}
|
|
179
|
-
function watch(
|
|
180
|
-
const {
|
|
251
|
+
function watch(given) {
|
|
252
|
+
const { named, args, usage, who } = watchSession(given, watchUsage, watchForUsage);
|
|
253
|
+
const { directory, session } = requireWatchList(named);
|
|
181
254
|
const watches = readRunWatches(directory, session);
|
|
182
255
|
const runs = publishedRuns(directory);
|
|
183
256
|
if (args.length === 0) {
|
|
184
257
|
if (watches.length === 0)
|
|
185
|
-
console.log(
|
|
258
|
+
console.log(`${who} watches no other runs.`);
|
|
186
259
|
for (const watched of watches) {
|
|
187
260
|
console.log(describeWatch(watched));
|
|
188
261
|
printWatchedRuns(watched, runs);
|
|
189
262
|
}
|
|
190
263
|
return;
|
|
191
264
|
}
|
|
192
|
-
const added = parseWatch(args,
|
|
265
|
+
const added = parseWatch(args, usage);
|
|
193
266
|
if (!watches.some((watched) => sameRunWatch(watched, added)))
|
|
194
267
|
writeRunWatches(directory, session, [...watches, added]);
|
|
195
268
|
console.log(`Watching ${describeWatch(added)}`);
|
|
196
269
|
printWatchedRuns(added, runs);
|
|
197
270
|
}
|
|
198
|
-
function unwatch(
|
|
199
|
-
const {
|
|
271
|
+
function unwatch(given) {
|
|
272
|
+
const { named, args, usage, who } = watchSession(given, unwatchUsage, unwatchForUsage);
|
|
273
|
+
if (args.length === 0)
|
|
274
|
+
throw new Error(`Usage: ${usage}`);
|
|
275
|
+
const { directory, session } = requireWatchList(named);
|
|
200
276
|
if (args[0] === "--all") {
|
|
201
277
|
if (args.length !== 1)
|
|
202
|
-
throw new Error(`Usage: ${
|
|
278
|
+
throw new Error(`Usage: ${usage}`);
|
|
203
279
|
writeRunWatches(directory, session, []);
|
|
204
|
-
console.log(
|
|
280
|
+
console.log(`${who} watches no other runs.`);
|
|
205
281
|
return;
|
|
206
282
|
}
|
|
207
|
-
const removed = parseWatch(args,
|
|
283
|
+
const removed = parseWatch(args, usage);
|
|
208
284
|
const watches = readRunWatches(directory, session);
|
|
209
285
|
const kept = watches.filter((watched) => !sameRunWatch(watched, removed));
|
|
210
286
|
if (kept.length === watches.length) {
|
|
211
|
-
console.log(
|
|
287
|
+
console.log(`${who} was not watching ${describeWatch(removed)}`);
|
|
212
288
|
return;
|
|
213
289
|
}
|
|
214
290
|
writeRunWatches(directory, session, kept);
|
|
215
291
|
console.log(`No longer watching ${describeWatch(removed)}`);
|
|
216
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* Takes a leading `--for <session>` from what `machine watch` and
|
|
295
|
+
* `machine unwatch` were given: the list to change is then that session's and
|
|
296
|
+
* not this process's own.
|
|
297
|
+
*/
|
|
298
|
+
function watchSession(given, usage, forUsage) {
|
|
299
|
+
if (given[0] !== "--for")
|
|
300
|
+
return { named: undefined, args: given, usage, who: "This session" };
|
|
301
|
+
const named = given[1];
|
|
302
|
+
if (named === undefined || named.startsWith("--"))
|
|
303
|
+
throw new Error(`Usage: ${forUsage}`);
|
|
304
|
+
return { named, args: given.slice(2), usage: forUsage, who: `Session ${named}` };
|
|
305
|
+
}
|
|
217
306
|
function parseWatch(args, usage) {
|
|
218
307
|
if (args[0] === "--dir") {
|
|
219
308
|
if (args.length !== 2)
|
|
@@ -234,10 +323,10 @@ function printWatchedRuns(watched, runs) {
|
|
|
234
323
|
for (const record of matched)
|
|
235
324
|
console.log(` ${runColumns(record).join("\t")}`);
|
|
236
325
|
}
|
|
237
|
-
/** The session whose watch list this process
|
|
238
|
-
function requireWatchList() {
|
|
326
|
+
/** The session whose watch list is read or changed, this process's own unless one is named, and where the list is kept. */
|
|
327
|
+
function requireWatchList(named) {
|
|
239
328
|
const directory = requireStatusDirectory();
|
|
240
|
-
const session = runSessionId();
|
|
329
|
+
const session = named ?? runSessionId();
|
|
241
330
|
if (session === undefined) {
|
|
242
331
|
throw new Error("No session to watch for: set MACHINES_RUN_OWNER to the id of the session whose status line shows the runs.");
|
|
243
332
|
}
|
|
@@ -265,7 +354,10 @@ async function respond(args) {
|
|
|
265
354
|
function requireStatusDirectory() {
|
|
266
355
|
const directory = runStatusDirectory();
|
|
267
356
|
if (directory === undefined) {
|
|
268
|
-
|
|
357
|
+
// Unset, the variable leaves publication on, so only a home that cannot hold the standard directory turns it off.
|
|
358
|
+
throw new Error(process.env.MACHINES_RUN_STATUS_DIR === undefined
|
|
359
|
+
? "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."
|
|
360
|
+
: "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
361
|
}
|
|
270
362
|
return directory;
|
|
271
363
|
}
|
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
|
/**
|
|
@@ -111,7 +129,9 @@ export declare function runStatusObserver(update: (change: RunStatusChange) => v
|
|
|
111
129
|
};
|
|
112
130
|
/**
|
|
113
131
|
* The session a process belongs to: the owner of the runs it starts, and the
|
|
114
|
-
* session whose watch list `machine watch` changes.
|
|
132
|
+
* session whose watch list `machine watch` changes. `MACHINES_RUN_OWNER` is how
|
|
133
|
+
* any host names it; the others are what Claude Code, Codex, and Pi give the
|
|
134
|
+
* commands they start.
|
|
115
135
|
*/
|
|
116
136
|
export declare function runSessionId(environment?: NodeJS.ProcessEnv): string | undefined;
|
|
117
137
|
/** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
|
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;
|
|
@@ -187,10 +228,16 @@ export function runStatusObserver(update) {
|
|
|
187
228
|
}
|
|
188
229
|
/**
|
|
189
230
|
* The session a process belongs to: the owner of the runs it starts, and the
|
|
190
|
-
* session whose watch list `machine watch` changes.
|
|
231
|
+
* session whose watch list `machine watch` changes. `MACHINES_RUN_OWNER` is how
|
|
232
|
+
* any host names it; the others are what Claude Code, Codex, and Pi give the
|
|
233
|
+
* commands they start.
|
|
191
234
|
*/
|
|
192
235
|
export function runSessionId(environment = process.env) {
|
|
193
|
-
return environment.MACHINES_RUN_OWNER
|
|
236
|
+
return environment.MACHINES_RUN_OWNER
|
|
237
|
+
|| environment.CLAUDE_CODE_SESSION_ID
|
|
238
|
+
|| environment.CODEX_THREAD_ID
|
|
239
|
+
|| environment.PI_SESSION_ID
|
|
240
|
+
|| undefined;
|
|
194
241
|
}
|
|
195
242
|
/** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
|
|
196
243
|
export function readRunStatuses(directory) {
|
|
@@ -198,27 +245,60 @@ export function readRunStatuses(directory) {
|
|
|
198
245
|
}
|
|
199
246
|
function pruneRunStatuses(directory) {
|
|
200
247
|
const now = Date.now();
|
|
248
|
+
const remove = (file) => {
|
|
249
|
+
try {
|
|
250
|
+
rmSync(file, { force: true });
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Not a file, or not ours to remove: nothing else waits on it.
|
|
254
|
+
}
|
|
255
|
+
};
|
|
201
256
|
for (const { file, record } of readRecordFiles(directory)) {
|
|
202
257
|
const settled = record.status === "completed" || record.status === "failed" || !isProcessAlive(record.pid);
|
|
203
258
|
if (!settled || now - Date.parse(record.updatedAt) <= staleAfterMilliseconds)
|
|
204
259
|
continue;
|
|
260
|
+
// A response nobody collected goes with the run it was meant for, under each
|
|
261
|
+
// of its names. The record goes last: a publisher stopped part way leaves it
|
|
262
|
+
// for the next one to prune whole.
|
|
263
|
+
const id = basename(file, ".json");
|
|
264
|
+
remove(runInboxPath(directory, id));
|
|
265
|
+
for (const name of listNames(directory)) {
|
|
266
|
+
if (!name.startsWith(`.${id}.`))
|
|
267
|
+
continue;
|
|
268
|
+
if (/^[^.]+\.inbox\.(?:sent|taken)$/u.test(name.slice(id.length + 2)))
|
|
269
|
+
remove(join(directory, name));
|
|
270
|
+
}
|
|
271
|
+
remove(file);
|
|
272
|
+
}
|
|
273
|
+
// What a stopped process left half done has no record to go with, nor has an
|
|
274
|
+
// inbox whose run was pruned without it, and a session that ended never
|
|
275
|
+
// removes its watch list. Other files are not ours.
|
|
276
|
+
const names = new Set(listNames(directory));
|
|
277
|
+
for (const name of names) {
|
|
278
|
+
const orphaned = inboxName.test(name) && !names.has(`${basename(name, ".inbox")}.json`);
|
|
279
|
+
const kept = leftoverName.test(name) || orphaned
|
|
280
|
+
? staleAfterMilliseconds
|
|
281
|
+
: watchListName.test(name) ? watchRetentionMilliseconds : undefined;
|
|
282
|
+
if (kept === undefined)
|
|
283
|
+
continue;
|
|
284
|
+
const file = join(directory, name);
|
|
205
285
|
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
|
-
}
|
|
286
|
+
if (now - statSync(file).mtimeMs > kept)
|
|
287
|
+
rmSync(file, { force: true });
|
|
216
288
|
}
|
|
217
289
|
catch {
|
|
218
290
|
// Another publisher may have pruned it first.
|
|
219
291
|
}
|
|
220
292
|
}
|
|
221
293
|
}
|
|
294
|
+
function listNames(directory) {
|
|
295
|
+
try {
|
|
296
|
+
return readdirSync(directory);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
}
|
|
222
302
|
function readRecordFiles(directory) {
|
|
223
303
|
let names;
|
|
224
304
|
try {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type RunStatusRecord } from "./run-status.ts";
|
|
2
|
+
import { type RunWatch } from "./run-watch.ts";
|
|
3
|
+
export { readRunStatuses, runSessionId, runStatusDirectory } from "./run-status.ts";
|
|
4
|
+
export type { RunStatusAgent, RunStatusHuman, RunStatusLimit, RunStatusRecord } from "./run-status.ts";
|
|
5
|
+
export { readRunWatches } from "./run-watch.ts";
|
|
6
|
+
export type { RunWatch } from "./run-watch.ts";
|
|
7
|
+
/** How long a settled or lost run stays with its viewers before it drops out. */
|
|
8
|
+
export declare const settledRunVisibleMilliseconds = 30000;
|
|
9
|
+
/** One surface that shows runs: a coding-agent session, a status line, a browser tab. */
|
|
10
|
+
export interface RunViewer {
|
|
11
|
+
/** Shows the runs this session owns. Its watch list is kept under the same id. */
|
|
12
|
+
readonly sessionId: string;
|
|
13
|
+
/** Absolute project directory; shows unowned runs working in it or below it. */
|
|
14
|
+
readonly projectDir?: string;
|
|
15
|
+
/** What the session asked to watch; shows the top-level runs an entry names, whoever owns them. */
|
|
16
|
+
readonly watches?: readonly RunWatch[];
|
|
17
|
+
}
|
|
18
|
+
export interface ViewerRunOptions {
|
|
19
|
+
readonly now?: number;
|
|
20
|
+
/** Keeps settled and lost runs however long ago they stopped. */
|
|
21
|
+
readonly all?: boolean;
|
|
22
|
+
readonly isAlive?: (pid: number) => boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* How a waiting run's Human request is answered: by `machine respond` with
|
|
26
|
+
* these two ids, or only through the program that hosts the run.
|
|
27
|
+
*/
|
|
28
|
+
export type RunAnswer = {
|
|
29
|
+
readonly via: "respond";
|
|
30
|
+
readonly runId: string;
|
|
31
|
+
readonly requestId: string;
|
|
32
|
+
} | {
|
|
33
|
+
readonly via: "host";
|
|
34
|
+
};
|
|
35
|
+
/** A published record as one viewer shows it. */
|
|
36
|
+
export interface ViewerRun extends Omit<RunStatusRecord, "status"> {
|
|
37
|
+
/** `lost` when the record says running or waiting and its process is gone. */
|
|
38
|
+
readonly status: RunStatusRecord["status"] | "lost";
|
|
39
|
+
/** Why the viewer shows it; the first that holds of owned, project, and watched. */
|
|
40
|
+
readonly shown: "owned" | "project" | "watched";
|
|
41
|
+
/** Present while the run waits on a request that something can answer. */
|
|
42
|
+
readonly answer?: RunAnswer;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The runs one viewer shows: the runs its session owns, unowned runs working
|
|
46
|
+
* inside its project, and runs it watches. Sub-runs are never shown, and a
|
|
47
|
+
* settled or lost run drops out after `settledRunVisibleMilliseconds`. Runs
|
|
48
|
+
* that can still change come first, then the most recently updated; records
|
|
49
|
+
* that tie keep the order they were given in.
|
|
50
|
+
*
|
|
51
|
+
* Pure apart from the default process-liveness probe. `claude/statusline.mjs`
|
|
52
|
+
* cannot load this module and carries a copy of the selection;
|
|
53
|
+
* tests/run-viewer.test.ts keeps the two in step.
|
|
54
|
+
*/
|
|
55
|
+
export declare function runsForViewer(records: readonly RunStatusRecord[], viewer: RunViewer, options?: ViewerRunOptions): ViewerRun[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { isProcessAlive } from "./run-status.js";
|
|
2
|
+
import { isWithinDirectory, runWatchMatches } from "./run-watch.js";
|
|
3
|
+
// The package exports this module as `@dna113p/machines/run-status`: what an
|
|
4
|
+
// application needs to draw its own widgets for published runs. It only reads;
|
|
5
|
+
// the publisher and the watch-list writer stay in the runtime.
|
|
6
|
+
export { readRunStatuses, runSessionId, runStatusDirectory } from "./run-status.js";
|
|
7
|
+
export { readRunWatches } from "./run-watch.js";
|
|
8
|
+
/** How long a settled or lost run stays with its viewers before it drops out. */
|
|
9
|
+
export const settledRunVisibleMilliseconds = 30_000;
|
|
10
|
+
/**
|
|
11
|
+
* The runs one viewer shows: the runs its session owns, unowned runs working
|
|
12
|
+
* inside its project, and runs it watches. Sub-runs are never shown, and a
|
|
13
|
+
* settled or lost run drops out after `settledRunVisibleMilliseconds`. Runs
|
|
14
|
+
* that can still change come first, then the most recently updated; records
|
|
15
|
+
* that tie keep the order they were given in.
|
|
16
|
+
*
|
|
17
|
+
* Pure apart from the default process-liveness probe. `claude/statusline.mjs`
|
|
18
|
+
* cannot load this module and carries a copy of the selection;
|
|
19
|
+
* tests/run-viewer.test.ts keeps the two in step.
|
|
20
|
+
*/
|
|
21
|
+
export function runsForViewer(records, viewer, options = {}) {
|
|
22
|
+
const { now = Date.now(), all = false, isAlive = isProcessAlive } = options;
|
|
23
|
+
const visible = [];
|
|
24
|
+
for (const record of records) {
|
|
25
|
+
// Sub-runs belong to the run that started them, not to the session.
|
|
26
|
+
if (record.parent !== undefined)
|
|
27
|
+
continue;
|
|
28
|
+
const shown = shownBecause(record, viewer);
|
|
29
|
+
if (shown === undefined)
|
|
30
|
+
continue;
|
|
31
|
+
const updatedAt = Date.parse(record.updatedAt);
|
|
32
|
+
const recorded = record.status === "running" || record.status === "waiting";
|
|
33
|
+
// A run that stopped reporting without finishing has lost its process.
|
|
34
|
+
const lost = recorded && !isAlive(record.pid);
|
|
35
|
+
const active = recorded && !lost;
|
|
36
|
+
if (!active && !all && now - updatedAt > settledRunVisibleMilliseconds)
|
|
37
|
+
continue;
|
|
38
|
+
const status = lost ? "lost" : record.status;
|
|
39
|
+
const answer = status === "waiting" ? runAnswer(record) : undefined;
|
|
40
|
+
visible.push({ run: { ...record, status, shown, ...(answer === undefined ? {} : { answer }) }, active, updatedAt });
|
|
41
|
+
}
|
|
42
|
+
visible.sort((left, right) => Number(right.active) - Number(left.active) || right.updatedAt - left.updatedAt);
|
|
43
|
+
return visible.map(({ run }) => run);
|
|
44
|
+
}
|
|
45
|
+
function shownBecause(record, viewer) {
|
|
46
|
+
// An owned run is its owner's: the project it works in shows it to nobody else.
|
|
47
|
+
if (record.owner !== undefined) {
|
|
48
|
+
if (record.owner === viewer.sessionId)
|
|
49
|
+
return "owned";
|
|
50
|
+
}
|
|
51
|
+
else if (viewer.projectDir !== undefined && isWithinDirectory(record.cwd, viewer.projectDir)) {
|
|
52
|
+
return "project";
|
|
53
|
+
}
|
|
54
|
+
// A run the session asked to watch is shown whoever owns it and wherever it works.
|
|
55
|
+
return viewer.watches?.some((watch) => runWatchMatches(watch, record)) === true ? "watched" : undefined;
|
|
56
|
+
}
|
|
57
|
+
function runAnswer(record) {
|
|
58
|
+
if (record.human === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (record.answeredBy === "host")
|
|
61
|
+
return { via: "host" };
|
|
62
|
+
// A record from before requests had ids says nothing about how it is answered.
|
|
63
|
+
const { requestId } = record.human;
|
|
64
|
+
return requestId === undefined ? undefined : { via: "respond", runId: record.id, requestId };
|
|
65
|
+
}
|
package/dist/src/run-watch.d.ts
CHANGED
|
@@ -18,7 +18,9 @@ export declare function readRunWatches(directory: string, sessionId: string): Ru
|
|
|
18
18
|
/** Replaces a session's watch list; an empty one removes its file. */
|
|
19
19
|
export declare function writeRunWatches(directory: string, sessionId: string, watches: readonly RunWatch[]): void;
|
|
20
20
|
/** Whether a watch names a run. A sub-run is never watched: it is its parent's detail. */
|
|
21
|
-
export declare function runWatchMatches(watch: RunWatch, record: RunStatusRecord): boolean;
|
|
21
|
+
export declare function runWatchMatches(watch: RunWatch, record: Pick<RunStatusRecord, "id" | "label" | "cwd" | "parent">): boolean;
|
|
22
|
+
/** Whether a path is a directory or inside it. Only absolute paths say where they are. */
|
|
23
|
+
export declare function isWithinDirectory(path: string, directory: string): boolean;
|
|
22
24
|
/** Checks and normalizes what `machine watch` was given. */
|
|
23
25
|
export declare function runWatch(input: RunWatch): RunWatch;
|
|
24
26
|
export declare function sameRunWatch(left: RunWatch, right: RunWatch): boolean;
|
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
|
|
@@ -54,18 +52,22 @@ export function writeRunWatches(directory, sessionId, watches) {
|
|
|
54
52
|
export function runWatchMatches(watch, record) {
|
|
55
53
|
if (record.parent !== undefined)
|
|
56
54
|
return false;
|
|
57
|
-
if ("dir" in watch)
|
|
58
|
-
|
|
59
|
-
return false;
|
|
60
|
-
const cwd = resolve(record.cwd);
|
|
61
|
-
return cwd === watch.dir || cwd.startsWith(watch.dir.endsWith(sep) ? watch.dir : `${watch.dir}${sep}`);
|
|
62
|
-
}
|
|
55
|
+
if ("dir" in watch)
|
|
56
|
+
return isWithinDirectory(record.cwd, watch.dir);
|
|
63
57
|
if (record.id.startsWith(watch.text))
|
|
64
58
|
return true;
|
|
65
59
|
// The label must start with the whole text, not with a longer word that begins like it.
|
|
66
60
|
const next = record.label?.startsWith(watch.text) ? record.label.slice(watch.text.length) : undefined;
|
|
67
61
|
return next !== undefined && !/^[\p{L}\p{N}_-]/u.test(next);
|
|
68
62
|
}
|
|
63
|
+
/** Whether a path is a directory or inside it. Only absolute paths say where they are. */
|
|
64
|
+
export function isWithinDirectory(path, directory) {
|
|
65
|
+
if (!isAbsolute(path) || !isAbsolute(directory))
|
|
66
|
+
return false;
|
|
67
|
+
const outer = resolve(directory);
|
|
68
|
+
const inner = resolve(path);
|
|
69
|
+
return inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : `${outer}${sep}`);
|
|
70
|
+
}
|
|
69
71
|
/** Checks and normalizes what `machine watch` was given. */
|
|
70
72
|
export function runWatch(input) {
|
|
71
73
|
if ("dir" in input) {
|
|
@@ -94,15 +96,18 @@ function pickWatch(value) {
|
|
|
94
96
|
return { dir: resolve(dir) };
|
|
95
97
|
return undefined;
|
|
96
98
|
}
|
|
97
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Sessions end without saying so; a list nobody has changed for a month belongs
|
|
101
|
+
* to one that did. A run that starts prunes them by the same rule.
|
|
102
|
+
*/
|
|
98
103
|
function pruneRunWatches(directory, kept) {
|
|
99
104
|
const now = Date.now();
|
|
100
105
|
for (const name of readdirSync(directory)) {
|
|
101
106
|
const file = join(directory, name);
|
|
102
|
-
if (!
|
|
107
|
+
if (!watchListName.test(name) || file === kept)
|
|
103
108
|
continue;
|
|
104
109
|
try {
|
|
105
|
-
if (now - statSync(file).mtimeMs >
|
|
110
|
+
if (now - statSync(file).mtimeMs > watchRetentionMilliseconds)
|
|
106
111
|
rmSync(file, { force: true });
|
|
107
112
|
}
|
|
108
113
|
catch {
|
package/docs/architecture.md
CHANGED
|
@@ -61,6 +61,8 @@ MCP server ────┘ ↑
|
|
|
61
61
|
| `src/discovery-worker.ts` | Fresh metadata imports in disposable workers |
|
|
62
62
|
| `src/host.ts` | One child run, IPC, Human request identity, owned process termination, run-status publication for the run |
|
|
63
63
|
| `src/session.ts` | Shared Pi/MCP run lifecycle, snapshots, response handling, retention, shutdown |
|
|
64
|
+
| `src/run-status.ts`, `src/run-watch.ts` | The run-status directory rule, one record per published run, session identity, and each session's watch list |
|
|
65
|
+
| `src/run-viewer.ts` | Which published runs one viewer shows; exported with the readers as `@dna113p/machines/run-status` |
|
|
64
66
|
| `src/cli.ts` | Command parsing, terminal progress, source inspection |
|
|
65
67
|
| `pi-extension/index.ts` | Pi tools, widgets, notifications, native input presentation |
|
|
66
68
|
| `mcp/server.ts` | MCP schemas, tool results, and the optional HTML resource |
|
|
@@ -100,7 +102,12 @@ and that request. Claiming a request before asynchronous delivery prevents two
|
|
|
100
102
|
answers from being accepted, and the identity prevents a stale card or dialog from
|
|
101
103
|
answering a later prompt. Restricted choices are validated for every interface.
|
|
102
104
|
|
|
103
|
-
`machine run` asks on its own terminal.
|
|
105
|
+
`machine run` asks on its own terminal. Run-status publication is on unless
|
|
106
|
+
`MACHINES_RUN_STATUS_DIR` turns it off, in a private per-user directory that
|
|
107
|
+
every reader and writer finds by one rule (`runStatusDirectory`; the standalone
|
|
108
|
+
status line carries a tested copy). It is presentation and never changes a run:
|
|
109
|
+
a run that cannot write its first record is simply not published. With
|
|
110
|
+
publication on, `machine run` also
|
|
104
111
|
publishes the pending request and its request ID, and takes the first valid
|
|
105
112
|
response from either the terminal or a private per-run inbox file written by
|
|
106
113
|
`machine respond`; the MCP session takes inbox responses through the same
|
|
@@ -115,6 +122,14 @@ only way to answer it, its record says so, and `machine respond` refuses it. See
|
|
|
115
122
|
A session's watch list changes only which published runs its status line shows.
|
|
116
123
|
It gives no ownership of a run and no way to answer one.
|
|
117
124
|
|
|
125
|
+
Which runs a viewer shows is one rule, `runsForViewer` in `src/run-viewer.ts`:
|
|
126
|
+
`machine runs --for` prints its answer, and `@dna113p/machines/run-status`
|
|
127
|
+
exports it with the readers, so that an application draws its own widgets
|
|
128
|
+
without a second copy. It selects and orders; presentation stays with whatever
|
|
129
|
+
draws. The standalone status line cannot load it and carries a tested copy, as
|
|
130
|
+
it does of the directory rule. See
|
|
131
|
+
[Integrations](integrations.md#drawing-your-own-widgets).
|
|
132
|
+
|
|
118
133
|
Closing a session terminates active and starting hosts. Host termination includes
|
|
119
134
|
ordinary descendants in the host's owned process group on POSIX and process-tree
|
|
120
135
|
termination on Windows. Deliberately detached processes can escape normal ownership;
|
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
|
{
|
|
@@ -190,8 +209,11 @@ change (directory `0700`, file `0600`):
|
|
|
190
209
|
`status` is `running`, `waiting`, `completed`, or `failed`. `state` is the current
|
|
191
210
|
state as a string (JSON for a nested state). `agent` is present while an Agent
|
|
192
211
|
state has reported its identity, and `error` after a failure. `owner` is
|
|
193
|
-
`MACHINES_RUN_OWNER` when set, otherwise
|
|
194
|
-
|
|
212
|
+
`MACHINES_RUN_OWNER` when set, otherwise the session id the coding agent gives
|
|
213
|
+
the commands it starts (`CLAUDE_CODE_SESSION_ID`, then `CODEX_THREAD_ID`, then
|
|
214
|
+
`PI_SESSION_ID`), otherwise absent; see
|
|
215
|
+
[how a host names its session](#drawing-your-own-widgets). `stateSince` is when
|
|
216
|
+
the current state was entered. `label` is the first
|
|
195
217
|
line of `MACHINES_RUN_LABEL` (at most 160 characters) when a launcher sets it to
|
|
196
218
|
say what the run is for, such as a ticket id and title; it is the launcher's
|
|
197
219
|
choice to publish that text. `machine run` exports its run id as
|
|
@@ -199,11 +221,11 @@ choice to publish that text. `machine run` exports its run id as
|
|
|
199
221
|
records that id as `parent`; the status line shows only top-level runs.
|
|
200
222
|
|
|
201
223
|
A run hosted through `startMachineHost` from `@dna113p/machines/host` publishes
|
|
202
|
-
the same record when its host asks with `publishRunStatus: true` and
|
|
203
|
-
|
|
224
|
+
the same record when its host asks with `publishRunStatus: true` and
|
|
225
|
+
publication is on in the hosting process, as a daemon that runs tickets does. A
|
|
204
226
|
host that does not ask publishes nothing, whatever its environment says: a
|
|
205
227
|
program that presents its runs itself, such as Pi with the extension loaded, is
|
|
206
|
-
not changed by
|
|
228
|
+
not changed by publication being on. Two options replace the environment for that one run:
|
|
207
229
|
`label`, shortened as `MACHINES_RUN_LABEL` is, and `owner`. When an option is
|
|
208
230
|
absent the environment rule above applies; an empty one publishes the run
|
|
209
231
|
without that field. The run exports its id as `MACHINES_RUN_PARENT` to its
|
|
@@ -262,17 +284,37 @@ after it, and never contains the response.
|
|
|
262
284
|
|
|
263
285
|
Machine input, Machine output, and Human responses are never recorded. A Human
|
|
264
286
|
prompt and its choices are written to the `0600` file while the request is
|
|
265
|
-
waiting
|
|
266
|
-
|
|
267
|
-
|
|
287
|
+
waiting. The standard directory is private to you; one you name instead should
|
|
288
|
+
be a directory only you can read and write.
|
|
289
|
+
|
|
290
|
+
Publication cannot change a run. Where the directory cannot be created or
|
|
291
|
+
written, as in a read-only home, a sandbox, or a container, a run goes on
|
|
292
|
+
exactly as one with publication off: it is not published, nothing is reported,
|
|
293
|
+
and the directory is not tried again at each state. Reading a directory that
|
|
294
|
+
does not exist yet finds no runs; it is not an error.
|
|
295
|
+
|
|
296
|
+
The directory does not grow without bound. Each run that starts removes:
|
|
297
|
+
|
|
298
|
+
- the record of a run that finished, or whose process is gone, more than an hour
|
|
299
|
+
ago, with its inbox and any response left for it;
|
|
300
|
+
- what an interrupted write or delivery left behind more than an hour ago;
|
|
301
|
+
- an inbox with no record beside it, left when a run was pruned without it,
|
|
302
|
+
more than an hour after it was last written;
|
|
303
|
+
- a [watch list](#watching-other-runs) that has not changed for 30 days.
|
|
304
|
+
|
|
305
|
+
A running or waiting run keeps its record for as long as its process lives.
|
|
306
|
+
Files Machines did not name are never removed, and nothing is removed while no
|
|
307
|
+
run starts. A record whose process id now belongs to another process counts as
|
|
308
|
+
running until that process ends.
|
|
268
309
|
|
|
269
310
|
### Answering a waiting run from outside
|
|
270
311
|
|
|
271
312
|
With publication on, a waiting run can be seen and answered by any process
|
|
272
313
|
running as the same user: a terminal, a coordinating agent, a script, or a
|
|
273
314
|
presentation surface. They all read the same request, and the first valid
|
|
274
|
-
response wins.
|
|
275
|
-
whatever answers
|
|
315
|
+
response wins. With the standard directory there is nothing to set; if you move
|
|
316
|
+
it, set the same `MACHINES_RUN_STATUS_DIR` for the run and for whatever answers
|
|
317
|
+
it. With publication off these commands fail and say how to turn it on.
|
|
276
318
|
|
|
277
319
|
```bash
|
|
278
320
|
machine runs # running and waiting runs
|
|
@@ -287,7 +329,8 @@ waiting run its prompt, request id, and choices (tab-separated when its
|
|
|
287
329
|
output is not a terminal). A run's label follows as a fifth column when it has
|
|
288
330
|
one, and a sixth says `watched` for a run this session
|
|
289
331
|
[watches](#watching-other-runs). It lists the running and waiting runs of every
|
|
290
|
-
session and project
|
|
332
|
+
session and project; `--for` narrows it to
|
|
333
|
+
[what one viewer shows](#drawing-your-own-widgets). `--all`, with or without `--json`, lists every published
|
|
291
334
|
run: also the ones whose records remain after they finished, as `completed` or
|
|
292
335
|
`failed`, or after their process disappeared, as `lost` (in JSON such a record
|
|
293
336
|
keeps the status it last published). A run answered through its host shows
|
|
@@ -330,17 +373,25 @@ waits, it holds the same file under a second name of its own,
|
|
|
330
373
|
response and the sender removes it to take the response back, so exactly one of
|
|
331
374
|
them succeeds and neither can act on a response that arrived later. A response
|
|
332
375
|
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
|
-
|
|
376
|
+
inbox or finishes, and a new response is refused until then.
|
|
377
|
+
|
|
378
|
+
**Anything that can write to the directory as you can answer a waiting run**,
|
|
379
|
+
including approvals. That is why the standard directory is a private per-user
|
|
380
|
+
one, created `0700`: no other user can read a request or leave a response. It
|
|
381
|
+
is also all that protects a waiting run, so do not loosen its permissions and do
|
|
382
|
+
not point `MACHINES_RUN_STATUS_DIR` at a shared directory. Set it to `off`
|
|
383
|
+
where no process of yours should be able to answer a run from outside it.
|
|
336
384
|
|
|
337
385
|
`machine run` still asks on its own terminal, and whichever of the terminal and
|
|
338
386
|
the inbox gives the first valid response is used. Terminal input outside a
|
|
339
387
|
request's `choices` is reported and asked for again instead of failing the run.
|
|
340
388
|
A published run started without terminal input, for example by a coordinating
|
|
341
389
|
agent or a script, waits for `machine respond` instead of failing, and prints
|
|
342
|
-
the exact command to its standard error. With publication
|
|
343
|
-
|
|
390
|
+
the exact command to its standard error. With publication on by default this is
|
|
391
|
+
what a script or a CI job gets as well. With publication off, or where the run
|
|
392
|
+
could not be published, it fails at its first Human state with "Terminal input
|
|
393
|
+
closed before a response": set `MACHINES_RUN_STATUS_DIR=off` for a run that
|
|
394
|
+
should fail there instead of waiting.
|
|
344
395
|
Runs hosted in any other program through `startMachineHost` are answered only
|
|
345
396
|
through that program.
|
|
346
397
|
Runs hosted by the MCP server take a response from their own `machine_respond`
|
|
@@ -348,12 +399,13 @@ tool or from the inbox. That tool is answer-only, so a question to a hosted
|
|
|
348
399
|
request that allows discussion goes through the inbox with
|
|
349
400
|
`machine respond --question`.
|
|
350
401
|
|
|
351
|
-
For Claude Code,
|
|
352
|
-
|
|
402
|
+
For Claude Code, call the dependency-free renderer from `statusLine`. That is
|
|
403
|
+
the whole setup: the runs Claude Code launches, a daemon that hosts runs as the
|
|
404
|
+
same user, and the status line all use the standard directory, so there is no
|
|
405
|
+
`env` block to add and no path to repeat anywhere.
|
|
353
406
|
|
|
354
407
|
```json
|
|
355
408
|
{
|
|
356
|
-
"env": { "MACHINES_RUN_STATUS_DIR": "/home/you/.cache/machines/run-status" },
|
|
357
409
|
"statusLine": {
|
|
358
410
|
"type": "command",
|
|
359
411
|
"command": "node /path/to/machines/claude/statusline.mjs",
|
|
@@ -397,8 +449,8 @@ to change the 120-column truncation.
|
|
|
397
449
|
A session can pull in runs it did not start: a ticket that a daemon runs, or a
|
|
398
450
|
run started by another session or under another project. Nothing is configured.
|
|
399
451
|
The session is identified as a run's `owner` is, by `MACHINES_RUN_OWNER` and
|
|
400
|
-
otherwise
|
|
401
|
-
command.
|
|
452
|
+
otherwise by the session id its coding agent gives it, so an agent inside Claude
|
|
453
|
+
Code, Codex, or Pi only runs the command.
|
|
402
454
|
|
|
403
455
|
```bash
|
|
404
456
|
machine runs # running and waiting runs, with their labels
|
|
@@ -407,6 +459,7 @@ machine watch org-9 # runs whose id or label starts with org-9
|
|
|
407
459
|
machine watch --dir ../other # runs working in that directory or below it
|
|
408
460
|
machine watch # what this session watches, and the published runs each entry matches now
|
|
409
461
|
machine unwatch org-9 # also: unwatch --dir <path>, unwatch --all
|
|
462
|
+
machine watch --for <session> org-9 # the same commands for another session's list
|
|
410
463
|
```
|
|
411
464
|
|
|
412
465
|
- `machine watch <text>` names every top-level run whose id starts with the
|
|
@@ -420,6 +473,10 @@ machine unwatch org-9 # also: unwatch --dir <path>, unwatch --all
|
|
|
420
473
|
before its run exists. `machine watch` lists a matched run for as long as its
|
|
421
474
|
record remains, with the status `machine runs --all` gives it, so it also
|
|
422
475
|
says how a watched run ended.
|
|
476
|
+
- `--for <session>`, first, makes `machine watch` and `machine unwatch` read
|
|
477
|
+
and change the list of the session it names instead of this process's own.
|
|
478
|
+
A program that draws [its own widgets](#drawing-your-own-widgets) for a
|
|
479
|
+
viewer manages that viewer's list this way, without setting a variable.
|
|
423
480
|
|
|
424
481
|
A watched run is shown even when another session owns it or it works outside
|
|
425
482
|
the project. Everything else is as for any run: sub-runs stay hidden, a run is
|
|
@@ -435,10 +492,125 @@ status line reads itself with the `session_id` Claude Code gives it:
|
|
|
435
492
|
|
|
436
493
|
Record readers skip it, as they skip every name that starts with a dot. A
|
|
437
494
|
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
|
|
495
|
+
removes the file, and writing a list or starting a run removes other sessions'
|
|
496
|
+
lists that have not changed for 30 days. A missing, unreadable, or malformed list watches
|
|
440
497
|
nothing; it is never an error on the status line. The commands fail with a
|
|
441
|
-
message when publication is off or when
|
|
498
|
+
message when publication is off or when nothing names the session.
|
|
499
|
+
|
|
500
|
+
### Drawing your own widgets
|
|
501
|
+
|
|
502
|
+
Any application can show Machine runs in its own way from what is already
|
|
503
|
+
published: an extension for Codex or Pi, a web app, an editor panel. The Claude
|
|
504
|
+
Code status line above is one such consumer. Nothing is configured and nothing
|
|
505
|
+
new runs; there are three ways to read the same directory:
|
|
506
|
+
|
|
507
|
+
| Read with | You get |
|
|
508
|
+
| --- | --- |
|
|
509
|
+
| `machine runs --for <session> --json` | The runs one viewer shows, each ready to draw |
|
|
510
|
+
| `@dna113p/machines/run-status` | The same rule and the readers, in your own process |
|
|
511
|
+
| The files | The [records](#run-status-for-host-status-lines) and [watch lists](#watching-other-runs) themselves; the rule is then yours to apply |
|
|
512
|
+
|
|
513
|
+
A viewer is one surface that shows runs: a session of a coding agent, a browser
|
|
514
|
+
tab, a status line. It is named by a session id and may have a project
|
|
515
|
+
directory. The rule that decides what it shows is the status line's:
|
|
516
|
+
|
|
517
|
+
- only top-level runs: a run started from inside another Machine is never shown;
|
|
518
|
+
- the runs its session owns, unowned runs working in its project directory or
|
|
519
|
+
below it, and the runs its session [watches](#watching-other-runs), whoever
|
|
520
|
+
owns them;
|
|
521
|
+
- a run recorded as running or waiting whose process is gone is `lost`;
|
|
522
|
+
- a finished or lost run stays for 30 seconds and then drops out;
|
|
523
|
+
- each run once, the ones that can still change first, then the most recently
|
|
524
|
+
updated. Runs updated in the same millisecond keep the order they were read
|
|
525
|
+
in from the directory, for the command, the library, and the status line
|
|
526
|
+
alike.
|
|
527
|
+
|
|
528
|
+
Both ways below take a snapshot. Read again to refresh, as the status line does
|
|
529
|
+
every few seconds, and read where the runs' processes can be seen, or every run
|
|
530
|
+
looks `lost`.
|
|
531
|
+
|
|
532
|
+
```bash
|
|
533
|
+
machine runs --for "$SESSION" --project "$PWD" --json # what that viewer shows
|
|
534
|
+
machine runs --for --json # for this process's own session
|
|
535
|
+
machine runs --for "$SESSION" --all # as text, and with older finished and lost runs
|
|
536
|
+
machine watch --for "$SESSION" org-9 # change what that viewer watches
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
`--for` takes the viewer's session id; with no id after it, the viewer is this
|
|
540
|
+
process's own session, and the command fails when nothing names one.
|
|
541
|
+
`--project` is the viewer's project directory, compared as an absolute path;
|
|
542
|
+
without it only owned and watched runs are selected. `--all` also keeps the
|
|
543
|
+
finished and lost runs that the 30 seconds would have dropped, for as long as
|
|
544
|
+
their records remain. Without `--json` the runs are printed in the columns of
|
|
545
|
+
`machine runs`. Without `--for`, `machine runs` is unchanged: every session's
|
|
546
|
+
running and waiting runs, as plain records.
|
|
547
|
+
|
|
548
|
+
Each item is the published record with three additions:
|
|
549
|
+
|
|
550
|
+
```json
|
|
551
|
+
{
|
|
552
|
+
"schemaVersion": 2, "id": "1a2b3c4d-…", "pid": 4242, "owner": "<session id>",
|
|
553
|
+
"machine": "release", "path": "/work/.machines/release.ts", "cwd": "/work",
|
|
554
|
+
"status": "waiting", "state": "approve",
|
|
555
|
+
"human": { "prompt": "Publish version 0.4.0?", "requestId": "9f8e7d6c-…", "choices": ["publish", "hold"] },
|
|
556
|
+
"startedAt": "2026-03-04T05:06:07.000Z", "updatedAt": "2026-03-04T05:08:12.000Z",
|
|
557
|
+
"shown": "owned",
|
|
558
|
+
"answer": { "via": "respond", "runId": "1a2b3c4d-…", "requestId": "9f8e7d6c-…" }
|
|
559
|
+
}
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
- `status` is the effective one: what the record says, or `lost`.
|
|
563
|
+
- `shown` says why the viewer shows the run: `owned`, `project`, or `watched`,
|
|
564
|
+
the first of them that holds.
|
|
565
|
+
- `answer` is present while the run waits on a Human request that something
|
|
566
|
+
can answer. `"via": "respond"` carries the two ids for
|
|
567
|
+
`machine respond <runId> <requestId> <response>`; `"via": "host"` means only
|
|
568
|
+
the program hosting the run can answer it, so a widget shows the prompt and
|
|
569
|
+
offers no input. `human` still has the prompt, choices, and suggestions.
|
|
570
|
+
|
|
571
|
+
Nothing is added for presentation: no colour, layout, or shortening, and no
|
|
572
|
+
limit on how many runs are returned. Recorded text is the run's own, so escape
|
|
573
|
+
it for your surface as the status line strips control characters.
|
|
574
|
+
|
|
575
|
+
The library gives the same answer without starting a process:
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
import {
|
|
579
|
+
readRunStatuses, readRunWatches, runStatusDirectory, runsForViewer,
|
|
580
|
+
} from "@dna113p/machines/run-status";
|
|
581
|
+
|
|
582
|
+
const directory = runStatusDirectory(); // undefined while publication is off
|
|
583
|
+
const runs = directory === undefined ? [] : runsForViewer(readRunStatuses(directory), {
|
|
584
|
+
sessionId,
|
|
585
|
+
projectDir: "/work/project",
|
|
586
|
+
watches: readRunWatches(directory, sessionId),
|
|
587
|
+
});
|
|
588
|
+
for (const run of runs) draw(run.label ?? run.id, run.status, run.shown, run.answer);
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
`runsForViewer(records, viewer, options)` is pure apart from probing whether
|
|
592
|
+
each process is alive; `options` takes `now`, `all`, and an `isAlive` of your
|
|
593
|
+
own. The module also exports `runSessionId`, the types of a record, a watch, a
|
|
594
|
+
viewer, and an item, and `settledRunVisibleMilliseconds`. It only reads:
|
|
595
|
+
publishing stays with the runtime, and a viewer's watch list is changed with
|
|
596
|
+
`machine watch --for`.
|
|
597
|
+
|
|
598
|
+
**Naming the session.** A viewer shows the runs whose `owner` is its session
|
|
599
|
+
id, so the host and the runs it starts must agree on that id. Any host can set
|
|
600
|
+
`MACHINES_RUN_OWNER` in the environment of the commands it starts and pass the
|
|
601
|
+
same value to `--for`; the id may use letters, digits, `.`, `_`, and `-`. Where
|
|
602
|
+
it is not set, the id is the one the coding agent already gives its commands:
|
|
603
|
+
|
|
604
|
+
| Host | Variable | Given to |
|
|
605
|
+
| --- | --- | --- |
|
|
606
|
+
| Any | `MACHINES_RUN_OWNER` | Whatever the host starts with it set; takes precedence |
|
|
607
|
+
| Claude Code | `CLAUDE_CODE_SESSION_ID` | The commands a session runs; also the `session_id` its status line receives |
|
|
608
|
+
| Codex | `CODEX_THREAD_ID` | The commands a thread runs |
|
|
609
|
+
| Pi | `PI_SESSION_ID` | The commands its `bash` and `powershell` tools run, not `!` commands typed by the user |
|
|
610
|
+
|
|
611
|
+
The first one that is set names the session, in that order. A run started from
|
|
612
|
+
inside one of these agents is therefore owned by that session: it is shown
|
|
613
|
+
there, and in another session only when that session watches it.
|
|
442
614
|
|
|
443
615
|
## Troubleshooting
|
|
444
616
|
|
|
@@ -446,6 +618,8 @@ For an isolated global catalog, set `MACHINES_USER_HOME` to an absolute director
|
|
|
446
618
|
Machines reads global definitions and presets from that directory's `.machines/`.
|
|
447
619
|
Explicit programmatic `home` options take precedence. This is useful for tests or
|
|
448
620
|
separate workflow collections without changing the operating-system home directory.
|
|
621
|
+
A process with that variable also publishes its
|
|
622
|
+
[run status](#run-status-for-host-status-lines) under that directory.
|
|
449
623
|
|
|
450
624
|
- **Node cannot load TypeScript from a dependency:** install the built tarball;
|
|
451
625
|
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.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Reusable workflows coordinating agents, local operations, and human decisions.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"./host": { "types": "./dist/src/host.d.ts", "import": "./dist/src/host.js" },
|
|
37
37
|
"./launcher": { "types": "./dist/src/launcher.d.ts", "import": "./dist/src/launcher.js" },
|
|
38
38
|
"./mcp": { "types": "./dist/mcp/server.d.ts", "import": "./dist/mcp/server.js" },
|
|
39
|
-
"./pi-extension": { "types": "./dist/pi-extension/index.d.ts", "import": "./dist/pi-extension/index.js" }
|
|
39
|
+
"./pi-extension": { "types": "./dist/pi-extension/index.d.ts", "import": "./dist/pi-extension/index.js" },
|
|
40
|
+
"./run-status": { "types": "./dist/src/run-viewer.d.ts", "import": "./dist/src/run-viewer.js" }
|
|
40
41
|
},
|
|
41
42
|
"files": ["dist", "claude/statusline.mjs", "docs/architecture.md", "docs/authoring.md", "docs/integrations.md", "docs/local-decisions.md", "services/laya/server.py", "skills", "LICENSE", "README.md"],
|
|
42
43
|
"scripts": {
|