@neuralis/brain-core
v0.3.2
Published
The brain of Neuralis: any system a connector can reach — local directories, repositories, machine sandboxes, the vector memory itself — joins ONE URI-addressed, vector-indexed, project/user/agent-scoped virtual filesystem, chunked and indexed on sync wit
Readme
@neuralis/brain-core
The brain of Neuralis: any system a connector can reach — local directories, repositories, machine sandboxes, the vector memory itself — joins ONE URI-addressed, vector-indexed, project/user/agent-scoped virtual filesystem, chunked and indexed on sync with optional embedding, revertible on write, policy-governed per path. A synced source pays back twice: its content becomes searchable memory, and its markdown becomes source packages. Concretely: URI-native filesystem, vector memory, source connectors, and the Files UI.
Responsibility
- Read, create, modify, list, and search files across every registered source through one URI vocabulary.
- Manage project-, user-, and agent-scoped source configurations and persist them in the platform zone.
- Host the connector registry contract: source connectors declared by other packages plug in via
SourceKindRegistry. - Sync connector content into a Qdrant-backed payload/full-text index, with optional chunked embeddings.
- Expose the Filesystem package routes (
/api/packages/brain-core/*) and the agent tools (fs_read,fs_write,fs_list,fs_search). - Render the workspace Files widget and the
filesystem.changeagent-card.
URI vocabulary (the four-tuple coordinate)
Every file artifact in Neuralis lives at the coordinate
(uri, connector, os_uri?, container_dir?):
uri—<source>://<path>where<source>is the source slug (URI scheme).connector— theConnectorPortbound to that source slug at boot.os_uri— optional full host-OS path, produced byconnector.resolveOsUri(uri).LocalDiskConnector.resolveOsUrithrowsOsUriUnresolvableError(code: 'os_uri_host_pair_missing') when running in Docker without ahostRootconfigured — vectors and UI persistos_uri: undefinedrather than fake paths. The shared helperdomain/paths.ts:resolveOsUriswallows that throw.container_dir— optional container-path counterpart for skill / shell execution, threaded throughruntimeStack.
Connector kinds shipping with brain-core: local (full filesystem
capabilities incl. mkdir/rmdir for real folder create/remove and stat, the
content-free existence probe a caller uses instead of reading a whole file to
learn it is still there), brain
(Qdrant-backed internal memory; reads go through FilesystemService, not the
placeholder connector). Other packages contribute more kinds — webtop from
machine-core, and host from agent-core (a read-WRITE view of a paired
device — the server or a person's own machine — through its broker over the device
link; 13 declared capabilities including write/delete/move/mkdir/rmdir
and rawRead; containment comes from the device owner's ceiling, never from a
missing capability. See the
host-plane rules below).
A connector's manifest id is not its kind, and only the KIND addresses the
registry: brain-core's on-disk connector is declared as id local-disk with
"kind": "local", and source_kind: 'local-disk' is additionally a UI-side
rename applied by FilesystemService. GET /sources/kinds/:kind/schema takes
the registry kind — local — and answers 404 Unknown kind "local-disk" for
every caller, at every role, when handed the id instead. Enumerate the live set
with GET /sources/kinds rather than deriving it from a connector id or a
UI label.
A mutation against a connector that cannot perform it is a 400, never a
silent success. applySourceMutation checks the connector's declared
capability SET before every write/delete/move — an emulated move needs
both write and delete. Optional-chaining these calls (the previous shape)
made a read-only connector report a write that never happened, while the
journal and the vector index recorded it as landed, so the indexed content and
the real file diverged with nothing to signal it.
The Files widget offers VS Code-style structural ops — create file/folder,
rename, move (incl. cross-source), copy/paste, delete (files + recursive
folders) — each a package capability the agent ALSO has: fs_write carries
mkdir and copy as verb keys (and recursive folder delete + cross-source
move), all behind the one write route. A rename or
move onto a uri that already holds a file or folder is refused (DESTINATION_EXISTS,
still HTTP 200 — the outcome is in ok): a relocation never overwrites, on any connector
kind. Create, copy and upload land BESIDE an existing file under a suffixed name. The
occupancy answer is ONE primitive (uriOccupied in usecases/write/occupancy.ts: the index
at any status ∪ the connector — the disk decides, never the user-scoped index alone). A HUMAN's
structural op commits immediately; an AGENT's stays pending_review (the
interactive marker — see src/routes/README.md + app/filesystem/ui/README.md).
Pending pages expose visible-id continuation and complete/partial/restart state. Full
projected vector reconstruction plus canonical URI twins occurs before slicing, so page
size bounds output rather than scan cost. Journal retention defaults to 32 MiB; oversize
journals rebuild per operation. Repair defaults to 50 visible records per batch with fresh
per-record authorization.
URI schemes you will see in production:
| Scheme | Connector kind | Source of slug | Notes |
|---|---|---|---|
| brain:// | brain | builtin | Vector-only memory, agent-scoped by default |
| data:// | local | builtin | Project data zone ({neuralisHome}/projects/{pid}/data/) |
| data.X:// | local | user | Additional local mount with a named slug |
| packages:// | local | builtin | Project's _packages/ directory |
| app:// | local | builtin (privileged mount — sources.privileged) | Platform app zone (~/.neuralis/app/); writes from agents are denied |
| neuralis:// | local | user (pnpm neuralis:mount add … --neuralis) | The deployment's OWN neuralis/ folder, live from the host. Surfaces as a local-kind source — there is no dedicated neuralis connector kind. The switch is --neuralis, not --slug neuralis (that is the ordinary data mount at /mounts/neuralis, without the two anonymous volumes that keep _runtime/ and node_modules/ from being shadowed). |
| neuralis-skills:// | local | user (same overlay, rooted at <root>/skills) | The narrower default: only the shipped operator skills. Preferred over neuralis:// for giving an agent the operator-command vocabulary — the overlay root holds .env, and a source root is a READ boundary in a way a sync include is not. |
| machine:// | webtop | machine-core | Webtop sandbox; declared by machine-core, not brain-core. machine is the slug / urlScheme — the registry KIND is webtop, and only the kind addresses GET /sources/kinds/:kind/schema. |
| os://<key> | — | decorative only | Renders inside the system prompt's <runtime_stack> block as a label for NEURALIS_MOUNT_<KEY> mounts. No connector handles os://. Agents addressing files inside a mount use the local-kind source slug attached to that mount. |
The git MCP connector (first type:'mcp' in the repo)
package.json declares the repo's first PackageConnector of type:'mcp',
transport:'in-process' — { id:'git', name:'Git', icon:'GitBranch',
category:'mcp', requiredScopes:['project'] }. Unlike
the type:'source' connectors above, an in-process type:'mcp' descriptor is
inbound-host: it advertises brain-core's OWN hosted git tools through
PackageContributionHost / GenericMcpServer, it is NOT an outbound MCP server.
Because of that direction, both ConnectorLifecycleManager.registerFromPackage
and PackageLoader skip transport==='in-process' (alongside
type==='source') — it is never persisted to ConnectorConfigStore and never
auto-connected, so it does not produce a phantom "Git connected" row from
GET /connectors. It is accepted only for trust >= trusted (in-process is
in-host execution, the same floor as stdio/sidecar; untrusted/WASM is rejected).
The two advisory fields (defaultToolFilter, requiredScopes) are metadata
only and GATE NOTHING — the real gates stay mcpHosting.enabledPackages + each
hosted tool's requires.features + source uri-policy. There is no
credentialScopeHint: a connector's secrets are declared once in
neuralis.credentials[] and resolved through the injected CredentialResolver. Today the descriptor is metadata only: no git tools are hosted
yet, so it appears in the <packages> overview (PackagesOverviewBuilder renders
- Git (mcp)) but NOT in the MCP-hosting config panel — the hosted tools + the
enable-semantics land later.
Git engine (src/git/GitRunner.ts)
GitRunner wraps simple-git over the container's baked-in git binary (reused
via resolveSearchBinaries, the same path as gitGrep). It is keyed on a
resolved checkout root (resolveLocalRoot().resolvedRoot) — the SAME root the
local connector mounts and the shell uri-policy gate keys on — and enforces four
containment floors: (1) cwd = root + a sub-path guard + a
git rev-parse --show-toplevel --absolute-git-dir clamp, so a .git ABOVE the
source and a .git FILE/symlink whose git-dir redirects OUTSIDE the source
are both rejected (GitOutsideSourceError, one memoized spawn — the
containment close; the git-dir is compared against the CANONICALIZED root, so a
legitimately symlinked mount path never false-rejects. Limit: a standalone-mounted
LINKED WORKTREE, whose git-dir lives in the main repo outside the source, fails
soft — no badges); (2) reject leading-dash refs/paths (option-injection)
and require -- before path operands; (3) -c core.hooksPath=/dev/null
-c core.fsmonitor=false -c core.pager=cat on every call, defeating a malicious
repo-local .git/config (closes the malicious-checkout RCE class); (4) strip EVERY
ambient GIT_* env var, then set GIT_CONFIG_GLOBAL=/dev/null,
GIT_CONFIG_SYSTEM=/dev/null, GIT_TERMINAL_PROMPT=0 and GIT_OPTIONAL_LOCKS=0.
Simple-git v4 requires top-level allowEnvironment naming exactly those four
fixed defensive overrides, together with the existing explicit unsafe:{…}
opt-ins for floors (3)/(4). It blocks those same flags/env as attacker-injection
vectors — stripping ALL ambient GIT_* (not a denylist) remains the robust rule. It exports the
pure helpers classifyGitOp(op, args) → 'read'|'mutating'|'destructive' (for the
later approval gate) and normalizeStatus. Read primitives are fully implemented;
local-mutating ops are implemented but NOT yet tool-exposed (they wait on the
uri-policy write gate + approval guard).
Remote ops. fetch/push/pull/mergeFetched
run through a SEPARATE #spawnGitRemote variant — raw node:child_process, NEVER
simple-git (the remote -c set — credential.helper, core.sshCommand,
protocol.* — is exactly what simple-git's vulnerability check blocks, so routing
it through the simple-git ctor config: would be boot-fatal). In addition to the
three local hardening flags it prepends the full protected-config-aware
remote-hardening -c set (only 4 git config keys are protected; everything else
is honored from a hostile repo-local .git/config, and -c = command scope wins):
credential.helper= (empty RESET first — a hostile repo-local helper is BOTH a
!shell RCE AND receives the pushed token on git's unconditional store
broadcast) then credential.helper='!<constant helper>'; core.askPass= +
credential.interactive=false (a repo-local core.askPass is consulted before the
GIT_TERMINAL_PROMPT=0 gate); protocol.{file,ext,git}.allow=never; pinned
remote.<n>.{upload,receive}pack; core.sshCommand=/core.gitProxy=. The
credential helper snippet is a CONSTANT string (no interpolation of ANY dynamic
value — username/host/token arrive via child-env NRS_GIT_{USER,HOST,TOKEN}, which
the shell reads but never re-evaluates as code); it reads protocol=/host= from
stdin and releases the token ONLY when protocol is https AND host equals the
BINDING host (NRS_GIT_HOST) — this stdin host-match, NOT any URL derivation, is
the redirect-class defense (insteadOf is un--c-clearable). HTTPS-only (SSH
fails closed). The token is present ONLY on the network-only invocations
(fetch/push); pull = fetch (token in env) + mergeFetched (merge
--ff-only, NO token in env) so the token never rides a working-tree checkout/merge
(a hostile filter/textconv/merge driver is attacker-NAMED ⇒ un--c-clearable,
closed only by keeping the token out of that process). No clone, no force-push.
The push token is the CALLER'S OWN git.<host>.pat/.oauth.accessToken, resolved
per-request via src/git/tokenResolve.ts (resolveGitToken) over the
kernel-injected ctx.credentials cascade at the ensureAccess-validated scope, and
never cached on GitRunner. status(subPath?) is pathspec-scoped when given a folder (the per-file
badge path) — git status -- <subPath> so it costs O(subtree), never O(repo)
— and an optional GitRunnerOptions.timeoutMs caps a slow scan (kills the child →
GitTimeoutError). That timeout is an INACTIVITY timer — it resets on every output chunk,
capping silence, not wall-clock — so the potentially-large read primitives (diff,
logGraph, show, cat-file type/size) run on a direct-spawn path with an engine-level
output accumulation bound (~4 MB combined stdout+stderr; the child is KILLED past it →
GitOutputLimitError): every byte is buffered in-process before any route cap can trim it,
and a continuously-streaming command trips no inactivity timer, so the byte bound is the real
memory floor beneath the route caps. blobType/blobSize (git cat-file -t/-s) let a
caller refuse a non-blob or oversized object BEFORE buffering its content; the <rev>:<path>
colon form is composed inside the engine (the literal rev index is the sentinel for the
staged blob, :0:<path>) — caller text never enters it raw. The per-commit / outgoing file
lists ride ONE two-tree form: treeDiffFiles(from, to) = git diff-tree -r
--name-status -M -z <from> <to> (NUL-safe parseNameStatusZ; a rename record is
R<score> NUL <old> NUL <new>), with commitFiles(sha, {hasParent}) composing
<sha>^ ↔ <sha> (first-parent, merge-correct) or EMPTY_TREE_SHA ↔ <sha> for a root
commit — the caller answers the parent question via commitParents(sha) (rev-list
--parents -n 1), never by catching errors. The single-arg diff-tree -r <sha> and the
<sha>^! forms are EMPTY on merge commits (live-proven) — never use them, and never
--root (it prepends a header record the -z parse would have to skip). The outgoing
readers are outgoingShas(cap=500) (rev-list @{u}..HEAD, null — never a throw — when
no upstream/any failure) and resolveUpstreamHead() ({baseSha|null, headSha}, server-side
ref resolution so no route ever takes caller refs; baseSha is merge-base(@{u}, HEAD),
NOT @{u} — a two-tree diff against a DIVERGED remote tip would phantom remote-only files
as Deleted, disagreeing with the two-dot @{u}..HEAD commit set; on an ahead-only branch the
merge base equals @{u}, on unrelated histories it degrades to EMPTY_TREE_SHA); both are
LOCAL-ref reads — zero network; the network reach is the remote-ops surface (fetch/pull/push, below).
Git-aware file tree — per-file badges
The first consumer of GitRunner: FilesystemService.list stamps a
git working-tree letter (M/A/D/R/?, the 2-char porcelain XY code) onto
dirty files under a git-backed local source, surfaced as a new git? field on each
ListFolderResponse file. Both the fs_list tool (a bracketed [M] glyph in the
tree/flat markdown + legend) and the Files-widget tree (a colour-coded GitStatusBadge)
render it as a third, orthogonal decoration axis — a file is simultaneously synced
(sync tone) AND M (git) — never folded into the sync tone / getNodeVisualState
precedence. Computation lives in src/git/sourceGitStatus.ts. Folders with dirty
descendants get a rollup dot (rollupFolderGit picks the most-significant descendant), so
changes are visible from the root without drilling in. Both client list paths carry the
git field — the root load (listSlice) AND the lazy folder-expand
(filesystemStore.ensureFolderLoaded, what the tree uses when you open a folder); a
server-sourced FileNode field is dropped if only one is wired.
Scaling (million-file safe). The filesystem serves huge sources by paying only for
the folder being viewed, so git enrichment does too: sourceGitStatus runs git status
pathspec-scoped to the listed folder (never the whole repo), behind an inactivity
timeout (bounds git's silent whole-tree scan — the huge-repo lstat storm — so a
too-slow scan is skipped, not blocked), a short per-(root,folder) TTL cache, and a
cheap .git stat pre-gate (non-git sources — the majority — pay one stat and skip). It
is strictly additive and fail-soft: any problem yields no badges, never an error and
never a slowdown of the vector/connector list paths.
Security + limits. Badges are attached AFTER the source-level access filter, so a
badge never lands on a file from a source the caller cannot see. The status is read-only
(no creds/network/mutation); the listed folder passes GitRunner's existing path guard.
UX limit: per-file badges require the source ROOT to BE the git checkout toplevel — a
source that is a subdir of a larger repo shows no badges (the toplevel clamp fails soft).
Files INSIDE the nested checkouts of a multi-repo source (see the multi-repo model below) now DO get
per-file badges/rollups: when the source root has no .git the badge path
enumerates the discovered nested roots (the injected gitRepos port =
discoverSourceRepoRoots) and runs a pathspec-scoped git status against each in the
listed scope, rebasing the match keys by the <rel>/ prefix — up to the ≤25-repo
discovery cap of nested git status on a root view (each timed + cached, the documented
first-view cost). Untracked files list individually (--untracked-files=all, VS-Code
parity; .gitignore still excludes ignored trees).
File tree — lazy hydration and convergence
The tree loads in two independent stages.
Stage 1 — source headers, no walk. The root call asks for the source REGISTRY
only (sources_only), so every header paints before a single connector is touched.
Nothing about one source's reachability can delay another's row. Counts stay opt-in
and belong to the Sources panel.
Stage 2 — content on expand. Opening a source branch (or a folder, or following
a deep link) lists exactly that folder, non-recursively. Each source carries its own
fetch state — idle / loading / ready / error — shown as a dim + an in-place
spinner on its header. That state is the CLIENT's; it is orthogonal to the source's
connector liveness (live), which dims the same row for a different reason and says
so in the tooltip. A listing that failed is not retried automatically; the header's
refresh control retries it.
Converging on server truth. Refresh is deliberately more than a re-fetch. The plain source/root load is upsert-only — it merges what came back and leaves the rest alone, which is right for a partial or truncated response but means a row the server no longer has can never disappear through it. So the toolbar Refresh re-lists the visible folders of every hydrated source and applies absence semantics: an external-only file that is gone is pruned; a tracked file that is gone becomes a struck-through, still-revertible tombstone; a folder is pruned only when it is external-only and parents nothing live. A truncated listing never prunes (absence is not deletion there) and instead flags the source as possibly-stale on its header. Every mutation converges the same way, but scoped to the parents it actually touched.
Per-source refresh. Each source header carries its own refresh control, in the same slot the spinner uses. It works on a collapsed source too — an explicit click is a user intent, unlike the automatic event-driven loads, which never list a collapsed source — and it opens the branch so the result is visible.
When events go missing. The filesystem event channel is live-only: no replay, no cursor. Anything emitted while the connection was down is gone for good. The widget therefore re-converges automatically after a connection flip, which is what keeps Refresh a deliberate action rather than routine repair.
Search view
The Search sidebar view replaces the tree with a search surface. The query
input rides INLINE in the sidebar header (title flips to "Search"; the toggle —
or Escape — clears the query and closes even with text present). Three modes:
Keyword (default — the indexed filter engine, term-frequency ranked),
Semantic (embedding/hybrid), Grep (literal scan of a live connector
scope; disabled when only brain:// is reachable). A separate filter row
(filename glob, category, status, tags, created range, scope-to-folder) combines
with Keyword AND Semantic; grep uses the filename + scope filters. Results are a
single list with match badges [H]/[S]/[K], scores, grep uri:line, and
per-mode loading/error states; backend error rows (e.g. grep scope guidance)
render inline. The wire body is the tool vocabulary (search_mode) — pinned by
test/ui/searchApi.wire.test.ts.
Git Control panel — source-control view
The Git Control sidebar view (a top-level sibling toggle beside Explorer and
Search — sidebarView: 'tree' | 'search' | 'git' on the Files store) replaces the file
tree in place with a source-control surface for one focused git repository. It
shows a fixed header zone — a repo-switcher over the caller's accessible git
REPOSITORIES (a root-repo source contributes one entry; a multi-repo source lists every
nested checkout, lazy-fetched from GET /git/repos — the multi-repo model below; header + rows
render the two-part slug:// Name label from git.repoName / the nested repo's name
— dim-mono source coordinate + the repo's real name; slug-only fallback for older cached
payloads), the current branch (a quick-pick to switch / create / delete), a
collapsible commit box behind a canWrite-gated Commit toggle in the branch bar
(default-collapsed so the change lists get the vertical space, staged-count pill on
the toggle, open/closed state persisted component-local like the section prefs,
Cmd/Ctrl+Enter in the textarea commits), and an always-visible tree/list
toggle — over a VS-Code-style pane stack
(the generic app/filesystem/ui/StackedSections.tsx): collapsible,
drag-resizable sections with always-visible fixed-height headers (uppercase title +
chevron + count + hover-revealed bulk stage / unstage / discard actions; trailing
collapsed headers pile at the bottom, and each expanded body scrolls itself). Resizing
is VS-Code-sash style: the section HEADER itself drags the boundary above it
(pointer travel past a 4px threshold = resize; a short press stays a click-to-toggle —
the toggle fires from pointerup, NOT the button's onClick, because pointer capture
retargets the post-drag click; keyboard activation detail===0 keeps the button path),
plus a thin dedicated separator strip above every section whose predecessor is expanded —
including a COLLAPSED one, so a collapsed pane shows a grabbable divider. A section's
header (and that strip) is a drag handle whenever the section directly above it is
expanded: an expanded section resizes the boundary, and a collapsed section OPENS +
sizes itself in the same gesture — so a collapsed pane is resizable without the
open-resize-close dance (owner fixes, 2026-07-14). Two layout invariants keep collapsed
headers where the user expects: expanded-body flex-grow factors are normalized so their
sum is always ≥ 1 (growScaleForSum) — a CSS quirk otherwise leaves a lone small weight
filling only a fraction of the pane and stranding collapsed headers mid-stack instead of at
the bottom — and re-expanding a section dragged to the min-body floor RESCUES its starved
weight to the default (MIN_USABLE_WEIGHT). Gestures starting on the hover actions never
drag (data-section-actions guard). Each change row is the VS-Code layout: a
shared file-type icon LEFT (the same pickFileIcon table as the Files tree, extracted
to app/filesystem/lib/fileIcon.tsx), the filename (always visible), the dimmed dir path
(truncates FIRST), and the status letter RIGHT — the per-row Stage/Unstage/Discard
cluster is a hover-revealed ABSOLUTE overlay that reserves no width. The THREE
sections: Changes (always renders, empty-state text at 0 — and INCLUDES untracked
files as a green U, VS-Code parity: untracked rows get Stage only, no Discard),
Staged Changes (vanishes when empty), and Graph (always present,
collapsed by default, defaultWeight: 2 so the first expand is usable without an
immediate resize) — the commit DAG renders INSIDE the panel; the former FileEditor
Graph tab ('git-graph' / gitGraphSource) is REMOVED. Section {collapsed, weight}
prefs persist to component-local localStorage (neuralis.filesystem.gitPanel.sections,
same guards as the sidebar width); collapsing a section unmounts its body, so the graph
only fetches while open. The graph itself is scroll-paged and virtualized:
200-commit pages append as an IntersectionObserver sentinel nears the bottom (skip +
the exact hasMore probe, de-duped by sha so a concurrent commit shifting the pages
produces a dropped duplicate, never a gap; a load that leaves the sentinel still
visible re-pages on its own — the observer only reports status changes — and a FAILED
fetch keeps the already-loaded pages behind an error strip instead of wiping the
graph), and rows render through
@tanstack/react-virtual as fixed-height per-row SVG slices — the pure
gitGraphLayout pre-splits every lane line and fork/merge curve at the row edges so
the DOM stays bounded on unbounded histories. It also live-refreshes off the
existing git-badge SSE with no new channel: the debounced badge path bumps a store
gitLogNonce for the FOCUSED repo only, and the graph re-fetches its head window and
REPLACES the loaded prefix (decorations move on checkout, so prepending new SHAs would
go stale); the graph's own header refresh button forces the same head-window path and is
THE graph refresh (the old Graph section-header remount button was dropped as a duplicate).
Graph rows are commit drill-ins: the rows render flush (no card
background) to fill the width; hovering a collapsed row floats a full-message
card (subject + the full commit body — %b, carried on GitGraphCommit.body, a
NUL-terminated logGraph record so a multi-line body is parsed safely, server-capped —
plus sha/author/relative-date), anchored to the graph ROOT and pointer-events-none so it
never steals the hover. Clicking a row expands it inline (the virtualizer switches that
row to dynamic measureElement height; collapsed rows keep the fixed-height fast path)
with the body + sha/author/relative-date and the commit's file list — lazy-fetched ONCE
per sha from GET /git/commit and cached client-side; a file row opens the commit↔parent
side-by-side diff, and a hover Open Changes action (an absolute overlay) opens the
multi-file stacked tab (GitCommitChangesView, one lazy diff card per file — the
PendingChangesPanel pattern). Outgoing indication (LOCAL refs — the network
reach is the remote-ops popover below): BLUE is reserved as the UNPUSHED language — commits not yet on the
upstream render a HOLLOW blue dot (OUTGOING_COLOR; the HEAD ring follows the dot color;
pushed dots keep their lane color, and the lane palette moved blue to its LAST slot so an
early fork lane can't collide with it), an expandable "Outgoing changes" divider row
(blue family) sits above the first outgoing run (expanding shows the
aggregated merge-base↔HEAD file list from GET /git/outgoing; its Open Changes opens the same
stacked tab over the range), ref chips are explicitly classed (HEAD/local = violet filled —
violet stays the local-ref language, origin/* = outline + cloud glyph, tags = amber), and the
branch bar shows ↑ahead ↓behind chips whenever /git/status carries an upstream — per-pair
arrow/number spans, the ahead pair blue (it counts exactly the outgoing commits), the behind
pair neutral. For a writer (canWrite) the chips ride inside the fetch/pull/push
trigger button (a sync glyph when there is no upstream); clicking it opens the remote-ops
popover — Pull / Push / Fetch when the source grants uri-policy
exec (canExec), otherwise a precise "grant exec" affordance. A read-only caller sees
the display-only chips. No upstream ⇒ the chips don't render (the trigger stays for a writer). Clicking a change row
opens a side-by-side diff (below). The focused repo is the store PAIR
(activeSource, activeRepoRel) — the source slug plus the nested checkout's dir name
('' = the source root — the multi-repo model below) — never a second slug concept; the graph derives its
source/repo props from it and remounts on the pair, so pages/decorations never leak
across checkouts. (Agent-facing git tools + the PendingInteraction approval ladder are a
SEPARATE later increment — the panel here is the HUMAN face.)
Source-level git field. Source rows now carry an optional
git?: { isRepo: boolean; repoName?: string; repoNames?: string[]; branch?: string;
canWrite?: boolean; containsRepos?: boolean }, present when the source's LOCAL root is a git
checkout — or, for a multi-repo source, stamped { isRepo: false, containsRepos: true, repoNames? } when a NON-repo
root contains nested depth-1 checkouts (that row carries NO branch/canWrite — per-repo state
lives on GET /git/repos). It drives a small GitBranch overlay + a repository-NAME hint on
the source header in BOTH the file tree (SourceBranchHeader) and the Sources config panel
(SourcesPanel), and populates the Git Control repo-switcher. repoName is the
repository's REAL name — the git remote origin's basename (repoNameFromOrigin,
mount-independent: a checkout mounted at /mounts/repo still reads Neuralis, not repo),
falling back to the checkout dir basename (repoNameFromRoot) for a no-remote repo / linked
worktree; a NAME only, never the URL/host/org (scrubHostPath discipline). The file tree
shows the repository NAME(s) beside the source name, not the branch (a source can hold several
repos; the branch lives in the Git Control panel): a root-repo source its repoName, a
containsRepos source its nested repoNames (the checkout DIRS, first two + +N). Those nested names are
per-path READ-gated server-side in BOTH summary builders (filterVisibleRepoNames,
resolvePermissions(<source>://<rel>/).read — "per-file uri-policy wherever file NAMES are
revealed"; a nested checkout the caller cannot read is absent). The /git/repos
route applies the SAME filterVisibleRepoNames per-path gate (tree and route are unified —
no name residual is left), keeping only the root rel:'' row unconditionally. repoName also
sits behind the two-part slug:// Name label on the panel header, switcher rows, and graph
header; absent (older cached payloads) ⇒ slug-only fallback. It is orthogonal to the sync
tone — a source is simultaneously synced (blue) AND a git repo — rendered alongside, never
replacing it. The field is CHEAP and bounded O(1)/source (a .git stat + an O(1) .git/HEAD
branch read via getSourceGitSummary, TTL-cached — never a whole-repo status on the list path)
and is stamped in the two source-summary builders: FilesystemService.list's
filteredSources (the tree) and GET /sources (the config panel), both AFTER the
source-level access filter so a non-member never infers a project's git repos. dirty
stays deferred off the list path; ahead/behind now ride the ON-DEMAND panel status
(below) — the source-ROW summary itself stays O(1) and tracking-free. Refreshing the
remote-tracking refs (fetch + credentials) is the remote-ops surface above.
Multi-repo sources — nested checkouts. A source does not have to BE a checkout to
be a git surface. The model: root .git wins — a source whose root IS a repo behaves
exactly as before (one repo, no scan); only when the root is NOT a repo does brain-core run
a depth-1 discovery (discoverSourceRepoRoots, src/git/sourceGitControl.ts): direct
children with their own .git, found by a STREAMING fs.opendir scan (≤ 500 dirents
examined — a 100k-child source never pays a full readdir; ≤ 25 repos reported,
name-sorted), zero git spawn, its own 30 s TTL (separate from the 2.5 s summary TTL —
discovery carries no branch payload and checkout creation is rare; deletion is covered by
the route's fresh re-check). This ONE discovery cache feeds three consumers — the source
summary's containsRepos + repoNames, the GET /git/repos route, and the watcher
expansion. Containment is enforced AT DISCOVERY, not only in the route
ladder (the load-bearing rule): hasDotGit uses stat, which FOLLOWS SYMLINKS, and two
consumers — the GET /git/repos listing and the watcher expansion below — consume the
discovered set WITHOUT ever passing the route ladder, so a symlinked child pointing at a
foreign repo would otherwise leak that repo's existence + branch and plant a host-side
watcher on a foreign .git. Every candidate must therefore realpath-resolve inside the
source root — the child dir AND its .git entry (a contained child whose .git is itself
a symlink onto a foreign repo is dropped the same way; a regular .git dir or a
linked-worktree .git FILE stays admitted). The containsRepos summary probe applies the
SAME predicate with a stop-at-first-find short-circuit — the ONLY new list-path cost,
fail-soft. Residual: a .git FILE whose gitdir: line points outside the root would need
a git spawn to detect (banned on this zero-spawn path) — a known, logged residual.
Every git/* route then accepts an optional repo selector (query for GET, JSON body
for POST) naming ONE discovered child checkout; a root-repo source rejects any repo.
The shared resolver validates it as belt AND braces — syntactic whitelist (one path
segment, 1–128 chars, no ./../.git/leading ~; a malformed value is a request-shape
400 BEFORE any access check, uniform whether or not the source exists) → membership in the
TTL-cached discovered set (no arbitrary-subdir probing, even syntactically valid names) →
a FRESH .git stat + realpath containment re-check on the nested root AND its .git
target (closes discovery-TTL races and case-aliasing) — and every failure past the belt
maps onto ONE constant 404, never revealing whether the child exists. With repo set the git ops re-root at the
nested checkout and every policy URI carries the <repo>/ prefix, composed ONCE in
the resolver (policyPrefix) so no gate site can drift: root-gated routes gate
<slug>://<repo>/ (trailing slash — the exact root form a <repo>/** path rule matches,
pinned by test), per-file routes gate <slug>://<repo>/<path>, and guardWritePaths
prefixes EVERY gated write path — the whole-file paths[] AND the hunk-extracted ones (a
+++ b/<path> patch header gates as <repo>/<path> through the nested form). Without
repo everything is byte-identical to the single-repo behavior.
GET /git/repos?source=<slug> is the repo-picker listing: { rel, name, branch?,
canWrite, canExec } rows — the realpath-FILTERED discovery, branch-enriched per nested
root, plus a per-repo canWrite (the two-plane gate evaluated at <slug>://<repo>/ —
root-level write capability is the wrong granularity in both directions: a <repo>/**
write-deny flips that row read-only while siblings stay writable, and a per-repo allow is
not hidden by a stricter root) and a per-repo canExec (the uri-policy exec
capability at the same granularity — whether the remote push/pull/fetch ops are enabled,
so the panel renders the popover or the grant-exec affordance without a click-then-403).
Names-only ⇒ per-path READ-gated (filterVisibleRepoNames, <slug>://<rel>/.read — keyed on the DIR, never the display name, which is the origin basename when the checkout has one, the same order as repoName): a nested repo whose name is read-denied is DROPPED from the listing — the SAME gate
the file-tree repo-name stamp applies, unified onto this route (resolveGitSource runs
with ignoreRepo, so the listing spans all nested repos of the source, each then
name-gated). The root repo (rel:'') is ALWAYS kept and source-root-gated (a <slug>:///
form would mis-gate it), so the ONLY residual is a fully read-denied ROOT-level repo still
listing its own name + branch (source-root name-level exposure, the same family as
/git/status). Rows carry rel/name only; the server-side nested root (a host path) is
never serialized.
The .git badge watchers expand per nested repo: SyncWatchService derives its
git-watch desired set from the FILTERED discovery — one .git watcher per nested checkout
of a containsRepos source (root-repo sources keep exactly one), so external commits in
ANY nested repo refresh the panel/graph live. The badge event stays SOURCE-scoped (an
in-source over-refresh is fine, never a leak), and cache invalidation is already exact
(the TTL caches key by resolved root, so a nested-root write invalidates only that repo).
git.canWrite — the write-UI visibility gate. canWrite is computed SERVER-side
per-caller as the SAME two-plane gate the write routes enforce: the caller HAS the
files.write FEATURE and the caller's RESOLVED uri-policy write on the source root
<slug>:// is allowed (resolveGitCanWrite, deny-by-default). The panel shows the
Stage/Unstage/Discard/Commit/branch-write affordances IFF git.canWrite === true — or, with
a focused NESTED repo, IFF that repo's /git/repos row carries canWrite: true (the
per-repo granularity above; the root row's flag is not consulted). This must NOT
be derived from SourceInfo.permissions.write (the source's DEFAULT permission block) — that
hid the owner's write UI on a default write:false source where the owner gains write via
byRole/byUser, and would also show buttons to a files.read-only role. The server route is
the deny-by-default floor regardless of this UI gate.
The git/* routes (src/routes/git.ts, file-level feature='files.read'):
- GET (read,
files.read):status?source=<slug>(whole-repo change-set + current branch, plusupstream/ahead/behindwhen the branch tracks an upstream — all three absent otherwise),branch?source=<slug>(local branch list, for the switcher),diff?source=<slug>&path=<repoRelPath>&(staged=<0|1> | commit=<sha> | base=<sha>&head=<sha>)(the unified diff for ONE file — three MUTUALLY-EXCLUSIVE modes: worktree WT↔Index / Index↔HEAD, commit↔FIRST-parent (a root commit diffs from the empty tree), or an explicit sha pair (the outgoing per-file form — the commit mode composes to it internally); 400 on any combination or half pair, every sha whitelisted before the engine),log?source=<slug>&maxCount=&skip=&ref=(ONE page of the commit DAG with parent SHAs for the SVG graph —maxCountis the page size, default 200, clamped to 1999 (one below the 2000 engine hard cap so the server'smaxCount+1probe always fits);skip(≤ 100 000) offsets the page from the tip — askipPAST that window answershasMore:false(the clamp is the END of the pageable window, never an infinite repeat of the deepest page); the response'shasMoreis otherwise an EXACT next-page probe, not a heuristic — it replacedtruncatedon this route only; plusoutgoing?: string[], the not-yet-pushed@{u}..HEADsha set, newest first, capped at 500, ABSENT — not[]— when the branch has no upstream),commit?source=<slug>&sha=<sha>(the files ONE commit changed as{path, status, renamedFrom?}rows, FIRST-PARENT semantics; an unknown sha is a typed{ok:false, code:'unavailable'}, never a fake empty list),outgoing?source=<slug>(the aggregated committed-but-not-pushed file list + the resolved{baseSha, headSha}pair; the server resolves BOTH refs ITSELF, the route takes NO refs, andbaseShaismerge-base(@{u}, HEAD)— never@{u}, which would phantom remote-only files as deletions on a diverged branch; no upstream →{baseSha:null, headSha, files:[]}fail-soft; LOCAL refs only, zero network — staleness is "since the last fetch", and the network reach is thefetch/pull/pushremote POSTs), andshow?source=<slug>&path=<repoRelPath>&rev=<sha|index>(the blob CONTENT of one file at a commit sha or the index — the diff-expander data source; blob-ONLY with typed refusals, below), andrepos?source=<slug>(the in-source repo-picker listing{rel, name, branch?, canWrite}; names-only, source-root read gate + per-nested-name filter — see the multi-repo model above). - POST (write, additionally
files.write-gated):stage({paths[]}whole-fileaddOR{hunk}git apply --cached),unstage({paths[]}reset -- <p>OR{hunk}reverse-apply),discard({paths[]}restore WT from index; typedrescue?returned),commit({message, amend?}),branch({action:'create'|'delete', name, startPoint?, force?}; delete returns a typedrescue?),checkout({ref}— PLAIN switch, no-f; git refuses a clobber → 409), andrestore({rescue:{kind,ref,name?}}— one-click UNDO:git stash apply/git branch <name> <ref>, re-snapshots first).
Every route resolves the source deny-by-default (non-accessible → 404, never a leak) and
returns repo-relative paths only (never host / os_uri paths); git op-error messages are
host-path-scrubbed. Responses are size-capped for MEMORY/response bounding, with a truncated
flag on /git/status + /git/branch + /git/diff (/git/log instead PAGES — skip +
hasMore, above) — a TWO-cap split: the human-panel /git/diff cap is 2 MB (MAX_DIFF_BYTES_UI, whole-line
truncation; the confidentiality boundary for these routes is the per-path read policy, not the
cap), while the boundToolResult 20 KB floor stays the MODEL-facing tool-result invariant for
the planned hosted git tools. A diff past the engine's ~4 MB output accumulation bound is KILLED
inside GitRunner (there is no serveable prefix), so /git/diff returns a typed
{ok:false, code:'too_large'} refusal on a 200 (the /git/show dialect) instead of an empty
diff that would render as a false "no changes" for a file the panel lists as Modified.
diff (every mode — historical content is content)/show/stage/unstage/discard gate
the uri-policy check on the SPECIFIC file (<slug>://<path>) so a per-path deny is
honored, where status/branch/log/GET commit/outgoing/POST commit/checkout gate
the checkout root (the GET commit/outgoing file lists are NAMES-only — the names-only dialect,
consistent with /git/status). With the multi-repo repo selector BOTH forms carry the <repo>/
policy prefix (root-gated → <slug>://<repo>/, per-file → <slug>://<repo>/<path> — see
the multi-repo model above). Names-only residual (documented, not yet closed):
a per-path read-DENIED file's NAME — including historical names — appears in the root-gated
listings (status, the commit/outgoing file lists, log decorations) while its CONTENT
403s on the per-path-gated diff/show; closing the names must close
status+commit+log-decorations together. Diff direction:
a Changes row is WT↔Index (staged=0), a Staged Changes row is Index↔HEAD (staged=1);
an UNTRACKED (brand-new) file has no index entry so its WT↔Index diff is empty — it is rendered
fully-added via git diff --no-index -- /dev/null <path> so the whole file shows as added.
Whole-repo status/log are O(repo) so they fire ON DEMAND only (panel open / repo select /
graph open), TTL-cached, for the SELECTED repo — never the fs_list hot path. The panel
readers split their inactivity timeouts two-tier: status/branches keep 5 s, while the HEAVY
readers (diff / log / show / commit files / outgoing) get 15 s so a 2 MB diff or a
2000-commit log on a slow volume isn't killed early (both tiers cap silence, not wall-clock —
see the engine section above).
GIT_OPTIONAL_LOCKS=0 (set in GitRunner.sanitizedGitEnv()) makes read queries truly
index-read-only, closing the badge → re-list → git status → index-rewrite watcher feedback
loop (see the live-badge note below).
GET /git/show — blob-only, refuse-don't-truncate. The diff-expander data source
returns the CONTENT of one file at a rev. rev is whitelisted at the route BEFORE the
engine (the literal index or a 7–40 lowercase-hex commit sha; everything else is a 400 —
defense-in-depth ahead of the engine's own ref guard). It never truncates — refusals come
back typed (ok:false) on a 200, fail-soft, so the client degrades expanders instead of
erroring: not_a_blob (a directory target would print a tree LISTING — an embedded
name-listing side door around the per-path read policy), too_large (> 2 MB, size-checked
via cat-file -s BEFORE buffering the content), binary (NUL in the head), unavailable
(non-repo / path missing at the rev). The result is deliberately UNcached — a stale blob
would corrupt expanded diff context.
Write model — human consent + SERVER unconditional auto-snapshot. The write POST
routes are the HUMAN face of the git engine: the caller's authenticated session IS the consent
(files.write feature + uri-policy write), NOT the PendingInteraction agent-approval
guard (that is the additive AGENT face, a later increment; both faces drive the same GitRunner
mutating methods). Before ANY destructive op (discard, branch delete) the SERVER
UNCONDITIONALLY snapshot()s a reflog-recoverable rescue point (a refs/nrs-rescue/<label>
anchor + a git stash create of working-tree+index content) — never gated on a client
confirmed flag (the UI confirm dialog is UX-only; a client can POST /git/discard
directly). The op returns a TYPED, CONDITIONAL rescue carrier — {kind:'stash', ref} on a
dirty-tree discard, {kind:'branch', ref, name} on a resolved-tip branch delete (absent when
there is nothing to recover) — which drives the one-click UNDO: POST /git/restore
{rescue} re-snapshots first (a restore is itself a mutation) then git stash apply /
git branch <name> <ref> (non-force → 409 on an existing name), same files.write +
uri-policy write floor, no PendingInteraction. The panel's rescue banner grows an Undo
button beside Dismiss. Every successful write publishes the IN-APP changeType:'badge'
event with the actor's REAL (userId, projectId, agentId) via publishInAppGitBadge (the
ONLY in-app publish path — an empty userId would broaden delivery) so the panel + badges
update live through the ordinary user/agent-equality SSE filter.
Side-by-side diff-on-click — full content + hidden-region expanders. Clicking a
change-set row (or a file inside an expanded commit / the outgoing divider) opens a diff as
a FileEditor view (tab id git-diff, driven by the gitDiffTarget store descriptor —
a discriminated union mirroring the route's three modes: {source, path, staged} working
tree, {source, path, commit} commit↔first-parent, or {source, path, base, head} the
explicit outgoing pair — because a bare activeWorkspaceTab id can't carry the target).
GitDiffView is a THIN adapter: the fetch-and-render core is the prop-driven
GitFileDiff (app/filesystem/ui/GitFileDiff.tsx, mode vocabulary in the pure
gitFileDiffMode.ts), shared with the multi-file card view below so a diff fix lands in
both surfaces at once. It
fetches GET /git/diff, parses the unified diff with react-diff-view (MIT, no network
— it only parses the diff string), and renders a split (side-by-side) view of the WHOLE
change: the diff is size-capped by the SERVER alone (the 2 MB MAX_DIFF_BYTES_UI route cap
— the ONE cap owner; the client keeps no line/byte re-cap and only renders the server
truncated flag as a banner). Unchanged regions between hunks collapse into clickable
"⋯ N hidden lines" expander rows (per-gap, plus an "Expand all" header button); the
omit-gutter stripe is transparent — the expander rows ARE the affordance. Expansion lazily
fetches the NEW-side full source on first click (unchanged gaps are identical on both
sides), routed per mode: an unstaged diff reads the working-tree file via the read API, a
staged diff fetches the index blob via GET /git/show?rev=index, a commit diff fetches
rev=<sha> and a range diff rev=<head> (never <sha>^ — the show route rejects it); the
result is cached per (source, path, rev)
and any refusal (not_a_blob/too_large/binary/missing file) degrades the expanders to
static "N hidden lines" text — fail-soft, never an error banner. Staging renders in the
WORKING variant ONLY — a commit/range (historical) diff never shows stage buttons; the gate
is the mode variant, never staged truthiness. Hunk staging rides a
DUAL-MODEL (app/filesystem/ui/gitDiffExpand.ts): the raw server diff is parsed ONCE into
ORIGINAL hunks — the sole staging source (the same raw text feeds gitDiffPatch) — while
expansion produces a separate RENDER-ONLY hunk list (expandFromRawCode MERGES and REWRITES
hunks, so rendered indexes stop mapping 1:1 onto the originals); stage/unstage buttons
anchor by ORIGINAL-hunk identity (old-range containment — a rendered hunk that swallowed N
originals renders N buttons), so the staged payload is identical before and after any
expansion. On a server-TRUNCATED diff both staging AND the expanders are disabled — a cut
final hunk would apply wrong, and gaps computed from the VISIBLE hunk headers would
fabricate "unchanged" rows over regions the dropped hunks own. The server's typed
too_large refusal (>4 MB engine-killed diff) renders as a dedicated too-large state, never
the "No … changes" empty state; the flag states reset on every target switch so a failed
fetch can't inherit the previous target's banner. FileEditor's showFilePane guard
EXCLUDES the git views (&& !showGitDiff && !showGitCommit) — otherwise the file-content
layer renders ON TOP and blocks the pane (the same overlay trap the Pending view hit).
Multi-file "Open Changes" tab (GitCommitChangesView). The second git
FileEditor view (tab id git-commit, descriptor gitCommitTarget — {kind:'commit', sha,
subject} from a graph row's Open Changes action, or {kind:'outgoing', base, head} from
the outgoing divider's) renders one commit's — or the whole outgoing range's — change-set
as a vertical stack of collapsible
per-file diff cards (the PendingChangesPanel pattern): the file list comes from
GET /git/commit / GET /git/outgoing, and each card mounts the SAME GitFileDiff (card
variant) on expand only — conditional render, so a collapsed card fetches nothing.
Every change-set starts logically expanded; only visible expanded cards mount and
fetch through the shared content lanes (three at a time, answers cached per scroller), including histories above 200 files. A per-path read-DENIED file renders a fail-soft LOCK row inside its
own card (the per-file diff gate is per-file; the names-only list is root-gated — the names-only
dialect above), never a view-level error. Clearing the descriptor (pill close) falls the
active tab back to file — the store centralises the fallback so a git-commit tab id is
never stranded without a target — and BOTH git targets reset on an agent/project switch.
Live changeType:'badge' SSE channel + .git/HEAD watcher. The previously-dead
changeType:'badge' filesystem-event channel is now LIT so git badges + the panel +
the branch indicator update live. A per-checkout GitWatchRegistry
(src/git/GitWatchRegistry.ts) — reconciled over the DISCOVERED checkout set:
one watcher per root-repo source, one per nested repo of a multi-repo source (see the
multi-repo model above) — watches each checkout's .git/ root for external git ops
(a terminal git commit/add/checkout the sync watcher can't see) and publishes a
source-scoped badge on the per-project event bus (git-state only — it NEVER feeds the
vector index / sync pipeline; the .git/-sync-ignore floor is unchanged). It acquires the
.git directory as a SEPARATE FsWatchHub root from the source root (a second
ignored predicate on a shared root is inherited, not applied — so the source watcher's
.git-ignore would otherwise blind it), reacts NARROWLY to HEAD/packed-refs/refs/**/
index, and prunes the high-churn objects/+logs/ subtrees; it is reconciled + torn
down by SyncWatchService's per-source lifecycle. On the client the
changeType:'badge' hook branch (useFilesystemSSE.ts) is an EARLY short-circuit (before
incrementFolderBadges/upsertFileNodeFromEvent) that debounces + re-lists the source
subtree (recomputing FileNode.git) and refetches the panel status + SourceInfo.git.
Changes-section live stream. The badge watcher above only fires on .git
ref/index moves — so Staged streams (staging writes .git/index) and the graph
streams (commits write .git/HEAD), but a plain WORKING-TREE edit (a new untracked file, an
unstaged modification) touches neither and emitted NO badge, leaving the Changes section
stale until a manual Refresh. Now useFilesystemSSE also refetches the FOCUSED repo's
/git/status from the two working-tree event branches (the external:true reconcile branch +
the per-file created/updated/deleted/reverted switch), focused-source-gated + per-source
trailing-debounced (gitStatusSeq drops out-of-order responses) — so Changes streams too.
Badge delivery is an AUTHORIZATION decision, not a filter tweak — the external watcher's
badge carries NO acting user, so subscribeFilesystemEventsImpl has a dedicated
changeType:'badge' branch that authorizes delivery by SOURCE-ACCESS (project gate +
payload.source ∈ the subscriber's accessible-source set, precomputed once at
subscription), deny-by-default, NEVER a blanket project broadcast — a user/agent-scoped
source's badge stays scoped to its owner. IN-APP badges (published by the write routes via
publishInAppGitBadge with the actor's REAL identity) skip that branch and reach the actor
through the ordinary user/agent-equality filter, unchanged.
Public Surface
Lifecycle exports (src/lifecycle.ts):
init,start,onBootstrapComplete,drain,stop,api,onCredentialChanged,health,onPrincipalRevoked,provisionProject(usecases/provisionProject.ts— a NEW project's declaredseed:'auto'sources, written complete through the same create path as the boot repair; see Declared sources),deprovisionProject(usecases/deprovisionProject.ts— on a PERMANENT project delete, ALL of the project's vector points leave the shared index in ONE boundeddeleteByFilter({ must: [{ field: 'projectId', equals: id }] })— chunks + folder-ancestor points covered by the same keyword-index filter. MUST-HAVE: a failed delete throws, and so does a degraded boot where Qdrant is configured but the in-memory index is wired (a delete there would leave every real point in Qdrant); the host then stops the purge before any file is removed. It runs BEFORE the fs tree is removed, so resolving the brain infra cannot re-materialize the project dir).initstate exposesservices["package-source-roots"]for kernel registration; itslistPackageSourceRootsdelegate is the same function as the direct API method, and itssubscribePackageSourceRoots(listener)callslistener(projectId)after every committed source-config write of the app root (SourceConfigMutation.onChanged— fired outside the write lock, a no-op write never fires, a throwing listener is logged and never reaches the writer), so the host re-derives that project's flagged package roots at once.BrainCoreApi—getProjectServices,getProjectInfra,getFilesystem,upsertSourceConfig,listShellSourceRoots(projectId)(every shell-capable source with its persisted permissions; feeds agent-core's shell uri-policy gate and the terminal's host-tab resolver; NOT access-filtered — consumers evaluate the policy against the caller's ownSessionContext. Rows carry an explicitplane:containersources contribute theircontainerRoot, resolved by the canonicalresolveLocalRoot; host-plane sources contribute a rawhostRootand an intentionally EMPTYcontainerRoot— no container path exists for them, and the empty value is load-bearing, because the consumer's container-plane filter drops rootless entries so a host source can never leak into a container-plane lookup even ifplanewere ignored. The plane is derived from the connector KIND, never from the slug string — an ordinary local source may legitimately be namedhost-notes. Kinds that host no shell at all (brain vectors, webtop) contribute nothing), andlistSourceRuntimeSummaries(projectId, { userId, agentId, role?, grantedFeatures? })(the READ sibling oflistShellSourceRoots, feeding agent-core's<runtime_stack>source rendering — all connector kinds, access-filtered (host-plane rows carrycontainerDir: null— their root is a path in the OPERATOR's filesystem with no container equivalent, and the local-shaped derivation would otherwise print a container path that does not exist, baked into every agent's cached system prompt) viacanAccessScope→files.readOthers/deny-by-default, each row carrying connector,os_uri/container_dir, the caller-effective uri-policy,description, and the cached index snapshot; reads persisted source-config JSON ONLY — no live walk / no vector count — so it is safe to call once per stream. Agent-core renders the stable structure in the cached<runtime_stack>prefix and the volatile counts + on/off in<workspace_live>. Disabled sources are INCLUDED, markedenabledEffective:false).listPackageSourceRoots(projectId)(enumerate every project-associated source as a runtime package-source candidate{ slug, uriRoot, containerRoot, scope, recognizesPackages, enabled, kind }; identity-free likelistShellSourceRoots, ALL candidates returned so the host applies the floor — project-scope + flagged + enabled + local; the host'sprojectPackagesconsumes it to scan + hot-reload flagged source dirs alongside_packages/). Filesystem events reach the/brain-core/eventsSSE route and the discoveredfilesystemchannel of the host/api/eventshub through ONE authorization adapter: it takes the real session, authorizes EACH requested agent deny-by-default (a foreign agent yields zero events, never an existence-confirming error), and filters events by user/project/types and the authorized agent or source.
Cross-package writes go through FilesystemService itself, and its permission check is
the ONLY gate on that path. getProjectServices(projectId).filesystem hands back the live
service, so search / read / list / write / writeUpload are all reachable from another
package via getPackageApi() — and such a call skips the HTTP route layer entirely, meaning
the route-level assertUriCapability never runs for it. Every one of those methods opens with
#checkPermission(ctx, uri, …); that is what stands between a cross-package caller and the
filesystem. Two consequences for anyone mirroring the request types into another package
(agent-core does, in contributions/brainApiTypes.ts — it cannot import brain-core, which
would be a cycle, so that mirror is pinned instead by a compile-time guard against brain-core's
shipped .d.ts): the mirrored type must expose neither
review nor allowEmpty — both are INTERNAL-only governance escapes deliberately absent from
every tool schema and route body — and the caller must gate on its own feature (files.write)
before it gets here, because #checkPermission answers "may this URI be written", not "may
this caller write at all".
The reverse direction is a real type edge, not a mirror. brain-core reaches agent-core in
two places — routes/git.ts for the caller's own git remote binding and the
lifecycle.ts verifyAgent closure — and each imports the canonical AgentCoreApi
(import type, so no runtime edge is added to a package agent-core does not load). The ?.
stays on the API OBJECT, because getPackageApi answers null when agent-core is not loaded
and every site keeps its own "unavailable" arm; it is GONE from the members, which are required
on AgentCoreApi. A member agent-core renames is now a brain-core compile error instead of a
silent undefined. Type-only use means no brain-core declaration surfaces an agent-core
type — dist/**/*.d.ts carries no @neuralis/agent-core reference.
Source-package discovery (listSourcePackages)
FilesystemService.listSourcePackages(ctx) (reached via getProjectServices(projectId).filesystem)
lists source-contributed packages from the vector index — the discovery half of the
source-package pipeline (agent-core consumes it per stream). The service is a thin
delegate: the pass itself is src/usecases/listSourcePackages.ts, and the raw inventory it
shares is src/usecases/sourcePackageInventory.ts. Four invariants:
No connector I/O and no OS walk. The pass is index-only: pass 1 scrolls the bounded structural trigger categories (
skill,instruction,rule,agent,package) project-wide; pass 2 fetchesdocsfor every detected package root and the skill-bundle siblings of every detected skill through the keyword-indexedfolder_ancestorsfield. Content hydration is the caller's job (per-stream, after gates).Pass 2 is batched, not per root: one paged
folder_ancestors matchAny(active roots)query answers every root at once, and a root whose raw cap is full leaves the active set so later pages stop carrying it. Three properties of that shape are load-bearing — the raw cap stays per root and before the privacy/policy filters, exactly where the per-root query used to stop paging; a point under two NESTED roots is offered to BOTH (the nearest-root rule then decides); and the cursor is a point ID scanned in ID order, which is the only reason narrowing the filter between pages is safe.Every pass projects its payloads (
uri,category,agent_owner,manifest_usable,frontmatter,os_uri,content_hash, plusfolder_ancestorsin pass 2) — nevercontent. A manifest's display name comes from its own bounded batch, for the manifests the caller may actually read. Measured on 70 skills across 8 roots: 79 backend round-trips → 5, and 1.44 MB of payload → 88 KB.Category-driven grouping. The sync indexer assigns the trigger categories (the ONE shared
categorizeContributionPathtable in@neuralis/package-system/source, kept only for files the ONE sharedadmitsContributionrule admits — a filename-identity form, or frontmatterid/name; an undeclaredagents/references/*.mdis a plainfile); the query groups the trigger nodes with the ONE shareddetectPackageRootsrules module (usable-manifest anchor → marker dot-dir (.claude,.cursor, …) → category-dirs root → bare-skill dir → RESTRICTED implicit source-root). Grouping is query-time and in-memory — layout changes self-heal on the next call, no payload migration.All caller scoping is post-filter. The vector filter is
projectId-only — index points carry the SYNCING identity, auserId equalsfilter would under-report. The caller is scoped by:#listAccessibleSources(enabled + scope + source-level read), the source's persisted uri-policy evaluated per package root AND per file (denied ⇒ absent — member non-leak extends to file and bundle-member names), and theagent_ownerprivacy marker (data://agent-core/<agentId>/…subtrees belong to that agent only). A source without a persisted config is skipped (fail closed). An absentctx.rolereturns[](weakest interpretation — never a'member'default).The shared inventory holds RAW index points, never a caller's answer. Root detection runs AFTER the
agent_ownerfilter and the accessible-source set, so the roots, the raw caps and the package tree a privileged caller produces are not the ones a member produces — freezing any of that into a shared entry would hand one caller's view to another. Every pass therefore re-reads the persisted source config and re-evaluates source enable,enabled:'auto', scope, role/priority and every uri-policy row; a revocation bites on the NEXT request, including one issued while a scan is in flight. Discovery deliberately bypasses thebrainAccessibleCacheTtlMswindow that the hot list/read paths use, in both directions — it neither reads that cache nor seeds it.
