pi-fovea 0.22.0 → 0.22.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/README.md CHANGED
@@ -177,7 +177,7 @@ limits accept environment overrides:
177
177
  | --- | :---: | --- |
178
178
  | `FOVEA_MAX_FILES` | `8000` | maximum indexed files in one graph |
179
179
  | `FOVEA_MAX_FILE_BYTES` | `1048576` | maximum bytes extracted from one source file |
180
- | `FOVEA_MAX_ROOTS` | `2` | resident graph, fact, session, sync, and root-metadata caches |
180
+ | `FOVEA_MAX_ROOTS` | `2` | observed execution roots and resident graph, fact, session, sync, and root-metadata caches |
181
181
  | `FOVEA_SPAWN_CONCURRENCY` | `3` | concurrent ast-grep/git child processes (ast-grep parallelizes parsing inside each process; values above ~4 rarely help) |
182
182
  | `FOVEA_MEMORY_HALF_LIFE_HOURS` | `48` | wall-clock half-life of the per-node sync memory (charged cascade warmth) |
183
183
  | `FOVEA_IO_CONCURRENCY` | `32` | concurrent file stat/read operations |
@@ -187,6 +187,62 @@ Files over the size cap keep their place in the model's view of the repo.
187
187
  Failed extractions do the same. You find both in `/fovea status` and in tool
188
188
  details.
189
189
 
190
+ ## Explicit project/worktree continuity (Rakazo)
191
+
192
+ The extension's existing four graph tools are the headless binding API; no new
193
+ host event or trust flag is required:
194
+
195
+ ```ts
196
+ await extensions.fovea_focus({ root: "../project-b", query: "src/handler.ts", maxTokens: 1024 });
197
+ // Baseline is ready before this resolves (unless target sync is disabled).
198
+ // Native tools still use the host cwd: use absolute paths or cwd-relative paths.
199
+ await pi.edit({ path: "../project-b/src/handler.ts", old: "before", new: "after" });
200
+ await extensions.fovea_dwell({ maxTokens: 512 }); // last bound root
201
+ ```
202
+
203
+ - `root` resolves against **the tool context's cwd**, not process cwd or the last
204
+ binding. Real paths unify symlink aliases; linked Git worktrees remain distinct
205
+ even when they share HEAD and a common Git directory. Roots are exact directory
206
+ scopes, not automatically promoted to Git toplevels.
207
+ - Cwd is the startup/fallback observation target. After a graph call binds a root,
208
+ hooks inspect only the bound set. Bind each project you want observed; an
209
+ alternate binding does not keep an otherwise-unselected umbrella cwd active.
210
+ Calls without `root` use the last binding. Parallel coordinators should always
211
+ supply `root`; enrollment is serialized in invocation order. Results include
212
+ canonical `details.root` and sorted `details.observedRoots`.
213
+ - A first binding establishes that target's semantic baseline before edits can
214
+ follow. Subsequent focus calls do not consume pending drift. Before-prompt and
215
+ post-turn sync compare every bound target, including hintless shell/Fabric/editor
216
+ changes. Attention remains target-local: focus a file/symbol or use
217
+ `fovea_impact({ root, files: ["src/file.ts"], includeUncommitted: false })` before
218
+ a headless mutation to enter its scope. Ordinary path events never enroll a new
219
+ root; they route to the most specific enrolled physical owner.
220
+ - Native read/edit/write/search paths **do not change cwd**. Augment-mode grep
221
+ attaches only the graph of the enrolled owner of its actual cwd-relative or
222
+ absolute search path. No-path native grep still searches cwd; it never appends
223
+ an unrelated alternate graph. In legacy replace mode, bare graph queries and
224
+ their miss fallback use the bound root; explicit native options retain cwd
225
+ semantics. Each target's grep mode is checked independently.
226
+ - Binding is an explicit request to index a directory, **not a sandbox or a trust
227
+ grant**. Rakazo must authorize tool arguments itself. `.pi/fovea.json` is loaded
228
+ only when that exact canonical target equals `ctx.cwd` and the context is
229
+ trusted. Parent/sibling trust never authorizes it; alternate targets use global
230
+ defaults. To honor a target's project config, run it in its own trusted Pi
231
+ context. Config cache keys include trust and agent directory. Existing
232
+ target-local declarative `.fovea/rules.json` extraction rules are unchanged.
233
+ - All roots share one per-hook `sync.budget` allowance from the session cwd's
234
+ effective config, divided evenly and capped again by each target's budget;
235
+ root labels count toward that allowance. Hidden targets remain hidden (a mixed
236
+ pre-prompt aggregate is hidden). Graph calls keep their individual `maxTokens`.
237
+ Enrollment is capped by `FOVEA_MAX_ROOTS` (default 2); excess roots fail before
238
+ indexing rather than silently losing a baseline. Set this before startup for
239
+ larger coordinated runs.
240
+ - `/fovea status` reports the bound root and observed count. `/fovea reset`,
241
+ shutdown, new/resume/fork/reload clear bindings and conversation baselines;
242
+ reusable content facts remain cached. Rebind after session replacement.
243
+ The standalone CLI remains stateless; these continuity semantics belong to
244
+ the Pi extension lifecycle.
245
+
190
246
  ## Turn sync
191
247
 
192
248
  Continuous sync is enabled and visible by default. Before an agent starts,
