-
-
Notifications
You must be signed in to change notification settings - Fork 5
Developer Reference
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.
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).
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.
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 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.
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 checkbox selection and confirmed bulk deletion in Branches and Worktrees. Selection is limited to visible eligible rows and scoped to the browse root. 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
|
-
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.
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.
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.
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.
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/.
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 compact strip: When #composerControls is under 880px (leave compact only above 920px), both the hub composer (.input-bar--hub) and the active-chat composer collapse to a current-mode dropdown, overflow cog, model chip, and context wheel (in that order, all inside the composer column). Local/branch, thinking, reasoning effort, docs/map/brain, and other extras park into the cog sheet as labeled this-turn rows. Tools is a chevron row that drills into a second page (group rows, Enable all, All tool settings; web search and cache stay in the wide Tools popover and Settings). Mode labels never clip; wide layout keeps the four labelled segments. 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 mode blocks mutating file/git writes except save_file / make_directory under documentation/plans/ (client + server guards).
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): Code (primary surface, hosts chat), Research, Models, Brain, Issues, Scheduler, Settings. Hidden (releaseState: 'hidden', MIN-471): 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. 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. Distinct simultaneous creations with the same issue ID fail visibly and retain the unsaved local card instead of overwriting the other window's card.
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.
| 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; 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), 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 (This PC / 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. Auto pushes title/description/labels/closed-state after a 1.5s debounce (peek or issue_*), creates on GitHub on the first of those edits to an unlinked card (no backfill), and on boot, wake, and every 5 minutes syncs already-linked issues in the current workspace (skipping other projects and unassigned cards) even while the desktop shell is in the background (issues-github-auto.ts). 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 title/body/label/closed-state edits separately from local-only metadata, so chat links, ranks, and priority 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).
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: Code, Research, Models, Brain, Scheduler, Issues, Settings) 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. First-run Choose your apps (after Appearance) and Settings ? Apps share 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, both surfaces show a Coming soon empty state instead of an empty card grid.
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.
-
105 built-in tools (0 app-gated; 105 shipped) —
src/tools/definitions.ts. V1 board tools (board_initand siblings) were deleted in MIN-715. -
19 bundled slash skills —
src/skills/, manifest vianpm run prebuild; everything else installs from Skills Library - 7 released apps, all core — no optional released apps in this build
-
7 registered modes (persisted
desktop/emailremap to general) + work agents, sub-agents, orchestrator boards, Brain wiki
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 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). 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.
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 on pointerenter / 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 |
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.
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 selects seven core names plus
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.
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 for loopback navigations, never into HTML served to a LAN address (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. A paired browser uses the full shell on wider viewports and a narrow companion Chat shell (mode picker, notifications, Scheduler, per-call approval for mutating tools) at =640px. Revocation takes effect on the next request; companion clients also probe every five seconds and show a reconnect banner while the host is unavailable. 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.
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.
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); it holds no attempts, patches, or evidence dumps. A task row is id · title · phase badge · N runs · diffstat, closed; opening it mounts Runs (numbered role + outcome badge, journaled blockers, needs, a one-sentence scent with the full write-up behind a closed disclosure, and testOutput behind one more) beside Files. 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 Working/Worked disclosures while retaining the original transcript nodes and absolute history indices for streaming, tools, actions, and chunked backfill. Compact hides intermediate activity; final replies, failures, and stopped output stay visible. 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.
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.
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).
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. Codex enriches from models_cache.json; 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 node.exe + index.js without spawning cmd.exe; if PATH does not include the vendor install dir, well-known locations are still treated as installed. Install and Sign in open Minnow Terminal and run the vendor command for the tab's shell (cli-panel.ts); 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. Each round replays the caller's transcript in private temporary configuration with native CLI tools disabled. Claude and Codex send that transcript on stdin; Cursor does too (--print with no positional prompt, plus --trust for the headless scratch workspace) so Windows argv limits cannot reject ordinary turns. The generation-scoped mcp-shim.mjs exposes exactly the caller's tool catalog and submits one request to a private, token-authenticated loopback listener. That request is streamed to the UI as soon as the bridge validates it, then stops the process tree and becomes a normal tool_calls completion; 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 image content travels over stdin; Codex and Cursor advertise 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.
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 the existing 16 MB 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. 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). 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 only (chars / 4). 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). 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 via completion-weighted 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, retries, and the generation grows until the 16 MB replay cap in server/generations/store.js aborts it — the live symptom is a climbing Thinking timer with no tool card. 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.
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. The composer strip (composer-message-queue.ts) stays as the compact control; the transcript paints the same items as muted Queued user bubbles at the tail (#queuedTranscript in queued-transcript.ts). Live stream / tool / card rows insert before that cluster via appendChatTranscriptNode. flushPendingMessageQueue notifies the UI as soon as an item is dequeued (turn start), so the bubble is gone before the real user row is appended. Edit / push-now / delete work from either surface.
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 (32768), 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. 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/) |
/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.
Catalog: BUILT_IN_TOOLS. Config UI: Settings ? Tools; quick access from the Code chat composer 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 |
| 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.
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, plan-work, orchestrate-plan, impeccable (default-on), ui-designer, 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.
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 write to ~/.minnow/skills/<id>/, record provenance in ~/.minnow/skills/installed-skills.json, and 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.
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).
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. Web RAG (rag_web_content) fetches up to ~24KB per page and returns up to 16 query-ranked sentences/paragraphs.
| 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, 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 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.
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.
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). 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 role is shown browser_drive_* — the browser rung dispatches those from code via dispatchToolIdsForRole('final'). 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: default wall-clock is 120 minutes (ATTEMPT_WALL_CLOCK_MS); there is no default maxTurns (tests may still pass one). Hitting the wall clock is runner-produced 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. plan-repairer workers rewrite an unparseable board plan in place from the Boards parse-error pane, then Open board is retried. 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 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 appends result.delivered only after the seam resolves (same ordering as attempt.started). A crash between inject and append re-delivers; a crash after append does not. 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 and does not connect() them. 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 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).
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 103 built-in / 0 app-gated / 103 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; attempts still map that to TurnModel.thinking.mode on/off. 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). 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 This PC / 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 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. A parse failure on Boards offers Repair, which spawns a background plan-repairer sub-agent (plan-repair.ts) to rewrite the same file for schema only (keep waves, task ids, and intent), then retries POST /api/boards. Repair is a user click; a still-broken file does not auto-spawn another agent.
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.
Orphan worktree reclaim: 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 context window at a 90% safety margin (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 take the stage and Scheduler overlays it as a side panel. #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).
Research app: Deep Research is a released 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). launchApp('research') and legacy #/research hashes route there; 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. 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 form uses inline checkbox styling for auto-start. 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.
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) |
| Voice | Models → Voice |
server/voice/, built-in ONNX Whisper Tiny + system speech; optional Python Whisper / Qwen |
| Settings | #/app/settings |
Full config via /api/config/*; General category includes General, Notifications, Audio, About; Appearance, Models (Providers, Routing, Usage & cost, Sampler, Thinking, Voice reparented into Models ? Voice with the same settings-general / emphasis-group layout) use the emphasis-panel layout; Integrations hubs Search, Servers, Tools (collapsible category groups in the tool catalog), Skills, Browser, MCP servers, Language servers, and Editor match the General emphasis-panel pattern (settings-general shell, offline banner, emphasis groups, related links); other Integrations hubs (Deep Research, External); Advanced is Health & diagnostics only (Orchestration and Evals Settings pages removed — supervisor tuning lives under Autopilot / config.supervisor). Content columns are centered in the pane (--settings-column-max: 56rem; .settings-section--wide / wide hubs use --settings-column-wide-max: 72rem in settings-page.css). In-page cross-links (linkToSettingsSection) call openSettings / openModels — hash-only #/settings/… redirects are not enough once Settings is already open. openModels (like openSettings) must launchApp('models') when another app is foreground, otherwise Tools → Open Providers paints Models without switching the OS layer (src/ui/settings-layout.ts, src/ui/models-page.ts, src/os/app-host.ts). |
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).
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.
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. Server in-process dispatch admits MCP tools independently of built-in role allowlists; registry checks remain authoritative. Context7 remains built in.
Native tool plugins: plugin__* tools from user plugins (documentation/plugins/tool-authoring.md).
Models → Routing uses the shared combined provider/model picker for every routing row and fallback-chain entry; 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).
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 uses the settings-general emphasis-panel layout (like Routing and Usage): grouped picker (local servers, featured APIs, more cloud APIs, then custom), flat provider rows inside the configured panel, and related links to Routing and Usage. 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). 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): Inference opens with Loaded with first (llama.cpp flags on serve.llamaSettings, mlx-lm snapshot/quant/version/port/context on serve.mlxSettings). 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 Models inspector Load tab 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 → body.llama when libraryId is set (picker / CLI included). A successful llama.cpp load records lastLoadMs / lastWeightsBytes on that row for a time-based duration estimate (file size × last rate — not a fake %). Live load progress: Local Server's loading chip is modelled in src/models/load-progress.mjs because llama.cpp prints no weight-load percentage. computeLoadProgress treats reportedPercent: null as none (Number(null) === 0 would pin the chip at 0% for the whole load). Phase floors follow a captured b9628 Qwen3.8-27B load (7.09s to server is listening): loading model tensors owns 16–82 because that is the last line on disk during the silent CUDA copy; dots (one . per tensor percent) map onto the same band; MTP / CLIP / slots sit in warmup. /health is the only path to 100 for llama.cpp. A slow lastLoadMs (cold cache) is floored at ~1.5 GiB/s when the GGUF size is known, so a 7s warm mmap of a 13 GiB file cannot paint 35% (7/20) and then jump to Ready. Unknown size uses a saturating clock (half-life 3s) instead of a linear 25s ruler. The models store matches extra-folder paths with slash/case normalisation (E:\Models\...) and serve.libraryId so weightsBytes is not left at 0. parseLoadProgress only accepts progress = N % / loading N %, not tokenizer dumps or jinja {%. mlx-lm uses the same function with runtime: 'mlx-lm' (a single "Loading weights" band, no llama log regexes); 100 is warmup-done, not /health. The same percent is shared on the loading card, Models header, and chat loading_model detail. The models store accumulates log SSE with foldServeLogEvent (initial tail, then deltas); replacing each chunk drops phase markers and freezes the bar. Serve-log follow polls the spawn file every 200 ms and does not skip unread bytes after a burst larger than 512 KiB (Qwen3.8's tokenizer dump); the late burst (context / warmup / server is listening) can paint before /health removes the loading card, which itself paints 100 on the last tick. The stream re-resolves the serve's log path on each poll (subscribeServeLogForServe takes a lookup, not a connect-time snapshot) so opening EventSource on commitServes('llama-starting') — before runId exists — still shows Runtime log after eject-and-reload; a spawn retry that assigns a new runId switches files. Local Server also rebinds when runId appears instead of skipping bind during in-flight card patches. Live VRAM/RAM estimates stay in src/models/serve-memory-estimate.ts; the Load tab paints them as occupancy meters against measured GPU VRAM and available RAM (launch-memory-meter.ts, DOM in src/ui/models/launch-memory-meter.ts). trainCtx is on GgufGeometryFacts so the slider can show the trained-context cap. GGUF headers from server/models/gguf-metadata.js now read {arch}.full_attention_interval (and blk.*.ssm_* tensors as a fallback) so Gated DeltaNet hybrids (Qwen3.5 / 3.6 / 3.8) are charged for the 1-in-4 full-attention layers, not every block — without that, Qwen3.5-9B Q8_0 at 125k ctx showed ~25 GB against a real ~14 GB load. GET /api/models/profiles still serves Quality/Balanced/Speed presets for onboarding (server/models/profiles.js). profileToLlamaArgs is gone — it was a second, unclamped argv path (-ngl from the profile, no --fit on); launch argv is only buildLlamaServerArgs. startServe reads the GGUF header via readGgufMetadata (LRU-cached; null on dummy/invalid files) and threads it into buildLlamaServerArgs / computeServeProfiles as ggufMeta — the same object the profiles handler already passed into computeServeProfiles and now also into buildLlamaServerArgs, so inspector preview argv and the actual launch share exact header geometry (nLayers, layerBytes) rather than parameter-count guesses. Active serves persist in ~/.minnow/models/serves.json. Statuses: starting (spawned, not healthy yet), running, stopped (user eject / boot reconcile of a dead process), error (failed to become healthy — load never succeeded), crashed (mid-session child exit after a successful load), unhealthy (PID still alive but /health failed three heartbeats in a row). Boot reconcileInterruptedServes still only runs at tool-server start (MIN-562) and maps stale running/starting/unhealthy to stopped/error — it does not invent crashed. Mid-session llama.cpp death is a subscribeRun exit watcher after settle() promotes to running; MLX rows follow subscribeServerState('mlx-lm') from manager.js child.on('exit'). A module-level 10s heartbeat (tickServeHeartbeatForTests in unit tests) probes /health for running/unhealthy rows. Auto-restart is once, after 2s, only if the serve was healthy ≥ 30s and classification is unknown / transient / port_conflict — never oom_vram (same settings would OOM again). Load/crash failures go through diagnoseLlamaFailure (pure; no I/O): log tail + exit code + optional launch plan → { code, title, detail, remediation, retryable, suggestedSettings? }. Codes: oom_vram (re-plans at 85% VRAM budget when geometry/hardware are on the plan), oom_ram, killed_by_os (exit 137), wrong_runtime_arch (ARM64 llama-server on AMD64 or the reverse; Reinstall), unsupported_arch (Settings → Servers Upgrade), missing_runtime_lib (Reinstall), port_conflict (one automatic retry on pickFreePort(0)), bad_template (one automatic retry without --jinja; failure object surfaces first-class chat_template / chat_template_file so the UI can point at --chat-template — no invented template string), corrupt_gguf (re-download; split shards when splitCount > 1), mmap_failed (--no-mmap suggested), unknown (same 280-char grepped excerpt as before). classifyServeExit wraps diagnose so restart policy still keys off .code (tests may inject oom_vram). publicServe.failure carries the full object; Local Server / inspector / My Models show title + remediation and a Retry with suggested settings button that loadModels with fit_mode: 'manual' plus suggestedSettings. Failed loads stay error (never became healthy); mid-session deaths stay crashed. Every serves.json write goes through commitServes(reason), which emits { serves, reason } on GET /api/models/serve/events (client subscribeServeEvents); the Models store dropped its 1s trackLoad poll and keeps a 15s reconciling poll as fallback. Quit paths await shutdownAllModelServes() including packaged Electron. Residency: more than one llama-server may stay loaded (each process on its own port). startServe calls admitServe / admit-serve.js instead of killing every live llama.cpp serve: while over launchBudgetBytes or at models_max, it evicts LRU by lastUsedAt (missing → oldest) via stopServe. A zero byte budget (failed hardware probe, or Darwin os.freemem() reporting nothing usable) is cap-only — it must not evict every resident. Default models_max is 1 under 16 GB GPU VRAM, 2 at 16–32 GB, 3 above 32 GB (card rating, not the launch reserve). CPU defaults to 1 (shared RAM + bandwidth with the OS and renderer — llama-cpp.json models_max still wins when set). Idle TTL is 20 minutes on the Phase 2 heartbeat; the most recently TTL-evicted model JIT-reloads on the next completion that names its --alias / libraryId / filename, bounded by MODEL_LOAD_TIMEOUT_MS. User eject is not JIT. Completions for llama-cpp-local are routed in proxy.js admitLocalCompletion (called from pumpUpstream) to that row's baseUrl — not llama-server router mode, because a single profile.baseUrl cannot reach two ports. Background jobs (benchmark with no role, expander utility, chat-titles, editor-completion, context-summarize) take a semaphore of 1; interactive chat (persist, chatId, or a fallbackRole outside NON_AGENT_FALLBACK_ROLES) bypasses it so llama.cpp FIFO cannot starve the composer. --parallel is always emitted from the launch plan (default 1 unless settings.parallel is set — do not silently default GPU to 2). --cont-batching stays on. MLX stays one process (stopExistingMlxServes). Serve residency tests pin a 64 GB hardware object on startServe so macOS CI os.freemem() cannot make a 4-byte GGUF stub look over-budget. Model load timeouts (llama.cpp health wait, mlx-lm managed-server startup, My Models picker poll, provider load/unload proxy) share MODEL_LOAD_TIMEOUT_MS = 180s (3 min) (server/models/timeouts.js, src/models/serve-timeouts.ts). llama.cpp serve settle and managed-server start share waitForHealth: healthPath (manager), structured { ok, error, logTail, exitCode } plus run-exit (serve / Phase 3 diagnose). Stable local ids llama-cpp-local / mlx-lm-local live in runtime-ids.mjs (re-exported from store.js; client types.ts and sanitizer fallbacks import the same strings). Local-runtime provider upsert is one helper upsertLocalRuntimeProvider with thin upsertLlamaCppProvider / upsertMlxLmProvider wrappers; supportsExtendedSamplers: true on create and update.
Qwen3.8-27B: Catalog row Qwen/Qwen3.8-27B is a native VLM (image-text-to-text, vision + tool_use, 262,144 context, architecture qwen3_5 / GGUF qwen35). Default GGUF is Unsloth Qwen3.8-27B-Q4_K_M.gguf (~17 GB; 24GB-class sweet spot). No MLX Discover row — mlx-lm cannot serve image-text-to-text. Geometry family qwen3_5 in model-geometry.mjs (9B: 32 layers / 4096 embd; 27B: 64 layers / 5120 embd; both 4 KV heads, head dim 256, vocab 248320, swaPeriod 4). Context fallback keys qwen3.8 / qwen3.5 / qwen3.6 are 262K in known-context-windows.ts so they do not inherit generic qwen3 131K. Fit ranking gives qwen3.8 a bonus above 3.6. Thinking is on by default at composer High; LM Studio reports xhigh, which ingest maps to High, and send maps High back to wire xhigh. Composer levels are inferred from the model id (qwen3.8 / qwen3_8) even without catalog allowed_options or a registry apiKind — that covers My Models minnow-library rows (which fetchModels() stamps with catalogCapabilitiesFromRow, without forcing openai-v1 on every GGUF) and llama.cpp labels after serve rebind. LM Studio off/on catalogs and cached probe files without levels are upgraded to off/low/medium/high. LM Studio (lm-studio-v0) also sends enable_thinking and preserve_thinking: true when thinking is on. Local llama.cpp / mlx keep chat_template_kwargs.enable_thinking for off; when on for Qwen3.8 they add chat_template_kwargs.preserve_thinking plus mapped reasoning_effort. Hosted openai-v1 still strips chat_template_kwargs and gets mapped reasoning_effort only. Local llama.cpp / mlx-lm provider rows set supportsExtendedSamplers (create and update via upsertLocalRuntimeProvider) so sanitizeCompletionBodyForProvider and the server mirror keep min_p / top_k / repetition_penalty / enable_thinking; hosted OpenAI (id: 'openai-v1', no flag) still strips those. Sampler presets may include stop sequences (string or string[], trimmed, max 8) via normalizeSamplerPreset / samplerToCompletionFields. Composer UI stays off / low / medium / high — no xhigh option.
MLX process lifecycle: mlx-lm is in MANAGED_SERVER_IDS / servers.json. Spawn records live in ~/.minnow/servers/mlx-lm/run.json; reapOrphanedServers() runs on boot before auto-start. Quit and last-MLX eject call stopServer('mlx-lm') with awaited killProcessTreeAndWait (no mlx unload API). The orphan reaper also uses killProcessTreeAndWait with a { pid } handle so ancestor-kill guards still apply — not a raw taskkill /T /F. Electron shutdownRuntime() runs shutdownAllModelServes() then shutdownAllServers(). Serve reconciliation uses isManagedServerRunning('mlx-lm'), not a bare /v1/models probe; only one MLX serve row stays running (stopExistingMlxServes). Unexpected mlx_lm.server exit emits subscribeServerState('mlx-lm', { type: 'exit' }) so live MLX serve rows become crashed (testable by stubbing the manager on Windows).
MLX detection: detectMlxRepo keys on the quantization: {group_size, bits} block mlx_lm.convert writes, falling back to quantization_config with bits and no foreign quant_method (GPTQ/AWQ/bitsandbytes always name themselves), then a repo-id match. config.json + *.safetensors alone describes every transformers repo, so the block is load-bearing ? without it a cached fp16 Llama becomes a servable "MLX" row that fails at load. Minnow deliberately does not use mlx_lm.server's own /v1/models heuristic, which requires model.safetensors.index.json and therefore misses every single-shard model. Rows carry mlx_root / mlx_quant; loadableLibrary gates them on hardware.backend === 'metal' (not the runtime probe, which resolves later and would reorder the table). The composer / #modelSelect My Models optgroup uses the same gate via loadableLibraryFromCached (hardware probe on merge). Chat keeps the synthetic minnow-library binding; resolveUpstreamProviderId maps gguf:* ? llama-cpp-local and mlx:* ? mlx-lm-local for completions. MLX send bindings use the absolute snapshot directory (serve.modelPath / library path), not mlx: picker ids or short /v1/models labels. Thinking-off on local runtimes needs chat_template_kwargs: { enable_thinking: false } — neither runtime reads thinking.type, and mlx_lm.server never reads reasoning_effort either (mlx-lm 0.31.3 do_POST stores only chat_template_kwargs and splats it into apply_chat_template); llama-server does read top-level reasoning_effort and treats none as a disable. So utility calls that force thinking off (expander, inline completion, intent coding) used to burn their whole token budget on a reasoning chain. The same asymmetry is why the composer effort level rides inside chat_template_kwargs for every model, not just Qwen3.8 — a top-level-only reasoning_effort is invisible to MLX and MTPLX. thinkingToCompletionBody emits top-level enable_thinking: false plus kwargs for openai-v1 + off; sanitizeCompletionBodyForProvider (client + server mirror) keeps those fields for llama-cpp-local, mlx-lm-local, and any loopback openai-v1 base URL (MTPLX, etc.), and strips them for hosted APIs that 400 on unknown body fields.
MLX runtime (Apple Silicon only): mlx-lm is a managed server (server/servers/mlx-lm.js, registered in catalog.js as python-venv, port 8087, autostart off, health /v1/models), so install / spawn / health / logs / uninstall / the Settings ? Servers panel all come from manager.js. Unlike llama.cpp it is one long-lived process hosting every model: mlx_lm.server is started without --model and loads whatever each request's model field names, so "Load model" is selectProviderModel on the shared mlx-lm-local provider, not a spawn — no per-serve runId, and switching models costs a request instead of a restart. Because weights load on that first request, startServe for mlx-lm commits the row as starting, POSTs a 1-token completion (max_tokens: 1) naming the snapshot directory, and only then flips to running, bounded by MODEL_LOAD_TIMEOUT_MS. Client load paths pass async: true (same as GGUF) so the UI can trackLoad while warmup runs in the background; sync startServe still waits for warmup (existing tests). The row stores libraryId and mlxSettings (snapshot path, quant, pinned MLX_LM_VERSION, port, context from config.json). A successful warmup records lastLoadMs / lastWeightsBytes via recordLaunchLoadPrior. Timeout or a failed warmup marks error (does not lie running). The warmup POST is injectable in tests (setMlxWarmupOverrideForTests). Models ? Local Server runtime log for MLX serves tails the shared managed log at ~/.minnow/logs/servers/mlx-lm.log via resolveServeLogPath (same file as Settings ? Servers). --allowed-origins is passed explicitly (upstream defaults it to *); --trust-remote-code stays off. On spawn, Minnow sets HF_HUB_CACHE / HUGGINGFACE_HUB_CACHE via resolveHfHubCacheDir and creates the directory if missing so mlx_lm.server's /v1/models hub scan does not raise CacheNotFound when the user only has Minnow artifact downloads. The Apple Silicon gate is Minnow's, not pip's � pip install mlx-lm succeeds on Linux/Windows because mlx is declared platform_system == "Darwin", so pip silently omits it and the failure only surfaces at first inference; isMlxSupported() (darwin + arm64 + Darwin major = 22) is the single source of truth shared by provision(), startDownload, and Hub search. listServers() forwards supported / installable / reason from the provisioner so Settings → Servers can hide a working MLX Install off-platform and show the reason. Model keys are absolute directory paths, not repo ids: Minnow downloads to ~/.minnow/models/artifacts/, which is not an HF cache layout.
Models Discover: src/ui/models/discover-panel.ts renders a curated browser and discover-inspector.ts renders explicit file selection. src/models/recommended.json is the hand-maintained editorial shortlist (repository, default file, measured bytes, purpose, model context, review date); the broad legacy catalog remains available to other consumers. discover-fit.ts uses the shared serving estimator with actual file bytes and a fixed user-selected context (default 16,384). It never shrinks context to claim a fit, keeps unknown data explicit, reserves memory headroom, and distinguishes GPU/unified memory from RAM execution. The inspector groups GGUF shards through GET /api/models/hf/files?repo= (server/models/hf-files.js); incomplete groups are disabled and projectors omitted. GET /api/models/hf/search?q=&format=&limit=&sort=&cursor= supports validated Hugging Face pagination, owner/repository queries, and credential-scoped 60-second caching. File listings also paginate. Search response updates preserve the input node and reject stale requests. MLX remains Apple-Silicon-only; incompatible vision pipelines remain excluded.
MLX downloads: POST /api/models/download takes format: 'gguf' | 'mlx' (absent means gguf, so jobs persisted in downloads.json keep working). MLX fetches a whole repo snapshot into repoDownloadDir(repoId) via downloadHfSnapshot with MLX_SNAPSHOT_EXCLUDE. Cleanup is format-aware (cleanupJobArtifacts) because fsp.rm without recursive: true silently no-ops on a directory — cancelled jobs only; failed / interrupted jobs keep .partial / dest. Snapshot glob filtering is opt-in — voice downloads Kokoro-style repos where .pth / .bin are the real weights. GGUF (and MLX snapshot files, via the shared stream helper) write ${dest}.partial and resume with Range: bytes=<size>-: HTTP 206 appends; 200 (server ignored Range) truncates and restarts from byte 0. redirect: 'follow' would drop Range on the CDN hop, so the client follows redirects by hand and keeps the header. Tool-server restart marks in-flight jobs interrupted (artifacts kept, resumeAt = .partial size) and auto-requeues them so the pump actually resumes. Hugging Face X-Linked-Etag (quoted sha256 of the LFS blob; W/ / sha256: stripped) is hashed while streaming (crypto.createHash('sha256')) and compared before rename; a resume re-hashes the existing prefix first so the digest covers the whole file. The header is optional — missing / non-sha256 etags skip verify so tests and non-HF mirrors still work; a mismatch fails and discards the corrupt partial so retry starts cleanly. Redirect response checksums are retained across CDN hops; mismatched Range offsets are rejected and HTTP 416 retries from byte zero. Split GGUF repos (-NNNNN-of-NNNNN.gguf) download every shard into the repo dest dir; the job destPath (llama.cpp -m) is shard 00001, and validateServeModelTarget refuses to load when GGUF split.count > 1 and a sibling is missing (assertSplitGgufSiblings). 4-byte GGUF stubs still load — readGgufMetadata returns null and the sibling guard is skipped. The download pump allows 2 jobs across repos and 1 within a repo (queued until a slot is free). SSE snapshots include EWMA bytesPerSec / etaMs (alpha 0.2); Discover paints them on stable, keyed rows in a persistent download shelf. Download progress SSE is throttled (~200 ms) on the tool server; the Models store batches byte updates to one notification per animation frame, and Discover patches only the download strip on progress (catalog updates run when hardware, library, or job states change, not on every chunk).
Fallback chains: config.json ? fallbackChains ? sequential retry before first upstream byte (server/generations/fallback.js).
Constrained decoding: optional response_format JSON Schema on tool turns when provider supports it.
| Concern | Location |
|---|---|
| Encrypted secrets | server/security/secret-box.js |
| Untrusted content fencing |
src/lib/untrusted.mjs, server/security/untrusted.js
|
| Webhook SSRF | server/webhooks/ssrf.js |
| Browser origin allowlist |
config.json ? browser.allowedOriginPatterns, /api/browser/allowlist/* (invalid URLs ? 400; SPA checks need session token) |
| Host kill / port bind guards | Agent shell commands cannot kill Minnow or bind its port |
src/attachments/ ? composer chips, max 10 MB. Images ? VLM image_url parts when model supports vision. PDF/office from the OS file picker ? server read_document when npm start. Workspace-tree chips for PDF/office also resolve through read_document on send (MIN-614); other files use read_file. Editor selection drag: dragging highlighted code from the file viewer into the composer queues a codeRef chip (application/x-minnow-code-selection in code-selection-drag.ts, wired in editor-code-selection-drag.ts and composer-drop.ts); plain text/plain drops no longer masquerade as workspace paths when they look like code (e.g. cat_count = 0). Tab ? chat links (MIN-630): dragging a Code editor tab or in-app browser tab onto the chat transcript or composer pins a durable .code-ref-link chip on that chat (Chat.links, session-normalized via ensureChatLinks). File-tree drops stay this-turn attachments; tab drops are standing links (file path or http(s) URL) that survive reload and are listed in the system prompt. Payloads: tab-drag.ts (application/x-minnow-viewer-tab / application/x-minnow-preview-tab); chips: chat-link-chips.ts. Tab-strip reorder still uses text/plain file:<path> / preview:<id>.
Document read (agents): read_document extracts plain text from PDF and office files. Prefer path (workspace-relative) for on-disk files; content (base64) remains for composer attachments. Spreadsheets lead with a sheet manifest (name: rows x cols), shrink each sheet to the cells that actually hold data, and return 200 rows per sheet — sheet picks one, start_row / max_rows page it, full_result returns everything; read_file_range extracts the whole document so its line numbers stay stable. Output is capped (~128k chars by default) via capTextOutput unless full_result or Settings Tool result size is off; corrupt .xlsx / .xls binaries are rejected before parsing.
Document creation (agents): create_pdf, create_spreadsheet (.xlsx), and create_word_document (.docx) write binary files via the tool server (pdf-lib, @pdf-lib/fontkit, xlsx, docx optional deps). PDF body text uses subsetted Noto fonts (server/tools/pdf-layout.js + bundled TTFs under server/tools/fonts/) for Latin/Cyrillic/Greek, CJK, and emoji with measured wrapping; unsupported code points become U+FFFD and are reported in the tool result. File viewer preview: PDFs embed via /api/preview/file/*; spreadsheets and Word docs render HTML via /api/preview/document-html/* (uses xlsx / mammoth / officeparser when installed). Document HTML preview sanitizes embedded fragments (sanitizeDocumentHtml), caps sheet/row counts, sets CSP + nosniff on the preview route, and loads Word/Excel previews in a bare sandbox iframe (no scripts / same-origin).
File viewer recent files: When no viewer tabs are open, #fileViewerHost shows a recent-files empty state (src/ui/file-viewer-recent.ts) ? especially useful in the workspace file viewer (including Scratch), which stays mounted even with zero tabs. MRU paths are persisted in config.json ? filePanel.recentViewerFilesByWorkspace (keyed by absolute workspace / listing root, max 12 per workspace) via src/state/recent-viewer-files.ts. Opens record through openFileInViewer / workspace image open; delete/rename prune or remap entries with the file-tree ops sync.
File viewer dirty detection: CodeMirror 6 stores documents as LF-only. Loaded/saved baselines are normalized via normalizeViewerDocText / isViewerDocDirty in file-viewer-tab-store.ts so CRLF files (common on Windows) do not spuriously prompt ?Unsaved changes? on close/tab switch. After mount, the viewer rebases originalContent to the live CM doc. Leave/close confirms re-snapshot from the editor before prompting. save_file still preserves on-disk EOL when writing.
Markdown preview links: Rendered markdown (setAssistantBubbleContent in src/markdown/renderer.ts, including the file-viewer .md preview) stamps GitHub-style heading ids and intercepts <a href> clicks in capture phase (src/markdown/links.ts, initMarkdownLinkRouting at boot). Unhandled, #overview would replace the OS hash (#/app/code/…) and remount the shell, and foo.md would load Vite's index.html as a full reload; both look like a crash with nothing in Health & diagnostics. In-document hashes scroll inside the preview (hash unchanged); workspace-relative paths open in the file viewer (resolved from data-md-source-path on the preview); http(s) goes to the in-app browser. #/… and #brain-wiki/… stay with existing routers. Chat http(s) chips still go through minnow-browser-links.ts first.
Editor suggestions ? ghost text + Intent mode (MIN-131): src/ui/editor-suggestions/ ? one engine, one suggestion field, and one keymap for both inline completion and Intent proposals. Intent is propose-then-accept: a block proposal renders under the intent line (IntentProposalWidget); Tab accepts (with optional indentRange on multi-line accepts), Esc dismisses, Mod+Enter forces resolve, Ctrl+Z is the undo. In-flight resolves keep a pending anchor mapped through mapPendingResolveThroughTransaction and discard results when the line is edited or shifted during the await.
-
intent-context.ts? neighbor lines for prompts (neighborLinesForPrompt); resolved-neighbor lines for staleness hashes (resolvedNeighborLines/contextHashForRegion) using CodeMirrorTextropes; debounced staleness viaacceptedIntentStalenessPlugin. -
intent-regions.ts? accepted regions after Tab (mapRegionsThroughTransactiondrops regions touched by user edits); cursor-line-only chrome inintent-line-decorations.ts(ViewPlugin+visibleRanges). -
intent-pending.ts? pending resolve mapping while the model streams. -
engine.ts? Mod+Enter is the primary explicit trigger;autoResolveOnLineLeave(default off) debounces resolve on line leave and blur when enabled. Idle auto-resolve on the current line only when that flag is on.onPartialstreams into the proposal widget. -
intent-prompt.ts? instruct messages with resolved neighbor context + full prefix/suffix;temperature: 0.1,max_tokenscapped at 400,stop: ["```", "\\n\\n\\n"],streamEditorGeneration+fallbackRole: 'editor-completion'; multi-line alignment viareindentCompletionText+reindentBlock. -
state.ts? intent proposals still map throughmapIntentSuggestion; completion ghosts type-through as before. -
suggestionTabTargetineditor-completion-policy.tsarbitrates Tab:lsp?intent?completion?indent, returning false for the first and last sofileEditorTabBindinghandles them.completionModeAt(state, pos)chooses single vs multi inline completion (blank line tail ? multi). Alignment ineditor-ai-completion-prompt.tsusesreindentCompletionText, bracket repair (editor-completion-brackets.ts), and single-mode newline truncation; Tab accept may runindentRange(editor-completion-accept.ts) for multi-line ghosts whenreindentOnAcceptis on (skipped for Python/YAML/Markdown). Anthropic upstream maps OpenAIstop? AI SDKstopSequences(server/generations/anthropic/pump.js).
Config: config.editorIntentMode (enabledByDefault, debounceMs, contextWindow, autoResolveOnLineLeave, sigil, providerId, modelId, maxTokens) and Settings ? Editor.
File viewer text loads: readWorkspaceTextFile fetches GET /api/preview/file/??raw=1 so HTML is not rewritten. Browser preview omits raw and still injects <base href> (server/preview/middleware.js) for relative assets. Async tab loads apply results by path (setViewerTabLoadState) so a slow read cannot land on the wrong tab. Workspace HTML in the preview pane: Electron loads via navigateAndWait after tab create/activate + layout bounds (src/ui/preview-panel.ts); main-process PREVIEW_LOAD_SOURCE / PREVIEW_LOAD_URL / PREVIEW_NAVIGATE_AWAIT await navigation and attach the guest without auto-revealing from lastBounds — paint still requires renderer preview.show(bounds) (electron/preview-host.ts).
Preview Design Mode: guest synchronization preserves loaded iframe documents instead of reassigning their URLs (both pane slots). Cross-origin Electron Select keeps the native WebContentsView and mirrors the floating tool strip into a guest shadow root (native-design-strip.ts); controls forward to the existing design session and are removed on tool changes or exit. CDP hover uses the toolbar theme accent with a light fill and opaque outline, without the Chromium inspector tooltip.
Right pane split (Code workspace): The right column (#rightPaneColumn) can show a vertical two-slot split (#rightPaneSplit) so file viewer and browser preview surfaces can appear side by side (file+file, file+browser, browser+browser). State lives in filePanel.rightPaneSplit (ratio, focused slot, per-slot content) with rightPaneMode: 'split' when enabled; disabled at =640px (single slot). Primary browser uses Electron instance workspace-preview; secondary uses workspace-preview-secondary (src/ui/preview-instance-host.ts, src/ui/right-pane-split.ts). When .right-pane-split.is-active, header split icons and preview Auto-reload hide so Design Mode and other trailing preview controls stay visible in narrow panes. Secondary file viewer headers mirror primary Intent / Save chrome; secondary preview close sits in a trailing .preview-header__trail. Secondary preview header controls (back, forward, reload, Go, Design Mode, close split) are wired in preview-secondary-slot.ts and preview-secondary-design.ts via bindSecondaryPreviewControls() / bindSecondaryPreviewDesignControls() at file-panel init. Entry: Split right (Ctrl+\ / Cmd+\), unified tab Open to the right / Move to other pane, header split icon, or dragging a tab onto the other strip. With split enabled, each pane is an independent editor group with its own tab strip (#unifiedTabs / #unifiedTabsSecondary) and its own CodeMirror view (file-viewer.ts for the primary, file-viewer-secondary-slot.ts for the secondary). The unified strip shows at most one selected tab per group: unifiedStripActiveTabForSlot in right-pane-slot-tabs.ts picks viewer vs preview from rightPaneMode (single pane) or per-slot surface (split), even when both stores still hold an active file and browser tab (unified-right-tabs.ts). Single-pane slot visibility (defaultPrimarySlotContent in right-pane-split.ts) follows the same rightPaneMode rule — a leftover activePreviewTab must not hide the file viewer behind an empty preview guest. One global tab store backs open files and preview guests; rightPaneSplit.primaryTabs / secondaryTabs partition it ? a path or preview id lives in exactly one group, and rightPaneSplit.primary / .secondary are always derived from those lists (right-pane-slot-tabs.ts). Each pane renders its own group's active tab; the global active tab tracks the focused group and decides where a newly opened file lands and which file Save/Close act on. Moving a tab never empties its source group ? split with a lone tab opens the second group blank instead. Closing the split merges the second group's tabs back into the first; emptying a group collapses the split. The secondary editor mounts the same inline completion + Intent stack as the primary (editor-suggestions: getConfig / canRequest / onStatus, compartment hot-reload via reconfigureSecondaryEditorSuggestions) but does not mount lspEditorExtensions ? LSP document open/change/close sync runs only when isLspEnabledForViewer() is true (shared with primary). Agent browser_* tools target the focused preview instance when split (src/tools/browser-preview-tools.ts). Not available on the desktop workspace drawer: desktop-workspace-mounts.ts reparents only #fileViewerPane / #previewPane into drawer hosts; entering desktop hosting closes an active split and split UI stays disabled until Code is foreground again (see documentation/plans/right-pane-split-viewer.md).
Workspace mounts: the file tree, preview, and file viewer DOM nodes always live in the Code layout; repairRightPaneDomStructure() keeps viewer/preview nodes under #rightPaneColumn / #rightPaneSlotPrimary. src/os/desktop-workspace-mounts.ts is a dead stub — isDesktopWorkspaceHostingActive() returns false and syncDesktopWorkspaceMounts() is a no-op. Nothing reparents these nodes out of Code; do not add a surface that does.
Bench file-tool probes: every category: 'files' tool must appear exactly once in FILE_TOOL_PROBE_ORDER (src/benchmark/suites/file-tool-fixtures.ts); validateFileToolProbeOrder() throws on import if a tool is missing. Probe chain is create ? read ? mutate ? create_pdf / spreadsheet / Word ? read_document ? delete_path.
Capability matrix catalog (Settings): pure module at src/benchmark/capabilities/ — 52 spreadsheet-ordered CapabilityDefinition rows (48 auto / 4 manual), 13 group bands (the source workbook still lists a Modes band, but those nine columns are omitted from the shipped catalog — they duplicated mode-control coverage without useful signal), house rules, probe specs (probes.ts + split auto modules), capabilityRowScore (null when untested), and classifyProviderHost for Cloud / LM Studio / Minnow Hosting roster bands. Source workbook: documentation/minnow-model-capability-matrix.xlsx; entries regenerated via scripts/gen-capability-catalog-entries.mjs (skips modes group). Benchmark suite: capability-matrix is registered in test-catalog.ts (cap-matrix/<id> test ids, ALL_BENCHMARK_SUITE_IDS) and runner.ts via suites/capability-matrix.ts. Phase 1 emits all 59 rows as skipped with verdict: n-a and suite score from capabilityRowScore; not in quick/full presets or Bench UI toggle order. Phase 3 model lifecycle: shared owner-tagged lock in load-lock.ts (UI + benchmark); headless model-lifecycle.ts gates on detectLocalServer(), tray snapshot delta unload, and rewrites minnow-library roster rows to llama-cpp-local / mlx-lm-local for completions while preserving roster targetKey — the rewritten binding is used only to call the model, so runTargetSuites takes the roster row separately and every progress event, Academic cell, and BenchmarkRun.targetKey stamp keys off it (without that stamp a served row cannot be mapped back to its grid column, since run.provider / run.model hold the serve binding). campaign-runner.ts: optional manageModelLifecycle (default false), localConcurrency (default 1), capabilityMatrix passthrough, load progress events, campaign.skippedTargets (load failures never become zero-score aggregates), two-pass scheduling (local serial then remote min(8,n)). Driver telemetry (Phase 2a): llm-driver.ts records LlmTurnTiming.streamChunkCount per streamed turn and optional ToolLoopInput.onRound callbacks with CapabilityRoundTelemetry (tool-call rounds for chain probes). Probe completions are built via completion-body.ts — fixed sampler (temp 1.0 / top_p 0.95 / top_k 20), max_tokens 32768, thinking on at medium with 8192-token budget, constrained tool decoding when the provider gate allows — matching main-chat request shape rather than the old near-greedy temp 0.2 body. Post-stream: inline <think> split, harmony commentary routing, mergeContentJsonToolCalls, and client-side ThinkingBudgetTracker cutoff. Capability probes enforce perTestTimeoutMs (default 300s) via withBenchmarkTimeout on the whole tool chain. Auto probes (Phase 2b): run-probe.ts runs headless runOneShot / runToolLoop (default 6 tool rounds, CHAIN_ROUND_HEADROOM 6 for tool-chain rows; per-probe maxToolRounds doubled from the original spreadsheet-era caps) (with createCapabilityExecuteToolFn — allowSideEffects from BenchmarkRunContext.capabilityMatrix, default false — or createBenchmarkExecuteToolFn indirectly when side effects are allowed). probe-requirements.ts gates rows to verdict: n-a when manual, delegated, unmet requires (workspace, tool-server, vision, LSP, git-fixture), or not yet in the phase allowlist — never fails the suite when the server is down. Phase 2b allowlist (six core-protocol autos, no requires, no npm start): core-streaming, core-tool-calling, core-json-args, core-no-hallucinated-tools, core-system-prompt, core-reasoning. Phase 2c workspace fixtures: fixtures-workspace.ts seeds matrix/ (notes.md, replace.md for the replace probe only, sample.ts, haystack.txt, a/b/c.json, fixture.pdf, matrix/repo/ with an uncommitted edit) via executeBenchmarkTool before the capability-matrix suite when localServer and benchmark workspace resolve; git is initialized at the benchmark workspace root (not inside matrix/repo/) so git_* tools match where the server runs. files-save-append overwrites notes.md; files-replace-text targets replace.md so earlier probes cannot clobber the search string. CAPABILITY_FIXTURE_PROBE_PROMPTS overrides spreadsheet prompts so probes never reference the Minnow repo. Phase 2c allowlist adds 14 autos (files×6, docs×1, git×2, code-shell×5) when workspace (+ tool-server where required) is ready. Phase 2d emit-only: thirteen autos (web×3, agents×3 including agents-delegate-tasks, knowledge×4, apps×1, modes×2) run without workspace; side-effect tool ids are stubbed via CAPABILITY_SIDE_EFFECT_TOOL_IDS unless capabilityMatrix.allowSideEffects is true. Phase 2e conditional: probe-lsp-ready.ts polls /api/config/lsp (enabled + usable server row) for lsp requirements; lspEnvironmentReady on CapabilityProbeEnvironment overrides for tests. Phase 2e allowlist adds seven autos: core-parallel-tools, core-tool-loop (sequential file-tool discovery under matrix/a/ — verdict passes when list ? read ? grep land in separate rounds; a single parallel batch scores partial, not fail), core-long-context, lsp-diagnostics (gated on workspace / LSP), plus agents-todo-write, features-chat-title, features-markdown (no extra requires). PHASE_2E_FIXTURE_PROBE_PROMPTS overrides user messages for the four fixture-backed 2e rows. Phase 2f (delegated / derived): delegated-probes.ts runs core-vision via shared runCapMultimodalProbe (same path as capability suite cap-multimodal, gated by isVisionModel on catalog rows) and features-skills via headless impeccable skill probe (skills / skill-impeccable). Phase 2f allowlist also enables derived mode-impeccable (tool-order signal via onRound). Under MINNOW_TEST, fetchSkillById reads built-in src/skills/<id>/SKILL.md from disk when Vite import.meta.glob is unavailable (headless delegated skill probes). PHASE_2F_DELEGATED_DERIVED_CAPABILITY_IDS + assertAllAutoCapabilitiesWired cover every auto row. Phase 2d extension (manual?auto sweep): eighteen rows that used to be manual now score automatically — browser ×3, sub-agent control, board ×2, recall, email ×3 and calendar join 2d as emit-only probes (stub payloads in stub-fixtures.ts return ids chain probes act on). Manual rows are now only features-research, features-compare, features-mcp, and features-voice. Probe user messages are authored in probe-prompts.ts (the spreadsheet prompt column is a test description, not a usable prompt) and fixture path constants live in fixture-paths.ts; CapabilityProbeRunOutput carries contentText / reasoningText / executedResults / offeredToolNames so verdicts can check real tool output and catch invented tool names. Every probe toolIds entry must exist in BUILT_IN_TOOLS (a missing id is silently dropped from the request) — asserted by capability-probe-specs.test.mts. Phase 4 persistence: durable roster + manual verdicts under ~/.minnow/benchmarks/capability-matrix/ via server/benchmarks/middleware.js (GET/PUT roster, GET/POST verdicts, PUT import); client APIs roster-store.ts + manual-verdicts.ts with localStorage fallback; hybrid grid merge in merge.ts (manual wins, overridesAuto when auto differs). Campaign saves use prepareCampaignForPersistence (trimmed transcripts for all scored cells, ~32 KB/cell, 413 retry strips all) and POST body cap raised to 64 MB on /api/benchmarks/campaigns. setBenchmarkModelSource injects model id reads; campaigns tagged kind: 'capability-matrix'; active-run-session.ts optional campaignKind. Phase 5a Settings UI: Settings ? Advanced ? Capability matrix (settings-capability-matrix.ts, panels under src/ui/capability-matrix/, view-model view-model.ts, styles settings-capability-matrix.css) — instrument workbench: all Settings sections and integration hubs share a centered 72rem column, with inner panels filling the column; command bar (lead copy + export/import + SheetJS hint) above a single flat bordered frame; left rail uses native <details> drawers for Models (roster chips by host band) and Run probes (filters + pinned run dock with progress/target chips), plus collapsible run history in the rail footer; main pane has grid toolbar (grid-toolbar.ts) with search, uppercase group filter chips (All/None), and inline verdict legend, then sticky-header grid with sparse layout (=8 models: columns expand to fill the pane, readable headers) or dense layout (>8: compact columns, horizontal scroll), score-tier column badges, . Clicking a cell opens the shared transcript drawer with the manual verdict editor pinned below the probe log (untested cells still open so a verdict can be set). Merged grid with verdict glyphs/conflict hatch, manual cell editor (POST upsert) in that drawer, run history (listCampaignSummaries filtered by kind: 'capability-matrix') — click a row to filter the grid to that run (banner + Show all runs); cancelled sweeps expose Continue on the row and in the run panel via resume-from-campaign.ts. Danger zone below workbench. Phase 5b run controls: singleton matrix-run-controller.ts (AbortController survives Settings navigation; disposeCapabilityMatrixRunView only drops UI listeners), run-panel.ts (group + probe-wave filters, allowSideEffects, skip-scored, lifecycle smoke toggle, progress bar, per-target chips including load-failed skips), runBenchmarkCampaign with integrationSuites: ['capability-matrix'], kind: 'capability-matrix', manageModelLifecycle default false, per-target resolveCapabilityMatrixForTarget for skip-scored; resume via active-run-session.ts capabilityMatrixRun payload + explicit Resume sweep banner (no auto-resume on mount), completedProbeKeys merged into skipCapabilityIds mid-sweep; live grid: per-probe subscribeCapabilityMatrixProbeUpdates + getCapabilityMatrixInFlightAutos overlay in merge.ts extraAutos so verdict glyphs update before campaign persistence; completed probes stay in that overlay after Stop until Run matrix starts a fresh sweep (not cleared on cancel/settle); currentProbe tracks the active cell from test-start progress (run dock phase + aria-live, grid cap-matrix-grid__cell--running highlight); clicking a running cell opens the transcript drawer with a running badge/spinner and updateBenchmarkTranscriptDrawer refreshes in place when the probe completes or the sweep is cancelled; roster provider picker includes synthetic My Models (minnow-library via settings-model-binding.ts includeLibraryProvider) and Add all catalog models (fetchAllCatalogRosterTargets = registry catalogs + library scan, deduped against llama-cpp-local) + mountAuxiliaryModelSelectCombobox on model picker; filters in matrix-run-filters.ts / wave ids probe-wave-ids.ts. Phase 7 polish: @container capmatrix responsive workbench (rail stacks below grid under ~900cqi; card-row grid below ~620cqi in settings-capability-matrix.css), grid toolbar (grid-toolbar.ts) for search + group filter, arrow-key cell focus (grid.ts), cell click opens the probe transcript via cell-transcript.ts + shared benchmark-transcript-drawer.ts (mounts on document.body from Settings; Copy transcript dumps metadata + full message log to clipboard via format-benchmark-transcript.ts, including [reasoning] blocks when the probe captured a separate reasoning channel; verdict editor folded into the drawer extra slot; untested cells included), roster model chips by host band. Async section guard: settings-section-render-guard.ts. Board testing nav/section gated at runtime when MINNOW_DEBUG is off (settings-page.ts + hidden in index.html). Phase 6 xlsx: pure workbook build/parse in xlsx-workbook.ts (buildCapabilityMatrixWorkbook, parseCapabilityMatrixWorkbook, verbatim header ? capabilityId); client export export-xlsx.ts and import import-xlsx.ts via SheetJS (no comments/validation); Settings Export / import section + danger-zone Clear manual verdicts; tests capability-xlsx.test.mts.
-
Dev:
npm startspawns Electron after Vite is up. -
Shell zoom: Main window default 80% (
config.desktopShell.zoomPercent,electron/shell-zoom.ts). Applied on load so it overrides stale Chromium per-host zoom; Settings ? General ? Desktop app ? Interface zoom and Ctrl/Cmd +/- keep config in sync. -
Package:
npm run package?release/pkg(NSIS on Windows).npm run package:linux? AppImage on Linux; on Windows/macOS usenpm run package:linux:docker(Docker +electronuserland/builder:22).npm run package:mac/package:wintarget a single OS. Pre-packagescripts/clean-release.mjsstops only Minnow-owned processes (scripts/kill-minnow-processes.mjsmatches packagedMinnow.appand this repo’selectron/dist/main.js— never genericelectron, which would kill Cursor and other Electron IDEs). -
Native binaries in asar:
electron-builderasarUnpackmust include subprocess executables (e.g.@lydell/node-pty,@vscode/ripgrep*).server/lib/ripgrep-path.jsremaps@vscode/ripgreppaths fromapp.asar?app.asar.unpackedbefore spawn and never execs throughapp.asar/…(Electron’sexistsSynccan lie on asar paths; macOS then fails spawn withENOTDIR).grep,find_files, Brain code index, research codebase search, and workspace LOC all depend on it. Thegreptool passes ripgrep--sort pathso match order is stable across invocations (required foroffsetpagination). -
Packaged
src/for the Node server: electron-builder does not ship the SPA TypeScript tree. Anythingserver/imports fromsrc/at runtime must be listed inpackage.jsonbuild.files— includingsrc/models/**(.mjsmemory/geometry/launch-plan shared with llama.cpp hosting).!src/**/*.tsstill strips TS.scripts/validate-packaged-runtime-files.mjswalks those imports and their relative imports, and refuses extraResources-only leaves (the v0.1.3harness-commands.jsonmiss). It is invoked fromelectron-builder-run.mjsandpackage-linux-docker.mjssopackage:win/package:linux:dockercannot skip it (npmprepackageonly runs fornpm run package). -
Packaged boot: Electron
bootstrapInnerawaits the in-process server before restoring windows. Workspace claim never POSTs the leftover Vite origin in a packaged build; a failedfetchincludeserror.causein the crash dialog (~/.minnow/logs/crash.jsonl). -
Research HTML export:
server/research/report-theme.jsreadssrc/styles/tokens.cssat runtime to inline palette tokens. Packaged builds exclude mostsrc/**/*.cssbut must re-includesrc/styles/tokens.cssafter that negation (seepackage.jsonbuild.files); the same validator asserts the file exists. -
Preview browser: requires Electron (
window.minnow.preview); hidden in plain browser tabs. The nativeWebContentsViewis a window-level overlay. Guest visibility is renderer-gated:preview-electron-visibility.tshides it when another app is foreground (data-os-app !== 'code'), a full-screen app page is open, the Minnow wiki overlay (#/wiki,product-wiki.ts) is open, or chrome popovers overlap the pane. Main-process navigate / tab activate /loadSourceattach the guest for background loads but must not restorelastBoundsandsetVisible(true)— only rendererpreview.show(bounds)turns a hidden guest on (electron/preview-host.ts,preview-guest-reveal.ts). The user-surfacebrowser_navigateopens the Code preview panel (chrome + body, including Orchestrate stage views) then shows the guest inside it; on Issues / Settings / other apps it navigates in the background and never paints the overlay (preview-panel.tsrevealPreviewPanelForAgentNavigation). Code stage views (Overview, Code map, Super Plan, Orchestrate, Dev servers) replace#chatAreaonly;#previewPaneis a sibling in#workspaceSplit, so the guest stays painted when the pane is open andnotifyCodeStageViewChanged/ Super Plan chrome re-sync bounds after the chat rail hides.app-host.tsre-syncs visibility on every app-layer switch so a stale guest cannot block clicks (hand cursor, dead buttons). PreviewcapturePageis bounded at 3s and re-hides afterward if the instance was not previously visible; guestexecuteJavaScript(browser_eval, snapshot, click, fill) is bounded at 30s in both the guest wrapper and main-processpreviewExecJs(electron/preview-guest-actions.ts), with a matching renderer race plus chat abort inbrowser-preview-tools.ts, so a never-settling Promise or infinite loop cannot stall the tool loop or orchestrator task chats. -
Auto-update: GitHub Releases on
henrigrimm/minnowviaelectron-updater(package.jsonbuild.publish+repository); packaged installs use Settings ? General ? App updates. Releases must be published (not draft); Stable channel needs a non–pre-release Latest on GitHub or checks fail with “Could not check for updates”.latest.yml/latest-linux.yml/latest-mac.ymlmust come from the sameelectron-builderrun as the installer they list — replacingMinnow-Setup-*.exelater without re-uploadinglatest.ymlmakes the app download then reject the file (SHA-512 / size mismatch) so it never installs. Check withnode scripts/verify-github-update-feed.mjs. Unpackagednpm startwriteselectron/dist/package.jsonsoapp.getVersion()matches the repo instead of a stale FileVersion on brandedelectron.exe. Release notes for maintainers:releases/(v0.0.1.md…v0.1.0.md); shiplatest.yml(Windows),latest-linux.yml(Linux AppImage), andlatest-mac.yml(macOS) with each build. -
System tray: Close-to-tray is on by default (
config.desktopShell.closeToTray). Closing the last window hides Minnow to the tray so chats, agents, and the tool server keep running; tray Quit Minnow runs the normal shutdown path. Closing one of several windows asks (config.desktopShell.windowCloseAction, defaultask) via the in-app dialog (src/ui/window-close-prompt.ts, IPCWINDOW_CLOSE_PROMPT); nativedialog.showMessageBoxis only the fallback when that window's renderer is not ready. Tray menu: Open, New chat, agent/model status, unload local models, Settings, launch at startup (OS login item — not duplicated in config). Platform tray icons live underbuild/tray/(generated bynpm run app-icon:syncfrompublic/logos/minnow-glyph.svg): macOS uses a menu-bar template (trayTemplate.png+@2x); Linux usestray-linux.png; Windows usesbuild/icon.ico. macOS: left-click focuses the window and opens the tray menu; right-click opens the menu. Linux: requires a desktop environment with AppIndicator/StatusNotifier support (GNOME extension, KDE, etc.). Modules:electron/tray.ts,electron/tray-icon.ts,electron/tray-close.ts,electron/window-close-prompt.ts,electron/login-item.ts,src/electron-tray-bridge.ts. Settings ? General ? Desktop app. -
In-app dialogs:
src/ui/app-dialog.tsreplaces blocking nativealert/confirm/promptin the Electron shell with Minnow-styled modals (installAppDialogs()at boot; call sites useawait appConfirm()/appAlert()/appPrompt()/appChoice()). Overlay z-index100030keeps dialogs above shell chrome. Do not use synchronouswindow.confirm()/window.alert()/window.prompt()in product SPA code — in Electron the patched sync APIs cannot block on custom UI (syncconfirmalways returnsfalse, syncpromptreturnsnull).
-
npm test? discoverstest/**/*.test.{js,mjs,mts,ts}viatest/run-all.mjs. -
npx tsc --noEmit? typecheck forsrc/withstrict: truein roottsconfig.json(no ESLint config); CI enforces the same command on Windows and Ubuntu. -
CI:
.github/workflows/ci.yml? Windows + Ubuntu + macOS (windows-latest,ubuntu-latest,macos-latest; MIN-553 Phase 0). Headless CLI unit tests (test/headless/) run in that suite vianpm test; there is no separate headless workflow. Product wiki (product wikiCI job, Ubuntu only):test/product-wiki/product-wiki.test.mjsvianpm run test:product-wiki, excluded fromnpm testso catalog drift fails in minutes instead of after the full OS matrix. -
Other workflows:
board-nightly.yml,board-release.yml,wiki-sync.yml.
Scoped suites: see package.json (test:memory, test:brain, test:product-wiki, test:onboarding, test:issues, test:scheduler, test:voice, test:engine, test:a11y, ?).
Accessibility: contributor/accessibility-audit.md — per-app keyboard checklist, NVDA smoke notes, contrast tokens. Regression: npm run test:a11y (test/a11y/, test/theme-contrast.test.mts). Global shortcuts overlay: ? (src/ui/shell-keyboard-help.ts) — mounts inside the foreground app layer (or #osStage on desktop) with scoped absolute positioning; grouped sections, key chips, scrollable body. Rows tagged with appId are omitted when the app is not developer-released (releaseState: 'hidden'). Per-chat model picker: Mod+M (src/ui/composer-model-trigger.ts). Streaming SR throttling: src/ui/a11y/stream-announcer.ts. App surface cycle: Ctrl+Tab / Ctrl+Shift+Tab cycles MRU apps on the left rail (src/os/app-focus-cycle.ts).
Model routers: server/model-routers/ owns workspace-scoped router configuration, persisted sticky assignments/overrides, smooth rank-weighted or priority selection, and FIFO admission keyed by provider/model. Data lives in ~/.minnow/model-routers/<sha256-workspace-path>.json; writes use atomic replacement and revision checks. Sub-agent attempts carry a capacity preference through the in-process completion adapter: each round keeps its own sticky assignment while capacity is free, but selects another eligible free entry when that model is busy. Parent chat assignments and explicit router overrides stay sticky; when all eligible entries are busy, FIFO admission still applies. Activity/telemetry is session-only. /api/generations/routers supports GET/PUT configuration; /:routerId/activity returns live counters/assignments/availability; POST /:routerId/override changes a chat override. The synthetic picker provider minnow-router carries the router id as model; both HTTP and in-process generation bindings dispatch to pumpRouterGeneration. Each attempt uses the existing provider transport with same-candidate retries disabled. A minnow_router SSE control payload identifies assignments and resets failed partial responses before failover (phase: loading | waiting | generating); the shared runner emits response_restart or loading_model, clearing prose on restart, and the chat painter / stream status show Loading model… while a My Models serve comes up. Providers are checked against live catalogs and persisted capabilities; My Models (minnow-library + gguf:/mlx:) entries are eligible from the cached library even when unloaded. library-serve.js serializes local load/swap: wait until in-flight llama.cpp / MLX generations finish, then resolveLibraryAttemptBinding → startServe / admitServe. admitServe also skips victims that still have in-flight completions. llama-cpp-local / mlx-lm-local are omitted from the Routers editor; the surface is My Models plus other configured providers. src/models/routers.ts caches workspace defaults for synchronous new-chat creation, while existing chats retain their explicit binding. Models → Routers is a lazy section (src/ui/models/routers-panel.ts) with editable rank, live capacity graph, telemetry, and overrides. Coverage: test/model-routers/.
| File | Role |
|---|---|
server.js |
Vite + API middleware |
src/main.ts |
Client bootstrap |
src/tools/definitions.ts |
Tool catalog |
src/chat/run-turn-chat.ts |
Product chat send around runTurn()
|
src/chat/build-api-messages.ts |
Outbound VLM / history messages[]
|
src/tools/client.ts |
Tool router + approval |
src/chat/prompts/prompt-composer.ts |
System prompt composition |
src/chat/modes/registry.ts |
Mode definitions |
src/state/sessions.ts |
Session persistence |
src/api/generations.ts |
Generations client |
src/api/sse-parse.ts |
SSE framing |
src/os/shell.ts |
Minnow Shell |
server/runtime/tools-middleware.js |
Server tool dispatch |
server/generations/ |
Buffered upstream streams |
server/config/validators.js |
Config + session schema |
- Root
tsconfig.jsontypecheckssrc/withstrict: true; keepnpx tsc --noEmitclean before merge (CI gate). Enablement plan:plans/typescript-strict-enablement.md. - Match surrounding code style; CSS uses
--mn-*tokens only (tokens.css). - Comments: one short line above a function when the name is not enough. No comments inside functions except compiler/linter directives. Large files use 80-character section banners so you can jump by name, e.g.
// ── Branches ─────────────────────────────────────────────────────────────────(scc-refs.ts,research/panel.ts). - Update this file when architecture, APIs, or storage change.
- Feature plans and historical notes live in
documentation/plans/? not here. - Path safety: file/git tools resolve under workspace root unless
TOOLS_ALLOW_ALL_PATHS=1./api/gitvalidates a caller-suppliedcwdthe same way/api/toolsand/api/terminaldo. Tool/previewworkspaceRootoverrides also allow folders open in some view, chats, Scratch, benchmark, scheduler, recent workspace MRU paths, registered git worktrees, and repo-local.worktrees/(validateAllowedWorkspaceRoot). On Windows and macOS,normalizeWorkspacePathKeycanonicalizes viarealpathForBoundaryCheckso short/symlinked paths (GitHub ActionsRUNNER~1,/var→/private/var) match long paths from Node/git worktree list. Missing preview files are HTTP 404 (allowlist failures stay 400).
Download recovery: POST /api/models/download/:id/pause|resume preserves and resumes the same job; paused jobs stay paused across restart. Active duplicate starts are serialized and reuse the existing job. Job-index writes are serialized and atomic. Explicit nested GGUF paths keep their repository subdirectories. discover-downloads.ts retains failed/paused rows and recent completions, with retry, cancel-and-discard, and My Models actions. Cancel waits for stream closure before returning; failed transfers keep valid partial bytes.
Generated from documentation/. Do not edit generated pages directly.
Extensions
Developer reference
Contributing
- Accessibility and keyboard-first audit
- Minnow apps
- Architecture overview
- Command reference
- LAN companion
- Orchestrator V2 board testing
- Contributing to Minnow
- Setup from source
Design system
- CSS file map
- Layout shell
- UI primitives
- Minnow design system (current state)
- Minnow Shell
- Themes
- Design tokens
Guides
- Accessibility and keyboard-first audit
- Minnow apps
- Architecture overview
- Command reference
- Configuration & storage
- Keyboard shortcuts
- LAN companion
- Model bench: Flip Match v1
- Orchestrate board testing
- Minnow guides
- Release E2E testing guide
- Setup guide
- Troubleshooting
- Minnow wiki
Maintainers
- macOS release signing & notarization
- Prompt ownership matrix (MIN-379)
- Releasing Minnow
- Settings reference
- GitHub Wiki publishing
Apps
Chat
Core concepts
Extend Minnow
Get started
Orchestrate
Overview
Reference