pi-subagents 0.47.0 → 0.47.1

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/CHANGELOG.md CHANGED
@@ -2,15 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.47.1] - 2026-08-12
6
+
7
+ ### Fixed
8
+ - Honor configured artifact cleanup retention days and let `0` disable artifact cleanup. Thanks to @elecnix for #1012.
9
+ - Add a display-only dismiss action for reload-recovered running workflows without claiming or attempting to stop their work (#1010).
10
+ - Stop the bundled reviewer from inheriting chain-only plan/progress reads in ad-hoc review runs. Thanks to @Ostii for #1000.
11
+ - Remove mutation-capable tools from the bundled reviewer so read-only review lanes have a hard launch-time tool boundary (#1007).
12
+ - Show the requested child agent in workflow started trace entries. Thanks to @albertgwo for #1001.
13
+
5
14
  ## [0.47.0] - 2026-08-11
6
15
 
7
16
  ### Changed
17
+ - Avoid fully parsing stale cross-session result files during watcher recovery and reduce healthy watcher safety scans.
18
+ - Index active async runs so status restoration no longer scans all historical run directories.
19
+ - Refresh active async job state from filesystem events while reserving polling for slow liveness repair.
8
20
  - Add optional strict model-scope enforcement that rejects inherited and fallback models outside the configured allowlist. Thanks to @antonioc-cl for #995.
9
21
  - Trim legacy chain-control schema fields and guidance by default, saving 1,319 `o200k_base` tokens from the serialized default tool schema plus description versus `legacyChainControls: true`. Thanks to @tajquitgenius for #977.
10
22
  - Move project-scoped pi-subagents storage from `.pi-subagents/` to `.pi/subagents/` for cleaner project roots. Thanks to @yceachan for #971.
11
23
  - Clarify the accepted mission launch object contract for tool callers.
12
24
  - Reduce repeated async status parsing, workflow trace projection, and constrained widget rendering work.
25
+ - Coalesce rapid running-status writes while keeping terminal and attention status changes durable immediately.
13
26
  - Keep parsed async statuses cached beyond 50 runs while preserving per-read freshness checks. Thanks to @bcanvural for #982.
27
+ - Prefer native control inbox watchers over per-process 250 ms polling, with polling retained as a fallback.
14
28
 
15
29
  ### Fixed
16
30
  - Omit missing configured read files from child task instructions.
@@ -1,12 +1,11 @@
1
1
  ---
2
2
  name: reviewer
3
3
  description: Versatile review specialist for code diffs, plans, proposed solutions, codebase health, and PR/issue validation
4
- tools: read, grep, find, ls, bash, edit, write, intercom
4
+ tools: read, grep, find, ls, intercom
5
5
  thinking: high
6
6
  systemPromptMode: replace
7
7
  inheritProjectContext: true
8
8
  inheritSkills: false
9
- defaultReads: plan.md, progress.md
10
9
  ---
11
10
 
12
11
  You are a disciplined review subagent. Your job is to inspect, evaluate, and report findings with evidence. You do not guess; you verify from the code, tests, docs, or requirements.
@@ -51,9 +50,9 @@ Review a PR or issue by understanding the context, then verifying:
51
50
  - Tests and docs are updated as needed.
52
51
 
53
52
  ## Working rules
54
- - Read the plan, progress, and relevant files first when available.
53
+ - Read the relevant files first. Read plan and progress when the task supplies them.
55
54
  - Repo-local `progress.md` files are allowed scratch/memory files. Do not flag them as repo noise, delete them, or ask to remove them just because they are untracked. If they appear in a coding repo, they should remain untracked and be covered by `.gitignore`.
56
- - Use `bash` only for read-only inspection (e.g., `git diff`, `git log`, `git show`, test runs).
55
+ - Do not use shell commands or write files. Report any test or Git command that a supervisor must run.
57
56
  - Do not invent issues. Only report problems you can justify from evidence.
58
57
  - Prefer small corrective edits over broad rewrites.
59
58
  - If everything looks good, say so plainly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.47.0",
3
+ "version": "0.47.1",
4
4
  "description": "Pi extension for single-agent delegation and scripted multi-agent workflows",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -37,6 +37,15 @@ function validateFleetKeybindingsConfig(value: unknown): void {
37
37
  }
38
38
  }
39
39
 
40
+ function validateArtifactConfig(value: unknown): void {
41
+ if (value === undefined) return;
42
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("config.artifactConfig must be a JSON object");
43
+ const cleanupDays = (value as Record<string, unknown>).cleanupDays;
44
+ if (cleanupDays !== undefined && (typeof cleanupDays !== "number" || !Number.isInteger(cleanupDays) || cleanupDays < 0)) {
45
+ throw new Error("config.artifactConfig.cleanupDays must be a non-negative integer");
46
+ }
47
+ }
48
+
40
49
  function validateConfig(config: Record<string, unknown>): void {
41
50
  if (config.artifactDir !== undefined && !ARTIFACT_DIR_PREFERENCES.has(config.artifactDir as ArtifactDirPreference)) {
42
51
  throw new Error(`config.artifactDir must be "project", "session", or "temp"`);
@@ -49,6 +58,7 @@ function validateConfig(config: Record<string, unknown>): void {
49
58
  validatePermissionConfig(config.permissions);
50
59
  validateScheduledRunsConfig(config.scheduledRuns);
51
60
  validateFleetKeybindingsConfig(config.fleetKeybindings);
61
+ validateArtifactConfig(config.artifactConfig);
52
62
  }
53
63
 
54
64
  export function getConfigPath(): string {
@@ -366,7 +366,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
366
366
  const asyncWidgetEnabled = config.asyncWidget !== false;
367
367
  const summaryInlineToolDisplay = config.inlineToolDisplay === "summary";
368
368
  const tempArtifactsDir = getArtifactsDir(null);
369
- cleanupAllArtifactDirs(DEFAULT_ARTIFACT_CONFIG.cleanupDays);
369
+ const artifactCleanupDays = config.artifactConfig?.cleanupDays ?? DEFAULT_ARTIFACT_CONFIG.cleanupDays;
370
+ cleanupAllArtifactDirs(artifactCleanupDays);
370
371
 
371
372
  const state: SubagentState = {
372
373
  baseCwd: "",
@@ -430,6 +431,9 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
430
431
  },
431
432
  resolveCapabilityCeiling: (sessionId) => resolveCurrentSubagentCapabilityCeiling(sessionId),
432
433
  });
434
+ const { ensurePoller, refreshWidget, handleStarted, handleComplete, resetJobs, restoreActiveJobs, dispose: disposeAsyncJobTracker } = createAsyncJobTracker(pi, state, DIRS.async, {
435
+ widgetEnabled: asyncWidgetEnabled,
436
+ });
433
437
  const { startResultWatcher, primeExistingResults, stopResultWatcher } = createResultWatcher(
434
438
  pi,
435
439
  state,
@@ -438,6 +442,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
438
442
  {
439
443
  notifier: completionNotifier,
440
444
  observeCompletion: (result) => scheduledRunManager.handleAsyncCompletion(result),
445
+ observedCompletionRunIds: () => scheduledRunManager.observedCompletionRunIds(),
441
446
  deliverIntercomResults: config.intercomBridge?.resultDelivery === true,
442
447
  },
443
448
  );
@@ -451,16 +456,10 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
451
456
  supervisorChannel.dispose();
452
457
  waitSubscriptionManager.dispose();
453
458
  fleetStatus?.dispose();
454
- if (state.poller) {
455
- clearInterval(state.poller);
456
- state.poller = null;
457
- }
459
+ disposeAsyncJobTracker();
458
460
  };
459
461
  globalStore[runtimeCleanupStoreKey] = runtimeCleanup;
460
462
 
461
- const { ensurePoller, refreshWidget, handleStarted, handleComplete, resetJobs, restoreActiveJobs } = createAsyncJobTracker(pi, state, DIRS.async, {
462
- widgetEnabled: asyncWidgetEnabled,
463
- });
464
463
  const executor = createSubagentExecutor({
465
464
  pi,
466
465
  state,
@@ -708,7 +707,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
708
707
  try {
709
708
  const sessionFile = ctx.sessionManager.getSessionFile();
710
709
  if (sessionFile) {
711
- cleanupOldArtifacts(getArtifactsDir(sessionFile), DEFAULT_ARTIFACT_CONFIG.cleanupDays);
710
+ cleanupOldArtifacts(getArtifactsDir(sessionFile), artifactCleanupDays);
712
711
  }
713
712
  } catch {
714
713
  // Cleanup failures should not block session lifecycle events.
@@ -823,8 +822,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
823
822
  delete globalStore[eventUnsubscribeStoreKey];
824
823
  }
825
824
  scheduledRunManager.stop();
826
- if (state.poller) clearInterval(state.poller);
827
- state.poller = null;
825
+ disposeAsyncJobTracker();
828
826
  for (const timer of state.cleanupTimers.values()) {
829
827
  clearTimeout(timer);
830
828
  }
@@ -490,6 +490,10 @@ function stopAsyncRun(
490
490
  throw new SubagentRpcError("not_found", `Async run '${initialRunId}' was not found in the active session.`);
491
491
  }
492
492
 
493
+ if (initialStatus.mode === "workflow" && initialStatus.state === "running") {
494
+ throw new SubagentRpcError("invalid_state", `Workflow ${initialRunId} is not controlled by this extension runtime; reload recovery cannot stop it safely.`);
495
+ }
496
+
493
497
  let status;
494
498
  try {
495
499
  status = reconcileAsyncRun(location.asyncDir, { resultsDir, kill: options.kill, now: options.now }).status;
@@ -263,10 +263,10 @@ const SubagentParamProperties = {
263
263
  })),
264
264
  name: Type.Optional(Type.String({ description: "Human-readable name for action='schedule.create'." })),
265
265
  id: Type.Optional(Type.String({
266
- description: "Run id or prefix for status, interrupt, stop, resume, steer, append-step, approve-checkpoint, reject-checkpoint, mission.attach-run, or the decision id for mission.resolve-decision."
266
+ description: "Run id or prefix for status, interrupt, stop, dismiss, resume, steer, append-step, approve-checkpoint, reject-checkpoint, mission.attach-run, or the decision id for mission.resolve-decision."
267
267
  })),
268
268
  runId: Type.Optional(Type.String({
269
- description: "Target run ID for interrupt, stop, resume, steer, append-step, approve-checkpoint, reject-checkpoint, or mission.attach-run. Prefer id for new calls."
269
+ description: "Target run ID for interrupt, stop, dismiss, resume, steer, append-step, approve-checkpoint, reject-checkpoint, or mission.attach-run. Prefer id for new calls."
270
270
  })),
271
271
  dir: Type.Optional(Type.String({
272
272
  description: "Async run directory for action='status', action='stop', action='resume', or action='steer'."
@@ -22,6 +22,7 @@ export const NATIVE_SUPERVISOR_TOOL_NAME = "subagent_supervisor";
22
22
  const MAX_MESSAGE_BYTES = 64 * 1024;
23
23
  const DEFAULT_ASK_TIMEOUT_MS = 10 * 60 * 1000;
24
24
  const CHANNEL_POLL_MS = Math.min(POLL_INTERVAL_MS, 500);
25
+ const CHANNEL_SAFETY_POLL_MS = 5000;
25
26
  const STALE_EMPTY_CHANNEL_AGE_MS = 60 * 1000;
26
27
  const STALE_EMPTY_CHANNEL_CLEANUP_INTERVAL_MS = 60 * 1000;
27
28
 
@@ -69,6 +70,13 @@ interface IntercomParams {
69
70
  replyTo?: string;
70
71
  }
71
72
 
73
+ type SupervisorWatch = (filename: fs.PathLike, listener: fs.WatchListener<string>) => fs.FSWatcher;
74
+
75
+ interface NativeSupervisorChannelDeps {
76
+ platform?: NodeJS.Platform;
77
+ watch?: SupervisorWatch;
78
+ }
79
+
72
80
  const ContactSupervisorParamsSchema = Type.Object({
73
81
  reason: Type.String({ enum: ["need_decision", "interview_request", "progress_update"] }),
74
82
  message: Type.Optional(Type.String()),
@@ -626,10 +634,16 @@ function buildParentIntercomTool(pending: Map<string, PendingSupervisorRequest>,
626
634
  };
627
635
  }
628
636
 
629
- export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentState): { start: () => void; dispose: () => void; pending: Map<string, PendingSupervisorRequest> } {
637
+ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentState, deps: NativeSupervisorChannelDeps = {}): { start: () => void; dispose: () => void; pending: Map<string, PendingSupervisorRequest> } {
638
+ const watch = deps.watch ?? fs.watch;
630
639
  const pending = new Map<string, PendingSupervisorRequest>();
631
640
  const seenFiles = new Set<string>();
641
+ const requestWatchers = new Map<string, fs.FSWatcher>();
642
+ let rootWatcher: fs.FSWatcher | undefined;
632
643
  let poller: ReturnType<typeof setInterval> | undefined;
644
+ let safetyPoller: ReturnType<typeof setInterval> | undefined;
645
+ let deferredWatcherRefresh: ReturnType<typeof setImmediate> | undefined;
646
+ let started = false;
633
647
  let lastStaleCleanupAt = 0;
634
648
 
635
649
  const registerParentTools = (): void => {
@@ -696,17 +710,101 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
696
710
  }
697
711
  };
698
712
 
713
+ const startPolling = (): void => {
714
+ if (poller) return;
715
+ poller = setInterval(poll, CHANNEL_POLL_MS);
716
+ poller.unref?.();
717
+ };
718
+ const startSafetyPolling = (): void => {
719
+ if (safetyPoller) return;
720
+ safetyPoller = setInterval(() => {
721
+ watchExistingRequestDirs();
722
+ poll();
723
+ }, CHANNEL_SAFETY_POLL_MS);
724
+ safetyPoller.unref?.();
725
+ };
726
+ const watchRequestDir = (requestsDir: string): void => {
727
+ if (requestWatchers.has(requestsDir)) return;
728
+ try {
729
+ const watcher = watch(requestsDir, () => poll());
730
+ watcher.on("error", () => {
731
+ try { watcher.close(); } catch {}
732
+ requestWatchers.delete(requestsDir);
733
+ startPolling();
734
+ });
735
+ watcher.unref?.();
736
+ requestWatchers.set(requestsDir, watcher);
737
+ } catch (error) {
738
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") startPolling();
739
+ }
740
+ };
741
+ const watchExistingRequestDirs = (): void => {
742
+ let channelEntries: fs.Dirent[];
743
+ try {
744
+ channelEntries = fs.readdirSync(SUPERVISOR_CHANNEL_ROOT, { withFileTypes: true });
745
+ } catch (error) {
746
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
747
+ startPolling();
748
+ return;
749
+ }
750
+ for (const entry of channelEntries) {
751
+ if (entry.isDirectory()) watchRequestDir(path.join(SUPERVISOR_CHANNEL_ROOT, entry.name, REQUESTS_DIR));
752
+ }
753
+ };
754
+ const scheduleWatcherRefresh = (): void => {
755
+ if (deferredWatcherRefresh) return;
756
+ deferredWatcherRefresh = setImmediate(() => {
757
+ deferredWatcherRefresh = undefined;
758
+ if (!started) return;
759
+ watchExistingRequestDirs();
760
+ poll();
761
+ });
762
+ deferredWatcherRefresh.unref?.();
763
+ };
764
+
699
765
  return {
700
766
  start: () => {
701
- if (poller) return;
767
+ if (started) return;
768
+ started = true;
702
769
  registerParentTools();
703
770
  poll();
704
- poller = setInterval(poll, CHANNEL_POLL_MS);
705
- poller.unref?.();
771
+ try {
772
+ fs.mkdirSync(SUPERVISOR_CHANNEL_ROOT, { recursive: true });
773
+ if ((deps.platform ?? process.platform) === "win32") {
774
+ startPolling();
775
+ return;
776
+ }
777
+ watchExistingRequestDirs();
778
+ rootWatcher = watch(SUPERVISOR_CHANNEL_ROOT, () => {
779
+ watchExistingRequestDirs();
780
+ poll();
781
+ scheduleWatcherRefresh();
782
+ });
783
+ rootWatcher.on("error", startPolling);
784
+ startSafetyPolling();
785
+ scheduleWatcherRefresh();
786
+ } catch {
787
+ startPolling();
788
+ }
706
789
  },
707
790
  dispose: () => {
791
+ started = false;
792
+ try {
793
+ rootWatcher?.close();
794
+ } catch {
795
+ // Best effort during shutdown.
796
+ }
797
+ rootWatcher = undefined;
798
+ for (const watcher of requestWatchers.values()) {
799
+ try { watcher.close(); } catch {}
800
+ }
801
+ requestWatchers.clear();
708
802
  if (poller) clearInterval(poller);
709
803
  poller = undefined;
804
+ if (safetyPoller) clearInterval(safetyPoller);
805
+ safetyPoller = undefined;
806
+ if (deferredWatcherRefresh) clearImmediate(deferredWatcherRefresh);
807
+ deferredWatcherRefresh = undefined;
710
808
  pending.clear();
711
809
  seenFiles.clear();
712
810
  },
@@ -0,0 +1,42 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { AsyncStatus } from "../../shared/types.ts";
4
+
5
+ export const ACTIVE_RUN_INDEX_DIR = ".active-runs";
6
+
7
+ function indexDir(asyncDirRoot: string): string {
8
+ return path.join(asyncDirRoot, ACTIVE_RUN_INDEX_DIR);
9
+ }
10
+
11
+ function markerPath(asyncDir: string): string {
12
+ return path.join(indexDir(path.dirname(asyncDir)), path.basename(asyncDir));
13
+ }
14
+
15
+ export function isActiveAsyncState(state: AsyncStatus["state"]): boolean {
16
+ return state === "queued" || state === "running";
17
+ }
18
+
19
+ export function updateActiveRunIndex(asyncDir: string, state: AsyncStatus["state"]): void {
20
+ const marker = markerPath(asyncDir);
21
+ if (isActiveAsyncState(state)) {
22
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
23
+ fs.writeFileSync(marker, "", { flag: "a" });
24
+ return;
25
+ }
26
+ try {
27
+ fs.rmSync(marker);
28
+ } catch (error) {
29
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
30
+ }
31
+ }
32
+
33
+ export function readActiveRunIndex(asyncDirRoot: string): string[] | undefined {
34
+ try {
35
+ return fs.readdirSync(indexDir(asyncDirRoot), { withFileTypes: true })
36
+ .filter((entry) => entry.isFile())
37
+ .map((entry) => entry.name);
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
40
+ throw error;
41
+ }
42
+ }