@@ -496,9 +552,28 @@ Exact contract topology: **Protocol Buffers (`.proto`) and GraphQL (`.graphql`,
496
552
  ```sh
497
553
  bun install
498
554
  bun run check # typecheck + full vitest suite
499
- bun run bench # rate–distortion bench against ../pi-fabric
555
+ bun run bench # rate–distortion and refresh bench against ../pi-fabric
556
+ bun run bench tests/fixtures/mini # self-contained smoke run
500
557
  ```
501
558
 
559
+ The developer benchmark gates timings on semantic equivalence: cold versus
560
+ cached builds, and forced refresh versus clean rebuild after unchanged,
561
+ location-only, semantic, added-file, and deleted-file scenarios in disposable
562
+ copies of the cross-language fixture. It checks facts, weighted edges and
563
+ evidence, coverage, operator contents, and fixture navigation; extraction-order
564
+ permutations and opaque rebuild hashes are not semantic differences. Runtime
565
+ ordering and generation invalidation are unchanged.
566
+
567
+ Reports include focus/dwell and refresh median/p95 samples, actual estimated
568
+ output tokens, and process peak RSS (including the validation work). Cold and
569
+ disk-warm target builds are single samples; three-sample refresh p95s are only
570
+ smoke diagnostics. The outline gets no more tokens than Fovea actually used.
571
+ `fidelity@16k` measures disclosed node IDs against a finite larger Fovea response,
572
+ **not independently labeled relevance**. Timing results are informational, never
573
+ a flaky CI gate; deterministic equivalence tests run in `bun run check`.
574
+ The bench clears the target's disposable facts cache to measure cold loading,
575
+ but edits only temporary fixture copies.
576
+
502
577
  pi loads the extension straight from `src/` through jiti, so nothing needs
503
578
  building. Per-repo JSONL caches live in `$TMPDIR`, guarded by per-file content
504
579
  sha1 values and stat manifests. Cache I/O streams. Only dirty files re-run
package/dist/cli.mjs CHANGED
@@ -403,10 +403,13 @@ var buildCsr = (g) => {
403
403
  }
404
404
  return { n, rowPtr, col, w, deg };
405
405
  };
