@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
@@ -19,6 +19,8 @@ import { DiffView } from '../../cards/DiffView';
19
19
  import { detectLanguage } from '../../cards/diffUtils';
20
20
  import type { VisibleDiffQueue } from '../hooks/useVisibleDiffCards';
21
21
  import { acceptsAbsence, classifyReviewOutcome, hunksOf, type ReviewOutcome } from './pendingChangesModel';
22
+ import { useFilesystemStore } from '../store/filesystemStore';
23
+ import type { PackageApi } from '@neuralis/package-system/client';
22
24
 
23
25
  export type ReviewAction = 'approve' | 'dismiss' | 'revert';
24
26
  export type ReviewRequest =
@@ -42,13 +44,13 @@ const OUTCOME: Record<ReviewOutcome, { label: string; tone: string }> = {
42
44
  export type ReviewResult = { id: string; uri: string; action: ReviewAction; outcome: ReviewOutcome; detail?: string };
43
45
 
44
46
  /** Run one review verb with the record's own fingerprint; never throws. */
45
- export async function applyReview(params: { agentId: string; projectId: string | null }, rec: PendingChangeRecord,
47
+ export async function applyReview(params: { agentId: string; projectId: string | null; client: PackageApi }, rec: PendingChangeRecord,
46
48
  preview: PendingPreview, action: ReviewAction, acknowledge: boolean): Promise<ReviewResult> {
47
49
  const body = { agentId: params.agentId, ...(params.projectId ? { projectId: params.projectId } : {}), id: rec.id,
48
50
  expectedFingerprint: preview.expectedFingerprint, ...(acknowledge ? { acknowledgeCurrentState: true as const } : {}) };
49
51
  try {
50
- const result = action === 'approve' ? await approveFileReview({ ...body, uri: rec.uri })
51
- : action === 'revert' ? await revertPendingChange(body) : await dismissPendingChange(body);
52
+ const result = action === 'approve' ? await approveFileReview({ ...body, uri: rec.uri }, params.client)
53
+ : action === 'revert' ? await revertPendingChange(body, params.client) : await dismissPendingChange(body, params.client);
52
54
  if (result && result.ok === false) {
53
55
  const detail = 'error' in result && result.error ? String(result.error) : 'Review refused';
54
56
  return { id: rec.id, uri: rec.uri, action, outcome: classifyReviewOutcome(detail), detail };
@@ -90,6 +92,7 @@ export function PendingReviewDialog({ request, agentId, projectId, run, onClose,
90
92
  /** Records still waiting outside this batch; `onNext` (given this batch's ids) opens the next one. */
91
93
  next?: number; onNext?: (batch: string[]) => void;
92
94
  }): React.ReactElement | null {
95
+ const client = useFilesystemStore((s) => s.client);
93
96
  const records = useMemo(() => request.kind === 'single' ? [request.rec] : request.records, [request]);
94
97
  const [previews, setPreviews] = useState<Record<string, PendingPreview | Error>>(
95
98
  () => request.kind === 'single' && request.preview ? { [request.rec.id]: request.preview } : {});
@@ -104,7 +107,7 @@ export function PendingReviewDialog({ request, agentId, projectId, run, onClose,
104
107
  const seq = (loads.current.get(rec.id) ?? 0) + 1;
105
108
  loads.current.set(rec.id, seq);
106
109
  const current = () => mounted.current && loads.current.get(rec.id) === seq;
107
- const load = () => previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id });
110
+ const load = () => previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }, client);
108
111
  void (again && refetch ? refetch(rec) : run(load, current))
109
112
  .then(preview => { if (current() && preview) setPreviews(p => ({ ...p, [rec.id]: preview })); },
110
113
  error => { if (current()) setPreviews(p => ({ ...p, [rec.id]: error instanceof Error ? error : new Error('Preview failed') })); });
@@ -140,7 +143,7 @@ export function PendingReviewDialog({ request, agentId, projectId, run, onClose,
140
143
  for (const rec of targets) {
141
144
  const preview = previews[rec.id];
142
145
  if (!preview || preview instanceof Error || results[rec.id]?.outcome === 'ok') continue;
143
- const result = await applyReview({ agentId, projectId }, rec, preview, action, preview.requiresAcknowledgement.length > 0);
146
+ const result = await applyReview({ agentId, projectId, client }, rec, preview, action, preview.requiresAcknowledgement.length > 0);
144
147
  done.push(result);
145
148
  setResults(r => ({ ...r, [rec.id]: result }));
146
149
  // The state moved under the preview: fetch ONE fresh preview for this record
@@ -25,9 +25,9 @@ Re-exported from `app/filesystem/ui/index.ts`:
25
25
  - **`DiffModeToggle`** is the ONE Auto/Unified/Split control bound to the store `diffViewMode`, shared by BOTH the review diff AND the git diff (Auto picks unified/split by container width via `useContainerWidth`).
26
26
  - The editor also carries the VS Code-style **git left-gutter** (`codeEditor/editorGitGutter.ts` + the pure `codeEditor/gitLineStatus.ts` parser) for git-tracked files — a thin emerald(added)/blue(modified) bar + red delete caret, derived **fail-soft** from `getGitDiff({staged:false})` (Working↔Index).
27
27
  - `DiffView` (the old hand-rolled Myers review diff) was **DELETED** — superseded by `ReviewDiffView`. NB `app/cards/DiffView.tsx` is a DIFFERENT file (the chat-card single-column diff) and stays.
28
- - `Toolbar` — top toolbar with the active agent’s standalone configured icon (no visible name or identity badge), sidebar toggle, refresh, upload, and panel switches. The Changes switch carries the caller's visible pending COUNT as a static badge (`pendingCount`, the server `count_only` answer — `1000+` at the listing cap); the client `pendingReviewPaths` set only tones tree rows.
28
+ - `Toolbar` — top toolbar with the active agent’s standalone configured icon (no visible name or identity badge), sidebar toggle, refresh, upload, and panel switches. The Changes switch carries the caller's visible pending COUNT as a static badge (`pendingCount`, the server `count_only` answer — `1000+` display cap; partial totals carry `+`, stale/unavailable totals `?`); the client `pendingReviewPaths` set only tones tree rows.
29
29
  - `SourcesPanel`, `PackagesPanel`, `VectorHealthPanel`, `PanelScaffold` — right-hand panels. Sources offers advisory metrics exclusions only when enabling an attested own data source with no sync block; Save persists the editable draft. Media summaries include local structural PNG failures, and chunk-only cleanup refreshes Files separately from removed-file counts.
30
- - `PendingChangesPanel` — Changes groups current Git status and pending review by source/repository, including non-Git sources. Normalized URI joining renders a file once while retaining separately labeled staged/unstaged comparisons for each overlapping repository and one pending review. All cards start expanded in ONE `useVirtualizer` list (`pendingChangesModel.ts` owns the filters, the flat row list, `rowEstimate`/`isMeasuredRow` and the Approve split; the compact row pins `h-8`, every card is measured; `overflow-anchor:none` plus an above-the-fold size adjustment keep the view still); the content lanes cache every loaded preview/diff, and source revisions plus context sequences fence obsolete replies. A **Type** filter (all · pending · Git) narrows the layers; the repository picker uses the Git panel's `resolveRepoLabel`. **Approve N** counts records with no acknowledgement on their own fields (`ackHintFor`, the server preview's own predicate) or in an already-loaded preview (`splitApprovable`'s `knownPreview`, no fetch of its own); the rest open `PendingReviewDialog` grouped by reason, each record sent with its own fingerprint, and every sweep reports a per-record result row (a refused revert included) — a record the sweep found needing an acknowledgement is reported AND handed to that grouped dialog (approve or dismiss with the acknowledgement). A batch (the sweep, a dialog group) HOLDS the source revisions its own writes bump (`holdRevisions`, released in `afterMutation`): N writes cost N verb requests, ONE re-list and at most one re-preview per card still listed — never a re-list plus every visible card's re-preview per write. A `changed` answer (`PENDING_STATE_CHANGED` / `PENDING_GENERATION_CHANGED`) is not final: the dialog fetches ONE fresh preview for that record (per-record sequence, liveness cleared on unmount only) and waits for a new click — never a replay. In the panel that ONE fetch is the panel's `refetch`: it waits for the re-list, the record's card holds meanwhile and adopts the answer under its final key (`takeRefetch`), so the card sends nothing of its own; a direct (dialog-less) refusal bumps only that card's preview nonce. A confirmed absence with a saved baseline is named in the preview body (`acceptsAbsence`). The orphan probe runs on mount, Refresh and Repair only — a source revision re-reads the plain list. Source inventory is separate from content, completed siblings are cached, truncation is shown, and Git errors do not hide other sources. Review reads a bounded pending preview/fingerprint with typed current/baseline/index states; ack-required state opens an explicit confirmation. Actual WRITE access gates card, compact, bulk and repair controls; historical cards remain read-only. Orphan ids arrive with the same pending listing, so Repair remains orphan-only and re-authorized server-side.
30
+ - `PendingChangesPanel` — Changes groups current Git status and pending review by source/repository, including non-Git sources. Normalized URI joining renders a file once while retaining separately labeled staged/unstaged comparisons for each overlapping repository and one pending review. All cards start expanded in ONE `useVirtualizer` list (`pendingChangesModel.ts` owns the filters, the flat row list, `rowEstimate`/`isMeasuredRow` and the Approve split; the compact row pins `h-8`, every card is measured; `overflow-anchor:none` plus an above-the-fold size adjustment keep the view still); the content lanes cache every loaded preview/diff, and source revisions plus context sequences fence obsolete replies. A **Type** filter (all · pending · Git) narrows the layers; the repository picker uses the Git panel's `resolveRepoLabel`. **Approve N** counts records with no acknowledgement on their own fields (`ackHintFor`, the server preview's own predicate) or in an already-loaded preview (`splitApprovable`'s `knownPreview`, no fetch of its own); the rest open `PendingReviewDialog` grouped by reason, each record sent with its own fingerprint, and every sweep reports a per-record result row (a refused revert included) — a record the sweep found needing an acknowledgement is reported AND handed to that grouped dialog (approve or dismiss with the acknowledgement). A batch (the sweep, a dialog group) HOLDS the source revisions its own writes bump (`holdRevisions`, released in `afterMutation`): N writes cost N verb requests, ONE re-list and at most one re-preview per card still listed — never a re-list plus every visible card's re-preview per write. A `changed` answer (`PENDING_STATE_CHANGED` / `PENDING_GENERATION_CHANGED`) is not final: the dialog fetches ONE fresh preview for that record (per-record sequence, liveness cleared on unmount only) and waits for a new click — never a replay. In the panel that ONE fetch is the panel's `refetch`: it waits for the re-list, the record's card holds meanwhile and adopts the answer under its final key (`takeRefetch`), so the card sends nothing of its own; a direct (dialog-less) refusal bumps only that card's preview nonce. A confirmed absence with a saved baseline is named in the preview body (`acceptsAbsence`). The orphan probe runs on mount, Refresh and Repair only — a source revision re-reads the plain list. Source inventory is separate from content, completed siblings are cached, truncation is shown, and Git errors do not hide other sources. Review reads a bounded pending preview/fingerprint with typed current/baseline/index states; ack-required state opens an explicit confirmation. Actual WRITE access gates card, compact, bulk and repair controls; historical cards remain read-only. Orphan ids arrive with the same pending listing, so Repair remains orphan-only and re-authorized server-side. Changes exposes Load more and incomplete inventory state. Repair processes batches sequentially with progress and Stop after the current batch; partial pages pause at their cursor for explicit Continue available/Retry. List and orphan epochs are independent; classification survives a plain relist only for matching record generations. FileEditor opens the shared review dialog for the exact visible URI. Returned generation conflicts appear in the existing Changes row status before preview succeeds. Git hunk rows retain geometry during same-context permission refresh while actions remain disabled until permission and diff settle.
31
31
 
32
32
  - `InlineUploader` — the Upload tab (folder pick, new folder, drop zone). The two modals it once sat beside (`EditFileModal`, `UploadFileModal`) were deleted 2026-09-04: nothing had mounted either since the repository's baseline, so the settling-sandpiper M9 reconcile tails had been fixed on dead code while the live in-place Edit still called the upsert-only `refresh`. Those tails are store actions now (`saveFileInPlace` / `markFileDeleted`, `../store/README.md`) that `FileEditor` calls.
33
33
  - `GitControlPanel`, `StackedSections` (+ pure `stackedSectionsLayout.ts`), `GitCommitGraph` (+ pure `gitGraphLayout.ts`/`gitGraphPaging.ts`/`gitGraphItems.ts` — the item model: expandable rows, the outgoing divider, ref-chip/dot classing, the branch-bar ahead/behind chips), `GitDiffView` + `GitFileDiff` + `GitCommitChangesView` (re-exported; + pure `gitDiffExpand.ts`/`gitDiffPatch.ts`/`gitFileDiffMode.ts`), `GitStatusBadge`, `DiffCountBadge` — the Git Control surface: the sidebar pane-stack panel, the in-panel virtualized commit graph with commit drill-in + outgoing markers, the side-by-side diff view (single + multi-file stacked tab), the per-file tree badge. The owning narrative (sections, paging/live-refresh, drill-in, outgoing, caps, write gating) is the package-root README "Git Control panel" section — this bullet only anchors the files.
@@ -39,7 +39,7 @@ Re-exported from `app/filesystem/ui/index.ts`:
39
39
  - `PolicyEditor` — compact admin-style permissions editor for the source-config panel. Renders the persisted JSON tree (`default`, `byRole`, `byUser`, `byAgent`, `paths[]`) with tri-state R/W/X pill badges (allow / deny / inherit cycle), per-role/user/agent override rows (with `+ Add` input + trash), and path rules with clickable role chips + per-rule agent chips (`$self/agentId`, inline-add). `OVR` toggles the rule's `override` flag. Adding a rule **prepends** it (visible at the top of long policy sets; first-match-wins means the fresh rule also takes precedence). Two modes: `edit` (Save button visible when dirty) and `view` (read-only chat-config render). It is wired into `SourcesPanel`, laid out admin-style, and carries the By Role / By User / By Agent sections. Save threads through `handlePolicySave` → `updateSourceConfig` without disturbing the user's pending sync/credential edits.
40
40
  - `SourcesPanel` "Discoverable" section — lives INSIDE the Vector Health expandable, alongside a `mount.mts` explainer card. Suggestions arrive from `GET /sources/discoverable` and use the shared `dimmedSourceTone('unattached')` util. The icon name (`AppWindow`, `GitBranch`, `FolderTree`, …) resolves through the shared `resolveLucideIcon` helper (same `ICON_MAP` as the workspace dock-left). Click pre-fills the add-source form with `{ key, path, kind, hostRoot? }` from the suggestion, plus the suggestion's optional `suggestedAccess` (read/write/exec) which overrides the connector-kind default, and — for `seed:'discoverable'` package declarations (e.g. the admin app zone, suggestion id `discover:decl:app`) — the **declared `description`** prefills the add form's description field. The **Full Host** suggestion (native/non-Docker only — `/` is the readable host root; in Docker the host must first be bound via `pnpm neuralis:mount add /`) ships `suggestedAccess: { read:true, write:false, exec:false }` so the whole-host root attaches **read-only by default** — the owner widens it (or narrows the uri-policy) in the form before saving. A host source is readable from the container shell, but host-native binaries still won't execute under the container runtime (a host PTY broker is a separate, future capability).
41
41
  - `SourcesPanel` row actions say what they do: **Rescan changes** (the `reindex` route without `force` — changed files are embedded again, unchanged ones keep their vectors), **Re-embed…** (`ReembedDialog` — every file of the source with the active model, estimate first), **Embed pending…** (`ReembedDialog kind="pending"`; it and **Re-embed…** are disabled while the row's live `embedding_enabled` says off), **Forget sync state** (the `reset` route — the next sync compares every file again; deletes no file and no vector). A started re-embed is followed by the same job poll + count refresh as a rescan (`followReindex`); an embedding pass that ends `paused` (terminal, not failed) shows "Embedding paused: <reason>" with **Resume** (the same owed-only apply). The Modify-source body states `binarySkipLine` ("N binary files not indexed" — the last count a pass measured) and `embeddingLine` beside `mediaSkipLine`: off — the live `embedding_enabled`, never the last pass — reads neutral ("Semantic embedding off — text and field search only"); the last pass's other reasons read amber ("Semantic embedding pending: <reasons>").
42
- - `SourcesPanel` row is **name-first** (the redesign for the always-narrow widget). The source name is the dominant element (`flex-1`, larger/bold); the kind icon uses the SAME resolved icon set as the file tree (`resolveIconWithFallback` over the connector-manifest `kind_icon`/`meta.icon`) and is **coloured by `getSourceVisualTone`** — so the icon tone IS the sync/live signal, matching the tree (the old `Healthy/Idle/Error` status text + `statusTone` are gone). The per-row **trust badge** (`first-party`/…) and the **scope badge** (`project`/`user`/`agent` — already a tab filter above) are removed; only `observed`, `orphan`, `unsaved`, and the enabled toggle remain inline. Rows are **grouped by `origin_package`** — the **declaring package id** the server resolves per source (the CONTRACT-BACKED `PackageSourceDeclaration` owner, returned on each `SourceRuntimeSummary` / `s.origin_package`), one group **per package**: each real `@neuralis/*` (or any other) package sorts **alphabetically** and user-added mounts collapse into a single **`Mounted (user-added)`** group that sorts **LAST** (`sourceOriginGroup` / `MOUNTED_GROUP`). The small left-indented group header **replaces** the per-row trust/`first-party` badge. The old client-side heuristic that inferred the owner from the connector kind's npm scope (`sourceOriginLabel`) is **gone** — attribution is now resolved server-side from the declarations (`matchDeclaredSource`, slug + resolved-root for rooted kinds; kind-only for rootless sandbox kinds), so a user mount named `app` that is NOT rooted at the platform app zone is NOT attributed to admin — the grouping is **unspoofable**.
42
+ - `SourcesPanel` row is **name-first** (the redesign for the always-narrow widget). The source name stays left-aligned and is the dominant element (`flex-1`, larger/bold); the kind icon uses the SAME resolved icon set as the file tree (`resolveIconWithFallback` over the connector-manifest `kind_icon`/`meta.icon`) and is **coloured by `getSourceVisualTone`** — so the icon tone IS the sync/live signal, matching the tree (the old `Healthy/Idle/Error` status text + `statusTone` are gone). The per-row **trust badge** (`first-party`/…) and the **scope badge** (`project`/`user`/`agent` — already a tab filter above) are removed; only `observed`, `orphan`, `unsaved`, and the enabled toggle remain inline. Rows are **grouped by `origin_package`** — the **declaring package id** the server resolves per source (the CONTRACT-BACKED `PackageSourceDeclaration` owner, returned on each `SourceRuntimeSummary` / `s.origin_package`), one group **per package**: each real `@neuralis/*` (or any other) package sorts **alphabetically** and user-added mounts collapse into a single **`Mounted (user-added)`** group that sorts **LAST** (`sourceOriginGroup` / `MOUNTED_GROUP`). The small left-indented group header **replaces** the per-row trust/`first-party` badge. The old client-side heuristic that inferred the owner from the connector kind's npm scope (`sourceOriginLabel`) is **gone** — attribution is now resolved server-side from the declarations (`matchDeclaredSource`, slug + resolved-root for rooted kinds; kind-only for rootless sandbox kinds), so a user mount named `app` that is NOT rooted at the platform app zone is NOT attributed to admin — the grouping is **unspoofable**.
43
43
  - `SourcesPanel` source body: when a row is expanded it renders at **full height** (no `max-h` clamp) so every Path Rule / uri-policy is visible and the outer panel scrolls — the closed state keeps the collapse animation. The Sync sub-section shows a per-trigger explainer (Manual = only the Sync button; Auto = file-watch + reconcile; Scheduled = interval) and clarifies the "Always active" pin. Disabling sync (unchecking "Enable sync" + Save) sends `clearSync: true` — an omitted `sync` means "no change" server-side, so without the flag the disable would silently revert.
44
44
  - `SourcesPanel` per-source counts are **lazy** — the exact `connector` / `indexed` / `unindexed` stats row is fetched ONLY when its row is opened (`toggleSource` → `fetchSourceCounts` → a list scoped to `<source>://` with `include_counts`, so `computeSourcesRegistry` walks that ONE source), cached in `countsBySource`, and shown behind a "Loading counts…" placeholder. Panel-open itself does a **count-FREE** `refresh()` (no `withCounts`) — that still populates `sourceSummaries` (`s.sources`) for source discovery (observed-only sources, project scope) + per-row connector liveness dim, but fires **zero** project-wide count walks. There is deliberately no eager `refresh({withCounts:true})` on open — that, together with the Vector Health walk below, is what used to make opening one panel slow everything. Counted refreshes are narrowed to the operations that actually MOVE a number: sync, reindex, and a config save that changed the sync FILTER set (`syncFilterChanged` — exclude/include/`maxScanFiles`/a cleared sync block, which `connector.count()` applies, so the on-disk total shifts at once). A permissions save, a delete and an add refresh count-FREE: the indexed count is a project-scoped `countByFilter` with no per-caller policy input, the on-disk count is physical truth, a deleted row is gone and a brand-new row's numbers are one expand away. Every counted path also evicts that source's cached counts (`clearCachedCounts`) so the fresh summary is what renders — the lazy cache used to shadow it indefinitely after an exclude edit. `pollSyncJob` (1.5 s × `MAX_POLLS` 2400 = a sixty-minute ceiling) checks a `mountedRef` each turn and returns the moment the panel unmounts; the ref is cleared on UNMOUNT ONLY, never in a dependency-change cleanup, so a live poll is never dropped while the panel is still there. And EVERY hook in `SourcesPanel` sits above its `if (!open) return null` guard: two `useCallback`s used to live below it, so the component rendered a different number of hooks open than closed — React refuses that on one instance, and only the parent's mount gate carrying the same `workspacePanels.sources` flag kept it from ever firing.
45
45
  - `useFilesystemSSE` `sync:completed`: the handler is matched **before** the `if (!uri) return` guard — the event carries `source` (the slug), NOT a file `uri`. It runs the authoritative `reconcileSourceFolders` over that source's VISIBLE folders (a sync can have removed files), skips the pass entirely when every change count on the payload is zero (a missing count is unknown and still re-lists), and falls back to `refreshTree` only when no source is present. That pass rides the ONE per-source reconcile schedule shared with external-change events (a trailing quiet window with a max-wait), so a burst of changes becomes one pass. Owner: `app/filesystem/hooks/README.md`.
@@ -53,13 +53,13 @@ Re-exported from `app/filesystem/ui/index.ts`:
53
53
 
54
54
  - `SourceDeleteConfirm` — inline rose-tinted popover under the row (no modal, matches the panel's "no overlay" pattern). Headline + one-line reminder that the host folder is not touched + `[ ] Purge vector index entries (~N point(s))` checkbox (default OFF — conservative) + Docker-bind-mount warning ribbon when `connection.config.hostRoot` is set, telling the user to run `neuralis mount remove <slug>` before restarting the container. On confirm, `SourcesPanel.handleDelete` calls `deleteSource({ purgeVectors })` and surfaces a transient `successMsg` ("Deleted "<source>" (vectors purged: N)").
55
55
  - **`SkillLauncher` coverage** (`@neuralis/package-system/client`, additive "+" launchers) — every filesystem function the LLM can drive has a visible composer-prefill trace. Clicking a launcher PREFILLS the chat composer with the skill (+ scripts) like a `/` slash pick; it never sends/streams. **No launcher states a feature list**: visibility is DERIVED from the host's per-caller skill catalog, so it cannot drift from each skill's own `SKILL.md` `requiredFeatures` and an unresolved catalog renders nothing (the gate named per row below is the SKILL's, documented here, not repeated in the JSX). `packageId` is the REGISTRY id (`@neuralis/brain-core`), matched as an exact pair with the skill id. The launchers resolve the `WorkspaceHostPort` from the `WorkspaceHostPortProvider` mounted once in `host/installFilesystemHostComponents.tsx` — without it they render `null` silently. Current placements:
56
- - `Toolbar` — `sync` (`drive.sync`, icon-`brain`, script-less) next to the Sources panel toggle, and `manage-files` (`drive.write`; `scripts: mkdir, copy, modify, search`) as the general file-ops handoff. The tree's CONTEXT MENU deliberately carries none: it is a keyboard-navigable click-to-act list with one uniform item model, and a composer-prefill affordance would need a bespoke item kind there.
57
- - `SourcesPanel` header — `manage-sources` (`drive.read`, attached-left to the `+ Source` button; `scripts: sync, reindex, set-enabled`).
58
- - `SourcesPanel` per-source **Permissions** header — `manage-uri-policy` (`drive.policy` — no grant below the `admin` tier, grantable to any custom role; `scripts: show-uri, set-uri, manifests`) hands the URI-policy / path-permission edit for that source to the agent, alongside the `PolicyEditor`.
56
+ - `Toolbar` — `sync` (`drive.sync`, icon-`brain`, script-less) next to the Sources panel toggle, and `manage-files` (`drive.write`; `scripts: mkdir, copy, modify`) as the general file-ops handoff. The tree's CONTEXT MENU deliberately carries none: it is a keyboard-navigable click-to-act list with one uniform item model, and a composer-prefill affordance would need a bespoke item kind there.
57
+ - `SourcesPanel` header — `manage-sources` (`drive.read`, attached-left to the `+ Source` button).
58
+ - `SourcesPanel` per-source **Permissions** header — `manage-uri-policy` (`drive.policy` — no grant below the `admin` tier, grantable to any custom role) hands the URI-policy / path-permission edit for that source to the agent, alongside the `PolicyEditor`.
59
59
  - `FileTree` empty-state — `manage-files` (`drive.write`, label; `scripts: upload, modify`) to populate a source.
60
- - `GitControlPanel` header — `git` (`drive.read`; `scripts: git.sh`) next to the refresh button. The `git` skill drives the SAME `/git/*` routes this panel does — its own SKILL.md says so — and the panel shipped with no way to reach it until 2026-07-28.
61
- - `PendingChangesPanel` toolbar — `manage-files` (`drive.write`; `scripts: pending, approve`) for bulk review; the per-row approve/revert buttons stay the primary path.
62
- - `VectorHealthPanel` actions row — `manage-sources` (`drive.read`; `scripts: sync-status, reindex`). The index is per-SOURCE, so the agent-side equivalent of scan/repair is a source reindex plus a sync-status readout.
60
+ - `GitControlPanel` header — `git` (`drive.read`) next to the refresh button. The `git` skill drives the SAME `/git/*` routes this panel does — its own SKILL.md says so — and the panel shipped with no way to reach it until 2026-07-28.
61
+ - `PendingChangesPanel` toolbar — `manage-files` (`drive.write`) for bulk review; the per-row approve/revert buttons stay the primary path.
62
+ - `VectorHealthPanel` actions row — `manage-sources` (`drive.read`). The index is per-SOURCE, so the agent-side equivalent of scan/repair is a source reindex plus a sync-status readout.
63
63
  - `PackagesPanel` header — `create-package` (label, script-less) hands the whole authoring flow (scaffold, manifest, tools, surfaces, connectors, build — including the WASM pipeline) to the agent; additive to the per-row `Build` button. This is the ONE launcher that narrows further, with `alsoRequires={["packages.author"]}`, even though the SKILL ships ungated (so it stays importable): it scaffolds into `packages://`, which needs write + install/build capability the default `member` role does not hold, so showing it to a caller whose first action would be denied is worse UX than hiding it. `alsoRequires` can only ever hide a launcher, never reveal one, so this cannot become a disclosure. Deliberate — do not "align" it with the skill frontmatter.
64
64
 
65
65
  ## How it plugs into the package-system kernel
@@ -18,6 +18,7 @@ import { useEffect, useState } from 'react';
18
18
  import { createPortal } from 'react-dom';
19
19
  import { Loader2, RefreshCcw, X } from 'lucide-react';
20
20
  import { estimateReembed, startReembed, type ReembedEstimate, type ReembedKind } from '../api';
21
+ import { useFilesystemStore } from '../store/filesystemStore';
21
22
 
22
23
  export type ReembedTarget = {
23
24
  source: string;
@@ -58,6 +59,7 @@ export function ReembedDialog({
58
59
  /** Called after a job was queued — the caller follows it however it follows sync jobs. */
59
60
  onStarted?: () => void;
60
61
  }) {
62
+ const client = useFilesystemStore((s) => s.client);
61
63
  const [estimate, setEstimate] = useState<ReembedEstimate | null>(null);
62
64
  const [estimating, setEstimating] = useState(true);
63
65
  const [starting, setStarting] = useState(false);
@@ -75,7 +77,7 @@ export function ReembedDialog({
75
77
  useEffect(() => {
76
78
  let live = true;
77
79
  setEstimating(true);
78
- estimateReembed(scope, kind)
80
+ estimateReembed(scope, kind, client)
79
81
  .then((result) => {
80
82
  if (live) setEstimate(result);
81
83
  })
@@ -108,7 +110,7 @@ export function ReembedDialog({
108
110
  setStarting(true);
109
111
  setError(null);
110
112
  try {
111
- const started = await startReembed(scope, kind);
113
+ const started = await startReembed(scope, kind, client);
112
114
  if (!started.job) {
113
115
  // Nothing readable in scope — the route queued nothing. Say so.
114
116
  setNothingToDo(true);
@@ -470,6 +470,7 @@ const textareaCls = cn(
470
470
  // ---------------------------------------------------------------------------
471
471
 
472
472
  export function SourcesPanel(props: Props) {
473
+ const client = useFilesystemStore((s) => s.client);
473
474
  const { agentId, projectId, isDocker } = props;
474
475
  const open = useFilesystemStore((s) => s.workspacePanels.sources);
475
476
  const setWorkspacePanelOpen = useFilesystemStore((s) => s.setWorkspacePanelOpen);
@@ -577,7 +578,7 @@ export function SourcesPanel(props: Props) {
577
578
  // schema-driven picker (replacing the old hardcoded KIND_META).
578
579
  useEffect(() => {
579
580
  let cancelled = false;
580
- listSourceKinds()
581
+ listSourceKinds(client)
581
582
  .then((res) => {
582
583
  if (cancelled) return;
583
584
  setKinds(res.kinds ?? []);
@@ -704,8 +705,8 @@ export function SourcesPanel(props: Props) {
704
705
  agentId,
705
706
  projectId,
706
707
  scope: activeScope,
707
- }),
708
- listDiscoverableSources({ agentId, projectId })
708
+ }, client),
709
+ listDiscoverableSources({ agentId, projectId }, client)
709
710
  .catch(() => ({ suggestions: [] as DiscoverableSourceSuggestion[] })),
710
711
  ]);
711
712
  setConfigs(next);
@@ -746,7 +747,7 @@ export function SourcesPanel(props: Props) {
746
747
  });
747
748
  if (alreadyLoading) return;
748
749
  try {
749
- const info = await fetchSourceCounts(agentId, projectId ?? undefined, source);
750
+ const info = await fetchSourceCounts(agentId, projectId ?? undefined, source, client);
750
751
  if (info) setCountsBySource((prev) => ({ ...prev, [source]: info }));
751
752
  } catch {
752
753
  // Count failure is non-fatal — the row still works, the stats row just stays blank.
@@ -793,7 +794,7 @@ export function SourcesPanel(props: Props) {
793
794
  useEffect(() => {
794
795
  if (!showAddForm || !isDocker || !canMount) return;
795
796
  setDockerMountsLoading(true);
796
- browseDirectories({ path: '/mounts', agentId, ...(projectId ? { projectId } : {}) })
797
+ browseDirectories({ path: '/mounts', agentId, ...(projectId ? { projectId } : {}) }, client)
797
798
  .then((result) => {
798
799
  // Filter out volumes already configured as sources
799
800
  const configuredSources = new Set(configs.map((c) => c.source));
@@ -905,7 +906,7 @@ export function SourcesPanel(props: Props) {
905
906
  agentId,
906
907
  ...(projectId ? { projectId } : {}),
907
908
  config: { ...payload, ...(clearSync ? { clearSync: true } : {}) },
908
- });
909
+ }, client);
909
910
  setConfigs((s) => s.map((e) => (e.source === updated.source ? updated : e)));
910
911
  setDrafts((s) => ({ ...s, [updated.source]: buildDraft(updated) }));
911
912
  // Counted refresh ONLY when the save moved the sync FILTER set — that is
@@ -943,7 +944,7 @@ export function SourcesPanel(props: Props) {
943
944
  agentId,
944
945
  ...(projectId ? { projectId } : {}),
945
946
  config: { ...currentDraft, permissions: nextPerms },
946
- });
947
+ }, client);
947
948
  setConfigs((s) => s.map((e) => (e.source === updated.source ? updated : e)));
948
949
  setDrafts((s) => ({ ...s, [updated.source]: buildDraft(updated) }));
949
950
  // A permissions edit moves no number: the indexed count is a
@@ -977,7 +978,7 @@ export function SourcesPanel(props: Props) {
977
978
  // is cleared on UNMOUNT ONLY (never in a dependency-change cleanup), so a
978
979
  // live poll is never dropped while the panel is still mounted.
979
980
  if (!mountedRef.current) return null;
980
- const status = await getSyncStatus({ agentId, ...(projectId ? { projectId } : {}), source });
981
+ const status = await getSyncStatus({ agentId, ...(projectId ? { projectId } : {}), source }, client);
981
982
  const job = status.job;
982
983
  if (!job) return null;
983
984
  if (job.status === 'pending' || job.status === 'running') continue;
@@ -1005,7 +1006,7 @@ export function SourcesPanel(props: Props) {
1005
1006
  setSyncingSource(source);
1006
1007
  setError(null);
1007
1008
  try {
1008
- await triggerSourceSync({ agentId, ...(projectId ? { projectId } : {}), source });
1009
+ await triggerSourceSync({ agentId, ...(projectId ? { projectId } : {}), source }, client);
1009
1010
  await pollSyncJob(source);
1010
1011
  await load();
1011
1012
  await refresh({ agentId, projectId, withCounts: true });
@@ -1019,7 +1020,7 @@ export function SourcesPanel(props: Props) {
1019
1020
 
1020
1021
  const handleCancelSync = async (source?: string) => {
1021
1022
  try {
1022
- await cancelSync({ agentId, ...(projectId ? { projectId } : {}), ...(source ? { source } : {}) });
1023
+ await cancelSync({ agentId, ...(projectId ? { projectId } : {}), ...(source ? { source } : {}) }, client);
1023
1024
  // The polling loop in handleSync/handleReindex observes the cancelled
1024
1025
  // status and clears the spinner; clear eagerly for instant feedback.
1025
1026
  setSyncingSource(null);
@@ -1058,7 +1059,7 @@ export function SourcesPanel(props: Props) {
1058
1059
  const handleResumeEmbedding = (source: string) =>
1059
1060
  followReindex(
1060
1061
  source,
1061
- () => startReembed({ agentId, ...(projectId ? { projectId } : {}), source }, 'pending'),
1062
+ () => startReembed({ agentId, ...(projectId ? { projectId } : {}), source }, 'pending', client),
1062
1063
  'Embedding failed',
1063
1064
  );
1064
1065
 
@@ -1066,7 +1067,7 @@ export function SourcesPanel(props: Props) {
1066
1067
  const handleReindex = (source: string) =>
1067
1068
  followReindex(
1068
1069
  source,
1069
- () => reindexSource({ agentId, ...(projectId ? { projectId } : {}), source }),
1070
+ () => reindexSource({ agentId, ...(projectId ? { projectId } : {}), source }, client),
1070
1071
  'Rescan failed',
1071
1072
  );
1072
1073
 
@@ -1076,7 +1077,7 @@ export function SourcesPanel(props: Props) {
1076
1077
  setPurgeBusy(true);
1077
1078
  setError(null);
1078
1079
  try {
1079
- const counts = await purgeSourceIndex({ agentId, ...(projectId ? { projectId } : {}), source, selector, mode, obsoleteOnly: purgeObsoleteOnly });
1080
+ const counts = await purgeSourceIndex({ agentId, ...(projectId ? { projectId } : {}), source, selector, mode, obsoleteOnly: purgeObsoleteOnly }, client);
1080
1081
  if (epoch !== purgeEpoch.current) return;
1081
1082
  if (mode === 'estimate') setPurgeCounts(counts);
1082
1083
  else { setPurgeFor(null); setPurgeCounts(null); clearCachedCounts(source); await load(); await refresh({ agentId, projectId, withCounts: true }); }
@@ -1088,7 +1089,7 @@ export function SourcesPanel(props: Props) {
1088
1089
  setResettingSource(source);
1089
1090
  setError(null);
1090
1091
  try {
1091
- await resetSync({ agentId, ...(projectId ? { projectId } : {}), source });
1092
+ await resetSync({ agentId, ...(projectId ? { projectId } : {}), source }, client);
1092
1093
  await load();
1093
1094
  } catch (err) {
1094
1095
  setError(err instanceof Error ? err.message : 'Reset failed');
@@ -1107,7 +1108,7 @@ export function SourcesPanel(props: Props) {
1107
1108
  setTogglingEnabledSource(source);
1108
1109
  setError(null);
1109
1110
  try {
1110
- await setSourceEnabled({ agentId, ...(projectId ? { projectId } : {}), source, enabled: next });
1111
+ await setSourceEnabled({ agentId, ...(projectId ? { projectId } : {}), source, enabled: next }, client);
1111
1112
  await load();
1112
1113
  } catch (err) {
1113
1114
  setError(err instanceof Error ? err.message : 'Toggle failed');
@@ -1125,7 +1126,7 @@ export function SourcesPanel(props: Props) {
1125
1126
  ...(projectId ? { projectId } : {}),
1126
1127
  source,
1127
1128
  ...(opts.purgeVectors ? { purgeVectors: true } : {}),
1128
- });
1129
+ }, client);
1129
1130
  await load();
1130
1131
  // The deleted row is gone; no surviving source's count moved, so the
1131
1132
  // refresh runs count-free. `clearCachedCounts` still drops the row's own
@@ -1151,7 +1152,7 @@ export function SourcesPanel(props: Props) {
1151
1152
  setBrowseLoading(true);
1152
1153
  setBrowseError(null);
1153
1154
  try {
1154
- const result = await browseDirectories({ path, agentId, ...(projectId ? { projectId } : {}) });
1155
+ const result = await browseDirectories({ path, agentId, ...(projectId ? { projectId } : {}) }, client);
1155
1156
  setBrowseEntries(result.entries);
1156
1157
  setBrowsePath(path);
1157
1158
  } catch {
@@ -1212,7 +1213,7 @@ export function SourcesPanel(props: Props) {
1212
1213
  ...(isOwnerEffective && addKind === 'local' && addDraft.recognizesPackages
1213
1214
  ? { recognizesPackages: true }
1214
1215
  : {}),
1215
- });
1216
+ }, client);
1216
1217
  setShowAddForm(false);
1217
1218
  {
1218
1219
  const reset = buildAddDraft(selectedKind, sourceKeyIds);
@@ -1260,7 +1261,6 @@ export function SourcesPanel(props: Props) {
1260
1261
  attached="left"
1261
1262
  skill="manage-sources"
1262
1263
  packageId="@neuralis/brain-core"
1263
- scripts={["scripts/sync.sh", "scripts/reindex.sh", "scripts/set-enabled.sh"]}
1264
1264
  label="Manage sources with the agent"
1265
1265
  />
1266
1266
  </div>
@@ -1672,13 +1672,11 @@ export function SourcesPanel(props: Props) {
1672
1672
 
1673
1673
  return (
1674
1674
  <div key={fileKey(sourceRootUri(config.source))} className="rounded-sm">
1675
- {/* Accordion header */}
1676
- <button
1677
- type="button"
1678
- onClick={() => toggleSource(config.source)}
1679
- title={disconnected ? sourceLiveReasonLabel(config.live_reason) : undefined}
1680
- className="w-full flex items-center gap-2 py-2 text-left group hover:bg-white/[0.02] rounded-md px-1 transition"
1681
- >
1675
+ {/* Accordion header: selection and enabled actions are siblings. */}
1676
+ <div className="w-full flex items-center gap-2 py-2 text-left group hover:bg-white/[0.02] rounded-md px-1 transition">
1677
+ <button type="button" onClick={() => toggleSource(config.source)} aria-expanded={isOpen}
1678
+ title={disconnected ? sourceLiveReasonLabel(config.live_reason) : undefined}
1679
+ className="min-w-0 flex-1 flex items-center gap-2 text-left">
1682
1680
  {/* Git indicator (G2.1) — a GitBranch overlay on a git-repo
1683
1681
  source's icon, ORTHOGONAL to the sync tone (visualTone still
1684
1682
  owns the icon colour). */}
@@ -1713,6 +1711,7 @@ export function SourcesPanel(props: Props) {
1713
1711
  {dirty && (
1714
1712
  <span className="shrink-0 rounded px-1 py-0.5 text-[8px] font-medium text-amber-300/80 bg-amber-500/10">unsaved</span>
1715
1713
  )}
1714
+ </button>
1716
1715
  {/* W3K — enabled toggle (per scope, per source). Machine sources also offer Auto. */}
1717
1716
  {(() => {
1718
1717
  const enabled = config.enabled ?? true;
@@ -1763,7 +1762,7 @@ export function SourcesPanel(props: Props) {
1763
1762
  {describeConn(config, meta)}
1764
1763
  </span>
1765
1764
  <ChevronDown className={cn('h-3 w-3 text-white/25 shrink-0 transition-transform', isOpen && 'rotate-180')} />
1766
- </button>
1765
+ </div>
1767
1766
 
1768
1767
  {/* Expanded body — when open it expands to full height (no clamp)
1769
1768
  so every Path Rule / uri-policy is visible; the outer panel
@@ -1902,7 +1901,6 @@ export function SourcesPanel(props: Props) {
1902
1901
  size="sm"
1903
1902
  skill="manage-uri-policy"
1904
1903
  packageId="@neuralis/brain-core"
1905
- scripts={["scripts/show-uri.sh", "scripts/set-uri.sh", "scripts/manifests.sh"]}
1906
1904
  label={`Edit URI policy for "${config.source}" with the agent`}
1907
1905
  icon="shield"
1908
1906
  />
@@ -20,6 +20,7 @@ export function Toolbar(props: { agentId: string; onOpenProjectFiles: () => void
20
20
  const activeWorkspaceTab = useFilesystemStore((s) => s.activeWorkspaceTab);
21
21
  // The server count of the caller's visible pending reviews (`refreshPendingCount`),
22
22
  // never the client set of loaded folders' rows.
23
+ const pendingCountState = useFilesystemStore((s) => s.pendingCountState);
23
24
  const pendingCount = useFilesystemStore((s) => s.pendingCount);
24
25
 
25
26
  return (
@@ -112,7 +113,7 @@ export function Toolbar(props: { agentId: string; onOpenProjectFiles: () => void
112
113
  size="sm"
113
114
  skill="manage-files"
114
115
  packageId="@neuralis/brain-core"
115
- scripts={["scripts/mkdir.sh", "scripts/copy.sh", "scripts/modify.sh", "scripts/search.sh"]}
116
+ scripts={["scripts/mkdir.sh", "scripts/copy.sh", "scripts/modify.sh"]}
116
117
  label="Create, copy, edit or search files with the agent"
117
118
  icon="Folder"
118
119
  />
@@ -133,7 +134,7 @@ export function Toolbar(props: { agentId: string; onOpenProjectFiles: () => void
133
134
 
134
135
  <button
135
136
  className={`relative p-1.5 rounded-lg transition-colors ${workspacePanels.pending && activeWorkspaceTab === 'pending' ? 'bg-amber-500/15 text-amber-300' : 'text-white/50 hover:bg-white/5 hover:text-amber-200'}`}
136
- title={pendingCount > 0 ? `Pending changes — ${pendingCount} to review` : 'Pending changes — review, approve & revert'}
137
+ title={pendingCountState === 'stale' ? `Pending changes — count unavailable${pendingCount > 0 ? ` (last count ${pendingCount})` : ''}` : pendingCountState === 'partial' ? `Pending changes — ${pendingCount}+ (incomplete) to review` : pendingCount > 0 ? `Pending changes — ${pendingCount} to review` : 'Pending changes — review, approve & revert'}
137
138
  onClick={() => {
138
139
  if (workspacePanels.pending && activeWorkspaceTab === 'pending') {
139
140
  setWorkspacePanelOpen('pending', false);
@@ -143,9 +144,9 @@ export function Toolbar(props: { agentId: string; onOpenProjectFiles: () => void
143
144
  }}
144
145
  >
145
146
  <FileDiff className="w-4 h-4" />
146
- {pendingCount > 0 ? (
147
+ {pendingCount > 0 || pendingCountState !== 'exact' ? (
147
148
  <span data-testid="pending-count-badge" className="absolute -top-1 -right-1 min-w-[14px] rounded-full px-1 text-center text-[9px] leading-[14px] font-semibold bg-amber-500/15 text-amber-300">
148
- {pendingCount >= 1000 ? '1000+' : pendingCount}
149
+ {pendingCount > 999 ? '1000+' : pendingCount}{pendingCountState === 'partial' && pendingCount <= 999 ? '+' : pendingCountState === 'stale' ? '?' : ''}
149
150
  </span>
150
151
  ) : null}
151
152
  </button>
@@ -7,6 +7,7 @@ import { hasFeature } from '@neuralis/package-system/access';
7
7
  import { useFilesystemHost } from '../FilesystemHost';
8
8
  import { cn } from '../lib/cn';
9
9
  import { checkVectorHealth, scanVectorHealth, runHealthAction, getSourcesHealth } from '../api';
10
+ import { useFilesystemStore } from '../store/filesystemStore';
10
11
 
11
12
  type Props = {
12
13
  agentId: string;
@@ -73,6 +74,7 @@ const ACTION_COPY: Record<ActionKind, { label: string; explainer: string; toolti
73
74
  };
74
75
 
75
76
  export function VectorHealthPanel({ agentId, projectId }: Props) {
77
+ const client = useFilesystemStore((s) => s.client);
76
78
  const host = useFilesystemHost();
77
79
  // A mirror only — the server's `drive.write` escalation is the gate.
78
80
  const canWrite = hasFeature({ grantedFeatures: [...(host.grantedFeatures ?? [])] }, 'drive.write');
@@ -97,9 +99,17 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
97
99
  * Cleared automatically after 3s. Skipped when `dryRun === true` since
98
100
  * preview-only doesn't need a guard. */
99
101
  const [confirmingAction, setConfirmingAction] = useState<ActionKind | null>(null);
102
+ const epoch = useRef(0);
103
+ const reportSeq = useRef(0);
104
+ const scanSeq = useRef(0);
105
+ const stop = useRef(false);
106
+ const pendingCursor = useRef<string | undefined>(undefined);
107
+ const [pendingPaused, setPendingPaused] = useState(false);
108
+ useEffect(() => { pendingCursor.current = undefined; setPendingPaused(false); }, [dryRun, retentionHours]);
100
109
  const confirmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
101
110
 
102
111
  const fetchReport = useCallback(async () => {
112
+ const mine = epoch.current; const request = ++reportSeq.current;
103
113
  setLoading(true);
104
114
  setError(null);
105
115
  try {
@@ -108,8 +118,8 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
108
118
  // table so it surfaces real "when was this source last synced"
109
119
  // info instead of being a count-only view.
110
120
  const [data, sourcesHealth] = await Promise.all([
111
- checkVectorHealth({ agentId, ...(projectId ? { projectId } : {}) }),
112
- getSourcesHealth({ agentId, ...(projectId ? { projectId } : {}) })
121
+ checkVectorHealth({ agentId, ...(projectId ? { projectId } : {}) }, client),
122
+ getSourcesHealth({ agentId, ...(projectId ? { projectId } : {}) }, client)
113
123
  .catch(() => [] as Array<{ source: string; last_sync?: string; sync_status: string }>),
114
124
  ]);
115
125
  const lastSyncBySource = new Map<string, { last_sync?: string; sync_status: string }>();
@@ -126,26 +136,29 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
126
136
  return meta ? { ...row, ...meta } : row;
127
137
  });
128
138
  }
139
+ if (mine !== epoch.current || request !== reportSeq.current) return;
129
140
  setReport(enriched);
130
141
  } catch (err) {
131
- setError(err instanceof Error ? err.message : 'Failed to fetch health report');
142
+ if (mine === epoch.current && request === reportSeq.current) setError(err instanceof Error ? err.message : 'Failed to fetch health report');
132
143
  } finally {
133
- setLoading(false);
144
+ if (mine === epoch.current && request === reportSeq.current) setLoading(false);
134
145
  }
135
- }, [agentId, projectId]);
146
+ }, [client, agentId, projectId]);
136
147
 
137
148
  const runScan = useCallback(async () => {
149
+ const mine = epoch.current; const request = ++scanSeq.current;
138
150
  setScanLoading(true);
139
151
  setError(null);
140
152
  try {
141
- const result = await scanVectorHealth({ agentId, ...(projectId ? { projectId } : {}) });
153
+ const result = await scanVectorHealth({ agentId, ...(projectId ? { projectId } : {}) }, client);
154
+ if (mine !== epoch.current || request !== scanSeq.current) return;
142
155
  setScan({ orphanChunkCount: result.orphanChunkCount ?? 0, duplicateGroups: result.duplicateGroups ?? 0 });
143
156
  } catch (err) {
144
- setError(err instanceof Error ? err.message : 'Scan failed');
157
+ if (mine === epoch.current && request === scanSeq.current) setError(err instanceof Error ? err.message : 'Scan failed');
145
158
  } finally {
146
- setScanLoading(false);
159
+ if (mine === epoch.current && request === scanSeq.current) setScanLoading(false);
147
160
  }
148
- }, [agentId, projectId]);
161
+ }, [client, agentId, projectId]);
149
162
 
150
163
  const runAction = useCallback(async (action: ActionKind) => {
151
164
  // Two-click confirm for destructive actions when not in dry-run mode.
@@ -160,6 +173,7 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
160
173
  confirmTimerRef.current = null;
161
174
  }
162
175
  setConfirmingAction(null);
176
+ const mine = epoch.current; stop.current = false;
163
177
  setActionLoading(true);
164
178
  setActionResult(null);
165
179
  setLastActionKind(action);
@@ -168,8 +182,22 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
168
182
  // we're actually deleting something (`dryRun=false`).
169
183
  setPrevReport(!dryRun ? report : null);
170
184
  try {
171
- const result = await runHealthAction({ agentId, ...(projectId ? { projectId } : {}), action, dryRun, retentionHours });
172
- setActionResult(result);
185
+ let cursor = action === 'repair-pending' ? pendingCursor.current : undefined; setPendingPaused(false);
186
+ const totals: Record<string, number> = {};
187
+ do {
188
+ const result = await runHealthAction({ agentId, ...(projectId ? { projectId } : {}), action, dryRun, retentionHours, ...(cursor ? { cursor } : {}) }, client);
189
+ if (mine !== epoch.current) return;
190
+ for (const key of ['scannedVisible', 'orphaned', 'attempted', 'discarded', 'refused', 'failed', 'legacyUnknownDraft']) {
191
+ if (typeof result[key] === 'number') totals[key] = (totals[key] ?? 0) + result[key];
192
+ }
193
+ setActionResult({ ...result, ...totals });
194
+ cursor = action === 'repair-pending' && !result.restartRequired && typeof result.nextCursor === 'string' ? result.nextCursor : undefined;
195
+ if (action === 'repair-pending') pendingCursor.current = cursor;
196
+ if (result.partial === true) { setPendingPaused(true); setError('Repair paused; part of the inventory or orphan checks are unavailable.'); break; }
197
+ if (result.restartRequired) { pendingCursor.current = undefined; setPendingPaused(true); setError('Changes moved. Refresh before continuing repair.'); }
198
+ if (typeof result.failed === 'number' && result.failed > 0) { cursor = undefined; setPendingPaused(true); setError('Repair paused after a failed change. Refresh before continuing.'); }
199
+ } while (cursor && !stop.current);
200
+ if (cursor) setPendingPaused(true);
173
201
  if (!dryRun) {
174
202
  void fetchReport();
175
203
  // A destructive action changes orphan/dup counts — refresh the scan
@@ -177,15 +205,17 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
177
205
  if (scan) void runScan();
178
206
  }
179
207
  } catch (err) {
180
- setError(err instanceof Error ? err.message : 'Action failed');
208
+ if (mine === epoch.current) { if (action === 'repair-pending') setPendingPaused(true); setError(err instanceof Error ? err.message : 'Action failed'); }
181
209
  } finally {
182
- setActionLoading(false);
210
+ if (mine === epoch.current) setActionLoading(false);
183
211
  }
184
- }, [agentId, projectId, dryRun, retentionHours, fetchReport, confirmingAction, report, scan, runScan]);
212
+ }, [client, agentId, projectId, dryRun, retentionHours, fetchReport, confirmingAction, report, scan, runScan]);
185
213
 
186
214
  // Auto-load on mount
187
215
  useEffect(() => {
216
+ epoch.current++; stop.current = true; pendingCursor.current = undefined; setPendingPaused(false); setActionLoading(false); setActionResult(null); setReport(null); setScan(null); setError(null);
188
217
  void fetchReport();
218
+ return () => { epoch.current++; stop.current = true; };
189
219
  }, [fetchReport]);
190
220
 
191
221
  // Clean up confirm timer on unmount.
@@ -209,6 +239,8 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
209
239
 
210
240
  return (
211
241
  <div className="space-y-3 px-2 pb-2">
242
+ {actionLoading && lastActionKind === 'repair-pending' ? <button type="button" onClick={() => { stop.current = true; }} className="text-xs text-amber-200">Stop after this batch</button> : null}
243
+ {canWrite && pendingPaused && !actionLoading ? <button type="button" onClick={() => void runAction('repair-pending')} className="text-xs text-amber-200">{pendingCursor.current ? 'Continue available repair' : 'Retry repair'}</button> : null}
212
244
  {/* Description */}
213
245
  <div className="text-[10px] text-white/40 italic leading-relaxed">
214
246
  Cleanup tool for the vector index. Removes leftover records:{' '}
@@ -368,7 +400,6 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
368
400
  size="sm"
369
401
  skill="manage-sources"
370
402
  packageId="@neuralis/brain-core"
371
- scripts={["scripts/sync-status.sh", "scripts/reindex.sh"]}
372
403
  label="Inspect sync status or reindex a source with the agent"
373
404
  icon="Database"
374
405
  />
@@ -376,13 +407,13 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
376
407
  <div className="flex items-center gap-3">
377
408
  <label className="inline-flex items-center gap-1 text-[10px] text-white/60 cursor-pointer"
378
409
  title="Preview only — nothing will be deleted.">
379
- <input type="checkbox" checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} className="accent-emerald-400 h-3 w-3" />
410
+ <input type="checkbox" disabled={actionLoading} checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} className="accent-emerald-400 h-3 w-3" />
380
411
  Dry run
381
412
  </label>
382
413
  <label className="inline-flex items-center gap-1 text-[10px] text-white/60"
383
414
  title="Records marked as deleted within this many hours are kept (GC ignores them).">
384
415
  Retention:
385
- <input type="number" min={1} value={retentionHours}
416
+ <input type="number" disabled={actionLoading} min={1} value={retentionHours}
386
417
  onChange={(e) => setRetentionHours(Math.max(1, Number(e.target.value) || 24))}
387
418
  className="w-12 h-5 rounded px-1 text-[10px] bg-white/[0.04] border border-white/[0.08] text-white/80 outline-none" />h
388
419
  </label>