@llblab/pi-actors 0.18.0 → 0.19.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/AGENTS.md +2 -0
- package/BACKLOG.md +0 -15
- package/CHANGELOG.md +5 -0
- package/package.json +1 -1
- package/recipes/coordinator-locker.json +1 -1
- package/recipes/locker.json +45 -0
- package/recipes/pipeline-room-swarm.json +3 -1
- package/scripts/coordinator.mjs +434 -0
- package/scripts/{coordinator-locker.mjs → locker.mjs} +23 -22
- package/skills/actors/SKILL.md +2 -1
- package/skills/swarm/SKILL.md +1 -1
- package/scripts/room-swarm.mjs +0 -243
package/AGENTS.md
CHANGED
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
- `Release artifact hygiene`: PR/release summaries become stale during active branch work and do not belong in the repository documentation tree | Trigger: Preparing release notes or PR bodies | Action: Create temporary/operator-facing artifacts outside the repo only during explicit release finalization; keep durable release evidence in `CHANGELOG.md` and open gates in `BACKLOG.md`
|
|
53
53
|
- `Graceful actor retirement`: Coordinator/helper actors that exist only to supervise a bounded worker tree should have explicit retirement semantics instead of relying on the operator or LLM to remember cleanup | Trigger: Designing coordinator recipes, helper actors, worker fanout, locker-backed swarms, or auto-stop behavior | Action: Make retirement opt-in through recipe/run metadata, retire only after observed child actors or descendant workers are terminal and outputs are flushed, prefer graceful control messages before process termination, record retirement events, and never infer retirement for persistent services or backlog implementers
|
|
54
54
|
- `Persistent implementer workflows are recipe composition`: Backlog implementer scenarios should be launched through reusable component recipes, not one-off scripts or ad hoc shell orchestration | Trigger: Designing implementer swarms, backlog workers, coordinator-assigned task loops, or related recipes | Action: Compose cells such as `coordinator-locker`, subagent launchers, and actor-message utilities; preserve JSON envelope object shape across handoffs; add missing reusable component recipes only when needed; update the actors skill launcher map with supported scenarios
|
|
55
|
+
- `Modular coordination and separate lock state`: The coordination of multi-agent workflows is split into two cleanly decoupled layers: the active coordinator and the stateful locker. The locker manages task queueing and resource lock leases over Unix FIFO/pipes without project policy. The coordinator script (`scripts/coordinator.mjs`) manages process pools, rooms, and lifecycles, and supports different pluggable mode strategies (`pipeline`, `fanout`, `pool`, `consensus`). | Trigger: Modifying coordination scripts, queues, locking, or parallel worker flows | Action: Keep the locker generic and thin, and implement all orchestration strategy rules inside the multi-mode coordinator.
|
|
56
|
+
- `Active branch inbox queues`: Direct branch messages are active, work-triggering inbox queues rather than passive files. During subagent execution, the coordinator automatically claims (`claimed`), injects, and handles (`handled`/`failed`) queued branch-local direct messages to allow interactive/resumable worker workflows. | Trigger: Delivering branch messages, executing subagents, or updating branch queues | Action: Ensure direct messages can continue or wake long-lived branch runners, and keep the FIFO queue status transitions clean and fully tested.
|
|
55
57
|
- `Recipe library growth is demand-driven`: Packaged recipes should grow from concrete repeated task patterns, not speculative scenario catalogs | Trigger: Adding packaged utilities, pipelines, or component recipes | Action: Prefer existing component composition, keep recipes policy-light with caller-owned prompts/models/paths/knobs, avoid scenario-specific scripts when existing components suffice, and document new reusable launch scenarios in the actors skill only after the recipe exists
|
|
56
58
|
- `Context sync`: Meaningful implementation or docs changes must reconcile `BACKLOG.md`, `CHANGELOG.md`, README, and docs navigation | Trigger: Closing, narrowing, or discovering work | Action: Run the context validator before final status when practical
|
|
57
59
|
- `Public path hygiene`: Published docs must not include machine-local absolute paths | Trigger: Adding validation commands, examples, or local instructions to README/AGENTS/docs/changelog | Action: Use `~/.pi/...`, `<repo>/...`, `${SKILL_DIR}/...`, or relative paths
|
package/BACKLOG.md
CHANGED
|
@@ -2,19 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
## Open Work
|
|
4
4
|
|
|
5
|
-
### Direct Actor Inbox Queue Semantics
|
|
6
|
-
|
|
7
|
-
- Priority: High.
|
|
8
|
-
- Goal: Make direct actor-to-actor messages initiating work items, not passive files that the receiving actor may or may not inspect.
|
|
9
|
-
- Direction:
|
|
10
|
-
- Deliver direct messages into the recipient actor's next prompt/context as soon as the branch runner can accept work.
|
|
11
|
-
- Keep the model simple: FIFO direct messages, no priority tiers unless real usage proves they are needed.
|
|
12
|
-
- Keep room messages as shared transcript only; a direct message may ask the recipient to inspect room history when broader context is needed.
|
|
13
|
-
- Wire branch runner protocols to claim/handle/fail messages from the current inspectable per-branch inbox files (`branches/<branch>/inbox.jsonl`, surfaced through `inspect branch:<run>/<branch> view=mailbox`); internal helpers can already transition queued messages to claimed/handled/failed for retries.
|
|
14
|
-
- Exit:
|
|
15
|
-
- A direct branch message can wake or continue a long-lived branch runner without waiting for that runner to poll room files.
|
|
16
|
-
- Room transcript semantics remain unchanged and compatible with `room:<run>` inspection.
|
|
17
|
-
|
|
18
5
|
### Actor Rooms, Roster, and Cross-Branch Messaging
|
|
19
6
|
|
|
20
7
|
- Priority: High.
|
|
@@ -78,7 +65,6 @@
|
|
|
78
65
|
- A packaged workflow, if added, is described by recipes and existing helper cells; no one-off backlog-implementer scripts are required.
|
|
79
66
|
- The actors skill documents the supported launch scenarios and the concrete packaged recipes for each.
|
|
80
67
|
|
|
81
|
-
|
|
82
68
|
### Branch-Local Checkpoint Semantics
|
|
83
69
|
|
|
84
70
|
- Priority: Low.
|
|
@@ -114,4 +100,3 @@
|
|
|
114
100
|
- Direction:
|
|
115
101
|
- Consider sidecar stats sync/backup policy after inline user-owned `usage.calls` / `usage.last_called` proves useful.
|
|
116
102
|
- Do not add failure counters as primary usefulness evidence unless there is a strong operator-facing need.
|
|
117
|
-
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.0: Modular Coordination And Active Mailboxes
|
|
4
|
+
|
|
5
|
+
- `[Coordination]` Decoupled the overloaded `coordinator-locker.mjs` script into two completely independent, single-purpose components: a dedicated stateful `locker.mjs` (recipes `locker.json` and `coordinator-locker.json`) that manages resource locking, task queueing, and lease expirations; and a powerful, modular `coordinator.mjs` orchestrator. The coordinator manages execution lifecycles and process pools, supporting four distinct pluggable strategies via `--mode`: `consensus` (chat-swarm), `pipeline` (sequential), `fanout` (parallel review), and `pool` (worker pool pulling from locker).
|
|
6
|
+
- `[Actor Messages]` Implemented active direct actor inbox queue semantics. The modular coordinator now automatically inspects, claims (`claimed`), injects into prompt context, and finalizes (`handled` or `failed`) any queued direct branch messages (`branches/<branch>/inbox.jsonl`) during subagent executions, making direct messages active initiating work items. Backed by complete regression test coverage.
|
|
7
|
+
|
|
3
8
|
## 0.18.0: Actor Runtime Hardening And Recipe Guardrails
|
|
4
9
|
|
|
5
10
|
- `[Async Runs]` Made actor starts safer under concurrency and stale state. Duplicate active `run_id` / `state_dir` launches now fail before state is cleared, concurrent starts are serialized by a start lock, stale terminal run directories are cleaned automatically, and atomic JSON writes use collision-resistant temp names. Impact: restarts and concurrent launches no longer orphan active processes or overwrite logs, messages, progress, and control metadata.
|
package/package.json
CHANGED
|
@@ -41,5 +41,5 @@
|
|
|
41
41
|
"locks": "{state_dir}/locks.json",
|
|
42
42
|
"journal": "{state_dir}/journal.jsonl"
|
|
43
43
|
},
|
|
44
|
-
"template": "{repo}/scripts/
|
|
44
|
+
"template": "{repo}/scripts/locker.mjs serve --state-dir {state_dir} --lease-ms {lease_ms}"
|
|
45
45
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"async": true,
|
|
3
|
+
"args": [
|
|
4
|
+
"repo:path",
|
|
5
|
+
"lease_ms:int"
|
|
6
|
+
],
|
|
7
|
+
"defaults": {
|
|
8
|
+
"repo": "~/.pi/agent/extensions/pi-actors",
|
|
9
|
+
"lease_ms": "600000"
|
|
10
|
+
},
|
|
11
|
+
"mailbox": {
|
|
12
|
+
"accepts": [
|
|
13
|
+
"lock.enqueue",
|
|
14
|
+
"lock.claim",
|
|
15
|
+
"lock.complete",
|
|
16
|
+
"lock.fail",
|
|
17
|
+
"lock.acquire",
|
|
18
|
+
"lock.renew",
|
|
19
|
+
"lock.release",
|
|
20
|
+
"control.stop",
|
|
21
|
+
"control.cancel",
|
|
22
|
+
"control.kill"
|
|
23
|
+
],
|
|
24
|
+
"emits": [
|
|
25
|
+
"lock.started",
|
|
26
|
+
"lock.enqueued",
|
|
27
|
+
"lock.assigned",
|
|
28
|
+
"lock.empty",
|
|
29
|
+
"lock.completed",
|
|
30
|
+
"lock.failed",
|
|
31
|
+
"lock.granted",
|
|
32
|
+
"lock.denied",
|
|
33
|
+
"lock.renewed",
|
|
34
|
+
"lock.released",
|
|
35
|
+
"run.done",
|
|
36
|
+
"run.failed"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"artifacts": {
|
|
40
|
+
"queue": "{state_dir}/queue.json",
|
|
41
|
+
"locks": "{state_dir}/locks.json",
|
|
42
|
+
"journal": "{state_dir}/journal.jsonl"
|
|
43
|
+
},
|
|
44
|
+
"template": "{repo}/scripts/locker.mjs serve --state-dir {state_dir} --lease-ms {lease_ms}"
|
|
45
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"async": true,
|
|
3
3
|
"args": [
|
|
4
|
+
"mode:string",
|
|
4
5
|
"mission:string",
|
|
5
6
|
"model:string",
|
|
6
7
|
"thinking:string",
|
|
@@ -14,6 +15,7 @@
|
|
|
14
15
|
"repo:path"
|
|
15
16
|
],
|
|
16
17
|
"defaults": {
|
|
18
|
+
"mode": "consensus",
|
|
17
19
|
"thinking": "off",
|
|
18
20
|
"roles": "",
|
|
19
21
|
"roles_path": "",
|
|
@@ -44,5 +46,5 @@
|
|
|
44
46
|
"run.failed"
|
|
45
47
|
]
|
|
46
48
|
},
|
|
47
|
-
"template": "{repo}/scripts/
|
|
49
|
+
"template": "{repo}/scripts/coordinator.mjs --run-id={run_id} --mode={mode} --mission={mission} --model={model} --thinking={thinking} --roles={roles} --roles-path={roles_path} --rounds={rounds} --delay={delay} --locker={locker} --locker-lease-ms={locker_lease_ms} --artifact-path={artifact_path}"
|
|
48
50
|
}
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
function arg(name, fallback = "") {
|
|
7
|
+
const prefix = `--${name}=`;
|
|
8
|
+
const value = process.argv.find((item) => item.startsWith(prefix));
|
|
9
|
+
return value ? value.slice(prefix.length) : fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function numberArg(name, fallback) {
|
|
13
|
+
const value = Number(arg(name, String(fallback)));
|
|
14
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function boolArg(name, fallback = false) {
|
|
18
|
+
const value = arg(name, String(fallback)).trim().toLowerCase();
|
|
19
|
+
return ["1", "true", "yes", "on"].includes(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeRoles(value) {
|
|
23
|
+
if (!Array.isArray(value)) throw new Error("roles must be an array");
|
|
24
|
+
return value.map((role, index) => ({
|
|
25
|
+
name: String(role.name ?? `actor-${index + 1}`),
|
|
26
|
+
persona: String(role.persona ?? role.role ?? "room participant"),
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseRoles(raw) {
|
|
31
|
+
if (!raw.trim()) return defaultRoles;
|
|
32
|
+
try {
|
|
33
|
+
return normalizeRoles(JSON.parse(raw));
|
|
34
|
+
} catch {
|
|
35
|
+
return raw.split(",").map((item, index) => ({
|
|
36
|
+
name: item.trim() || `actor-${index + 1}`,
|
|
37
|
+
persona: "room participant",
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function loadRoles(raw, path) {
|
|
43
|
+
if (path.trim()) return normalizeRoles(JSON.parse(await readFile(path, "utf8")));
|
|
44
|
+
return parseRoles(raw);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function shellQuote(value) {
|
|
48
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function agentDir() {
|
|
52
|
+
return process.env.PI_CODING_AGENT_DIR || `${process.env.HOME}/.pi/agent`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runStateDir(runId) {
|
|
56
|
+
return `${agentDir()}/tmp/pi-actors/runs/${runId}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function sleep(ms) {
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function waitForPath(path, timeoutMs = 5000) {
|
|
64
|
+
const started = Date.now();
|
|
65
|
+
while (!existsSync(path)) {
|
|
66
|
+
if (Date.now() - started > timeoutMs) throw new Error(`timed out waiting for ${path}`);
|
|
67
|
+
await sleep(50);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function writeLockerMessage(locker, message) {
|
|
72
|
+
if (!locker) return;
|
|
73
|
+
await waitForPath(locker.controlPath);
|
|
74
|
+
await writeFile(locker.controlPath, `${JSON.stringify(message)}\n`, { flag: "a" });
|
|
75
|
+
await sleep(50);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function startLocker(config) {
|
|
79
|
+
if (!config.locker) return undefined;
|
|
80
|
+
const lockerStateDir = `${runStateDir(config.runId)}/locker`;
|
|
81
|
+
await mkdir(lockerStateDir, { recursive: true });
|
|
82
|
+
const child = spawn(new URL("./locker.mjs", import.meta.url).pathname, [
|
|
83
|
+
"serve",
|
|
84
|
+
"--state-dir",
|
|
85
|
+
lockerStateDir,
|
|
86
|
+
"--lease-ms",
|
|
87
|
+
String(config.lockerLeaseMs),
|
|
88
|
+
], {
|
|
89
|
+
env: { ...process.env, run_id: config.runId },
|
|
90
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
91
|
+
});
|
|
92
|
+
child.stdout.on("data", (chunk) => process.stdout.write(`[locker] ${chunk}`));
|
|
93
|
+
child.stderr.on("data", (chunk) => process.stderr.write(`[locker] ${chunk}`));
|
|
94
|
+
const locker = { child, controlPath: `${lockerStateDir}/control.fifo`, stateDir: lockerStateDir };
|
|
95
|
+
await waitForPath(locker.controlPath);
|
|
96
|
+
await writeLockerMessage(locker, {
|
|
97
|
+
type: "lock.enqueue",
|
|
98
|
+
body: { id: "coordinator-artifact", task: "Own final artifact synthesis", resources: [config.artifactPath || "artifact"] },
|
|
99
|
+
});
|
|
100
|
+
return locker;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function stopLocker(locker) {
|
|
104
|
+
if (!locker) return;
|
|
105
|
+
try {
|
|
106
|
+
await writeLockerMessage(locker, { type: "control.stop", body: {} });
|
|
107
|
+
await sleep(100);
|
|
108
|
+
} finally {
|
|
109
|
+
if (!locker.child.killed) locker.child.kill("SIGTERM");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function runPi(prompt, model, thinking) {
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
const child = spawn("pi", [
|
|
116
|
+
"--model", model,
|
|
117
|
+
"--thinking", thinking,
|
|
118
|
+
"--tools", "inspect,message",
|
|
119
|
+
"--no-context-files",
|
|
120
|
+
"--no-skills",
|
|
121
|
+
"--no-session",
|
|
122
|
+
"-p",
|
|
123
|
+
prompt,
|
|
124
|
+
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
125
|
+
let stdout = "";
|
|
126
|
+
let stderr = "";
|
|
127
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
128
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
129
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
130
|
+
child.on("error", (error) => resolve({ code: 1, stdout, stderr: String(error) }));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function readRoomTranscript(config) {
|
|
135
|
+
const messagesPath = `${runStateDir(config.runId)}/rooms/main/messages.jsonl`;
|
|
136
|
+
try {
|
|
137
|
+
const raw = await readFile(messagesPath, "utf8");
|
|
138
|
+
return raw
|
|
139
|
+
.split("\n")
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.slice(-160)
|
|
142
|
+
.map((line) => {
|
|
143
|
+
try {
|
|
144
|
+
const message = JSON.parse(line);
|
|
145
|
+
const from = String(message.from || "unknown").replace(/^branch:[^/]+\//, "");
|
|
146
|
+
const type = String(message.type || "message");
|
|
147
|
+
const body = typeof message.body === "string" ? message.body : JSON.stringify(message.body ?? "");
|
|
148
|
+
return `${from} [${type}]: ${body}`;
|
|
149
|
+
} catch {
|
|
150
|
+
return line;
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
.join("\n");
|
|
154
|
+
} catch {
|
|
155
|
+
return "";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function synthesize(config, locker) {
|
|
160
|
+
if (!config.artifactPath) return;
|
|
161
|
+
if (locker) {
|
|
162
|
+
await writeLockerMessage(locker, {
|
|
163
|
+
type: "lock.claim",
|
|
164
|
+
body: { owner: "coordinator:synthesizer" },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const transcript = await readRoomTranscript(config);
|
|
168
|
+
const prompt = `Synthesize this transcript into a concise Markdown artifact. Mission: ${config.mission}. Include: Title, Consensus, Roles, Protocol, Final Artifact Shape, Next Actions, Open Questions. Use only the transcript evidence below.\n\nTRANSCRIPT:\n${transcript.slice(-24000)}`;
|
|
169
|
+
const result = await runPi(prompt, config.model, config.thinking);
|
|
170
|
+
await writeFile(config.artifactPath, result.stdout.trim() ? result.stdout : "# Synthesis Artifact\n\nNo synthesis output.\n", "utf8");
|
|
171
|
+
if (locker) {
|
|
172
|
+
await writeLockerMessage(locker, {
|
|
173
|
+
type: "lock.complete",
|
|
174
|
+
body: { id: "coordinator-artifact", artifact: config.artifactPath },
|
|
175
|
+
});
|
|
176
|
+
await writeLockerMessage(locker, {
|
|
177
|
+
type: "lock.release",
|
|
178
|
+
body: { resource: config.artifactPath },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
process.stdout.write(`artifact=${config.artifactPath}\n`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function updateInboxMessagesStatus(runId, branchName, ids, status) {
|
|
185
|
+
const inboxPath = `${runStateDir(runId)}/branches/${branchName}/inbox.jsonl`;
|
|
186
|
+
try {
|
|
187
|
+
if (!existsSync(inboxPath)) return;
|
|
188
|
+
const content = await readFile(inboxPath, "utf8");
|
|
189
|
+
const lines = content.split("\n").filter(Boolean);
|
|
190
|
+
const updatedLines = [];
|
|
191
|
+
for (const line of lines) {
|
|
192
|
+
const msg = JSON.parse(line);
|
|
193
|
+
if (msg.id && ids.includes(msg.id)) {
|
|
194
|
+
msg.status = status;
|
|
195
|
+
msg[`${status}_at`] = new Date().toISOString();
|
|
196
|
+
}
|
|
197
|
+
updatedLines.push(JSON.stringify(msg));
|
|
198
|
+
}
|
|
199
|
+
await writeFile(inboxPath, updatedLines.join("\n") + "\n", "utf8");
|
|
200
|
+
} catch (err) {
|
|
201
|
+
// Best-effort write
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function executeParticipantPrompt(role, basePrompt, config) {
|
|
206
|
+
const branchName = role.name;
|
|
207
|
+
const inboxPath = `${runStateDir(config.runId)}/branches/${branchName}/inbox.jsonl`;
|
|
208
|
+
const queuedMessages = [];
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
if (existsSync(inboxPath)) {
|
|
212
|
+
const content = await readFile(inboxPath, "utf8");
|
|
213
|
+
const lines = content.split("\n").filter(Boolean);
|
|
214
|
+
for (const line of lines) {
|
|
215
|
+
const msg = JSON.parse(line);
|
|
216
|
+
if (msg.status === "queued" || !msg.status) {
|
|
217
|
+
queuedMessages.push(msg);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch (err) {
|
|
222
|
+
// Best-effort read
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let finalPrompt = basePrompt;
|
|
226
|
+
const claimedIds = [];
|
|
227
|
+
|
|
228
|
+
if (queuedMessages.length > 0) {
|
|
229
|
+
let inboxSection = "\n\nDIRECT INBOX MESSAGES FOR YOU (FIFO queue):\n";
|
|
230
|
+
for (const msg of queuedMessages) {
|
|
231
|
+
const bodyText = typeof msg.body === "string" ? msg.body : JSON.stringify(msg.body ?? "");
|
|
232
|
+
inboxSection += `- From: ${msg.from || "unknown"} (Type: ${msg.type || "message"})\n Body: ${bodyText}\n`;
|
|
233
|
+
if (msg.id) claimedIds.push(msg.id);
|
|
234
|
+
}
|
|
235
|
+
inboxSection += "\nPlease acknowledge and address these direct messages in your response.\n";
|
|
236
|
+
finalPrompt += inboxSection;
|
|
237
|
+
|
|
238
|
+
if (claimedIds.length > 0) {
|
|
239
|
+
await updateInboxMessagesStatus(config.runId, branchName, claimedIds, "claimed");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const result = await runPi(finalPrompt, config.model, config.thinking);
|
|
244
|
+
|
|
245
|
+
if (claimedIds.length > 0) {
|
|
246
|
+
const finalStatus = result.code === 0 ? "handled" : "failed";
|
|
247
|
+
await updateInboxMessagesStatus(config.runId, branchName, claimedIds, finalStatus);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function participantRound(role, round, config) {
|
|
254
|
+
const displayName = role.name;
|
|
255
|
+
const address = `branch:${config.runId}/${role.name}`;
|
|
256
|
+
const prompt = `You are ${displayName} (${address}), ${role.persona}. Mission: ${config.mission}. Round ${round}/${config.rounds}. First call inspect target=${config.room} view=previews lines=30 and inspect target=${config.room} view=contacts. Then call message once to ${config.room} from ${address} type=chat.message. Body: 2-4 sentences that react to a named participant, propose the next coordination step, and refine the shared artifact. Use contacts for peer names and addresses. End stdout with summary <=160 chars.`;
|
|
257
|
+
const result = await executeParticipantPrompt(role, prompt, config);
|
|
258
|
+
process.stdout.write(`[${role.name} round ${round}] code=${result.code}\n${result.stdout}\n`);
|
|
259
|
+
if (result.stderr.trim()) process.stderr.write(`[${role.name} round ${round}] ${result.stderr}\n`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function participantJoin(role, config) {
|
|
263
|
+
const displayName = role.name;
|
|
264
|
+
const address = `branch:${config.runId}/${role.name}`;
|
|
265
|
+
const joinPrompt = `You are ${displayName}, ${role.persona}. Mission: ${config.mission}. Call tool message exactly once with to=${shellQuote(config.room)}, from=${shellQuote(address)}, type='actor.join', summary='${displayName} joined', body JSON {"role":${JSON.stringify(role.persona)},"display":${JSON.stringify(displayName)},"caps":["coordination","synthesis"],"claim":"coordinate on mission"}. Then print one short line.`;
|
|
266
|
+
await runPi(joinPrompt, config.model, config.thinking);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function participantLeave(role, config) {
|
|
270
|
+
const displayName = role.name;
|
|
271
|
+
const address = `branch:${config.runId}/${role.name}`;
|
|
272
|
+
const leavePrompt = `Call tool message exactly once with to=${shellQuote(config.room)}, from=${shellQuote(address)}, type='actor.leave', summary='${displayName} left', body='finished coordinated work'. Then print goodbye.`;
|
|
273
|
+
await runPi(leavePrompt, config.model, config.thinking);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 1. consensus / swarm mode: iterative chat in a room
|
|
277
|
+
async function runConsensus(config, locker) {
|
|
278
|
+
await Promise.all(config.roles.map((role) => participantJoin(role, config)));
|
|
279
|
+
for (let round = 1; round <= config.rounds; round += 1) {
|
|
280
|
+
await Promise.all(config.roles.map((role) => participantRound(role, round, config)));
|
|
281
|
+
if (round < config.rounds && config.delay > 0) await sleep(config.delay * 1000);
|
|
282
|
+
}
|
|
283
|
+
await Promise.all(config.roles.map((role) => participantLeave(role, config)));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 2. pipeline mode: sequential step-by-step
|
|
287
|
+
async function runPipeline(config, locker) {
|
|
288
|
+
for (const role of config.roles) {
|
|
289
|
+
await participantJoin(role, config);
|
|
290
|
+
for (let round = 1; round <= config.rounds; round += 1) {
|
|
291
|
+
await participantRound(role, round, config);
|
|
292
|
+
if (round < config.rounds && config.delay > 0) await sleep(config.delay * 1000);
|
|
293
|
+
}
|
|
294
|
+
await participantLeave(role, config);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 3. fanout mode: run completely parallel independent processes
|
|
299
|
+
async function runFanout(config, locker) {
|
|
300
|
+
await Promise.all(config.roles.map(async (role) => {
|
|
301
|
+
await participantJoin(role, config);
|
|
302
|
+
for (let round = 1; round <= config.rounds; round += 1) {
|
|
303
|
+
await participantRound(role, round, config);
|
|
304
|
+
if (round < config.rounds && config.delay > 0) await sleep(config.delay * 1000);
|
|
305
|
+
}
|
|
306
|
+
await participantLeave(role, config);
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 4. pool mode: dynamic task pulling from the locker queue
|
|
311
|
+
async function runPool(config, locker) {
|
|
312
|
+
if (!locker) {
|
|
313
|
+
throw new Error("Pool mode requires locker enabled (--locker=true)");
|
|
314
|
+
}
|
|
315
|
+
// Enqueue a set of sub-tasks based on the mission splits (for demo/simulation, we enqueue 3 sub-tasks)
|
|
316
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
317
|
+
await writeLockerMessage(locker, {
|
|
318
|
+
type: "lock.enqueue",
|
|
319
|
+
body: { id: `subtask-${index}`, task: `Execute subtask ${index} under mission: ${config.mission}`, resources: [`resource-${index}`] },
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Workers concurrently poll task queue
|
|
324
|
+
await Promise.all(config.roles.map(async (role) => {
|
|
325
|
+
const address = `branch:${config.runId}/${role.name}`;
|
|
326
|
+
await participantJoin(role, config);
|
|
327
|
+
|
|
328
|
+
while (true) {
|
|
329
|
+
// Dequeue/Claim a task from the locker
|
|
330
|
+
await writeLockerMessage(locker, {
|
|
331
|
+
type: "lock.claim",
|
|
332
|
+
body: { owner: role.name },
|
|
333
|
+
});
|
|
334
|
+
await sleep(100);
|
|
335
|
+
|
|
336
|
+
// Check locks and queue state from locker snapshot
|
|
337
|
+
const lockerData = await readJsonFile(`${locker.stateDir}/locks.json`);
|
|
338
|
+
const assignedTask = Object.values(lockerData).find(lock => lock.owner === role.name);
|
|
339
|
+
|
|
340
|
+
if (!assignedTask) {
|
|
341
|
+
// No task assigned or queue is empty
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const taskId = assignedTask.task;
|
|
346
|
+
const prompt = `You are ${role.name}, ${role.persona}. You have been assigned task ${taskId}: "${assignedTask.task}". Solve it as part of the overall mission: ${config.mission}. End your response with a clear summary.`;
|
|
347
|
+
const result = await executeParticipantPrompt(role, prompt, config);
|
|
348
|
+
|
|
349
|
+
process.stdout.write(`[${role.name} completed ${taskId}] code=${result.code}\n${result.stdout}\n`);
|
|
350
|
+
|
|
351
|
+
// Report task completion back to locker and release resource locks
|
|
352
|
+
await writeLockerMessage(locker, {
|
|
353
|
+
type: "lock.complete",
|
|
354
|
+
body: { id: taskId, result: result.stdout },
|
|
355
|
+
});
|
|
356
|
+
// Release any locks associated with this resource
|
|
357
|
+
for (const [res, lock] of Object.entries(lockerData)) {
|
|
358
|
+
if (lock.owner === role.name) {
|
|
359
|
+
await writeLockerMessage(locker, {
|
|
360
|
+
type: "lock.release",
|
|
361
|
+
body: { resource: res },
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
await participantLeave(role, config);
|
|
368
|
+
}));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function readJsonFile(path) {
|
|
372
|
+
try {
|
|
373
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
374
|
+
} catch {
|
|
375
|
+
return {};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const defaultRoles = [
|
|
380
|
+
{ name: "mapper", persona: "systems mapper; tracks shared structure and dependencies" },
|
|
381
|
+
{ name: "memory", persona: "memory keeper; preserves decisions and unresolved questions" },
|
|
382
|
+
{ name: "risk", persona: "risk scout; challenges weak coordination and missing owners" },
|
|
383
|
+
{ name: "flow", persona: "flow designer; turns scattered ideas into process rhythm" },
|
|
384
|
+
{ name: "operator", persona: "operator; converts ideas into concrete next actions" },
|
|
385
|
+
{ name: "narrative", persona: "narrative synthesizer; keeps the artifact coherent" },
|
|
386
|
+
{ name: "interface", persona: "interface designer; makes outputs visible and usable" },
|
|
387
|
+
{ name: "facilitator", persona: "facilitator; asks for consensus and convergence" },
|
|
388
|
+
];
|
|
389
|
+
|
|
390
|
+
const config = {
|
|
391
|
+
runId: arg("run-id", process.env.run_id || process.env.RUN_ID || "coordinator-run"),
|
|
392
|
+
mode: arg("mode", "consensus"), // "consensus" (default/swarm), "pipeline", "fanout", "pool"
|
|
393
|
+
mission: arg("mission", "Coordinate a shared artifact and converge on next actions"),
|
|
394
|
+
model: arg("model", ""),
|
|
395
|
+
thinking: arg("thinking", "off"),
|
|
396
|
+
roles: await loadRoles(arg("roles", ""), arg("roles-path", "")),
|
|
397
|
+
rounds: numberArg("rounds", 4),
|
|
398
|
+
delay: numberArg("delay", 10),
|
|
399
|
+
artifactPath: arg("artifact-path", ""),
|
|
400
|
+
locker: boolArg("locker", false),
|
|
401
|
+
lockerLeaseMs: numberArg("locker-lease-ms", 600000),
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
if (!config.model) {
|
|
405
|
+
console.error("--model is required");
|
|
406
|
+
process.exit(2);
|
|
407
|
+
}
|
|
408
|
+
config.room = `room:${config.runId}`;
|
|
409
|
+
|
|
410
|
+
const failures = [];
|
|
411
|
+
const locker = await startLocker(config);
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
if (config.mode === "pipeline") {
|
|
415
|
+
await runPipeline(config, locker);
|
|
416
|
+
} else if (config.mode === "fanout") {
|
|
417
|
+
await runFanout(config, locker);
|
|
418
|
+
} else if (config.mode === "pool") {
|
|
419
|
+
await runPool(config, locker);
|
|
420
|
+
} else {
|
|
421
|
+
// default: consensus / swarm
|
|
422
|
+
await runConsensus(config, locker);
|
|
423
|
+
}
|
|
424
|
+
await synthesize(config, locker);
|
|
425
|
+
} catch (globalError) {
|
|
426
|
+
failures.push(`Global coordinator error: ${globalError.message}`);
|
|
427
|
+
} finally {
|
|
428
|
+
await stopLocker(locker);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (failures.length > 0) {
|
|
432
|
+
console.error(failures.join("\n"));
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
@@ -58,7 +58,7 @@ function journal(event, data = {}) {
|
|
|
58
58
|
function outbox(type, summary, body = {}, level = "info") {
|
|
59
59
|
appendFileSync(
|
|
60
60
|
outboxPath,
|
|
61
|
-
`${JSON.stringify({ to: "coordinator", from: `run:${process.env.run_id ?? "
|
|
61
|
+
`${JSON.stringify({ to: "coordinator", from: `run:${process.env.run_id ?? "locker"}`, type, event: type, summary, body, data: body, delivery: "followup", level, ts: new Date().toISOString() })}\n`,
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
function now() {
|
|
@@ -82,7 +82,7 @@ function normalizeMessage(line) {
|
|
|
82
82
|
try {
|
|
83
83
|
return JSON.parse(trimmed);
|
|
84
84
|
} catch {
|
|
85
|
-
return { type: "
|
|
85
|
+
return { type: "lock.enqueue", body: { task: trimmed } };
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
function tailJournal(count) {
|
|
@@ -127,7 +127,7 @@ function nextTask(queue, locks) {
|
|
|
127
127
|
return items.splice(index, 1)[0];
|
|
128
128
|
}
|
|
129
129
|
function handle(message) {
|
|
130
|
-
const type = message.type || message.event || "
|
|
130
|
+
const type = message.type || message.event || "lock.enqueue";
|
|
131
131
|
const body =
|
|
132
132
|
message.body && typeof message.body === "object" ? message.body : message;
|
|
133
133
|
let queue = readJson(queuePath, { items: [] });
|
|
@@ -135,12 +135,12 @@ function handle(message) {
|
|
|
135
135
|
if (type === "control.stop" || type === "control.cancel") {
|
|
136
136
|
writeJson(locksPath, locks);
|
|
137
137
|
journal("control.stop", {});
|
|
138
|
-
outbox("
|
|
138
|
+
outbox("lock.stopped", "Locker stopped", {
|
|
139
139
|
queueDepth: queue.items?.length ?? 0,
|
|
140
140
|
});
|
|
141
141
|
process.exit(0);
|
|
142
142
|
}
|
|
143
|
-
if (type === "coord.enqueue") {
|
|
143
|
+
if (type === "lock.enqueue" || type === "coord.enqueue") {
|
|
144
144
|
const item = {
|
|
145
145
|
id: body.id || `task-${Date.now()}`,
|
|
146
146
|
task: body.task ?? body,
|
|
@@ -150,20 +150,20 @@ function handle(message) {
|
|
|
150
150
|
queue.items = [...(queue.items ?? []), item];
|
|
151
151
|
writeJson(queuePath, queue);
|
|
152
152
|
writeJson(locksPath, locks);
|
|
153
|
-
journal("
|
|
154
|
-
outbox("
|
|
153
|
+
journal("lock.enqueued", { id: item.id, resources: item.resources });
|
|
154
|
+
outbox("lock.enqueued", `Queued task ${item.id}`, {
|
|
155
155
|
id: item.id,
|
|
156
156
|
queueDepth: queue.items.length,
|
|
157
157
|
});
|
|
158
158
|
return;
|
|
159
159
|
}
|
|
160
|
-
if (type === "coord.claim") {
|
|
160
|
+
if (type === "lock.claim" || type === "coord.claim") {
|
|
161
161
|
const owner = body.owner || message.from || "worker";
|
|
162
162
|
const item = nextTask(queue, locks);
|
|
163
163
|
if (!item) {
|
|
164
164
|
writeJson(queuePath, queue);
|
|
165
165
|
writeJson(locksPath, locks);
|
|
166
|
-
outbox("
|
|
166
|
+
outbox("lock.empty", "No claimable task", {
|
|
167
167
|
owner,
|
|
168
168
|
queueDepth: queue.items?.length ?? 0,
|
|
169
169
|
});
|
|
@@ -173,12 +173,12 @@ function handle(message) {
|
|
|
173
173
|
locks[resource] = { owner, task: item.id, expiresAt: now() + leaseMs };
|
|
174
174
|
writeJson(queuePath, queue);
|
|
175
175
|
writeJson(locksPath, locks);
|
|
176
|
-
journal("
|
|
176
|
+
journal("lock.assigned", {
|
|
177
177
|
id: item.id,
|
|
178
178
|
owner,
|
|
179
179
|
resources: item.resources,
|
|
180
180
|
});
|
|
181
|
-
outbox("
|
|
181
|
+
outbox("lock.assigned", `Assigned task ${item.id}`, { owner, task: item });
|
|
182
182
|
return;
|
|
183
183
|
}
|
|
184
184
|
if (type === "lock.acquire") {
|
|
@@ -222,20 +222,21 @@ function handle(message) {
|
|
|
222
222
|
outbox("lock.released", `Lock released ${resource}`, { resource });
|
|
223
223
|
return;
|
|
224
224
|
}
|
|
225
|
-
if (type === "coord.complete" || type === "coord.fail") {
|
|
226
|
-
|
|
225
|
+
if (type === "lock.complete" || type === "lock.fail" || type === "coord.complete" || type === "coord.fail") {
|
|
226
|
+
const eventType = type.startsWith("coord.") ? type.replace("coord.", "lock.") : type;
|
|
227
|
+
journal(eventType, body);
|
|
227
228
|
outbox(
|
|
228
|
-
|
|
229
|
-
`${
|
|
229
|
+
eventType,
|
|
230
|
+
`${eventType} ${body.id ?? ""}`.trim(),
|
|
230
231
|
body,
|
|
231
|
-
|
|
232
|
+
eventType === "lock.fail" ? "error" : "info",
|
|
232
233
|
);
|
|
233
234
|
writeJson(locksPath, locks);
|
|
234
235
|
writeJson(queuePath, queue);
|
|
235
236
|
return;
|
|
236
237
|
}
|
|
237
|
-
journal("
|
|
238
|
-
outbox("
|
|
238
|
+
journal("lock.unknown", { type, body });
|
|
239
|
+
outbox("lock.unknown", `Unknown message ${type}`, { type, body }, "warning");
|
|
239
240
|
}
|
|
240
241
|
|
|
241
242
|
if (mode === "snapshot") {
|
|
@@ -250,8 +251,8 @@ if (!existsSync(controlPath)) {
|
|
|
250
251
|
}
|
|
251
252
|
writeJson(queuePath, readJson(queuePath, { items: [] }));
|
|
252
253
|
writeJson(locksPath, cleanExpiredLocks(readJson(locksPath, {})));
|
|
253
|
-
journal("
|
|
254
|
-
outbox("
|
|
254
|
+
journal("lock.started", { leaseMs });
|
|
255
|
+
outbox("lock.started", "Locker ready", { leaseMs });
|
|
255
256
|
|
|
256
257
|
while (true) {
|
|
257
258
|
const stream = await import("node:fs").then((fs) =>
|
|
@@ -265,8 +266,8 @@ while (true) {
|
|
|
265
266
|
handle(message);
|
|
266
267
|
} catch (error) {
|
|
267
268
|
const text = error instanceof Error ? error.message : String(error);
|
|
268
|
-
journal("
|
|
269
|
-
outbox("
|
|
269
|
+
journal("lock.error", { error: text });
|
|
270
|
+
outbox("lock.error", text, { error: text }, "error");
|
|
270
271
|
}
|
|
271
272
|
}
|
|
272
273
|
}
|
package/skills/actors/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: actors
|
|
3
3
|
description: Highest-density practical guide for pi-actors. Read this skill whenever prompt and tools are not enough for spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 0.
|
|
5
|
+
version: 0.19.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Actors (pi-actors)
|
|
@@ -257,6 +257,7 @@ Use packaged recipes by name with `spawn file=<name>` for async actors, or regis
|
|
|
257
257
|
### Coordination and Services
|
|
258
258
|
|
|
259
259
|
- [`coordinator-locker`](../../recipes/coordinator-locker.json): queue + acquire/renew/release lease locks + journaled coordinator messages.
|
|
260
|
+
- [`locker`](../../recipes/locker.json): modular queue + acquire/renew/release lease locks + journaled locker messages.
|
|
260
261
|
- [`utility-coordinator-lock-snapshot`](../../recipes/utility-coordinator-lock-snapshot.json): one-shot JSON snapshot of a coordinator-locker state directory.
|
|
261
262
|
- [`music-player`](../../recipes/music-player.json): background local/URL/directory/playlist playback actor controlled by messages.
|
|
262
263
|
|
package/skills/swarm/SKILL.md
CHANGED
package/scripts/room-swarm.mjs
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
4
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
-
|
|
6
|
-
function arg(name, fallback = "") {
|
|
7
|
-
const prefix = `--${name}=`;
|
|
8
|
-
const value = process.argv.find((item) => item.startsWith(prefix));
|
|
9
|
-
return value ? value.slice(prefix.length) : fallback;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function numberArg(name, fallback) {
|
|
13
|
-
const value = Number(arg(name, String(fallback)));
|
|
14
|
-
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function boolArg(name, fallback = false) {
|
|
18
|
-
const value = arg(name, String(fallback)).trim().toLowerCase();
|
|
19
|
-
return ["1", "true", "yes", "on"].includes(value);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function normalizeRoles(value) {
|
|
23
|
-
if (!Array.isArray(value)) throw new Error("roles must be an array");
|
|
24
|
-
return value.map((role, index) => ({
|
|
25
|
-
name: String(role.name ?? `actor-${index + 1}`),
|
|
26
|
-
persona: String(role.persona ?? role.role ?? "room participant"),
|
|
27
|
-
}));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function parseRoles(raw) {
|
|
31
|
-
if (!raw.trim()) return defaultRoles;
|
|
32
|
-
try {
|
|
33
|
-
return normalizeRoles(JSON.parse(raw));
|
|
34
|
-
} catch {
|
|
35
|
-
return raw.split(",").map((item, index) => ({
|
|
36
|
-
name: item.trim() || `actor-${index + 1}`,
|
|
37
|
-
persona: "room participant",
|
|
38
|
-
}));
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function loadRoles(raw, path) {
|
|
43
|
-
if (path.trim()) return normalizeRoles(JSON.parse(await readFile(path, "utf8")));
|
|
44
|
-
return parseRoles(raw);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function shellQuote(value) {
|
|
48
|
-
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function agentDir() {
|
|
52
|
-
return process.env.PI_CODING_AGENT_DIR || `${process.env.HOME}/.pi/agent`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function runStateDir(runId) {
|
|
56
|
-
return `${agentDir()}/tmp/pi-actors/runs/${runId}`;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function sleep(ms) {
|
|
60
|
-
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function waitForPath(path, timeoutMs = 5000) {
|
|
64
|
-
const started = Date.now();
|
|
65
|
-
while (!existsSync(path)) {
|
|
66
|
-
if (Date.now() - started > timeoutMs) throw new Error(`timed out waiting for ${path}`);
|
|
67
|
-
await sleep(50);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function writeLockerMessage(locker, message) {
|
|
72
|
-
if (!locker) return;
|
|
73
|
-
await waitForPath(locker.controlPath);
|
|
74
|
-
await writeFile(locker.controlPath, `${JSON.stringify(message)}\n`, { flag: "a" });
|
|
75
|
-
await sleep(50);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function startLocker(config) {
|
|
79
|
-
if (!config.locker) return undefined;
|
|
80
|
-
const lockerStateDir = `${runStateDir(config.runId)}/locker`;
|
|
81
|
-
await mkdir(lockerStateDir, { recursive: true });
|
|
82
|
-
const child = spawn(new URL("./coordinator-locker.mjs", import.meta.url).pathname, [
|
|
83
|
-
"serve",
|
|
84
|
-
"--state-dir",
|
|
85
|
-
lockerStateDir,
|
|
86
|
-
"--lease-ms",
|
|
87
|
-
String(config.lockerLeaseMs),
|
|
88
|
-
], {
|
|
89
|
-
env: { ...process.env, run_id: config.runId },
|
|
90
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
91
|
-
});
|
|
92
|
-
child.stdout.on("data", (chunk) => process.stdout.write(`[locker] ${chunk}`));
|
|
93
|
-
child.stderr.on("data", (chunk) => process.stderr.write(`[locker] ${chunk}`));
|
|
94
|
-
const locker = { child, controlPath: `${lockerStateDir}/control.fifo`, stateDir: lockerStateDir };
|
|
95
|
-
await waitForPath(locker.controlPath);
|
|
96
|
-
await writeLockerMessage(locker, {
|
|
97
|
-
type: "coord.enqueue",
|
|
98
|
-
body: { id: "room-swarm-artifact", task: "Own final room-swarm artifact synthesis", resources: [config.artifactPath || "artifact"] },
|
|
99
|
-
});
|
|
100
|
-
return locker;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async function stopLocker(locker) {
|
|
104
|
-
if (!locker) return;
|
|
105
|
-
try {
|
|
106
|
-
await writeLockerMessage(locker, { type: "control.stop", body: {} });
|
|
107
|
-
await sleep(100);
|
|
108
|
-
} finally {
|
|
109
|
-
if (!locker.child.killed) locker.child.kill("SIGTERM");
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function runPi(prompt, model, thinking) {
|
|
114
|
-
return new Promise((resolve) => {
|
|
115
|
-
const child = spawn("pi", [
|
|
116
|
-
"--model", model,
|
|
117
|
-
"--thinking", thinking,
|
|
118
|
-
"--tools", "inspect,message",
|
|
119
|
-
"--no-context-files",
|
|
120
|
-
"--no-skills",
|
|
121
|
-
"--no-session",
|
|
122
|
-
"-p",
|
|
123
|
-
prompt,
|
|
124
|
-
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
125
|
-
let stdout = "";
|
|
126
|
-
let stderr = "";
|
|
127
|
-
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
128
|
-
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
129
|
-
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
130
|
-
child.on("error", (error) => resolve({ code: 1, stdout, stderr: String(error) }));
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
async function participant(role, config) {
|
|
135
|
-
const displayName = role.name;
|
|
136
|
-
const address = `branch:${config.runId}/${role.name}`;
|
|
137
|
-
const joinPrompt = `You are ${displayName}, ${role.persona}. Mission: ${config.mission}. Call tool message exactly once with to=${shellQuote(config.room)}, from=${shellQuote(address)}, type='actor.join', summary='${displayName} joined', body JSON {"role":${JSON.stringify(role.persona)},"display":${JSON.stringify(displayName)},"caps":["coordination","synthesis"],"claim":"coordinate on mission"}. Then print one short line.`;
|
|
138
|
-
await runPi(joinPrompt, config.model, config.thinking);
|
|
139
|
-
for (let round = 1; round <= config.rounds; round += 1) {
|
|
140
|
-
const prompt = `You are ${displayName} (${address}), ${role.persona}. Mission: ${config.mission}. Round ${round}/${config.rounds}. First call inspect target=${config.room} view=previews lines=30 and inspect target=${config.room} view=contacts. Then call message once to ${config.room} from ${address} type=chat.message. Body: 2-4 sentences that react to a named participant, propose the next coordination step, and refine the shared artifact. Use contacts for peer names and addresses, but keep this packaged swarm's coordination room-visible; do not send direct branch messages unless a caller-specific worker protocol says recipients consume them. End stdout with summary <=160 chars.`;
|
|
141
|
-
const result = await runPi(prompt, config.model, config.thinking);
|
|
142
|
-
process.stdout.write(`[${role.name} round ${round}] code=${result.code}\n${result.stdout}\n`);
|
|
143
|
-
if (result.stderr.trim()) process.stderr.write(`[${role.name} round ${round}] ${result.stderr}\n`);
|
|
144
|
-
if (round < config.rounds && config.delay > 0) await new Promise((resolve) => setTimeout(resolve, config.delay * 1000));
|
|
145
|
-
}
|
|
146
|
-
const leavePrompt = `Call tool message exactly once with to=${shellQuote(config.room)}, from=${shellQuote(address)}, type='actor.leave', summary='${displayName} left', body='finished coordinated work'. Then print goodbye.`;
|
|
147
|
-
await runPi(leavePrompt, config.model, config.thinking);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
async function readRoomTranscript(config) {
|
|
151
|
-
const messagesPath = `${agentDir()}/tmp/pi-actors/runs/${config.runId}/rooms/main/messages.jsonl`;
|
|
152
|
-
try {
|
|
153
|
-
const raw = await readFile(messagesPath, "utf8");
|
|
154
|
-
return raw
|
|
155
|
-
.split("\n")
|
|
156
|
-
.filter(Boolean)
|
|
157
|
-
.slice(-160)
|
|
158
|
-
.map((line) => {
|
|
159
|
-
try {
|
|
160
|
-
const message = JSON.parse(line);
|
|
161
|
-
const from = String(message.from || "unknown").replace(/^branch:[^/]+\//, "");
|
|
162
|
-
const type = String(message.type || "message");
|
|
163
|
-
const body = typeof message.body === "string" ? message.body : JSON.stringify(message.body ?? "");
|
|
164
|
-
return `${from} [${type}]: ${body}`;
|
|
165
|
-
} catch {
|
|
166
|
-
return line;
|
|
167
|
-
}
|
|
168
|
-
})
|
|
169
|
-
.join("\n");
|
|
170
|
-
} catch {
|
|
171
|
-
return "";
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async function synthesize(config, locker) {
|
|
176
|
-
if (!config.artifactPath) return;
|
|
177
|
-
await writeLockerMessage(locker, {
|
|
178
|
-
type: "coord.claim",
|
|
179
|
-
body: { owner: "room-swarm:synthesizer" },
|
|
180
|
-
});
|
|
181
|
-
const transcript = await readRoomTranscript(config);
|
|
182
|
-
const prompt = `Synthesize this room-swarm transcript into a concise Markdown artifact. Mission: ${config.mission}. Include: Title, Consensus, Roles, Protocol, Final Artifact Shape, Next Actions, Open Questions. Use only the transcript evidence below.\n\nTRANSCRIPT:\n${transcript.slice(-24000)}`;
|
|
183
|
-
const result = await runPi(prompt, config.model, config.thinking);
|
|
184
|
-
await writeFile(config.artifactPath, result.stdout.trim() ? result.stdout : "# Room Swarm Artifact\n\nNo synthesis output.\n", "utf8");
|
|
185
|
-
await writeLockerMessage(locker, {
|
|
186
|
-
type: "coord.complete",
|
|
187
|
-
body: { id: "room-swarm-artifact", artifact: config.artifactPath },
|
|
188
|
-
});
|
|
189
|
-
await writeLockerMessage(locker, {
|
|
190
|
-
type: "lock.release",
|
|
191
|
-
body: { resource: config.artifactPath },
|
|
192
|
-
});
|
|
193
|
-
process.stdout.write(`artifact=${config.artifactPath}\n`);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const defaultRoles = [
|
|
197
|
-
{ name: "mapper", persona: "systems mapper; tracks shared structure and dependencies" },
|
|
198
|
-
{ name: "memory", persona: "memory keeper; preserves decisions and unresolved questions" },
|
|
199
|
-
{ name: "risk", persona: "risk scout; challenges weak coordination and missing owners" },
|
|
200
|
-
{ name: "flow", persona: "flow designer; turns scattered ideas into process rhythm" },
|
|
201
|
-
{ name: "operator", persona: "operator; converts ideas into concrete next actions" },
|
|
202
|
-
{ name: "narrative", persona: "narrative synthesizer; keeps the artifact coherent" },
|
|
203
|
-
{ name: "interface", persona: "interface designer; makes outputs visible and usable" },
|
|
204
|
-
{ name: "facilitator", persona: "facilitator; asks for consensus and convergence" },
|
|
205
|
-
];
|
|
206
|
-
|
|
207
|
-
const config = {
|
|
208
|
-
runId: arg("run-id", process.env.run_id || process.env.RUN_ID || "room-swarm"),
|
|
209
|
-
mission: arg("mission", "Coordinate a shared artifact and converge on next actions"),
|
|
210
|
-
model: arg("model", ""),
|
|
211
|
-
thinking: arg("thinking", "off"),
|
|
212
|
-
roles: await loadRoles(arg("roles", ""), arg("roles-path", "")),
|
|
213
|
-
rounds: numberArg("rounds", 4),
|
|
214
|
-
delay: numberArg("delay", 10),
|
|
215
|
-
artifactPath: arg("artifact-path", ""),
|
|
216
|
-
locker: boolArg("locker", false),
|
|
217
|
-
lockerLeaseMs: numberArg("locker-lease-ms", 600000),
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
if (!config.model) {
|
|
221
|
-
console.error("--model is required");
|
|
222
|
-
process.exit(2);
|
|
223
|
-
}
|
|
224
|
-
config.room = `room:${config.runId}`;
|
|
225
|
-
|
|
226
|
-
const failures = [];
|
|
227
|
-
const locker = await startLocker(config);
|
|
228
|
-
try {
|
|
229
|
-
await Promise.all(config.roles.map(async (role) => {
|
|
230
|
-
try {
|
|
231
|
-
await participant(role, config);
|
|
232
|
-
} catch (error) {
|
|
233
|
-
failures.push(`${role.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
234
|
-
}
|
|
235
|
-
}));
|
|
236
|
-
await synthesize(config, locker);
|
|
237
|
-
} finally {
|
|
238
|
-
await stopLocker(locker);
|
|
239
|
-
}
|
|
240
|
-
if (failures.length > 0) {
|
|
241
|
-
console.error(failures.join("\n"));
|
|
242
|
-
process.exit(1);
|
|
243
|
-
}
|