406
- var applyNegP = (csr, x) => {
407
- const { n, rowPtr, col, w, deg } = csr;
408
- const invSqrt = new Float64Array(n);
409
- for (let i = 0; i < n; i++) invSqrt[i] = deg[i] > 0 ? 1 / Math.sqrt(deg[i]) : 0;
406
+ var inverseDegrees = ({ n, deg }) => {
407
+ const out = new Float64Array(n);
408
+ for (let i = 0; i < n; i++) out[i] = deg[i] > 0 ? 1 / Math.sqrt(deg[i]) : 0;
409
+ return out;
410
+ };
411
+ var applyNegP = (csr, x, invSqrt = inverseDegrees(csr)) => {
412
+ const { n, rowPtr, col, w } = csr;
410
413
  const y = new Float64Array(n);
411
414
  for (let i = 0; i < n; i++) {
412
415
  let acc = 0;
@@ -464,13 +467,14 @@ var heatCoeff = (k, t) => {
464
467
  var chebyshevVectors = (csr, s, K) => {
465
468
  const tk = new Array(K + 1);
466
469
  tk[0] = Float64Array.from(s);
467
- if (K >= 1) tk[1] = applyNegP(csr, tk[0]);
470
+ if (K === 0) return tk;
471
+ const invSqrt = inverseDegrees(csr);
472
+ if (K >= 1) tk[1] = applyNegP(csr, tk[0], invSqrt);
468
473
  for (let k = 2; k <= K; k++) {
469
474
  const prev = tk[k - 1];
470
- const mv = applyNegP(csr, prev);
471
- const out = new Float64Array(csr.n);
475
+ const out = applyNegP(csr, prev, invSqrt);
472
476
  const p2 = tk[k - 2];
473
- for (let i = 0; i < csr.n; i++) out[i] = 2 * mv[i] - p2[i];
477
+ for (let i = 0; i < csr.n; i++) out[i] = 2 * out[i] - p2[i];
474
478
  tk[k] = out;
475
479
  }
476
480
  return tk;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fovea",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
4
4
  "description": "Token-budgeted repo mapping for agent sessions: foveated heat diffusion over a cross-language code graph, with progressive disclosure.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,6 +23,24 @@ All four accept `maxTokens` (256–16000). Budget is roughly 4 chars per token.
23
23
  - **Sketch is the safe opening bid.** If unsure, pay for a sketch; it almost never exceeds a few hundred tokens.
24
24
  - **Skip the map when the repo is tiny.** A few dozen files are cheaper to bulk-read than to sketch; Fovea's value grows with repos larger than context. Either way it points at windows to read — the project's own format/lint/typecheck/test commands and CI remain the final verification layer.
25
25
 
26
+ ## Multiple projects/worktrees
27
+
28
+ Use an explicit graph-tool `root` before editing an alternate authorized project.
29
+ Roots resolve relative to `ctx.cwd`; symlink aliases share identity, linked Git
30
+ worktrees do not. The first call establishes its sync baseline before returning.
31
+ Graph results expose `details.root` and `details.observedRoots`; omitted roots use
32
+ the last binding. Bind every target to observe; after binding, hooks stop using
33
+ an unselected umbrella cwd. Parallel calls should specify roots explicitly.
34
+
35
+ Native paths remain cwd-relative: use absolute paths or `../project/file` for
36
+ edits and grep. Augment grep follows its actual search path's enrolled owner,
37
+ not the active graph binding. Focus or file-seeded impact establishes attention
38
+ for headless shell edits; path events alone never enroll siblings. Alternate
39
+ roots do not inherit cwd project-config trust. Shared sync context and observed
40
+ roots are bounded (`FOVEA_MAX_ROOTS`, default 2; excess enrollment errors).
41
+ Reset/reload/session replacement clears bindings, not reusable extraction facts.
42
+ See README's “Explicit project/worktree continuity (Rakazo)” for the full contract.
43
+
26
44
  ## Turn sync
27
45
 
28
46
  Before an agent starts, pi-fovea establishes its baseline or injects relevant out-of-band semantic drift into that run. After each assistant turn it compares again. The default `sync.scope: "session"` indexes the whole root but steers only for top-level directories/root files this conversation entered through path-bearing tools or focus. Sibling-directory drift advances the index and baseline silently. Current, mixed, and unattributed changes in scope may trigger a continuation; changes owned solely by another Fovea session wait for the next user prompt and cannot restart an idle agent. Comment- and formatting-only edits stay silent. Set `sync.scope: "repository"` only when root-wide steering is intentional.
package/src/core/heat.ts CHANGED
@@ -64,11 +64,15 @@ export const buildCsr = (g: Graph): Csr => {
64
64
  return { n, rowPtr, col, w, deg };
65
65
  };
66
66
 
67
- // y = -P~ x where P~ = D^{-1/2} W D^{-1/2}. Isolated nodes (deg 0) map to themselves under heat.
68
- const applyNegP = (csr: Csr, x: Float64Array): Float64Array => {
69
- const { n, rowPtr, col, w, deg } = csr;
70
- const invSqrt = new Float64Array(n);
71
- for (let i = 0; i < n; i++) invSqrt[i] = deg[i]! > 0 ? 1 / Math.sqrt(deg[i]!) : 0;
67
+ const inverseDegrees = ({ n, deg }: Csr): Float64Array => {
68
+ const out = new Float64Array(n);
69
+ for (let i = 0; i < n; i++) out[i] = deg[i]! > 0 ? 1 / Math.sqrt(deg[i]!) : 0;
70
+ return out;
71
+ };
72
+
73
+ // y = -P~ x where P~ = D^{-1/2} W D^{-1/2}.
74
+ const applyNegP = (csr: Csr, x: Float64Array, invSqrt = inverseDegrees(csr)): Float64Array => {
75
+ const { n, rowPtr, col, w } = csr;
72
76
  const y = new Float64Array(n);
73
77
  for (let i = 0; i < n; i++) {
74
78
  let acc = 0;
@@ -130,13 +134,16 @@ const heatCoeff = (k: number, t: number): number => {
130
134
  export const chebyshevVectors = (csr: Csr, s: Float64Array, K: number): Float64Array[] => {
131
135
  const tk: Float64Array[] = new Array(K + 1);
132
136
  tk[0] = Float64Array.from(s);
133
- if (K >= 1) tk[1] = applyNegP(csr, tk[0]!);
137
+ // Invocation-local normalization preserves compatibility with mutable/synthetic CSR
138
+ // callers without adding a persistent cache or a new invalidation boundary.
139
+ if (K === 0) return tk;
140
+ const invSqrt = inverseDegrees(csr);
141
+ if (K >= 1) tk[1] = applyNegP(csr, tk[0]!, invSqrt);
134
142
  for (let k = 2; k <= K; k++) {
135
143
  const prev = tk[k - 1]!;
136
- const mv = applyNegP(csr, prev);
137
- const out = new Float64Array(csr.n);
144
+ const out = applyNegP(csr, prev, invSqrt);
138
145
  const p2 = tk[k - 2]!;
139
- for (let i = 0; i < csr.n; i++) out[i] = 2 * mv[i]! - p2[i]!;
146
+ for (let i = 0; i < csr.n; i++) out[i] = 2 * out[i]! - p2[i]!;
140
147
  tk[k] = out;
141
148
  }
142
149
  return tk;
@@ -0,0 +1,47 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { dirname, basename, join, relative, resolve, sep, isAbsolute } from "node:path";
3
+ import { ROOT_CACHE_LIMIT } from "./asyncutil.js";
4
+
5
+ // Resolve aliases without collapsing linked worktrees to their common git dir.
6
+ export const canonicalPath = (cwd: string, input = "."): string => {
7
+ const absolute = resolve(cwd, input);
8
+ try { return realpathSync(absolute); } catch {
9
+ const parent = dirname(absolute);
10
+ return parent === absolute ? absolute : join(canonicalPath(parent), basename(absolute));
11
+ }
12
+ };
13
+
14
+ const contains = (root: string, path: string): boolean => {
15
+ const rel = relative(root, path);
16
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
17
+ };
18
+
19
+ /** Explicit session enrollment only; never discovers parents or siblings. */
20
+ export class ExecutionRoots {
21
+ private observed = new Set<string>();
22
+ private active?: string;
23
+
24
+ clear(): void { this.observed.clear(); this.active = undefined; }
25
+ list(cwd: string): string[] {
26
+ return this.observed.size ? [...this.observed].sort() : [canonicalPath(cwd)];
27
+ }
28
+ target(cwd: string, root?: string): string {
29
+ return root === undefined ? this.active ?? canonicalPath(cwd) : canonicalPath(cwd, root);
30
+ }
31
+ check(root: string): void {
32
+ if (!statSync(root).isDirectory()) throw new Error(`Fovea root is not a directory: ${root}`);
33
+ if (!this.observed.has(root) && this.observed.size >= ROOT_CACHE_LIMIT) {
34
+ throw new Error(`Fovea observed-root limit (${ROOT_CACHE_LIMIT}) reached; use /fovea reset or set FOVEA_MAX_ROOTS before startup.`);
35
+ }
36
+ }
37
+ bind(root: string): void {
38
+ this.check(root);
39
+ this.observed.add(root);
40
+ this.active = root;
41
+ }
42
+ owner(cwd: string, path: string): { root: string; path: string } | undefined {
43
+ const absolute = canonicalPath(cwd, path);
44
+ const root = this.list(cwd).filter((r) => contains(r, absolute)).sort((a, b) => b.length - a.length || a.localeCompare(b))[0];
45
+ return root ? { root, path: relative(root, absolute).split(sep).join("/") } : undefined;
46
+ }
47
+ }
package/src/index.ts CHANGED
@@ -4,8 +4,10 @@
4
4
  // incremental across sessions.
5
5
 
6
6
  import { readFileSync } from "node:fs";
7
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import { Type } from "typebox";
9
+ import { canonicalPath, ExecutionRoots } from "./core/roots.js";
10
+ import { ensureState } from "./core/state.js";
9
11
  import { resolveAgentDir } from "./core/agent-dir.js";
10
12
  import { loadFoveaConfig, type FoveaConfig } from "./core/config.js";
11
13
  import { hasAstGrep } from "./core/astgrep.js";
@@ -13,7 +15,7 @@ import { ROOT_CACHE_LIMIT } from "./core/asyncutil.js";
13
15
  import { coverageSummary, dwell, ensureStateBackground, focus, impact, sketch } from "./core/ops.js";
14
16
  import { observeSessionPaths, resetSessions } from "./core/session.js";
15
17
  import { captureMutation, finishMutation, type MutationCapture } from "./core/provenance.js";
16
- import { resetSyncBaselines, sync, warmSync } from "./core/sync.js";
18
+ import { resetSyncBaselines, sync, syncBaselineStore, warmSync } from "./core/sync.js";
17
19
  import type { NodeKind } from "./core/types.js";
18
20
 
19
21
  const PACKAGE_VERSION = (() => {
@@ -29,7 +31,7 @@ const BudgetParam = Type.Optional(
29
31
  Type.Number({ description: "Max tokens for the response (256..16000). Estimate: 4 chars/token.", minimum: 256, maximum: 16000 }),
30
32
  );
31
33
  const RootParam = Type.Optional(
32
- Type.String({ description: "Repo root to map. Defaults to the session working directory." }),
34
+ Type.String({ description: "Explicit root to observe and bind. Relative to session cwd; omitted uses the last bound root. Does not change native tool cwd or grant project trust." }),
33
35
  );
34
36
  const GrepParams = Type.Object({
35
37
  pattern: Type.String({ description: "Graph query for a bare identifier/path; exact text or regex pattern when search options are present." }),
@@ -80,21 +82,46 @@ const focusKind = (value: string | undefined): NodeKind | undefined =>
80
82
  value && NODE_KINDS.has(value as NodeKind) ? value as NodeKind : undefined;
81
83
 
82
84
  export default function fovea(pi: ExtensionAPI) {
85
+ const roots = new ExecutionRoots();
86
+ const targetConfig = (root: string, ctx: ExtensionContext): FoveaConfig =>
87
+ configFor(root, canonicalPath(ctx.cwd) === root && ctx.isProjectTrusted());
83
88
  // Per-root config cache; invalidated by settings saves (/fovea settings).
84
89
  const configs = new Map<string, FoveaConfig>();
85
90
  const configFor = (root: string, trusted = false, agentDir?: string): FoveaConfig => {
86
- const hit = configs.get(root);
91
+ const key = `${root}\0${trusted}\0${agentDir ?? resolveAgentDir()}`;
92
+ const hit = configs.get(key);
87
93
  if (hit) {
88
- configs.delete(root);
89
- configs.set(root, hit);
94
+ configs.delete(key);
95
+ configs.set(key, hit);
90
96
  return hit;
91
97
  }
92
98
  const cfg = loadFoveaConfig({ cwd: root, agentDir: agentDir ?? resolveAgentDir(), projectTrusted: trusted });
93
- configs.set(root, cfg);
99
+ configs.set(key, cfg);
94
100
  while (configs.size > ROOT_CACHE_LIMIT) configs.delete(configs.keys().next().value!);
95
101
  return cfg;
96
102
  };
97
103
 
104
+ // Serialize enrollment in invocation order, including parallel graph calls.
105
+ let bindingTail: Promise<unknown> = Promise.resolve();
106
+ const bindRoot = (root: string, ctx: ExtensionContext): Promise<void> => {
107
+ const epoch = lifecycleEpoch;
108
+ const pending = bindingTail.catch(() => {}).then(async () => {
109
+ if (epoch !== lifecycleEpoch) throw new Error("Fovea session changed during root binding");
110
+ roots.check(root);
111
+ const cfg = targetConfig(root, ctx);
112
+ if (syncRuns(cfg) && !syncBaselineStore().has(root)) {
113
+ const state = await ensureState(root);
114
+ if (epoch !== lifecycleEpoch) throw new Error("Fovea session changed during root binding");
115
+ await sync(root, { files: [], budget: cfg.sync.budget, steerThreshold: cfg.sync.steerThreshold, scope: cfg.sync.scope,
116
+ sessionId: ctx.sessionManager.getSessionId() }, state);
117
+ }
118
+ if (epoch !== lifecycleEpoch) throw new Error("Fovea session changed during root binding");
119
+ roots.bind(root);
120
+ });
121
+ bindingTail = pending;
122
+ return pending;
123
+ };
124
+
98
125
  // A missing ast-grep throws the full install guidance on the first
99
126
  // failure; subsequent calls answer with a short "proceed natively" result
100
127
  // instead of burning turns on identical hard errors. Self-healing: once
@@ -125,14 +152,16 @@ export default function fovea(pi: ExtensionAPI) {
125
152
  // a broken or seedless graph simply yields native grep unchanged.
126
153
  pi.on("tool_result", async (event, ctx) => {
127
154
  if (event.toolName !== "grep" || event.isError) return undefined;
128
- const cfg = configFor(ctx.cwd, ctx.isProjectTrusted());
129
- if (cfg.tools.grepMode !== "augment") return undefined;
130
155
  const input = (event.input ?? {}) as { pattern?: unknown; path?: unknown };
156
+ const owner = roots.owner(ctx.cwd, typeof input.path === "string" ? input.path : ".");
157
+ if (!owner) return undefined;
158
+ const cfg = targetConfig(owner.root, ctx);
159
+ if (cfg.tools.grepMode !== "augment") return undefined;
131
160
  const pattern = typeof input.pattern === "string" ? input.pattern.trim() : "";
132
161
  if (!pattern || !isSymbolLikeGrepQuery(pattern)) return undefined;
133
162
  try {
134
- const result = await focus(ctx.cwd, pattern, cfg.tools.grepAugmentBudget, {
135
- path: typeof input.path === "string" ? input.path : undefined,
163
+ const result = await focus(owner.root, pattern, cfg.tools.grepAugmentBudget, {
164
+ path: owner.path || undefined,
136
165
  fresh: true,
137
166
  });
138
167
  if (Number(result.details.seeds ?? 0) === 0) return undefined;
@@ -153,7 +182,7 @@ export default function fovea(pi: ExtensionAPI) {
153
182
  ...event.content,
154
183
  text(gap + result.text.replace(/^fovea focus/, "fovea graph")),
155
184
  ],
156
- details: { ...details, backend: "hybrid", foveaAppended: true, query: pattern },
185
+ details: { ...details, backend: "hybrid", foveaAppended: true, query: pattern, root: owner.root },
157
186
  };
158
187
  } catch {
159
188
  return undefined;
@@ -177,12 +206,12 @@ export default function fovea(pi: ExtensionAPI) {
177
206
  ],
178
207
  parameters: GrepParams,
179
208
  async execute(id, params, signal, onUpdate, ctx) {
180
- const root = ctx.cwd;
181
- if (requestsNativeGrep(params)) {
182
- const native = await loadNativeGrepTool(root);
209
+ const root = roots.target(ctx.cwd);
210
+ if (requestsNativeGrep(params) || targetConfig(root, ctx).tools.grepMode !== "replace") {
211
+ const native = await loadNativeGrepTool(ctx.cwd);
183
212
  return native.execute(id, params, signal, onUpdate);
184
213
  }
185
- const budget = configFor(root, ctx.isProjectTrusted()).tools.defaultBudget;
214
+ const budget = targetConfig(root, ctx).tools.defaultBudget;
186
215
  const query = params.pattern.trim() || params.pattern;
187
216
  try {
188
217
  const result = await focus(root, query, budget, { fresh: true });
@@ -192,7 +221,7 @@ export default function fovea(pi: ExtensionAPI) {
192
221
  }
193
222
  return {
194
223
  content: [text(result.text.replace(/^fovea focus/, "fovea grep"))],
195
- details: { ...result.details, backend: "fovea", query },
224
+ details: { ...result.details, backend: "fovea", query, root },
196
225
  };
197
226
  } catch (error) {
198
227
  // A broken graph backend must not break text search: degrade to
@@ -216,6 +245,7 @@ export default function fovea(pi: ExtensionAPI) {
216
245
  // pi 0.84 replaces extension runtimes on resume/fork/new/reload. Core
217
246
  // module caches may outlive one factory instance, but disclosure and sync
218
247
  // baselines are session-local and must never cross that boundary.
248
+ roots.clear();
219
249
  resetSessions();
220
250
  resetSyncBaselines();
221
251
  if (configFor(ctx.cwd, ctx.isProjectTrusted()).tools.grepMode === "replace") registerGrepOverride();
@@ -223,7 +253,8 @@ export default function fovea(pi: ExtensionAPI) {
223
253
  // wait on hashing/ast-grep. Slow cold builds surface a ready notice so
224
254
  // the freeze feels like progress instead of a hang.
225
255
  try {
226
- const kick = ensureStateBackground(ctx.cwd);
256
+ const startupRoot = canonicalPath(ctx.cwd);
257
+ const kick = ensureStateBackground(startupRoot);
227
258
  const t0 = Date.now();
228
259
  // Always attach a rejection handler; headless sessions must not leak an
229
260
  // unhandled rejection when ast-grep is unavailable.
@@ -237,9 +268,10 @@ export default function fovea(pi: ExtensionAPI) {
237
268
  // Pre-establish the sync baseline in the background so the very
238
269
  // first prompt fast-paths instead of paying the snapshot on the
239
270
  // send path. A /new, /fork, or reload bumps the epoch and clears it.
240
- const pre = configFor(ctx.cwd, ctx.isProjectTrusted());
271
+ const pre = targetConfig(startupRoot, ctx);
272
+ if (!roots.list(ctx.cwd).includes(startupRoot) || !syncRuns(pre) || syncBaselineStore().has(startupRoot)) return;
241
273
  void sync(
242
- ctx.cwd,
274
+ startupRoot,
243
275
  { files: [], budget: pre.sync.budget, steerThreshold: pre.sync.steerThreshold, pushFocus: pre.sync.pushFocus, scope: pre.sync.scope, sessionId },
244
276
  st,
245
277
  { probe: "full" },
@@ -290,6 +322,7 @@ export default function fovea(pi: ExtensionAPI) {
290
322
  lifecycleEpoch++;
291
323
  turnFiles = [];
292
324
  lastSyncError = undefined;
325
+ roots.clear();
293
326
  resetSessions();
294
327
  resetSyncBaselines();
295
328
  });
@@ -299,12 +332,15 @@ export default function fovea(pi: ExtensionAPI) {
299
332
  pi.on("tool_execution_start", async (event, ctx) => {
300
333
  const args = event.args as { path?: unknown };
301
334
  if (ATTENTION_PATH_TOOLS.has(event.toolName) && typeof args.path === "string") {
302
- observeSessionPaths(ctx.cwd, [args.path]);
335
+ const owner = roots.owner(ctx.cwd, args.path);
336
+ if (owner) observeSessionPaths(owner.root, [owner.path]);
303
337
  }
304
338
  if (event.toolName !== "edit" && event.toolName !== "write") return;
305
339
  if (typeof args.path !== "string") return;
306
- turnFiles.push(args.path);
307
- const capture = await captureMutation(ctx.cwd, args.path);
340
+ const owner = roots.owner(ctx.cwd, args.path);
341
+ if (!owner) return;
342
+ turnFiles.push(canonicalPath(owner.root, owner.path));
343
+ const capture = await captureMutation(owner.root, owner.path);
308
344
  if (capture) pendingMutations.set(event.toolCallId, capture);
309
345
  });
310
346
  // Warm once the file is actually on disk (tool_execution_start fires during
@@ -316,79 +352,63 @@ export default function fovea(pi: ExtensionAPI) {
316
352
  if (!event.isError && capture) {
317
353
  await finishMutation(capture, ctx.sessionManager.getSessionId(), event.toolCallId).catch(() => false);
318
354
  }
319
- warmAfterEdit(ctx.cwd, configFor(ctx.cwd, ctx.isProjectTrusted()));
355
+ if (capture) warmAfterEdit(capture.root, targetConfig(capture.root, ctx));
320
356
  });
321
- pi.on("before_agent_start", async (_event, ctx) => {
322
- try {
323
- const cfg = configFor(ctx.cwd, ctx.isProjectTrusted());
324
- if (!syncRuns(cfg)) return;
325
- const outcome = await sync(
326
- ctx.cwd,
327
- { files: [], budget: cfg.sync.budget, steerThreshold: cfg.sync.steerThreshold, pushFocus: cfg.sync.pushFocus, scope: cfg.sync.scope, sessionId: ctx.sessionManager.getSessionId() },
328
- undefined,
329
- // Respond to the Enter key, never block on it: the TTL-bounded probe
330
- // detects out-of-band drift, a prepared warm verdict steers pre-prompt,
331
- // and everything else defers to turn_end's full sync.
332
- { probe: "defer" },
333
- );
334
- lastSyncError = undefined;
335
- if (outcome.red && outcome.text) {
336
- return {
337
- message: {
338
- customType: "pi-fovea-sync",
339
- content: outcome.text,
340
- display: syncDisplays(cfg),
341
- details: outcome.details,
342
- },
343
- };
344
- }
345
- if (outcome.structural && !outcome.details.baseline && !outcome.details.deferred && !outcome.details.outsideAttention && cfg.sync.ackClean && ctx.hasUI) {
346
- ctx.ui.notify("fovea: checked repository changes; no new action is needed.", "info");
357
+ // One session-level context allowance, shared across only explicit roots.
358
+ const pollRoots = async (ctx: ExtensionContext, probe: "cheap" | "defer", files: string[]) => {
359
+ const targets = roots.list(ctx.cwd);
360
+ const allowance = configFor(canonicalPath(ctx.cwd), ctx.isProjectTrusted()).sync.budget;
361
+ const share = Math.floor(allowance / targets.length);
362
+ const notices: Array<{ content: string; display: boolean; details: Record<string, unknown>; nextPrompt: boolean }> = [];
363
+ for (const root of targets) {
364
+ try {
365
+ const cfg = targetConfig(root, ctx);
366
+ if (!syncRuns(cfg)) continue;
367
+ const budget = Math.min(cfg.sync.budget, share);
368
+ const rels = files.flatMap((file) => {
369
+ const owner = roots.owner(ctx.cwd, file);
370
+ return owner?.root === root ? [owner.path] : [];
371
+ });
372
+ const outcome = await sync(root, {
373
+ files: rels, budget, steerThreshold: cfg.sync.steerThreshold,
374
+ pushFocus: cfg.sync.pushFocus, scope: cfg.sync.scope,
375
+ sessionId: ctx.sessionManager.getSessionId(),
376
+ }, undefined, { probe });
377
+ lastSyncError = undefined;
378
+ if (outcome.red && outcome.text) {
379
+ const content = (targets.length > 1 ? `Root: ${root}\n` : "") + outcome.text;
380
+ notices.push({ content: content.slice(0, budget * 4 - 1) + "\n", display: syncDisplays(cfg),
381
+ details: { ...outcome.details, root }, nextPrompt: outcome.delivery === "next-prompt" });
382
+ } else if (outcome.structural && !outcome.details.baseline && !outcome.details.deferred &&
383
+ !outcome.details.outsideAttention && cfg.sync.ackClean && ctx.hasUI) {
384
+ ctx.ui.notify(`fovea: checked ${root}; no new action is needed.`, "info");
385
+ }
386
+ } catch (error) {
387
+ const message = `${root}: ${error instanceof Error ? error.message : error}`;
388
+ if (ctx.hasUI && message !== lastSyncError) ctx.ui.notify(`fovea: sync paused: ${message}`, "warning");
389
+ lastSyncError = message;
347
390
  }
348
- } catch (error) {
349
- const message = error instanceof Error ? error.message : String(error);
350
- if (ctx.hasUI && message !== lastSyncError) ctx.ui.notify(`fovea: sync paused: ${message}`, "warning");
351
- lastSyncError = message;
352
391
  }
392
+ return notices;
393
+ };
394
+ pi.on("before_agent_start", async (_event, ctx) => {
395
+ const notices = await pollRoots(ctx, "defer", []);
396
+ if (!notices.length) return;
397
+ return { message: {
398
+ customType: "pi-fovea-sync",
399
+ content: notices.map((n) => n.content).join(""),
400
+ // A combined pre-prompt notice must not expose a hidden target.
401
+ display: notices.every((n) => n.display),
402
+ details: notices.length === 1 ? notices[0]!.details : { roots: notices.map((n) => n.details) },
403
+ } };
353
404
  });
354
-
355
405
  pi.on("turn_end", async (_event, ctx) => {
356
- try {
357
- const cfg = configFor(ctx.cwd, ctx.isProjectTrusted());
358
- const rels = turnFiles
359
- .map((p) => (p.startsWith(ctx.cwd + "/") ? p.slice(ctx.cwd.length + 1) : p))
360
- .filter((p) => !p.startsWith("/"));
361
- turnFiles = [];
362
- if (!syncRuns(cfg)) return;
363
- const outcome = await sync(
364
- ctx.cwd,
365
- { files: rels, budget: cfg.sync.budget, steerThreshold: cfg.sync.steerThreshold, pushFocus: cfg.sync.pushFocus, scope: cfg.sync.scope, sessionId: ctx.sessionManager.getSessionId() },
366
- undefined,
367
- { probe: "cheap" },
368
- );
369
- lastSyncError = undefined;
370
- if (!outcome.structural) return;
371
- if (outcome.red && outcome.text) {
372
- // Self/mixed/unattributed work can still receive an immediate
373
- // consequence steer. Another session's relevant update waits for the
374
- // next user prompt and therefore cannot restart an idle agent.
375
- pi.sendMessage({
376
- customType: "pi-fovea-sync",
377
- content: outcome.text,
378
- display: syncDisplays(cfg),
379
- details: outcome.details,
380
- }, outcome.delivery === "next-prompt"
381
- ? { deliverAs: "nextTurn" }
382
- : { deliverAs: "steer", triggerTurn: true });
383
- } else if (!outcome.details.baseline && !outcome.details.outsideAttention && cfg.sync.ackClean && ctx.hasUI) {
384
- ctx.ui.notify("fovea: checked repository changes; no new action is needed.", "info");
385
- }
386
- } catch (error) {
387
- // Turn-sync stays nonfatal, but a persistent index failure must not look
388
- // like a clean repository. Notify once until a successful sync clears it.
389
- const message = error instanceof Error ? error.message : String(error);
390
- if (ctx.hasUI && message !== lastSyncError) ctx.ui.notify(`fovea: sync paused: ${message}`, "warning");
391
- lastSyncError = message;
406
+ const files = turnFiles;
407
+ turnFiles = [];
408
+ for (const notice of await pollRoots(ctx, "cheap", files)) {
409
+ pi.sendMessage({ customType: "pi-fovea-sync", content: notice.content,
410
+ display: notice.display, details: notice.details }, notice.nextPrompt
411
+ ? { deliverAs: "nextTurn" } : { deliverAs: "steer", triggerTurn: true });
392
412
  }
393
413
  });
394
414
 
@@ -404,12 +424,13 @@ export default function fovea(pi: ExtensionAPI) {
404
424
  ],
405
425
  parameters: Type.Object({ root: RootParam, maxTokens: BudgetParam }),
406
426
  async execute(_id, params, signal, onUpdate, ctx) {
407
- const root = params.root ?? ctx.cwd;
427
+ const root = roots.target(ctx.cwd, params.root);
408
428
  try {
409
429
  if (signal?.aborted) throw new Error("Fovea sketch cancelled");
430
+ await bindRoot(root, ctx);
410
431
  onUpdate?.({ content: [text("Surveying production architecture…")], details: { phase: "sketch" } });
411
- const r = await sketch(root, params.maxTokens ?? configFor(root, ctx.isProjectTrusted()).tools.defaultBudget);
412
- return { content: [text(r.text)], details: r.details };
432
+ const r = await sketch(root, params.maxTokens ?? targetConfig(root, ctx).tools.defaultBudget);
433
+ return { content: [text(r.text)], details: { ...r.details, root, observedRoots: roots.list(ctx.cwd) } };
413
434
  } catch (error) {
414
435
  return rethrowOrDegrade(error);
415
436
  }
@@ -439,14 +460,15 @@ export default function fovea(pi: ExtensionAPI) {
439
460
  maxTokens: BudgetParam,
440
461
  }),
441
462
  async execute(_id, params, signal, onUpdate, ctx) {
442
- const root = params.root ?? ctx.cwd;
463
+ const root = roots.target(ctx.cwd, params.root);
443
464
  try {
444
465
  if (signal?.aborted) throw new Error("Fovea focus cancelled");
466
+ await bindRoot(root, ctx);
445
467
  onUpdate?.({ content: [text("Resolving focused repository context…")], details: { phase: "focus" } });
446
468
  const r = await focus(
447
469
  root,
448
470
  params.query,
449
- params.maxTokens ?? configFor(root, ctx.isProjectTrusted()).tools.defaultBudget,
471
+ params.maxTokens ?? targetConfig(root, ctx).tools.defaultBudget,
450
472
  {
451
473
  path: params.path,
452
474
  language: params.language,
@@ -454,7 +476,7 @@ export default function fovea(pi: ExtensionAPI) {
454
476
  fresh: params.fresh,
455
477
  },
456
478
  );
457
- return { content: [text(r.text)], details: r.details };
479
+ return { content: [text(r.text)], details: { ...r.details, root, observedRoots: roots.list(ctx.cwd) } };
458
480
  } catch (error) {
459
481
  return rethrowOrDegrade(error);
460
482
  }
@@ -474,12 +496,13 @@ export default function fovea(pi: ExtensionAPI) {
474
496
  maxTokens: BudgetParam,
475
497
  }),
476
498
  async execute(_id, params, signal, onUpdate, ctx) {
477
- const root = params.root ?? ctx.cwd;
499
+ const root = roots.target(ctx.cwd, params.root);
478
500
  try {
479
501
  if (signal?.aborted) throw new Error("Fovea dwell cancelled");
502
+ await bindRoot(root, ctx);
480
503
  onUpdate?.({ content: [text("Widening the current graph context…")], details: { phase: "diffuse" } });
481
- const r = await dwell(root, params.factor, params.maxTokens ?? configFor(root, ctx.isProjectTrusted()).tools.defaultBudget);
482
- return { content: [text(r.text)], details: r.details };
504
+ const r = await dwell(root, params.factor, params.maxTokens ?? targetConfig(root, ctx).tools.defaultBudget);
505
+ return { content: [text(r.text)], details: { ...r.details, root, observedRoots: roots.list(ctx.cwd) } };
483
506
  } catch (error) {
484
507
  return rethrowOrDegrade(error);
485
508
  }
@@ -502,21 +525,22 @@ export default function fovea(pi: ExtensionAPI) {
502
525
  maxTokens: BudgetParam,
503
526
  }),
504
527
  async execute(_id, params, signal, onUpdate, ctx) {
505
- const root = params.root ?? ctx.cwd;
528
+ const root = roots.target(ctx.cwd, params.root);
506
529
  try {
507
530
  if (signal?.aborted) throw new Error("Fovea impact cancelled");
531
+ await bindRoot(root, ctx);
508
532
  onUpdate?.({ content: [text("Tracing likely change impact…")], details: { phase: "impact" } });
509
- if ((params.root === undefined || params.root === ctx.cwd) && params.files?.length) {
510
- observeSessionPaths(ctx.cwd, params.files);
533
+ if (params.files?.length) {
534
+ observeSessionPaths(root, params.files);
511
535
  }
512
536
  const r = await impact(root, {
513
537
  files: params.files,
514
538
  symbols: params.symbols,
515
539
  includeUncommitted: params.includeUncommitted,
516
540
  base: params.base,
517
- budget: params.maxTokens ?? configFor(root, ctx.isProjectTrusted()).tools.defaultBudget,
541
+ budget: params.maxTokens ?? targetConfig(root, ctx).tools.defaultBudget,
518
542
  });
519
- return { content: [text(r.text)], details: r.details };
543
+ return { content: [text(r.text)], details: { ...r.details, root, observedRoots: roots.list(ctx.cwd) } };
520
544
  } catch (error) {
521
545
  return rethrowOrDegrade(error);
522
546
  }
@@ -540,6 +564,11 @@ export default function fovea(pi: ExtensionAPI) {
540
564
  }
541
565
  if (sub === "reset") {
542
566
  lifecycleEpoch++;
567
+ roots.clear();
568
+ for (const timer of warmTimers.values()) clearTimeout(timer);
569
+ warmTimers.clear();
570
+ pendingMutations.clear();
571
+ turnFiles = [];
543
572
  resetSessions();
544
573
  resetSyncBaselines();
545
574
  ctx.ui.notify("Fovea focus history and sync baseline reset.", "info");
@@ -555,8 +584,9 @@ export default function fovea(pi: ExtensionAPI) {
555
584
  return;
556
585
  }
557
586
  try {
587
+ const root = roots.target(ctx.cwd);
558
588
  const [state, astGrep] = await Promise.all([
559
- sketch(ctx.cwd, 256),
589
+ sketch(root, 256),
560
590
  pi.exec(process.env.FOVEA_AST_GREP ?? "ast-grep", ["--version"], { timeout: 15_000 })
561
591
  .catch(() => ({ code: -1, stdout: "" })),
562
592
  ]);
@@ -565,9 +595,9 @@ export default function fovea(pi: ExtensionAPI) {
565
595
  const unreadableCount = Array.isArray(state.details.extractionUnreadable) ? state.details.extractionUnreadable.length : 0;
566
596
  const oversizedCount = Array.isArray(state.details.extractionOversized) ? state.details.extractionOversized.length : 0;
567
597
  const generatedCount = Array.isArray(state.details.extractionGenerated) ? state.details.extractionGenerated.length : 0;
568
- const cfg = configFor(ctx.cwd, ctx.isProjectTrusted());
598
+ const cfg = targetConfig(root, ctx);
569
599
  ctx.ui.notify(
570
- `pi-fovea ${PACKAGE_VERSION} · ${coverage} · ${state.details.nodes ?? 0} symbols · ` +
600
+ `pi-fovea ${PACKAGE_VERSION} · root ${root} · ${roots.list(ctx.cwd).length} observed · ${coverage} · ${state.details.nodes ?? 0} symbols · ` +
571
601
  `${state.details.productionAnchors ?? state.details.anchors ?? 0} production anchors` +
572
602
  `${Number(state.details.testAnchors ?? 0) ? ` (${state.details.testAnchors} test/fixture collapsed)` : ""}` +
573
603
  `${failedCount ? ` · !${failedCount} files failed extraction` : ""}` +