@dna113p/machines 0.1.0 → 0.3.0
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 +120 -4
- package/claude/statusline.mjs +244 -0
- package/dist/examples/agy.d.ts +1 -0
- package/dist/examples/agy.js +30 -0
- package/dist/examples/chatgpt-web.d.ts +1 -0
- package/dist/examples/chatgpt-web.js +36 -0
- package/dist/examples/claude.d.ts +1 -0
- package/dist/examples/claude.js +30 -0
- package/dist/examples/codex.d.ts +1 -0
- package/dist/examples/codex.js +30 -0
- package/dist/examples/decision.d.ts +1 -0
- package/dist/examples/decision.js +66 -0
- package/dist/examples/deepseek.d.ts +1 -0
- package/dist/examples/deepseek.js +27 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +72 -10
- package/dist/pi-extension/index.js +16 -9
- package/dist/src/acp.js +4 -41
- package/dist/src/agent-bindings.d.ts +6 -0
- package/dist/src/agent-bindings.js +35 -5
- package/dist/src/agent-limit.d.ts +25 -0
- package/dist/src/agent-limit.js +34 -0
- package/dist/src/agent-limits.d.ts +37 -0
- package/dist/src/agent-limits.js +212 -0
- package/dist/src/agent-protocol.d.ts +6 -0
- package/dist/src/agent-protocol.js +40 -0
- package/dist/src/agy.d.ts +11 -0
- package/dist/src/agy.js +168 -0
- package/dist/src/catalog.d.ts +3 -0
- package/dist/src/catalog.js +94 -12
- package/dist/src/chatgpt-web.d.ts +143 -0
- package/dist/src/chatgpt-web.js +946 -0
- package/dist/src/claude.d.ts +35 -0
- package/dist/src/claude.js +294 -0
- package/dist/src/cli.js +194 -16
- package/dist/src/codex.d.ts +16 -0
- package/dist/src/codex.js +176 -0
- package/dist/src/conversation.d.ts +74 -0
- package/dist/src/conversation.js +46 -0
- package/dist/src/coordinator-guidance.d.ts +3 -0
- package/dist/src/coordinator-guidance.js +17 -0
- package/dist/src/decision.d.ts +29 -0
- package/dist/src/decision.js +67 -0
- package/dist/src/deepseek.d.ts +12 -0
- package/dist/src/deepseek.js +16 -0
- package/dist/src/host.d.ts +3 -0
- package/dist/src/host.js +68 -7
- package/dist/src/index.d.ts +39 -2
- package/dist/src/index.js +62 -5
- package/dist/src/jev.d.ts +15 -0
- package/dist/src/jev.js +90 -0
- package/dist/src/json.d.ts +6 -0
- package/dist/src/json.js +28 -0
- package/dist/src/launcher.d.ts +11 -1
- package/dist/src/launcher.js +72 -3
- package/dist/src/laya.d.ts +15 -0
- package/dist/src/laya.js +13 -0
- package/dist/src/openrouter.d.ts +16 -0
- package/dist/src/openrouter.js +97 -0
- package/dist/src/run-inbox.d.ts +49 -0
- package/dist/src/run-inbox.js +229 -0
- package/dist/src/run-status.d.ts +97 -0
- package/dist/src/run-status.js +285 -0
- package/dist/src/session.d.ts +13 -0
- package/dist/src/session.js +41 -3
- package/dist/src/system-one-http.d.ts +25 -0
- package/dist/src/system-one-http.js +146 -0
- package/dist/src/terminal-human.d.ts +8 -1
- package/dist/src/terminal-human.js +36 -8
- package/dist/src/von.d.ts +15 -0
- package/dist/src/von.js +13 -0
- package/docs/architecture.md +69 -2
- package/docs/authoring.md +849 -2
- package/docs/integrations.md +243 -3
- package/docs/local-decisions.md +212 -0
- package/package.json +23 -4
- package/services/laya/server.py +230 -0
- package/skills/machine-builder/SKILL.md +56 -6
- package/skills/machine-delegation/SKILL.md +123 -0
- package/skills/machine-delegation/references/delegation-machine.md +90 -0
- package/skills/machine-project-setup/SKILL.md +181 -0
- package/skills/machine-project-setup/references/project-workflows.md +50 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { HumanRequest, HumanResponse, HumanRunner } from "./index.ts";
|
|
2
|
+
import { type RunStatusHuman } from "./run-status.ts";
|
|
3
|
+
type PendingRequest = Pick<RunStatusHuman, "choices" | "discussion"> & {
|
|
4
|
+
readonly requestId: string;
|
|
5
|
+
};
|
|
6
|
+
export interface RunResponse {
|
|
7
|
+
readonly directory: string;
|
|
8
|
+
readonly runId: string;
|
|
9
|
+
readonly requestId: string;
|
|
10
|
+
readonly response: HumanResponse;
|
|
11
|
+
/** How long to wait for the run to take the response. */
|
|
12
|
+
readonly timeoutMilliseconds?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Answers a waiting run from another process of the same user. Resolves once
|
|
16
|
+
* the run has left the request and named this delivery among the responses it
|
|
17
|
+
* took; otherwise throws, having taken the response back unless the run
|
|
18
|
+
* already has it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function respondToRun(options: RunResponse): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Hands `accept` the first valid inbox response to a request, then stops; the
|
|
23
|
+
* returned function stops earlier. Start watching before the request is
|
|
24
|
+
* published: whatever is already in the inbox predates it and is dropped.
|
|
25
|
+
* The run's record must name the `delivery` it applies before leaving the
|
|
26
|
+
* request, or the sender is told its response was not taken.
|
|
27
|
+
*/
|
|
28
|
+
export declare function watchRunInbox(directory: string, runId: string, request: PendingRequest, accept: (response: HumanResponse, delivery: string | undefined) => void): () => void;
|
|
29
|
+
export interface InboxHumanOptions {
|
|
30
|
+
readonly directory: string;
|
|
31
|
+
readonly runId: string;
|
|
32
|
+
/** Publishes the request in the run's record so that others can answer it. */
|
|
33
|
+
publish(request: RunStatusHuman & {
|
|
34
|
+
readonly requestId: string;
|
|
35
|
+
}): void;
|
|
36
|
+
/** Names, in the run's record, the inbox response that answered the request. */
|
|
37
|
+
acknowledge(delivery: string): void;
|
|
38
|
+
readonly terminal?: (request: HumanRequest, signal: AbortSignal) => Promise<string>;
|
|
39
|
+
/** The terminal cannot answer this request; only the inbox can. */
|
|
40
|
+
readonly onTerminalClosed?: (requestId: string) => void;
|
|
41
|
+
/** The terminal gave an answer the request does not allow, and is asked again. */
|
|
42
|
+
readonly onTerminalRefused?: (reason: string) => void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A Human runner for a published run: asks on the terminal and watches the
|
|
46
|
+
* run's inbox, and whichever answers first wins.
|
|
47
|
+
*/
|
|
48
|
+
export declare function inboxHuman(options: InboxHumanOptions): HumanRunner;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { linkSync, readFileSync, renameSync, rmSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { deliveryRetentionMilliseconds, isFileSafeId, isProcessAlive, readRunStatuses, runDeliveryPath, runInboxPath, } from "./run-status.js";
|
|
6
|
+
import { TerminalInputClosed, terminalHuman } from "./terminal-human.js";
|
|
7
|
+
const pollMilliseconds = 100;
|
|
8
|
+
const confirmationMilliseconds = 5_000;
|
|
9
|
+
/**
|
|
10
|
+
* Answers a waiting run from another process of the same user. Resolves once
|
|
11
|
+
* the run has left the request and named this delivery among the responses it
|
|
12
|
+
* took; otherwise throws, having taken the response back unless the run
|
|
13
|
+
* already has it.
|
|
14
|
+
*/
|
|
15
|
+
export async function respondToRun(options) {
|
|
16
|
+
const { directory, runId, requestId, response } = options;
|
|
17
|
+
const published = () => (isFileSafeId(runId)
|
|
18
|
+
? readRunStatuses(directory).find((record) => record.id === runId)
|
|
19
|
+
: undefined);
|
|
20
|
+
const record = published();
|
|
21
|
+
if (record === undefined)
|
|
22
|
+
throw new Error(`Machine run "${runId}" is not published in ${directory}`);
|
|
23
|
+
const request = pendingRequest(record);
|
|
24
|
+
if (request === undefined)
|
|
25
|
+
throw new Error(`Machine run "${runId}" is not waiting for Human input`);
|
|
26
|
+
if (request.requestId === undefined) {
|
|
27
|
+
throw new Error(`Machine run "${runId}" does not take a response from outside its host`);
|
|
28
|
+
}
|
|
29
|
+
if (request.requestId !== requestId) {
|
|
30
|
+
throw new Error(`Machine run "${runId}" is waiting on request "${request.requestId}", not "${requestId}"`);
|
|
31
|
+
}
|
|
32
|
+
const refusal = responseRefusal(request, response);
|
|
33
|
+
if (refusal !== undefined)
|
|
34
|
+
throw new Error(refusal);
|
|
35
|
+
const delivery = randomUUID();
|
|
36
|
+
const withdraw = deliver(directory, runId, { delivery, requestId, response });
|
|
37
|
+
const timeout = options.timeoutMilliseconds ?? confirmationMilliseconds;
|
|
38
|
+
const delivered = Date.now();
|
|
39
|
+
while (true) {
|
|
40
|
+
const current = published();
|
|
41
|
+
const left = pendingRequest(current)?.requestId !== requestId;
|
|
42
|
+
// The run also removes a response it discards, so only its record says which ones it took.
|
|
43
|
+
if (left && current?.deliveries?.includes(delivery) === true)
|
|
44
|
+
return;
|
|
45
|
+
const waited = Date.now() - delivered;
|
|
46
|
+
// A record still names every response its run took since this one was delivered.
|
|
47
|
+
if (left && waited < deliveryRetentionMilliseconds) {
|
|
48
|
+
withdraw();
|
|
49
|
+
throw new Error(`Response to Machine run "${runId}" was not confirmed: the run left request "${requestId}" without taking it`);
|
|
50
|
+
}
|
|
51
|
+
if (left || waited >= timeout) {
|
|
52
|
+
throw new Error(`Response to Machine run "${runId}" was not confirmed within ${timeout / 1_000}s${withdraw() ? "; it was withdrawn and will not be applied" : ""}`);
|
|
53
|
+
}
|
|
54
|
+
await sleep(Math.min(pollMilliseconds / 2, timeout));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Hands `accept` the first valid inbox response to a request, then stops; the
|
|
59
|
+
* returned function stops earlier. Start watching before the request is
|
|
60
|
+
* published: whatever is already in the inbox predates it and is dropped.
|
|
61
|
+
* The run's record must name the `delivery` it applies before leaving the
|
|
62
|
+
* request, or the sender is told its response was not taken.
|
|
63
|
+
*/
|
|
64
|
+
export function watchRunInbox(directory, runId, request, accept) {
|
|
65
|
+
takeRunResponse(directory, runId);
|
|
66
|
+
const timer = setInterval(() => {
|
|
67
|
+
const message = takeRunResponse(directory, runId);
|
|
68
|
+
// A response to another request, or one this request does not allow, is discarded.
|
|
69
|
+
if (message === undefined || message.requestId !== request.requestId)
|
|
70
|
+
return;
|
|
71
|
+
if (responseRefusal(request, message.response) !== undefined)
|
|
72
|
+
return;
|
|
73
|
+
clearInterval(timer);
|
|
74
|
+
accept(message.response, message.delivery);
|
|
75
|
+
}, pollMilliseconds);
|
|
76
|
+
return () => clearInterval(timer);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A Human runner for a published run: asks on the terminal and watches the
|
|
80
|
+
* run's inbox, and whichever answers first wins.
|
|
81
|
+
*/
|
|
82
|
+
export function inboxHuman(options) {
|
|
83
|
+
const { directory, runId } = options;
|
|
84
|
+
const terminal = options.terminal ?? askTerminal;
|
|
85
|
+
return (request) => new Promise((resolve, reject) => {
|
|
86
|
+
const requestId = randomUUID();
|
|
87
|
+
const asked = new AbortController();
|
|
88
|
+
let settled = false;
|
|
89
|
+
const settle = (finish) => {
|
|
90
|
+
if (settled)
|
|
91
|
+
return;
|
|
92
|
+
settled = true;
|
|
93
|
+
stopWatching();
|
|
94
|
+
asked.abort();
|
|
95
|
+
finish();
|
|
96
|
+
};
|
|
97
|
+
const stopWatching = watchRunInbox(directory, runId, { ...request, requestId }, (response, delivery) => {
|
|
98
|
+
settle(() => {
|
|
99
|
+
if (delivery !== undefined)
|
|
100
|
+
options.acknowledge(delivery);
|
|
101
|
+
resolve(response);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
options.publish({ ...request, requestId });
|
|
105
|
+
const ask = () => new Promise((answer) => answer(terminal(request, asked.signal))).then((answer) => {
|
|
106
|
+
if (settled)
|
|
107
|
+
return;
|
|
108
|
+
// Only a valid answer wins: one the request does not allow leaves it waiting on both sources.
|
|
109
|
+
const refusal = responseRefusal(request, answer);
|
|
110
|
+
if (refusal === undefined)
|
|
111
|
+
return settle(() => resolve(answer));
|
|
112
|
+
options.onTerminalRefused?.(refusal);
|
|
113
|
+
void ask();
|
|
114
|
+
}, (cause) => {
|
|
115
|
+
if (settled)
|
|
116
|
+
return;
|
|
117
|
+
// Without terminal input the published request is the only way to
|
|
118
|
+
// answer. When nobody can see it either, fail as an unpublished run does.
|
|
119
|
+
const visible = readRunStatuses(directory)
|
|
120
|
+
.some((record) => record.id === runId && record.human?.requestId === requestId);
|
|
121
|
+
if (cause instanceof TerminalInputClosed && visible)
|
|
122
|
+
options.onTerminalClosed?.(requestId);
|
|
123
|
+
else
|
|
124
|
+
settle(() => reject(cause));
|
|
125
|
+
});
|
|
126
|
+
void ask();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function askTerminal(request, signal) {
|
|
130
|
+
// Input that ended during an earlier request never closes a new prompt, so say it is closed.
|
|
131
|
+
return process.stdin.readableEnded ? Promise.reject(new TerminalInputClosed()) : terminalHuman(request, signal);
|
|
132
|
+
}
|
|
133
|
+
function pendingRequest(record) {
|
|
134
|
+
// A record whose process is gone can no longer be answered.
|
|
135
|
+
return record?.status === "waiting" && isProcessAlive(record.pid) ? record.human : undefined;
|
|
136
|
+
}
|
|
137
|
+
/** Why a request does not allow a response, checked before delivery and again on receipt. */
|
|
138
|
+
function responseRefusal(request, response) {
|
|
139
|
+
if (typeof response === "string") {
|
|
140
|
+
return request.choices === undefined || request.choices.includes(response)
|
|
141
|
+
? undefined
|
|
142
|
+
: `Expected one of: ${request.choices.join(", ")}`;
|
|
143
|
+
}
|
|
144
|
+
if (request.discussion !== true)
|
|
145
|
+
return "This Human request does not support discussion";
|
|
146
|
+
return response.text.trim() === "" || response.text.length > 8000
|
|
147
|
+
? "Question must contain 1–8000 characters"
|
|
148
|
+
: undefined;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Puts a response in the run's inbox, which holds one at a time. Returns a
|
|
152
|
+
* function that takes it back and tells whether it did: false means the run
|
|
153
|
+
* already has it.
|
|
154
|
+
*
|
|
155
|
+
* The inbox's name is shared by every sender, so nothing can be learned from it
|
|
156
|
+
* and then acted on: it may hold a later response by then. A response therefore
|
|
157
|
+
* has a second name that carries its delivery, and removing that name, which
|
|
158
|
+
* only one process can do, decides who owns it. Only the run removes a file
|
|
159
|
+
* from the inbox.
|
|
160
|
+
*/
|
|
161
|
+
function deliver(directory, runId, message) {
|
|
162
|
+
const sent = runDeliveryPath(directory, runId, message.delivery);
|
|
163
|
+
try {
|
|
164
|
+
writeFileSync(sent, `${JSON.stringify(message)}\n`, { mode: 0o600 });
|
|
165
|
+
// The run only ever sees a whole response, and a link, unlike a rename,
|
|
166
|
+
// cannot replace one that is already pending.
|
|
167
|
+
linkSync(sent, runInboxPath(directory, runId));
|
|
168
|
+
}
|
|
169
|
+
catch (cause) {
|
|
170
|
+
rmSync(sent, { force: true });
|
|
171
|
+
if (cause.code !== "EEXIST")
|
|
172
|
+
throw cause;
|
|
173
|
+
throw new Error(`Machine run "${runId}" already has a response pending`);
|
|
174
|
+
}
|
|
175
|
+
return () => {
|
|
176
|
+
try {
|
|
177
|
+
// Emptied through its own name first, so that what stays in the inbox until
|
|
178
|
+
// the run next reads it, and discards it, no longer holds the response.
|
|
179
|
+
truncateSync(sent);
|
|
180
|
+
unlinkSync(sent);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** Removes whatever is pending from the inbox and returns it if it is a response the run now owns. */
|
|
189
|
+
function takeRunResponse(directory, runId) {
|
|
190
|
+
const taken = join(directory, `.${runId}.${randomUUID()}.inbox.taken`);
|
|
191
|
+
try {
|
|
192
|
+
// Moved out before it is read, so what is read is what was removed, whatever arrives next.
|
|
193
|
+
renameSync(runInboxPath(directory, runId), taken);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const message = JSON.parse(readFileSync(taken, "utf8"));
|
|
200
|
+
if (!isInboxMessage(message))
|
|
201
|
+
return undefined;
|
|
202
|
+
// Fails when the sender has taken the response back.
|
|
203
|
+
if (message.delivery !== undefined)
|
|
204
|
+
unlinkSync(runDeliveryPath(directory, runId, message.delivery));
|
|
205
|
+
return message;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
rmSync(taken, { force: true });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function isInboxMessage(value) {
|
|
215
|
+
if (value === null || typeof value !== "object")
|
|
216
|
+
return false;
|
|
217
|
+
const { delivery, requestId, response } = value;
|
|
218
|
+
if (typeof requestId !== "string")
|
|
219
|
+
return false;
|
|
220
|
+
// The delivery becomes part of a file name.
|
|
221
|
+
if (delivery !== undefined && (typeof delivery !== "string" || !/^[A-Za-z0-9_-]+$/u.test(delivery)))
|
|
222
|
+
return false;
|
|
223
|
+
if (typeof response === "string")
|
|
224
|
+
return true;
|
|
225
|
+
if (response === null || typeof response !== "object")
|
|
226
|
+
return false;
|
|
227
|
+
const question = response;
|
|
228
|
+
return question.type === "question" && typeof question.text === "string";
|
|
229
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { StateValue } from "xstate";
|
|
2
|
+
/** One published run, stored as `<MACHINES_RUN_STATUS_DIR>/<id>.json`. */
|
|
3
|
+
export interface RunStatusRecord {
|
|
4
|
+
/** Version 1 records carry only `human.prompt`. */
|
|
5
|
+
readonly schemaVersion: 1 | 2;
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly pid: number;
|
|
8
|
+
readonly owner?: string;
|
|
9
|
+
/** The enclosing run when this one was started from inside another Machine. */
|
|
10
|
+
readonly parent?: string;
|
|
11
|
+
/** What this run is for, as its launcher chose to describe it. */
|
|
12
|
+
readonly label?: string;
|
|
13
|
+
readonly machine: string;
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly cwd: string;
|
|
16
|
+
readonly status: "running" | "waiting" | "completed" | "failed";
|
|
17
|
+
readonly state?: string;
|
|
18
|
+
/** When the current state was entered. */
|
|
19
|
+
readonly stateSince?: string;
|
|
20
|
+
readonly agent?: RunStatusAgent;
|
|
21
|
+
/** Present while the current Agent state waits for a usage limit to reset; `status` stays `running`. */
|
|
22
|
+
readonly limit?: RunStatusLimit;
|
|
23
|
+
readonly human?: RunStatusHuman;
|
|
24
|
+
/** Names the inbox responses the run took lately, so that each sender can tell; never a response. */
|
|
25
|
+
readonly deliveries?: readonly string[];
|
|
26
|
+
readonly error?: string;
|
|
27
|
+
readonly startedAt: string;
|
|
28
|
+
readonly updatedAt: string;
|
|
29
|
+
}
|
|
30
|
+
/** The pending Human request: what a surface needs to show it and to answer it. */
|
|
31
|
+
export interface RunStatusHuman {
|
|
32
|
+
readonly prompt: string;
|
|
33
|
+
/** Names this request to `machine respond`; absent when the run takes no outside response. */
|
|
34
|
+
readonly requestId?: string;
|
|
35
|
+
readonly choices?: readonly string[];
|
|
36
|
+
readonly suggestions?: readonly string[];
|
|
37
|
+
readonly discussion?: boolean;
|
|
38
|
+
}
|
|
39
|
+
export interface RunStatusAgent {
|
|
40
|
+
readonly harness: string;
|
|
41
|
+
readonly model?: string;
|
|
42
|
+
readonly thinking?: string;
|
|
43
|
+
}
|
|
44
|
+
/** A wait for an Agent usage limit: a one-line summary, and when the wait is expected to end. */
|
|
45
|
+
export interface RunStatusLimit {
|
|
46
|
+
readonly text: string;
|
|
47
|
+
readonly until?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface RunStatusRun {
|
|
50
|
+
readonly id?: string;
|
|
51
|
+
readonly machine: string;
|
|
52
|
+
readonly path: string;
|
|
53
|
+
readonly cwd: string;
|
|
54
|
+
readonly startedAt?: string;
|
|
55
|
+
}
|
|
56
|
+
/** A present key replaces its field; a present key holding `undefined` clears it. */
|
|
57
|
+
export interface RunStatusChange {
|
|
58
|
+
readonly status?: "running" | "waiting";
|
|
59
|
+
readonly state?: StateValue;
|
|
60
|
+
readonly agent?: RunStatusAgent;
|
|
61
|
+
/** Leaving the state clears it as well. */
|
|
62
|
+
readonly limit?: RunStatusLimit;
|
|
63
|
+
readonly human?: RunStatusHuman;
|
|
64
|
+
/** Names an inbox response the run took, in addition to those its record already names. */
|
|
65
|
+
readonly delivery?: string;
|
|
66
|
+
}
|
|
67
|
+
export type RunStatusOutcome = {
|
|
68
|
+
readonly state?: StateValue;
|
|
69
|
+
} | {
|
|
70
|
+
readonly error: string;
|
|
71
|
+
};
|
|
72
|
+
export interface RunStatusPublisher {
|
|
73
|
+
update(change: RunStatusChange): void;
|
|
74
|
+
finish(outcome: RunStatusOutcome): void;
|
|
75
|
+
}
|
|
76
|
+
/** How long a record goes on naming a response its run took: longer than a sender waits to hear of it. */
|
|
77
|
+
export declare const deliveryRetentionMilliseconds = 60000;
|
|
78
|
+
/** The directory runs publish to, or undefined while publication is off. */
|
|
79
|
+
export declare function runStatusDirectory(environment?: NodeJS.ProcessEnv): string | undefined;
|
|
80
|
+
/** The one pending outside response for a run, kept beside its record. */
|
|
81
|
+
export declare function runInboxPath(directory: string, id: string): string;
|
|
82
|
+
/**
|
|
83
|
+
* A second name for a response in a run's inbox, which its sender keeps. Only
|
|
84
|
+
* the sender and the run remove it, and whichever does owns the response.
|
|
85
|
+
*/
|
|
86
|
+
export declare function runDeliveryPath(directory: string, id: string, delivery: string): string;
|
|
87
|
+
/**
|
|
88
|
+
* Publishes one run for hosts that can only read files, such as a status line.
|
|
89
|
+
* Opt-in through MACHINES_RUN_STATUS_DIR. Publication is presentation: it never
|
|
90
|
+
* throws, and it records neither Machine input nor output.
|
|
91
|
+
*/
|
|
92
|
+
export declare function createRunStatusPublisher(run: RunStatusRun, environment?: NodeJS.ProcessEnv): RunStatusPublisher;
|
|
93
|
+
/** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
|
|
94
|
+
export declare function readRunStatuses(directory: string): RunStatusRecord[];
|
|
95
|
+
/** The id becomes a file name, so it must not be able to leave the directory. */
|
|
96
|
+
export declare function isFileSafeId(id: string): boolean;
|
|
97
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
@@ -0,0 +1,285 @@
|
|
|
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";
|
|
4
|
+
const staleAfterMilliseconds = 60 * 60 * 1_000;
|
|
5
|
+
/** How long a record goes on naming a response its run took: longer than a sender waits to hear of it. */
|
|
6
|
+
export const deliveryRetentionMilliseconds = 60_000;
|
|
7
|
+
const disabled = { update() { }, finish() { } };
|
|
8
|
+
let temporaryFiles = 0;
|
|
9
|
+
/** The directory runs publish to, or undefined while publication is off. */
|
|
10
|
+
export function runStatusDirectory(environment = process.env) {
|
|
11
|
+
const configured = environment.MACHINES_RUN_STATUS_DIR;
|
|
12
|
+
return configured === undefined || configured === "" ? undefined : resolve(configured);
|
|
13
|
+
}
|
|
14
|
+
/** The one pending outside response for a run, kept beside its record. */
|
|
15
|
+
export function runInboxPath(directory, id) {
|
|
16
|
+
return join(directory, `${id}.inbox`);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A second name for a response in a run's inbox, which its sender keeps. Only
|
|
20
|
+
* the sender and the run remove it, and whichever does owns the response.
|
|
21
|
+
*/
|
|
22
|
+
export function runDeliveryPath(directory, id, delivery) {
|
|
23
|
+
return join(directory, `.${id}.${delivery}.inbox.sent`);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Publishes one run for hosts that can only read files, such as a status line.
|
|
27
|
+
* Opt-in through MACHINES_RUN_STATUS_DIR. Publication is presentation: it never
|
|
28
|
+
* throws, and it records neither Machine input nor output.
|
|
29
|
+
*/
|
|
30
|
+
export function createRunStatusPublisher(run, environment = process.env) {
|
|
31
|
+
const directory = runStatusDirectory(environment);
|
|
32
|
+
const id = run.id ?? randomUUID();
|
|
33
|
+
if (directory === undefined || !isFileSafeId(id))
|
|
34
|
+
return disabled;
|
|
35
|
+
const owner = environment.MACHINES_RUN_OWNER || environment.CLAUDE_CODE_SESSION_ID || undefined;
|
|
36
|
+
const parent = environment.MACHINES_RUN_PARENT || undefined;
|
|
37
|
+
const label = formatLabel(environment.MACHINES_RUN_LABEL);
|
|
38
|
+
const startedAt = run.startedAt ?? new Date().toISOString();
|
|
39
|
+
let status = "running";
|
|
40
|
+
let state;
|
|
41
|
+
let stateSince;
|
|
42
|
+
let agent;
|
|
43
|
+
let limit;
|
|
44
|
+
let human;
|
|
45
|
+
let deliveries = [];
|
|
46
|
+
let error;
|
|
47
|
+
const publish = () => {
|
|
48
|
+
const record = {
|
|
49
|
+
schemaVersion: 2,
|
|
50
|
+
id,
|
|
51
|
+
pid: process.pid,
|
|
52
|
+
...(owner === undefined ? {} : { owner }),
|
|
53
|
+
...(parent === undefined ? {} : { parent }),
|
|
54
|
+
...(label === undefined ? {} : { label }),
|
|
55
|
+
machine: run.machine,
|
|
56
|
+
path: run.path,
|
|
57
|
+
cwd: run.cwd,
|
|
58
|
+
status,
|
|
59
|
+
...(state === undefined ? {} : { state }),
|
|
60
|
+
...(stateSince === undefined ? {} : { stateSince }),
|
|
61
|
+
...(agent === undefined ? {} : { agent }),
|
|
62
|
+
...(limit === undefined ? {} : { limit }),
|
|
63
|
+
...(human === undefined ? {} : { human }),
|
|
64
|
+
...(deliveries.length === 0 ? {} : { deliveries: deliveries.map((delivery) => delivery.id) }),
|
|
65
|
+
...(error === undefined ? {} : { error }),
|
|
66
|
+
startedAt,
|
|
67
|
+
updatedAt: new Date().toISOString(),
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
71
|
+
// Readers only ever see a whole record: write beside it, then rename over it.
|
|
72
|
+
const temporary = join(directory, `.${id}.${process.pid}.${temporaryFiles += 1}.tmp`);
|
|
73
|
+
try {
|
|
74
|
+
writeFileSync(temporary, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
75
|
+
renameSync(temporary, join(directory, `${id}.json`));
|
|
76
|
+
}
|
|
77
|
+
catch (cause) {
|
|
78
|
+
rmSync(temporary, { force: true });
|
|
79
|
+
throw cause;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// A status line is never worth a run: an unwritable directory is ignored.
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
pruneRunStatuses(directory);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Pruning is best effort for the same reason.
|
|
91
|
+
}
|
|
92
|
+
publish();
|
|
93
|
+
return {
|
|
94
|
+
update(change) {
|
|
95
|
+
if (status === "completed" || status === "failed")
|
|
96
|
+
return;
|
|
97
|
+
const nextState = "state" in change ? formatState(change.state) : state;
|
|
98
|
+
const next = {
|
|
99
|
+
status: change.status ?? status,
|
|
100
|
+
state: nextState,
|
|
101
|
+
agent: "agent" in change ? pickAgent(change.agent) : agent,
|
|
102
|
+
// A wait belongs to the state whose Agent is waiting.
|
|
103
|
+
limit: "limit" in change ? pickLimit(change.limit) : nextState === state ? limit : undefined,
|
|
104
|
+
human: "human" in change ? pickHuman(change.human) : human,
|
|
105
|
+
// A later response must not hide an earlier one from a sender that has yet to look.
|
|
106
|
+
deliveries: change.delivery === undefined ? deliveries : [
|
|
107
|
+
...deliveries.filter((delivery) => Date.now() - delivery.takenAt < deliveryRetentionMilliseconds),
|
|
108
|
+
{ id: change.delivery, takenAt: Date.now() },
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
// Repeated notifications for an unchanged run do not need another write.
|
|
112
|
+
if (JSON.stringify(next) === JSON.stringify({ status, state, agent, limit, human, deliveries }))
|
|
113
|
+
return;
|
|
114
|
+
if (next.state !== state)
|
|
115
|
+
stateSince = new Date().toISOString();
|
|
116
|
+
({ status, state, agent, limit, human, deliveries } = next);
|
|
117
|
+
publish();
|
|
118
|
+
},
|
|
119
|
+
finish(outcome) {
|
|
120
|
+
if (status === "completed" || status === "failed")
|
|
121
|
+
return;
|
|
122
|
+
if ("error" in outcome) {
|
|
123
|
+
status = "failed";
|
|
124
|
+
error = outcome.error;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
status = "completed";
|
|
128
|
+
state = formatState(outcome.state) ?? state;
|
|
129
|
+
}
|
|
130
|
+
stateSince = undefined;
|
|
131
|
+
agent = undefined;
|
|
132
|
+
limit = undefined;
|
|
133
|
+
human = undefined;
|
|
134
|
+
publish();
|
|
135
|
+
try {
|
|
136
|
+
// Nothing reads the inbox from here on, and only the run empties it.
|
|
137
|
+
rmSync(runInboxPath(directory, id), { force: true });
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Left for pruning.
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
|
|
146
|
+
export function readRunStatuses(directory) {
|
|
147
|
+
return readRecordFiles(directory).map(({ record }) => record);
|
|
148
|
+
}
|
|
149
|
+
function pruneRunStatuses(directory) {
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
for (const { file, record } of readRecordFiles(directory)) {
|
|
152
|
+
const settled = record.status === "completed" || record.status === "failed" || !isProcessAlive(record.pid);
|
|
153
|
+
if (!settled || now - Date.parse(record.updatedAt) <= staleAfterMilliseconds)
|
|
154
|
+
continue;
|
|
155
|
+
try {
|
|
156
|
+
rmSync(file, { force: true });
|
|
157
|
+
// A response nobody collected goes with the run it was meant for, under each of its names.
|
|
158
|
+
const id = basename(file, ".json");
|
|
159
|
+
rmSync(runInboxPath(directory, id), { force: true });
|
|
160
|
+
for (const name of readdirSync(directory)) {
|
|
161
|
+
if (!name.startsWith(`.${id}.`))
|
|
162
|
+
continue;
|
|
163
|
+
if (/^[^.]+\.inbox\.(?:sent|taken)$/u.test(name.slice(id.length + 2)))
|
|
164
|
+
rmSync(join(directory, name), { force: true });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Another publisher may have pruned it first.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function readRecordFiles(directory) {
|
|
173
|
+
let names;
|
|
174
|
+
try {
|
|
175
|
+
names = readdirSync(directory);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
const found = [];
|
|
181
|
+
for (const name of names) {
|
|
182
|
+
if (name.startsWith(".") || !name.endsWith(".json"))
|
|
183
|
+
continue;
|
|
184
|
+
const file = join(directory, name);
|
|
185
|
+
try {
|
|
186
|
+
const record = JSON.parse(readFileSync(file, "utf8"));
|
|
187
|
+
if (isRunStatusRecord(record))
|
|
188
|
+
found.push({ file, record });
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// Not a record, or replaced while it was being read.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return found;
|
|
195
|
+
}
|
|
196
|
+
function isRunStatusRecord(value) {
|
|
197
|
+
if (value === null || typeof value !== "object")
|
|
198
|
+
return false;
|
|
199
|
+
const record = value;
|
|
200
|
+
const agent = record.agent;
|
|
201
|
+
const human = record.human;
|
|
202
|
+
const limit = record.limit;
|
|
203
|
+
return (record.schemaVersion === 1 || record.schemaVersion === 2)
|
|
204
|
+
&& typeof record.id === "string" && record.id !== ""
|
|
205
|
+
&& Number.isSafeInteger(record.pid) && record.pid > 0
|
|
206
|
+
&& isOptionalString(record.owner)
|
|
207
|
+
&& isOptionalString(record.parent)
|
|
208
|
+
&& isOptionalString(record.label)
|
|
209
|
+
&& (record.stateSince === undefined || isTimestamp(record.stateSince))
|
|
210
|
+
&& typeof record.machine === "string"
|
|
211
|
+
&& typeof record.path === "string"
|
|
212
|
+
&& typeof record.cwd === "string"
|
|
213
|
+
&& (record.status === "running" || record.status === "waiting"
|
|
214
|
+
|| record.status === "completed" || record.status === "failed")
|
|
215
|
+
&& isOptionalString(record.state)
|
|
216
|
+
&& (agent === undefined || (agent !== null && typeof agent === "object" && typeof agent.harness === "string"
|
|
217
|
+
&& isOptionalString(agent.model) && isOptionalString(agent.thinking)))
|
|
218
|
+
&& (human === undefined || (human !== null && typeof human === "object" && typeof human.prompt === "string"
|
|
219
|
+
&& isOptionalString(human.requestId) && isOptionalStrings(human.choices) && isOptionalStrings(human.suggestions)
|
|
220
|
+
&& (human.discussion === undefined || typeof human.discussion === "boolean")))
|
|
221
|
+
&& (limit === undefined || (limit !== null && typeof limit === "object" && typeof limit.text === "string"
|
|
222
|
+
&& isOptionalString(limit.until)))
|
|
223
|
+
&& isOptionalStrings(record.deliveries)
|
|
224
|
+
&& isOptionalString(record.error)
|
|
225
|
+
&& isTimestamp(record.startedAt)
|
|
226
|
+
&& isTimestamp(record.updatedAt);
|
|
227
|
+
}
|
|
228
|
+
function isOptionalString(value) {
|
|
229
|
+
return value === undefined || typeof value === "string";
|
|
230
|
+
}
|
|
231
|
+
function isOptionalStrings(value) {
|
|
232
|
+
return value === undefined || (Array.isArray(value) && value.every((item) => typeof item === "string"));
|
|
233
|
+
}
|
|
234
|
+
function isTimestamp(value) {
|
|
235
|
+
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
236
|
+
}
|
|
237
|
+
/** The id becomes a file name, so it must not be able to leave the directory. */
|
|
238
|
+
export function isFileSafeId(id) {
|
|
239
|
+
return /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/u.test(id);
|
|
240
|
+
}
|
|
241
|
+
export function isProcessAlive(pid) {
|
|
242
|
+
try {
|
|
243
|
+
process.kill(pid, 0);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
catch (cause) {
|
|
247
|
+
// EPERM means the process exists but belongs to someone else.
|
|
248
|
+
return cause.code === "EPERM";
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function formatState(state) {
|
|
252
|
+
if (state === undefined)
|
|
253
|
+
return undefined;
|
|
254
|
+
return typeof state === "string" ? state : JSON.stringify(state);
|
|
255
|
+
}
|
|
256
|
+
/** One short line: a label is a caption, not a place for task text. */
|
|
257
|
+
function formatLabel(label) {
|
|
258
|
+
const line = label?.split(/\r?\n/u).map((part) => part.trim()).find((part) => part !== "");
|
|
259
|
+
return line === undefined ? undefined : line.slice(0, 160);
|
|
260
|
+
}
|
|
261
|
+
function pickAgent(agent) {
|
|
262
|
+
if (agent === undefined)
|
|
263
|
+
return undefined;
|
|
264
|
+
return {
|
|
265
|
+
harness: agent.harness,
|
|
266
|
+
...(agent.model === undefined ? {} : { model: agent.model }),
|
|
267
|
+
...(agent.thinking === undefined ? {} : { thinking: agent.thinking }),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function pickLimit(limit) {
|
|
271
|
+
if (limit === undefined)
|
|
272
|
+
return undefined;
|
|
273
|
+
return { text: limit.text, ...(limit.until === undefined ? {} : { until: limit.until }) };
|
|
274
|
+
}
|
|
275
|
+
function pickHuman(human) {
|
|
276
|
+
if (human === undefined)
|
|
277
|
+
return undefined;
|
|
278
|
+
return {
|
|
279
|
+
prompt: human.prompt,
|
|
280
|
+
...(human.requestId === undefined ? {} : { requestId: human.requestId }),
|
|
281
|
+
...(human.choices === undefined ? {} : { choices: [...human.choices] }),
|
|
282
|
+
...(human.suggestions === undefined ? {} : { suggestions: [...human.suggestions] }),
|
|
283
|
+
...(human.discussion === true ? { discussion: true } : {}),
|
|
284
|
+
};
|
|
285
|
+
}
|