@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
@@ -453,6 +453,7 @@ type ConfirmState = { title: string; message: string; confirmLabel: string; onCo
453
453
  type RescueState = { message: string; rescue: GitRescue };
454
454
 
455
455
  export function GitControlPanel({ agentId }: { agentId: string }) {
456
+ const client = useFilesystemStore((s) => s.client);
456
457
  const sources = useFilesystemStore((s) => s.sources);
457
458
  const projectId = useFilesystemStore((s) => s.activeProjectId);
458
459
  const activeSource = useFilesystemStore((s) => s.activeSource);
@@ -554,7 +555,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
554
555
  if (cached && (cached instanceof Promise || !opts?.force)) return cached;
555
556
  const task = (async () => {
556
557
  try {
557
- const res = await getGitRepos({ agentId, ...(projectId ? { projectId } : {}), source: slug });
558
+ const res = await getGitRepos({ agentId, ...(projectId ? { projectId } : {}), source: slug }, client);
558
559
  const repos = Array.isArray(res?.repos) ? res.repos : [];
559
560
  repoListsRef.current.set(slug, repos);
560
561
  setRepoLists((prev) => new Map(prev).set(slug, repos));
@@ -799,7 +800,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
799
800
  const handleStage = useCallback(
800
801
  (paths: string[]) => {
801
802
  if (paths.length === 0) return;
802
- void doWrite(() => stagePaths({ agentId, ...p, source: selectedSlug, paths }));
803
+ void doWrite(() => stagePaths({ agentId, ...p, source: selectedSlug, paths }, client));
803
804
  },
804
805
  [doWrite, agentId, p, selectedSlug],
805
806
  );
@@ -807,7 +808,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
807
808
  const handleUnstage = useCallback(
808
809
  (paths: string[]) => {
809
810
  if (paths.length === 0) return;
810
- void doWrite(() => unstagePaths({ agentId, ...p, source: selectedSlug, paths }));
811
+ void doWrite(() => unstagePaths({ agentId, ...p, source: selectedSlug, paths }, client));
811
812
  },
812
813
  [doWrite, agentId, p, selectedSlug],
813
814
  );
@@ -815,7 +816,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
815
816
  const runDiscard = useCallback(
816
817
  (paths: string[]) => {
817
818
  void (async () => {
818
- const res = await doWrite(() => discardPaths({ agentId, ...p, source: selectedSlug, paths }));
819
+ const res = await doWrite(() => discardPaths({ agentId, ...p, source: selectedSlug, paths }, client));
819
820
  if (res && res.rescue) {
820
821
  setRescue({
821
822
  message: `Discarded ${paths.length} file${paths.length === 1 ? '' : 's'} — recoverable`,
@@ -875,7 +876,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
875
876
  const message = commitMessage.trim();
876
877
  if (!message) return;
877
878
  void (async () => {
878
- const res = await doWrite(() => commitGit({ agentId, ...p, source: selectedSlug, message, amend }));
879
+ const res = await doWrite(() => commitGit({ agentId, ...p, source: selectedSlug, message, amend }, client));
879
880
  if (res && res.ok) {
880
881
  setCommitMessage('');
881
882
  setAmend(false);
@@ -901,7 +902,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
901
902
  (ref: string) => {
902
903
  setBranchPickerOpen(false);
903
904
  void (async () => {
904
- const res = await doWrite(() => checkoutGitBranch({ agentId, ...p, source: selectedSlug, ref }));
905
+ const res = await doWrite(() => checkoutGitBranch({ agentId, ...p, source: selectedSlug, ref }, client));
905
906
  if (res)
906
907
  void loadGitBranches({ agentId, projectId: projectId ?? null, source: selectedSlug, repo: activeRepoRel });
907
908
  })();
@@ -920,10 +921,10 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
920
921
  void (async () => {
921
922
  // Create then switch to it (VS-Code "Create new branch" also checks out).
922
923
  const created = await doWrite(() =>
923
- createGitBranch({ agentId, ...p, source: selectedSlug, name, ...(from ? { startPoint: from } : {}) }),
924
+ createGitBranch({ agentId, ...p, source: selectedSlug, name, ...(from ? { startPoint: from } : {}) }, client),
924
925
  );
925
926
  if (created) {
926
- await doWrite(() => checkoutGitBranch({ agentId, ...p, source: selectedSlug, ref: name }));
927
+ await doWrite(() => checkoutGitBranch({ agentId, ...p, source: selectedSlug, ref: name }, client));
927
928
  void loadGitBranches({ agentId, projectId: projectId ?? null, source: selectedSlug, repo: activeRepoRel });
928
929
  }
929
930
  })();
@@ -932,7 +933,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
932
933
  const runDeleteBranch = useCallback(
933
934
  (name: string) => {
934
935
  void (async () => {
935
- const res = await doWrite(() => deleteGitBranch({ agentId, ...p, source: selectedSlug, name }));
936
+ const res = await doWrite(() => deleteGitBranch({ agentId, ...p, source: selectedSlug, name }, client));
936
937
  if (res && res.rescue) {
937
938
  setRescue({ message: `Deleted branch "${name}" — recoverable`, rescue: res.rescue });
938
939
  }
@@ -966,7 +967,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
966
967
  if (!rescue) return;
967
968
  const carrier = rescue.rescue;
968
969
  setRescue(null);
969
- void doWrite(() => restoreGit({ agentId, ...p, source: selectedSlug, rescue: carrier }));
970
+ void doWrite(() => restoreGit({ agentId, ...p, source: selectedSlug, rescue: carrier }, client));
970
971
  }, [rescue, doWrite, agentId, p, selectedSlug]);
971
972
 
972
973
  // --- Remote operations (G4 keyring-kestrel Slice 2) ------------------------
@@ -976,17 +977,17 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
976
977
 
977
978
  const handleFetch = useCallback(() => {
978
979
  setRemotePickerOpen(false);
979
- void doWrite(() => fetchGit({ agentId, ...p, source: selectedSlug }));
980
+ void doWrite(() => fetchGit({ agentId, ...p, source: selectedSlug }, client));
980
981
  }, [doWrite, agentId, p, selectedSlug]);
981
982
 
982
983
  const handlePull = useCallback(() => {
983
984
  setRemotePickerOpen(false);
984
- void doWrite(() => pullGit({ agentId, ...p, source: selectedSlug }));
985
+ void doWrite(() => pullGit({ agentId, ...p, source: selectedSlug }, client));
985
986
  }, [doWrite, agentId, p, selectedSlug]);
986
987
 
987
988
  const handlePush = useCallback(() => {
988
989
  setRemotePickerOpen(false);
989
- void doWrite(() => pushGit({ agentId, ...p, source: selectedSlug }));
990
+ void doWrite(() => pushGit({ agentId, ...p, source: selectedSlug }, client));
990
991
  }, [doWrite, agentId, p, selectedSlug]);
991
992
 
992
993
  const unstagedChanges = gitStatus?.unstaged ?? [];
@@ -1201,7 +1202,6 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
1201
1202
  size="sm"
1202
1203
  skill="git"
1203
1204
  packageId="@neuralis/brain-core"
1204
- scripts={["scripts/git.sh"]}
1205
1205
  label="Status, diff, commit, branch, fetch or push with the agent"
1206
1206
  icon="GitBranch"
1207
1207
  />
@@ -26,15 +26,21 @@ export function GitDiffView({
26
26
  agentId: string | null;
27
27
  projectId: string | null;
28
28
  }): React.ReactElement {
29
- const [canWrite, setCanWrite] = useState(false);
29
+ const client = useFilesystemStore((s) => s.client);
30
30
  const revision = useFilesystemStore(state => state.sourceChangeRevision[target.source] ?? 0);
31
+ const context = `${agentId ?? ''}\u0000${projectId ?? ''}\u0000${target.source}\u0000${target.repo ?? ''}`;
32
+ const [permission, setPermission] = useState<{ client: typeof client; context: string; revision: number; allowed: boolean; pending: boolean } | null>(null);
33
+ const current = permission?.client === client && permission.context === context;
34
+ const canWrite = current && !permission.pending && permission.revision === revision && permission.allowed;
35
+ const stageRows = current && permission.allowed;
31
36
  useEffect(() => {
32
- let alive = true; setCanWrite(false);
33
- if (agentId) void getGitRepos({ agentId, ...(projectId ? { projectId } : {}), source: target.source }).then(result => {
34
- if (alive) setCanWrite(result.repos.find(repo => repo.rel === (target.repo ?? ''))?.canWrite === true);
35
- }).catch(() => { if (alive) setCanWrite(false); });
37
+ let alive = true;
38
+ setPermission(previous => ({ client, context, revision, pending: true, allowed: previous?.client === client && previous.context === context && previous.allowed }));
39
+ if (agentId) void getGitRepos({ agentId, ...(projectId ? { projectId } : {}), source: target.source }, client).then(result => {
40
+ if (alive) setPermission({ client, context, revision, pending: false, allowed: result.repos.find(repo => repo.rel === (target.repo ?? ''))?.canWrite === true });
41
+ }).catch(() => { if (alive) setPermission({ client, context, revision, pending: false, allowed: false }); });
36
42
  return () => { alive = false; };
37
- }, [agentId, projectId, target.source, target.repo, revision]);
43
+ }, [client, context, agentId, projectId, target.source, target.repo, revision]);
38
44
  const mode = useMemo(() => gitDiffModeFromTarget(target), [target]);
39
45
  return (
40
46
  <GitFileDiff
@@ -48,6 +54,8 @@ export function GitDiffView({
48
54
  projectId={projectId}
49
55
  variant="tab"
50
56
  canWrite={canWrite}
57
+ stageRows={stageRows}
58
+ permissionPending={!current || permission.pending || permission.revision !== revision}
51
59
  revision={revision}
52
60
  />
53
61
  );
@@ -36,7 +36,7 @@ import {
36
36
  import { computeGitDiffTokens, renderShikiToken } from './gitDiffShikiTokenizer';
37
37
  import { resolveShikiLang, isShikiReady, ensureLanguage, getHighlighter } from './codeEditor/shikiHighlighter';
38
38
  import { detectCodeLanguage } from '../lib/detectLanguage';
39
- import { useContainerWidth } from '../hooks/useContainerWidth';
39
+ import { useContainerWidth } from '@neuralis/package-system/client';
40
40
  import type { VisibleDiffQueue } from '../hooks/useVisibleDiffCards';
41
41
  import { DiffModeToggle } from './DiffModeToggle';
42
42
 
@@ -130,6 +130,8 @@ export function GitFileDiff({
130
130
  variant = 'tab',
131
131
  revision = 0,
132
132
  canWrite = false,
133
+ stageRows = canWrite,
134
+ permissionPending = false,
133
135
  runContent,
134
136
  peekContent,
135
137
  }: {
@@ -151,11 +153,14 @@ export function GitFileDiff({
151
153
  variant?: 'tab' | 'card';
152
154
  revision?: number;
153
155
  canWrite?: boolean;
156
+ stageRows?: boolean;
157
+ permissionPending?: boolean;
154
158
  /** A virtualized list's content lane; `cache` lets a re-mounted card reuse its answer. */
155
159
  runContent?: VisibleDiffQueue['run'];
156
160
  /** The lane's synchronous cache read: a re-mounted card paints its last answer on its first frame. */
157
161
  peekContent?: VisibleDiffQueue['peek'];
158
162
  }): React.ReactElement {
163
+ const client = useFilesystemStore((s) => s.client);
159
164
  const [initial] = useState(() => peekContent?.<DiffAnswer>(`${diffFamily(source, repo, path, mode)}\u0000${revision}`, diffFamily(source, repo, path, mode)));
160
165
  const [raw, setRaw] = useState<string | null>(() => initial === undefined ? null : initial && initial.ok !== false ? initial.diff : '');
161
166
  const [loading, setLoading] = useState(false);
@@ -229,7 +234,7 @@ export function GitFileDiff({
229
234
  }, [language]);
230
235
 
231
236
  useEffect(() => {
232
- sourceCacheRef.current.clear();
237
+ sourceCacheRef.current.clear(); setHunkBusy(null); setHunkError(null);
233
238
  // New target/revision (or post-stage refetch): drop cached source and expansion state —
234
239
  // the ranges are relative to the OLD raw's original hunks — AND the
235
240
  // server-flag states: `serverTruncated`/`tooLarge` belong to the PREVIOUS
@@ -262,7 +267,7 @@ export function GitFileDiff({
262
267
  path,
263
268
  // ONE mode per request — the server 400s any combination.
264
269
  ...diffRequestParams(normalizedMode),
265
- });
270
+ }, client);
266
271
  // Keyed on everything the answer depends on; after a hunk apply the refetch
267
272
  // replaces the cached answer instead of reading it.
268
273
  const family = diffFamily(source, repo, path, normalizedMode);
@@ -292,7 +297,7 @@ export function GitFileDiff({
292
297
  }
293
298
  })();
294
299
  return () => { seqRef.current++; };
295
- }, [agentId, projectId, source, repo, path, normalizedMode, reloadNonce, revision, runContent]);
300
+ }, [client, agentId, projectId, source, repo, path, normalizedMode, reloadNonce, revision, runContent]);
296
301
 
297
302
  // The ONE parse of the raw diff — the ORIGINAL (staging) model.
298
303
  const { files, truncated, hunkPatches } = useMemo(
@@ -316,7 +321,7 @@ export function GitFileDiff({
316
321
  // parsed count, or the index→patch mapping is unsafe.
317
322
  const singleFile: FileData | null = files.length === 1 ? files[0] : null;
318
323
  const canStageHunks =
319
- canWrite && kind === 'working' &&
324
+ stageRows && kind === 'working' &&
320
325
  !!agentId &&
321
326
  !!singleFile &&
322
327
  singleFile.type === 'modify' &&
@@ -364,7 +369,7 @@ export function GitFileDiff({
364
369
  ...(repo ? { repo } : {}),
365
370
  path,
366
371
  rev: expanderSrc.rev,
367
- });
372
+ }, client);
368
373
  const res = runContent ? await runContent(fetchSource, () => mySeq === seqRef.current) : await fetchSource();
369
374
  if (!res || mySeq !== seqRef.current) return null;
370
375
  return settle(res.ok ? { ok: true, content: res.content } : { ok: false });
@@ -376,7 +381,7 @@ export function GitFileDiff({
376
381
  agentId,
377
382
  ...(projectId ? { projectId } : {}),
378
383
  files: [`${source}://${repo ? `${repo}/` : ''}${path}`],
379
- });
384
+ }, client);
380
385
  const res = runContent ? await runContent(fetchSource, () => mySeq === seqRef.current) : await fetchSource();
381
386
  if (!res || mySeq !== seqRef.current) return null;
382
387
  const file = extractReadFilesPayload(res)[0];
@@ -385,7 +390,7 @@ export function GitFileDiff({
385
390
  } catch {
386
391
  return settle({ ok: false });
387
392
  }
388
- }, [agentId, projectId, source, repo, path, expanderSrc, runContent]);
393
+ }, [client, agentId, projectId, source, repo, path, expanderSrc, runContent]);
389
394
 
390
395
  // The synthetic OLD-side lines (`expandFromRawCode` slices by old-line
391
396
  // numbers) + the RENDER-ONLY expanded hunk list. `oldTotal` also sizes the
@@ -457,20 +462,22 @@ export function GitFileDiff({
457
462
  const applyHunk = useCallback(
458
463
  async (hunkIndex: number) => {
459
464
  // A raw shown while its fresh answer loads may be an older revision's — never stage from it.
460
- if (loading || !canStageHunks || !hunkPatches || !agentId) return;
465
+ if (loading || permissionPending || !canWrite || !canStageHunks || !hunkPatches || !agentId) return;
461
466
  const patch = assembleHunkPatch(hunkPatches.header, hunkPatches.hunks[hunkIndex]);
462
467
  if (!patch) {
463
468
  setHunkError('Could not build a hunk patch for this change.');
464
469
  return;
465
470
  }
471
+ const mySeq = seqRef.current;
466
472
  setHunkBusy(hunkIndex);
467
473
  setHunkError(null);
468
474
  try {
469
475
  if (staged) {
470
- await unstageHunk({ agentId, ...(projectId ? { projectId } : {}), source, ...(repo ? { repo } : {}), hunk: patch });
476
+ await unstageHunk({ agentId, ...(projectId ? { projectId } : {}), source, ...(repo ? { repo } : {}), hunk: patch }, client);
471
477
  } else {
472
- await stageHunk({ agentId, ...(projectId ? { projectId } : {}), source, ...(repo ? { repo } : {}), hunk: patch });
478
+ await stageHunk({ agentId, ...(projectId ? { projectId } : {}), source, ...(repo ? { repo } : {}), hunk: patch }, client);
473
479
  }
480
+ if (mySeq !== seqRef.current) return;
474
481
  // The index changed → the cached `rev=index` source is stale (the WT
475
482
  // entry would survive, but a whole-map clear is cheap and race-free).
476
483
  sourceCacheRef.current.clear();
@@ -486,12 +493,12 @@ export function GitFileDiff({
486
493
  void loadGitStatus({ agentId, projectId });
487
494
  setReloadNonce((n) => n + 1);
488
495
  } catch (e) {
489
- setHunkError(e instanceof Error ? e.message : 'Hunk apply failed');
496
+ if (mySeq === seqRef.current) setHunkError(e instanceof Error ? e.message : 'Hunk apply failed');
490
497
  } finally {
491
- setHunkBusy(null);
498
+ if (mySeq === seqRef.current) setHunkBusy(null);
492
499
  }
493
500
  },
494
- [loading, canStageHunks, hunkPatches, agentId, projectId, source, repo, staged, loadGitStatus],
501
+ [client, loading, permissionPending, canWrite, canStageHunks, hunkPatches, agentId, projectId, source, repo, staged, loadGitStatus],
495
502
  );
496
503
 
497
504
  /** One collapsed-gap row: clickable expander, or static text once refused. */
@@ -541,7 +548,7 @@ export function GitFileDiff({
541
548
  key={idx}
542
549
  type="button"
543
550
  onClick={() => void applyHunk(idx)}
544
- disabled={hunkBusy !== null || loading}
551
+ disabled={hunkBusy !== null || loading || permissionPending || !canWrite}
545
552
  className={cn(
546
553
  'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10px] transition-colors disabled:opacity-40',
547
554
  staged
@@ -23,7 +23,7 @@ import {
23
23
  ChevronLeft, CheckCheck, X, XCircle, Wrench, Unlink, ShieldAlert,
24
24
  } from 'lucide-react';
25
25
  import { SkillLauncher } from '@neuralis/package-system/client';
26
- import { useFilesystemStore } from '../store/filesystemStore';
26
+ import { useFilesystemStore, useFilesystemStoreApi } from '../store/filesystemStore';
27
27
  import {
28
28
  listPendingChanges, previewPendingChange, discardPendingChange, discardOrphanedPending,
29
29
  type PendingChangeRecord, type PendingPreview,
@@ -115,6 +115,7 @@ export const PendingDiffCard = memo(function PendingDiffCard({
115
115
  /** The dialog's post-409 refetch: `'wait'` while in flight, then its answer ONCE for the key it was taken for. */
116
116
  takeRefetch: (id: string, key: string) => PendingPreview | 'wait' | undefined; extra?: React.ReactNode;
117
117
  }) {
118
+ const client = useFilesystemStore((s) => s.client);
118
119
  // Cards open inline by default — the diff is the review unit.
119
120
  const [expanded, setExpanded] = useState(true);
120
121
  // The last answer this record had (a remount or a new source revision) stays on screen
@@ -140,7 +141,7 @@ export const PendingDiffCard = memo(function PendingDiffCard({
140
141
  if (adopted === 'wait') { setLoading(true); return; }
141
142
  if (adopted) { setPreview(adopted); setLoadError(null); setAnswerKey(cacheKey); loadedKey.current = cacheKey; setLoading(false); return; }
142
143
  setLoading(true);
143
- void queue.run(() => previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }),
144
+ void queue.run(() => previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }, client),
144
145
  () => generation === seq.current && active.current, { key: cacheKey, bytes: previewBytes }).then(next => {
145
146
  if (!next || generation !== seq.current || !active.current) return;
146
147
  onPreview(next);
@@ -152,7 +153,7 @@ export const PendingDiffCard = memo(function PendingDiffCard({
152
153
  setLoadError(error instanceof Error ? error.message : 'Failed to load review'); setLoading(false);
153
154
  });
154
155
  return () => { seq.current++; };
155
- }, [agentId, projectId, rec.id, cacheKey, expanded, shown, queue.run]);
156
+ }, [client, agentId, projectId, rec.id, cacheKey, expanded, shown, queue.run]);
156
157
 
157
158
  const hunks = useMemo(() => preview ? hunksOf(preview) : null, [preview]);
158
159
  const computed = useMemo(() => hunks ? countHunks(hunks) : null, [hunks]);
@@ -183,6 +184,7 @@ export const PendingDiffCard = memo(function PendingDiffCard({
183
184
  {indexState && indexState !== 'stored' ? <span className="text-[10px] text-amber-200/80 shrink-0">
184
185
  {indexState === 'missing' ? 'Index missing' : indexState === 'stale' ? 'Index stale' : 'Recovery only'}
185
186
  </span> : null}
187
+ {rec.conflict === 'PENDING_GENERATION_CHANGED' ? <span className="text-[10px] text-amber-200/80 shrink-0" title="Pending generation changed; this record cannot be repaired automatically.">Pending generation changed</span> : null}
186
188
  {orphaned ? ORPHAN_BADGE : rec.status === 'approved' || rec.status === 'reverted' ? (
187
189
  <span className={`w-1.5 h-1.5 rounded-full shrink-0 ${statusTone(rec.status)}`} title={rec.status} />
188
190
  ) : null}
@@ -212,8 +214,10 @@ const NO_FILTERS: ChangeFilters = { kind: 'all', source: '', repo: '', branch: '
212
214
  /** One bulk pass (the sweep and the grouped dialog) covers at most this many records. */ const REVIEW_BATCH = 100;
213
215
 
214
216
  export function PendingChangesPanel({ agentId, projectId }: Props) {
217
+ const client = useFilesystemStore((s) => s.client);
215
218
  const host = useFilesystemHost();
216
219
  const canWrite = hasFeature({ grantedFeatures: [...(host.grantedFeatures ?? [])] }, 'drive.write');
220
+ const storeApi = useFilesystemStoreApi();
217
221
  const sources = useFilesystemStore(s => s.sources);
218
222
  const liveRevisions = useFilesystemStore(s => s.sourceChangeRevision);
219
223
  // A review batch (the sweep, a dialog group) HOLDS the revisions its own writes bump: one re-list
@@ -258,17 +262,20 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
258
262
  const [nonces, setNonces] = useState<Readonly<Record<string, number>>>({});
259
263
  const latest = useRef({ nonces, revisions }); latest.current = { nonces, revisions };
260
264
  const refetch = useCallback(async (rec: PendingChangeRecord): Promise<PendingPreview> => {
265
+ const epoch = contextEpoch.current;
261
266
  refetches.current.set(rec.id, null);
262
267
  try {
263
268
  const listed = (await relisted.current.promise)?.find(r => r.id === rec.id) ?? rec;
264
- const fresh = await previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id });
269
+ if (epoch !== contextEpoch.current) throw new Error('Review context changed');
270
+ const fresh = await previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }, client);
271
+ if (epoch !== contextEpoch.current) throw new Error('Review context changed');
265
272
  const revision = (latest.current.nonces[rec.id] ?? 0) + 1 + (latest.current.revisions[listed.sourceSlug] ?? 0);
266
273
  refetches.current.set(rec.id, { key: previewKey(listed, revision), preview: fresh });
267
274
  previews.current.set(rec.id, fresh);
268
275
  return fresh;
269
- } catch (failure) { refetches.current.delete(rec.id); throw failure; }
270
- finally { setNonces(prev => ({ ...prev, [rec.id]: (prev[rec.id] ?? 0) + 1 })); }
271
- }, [agentId, projectId]);
276
+ } catch (failure) { if (epoch === contextEpoch.current) refetches.current.delete(rec.id); throw failure; }
277
+ finally { if (epoch === contextEpoch.current) setNonces(prev => ({ ...prev, [rec.id]: (prev[rec.id] ?? 0) + 1 })); }
278
+ }, [client, agentId, projectId]);
272
279
  const [focusIndex, setFocusIndex] = useState(0);
273
280
  const [review, setReview] = useState<ReviewRequest | null>(null);
274
281
  const [results, setResults] = useState<ReviewResult[]>([]);
@@ -279,27 +286,50 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
279
286
  const [collapsedGit, setCollapsedGit] = useState<ReadonlySet<string>>(new Set());
280
287
  const repairTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
281
288
  const listSeq = useRef(0);
289
+ const orphanSeq = useRef(0);
290
+ const contextEpoch = useRef(0);
291
+ const changesRef = useRef(changes); changesRef.current = changes;
292
+ const classified = useRef(new Map<string, string>());
293
+ const [nextCursor, setNextCursor] = useState<string | undefined>();
294
+ const [partial, setPartial] = useState(false);
295
+ const [repairProgress, setRepairProgress] = useState('');
296
+ const repairStop = useRef(false);
297
+ const repairCursor = useRef<string | undefined>(undefined);
298
+ const [repairPaused, setRepairPaused] = useState(false);
299
+ const generation = (rec: PendingChangeRecord): string => JSON.stringify([rec.id, rec.updatedAt, rec.snapshotHash, rec.status]);
282
300
 
283
301
  const inventory = useGitChangesInventory({ agentId, projectId, sources, revisions, resetKey: `${agentId}\u0000${projectId}\u0000${convFilter}` });
284
302
 
285
303
  /** The pending list. The orphan probe (one connector stat per record) runs only on mount, Refresh and Repair. */
286
- const fetchPending = useCallback(async (withOrphans: boolean): Promise<PendingChangeRecord[] | undefined> => {
304
+ const fetchPending = useCallback(async (withOrphans: boolean, append = false): Promise<PendingChangeRecord[] | undefined> => {
287
305
  if (!agentId) return undefined;
288
306
  const mine = ++listSeq.current;
307
+ const scan = withOrphans ? ++orphanSeq.current : orphanSeq.current;
308
+ const epoch = contextEpoch.current;
289
309
  setLoading(true); setError(null);
290
310
  try {
291
311
  const list = await listPendingChanges({ agentId, ...(projectId ? { projectId } : {}),
292
- ...(convFilter ? { conversationId: convFilter } : {}), ...(withOrphans ? { include_orphans: true } : {}) });
312
+ ...(convFilter ? { conversationId: convFilter } : {}), ...(withOrphans ? { include_orphans: true } : {}),
313
+ ...(append && nextCursor ? { cursor: nextCursor } : {}) }, client);
314
+ if (epoch !== contextEpoch.current) return undefined;
315
+ if (withOrphans && scan === orphanSeq.current) {
316
+ for (const rec of list.changes) classified.current.set(rec.id, `${generation(rec)}:${list.orphanIds?.includes(rec.id) === true}`);
317
+ setOrphanIds(new Set(changesRef.current.filter(rec => classified.current.get(rec.id) === `${generation(rec)}:true`).map(rec => rec.id)));
318
+ }
293
319
  if (mine !== listSeq.current) return undefined;
294
- const ids = new Set(list.changes.map(rec => rec.id));
295
- setChanges(list.changes);
296
- setOrphanIds(previous => withOrphans ? new Set(list.orphanIds) : new Set([...previous].filter(id => ids.has(id))));
297
- return list.changes;
320
+ if (list.restartRequired) { setNextCursor(undefined); setError('Changes moved. Refresh to restart the list.'); return undefined; }
321
+ const prior = new Map(changesRef.current.map(rec => [rec.id, rec]));
322
+ const fresh = new Map(list.changes.map(rec => [rec.id, rec]));
323
+ const merged = append ? [...changesRef.current.map(rec => fresh.get(rec.id) ?? rec), ...list.changes.filter(rec => !prior.has(rec.id))] : list.changes;
324
+ changesRef.current = merged; setChanges(merged);
325
+ setOrphanIds(new Set(merged.filter(rec => classified.current.get(rec.id) === `${generation(rec)}:true`).map(rec => rec.id)));
326
+ setNextCursor(list.nextCursor); setPartial(list.partial);
327
+ return merged;
298
328
  } catch (failure) {
299
- if (mine === listSeq.current) setError(failure instanceof Error ? failure.message : 'Failed to load changes');
329
+ if (epoch === contextEpoch.current && mine === listSeq.current) setError(failure instanceof Error ? failure.message : 'Failed to load changes');
300
330
  return undefined;
301
- } finally { if (mine === listSeq.current) setLoading(false); }
302
- }, [agentId, projectId, convFilter]);
331
+ } finally { if (epoch === contextEpoch.current && mine === listSeq.current) setLoading(false); }
332
+ }, [client, agentId, projectId, convFilter, nextCursor]);
303
333
 
304
334
  const refreshAll = useCallback(async () => {
305
335
  await Promise.all([fetchPending(true), inventory.refresh(true)]);
@@ -307,10 +337,14 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
307
337
  }, [fetchPending, inventory.refresh, refreshPendingCount, agentId, projectId]);
308
338
 
309
339
  useEffect(() => {
310
- previews.current.clear(); setReview(null); setChanges([]); setOrphanIds(new Set()); setResults([]);
340
+ contextEpoch.current++; repairStop.current = true; repairCursor.current = undefined; setRepairPaused(false);
341
+ relisted.current.resolve(undefined); relisted.current = Promise.withResolvers(); refetches.current.clear();
342
+ setRepairing(false); setBusyId(null); setApprovingAll(false); setConfirmAll(false); setConfirmRepair(false);
343
+ classified.current.clear(); previews.current.clear(); setReview(null); changesRef.current = [];
344
+ setChanges([]); setOrphanIds(new Set()); setResults([]); setNextCursor(undefined); setPartial(false); setHeld(null);
311
345
  void fetchPending(true);
312
- return () => { listSeq.current++; previews.current.clear(); };
313
- }, [fetchPending]);
346
+ return () => { contextEpoch.current++; listSeq.current++; orphanSeq.current++; repairStop.current = true; previews.current.clear(); };
347
+ }, [client, agentId, projectId, convFilter]);
314
348
  // A source revision re-reads the plain list, never the orphan probe; a context change is the effect above's.
315
349
  const fetchPendingRef = useRef(fetchPending); fetchPendingRef.current = fetchPending;
316
350
  const firstRevision = useRef(true);
@@ -372,11 +406,12 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
372
406
  */
373
407
  const dropResolvedDeleteRow = useCallback((rec: PendingChangeRecord) => {
374
408
  if (rec.op !== 'delete' || !rec.uri) return;
375
- const node = useFilesystemStore.getState().files[rec.uri];
409
+ const node = storeApi.getState().files[rec.uri];
376
410
  if (!node || node.status === 'deleted') removeFilePath(rec.uri);
377
- }, [removeFilePath]);
411
+ }, [removeFilePath, storeApi]);
378
412
 
379
413
  const afterMutation = useCallback(async (done: ReviewResult[]) => {
414
+ const epoch = contextEpoch.current;
380
415
  for (const result of done) {
381
416
  const rec = changes.find(c => c.id === result.id);
382
417
  if (rec && result.outcome === 'ok' && result.action !== 'revert') dropResolvedDeleteRow(rec);
@@ -387,48 +422,66 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
387
422
  if (refused.length) setNonces(prev => { const next = { ...prev }; for (const r of refused) next[r.id] = (next[r.id] ?? 0) + 1; return next; });
388
423
  setResults(done);
389
424
  const after = await fetchPending(false);
425
+ if (epoch !== contextEpoch.current) return;
390
426
  // The STORE's value, not the last render's: the batch's own bumps may not have rendered yet.
391
- listedRevisions.current = useFilesystemStore.getState().sourceChangeRevision; setHeld(null);
427
+ listedRevisions.current = storeApi.getState().sourceChangeRevision; setHeld(null);
392
428
  relisted.current.resolve(after); relisted.current = Promise.withResolvers();
393
429
  void refreshPendingCount({ agentId, projectId });
394
- }, [changes, dropResolvedDeleteRow, fetchPending, refreshPendingCount, agentId, projectId]);
430
+ }, [changes, dropResolvedDeleteRow, fetchPending, refreshPendingCount, agentId, projectId, storeApi]);
395
431
 
396
432
  const resolveReview = useCallback(async (rec: PendingChangeRecord, action: ReviewAction) => {
397
433
  if (!canWrite) return;
398
434
  const preview = previews.current.get(rec.id);
399
435
  if (!preview || preview.requiresAcknowledgement.length) { setReview({ kind: 'single', rec, action, ...(preview ? { preview } : {}) }); return; }
436
+ const epoch = contextEpoch.current;
400
437
  setBusyId(rec.id);
401
- try { await afterMutation([await applyReview({ agentId, projectId }, rec, preview, action, false)]); }
402
- finally { setBusyId(null); }
403
- }, [canWrite, agentId, projectId, afterMutation]);
438
+ try { const result = await applyReview({ agentId, projectId, client }, rec, preview, action, false); if (epoch === contextEpoch.current) await afterMutation([result]); }
439
+ finally { if (epoch === contextEpoch.current) setBusyId(null); }
440
+ }, [client, canWrite, agentId, projectId, afterMutation]);
404
441
 
405
442
  // Discard a single ORPHANED row — removes the review record + baseline blob + vector point.
406
443
  const discard = useCallback(async (rec: PendingChangeRecord) => {
444
+ const epoch = contextEpoch.current;
407
445
  setBusyId(rec.id);
408
446
  try {
409
- const res = await discardPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id });
447
+ const res = await discardPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }, client);
448
+ if (epoch !== contextEpoch.current) return;
410
449
  if (!res.ok) setError(res.error || 'Discard failed'); else dropResolvedDeleteRow(rec);
411
450
  await fetchPending(false);
412
451
  void refreshPendingCount({ agentId, projectId });
413
- } catch (e) { setError(e instanceof Error ? e.message : 'Discard failed'); } finally { setBusyId(null); }
414
- }, [agentId, projectId, fetchPending, dropResolvedDeleteRow, refreshPendingCount]);
452
+ } catch (e) { if (epoch === contextEpoch.current) setError(e instanceof Error ? e.message : 'Discard failed'); } finally { if (epoch === contextEpoch.current) setBusyId(null); }
453
+ }, [client, agentId, projectId, fetchPending, dropResolvedDeleteRow, refreshPendingCount]);
415
454
 
416
455
  // Repair: bulk-discard every orphaned change the caller can write. Which records
417
456
  // went is read from the refetch — the route re-authorizes each one.
418
457
  const repair = useCallback(async () => {
419
458
  if (repairTimer.current) { clearTimeout(repairTimer.current); repairTimer.current = null; }
420
- setRepairing(true); setConfirmRepair(false); setError(null);
459
+ const epoch = contextEpoch.current;
460
+ repairStop.current = false; holdRevisions(); setRepairing(true); setConfirmRepair(false); setError(null);
461
+ let cursor = repairCursor.current; setRepairPaused(false);
462
+ let scanned = 0; let discarded = 0; let refused = 0; let failed = 0;
421
463
  try {
422
- await discardOrphanedPending({ agentId, ...(projectId ? { projectId } : {}), ...(convFilter ? { conversationId: convFilter } : {}) });
423
- const candidates = changes.filter((rec) => orphanIds.has(rec.id));
424
- const after = await fetchPending(true);
425
- if (after) {
426
- const survivors = new Set(after.map((rec) => rec.id));
427
- for (const rec of candidates) if (!survivors.has(rec.id)) dropResolvedDeleteRow(rec);
428
- }
464
+ do {
465
+ const page = await discardOrphanedPending({ agentId, ...(projectId ? { projectId } : {}),
466
+ ...(convFilter ? { conversationId: convFilter } : {}), ...(cursor ? { cursor } : {}) }, client);
467
+ if (epoch !== contextEpoch.current) return;
468
+ scanned += page.scannedVisible; discarded += page.discarded; refused += page.refused; failed += page.failed;
469
+ setRepairProgress(`${scanned} scanned · ${discarded} discarded · ${refused} refused · ${failed} failed`);
470
+ cursor = page.nextCursor; repairCursor.current = cursor;
471
+ if (page.restartRequired) { repairCursor.current = undefined; setRepairPaused(true); setError('Changes moved. Refresh before continuing repair.'); break; }
472
+ if (page.failed > 0) { setRepairPaused(true); setError('Repair paused after a failed change. Refresh before continuing.'); break; }
473
+ if (page.partial) { setRepairPaused(true); setError('Repair paused; part of the inventory or orphan checks are unavailable.'); break; }
474
+ } while (cursor && !repairStop.current);
475
+ if (cursor) setRepairPaused(true);
476
+ if (epoch !== contextEpoch.current) return;
477
+ await fetchPending(true);
478
+ if (epoch !== contextEpoch.current) return;
479
+ listedRevisions.current = storeApi.getState().sourceChangeRevision; setHeld(null);
429
480
  void refreshPendingCount({ agentId, projectId });
430
- } catch (e) { setError(e instanceof Error ? e.message : 'Repair failed'); } finally { setRepairing(false); }
431
- }, [agentId, projectId, convFilter, fetchPending, changes, orphanIds, dropResolvedDeleteRow, refreshPendingCount]);
481
+ } catch (e) { if (epoch === contextEpoch.current) { setRepairPaused(true); setError(e instanceof Error ? e.message : 'Repair failed'); } }
482
+ finally { if (epoch === contextEpoch.current) { setRepairing(false); setHeld(null); } }
483
+ }, [client, agentId, projectId, convFilter, fetchPending, refreshPendingCount, holdRevisions, storeApi]);
484
+
432
485
  const onRepairClick = useCallback(() => {
433
486
  if (confirmRepair) { void repair(); return; }
434
487
  setConfirmRepair(true);
@@ -440,27 +493,32 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
440
493
  // confirm). A record whose fresh preview turns out to need one is reported AND handed
441
494
  // to the grouped review dialog, where it is approved or dismissed with its acknowledgement.
442
495
  const approveAll = useCallback(async () => {
496
+ const epoch = contextEpoch.current;
443
497
  setApprovingAll(true); setConfirmAll(false); setError(null); holdRevisions();
444
498
  const done: ReviewResult[] = [];
445
499
  try {
446
500
  for (const rec of approvable) {
501
+ if (epoch !== contextEpoch.current) break;
447
502
  let preview = previews.current.get(rec.id);
448
503
  if (!preview) {
449
- try { preview = await previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }); }
504
+ try { preview = await previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }, client); }
450
505
  catch (e) { done.push({ id: rec.id, uri: rec.uri, action: 'approve', outcome: 'error', detail: e instanceof Error ? e.message : 'Preview failed' }); continue; }
506
+ if (epoch !== contextEpoch.current) break;
451
507
  previews.current.set(rec.id, preview);
452
508
  }
453
509
  if (preview.requiresAcknowledgement.length) { done.push({ id: rec.id, uri: rec.uri, action: 'approve', outcome: 'needs-ack' }); continue; }
454
- done.push(await applyReview({ agentId, projectId }, rec, preview, 'approve', false));
510
+ done.push(await applyReview({ agentId, projectId, client }, rec, preview, 'approve', false));
455
511
  }
456
512
  } finally {
513
+ if (epoch !== contextEpoch.current) return;
457
514
  setApprovingAll(false);
458
515
  const handed = approvable.filter(rec => done.some(r => r.id === rec.id && r.outcome === 'needs-ack'));
459
516
  if (handed.length) setAckPreviews(n => n + 1);
460
517
  await afterMutation(done);
518
+ if (epoch !== contextEpoch.current) return;
461
519
  if (handed.length && canWrite) { reviewed.current.clear(); setReview({ kind: 'group', records: handed.slice(0, REVIEW_BATCH), total: handed.length }); }
462
520
  }
463
- }, [approvable, agentId, projectId, afterMutation, canWrite]);
521
+ }, [approvable, agentId, projectId, client, afterMutation, canWrite]);
464
522
 
