@dna113p/machines 0.3.3 → 0.3.5
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 -2
- package/claude/statusline.mjs +5 -2
- package/dist/mcp/server.js +2 -2
- package/dist/src/cli.js +163 -20
- package/dist/src/codex.d.ts +2 -0
- package/dist/src/codex.js +2 -0
- package/dist/src/host.js +6 -2
- package/dist/src/launcher.d.ts +3 -0
- package/dist/src/launcher.js +3 -0
- package/dist/src/run-graph.d.ts +39 -0
- package/dist/src/run-graph.js +91 -0
- package/dist/src/run-status.d.ts +19 -3
- package/dist/src/run-status.js +64 -5
- package/dist/src/run-ui-page.d.ts +6 -0
- package/dist/src/run-ui-page.js +690 -0
- package/dist/src/run-ui.d.ts +62 -0
- package/dist/src/run-ui.js +258 -0
- package/dist/src/run-viewer.d.ts +57 -0
- package/dist/src/run-viewer.js +66 -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/authoring.md +13 -0
- package/docs/integrations.md +186 -10
- package/package.json +88 -20
- package/skills/machine-builder/SKILL.md +31 -0
- package/skills/machine-delegation/SKILL.md +23 -0
- package/skills/machine-project-setup/SKILL.md +23 -5
- package/skills/machine-project-setup/references/project-workflows.md +4 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type RunStatusRecord } from "./run-status.ts";
|
|
2
|
+
/**
|
|
3
|
+
* `machine ui`: a local page that shows every published run as it happens,
|
|
4
|
+
* with its Machine's graph, and the project's own panels beside them. It reads
|
|
5
|
+
* the run-status directory and answers a waiting run through its inbox, as
|
|
6
|
+
* `machine respond` does; nothing else in the page changes anything.
|
|
7
|
+
*/
|
|
8
|
+
export interface RunUiOptions {
|
|
9
|
+
/** The run-status directory. */
|
|
10
|
+
readonly directory: string;
|
|
11
|
+
/** The project whose `.machines/ui.json` names panels; its name heads the page. */
|
|
12
|
+
readonly projectDir: string;
|
|
13
|
+
readonly port?: number;
|
|
14
|
+
/** How often the directory is read for changes. */
|
|
15
|
+
readonly pollMilliseconds?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface RunUi {
|
|
18
|
+
readonly url: string;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
/** A run as the page shows it: `lost` when its record says running or waiting and its process is gone. */
|
|
22
|
+
export type UiRun = Omit<RunStatusRecord, "status"> & {
|
|
23
|
+
readonly status: RunStatusRecord["status"] | "lost";
|
|
24
|
+
};
|
|
25
|
+
/** What a panel command prints: a few lines a person can take in at a glance. */
|
|
26
|
+
export interface UiPanel {
|
|
27
|
+
readonly title: string;
|
|
28
|
+
readonly summary?: string;
|
|
29
|
+
readonly items: readonly UiPanelItem[];
|
|
30
|
+
readonly error?: string;
|
|
31
|
+
readonly checkedAt?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface UiPanelItem {
|
|
34
|
+
readonly text: string;
|
|
35
|
+
readonly detail?: string;
|
|
36
|
+
/** Colours the item: `bad` and `warn` draw the eye, `active` is work under way. */
|
|
37
|
+
readonly status?: "ok" | "warn" | "bad" | "active" | "idle";
|
|
38
|
+
readonly href?: string;
|
|
39
|
+
readonly at?: string;
|
|
40
|
+
}
|
|
41
|
+
interface PanelSource {
|
|
42
|
+
readonly title: string;
|
|
43
|
+
readonly command: readonly string[];
|
|
44
|
+
readonly refreshSeconds: number;
|
|
45
|
+
}
|
|
46
|
+
export declare const defaultUiPort = 4777;
|
|
47
|
+
/** The nearest directory at or above `start` that has a `.machines` directory, or `start` itself. */
|
|
48
|
+
export declare function uiProjectDir(start: string): string;
|
|
49
|
+
/** Every published run, with runs whose process is gone shown as lost. */
|
|
50
|
+
export declare function uiRuns(directory: string, isAlive?: (pid: number) => boolean): UiRun[];
|
|
51
|
+
/**
|
|
52
|
+
* Reads `.machines/ui.json`: `{ "panels": [{ "title", "command": [...], "refreshSeconds"? }] }`.
|
|
53
|
+
* A missing file is no panels; a malformed one is one panel saying what is wrong.
|
|
54
|
+
*/
|
|
55
|
+
export declare function readPanelSources(projectDir: string): {
|
|
56
|
+
sources: PanelSource[];
|
|
57
|
+
error?: string;
|
|
58
|
+
};
|
|
59
|
+
/** Turns a panel command's output into a panel, or says why it could not. */
|
|
60
|
+
export declare function parsePanelOutput(title: string, stdout: string): UiPanel;
|
|
61
|
+
export declare function startRunUi(options: RunUiOptions): Promise<RunUi>;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { respondToRun } from "./run-inbox.js";
|
|
7
|
+
import { runUiPage } from "./run-ui-page.js";
|
|
8
|
+
import { isProcessAlive, readRunStatuses } from "./run-status.js";
|
|
9
|
+
export const defaultUiPort = 4777;
|
|
10
|
+
const panelTimeoutMilliseconds = 30_000;
|
|
11
|
+
const maxPanelItems = 50;
|
|
12
|
+
/** The nearest directory at or above `start` that has a `.machines` directory, or `start` itself. */
|
|
13
|
+
export function uiProjectDir(start) {
|
|
14
|
+
let current = resolve(start);
|
|
15
|
+
while (true) {
|
|
16
|
+
if (existsSync(join(current, ".machines")) && statSync(join(current, ".machines")).isDirectory())
|
|
17
|
+
return current;
|
|
18
|
+
const parent = dirname(current);
|
|
19
|
+
if (parent === current)
|
|
20
|
+
return resolve(start);
|
|
21
|
+
current = parent;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Every published run, with runs whose process is gone shown as lost. */
|
|
25
|
+
export function uiRuns(directory, isAlive = isProcessAlive) {
|
|
26
|
+
return readRunStatuses(directory)
|
|
27
|
+
.map((record) => ((record.status === "running" || record.status === "waiting") && !isAlive(record.pid)
|
|
28
|
+
? { ...record, status: "lost" }
|
|
29
|
+
: record))
|
|
30
|
+
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Reads `.machines/ui.json`: `{ "panels": [{ "title", "command": [...], "refreshSeconds"? }] }`.
|
|
34
|
+
* A missing file is no panels; a malformed one is one panel saying what is wrong.
|
|
35
|
+
*/
|
|
36
|
+
export function readPanelSources(projectDir) {
|
|
37
|
+
const file = join(projectDir, ".machines", "ui.json");
|
|
38
|
+
if (!existsSync(file))
|
|
39
|
+
return { sources: [] };
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
42
|
+
if (!Array.isArray(parsed.panels))
|
|
43
|
+
throw new Error("panels must be an array");
|
|
44
|
+
const sources = parsed.panels.map((panel, index) => {
|
|
45
|
+
const { title, command, refreshSeconds } = (panel ?? {});
|
|
46
|
+
if (typeof title !== "string" || title.trim() === "")
|
|
47
|
+
throw new Error(`panels[${index}].title must be a non-empty string`);
|
|
48
|
+
if (!Array.isArray(command) || command.length === 0 || !command.every((part) => typeof part === "string")) {
|
|
49
|
+
throw new Error(`panels[${index}].command must be a non-empty array of strings`);
|
|
50
|
+
}
|
|
51
|
+
if (refreshSeconds !== undefined && (typeof refreshSeconds !== "number" || !(refreshSeconds >= 1))) {
|
|
52
|
+
throw new Error(`panels[${index}].refreshSeconds must be a number of at least 1`);
|
|
53
|
+
}
|
|
54
|
+
return { title, command, refreshSeconds: refreshSeconds ?? 10 };
|
|
55
|
+
});
|
|
56
|
+
return { sources };
|
|
57
|
+
}
|
|
58
|
+
catch (cause) {
|
|
59
|
+
return { sources: [], error: `${file}: ${cause instanceof Error ? cause.message : String(cause)}` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Turns a panel command's output into a panel, or says why it could not. */
|
|
63
|
+
export function parsePanelOutput(title, stdout) {
|
|
64
|
+
const parsed = JSON.parse(stdout);
|
|
65
|
+
if (parsed === null || typeof parsed !== "object" || !Array.isArray(parsed.items)) {
|
|
66
|
+
throw new Error("the command must print a JSON object with an items array");
|
|
67
|
+
}
|
|
68
|
+
const statuses = new Set(["ok", "warn", "bad", "active", "idle"]);
|
|
69
|
+
const text = (value, limit) => typeof value === "string" && value.trim() !== "" ? value.slice(0, limit) : undefined;
|
|
70
|
+
const items = parsed.items.slice(0, maxPanelItems).flatMap((item) => {
|
|
71
|
+
const entry = (item ?? {});
|
|
72
|
+
const line = text(entry.text, 300);
|
|
73
|
+
if (line === undefined)
|
|
74
|
+
return [];
|
|
75
|
+
const href = text(entry.href, 2000);
|
|
76
|
+
return [{
|
|
77
|
+
text: line,
|
|
78
|
+
...(text(entry.detail, 600) === undefined ? {} : { detail: text(entry.detail, 600) }),
|
|
79
|
+
...(typeof entry.status === "string" && statuses.has(entry.status) ? { status: entry.status } : {}),
|
|
80
|
+
// Only web links: a panel never gets the page to open anything else.
|
|
81
|
+
...(href !== undefined && /^https?:\/\//u.test(href) ? { href } : {}),
|
|
82
|
+
...(text(entry.at, 40) !== undefined && !Number.isNaN(Date.parse(entry.at)) ? { at: entry.at } : {}),
|
|
83
|
+
}];
|
|
84
|
+
});
|
|
85
|
+
return { title, ...(text(parsed.summary, 300) === undefined ? {} : { summary: text(parsed.summary, 300) }), items };
|
|
86
|
+
}
|
|
87
|
+
export async function startRunUi(options) {
|
|
88
|
+
const { directory, projectDir } = options;
|
|
89
|
+
const token = randomBytes(24).toString("hex");
|
|
90
|
+
const poll = options.pollMilliseconds ?? 1_000;
|
|
91
|
+
const clients = new Set();
|
|
92
|
+
const panels = new Map();
|
|
93
|
+
let port = 0;
|
|
94
|
+
let last = "";
|
|
95
|
+
const refreshPanels = () => {
|
|
96
|
+
const { sources, error } = readPanelSources(projectDir);
|
|
97
|
+
if (error !== undefined) {
|
|
98
|
+
panels.set("\0config", { panel: { title: "ui.json", items: [], error }, checkedAt: Date.now(), running: false });
|
|
99
|
+
}
|
|
100
|
+
else
|
|
101
|
+
panels.delete("\0config");
|
|
102
|
+
const wanted = new Set(sources.map((source) => source.title));
|
|
103
|
+
for (const title of panels.keys())
|
|
104
|
+
if (title !== "\0config" && !wanted.has(title))
|
|
105
|
+
panels.delete(title);
|
|
106
|
+
for (const source of sources) {
|
|
107
|
+
const known = panels.get(source.title);
|
|
108
|
+
if (known?.running || (known !== undefined && Date.now() - known.checkedAt < source.refreshSeconds * 1_000))
|
|
109
|
+
continue;
|
|
110
|
+
const entry = { panel: known?.panel ?? { title: source.title, items: [] }, checkedAt: Date.now(), running: true };
|
|
111
|
+
panels.set(source.title, entry);
|
|
112
|
+
execFile(source.command[0], source.command.slice(1), {
|
|
113
|
+
cwd: projectDir, timeout: panelTimeoutMilliseconds, maxBuffer: 4 * 1024 * 1024, encoding: "utf8",
|
|
114
|
+
}, (failure, stdout, stderr) => {
|
|
115
|
+
let panel;
|
|
116
|
+
try {
|
|
117
|
+
if (failure)
|
|
118
|
+
throw new Error((stderr.trim() || failure.message).split("\n").slice(-3).join(" "));
|
|
119
|
+
panel = parsePanelOutput(source.title, stdout);
|
|
120
|
+
}
|
|
121
|
+
catch (cause) {
|
|
122
|
+
panel = { ...entry.panel, error: cause instanceof Error ? cause.message : String(cause) };
|
|
123
|
+
}
|
|
124
|
+
panels.set(source.title, { panel: { ...panel, checkedAt: new Date().toISOString() }, checkedAt: Date.now(), running: false });
|
|
125
|
+
broadcast();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const snapshot = () => ({
|
|
130
|
+
project: { name: basename(projectDir), dir: projectDir },
|
|
131
|
+
directory,
|
|
132
|
+
runs: uiRuns(directory),
|
|
133
|
+
panels: [...panels.values()].map((entry) => entry.panel),
|
|
134
|
+
});
|
|
135
|
+
const broadcast = (force = false) => {
|
|
136
|
+
const state = snapshot();
|
|
137
|
+
const text = JSON.stringify(state);
|
|
138
|
+
if (!force && text === last)
|
|
139
|
+
return;
|
|
140
|
+
last = text;
|
|
141
|
+
for (const client of clients)
|
|
142
|
+
client.write(`data: ${text}\n\n`);
|
|
143
|
+
};
|
|
144
|
+
const timer = setInterval(() => {
|
|
145
|
+
refreshPanels();
|
|
146
|
+
broadcast();
|
|
147
|
+
}, poll);
|
|
148
|
+
timer.unref();
|
|
149
|
+
// A quiet connection is closed by some proxies and browsers; a comment keeps it open.
|
|
150
|
+
const heartbeat = setInterval(() => { for (const client of clients)
|
|
151
|
+
client.write(": ping\n\n"); }, 15_000);
|
|
152
|
+
heartbeat.unref();
|
|
153
|
+
const server = createServer((request, response) => {
|
|
154
|
+
void handle(request, response).catch((cause) => {
|
|
155
|
+
if (!response.headersSent)
|
|
156
|
+
send(response, 500, { error: cause instanceof Error ? cause.message : String(cause) });
|
|
157
|
+
else
|
|
158
|
+
response.end();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
async function handle(request, response) {
|
|
162
|
+
// Another site can make the browser send to this port, but not with this host name: a page served
|
|
163
|
+
// from a name that resolves here (DNS rebinding) is refused, and only the page itself holds the token.
|
|
164
|
+
const host = request.headers.host ?? "";
|
|
165
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`)
|
|
166
|
+
return send(response, 421, { error: "unexpected host" });
|
|
167
|
+
const url = new URL(request.url ?? "/", `http://${host}`);
|
|
168
|
+
if (request.method === "GET" && url.pathname === "/") {
|
|
169
|
+
response.writeHead(200, {
|
|
170
|
+
"content-type": "text/html; charset=utf-8",
|
|
171
|
+
"cache-control": "no-store",
|
|
172
|
+
"content-security-policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
173
|
+
"x-content-type-options": "nosniff",
|
|
174
|
+
});
|
|
175
|
+
response.end(runUiPage(token));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (request.method === "GET" && url.pathname === "/api/state")
|
|
179
|
+
return send(response, 200, snapshot());
|
|
180
|
+
if (request.method === "GET" && url.pathname === "/api/events") {
|
|
181
|
+
response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
182
|
+
clients.add(response);
|
|
183
|
+
response.write(`data: ${JSON.stringify(snapshot())}\n\n`);
|
|
184
|
+
request.on("close", () => clients.delete(response));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (request.method === "POST" && url.pathname === "/api/respond") {
|
|
188
|
+
if (request.headers["x-machines-ui"] !== token)
|
|
189
|
+
return send(response, 403, { error: "missing or wrong page token" });
|
|
190
|
+
const body = await readBody(request);
|
|
191
|
+
const { runId, requestId, response: text, question } = body;
|
|
192
|
+
if (typeof runId !== "string" || typeof requestId !== "string" || typeof text !== "string" || text.trim() === "") {
|
|
193
|
+
return send(response, 400, { error: "runId, requestId, and a non-empty response are required" });
|
|
194
|
+
}
|
|
195
|
+
const answer = question === true ? { type: "question", text } : text;
|
|
196
|
+
try {
|
|
197
|
+
await respondToRun({ directory, runId, requestId, response: answer });
|
|
198
|
+
}
|
|
199
|
+
catch (cause) {
|
|
200
|
+
return send(response, 409, { error: cause instanceof Error ? cause.message : String(cause) });
|
|
201
|
+
}
|
|
202
|
+
broadcast(true);
|
|
203
|
+
return send(response, 200, { ok: true });
|
|
204
|
+
}
|
|
205
|
+
send(response, 404, { error: "not found" });
|
|
206
|
+
}
|
|
207
|
+
const start = options.port ?? defaultUiPort;
|
|
208
|
+
// The first free port from the one asked for: a second page for another project is not an error.
|
|
209
|
+
for (let candidate = start;; candidate += 1) {
|
|
210
|
+
try {
|
|
211
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
212
|
+
server.once("error", rejectListen);
|
|
213
|
+
server.listen(candidate, "127.0.0.1", () => {
|
|
214
|
+
server.off("error", rejectListen);
|
|
215
|
+
resolveListen();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
port = candidate;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
catch (cause) {
|
|
222
|
+
if (cause.code !== "EADDRINUSE" || candidate - start >= 20 || options.port === 0)
|
|
223
|
+
throw cause;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const address = server.address();
|
|
227
|
+
if (address !== null && typeof address === "object")
|
|
228
|
+
port = address.port;
|
|
229
|
+
refreshPanels();
|
|
230
|
+
return {
|
|
231
|
+
url: `http://127.0.0.1:${port}/`,
|
|
232
|
+
close: () => new Promise((resolveClose) => {
|
|
233
|
+
clearInterval(timer);
|
|
234
|
+
clearInterval(heartbeat);
|
|
235
|
+
for (const client of clients)
|
|
236
|
+
client.end();
|
|
237
|
+
server.close(() => resolveClose());
|
|
238
|
+
}),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function send(response, status, body) {
|
|
242
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
243
|
+
response.end(JSON.stringify(body));
|
|
244
|
+
}
|
|
245
|
+
async function readBody(request) {
|
|
246
|
+
let text = "";
|
|
247
|
+
for await (const chunk of request) {
|
|
248
|
+
text += String(chunk);
|
|
249
|
+
if (text.length > 64 * 1024)
|
|
250
|
+
throw new Error("request body too large");
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
return JSON.parse(text);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return {};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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 { statePaths } from "./run-graph.ts";
|
|
6
|
+
export type { RunGraph, RunGraphEdge, RunGraphState, RunTrailStep } from "./run-graph.ts";
|
|
7
|
+
export { readRunWatches } from "./run-watch.ts";
|
|
8
|
+
export type { RunWatch } from "./run-watch.ts";
|
|
9
|
+
/** How long a settled or lost run stays with its viewers before it drops out. */
|
|
10
|
+
export declare const settledRunVisibleMilliseconds = 30000;
|
|
11
|
+
/** One surface that shows runs: a coding-agent session, a status line, a browser tab. */
|
|
12
|
+
export interface RunViewer {
|
|
13
|
+
/** Shows the runs this session owns. Its watch list is kept under the same id. */
|
|
14
|
+
readonly sessionId: string;
|
|
15
|
+
/** Absolute project directory; shows unowned runs working in it or below it. */
|
|
16
|
+
readonly projectDir?: string;
|
|
17
|
+
/** What the session asked to watch; shows the top-level runs an entry names, whoever owns them. */
|
|
18
|
+
readonly watches?: readonly RunWatch[];
|
|
19
|
+
}
|
|
20
|
+
export interface ViewerRunOptions {
|
|
21
|
+
readonly now?: number;
|
|
22
|
+
/** Keeps settled and lost runs however long ago they stopped. */
|
|
23
|
+
readonly all?: boolean;
|
|
24
|
+
readonly isAlive?: (pid: number) => boolean;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* How a waiting run's Human request is answered: by `machine respond` with
|
|
28
|
+
* these two ids, or only through the program that hosts the run.
|
|
29
|
+
*/
|
|
30
|
+
export type RunAnswer = {
|
|
31
|
+
readonly via: "respond";
|
|
32
|
+
readonly runId: string;
|
|
33
|
+
readonly requestId: string;
|
|
34
|
+
} | {
|
|
35
|
+
readonly via: "host";
|
|
36
|
+
};
|
|
37
|
+
/** A published record as one viewer shows it. */
|
|
38
|
+
export interface ViewerRun extends Omit<RunStatusRecord, "status"> {
|
|
39
|
+
/** `lost` when the record says running or waiting and its process is gone. */
|
|
40
|
+
readonly status: RunStatusRecord["status"] | "lost";
|
|
41
|
+
/** Why the viewer shows it; the first that holds of owned, project, and watched. */
|
|
42
|
+
readonly shown: "owned" | "project" | "watched";
|
|
43
|
+
/** Present while the run waits on a request that something can answer. */
|
|
44
|
+
readonly answer?: RunAnswer;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The runs one viewer shows: the runs its session owns, unowned runs working
|
|
48
|
+
* inside its project, and runs it watches. Sub-runs are never shown, and a
|
|
49
|
+
* settled or lost run drops out after `settledRunVisibleMilliseconds`. Runs
|
|
50
|
+
* that can still change come first, then the most recently updated; records
|
|
51
|
+
* that tie keep the order they were given in.
|
|
52
|
+
*
|
|
53
|
+
* Pure apart from the default process-liveness probe. `claude/statusline.mjs`
|
|
54
|
+
* cannot load this module and carries a copy of the selection;
|
|
55
|
+
* tests/run-viewer.test.ts keeps the two in step.
|
|
56
|
+
*/
|
|
57
|
+
export declare function runsForViewer(records: readonly RunStatusRecord[], viewer: RunViewer, options?: ViewerRunOptions): ViewerRun[];
|
|
@@ -0,0 +1,66 @@
|
|
|
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 { statePaths } from "./run-graph.js";
|
|
8
|
+
export { readRunWatches } from "./run-watch.js";
|
|
9
|
+
/** How long a settled or lost run stays with its viewers before it drops out. */
|
|
10
|
+
export const settledRunVisibleMilliseconds = 30_000;
|
|
11
|
+
/**
|
|
12
|
+
* The runs one viewer shows: the runs its session owns, unowned runs working
|
|
13
|
+
* inside its project, and runs it watches. Sub-runs are never shown, and a
|
|
14
|
+
* settled or lost run drops out after `settledRunVisibleMilliseconds`. Runs
|
|
15
|
+
* that can still change come first, then the most recently updated; records
|
|
16
|
+
* that tie keep the order they were given in.
|
|
17
|
+
*
|
|
18
|
+
* Pure apart from the default process-liveness probe. `claude/statusline.mjs`
|
|
19
|
+
* cannot load this module and carries a copy of the selection;
|
|
20
|
+
* tests/run-viewer.test.ts keeps the two in step.
|
|
21
|
+
*/
|
|
22
|
+
export function runsForViewer(records, viewer, options = {}) {
|
|
23
|
+
const { now = Date.now(), all = false, isAlive = isProcessAlive } = options;
|
|
24
|
+
const visible = [];
|
|
25
|
+
for (const record of records) {
|
|
26
|
+
// Sub-runs belong to the run that started them, not to the session.
|
|
27
|
+
if (record.parent !== undefined)
|
|
28
|
+
continue;
|
|
29
|
+
const shown = shownBecause(record, viewer);
|
|
30
|
+
if (shown === undefined)
|
|
31
|
+
continue;
|
|
32
|
+
const updatedAt = Date.parse(record.updatedAt);
|
|
33
|
+
const recorded = record.status === "running" || record.status === "waiting";
|
|
34
|
+
// A run that stopped reporting without finishing has lost its process.
|
|
35
|
+
const lost = recorded && !isAlive(record.pid);
|
|
36
|
+
const active = recorded && !lost;
|
|
37
|
+
if (!active && !all && now - updatedAt > settledRunVisibleMilliseconds)
|
|
38
|
+
continue;
|
|
39
|
+
const status = lost ? "lost" : record.status;
|
|
40
|
+
const answer = status === "waiting" ? runAnswer(record) : undefined;
|
|
41
|
+
visible.push({ run: { ...record, status, shown, ...(answer === undefined ? {} : { answer }) }, active, updatedAt });
|
|
42
|
+
}
|
|
43
|
+
visible.sort((left, right) => Number(right.active) - Number(left.active) || right.updatedAt - left.updatedAt);
|
|
44
|
+
return visible.map(({ run }) => run);
|
|
45
|
+
}
|
|
46
|
+
function shownBecause(record, viewer) {
|
|
47
|
+
// An owned run is its owner's: the project it works in shows it to nobody else.
|
|
48
|
+
if (record.owner !== undefined) {
|
|
49
|
+
if (record.owner === viewer.sessionId)
|
|
50
|
+
return "owned";
|
|
51
|
+
}
|
|
52
|
+
else if (viewer.projectDir !== undefined && isWithinDirectory(record.cwd, viewer.projectDir)) {
|
|
53
|
+
return "project";
|
|
54
|
+
}
|
|
55
|
+
// A run the session asked to watch is shown whoever owns it and wherever it works.
|
|
56
|
+
return viewer.watches?.some((watch) => runWatchMatches(watch, record)) === true ? "watched" : undefined;
|
|
57
|
+
}
|
|
58
|
+
function runAnswer(record) {
|
|
59
|
+
if (record.human === undefined)
|
|
60
|
+
return undefined;
|
|
61
|
+
if (record.answeredBy === "host")
|
|
62
|
+
return { via: "host" };
|
|
63
|
+
// A record from before requests had ids says nothing about how it is answered.
|
|
64
|
+
const { requestId } = record.human;
|
|
65
|
+
return requestId === undefined ? undefined : { via: "respond", runId: record.id, requestId };
|
|
66
|
+
}
|
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/authoring.md
CHANGED
|
@@ -101,6 +101,10 @@ The Machine owns this loop. An Agent runner only returns an allowed outcome; it
|
|
|
101
101
|
does not decide destination states. Add verification Operations where the workflow
|
|
102
102
|
has a concrete condition to check.
|
|
103
103
|
|
|
104
|
+
When designing verification and repair routes, follow
|
|
105
|
+
[Checks the Machine owns](../skills/machine-builder/SKILL.md#checks-the-machine-owns)
|
|
106
|
+
for the checks and evidence the workflow must own.
|
|
107
|
+
|
|
104
108
|
### Handling a usage limit in the Machine
|
|
105
109
|
|
|
106
110
|
By default an Agent state never sees its provider's usage limit: the launcher
|
|
@@ -471,6 +475,15 @@ Options:
|
|
|
471
475
|
`MACHINES_AGENT_MODEL` / `MACHINES_AGENT_EFFORT`. The `env` option overrides
|
|
472
476
|
matching parent variables. With no setting, Codex selects its configured values;
|
|
473
477
|
effort must be supported by the selected model.
|
|
478
|
+
- `serviceTier`: selects a Codex service tier, for example
|
|
479
|
+
`codexAgent("codex", [], { serviceTier: "fast" })` for Fast mode or
|
|
480
|
+
`codexAgent("codex", [], { serviceTier: "ultrafast" })` for Ultrafast. Overrides
|
|
481
|
+
`CODEX_SERVICE_TIER` (including values supplied through `env`); when neither
|
|
482
|
+
is set, the runner leaves Codex's configured tier unchanged. The built-in
|
|
483
|
+
preset also accepts `CODEX_SERVICE_TIER=fast` or `CODEX_SERVICE_TIER=ultrafast`.
|
|
484
|
+
Both are opt-in; Machines does not enable either tier by default. Supported
|
|
485
|
+
tiers depend on the installed CLI, model, and account. See the
|
|
486
|
+
[Codex configuration reference](https://developers.openai.com/codex/config-reference#service_tier).
|
|
474
487
|
- `output`: `stream` (default) writes agent messages and stderr to the terminal;
|
|
475
488
|
`capture` keeps them off the terminal and includes final prose in the event's
|
|
476
489
|
`message`. Both modes report activity to observers.
|