@neuralis/brain-core 0.2.1 → 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 (103) hide show
  1. package/README.md +8 -3
  2. package/app/filesystem/api/README.md +1 -1
  3. package/app/filesystem/api/index.ts +24 -24
  4. package/app/filesystem/store/README.md +1 -1
  5. package/app/filesystem/store/filesystemStore.ts +11 -3
  6. package/app/filesystem/store/listSlice.ts +1 -0
  7. package/app/filesystem/ui/FileEditor.tsx +42 -4
  8. package/app/filesystem/ui/GitControlPanel.tsx +0 -1
  9. package/app/filesystem/ui/GitDiffView.tsx +12 -5
  10. package/app/filesystem/ui/GitFileDiff.tsx +15 -9
  11. package/app/filesystem/ui/PendingChangesPanel.tsx +95 -34
  12. package/app/filesystem/ui/README.md +9 -9
  13. package/app/filesystem/ui/SourcesPanel.tsx +1 -3
  14. package/app/filesystem/ui/Toolbar.tsx +5 -4
  15. package/app/filesystem/ui/VectorHealthPanel.tsx +43 -14
  16. package/dist/app/host.js +40 -40
  17. package/dist/src/config/env.d.ts +4 -0
  18. package/dist/src/config/env.d.ts.map +1 -1
  19. package/dist/src/config/env.js +13 -0
  20. package/dist/src/config/env.js.map +1 -1
  21. package/dist/src/connectors/LocalDiskConnector.d.ts +9 -1
  22. package/dist/src/connectors/LocalDiskConnector.d.ts.map +1 -1
  23. package/dist/src/connectors/LocalDiskConnector.js +51 -2
  24. package/dist/src/connectors/LocalDiskConnector.js.map +1 -1
  25. package/dist/src/domain/healthTypes.d.ts +1 -7
  26. package/dist/src/domain/healthTypes.d.ts.map +1 -1
  27. package/dist/src/domain/pendingChange.d.ts +31 -0
  28. package/dist/src/domain/pendingChange.d.ts.map +1 -1
  29. package/dist/src/domain/pendingChange.js.map +1 -1
  30. package/dist/src/routes/health.js +2 -3
  31. package/dist/src/routes/health.js.map +1 -1
  32. package/dist/src/routes/pending.d.ts +7 -7
  33. package/dist/src/routes/pending.d.ts.map +1 -1
  34. package/dist/src/routes/pending.js +21 -21
  35. package/dist/src/routes/pending.js.map +1 -1
  36. package/dist/src/services/FilesystemService.d.ts +13 -61
  37. package/dist/src/services/FilesystemService.d.ts.map +1 -1
  38. package/dist/src/services/FilesystemService.js +47 -189
  39. package/dist/src/services/FilesystemService.js.map +1 -1
  40. package/dist/src/services/FilesystemUiService.d.ts +33 -1
  41. package/dist/src/services/FilesystemUiService.d.ts.map +1 -1
  42. package/dist/src/services/FilesystemUiService.js +50 -1
  43. package/dist/src/services/FilesystemUiService.js.map +1 -1
  44. package/dist/src/store/PendingChangeStore.d.ts +3 -0
  45. package/dist/src/store/PendingChangeStore.d.ts.map +1 -1
  46. package/dist/src/store/PendingChangeStore.js +84 -25
  47. package/dist/src/store/PendingChangeStore.js.map +1 -1
  48. package/dist/src/usecases/orphanedPending.d.ts +2 -2
  49. package/dist/src/usecases/orphanedPending.d.ts.map +1 -1
  50. package/dist/src/usecases/orphanedPending.js +5 -3
  51. package/dist/src/usecases/orphanedPending.js.map +1 -1
  52. package/dist/src/usecases/pendingListing.d.ts +34 -0
  53. package/dist/src/usecases/pendingListing.d.ts.map +1 -0
  54. package/dist/src/usecases/pendingListing.js +244 -0
  55. package/dist/src/usecases/pendingListing.js.map +1 -0
  56. package/dist/src/usecases/vectorMaintenance.d.ts +6 -9
  57. package/dist/src/usecases/vectorMaintenance.d.ts.map +1 -1
  58. package/dist/src/usecases/vectorMaintenance.js +4 -13
  59. package/dist/src/usecases/vectorMaintenance.js.map +1 -1
  60. package/dist/src/vector/InMemoryVectorIndex.d.ts.map +1 -1
  61. package/dist/src/vector/InMemoryVectorIndex.js +30 -9
  62. package/dist/src/vector/InMemoryVectorIndex.js.map +1 -1
  63. package/package.json +26 -4
  64. package/rules/brain-rules.md +1 -1
  65. package/skills/create-package/SKILL.md +3 -3
  66. package/skills/create-package/references/config-and-credentials.md +1 -1
  67. package/skills/create-package/references/runtime-and-build.md +1 -1
  68. package/skills/create-package/references/skills-and-context.md +25 -12
  69. package/skills/create-package/references/test-and-ship.md +9 -16
  70. package/skills/create-package/scripts/verify-loaded.sh +18 -27
  71. package/skills/git/SKILL.md +27 -29
  72. package/skills/manage-files/SKILL.md +37 -32
  73. package/skills/manage-files/scripts/upload.sh +7 -9
  74. package/skills/manage-files/scripts/write.sh +2 -6
  75. package/skills/manage-sources/SKILL.md +93 -95
  76. package/skills/manage-sources/scripts/reembed.sh +2 -6
  77. package/skills/manage-uri-policy/SKILL.md +30 -58
  78. package/skills/git/scripts/git.sh +0 -48
  79. package/skills/manage-files/scripts/approve.sh +0 -9
  80. package/skills/manage-files/scripts/list.sh +0 -9
  81. package/skills/manage-files/scripts/pending.sh +0 -23
  82. package/skills/manage-files/scripts/raw.sh +0 -11
  83. package/skills/manage-files/scripts/read.sh +0 -9
  84. package/skills/manage-files/scripts/search.sh +0 -15
  85. package/skills/manage-sources/scripts/browse.sh +0 -12
  86. package/skills/manage-sources/scripts/cancel.sh +0 -14
  87. package/skills/manage-sources/scripts/create.sh +0 -13
  88. package/skills/manage-sources/scripts/delete.sh +0 -17
  89. package/skills/manage-sources/scripts/discoverable.sh +0 -6
  90. package/skills/manage-sources/scripts/get.sh +0 -7
  91. package/skills/manage-sources/scripts/kind-schema.sh +0 -7
  92. package/skills/manage-sources/scripts/kinds.sh +0 -6
  93. package/skills/manage-sources/scripts/list.sh +0 -6
  94. package/skills/manage-sources/scripts/purge-index.sh +0 -18
  95. package/skills/manage-sources/scripts/reindex.sh +0 -13
  96. package/skills/manage-sources/scripts/reset.sh +0 -11
  97. package/skills/manage-sources/scripts/set-enabled.sh +0 -17
  98. package/skills/manage-sources/scripts/sync-status.sh +0 -14
  99. package/skills/manage-sources/scripts/sync.sh +0 -13
  100. package/skills/manage-sources/scripts/update.sh +0 -18
  101. package/skills/manage-uri-policy/scripts/manifests.sh +0 -20
  102. package/skills/manage-uri-policy/scripts/set-uri.sh +0 -66
  103. package/skills/manage-uri-policy/scripts/show-uri.sh +0 -23
