@neuralis/brain-core 0.2.0 → 0.3.0

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 (123) hide show
  1. package/README.md +8 -3
  2. package/app/README.md +1 -1
  3. package/app/filesystem/README.md +3 -3
  4. package/app/filesystem/api/README.md +4 -4
  5. package/app/filesystem/api/index.ts +144 -139
  6. package/app/filesystem/domain/types.ts +12 -0
  7. package/app/filesystem/hooks/README.md +5 -5
  8. package/app/filesystem/hooks/useDiscoverableSources.ts +4 -2
  9. package/app/filesystem/hooks/useFilesystemSSE.ts +31 -23
  10. package/app/filesystem/hooks/useGitChangesInventory.ts +4 -2
  11. package/app/filesystem/host/README.md +3 -2
  12. package/app/filesystem/host/installFilesystemHostComponents.tsx +22 -9
  13. package/app/filesystem/store/README.md +12 -8
  14. package/app/filesystem/store/filesystemStore.ts +444 -163
  15. package/app/filesystem/store/gitSlice.ts +6 -6
  16. package/app/filesystem/store/listSlice.ts +9 -6
  17. package/app/filesystem/store/searchSlice.ts +3 -3
  18. package/app/filesystem/ui/FileEditor.tsx +90 -39
  19. package/app/filesystem/ui/FileTree.tsx +10 -9
  20. package/app/filesystem/ui/GitCommitChangesView.tsx +5 -3
  21. package/app/filesystem/ui/GitCommitGraph.tsx +6 -5
  22. package/app/filesystem/ui/GitControlPanel.tsx +14 -14
  23. package/app/filesystem/ui/GitDiffView.tsx +14 -6
  24. package/app/filesystem/ui/GitFileDiff.tsx +22 -15
  25. package/app/filesystem/ui/PendingChangesPanel.tsx +108 -44
  26. package/app/filesystem/ui/PendingReviewDialog.tsx +8 -5
  27. package/app/filesystem/ui/README.md +9 -9
  28. package/app/filesystem/ui/ReembedDialog.tsx +4 -2
  29. package/app/filesystem/ui/SourcesPanel.tsx +26 -28
  30. package/app/filesystem/ui/Toolbar.tsx +5 -4
  31. package/app/filesystem/ui/VectorHealthPanel.tsx +48 -17
  32. package/dist/app/host.js +46 -46
  33. package/dist/src/config/env.d.ts +4 -0
  34. package/dist/src/config/env.d.ts.map +1 -1
  35. package/dist/src/config/env.js +13 -0
  36. package/dist/src/config/env.js.map +1 -1
  37. package/dist/src/connectors/LocalDiskConnector.d.ts +9 -1
  38. package/dist/src/connectors/LocalDiskConnector.d.ts.map +1 -1
  39. package/dist/src/connectors/LocalDiskConnector.js +51 -2
  40. package/dist/src/connectors/LocalDiskConnector.js.map +1 -1
  41. package/dist/src/domain/healthTypes.d.ts +1 -7
  42. package/dist/src/domain/healthTypes.d.ts.map +1 -1
  43. package/dist/src/domain/pendingChange.d.ts +31 -0
  44. package/dist/src/domain/pendingChange.d.ts.map +1 -1
  45. package/dist/src/domain/pendingChange.js.map +1 -1
  46. package/dist/src/routes/health.js +2 -3
  47. package/dist/src/routes/health.js.map +1 -1
  48. package/dist/src/routes/pending.d.ts +7 -7
  49. package/dist/src/routes/pending.d.ts.map +1 -1
  50. package/dist/src/routes/pending.js +21 -21
  51. package/dist/src/routes/pending.js.map +1 -1
  52. package/dist/src/services/FilesystemService.d.ts +13 -61
  53. package/dist/src/services/FilesystemService.d.ts.map +1 -1
  54. package/dist/src/services/FilesystemService.js +47 -189
  55. package/dist/src/services/FilesystemService.js.map +1 -1
  56. package/dist/src/services/FilesystemUiService.d.ts +33 -1
  57. package/dist/src/services/FilesystemUiService.d.ts.map +1 -1
  58. package/dist/src/services/FilesystemUiService.js +50 -1
  59. package/dist/src/services/FilesystemUiService.js.map +1 -1
  60. package/dist/src/store/PendingChangeStore.d.ts +3 -0
  61. package/dist/src/store/PendingChangeStore.d.ts.map +1 -1
  62. package/dist/src/store/PendingChangeStore.js +84 -25
  63. package/dist/src/store/PendingChangeStore.js.map +1 -1
  64. package/dist/src/usecases/orphanedPending.d.ts +2 -2
  65. package/dist/src/usecases/orphanedPending.d.ts.map +1 -1
  66. package/dist/src/usecases/orphanedPending.js +5 -3
  67. package/dist/src/usecases/orphanedPending.js.map +1 -1
  68. package/dist/src/usecases/pendingListing.d.ts +34 -0
  69. package/dist/src/usecases/pendingListing.d.ts.map +1 -0
  70. package/dist/src/usecases/pendingListing.js +244 -0
  71. package/dist/src/usecases/pendingListing.js.map +1 -0
  72. package/dist/src/usecases/vectorMaintenance.d.ts +6 -9
  73. package/dist/src/usecases/vectorMaintenance.d.ts.map +1 -1
  74. package/dist/src/usecases/vectorMaintenance.js +4 -13
  75. package/dist/src/usecases/vectorMaintenance.js.map +1 -1
  76. package/dist/src/vector/InMemoryVectorIndex.d.ts.map +1 -1
  77. package/dist/src/vector/InMemoryVectorIndex.js +30 -9
  78. package/dist/src/vector/InMemoryVectorIndex.js.map +1 -1
  79. package/package.json +27 -5
  80. package/rules/brain-rules.md +1 -1
  81. package/skills/create-package/SKILL.md +3 -3
  82. package/skills/create-package/references/config-and-credentials.md +1 -1
  83. package/skills/create-package/references/manifest-and-discovery.md +1 -1
  84. package/skills/create-package/references/runtime-and-build.md +1 -1
  85. package/skills/create-package/references/skills-and-context.md +25 -12
  86. package/skills/create-package/references/surfaces-and-design.md +7 -5
  87. package/skills/create-package/references/test-and-ship.md +9 -16
  88. package/skills/create-package/scripts/check-package.sh +47 -1
  89. package/skills/create-package/scripts/verify-loaded.sh +18 -27
  90. package/skills/git/SKILL.md +27 -29
  91. package/skills/manage-files/SKILL.md +37 -32
  92. package/skills/manage-files/scripts/upload.sh +7 -9
  93. package/skills/manage-files/scripts/write.sh +2 -6
  94. package/skills/manage-sources/SKILL.md +93 -95
  95. package/skills/manage-sources/scripts/reembed.sh +2 -6
  96. package/skills/manage-uri-policy/SKILL.md +30 -58
  97. package/app/filesystem/hooks/useContainerWidth.ts +0 -39
  98. package/skills/git/scripts/git.sh +0 -48
  99. package/skills/manage-files/scripts/approve.sh +0 -9
  100. package/skills/manage-files/scripts/list.sh +0 -9
  101. package/skills/manage-files/scripts/pending.sh +0 -23
  102. package/skills/manage-files/scripts/raw.sh +0 -11
  103. package/skills/manage-files/scripts/read.sh +0 -9
  104. package/skills/manage-files/scripts/search.sh +0 -15
  105. package/skills/manage-sources/scripts/browse.sh +0 -12
  106. package/skills/manage-sources/scripts/cancel.sh +0 -14
  107. package/skills/manage-sources/scripts/create.sh +0 -13
  108. package/skills/manage-sources/scripts/delete.sh +0 -17
  109. package/skills/manage-sources/scripts/discoverable.sh +0 -6
  110. package/skills/manage-sources/scripts/get.sh +0 -7
  111. package/skills/manage-sources/scripts/kind-schema.sh +0 -7
  112. package/skills/manage-sources/scripts/kinds.sh +0 -6
  113. package/skills/manage-sources/scripts/list.sh +0 -6
  114. package/skills/manage-sources/scripts/purge-index.sh +0 -18
  115. package/skills/manage-sources/scripts/reindex.sh +0 -13
  116. package/skills/manage-sources/scripts/reset.sh +0 -11
  117. package/skills/manage-sources/scripts/set-enabled.sh +0 -17
  118. package/skills/manage-sources/scripts/sync-status.sh +0 -14
  119. package/skills/manage-sources/scripts/sync.sh +0 -13
  120. package/skills/manage-sources/scripts/update.sh +0 -18
  121. package/skills/manage-uri-policy/scripts/manifests.sh +0 -20
  122. package/skills/manage-uri-policy/scripts/set-uri.sh +0 -66
  123. package/skills/manage-uri-policy/scripts/show-uri.sh +0 -23
