@llblab/pi-actors 0.34.1 → 0.36.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 +3 -1
- package/BACKLOG.md +4 -90
- package/CHANGELOG.md +14 -2
- package/README.md +12 -2
- package/dist/lib/command-templates.d.ts +2 -0
- package/dist/lib/command-templates.js +76 -0
- package/dist/lib/prompts.d.ts +1 -0
- package/dist/lib/prompts.js +1 -0
- package/dist/lib/recipes-discovery.d.ts +2 -0
- package/dist/lib/recipes-discovery.js +77 -5
- package/dist/lib/registry.d.ts +3 -0
- package/dist/lib/registry.js +60 -1
- package/dist/lib/runtime-notifier.js +8 -3
- package/dist/lib/tools-inspect.js +172 -4
- package/dist/lib/tools-register.js +1 -0
- package/dist/lib/tools-response.js +13 -2
- package/dist/scripts/validate-recipe.mjs +164 -7
- package/dist/skills/actors/SKILL.md +28 -11
- package/dist/skills/swarm/SKILL.md +1 -1
- package/docs/recipe-library.md +2 -0
- package/docs/template-recipes.md +2 -0
- package/docs/tool-registry.md +17 -12
- package/lib/command-templates.ts +124 -0
- package/lib/prompts.ts +2 -0
- package/lib/recipes-discovery.ts +98 -6
- package/lib/registry.ts +94 -1
- package/lib/runtime-notifier.ts +9 -3
- package/lib/tools-inspect.ts +186 -4
- package/lib/tools-register.ts +1 -0
- package/lib/tools-response.ts +16 -2
- package/package.json +3 -2
- package/scripts/validate-recipe.mjs +164 -7
- package/skills/actors/SKILL.md +28 -11
- package/skills/swarm/SKILL.md +1 -1
package/lib/command-templates.ts
CHANGED
|
@@ -67,6 +67,29 @@ export interface CommandTemplateExecResult {
|
|
|
67
67
|
killed: boolean;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
export type CommandTemplateRiskLabel =
|
|
71
|
+
| "risk.shell"
|
|
72
|
+
| "risk.eval"
|
|
73
|
+
| "risk.broad_fs_write"
|
|
74
|
+
| "risk.destructive_fs"
|
|
75
|
+
| "risk.network"
|
|
76
|
+
| "risk.external_side_effect"
|
|
77
|
+
| "risk.long_running"
|
|
78
|
+
| "risk.platform_specific"
|
|
79
|
+
| "risk.secret_touching";
|
|
80
|
+
|
|
81
|
+
const COMMAND_TEMPLATE_RISK_LABEL_ORDER: CommandTemplateRiskLabel[] = [
|
|
82
|
+
"risk.shell",
|
|
83
|
+
"risk.eval",
|
|
84
|
+
"risk.destructive_fs",
|
|
85
|
+
"risk.broad_fs_write",
|
|
86
|
+
"risk.external_side_effect",
|
|
87
|
+
"risk.secret_touching",
|
|
88
|
+
"risk.network",
|
|
89
|
+
"risk.long_running",
|
|
90
|
+
"risk.platform_specific",
|
|
91
|
+
];
|
|
92
|
+
|
|
70
93
|
export type CommandTemplateExecCommand = (
|
|
71
94
|
command: string,
|
|
72
95
|
args: string[],
|
|
@@ -181,6 +204,97 @@ function hasRiskyPathArg(args: string[]): boolean {
|
|
|
181
204
|
);
|
|
182
205
|
}
|
|
183
206
|
|
|
207
|
+
function sortRiskLabels(
|
|
208
|
+
labels: Iterable<CommandTemplateRiskLabel>,
|
|
209
|
+
): CommandTemplateRiskLabel[] {
|
|
210
|
+
const unique = new Set(labels);
|
|
211
|
+
return COMMAND_TEMPLATE_RISK_LABEL_ORDER.filter((label) => unique.has(label));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function hasAnyArg(args: string[], values: string[]): boolean {
|
|
215
|
+
return args.some((arg) => values.includes(arg.toLowerCase()));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function hasSecretTouchingText(parts: string[]): boolean {
|
|
219
|
+
return parts.some((part) =>
|
|
220
|
+
/(^|[{}._\-\s/])(?:secret|token|password|passwd|credential|api[_-]?key|private[_-]?key|\.env|ssh[_-]?key)(?:[{}._\-\s/]|$)/i.test(
|
|
221
|
+
part,
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function getLeafCommandTemplateRiskLabels(
|
|
227
|
+
config: CommandTemplateLeafConfig,
|
|
228
|
+
): CommandTemplateRiskLabel[] {
|
|
229
|
+
const parts = splitCommandTemplate(config.template);
|
|
230
|
+
const command = getExecutableName(parts[0]);
|
|
231
|
+
const args = parts.slice(1);
|
|
232
|
+
const labels = new Set<CommandTemplateRiskLabel>();
|
|
233
|
+
if (["bash", "sh", "zsh", "fish"].includes(command)) {
|
|
234
|
+
labels.add("risk.shell");
|
|
235
|
+
if (hasAnyFlag(args, ["-c"])) labels.add("risk.eval");
|
|
236
|
+
}
|
|
237
|
+
if (
|
|
238
|
+
["node", "deno", "bun"].includes(command) &&
|
|
239
|
+
hasAnyFlag(args, ["-e", "--eval"])
|
|
240
|
+
) {
|
|
241
|
+
labels.add("risk.eval");
|
|
242
|
+
}
|
|
243
|
+
if (
|
|
244
|
+
["python", "python3", "perl", "ruby"].includes(command) &&
|
|
245
|
+
hasAnyFlag(args, ["-c", "-e"])
|
|
246
|
+
) {
|
|
247
|
+
labels.add("risk.eval");
|
|
248
|
+
}
|
|
249
|
+
if (
|
|
250
|
+
command === "rm" &&
|
|
251
|
+
(args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
|
|
252
|
+
hasRiskyPathArg(args))
|
|
253
|
+
) {
|
|
254
|
+
labels.add("risk.destructive_fs");
|
|
255
|
+
}
|
|
256
|
+
if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
|
|
257
|
+
labels.add("risk.broad_fs_write");
|
|
258
|
+
}
|
|
259
|
+
if (
|
|
260
|
+
["curl", "wget", "ssh", "scp", "sftp", "rsync", "nc", "ncat", "telnet", "ftp"].includes(
|
|
261
|
+
command,
|
|
262
|
+
) ||
|
|
263
|
+
(command === "git" &&
|
|
264
|
+
hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
|
|
265
|
+
["npm", "pnpm", "yarn", "pip", "cargo"].includes(command)
|
|
266
|
+
) {
|
|
267
|
+
labels.add("risk.network");
|
|
268
|
+
}
|
|
269
|
+
if (
|
|
270
|
+
["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
|
|
271
|
+
(command === "git" && hasAnyArg(args, ["push"])) ||
|
|
272
|
+
(["npm", "pnpm", "yarn"].includes(command) &&
|
|
273
|
+
hasAnyArg(args, ["publish", "login", "logout", "deprecate"]))
|
|
274
|
+
) {
|
|
275
|
+
labels.add("risk.external_side_effect");
|
|
276
|
+
}
|
|
277
|
+
if (
|
|
278
|
+
command === "sleep" ||
|
|
279
|
+
command === "watch" ||
|
|
280
|
+
(command === "tail" && hasAnyFlag(args, ["-f"])) ||
|
|
281
|
+
hasAnyArg(args, ["--watch", "--serve", "serve"])
|
|
282
|
+
) {
|
|
283
|
+
labels.add("risk.long_running");
|
|
284
|
+
}
|
|
285
|
+
if (
|
|
286
|
+
["systemctl", "launchctl", "osascript", "open", "xdg-open", "powershell", "pwsh", "cmd.exe", "apt", "apt-get", "dnf", "yum", "brew", "pacman", "apk", "xclip", "wl-copy"].includes(
|
|
287
|
+
command,
|
|
288
|
+
)
|
|
289
|
+
) {
|
|
290
|
+
labels.add("risk.platform_specific");
|
|
291
|
+
}
|
|
292
|
+
if (["pass", "gpg", "ssh-add"].includes(command) || hasSecretTouchingText(parts)) {
|
|
293
|
+
labels.add("risk.secret_touching");
|
|
294
|
+
}
|
|
295
|
+
return sortRiskLabels(labels);
|
|
296
|
+
}
|
|
297
|
+
|
|
184
298
|
function getLeafCommandTemplateWarnings(
|
|
185
299
|
config: CommandTemplateLeafConfig,
|
|
186
300
|
): string[] {
|
|
@@ -347,6 +461,16 @@ export function getCommandTemplateWarnings(
|
|
|
347
461
|
];
|
|
348
462
|
}
|
|
349
463
|
|
|
464
|
+
export function getCommandTemplateRiskLabels(
|
|
465
|
+
config: CommandTemplateConfig,
|
|
466
|
+
): CommandTemplateRiskLabel[] {
|
|
467
|
+
return sortRiskLabels(
|
|
468
|
+
expandCommandTemplateConfigs(config).flatMap((leaf) =>
|
|
469
|
+
getLeafCommandTemplateRiskLabels(leaf),
|
|
470
|
+
),
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
350
474
|
function parseCommandTemplateArgToken(value: string): {
|
|
351
475
|
name: string;
|
|
352
476
|
defaultValue?: string;
|
package/lib/prompts.ts
CHANGED
|
@@ -38,6 +38,8 @@ export const REGISTER_TOOL_PARAM_DESCRIPTIONS = {
|
|
|
38
38
|
name: "Tool name in snake_case (e.g., 'transcribe')",
|
|
39
39
|
description:
|
|
40
40
|
"Describe what the tool does for the LLM. Required unless deleting; omitted updates keep the old description.",
|
|
41
|
+
draft:
|
|
42
|
+
"Promote a draft recipe path from ~/.pi/agent/recipes/drafts into an active named recipe under ~/.pi/agent/recipes. Requires name; use update=true to overwrite.",
|
|
41
43
|
async:
|
|
42
44
|
"Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.",
|
|
43
45
|
state_dir:
|
package/lib/recipes-discovery.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface DiscoveredRecipe {
|
|
|
27
27
|
tool: boolean;
|
|
28
28
|
mutableUsage: boolean;
|
|
29
29
|
diagnostics: string[];
|
|
30
|
+
riskLabels: CommandTemplates.CommandTemplateRiskLabel[];
|
|
30
31
|
shadows: string[];
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -110,6 +111,14 @@ function listRecipeFiles(root: string): string[] {
|
|
|
110
111
|
);
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
function getRecipeCommandTemplateConfig(
|
|
115
|
+
config: TemplateRecipeConfig,
|
|
116
|
+
): CommandTemplates.CommandTemplateConfig {
|
|
117
|
+
return (typeof config.template === "object" && config.template !== null
|
|
118
|
+
? config.template
|
|
119
|
+
: config) as CommandTemplates.CommandTemplateConfig;
|
|
120
|
+
}
|
|
121
|
+
|
|
113
122
|
function getRecipeConfigDiagnostics(
|
|
114
123
|
file: string,
|
|
115
124
|
config: TemplateRecipeConfig | undefined,
|
|
@@ -118,15 +127,36 @@ function getRecipeConfigDiagnostics(
|
|
|
118
127
|
const reason = RecipesReferences.diagnoseRawRecipeConfigFailure(file);
|
|
119
128
|
return [`Invalid recipe: ${file}${reason ? `: ${reason}` : ""}`];
|
|
120
129
|
}
|
|
121
|
-
const commandTemplateConfig =
|
|
122
|
-
typeof config.template === "object" && config.template !== null
|
|
123
|
-
? config.template
|
|
124
|
-
: config;
|
|
125
130
|
return CommandTemplates.getCommandTemplateWarnings(
|
|
126
|
-
|
|
131
|
+
getRecipeCommandTemplateConfig(config),
|
|
127
132
|
).map((warning) => `Recipe ${file}: ${warning}`);
|
|
128
133
|
}
|
|
129
134
|
|
|
135
|
+
function getRecipeRiskLabels(
|
|
136
|
+
config: TemplateRecipeConfig | undefined,
|
|
137
|
+
): CommandTemplates.CommandTemplateRiskLabel[] {
|
|
138
|
+
if (!config) return [];
|
|
139
|
+
const labels = new Set(
|
|
140
|
+
CommandTemplates.getCommandTemplateRiskLabels(
|
|
141
|
+
getRecipeCommandTemplateConfig(config),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
if (config.async === true) labels.add("risk.long_running");
|
|
145
|
+
return [
|
|
146
|
+
"risk.shell",
|
|
147
|
+
"risk.eval",
|
|
148
|
+
"risk.destructive_fs",
|
|
149
|
+
"risk.broad_fs_write",
|
|
150
|
+
"risk.external_side_effect",
|
|
151
|
+
"risk.secret_touching",
|
|
152
|
+
"risk.network",
|
|
153
|
+
"risk.long_running",
|
|
154
|
+
"risk.platform_specific",
|
|
155
|
+
].filter((label): label is CommandTemplates.CommandTemplateRiskLabel =>
|
|
156
|
+
labels.has(label as CommandTemplates.CommandTemplateRiskLabel),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
130
160
|
function readDiscoveredRecipe(
|
|
131
161
|
root: string,
|
|
132
162
|
file: string,
|
|
@@ -152,6 +182,7 @@ function readDiscoveredRecipe(
|
|
|
152
182
|
tool: defaultTool && !disabled && !invalid,
|
|
153
183
|
mutableUsage,
|
|
154
184
|
diagnostics: getRecipeConfigDiagnostics(file, config),
|
|
185
|
+
riskLabels: getRecipeRiskLabels(config),
|
|
155
186
|
shadows: [],
|
|
156
187
|
};
|
|
157
188
|
} catch (error) {
|
|
@@ -169,6 +200,7 @@ function readDiscoveredRecipe(
|
|
|
169
200
|
diagnostics: [
|
|
170
201
|
`Failed to load recipe ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
171
202
|
],
|
|
203
|
+
riskLabels: [],
|
|
172
204
|
shadows: [],
|
|
173
205
|
};
|
|
174
206
|
}
|
|
@@ -392,7 +424,7 @@ function diagnosticSeverity(message: string): "info" | "warning" | "error" {
|
|
|
392
424
|
return "error";
|
|
393
425
|
}
|
|
394
426
|
if (
|
|
395
|
-
/world-writable|group-writable|invokes bash|eval|destructive|unsafe/i.test(
|
|
427
|
+
/world-writable|group-writable|invokes bash|eval|destructive|broad filesystem|unsafe/i.test(
|
|
396
428
|
message,
|
|
397
429
|
)
|
|
398
430
|
) {
|
|
@@ -435,6 +467,7 @@ function diagnosticDetails(
|
|
|
435
467
|
seen.add(key);
|
|
436
468
|
details.push({
|
|
437
469
|
...(entry ? { id: entry.id, path: entry.path } : {}),
|
|
470
|
+
...(entry?.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
|
|
438
471
|
action: diagnosticSuggestedAction(message),
|
|
439
472
|
message,
|
|
440
473
|
severity: diagnosticSeverity(message),
|
|
@@ -496,6 +529,7 @@ function remediationForEntry(
|
|
|
496
529
|
kind: "risky_shell_boundary",
|
|
497
530
|
severity: "warning",
|
|
498
531
|
path: entry.path,
|
|
532
|
+
...(entry.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
|
|
499
533
|
reason: riskyDiagnostics[0],
|
|
500
534
|
action:
|
|
501
535
|
"audit trusted command boundary; keep only if the recipe is local and intentional",
|
|
@@ -544,6 +578,31 @@ function discoveryRemediations(
|
|
|
544
578
|
);
|
|
545
579
|
}
|
|
546
580
|
|
|
581
|
+
function summarizeRiskLabels(
|
|
582
|
+
entries: DiscoveredRecipe[],
|
|
583
|
+
): Array<Record<string, unknown>> {
|
|
584
|
+
const counts = new Map<string, { count: number; ids: Set<string> }>();
|
|
585
|
+
for (const entry of entries) {
|
|
586
|
+
for (const label of entry.riskLabels) {
|
|
587
|
+
const current = counts.get(label) ?? { count: 0, ids: new Set<string>() };
|
|
588
|
+
current.count += 1;
|
|
589
|
+
current.ids.add(entry.id);
|
|
590
|
+
counts.set(label, current);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return [...counts.entries()]
|
|
594
|
+
.map(([label, value]) => ({
|
|
595
|
+
label,
|
|
596
|
+
count: value.count,
|
|
597
|
+
recipes: [...value.ids].sort(),
|
|
598
|
+
}))
|
|
599
|
+
.sort(
|
|
600
|
+
(a, b) =>
|
|
601
|
+
Number(b.count) - Number(a.count) ||
|
|
602
|
+
String(a.label).localeCompare(String(b.label)),
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
547
606
|
function recommendationForEntry(
|
|
548
607
|
entry: DiscoveredRecipe,
|
|
549
608
|
activePath: string | undefined,
|
|
@@ -574,14 +633,45 @@ export function getShadowedLaunchDiagnostic(
|
|
|
574
633
|
};
|
|
575
634
|
}
|
|
576
635
|
|
|
636
|
+
function templatePreview(value: unknown): string | undefined {
|
|
637
|
+
const rendered =
|
|
638
|
+
typeof value === "string"
|
|
639
|
+
? value
|
|
640
|
+
: value === undefined
|
|
641
|
+
? undefined
|
|
642
|
+
: JSON.stringify(value);
|
|
643
|
+
return rendered && rendered.length > 120
|
|
644
|
+
? `${rendered.slice(0, 117)}...`
|
|
645
|
+
: rendered;
|
|
646
|
+
}
|
|
647
|
+
|
|
577
648
|
export function listDraftRecipes(root: string): Array<Record<string, unknown>> {
|
|
578
649
|
return listRecipeFiles(root).map((path) => {
|
|
579
650
|
const id = RecipesReferences.getRecipeIdFromPath(path);
|
|
651
|
+
const bytes = readFileSync(path);
|
|
652
|
+
const stat = statSync(path);
|
|
580
653
|
const config = RecipesReferences.readRawRecipeConfig(path);
|
|
654
|
+
const resolved = RecipesReferences.readResolvedRecipeConfig(path);
|
|
655
|
+
const diagnostics = getRecipeConfigDiagnostics(path, resolved);
|
|
656
|
+
const sourceRun = String(config?.description ?? "").match(
|
|
657
|
+
/spawn run ([^\s]+)/,
|
|
658
|
+
)?.[1];
|
|
659
|
+
const preview = templatePreview(config?.template);
|
|
660
|
+
const riskLabels = getRecipeRiskLabels(resolved);
|
|
581
661
|
return {
|
|
582
662
|
id,
|
|
583
663
|
path,
|
|
664
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
665
|
+
size: bytes.byteLength,
|
|
666
|
+
created_at: stat.birthtime.toISOString(),
|
|
667
|
+
modified_at: stat.mtime.toISOString(),
|
|
668
|
+
valid: Boolean(resolved),
|
|
669
|
+
diagnostics,
|
|
670
|
+
...(riskLabels.length ? { risk_labels: riskLabels } : {}),
|
|
584
671
|
...(config?.description ? { description: config.description } : {}),
|
|
672
|
+
...(sourceRun ? { source_run: sourceRun } : {}),
|
|
673
|
+
...(config?.async !== undefined ? { async: config.async } : {}),
|
|
674
|
+
...(preview ? { template_preview: preview } : {}),
|
|
585
675
|
};
|
|
586
676
|
});
|
|
587
677
|
}
|
|
@@ -610,6 +700,7 @@ export function summarizeDiscovery(
|
|
|
610
700
|
disabled: entry.disabled,
|
|
611
701
|
invalid: entry.invalid,
|
|
612
702
|
shadows: entry.shadows,
|
|
703
|
+
...(entry.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
|
|
613
704
|
...(entry.config?.imports ? { imports: entry.config.imports } : {}),
|
|
614
705
|
...(recipeUsage(entry.config)
|
|
615
706
|
? { usage: recipeUsage(entry.config) }
|
|
@@ -636,6 +727,7 @@ export function summarizeDiscovery(
|
|
|
636
727
|
.filter((entry) => entry.disabled)
|
|
637
728
|
.map((entry) => ({ id: entry.id, path: entry.path }))
|
|
638
729
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
730
|
+
risk_summary: summarizeRiskLabels(result.entries),
|
|
639
731
|
recommendations,
|
|
640
732
|
remediations,
|
|
641
733
|
top_action: remediations[0],
|
package/lib/registry.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
8
|
-
import { dirname, join } from "node:path";
|
|
8
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
9
9
|
|
|
10
10
|
import * as CommandTemplates from "./command-templates.ts";
|
|
11
11
|
import * as Config from "./config.ts";
|
|
@@ -22,6 +22,7 @@ export interface RegisterToolInput {
|
|
|
22
22
|
async?: boolean;
|
|
23
23
|
state_dir?: string;
|
|
24
24
|
template?: CommandTemplates.CommandTemplateValue | null;
|
|
25
|
+
draft?: string;
|
|
25
26
|
args?: string;
|
|
26
27
|
update?: boolean;
|
|
27
28
|
values?: Record<string, unknown>;
|
|
@@ -32,6 +33,8 @@ export interface RegisterToolResultDetails {
|
|
|
32
33
|
async?: boolean;
|
|
33
34
|
config?: string;
|
|
34
35
|
defaults?: Record<string, string>;
|
|
36
|
+
draft?: string;
|
|
37
|
+
promoted?: boolean;
|
|
35
38
|
recipeName?: string;
|
|
36
39
|
state_dir?: string;
|
|
37
40
|
template?: CommandTemplates.CommandTemplateValue;
|
|
@@ -95,6 +98,93 @@ function getToolRecipePath<TContext>(
|
|
|
95
98
|
return join(getRecipeRoot(deps), `${name}.json`);
|
|
96
99
|
}
|
|
97
100
|
|
|
101
|
+
function assertDraftPath<TContext>(
|
|
102
|
+
deps: RegisterToolRuntimeDeps<TContext>,
|
|
103
|
+
draft: string,
|
|
104
|
+
): string {
|
|
105
|
+
const draftRoot = resolve(getRecipeRoot(deps), "drafts");
|
|
106
|
+
const path = resolve(draft);
|
|
107
|
+
const relation = relative(draftRoot, path);
|
|
108
|
+
if (
|
|
109
|
+
relation === "" ||
|
|
110
|
+
relation.startsWith("..") ||
|
|
111
|
+
resolve(relation) === relation
|
|
112
|
+
) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
ExecutionOutput.formatToolText(
|
|
115
|
+
`Draft must be under ${draftRoot}. Use inspect target=recipes view=summary to list drafts.`,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (!existsSync(path)) {
|
|
120
|
+
throw new Error(ExecutionOutput.formatToolText(`Draft not found: ${path}`));
|
|
121
|
+
}
|
|
122
|
+
return path;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function promoteDraftRecipe<TContext>(
|
|
126
|
+
name: string,
|
|
127
|
+
input: RegisterToolInput,
|
|
128
|
+
ctx: TContext,
|
|
129
|
+
deps: RegisterToolRuntimeDeps<TContext>,
|
|
130
|
+
): RegisterToolResult {
|
|
131
|
+
const draftPath = assertDraftPath(deps, String(input.draft ?? ""));
|
|
132
|
+
const targetPath = getToolRecipePath(deps, name);
|
|
133
|
+
const tools = deps.getTools();
|
|
134
|
+
const existing = tools.get(name);
|
|
135
|
+
const conflict = deps.getExternalToolConflict(name);
|
|
136
|
+
if (conflict) throw new Error(ExecutionOutput.formatToolText(conflict));
|
|
137
|
+
if ((existing || existsSync(targetPath)) && !input.update) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
ExecutionOutput.formatToolText(
|
|
140
|
+
`Tool "${name}" already registered. Use update=true to overwrite.`,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const config = RecipesReferences.readResolvedRecipeConfig(draftPath);
|
|
145
|
+
if (!config) {
|
|
146
|
+
const reason = RecipesReferences.diagnoseRawRecipeConfigFailure(draftPath);
|
|
147
|
+
throw new Error(
|
|
148
|
+
ExecutionOutput.formatToolText(
|
|
149
|
+
`Draft recipe is invalid${reason ? `: ${reason}` : "."}`,
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const promoted = buildConfig(
|
|
154
|
+
name,
|
|
155
|
+
{
|
|
156
|
+
...input,
|
|
157
|
+
description: input.description ?? config.description,
|
|
158
|
+
template: draftPath,
|
|
159
|
+
},
|
|
160
|
+
existing,
|
|
161
|
+
);
|
|
162
|
+
const raw = RecipesReferences.readRawRecipeConfig(draftPath)!;
|
|
163
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
164
|
+
writeJsonAtomic(targetPath, raw);
|
|
165
|
+
promoted.template = targetPath;
|
|
166
|
+
promoted.sourcePath = targetPath;
|
|
167
|
+
tools.set(name, promoted);
|
|
168
|
+
deps.registerRuntimeTool(promoted);
|
|
169
|
+
deps.notify(ctx, `Promoted draft recipe: ${name}`, "info");
|
|
170
|
+
return {
|
|
171
|
+
content: [
|
|
172
|
+
textContent(
|
|
173
|
+
ExecutionOutput.formatToolText(
|
|
174
|
+
`${existing ? "Updated" : "Registered"} tool "${name}" from draft recipe.`,
|
|
175
|
+
),
|
|
176
|
+
),
|
|
177
|
+
],
|
|
178
|
+
details: {
|
|
179
|
+
args: promoted.args,
|
|
180
|
+
config: targetPath,
|
|
181
|
+
draft: draftPath,
|
|
182
|
+
promoted: true,
|
|
183
|
+
tool: name,
|
|
184
|
+
} as RegisterToolResultDetails & Record<string, unknown>,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
98
188
|
function persistToolRecipe<TContext>(
|
|
99
189
|
deps: RegisterToolRuntimeDeps<TContext>,
|
|
100
190
|
cfg: Config.RegisteredTool,
|
|
@@ -284,6 +374,9 @@ export async function executeRegisterTool<TContext>(
|
|
|
284
374
|
ExecutionOutput.formatToolText(`Reserved tool name: ${name}`),
|
|
285
375
|
);
|
|
286
376
|
}
|
|
377
|
+
if (typeof input.draft === "string" && input.draft.trim()) {
|
|
378
|
+
return promoteDraftRecipe(name, input, ctx, deps);
|
|
379
|
+
}
|
|
287
380
|
const templateProvided = Object.hasOwn(input, "template");
|
|
288
381
|
const template = getInputTemplate(input.template);
|
|
289
382
|
if (templateProvided && (template === null || template === ""))
|
package/lib/runtime-notifier.ts
CHANGED
|
@@ -147,6 +147,7 @@ export function createFileRuntimeNotifier(
|
|
|
147
147
|
mkdirSync(dirname(file), { recursive: true });
|
|
148
148
|
let position =
|
|
149
149
|
options.replay || !existsSync(file) ? 0 : statSync(file).size;
|
|
150
|
+
let pending = "";
|
|
150
151
|
let closed = false;
|
|
151
152
|
const reconcile = (reason: RuntimeReconcileReason): void => {
|
|
152
153
|
if (closed) return;
|
|
@@ -160,10 +161,15 @@ export function createFileRuntimeNotifier(
|
|
|
160
161
|
const drain = (): void => {
|
|
161
162
|
if (closed || !existsSync(file)) return;
|
|
162
163
|
const buffer = readFileSync(file);
|
|
163
|
-
if (position > buffer.length)
|
|
164
|
-
|
|
164
|
+
if (position > buffer.length) {
|
|
165
|
+
position = 0;
|
|
166
|
+
pending = "";
|
|
167
|
+
}
|
|
168
|
+
const chunk = pending + buffer.subarray(position).toString("utf8");
|
|
165
169
|
position = buffer.length;
|
|
166
|
-
|
|
170
|
+
const lines = chunk.split("\n");
|
|
171
|
+
pending = chunk.endsWith("\n") ? "" : (lines.pop() ?? "");
|
|
172
|
+
for (const line of lines) {
|
|
167
173
|
if (!line.trim()) continue;
|
|
168
174
|
const event = parseRuntimeWakeEventLine(line);
|
|
169
175
|
if (event && event.actor === actor) {
|