Developer Reference - HenriGrimm/Minnow GitHub Wiki
Authoritative technical reference for the codebase. For orientation, start with contributor/architecture.md. For setup and commands, see contributor/setup-from-source.md and contributor/commands.md. UI key bindings: manual/reference/keyboard-shortcuts.md. Product overview: README.md.
Agent file handoffs: present_files is a read-only file tool for saved project files and generated deliverables. server/tools/present-files.js validates file paths under the request's filesystem scope and returns a versioned JSON envelope with the source workspace and file metadata. The envelope persists in ordinary tool-result content; no new session schema or file-byte copies are needed. src/chat/presented-files.ts reconstructs handoffs only from correlated present_files results, and src/ui/presented-file-cards.ts renders compact cards outside the work disclosure in compact and full chat views, including live turns. Open uses Code with the original workspace; browser viewer tabs retain their file scope. Download uses authenticated /api/preview/file/*?download=1, streaming original bytes without HTML injection and retaining workspace/path checks. Download permits build-output paths that executable preview blocks. References point to current disk files. Tests cover SQLite close/reopen, history repaint, live/full/compact display, path boundaries, and raw downloads.
Local model shutdown: llama.cpp serves use the native router-child input protocol (LLAMA_SERVER_ROUTER_PORT=0, normal child mode) with a dedicated stdin pipe. Eject and app shutdown send cmd_router_to_child:exit and wait for process exit before reporting stopped. A loading serve gets up to 60 seconds to consume the queued command after model initialization; ready serves get 10 seconds for cleanup, then fall back to the existing process-tree termination. stdin stays open because llama.cpp handles EOF as forced termination. Concurrent ejects share one serve stop and one run stop. This mitigates abrupt Windows GPU-process teardown; it does not establish the cause of system-wide hangs. Tests: test/terminal/graceful-stop.test.mjs and test/models/serve-async.test.mjs.
Model runtime logs: Hosted llama.cpp defaults to --verbose so generated-token traces reach Local Server; explicit extra-argument verbosity flags win. server/models/serve-logs.js replays the complete recorded file in bounded chunks before following appended output, preserving UTF-8 across chunk boundaries. The runtime pane retains all messages and partial lines, appending new lines without rebuilding completed DOM rows. Copy includes all received output; Clear view resets the displayed buffer. Other runtimes expose their existing process output.
Source Control Actions and Releases: The compatible checks navigation ID renders Actions (Runs, Workflows, Commands); Releases is the eighth section. Both use a responsive list and inspector with shared header/row helpers in src/ui/scc-action-layout.ts. Run jobs collapse completed steps and retain expansion and opened logs during polling. Releases auto-selects the newest loaded release, renders sanitized Markdown notes, and exposes editing separately from the overview and assets. Polling preserves unsaved release and command edits. server/git/action-service.js shares operations between /api/git and seven action_* / release_* agent tools. Remote operations target the discovered GitHub host/repository through gh; repository-root API requests omit the trailing slash GitHub rejects, including the release permission lookup. Local commands read version-1 .minnow/actions.json plus root package scripts. Local workflows require user-installed act and Docker and accept declared Ubuntu container jobs. Runs capture worktree/branch/SHA/dirty state, use an IPC worker to own process trees, retain capped logs and metadata for seven days under runs/actions, and hold a checkout/removal lock. Local secrets are encrypted under action-secrets, included only in credential backups; run logs remain excluded. Execution and release mutations are blocked in Plan mode on client and server. Coverage: test/server/actions-*.test.*, test/server/action-common.test.mjs, and test/ui/scc-actions.test.mts.
Release draft writer: Editable drafts open directly in the release editor. Write draft reads full commit subjects and bodies from the last published release (including prereleases), or an overridden base tag, through the pinned remote draft target. releaseDraftContext is a read-only /api/git action in server/git/release-draft-context.js; it resolves annotated tags, pages complete comparisons, and reads full target history for initial releases. src/ui/release-draft-client.ts uses transient shared utility generations (default-model fallback), loads library models, batches large histories, and consolidates summaries without dropping source text. Notes and the sanitized preview change only after successful completion; Undo restores preceding edits, and Save changes remains explicit. Cancel, base changes, selection changes, workspace changes, and disposal discard late output. No chat, agent tool, or persistent suggestion store is added. Tests: test/server/release-draft-context.test.mjs and test/ui/release-draft-*.test.mts.
Release workflow shortcut: Actions β Workflows exposes Use for releases for active, manually dispatchable GitHub workflows. src/state/release-workflow.ts stores the workflow id/path/name in localStorage under minnow.scc.releaseWorkflow.<hostname/owner/repo> (lowercase identity), shared across worktrees for that remote. workflowList returns the same host-qualified repo identity as releases and remote options. The Releases toolbar opens scc-release-workflow.ts, an anchored, dismissible popover with the shared remote dispatch form (scc-workflow-form.ts): remote branch/tag, ref-specific inputs, validation, dispatch acceptance, and an explicit link to Runs. Request guards discard late loads after ref/workspace changes or dismissal; accepted dispatches disable repeat submission within the current form. Unmapped repositories offer workflow setup, and unavailable mappings show errors with a change-workflow action.
Actions run loading: The GitHub run inspector paints the selected run's summary immediately with a loading wheel while jobs arrive. Its view-scoped cache retains up to 50 run details for immediate revisits, refreshes them in the background, and shares pending requests between selection and polling. Workspace changes clear the cache; request guards prevent late responses from replacing the selected run. Loading status is announced to assistive technology and the wheel respects reduced motion.
Also useful: manual/reference/configuration.md (storage layout), guides/release-e2e-testing.md (manual pre-release checklist), DESIGN.md (visual tokens), AGENTS.md (agent quick reference), plans/ (in-flight feature plans), archive/ (shipped one-off plans).
Settings navigation: Six disclosure groups in SETTINGS_NAV_GROUPS organize General, Code & workspace, AI & agents, Extensions, Data & privacy, and About & troubleshooting. The internal category containers and persisted keys remain compatible. General is split into general, terminal, data, and updates; old field links resolve via resolveSettingsSectionNavigation. Only the active page (or integration hub) mounts. Search uses destination breadcrumbs and opens only the requested disclosure, without filtering the current form while typing. Board testing and Capability matrix remain in source but are excluded from navigation and the field catalog; old links open diagnostics. Notification preferences expose default-aware resets and a modified-only filter.
Default project location: General β New projects auto-saves through PUT /api/workspace/project-location into config.json β workspace.newProjectParent, using serialized config updates. The endpoint validates an existing absolute directory; an empty path restores ~/Projects. GET /api/workspace supplies the effective default to the project wizard. Choose workspace links to general.projectLocation; its Change location action stays a per-project override. Saving does not switch the active workspace or move projects. Configured locations are not created during workspace reads, so an unavailable external drive does not block workspace selection.
Create From Remote: The workspace picker reuses its inline create form for HTTPS and SSH clone links, suggests an editable folder name, and uses the configured project location with a per-project override. POST /api/workspace/clone (parentPath, name, remoteUrl) invokes Git without a shell, with bounded output, a five-minute timeout, and noninteractive authentication through the device's Git credentials or SSH keys. server/workspace/clone.js exclusively creates the destination, rejects existing folders, and removes its new folder after clone failure. Shared link/name validation lives in src/lib/remote-repository.mjs. Cloning does not switch server workspace state; the picker activates the result through executeWorkspaceSwitch, retaining the cloned path for an opening retry. This clones files and Git history; dependency installation remains part of the project's build setup.
Interface icons: src/styles/icons.css loads locally bundled Flaticon Uicons
4.0.0 Regular Rounded and Solid Rounded styles from src/styles/vendor/uicons/.
The official versioned CDN supplies these assets because the npm package still
publishes 3.3.1. WOFF2 fonts ship locally for offline use; npm run check:icons
validates src/ui/icon.ts against the bundled CSS. Reef uses fi-rr-coral-reef.
Reef is a lazy app, ungated (released) for testing. It is not a composer mode.
Shared records and client API helpers live in src/reef/; src/ui/reef-page.ts
owns separate app-store (#/app/reef) and creation (#/app/reef/new) screens;
the reserved new route never loads an app record. Creation is a three-step
setup (idea, name, look; src/reef/setup.ts) whose answers persist while
navigating within Reef. The name step suggests playful names from the idea's
subject words; the look step offers six vibe presets (swatches from the
--mn-label-* tokens), Match Minnow (the live theme tokens, resolved to hex at
submit) and Make your own (a background/main/highlight palette set with colour
pickers), plus free text. Build reads the top-bar default model and
swaps the form for a live orb seeded with the answers until the app opens.
POST /api/reef/apps takes { prompt, name?, look? }; the app stores look, and
initialBuildRequest folds the name and look into the first run's prompt. The workspace model control is the composer's own chip
(mountAppModelTrigger, an app-bound variant in composer-model-trigger.ts): the same menu, filter bar
and search over the full #modelSelect catalog, CLI agents included;
PUT /api/reef/apps/:id/model validates and persists the app's provider/model and
publishes a model event. Each fresh agent reads the latest app binding, so a change
applies to the next stage, continuation, repair, retry or reply; an active response
finishes on its original model. The global default and Code chat stay unchanged.
App UUID routes open a workspace
with Build/Preview views. Build is a stage built around one ring
(src/ui/reef-orb.ts, canvas): each snapshot's newly streamed text and log
output is written into the rim at a rotating head and spreads as a damped wave,
and tool calls land as splashes. It animates at a fixed 30 Hz only while a run is
busy, pauses when hidden or render-idle, and draws one still frame otherwise.
describeReefActivity (src/reef/activity.ts) turns the run into the ring's
plain-language stage, headline and caption; raw errors never reach the headline.
A six-step track, the files touched and Retry (inside the ring) sit on the stage.
The agent transcript and build log are backstage disclosures, closed by default,
and the transcript only renders while open. The conversation pane is hidden behind
a Chat toggle (minnow.reef.chatOpen, per viewer). Preview opens the verified app
automatically through src/ui/reef-preview.ts, using an explicit Electron guest
tab or a separate-origin iframe so controls accept clicks, typing and scrolling.
Loading and launch/navigation failures appear in the preview with Run app as the
retry action; leaving the page disposes its guest and pending frame load.
Preview and export require a verified release; failed
revisions retain access to the previous release. The app store and Build view
offer Retry build for failures and Resume build for cancelled/interrupted runs;
builds recover on their own, so these are for a stop only the user can clear.
The runs endpoint accepts { action: 'resume' | 'reset-phase' | 'reset-build', runId };
recovery targets only the latest stopped run. Retry requeues that same run and
retains its workspace, final plan, logs and agent chats. Reset current phase
restores the source tree saved at phase entry and reruns that phase; Reset whole
build enqueues a fresh run for the same request from the last verified repository.
Neither reset removes the previous release or build history. reef-chat.ts embeds
the shared message renderer and composer without selecting a Code chat or
changing Code's workspace. Only the explicit Open in Code action switches it.
src/ui/reef-export.ts mounts the build/export panel with labeled platform,
architecture and method controls beside export history. Live snapshots update
status, downloads and logs in place, preserving selected options and open logs;
the fallback poll also stays active while an export is queued or building.
The authenticated /api/reef middleware exposes apps, runs, cancellation,
replayable SSE events, verified previews, launch/stop, chats and exports.
server/reef/supervisor.js owns a persisted FIFO queue with one active build,
a three-hour backstop deadline, process-tree cancellation and automatic resume:
on start it requeues any unfinished run (and one Minnow's own shutdown
interrupted, hostStopped) as recovery: 'resume'. A run records ownerPid;
start leaves a run alone while another live Minnow on the same home owns it, and
after MAX_AUTO_RESUMES (3) consecutive restarts it waits for Resume instead.
pipeline.js scaffolds a versioned Vite/TypeScript project, plans through the
shared headless engine, implements in an isolated local Git worktree, installs
with lifecycle scripts disabled, runs host-controlled type/test/build and
Chromium scenario checks, then commits and promotes a release. A build runs
unattended: buildApp retries any failure from its checkpoint unless
needsUser() says only the user can clear it (spent credit or quota, a rejected
sign-in, a missing model, missing Git, uncommitted repository changes, a lost
workspace); those stop at once with a plain run.stopReason. Verification
failures get MAX_REPAIRS (4) repairs. Anything else, including network trouble
during install or checks, retries the same phase with backoff (15 s doubling to
5 min, MAX_RETRIES 8) while run.retry drives a "Trying again shortly" state.
A per-attempt watchdog aborts and retries an attempt that produces no output for
STALL_MS (15 min). Retry, continuation and repair counts live in the
checkpoint, so an automatic resume cannot reset them; a phase advance refills
retries and continuations but never repairs. Every agent attempt is a separate persisted
normal chat; plans and diagnostics are explicitly passed between attempts.
Each run persists runs/<id>/checkpoint.json with its next/current phase,
plan, repair diagnostics, phase-entry Git tree and promotion commit. Completed
phases are skipped on recovery; incomplete Builder work is continued in a fresh
chat against the same files. Installation and checking safely rerun the stopped
phase's host checks. Promotion resumes with its saved commit without rebuilding.
Legacy runs reuse their existing worktree and plan.txt when available; missing
workspaces require Reset whole build, and phase reset needs a saved checkpoint.
Reef explicitly launches the Planner and Builder work agents in different
headless processes and fresh chat/context windows. Only the final plan and
verification diagnostics are passed to the Builder, never the Planner transcript.
Builder context-budget exhaustion allows MAX_CONTINUATIONS (6) continuations in
fresh Builder chats, inspecting the files already written in the same worktree;
after that the saved work goes to verification, whose failures direct a repair.
Structured result errors take precedence over command stdout dumps.
Reef agents use the supervisor's cancellation signal, the stall watchdog and the
build deadline
instead of the CLI's ten-minute/100-round ceilings or a second process timer.
Ordinary tool failures return to the shared chat runner for model recovery;
context exhaustion continues from saved files in a fresh Builder chat. Process
timeouts retain their cause, and streamed agent JSON is excluded from failure
diagnostics when no structured result is available. CLI and Scheduler limits
remain bounded independently.
Reef, CLI and Scheduler context limits use the same saved work-agent/global
context policy and compaction settings as regular chat, without a headless 32K
fallback. GET /api/providers/:id/context-window?modelId=β¦ exposes the shared
host model-window resolver: a running local serve's per-slot window wins, then
provider catalog/probe metadata and known-model resolution. Unknown capacity
stays unknown rather than introducing a separate headless cap.
Headless opening transcript rows retain their store IDs so deterministic
compaction can fold them and recall_history can read the original content.
Headless --stream-json forwards runner events as JSON lines; --stream remains
the readable CLI format. Reef folds events into bounded agentSessions (512 KB
of display data per run; full chats persist separately), batches activity and
process-log persistence every 200ms, and replays notifications over SSE through
the shared WebSocket transport. Backpressure waits for drain instead of closing
the connection. The Build view reuses Markdown, Thoughts and tool-call components
with stable rows and disclosures for each Planner/Builder context. It retains
legacy agentLog display, separates command output, and shows elapsed time,
last-activity age and reconnect status. A five-second snapshot refresh catches
missed updates without app navigation. These updates never switch the Code chat
or its workspace.
Read-only app conversations use the same runner and can enqueue explicit
revision requests. Failed revisions leave the prior release available.
Reef source tools use the headless request-scoped workspace and a run-specific allowlist. Model providers and CLI agents use this same policy: CLI adapters expose the permitted tools through Minnow's bridge/dynamic tools with native execution disabled, and the headless runner owns source-tool execution. Headless generations retain the runner's chat ID across tool rounds so native CLI processes resume pending tool results. Reef closes that attempt's CLI sessions after success, failure or cancellation while preserving saved chats and clean native checkpoints. Anonymous Codex handoffs close immediately because they have no binding that a later generation can resume. The headless CLI registers each fresh workspace with an authenticated, unscoped request before attaching its workspace header to agent requests. Disabled tools stay disabled; interactive questions, shell execution, remote Git and connector tools are excluded. Node 24.21.0/npm are downloaded with official SHA-256 verification. Playwright 1.64.0 supplies Chromium checks. Packaged hosts extract trusted runtime/verifier scripts from app.asar into a content-addressed cache before launching managed Node. Generated Node code is trusted local code, not an OS sandbox. Runtime environments exclude Minnow's session token and provider secrets. Each app serves a separate loopback origin with an app-specific capability token, using an isolated Electron preview guest or a separate-origin iframe.
Storage is getMinnowHome()/reef/apps/<UUID>/: repo/, app.json, runs/,
releases/, data/, temporary worktrees/, and exports/. App repositories
have no remotes. The passphrase-required Reef backup category preserves source,
Git, releases, metadata and user data; it excludes dependencies, toolchains,
worktrees, temporary verification data and export outputs. Launch restores
omitted runtime dependencies from the release lockfile after a backup restore.
Export staging copies a selected validated release. The Electron wrapper owns
its runtime, isolated renderer and user data directory. Native export produces
a Windows portable executable, macOS app ZIP or Linux unpacked tar archive.
Exports carry the app's name, never its id: server/reef/icon.js derives the
product name (window, menu, .exe/.app, download filename) and npm/Linux
executable name. The Builder draws icon.svg (README contract; checkProject
rejects scripts, external references or a non-square viewBox so a repair fixes
it); apps without a usable one get a deterministic monogram. Export rasterizes it
in Reef's Playwright Chromium through <img> (render-icon.mjs) and writes
PNG-based icon.ico/icon.icns/icon.png itself into reef-build/, so
electron-builder needs no icon toolset. Windows uses signExecutable: false,
never signAndEditExecutable: false, which would also skip stamping the icon
and version info. If no icon renders the export proceeds with Electron's default.
The wrapper reads reef-host/identity.json: user data lives under
<appData>/Reef Apps/<app id> (rename-stable, never shared by same-named apps),
one instance runs at a time, Windows/Linux have no default Electron menu, http(s)
and mailto links open in the system browser, and window bounds persist.
Source build kits exclude Git history, chats, logs, data and credentials.
Docker supplies optional Windows/Linux cross-builds. Every cloud export requires
explicit consent: gh creates/reuses a private per-app staging repository,
dispatches a platform workflow, watches its exact commit and downloads the
artifact. The original repository remains local. Packages are unsigned and
unnotarized; no GitHub releases are published.
test/reef/core.test.mjs covers storage, confinement, runtime tokens/origins,
queue/recovery, process cancellation, SSE replay, encrypted backup selection
and mocked GitHub contracts. chat.test.mts verifies composer isolation.
MINNOW_REEF_E2E=1 enables deterministic-agent fixtures exercising real
Git/npm/Chromium, automatic repair and restart. Add MINNOW_REEF_EXPORT_E2E=1
for native packaging and executable smoke checks. These use scratch profiles
and never create cloud repositories. .github/workflows/reef.yml runs native
fixtures on Windows, macOS and Linux. Cross-platform installed-host and
live-provider/cloud acceptance remains the release gate.
Git changes refresh: Code's Source Control sidebar and the Source Control Changes view retain their file-list DOM when the workspace, buckets, paths, and status letters are unchanged. The Changes view still fetches the selected diff on refresh (a file can change while its status remains M), but only replaces the diff DOM when the response changes. Request generations reject superseded status/diff responses, including same-path selections across staging buckets. This removes repeated accessibility-subtree deletion during idle polling; a Windows Electron 43.2.0 freeze was sampled in Chromium's UIA text-range handling during subtree deletion, and the user reported relief after committing 81 modified files. The optimization addresses measured DOM churn, not a confirmed complete resolution of the native hang. Regression tests: test/ui/git-panel-branch-sync.test.mts and test/ui/scc-changes-refresh.test.mts.
Development process logs: npm start and npm run electron:dev supervise the Node server through scripts/dev-server.mjs; scripts/spawn-electron.mjs captures Electron output in foreground and detached launches. scripts/runtime-process-log.mjs writes raw stdout/stderr and timestamped lifecycle events (PIDs, exit code, signal, spawn failure) outside the observed process under ~/.minnow/logs/runtime/{server,electron}/, honoring MINNOW_HOME/SPEEDCHAT_HOME. Each launch gets separate files; each file rotates at 4 MiB with two backups, and startup retains ten runs plus any older live supervisors. Disk logging failures warn once and do not stop the app. Native fatal stderr survives app-process failure; forcibly killing the supervisor or a whole process tree can still prevent an exit event. Direct node server.js and packaged launches bypass the dev server supervisor. The detached Electron launcher keeps its output pipes open to capture the shell until it exits, without inheriting the server's pipes.
Electron JSON serialization: electron/json-runtime.ts loads first in the shell entry point and disables V8's json_stringify_fast_path for the main process. An Electron 43.2.0 native crash dump traced a fatal Zone allocation to the fast serializer's two-byte output buffer despite a small JavaScript heap. The standard serializer preserves JSON behavior and avoids that native path; this is a crash mitigation, since the triggering input has not been recovered. Subsequent Windows dumps from installed 0.1.11 also fault in the standard serializer's SerializeDouble/DoubleToStringView, so the flag does not establish that native serialization crashes are resolved. Renderer serialization is unchanged.
Issues merge performance: The shared renderer/server merge (src/lib/merge-issues-state.mjs) compares JSON records structurally, ignoring property insertion order and omitted optional fields. Returned-ID reconciliation lazily indexes canonical card contents once per merge instead of repeatedly serializing every remote card for each changed baseline card. This prevents normalization differences from triggering quadratic comparison work during saves and refreshes. Regression coverage in test/issues/state-merge.test.mts checks reordered records, edits during rekeyed saves, and a linear serialization-call budget for bulk changes. This removes a measured stall; it is not a verified fix for the native Electron serializer fault.
Native-call diagnostics: MINNOW_NATIVE_CRASH_TRACE=1 enables main-process breadcrumbs for JSON.stringify and Object.entries through electron/native-call-trace.ts. Each process synchronously replaces ~/.minnow/logs/native-call-<pid>.txt with the operation's active/completed/threw status and caller stack, without argument or result contents. Nested operations restore their active parent. This tracing is disabled by default and adds synchronous disk writes when enabled; it captures callers that native crash dumps may omit. A breadcrumb marked completed is not evidence that its operation caused a subsequent crash.
Preview overlay visibility: The shared chrome-popover registry covers command palettes, New issue, context menus (including submenus), chat search, Git name entry, keyboard help, and model-fork dialogs. Bound browser instances subscribe to registry changes, so secondary split-pane guests hide for overlapping overlays and return only after the last overlay closes; resizing or binding a guest while an overlay is open cannot bring it above the overlay.
Preview popover snapshots: When shell popovers temporarily hide Electron's primary native browser guest, src/ui/preview-popover-snapshot.ts retains its last painted frame in the preview body until the live guest returns. The capturePage bridge accepts an optional third immediate flag for this path: it captures only an already-visible guest, skips page-load/script waits, and bounds capture to 250 ms. Normal screenshot tools retain their existing load waits. Snapshots are transient DOM images and are cleared on leaving the preview surface.
Source Control history (MIN-108): src/ui/git-history-map/ owns the commit DAG model, deterministic horizontal lane layout (older on the left, newer on the right), virtualized SVG/button scene, canvas overview, and history controller. Source Control β History defaults to Map and offers a List switch, persisted under minnow.git-history.view; both views share loaded commits, filters, collapse groups, focus, selection, context actions, and gitShow() review below history using horizontal file tabs and Code's shared side-by-side patch renderer. The Code sidebar retains its compact row graph. src/ui/spatial-viewport.ts supplies reusable pan/zoom, fit/reset, reveal, and minimap behavior; Code Map re-exports it from its original viewport module. gitLog({ count, skip, fullRefs, historyKey }) returns at most 200 commits with hasMore, nextSkip, and a ref fingerprint. The map requests full decorators to distinguish any remote from slash-containing local branches; other consumers keep short decorators. A sentinel establishes continuation, and changed refs invalidate paging rather than mixing history windows. Search covers loaded messages/authors/SHAs/refs; filters retain neighboring topology and selection, while collapse retains tips and joins. Older windows and polling preserve the screen position of a visible commit and retain zoom/focus; refresh restores native focus without revealing or recentering it. History navigation is horizontal, vertically fits and centers the branch rows, and bounds empty space. Cards, links, distant overview, and minimap share Git lane colors. Buttons and SVG links are culled outside the viewport; distant views use canvas and the chronological list uses bounded rows. Map/detail requests ignore stale responses, selection changes do not reload history, and workspace changes clear selection. Tests: test/ui/git-history-map.test.mts, test/ui/spatial-viewport.test.mts, test/ui/scc-history-click.test.mts, and test/server/git-log-parse.test.mjs.
Dropdown styling: src/styles/dropdowns.css styles single-value native selects
with progressive appearance: base-select enhancement.
installThemedSelects() adds native selectedcontent faces for long-label
truncation and maintains them when lazy controls or replacement options appear.
Existing custom menus and pickers consume --mn-menu-* tokens in
src/styles/tokens.css for rounded panels,
inset rows, surfaces, borders, spacing, and shadows. src/ui/context-menu.ts remains
the action-menu primitive. installNativeSelectPreviewGuard() delegates focus,
pointer, and keyboard events and observes DOM changes only while a picker is open,
balancing the existing chrome-popover registry so Electron's native preview guest
cannot cover the styled popup. Older browser clients keep their native picker.
Usage: design-system/dropdowns.md.
Built-in voice: Fresh voice settings select stt.backend=builtin (quantized Xenova/whisper-tiny via the existing Transformers.js dependency) and tts.backend=browser (system speech synthesis). server/voice/builtin-stt.js owns a bounded worker-thread queue; builtin-worker.js isolates ONNX inference and caches weights under ~/.minnow/models/voice/builtin/. POST /api/stt/prepare starts automatic preparation; /api/stt/status reports loading progress. Capture can begin before loading completes; transcription uses 16 kHz mono PCM WAV, validates duration, and runs after stop/silence. Cached models prewarm at UI startup. GET /api/voice/config returns effective settings including fallback from uninstalled legacy default models; installed/custom local models and provider configurations remain available. Python setup is an advanced disclosure in Models β Voice. Packaged Python scripts are unpacked under app.asar.unpacked/server/voice/python/, since external Python cannot read ASAR. Concurrent Python starts share one promise, and early exits fail promptly. Python model load/unload handlers share the inference lock: overlapping Transformers initialization can corrupt process-wide initialization hooks and leave meta tensors, so readiness checks and loading are serialized together. Qwen TTS defaults to eager inference: compilation/CUDA graphs are opt-in via MINNOW_TTS_USE_COMPILE=true, gated by PyTorch's Triton capability probe. tts_optimizations.py enables Dynamo eager fallback for lazy compiler failures; ordinary TTS needs no Triton installation. System read-aloud uses OS output, supports stop/replacement, and local TTS starts its installed worker on demand.
Board pause/resume: POST /api/boards/:id/pause journals board.stopped with reason paused, keeping attempts open and bypassing reconciliation. The runner effector holds the same live attempts through the shared runner pause gate at model/tool boundaries and before finalization; work already in flight can finish. Wall-clock attempt limits exclude paused time. /start releases the gate; /stop retains its abort-and-replace behavior. The header exposes Pause/Resume and a secondary Stop. Paused boards remain paused after server reload; explicit Resume then uses existing vanished-attempt recovery (retained worktree and builder resume digest), since an in-memory runner cannot survive process exit.
Board reasoning levels: The board header's journal adapter and /api/boards/:boardId/model validation use the shared reasoning-effort vocabulary, including minimal, xhigh, and max. Worker attempts enable thinking for any explicit effort level and retain that level in transcript metadata for the completion request. Changes affect new attempts; active attempts keep their current setting.
Codex reasoning and context display: The app-server translator forwards both reasoning summary and content deltas, fills missing text from completed items, and separates sections without replaying streamed text. Models that supply only summaries still display only those summaries. The chat adapter persists native context occupancy in Chat.lastNativeContext, bound to the provider/model and history length. The context ring prefers this measurement and its native window over per-generation billing counts, retaining it through tool handoffs and reloads; clearing or compacting the chat invalidates it. Billing and session token totals remain separate.
Board thread display: Task-detail chats always use the full transcript view, independent of the global compact/full chat preference. Activity stays in the reading flow without a Worked/Working disclosure. Thoughts start expanded and tool results start closed; individual disclosure choices survive live repaints.
Background index workspace: Brain's child-process code indexer validates its target folder and binds it with runWithToolContext. Indexing board or chat worktrees does not persist a new default workspace, change recent folders, or apply a workspace profile; the saved project remains the cold-boot default. Regression coverage: test/brain/code/index-worker-bundles.test.mjs.
Source Control file review: Clicking a changed file in Code's Source Control sidebar opens its full content in a CodeMirror tab through git-file-editor.ts and openGitFileInEditor. /api/git's read-only fileDiff operation supplies HEAD/index/working-tree text with workspace path checks and a 512 KB limit. editor-git-diff.ts highlights additions and displays removed lines as block widgets outside the editable document, recalculating against the baseline when typing. Unstaged files use editable workspace tabs and preserve drafts; staged and deleted files use transient read-only snapshots, separate from working-file tabs. Staged files compare HEAD to the index; unstaged files compare the index to the working tree. Commit and recorded-chat reviews retain the shared side-by-side renderer.
GitHub issue watcher (MIN-7): Scheduler jobs optionally carry githubWatch: { repository, label: 'minnow' }. server/scheduler/github-watch.js owns durable claim/planning/starting/board/publishing/complete/blocked transitions; its runtime adapter uses gh with argument arrays and temporary body files, the shared headless Plan runner, and authenticated workspace-scoped board APIs. One issue runs per repository, with a process lock during polls and board ownership across polls. Ledger files under scheduler-runs/github-watch/ are covered by the existing Scheduler backup category. Deterministic board IDs, base refs, planning worktrees, PR heads, and comment markers reconcile retries. Planning uses a detached .worktrees/gh-* checkout based on the fetched default branch; boards remain associated with the original workspace and keep their existing isolated Builder/Tester/integration worktrees. Every task must be merged and the final test must pass before publication. Polling resumes running boards through the existing resume gate; interrupted planning and failed boards require explicit retry. Scheduler's existing editor configures watchers; per-job /issues and /issues/:number/retry routes expose progress and recovery. Repository/workspace changes require a new watcher; watchers with active work can be disabled but not deleted. Tests: test/scheduler/github-watch*.test.mjs.
Hosted MTPLX: src/models/engine-ids.mjs centralizes engine/provider identity; engine-support.ts separates weight format from compatible engines. Validated MTPLX cache rows keep format MLX and use mtplx:<repo> library IDs. They offer MTPLX or mlx-lm in the Models Load inspector. server/models/mtplx-runtime.js detects an existing installation on Apple Silicon/macOS 14+; Minnow never provisions a second Python environment. The CLI supplies cache validation and pre-launch compatibility. mtplx-descriptor.js normalizes model controls and persists them in ~/.minnow/models/mtplx-descriptors.json, keyed by model directory and file timestamps; health descriptors take precedence. Normalization version 2 invalidates older cached descriptors, reads MTPLX 2.12 controls from startup.model_controls, accepts explicit MTP support flags and stores a scalar profile name. Boot reconciliation refreshes descriptors for live persisted serves without changing saved launch preferences. Contract-only inspect output uses conservative control fallbacks until health supplies full controls. The MTPLX Load panel retains expanded groups and its scroll position through status refreshes.
MTPLX settings layer descriptor defaults, ~/.minnow/mtplx.json defaults, models.launch.byLibraryId[id].mtplx, and request mtplx. The sibling engine selects the runtime; existing flat llama settings remain backward compatible, and load-progress priors remain engine-independent. /api/models/mtplx/{runtime,diagnostics,descriptor,estimate} supplies detection, troubleshooting, model controls and conservative memory estimates. The descriptor response adds display-only loadDefaults, merging model and configured defaults with MTPLX 2.12 fallback values or descriptions of automatic policies. The Load panel displays those inherited values in dropdown labels and input placeholders; selecting the default removes the per-model override, including boolean settings. MLX snapshot downloads with engine: 'mtplx' use the CLI cache directory and preserve pre-existing artifacts on cancellation.
mtplx-serve.js dispatches through the shared serve store. It adopts a healthy matching model at the requested/configured port (8088 by default), also checking 8000 when no port is specified; otherwise it starts a per-model process. Rows explicitly record ownership: 'minnow' | 'external'; external daemons are never stopped or evicted by Minnow. Owned serves share admission, crash handling, TTL, logs and restart infrastructure. Idle eviction combines Minnow activity with native active/queued requests and completed-request epoch timestamps (last_request_at, with idle_seconds fallback); unavailable health defers eviction. Crash classification matches actual failure signatures, so successful contract validation and memory-limit informational logs do not suppress restart. Healthy stretches survive heartbeat probes; automatic restarts preserve port, hardware, model identity and launch settings. Completions route by serve model/library ID through mtplx-local; the upstream model ID and observed context come from the daemon. /health and JSON /metrics feed the existing activity stream with native MTPLX metrics, not synthetic llama slots. Pre-launch memory uses a conservative config-based KV bound; MTPLX's native memory plan is authoritative after loading. SSE mtplx_progress.completion_tokens is forwarded as stream_meta.runtime.mtplx_progress for the live chat token counter, including while output is buffered; final usage remains authoritative for the ledger.
All spawned MTPLX serves force --no-stats-footer --agent-rewrites off --yes and default to loopback with --no-auth. An explicit extra-argument host override uses ~/.mtplx/api-key for authenticated non-loopback binds. Attribution is a shipping requirement: retain Powered by MTPLX in the engine picker and Settings β Servers. Integration baseline: MTPLX 2.12.0 (Apple Silicon/macOS); legacy descriptor shapes remain covered. Tests: test/models/mtplx*.test.*; browser fixture: test/fixtures/mtplx-ui-harness.html through Vite.
Source Control pull requests: src/ui/scc-pulls.ts owns state filters (Open/Merged/Closed/All), search over the loaded PR list, current-branch selection, and request guards for list/detail refreshes. New-PR forms survive polling. src/ui/scc-pr-detail.ts renders a tabbed Overview/Checks/Files/Commits inspector, sanitized GitHub Markdown with inert task lists and external links, CI detail links, and merge/review summaries. The active tab and detail scroll survive updates; unchanged details keep their DOM. Merge methods share one selector, drafts omit merge controls, and conflicting PRs disable the direct merge action. Review records still use the shared pr-review-panel.ts and store. Coverage: test/ui/scc-pulls.test.mts.
Chat transcript export: The shared chat-row context menu offers Copy chat transcript (plain text with Markdown and tool activity) and Export chat as HTML (standalone, sanitized Markdown, embedded raster attachments, expandable tool calls/results, responsive and print styles). Actions hydrate lazy history before reading it and export the current saved history without hidden user prompts, injected context notices, or private reasoning. Formatting lives in src/chat/transcript-export.ts; clipboard/download handling in src/ui/chat-transcript-export.ts is lazy-loaded from the sidebar. HTML has no remote assets or scripts and embeds styles from src/chat/transcript-export-styles.ts.
Commit review in Code Map: Selecting a history commit in Code's Source Control sidebar keeps the existing adjacent diff viewer and focuses the map on its selected file. Diff file tabs and map file selections share one workspace-scoped selection (git-commit-review.ts); changing folders follows the selected path, while unchanged files show an explicit empty diff. Commit status and line-count badges overlay the current index on file and module nodes; deleted and unindexed changed paths are added as selectable cards without invented call edges. Renames show their old paths. During review the adjacent diff serves as the file inspector, and activating a map file keeps the diff open. Closing or replacing the commit diff clears the overlay. Superseded commit and folder requests cannot restore an older selection. Relationships describe the current code index, not a historical call graph.
Code Map chats: Ask actions in the Code map open a fresh General chat in a compact sidebar beside the map and immediately send the question. The composer exposes only the reply field and Send/Stop. A persisted UserMessage.codeMap snapshot renders the selected code as a context card with the question; the underlying message content retains bounded, untrusted index evidence (workspace, location, summaries, relationships, symbols, and source where available) for the agent and later turns. The sidebar uses the shared Code composer, flex-based transcript layout, tool loop, and session store. Its divider supports dragging and arrow keys; width persists in minnow.codeMapChatWidth in localStorage, clamped to leave room for the map. Narrow containers stack the chat below the map and hide the divider. Closing it leaves the map open; leaving the map restores the composer to the main column and shows the new conversation. Brain's standalone map remains read-only for chat handoff.
Code readiness after deferred setup: Skipping onboarding retains the existing tool permission policy. A blocked list_directory file tree offers a deep link to tools.item.list_directory in Settings β Tools and an explicit retry. Primary editor errors for disabled metadata or file reads also link to the exact permission with a retry. Code's empty-chat hub places Browse files and Open terminal before model status and metrics; the empty editor shares those actions. Browsing reveals the Files pane without toggling it closed. These controls do not grant agent permissions automatically.
Model picker catalog metadata: Claude Code discovery initializes the installed CLI's stream-json SDK protocol and reads its models metadata without submitting a prompt. The account catalog supplies model values, resolved pinned IDs, labels and effort controls; new models appear without editing Minnow's catalog. Discovery runs in a disposable directory with native tools, MCP servers and hooks disabled. Only model metadata is retained, with a five-minute cache keyed by binary/version, login-file timestamps and provider environment. Older or unavailable handshakes retain version-aware fallbacks: Haiku 5.5 requires CLI 2.1.293, Sonnet 5.5 requires 2.1.284, and Opus 5.5 requires 2.1.280. Haiku 5.5 exposes native 1M context and reasoning; older Haiku remains capped at 200K. Picker Refresh bypasses catalog and detection caches for all CLI providers; CLI verification also clears the corresponding model cache. Provider normalization preserves display_name; aliases are labeled CLI default, and requests retain the original alias IDs. Picker grouping, logos, and producer search use recognized catalog owned_by metadata when IDs are opaque; recognized model families take precedence over gateway ownership. Unknown CLI versions keep the unversioned fallback. Coverage: test/models/agent-cli-claude-catalog.test.mjs and the CLI catalog/provider suites.
Cursor model variants: The picker collapses Cursor Agent's effort and Fast IDs into one row per model family; separate Thinking families stay distinct. The normal composer reasoning control uses the family's advertised effort levels. Chat.cursorFast stores the per-chat Fast preference. Changing effort or the Fast switch immediately updates the chat's model binding and chip to an advertised Cursor ID; the send path also resolves the variant before requesting the CLI. Fast sits beside reasoning in the footer and moves into composer settings on narrow layouts. It is disabled for levels without an advertised Fast variant. The native select retains the raw IDs for existing chat bindings and other model consumers.
Global search: The menubar Search icon and palette chords open src/ui/command-palette.ts, with All / Chats / Code / Workspace / Models / Settings / Actions filters. shell-commands.ts registers project-scoped recent chats before actions; recent-chat-commands.ts sorts up to twelve sidebar-visible chats by last-message metadata without hydrating transcripts and opens them through launchApp('code', { chatId }). Commands may specify category; other commands use the group classifier in command-registry.ts. Scoped palettes can omit the category chrome.
Sidebar file search: GET /api/brain/code/files serves a full workspace path catalog from the code-index SQLite DB (workspace_files and workspace_files_meta), independently of symbol-index enablement or LSP support. It accepts the same validated workspaceRoot override as code-map queries, plus a workspace-contained path subtree and refresh=1. Discovery uses one bounded, bundled ripgrep scan, including hidden/ignored files but excluding .git, .godot, node_modules, dist, build, and .minnow; concurrent scans within a process share one promise. Catalogs record the absolute root to prevent same-named workspaces from sharing file results, expire after five seconds, and refresh explicitly on file-tree invalidation. Default symbol indexing uses the same catalog with language filters; custom include/exclude scopes retain their original ripgrep semantics. The sidebar paints up to 200 filename matches before content search finishes, then adds content previews/results; superseded searches and clearing the filter abort pending requests. No per-directory UI crawl or LSP reindex is needed for search.
Board starting branches: Board creation accepts optional baseBranch and journals it on board.created; absent input inherits the workspace branch (or detached HEAD commit). The Boards hub and create form expose a Starting branch picker with the current branch preselected. ensureBoardIntegration uses this saved ref when first creating integration, so later workspace checkouts do not change the board's starting branch. Existing boards retain their saved choice; older journals without a ref retain the HEAD fallback. Snapshot format is v7.
Board landing destination: Report Commit opens an inline picker for the original branch, another local branch, or a new branch with an available suggested name. /api/worktree merge_integration_into_workspace accepts targetBranch, createBranch, and baseRef; explicit destinations require a clean workspace, validate branch names, and switch the workspace before merge. New branches start from the saved board base. createdBranch in the result keeps retries on the created branch. Push sets upstream on the selected destination; PR creation accepts an explicit baseBranch selected separately in the report. Failed push/PR attempts keep the picker available.
Renderer scheduling: Dedicated app windows retain shared session/config hydration but skip chat composer wiring, transcript painting, chat resume/loop timers, terminal-history refresh, and Issues-page warming in src/main.ts. Code windows keep those behaviors. src/boot/render-idle.ts combines document and Electron visibility and exposes subscribeRenderIdle; file-tree Git and Code board-badge polls skip hidden views and refresh on return. Transcript backfill pauses while hidden and yields after a 6 ms slice (at most 15 history entries); this remains chunked full-history rendering, not virtualization. Splitter drags coalesce pointer positions with src/ui/pointer-frame.ts, read container geometry at drag start, and commit state after flushing the final position on release/cancel/blur. Streaming Markdown decorates only changed blocks and rolls back per-token heading counts when its suffix changes; full-document lexing remains. Session saves retain dirty edits during capped, jittered retry backoff, with one immediate outer conflict retry and a shutdown-flush bypass. Per-chat revision conflicts retain the affected rowβs unsaved edits and exclude that row from subsequent writes, including shutdown; unrelated edits and new chats continue saving. Ctrl/Cmd+R, F5, and hard-reload shortcuts await an acknowledged ordinary session write (including edits made during an in-flight save) before replacing the renderer; save failures cancel reload and retain the open window. Electron intercepts these keys in electron/shell-reload.ts and calls the rendererβs prepareSessionsForReload hook; browser clients use a capture-phase key handler. Pagehide remains a best-effort fallback for abrupt navigation. A stale, unedited boot describe is discarded on either store or chat revision conflict. Focused coverage includes test/state/session-save-backoff.test.mts, test/ui/sidebar-resize-frame.test.mts, test/ui/pointer-frame.test.mts, test/boot/render-idle.test.mts, and the Markdown/history suites.
Shared live transport: Browser SSE consumers use src/api/stream-fetch.ts (streaming GET responses) or src/api/stream-event-source.ts (EventSource semantics), multiplexed on one authenticated /api/streams/ws socket per renderer. Generations, terminal output, boards, model activity/downloads/logs, voice installers, research, compare, and the Agent Browser viewer no longer reserve Chromium HTTP/1.1 connections while idle or streaming. Ordinary RPCs remain HTTP. server/runtime/stream-ws.js is registered in both Vite and the packaged Electron host; it forwards GET subscriptions over dedicated loopback HTTP connections through the existing auth/workspace middleware using the original credential, never a host-token elevation. Channels carry independent cancellation and one chunk of read credit; the socket has heartbeat detection, a 256-channel cap with explicit failure, and a send-buffer limit. Generation subscribers retain their existing replay/retry handling; event feeds reconnect with Last-Event-ID. Node/headless consumers retain HTTP. New browser SSE feeds must use these shared helpers, not native EventSource/fetch. Existing dedicated agent/PTY/audio WebSockets remain separate. Regression coverage: test/api/stream-transport.test.mts; the isolated Chromium reproduction is node test/fixtures/stream-browser-harness.mjs (six native streams block an RPC, 32 multiplexed streams leave it responsive).
Issues views: A labeled native View selector replaces the saved-filter tabs: All issues, Needs review, Agent work, My open issues, and user-saved views. A description explains each built-in. Switching views resets filter chips to the selected defaults and keeps scope/search; saved type/status/priority/project filters populate the editable chips rather than silently overriding them. All issues and Agent work include completed issues by default. View ids remain compatible; UI filter version 2 migrates older saved defaults into editable chips once.
Issue labels and sync indicators: Label autocomplete and expansion catalogs use labels on issues in the destination workspace, including completed cards. Existing issue editors retain their own workspace in All workspaces; new forms resolve the Workspace picker when suggestions open. The shared label catalog continues to store swatches, without supplying cross-workspace suggestions. List rows, board cards, and detail headers share issues-github-badge.ts: GitHub Β· Needs push uses the synced-field watermark, Not synced marks unlinked cards when Two-way mirror is enabled, and Conflict marks known sync conflicts. Remote changes become known during synchronization; the badge adds no polling.
Linked issue chat activity: Issues list rows, board cards, detail headers, the Code Issues sidebar, and linked Chats rows show the current linked chat turn as running, finished, failed, stopped, or interrupted. Runtime turn boundaries publish owner-scoped BroadcastChannel snapshots; newly opened Issues windows request the current snapshot. Active owners heartbeat every 10 seconds; observers mark a missing owner interrupted after 30 seconds. A versioned localStorage cache (minnow.chatRunActivity.<chatId>) retains the latest 100 terminal snapshots for observer reloads, with existing persisted chat.runs as a fallback. New chats with no completed turn show no finished badge. Live work overrides previous terminal outcomes; finishing a chat does not resolve its issue.
Issue expansion activity: Expand and Create keeps a compact Expanding spinner on the saved issue in list rows, board cards, detail headers, and the Code Issues sidebar while its background request runs. Renderer-owned workspace/issue snapshots share activity with separate app windows, including windows opened mid-request; success, failure, and window close withdraw it. Activity patches its mounted slot in place without remounting an editor. Duplicate starts for the same issue are ignored; reduced motion keeps the text indicator without rotation.
Issues projects: The Issues app has a dedicated #/app/issues/projects screen, reached from its Issues / Projects primary navigation. It lists active and archived projects, shows issue completion rollups, and supports create, rename, archive, restore, and project-filtered issue handoff using the shared Issues store.
Utility model compatibility: The client and server completion sanitizers remove native thinking and nested reasoning from OpenCode Go Chat Completions requests after model-specific normalization; Go receives reasoning_effort instead. Hy3 utility thinking-off maps to reasoning_effort: none. The server preserves explicit effort when no model capability row is available, so orchestrator requests and the second sanitization pass do not reset it. Responses models retain controls for translation: utility thinking-off maps to low for Grok 4.5/4.6 and Muse Spark, which cannot disable reasoning. Native Z.ai and local runtime controls remain provider-specific.
Reasoning levels from providers: OpenAI-compatible /models rows retain reasoning.allowed_options and reasoning.default, including the same block under capabilities.reasoning; the composer uses those values for named effort controls. Model-picker footers expose Reasoning default only when the targeted provider/model offers named effort levels. Overrides are keyed by the full picker value, persist in ~/.minnow/model-reasoning-defaults.json through /api/config/model-reasoning-defaults (with localStorage fallback), and seed a chat whenever that model is selected; Model default removes the override. DeepSeek and Muse use only provider-advertised effort levels; OpenAI-compatible model names no longer imply selectable levels. For OpenCode models, models.dev supplies the reasoning yes/no signal when its model list omits it, without inventing effort levels. The official DeepSeek Chat Completions wire receives reasoning_effort (and thinking for Off), with the Responses-only top-level reasoning object removed. A model-list endpoint that returns only IDs cannot reliably disclose effort levels; capability probes do not infer them from a successful completion because gateways may silently ignore unknown effort values.
Go routing and utility budgets: src/lib/resolve-model-api.mjs routes Go's MiniMax M3/M2.7/M2.5, Qwen 3.6 Plus/3.7 Max/3.7 Plus/3.8 Max/3.8 Flash, and Union Alpha through Anthropic Messages, independently of architecture heuristics; explicit per-model overrides still win. Other providers retain their configured routes. All three generation transports apply server/generations/utility-output-budget.js: short Go utility/title requests receive at least 2,048 total output tokens for reasoning plus their answer (GLM-5.3-Flash rejects limits at or below 1,024). Existing larger limits and normal chat limits are preserved. LAN inference addresses retain template thinking controls just like loopback runtimes; LAN servers remain distinct from on-device inference for UI performance decisions.
Local speech playback: src/voice/audio-playback-queue.ts preserves streamed PCM samples without another crossfade. It serializes initialization/enqueues/drain, schedules contiguous buffers at their source sample rate, and uses an adaptive reserve before playback. Streams arriving below speaking speed wait for completion; fast streams can start early. Read-aloud remains cancellable while buffering, and unexpected WebSocket closure reports an error.
Chat change review: Turn cards open files in the editor and use the Git history side-by-side viewer for Review. Commit tool results retain commitSha and per-file counts; older commit output can supply the SHA. Other turns show their recorded diff chunks with explicit missing/truncation notices. Aggregate-only historical statistics are not repeated as per-file counts.
Home app: home is a released core app before Code on the app rail, with its own lazy homeView page (src/ui/home-page.ts). It reads the shared workspace/session/issue stores, V2 board summaries, recent viewer files, scheduler, and compact git.diffSummary tracked totals. Old #/app/code/overview links redirect to #/app/home; the compatibility overview module no longer mounts into Code. New project picks open Home; existing app routes survive boot.
Code activity: Authenticated /api/activity GET returns daily aggregates for the last 366 days and optional latest 100 day events; POST records accepted editor edits with idempotent event ids. server/activity/store.js stores counts, source, timestamps, paths, optional chat id, and originating workspace in per-project SQLite files under ~/.minnow/activity/<sha256-workspace>.sqlite. Linked worktrees resolve to the common project ledger. AI file tools record server-side for chat/runner contexts; manual saves, git commits, shell snapshot estimates and external edits are excluded. Completion/partial completion, intent, and Quick Edit accepts record at buffer acceptance. UTC calendar days, source filtering, tracking-start metadata, no historical AI attribution. Home refreshes project sections every 10 seconds and activity every 30 seconds while visible, guards workspace races, and suspends on app leave.
Source Control deletion: src/ui/scc-refs.ts provides row selection (click, Ctrl/Cmd-click to toggle, Shift-click for ranges, Ctrl/Cmd+A and Escape) and confirmed bulk deletion in Branches and Worktrees. Selection is limited to visible rows and scoped to the browse root; bulk deletion skips protected rows. Deletion shows inline item/count progress and the shared git activity overlay, with repeat row actions blocked while the batch runs. Bulk operations run sequentially without force, preserve failed selections, and report individual failures. /api/git operation deleteRemoteBranch validates a configured remote and tracking ref, protects main/master/HEAD, and pushes a fully qualified deletion refspec; local branches are retained.
Minnow is a full agentic development workspace (local-first, AGPL): a Vite + TypeScript SPA, a Node tool server (server.js), and an Electron shell (Minnow Shell). Code is the primary surface; the other apps support work done there. It targets LM Studio and other OpenAI-compatible providers. End users install packaged builds from GitHub Releases (manual install); npm start is the development path (setup from source).
| Layer | Role |
|---|---|
Electron (electron/) |
Desktop window, frameless chrome, WebContentsView preview browser, Agent Browser viewer, packaged in-process server |
SPA (src/, index.html) |
Minnow Shell, Code workspace, chat, modes, tools loop |
Tool server (server.js, server/) |
Vite dev host, /api/*, file/git/shell tools, generations SSE, persistence under ~/.minnow
|
Preview toolbar (MIN-37): Navigation, address entry, expand, and close stay visible. The shared More menu exposes History, Auto-reload, Design Mode, annotations, DevTools/docking, and split through the original control handlers, with availability and checked states preserved. Primary and split panes support keyboard navigation and browser fallback; Electron-only browser actions remain conditional. History anchors to the visible More button.
Workspace previews: Executable workspace pages load from a separate, route-limited HTTP origin (server/preview/isolated-host.js). Authenticated /api/preview/access issues a ten-minute capability bound to the requesting workspace; the preview origin serves only workspace file and document assets under that root. The host session token is never put in a workspace preview URL or document. Main-origin HTML file reads are downloads with sandbox CSP, and generated document previews have sandbox CSP. The Electron preview session retains security headers on Minnow preview responses. Browser regression coverage: test/security/preview-origin.test.mjs.
-
npm start? Vite + tool server (default port 9473) + Electron. -
UI offline copy (MIN-529): Settings banners, status lines, and tool errors shown in the SPA avoid
npm startand internal backend jargon; prefer Open or restart Minnow (seesrc/copy/local-session.ts). -
npm run dev? Vite only; most server features unavailable. -
npm run electron:dev? Vite + Electron with HMR. -
Dev boot:
server.jsclears stale~/.minnow/run/dev-host.jsonon start and writes the bound URL only after Vite warmup;scripts/wait-for-minnow-dev.mjsignores dev-host metadata when the writer PID is gone so Electron does not attach to an orphaned server on the preferred port.
Agent wait tool: wait is a renderer tool that parks a chat turn on a timer instead of spin-polling. The call blocks in the tool loop β the same shape as ask_question β and returns Timer completed after 5m β <reason>. Continue now. so the turn resumes in place. Durations accept 30s / 5m / 1h30m or a bare number of seconds, capped at 2 h (src/tools/wait-tool.ts); longer waits are rejected before a timer starts. When the timer fires, notifyWaitTimerFired pushes the silent agent_wait kind (Chat group) through the menubar bell. The turn shows a frozen waiting phase in the agent activity panel with the model's reason. Composer Stop aborts the wait and ends the turn like any other stopped tool. The tool is chat-only: server/runner/tool-set.js headless lists are unchanged, and AFK execution blocks it.
Context and provider usage: the composer context popover shows the last-request context budget separately from cumulative chat.tokenLedger.totals (input, output, total, request count). Replayed history counts again in session usage; the wheel remains a context-capacity gauge.
Usage timeline: Usage & cost aggregates retained ledger entries into local calendar intervals (hour, day, week, month, or year) with empty intervals preserved. The chart has independent All retained / Last 30 days / Last 7 days filters and exact token/response totals on hover or keyboard focus; overall totals and source/model breakdowns keep their existing scope. Aggregation: src/usage/usage-timeline.ts; chart: src/ui/usage-timeline.ts.
Board attempt context: task transcripts show a context wheel, used/limit tokens and percentage, with an expandable remaining-token readout. The shared runner emits context_usage at model round boundaries (request estimates and provider-reported prompt plus completion usage), retained in per-attempt JSONL by server/orchestrator/transcripts.js. The display follows the selected attempt, including decreases after context compression, independently of the active chat. Older transcripts without measurements show unavailable; unknown model limits show no percentage. This is the latest round's context occupancy, not cumulative usage.
Board work agents continue an unfinished task when a reasoning round ends at the model's output token limit. The runner asks for the next concrete tool action and reserves the report_outcome nudge for a completed response that omitted the required report tool. This prevents a long thinking round from ending a builder attempt before it edits or verifies anything.
Web extraction and Anthropic replay: src/lib/fetch-web-content.mjs removes raw script/style/textarea elements before parsing noise subtrees, so embedded HTML strings in hydration scripts cannot escape the cleaner. Web RAG ranks bounded 1,200-character units and returns at most 16 excerpts / 12,000 excerpt characters, independently of generic tool-output caps. The Anthropic bridge preserves exact signed and redacted blocks as reasoning_blocks on the internal completion wire; the runner carries them across tool rounds, and chat persistence uses thinkingBlocks separately from display thinking[]. Reloaded Claude requests replay these blocks via AI SDK providerOptions.anthropic (signatures/redacted data), preserving whitespace and block boundaries. Gateway tool requests retain requested thinking; unsigned history only suppresses it within the current user turn. Opus 5 uses adaptive thinking.
Composer prompt recall: src/ui/composer-prompt-history.ts snapshots the unsent text, selection, and scroll position before browsing. Down at the draft tail does nothing; returning from history restores the snapshot. Recalled edits survive navigation between entries. Plain arrows recall only at single-line edges; Alt+Up/Down explicitly browse multiline or wrapped prompts. Draft persistence reads the saved unsent text while recall is active, so browsing does not overwrite it on blur, chat switch, or session save; explicit edits to recalled text become the durable draft. composer-auto-resize.ts restores overflow after clears in both sizing paths and retains the scroll offset when pinning CSS field sizing; composers expose native scrollbars, and the skill highlight overlay matches their text width.
Four composer modes: General, Build, Plan, Debug. Orchestrate opens from the sidebar hub. Super Plan is a Plan sub-mode. Onboarding is first-run only (not in the Code composer strip). Seven live entries in the registry; persisted 'desktop' and 'email' remap to General via normalizeModeId. Reef mode was removed in MIN-473.
Registry: src/chat/modes/registry.ts. Tool allowlists: src/chat/modes/tool-groups.ts. Prompts: src/chat/prompts/modes/.
General document creation (MIN-525): General mode and its default General assistant create and revise PRDs, specs, RFCs, proposals, reports, notes, guides, and written plans directly with enabled file tools under normal permissions. Both full/lite mode and work-agent prompts carry this guidance because the default work agent suppresses the mode body. Shared handoff rules distinguish document creation from a specialized implementation planning workflow or an explicit request for Plan mode.
Agent set_chat_mode handoffs apply immediately to the originating chat, including during a running turn. The active composer synchronizes without rebuilding the streaming transcript. Before the next model request, the chat adapter refreshes the system prompt and tool catalog through the runner's refreshRoundConfig hook, including lazy tool discovery. Manual composer mode changes remain disabled while streaming.
Composer card + footer row: The Code composer (.input-bar, also relocated into the hub) is a floating card --chat-composer-max wide (the --chat-msg-max 1024px transcript column plus 20px each side): strips on top, #msgInput, then one footer row sharing the last grid row with Send (grid order does the stacking; DOM order is unchanged). The footer is always the "compact" strip once initComposerCompact() runs: current-mode dropdown pill, run target (Local/branch), reasoning, the cog, then context wheel and model chip on the right; attach/mic/expand (#composerInsetActions) ride the row just left of Send. Context docs, code map, brain notes, orchestrate plan, work-agent dev and the board-view toggle always park in the cog sheet as labeled rows; run target and reasoning also park when the row is narrow (under 560px, back above 600px; composer-controls--narrow). Tools is a chevron row that drills into a second page (group rows, Enable all, All tool settings; web search and cache live in Settings β Tools). #composerRunStatus above the card shows Working Β· elapsed Β· actions while the active chat's run is live (painted by chat-work.ts). Mode labels never clip. The cog sheet portals to document.body at z-index 1200 while open (same family as the model menu) so #mainColumn's container-type stacking context cannot trap it under .chat-sidebar; placement prefers the chat column when the sheet fits. Logic: src/ui/composer-compact.ts, src/ui/mode-selector.ts. CSS: src/styles/composer-overflow.css, src/styles/mode-selector.css.
Plan execution formats (MIN-524): Interactive Planner and Plan-mode prompts ask once per new plan whether to Build in one chat or Orchestrate on a board. Saved planType: build front matter selects sequential steps with Changes/Verify/checklists; omitted type retains the legacy board format. server/orchestrator/core/plan-format.js shares classification across preview, board discovery, and server validation. check_plan dispatches Build documents to server/tools/validate-build-plan.js; board intake/resync continues through parsePlan, which rejects Build and unknown explicit types. Build previews and chat file-change cards omit board actions. Revisions retain the artifact's type; no session schema migration is required. Unattended board planners keep their board contract.
Plan mode blocks mutating file/git writes except save_file, make_directory, and targeted append_file / insert_at_line / replace_text_in_file edits under documentation/plans/ (client + server guards). The default Planner work-agent allowlist includes those edits, web tools, and the current Context7 MCP names when Context7 is configured.
Super Plan (super-plan mode) is a server-owned journaled run under ~/.minnow/superplan/<runId>/. The pure graph (server/super-plan/{events,derive,plan,policy,graph}.js) sequences five roles: interview β research β draft β review β polish, with separate specification and acceptance gates. Interview and draft use renderer leases via src/chat/super-plan/claim-loop.ts; research uses the durable Research store, and review/polish use the in-process runner with persistent transcripts. The production factory is shared by boot recovery and HTTP creation. /api/super-plan creates runs; per-run routes expose state/events, start/stop/resume/cancel, claim/finish, ask, gate answers, skip and rework. Questions are journaled before delivery and answers are scoped to their attempt. Pause is non-terminal; boot re-arms unfinished runs without a renderer subscription. Claimed lease expiry reoffers interrupted work; unclaimed stages wait for a renderer. journal.js writes a read-only superPlanView into the owning chat; session normalization and imports preserve its sequence. UI actions use client.ts; the old renderer controller and fixed ten-stage sequencer are removed. The full-column surface (super-plan-page.ts) renders the projection, artifact tabs and journal/live activity. Artifacts appear only after validation; spec confirmation assigns a title-based slug with a run suffix. Drafts receive structured review feedback and acceptance errors; executable plans additionally pass the board parser. Review termination records the round cap or lack of progress and disputed fix claims. Settings are snapshotted at creation, including model bindings and reviewer timeout. See server/super-plan/README.md for contracts and recovery details.
Foreground chat browser guides use short, non-overlapping /api/browser-agent/runtime/:token/guides polls instead of a persistent SSE connection per turn. Guides remain queued until acknowledged at a runner boundary; stopping a turn aborts its poll. This keeps concurrent project chats from consuming the HTTP connection pool with idle guide streams and blocking file requests.
Live Models prompt/token overlays synchronize across same-origin windows through a renderer-owned BroadcastChannel, including a snapshot request when Models opens mid-generation. Only the originating renderer clears its overlay; closing that window withdraws it. Code command registration is idempotent, and its Electron readiness state is cleaned up by the existing window-close handler.
Released (all core): Home, Code, Source Control, Models, Brain, Issues, Scheduler, Settings. Hidden (releaseState: 'hidden'): Research, Compare, Bench, Experts β code and tests stay in tree, but they are omitted from every product surface. Default boot hash #/workspaces; legacy #/desktop, #/app/chat, #/email, and #/app/email redirect via resolveLegacyHash. Registry in src/os/app-registry.ts. Source Control is a released core app at #/app/source-control, beside Code in the navigation rail. Its full git view mounts in #sourceControlView; opening it leaves the chat transcript intact. Existing full-view git actions launch this app.
Issues (#/app/issues, core) is the Linear-style tracker (list + board + peek). #issuesView is a thin mount; chrome is rendered into .issues-shell (container-query child of .issues-page) by issues-chrome.ts. Saved-view tabs (Triage, Assigned to agents, My open, plus user views) replace the old filter <select> row; active filters are removable chips. The list is one row per issue (columns: ID, priority, type, title, labels, then metadata and status; labels shrink-wrap to up to three chips plus a caret and +, title takes leftover width), grouped (status default, or priority / assignee / label / project) with sticky collapsible headers. Manual rank (Alt+β/β) wins inside a group; the first move in a peer set materializes ranks for every current visual peer so Alt+β can land below an unranked row. Column-header sort is a session fallback when ranks are equal or missing (grouping.ts, rank.ts). Sub-issues nest one level (parentId); peek lists children with New / Existing / Remove, the row menu can add or unparent, and dropping an issue onto another issue sets parentId (hierarchy.ts, issues-sub-issues.ts). A child whose parent is filtered out still renders as a top-level row. Status, priority, assignee, labels, and project edit inline from the row via the shared openContextMenu({ anchor }) primitive β peek is a description-first document (sticky identity, property chips, and workflow; empty Code / Attachments / Git collapse to add-rows; Plan and Related omit when empty; Sub-issues stay visible so a parent can add children). Labels are a workspace catalog (label-catalog.ts): each name has one of ten swatches (labelCatalog on state.json), Linear-tinted chips, list/board cap of three plus a caret overflow popover, and a + add flyout. Right-click a chip to recolor; that does not bump updatedAt. Type/status/priority also edit from peek chips with the same menu. Keyboard map lives on the Issues page (j/k, s/p/u/l/g, A queues agent.phase='queued' only, Y/N triage, C new issue, E expand); the page registers registerCommandSource('issues', Γ’β¬Β¦) while open (issues-commands.ts). Triage is a built-in view, not a status: unreviewed = source Γ’ΛΛ {crash, agent, github} and triagedAt unset (triage.ts). New user issues default to the backlog-role status and source: 'user'. Board column drop onto empty space writes status; drop onto a card sets parentId. No insert-line rank. Peek width is minmax(380px, var(--issues-peek-w)) (default 520px) on a wide Issues container, dragged from the peek's left edge and stored per workspace in localStorage minnow.issues.peekWidth. A header control opens a centered sheet over the Issues body with a dim scrim; first Escape or scrim click restores the docked peek. Compact @container issues (max-width: 900px) replaces the list with peek and hides the resizer and sheet control. Legacy #/bugs hashes redirect via resolveLegacyHash.
Issues windows: code-window-command.ts forwards seeded chats, files, linked chats/boards, and activity to the existing Code window for the workspace, or opens one. Electron queues commands until its renderer is ready. Files initializes the lazy file panel in dedicated Issues windows. Cached inactive app layers do not obscure native browser guests when returning to Code. Foreground issue sends persist an immutable issue snapshot on the user message so chat renders a dedicated ticket instead of the generated workflow prompt, including after reload or later issue edits. Ticket descriptions render sanitized Markdown in full, with responsive inline images; attachment display URLs refresh authentication and retain the source issue workspace when the chat uses a worktree. Issue saves serialize across windows using a shared Web Lock, merge each renderer's changes against its persisted baseline, and refresh on storage notifications (state-merge.ts); GitHub sync also uses a shared per-issue lock. Issue IDs use one number sequence per project key across all workspaces. Client and server parsers rekey duplicate legacy rows without dropping either card, and the serialized three-way merge rekeys simultaneous new cards with the same ID while preserving edits made during a save.
Portable issue categorization: src/issues/github-metadata.ts carries version-1 taxonomy rows (type, priority, status), stable project identity/name, workspace-scoped parent GitHub issue numbers, and the Minnow comment timeline in a trailing minnow-issue:v1 HTML comment in the GitHub issue body. This is a Minnow transport, not a mapping to native GitHub types/Projects/sub-issues/comments. Import restores missing taxonomy/project entries, then resolves parent links after all remote identities exist. Sync all orders parents before children. Comment additions/deletions and categorization edits participate in auto-sync and watermarks; rank, assignee, and chat links remain local. Legacy equal-content issues publish metadata on their next sync. Decoding removes repeated valid trailing transport blocks (the last metadata wins), accepts CRLF and trailing whitespace, and encoding replaces existing valid blocks. Loading linked local issues also strips leaked transport copies from descriptions without changing local fields or sync watermarks. Invalid/future blocks remain description text; over-limit bodies fail instead of truncating metadata. Whole-issue latest-change conflict resolution still applies to comments. Coverage: test/issues/github-metadata.test.mts.
Issues creation age: the trailing list column is Created, sorted by createdAt (newest first by default), with compact elapsed labels from age.ts and an exact creation timestamp on hover.
Issue images: descriptions accept image paste/drop, including unsaved new-issue drafts. Upload bytes stay under the attachment API; only attachment metadata and Markdown links are persisted. Creation waits for uploads and attaches draft metadata to the resulting issue. Browser-rendered attachment <img> / thumbnail URLs append the session token via issueAttachmentDisplayUrl / displayIssueAttachmentSrc (same pattern as tool screenshots) because <img> cannot send X-Minnow-Token; markdown persistence strips auth query params on round-trip. issue-tool-images.ts adds actual pixels to issue_get_state and issue_search results when attachment metadata is present; requesting description includes attachment metadata. Results cap at 8 images / 24 MB with an omission note, using the existing vision/text-only tool result handling.
Code sidebar vs desktop: the Code chat sidebar Issues button (btnAllBugs) embeds #issuesView into #chatArea inside the Code window (toggle / Back / Escape; same main-column overlay family as Code overview / Code map β see issues-page.ts). While embedded, issue detail opens in-place and does not rewrite the hash to #/app/issues/ISS-n (that would foreground the fullscreen Issues app). App rail, menubar app switcher, and #/app/issues still launch the fullscreen Issues app. The files toolbar also offers btnIssuesPanelToggle: issues-sidebar.ts lazily mounts a workspace-scoped list grouped by status in #issuesSidebarRoot, reusing the Issues appβs taxonomy icons, status/priority chips, label editor, and buildIssueRowMenuItems. The sidebar mounts the same editable detail view through createIssueDetailController with its own selection and editor lifecycle; properties, description, links, workflow, and comments stay available in Code. Related issues open in the same pane. Back restores list filters, scroll, and focus; Open in Issues hands the selected issue to the full app. file-sidebar-view.ts coordinates exclusivity with Files and Source Control. Quick capture splits title/body, persists drafts by normalized workspace in localStorage minnow.issues.sidebarDrafts, and hands drafts to the full form through QuickIssueFormSession callbacks. Cancel returns edits; Create clears the draft. Direct capture waits for persistence, retaining a failed-save issue ID for duplicate-free retries. View stays in the pane; the row menuβs Edit action navigates to the full Issues issue route.
| Concern | Location |
|---|---|
| Persist |
~/.minnow/issues/state.json (src/state/issues-store.ts); disk version stays 2 (ISSUES_COMPAT_VERSION) so older readers do not wipe the file; real revision is schemaRevision 3. Per-workspace project keys (MIN-12) via project-key.ts; projects[], views[], and labelCatalog[] (name β swatch) live on the same blob; Vite-only key minnow-issues-v1
|
| Taxonomy |
~/.minnow/issues/taxonomy.json (src/issues/taxonomy.ts, issues-taxonomy-store.ts); API GET/PUT /api/config/issues-taxonomy; Vite-only key minnow-issues-taxonomy-v1. Settings β Issues (settings-issues.ts, settings-issues.css) edits per-workspace Issue IDs (project key + next-id preview) and taxonomy types, statuses (workflow roles + board/closed flags), and priorities in emphasis panels with bordered tables. Type and status rows include an icon column (Flaticon Uicons via type-icons.ts, shared picker in issue-type-icon-picker.ts); type rows also have a color column (palette picker in issue-type-color-picker.ts, stored as color on the taxonomy item). Built-in types are bug, task, idea, note, feature, and improvement; existing catalogs receive the last two once via typeSeedRevision 2 (seedDefaultIssueTypes). List type chips render the glyph instead of a letter and tint from --issues-chip-color; status chips show the glyph beside the label. Deletes blocked when issues still reference an id. First paint is safe before loadIssuesFromStorage() finishes (MIN-660); GitHub import stays disabled until the store is ready. |
| Migration | First load with no issues file reads leftover bugs/state.json / minnow-bugs-v1 (leaves bugs file on disk). migrateLegacyBugBoardsFromChats folds any remaining chat.bugBoard cards, then strips them. |
| UI |
src/ui/issues-page.ts, chrome issues-chrome.ts, commands issues-commands.ts, detail issues-detail.ts (sticky identity + chips + workflow; empty secondary sections collapse to add-rows; Chats always lists chatIds plus boards on those sessions or boardChatId (title, Running/Done, mode, New/Existing, unlink without deleting; issues-chats-section.ts, issue-peek-chats.ts); peek resize + expand sheet in issues-detail-layout.ts), labels issues-labels-field.ts + issues-label-chip.ts (catalog tint, row cap of three, caret overflow popover, + add flyout that stays open after each Enter/comma so names can be added in a row until click-away or Escape), description WYSIWYG issue-editor.ts (empty cards seed an editable paragraph; nested lists, HTML, and other raw blocks use source textareas with verbatim serialization; flush on blur, Create, and peek remount so typed text is not dropped), sparkles expander thin controls issues-expand-controls.ts (lazy overlay/client issues-expand.ts; prompt in expand-issue.ts), drop targets issue-drop-target.ts (list rows, board cards, and detail accept capture drags, OS file attachments, and issue-on-issue parent drops; in-flight ids in issue-drag.ts because dragover cannot read custom MIME, and effectAllowed: all so Chromium will not cancel a list-row dropEffect: link or a board-column dropEffect: move), quick capture (menubar-capture.ts, Alt + C; drop sidebar chats or any text selection on the menubar capture button or Issues rail tile Γ’β¬β capture-drag.ts reads CHAT_DRAG_MIME and text/plain alongside file-tree and editor MIMEs; dismissed popovers persist title/chips/destination per workspace in issue-capture-draft.ts and the popover accepts further drops while open), Global quick capture (menubar button or Ctrl/Cmd + I) reuses the full New issue form without navigating away; Expand and Create saves and closes first, then expandCreatedIssueInBackground expands the saved card while preserving fields edited during generation. Captured workspace and links follow the issue. New issue workspace picker when header scope is All workspaces (issues-new-workspace-field.ts: Scratch + recent MRU from /api/workspace), file drawer issues-file-drawer.ts (fullscreen Issues Files toggle docks #fileSidebar on the right like Code), attachments issues-attachments-section.ts, styles issues.css (container-name: issues); deep link #/app/issues/<id>
|
| Tools |
issue_add / issue_update / issue_link / issue_get_state / issue_delete / issue_search / issue_comment / issue_assign / issue_unlink / issue_move (issue-tools-v2.ts + legacy issue-tools.ts). issue_link also accepts issue_refs (string id or { issue_id, kind?, note? }) with kinds related, blocks, blocked-by, duplicate-of, parent, sub-issue; links are bidirectional (inverse kind on the target card). Allowed in General, Build, Plan, Super Plan, and Debug (tool-groups.ts issues group). Plan/Super Plan chats default to the Planner work agent, whose allowedTools also includes the full issue_* set β the mode matrix alone is not enough, because send-path filtering intersects work-agent allowlists (chatToolDefinitionsForTurn). Retired bug_* tool names still execute via bug-board-tools.ts for older transcripts but are no longer exposed to models. |
| Workflows |
Send to chat offers General, Build, Plan, and Debug modes in the detail workflow toolbar and row menu, then the same composer run-target panel (current workspace name / Worktreeβ¦ / New worktree) so the new chat is attached before the seed (composer-run-target-menu.ts, issues-chat-run-target.ts); Send to board appears in the Plan section when planPath is set (src/chat/issues/pipeline.ts, workflow-seeds.ts, issues-workflow-menu.ts); Expand (sparkles on peek, board cards, list menu, E) rewrites title + description and proposes labels + taxonomy priority from the current card into a review overlay β no research, nothing saved until Apply. New issue β Expand fills the unsaved form from its current values; concurrent typing and closed forms are not overwritten. Triage Expand with agent still spawns shipped issue-writer to research the workspace; detail Open plan foregrounds Code in the window already on that folder (normalized path + viewContext) then opens the plan in the file viewer (openIssuePlanInEditor). If another window already has the folder, it focuses that view instead of retargeting this one. |
| Git | Branch issue/iss-n-<slug>, commit grep [ISS-n], PR via gh, GitHub URL chips (git-helpers.ts, git-actions.ts). Review PR starts a pr-reviewer sub-agent (run-pr-review.ts); results persist in ~/.minnow/reviews/state.json and render on Issues and Source Control (pr-review-panel.ts). GitHub sync (Settings β Issues): Off / Two-way mirror, plus optional Sync automatically (minnow.issues.github.auto). Header Sync all (visible in Two-way mirror) runs syncAllIssuesWithGithub for cards in the current Workspace scope (current workspace vs all workspaces) via issues-github-section.ts. Each manual pass first imports missing open and closed GitHub issues (up to 500 per workspace, using the existing forge list limit) into Triage; All workspaces discovers in the current workspace and other workspaces represented by local cards, skipping Scratch. Imported cards retain remote metadata and watermarks without redundant pushes; existing cards then sync with parents first. Imports capture the destination workspace before network calls and deduplicate GitHub numbers within that workspace. Linked-only passes skip discovery. Auto pushes title/description/labels/closed-state plus type/priority/status/project/parent/comments after a 1.5s debounce (peek or issue_*), creates newly added local issues on GitHub after the same debounce (no historical backfill), and on boot, wake, and every 5 minutes checks already-linked open issues plus closed issues whose last sync was at least 24 hours ago in the current workspace (skipping other projects and unassigned cards) even while the desktop shell is in the background (issues-github-auto.ts). Storage refresh/save merges also queue newly observed external agent or window writes in the current workspace, excluding acknowledged GitHub pulls/imports and local-only changes. Push and create send label names (addLabels / removeLabels on issueEdit); names that are not yet in the GitHub repo catalog are created first (gh label create, neutral color β Minnow swatches stay local). If the repo forbids creating labels, the issue still lands and the toast reports dropped names. Conflicts resolve automatically using the newest synced-field change (ties use GitHub), and background success stays quiet. github.localChangedAt tracks all synced content and categorization separately from local-only fields, so chat links, ranks, and assignee do not cause false Needs push. Equal-content sync refreshes the watermark. Peek Git section shows #n Β· synced β¦ / Needs push, Open (system browser), and Sync; unlinked + mirror shows Push to GitHub. Import issues from GitHub files Triage cards (source: 'github') via /api/git issueList (issues-github.ts, forge-issue-ops.js). Import/sync failures show Open or restart Minnow when the local backend is down and must not flip localServerAvailable (MIN-660) β that flag emptying the file tree until restart was the brick. |
| Plans |
documentation/plans/issues/<id>.md; board completion ? status review (board-review.ts). Dropping or capturing any executable plan markdown (documentation/plans/*.md, excluding references/ and verification/) onto an issue sets planPath via plan-attach.ts instead of listing it under Code links; detail code rows and the plan path row each have a remove control (unlink only, file stays on disk) |
Deleting a linked card asks whether to delete its GitHub issue too. Remember stores either choice in minnow.issues.github.deleteBehavior; Settings β Issues β GitHub can restore the prompt. GitHub deletion completes before the local card is removed (issues-delete.ts, issues-github.ts).
GitHub bulk sync performance (MIN-115): Sync all and background linked-only passes read exact linked identities in GraphQL batches of up to 50 through /api/git issueBatch (forge-issue-ops.js). Missing/deleted identities do not discard other batch results or local cards; other partial errors and truncated responses fail closed. Three concurrent issue workers retain per-issue Web Locks and serialized storage merge/save, with parents first and results reported in list order. Batch snapshots are discarded when a card's workspace, GitHub identity, or sync watermark changes during the fetch. Auto checks open issues every five minutes and unchanged closed issues once per 24 hours using the persisted github.syncedAt; local edits to closed issues still sync after the ordinary debounce. Pushes retain a fresh post-write read for the remote watermark. Disabling Auto, changing workspace, or entering cooldown stops queued background work. gh-requests.js shares in-flight PR list/detail reads and a bounded 25-second cache across renderers/worktrees for the same explicit repository and arguments; successful PR mutations and forge refresh invalidate it, including pending older reads. Source Control's five-second local git timer skips PR/CI views; its 25-second forge timer refreshes them. Rate-limit failures trigger one shared REST rate_limit probe and block the affected host's GraphQL or REST allowance until its reset plus one second; failed probes use a one-hour primary or one-minute secondary fallback. Absolute retry times survive error normalization. Background local edits are retained until retry, and manual bulk passes stop on exhaustion. Ordinary gh commands time out after 45 seconds, reset probes after at most 20 seconds, and forge readiness is cached per workspace for 60 seconds. Coverage: test/server/gh-requests.test.mjs, test/server/forge-issue-ops.test.mjs, test/issues/github-auto-sync.test.mts.
Renderer crash diagnostics can file Issues cards (type bug) when Settings ? Advanced ? Health & diagnostics ? File renderer errors to Issues is enabled (localStorage minnow.diagnostics.fileErrorsToIssues; default off). Errors still log locally and appear in the diagnostics viewer regardless. Chromiumβs ResizeObserver loop completed with undelivered notifications window error is ignored in src/boot/diagnostics.ts (not a crash). First-party observers that write layout (composer compact, Super Plan / Orchestrate rails, design overlay, preview instance host) defer those writes via src/lib/schedule-animation-frame.ts.
Availability: each app is core (always on when released; current app lists come from the generated registry inventory) or optional, plus a developer releaseState (released | hidden). User preferences store disabled optional ids in localStorage key minnow.os.disabledApps (src/os/app-preferences.ts). Missing key = all released optional apps enabled. App rail, menubar shortcuts, hash routes, notifications, and launch_minnow_app all consult the same selectors. Settings ? Apps uses src/os/app-picker-ui.ts: core apps collapse to a read-only ?Always included? line; optional apps use quiet toggle cards (dimmed when off, no accent wash when on) with Enable all / Disable all. When no optional apps are released yet, Settings shows the included core apps.
Local model shutdown: Electron starts model cleanup before waiting for renderer shutdown. Packaged Electron stops its in-process model serves directly; the development shell sends authenticated POST /api/models/shutdown to the separate tool server that owns the model processes. That route waits for termination, stops serves concurrently, and returns an error if a run cannot be stopped instead of recording it as stopped.
-
Built-in tools (counts in the generated inventory) β
src/tools/definitions.ts. For Orchestrate plans,check_plan, board creation, and resync sharevalidateBoardPlan:parsePlanchecks structure, thenvalidatePlanDependencieschecks missing edges against files in the active request workspace. All three return the same line-numbered repair hints; Plan mode and the Planner run the tool before completion. Build plans retain their sequential validator. V1 board tools (board_initand siblings) were deleted in MIN-715. -
Bundled slash skills (generated inventory above) β
src/skills/, manifest vianpm run prebuild; everything else installs from Skills Library -
Released apps and hidden apps derive from
src/os/app-registry.ts; see the generated inventory. -
Available and composer modes derive from the mode registry (generated inventory above) (persisted
desktop/emailremap to general) + work agents, sub-agents, orchestrator boards, Brain wiki
Verified issue follow-ups: Headless CLI and Scheduler use the shared server/runner/run-turn.js core through Node-safe adapters, retaining subprocess isolation for unattended jobs, bounded rounds, repeats and time, and shared context compaction. Scheduler admits oldest due jobs up to capacity without waiting for their completion. Brain hybrid retrieval scopes catalog metadata before reading bodies, with eight bounded readers; Brain Edit keeps session-only drafts across navigation and ignores obsolete load/save responses. Onboarding uses canonical POST /api/config/onboarding saves and renewable owner leases; failed saves retain progress and offer Retry. Git, LSP and terminal JSON uploads share an 8 MiB byte limit and reject disconnected uploads. Admitted Git subprocesses terminate their tree on disconnect; LSP cancels individual requests and diagnostic waits while preserving shared servers. Background terminal sessions retain their lifetime. MCP issue content writes advance the GitHub local-change watermark so mirror sync does not discard external edits or comments. Generation RAM budgets and replay failures are defined in server/generations/memory-limits.js; replay overflow terminates explicitly, and slow HTTP subscribers disconnect without truncating their stream.
Generation request lifetime: Terminal generations release their serialized input immediately on completion, error, or cancellation, before notifying subscribers. Only output and metadata remain for the existing replay retention window. Active requests stay available for upstream retries and fallback. This prevents completed tool rounds from accumulating full conversation copies and rejecting unrelated chats with HTTP 503. The shared generation budget is 512 MiB to accommodate concurrent chats and retained reply streams; individual requests and reply replay remain capped at 32 MiB each. Regression coverage: test/generations/memory-budgets.test.mjs.
Minnow/
+-- index.html # Vite shell; wiring via src/ui/shell-handlers.ts
+-- server.js # Dev: Vite + /api/*
+-- server/ # Config, tools, providers, generations, shared runner (MIN-698), appsβ¦
+-- electron/ # Desktop main/preload ? electron/dist/
+-- src/
Β¦ +-- main.ts # Boot: theme, OS shell, initApp
Β¦ +-- os/ # Minnow Shell, router, app rail
Β¦ +-- chat/ # Modes, prompts, plans listing, goal/loop, titles
Β¦ +-- tools/ # definitions, loop, client, permission gate
Β¦ +-- agents/ # Sub-agents, work agents, UI Designer
Β¦ +-- api/ # models, chat, generations, sse-parse
Β¦ +-- providers/ # Multi-provider store and fetch
Β¦ +-- state/ # Sessions, workspace, runs
Β¦ +-- ui/ # Views (settings, messages, file panel, apps)
Β¦ +-- skills/ # SKILL.md packs
Β¦ +-- styles/ # CSS; tokens in tokens.css only
+-- public/ # sw.js, icons, benchmark-packs
+-- test/ # Auto-discovered via test/run-all.mjs
+-- documentation/
Startup recovery: The inline minnowBootGuard in index.html provides reload controls even when the module bundle cannot load. startApp awaits initApp and reports rejected startup through minnow-boot-failed. The chrome deadline emits minnow-boot-stalled; it never signals chrome readiness or removes the companion connection screen. Critical loading CSS leaves #companionConnecting visible, with a retry button that preserves the saved device credential. Tests cover failed imports, slow startup followed by recovery, and companion status with the actual critical CSS.
Startup and /api/config/ping share ensureMinnowLayoutInitialized(): concurrent calls coalesce, successful initialization is cached for the resolved home, and failures remain retryable. resetMinnowHomeCache() invalidates it. Regular ensureMinnowLayout() callers still repair missing layout files; health probes avoid repeating that filesystem and Brain initialization sweep.
Override: MINNOW_HOME for tests/CI.
| Platform | Path |
|---|---|
| Linux / macOS | $HOME/.minnow |
| Windows | %USERPROFILE%\.minnow |
Secrets: AES-256-GCM at rest; key file ~/.minnow/.key (0o600). Rotating or deleting .key makes encrypted secrets unrecoverable.
Canonical session store (HTTP): sessions/sessions.db (SQLite) β GET/PUT still exchange the whole SessionState blob; the SPA flushes with PATCH when dirty sets are available (B.2). Current-schema hydration establishes trusted dirty sets immediately, so the first save excludes untouched chats and does not advance their revisions in another window. Startup repairs and code-change backfills mark only changed rows/scalars; legacy schema migrations retain the whole-state fallback. Dropped describe-only conflicts do not show the unsaved-chat warning. Chat/group/scalar normalization is shared in src/state/session-schema.mjs (normalizeChatRow / normalizeGroupRow / normalizeSessionScalars) β imported by server validators and client ensureChatShape (thin wrapper; no client twin). There is no MAX_CHATS hard-trim on save. Kitchen-sink contract: test/fixtures/migration/kitchen-sink-sessions-state.json.
Concurrent session writers (MIN-65): SQLite summaries and full GET return chatRevisions; each SPA PATCH/PUT carries chatBaseRevisions for changed or deleted chats. The server stamps each committed chat write and deletion (including prune tombstones) and rejects a stale chat base with 409 plus conflictingChatIds. A global baseRevision conflict may be retried for disjoint chats, but a same-chat conflict stops saving in that viewer and leaves its unsaved edits in memory until the user copies them and reloads. Lazy history hydration retains the summary's chat revision because it fetches messages without refreshing chat metadata; a concurrent metadata edit must still conflict at save. Shutdown beacons also carry per-chat bases. Older clients that omit chat bases and the JSON-store rollback mode do not provide same-chat conflict protection; use one writer per chat with those clients.
Multi-chat runtime (MIN-584): Dirty-tracking shadow capture (JSON.stringify of every hydrated chat) runs only when the DEV verifier is on. scheduleSaveSessions is a 300ms quiet debounce with a 2s max-wait so overlapping streams cannot postpone a flush forever. notifyChatStreamActivity is a no-op when nothing is subscribed. Chat scroll root is cached per animation frame. Windows WSL/powershell probes are warmed asynchronously after boot (warmupTerminalPlatformCaches in server/terminal-runner.js) so the first execute_command does not block the server event loop.
Sessions SQLite (Phase A ? C.2): On first open, legacy sessions/state.json is imported once and renamed to state.json.migrated. Whole-blob R/W lives in server/config/sessions-repo.js (the persistence seam; optimistic concurrency is live β session_meta.revision bumps on every committed write, summaries return it, and PUT/PATCH carrying a stale baseRevision get 409 with the current revision so the client re-hydrates instead of clobbering ? see plans/sessions-sqlite-migration.md). terminalHistory is server-owned on PUT/PATCH (only appendTerminalRun writes it). PATCH /api/config/sessions accepts { baseVersion, chats?, deleteChatIds?, groups?, deleteGroupIds?, scalars? } ? absent keys mean unchanged; deletes are explicit id lists; dirty chats/groups are full objects. Implemented via patchResource ? patchSessionState. POST on the same path is a sendBeacon alias for PATCH (beacons cannot PATCH). Headless src/headless/persist-chat.ts uses PATCH (no GET-splice-PUT). B.2 SPA flush: saveSessionsNow uses PATCH when sessionsClientPatchEnabled (default ON) and dirty sets are trusted; full PUT on the first save after load or after a dirty-tracking verifier miss. Flushes are serialized (one in-flight network write); mid-flight dirty work sets a follow-up queue. Dirty sets clear only when a successful PATCH/PUT finishes with an unchanged sessionDirtyEpoch ? so a slow baseline PUT cannot clear a delete that landed during the request and resurrect the chat. removeChatById flushes immediately (no debounce). Electron confirm: installAppDialogs makes sync window.confirm() always return false (native dialogs break Electron input). Chat/group delete and Brain memory delete use await appConfirm() from src/ui/app-dialog.ts. Chat/group delete (MIN-509): removeChatById in src/state/sessions.ts records deletedChatIds, purges stale lastActiveChatIdByWorkspace / lastActiveChatIdByApp entries, and when the active chat is removed picks the next listed chat in the same workspace ? Unassigned rows (workspacePath === '') use getUnassignedChats, not getSidebarListedChatsForWorkspace (empty key returns none). Sidebar context-menu deletes call refreshSessionListUIs in src/ui/sidebar.ts so Code sidebar, session rail, and Chat app rail all repaint. Shutdown: serialize the delta; if < 60 KiB use navigator.sendBeacon (POST alias); else split into one beacon per dirty chat, falling back to a keepalive whole-blob PUT. Fetch keepalive bodies are capped at 64 KiB and oversized ones are dropped silently, so putSessionsKeepalive returns whether it actually dispatched and flushSessionsOnShutdown only reports clearedOk for writes that left the process β reporting an undispatched PUT as success used to drop the dirty markers for work the browser had already discarded. MIN-408: no PATCH/PUT before sessionsHydratedFromServer. Dev builds compare chats against a shadow copy at flush and console.warn unmarked mutations (forces PUT fallback). Backup: a rotating SQLite snapshot (server/config/sessions-snapshot.js) writes sessions/snapshots/sessions-<ISO>.db via db.backup() (chunked, WAL-consistent, no whole-store string in the heap) from a post-boot setImmediate in server/runtime/bootstrap.js β keep newest 3, skip when the newest is under 12 h old or free disk is under 2Γ the store, and pragma quick_check the copy before keeping it (a snapshot of an already-corrupt DB looks like a valid restore source). Recovery order on a failed quick_check in getSessionsDb(): quarantine β restore the newest verified snapshot (stamps dbRestoredFromSnapshotAt / dbRestoredFromSnapshotFile) β only if none is usable, importJsonSessionsIfNeeded(db, { recovery: true }) from state.json.migrated. The old size-capped JSON mirror is gone (it skipped every flush above 128 MB and had no production shutdown hook); an existing state.json.backup is left on disk unread. Rollback: MINNOW_SESSIONS_STORE=json. Export: POST /api/config/sessions/export-json. Hot server consumers use indexed SQLite point lookups ? see plans/sessions-sqlite-migration.md.
Lazy history (C.2, flag ON): Boot uses GET /api/config/sessions/summaries?workspace=? ? chats omit history, include denormalized messageCount / lastMessagePreview, plus meta_json cold fields and non-message children (runs, subAgentRuns, activeLoops, terminalHistory). Client flag sessionsLazyHistoryEnabled defaults ON; ensureChatHistoryLoaded (idempotent, in-flight dedupe) hydrates full GET /api/config/sessions/history/:chatId on switch / activate / workspace change / before turn mutate. Inflated chats keep messageCount so isSidebarListedChat / session rail can list unloaded chats (history: [] alone would hide them). messageCount is on the shared CHAT_PASSTHROUGH_KEYS allowlist and is re-applied after parseSessionStateFromJson in sessionStateFromSummaries ? dropping it made every unloaded chat look empty (blank rails after reload) and let pruneEphemeralEmptyChats delete real chats before the first full PUT. Unloaded rows with missing messageCount stay listable as a fail-safe; explicit 0 still hides ephemeral empties. Sidebar listing/dot state uses getChatMessageCount / skips chatAwaitingUserInputTool until history loads β never reads chat.history on the summary boot path. Code-change file counts (getPerFileChangeSummary / runHadCodeChanges in code-change-ledger.ts) also skip unloaded chats so sidebar +/β can use persisted codeChangeTotals without tripping the DEV trap (MIN-734). First-turn gates (isFirstUserMessagePending, context ring getContextBudget) also use messageCount / ensureChatHistoryLoaded so token estimates and context-document preview fetches do not treat every unloaded chat as an empty first turn. UI switch paths (switchChat, desktop/Chat-app activate, onWorkspaceChanged) await hydrate before painting ? otherwise empty-state landings stick after restart. When history is still unloaded, switchChat / activateChatById first paint paintChatTranscriptHistoryPending synchronously (clears stale bubbles, shows a skeleton) then await hydrate; chats with historyLoaded !== false skip the GET entirely. Sidebar chat rows prefetch history after a 150 ms pointer dwell (cancelled on pointerleave) or on focus (deduped via historyLoadInflight). Wire saves (chatForSessionsWire / sessionStateForSessionsWire in src/state/sessions.ts) omit the history key for chats with historyLoaded === false so PATCH/PUT cannot wipe stored messages; server patchSessionState / writeWholeSessionState skip syncMessages when history is absent on the wire object, and refuse to create a chat row from such a write (a history-omitting upsert of a missing chat resurrects it permanently empty). saveSessionsNow also refuses to full-PUT while any chat is unhydrated: it marks the whole session dirty and PATCHes instead, since a PUT claims to describe transcripts this client never loaded. Never page history into archive or turn-run absolute-index consumers. FTS5 search: GET /api/config/sessions/search?q= (messages_fts); UI uses server FTS in server mode and the pure scorer in src/chat/chat-search.ts for localStorage / JSON-store fallback. DEV trap: first history read while unloaded console.errors with stack. requireHistory(chat) throws if unloaded. Task history trim removed ? unused chats stay unloaded. OS shell chrome (syncLoopActiveHint via page-bridge) no-ops until sessionState is loaded ? do not call getActiveChat() before loadSessionsFromStorage.
Deletion is never inferred from absence (2026-08-22): writeWholeSessionState upserts only. Chats vanish from the store through explicit deleteChatIds (PATCH, or the same key alongside a PUT body) or an opt-in pruneMissingChats flag the client never sets β a prune that would remove more than half of at least five stored chats throws 409. The old DELETE FROM chats WHERE id NOT IN (...) let a lazily-booted or degraded client delete every chat it had not listed; the surviving window then re-upserted those ids without a history key, so they came back with metadata and runs but no messages. deleteChatRows also sweeps messages_fts by hand β FTS5 has no foreign key, so a bare chat delete stranded its index rows (12,416 of them in the incident). syncMessages warns whenever a transcript shrinks. See archive/session-history-loss-on-restart.md and test/config/sessions-history-loss.test.js.
Hydrate races and history reads: materializeChatHistory splices any locally-appended tail onto the fetched transcript instead of replacing the array β a send landing mid-hydrate (Continue on a chat a lazy boot never loaded) otherwise left the bubble on screen and the row out of what buildApiMessages replays. A degraded parseSessionStateFromJson (its one-empty-chat fallback) no longer counts as sessionsHydratedFromServer, and a failed active-chat history GET no longer discards the whole parsed session. forkFromUserIndex awaits ensureChatHistoryLoaded before computing absolute indices, truncateChatHistory returns history_not_loaded rather than slicing a placeholder, boot resumes only the active chat (after hydrating it), and a GENERATION_LOST_ON_RESTART failure leaves history untouched β nothing was produced, so rolling back could only destroy rows.
Test teardown (sessions.db): getSessionsDb() caches better-sqlite3 handles. Suites that open the store (e.g. initBrainApi ? readAllChatIds, scheduler resolveJobRunModel) must call closeSessionsDb() from server/config/sessions-db.js in after / afterEach before fs.rm / rmTestHome, or Windows teardown hits EBUSY on sessions.db (+ -wal/-shm).
Full directory map: manual/reference/configuration.md. Notable paths:
| Path | Purpose |
|---|---|
config.json |
Workspace, features, voice, synthesis, tool security, fallbacks |
default-model.json |
Global top-bar model selection (GET/PUT /api/config/default-model), shared across workspaces and loopback ports. Browser storage is a cache and migration source. Catalog refreshes and model loads preserve an explicit choice, including while its provider is unavailable; per-chat overrides remain independent. |
auth/devices.json |
Named LAN companion devices; stores SHA-256 token hashes only (pairing challenges are memory-only) |
tools.json |
Per-tool permissions (full / ask / off), session cache, and tool result size (toolOutput.enabled / toolOutput.maxChars) |
providers/<id>/ |
profile.json, encrypted secrets.json, capabilities.json
|
prompts/, prompt-configs/, profiles/
|
User prompt overrides and setup bundles |
work-agents.json, sub-agents.json
|
Agent overrides and sub-agent types |
rules.json |
Grouped user rules (v2: enable flags, groups, per-rule text); legacy v1 text migrates on read/write. Settings β Rules (settings-rules.ts) adds/edits rules per group and deletes empty groups only (removeUserRuleGroup in user-rules.ts) β a group that still has rules is blocked with a count; the last remaining group is kept because an empty groups list is rewritten to default General on save |
brain/ |
Wiki pages, vectors, code index DBs, proposals |
skills/, skills.json
|
User skills and enable flags |
scheduler.json |
Scheduler jobs |
issues/state.json |
Issues app store (MIN-261); migrates from leftover bugs/state.json once |
issues/taxonomy.json |
Issues types / statuses / priorities catalog (Settings ? Issues); workflows resolve status roles at runtime |
reviews/state.json |
In-app PR reviews (GET/PUT /api/config/reviews, pr-review-store.ts) |
appearance.json |
Theme family/mode, custom colors, and fonts (GET/PUT /api/config/appearance). Chromium localStorage is a FOUC cache only β packaged Electron can bind a new loopback port per launch, which would otherwise look like a new origin and drop the theme. |
bugs/state.json |
Legacy bug tracker blob ? read-only migration source; left on disk after import |
boards/<id>/ |
V2 board journals (journal.jsonl + snapshot). Namespace 'boards' of the generic store (journal-store.js); the boards binding keeps this exact path so a journal written before P8-B still loads |
agents/<parentChatId>/ |
Sub-agent journals (journal.jsonl) plus lossy attempt transcripts under attempts/ (P9-D JSONL, same recorder as boards). Namespace 'agents' of the same store. The graph that folds this journal is server/sub-agents/ (P8-C / MIN-756). Pending vs delivered parent completions are a fold over result.delivered (P8-E / MIN-758) so MIN-639 survives restart. Served over /api/agents/* (P8-F / MIN-759): spawn/cancel are POSTs; POST /api/agents/cancel?parentChatId=β¦ atomically stops a chat's children; one multiplexed GET /api/agents/events?parentChatId=β¦ SSE carries every live child without exhausting the browser HTTP connection pool; GET /api/agents/:runId/transcript is the drawer hydrate; the renderer is a view of derived state |
compare/, benchmarks/, evals/
|
Compare history, bench runs, eval harness |
backup.json |
Backup category selection, scheduled-snapshot settings and state; the snapshot passphrase is a secret-box envelope |
restore-pending.json, restore-staging/<id>/, pre-restore/<id>/, restore-last.json
|
Staged-restore marker, the unpacked backup awaiting a restart, the data a restore set aside, and the record of the last restore (see Backup and restore) |
run/host.json |
Pid of the host that has this home open, so minnow restore knows whether to apply now or at the next start |
Sub-agent summary metrics (MIN-521): compact cards use actual transcript tool-call totals, preserving saved totals when session transcripts are capped. The durable run fold retains the latest settlement event timestamp as endedAt; completed summaries show elapsed wall time after reload. Legacy records without an end timestamp omit duration.
Sub-agent live transport: renderer viewers use authenticated WebSockets at /api/agents/ws?runId=... (sub-agent-stream.ts, ws.js), keeping long-lived agent streams out of Chromium's shared six-connection HTTP/1.1 pool across windows. The SSE endpoint remains available; both transports share snapshot, journal, live, error, delivery, and terminal cleanup logic. WebSocket reconnects request a fresh fold. The drawer paints cached runs before refreshing and ignores stale open requests after close or a newer selection.
Vite-only (npm run dev): falls back to localStorage for sessions (minnow-sessions-v1) and tools (minnow.tools); server features disabled.
Agent Browser action references: clicks and fills retain the latest snapshot's backend node identities, allowing multiple calls from one snapshot to queue on the tab. Document replacement clears references; detached elements are rejected. Screenshot tool results include image pixels in the next completion (after the entire tool-result batch); explicit negative vision capability produces a text explanation instead. The dedicated viewer initializes saved appearance, custom colors, and fonts before mounting its chrome.
One-file backups of the home, scheduled snapshots, and a restore that never overwrites in place. Server: server/backup/. UI: Settings β General β Backup and restore (src/ui/settings-backup.ts) and the shared restore flow (src/ui/backup-restore-panel.ts), which the setup wizard's welcome step also mounts ("Restore from a backup"). CLI: minnow backup / minnow restore (cli.js). User manual: manual/reference/backup-and-restore.md.
| Module | Role |
|---|---|
catalog.js |
The eleven categories and their roots (top-level home entries, never nested), exclude / omit subpaths, NEVER_BACKED_UP, credential path patterns. Pure data. |
plan.js |
Walks the roots (no symlinks), tags credential files, records which JSON files need plaintext secrets blanked, fingerprints the result for skip-if-unchanged. |
format.js |
The .mnbak container, streamed both ways. |
export.js |
createBackup() β safe against a running server. |
restore.js |
inspectBackup() (header only), stageRestore(), cancel / retry / undo / discard. |
restore-apply.js |
applyPendingRestore() β the boot-time swap and its rollback. |
settings.js, schedule.js, jobs.js
|
backup.json, the snapshot loop and retention, and the one-job-at-a-time tracker the UI polls. |
middleware.js |
/api/backup/*, host session only (a paired LAN device gets 403). |
What is backed up is opt-in by catalog. The home on a working machine is tens of gigabytes of worktrees, browser profiles, runtimes, models and logs around under one gigabyte of data that matters, so the catalog lists what to keep rather than what to skip. A new top-level home entry must be added to a category or to NEVER_BACKED_UP; test/backup/catalog.test.mjs scans the server for getMinnowHome(), '<name>' and fails on an unclassified one.
Container. MAGIC | u32 headerLen | header JSON | payload. The header (categories, roots, totals, app version, KDF parameters) is always readable β it is the restore preview. The payload is a gzip stream of u32 metaLen | meta | data | sha256(data) entries closed by a totals record. With a passphrase the gzip stream is cut into 64 KiB frames sealed with AES-256-GCM under an scrypt key (N=2^17, r=8, p=1); the nonce is a per-archive prefix plus the frame counter, and the header digest plus a final-frame flag are bound in as AAD, so an edited header, a reordered frame and a truncated file all fail. KDF parameters read from a header are bounds-checked before use.
Live-safe export. SQLite stores (sessions.db, activity databases) are copied with db.backup() to a temp file, quick_checked, then archived and marked q: 1; -wal / -shm are never archived. Files up to 4 MB are read whole so the recorded length is exact; larger files are read from one open handle up to the size they had when opened.
Secrets policy. .key and everything encrypted under it form the credentials category and are written only into passphrase-protected archives. A file counts as a credential if its path matches a credential pattern or it is JSON containing a secret-box envelope β so scheduler.json (encrypted prompts) and backup.json with a saved passphrase ride with the key. Selecting credentials collects those files from every secret-holding root, selected or not. In any archive without credentials, plaintext secrets in config JSON are blanked (search.json / tools.json keys, MCP env and headers, any string under a key ending in apiKey / secret / token / password β¦) and the blanked paths are recorded in the header.
Restore is staged, then swapped at boot. stageRestore() extracts into restore-staging/<id>/tree while Minnow runs β entry paths are validated, files outside known roots are skipped, every checksum and every q: 1 database is verified β and writes restore-pending.json. applyPendingRestore() runs first thing in both hosts (top of server.js main(), bootstrapInner() in electron/main.ts, and again as a backstop in bootstrapMinnowRuntime()): each selected root is renamed into pre-restore/<id>/ and the staged root renamed into place. All moves are same-volume renames kept in a journal, so a failure unwinds to the previous home; the marker records the error, retries on the next two starts, then sets failed. restore-apply.js must stay free of store imports β it runs before the config cache, the sessions DB and the secret box may read anything.
After each root swap, things the backup deliberately lacks are carried over from the outgoing tree: exclude subpaths (brain/code, research/cache) and β when the restore brings no credentials β credential files, provided their folder still exists; blanked plaintext secrets are refilled from the file being replaced. omit subpaths (sessions/snapshots, state.json.migrated) are neither archived nor carried: they are recovery copies of the data being replaced and would resurface as the wrong timeline. Credential files whose root is not part of the restore are overlaid individually. When the restored .key differs from the live one, encrypted files the restore does not replace are parked too (setAside). Undo is the same swap in reverse, driven by restore-last.json.
Scheduled snapshots run on their own unref'd 60 s timer started from bootstrapMinnowRuntime(), not as Scheduler jobs: a job is a headless agent run that needs a model and the CLI, and a snapshot needs neither. The loop waits three minutes after process start, skips a run whose plan fingerprint is unchanged, rotates only minnow-snapshot-*.mnbak files to the newest N, and on failure retries after 1 h, then 6 h, then the interval. Failures are enqueued on the Scheduler notification queue with jobId: 'minnow-backup'; notifications-poll.ts routes those to Settings.
API. GET /status, GET /sizes, PUT /settings, POST /export, POST /snapshot, GET /jobs/:id, POST /list, POST /inspect, POST /restore, and POST /restore/{cancel,retry,undo,discard-previous} under /api/backup. Long operations return 202 with a job the page polls. Passphrases travel only in request bodies to the loopback host and are never returned.
Tests: npm run test:backup β round trip into an empty home through the real stores, the plaintext no-secrets proof over the decompressed payload, tamper / truncation / path-traversal refusal, failure unwind, undo, key change, snapshots, CLI and API; UI in test/ui/settings-backup.test.mts.
MCP hub settings: Settings β Integrations β MCP hub (src/ui/settings-mcp-hub.ts, searchable mcp-hub section) checks authenticated /api/mcp/hub/info and builds reusable HTTP or local stdio client configurations without a fixed workspace. Setup works before opening a project folder. Metadata returns the live tool catalog and source CLI availability without returning a token. HTTP defaults to named persistent connections with one-time token display, copying, listing, creation/last-used metadata, replacement and revocation. The secret stays in the current view only; refresh, leaving, or Hide token clears it. Legacy host-session configuration remains selectable and masked until copied. Local command (stdio) remains visible in packaged and remote contexts with an unavailable explanation; the current bridge requires a source checkout, installed dependencies and Node.js. Clipboard failures, stale workspaces, loading and unavailable states have inline feedback.
External MCP hub: server/mcp-hub/ exposes the curated Issues, Brain and manual tool catalog over stateless Streamable HTTP at /api/mcp/hub, behind the shared auth/workspace gates in both hosts. Discovery works without a workspace. Tool calls accept workspace_path from the requesting agent, falling back to an explicit transport workspace header; absent context never selects the active window. Each call validates an absolute existing allowed directory and runs Issues and Brain under that requestβs workspace ALS context. Issues are scoped to that workspace; Brain retains shared pages and workspace-aware search. server/auth/mcp-store.js stores SHA-256 credential digests and metadata atomically in ~/.minnow/auth/mcp-connections.json with restrictive Unix creation permissions. New credentials use workspace: null and retain a saved read/write level across allowed workspaces. Legacy credentials retain their canonical real workspace restriction, including for explicit tool workspace arguments. The HTTP auth gate accepts these tokens only on the exact /api/mcp/hub path, never general APIs or WebSockets; each request rereads the store for immediate revocation and replacement, updates last use, and applies saved read-only access regardless of URL flags. Host-only GET/POST/DELETE /api/mcp/hub/connections lists/creates/replaces/revokes connections (agent-scoped records are visible across workspaces); POST defaults to agent scope; workspaceScope: workspace explicitly creates a legacy folder-bound record. POST with replaceId rotates a secret without changing scope or access, returning the secret once with no-store caching. Existing session and device authentication remains compatible. node bin/minnow.mjs mcp bridges stdio clients to the running loopback host using the current session-token file. It resolves workspace per call from workspace_path, a fresh client roots request (exactly one file URI), or the agent launch directory when roots are unsupported; multiple/empty roots require explicit tool context. Legacy --workspace remains a fallback for clients without roots. Stateless HTTP clients supply workspace_path directly; --base-url selects another port and --read-only omits/rejects writes (?readOnly=1 over HTTP). No shell/file/plugin dispatch is exposed. Issues mutations and renderer three-way merge saves share updateIssuesResource serialization in server/config/store.js; the merge implementation lives in src/lib/merge-issues-state.mjs. Visible renderers poll Issues every five seconds with a conditional GET: GET /api/config/issues returns a content-hash ETag, and an unchanged store answers 304, so the poll downloads and merges nothing (getIssuesIfChanged; the remembered tag is keyed to the exact persistedIssuesBase object it produced, so any load or save invalidates it). Brain page mutations, catalog rebuilds, and log appends serialize through shared store queues. Protocol, restart, auth, scope, revocation, concurrent writes and stdio integration coverage lives in test/mcp/hub.test.mjs; cross-process persistence and canonical-path isolation in test/security/mcp-credentials.test.mjs; settings lifecycle in test/ui/settings-mcp-hub.test.mts. Setup: MCP hub.
Board reports offer one Clean up action with a deletion notice and a three-stage progress bar. The worktree cleanup operation supports checkOnly and protectDirty: inspection blocks the whole cleanup when any worktree has tracked or untracked changes, and protected deletion uses non-forced Git removal to recheck safety at deletion time. /api/worktree operation cleanup_branches enumerates local refs under the exact minnow/board/<boardId>/ prefix even when worktree folders are gone. It removes only branches merged into the current workspace HEAD using Git's safe deletion, retains checked-out or unmerged branches, and returns per-branch reasons. Remote branches are untouched.
Orchestrator merge retries preserve the merge failure summary in the merge.conflicted journal event and derived merge attempt. The returning builder's rebase seed includes that diagnostic, conflicted paths, and explicit instructions to repair integration before reporting pass, including failures with no conflicted paths.
Shared tool catalog: server/tools/builtin-catalog.js owns built-in metadata and full parameter schemas; src/tools/definitions.ts retains the public TypeScript types and re-exports the catalog. Board and sub-agent effectors resolve permitted ids through headless-tool-defs.js, preserving isolated agent-browser schemas and supplying server search endpoint schemas. Missing definitions fail explicitly instead of advertising name-only stubs. Lazy discovery receives the same complete schemas.
Lazy tool schemas: product chat, boards and sub-agents pass the persisted tools.json.lazyTools
setting (default true) into runTurn. Settings β Integrations β Tools β Load tool schemas
on demand switches to the full catalog when off; changes apply to new turns/attempts.
server/runner/lazy-tools.js preloads core file/shell tools, apply_patch, git status/diff, diagnostics, code navigation, Issues, the turn-cleanup tools, and the small browser verification set, and
injected report tools from the caller-filtered catalog, and supplies search_tools when
deferred tools exist. Search loads at most five schemas for subsequent requests in that
runner invocation. search_tools({ list_only: true }) instead returns all permitted catalog
names, sorted and without loading schemas; no query is required. Results contain names only; context reserves are recalculated as schemas
load. Unloaded calls are rejected before dispatch, and discovery never expands permissions.
Loaded state resets on new/resumed runner invocations. The low-level runTurn option is
opt-in for API compatibility (Phase 6 signature addition: lazyTools?: boolean). The built-in
catalog is unchanged; discovery is handled by the runner. See the
design.
Efficient coding loop (chats and boards): shared chat tool-usage guidance and board Builder prompts favor focused reads, independent-call batching, and coherent multi-file patches. Builders check affected behavior after a coherent edit; per-task Testers run the Test spec plus risk-proportionate checks. The final integration ladder still runs the full checks. Explicit required tests are never skipped.
Board CLI usage diagnostics: round_end events retain the shared runner's usage breakdown and runtime.minnow_cli metadata in per-attempt transcripts, including available native cost, continuation and rebuild reason. High-frequency stream frames remain omitted. CLI handoffs can split several tools from one native inference into multiple Minnow rounds, so round counts are not native request counts. Cached input, newly created cache and output remain separate; none measures subscription quota. Rejected board reports include the complete role-specific JSON schema in the first error, preserving strict validation while avoiding one retry per missing field.
Build-mode chat Builders and board Builders use shared advisory investigation checkpoints after six calls and then every 12 evidence-gathering calls. Changed probe arguments do not evade the count. A successful edit or user answer resets it. The checkpoints never abort a turn or prevent an edit; builders can continue working or report a genuine blocker. Read-only reviewers/testers are unaffected. The reminder interval is overridable through TurnLimits.
A first build checkpoint at six calls asks for labeled Hypothesis:, Next edit:, and Acceptance check: lines. Shared full/lite chat guidance and board Builder prompts request these after the first focused read batch, with verification tied to the requested behavior. Every four browser probes or after three consecutive probe failures, an advisory checkpoint requests acceptance evidence and a narrower next check. Probes remain available when required to finish verification. This does not waive required tests or authorize a pass with unverified behavior. Board attempts (Builder and Tester) also get a batching checkpoint after four single-call lookup rounds in a row, repeating every ten, and Testers get verdict checkpoints at 30 rounds and from 45 every ten (round-shape.js, limits.batchGuard / limits.verdictRounds); like the build checkpoint, they ride on a tool result and never deny a call.
Compaction retains bounded, historical source excerpts (up to three per file) with recall row references, and up to 12 explicitly labeled assistant findings/check results. Claims remain labeled as claims, and recorded file writes invalidate that file's observations. Elided read results also retain a small declaration/range excerpt instead of only a first-line marker. Existing summary token budgets still apply; no additional model calls are used. Per-round chat MCP discovery uses a 30-second cache with in-flight deduplication; settings-triggered refreshes bypass the freshness window. Mode/tool permission filtering remains live every round.
The shared runner emits payload-free runner_timing events for setup, request headers, model rounds, tools/batches, configuration refresh, transcript synchronization, between-round gaps, and total duration. Boards retain them in attempt transcripts; regular chats retain the latest turn's stage totals and up to 256 events in runnerTiming session metadata. They are not model context. Timings overlap (for example, parallel tools and their batch); do not sum stages to infer wall time.
Context budgeting uses the model's full context window by default, with tool schemas reserved inside that budget and normal compaction watermarks applied. An optional defaultContextCompaction.workingContextTokens in sub-agent configuration or Settings β Agents β Context policy can lower the ceiling; omission or 0 leaves the model window in control. When the model capacity is unknown and no cap is set, automatic trimming is skipped. On resume, an automatic checkpoint from a smaller previous window is reopened from persisted transcript rows when the full history fits below the current high-water mark; manual and provider-overflow checkpoints remain in place. The shared policy covers chats and board runners. Repeated Update sections for one file in apply_patch are applied sequentially to a staged result and validated before any file is written.
Multi-file edits: apply_patch accepts Begin/End Patch blocks with Add/Update/Delete File, optional Move to, and context hunks. The server validates all paths and hunks before writing, rejects ambiguous matches and symlink traversal, preserves existing EOLs, and attempts rollback on write failures. It participates in workspace approvals, file-tree/cache invalidation, activity stats, and compaction history. It is unavailable to Plan and verifier roles. Existing chat editing setups without a saved patch permission get Ask, not unrestricted permission; explicit choices are preserved. The shipped catalog now contains 104 tools.
Output defaults: ordinary tool calls return at most 40,000 characters (or a lower configured ceiling). Explicit max_output_chars can request more up to the saved ceiling; full_result remains the opt-out. File reads default to 300 lines and a 32,000-character window, with continuation offsets. Existing saved ceilings are preserved.
Context-window parity: src/lib/context-length.mjs and known-context-windows.mjs supply shared chat/server resolution. Server runners consult live local serves first, then a provider-scoped normalized catalog (60-second, in-flight-deduplicated cache), persisted capabilities, and known-model fallback. Loaded runtime limits override training maxima; newer catalog metadata overrides stale catalog-sourced observations. Missing/invalid metadata stays unknown for unrecognized models. Chat resolution uses the supplied chat's provider, not the foreground chat's provider. This fixes null board limits when a model such as DeepSeek resolves through the known-model fallback in chat.
Hosted provider model lists that omit a context limit are enriched from the matching models.dev provider catalog by exact model id. OpenCode Zen and Go use separate catalogs; Go's Muse Spark Contributor models resolve to 1,048,576 tokens. The catalog is fetched once for concurrent requests and refreshed daily. Provider-reported limits take priority outside OpenCode; unknown custom endpoints and unmatched models remain unknown.
Browser / Electron (same origin, default :9473)
+- GET /api/config/ping, /api/tools/ping, /api/memory/ping, /api/brain/ping
+- GET/PUT /api/config/* ? ~/.minnow JSON
+- POST /api/tools ? { name, args, modeId? } ? { result }
+- POST /api/generations ? backend-owned SSE streams
+- /api/boards/*, /api/agents/* ? journal-backed board / sub-agent views
+- /api/browser-agent/* ? isolated Agent Browser tabs, frames, and runtime guides
+- /api/providers/*, /api/terminal/*, /api/brain/*, /api/product-wiki/*, ?
+- Vite SPA
Auth: The local host uses a per-boot token in ~/.minnow/session-token; request-aware SPA serving injects it only when both the socket peer and Host header are loopback, never into HTML served to a LAN peer (server/runtime/spa-auth-html.js). Extensionless client routes (e.g. /settings/general) receive index.html, but Vite dev internals (/@vite/*, /@fs/*, /@id/*) are excluded so module scripts are not replaced with the SPA shell. Named LAN companions pair through a five-minute, single-use 6-digit code (QR link or manual entry) and receive a revocable minnow_device_* token; only its SHA-256 hash is stored in ~/.minnow/auth/devices.json (server/auth/). The central gate accepts host or active device credentials from X-Minnow-Token / ?token=, while device management remains host-only (server/runtime/auth-middleware.js). POST /api/auth/pair is the sole unauthenticated API operation and remains Host-validated, LAN-mode-only, private-client-only, throttled, and same-origin. No blanket CORS.
Path policy: Default workspace-only via resolveSafePath() (server/runtime/path-access.js). Full disk when toolSecurity.filesystemAccess is full (Settings ? General ? Filesystem access) or TOOLS_ALLOW_ALL_PATHS=1.
LAN companion (MIN-393): Opt in with MINNOW_NETWORK=lan or Settings ? General ? Network access, restart, then create a named pairing QR in the same Settings group. Both the Vite host and packaged Electron read this mode at boot; packaged Electron binds 0.0.0.0 in LAN mode and 127.0.0.1 in local mode while keeping the desktop window on the loopback URL. A paired browser keeps the workspace shell and bottom app rail on phones; viewport changes preserve the active route. The shared Code composer owns mode selection, without a companion-wide DOM observer. Device credentials persist across workspace switches and host restarts, using localStorage or a first-party cookie; an existing credential takes precedence over a reused pairing link. Revocation takes effect on the next request. All paired viewports probe authenticated GET /api/auth/session every five seconds and on network/visibility/pageshow changes (src/companion/connection.ts); probes have a four-second timeout and never overlap. Host outages show a reconnect banner and retry without deleting pairing; boot waits for authorization before loading server-backed state and keeps a connection screen visible until coherent chrome is ready. Wake and online events abort suspended probes and immediately retry without overlapping requests. Browser UUIDs use the LAN-safe shared random-id helper for onboarding, issue drafts, model pools, rules, and plugin panels. Only a dedicated probe's 401 with the central gate's X-Minnow-Auth: required marker clears the unchanged device credential. Provider 401s do not revoke devices, and registry read failures return retryable 503. Browser/terminal desktop chrome is omitted. Plain http://<lan-ip> is not a secure context, so service-worker shell caching and reliable installability require a future HTTPS transport; current LAN v1 is same-network, online-only. See contributor/lan-companion.md.
Companion credential recovery: src/api/session-token.ts retains the current credential in page memory across temporary browser-storage failures. Reading an older localStorage-only pairing restores its cookie for Home Screen installation. New pairings verify the two persistent stores independently, so a stale read-only store cannot override a successful save in the other; stale localStorage values are removed when possible. Each read still detects newer localStorage pairings from other tabs, including before storage events arrive. Confirmed revocation clears the page credential even when persistent storage cannot be erased. Coverage: test/api/device-token-storage.test.mts and test/api/companion-connection.test.mts.
Companion installation and live sessions: After device authorization, the manifest link uses authenticated GET /api/auth/manifest. Its stable / identity and scope carry the same revocable device credential in the launch fragment so a newly installed PWA can hydrate isolated storage; bootstrap removes the fragment immediately and still waits for /api/auth/session. The manifest is private/no-store and never contains a host credential. Existing app storage takes precedence over the install credential. src/state/session-live-sync.ts polls session summaries every five seconds while visible and on focus/pageshow/online, with abortable non-overlapping reads. Each poll sends ?sinceRevision= with the last store revision it fully read (summariesSyncedRevision, which this window's own saves never advance): the server answers { revision, unchanged: true } when nothing was written, otherwise a delta listing only chats stamped after that revision while chatRevisions still names every chat, so deletions remain visible. A remote edit skipped because the local row is protected holds the synced revision back, so it is re-offered on the next poll. A full answer is ~100 MB on a long-lived home (every chat's runs, ledgers and sub-agent records), so polling must never fall back to it. refreshSessionsFromServer merges remote chat/group additions, updates, and deletions, fetches changed active history, preserves navigation and local object identity, and protects dirty, streaming, loading, conflicted, or drafted chats. Per-chat conflict bases remain unchanged for protected rows. Provider registry reads share an in-flight request, allow five seconds per attempt and one retry, retain cached data on outages, and never substitute a phone-local provider for a failed server registry. Host recovery refreshes the model picker. Passive touch tracking releases chat follow before iOS scroll and momentum events.
Browser-only tools (get_datetime, calculate, ask_question, sub-agent/board tools, mode handoff, and the user-surface browser_* calls) run client-side. The Agent Browser tools are the exception: browser_reserve_tab, browser_release_tab, and the agent-surface browser actions are dispatched through server/browser-agent-api.js to a server-owned headless browser. POST /api/tools still returns Not implemented for the browser-only names when called without the client executor.
Server browser-drive tools (browser_drive_* in server/tools/browser-driver-tools.js) run on the tool server via CDP. browser_drive_resize waits for window.innerWidth / innerHeight and dispatches a resize event after Emulation.setDeviceMetricsOverride so page listeners observe the new viewport (macOS Chrome can skip the event otherwise).
Agent Browser: Each run first reserves an owned, headless tab and must pass its returned tab_id on every later action. browser_list on the agent surface returns only that run's tabs; ownership checks reject other agents' tabs. Tabs use a disposable session profile, close popup/page targets, and are capped at 8 per service. The service discovers an installed Chrome, Edge, Brave, or Chromium executable (or MINNOW_BROWSER_PATH); it does not download a browser or restore tabs between sessions. The navigation allowlist applies. browser_screenshot captures the headless tab even when the Agent Browser viewer is closed. The dedicated viewer is opened from the browser sidebar button and offers Watch, Guide, and Take control; it can assign or unassign an explicitly selected tab, close one tab, or clear all tabs. User-created preview tabs remain a separate surface. Closing the viewer leaves the service running; quitting Minnow shuts it down. Owned browser teardown terminates the detached process group even if the parent exits first, and waits for inherited stdio to close before removing its disposable profile.
Middleware registration: server/runtime/middlewares.js. Bootstrap: server/runtime/bootstrap.js.
The end-of-run report (src/orchestrator/board-report.ts) is a full-width dashboard over derived board state, not a document: a header bar (board name, verdict, run summary, and the back / rerun / follow-up / commit actions), a strip of stat tiles (merged, abandoned, skipped, runs, git files, git lines, integration pass/fail), a Needs attention list, one collapsed row per task, then collapsed Run notes and journal. max-width is 96rem and the pane gutter comes from .ov2__board. Needs attention is triage only β one .ov2-attention__row per abandoned/skipped card (and the failed integration check first) carrying the humanized reason, the newest journaled blocker, and a Reset task button wired to BoardReportActions.resetTask (commandResetTask, the same confirm-then-POST .../reset path as the kanban); task triage rows hold no attempts, patches, or evidence dumps. The failed integration row names the recorded failed check and exit code, shows command output in an initially open, bounded, keyboard-scrollable disclosure, preserves command/cwd line breaks, and offers the remaining check evidence behind Check details. Older checks without an explanation or output say that explicitly. A task row is id Β· title Β· phase badge Β· N runs Β· diffstat, closed; opening it lazily mounts Runs (numbered role + outcome badge, a bounded blocker preview with every full blocker behind a closed disclosure, needs, a one-sentence scent with the full write-up behind a closed disclosure, and testOutput behind one more) beside Files. The layout uses two explicit columns, giving Runs twice the space of Files, and stacks below 850px; expanded blocker logs preserve line breaks in a keyboard-scrollable region. Files come from src/orchestrator/report-files.ts: merged tasks fetch real per-file additions/deletions from GET /api/boards/:id/tasks/:taskId/files (four at a time, cached per board, failures fall back rather than retry) and render GitHub's five-block proportion bar; unmerged tasks show the union of paths their attempts journaled with countless: true, so no count is invented. The old per-attempt Changes/Evidence/patch stack is gone β raw patches live in the task-detail overlay, which has real per-file diffs. Each attempt still leads with the verdict scan (src/orchestrator/attempt-scan.ts, attempt-report.ts): humanized abandon/skip reason, journaled blockers/needs/file counts/test-output presence, then the scent; renderAttemptScan is now the task-card Work list only. report-evidence.ts renders evidence as text-safe labeled fields, compact file-path lists, status badges, and lazy native disclosures (patches omit raw originalLength) β it now serves saved-report JSON fences, which keep their structured presentation and an expandable raw view without changing report storage or exports. Run notes (src/orchestrator/report-notes.ts) parses the saved end-of-run markdown into the same section vocabulary as the dashboard (Summary, Shipped, abandoned task cards with labeled evidence fields, and so on) instead of dumping raw #/## headings into the card; unstructured or legacy prose still falls back to ov2-spec__prose plus JSON-fence evidence.
-
detectConfigServer()?loadSessionsFromStorage()anddetectLocalServer()in parallel (before OS router). -
initOsRouter()when Minnow enabled (first hash sync β do not callsyncOsRouteFromHashfrominitOsShellbefore the probe completes).
initApp(): opens the workspace gate early on cold boot (beginWorkspaceGateForBoot) and warms tool/config/composer init while the picker is visible ? waits for folder pick ? workspace modules + sidebar/chat/composer sync under the gate cover ? revealAppAfterWorkspaceGate() ? post-reveal fetchModels / Issues warm / resumes. Reload skips the gate wait and uses the dual-gate loader instead.
Loader dual-gate: src/boot/app-ready.ts dismisses #app-loader only when both bundled CSS is applied and chrome is ready (markChromeReady after first coherent paint, or when the workspace gate opens so the picker is usable). A 4s stylesheet fallback and separate 15s last-resort chrome fallback prevent a normal slow Code boot from exposing lazy feature DOM before its CSS arrives. GateβCode cover: after a cold workspace pick, src/os/workspace-gate.ts keeps #osWorkspaceGate up (html.os-workspace-gate-holding) until Code chrome finishes first paint, so composer/sidebar/chat do not assemble on screen. In-session workspace switch (menubar picker) calls finishWorkspaceGateSwitch() instead β initApp has already run, so the cold-boot hold/reveal path must not run again. Boot timing (__MINNOW_BOOT_ORIGIN_MS in index.html β recordAppReadyMetrics in src/boot/boot-metrics.ts) is surfaced in Settings β About β Performance diagnostics and gated in CI via test/boot/boot-budget-ci.test.mts. With MINNOW_DEBUG=1, markBootPhase / measureBootPhase stamp performance.mark('minnow:boot:β¦') phases (shell-ready, sessions, config, ui-init, first-paint, interactive) and print a phase table including interactiveMs (initApp exit); long-task logs correlate overlapping measures (src/boot/long-task-observer.ts). Bundle ceilings: budgets.json, enforced by scripts/check-performance-budgets.mjs after vite build. Full read-only audit (2026-08-09): documentation/archive/minnow-performance-review-2026-08-09.md. eagerJsMaxKb sums the entry <script type="module"> plus every rel="modulepreload" href in dist/index.html (cold-boot fetch set) via scripts/lib/analyze-dist-assets.mjs. Boot-graph eviction keeps CodeMirror and the full highlight.js package off that preload set (test/boot/eager-graph.test.mts guards @codemirror/* / @xterm/* value-imports from src/main.ts and forbids deferred feature CSS side-effect imports); highlight uses a core+~30-language build in src/markdown/highlighter.ts with a lazy full-bundle fallback. Feature CSS (file panel, terminal, orchestrate board/hub, models page, onboarding, β¦) loads with their lazy modules. Streaming assistant markdown is incremental (src/markdown/renderer.ts). The live caret is .cursor--prose only (CSS does not match a bare .cursor class from model HTML). Stream end calls finishStreamingBubbleRender, which cancels the debounce (and drops pendingCursor so a late flush cannot resurrect the bar) and sweeps leftover carets. appendStreamingAssistantRow treats any assistant row still holding that caret as a stale live shell. remountStreamDomForChat is a no-op without a registered owner; the tool loop retargets bubble/cursor live and replays livePartialText onto the new shell.
Turn checklists: src/ui/todo-panel.ts reads successful todo_write results per turn and renders a collapsible todo list directly below the activity disclosure. Checklists stay in the transcript, with wrapping task labels and completion counts. Failed tool rows remain behind the activity disclosure even for failed or stopped turns.
Chat presentation: Settings β Appearance β Chat view selects compact (default) or full, persisted as chatView in appearance.json and cached under minnow.appearance.chatView. src/chat/transcript-turns.ts derives visible user-turn boundaries and selected run timing without changing history. src/ui/chat-work.ts adds per-turn Worked disclosures (outcome glyph Β· duration Β· tally) while retaining the original transcript nodes and absolute history indices for streaming, tools, actions, and chunked backfill. Compact hides a settled turn's intermediate activity; final replies, failures, and stopped output stay visible. A live turn shows its activity in place and hides its disclosure button (the composer run status stands in). Visible activity is drawn as a timeline purely by classes: tool rows and thinking-only rounds get chat-step (dot) and chat-step--joined (rail to the next step). A thinking-only round keeps its own step even when it led straight into a tool round, because its Thoughts panel is the only place the reasoning can be read; compact never hides a mounted thoughts panel. The latest settled answer gets a .chat-reply-foot (copy, remake, tok/s Β· tokens). Multiple tool calls emitted in one model round render as one collapsed tool-call-batch disclosure β labelled Explored N files, M searches with file chips when every call only read or searched, otherwise N tool calls; expanding it preserves the original detailed tool rows, including live state and controls. Full keeps every transcript row visible while tool-call <details> and thoughts toggles stay collapsed until the user expands them. Recorded per-turn file summaries in chat-turn-changes.ts use the code-change ledger's inclusive history range and lazy inline diff review. This is display grouping, not model-context compaction.
Reply selection retains all assistant prose since the last substantive tool round, including prose attached to or separated by trailing save_memory / todo_write calls. These bookkeeping calls do not clear the reply; a round containing any other tool does. Compact view keeps each reply part visible and hides its thoughts with the work disclosure. The latest reply footer copies all mounted parts in order, while retaining each part's original message actions and history index. This applies to saved histories as well as live turns settling.
Content-channel <tool_call> envelopes also emit live tool_streaming progress as soon as their function name is available, including split JSON and Qwen XML headers. The shared runner uses the same progress handler for content and reasoning envelopes, so the transcript shows Calling⦠while arguments arrive.
Reloaded workspace picker: A late workspace choice only holds the loading cover when a boot handoff is actually pending. After reload, the picker closes through the already-initialized workspace-switch path so Code cannot remain hidden behind a completed boot cover.
| Role | Shape | Notes |
|---|---|---|
user |
{ role, content: string } |
Attachments as [image: ?] / <file name="?"> in content |
assistant |
{ role, content, thinking?, thinkingDurationMs?, tool_calls?, stats? } |
Markdown UI; optional thinking[] and wall-clock reasoning duration |
tool |
{ role, tool_call_id, content, attachments? } |
Paired to tool_calls in UI. Screenshot tools store a PNG dataUrl on attachments for the next model round. |
Wire format may use multimodal ContentPart[] for VLMs; built in src/chat/build-api-messages.ts (buildApiMessages). OpenAI-compatible tool messages are string-only, so browser_screenshot pixels are not in the tool result text (that is a localhost /api/browser/screenshot/:id URL). On a vision model, buildApiMessages injects an ephemeral user follow-up with image_url data URLs (src/chat/tool-image-follow-up.ts); toolImageFollowUp is stripped before the provider POST. Those follow-ups are not transcript rows β if one leaks into chat.history (array content), reload and New Chat must not call string methods on it (trimStart / replace is not a function). isHiddenTranscriptUserMessage hides them, apiMessageContentToText coerces parts to text for bubble paint, stripSkillTagFromHistory coerces before composer recall (New Chat clears the composer while the previous chat is still active), and buildApiMessages skips leaked follow-ups then rebuilds one from the tool attachment. Preview capturePage is bounded at 3s (electron/preview-guest-actions.ts) so a hung macOS CopyFromSurface cannot stall the tool loop.
Unread replies: A chat that completes while another chat is selected shows a success-colored dot in its sidebar spinner position. Running turns retain the spinner. Opening the chat clears and persists its unread flag; replies received while viewing that chat advance the read baseline so leaving does not mark them unread again. This uses the existing unread and lastAssistantAt session fields in src/ui/chat-item-dot.ts and src/notifications/acknowledge.ts.
Turn ownership: runChatTurn claims per-chat setup, streaming state, and its abort controller before awaiting history hydration. Background continuations use the same ownership and Stop path. The return value reports whether the turn was accepted; queued follow-ups are restored if a competing turn wins setup or continuation loading fails. Fork, truncation, and explicit Stop checks target the requested chat, including its setup phase.
- Persisted in
sessions/sessions.db(SQLite); legacysessions/state.jsonis imported once on upgrade (see canonical session store above). Schema version insrc/types.ts. - Each chat has
workspacePath; sidebar lists current workspace (+ Unassigned legacy). -
Workspace pick / switch (
onWorkspaceChanged,applyWorkspaceScopedSession): dismisses any open Orchestrate board view and starts a new empty General chat for that folder (does not restorelastActiveChatIdByWorkspaceor the last planner/board session). Foregrounding Code from another app still useslastActiveChatIdByWorkspaceviarestoreCodeSessionOnForeground. - No
MAX_CHATShard trim on save; sidebar order useslastMessageAt(newest first).pruneEphemeralEmptyChatsdrops unused empty chats when switching away (board-linked and planner chats are protected). -
Scratch workspace (
~/.minnow/workspace): registered on server boot as a normal workspace folder, labeled Sandbox in the UI (server/workspace/root.jsβensureScratchWorkspaceRegistered,scratchPathinconfig.json). It is pinned on the workspace picker, not stored inworkspace.recentPaths. Recents only record folders the user opened or created; GET/api/workspaceprunes temp dirs,~/.minnow/worktrees/**, placeholders, and Sandbox from the MRU. Legacy Chat (~/.minnow/chats) and unscoped chats migrate to Scratch on session load (migrateScratchWorkspacePaths). The standalone Chat app was removed fromapp-registry;#/app/chatredirects to#/app/code/chat. -
Request-scoped workspace: every request names the folder it means β
X-Minnow-Workspaceforfetch(install-fetch-auth.ts) and?workspace=for SSE/WebSocket, which cannot set headers (withSessionToken).createWorkspaceScopeMiddlewareruns right after auth and binds it into the path-accessAsyncLocalStorage. Precedence is explicit bodyworkspaceRoot> the view's workspace > the persisted global; the last fallback keeps the LAN companion and any older client working unchanged. Server code callsgetEffectiveWorkspaceRoot()βgetDefaultWorkspaceRoot()inroot.jsmeans the persisted default and is for boot and config persistence only. ALS only covers work that finishes inside the request: boards, sub-agents, scheduler jobs, dev servers and PTY runs carry their own workspace and re-enterrunWithToolContextwhen they resume (the runner effector reads the board's journaledworkspacePath). -
Open-workspace registry (
server/workspace/open-workspaces.js): the in-memory set of folders some view has open, driven by Electron main throughPOST/DELETE /api/workspace/open. Membership β not equality with one global root β is whatisAllowedWorkspaceRootadmits, so two real project folders can be live at once. Deliberately not persisted: a stale entry would widen the filesystem boundary after a crash.PUT /api/workspaceis no longer a global repoint β it records the cold-boot default and touches the MRU, and no longer shuts down LSP or kicks a brain cascade. The headless CLI registers its folder instead of PUTting, so a scheduled job can no longer move the desktop's workspace mid-session. -
Code workspace (
GET/PUT /api/workspace,server/workspace/root.js): defaults to the Minnow install root until the user opens/creates a project.isDefaultstays true for placeholder roots (app.asar, and the install dir only when the app is packaged βsetAppRoot(dir, { packaged }); a dev checkout's app root is a real project, so it stays a legitimate recent) and untilworkspace.userChosenis set by an explicit pick. Minnow Shell foregrounds Code with the welcome screen (src/ui/welcome-page.ts) whileisDefaultis true instead of loading the file tree against the bundle. -
Removed-app session migrate: stored
Chat.appScope'calendar'/'email'is dropped on load (those chats become ordinary Code chats).lastActiveChatIdByApp.calendar/.emailkeys are omitted. PersistedmodeId'desktop'/'email'remaps to General vianormalizeModeId(ModeIdstill lists both so old transcripts type-check).
CLI concurrency: Claude, Codex, and Cursor generations share the provider's configured agentCli.maxConcurrent admission limit. Codex does not hold an account-wide lock during inference or tool-result delivery. Its shared credential helper serializes only the local compare-and-replace of refreshed credentials, keyed by canonical source path; a competing refresh or newer sign-in is preserved. This guard also covers legacy exec and session shutdown. Regression coverage: test/generations/agent-cli-concurrency.test.mjs and test/generations/agent-cli-codex-auth.test.mjs.
Claude CLI title inference: invocation.js sets CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 for Claude subprocesses. Minnow owns chat titles; the CLI's automatic session-title request otherwise submits the entire replay transcript to a separate inference, outside Minnow's utility-role gate and token ledger. The native fake-endpoint smoke verifies that a tool call and its continuation make exactly two requests, with no title inference.
Persistent CLI conversations: agent-cli/lifecycle.js owns adapter pools, chat locks, disposal, idle retention and the process ceiling. Bindings use provider + Minnow chat; native adapters own creation/resume, input delivery, completion, cancellation and wire state. Generation completion is independent of process exit. Each provider retains at most eight idle processes for five minutes, with a ceiling of configured concurrency plus eight including pending handoffs and closing children. Pending tools keep their existing handoff deadline and never enter idle eviction. Output is bounded per generation; diagnostic captures remain bounded and redacted. Workspace close and host shutdown release every adapter while retaining valid resumable state; chat deletion and configuration changes remove bindings and owned caches. Legacy agentCli.sessionMode: 'replay' migrates to auto; MINNOW_AGENT_CLI_REPLAY=1 is the internal restart/reuse rollback, selected before inference.
checkpoints.js stores atomic version-1 records under ~/.minnow/cli-sessions/<provider-chat-hash>/: adapter version, native ID, fingerprint, accepted-history hash/count, native verification hashes, counter baselines and clean/dirty state. Input is sent only after marking the checkpoint dirty. Claude retains the last verified checkpoint in the dirty record's recovery field; a matching handoff can resume once Minnow has recorded every pending tool result. Missing, modified or compacted state reconstructs from Minnow history without rerunning recorded tools. These regenerable caches contain no bridge tokens, are excluded from backups and expire after 30 unused days. The fingerprint covers workspace, stable account identity (token refresh is not an account switch when identity is available), binary/configuration, instructions, tools, model and supported generation settings. Shipped prompt composition separates identified current-turn data from stable instructions; opaque custom/legacy prompt changes remain conservative and rebuild.
Claude adapter: Chats, board agents, and enabled utility roles use a real interactive PTY in claude-interactive.js, requiring Claude Code 2.1.289 or newer. The native entrypoint is cli; no billing headers or identity fields are overridden. A fixed, single-use /mcp__minnow__message command retrieves text and images through the private MCP prompt endpoint. Message contents never become terminal keystrokes or command arguments. Startup waits for both tool and prompt discovery. First-run display setup keeps the selected theme and continues the informational security notice through the native UI; it does not fabricate onboarding or auth state. Workspace trust is accepted only for the exact Minnow-owned private working directory; login and other permissions are not auto-confirmed. Startup input handling stops after MCP discovery. Startup progress and actionable failures reach the CLI view, and cleanup cannot overwrite a startup error with a zero-exit completion error. Documented MessageDisplay, Stop, and StopFailure hooks use a separate authenticated loopback listener. Completed native transcript records supply per-request input, cache, output and thinking counts. Handoffs allow a short grace period for committed response records. If native Claude waits for a tool result before committing, Minnow releases the handoff and retains later output across generation boundaries; repeated native calls are matched by unused tool IDs. Hook text and transcript text reconcile without duplicate output. Missing native dollar cost and quota percentages remain unavailable. The print-only dollar budget is rejected before inference with an actionable message; it is never ignored. Native compaction, automatic prompt suggestions, tool search, title inference and in-process CLI updates are disabled. Native execution tools stay disabled; Minnow retains tool execution and approval ownership. Claude's native EndConversation safeguard remains available, and ended chats cannot silently rebuild into a fresh native conversation.
Claude retains an explicit native UUID and private working directory. The session canonicalizes this directory before spawning and transcript discovery, so macOS /var aliases match the native project key across checkpoint/resume. claude-state.js verifies opaque transcripts before checkpointing and absolute-path resume. Verified headless checkpoints can migrate to interactive sessions without replay. Image annotations are accepted only after an image and inside the owned native project folder; imported image assets are removed on shutdown after the embedded transcript is preserved. Ordinary follow-ups retain the process; clean checkpoints and completed tool results resume after eviction or restart. Stop closes the PTY, process tree and hook listener. MINNOW_CLAUDE_LEGACY_PRINT=1 explicitly selects the previous stream-JSON adapter before inference; startup/protocol errors never silently switch transports. Requests without a chat binding, including enabled utility roles, use a fresh interactive session with its own native UUID and private temporary directory. They create no durable chat checkpoint and close the process, MCP bridge, hook listener, native transcript and temporary files after completion, tool handoff, failure or cancellation. Utility opt-in and the explicit legacy/replay overrides remain enforced. MINNOW_CLAUDE_INTERACTIVE_SMOKE=1 MINNOW_CLAUDE_TRUST_SMOKE=1 node test/fixtures/native-claude-recovery-smoke.mjs tests the installed CLI against a local fake endpoint: early tool dispatch, recorded-tool recovery, warm follow-up, a 330,030-character prompt, images and image restart. It performs no account inference and does not establish subscription quota parity.
Claude interactive output: Interactive invocations retain --thinking-display summarized. The MessageDisplay hook streams reply text in completed-line batches; it does not expose thinking deltas. During active prompts, Minnow polls its owned native transcript for thinking blocks and emits each summary once, without waiting for a tool handoff or duplicating it at final commit. Availability depends on Claude's transcript flush timing; Claude Code 2.1.290 can withhold summaries until the response completes. Only committed responses supply usage. Deferred text and usage are buffered between generations and attributed once on continuation. An uncommitted handoff does not create a native checkpoint; recovery uses the last verified state or reconstructs from recorded Minnow history. Repeated UserPromptSubmit delivery is acknowledged only for the same active prompt ID, exact command, session and directory; altered or completed-turn submissions remain blocked. node test/fixtures/native-claude-streaming-smoke.mjs verifies the installed CLI against local fake inference: visible text before completion, requested summaries, two early tool calls with delayed text and final usage, deduplication, a warm follow-up, and isolated non-streaming utility replies with native transcript cleanup.
Claude recovery and usage: Tool handoffs also checkpoint the native transcript after verifying the native tool names and arguments against Minnow's pending calls. Resume uses a working copy, preserving the verified snapshot through a crash. Recovery sends only the pending calls and recorded results as incremental context, since Claude's stream-json input does not accept restored tool_result blocks; native history remains opaque and unmodified. The accepted-prefix hash, full result set, configuration fingerprint and snapshot digest must all match. test/fixtures/native-claude-recovery-smoke.mjs verifies this path with an installed CLI and a local fake endpoint, including a subsequent disk resume. Generation metadata includes the resolved model in minnow_cli.model, so moving aliases can be compared against the model actually used. The shared runner sends CLI providers their full permitted tool catalog from the outset instead of applying Minnow's lazy schema discovery: changing that catalog midway forces a native session rebuild and loses the cached conversation prefix. Non-CLI providers retain lazy discovery.
Legacy Claude stream-JSON tool handoffs wait for the current native message_stop before closing the generation: native MCP calls can arrive while later response blocks and final usage are still streaming. Text and reasoning after the first tool call remain part of that generation, and final thinking tokens map to completion_tokens_details.reasoning_tokens. Native rate_limit_event observations are allowlisted into minnow_cli.rate_limit (status, optional window/utilization/reset, observation time), emitted immediately to streaming logs even on later failure and retained in CLI diagnostics. Missing utilization stays unknown; these observations are distinct from token totals and require no extra account request. The delayed-stream fixture verifies complete usage, late text, checkpointing and no double counting on continuation.
Codex app-server: Codex generation and model discovery share the bounded bidirectional client in agent-cli/rpc.js, re-exported through codex-app-server/rpc.js. CLI 0.153.4 is the minimum. Durable threads retain their private native home, verify thread/read history plus the owned rollout-file hash, and use thread/resume after eviction/restart. Mismatches seed typed Minnow history before inference. Native dynamic tools hand off to the shared runner; recorded results resume the same turn or seed a replacement without repeating execution. The private configuration enables the isolated code-mode executor so code_mode_only models can invoke those dynamic tools; descriptions retain the original Minnow names. Threads start/resume with environments: [], and agents.enabled plus both multi-agent feature flags disable native environment tools and delegation on newer CLIs. Native compaction is rejected and reaches Minnow's context-policy retry path. Native context usage stays separate from billable usage, and cumulative token counters are allocated per generation using saved baselines. Stop interrupts the native turn and terminates its process tree. Credential refresh keeps the existing concurrent-sign-in guard; clean retained homes remove their credential copy. MINNOW_CODEX_LEGACY_EXEC=1 remains the internal exec rollback, selected before inference. node scripts/codex-model-tool-smoke.mjs checks every installed catalog model against a loopback Responses fixture; --account explicitly runs a tiny live nonce-tool check for each model using the signed-in account, without workspace content. Earlier architecture: migration status, spike report.
CLI tool batching: Transport instructions tell agents to dispatch independent read-only calls together before awaiting results, retaining sequential dependent calls, writes, shell, browser and interactive actions. Codex native dynamic calls are serialized even inside code-mode Promise.all, as verified with the installed CLI against fake loopback inference. The adapter therefore exposes transport-only mn_read_batch when automatic tool choice allows permitted read tools. One native request expands into up to six ordinary, unique Minnow calls; all members are validated against the current safe catalog before any handoff, then run through the existing execution/permission pipeline. Results return in input order, including genuine permission errors and images. Duplicate native notifications reuse the group rather than repeating execution. Code-mode invokes the batch through one tools.mn_read_batch call. Its handoff collector waits for 200 ms of native item inactivity after the first call, bounded to one second from that call; complete transport batches and eight-call handoffs flush immediately, while overflow calls remain buffered for subsequent rounds. minnow_cli.timings.tool_batch_count and tool_batch_collect_ms expose collection size and duration. The shared server/renderer executor allows six concurrent reads, including repo_map, find_symbol, who_calls and read_symbol without enabling result caching. Permission checks, fixed native catalogs, session verification and write barriers are unchanged. Regression coverage: test/generations/agent-cli-codex-app-server-pump.test.mjs, test/generations/agent-cli-protocol.test.mjs and the tool-batch/policy suites. Fixture tests establish collector behavior, not account-model batching frequency or API latency parity.
CLI handoff recovery: Invalid Codex mn_read_batch arguments return an unsuccessful native tool result without dispatching any members, allowing correction within the same turn. Duplicate calls reuse that rejection; unexposed tools and protocol/queue violations still terminate the session. CLI question handoffs retain their process through the shared runner's one-hour answer timeout plus return overhead, rather than expiring at the ordinary five-minute tool limit. Coverage: test/generations/agent-cli-codex-app-server-pump.test.mjs and test/generations/agent-cli-tool-wait.test.mjs.
Codex vision: Model discovery preserves model/list.inputModalities, falling back to the private cache metadata and then the documented text/image default for older catalogs; explicit text-only models stay text-only. The adapter validates raster data URLs, keeps image parts in the canonical conversation, submits native image inputs on fresh, warm and restored turns, and reconstructs historical input_image items when rebuilding. Marked screenshot follow-ups are returned as inputImage content in the pending dynamic tool result so the native turn stays open. Requests retain the 8 MB transcript bound and twelve-image cap; inference RPC permits that transcript plus bounded envelope overhead. Installed-CLI regression coverage uses a loopback Responses fixture, without account inference. Protocol reference: Codex app-server.
Cursor ACP: cursor-acp.js uses private configuration/auth copies, disabled native execution and Minnow's MCP bridge. Preflight initializes/authenticates (advertising Cursor's parameterized model picker) and requires session loading. Cursor stores a session only after its first prompt, so a new session is not loaded back. The model check maps Minnow's CLI slug onto ACP's base model plus parameters (gpt-5.3-codex-high-fast β gpt-5.3-codex, reasoning=high, fast=true; auto β default). Cursor's config dir β which holds its acp-sessions/ and chats/ stores β lives beside cursor-data in the chat's CLI cache, not in the work dir removed on close, so a restarted process resumes via session/load. Saved sessions load with output suppressed and verify a canonical native-history digest. Cursor announces an MCP call as an unnamed MCP: tool placeholder and names it (minnow: <tool>, rawInput.providerIdentifier) in a later update; every named row must be an exposed Minnow tool. Native permissions outside exposed Minnow MCP tools and unsupported blocking extensions receive errors and close the process. An unavailable handshake selects isolated print replay with a visible reason before inference and is remembered per provider model for 30 minutes so later turns skip the ~5 s preflight; a started prompt never retries through another transport. Contract fixtures verify model arguments, permission denial, tool handoff, loading and cancellation without paid inference.
CLI status and usage: Output SSE snapshots expose active/awaiting-tools/idle/closed state, selected transport, restart-resume support, continuation (new, reused, resumed, rebuilt) and rebuild/fallback reason. The read-only CLI view shows the latest generation's input/cache/output breakdown and native dollar cost where supplied; missing metrics remain unavailable. Claude native request usage is allocated by generation. When the legacy print adapter supplies dollar cost, it is differenced within a process and reset on fresh resume. Native per-generation cost reaches the chat ledger through runner stream metadata. When Claude supplies only a cost for several requests across tool steps, the CLI view labels it as a native turn total and leaves the final generation's cost unavailable. Token totals and dollar cost are not subscription quota measurements. test/fixtures/native-cli-conversation-smoke.mjs benchmarks installed Claude against a local fake endpoint in replay/warm/restart modes; each three-turn variant makes exactly three requests, stable structured prefixes survive continuation/resume, and warm reuse needs one process. MINNOW_CODEX_APP_SERVER_SMOKE=1 node --test test/generations/agent-cli-codex-app-server-live.test.mjs validates installed Codex warm turns, restart resume and recorded-tool recovery against a local fake endpoint. Neither fixture measures subscription savings.
Cursor print snapshots: The shared CLI translator tracks text since the last native aggregate flush. It suppresses the unmarked final assistant snapshot emitted after timestamped partials, and uses retry/query boundaries to distinguish their timestamped flushes from repeated real deltas. Tool aggregates reset the segment; untimestamped ACP and compatibility deltas remain incremental. This prevents duplicate text in chat replies and utility consumers such as prompt expansion. Regression fixtures cover the installed 2026.10.01 stream format, repeated words, tool rounds, retries, interaction queries, and streaming/non-streaming utility output.
Agent CLI providers (#1175): agent-cli-v1 reserves claude-code-cli, codex-cli, and cursor-agent-cli, managed in Models β CLIs through server/models/agent-cli-middleware.js. Detection and catalog discovery never run inference; settings live in provider profiles and login credentials remain in the CLI's store. Claude reads the installed SDK initialization catalog, preserving moving aliases and adding the resolved pinned IDs, with version-aware fallback for older CLIs; Codex discovers models through the installed binary's app-server model/list handshake in a fresh private home (codex-cli-catalog.js), never the desktop/shared models_cache.json; discovery shares in-flight requests and caches for five minutes by binary/version and hashed login state, with explicit timeout/protocol failures and no inference. Context metadata comes only from that discovery process's own cache. Refreshed file credentials use the same concurrent-sign-in guard as inference (codex-auth.js); Cursor enriches from cursor-agent --list-models (cached 5 minutes, static headline fallback including more than auto) in agent-cli-catalog.js. Listing and auth-status spawns force a colorless capture env (FORCE_COLOR=0, TERM=dumb) and strip ANSI, because Vite's FORCE_COLOR=1 otherwise colorizes --list-models and the picker falls back to the static headlines. Windows discovery (resolve-bin.js) unwraps current npm "%_prog%" .cmd shims (Codex) and Cursor's %LOCALAPPDATA%\\cursor-agent\\cursor-agent.cmd launcher to direct invocations without spawning cmd.exe; official Codex npm installs resolve to the platform packageβs native codex.exe (or the legacy bundled vendor payload), so Minnowβs windowsHide applies to the executable rather than only its Node launcher; Cursor uses its bundled node.exe + index.js; if PATH does not include the vendor install dir, well-known locations are still treated as installed. Install and Update open Minnow Terminal and run the vendor command for the tab's shell (cli-panel.ts); Update runs the CLI's own self-updater (claude update, codex update, cursor-agent update, via the detected binary) after POST /api/models/agent-clis/:kind/prepare-update closes that provider's idle Minnow-owned processes without forgetting checkpoints, because a running child holds its binary open and blocks a Windows self-update; Cursor uses PowerShell irm 'https://cursor.com/install?win32=true' | iex on native Windows instead of the POSIX curl installer. server/generations/agent-cli/pump.js branches before HTTP host/admission logic, applies per-provider FIFO concurrency (default one), utility-role opt-in, bounded JSONL translation, and cancellation/timeouts. The translator forwards Claude thinking deltas, Codex reasoning item updates, current Cursor assistant deltas, and CLI lifecycle hints into the shared live transcript. Claude and Cursor keep one CLI process across tool rounds when the chat transcript, model, and tools remain in sync; Minnow sends their tool results through its private MCP bridge without replaying the growing prompt. Codex uses the retained app-server adapter described above. Each process uses private temporary configuration with native CLI tools disabled. Claude chat sends text and images through private MCP prompts; Codex seeds typed history over bidirectional stdio; Cursor sends its transcript on stdin too (--print with no positional prompt, plus --trust for the headless scratch workspace) so Windows argv limits cannot reject ordinary turns. The private mcp-shim.mjs exposes exactly the caller's tool catalog and submits up to eight independent requests to a private, token-authenticated loopback listener. Validated requests stream to the UI immediately; a brief quiet period collects parallel calls into one tool_calls completion. The CLI process waits for Minnow tool results and resumes on the next round; the shared Minnow runner retains sole ownership of execution, approvals, browser tools, questions, and board report interception. The bridge cannot call /api/tools and receives no Minnow API token. Claude chat image content travels through MCP prompts and tool results; Codex carries image attachments and tool screenshots through app-server. Cursor advertises no vision. CLI thinking maps to supported effort controls, never LM Studio template options. Cursor's documented print protocol suppresses thinking text, so Minnow shows its live phase and tool activity but only renders a Thoughts disclosure when the CLI actually supplies reasoning. Run npm run test:agent-cli for protocol, subprocess, provider, discovery, and interface coverage. Design decisions and vendor references: documentation/plans/agent-cli-providers.md.
CLI settings UI: Models β CLIs uses compact, expandable connection rows with provider identity, installation/authentication status, and an enable switch. Concurrency, context, and Claude turn budgets save automatically after a brief typing pause or immediately on change. Binary overrides and helper-task opt-in live under Advanced settings. Forms retain focus and drafts through status refreshes and failed saves; inline feedback offers Retry. Writes are serialized per CLI, pending edits flush on section teardown, and reopening waits for outstanding writes before loading persisted status. Styles: models-cli.css.
CLI context windows (MIN-20): Models β CLIs exposes optional agentCli.contextWindowTokens (1,000β1,000,000; omission/null restores automatic). Claude aliases inherit their version-resolved capacity; explicit extended windows request [1m] for eligible older/alias Sonnet/Opus, while native 1M IDs and Haiku retain their restrictions. Codex receives model_context_window before exec. Cursor budgets are capped by advertised model capacity and do not change native context. Runtime loaded_context_length wins over stale capability probes; changing the configured window replaces a waiting native process on the next generation.
CLI output view: Chat-owned CLI sessions capture native process stdout/stderr in server/generations/agent-cli/output.js, redacting known credentials per complete line and retaining only the last 256K characters for up to 64 chats in server memory. Authenticated GET /api/generations/agent-cli-output?chatId=β¦ returns a snapshot and supports since version checks. The Code and Chat surfaces use src/ui/agent-cli-view.ts to toggle between the transcript and this read-only process stream. Claude interactive sessions expose structured adapter events in this read-only view; terminal rendering is not used as model output. Other adapters retain their native structured stream. Composer sends use the shared chat runner, and a server restart clears captured output.
Command logs and CLI tool waits: read_command_log honors its byte limit for live, finished, and evicted runs. It waits for already-queued log writes and reads the latest file tail; an unavailable file falls back to a byte-bounded UTF-8 memory suffix. Large test logs therefore cannot replace a requested small tail with the whole in-memory buffer. The shared tool-batch backstop respects a foreground execute_command timeout up to the terminal runner's ten-minute ceiling, with thirty seconds for cleanup. CLI handoff expiry covers the batch's tool deadlines plus return time, keeping the same process available during long commands. Board report validation identifies tool-call markup swallowed into other arguments when required evidence or test output is missing.
Transport recovery: Both generation subscribers accept LF/CRLF framing across byte boundaries and require the terminal event: end. A premature EOF or transient stream failure re-subscribes once to the same generation, skipping already delivered blocks from its retained byte replay. It does not start duplicate model work. Retention remains 30 seconds for ephemeral generations and five minutes for persistent ones, with no byte cap; eviction or another disconnect surfaces an error. Readers are cancelled/released on early termination. The nonstreaming response parser assembles text, reasoning, and tool-call deltas when a provider returns SSE despite stream: false.
Main chat: POST /api/generations + GET .../stream with replay. The runner passes the reasoning half of the capabilities it sanitized the body with (modelCapabilities: reasoning, reasoningAllowedOptions) on the POST, or via postChatCompletionsInProcess for server-run turns; the store's pre-wire sanitize pass applies them to the requested model only, because without them it treats reasoning as unsupported and strips the composer's reasoning_effort. A fallback model keeps the capability-unknown pass. Client stores chat.currentGenerationId; reload re-subscribes via src/chat/generation-resume.ts. Stop: src/chat/stop-generation.ts aborts the main stream, immediately clears its visible running state, and cancels every child sub-agent for that chat (including a spawn whose response arrives after Stop). runChatTurn finally clears that id unless a resumable system Stop so the agent activity fallback row cannot keep ticking after the turn. Boot resume gate (src/boot/resume-gate-boot.ts, hold flag src/chat/resume-gate.ts): on app open, asks before restarting an interrupted chat generation or unfinished tool batch β including after Quit Minnow, which cancels generations. Mid-turn work stamps persisted chat.resumeInterrupted (src/chat/resume-interrupted.ts); Electron before-quit calls __minnowPrepareForShutdown so the marker is flushed before generations die. Chat-switch generation/tool resume no-ops while the gate is held. Under V2 the gate is chat-only: the client no longer resumes boards at boot, so there is nothing on this side to park β the server engine owns board lifecycle through its own journal + reconcile. Agent activity panel (src/ui/agent-activity-panel.ts, OS menubar next to notifications): lists in-flight main turns, sub-agents, and title jobs across all apps. While ask_question is waiting on the user, rows show Pending question and the elapsed timer pauses (pauseMainTurnActivityForQuestion from ask-question-queue.ts). Stop all (footer, confirm via appConfirm) calls stopAllAgentActivity to halt orchestrate boards, Super Plan, streaming chats, sub-agents, title jobs, and desktop research runs; /loop schedules are left running.
SSE parsing: src/api/sse-parse.ts β event boundaries and glued JSON chunks; do not Response.json() on the generations shim. mlx-lm prefill : keepalive processed/total comments become a synthetic prompt_progress chunk. Stream end: src/api/stream-end.ts; no empty-SSE non-streaming fallback; turnProducedOutput preserves partial failed turns. Failed-turn recovery (MIN-666): the error chrome and the tail failed: true chip expose Continue (retry with the visible transcript still in context β continueFailedTurn, hydrates first, never truncates) and Clear (clearFailedAssistantOutput drops only the failed assistant row; the user prompt and earlier successful turns stay). After claiming a Continue turn, runChatTurn acknowledges the current turn's failed assistant flags, saves the preserved partials, and rebuilds the visible transcript to dismiss the transient error banner and recovery controls. Earlier failures and run diagnostics remain intact; a new failure creates its own notice. Claude's CLI prompt explicitly maps original Minnow tool names to advertised mcp__minnow__ names; unprefixed and native calls remain rejected. Truncation UI src/ui/truncated-affordance.ts. subscribeToGeneration / subscribeToGenerationRaw yield after every 8 SSE blocks in one read() (SSE_BLOCKS_PER_YIELD, MIN-729) so a token burst cannot monopolize the renderer; events are not dropped.
Stream paint (MIN-729 / P7-B / P10-F / MIN-584): Main chat maps runTurn events through createChatTurnEventPainter (runChatTurn in run-turn-chat.ts). Live delta events are coalesced from the runner's onDelta (microtask, latest snapshot wins) and flushed immediately on tool_streaming / stream end so a token burst cannot leave the bubble on the first word until the generation finishes. emitProgress remains the persist throttle and is leading+trailing (~80ms); it is not the live UI path. Cumulative delta / thinking snapshots apply at most once per animation frame (latest snapshot wins; thinking prefix-diffs against the last painted text via thinkingDeltaFromSnapshot). scrollChatIfPinned runs once per that paint tick β live appendReasoningDelta does not scroll. Follow-scroll (chat-scroll.ts) stays glued to the tail only while pinned: Jump to latest and a downward gesture that lands within CHAT_PIN_THRESHOLD_PX re-pin; wheel/PageUp/scrollbar away from the tail unpins immediately and a later scroll inside that slack must not re-pin; delayed programmatic scroll events never unpin. Unpinned viewports do not move when new tokens arrive. The mounted transcript controller observes row and viewport sizes via observeChatScrollLayout, keeping pinned chats at the actual tail after Full-view expansion, history backfill, and late layout changes; observers disconnect when the transcript controller is disposed. Discrete tool_call / tool_result / tool_streaming rows still paint immediately when the origin stream is visible; a background chat skips tool-card construction (history rebuild on switch). Origin-chat gate: every painter DOM write (delta paint, tool_call create, live tool_result resolve) checks isStreamDomVisible for the origin chat so a mid-turn switch cannot append into the newly visible transcript; in-memory snapshots stay so remountStreamDomForChat can catch up on switch back. A tool-bearing round_end finalizes the current assistant row (prose + Thoughts toggle) and opens a fresh streaming shell so live DOM matches chat.history (assistant β tools β assistant) β one thought group per model round, no stream-end scroll jump. Markdown remains 100 ms-debounced via scheduleAssistantBubbleRender except tool_streaming / tool_call, which pass immediate: true so a pending full sentence is not hidden under Callingβ¦. The painter skips that flush's extra scroll (pinScroll: false). reasoning_end flushes then calls endReasoningPhase so the thinking timer stops and status can flip to "Generating responseβ¦". The live elapsed suffix is a ThinkingDurationTracker in runChatTurn, not a second paint loop. Loading model: runChatTurn mounts the streaming row before ensureChatModelLoadedForTurn and sets stream-status / sidebar phase to loading_model so Loading modelβ¦ appears in the transcript, not only the status bar. Stream-end composer: runChatTurn finally calls syncComposerFromStreamingState when the origin chat is still active (idle placeholder, send icon) β #chatArea is not a subscribeChatStreamEnd listener. It also clears chat.currentGenerationId unless a resumable system Stop, disposes leftover stream-status / awaiting-prose chrome, and rehydrateLiveParentSubAgents if the client map still shows live children. Live context ring (P10-I / MIN-774 / MIN-584): runChatTurn writes syncTurnContextUsage from the painter's onCoalescedPaint (once per rAF tick, never per token) and once per tool_call with serialized pending calls; overlays are keyed by chatId. runChatTurn finally calls clearContextInFlightOverlay(chat.id) so a finishing turn cannot wipe a sibling stream.
Ticked motion: During any provider stream, runChatTurn calls acquireTickedMotion (released in finally). That parks every infinite CSS animation in the document once on acquire and steps them at 20 Hz so the compositor does not emit a frame every vsync. Reduced-motion still skips the ticker. Mid-turn discovery uses a MutationObserver on <html> (childList + subtree, not characterData) plus capture-phase animationstart; mutation bursts coalesce to one getAnimations({ subtree: true }) per animation frame so a fast markdown stream does not force style recalc on every inserted node (MIN-584). Hidden / minimised windows park animations via render-idle.ts because Electron sets backgroundThrottling: false. Streaming UI lag: Orchestrator V2 Phase 7 / MIN-727 β archive/chat-stream-ui-lag.md.
Live metrics (MIN-413 / P10-G / MIN-772 / MIN-584): src/chat/streaming-stats.ts updates chat.lastStats and the bottom metrics strip during SSE (throttled ~100ms) and calls touchChat so dirty tracking sees the write. runChatTurn folds P10-B stream_meta into a real StreamMetaAccumulator (turn-stream-meta.ts) and schedules from the coalesced painter snapshot (lastDelta / thinking length) so the live path never joins thought-bubble segments. Provider usage from stream_meta is preferred when completion_tokens is present; otherwise completion tokens are estimated from partial assistant prose plus the reasoning text length when reasoning streams (streamed_reasoning), using the shared prose estimator, so tok/s is reported during thinking; reasoning a provider never streams stays excluded. LM Studio stats (time_to_first_token, generation_time, tokens_per_second) are authoritative when usage-coherent β reconcileCompletionStats uses full trust when server timing matches usage and wall clock, partial trust when generation_time alone fits completion tokens (reasoning models where client tFirst was prose-only), and client fallback otherwise; it always preserves llama-only prompt_tokens_per_second / draft_acceptance. Client fallback measures tFirst at the first output token (reasoning or prose), but uses the full request window for throughput when the provider supplies no authoritative decode timing so buffered hosted output is not presented as model speed. finalizeResponseMeta fills missing usage from llama.cpp timings.prompt_n / predicted_n via fillUsageFromLlamaTimings, then derives total_tokens via normalizeUsageTotals. Runner streamed bodies request stream_options.include_usage through applySamplerToBody; mergeStreamMeta also backfills usage from timings when the provider still omits the block. Tool-loop turns push each round_end into priorSegments / priorStatsSegments and aggregate token totals via aggregateTurnUsageSegments (sum completions, keep latest prompt) and tok/s as total measured output divided by total measured generation time via averageStatsSegments / aggregateTurnMetaSegments; live strip timing uses the current round only so prior rounds do not inflate tok/s. chat.lastStats / chat.modelInfo are set from that aggregate so refreshMetricsStripForChat can repaint after a rebuild. resolveLastTurnMetrics is the single reader for last-turn tokens: it normalizes total_tokens from prompt+completion, fills gaps from the last assistant row when lastStats is prompt-only, and is what the metrics strip, context-ring USED, and hub tiles display. showCachedModelInfo repaints from that snapshot (it used to call updateStrip({}, {}) and blank TPS/tokens on every model catalog refresh). Context-ring USED is the same last-turn total plus pending composer/attachments/in-flight tool JSON; breakdown section rows stay character estimates scaled to that total. Per-message appendStats runs on the live row at tool-bearing round_end and at turn end; it also derives the red token chip from prompt+completion when total_tokens is absent (legacy rows) and formats the count with formatStatCount. Persisted history uses the same finalizeResponseMeta path on round_end (chat-transcript-store.ts reads t0 / tFirst / tEnd) so rebuild chips match the live bubble. Per-round ledger recording is recordMainChatTurnUsage (source.kind: 'main'), not the sub-agent helper; inner-loop deps.recordTurnUsage is remapped the same way so completions are not attributed to a helper. stream_meta.runtime is { timings, prompt_progress } and maps through llamaRuntimeStatusView onto setRuntimeDetail / prompt_processing. Token totals in the strip (updateStrip) use formatStatCount: locale commas below 1M, then compact M / B, with the full precise count on hover (title).
Thinking duration (MIN-467 / P10-F): ThinkingDurationTracker accumulates wall time only while reasoning SSE is active. runChatTurn owns the live onTick (setThinkingElapsed on the thought controller and stream status). The painter drives endReasoningPhase from TurnEvent.reasoning_end so the elapsed suffix clears and status can flip to "Generating responseβ¦". endReasoningPhase settles the live panel into a persisted Thoughts toggle immediately (keeps expanded state) so thoughts stay visible during tool_streaming / tool cards; round_end finalizeAndAnchorThinkingRound is then a no-op when the panel already exists. The shared runner ends the reasoning phase when the first tool_calls delta arrives or when a thinking-channel <tool_call> envelope names a function, so the live Thinking timer and persisted thinkingDurationMs do not include tool-call streaming or execution.
Content-embedded tool calls (local runtimes): Some OpenAI-compatible servers never populate delta.tool_calls. Three content shapes are recovered instead, all merged by mergeContentJsonToolCalls: constrained-decoding {"tool_calls":[...]} JSON, gpt-oss Harmony commentary (harmony-tool-calls.ts), and Qwen-style <tool_call> blocks (xml-tool-calls.ts) as emitted by mlx-lm, llama.cpp, and MTPLX. JSON {name,arguments}, native Qwen3.5/3.8 <function=name><parameter=key>value</parameter></function> envelopes, and unwrapped JSON after <function=name> (for example <function=todo_write>{"todos":[β¦]}</function>) all parse. Each has a stream router that keeps its markup out of the visible bubble and hands the captured payload to the parser after the stream ends (getCommentaryParseText / getToolCallParseText), so the reply never shows raw tool markup and the tool loop still runs. Left unparsed, the model sees no tool result and may keep generating without a tool card; inspect the raw stream and tool-call routing when the Thinking timer climbs indefinitely. Tag routing is chunk-boundary safe: InlineContentThinkingRouter and ContentToolCallRouter both hold back a trailing partial tag, because local runtimes split </think> and <tool_call> across SSE deltas -- an unmatched close used to leave every later delta, tool markup included, routed as reasoning. Interleaved-thinking models (Qwen3.8) may emit a second <think> span after visible prose; the router re-enters thinking only when a complete opener stands at a delta or line boundary and a matching close arrives, so a code answer that merely mentions the tags is not reclassified (MIN-653). Native reasoning channel: MTPLX (and DeepSeek-style servers) map the think span onto delta.reasoning_content / delta.reasoning / delta.thinking instead of wrapping it in <think> inside content. The runner feeds that channel through the same thinking-side ContentToolCallRouter, withholds the envelope from the Thoughts panel, emits tool_streaming once the function is named, and recovers the call via thinkingXmlParseText when SSE tool_calls stayed empty.
Sub-agent delivery acceptance: A completion is acknowledged after its hidden parent-history row and per-run subAgentDeliveryReceipts are durably saved, before the resumed model turn finishes. Replayed or overlapping deliveries re-acknowledge those receipts without another response. User Stop persists subAgentAutoResumeBlocked until the next explicit user message; delayed completions and cancelled-child results remain in history without restarting the parent, and check-ins stand down. Pending or declined boot resume gates also retain results without auto-running a chat.
Hosted reasoning discovery: Provider model lists are enriched from the matching official models.dev provider catalog, including its reasoning_options effort values and toggles. This runs when reasoning metadata is missing even if context limits are already populated. Muse keeps Minimal/Low/Medium/High/Extra high as distinct options; DeepSeek uses the effort values advertised for its exact model and endpoint. Current catalog controls override stale capability guesses, while positively probed controls survive a models list that omits them. Minimal and Extra high remain distinct in the composer, saved model defaults, and the shared completion request mapper.
Thinking budgets (per turn): Models β Thinking sets a reasoning-token ceiling (thinkingBudgetTokens, resolved per work agent / sub-agent type by resolveThinkingBudgetTokens). Two enforcement paths: providers that accept a native budget get it in the request body (mergeThinkingIntoCompletionBody ? nativeBudgetApplied); everything else runs the client watchdog ThinkingBudgetTracker, which estimates reasoning tokens mid-stream (chars Γ· 4) and cuts the generation when the ceiling is crossed. The budget bounds one user turn, not one tool-loop iteration: the tracker is created once outside the loop in server/runner/, and endSession() (prose or the first tool_calls delta) banks the finished phase while keeping the turn total. Continuation on a trip carries the work forward instead of restarting: attempt 1 resends the reasoning (and any prose already streamed) with thinking still on β an inline-<think> prefill when the reasoning arrived on the inline channel and no prose was written yet, otherwise the provider-agnostic assistant+user payload from buildBudgetContinuationMessages β plus a grace allowance (beginContinuation, =256 tokens or 25% of the budget) so the wrap-up thought does not instantly re-trip; attempt 2 resends the same payload with thinking merged off and disarm()s the tracker for the rest of the turn. Which channel the reasoning arrived on is observed (StreamTurnResult.thinkingChannel), not guessed from the model id; modelLikelyUsesInlineThinking is only the fallback when no reasoning was seen. Prose from the aborted attempt is seeded back into the stream (carriedText), so the bubble never blanks and fullText stays cumulative; a verbatim echo of it is stripped by stripCarriedTextEcho. Benchmark probes read the same ceiling through ThinkingBudgetTracker.limitTokens and may size it from remaining wall clock via thinking-budget-policy.ts.
Reasoning replay on follow-up API calls: outboundReasoningReplayFields attaches provider-specific assistant fields during tool loops (e.g. reasoning_content for DeepSeek). Kimi / Moonshot reject message-level reasoning ? those fields are omitted on outbound build and stripped again in sanitizeCompletionBodyForProvider before upstream POST.
Turn runs (chat.runs): semantic branches for replay/fork (src/state/runs-store.ts), separate from transport generations. Branch picker (src/ui/branch-picker.ts) calls activateBranch, which snapshots the active branch?s continuation into outputMessages before swapping so follow-up turns survive switching between branches; it also calls switchActiveChat so the next composer send stays on that chat (legacy chat mount paths no longer re-resolve a different sandbox chat when history is already present). After fork/replay turns settle, runChatTurn calls refreshBranchPickerAtFork so the picker appears without reloading the chat.
Agent undo (MIN-409): src/chat/undo-turn.ts rewinds the last settled agent turn to the fork user message (no auto-regenerate). pruneSupersededRunsAfterTruncate keeps outputMessages so the undone reply stays redoable via the branch picker (including the single-branch ?Restore branch? case when history ends at the user row). Orchestrate / board-linked / worktree-isolated chats block Undo in v1 (disabled control + ? item with reason tooltips). UI: per-turn file summaries in chat-turn-changes.ts (chat-turn-changes in the transcript) include Commit, Create PR, and Undo on the latest turn card (data-turn-changes-primary). #btnCodeChangeUndo is wired by composer-undo.ts (Uicons undo glyph via createIcon) ? hidden when the workspace is not a git repository (no snapshot capture) or the undo target turn had no file mutations (runHadCodeChanges in code-change-ledger.ts); message ? Undo turn still offers chat-only rewind; shared status copy via UNDO_STATUS. Commit and Create PR on the same card (code-change-strip-actions.ts): Commit stages chat-touched paths then runs /git-commit; Create PR runs /create-pr (src/skills/create-pr/SKILL.md). Both hide after a successful commit or PR (chat.codeChangeShipHandled). #chatJumpLatest alone floats in .chat-viewport-dock at the bottom of .chat-viewport. File-restore confirms use in-app appConfirm (not sync window.confirm). Phase 2 attaches optional git snapshot fields on TurnRunRecord (preTurnSnapshotSha, postTurnSnapshotSha, headShaAtTurn, snapshotCwd) ? persisted through ensureTurnRuns ? and restores the working tree on undo/redo when SHAs exist. Capture hooks live in src/chat/turn-snapshots.ts (called from run-turn-chat.ts around createRun / finalizeRun). Concurrent chats sharing one repo: last restore wins. Desktop / Chat-app chrome parity is deferred.
Queued follow-ups (MIN-200 / MIN-647): While a turn is in flight, Enter enqueues on chat.pendingMessageQueue (message-queue.ts) instead of sending. Pending messages appear only in the composer strip (composer-message-queue.ts), with edit / push-now / delete controls. They enter the transcript as real user messages when sent. flushPendingMessageQueue notifies the UI as soon as an item is dequeued (turn start), removing it from the composer strip before the real user row is appended.
Product send (sendMessageWithTools / runChatTurn) composes the system prompt and tool catalog, then calls runTurn(). There is one stream/tool loop β not a client copy. Chat must pass model.sampler as { preset, maxTokens } from resolveSamplerPreset (Settings β Sampler, composer drawer, per-library inference). runTurn substitutes that object for deps.resolveSamplerPreset. The inner loop's last-ditch fallback for type: 'turn' is work-agent kind + the shipped Settings max (131072), not the old sub-agent 2048 cap β omitting model.sampler used to hit finish_reason: length (Response truncated) on every provider. Headless minnow run / scheduler uses the same Settings merge via resolveHeadlessTurnSampler. Board workers attach Settings sampler on TurnModel; sub-agent types that omit sampler.maxTokens inherit that global max (wrapSamplerForTurn). Transcript persist for that loop is createChatTranscriptStore wrapping the session store (P10-D); live DOM is createChatTurnEventPainter.onEvent (coalesced delta/thinking paint, MIN-729; per-round rows on round_end, MIN-771). First-turn injection chips paint via appendInjectionNoticesDom on the same path. src/tools/loop.ts is gone. Dual-path flags (minnow.p6a.runTurnChat / minnow.p6c.runTurnChat) are gone.
TurnEvent (P10-B / MIN-767, P10-L / MIN-777): runTurn({ onEvent }) emits presentation-free events the inner loop already computed: delta, thinking, tool_streaming, tool_call, tool_result (full outcome: content plus optional attachments / codeChange / isError, and it fires for parseError and abort fills via onToolDone), plus phase (generating | thinking | tools), reasoning_end, throttled stream_meta, and per-round round_start / round_end (round_end after the last tool_result of that round). Event-type filtering is a server/runner/ contract any TurnEvent sink must use β not a board detail. isHighFrequencyTurnEvent classifies stream_meta / phase / round_start / reasoning_end / token / delta / reasoning_delta β disk transcripts (transcripts.js) drop the whole set so a 12 Hz stream_meta cannot cap the P9-D log. Boards write under ~/.minnow/boards/<id>/attempts/; sub-agents write under ~/.minnow/agents/<parentChatId>/attempts/ (entryDir injected). Sub-agent live SSE uses shouldEmitSubAgentLiveTurnEvent so phase / tools / thinking reach cards (delta stays false on that predicate). The effector additionally emits a throttled delta (last 400 chars, ~80ms) so the drawer generating tail is never an empty row. Board live SSE still drops the whole high-frequency set. The runner has no product-shaped chat branch; mapping phase onto "Generating responseβ¦" is a caller job (runChatTurn + P10-F, sub-agent onLive + P10-L). runTurn still does not scrape assistant prose (no isSubAgent branch).
Continue persist (P10-C / MIN-768): the inner loop's onMessagesChange(messages, meta?: { settled }) marks forced emits after a real messages.push as settled and throttled stream clones (synthetic partial assistant) as not. Continue turns persist each settled suffix via a monotonic persistCursor; finally is an idempotent backstop for abort/throw. Isolated/board persist is unchanged. Sub-agent retries are continue turns against createMemoryTranscriptStore() (persist is a no-op); after a process restart the effector seeds that store from the disk transcript when the in-memory map is empty. A prior transcript ending in a user row equal to seed is not duplicated; chat passes seed: historyContent (not pre-tag userText) so a skill-tagged send is one user row (P10-D).
Stopped / failed partials (P10-E / MIN-770): User Stop does not throw into runChatTurn's catch. runTurn returns { outcome: 'crashed', error: 'aborted' } and the post-await path would otherwise paint without a stopped: true row. settleStoppedTurn / settleFailedTurn handle (1) that returned aborted outcome, (2) a thrown AbortError if a caller still throws, and (3) real provider failures (returned crashed/timeout or a thrown error). Settled prefix from P10-C is left alone; the overlay mints a { stopped: true } or { failed: true } assistant from live streamed text/thinking via resolveFinalAssistantContent / resolveFailedTurnPartialRow, through the decorating store so touchChat runs. Failed turns persist the partial before triage so turnProducedOutput preserves it and the error bubble lands underneath (MIN-666 Continue). GENERATION_LOST_ON_RESTART_MESSAGE skips the partial, leaves the transcript, and only repairSessionHistoryTail (orphan tool tail). A turn that produced nothing rolls back to the user row. finalizeRun records stopped plus the captured ChatStopReason (user / timeout / system). A system Stop (Quit Minnow) keeps currentGenerationId so boot resume can still prompt.
Chat transcript decorator (P10-D / MIN-769): product chat persist wraps createSessionTranscriptStore with createChatTranscriptStore β it does not fork the session store (sub-agent callers still use the bare wrapper). Each append clones the inner-loop row so wire reasoning / reasoning_content / reasoning_signature stay on the in-memory API transcript, then writes the renderer shape: thinking[] (from the live ThoughtBubbleController snapshot, else round_end.reasoning / TurnEvent.thinking), thinkingDurationMs, thinkingSignature, stats/usage from that round, and attachments/codeChange on role:'tool' from tool_result. Inner-loop control user rows (SUB_AGENT_TOOL_USE_NUDGE_INSTRUCTION, empty-post-tool / prose-question / intent-to-act retries, continue-after-truncation) are dropped. A completed stream that announces a next action in the last sentence (Let me inspectβ¦) with no tool_calls gets one hidden retry even when chat passes nudgeToolUse: false (looksLikeIntentToAct); truncation still uses the Continue chip. round_end.finishReason goes through applyClassifiedStreamEnd so truncated: true lands on the row; resolveFinalAssistantContent fills a pure-reasoning reply so it is not an empty bubble. Every append calls noteRunOutputIndex + recordChatMessage (touchChat); onGenerationId calls noteGeneration. load() delegates so the UI-only filter stays in step with overlayMultimodalHistoryForRunTurn (one model-facing view of have).
Tool-row chrome (P10-H / MIN-773): the painter dropped args / attachments / code-change / shell-kill / remount that chat-tool-batch.ts still owns for incomplete-tool resume. Live tool_call / tool_result now reuse those helpers: parseToolArguments (malformed JSON is an error row, not { raw }), full-arity renderToolResult from P10-B's widened tool_result, attachShellKillUi on create and on result, notifyMemorySavedFromTool, and resolveLiveToolWrap so a mid-batch chat switch re-attaches by toolCallId (MIN-649). runChatTurn's execute sets setSubAgentExecutorContext / setBugBoardExecutorContext and assertUiDesignerToolAllowed, then clears the sub-agent latch in finally so the next turn cannot inherit a stale parent tool row. A 6-wide parallel read batch patches activity with parallelToolsActivityLabel. The latch itself lives in sub-agent-executor-context.ts so spawn can read it without a cycle.
Sub-agent spawn cards (P10-K / MIN-776): setSubAgentExecutorContext around chat execute (P10-H) is what journals parentToolCallId / parentTurnId / modeId so abort can cancelAllForParentTurn and the card can find the spawn tool row. upsertSubAgentCardForRun re-anchors on every upsert β not only on first create β when the card is detached or the [data-tool-call-id] tool row exists and the card is not already the next sibling. That keeps the card under spawn_sub_agent after renderChatFromHistory. With no tool-row anchor (issue expand passes parentToolCallId: null) the fallback is appendChatTranscriptNode so the card stays above queued follow-ups. resolveParentChatId prefers the execute latch over getActiveChat() so switching chats mid-POST cannot attribute the run to the newly visible chat. The getActiveChat() fallback remains for callers that still omit parentChatId (issue expand when ensureIssueWorkflowChat fails); it should become an error once those pass an id. spawnSubAgent fills providerId/modelId from resolveSubAgentModelBinding (type row β parent chat) unless the caller passed an explicit override, so Settings β Model routing's effective model is what the attempt actually runs.
In-turn steer (P10-I / MIN-774): P6-C reduced mid-turn steer to abort + follow-up (the live turn died, the run was marked failed, the transcript split). runTurn({ onRoundBoundary }) restores the loop.ts splice at the next tool-loop boundary β same injection shape as AskCapability, not an isChat branch. Chat implements it with createChatRoundBoundary (consumePendingSteer + syncComposerMessageQueue). The product row keeps steer: true so markMessageSteered survives renderChatFromHistory; the runner sees role + content only. Presentation groups steers into their original transcript turn, with an inline βYou steeredβ row on the compact activity rail and one final file-change summary. Corrections remain visible when work is collapsed; live insertion respects the user's scroll position. Board and sub-agent callers omit the hook. A completed turn with no tool boundary still follow-ups via resumeParentChatWithMessage (not abort). Continue persist advances persistCursor by the spliced length so the suffix store does not duplicate the product row.
Live context overlay (P10-I / MIN-774 / MIN-584): setContextInFlightOverlay / syncTurnContextUsage keep a per-chat Map so two streaming chats cannot clobber the context ring. Driven from coalesced paint + tool_call as above; never from raw delta events. runChatTurn finally calls clearContextInFlightOverlay(chat.id). Streaming ring refresh is a leading+trailing throttle (~1s), not a reset debounce, and only the active chat schedules it.
Tool approval: src/tools/permission-gate.ts (full / ask / off). enqueueToolApproval takes an optional AbortSignal on the request: abort resolves cancel without showing the strip, and abort while it is open dismisses it so a cancelled run cannot execute the tool later. ask_question uses a per-chat UI queue (src/tools/ask-question-queue.ts): each chat drains independently, and parked strips keep their panel off the shared #questionHost so switching chats cannot mix or block another chat's questions.
Built-in non-skill slash commands live in src/chat/slash-commands/registry.ts (picker) and dispatch inside sendMessageWithTools before skill resolution.
| Command | Role | Persistence |
|---|---|---|
/goal |
Work until a completion condition; post-turn evaluator continues the chat |
chat.activeGoal (src/chat/goal/) |
/loop |
Re-run a prompt on a fixed interval or self-paced delay while the app is open and the chat is idle |
chat.activeLoops[] (src/chat/loop/) |
/followup |
Hand the work to a new chat seeded with a context summary plus the next task, chaining up to 10 links |
chat.followupChain (src/chat/followup/) |
/loop modes: /loop 5m <prompt> (interval; units s/m/h/d, sub-minute rounds up to 1m); /loop <prompt> (auto delay 1?60m from output change); bare /loop (maintenance: <workspace>/.minnow/loop.md or built-in checklist). Loops expire after 7 days. A global ticker in src/chat/loop/ticker.ts (started from src/main.ts) wakes at each loop's persisted dueAt (with a 15s safety poll) so reload/sleep survive and countdown matches fire time. Fires go through sendProgrammaticChatText so looped text gets full slash/skill resolution. /goal and /loop are mutually exclusive on a chat. /clear clears both. activeLoops persist in session storage (shared normalizeChatRow via client ensureChatShape + server validateSessionState). Chat panel: src/ui/loop-status.ts (countdown, interval edit, pause/resume, skip, stop); skip marks the loop due immediately via src/chat/loop/skip.ts and triggers an immediate ticker wake. Re-synced after transcript paint and OS app foreground changes. Sidebar rows with active loops show a loop icon from the central registry (src/ui/icon.ts, Uicons class fi-rr-rotate-right) via src/ui/chat-item-loop-icon.ts: rotates while at least one loop is unpaused, static when all are paused.
Naming: session /loop lives in src/chat/loop/ β unrelated to the chat turn loop in server/runner/. Do not confuse the two when touching session loops.
/followup chains (MIN-206): a composer slash command, not a model-invocable tool β it is intercepted in sendMessageWithTools before the streaming branch (so it arms rather than queues as literal text), and the seeded follow-up message is sent with parseSlash: false. Forms: /followup, /followup <prompt>, /followup <n>, /followup <n> <prompt>, /followup stop; parser in src/chat/followup/parse-command.ts, guards in command.ts (one chain per chat, mutually exclusive with /goal and /loop, 1β10 links). State lives on Chat.followupChain (src/types.ts) and persists through the shared session-schema.mjs (ensureFollowupChain in normalizeChatRow; deliberately not a CHAT_PASSTHROUGH_KEYS entry, since the passthrough loop runs last and would clobber the normalized row). runner.ts advances the chain when the owning chat goes idle: a subscribeChatStreamEnd listener deferring through queueMicrotask (stream-end fires before flags clear) plus a 30 s safety sweep, and one sweep at boot so a chain armed before a restart still fires. spawn.ts creates the chat β link 1 foreground via createChatWithMode({ workspacePath }), later links through ensureBackgroundChat so a long chain does not steal focus β stamps the decremented chain, and seeds the first turn. The summary is deterministic (summary.ts: folded checkpoint, last six user requests, where the last reply ended, changed files); only an agent-chosen task costs a completion, which rides the Utility tasks model route (generate-task.ts, ledger source { kind: 'utility', task: 'followup-task' }). Panel: src/ui/followup-status.ts. /clear clears an armed chain.
Catalog: BUILT_IN_TOOLS. Config UI: Settings ? Tools; quick access from the Code chat composer cog β Tools page, which inlines the Tools popover (src/ui/composer-tools-popover.ts, src/styles/composer-tools-popover.css) with segmented Off/Ask/Full rows, consolidated availability notices, web-search provider (SearXNG, DuckDuckGo, Brave, Tavily ? persisted to search.json with tools.json fallback) + session-cache toggles, and a link to full tool settings; persistence tools.json / minnow.tools. Tool result size (MIN-667) is a separate execute-time cap from Settings β Agents β Context policy (MIN-39): file/grep/shell/web results default to 128β―000 chars and 2β―000 chars/line (server/tools/output-cap.js, shared with the SPA β no node:* imports; per-request policy on the tool server uses output-cap-als.js); grep defaults to 500 lines (max 2β―000); find_files 2β―000 paths; web fetch 48β―KB by default with a 128β―KB ceiling (max_bytes); read_document 200 spreadsheet rows per sheet (sheet / start_row / max_rows); issue_get_state / issue_search 25 issues per page with a compact field projection (fields / limit / offset). execute_command output over budget keeps the head and the tail (elideMiddle) so a failure at the end of a build log survives, and accepts per-call tail_lines / head_lines / max_output_chars (the last may only lower the configured budget). Disable the product cap or pass full_result: true (also accepts full) to skip those automatic ceilings; hard memory guards stay (25β―MB file refuse, 5β―MB process capture with a loud footer). Settings: Integrations β Tools β Tool result size. Settings drawer, composer, and chat-app tool lists use the same segmented Off/Ask/Full controls (src/ui/tools-list.ts); setToolPermission defers cross-list refresh via queueMicrotask so happy-dom and live DOM stay in sync after change/click handlers. Optional appId on a catalog entry gates exposure through getEnabledToolCatalogEntries() and fillToolsSection(): email tools appear only when isAppEnabled for that app (developer releaseState plus user disabledApps).
| Category | Examples | Runs on |
|---|---|---|
| Utility |
get_datetime, calculate, clipboard |
Browser |
| Web |
web_search, fetch_web_content, wikipedia_search
|
Browser + server fetch |
| Tavily |
web_map, web_extract; richer web_search options |
Server, saved Tavily key |
| Files |
read_file, read_document, save_file, grep, find_files
|
Server (npm start) |
| Git |
git_status, git_commit, git_diff, β¦ |
Server |
| Code |
execute_command, run_javascript, run_python
|
Server (+ terminal SSE) |
| Code intel |
repo_map, find_symbol, who_calls, read_symbol, brain_*
|
Server |
| LSP | get_lsp_diagnostics |
Server |
| Memory | save_memory |
Server (Brain adapter) |
| Minnow docs |
minnow_docs_search, minnow_docs_read, minnow_docs_list
|
Server (installed read-only docs) |
| Agents |
spawn_sub_agent, board_*, mode handoff |
Browser executors |
| Browser preview |
browser_navigate, browser_snapshot, β¦ |
User preview surface in Electron + server allowlist |
| Agent Browser |
browser_reserve_tab, browser_snapshot, browser_screenshot, β¦ |
Server-owned headless browser + server allowlist |
| Chat UI |
ask_question, propose_mode_switch
|
Browser |
| Appearance |
get_appearance, update_appearance
|
Browser (desktop only) |
mcp__* tools bypass mode and agent allowlists and are approved by server addition; server disable/removal is enforced at dispatch. plugin__* tools bypass the mode matrix but retain Settings permissions.
Tavily web tools: server/tools/tavily-client.js makes single-attempt, deadline-bound requests with a 2 MB response guard. web_map defaults to depth 1, breadth 20, 50 processed links (maximum 200), external links off, and a 60-second timeout. web_extract accepts 1β10 public URLs, basic/advanced extraction, and optional query-ranked excerpts; failures retain successful sources, with per-source and total result caps. Both tools use the saved Tavily key independently of the selected search provider and join the read-only web group for chat/headless execution. web_search forwards optional Tavily depth, topic, domain/date filters, result count, and snippet count; non-Tavily backends reject unsupported options. Search caches include provider/config revision. Search Settings loads key/account credit usage on entry via authenticated GET /api/config/search/tavily-usage (?refresh=1 bypasses the 60-second in-memory cache). Usage is keyed by a credential fingerprint, is never persisted, and does not enter the model tool catalog.
Shipped under src/chat/prompts/: base/, modes/, tool-usage/, work-agents/, experts/, titles/. User overrides: ~/.minnow/prompts/.
The Agents settings page leads with the shared system prompt, then lists the four composer modes (general, build, plan, debug), seven live work agents (general, builder, planner, reviewer, researcher, ui-designer, tester), and sub-agent types. Internal orchestrate / onboarding mode prompts and release-hidden expert prompts stay out of the settings cards. Retired prompt presets and the old prompt-hub implementation are removed.
The default-on interface icon guidance appends info/interface-icons to every composed profile: never use emoji as product-interface icons and source icons from @flaticon/flaticon-uicons. Users can disable it under Settings β Agents β Base system prompt (config.json β flaticonIconGuidanceEnabled).
Composer: src/chat/prompts/prompt-composer.ts. Custom profiles: ~/.minnow/prompt-configs/, portable bundles ~/.minnow/profiles/. When execute_command is enabled, the github-cli tool-usage appendix (tool-usage/github-cli.md, MIN-558) tells agents to use the local gh CLI for this repo's PRs/issues/CI instead of browser or web fetch to github.com. Composer reasoning UI (src/ui/composer-thinking.ts, src/ui/composer-reasoning-effort.ts): models with low/medium/high show the brain toggle plus an effort dropdown when reasoning is on; models with only off/on use the brain tri-state toggle alone (no redundant Off/On select). Qwen3.8 always uses the level dropdown (ensureQwen38ReasoningAllowedOptions) even when LM Studio advertises off/on, My Models rows have no apiKind, or the cache row is missing after a llama.cpp rebind. GLM-5.3 / GLM-5.3-Flash always think (ensureGlm53ReasoningAllowedOptions): composer shows Low / High / Max with no Off brain; send and sanitize never emit thinking.type: disabled or reasoning_effort off/medium/none/xhigh (utility βthinking offβ remaps to enabled + low). Composer textarea (#msgInput, input.css): grows with CSS field-sizing: content (JS autoResize is a fallback that skips height: auto on single-line typing and keys overflow-y off actual overflow β scrollHeight > clientHeight β so content capped at max-height stays scrollable) so macOS Electron does not reflow the chat column per keystroke; overscroll-behavior: contain stops wheel/trackpad chaining to the chat (MIN-344); text-entry controls have no theme transitions (MIN-168) and text-rendering: auto so CoreText ligatures do not delay glyph paint. Composer input stays off the session PATCH and context-ring estimate: drafts stay in memory while typing and flush after 2.5s idle or blur (composer-draft.ts); the context ring records typing so it does not re-tokenize history after a pause (context-usage-ring.ts). #msgInput / #chatAppInput use spellcheck="false" so macOS native checking does not hitch after a pause.
API: GET/PUT /api/prompts/... when server running.
Cursor-style SKILL.md (YAML front matter + body). Built-in: src/skills/<id>/; user: ~/.minnow/skills/<id>/ (user wins on name clash).
Invoke via / slash picker (src/ui/skill-picker.ts). Built-ins include git-commit, code-review, fix-ci, kickoff, plan-work, orchestrate-plan, impeccable (default-on), ui-designer, frontend-design, caveman, and partymode. plan-work writes phased plans under documentation/plans/ via discovery sub-agents; orchestrate-plan executes those plans with implement ? verify sub-agent gates (lead agent does not ship product code). Only installed skills appear in the picker β remote library packs are absent until installed.
Kickoff: /kickoff (model-invocable) takes a new project, app, or feature from idea to saved plan in five gated stages: grill the user on project and build context, write a PRD, Impeccable init (PRODUCT.md), design (frontend-design chooses a direction; Impeccable document or shape records it), then set_chat_mode('plan') and a Plan-mode plan. The user picks Build or Orchestrate at the end of the grilling; Plan mode reuses that answer for the plan format, and the finish recommends the same path. Stage is resumed from files keyed by a slug: documentation/plans/references/<slug>-context.md, root PRD.md (project) or references/<slug>-prd.md (feature), and DESIGN.md (project) or references/<slug>-design.md (feature). No-UI work skips init and design. It writes documents only. test/skills/kickoff-skill.test.mjs guards the Impeccable and frontend-design reference files and headings it loads.
Frontend design: /frontend-design provides project-neutral frontend design and implementation guidance with a compact workflow in src/skills/frontend-design/SKILL.md. The full maintained guide, examples, and source attribution live in reference/frontend-aesthetics.md within that skill; agents read it through load_skill references. The existing load_aesthetics_reference tool reads that same shipped file, so the skill and tool share one source. It is enabled and model-invocable by default, alongside the existing Impeccable and Minnow-specific UI Designer workflows.
Skill chips: Known /skill-id tokens paint as inline command pills in the composer (highlight overlay over #msgInput / #chatAppInput) and in sent user bubbles (src/skills/skill-chip.ts, src/ui/composer-skill-highlight.ts, src/styles/skill-chip.css). Send still stores [skill: id] for the model path, but the UI restores the leading slash so the token is not stripped from the thread. Edit, copy, and prompt-history recall use formatComposerTextFromHistory.
Matt Pocock pack (MIN-476): No longer bundled. Install from Settings ? Skills Library (matt-pocock pack, 19 skills: ask-minnow, triage, implement, handoff, ?). Post-install hook server/skills/library/post-install.js runs scripts/matt-pocock-preserves/apply-minnow-patches.mjs to apply Minnow renames (ask-matt ? ask-minnow, /review ? /code-review, /compact guidance, etc.). Lock file: skills-lock.json (matt-pocock-skills section). Maintainer sync (hashes only, not bundled): npm run matt-pocock-skills:sync.
Skills Library (MIN-474/475/477): Curated third-party SKILL.md packs for browse/install from Settings ? Tools & integrations ? Skills Library (src/ui/settings-skills-library.ts, section id skills-library). Pack registry data: src/skills/library/registry.mjs (shared with server); types in registry.ts ? five curated packs (Matt Pocock, Addy Osmani, Superpowers, last30days, Browserbase); Antigravity and AWS Agent Toolkit are excluded from the curated list. Each pack pins a GitHub commit SHA plus skillsGlobs for discovery. Prebuilt offline indexes ship at src/skills/library/index/<pack>.json (metadata only: skillId, label, description, subpath). Regenerate: npm run skills-library:index (also runs in prebuild via scripts/generate-skills-library-index.mjs). Matt Pocock pack declares postInstallPatch: 'matt-pocock' for Minnow adaptations on install. Client API: src/skills/library-api.ts ? fetches library routes when npm start is running, falls back to shipped indexes for offline browse.
Skills Library API (server/skills/library/, routes in middleware.js): GET /api/skills/library/packs (registry + installed counts), GET /api/skills/library/packs/:id/index (offline shipped index), GET /api/skills/library/search?q=, POST /api/skills/library/install ({ pack, skillIds[] | all } or { repoUrl, subpath? }), POST /api/skills/library/remove ({ skillId } or { pack, all: true }). Installs stage and validate the complete skill tree (including post-install patches) beside the destination, then swap it into ~/.minnow/skills/<id>/ with rollback on metadata failure. Existing untracked or locally modified skills are kept and reported as conflicts. Provenance in ~/.minnow/skills/installed-skills.json and skills configuration use serialized updates; bulk installs report installed, skipped, and failed skill ids; bulk removals report removed and failed skill ids. Successful installs enable the skill immediately. Network fetches are SSRF-guarded and GitHub-host-only (api.github.com, codeload.github.com, raw.githubusercontent.com). Skills catalog (settings.skills) remains the enable/disable + custom authoring surface; cross-links between the two sections.
API: GET /api/skills, GET/PUT /api/skills/:id, GET/PUT /api/config/skills.
Agent skill loading: the read-only server tool load_skill (server/skills/load-skill.js) lists enabled, model-invocable skills when id is omitted (optional keyword query), loads the chosen skill body with id, and reads bundled UTF-8 files with id + relative reference. Catalog entries and content lines are paginated with one-based offset, limit, and next_offset. It uses the merged built-in/user/plugin catalog, honors user overrides, skill enablement and disable-model-invocation, and rejects reference traversal and symlink/junction escapes. Loading supplies workflow guidance in the tool transcript; it neither pins a skill nor executes actions or changes mode/tool permissions. Shared SPA, headless/sub-agent, and board read tool sets expose it; newly shipped defaults seed Full permission without replacing saved choices. Development helpers permit model loading; personality, git shipping/setup, and orchestration skills remain explicitly invoked.
Set up git (background /git-setup): When the workspace is not a git repo, Source Control Center, the sidebar git panel, and Code overview share git-no-repo-state.ts. Set up git starts a background chat (backgroundKey git-setup:<normalizedWorkspacePath>, name Set up git, Build mode) via git-setup-background.ts and auto-sends /git-setup Initialize git in this workspace (init, .gitignore, initial commit). with ownsGlobalStreaming: false. It does not prefill the composer or assign sessionState.activeId (MIN-637). A second click while that chat is streaming toasts that setup is already running. After the turn settles, SCC and the git panel refresh and the composer-undo git cache is invalidated. Orchestrate board onboarding still uses programmatic initializeWorkspaceGit (MIN-615), not this launcher.
Git /api/git (MIN-198 + MIN-409 snapshots): Programmatic git ops via POST /api/git (op + args) ? server/git/git-ops.js, middleware server/git/middleware.js, client src/state/git-api.ts. UI feedback: mutating git/GitHub actions from Source Control, the sidebar git panel, the history graph, and Issues GitHub sync go through runGitUiOp. After ~180ms a floating bouncing progress card appears (git-activity-overlay.ts); success stays a short toast. Failures parse stderr in git-error-parse.ts (auth, missing gh, rejected push, protected branch, conflict, hook, timeout, network, nothing to commit, server_off) and open a popover with title, summary, raw Details, Copy, and Send to chat. Seeds live in git-error-to-chat.ts (commit / merge / push / pull / fetch / checkout / rebase / pr / github / generic) and auto-run a Build chat. Status polls and agent git_* tools do not use the overlay. Agent-undo snapshot ops (MIN-409): snapshotCreate builds a dangling commit of the working tree using a temp GIT_INDEX_FILE (real index + HEAD untouched); snapshotRestore takes a safety snapshot first, then git read-tree --reset -u <tree> + git clean -fd (rewrites index/WT, does not move branch tip/HEAD); snapshotDiff lists --name-status paths between two SHAs or a SHA vs the current WT. Client wrappers: gitSnapshotCreate / gitSnapshotRestore / gitSnapshotDiff. Issues forge (issueList, issueView, issueCreate, issueEdit, issueState, issueComment) lives in forge-issue-ops.js and uses the same gh helper as PRs/CI. Create and edit ensure repo labels exist before attaching names (gh label list / gh label create); a remaining label error retries without the new names and returns droppedLabels so the issue itself is not lost. gh() never rejects (timeout / missing binary become { code: 1 }) so an import cannot 500 the route (MIN-660).
Git branch / worktree names (MIN-659): Composer New worktree, Source Control Center New branch / Add worktree, the sidebar git panel create buttons, and git-graph Create Branch slugify typed names instead of rejecting them (Test Worktree β test-worktree). The name popover shows Will use <slug> while the typed text differs. Defaults come from the chat title (skipping placeholder βNew chatβ) or the workspace/folder basename β never the current branch or an opaque id. The same popover adds a Start from select of local and remote-tracking refs (default: the currently checked-out branch). Worktree create also offers Check out to attach an existing branch (or --track a remote-only ref) instead of minting a new one. Git-graph Create Branch still starts from the clicked commit. Board-task isolation naming is unchanged (worktree-isolation.ts). Shared helpers: src/lib/git-branch-slug.mjs, src/lib/git-ref-start.mjs. Server backstop on checkout (create), worktreeAdd (baseRef / checkoutExisting), and createChatWorktree only.
Git commit messages (MIN-412): The Code git panel and /git-commit skill share conventions ? conventional commits with optional gitmoji (config.json ? gitCommitMessage.useGitmoji, default on), imperative subject (=72 chars), body explaining why, staged-vs-unstaged scope, and BREAKING CHANGE: footers. UI generation: src/ui/git-commit-message-client.ts (diff filtering, reasoning-chain extraction, prompt builder). The AI commit button calls /api/generations with applyUtilityThinkingOff (same path as composer ?thinking off?, including level-based reasoning catalogs). During streaming, only high-confidence conventional commit lines are shown in the input; heuristic/plain-text extraction and reasoning-channel fallback run on completion. Markdown diff walkthroughs (numbered steps, Removed/Updated bullets, **Identify Key Changes** headers) from local/LM Studio models are rejected as non-commit output. Gitmoji shortcodes: agents often write :sparkles: instead of β¨; history, PR commit lists, and related surfaces expand official gitmoji.dev codes via src/lib/gitmoji-shortcodes.mjs. Minnow also expands those codes when writing a commit through /api/git, git_commit, or board worktree commit so GitHub sees the glyph. Existing history is not rewritten.
Git commit / working-tree diff review wrap (MIN-675): The side-by-side commit and working-file diff panel (src/ui/git-commit-diff-panel.ts, src/ui/side-by-side-patch-diff.ts) wraps long lines by default so before/after stay comparable without horizontal scrolling. A Wrap toggle in the panel meta bar persists via localStorage key minnow.gitCommitDiffWordWrap ('1' / '0'; missing key = on) in src/ui/git-commit-diff-prefs.ts. CSS class sbs-diff--wrap switches cells to pre-wrap and drops the grid min-width: max-content stretch (src/styles/git-commit-diff.css).
Merge to main (MIN-465): When the Source Control panel is on a feature branch (main or secondary worktree), a Merge to main toolbar button appears beside Pull/Push. It checks out main/master on the main workspace (with dirty-tree confirmation when needed), merges the current branch, switches the panel back to the workspace worktree, and surfaces merge failures via the git error popover + optional Send to chat. Trunk resolution prefers local main/master, then origin/main/origin/master (needed when trunk is checked out in another worktree and omitted from the local branch list). Logic: src/lib/git-trunk-branch.ts, src/ui/git-merge-to-main.ts.
Official Minnow wiki (MIN-406): User-facing manual copy was refreshed for the workspace-first shell (seven released apps, #/workspaces gate, Code chat at #/app/code/chat; legacy #/desktop and #/app/chat documented once in the glossary). The repository holds the full documentation tree (documentation/manual/, documentation/contributor/, documentation/guides/ redirect stubs, context.md, design system, extensions, maintainer runbooks, plans, etc.). The in-app reader and minnow_docs_* chat tools index documentation/manual/ plus root ROADMAP.md and THIRD_PARTY_NOTICES.md. scripts/generate-product-wiki-catalog.mjs builds the in-app server/product-wiki/catalog.json from the shared allowlist in src/product-wiki/path-filter.mjs during prebuild; server/product-wiki/catalog.js imports the same module at load time so a stale catalog cannot surface developer pages. Packaged installers copy only documentation/manual/, ROADMAP.md, and THIRD_PARTY_NOTICES.md via extraResources. Runtime catalog, full-text ranking, capped reads, allowlist enforcement, traversal rejection, and packaged documentation resolution live in server/product-wiki/. Read-only API: GET /api/product-wiki/catalog, /page?path=, and /search?q=.
The in-app reader is a responsive overlay at #/wiki/<encoded-documentation-path> (src/ui/product-wiki.ts, product-wiki.css). Open it from the menubar ? button (user manual). Layout: flat documentation desk (index rail + centered search, no card stacks), grouped navigation with sticky section labels in the same order as manual/README.md (overview ? get started ? chat ? apps ? reference ? roadmap/legal; apps overview first within Apps), debounced full-text search, reloadable article deep links, doc header (section eyebrow, title, path breadcrumb), sticky ?On this page? TOC with scroll spy on wide viewports, sanitized Markdown with heading anchors, internal-link routing, GitHub edit footer, and external source links. Nav sort is defined in src/product-wiki/nav-order.mjs and applied at catalog generation and in the overlay rail. It intentionally is not a separate app on the app rail. Wiki vs Brain vs GitHub: manual/reference/wiki-and-brain.md.
minnow_docs_search, minnow_docs_read, and minnow_docs_list (server/tools/minnow-docs-tools.js) search and read the same manual-only catalog as the overlay. They are read-only, default to Full, run independently of the active workspace and Brain settings, and are exposed in General and Onboarding modes. Build/Plan/Debug keep their tighter developer-tool payload budgets and can read documentation/context.md and the repo directly. Prompts route Minnow user/product help here when available and reserve brain_* for user/project knowledge; repo architecture is not retrieved via these tools.
GitHub Wiki: scripts/publish-github-wiki.mjs stages a flattened, link-rewritten public mirror of the full published corpus (manual, contributor, guides, maintainer, design system, extensions, roadmap, context.md); scripts/push-github-wiki.mjs (npm run wiki:publish) stages and pushes from a maintainer machine. .github/workflows/wiki-sync.yml publishes from main. GitHub creates Minnow.wiki.git only after the first wiki page is saved in the UI (see maintainer/wiki-publishing.md). Publication rules and rollback: same doc. Plans, specs, archives, and agent memory are not published.
Memory is a thin adapter over the Brain wiki (server/memory/store.js ? pages/facts/). Retrieval injects into composer memory part; untrusted fencing via src/lib/untrusted.mjs. Defaults (2026): semantic embeddings on (embeddings.enabled: true), 8k full-profile inject cap (maxInjectCharsFull), retrieve limit 12, and query-relevant excerpts (~500 chars per hit) instead of first-line previews ? see server/engine/retrieve.js + src/lib/fetch-web-content.mjs (selectQueryRelevantExcerpt). Brain notes injection (optional): wiki retrieve for the memory prompt part on the first user message when resolved on (src/memory/config.ts, src/chat/prompts/compose-context.ts, gate src/chat/prompts/first-turn-injection.ts). Later turns replay the stored role: 'injection' body (resolveInjectionReplay) while the source stays on β they do not re-retrieve, and they do not drop the stored notes to fit a window-share cap. Global default features.memoryInjection (boolean, default true, Settings ? Agents ? Injection); per-chat tri-state chat.brainNotesInjection (inherit | on | off, same semantics as thinking). Composer Brain notes toggles (src/ui/composer-brain-notes.ts) are shown only when both the memory store and global injection setting are enabled (#desktopBrainNotesControl is the desktop composer mount, left of attach). Injection still requires the memory store on for the chat (global + per-chat memoryEnabled). Context-usage breakdown has a Brain notes row (system row excludes those tokens).
First-turn Brain retrieval in ordinary chats sends autoInject: true to POST /api/brain/retrieve. This only injects pages whose title or tags match a specific topic word in the user request; broad words such as βmakeβ, βwebβ, and βgameβ do not qualify. A request with no matching topic page receives no automatic note. Expert-scoped memory and explicit Brain searches retain the broader keyword and semantic retrieval behavior.
Code map injection (optional): ranked signature map from the Brain code index injects into the code-map prompt part on the first user message when resolved on (src/brain/code-injection-config.ts, src/chat/prompts/compose-context.ts, same first-turn gate). Later turns replay the stored injection body the same way as Brain notes (no second 20% window cap). Skipped for Scratch workspace chats (chatUsesDesktopSandboxWorkspace β threads whose tool cwd is ~/.minnow/workspace); Code app project chats only. Global default features.codeMapInjectionDefault (boolean, default false, Settings ? Agents ? Injection); per-chat tri-state chat.codeMapInjection (inherit | on | off, same semantics as thinking). The composer code-map toggle is shown only when the Brain default is enabled and the active project has code indexing enabled (src/ui/composer-code-map.ts). Injection uses Brain ? Code code map injection token budget (config.brain.code.repoMapInjectionTokenBudget, default 4000); the separate repo map token budget sizes the Brain Code map panel, while a repo_map tool call defaults to REPO_MAP_TOOL_DEFAULT_TOKEN_BUDGET (4000) capped by it. Fetch: POST /api/brain/code/repo-map with profile: 'injection', repo (worktree-aware workspace key), ensureIndexed, and focus β a list of substrings from extractCodeMapFocusHints (attachment paths, path-like tokens, PascalCase/lowerCamelCase/snake_case identifiers; capped at 8). Focus boosts on the injection profile (matches render first, remaining budget fills with the global ranked surface, so a wrong hint never empties the map) and filters on the tool profile. The old focusFiles PageRank personalization is gone: it ran a full-graph power iteration and rewrote every symbols.pagerank row on the request path, and its biased scores leaked into later unfocused queries. Injection render uses prepareRepoMapSymbolsForInjection: drops nested body noise (max depth 1, no constant, no callback symbols), extra test paths (vitest.setup.*, *.vitest.*, test-ws, __mocks__) and one-off script paths (scripts/, tools/, bin/, examples/, benchmarks/), flat path:line lines (no repeated ## file headers). Both profiles drop vendored / generated / minified paths (isRepoMapVendorPath), whose short generic exports were acting as PageRank super-nodes. Tool/default profile still uses prepareRepoMapSymbols with file sections. Map body is wrapped source="code-map" (src/brain/code-map-injection.ts). Context usage ring + breakdown (src/ui/context-usage-ring.ts, src/chat/context-usage.ts): Code map breakdown row when injected (system row excludes map tokens; map tokens count toward the single ring Used total); Code map (loading) when injection is on but the map fetch is still empty. The outbound estimate cache key includes first-turn vs replay plus stored injection sizes so follow-up turns do not reuse an empty first-turn snapshot.
Workspace context documents (optional): reads preset and custom workspace-relative paths into the context-documents prompt part on the first user message (src/chat/context-documents/, src/chat/prompts/context-documents/); later turns replay the stored body. Global default features.contextDocumentsInjectionDefault (boolean, default true); per-chat tri-state chat.contextDocumentsInjection. Settings ? Agents ? Injection ? Workspace context documents (src/ui/settings-context-documents.ts); composer toggle (src/ui/composer-context-documents.ts). Config object contextDocuments in config.json: enabledPresets (default agents-md, context-md), customPaths, maxTotalChars (default 48000). Missing files are skipped silently; .cursor/rules expands to sorted *.md / *.mdc via list_directory. Body wrapped source="context-documents".
Injection transcript rows (UI-only): after the first user send, when Brain retrieve, code-map, or context-documents injection produced a non-empty raw block, the chat transcript gets user-turn-aligned tool-call-style disclosure rows (glyph ? action ? line-count outcome ? chevron), same chrome as tool-call-msg (src/styles/context-notice.css, src/ui/messages.ts). They render immediately after the user bubble (including in the tool loop, inserted before the in-flight streaming assistant row). Expand shows the retrieved payload (memoryBlock / codeMapBlock / context document body), not the full prompt markdown templates. Rows use role: 'injection' (src/chat/context/injection-notice.ts); they are omitted from API history, history token estimate, synthesis, compress, and orchestrate failure classification (same rule as role: 'context'). Follow-up turns compose from those stored bodies (resolveInjectionReplay) and skip appending duplicate notices (injectionsReplayed). Replay does not re-run live Brain/memory retrieve gates β a failed fetchBrainCodeConfig on turn 2 used to drop the map even though the first-turn chips were still on screen. A chat-level injectedContext snapshot is written when notices are appended so replay survives if role: 'injection' rows are missing from history. Transcript notice bodies are capped at 24,000 chars (suffixed with a truncation marker and flagged truncated: true); the injectedContext snapshot is not capped, and resolveInjectionReplay prefers it over a cut row β replaying the capped copy shrank turn 2's prompt by ~5k tokens against a default 10k-token code map (~40k chars). Wired from resolveOutboundSystemMessages in the tool loop and plain streaming chat path. Send path passes firstUserSend (captured before history.push) so injection matches the first message even when compose runs after the user row exists.
Brain (~/.minnow/brain/): nested markdown pages, catalog.json cache, hybrid keyword + vector retrieve, code index per workspace (code/<workspace-key>.db), synthesis proposals. Page frontmatter uses JSON-quoted strings and inline arrays inside the Markdown delimiters; its reader also accepts older unquoted and single-quoted values. Closing delimiters must occupy their own line, so --- within metadata text does not truncate a page. Web RAG (rag_web_content) fetches up to ~24KB per page and returns up to 16 query-ranked sentences/paragraphs.
Brain page storage (MIN-75): Page writes use same-directory temporary files and rename, with a backup of the previous complete page on update. Reads and successful creates/updates return a SHA-256 revision of the full Markdown source. REST updates and brain_write_page accept optional expectedRevision; a stale revision rejects the write, while omission retains unconditional updates. brain_read_page includes the revision so agents can guard their next write.
| API prefix | Purpose |
|---|---|
/api/memory/* |
Legacy CRUD + retrieve (delegates to brain) |
/api/brain/* |
Wiki pages, tree, ingest, retrieve, code index, proposals, cleanup plan/execute |
Brain code index API (/api/brain/code/*): status, reindex, repo-map, map/*, and symbol queries accept optional workspaceRoot (query param or JSON body), validated like tool workspaceRoot overrides. The Code app Brain/code-map UI passes the active Code workspace path so indexing and maps target the same tree as the file viewer (worktrees included). SQLite schema user_version 2 uses FTS5 external content over symbols (sync triggers; INSERT INTO symbols_fts(symbols_fts) VALUES('rebuild') on migrate). Indexing runs in a child Node process in packaged Electron (server/brain/code/index-host.js + index-worker.js; MINNOW_BRAIN_INDEX_IN_PROCESS=1 or tests keep it in-process). The worker receives the app install root and packaged flag from the host alongside the workspace and Minnow home, so bundled language servers resolve independently of the desktop launch directory. The indexer uses a dedicated LSP scope (LSP_SCOPE_INDEX) and outgoing-only call hierarchy. Chat/code-map injection does not block on reindex (ensureIndexed defaults false; compose fires background POST /api/brain/code/cascade). POST /api/brain/code/reindex is fire-and-forget: it returns 202 with { started, repo, startedAt } and the job runs in the background (a full index takes minutes, far longer than any client timeout). /api/brain/code/status includes indexing, filesDone, filesTotal, phase while a run is active, plus lastRun (ok, error, indexedFiles, failedFiles, symbolsIndexed, errorSummary) once it ends β that is how callers learn the outcome. Files with no matching language server are counted in failedFiles and grouped in errorSummary rather than silently reported as zero. Worker?host IPC is newline-framed and must be reassembled across chunk boundaries (createNdjsonFramer); the done frame carries summary counts only, never the per-file results array. writePageRanks takes the Map returned by personalizedPageRank and returns the row count β rankedSymbols: 0 on a graph with edges means ranking wrote nothing. Code map aggregates (server/brain/code/map.js): GET /api/brain/code/map/architecture (layers, modules, module links, top external packages from a cached import scan of the first 32 KB of each JS/TS/Python file), map/folder?path= (direct files + subfolder nodes, links, outside callers, README/header summary), map/file?path= (symbols with nesting depth, header summary, file-level callers/callees), map/search?query= (files and folders). All are regroupings of one per-repo file graph (symbol calls edges rolled up to file pairs), cached until the index fingerprint (symbol count + max rowid, file-hash count + max mtime, edge count) changes. Calls resolve by name, so the roll-up drops callee names that shadow built-in methods (map, push, fetch, β¦), names defined in β₯10 files, and productionβtest edges β without that, list.map() alone produced a 253-call src/ui β scripts link on Minnow. The architecture base descends into a folder holding β₯80% of files (a repo that is all src/ maps src/*).
Code Map: a map-first page over the code index (src/ui/code-map/), hosted by the Brain Code section (code-section.ts only delegates) and the Code app overlay (code-brain-map.ts, which no longer draws its own header β the map bar carries Back). Three views share one pan/zoom canvas (viewport.ts: drag pans, wheel zooms at the cursor, trackpad scroll pans, +/-/0/arrows when focused, minimap): Architecture β top-level folders as layers, their child folders as module cards, moduleβmodule call links, third-party packages in an "External packages" layer; Files β one folder's files plus one card per subfolder, ranked leftβright by call depth (cycles broken DFS-first, unlinked files parked in a grid); Call graph β opens a symbol picker with workspace search, or symbols from the selected file; choosing a symbol draws caller/callee columns, and Choose symbol returns to the picker (depth 2 adds callers-of-callers and callees-of-callees, max 8 fetched per side). Busy-map rules live in model.ts and are unit-tested: test layers hidden by default; tiny top-level folders (β€3 files) share one Other layer; up to 8 layers are ordered exhaustively to minimise link span and upward links (upward links draw dashed in the warning colour); per layer the 8 heaviest modules show and the rest fold into an "N more modules" card; the most-shared ~12% of cards (β₯4 distinct sources and β₯30% of peers) become hubs with a "used by N" badge and quiet incoming links; under Strong links (default), links below 3 calls (architecture), links touching folded cards, and package links stay quiet. Quiet links render hidden and appear when either end is selected or hovered; selection dims unrelated cards. Icons (icons.ts): folders by keyword table on the name and its words (fallback two-letter monogram), packages by category, files by language tag, symbols by kind badge; right-click a folder card to override its icon (localStorage per repo key). The inspector shows a summary from a folder README, a file's header comment (or a detached comment after its imports), or a symbol's doc β readSymbol now falls back to the comment written directly above the declaration when the index stored none (docCommentAbove) β and never invents one; in the overlay, Ask / "Ask for a summary" close the map and prefill the Code composer (not sent). Search (Ctrl+K while visible) mixes path hits and find-symbol. More menu: Copy as Mermaid, Save as PNG (canvas repaint with theme colours), Reset index.
UI: Brain app #/app/brain/<section>. Settings for embeddings/synthesis live in Brain ? Settings. Utility sections (Edit, Memories, Schema, Log, Proposals, Ingest, Lint, Code) use a full-height workbench layout: toolbar + split or scroll panes, aligned with the graph-first shell. Memories lists legacy /api/memory/entries facts with add, edit (PUT), and delete in src/ui/brain/memories-section.ts. Lint is the AI wiki cleanup planner (plan ? confirm ? execute), not a static findings table.
Brain wiki cleanup: Read-only diagnostics collectWikiDiagnostics load the catalog and report orphans, stale pages, broken wikilinks, code anchor drift, and a dry-run preview of weak similarTo edges (pruneWeakSimilarLinks with dryRun: true) ? no writes and no contradiction LLM pass. POST /api/brain/cleanup/plan (providerId, modelId, optional maxSnapshotChars) runs diagnostics, builds a bounded snapshot for planning (server/brain/cleanup/snapshot.js), asks the configured model for structured JSON (planVersion: 1, planMarkdown, summary buckets: deletes, merges, link fixes, stale actions, anchor drift, risks), and persists the bundle under ~/.minnow/brain/.cleanup/<planId>.json (server/brain/cleanup/plan.js, server/brain/cleanup/persist.js). POST /api/brain/cleanup/execute (planId, providerId, modelId) loads that plan and runs a capped server agent loop (server/brain/cleanup/execute.js): brain_list / brain_read_page / brain_search / brain_write_page, manage_brain with delete_page only, prune_weak_similar_links, apply_anchor_drift, then cleanup_complete (max tool rounds + wall-clock budget). SPA client: src/brain/client.ts (planBrainWikiCleanup, executeBrainWikiCleanup); UI src/ui/brain/lint-section.ts binds provider/model from the top-bar model picker (src/ui/brain/cleanup-model-binding.ts, same source as the composer). Requires the local tool server. Legacy POST /api/brain/lint (includeLlm, apply) remains for scripted lint and optional auto-apply; the Lint section does not call it.
Tools: brain_search, brain_read_page, brain_write_page, save_memory, repo_map, find_symbol, ?
Memory saved review card (MIN-523): Individual wiki/memory saves from Brain forms, save_memory / brain_write_page chat tools, chat/research capture, and automatic synthesis queue a global 10-second review card (src/ui/memory-saved-toast.ts, memory-saved-toast.css). The card shows the title and a plain-text excerpt, pauses while hovered or focused, and exposes Reject (delete the saved page/entry) plus Open memory (Brain Edit for wiki pages, Brain Memories for legacy entries). Concurrent saves are shown sequentially rather than replacing one another; failed tool calls do not enqueue cards.
Reset model routing (MIN-111): Work-agent and sub-agent binding panels in Settings β Agents expose Use current chat model (mountStandaloneRoutingEditor in settings-model-routing.ts). It clears both provider/model overrides through the existing persistence APIs, preserves advanced settings and fallback chains, and reloads the panel to show inherited effective routing. Failed saves leave the displayed binding intact.
Per-role prompts and optional provider/model binding. Shipped: default, builder, planner, reviewer, researcher, ui-designer, β¦
The planner work agent is the default for Plan and Super Plan. Its allowedTools is a strict send-path intersection (not a second mode matrix): it includes Context7 MCP ids plus the full issue_* group so planning turns can file, update, and attach plan_path without gaining application-file writes. Empty-workspace plans put scaffold in a solo Wave 1; later tasks Depends on that id (readyTasks does not wait for earlier waves).
src/agents/work-agent-registry.ts, overrides ~/.minnow/work-agents.json. API: /api/work-agents, /api/agent-packs (list/toggle + GET /api/agent-packs/template zip + GET /api/agent-packs/builtin default pack export + POST /api/agent-packs/upload zip install). Settings ? Agent packs: authoring steps, template/default downloads, zip upload, installed pack list.
The isolated turn loop lives in server/runner/ (plain .js + .d.ts, MIN-698). Renderer I/O is src/agents/renderer-runner-deps.ts: session store behind a TranscriptStore (load / append / setMeta), HTTP /api/generations (postChatCompletions), and runHeadlessToolBatch. Node / server callers pass createMemoryTranscriptStore() and postChatCompletionsInProcess (MIN-700 / P2-C) β that adapter creates a generation with persist: false, calls pumpUpstream in-process (no hop through /api/generations), and returns a synthetic Response whose body replays SSE bytes. Default fallback role is sub-agent (agent family, not utility / chat-titles / goal-eval / editor-completion); aborting the signal calls cancel(state). postChatCompletionsHttp remains for tests that POST a fake host directly. The package does not import boards or src/ (the binding may import server/generations/). runTurn() (server/runner/run-turn.js, MIN-699 / P2-B) is the board-agnostic entry from PRD Β§9: runTurn({ chatId, seed, tools, model, onEvent }) returns the six-way object union (TurnResult). chatId is opaque. pass / fail / blocked come only from the injected report tool (default report_outcome); the wrapper never scrapes assistant prose. A malformed report is rejected at execute-time (P2-E) so the model can retry inside the turn β that is not no_report. Inject parseReport for a role-specific schema (Phase 6 finding). Core AttemptResult stays the string alias β P2-F maps .outcome. Any change to this signature is a Phase 6 finding. P6-AβD (MIN-723β726): product chat send is runChatTurn around runTurn(). src/tools/loop.ts and the dual-path flags (minnow.p6a.runTurnChat / minnow.p6c.runTurnChat) are deleted. Chat injects AskCapability, omits the report tool, continues from transcript (seedKind: 'continue'), and disables inner nudge/finalization. Successful chat prose is no_report. Session TranscriptStore is shared from src/agents/session-transcript-store.ts (P2-A seam); product persist wraps it in createChatTranscriptStore (P10-D). P6-B (MIN-724): runTurn({ ask, askTimeoutMs }) is the PRD Β§9 injected capability. ask.ask present β ask_question is on the resolved list and handled in the runner (not executeServerTool); null / omitted strips it even if the caller passed the schema. A fabricated call with null returns an Error: tool result immediately (no hang). Default wait is 60 min (DEFAULT_ASK_TIMEOUT_MS, same as Watchdog chat.generationIdleTimeoutMs); composer Stop is options.signal. Chat spike injects createChatAskCapability (enqueueAskQuestion); board effector passes ask: null. Approval / destructive-confirm hang paths stay renderer executeTool β audit plans/orchestrator-v2-p6b-human-tools.md. P6-C (MIN-725): interface (history continue, optional report tool, nudge/finalization gates) landed. Board createRunnerEffector passes finalizeStructuredOutcome: false so the inner loop cannot ask for sub-agent summary/findings/artifacts JSON (that dump used to show in the transcript while the attempt ended no_report). It nudges report_outcome instead; if the model still dumps findings JSON or a report_outcome object as assistant text, recoverBoardReportIfDumped maps it onto pass/fail/blocked (blocker findings β tester fail / builder blocked). runTurn itself still does not scrape prose. P6-D (MIN-726): one chat turn loop β runChatTurn is a caller around runTurn(). src/tools/loop.ts is deleted. Super Plan, resume (HTTP /api/generations re-subscribe on the first postChatCompletions, not a runTurn option), fork, attachments/VLM, exclusive skill compose, and suppressUserEcho are overlays around runTurn. Stream-end order is setStreaming(false) before notifyChatStreamEnded. Board runTurn is unchanged (report tool, ask: null). P10-I (MIN-774): runTurn({ onRoundBoundary }) splices caller rows at each tool-loop boundary (Phase 6 finding: signature change). Chat implements it; boards omit it. There is still no isBoard branch. P8-G (MIN-760): src/agents/controller/ and renderer src/agents/sub-agent-runner.ts are deleted. Gap list: plans/orchestrator-v2-p6a-gap-list.md. P2-E (MIN-702): Builder/Tester contracts live in server/orchestrator/ so the runner stays board-agnostic. Prompts: server/orchestrator/prompts/ (builder/, tester/, final/ β full + lite; V1 src/chat/prompts/work-agents/builder|tester is untouched). Report tool report-tool.js (report_outcome; Builder { pass | fail | blocked, summary, evidence[], blockers[], needs[] }, Tester { pass | fail, summary, evidence[], testOutput }). Seven seed builders in seeds.js (pure functions of derived task state, golden-filed under test/orchestrator/seeds.golden/; integration-fix is the rerun seed, not a policy-table cell). A Tester seed appends ## Builder report β the latest builder pass's summary, evidence, and files changed outside the declared touches β and points at git diff <integration>...HEAD, so verification starts from the change rather than re-surveying the codebase. Recovery: a continue seed is the seed the interrupted builder was running (initial / fix / rebase / β¦) plus a ## Resume section β a digest of the interrupted attempts' on-disk transcripts (resume-digest.js: its compaction summary, files changed, last actions with exit codes, last notes), so a retry picks up instead of re-exploring. Interruptions β attempts reaped after a restart or stop (evidence.interrupted) and attempts whose model server stayed unreachable past limits.providerWaitMs (10 min for boards, evidence.providerUnreachable) β resume in place without spending the retry budget, up to FREE_INTERRUPTION_RETRIES (3) per role since the last pass. Unattended attempts also end as crashed after limits.maxRepeatedToolCalls (8) identical call+result pairs (repeat-guard.js); chat only gets the warning appended to the tool result. blocked means the environment cannot support the work; policy decide({ role: 'builder', outcome: 'blocked', attemptCount: 0 }) is a same-worktree repair retry. Server tool dispatch (MIN-701 / P2-D) is createInProcessToolDispatch({ cwd, allowedToolNames, modeId }) β required cwd (never a silent workspace-root default), same guards as POST /api/tools via executeServerTool, batching ported from execute-tool-batch.ts. The renderer adapter keeps src/tools/headless-tool-batch.ts and must not import tool-dispatch.js or node.js. In-process adapters (postChatCompletionsInProcess, createInProcessToolDispatch) are re-exported from server/runner/node.js, not the isomorphic index.js barrel β Vite follows unused named re-exports and would otherwise pull officeparser/file-type into the client dep optimizer. Default unattended ids: DEFAULT_HEADLESS_TOOL_IDS (the sub-agent set). Board roles are narrower β headlessToolIdsForRole gives builder read+write (BOARD_BUILDER_TOOL_IDS) and tester/final/merge read-only (BOARD_VERIFIER_TOOL_IDS, matching those prompts' own "do not modify application code"), and no board role is offered or allowed to dispatch Agent Browser or browser_drive_* tools. The final ladder skips the browser rung. Renderer-only port-vs-exclude and the board-scoping rationale: server/runner/tool-set.md. P2-F (MIN-703): createRunnerEffector implements the P1-B Effector interface with real runTurn attempts. start() resolves as soon as the attempt is live (licenses task.attempt.started); inspect() is the in-memory map and stays populated until onEnd resolves. Production wires it via setEffectorFactory in server/runtime/middlewares.js; the scripted effector remains the middleware default so the Phase 1 suite needs no model. Live tokens stream as SSE event: live (live-events.js, no seq) and are never journaled. Caps live in attempt-limits.js: there is no default wall-clock, and no default maxTurns for Builders (tests may still pass either); Testers stop at 80 model rounds (TESTER_MAX_ROUNDS). A caller-supplied wall clock can still produce timeout. Merge is the P3-C queue when worktrees are isolated; Final is the P3-F static ladder in the integration worktree unless tests inject runTurn. The scripted effector stays instant-pass so Phase 1 stays git-free. runTurn({ systemPrompt }) is a Phase 6 finding so Builder/Tester prompts inject without the runner knowing a role. P2-G (MIN-704): a 3-task fixture (test/fixtures/orchestrator-v2-p2g/plan.md) completes at concurrency 1 with the UI closed. The fake model host emits real save_file then report_outcome; tools run in-process against a sandbox workspace (not Minnow product source). E2E + 10-run baseline: test/orchestrator/p2g-e2e.test.mjs, numbers in test/orchestrator/p2g-reliability.json. Model binding is Settings β Autopilot planner (model-binding.js). V2 board UI (#/app/code/boards, src/orchestrator/boards-view.ts) shows the current tool name from SSE event: live. The left-rail board list (ov2__board-item) does not use the V1 ob-row class (that padding nested the selected card); delete is a hover/focus trash icon (.ov2__board-delete, createIcon('trash')) with a second click confirming journal loss via danger styling + aria-label. New board lists top-level documentation/plans/*.md in a dropdown (same discoverOrchestratePlans filter as Orchestrate); POST /api/boards reads that path through resolveSafePath against the workspace (not process.cwd()). Board intake is parsePlan (server/orchestrator/core/parse-plan.js): every task needs Build / Test / Accept / Touches. Preferred emit is - **Label:** bullets (Plan, Super Plan, and Planner prompts); the parser also accepts plain - Label: bullets, bare Label: / **Label:** headings, and nested step lists under an empty - **Build:** (the common Planner shape β those steps used to be dropped, so boards reported "has no Build:"). P3-A (MIN-705): each builder/tester attempt runs in an isolated git worktree allocated by worktree-lifecycle.js via existing worktree-ops.js. start() returns { attemptId, worktree } after the tree exists so the engine journals it on task.attempt.started β there is no worktree registry. Task worktrees seed node_modules (and other ecosystem dep dirs) from the integration checkout via dep-symlinks.js, falling back to the main workspace when that source is a dangling or looping link. ensuredBoards skips the git round-trip on later allocates but still re-runs ensureDependencyDirs. Allocate fails only when the task tree still has a broken dep link after both attempts (the ELOOP class from MIN-628) β an unusable source does not stall the next task. repair, continue, and rebase reuse the previous path; failure-aware and fix get a fresh one (wantsSameWorktree in policy.js). Pass commits via commitWorktree. Engine load() reclaims git worktree list minus journal-live; dirty removals journal opaque worktree.discarded. runTurn({ cwd }) is unchanged. P3-B (MIN-706): rebaseOntoIntegration({ boardId, slotId }) rebases a task worktree onto the board integration tip. Discriminated result: { ok: true, sha } or { ok: false, conflicts: string[] } β a conflict is a normal outcome, not an exception (zero LLM). On conflict it captures git diff --name-only --diff-filter=U, then git rebase --abort and verifies by status / absence of rebase-merge and rebase-apply so the worktree is the pre-rebase tree. Empty and already-current branches return ok with the unchanged sha and do not start a rebase. Rebase, merge, abort, and restore share one in-process mutex keyed by boardId (today mergeInProgress is still a MERGE_HEAD probe plus βlock heldβ). P3-C (MIN-707): merge-queue.js is the mechanical rebase-then-merge step β zero LLM (imports are worktree ops + lifecycle only). For each merge desire: snapshot the integration tip (beforeSha, optional field on merge.succeeded / merge.conflicted), rebase the last builder/tester worktree onto it, merge, verify. A conflict returns AttemptEnd with the file list so the rebase seed can quote it; the owning task is re-opened, never a fixer. After a conflicted merge the task worktree is kept (P3-B abort left it at the pre-rebase SHA) so the rebase-seeded builder already has the unique commits; only a successful merge releases it. Verification failure restores to beforeSha. After a successful merge, refreshIntegrationDepsAfterMerge({ boardId, sinceSha }). MERGE_HEAD on restart is aborted so journal and git agree (completed or not β never half). The runner effector calls runMerge when role === 'merge' and worktrees are isolated. P3-F (MIN-710): final-test.js runs a fixed typecheck β lint β unit β build ladder in the integration worktree after the last merge. Rungs are mechanical (no LLM in the control plane or in merge-queue.js). runInstructions on final.test.ended is command: plus cwd: so a human can reproduce the failure; a fail does not reopen tasks automatically. User Retry POSTs /api/boards/:id/rerun (board.reopened), which reopens abandoned/skipped work (merged stays merged) or appends a synthetic FIX-n task when only the ladder failed. Reset and Rewind are separate (POST /api/boards/:id/tasks/:taskId/reset / β¦/rewind; task.reset / board.rewound) β they wipe debris and do not auto-start. Plan ## Verification Checklist backtick commands override defaults. A recorded baseline at documentation/plans/final-test-baseline.json (or .minnow/final-test-baseline.json) with expectedExitCode and failingPatterns means a matching non-zero unit exit is not a new regression β Minnow's own npm test is a bad ladder target (known-failing suites, fixture rewrite). The runner effector runs the ladder when worktrees are isolated and runTurn is not injected; scripted / explicit-cwd / fake-runTurn stay instant-pass so Phase 1 and P2-G stay git-free. Prompts: server/orchestrator/prompts/final/. Engine still maps AttemptEnd onto final.test.ended. Engine still maps merge AttemptEnd onto merge.succeeded / merge.conflicted β no second event path. P3-D (MIN-708): touches is a scheduling gate, not a diff contract. At board creation, middleware expands each task's declared globs against the workspace (git ls-files) and journals touchesExpanded / emptyTouchesGlobs on board.created so plan() can replay without re-walking the disk. Overlap is declared-glob intersection or frozen file-set intersection (footprintsClash in plan.js). A glob that matches nothing is a board warning, not a run blocker. After a passing builder, the engine diffs the worktree and journals touches.overflow when files sit outside the declared globs β the attempt still passes. Frequency report: summarizeTouchesOverflow. I/O lives in touches.js; the core stays I/O-free. V2 board overflow notes use an informational (not warning) tone. P3-E (MIN-709): default start concurrency is 2 (DEFAULT_BOARD_CONCURRENCY); the fold stays at 1 until board.started. Omitting concurrency on POST /api/boards/:id/start uses that default. Autonomy is Running/Stopped plus the integer (Sequential = Running at N=1; AFK = Running with no interactive gates; Manual = Stopped + per-task start). The cap gates starting, not continuing. V2 UI is that pair, a stepper, and a resource hint (N agents = N model calls + N worktrees; no hard cap). Overlap is journal seq windows, not ts. The N=2 overlap+finish proof in p3e-e2e.test.mjs isolates worktrees (worktrees: true, no shared-sandbox cwd) and asserts merge beforeSha / git shas (queue, not P2-G workspace-head instant pass). Seq/cap/AFK and the 10-run fake-host reliability file stay on the shared-cwd seam for speed. Reliability vs P2-G: p3e-reliability.json (fake host; 10/10 is the deterministic ceiling). V1 autonomy field removal is P4-F. P3-H (MIN-712): an unbuildable task is abandoned and only its dependsOn descendants skip; task.skipped.blockedBy names the abandoned root. Wave-sharing is not a skip. Every task.abandoned carries full attempt history (outcomes, seeds, testOutput / needs / blockers, capped diffs captured in touches.js then attached by the engine). queryAbandonments reconstructs that history from the journal alone. Control plane makes zero LLM calls. V2 board skipped copy is warning-tone βwaiting on X, which failedβ. P3-G (MIN-711): one stateless LLM call after run.finished (or a user stop) writes report.js markdown to ~/.minnow/boards/<id>/report.md and journals opaque run.report.written. Input is the journal plus derived outcomes (not transcripts). The report is terminal output: plan / derive / policy / merge-queue do not import it. GET /api/boards/:id/report; V2 finish pane in board-report.ts replaces the kanban when finished or user-stopped (session-local Board/Report toggle). Stats are journal counts plus git files/lines (no invented elapsed/tokens). Tests inject complete. journalHasReport only counts run.report.written after the last board.reopened. Snapshot format is v3 (Attempt.retired).
Tools: spawn_sub_agent, get_sub_agent_status, cancel_sub_agent. Spawn/cancel POST to /api/agents; the renderer store (src/agents/orchestrator.ts) is a view of derived state (P8-F / MIN-759). The client controller and src/agents/sub-agent-runner.ts adapter are deleted (P8-G / MIN-760). Concurrency cap lives in sub-agents.json and is an effector/plan() argument, not a renderer scheduler. Research depth (General/Plan/Build/Debug): tool-usage fragment investigate-before-answer requires a codebase ? Brain ? Context7 ? web ladder and =2 tool-backed sources before confident factual answers; pairs with strengthened sub-agent delegation (prefer researcher/explore batching). Shipped researcher workers add Brain + code-map; explore workers add rag_web_content + Brain + repo_map/find_symbol; pr-reviewer workers review a PR diff in a background Build chat (never focused; MIN-637) and return minnow.pr-review.v1 findings, rendered by the same panel on Issues and Source Control. Background completion pushes a hidden user resume row to the parent (delivery.js fold + SSE event: deliver + renderer adapter sub-agent-completion-push.ts + hidden-transcript-user-messages.ts) β the model sees it; the transcript does not. The delivery queue is a fold over the sub-agent journal (pendingDeliveries / result.delivered / run.nudged), so MIN-639's "drop only once known delivered" survives renderer reload and server restart (P8-E / MIN-758). Production buildMessage is buildProductionParentMessage (type, status, last summary / abandon evidence β not ids-only). Check-in copy stays the default. Production inject is emitDeliver (throws 'no delivery listener' when no SSE listener so the fold stays pending). That miss is idle: no warn and no 5s retry β GET /api/agents/:runId/events ticks after subscribeDeliver. bootAgentsRuntime() ticks every journal at process start.
Live status UI: While a run is in flight, SSE event: live overlays livePhase (thinking β generating β tools, plus stopping while the fold is cancelling) plus partial reasoning, throttled livePartialText, and the current tool name onto the store (src/agents/orchestrator.ts). Production ensureClient opens /api/agents/:runId/events with withSessionToken (EventSource cannot send X-Minnow-Token); the client stays token-free so tests inject a plain stream β same split as boards. onLive accumulates thinking / tool_call / tool_result / round_end onto run.messages (via applyTurnEventToMessages) so Activity keeps reasoning after the phase leaves thinking and is not an empty generating pane. Tokens are never journaled. Opening the drawer hydrates from GET /api/agents/:runId/transcript (mapped by transcript-messages.js, including coalesced thinking β assistant reasoning on an open stub only β post-tool thinking appends a new row so Activity stays chronological β and attempt_end.summary β assistant content when no later prose exists). Fold summary / error / foldAttemptCount win over sticky empty client overlays. The overlay does not treat a blank fold summary as a structured outcome (that used to paint βSub-agent completed with no text output.β and collapse Activity); Activity stays open unless structuredOutcome or a trimmed fold summary exists. A terminal run with no prose shows the fold error or a short honest empty line. Agent activity lists running, or queued only when foldAttemptCount === 0 β fold idle after a failed attempt is not an active row. onLive drops a frame because it is a replay (stale seq / attempt that ended with an outcome), not because the fold is terminal β a live tool_call after run.cancelled still reaches the client until reap confirms the stop (P10-L / MIN-777). The store overlays livePhase: stopping for that window so the card says Stoppingβ¦, not Generating. A zero-attempt cancel is already cancelled and must not sit on the generating fallback. Emit is still keyed on parentChatId (P8-B); P10-M / MIN-778 filters at the edge so two concurrent cards under one parent do not share activity β /api/agents/:runId/events drops taskId !== runId, and sub-agent-client.ts onLive ignores a sibling taskId and a stale attemptId after retry. Boards still dispatch by taskId on the parent stream. Parent chat cards (sub-agent-cards.ts) sit under the parent spawn_sub_agent tool row (parentToolCallId) and re-anchor on upsert after a history rebuild (P10-K / MIN-776). The drawer (sub-agent-drawer.ts) subscribes and paints Activity through transcript-view.ts + sub-agent-live-status.ts: reasoning uses the same main-chat Thoughts toggle (renderThoughtsToggle) from thinking[] / reasoning / reasoning_content, placed before tool calls; live thinking pulses that toggle (label Thinkingβ¦) instead of a separate details block. Stream-status / tool spinner still mirror main-chat phases for generating and tools. Start failures ride event: error as a consecutive counter on the existing card/overlay chrome (P9-A), not one toast per tick.
Sub-agent cancel origin (P10-L / MIN-777): run.cancelled is only appended by POST /api/agents/:runId/cancel. Client callers: drawer / stop-all / cancel_sub_agent / restart / Super Plan parent_abort / waitForSubAgent's onAbort. waitForSubAgent(runId, signal) POSTs cancel when that signal aborts β Super Plan review uses AbortSignal.timeout. Chat spawn_sub_agent does not pass chatSignal (executeSubAgentTool takes no signal; wait:true calls waitForSubAgent(result.runId) only). cancelAllForParentTurn is indexed by P10-K parentTurnId but is not invoked from stopGeneration or runChatTurn. The "cancelled before it did anything (0 tool turns)" parent line was the fold treating cancel as immediately terminal (cancelledReason before an open attempt, closeOpenAttempts) so delivery fired with toolTurns still 0 while the effector was mid-flight. Fold phase is now cancelling until reap journals attempt.ended; pendingDeliveries waits for cancelled.
Generation timeouts: Settings β Watchdog (config.json β chat.generationIdleTimeoutMs, chat.generationMaxDurationMs) β upstream idle (default 60 min) and max-duration (default 240 min) limits while streaming; 0 disables either limit. Sub-agent recovery (P8-G): there is no heartbeat/stall supervisor. Crashed or timed-out attempts are retried from the journal (plan() + policy table). Wall-clock is defaultTimeoutMs / per-type timeoutMs β P8-D limits.wallClockMs. Dead keys removed from shipped sub-agents.json and AutopilotMeta: heartbeatIntervalMs, heartbeatDeadMs, progressStallMs, duplicateToolCallThreshold. Leftover ~/.minnow/runs/registry/ files are left in place and never imported (no last-write-wins); the journal at ~/.minnow/agents/<parentChatId>/journal.jsonl is the record. Check-in nudge stays one-shot (run.nudged). A structured-outcome parse miss with real assistant prose completes degraded (prose fallback) even with zero tool calls β mapped on the effector (degradeNoReportIfProse), not inside runTurn. Empty no_report still retries then abandons, and abandon still delivers evidence to the parent. Transient HTTP (429/502/β¦) on a work turn retries with backoff in transient-fetch-retry.js; a terminal failure after retries returns the partial transcript instead of discarding the run.
Board attempt wall clock: Settings β Autopilot β Attempt time limit (config.json β autopilot.attemptWallClockMs, default 240 min, clamped 5 minβ24 h, 0 = off). createRunnerEffector reads it on every builder/tester start() and passes it to runTurn as limits.wallClockMs; an explicit options.limits.wallClockMs wins. Hitting it ends the attempt as timeout, routed by the policy table. attemptLimits() itself stays uncapped, so sub-agents keep running without a wall clock.
Board recovery and reports: Tester fix retries retain the task worktree so the builder repairs the implementation the tester reviewed. Commit failures become retryable crashes rather than passes. Attempt finalization errors release live bookkeeping for journal recovery; cleanup errors do not suppress completed outcomes. Timer errors are caught and retried. Reports use current derived task phases, retaining historical failures only as audit evidence. Resume, rerun, reset, task outcomes, and final completion invalidate earlier reports; missing terminal reports retry on the safety timer, including after reload. The report view caches by journal sequence and ignores responses from an older sequence.
Engine / journal graph injection (P8-B, MIN-755): createEngine takes a graph (foldInto, plan, plus board-only hooks). It does not statically import core/plan.js or core/derive.js. Omitted graph is boardGraph. When isRunComplete fires, the engine persists the end-of-run report and journals run.finished / board.stopped / run.report.written in one append so a finished board is already quiescent (extra ticks must not add events). The report writer receives a cloned finished snapshot β live state.finished stays false until that append, so GET /api/boards/:id cannot return finished while the journal still lacks run.finished. The journal store is namespaced (journal-store.js); journal.js is the boards thin binding so boardDir / journalPath / loadState still resolve under ~/.minnow/boards/<id>/journal.jsonl. getEngine / peekEngine / disposeEngines key on (namespace, id) with default namespace 'boards'. live-events.js subscribe keys are opaque (key ?? boardId); payload boardId stays on board SSE frames. BoardState, the event envelope, and the policy table are unchanged.
Sub-agent graph (P8-C, MIN-756): server/sub-agents/ is a second, runless graph for the same engine β independent runs, no dependsOn / waves / touches / merge / worktrees / final tester. Seven events (run.requested, attempt.started, attempt.ended, run.abandoned, run.cancelled, result.delivered, run.nudged); envelope matches P0-B, payload union is not in board EVENT_SCHEMAS. Product type is agentType on run.requested (envelope type is the discriminant). Fold is a pure function of the event list; attempt counts are a filter, never a stored counter. Worker role is 'sub-agent' (isAgentRole); type names live on the run and key the per-type cap. plan(state, caps) takes two caps as arguments (shipped defaults: globalMaxConcurrent 3, per-type maxConcurrent 2) and must not read sub-agents.json. Caps gate starting, not continuing β lowering a cap mid-run does not kill in-flight work. Policy is a table (POLICY_TABLE): crashed / timeout / no_report retry with a continue seed; fail past the cap abandons with a full (never list-truncated) evidence bundle; pass is terminal and waits for result.delivered (P8-E appends it after the parent resume is known delivered; the fold derives pending vs delivered); user cancel is cancelling while an attempt is still open (engine drops it from desired so stop() runs) and cancelled after reap journals attempt.ended β not a policy failure. run.nudged is the once-per-run check-in, also a fold. No watchdog timers in the fold. Inject via createSubAgentGraph(caps) / subAgentGraph. Tests: test/sub-agents/{events,derive,plan,policy,core-purity,conformance,graph}.test.mjs.
Sub-agent effector (P8-D, MIN-757): createSubAgentEffector is the P2-F runner effector's sibling β inspect / start / stop / onEnd, start() resolving as soon as the attempt is live (licenses attempt.started). It maps sub-agents.json onto runTurn() (allow/deny β tools resolved once per type; summarySchema β parseReport; type prompt β systemPrompt; timeoutMs β limits.wallClockMs via attempt-limits.js; context policy onto deps; thinking onto TurnModel; sampler JSON is wrapped as { preset, maxTokens } because runTurn substitutes the whole object for deps.resolveSamplerPreset β a flat { temperature, topP } row would crash applySamplerToBody. Types that omit sampler.maxTokens inherit Settings β Sampler max via readGlobalSamplerForTurn, not 2048). Config is server/sub-agents/config.js (shipped JSON + ~/.minnow/sub-agents.json) β the server does not import renderer sub-agent-config.ts. The shipped file is src/agents/defaults/sub-agents.json, listed in Electron build.files and scanned by scripts/validate-packaged-runtime-files.mjs via new URL(..., import.meta.url). Settings PUT and profile apply call resetSubAgentServerConfigCache() after writing so a type/model/timeout change is live without a restart. Journal binding: server/sub-agents/journal.js wraps createJournalStore at namespace 'agents'. cwd is the spawning chat's workspace from run.requested (required, no worktree, no silent workspace-root default). headlessToolIdsForRole('sub-agent') then the per-type list; no browser_drive_*. ask: null (MIN-724 on a background surface) β same as boards; ask_question is stripped. A parent-injected AskCapability later is an effector options argument, default null; do not add isBoard / isSubAgent in server/runner/. Tokens on live-events.js (opaque key = parent chat id), never journaled. The effector records a lossy P9-D transcript (recordTranscriptEvent with entryDir: agentsDir(parentChatId)); high-frequency types stay off disk. Uncaught throw β crashed. Orphan cancel (cancelOrphanedSubAgentGenerations) only reaps sa- chat ids so it cannot steal a live board attempt. TurnResult.usage lands on attempt.ended. Engine wiring is createEngine({ id, effector, graph: subAgentGraph, journal }) / getEngine(parentChatId, makeEffector, { namespace: 'agents', graph }) β engine.js is not modified. Tests: test/sub-agents/effector-runner.test.mjs. No runTurn signature change (Phase 6 finding: none).
Sub-agent parent delivery (P8-E, MIN-758): the completion queue is a fold, not renderer Sets. delivery.js reads pendingDeliveries(state), injects via an injectable deliverToParent(parentChatId, message) seam, and, in production, waits for POST /api/agents/delivery/ack before appending result.delivered. The renderer ACKs only after the parent resume succeeds and its session save finishes; transport listener count alone leaves the fold pending for retry. The acknowledgement is idempotent. Test adapters may still append after their injection seam resolves. run.nudged is the once-per-run check-in, recorded the same way. An undeliverable parent (chat deleted) still reaches a terminal journal state (result.delivered + skipReason) so the fold stops offering. Orchestrate-mode parents resume like any other chat (resumeDeliverFrame does not discard); adapterParentStatus's skip: 'orchestrate' is test-only wiring. Coalescing while the parent streams is session-local in the renderer (the server cannot see streaming). Resume-message copy (buildSubAgentParentResumeMessage) is unchanged. Production runtime.js uses the disk journal, emitDeliver, and buildProductionParentMessage so the parent resume includes the last summary. A missing SSE viewer throws 'no delivery listener' and parks (no warn, no 5s retry); connecting /api/agents/:runId/events subscribes, awaits tick so a pending completion can emit deliver, then sends done for a terminal run. delivery.js is I/O and is not imported by derive / plan / policy. Tests: test/sub-agents/delivery.test.mjs.
Sub-agent renderer as view (P8-F, MIN-759 / MIN-584): /api/agents/* mirrors /api/boards/* (ROUTES + MUTATING_ROUTES). Spawn is POST /api/agents (preflight before 201; unresolvable model is 400 at the spawn site). Cancel is POST /api/agents/:runId/cancel. List/get/journal/events/transcript are reads. SSE: snapshot + journal frames with seq; event: live / error / deliver have none β reconnect uses Last-Event-ID and must not replay a completed run's tokens. GET /api/agents/:runId/transcript returns the latest attempt's JSONL (open attempt, else last ended; missing file is empty events, not 404). Per-run live SSE drops sibling taskIds (P10-M / MIN-778); the client also ignores a stale attemptId after retry. A terminal fold (passed / abandoned / cancelled) sends event: done then res.end(); the client closes the EventSource so Chromium's HTTP/1.1 six-socket pool is not held forever. Hydrate of a parent chat adopts journal snapshots for finished runs. Delivered terminal runs do not connect; active and undelivered terminal runs retain one parent delivery lease until all children settle and their results are acknowledged, independent of run-card leases. This lease also carries running check-in nudges; only completions require an acceptance ACK. This also keeps the delivery channel open after attempt.ended and reconnects offline completions. src/agents/orchestrator.ts is the SSE-backed store with the same read API the drawer, completion-push, and sub-agent-events subscribers already call. Nothing under src/agents/ outside that store mutates a run. Tests: test/sub-agents/api.test.mjs, test/sub-agents/sse-stream-lifecycle.test.mts, test/sub-agents/live-frame-isolation.test.mts, test/ui/sub-agent-card-states.test.mts, test/sub-agents/renderer-view-purity.test.mjs.
Sub-agent controller deleted (P8-G, MIN-760): src/agents/controller/ (watchdog, heartbeats, timers, last-write-wins registry mirror, boot reconcile) and the renderer adapter src/agents/sub-agent-runner.ts are gone. Super Plan abort POSTs cancel through the SSE store. sub-agent-config.ts stays (effector arguments). Existing ~/.minnow/runs/registry/ files are left, not migrated. No file under src/ schedules, times, heartbeats, or persists a sub-agent run.
Sub-agent E2E reliability (P8-H, MIN-761): the Phase 8 gate. HTTP /api/agents/* with the UI closed (no renderer in the E2E file) proves a run survives reload (GET state = derive(journal)), server restart (inspect() empty β reap crashed β continue seed), a killed model host, wall-clock timeout retried by policy, fail past the cap with a full evidence bundle, cancel-during-approval (AbortSignal, no tool execute), and delivery after the parent was streaming. Ten fake-host runs are recorded at test/sub-agents/p8h-reliability.json β that 10/10 is the deterministic-host ceiling, not a live-LLM measurement. A real provider from a real chat is recorded separately and skipped (not faked) when none is reachable. No runTurn signature change.
Board deletion recovery: Deletion checks workspace ownership using the first board.created journal record, without replaying later events or loading a snapshot. Boards whose later journal events cannot replay remain listed as needing recovery and can be deleted through the normal board list action. Engine disposal cancels live attempts immediately, including engines still loading; late starts are cancelled without journaling into a deleted board. Reset and Stop invalidate pending starts. Journal deletion and snapshot refreshes share the append queue so an in-flight snapshot cannot recreate a removed board folder. Disposed report writers cannot persist late completions; report persistence requires an existing board directory. A damaged creation record still requires ownership recovery before deletion.
Board reconnect consistency: GET /api/boards/:id returns seq with its state snapshot. The renderer uses that position when adopting the HTTP baseline and ignores older streamed events, so replay of a failed integration check cannot overwrite a newer successful re-verification.
Board plan worktree seed: At Start, isolated-worktree boards copy the live workspace plan and commit it only on the board integration branch. Task worktrees can therefore read an untracked or modified plan without changing the user's checkout (ensureBoardPlan in server/orchestrator/worktree-lifecycle.js).
Board worktree path limits: New board IDs longer than 64 characters are shortened with a hash suffix; the full front-matter name remains the display name. IDs derive from that name, not the Markdown filename, and an existing board retains its journaled plan path. Long task-slot names also receive a hash so truncation cannot merge multiple tasks into one checkout. Managed worktree Git commands enable core.longpaths on Windows for that invocation only. Integration reuse, including the lifecycle cache, validates Git metadata; an invalid folder produces a path-specific recovery error without deleting its contents. Existing board IDs and journaled attempt paths are not migrated.
Board task edits: a board's tasks are frozen into board.created; later changes to the plan file never re-parse into the board. To change one card, POST /api/boards/:id/tasks/:taskId/edit with any of title, build, test, accept, touches (graph fields β wave, dependsOn β are refused). Rules live in core/task-edit.js: 400 for a bad body, 404 for an unknown card, 409 while the card is running, queued to merge, or merged (Rewind first). The engine journals task.updated with only the fields that moved (touches re-expanded against the repo at edit time); the fold replaces the spec, keeps attempt history, and bumps TaskState.edits. An edited card's seed says its board spec overrides the plan file, which is not rewritten. UI: Edit on the Spec panel of the task detail. Snapshot format is v4 for the new field.
Board Merge and skip: POST /api/boards/:id/tasks/:taskId/merge-and-skip queues a manual merge of retained task work, including committing outstanding worktree changes. The serialized queue runs on stopped boards too. A successful merge records the retained SHA, marks the task waived, and releases stranded dependents; failed merges leave the task unresolved and retain its work. The card menu offers the action for abandoned, stranded, and dependency-blocked tasks with recorded worktrees. History stays journaled; merged work uses Rewind for undo.
Board hand Skip: POST /api/boards/:id/tasks/:taskId/skip (engine.skipTask) stops the card's agents, closes their attempts (crashed, evidence.skipped), and journals task.waived in one write. The fold sets TaskState.waived; the card's phase is skipped, but satisfiesDependents in derive.js counts it like a merge, so readyTasks frees its dependents and deadEnded does not strand them. Waiving also clears the card's own skippedBy and releases stranded cards that are no longer dead-ended (releaseStrandedSkips), reopening a finished run if any were released. needsAttention excludes hand skips, so the finish report, list attentionCount, and a default Retry (reopenTargets) leave them alone; Retry naming the card, or Reset, clears waived. 409 for a merged card, one mid-merge, or one already waived. UI: Skip in the card actions menu, behind a confirm that lists pendingDependents as likely to fail. Snapshot format is v5 for the new field.
Board plan re-sync: POST /api/boards/:id/resync ({ dryRun: true } previews) re-reads the board's planPath, parses and dependency-checks it like create, then runs a per-field three-way merge in core/plan-resync.js. The base is each card's spec as it last came from the plan (board.created, plan-sourced task.added, and earlier task.updated with reason: 'plan' β planBaseSpecs rebuilds it from the journal). Plan-only changes land as task.updated (reason: 'plan', which does not bump edits); a field changed on both sides differently is a conflict and keeps the board's value; wave and dependsOn merge the same way (deps compare as sets); running/queued/merged cards are blocked; cards dropped from the plan are reported, never removed. The resulting board graph must have every dependency present and no cycle, or the sync refuses (409). A task.updated that changes dependsOn releases skips whose skippedBy is no longer upstream (releaseStaleSkips in derive.js) and reopens a finished run if any were released; the engine re-skips anything still dead-ended. New plan tasks journal task.added with source: 'plan', which reopens a finished run in the fold; an id clashing with an engine-made card (FIX-n) or an unknown dependency returns 409. UI: Sync plan in the board header previews, confirms, then applies (plan-resync-summary.ts).
P4-A (MIN-713): the V1 renderer engine is gone. Deleted: src/state/orchestrate-board-store.ts, orchestrate-board-actions.ts, orchestrate-self-heal.ts, orchestrate-failure-classify.ts, orchestrate-pipeline-holds.ts, orchestrate-board-events.ts, board-log-disk.ts, board-log-invariants.ts, board-task-teardown.ts, board-execution-mode.ts, worktree-isolation.ts, and the V1 kanban src/ui/orchestrate-board.ts. Live boards run in server/orchestrator/ (journal, derive, plan, policy, engine, SSE). The renderer surface is src/orchestrator/ at #/app/code/boards β it never mutates board state. Leftover ChatGroup.orchestrateBoard rows still hydrate old sessions (LeftoverBoardTask in src/types.ts); autonomy on that blob is status (running | stopped) plus maxConcurrentTasks (MIN-718).
P4-B (MIN-714): renderer lifecycle-repair is gone. Deleted: live-stats refresh, send-gate, user-stopped, task-chats, plan-path-sync, leftover completion helpers, V1 model/reasoning chat propagation, and boot wiring that repaired boards after the UI slept. A board does not care whether a window is visible β P1-G restart=replay is the recovery. Presentation keepers live in src/chat/plans/ (plan path / preview / listing / stats-math re-export, plus planner-chat-title.ts so orchestrate planner chats get a deterministic Orchestrator - <plan> sidebar title) and src/orchestrator/task-category-badge.ts. Completion notifications no longer fire from leftover session-board mutation; they belong on journal events. Guard: test/orchestrator/no-lifecycle-repair.test.mjs.
P4-C (MIN-715): the control-plane planner LLM is gone. Deleted: the orchestrator work-agent (src/chat/prompts/work-agents/orchestrator/), its persistent planner chat, src/tools/board-tools.ts (board_init, board_add_tasks, board_update_task, board_set_autonomy, board_get_state, board_report, delegate_tasks), and src/chat/modes/orchestrate-tool-filter.ts. Leftover src/chat/orchestrate/board-setup.ts (planner-hub chrome for board_init) is retired β src/chat/orchestrate/ is empty/gone. Orchestrate opens a board, not a chat: the sidebar hub, mode picker, create_chat_with_mode with orchestrate, and plan handoff all route to #/app/code/boards. Intake is parsePlan via POST /api/boards; concurrency is POST /api/boards/:id/concurrency; reports are typed report_outcome plus the P3-G report writer. Catalog is 104 built-in / 0 app-gated / 104 shipped.
P4-D (MIN-716): V1 test/orchestrate/ is gone. Behaviour keepers live in test/orchestrator/ (journal / core) plus test/chat/plans/list-plans.test.mts and test/orchestrator/stats-aggregate.test.mjs. Scenario catalog tests moved to test/dev/. board:scenario-contract remains; board:persisted / restart / soak / electron-smoke / gate-metrics and seed:test-board were deleted (P1-F/P1-G cover crash and scheduler). npm run test:board aliases test:orchestrator.
P4-F (MIN-718): leftover autonomy is one enum and one integer. BoardState (server/orchestrator/core) carries status (created | running | stopped) plus concurrency. Sequential is Running at N=1; unattended is Running; Manual is Stopped plus per-task start. Hydrate (src/lib/leftover-autonomy.mjs) maps stored AFK/auto-run onto Running and an explicit user stop onto Stopped, then drops the stale keys. Settings β Autopilot stores defaultStatus + maxConcurrentTasks (planner model binding unchanged). Live V2 start still uses DEFAULT_BOARD_CONCURRENCY (2) when N is omitted.
Kanban delivery from plans under documentation/plans/. V2 boards have no planner LLM and no board mutation tools. Leftover V1 hub/onboarding UI may still mount in tests; builder/tester leftover chats keep todo_write for the composer checklist (src/tools/todo-tools.ts, src/ui/todo-panel.ts). Board onboarding git preflight (MIN-615): V2 Start runs programmatic git init + baseline .gitignore + initial commit via POST /api/workspace/initialize-git (initialize-git.js). Existing repos with HEAD are a no-op.
Page family (ob- twin of Super Plan / Research):* Orchestrate hub and live board share one library-first shell (src/ui/orchestrate-page-shell.ts, src/styles/ob-page.css): left rail of board rows + main pane. Hub ask/start lives in .ob-pane--ask (src/ui/orchestrate-hub.ts); opening the hub from a board task chat calls closeBoardChatEmbedForTeardown before replacing #chatArea so openBoardChatId does not block renderBoardView after returning from the rail. The live board nests .board-root in .ob-main with original board chrome from orchestrate-board.css. While the kanban is up, the Code session list (#chatSidebar) and .input-bar are display-suppressed (code-chrome.css, ob-page.css, syncBoardViewChrome) β .ob-rail is the list; Chats in the view bar exits board view the same way it leaves Super Plan. A board chat in .ob-main (.main-column--board-chat) brings the composer back. On narrow widths the rail overlays the main pane (Super Plan pattern); opening a board chat in .ob-main keeps the rail visible (is-chat-open + syncOrchestratePageRailVisibility) and pins it to the full #mainColumn height (syncBoardChatRailColumnBox) so it does not stop above the composer. The composer and #terminalPanel are #mainColumn rows below the viewport: the composer is measured from the padded transcript (syncBoardChatComposerBox, --ob-chat-composer-*) and the terminal from .ob-main (syncBoardChatTerminalBox, --ob-chat-terminal-*) so neither stretches under the rail. Terminal expand (main-column--terminal-maximized) drops the terminal inset because the viewport β and the rail β are hidden. The embed reuses messages.ts into .ob-chat__transcript; while viewMode stays board, isBoardChatEmbedOpenForChat exempts the embed from board-view DOM suppression (bubbles, stream status, ask_question, sub-agent cards) the same way isStreamDomVisible already did for tool rows. The rail's chat level lists every task-linked chatId on the board (even before the chat row is hydrated in session) via listBoardChatRailRows; #chatArea stops acting as a scrollport while .main-column--board-chat is set so the out-of-flow rail is not clipped. Shape brief: plans/orchestrator-boards-sp-research-twin-shape.md.
Testing guide: contributor/orchestrate-board-testing.md β npm run test:orchestrator (alias test:board), fake model, board:scenario-contract. Manual workflow GUI: Settings β Advanced β Board testing (src/ui/settings-board-testing.ts) when MINNOW_DEBUG=1 at build time β scenario catalog + runner (GET/POST /api/orchestrate/board-testing/runs/*), in-process fake model (POST /api/orchestrate/board-testing/fake-model/*). V1 session Seed board and check-log are 410; create boards with POST /api/boards. The HTTP API is enabled when MINNOW_DEBUG=1 or MINNOW_TEST=1 (server/config/dev-surfaces.js). API: server/orchestrate/board-testing/. Scenario catalog: src/dev/orchestrate-scenarios/ (kept β Settings and P1-D scripted effector still use it). Catalog unit tests: test/dev/.
AFK E2E reliability (MIN-513 / MIN-716): the V1 persisted AFK harness is deleted. Scheduler correctness is conformance.test.mjs (P1-F); crash/restart is recovery.test.mjs (P1-G). PR gate: npm run board:scenario-contract. Nightly: orchestrator suite (board-nightly.yml). Release: catalog contract (board-release.yml).
State: leftover Chat.orchestratePlanPath, ChatGroup.orchestratePlanPath, ChatGroup.orchestrateBoard (hydrate-only; autonomy is status + maxConcurrentTasks). Live V2 boards are journals under ~/.minnow/boards/ plus src/orchestrator/boards-view.ts. resolveEffectiveOrchestratePlanPath (src/chat/plans/plan-path.ts) is the resolver for leftover planner prompts and plan <select> restore. Plan dropdowns list top-level documentation/plans/*.md only via discoverOrchestratePlans (isOrchestratePlanPickerEntry excludes nested paths and Super Plan *-spec.md / *-research.md / *-context.md basenames); saved nested paths still appear as extra options. The V2 Boards ask pane (and the leftover create form used in tests) uses that same list. Global defaults (autopilot block in config.json): Settings β Autopilot (src/ui/settings-autopilot.ts) β planner model, isolation, retries, and defaultStatus / concurrency. Reasoning on the V2 header uses src/ui/orchestrate-board-reasoning.ts via wireBoardHeaderReasoningSource (journals board.model.set, no leftover session persist).
Board header (tight instrument strip): V2 live pane reuses .board-header in src/orchestrator/board-header-v2.ts. V1 refreshActiveBoardIfMounted / kanban wave collapse are gone.
V2 Boards page (#orchestratorBoardsRoot, src/orchestrator/boards-view.ts): the only live orchestrator surface. Last-opened resume: teardown remembers the journal id (lastOpenedBoardId, plus sessionStorage keyed by workspace). Re-opening Boards reconnects that client; it must not keep a selected rail row with client === null (that paints renderBoardSkeleton until the user clicks another board). Workspace switch calls forgetLastOpenedBoard. With no board selected, .ov2__board hosts the V1 hub ask/start pane (.ob-pane--ask: plan <select>, preview, Open board, Refresh, Make a plan) via mountBoardsAskPane; Open board is createBoardFromPlan (no planner chat). The left rail stays the V2 journal list; New board focuses the plan picker. While it is mounted, the Code session list (#chatSidebar) and the Code composer (.input-bar plus approval/question/stats companions) are display-suppressed (code-chrome.css, orchestrator-boards.css) β .ov2__list is the list; Chats in the view bar (and the Orchestrate top-bar toggle) call closeBoardsView, which tears down the root, clears leftover V1 board-folder focus (dismissActiveBoardView), stamps #/app/code/chat, and paints sessionState.activeId via renderChatFromHistory β same restore contract as Overview / Dev Servers (restoreChat: false / skipNavigate for stage swaps). There is no V2 board-chat embed, so the composer stays hidden for the whole visit. Start follow-up chat on the end-of-run report (board-report.ts startFollowUp) calls closeBoardsView with restoreChat: false, opens a new General Code chat via createChatWithMode with no initialUserMessage (nothing is queued or auto-sent), and attaches a kind: 'text' composer chip labeled with the board title (board-ref.ts); the payload (id/name, plan, integration branch, run summary, every task with phase, 4000-char report excerpt) is inlined on send through the existing <file name="β¦"> path in buildHistoryUserContent. Git-error / PR-review / Issues Send to chat still auto-run. The live pane reuses the same .board-header instrument strip (title, status badge, tasks Β· waves Β· run telemetry, Orchestrate model chip + reasoning strip, concurrency, Start/Stop/Rerun). A failed ladder is Failed (stopReason: 'terminal'), not Complete. POST /api/boards/:id/start on a finished board is 409; Retry is POST /api/boards/:id/rerun. Model and reasoning are the V1 header widgets (composer-model-trigger.ts board variant, orchestrate-board-reasoning.ts); they read the canonical #modelSelect catalog (so they never stall on "Loading modelsβ¦") and persist with POST /api/boards/:id/model (board.model.set). Create and first Start seed that journal from the menubar #modelSelect default when the board has no override (board-model-bind.ts); POST /api/boards accepts optional providerId/id and journals board.model.set next to board.created. resolveAttemptModel accepts a model id without a provider id (gguf:/mlx: β minnow-library, otherwise the first enabled provider whose cached catalog lists that id) and only throws when no model id exists at all. Changing the top bar later does not retarget a board that already has an override (P9-C). The V2 runner remaps picker minnow-library + gguf:/mlx: onto the live llama-cpp-local / mlx-lm-local serve (and auto-loads via startServe when nothing is running) in effector preflight() and start() before completions β same send-time remap as chat; the journaled chip stays minnow-library (library-binding.js). The chip is detached before each live replaceChildren and re-homed (board-header-v2.ts). Reasoning journals on / off / low / medium / high; the header remains editable while running. Changes affect new attempts; current attempts keep their snapshot. The effector stores selected effort in per-attempt transcript metadata so the shared runner honors the level, alongside TurnModel.thinking.mode. Markup shares the V1 class names; rules are restated under .ov2 in src/styles/orchestrator-boards.css so Phase 4 can delete V1 CSS. Start/Stop and concurrency POST through client.ts; the header does not write board state. Changing N on a stopped board only affects the next Start (setConcurrency journals board.started and would start the loop). Task detail opens as a centered near-full dialog overlay over the whole Boards shell (.ov2.is-detail-open + .ov2-detail-overlay / role="dialog") from a card click β Build / Test / Accept first, compact meta strip in the header; dismiss via Close, scrim click, or Escape (same-card click does not toggle). Focus returns to the task card on close (task-detail.ts renderTaskDetail). Attempt logs are chat-shaped threads (MIN-777): GET /api/boards/:id/tasks/:taskId/transcript JSONL is folded by transcript-adapter.ts into API messages and painted with transcript-view.ts (src/styles/transcript-view.css). Live thinking/tool SSE goes through subscribeLive (not subscribe) and patches kanban activity lines plus the open thread tail in place β a full paintBoard() is journal/snapshot/error only, so a streaming thought cannot swallow the card click or remount chats (collapsed Thoughts caret and tool-call fadeUp stay). transcriptStructureKey ignores growing thinking text so the 1.2s transcript poll does the same. Kanban cards show retry count / live activity from client.ts (getLiveActivity, retryCount). Board SSE snapshots carry journal-derived attemptStartedAt and attemptEndedAt sidecars, including synthetic merge attempts. Completed task cards retain current-run total agent time; Work rows and transcript headers retain per-attempt duration, including retired attempts. Completed clocks remain static and survive reload; display timestamps remain outside the pure BoardState fold. Per-task Rerun is POST /api/boards/:id/rerun with taskIds. Reset (POST /api/boards/:id/tasks/:taskId/reset, journal task.reset) wipes a non-merged cardβs attempt history, transcripts, worktree, and attempt branch (plus skipped dependents blocked by it) and leaves it Planned β it never unmerges. Rewind (POST /api/boards/:id/tasks/:taskId/rewind, board.rewound) is the merged-card action: restore integration to that mergeβs beforeSha, then wipe this task plus later merged/started/in-flight/merge-queued work (and skipped cards blocked by that set). Neither auto-starts; if the board is already Running, tick() may pick idle work. Retry (board.reopened) is unchanged: it keeps attempt history and does not rewind git. Vocabulary is 19 types (EVENT_SCHEMAS). Helpers: resetTargets / rewindCascade.
V2 workspace scope (MIN-752): Journals stay global on disk (~/.minnow/boards/<boardId>/) but are listed and started only for getWorkspaceRoot(). board.created stamps workspacePath; older journals are inferred at list time (workspace-scope.js) and never rewritten. Mutating commands 409 when the board belongs to another workspace. Git/SCC/composer/dev-server worktree pickers list every checkout git worktree list returns for this repo, including orchestrator slots under ~/.minnow/worktrees/ (filterUserFacingWorktrees, MIN-789). Reconstructing a client-side repo key to hide "other-repo" slots was wrong: porcelain is already repo-scoped, and that drop hid this-repo board worktrees when the Code workspace was a linked checkout or on Windows. Task slots are {boardSlug}-wave{n}-{taskId} (board display name via sanitizePathSegment, wave number, declared task id β e.g. Auth-Rewrite-wave1-W1-A) under ~/.minnow/worktrees/<repo>/<boardId>/<slotId>; the matching branch is minnow/board/<boardId>/<slotId> (slotIdForTask / attemptBranch). Integration stays integration / minnow/board/<boardId>/integration. In-flight UUID attempt folders are not renamed. Board branches (minnow/board/β¦) stay in History; the branch dropdown omits them only while another worktree has them checked out (filterUserFacingBranches). Workspace switch confirm-and-stops running V2 boards the same way as V1 (workspace-switch-guard.ts), then refreshes the Boards list and git/SCC worktree chrome. Stop is best-effort with a timeout so a hung end-of-run report writer cannot block PUT /api/workspace; leftover V1 stopped is persisted. The workspace gate stays clickable until the switch is confirmed (opening/pointer-events: none only after). The in-app confirm overlay is -webkit-app-region: no-drag so Electron macOS frameless chrome cannot swallow the click.
Principal / main worktree (MIN-780): Gitβs first git worktree list --porcelain entry is the principal checkout. Pickers always keep it (even when the Code workspace folder is a linked worktree) and label it main worktree when it differs from the Code workspace path (β workspace stays for the Code folder). Path compares use Windows-safe equality (worktreePathsEqual / panelPathsEqual). Selecting the Code workspace path clears browse cwd (Local). Composer Worktreeβ¦ treating a case/slash twin of the workspace as an attach target is collapsed to the current workspace name / Local; when the workspace is a linked slot, Run on also lists the principal as Main worktree. Issues Send to chat reuses the same run-target panel (composer-run-target-menu.ts) after the mode pick; applyChatRunTargetChoice runs before the seed turn. A chat.worktreeRoot that only differs by path casing from the Code workspace is not worktree mode (isChatWorktreeMode). Sub-agent cwd follows the parent chat worktree via resolveChatSpawnCwd.
V2 plan intake: POST /api/boards returns the existing workspace board for the same planPath (even when the source plan no longer parses); an explicit board id belonging to another plan or workspace still returns 409. For new boards it parses markdown with parsePlan (no model call). YAML front matter, name, ## Wave Breakdown, task headings, and Build / Test / Accept / Touches stay required. Omitted, empty, and placeholder Depends on values (none, nothing, n/a, including punctuation and wrapping markup) are no dependencies; unknown real ids and cycles still fail. parsePlan does no I/O and does not infer previous-wave edges. For agent check_plan, board creation, and resync, the shared validateBoardPlan runs parsePlan and then validatePlanDependencies, which checks explicit references to files, exports, and npm scripts introduced by earlier tasks against repo files and transitive Depends on edges; a missing edge returns line-numbered 400 errors. Implicit contracts still need planner review. A plan failure on Boards offers Repair, which sends the repair task as a regular turn in a Plan chat keyed plan-repair:<workspace>:<path> (plan-repair.ts) β created in the sidebar without stealing focus, reused on later clicks β to edit schema or named dependency edges in the same file with targeted edits rather than a full rewrite (keep waves, task ids, and intent), then retries POST /api/boards when the turn ends. Cancel stops that chat's turn. Repair is a user click; a still-broken file does not auto-start another turn.
V2 Start git preflight: Isolated-worktree Start runs MIN-615 initializeWorkspaceGit via ensureBoardWorkspaceGit in runner preflight() (and again from ensureBoardIntegration for manual card Start). Failure is 400 on Start, not a running board that never launches. Existing repos with HEAD are a no-op. Explicit cwd sandboxes (P2-G) skip it. When init actually creates a repo or the first commit, the journal records opaque board.git.initialized (createdRepo, gitignoreCreated, committed, optional commitSha) β same tolerance as worktree.discarded. No Wave 0 card and no V1 yes/no dialog on the V2 Boards page.
V2 board metrics: The Code status-bar metrics button opens a board-scoped rollup on Boards. Completed attempts retain provider token usage and a compact model-round speed measure in task.attempt.ended; the derived state stores those values and uses snapshot version 6. In-progress attempts contribute round_end usage from the board client's live sidecar, keyed by attempt and round, until the final journal event replaces it. Missing provider counts remain unknown, and synthetic merge attempts do not count as active model work. The shared chat metrics strip resumes its chat values when Boards closes.
Orphan worktree reclaim: Latest recorded worktrees of unmerged tasks are retained even after their attempts end, so abandoned work survives restart for recovery or Merge and skip. boardOnLoad β reconcileOrphanWorktrees. Path compares use fs.realpathSync.native on Windows so 8.3 short names (C:\Users\RUNNER~1\...) match the long path git listed. A handful of crash leftovers still take the per-slot dirty-check + git worktree remove path so worktree.discarded journals. Eight or more orphans use removeWorktreeSlotsBulk (rm + one git worktree prune --expire now) so engine load cannot stall the SSE snapshot. Per-slot remove+prune is O(nΒ²) against git's worktree list β a leaked thousand slots left EventSource on "reconnecting" and later POSTs as Failed to fetch. DELETE /api/boards/:id calls cleanupBoardWorktrees({ includeIntegration: true }) so recreating the same board id does not inherit the previous run's slots. SSE writes : connected and flushes headers before getEngine().
Board metrics strip: V1 board-wide rollup across leftover planner + member chats is deleted (MIN-714). The bottom strip paints the active chat's lastStats via refreshMetricsStripForChat. V2 attempt tokens stay on the journal / live SSE.
Board view browse root: File explorer / terminal / Source Control cwd follow the active chat's worktreeRoot (resolveChatWorktreeRoot, resolvePanelBrowseCwd, syncPanelFromActiveChat).
File tree context menu ? Open in System Explorer: Right-click a file or folder in the Code file tree to open it in the OS explorer (Windows Explorer / macOS Finder / Linux Files). Files are revealed/selected in their parent folder when the platform supports it; folders open as the explorer root. Desktop shell: the renderer validates the path via POST /api/workspace/reveal-in-explorer with openViaHostShell: true, then opens it through Electron shell.showItemInFolder / shell.openPath (electron/shell-reveal.ts, IPC minnow:shell:reveal-in-explorer). Browser / headless: the tool server spawns explorer.exe (Windows), open (macOS), or xdg-open (Linux) in server/workspace/reveal-in-explorer.js. Client: src/ui/reveal-in-system-explorer.ts + src/ui/file-tree-context-menu.ts; path is resolved under the workspace (or allowed worktree override) via resolveSafePath.
File tree ? terminal path drop: Drag a file-tree row onto the xterm host (#terminalXtermHost) to insert the workspace-relative path at the active PTY prompt (src/ui/terminal-workspace-drop.ts, insertTextAtTerminalInput). Uses the same drag payload as composer drop (WORKSPACE_FILE_MIME + text/plain). Copy path on the file-tree context menu copies the path string to the OS clipboard (copyWorkspacePathStringToClipboard); internal Copy / Cut / Paste remain the in-tree clipboard for duplicate/move.
File tree + editor tab icons: Material Icon Theme (PKief) colorful SVGs in the Code file tree and unified viewer tabs β same associations as VS Code (e.g. test.ts β test-ts, vite.config.ts β vite, README.md β readme). Association resolver: src/ui/file-type-icon-resolve.ts; DOM/<img> helpers: src/ui/file-type-icons.ts (reads material-icon-theme/dist/material-icons.json; SVG assets synced to public/material-icons/ by scripts/sync-material-file-icons.mjs on postinstall / prebuild). Wired from file-tree.ts and unified-right-tabs.ts. License note: documentation/THIRD_PARTY_NOTICES.md.
Code left-pane chrome (MIN-655): #fileSidebar header keeps a stable pane cluster β Files (fi-rr-folder / semantic fileTree), Browser (#btnPreviewToggle), Source Control (#btnGitPanelToggle) β with accent is-active highlighting for the current left pane (and Browser when preview is open). File-tree refresh (#btnFileTreeRefresh) sits outside that cluster so switching to Source Control does not drop the Browser control or reshuffle the toggle row. The Files button always shows the folder glyph (no chevron swap); clicking it while Source Control is open returns to the file tree. Layout sync: syncFileSidebarFilesPaneButton + applyFileSidebarVisuals; styles in file-panel.css.
File tree OS import (MIN-592): Drag files or folders from the OS (Explorer / Finder) onto the Code file tree to copy them into the workspace. Drop on a folder row to import under that folder, or on empty tree chrome for the workspace root. Folder drops walk DataTransferItem.webkitGetAsEntry() (src/attachments/directory-drop.ts) and write through UI-only import_workspace_file (src/ui/import-external-files.ts, toolImportWorkspaceFile) β nested files keep their relative paths (server mkdir -p), empty directories use { kind: 'dir' }. Per-file cap matches attachments (10MB); trees over 5000 entries are refused. Internal tree-to-tree moves are unchanged (src/ui/file-tree-dnd.ts).
UI chrome icons: Flaticon Uicons (@flaticon/flaticon-uicons) β Regular Rounded + Solid Rounded webfont glyphs behind a single registry in src/ui/icon.ts (createIcon / iconHtml, 142 semantic names). Styles: src/styles/icons.css (.icon-svg sizing via --mn-icon-size). Git panel pull / push / merge and the AI-commit sparkle inherit currentColor (toolbar --mn-fg, AI --mn-accent-ink); do not apply the raster .icon-img brightness(0) invert(1) tint. CI guard: scripts/check-icons.mjs (npm run check:icons, also in prebuild). Legacy registries (src/os/icons.ts, mode-icons.ts, git/board helpers) delegate to the central API. Carve-outs: Material file-type icons (above), provider brand marks in model-producer.ts, Minnow fish glyph (minnow-glyph.ts), PWA icons public/icons/icon-192.png / icon-512.png.
File tree incremental refresh: Agent filesystem writes trigger a debounced (500ms) subtree patch instead of a full tree rebuild. affectedDirsFromTool maps mutating tools to parent directories; refreshDirectories re-fetches only those listings and patches [data-tree-dir] containers in place. Scroll position, keyboard focus (data-path), and expanded folders are preserved across renders. Git status polls patch badge spans in place via setFileTreeGitStatus. Auto-refresh wiring: file-tree-auto-refresh.ts (defers while the user interacts with #fileTreeHost; shows a pending dot on #btnFileTreeRefresh). syncFileTreeToPanelWorktree({ force: true }) soft-refreshes when the listing root is unchanged (orchestrate board ticks no longer collapse the tree). Editor saves rely on save_file auto-refresh only (no duplicate bridge call).
Terminal panel (MIN-500): Bottom dock tabs are Agent (command output) + interactive PTY sessions only (src/ui/terminal-tabs.ts, src/ui/terminal-panel.ts). Agent execute_command stdout/stderr chunks call notifyChatStreamActivity so board stall credit tracks live tool output. Docked resize clamps to a 311px minimum PTY viewport (--terminal-xterm-min-height on #terminalXtermHost; panel floor in src/ui/terminal-layout.ts). Header Expand (#btnTerminalMaximize) fills the chat column by hiding composer/messages chrome (main-column--terminal-maximized); click again or hide the panel to restore the docked height. The former Dev Server virtual tab / log stream bridge was removed; workspace server logs move to the Dev Servers Code screen. PTY command history: submitted lines are still stored per tab in localStorage (src/ui/terminal-xterm.ts + src/ui/terminal-history-nav.ts, keyed by tab id, 500 entries). ArrowUp/Down for shipped shells (zsh, bash, Git Bash, fish, WSL, PowerShell, cmd) pass through to the PTY so the line editor owns history (usesShellNativeHistory / shouldInterceptPtyHistoryArrow). Client-side Ctrl+A/Ctrl+K line replace is only a fallback for unknown profiles β injecting it into zsh echoed as ^A^K on already-used macOS tabs (MIN-670). PTY clipboard: Ctrl+C with a selection copies instead of SIGINT (terminal-copy-shortcut.ts); Ctrl+V is intercepted and pasted via term.paste() so xterm does not send SYN (^V) to the shell. Cmd+V on macOS uses xterm's native paste event. Keystrokes are queued until xterm finishes parsing the latest PTY output (terminal-pty-input-gate.ts) so ArrowUp after a command is not delivered while zsh is still in cooked mode (^[[A / ^[[B). Interactive PTYs spawn with a sanitized env (pty-env.js: TERM=xterm-256color, drop COLUMNS/LINES/TERMCAP) and unix shells use -il. Tab bar metadata (tabs, activeTabId, sessionId per tab) persists in config.json via src/config/terminal-meta.ts; pagehide flushes live tabs with keepalive PUT and does not kill server PTYs so reload reconnects over WebSocket (scrollback replay from server/terminal/pty-ws.js); explicit tab close still DELETEs the session. WSL (Windows): installed distros are listed in server/terminal/shell-profiles.js (wsl:<distro> ids); execute_command and PTY spawn route through wsl.exe when Settings ? General ? Chat & terminal ? Default shell (or a per-workspace override in config.json terminal.workspaceShellProfiles) selects WSL. Path mapping: server/terminal/wsl.js (C:\οΏ½ ? /mnt/c/οΏ½ via wsl --cd). Setup: contributor/setup-from-source.md. Git Bash (Windows): listed as id git-bash when Git for Windows bin/bash.exe is found (never PATH bash.exe, never git-bash.exe); PTY uses --login -i with MSYS env (CHERE_INVOKING, MSYSTEM, MSYS=enable_pcon, MSYS2_PATH_TYPE=inherit); execute_command one-shots use --login -c and skip the cmd.exe unix-pipe guard. Agent sandbox does not wrap Git Bash into WSL Landlock.
Agent shell sandbox (MIN-553): One-shot agent commands (createRun / createBackgroundRun, source: 'agent') compose an optional argv wrapper after resolveOneShotSpawn (same pattern as WSL): resolveOneShotSpawn β applyAgentShellSandbox β spawn. Setting: toolSecurity.shellSandbox β off | prefer | require (default off; Settings β General β Agent shell sandbox) β same for orchestrate boards (no separate Autopilot default). On Windows, require is clamped to prefer at runtime and hidden in Settings. MINNOW_SHELL_SANDBOX=1 still elevates off β prefer for dev. Prefer unavailable β Ask strip; require β fail-closed (macOS/Linux only). Trailers + UI badge + board-log sandbox event. Platforms: macOS uses Seatbelt via /usr/bin/sandbox-exec (seatbelt.js). Linux uses the minnow-sandbox Landlock (+ minimal seccomp) helper (landlock.js, C source native/minnow-sandbox/); build with npm run sandbox:build-helper; packaged under Linux AppImage extraResources as resources/minnow-sandbox; override with MINNOW_SANDBOX_HELPER. Landlock write allowlist includes /dev/null, /dev/zero, /dev/tty (same as Seatbelt literals) so shell redirects work while /dev stays read-only in the read tree. Probe/--probe maps exit 75 ? landlock_abi_unavailable. Windows requires WSL2 + Landlock: wsl-landlock.js routes agent one-shots through WSL then applies the same wrapWithLandlock helper inside that tree; WSL wraps pass compactHomeRead: true so argv stays under Windows CreateProcess limits. Native Linux enumerates the home allowlist unless --read/--write would exceed the helper cap (LANDLOCK_HELPER_MAX_PATHS / C MAX_PATHS, currently 1024) β then buildLandlockArgv falls back to compact home reads and caps path lists (scoped /tmp sibling grants max LANDLOCK_MAX_SCOPED_WRITE_GRANTS) so the helper does not exit 64. package:win runs scripts/ensure-minnow-sandbox-helper.mjs and ships the Linux ELF via uild.win.extraResources; on first use Minnow copies it into the distro at ~/.local/share/minnow/minnow-sandbox (preferring that over /mnt/β¦ noexec mounts). Override with MINNOW_SANDBOX_HELPER. Bare WSL alone is not containment; missing WSL/helper/ABI β wsl_unavailable / Landlock reason codes (prefer β Ask; require β error). Git Bash one-shots skip WSL-Landlock (native_win_shell, no Ask). Native Win sandbox is future work. Profile workspace: filesystem write containment (workspace/worktree + temp + package caches), deny-read for ~/.minnow (re-allow active worktree slot + logs/terminal) and host credentials (.ssh, .aws, .config/gh, οΏ½), network allowed in v1. Landlock is allowlist-based (home is enumerated excluding deny roots οΏ½ not οΏ½allow home then carve outοΏ½). No Docker/OCI. Interactive user PTYs are never sandboxed. manage_dev_servers / model serve pass sandbox: false. Phase 2: run_javascript / run_python route through executeCommandBlocking ? createRun (model code no longer uses direct runProcess); Python binary discovery still probes --version outside the sandbox. Background execute_command shares the same wrap. Phase 3 wired. Board worktree isolation is git isolation only οΏ½ complementary, not a substitute. Plan: plans/min-553-agent-shell-sandbox.md.
V1 pipeline holds, failure classify, display-wake, board log invariants, and orchestrate-board-actions.ts: deleted in P4-A (MIN-713). V2 scheduling is plan.js; reports are the structured report_outcome tool (report-tool.js). The packed MIN-354 v1 copy at server/session/engine-bundle/ was deleted in P4-E (MIN-717); there is one engine (server/orchestrator/).
Task-chat stall watchdog: Heartbeat ticks at 3 Γ progressStallMs kill only (same as the fixer path): increment the stall-restart counter, mark the chat stall-stopped, stopGeneration, and store a stall reason. Live execute_command stdout/stderr chunks call notifyChatStreamActivity so a long but chatty tool does not false-stall.
Fake model server (orchestrate testability): scripts/fake-model-server.mjs β local OpenAI-v1 HTTP stub (GET /v1/models, POST /v1/chat/completions SSE) driven by ordered scenario JSON ({ match: { role?, taskId?, nth? }, emit: [...] }). Completions are a finite SSE dump (Connection: close, maxRequestsPerSocket = 1); close() is idempotent β it destroys tracked sockets, waits for libuv 'close', then server.close(). On Windows it then yields 75ms so --test-force-exit does not double-close undici uv_async handles (UV_HANDLE_CLOSING). Do not pair closeAllConnections() with server.close() on Windows. Default scenario for V2 boards emits report_outcome. Settings catalog adapters still emit leftover board_report for those scenarios. Per-(role, taskId) counters reset on fake-model start/stop. npm run fake-model -- --register writes provider fake-board under ~/.minnow/providers/. GET /api/providers/fake-board/models returns { data: [] } when the in-process host is stopped (avoids 500 on packaged boot with a leftover dev provider). The same route for a live provider whose host is down (LM Studio not running, connection refused) returns 200 { data: [], unreachable: true } instead of HTTP 500 (proxyModels); the picker still treats unreachable as βcannot reach providerβ. Exports requests for assertions.
Creating a test board: POST /api/boards (plan markdown) or the Boards create form. V1 seed:test-board and POST /api/orchestrate/board-testing/seed are retired (410).
Board log CLI: npm run check:board-log and POST /api/orchestrate/board-testing/check-log are retired (410). Leftover JSONL under ~/.minnow/logs/orchestrate/ can still be tailed. V2 history is the journal under ~/.minnow/boards/<id>/.
V2 engine tests: test/orchestrator/ β derive, plan, policy, engine, recovery, conformance, worktrees, report, P2-G / P3-E e2e. Run npm run test:orchestrator.
Board failure classification & recovery: V1 transcript scraping (orchestrate-failure-classify.ts) is deleted. V2 agents report through report_outcome (report-tool.js).
Task-chat stall watchdog: Heartbeat ticks at 3 Γ progressStallMs kill only (same as the fixer path): increment the stall-restart counter, mark the chat stall-stopped, stopGeneration, and store a stall reason. Continue-nudge (first stall) and runSelfHeal category stall (second) run from stream-end via runAfterChatRelease, after setStreaming(false) and slot release. Queued continuations are not invoked while isTaskChatActive. If the card is still in_progress/testing and idle after that flush, autoDelegateNext (via isTaskStalledForRestart) is the last-ditch re-drive β not a replacement for every slot-release drain. Live execute_command stdout/stderr chunks call notifyChatStreamActivity so a long but chatty tool does not false-stall; a silent hang still does. Stream-end board refresh re-asserts main-column--board-chat via ensureBoardChatComposerChrome so the embed composer cannot vanish when generation stops.
Context enforcement (MIN-39, compaction v2): Task/test/fixer chats run through runChatTurn with the active work agent's context policy. Enforcement uses the active model's full context window (resolveContextBudget in context-budget.ts); when the limit is unknown, trimming is skipped and the context ring shows compression disabled. Global default: Settings β Agents β Context policy (defaultContextEnforcementPolicy in sub-agents.json) applies unless a work agent or sub-agent type sets its own (Inherit global default in per-agent settings). Precedence: per-agent user override β global β shipped builtin; resolution in resolve-context-policy.ts. Policies: compact (default), slide, truncate. Stored summarize / dropMiddle / archive still load and run as compact (normalizeContextEnforcementPolicy). No policy makes a completion call β LLM summarization was deleted. Compaction (server/runner/compaction/, plan: documentation/plans/context-compaction-v2.md) runs inside the shared turn loop for main chat, boards, sub-agents and Super Plan alike: under 80% of the message ceiling nothing changes (the prompt prefix stays byte-identical); over it, a checkpoint elides old tool bodies into #row stubs, then folds whole turns (then rounds of the current turn) into a deterministic ## Prior context (compacted β rows #aβ#b folded β¦) summary targeting 50%. The latest real user row and the current round are never folded; the summary merges into a following user row so roles alternate. A turn is a real user row plus everything to the next one; a round is an assistant row with its tool results. Checkpoints are rows, not rewrites: main chat appends a UI-only context row carrying compaction (foldThroughIndex, elideThroughIndex, summary, machine state); chat.history keeps every folded row, and the next send passes the latest checkpoint as runTurn({ compaction }) so the opening transcript is projected the same way. Row ids are history indices: the session store's load returns rowIds, append returns the new index, and the loop tags each live row with its id at persist time (resolveRowId) because rowShift only aligns the tail. Edit / retry truncation slices the checkpoint row away with the rows after it. Once a checkpoint exists the loop offers recall_history (query β BM25 over the unprojected rows grouped by turn; rows: "120-140" β verbatim slice), answered in run-turn.js for every runner; a call that arrives through search_tools before any checkpoint is answered too. Main chat passes recallHistory (recall-client.ts): it fetches the SQLite FTS ranking from GET /api/config/sessions/recall/:chatId?q= (porter-stemmed messages_fts, rankChatHistoryRows) and fuses it with the local BM25 by reciprocal rank, so stemmed matches surface while tool-call arguments (not in the index) and unsaved rows still match. The route also answers rows= / q= as text for callers without the rows in memory. Board attempts and sub-agents keep checkpoints in memory and emit a context_compaction turn event; transcript-messages.js folds it into a UI-only context row (filtered out of continue seeds) that the drawers render as a divider. The retired recall_chat_context / recall_turn_full tools and src/chat/archive/ are gone; stored summarize / dropMiddle / archive policies read as compact everywhere (validators, work-agent registry, packs, Settings). Global compaction knobs (defaultContextCompaction in sub-agents.json: highWater, lowWater, minRecentTurns, summaryBudgetTokens) fill whatever an agent leaves unset (withCompactionDefaults); main chat, /compact and the ring share resolveChatContextBudget. The context ring projects through the latest checkpoint and predicts the next one with the same code (estimateContextPolicyTrim); History excludes the summary, which is its own Compaction summary segment, and the warn line sits at the high-water share (trimAtShare), not the ceiling. An existing checkpoint alone is not a pending compaction. The breakdown panel's Compact now writes the same manual checkpoint as /compact. In the transcript each checkpoint renders as a divider (compaction-divider.ts); rows the latest one folds (compactionFoldView, which keeps a request the fold cut through) are dimmed as not in model context. Manual /compact [focus] (aliases /compress, /summarize) writes a manual checkpoint over all but the recent turns β focus text lands under [User notes] β and never rewrites history (compact-command.ts). slide / truncate still go through applyContextPolicy and leave a clickable context notice row. During a live turn the ring also counts in-flight tokens from getContextInFlightOverlay (pending tool-call JSON priced as payload; streaming prose/reasoning do not count as prompt input). Writes are coalesced with paint (P7-B grain), not per token.
Context overflow recovery (MIN-783): Product chat passes limits.contextBudget and limits.modelContextLimit into runTurn. For a loaded model, contextLengthFromModelRow prefers loaded_context_length (llama.cpp /props n_ctx / -c) over capabilities.contextLength (n_ctx_train). Local llama.cpp / mlx-lm hosts count prompt + generation against n_ctx. Tool schemas still come out of the message ceiling; Settings max_tokens does not β leftover after the live prompt is applied as body.max_tokens instead (resolveLocalWindowReserves in context-budget.js). Subtracting a 32k Settings max from a 70k serve was what crashed chats the context wheel still showed as roomy. If a provider still rejects the request as over-window (isContextOverflowText β llama.cpp, OpenAI, Anthropic, mlx/OMLX wording), the shared loop in sub-agent-runner.js records estimate bias (estimate-calibration.js), compacts again against a tighter effectiveLimitOverride (a checkpoint with trigger overflow; slide / truncate re-run applyContextPolicy), and retries the same round (cap 2). Compact that cannot shrink returns contextBudgetExhausted; runTurn maps that to { outcome: 'crashed' } so the UI gets a failed turn (Continue / Clear) instead of a quiet no_report. Salvage of a stream error applies only to this round's streamed prose or tool turns β prior assistant rows in history do not count as a finished reply.
Personas under src/chat/prompts/experts/<id>/. Chats: Chat.kind === 'expert', memory under pages/experts/<id>/facts/. UI: Experts' Lab (releaseState: 'hidden' on the Experts app β not on the app rail or in onboarding; #/experts when enabled).
Workspace-first stage in #osStage (src/os/shell.ts): menubar, workspace chrome, left app rail, then #osAppsLayer for fullscreen apps.
One workspace per view. Minnow opens several workspaces at once as separate Electron windows, each running its own full SPA renderer bound to one folder (electron/shell-window-registry.ts). The window's folder reaches the renderer through webPreferences.additionalArguments and is read as window.minnow.viewContext (src/state/view-workspace.ts); a window that already names a folder skips the workspace gate. A folder opens in exactly one view β opening it again focuses the window that has it, because sessions.db keeps one global revision and two views owning the same chat rows would 409-thrash. Switching folders inside a window retargets the view and replaces the renderer rather than tearing state down in place β except when this window is already on that folder (isWindowOnWorkspace / isCurrentWindowWorkspace), which is a no-op so launches such as Issues Open plan do not spawn a replacement window for slash/casing-equivalent paths. Window geometry and the folder set persist as { version: 2, windows: [...] } (electron/window-state-schema.ts) and are restored at boot.
There is one stage per view. No floating windows, no dock, no desktop surface, and no standalone chat surface β apps, including Scheduler, take the foreground stage; the Scheduler job editor overlays its app. #osDesktopLayer, #osWindowsLayer, #osDockLayer and #osSidePanelsLayer are stripped at boot if they appear in markup; do not reintroduce them. Frameless Electron controls remain in src/os/window-control-buttons.ts.
| Region | Contents |
|---|---|
| Workspace gate |
#osWorkspaceGate β folder picker until workspace chosen |
| App rail | Released apps (listRailApps) β src/os/app-rail.ts
|
| Fullscreen apps | Code, Research, Issues, Scheduler, Settings, β¦ in #osAppsLayer
|
Menubar status pill (#osStatusText / legacy #sText, src/ui/status.ts): operational Ready / loading / error feedback. Frameless Electron chrome uses user-select: none for window drag, but the status pill restores selectable text. Error states are click-to-copy (full message ? clipboard + toast); selecting text first still allows a normal partial copy.
App rail and stage: released apps open as full main-view surfaces in #osAppsLayer, with a 48px left app rail (--sidebar-rail, 16px icons) (src/os/app-rail.ts, src/styles/minnowos-rail.css). Right-click a rail tile (except Code) offers Open in new window / Focus window in Electron: that launches a second BrowserWindow bound to the same folder via --minnow-app-window=<appId> (electron/main.ts openOrFocusAppWindow). One window per app; the SPA boots app-only chrome (viewContext.appId, no rail, no workspace gate) from src/os/app-window.ts. App windows do not claim/release the workspace allowlist (the main window owns that) and are not persisted in minnow-window-state.json. The item is omitted in a plain browser. ShellWindowRegistry.findByWorkspace ignores app windows so the one-folder-one-session-view rule still holds.
Workspace gate: full-stage gate (src/os/workspace-gate.ts, src/styles/workspace-gate.css): #osWorkspaceGate hosts the shared welcome-page (#welcomeView) until a folder is chosen; boot hash is #/workspaces (OsView: workspaces | app; #/desktop redirects in resolveLegacyHash). The picker is list-first: compact Open folder / Create project actions, a pinned Sandbox row, then recents of folders the user opened directly (no View all). Picking a workspace routes to #/app/code; #btnWorkspace / menubar slot opens the same picker. Cold boot replaces empty, #/desktop, and restored #/app/β¦ hashes with #/workspaces (startApp); initApp always awaits the gate until the user picks a folder (even when a project path is already persisted server-side).
Hidden Research implementation: Research is disabled for release. Its retained implementation is a fullscreen app at #/app/research (src/research/panel.ts, #researchView in #osAppsLayer). Run tab idle state: centered hero (accent mark, title, seed prompt chips) and a bordered composer shell with Options disclosure for rounds/scope overrides; progress and results replace the hero while a run is active. In-flight runs keep executing when you switch apps or reload; the client persists minnow.research.activeRunId in sessionStorage, GET /api/research/library merges in-memory running tasks, and opening Research re-subscribes to SSE (resumeActiveResearchIfNeeded). Leaving Research detaches the stream only (Stop, embed back, and Stop all still cancel). Product launch and hash entry points reject this hidden app; src/ui/research-panel.ts only reparents #researchView for legacy Code-embed teardown helpers.
Chat and Sandbox: general assistant threads live in Code (Sandbox / Scratch folder or project workspaces); modeId: desktop normalizes to general on read (normalizeModeId). File panel and terminal dock prefs persist per workspace under workspace.filePanelByPath / workspace.terminalByPath.
Chrome rules: job editors use in-app overlays (job-editor-overlay.ts), never window sheets β there are no windows to sheet onto. There is no Settings wallpaper section; theme families load via src/theme.ts for animated backgrounds painted directly on the workspace shell. navigateToDesktop() / showDesktop() are deprecated aliases for navigateToWorkspaces() / showWorkspaces(); call the workspace names in new code.
Narrow layout: below 768px (NARROW_MQ, html.mn-narrow in mobile-layout.ts) the rail docks as a bottom tab bar; Code chat/file lists use overlay drawers (#sidebarBackdrop, mobile-drawer-portal.ts). Rail tiles use delayed hover tooltips and focus-visible rings; Research open/close syncs rail active/hosting via subscribeResearchPanel. Ctrl+Tab cycles MRU rail apps (app-focus-cycle.ts); Escape closes embedded Research via dismissOpenLayers. theme-contrast.test.mts includes rail active (--mn-accent on --mn-surface-elevated) pairs.
Vibe hub (#vibeHub, src/ui/hub.ts): empty Code chats paint a landing in #chatArea (heading, composer, metrics strip, recent threads). Mode is chosen from the composer strip; the hub has no Build/Plan/Debug/Explain/Orchestrate intent chips. Orchestrate still opens from the Code sidebar.
Code reparents #appBody into #osAppsLayer. Chat transcript mount + inset overlay routing (sub-agent drawer, goal eval) live in src/ui/chat-mount.ts: chat mounts into Code only, at #mainColumn / #chatArea. The Code view bar (#codeViews, code-chrome.css) leads with Chats (#btnCodeViewsChats, code-views-chats-toggle.ts) to show/hide the session list (or exit full-stage Code views such as Overview / Orchestrate hub / Super Plan / orchestrate board via isCodeStageViewHidingChatSidebar β board view hides the Code session list and composer in CSS, same as Super Plan, because #orchestrateBoardPage already has .ob-rail; a task chat open in .ob-main (.main-column--board-chat) brings the composer back). Orchestrate (#btnOrchestrate, code-views-orchestrate-button.ts) shows a warning activity dot while a V1 or V2 board is running and the boards surface is closed; the OS app rail only switches apps (app-rail.ts). Menubar chat toggle remains on narrow layouts via menubar-visibility.ts. Every view-bar destination is a Code hash section (#/app/code/chat, /overview, /dev-server, /super-plan, /orchestrate, /map) so switching between them cannot revive a stale Overview URL. closeOtherCodeStageViews tears down competitors without restoring chat; isCodeStageOverlayMounted blocks kanban refresh while any of those overlays own the stage.
Tool-call rows in the transcript (tool-messages.ts, tool-call-presentation.ts, messages.css). Collapsed, every call is one fixed-width 32px line reading glyph ? action ? target ? outcome ? Read src/ui/icon.ts ? 402 lines, Run npm test ? exit 0. tool-call-presentation.ts owns the per-tool registry: TOOL_ICON and TOOL_ACTION give each tool its own glyph and verb; buildToolRow resolves the target (path / command / query, with paths ellipsized mid-string so the basename survives) and the outcome measurement that tool produces (lines, matches, exit code, commits, +/-). Status is encoded once: spinner in the glyph slot while running, neutral glyph plus a measurement when settled, danger-tinted glyph plus literal words (not found, exit 1) on failure ? no success pill, no green tint. describeToolFailure maps errno strings to a plain sentence shown at the top of the body. Expanded bodies use structured views (buildFriendlyToolBody: listings, found paths, grep matches grouped by file, commits, git status, shell output, ask_question cards with the chosen option highlighted, read previews) plus readable key/value argument fields (buildToolArgFields, skipping anything the row already showed). Verbatim I/O sits behind a single Raw input and output disclosure, and only when a structured view replaced it. File mutations still render as open diff cards. Dev harness: /dev/tool-row-preview.html?theme=swamp-dark (dev/tool-row-preview.ts).
The Code chat sidebar header keeps its navigation controls ordered as collapse sidebar, search chats, then Code overview. Collapsed to the 48px icon rail (src/styles/sidebar.css), orchestrate board folders show only the board group glyph β waves and member chats are hidden until the sidebar expands (or the mobile overlay opens) (renderSidebar). renderSidebar reuses keyed row/header nodes in place (no innerHTML = '') so CSS animations are not restarted; tool batches and other hot paths call scheduleRenderSidebar (one rAF) while click/switch still render immediately (MIN-584). Transcript rebuild paints off-DOM then replaceChildren so the previous chat stays on screen until the swap.
Dev Servers screen (MIN-500): First-class Code section at #/app/code/dev-server (src/ui/dev-server-screen.ts, src/styles/dev-server-screen.css). Sidebar footer rail button #btnDevServers. Detect in chat opens a normal Build chat and sends a short visible request with the detailed registry instructions as ephemeral turn context; it does not spawn a sub-agent or maintain a second status surface. Three-pane layout: server registry (top), collapsible logs (middle), collapsible listening ports (bottom). Logs and ports section headers toggle aria-expanded collapse; ports toolbar mirrors logs with search + scope filter (all / dev servers / protected / other), column sort on Port / Process / PID / Source, Refresh, and Live toggle (aria-pressed) for polling. Add/edit opens an anchored popover (src/ui/dev-server-form-popover.ts) with command, name, and port up front; a collapsed Advanced disclosure retains working folder, worktree, network, health URL, and auto-start. Save errors stay inline, duplicate submits are blocked, and Escape/click-away closes the popover. It registers with the Electron preview visibility gate and is torn down on navigation. Each server row and add/edit form include a Worktree <select> (from git worktree list) so start/restart spawn in the chosen checkout; worktreeRoot is persisted on registry rows and optional one-off override via POST ?/start|restart body. Multi-server registry in config.json ? workspace.devServersByPath (server/dev-server/registry.js); runtime state nested under workspace.devServerByPath[<key>].servers with legacy flat-row ? servers.primary migration (server/dev-server/manager.js; background health reconcile on ~250ms ticks promotes starting?running when healthUrl is set; default health-start timeout 45s, override MINNOW_DEV_SERVER_START_TIMEOUT_MS). Agent tool: manage_dev_servers (list / create / update / delete / start / stop / restart) ? server/dev-server/tool-handler.js; code-exec group; startup.md-linked rows keep command/cwd/health on disk. Port injection (server/dev-server/effective-guide.js): resolveEffectiveGuide reads the package.json script body for npm|pnpm|yarn run <name> and classifies the stack (vite / next / electron-vite / cra / split-stack / unknown). npm run dev alone is not Vite. Vite gets --port / --host; Next gets -p; electron-vite, CRA, and unknown get no CLI flags (avoids CACError: Unknown option '--port'). Spawn env always sets PORT (API or sole port), VITE_PORT (UI/client port, same as board tasks), HOST, and VITE_DEV_SERVER_HOST. Split-stack repos (concurrently API + Vite): when startup.md is npm run dev, Minnow expands package.json scripts, injects --port into client children only, sets PORT for the API (apiPort in startup.md or UI port + 1) and VITE_PORT for the UI, and health-checks the UI port. electron-vite only binds Minnow's port if the project config reads process.env.VITE_PORT (or equivalent). APIs: GET/POST /api/workspace/dev-servers, PUT/DELETE ?/dev-servers/:id, POST ?/:id/start|stop|restart, GET /api/workspace/ports, POST /api/workspace/ports/kill (server/workspace/middleware.js, server/dev-server/ports.js). Log pane backfills via fetchTerminalLog then tails SSE (src/ui/dev-server-log-view.ts). Hub strip cell is status + open-screen only (src/ui/hub-dev-server.ts). Legacy /api/workspace/dev-server/* routes remain primary-server aliases.
Router: src/os/router.ts. Boot: initOsPageBridge() ? initOsShell() ? initOsRouter(). App transitions: fullscreen app-to-app switches activate the next layer before hiding the previous one (app-host.ts, .mn-os-app-enter in minnowos-shell.css). Lazy full-page apps (Brain, Settings, β¦) defer showAppLayer until after ensureAppInitialized and the page module sets is-open, so bundled global.css never reveals Brain with only the hide rule while brain-page.css is still loading. Settings area scroll uses scrollSettingsTargetIntoView on .settings-content.
Notifications: menubar bell inbox (src/os/notifications-menu.ts, src/notifications/). Sounds use packs under public/sounds/packs/<packId>/ (src/notifications/sound-packs.ts): each pack maps three cues ? turn_complete, question, tool_turn ? to audio files; notification kinds resolve to a cue at playback time. Default pack Minnow ships turn-complete.wav, question.wav, tool-turn.mp3. Prefs: minnow.notifications.soundPackId (default | none), minnow.notifications.soundOnActiveChat (play cues while watching the active chat in Code without bell rows). Settings ? General ? Notifications.
Desktop alerts use trusted shell IPC and Electron's native Notification in electron/desktop-notifications.ts, bypassing Chromium origin permission. Delivery acknowledges show or reports failed/timeout; clicking restores the originating workspace window before navigating to the alert target. Notification objects and click callbacks are retained until dismissal, replacement, or window destruction. Browser sessions use the Web Notification API. Test desktop notification in Settings requests browser permission when needed and tests delivery even while the window is focused; normal alerts remain background-only and respect notification preferences. Windows notification settings apply separately to installed Minnow (org.grimmedia.minnow) and Minnow Dev (org.grimmedia.minnow.dev).
Menubar workspace (Code): while Code is foreground, workspace-menubar.ts reparents #workspaceControlSlot into the menubar left cluster (#osMenubarWorkspaceSlot, where the grid app switcher used to live). The control shows the folder basename with the full path on hover (title on the label and control); the folder button sits left of the name. Responsive layout: src/ui/mobile-layout.ts stamps mn-phone, mn-tablet, mn-touch, and mn-narrow (viewport < 768px) on <html>. OS breakpoints: styles/minnowos-responsive.css, styles/minnowos-rail.css, plus styles/mobile.css. Below 768px the app rail is a bottom tab bar; narrow Code uses overlay side drawers. app-switcher-menu.ts remains in tree for tests but is no longer mounted from the menubar.
Product help overlay: the menubar ? opens #/wiki without changing the underlying app lifecycle. Router handling leaves the current app surface mounted while product-wiki.ts owns overlay visibility and restores the prior hash on close.
Shell appearance prefs (minnow.os.*): disabled apps via minnow.os.disabledApps (src/os/app-preferences.ts).
16 themes: <html data-theme="{family}-{mode}"> (8 families ? dark/light). All hex/rgba only in src/styles/tokens.css; app code uses --mn-*.
Fonts: --font-ui and --font-mono live on :root in tokens.css. Settings β Appearance β Fonts writes the chosen stacks onto documentElement via applyAppearanceFonts. Presets live in font-catalog.ts (System + a large Google Fonts catalog for UI and mono; Geist was removed). Only the selected UI + mono pair is fetched, via a single fonts.googleapis.com/css2 stylesheet injected at apply time β boot CSS does not @import webfonts. Unknown stored ids (including retired geist / geist-mono) fall back to System. Stylesheets must consume --font-ui / --font-mono (not a --mn-font-* alias) so the preference reaches every mono surface.
Runtime: src/theme.ts + src/appearance/persist.ts, Settings β Appearance. Palette choice, custom colors, and fonts persist to ~/.minnow/appearance.json (and a localStorage cache for first paint). The SPA HTML injects window.__MINNOW_APPEARANCE_BOOT__ from that file so a new Chromium origin still boots the saved theme. Packaged Electron prefers loopback port 9473 so the renderer origin stays stable when the port is free.
Custom colors: simplified seed mode (bg / fg / accent / danger) expands via theme-derive.ts. --mn-success is always a semantic green (not cloned from accent); stored simplified palettes that still have success === accent are repaired on apply (custom-theme.ts).
color-mix gotcha: Prefer color-mix(in srgb, ?) (or a solid --mn-surface-* token) for fg/bg veils. Mixing near-achromatic --mn-fg into --mn-bg with in oklch can drop hue to none and paint a cool lavender wash on warm themes (e.g. coral-light). Dev Server log host uses --mn-surface-0 for that reason.
Design reference: DESIGN.md, documentation/design-system/.
| App | Route | Server / storage |
|---|---|---|
| Models | #/app/models |
/api/models/*, /api/system/hardware, downloads, serve; inspector β Load persists per-model launch settings (models.launch.byLibraryId via PUT /api/models/launch, library-launch-meta.ts); inspector β Inference tab persists per-model sampler overrides (models.inference via PUT /api/models/inference, library-inference-meta.ts, merged at send in run-turn-chat.ts); Inference nav (Routing, Sampler, Thinking) persists global / role edits on change (settings-model-routing.ts, settings-sampler.ts, settings-thinking.ts) |
| Compare | #/app/compare |
server/compare/, ~/.minnow/compare/
|
| Bench | #/app/bench |
src/benchmark/, ~/.minnow/benchmarks/
|
| Evals | Headless API / ~/.minnow/evals/ (no Settings page; Bench covers in-app runs) |
server/evals/, ~/.minnow/evals/
|
| Research | Code embed (#/app/code + panel); legacy #/research redirects |
server/research/, ~/.minnow/research/
|
| Scheduler | #/app/scheduler |
server/scheduler/, scheduler.json (jobs only run while app open) |
| Backup and restore | Settings β General (no app of its own) |
server/backup/, /api/backup/*, backup.json
|
| Voice | Models β Voice |
server/voice/, built-in ONNX Whisper Tiny + system speech; optional Python Whisper / Qwen |
| Settings | #/app/settings |
Configuration via /api/config/*. Six independent sidebar disclosures: General, Code & workspace, AI & agents, Extensions, Data & privacy, About & troubleshooting. Each destination mounts one page, including separate Skills and Skills Library hubs; search opens only the requested field disclosure. Shared settings-general groups use flat dividers, readable labels and connected descriptions. Forms use a centered 56rem column; active wide catalogs/tables use 72rem. Pane container queries reflow forms in split/floating windows. Appearance owns desktop interface zoom, including legacy General field links. Research-only fallback controls remain hidden with the Research app. Models settings reparent the same source sections into Models. Cross-links use openSettings / openModels to foreground the destination (settings-page.ts, settings-page-types.ts, settings-general.css, settings-page.css). |
Deep Research is a dedicated panel (not a composer mode). Its library rail (src/research/library.ts) lists saved runs; row labels prefer the report's markdown title (from extractReportTitle on finalize / library backfill) over the original query. Right-click a row (or use the ? control) for archive, delete, and related actions. In-flight runs checkpoint activity_log to ~/.minnow/research/ during progress (server/research/store.js) so reload or a tool-server restart can still hydrate the Evidence ledger from GET /api/research/detail/:id. The run header Brief tab stays hidden until a finished run has a saved report; Evidence is the only view while a run is working. Compare runs 2?6 blind model slots. Bench runs integration + academic packs; distinct from eval harness task packs.
Managed SearXNG (server/servers/searxng.js, port 8899, autostart): writeSearxngSettings merges engine overrides into upstream defaults β Bing enabled (stock SearXNG ships it disabled), Google left on, and brave / duckduckgo / startpage disabled so rate-limited scrapers do not zero out general JSON search when Bing or Google still respond. Settings are rewritten on each spawn via getSpawnSpec. Empty tool results surface SearXNG unresponsive_engines when present (server/tools/web-search-searxng.js).
Godot status also returns the project name and saved main-scene setting from project.godot. GET /api/godot/resolve-resource?project=<workspace-relative-directory>&uri=<res://...> maps a project-local resource URI to a workspace path with symlink and traversal checks. A uid:// main-scene reference remains unresolved until an engine-backed resolver is added.
Godot integration: authenticated GET /api/godot/status (after request workspace scoping) discovers project.godot files in a bounded, cache-skipping workspace walk. One project is selected automatically; multiple projects require project=<workspace-relative-directory> (or lsp.godot.project for LSP). Engine lookup prefers MINNOW_GODOT_PATH/GODOT_PATH, then a checksum-verified Minnow-managed runtime, PATH, platform/Steam/Scoop locations, and bounded portable-build discovery under Downloads/Desktop; it probes --version without a shell and accepts Godot 4 only. godot_control install_engine and POST /api/godot/install select the newest stable 4.x release (or a requested stable version) from the official godotengine/godot-builds feed, select the platform/architecture archive, require a matching entry in SHA512-SUMS.txt, verify before extraction, and persist it under ~/.minnow/runtimes/godot/. The built-in godot LSP entry uses a project-scoped TCP transport: opening GDScript starts one managed editor with allocated loopback LSP/DAP ports, shares its LSP socket across editor/agent/index scopes, and feeds the existing completion, diagnostics, hover, navigation, symbols, and formatting bridge. The DAP client supports launch, breakpoints, threads, stack/scopes/variables, evaluation, continue/pause/step, events, and disconnect through godot_control; a visual debugger panel is not yet mounted. Managed editor and game processes are distinct and only Minnow-owned children are stopped. Other APIs: POST/DELETE /api/godot/editor, POST /api/godot/run, /stop, /validate, /task (import/test/export), /debug, and GET /api/godot/logs; argv spawning and project-relative scene/script/export path checks are mandatory. Agent tools godot_inspect and godot_control expose the same bounded status, logs, saved-scene outline, install, editor/run/stop, debugger, validation, import, test-runner, and export operations. GET /api/godot/scene-outline still reads saved text scenes only; it does not deserialize or claim live editor state. Source loaders cover .gd, .gdshader, .tscn, .tres, .godot, and .gdextension; Brain Code includes .gd, while indexes and file search exclude .godot/. Live editor scene mutation and unsaved Godot selection/state still require the deferred companion. Plan: plans/godot-support.md.
Board checkout restoration preserves the existing task branch when a tester or same-worktree retry recreates a removed checkout; it does not merge newer integration commits during allocation. Integration conflicts remain the merge queue's responsibility. MCP discovery skips invalid or reserved entries in a hand-edited standard mcpServers map so one bad entry cannot block board/chat startup or shadow built-in servers; API imports still reject those entries.
LSP: Bundled TS/JS + on-demand language bundles; config ~/.minnow/lsp.json, defaults src/lsp/defaults.json. Catalog src/lsp/bundles.json β npm bundles install under ~/.minnow/lsp-servers, GitHub binary bundles download release assets, gopls uses go install into ~/.minnow/lsp-servers/bin (Go must be on PATH; upstream does not ship prebuilt gopls release assets). APIs: /api/lsp/* including POST /api/lsp/format and /api/lsp/format-range (whole-document / range textDocument/formatting; Shift+Alt+F in the Code editor). Optional formatOnSaveLanguageIds in lsp.json (LSP languageIds, default []) runs formatting in saveViewerTabByPath before save_file. Node bridge (server/lsp/manager.js) uses cancellable sendLspRequest with per-method timeouts (completion 1.5s, hover/signature 1s, definition 3s, formatting 10s, initialize 20s), incremental didChange when the server advertises sync kind 2, event-driven createDiagnosticWaiter for editor structured diagnostics, capped stderr/diagnostic snapshot LRU, and shutdownAllLsp() after PUT /api/config/lsp. The LSP client answers workspace/configuration per requested section (defaults for bashIde, html, css when lsp.<id>.settings omits them). GraphQL spawns graphql-lsp server -m stream (stream IPC β not stdio). Bash diagnostics come from ShellCheck (requirements.binary: shellcheck in defaults); without it the server still runs but usually publishes no diagnostics. HTML (vscode-langservers-extracted) validates embedded CSS/JS more than loose markup trees (unclosed tags may not surface). GraphQL validation expects a workspace GraphQL config (e.g. .graphqlrc.yml) per graphql-language-service-server. Browser client (src/lsp/completion-client.ts) returns discriminated LspClientPostResult errors. TypeScript 7 no longer ships tsserver.js; tsserver-fallback (npm alias to TS 5.8) supplies the bundled fallback path for typescript-language-server. Node-based servers spawn process.execPath with ELECTRON_RUN_AS_NODE (server/lsp/node-runtime.js), never a bare node: packaged servers live inside app.asar (only Electron's Node can read them) and Finder-launched macOS apps inherit a bare PATH with no Homebrew/nvm node β spawning node there died mid-initialize with Pending response rejected since connection got disposed. Agent-scoped get_lsp_diagnostics resolves paths and spawns language servers against the active tool workspaceRoot (board/chat worktree override via getEffectiveWorkspaceRoot()), with per-root agent connections so initialize rootUri matches the isolated checkout. Snapshots key on file bytes plus a TypeScript project fingerprint (tsconfig/jsconfig/package.json and @types/node from the file up to the workspace root β server/lsp/project-fingerprint.js); when that fingerprint changes, the agent-scoped tsserver is restarted so Vite vite.config.ts is not stuck on a stale Cannot find name 'process' after Node types or tsconfig.node.json land (MIN-616). The agent diagnostic waiter starts its total timeout only after getConnection and document sync; default total budget is initialize timeout (20s) plus a 10s settle margin (30s). On timeout without publishDiagnostics, it retries once with forceChange, then falls back to cached state.diagnostics like the editor structured path.
Packaged Node spawns: Impeccable context/CLI tools (server/impeccable/spawn-env.js) and the scheduler runner (server/scheduler/runner.js) spawn bundled .mjs scripts via process.execPath with ELECTRON_RUN_AS_NODE (same helper as LSP/Brain), so packaged Electron does not treat the script path as a workspace folder.
Scheduler hosting: both hosts start and stop the Scheduler through server/scheduler/host.js β startSchedulerForHost(url) once the HTTP server is listening, stopSchedulerForHost() on shutdown. server.js calls it in a checkout; electron/server-host.ts calls it for the in-process server of an installed build. Keep it a single call per host: test/scheduler/host.test.mjs fails if either host drops it. A run is a child process running minnow run against that server, and server/scheduler/headless-entry.js picks the script: bin/minnow.mjs (tsx over src/headless/cli-main.ts) in a checkout, and in an installed build the single-file bundle dist-headless/minnow-run.mjs, because neither tsx nor the .ts sources ship. scripts/build-headless-runner.mjs (npm run headless:build, esbuild) writes that bundle; electron-builder-run.mjs and package-linux-docker.mjs run it before electron-builder, and it is listed in build.files. The bundle must import nothing but Node builtins β the build fails otherwise, and test/headless/bundle.test.mjs runs it from an empty directory. The child gets the host's session token through MINNOW_TOKEN rather than reading ~/.minnow/session-token, which holds only the newest host's token when two hosts share a home. Its cwd in an installed build is the Minnow home, since the app root there is app.asar, a file. Headless model selection resolves explicit CLI choices before work-agent defaults and validates explicit providers without falling back to an unrelated active provider. Synthetic minnow-library selections use authenticated POST /api/models/library/bind to reuse the server's shared library resolver, including cold loads, saved engine choice and bounded readiness. Disconnected clients cancel their wait without stopping a shared serve.
Impeccable install location: agents only ever see ~/.minnow/skills/impeccable/. server/impeccable/skill-install.js copies the shipped copy (repo: src/skills/impeccable/; packaged: Resources/skills/impeccable/ via extraResources, excluded from app.asar) into the Minnow home on first skill listing / Impeccable API or tool call, rewriting src/skills/impeccable/β¦ and {{skill_dir}} in markdown to that absolute path. A .minnow-managed.json marker records the seed hash (reinstall only when it changes) and makes the scanner report the copy as builtin; an edited installed SKILL.md survives refreshes. Never point skill text, reference readers, or run_impeccable live / load_impeccable_context back at the app root β a packaged app.asar is unreadable by agent shells and real node. Asar-resident registry: harness-registry.mjs and harness-commands.mjs stay in app.asar (the in-process server imports them at boot). The JSON twin is extraResources-only for skill install β importing it from asar is what made v0.1.3 fail to start with a misleading fetch failed.
Design pass (run_impeccable detect): the chat label is Design pass. The handler (server/impeccable/run-impeccable.js) runs the bundled Impeccable CLI (detect / live only; harness commands return reference markdown). Omitted detect target resolves to existing UI roots (src/ui, src/styles, index.html), else conventional source dirs (src, app, β¦), else index.html, else . β it does not silently scan the whole workspace (a full-tree walk timed out at 60s). http(s) targets are rejected (Puppeteer). Detect is invoked with --json. CLI exit 2 means findings were found: the wrapper must not prefix Error: (chat cards treat that as a failed run), and isToolResultFailure / isImpeccableDetectFindingsResult still treat a leftover Error: impeccable detect exited 2 banner as success. On timeout the child is SIGTERM then SIGKILL / taskkill /T /F, and the error names the paths that were scanned.
MCP: Standard mcpServers objects live in ~/.minnow/mcp.json, alongside compatible legacy servers entries and mcp/servers/*.json. Settings JSON import and mcp__minnow__add_servers merge validated entries and reload connections. URL transports use SDK Streamable HTTP with legacy SSE fallback; stdio accepts command/args/env/cwd. server/mcp/oauth.js implements SDK OAuth provider persistence (encrypted per-server credentials), loopback callbacks with state and PKCE, discovery, registration, and refresh. Sign-in links appear in server summaries. Tool listing follows pagination; disabled/removed servers are rejected at dispatch. MCP server addition grants full tool access, including legacy Ask/Off overrides, across chat and headless agents; per-tool permission controls are removed. Chat round boundaries, headless rounds, server sub-agents, and board attempts refresh tool discovery for same-task installation while retaining schemas loaded through search_tools when still authorized. Server in-process dispatch admits MCP tools independently of built-in role allowlists; registry checks remain authoritative. Context7 remains built in and works without a key. Board attempts load its two schemas immediately when the server is connected, even when lazy tool discovery is enabled, and retain them after round refreshes.
Plugin packages (API v1): Settings β Plugins replaces the Apps settings page (plugins section; the legacy apps category remains a bookmark alias). server/plugins/manifest.js validates bounded local packages, paths, ESM syntax, tool JSON Schema and namespaced skills; manager.js stages immutable releases and atomically switches ~/.minnow/plugins/registry.json. Connections use security/secret-box.js; values are excluded from package metadata and secret fields are redacted. Native ESM handlers run as explicitly trusted local code in disposable workers (runtime.js, worker.js), with timeouts, cancellation, concurrency/output/heap limits. Workers are not a security sandbox. Source updates require explicit reload; invalid updates preserve the previous release. Disable/remove revokes contributions and terminates workers. Plugin data survives removal. HTTP lifecycle routes live under /api/plugins/packages through the existing authenticated plugin middleware in both hosts.
Settings accepts a public GitHub repository/tree URL or a local folder through a single source field and the existing folder picker. server/plugins/source.js downloads codeload source archives with the shared GitHub host/SSRF guard and redirects disabled, avoiding unauthenticated REST API rate limits. github-archive.js validates TAR checksums, PAX commit/path metadata, paths, duplicate entries and bounded decompression (16 MiB compressed / 64 MiB unpacked); selected packages reject links/special files and retain the 256-file / 8 MiB limits. Repository roots containing exactly one nested plugin resolve to that folder URL; ambiguous repositories require a folder link. Archive submodules are not downloaded. Validated files pass through the existing package validator in a temporary folder removed after reading. /inspect accepts source for explicit Settings imports, returning the resolved source, digest and optional commit; /manage accepts source and the reviewed commit for install/update and checks archive commit identity. Registry records retain the resolved URL and installed commit, while reload fetches the stored ref again. Explicit Settings folders and HTTP reload can read outside the active workspace without changing it; legacy path imports and agent local-path operations retain filesystem guards. No install hooks run, .git metadata is excluded, and no persistent download cache is added. Tests: test/plugins/source.test.mjs, test/plugins/package-api.test.mjs, and test/ui/settings-plugins.test.mjs.
plugin_inspect and plugin_manage plus the built-in /build-plugin skill support agent authoring and live installation. Mutations are blocked by the client/server Plan write guards. Tools join the existing plugin__* loader and refresh on chat/CLI round boundaries; unattended server sub-agents and boards discover only Full-permission tools and recheck permission at dispatch. Skills join the shared skill scanner as plugin-<package>-<skill> and are revoked on disable. Panels use opaque-origin allow-scripts iframes (src/plugins/panel.ts) with a restrictive content policy and a source/channel-bound bridge limited to the owning package's tools; calls go through normal tool approval. API and user contract: Plugins. Legacy ~/.minnow/tools/ single-tool plugins remain supported by their existing API and cannot collide with a package id.
Trusted plugin UI: API v1 packages optionally declare ui: {entry: 'ui.mjs'}. server/plugins/manifest.js validates the bundled ESM entry; /api/plugins/packages/:id/ui/:release serves only its current enabled release through host authentication. src/plugins/ui-runtime.ts fetches and imports source as a blob module, activates it before initial routing for plugin app deep links, watches the shared catalog, and disposes/replaces owned contributions on lifecycle changes. UI modules run in the host document as trusted code with DOM/storage/authenticated API privileges; existing settings panels remain isolated. Activation has an asynchronous five-second deadline; failures roll back managed contributions and surface in Settings. This does not contain synchronous host-document hangs.
src/plugins/ui-context.ts exposes DOM mounting (mount, named mountSlot targets), namespaced menu/command registration, app registration, tool dispatch with release pinning, abort and cleanup. Dynamic app metadata joins the OS registry under plugin-<package>--<app>; the rail and app-host recognize it, and optional app preferences survive cold boot. Plugin apps use the main renderer, not native app windows. Menubar plugin commands and rail context menus use the shared menu registry. Managed DOM mounts follow replacement targets using a shared observer and dispose per-target subscriptions. Direct DOM changes and other resources require plugin cleanup. src/plugins/usage.ts provides copied chat/workspace ledger totals and live/last metrics without messages or credentials, scoped to the view workspace; src/plugins/events.ts coalesces ledger, dirty-state, streaming, selection and workspace changes. Totals count completed recorded requests rather than context capacity or estimates. The UI-only examples/plugins/chat-token-counter/ demonstrates a counter next to TPS, a rail app and menu/palette actions. /build-plugin and the Codex backend instructions explicitly distinguish native read-only sandboxing from authorized Minnow dynamic tools and support authoring in any workspace.
Plugin UI modules also register direct / actions through ctx.registerSlashCommand. The shared slash registry merges namespaced commands and optional collision-checked aliases into the composer picker. composer-send routes them past the streaming queue and messaging handles them before model selection; errors do not fall through to generation. Disable/reload/remove dispose handlers. Bundled plugin skills remain the agent workflow path shared with the headless CLI.
Fork build CUDA discovery: server/models/llama-fork-build.js checks CUDA_PATH, CUDA_HOME, nvcc on PATH, versioned CUDA_PATH_V* variables, and installed toolkit folders (Windows NVIDIA CUDA versions under Program Files; Linux /usr/local/cuda and /usr/local/cuda-*). Folder fallback selects the newest version containing the compiler, so an already-running server can detect a newly installed toolkit despite stale environment variables. Source builds pass the detected compiler and toolkit root to CMake and set CUDA_PATH for build subprocesses; explicit fork CMake flags still win.
Local models workspace: My models uses searchable grouped rows with visible load actions and All / Loaded / Needs attention filters. Status filters apply before variant grouping so the displayed quantization matches its runtime state. Local Server separates sessions, the selected ready endpoint, and runtime logs; keyboard session selection follows its log, including crashed runtimes, while telemetry patches retain focus and scroll. src/ui/models/load-settings-popover.ts moves the existing inspector into a native top-layer popover for launch settings and restores its location, visibility preference, and focus on dismissal. load-settings-layout.ts switches Basic / Advanced panels without rebuilding controls. llama.cpp keeps context/GPU/cache and memory estimates in Basic; MTPLX uses descriptor-driven profile/context/KV/reasoning controls in Basic and preserves detailed controls in Advanced. Both use the existing per-library launch preferences and engine-specific defaults. Fixture: test/fixtures/models-workspace-ui-harness.html.
Model details inspector: src/ui/models/inspector-details.ts renders the model identity/status, Overview facts/capabilities/connection, and native Model & files disclosures. inspector.ts provides keyboard-accessible Overview / Inference tabs, keeps actual launch values in a Runtime snapshot disclosure, and exposes Load settings as a footer action rather than a side-panel tab. Dismissing the popover restores the previous tab and initiating control; Close model details persists the existing visibility preference. Inference uses uniquely labelled sampler inputs with explanations from settings-sampler-fields.ts. src/models/mtplx-loaded-with.ts formats recorded MTPLX values as readable facts, including explicit zero/false overrides, and counts environment variables without showing values. Unmatched serve ids retain runtime status, connection details, and snapshots. models-inspector.css applies only to the details panel, preserving popover layout. Telemetry does not rebuild sampling controls.
My models deletion: Row trash actions confirm the selected quantization and disk path through appConfirm. DELETE /api/models/library accepts a library id and expected model path; server/models/delete.js resolves it from a fresh cached-model scan, rejects changed/linked targets, loaded serves and unfinished downloads, and removes GGUF weights (all matching split shards) or the whole MLX model folder/HF repo. Other GGUF quantizations and shared projectors remain. artifact-access.js prevents deletion from racing serve/download admission. Deletion invalidates the server scan cache and refreshes the Models store and shared model pickers; saved chat bindings remain available for history.
Provider connection settings: Expanded provider cards save field changes and Enter submissions in place through a coalesced, serialized write queue. Successful saves update the summary and saved field defaults without replacing the form; newer drafts remain intact. Connection tests and capability probes wait for saved changes. Add-provider forms remain explicit submissions.
Parallel KV context sizing: planLlamaLaunch accepts kvUnified. With unified KV enabled, ctxPerSlot is the maximum for one request within a shared pool and ctx is that pool's total -c, without multiplying by parallel. With separate caches, ctx = ctxPerSlot * parallel. Inspector drafts preserve the selected context when toggling unified KV and preserve the pool size when changing slot count in unified mode; the meter refreshes immediately. Persisted manual ctx remains a literal total -c and is estimated as saved, including values above the slider ceiling. The server planner uses the merged kv_unified setting and honors explicit KV flags in extra args.
Recommended model samplers (MIN-10): src/models/sampler-presets.ts holds reviewed official model-card recommendations for the shipped recommended models and popular Qwen3 / DeepSeek R1 families. The Models inspector's Inference tab matches repo/file names, offers an explicit Apply preset, and writes ordinary editable overrides through the existing models.inference API. Presets never run on model selection, never change thinking mode, and preserve output limits and unspecified fields. Inspector saves are ordered so a subsequent field edit wins over an in-flight preset save. Client and server sampler normalization retain minP: 0; completion mapping sends explicitly set neutral min-p / penalties so runtime defaults cannot silently undo a preset. Provider sanitization still strips unsupported sampler fields.
Models β Routing has keyboard-accessible Models by task and Fallbacks tabs that preserve their mounted forms. Task rows expose binding source, effective model, explicit follow/reset actions, and progressive Options. Fallbacks exposes shared backup order, limits, enablement and host cooldown. Every routing row and fallback-chain entry uses the shared searchable combined provider/model picker and preserves missing saved bindings; the separate Provider + Model select pair remains available to standalone editors outside that page.
Utility model routing: Models β Routing exposes one Utility tasks model override (config.json β utilityModel, client src/config/utility-model-meta.ts) for chat title generation, composer prompt expansion, issue expansion, and git commit message generation. Its control uses the same combined multi-provider picker and custom menu as the composer/top bar. Empty preserves each caller's existing fallback: scheduled/chat binding for titles, the active composer chat for prompt expansion, the top-bar default for issue expansion, and the editor/chat binding for commit messages. Legacy titles and promptExpander model values remain fallback-only for existing installations. My Models utility overrides ensure-load and re-resolve through resolveLibraryRequestBinding before sending.
Multi-provider registry: ~/.minnow/providers/. UI: Models app ? Providers. Chat uses composite model keys (providerId + model id) in src/lib/model-select-key.ts. The top-bar picker and composer model menus share the same catalog via src/ui/model-select-picker.ts; host filter bars include search, All/Local/Cloud segments, a My Models toggle (Minnow glyph, minnow-model-library-filter in localStorage), loaded-only when Local is selected, and a refresh action; each open menu carries a single action row under the list β Load/Unload for the targeted model (hidden when the provider has no load API) and Open settings, which lands on that model's My Models load settings (src/ui/model-select-picker.ts / src/api/models.ts). The Code composer chip mounts into #codeComposerModelAnchor in .composer-controls__trail (src/ui/composer-model-trigger.ts) so compact overflow can park Tools without removing the picker. fetchModels() also merges My Models loadable GGUF rows into a My Models optgroup (src/models/model-select-library.ts) with synthetic provider id minnow-library; Load/Unload starts or stops Minnow serves from the picker. llama-cpp-local and mlx-lm-local catalogs are always omitted from the picker and roster export (omitLocalRuntimeCatalogModels) β those providers remain for serve routing only, so a hosted GGUF appears once under My Models, never again as llama.cpp (local) / mlx-lm (local). Chat auto-load for library rows uses live serve status (not picker modelCache alone) to decide a pending load, resolves the library id from either minnow-library + gguf:/mlx: or a persisted llama-cpp-local / mlx-lm-local binding (resolveLibraryModelIdForChatBinding), ensures via loadLibraryModelFromPicker with synthetic minnow-library + library ids, then re-resolves the send binding to the running serve (resolveServedBindingForLibraryId / resolveLibrarySendBinding) before completions ? and re-binds the generations provider in src/chat/run-turn-chat.ts after ensure, because mlx-lm-local / llama-cpp-local rows are created during serve and an early resolveProvider would otherwise fall back to the first enabled provider (often LM Studio ? Upstream HTTP 400: No models loaded). Cached-model and serve list fetches in runChatTurn soft-fail to empty lists so resume/offline turns still run when those APIs are unavailable. Every non-chat request path shares that remap through resolveLibraryRequestBinding: prompt expander (binding: per-chat composer model, or optional config.json promptExpander via Settings ? Routing ? Prompt expander β composer-expand-binding.ts / composer-expand-client.ts, which also ensure-loads an unserved row) and editor AI β ghost completion, intent coding, Quick Edit, commit messages β via resolveEditorAiBinding, which sets binding.error instead of auto-loading so a keystroke never starts a multi-minute weight load. Those send paths call resolveProvider(id, { strict: true }), which throws UnknownProviderError rather than substituting another backend; the non-strict fallback now warns. Library ensure also calls invalidateProviderCache after load. resolveWorkAgentBinding treats minnow-library + gguf:/mlx: as valid without a registry row (seeded llama-cpp-local stays disabled until the first serve). Models-app eject (unloadServe) refreshes the picker cache so loaded dots and Load/Unload stay honest. fetchModels() refreshes the global #modelSelect only ? it does not copy the default onto the active chat (per-chat bindings come from the composer or send-time resolveEffectiveChatModelBinding). Changing the menubar / global default (onModelSelectChange in src/ui/sidebar.ts) updates the active chat binding only when that chat is still ephemeral-empty (no messages, no composer draft) so the Code hub composer stays in sync for ?new chat? without rewriting models on threads that already have history. Desktop hero/docked composer picks are copied onto the desktop sandbox chat when the first send bootstraps or seeds a new thread (src/os/desktop-chat.ts ensureReadyForSend / createFreshAssistantChat). On send, if the active model is not loaded on a load/unload-capable provider (e.g. LM Studio), the turn loads it first and the assistant stream row shows Loading model? (src/ui/stream-status.ts) until generation begins (Generating response?).
Vision detection and capability probes: vision is resolved per catalog row, not per provider. LM Studio's /api/v0/models names VLMs outright (type: 'vlm' / capabilities.vision); every other backend is normalized by normalizeOpenAiModelRow, which reads openAiRowVisionFlag β type: 'vlm', boolean vision / supports_vision / supports_images / multimodal, capabilities.vision or a capability token list, and OpenRouter architecture.input_modalities / architecture.modality (only the input half of text+image->text; text->image is a generator, not a VLM) β into catalogVision. Bare { id } catalogs (llama.cpp, mlx_lm.server, MTPLX) yield no signal, so isVisionModel treats the type: 'llm' those normalizers stamp on as authoritative only for lm-studio-v0 rows and otherwise falls through to a positive-only id heuristic (vlm|vision|llava|internvl|pixtral|idefics|\bvl\b|minicpm-?v, never used to turn vision off). A probed vision: false (sources.vision === 'probe') beats the heuristic. My Models rows carry vision from the local scan: a sibling mmproj*.gguf (role: 'projector') adds vision to LibraryModel.capabilities in buildLibrary β the same file llama-server gets via --mmproj β and fetchLibraryModelSelectMerge stamps those rows type: 'vlm' + catalogVision. Probe results for llama-cpp-local / mlx-lm-local also stamp matching minnow-library picker rows (same model id), so a VLM without mmproj still gets a Vision badge after first load. Probes: on first load of a local model (LM Studio state: loaded, a running My Models serve, or a llama-cpp-local catalog row) or first selection of a hosted/cloud model (openai-v1 / anthropic-v1 β the default, a session chat binding, or a picker pick) Minnow queues the per-model matrix (runCapabilityProbe) in the background once the chat stream is idle β streaming, tools, and vision (16Γ16 PNG image_url). The corrupt-image control (garbage PNG labeled image/png) runs only on remote openai-v1 gateways that might 200 any content part without decoding. Loopback openai-v1 / llama-cpp-local / mlx-lm-local skip that control β local llama.cpp with --mmproj decodes via mtmd and the garbage buffer logs ffprobe (MIN-839). First-load also skips vision entirely on those loopback openai-v1 hosts (tools + streaming still run); Settings β Probe models still sends the valid PNG. If the valid image probe errors with mtmd / ffprobe / CUDA or drops the connection, Minnow does not stamp sources.vision = 'probe' (a probed false would beat the id heuristic). Known VLMs (type: 'vlm' / catalogVision) and rows that already have a probe-sourced field are skipped. Hosted catalogs stamp every row state: 'loaded', so unused siblings are not auto-probed (an OpenRouter-sized list would be billed). mlx-lm hub listings are not auto-probed (a request would load weights). Settings β Providers Probe models remains the optional bulk path and still does not run on refresh. A targeted probe (modelIds set, including first-load) writes only those models; a full Settings probe still ingests the rest of the catalog, but mergeModelCapabilityEntry will not overwrite probe-sourced fields with catalog ingest. Structured-output probe tests response_format / json_schema. Only lm-studio-v0 requires a loaded model for the Settings buttons β for every other apiKind the buttons stay enabled and the server resolves a target from the live catalog, because modelCache may legitimately have no rows for a provider (the picker omits llama-cpp-local / mlx-lm-local catalogs, and a disabled or unreachable provider is never fetched).
CLI onboarding (MIN-124): Provider setup includes a CLI agent path using the shared Models CLI panel for detection, verification, enablement and advanced settings. Only installed, enabled, authenticated agents can advance to the normal default model picker. The provider-cli step persists the CLI provider binding and resumes it without clearing an unchanged model. During onboarding, installation and updates display commands for an external terminal. Sign-in uses the same inline browser authorization UI as Models, with automatic verification and cancellation.
One-click presets: shared catalog in src/providers/presets.ts ? OpenCode Go/Zen, Anthropic, DeepSeek, GitHub Copilot, plus OpenRouter/OpenAI/Groq/Mistral. Onboarding ? Cloud API shows preset chips with a green check when that provider already has a saved API key (onboarding-cloud-<preset> ids in the registry). Settings β Providers and Models β Providers share a compact logo list with enabled API connections first. Offline connections offer an in-place retry. Native disclosures build connection forms on demand; protocol paths, gateway overrides, pricing, and capability probes live under Advanced settings. Add provider opens an inline local/cloud preset chooser or custom connection form; IDs are suggested automatically and repeat presets receive unique IDs. Test connection checks the saved model catalog without generating a completion. Forms guard duplicate submits and retain key drafts after save failures. Revision checks prevent overlapping refreshes from duplicating rows; capability metadata loads only when a card opens. CLI connections link to Models β CLIs. Logos are bundled under public/logos/providers/, resolved through src/providers/identity.ts, with initials for custom endpoints. Styles: src/styles/settings-providers.css.
OpenCode Go routing identity: OpenCode Go requires clients to send a product User-Agent (not undici / ai-sdk/anthropic) and a stable conversation id in x-opencode-session so they can route and cache prompts (docs). Minnow stamps User-Agent: Minnow/<package version> on every opencode.ai request (chat/completions, Anthropic Messages, Responses, catalog, capability probes) via opencode-identity.js. Completions send the Minnow chat id as x-opencode-session (fallback: generation id). Catalog uses minnow-catalog; probes use minnow-probe. Zen shares the same host, so the headers apply there too.
OpenCode Go endpoint matrix (MIN-855): the Go preset stays openai-v1. Per-model transport is resolveGenerationApi: Claude-looking ids still use Anthropic /v1/messages when autoApi is on; Muse Spark 1.2/1.3 Contributor, GPT 5.6 Luna, and Grok 4.6 on a Go base URL (/zen/go) POST /v1/responses via server/generations/openai-responses/ (chat body mapped, Responses SSE translated back to OpenAI chunks). Responses only returns reasoning text when the request asks for it, so chatCompletionBodyToResponses sends reasoning.summary: 'auto' on every turn whose effort is not none; without it a Responses model streams reasoning tokens Minnow can never display as thoughts. Catalog rows remain openai-v1. Zen Muse-like ids are not rerouted (Zen still converts GPT internally). Research, Brain cleanup, and capability probes use the same URL helper. MiniMax / Qwen3.6β3.8 on Go still use /v1/messages and are not auto-routed (id does not look like Claude).
fetchModels() loads all enabled providers. Main chat streams via generations API; postChatCompletions shim for headless/sub-agents. Local llama.cpp completions are routed per loaded serve in admitLocalCompletion (see llama.cpp residency).
Local Server UI: server-panel.ts renders server state, loaded models, OpenAI-compatible endpoints, and the runtime log. Loaded-model chips come from serveActivityChipLabels: per-slot PP/GEN counts plus N queued when llama.cpp's /metrics requests_deferred gauge is > 0 (serve-activity.js polls /slots and /metrics together; ServeActivity.queued). Prefill is a percent only when a Minnow-owned stream publishes in-flight-prompt.ts (prompt_progress.total); /slots has no total, so a curl against the same host keeps an honest token count. GEN stays a token count. mlx-lm has no /slots: buildMlxServeActivity synthesizes one slot from the same overlay (keepalive prompt_progress + counted completion deltas). Idle is Ready; do not invent a queue chip. Clicking a loaded, loading, or attention card binds the inspector by serve id (showServeInInspector / selectServe): Overview opens with model and connection details; Runtime snapshot discloses llama.cpp flags on serve.llamaSettings, mlx-lm snapshot/quant/version/port/context on serve.mlxSettings, or readable MTPLX facts on serve.mtplxSettings. A JIT path that does not match a library row still opens those flags via a serve-only inspector; name matching is not used. The selected card gets .is-selected. Starting a llama.cpp or mlx-lm serve switches to #/app/models/server only when Models is already the foreground app (getForegroundAppId() === 'models'); a JIT load from Code stays in chat. toLogLines drops update_slots: all slots are idle before the 500-line cap so heartbeats cannot shove real I srv lines off the buffer (llama-only; mlx-lm has no equivalent). Pollers start from commitServes and also after restoring a still-live serve on tool-server boot, so a restart does not leave Local Server blank until the next heartbeat write. The header picker suffix shares the queue field; the Models header loading label is Loading 37% (empty percent stays Loading, never a stuck 0%) via formatModelsHeaderLoadingLabel. Chat paints the same modelled load percent, then prefill percent, then live predicted_n via llamaRuntimeStatusView on both the agent loop (streamCompletionTurn) and the no-tools api/chat.ts path; setRuntimeDetail copies onto .tool-start-indicator__detail while Calling {tool} is showing. mlx-lm keepalive comments (: keepalive processed/total) are parsed in sse-parse.ts into prompt_progress; when timings.predicted_n is absent, mergeStreamMeta increments it from text-bearing deltas. The Models page fills the OS stage (height: 100% on .models-page, plus the #osAppsLayer .models-page.is-open rule in minnowos-shell.css) rather than 100vh, which would sit under the menubar and clip the log. .models-logs is a nested flex pane (min-height: 0, overflow: hidden) so .models-logs__body is the scrollport (MIN-606). In-flight load ticks (~250 ms) patch the existing loading card (percent, phase, bar) so .models-spinner and the indeterminate bar keep their CSS animations; a full redraw still runs when a load starts, finishes, fails, or the card set changes.
My Models local scan: Hugging Face hub cache, ~/.minnow/models/artifacts, and extra folders (config.models.modelDirs, Models ? Storage) are merged in server/models/cached.js. listCachedModels is wrapped in a 30 s TTL cache (keyed by Minnow home), invalidated on download completed and when models.modelDirs is written, so mlx-lm-local /v1/models enrichment does not recursively walk disk on every request (enrichMlxLmModelsWithCachedContext). Extra folders can be typed or chosen with BrowseοΏ½ (in-app folder picker); adds and removes save immediately without a separate Save control. LM StudioοΏ½style trees (<root>/<publisher>/<ModelFolder>/*.gguf) emit one row per model folder; flat layouts (<root>/<modelName>/*.gguf) still work. blobs / manifests under a publisher are ignored. The My Models table lists only loadable GGUF rows (loadableLibrary in src/models/library.ts: resolved file path, not Ollama API tags). Multiple quants of the same model in one HF repo merge into one row with a quant <select> (each option shows tier + file size); grouping logic in src/models/library-group.ts. Clicking a row (or changing quant) selects that variant and opens the right-hand inspector (showModelInInspector in library-panel.ts). SafeTensors and other cached formats stay in the scan but are hidden from that list; Ollama-managed models are omitted from the scan entirely (server/models/cached.js). Each row shows a Maker column (model family with inline logo via src/providers/model-producer.ts); toolbar filters separate maker (Qwen, Google, οΏ½) from publisher (HF repo owner / quantizer). Column headers (Model, Maker, Params, Quant, Context, Size) are clickable to sort with toggle direction and aria-sort indicators (session-only; shared compare rules in src/models/library-sort.ts, UI in src/ui/models/library-panel.ts).
llama.cpp runtime (GGUF serve): Minnow can download pinned llama-server builds from ggml-org releases (server/models/llama-runtime.js). The pin is b10448 (Qwen3.8 GGUF architecture qwen35 needs b10430+; older llama-server rejects the file). Release asset discovery (fetchReleaseAssetList) prefers the GitHub REST API (optional GITHUB_TOKEN / GH_TOKEN Bearer auth β unauthenticated REST is 60 req/hr/IP) and falls back to the public expanded_assets HTML page when REST is rate-limited, constructing github.com/.../releases/download/<tag>/<asset> URLs. Install reuses that asset list (including API digest when present) and does not re-hit the REST API. ensureLlamaServer() compares managed meta.json version to LLAMA_CPP_RELEASE_TAG (leading b stripped, integer compare when both parse) but does not auto-upgrade mid-session β an older install keeps serving; Settings β Servers offers Upgrade (same reinstall: true path). GET /api/models/llama-runtime reports upgradeAvailable, pinnedVersion, and installedVersion (version is the installed tag when known, not a pretence that the pin is on disk). On Windows CUDA builds, the release ships a separate cudart-llama-bin-win-cuda-* zip (DLLs only); install merges that tree first, then extracts the main zip that contains llama-server.exe. After download, each asset is sha256-checked against GitHub's digest (sha256:β¦) before extract (assertArchiveDigest); a mismatch aborts. Assets with no digest skip verify so older GitHub API snapshots and the HTML fallback still install. CUDA 13 zips are chosen for this host's platform + arch, then the newest cuda-13.x patch among those β a global cuda-13* sort on b10448 installed win-cuda-13.4-arm64 on AMD64 (only 13.3 shipped for x64), so llama-server.exe never started and Local Server showed an empty runtime log. After extract, Windows installs refuse a PE whose machine type is not this CPU (assertLlamaServerMatchesHostArch); upgradeAvailable is also true for that mismatch even when meta.json version equals the pin. Default variant selection (server/models/llama-variant.js detectPreferredLlamaVariant) reads detectHardware()'s backend field (cuda when nvidia-smi succeeds, else Vulkan/Metal/CPU based on platform and release assets). Settings β Servers and the install prompt use preferredVariant from GET /api/models/llama-runtime. That payload also includes devices: GPU rows from llama-server --list-devices (CPU skipped), falling back to hardware.gpus synthesized as CUDA0 / Vulkan0 / HIP0. The Load settings popover Advanced β GPUs section (inspector.ts) checks those ids in check order for --device; two or more checks emit --split-mode layer (or tensor) and optional --tensor-split. With two or more inventory GPUs and no saved device, buildLlamaServerLaunch pins --device to the first id so llama.cpp does not silently use every card. device / split_mode / tensor_split / main_gpu persist on models.launch.byLibraryId. --fit-target and launchBudgetBytes use the smallest selected card when selectedGpuVramGb is set. Extra args still win (--device / -dev). Launch planner (src/models/launch-plan.mjs, types launch-plan.d.mts): planLlamaLaunch() sizes -c to the machine and to GGUF trainCtx (hard ceiling; missing header uses 8192). Preferred context is PREFERRED_CONTEXT_TOKENS = 32,768, snapped down to CONTEXT_LADDER (including non-power-of-two rungs such as 6144 / 12288). Auto mode returns n_gpu_layers: null so llama.cpp --fit can pick the GPU split β it does not emit 999. buildLlamaServerLaunch (called from startServe and wrapped by buildLlamaServerArgs) returns { args, plan, warning, settings } and applies that plan on every llama.cpp load. Auto is the default β including legacy { ctx: 125000, n_gpu_layers: 999 } and onboarding { fit: true } without fit_mode β so planLlamaLaunch owns ctx / ngl / cache_type. Auto GPU argv is --fit on, omit -ngl, Minnow-sized -c (preferred 32768, trainCtx hard cap, snapped to CONTEXT_LADDER), --flash-attn on (cuda/metal/rocm) or auto (vulkan/cpu), --fit-ctx 4096, and --fit-target in MiB (the same reserve launchBudgetBytes uses). Never --swa-full. LM-Studio-feel defaults also pass --cont-batching, --cache-reuse 256, --parallel (plan slot count, default 1), --alias <libraryId> when startServe has a library id (stable /v1/models id), and -t / --threads only when -ngl is set and less than GGUF nLayers (GPU auto leaves ngl unset β no -t; CPU auto -ngl 0 with a known header does pass threads). --no-mmap / --mlock and --chat-template / --chat-template-file are first-class LlamaServeSettings (default off / unset). extra_args is POSIX-quoted (argv-tokenize.mjs) so --chat-template "hello world" stays two argv tokens β the inspector extra box tokenizes on change instead of split(/\s+/). startServe threads libraryId into buildLlamaServerLaunch. fit_mode: 'manual' passes ctx / ngl through unclamped (--fit off when ngl is set) and, when estimateRunMemory exceeds the launch budget by >1.25Γ, startServe appends a serve-log line containing fit planner. startServe stores the effective planned settings on the serve row (llamaSettings) so Inference is not empty when the client sent {}. DEFAULT_CONTEXT_TOKENS = 125,000 (mirrored in server/models/default-context-tokens.js) stays exported for Discover ranking but is deprecated as a launch default. Default KV cache stays f16; quantized KV is a degradation under VRAM pressure. K and V cache types are always the same type (pairKvCacheTypes): mixed --cache-type-k / --cache-type-v (for example f16 K + q8_0 V) fragments the CUDA flash-attn graph and collapses prompt processing onto the CPU. Auto argv always emits both flags, including f16, so llama.cpp --fit cannot quantize only V. A one-sided inspector override copies to both sides; Load still coerces a saved mismatch and writes a serve-log warning. Serve argv from buildLlamaServerArgs always passes --jinja (GGUF-embedded chat templates; required for Qwen3.8 <think> / tools) and --mmproj when a sibling mmproj*.gguf sits next to the weights (findSiblingMmproj, preferred mmproj-F16.gguf). llama.cpp b10430+ enables --fit on by default; Minnow auto mode leaves -ngl unset so native fit can size the GPU split. Manual mode with an explicit -ngl passes --fit off so auto-fit does not abort with n_gpu_layers already set by user. Onboarding auto-setup still sends fit: true without fit_mode; the server treats that as auto and the planner wins. The Models inspector Load tab (src/ui/models/inspector.ts / inspector-launch.ts) no longer materializes {ctx: 125000, n_gpu_layers: 999} β settingsFor() / settingsForDraft() return {} until context / GPU layers / KV cache is touched (then fit_mode: 'manual'). The inspector shows planned ctxPerSlot, labels GPU layers Auto (not 999/all), and caps the context slider at trainCtx. After a custom GPU-layer count, an Auto control restores unset n_gpu_layers (--fit on); a GPU-only override collapses back to planner auto, while a custom context or KV cache stays manual. Context / GPU-layer sliders live in a div.models-field (not a <label>: Chromium drops range pointer capture inside a label after one tick) and must not rebuild the inspector on input; occupancy meters patch in place so the thumb stays grabbed. The context range is token-valued with a 1024-token step (not the 13-rung CONTEXT_LADDER used by auto-planning). Per-model launch drafts persist in config.json β models.launch.byLibraryId (launch-prefs.js, GET/PUT /api/models/launch, library-launch-meta.ts) because the inspector session Map was lost on reload. My Models table Load and inspector Load both send settingsFor(model). startServe merges **llama-cpp.json defaults β saved launch prefs β