@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 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 has been granted.
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. Now the aggregated report is **delivered** into the conversation when the run finishes, and 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.
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
 
@@ -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
- for (const [dir, source] of [
60
- [join(agentDir, "agents"), "global"],
61
- [join(cwd, ".pi", "agents"), "project"],
62
- ] as const) {
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).then(() => {
414
- notify(uiCtx, `swarm ${run.runId} finished`, "info");
415
- // The report goes to the agent, not only to the screen — otherwise
416
- // asking again was its only way to find out.
417
- try {
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
- } catch {
428
- // Delivery is a convenience; swarm_status still works.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Coordinate multiple pi agents in parallel: swarm_run fan-out with per-item auto-routing, concurrency queue, aggregated reports",
5
5
  "keywords": [
6
6
  "pi-package",
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
+ }