@pify/swarm 0.9.0 → 0.9.2

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.
@@ -32,13 +32,23 @@ import {
32
32
  decideConsent,
33
33
  envConsent,
34
34
  parseConsent,
35
+ persistConsent,
35
36
  readConsent,
36
- writeConsent,
37
37
  } from "../src/consent.ts";
38
38
  import { LiveChildren, cancelNote, type CancelReason } from "../src/cancel.ts";
39
39
  import { DELIVERY_TYPE, deliveryMessage, pendingResult } from "../src/pending.ts";
40
40
  import { createIsolationWorktree, isolationNote, removeIfUnchanged } from "../src/isolate.ts";
41
- import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "../src/mailbox.ts";
41
+ import {
42
+ MAILBOX_INBOX_TOOL,
43
+ MAILBOX_POST_TOOL,
44
+ MAILBOX_TOOL_NAMES,
45
+ formatInbox,
46
+ mailboxDir,
47
+ mailboxKey,
48
+ mailboxPrompt,
49
+ postMessage,
50
+ readInbox,
51
+ } from "../src/mailbox.ts";
42
52
  import { parseAgentFile } from "../src/frontmatter.ts";
43
53
  import { buildReport, buildStatusLine } from "../src/report.ts";
44
54
  import { routeItem } from "../src/routing.ts";
@@ -53,7 +63,7 @@ import {
53
63
  type ItemState,
54
64
  type SwarmRun,
55
65
  } from "../src/types.ts";
56
- import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
66
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
57
67
  import { basename, join } from "node:path";
58
68
 
59
69
  const RUN_ENTRY = "swarm-run";
