opensec-pi-subagents 0.19.1 → 0.19.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.19.2] - 2026-09-26
11
+
12
+ ### Changed
13
+ - **Cache unchanged subagent discovery and configuration.** Directory listings, parsed agent files, and parsed subagent settings are reused after metadata validation on each load. Changes invalidate the cache without a timer; sessions, extension initialization, tools, skills, model, and thinking inheritance remain unchanged.
14
+
10
15
  ## [0.19.0] - 2026-08-25
11
16
 
12
17
  > **⚠️ Breaking — this release requires pi 0.84.0 or newer** (`peerDependencies` moves from `>=0.81.0`). `SubagentWorkflow` needs two host APIs that do not exist below it, and both fail the typecheck rather than degrading quietly — see the `Changed` entry below for which, and why neither was worth reimplementing to hold the old floor. npm flags an older pi at install time.
package/README.md CHANGED
@@ -269,6 +269,8 @@ Project-level agents override global ones with the same name, so you can customi
269
269
 
270
270
  An unreadable or unparseable agent file is skipped, not fatal — a warning names the file and the error. If it was overriding a same-named agent, a second line names the file that loads instead. Set `strictAgentFiles: true` in `subagents.json` (or `/agents → Settings → Strict agent files`) to fail startup on a broken file instead; mid-session reloads still only warn.
271
271
 
272
+ Discovery reuses unchanged directory listings and parsed agent files within each Pi process. File and directory metadata are checked on every load, so edits, additions, removals, and atomic replacements are picked up on the next discovery without a timer or restart. Parsed `subagents.json` settings use the same bounded cache; their existing application points are unchanged. Each load still builds fresh agent configurations, and each spawn keeps its own session and extension initialization. Model/thinking inheritance, tools, skills, and prompts are unchanged.
273
+
272
274
  ### Example: `.pi/agents/auditor.md`
273
275
 
