@llblab/pi-actors 0.20.2 → 0.22.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/BACKLOG.md +34 -80
- package/CHANGELOG.md +29 -0
- package/README.md +7 -1
- package/dist/index.js +11 -0
- package/dist/lib/actor-rooms.d.ts +1 -0
- package/dist/lib/actor-rooms.js +33 -1
- package/dist/lib/async-runs.d.ts +35 -1
- package/dist/lib/async-runs.js +318 -36
- package/dist/lib/command-templates.js +8 -1
- package/dist/lib/observability.d.ts +15 -0
- package/dist/lib/observability.js +103 -18
- package/dist/lib/recipe-discovery.js +13 -5
- package/dist/lib/recipe-references.js +137 -11
- package/dist/lib/runtime-notifier.d.ts +48 -0
- package/dist/lib/runtime-notifier.js +137 -0
- package/dist/lib/tools.js +18 -9
- package/docs/README.md +1 -1
- package/docs/actor-messages.md +8 -3
- package/docs/async-runs.md +7 -5
- package/docs/recipe-library.md +25 -8
- package/docs/template-recipes.md +37 -7
- package/docs/tool-registry.md +4 -3
- package/index.ts +14 -0
- package/lib/actor-rooms.ts +46 -1
- package/lib/async-runs.ts +433 -49
- package/lib/command-templates.ts +8 -1
- package/lib/observability.ts +133 -20
- package/lib/recipe-discovery.ts +21 -6
- package/lib/recipe-references.ts +141 -17
- package/lib/runtime-notifier.ts +207 -0
- package/lib/tools.ts +36 -13
- package/package.json +2 -3
- package/recipes/music-player.json +1 -1
- package/recipes/pipeline-room-swarm.json +1 -1
- package/scripts/coordinator.mjs +276 -135
- package/scripts/locker.mjs +87 -28
- package/scripts/music-player.mjs +401 -94
- package/scripts/validate-recipe.mjs +2 -2
- package/skills/actors/SKILL.md +10 -9
- package/skills/swarm/SKILL.md +1 -1
- package/index.js +0 -19
|
@@ -36,15 +36,18 @@ function listRecipeFiles(root) {
|
|
|
36
36
|
return [];
|
|
37
37
|
return readdirSync(root, { withFileTypes: true })
|
|
38
38
|
.filter((entry) => entry.isFile() &&
|
|
39
|
-
entry.name.endsWith(".json") &&
|
|
39
|
+
(entry.name.endsWith(".json") || entry.name.endsWith(".md")) &&
|
|
40
40
|
entry.name !== "legacy-tool-registry-migration-report.json")
|
|
41
41
|
.map((entry) => join(root, entry.name))
|
|
42
|
-
.sort();
|
|
42
|
+
.sort((a, b) => a.replace(/\.md$/, ".json").localeCompare(b.replace(/\.md$/, ".json")) || (a.endsWith(".json") ? -1 : 1));
|
|
43
43
|
}
|
|
44
44
|
function getRecipeConfigDiagnostics(file, config) {
|
|
45
45
|
if (!config)
|
|
46
46
|
return [`Invalid recipe: ${file}`];
|
|
47
|
-
|
|
47
|
+
const commandTemplateConfig = typeof config.template === "object" && config.template !== null
|
|
48
|
+
? config.template
|
|
49
|
+
: config;
|
|
50
|
+
return CommandTemplates.getCommandTemplateWarnings(commandTemplateConfig).map((warning) => `Recipe ${file}: ${warning}`);
|
|
48
51
|
}
|
|
49
52
|
function readDiscoveredRecipe(root, file, priority, defaultTool = false, mutableUsage = false) {
|
|
50
53
|
const id = RecipeReferences.getRecipeIdFromPath(file);
|
|
@@ -135,13 +138,18 @@ export function discoverRecipeSources(sources) {
|
|
|
135
138
|
const active = new Map();
|
|
136
139
|
const diagnostics = getRecipeRootDiagnostics(sources);
|
|
137
140
|
for (const [id, bucket] of byId) {
|
|
138
|
-
bucket.sort((a, b) => a.priority - b.priority ||
|
|
141
|
+
bucket.sort((a, b) => a.priority - b.priority ||
|
|
142
|
+
a.path.replace(/\.md$/, ".json").localeCompare(b.path.replace(/\.md$/, ".json")) ||
|
|
143
|
+
(a.path.endsWith(".json") ? -1 : 1));
|
|
139
144
|
const winner = bucket[0];
|
|
140
145
|
winner.active = true;
|
|
141
146
|
winner.shadows = bucket.slice(1).map((entry) => entry.path);
|
|
142
147
|
active.set(id, winner);
|
|
143
|
-
for (const shadow of bucket.slice(1))
|
|
148
|
+
for (const shadow of bucket.slice(1)) {
|
|
144
149
|
shadow.shadowed = true;
|
|
150
|
+
if (winner.path.endsWith(".json") && shadow.path.endsWith(".md"))
|
|
151
|
+
shadow.diagnostics.push(`Markdown recipe ${shadow.path} is shadowed by JSON recipe ${winner.path}`);
|
|
152
|
+
}
|
|
145
153
|
if (winner.invalid)
|
|
146
154
|
diagnostics.push(`Recipe ${id} is invalid and blocks lower-priority recipes`);
|
|
147
155
|
if (winner.disabled)
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
|
-
import { basename, dirname, resolve } from "node:path";
|
|
8
|
+
import { basename, dirname, extname, resolve } from "node:path";
|
|
9
9
|
import * as CommandTemplates from "./command-templates.js";
|
|
10
10
|
import * as Paths from "./paths.js";
|
|
11
11
|
const MAX_RECIPE_FILE_BYTES = 1024 * 1024;
|
|
@@ -23,26 +23,31 @@ export function resolveRecipePath(value, recipeRoot = Paths.getRecipeRoot()) {
|
|
|
23
23
|
return resolve(homedir(), expanded.slice(2));
|
|
24
24
|
if (expanded.includes("/"))
|
|
25
25
|
return resolve(expanded);
|
|
26
|
-
return resolve(recipeRoot, expanded.endsWith(".json")
|
|
26
|
+
return resolve(recipeRoot, expanded.endsWith(".json") || expanded.endsWith(".md")
|
|
27
|
+
? expanded
|
|
28
|
+
: `${expanded}.json`);
|
|
27
29
|
}
|
|
28
30
|
function isBareRecipeName(value) {
|
|
29
31
|
const trimmed = value.trim();
|
|
30
32
|
return Boolean(trimmed) && !trimmed.includes("/") && !trimmed.startsWith("~") && !trimmed.includes("{");
|
|
31
33
|
}
|
|
32
|
-
function
|
|
34
|
+
function recipeNameFiles(value) {
|
|
33
35
|
const trimmed = value.trim();
|
|
34
|
-
|
|
36
|
+
if (trimmed.endsWith(".json") || trimmed.endsWith(".md"))
|
|
37
|
+
return [trimmed];
|
|
38
|
+
return [`${trimmed}.json`, `${trimmed}.md`];
|
|
35
39
|
}
|
|
36
40
|
function resolveRecipeImportPath(value, currentRecipeRoot) {
|
|
37
41
|
if (!isBareRecipeName(value))
|
|
38
42
|
return resolveRecipePath(value, currentRecipeRoot);
|
|
39
|
-
const file = recipeNameFile(value);
|
|
40
43
|
const roots = [
|
|
41
44
|
Paths.getRecipeRoot(),
|
|
42
45
|
currentRecipeRoot,
|
|
43
46
|
Paths.getPackagedRecipeRoot(),
|
|
44
47
|
];
|
|
45
|
-
const candidates = [
|
|
48
|
+
const candidates = [
|
|
49
|
+
...new Set(roots.flatMap((root) => recipeNameFiles(value).map((file) => resolve(root, file)))),
|
|
50
|
+
];
|
|
46
51
|
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
|
|
47
52
|
}
|
|
48
53
|
export function getRecipePath(value, recipeRoot = Paths.getRecipeRoot()) {
|
|
@@ -51,13 +56,15 @@ export function getRecipePath(value, recipeRoot = Paths.getRecipeRoot()) {
|
|
|
51
56
|
const trimmed = value.trim();
|
|
52
57
|
if (!trimmed || hasWhitespace(trimmed))
|
|
53
58
|
return undefined;
|
|
54
|
-
if (trimmed.endsWith(".json"))
|
|
59
|
+
if (trimmed.endsWith(".json") || trimmed.endsWith(".md"))
|
|
55
60
|
return resolveRecipePath(trimmed, recipeRoot);
|
|
56
|
-
const
|
|
61
|
+
const jsonPath = resolveRecipePath(trimmed, recipeRoot);
|
|
62
|
+
const mdPath = resolveRecipePath(`${trimmed}.md`, recipeRoot);
|
|
63
|
+
const path = existsSync(jsonPath) ? jsonPath : mdPath;
|
|
57
64
|
if (!existsSync(path))
|
|
58
65
|
return undefined;
|
|
59
66
|
try {
|
|
60
|
-
const raw =
|
|
67
|
+
const raw = readRawRecipeConfig(path);
|
|
61
68
|
return raw && typeof raw === "object" && Object.hasOwn(raw, "template")
|
|
62
69
|
? path
|
|
63
70
|
: undefined;
|
|
@@ -137,6 +144,122 @@ function getRecipeCommandTemplate(raw) {
|
|
|
137
144
|
}
|
|
138
145
|
return normalizeRecipeTemplate({ ...envelope, template });
|
|
139
146
|
}
|
|
147
|
+
function parseMarkdownScalar(value) {
|
|
148
|
+
const trimmed = value.trim();
|
|
149
|
+
if (!trimmed)
|
|
150
|
+
return "";
|
|
151
|
+
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
152
|
+
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(trimmed);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return trimmed;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const quoted = trimmed.match(/^(?:"([^"]*)"|'([^']*)')$/);
|
|
161
|
+
if (quoted)
|
|
162
|
+
return quoted[1] ?? quoted[2] ?? "";
|
|
163
|
+
if (trimmed === "true")
|
|
164
|
+
return true;
|
|
165
|
+
if (trimmed === "false")
|
|
166
|
+
return false;
|
|
167
|
+
if (trimmed === "null")
|
|
168
|
+
return null;
|
|
169
|
+
if (/^-?\d+(?:\.\d+)?$/.test(trimmed))
|
|
170
|
+
return Number(trimmed);
|
|
171
|
+
return trimmed;
|
|
172
|
+
}
|
|
173
|
+
function parseMarkdownFrontmatterObject(lines) {
|
|
174
|
+
if (lines.every((line) => /^\s*-\s+/.test(line))) {
|
|
175
|
+
return lines.map((line) => parseMarkdownScalar(line.replace(/^\s*-\s+/, "")));
|
|
176
|
+
}
|
|
177
|
+
const result = {};
|
|
178
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
179
|
+
const match = lines[index].match(/^\s{2}([A-Za-z_][A-Za-z0-9_.-]*):\s*(.*)$/);
|
|
180
|
+
if (!match)
|
|
181
|
+
continue;
|
|
182
|
+
if (match[2]) {
|
|
183
|
+
result[match[1]] = parseMarkdownScalar(match[2]);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const nested = [];
|
|
187
|
+
while (index + 1 < lines.length && /^\s{4}/.test(lines[index + 1])) {
|
|
188
|
+
index += 1;
|
|
189
|
+
nested.push(lines[index].slice(2));
|
|
190
|
+
}
|
|
191
|
+
result[match[1]] = parseMarkdownFrontmatterObject(nested);
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
function parseMarkdownFrontmatter(value) {
|
|
196
|
+
const result = {};
|
|
197
|
+
const lines = value.split(/\r?\n/);
|
|
198
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
199
|
+
const line = lines[index];
|
|
200
|
+
if (!line.trim() || line.trim().startsWith("#"))
|
|
201
|
+
continue;
|
|
202
|
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_.-]*):\s*(.*)$/);
|
|
203
|
+
if (!match)
|
|
204
|
+
continue;
|
|
205
|
+
if (match[2]) {
|
|
206
|
+
result[match[1]] = parseMarkdownScalar(match[2]);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const nested = [];
|
|
210
|
+
while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
|
|
211
|
+
index += 1;
|
|
212
|
+
nested.push(lines[index]);
|
|
213
|
+
}
|
|
214
|
+
result[match[1]] = parseMarkdownFrontmatterObject(nested);
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
function findMarkdownRecipeFence(body) {
|
|
219
|
+
const pattern = /```([^\n`]*)\n([\s\S]*?)```/g;
|
|
220
|
+
for (const match of body.matchAll(pattern)) {
|
|
221
|
+
const info = match[1].trim().toLowerCase();
|
|
222
|
+
if (info.includes("recipe") ||
|
|
223
|
+
info.includes("template") ||
|
|
224
|
+
info.includes("command") ||
|
|
225
|
+
info.includes("json")) {
|
|
226
|
+
return { info, body: match[2].trim() };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
function parseMarkdownRecipeConfig(content) {
|
|
232
|
+
const lines = content.split(/\r?\n/);
|
|
233
|
+
if (lines[0]?.trim() !== "---")
|
|
234
|
+
return undefined;
|
|
235
|
+
const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
236
|
+
if (end === -1)
|
|
237
|
+
return undefined;
|
|
238
|
+
const frontmatter = parseMarkdownFrontmatter(lines.slice(1, end).join("\n"));
|
|
239
|
+
const fence = findMarkdownRecipeFence(lines.slice(end + 1).join("\n"));
|
|
240
|
+
if (!fence)
|
|
241
|
+
return Object.hasOwn(frontmatter, "template") ? frontmatter : undefined;
|
|
242
|
+
const text = fence.body.trim();
|
|
243
|
+
if (!text)
|
|
244
|
+
return undefined;
|
|
245
|
+
if (fence.info.includes("json") ||
|
|
246
|
+
fence.info.includes("recipe") ||
|
|
247
|
+
text.startsWith("{") ||
|
|
248
|
+
text.startsWith("[") ||
|
|
249
|
+
text.startsWith('"')) {
|
|
250
|
+
try {
|
|
251
|
+
const parsed = JSON.parse(text);
|
|
252
|
+
if (isRecord(parsed) && Object.hasOwn(parsed, "template"))
|
|
253
|
+
return { ...frontmatter, ...parsed };
|
|
254
|
+
return { ...frontmatter, template: parsed };
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
if (fence.info.includes("json") || fence.info.includes("recipe"))
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return { ...frontmatter, template: text };
|
|
262
|
+
}
|
|
140
263
|
export function readRawRecipeConfig(path) {
|
|
141
264
|
if (!existsSync(path))
|
|
142
265
|
return undefined;
|
|
@@ -145,7 +268,10 @@ export function readRawRecipeConfig(path) {
|
|
|
145
268
|
throw new Error(`Recipe file exceeds size limit ${MAX_RECIPE_FILE_BYTES} bytes: ${path}`);
|
|
146
269
|
}
|
|
147
270
|
try {
|
|
148
|
-
const
|
|
271
|
+
const content = readFileSync(path, "utf8");
|
|
272
|
+
if (path.endsWith(".md"))
|
|
273
|
+
return parseMarkdownRecipeConfig(content);
|
|
274
|
+
const raw = JSON.parse(content);
|
|
149
275
|
return raw && typeof raw === "object" ? raw : undefined;
|
|
150
276
|
}
|
|
151
277
|
catch {
|
|
@@ -153,7 +279,7 @@ export function readRawRecipeConfig(path) {
|
|
|
153
279
|
}
|
|
154
280
|
}
|
|
155
281
|
export function getRecipeIdFromPath(file) {
|
|
156
|
-
return basename(file,
|
|
282
|
+
return basename(file, extname(file));
|
|
157
283
|
}
|
|
158
284
|
function readRecipeConfig(value) {
|
|
159
285
|
const path = getRecipePath(value);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime wake notifications for actor state.
|
|
3
|
+
* Zones: advisory wake layer, file-backed runtime state, cross-platform notification boundary
|
|
4
|
+
* Owns best-effort live wake signals while durable mailbox/state files remain canonical.
|
|
5
|
+
*/
|
|
6
|
+
export interface RuntimeWakeEvent {
|
|
7
|
+
actor: string;
|
|
8
|
+
id: string;
|
|
9
|
+
metadata?: Record<string, unknown>;
|
|
10
|
+
reason: string;
|
|
11
|
+
state_dir: string;
|
|
12
|
+
ts: string;
|
|
13
|
+
}
|
|
14
|
+
export interface RuntimeNotifierSubscription {
|
|
15
|
+
close(): void;
|
|
16
|
+
}
|
|
17
|
+
export type RuntimeReconcileReason = "initial" | "poll" | "wake";
|
|
18
|
+
export interface RuntimeReconcileEvent {
|
|
19
|
+
actor: string;
|
|
20
|
+
reason: RuntimeReconcileReason;
|
|
21
|
+
state_dir: string;
|
|
22
|
+
ts: string;
|
|
23
|
+
}
|
|
24
|
+
export interface RuntimeNotifierSubscribeOptions {
|
|
25
|
+
onReconcile?: (event: RuntimeReconcileEvent) => void;
|
|
26
|
+
}
|
|
27
|
+
export interface FileRuntimeNotifierOptions {
|
|
28
|
+
pollIntervalMs?: number;
|
|
29
|
+
replay?: boolean;
|
|
30
|
+
watch?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface RuntimeNotifier {
|
|
33
|
+
notify(event: {
|
|
34
|
+
actor: string;
|
|
35
|
+
metadata?: Record<string, unknown>;
|
|
36
|
+
reason: string;
|
|
37
|
+
}): RuntimeWakeEvent;
|
|
38
|
+
subscribe(actor: string, onWake: (event: RuntimeWakeEvent) => void, options?: RuntimeNotifierSubscribeOptions): RuntimeNotifierSubscription;
|
|
39
|
+
}
|
|
40
|
+
export declare function runtimeWakeFile(stateDir: string): string;
|
|
41
|
+
export declare function notifyRuntimeWake(stateDir: string, event: {
|
|
42
|
+
actor: string;
|
|
43
|
+
metadata?: Record<string, unknown>;
|
|
44
|
+
reason: string;
|
|
45
|
+
}): RuntimeWakeEvent;
|
|
46
|
+
export declare function parseRuntimeWakeEventLine(line: string): RuntimeWakeEvent | undefined;
|
|
47
|
+
export declare function readRuntimeWakeEvents(stateDir: string): RuntimeWakeEvent[];
|
|
48
|
+
export declare function createFileRuntimeNotifier(stateDir: string, options?: FileRuntimeNotifierOptions): RuntimeNotifier;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime wake notifications for actor state.
|
|
3
|
+
* Zones: advisory wake layer, file-backed runtime state, cross-platform notification boundary
|
|
4
|
+
* Owns best-effort live wake signals while durable mailbox/state files remain canonical.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, watch, } from "node:fs";
|
|
8
|
+
import { basename, dirname, join } from "node:path";
|
|
9
|
+
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
10
|
+
export function runtimeWakeFile(stateDir) {
|
|
11
|
+
return join(stateDir, "wake.jsonl");
|
|
12
|
+
}
|
|
13
|
+
function normalizeWakeEvent(stateDir, event) {
|
|
14
|
+
const actor = event.actor.trim();
|
|
15
|
+
const reason = event.reason.trim();
|
|
16
|
+
if (!actor)
|
|
17
|
+
throw new Error("Runtime wake event requires actor.");
|
|
18
|
+
if (!reason)
|
|
19
|
+
throw new Error("Runtime wake event requires reason.");
|
|
20
|
+
return {
|
|
21
|
+
actor,
|
|
22
|
+
id: randomUUID(),
|
|
23
|
+
...(event.metadata ? { metadata: event.metadata } : {}),
|
|
24
|
+
reason,
|
|
25
|
+
state_dir: stateDir,
|
|
26
|
+
ts: new Date().toISOString(),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function notifyRuntimeWake(stateDir, event) {
|
|
30
|
+
const normalized = normalizeWakeEvent(stateDir, event);
|
|
31
|
+
const file = runtimeWakeFile(stateDir);
|
|
32
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
33
|
+
appendFileSync(file, `${JSON.stringify(normalized)}\n`, "utf8");
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
36
|
+
export function parseRuntimeWakeEventLine(line) {
|
|
37
|
+
try {
|
|
38
|
+
const record = JSON.parse(line);
|
|
39
|
+
if (typeof record.actor !== "string" ||
|
|
40
|
+
typeof record.id !== "string" ||
|
|
41
|
+
typeof record.reason !== "string" ||
|
|
42
|
+
typeof record.state_dir !== "string" ||
|
|
43
|
+
typeof record.ts !== "string") {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
actor: record.actor,
|
|
48
|
+
id: record.id,
|
|
49
|
+
...(record.metadata &&
|
|
50
|
+
typeof record.metadata === "object" &&
|
|
51
|
+
!Array.isArray(record.metadata)
|
|
52
|
+
? { metadata: record.metadata }
|
|
53
|
+
: {}),
|
|
54
|
+
reason: record.reason,
|
|
55
|
+
state_dir: record.state_dir,
|
|
56
|
+
ts: record.ts,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function readRuntimeWakeEvents(stateDir) {
|
|
64
|
+
const file = runtimeWakeFile(stateDir);
|
|
65
|
+
if (!existsSync(file))
|
|
66
|
+
return [];
|
|
67
|
+
return readFileSync(file, "utf8")
|
|
68
|
+
.split("\n")
|
|
69
|
+
.filter((line) => line.trim())
|
|
70
|
+
.map(parseRuntimeWakeEventLine)
|
|
71
|
+
.filter((event) => Boolean(event));
|
|
72
|
+
}
|
|
73
|
+
export function createFileRuntimeNotifier(stateDir, options = {}) {
|
|
74
|
+
const file = runtimeWakeFile(stateDir);
|
|
75
|
+
const pollIntervalMs = Math.max(25, Number(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS));
|
|
76
|
+
return {
|
|
77
|
+
notify: (event) => notifyRuntimeWake(stateDir, event),
|
|
78
|
+
subscribe: (actor, onWake, subscribeOptions = {}) => {
|
|
79
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
80
|
+
let position = options.replay || !existsSync(file) ? 0 : statSync(file).size;
|
|
81
|
+
let closed = false;
|
|
82
|
+
const reconcile = (reason) => {
|
|
83
|
+
if (closed)
|
|
84
|
+
return;
|
|
85
|
+
subscribeOptions.onReconcile?.({
|
|
86
|
+
actor,
|
|
87
|
+
reason,
|
|
88
|
+
state_dir: stateDir,
|
|
89
|
+
ts: new Date().toISOString(),
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
const drain = () => {
|
|
93
|
+
if (closed || !existsSync(file))
|
|
94
|
+
return;
|
|
95
|
+
const buffer = readFileSync(file);
|
|
96
|
+
if (position > buffer.length)
|
|
97
|
+
position = 0;
|
|
98
|
+
const chunk = buffer.subarray(position).toString("utf8");
|
|
99
|
+
position = buffer.length;
|
|
100
|
+
for (const line of chunk.split("\n")) {
|
|
101
|
+
if (!line.trim())
|
|
102
|
+
continue;
|
|
103
|
+
const event = parseRuntimeWakeEventLine(line);
|
|
104
|
+
if (event && event.actor === actor) {
|
|
105
|
+
onWake(event);
|
|
106
|
+
reconcile("wake");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
let watcher;
|
|
111
|
+
if (options.watch !== false) {
|
|
112
|
+
try {
|
|
113
|
+
watcher = watch(dirname(file), { persistent: false }, (_eventType, changedFile) => {
|
|
114
|
+
if (!changedFile || String(changedFile) === basename(file))
|
|
115
|
+
drain();
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// fs.watch availability varies by platform/filesystem; polling below is the fallback.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
reconcile("initial");
|
|
123
|
+
const timer = setInterval(() => {
|
|
124
|
+
drain();
|
|
125
|
+
reconcile("poll");
|
|
126
|
+
}, pollIntervalMs);
|
|
127
|
+
timer.unref?.();
|
|
128
|
+
return {
|
|
129
|
+
close: () => {
|
|
130
|
+
closed = true;
|
|
131
|
+
clearInterval(timer);
|
|
132
|
+
watcher?.close();
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
package/dist/lib/tools.js
CHANGED
|
@@ -235,9 +235,9 @@ function compactCommunicationSnapshot(snapshot) {
|
|
|
235
235
|
return "\n(no communication snapshot)";
|
|
236
236
|
return `\nself=${snapshot.self} root=${snapshot.root} rooms=${snapshot.rooms.length} updated_at=${snapshot.updated_at}`;
|
|
237
237
|
}
|
|
238
|
-
function
|
|
238
|
+
function compactInboxMessages(messages, emptyLabel) {
|
|
239
239
|
if (messages.length === 0)
|
|
240
|
-
return
|
|
240
|
+
return `\n(no ${emptyLabel} messages)`;
|
|
241
241
|
return `\n${messages
|
|
242
242
|
.map((message) => [
|
|
243
243
|
...(message.id ? [`id=${String(message.id)}`] : []),
|
|
@@ -246,12 +246,19 @@ function compactBranchInbox(messages) {
|
|
|
246
246
|
`from=${String(message.from ?? "")}`,
|
|
247
247
|
`to=${String(message.to ?? "")}`,
|
|
248
248
|
...(message.queued_at ? [`queued_at=${String(message.queued_at)}`] : []),
|
|
249
|
+
...(message.sent_at ? [`sent_at=${String(message.sent_at)}`] : []),
|
|
249
250
|
...(message.claimed_at ? [`claimed_at=${String(message.claimed_at)}`] : []),
|
|
250
251
|
...(message.handled_at ? [`handled_at=${String(message.handled_at)}`] : []),
|
|
251
252
|
...(message.failed_at ? [`failed_at=${String(message.failed_at)}`] : []),
|
|
252
253
|
].join(" "))
|
|
253
254
|
.join("\n")}`;
|
|
254
255
|
}
|
|
256
|
+
function compactBranchInbox(messages) {
|
|
257
|
+
return compactInboxMessages(messages, "branch inbox");
|
|
258
|
+
}
|
|
259
|
+
function compactRunMailbox(run, mailbox, messages) {
|
|
260
|
+
return `\nrun=${run} accepts=${Array.isArray(mailbox.accepts) ? mailbox.accepts.join(",") : ""} emits=${Array.isArray(mailbox.emits) ? mailbox.emits.join(",") : ""}${compactInboxMessages(messages, "run inbox")}`;
|
|
261
|
+
}
|
|
255
262
|
function compactActorFiles(status) {
|
|
256
263
|
const run = String(status.run ?? "<unknown>");
|
|
257
264
|
const artifacts = asRecord(status.artifacts);
|
|
@@ -726,14 +733,16 @@ export function createInspectToolDefinition(deps = {}) {
|
|
|
726
733
|
case "mailbox": {
|
|
727
734
|
const status = assertRunAccessibleToContext(runId, ctx);
|
|
728
735
|
const mailbox = asRecord(status.mailbox);
|
|
736
|
+
const messages = AsyncRuns.readRunInboxMessages(runId, Number(input.lines || 40));
|
|
737
|
+
const details = { mailbox, messages };
|
|
729
738
|
return {
|
|
730
739
|
content: [
|
|
731
740
|
{
|
|
732
741
|
type: "text",
|
|
733
|
-
text: maybeJsonText(
|
|
742
|
+
text: maybeJsonText(details, input.verbose === true, compactRunMailbox(String(status.run ?? runId), mailbox, messages)),
|
|
734
743
|
},
|
|
735
744
|
],
|
|
736
|
-
details
|
|
745
|
+
details,
|
|
737
746
|
};
|
|
738
747
|
}
|
|
739
748
|
case "communication": {
|
|
@@ -790,7 +799,7 @@ export function createActorMessageToolDefinition(deps = {}) {
|
|
|
790
799
|
result = AsyncRuns.killRun(address.value);
|
|
791
800
|
}
|
|
792
801
|
else {
|
|
793
|
-
result = AsyncRuns.sendRunMessage(address.value, messageBodyToRunLine(message));
|
|
802
|
+
result = await AsyncRuns.sendRunMessage(address.value, messageBodyToRunLine(message));
|
|
794
803
|
}
|
|
795
804
|
}
|
|
796
805
|
else if (address.kind === "branch" && address.value) {
|
|
@@ -820,7 +829,7 @@ export function createActorMessageToolDefinition(deps = {}) {
|
|
|
820
829
|
ActorRooms.writeCommunicationSnapshot(stateDir, runId);
|
|
821
830
|
ActorRooms.appendBranchInboxMessage(stateDir, runId, message.to, message);
|
|
822
831
|
}
|
|
823
|
-
result = AsyncRuns.sendRunMessage(address.value, JSON.stringify(message));
|
|
832
|
+
result = await AsyncRuns.sendRunMessage(address.value, JSON.stringify(message));
|
|
824
833
|
}
|
|
825
834
|
else if (address.kind === "room" && address.value && address.room) {
|
|
826
835
|
const runId = address.value;
|
|
@@ -831,11 +840,11 @@ export function createActorMessageToolDefinition(deps = {}) {
|
|
|
831
840
|
throw new Error(`${message.to} has no run state directory.`);
|
|
832
841
|
const recipients = getRoomMulticastRecipients(message, runId);
|
|
833
842
|
const roomResult = ActorRooms.appendRoomMessage(stateDir, address.room, message);
|
|
834
|
-
|
|
843
|
+
await Promise.all(recipients.map((recipient) => AsyncRuns.sendRunMessage(runId, JSON.stringify({ ...message, to: recipient }))));
|
|
835
844
|
result = {
|
|
836
845
|
...roomResult,
|
|
837
|
-
...(
|
|
838
|
-
? { multicast: recipients, multicast_count:
|
|
846
|
+
...(recipients.length > 0
|
|
847
|
+
? { multicast: recipients, multicast_count: recipients.length }
|
|
839
848
|
: {}),
|
|
840
849
|
};
|
|
841
850
|
}
|
package/docs/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Living index of all documentation in the `/docs` directory.
|
|
|
5
5
|
## Documents
|
|
6
6
|
|
|
7
7
|
- [command-templates.md](./command-templates.md) — Portable synchronous command execution standard
|
|
8
|
-
- [template-recipes.md](./template-recipes.md) — Saved JSON recipe standard, imports, and reusable command-template graph composition
|
|
8
|
+
- [template-recipes.md](./template-recipes.md) — Saved JSON/Markdown recipe standard, imports, and reusable command-template graph composition
|
|
9
9
|
- [async-runs.md](./async-runs.md) — Detached run lifecycle, state files, actor messages, cancellation, and ambient indicators
|
|
10
10
|
- [actor-messages.md](./actor-messages.md) — Actor/message protocol for symmetric communication primitives
|
|
11
11
|
- [tool-registry.md](./tool-registry.md) — Local `pi-actors` registry storage and `register_tool` adaptation
|
package/docs/actor-messages.md
CHANGED
|
@@ -44,6 +44,8 @@ An alternate implementation shape is a dedicated non-LLM communication actor: a
|
|
|
44
44
|
|
|
45
45
|
That actor-backed shape can also reduce direct file storage. Instead of every protocol feature owning JSON files as primary state, a helper actor can keep live room/roster structures in memory or another local structure and write files only as snapshots, audit logs, artifacts, or recovery checkpoints. The decision boundary is practical: keep files when durability and inspectability are the main value; prefer actor-owned structures when live coordination, subscriptions, fanout, unread state, or mutation consistency becomes the main value.
|
|
46
46
|
|
|
47
|
+
Current backend decision: keep the file-backed adapter for now. The covered workload is append-heavy room coordination plus direct branch inbox queueing/claiming, where durable local files are still the useful source of truth for recovery and `inspect`. Live notification is a separate advisory wake layer: actors may subscribe to `wake.jsonl` changes through a cross-platform file notifier and still reconcile canonical mailbox files if a wake is missed. A communication helper should be introduced only when a real workflow needs long-lived subscriptions, live fanout policy, or shared mutable room state beyond the current lock/debounce/compaction safeguards.
|
|
48
|
+
|
|
47
49
|
Package-specific endpoints may still exist, but the envelope stays the same.
|
|
48
50
|
|
|
49
51
|
## Message Envelope
|
|
@@ -94,7 +96,7 @@ Transports differ, but the public contract does not:
|
|
|
94
96
|
|
|
95
97
|
- `to: run:<id>` routes through the run-local control channel selected by that recipe or runtime adapter.
|
|
96
98
|
- `to: coordinator` routes to the runtime attention path when `from` names a run actor. `to: session:<id>` uses the same actor-message path only when the sender run is owned by that session, making explicit session-directed checkpoints possible without exposing runtime delivery knobs. Generic async-runner `command.done` messages and explicit coordinator/session-bound messages include the actor envelope fields alongside runtime metadata.
|
|
97
|
-
- `to: branch:<run>/<branch>` currently routes through the parent run mailbox with the full envelope preserved so the run or recipe-specific worker protocol can dispatch branch-local control. It also persists a queued branch-local copy under `branches/<branch>/inbox.jsonl`, inspectable with `inspect branch:<run>/<branch> view=mailbox`; compact inspection includes the inbox message `id`, status, route, type, and timestamps so worker protocols can correlate claims/retries. Branch-local inbox append and status rewrites are guarded by a small lock so direct delivery and coordinator claims do not overwrite each other during bursts. Coordinator claim handling also assigns an ID to older/manual queued records that do not have one so they can still transition to `handled` or `failed` instead of repeating forever. It is not a broadcast room and it does not make an arbitrary prompt process consume the message automatically. Target direction: direct branch messages should become initiating inbox work for long-lived branch runners, delivered into the recipient's next prompt/context as soon as the runner can accept work.
|
|
99
|
+
- `to: branch:<run>/<branch>` currently routes through the parent run mailbox with the full envelope preserved so the run or recipe-specific worker protocol can dispatch branch-local control. It also persists a queued branch-local copy under `branches/<branch>/inbox.jsonl`, inspectable with `inspect branch:<run>/<branch> view=mailbox`; compact inspection includes the inbox message `id`, status, route, type, and timestamps so worker protocols can correlate claims/retries. Branch-local inbox append and status rewrites are guarded by a small lock so direct delivery and coordinator claims do not overwrite each other during bursts. Status transitions preserve active queued/claimed records and compact older handled/failed terminal records with bounded retention, so persistent runners do not accumulate unbounded completed inbox history. Coordinator claim handling also assigns an ID to older/manual queued records that do not have one so they can still transition to `handled` or `failed` instead of repeating forever. It is not a broadcast room and it does not make an arbitrary prompt process consume the message automatically. Target direction: direct branch messages should become initiating inbox work for long-lived branch runners, delivered into the recipient's next prompt/context as soon as the runner can accept work.
|
|
98
100
|
- `to: room:<run>` appends the full envelope to the room timeline, updates room state for room-control types such as `actor.join` and `actor.leave`, and can route selected-recipient multicast when `metadata.recipients` contains same-run `branch:<run>/<branch>` addresses.
|
|
99
101
|
- `to: tool:<name>` invokes an executable pi tool by name. Object bodies become tool parameters; primitive bodies are passed as `{ "input": body }`.
|
|
100
102
|
|
|
@@ -104,7 +106,9 @@ Transport is not public API unless a recipe explicitly documents a custom endpoi
|
|
|
104
106
|
|
|
105
107
|
The task room is the discovery and shared-context layer for actors whose spawn-tree positions do not give them each other's addresses. The spawn tree remains the lifecycle/provenance structure; the task room describes the group communication graph. Direct messages and room messages can share the same semantic `type` such as `chat.message`; the route (`to: branch:*` versus `to: room:*`) determines whether delivery is private or group-wide.
|
|
106
108
|
|
|
107
|
-
Use direct branch messages only when the receiving branch is backed by a worker or recipe that reads the parent run mailbox or branch inbox and dispatches branch-targeted envelopes. Room roster contacts are discovery hints, not a guarantee that an independent prompt process is subscribed to its branch address. The current branch inbox records queued
|
|
109
|
+
Use direct branch messages only when the receiving branch is backed by a worker or recipe that reads the parent run mailbox or branch inbox and dispatches branch-targeted envelopes. Room roster contacts are discovery hints, not a guarantee that an independent prompt process is subscribed to its branch address. The current branch inbox records queued mailbox work; runner-side claiming/handling turns direct messages into prompt work for the recipient branch, while room messages remain shared transcript entries. A direct message may ask the recipient to inspect room history when broader shared context is needed. For ad hoc or transcript-driven swarms without such a runner, prefer room-visible replies and mentions so every participant can inspect the shared timeline.
|
|
110
|
+
|
|
111
|
+
Direct branch delivery is prompt steering for worker-backed branches, not a coordinator follow-up. Packaged coordinator flows claim queued branch inbox records immediately before launching the branch's next prompt, append a bounded "direct messages for you" section to that prompt, and then mark the claimed records `handled` or `failed` based on the prompt result. Generic one-shot `pi -p` children do not receive this injection automatically; a recipe must own the runner loop or use the packaged coordinator path for direct messages to become next-prompt work.
|
|
108
112
|
|
|
109
113
|
Selected-recipient multicast stays route-based: send one `to: room:<run>` envelope with `metadata.recipients` set to same-run branch addresses. The room timeline keeps the original room-visible envelope, and the runtime also forwards branch-targeted copies to each listed recipient. This is not a subroom; it is a shared transcript plus explicit direct delivery for actors whose worker protocol consumes branch envelopes.
|
|
110
114
|
|
|
@@ -195,7 +199,7 @@ Recipes can declare their conversational surface:
|
|
|
195
199
|
}
|
|
196
200
|
```
|
|
197
201
|
|
|
198
|
-
The implementation supports `status`, `tail`, `messages`, `artifacts`, `files`, `mailbox`, and `communication` for `run:<id>` actors, `status`, `messages`, `previews`, `roster`, and `contacts` for `room:<run>` actors, `status`/`runs` for `coordinator`, `session:<id>`, and `session:all` actors with optional status filtering, and `status`/`schema` for registered `tool:<name>` actors. Room `status` returns compact message/roster counts plus `last_message_at`, `last_message_from`, `last_message_type`, and `last_message_summary` when available, without parsing the full timeline into actor envelopes. Use `messages` for actor-envelope inspection. `inspect target=coordinator` requires a current coordinator session; use `session:<id>` or `session:all` when the session is intentionally explicit. Direct `run:<id>` and `room:<run>` inspection respects coordinator-session ownership when the current session is known. `inspect` is for decision points and diagnosis only; examples must not teach sleep-then-inspect polling.
|
|
202
|
+
The implementation supports `status`, `tail`, `messages`, `artifacts`, `files`, `mailbox`, and `communication` for `run:<id>` actors, `status`, `messages`, `previews`, `roster`, and `contacts` for `room:<run>` actors, `status`/`runs` for `coordinator`, `session:<id>`, and `session:all` actors with optional status filtering, and `status`/`schema` for registered `tool:<name>` actors. Run mailbox inspection shows recipe-declared mailbox metadata plus recent durable run inbox entries; branch mailbox inspection shows branch-local queued/claimed/handled records. Room `status` returns compact message/roster counts plus `last_message_at`, `last_message_from`, `last_message_type`, and `last_message_summary` when available, without parsing the full timeline into actor envelopes. Use `messages` for actor-envelope inspection. `inspect target=coordinator` requires a current coordinator session; use `session:<id>` or `session:all` when the session is intentionally explicit. Direct `run:<id>` and `room:<run>` inspection respects coordinator-session ownership when the current session is known. `inspect` is for decision points and diagnosis only; examples must not teach sleep-then-inspect polling.
|
|
199
203
|
|
|
200
204
|
## Runtime Direction
|
|
201
205
|
|
|
@@ -205,6 +209,7 @@ Runtime operations use the actor/message vocabulary:
|
|
|
205
209
|
create detached work -> spawn
|
|
206
210
|
run-local control -> message to run:<id>
|
|
207
211
|
run stop/kill -> message type control.stop/control.kill
|
|
212
|
+
platform control -> internal adapter selected from run state
|
|
208
213
|
coordinator signal -> message to coordinator/session
|
|
209
214
|
tool execution -> message to tool:<name>
|
|
210
215
|
intentional observe -> inspect
|
package/docs/async-runs.md
CHANGED
|
@@ -156,9 +156,9 @@ The actor-level surface is:
|
|
|
156
156
|
|
|
157
157
|
- `spawn`: start a detached `run:<id>` actor from `file`, `recipe`, or inline `template`.
|
|
158
158
|
- `message`: send one typed envelope to `run:<id>`, `branch:<run>/<branch>`, `room:<run>`, `tool:<name>`, `coordinator`, or `session:<id>`.
|
|
159
|
-
- `inspect`: intentionally read owned `run:<id>` status, tail, messages, artifacts, files, mailbox metadata, or communication snapshot; read `room:<run>` status, messages, previews, roster, or contacts; read current `coordinator` run inventory only when a coordinator session is known; read `session:<id>` or `session:all` run inventory with optional status filtering when the session is explicit; read `tool:<name>` status or schema for registered tool actors.
|
|
159
|
+
- `inspect`: intentionally read owned `run:<id>` status, tail, messages, artifacts, files, mailbox metadata plus recent run inbox entries, or communication snapshot; read `room:<run>` status, messages, previews, roster, or contacts; read current `coordinator` run inventory only when a coordinator session is known; read `session:<id>` or `session:all` run inventory with optional status filtering when the session is explicit; read `tool:<name>` status or schema for registered tool actors.
|
|
160
160
|
|
|
161
|
-
Opt-in supervisor retirement uses `retire_when: "children_terminal"` as lifecycle metadata. Candidate detection is conservative: a supervisor is not retirement-ready while command-template progress
|
|
161
|
+
Opt-in supervisor retirement uses `retire_when: "children_terminal"` as lifecycle metadata. Run summaries discover nested child run state dirs under the visible state root so bounded supervisor trees are observable. Candidate detection is conservative: a supervisor is not retirement-ready while command-template progress, descendant `pi -p` worker processes, or nested child async runs under the supervisor state dir are still active. Candidate metadata includes observed child-run counts. When the session watcher observes a ready candidate, it sends a graceful `stop` control message once; if the run has no ready control endpoint, it falls back to owned-run cancellation and records the terminal action through normal run events. Persistent or non-opt-in runs are not retirement candidates.
|
|
162
162
|
|
|
163
163
|
Low-level async actions map into the actor surface instead of forming a second public model:
|
|
164
164
|
|
|
@@ -183,9 +183,11 @@ Some recipes expose a run-local control channel. When present, a caller can send
|
|
|
183
183
|
}
|
|
184
184
|
```
|
|
185
185
|
|
|
186
|
-
For `run:<id>`, `message` adapts the body to the recipe's run-local control channel. For `branch:<run>/<branch>`, it sends the full envelope through the parent run mailbox and records a queued branch-local inbox entry at `branches/<branch>/inbox.jsonl` so the run can dispatch branch-local control. Current consumers are recipe-specific worker protocols that read the parent run mailbox or branch inbox; independent one-shot prompt processes do not automatically consume branch inbox entries. For `tool:<name>`, object bodies become the target tool parameters and primitive bodies are passed as `{ "input": body }`. The generic runtime records control messages but does not interpret arbitrary run mailbox content. For example, a music player may accept `play`, `pause`, `next`, and `stop`, while a collaborative agent recipe may accept `continue`, `revise:<note>`, `approve`, or `abort`. Recipes may treat terminal control messages such as `stop` as synchronously handled so the later process exit does not generate a duplicate async follow-up.
|
|
186
|
+
For `run:<id>`, `message` adapts the body to the recipe's run-local control channel. For `branch:<run>/<branch>`, it sends the full envelope through the parent run mailbox and records a queued branch-local inbox entry at `branches/<branch>/inbox.jsonl` so the run can dispatch branch-local control. Current consumers are recipe-specific worker protocols that read the parent run mailbox or branch inbox; independent one-shot prompt processes do not automatically consume branch inbox entries. In packaged coordinator flows, queued branch inbox records are claimed immediately before the branch's next prompt is launched, appended as direct prompt-steering context, and then marked `handled` or `failed` from the prompt result. This path is not a follow-up notification; it is a runner-owned prompt queue. For `tool:<name>`, object bodies become the target tool parameters and primitive bodies are passed as `{ "input": body }`. The generic runtime records control messages but does not interpret arbitrary run mailbox content. For example, a music player may accept `play`, `pause`, `next`, and `stop`, while a collaborative agent recipe may accept `continue`, `revise:<note>`, `approve`, or `abort`. Recipes may treat terminal control messages such as `stop` as synchronously handled so the later process exit does not generate a duplicate async follow-up.
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
Run-local control uses a platform adapter under the same `message` API. Unix recipes may keep the existing FIFO endpoint, and native Windows recipes can expose a named-pipe endpoint in run state. Recipe authors should document message vocabulary through `mailbox.accepts`, not through transport arguments. Packaged scripts that still create Unix-only endpoints remain WSL/Linux/macOS-only until migrated.
|
|
189
|
+
|
|
190
|
+
Runtime wake notifications are now modeled separately from durable queues. Message handling records canonical state in file-backed mailbox/event files before attempting optional live endpoint delivery. Runs may expose a mailbox-only control endpoint when durable inbox plus wake notification is the intended delivery path; FIFO and named-pipe endpoints remain compatibility/fast-wake paths rather than the durable queue itself. Successful FIFO or named-pipe delivery marks the run inbox entry `sent`; mailbox-only delivery leaves the entry queued for the runtime to claim. `wake.jsonl` is an advisory doorbell that lets a live runtime subscribe through file-system notifications plus explicit initial, wake-triggered, and polling reconciliation callbacks. A missed wake must not lose work because actors can re-read the canonical mailbox state. Runtime loops that consume the file-backed mailbox should claim queued run inbox entries, then mark them `handled` or `failed`; the helper path uses a small lock so concurrent reconciliation callbacks do not process the same entry twice.
|
|
189
191
|
|
|
190
192
|
## Coordinator Notifications
|
|
191
193
|
|
|
@@ -219,7 +221,7 @@ Use coordinator/session-bound messages for completion and decision points, not f
|
|
|
219
221
|
|
|
220
222
|
An async run belongs to the current user, cwd, and launching agent session at start time. Send, cancellation, and force-kill target only the recorded runner pid when command line and cwd still match the recorded owner data. Stale pid reuse must fail closed.
|
|
221
223
|
|
|
222
|
-
On Unix-like systems, cancel and kill signal the runner process group when available, then fall back to the runner pid. The runner starts command-template children in
|
|
224
|
+
On Unix-like systems, cancel and kill signal the runner process group when available, then fall back to the runner pid. On native Windows, cancel and kill use Windows process-tree termination through the platform adapter. The runner starts command-template children in the owned process tree, so long-running descendants such as audio players should stop with the run instead of becoming orphaned background processes. After the process exits, status reflects the operator action as `cancelled` or `killed` instead of a generic `exited`.
|
|
223
225
|
|
|
224
226
|
State is append-only where practical. Final result writes should be atomic. Recipe-local control endpoints and actor-message logs may live in the state dir. pi-actors core owns the generic run-local message adapter and runtime attention policy; command and message vocabularies belong to the recipe/script.
|
|
225
227
|
|