@neuralis/brain-core 0.2.0 → 0.2.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.
Files changed (34) hide show
  1. package/app/README.md +1 -1
  2. package/app/filesystem/README.md +3 -3
  3. package/app/filesystem/api/README.md +3 -3
  4. package/app/filesystem/api/index.ts +127 -122
  5. package/app/filesystem/domain/types.ts +12 -0
  6. package/app/filesystem/hooks/README.md +5 -5
  7. package/app/filesystem/hooks/useDiscoverableSources.ts +4 -2
  8. package/app/filesystem/hooks/useFilesystemSSE.ts +31 -23
  9. package/app/filesystem/hooks/useGitChangesInventory.ts +4 -2
  10. package/app/filesystem/host/README.md +3 -2
  11. package/app/filesystem/host/installFilesystemHostComponents.tsx +22 -9
  12. package/app/filesystem/store/README.md +11 -7
  13. package/app/filesystem/store/filesystemStore.ts +434 -161
  14. package/app/filesystem/store/gitSlice.ts +6 -6
  15. package/app/filesystem/store/listSlice.ts +8 -6
  16. package/app/filesystem/store/searchSlice.ts +3 -3
  17. package/app/filesystem/ui/FileEditor.tsx +48 -35
  18. package/app/filesystem/ui/FileTree.tsx +10 -9
  19. package/app/filesystem/ui/GitCommitChangesView.tsx +5 -3
  20. package/app/filesystem/ui/GitCommitGraph.tsx +6 -5
  21. package/app/filesystem/ui/GitControlPanel.tsx +14 -13
  22. package/app/filesystem/ui/GitDiffView.tsx +2 -1
  23. package/app/filesystem/ui/GitFileDiff.tsx +7 -6
  24. package/app/filesystem/ui/PendingChangesPanel.tsx +16 -13
  25. package/app/filesystem/ui/PendingReviewDialog.tsx +8 -5
  26. package/app/filesystem/ui/ReembedDialog.tsx +4 -2
  27. package/app/filesystem/ui/SourcesPanel.tsx +26 -26
  28. package/app/filesystem/ui/VectorHealthPanel.tsx +6 -4
  29. package/dist/app/host.js +46 -46
  30. package/package.json +4 -4
  31. package/skills/create-package/references/manifest-and-discovery.md +1 -1
  32. package/skills/create-package/references/surfaces-and-design.md +7 -5
  33. package/skills/create-package/scripts/check-package.sh +47 -1
  34. package/app/filesystem/hooks/useContainerWidth.ts +0 -39
@@ -699,6 +699,18 @@ export interface PersistedFilesContext {
699
699
  activeFolderPath: string | null;
700
700
  }
701
701
 
702
+ /**
703
+ * ONE Files window's reload-surviving view, keyed in the persisted record by the
704
+ * host's window id: the same allowlist as `PersistedFilesContext`, plus the
705
+ * user, project and bound agent it was written for — a projection whose identity
706
+ * does not match the LIVE one is ignored, never applied.
707
+ */
708
+ export interface PersistedFilesWindow extends PersistedFilesContext {
709
+ userId: string;
710
+ projectId: string;
711
+ agentId: string;
712
+ }
713
+
702
714
  // ---------------------------------------------------------------------------
703
715
  // API response shapes (re-declared locally so the package is self-contained)
704
716
  // ---------------------------------------------------------------------------
@@ -1,10 +1,10 @@
1
1
  # brain-core/app/filesystem/hooks
2
2
 
3
- React hooks used by the Files widget: container-width detection, viewport diff loading and the filesystem SSE consumer.
3
+ React hooks used by the Files widget: viewport diff loading and the filesystem SSE consumer. The container-width hook is a shared kernel client export.
4
4
 
5
5
  ## Responsibility
6
6
 
7
- - `useContainerWidth(ref, options)` — observe an element and report whether it is "wide" (≥ threshold), with hysteresis so the diff views' auto unified↔split flip does not strobe while the user drags the widget across the boundary. Consumers: `GitFileDiff`, `FileEditor`.
7
+ - `GitFileDiff` and `FileEditor` import `useContainerWidth` from `@neuralis/package-system/client`. There is no package-local responsive hook.
8
8
  - `useFilesystemSSE(agentId, projectId)` — subscribe to the brain-core `filesystem` channel and dispatch the right store updates. Event types: `filesystem:created`/`updated`/`deleted`/`reverted` (per-file activity), `sync:completed` (source-scoped backstop — skipped when every change count, `filesMoved` and separate `chunksRemoved` included, says zero; a missing count is unknown and still re-lists, and a `purge` job never skips, because its removal count comes from a `deleteByFilter` and proves nothing), a `changeType:'badge'` git-state signal, and a `changeType:'structure'` + `external:true` EXTERNAL connector file change (brisk-kingfisher).
9
9
 
10
10
  - `useVisibleDiffCards(root)` — one viewport observer (cards within ~2 screens count as visible) and `CONTENT_LANES` (3) content lanes per Changes/history scroller, plus an LRU answer cache (200 entries / ~8 MB estimated) keyed by the caller — record or file, its `updatedAt`/mode and the source revision — so a card re-entering the viewport sends nothing and any change is a new key; `fresh` replaces an answer after a mutation. Pending previews defer to the observer; a git diff in the virtualized Changes list reads on mount (the virtualizer — overscan 4 — is that list's ONE mount bound) and paints its first frame from the synchronous `peek(key, family)` (the exact key, else the target's newest cached revision, shown as stale until its own answer lands — staging stays disabled meanwhile). The caller validates its current revision before queued work runs.
@@ -14,7 +14,7 @@ React hooks used by the Files widget: container-width detection, viewport diff l
14
14
 
15
15
  ## Public Surface
16
16
 
17
- - `useContainerWidth<T extends HTMLElement>(ref, options?: { wideThresholdPx?, hysteresisPx? }): boolean` — defaults 900 / 60.
17
+ - Responsive hook contracts live in the kernel client export map; existing Files callers retain their width thresholds and hysteresis.
18
18
  - `useFilesystemSSE(agentId: string | undefined, projectId: string | null): void` — side-effecting hook; returns nothing.
19
19
  - `useDiscoverableSources(params): readonly DiscoverableSourceSuggestion[]` — empty until the first response lands, and empty again immediately on a context change.
20
20
  - **The "already fetched" latch is a REF, never state.** As state it is a dependency of the effect it guards: committing it re-runs the effect, the first instance's cleanup cancels the in-flight request, and the second returns at the guard and never re-fetches. Live symptom: the request 200s with real data, there is no console error and no banner, and the dock renders nothing — invisible to any presentational test. Pinned by `test/ui/useDiscoverableSources.test.tsx` (5 of its 8 cases fail on the old shape).
@@ -25,7 +25,7 @@ React hooks used by the Files widget: container-width detection, viewport diff l
25
25
  ## How it plugs into the package-system kernel
