@dna113p/machines 0.2.0 → 0.3.1
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 +115 -11
- package/claude/statusline.mjs +277 -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 +74 -11
- package/dist/pi-extension/index.js +16 -9
- package/dist/src/acp.js +2 -1
- 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.js +3 -1
- package/dist/src/catalog.d.ts +3 -0
- package/dist/src/catalog.js +84 -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 +282 -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 +11 -0
- package/dist/src/host.js +153 -16
- 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 +233 -0
- package/dist/src/run-status.d.ts +121 -0
- package/dist/src/run-status.js +336 -0
- package/dist/src/run-watch.d.ts +24 -0
- package/dist/src/run-watch.js +112 -0
- package/dist/src/session.d.ts +15 -2
- package/dist/src/session.js +47 -8
- 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 +74 -3
- package/docs/authoring.md +809 -1
- package/docs/integrations.md +322 -3
- package/docs/local-decisions.md +212 -0
- package/package.json +21 -4
- package/services/laya/server.py +230 -0
- package/skills/machine-builder/SKILL.md +56 -6
- package/skills/machine-delegation/SKILL.md +128 -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,336 @@
|
|
|
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 = (run.owner ?? runSessionId(environment)) || undefined;
|
|
36
|
+
const parent = environment.MACHINES_RUN_PARENT || undefined;
|
|
37
|
+
const label = formatLabel(run.label ?? 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
|
+
...(run.answeredBy === undefined ? {} : { answeredBy: run.answeredBy }),
|
|
65
|
+
...(deliveries.length === 0 ? {} : { deliveries: deliveries.map((delivery) => delivery.id) }),
|
|
66
|
+
...(error === undefined ? {} : { error }),
|
|
67
|
+
startedAt,
|
|
68
|
+
updatedAt: new Date().toISOString(),
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
72
|
+
// Readers only ever see a whole record: write beside it, then rename over it.
|
|
73
|
+
const temporary = join(directory, `.${id}.${process.pid}.${temporaryFiles += 1}.tmp`);
|
|
74
|
+
try {
|
|
75
|
+
writeFileSync(temporary, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
76
|
+
renameSync(temporary, join(directory, `${id}.json`));
|
|
77
|
+
}
|
|
78
|
+
catch (cause) {
|
|
79
|
+
rmSync(temporary, { force: true });
|
|
80
|
+
throw cause;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// A status line is never worth a run: an unwritable directory is ignored.
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
pruneRunStatuses(directory);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Pruning is best effort for the same reason.
|
|
92
|
+
}
|
|
93
|
+
publish();
|
|
94
|
+
return {
|
|
95
|
+
update(change) {
|
|
96
|
+
if (status === "completed" || status === "failed")
|
|
97
|
+
return;
|
|
98
|
+
const nextState = "state" in change ? formatState(change.state) : state;
|
|
99
|
+
const next = {
|
|
100
|
+
status: change.status ?? status,
|
|
101
|
+
state: nextState,
|
|
102
|
+
agent: "agent" in change ? pickAgent(change.agent) : agent,
|
|
103
|
+
// A wait belongs to the state whose Agent is waiting.
|
|
104
|
+
limit: "limit" in change ? pickLimit(change.limit) : nextState === state ? limit : undefined,
|
|
105
|
+
human: "human" in change ? pickHuman(change.human) : human,
|
|
106
|
+
// A later response must not hide an earlier one from a sender that has yet to look.
|
|
107
|
+
deliveries: change.delivery === undefined ? deliveries : [
|
|
108
|
+
...deliveries.filter((delivery) => Date.now() - delivery.takenAt < deliveryRetentionMilliseconds),
|
|
109
|
+
{ id: change.delivery, takenAt: Date.now() },
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
// Repeated notifications for an unchanged run do not need another write.
|
|
113
|
+
if (JSON.stringify(next) === JSON.stringify({ status, state, agent, limit, human, deliveries }))
|
|
114
|
+
return;
|
|
115
|
+
if (next.state !== state)
|
|
116
|
+
stateSince = new Date().toISOString();
|
|
117
|
+
({ status, state, agent, limit, human, deliveries } = next);
|
|
118
|
+
publish();
|
|
119
|
+
},
|
|
120
|
+
finish(outcome) {
|
|
121
|
+
if (status === "completed" || status === "failed")
|
|
122
|
+
return;
|
|
123
|
+
if ("error" in outcome) {
|
|
124
|
+
status = "failed";
|
|
125
|
+
error = outcome.error;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
status = "completed";
|
|
129
|
+
state = formatState(outcome.state) ?? state;
|
|
130
|
+
}
|
|
131
|
+
stateSince = undefined;
|
|
132
|
+
agent = undefined;
|
|
133
|
+
limit = undefined;
|
|
134
|
+
human = undefined;
|
|
135
|
+
publish();
|
|
136
|
+
try {
|
|
137
|
+
// Nothing reads the inbox from here on, and only the run empties it.
|
|
138
|
+
rmSync(runInboxPath(directory, id), { force: true });
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Left for pruning.
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Turns what a run reports as it executes into the changes to its record, for
|
|
148
|
+
* `machine run` and for a hosted run alike.
|
|
149
|
+
*/
|
|
150
|
+
export function runStatusObserver(update) {
|
|
151
|
+
let publishedState;
|
|
152
|
+
// A runner reports its identity as it starts. For a state entered by a
|
|
153
|
+
// transition that is before the state is announced, so an identity is held
|
|
154
|
+
// for the rest of the tick and published with the state it belongs to.
|
|
155
|
+
let arriving;
|
|
156
|
+
return {
|
|
157
|
+
onAgentUpdate(agent) {
|
|
158
|
+
if (agent.type === "identity") {
|
|
159
|
+
arriving = agent;
|
|
160
|
+
queueMicrotask(() => {
|
|
161
|
+
if (arriving !== agent)
|
|
162
|
+
return;
|
|
163
|
+
arriving = undefined;
|
|
164
|
+
update({ agent });
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
else if (agent.type === "limit" && agent.status !== "swapped") {
|
|
168
|
+
// A swap while every preset is being tried again leaves the wait as it is published.
|
|
169
|
+
update({
|
|
170
|
+
limit: agent.status === "resumed"
|
|
171
|
+
? undefined
|
|
172
|
+
: { text: agent.text, ...(agent.until === undefined ? {} : { until: agent.until }) },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
onState(state) {
|
|
177
|
+
const entered = formatState(state);
|
|
178
|
+
if (entered === publishedState)
|
|
179
|
+
update({ state });
|
|
180
|
+
else {
|
|
181
|
+
update({ state, agent: arriving });
|
|
182
|
+
arriving = undefined;
|
|
183
|
+
}
|
|
184
|
+
publishedState = entered;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The session a process belongs to: the owner of the runs it starts, and the
|
|
190
|
+
* session whose watch list `machine watch` changes.
|
|
191
|
+
*/
|
|
192
|
+
export function runSessionId(environment = process.env) {
|
|
193
|
+
return environment.MACHINES_RUN_OWNER || environment.CLAUDE_CODE_SESSION_ID || undefined;
|
|
194
|
+
}
|
|
195
|
+
/** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
|
|
196
|
+
export function readRunStatuses(directory) {
|
|
197
|
+
return readRecordFiles(directory).map(({ record }) => record);
|
|
198
|
+
}
|
|
199
|
+
function pruneRunStatuses(directory) {
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
for (const { file, record } of readRecordFiles(directory)) {
|
|
202
|
+
const settled = record.status === "completed" || record.status === "failed" || !isProcessAlive(record.pid);
|
|
203
|
+
if (!settled || now - Date.parse(record.updatedAt) <= staleAfterMilliseconds)
|
|
204
|
+
continue;
|
|
205
|
+
try {
|
|
206
|
+
rmSync(file, { force: true });
|
|
207
|
+
// A response nobody collected goes with the run it was meant for, under each of its names.
|
|
208
|
+
const id = basename(file, ".json");
|
|
209
|
+
rmSync(runInboxPath(directory, id), { force: true });
|
|
210
|
+
for (const name of readdirSync(directory)) {
|
|
211
|
+
if (!name.startsWith(`.${id}.`))
|
|
212
|
+
continue;
|
|
213
|
+
if (/^[^.]+\.inbox\.(?:sent|taken)$/u.test(name.slice(id.length + 2)))
|
|
214
|
+
rmSync(join(directory, name), { force: true });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Another publisher may have pruned it first.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function readRecordFiles(directory) {
|
|
223
|
+
let names;
|
|
224
|
+
try {
|
|
225
|
+
names = readdirSync(directory);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
const found = [];
|
|
231
|
+
for (const name of names) {
|
|
232
|
+
if (name.startsWith(".") || !name.endsWith(".json"))
|
|
233
|
+
continue;
|
|
234
|
+
const file = join(directory, name);
|
|
235
|
+
try {
|
|
236
|
+
const record = JSON.parse(readFileSync(file, "utf8"));
|
|
237
|
+
if (isRunStatusRecord(record))
|
|
238
|
+
found.push({ file, record });
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// Not a record, or replaced while it was being read.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return found;
|
|
245
|
+
}
|
|
246
|
+
function isRunStatusRecord(value) {
|
|
247
|
+
if (value === null || typeof value !== "object")
|
|
248
|
+
return false;
|
|
249
|
+
const record = value;
|
|
250
|
+
const agent = record.agent;
|
|
251
|
+
const human = record.human;
|
|
252
|
+
const limit = record.limit;
|
|
253
|
+
return (record.schemaVersion === 1 || record.schemaVersion === 2)
|
|
254
|
+
&& typeof record.id === "string" && record.id !== ""
|
|
255
|
+
&& Number.isSafeInteger(record.pid) && record.pid > 0
|
|
256
|
+
&& isOptionalString(record.owner)
|
|
257
|
+
&& isOptionalString(record.parent)
|
|
258
|
+
&& isOptionalString(record.label)
|
|
259
|
+
&& (record.stateSince === undefined || isTimestamp(record.stateSince))
|
|
260
|
+
&& typeof record.machine === "string"
|
|
261
|
+
&& typeof record.path === "string"
|
|
262
|
+
&& typeof record.cwd === "string"
|
|
263
|
+
&& (record.status === "running" || record.status === "waiting"
|
|
264
|
+
|| record.status === "completed" || record.status === "failed")
|
|
265
|
+
&& isOptionalString(record.state)
|
|
266
|
+
&& (agent === undefined || (agent !== null && typeof agent === "object" && typeof agent.harness === "string"
|
|
267
|
+
&& isOptionalString(agent.model) && isOptionalString(agent.thinking)))
|
|
268
|
+
&& (human === undefined || (human !== null && typeof human === "object" && typeof human.prompt === "string"
|
|
269
|
+
&& isOptionalString(human.requestId) && isOptionalStrings(human.choices) && isOptionalStrings(human.suggestions)
|
|
270
|
+
&& (human.discussion === undefined || typeof human.discussion === "boolean")))
|
|
271
|
+
&& (limit === undefined || (limit !== null && typeof limit === "object" && typeof limit.text === "string"
|
|
272
|
+
&& isOptionalString(limit.until)))
|
|
273
|
+
&& (record.answeredBy === undefined || record.answeredBy === "host")
|
|
274
|
+
&& isOptionalStrings(record.deliveries)
|
|
275
|
+
&& isOptionalString(record.error)
|
|
276
|
+
&& isTimestamp(record.startedAt)
|
|
277
|
+
&& isTimestamp(record.updatedAt);
|
|
278
|
+
}
|
|
279
|
+
function isOptionalString(value) {
|
|
280
|
+
return value === undefined || typeof value === "string";
|
|
281
|
+
}
|
|
282
|
+
function isOptionalStrings(value) {
|
|
283
|
+
return value === undefined || (Array.isArray(value) && value.every((item) => typeof item === "string"));
|
|
284
|
+
}
|
|
285
|
+
function isTimestamp(value) {
|
|
286
|
+
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
287
|
+
}
|
|
288
|
+
/** The id becomes a file name, so it must not be able to leave the directory. */
|
|
289
|
+
export function isFileSafeId(id) {
|
|
290
|
+
return /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/u.test(id);
|
|
291
|
+
}
|
|
292
|
+
export function isProcessAlive(pid) {
|
|
293
|
+
try {
|
|
294
|
+
process.kill(pid, 0);
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
catch (cause) {
|
|
298
|
+
// EPERM means the process exists but belongs to someone else.
|
|
299
|
+
return cause.code === "EPERM";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function formatState(state) {
|
|
303
|
+
if (state === undefined)
|
|
304
|
+
return undefined;
|
|
305
|
+
return typeof state === "string" ? state : JSON.stringify(state);
|
|
306
|
+
}
|
|
307
|
+
/** One short line: a label is a caption, not a place for task text. */
|
|
308
|
+
function formatLabel(label) {
|
|
309
|
+
const line = label?.split(/\r?\n/u).map((part) => part.trim()).find((part) => part !== "");
|
|
310
|
+
return line === undefined ? undefined : line.slice(0, 160);
|
|
311
|
+
}
|
|
312
|
+
function pickAgent(agent) {
|
|
313
|
+
if (agent === undefined)
|
|
314
|
+
return undefined;
|
|
315
|
+
return {
|
|
316
|
+
harness: agent.harness,
|
|
317
|
+
...(agent.model === undefined ? {} : { model: agent.model }),
|
|
318
|
+
...(agent.thinking === undefined ? {} : { thinking: agent.thinking }),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function pickLimit(limit) {
|
|
322
|
+
if (limit === undefined)
|
|
323
|
+
return undefined;
|
|
324
|
+
return { text: limit.text, ...(limit.until === undefined ? {} : { until: limit.until }) };
|
|
325
|
+
}
|
|
326
|
+
function pickHuman(human) {
|
|
327
|
+
if (human === undefined)
|
|
328
|
+
return undefined;
|
|
329
|
+
return {
|
|
330
|
+
prompt: human.prompt,
|
|
331
|
+
...(human.requestId === undefined ? {} : { requestId: human.requestId }),
|
|
332
|
+
...(human.choices === undefined ? {} : { choices: [...human.choices] }),
|
|
333
|
+
...(human.suggestions === undefined ? {} : { suggestions: [...human.suggestions] }),
|
|
334
|
+
...(human.discussion === true ? { discussion: true } : {}),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type RunStatusRecord } from "./run-status.ts";
|
|
2
|
+
/**
|
|
3
|
+
* One thing a session asked to see on its status line besides its own runs:
|
|
4
|
+
* runs named by the start of their id or label, or runs working in a directory.
|
|
5
|
+
*/
|
|
6
|
+
export type RunWatch = {
|
|
7
|
+
readonly text: string;
|
|
8
|
+
} | {
|
|
9
|
+
readonly dir: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* A session's watch list, kept beside the records under a name their readers
|
|
13
|
+
* skip. `claude/statusline.mjs` reads the same file without loading this module.
|
|
14
|
+
*/
|
|
15
|
+
export declare function runWatchPath(directory: string, sessionId: string): string;
|
|
16
|
+
/** What a session watches. A missing, unreadable, or malformed list watches nothing. */
|
|
17
|
+
export declare function readRunWatches(directory: string, sessionId: string): RunWatch[];
|
|
18
|
+
/** Replaces a session's watch list; an empty one removes its file. */
|
|
19
|
+
export declare function writeRunWatches(directory: string, sessionId: string, watches: readonly RunWatch[]): void;
|
|
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;
|
|
22
|
+
/** Checks and normalizes what `machine watch` was given. */
|
|
23
|
+
export declare function runWatch(input: RunWatch): RunWatch;
|
|
24
|
+
export declare function sameRunWatch(left: RunWatch, right: RunWatch): boolean;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
3
|
+
import { isFileSafeId } from "./run-status.js";
|
|
4
|
+
const forgottenAfterMilliseconds = 30 * 24 * 60 * 60 * 1_000;
|
|
5
|
+
const watchFile = /^\.watch\..+\.json$/u;
|
|
6
|
+
let temporaryFiles = 0;
|
|
7
|
+
/**
|
|
8
|
+
* A session's watch list, kept beside the records under a name their readers
|
|
9
|
+
* skip. `claude/statusline.mjs` reads the same file without loading this module.
|
|
10
|
+
*/
|
|
11
|
+
export function runWatchPath(directory, sessionId) {
|
|
12
|
+
if (!isFileSafeId(sessionId)) {
|
|
13
|
+
throw new Error(`Session id "${sessionId}" cannot name a watch list: use only letters, digits, ".", "_", and "-"`);
|
|
14
|
+
}
|
|
15
|
+
return join(directory, `.watch.${sessionId}.json`);
|
|
16
|
+
}
|
|
17
|
+
/** What a session watches. A missing, unreadable, or malformed list watches nothing. */
|
|
18
|
+
export function readRunWatches(directory, sessionId) {
|
|
19
|
+
try {
|
|
20
|
+
const list = JSON.parse(readFileSync(runWatchPath(directory, sessionId), "utf8"));
|
|
21
|
+
const watches = list?.watches;
|
|
22
|
+
return Array.isArray(watches) ? watches.flatMap((watch) => pickWatch(watch) ?? []) : [];
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Replaces a session's watch list; an empty one removes its file. */
|
|
29
|
+
export function writeRunWatches(directory, sessionId, watches) {
|
|
30
|
+
const path = runWatchPath(directory, sessionId);
|
|
31
|
+
if (watches.length === 0) {
|
|
32
|
+
rmSync(path, { force: true });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
36
|
+
// Readers only ever see a whole list: write beside it, then rename over it.
|
|
37
|
+
const temporary = join(directory, `.watch.${sessionId}.${process.pid}.${temporaryFiles += 1}.tmp`);
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(temporary, `${JSON.stringify({ schemaVersion: 1, watches })}\n`, { mode: 0o600 });
|
|
40
|
+
renameSync(temporary, path);
|
|
41
|
+
}
|
|
42
|
+
catch (cause) {
|
|
43
|
+
rmSync(temporary, { force: true });
|
|
44
|
+
throw cause;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
pruneRunWatches(directory, path);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Pruning is best effort.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Whether a watch names a run. A sub-run is never watched: it is its parent's detail. */
|
|
54
|
+
export function runWatchMatches(watch, record) {
|
|
55
|
+
if (record.parent !== undefined)
|
|
56
|
+
return false;
|
|
57
|
+
if ("dir" in watch) {
|
|
58
|
+
if (!isAbsolute(record.cwd))
|
|
59
|
+
return false;
|
|
60
|
+
const cwd = resolve(record.cwd);
|
|
61
|
+
return cwd === watch.dir || cwd.startsWith(watch.dir.endsWith(sep) ? watch.dir : `${watch.dir}${sep}`);
|
|
62
|
+
}
|
|
63
|
+
if (record.id.startsWith(watch.text))
|
|
64
|
+
return true;
|
|
65
|
+
// The label must start with the whole text, not with a longer word that begins like it.
|
|
66
|
+
const next = record.label?.startsWith(watch.text) ? record.label.slice(watch.text.length) : undefined;
|
|
67
|
+
return next !== undefined && !/^[\p{L}\p{N}_-]/u.test(next);
|
|
68
|
+
}
|
|
69
|
+
/** Checks and normalizes what `machine watch` was given. */
|
|
70
|
+
export function runWatch(input) {
|
|
71
|
+
if ("dir" in input) {
|
|
72
|
+
if (input.dir === "")
|
|
73
|
+
throw new Error("Expected a directory after --dir");
|
|
74
|
+
return { dir: resolve(input.dir) };
|
|
75
|
+
}
|
|
76
|
+
const text = input.text.trim();
|
|
77
|
+
if (text === "")
|
|
78
|
+
throw new Error("Expected the start of a run id or label to watch");
|
|
79
|
+
// Checked before the ends are trimmed: a line break there is still a second line.
|
|
80
|
+
if (/[\r\n]/u.test(input.text))
|
|
81
|
+
throw new Error("A watch is one line: the start of a run id or label");
|
|
82
|
+
return { text };
|
|
83
|
+
}
|
|
84
|
+
export function sameRunWatch(left, right) {
|
|
85
|
+
return "dir" in left ? "dir" in right && left.dir === right.dir : "text" in right && left.text === right.text;
|
|
86
|
+
}
|
|
87
|
+
function pickWatch(value) {
|
|
88
|
+
if (value === null || typeof value !== "object")
|
|
89
|
+
return undefined;
|
|
90
|
+
const { text, dir } = value;
|
|
91
|
+
if (typeof text === "string" && text !== "" && dir === undefined)
|
|
92
|
+
return { text };
|
|
93
|
+
if (typeof dir === "string" && isAbsolute(dir) && text === undefined)
|
|
94
|
+
return { dir: resolve(dir) };
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
/** Sessions end without saying so; a list nobody has changed for a month belongs to one that did. */
|
|
98
|
+
function pruneRunWatches(directory, kept) {
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
for (const name of readdirSync(directory)) {
|
|
101
|
+
const file = join(directory, name);
|
|
102
|
+
if (!watchFile.test(name) || file === kept)
|
|
103
|
+
continue;
|
|
104
|
+
try {
|
|
105
|
+
if (now - statSync(file).mtimeMs > forgottenAfterMilliseconds)
|
|
106
|
+
rmSync(file, { force: true });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Another session may have pruned it first.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
package/dist/src/session.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { StateValue } from "xstate";
|
|
2
|
-
import {
|
|
2
|
+
import type { JsonValue } from "./json.ts";
|
|
3
|
+
import { type HostedHumanRequest, type MachineHostOptions } from "./host.ts";
|
|
3
4
|
import type { AgentUpdate } from "./index.ts";
|
|
4
5
|
import type { PrepareMachineRunOptions } from "./launcher.ts";
|
|
5
6
|
export type RunStatus = "running" | "waiting" | "completed" | "failed";
|
|
@@ -11,12 +12,18 @@ export interface RunSnapshot {
|
|
|
11
12
|
readonly cwd: string;
|
|
12
13
|
readonly status: RunStatus;
|
|
13
14
|
readonly state?: StateValue;
|
|
15
|
+
readonly output?: JsonValue;
|
|
14
16
|
readonly startedAt: string;
|
|
15
17
|
readonly updatedAt: string;
|
|
16
18
|
readonly elapsedSeconds: number;
|
|
17
19
|
readonly agent?: Extract<AgentUpdate, {
|
|
18
20
|
type: "identity";
|
|
19
21
|
}>;
|
|
22
|
+
/** Present while the current Agent state waits for a usage limit to reset; `status` stays `running`. */
|
|
23
|
+
readonly limit?: {
|
|
24
|
+
readonly text: string;
|
|
25
|
+
readonly until?: string;
|
|
26
|
+
};
|
|
20
27
|
readonly human?: HostedHumanRequest;
|
|
21
28
|
readonly error?: string;
|
|
22
29
|
}
|
|
@@ -28,13 +35,19 @@ export interface SessionEvent {
|
|
|
28
35
|
export declare class MachineSession {
|
|
29
36
|
#private;
|
|
30
37
|
subscribe(listener: (event: SessionEvent) => void): () => void;
|
|
31
|
-
start(options: PrepareMachineRunOptions): Promise<RunSnapshot>;
|
|
38
|
+
start(options: PrepareMachineRunOptions & Pick<MachineHostOptions, "label" | "owner" | "publishRunStatus">): Promise<RunSnapshot>;
|
|
32
39
|
status(runId?: string): RunSnapshot[];
|
|
33
40
|
respond(input: {
|
|
34
41
|
readonly runId: string;
|
|
35
42
|
readonly requestId: string;
|
|
36
43
|
readonly response: string;
|
|
37
44
|
}): Promise<RunSnapshot>;
|
|
45
|
+
/** Sends a discussion question in place of an answer; the Machine then asks again as a new request. */
|
|
46
|
+
ask(input: {
|
|
47
|
+
readonly runId: string;
|
|
48
|
+
readonly requestId: string;
|
|
49
|
+
readonly question: string;
|
|
50
|
+
}): Promise<RunSnapshot>;
|
|
38
51
|
close(): void;
|
|
39
52
|
}
|
|
40
53
|
export declare function isActive(status: RunStatus): boolean;
|
package/dist/src/session.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import { startMachineHost } from "./host.js";
|
|
3
2
|
/** Owns runs from startup to disposal. Presentation belongs to its subscribers. */
|
|
4
3
|
export class MachineSession {
|
|
@@ -15,10 +14,11 @@ export class MachineSession {
|
|
|
15
14
|
throw new Error("Machine session is closed");
|
|
16
15
|
const cancellation = new AbortController();
|
|
17
16
|
this.#starting.add(cancellation);
|
|
18
|
-
const id = randomUUID();
|
|
19
17
|
const startedAt = Date.now();
|
|
18
|
+
// The run is known by its host's id, which is also the id of its published run status.
|
|
19
|
+
let started;
|
|
20
20
|
const update = (type, change) => {
|
|
21
|
-
const run = this.#runs.get(id);
|
|
21
|
+
const run = started === undefined ? undefined : this.#runs.get(started.id);
|
|
22
22
|
if (run === undefined || !isActive(run.status))
|
|
23
23
|
return;
|
|
24
24
|
change(run);
|
|
@@ -31,13 +31,23 @@ export class MachineSession {
|
|
|
31
31
|
...options,
|
|
32
32
|
signal: cancellation.signal,
|
|
33
33
|
onState: (state) => update("updated", (run) => {
|
|
34
|
-
if (JSON.stringify(run.state) !== JSON.stringify(state))
|
|
34
|
+
if (JSON.stringify(run.state) !== JSON.stringify(state)) {
|
|
35
35
|
run.agent = undefined;
|
|
36
|
+
run.limit = undefined;
|
|
37
|
+
}
|
|
36
38
|
run.state = state;
|
|
37
39
|
}),
|
|
38
40
|
onAgentUpdate: (agent) => {
|
|
39
41
|
if (agent.type === "identity")
|
|
40
42
|
update("updated", (run) => { run.agent = agent; });
|
|
43
|
+
// A swap while every preset is being tried again leaves the wait as it is shown.
|
|
44
|
+
else if (agent.type === "limit" && agent.status !== "swapped") {
|
|
45
|
+
update("updated", (run) => {
|
|
46
|
+
run.limit = agent.status === "resumed"
|
|
47
|
+
? undefined
|
|
48
|
+
: { text: agent.text, ...(agent.until === undefined ? {} : { until: agent.until }) };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
41
51
|
},
|
|
42
52
|
onHumanRequest: (request) => update("human", (run) => {
|
|
43
53
|
run.status = "waiting";
|
|
@@ -53,15 +63,16 @@ export class MachineSession {
|
|
|
53
63
|
throw new Error("Machine session is closed");
|
|
54
64
|
}
|
|
55
65
|
const run = {
|
|
56
|
-
id,
|
|
66
|
+
id: host.id,
|
|
57
67
|
cwd: options.cwd ?? process.cwd(),
|
|
58
68
|
host,
|
|
59
69
|
startedAt,
|
|
60
70
|
updatedAt: Date.now(),
|
|
61
71
|
status: "running",
|
|
62
72
|
};
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
started = run;
|
|
74
|
+
this.#runs.set(run.id, run);
|
|
75
|
+
void host.result.then((result) => this.#finish(run, result), (cause) => this.#finish(run, { error: errorMessage(cause) }));
|
|
65
76
|
this.#emit("updated", run);
|
|
66
77
|
return snapshot(run);
|
|
67
78
|
}
|
|
@@ -92,6 +103,29 @@ export class MachineSession {
|
|
|
92
103
|
await sent;
|
|
93
104
|
return snapshot(run);
|
|
94
105
|
}
|
|
106
|
+
/** Sends a discussion question in place of an answer; the Machine then asks again as a new request. */
|
|
107
|
+
async ask(input) {
|
|
108
|
+
const run = this.#requireRun(input.runId);
|
|
109
|
+
if (run.status !== "waiting" || run.human === undefined) {
|
|
110
|
+
throw new Error(`Machine run "${input.runId}" is not waiting for Human input`);
|
|
111
|
+
}
|
|
112
|
+
if (run.human.requestId !== input.requestId) {
|
|
113
|
+
throw new Error("Machine Human request is stale; read the current requestId");
|
|
114
|
+
}
|
|
115
|
+
if (run.human.discussion !== true)
|
|
116
|
+
throw new Error("This Human request does not support discussion");
|
|
117
|
+
if (input.question.trim() === "" || input.question.length > 8000) {
|
|
118
|
+
throw new Error("Question must contain 1–8000 characters");
|
|
119
|
+
}
|
|
120
|
+
// Claimed as a response is: the request is spent once its question is on the way.
|
|
121
|
+
run.status = "running";
|
|
122
|
+
run.human = undefined;
|
|
123
|
+
run.updatedAt = Date.now();
|
|
124
|
+
const sent = run.host.ask(input.question, input.requestId);
|
|
125
|
+
this.#emit("updated", run);
|
|
126
|
+
await sent;
|
|
127
|
+
return snapshot(run);
|
|
128
|
+
}
|
|
95
129
|
close() {
|
|
96
130
|
if (this.#closed)
|
|
97
131
|
return;
|
|
@@ -114,12 +148,15 @@ export class MachineSession {
|
|
|
114
148
|
if (this.#closed || !isActive(run.status))
|
|
115
149
|
return;
|
|
116
150
|
run.status = "state" in outcome ? "completed" : "failed";
|
|
117
|
-
if ("state" in outcome)
|
|
151
|
+
if ("state" in outcome) {
|
|
118
152
|
run.state = outcome.state;
|
|
153
|
+
run.output = outcome.output;
|
|
154
|
+
}
|
|
119
155
|
else
|
|
120
156
|
run.error = outcome.error;
|
|
121
157
|
run.human = undefined;
|
|
122
158
|
run.agent = undefined;
|
|
159
|
+
run.limit = undefined;
|
|
123
160
|
run.updatedAt = Date.now();
|
|
124
161
|
const terminal = [...this.#runs.values()]
|
|
125
162
|
.filter((item) => !isActive(item.status))
|
|
@@ -152,10 +189,12 @@ function snapshot(run) {
|
|
|
152
189
|
cwd: run.cwd,
|
|
153
190
|
status: run.status,
|
|
154
191
|
...(run.state === undefined ? {} : { state: structuredClone(run.state) }),
|
|
192
|
+
...(run.output === undefined ? {} : { output: structuredClone(run.output) }),
|
|
155
193
|
startedAt: new Date(run.startedAt).toISOString(),
|
|
156
194
|
updatedAt: new Date(run.updatedAt).toISOString(),
|
|
157
195
|
elapsedSeconds: Math.floor(((isActive(run.status) ? Date.now() : run.updatedAt) - run.startedAt) / 1_000),
|
|
158
196
|
...(run.agent === undefined ? {} : { agent: { ...run.agent } }),
|
|
197
|
+
...(run.limit === undefined ? {} : { limit: { ...run.limit } }),
|
|
159
198
|
...(run.human === undefined ? {} : { human: structuredClone(run.human) }),
|
|
160
199
|
...(run.error === undefined ? {} : { error: run.error }),
|
|
161
200
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { DecisionProvider } from "./decision.ts";
|
|
2
|
+
/** Shared transport options for self-hosted System One Choice services. */
|
|
3
|
+
export interface SystemOneHttpOptions {
|
|
4
|
+
/** Server root (optionally including a proxy prefix), without /v1/systemone. */
|
|
5
|
+
readonly baseUrl?: string;
|
|
6
|
+
/** Optional Bearer token. Only the selected provider's environment is read. */
|
|
7
|
+
readonly apiKey?: string;
|
|
8
|
+
/** Wire model identifier; checkpoint/backend selection belongs to the server. */
|
|
9
|
+
readonly model?: string;
|
|
10
|
+
/** Defaults to 30 seconds, including reading the response body. */
|
|
11
|
+
readonly timeoutMs?: number;
|
|
12
|
+
/** Injected clients must honor the AbortSignal to release network resources. */
|
|
13
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
14
|
+
}
|
|
15
|
+
interface Service {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly label: string;
|
|
18
|
+
readonly envPrefix: string;
|
|
19
|
+
readonly baseUrl: string;
|
|
20
|
+
readonly model: string;
|
|
21
|
+
readonly minChoices: number;
|
|
22
|
+
}
|
|
23
|
+
/** Internal plumbing shared by Von and Laya, not a new workflow primitive. */
|
|
24
|
+
export declare function systemOneHttpProvider(service: Service, options: SystemOneHttpOptions): DecisionProvider;
|
|
25
|
+
export {};
|