# Plan #135 — Reduce subagent resource retention and make extension reload handoff explicit

Parent: #128. Separate design/benchmark PR (p1-enabler, not a release blocker).

## Problem

Every workflow subagent constructs its own `DefaultResourceLoader` and calls
`reload()`, which runs the full package-manager resolve + extension/skill/prompt/
theme/context-file discovery. The discovered set is identical across subagents
sharing the same `(cwd, agentDir)`; only the per-agent filters
(`noContextFiles`, `noSkills`, `systemPrompt`, `appendSystemPrompt`,
`skillsOverride`) differ — and those are pure read-time filters over one
full-discovery base. This is redundant work on every fan-out.

Additionally, there was no defined handoff for when the host extension set
changes (e.g. after a `/n` reload): a stale shared snapshot could be replayed.

## Scope (from issue #135)

1. Profile per-subagent resource-loader/session retention on representative
   fan-out runs (REAL persisted runs + measured benchmarks, not estimates).
2. Design a shared immutable/no-extensions loader ONLY where context-mode and
   per-agent skills semantics remain correct.
3. Preserve focused/legacy/scoped/isolated behavior, project context, skills
   allowlists, and dynamic provider/model visibility.
4. Define extension-reload handoff for manager, result delivery, tracing, task
   panel, and active runs without duplicate subscriptions or lost notifications.
5. Add lifecycle/leak benchmarks and reload integration tests BEFORE adopting
   any upstream structure.

## Design

### Shared immutable discovery cache (`src/agent.ts`)

A module-level `sharedDiscoveryLoaders` map keyed by `${cwd}\0${agentDir}` holds a
single pre-reloaded `DefaultResourceLoader` per project. `ensureSharedDiscoveryLoader`
promise-memoizes the one-time `reload()` so concurrent subagents in a fan-out
share a single discovery pass; rejection clears the cache so a transient
failure stays retryable.

### `ImmutableResourceLoader` facade (`src/agent.ts`)

A per-agent read-only facade over the shared base that applies the context-mode +
skills-allowlist filters at READ time without mutating the shared base:

- `getExtensions()` → shared base (extensions are never filtered by
  context-mode/skills; the tool-authority fence `applyToolPolicy` governs active
  tools, not the loader).
- `getSkills()` → `noSkills ? [] : (skillsFilter ? filtered : base)`.
- `getAgentsFiles()` → `noContextFiles ? [] : base`.
- `getSystemPrompt()` / `getSystemPromptSource()` → `replace` override or base.
- `getAppendSystemPrompt()` / sources → `[]` override (block main rules) or base.
- `extendResources()` → no-op with an observable warning (defense-in-depth; see
  Safety below).
- `reload()` → no-op (the shared base is already loaded; `createAgentSession`
  never reloads an injected loader).

### Shareability contract (`src/context-mode.ts`)

`isShareableResourceLoaderConfig(primitives, skillsAllowlistActive)` documents
and tests that every per-agent flag is a pure read-time filter over one
full-discovery base. It returns `true` for every built-in mode and arbitrary
allowlists today. A future flag that breaks the property fails this check and
the `run()` path falls back to a per-agent loader (no cross-agent mutation).

### Extension-reload handoff

`invalidateSharedDiscoveryLoaders()` clears the cache. The host calls it when
the extension set changes (after `/n`). The next `ensureSharedDiscoveryLoader`
rebuilds ONE active set. This keeps run ownership explicit (the shared base is
owned by the cache; each facade is owned by one session) and avoids duplicate
discovery or stale snapshots. No subscription/delivery changes were needed: the
subagent session only READS the loader; it never subscribes to it, so there is
no duplicate-subscription or lost-notification risk in the workflow path.

## Safety: why sharing is correct in the workflow subagent path

A subagent session built via `createAgentSession` (the workflow path) only
READS the loader during construction (`getExtensions`/`getSkills`/
`getAgentsFiles`/`getSystemPrompt`/`getAppendSystemPrompt`). It never calls
`bindExtensions`, so `extendResourcesFromExtensions` (the only path that mutates
the loader via `resources_discover` extension hooks) never runs. Verified in
the SDK: `bindExtensions` is called only by the *mode* (interactive/print/rpc),
never by `createAgentSession` or the workflow layer. No installed extension
registers a `resources_discover` handler today (Context7, lean-ctx, hindsight,
crew, research-provider-tools, autocompactor, herdr-agent-state — none).

The facade's `extendResources` no-op + warning is defense-in-depth: if a future
extension registers `resources_discover` AND the host binds extensions into a
subagent session, the divergence is observable rather than silent.

## Measured before/after (oracle = wall-clock + heap, not model prose)

`tests/resource-loader-retention-bench.test.ts`, real user extension set, fan-out N=14:

| Metric | Before (per-subagent reload) | After (shared immutable) |
|---|---|---|
| Fan-out reload total | 1876 ms | 12 ms (1 shared reload + 14 facades) |
| Per-subagent mean | 134 ms | 0.019 ms (facade + reads) |
| Per-subagent max | 1696 ms (cold first) | 0.02 ms |
| Discovery reloads run | 14 | 1 |

The first subagent pays the one-time cold discovery (~1.5–1.7 s); subsequent
subagents in the BEFORE path still re-resolve the package manager and re-load
extensions (~11 ms each, helped by the SDK extension cache). The AFTER path runs
discovery exactly once and serves every other subagent from the immutable
snapshot at sub-millisecond cost.

## Tests added (BEFORE adopting the structure, per issue scope item 5)

- `tests/resource-loader-retention-bench.test.ts` — lifecycle/leak benchmark
  (before vs after, real extension set, oracle = `performance.now` +
  `process.memoryUsage`).
- `tests/resource-loader-shared.test.ts` — shareability contract, immutable
  facade read-time filters (noSkills/noContextFiles/replace/appendAllowlist/
  skillsFilter), no cross-agent mutation (sibling facades independent),
  reload no-op, extendResources no-op+warning, reload handoff
  (`invalidateSharedDiscoveryLoaders`), and an end-to-end `WorkflowAgent.run`
  over the shared loader.

All tests declare explicit per-test timeouts (lesson from #133: a hang must
fail, never wait forever).

## Files

- `src/agent.ts` — shared discovery cache, `ImmutableResourceLoader`, wired into
  `run()` (both loader-construction sites), `invalidateSharedDiscoveryLoaders`.
- `src/context-mode.ts` — `isShareableResourceLoaderConfig`.
- `src/index.ts` — exports for the new public symbols.
- `tests/resource-loader-retention-bench.test.ts` (new).
- `tests/resource-loader-shared.test.ts` (new).

## Verification floor (all pass)

- `npm run build` ✅
- `npm test` ✅ (1470 tests, 0 failures)
- `npm run check:workflow-lock` ✅ (0 errors)
- `npm run test:package-smoke` ✅ (`"ok": true`, Pi 0.84.1)

## Out of scope / not done

- Adopting any upstream "no-extensions shared loader" SDK structure. The issue
  requires benchmarks + tests BEFORE adopting upstream structure; this PR adds
  them and the fork-local shared immutable loader. Upstream SDK changes are a
  separate repo.
- Manager/result-delivery/tracing/task-panel subscription changes: the loader is
  not a subscription source in the workflow path, so no duplicate-subscription or
  lost-notification surface exists there. The handoff is `invalidateSharedDiscoveryLoaders()`.