@@ -1,7 +1,10 @@
1
- import { create } from 'zustand';
1
+ import { createContext, createElement, useContext, useLayoutEffect, useMemo, type ReactNode } from 'react';
2
+ import { useStore } from 'zustand';
2
3
  import { persist, createJSONStorage } from 'zustand/middleware';
4
+ import { createStore, type StoreApi as ZustandStoreApi } from 'zustand/vanilla';
3
5
  import type { ClientStateResetScope } from '@neuralis/package-system/contracts';
4
- import type { EditorTabMeta, FileNode, FileSystemState, FSWriteResponse, PersistedFilesContext, SidebarView, WorkspacePanelId } from '../domain/types';
6
+ import { createPackageApi, type PackageApi } from '@neuralis/package-system/client';
7
+ import type { EditorTabMeta, FileNode, FileSystemState, FSWriteResponse, PersistedFilesContext, PersistedFilesWindow, SidebarView, WorkspacePanelId } from '../domain/types';
5
8
  export type { SidebarView, WorkspacePanelId } from '../domain/types';
6
9
  import { countPendingChanges, readFiles, uploadFile, writeFile, writeRefusalMessage } from '../api';
7
10
  import {
@@ -42,44 +45,6 @@ import { gitSliceInitial, createGitSlice, type GitSliceState, type GitSliceActio
42
45
  */
43
46
  export const FILES_LRU_HARD_CAP = 50_000;
44
47
 
45
- /**
46
- * Bumped by `fenceInFlightWriters` — the ONE fence BOTH reset paths run before
47
- * their wipe: `resetClientState` (the dock broom) and `loadList`'s agent/project
48
- * switch. Every async writer that materialises a node or content AFTER an await
49
- * (the `openFile` read, a reconcile pass's `set`, the optimistic folder in
50
- * `createNode`, the editor tails `saveFileInPlace` / `markFileDeleted`) captures
51
- * it BEFORE the await and writes only when it still matches — a read, listing
52
- * or mutation answer that resolves after the reset must not put a tab, its
53
- * folder chain, its content or its parent's dirty flag into a store that now
54
- * belongs to another context. Module-level like the generation map: the reset
55
- * is a store-wide event. Until 2026-09-04 only the broom fenced; the switch
56
- * wiped without fencing, so a read a tab click had started under the previous
57
- * agent could land its file — and `activeFile` — under the new one.
58
- *
59
- * Writers that need NO epoch (documented, not oversight): `moveFileToFolder` /
60
- * `renameNode` / `pasteNodes` / `deleteNodes` / `upload*` are no-ops on an empty
61
- * store (`replaceFilePath`, `prunePrefix`, `patchFileNode`, the `openFiles.map`
62
- * all find nothing) or set a hint flag; a late `loadList` answer writes
63
- * `brain://` + `sources`, which the remounted widget re-requests anyway; late
64
- * `runSearch` / `loadGitStatus` / `loadGitBranches` answers are overwritten on
65
- * the next use; `ensureFolderLoaded`'s superseded and catch branches write
66
- * `hydrationState[uri]` only (`'idle'` / `'error'` for a folder row that is gone);
67
- * the SSE handlers are synchronous and die with the widget; `applyNav` /
68
- * `autoExpandToFile` write synchronously before any await.
69
- */
70
- let resetEpoch = 0;
71
- /** The newest `refreshPendingCount` call — an older answer never overwrites a newer one. */
72
- let pendingCountSeq = 0;
73
- /**
74
- * Tabs whose rehydrate read is in flight — a second activation must not issue a
75
- * second read. Keyed by uri, so the fence CLEARS it: the same uri under another
76
- * context is another file, and a stale key would suppress the new context's read
77
- * for good (the old answer is fenced out, the baseline never arrives).
78
- */
79
- const rehydrateInFlight = new Set<string>();
80
-
81
- const liveFolderLoadInFlight = new Map<string, Promise<void>>();
82
-
83
48
  /**
84
49
  * Per-source reconcile job. The guard used to DROP a request that arrived while a
85
50
  * reconcile was running, which made every mutation tail unsafe: the drop silently
@@ -89,29 +54,78 @@ const liveFolderLoadInFlight = new Map<string, Promise<void>>();
89
54
  * and the caller awaits the whole chain.
90
55
  */
91
56
  type ReconcileJob = { rerun: boolean; wide: boolean; extra: Set<string>; promise: Promise<void> };
92
- const reconcileJobs = new Map<string, ReconcileJob>();
93
57
 
94
58
  /** Bound on coalesced reruns so a hot event stream can't spin the loop forever. */
95
59
  const MAX_RECONCILE_PASSES = 3;
96
60
 
97
61
  /**
98
- * Monotonic list-generation token PER FOLDER (not per source). Two listings of the
99
- * SAME folder can be in flight at once — `reconcileSourceFolders` lists its targets
100
- * concurrently and `ensureFolderLoaded` can overlap it — so a slow response must not
101
- * overwrite a newer one. Every list issue bumps its folder's counter; a response
102
- * whose token is no longer current is discarded. A per-SOURCE token would not catch
103
- * this: both listings share the source.
62
+ * The asynchronous fences of ONE Files store — one set per window, created with
63
+ * it (`createFilesystemStore`). Two windows never share a fence: a reset or a
64
+ * context switch is that window's event, so it fences only that window's
65
+ * in-flight work, and a sibling window's listing can never supersede this one's.
66
+ *
67
+ * - `resetEpoch` — bumped by `fenceInFlightWriters`, the ONE fence BOTH reset
68
+ * paths run before their wipe: `resetClientState` (the dock broom) and
69
+ * `loadList`'s context switch. Every async writer that materialises a node or
70
+ * content AFTER an await (the `openFile` read, a reconcile pass's `set`, the
71
+ * optimistic folder in `createNode`, the editor tails `saveFileInPlace` /
72
+ * `markFileDeleted`) captures it BEFORE the await and writes only when it still
73
+ * matches — a read, listing or mutation answer that resolves after the reset
74
+ * must not put a tab, its folder chain, its content or its parent's dirty flag
75
+ * into a store that now belongs to another context. Writers that need NO epoch
76
+ * (documented, not oversight): `moveFileToFolder` / `renameNode` / `pasteNodes`
77
+ * / `deleteNodes` / `upload*` are no-ops on an empty store (`replaceFilePath`,
78
+ * `prunePrefix`, `patchFileNode`, the `openFiles.map` all find nothing) or set
79
+ * a hint flag; a late `loadList` answer writes `brain://` + `sources`, which the
80
+ * remounted widget re-requests anyway; late `runSearch` / `loadGitStatus` /
81
+ * `loadGitBranches` answers are overwritten on the next use;
82
+ * `ensureFolderLoaded`'s superseded and catch branches write
83
+ * `hydrationState[uri]` only; the SSE handlers are synchronous and die with the
84
+ * widget; `applyNav` / `autoExpandToFile` write synchronously before any await.
85
+ * - `pendingCountSeq` — the newest `refreshPendingCount` call; an older answer
86
+ * never overwrites a newer one.
87
+ * - `rehydrateInFlight` — tabs whose rehydrate read is in flight, so a second
88
+ * activation issues no second read. Keyed by uri, so the fence CLEARS it: the
89
+ * same uri under another context is another file, and a stale key would
90
+ * suppress the new context's read for good.
91
+ * - `liveFolderLoadInFlight` — the folder-listing dedup map.
92
+ * - `reconcileJobs` — the per-source coalescer (`ReconcileJob`).
93
+ * - `folderGeneration` — a monotonic list-generation token PER FOLDER (not per
94
+ * source). Two listings of the SAME folder can be in flight at once —
95
+ * `reconcileSourceFolders` lists its targets concurrently and
96
+ * `ensureFolderLoaded` can overlap it — so a slow response must not overwrite a
97
+ * newer one. Every list issue bumps its folder's counter; a response whose
98
+ * token is no longer current is discarded. A per-SOURCE token would not catch
99
+ * this: both listings share the source.
104
100
  */
105
- const folderGeneration = new Map<string, number>();
101
+ type StoreFences = {
102
+ resetEpoch: number;
103
+ pendingCountSeq: number;
104
+ rehydrateInFlight: Set<string>;
105
+ liveFolderLoadInFlight: Map<string, Promise<void>>;
106
+ reconcileJobs: Map<string, ReconcileJob>;
107
+ folderGeneration: Map<string, number>;
108
+ };
106
109
 
107
- function nextFolderGeneration(folderKey: string): number {
108
- const gen = (folderGeneration.get(folderKey) ?? 0) + 1;
109
- folderGeneration.set(folderKey, gen);
110
+ function createStoreFences(): StoreFences {
111
+ return {
112
+ resetEpoch: 0,
113
+ pendingCountSeq: 0,
114
+ rehydrateInFlight: new Set<string>(),
115
+ liveFolderLoadInFlight: new Map<string, Promise<void>>(),
116
+ reconcileJobs: new Map<string, ReconcileJob>(),
117
+ folderGeneration: new Map<string, number>(),
118
+ };
119
+ }
120
+
121
+ function nextFolderGeneration(fences: StoreFences, folderKey: string): number {
122
+ const gen = (fences.folderGeneration.get(folderKey) ?? 0) + 1;
123
+ fences.folderGeneration.set(folderKey, gen);
110
124
  return gen;
111
125
  }
112
126
 
113
- function isCurrentFolderGeneration(folderKey: string, gen: number): boolean {
114
- return folderGeneration.get(folderKey) === gen;
127
+ function isCurrentFolderGeneration(fences: StoreFences, folderKey: string, gen: number): boolean {
128
+ return fences.folderGeneration.get(folderKey) === gen;
115
129
  }
116
130
 
117
131
  /** `pendingReviewPaths` transitions the shared mapper observed, applied by the caller. */
@@ -280,8 +294,8 @@ function withPendingDelta(
280
294
  return out;
281
295
  }
282
296
 
283
- /** Minimal store handle the module-level reconcile engine needs. */
284
- type StoreApi = { get: () => any; set: (fn: (s: any) => any) => void };
297
+ /** The store handle the module-level reconcile engine needs: state, its window's fences and request client. */
298
+ type StoreApi = { get: () => any; set: (fn: (s: any) => any) => void; fences: StoreFences; client: PackageApi };
285
299
 
286
300
  /**
287
301
  * How many folder listings one reconcile pass has in flight. Each costs a server
@@ -399,7 +413,7 @@ async function runReconcilePass(
399
413
  // Captured at pass START: the per-folder generation guard sits in the worker,
400
414
  // so a listing set that landed BEFORE a broom would still be written by the
401
415
  // `set()` below unless the pass itself is fenced.
402
- const epoch = resetEpoch;
416
+ const epoch = api.fences.resetEpoch;
403
417
  const state0 = api.get();
404
418
  const lf = state0.loadedFolders || {};
405
419
  const targetSet = new Set<string>();
@@ -442,12 +456,12 @@ async function runReconcilePass(
442
456
  // walk plus an index pass. A tree-wide Refresh across several sources multiplies
443
457
  // that again.
444
458
  const listed = await mapWithLimit(targets, RECONCILE_LIST_CONCURRENCY, async (folderKey) => {
445
- const gen = nextFolderGeneration(folderKey);
459
+ const gen = nextFolderGeneration(api.fences, folderKey);
446
460
  const issuedAt = Date.now();
447
461
  try {
448
- const res = await resolveList(agentId, projectId ?? undefined, { uri: folderKey, recursive: false });
462
+ const res = await resolveList(agentId, projectId ?? undefined, { uri: folderKey, recursive: false }, api.client);
449
463
  // A newer listing of the SAME folder superseded this one → drop it whole.
450
- if (!isCurrentFolderGeneration(folderKey, gen)) return { folderKey, res: null, issuedAt, ok: false as const };
464
+ if (!isCurrentFolderGeneration(api.fences, folderKey, gen)) return { folderKey, res: null, issuedAt, ok: false as const };
451
465
  return { folderKey, res, issuedAt, ok: true as const };
452
466
  } catch {
453
467
  return { folderKey, res: null, issuedAt, ok: false as const };
@@ -455,7 +469,7 @@ async function runReconcilePass(
455
469
  });
456
470
 
457
471
  api.set((state: any) => {
458
- if (epoch !== resetEpoch) return state; // the broom emptied the store mid-pass → inert
472
+ if (epoch !== api.fences.resetEpoch) return state; // the broom emptied the store mid-pass → inert
459
473
  const next = { ...state.files };
460
474
  const ts = Date.now();
461
475
  const pendingDelta: ListedPendingDelta = { add: new Set(), remove: new Set() };
@@ -612,7 +626,7 @@ async function runSourceReconcile(
612
626
  const { agentId, projectId, source, targets } = params;
613
627
  const wide = params.wide !== false;
614
628
  if (!agentId || !source) return;
615
- const existing = reconcileJobs.get(source);
629
+ const existing = api.fences.reconcileJobs.get(source);
616
630
  if (existing) {
617
631
  existing.rerun = true;
618
632
  if (wide) existing.wide = true;
@@ -621,7 +635,7 @@ async function runSourceReconcile(
621
635
  return;
622
636
  }
623
637
  const job: ReconcileJob = { rerun: false, wide, extra: new Set(targets ?? []), promise: Promise.resolve() };
624
- reconcileJobs.set(source, job);
638
+ api.fences.reconcileJobs.set(source, job);
625
639
  job.promise = (async () => {
626
640
  try {
627
641
  for (let pass = 0; pass < MAX_RECONCILE_PASSES; pass++) {
@@ -634,7 +648,7 @@ async function runSourceReconcile(
634
648
  if (!job.rerun) break;
635
649
  }
636
650
  } finally {
637
- reconcileJobs.delete(source);
651
+ api.fences.reconcileJobs.delete(source);
638
652
  }
639
653
  })();
640
654
  await job.promise;
@@ -837,9 +851,11 @@ interface FilesystemStore extends FileSystemState, SearchSliceActions, ListSlice
837
851
  refreshTree: (params: { agentId: string; projectId: string | null }) => Promise<void>;
838
852
  /**
839
853
  * The dock broom's reach into this store (host → kernel port
840
- * `onClientStateReset` → here). Matches the scope against the ACTIVE
841
- * `(activeProjectId, activeAgentId)` pair (widget level: only the
842
- * `filesystem` widget type); a non-matching scope is a no-op. A match applies
854
+ * `onClientStateReset` → `resetFilesClientState` → here). Matches the scope
855
+ * against the ACTIVE `(activeProjectId, activeAgentId)` pair; the `instance`
856
+ * and `widget` levels also need the `filesystem` widget type AND this store's
857
+ * own `windowId` — one window's clean or close never empties a sibling
858
+ * window, of the same agent included. A non-matching scope is a no-op. A match applies
843
859
  * the ONE `contextResetPatch` (the same wipe an agent/project switch does),
844
860
  * clears the persisted tab strip (`tabMeta` included — the allowlist is written
845
861
  * EMPTY, so a persisted `activeFile` cannot re-run the read/list race on the
@@ -856,6 +872,18 @@ interface FilesystemStore extends FileSystemState, SearchSliceActions, ListSlice
856
872
  * through `get()` — never a second copy.
857
873
  */
858
874
  fenceInFlightWriters: () => void;
875
+ /**
876
+ * The host's id of the Files window this store belongs to (`null` for a store
877
+ * no window owns). Identity like `activeAgentId`: no reset touches it, and it
878
+ * is never an authority source — it only addresses an `instance`/`widget` reset.
879
+ */
880
+ windowId: string | null;
881
+ /**
882
+ * The request client every API call of this store goes through — created with
883
+ * the store over the window's project and BOUND agent, so a request's headers
884
+ * never come from another mounted window (`api/index.ts`).
885
+ */
886
+ client: PackageApi;
859
887
  /** Dismiss the structural-operation banner (`organizeError`). */
860
888
  clearOrganizeError: () => void;
861
889
  /**
@@ -950,6 +978,7 @@ interface FilesystemStore extends FileSystemState, SearchSliceActions, ListSlice
950
978
  pendingReviewPaths: Set<string>;
951
979
  /** The caller's visible pending-review count (server `count_only`), shown on the Changes button. */
952
980
  pendingCount: number;
981
+ pendingCountState: 'exact' | 'partial' | 'stale';
953
982
  /** Re-read `pendingCount`; a newer call or another active context drops the answer. */
954
983
  refreshPendingCount: (params: { agentId: string; projectId: string | null }) => Promise<void>;
955
984
 
@@ -1078,7 +1107,11 @@ function readStoredSidebarWidth(): number {
1078
1107
  }
1079
1108
  }
1080
1109
 
1081
- /** The localStorage payload (v2): prefs, the active triple, the context projections. */
1110
+ /**
1111
+ * The localStorage payload (v2): prefs, the active triple, the per-agent context
1112
+ * projections, and — OPTIONAL, absent until a window writes one — the per-window
1113
+ * projections keyed by the host's window id (`PersistedFilesWindow`).
1114
+ */
1082
1115
  type PersistedFilesShape = {
1083
1116
  previewEditorsEnabled: boolean;
1084
1117
  diffViewMode: DiffViewMode;
@@ -1086,9 +1119,39 @@ type PersistedFilesShape = {
1086
1119
  activeAgentId: string | null;
1087
1120
  activeProjectId: string | null;
1088
1121
  contexts: Record<string, PersistedFilesContext>;
1122
+ windows?: Record<string, PersistedFilesWindow>;
1089
1123
  };
1090
1124
 
1091
- export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) => ({
1125
+ /** What a new store starts from: its window, its request client, the preferences and the seeded view. */
1126
+ export type FilesystemStoreInit = {
1127
+ /** The request client every API call of this store goes through (see `FilesystemStore.client`). */
1128
+ client: PackageApi;
1129
+ windowId?: string | null;
1130
+ userId?: string | null;
1131
+ projectId?: string | null;
1132
+ agentId?: string | null;
1133
+ previewEditorsEnabled?: boolean;
1134
+ diffViewMode?: DiffViewMode;
1135
+ /** The window's persisted view — its own projection (a new window has none and starts from the defaults). */
1136
+ projection?: Partial<PersistedFilesContext>;
1137
+ };
1138
+
1139
+ export type FilesystemStoreApi = ZustandStoreApi<FilesystemStore>;
1140
+
1141
+ /**
1142
+ * ONE Files store per window: a live view that persists nothing itself — the
1143
+ * ONE writer of `neuralis.filesystem.tabs` is `filesPersistence` below, which a
1144
+ * mounted window feeds through `mountFilesWindow`. Every asynchronous fence and
1145
+ * dedup map is created with the store (`StoreFences`, the list/search/git slice
1146
+ * closures), so two windows — of two agents, or of the same agent — never fence,
1147
+ * supersede or dedup each other.
1148
+ */
1149
+ export function createFilesystemStore(init: FilesystemStoreInit): FilesystemStoreApi {
1150
+ const fences = createStoreFences();
1151
+ const client = init.client;
1152
+ return createStore<FilesystemStore>()((set, get) => ({
1153
+ windowId: init.windowId ?? null,
1154
+ client,
1092
1155
  files: {},
1093
1156
  folderBadges: {},
1094
1157
  fileContentByPath: {},
@@ -1098,17 +1161,17 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1098
1161
  openFiles: [],
1099
1162
  activeFile: null,
1100
1163
  tabMeta: {},
1101
- activeUserId: null,
1164
+ activeUserId: init.userId ?? null,
1102
1165
  contextStateByKey: {},
1103
- previewEditorsEnabled: false,
1166
+ previewEditorsEnabled: init.previewEditorsEnabled ?? false,
1104
1167
  // Nothing is pre-expanded — under lazy hydration an expanded entry is a FETCH,
1105
1168
  // and every source branch (including `brain://`) starts collapsed.
1106
1169
  expandedFolders: new Set<string>(),
1107
1170
  sidebarOpen: true,
1108
1171
  sidebarWidth: readStoredSidebarWidth(),
1109
1172
  sidebarView: 'tree',
1110
- activeAgentId: null,
1111
- activeProjectId: null,
1173
+ activeAgentId: init.agentId ?? null,
1174
+ activeProjectId: init.projectId ?? null,
1112
1175
  sources: [],
1113
1176
  sourcesLoading: true,
1114
1177
  activeSource: null,
@@ -1119,7 +1182,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1119
1182
  activeWorkspaceTab: 'file',
1120
1183
  pendingSourceSuggestion: null,
1121
1184
  vectorHealthOpen: false,
1122
- diffViewMode: 'auto',
1185
+ diffViewMode: init.diffViewMode ?? 'auto',
1123
1186
  pendingPanelFilter: {},
1124
1187
  // Nothing is hydrated at boot. `brain://` is EXPANDED by default (below) and the
1125
1188
  // tree's hydration effect lists it — marking it "loaded" here would suppress that
@@ -1132,6 +1195,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1132
1195
  snapshotError: undefined,
1133
1196
  pendingReviewPaths: new Set<string>(),
1134
1197
  pendingCount: 0,
1198
+ pendingCountState: 'stale',
1135
1199
 
1136
1200
  ...searchSliceInitial,
1137
1201
  ...createSearchSlice(set as any, get),
@@ -1269,28 +1333,28 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1269
1333
  // Enumerated once, then hidden — no reconcile listed it off screen, so its rows
1270
1334
  // may be stale. ONE authoritative listing of exactly this folder (absence
1271
1335
  // applies), never a re-list of the whole visible tree.
1272
- await runSourceReconcile({ get, set }, { agentId, projectId, source: sourceFromUri(uri), targets: [uri], wide: false });
1336
+ await runSourceReconcile({ get, set, fences, client }, { agentId, projectId, source: sourceFromUri(uri), targets: [uri], wide: false });
1273
1337
  return;
1274
1338
  }
1275
1339
 
1276
- const existing = liveFolderLoadInFlight.get(uri);
1340
+ const existing = fences.liveFolderLoadInFlight.get(uri);
1277
1341
  if (existing) { await existing; return; }
1278
1342
 
1279
1343
  set((state: any) => ({ hydrationState: { ...(state.hydrationState || {}), [uri]: 'loading' as const } }));
1280
1344
 
1281
1345
  const task = (async () => {
1282
- const gen = nextFolderGeneration(uri);
1346
+ const gen = nextFolderGeneration(fences, uri);
1283
1347
  const issuedAt = Date.now();
1284
1348
  try {
1285
1349
  // recursive:false → single `readdir` on the connector, ~50ms even on
1286
1350
  // million-file subtrees. Replaces the previous full-tree snapshot call.
1287
- const res = await resolveList(agentId, projectId ?? undefined, { uri, recursive: false });
1351
+ const res = await resolveList(agentId, projectId ?? undefined, { uri, recursive: false }, client);
1288
1352
  // Superseded by a newer listing of the same folder (a concurrent reconcile
1289
1353
  // or a second expand) → drop this response instead of overwriting. Restore
1290
1354
  // the fetch state on the way out: returning bare left `loading` pinned
1291
1355
  // forever, and the refresh control is `disabled={loading}` — the one control
1292
1356
  // that could recover would have been disabled by the state it must clear.
1293
- if (!isCurrentFolderGeneration(uri, gen)) {
1357
+ if (!isCurrentFolderGeneration(fences, uri, gen)) {
1294
1358
  set((state: any) => ({
1295
1359
  hydrationState: {
1296
1360
  ...(state.hydrationState || {}),
@@ -1327,8 +1391,8 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1327
1391
  }
1328
1392
  })();
1329
1393
 
1330
- liveFolderLoadInFlight.set(uri, task);
1331
- try { await task; } finally { liveFolderLoadInFlight.delete(uri); }
1394
+ fences.liveFolderLoadInFlight.set(uri, task);
1395
+ try { await task; } finally { fences.liveFolderLoadInFlight.delete(uri); }
1332
1396
  },
1333
1397
 
1334
1398
  clearOrganizeError: () => set(() => ({ organizeError: null })),
@@ -1345,31 +1409,32 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1345
1409
  }),
1346
1410
 
1347
1411
  async reconcileSourceFolders({ agentId, projectId, source }) {
1348
- await runSourceReconcile({ get, set }, { agentId, projectId, source });
1412
+ await runSourceReconcile({ get, set, fences, client }, { agentId, projectId, source });
1349
1413
  },
1350
1414
 
1351
1415
  fenceInFlightWriters() {
1352
- resetEpoch += 1;
1353
- for (const key of Array.from(folderGeneration.keys())) nextFolderGeneration(key);
1416
+ fences.resetEpoch += 1;
1417
+ for (const key of Array.from(fences.folderGeneration.keys())) nextFolderGeneration(fences, key);
1354
1418
  // The dedup maps only; the in-flight tasks' own `finally` deletes a missing
1355
1419
  // key as a no-op. `reconcileJobs` is deliberately NOT cleared: a running job's
1356
1420
  // `finally` would then delete the NEXT job's entry and two passes would run
1357
1421
  // concurrently — the job runs to completion and the epoch makes it inert.
1358
- liveFolderLoadInFlight.clear();
1359
- rehydrateInFlight.clear();
1422
+ fences.liveFolderLoadInFlight.clear();
1423
+ fences.rehydrateInFlight.clear();
1360
1424
  },
1361
1425
 
1362
1426
  resetClientState(scope) {
1363
1427
  const s = get();
1364
1428
  if (!s.activeProjectId || scope.projectId !== s.activeProjectId) return;
1365
1429
  if (scope.level !== 'project' && scope.agentId !== s.activeAgentId) return;
1366
- if (scope.level === 'widget' && scope.widgetType !== FILESYSTEM_WIDGET_TYPE) return;
1430
+ if ((scope.level === 'widget' || scope.level === 'instance')
1431
+ && (scope.widgetType !== FILESYSTEM_WIDGET_TYPE || scope.instanceId !== s.windowId)) return;
1367
1432
  // Fence FIRST, then wipe: a read/listing/reconcile that resolves after this
1368
1433
  // point finds a stale epoch (or a stale folder generation) and writes nothing.
1369
1434
  get().fenceInFlightWriters();
1370
1435
  // The broom is the ONE writer that drops a stashed context — its own scope
1371
- // only: the widget/agent levels drop this context, the project level every
1372
- // context of the project (this user's). Other projects are not its business.
1436
+ // only: the instance/widget/agent levels drop this context, the project level
1437
+ // every context of the project (this user's). Other projects are not its business.
1373
1438
  const prefix = `${s.activeUserId ?? ''}::${s.activeProjectId}::`;
1374
1439
  const own = contextKey(s.activeUserId, s.activeProjectId, s.activeAgentId ?? '');
1375
1440
  const kept: Record<string, Record<string, unknown>> = {};
@@ -1408,7 +1473,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1408
1473
  if (bySource.size === 0) return;
1409
1474
  await Promise.all(
1410
1475
  Array.from(bySource.entries()).map(([source, targets]) =>
1411
- runSourceReconcile({ get, set }, { agentId, projectId, source, targets }),
1476
+ runSourceReconcile({ get, set, fences, client }, { agentId, projectId, source, targets }),
1412
1477
  ),
1413
1478
  );
1414
1479
  },
@@ -1430,7 +1495,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1430
1495
  if (source) sources.add(source);
1431
1496
  }
1432
1497
  await Promise.all(
1433
- Array.from(sources).map((source) => runSourceReconcile({ get, set }, { agentId, projectId, source })),
1498
+ Array.from(sources).map((source) => runSourceReconcile({ get, set, fences, client }, { agentId, projectId, source })),
1434
1499
  );
1435
1500
  },
1436
1501
 
@@ -1445,8 +1510,8 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1445
1510
  const agentId = s.activeAgentId;
1446
1511
  if (!uri || !agentId) return;
1447
1512
  if (hasCompletedRead(s, uri)) return;
1448
- if (rehydrateInFlight.has(uri)) return;
1449
- rehydrateInFlight.add(uri);
1513
+ if (fences.rehydrateInFlight.has(uri)) return;
1514
+ fences.rehydrateInFlight.add(uri);
1450
1515
  try {
1451
1516
  // A tab from another agent's source reads through that agent, as the tree
1452
1517
  // open did — never through a SENTINEL (`__system__` is the writer the
@@ -1459,7 +1524,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1459
1524
  // rehydrate is not a user open.
1460
1525
  await s.openFile({ agentId, projectId: s.activeProjectId, uri, readAgentId, revealPane: false });
1461
1526
  } finally {
1462
- rehydrateInFlight.delete(uri);
1527
+ fences.rehydrateInFlight.delete(uri);
1463
1528
  }
1464
1529
  },
1465
1530
 
@@ -1679,8 +1744,8 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1679
1744
  // The server-side work completes whatever happens to the store; every store
1680
1745
  // write after an await is fenced — a context reset mid-save must not re-key,
1681
1746
  // fill, dirty-flag or re-open anything under the new context.
1682
- const epoch = resetEpoch;
1683
- const fenced = () => epoch !== resetEpoch;
1747
+ const epoch = fences.resetEpoch;
1748
+ const fenced = () => epoch !== fences.resetEpoch;
1684
1749
 
1685
1750
  let effectiveUri = fromUri;
1686
1751
  if (moved) {
@@ -1690,7 +1755,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1690
1755
  id: node.id,
1691
1756
  uri: fromUri,
1692
1757
  move: targetUri,
1693
- });
1758
+ }, client);
1694
1759
  if (!res || res.ok !== true) throw new Error(writeRefusalMessage(res, 'Move failed'));
1695
1760
  effectiveUri = targetUri;
1696
1761
  if (fenced()) return { uri: effectiveUri, moved, contentChanged: false };
@@ -1704,7 +1769,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1704
1769
  id: node.id,
1705
1770
  uri: effectiveUri,
1706
1771
  overwrite: { content },
1707
- });
1772
+ }, client);
1708
1773
  if (!res || res.ok !== true) throw new Error(writeRefusalMessage(res, 'Save failed'));
1709
1774
  if (fenced()) return { uri: effectiveUri, moved, contentChanged };
1710
1775
  // After a move the node lives under its NEW uri (`replaceFilePath` re-keys
@@ -1726,32 +1791,38 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1726
1791
 
1727
1792
  async refreshPendingCount({ agentId, projectId }) {
1728
1793
  if (!agentId) return;
1729
- const mine = ++pendingCountSeq;
1794
+ const mine = ++fences.pendingCountSeq;
1730
1795
  // A failed count keeps the last known number — the next event, focus or reconnect re-asks.
1731
- let count: number;
1732
- try { count = await countPendingChanges({ agentId, ...(projectId ? { projectId } : {}) }); } catch { return; }
1796
+ let count: Awaited<ReturnType<typeof countPendingChanges>>;
1797
+ try { count = await countPendingChanges({ agentId, ...(projectId ? { projectId } : {}) }, client); } catch {
1798
+ if (mine === fences.pendingCountSeq && get().activeAgentId === agentId && get().activeProjectId === projectId) set({ pendingCountState: 'stale' });
1799
+ return;
1800
+ }
1733
1801
  // No reset-epoch fence: the count is the context's own server answer, so one
1734
1802
  // that resolves after a reset of the SAME context is still right; the context
1735
1803
  // check below is what keeps another context's number out.
1736
- if (mine !== pendingCountSeq) return;
1804
+ if (mine !== fences.pendingCountSeq) return;
1737
1805
  const s = get();
1738
1806
  if (s.activeAgentId !== agentId || s.activeProjectId !== projectId) return;
1739
- set(() => ({ pendingCount: count }));
1807
+ if (count.unavailable?.includes('journal') && count.unavailable.includes('vector')) {
1808
+ set({ pendingCountState: 'stale' }); return;
1809
+ }
1810
+ set(() => ({ pendingCount: count.count, pendingCountState: count.exact ? 'exact' : 'partial' }));
1740
1811
  },
1741
1812
 
1742
1813
  async markFileDeleted({ agentId, projectId, node }) {
1743
- const epoch = resetEpoch;
1814
+ const epoch = fences.resetEpoch;
1744
1815
  const res = await writeFile({
1745
1816
  agentId,
1746
1817
  ...(projectId ? { projectId } : {}),
1747
1818
  id: node.id,
1748
1819
  uri: node.uri,
1749
1820
  delete: true,
1750
- });
1821
+ }, client);
1751
1822
  if (!res || res.ok !== true) throw new Error(writeRefusalMessage(res, 'Delete failed'));
