@llblab/pi-actors 0.17.0 → 0.18.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 +5 -3
- package/BACKLOG.md +54 -29
- package/CHANGELOG.md +18 -2
- package/README.md +184 -300
- package/docs/actor-messages.md +6 -2
- package/docs/async-runs.md +3 -5
- package/docs/command-templates.md +2 -0
- package/docs/recipe-library.md +3 -0
- package/docs/task-first-recipes.md +29 -0
- package/docs/template-recipes.md +9 -14
- package/index.ts +158 -34
- package/lib/actor-inspector-tui.ts +374 -118
- package/lib/actor-rooms.ts +222 -24
- package/lib/async-runs.ts +59 -1
- package/lib/execution.ts +17 -0
- package/lib/file-state.ts +2 -1
- package/lib/observability.ts +82 -2
- package/lib/prompts.ts +2 -2
- package/lib/recipe-discovery.ts +86 -6
- package/lib/recipe-migration.ts +0 -2
- package/lib/recipe-references.ts +43 -10
- package/lib/temp.ts +55 -2
- package/lib/tools.ts +99 -11
- package/package.json +1 -1
- package/recipes/coordinator-locker.json +0 -1
- package/recipes/lens-swarm.json +0 -1
- package/recipes/music-player.json +0 -1
- package/recipes/pipeline-architect-coordinator.json +0 -1
- package/recipes/pipeline-artifact-bundle.json +0 -1
- package/recipes/pipeline-artifact-report.json +0 -1
- package/recipes/pipeline-artifact-write.json +0 -1
- package/recipes/pipeline-async-run-ops.json +0 -1
- package/recipes/pipeline-checkpoint-continuation.json +0 -1
- package/recipes/pipeline-development-tasking.json +0 -1
- package/recipes/pipeline-docs-maintenance.json +0 -1
- package/recipes/pipeline-media-library.json +0 -1
- package/recipes/pipeline-quorum-review.json +0 -1
- package/recipes/pipeline-release-readiness.json +0 -1
- package/recipes/pipeline-release-summary.json +0 -1
- package/recipes/pipeline-repo-health.json +0 -1
- package/recipes/pipeline-research-synthesis.json +0 -1
- package/recipes/pipeline-review-readiness.json +0 -1
- package/recipes/pipeline-room-swarm.json +48 -0
- package/recipes/subagent-artifact.json +0 -1
- package/recipes/subagent-checkpoint.json +0 -1
- package/recipes/subagent-conflict-report.json +0 -1
- package/recipes/subagent-contradiction-map.json +0 -1
- package/recipes/subagent-critic.json +0 -1
- package/recipes/subagent-evidence-map.json +0 -1
- package/recipes/subagent-followup.json +0 -1
- package/recipes/subagent-judge.json +0 -1
- package/recipes/subagent-merge.json +0 -1
- package/recipes/subagent-message.json +0 -1
- package/recipes/subagent-normalize.json +0 -1
- package/recipes/subagent-plan.json +0 -1
- package/recipes/subagent-prompt.json +0 -1
- package/recipes/subagent-quorum.json +0 -1
- package/recipes/subagent-review-coordinator.json +0 -1
- package/recipes/subagent-review.json +0 -1
- package/recipes/subagent-task-card.json +0 -1
- package/recipes/subagent-tools.json +0 -1
- package/recipes/subagent-verify.json +0 -1
- package/recipes/subagents-prompts.json +0 -1
- package/recipes/utility-actor-message.json +0 -1
- package/recipes/utility-artifact-manifest.json +0 -1
- package/recipes/utility-artifact-write.json +0 -1
- package/recipes/utility-changelog-head.json +0 -1
- package/recipes/utility-changelog-section.json +0 -1
- package/recipes/utility-coordinator-lock-snapshot.json +0 -1
- package/recipes/utility-git-log.json +0 -1
- package/recipes/utility-git-status.json +0 -1
- package/recipes/utility-jsonl-tail.json +0 -1
- package/recipes/utility-markdown-index.json +0 -1
- package/recipes/utility-package-summary.json +0 -1
- package/recipes/utility-playlist-build.json +0 -1
- package/recipes/utility-playlist-scan.json +0 -1
- package/recipes/utility-run-ops-snapshot.json +0 -1
- package/recipes/utility-run-state-files.json +0 -1
- package/recipes/utility-run-summary.json +0 -1
- package/recipes/utility-skill-summary.json +0 -1
- package/recipes/utility-validate-recipe.json +0 -1
- package/recipes/utility-validation-wrapper.json +0 -1
- package/scripts/room-swarm.mjs +243 -0
- package/skills/actors/SKILL.md +25 -12
- package/skills/swarm/SKILL.md +15 -1
package/lib/recipe-discovery.ts
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
* Owns filename identity discovery across prioritized recipe roots
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
8
9
|
import { join } from "node:path";
|
|
9
10
|
|
|
10
11
|
import type { RegisteredTool } from "./config.ts";
|
|
11
12
|
import type { TemplateRecipeConfig } from "./recipe-references.ts";
|
|
13
|
+
import * as CommandTemplates from "./command-templates.ts";
|
|
12
14
|
import * as RecipeReferences from "./recipe-references.ts";
|
|
13
15
|
import * as Schema from "./schema.ts";
|
|
14
16
|
|
|
@@ -28,6 +30,19 @@ export interface DiscoveredRecipe {
|
|
|
28
30
|
shadows: string[];
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
export interface RecipeIntegrityManifestEntry {
|
|
34
|
+
id: string;
|
|
35
|
+
path: string;
|
|
36
|
+
root: string;
|
|
37
|
+
sha256: string;
|
|
38
|
+
size: number;
|
|
39
|
+
tool: boolean;
|
|
40
|
+
active: boolean;
|
|
41
|
+
invalid: boolean;
|
|
42
|
+
disabled: boolean;
|
|
43
|
+
shadowed: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
export interface RecipeDiscoveryResult {
|
|
32
47
|
active: Map<string, DiscoveredRecipe>;
|
|
33
48
|
entries: DiscoveredRecipe[];
|
|
@@ -80,6 +95,16 @@ function listRecipeFiles(root: string): string[] {
|
|
|
80
95
|
.sort();
|
|
81
96
|
}
|
|
82
97
|
|
|
98
|
+
function getRecipeConfigDiagnostics(
|
|
99
|
+
file: string,
|
|
100
|
+
config: TemplateRecipeConfig | undefined,
|
|
101
|
+
): string[] {
|
|
102
|
+
if (!config) return [`Invalid recipe: ${file}`];
|
|
103
|
+
return CommandTemplates.getCommandTemplateWarnings(
|
|
104
|
+
typeof config.template === "string" ? config.template : { template: config.template },
|
|
105
|
+
).map((warning) => `Recipe ${file}: ${warning}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
83
108
|
function readDiscoveredRecipe(
|
|
84
109
|
root: string,
|
|
85
110
|
file: string,
|
|
@@ -102,9 +127,9 @@ function readDiscoveredRecipe(
|
|
|
102
127
|
shadowed: false,
|
|
103
128
|
invalid,
|
|
104
129
|
disabled,
|
|
105
|
-
tool:
|
|
130
|
+
tool: defaultTool && !disabled && !invalid,
|
|
106
131
|
mutableUsage,
|
|
107
|
-
diagnostics:
|
|
132
|
+
diagnostics: getRecipeConfigDiagnostics(file, config),
|
|
108
133
|
shadows: [],
|
|
109
134
|
};
|
|
110
135
|
} catch (error) {
|
|
@@ -144,6 +169,36 @@ function filesForSource(
|
|
|
144
169
|
: [];
|
|
145
170
|
}
|
|
146
171
|
|
|
172
|
+
function getRecipeRootDiagnostics(sources: RecipeDiscoverySource[]): string[] {
|
|
173
|
+
const diagnostics: string[] = [];
|
|
174
|
+
const roots = new Set(
|
|
175
|
+
sources
|
|
176
|
+
.map((source) => source.root)
|
|
177
|
+
.filter((root): root is string => typeof root === "string"),
|
|
178
|
+
);
|
|
179
|
+
for (const root of roots) {
|
|
180
|
+
try {
|
|
181
|
+
if (!existsSync(root)) continue;
|
|
182
|
+
const stat = statSync(root);
|
|
183
|
+
if ((stat.mode & 0o002) !== 0) {
|
|
184
|
+
diagnostics.push(
|
|
185
|
+
`Recipe root is world-writable; review permissions: ${root}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if ((stat.mode & 0o020) !== 0) {
|
|
189
|
+
diagnostics.push(
|
|
190
|
+
`Recipe root is group-writable; review ownership and permissions: ${root}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
} catch (error) {
|
|
194
|
+
diagnostics.push(
|
|
195
|
+
`Failed to inspect recipe root ${root}: ${error instanceof Error ? error.message : String(error)}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return diagnostics;
|
|
200
|
+
}
|
|
201
|
+
|
|
147
202
|
export function discoverRecipeSources(
|
|
148
203
|
sources: RecipeDiscoverySource[],
|
|
149
204
|
): RecipeDiscoveryResult {
|
|
@@ -160,7 +215,7 @@ export function discoverRecipeSources(
|
|
|
160
215
|
}
|
|
161
216
|
|
|
162
217
|
const active = new Map<string, DiscoveredRecipe>();
|
|
163
|
-
const diagnostics: string[] =
|
|
218
|
+
const diagnostics: string[] = getRecipeRootDiagnostics(sources);
|
|
164
219
|
for (const [id, bucket] of byId) {
|
|
165
220
|
bucket.sort(
|
|
166
221
|
(a, b) => a.priority - b.priority || a.path.localeCompare(b.path),
|
|
@@ -231,7 +286,7 @@ function cleanupRecommendation(entry: DiscoveredRecipe): Record<string, unknown>
|
|
|
231
286
|
id: entry.id,
|
|
232
287
|
path: entry.path,
|
|
233
288
|
reason: "active user tool has no recorded launches",
|
|
234
|
-
actions: ["keep as tool", "
|
|
289
|
+
actions: ["keep as tool", "move out of tool root", "delete", "archive"],
|
|
235
290
|
};
|
|
236
291
|
}
|
|
237
292
|
if (entry.mutableUsage && !entry.tool) {
|
|
@@ -239,7 +294,7 @@ function cleanupRecommendation(entry: DiscoveredRecipe): Record<string, unknown>
|
|
|
239
294
|
id: entry.id,
|
|
240
295
|
path: entry.path,
|
|
241
296
|
reason: "user recipe is a component, not an active tool",
|
|
242
|
-
actions: ["keep component", "
|
|
297
|
+
actions: ["keep component", "move into tool root", "merge", "delete", "archive"],
|
|
243
298
|
};
|
|
244
299
|
}
|
|
245
300
|
if (entry.shadows.length > 0) {
|
|
@@ -253,6 +308,30 @@ function cleanupRecommendation(entry: DiscoveredRecipe): Record<string, unknown>
|
|
|
253
308
|
return undefined;
|
|
254
309
|
}
|
|
255
310
|
|
|
311
|
+
export function createRecipeIntegrityManifest(
|
|
312
|
+
result: RecipeDiscoveryResult,
|
|
313
|
+
): RecipeIntegrityManifestEntry[] {
|
|
314
|
+
return result.entries
|
|
315
|
+
.map((entry) => {
|
|
316
|
+
const bytes = readFileSync(entry.path);
|
|
317
|
+
return {
|
|
318
|
+
active: entry.active,
|
|
319
|
+
disabled: entry.disabled,
|
|
320
|
+
id: entry.id,
|
|
321
|
+
invalid: entry.invalid,
|
|
322
|
+
path: entry.path,
|
|
323
|
+
root: entry.root,
|
|
324
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
325
|
+
shadowed: entry.shadowed,
|
|
326
|
+
size: bytes.byteLength,
|
|
327
|
+
tool: entry.tool,
|
|
328
|
+
};
|
|
329
|
+
})
|
|
330
|
+
.sort(
|
|
331
|
+
(a, b) => a.id.localeCompare(b.id) || a.path.localeCompare(b.path),
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
256
335
|
function recommendationForEntry(
|
|
257
336
|
entry: DiscoveredRecipe,
|
|
258
337
|
activePath: string | undefined,
|
|
@@ -298,6 +377,7 @@ export function summarizeDiscovery(result: RecipeDiscoveryResult): Record<string
|
|
|
298
377
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
299
378
|
recommendations,
|
|
300
379
|
diagnostics: result.diagnostics,
|
|
380
|
+
integrity_manifest: createRecipeIntegrityManifest(result),
|
|
301
381
|
};
|
|
302
382
|
}
|
|
303
383
|
|
package/lib/recipe-migration.ts
CHANGED
|
@@ -35,9 +35,7 @@ function recipePathForTool(recipeRoot: string, name: string): string {
|
|
|
35
35
|
|
|
36
36
|
function toRecipeConfig(tool: Config.RegisteredTool): TemplateRecipeConfig {
|
|
37
37
|
return {
|
|
38
|
-
name: tool.name,
|
|
39
38
|
description: tool.description,
|
|
40
|
-
tool: true,
|
|
41
39
|
...(tool.recipe?.async !== undefined ? { async: tool.recipe.async } : {}),
|
|
42
40
|
...(tool.recipe?.state_dir ? { state_dir: tool.recipe.state_dir } : {}),
|
|
43
41
|
...(tool.storedArgs ? { args: tool.storedArgs } : {}),
|
package/lib/recipe-references.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Owns detection, loading, and recipe-layer expansion for template recipe files
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { basename, dirname, resolve } from "node:path";
|
|
10
10
|
|
|
@@ -15,6 +15,9 @@ import type {
|
|
|
15
15
|
import * as CommandTemplates from "./command-templates.ts";
|
|
16
16
|
import * as Paths from "./paths.ts";
|
|
17
17
|
|
|
18
|
+
const MAX_RECIPE_FILE_BYTES = 1024 * 1024;
|
|
19
|
+
const MAX_RECIPE_IMPORT_DEPTH = 32;
|
|
20
|
+
|
|
18
21
|
export interface TemplateRecipeImportBinding {
|
|
19
22
|
from?: string;
|
|
20
23
|
defaults?: Record<string, unknown>;
|
|
@@ -31,7 +34,6 @@ export interface TemplateRecipeMailbox {
|
|
|
31
34
|
export interface TemplateRecipeDefinition {
|
|
32
35
|
name?: string;
|
|
33
36
|
description?: string;
|
|
34
|
-
tool?: boolean;
|
|
35
37
|
disabled?: boolean;
|
|
36
38
|
imports?: Record<string, TemplateRecipeImport>;
|
|
37
39
|
template: CommandTemplateValue;
|
|
@@ -45,6 +47,7 @@ export interface TemplateRecipeDefinition {
|
|
|
45
47
|
output?: string;
|
|
46
48
|
artifacts?: Record<string, string>;
|
|
47
49
|
mailbox?: TemplateRecipeMailbox;
|
|
50
|
+
retire_when?: "children_terminal";
|
|
48
51
|
retry?: number | string;
|
|
49
52
|
failure?: CommandTemplates.CommandTemplateFailureScope;
|
|
50
53
|
recover?: CommandTemplateValue;
|
|
@@ -88,6 +91,28 @@ export function resolveRecipePath(
|
|
|
88
91
|
);
|
|
89
92
|
}
|
|
90
93
|
|
|
94
|
+
function isBareRecipeName(value: string): boolean {
|
|
95
|
+
const trimmed = value.trim();
|
|
96
|
+
return Boolean(trimmed) && !trimmed.includes("/") && !trimmed.startsWith("~") && !trimmed.includes("{");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function recipeNameFile(value: string): string {
|
|
100
|
+
const trimmed = value.trim();
|
|
101
|
+
return trimmed.endsWith(".json") ? trimmed : `${trimmed}.json`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveRecipeImportPath(value: string, currentRecipeRoot: string): string {
|
|
105
|
+
if (!isBareRecipeName(value)) return resolveRecipePath(value, currentRecipeRoot);
|
|
106
|
+
const file = recipeNameFile(value);
|
|
107
|
+
const roots = [
|
|
108
|
+
Paths.getRecipeRoot(),
|
|
109
|
+
currentRecipeRoot,
|
|
110
|
+
Paths.getPackagedRecipeRoot(),
|
|
111
|
+
];
|
|
112
|
+
const candidates = [...new Set(roots.map((root) => resolve(root, file)))];
|
|
113
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
|
|
114
|
+
}
|
|
115
|
+
|
|
91
116
|
export function getRecipePath(
|
|
92
117
|
value: unknown,
|
|
93
118
|
recipeRoot = Paths.getRecipeRoot(),
|
|
@@ -197,6 +222,12 @@ function readRawRecipeConfig(
|
|
|
197
222
|
path: string,
|
|
198
223
|
): Record<string, unknown> | undefined {
|
|
199
224
|
if (!existsSync(path)) return undefined;
|
|
225
|
+
const size = statSync(path).size;
|
|
226
|
+
if (size > MAX_RECIPE_FILE_BYTES) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Recipe file exceeds size limit ${MAX_RECIPE_FILE_BYTES} bytes: ${path}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
200
231
|
try {
|
|
201
232
|
const raw = JSON.parse(readFileSync(path, "utf8")) as Record<
|
|
202
233
|
string,
|
|
@@ -471,11 +502,16 @@ export function readResolvedRecipeConfig(
|
|
|
471
502
|
if (stack.includes(path)) {
|
|
472
503
|
throw new Error(`Cyclic recipe import: ${[...stack, path].join(" -> ")}`);
|
|
473
504
|
}
|
|
505
|
+
if (stack.length >= MAX_RECIPE_IMPORT_DEPTH) {
|
|
506
|
+
throw new Error(
|
|
507
|
+
`Recipe import depth exceeds limit ${MAX_RECIPE_IMPORT_DEPTH}: ${[...stack, path].join(" -> ")}`,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
474
510
|
const raw = readRawRecipeConfig(path);
|
|
475
511
|
if (!raw || !Object.hasOwn(raw, "template")) return undefined;
|
|
476
512
|
const imports: Record<string, ImportedRecipe> = {};
|
|
477
513
|
for (const [alias, binding] of Object.entries(getRecipeImports(raw))) {
|
|
478
|
-
const importPath =
|
|
514
|
+
const importPath = resolveRecipeImportPath(getImportFrom(binding), dirname(path));
|
|
479
515
|
const config = readResolvedRecipeConfig(importPath, [...stack, path]);
|
|
480
516
|
if (!config) throw new Error(`Recipe import not found: ${alias}`);
|
|
481
517
|
const bindingDefaults =
|
|
@@ -499,17 +535,11 @@ export function readResolvedRecipeConfig(
|
|
|
499
535
|
if (!template) return undefined;
|
|
500
536
|
const expandedTemplate = expandImportNodes(template, imports);
|
|
501
537
|
return {
|
|
502
|
-
name:
|
|
503
|
-
typeof substituted.name === "string"
|
|
504
|
-
? substituted.name
|
|
505
|
-
: getRecipeIdFromPath(path),
|
|
538
|
+
name: getRecipeIdFromPath(path),
|
|
506
539
|
...(typeof substituted.description === "string" &&
|
|
507
540
|
substituted.description.trim()
|
|
508
541
|
? { description: substituted.description.trim() }
|
|
509
542
|
: {}),
|
|
510
|
-
...(typeof substituted.tool === "boolean"
|
|
511
|
-
? { tool: substituted.tool }
|
|
512
|
-
: {}),
|
|
513
543
|
...(typeof substituted.disabled === "boolean"
|
|
514
544
|
? { disabled: substituted.disabled }
|
|
515
545
|
: {}),
|
|
@@ -582,6 +612,9 @@ export function readResolvedRecipeConfig(
|
|
|
582
612
|
},
|
|
583
613
|
}
|
|
584
614
|
: {}),
|
|
615
|
+
...(substituted.retire_when === "children_terminal"
|
|
616
|
+
? { retire_when: "children_terminal" as const }
|
|
617
|
+
: {}),
|
|
585
618
|
...(typeof substituted.retry === "number" ||
|
|
586
619
|
typeof substituted.retry === "string"
|
|
587
620
|
? { retry: substituted.retry }
|
package/lib/temp.ts
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* Owns pi-agent tmp directory preparation and stale-entry cleanup
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
|
7
|
+
import { mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
|
|
10
10
|
export const DEFAULT_TEMP_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
export const DEFAULT_RUN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
11
12
|
|
|
12
13
|
export async function cleanupStaleTempEntries(
|
|
13
14
|
tempDir: string,
|
|
@@ -37,10 +38,62 @@ export async function cleanupStaleTempEntries(
|
|
|
37
38
|
return removed;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
function isPidAlive(pid: number): boolean {
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function isRunEntryAlive(path: string): Promise<boolean> {
|
|
51
|
+
try {
|
|
52
|
+
const raw = await readFile(join(path, "run.json"), "utf8");
|
|
53
|
+
const meta = JSON.parse(raw) as Record<string, unknown>;
|
|
54
|
+
const pid = Number(meta.pid || 0);
|
|
55
|
+
return pid > 0 && isPidAlive(pid);
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function cleanupStaleRunEntries(
|
|
62
|
+
runsDir: string,
|
|
63
|
+
maxAgeMs = DEFAULT_RUN_MAX_AGE_MS,
|
|
64
|
+
now = Date.now(),
|
|
65
|
+
): Promise<number> {
|
|
66
|
+
let entries: Array<{ name: string; isDirectory(): boolean }>;
|
|
67
|
+
let removed = 0;
|
|
68
|
+
try {
|
|
69
|
+
entries = await readdir(runsDir, { withFileTypes: true });
|
|
70
|
+
} catch {
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (!entry.isDirectory()) continue;
|
|
75
|
+
const path = join(runsDir, entry.name);
|
|
76
|
+
try {
|
|
77
|
+
const info = await stat(path);
|
|
78
|
+
const timestamp = Math.min(info.birthtimeMs || info.mtimeMs, info.mtimeMs);
|
|
79
|
+
if (now - timestamp <= maxAgeMs) continue;
|
|
80
|
+
if (await isRunEntryAlive(path)) continue;
|
|
81
|
+
await rm(path, { force: true, recursive: true });
|
|
82
|
+
removed += 1;
|
|
83
|
+
} catch {
|
|
84
|
+
// Ignore temp cleanup races.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return removed;
|
|
88
|
+
}
|
|
89
|
+
|
|
40
90
|
export async function prepareExtensionTempDir(
|
|
41
91
|
tempDir: string,
|
|
42
92
|
maxAgeMs = DEFAULT_TEMP_MAX_AGE_MS,
|
|
93
|
+
runMaxAgeMs = DEFAULT_RUN_MAX_AGE_MS,
|
|
43
94
|
): Promise<number> {
|
|
44
95
|
await mkdir(tempDir, { recursive: true });
|
|
45
|
-
|
|
96
|
+
const removedTemp = await cleanupStaleTempEntries(tempDir, maxAgeMs);
|
|
97
|
+
const removedRuns = await cleanupStaleRunEntries(join(tempDir, "runs"), runMaxAgeMs);
|
|
98
|
+
return removedTemp + removedRuns;
|
|
46
99
|
}
|
package/lib/tools.ts
CHANGED
|
@@ -164,6 +164,7 @@ function compactAsyncRunStatus(value: unknown): string {
|
|
|
164
164
|
];
|
|
165
165
|
if (status.tool) tokens.push(`tool=${String(status.tool)}`);
|
|
166
166
|
if (status.recipe) tokens.push(`recipe=${String(status.recipe)}`);
|
|
167
|
+
if (status.retire_when) tokens.push(`retire_when=${String(status.retire_when)}`);
|
|
167
168
|
if (Number(status.pid) > 0) tokens.push(`pid=${Number(status.pid)}`);
|
|
168
169
|
if (progress.phase && progress.phase !== status.status)
|
|
169
170
|
tokens.push(`phase=${String(progress.phase)}`);
|
|
@@ -286,6 +287,25 @@ function compactCommunicationSnapshot(
|
|
|
286
287
|
return `\nself=${snapshot.self} root=${snapshot.root} rooms=${snapshot.rooms.length} updated_at=${snapshot.updated_at}`;
|
|
287
288
|
}
|
|
288
289
|
|
|
290
|
+
function compactBranchInbox(messages: Array<Record<string, unknown>>): string {
|
|
291
|
+
if (messages.length === 0) return "\n(no branch inbox messages)";
|
|
292
|
+
return `\n${messages
|
|
293
|
+
.map((message) =>
|
|
294
|
+
[
|
|
295
|
+
...(message.id ? [`id=${String(message.id)}`] : []),
|
|
296
|
+
`status=${String(message.status ?? "")}`,
|
|
297
|
+
`type=${String(message.type ?? "")}`,
|
|
298
|
+
`from=${String(message.from ?? "")}`,
|
|
299
|
+
`to=${String(message.to ?? "")}`,
|
|
300
|
+
...(message.queued_at ? [`queued_at=${String(message.queued_at)}`] : []),
|
|
301
|
+
...(message.claimed_at ? [`claimed_at=${String(message.claimed_at)}`] : []),
|
|
302
|
+
...(message.handled_at ? [`handled_at=${String(message.handled_at)}`] : []),
|
|
303
|
+
...(message.failed_at ? [`failed_at=${String(message.failed_at)}`] : []),
|
|
304
|
+
].join(" "),
|
|
305
|
+
)
|
|
306
|
+
.join("\n")}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
289
309
|
function compactActorFiles(status: Record<string, unknown>): string {
|
|
290
310
|
const run = String(status.run ?? "<unknown>");
|
|
291
311
|
const artifacts = asRecord(status.artifacts);
|
|
@@ -311,10 +331,15 @@ function compactSessionRuns(
|
|
|
311
331
|
): string {
|
|
312
332
|
if (runs.length === 0) return `\nsession=${session} runs=0`;
|
|
313
333
|
return `\nsession=${session} runs=${runs.length}\n${runs
|
|
314
|
-
.map(
|
|
315
|
-
|
|
316
|
-
`run=${String(run.run ?? "")}
|
|
317
|
-
|
|
334
|
+
.map((run) => {
|
|
335
|
+
const tokens = [
|
|
336
|
+
`run=${String(run.run ?? "")}`,
|
|
337
|
+
`status=${String(run.status ?? "")}`,
|
|
338
|
+
];
|
|
339
|
+
if (run.recipe) tokens.push(`recipe=${String(run.recipe)}`);
|
|
340
|
+
if (run.retire_when) tokens.push(`retire_when=${String(run.retire_when)}`);
|
|
341
|
+
return tokens.join(" ");
|
|
342
|
+
})
|
|
318
343
|
.join("\n")}`;
|
|
319
344
|
}
|
|
320
345
|
|
|
@@ -474,6 +499,29 @@ function assertMessageSenderBelongsToRun(
|
|
|
474
499
|
}
|
|
475
500
|
}
|
|
476
501
|
|
|
502
|
+
function getRoomMulticastRecipients(
|
|
503
|
+
message: ActorMessages.ActorMessage,
|
|
504
|
+
run: string,
|
|
505
|
+
): string[] {
|
|
506
|
+
const raw = message.metadata?.recipients;
|
|
507
|
+
if (raw === undefined) return [];
|
|
508
|
+
if (!Array.isArray(raw)) {
|
|
509
|
+
throw new Error("room multicast metadata.recipients must be an array.");
|
|
510
|
+
}
|
|
511
|
+
return raw.map((recipient) => {
|
|
512
|
+
if (typeof recipient !== "string") {
|
|
513
|
+
throw new Error("room multicast recipients must be actor addresses.");
|
|
514
|
+
}
|
|
515
|
+
const parsed = ActorMessages.parseActorAddress(recipient);
|
|
516
|
+
if (parsed.kind !== "branch" || parsed.value !== run) {
|
|
517
|
+
throw new Error(
|
|
518
|
+
`room multicast recipient must be branch:${run}/<branch>; got ${recipient}.`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
return ActorMessages.formatActorAddress(parsed);
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
477
525
|
export function createSpawnToolDefinition<
|
|
478
526
|
TContext extends AsyncRunToolContext,
|
|
479
527
|
>(): any {
|
|
@@ -534,7 +582,9 @@ export function createSpawnToolDefinition<
|
|
|
534
582
|
run_id: runId,
|
|
535
583
|
state_dir:
|
|
536
584
|
typeof input.state_dir === "string" ? input.state_dir : undefined,
|
|
537
|
-
|
|
585
|
+
...(input.template !== undefined
|
|
586
|
+
? { template: input.template as AsyncRuns.AsyncRunStartParams["template"] }
|
|
587
|
+
: {}),
|
|
538
588
|
values: asRecord(input.values),
|
|
539
589
|
...(input.artifacts &&
|
|
540
590
|
typeof input.artifacts === "object" &&
|
|
@@ -840,11 +890,34 @@ export function createInspectToolDefinition<TContext = unknown>(
|
|
|
840
890
|
}
|
|
841
891
|
throw new Error("inspect room:<run> supports view=status, view=messages, view=previews, view=roster, or view=contacts.");
|
|
842
892
|
}
|
|
843
|
-
const runId = address.kind === "run" ? address.value : undefined;
|
|
893
|
+
const runId = address.kind === "run" || address.kind === "branch" ? address.value : undefined;
|
|
844
894
|
if (!runId)
|
|
845
895
|
throw new Error(
|
|
846
|
-
"inspect target must be run:<id>, coordinator, session:<id>, or tool:<name>.",
|
|
896
|
+
"inspect target must be run:<id>, branch:<run>/<branch>, coordinator, session:<id>, or tool:<name>.",
|
|
847
897
|
);
|
|
898
|
+
if (address.kind === "branch") {
|
|
899
|
+
if (view !== "mailbox") throw new Error("inspect branch:<run>/<branch> supports view=mailbox.");
|
|
900
|
+
const status = assertRunAccessibleToContext(runId, ctx);
|
|
901
|
+
const messages = ActorRooms.readBranchInboxMessages(
|
|
902
|
+
String(status.state_dir ?? ""),
|
|
903
|
+
runId,
|
|
904
|
+
target,
|
|
905
|
+
Number(input.lines || 40),
|
|
906
|
+
);
|
|
907
|
+
return {
|
|
908
|
+
content: [
|
|
909
|
+
{
|
|
910
|
+
type: "text" as const,
|
|
911
|
+
text: maybeJsonText(
|
|
912
|
+
messages,
|
|
913
|
+
input.verbose === true,
|
|
914
|
+
compactBranchInbox(messages.map((message) => ({ ...message }))),
|
|
915
|
+
),
|
|
916
|
+
},
|
|
917
|
+
],
|
|
918
|
+
details: { messages },
|
|
919
|
+
};
|
|
920
|
+
}
|
|
848
921
|
switch (view) {
|
|
849
922
|
case "status": {
|
|
850
923
|
const status = assertRunAccessibleToContext(runId, ctx);
|
|
@@ -945,7 +1018,7 @@ export function createInspectToolDefinition<TContext = unknown>(
|
|
|
945
1018
|
}
|
|
946
1019
|
default:
|
|
947
1020
|
throw new Error(
|
|
948
|
-
"inspect view must be one of: status, tail, messages, artifacts, files, mailbox, communication.",
|
|
1021
|
+
"inspect view must be one of: status, tail, messages, artifacts, files, mailbox, communication; branch targets support mailbox.",
|
|
949
1022
|
);
|
|
950
1023
|
}
|
|
951
1024
|
},
|
|
@@ -1055,17 +1128,32 @@ export function createActorMessageToolDefinition<TContext = unknown>(
|
|
|
1055
1128
|
}
|
|
1056
1129
|
}
|
|
1057
1130
|
ActorRooms.writeCommunicationSnapshot(stateDir, runId);
|
|
1131
|
+
ActorRooms.appendBranchInboxMessage(stateDir, runId, message.to, message);
|
|
1058
1132
|
}
|
|
1059
1133
|
result = AsyncRuns.sendRunMessage(
|
|
1060
1134
|
address.value,
|
|
1061
1135
|
JSON.stringify(message),
|
|
1062
1136
|
);
|
|
1063
1137
|
} else if (address.kind === "room" && address.value && address.room) {
|
|
1064
|
-
|
|
1065
|
-
|
|
1138
|
+
const runId = address.value;
|
|
1139
|
+
assertMessageSenderBelongsToRun(message, runId, `room:${runId}`);
|
|
1140
|
+
const status = assertRunExistsForActorMessage(runId);
|
|
1066
1141
|
const stateDir = String(status.state_dir ?? "");
|
|
1067
1142
|
if (!stateDir) throw new Error(`${message.to} has no run state directory.`);
|
|
1068
|
-
|
|
1143
|
+
const recipients = getRoomMulticastRecipients(message, runId);
|
|
1144
|
+
const roomResult = ActorRooms.appendRoomMessage(stateDir, address.room, message);
|
|
1145
|
+
const multicast = recipients.map((recipient) =>
|
|
1146
|
+
AsyncRuns.sendRunMessage(
|
|
1147
|
+
runId,
|
|
1148
|
+
JSON.stringify({ ...message, to: recipient }),
|
|
1149
|
+
),
|
|
1150
|
+
);
|
|
1151
|
+
result = {
|
|
1152
|
+
...roomResult,
|
|
1153
|
+
...(multicast.length > 0
|
|
1154
|
+
? { multicast: recipients, multicast_count: multicast.length }
|
|
1155
|
+
: {}),
|
|
1156
|
+
};
|
|
1069
1157
|
} else if (address.kind === "tool" && address.value) {
|
|
1070
1158
|
const tool = deps.getTool?.(address.value);
|
|
1071
1159
|
if (!tool || typeof tool.execute !== "function") {
|
package/package.json
CHANGED
package/recipes/lens-swarm.json
CHANGED