26
26
 
27
27
  - `useFilesystemSSE` subscribes through `WorkspaceHostPort.subscribeRealtime({ channel: 'filesystem', agentId, projectId, onEvent })` (via `getFilesystemHostImperative().subscribeFilesystemEvents`) — the shared, multiplexed `/api/events` hub owned by the host, NOT a per-widget `EventSource` (the SSE-consolidation invariant: never open a per-widget connection — see `docs/architect/realtime-transport.md`). The hub owns reconnect/backoff; the host route is gated by `drive.read`.
28
- - Handlers dispatch into `useFilesystemStore` actions per event: `upsertFileNodeFromEvent` (per-file), `reconcileSourceFolders` (`sync:completed` and the `external:true` branch — both EARLY short-circuits BEFORE the `uri` guard, because the payload `uri` is the bare `<source>://` and would otherwise fabricate a phantom root node), `reconcileFolders` (a `changeType:'structure'` create/update/delete/revert — the affected PARENTS, never a global refresh), `ensureFolderLoaded({force})` + `refresh` (badge → `FileNode.git` subtree re-list + `SourceInfo.git` summaries), and `setSourceDirty` for a source that is not hydrated. An external change and a changing `sync:completed` share ONE per-source reconcile schedule: a trailing debounce (`RECONCILE_QUIET_MS` = 2 s — every event restarts it) bounded by a max-wait (`RECONCILE_MAX_WAIT_MS` = 6 s from the window's first event), so one agent turn's burst costs one or two passes of the visible folders instead of one per event, and a continuous burst still converges. The quiet window is sized on a measured turn, whose events arrive up to ~1.8 s apart. Pending passes are dropped on unmount and on an agent/project change. The badge branch keeps its own 250 ms timers (it does different work; a shared key would let one cancel the other). Badge/status and reconnect callbacks publish source revisions for Changes; timers are cancelled on context changes, and an epoch check prevents an old reconnect response from publishing into the new context.
28
+ - Handlers dispatch into the actions of the window's OWN store (`useFilesystemStore` / `useFilesystemStoreApi`) per event; a frame the hub delivers after the scope changed (agent, project or store) is dropped, never applied to the next scope: `upsertFileNodeFromEvent` (per-file), `reconcileSourceFolders` (`sync:completed` and the `external:true` branch — both EARLY short-circuits BEFORE the `uri` guard, because the payload `uri` is the bare `<source>://` and would otherwise fabricate a phantom root node), `reconcileFolders` (a `changeType:'structure'` create/update/delete/revert — the affected PARENTS, never a global refresh), `ensureFolderLoaded({force})` + `refresh` (badge → `FileNode.git` subtree re-list + `SourceInfo.git` summaries), and `setSourceDirty` for a source that is not hydrated. An external change and a changing `sync:completed` share ONE per-source reconcile schedule: a trailing debounce (`RECONCILE_QUIET_MS` = 2 s — every event restarts it) bounded by a max-wait (`RECONCILE_MAX_WAIT_MS` = 6 s from the window's first event), so one agent turn's burst costs one or two passes of the visible folders instead of one per event, and a continuous burst still converges. The quiet window is sized on a measured turn, whose events arrive up to ~1.8 s apart. Pending passes are dropped on unmount and on an agent/project change. The badge branch keeps its own 250 ms timers (it does different work; a shared key would let one cancel the other). Badge/status and reconnect callbacks publish source revisions for Changes; timers are cancelled on context changes, and an epoch check prevents an old reconnect response from publishing into the new context.
29
29
  - **The Changes badge re-count.** `pending:changed` (content-free, project-wide, derived server-side) never reaches the file handlers: it — plus a window focus / tab return and a successful reconnect probe — feeds ONE trailing schedule (`PENDING_COUNT_QUIET_MS` 1 000 ms, `PENDING_COUNT_MAX_WAIT_MS` 5 000 ms) that calls `refreshPendingCount`; a hidden tab skips the call, the mount counts once, a context change drops the timer. No poll, no timer while idle.
30
30
  - **Reconnect recovery.** The filesystem channel is strictly live fan-out — the package route has no `Last-Event-ID`, no backfill and no cursor, and the host hub replays a snapshot only for `presence`. Everything emitted while the connection was down is lost. The hook therefore subscribes with `onConnChange` and, on a settled liveness flip, PROBES with the cheap summaries call and then re-converges every hydrated source. The hub's flip carries no direction, which is why the probe exists instead of a guess. This is what makes the toolbar Refresh a rare, deliberate action rather than routine repair.
31
31
 
@@ -35,7 +35,7 @@ React hooks used by the Files widget: container-width detection, viewport diff l
35
35
 
36
36
  ## Gotchas
37
37
 
38
- - `useContainerWidth` re-measures on `ResizeObserver` ticks. Avoid CSS that changes layout in response to the returned value without hysteresis — the default `hysteresisPx: 60` is what prevents oscillation. It reads `clientWidth` ONLY: a `scrollWidth` comparison inside the observer callback forces a layout flush per tick. Its `scrollWidth`-comparing sibling `useElementFits` was DELETED (2026-08) after a repo-wide grep found no consumer in `packages/*` — do not resurrect it without one.
38
+ - The shared width hook observes `clientWidth`. Preserve hysteresis when CSS changes layout in response to its result; do not recreate the deleted `useElementFits` hook without a consumer.
39
39
  - `useFilesystemSSE` handlers must be defensive — a throw would break the shared hub subscription for every channel, not just this widget.
40
40
  - The `external:true` branch reconciles the source's VISIBLE folders (adds new files; strikes through / prunes removed ones). It must stay an EARLY `return` short-circuit before `upsertFileNodeFromEvent` (the `<source>://` uri is truthy → a phantom node otherwise), exactly like the badge branch.
41
41
  - **Anything guarded by `!uri` below the `if (!uri) return` is DEAD.** Three structural branches sat there and could never run; the intent (re-converge after a move/delete) is delivered now by the `changeType === 'structure'` parent reconcile. Do not reintroduce a `!uri` condition below that guard.
@@ -40,6 +40,7 @@
40
40
 
41
41
  import { useEffect, useRef, useState } from 'react';
42
42
  import { listDiscoverableSources, type DiscoverableSourceSuggestion } from '../api';
43
+ import { useFilesystemStore } from '../store/filesystemStore';
43
44
 