1752
1823
  // The reconcile flags an unloaded source dirty — not a no-op on a store the
1753
1824
  // reset just handed to another context.
1754
- if (epoch !== resetEpoch) return;
1825
+ if (epoch !== fences.resetEpoch) return;
1755
1826
  await get().reconcileFolders({ agentId, projectId, folders: [folderUri(node.uri)] });
1756
1827
  },
1757
1828
 
@@ -1772,7 +1843,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1772
1843
  id,
1773
1844
  uri: fromUri,
1774
1845
  move: destinationUri,
1775
- });
1846
+ }, client);
1776
1847
  } catch (e) {
1777
1848
  console.error('[Filesystem] move failed', fromUri, e);
1778
1849
  set(() => ({ organizeError: 'Move failed' }));
@@ -1801,7 +1872,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1801
1872
  // of the old subtree would stay in the store forever. Prune by prefix (the
1802
1873
  // pattern `renameNode` already proved) and force-relist the destination.
1803
1874
  if (!retained) {
1804
- prunePrefix({ get, set }, fromUri);
1875
+ prunePrefix({ get, set, fences, client }, fromUri);
1805
1876
  }
1806
1877
  await get().ensureFolderLoaded({ agentId, projectId, uri: toFolderUri, force: true });
1807
1878
  } else {
@@ -1842,7 +1913,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1842
1913
  id,
1843
1914
  uri,
1844
1915
  move: isFolder ? destinationUri : trimmed,
1845
- });
1916
+ }, client);
1846
1917
  } catch (e) {
1847
1918
  console.error('[Filesystem] rename failed', uri, e);
1848
1919
  set(() => ({ organizeError: 'Rename failed' }));
@@ -1861,7 +1932,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1861
1932
  // tombstoned the old one. Prune the stale old subtree from the store and
1862
1933
  // force-relist the parent so the renamed folder surfaces immediately (the
1863
1934
  // non-recursive `refresh` alone would not re-list a nested folder's parent).
1864
- prunePrefix({ get, set }, uri);
1935
+ prunePrefix({ get, set, fences, client }, uri);
1865
1936
  await get().ensureFolderLoaded({ agentId, projectId, uri: parent, force: true });
1866
1937
  } else {
1867
1938
  get().replaceFilePath(uri, destinationUri);
@@ -1873,11 +1944,11 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1873
1944
  const trimmed = (name ?? '').trim();
1874
1945
  if (!agentId || !parentUri || !trimmed) return;
1875
1946
  const targetUri = parentUri.endsWith('://') ? `${parentUri}${trimmed}` : `${parentUri}/${trimmed}`;
1876
- const epoch = resetEpoch;
1947
+ const epoch = fences.resetEpoch;
1877
1948
  try {
1878
1949
  if (kind === 'folder') {
1879
- const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: targetUri, mkdir: true });
1880
- if (res?.ok && epoch === resetEpoch) {
1950
+ const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: targetUri, mkdir: true }, client);
1951
+ if (res?.ok && epoch === fences.resetEpoch) {
1881
1952
  // Optimistic folder node — an EMPTY folder has no vector node, so only a
1882
1953
  // connector-live re-list surfaces it; seed it now so it appears instantly.
1883
1954
  set((state) => {
@@ -1908,7 +1979,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1908
1979
  file: new File([''], trimmed),
1909
1980
  uri: parentUri,
1910
1981
  tags: [],
1911
- });
1982
+ }, client);
1912
1983
  }