465
523
  const move = useCallback((delta: number) => setFocusIndex((i) => (visible.length === 0 ? 0
466
524
  : Math.max(0, Math.min(visible.length - 1, i + delta)))), [visible.length]);
@@ -511,6 +569,7 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
511
569
  </div>;
512
570
  };
513
571
 
572
+ const renderedEpoch = contextEpoch.current;
514
573
  const failed = results.filter(r => r.outcome !== 'ok');
515
574
  const busy = loading || inventory.loading;
516
575
  return (
@@ -519,7 +578,7 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
519
578
  key={review.kind === 'group' ? `group\u0000${review.records[0]?.id ?? ''}` : `single\u0000${review.rec.id}`}
520
579
  onClose={() => setReview(null)} next={review.kind === 'group' ? unreviewed.filter(rec => !review.records.some(r => r.id === rec.id)).length : 0}
521
580
  onNext={openReviewBatch} refetch={refetch} onBatchStart={holdRevisions}
522
- onDone={done => { for (const result of done) reviewed.current.add(result.id); void afterMutation(done); }} /> : null}
581
+ onDone={done => { if (renderedEpoch !== contextEpoch.current) return; for (const result of done) reviewed.current.add(result.id); void afterMutation(done); }} /> : null}
523
582
  {/* Header: title + nav cursor + approve-all + view toggle + refresh */}