44
45
  export function useDiscoverableSources(params: {
45
46
  agentId: string | undefined;
@@ -47,6 +48,7 @@ export function useDiscoverableSources(params: {
47
48
  /** Only fetch once the caller could actually use the result. */
48
49
  enabled: boolean;
49
50
  }): readonly DiscoverableSourceSuggestion[] {
51
+ const client = useFilesystemStore((s) => s.client);
50
52
  const { agentId, projectId, enabled } = params;
51
53
  const [suggestions, setSuggestions] = useState<readonly DiscoverableSourceSuggestion[]>([]);
52
54
 
@@ -69,13 +71,13 @@ export function useDiscoverableSources(params: {
69
71
  useEffect(() => {
70
72
  if (!agentId || !enabled || fetchedRef.current) return;
71
73
  fetchedRef.current = true;
72
- listDiscoverableSources({ agentId, ...(projectId ? { projectId } : {}) })
74
+ listDiscoverableSources({ agentId, ...(projectId ? { projectId } : {}) }, client)
73
75
  .then((res) => { if (aliveRef.current) setSuggestions(res.suggestions ?? []); })
74
76
  // Fail-soft: no dock, no banner. It is an affordance, not a result. The
75
77
  // latch stays set, so a transient failure does not retry-loop; a context
76
78
  // change (the effect above) is what re-arms it.
77
79
  .catch(() => { if (aliveRef.current) setSuggestions([]); });
78
- }, [agentId, projectId, enabled]);
80
+ }, [agentId, projectId, enabled, client]);
79
81
 
80
82
  return suggestions;
81
83
  }
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useCallback, useRef } from 'react';
2
- import { useFilesystemStore } from '../store/filesystemStore';
2
+ import { useFilesystemStore, useFilesystemStoreApi } from '../store/filesystemStore';
3
3
  import { getFilesystemHostImperative } from '../FilesystemHost';
4
4
  import { uriAncestors, sourceFromUri, folderUri } from '../store/helpers';
5
5
 
@@ -96,6 +96,9 @@ export function syncChangedNothing(payload: Record<string, unknown>): boolean {
96
96
  * hook just filters the channel to its own agent + the known event types.
97
97
  */
98
98
  export function useFilesystemSSE(agentId: string | undefined, projectId: string | null) {
99
+ // THIS window's store: every handler below reads and writes it, never another
100
+ // window's — a new store (another identity) is a new subscription.
101
+ const storeApi = useFilesystemStoreApi();
99
102
  const refreshTree = useFilesystemStore((s) => s.refreshTree);
100
103
  const loadSourceSummaries = useFilesystemStore((s) => s.refresh);
101
104
  const ensureFolderLoaded = useFilesystemStore((s) => s.ensureFolderLoaded);
@@ -135,7 +138,7 @@ export function useFilesystemSSE(agentId: string | undefined, projectId: string
135
138
  for (const t of status.values()) clearTimeout(t);
136
139
  status.clear();
137
140
  };
138
- }, [agentId, projectId]);
141
+ }, [agentId, projectId, storeApi]);
139
142
  useEffect(() => {
140
143
  const reconcile = reconcileTimersRef.current;
141
144
  return () => {
@@ -143,7 +146,7 @@ export function useFilesystemSSE(agentId: string | undefined, projectId: string
143
146
  for (const entry of reconcile.values()) clearTimeout(entry.timer);
144
147
  reconcile.clear();
145
148
  };
146
- }, [agentId, projectId]);
149
+ }, [agentId, projectId, storeApi]);
147
150
 
148
151
  // ONE pending re-count schedule per context (see `PENDING_COUNT_QUIET_MS`).
149
152
  const countTimerRef = useRef<{ timer: ReturnType<typeof setTimeout>; deadline: number } | null>(null);
