@llblab/pi-actors 0.22.4 → 0.23.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 +1 -0
- package/BACKLOG.md +0 -220
- package/CHANGELOG.md +23 -1
- package/README.md +1 -1
- package/dist/index.js +15 -0
- package/dist/lib/actor-inspector-tui.d.ts +4 -0
- package/dist/lib/actor-inspector-tui.js +53 -12
- package/dist/lib/actor-rooms.d.ts +8 -0
- package/dist/lib/actor-rooms.js +61 -10
- package/dist/lib/async-runs.d.ts +34 -2
- package/dist/lib/async-runs.js +215 -22
- package/dist/lib/command-templates.js +5 -5
- package/dist/lib/limits.d.ts +11 -0
- package/dist/lib/limits.js +11 -0
- package/dist/lib/observability.js +6 -4
- package/dist/lib/output.js +4 -5
- package/dist/lib/recipe-discovery.js +1 -0
- package/dist/lib/recipe-references.d.ts +11 -2
- package/dist/lib/recipe-references.js +4 -2
- package/dist/lib/recipe-usage.d.ts +2 -1
- package/dist/lib/recipe-usage.js +15 -3
- package/dist/lib/runtime.js +12 -2
- package/dist/lib/tools.js +197 -38
- package/docs/async-runs.md +2 -1
- package/docs/template-recipes.md +1 -1
- package/docs/tool-registry.md +0 -1
- package/index.ts +18 -0
- package/lib/actor-inspector-tui.ts +131 -29
- package/lib/actor-rooms.ts +155 -44
- package/lib/async-runs.ts +290 -31
- package/lib/command-templates.ts +5 -5
- package/lib/limits.ts +12 -0
- package/lib/observability.ts +10 -4
- package/lib/output.ts +4 -6
- package/lib/recipe-discovery.ts +1 -0
- package/lib/recipe-references.ts +20 -4
- package/lib/recipe-usage.ts +31 -4
- package/lib/runtime.ts +31 -7
- package/lib/tools.ts +296 -57
- package/package.json +2 -1
- package/scripts/async-runner.mjs +6 -0
- package/scripts/conformance.mjs +47 -0
- package/scripts/coordinator.mjs +13 -0
- package/scripts/locker.mjs +13 -0
- package/scripts/music-player.mjs +21 -2
- package/scripts/recipe-utils.mjs +13 -0
- package/scripts/validate-recipe.mjs +13 -0
- package/skills/actors/SKILL.md +3 -3
- package/skills/swarm/SKILL.md +1 -1
package/lib/async-runs.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
-
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
9
|
import {
|
|
10
10
|
closeSync,
|
|
11
11
|
constants,
|
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
openSync,
|
|
15
15
|
readdirSync,
|
|
16
16
|
readFileSync,
|
|
17
|
+
cpSync,
|
|
17
18
|
readlinkSync,
|
|
19
|
+
renameSync,
|
|
18
20
|
rmSync,
|
|
19
21
|
statSync,
|
|
20
22
|
writeFileSync,
|
|
@@ -65,7 +67,7 @@ export interface AsyncRunStartParams {
|
|
|
65
67
|
timeout?: number | string;
|
|
66
68
|
delay?: number | string;
|
|
67
69
|
output?: string;
|
|
68
|
-
artifacts?: Record<string,
|
|
70
|
+
artifacts?: Record<string, RunArtifactDeclaration>;
|
|
69
71
|
mailbox?: RecipeReferences.TemplateRecipeMailbox;
|
|
70
72
|
retire_when?: "children_terminal";
|
|
71
73
|
retry?: number | string;
|
|
@@ -121,7 +123,7 @@ export interface AsyncRunMeta {
|
|
|
121
123
|
tool?: string;
|
|
122
124
|
template: CommandTemplateValue;
|
|
123
125
|
values: Record<string, unknown>;
|
|
124
|
-
artifacts?: Record<string,
|
|
126
|
+
artifacts?: Record<string, RunArtifactDeclaration>;
|
|
125
127
|
control?: AsyncRunControlEndpoint;
|
|
126
128
|
mailbox?: RecipeReferences.TemplateRecipeMailbox;
|
|
127
129
|
recipe_context_records?: RecipeReferences.TemplateRecipeContextRecord[];
|
|
@@ -160,23 +162,93 @@ function safeRunId(value: string | undefined): string {
|
|
|
160
162
|
return run;
|
|
161
163
|
}
|
|
162
164
|
|
|
165
|
+
export type RunArtifactDeclaration =
|
|
166
|
+
| string
|
|
167
|
+
| { path: string; kind?: string; media_type?: string; required?: boolean };
|
|
168
|
+
|
|
169
|
+
export interface RunArtifactManifestEntry {
|
|
170
|
+
exists: boolean;
|
|
171
|
+
kind?: string;
|
|
172
|
+
media_type?: string;
|
|
173
|
+
path: string;
|
|
174
|
+
required?: boolean;
|
|
175
|
+
sha256?: string;
|
|
176
|
+
size?: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
163
179
|
function resolveArtifactPaths(
|
|
164
|
-
artifacts: Record<string,
|
|
180
|
+
artifacts: Record<string, RunArtifactDeclaration> | undefined,
|
|
165
181
|
values: Record<string, unknown>,
|
|
166
|
-
): Record<string,
|
|
182
|
+
): Record<string, RunArtifactDeclaration> | undefined {
|
|
167
183
|
if (!artifacts) return undefined;
|
|
168
|
-
const resolved: Record<string,
|
|
184
|
+
const resolved: Record<string, RunArtifactDeclaration> = {};
|
|
169
185
|
for (const [key, value] of Object.entries(artifacts)) {
|
|
170
186
|
if (!key.trim()) continue;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
187
|
+
if (typeof value === "string") {
|
|
188
|
+
resolved[key] = substituteCommandTemplateToken(
|
|
189
|
+
value,
|
|
190
|
+
values,
|
|
191
|
+
`recipe artifacts.${key}`,
|
|
192
|
+
);
|
|
193
|
+
} else if (
|
|
194
|
+
value &&
|
|
195
|
+
typeof value === "object" &&
|
|
196
|
+
typeof value.path === "string"
|
|
197
|
+
) {
|
|
198
|
+
resolved[key] = {
|
|
199
|
+
...value,
|
|
200
|
+
path: substituteCommandTemplateToken(
|
|
201
|
+
value.path,
|
|
202
|
+
values,
|
|
203
|
+
`recipe artifacts.${key}.path`,
|
|
204
|
+
),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
176
207
|
}
|
|
177
208
|
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
|
178
209
|
}
|
|
179
210
|
|
|
211
|
+
export function resolveArtifactManifest(
|
|
212
|
+
artifacts: Record<string, RunArtifactDeclaration> | undefined,
|
|
213
|
+
): Record<string, RunArtifactManifestEntry> | undefined {
|
|
214
|
+
if (!artifacts) return undefined;
|
|
215
|
+
const manifest: Record<string, RunArtifactManifestEntry> = {};
|
|
216
|
+
for (const [name, artifact] of Object.entries(artifacts)) {
|
|
217
|
+
const declaration =
|
|
218
|
+
typeof artifact === "string" ? { path: artifact } : artifact;
|
|
219
|
+
if (!declaration?.path) continue;
|
|
220
|
+
try {
|
|
221
|
+
const content = readFileSync(declaration.path);
|
|
222
|
+
manifest[name] = {
|
|
223
|
+
exists: true,
|
|
224
|
+
...(declaration.kind ? { kind: declaration.kind } : {}),
|
|
225
|
+
...(declaration.media_type
|
|
226
|
+
? { media_type: declaration.media_type }
|
|
227
|
+
: {}),
|
|
228
|
+
path: declaration.path,
|
|
229
|
+
...(declaration.required !== undefined
|
|
230
|
+
? { required: declaration.required }
|
|
231
|
+
: {}),
|
|
232
|
+
sha256: createHash("sha256").update(content).digest("hex"),
|
|
233
|
+
size: content.byteLength,
|
|
234
|
+
};
|
|
235
|
+
} catch {
|
|
236
|
+
manifest[name] = {
|
|
237
|
+
exists: false,
|
|
238
|
+
...(declaration.kind ? { kind: declaration.kind } : {}),
|
|
239
|
+
...(declaration.media_type
|
|
240
|
+
? { media_type: declaration.media_type }
|
|
241
|
+
: {}),
|
|
242
|
+
path: declaration.path,
|
|
243
|
+
...(declaration.required !== undefined
|
|
244
|
+
? { required: declaration.required }
|
|
245
|
+
: {}),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return Object.keys(manifest).length > 0 ? manifest : undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
180
252
|
function resolveRunTemplate(params: AsyncRunStartParams): {
|
|
181
253
|
template: CommandTemplateValue;
|
|
182
254
|
} {
|
|
@@ -426,7 +498,11 @@ export function startRun(
|
|
|
426
498
|
? RecipeReferences.buildRecipeContextRecords(recipeFile)
|
|
427
499
|
: undefined;
|
|
428
500
|
if (recipeFile && isMutableUsageRecipeFile(recipeFile)) {
|
|
429
|
-
RecipeUsage.recordRecipeLaunch(
|
|
501
|
+
RecipeUsage.recordRecipeLaunch(
|
|
502
|
+
recipeFile,
|
|
503
|
+
new Date(),
|
|
504
|
+
startParams.launch_source === "tool" ? "tool" : "spawn",
|
|
505
|
+
);
|
|
430
506
|
}
|
|
431
507
|
const outFd = openSync(stdout, "a");
|
|
432
508
|
const errFd = openSync(stderr, "a");
|
|
@@ -611,6 +687,16 @@ export function getRunStatus(runOrDir: string): Record<string, unknown> {
|
|
|
611
687
|
};
|
|
612
688
|
}
|
|
613
689
|
|
|
690
|
+
export interface RunStateIndexEntry {
|
|
691
|
+
ownerId?: string;
|
|
692
|
+
recipe?: string;
|
|
693
|
+
run: string;
|
|
694
|
+
state_dir: string;
|
|
695
|
+
status: AsyncRunStatus;
|
|
696
|
+
tool?: string;
|
|
697
|
+
updated_at?: string;
|
|
698
|
+
}
|
|
699
|
+
|
|
614
700
|
function matchesStatusFilter(
|
|
615
701
|
status: unknown,
|
|
616
702
|
filter: string | undefined,
|
|
@@ -621,30 +707,125 @@ function matchesStatusFilter(
|
|
|
621
707
|
return status === filter;
|
|
622
708
|
}
|
|
623
709
|
|
|
710
|
+
export function listRunStateDirs(
|
|
711
|
+
stateRoot = DEFAULT_STATE_ROOT,
|
|
712
|
+
depth = 0,
|
|
713
|
+
seen = new Set<string>(),
|
|
714
|
+
): string[] {
|
|
715
|
+
if (!existsSync(stateRoot) || seen.has(resolve(stateRoot))) return [];
|
|
716
|
+
seen.add(resolve(stateRoot));
|
|
717
|
+
let entries: import("node:fs").Dirent[];
|
|
718
|
+
try {
|
|
719
|
+
entries = readdirSync(stateRoot, { withFileTypes: true });
|
|
720
|
+
} catch {
|
|
721
|
+
return [];
|
|
722
|
+
}
|
|
723
|
+
const result: string[] = [];
|
|
724
|
+
for (const entry of entries) {
|
|
725
|
+
if (!entry.isDirectory()) continue;
|
|
726
|
+
const child = join(stateRoot, entry.name);
|
|
727
|
+
if (existsSync(join(child, "run.json"))) result.push(child);
|
|
728
|
+
if (depth + 1 < 8) result.push(...listRunStateDirs(child, depth + 1, seen));
|
|
729
|
+
}
|
|
730
|
+
return result;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function runIndexPath(stateRoot: string): string {
|
|
734
|
+
return join(stateRoot, "index.json");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function indexEntryFromStatus(
|
|
738
|
+
status: Record<string, unknown>,
|
|
739
|
+
): RunStateIndexEntry {
|
|
740
|
+
const progress =
|
|
741
|
+
status.progress && typeof status.progress === "object"
|
|
742
|
+
? (status.progress as Record<string, unknown>)
|
|
743
|
+
: {};
|
|
744
|
+
return {
|
|
745
|
+
...(typeof status.ownerId === "string" ? { ownerId: status.ownerId } : {}),
|
|
746
|
+
...(typeof status.recipe === "string" ? { recipe: status.recipe } : {}),
|
|
747
|
+
run: String(status.run),
|
|
748
|
+
state_dir: String(status.state_dir),
|
|
749
|
+
status: status.status as AsyncRunStatus,
|
|
750
|
+
...(typeof status.tool === "string" ? { tool: status.tool } : {}),
|
|
751
|
+
updated_at:
|
|
752
|
+
typeof progress.updatedAt === "string"
|
|
753
|
+
? progress.updatedAt
|
|
754
|
+
: typeof status.createdAt === "string"
|
|
755
|
+
? status.createdAt
|
|
756
|
+
: new Date(0).toISOString(),
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
export function rebuildRunStateIndex(
|
|
761
|
+
stateRoot = DEFAULT_STATE_ROOT,
|
|
762
|
+
): RunStateIndexEntry[] {
|
|
763
|
+
mkdirSync(stateRoot, { recursive: true });
|
|
764
|
+
const entries = listRunStateDirs(stateRoot)
|
|
765
|
+
.flatMap((stateDir) => {
|
|
766
|
+
try {
|
|
767
|
+
return [indexEntryFromStatus(getRunStatus(stateDir))];
|
|
768
|
+
} catch {
|
|
769
|
+
return [];
|
|
770
|
+
}
|
|
771
|
+
})
|
|
772
|
+
.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""));
|
|
773
|
+
writeJsonAtomic(runIndexPath(stateRoot), {
|
|
774
|
+
entries,
|
|
775
|
+
rebuilt_at: new Date().toISOString(),
|
|
776
|
+
});
|
|
777
|
+
return entries;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export function readRunStateIndex(
|
|
781
|
+
stateRoot = DEFAULT_STATE_ROOT,
|
|
782
|
+
): RunStateIndexEntry[] | undefined {
|
|
783
|
+
const index = readJson(runIndexPath(stateRoot));
|
|
784
|
+
if (!index || typeof index !== "object") return undefined;
|
|
785
|
+
const entries = (index as Record<string, unknown>).entries;
|
|
786
|
+
if (!Array.isArray(entries)) return undefined;
|
|
787
|
+
const valid = entries.filter((entry): entry is RunStateIndexEntry => {
|
|
788
|
+
const record =
|
|
789
|
+
entry && typeof entry === "object"
|
|
790
|
+
? (entry as Record<string, unknown>)
|
|
791
|
+
: {};
|
|
792
|
+
return (
|
|
793
|
+
typeof record.run === "string" &&
|
|
794
|
+
typeof record.state_dir === "string" &&
|
|
795
|
+
typeof record.status === "string"
|
|
796
|
+
);
|
|
797
|
+
});
|
|
798
|
+
if (valid.some((entry) => !existsSync(join(entry.state_dir, "run.json"))))
|
|
799
|
+
return undefined;
|
|
800
|
+
return valid;
|
|
801
|
+
}
|
|
802
|
+
|
|
624
803
|
export function listRuns(
|
|
625
804
|
stateRoot = DEFAULT_STATE_ROOT,
|
|
626
805
|
statusFilter?: string,
|
|
627
806
|
): Array<Record<string, unknown>> {
|
|
628
807
|
if (!existsSync(stateRoot)) return [];
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
...(typeof status.tool === "string" ? { tool: status.tool } : {}),
|
|
641
|
-
...(typeof status.recipe === "string" ? { recipe: status.recipe } : {}),
|
|
642
|
-
});
|
|
643
|
-
} catch {
|
|
644
|
-
// Ignore malformed run dirs.
|
|
645
|
-
}
|
|
808
|
+
const indexed = readRunStateIndex(stateRoot);
|
|
809
|
+
if (indexed) {
|
|
810
|
+
return indexed
|
|
811
|
+
.filter((entry) => matchesStatusFilter(entry.status, statusFilter))
|
|
812
|
+
.map((entry) => ({
|
|
813
|
+
run: entry.run,
|
|
814
|
+
state_dir: entry.state_dir,
|
|
815
|
+
status: entry.status,
|
|
816
|
+
...(entry.tool ? { tool: entry.tool } : {}),
|
|
817
|
+
...(entry.recipe ? { recipe: entry.recipe } : {}),
|
|
818
|
+
}));
|
|
646
819
|
}
|
|
647
|
-
return
|
|
820
|
+
return rebuildRunStateIndex(stateRoot)
|
|
821
|
+
.filter((entry) => matchesStatusFilter(entry.status, statusFilter))
|
|
822
|
+
.map((entry) => ({
|
|
823
|
+
run: entry.run,
|
|
824
|
+
state_dir: entry.state_dir,
|
|
825
|
+
status: entry.status,
|
|
826
|
+
...(entry.tool ? { tool: entry.tool } : {}),
|
|
827
|
+
...(entry.recipe ? { recipe: entry.recipe } : {}),
|
|
828
|
+
}));
|
|
648
829
|
}
|
|
649
830
|
|
|
650
831
|
export function tailRun(runOrDir: string, lines = 40): string {
|
|
@@ -879,12 +1060,19 @@ export function appendRunOutboxEvent(
|
|
|
879
1060
|
const run = String(status.run ?? runOrDir);
|
|
880
1061
|
const type = event.type || event.event || "run.message";
|
|
881
1062
|
const to = event.to || "coordinator";
|
|
1063
|
+
const metadata = event.metadata ?? {};
|
|
1064
|
+
const requiresResponse = metadata.requires_response === true;
|
|
882
1065
|
const payload = {
|
|
883
1066
|
...(event.body !== undefined ? { body: event.body } : {}),
|
|
884
1067
|
...(event.correlation_id ? { correlation_id: event.correlation_id } : {}),
|
|
885
1068
|
...(event.data !== undefined ? { data: event.data } : {}),
|
|
886
1069
|
delivery: normalizeRunOutboxDelivery(
|
|
887
|
-
event.delivery ??
|
|
1070
|
+
event.delivery ??
|
|
1071
|
+
(requiresResponse
|
|
1072
|
+
? "followup"
|
|
1073
|
+
: to === "coordinator"
|
|
1074
|
+
? "notify"
|
|
1075
|
+
: "log"),
|
|
888
1076
|
),
|
|
889
1077
|
event: type,
|
|
890
1078
|
from: event.from || `run:${run}`,
|
|
@@ -1242,6 +1430,77 @@ export function cancelRun(runOrDir: string): Record<string, unknown> {
|
|
|
1242
1430
|
: result;
|
|
1243
1431
|
}
|
|
1244
1432
|
|
|
1433
|
+
function assertTerminalRun(runOrDir: string): Record<string, unknown> {
|
|
1434
|
+
const status = getRunStatus(runOrDir);
|
|
1435
|
+
if (status.status === "running") {
|
|
1436
|
+
throw new Error("Only terminal runs can be archived or pruned.");
|
|
1437
|
+
}
|
|
1438
|
+
return status;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function archivePathFor(run: string, stateDir: string): string {
|
|
1442
|
+
const archiveRoot = join(dirname(stateDir), "archived");
|
|
1443
|
+
mkdirSync(archiveRoot, { recursive: true });
|
|
1444
|
+
return join(
|
|
1445
|
+
archiveRoot,
|
|
1446
|
+
`${safeRunId(run)}-${new Date().toISOString().replace(/[:.]/g, "-")}`,
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
export function archiveRun(runOrDir: string): Record<string, unknown> {
|
|
1451
|
+
const status = assertTerminalRun(runOrDir);
|
|
1452
|
+
const stateDir = String(status.state_dir);
|
|
1453
|
+
const run = String(status.run ?? basename(stateDir));
|
|
1454
|
+
const archiveDir = archivePathFor(run, stateDir);
|
|
1455
|
+
renameSync(stateDir, archiveDir);
|
|
1456
|
+
mkdirSync(stateDir, { recursive: true });
|
|
1457
|
+
const tombstone = {
|
|
1458
|
+
archived: true,
|
|
1459
|
+
archive_dir: archiveDir,
|
|
1460
|
+
original_state_dir: stateDir,
|
|
1461
|
+
run,
|
|
1462
|
+
status: status.status,
|
|
1463
|
+
ts: new Date().toISOString(),
|
|
1464
|
+
};
|
|
1465
|
+
writeJsonAtomic(join(stateDir, "archive-tombstone.json"), tombstone);
|
|
1466
|
+
return tombstone;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
export function pruneRun(
|
|
1470
|
+
runOrDir: string,
|
|
1471
|
+
options: { preserveArtifacts?: boolean } = {},
|
|
1472
|
+
): Record<string, unknown> {
|
|
1473
|
+
const status = assertTerminalRun(runOrDir);
|
|
1474
|
+
const stateDir = String(status.state_dir);
|
|
1475
|
+
const run = String(status.run ?? basename(stateDir));
|
|
1476
|
+
const manifest = resolveArtifactManifest(
|
|
1477
|
+
status.artifacts as Record<string, RunArtifactDeclaration> | undefined,
|
|
1478
|
+
);
|
|
1479
|
+
const preserved: Record<string, string> = {};
|
|
1480
|
+
if (options.preserveArtifacts && manifest) {
|
|
1481
|
+
const preserveRoot = join(
|
|
1482
|
+
dirname(stateDir),
|
|
1483
|
+
"preserved-artifacts",
|
|
1484
|
+
safeRunId(run),
|
|
1485
|
+
);
|
|
1486
|
+
mkdirSync(preserveRoot, { recursive: true });
|
|
1487
|
+
for (const [name, artifact] of Object.entries(manifest)) {
|
|
1488
|
+
if (!artifact.exists) continue;
|
|
1489
|
+
const target = join(preserveRoot, basename(artifact.path));
|
|
1490
|
+
cpSync(artifact.path, target, { force: true });
|
|
1491
|
+
preserved[name] = target;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
1495
|
+
return {
|
|
1496
|
+
pruned: true,
|
|
1497
|
+
preserved_artifacts: preserved,
|
|
1498
|
+
run,
|
|
1499
|
+
state_dir: stateDir,
|
|
1500
|
+
ts: new Date().toISOString(),
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1245
1504
|
export function killRun(runOrDir: string): Record<string, unknown> {
|
|
1246
1505
|
const result = stopRun(runOrDir, "SIGKILL", "run.kill");
|
|
1247
1506
|
return Object.hasOwn(result, "stopped")
|
package/lib/command-templates.ts
CHANGED
|
@@ -193,7 +193,7 @@ function getLeafCommandTemplateWarnings(
|
|
|
193
193
|
? "shell command strings"
|
|
194
194
|
: "shell scripts";
|
|
195
195
|
warnings.push(
|
|
196
|
-
`${config.label ?? command}: invokes ${command}; ${shellContent} are trusted executable content and are not sandboxed by command-template argv splitting.`,
|
|
196
|
+
`${config.label ?? command}: invokes ${command}; ${shellContent} are trusted executable content and are not sandboxed by command-template argv splitting. Mitigation: keep scripts local, reviewed, and parameterized with explicit placeholders.`,
|
|
197
197
|
);
|
|
198
198
|
}
|
|
199
199
|
if (
|
|
@@ -201,7 +201,7 @@ function getLeafCommandTemplateWarnings(
|
|
|
201
201
|
hasAnyFlag(args, ["-e", "--eval"])
|
|
202
202
|
) {
|
|
203
203
|
warnings.push(
|
|
204
|
-
`${config.label ?? command}: invokes ${command} eval mode; code strings are trusted executable content and are not sandboxed.`,
|
|
204
|
+
`${config.label ?? command}: invokes ${command} eval mode; code strings are trusted executable content and are not sandboxed. Mitigation: prefer a checked-in script file or keep eval input fixed and reviewed.`,
|
|
205
205
|
);
|
|
206
206
|
}
|
|
207
207
|
if (
|
|
@@ -209,7 +209,7 @@ function getLeafCommandTemplateWarnings(
|
|
|
209
209
|
hasAnyFlag(args, ["-c", "-e"])
|
|
210
210
|
) {
|
|
211
211
|
warnings.push(
|
|
212
|
-
`${config.label ?? command}: invokes ${command} code-eval mode; code strings are trusted executable content and are not sandboxed.`,
|
|
212
|
+
`${config.label ?? command}: invokes ${command} code-eval mode; code strings are trusted executable content and are not sandboxed. Mitigation: prefer a checked-in script file or keep eval input fixed and reviewed.`,
|
|
213
213
|
);
|
|
214
214
|
}
|
|
215
215
|
if (
|
|
@@ -218,12 +218,12 @@ function getLeafCommandTemplateWarnings(
|
|
|
218
218
|
hasRiskyPathArg(args))
|
|
219
219
|
) {
|
|
220
220
|
warnings.push(
|
|
221
|
-
`${config.label ?? command}: removes filesystem paths; verify placeholders and paths before running trusted destructive commands.`,
|
|
221
|
+
`${config.label ?? command}: removes filesystem paths; verify placeholders and paths before running trusted destructive commands. Mitigation: constrain path placeholders and consider dry-run or explicit confirmation.`,
|
|
222
222
|
);
|
|
223
223
|
}
|
|
224
224
|
if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
|
|
225
225
|
warnings.push(
|
|
226
|
-
`${config.label ?? command}: mutates broad filesystem paths; verify placeholders and paths before running trusted commands.`,
|
|
226
|
+
`${config.label ?? command}: mutates broad filesystem paths; verify placeholders and paths before running trusted commands. Mitigation: constrain path placeholders and prefer narrow source/destination paths.`,
|
|
227
227
|
);
|
|
228
228
|
}
|
|
229
229
|
return warnings;
|
package/lib/limits.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared output and preview size limits.
|
|
3
|
+
* Zones: output governance, actor-message previews, inspect defaults
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_INSPECT_LINES = 40;
|
|
7
|
+
export const TOOL_OUTPUT_MAX_BYTES = 50 * 1024;
|
|
8
|
+
export const TOOL_OUTPUT_MAX_LINES = 2_000;
|
|
9
|
+
export const COMPACT_PREVIEW_CHARS = 160;
|
|
10
|
+
export const INSPECTOR_BODY_PREVIEW_CHARS = 320;
|
|
11
|
+
export const ROOM_MESSAGE_PREVIEW_CHARS = 320;
|
|
12
|
+
export const DOCTOR_ACTION_PREVIEW_CHARS = 72;
|
package/lib/observability.ts
CHANGED
|
@@ -144,7 +144,7 @@ function getUpdatedAt(status: Record<string, unknown>): string | undefined {
|
|
|
144
144
|
: undefined;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
function
|
|
147
|
+
function scanRunStateDirs(
|
|
148
148
|
stateRoot: string,
|
|
149
149
|
depth = 0,
|
|
150
150
|
seen = new Set<string>(),
|
|
@@ -163,7 +163,7 @@ function listRunStateDirs(
|
|
|
163
163
|
const child = join(stateRoot, entry.name);
|
|
164
164
|
if (existsSync(join(child, "run.json"))) result.push(child);
|
|
165
165
|
if (depth + 1 < RUN_STATE_DISCOVERY_MAX_DEPTH)
|
|
166
|
-
result.push(...
|
|
166
|
+
result.push(...scanRunStateDirs(child, depth + 1, seen));
|
|
167
167
|
}
|
|
168
168
|
return result;
|
|
169
169
|
}
|
|
@@ -226,7 +226,10 @@ export function summarizeRuns(
|
|
|
226
226
|
total: 0,
|
|
227
227
|
};
|
|
228
228
|
}
|
|
229
|
-
const runs =
|
|
229
|
+
const runs = (
|
|
230
|
+
AsyncRuns.readRunStateIndex(stateRoot)?.map((entry) => entry.state_dir) ??
|
|
231
|
+
scanRunStateDirs(stateRoot)
|
|
232
|
+
)
|
|
230
233
|
.map((stateDir) => observeRun(stateDir))
|
|
231
234
|
.filter((run): run is RunObservation => Boolean(run))
|
|
232
235
|
.filter((run) => ownerId === undefined || run.ownerId === ownerId)
|
|
@@ -331,7 +334,10 @@ function summarizeRunsWithoutSubagents(
|
|
|
331
334
|
runs: [],
|
|
332
335
|
total: 0,
|
|
333
336
|
};
|
|
334
|
-
const runs =
|
|
337
|
+
const runs = (
|
|
338
|
+
AsyncRuns.readRunStateIndex(stateRoot)?.map((entry) => entry.state_dir) ??
|
|
339
|
+
scanRunStateDirs(stateRoot)
|
|
340
|
+
)
|
|
335
341
|
.map((stateDir) => observeRun(stateDir))
|
|
336
342
|
.filter((run): run is RunObservation => Boolean(run))
|
|
337
343
|
.filter((run) => ownerId === undefined || run.ownerId === ownerId)
|
package/lib/output.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
|
|
10
10
|
import { sanitizeFilePart } from "./identity.ts";
|
|
11
|
+
import * as Limits from "./limits.ts";
|
|
11
12
|
import * as Paths from "./paths.ts";
|
|
12
13
|
|
|
13
14
|
export interface FormattedOutput {
|
|
@@ -16,9 +17,6 @@ export interface FormattedOutput {
|
|
|
16
17
|
fullOutputPath?: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
20
|
-
const MAX_OUTPUT_LINES = 2_000;
|
|
21
|
-
|
|
22
20
|
export function writeFullOutput(
|
|
23
21
|
toolName: string,
|
|
24
22
|
stream: string,
|
|
@@ -70,10 +68,10 @@ export function truncateTailContent(content: string): {
|
|
|
70
68
|
const lines = content.split("\n");
|
|
71
69
|
const totalLines = lines.length;
|
|
72
70
|
let output =
|
|
73
|
-
totalLines >
|
|
74
|
-
? lines.slice(-
|
|
71
|
+
totalLines > Limits.TOOL_OUTPUT_MAX_LINES
|
|
72
|
+
? lines.slice(-Limits.TOOL_OUTPUT_MAX_LINES).join("\n")
|
|
75
73
|
: content;
|
|
76
|
-
output = trimToTailBytes(output,
|
|
74
|
+
output = trimToTailBytes(output, Limits.TOOL_OUTPUT_MAX_BYTES);
|
|
77
75
|
const outputBytes = byteLength(output);
|
|
78
76
|
const outputLines = output ? output.split("\n").length : 0;
|
|
79
77
|
return {
|
package/lib/recipe-discovery.ts
CHANGED
|
@@ -493,6 +493,7 @@ export function summarizeDiscovery(
|
|
|
493
493
|
disabled: entry.disabled,
|
|
494
494
|
invalid: entry.invalid,
|
|
495
495
|
shadows: entry.shadows,
|
|
496
|
+
...(entry.config?.imports ? { imports: entry.config.imports } : {}),
|
|
496
497
|
...(recipeUsage(entry.config)
|
|
497
498
|
? { usage: recipeUsage(entry.config) }
|
|
498
499
|
: {}),
|
package/lib/recipe-references.ts
CHANGED
|
@@ -26,9 +26,21 @@ export interface TemplateRecipeImportBinding {
|
|
|
26
26
|
|
|
27
27
|
export type TemplateRecipeImport = string | TemplateRecipeImportBinding;
|
|
28
28
|
|
|
29
|
+
export type TemplateRecipeMailboxEntry =
|
|
30
|
+
| string
|
|
31
|
+
| {
|
|
32
|
+
type: string;
|
|
33
|
+
body_schema?: unknown;
|
|
34
|
+
ack?: boolean | string;
|
|
35
|
+
idempotency?: string;
|
|
36
|
+
requires_response?: boolean;
|
|
37
|
+
level?: string;
|
|
38
|
+
summary?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
29
41
|
export interface TemplateRecipeMailbox {
|
|
30
|
-
accepts?:
|
|
31
|
-
emits?:
|
|
42
|
+
accepts?: TemplateRecipeMailboxEntry[];
|
|
43
|
+
emits?: TemplateRecipeMailboxEntry[];
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
export interface TemplateRecipeDefinition {
|
|
@@ -834,14 +846,18 @@ export function readResolvedRecipeConfig(
|
|
|
834
846
|
...(Array.isArray(substituted.mailbox.accepts)
|
|
835
847
|
? {
|
|
836
848
|
accepts: substituted.mailbox.accepts.filter(
|
|
837
|
-
(value): value is
|
|
849
|
+
(value): value is TemplateRecipeMailboxEntry =>
|
|
850
|
+
typeof value === "string" ||
|
|
851
|
+
(isRecord(value) && typeof value.type === "string"),
|
|
838
852
|
),
|
|
839
853
|
}
|
|
840
854
|
: {}),
|
|
841
855
|
...(Array.isArray(substituted.mailbox.emits)
|
|
842
856
|
? {
|
|
843
857
|
emits: substituted.mailbox.emits.filter(
|
|
844
|
-
(value): value is
|
|
858
|
+
(value): value is TemplateRecipeMailboxEntry =>
|
|
859
|
+
typeof value === "string" ||
|
|
860
|
+
(isRecord(value) && typeof value.type === "string"),
|
|
845
861
|
),
|
|
846
862
|
}
|
|
847
863
|
: {}),
|
package/lib/recipe-usage.ts
CHANGED
|
@@ -11,9 +11,14 @@ import { writeJsonAtomic } from "./file-state.ts";
|
|
|
11
11
|
|
|
12
12
|
interface RecipeUsageRecord {
|
|
13
13
|
calls?: number;
|
|
14
|
-
|
|
14
|
+
direct_calls?: number;
|
|
15
15
|
fingerprint?: string;
|
|
16
|
+
last_called?: string;
|
|
17
|
+
launch_kind?: string;
|
|
16
18
|
reset_at?: string;
|
|
19
|
+
reset_reason?: string;
|
|
20
|
+
spawn_calls?: number;
|
|
21
|
+
tool_calls?: number;
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -40,23 +45,45 @@ function getRecipeFingerprint(raw: Record<string, unknown>): string {
|
|
|
40
45
|
return createHash("sha256").update(stableStringify(content)).digest("hex");
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
export
|
|
48
|
+
export type RecipeLaunchKind = "direct" | "spawn" | "tool";
|
|
49
|
+
|
|
50
|
+
function launchCounterKey(
|
|
51
|
+
kind: RecipeLaunchKind,
|
|
52
|
+
): "direct_calls" | "spawn_calls" | "tool_calls" {
|
|
53
|
+
return `${kind}_calls` as "direct_calls" | "spawn_calls" | "tool_calls";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function recordRecipeLaunch(
|
|
57
|
+
path: string,
|
|
58
|
+
now = new Date(),
|
|
59
|
+
kind: RecipeLaunchKind = "direct",
|
|
60
|
+
): boolean {
|
|
44
61
|
if (!existsSync(path)) return false;
|
|
45
62
|
try {
|
|
46
63
|
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
47
64
|
if (!isRecord(raw)) return false;
|
|
48
65
|
const usage: RecipeUsageRecord = isRecord(raw.usage) ? raw.usage : {};
|
|
49
66
|
const fingerprint = getRecipeFingerprint(raw);
|
|
50
|
-
const changed =
|
|
67
|
+
const changed =
|
|
68
|
+
typeof usage.fingerprint === "string" &&
|
|
69
|
+
usage.fingerprint !== fingerprint;
|
|
51
70
|
const nowIso = now.toISOString();
|
|
71
|
+
const counterKey = launchCounterKey(kind);
|
|
52
72
|
writeJsonAtomic(path, {
|
|
53
73
|
...raw,
|
|
54
74
|
usage: {
|
|
55
75
|
...usage,
|
|
56
76
|
calls: (changed ? 0 : normalizeCalls(usage.calls)) + 1,
|
|
77
|
+
[counterKey]: (changed ? 0 : normalizeCalls(usage[counterKey])) + 1,
|
|
57
78
|
last_called: nowIso,
|
|
79
|
+
launch_kind: kind,
|
|
58
80
|
fingerprint,
|
|
59
|
-
...(changed
|
|
81
|
+
...(changed
|
|
82
|
+
? {
|
|
83
|
+
reset_at: nowIso,
|
|
84
|
+
reset_reason: "recipe content fingerprint changed",
|
|
85
|
+
}
|
|
86
|
+
: {}),
|
|
60
87
|
},
|
|
61
88
|
});
|
|
62
89
|
return true;
|