@dna113p/machines 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/dist/codex/machines/ui/machines.html +286 -0
- package/dist/examples/agent.d.ts +1 -0
- package/dist/examples/agent.js +19 -0
- package/dist/examples/choices.d.ts +1 -0
- package/dist/examples/choices.js +17 -0
- package/dist/examples/host.d.ts +3 -0
- package/dist/examples/host.js +32 -0
- package/dist/examples/human.d.ts +1 -0
- package/dist/examples/human.js +17 -0
- package/dist/examples/named-agents.d.ts +1 -0
- package/dist/examples/named-agents.js +38 -0
- package/dist/examples/operation.d.ts +1 -0
- package/dist/examples/operation.js +10 -0
- package/dist/examples/pi.d.ts +1 -0
- package/dist/examples/pi.js +29 -0
- package/dist/mcp/server.d.ts +17 -0
- package/dist/mcp/server.js +313 -0
- package/dist/pi-extension/index.d.ts +52 -0
- package/dist/pi-extension/index.js +390 -0
- package/dist/src/acp.d.ts +7 -0
- package/dist/src/acp.js +184 -0
- package/dist/src/agent-bindings.d.ts +15 -0
- package/dist/src/agent-bindings.js +113 -0
- package/dist/src/catalog.d.ts +31 -0
- package/dist/src/catalog.js +68 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +392 -0
- package/dist/src/discovery-worker.d.ts +3 -0
- package/dist/src/discovery-worker.js +52 -0
- package/dist/src/discovery.d.ts +8 -0
- package/dist/src/discovery.js +94 -0
- package/dist/src/host.d.ts +25 -0
- package/dist/src/host.js +357 -0
- package/dist/src/index.d.ts +210 -0
- package/dist/src/index.js +204 -0
- package/dist/src/launcher.d.ts +19 -0
- package/dist/src/launcher.js +98 -0
- package/dist/src/machine-module.d.ts +3 -0
- package/dist/src/machine-module.js +55 -0
- package/dist/src/session.d.ts +40 -0
- package/dist/src/session.js +165 -0
- package/dist/src/terminal-human.d.ts +2 -0
- package/dist/src/terminal-human.js +100 -0
- package/docs/architecture.md +111 -0
- package/docs/authoring.md +193 -0
- package/docs/integrations.md +142 -0
- package/package.json +58 -0
- package/skills/machine-builder/SKILL.md +34 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { startMachineHost } from "./host.js";
|
|
3
|
+
/** Owns runs from startup to disposal. Presentation belongs to its subscribers. */
|
|
4
|
+
export class MachineSession {
|
|
5
|
+
#runs = new Map();
|
|
6
|
+
#starting = new Set();
|
|
7
|
+
#listeners = new Set();
|
|
8
|
+
#closed = false;
|
|
9
|
+
subscribe(listener) {
|
|
10
|
+
this.#listeners.add(listener);
|
|
11
|
+
return () => this.#listeners.delete(listener);
|
|
12
|
+
}
|
|
13
|
+
async start(options) {
|
|
14
|
+
if (this.#closed)
|
|
15
|
+
throw new Error("Machine session is closed");
|
|
16
|
+
const cancellation = new AbortController();
|
|
17
|
+
this.#starting.add(cancellation);
|
|
18
|
+
const id = randomUUID();
|
|
19
|
+
const startedAt = Date.now();
|
|
20
|
+
const update = (type, change) => {
|
|
21
|
+
const run = this.#runs.get(id);
|
|
22
|
+
if (run === undefined || !isActive(run.status))
|
|
23
|
+
return;
|
|
24
|
+
change(run);
|
|
25
|
+
run.updatedAt = Date.now();
|
|
26
|
+
this.#emit(type, run);
|
|
27
|
+
};
|
|
28
|
+
let host;
|
|
29
|
+
try {
|
|
30
|
+
host = await startMachineHost({
|
|
31
|
+
...options,
|
|
32
|
+
signal: cancellation.signal,
|
|
33
|
+
onState: (state) => update("updated", (run) => {
|
|
34
|
+
if (JSON.stringify(run.state) !== JSON.stringify(state))
|
|
35
|
+
run.agent = undefined;
|
|
36
|
+
run.state = state;
|
|
37
|
+
}),
|
|
38
|
+
onAgentUpdate: (agent) => {
|
|
39
|
+
if (agent.type === "identity")
|
|
40
|
+
update("updated", (run) => { run.agent = agent; });
|
|
41
|
+
},
|
|
42
|
+
onHumanRequest: (request) => update("human", (run) => {
|
|
43
|
+
run.status = "waiting";
|
|
44
|
+
run.human = request;
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
this.#starting.delete(cancellation);
|
|
50
|
+
}
|
|
51
|
+
if (this.#closed) {
|
|
52
|
+
host.terminate();
|
|
53
|
+
throw new Error("Machine session is closed");
|
|
54
|
+
}
|
|
55
|
+
const run = {
|
|
56
|
+
id,
|
|
57
|
+
cwd: options.cwd ?? process.cwd(),
|
|
58
|
+
host,
|
|
59
|
+
startedAt,
|
|
60
|
+
updatedAt: Date.now(),
|
|
61
|
+
status: "running",
|
|
62
|
+
};
|
|
63
|
+
this.#runs.set(id, run);
|
|
64
|
+
void host.result.then(({ state }) => this.#finish(run, { state }), (cause) => this.#finish(run, { error: errorMessage(cause) }));
|
|
65
|
+
this.#emit("updated", run);
|
|
66
|
+
return snapshot(run);
|
|
67
|
+
}
|
|
68
|
+
status(runId) {
|
|
69
|
+
const runs = runId === undefined
|
|
70
|
+
? [...this.#runs.values()].sort((left, right) => right.updatedAt - left.updatedAt)
|
|
71
|
+
: [this.#requireRun(runId)];
|
|
72
|
+
return runs.map(snapshot);
|
|
73
|
+
}
|
|
74
|
+
async respond(input) {
|
|
75
|
+
const run = this.#requireRun(input.runId);
|
|
76
|
+
if (run.status !== "waiting" || run.human === undefined) {
|
|
77
|
+
throw new Error(`Machine run "${input.runId}" is not waiting for Human input`);
|
|
78
|
+
}
|
|
79
|
+
if (run.human.requestId !== input.requestId) {
|
|
80
|
+
throw new Error("Machine Human request is stale; read the current requestId");
|
|
81
|
+
}
|
|
82
|
+
if (run.human.choices !== undefined && !run.human.choices.includes(input.response)) {
|
|
83
|
+
throw new Error(`Expected one of: ${run.human.choices.join(", ")}`);
|
|
84
|
+
}
|
|
85
|
+
// Claim synchronously in the session as well as in the host. New requests or
|
|
86
|
+
// terminal events arriving during IPC must never be overwritten by this reply.
|
|
87
|
+
run.status = "running";
|
|
88
|
+
run.human = undefined;
|
|
89
|
+
run.updatedAt = Date.now();
|
|
90
|
+
const sent = run.host.respond(input.response, input.requestId);
|
|
91
|
+
this.#emit("updated", run);
|
|
92
|
+
await sent;
|
|
93
|
+
return snapshot(run);
|
|
94
|
+
}
|
|
95
|
+
close() {
|
|
96
|
+
if (this.#closed)
|
|
97
|
+
return;
|
|
98
|
+
this.#closed = true;
|
|
99
|
+
this.#listeners.clear();
|
|
100
|
+
for (const cancellation of this.#starting)
|
|
101
|
+
cancellation.abort();
|
|
102
|
+
this.#starting.clear();
|
|
103
|
+
for (const run of this.#runs.values())
|
|
104
|
+
run.host.terminate();
|
|
105
|
+
this.#runs.clear();
|
|
106
|
+
}
|
|
107
|
+
#requireRun(id) {
|
|
108
|
+
const run = this.#runs.get(id);
|
|
109
|
+
if (run === undefined)
|
|
110
|
+
throw new Error(`Machine run "${id}" not found in this session`);
|
|
111
|
+
return run;
|
|
112
|
+
}
|
|
113
|
+
#finish(run, outcome) {
|
|
114
|
+
if (this.#closed || !isActive(run.status))
|
|
115
|
+
return;
|
|
116
|
+
run.status = "state" in outcome ? "completed" : "failed";
|
|
117
|
+
if ("state" in outcome)
|
|
118
|
+
run.state = outcome.state;
|
|
119
|
+
else
|
|
120
|
+
run.error = outcome.error;
|
|
121
|
+
run.human = undefined;
|
|
122
|
+
run.agent = undefined;
|
|
123
|
+
run.updatedAt = Date.now();
|
|
124
|
+
const terminal = [...this.#runs.values()]
|
|
125
|
+
.filter((item) => !isActive(item.status))
|
|
126
|
+
.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
127
|
+
for (const item of terminal.slice(5))
|
|
128
|
+
this.#runs.delete(item.id);
|
|
129
|
+
this.#emit("finished", run);
|
|
130
|
+
}
|
|
131
|
+
#emit(type, run) {
|
|
132
|
+
for (const listener of this.#listeners) {
|
|
133
|
+
try {
|
|
134
|
+
listener({ type, run: snapshot(run) });
|
|
135
|
+
}
|
|
136
|
+
catch (cause) {
|
|
137
|
+
// Rendering and notifications cannot change the outcome of an owned run.
|
|
138
|
+
process.emitWarning(errorMessage(cause), { type: "MachineSessionObserverError" });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export function isActive(status) {
|
|
144
|
+
return status === "running" || status === "waiting";
|
|
145
|
+
}
|
|
146
|
+
function snapshot(run) {
|
|
147
|
+
return {
|
|
148
|
+
id: run.id,
|
|
149
|
+
machine: run.host.name,
|
|
150
|
+
description: run.host.description,
|
|
151
|
+
path: run.host.path,
|
|
152
|
+
cwd: run.cwd,
|
|
153
|
+
status: run.status,
|
|
154
|
+
...(run.state === undefined ? {} : { state: structuredClone(run.state) }),
|
|
155
|
+
startedAt: new Date(run.startedAt).toISOString(),
|
|
156
|
+
updatedAt: new Date(run.updatedAt).toISOString(),
|
|
157
|
+
elapsedSeconds: Math.floor(((isActive(run.status) ? Date.now() : run.updatedAt) - run.startedAt) / 1_000),
|
|
158
|
+
...(run.agent === undefined ? {} : { agent: { ...run.agent } }),
|
|
159
|
+
...(run.human === undefined ? {} : { human: structuredClone(run.human) }),
|
|
160
|
+
...(run.error === undefined ? {} : { error: run.error }),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function errorMessage(cause) {
|
|
164
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
165
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { clearScreenDown, createInterface, cursorTo, emitKeypressEvents, moveCursor, } from "node:readline";
|
|
2
|
+
import { cyan, dim, green } from "yoctocolors";
|
|
3
|
+
export function terminalHuman(request) {
|
|
4
|
+
const values = request.choices ?? request.suggestions ?? [];
|
|
5
|
+
const allowOther = request.suggestions !== undefined;
|
|
6
|
+
if (values.length > 0
|
|
7
|
+
&& process.stdin.isTTY
|
|
8
|
+
&& process.stdout.isTTY
|
|
9
|
+
&& typeof process.stdin.setRawMode === "function") {
|
|
10
|
+
return selectHumanInput(request.prompt, values, allowOther);
|
|
11
|
+
}
|
|
12
|
+
return readFreeformInput(request.prompt, values, allowOther ? "Suggestions" : "Choices");
|
|
13
|
+
}
|
|
14
|
+
function readFreeformInput(prompt, values = [], heading = "Suggestions") {
|
|
15
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
16
|
+
const suggestionText = values.length === 0
|
|
17
|
+
? ""
|
|
18
|
+
: `\n${heading}:\n${values.map((value) => `- ${value}`).join("\n")}`;
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
let answered = false;
|
|
21
|
+
terminal.once("close", () => {
|
|
22
|
+
if (!answered)
|
|
23
|
+
reject(new Error("Terminal input closed before a response"));
|
|
24
|
+
});
|
|
25
|
+
terminal.question(`${prompt}${suggestionText}\n> `, (answer) => {
|
|
26
|
+
answered = true;
|
|
27
|
+
terminal.close();
|
|
28
|
+
resolve(answer);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function selectHumanInput(prompt, values, allowOther) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const choices = allowOther ? [...values, "Other…"] : [...values];
|
|
35
|
+
const initialRawMode = process.stdin.isRaw;
|
|
36
|
+
let selected = 0;
|
|
37
|
+
let renderedLines = 0;
|
|
38
|
+
function clearMenu() {
|
|
39
|
+
if (renderedLines === 0)
|
|
40
|
+
return;
|
|
41
|
+
moveCursor(process.stdout, 0, -renderedLines);
|
|
42
|
+
cursorTo(process.stdout, 0);
|
|
43
|
+
clearScreenDown(process.stdout);
|
|
44
|
+
renderedLines = 0;
|
|
45
|
+
}
|
|
46
|
+
function render() {
|
|
47
|
+
clearMenu();
|
|
48
|
+
const lines = choices.map((choice, index) => (index === selected ? `${cyan("❯")} ${cyan(choice)}` : ` ${choice}`));
|
|
49
|
+
lines.push(dim("↑↓ select · enter confirm"));
|
|
50
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
51
|
+
renderedLines = lines.length;
|
|
52
|
+
}
|
|
53
|
+
function cleanup() {
|
|
54
|
+
process.stdin.off("keypress", onKeypress);
|
|
55
|
+
process.stdin.off("end", onInputClosed);
|
|
56
|
+
process.stdin.off("close", onInputClosed);
|
|
57
|
+
process.stdin.setRawMode(initialRawMode ?? false);
|
|
58
|
+
if (!initialRawMode)
|
|
59
|
+
process.stdin.pause();
|
|
60
|
+
}
|
|
61
|
+
function onInputClosed() {
|
|
62
|
+
cleanup();
|
|
63
|
+
reject(new Error("Terminal input closed before a response"));
|
|
64
|
+
}
|
|
65
|
+
function onKeypress(_character, key) {
|
|
66
|
+
if (key.ctrl && key.name === "c") {
|
|
67
|
+
clearMenu();
|
|
68
|
+
cleanup();
|
|
69
|
+
process.kill(process.pid, "SIGINT");
|
|
70
|
+
}
|
|
71
|
+
else if (key.name === "up" || key.name === "down") {
|
|
72
|
+
const direction = key.name === "up" ? -1 : 1;
|
|
73
|
+
selected = (selected + direction + choices.length) % choices.length;
|
|
74
|
+
render();
|
|
75
|
+
}
|
|
76
|
+
else if (key.name === "return" || key.name === "enter") {
|
|
77
|
+
const answer = choices[selected];
|
|
78
|
+
if (answer === undefined)
|
|
79
|
+
return;
|
|
80
|
+
clearMenu();
|
|
81
|
+
cleanup();
|
|
82
|
+
if (!allowOther || selected < values.length) {
|
|
83
|
+
process.stdout.write(`${green("✔")} ${answer}\n`);
|
|
84
|
+
resolve(answer);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
void readFreeformInput("Feedback").then(resolve, reject);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
process.stdout.write(`${prompt}\n\n`);
|
|
92
|
+
emitKeypressEvents(process.stdin);
|
|
93
|
+
process.stdin.setRawMode(true);
|
|
94
|
+
process.stdin.on("keypress", onKeypress);
|
|
95
|
+
process.stdin.once("end", onInputClosed);
|
|
96
|
+
process.stdin.once("close", onInputClosed);
|
|
97
|
+
process.stdin.resume();
|
|
98
|
+
render();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
Machines separates workflow decisions from execution and presentation. A Machine
|
|
4
|
+
can say “review after implementation” without knowing whether an Agent uses ACP,
|
|
5
|
+
a Human uses a terminal, or a run is displayed in Codex.
|
|
6
|
+
|
|
7
|
+
## Vocabulary and ownership
|
|
8
|
+
|
|
9
|
+
| Concept | Responsibility |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| Machine | Finite-state policy mapping returned events to the next state |
|
|
12
|
+
| State | Named position invoking one Agent, Human, or Operation, or a final state |
|
|
13
|
+
| Agent | Bounded request implemented by an `AgentRunner` |
|
|
14
|
+
| Operation | Trusted local function returning an event |
|
|
15
|
+
| Human | Input request returning a `submitted` event with the response |
|
|
16
|
+
| Run | One execution of a Machine |
|
|
17
|
+
| Adapter | Mechanism connecting a primitive or run to an external interface |
|
|
18
|
+
|
|
19
|
+
XState owns transitions and actor lifecycle. Each primitive returns a semantic
|
|
20
|
+
event; the active state declares its accepted event types. Adapters return events
|
|
21
|
+
and report observations. Destination states stay in the Machine definition.
|
|
22
|
+
|
|
23
|
+
Workflow-specific retries, escalation, approval, merging, and cleanup belong in
|
|
24
|
+
Machines. Add shared runtime behavior when multiple real workflows require the
|
|
25
|
+
same mechanism and cannot reasonably own it themselves.
|
|
26
|
+
|
|
27
|
+
## Module boundaries
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
CLI ──────────────────────→ launcher → runtime → AgentRunner / HumanRunner
|
|
31
|
+
Pi extension ──┐ ↑
|
|
32
|
+
├─→ session → child host ─┘
|
|
33
|
+
MCP server ────┘ ↑
|
|
34
|
+
└── snapshots and notifications → adapter presentation
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| Module | Owns |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `src/index.ts` | Typed primitives, Machine validation, XState execution, observer contracts |
|
|
40
|
+
| `src/terminal-human.ts` | Default terminal Human input, selectors, raw-mode restoration |
|
|
41
|
+
| `src/machine-module.ts` | Shared module contract and metadata validation |
|
|
42
|
+
| `src/catalog.ts` | Definition and preset catalog reads |
|
|
43
|
+
| `src/acp.ts` | ACP process/session protocol, bounded Agent result, normalized activity |
|
|
44
|
+
| `src/discovery.ts` | Project/global definition locations and precedence |
|
|
45
|
+
| `src/launcher.ts` | Definition metadata, Agent bindings, preflight, single-use launch preparation |
|
|
46
|
+
| `src/discovery-worker.ts` | Fresh metadata imports in disposable workers |
|
|
47
|
+
| `src/host.ts` | One child run, IPC, Human request identity, owned process termination |
|
|
48
|
+
| `src/session.ts` | Shared Pi/MCP run lifecycle, snapshots, response handling, retention, shutdown |
|
|
49
|
+
| `src/cli.ts` | Command parsing, terminal progress, source inspection |
|
|
50
|
+
| `pi-extension/index.ts` | Pi tools, widgets, notifications, native input presentation |
|
|
51
|
+
| `mcp/server.ts` | MCP schemas, tool results, and the optional HTML resource |
|
|
52
|
+
| `codex/machines/ui/machines.html` | Status rendering and submission of the current Human request |
|
|
53
|
+
|
|
54
|
+
## Discovery and launch
|
|
55
|
+
|
|
56
|
+
Discovery combines the nearest project directory with the global directory. Each
|
|
57
|
+
listing uses a disposable worker so edits to definitions, presets, and imported
|
|
58
|
+
helpers are visible without restarting the interface. An invalid definition has
|
|
59
|
+
an `error` in its listing; valid definitions remain available. The CLI displays
|
|
60
|
+
all entries and exits unsuccessfully if any definition is invalid.
|
|
61
|
+
|
|
62
|
+
Workers isolate module caches and ordinary output, not file or network permissions.
|
|
63
|
+
Importing metadata still executes code. Definition factories run during launch
|
|
64
|
+
preparation, so side effects that require a successful preflight belong inside an
|
|
65
|
+
Operation rather than the factory.
|
|
66
|
+
|
|
67
|
+
The launcher resolves required semantic Agent roles and configured presets before
|
|
68
|
+
starting the actor. Missing bindings, invalid overrides, or invalid definitions
|
|
69
|
+
fail preflight. The resulting prepared run can be started once. Long-lived hosted
|
|
70
|
+
runs get a fresh child process; direct runtime users own their own definitions.
|
|
71
|
+
|
|
72
|
+
## Session and process lifecycle
|
|
73
|
+
|
|
74
|
+
Pi and MCP delegate to the same session module. The session owns hosts from the
|
|
75
|
+
start of launch, keeps authoritative snapshots, and closes permanently. Presentation
|
|
76
|
+
adapters subscribe to changes and format them for their own surfaces.
|
|
77
|
+
|
|
78
|
+
Each pending Human prompt has a unique request ID. A response names both the run
|
|
79
|
+
and that request. Claiming a request before asynchronous delivery prevents two
|
|
80
|
+
answers from being accepted, and the identity prevents a stale card or dialog from
|
|
81
|
+
answering a later prompt. Restricted choices are validated for every interface.
|
|
82
|
+
|
|
83
|
+
Closing a session terminates active and starting hosts. Host termination includes
|
|
84
|
+
ordinary descendants in the host's owned process group on POSIX and process-tree
|
|
85
|
+
termination on Windows. Deliberately detached processes can escape normal ownership;
|
|
86
|
+
Machines should await work they own and avoid launching untracked background jobs.
|
|
87
|
+
Abrupt operating-system termination cannot guarantee cleanup. Stopping processes
|
|
88
|
+
also cannot reverse a completed external action.
|
|
89
|
+
The automated process-lifecycle checks run on Linux; the Windows termination path
|
|
90
|
+
has not been exercised in this environment.
|
|
91
|
+
|
|
92
|
+
Human drafts and focus belong to the presentation adapter while the request ID is
|
|
93
|
+
unchanged. The card polls authoritative status; it does not decide transitions or
|
|
94
|
+
persist a second copy of the workflow state.
|
|
95
|
+
|
|
96
|
+
## Distribution
|
|
97
|
+
|
|
98
|
+
Development runs source TypeScript on Node 24. `npm run build` emits JavaScript and
|
|
99
|
+
declarations under `dist/`, rewrites relative TypeScript import extensions, and
|
|
100
|
+
copies UI assets at the same relative paths used by the server. The npm package
|
|
101
|
+
exports built entry points so runtime loading works inside `node_modules`.
|
|
102
|
+
|
|
103
|
+
`npm run build:plugin` assembles a self-contained directory with built code, UI,
|
|
104
|
+
authoring instructions, and production dependencies. It needs Node on PATH but
|
|
105
|
+
has no dependency on the source checkout after copying. Packaging smoke tests
|
|
106
|
+
exercise a real installed tarball and a relocated plugin, including discovery,
|
|
107
|
+
the MCP widget resource, and a hosted Human response.
|
|
108
|
+
|
|
109
|
+
The earlier implementation plans are retained in the repository's
|
|
110
|
+
[history directory](https://github.com/dna113p/machines/tree/main/docs/history) for rationale.
|
|
111
|
+
Their phase ordering and manual proof gates describe past work.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Authoring Machines
|
|
2
|
+
|
|
3
|
+
## Definition contract
|
|
4
|
+
|
|
5
|
+
Store a workflow in `.machines/name.ts` or `.machines/name/index.ts` in the project.
|
|
6
|
+
The [README quickstart](../README.md#run-your-own-workflows) is a complete minimal
|
|
7
|
+
definition. Export a non-empty, one-line `description` and a default factory:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import type { MachinePrimitives } from "@dna113p/machines";
|
|
11
|
+
|
|
12
|
+
export const description = "Prints a supplied message after a Human confirms it.";
|
|
13
|
+
|
|
14
|
+
export default function printMessage(
|
|
15
|
+
{ machine, operation, human, final }: MachinePrimitives,
|
|
16
|
+
input: string,
|
|
17
|
+
) {
|
|
18
|
+
return machine({
|
|
19
|
+
initial: "confirm",
|
|
20
|
+
states: {
|
|
21
|
+
confirm: human(`Print ${JSON.stringify(input)}?`, {
|
|
22
|
+
submitted: "print",
|
|
23
|
+
}, { choices: ["print"] }),
|
|
24
|
+
print: operation(() => {
|
|
25
|
+
console.log(input);
|
|
26
|
+
return { type: "completed" };
|
|
27
|
+
}, { completed: "done" }),
|
|
28
|
+
done: final(),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Factories receive primitives, so workflow execution need not resolve the runtime
|
|
35
|
+
package from a global `~/.machines/` folder. A type-only import is erased by Node;
|
|
36
|
+
installing the local package makes those types available to your editor.
|
|
37
|
+
|
|
38
|
+
Keep top-level evaluation free of side effects. Discovery imports the definition
|
|
39
|
+
and its helpers to inspect metadata. Keep effects inside states: the launcher
|
|
40
|
+
calls the factory before it finishes Agent preflight.
|
|
41
|
+
|
|
42
|
+
## Events and Human input
|
|
43
|
+
|
|
44
|
+
An Operation or Agent returns `{ type: "eventName", ...data }`. Its state maps
|
|
45
|
+
that type to a target. An unexpected event fails the run. Human returns
|
|
46
|
+
`{ type: "submitted", value: response }`; use an XState transition action or guard
|
|
47
|
+
to inspect `event.value`.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
human("Feedback?", { submitted: "revise" });
|
|
51
|
+
human("Approve or give feedback", { submitted: "decide" }, {
|
|
52
|
+
suggestions: ["approve"],
|
|
53
|
+
});
|
|
54
|
+
human("Choose the outcome", { submitted: "decide" }, {
|
|
55
|
+
choices: ["approve", "deny"],
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Suggestions preserve free-form input. Explicit choices are restrictive and are
|
|
60
|
+
validated by the runtime across terminal, injected, Pi, and MCP Human runners.
|
|
61
|
+
Both properties cannot be supplied together. Terminal selectors support arrow
|
|
62
|
+
keys; piped input stays plain text.
|
|
63
|
+
|
|
64
|
+
Here is a complete Agent/Human feedback loop. Save as `.machines/review-task.ts`:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import type { Event, MachinePrimitives } from "@dna113p/machines";
|
|
68
|
+
|
|
69
|
+
export const description = "Implements a task and revises it until the Human approves.";
|
|
70
|
+
export const agentRoles = { implementer: "Implements the task and review feedback" };
|
|
71
|
+
|
|
72
|
+
export default function reviewTask(
|
|
73
|
+
{ machine, agent, human, final }: MachinePrimitives,
|
|
74
|
+
input: string,
|
|
75
|
+
) {
|
|
76
|
+
let feedback = "";
|
|
77
|
+
return machine({
|
|
78
|
+
initial: "implement",
|
|
79
|
+
states: {
|
|
80
|
+
implement: agent(
|
|
81
|
+
() => `${input}\n${feedback}`,
|
|
82
|
+
{ completed: "review" },
|
|
83
|
+
{ using: "implementer", cwd: process.cwd() },
|
|
84
|
+
),
|
|
85
|
+
review: human("Approve or describe changes", {
|
|
86
|
+
submitted: [
|
|
87
|
+
{ guard: ({ event }: { event: Event }) => event.value === "approve", target: "done" },
|
|
88
|
+
{
|
|
89
|
+
target: "implement",
|
|
90
|
+
actions: ({ event }: { event: Event }) => { feedback = String(event.value); },
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
}, { suggestions: ["approve"] }),
|
|
94
|
+
done: final(),
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The Machine owns this loop. An Agent runner only returns an allowed outcome; it
|
|
101
|
+
does not decide destination states. Add verification Operations where the workflow
|
|
102
|
+
has a concrete condition to check.
|
|
103
|
+
|
|
104
|
+
## Agent presets
|
|
105
|
+
|
|
106
|
+
Named Agent states select a semantic role with `using`. Declare each named role in
|
|
107
|
+
`agentRoles` with a one-line description. Unnamed states use `default`.
|
|
108
|
+
|
|
109
|
+
The launcher provides `default` through `npx -y pi-acp`. For deterministic local
|
|
110
|
+
practice, this `.machines/agents.ts` preset satisfies the example role without a
|
|
111
|
+
model or credentials:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
export default () => ({
|
|
115
|
+
implementer: {
|
|
116
|
+
description: "Demonstrates the review loop without changing files",
|
|
117
|
+
runner: async () => ({ type: "completed" }),
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Run `machine agents`, then `machine run review-task "Demonstrate the loop"`.
|
|
123
|
+
Supply feedback once, then `approve`, to exercise both transitions.
|
|
124
|
+
|
|
125
|
+
To use a real ACP harness, replace the preset with:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
export default ({ acpAgent }: { acpAgent: typeof import("@dna113p/machines/acp").acpAgent }) => ({
|
|
129
|
+
implementer: {
|
|
130
|
+
description: "Uses the locally configured Pi Agent to implement changes",
|
|
131
|
+
harness: "pi-acp",
|
|
132
|
+
runner: acpAgent("npx", ["-y", "pi-acp"], {
|
|
133
|
+
harness: "pi-acp",
|
|
134
|
+
output: "capture",
|
|
135
|
+
}),
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Install/configure the selected harness and its model credentials first. `acpAgent`
|
|
141
|
+
accepts any compatible ACP command and argument array; the Pi adapter uses ACP as
|
|
142
|
+
its supported execution path. The former `machines/pi` export has been retired;
|
|
143
|
+
use `acpAgent` from `machines/acp` for Pi execution.
|
|
144
|
+
|
|
145
|
+
Global presets live in `~/.machines/agents.ts`; project presets replace matching
|
|
146
|
+
global names. Each preset requires a description and runner. Optional `harness`,
|
|
147
|
+
`model`, and `thinking` fields are discovery labels, while the runner reports its
|
|
148
|
+
actual identity during execution. Harness configuration belongs in the runner
|
|
149
|
+
command, arguments, or `env` option; labels do not change model settings.
|
|
150
|
+
|
|
151
|
+
Use `machine agents` to inspect configured names. Rebind a role for one run:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
machine run review-task --agent implementer=anotherConfiguredPreset -- "Implement the task"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Repeat `--agent role=preset` for multiple overrides. `--` ends launcher options.
|
|
158
|
+
The launcher rejects unknown roles/presets, duplicate overrides, and missing roles
|
|
159
|
+
before starting the actor. `machine list` refreshes metadata on each call and
|
|
160
|
+
reports missing bindings or invalid definitions.
|
|
161
|
+
`ready` describes valid metadata and resolved roles; it does not verify that a
|
|
162
|
+
real harness is installed, authenticated, or able to run the requested model.
|
|
163
|
+
|
|
164
|
+
## Programmatic execution and verification
|
|
165
|
+
|
|
166
|
+
Direct runtime users supply runners through `run(definition, { agents, human })`.
|
|
167
|
+
An `AgentRunner` receives `prompt`, allowed `outcomes`, and optional `cwd`, plus an
|
|
168
|
+
optional reporter for `identity`, `output`, and `tool` updates. It returns one event.
|
|
169
|
+
A `HumanRunner` receives the prompt, suggestions, or choices and returns a string.
|
|
170
|
+
|
|
171
|
+
For example, exercise the feedback loop in an installed project:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { prepareMachineRun } from "@dna113p/machines/launcher";
|
|
175
|
+
|
|
176
|
+
const answers = ["Please revise", "approve"];
|
|
177
|
+
const prepared = await prepareMachineRun({
|
|
178
|
+
cwd: process.cwd(),
|
|
179
|
+
machine: "review-task",
|
|
180
|
+
input: "Demonstrate the loop",
|
|
181
|
+
});
|
|
182
|
+
const result = await prepared.start({ human: () => answers.shift() ?? "approve" });
|
|
183
|
+
console.log(result.value); // done
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Use the fake preset above for deterministic verification. A prepared run is
|
|
187
|
+
single-use. Omit `human` to retain terminal input. For direct runtime examples,
|
|
188
|
+
see the built package's `dist/examples/operation.js`, `human.js`, and
|
|
189
|
+
`named-agents.js`; source equivalents live in the checkout's `examples/`.
|
|
190
|
+
|
|
191
|
+
Verify the final state and the expected effect, including a feedback/error branch
|
|
192
|
+
when it changes behavior. Real Agent execution is a separate check that requires
|
|
193
|
+
the configured harness and any external authorization for the task.
|