@pify/swarm 0.6.2 → 0.7.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/README.md +2 -2
- package/extensions/swarm.ts +90 -16
- package/package.json +1 -1
- package/src/consent.ts +116 -0
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ match_keywords: rust, memory safety
|
|
|
57
57
|
|
|
58
58
|
`match_patterns` are globs matched against path-like tokens in the item — the longest match wins, so a specific rule beats a general one. `match_keywords` match the item's words. `review src/auth.rs` routes to the Rust auditor; `test the login flow` to a tester; anything matching nothing falls back to the read-only `scout`, so **the fallback can never mutate**.
|
|
59
59
|
|
|
60
|
-
The catalog is the same `.pi/agents/*.md` one [`@pify/subagent`](https://github.com/pifydev/subagent) reads — `description`, `tools`, `model`, `thinking`, `max_turns` — plus the two routing keys. Project-local definitions load only once pi's project trust
|
|
60
|
+
The catalog is the same `.pi/agents/*.md` one [`@pify/subagent`](https://github.com/pifydev/subagent) reads — `description`, `tools`, `model`, `thinking`, `max_turns` — plus the two routing keys. Project-local definitions load only after you approve them — once per project, remembered in `pify-project-consent.json`, the same "agents" answer [`@pify/subagent`](https://github.com/pifydev/subagent) records, so approving or refusing once means the same thing across the suite. pi's own project trust is necessary but not sufficient here: pi only asks about trust when a repo ships something pi itself loads, and `.pi/agents/` is not on that list — measured, a repo whose only pi file is `.pi/agents/reviewer.md` reports `isProjectTrusted=true`. For CI, `PIFY_TRUST_PROJECT=1`. (Earlier versions of this paragraph claimed the gate existed before it did; as of v0.7.0 it does.)
|
|
61
61
|
|
|
62
62
|
## Behaviour
|
|
63
63
|
|
|
@@ -67,7 +67,7 @@ The catalog is the same `.pi/agents/*.md` one [`@pify/subagent`](https://github.
|
|
|
67
67
|
|
|
68
68
|
## A background run comes back to you
|
|
69
69
|
|
|
70
|
-
`swarm_status` on a run still in flight used to say "still running", which left the model one option: ask again.
|
|
70
|
+
`swarm_status` on a run still in flight used to say "still running", which left the model one option: ask again. The aggregated report is *sent* into the conversation when the run finishes — honestly: this delivery mechanism is the same one whose only live measurement in the suite (subagent's) currently fails under print mode's teardown, and swarm has no live test of its own yet, so treat delivery as unmeasured and `swarm_status` as the reliable path. Asking early returns a structured result carrying `retryable`, the elapsed time and `pollRequired: false` — a normal answer rather than an error, because a tool error over a condition only time resolves invites the model's retry machinery into a loop.
|
|
71
71
|
|
|
72
72
|
## Command
|
|
73
73
|
|
package/extensions/swarm.ts
CHANGED
|
@@ -25,6 +25,14 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
25
25
|
import { Type } from "typebox";
|
|
26
26
|
|
|
27
27
|
import { BUILTIN_AGENTS } from "../src/builtin.ts";
|
|
28
|
+
import {
|
|
29
|
+
consentQuestion,
|
|
30
|
+
decideConsent,
|
|
31
|
+
envConsent,
|
|
32
|
+
parseConsent,
|
|
33
|
+
readConsent,
|
|
34
|
+
writeConsent,
|
|
35
|
+
} from "../src/consent.ts";
|
|
28
36
|
import { LiveChildren, cancelNote, type CancelReason } from "../src/cancel.ts";
|
|
29
37
|
import { DELIVERY_TYPE, deliveryMessage, pendingResult } from "../src/pending.ts";
|
|
30
38
|
import { createIsolationWorktree, isolationNote, removeIfUnchanged } from "../src/isolate.ts";
|
|
@@ -41,7 +49,7 @@ import {
|
|
|
41
49
|
type ItemState,
|
|
42
50
|
type SwarmRun,
|
|
43
51
|
} from "../src/types.ts";
|
|
44
|
-
import { readFileSync, readdirSync } from "node:fs";
|
|
52
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
45
53
|
import { basename, join } from "node:path";
|
|
46
54
|
|
|
47
55
|
const RUN_ENTRY = "swarm-run";
|
|
@@ -50,16 +58,23 @@ const CLEAN_WORKTREE_NOTE =
|
|
|
50
58
|
|
|
51
59
|
type UiContext = ExtensionContext;
|
|
52
60
|
|
|
53
|
-
function loadDefs(cwd: string, agentDir: string): Map<string, AgentDef> {
|
|
61
|
+
function loadDefs(cwd: string, agentDir: string, projectAllowed: boolean): Map<string, AgentDef> {
|
|
54
62
|
const defs = new Map<string, AgentDef>();
|
|
55
63
|
for (const [name, content] of Object.entries(BUILTIN_AGENTS)) {
|
|
56
64
|
const def = parseAgentFile(name, content, "builtin");
|
|
57
65
|
if (def) defs.set(def.name, def);
|
|
58
66
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
// The project directory is consent-gated: `.pi/agents/*.md` is repo-shipped
|
|
68
|
+
// text that becomes a CHILD SYSTEM PROMPT, overriding builtins of the same
|
|
69
|
+
// name, and pi never asks about it — measured, a repo whose only pi file is
|
|
70
|
+
// `.pi/agents/reviewer.md` reports isProjectTrusted=true. One answer,
|
|
71
|
+
// recorded under the same "agents" scope subagent uses, governs the catalog
|
|
72
|
+
// across the whole suite.
|
|
73
|
+
const sources: Array<readonly [string, "global" | "project"]> = [
|
|
74
|
+
[join(agentDir, "agents"), "global"] as const,
|
|
75
|
+
...(projectAllowed ? [[join(cwd, ".pi", "agents"), "project"] as const] : []),
|
|
76
|
+
];
|
|
77
|
+
for (const [dir, source] of sources) {
|
|
63
78
|
try {
|
|
64
79
|
for (const file of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
|
65
80
|
try {
|
|
@@ -243,6 +258,15 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
243
258
|
item.status =
|
|
244
259
|
last?.stopReason === "aborted" ? "aborted" : last?.stopReason === "error" ? "error" : "done";
|
|
245
260
|
if (item.status === "error") item.error = text || "child session error";
|
|
261
|
+
// A child that stopped cleanly and said nothing has not answered — the
|
|
262
|
+
// fix subagent already carries and this executor never received. Left
|
|
263
|
+
// as "done", a reasoning-only finish (measured on anthropic/claude-opus-5
|
|
264
|
+
// through child sessions) rendered a successful fan-out of "(empty
|
|
265
|
+
// report)" items. An exit status is not an answer.
|
|
266
|
+
if (item.status === "done" && !item.result) {
|
|
267
|
+
item.status = "error";
|
|
268
|
+
item.error = "the child finished without producing an answer";
|
|
269
|
+
}
|
|
246
270
|
} catch (err) {
|
|
247
271
|
item.status = "error";
|
|
248
272
|
item.error = err instanceof Error ? err.message : String(err);
|
|
@@ -332,6 +356,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
332
356
|
pi.registerTool({
|
|
333
357
|
name: "swarm_run",
|
|
334
358
|
label: "Run swarm",
|
|
359
|
+
promptSnippet: "Run many independent items through child agents at once",
|
|
335
360
|
description:
|
|
336
361
|
`Fan out 1-${MAX_ITEMS} independent task items to parallel child agents (concurrency ${DEFAULT_CONCURRENCY}). ` +
|
|
337
362
|
"Each item auto-routes to an agent type via its match_patterns/match_keywords, falling back to the " +
|
|
@@ -410,11 +435,11 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
410
435
|
}
|
|
411
436
|
|
|
412
437
|
if (run.background) {
|
|
413
|
-
void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true)
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
438
|
+
void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true)
|
|
439
|
+
.then(() => {
|
|
440
|
+
notify(uiCtx, `swarm ${run.runId} finished`, "info");
|
|
441
|
+
// The report goes to the agent, not only to the screen — otherwise
|
|
442
|
+
// asking again was its only way to find out.
|
|
418
443
|
pi.sendMessage(
|
|
419
444
|
{
|
|
420
445
|
customType: DELIVERY_TYPE,
|
|
@@ -424,10 +449,15 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
424
449
|
},
|
|
425
450
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
426
451
|
);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
452
|
+
})
|
|
453
|
+
.catch(() => {
|
|
454
|
+
// The whole chain, not just sendMessage: a /reload or session
|
|
455
|
+
// switch while the run is in flight makes every captured pi/ctx
|
|
456
|
+
// handle throw "ctx is stale" on next use, and an uncaught
|
|
457
|
+
// rejection here takes the entire process down with it — the run's
|
|
458
|
+
// work lost to a notification. Delivery is a convenience;
|
|
459
|
+
// swarm_status still works.
|
|
460
|
+
});
|
|
431
461
|
return {
|
|
432
462
|
content: [
|
|
433
463
|
{ type: "text", text: `Swarm ${run.runId} started (${items.length} items). Poll swarm_status runId="${run.runId}".` },
|
|
@@ -451,6 +481,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
451
481
|
pi.registerTool({
|
|
452
482
|
name: "swarm_status",
|
|
453
483
|
label: "Swarm status",
|
|
484
|
+
promptSnippet: "Progress of a running swarm",
|
|
454
485
|
description: "Progress of a swarm run (default: the latest). Returns the full report when finished.",
|
|
455
486
|
parameters: Type.Object({
|
|
456
487
|
runId: Type.Optional(Type.String()),
|
|
@@ -481,8 +512,51 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
481
512
|
|
|
482
513
|
// ── Lifecycle & command ──────────────────────────────────────────────
|
|
483
514
|
|
|
515
|
+
/** Where the suite records which projects you approved, and for what. */
|
|
516
|
+
function consentFile(): string {
|
|
517
|
+
return join(getAgentDir(), "pify-project-consent.json");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* May this repository's own agent definitions load? Same question, same
|
|
522
|
+
* store, and the same "agents" scope subagent records — one answer governs
|
|
523
|
+
* the catalog across subagent, swarm and workflow, so approving or refusing
|
|
524
|
+
* once means the same thing everywhere.
|
|
525
|
+
*/
|
|
526
|
+
async function projectAgentsAllowed(ctx: ExtensionContext): Promise<boolean> {
|
|
527
|
+
const dir = join(ctx.cwd, ".pi", "agents");
|
|
528
|
+
if (!existsSync(dir)) return false;
|
|
529
|
+
const file = consentFile();
|
|
530
|
+
let raw: string | null = null;
|
|
531
|
+
try {
|
|
532
|
+
raw = readFileSync(file, "utf8");
|
|
533
|
+
} catch {
|
|
534
|
+
raw = null;
|
|
535
|
+
}
|
|
536
|
+
const store = parseConsent(raw);
|
|
537
|
+
const verdict = decideConsent({
|
|
538
|
+
projectTrusted: (ctx as unknown as { isProjectTrusted?: () => boolean }).isProjectTrusted?.() ?? false,
|
|
539
|
+
remembered: readConsent(store, ctx.cwd, "agents"),
|
|
540
|
+
hasUI: ctx.hasUI,
|
|
541
|
+
envOverride: envConsent(process.env),
|
|
542
|
+
});
|
|
543
|
+
if (verdict !== "ask") return verdict === "allow";
|
|
544
|
+
|
|
545
|
+
const approved = await ctx.ui.confirm(
|
|
546
|
+
"Load this project's agent definitions?",
|
|
547
|
+
consentQuestion("its own agent definitions, which override the builtins of the same name", dir),
|
|
548
|
+
);
|
|
549
|
+
try {
|
|
550
|
+
writeFileSync(file, `${JSON.stringify(writeConsent(store, ctx.cwd, "agents", approved), null, 2)}
|
|
551
|
+
`);
|
|
552
|
+
} catch {
|
|
553
|
+
// An unwritable consent file costs us the memory of the answer, not the answer.
|
|
554
|
+
}
|
|
555
|
+
return approved;
|
|
556
|
+
}
|
|
557
|
+
|
|
484
558
|
pi.on("session_start", async (_event, ctx) => {
|
|
485
|
-
defs = loadDefs(ctx.cwd, getAgentDir());
|
|
559
|
+
defs = loadDefs(ctx.cwd, getAgentDir(), await projectAgentsAllowed(ctx));
|
|
486
560
|
runs.clear();
|
|
487
561
|
activeRun = null;
|
|
488
562
|
for (const entry of ctx.sessionManager.getBranch()) {
|
package/package.json
CHANGED
package/src/consent.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consent for project-supplied files that pi does not know about.
|
|
3
|
+
*
|
|
4
|
+
* `ctx.isProjectTrusted()` looked like the right gate, and for pi's own
|
|
5
|
+
* project resources it is. But pi only *asks* about trust when the repository
|
|
6
|
+
* ships one of the things pi itself loads — `.pi/settings.json`,
|
|
7
|
+
* `.pi/extensions`, `.pi/skills`, `.pi/prompts`, `.pi/themes`, `SYSTEM.md`,
|
|
8
|
+
* `APPEND_SYSTEM.md`. A repository carrying only a repository carrying only its own extension config
|
|
9
|
+
* triggers no prompt at all, and `isProjectTrusted()` then returns true by
|
|
10
|
+
* default. Measured, not assumed: a repo whose only pi file was
|
|
11
|
+
* `.pi/agents/reviewer.md` reported `isProjectTrusted=true`, while the same repo
|
|
12
|
+
* with a `.pi/skills` directory reported false.
|
|
13
|
+
*
|
|
14
|
+
* So a file this extension invented needs consent this extension asks for. pi
|
|
15
|
+
* saying no is still final — this can only ever be a second gate, never a way
|
|
16
|
+
* around the first.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** What to do with a project-supplied file this session. */
|
|
20
|
+
export type ConsentVerdict = "allow" | "refuse" | "ask";
|
|
21
|
+
|
|
22
|
+
export interface ConsentInput {
|
|
23
|
+
/** pi's own decision. False is final. */
|
|
24
|
+
projectTrusted: boolean;
|
|
25
|
+
/** What the user answered for this project before, if they ever did. */
|
|
26
|
+
remembered: boolean | undefined;
|
|
27
|
+
/** Whether there is a UI to ask through. */
|
|
28
|
+
hasUI: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* PIFY_TRUST_PROJECT, for headless runs. An environment variable is set by
|
|
31
|
+
* whoever starts the process, never by the repository being read, so it is
|
|
32
|
+
* a signal from the user and not from the code under inspection.
|
|
33
|
+
*/
|
|
34
|
+
envOverride?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read PIFY_TRUST_PROJECT the same way everywhere. */
|
|
38
|
+
export function envConsent(env: Record<string, string | undefined>): boolean | undefined {
|
|
39
|
+
const raw = env.PIFY_TRUST_PROJECT?.trim().toLowerCase();
|
|
40
|
+
if (raw === undefined || raw === "") return undefined;
|
|
41
|
+
if (raw === "1" || raw === "true" || raw === "yes") return true;
|
|
42
|
+
if (raw === "0" || raw === "false" || raw === "no") return false;
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function decideConsent(input: ConsentInput): ConsentVerdict {
|
|
47
|
+
// pi already refused; nothing here may widen that.
|
|
48
|
+
if (!input.projectTrusted) return "refuse";
|
|
49
|
+
// An explicit answer from the person who started the process outranks a
|
|
50
|
+
// remembered one — that is what typing it again means.
|
|
51
|
+
if (input.envOverride !== undefined) return input.envOverride ? "allow" : "refuse";
|
|
52
|
+
if (input.remembered === true) return "allow";
|
|
53
|
+
if (input.remembered === false) return "refuse";
|
|
54
|
+
// Headless runs cannot ask, and a file nobody approved must not be loaded
|
|
55
|
+
// just because no one was there to say no.
|
|
56
|
+
if (!input.hasUI) return "refuse";
|
|
57
|
+
return "ask";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Consent is per project *and* per kind of file: approving a repository's
|
|
62
|
+
* memory says nothing about approving the agent definitions it also ships.
|
|
63
|
+
*/
|
|
64
|
+
export type ConsentFile = Record<string, Record<string, boolean>>;
|
|
65
|
+
|
|
66
|
+
export function readConsent(file: ConsentFile, cwd: string, scope: string): boolean | undefined {
|
|
67
|
+
const entry = file[normalizeCwd(cwd)];
|
|
68
|
+
if (!entry) return undefined;
|
|
69
|
+
const value = entry[scope];
|
|
70
|
+
return typeof value === "boolean" ? value : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function writeConsent(
|
|
74
|
+
file: ConsentFile,
|
|
75
|
+
cwd: string,
|
|
76
|
+
scope: string,
|
|
77
|
+
allowed: boolean,
|
|
78
|
+
): ConsentFile {
|
|
79
|
+
const key = normalizeCwd(cwd);
|
|
80
|
+
return { ...file, [key]: { ...(file[key] ?? {}), [scope]: allowed } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Tolerate anything on disk: a corrupt consent file means "never asked". */
|
|
84
|
+
export function parseConsent(raw: string | null): ConsentFile {
|
|
85
|
+
if (!raw) return {};
|
|
86
|
+
try {
|
|
87
|
+
const data = JSON.parse(raw) as unknown;
|
|
88
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return {};
|
|
89
|
+
const out: ConsentFile = {};
|
|
90
|
+
for (const [cwd, scopes] of Object.entries(data as Record<string, unknown>)) {
|
|
91
|
+
if (!scopes || typeof scopes !== "object" || Array.isArray(scopes)) continue;
|
|
92
|
+
const entry: Record<string, boolean> = {};
|
|
93
|
+
for (const [scope, value] of Object.entries(scopes as Record<string, unknown>)) {
|
|
94
|
+
if (typeof value === "boolean") entry[scope] = value;
|
|
95
|
+
}
|
|
96
|
+
if (Object.keys(entry).length > 0) out[cwd] = entry;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
} catch {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeCwd(cwd: string): string {
|
|
105
|
+
return cwd.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function consentQuestion(what: string, path: string): string {
|
|
109
|
+
return [
|
|
110
|
+
`This project ships ${what}:`,
|
|
111
|
+
` ${path}`,
|
|
112
|
+
"",
|
|
113
|
+
"pi did not ask about it, because it is not one of the files pi itself loads.",
|
|
114
|
+
"Load it for this project from now on?",
|
|
115
|
+
].join("\n");
|
|
116
|
+
}
|