524
583
  <div className="flex flex-wrap items-center gap-1.5 px-3 py-2 border-b border-white/5 shrink-0">
525
584
  <span className="text-xs font-semibold text-white/80">Changes</span>
@@ -570,10 +629,15 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
570
629
  </button>
571
630
  {/* Additive — the per-row buttons stay the primary path. */}
572
631
  <SkillLauncher variant="icon" size="sm" skill="manage-files" packageId="@neuralis/brain-core"
573
- scripts={["scripts/pending.sh", "scripts/approve.sh"]} label="Review or approve pending changes with the agent" icon="FileText" />
632
+ label="Review or approve pending changes with the agent" icon="FileText" />
574
633
  </div>
575
634
  </div>
576
635
 
636
+ {repairing ? <button type="button" onClick={() => { repairStop.current = true; }} className="px-3 py-1 text-xs text-amber-200">Stop after this batch</button> : null}
637
+ {repairProgress ? <div role="status" className="px-3 py-1 text-xs text-white/60">{repairProgress}</div> : null}
638
+ {canWrite && repairPaused && !repairing ? <button type="button" onClick={() => void repair()} className="px-3 py-1 text-xs text-amber-200">{repairCursor.current ? 'Continue available repair' : 'Retry repair'}</button> : null}
639
+ {partial ? <div role="status" className="px-3 py-1 text-xs text-amber-200">Showing available changes; some inventory or orphan checks are unavailable.</div> : null}
640
+ {nextCursor ? <button type="button" disabled={loading || repairing} onClick={() => void fetchPending(true, true)} className="px-3 py-1 text-xs text-violet-200 disabled:opacity-40">Load more</button> : null}
577
641
  {/* Filter bar */}
578
642
  <div className="flex flex-wrap items-center gap-2 px-3 py-1.5 border-b border-white/5 shrink-0 text-[11px]">
579
643
  {convFilter ? <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-200/90" title={convFilter}>