@dna113p/machines 0.3.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 +21 -10
- package/claude/statusline.mjs +38 -5
- package/dist/mcp/server.js +2 -1
- package/dist/src/cli.js +138 -50
- package/dist/src/host.d.ts +8 -0
- package/dist/src/host.js +85 -9
- package/dist/src/run-inbox.js +4 -0
- package/dist/src/run-status.d.ts +24 -0
- package/dist/src/run-status.js +53 -2
- package/dist/src/run-watch.d.ts +24 -0
- package/dist/src/run-watch.js +112 -0
- package/dist/src/session.d.ts +2 -2
- package/dist/src/session.js +6 -5
- package/docs/architecture.md +10 -4
- package/docs/integrations.md +89 -10
- package/package.json +1 -1
- package/skills/machine-delegation/SKILL.md +5 -0
package/README.md
CHANGED
|
@@ -176,10 +176,10 @@ npm ci
|
|
|
176
176
|
npm pack
|
|
177
177
|
```
|
|
178
178
|
|
|
179
|
-
Then install the generated `dna113p-machines-0.3.
|
|
179
|
+
Then install the generated `dna113p-machines-0.3.1.tgz` into a project:
|
|
180
180
|
|
|
181
181
|
```bash
|
|
182
|
-
npm install /absolute/path/to/machines/dna113p-machines-0.3.
|
|
182
|
+
npm install /absolute/path/to/machines/dna113p-machines-0.3.1.tgz
|
|
183
183
|
npx machine list
|
|
184
184
|
```
|
|
185
185
|
|
|
@@ -198,8 +198,10 @@ read. A published run that is waiting for a Human can then be answered from
|
|
|
198
198
|
outside its own terminal:
|
|
199
199
|
|
|
200
200
|
```bash
|
|
201
|
-
machine runs [--json]
|
|
201
|
+
machine runs [--json] [--all] # running and waiting runs; --all adds finished and lost ones
|
|
202
202
|
machine respond <run-id> <request-id> <response> # answer a waiting run
|
|
203
|
+
machine watch [<run-id-or-label-start> | --dir <path>] # show other runs on this session's status line
|
|
204
|
+
machine unwatch <run-id-or-label-start> | --dir <path> | --all
|
|
203
205
|
```
|
|
204
206
|
|
|
205
207
|
`machine runs` shows each waiting run's prompt, request id, and choices. A run
|
|
@@ -207,6 +209,10 @@ started without a terminal, for example by a coordinating agent or a script,
|
|
|
207
209
|
waits for `machine respond` instead of failing. Anything running as the same user
|
|
208
210
|
can answer a waiting run, including an approval; see
|
|
209
211
|
[answering a waiting run from outside](docs/integrations.md#answering-a-waiting-run-from-outside).
|
|
212
|
+
Runs hosted through `startMachineHost` publish too and are answered through their
|
|
213
|
+
host. A session shows the runs it started; `machine watch` adds runs it did not,
|
|
214
|
+
such as a ticket a daemon runs. See
|
|
215
|
+
[watching other runs](docs/integrations.md#watching-other-runs).
|
|
210
216
|
|
|
211
217
|
## Execution model and limitations
|
|
212
218
|
|
|
@@ -280,18 +286,23 @@ npm run smoke:plugin # exercise a relocated standalone plugin
|
|
|
280
286
|
|
|
281
287
|
The [publishing workflow](.github/workflows/publish.yml) uses a standard GitHub
|
|
282
288
|
runner and npm trusted publishing, without an npm token stored in GitHub.
|
|
283
|
-
|
|
284
|
-
|
|
289
|
+
A release is a change to the version in `package.json`. When `main` receives a
|
|
290
|
+
stable version that npm does not have yet, the workflow runs the checks above,
|
|
291
|
+
publishes to npm if they pass, and tags the commit `vX.Y.Z`. Every other push to
|
|
292
|
+
`main` publishes nothing.
|
|
285
293
|
|
|
286
|
-
For example, to release the next minor version
|
|
294
|
+
For example, to release the next minor version, bump it on a branch and merge
|
|
295
|
+
that branch:
|
|
287
296
|
|
|
288
297
|
```bash
|
|
289
|
-
npm version minor
|
|
290
|
-
git
|
|
298
|
+
npm version minor --no-git-tag-version
|
|
299
|
+
git commit -am "Release X.Y.0"
|
|
291
300
|
```
|
|
292
301
|
|
|
293
|
-
|
|
294
|
-
|
|
302
|
+
Pushing a `vX.Y.Z` tag yourself still publishes that version if npm lacks it;
|
|
303
|
+
the tag must match the stable version in `package.json`. Running **Publish to
|
|
304
|
+
npm** manually in GitHub Actions performs the checks and a publishing dry run;
|
|
305
|
+
it does not publish a package.
|
|
295
306
|
|
|
296
307
|
Licensed under the [MIT License](LICENSE).
|
|
297
308
|
|
package/claude/statusline.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// Claude Code status line for Machine runs published through
|
|
4
4
|
// MACHINES_RUN_STATUS_DIR (see docs/integrations.md). It runs on every status
|
|
5
5
|
// refresh, so it imports only node: modules and never loads the runtime; the
|
|
6
|
-
// record shape below mirrors RunStatusRecord in src/run-status.ts
|
|
6
|
+
// record shape below mirrors RunStatusRecord in src/run-status.ts, and the
|
|
7
|
+
// watch list and its matching mirror src/run-watch.ts.
|
|
7
8
|
|
|
8
9
|
import { readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
9
10
|
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
@@ -27,6 +28,7 @@ export function runStatusLines(records, options = {}) {
|
|
|
27
28
|
const {
|
|
28
29
|
sessionId,
|
|
29
30
|
projectDir,
|
|
31
|
+
watches = [],
|
|
30
32
|
now = Date.now(),
|
|
31
33
|
columns = defaultColumns,
|
|
32
34
|
color = true,
|
|
@@ -38,7 +40,9 @@ export function runStatusLines(records, options = {}) {
|
|
|
38
40
|
const visible = [];
|
|
39
41
|
for (const record of records) {
|
|
40
42
|
// Sub-runs belong to the run that started them, not to the session.
|
|
41
|
-
if (!isRecord(record) || record.parent !== undefined
|
|
43
|
+
if (!isRecord(record) || record.parent !== undefined) continue;
|
|
44
|
+
// A run the session asked to watch is shown whoever owns it and wherever it works.
|
|
45
|
+
if (!isVisibleTo(record, sessionId, projectDir) && !watches.some((watch) => isWatched(record, watch))) continue;
|
|
42
46
|
const updatedAt = Date.parse(record.updatedAt);
|
|
43
47
|
const recorded = record.status === "running" || record.status === "waiting";
|
|
44
48
|
// A run that stopped reporting without finishing has lost its process.
|
|
@@ -91,9 +95,25 @@ export function runStatusLines(records, options = {}) {
|
|
|
91
95
|
function isVisibleTo(record, sessionId, projectDir) {
|
|
92
96
|
if (record.owner !== undefined) return typeof sessionId === "string" && record.owner === sessionId;
|
|
93
97
|
if (typeof projectDir !== "string" || !isAbsolute(projectDir) || !isAbsolute(record.cwd)) return false;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
return isInside(record.cwd, projectDir);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isInside(path, directory) {
|
|
102
|
+
const outer = resolve(directory);
|
|
103
|
+
const inner = resolve(path);
|
|
104
|
+
return inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : `${outer}${sep}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isWatched(record, watch) {
|
|
108
|
+
if (watch === null || typeof watch !== "object") return false;
|
|
109
|
+
const { text, dir } = watch;
|
|
110
|
+
if (typeof dir === "string" && text === undefined) {
|
|
111
|
+
return isAbsolute(dir) && isAbsolute(record.cwd) && isInside(record.cwd, dir);
|
|
112
|
+
}
|
|
113
|
+
if (typeof text !== "string" || text === "" || dir !== undefined) return false;
|
|
114
|
+
if (record.id.startsWith(text)) return true;
|
|
115
|
+
// The label must start with the whole text, not with a longer word that begins like it.
|
|
116
|
+
return record.label?.startsWith(text) === true && !/^[\p{L}\p{N}_-]/u.test(record.label.slice(text.length));
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
function isRecord(value) {
|
|
@@ -204,6 +224,18 @@ function readRecords(directory) {
|
|
|
204
224
|
return records;
|
|
205
225
|
}
|
|
206
226
|
|
|
227
|
+
/** What the session asked to watch with `machine watch`; nothing when it has no readable list. */
|
|
228
|
+
function readWatches(directory, sessionId) {
|
|
229
|
+
// The id is part of the file name, so it must not be able to leave the directory.
|
|
230
|
+
if (typeof sessionId !== "string" || !/^[A-Za-z0-9_-][A-Za-z0-9._-]*$/u.test(sessionId)) return [];
|
|
231
|
+
try {
|
|
232
|
+
const { watches } = JSON.parse(readFileSync(join(directory, `.watch.${sessionId}.json`), "utf8"));
|
|
233
|
+
return Array.isArray(watches) ? watches : [];
|
|
234
|
+
} catch {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
207
239
|
async function main() {
|
|
208
240
|
const directory = process.env.MACHINES_RUN_STATUS_DIR;
|
|
209
241
|
if (directory === undefined || directory === "" || process.stdin.isTTY) return;
|
|
@@ -217,6 +249,7 @@ async function main() {
|
|
|
217
249
|
const columns = /^[0-9]+$/u.test(process.env.COLUMNS ?? "") ? Number(process.env.COLUMNS) : defaultColumns;
|
|
218
250
|
const lines = runStatusLines(readRecords(directory), {
|
|
219
251
|
sessionId: input.session_id,
|
|
252
|
+
watches: readWatches(directory, input.session_id),
|
|
220
253
|
projectDir: [input.workspace?.project_dir, input.workspace?.current_dir, input.cwd]
|
|
221
254
|
.find((path) => typeof path === "string" && path !== ""),
|
|
222
255
|
columns,
|
package/dist/mcp/server.js
CHANGED
|
@@ -176,7 +176,8 @@ export class MachineSession {
|
|
|
176
176
|
return result(text, { agents });
|
|
177
177
|
}
|
|
178
178
|
async start(input) {
|
|
179
|
-
|
|
179
|
+
// This server publishes its runs itself, so that they also take responses through their inbox.
|
|
180
|
+
const run = await this.#session.start({ ...parse(startInput, input), publishRunStatus: false });
|
|
180
181
|
return result(`Started ${run.machine}.\nRun id: ${run.id}\nStatus: ${run.status}`, { run });
|
|
181
182
|
}
|
|
182
183
|
status(input) {
|
package/dist/src/cli.js
CHANGED
|
@@ -10,8 +10,12 @@ import { assertJsonValue, isJsonValue } from "./json.js";
|
|
|
10
10
|
import { discoverMachines, machinesUserHome } from "./discovery.js";
|
|
11
11
|
import { listAgentPresets, listMachines, prepareMachineRun, } from "./launcher.js";
|
|
12
12
|
import { inboxHuman, respondToRun } from "./run-inbox.js";
|
|
13
|
-
import { createRunStatusPublisher, isProcessAlive, readRunStatuses, runStatusDirectory, } from "./run-status.js";
|
|
13
|
+
import { createRunStatusPublisher, isProcessAlive, readRunStatuses, runSessionId, runStatusDirectory, runStatusObserver, } from "./run-status.js";
|
|
14
|
+
import { readRunWatches, runWatch, runWatchMatches, runWatchPath, sameRunWatch, writeRunWatches, } from "./run-watch.js";
|
|
14
15
|
const [command, selector, ...argumentsAfterSelector] = process.argv.slice(2);
|
|
16
|
+
const runsOptions = selector === undefined ? [] : [selector, ...argumentsAfterSelector];
|
|
17
|
+
const watchUsage = "machine watch [<run-id-or-label-start> | --dir <path>]";
|
|
18
|
+
const unwatchUsage = "machine unwatch <run-id-or-label-start> | --dir <path> | --all";
|
|
15
19
|
try {
|
|
16
20
|
if (command === "list" && selector === undefined) {
|
|
17
21
|
const machines = await listMachines({ cwd: process.cwd() });
|
|
@@ -39,12 +43,19 @@ try {
|
|
|
39
43
|
else if (command === "run" && selector !== undefined) {
|
|
40
44
|
await runMachine(await resolveMachine(selector), argumentsAfterSelector);
|
|
41
45
|
}
|
|
42
|
-
else if (command === "runs"
|
|
43
|
-
|
|
46
|
+
else if (command === "runs"
|
|
47
|
+
&& runsOptions.every((option, index) => (option === "--json" || option === "--all") && runsOptions.indexOf(option) === index)) {
|
|
48
|
+
listRuns(runsOptions.includes("--json"), runsOptions.includes("--all"));
|
|
44
49
|
}
|
|
45
50
|
else if (command === "respond" && selector !== undefined) {
|
|
46
51
|
await respond([selector, ...argumentsAfterSelector]);
|
|
47
52
|
}
|
|
53
|
+
else if (command === "watch") {
|
|
54
|
+
watch(selector === undefined ? [] : [selector, ...argumentsAfterSelector]);
|
|
55
|
+
}
|
|
56
|
+
else if (command === "unwatch" && selector !== undefined) {
|
|
57
|
+
unwatch([selector, ...argumentsAfterSelector]);
|
|
58
|
+
}
|
|
48
59
|
else {
|
|
49
60
|
throw new Error([
|
|
50
61
|
"Usage:",
|
|
@@ -52,8 +63,10 @@ try {
|
|
|
52
63
|
" machine agents",
|
|
53
64
|
" machine show <name-or-file>",
|
|
54
65
|
" machine run <name-or-file> [--agent role=preset]... [--input-file <file|->] [--] [input]",
|
|
55
|
-
" machine runs [--json]",
|
|
66
|
+
" machine runs [--json] [--all]",
|
|
56
67
|
" machine respond [--question] <run-id> <request-id> [--] <response>",
|
|
68
|
+
` ${watchUsage}`,
|
|
69
|
+
` ${unwatchUsage}`,
|
|
57
70
|
].join("\n"));
|
|
58
71
|
}
|
|
59
72
|
}
|
|
@@ -87,31 +100,11 @@ async function runMachine(machinePath, args) {
|
|
|
87
100
|
const statusDirectory = runStatusDirectory();
|
|
88
101
|
// Machines started by this run's Operations and Agents are its sub-runs.
|
|
89
102
|
process.env.MACHINES_RUN_PARENT = runId;
|
|
90
|
-
|
|
91
|
-
// A runner reports its identity as it starts. For a state entered by a
|
|
92
|
-
// transition that is before the state is announced, so an identity is held
|
|
93
|
-
// for the rest of the tick and published with the state it belongs to.
|
|
94
|
-
let arriving;
|
|
103
|
+
const observed = runStatusObserver((change) => published.update(change));
|
|
95
104
|
try {
|
|
96
105
|
const result = await prepared.start({
|
|
97
106
|
onAgentUpdate(update) {
|
|
98
|
-
|
|
99
|
-
arriving = update;
|
|
100
|
-
queueMicrotask(() => {
|
|
101
|
-
if (arriving !== update)
|
|
102
|
-
return;
|
|
103
|
-
arriving = undefined;
|
|
104
|
-
published.update({ agent: update });
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
else if (update.type === "limit" && update.status !== "swapped") {
|
|
108
|
-
// A swap while every preset is being tried again leaves the wait as it is published.
|
|
109
|
-
published.update({
|
|
110
|
-
limit: update.status === "resumed"
|
|
111
|
-
? undefined
|
|
112
|
-
: { text: update.text, ...(update.until === undefined ? {} : { until: update.until }) },
|
|
113
|
-
});
|
|
114
|
-
}
|
|
107
|
+
observed.onAgentUpdate(update);
|
|
115
108
|
status.onAgentUpdate(update);
|
|
116
109
|
},
|
|
117
110
|
// A published run can also be answered through its inbox by `machine respond`.
|
|
@@ -134,14 +127,7 @@ async function runMachine(machinePath, args) {
|
|
|
134
127
|
status.onHumanInput(active);
|
|
135
128
|
},
|
|
136
129
|
onState(state) {
|
|
137
|
-
|
|
138
|
-
if (entered === publishedState)
|
|
139
|
-
published.update({ state });
|
|
140
|
-
else {
|
|
141
|
-
published.update({ state, agent: arriving });
|
|
142
|
-
arriving = undefined;
|
|
143
|
-
}
|
|
144
|
-
publishedState = entered;
|
|
130
|
+
observed.onState(state);
|
|
145
131
|
status.onState(state);
|
|
146
132
|
},
|
|
147
133
|
});
|
|
@@ -161,20 +147,104 @@ async function runMachine(machinePath, args) {
|
|
|
161
147
|
status.close();
|
|
162
148
|
}
|
|
163
149
|
}
|
|
164
|
-
function listRuns(json) {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
150
|
+
function listRuns(json, all) {
|
|
151
|
+
const directory = requireStatusDirectory();
|
|
152
|
+
const published = publishedRuns(directory);
|
|
153
|
+
// Without --all, only the runs that can still change or be answered.
|
|
154
|
+
const runs = all ? published : published.filter(isActiveRun);
|
|
168
155
|
if (json) {
|
|
169
156
|
console.log(JSON.stringify(runs, null, 2));
|
|
170
157
|
return;
|
|
171
158
|
}
|
|
172
159
|
if (runs.length === 0)
|
|
173
|
-
console.log("No Machine runs are running or waiting.");
|
|
160
|
+
console.log(all ? "No Machine runs are published." : "No Machine runs are running or waiting.");
|
|
161
|
+
const session = runSessionId();
|
|
162
|
+
const watches = session === undefined ? [] : readRunWatches(directory, session);
|
|
174
163
|
runs.forEach((record, index) => {
|
|
175
|
-
printRunListing(record, index > 0);
|
|
164
|
+
printRunListing(record, index > 0, watches.some((watched) => runWatchMatches(watched, record)));
|
|
176
165
|
});
|
|
177
166
|
}
|
|
167
|
+
/** Every record in the directory, including runs that finished or lost their process and are still recorded. */
|
|
168
|
+
function publishedRuns(directory) {
|
|
169
|
+
return readRunStatuses(directory).sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt));
|
|
170
|
+
}
|
|
171
|
+
/** A run that stopped reporting without finishing has lost its process, as on the status line. */
|
|
172
|
+
function runStatus(record) {
|
|
173
|
+
return record.status !== "completed" && record.status !== "failed" && !isProcessAlive(record.pid) ? "lost" : record.status;
|
|
174
|
+
}
|
|
175
|
+
function isActiveRun(record) {
|
|
176
|
+
const status = runStatus(record);
|
|
177
|
+
return status === "running" || status === "waiting";
|
|
178
|
+
}
|
|
179
|
+
function watch(args) {
|
|
180
|
+
const { directory, session } = requireWatchList();
|
|
181
|
+
const watches = readRunWatches(directory, session);
|
|
182
|
+
const runs = publishedRuns(directory);
|
|
183
|
+
if (args.length === 0) {
|
|
184
|
+
if (watches.length === 0)
|
|
185
|
+
console.log("This session watches no other runs.");
|
|
186
|
+
for (const watched of watches) {
|
|
187
|
+
console.log(describeWatch(watched));
|
|
188
|
+
printWatchedRuns(watched, runs);
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const added = parseWatch(args, watchUsage);
|
|
193
|
+
if (!watches.some((watched) => sameRunWatch(watched, added)))
|
|
194
|
+
writeRunWatches(directory, session, [...watches, added]);
|
|
195
|
+
console.log(`Watching ${describeWatch(added)}`);
|
|
196
|
+
printWatchedRuns(added, runs);
|
|
197
|
+
}
|
|
198
|
+
function unwatch(args) {
|
|
199
|
+
const { directory, session } = requireWatchList();
|
|
200
|
+
if (args[0] === "--all") {
|
|
201
|
+
if (args.length !== 1)
|
|
202
|
+
throw new Error(`Usage: ${unwatchUsage}`);
|
|
203
|
+
writeRunWatches(directory, session, []);
|
|
204
|
+
console.log("This session watches no other runs.");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const removed = parseWatch(args, unwatchUsage);
|
|
208
|
+
const watches = readRunWatches(directory, session);
|
|
209
|
+
const kept = watches.filter((watched) => !sameRunWatch(watched, removed));
|
|
210
|
+
if (kept.length === watches.length) {
|
|
211
|
+
console.log(`This session was not watching ${describeWatch(removed)}`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
writeRunWatches(directory, session, kept);
|
|
215
|
+
console.log(`No longer watching ${describeWatch(removed)}`);
|
|
216
|
+
}
|
|
217
|
+
function parseWatch(args, usage) {
|
|
218
|
+
if (args[0] === "--dir") {
|
|
219
|
+
if (args.length !== 2)
|
|
220
|
+
throw new Error(`Usage: ${usage}`);
|
|
221
|
+
return runWatch({ dir: args[1] });
|
|
222
|
+
}
|
|
223
|
+
if (args[0] !== "--" && args[0]?.startsWith("--") === true)
|
|
224
|
+
throw new Error(`Usage: ${usage}`);
|
|
225
|
+
return runWatch({ text: (args[0] === "--" ? args.slice(1) : args).join(" ") });
|
|
226
|
+
}
|
|
227
|
+
function describeWatch(watched) {
|
|
228
|
+
return "dir" in watched ? `--dir ${watched.dir}` : watched.text;
|
|
229
|
+
}
|
|
230
|
+
function printWatchedRuns(watched, runs) {
|
|
231
|
+
const matched = runs.filter((record) => runWatchMatches(watched, record));
|
|
232
|
+
if (matched.length === 0)
|
|
233
|
+
console.log(" no published run matches yet");
|
|
234
|
+
for (const record of matched)
|
|
235
|
+
console.log(` ${runColumns(record).join("\t")}`);
|
|
236
|
+
}
|
|
237
|
+
/** The session whose watch list this process changes, and where the list is kept. */
|
|
238
|
+
function requireWatchList() {
|
|
239
|
+
const directory = requireStatusDirectory();
|
|
240
|
+
const session = runSessionId();
|
|
241
|
+
if (session === undefined) {
|
|
242
|
+
throw new Error("No session to watch for: set MACHINES_RUN_OWNER to the id of the session whose status line shows the runs.");
|
|
243
|
+
}
|
|
244
|
+
// Refuses a session id that cannot name a file.
|
|
245
|
+
runWatchPath(directory, session);
|
|
246
|
+
return { directory, session };
|
|
247
|
+
}
|
|
178
248
|
async function respond(args) {
|
|
179
249
|
const question = args[0] === "--question";
|
|
180
250
|
const [runId, requestId, ...words] = question ? args.slice(1) : args;
|
|
@@ -301,30 +371,48 @@ function printAgentListing(preset, separate) {
|
|
|
301
371
|
console.log(` fallback: ${preset.fallback.join(", ") || "none"}`);
|
|
302
372
|
console.log(` ${dim(preset.source)}`);
|
|
303
373
|
}
|
|
304
|
-
|
|
374
|
+
/** Recorded text must not be able to move the cursor or restyle the terminal. */
|
|
375
|
+
function plain(text) {
|
|
376
|
+
return text.replace(/[\u0000-\u001f\u007f-\u009f]+/gu, " ").trim();
|
|
377
|
+
}
|
|
378
|
+
/** A run as tab-separated columns: its label follows the four that were always there. */
|
|
379
|
+
function runColumns(record) {
|
|
380
|
+
const label = record.label === undefined ? "" : plain(record.label);
|
|
381
|
+
return [record.id, record.machine, runStatus(record), record.state ?? "starting"].map(plain).concat(label === "" ? [] : [label]);
|
|
382
|
+
}
|
|
383
|
+
function printRunListing(record, separate, watched) {
|
|
305
384
|
const state = record.state ?? "starting";
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
385
|
+
const recorded = runStatus(record);
|
|
386
|
+
const human = recorded === "waiting" ? record.human : undefined;
|
|
387
|
+
// A run that finished or lost its process is no longer waiting for anything.
|
|
388
|
+
const limit = recorded === "running" || recorded === "waiting" ? record.limit : undefined;
|
|
389
|
+
const details = human === undefined ? (limit === undefined ? [] : [plain(limit.text)]) : [
|
|
310
390
|
...human.prompt.split("\n").map(plain).filter((line) => line !== ""),
|
|
311
391
|
...(human.requestId === undefined ? [] : [`request: ${plain(human.requestId)}`]),
|
|
392
|
+
...(record.answeredBy === "host" ? ["answered through its host"] : []),
|
|
312
393
|
...(human.choices === undefined ? [] : [`choices: ${human.choices.map(plain).join(", ")}`]),
|
|
313
394
|
...(human.suggestions === undefined ? [] : [`suggestions: ${human.suggestions.map(plain).join(", ")}`]),
|
|
314
395
|
...(human.discussion === true ? ["discussion: questions allowed"] : []),
|
|
315
396
|
];
|
|
316
397
|
if (!process.stdout.isTTY) {
|
|
317
|
-
|
|
398
|
+
const columns = runColumns(record);
|
|
399
|
+
// The mark has its own column, after the label's, whether or not the run has a label.
|
|
400
|
+
console.log((watched ? [...columns, ...(columns.length === 4 ? [""] : []), "watched"] : columns).join("\t"));
|
|
318
401
|
for (const line of details)
|
|
319
402
|
console.log(` ${line}`);
|
|
320
403
|
return;
|
|
321
404
|
}
|
|
322
405
|
if (separate)
|
|
323
406
|
console.log();
|
|
324
|
-
const status =
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
407
|
+
const status = recorded === "waiting"
|
|
408
|
+
? yellow(recorded)
|
|
409
|
+
: recorded === "running" ? cyan(recorded) : recorded === "completed" ? green(recorded) : red(recorded);
|
|
410
|
+
console.log(`${bold(plain(record.id))} ${bold(cyan(plain(record.machine)))} › ${status} · ${plain(state)}${watched ? ` · ${dim("watched")}` : ""}`);
|
|
411
|
+
if (record.label !== undefined && plain(record.label) !== "")
|
|
412
|
+
console.log(` ${plain(record.label)}`);
|
|
413
|
+
for (const line of details) {
|
|
414
|
+
console.log(` ${line.startsWith("request: ") || line === "answered through its host" ? dim(line) : line}`);
|
|
415
|
+
}
|
|
328
416
|
}
|
|
329
417
|
function printMachineHeader(machinePath) {
|
|
330
418
|
if (!process.stdout.isTTY) {
|
package/dist/src/host.d.ts
CHANGED
|
@@ -7,6 +7,12 @@ export interface HostedHumanRequest extends HumanRequest {
|
|
|
7
7
|
}
|
|
8
8
|
export interface MachineHostOptions extends PrepareMachineRunOptions {
|
|
9
9
|
readonly signal?: AbortSignal;
|
|
10
|
+
/** What the run is for, in its published run status. `MACHINES_RUN_LABEL` applies when absent. */
|
|
11
|
+
readonly label?: string;
|
|
12
|
+
/** The session that owns the run in its published run status. The environment's owner applies when absent. */
|
|
13
|
+
readonly owner?: string;
|
|
14
|
+
/** Set to false by a caller that publishes the run's status itself, as the MCP server does. */
|
|
15
|
+
readonly publishRunStatus?: boolean;
|
|
10
16
|
readonly onAgentUpdate?: (update: AgentUpdate) => void;
|
|
11
17
|
readonly onHumanRequest?: (request: HostedHumanRequest) => void;
|
|
12
18
|
readonly onState?: (state: StateValue) => void;
|
|
@@ -16,6 +22,8 @@ export interface MachineHostResult {
|
|
|
16
22
|
readonly output?: JsonValue;
|
|
17
23
|
}
|
|
18
24
|
export interface MachineHostRun {
|
|
25
|
+
/** Names the run. Its published run status, when there is one, is recorded under this id. */
|
|
26
|
+
readonly id: string;
|
|
19
27
|
readonly name: string;
|
|
20
28
|
readonly description: string;
|
|
21
29
|
readonly path: string;
|
package/dist/src/host.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { execFileSync, fork } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { resolve as resolvePath } from "node:path";
|
|
2
4
|
import { fileURLToPath } from "node:url";
|
|
3
5
|
import * as v from "valibot";
|
|
4
6
|
import { assertJsonValue, isJsonValue } from "./json.js";
|
|
5
7
|
import { prepareMachineRun, } from "./launcher.js";
|
|
8
|
+
import { createRunStatusPublisher, runStatusDirectory, runStatusObserver, } from "./run-status.js";
|
|
6
9
|
const childArgument = "--machines-child-host";
|
|
7
10
|
const humanRequestSchema = v.strictObject({
|
|
8
11
|
discussion: v.optional(v.boolean()),
|
|
@@ -10,13 +13,13 @@ const humanRequestSchema = v.strictObject({
|
|
|
10
13
|
choices: v.optional(v.array(v.string())),
|
|
11
14
|
suggestions: v.optional(v.array(v.string())),
|
|
12
15
|
});
|
|
16
|
+
const agentIdentity = {
|
|
17
|
+
harness: v.string(),
|
|
18
|
+
model: v.optional(v.string()),
|
|
19
|
+
thinking: v.optional(v.string()),
|
|
20
|
+
};
|
|
13
21
|
const agentUpdateSchema = v.variant("type", [
|
|
14
|
-
v.strictObject({
|
|
15
|
-
type: v.literal("identity"),
|
|
16
|
-
harness: v.string(),
|
|
17
|
-
model: v.optional(v.string()),
|
|
18
|
-
thinking: v.optional(v.string()),
|
|
19
|
-
}),
|
|
22
|
+
v.strictObject({ type: v.literal("identity"), ...agentIdentity }),
|
|
20
23
|
v.strictObject({
|
|
21
24
|
type: v.literal("output"),
|
|
22
25
|
text: v.string(),
|
|
@@ -48,6 +51,10 @@ const parentMessageSchema = v.variant("type", [
|
|
|
48
51
|
agents: v.optional(v.record(v.string(), v.string())),
|
|
49
52
|
cwd: v.string(),
|
|
50
53
|
home: v.optional(v.string()),
|
|
54
|
+
// Present while run status is published, by the host or by its caller, under this id.
|
|
55
|
+
runId: v.optional(v.string()),
|
|
56
|
+
// The host publishes the record itself and needs each change to it.
|
|
57
|
+
reportStatus: v.optional(v.boolean()),
|
|
51
58
|
}),
|
|
52
59
|
v.strictObject({
|
|
53
60
|
type: v.literal("ask"),
|
|
@@ -70,6 +77,15 @@ const childMessageSchema = v.variant("type", [
|
|
|
70
77
|
}),
|
|
71
78
|
v.strictObject({ type: v.literal("state"), state: stateValueSchema }),
|
|
72
79
|
v.strictObject({ type: v.literal("agentUpdate"), update: agentUpdateSchema }),
|
|
80
|
+
v.strictObject({
|
|
81
|
+
type: v.literal("status"),
|
|
82
|
+
// A change to the published record. Null clears a field: IPC drops a key that holds undefined.
|
|
83
|
+
change: v.strictObject({
|
|
84
|
+
state: v.optional(stateValueSchema),
|
|
85
|
+
agent: v.optional(v.nullable(v.object(agentIdentity))),
|
|
86
|
+
limit: v.optional(v.nullable(v.strictObject({ text: v.string(), until: v.optional(v.string()) }))),
|
|
87
|
+
}),
|
|
88
|
+
}),
|
|
73
89
|
v.strictObject({
|
|
74
90
|
type: v.literal("humanRequest"),
|
|
75
91
|
requestId: v.string(),
|
|
@@ -94,6 +110,16 @@ export function startMachineHost(options) {
|
|
|
94
110
|
}
|
|
95
111
|
}
|
|
96
112
|
const cwd = options.cwd ?? process.cwd();
|
|
113
|
+
// Run status is published here, by the host, and not by the hosted child.
|
|
114
|
+
// Only the host sees every ending: it stops the child with SIGKILL, and a
|
|
115
|
+
// child that crashed cannot record its own death. The record therefore
|
|
116
|
+
// carries this process's pid. While the host lives it settles the record
|
|
117
|
+
// itself; when the host dies the child stops with it (see "disconnect"
|
|
118
|
+
// below), so a reader that finds the pid gone rightly shows the run as lost.
|
|
119
|
+
const id = randomUUID();
|
|
120
|
+
const publication = runStatusDirectory() !== undefined;
|
|
121
|
+
const publishes = publication && options.publishRunStatus !== false;
|
|
122
|
+
let published;
|
|
97
123
|
const child = fork(fileURLToPath(import.meta.url), [childArgument], {
|
|
98
124
|
cwd,
|
|
99
125
|
execArgv: [],
|
|
@@ -135,6 +161,7 @@ export function startMachineHost(options) {
|
|
|
135
161
|
const wasStarting = phase === "starting";
|
|
136
162
|
phase = "failed";
|
|
137
163
|
pendingHuman = undefined;
|
|
164
|
+
published?.finish({ error: error.message });
|
|
138
165
|
rejectResult(error);
|
|
139
166
|
if (wasStarting)
|
|
140
167
|
rejectStarted(error);
|
|
@@ -158,7 +185,20 @@ export function startMachineHost(options) {
|
|
|
158
185
|
return;
|
|
159
186
|
}
|
|
160
187
|
phase = "running";
|
|
188
|
+
if (publishes) {
|
|
189
|
+
published = createRunStatusPublisher({
|
|
190
|
+
id,
|
|
191
|
+
machine: message.name,
|
|
192
|
+
path: message.path,
|
|
193
|
+
cwd: resolvePath(cwd),
|
|
194
|
+
...(options.label === undefined ? {} : { label: options.label }),
|
|
195
|
+
...(options.owner === undefined ? {} : { owner: options.owner }),
|
|
196
|
+
// Responses reach this run through `respond` and `ask` below, never through an inbox.
|
|
197
|
+
answeredBy: "host",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
161
200
|
resolveStarted({
|
|
201
|
+
id,
|
|
162
202
|
name: message.name,
|
|
163
203
|
description: message.description,
|
|
164
204
|
path: message.path,
|
|
@@ -177,6 +217,7 @@ export function startMachineHost(options) {
|
|
|
177
217
|
}
|
|
178
218
|
// Claim before yielding: no second caller can send this request again.
|
|
179
219
|
pendingHuman = undefined;
|
|
220
|
+
published?.update({ status: "running", human: undefined });
|
|
180
221
|
try {
|
|
181
222
|
await sendChild(child, { type: "respond", requestId, response });
|
|
182
223
|
}
|
|
@@ -195,6 +236,7 @@ export function startMachineHost(options) {
|
|
|
195
236
|
throw new Error("Question must contain 1–8000 characters");
|
|
196
237
|
}
|
|
197
238
|
pendingHuman = undefined;
|
|
239
|
+
published?.update({ status: "running", human: undefined });
|
|
198
240
|
try {
|
|
199
241
|
await sendChild(child, { type: "ask", requestId, question });
|
|
200
242
|
}
|
|
@@ -218,6 +260,14 @@ export function startMachineHost(options) {
|
|
|
218
260
|
else if (message.type === "agentUpdate") {
|
|
219
261
|
observe(() => options.onAgentUpdate?.(message.update));
|
|
220
262
|
}
|
|
263
|
+
else if (message.type === "status") {
|
|
264
|
+
const { state, agent, limit } = message.change;
|
|
265
|
+
published?.update({
|
|
266
|
+
...(state === undefined ? {} : { state: state }),
|
|
267
|
+
...(agent === undefined ? {} : { agent: agent ?? undefined }),
|
|
268
|
+
...(limit === undefined ? {} : { limit: limit ?? undefined }),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
221
271
|
else if (message.type === "humanRequest") {
|
|
222
272
|
if (phase !== "running" || pendingHuman !== undefined) {
|
|
223
273
|
fail(new Error("Machine host sent an unexpected Human request"));
|
|
@@ -227,6 +277,8 @@ export function startMachineHost(options) {
|
|
|
227
277
|
requestId: message.requestId,
|
|
228
278
|
request: message.request,
|
|
229
279
|
};
|
|
280
|
+
// Published without its request id, which only this host can use.
|
|
281
|
+
published?.update({ status: "waiting", human: message.request });
|
|
230
282
|
observe(() => options.onHumanRequest?.({ ...message.request, requestId: message.requestId }));
|
|
231
283
|
}
|
|
232
284
|
else if (message.type === "completed") {
|
|
@@ -236,6 +288,7 @@ export function startMachineHost(options) {
|
|
|
236
288
|
}
|
|
237
289
|
phase = "completed";
|
|
238
290
|
pendingHuman = undefined;
|
|
291
|
+
published?.finish({ state: message.state });
|
|
239
292
|
dispose();
|
|
240
293
|
resolveResult({
|
|
241
294
|
state: message.state,
|
|
@@ -268,6 +321,9 @@ export function startMachineHost(options) {
|
|
|
268
321
|
...(options.input === undefined ? {} : { input: options.input }),
|
|
269
322
|
...(options.agents === undefined ? {} : { agents: options.agents }),
|
|
270
323
|
...(options.home === undefined ? {} : { home: options.home }),
|
|
324
|
+
// A caller that publishes the record itself still publishes it under this id.
|
|
325
|
+
...(publication ? { runId: id } : {}),
|
|
326
|
+
...(publishes ? { reportStatus: true } : {}),
|
|
271
327
|
};
|
|
272
328
|
void sendChild(child, launch).catch((cause) => fail(asError(cause)));
|
|
273
329
|
});
|
|
@@ -335,7 +391,8 @@ function runChildHost() {
|
|
|
335
391
|
});
|
|
336
392
|
async function launch(message) {
|
|
337
393
|
try {
|
|
338
|
-
const
|
|
394
|
+
const { runId, reportStatus, ...launched } = message;
|
|
395
|
+
const prepared = await prepareMachineRun(launched);
|
|
339
396
|
send({
|
|
340
397
|
type: "started",
|
|
341
398
|
name: prepared.name,
|
|
@@ -343,9 +400,28 @@ function runChildHost() {
|
|
|
343
400
|
path: prepared.path,
|
|
344
401
|
agentRoles: prepared.agentRoles,
|
|
345
402
|
});
|
|
403
|
+
// Machines started by this run's Operations and Agents are its sub-runs.
|
|
404
|
+
if (runId !== undefined)
|
|
405
|
+
process.env.MACHINES_RUN_PARENT = runId;
|
|
406
|
+
// Only this process can tell which state an Agent identity belongs to, so
|
|
407
|
+
// it works out each change to the record and the host publishes it.
|
|
408
|
+
const observed = reportStatus !== true ? undefined : runStatusObserver((change) => send({
|
|
409
|
+
type: "status",
|
|
410
|
+
change: {
|
|
411
|
+
...(change.state === undefined ? {} : { state: change.state }),
|
|
412
|
+
...("agent" in change ? { agent: change.agent ?? null } : {}),
|
|
413
|
+
...("limit" in change ? { limit: change.limit ?? null } : {}),
|
|
414
|
+
},
|
|
415
|
+
}));
|
|
346
416
|
const result = await prepared.start({
|
|
347
|
-
onAgentUpdate
|
|
348
|
-
|
|
417
|
+
onAgentUpdate(update) {
|
|
418
|
+
send({ type: "agentUpdate", update });
|
|
419
|
+
observed?.onAgentUpdate(update);
|
|
420
|
+
},
|
|
421
|
+
onState(state) {
|
|
422
|
+
send({ type: "state", state });
|
|
423
|
+
observed?.onState(state);
|
|
424
|
+
},
|
|
349
425
|
human: (request) => new Promise((resolve) => {
|
|
350
426
|
if (pendingHuman !== undefined) {
|
|
351
427
|
throw new Error("Machine host cannot wait for two Human responses at once");
|
package/dist/src/run-inbox.js
CHANGED
|
@@ -20,6 +20,10 @@ export async function respondToRun(options) {
|
|
|
20
20
|
const record = published();
|
|
21
21
|
if (record === undefined)
|
|
22
22
|
throw new Error(`Machine run "${runId}" is not published in ${directory}`);
|
|
23
|
+
// Such a run reads no inbox: a response left there would only look delivered.
|
|
24
|
+
if (record.answeredBy === "host") {
|
|
25
|
+
throw new Error(`Machine run "${runId}" is answered through the program hosting it, not by machine respond: answer it there`);
|
|
26
|
+
}
|
|
23
27
|
const request = pendingRequest(record);
|
|
24
28
|
if (request === undefined)
|
|
25
29
|
throw new Error(`Machine run "${runId}" is not waiting for Human input`);
|
package/dist/src/run-status.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { StateValue } from "xstate";
|
|
2
|
+
import type { AgentUpdate } from "./index.ts";
|
|
2
3
|
/** One published run, stored as `<MACHINES_RUN_STATUS_DIR>/<id>.json`. */
|
|
3
4
|
export interface RunStatusRecord {
|
|
4
5
|
/** Version 1 records carry only `human.prompt`. */
|
|
@@ -21,6 +22,11 @@ export interface RunStatusRecord {
|
|
|
21
22
|
/** Present while the current Agent state waits for a usage limit to reset; `status` stays `running`. */
|
|
22
23
|
readonly limit?: RunStatusLimit;
|
|
23
24
|
readonly human?: RunStatusHuman;
|
|
25
|
+
/**
|
|
26
|
+
* `host` when the program hosting the run answers its Human requests itself:
|
|
27
|
+
* the run has no inbox, and `machine respond` refuses it. Absent otherwise.
|
|
28
|
+
*/
|
|
29
|
+
readonly answeredBy?: "host";
|
|
24
30
|
/** Names the inbox responses the run took lately, so that each sender can tell; never a response. */
|
|
25
31
|
readonly deliveries?: readonly string[];
|
|
26
32
|
readonly error?: string;
|
|
@@ -52,6 +58,11 @@ export interface RunStatusRun {
|
|
|
52
58
|
readonly path: string;
|
|
53
59
|
readonly cwd: string;
|
|
54
60
|
readonly startedAt?: string;
|
|
61
|
+
/** Replaces the owner the environment names; an empty one publishes the run unowned. */
|
|
62
|
+
readonly owner?: string;
|
|
63
|
+
/** Replaces `MACHINES_RUN_LABEL`, and is shortened as it is. */
|
|
64
|
+
readonly label?: string;
|
|
65
|
+
readonly answeredBy?: "host";
|
|
55
66
|
}
|
|
56
67
|
/** A present key replaces its field; a present key holding `undefined` clears it. */
|
|
57
68
|
export interface RunStatusChange {
|
|
@@ -90,6 +101,19 @@ export declare function runDeliveryPath(directory: string, id: string, delivery:
|
|
|
90
101
|
* throws, and it records neither Machine input nor output.
|
|
91
102
|
*/
|
|
92
103
|
export declare function createRunStatusPublisher(run: RunStatusRun, environment?: NodeJS.ProcessEnv): RunStatusPublisher;
|
|
104
|
+
/**
|
|
105
|
+
* Turns what a run reports as it executes into the changes to its record, for
|
|
106
|
+
* `machine run` and for a hosted run alike.
|
|
107
|
+
*/
|
|
108
|
+
export declare function runStatusObserver(update: (change: RunStatusChange) => void): {
|
|
109
|
+
onAgentUpdate(update: AgentUpdate): void;
|
|
110
|
+
onState(state: StateValue): void;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* The session a process belongs to: the owner of the runs it starts, and the
|
|
114
|
+
* session whose watch list `machine watch` changes.
|
|
115
|
+
*/
|
|
116
|
+
export declare function runSessionId(environment?: NodeJS.ProcessEnv): string | undefined;
|
|
93
117
|
/** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
|
|
94
118
|
export declare function readRunStatuses(directory: string): RunStatusRecord[];
|
|
95
119
|
/** The id becomes a file name, so it must not be able to leave the directory. */
|
package/dist/src/run-status.js
CHANGED
|
@@ -32,9 +32,9 @@ export function createRunStatusPublisher(run, environment = process.env) {
|
|
|
32
32
|
const id = run.id ?? randomUUID();
|
|
33
33
|
if (directory === undefined || !isFileSafeId(id))
|
|
34
34
|
return disabled;
|
|
35
|
-
const owner =
|
|
35
|
+
const owner = (run.owner ?? runSessionId(environment)) || undefined;
|
|
36
36
|
const parent = environment.MACHINES_RUN_PARENT || undefined;
|
|
37
|
-
const label = formatLabel(environment.MACHINES_RUN_LABEL);
|
|
37
|
+
const label = formatLabel(run.label ?? environment.MACHINES_RUN_LABEL);
|
|
38
38
|
const startedAt = run.startedAt ?? new Date().toISOString();
|
|
39
39
|
let status = "running";
|
|
40
40
|
let state;
|
|
@@ -61,6 +61,7 @@ export function createRunStatusPublisher(run, environment = process.env) {
|
|
|
61
61
|
...(agent === undefined ? {} : { agent }),
|
|
62
62
|
...(limit === undefined ? {} : { limit }),
|
|
63
63
|
...(human === undefined ? {} : { human }),
|
|
64
|
+
...(run.answeredBy === undefined ? {} : { answeredBy: run.answeredBy }),
|
|
64
65
|
...(deliveries.length === 0 ? {} : { deliveries: deliveries.map((delivery) => delivery.id) }),
|
|
65
66
|
...(error === undefined ? {} : { error }),
|
|
66
67
|
startedAt,
|
|
@@ -142,6 +143,55 @@ export function createRunStatusPublisher(run, environment = process.env) {
|
|
|
142
143
|
},
|
|
143
144
|
};
|
|
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
|
+
}
|
|
145
195
|
/** Returns the valid records in a directory. Unreadable or malformed files are skipped. */
|
|
146
196
|
export function readRunStatuses(directory) {
|
|
147
197
|
return readRecordFiles(directory).map(({ record }) => record);
|
|
@@ -220,6 +270,7 @@ function isRunStatusRecord(value) {
|
|
|
220
270
|
&& (human.discussion === undefined || typeof human.discussion === "boolean")))
|
|
221
271
|
&& (limit === undefined || (limit !== null && typeof limit === "object" && typeof limit.text === "string"
|
|
222
272
|
&& isOptionalString(limit.until)))
|
|
273
|
+
&& (record.answeredBy === undefined || record.answeredBy === "host")
|
|
223
274
|
&& isOptionalStrings(record.deliveries)
|
|
224
275
|
&& isOptionalString(record.error)
|
|
225
276
|
&& isTimestamp(record.startedAt)
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
import type { StateValue } from "xstate";
|
|
2
2
|
import type { JsonValue } from "./json.ts";
|
|
3
|
-
import { type HostedHumanRequest } from "./host.ts";
|
|
3
|
+
import { type HostedHumanRequest, type MachineHostOptions } from "./host.ts";
|
|
4
4
|
import type { AgentUpdate } from "./index.ts";
|
|
5
5
|
import type { PrepareMachineRunOptions } from "./launcher.ts";
|
|
6
6
|
export type RunStatus = "running" | "waiting" | "completed" | "failed";
|
|
@@ -35,7 +35,7 @@ export interface SessionEvent {
|
|
|
35
35
|
export declare class MachineSession {
|
|
36
36
|
#private;
|
|
37
37
|
subscribe(listener: (event: SessionEvent) => void): () => void;
|
|
38
|
-
start(options: PrepareMachineRunOptions): Promise<RunSnapshot>;
|
|
38
|
+
start(options: PrepareMachineRunOptions & Pick<MachineHostOptions, "label" | "owner" | "publishRunStatus">): Promise<RunSnapshot>;
|
|
39
39
|
status(runId?: string): RunSnapshot[];
|
|
40
40
|
respond(input: {
|
|
41
41
|
readonly runId: string;
|
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);
|
|
@@ -63,14 +63,15 @@ export class MachineSession {
|
|
|
63
63
|
throw new Error("Machine session is closed");
|
|
64
64
|
}
|
|
65
65
|
const run = {
|
|
66
|
-
id,
|
|
66
|
+
id: host.id,
|
|
67
67
|
cwd: options.cwd ?? process.cwd(),
|
|
68
68
|
host,
|
|
69
69
|
startedAt,
|
|
70
70
|
updatedAt: Date.now(),
|
|
71
71
|
status: "running",
|
|
72
72
|
};
|
|
73
|
-
|
|
73
|
+
started = run;
|
|
74
|
+
this.#runs.set(run.id, run);
|
|
74
75
|
void host.result.then((result) => this.#finish(run, result), (cause) => this.#finish(run, { error: errorMessage(cause) }));
|
|
75
76
|
this.#emit("updated", run);
|
|
76
77
|
return snapshot(run);
|
package/docs/architecture.md
CHANGED
|
@@ -59,7 +59,7 @@ MCP server ────┘ ↑
|
|
|
59
59
|
| `src/discovery.ts` | Project/global definition locations and precedence |
|
|
60
60
|
| `src/launcher.ts` | Definition metadata, Agent bindings, preflight, single-use launch preparation |
|
|
61
61
|
| `src/discovery-worker.ts` | Fresh metadata imports in disposable workers |
|
|
62
|
-
| `src/host.ts` | One child run, IPC, Human request identity, owned process termination |
|
|
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
64
|
| `src/cli.ts` | Command parsing, terminal progress, source inspection |
|
|
65
65
|
| `pi-extension/index.ts` | Pi tools, widgets, notifications, native input presentation |
|
|
@@ -107,9 +107,14 @@ response from either the terminal or a private per-run inbox file written by
|
|
|
107
107
|
session `respond` path as its tool, and inbox questions through the session's
|
|
108
108
|
`ask`. The inbox is a second way to deliver a response to the owning run, not a
|
|
109
109
|
second owner: the run still validates the request ID and choices, and a response
|
|
110
|
-
to any other request is discarded.
|
|
110
|
+
to any other request is discarded. A run hosted directly through
|
|
111
|
+
`startMachineHost` publishes its status without an inbox: its host remains the
|
|
112
|
+
only way to answer it, its record says so, and `machine respond` refuses it. See
|
|
111
113
|
[Integrations](integrations.md#answering-a-waiting-run-from-outside).
|
|
112
114
|
|
|
115
|
+
A session's watch list changes only which published runs its status line shows.
|
|
116
|
+
It gives no ownership of a run and no way to answer one.
|
|
117
|
+
|
|
113
118
|
Closing a session terminates active and starting hosts. Host termination includes
|
|
114
119
|
ordinary descendants in the host's owned process group on POSIX and process-tree
|
|
115
120
|
termination on Windows. Deliberately detached processes can escape normal ownership;
|
|
@@ -167,8 +172,9 @@ Their phase ordering and manual proof gates describe past work.
|
|
|
167
172
|
|
|
168
173
|
## External execution owners
|
|
169
174
|
|
|
170
|
-
The supported `@dna113p/machines/host` export exposes `startMachineHost`,
|
|
171
|
-
observations, correlated Human responses, termination, and final
|
|
175
|
+
The supported `@dna113p/machines/host` export exposes `startMachineHost`, the
|
|
176
|
+
run's id, its observations, correlated Human responses, termination, and final
|
|
177
|
+
state/output.
|
|
172
178
|
External applications can own hosted runs without importing private modules or
|
|
173
179
|
parsing terminal output. Hosted input and optional output are JSON values.
|
|
174
180
|
|
package/docs/integrations.md
CHANGED
|
@@ -173,9 +173,9 @@ a command, such as Claude Code's status line, reads published run status instead
|
|
|
173
173
|
Publication is opt-in: set `MACHINES_RUN_STATUS_DIR` to an absolute directory.
|
|
174
174
|
When it is unset nothing is written.
|
|
175
175
|
|
|
176
|
-
`machine run
|
|
177
|
-
`<dir>/<runId>.json`, replaced atomically on each
|
|
178
|
-
`0600`):
|
|
176
|
+
`machine run`, the MCP server, and every run hosted through `startMachineHost`
|
|
177
|
+
then keep one file per run, `<dir>/<runId>.json`, replaced atomically on each
|
|
178
|
+
change (directory `0700`, file `0600`):
|
|
179
179
|
|
|
180
180
|
```json
|
|
181
181
|
{
|
|
@@ -198,6 +198,26 @@ choice to publish that text. `machine run` exports its run id as
|
|
|
198
198
|
`MACHINES_RUN_PARENT`, so a Machine started by one of its Operations or Agents
|
|
199
199
|
records that id as `parent`; the status line shows only top-level runs.
|
|
200
200
|
|
|
201
|
+
A run hosted through `startMachineHost` from `@dna113p/machines/host` publishes
|
|
202
|
+
the same record when the variable is set in the hosting process, for example a
|
|
203
|
+
daemon that runs tickets, or Pi with the extension loaded. Two options replace the environment for that one run:
|
|
204
|
+
`label`, shortened as `MACHINES_RUN_LABEL` is, and `owner`. When an option is
|
|
205
|
+
absent the environment rule above applies; an empty one publishes the run
|
|
206
|
+
without that field. The run exports its id as `MACHINES_RUN_PARENT` to its
|
|
207
|
+
Operations and Agents, as `machine run` does. The host publishes, not the hosted
|
|
208
|
+
process, so `pid` is the host's: a hosted process that is terminated or exits is
|
|
209
|
+
recorded as `failed` with the reason, and a run whose host is gone shows as
|
|
210
|
+
`lost`. The value `startMachineHost` resolves to carries the run's `id`, which
|
|
211
|
+
is the id of its record; a session built on it, such as Pi's, reports that same
|
|
212
|
+
id for the run, so it is the id to give `machine watch`.
|
|
213
|
+
|
|
214
|
+
Such a record carries `"answeredBy": "host"`. Its Human requests are answered
|
|
215
|
+
only through the host's own `respond` and `ask`: the run reads no inbox, its
|
|
216
|
+
`human` has no `requestId`, and `machine respond` refuses it. The field is absent
|
|
217
|
+
from every other record and did not exist in earlier version 2 records. A caller
|
|
218
|
+
that publishes its own record for the run, as the MCP server does so that its
|
|
219
|
+
runs keep their inbox, passes `publishRunStatus: false`.
|
|
220
|
+
|
|
201
221
|
While an Agent state waits for a [usage limit](authoring.md#usage-limits) to
|
|
202
222
|
reset, the record stays `running` and carries the wait in `limit`:
|
|
203
223
|
|
|
@@ -253,13 +273,21 @@ whatever answers it.
|
|
|
253
273
|
```bash
|
|
254
274
|
machine runs # running and waiting runs
|
|
255
275
|
machine runs --json # the same runs as their records
|
|
276
|
+
machine runs --all # every published run, also finished and lost ones still recorded
|
|
256
277
|
machine respond <run-id> <request-id> <response>
|
|
257
278
|
machine respond --question <run-id> <request-id> <text>
|
|
258
279
|
```
|
|
259
280
|
|
|
260
281
|
`machine runs` prints each run's id, Machine, status, and state, and for a
|
|
261
|
-
waiting run its prompt, request id, and choices (tab-separated when its
|
|
262
|
-
is not a terminal)
|
|
282
|
+
waiting run its prompt, request id, and choices (tab-separated when its
|
|
283
|
+
output is not a terminal). A run's label follows as a fifth column when it has
|
|
284
|
+
one, and a sixth says `watched` for a run this session
|
|
285
|
+
[watches](#watching-other-runs). It lists the running and waiting runs of every
|
|
286
|
+
session and project. `--all`, with or without `--json`, lists every published
|
|
287
|
+
run: also the ones whose records remain after they finished, as `completed` or
|
|
288
|
+
`failed`, or after their process disappeared, as `lost` (in JSON such a record
|
|
289
|
+
keeps the status it last published). A run answered through its host shows
|
|
290
|
+
`answered through its host` in place of a request id:
|
|
263
291
|
|
|
264
292
|
```text
|
|
265
293
|
1a2b3c4d-… release waiting approve
|
|
@@ -269,9 +297,9 @@ is not a terminal):
|
|
|
269
297
|
```
|
|
270
298
|
|
|
271
299
|
`machine respond` checks the response against the current record before it
|
|
272
|
-
delivers anything. It fails with a nonzero exit when the run is unknown, is
|
|
273
|
-
waiting, is waiting on a different request id,
|
|
274
|
-
that do not include the response. `--question` sends a discussion question
|
|
300
|
+
delivers anything. It fails with a nonzero exit when the run is unknown, is
|
|
301
|
+
answered through its host, is not waiting, is waiting on a different request id,
|
|
302
|
+
or restricts `choices` to values that do not include the response. `--question` sends a discussion question
|
|
275
303
|
instead of an answer and is refused unless the request has `discussion: true`.
|
|
276
304
|
Words after the request id are joined into one response; put `--` before a
|
|
277
305
|
response that starts with a dash.
|
|
@@ -309,6 +337,8 @@ A published run started without terminal input, for example by a coordinating
|
|
|
309
337
|
agent or a script, waits for `machine respond` instead of failing, and prints
|
|
310
338
|
the exact command to its standard error. With publication off it fails at its
|
|
311
339
|
first Human state with "Terminal input closed before a response", as before.
|
|
340
|
+
Runs hosted in any other program through `startMachineHost` are answered only
|
|
341
|
+
through that program.
|
|
312
342
|
Runs hosted by the MCP server take a response from their own `machine_respond`
|
|
313
343
|
tool or from the inbox. That tool is answer-only, so a question to a hosted
|
|
314
344
|
request that allows discussion goes through the inbox with
|
|
@@ -350,13 +380,62 @@ prints one to three lines per run and nothing when there is nothing to show:
|
|
|
350
380
|
Publish version 0.4.0?
|
|
351
381
|
```
|
|
352
382
|
|
|
353
|
-
A session sees the runs it owns
|
|
354
|
-
|
|
383
|
+
A session sees the runs it owns and the runs it
|
|
384
|
+
[watches](#watching-other-runs); runs without an owner also appear in sessions
|
|
385
|
+
whose project directory contains the run's working directory. Finished runs stay for
|
|
355
386
|
30 seconds. A run whose process disappeared without finishing is shown as `lost`
|
|
356
387
|
for the same time, so the status line must run where it can see that process.
|
|
357
388
|
Set `NO_COLOR` (to any value, even an empty one) for plain text and `COLUMNS`
|
|
358
389
|
to change the 120-column truncation.
|
|
359
390
|
|
|
391
|
+
### Watching other runs
|
|
392
|
+
|
|
393
|
+
A session can pull in runs it did not start: a ticket that a daemon runs, or a
|
|
394
|
+
run started by another session or under another project. Nothing is configured.
|
|
395
|
+
The session is identified as a run's `owner` is, by `MACHINES_RUN_OWNER` and
|
|
396
|
+
otherwise `CLAUDE_CODE_SESSION_ID`, so an agent inside Claude Code only runs the
|
|
397
|
+
command.
|
|
398
|
+
|
|
399
|
+
```bash
|
|
400
|
+
machine runs # running and waiting runs, with their labels
|
|
401
|
+
machine runs --all # every published run, also finished and lost ones
|
|
402
|
+
machine watch org-9 # runs whose id or label starts with org-9
|
|
403
|
+
machine watch --dir ../other # runs working in that directory or below it
|
|
404
|
+
machine watch # what this session watches, and the published runs each entry matches now
|
|
405
|
+
machine unwatch org-9 # also: unwatch --dir <path>, unwatch --all
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
- `machine watch <text>` names every top-level run whose id starts with the
|
|
409
|
+
text, or whose label starts with it as a whole leading part: the label ends
|
|
410
|
+
there or goes on with something other than a letter, a digit, `-`, or `_`.
|
|
411
|
+
`org-9` matches the labels `org-9 → machines: …` and `org-9: …`, not
|
|
412
|
+
`org-90 …`. Matching is case-sensitive, and the text is one non-empty line.
|
|
413
|
+
- `machine watch --dir <path>` names every top-level run whose working
|
|
414
|
+
directory is that path or inside it. The path is kept absolute.
|
|
415
|
+
- An entry keeps matching runs that start later, so a ticket can be watched
|
|
416
|
+
before its run exists. `machine watch` lists a matched run for as long as its
|
|
417
|
+
record remains, with the status `machine runs --all` gives it, so it also
|
|
418
|
+
says how a watched run ended.
|
|
419
|
+
|
|
420
|
+
A watched run is shown even when another session owns it or it works outside
|
|
421
|
+
the project. Everything else is as for any run: sub-runs stay hidden, a run is
|
|
422
|
+
listed once however many entries name it, and at most five runs are shown.
|
|
423
|
+
|
|
424
|
+
The list is one file per session beside the records,
|
|
425
|
+
`<dir>/.watch.<session id>.json` (`0600`, replaced atomically), which the
|
|
426
|
+
status line reads itself with the `session_id` Claude Code gives it:
|
|
427
|
+
|
|
428
|
+
```json
|
|
429
|
+
{ "schemaVersion": 1, "watches": [{ "text": "org-9" }, { "dir": "/work/other" }] }
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Record readers skip it, as they skip every name that starts with a dot. A
|
|
433
|
+
session id that is not safe as a file name is refused. Removing the last entry
|
|
434
|
+
removes the file, and writing a list removes other sessions' lists that have
|
|
435
|
+
not changed for 30 days. A missing, unreadable, or malformed list watches
|
|
436
|
+
nothing; it is never an error on the status line. The commands fail with a
|
|
437
|
+
message when publication is off or when no variable names the session.
|
|
438
|
+
|
|
360
439
|
## Troubleshooting
|
|
361
440
|
|
|
362
441
|
For an isolated global catalog, set `MACHINES_USER_HOME` to an absolute directory;
|
package/package.json
CHANGED
|
@@ -103,6 +103,11 @@ polling continuously. Interpret the current snapshot:
|
|
|
103
103
|
Preserve existing user authorization when answering Human requests, and surface a
|
|
104
104
|
new decision when the request requires one. A desire to finish is not an answer.
|
|
105
105
|
|
|
106
|
+
To follow a run this session did not start, such as a ticket a daemon runs, use
|
|
107
|
+
`machine watch <ticket-or-run-id>` where run status is published (`machine runs`
|
|
108
|
+
lists what can be watched; `--all` adds runs that already ended). Watching only
|
|
109
|
+
shows the run on this session's status line: it neither owns nor answers it.
|
|
110
|
+
|
|
106
111
|
The CLI fallback is `machine run <name> [--agent role=preset] -- <input>` in the
|
|
107
112
|
target workspace. It waits for completion and uses terminal Human input; retain
|
|
108
113
|
the shell session if running it asynchronously. It does not return an MCP run ID.
|