@@ -198,7 +201,7 @@ export function useFilesystemSSE(agentId: string | undefined, projectId: string
198
201
  // `gitStatusSeq` guard drops any out-of-order response.
199
202
  const scheduleGitStatusRefresh = (source: string): void => {
200
203
  if (!agentId || !source) return;
201
- const active = useFilesystemStore.getState().activeSource;
204
+ const active = storeApi.getState().activeSource;
202
205
  const focused =
203
206
  typeof active === 'string' && active
204
207
  ? active.includes('://')
@@ -212,7 +215,7 @@ export function useFilesystemSSE(agentId: string | undefined, projectId: string
212
215
  source,
213
216
  setTimeout(() => {
214
217
  timers.delete(source);
215
- useFilesystemStore.setState(s => ({ sourceChangeRevision: { ...s.sourceChangeRevision, [source]: (s.sourceChangeRevision[source] ?? 0) + 1 } }));
218
+ storeApi.setState(s => ({ sourceChangeRevision: { ...s.sourceChangeRevision, [source]: (s.sourceChangeRevision[source] ?? 0) + 1 } }));
216
219
  if (focused === source) void loadGitStatus({ agentId, projectId, source });
217
220
  }, BADGE_REFRESH_DEBOUNCE_MS),
218
221
  );
@@ -254,7 +257,7 @@ export function useFilesystemSSE(agentId: string | undefined, projectId: string
254
257
  // folder of the source and applies the absence semantics. On a source that
255
258
  // is not hydrated yet it is a no-op by design; mark it dirty instead so the
256
259
  // header can tell the user a refresh is worth it.
257
- if (useFilesystemStore.getState().loadedFolders?.[`${source}://`]) {
260
+ if (storeApi.getState().loadedFolders?.[`${source}://`]) {
258
261
  scheduleSourceReconcile(source);
259
262
  } else {
260
263
  setSourceDirty(source, true);
@@ -287,9 +290,9 @@ export function useFilesystemSSE(agentId: string | undefined, projectId: string
287
290
  // (recursive:false re-list) — acceptable v1.
288
291
  if (payload.changeType === 'badge') {
289
292
  if (!agentId) return;
290
- // A background Files widget bound to a DIFFERENT agent than the active tree
291
- // must not thrash the shared store's context on a source-scoped badge.
292
- const storeAgent = useFilesystemStore.getState().activeAgentId;
293
+ // A store whose context is not this hook's agent (mid-switch) must not have
294
+ // its context thrashed by a source-scoped badge.
295
+ const storeAgent = storeApi.getState().activeAgentId;
293
296
  if (storeAgent && storeAgent !== agentId) return;
294
297
  const source =
295
298
  typeof payload.source === 'string' && payload.source
@@ -303,14 +306,14 @@ export function useFilesystemSSE(agentId: string | undefined, projectId: string
303
306
  source,
304
307
  setTimeout(() => {
305
308
  timers.delete(source);
306
- useFilesystemStore.setState(s => ({ sourceChangeRevision: { ...s.sourceChangeRevision, [source]: (s.sourceChangeRevision[source] ?? 0) + 1 } }));
309
+ storeApi.setState(s => ({ sourceChangeRevision: { ...s.sourceChangeRevision, [source]: (s.sourceChangeRevision[source] ?? 0) + 1 } }));
307
310
  // FileNode.git for the source subtree (matches the sync:completed precedent).
308
311
  void ensureFolderLoaded({ agentId, projectId, uri: `${source}://`, force: true });
309
312
  // SourceInfo.git (repo-switcher, tree source header, panel branch hint).
310
313
  // Summaries ONLY — a git badge must never trigger a per-source walk.
311
314
  void loadSourceSummaries({ agentId, projectId });
312
315
  // Git Control panel change-set + branch, only when this is the focused repo.
313
- const active = useFilesystemStore.getState().activeSource;
316
+ const active = storeApi.getState().activeSource;
314
317
  const focused =
315
318
  typeof active === 'string' && active
316
319
  ? active.includes('://')
@@ -341,9 +344,9 @@ export function useFilesystemSSE(agentId: string | undefined, projectId: string
341
344
  // shared per-source reconcile schedule, never the badge's timers.
342
345
  if (payload.external === true) {
343
346
  if (!agentId) return;
344
- // A background Files widget bound to a DIFFERENT agent than the active tree
345
- // must not thrash the shared store's context on a source-scoped event.
346
- const storeAgent = useFilesystemStore.getState().activeAgentId;
347
+ // A store whose context is not this hook's agent (mid-switch) must not have
348
+ // its context thrashed by a source-scoped event.
349
+ const storeAgent = storeApi.getState().activeAgentId;
347
350
  if (storeAgent && storeAgent !== agentId) return;
348
351
  const source =
349
352
  typeof payload.source === 'string' && payload.source
@@ -356,7 +359,7 @@ export function useFilesystemSSE(agentId: string | undefined, projectId: string
356
359
  // A source that has never been hydrated has nothing to reconcile against —
357
360
  // an eager content list here would defeat lazy hydration. Flag it instead:
358
361
  // the header shows the "stale" hint and the next expand/refresh converges it.
359
- if (!useFilesystemStore.getState().loadedFolders?.[`${source}://`]) {
362
+ if (!storeApi.getState().loadedFolders?.[`${source}://`]) {
360
363
  setSourceDirty(source, true);
361
364
  return;
362
365
  }
@@ -460,6 +463,7 @@ export function useFilesystemSSE(agentId: string | undefined, projectId: string
460
463
  [
461
464
  agentId,
462
465
  projectId,
466
+ storeApi,
463
467
  refreshTree,
464
468
  loadSourceSummaries,
465
469
  ensureFolderLoaded,
@@ -511,8 +515,8 @@ export function useFilesystemSSE(agentId: string | undefined, projectId: string
511
515
  if (epoch !== contextEpoch.current) return;
512
516
  // A gap may have hidden pending:changed frames — re-count once.
513
517
  scheduleCountRef.current();
514
- const state = useFilesystemStore.getState();
515
- useFilesystemStore.setState(s => ({ sourceChangeRevision: Object.fromEntries(s.sources.map(source => [sourceFromUri(source.uri_root), (s.sourceChangeRevision[sourceFromUri(source.uri_root)] ?? 0) + 1])) }));
518
+ const state = storeApi.getState();
519
+ storeApi.setState(s => ({ sourceChangeRevision: Object.fromEntries(s.sources.map(source => [sourceFromUri(source.uri_root), (s.sourceChangeRevision[sourceFromUri(source.uri_root)] ?? 0) + 1])) }));
516
520
  const sources = new Set<string>();
517
521
  for (const [key, loaded] of Object.entries(state.loadedFolders || {})) {
518
522
  if (!loaded) continue;
@@ -526,7 +530,7 @@ export function useFilesystemSSE(agentId: string | undefined, projectId: string
526
530
  );
527
531
  })();
528
532
  }, RECONNECT_RECONCILE_DEBOUNCE_MS);
529
- }, [agentId, projectId, loadSourceSummaries, reconcileSourceFolders]);
533
+ }, [agentId, projectId, storeApi, loadSourceSummaries, reconcileSourceFolders]);
530
534
 
531
535
  const handleConnChangeRef = useRef(handleConnChange);
532
536
  handleConnChangeRef.current = handleConnChange;
@@ -536,14 +540,17 @@ export function useFilesystemSSE(agentId: string | undefined, projectId: string
536
540
  return () => {
537
541
  if (timer.current) clearTimeout(timer.current);
538
542
  };
539
- }, [agentId, projectId]);
543
+ }, [agentId, projectId, storeApi]);
540
544
 
541
545
  useEffect(() => {
542
546
  if (!agentId || !projectId) return;
543
547
  const host = getFilesystemHostImperative();
544
548
  if (!host?.subscribeFilesystemEvents) return;
545
-
546
- return host.subscribeFilesystemEvents(agentId, projectId, (raw) => {
549
+ // Inert once this scope is gone (agent/project/store change, unmount): a frame
550
+ // the hub delivers after the unsubscribe must not reach the next scope's store.
551
+ let live = true;
552
+ const unsubscribe = host.subscribeFilesystemEvents(agentId, projectId, (raw) => {
553
+ if (!live) return;
547
554
  // The hub carries the whole authorized agent set; keep only THIS agent's
548
555
  // known event types (server already scoped by user/project).
549
556
  const event = raw as FilesystemEvent | null;
@@ -565,6 +572,7 @@ export function useFilesystemSSE(agentId: string | undefined, projectId: string
565
572
  const isExternal = (event.payload as { external?: boolean }).external === true;
566
573
  if (!isBadge && !isExternal && event.agentId !== agentId) return;
567
574
  handleEventRef.current(event);
568
- }, () => handleConnChangeRef.current());
569
- }, [agentId, projectId]);
575
+ }, () => { if (live) handleConnChangeRef.current(); });
576
+ return () => { live = false; unsubscribe(); };
577
+ }, [agentId, projectId, storeApi]);
570
578
  }
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
4
4
  import { getGitRepos, getGitStatus } from '../api';
5
5
  import type { SourceInfo } from '../domain/types';
6
6
  import type { GitGroup } from '../ui/pendingChangesModel';
7
+ import { useFilesystemStore } from '../store/filesystemStore';
7
8
 
8
9
  /**
9
10
  * The Git half of the Changes inventory: every accessible checkout's status, one
@@ -15,6 +16,7 @@ import type { GitGroup } from '../ui/pendingChangesModel';
15
16
  export function useGitChangesInventory(params: {
16
17
  agentId: string; projectId: string | null; sources: SourceInfo[]; revisions: Record<string, number>; resetKey: string;
17
18
  }): { groups: GitGroup[]; loading: boolean; refresh: (force: boolean) => Promise<void> } {
19
+ const client = useFilesystemStore((s) => s.client);
18
20
  const { agentId, projectId, sources, revisions, resetKey } = params;
19
21
  const [groups, setGroups] = useState<GitGroup[]>([]);
20
22
  const [loading, setLoading] = useState(false);
@@ -42,7 +44,7 @@ export function useGitChangesInventory(params: {
42
44
  const revision = revisions[slug] ?? 0;
43
45
  if (!all && seen.current[slug] === revision) continue;
44
46
  try {
45
- const repos = await getGitRepos({ agentId, ...scope, source: slug });
47
+ const repos = await getGitRepos({ agentId, ...scope, source: slug }, client);
46
48
  if (mine !== seq.current) return;
47
49
  for (let index = next.length - 1; index >= 0; index--) {
48
50
  if (next[index].source === slug && !repos.repos.some(repo => repo.rel === next[index].repo.rel)) next.splice(index, 1);
@@ -50,7 +52,7 @@ export function useGitChangesInventory(params: {
50
52
  for (const repo of repos.repos) {
51
53
  const group: GitGroup = { source: slug, repo };
52
54
  try {
53
- const result = await getGitStatus({ agentId, ...scope, source: slug, repo: repo.rel });
55
+ const result = await getGitStatus({ agentId, ...scope, source: slug, repo: repo.rel }, client);
54
56
  if (mine !== seq.current) return;
55
57
  if (result.ok === false) group.error = 'Git status unavailable'; else group.status = result.status;
56
58
  } catch (error) { group.error = error instanceof Error ? error.message : 'Git status failed'; }
@@ -15,18 +15,19 @@ The host integration entry point — `installFilesystemHostComponents`, re-expor
15
15
  - `FilesystemHostValue.currentRole` — `{ role?, priority? }` for the caller in the active project, derived from `WorkspaceHostPort.useCurrentUser()` (which now returns role + ordinal priority alongside name/email). Owner/admin-effective is `priority <= 2`. Used to HIDE owner-only Sources/Packages controls (the `recognizesPackages` toggle, the package access-feature override); UX-only — the server route stays the enforcement floor, so an absent signal hides the control (deny-by-default).
16
16
  - `FilesystemHostValue.grantedFeatures` — `readonly string[]`, the caller's granted feature ids in the active project, read from `WorkspaceHostPort.getGrantedFeatures(projectId, userId)`. **Evaluate it ONLY through `hasFeature` / `meetsRequires` from `@neuralis/package-system/access`** — never a hand-rolled `includes` (it would miss the `'*'` wildcard), and never `currentRole`: the role NAME is not an authority source, not even for a hide-only gate. It is `readonly` so no UI file can mutate the caller's grants; the kernel predicates take a mutable array, so copy at the call site rather than widening the contract. UX-only exactly like `currentRole` — it hides an affordance the caller could not use (the S2 "hidden means absent" discipline: hidden entirely, never a greyed-out or locked row), and the route re-checks every call regardless. Absent/empty ⇒ hide, deny-by-default. First consumer: the Explorer tree's **Discover dock**, gated on `drive.mount`.
17
17
  - The port returns a FRESH array on every call, so `buildFilesystemHostValue` memoises on a joined key rather than array identity — a raw pass-through would invalidate the whole host-value memo on every render.
18
- - `installFilesystemHostComponents({ registry: PackageHostRegistry, port: WorkspaceHostPort })` — side-effecting; registers widget + card + the provider fill (one component per install, closed over the port; it ignores the slot's `snapshot`/`assetScope` props), and subscribes ONCE to the host's optional `port.onClientStateReset` so the dock broom (widget / agent / project level) reaches the Files store (`resetClientState`). A host without the method simply never resets the store.
18
+ - `installFilesystemHostComponents({ registry: PackageHostRegistry, port: WorkspaceHostPort })` — side-effecting; registers widget + card + the provider fill (one component per install, closed over the port; it ignores the slot's `snapshot`/`assetScope` props), and subscribes ONCE to the host's optional `port.onClientStateReset` with `resetFilesClientState`, so the dock broom (instance / widget / agent / project level) reaches every mounted Files window it scopes and the one persisted record. A host without the method simply never resets.
19
19
  - `InstallFilesystemHostComponentsOptions`.
20
20
 
21
21
  ## How it plugs into the package-system kernel
22
22
 
23
23
  - `WorkspaceHostPort` (from `@neuralis/package-system/contracts`) is the single point of contact between the host and brain-core's UI. The host has exactly one implementation; brain-core consumes the port without knowing which host runs it.
24
24
  - `installFilesystemHostComponents` registers the widget under `componentImport: '@neuralis/brain-core/app/filesystem'` — the same string the package manifest declares for `app.surfaces[].component.import`. The host renderer resolves widgets by that import string at render time.
25
- - The widget is wrapped in `<PackageApiProvider slug="brain-core" projectId agentId userId>` from `@neuralis/package-system/client` so every internal API call resolves through the kernel session.
25
+ - `FilesystemWidgetHost` is the window seam: it receives the window's `widgetId` and BOUND `agentId`, creates the window's own store (`useFilesWindowStore` — none, and nothing painted, until the live user and project are known) and mounts it under `FilesystemStoreProvider`, keyed on that identity so a different user or project remounts the whole Files tree instead of re-using callbacks of the previous one. The store's request `client` (`createPackageApi` over the window's project and bound agent) is what every API call passes; the surrounding `<PackageApiProvider slug="brain-core" projectId agentId userId>` keeps serving the kernel's own React consumers.
26
26
  - The card registration uses `registerCardComponent('filesystem.change', FilesystemChangeCard, { owner: { kind: 'package-direct', packageId: '@neuralis/brain-core' }, renderer: 'direct', mountPolicy: 'virtualized', estimateHeight: estimateFilesystemChangeCardHeight })` from `@neuralis/agent-core/app/chat` — the agent-core chat surface owns the card registry; brain-core contributes an OWNED first-party direct (React) card. The `virtualized` mount policy keeps it out of the iframe/keep-alive overscan bump. The registry rejects any OTHER owner touching `filesystem.change` (`OWNER_MISMATCH`) and never lets a package overwrite a builtin. `estimateHeight` is the card's own row-height model (see `app/cards/README.md`); it MUST stay a module-level reference — the registry compares estimator identity when deciding whether a re-registration is idempotent, so an inline arrow here would churn the registry (fingerprint bump + retained-View eviction) on every call.
27
27
 
28
28
  ## Security & scope
29
29
 
30
+ - One window store per mounted Files window, hidden and minimized windows included (they stay mounted); a window id addresses a reset and keys a persisted view, never an authority.
30
31
  - The widget never sees raw host internals. `WorkspaceHostPort` exposes only the active session ids, the agent map, file marks, the SSE URL builder, granted features (read-only), the docker flag, and the client-state reset subscription (a scope, never a package name — the host stays generic).
31
32
  - File marks and event subscriptions are agent-scoped; cross-agent observation requires the corresponding `filesystem.observeScoped` / `filesystem.modifyScoped` features which the route layer enforces.
32
33
 
@@ -9,7 +9,7 @@
9
9
 
10
10
  'use client';
11
11
 
12
- import React, { Suspense, type ReactNode } from 'react';
12
+ import React, { Suspense, useEffect, type ReactNode } from 'react';
13
13
  import type { WorkspaceHostPort } from '@neuralis/package-system/contracts';
14
14
  import {
15
15
  PackageApiProvider,
@@ -18,7 +18,7 @@ import {
18
18
  } from '@neuralis/package-system/client';
19
19
  import { registerCardComponent } from '@neuralis/agent-core/app/chat';
20
20
  import { FilesystemWidget } from '../ui';
21
- import { useFilesystemStore } from '../store/filesystemStore';
21
+ import { FilesystemStoreProvider, resetFilesClientState, setFilesLiveUser, useFilesWindowStore } from '../store/filesystemStore';
22
22
  import { FilesystemChangeCard } from '../../cards/FilesystemChangeCard';
23
23
  import { estimateFilesystemChangeCardHeight } from '../../cards/FilesystemCardShell';
24
24
  import { FILESYSTEM_CARD_MATCH_TOOLS, FILESYSTEM_COMPOSER_CONTRACT } from '../../cards/diffUtils';
@@ -48,10 +48,12 @@ type FilesystemWidgetProps = {
48
48
  };
49
49
 
50
50
  /**
51
- * The dock broom's reach into the Files store is wired ONCE per module, at
51
+ * The dock broom's reach into the Files stores is wired ONCE per module, at
52
52
  * install time, to the FIRST port that carries `onClientStateReset` — the only
53
- * place the port is consumed outside `buildFilesystemHostValue`. A host without
54
- * the optional method simply never resets this store (fail-safe: the package
53
+ * place the port is consumed outside `buildFilesystemHostValue` and the window
54
+ * host. ONE subscription for every window: `resetFilesClientState` fans the scope
55
+ * out to the mounted windows it reaches and to the one persisted record. A host
56
+ * without the optional method simply never resets (fail-safe: the package
55
57
  * assumes nothing); a second install with a different port is not re-wired
56
58
  * (there is one host port per process).
57
59
  */
@@ -64,15 +66,17 @@ export function installFilesystemHostComponents(
64
66
 
65
67
  if (!resetSubscribed && port.onClientStateReset) {
66
68
  resetSubscribed = true;
67
- port.onClientStateReset((scope) => {
68
- useFilesystemStore.getState().resetClientState(scope);
69
- });
69
+ port.onClientStateReset(resetFilesClientState);
70
70
  }
71
71
 
72
72
  // The provider fill: ONE component per install (its identity is the host's
73
73
  // memo key). The slot's `snapshot`/`assetScope` props are not read — the
74
74
  // Files host value comes from the port alone.
75
75
  function FilesystemWorkspaceProvider({ children }: { children?: ReactNode }) {
76
+ // Whose persisted Files records a broom prunes — the signed-in user, also
77
+ // when no Files window is open.
78
+ const userId = port.useUserId();
79
+ useEffect(() => { setFilesLiveUser(userId); }, [userId]);
76
80
  return <FilesystemPackageHost port={port}>{children}</FilesystemPackageHost>;
77
81
  }
78
82
 
@@ -82,13 +86,22 @@ export function installFilesystemHostComponents(
82
86
  Component: FilesystemWorkspaceProvider,
83
87
  });
84
88
 
89
+ // ONE store per window — the insertion seam. `agentId` is the window's BOUND
90
+ // agent and `widgetId` its id; a different user or project is a new store, and
91
+ // nothing renders before both are known. The key remounts the Files tree with a
92
+ // new store, so no component keeps a callback, client or local list of the
93
+ // previous identity.
85
94
  function FilesystemWidgetHost({ agentId, widgetId, state }: FilesystemWidgetProps) {
86
95
  const projectId = port.useProjectId();
87
96
  const userId = port.useUserId();
97
+ const store = useFilesWindowStore(widgetId, userId, projectId, agentId);
98
+ if (!store) return LOADING;
88
99
  return (
89
100
  <WorkspaceHostPortProvider port={port}>
90
101
  <PackageApiProvider slug="brain-core" projectId={projectId} agentId={agentId} userId={userId}>
91
- <FilesystemWidget agentId={agentId} projectId={projectId} userId={userId} widgetId={widgetId} state={state} />
102
+ <FilesystemStoreProvider key={`${widgetId}\u0000${userId}\u0000${projectId}\u0000${agentId}`} store={store}>
103
+ <FilesystemWidget agentId={agentId} projectId={projectId} userId={userId} widgetId={widgetId} state={state} />
104
+ </FilesystemStoreProvider>
92
105
  </PackageApiProvider>
93
106
  </WorkspaceHostPortProvider>
94
107
  );
@@ -1,6 +1,6 @@
1
1
  # brain-core/app/filesystem/store
2
2
 
3
- Zustand store powering the Files widget: file dictionary, expanded folders, sources registry, search state, list / search slices.
3
+ Zustand stores powering the Files windows — ONE live store per window (file dictionary, expanded folders, sources registry, search and git state, list / search / git slices) and ONE persistence owner for all of them.
4
4
 
5
5
  ## Responsibility
6
6
 
@@ -28,12 +28,15 @@ Two guards make this safe against the races a plain re-list would lose:
28
28
 
29
29
  ## Public Surface
30
30
 
31
- - `useFilesystemStore` — Zustand hook exposing `FileSystemState` plus `ListSliceActions` and `SearchSliceActions`.
31
+ - `createFilesystemStore(init)` — one Files store (a vanilla Zustand store exposing `FileSystemState` plus the list / search / git slice actions). Every asynchronous fence and dedup map is created with it (`resetEpoch`, the pending-count and search / git sequences, the rehydrate set, the folder-load and root-listing dedup maps, the reconcile jobs, the per-folder generations), so two windows — of two agents or of the same one — never fence, supersede or dedup each other. It persists nothing itself and carries its window's identity: `windowId` and `client`, the request client built over the window's project and BOUND agent that every API call of the store passes.
32
+ - `createFilesWindowStore(identity)` / `mountFilesWindow(store, identity)` / `useFilesWindowStore(...)` — a window's store for its LIVE `(windowId, userId, projectId, agentId)`, registered while mounted. `useFilesWindowStore` returns `null` until the user and project are known (nothing persisted is read and nothing paints before), and a different user or project is a different store.
33
+ - `FilesystemStoreProvider` / `useFilesystemStore(selector)` / `useFilesystemStoreApi()` — the window's store through React context. The hook keeps its name and reads THIS window's store; outside a `FilesystemStoreProvider` it throws (there is no module store to fall back to). Imperative readers (event handlers, the SSE hook) take the store from `useFilesystemStoreApi()`.
34
+ - `filesPersistence` — the ONE writer of `neuralis.filesystem.tabs` (v2, see the context space below), and `resetFilesClientState(scope)`, the broom's ONE entry.
32
35
  - `helpers.ts` — URI helpers (`fileKey`, `fileKeyFromNode`, `folderUri`, `sourceFromUri`, `sourceRootUri`, `extractFilename`, `uriAncestors`, `parseFilePrev`, `extractFileContent`, `extractReadFilesPayload`, `ensureFolderChainForUri`, `hasCompletedRead` — a tab's read is done when it left a string baseline or an error; FileNode presence says nothing — and `contextKey(userId, projectId, agentId)`, the ONE key of the context space).
33
36
  - `searchSlice.ts` — `SearchSliceState`, `SearchSliceActions`, `SearchSliceHit`.
34
37
  - `listSlice.ts` — `ListSliceActions` (`loadList`, `refresh` — both source-summary loaders) + `contextResetPatch()`, the ONE per-context wipe (everything except the preference fields, the active pair and the in-flight `uploading` flag) shared by `loadList`'s agent/project switch and `resetClientState`.
35
38
  - `FILES_LRU_HARD_CAP` — the cold-row cap (exported for the eviction tests, which seed `cap + n` rows instead of a hardcoded count).
36
- - **The context space — `activeUserId` + `contextStateByKey`** (owner rule 2026-09-04: isolation between agents is not loss). The store is scoped the way the platform is: by `(user, project, agent)`. The ACTIVE context lives in the flat fields; every context the store has left sits in `contextStateByKey` under `contextKey(userId, projectId, agentId)` as its FULL per-context slice — exactly the keys of `contextResetPatch()` (`captureContextSlice`): tree nodes, expanded/loaded folders, tabs, content caches, search, git panel, sources, panels. `loadList` on a switch fences, stashes the outgoing slice, and restores the incoming one (or the reset defaults for a context never visited), then re-reads the restored active tab (`rehydrateActiveTab`) and reconciles the restored context's loaded sources — its realtime events were dropped while it was inactive, so a switch-back IS a gap. The widget passes `userId` (from `port.useUserId()`) only on its mount `loadList`; a store with no user ADOPTS the first real one in place, a different user is a switch. Persisted as `contexts` projections (`PersistedFilesContext`: the tab shape, `expandedFolders` as an array, `sidebarView`, `activeWorkspaceTab`, `workspacePanels`, `activeFolderPath`) — nodes and caches stay in memory and re-fetch; the active context's projection is taken off the flat fields on every write, `merge` puts it back into them on rehydrate, and a v1 payload's flat tab trio is lifted into the active context by `migrate`. The dock broom is the ONE writer that drops a stashed context, its own scope only (widget/agent: this context; project: every context of this user's project). It mirrors the host's per-context runtime map; the routes remain the enforcement floor — this is the client's mirror, never an access decision.
39
+ - **The context space — `activeUserId` + `contextStateByKey`** (owner rule 2026-09-04: isolation between agents is not loss). The store is scoped the way the platform is: by `(user, project, agent)`. The ACTIVE context lives in the flat fields; every context the store has left sits in `contextStateByKey` under `contextKey(userId, projectId, agentId)` as its FULL per-context slice — exactly the keys of `contextResetPatch()` (`captureContextSlice`): tree nodes, expanded/loaded folders, tabs, content caches, search, git panel, sources, panels. `loadList` on a switch fences, stashes the outgoing slice, and restores the incoming one (or the reset defaults for a context never visited), then re-reads the restored active tab (`rehydrateActiveTab`) and reconciles the restored context's loaded sources — its realtime events were dropped while it was inactive, so a switch-back IS a gap. The widget passes `userId` (from `port.useUserId()`) only on its mount `loadList`; a store with no user ADOPTS the first real one in place, a different user is a switch. **Persistence has ONE owner** (`filesPersistence`) — window stores persist nothing, so two windows writing one key can never overwrite each other. The v2 record keeps its prefs, its active triple and the per-agent `contexts` projections (`PersistedFilesContext`: the tab shape, `expandedFolders` as an array, `sidebarView`, `activeWorkspaceTab`, `workspacePanels`, `activeFolderPath`) exactly as an older build wrote them, plus an OPTIONAL `windows` map: one `PersistedFilesWindow` per window id — the same allowlist plus the user, project and bound agent it was written for. Nodes and caches stay in memory and re-fetch. A window's store starts from its OWN projection when the record holds one for the LIVE identity (one written for another user, project or agent is ignored); a window with none starts from the defaults — the per-agent `contexts` entries are never read into a window (no translation from an agent key to a window id; an upgrade resets the open tabs once). While mounted, the window feeds its projection to the owner: per store update eight reference compares, and only a projection that changed is serialized and written (the whole allowlisted record, once). A record without `windows` is read without one and is not rewritten until a window really changes; a v1 payload's flat tab trio is lifted into the active context by `migrate` (pre-existing, not extended). Hiding a window or switching a Group keeps its projection; unmounting drops its subscription and fences its in-flight readers, never its projection. The dock broom is the ONE writer that drops persisted state, its own scope only (see below). It mirrors the host's per-context runtime map; the routes remain the enforcement floor — this is the client's mirror, never an access decision.
37
40
  - `rehydrateActiveTab` — fetches the active tab's content when no read has completed for it (`hasCompletedRead`), against the store's OWN active pair; run by `loadList` on a same-context mount (page reload) and after a switch-back restore, and by `setActiveFile` (a tab click). It replaced a `FilesystemWidget` effect that decided from its render closure (the previous agent's tab re-opened under the new one after a switch) and from FileNode presence (a listed-but-unread tab rendered "Empty file"). A per-uri in-flight set stops a double activation from issuing two reads; the fence clears it (the same uri under another context is another file).
38
41
  - `saveFileInPlace` / `markFileDeleted` — the editor's in-place mutation tails beside `moveFileToFolder`: move when the target uri differs, update content when it differs, then a targeted `reconcileFolders` of BOTH parents (the old folder loses the departed row — the upsert-only `refresh` never could) and a forced re-read; delete, then reconcile the parent (the tombstone comes from the reconcile). A rejected `/modify` is HTTP 200 — `ok !== true` throws the server's message and runs no tail.
39
42
  - `snapshotError` / `clearSnapshotError` — the ROOT-listing failure (`loadList`'s catch: the service, not one source — a source's own failure is `hydrationState[uri]`); rendered by the tree with Retry, cleared by the next successful listing, the reset patch or the dismiss.
@@ -41,9 +44,9 @@ Two guards make this safe against the races a plain re-list would lose:
41
44
 
42
45
  ## How it plugs into the package-system kernel
43
46
 
44
- - The store calls `api/index.ts` clients which go through `tryGetPackageApiImperativeAsync('brain-core')` — every API call is session-scoped by the kernel.
47
+ - The store calls the `api/index.ts` functions with its own `client` (`createPackageApi` over the window's project and bound agent) — the X-Project-Id / X-Agent-Id headers are fixed before any await and never come from another mounted window; the routes resolve the real session from them.
45
48
  - SSE events arrive via `useFilesystemSSE` (in `hooks/`) and dispatch into store actions; the event payload follows the `FilesystemEventPayload` shape from `src/domain/events.ts`.
46
- - **The dock broom reaches this store through the kernel port**: `installFilesystemHostComponents` subscribes `port.onClientStateReset` once at install time and forwards every scope to `resetClientState(scope)`. The action matches the scope against the ACTIVE `(activeProjectId, activeAgentId)` pair (widget level: only the `filesystem` widget type; anything else is a no-op), applies `contextResetPatch()` (which includes an empty `tabMeta`) — so the persisted allowlist (`openFiles`/`activeFile`/`tabMeta`) is written EMPTY and a persisted tab cannot re-run the read/list race on the next mount — and fences everything in flight through the ONE `fenceInFlightWriters` action — a module-level `resetEpoch` that the `openFile` read, a reconcile pass's `set()`, `createNode`'s optimistic folder and the editor tails `saveFileInPlace` / `markFileDeleted` capture before their `await` and check after, plus a generation bump on every folder ever listed and a clear of the folder-load dedup map and the rehydrate set. **`loadList`'s agent/project/user switch runs the SAME fence before it swaps the context** — until 2026-09-04 only the broom fenced, so a read a tab click had started under the previous agent could land its file, and `activeFile`, under the new one. Preferences survive (`previewEditorsEnabled`, `diffViewMode`, `sidebarWidth`, `sidebarOpen`, `gitViewMode`, `searchMode`) and so does the active pair. Never a server call; `reconcileJobs` is deliberately not cleared (a running job's `finally` would delete the next job's entry and two passes would run at once — the epoch makes the running job inert instead).
49
+ - **The dock broom reaches these stores through the kernel port**: `installFilesystemHostComponents` subscribes `port.onClientStateReset` ONCE at install time with `resetFilesClientState(scope)`, which calls `resetClientState(scope)` on every mounted window the scope reaches and then prunes the persisted record: `instance` (one window cleaned) drops that window's projection; `widget` (one window destroyed) drops it and the legacy context of its `(user, project, agent)`; `agent` / `project` drop the legacy contexts and the window projections of their scope — the signed-in user's only, never another project's or a sibling window's. The store action matches the scope against its ACTIVE `(activeProjectId, activeAgentId)` pair (`instance` / `widget`: also the `filesystem` widget type AND its own `windowId`; anything else is a no-op), applies `contextResetPatch()` (which includes an empty `tabMeta`) — so the persisted allowlist (`openFiles`/`activeFile`/`tabMeta`) is written EMPTY and a persisted tab cannot re-run the read/list race on the next mount — and fences everything in flight through the ONE `fenceInFlightWriters` action — the store's own `resetEpoch` that the `openFile` read, a reconcile pass's `set()`, `createNode`'s optimistic folder and the editor tails `saveFileInPlace` / `markFileDeleted` capture before their `await` and check after, plus a generation bump on every folder ever listed and a clear of the folder-load dedup map and the rehydrate set. **`loadList`'s agent/project/user switch runs the SAME fence before it swaps the context** — until 2026-09-04 only the broom fenced, so a read a tab click had started under the previous agent could land its file, and `activeFile`, under the new one. Preferences survive (`previewEditorsEnabled`, `diffViewMode`, `sidebarWidth`, `sidebarOpen`, `gitViewMode`, `searchMode`) and so does the active pair. Never a server call; `reconcileJobs` is deliberately not cleared (a running job's `finally` would delete the next job's entry and two passes would run at once — the epoch makes the running job inert instead).
47
50
 
48
51
  ## Security & scope
49
52
 
@@ -53,9 +56,9 @@ Two guards make this safe against the races a plain re-list would lose:
53
56
 
54
57
  ## Gotchas
55
58
 
56
- - `searchSliceSeq` and `filenameSearchSeq` are module-level monotonic counters used to discard stale search responses. Removing them re-introduces the classic "old query results overwrite new ones" race.
59
+ - The search and git slices keep monotonic per-store sequences (`searchSeq`, `gitStatusSeq`, `gitBranchSeq`) that discard stale responses. Removing them re-introduces the classic "old query results overwrite new ones" race; sharing them across stores would let one window's request supersede another's.
57
60
  - `fileKey` returns the URI as the canonical key. URIs MUST be canonicalised (`canonicalUri` in `src/usecases/list.ts`) before lookup — three-slash URIs (`brain:///foo`) collapse to two-slash form.
58
- - `loadList` uses an in-flight map keyed by `(agentId, projectId, withCounts)` to deduplicate concurrent calls. Calling `refresh` while a load is pending no-ops the second call.
61
+ - `loadList` uses a per-store in-flight map keyed by `(agentId, projectId, withCounts)` to deduplicate concurrent calls. Calling `refresh` while a load is pending no-ops the second call.
59
62
  - **`/modify` answers HTTP 200 for a REJECTED operation** — the outcome is in `ok`, not the status code. Every mutation action branches on `ok === false` before applying an optimistic state, and reports through `organizeError` (a dismissible banner above the tree). `warning: 'moved_with_source_retained'` is a *partial success*: the copy landed, the source delete did not, and BOTH copies exist on purpose.
60
63
  - A **folder** move or delete is decomposed server-side into a recursive copy + a delete of the old subtree. The reconcile only inspects DIRECT children, so the store prunes by URI PREFIX for those two cases — a parent-only reconcile would leave the old subtree's interior behind forever.
61
64
  - **`removeFilePath` purges a URI from EVERY surface** — node, `openFiles`, `activeFile`, `tabMeta` and the content/baseline/error/prev side maps — and it must not require the node to exist. `tabMeta`/`openFiles` are PERSISTED, so a tab pointing at a deleted file outlives the node; gating the purge on `files[uri]` made it a no-op in exactly that case, and the tab could never close itself.
@@ -69,4 +72,5 @@ Two guards make this safe against the races a plain re-list would lose:
69
72
  - `test/store/reconcileSourceFolders.test.ts` — the external-event reconcile path.
70
73
  - `test/store/visibleReconcile.test.ts` — visible-only wide passes, collapse → stale → one narrow authoritative listing on re-open, and the bound.
71
74
  - `test/store/syncCompletedRelist.test.tsx` — the real SSE hook + store: a zero-count `sync:completed` lists nothing, and one event burst's list-request count.
75
+ - `test/store/filesWindows.test.ts` — independent windows on the one owner: two same-agent windows keep distinct tabs and send their own client, interleaved writes survive a reload, an old record is read as it is and not rewritten until a real change, no content is persisted, a reset reaches exactly its scope, and a late answer never lands in the next identity's store.
72
76
  - `test/ui/` covers the rest of the store actions: list merge with the source registry, SSE event reduction, search slice debouncing, and the eviction cap.