package/README.md CHANGED
@@ -68,6 +68,11 @@ occupancy answer is ONE primitive (`uriOccupied` in `usecases/write/occupancy.ts
68
68
  at any status ∪ the connector — the disk decides, never the user-scoped index alone). A HUMAN's
69
69
  structural op commits immediately; an AGENT's stays `pending_review` (the
70
70
  `interactive` marker — see `src/routes/README.md` + `app/filesystem/ui/README.md`).
71
+ Pending pages expose visible-id continuation and complete/partial/restart state. Full
72
+ projected vector reconstruction plus canonical URI twins occurs before slicing, so page
73
+ size bounds output rather than scan cost. Journal retention defaults to 32 MiB; oversize
74
+ journals rebuild per operation. Repair defaults to 50 visible records per batch with fresh
75
+ per-record authorization.
71
76
 
72
77
  URI schemes you will see in production:
73
78
 
@@ -975,7 +980,7 @@ There is intentionally no README inside the contribution folders; this section i
975
980
  - `init` delegates to `wiring.buildBrainState`, wiring the declared `ctx.config` reader and GLOBAL-only `ctx.credentials.resolveCredential` into the embedding provider before shared infrastructure is built. Platform facts are read lazily through `ctx.platform`; no config setter or source-running bootstrap push is exposed. Credential changes reach the bounded lifecycle hook, and runtime health reads structured vector checks.
976
981
  - **Data formats.** brain-core's three durable record kinds — `brain-core/embedding-lock` (v3), `brain-core/source-config` (v2) and `brain-core/pending-changes` (v1, the `_pending/changes.jsonl` journal) — are declared on the kernel's data-format ledger at module load (`src/store/dataFormats.ts`) and claimed at the top of `init`, before the first read: a kind a newer binary raised stops the init by name. Per record, the lock's `version` and a source config's or pending change's `formatVersion` (absent = 1) are checked before every rewrite — the check reads the STORED record, never the incoming one, so a config built fresh over a newer file is refused too — and a newer record is never rewritten (the journal's appends stay open; its compaction refuses). Every source-config write stamps the declared version (`SOURCE_CONFIG_FORMAT.version`, 2); an identical re-upsert still writes nothing. `embedding-lock` v3 and `source-config` v2 are `upgrade: offline` kinds: the app refuses to start over older data naming `neuralis upgrade`, which raises them (`scripts/embeddingLockV3.ts`, exported as `@neuralis/brain-core/upgrade`). Source configs are written durably, and one that fails validation is skipped AND logged (`source-config.invalid-skipped`), never silently gone.
977
982
  - **Declared credentials + dead-sweep.** brain-core's manifest declares the four embedding credentials — `embedding.openai`, `embedding.gemini`, `embedding.qwen`, `embedding.voyage` — in `neuralis.credentials[]` (`category: 'vector'`), so they appear in the admin catalog for an owner to set; an operator endpoint's `embedding.endpoint.<id>` is a dynamic family, never declared. `qdrant.apiKey` is deliberately NOT declared: `getQdrantClient()` is synchronous and boot-critical, so it structurally cannot read the credential store; the value comes from `QDRANT_API_KEY` in the boot env (Qdrant is infrastructure topology, not a per-scope secret — see `src/config/README.md`; setup writes the line itself and the server enforces the key on every request, with a mismatch surfacing as the structured `vector.qdrant.unauthorized` log line + a degraded Vector tab, not a boot death). `src/config/env.ts` was swept of dead credential plumbing: the old `resolveCredential()` helper, `BRAIN_CREDENTIAL_MAP`, and the `s3.*` / `media.apiKey` env reads (declared nowhere, consumed nowhere) are all removed. Only `getNeuralisEnv().qdrant.*` (URL/apiKey/collection — boot-critical infra topology from `process.env`) and the registry-driven embedding resolvers remain.
978
- - **Declared config settings.** brain-core's manifest declares its **84** platform-config keys in `neuralis.configSettings[]` (`PackageConfigSetting[]`) — sync/scan/chunking/media/binary-sniff/git-panel/watch-recovery/vector-maintenance/vector-write-retry/pending-orphan-probe/search caps and timeouts across three categories: `brain` (78 keys), `vector` (5) and `debug` (1). (Re-count the manifest array before quoting this; it has been stale before.) The originals: the `brain` category (`brainAutoSyncMode`, `brainMaxScanFiles`, `inlineVideoMaxMb`, `brainSyncConcurrency`, `brainOllamaEmbedConcurrency`, `brainDebug` — `brainEmbedConcurrency` was DELETED: every embedder implements `embedBatch`, so the only fan-out fallback was dead code), the `vector` category (`embeddingModelId`, `embeddingDimension`, `ollamaUrl`, `openaiBaseUrl`, and `embeddingEndpoints` — written only through the host's endpoint route — rendered by the admin Vector tab, not the flat list), the embedding keys in `brain` (`embeddingEnabled`, `embeddingRequestTimeoutMs`, `qwenEmbeddingBaseUrl`, `embeddingDailyUsdCap`, `vectorKeepPreviousGeneration`), the media lane's `brainSyncMaxMediaMb` and `brainSyncMaxPdfPages`, and the `debug` category (`brainVectorLogConsole` — the stdout tee described under Observability below). The host registers them into `PlatformConfigStore` at bootstrap via the agent-core collector (first-party trust-gated); values reach brain-core through the injected `BrainConfigProvider`; the shared `getBrainConfigNumber(key, env, default)` live reader (config/env.ts) backs `brainSyncConcurrency` (DeltaSyncRunner) and `brainOllamaEmbedConcurrency` (the OllamaEmbedder semaphore, embeddings/factory.ts — applies on restart/embedder rebuild). The agent-core `configDeclarationDrift` test pins brain-core's consumed ⊆ declared set bidirectionally.
983
+ - **Declared config settings.** brain-core's manifest declares its **86** platform-config keys in `neuralis.configSettings[]` (`PackageConfigSetting[]`) — sync/scan/chunking/media/binary-sniff/git-panel/watch-recovery/vector-maintenance/vector-write-retry/pending-orphan-probe/search caps and timeouts across three categories: `brain` (80 keys), `vector` (5) and `debug` (1). (Re-count the manifest array before quoting this; it has been stale before.) The originals: the `brain` category (`brainAutoSyncMode`, `brainMaxScanFiles`, `inlineVideoMaxMb`, `brainSyncConcurrency`, `brainOllamaEmbedConcurrency`, `brainDebug` — `brainEmbedConcurrency` was DELETED: every embedder implements `embedBatch`, so the only fan-out fallback was dead code), the `vector` category (`embeddingModelId`, `embeddingDimension`, `ollamaUrl`, `openaiBaseUrl`, and `embeddingEndpoints` — written only through the host's endpoint route — rendered by the admin Vector tab, not the flat list), the embedding keys in `brain` (`embeddingEnabled`, `embeddingRequestTimeoutMs`, `qwenEmbeddingBaseUrl`, `embeddingDailyUsdCap`, `vectorKeepPreviousGeneration`), the media lane's `brainSyncMaxMediaMb` and `brainSyncMaxPdfPages`, and the `debug` category (`brainVectorLogConsole` — the stdout tee described under Observability below). The host registers them into `PlatformConfigStore` at bootstrap via the agent-core collector (first-party trust-gated); values reach brain-core through the injected `BrainConfigProvider`; the shared `getBrainConfigNumber(key, env, default)` live reader (config/env.ts) backs `brainSyncConcurrency` (DeltaSyncRunner) and `brainOllamaEmbedConcurrency` (the OllamaEmbedder semaphore, embeddings/factory.ts — applies on restart/embedder rebuild). The agent-core `configDeclarationDrift` test pins brain-core's consumed ⊆ declared set bidirectionally.
979
984
  - The `CommunityInfraFactory` uses the kernel’s bounded `ProjectServiceFactory` internally. Routes and tools resolve services via `state.infraFactory.getServices(projectId)` — no global default-project state.
980
985
  - Source connectors are declared in package manifests via `neuralis.connectors[]`. Connector **kinds** register into the host's `SourceKindRegistry` at boot (the factory templates); Brain-core itself contributes the built-in `local` and `brain` kinds via `LocalDiskConnector` and `BrainInternalConnector`. Connector **instances** are bound PER PROJECT — `buildCommunityFilesystemInfra` constructs a fresh `ConnectorRegistry` inside each project's `FilesystemInfra` and `registerForSource`s that project's source slugs into it. Two projects with the same local slugs (`data`/`packages`/`brain`/`app`) therefore never collide; the previously-shared host registry was last-writer-wins and leaked one tenant's filesystem (and poisoned the shared vector index) into another. The host-shared registry handed in at bootstrap is retained only as the boot-readiness gate, no longer a source-binding store.
981
986
  - Brain-core declares **no hooks**. The former `hooks.json` / `host.ts` `onFileCreated` / `onFileModified` PostToolUse callbacks were deleted: they were intentional no-ops (the create/modify use cases update the vector index inline), and their registration produced a noisy `[hooks] Callback resolution failed` warning whenever `dist/src/host.js` drifted. The `./host` package export was removed with them.
@@ -1178,7 +1183,7 @@ a deletion; it declares `Allow: POST` (the host catch-all forwards route headers
1178
1183
  only on content-type-bearing responses, so it is not on the wire yet). The arm is
1179
1184
  fenced, not removed: a `drive.write` holder (manager tier and up) runs every
1180
1185
  action exactly as before, and the Vector Health panel already posts.
1181
- `action:"repair-pending"` (dry-run first in the panel) works on the CALLER's own
1186
+ `action:"repair-pending"` (dry-run supported in the panel) works on the CALLER's own
1182
1187
  visible pending records, never the project scope: it counts orphaned and
1183
1188
  older unknown-original records and, applied, discards the orphans through the
1184
1189
  per-record discard (WRITE re-authorized per record); the unknown-original ones
@@ -1520,7 +1525,7 @@ the `execute action="skill"` + `execute action="shell"` pipeline:
1520
1525
  | `manage-sources` | `/sources/*` CRUD + sync / sync-status / cancel / reset / reindex / enabled lifecycle, scoped index purge, kind catalog, browse + discoverable roots (both `drive.mount`, not the skill's `drive.read` floor) | 17 |
1521
1526
  | `manage-files` | `/list`, `/search`, `/read`, `/raw`, `/create`, `/modify`, `/mkdir`, `/copy`, `/upload`, `/approve`, `/pending` (bulk; `fs_*` tools remain primary for one-offs). `/create` and `/upload` both use the write engine and `drive.write`; agent-origin uploads are journaled as pending review, while interactive human uploads land committed | 11 |
1522
1527
  | `manage-uri-policy` | `/policies/uri/*` — read manifests baselines, read effective merged policy, patch path rules. Reads are the route's `drive.read` module feature (every built-in role holds it); the patch escalates in-handler to `drive.policy` — a feature check, not a role-name check. Immutable `floor:true` rules (trusted-manifest-only) can't be created or removed here — the route answers `409 immutable_policy_floor`. | 3 |
1523
- | `git` | `/git/*` — the same routes the Git Control panel drives. Reads: status / branch / diff / log / log-stats / commit / outgoing / show / repos. Writes: stage / unstage / discard, commit, branch / checkout, `restore`, and fetch / pull / push (uri-policy `exec` + connected remote). ONE generic `git.sh <GET\|POST> <route>` helper; also documents the raw-shell and external-GitHub-MCP git planes (a routes-based skill, NOT a `git_*` MCP bundle). **The helper takes its HTTP VERB as a positional argument, so the mutating half of the skill is invisible to anything that classifies an example by the SCRIPT FILE** — 10 of the 19 routes are POST writes, and the skill's write examples are marked `NOT DISPOSABLE` (git routes only act on an already-attached source; there is no throwaway repo) with point-of-use consequences for commit / checkout / pull / push | 1 |
1528
+ | `git` | `/git/*` — the same routes the Git Control panel drives. Reads: status / branch / diff / log / log-stats / commit / outgoing / show / repos. Writes: stage / unstage / discard, commit, branch / checkout, `restore`, and fetch / pull / push (uri-policy `exec` + connected remote). its routes taught as `neuralis request <METHOD> @neuralis/brain-core/git/<route>` lines; also documents the raw-shell and external-GitHub-MCP git planes (a routes-based skill, NOT a `git_*` MCP bundle). **The helper takes its HTTP VERB as a positional argument, so the mutating half of the skill is invisible to anything that classifies an example by the SCRIPT FILE** — 10 of the 19 routes are POST writes, and the skill's write examples are marked `NOT DISPOSABLE` (git routes only act on an already-attached source; there is no throwaway repo) with point-of-use consequences for commit / checkout / pull / push | 1 |
1524
1529
 
1525
1530
  `manage-uri-policy` migrated here from `@neuralis/agent-core` on
1526
1531
  2026-05-16 because the route lives in brain-core (`src/routes/policies.ts`)
@@ -11,7 +11,7 @@ Typed client for brain-core package routes plus a small set of host-side helpers
11
11
  ## Public Surface
12
12
 
13
13
  - Filesystem (`index.ts`): `resolveList`, `fetchSourceCounts`, `readFiles`, `searchFiles`, `writeFile` (every write verb on the ONE `write` route) + `writeRefusalMessage`, `approveFileReview`, `uploadFile` (the multipart byte lane), `getRawFileUrl`, `checkVectorHealth` (cheap count-only stats), `scanVectorHealth` (expensive on-demand orphan/duplicate scan → `/health/scan`), `runHealthAction`.
14
- - Pending changes (`index.ts`): `listPendingChanges` (with `include_orphans` it returns `orphanIds` beside the rows, from the server's SAME listing pass — the panel needs both on every mount, and two calls ran the whole dual-source listing twice), `previewPendingChange` (authoritative current/baseline states and fingerprint), `revertPendingChange`, `scanOrphanedPending` (the orphaned subset alone; kept for callers that want only that), `discardPendingChange`, `dismissPendingChange`, `discardOrphanedPending`. Pending-backed approve/dismiss/revert carry the fresh `expectedFingerprint` and any required acknowledgement; a stale intent returns409. Types: `PendingChangeRecord`, `PendingChangesQuery`, `PendingPreview`, `PendingReviewIntent`.
14
+ - Pending changes (`index.ts`): `listPendingChanges` (with `include_orphans` it returns `orphanIds` beside the rows, from the server's SAME listing pass — the panel needs both on every mount, and two calls ran the whole dual-source listing twice), `previewPendingChange` (authoritative current/baseline states and fingerprint), `revertPendingChange`, `scanOrphanedPending` (the complete scanned page in `changes`, its orphan subset in `orphans`, and the same continuation), `discardPendingChange`, `dismissPendingChange`, `discardOrphanedPending`. Pending-backed approve/dismiss/revert carry the fresh `expectedFingerprint` and any required acknowledgement; a stale intent returns409. Types: `PendingChangeRecord`, `PendingChangesQuery`, `PendingPreview`, `PendingReviewIntent`. Pending clients preserve the page envelope and URI/cursor filters; count returns `{count, exact, complete, partial}` and safe unavailable branches; the store preserves its previous count when both branches fail.
15
15
  - Git (`index.ts`) — the `/git/*` client behind the Git Control panel. Reads: `getGitRepos`, `getGitStatus`, `getGitBranches`, `getGitDiff`, `getGitLog`, `getGitLogStats`, `getGitCommitFiles`, `getGitOutgoing`, `getGitShow`. Writes: `stagePaths`, `unstagePaths`, `stageHunk`, `unstageHunk`, `discardPaths`, `commitGit`, `createGitBranch`, `deleteGitBranch`, `restoreGit`, `checkoutGitBranch`. Remote (uri-policy `exec`): `fetchGit`, `pullGit`, `pushGit`. Type: `GitRescue` — the typed carrier a destructive op returns and `restoreGit` echoes back.
16
16
  - Sources (`index.ts`): `listSourceConfigs`, `updateSourceConfig`, `createSource`, `deleteSource`, `triggerSourceSync`, `getSyncStatus`, `cancelSync`, `resetSync`, `reindexSource` ("Rescan changes"), `estimateReembed` / `startReembed` (the same route with a `folder` / `uri` scope and `force: true` — a re-embed with the active model; the estimate answers before any side effect, a scope with nothing readable queues nothing), `purgeSourceIndex` (source/folder/file estimate or apply; keeps source and recovery content), `getSourcesHealth`, `browseDirectories`, `listSourceKinds`, `getSourceKindSchema`, `setSourceEnabled` (W3K), `listDiscoverableSources` (W3E). Types: `ReembedScope`, `ReembedEstimate`, `SourceKindRecord`, `SourceKindSchema`, `ResolveListOpts`, `DiscoverableSourceSuggestion`.
17
17
  - Project packages (`projectPackages.ts`): `listProjectPackages`, `buildPackage`, `setPackageTrust`, `setPackageAccessFeature`, `getGrantableFeatureIds` (the per-project base-access governance pair — see `../ui/README.md`), `getPackagesDiagnostics` (W3F — also consumed by chat-config Packages Overview through the agent-core ports). Types: `ProjectPackageRow`, `BuildResponse`, `TrustValue`, `PackageDiagnosticEntry`, `PackagesDiagnosticsResponse`.
@@ -943,7 +943,8 @@ export async function approveFileReview(params: {
943
943
  // ---------------------------------------------------------------------------
944
944
 
945
945
  export type { PendingChangeRecord, PendingPreview, PendingReviewIntent } from '@neuralis/brain-core/domain/pending-change';
946
- import type { PendingChangeRecord, PendingPreview } from '@neuralis/brain-core/domain/pending-change';
946
+ import type { PendingChangeRecord, PendingPreview, PendingChangePage, PendingCount, PendingRepairPage } from '@neuralis/brain-core/domain/pending-change';
947
+ export type { PendingChangePage, PendingCount, PendingRepairPage } from '@neuralis/brain-core/domain/pending-change';
947
948
 
948
949
  export async function previewPendingChange(params: { agentId: string; projectId?: string; id: string }, client: PackageApi): Promise<PendingPreview> {
949
950
  const result = await client.post('pending', { ...params, action: 'preview' }) as { preview: PendingPreview };
@@ -951,6 +952,8 @@ export async function previewPendingChange(params: { agentId: string; projectId?
951
952
  }
952
953
 
953
954
  export type PendingChangesQuery = {
955
+ cursor?: string;
956
+ uri?: string;
954
957
  agentId: string;
955
958
  projectId?: string;
956
959
  userId?: string;
@@ -974,26 +977,24 @@ export type PendingChangesQuery = {
974
977
  export async function listPendingChanges(
975
978
  params: PendingChangesQuery,
976
979
  client: PackageApi,
977
- ): Promise<{ ok: boolean; changes: PendingChangeRecord[]; orphanIds: string[] }> {
978
- const res = await client.post('pending', params) as {
979
- ok?: boolean; changes?: PendingChangeRecord[]; orphanIds?: string[];
980
- };
981
- return {
982
- ok: res?.ok !== false,
983
- changes: Array.isArray(res?.changes) ? res.changes : [],
984
- orphanIds: Array.isArray(res?.orphanIds) ? res.orphanIds : [],
985
- };
980
+ ): Promise<PendingChangePage & { ok: boolean }> {
981
+ const res = await client.post('pending', params) as PendingChangePage & { ok: boolean };
982
+ if (!Array.isArray(res.changes) || typeof res.complete !== 'boolean' || typeof res.partial !== 'boolean') {
983
+ throw new Error('Pending listing unavailable; retry the request');
984
+ }
985
+ return res;
986
986
  }
987
987
 
988
988
  /**
989
989
  * The caller's visible pending COUNT — the list's own per-record floor, no orphan
990
990
  * probe and no rows on the wire. Feeds the Changes badge.
991
991
  */
992
- export async function countPendingChanges(params: { agentId: string; projectId?: string }, client: PackageApi): Promise<number> {
992
+ export async function countPendingChanges(params: { agentId: string; projectId?: string }, client: PackageApi): Promise<PendingCount> {
993
993
  const qs: Record<string, string> = { agentId: params.agentId, count_only: '1' };
994
994
  if (params.projectId) qs.projectId = params.projectId;
995
- const res = await client.get('pending', qs) as { count?: number };
996
- return typeof res?.count === 'number' ? res.count : 0;
995
+ const res = await client.get('pending', qs) as PendingCount;
996
+ if (!Number.isInteger(res.count) || res.count < 0 || typeof res.exact !== 'boolean') throw new Error('Pending count unavailable');
997
+ return res;
997
998
  }
998
999
 
999
1000
  export async function revertPendingChange(params: {
@@ -1013,9 +1014,10 @@ export async function scanOrphanedPending(params: {
1013
1014
  agentId: string;
1014
1015
  projectId?: string;
1015
1016
  conversationId?: string;
1016
- }, client: PackageApi): Promise<{ ok: boolean; orphans: PendingChangeRecord[] }> {
1017
- const res = await client.post('pending', { ...params, action: 'scan-orphans' }) as { ok?: boolean; orphans?: PendingChangeRecord[] };
1018
- return { ok: res?.ok !== false, orphans: Array.isArray(res?.orphans) ? res.orphans : [] };
1017
+ cursor?: string;
1018
+ uri?: string;
1019
+ }, client: PackageApi): Promise<PendingChangePage & { ok: boolean; orphans: PendingChangeRecord[] }> {
1020
+ return client.post('pending', { ...params, action: 'scan-orphans' }) as Promise<PendingChangePage & { ok: boolean; orphans: PendingChangeRecord[] }>;
1019
1021
  }
1020
1022
 
1021
1023
  /** Discard ONE orphaned pending record (journal mark + blob + vector point) —
@@ -1042,15 +1044,12 @@ export async function dismissPendingChange(params: {
1042
1044
  return client.post('pending', { ...params, action: 'dismiss' }) as Promise<{ ok: boolean; id: string; uri?: string; error?: string; retryable?: boolean }>;
1043
1045
  }
1044
1046
 
1045
- /** Bulk-discard every orphaned pending change the caller can write (the Repair
1046
- * button). Returns the actual discarded count. */
1047
+ /** Repair one visible page through the shared owner; callers continue explicitly
1048
+ * with nextCursor and pause on partial/failure. Counters describe actual effects. */
1047
1049
  export async function discardOrphanedPending(params: {
1048
- agentId: string;
1049
- projectId?: string;
1050
- conversationId?: string;
1051
- }, client: PackageApi): Promise<{ ok: boolean; discarded: number }> {
1052
- const res = await client.post('pending', { ...params, action: 'discard-orphans' }) as { ok?: boolean; discarded?: number };
1053
- return { ok: res?.ok !== false, discarded: typeof res?.discarded === 'number' ? res.discarded : 0 };
1050
+ agentId: string; projectId?: string; conversationId?: string; cursor?: string;
1051
+ }, client: PackageApi): Promise<PendingRepairPage> {
1052
+ return client.post('pending', { ...params, action: 'discard-orphans' }) as Promise<PendingRepairPage>;
1054
1053
  }
1055
1054
 
1056
1055
  export function getRawFileUrl(agentId: string, uri: string, projectId?: string): string {
@@ -1088,6 +1087,7 @@ export async function runHealthAction(params: {
1088
1087
  action: 'gc' | 'repair-orphans' | 'deduplicate' | 'repair-pending';
1089
1088
  dryRun?: boolean;
1090
1089
  retentionHours?: number;
1090
+ cursor?: string;
1091
1091
  }, client: PackageApi): Promise<Record<string, unknown>> {
1092
1092
  return client.post('health/gc', params);
1093
1093
  }
@@ -16,7 +16,7 @@ Zustand stores powering the Files windows — ONE live store per window (file di
16
16
 
17
17
  - `reconcileSourceFolders({ agentId, projectId, source })` — re-lists ONE source's VISIBLE folders (its root + every loaded folder that is expanded AND whose every ancestor is expanded — a loaded folder under a collapsed ancestor is off screen and never a target) and applies absence semantics per **direct child** of a **non-truncated** listing: prune an `unindexed` external-only file, flip a tracked/`synced` file to `status:'deleted'` (struck-through and revertible, matching an in-platform delete), keep an existing tombstone, prune a folder when it parents nothing live — `presence` deliberately does NOT gate this: a folder has no pending record and no revert, so once it is gone from the listing there is nothing to preserve it for, and a `synced` folder left in place rendered with the ORDINARY tone, clearable only by a browser reload. A gone folder that still hosts revertible tombstones is KEPT but toned `deleted`, never left looking live. A **truncated** listing stays upsert-only (absence ≠ deletion).
18
18
  - `reconcileFolders({ agentId, projectId, folders })` — the same pass over specific folders. Every mutation tail uses it: a move/delete/paste knows exactly which parents changed. Targets that are not in `loadedFolders` are dropped and flag the source dirty; a STALE target (`false`) is dropped silently, because opening it re-lists it authoritatively. The list is grouped per source so the coalescer applies.
19
- - `refreshPendingCount({ agentId, projectId })` — re-reads `pendingCount` (the Changes badge) through `count_only`; a newer call or a context the store has left drops the answer (a same-context reset does not — the mount count must land), a failed count keeps the last number. `pendingCount` is a `contextResetPatch()` key.
19
+ - `refreshPendingCount({ agentId, projectId })` — re-reads `pendingCount` (the Changes badge) through `count_only`; a newer call or a context the store has left drops the answer (a same-context reset does not — the mount count must land), a failed count keeps the last number. `pendingCount` is a `contextResetPatch()` key. The badge stores exact/partial/stale state; failed requests preserve the previous number as stale, and context reset clears its authority.
20
20
  - `refreshTree({ agentId, projectId })` — the toolbar Refresh: source summaries + an absence pass over every hydrated source. `refresh` is deliberately NOT rewritten in place — the Sources panel's `withCounts` calls depend on its loading semantics.
21
21
 
22
22
  Two guards make this safe against the races a plain re-list would lose:
@@ -978,6 +978,7 @@ interface FilesystemStore extends FileSystemState, SearchSliceActions, ListSlice
978
978
  pendingReviewPaths: Set<string>;
979
979
  /** The caller's visible pending-review count (server `count_only`), shown on the Changes button. */
980
980
  pendingCount: number;
981
+ pendingCountState: 'exact' | 'partial' | 'stale';
981
982
  /** Re-read `pendingCount`; a newer call or another active context drops the answer. */
982
983
  refreshPendingCount: (params: { agentId: string; projectId: string | null }) => Promise<void>;
983
984
 
@@ -1194,6 +1195,7 @@ export function createFilesystemStore(init: FilesystemStoreInit): FilesystemStor
1194
1195
  snapshotError: undefined,
1195
1196
  pendingReviewPaths: new Set<string>(),
1196
1197
  pendingCount: 0,
1198
+ pendingCountState: 'stale',
1197
1199
 
1198
1200
  ...searchSliceInitial,
1199
1201
  ...createSearchSlice(set as any, get),
@@ -1791,15 +1793,21 @@ export function createFilesystemStore(init: FilesystemStoreInit): FilesystemStor
1791
1793
  if (!agentId) return;
1792
1794
  const mine = ++fences.pendingCountSeq;
1793
1795
  // A failed count keeps the last known number — the next event, focus or reconnect re-asks.
1794
- let count: number;
1795
- try { count = await countPendingChanges({ agentId, ...(projectId ? { projectId } : {}) }, client); } 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
+ }
1796
1801
  // No reset-epoch fence: the count is the context's own server answer, so one
1797
1802
  // that resolves after a reset of the SAME context is still right; the context
1798
1803
  // check below is what keeps another context's number out.
1799
1804
  if (mine !== fences.pendingCountSeq) return;
1800
1805
  const s = get();
1801
1806
  if (s.activeAgentId !== agentId || s.activeProjectId !== projectId) return;
1802
- 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' }));
1803
1811
  },
1804
1812
 
1805
1813
  async markFileDeleted({ agentId, projectId, node }) {
@@ -76,6 +76,7 @@ export function contextResetPatch(): Record<string, unknown> {
76
76
  pendingReviewPaths: new Set<string>(),
77
77
  // The Changes badge count is the context's own server answer.
78
78
  pendingCount: 0,
79
+ pendingCountState: 'stale',
79
80
  // Nothing is pre-expanded — under lazy hydration an expanded entry is a
80
81
  // FETCH, and every source branch (including `brain://`) starts collapsed.
81
82
  expandedFolders: new Set<string>(),
@@ -21,6 +21,10 @@ import { getRawFileUrl, approveFileReview, writeFile, writeRefusalMessage, getGi
21
21
  import { InlineUploader } from './InlineUploader';
22
22
  import { SourcesPanel } from './SourcesPanel';
23
23
  import { PackagesPanel } from './PackagesPanel';
24
+ import { PendingReviewDialog, type ReviewRequest, type ReviewAction } from './PendingReviewDialog';
25
+ import { useVisibleDiffCards } from '../hooks/useVisibleDiffCards';
26
+ import { normalizePolicyUri } from '@neuralis/package-system/policies';
27
+ import { listPendingChanges } from '../api';
24
28
  import { PendingChangesPanel } from './PendingChangesPanel';
25
29
  import { getDeltaTone } from '../lib/treeVisualState';
26
30
  import { fileKeyFromNode, folderUri, hasCompletedRead, sourceFromUri, extractFilename, sourceRootUri } from '../store/helpers';
@@ -99,6 +103,7 @@ export function FileEditor() {
99
103
  const [reverting, setReverting] = useState(false);
100
104
  const [showDiff, setShowDiff] = useState(true);
101
105
  const [metaExpanded, setMetaExpanded] = useState(false);
106
+ const refreshPendingCount = useFilesystemStore((s) => s.refreshPendingCount);
102
107
  const filePrevByPath = useFilesystemStore((s) => s.filePrevByPath);
103
108
  const activePrev = activeFile ? filePrevByPath[activeFile] : undefined;
104
109
  const hasPendingChanges = !!activePrev && activeNode?.status === 'pending_review';
@@ -138,6 +143,31 @@ export function FileEditor() {
138
143
  !showUploadTab && !showSourcesTab && !showPackagesTab && !showPendingTab && !showGitDiff && !showGitCommit;
139
144
  const rawAgentId = activeNode?.sourceAgentId ?? agentId ?? undefined;
140
145
  const targetAgentId = activeNode?.sourceAgentId ?? agentId ?? null;
146
+ const reviewQueue = useVisibleDiffCards(null);
147
+ const [pendingReview, setPendingReview] = useState<ReviewRequest | null>(null);
148
+ const [pendingReviewError, setPendingReviewError] = useState<string | null>(null);
149
+ const reviewEpoch = useRef(0);
150
+ const pendingReviewEpoch = useRef(0);
151
+ useEffect(() => {
152
+ reviewEpoch.current++; setPendingReview(null); setPendingReviewError(null);
153
+ return () => { reviewEpoch.current++; };
154
+ }, [client, targetAgentId, projectId, activeUri, activeFile]);
155
+ const openPendingReview = useCallback(async (action: ReviewAction) => {
156
+ if (!targetAgentId || !activeUri) return;
157
+ const epoch = reviewEpoch.current;
158
+ setPendingReviewError(null);
159
+ try {
160
+ const page = await listPendingChanges({ agentId: targetAgentId, ...(projectId ? { projectId } : {}), uri: activeUri }, client);
161
+ if (epoch !== reviewEpoch.current) return;
162
+ const records = page.changes.filter(rec => normalizePolicyUri(rec.uri) === normalizePolicyUri(activeUri));
163
+ if (page.partial || page.complete === false || page.restartRequired || records.length !== 1) {
164
+ setPendingReviewError('The pending change is unavailable or ambiguous. Refresh and try again.'); return;
165
+ }
166
+ pendingReviewEpoch.current = epoch;
167
+ setPendingReview({ kind: 'single', rec: records[0]!, action });
168
+ } catch (error) { if (epoch === reviewEpoch.current) setPendingReviewError(error instanceof Error ? error.message : 'Could not load the pending change.'); }
169
+ }, [client, targetAgentId, projectId, activeUri]);
170
+
141
171
  const rawUrl = useMemo(() => {
142
172
  if (!activeNode?.uri || !rawAgentId) return null;
143
173
  return getRawFileUrl(rawAgentId, activeNode.uri, projectId ?? undefined);
@@ -267,7 +297,7 @@ export function FileEditor() {
267
297
  const handleApprove = useCallback(async () => {
268
298
  if (!targetAgentId || !activeNode || approving) return;
269
299
  if (activeNode.status === 'pending_review') {
270
- setWorkspacePanelOpen('pending', true); setActiveWorkspaceTab('pending'); return;
300
+ await openPendingReview('approve'); return;
271
301
  }
272
302
  setApproving(true);
273
303
  try {
@@ -296,12 +326,12 @@ export function FileEditor() {
296
326
  } finally {
297
327
  setApproving(false);
298
328
  }
299
- }, [targetAgentId, activeNode, approving, projectId, openFile, resolvedAgent?.id, activeFile, patchFileNode, clearFilePrev]);
329
+ }, [openPendingReview, client, targetAgentId, activeNode, approving, projectId, openFile, resolvedAgent?.id, activeFile, patchFileNode, clearFilePrev]);
300
330
 
301
331
  const handleRevert = useCallback(async () => {
302
332
  if (!targetAgentId || !activeNode || reverting) return;
303
333
  if (activeNode.status === 'pending_review') {
304
- setWorkspacePanelOpen('pending', true); setActiveWorkspaceTab('pending'); return;
334
+ await openPendingReview('revert'); return;
305
335
  }
306
336
  setReverting(true);
307
337
  try {
@@ -335,7 +365,7 @@ export function FileEditor() {
335
365
  } finally {
336
366
  setReverting(false);
337
367
  }
338
- }, [targetAgentId, activeNode, reverting, projectId, openFile, resolvedAgent?.id, activeFile, activePrev, patchFileNode, clearFilePrev]);
368
+ }, [openPendingReview, client, targetAgentId, activeNode, reverting, projectId, openFile, resolvedAgent?.id, activeFile, activePrev, patchFileNode, clearFilePrev]);
339
369
 
340
370
  useEffect(() => {
341
371
  if (!activeNode) {
@@ -420,6 +450,14 @@ export function FileEditor() {
420
450
 
421
451
  return (
422
452
  <div className="h-full w-full flex flex-col min-w-0 min-h-0">
453
+ {pendingReviewError ? <div role="alert" className="px-3 py-2 text-xs text-red-300">{pendingReviewError}</div> : null}
454
+ {pendingReview && targetAgentId ? <PendingReviewDialog request={pendingReview} agentId={targetAgentId} projectId={projectId}
455
+ run={reviewQueue.run} onClose={() => setPendingReview(null)} onDone={done => {
456
+ if (pendingReviewEpoch.current !== reviewEpoch.current || !done.some(result => result.outcome === 'ok')) return;
457
+ setPendingReview(null);
458
+ void refreshPendingCount({ agentId: targetAgentId, projectId });
459
+ void openFile({ agentId: targetAgentId, projectId: projectId ?? null, uri: activeUri, force: true });
460
+ }} /> : null}
423
461
  {openFiles.length > 0 || workspacePanels.upload || (supportsSources && workspacePanels.sources) || workspacePanels.packages || workspacePanels.pending || gitDiffTarget || gitCommitTarget ? (
424
462
  <div
425
463
  className="flex shrink-0 items-stretch"
@@ -1202,7 +1202,6 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
1202
1202
  size="sm"
1203
1203
  skill="git"
1204
1204
  packageId="@neuralis/brain-core"
1205
- scripts={["scripts/git.sh"]}
1206
1205
  label="Status, diff, commit, branch, fetch or push with the agent"
1207
1206
  icon="GitBranch"
1208
1207
  />
@@ -27,15 +27,20 @@ export function GitDiffView({
27
27
  projectId: string | null;
28
28
  }): React.ReactElement {
29
29
  const client = useFilesystemStore((s) => s.client);
30
- const [canWrite, setCanWrite] = useState(false);
31
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;
32
36
  useEffect(() => {
33
- let alive = true; setCanWrite(false);
37
+ let alive = true;
38
+ setPermission(previous => ({ client, context, revision, pending: true, allowed: previous?.client === client && previous.context === context && previous.allowed }));
34
39
  if (agentId) void getGitRepos({ agentId, ...(projectId ? { projectId } : {}), source: target.source }, client).then(result => {
35
- if (alive) setCanWrite(result.repos.find(repo => repo.rel === (target.repo ?? ''))?.canWrite === true);
36
- }).catch(() => { if (alive) setCanWrite(false); });
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 }); });
37
42
  return () => { alive = false; };
38
- }, [agentId, projectId, target.source, target.repo, revision]);
43
+ }, [client, context, agentId, projectId, target.source, target.repo, revision]);
39
44
  const mode = useMemo(() => gitDiffModeFromTarget(target), [target]);
40
45
  return (
41
46
  <GitFileDiff
@@ -49,6 +54,8 @@ export function GitDiffView({
49
54
  projectId={projectId}
50
55
  variant="tab"
51
56
  canWrite={canWrite}
57
+ stageRows={stageRows}
58
+ permissionPending={!current || permission.pending || permission.revision !== revision}
52
59
  revision={revision}
53
60
  />
54
61
  );
@@ -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,6 +153,8 @@ 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. */
@@ -230,7 +234,7 @@ export function GitFileDiff({
230
234
  }, [language]);
231
235
 
232
236
  useEffect(() => {
233
- sourceCacheRef.current.clear();
237
+ sourceCacheRef.current.clear(); setHunkBusy(null); setHunkError(null);
234
238
  // New target/revision (or post-stage refetch): drop cached source and expansion state —
235
239
  // the ranges are relative to the OLD raw's original hunks — AND the
236
240
  // server-flag states: `serverTruncated`/`tooLarge` belong to the PREVIOUS
@@ -293,7 +297,7 @@ export function GitFileDiff({
293
297
  }
294
298
  })();
295
299
  return () => { seqRef.current++; };
296
- }, [agentId, projectId, source, repo, path, normalizedMode, reloadNonce, revision, runContent]);
300
+ }, [client, agentId, projectId, source, repo, path, normalizedMode, reloadNonce, revision, runContent]);
297
301
 
298
302
  // The ONE parse of the raw diff — the ORIGINAL (staging) model.
299
303
  const { files, truncated, hunkPatches } = useMemo(
@@ -317,7 +321,7 @@ export function GitFileDiff({
317
321
  // parsed count, or the index→patch mapping is unsafe.
318
322
  const singleFile: FileData | null = files.length === 1 ? files[0] : null;
319
323
  const canStageHunks =
320
- canWrite && kind === 'working' &&
324
+ stageRows && kind === 'working' &&
321
325
  !!agentId &&
322
326
  !!singleFile &&
323
327
  singleFile.type === 'modify' &&
@@ -386,7 +390,7 @@ export function GitFileDiff({
386
390
  } catch {
387
391
  return settle({ ok: false });
388
392
  }
389
- }, [agentId, projectId, source, repo, path, expanderSrc, runContent]);
393
+ }, [client, agentId, projectId, source, repo, path, expanderSrc, runContent]);
390
394
 
391
395
  // The synthetic OLD-side lines (`expandFromRawCode` slices by old-line
392
396
  // numbers) + the RENDER-ONLY expanded hunk list. `oldTotal` also sizes the
@@ -458,12 +462,13 @@ export function GitFileDiff({
458
462
  const applyHunk = useCallback(
459
463
  async (hunkIndex: number) => {
460
464
  // A raw shown while its fresh answer loads may be an older revision's — never stage from it.
461
- if (loading || !canStageHunks || !hunkPatches || !agentId) return;
465
+ if (loading || permissionPending || !canWrite || !canStageHunks || !hunkPatches || !agentId) return;
462
466
  const patch = assembleHunkPatch(hunkPatches.header, hunkPatches.hunks[hunkIndex]);
463
467
  if (!patch) {
464
468
  setHunkError('Could not build a hunk patch for this change.');
465
469
  return;
466
470
  }
471
+ const mySeq = seqRef.current;
467
472
  setHunkBusy(hunkIndex);
468
473
  setHunkError(null);
469
474
  try {
@@ -472,6 +477,7 @@ export function GitFileDiff({
472
477
  } else {
473
478
  await stageHunk({ agentId, ...(projectId ? { projectId } : {}), source, ...(repo ? { repo } : {}), hunk: patch }, client);
474
479
  }
480
+ if (mySeq !== seqRef.current) return;
475
481
  // The index changed → the cached `rev=index` source is stale (the WT
476
482
  // entry would survive, but a whole-map clear is cheap and race-free).
477
483
  sourceCacheRef.current.clear();
@@ -487,12 +493,12 @@ export function GitFileDiff({
487
493
  void loadGitStatus({ agentId, projectId });
488
494
  setReloadNonce((n) => n + 1);
489
495
  } catch (e) {
490
- setHunkError(e instanceof Error ? e.message : 'Hunk apply failed');
496
+ if (mySeq === seqRef.current) setHunkError(e instanceof Error ? e.message : 'Hunk apply failed');
491
497
  } finally {
492
- setHunkBusy(null);
498
+ if (mySeq === seqRef.current) setHunkBusy(null);
493
499
  }
494
500
  },
495
- [loading, canStageHunks, hunkPatches, agentId, projectId, source, repo, staged, loadGitStatus],
501
+ [client, loading, permissionPending, canWrite, canStageHunks, hunkPatches, agentId, projectId, source, repo, staged, loadGitStatus],
496
502
  );
497
503
 
498
504
  /** One collapsed-gap row: clickable expander, or static text once refused. */
@@ -542,7 +548,7 @@ export function GitFileDiff({
542
548
  key={idx}
543
549
  type="button"
544
550
  onClick={() => void applyHunk(idx)}
545
- disabled={hunkBusy !== null || loading}
551
+ disabled={hunkBusy !== null || loading || permissionPending || !canWrite}
546
552
  className={cn(
547
553
  'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10px] transition-colors disabled:opacity-40',
548
554
  staged