1913
1984
  } catch (e) {
1914
1985
  console.error('[Filesystem] create failed', kind, targetUri, e);
@@ -1931,11 +2002,11 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1931
2002
  touched.add(folderUri(src));
1932
2003
  try {
1933
2004
  if (op === 'copy') {
1934
- const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: src, copy: dest });
2005
+ const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: src, copy: dest }, client);
1935
2006
  if (res && res.ok === false) rejected.push(base);
1936
2007
  } else {
1937
2008
  // 200-with-`ok:false` is a REJECTED move — collect it, never treat it as done.
1938
- const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: src, move: dest });
2009
+ const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: src, move: dest }, client);
1939
2010
  if (res && res.ok === false) rejected.push(base);
1940
2011
  else if (res?.warning === 'moved_with_source_retained') rejected.push(`${base} (original retained)`);
1941
2012
  else if (get().files[src]?.type === 'folder') {
@@ -1944,7 +2015,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1944
2015
  // inspects direct children — so the old prefix has to go here or its
1945
2016
  // interior lingers. Cut/paste reaches this path too, and only the drag
1946
2017
  // path used to handle it.
1947
- prunePrefix({ get, set }, src);
2018
+ prunePrefix({ get, set, fences, client }, src);
1948
2019
  }
1949
2020
  }