274
276
  ```markdown
@@ -946,6 +948,7 @@ src/
946
948
  # Agent registry
947
949
  default-agents.ts # Embedded default agent configs (general-purpose, Explore, Plan)
948
950
  custom-agents.ts # Load user-defined agents from .pi/agents/, .agents/agents/, and global agents
951
+ discovery-cache.ts # Bounded metadata-validated caches for discovery and parsed configuration
949
952
  agent-types.ts # Unified agent registry (defaults + user), tool name resolution
950
953
  agent-file-toggle.ts # Locate/edit an agent's .md: enabled: toggle, eject to frontmatter
951
954
  agent-color.ts # Claude Code/Agency Agents name color parsing and badge rendering
@@ -5,6 +5,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
5
5
  import { basename, join } from "node:path";
6
6
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
7
7
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
8
+ import { createDiscoveryCache } from "./discovery-cache.js";
8
9
  /**
9
10
  * The one thing a declared `name:` may not contain, matching Claude Code
10
11
  * exactly: it reserves `:` for plugin-scoped identifiers (`my-plugin:reviewer`)
@@ -19,6 +20,8 @@ import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
19
20
  * and a file must be able to override one.
20
21
  */
21
22
  const RESERVED_IN_TYPE = ":";
23
+ const readAgentDirectory = createDiscoveryCache((path) => readdirSync(path).filter(f => f.endsWith(".md")), 64);
24
+ const readParsedAgent = createDiscoveryCache((path) => parseAgentFrontmatter(readFileSync(path, "utf-8")));
22
25
  /**
23
26
  * Scan for custom agent .md files from multiple locations.
24
27
  * Discovery hierarchy (higher priority wins):
@@ -55,7 +58,7 @@ function loadFromDir(dir, agents, source, strict) {
55
58
  return;
56
59
  let files;
57
60
  try {
58
- files = readdirSync(dir).filter(f => f.endsWith(".md"));
61
+ files = readAgentDirectory(dir);
59
62
  }
60
63
  catch {
61
64
  return;
@@ -156,7 +159,7 @@ export function parseAgentFrontmatter(content) {
156
159
  }
157
160
  function readAgentFile(path, strict) {
158
161
  try {
159
- return parseAgentFrontmatter(readFileSync(path, "utf-8"));
162
+ return readParsedAgent(path);
160
163
  }
161
164
  catch (err) {
162
165
  const reason = err instanceof Error ? err.message : String(err);
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Cache successful file parsing or directory listings, not live session state.
3
+ * Validate on every access (no TTL/watchers); never reuse failures. Values stay
4
+ * private to the caller, which must not expose mutable cached objects.
5
+ */
6
+ export declare function createDiscoveryCache<T>(read: (path: string) => T, capacity?: number): (path: string) => T;
@@ -0,0 +1,42 @@
1
+ import { statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ /** Metadata includes replacement, same-size edits, and permission changes. */
4
+ function fingerprint(path) {
5
+ const s = statSync(path, { bigint: true });
6
+ return `${s.dev}:${s.ino}:${s.mode}:${s.size}:${s.mtimeNs}:${s.ctimeNs}`;
7
+ }
8
+ /**
9
+ * Cache successful file parsing or directory listings, not live session state.
10
+ * Validate on every access (no TTL/watchers); never reuse failures. Values stay
11
+ * private to the caller, which must not expose mutable cached objects.
12
+ */
13
+ export function createDiscoveryCache(read, capacity = 256) {
14
+ const entries = new Map();
15
+ return (path) => {
16
+ const key = resolve(path);
17
+ try {
18
+ const before = fingerprint(key);
19
+ const cached = entries.get(key);
20
+ entries.delete(key);
21
+ if (cached?.fingerprint === before) {
22
+ entries.set(key, cached);
23
+ return cached.value;
24
+ }
25
+ const value = read(key);
26
+ // An edit racing the read must not label old content with new metadata.
27
+ if (before === fingerprint(key)) {
28
+ entries.set(key, { fingerprint: before, value });
29
+ if (entries.size > capacity) {
30
+ const oldest = entries.keys().next().value;
31
+ if (oldest !== undefined)
32
+ entries.delete(oldest);
33
+ }
34
+ }
35
+ return value;
36
+ }
37
+ catch (error) {
38
+ entries.delete(key);
39
+ throw error;
40
+ }
41
+ };
42
+ }
package/dist/settings.js CHANGED
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
7
7
  import { NO_FALLBACK } from "./agent-types.js";
8
+ import { createDiscoveryCache } from "./discovery-cache.js";
8
9
  const VALID_JOIN_MODES = new Set(["async", "group", "smart"]);
9
10
  const VALID_TOOL_DESCRIPTION_MODES = new Set(["full", "compact", "custom"]);
10
11
  const VALID_WIDGET_MODES = new Set(["all", "background", "off"]);
@@ -133,11 +134,12 @@ function projectPath(cwd) {
133
134
  * exists but can't be parsed emits a warning to stderr so users aren't
134
135
  * silently reverted to defaults — and still returns `{}` so startup proceeds.
135
136
  */
137
+ const readParsedSettings = createDiscoveryCache((path) => sanitize(JSON.parse(readFileSync(path, "utf-8"))), 64);
136
138
  function readSettingsFile(path) {
137
139
  if (!existsSync(path))
138
140
  return {};
139
141
  try {
140
- return sanitize(JSON.parse(readFileSync(path, "utf-8")));
142
+ return readParsedSettings(path);
141
143
  }
142
144
  catch (err) {
143
145
  const reason = err instanceof Error ? err.message : String(err);
package/package.json CHANGED
@@ -1,10 +1,18 @@
1
1
  {
2
2
  "name": "opensec-pi-subagents",
3
- "version": "0.19.1",
4
- "description": "Fork of @tintinweb/pi-subagents with parent model and thinking inheritance for Explore agents.",
3
+ "version": "0.19.2",
4
+ "description": "Fork of @tintinweb/pi-subagents with parent model/thinking inheritance and cached agent discovery.",
5
5
  "author": "tintinweb",
6
6
  "contributors": ["kushalkhemka"],
7
7
  "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/opensec-git/opensec-pi-subagents.git"
11
+ },
12
+ "homepage": "https://github.com/opensec-git/opensec-pi-subagents#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/opensec-git/opensec-pi-subagents/issues"
15
+ },
8
16
  "publishConfig": {
9
17
  "access": "public"
10
18
  },
@@ -6,6 +6,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
6
6
  import { basename, join } from "node:path";
7
7
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
9
+ import { createDiscoveryCache } from "./discovery-cache.js";
9
10
  import type { AgentConfig, IsolationMode, MemoryScope, ThinkingLevel } from "./types.js";
10
11
 
11
12
  /**
@@ -23,6 +24,14 @@ import type { AgentConfig, IsolationMode, MemoryScope, ThinkingLevel } from "./t
23
24
  */
24
25
  const RESERVED_IN_TYPE = ":";
25
26
 
27
+ const readAgentDirectory = createDiscoveryCache(
28
+ (path) => readdirSync(path).filter(f => f.endsWith(".md")),
29
+ 64,
30
+ );
31
+ const readParsedAgent = createDiscoveryCache(
32
+ (path) => parseAgentFrontmatter<Record<string, unknown>>(readFileSync(path, "utf-8")),
33
+ );
34
+
26
35
  /**
27
36
  * Scan for custom agent .md files from multiple locations.
28
37
  * Discovery hierarchy (higher priority wins):
@@ -62,7 +71,7 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
62
71
 
63
72
  let files: string[];
64
73
  try {
65
- files = readdirSync(dir).filter(f => f.endsWith(".md"));
74
+ files = readAgentDirectory(dir);
66
75
  } catch {
67
76
  return;
68
77
  }
@@ -172,7 +181,7 @@ export function parseAgentFrontmatter<T extends Record<string, unknown>>(content
172
181
 
173
182
  function readAgentFile(path: string, strict: boolean): { frontmatter: Record<string, unknown>; body: string } | undefined {
174
183
  try {
175
- return parseAgentFrontmatter<Record<string, unknown>>(readFileSync(path, "utf-8"));
184
+ return readParsedAgent(path);
176
185
  } catch (err) {
177
186
  const reason = err instanceof Error ? err.message : String(err);
178
187
  if (strict) throw new Error(`${path}: ${reason}`);
@@ -0,0 +1,42 @@
1
+ import { statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ /** Metadata includes replacement, same-size edits, and permission changes. */
5
+ function fingerprint(path: string): string {
6
+ const s = statSync(path, { bigint: true });
7
+ return `${s.dev}:${s.ino}:${s.mode}:${s.size}:${s.mtimeNs}:${s.ctimeNs}`;
8
+ }
9
+
10
+ /**
11
+ * Cache successful file parsing or directory listings, not live session state.
12
+ * Validate on every access (no TTL/watchers); never reuse failures. Values stay
13
+ * private to the caller, which must not expose mutable cached objects.
14
+ */
15
+ export function createDiscoveryCache<T>(read: (path: string) => T, capacity = 256): (path: string) => T {
16
+ const entries = new Map<string, { fingerprint: string; value: T }>();
17
+ return (path) => {
18
+ const key = resolve(path);
19
+ try {
20
+ const before = fingerprint(key);
21
+ const cached = entries.get(key);
22
+ entries.delete(key);
23
+ if (cached?.fingerprint === before) {
24
+ entries.set(key, cached);
25
+ return cached.value;
26
+ }
27
+ const value = read(key);
28
+ // An edit racing the read must not label old content with new metadata.
29
+ if (before === fingerprint(key)) {
30
+ entries.set(key, { fingerprint: before, value });
31
+ if (entries.size > capacity) {
32
+ const oldest = entries.keys().next().value;
33
+ if (oldest !== undefined) entries.delete(oldest);
34
+ }
35
+ }
36
+ return value;
37
+ } catch (error) {
38
+ entries.delete(key);
39
+ throw error;
40
+ }
41
+ };
42
+ }
package/src/settings.ts CHANGED
@@ -6,6 +6,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
8
8
  import { NO_FALLBACK } from "./agent-types.js";
9
+ import { createDiscoveryCache } from "./discovery-cache.js";
9
10
  import type { AgentMentionMode, JoinMode, ViewerMarkdownMode, WidgetMode } from "./types.js";
10
11
 
11
12
  export interface SubagentsSettings {
@@ -477,10 +478,15 @@ function projectPath(cwd: string): string {
477
478
  * exists but can't be parsed emits a warning to stderr so users aren't
478
479
  * silently reverted to defaults — and still returns `{}` so startup proceeds.
479
480
  */
481
+ const readParsedSettings = createDiscoveryCache(
482
+ (path) => sanitize(JSON.parse(readFileSync(path, "utf-8"))),
483
+ 64,
484
+ );
485
+
480
486
  function readSettingsFile(path: string): SubagentsSettings {
481
487
  if (!existsSync(path)) return {};
482
488
  try {
483
- return sanitize(JSON.parse(readFileSync(path, "utf-8")));
489
+ return readParsedSettings(path);
484
490
  } catch (err) {
485
491
  const reason = err instanceof Error ? err.message : String(err);
486
492
  console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);