@dna113p/machines 0.3.3 → 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 +7 -2
- package/claude/statusline.mjs +3 -2
- package/dist/src/cli.js +105 -16
- package/dist/src/run-status.d.ts +3 -1
- package/dist/src/run-status.js +8 -2
- 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 +10 -6
- package/docs/architecture.md +10 -0
- package/docs/integrations.md +130 -6
- 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
|
|
|
@@ -201,6 +201,7 @@ answered from outside its own terminal:
|
|
|
201
201
|
|
|
202
202
|
```bash
|
|
203
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
|
|
204
205
|
machine respond <run-id> <request-id> <response> # answer a waiting run
|
|
205
206
|
machine watch [<run-id-or-label-start> | --dir <path>] # show other runs on this session's status line
|
|
206
207
|
machine unwatch <run-id-or-label-start> | --dir <path> | --all
|
|
@@ -215,6 +216,10 @@ Runs hosted through `startMachineHost` publish too and are answered through thei
|
|
|
215
216
|
host. A session shows the runs it started; `machine watch` adds runs it did not,
|
|
216
217
|
such as a ticket a daemon runs. See
|
|
217
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.
|
|
218
223
|
|
|
219
224
|
## Execution model and limitations
|
|
220
225
|
|
package/claude/statusline.mjs
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// docs/integrations.md). It runs on every status refresh, so it imports only
|
|
5
5
|
// node: modules and never loads the runtime; the directory rule and the record
|
|
6
6
|
// shape below mirror runStatusDirectory and RunStatusRecord in
|
|
7
|
-
// src/run-status.ts,
|
|
8
|
-
//
|
|
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.
|
|
9
10
|
|
|
10
11
|
import { readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
11
12
|
import { homedir } from "node:os";
|
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
|
}
|
|
@@ -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
|
}
|
package/dist/src/run-status.d.ts
CHANGED
|
@@ -129,7 +129,9 @@ export declare function runStatusObserver(update: (change: RunStatusChange) => v
|
|
|
129
129
|
};
|
|
130
130
|
/**
|
|
131
131
|
* The session a process belongs to: the owner of the runs it starts, and the
|
|
132
|
-
* 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.
|
|
133
135
|
*/
|
|
134
136
|
export declare function runSessionId(environment?: NodeJS.ProcessEnv): string | undefined;
|
|
135
137
|
/** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
|
package/dist/src/run-status.js
CHANGED
|
@@ -228,10 +228,16 @@ export function runStatusObserver(update) {
|
|
|
228
228
|
}
|
|
229
229
|
/**
|
|
230
230
|
* The session a process belongs to: the owner of the runs it starts, and the
|
|
231
|
-
* 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.
|
|
232
234
|
*/
|
|
233
235
|
export function runSessionId(environment = process.env) {
|
|
234
|
-
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;
|
|
235
241
|
}
|
|
236
242
|
/** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
|
|
237
243
|
export function readRunStatuses(directory) {
|
|
@@ -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
|
@@ -52,18 +52,22 @@ export function writeRunWatches(directory, sessionId, watches) {
|
|
|
52
52
|
export function runWatchMatches(watch, record) {
|
|
53
53
|
if (record.parent !== undefined)
|
|
54
54
|
return false;
|
|
55
|
-
if ("dir" in watch)
|
|
56
|
-
|
|
57
|
-
return false;
|
|
58
|
-
const cwd = resolve(record.cwd);
|
|
59
|
-
return cwd === watch.dir || cwd.startsWith(watch.dir.endsWith(sep) ? watch.dir : `${watch.dir}${sep}`);
|
|
60
|
-
}
|
|
55
|
+
if ("dir" in watch)
|
|
56
|
+
return isWithinDirectory(record.cwd, watch.dir);
|
|
61
57
|
if (record.id.startsWith(watch.text))
|
|
62
58
|
return true;
|
|
63
59
|
// The label must start with the whole text, not with a longer word that begins like it.
|
|
64
60
|
const next = record.label?.startsWith(watch.text) ? record.label.slice(watch.text.length) : undefined;
|
|
65
61
|
return next !== undefined && !/^[\p{L}\p{N}_-]/u.test(next);
|
|
66
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
|
+
}
|
|
67
71
|
/** Checks and normalizes what `machine watch` was given. */
|
|
68
72
|
export function runWatch(input) {
|
|
69
73
|
if ("dir" in input) {
|
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 |
|
|
@@ -120,6 +122,14 @@ only way to answer it, its record says so, and `machine respond` refuses it. See
|
|
|
120
122
|
A session's watch list changes only which published runs its status line shows.
|
|
121
123
|
It gives no ownership of a run and no way to answer one.
|
|
122
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
|
+
|
|
123
133
|
Closing a session terminates active and starting hosts. Host termination includes
|
|
124
134
|
ordinary descendants in the host's owned process group on POSIX and process-tree
|
|
125
135
|
termination on Windows. Deliberately detached processes can escape normal ownership;
|
package/docs/integrations.md
CHANGED
|
@@ -209,8 +209,11 @@ directory is created by the first write, as `0700`, and each file is `0600`:
|
|
|
209
209
|
`status` is `running`, `waiting`, `completed`, or `failed`. `state` is the current
|
|
210
210
|
state as a string (JSON for a nested state). `agent` is present while an Agent
|
|
211
211
|
state has reported its identity, and `error` after a failure. `owner` is
|
|
212
|
-
`MACHINES_RUN_OWNER` when set, otherwise
|
|
213
|
-
|
|
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
|
|
214
217
|
line of `MACHINES_RUN_LABEL` (at most 160 characters) when a launcher sets it to
|
|
215
218
|
say what the run is for, such as a ticket id and title; it is the launcher's
|
|
216
219
|
choice to publish that text. `machine run` exports its run id as
|
|
@@ -326,7 +329,8 @@ waiting run its prompt, request id, and choices (tab-separated when its
|
|
|
326
329
|
output is not a terminal). A run's label follows as a fifth column when it has
|
|
327
330
|
one, and a sixth says `watched` for a run this session
|
|
328
331
|
[watches](#watching-other-runs). It lists the running and waiting runs of every
|
|
329
|
-
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
|
|
330
334
|
run: also the ones whose records remain after they finished, as `completed` or
|
|
331
335
|
`failed`, or after their process disappeared, as `lost` (in JSON such a record
|
|
332
336
|
keeps the status it last published). A run answered through its host shows
|
|
@@ -445,8 +449,8 @@ to change the 120-column truncation.
|
|
|
445
449
|
A session can pull in runs it did not start: a ticket that a daemon runs, or a
|
|
446
450
|
run started by another session or under another project. Nothing is configured.
|
|
447
451
|
The session is identified as a run's `owner` is, by `MACHINES_RUN_OWNER` and
|
|
448
|
-
otherwise
|
|
449
|
-
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.
|
|
450
454
|
|
|
451
455
|
```bash
|
|
452
456
|
machine runs # running and waiting runs, with their labels
|
|
@@ -455,6 +459,7 @@ machine watch org-9 # runs whose id or label starts with org-9
|
|
|
455
459
|
machine watch --dir ../other # runs working in that directory or below it
|
|
456
460
|
machine watch # what this session watches, and the published runs each entry matches now
|
|
457
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
|
|
458
463
|
```
|
|
459
464
|
|
|
460
465
|
- `machine watch <text>` names every top-level run whose id starts with the
|
|
@@ -468,6 +473,10 @@ machine unwatch org-9 # also: unwatch --dir <path>, unwatch --all
|
|
|
468
473
|
before its run exists. `machine watch` lists a matched run for as long as its
|
|
469
474
|
record remains, with the status `machine runs --all` gives it, so it also
|
|
470
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.
|
|
471
480
|
|
|
472
481
|
A watched run is shown even when another session owns it or it works outside
|
|
473
482
|
the project. Everything else is as for any run: sub-runs stay hidden, a run is
|
|
@@ -486,7 +495,122 @@ session id that is not safe as a file name is refused. Removing the last entry
|
|
|
486
495
|
removes the file, and writing a list or starting a run removes other sessions'
|
|
487
496
|
lists that have not changed for 30 days. A missing, unreadable, or malformed list watches
|
|
488
497
|
nothing; it is never an error on the status line. The commands fail with a
|
|
489
|
-
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.
|
|
490
614
|
|
|
491
615
|
## Troubleshooting
|
|
492
616
|
|
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": {
|