1950
2021
  } catch (e) {
@@ -1974,7 +2045,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1974
2045
  id: t.id,
1975
2046
  uri: t.uri,
1976
2047
  delete: true,
1977
- });
2048
+ }, client);
1978
2049
  if (res && res.ok === false) {
1979
2050
  // 200-with-`ok:false`. A FOLDER delete can still have removed N files
1980
2051
  // before failing — `affected`/`failed` carry that partial outcome, and
@@ -1990,7 +2061,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
1990
2061
  if (isFolder) {
1991
2062
  // A folder delete removes the whole subtree server-side; prune by prefix so
1992
2063
  // the interior cannot linger (the reconcile only inspects direct children).
1993
- prunePrefix({ get, set }, t.uri);
2064
+ prunePrefix({ get, set, fences, client }, t.uri);
1994
2065
  } else {
1995
2066
  // Optimistic: strike the node (revertible tombstone). The targeted
1996
2067
  // reconcile below converges to server truth — a tracked node KEEPS the
@@ -2074,16 +2145,16 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
2074
2145
  (typeof st.fileContentByPath[uri] === 'string' && (st.fileContentByPath[uri]?.length || 0) > 0);
2075
2146
  if (!force && hasContent) return;
2076
2147
 
2077
- const epoch = resetEpoch;
2148
+ const epoch = fences.resetEpoch;
2078
2149
  try {
2079
2150
  const res = await readFiles({
2080
2151
  agentId: readAgentId || agentId,
2081
2152
  ...(projectId ? { projectId } : {}),
2082
2153
  files: [uri],
2083
- });
2154
+ }, client);
2084
2155
  // The broom ran while the read was in flight: the tab is gone, the store is
2085
2156
  // empty, and this answer must not put either back.
2086
- if (epoch !== resetEpoch) return;
2157
+ if (epoch !== fences.resetEpoch) return;
2087
2158
  const files = extractReadFilesPayload(res);
2088
2159
  if (files.length > 0) {
2089
2160
  const f = files[0];
@@ -2193,7 +2264,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
2193
2264
  } catch (e) {
2194
2265
  const message = e instanceof Error ? e.message : 'Read failed';
2195
2266
  console.error('[Filesystem] read failed', e);
2196
- if (epoch !== resetEpoch) return;
2267
+ if (epoch !== fences.resetEpoch) return;
2197
2268
  set((state) => ({
2198
2269
  files: {
2199
2270
  ...state.files,
@@ -2253,7 +2324,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
2253
2324
  const targetUri = uri || 'brain://docs';
2254
2325
  set(() => ({ uploading: true }));
2255
2326
  try {
2256
- await uploadFile({ agentId, ...(projectId ? { projectId } : {}), file, uri: targetUri, tags: ['upload'] });
2327
+ await uploadFile({ agentId, ...(projectId ? { projectId } : {}), file, uri: targetUri, tags: ['upload'] }, client);
2257
2328
  await get().reconcileFolders({ agentId, projectId, folders: [targetUri] });
2258
2329
  } finally {
2259
2330
  set(() => ({ uploading: false }));
@@ -2268,25 +2339,43 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
2268
2339
  set(() => ({ uploading: true }));
2269
2340
  try {
2270
2341
  for (const file of list) {
2271
- await uploadFile({ agentId, ...(projectId ? { projectId } : {}), file, uri: targetUri, tags: ['upload'] });
2342
+ await uploadFile({ agentId, ...(projectId ? { projectId } : {}), file, uri: targetUri, tags: ['upload'] }, client);
2272
2343
  }
2273
2344
  await get().reconcileFolders({ agentId, projectId, folders: [targetUri] });
2274
2345
  } finally {
2275
2346
  set(() => ({ uploading: false }));
2276
2347
  }
2277
2348
  },
2349
+ // The window's persisted view, lifted into the flat fields — after every
2350
+ // default above, so a seeded tab strip is what the first render shows.
2351
+ ...(init.projection ? sliceFromPersisted(init.projection) : {}),
2352
+ }));
2353
+ }
2354
+
2355
+ /**
2356
+ * The ONE writer of `neuralis.filesystem.tabs` (v2). Window stores persist
2357
+ * nothing; a mounted window feeds its projection here (`mountFilesWindow`), so
2358
+ * two windows writing the same key can never overwrite each other's view — the
2359
+ * whole record is written from this one in-memory owner.
2360
+ *
2361
+ * What it holds: the preferences, the legacy per-agent `contexts` (the record an
2362
+ * older build wrote — never read into a window, pruned by the broom, otherwise
2363
+ * left as it is: never rewritten, re-keyed or converted), and the optional
2364
+ * `windows` map — one `PersistedFilesWindow` per window, carrying the
2365
+ * user/project/agent it was written for. A record without `windows` reads
2366
+ * without one and is not written back until a window really changes.
2367
+ */
2368
+ export const filesPersistence = createStore<PersistedFilesShape>()(persist((): PersistedFilesShape => ({
2369
+ previewEditorsEnabled: false,
2370
+ diffViewMode: 'auto',
2371
+ activeUserId: null,
2372
+ activeAgentId: null,
2373
+ activeProjectId: null,
2374
+ contexts: {},
2278
2375
  }), {
2279
- // Persist the prefs, the active triple and the CONTEXT SPACE as projections
2280
- // (`PersistedFilesContext` per context — tab shape, expanded folders as an
2281
- // array, sidebar view, panels, last-touched folder): the active context's
2282
- // projection is taken off the flat fields on every write, the others off
2283
- // `contextStateByKey`. NEVER the `files`/content dicts or a `Set` as such —
2284
- // `pendingReviewPaths` is derived from `files` and stays in memory.
2285
- // `sidebarWidth` keeps its own hand-persist. On rehydrate (`merge`) the active
2286
- // context's projection lands in the flat fields so `loadList`'s same-context
2287
- // guard keeps them on a page refresh, and every other context waits in the
2288
- // space for its switch-back. v2 (2026-09-04): the flat tab trio became the
2289
- // context space; `migrate` lifts a v1 payload's trio into the active context.
2376
+ // v2 (2026-09-04): the flat tab trio became the context space; `migrate` lifts
2377
+ // a v1 payload's trio into the active context. The optional `windows` map is
2378
+ // additive — no version bump, an absent map stays absent.
2290
2379
  name: 'neuralis.filesystem.tabs',
2291
2380
  version: 2,
2292
2381
  migrate: (persistedState, version) => {
@@ -2322,44 +2411,236 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
2322
2411
  merge: (persistedState, currentState) => {
2323
2412
  const p = persistedState as Partial<PersistedFilesShape> | undefined;
2324
2413
  if (!p) return currentState;
2325
- const contexts = p.contexts ?? {};
2326
- const activeKey = p.activeAgentId ? contextKey(p.activeUserId ?? null, p.activeProjectId ?? null, p.activeAgentId) : null;
2327
- const space: Record<string, Record<string, unknown>> = {};
2328
- for (const [key, projection] of Object.entries(contexts)) {
2329
- if (key !== activeKey) space[key] = sliceFromPersisted(projection);
2330
- }
2331
- const active = activeKey && contexts[activeKey] ? sliceFromPersisted(contexts[activeKey]) : {};
2332
2414
  return {
2333
- ...currentState,
2334
2415
  previewEditorsEnabled: p.previewEditorsEnabled ?? currentState.previewEditorsEnabled,
2335
2416
  diffViewMode: p.diffViewMode ?? currentState.diffViewMode,
2336
2417
  activeUserId: p.activeUserId ?? null,
2337
2418
  activeAgentId: p.activeAgentId ?? null,
2338
2419
  activeProjectId: p.activeProjectId ?? null,
2339
- ...active,
2340
- contextStateByKey: space,
2341
- } as FilesystemStore;
2420
+ contexts: p.contexts ?? {},
2421
+ ...(p.windows ? { windows: p.windows } : {}),
2422
+ };
2342
2423
  },
2343
2424
  storage: createJSONStorage(() =>
2344
2425
  typeof window !== 'undefined' && window.localStorage
2345
2426
  ? window.localStorage
2346
2427
  : { getItem: () => null, setItem: () => undefined, removeItem: () => undefined },
2347
2428
  ),
2348
- partialize: (state): PersistedFilesShape => {
2349
- const contexts: Record<string, PersistedFilesContext> = {};
2350
- for (const [key, slice] of Object.entries(state.contextStateByKey)) contexts[key] = persistedContext(slice);
2351
- if (state.activeAgentId) {
2352
- contexts[contextKey(state.activeUserId, state.activeProjectId, state.activeAgentId)] = persistedContext(
2353
- state as unknown as Record<string, unknown>,
2354
- );
2355
- }
2356
- return {
2357
- previewEditorsEnabled: state.previewEditorsEnabled,
2358
- diffViewMode: state.diffViewMode,
2359
- activeUserId: state.activeUserId,
2360
- activeAgentId: state.activeAgentId,
2361
- activeProjectId: state.activeProjectId,
2362
- contexts,
2363
- };
2364
- },
2429
+ // NEVER the `files`/content dicts or a `Set` as such — every projection is a
2430
+ // `persistedContext` allowlist (Sets as arrays). `sidebarWidth` keeps its own
2431
+ // hand-persist.
2432
+ partialize: (state): PersistedFilesShape => ({
2433
+ previewEditorsEnabled: state.previewEditorsEnabled,
2434
+ diffViewMode: state.diffViewMode,
2435
+ activeUserId: state.activeUserId,
2436
+ activeAgentId: state.activeAgentId,
2437
+ activeProjectId: state.activeProjectId,
2438
+ contexts: state.contexts,
2439
+ ...(state.windows ? { windows: state.windows } : {}),
2440
+ }),
2365
2441
  }));
2442
+
2443
+ /** The live identity of one Files window: the host's window id, the signed-in user, the project and the BOUND agent. */
2444
+ export type FilesWindowIdentity = { windowId: string; userId: string; projectId: string; agentId: string };
2445
+
2446
+ function sameWindowIdentity(entry: PersistedFilesWindow, id: FilesWindowIdentity): boolean {
2447
+ return entry.userId === id.userId && entry.projectId === id.projectId && entry.agentId === id.agentId;
2448
+ }
2449
+
2450
+ /** A window projection without its identity fields — the shape `sliceFromPersisted` reads. */
2451
+ function viewOf(entry: PersistedFilesWindow): PersistedFilesContext {
2452
+ const { userId: _u, projectId: _p, agentId: _a, ...view } = entry;
2453
+ return view;
2454
+ }
2455
+
2456
+ /**
2457
+ * A new window store. Its view comes from the window's OWN projection when the
2458
+ * record holds one written for this LIVE identity; a projection written for
2459
+ * another user, project or agent is ignored, and a window with no projection
2460
+ * of its own starts from the defaults. The older per-agent `contexts` entries
2461
+ * are never read into a window (no ID translation from an agent key to a
2462
+ * window id): an upgrade resets the open tabs once.
2463
+ */
2464
+ export function createFilesWindowStore(id: FilesWindowIdentity): FilesystemStoreApi {
2465
+ const record = filesPersistence.getState();
2466
+ const own = record.windows?.[id.windowId];
2467
+ const projection = own && sameWindowIdentity(own, id) ? viewOf(own) : undefined;
2468
+ return createFilesystemStore({
2469
+ client: createPackageApi({ slug: 'brain-core', getProjectId: () => id.projectId, getAgentId: () => id.agentId }),
2470
+ windowId: id.windowId,
2471
+ userId: id.userId,
2472
+ projectId: id.projectId,
2473
+ agentId: id.agentId,
2474
+ previewEditorsEnabled: record.previewEditorsEnabled,
2475
+ diffViewMode: record.diffViewMode,
2476
+ ...(projection ? { projection } : {}),
2477
+ });
2478
+ }
2479
+
2480
+ /** The fields `persistedContext` reads — a store update that changed none of them changed no projection. */
2481
+ const PROJECTION_INPUTS = [
2482
+ 'openFiles', 'activeFile', 'tabMeta', 'expandedFolders',
2483
+ 'sidebarView', 'activeWorkspaceTab', 'workspacePanels', 'activeFolderPath',
2484
+ ] as const;
2485
+
2486
+ /**
2487
+ * Feed ONE window's view into the persistence owner while it is mounted. Per
2488
+ * store update: O(1) — eight reference compares; only an update that touched a
2489
+ * projection input serializes the projection, and only a projection that
2490
+ * differs from the last one written reaches the owner (which serializes the
2491
+ * whole allowlisted record once). Returns the unsubscribe.
2492
+ */
2493
+ function bindWindowPersistence(store: FilesystemStoreApi, id: FilesWindowIdentity): () => void {
2494
+ let last = JSON.stringify(persistedContext(store.getState() as unknown as Record<string, unknown>));
2495
+ return store.subscribe((state, prev) => {
2496
+ if (state.previewEditorsEnabled !== prev.previewEditorsEnabled || state.diffViewMode !== prev.diffViewMode) {
2497
+ const owner = filesPersistence.getState();
2498
+ if (owner.previewEditorsEnabled !== state.previewEditorsEnabled || owner.diffViewMode !== state.diffViewMode) {
2499
+ filesPersistence.setState({ previewEditorsEnabled: state.previewEditorsEnabled, diffViewMode: state.diffViewMode });
2500
+ }
2501
+ }
2502
+ if (!PROJECTION_INPUTS.some((key) => state[key] !== prev[key])) return;
2503
+ // The view belongs to the window's own identity; a store that left it writes nothing.
2504
+ if (state.activeUserId !== id.userId || state.activeProjectId !== id.projectId || state.activeAgentId !== id.agentId) return;
2505
+ const view = persistedContext(state as unknown as Record<string, unknown>);
2506
+ const json = JSON.stringify(view);
2507
+ if (json === last) return;
2508
+ last = json;
2509
+ filesPersistence.setState((owner) => ({
2510
+ windows: {
2511
+ ...(owner.windows ?? {}),
2512
+ [id.windowId]: { userId: id.userId, projectId: id.projectId, agentId: id.agentId, ...view },
2513
+ },
2514
+ }));
2515
+ });
2516
+ }
2517
+
2518
+ type LiveFilesWindow = { identity: FilesWindowIdentity; store: FilesystemStoreApi };
2519
+
2520
+ /** Every mounted Files window — hidden and minimized ones included (they stay mounted). */
2521
+ const liveFilesWindows = new Set<LiveFilesWindow>();
2522
+
2523
+ /** The signed-in user, from the workspace provider — which user's records a broom prunes. */
2524
+ let liveFilesUserId: string | null = null;
2525
+
2526
+ export function setFilesLiveUser(userId: string | null): void {
2527
+ liveFilesUserId = userId;
2528
+ }
2529
+
2530
+ /**
2531
+ * Register a window store while it is mounted: the reset fan-out reaches it and
2532
+ * its view feeds the persistence owner. The disposer drops the subscription and
2533
+ * fences the store's in-flight readers — the persisted view stays (closing or
2534
+ * hiding a window is not a reset).
2535
+ */
2536
+ export function mountFilesWindow(store: FilesystemStoreApi, identity: FilesWindowIdentity): () => void {
2537
+ const entry: LiveFilesWindow = { identity, store };
2538
+ liveFilesWindows.add(entry);
2539
+ const unbind = bindWindowPersistence(store, identity);
2540
+ return () => {
2541
+ unbind();
2542
+ liveFilesWindows.delete(entry);
2543
+ store.getState().fenceInFlightWriters();
2544
+ };
2545
+ }
2546
+
2547
+ function scopeReachesWindow(scope: ClientStateResetScope, id: FilesWindowIdentity): boolean {
2548
+ if (scope.projectId !== id.projectId) return false;
2549
+ if (scope.level === 'project') return true;
2550
+ if (scope.agentId !== id.agentId) return false;
2551
+ return scope.level === 'agent' || scope.instanceId === id.windowId;
2552
+ }
2553
+
2554
+ /**
2555
+ * The persisted half of a broom, applied to the ONE record: `instance` drops that
2556
+ * window's projection; `widget` drops it AND the legacy (user, project, agent)
2557
+ * context — the type-clean meaning that level always had; `agent` / `project`
2558
+ * drop the legacy contexts and the window projections of their scope. Only the
2559
+ * signed-in user's records; another user's, project's or agent's stay.
2560
+ */
2561
+ function resetPersistedFiles(scope: ClientStateResetScope, userId: string | null): void {
2562
+ if ((scope.level === 'instance' || scope.level === 'widget') && scope.widgetType !== FILESYSTEM_WIDGET_TYPE) return;
2563
+ const record = filesPersistence.getState();
2564
+ const dropWindow = (windowId: string, entry: PersistedFilesWindow): boolean => {
2565
+ if (entry.projectId !== scope.projectId) return false;
2566
+ if (scope.level === 'instance' || scope.level === 'widget') return windowId === scope.instanceId && entry.agentId === scope.agentId;
2567
+ if (userId === null || entry.userId !== userId) return false;
2568
+ return scope.level === 'project' || entry.agentId === scope.agentId;
2569
+ };
2570
+ const dropContext = (key: string): boolean => {
2571
+ if (scope.level === 'instance') return false;
2572
+ if (scope.level === 'project') return key.startsWith(`${userId ?? ''}::${scope.projectId}::`);
2573
+ return key === contextKey(userId, scope.projectId, scope.agentId);
2574
+ };
2575
+ let windows = record.windows;
2576
+ for (const [windowId, entry] of Object.entries(record.windows ?? {})) {
2577
+ if (!dropWindow(windowId, entry)) continue;
2578
+ if (windows === record.windows) windows = { ...record.windows };
2579
+ delete windows![windowId];
2580
+ }
2581
+ let contexts = record.contexts;
2582
+ for (const key of Object.keys(record.contexts)) {
2583
+ if (!dropContext(key)) continue;
2584
+ if (contexts === record.contexts) contexts = { ...record.contexts };
2585
+ delete contexts[key];
2586
+ }
2587
+ if (windows === record.windows && contexts === record.contexts) return;
2588
+ filesPersistence.setState({ contexts, ...(windows ? { windows } : {}) });
2589
+ }
2590
+
2591
+ /**
2592
+ * The ONE handler behind the install-level `port.onClientStateReset`
2593
+ * subscription: the matching live window stores first (each wipe re-feeds its
2594
+ * own, now empty, view), then the persisted record — so an `instance` / `widget`
2595
+ * reset leaves that window's projection absent and a sibling's untouched.
2596
+ * O(mounted Files windows) per broom event.
2597
+ */
2598
+ export function resetFilesClientState(scope: ClientStateResetScope): void {
2599
+ for (const live of liveFilesWindows) {
2600
+ if (scopeReachesWindow(scope, live.identity)) live.store.getState().resetClientState(scope);
2601
+ }
2602
+ resetPersistedFiles(scope, liveFilesUserId);
2603
+ }
2604
+
2605
+ /**
2606
+ * The store of the Files window being rendered: created once per LIVE identity
2607
+ * (window, user, project, bound agent) — a different user or project is a
2608
+ * different store, never a re-used one — and mounted for the window's lifetime.
2609
+ * `null` until the identity is known: nothing persisted is read, and nothing
2610
+ * paints, before the live user and project are.
2611
+ */
2612
+ export function useFilesWindowStore(
2613
+ windowId: string | null | undefined,
2614
+ userId: string | null | undefined,
2615
+ projectId: string | null | undefined,
2616
+ agentId: string | null | undefined,
2617
+ ): FilesystemStoreApi | null {
2618
+ const live = useMemo(() => {
2619
+ if (!windowId || !userId || !projectId || !agentId) return null;
2620
+ const identity: FilesWindowIdentity = { windowId, userId, projectId, agentId };
2621
+ return { identity, store: createFilesWindowStore(identity) };
2622
+ }, [windowId, userId, projectId, agentId]);
2623
+ // Layout phase: registered (and its persistence bound) before any child's
2624
+ // passive effect issues the first request; disposed before the next paint.
2625
+ useLayoutEffect(() => (live ? mountFilesWindow(live.store, live.identity) : undefined), [live]);
2626
+ return live?.store ?? null;
2627
+ }
2628
+
2629
+ const FilesystemStoreContext = createContext<FilesystemStoreApi | null>(null);
2630
+
2631
+ /** Puts a window's store under its Files tree; every `useFilesystemStore` below reads THIS window's store. */
2632
+ export function FilesystemStoreProvider({ store, children }: { store: FilesystemStoreApi; children?: ReactNode }) {
2633
+ return createElement(FilesystemStoreContext.Provider, { value: store }, children);
2634
+ }
2635
+
2636
+ /** The window's store itself, for imperative reads/writes outside a selector (event handlers, the SSE hook). */
2637
+ export function useFilesystemStoreApi(): FilesystemStoreApi {
2638
+ const store = useContext(FilesystemStoreContext);
2639
+ if (!store) throw new Error('useFilesystemStore must be used inside a Files window (FilesystemStoreProvider)');
2640
+ return store;
2641
+ }
2642
+
2643
+ /** Select from the store of the Files window this component renders in. */
2644
+ export function useFilesystemStore<T>(selector: (state: FilesystemStore) => T): T {
2645
+ return useStore(useFilesystemStoreApi(), selector);
2646
+ }