@@ -136,7 +146,7 @@ export default function swarm(pi: ExtensionAPI) {
136
146
  let seen = 0;
137
147
  return [
138
148
  {
139
- name: "swarm_post",
149
+ name: MAILBOX_POST_TOOL,
140
150
  label: "Post to swarm",
141
151
  description:
142
152
  "Tell the other agents in this swarm something that changes their work: a shared file you " +
@@ -154,7 +164,7 @@ export default function swarm(pi: ExtensionAPI) {
154
164
  },
155
165
  },
156
166
  {
157
- name: "swarm_inbox",
167
+ name: MAILBOX_INBOX_TOOL,
158
168
  label: "Read swarm inbox",
159
169
  description:
160
170
  "Read what the other agents in this swarm have posted since you last checked. Call it " +
@@ -227,7 +237,11 @@ export default function swarm(pi: ExtensionAPI) {
227
237
  sessionManager: SessionManager.inMemory(workDir ?? ctx.cwd),
228
238
  model,
229
239
  thinkingLevel: (def.thinking ?? pi.getThinkingLevel()) as never,
230
- tools: def.tools,
240
+ // `tools` is an allowlist and it filters customTools too, so a mailbox
241
+ // tool that is not named here is registered and then dropped — the
242
+ // child is told it has no such tool, and mailbox:true does nothing.
243
+ // Admit them exactly as @pify/subagent admits ask_supervisor.
244
+ tools: mailbox ? [...def.tools, ...MAILBOX_TOOL_NAMES] : def.tools,
231
245
  ...(mailbox ? { customTools: mailboxTools(mailbox, item.agent + "-" + item.index) } : {}),
232
246
  resourceLoader: loader,
233
247
  });
@@ -343,8 +357,12 @@ export default function swarm(pi: ExtensionAPI) {
343
357
  isolate?: boolean,
344
358
  useMailbox?: boolean,
345
359
  ): Promise<void> {
346
- // One shared log per run; only created when the caller asked for it.
347
- const mailbox = useMailbox ? mailboxDir(getAgentDir(), run.runId) : undefined;
360
+ // One shared log per run; only created when the caller asked for it. Keyed
361
+ // on a per-run token (not the reused "s1" run id), so one run never reads a
362
+ // previous run's stale messages, and removed at the end so it never leaks.
363
+ const mailbox = useMailbox
364
+ ? mailboxDir(getAgentDir(), mailboxKey(run.runId, run.startedAt))
365
+ : undefined;
348
366
 
349
367
  // A dependent structurally receives each upstream's output — the thing a
350
368
  // hand-sequenced coordinator forgets. Prepended to the shared preamble.
@@ -359,33 +377,45 @@ export default function swarm(pi: ExtensionAPI) {
359
377
 
360
378
  // Run each item once its needs finish, up to the concurrency cap; a flat
361
379
  // run (no needs) has everything ready at once, exactly like the old pool.
362
- await runGraph(
363
- run.items,
364
- DEFAULT_CONCURRENCY,
365
- async (item, upstream) => {
366
- const def = routeItem(item.item, defs, fixed);
367
- item.agent = def.name;
368
- const itemContext = contextFor(upstream);
369
- if (isolate) {
370
- try {
371
- const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
372
- await runItem(ctx, run.runId, def, item, itemContext, iso.path, mailbox);
373
- // Remove the worktree when the item changed nothing (the leak
374
- // removeIfUnchanged fixes); keep it when there is work to merge.
375
- const removed = removeIfUnchanged(ctx.cwd, iso);
376
- if (item.result !== null) {
377
- item.result = `${item.result}\n\n${removed ? CLEAN_WORKTREE_NOTE : isolationNote(iso)}`;
380
+ try {
381
+ await runGraph(
382
+ run.items,
383
+ DEFAULT_CONCURRENCY,
384
+ async (item, upstream) => {
385
+ const def = routeItem(item.item, defs, fixed);
386
+ item.agent = def.name;
387
+ const itemContext = contextFor(upstream);
388
+ if (isolate) {
389
+ try {
390
+ const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
391
+ await runItem(ctx, run.runId, def, item, itemContext, iso.path, mailbox);
392
+ // Remove the worktree when the item changed nothing (the leak
393
+ // removeIfUnchanged fixes); keep it when there is work to merge.
394
+ const removed = removeIfUnchanged(ctx.cwd, iso);
395
+ if (item.result !== null) {
396
+ item.result = `${item.result}\n\n${removed ? CLEAN_WORKTREE_NOTE : isolationNote(iso)}`;
397
+ }
398
+ } catch (err) {
399
+ item.status = "error";
400
+ item.error = err instanceof Error ? err.message : String(err);
378
401
  }
379
- } catch (err) {
380
- item.status = "error";
381
- item.error = err instanceof Error ? err.message : String(err);
402
+ } else {
403
+ await runItem(ctx, run.runId, def, item, itemContext, undefined, mailbox);
382
404
  }
383
- } else {
384
- await runItem(ctx, run.runId, def, item, itemContext, undefined, mailbox);
405
+ },
406
+ () => run.status === "cancelled",
407
+ );
408
+ } finally {
409
+ // The mailbox is per-run scratch: reclaim it however the run ends
410
+ // (finished, cancelled, or thrown) so a directory never accumulates.
411
+ if (mailbox) {
412
+ try {
413
+ rmSync(mailbox, { recursive: true, force: true });
414
+ } catch {
415
+ // best-effort: an unremovable scratch dir is not worth failing a run
385
416
  }
386
- },
387
- () => run.status === "cancelled",
388
- );
417
+ }
418
+ }
389
419
 
390
420
  if (run.status !== "cancelled") run.status = "done";
391
421
  run.finishedAt = Date.now();
@@ -630,8 +660,7 @@ export default function swarm(pi: ExtensionAPI) {
630
660
  ),
631
661
  );
632
662
  try {
633
- writeFileSync(file, `${JSON.stringify(writeConsent(store, ctx.cwd, "agents", approved), null, 2)}
634
- `);
663
+ persistConsent(file, ctx.cwd, "agents", approved);
635
664
  } catch {
636
665
  // An unwritable consent file costs us the memory of the answer, not the answer.
637
666
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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 CHANGED
@@ -16,6 +16,8 @@
16
16
  * around the first.
17
17
  */
18
18
 
19
+ import { readFileSync, writeFileSync } from "node:fs";
20
+
19
21
  /** What to do with a project-supplied file this session. */
20
22
  export type ConsentVerdict = "allow" | "refuse" | "ask";
21
23
 
@@ -80,6 +82,30 @@ export function writeConsent(
80
82
  return { ...file, [key]: { ...(file[key] ?? {}), [scope]: allowed } };
81
83
  }
82
84
 
85
+ /**
86
+ * Record a consent answer to disk without losing a concurrent writer's scope.
87
+ *
88
+ * Several @pify packages share this one file (keyed by cwd → {scope: bool}), and
89
+ * each asks the user with an `await` between reading the file and writing it
90
+ * back. If two flows both read the old file, then both write, the second write
91
+ * drops the scope the first added. So re-read and re-parse the file HERE, right
92
+ * before writing, with no `await` in between — the merge always starts from the
93
+ * freshest on-disk state, and within a single (single-threaded) process the
94
+ * read-modify-write can no longer interleave. Cross-process races remain
95
+ * theoretically possible but fail safe: the worst case is a re-prompt, never a
96
+ * silently-granted consent.
97
+ */
98
+ export function persistConsent(file: string, cwd: string, scope: string, allowed: boolean): void {
99
+ let raw: string | null = null;
100
+ try {
101
+ raw = readFileSync(file, "utf8");
102
+ } catch {
103
+ raw = null;
104
+ }
105
+ const next = writeConsent(parseConsent(raw), cwd, scope, allowed);
106
+ writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`);
107
+ }
108
+
83
109
  /** Tolerate anything on disk: a corrupt consent file means "never asked". */
84
110
  export function parseConsent(raw: string | null): ConsentFile {
85
111
  if (!raw) return {};
package/src/mailbox.ts CHANGED
@@ -23,6 +23,30 @@ export interface MailMessage {
23
23
  export const MAX_MESSAGE_CHARS = 1200;
24
24
  export const MAX_INBOX_MESSAGES = 30;
25
25
 
26
+ /**
27
+ * The two mailbox tool names. pi's child-session `tools` allowlist filters
28
+ * customTools too, so a mailbox tool that is not also named in the child's
29
+ * allowlist is registered and then silently dropped — the child is told it has
30
+ * no such tool. Naming them here keeps the allowlist and the tool definitions
31
+ * from drifting apart (mirrors how @pify/subagent wires ask_supervisor).
32
+ */
33
+ export const MAILBOX_POST_TOOL = "swarm_post";
34
+ export const MAILBOX_INBOX_TOOL = "swarm_inbox";
35
+ export const MAILBOX_TOOL_NAMES = [MAILBOX_POST_TOOL, MAILBOX_INBOX_TOOL] as const;
36
+
37
+ /**
38
+ * A per-run key for the mailbox directory. The run id ("s1", "s2", …) restarts
39
+ * at "s1" every session, so keying the dir on it alone made two different runs
40
+ * — a fresh "s1" and a previous session's "s1" — share one directory: the new
41
+ * run read the old run's stale messages, and the old dir was never reclaimed.
42
+ * Folding in the run's start time (base36-compact, and derived from the clock
43
+ * rather than Math.random so it stays deterministic and testable) gives every
44
+ * run its own directory, even two "s1"s from different sessions.
45
+ */
46
+ export function mailboxKey(runId: string, startedAt: number): string {
47
+ return `${runId}-${Math.trunc(startedAt).toString(36)}`;
48
+ }
49
+
26
50
  /**
27
51
  * One directory per run under the agent dir. Run ids are generated locally,
28
52
  * but this builds a filesystem path, so it stays a single flat segment: no