Compare commits

...

35 Commits

Author SHA1 Message Date
31d8efe66a feat(web): enhanced file panel — side-by-side diff, hide whitespace, inline review
Adds DiffSplitView component for side-by-side diff mode, whitespace-only
change filtering, inline review comments with thread/gutter cell UI, diff
preferences persistence, and write-file API support for in-browser editing.

Backend: hideWhitespace param on git diff endpoint, write_file route.
2026-06-07 22:16:20 +00:00
c935687725 chore(openspec): drop 9 superseded proposals + 11 stub archive files
Drop 9 batch proposals that are superseded by the boocode-lift-analysis
(boocontext-audit, conductor upgrades, self-healing/verify-gate skills):
add-3tier-memory, import-llm-evaluator, import-pregel-engine, plugin-platform,
conductor-evolution, code-intelligence-upgrade, dev-workflow, ui-overhaul,
agent-reliability.

Delete 11 stub archive files (49-66B each, 'Status: Shipped. Archived.' only)
that provide zero documentation value over the existing CHANGELOG.md + git tags.
2026-06-07 22:15:38 +00:00
0d6e9a2413 feat(coder): complete orchestrator advanced patterns
- Approval gate steps pause and await human resolution
- appendStepEvent wired into markStep, failRun, dispatchAgentStep
- Trigger rule unit tests (6 variants)
- New parallel-research flow with one_success trigger
2026-06-07 21:55:47 +00:00
6344105877 feat(server): memory v2 tests and search_memory tool 2026-06-07 21:55:47 +00:00
028c08b4cd docs: add openspec proposals for memory v2 and orchestrator flow patterns 2026-06-07 21:34:35 +00:00
fb52eb3efa feat(coder): orchestrator advanced flow patterns
- TriggerRule type (all_success/one_success/all_done) for parallel deps
- Variable substitution ($stepId.output.field) in agent step prompts
- Approval gate step kind (pauses flow via permission frames)
- flow_step_events table for append-only event-sourced step log
- evaluateTriggerRule pure function in flow-runner-decisions
2026-06-07 21:34:30 +00:00
648a59a563 feat(server): memory v2 — BM25 + local embedding hybrid search
- Bm25Ranker: Okapi BM25 scoring (pure TS, no deps)
- Embedding module: ONNX-based local embeddings via onnxruntime-node
- Hybrid recall: BM25 (30%) + cosine similarity (70%) weighted merge
- Falls back to keyword-only via MEMORY_SEARCH=keyword env var
- extract_memory agent tool for persisting memory entries
2026-06-07 21:34:25 +00:00
7f59f30f2d docs: update code review doc with v2.8 fork-lifts lift sources
- Added 10 new lift source entries (boocontext, TSA, type-inject,
  morph-fast-apply, tokenscope, DCP, qwen-code memory/LSP,
  oh-my-openagent, paseo protocol) under v2.8 fork-lifts section
- Added 9 new rows to the lift catalog table
- Added decisions log entry for v2.8.0-fork-lifts batch
- Bumped last-updated to 2026-06-07
2026-06-07 18:44:12 +00:00
f436021bf9 feat: deferred items — arena token API + UI, ToolShim docs
- Arena API: token_breakdown selected in contestant query
- ArenaPane: token category breakdown bar (s/u/a/t/r) in expanded contestant view
- apps/server/CLAUDE.md: document tool-shim and loop-detectors
2026-06-07 18:41:26 +00:00
bef6bef504 docs: update changelog, roadmap, current focus, and coder CLAUDE.md
- CHANGELOG: v2.8.0-fork-lifts entry covering all 8 integrations
- Roadmap: update shipped header through v2.8.0, bump last-updated date
- CURRENT.md: reflect fork-lifts as last-shipped batch
- apps/coder/CLAUDE.md: document edit-guards behavior and API
2026-06-07 18:05:55 +00:00
87923cb07b feat(coder): add flow-artifacts write helper and boocontext MCP template 2026-06-07 18:05:49 +00:00
c6ecd984c5 feat(coder): add TokenScope analyzer and DB persistence module
- analyzeMessages classifies message parts into system/user/assistant/tools/reasoning
- persistTaskBreakdown writes JSONB to tasks table
- Backfills the token-analysis/ module (contract committed earlier)
- 6 unit tests covering classification, tool calls, reasoning tokens
2026-06-07 18:05:35 +00:00
2a83f61070 feat(coder): add import-drop detection to edit safety guards
- checkDroppedImports detects removed import/require lines in edits
- Runs alongside truncation guard in pending_changes.ts
- Supports ESM imports, CJS require, type imports, side-effect imports
2026-06-07 18:05:30 +00:00
44874f0097 feat: fork lifts phases 3-9 — LSP, DCP, memory, boocontext, protocol, plugins, reliability 2026-06-07 17:58:30 +00:00
1b70d41996 feat(server): add inference reliability - tool-shim and loop detectors
- ToolShim recovers XML/JSON tool calls from plain-text model output
- detectContentRepeat catches same-content loops
- detectToolLoop catches repeated tool invocations
- detectDoomLoop combines both detectors
2026-06-07 17:57:58 +00:00
b64941ad4b feat(coder): add plugin hook host
- Typed hook registry with registerHook/emitHook/clearHooks
- Hooks: tool.execute.before/after, turn.start/end, task.terminal
- SUL patterns only (oh-my-openagent: architecture study, no code copy)
2026-06-07 17:57:53 +00:00
cdc782e044 feat(core): add subagent protocol enhancements
- AgentCapabilitiesSchema with supportsStreaming/Reasoning/Background flags
- supportsStreaming and supportsReasoningStream fields in ProviderSnapshotEntry
- new_task tool: background mode flag for non-blocking subtask dispatch
2026-06-07 17:57:49 +00:00
02bb355a09 feat(server): add institutional memory recall
- File-based memory under .boocode/memory/ (project/user/reference topics)
- Hierarchical 4-scope scan: global → home → project → session
- Keyword/tag relevance matching for query-based recall
- Injected as <boocode-memory> block in system prompt at assembly
- v1 recall-only (extract/dream deferred to v2)
2026-06-07 17:57:44 +00:00
b8b2666fdc feat(server): add DCP clean-room context pruning
- Deduplication: removes consecutive identical tool_call+tool_result pairs
- Purge-errors: removes failed/empty tool results
- Transform orchestrator runs strategies in sequence pre-payload
- Wired into turn.ts before buildMessagesPayload
- Clean-room reimplementation (AGPL reference: behavior only)
2026-06-07 17:57:39 +00:00
ee749d8698 feat(coder): add LSP code intelligence tools
- lsp/ module: types, config, JSON-RPC client, server-manager, operations
- lsp_diagnostics: TypeScript/JavaScript diagnostics for a file
- lsp_goto_definition: find symbol definition at position
- lsp_find_references: find all references to a symbol
- Registered as READ_TOOLS in tool index
2026-06-07 17:57:35 +00:00
bc83475a3d feat(server): add boocontext deep analysis tools and synthesis pipeline
- get_symbol_details: type signature, definition location, usage count
- get_call_graph: callers, callees, transitive references
- get_blast_radius added to SYNTHESIS_TOOLS
2026-06-07 17:57:29 +00:00
214cc32ac2 feat(codecontext): upgrade sidecar to boocontext MCP aggregator
- Multi-stage Dockerfile builds boocontext (Node) + HTTP shim (Go)
- shim.go supports CODECONTEXT_CHILD env var for configurable MCP child
- Adds routes for get_symbol_details, get_call_graph, get_blast_radius
- docker-compose.yml adds env vars for child MCP paths
2026-06-07 17:57:24 +00:00
6b7c2bab1e feat(coder): persist token breakdown in arena decisions and schema 2026-06-07 17:57:19 +00:00
373ba86e5d feat(coder): add edit safety guards against truncation 2026-06-07 17:57:15 +00:00
9106334e70 feat(contracts): add TokenBreakdownSchema and ContestantShape.token_breakdown 2026-06-07 17:57:11 +00:00
cce685b1a7 fix(coder): harden edit-apply pipeline against block duplication
Root cause: two proven corruption mechanisms — (M1) non-idempotent apply
stamped the same block N times when a quantized model re-emitted the same
edit_file call or a turn was retried; (M2) Levenshtein tier 4 was fail-open
with no uniqueness guard, silently splicing into the wrong location.

Fixes applied at every layer of the pipeline:

Matcher (fuzzy-match.ts): raise SIMILARITY_THRESHOLD 0.66 → 0.85; add
AMBIGUITY_EPSILON uniqueness guard — two windows within 0.05 of the top
score → ambiguous, not a guess; add block-anchor gate (≥3-line needles
require first+last line exact match before a window is scored).

Edit planner (pending_changes.ts): extract planEdit() as a pure function;
idempotency guards detect already-applied states (anchored insert re-stamp,
old-gone-but-new-present); findPendingDuplicate() collapses identical
pending rows at queue time so M1 never reaches applyOne.

Atomic writes (pending_changes.ts): temp-file + rename on the same
filesystem so a crash can't leave a half-written source file; realpath()
first so symlinks survive the rename.

Per-file mutex (pending_changes.ts): withFileLock() serializes concurrent
read-modify-write on the same path via a chained-Promise Map.

EOL preservation (pending_changes.ts): normalize CRLF → LF for matching,
restore native line ending on write so Windows-style files stay clean.

Context isolation (inference_context.ts): replace module-level singleton
with AsyncLocalStorage so concurrent inference runs (arena parallel
dispatch, dispatcher poll racing a user message) each get their own
scoped context with no clobbering.

Tests: plan-edit.test.ts (pure planEdit unit tests), extended fuzzy-match
and pending_changes_integration suites, ALS isolation test that proves
overlapping runs get correct session IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 01:44:37 +00:00
dbf1662982 docs: add v2.7.20-arena-pane changelog entry 2026-06-06 23:34:58 +00:00
d6d246c15b feat(web,coder): arena pane — compare 2-6 AI competitors on same prompt
Arena is a new pane kind for competitive AI evaluation. A Battle runs
the same prompt against 2-6 Contestants across two concurrent lanes:
local lane (llama-swap models, serial) and cloud lane (parallel).

Added to all three registries: @boocode/contracts WsFrameSchema,
server InferenceFrame, and web WsFrame.

Backend (apps/coder):
- arena-runner: battle scheduler, lane classifier, benchmark, results
  writer, resume, user winner override
- arena-analyzer: two-stage digest→judge analysis on DEFAULT_MODEL
- arena-decisions: status transitions and resume logic (unit-tested)
- arena-analyzer-helpers: pure helper functions (unit-tested)
- arena-model-call: model call utility for analysis
- arena routes: create/get/list/stop/analyze/cross-examine/winner/diff
- schema: battles, contestants, cross_examinations tables (idempotent)
- remove old /api/arena* routes and tasks.arena_id column

Frontend (apps/web):
- ArenaLauncherDialog: battle type, prompt, contestant selection
- ArenaPane: live roster, streaming output, analysis, cross-exam
- DiffView: unified diff with line-by-line color for coding contests
- Winner override per-row dropdown (Trophy icon)
- battle_updated WS handler for live winner/analysis updates
- arena pane kind in Workspace, ChatTabBar, useSidebar

Cross-app:
- ArenaState and ArenaContestantShape/WsFrame types (contracts)
- battle_* frames in WsFrameSchema, InferenceFrame, and web WsFrame
- manifest.json written per battle results folder
- /Arena added to .gitignore

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:25:29 +00:00
e04d0fdaa8 feat(coder): unified Plan/Ask/Bypass permission picker
Replace the raw per-agent mode dropdown in the BooCoder composer with a
curated three-option permission ladder mapped generically onto each
provider's native modes: `plan` id -> Plan, default -> Ask, isUnattended
-> Bypass (claude bypassPermissions, qwen yolo, opencode full-access).
modeId stays the single wire field; the active unified mode is derived
from it (no contracts change).

Native BooCode gains its own mode set: Ask stages to the pending-changes
queue (today's behavior), Bypass auto-applies the queue to disk after the
turn (interactive messages path + task dispatcher path), Plan falls back
to Ask. The shared apps/server inference engine is left untouched.

Also preserve isUnattended on live-probed ACP modes so opencode's bypass
mode stays detectable from the wire.

Coder 373 tests green; coder + web typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:14:21 +00:00
da36344d0b style(web): outline the slash-picker chevron buttons
Give the expand chevrons the BooCoder outline-button look (border-border
bg-background, hover:bg-muted, filled when expanded) instead of the borderless
ghost style. Applies to both BooChat's flat menu and BooCoder's grouped menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:00:49 +00:00
875cae0843 fix(coder): parse YAML block-scalar descriptions in slash command discovery
Most plugin/han SKILL.md and command files write `description:` as a folded
block scalar (`>` / `|`) with the text on the following indented lines. The
old single-line frontmatter reader captured the literal `>`, so the slash
menu showed garbage/blank descriptions for nearly all of them. frontmatterField
now collapses folded blocks (join with spaces) and preserves literal blocks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:00:49 +00:00
4caa5f91ff docs: CLAUDE.md notes for Orchestrator + gitignored .env.host
Document the in-app Orchestrator engine and its load-bearing read-only
invariant in apps/coder/CLAUDE.md, and note that apps/coder/.env.host is
now gitignored (recreated from .env.example with CLAUDE_SDK_BACKEND=1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:48:50 +00:00
1d416d0cf9 docs: refresh README + CURRENT.md for v2.7.17 (Orchestrator)
Bring README current (was v2.2.1): add the Orchestrator, the Files/Git diff
panel, persistent agent sessions + claude Agent-SDK, fix the provider list
(5 — cursor/copilot retired), drop the broken AGENTS.md link, update latest
release + planned. Refresh CURRENT.md to v2.7.17 on main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:43:53 +00:00
bfda61e27e chore: stop tracking apps/coder/.env.host
Untrack the host env file (git rm --cached, kept on disk for the boocoder
service) and widen .gitignore to .env.* (re-including .env.example) so env
files no longer get committed. The file's prior contents (dev DB password +
internal Tailscale URLs; no API keys) remain in history — left as-is given the
single-user Tailscale-only threat model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:32:03 +00:00
a734615480 docs: archive shipped openspec changes, refresh roadmap + DEFERRED-WORK
Move openspec/changes/{contracts-ssot,orchestrator} → archived/ (both shipped,
v2.7.13 and v2.7.17). Mark the roadmap's "Write/edit robustness" and "Claude
provider SDK" milestones as shipped (fuzzy-match.ts + checkpoints.ts; the
claude-sdk backend is live via CLAUDE_SDK_BACKEND in .env.host) and add a
v2.7.12–v2.7.17 shipped summary. Flag DEFERRED-WORK.md as superseded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:30:01 +00:00
273 changed files with 14186 additions and 447 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,8 @@
node_modules node_modules
dist dist
.env .env
.env.*
!.env.example
# Claude / Cursor (local agent & IDE config — CLAUDE.md and AGENTS.md stay tracked) # Claude / Cursor (local agent & IDE config — CLAUDE.md and AGENTS.md stay tracked)
.claude/ .claude/
@@ -18,3 +20,4 @@ data/*
!data/mcp.example.json !data/mcp.example.json
!data/coder-providers.example.json !data/coder-providers.example.json
codecontext/fork.tar.gz codecontext/fork.tar.gz
/Arena

View File

@@ -2,6 +2,36 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.8.0-fork-lifts — 2026-06-07
Completes the eight fork-lift integrations from `/opt/forks` into BooCode: boocontext sidecar upgrade, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol enhancements, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards (truncation + dropped imports) and the TokenScope analyzer/persist module. Closes the fork-lifts-mit epic.
**boocontext sidecar (Phase 3):** Upgrades the `codecontext` container from the old Go MCP server to the boocontext Node.js MCP aggregator. Multi-stage Dockerfile builds boocontext from `/opt/forks/boocontext` alongside the HTTP shim. `shim.go` gains `CODECONTEXT_CHILD` env-var support and three new HTTP routes for symbols, callgraph, and blast radius. Three TypeScript tool wrappers (`get_symbol_details`, `get_call_graph`, `get_blast_radius`) registered on the server, with blast radius added to the synthesis pipeline. Docker-compose env vars configure child MCP paths (tree-sitter-analyzer, type-inject).
**LSP integration (Phase 4):** Six-file `lsp/` module in the coder with config, JSON-RPC stdio client, lazy server-manager (per-project pool, 5-min idle shutdown), and operations (diagnostics, goto-definition, find-references). Three read-only agent tools registered — `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`. TypeScript/JavaScript only in v1.
**DCP clean-room (Phase 5):** Seven-file `dcp/` module in the server inference pipeline. Consecutive identical tool_call+tool_result pairs are deduplicated; failed/empty tool results are purged via configurable window. Orchestrated by `transformMessages()` running before `buildMessagesPayload` in `turn.ts`. Clean-room reimplementation — AGPL source was referenced for behavior only. 10 unit tests.
**Institutional memory (Phase 6):** Eight-file `memory/` module with file-based recall. Hierarchical 4-scope scan (global → home → project → session) under `.boocode/memory/`. Keyword/tag relevance matching at prompt assembly. Injected as a `<boocode-memory>` block in the system prompt. v1 recall-only — extract/dream deferred.
**Subagent protocol (Phase 7):** `AgentCapabilitiesSchema` in contracts with `supportsStreaming`, `supportsReasoningStream`, `supportsBackgroundExecution` flags. `ProviderSnapshotEntry` gains the two streaming capability fields. `new_task` tool gets a `background` mode flag for non-blocking dispatch. Flow-runner already supported per-step model override.
**Plugin host (Phase 8):** Typed hook registry in `plugins/host.ts` with `registerHook`/`emitHook` for five lifecycle events: `tool.execute.before`, `tool.execute.after`, `turn.start`, `turn.end`, `task.terminal`. Patterns-only from oh-my-openagent (SUL — no code copy).
**Inference reliability (Phase 9):** `tool-shim.ts` recovers XML/JSON tool calls from plain-text model output (e.g. Qwen inline format). `loop-detectors.ts` catches content-repeat and tool-loop patterns. Existing doom-loop detection remains — detectors are additive.
**Edit safety guards (Wave 1):** `edit-guards.ts` rejects catastrophic truncation (>60% chars AND >50% lines). `edit-guards-imports.ts` detects dropped import statements. Both run in `pending_changes.ts` immediately before `writeFileAtomic`.
**TokenScope (Wave 2):** `TokenBreakdownSchema` in contracts with system/user/assistant/tools/reasoning categories. `token-analysis/` module with analyzer and DB persistence. `ContestantShape.token_breakdown` field and `token_breakdown` JSONB column on `contestants`/`tasks` tables. Arena `computeBenchmark` accepts and returns token breakdown.
**Build:** Server 649 ✅ Coder 471 ✅ Contracts ✅ — all green.
Adds the **Arena** pane for running the same prompt against 26 AI competitors simultaneously and picking the best result. A Battle is one Arena run: pick a battle type (Coding — backend+model with git worktrees producing diffs; or Q&A — BooChat persona+model producing text), write or generate a prompt, add contestants, and hit Start. Contestants are scheduled in two concurrent lanes — the local lane (llama-swap models, serial) and the cloud lane (Claude Code, OpenCode-on-cloud, parallel). The lane scheduler captures wall-clock duration for every contestant and tokens/sec for local models. When all contestants finish, a two-stage analysis (digest then judge) auto-runs on the DEFAULT_MODEL, writing `analysis.md` naming a winner; the user can override the winner per-row or trigger cross-examination. Results land in `/<project-root>/Arena/<dated-battle>/` with per-contestant `result.md`, diff patches for coding, and `manifest.json`. Replaces the old API-only `POST /api/arena` with dedicated `battles`/`contestants`/`cross_examinations` tables and full UI. Also adds a `DiffView` component with line-by-line colored unified diff and a per-row dropdown for winner override. Built on `v2.7.18-permission-modes`; pairs conceptually with the earlier `v2.7.17-orchestrator` multi-agent work (both share the pane kind pattern and `onTaskTerminal` hook).
## v2.7.18-permission-modes — 2026-06-05
Adds a unified **permission picker** to the BooCoder composer — Plan / Ask Permission / Bypass — replacing the old raw per-agent mode dropdown that exposed each agent's full native vocabulary with inconsistent labels. The three options map generically onto every provider's existing mode metadata: the `plan`-id mode → Plan, the default mode → Ask, the `isUnattended` mode → Bypass (claude `bypassPermissions`, qwen `yolo`, opencode `full-access`); goose has no modes so it shows no picker, exactly as before. `modeId` stays the single wire field — the active unified mode is derived from it, so no contracts change was needed. Native BooCode gains its own mode set (registered in the manifest and exposed by the snapshot): **Ask** stages edits to the pending-changes queue as today, **Bypass** auto-applies the queue to disk after the turn (both the interactive messages path and the task-based dispatcher path), and **Plan** falls back to Ask — the shared `apps/server` inference engine is deliberately left untouched. A supporting fix preserves the `isUnattended` flag on live-probed ACP modes (`acp-derive.ts`) so opencode's bypass mode is still detectable from the wire. Coder 373 tests green, coder + web typecheck clean. Built on `v2.7.17-orchestrator`.
## v2.7.17-orchestrator — 2026-06-03 ## v2.7.17-orchestrator — 2026-06-03
Brings the deterministic multi-agent "conductor" into the app as the **Orchestrator**: launch any read-only Han flow (research, code-review, investigate, architectural-analysis, security-review, …) from BooChat or BooCoder and watch each specialist agent stream live in a Paseo-style run pane, ending with an evidence-disciplined, adversarially-validated report — all on free local Qwen, persisted and resumable. Built and audited end-to-end via `paseo-epic` in an isolated worktree, on top of the prior `/opt/boocode/conductor` standalone CLI: the conductor's 22 flow definitions, Spine factory, and Han evidence/YAGNI contracts were re-homed into `apps/coder/src/conductor`, and a new DB-backed flow-runner (`flow_runs`/`flow_steps`) dispatches each step as a real BooCoder task through the existing dispatcher — reusing its streaming→WS-frame pipeline and worktree-as-read-snapshot, with an `onTaskTerminal` hook that advances the wave and a startup resume that re-dispatches in-flight steps after a coder restart. Read-only is enforced hard: every step is dispatched `qwen --approval-mode plan`, an adversarial-security review caught and closed a bypass where a qwen-unavailable task silently fell through to write-capable native inference (now fails closed), and the ACP path's mode-set was made fail-closed too. The UI adds a fourth `orchestrator` pane kind (collapsed agent roster, expand-one live stream, report on top), a Workflow button + slash flows on the shared `ChatInput` for full BooChat/BooCoder parity, a "New Orchestrator" entry in the + and split menus, a category-grouped launcher dialog, runs history, and export (copy / save-to-file / send-to-chat) — fed by two new `flow_run_*` WS frames on a coder user channel. Qwen-only by design (Claude Code remains the Claude path); the existing model-competition Arena stays a separate feature. The flow launcher and the `/` slash menu both carry chevron-expandable per-item explanations (an always-on one-liner expands to a 12 sentence what-it-does / when-to-use blurb, condensed from each Han skill's own description), with a "read-only" pill pinned in the launcher and the fast/concise toggle wired through to the workers. Spec/plan in `openspec/changes/orchestrator`; coder 373 tests green (42 new scheduler/resume/read-only decision tests), contracts/coder/server builds + web tsc clean. Built on `v2.7.16-container-git-safedir`; pairs conceptually with the earlier `v2.7.12-audit-cleanup` multi-agent orchestration. Brings the deterministic multi-agent "conductor" into the app as the **Orchestrator**: launch any read-only Han flow (research, code-review, investigate, architectural-analysis, security-review, …) from BooChat or BooCoder and watch each specialist agent stream live in a Paseo-style run pane, ending with an evidence-disciplined, adversarially-validated report — all on free local Qwen, persisted and resumable. Built and audited end-to-end via `paseo-epic` in an isolated worktree, on top of the prior `/opt/boocode/conductor` standalone CLI: the conductor's 22 flow definitions, Spine factory, and Han evidence/YAGNI contracts were re-homed into `apps/coder/src/conductor`, and a new DB-backed flow-runner (`flow_runs`/`flow_steps`) dispatches each step as a real BooCoder task through the existing dispatcher — reusing its streaming→WS-frame pipeline and worktree-as-read-snapshot, with an `onTaskTerminal` hook that advances the wave and a startup resume that re-dispatches in-flight steps after a coder restart. Read-only is enforced hard: every step is dispatched `qwen --approval-mode plan`, an adversarial-security review caught and closed a bypass where a qwen-unavailable task silently fell through to write-capable native inference (now fails closed), and the ACP path's mode-set was made fail-closed too. The UI adds a fourth `orchestrator` pane kind (collapsed agent roster, expand-one live stream, report on top), a Workflow button + slash flows on the shared `ChatInput` for full BooChat/BooCoder parity, a "New Orchestrator" entry in the + and split menus, a category-grouped launcher dialog, runs history, and export (copy / save-to-file / send-to-chat) — fed by two new `flow_run_*` WS frames on a coder user channel. Qwen-only by design (Claude Code remains the Claude path); the existing model-competition Arena stays a separate feature. The flow launcher and the `/` slash menu both carry chevron-expandable per-item explanations (an always-on one-liner expands to a 12 sentence what-it-does / when-to-use blurb, condensed from each Han skill's own description), with a "read-only" pill pinned in the launcher and the fast/concise toggle wired through to the workers. Spec/plan in `openspec/changes/orchestrator`; coder 373 tests green (42 new scheduler/resume/read-only decision tests), contracts/coder/server builds + web tsc clean. Built on `v2.7.16-container-git-safedir`; pairs conceptually with the earlier `v2.7.12-audit-cleanup` multi-agent orchestration.

View File

@@ -74,11 +74,11 @@ Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ...
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only add-existing scope), `BOOTSTRAP_ROOT` (/opt/projects, writable bootstrap mkdir target — host must `mkdir -p` it before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale; the public host is behind Authelia, unusable from server context), `BOOCODE_TOOLS` (`core`|`standard`|`all`, default `all`; a ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (default `/data/mcp.json`, opencode `mcpServers` shape; missing = no MCP), `CONTEXT7_API_KEY` (the Context7 MCP key, referenced from `data/mcp.json` as `"{env:CONTEXT7_API_KEY}"`). `data/mcp.json` is **gitignored** but no longer holds secrets — string values support opencode-style `{env:VAR}` substitution (`mcp-config.ts:substituteEnvVars`, applied before Zod validation; unset var → `''` + warn), so real keys live in `.env`; template `data/mcp.example.json`. A config-only edit there needs only `docker compose restart boocode` (data/ is bind-mounted); changing a referenced secret edits `.env`. MCP loads at server startup with per-server graceful degradation; the coder does NOT load MCP (BooChat only). Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only add-existing scope), `BOOTSTRAP_ROOT` (/opt/projects, writable bootstrap mkdir target — host must `mkdir -p` it before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale; the public host is behind Authelia, unusable from server context), `BOOCODE_TOOLS` (`core`|`standard`|`all`, default `all`; a ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (default `/data/mcp.json`, opencode `mcpServers` shape; missing = no MCP), `CONTEXT7_API_KEY` (the Context7 MCP key, referenced from `data/mcp.json` as `"{env:CONTEXT7_API_KEY}"`). `data/mcp.json` is **gitignored** but no longer holds secrets — string values support opencode-style `{env:VAR}` substitution (`mcp-config.ts:substituteEnvVars`, applied before Zod validation; unset var → `''` + warn), so real keys live in `.env`; template `data/mcp.example.json`. A config-only edit there needs only `docker compose restart boocode` (data/ is bind-mounted); changing a referenced secret edits `.env`. MCP loads at server startup with per-server graceful degradation; the coder does NOT load MCP (BooChat only).
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`. BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Its env file `apps/coder/.env.host` is gitignored (`.env.*`, with `!.env.example`) — a fresh host recreates it from `.env.example` (incl. `CLAUDE_SDK_BACKEND=1` for the Claude Agent-SDK backend). Deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
- `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL. Set to a small llama-swap model (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls. - `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL. Set to a small llama-swap model (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls.
- Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — `-p` runs autonomously without prompts. ACP bridge is an HTTP daemon (not stdio); use PTY dispatch. - Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — `-p` runs autonomously without prompts. ACP bridge is an HTTP daemon (not stdio); use PTY dispatch.
- Arena: `POST /api/arena {project_id, input, contestants: [{agent?, model?}]}` dispatches the same task to N models/agents in parallel; each contestant gets its own task + worktree. `GET /api/arena/:id` for results; `POST /api/arena/:id/select/:task_id` picks a winner. - Arena: `POST /api/battles {project_id, battle_type, prompt, contestants}` starts a battle; `GET /api/battles/:id` returns battle + contestants + cross-examinations; `POST /api/battles/:id/stop` cancels; `POST /api/battles/:id/analyze` triggers/re-triggers two-stage digest→judge analysis; `GET /api/battles/:id/analysis` reads `analysis.md`; `POST /api/battles/:id/cross-examine {identity, model}` runs a cross-examination. All `/api/battles*` routes are served by `apps/coder` at port 9502 (proxied through `apps/server` as `/api/coder/battles*`).
## Workflow ## Workflow

67
CONTEXT.md Normal file
View File

@@ -0,0 +1,67 @@
# Context: BooCode
Glossary of the domain language. Terms only — no implementation detail.
## Workspace
- **Pane** — one tile in the multi-pane workspace. Each pane has a *kind*:
Chat (BooChat), Coder (BooCoder), Terminal (BooTerm), Orchestrator, Arena,
plus artifact/settings kinds.
- **Backend** — an AI engine a task is dispatched to: *native* (BooChat
inference on a local llama-swap model) or an *external* CLI agent (Claude Code,
OpenCode, Qwen, Goose). Code sometimes calls this the "agent" (`tasks.agent`).
- **BooChat Agent** (a.k.a. *persona*) — a preset from the `data/AGENTS.md`
registry (e.g. "Code Reviewer", "Debugger"): a system prompt + tool whitelist +
sampling knobs that runs **on the native backend** with a chosen model.
Distinct from a Backend — this is the overloaded sense of "agent" the UI's
Agent picker selects.
## Arena
A way to run the **same prompt** against several AI competitors at once and pick
the best result.
- **Battle** — one Arena run. Dated. Produces a results folder at
`/<project-root>/Arena/<dated-battle>/`. (The earlier API-only feature called
this an "arena"; a Battle is one such run.)
- **Battle Type** — what is being compared:
- *Coding* — Contestants change code; a result is the **diff** they produced
(plus their explanation). Each Contestant works in its own worktree.
- *Q&A* — Contestants answer a prompt; a result is the **text answer**. No
code changes.
- **Contestant** — one competitor in a Battle, given the Battle's prompt. What
defines a Contestant depends on Battle Type:
- *Coding* — a **Backend + Model** (e.g. Claude Code + opus, native BooCode +
35b). Each works in its own isolated git **worktree** (a branched on-disk
copy of the project). Contestants do not see each other's work.
- *Q&A* — a **BooChat Agent (persona) + Model** (e.g. Debugger + 35b), running
on the native backend only. No worktree (no code changes).
The same model can appear under two Contestants, so a Contestant's identity is
the (backend-or-persona, model) pair, not the model alone.
- **Benchmark** — per-Contestant performance captured during a Battle. Wall-clock
**duration** is recorded for every Contestant; **throughput** (tokens/sec) is
recorded only for local (llama-swap) models, which are the ones the speed
comparison is meaningful for.
- **Arena results folder** (`/<project-root>/Arena/<dated-battle>/`) — where a
Battle's *results* are written (not the working copies — those stay in each
Contestant's worktree). Holds the per-Contestant result and the final
analysis.
- **Lane** — how a Battle's Contestants are scheduled. The *local lane* holds
every llama-swap-backed Contestant and runs them strictly one at a time (the
local server can only load one model at a time, which also keeps their speed
Benchmark fair). The *cloud lane* holds cloud-backed Contestants (Claude Code,
OpenCode-on-cloud) and runs them all in parallel. The two lanes run
concurrently with each other.
- **Analysis** — an end-of-Battle judgement of the Contestants' results,
produced by the default BooChat model, naming a **Winner**.
- **Cross-examination** — an after-the-Battle step where a chosen model (from any
agent) is pointed at the Battle's results to interrogate / compare them.

View File

@@ -1,9 +1,9 @@
# Current focus # Current focus
Last updated: 2026-06-02 Last updated: 2026-06-07
- **Last shipped:** `v2.7.8-ember-coder-tabs-model-chips` (2026-06-01) - **Last shipped:** `v2.8.0-fork-lifts` (2026-06-07) — eight fork-lift integrations from `/opt/forks`: boocontext sidecar, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards and TokenScope analyzer/persist module.
- **Branch:** `codebase-audit-cleanup` (audit + cleanup epic, off main HEAD) - **Branch:** `main`
- **In progress:** Phase 3 — stale comments + docs refresh - **In progress:** nothing committed — all phases 3-9 of fork-lifts-mit epic are shipped. Optional/exploratory: verify-gate ensembler over pending changes; web Arena token UI display.
See `CHANGELOG.md` for the full shipped history. That file is always authoritative; this file is a quick orientation pointer only. See `CHANGELOG.md` for the full shipped history. That file is always authoritative; this file is a quick orientation pointer only.

View File

@@ -1,10 +1,10 @@
# boocode # boocode
Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals). Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals) — plus the in-app **Orchestrator**, a deterministic multi-agent conductor that runs read-only Han analysis/review flows on local Qwen.
**Latest release:** `v2.2.1-pane-scoped-chats` (2026-05-26) · [`CHANGELOG.md`](CHANGELOG.md) · **Current focus:** [`CURRENT.md`](CURRENT.md) **Latest release:** `v2.7.17-orchestrator` (2026-06-03) · [`CHANGELOG.md`](CHANGELOG.md) · **Current focus:** [`CURRENT.md`](CURRENT.md)
**Agent navigation:** [`AGENTS.md`](AGENTS.md) · **Architecture:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · **Engineering reference:** [`CLAUDE.md`](CLAUDE.md) **Architecture:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · **Engineering reference:** [`CLAUDE.md`](CLAUDE.md) · **Roadmap:** [`boocode_roadmap.md`](boocode_roadmap.md)
## Stack ## Stack
@@ -75,15 +75,16 @@ curl http://100.114.205.53:9502/api/health
## What's shipped ## What's shipped
See [`boocode_roadmap.md`](boocode_roadmap.md) for full version history. Highlights as of **v2.2.1**: See [`boocode_roadmap.md`](boocode_roadmap.md) and [`CHANGELOG.md`](CHANGELOG.md) for full version history. Highlights as of **v2.7.17**:
- **BooChat**: streaming chat, file-read tools, compaction, reasoning support, HTML/Markdown artifact panes, cross-repo read grants, MCP client (multi-server + stdio), tool-cost tracking, skills system, builtin agent registry, multi-pane workspace (chat / terminal / coder) - **BooChat**: streaming chat, file-read tools, compaction, reasoning support, HTML/Markdown artifact panes, cross-repo read grants, MCP client (multi-server + stdio), tool-cost tracking, skills system, builtin agent registry, multi-pane workspace (chat / terminal / coder / orchestrator)
- **BooTerm**: in-browser terminal panes via tmux + xterm.js, per-session tmux sessions, SSH-out support - **BooTerm**: in-browser terminal panes via tmux + xterm.js, per-session tmux sessions, SSH-out support
- **BooCoder (v2.2)**: write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`), pending-changes queue with diff UI, Paseo-style provider snapshot (7 providers: boocode, cursor, claude, opencode, goose, qwen, copilot), `AgentComposerBar` (provider / mode / model / thinking), ACP dispatch with inline permission prompts + tool/reasoning streaming, PTY fallback, Arena, MCP server (6 tools, stdio), CLI client, human inbox, Boomerang orchestration, path-guard fuzz suite, **pane-scoped chats** (v2.2.1 — each coder/terminal pane owns its chat) - **BooCoder**: write tools (`edit_file` with fuzzy matching, `create_file`, `delete_file`, `apply_pending`, `rewind`, git-ref checkpoints), pending-changes queue + a **Files/Git diff panel** (stage / commit / discard), provider snapshot (5 providers: boocode, claude, opencode, goose, qwen — cursor/copilot retired), `AgentComposerBar`, warm ACP + **persistent agent sessions** (opencode HTTP server; claude via the Agent SDK with native session resume) + PTY fallback, config-backed provider lifecycle, Arena (same task → N models), MCP server, CLI client, human inbox, Boomerang orchestration, pane-scoped chats
- **Orchestrator** (v2.7.17): launch any of 22 read-only Han flows (research, code-review, investigate, architectural-analysis, …) from BooChat or BooCoder via the Workflow button, a slash command, or **+ menu → New Orchestrator**; each step runs as a bounded agent on local Qwen (hard read-only via `qwen --approval-mode plan`), streaming live in a Paseo-style run pane with an evidence-disciplined, adversarially-validated report. Persisted + resumable. `@boocode/contracts` single-sources the cross-app wire contracts (v2.7.13).
## Planned ## Planned
- **v2.3 provider lifecycle** — config-backed provider registry (`/data/coder-providers.json`), enable/disable toggles, two-tier probe (openspec drafted). See [`CURRENT.md`](CURRENT.md). Most prior roadmap milestones have shipped (see [`boocode_roadmap.md`](boocode_roadmap.md)). What remains is optional/exploratory — e.g. a verify-gate ensembler over pending changes (majority-vote diff ranking). No committed milestones currently in flight.
## License ## License

View File

@@ -1,17 +0,0 @@
NODE_ENV=production
PORT=9502
HOST=100.114.205.53
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boochat
LLAMA_SWAP_URL=http://100.101.41.16:8401
PROJECT_ROOT_WHITELIST=/opt
BOOTSTRAP_ROOT=/opt/projects
DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4
LOG_LEVEL=info
SEARXNG_URL=http://100.114.205.53:8888
GITEA_BASE_URL=https://git.indifferentketchup.com
GITEA_USER=indifferentketchup
GITEA_SSH_HOST=100.114.205.53:2222
MCP_CONFIG_PATH=/data/mcp.json
SKILLS_ROOT=/opt/boocode/data/skills
CODER_PROVIDERS_PATH=/opt/boocode/data/coder-providers.json
CLAUDE_SDK_BACKEND=1

View File

@@ -32,3 +32,15 @@
- **Claude SDK backend tool RESULTS arrive as `type:'user'` SDK messages** (tool_result content blocks): `mapSdkMessage` (`claude-sdk-map.ts`) MUST map the `user` case → a terminal `tool_update` (completed/failed + output), else the tool_call persists `status:'running'` and the UI spinner never stops. The dispatcher's `tool_update` path then publishes + persists it. - **Claude SDK backend tool RESULTS arrive as `type:'user'` SDK messages** (tool_result content blocks): `mapSdkMessage` (`claude-sdk-map.ts`) MUST map the `user` case → a terminal `tool_update` (completed/failed + output), else the tool_call persists `status:'running'` and the UI spinner never stops. The dispatcher's `tool_update` path then publishes + persists it.
- **ACP command discovery is async**: `acp-probe.ts` must poll after `newSession` for `available_commands_update` (commands arrive in a later notification; reading synchronously captures 0). PTY providers (claude) discover from disk via `claude-command-discovery.ts` (`~/.claude/commands` + `enabledPlugins`, bare names, deduped). `AgentCommand.kind` tags `'command'` vs `'skill'`; `CoderPane`'s `slashGroups` splits them into icon'd groups. `SlashCommandPicker`'s `groups?` prop is opt-in. - **ACP command discovery is async**: `acp-probe.ts` must poll after `newSession` for `available_commands_update` (commands arrive in a later notification; reading synchronously captures 0). PTY providers (claude) discover from disk via `claude-command-discovery.ts` (`~/.claude/commands` + `enabledPlugins`, bare names, deduped). `AgentCommand.kind` tags `'command'` vs `'skill'`; `CoderPane`'s `slashGroups` splits them into icon'd groups. `SlashCommandPicker`'s `groups?` prop is opt-in.
- **A new per-message coder field silently drops unless you update every mapper**: the HTTP read SELECT + `mapCoderMessageRow` (`apps/coder/src/routes/messages.ts`), **the WS `snapshot` SELECT (`apps/coder/src/routes/ws.ts`)** — it has its OWN column list and the client's `snapshot` handler `setMessages`-overwrites the HTTP load, so a field present in the HTTP route but absent here shows live yet vanishes on refresh — `CoderPane.tsx` (`RawCoderMessage`/`CoderMessage`/`mapCoderTimelineRow` + the live `message_complete` WS reducer), `CoderMessageWire` (`CoderMessageList.tsx`), and `api/types.ts`. The client `mapCoderTimelineRow` whitelists fields — easiest to forget. This bit `model` twice: the client chain (`v2.7.9`) and then the WS snapshot SELECT (`v2.7.11`) — the chip showed live but vanished on coder refresh until both were fixed. - **A new per-message coder field silently drops unless you update every mapper**: the HTTP read SELECT + `mapCoderMessageRow` (`apps/coder/src/routes/messages.ts`), **the WS `snapshot` SELECT (`apps/coder/src/routes/ws.ts`)** — it has its OWN column list and the client's `snapshot` handler `setMessages`-overwrites the HTTP load, so a field present in the HTTP route but absent here shows live yet vanishes on refresh — `CoderPane.tsx` (`RawCoderMessage`/`CoderMessage`/`mapCoderTimelineRow` + the live `message_complete` WS reducer), `CoderMessageWire` (`CoderMessageList.tsx`), and `api/types.ts`. The client `mapCoderTimelineRow` whitelists fields — easiest to forget. This bit `model` twice: the client chain (`v2.7.9`) and then the WS snapshot SELECT (`v2.7.11`) — the chip showed live but vanished on coder refresh until both were fixed.
## Orchestrator (v2.7.17)
- **In-app multi-agent conductor**: `services/flow-runner.ts` runs a flow by inserting each step as a `tasks` row (the existing dispatcher runs it) and advancing on a new `onTaskTerminal` dispatcher-deps hook; persisted in `flow_runs`/`flow_steps` (resumed at startup via `initResume`). The 22 conductor flow defs + Spine factory are re-homed under `src/conductor/`. Pure scheduler/resume helpers in `flow-runner-decisions.ts`. Full design: `openspec/changes/archived/orchestrator/`.
- **Read-only is load-bearing — don't add a dispatch path that bypasses it.** Every step dispatches `agent='qwen', mode_id='plan'`; `dispatcher.ts` force-routes qwen+plan to the PTY `--approval-mode plan` gate and HARD-FAILS the task (never falls to write-capable native inference) when qwen is unavailable (`shouldFailOnMissingAgent`). `BOOCODE_TOOLS` gates BooChat's NATIVE inference tools only — it does NOT govern an external CLI agent (qwen/opencode bring their own write tools); read-only for a dispatched agent is the agent-layer mode (PTY `--approval-mode plan`; ACP `setSessionMode` is fail-OPEN by default, fail-CLOSED for `plan` via `READ_ONLY_MODE_IDS` in `acp-dispatch.ts`).
## Edit safety guards (v2.8)
- **`services/edit-guards.ts`** — `validateEditResult(original, updated, filePath)` runs in `pending_changes.ts` immediately before `writeFileAtomic`. Rejects catastrophic truncation (>60% char loss AND >50% line loss). Throws a `formatGuardError` message that percolates to the agent as a visible error.
- **`services/edit-guards-imports.ts`** — `checkDroppedImports(original, updated, filePath)` detects removed import/require lines. Called alongside the truncation guard.
- Both guards run on the `/apply` path only (not on queue). Re-queued identical edits re-validate at apply time.
- Guard functions are pure — no DB or filesystem access. Easy to unit-test.

View File

@@ -24,6 +24,7 @@ import {
} from './planning.js'; } from './planning.js';
import { adr, codingStandard, runbook, tdd, stakeholderSummary } from './authoring.js'; import { adr, codingStandard, runbook, tdd, stakeholderSummary } from './authoring.js';
import { codeReview } from './code-review.js'; import { codeReview } from './code-review.js';
import { parallelResearch } from './parallel-research.js';
const spines: Spine[] = [ const spines: Spine[] = [
// analysis / research // analysis / research
@@ -53,7 +54,7 @@ const spines: Spine[] = [
stakeholderSummary, stakeholderSummary,
]; ];
const bespoke: Flow[] = [codeReview]; const bespoke: Flow[] = [codeReview, parallelResearch];
const ALL: Flow[] = [...spines.map(buildSpineFlow), ...bespoke]; const ALL: Flow[] = [...spines.map(buildSpineFlow), ...bespoke];

View File

@@ -0,0 +1,59 @@
import type { Flow, Step, StepContext } from '../types.js';
const q = (ctx: StepContext) => String(ctx.input.question);
/**
* Parallel research flow — dispatches 3 research agents simultaneously,
* then synthesizes the result on the first one to complete.
*/
export const parallelResearch: Flow = {
name: 'parallel-research',
description: 'Research from 3 angles in parallel, synthesize results on first completion',
steps: [
{
id: 'angle-web',
kind: 'agent',
agent: 'research-analyst',
run: (ctx) =>
`Research the following question from a web / prior-art perspective:\n\n${q(ctx)}`,
},
{
id: 'angle-code',
kind: 'agent',
agent: 'codebase-explorer',
deps: [],
run: (ctx) =>
`Research the following question from a codebase analysis perspective:\n\n${q(ctx)}`,
},
{
id: 'angle-security',
kind: 'agent',
agent: 'adversarial-security-analyst',
deps: [],
run: (ctx) =>
`Research the following question from a security perspective:\n\n${q(ctx)}`,
},
{
id: 'synthesize',
kind: 'code',
deps: ['angle-web', 'angle-code', 'angle-security'],
trigger_rule: 'one_success',
run: (ctx) => {
const web = ctx.results['angle-web'];
const code = ctx.results['angle-code'];
const security = ctx.results['angle-security'];
const parts = [
'# Parallel Research Synthesis',
'',
web ? `## Web Angle\n${web}` : '## Web Angle\n*(not yet completed)*',
code ? `## Code Angle\n${code}` : '## Code Angle\n*(not yet completed)*',
security ? `## Security Angle\n${security}` : '## Security Angle\n*(not yet completed)*',
];
return parts.join('\n\n');
},
},
],
render: (ctx) => {
return ctx.results['synthesize'] ?? 'No synthesis produced.';
},
};

View File

@@ -38,7 +38,9 @@ export interface StepContext {
readonly model?: string; readonly model?: string;
} }
export type StepKind = 'agent' | 'code'; export type StepKind = 'agent' | 'code' | 'approval';
export type TriggerRule = 'all_success' | 'one_success' | 'all_done';
export interface Step { export interface Step {
/** unique id within the flow; other steps depend on it by this id */ /** unique id within the flow; other steps depend on it by this id */
@@ -46,6 +48,8 @@ export interface Step {
kind: StepKind; kind: StepKind;
/** ids that must complete (or skip) before this step runs */ /** ids that must complete (or skip) before this step runs */
deps?: string[]; deps?: string[];
/** how dependency satisfaction is evaluated (default: all_success) */
trigger_rule?: TriggerRule;
/** for kind:'agent' — the persona file name under conductor/agents (no .md) */ /** for kind:'agent' — the persona file name under conductor/agents (no .md) */
agent?: string; agent?: string;
/** /**

View File

@@ -13,7 +13,7 @@ import type { WsFrame } from '@boocode/contracts/ws-frames';
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility. // v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
import { WRITE_TOOLS } from './services/tools/index.js'; import { WRITE_TOOLS } from './services/tools/index.js';
import { adaptWriteTool } from './services/tools/adapter.js'; import { adaptWriteTool } from './services/tools/adapter.js';
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js'; import { runWithInferenceContext } from './services/tools/inference_context.js';
// Routes // Routes
import { registerMessageRoutes } from './routes/messages.js'; import { registerMessageRoutes } from './routes/messages.js';
import { registerSkillRoutes } from './routes/skills.js'; import { registerSkillRoutes } from './routes/skills.js';
@@ -23,8 +23,8 @@ import { registerAgentSessionRoutes } from './routes/agent-sessions.js';
import { registerTaskRoutes } from './routes/tasks.js'; import { registerTaskRoutes } from './routes/tasks.js';
import { registerInboxRoutes } from './routes/inbox.js'; import { registerInboxRoutes } from './routes/inbox.js';
import { registerStatsRoutes } from './routes/stats.js'; import { registerStatsRoutes } from './routes/stats.js';
import { registerArenaRoutes } from './routes/arena.js';
import { registerRunsRoutes } from './routes/runs.js'; import { registerRunsRoutes } from './routes/runs.js';
import { registerArenaRoutes } from './routes/arena.js';
import { registerProviderRoutes } from './routes/providers.js'; import { registerProviderRoutes } from './routes/providers.js';
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js'; import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
import { registerLifecycleRoutes } from './routes/lifecycle.js'; import { registerLifecycleRoutes } from './routes/lifecycle.js';
@@ -34,10 +34,13 @@ import { createDispatcher } from './services/dispatcher.js';
// Orchestrator (Phase 2): DB-backed flow-runner; advances on the dispatcher's // Orchestrator (Phase 2): DB-backed flow-runner; advances on the dispatcher's
// onTaskTerminal hook. // onTaskTerminal hook.
import { createFlowRunner } from './services/flow-runner.js'; import { createFlowRunner } from './services/flow-runner.js';
// Arena: DB-backed battle-runner; also advances on the onTaskTerminal hook.
import { createBattleRunner, type DispatchContestantFn } from './services/arena-runner.js';
import { createAnalyzer } from './services/arena-analyzer.js';
import { agentPool } from './services/agent-pool.js'; import { agentPool } from './services/agent-pool.js';
import { createOrphanWorktreeReaper } from './services/orphan-worktree-reaper.js'; import { createOrphanWorktreeReaper } from './services/orphan-worktree-reaper.js';
import { probeAgents } from './services/agent-probe.js'; import { probeAgents } from './services/agent-probe.js';
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js'; import { getProviderSnapshot, persistProbedModels, fetchLlamaSwapModels } from './services/provider-snapshot.js';
import { setPermissionHooks } from './services/permission-waiter.js'; import { setPermissionHooks } from './services/permission-waiter.js';
import { publishAgentStatus } from './services/agent-status-publish.js'; import { publishAgentStatus } from './services/agent-status-publish.js';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
@@ -171,22 +174,27 @@ async function main() {
} }
); );
// Wrap the inference runner to set/clear the write-tool context around each run. // Wrap the inference runner to bind the write-tool context around each run.
// The inference runner calls enqueue() which fires asynchronously — we hook // enqueue() starts its async loop synchronously, so wrapping the call in
// into the enqueue to set context before the run starts. // runWithInferenceContext propagates the per-run context (sql, sessionId, the
// Plan/Ask/Bypass gate) through every awaited tool execution — and concurrent
// runs (a user message racing a dispatcher-polled native task) each get their
// own, instead of clobbering a shared global.
const inferenceApi = { const inferenceApi = {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => { enqueue: (
// Set the inference context so write tools can access sql + sessionId. sessionId: string,
// The context persists for the duration of the inference run. Since chatId: string,
// BooCoder is single-user and runs one inference at a time per session, assistantId: string,
// this module-level state is safe. user: string,
setInferenceContext({ sql, sessionId, taskId: null }); permissionMode?: 'plan' | 'ask' | 'bypass',
inference.enqueue(sessionId, chatId, assistantId, user); ) => {
runWithInferenceContext({ sql, sessionId, taskId: null, permissionMode }, () => {
inference.enqueue(sessionId, chatId, assistantId, user);
});
}, },
cancel: async (sessionId: string, chatId: string) => { cancel: async (sessionId: string, chatId: string) => {
const result = await inference.cancel(sessionId, chatId); // No context to clear — AsyncLocalStorage scopes it to each run's own chain.
clearInferenceContext(); return inference.cancel(sessionId, chatId);
return result;
}, },
hasActive: (chatId: string) => inference.hasActive(chatId), hasActive: (chatId: string) => inference.hasActive(chatId),
}; };
@@ -220,31 +228,119 @@ async function main() {
// Orchestrator (Phase 2): the flow-runner reacts to the dispatcher's // Orchestrator (Phase 2): the flow-runner reacts to the dispatcher's
// onTaskTerminal hook to advance flow_runs. Created before the dispatcher so its // onTaskTerminal hook to advance flow_runs. Created before the dispatcher so its
// terminal callback can be wired in. Its launch() is driven by the runs route // terminal callback can be wired in.
// (a later phase); resume on startup is a later phase too.
const flowRunner = createFlowRunner({ sql, broker, log: app.log, config }); const flowRunner = createFlowRunner({ sql, broker, log: app.log, config });
// Phase 4: dispatcher — polls tasks table and runs inference. onTaskTerminal // Arena SEAM (a): build the local-model set from the live llama-swap model list.
// notifies the flow-runner when a step's task settles (D-2). // Both bare IDs ('qwen3.6-35b') and prefixed IDs ('llama-swap/qwen3.6-35b') are
// included so opencode-style prefixed contestants and native-style bare contestants
// both classify correctly as local.
const localModelsList = await fetchLlamaSwapModels(config).catch(() => []);
const localModels = new Set([
...localModelsList.map((m) => m.id),
...localModelsList.map((m) => `llama-swap/${m.id}`),
]);
// Arena dispatch function — Phase 4 SEAM (b).
// Coding: insert a tasks row with agent=identity (null for native/boocode);
// the dispatcher creates a worktree and runs the external agent (or native).
// Q&A: pre-create a session with agent_id stamped to the persona slug so native
// inference loads the persona's system_prompt + tools from AGENTS.md;
// task.session_id is pre-set so runNativeInference reuses the session.
const dispatchContestant: DispatchContestantFn = async ({
projectId,
prompt,
identity,
model,
battleType,
}) => {
if (battleType === 'qa') {
const sessionName = `Arena Q&A [${identity}]: ${prompt.slice(0, 30)}`;
const [session] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, agent_id, status)
VALUES (${projectId}, ${sessionName}, ${model}, ${identity}, 'open')
RETURNING id
`;
const [task] = await sql<{ id: string }[]>`
INSERT INTO tasks (project_id, input, model, session_id)
VALUES (${projectId}, ${prompt}, ${model}, ${session!.id})
RETURNING id
`;
return { taskId: task!.id, sessionId: session!.id };
}
// Coding: boocode = native inference (no external agent); any other identity
// is an external agent name (claude, opencode, qwen, goose) that maps to
// available_agents and gets its own per-task worktree via runExternalAgent.
// Session is created lazily by the dispatcher, so sessionId is unknown here.
const agentName = identity === 'boocode' ? null : identity;
const [task] = await sql<{ id: string }[]>`
INSERT INTO tasks (project_id, input, agent, model)
VALUES (${projectId}, ${prompt}, ${agentName}, ${model})
RETURNING id
`;
return { taskId: task!.id, sessionId: null };
};
// Arena analyzer: two-stage digest→judge (v1). Pluggable seam — a v2 Han
// Orchestrator flow can replace this without schema changes.
const analyzer = createAnalyzer({
sql,
broker,
log: app.log,
config,
localModels,
});
// Arena battle-runner: notified on the same onTaskTerminal hook as the flow-runner.
const battleRunner = createBattleRunner({
sql,
broker,
log: app.log,
dispatch: dispatchContestant,
onBattleComplete: (battleId) => {
void analyzer.analyze(battleId);
},
onCrossExamStart: ({ battleId, crossExamId, identity, model }) => {
void analyzer.crossExamine(battleId, crossExamId, { identity, model });
},
localModels,
});
// Compose onTaskTerminal: both flow-runner and battle-runner are notified.
// Each ignores tasks it doesn't own (flow-runner checks flow_steps.task_id;
// battle-runner checks contestants.task_id).
const onTaskTerminal = (taskId: string, state: string): void => {
flowRunner.handleTaskTerminal(taskId, state);
battleRunner.handleTaskTerminal(taskId, state);
};
// Phase 4: dispatcher — polls tasks table and runs inference. The composed
// onTaskTerminal hook notifies both the flow-runner and the battle-runner when
// any task settles.
const dispatcher = createDispatcher({ const dispatcher = createDispatcher({
sql, sql,
inference: inferenceApi, inference: inferenceApi,
broker, broker,
log: app.log, log: app.log,
config, config,
onTaskTerminal: flowRunner.handleTaskTerminal, onTaskTerminal,
}); });
dispatcher.start(); dispatcher.start();
// Phase 5: re-advance any flow_runs that were 'running' when the service last // Re-advance in-flight flow_runs and battles after a coder restart. Both run
// stopped (D-9). Runs AFTER dispatcher.start() so re-dispatched 'pending' tasks // AFTER dispatcher.start() so re-dispatched 'pending' tasks are picked up.
// are picked up by the dispatcher's startup poll.
void flowRunner.initResume().catch((err) => { void flowRunner.initResume().catch((err) => {
app.log.error( app.log.error(
{ err: err instanceof Error ? err.message : String(err) }, { err: err instanceof Error ? err.message : String(err) },
'flow-runner: initResume failed', 'flow-runner: initResume failed',
); );
}); });
void battleRunner.initResume().catch((err) => {
app.log.error(
{ err: err instanceof Error ? err.message : String(err) },
'arena: initResume failed',
);
});
// v2.6 Phase 3: configure + start the agent-pool lifecycle sweep (idle-TTL + // v2.6 Phase 3: configure + start the agent-pool lifecycle sweep (idle-TTL +
// LRU-cap eviction of warm backends, plus each backend's proactive health probe) // LRU-cap eviction of warm backends, plus each backend's proactive health probe)
@@ -281,8 +377,8 @@ async function main() {
registerTaskRoutes(app, sql, inferenceApi, dispatcher.cancelExternalTask); registerTaskRoutes(app, sql, inferenceApi, dispatcher.cancelExternalTask);
registerInboxRoutes(app, sql); registerInboxRoutes(app, sql);
registerStatsRoutes(app, sql); registerStatsRoutes(app, sql);
registerArenaRoutes(app, sql);
registerRunsRoutes(app, sql, flowRunner, dispatcher.cancelExternalTask); registerRunsRoutes(app, sql, flowRunner, dispatcher.cancelExternalTask);
registerArenaRoutes(app, sql, battleRunner, dispatcher.cancelExternalTask, config);
registerProviderRoutes(app, sql, config); registerProviderRoutes(app, sql, config);
registerWorktreeSafetyRoutes(app, sql); registerWorktreeSafetyRoutes(app, sql);
registerLifecycleRoutes(app, sql); registerLifecycleRoutes(app, sql);

View File

@@ -0,0 +1,42 @@
export type HookName =
| 'tool.execute.before'
| 'tool.execute.after'
| 'turn.start'
| 'turn.end'
| 'task.terminal';
export interface ToolHookContext {
tool: string;
args: Record<string, unknown>;
projectRoot: string;
sessionId: string;
}
export interface ToolResultContext extends ToolHookContext {
result: unknown;
}
export type PluginHook = (ctx: any) => Promise<any>;
const hooks = new Map<HookName, PluginHook[]>();
export function registerHook(name: HookName, fn: PluginHook): void {
const list = hooks.get(name) || [];
list.push(fn);
hooks.set(name, list);
}
export async function emitHook(name: HookName, ctx: any): Promise<any> {
const list = hooks.get(name);
if (!list) return ctx;
let current = ctx;
for (const fn of list) {
const result = await fn(current);
if (result !== undefined) current = result;
}
return current;
}
export function clearHooks(): void {
hooks.clear();
}

View File

@@ -1,136 +1,412 @@
/** /**
* v2.0.5: Arena routes — competitive dispatch of the same task to multiple agents. * Arena routes — HTTP surface for the Battle UI.
* *
* POST /api/arena — create an arena with 2-5 contestants * POST /api/battles — launch a battle
* GET /api/arena/:id — get all tasks in an arena * GET /api/battles?project_id= — list battles for a project
* POST /api/arena/:id/select/:task_id — mark a task as the arena winner * GET /api/battles/:id — one battle + contestants + cross-exams
* POST /api/battles/:id/stop — cancel a running battle
* POST /api/battles/:id/analyze — trigger analysis (Phase 5 fills the logic)
* POST /api/battles/:id/cross-examine — start a cross-examination (Phase 5 fills the logic)
*
* Mirrors the shape of runs.ts (Orchestrator routes). Battle creation delegates to
* the battle-runner; cancellation calls cancelBattle then aborts in-flight tasks
* via the dispatcher's cancelExternalTask.
*/ */
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { BattleRunner } from '../services/arena-runner.js';
import type { ExternalCancelFn } from './tasks.js';
import { arenaModelCall } from '../services/arena-model-call.js';
const ContestantSchema = z.object({ // ─── Validation schemas ───────────────────────────────────────────────────────
agent: z.string().max(100).optional(),
model: z.string().max(200).optional(), const UuidParam = z.string().uuid();
mode_id: z.string().max(200).optional(),
thinking_option_id: z.string().max(200).optional(), const ContestantInput = z.object({
identity: z.string().min(1).max(200),
model: z.string().min(1).max(200),
}); });
const CreateArenaBody = z.object({ const CreateBattleBody = z.object({
project_id: z.string().uuid(), project_id: z.string().uuid(),
input: z.string().min(1).max(64_000), battle_type: z.enum(['coding', 'qa']),
contestants: z.array(ContestantSchema).min(2).max(5), prompt: z.string().min(1).max(64_000),
contestants: z
.array(ContestantInput)
.min(2, 'at least 2 contestants required')
.max(6, 'at most 6 contestants allowed'),
}); });
interface TaskRow { const ListBattlesQuery = z.object({
id: string; project_id: z.string().uuid(),
agent: string | null; });
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
state: string;
}
export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void { const CrossExamineBody = z.object({
// POST /api/arena — create a new arena identity: z.string().min(1).max(200),
app.post('/api/arena', async (req, reply) => { model: z.string().min(1).max(200),
const parsed = CreateArenaBody.safeParse(req.body); });
const SetWinnerBody = z.object({
winner_contestant_id: z.string().uuid().nullable(),
});
// ─── Route registration ───────────────────────────────────────────────────────
const GeneratePromptBody = z.object({
description: z.string().min(1).max(2_000),
});
export function registerArenaRoutes(
app: FastifyInstance,
sql: Sql,
battleRunner: BattleRunner,
cancelExternal: ExternalCancelFn,
config: Config,
): void {
// POST /api/battles/generate-prompt — draft a fuller battle prompt from a
// short description using the default BooChat model. One-shot, non-streaming.
// Must be registered BEFORE /api/battles/:id so the literal 'generate-prompt'
// path is not mistaken for a UUID param.
app.post('/api/battles/generate-prompt', async (req, reply) => {
const parsed = GeneratePromptBody.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
reply.code(400); reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() }; return { error: 'invalid body', details: parsed.error.flatten() };
} }
const { project_id, input, contestants } = parsed.data; const { description } = parsed.data;
const arenaId = crypto.randomUUID();
const tasks: TaskRow[] = []; try {
for (const contestant of contestants) { const prompt = await arenaModelCall({
const [task] = await sql<TaskRow[]>` config,
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, arena_id) model: config.DEFAULT_MODEL,
VALUES ( system: [
${project_id}, 'You are a battle-prompt writer for an AI Arena.',
${input}, 'The user gives you a short description of a coding or Q&A challenge.',
${contestant.agent ?? null}, 'Expand it into a clear, self-contained prompt (26 sentences) that any AI model can act on.',
${contestant.model ?? null}, 'Include specific acceptance criteria where helpful.',
${contestant.mode_id ?? null}, 'Output ONLY the prompt — no preamble, no labels, no meta-commentary.',
${contestant.thinking_option_id ?? null}, ].join(' '),
${arenaId} user: description,
) maxTokens: 400,
RETURNING id, agent, model, mode_id, thinking_option_id, state temperature: 0.6,
`; });
tasks.push(task!); return { prompt };
} catch (err) {
app.log.warn(
{ err: err instanceof Error ? err.message : String(err) },
'arena generate-prompt: model call failed',
);
reply.code(502);
return { error: 'model call failed' };
} }
});
// POST /api/battles — launch a battle
app.post('/api/battles', async (req, reply) => {
const parsed = CreateBattleBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { project_id, battle_type, prompt, contestants } = parsed.data;
// Reject duplicate (identity, model) pairs up front — the schema UNIQUE
// constraint would catch it too, but an early 422 is friendlier.
const seen = new Set<string>();
for (const c of contestants) {
const key = `${c.identity}::${c.model}`;
if (seen.has(key)) {
reply.code(422);
return {
error: 'duplicate_contestant',
message: `duplicate contestant: identity="${c.identity}" model="${c.model}"`,
};
}
seen.add(key);
}
// Verify project exists
const [proj] = await sql<{ id: string }[]>`SELECT id FROM projects WHERE id = ${project_id}`;
if (!proj) {
reply.code(404);
return { error: 'project not found' };
}
const { battleId } = await battleRunner.startBattle({
projectId: project_id,
battleType: battle_type,
prompt,
contestants,
});
reply.code(201); reply.code(201);
return { return { battle_id: battleId };
arena_id: arenaId,
tasks: tasks.map((t) => ({
id: t.id,
agent: t.agent,
model: t.model,
mode_id: t.mode_id,
thinking_option_id: t.thinking_option_id,
state: t.state,
})),
};
}); });
// GET /api/arena/:arena_id — list all tasks in an arena // GET /api/battles?project_id= — list battles, most-recent-first
app.get<{ Params: { arena_id: string } }>('/api/arena/:arena_id', async (req, reply) => { app.get('/api/battles', async (req, reply) => {
const { arena_id } = req.params; const parsed = ListBattlesQuery.safeParse(req.query);
if (!parsed.success) {
// Validate UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(arena_id)) {
reply.code(400); reply.code(400);
return { error: 'invalid arena_id format' }; return { error: 'invalid query', details: parsed.error.flatten() };
} }
const tasks = await sql` const battles = await sql`
SELECT id, project_id, state, input, output_summary, agent, model, mode_id, thinking_option_id, execution_path, session_id, started_at, ended_at, created_at, arena_id SELECT id, project_id, battle_type, prompt, status,
FROM tasks winner_contestant_id, results_path, error,
WHERE arena_id = ${arena_id} created_at, updated_at
ORDER BY created_at FROM battles
WHERE project_id = ${parsed.data.project_id}
ORDER BY created_at DESC
LIMIT 100
`; `;
if (tasks.length === 0) { return { battles };
reply.code(404);
return { error: 'arena not found' };
}
return { arena_id, tasks };
}); });
// POST /api/arena/:arena_id/select/:task_id — mark the winner // GET /api/battles/:id — one battle + its contestants + cross-examinations
app.post<{ Params: { arena_id: string; task_id: string } }>( app.get<{ Params: { id: string } }>('/api/battles/:id', async (req, reply) => {
'/api/arena/:arena_id/select/:task_id', const parsedId = UuidParam.safeParse(req.params.id);
async (req, reply) => { if (!parsedId.success) {
const { arena_id, task_id } = req.params; reply.code(400);
return { error: 'invalid id' };
// Verify the task belongs to this arena
const rows = await sql<{ id: string; state: string; arena_id: string | null }[]>`
SELECT id, state, arena_id FROM tasks WHERE id = ${task_id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'task not found' };
}
const task = rows[0]!;
if (task.arena_id !== arena_id) {
reply.code(409);
return { error: 'task does not belong to this arena' };
}
// Mark as selected via output_summary prefix (lightweight — no schema change)
await sql`
UPDATE tasks
SET output_summary = COALESCE('[SELECTED] ' || output_summary, '[SELECTED]')
WHERE id = ${task_id}
`;
return { selected: true, task_id, arena_id };
} }
); const id = parsedId.data;
const [battle] = await sql<{
id: string;
project_id: string;
battle_type: string;
prompt: string;
status: string;
winner_contestant_id: string | null;
results_path: string | null;
error: string | null;
created_at: unknown;
updated_at: unknown;
}[]>`
SELECT id, project_id, battle_type, prompt, status,
winner_contestant_id, results_path, error,
created_at, updated_at
FROM battles WHERE id = ${id}
`;
if (!battle) {
reply.code(404);
return { error: 'battle not found' };
}
const contestants = await sql`
SELECT id, battle_id, identity, model, lane, task_id, worktree_id,
status, duration_ms, tokens_per_sec, cost_tokens, token_breakdown, result_path, error,
created_at, updated_at
FROM contestants
WHERE battle_id = ${id}
ORDER BY created_at ASC
`;
const crossExaminations = await sql`
SELECT id, battle_id, identity, model, verdict, created_at
FROM cross_examinations
WHERE battle_id = ${id}
ORDER BY created_at ASC
`;
return { battle, contestants, cross_examinations: crossExaminations };
});
// POST /api/battles/:id/stop — cancel a running battle
app.post<{ Params: { id: string } }>('/api/battles/:id/stop', async (req, reply) => {
const parsedId = UuidParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const id = parsedId.data;
const [row] = await sql<{ id: string; status: string }[]>`
SELECT id, status FROM battles WHERE id = ${id}
`;
if (!row) {
reply.code(404);
return { error: 'battle not found' };
}
if (row.status !== 'running') {
reply.code(409);
return { error: `cannot stop battle in status '${row.status}'` };
}
const { cancelled, taskIds } = await battleRunner.cancelBattle(id);
if (!cancelled) {
reply.code(409);
return { error: 'battle is no longer running' };
}
// Abort any in-flight dispatcher tasks (cloud contestants running externally).
for (const taskId of taskIds) {
cancelExternal(taskId);
}
return { cancelled: true };
});
// GET /api/battles/:id/analysis — read analysis.md from the battle's results_path
app.get<{ Params: { id: string } }>('/api/battles/:id/analysis', async (req, reply) => {
const parsedId = UuidParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const id = parsedId.data;
const [row] = await sql<{ results_path: string | null }[]>`
SELECT results_path FROM battles WHERE id = ${id}
`;
if (!row) {
reply.code(404);
return { error: 'battle not found' };
}
if (!row.results_path) {
reply.code(404);
return { error: 'analysis not ready' };
}
try {
const text = await readFile(join(row.results_path, 'analysis.md'), 'utf8');
return { text };
} catch {
reply.code(404);
return { error: 'analysis not ready' };
}
});
// POST /api/battles/:id/analyze — trigger or re-trigger analysis
app.post<{ Params: { id: string } }>('/api/battles/:id/analyze', async (req, reply) => {
const parsedId = UuidParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const id = parsedId.data;
const [row] = await sql<{ id: string; status: string }[]>`
SELECT id, status FROM battles WHERE id = ${id}
`;
if (!row) {
reply.code(404);
return { error: 'battle not found' };
}
if (row.status === 'running') {
reply.code(409);
return { error: 'battle is still running — wait for all contestants to finish' };
}
const result = await battleRunner.triggerAnalysis(id);
if (!result.triggered) {
reply.code(404);
return { error: 'battle not found' };
}
reply.code(202);
return { triggered: true };
});
// PATCH /api/battles/:id/winner — manually set or clear the winner.
// Validates the contestant belongs to the battle; publishes battle_updated so
// the pane badge reflects the override immediately. Human is authoritative.
app.patch<{ Params: { id: string } }>('/api/battles/:id/winner', async (req, reply) => {
const parsedId = UuidParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const parsed = SetWinnerBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const result = await battleRunner.setWinner(parsedId.data, parsed.data.winner_contestant_id);
if (!result.ok) {
if (result.notFound) { reply.code(404); return { error: 'battle not found' }; }
if (result.invalidContestant) { reply.code(422); return { error: 'contestant not found in this battle' }; }
reply.code(500); return { error: 'unknown error' };
}
return { ok: true };
});
// GET /api/battles/:id/contestants/:cid/diff — read the diff.patch for a coding contestant.
app.get<{ Params: { id: string; cid: string } }>('/api/battles/:id/contestants/:cid/diff', async (req, reply) => {
const parsedId = UuidParam.safeParse(req.params.id);
const parsedCid = UuidParam.safeParse(req.params.cid);
if (!parsedId.success || !parsedCid.success) {
reply.code(400);
return { error: 'invalid id' };
}
const [contestant] = await sql<{ result_path: string | null }[]>`
SELECT result_path FROM contestants
WHERE id = ${parsedCid.data} AND battle_id = ${parsedId.data}
`;
if (!contestant) {
reply.code(404);
return { error: 'contestant not found' };
}
if (!contestant.result_path) {
reply.code(404);
return { error: 'diff not available' };
}
try {
const text = await readFile(join(contestant.result_path, 'diff.patch'), 'utf8');
return { diff: text };
} catch {
reply.code(404);
return { error: 'diff not available' };
}
});
// POST /api/battles/:id/cross-examine — start a cross-examination
app.post<{ Params: { id: string } }>('/api/battles/:id/cross-examine', async (req, reply) => {
const parsedId = UuidParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const id = parsedId.data;
const parsed = CrossExamineBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const [row] = await sql<{ id: string; status: string }[]>`
SELECT id, status FROM battles WHERE id = ${id}
`;
if (!row) {
reply.code(404);
return { error: 'battle not found' };
}
if (row.status === 'running') {
reply.code(409);
return { error: 'battle is still running — cross-examine after all contestants finish' };
}
const { crossExamId } = await battleRunner.startCrossExam(id, {
identity: parsed.data.identity,
model: parsed.data.model,
});
reply.code(202);
return { cross_exam_id: crossExamId };
});
} }

View File

@@ -4,6 +4,7 @@ import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker'; import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames'; import type { WsFrame } from '@boocode/contracts/ws-frames';
import { resolveChatId } from './chat-resolve.js'; import { resolveChatId } from './chat-resolve.js';
import { asPermissionMode } from '../services/tools/types.js';
const AnswerUserInputBody = z.object({ const AnswerUserInputBody = z.object({
tool_call_id: z.string().min(1), tool_call_id: z.string().min(1),
@@ -43,7 +44,13 @@ const SendBody = z.object({
}); });
interface InferenceApi { interface InferenceApi {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void; enqueue: (
sessionId: string,
chatId: string,
assistantId: string,
user: string,
permissionMode?: 'plan' | 'ask' | 'bypass',
) => void;
cancel: (sessionId: string, chatId: string) => Promise<boolean>; cancel: (sessionId: string, chatId: string) => Promise<boolean>;
hasActive: (chatId: string) => boolean; hasActive: (chatId: string) => boolean;
} }
@@ -245,7 +252,16 @@ export function registerMessageRoutes(
RETURNING id RETURNING id
`; `;
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default'); // Native BooCode permission gate (plan/ask/bypass) — threaded into the
// write-tool context so create/edit/delete and apply_pending honor it.
// Plan = read-only, Ask = stage to the queue (agent can't self-apply),
// Bypass = apply each write immediately. Other mode ids (e.g. an external
// fallback's native mode) leave the gate undefined = legacy behavior.
req.log.info(
{ provider, mode_id, permissionMode: asPermissionMode(mode_id), chatId },
'native enqueue — permission gate',
);
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default', asPermissionMode(mode_id));
reply.code(202); reply.code(202);
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id }; return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };

View File

@@ -54,9 +54,6 @@ DO $$ BEGIN
END IF; END IF;
END $$; END $$;
-- v2.0.5: arena support — group tasks into competitive arenas.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS arena_id UUID;
-- Human inbox: tasks needing attention -- Human inbox: tasks needing attention
CREATE OR REPLACE VIEW human_inbox AS CREATE OR REPLACE VIEW human_inbox AS
SELECT * FROM tasks WHERE state IN ('blocked', 'failed'); SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
@@ -81,6 +78,7 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
DROP VIEW IF EXISTS human_inbox; DROP VIEW IF EXISTS human_inbox;
ALTER TABLE tasks DROP COLUMN IF EXISTS feature_values; ALTER TABLE tasks DROP COLUMN IF EXISTS feature_values;
ALTER TABLE tasks DROP COLUMN IF EXISTS worktree_path; ALTER TABLE tasks DROP COLUMN IF EXISTS worktree_path;
ALTER TABLE tasks DROP COLUMN IF EXISTS arena_id;
CREATE OR REPLACE VIEW human_inbox AS CREATE OR REPLACE VIEW human_inbox AS
SELECT * FROM tasks WHERE state IN ('blocked', 'failed'); SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
@@ -157,7 +155,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS worktrees_active_path_uidx ON worktrees(path)
DROP TABLE IF EXISTS session_worktrees; DROP TABLE IF EXISTS session_worktrees;
-- Dispatch hint: which chat (tab) a task belongs to. The coder message route and -- Dispatch hint: which chat (tab) a task belongs to. The coder message route and
-- skills route set it from the frontend tab; session-less creators (arena, MCP, -- skills route set it from the frontend tab; session-less creators (MCP,
-- new_task, generic /api/tasks) leave it NULL and the dispatcher creates a chat. -- new_task, generic /api/tasks) leave it NULL and the dispatcher creates a chat.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE SET NULL; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE SET NULL;
@@ -271,7 +269,7 @@ ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_backend_chk
CHECK (backend IN ('opencode_server', 'acp_warm', 'claude_sdk')); CHECK (backend IN ('opencode_server', 'acp_warm', 'claude_sdk'));
-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes, -- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
-- new_task tool, arena, MCP server) fires pg_notify('tasks_new') in the same -- new_task tool, MCP server) fires pg_notify('tasks_new') in the same
-- transaction, so the dispatcher reacts immediately instead of waiting for the -- transaction, so the dispatcher reacts immediately instead of waiting for the
-- fallback poll. Postgres holds the notification until COMMIT, so the listener -- fallback poll. Postgres holds the notification until COMMIT, so the listener
-- always sees the committed row. A trigger covers all insert paths with no -- always sees the committed row. A trigger covers all insert paths with no
@@ -357,3 +355,86 @@ DO $$ BEGIN
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped', 'cancelled')); CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped', 'cancelled'));
END IF; END IF;
END $$; END $$;
-- Arena: battles + contestants + cross_examinations.
-- project_id carries no FK (matches tasks.project_id + flow_runs.project_id convention).
-- winner_contestant_id FK is deferred (forward reference): added via guarded ALTER below.
CREATE TABLE IF NOT EXISTS battles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL,
battle_type TEXT NOT NULL,
prompt TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
winner_contestant_id UUID,
results_path TEXT,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT battles_type_chk CHECK (battle_type IN ('coding', 'qa')),
CONSTRAINT battles_status_chk CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled'))
);
CREATE TABLE IF NOT EXISTS contestants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
battle_id UUID NOT NULL REFERENCES battles(id) ON DELETE CASCADE,
identity TEXT NOT NULL,
model TEXT NOT NULL,
lane TEXT NOT NULL,
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
worktree_id UUID REFERENCES worktrees(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'queued',
duration_ms INTEGER,
tokens_per_sec DOUBLE PRECISION,
cost_tokens INTEGER,
result_path TEXT,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT contestants_lane_chk CHECK (lane IN ('local', 'cloud')),
CONSTRAINT contestants_status_chk CHECK (status IN ('queued', 'running', 'done', 'error')),
UNIQUE (battle_id, identity, model)
);
CREATE TABLE IF NOT EXISTS cross_examinations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
battle_id UUID NOT NULL REFERENCES battles(id) ON DELETE CASCADE,
identity TEXT NOT NULL,
model TEXT NOT NULL,
verdict TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
-- Add the winner FK now that contestants exists.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'battles_winner_contestant_id_fkey') THEN
ALTER TABLE battles ADD CONSTRAINT battles_winner_contestant_id_fkey
FOREIGN KEY (winner_contestant_id) REFERENCES contestants(id) ON DELETE SET NULL;
END IF;
END $$;
-- battles query (GET /api/battles?project_id=).
CREATE INDEX IF NOT EXISTS battles_project_created_idx ON battles(project_id, created_at DESC);
-- Lane-scheduler advance scans (contestants WHERE battle_id = ? AND status = ?).
CREATE INDEX IF NOT EXISTS contestants_battle_status_idx ON contestants(battle_id, status);
-- onTaskTerminal callback: look up the contestant owning a completed task.
CREATE INDEX IF NOT EXISTS contestants_task_id_idx ON contestants(task_id);
-- Cross-examination listing per battle.
CREATE INDEX IF NOT EXISTS cross_examinations_battle_idx ON cross_examinations(battle_id);
-- TokenScope: per-category token breakdown on arena contestants and tasks.
ALTER TABLE contestants ADD COLUMN IF NOT EXISTS token_breakdown JSONB;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS token_breakdown JSONB;
-- Orchestrator flow step events (append-only event log for resume/replay).
CREATE TABLE IF NOT EXISTS flow_step_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_id UUID NOT NULL REFERENCES flow_runs(id),
step_id VARCHAR(64) NOT NULL,
event VARCHAR(32) NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS flow_step_events_run_idx ON flow_step_events(run_id);

View File

@@ -0,0 +1,254 @@
import { describe, it, expect } from 'vitest';
import {
buildDigestPrompt,
buildJudgePrompt,
buildCrossExamPrompt,
extractWinner,
shouldNameWinner,
type ContestantDigest,
type ContestantDigestInput,
} from '../arena-analyzer-helpers.js';
// ─── shouldNameWinner ─────────────────────────────────────────────────────────
describe('shouldNameWinner', () => {
it('returns false with 0 succeeded contestants', () => {
expect(shouldNameWinner(0)).toBe(false);
});
it('returns false with exactly 1 succeeded contestant', () => {
expect(shouldNameWinner(1)).toBe(false);
});
it('returns true with exactly 2 succeeded contestants', () => {
expect(shouldNameWinner(2)).toBe(true);
});
it('returns true with more than 2 succeeded contestants', () => {
expect(shouldNameWinner(3)).toBe(true);
expect(shouldNameWinner(6)).toBe(true);
});
});
// ─── extractWinner ────────────────────────────────────────────────────────────
describe('extractWinner', () => {
it('extracts identity and model from a WINNER: line', () => {
const output = 'Some analysis\n\nWINNER: claude/opus-4-5\n\nMore text.';
expect(extractWinner(output)).toEqual({ identity: 'claude', model: 'opus-4-5' });
});
it('is case-insensitive for the WINNER keyword', () => {
expect(extractWinner('winner: boocode/qwen3.6-35b')).toEqual({
identity: 'boocode',
model: 'qwen3.6-35b',
});
expect(extractWinner('Winner: opencode/some-model')).toEqual({
identity: 'opencode',
model: 'some-model',
});
});
it('returns null when NO_WINNER is declared', () => {
expect(extractWinner('WINNER: NO_WINNER')).toBeNull();
expect(extractWinner('winner: no_winner')).toBeNull();
});
it('returns null when no WINNER line is present', () => {
expect(extractWinner('Just some analysis text with no verdict.')).toBeNull();
expect(extractWinner('')).toBeNull();
});
it('returns null when the WINNER line has no slash separator', () => {
expect(extractWinner('WINNER: justidentity')).toBeNull();
});
it('returns null when the WINNER line is empty after the colon', () => {
expect(extractWinner('WINNER:')).toBeNull();
expect(extractWinner('WINNER: ')).toBeNull();
});
it('handles leading and trailing whitespace around the slash parts', () => {
const result = extractWinner('WINNER: claude / opus-4-5 ');
expect(result).toEqual({ identity: 'claude', model: 'opus-4-5' });
});
it('picks the first WINNER line when multiple are present', () => {
const output = 'WINNER: claude/opus-4-5\nWINNER: opencode/other-model';
expect(extractWinner(output)).toEqual({ identity: 'claude', model: 'opus-4-5' });
});
it('handles model names that contain slashes by splitting at the first slash only', () => {
// edge case: model name with a slash — should still split at first slash
// identity = 'native', model = 'llama-swap/qwen3.6'
const result = extractWinner('WINNER: native/llama-swap/qwen3.6');
expect(result).toEqual({ identity: 'native', model: 'llama-swap/qwen3.6' });
});
});
// ─── buildDigestPrompt ────────────────────────────────────────────────────────
describe('buildDigestPrompt', () => {
const base: ContestantDigestInput = {
identity: 'claude',
model: 'opus-4-5',
resultMd: '# Output\n\nSome result content.',
benchmarkLine: '12000ms',
};
it('returns an object with non-empty system and user strings', () => {
const { system, user } = buildDigestPrompt(base);
expect(system.length).toBeGreaterThan(0);
expect(user.length).toBeGreaterThan(0);
});
it('includes the contestant identity and model in the user prompt', () => {
const { user } = buildDigestPrompt(base);
expect(user).toContain('claude');
expect(user).toContain('opus-4-5');
});
it('includes the benchmark line in the user prompt', () => {
const { user } = buildDigestPrompt(base);
expect(user).toContain('12000ms');
});
it('includes the result.md content in the user prompt', () => {
const { user } = buildDigestPrompt(base);
expect(user).toContain('Some result content.');
});
it('includes the diff.patch when provided', () => {
const input: ContestantDigestInput = { ...base, diffPatch: '--- a/foo.ts\n+++ b/foo.ts\n+added' };
const { user } = buildDigestPrompt(input);
expect(user).toContain('added');
expect(user).toContain('```diff');
});
it('omits the diff section when diffPatch is undefined', () => {
const { user } = buildDigestPrompt(base);
expect(user).not.toContain('```diff');
});
it('truncates resultMd longer than 8000 characters', () => {
const longResult = 'x'.repeat(10_000);
const { user } = buildDigestPrompt({ ...base, resultMd: longResult });
// The truncated content must not exceed 8000 chars in the sliced section.
// We just check the total user string doesn't balloon unreasonably.
expect(user.length).toBeLessThan(15_000);
});
it('truncates diffPatch longer than 5000 characters', () => {
const longDiff = '+' + 'x'.repeat(10_000);
const { user } = buildDigestPrompt({ ...base, diffPatch: longDiff });
expect(user.length).toBeLessThan(16_000);
});
});
// ─── buildJudgePrompt ─────────────────────────────────────────────────────────
describe('buildJudgePrompt', () => {
const digests: ContestantDigest[] = [
{ identity: 'claude', model: 'opus-4-5', digest: 'Good result.', benchmarkLine: '5000ms' },
{ identity: 'opencode', model: 'qwen3.6', digest: 'Decent result.', benchmarkLine: '8000ms' },
];
it('includes the original prompt in the user section', () => {
const { user } = buildJudgePrompt('Write a sorting algorithm', digests);
expect(user).toContain('Write a sorting algorithm');
});
it('includes each contestant heading in the user section', () => {
const { user } = buildJudgePrompt('prompt', digests);
expect(user).toContain('claude');
expect(user).toContain('opus-4-5');
expect(user).toContain('opencode');
expect(user).toContain('qwen3.6');
});
it('includes each contestant digest text', () => {
const { user } = buildJudgePrompt('prompt', digests);
expect(user).toContain('Good result.');
expect(user).toContain('Decent result.');
});
it('instructs the model to name a WINNER when 2+ digests are provided', () => {
const { system } = buildJudgePrompt('prompt', digests);
expect(system).toContain('WINNER:');
});
it('instructs the model NOT to name a winner when fewer than 2 digests are provided', () => {
const oneDigest = digests.slice(0, 1);
const { system } = buildJudgePrompt('prompt', oneDigest);
expect(system).toContain('NO_WINNER');
expect(system).not.toContain('WINNER: <identity>');
});
it('instructs NO_WINNER when digests list is empty', () => {
const { system } = buildJudgePrompt('prompt', []);
expect(system).toContain('NO_WINNER');
});
it('truncates originalPrompt longer than 2000 characters', () => {
const longPrompt = 'p'.repeat(5_000);
const { user } = buildJudgePrompt(longPrompt, digests);
// Should not contain more than 2000 chars of the prompt.
const promptSection = user.split('# Contestant Digests')[0] ?? '';
expect(promptSection.length).toBeLessThan(3_000);
});
});
// ─── buildCrossExamPrompt ─────────────────────────────────────────────────────
describe('buildCrossExamPrompt', () => {
const digests: ContestantDigest[] = [
{ identity: 'claude', model: 'opus-4-5', digest: 'Strong result.', benchmarkLine: '5000ms' },
{ identity: 'boocode', model: 'qwen3.6-35b', digest: 'Decent result.', benchmarkLine: '12000ms' },
];
const baseOpts = {
originalPrompt: 'Write a sorting algorithm.',
digests,
analysisContent: '# Arena Analysis\n\nClaude did better.\n\nWINNER: claude/opus-4-5',
proposedWinner: 'claude/opus-4-5',
examinerIdentity: 'goose',
examinerModel: 'gpt-4o',
};
it('includes the examiner identity and model in the system prompt', () => {
const { system } = buildCrossExamPrompt(baseOpts);
expect(system).toContain('goose');
expect(system).toContain('gpt-4o');
});
it('includes the original prompt in the user section', () => {
const { user } = buildCrossExamPrompt(baseOpts);
expect(user).toContain('Write a sorting algorithm.');
});
it('includes each contestant digest', () => {
const { user } = buildCrossExamPrompt(baseOpts);
expect(user).toContain('Strong result.');
expect(user).toContain('Decent result.');
});
it('includes the proposed analysis content', () => {
const { user } = buildCrossExamPrompt(baseOpts);
expect(user).toContain('Claude did better.');
});
it('includes the proposed winner when set', () => {
const { user } = buildCrossExamPrompt(baseOpts);
expect(user).toContain('claude/opus-4-5');
});
it('notes that no winner was proposed when proposedWinner is null', () => {
const { user } = buildCrossExamPrompt({ ...baseOpts, proposedWinner: null });
expect(user).toContain('No winner was proposed');
});
it('instructs the examiner to provide a VERDICT line', () => {
const { system } = buildCrossExamPrompt(baseOpts);
expect(system).toContain('VERDICT:');
});
});

View File

@@ -0,0 +1,350 @@
import { describe, it, expect } from 'vitest';
import {
classifyLane,
nextLocalContestant,
isBattleComplete,
computeBenchmark,
sanitizeSlug,
buildBattleSlug,
buildContestantDir,
reconcileContestantResume,
reconcileContestants,
type ContestantSlot,
} from '../arena-decisions.js';
// Local models = what the llama-swap server actually serves.
const LOCAL_MODELS: ReadonlySet<string> = new Set([
'qwen3.6-35b-a3b-mxfp4',
'qwen2.5-coder-7b',
]);
// ─── classifyLane ────────────────────────────────────────────────────────────
describe('classifyLane', () => {
it('classifies qa battles as local regardless of identity or model', () => {
expect(classifyLane('qa', 'boocode', 'qwen3.6-35b-a3b-mxfp4', LOCAL_MODELS)).toBe('local');
expect(classifyLane('qa', 'claude', 'claude-opus-4-5', LOCAL_MODELS)).toBe('local');
expect(classifyLane('qa', 'Debugger', 'cloud-model', new Set())).toBe('local');
expect(classifyLane('qa', 'opencode', 'any-model', LOCAL_MODELS)).toBe('local');
});
it('classifies coding contestants as local when model is in localModels', () => {
expect(classifyLane('coding', 'boocode', 'qwen3.6-35b-a3b-mxfp4', LOCAL_MODELS)).toBe('local');
expect(classifyLane('coding', 'opencode', 'qwen3.6-35b-a3b-mxfp4', LOCAL_MODELS)).toBe('local');
expect(classifyLane('coding', 'qwen', 'qwen2.5-coder-7b', LOCAL_MODELS)).toBe('local');
});
it('classifies coding contestants as cloud when model is not in localModels', () => {
expect(classifyLane('coding', 'claude', 'claude-opus-4-5', LOCAL_MODELS)).toBe('cloud');
expect(classifyLane('coding', 'opencode', 'claude-opus-4-5', LOCAL_MODELS)).toBe('cloud');
expect(classifyLane('coding', 'goose', 'gpt-4o', LOCAL_MODELS)).toBe('cloud');
expect(classifyLane('coding', 'qwen', 'unknown-remote-model', LOCAL_MODELS)).toBe('cloud');
});
it('uses the injected localModels set, not a hardcoded list', () => {
const custom = new Set(['my-local-model']);
expect(classifyLane('coding', 'any-agent', 'my-local-model', custom)).toBe('local');
expect(classifyLane('coding', 'boocode', 'other-model', custom)).toBe('cloud');
});
it('defaults to cloud for an empty localModels set', () => {
expect(classifyLane('coding', 'boocode', 'qwen3.6-35b-a3b-mxfp4', new Set())).toBe('cloud');
expect(classifyLane('coding', 'native', 'any-local-model', new Set())).toBe('cloud');
});
});
// ─── nextLocalContestant ─────────────────────────────────────────────────────
describe('nextLocalContestant', () => {
it('returns null for an empty list', () => {
expect(nextLocalContestant([])).toBeNull();
});
it('returns null when no local contestants are queued', () => {
const slots: ContestantSlot[] = [
{ id: 'c1', lane: 'local', status: 'running' },
{ id: 'c2', lane: 'cloud', status: 'queued' },
];
expect(nextLocalContestant(slots)).toBeNull();
});
it('returns the first queued local contestant in order', () => {
const slots: ContestantSlot[] = [
{ id: 'c1', lane: 'local', status: 'done' },
{ id: 'c2', lane: 'local', status: 'queued' },
{ id: 'c3', lane: 'local', status: 'queued' },
];
expect(nextLocalContestant(slots)).toBe('c2');
});
it('skips done/error local contestants and cloud contestants', () => {
const slots: ContestantSlot[] = [
{ id: 'c1', lane: 'cloud', status: 'queued' },
{ id: 'c2', lane: 'local', status: 'error' },
{ id: 'c3', lane: 'local', status: 'queued' },
];
expect(nextLocalContestant(slots)).toBe('c3');
});
it('returns null when all local contestants are done or error', () => {
const slots: ContestantSlot[] = [
{ id: 'c1', lane: 'local', status: 'done' },
{ id: 'c2', lane: 'local', status: 'error' },
];
expect(nextLocalContestant(slots)).toBeNull();
});
});
// ─── isBattleComplete ────────────────────────────────────────────────────────
describe('isBattleComplete', () => {
it('returns false for an empty list', () => {
expect(isBattleComplete([])).toBe(false);
});
it('returns true when all contestants are done', () => {
expect(isBattleComplete([{ status: 'done' }, { status: 'done' }])).toBe(true);
});
it('returns true when all contestants are error', () => {
expect(isBattleComplete([{ status: 'error' }, { status: 'error' }])).toBe(true);
});
it('returns true for a mixed done/error result', () => {
expect(isBattleComplete([{ status: 'done' }, { status: 'error' }, { status: 'done' }])).toBe(true);
});
it('returns false while any contestant is still running', () => {
expect(isBattleComplete([{ status: 'done' }, { status: 'running' }])).toBe(false);
});
it('returns false while any contestant is still queued', () => {
expect(isBattleComplete([{ status: 'done' }, { status: 'queued' }])).toBe(false);
});
});
// ─── computeBenchmark ────────────────────────────────────────────────────────
describe('computeBenchmark', () => {
const t0 = new Date('2026-06-06T10:00:00.000Z');
const t1 = new Date('2026-06-06T10:00:05.000Z'); // +5 000ms
it('computes duration in ms for both lanes', () => {
const local = computeBenchmark(t0, t1, 100, 'local');
expect(local.durationMs).toBe(5000);
const cloud = computeBenchmark(t0, t1, null, 'cloud');
expect(cloud.durationMs).toBe(5000);
});
it('computes tokens/sec for local lane when costTokens is known', () => {
const bench = computeBenchmark(t0, t1, 500, 'local');
expect(bench.tokensPerSec).toBeCloseTo(100, 5); // 500 / 5 = 100 tok/s
});
it('omits tokens/sec for cloud lane regardless of costTokens', () => {
const bench = computeBenchmark(t0, t1, 500, 'cloud');
expect(bench.tokensPerSec).toBeNull();
});
it('omits tokens/sec for local lane when costTokens is null', () => {
const bench = computeBenchmark(t0, t1, null, 'local');
expect(bench.tokensPerSec).toBeNull();
});
it('returns durationMs = 0 and null tokensPerSec when timestamps are equal', () => {
const bench = computeBenchmark(t0, t0, 100, 'local');
expect(bench.durationMs).toBe(0);
expect(bench.tokensPerSec).toBeNull();
});
it('clamps negative duration to 0 (clock skew)', () => {
const bench = computeBenchmark(t1, t0, 50, 'local');
expect(bench.durationMs).toBe(0);
expect(bench.tokensPerSec).toBeNull();
});
it('includes token breakdown when provided', () => {
const breakdown = {
system: 10,
user: 20,
assistant: 30,
tools: 40,
reasoning: 5,
total: 105,
};
const bench = computeBenchmark(t0, t1, 500, 'local', breakdown);
expect(bench.tokenBreakdown).toEqual(breakdown);
});
it('defaults token breakdown to null when omitted', () => {
const bench = computeBenchmark(t0, t1, 500, 'local');
expect(bench.tokenBreakdown).toBeNull();
});
});
// ─── sanitizeSlug ────────────────────────────────────────────────────────────
describe('sanitizeSlug', () => {
it('lowercases and preserves alphanumeric + hyphens', () => {
expect(sanitizeSlug('claude')).toBe('claude');
expect(sanitizeSlug('claude-opus-4-5')).toBe('claude-opus-4-5');
});
it('replaces spaces and special characters with hyphens', () => {
expect(sanitizeSlug('Code Reviewer')).toBe('code-reviewer');
expect(sanitizeSlug('native/boocode')).toBe('native-boocode');
expect(sanitizeSlug('qwen2.5-coder-35b')).toBe('qwen2-5-coder-35b');
});
it('collapses consecutive non-alphanumeric runs to a single hyphen', () => {
expect(sanitizeSlug('foo bar---baz')).toBe('foo-bar-baz');
});
it('strips leading and trailing hyphens', () => {
expect(sanitizeSlug('---foo---')).toBe('foo');
});
it('truncates to 64 characters', () => {
const long = 'a'.repeat(100);
expect(sanitizeSlug(long).length).toBe(64);
});
});
// ─── buildBattleSlug ─────────────────────────────────────────────────────────
describe('buildBattleSlug', () => {
it('builds a deterministic dated slug from id, type, and createdAt', () => {
const id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const createdAt = new Date('2026-06-06T12:00:00.000Z');
const slug = buildBattleSlug(id, 'coding', createdAt);
expect(slug).toBe('2026-06-06-coding-a1b2c3d4');
});
it('includes the battle type in the slug', () => {
const id = 'aaaaaaaa-0000-0000-0000-000000000000';
const createdAt = new Date('2026-01-01T00:00:00.000Z');
expect(buildBattleSlug(id, 'qa', createdAt)).toContain('-qa-');
expect(buildBattleSlug(id, 'coding', createdAt)).toContain('-coding-');
});
it('uses the first 8 hex chars of the uuid (dashes stripped)', () => {
const id = 'deadbeef-0000-0000-0000-000000000000';
const slug = buildBattleSlug(id, 'coding', new Date('2026-06-06T00:00:00Z'));
expect(slug.endsWith('-deadbeef')).toBe(true);
});
});
// ─── buildContestantDir ──────────────────────────────────────────────────────
describe('buildContestantDir', () => {
it('joins sanitized identity and model with a hyphen', () => {
expect(buildContestantDir('claude', 'claude-opus-4-5')).toBe('claude-claude-opus-4-5');
});
it('sanitizes both parts independently', () => {
expect(buildContestantDir('Code Reviewer', 'qwen2.5-35b')).toBe('code-reviewer-qwen2-5-35b');
});
});
// ─── reconcileContestantResume ───────────────────────────────────────────────
describe('reconcileContestantResume', () => {
it('keeps non-running contestants regardless of task state', () => {
for (const status of ['queued', 'done', 'error']) {
expect(reconcileContestantResume(status, 'tid', 'completed')).toBe('keep');
expect(reconcileContestantResume(status, null, null)).toBe('keep');
}
});
it('re-dispatches a running contestant with no task_id', () => {
expect(reconcileContestantResume('running', null, null)).toBe('re-dispatch');
});
it('re-dispatches a running contestant whose task row is absent', () => {
expect(reconcileContestantResume('running', 'tid', null)).toBe('re-dispatch');
});
it('marks done when the task completed before the terminal callback ran', () => {
expect(reconcileContestantResume('running', 'tid', 'completed')).toBe('mark-done');
});
it('marks error when the task failed', () => {
expect(reconcileContestantResume('running', 'tid', 'failed')).toBe('mark-error');
});
it('marks cancelled when the task was cancelled', () => {
expect(reconcileContestantResume('running', 'tid', 'cancelled')).toBe('mark-cancelled');
});
it('keeps a running contestant whose task is pending (dispatcher handles it)', () => {
expect(reconcileContestantResume('running', 'tid', 'pending')).toBe('keep');
});
it('re-dispatches when the task is stuck running (process died)', () => {
expect(reconcileContestantResume('running', 'tid', 'running')).toBe('re-dispatch');
});
it('re-dispatches when the task is blocked (permission dialog gone on restart)', () => {
expect(reconcileContestantResume('running', 'tid', 'blocked')).toBe('re-dispatch');
});
});
// ─── reconcileContestants ────────────────────────────────────────────────────
describe('reconcileContestants', () => {
it('returns one decision per contestant', () => {
const contestants = [
{ contestantId: 'c1', taskId: null, status: 'done' },
{ contestantId: 'c2', taskId: 't1', status: 'running' },
{ contestantId: 'c3', taskId: 't2', status: 'running' },
];
const taskStates = new Map([['t1', 'completed'], ['t2', 'running']]);
const decisions = reconcileContestants(contestants, taskStates);
expect(decisions).toHaveLength(3);
expect(decisions[0]).toEqual({ contestantId: 'c1', action: 'keep' });
expect(decisions[1]).toEqual({ contestantId: 'c2', action: 'mark-done' });
expect(decisions[2]).toEqual({ contestantId: 'c3', action: 're-dispatch' });
});
it('re-dispatches a running contestant whose taskId is absent from taskStates', () => {
const contestants = [{ contestantId: 'c1', taskId: 'orphan', status: 'running' }];
const decisions = reconcileContestants(contestants, new Map());
expect(decisions[0]?.action).toBe('re-dispatch');
});
it('re-dispatches a running contestant with null taskId', () => {
const contestants = [{ contestantId: 'c1', taskId: null, status: 'running' }];
const decisions = reconcileContestants(contestants, new Map());
expect(decisions[0]?.action).toBe('re-dispatch');
});
it('returns empty array for no contestants', () => {
expect(reconcileContestants([], new Map())).toEqual([]);
});
it('keeps a running contestant whose task is pending', () => {
const contestants = [{ contestantId: 'c1', taskId: 't1', status: 'running' }];
const taskStates = new Map([['t1', 'pending']]);
const decisions = reconcileContestants(contestants, taskStates);
expect(decisions[0]?.action).toBe('keep');
});
it('handles a mixed battle: done/queued kept, stale running re-dispatched', () => {
const contestants = [
{ contestantId: 'c1', taskId: 't1', status: 'done' },
{ contestantId: 'c2', taskId: null, status: 'queued' },
{ contestantId: 'c3', taskId: 't2', status: 'running' },
{ contestantId: 'c4', taskId: 't3', status: 'running' },
];
const taskStates = new Map([
['t1', 'completed'],
['t2', 'running'], // stuck — process dead
['t3', 'pending'], // dispatcher will handle
]);
const decisions = reconcileContestants(contestants, taskStates);
expect(decisions.find((d) => d.contestantId === 'c1')?.action).toBe('keep');
expect(decisions.find((d) => d.contestantId === 'c2')?.action).toBe('keep');
expect(decisions.find((d) => d.contestantId === 'c3')?.action).toBe('re-dispatch');
expect(decisions.find((d) => d.contestantId === 'c4')?.action).toBe('keep');
});
});

View File

@@ -161,6 +161,52 @@ describe('locateMatch — strategy 4: Levenshtein', () => {
}); });
}); });
describe('locateMatch — strategy 4: fail-closed on ambiguity (corruption guard)', () => {
it('refuses (ambiguous) when two equally-similar anchored blocks both clear the bar', () => {
// The repetitive-file case that duplicated blocks: two blocks share the same
// first+last anchor lines and their middle lines are EQUALLY similar to the
// (drifted) needle. Tier 4 must refuse rather than splice over one of them.
const content = [
'const x = {',
' total = aa;',
'};',
'const x = {',
' total = bb;',
'};',
].join('\n');
const needle = ['const x = {', ' total = ab;', '};'].join('\n');
const result = locateMatch(content, needle);
expect(result.kind).toBe('ambiguous');
});
it('refuses a below-threshold near-miss that the old 0.66 floor would have spliced', () => {
// ~0.7 similar: under the raised 0.85 floor this is now not_found, so the
// caller surfaces a correctable error instead of corrupting the file.
const content = 'const grandTotalAmount = a + b;\n';
const needle = 'const totalValue = a + b;';
const result = locateMatch(content, needle);
expect(result).toEqual({ kind: 'not_found' });
});
it('still matches a single genuine high-similarity drift uniquely', () => {
const content = 'const total = sum + tax;\n';
const needle = 'const totals = sum + tax;'; // one-char typo, ~0.96
const result = locateMatch(content, needle);
expect(result.kind).toBe('fuzzy');
const { start, end } = span(result);
expect(content.slice(start, end)).toBe('const total = sum + tax;');
});
it('requires an exact first+last line anchor for multi-line needles', () => {
// First line drifted too far to anchor → no window is scored → not_found,
// even though the middle lines are identical.
const content = ['function compute() {', ' return a + b;', ' return done;', '}'].join('\n');
const needle = ['totally different opener', ' return a + b;', '}'].join('\n');
const result = locateMatch(content, needle);
expect(result).toEqual({ kind: 'not_found' });
});
});
describe('locateMatch — edge cases', () => { describe('locateMatch — edge cases', () => {
it('returns not_found for an empty needle', () => { it('returns not_found for an empty needle', () => {
expect(locateMatch('anything', '')).toEqual({ kind: 'not_found' }); expect(locateMatch('anything', '')).toEqual({ kind: 'not_found' });

View File

@@ -83,6 +83,53 @@ describe.runIf(!!process.env.DATABASE_URL)('pending_changes integration', () =>
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(false); expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(false);
}); });
it('re-emitted identical edits dedupe at queue and never duplicate on apply', async () => {
// Regression: the 2-3x block-stamping corruption. An anchored insert queued
// three times (a local model re-emitting the same tool call) must collapse to
// ONE pending row and apply exactly once.
await queueCreate(sql, testSessionId, null, 'dup.js', '<script>\nrender();\n', projectRoot)
.then((c) => applyOne(sql, c.id, projectRoot));
const oldStr = '<script>';
const newStr = '<script>\nconst recordFormats = ["gif"];';
const a = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
const b = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
const c = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
// All three calls return the SAME pending row (deduped).
expect(b.id).toBe(a.id);
expect(c.id).toBe(a.id);
await applyOne(sql, a.id, projectRoot);
let content = await readFile(resolve(testDir, 'dup.js'), 'utf8');
expect((content.match(/const recordFormats/g) || []).length).toBe(1);
// Even a fresh, separately-queued identical edit re-applied is a no-op, not a stamp.
const again = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
const res = await applyOne(sql, again.id, projectRoot);
expect(res.success).toBe(true);
content = await readFile(resolve(testDir, 'dup.js'), 'utf8');
expect((content.match(/const recordFormats/g) || []).length).toBe(1);
});
it('preserves CRLF line endings on edit', async () => {
await queueCreate(sql, testSessionId, null, 'crlf.txt', 'line one\r\nline two\r\nline three\r\n', projectRoot)
.then((c) => applyOne(sql, c.id, projectRoot));
const edit = await queueEdit(sql, testSessionId, null, 'crlf.txt', 'line two', 'line TWO', projectRoot);
const res = await applyOne(sql, edit.id, projectRoot);
expect(res.success).toBe(true);
const content = await readFile(resolve(testDir, 'crlf.txt'), 'utf8');
expect(content).toBe('line one\r\nline TWO\r\nline three\r\n');
});
it('refuses an edit that matches multiple locations instead of corrupting', async () => {
await queueCreate(sql, testSessionId, null, 'ambig.js', 'x=1;\ny=2;\nx=1;\n', projectRoot)
.then((ch) => applyOne(sql, ch.id, projectRoot));
const edit = await queueEdit(sql, testSessionId, null, 'ambig.js', 'x=1;', 'x=9;', projectRoot);
const res = await applyOne(sql, edit.id, projectRoot);
expect(res.success).toBe(false);
expect(res.error).toMatch(/matches 2 locations/);
});
it('rewindOne → verify reverted', async () => { it('rewindOne → verify reverted', async () => {
// Setup: create and apply a file // Setup: create and apply a file
const createChange = await queueCreate(sql, testSessionId, null, 'rewindable.txt', 'initial', projectRoot); const createChange = await queueCreate(sql, testSessionId, null, 'rewindable.txt', 'initial', projectRoot);

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { planEdit } from '../pending_changes.js';
// planEdit is the pure core of applyOne's edit splice. These tests pin the
// idempotency guards that stop the "block stamped 2-3x" corruption: applying the
// same queued edit more than once must be a no-op, never a duplicate.
describe('planEdit — normal application', () => {
it('applies a unique exact edit', () => {
const content = 'a\nfoo\nb\n';
const plan = planEdit(content, 'foo', 'bar');
expect(plan).toEqual({ kind: 'apply', updated: 'a\nbar\nb\n' });
});
it('reports ambiguous when old_string occurs more than once', () => {
const content = 'foo\nx\nfoo\n';
const plan = planEdit(content, 'foo', 'bar');
expect(plan).toEqual({ kind: 'ambiguous', count: 2 });
});
it('reports not_found when old_string is absent and new is not present', () => {
const content = 'alpha\nbeta\n';
const plan = planEdit(content, 'gamma that is clearly nowhere', 'delta');
expect(plan).toEqual({ kind: 'not_found' });
});
});
describe('planEdit — idempotency (the corruption guard)', () => {
it('treats a re-applied anchored insert as already-applied (no duplicate)', () => {
// The exact mechanism that tripled `const recordFormats` in settings.html:
// an anchored insert (old=anchor, new=anchor+block) where the anchor still
// matches uniquely after the first apply.
const oldStr = '<script>';
const newStr = '<script>\nconst recordFormats = ["gif","mp4"];';
const before = '<script>\nfunction render() {}\n</script>\n';
const first = planEdit(before, oldStr, newStr);
expect(first.kind).toBe('apply');
const after = first.kind === 'apply' ? first.updated : '';
expect((after.match(/const recordFormats/g) || []).length).toBe(1);
// Re-applying the identical edit to the already-edited content is a no-op.
const second = planEdit(after, oldStr, newStr);
expect(second).toEqual({ kind: 'noop', reason: 'already-applied' });
});
it('treats an edit whose old_string is gone but new_string is present as already-applied', () => {
const content = 'const total = sum + tax;\n';
const plan = planEdit(content, 'const subtotal = sum;', 'const total = sum + tax;');
expect(plan).toEqual({ kind: 'noop', reason: 'already-applied' });
});
it('treats a no-change splice as a noop', () => {
const content = 'a\nfoo\nb\n';
const plan = planEdit(content, 'foo', 'foo');
expect(plan).toEqual({ kind: 'noop', reason: 'identical' });
});
it('does not duplicate across three repeated applications', () => {
const oldStr = 'function f() {';
const newStr = 'function f() {\n const x = 1;';
let content = 'function f() {\n return x;\n}\n';
for (let i = 0; i < 3; i++) {
const plan = planEdit(content, oldStr, newStr);
if (plan.kind === 'apply') content = plan.updated;
}
expect((content.match(/const x = 1;/g) || []).length).toBe(1);
});
});

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { evaluateTriggerRule } from '../flow-runner-decisions.js';
describe('evaluateTriggerRule', () => {
it('all_success requires all deps done', () => {
expect(evaluateTriggerRule(['a', 'b'], new Set(['a', 'b']), new Set(), new Set())).toBe(true);
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set())).toBe(false);
});
it('one_success fires on first completion', () => {
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set(), 'one_success')).toBe(true);
expect(evaluateTriggerRule(['a', 'b'], new Set(), new Set(), new Set(), 'one_success')).toBe(false);
});
it('all_done includes skipped deps', () => {
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(['b']), new Set(), 'all_done')).toBe(true);
});
it('all_success treats excluded deps as satisfied', () => {
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set(['b']))).toBe(true);
});
it('defaults to all_success', () => {
expect(evaluateTriggerRule(['a'], new Set(['a']), new Set(), new Set())).toBe(true);
expect(evaluateTriggerRule(['a'], new Set(), new Set(), new Set())).toBe(false);
});
it('returns true for empty deps', () => {
expect(evaluateTriggerRule([], new Set(), new Set(), new Set())).toBe(true);
});
});

View File

@@ -68,11 +68,18 @@ export function deriveModesFromACP(
): { modes: ProviderMode[]; currentModeId: string | null } { ): { modes: ProviderMode[]; currentModeId: string | null } {
if (modeState?.availableModes?.length) { if (modeState?.availableModes?.length) {
return { return {
modes: modeState.availableModes.map((mode) => ({ // ACP omits the unattended flag; inherit it from the manifest fallback by
id: mode.id, // id so the unified permission picker can still detect each agent's bypass
label: mode.name, // mode (e.g. opencode `full-access`) from live-probed modes.
description: mode.description ?? undefined, modes: modeState.availableModes.map((mode) => {
})), const fb = fallbackModes.find((f) => f.id === mode.id);
return {
id: mode.id,
label: mode.name,
description: mode.description ?? undefined,
...(fb?.isUnattended ? { isUnattended: true } : {}),
};
}),
currentModeId: modeState.currentModeId ?? null, currentModeId: modeState.currentModeId ?? null,
}; };
} }

View File

@@ -0,0 +1,191 @@
/**
* Pure, side-effect-free helpers for the Arena analyzer.
* No DB, no IO, no network — safe to unit-test directly.
*
* Covers: digest-prompt assembly, judge-prompt assembly, winner extraction
* from the judge output, the <2-survivors no-winner rule, and the
* cross-examination prompt.
*/
// ─── Shared types ─────────────────────────────────────────────────────────────
export interface ContestantDigestInput {
identity: string;
model: string;
resultMd: string;
diffPatch?: string;
benchmarkLine: string;
}
export interface ContestantDigest {
identity: string;
model: string;
digest: string;
benchmarkLine: string;
}
// ─── Digest stage ─────────────────────────────────────────────────────────────
/**
* Build the system + user prompts for the per-contestant digest call.
* The digest is a short structured summary; it keeps each call's context small
* so the downstream judge only sees digests (not raw diffs).
*/
export function buildDigestPrompt(input: ContestantDigestInput): { system: string; user: string } {
const system =
'You are an expert technical analyst evaluating the output of an AI coding or Q&A battle. ' +
'Produce a concise structured digest (under 300 words, Markdown bullet points) covering: ' +
'(1) correctness and quality, (2) completeness, (3) notable strengths, (4) notable weaknesses or issues. ' +
'Do not reference the battle or other contestants — focus only on this submission.';
const parts: string[] = [
`# Contestant: ${input.identity} / ${input.model}`,
`\nBenchmark: ${input.benchmarkLine}`,
'\n## Result\n',
input.resultMd.slice(0, 8_000),
];
if (input.diffPatch) {
parts.push('\n## Code Changes (diff)\n```diff');
parts.push(input.diffPatch.slice(0, 5_000));
parts.push('```');
}
return { system, user: parts.join('\n') };
}
// ─── Judge stage ──────────────────────────────────────────────────────────────
/**
* Build the system + user prompts for the comparative judge call.
* Receives contestant digests (NOT raw diffs) to keep context bounded.
*
* The judge output must contain a line starting with WINNER: or NO_WINNER.
* The caller extracts it with extractWinner().
*/
export function buildJudgePrompt(
originalPrompt: string,
digests: ContestantDigest[],
): { system: string; user: string } {
const canName = shouldNameWinner(digests.length);
const winnerInstruction = canName
? 'After your comparative analysis, name the best submission on its own line in this exact format:\n' +
'WINNER: <identity>/<model>\n' +
'where <identity> and <model> exactly match the heading above. No other text on that line.'
: 'Fewer than 2 contestants succeeded. Do NOT name a winner. Write the following on its own line:\nNO_WINNER';
const system =
'You are an expert judge for an AI battle. You have received digest summaries of each ' +
"contestant's work on the same task. Write a comparative analysis, then follow these instructions:\n" +
winnerInstruction;
const parts: string[] = [
'# Original Task Prompt\n',
originalPrompt.slice(0, 2_000),
'\n# Contestant Digests\n',
];
for (const d of digests) {
parts.push(`\n## ${d.identity} / ${d.model}`);
parts.push(`Benchmark: ${d.benchmarkLine}`);
parts.push(d.digest);
}
parts.push(
'\n# Instructions\nCompare the contestants and follow the winner-naming instructions above.',
);
return { system, user: parts.join('\n') };
}
// ─── No-winner rule ───────────────────────────────────────────────────────────
/**
* Returns true when enough contestants succeeded to name a winner.
* Rule: at least 2 must have produced a result. With 0 or 1 success the
* analysis must NOT name a winner (no meaningful comparison possible).
*/
export function shouldNameWinner(succeededCount: number): boolean {
return succeededCount >= 2;
}
// ─── Winner extraction ────────────────────────────────────────────────────────
/**
* Parse the judge's text output and extract the declared winner.
* Looks for a line matching: WINNER: <identity>/<model>
* Returns null when no valid winner line is found, or when the line contains
* NO_WINNER.
*
* The parse is lenient on surrounding whitespace and case for the keyword.
*/
export function extractWinner(judgeOutput: string): { identity: string; model: string } | null {
for (const line of judgeOutput.split('\n')) {
const trimmed = line.trim();
if (!trimmed.toUpperCase().startsWith('WINNER:')) continue;
const rest = trimmed.slice('WINNER:'.length).trim();
if (rest.toUpperCase() === 'NO_WINNER' || rest === '') return null;
const slashIdx = rest.indexOf('/');
if (slashIdx === -1) return null;
const identity = rest.slice(0, slashIdx).trim();
const model = rest.slice(slashIdx + 1).trim();
if (identity && model) return { identity, model };
}
return null;
}
// ─── Cross-examination stage ──────────────────────────────────────────────────
/**
* Build the system + user prompts for a cross-examination call.
* The cross-examiner sees the original prompt, contestant digests, and the
* proposed analysis, and is asked to challenge the result.
*/
export function buildCrossExamPrompt(opts: {
originalPrompt: string;
digests: ContestantDigest[];
analysisContent: string;
proposedWinner: string | null;
examinerIdentity: string;
examinerModel: string;
}): { system: string; user: string } {
const system =
`You are ${opts.examinerIdentity} (model: ${opts.examinerModel}), acting as an independent ` +
'cross-examiner in an AI battle. Your role is to critically challenge the proposed analysis ' +
'and winner, then give your own verdict. Be rigorous but fair. ' +
'End your response with your verdict on its own line:\n' +
'VERDICT: <identity>/<model> — if you agree or disagree with the proposed winner but can name one\n' +
'VERDICT: NO_WINNER — if no clear winner exists';
const parts: string[] = [
'# Original Task Prompt\n',
opts.originalPrompt.slice(0, 2_000),
'\n# Contestant Digests\n',
];
for (const d of opts.digests) {
parts.push(`\n## ${d.identity} / ${d.model}`);
parts.push(`Benchmark: ${d.benchmarkLine}`);
parts.push(d.digest);
}
parts.push('\n# Proposed Analysis\n');
parts.push(opts.analysisContent.slice(0, 5_000));
if (opts.proposedWinner) {
parts.push(`\n*(Proposed winner: ${opts.proposedWinner})*`);
} else {
parts.push('\n*(No winner was proposed — fewer than 2 contestants succeeded.)*');
}
parts.push(
'\n# Your Cross-Examination\n' +
'Challenge the analysis above, then give your independent verdict (VERDICT: … on its own line).',
);
return { system, user: parts.join('\n') };
}

View File

@@ -0,0 +1,496 @@
/**
* Arena Analyzer — pluggable seam for battle analysis and cross-examination.
*
* The Analyzer interface is the plug point: a v2 Han Orchestrator flow can
* replace the v1 two-stage digest→judge implementation without a schema change.
*
* v1 implementation uses DEFAULT_MODEL via direct llama-swap calls (arenaModelCall):
* Digest stage — one call per succeeded contestant, concurrent; produces a
* bounded summary of each result (result.md + diff.patch for
* coding, result.md for Q&A).
* Judge stage — one call with all digests + the original prompt; writes
* analysis.md, names a winner (unless < 2 succeeded), and
* updates battles.winner_contestant_id.
*
* Cross-examination:
* Local model — direct arenaModelCall to llama-swap with the chosen model.
* Cloud model — inserts a tasks row (triggers the dispatcher via pg_notify);
* polls for completion; reads output_summary as the verdict.
* In both cases the verdict is written to cross_examinations.verdict, appended
* to <resultsPath>/cross-exam.md, and a battle_updated frame is published.
*
* Never throws — all errors are caught, logged, and swallowed so the caller
* (arena-runner's onBattleComplete / onCrossExamStart) is never wedged.
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { FastifyBaseLogger } from 'fastify';
import type { Config } from '../config.js';
import type { BattleType } from '@boocode/contracts/arena';
import { arenaModelCall } from './arena-model-call.js';
import {
buildDigestPrompt,
buildJudgePrompt,
buildCrossExamPrompt,
extractWinner,
shouldNameWinner,
type ContestantDigest,
} from './arena-analyzer-helpers.js';
// ─── Public interface ─────────────────────────────────────────────────────────
/** Pluggable analysis seam — swap to a Han Orchestrator flow in v2. */
export interface Analyzer {
/** Run the two-stage digest→judge analysis for a completed battle. */
analyze(battleId: string): Promise<void>;
/**
* Run a cross-examination for an already-inserted cross_examinations row.
* The result is written back to that row and a battle_updated frame is published.
*/
crossExamine(
battleId: string,
crossExamId: string,
opts: { identity: string; model: string },
): Promise<void>;
}
// ─── Internal DB row types ────────────────────────────────────────────────────
interface BattleRow {
id: string;
project_id: string;
battle_type: BattleType;
prompt: string;
status: string;
results_path: string | null;
winner_contestant_id: string | null;
}
interface ContestantRow {
id: string;
identity: string;
model: string;
lane: string;
status: string;
result_path: string | null;
duration_ms: number | null;
tokens_per_sec: number | null;
}
// ─── Factory ──────────────────────────────────────────────────────────────────
interface AnalyzerDeps {
sql: Sql;
broker: Broker;
log: FastifyBaseLogger;
config: Pick<Config, 'LLAMA_SWAP_URL' | 'DEFAULT_MODEL'>;
/** Model IDs served by local llama-swap — cross-exam routing uses this. */
localModels: ReadonlySet<string>;
}
export function createAnalyzer(deps: AnalyzerDeps): Analyzer {
const { sql, broker, log, config, localModels } = deps;
// ─── analyze ──────────────────────────────────────────────────────────────
async function analyze(battleId: string): Promise<void> {
try {
await runAnalysis(battleId);
} catch (err) {
log.error(
{ err: errMsg(err), battleId },
'arena-analyzer: analysis failed',
);
}
}
async function runAnalysis(battleId: string): Promise<void> {
const battle = await loadBattle(battleId);
if (!battle) {
log.warn({ battleId }, 'arena-analyzer: battle not found');
return;
}
const contestants = await loadContestants(battleId);
const succeeded = contestants.filter((c) => c.status === 'done' && c.result_path);
log.info(
{ battleId, total: contestants.length, succeeded: succeeded.length },
'arena-analyzer: starting analysis',
);
// Digest stage — concurrent, one call per succeeded contestant.
const digests = (
await Promise.all(succeeded.map((c) => digestContestant(battle, c)))
).filter((d): d is ContestantDigest => d !== null);
// Failed contestants are noted in the analysis even if they produced no digest.
const failedNotes = contestants
.filter((c) => c.status === 'error')
.map((c) => `- **${c.identity} / ${c.model}**: failed (no result)\n`);
// Judge stage — single call with all digests.
const { analysisText, winner } = await judgeContestants(battle, digests, failedNotes);
// Write analysis.md to the battle results folder.
const resultsPath = battle.results_path;
if (resultsPath) {
await mkdir(resultsPath, { recursive: true });
await writeFile(join(resultsPath, 'analysis.md'), analysisText, 'utf8');
}
// Resolve the winner to a contestant id and update the battle row.
let winnerId: string | null = null;
if (winner && shouldNameWinner(succeeded.length)) {
const winnerContestant = contestants.find(
(c) => c.identity === winner.identity && c.model === winner.model,
);
if (winnerContestant) {
winnerId = winnerContestant.id;
await sql`
UPDATE battles
SET winner_contestant_id = ${winnerId}, updated_at = clock_timestamp()
WHERE id = ${battleId}
`;
log.info({ battleId, winnerId, identity: winner.identity, model: winner.model }, 'arena-analyzer: winner set');
} else {
log.warn({ battleId, winner }, 'arena-analyzer: judge named a winner not found in contestants');
}
}
publishUser({
type: 'battle_updated',
battle_id: battleId,
winner_contestant_id: winnerId,
analysis_ready: true,
});
log.info({ battleId }, 'arena-analyzer: analysis complete');
}
// ─── crossExamine ─────────────────────────────────────────────────────────
async function crossExamine(
battleId: string,
crossExamId: string,
opts: { identity: string; model: string },
): Promise<void> {
try {
await runCrossExam(battleId, crossExamId, opts);
} catch (err) {
log.error(
{ err: errMsg(err), battleId, crossExamId },
'arena-analyzer: cross-exam failed',
);
}
}
async function runCrossExam(
battleId: string,
crossExamId: string,
opts: { identity: string; model: string },
): Promise<void> {
const battle = await loadBattle(battleId);
if (!battle) {
log.warn({ battleId }, 'arena-analyzer: battle not found for cross-exam');
return;
}
const contestants = await loadContestants(battleId);
// Re-read the digests (if contestants have results) for context.
const succeeded = contestants.filter((c) => c.status === 'done' && c.result_path);
const digests = (
await Promise.all(succeeded.map((c) => digestContestant(battle, c)))
).filter((d): d is ContestantDigest => d !== null);
// Read analysis.md for the proposed analysis content.
let analysisContent = '';
if (battle.results_path) {
analysisContent = await readFile(
join(battle.results_path, 'analysis.md'), 'utf8',
).catch(() => '');
}
// Resolve proposed winner label.
let proposedWinner: string | null = null;
if (battle.winner_contestant_id) {
const w = contestants.find((c) => c.id === battle.winner_contestant_id);
if (w) proposedWinner = `${w.identity}/${w.model}`;
}
const { system, user } = buildCrossExamPrompt({
originalPrompt: battle.prompt,
digests,
analysisContent,
proposedWinner,
examinerIdentity: opts.identity,
examinerModel: opts.model,
});
log.info({ battleId, crossExamId, identity: opts.identity, model: opts.model }, 'arena-analyzer: running cross-exam');
const verdict = await executeModelCall({
battleId,
projectId: battle.project_id,
identity: opts.identity,
model: opts.model,
system,
user,
});
// Persist verdict and append to cross-exam.md.
await sql`
UPDATE cross_examinations
SET verdict = ${verdict}
WHERE id = ${crossExamId}
`;
if (battle.results_path) {
const crossExamPath = join(battle.results_path, 'cross-exam.md');
const section =
`\n---\n\n# Cross-Examination by ${opts.identity} / ${opts.model}\n\n` +
`${verdict}\n`;
await writeFile(crossExamPath, section, { flag: 'a', encoding: 'utf8' });
}
publishUser({
type: 'battle_updated',
battle_id: battleId,
cross_exam_id: crossExamId,
});
log.info({ battleId, crossExamId }, 'arena-analyzer: cross-exam complete');
}
// ─── Model call routing ───────────────────────────────────────────────────
/**
* Route a one-shot model call to llama-swap (local) or the task dispatcher
* (cloud). Cloud dispatch inserts a tasks row and polls for completion.
*/
async function executeModelCall(opts: {
battleId: string;
projectId: string;
identity: string;
model: string;
system: string;
user: string;
}): Promise<string> {
const isLocal = localModels.has(opts.model) || localModels.has(`llama-swap/${opts.model}`);
if (isLocal) {
return arenaModelCall({
config,
model: opts.model,
system: opts.system,
user: opts.user,
maxTokens: 2_000,
temperature: 0.3,
});
}
// Cloud path: dispatch through the task system and poll for completion.
return executeCloudModelCall(opts);
}
async function executeCloudModelCall(opts: {
projectId: string;
identity: string;
model: string;
system: string;
user: string;
}): Promise<string> {
// The cross-exam prompt is the full input to the external agent. We embed
// the system prompt as a preamble in the user message (external agents don't
// take a separate system arg through the tasks dispatcher).
const input = `${opts.system}\n\n${opts.user}`;
// For well-known external agents, stamp the agent name so the dispatcher
// routes via PTY/ACP. For unknown identities fall back to native inference
// (agent = null → DEFAULT_MODEL text generation).
const knownAgents = new Set(['claude', 'opencode', 'qwen', 'goose']);
const agentName = knownAgents.has(opts.identity) ? opts.identity : null;
const [task] = await sql<{ id: string }[]>`
INSERT INTO tasks (project_id, input, agent, model)
VALUES (${opts.projectId}, ${input}, ${agentName}, ${opts.model})
RETURNING id
`;
const taskId = task!.id;
log.info({ taskId, identity: opts.identity, model: opts.model }, 'arena-analyzer: cloud cross-exam task dispatched');
// Poll until terminal (up to 5 minutes).
const timeoutMs = 5 * 60 * 1_000;
const pollMs = 2_000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await sleep(pollMs);
const [row] = await sql<{ state: string; output_summary: string | null }[]>`
SELECT state, output_summary FROM tasks WHERE id = ${taskId}
`;
if (!row) break;
if (row.state === 'completed') return row.output_summary ?? '';
if (row.state === 'failed' || row.state === 'cancelled') {
throw new Error(`cross-exam task ${row.state}: ${row.output_summary ?? ''}`);
}
}
throw new Error(`cloud cross-exam task timed out after ${timeoutMs / 1000}s`);
}
// ─── Digest helper ────────────────────────────────────────────────────────
async function digestContestant(
battle: BattleRow,
c: ContestantRow,
): Promise<ContestantDigest | null> {
if (!c.result_path) return null;
const resultMd = await readFile(join(c.result_path, 'result.md'), 'utf8').catch(() => '');
let diffPatch: string | undefined;
if (battle.battle_type === 'coding') {
diffPatch = await readFile(join(c.result_path, 'diff.patch'), 'utf8').catch(
() => undefined,
);
}
const benchmarkLine = formatBenchmarkLine(c);
const { system, user } = buildDigestPrompt({
identity: c.identity,
model: c.model,
resultMd,
diffPatch,
benchmarkLine,
});
let digest: string;
try {
digest = await arenaModelCall({
config,
model: config.DEFAULT_MODEL,
system,
user,
maxTokens: 500,
temperature: 0.3,
});
} catch (err) {
log.warn(
{ err: errMsg(err), identity: c.identity, model: c.model },
'arena-analyzer: digest call failed — skipping contestant',
);
return null;
}
return { identity: c.identity, model: c.model, digest, benchmarkLine };
}
// ─── Judge helper ─────────────────────────────────────────────────────────
async function judgeContestants(
battle: BattleRow,
digests: ContestantDigest[],
failedNotes: string[],
): Promise<{ analysisText: string; winner: { identity: string; model: string } | null }> {
const { system, user } = buildJudgePrompt(battle.prompt, digests);
let judgeOutput = '';
try {
judgeOutput = await arenaModelCall({
config,
model: config.DEFAULT_MODEL,
system,
user,
maxTokens: 2_000,
temperature: 0.3,
});
} catch (err) {
log.error({ err: errMsg(err), battleId: battle.id }, 'arena-analyzer: judge call failed');
judgeOutput = '*(Judge call failed — no comparison produced.)*';
}
const winner = shouldNameWinner(digests.length) ? extractWinner(judgeOutput) : null;
const sections: string[] = [
`# Arena Analysis`,
`\n**Battle type:** ${battle.battle_type}`,
];
if (failedNotes.length > 0) {
sections.push('\n## Failed Contestants\n');
sections.push(...failedNotes);
}
if (digests.length > 0) {
sections.push('\n## Contestant Digests\n');
for (const d of digests) {
sections.push(`### ${d.identity} / ${d.model}`);
sections.push(`*Benchmark: ${d.benchmarkLine}*\n`);
sections.push(d.digest);
}
}
sections.push("\n## Judge's Verdict\n");
sections.push(judgeOutput);
if (winner) {
sections.push(`\n## Winner\n**${winner.identity} / ${winner.model}**`);
} else {
const reason =
digests.length < 2
? 'fewer than 2 contestants produced results'
: 'no clear winner identified';
sections.push(`\n## Winner\n*No winner named (${reason}).*`);
}
return { analysisText: sections.join('\n'), winner };
}
// ─── DB helpers ───────────────────────────────────────────────────────────
async function loadBattle(battleId: string): Promise<BattleRow | null> {
const [b] = await sql<BattleRow[]>`
SELECT id, project_id, battle_type, prompt, status, results_path, winner_contestant_id
FROM battles WHERE id = ${battleId}
`;
return b ?? null;
}
async function loadContestants(battleId: string): Promise<ContestantRow[]> {
return sql<ContestantRow[]>`
SELECT id, identity, model, lane, status, result_path, duration_ms, tokens_per_sec
FROM contestants WHERE battle_id = ${battleId}
ORDER BY created_at ASC
`;
}
// ─── Misc helpers ─────────────────────────────────────────────────────────
function formatBenchmarkLine(c: ContestantRow): string {
const parts: string[] = [];
if (c.duration_ms !== null) parts.push(`${c.duration_ms}ms`);
if (c.tokens_per_sec !== null) parts.push(`${c.tokens_per_sec.toFixed(1)} tok/s`);
return parts.length > 0 ? parts.join(', ') : 'no benchmark';
}
function publishUser(frame: Record<string, unknown>): void {
broker.publishUserFrame('default', frame as unknown as WsFrame);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
return { analyze, crossExamine };
}
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}

View File

@@ -0,0 +1,188 @@
/**
* Pure scheduling and classification decisions for the Arena battle-runner.
* No database, no IO. Mirrors the pattern of flow-runner-decisions.ts.
*
* Vocabulary:
* local lane — llama-swap-backed contestants, run strictly one at a time
* cloud lane — cloud-backed contestants, run all in parallel
*
* A contestant's status lifecycle:
* queued → running → done | error
*/
import type { BattleType, ContestantLane, TokenBreakdown } from '@boocode/contracts/arena';
// ─── Lane classification ──────────────────────────────────────────────────────
/**
* Classify a contestant into a lane.
*
* Q&A contestants always run on the native (llama-swap) backend → local.
* Coding contestants: their MODEL is checked against the localModels set
* (all model IDs served by the local llama-swap server). This means an
* opencode or qwen contestant pointed at a local model counts as local,
* which correctly captures GPU-contention and fair benchmarking (ADR 0001).
*
* @param battleType 'coding' | 'qa'
* @param identity backend name (coding) or persona name (qa) — not used for lane logic
* @param model the contestant's model id
* @param localModels set of model IDs served by the local llama-swap server
*/
export function classifyLane(
battleType: BattleType,
_identity: string,
model: string,
localModels: ReadonlySet<string>,
): ContestantLane {
if (battleType === 'qa') return 'local';
return localModels.has(model) ? 'local' : 'cloud';
}
// ─── Local-lane queue ─────────────────────────────────────────────────────────
export interface ContestantSlot {
id: string;
lane: ContestantLane;
status: string;
}
/**
* The next queued local contestant to dispatch — the first 'queued' contestant
* in the local lane, in creation order (caller must supply rows in created_at ASC).
* Returns null when the local queue is empty or all local slots are non-queued.
*/
export function nextLocalContestant(contestants: readonly ContestantSlot[]): string | null {
for (const c of contestants) {
if (c.lane === 'local' && c.status === 'queued') return c.id;
}
return null;
}
// ─── Battle completion ────────────────────────────────────────────────────────
/**
* True when every contestant has reached a terminal state (done | error).
* Returns false for an empty list — a battle with no contestants never completes.
*/
export function isBattleComplete(contestants: readonly { status: string }[]): boolean {
if (contestants.length === 0) return false;
return contestants.every((c) => c.status === 'done' || c.status === 'error');
}
// ─── Benchmark ────────────────────────────────────────────────────────────────
export interface Benchmark {
durationMs: number;
tokensPerSec: number | null;
tokenBreakdown: TokenBreakdown | null;
}
/**
* Compute the benchmark for a contestant.
* Wall-clock duration is captured for every contestant; tokens/sec is only
* meaningful for local (llama-swap) contestants where the model has sole
* access to the GPU and the measurement is fair.
*/
export function computeBenchmark(
startedAt: Date,
endedAt: Date,
costTokens: number | null,
lane: ContestantLane,
tokenBreakdown: TokenBreakdown | null = null,
): Benchmark {
const durationMs = Math.max(0, endedAt.getTime() - startedAt.getTime());
const tokensPerSec =
lane === 'local' && costTokens !== null && durationMs > 0
? (costTokens / durationMs) * 1000
: null;
return { durationMs, tokensPerSec, tokenBreakdown };
}
// ─── Slug / path helpers ──────────────────────────────────────────────────────
/**
* Sanitize a string for use as a directory name component.
* Lowercases, replaces non-alphanumeric runs with '-', trims leading/trailing
* dashes, and caps at 64 characters.
*/
export function sanitizeSlug(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
/**
* Build the dated battle slug used as the Arena results folder name.
* Format: YYYY-MM-DD-<battleType>-<first-8-hex-of-uuid>
* Deterministic: callers can rebuild it from (id, type, created_at) on resume.
*/
export function buildBattleSlug(battleId: string, battleType: BattleType, createdAt: Date): string {
const date = createdAt.toISOString().slice(0, 10);
const shortId = battleId.replace(/-/g, '').slice(0, 8);
return `${date}-${battleType}-${shortId}`;
}
/**
* Build the per-contestant results directory name within a battle folder.
* Format: <sanitized-identity>-<sanitized-model>
*/
export function buildContestantDir(identity: string, model: string): string {
return `${sanitizeSlug(identity)}-${sanitizeSlug(model)}`;
}
// ─── Resume reconciliation ────────────────────────────────────────────────────
export type ContestantResumeAction =
| 'keep'
| 're-dispatch'
| 'mark-done'
| 'mark-error'
| 'mark-cancelled';
export interface ContestantResumeDecision {
contestantId: string;
action: ContestantResumeAction;
}
/**
* Decide what to do with ONE contestant during startup resume.
* Mirrors reconcileResumeStep from flow-runner-decisions.ts.
*
* @param status contestants.status
* @param taskId contestants.task_id (null when not yet dispatched)
* @param taskState tasks.state for taskId, or null if the task row is absent
*/
export function reconcileContestantResume(
status: string,
taskId: string | null,
taskState: string | null,
): ContestantResumeAction {
if (status !== 'running') return 'keep';
if (!taskId || taskState === null) return 're-dispatch';
switch (taskState) {
case 'completed': return 'mark-done';
case 'failed': return 'mark-error';
case 'cancelled': return 'mark-cancelled';
case 'pending': return 'keep'; // dispatcher startup poll will run it normally
default: return 're-dispatch'; // 'running'/'blocked' — process is dead
}
}
/**
* Reconcile every contestant of an in-flight battle for startup resume.
* Returns one decision per contestant. Pure — no IO.
*/
export function reconcileContestants(
contestants: ReadonlyArray<{ contestantId: string; taskId: string | null; status: string }>,
taskStates: ReadonlyMap<string, string>,
): ContestantResumeDecision[] {
return contestants.map((c) => ({
contestantId: c.contestantId,
action: reconcileContestantResume(
c.status,
c.taskId,
c.taskId ? (taskStates.get(c.taskId) ?? null) : null,
),
}));
}

View File

@@ -0,0 +1,70 @@
/**
* One-shot model completion for the Arena analyzer.
*
* Calls the local llama-swap server directly for a single non-streaming
* completion. Used for the digest and judge stages (always DEFAULT_MODEL)
* and for local-model cross-examinations (any local model).
*
* Mirrors apps/server/src/services/task-model.ts but targets the coder's
* config shape and uses a longer timeout appropriate for analysis calls.
*/
import type { Config } from '../config.js';
const TIMEOUT_MS = 120_000;
export async function arenaModelCall(opts: {
config: Pick<Config, 'LLAMA_SWAP_URL'>;
model: string;
system: string;
user: string;
maxTokens?: number;
temperature?: number;
}): Promise<string> {
const { config, model, system, user } = opts;
const maxTokens = opts.maxTokens ?? 2_000;
const temperature = opts.temperature ?? 0.3;
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
max_tokens: maxTokens,
temperature,
stream: false,
chat_template_kwargs: { enable_thinking: false },
}),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`llama-swap responded ${res.status}: ${text.slice(0, 200)}`);
}
const data = (await res.json()) as {
choices?: Array<{
message?: { content?: string; reasoning_content?: string };
}>;
};
const choice = data.choices?.[0]?.message;
if (!choice) return '';
const content = (choice.content ?? '').trim();
if (content.length > 0) return content;
// For thinking-mode models the answer sometimes only lands in reasoning_content.
const reasoning = (choice.reasoning_content ?? '').trim();
if (reasoning.length > 0) {
const lines = reasoning.split('\n').filter((l) => l.trim().length > 0);
return lines[lines.length - 1] ?? '';
}
return '';
}

View File

@@ -0,0 +1,895 @@
/**
* Arena battle-runner — DB-backed execution engine for Arena battles.
*
* Mirrors flow-runner.ts but implements the Arena's two-lane scheduler instead
* of the Orchestrator's wave scheduler. Persists to battles/contestants tables
* (not flow_runs/flow_steps). Each contestant is dispatched as a real tasks row
* via an injected DispatchContestantFn (Phase 4 wires this to the dispatcher).
* Advances on the dispatcher's onTaskTerminal hook.
*
* Scheduling:
* - Cloud lane: all contestants start immediately, in parallel.
* - Local lane: contestants run strictly one at a time (serial queue). Only
* the first local contestant runs at start; the next is dispatched when the
* current one terminates. Both lanes run concurrently with each other.
*
* Results:
* Written to <projectRoot>/Arena/<battleSlug>/<identity>-<model>/
* Coding: result.md + diff.patch (from the contestant's worktree).
* Q&A: result.md with the text answer.
*
* Analyzer seam:
* onBattleComplete is called when all contestants are terminal. Phase 5 wires
* this to the two-stage digest→judge analyzer. A failed contestant does NOT
* abort the battle — others continue and the analyzer judges survivors.
*/
import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { FastifyBaseLogger } from 'fastify';
import type { BattleType, ContestantLane } from '@boocode/contracts/arena';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { diffWorktree } from './worktrees.js';
import {
buildBattleSlug,
buildContestantDir,
classifyLane,
computeBenchmark,
isBattleComplete,
nextLocalContestant,
reconcileContestants,
type ContestantResumeAction,
type ContestantSlot,
} from './arena-decisions.js';
// ─── Public types ─────────────────────────────────────────────────────────────
export interface ContestantSpec {
/** Backend name (coding) or persona name (qa). */
identity: string;
model: string;
}
export interface BattleStartOpts {
projectId: string;
battleType: BattleType;
prompt: string;
/** 26 contestants. Duplicate (identity, model) pairs are rejected by the schema UNIQUE constraint. */
contestants: ContestantSpec[];
}
/**
* Injected dispatch function — Phase 4 wires this to the real task inserter.
* Must INSERT a tasks row and return its id. The arena-runner sets the
* contestant's task_id and status after this call.
* `sessionId` is returned when already known (Q&A pre-creates the session);
* null for coding contestants whose session is created lazily by the dispatcher.
*/
export type DispatchContestantFn = (opts: {
projectId: string;
contestantId: string;
prompt: string;
identity: string;
model: string;
battleType: BattleType;
}) => Promise<{ taskId: string; sessionId: string | null }>;
/**
* Called once when every contestant in a battle has reached a terminal state.
* Phase 5 wires this to the two-stage digest→judge analyzer.
* Must never throw — the caller swallows errors.
*/
export type OnBattleComplete = (battleId: string) => void;
/**
* Called after a cross_examinations row has been inserted, with its id.
* Phase 5 wires this to the analyzer's cross-examination runner.
* Must never throw — the caller swallows errors.
*/
export type OnCrossExamStart = (opts: {
battleId: string;
crossExamId: string;
identity: string;
model: string;
}) => void;
export interface BattleRunner {
/** Start a battle: persist it + its contestants, classify lanes, dispatch initial wave. */
startBattle(opts: BattleStartOpts): Promise<{ battleId: string }>;
/**
* Wire to createDispatcher({ onTaskTerminal }). Fires when ANY task settles;
* the runner ignores tasks it doesn't own. Never throws.
*/
handleTaskTerminal(taskId: string, state: string): void;
/**
* Re-advance any battles still marked 'running' after a coder restart.
* Mirrors flow-runner's initResume (D-9). Never throws.
*/
initResume(): Promise<void>;
/**
* Cancel a running battle. Marks it and all non-terminal contestants cancelled,
* publishes frames, and returns the task_ids of in-flight contestants so the
* route can abort them via the dispatcher's cancelExternalTask.
*/
cancelBattle(battleId: string): Promise<{ cancelled: boolean; taskIds: string[] }>;
/**
* Trigger analysis for a completed (or manually re-analyzed) battle.
* Phase 5 wires this to the two-stage digest→judge analyzer. For now, calls
* the injected onBattleComplete seam directly.
*/
triggerAnalysis(battleId: string): Promise<{ triggered: boolean }>;
/**
* Start a cross-examination on a battle. Inserts a cross_examinations row and
* invokes the analyzer seam. Phase 5 fills the actual verdict logic.
*/
startCrossExam(
battleId: string,
opts: { identity: string; model: string },
): Promise<{ crossExamId: string }>;
/**
* Manually set (or clear) the winner. Validates the contestant belongs to the
* battle, updates battles.winner_contestant_id, and publishes a battle_updated
* frame so the pane reflects the override immediately.
*/
setWinner(battleId: string, winnerId: string | null): Promise<{
ok: boolean;
notFound?: boolean;
invalidContestant?: boolean;
}>;
}
// ─── Internal row shapes ──────────────────────────────────────────────────────
interface ContestantRow {
id: string;
battle_id: string;
identity: string;
model: string;
lane: ContestantLane;
task_id: string | null;
worktree_id: string | null;
status: string;
}
interface BattleRow {
id: string;
project_id: string;
battle_type: BattleType;
prompt: string;
status: string;
results_path: string | null;
created_at: Date;
}
// ─── Deps / factory ───────────────────────────────────────────────────────────
interface Deps {
sql: Sql;
broker: Broker;
log: FastifyBaseLogger;
dispatch: DispatchContestantFn;
onBattleComplete: OnBattleComplete;
/**
* Called after a cross_examinations row is inserted. Phase 5 wires this to
* the analyzer's cross-examination runner. Optional: absent → no cross-exam
* logic runs (stub behaviour for tests).
*/
onCrossExamStart?: OnCrossExamStart;
/**
* Model IDs served by the local llama-swap server. Used for lane classification:
* a contestant whose model is in this set runs in the local lane (serial, GPU-fair).
* Q&A contestants are always local regardless of this set.
* Defaults to an empty set → all coding contestants go to the cloud lane.
*/
localModels?: ReadonlySet<string>;
}
const DEFAULT_LOCAL_MODELS: ReadonlySet<string> = new Set();
export function createBattleRunner(deps: Deps): BattleRunner {
const { sql, broker, log, dispatch, onBattleComplete, onCrossExamStart } = deps;
const localModels = deps.localModels ?? DEFAULT_LOCAL_MODELS;
// Serialize local-lane advance per battle so two near-simultaneous terminal
// callbacks don't double-dispatch the next local contestant.
const advanceChain = new Map<string, Promise<void>>();
// Delta bridge: per-contestant broker unsubscribe functions.
// 'terminated' sentinel prevents a late-arriving setupDeltaBridge from
// registering a subscription that would never be cleaned up.
const deltaUnsubs = new Map<string, (() => void) | 'terminated'>();
function publishUser(frame: Record<string, unknown>): void {
broker.publishUserFrame('default', frame as unknown as WsFrame);
}
/**
* Subscribe to the contestant's inference session and forward delta frames
* to the user channel as contestant_updated{delta}. Polls for session_id
* when not immediately known (coding contestants whose session is created
* lazily by the dispatcher). Unsubscribes on termination or max retries.
*/
async function setupDeltaBridge(
battleId: string,
contestantId: string,
taskId: string,
knownSessionId: string | null,
): Promise<void> {
let sessionId = knownSessionId;
if (!sessionId) {
// Coding contestant: session_id is written by the dispatcher just before
// inference starts. Poll until it appears or the contestant terminates.
for (let i = 0; i < 50; i++) {
if (deltaUnsubs.get(contestantId) === 'terminated') return;
const [row] = await sql<{ session_id: string | null }[]>`
SELECT session_id FROM tasks WHERE id = ${taskId}
`.catch(() => []);
if (row?.session_id) { sessionId = row.session_id; break; }
await new Promise((r) => setTimeout(r, 200));
}
}
if (!sessionId) return;
if (deltaUnsubs.get(contestantId) === 'terminated') return;
const unsub = broker.subscribe(sessionId, (frame) => {
if (frame.type === 'delta') {
const deltaContent = (frame as unknown as { content?: unknown }).content;
if (typeof deltaContent === 'string') {
publishUser({
type: 'contestant_updated',
battle_id: battleId,
contestant_id: contestantId,
delta: deltaContent,
});
}
}
});
const existing = deltaUnsubs.get(contestantId);
if (existing === 'terminated') {
unsub();
} else {
deltaUnsubs.set(contestantId, unsub);
}
}
function teardownDeltaBridge(contestantId: string): void {
const entry = deltaUnsubs.get(contestantId);
if (typeof entry === 'function') {
entry();
deltaUnsubs.delete(contestantId);
} else {
deltaUnsubs.set(contestantId, 'terminated');
}
}
// ─── startBattle ────────────────────────────────────────────────────────────
async function startBattle(opts: BattleStartOpts): Promise<{ battleId: string }> {
if (opts.contestants.length < 2 || opts.contestants.length > 6) {
throw new Error(`battle requires 26 contestants; got ${opts.contestants.length}`);
}
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${opts.projectId}`;
if (!proj) throw new Error(`project not found: ${opts.projectId}`);
// Insert the battle row as 'running'; update results_path once we have the id.
const [battle] = await sql<{ id: string; created_at: Date }[]>`
INSERT INTO battles (project_id, battle_type, prompt, status)
VALUES (${opts.projectId}, ${opts.battleType}, ${opts.prompt}, 'running')
RETURNING id, created_at
`;
const battleId = battle!.id;
const battleSlug = buildBattleSlug(battleId, opts.battleType, battle!.created_at);
const resultsPath = join(proj.path, 'Arena', battleSlug);
await sql`
UPDATE battles SET results_path = ${resultsPath}, updated_at = clock_timestamp()
WHERE id = ${battleId}
`;
// Insert all contestant rows with lane classification.
const contestantRows: Array<{ id: string; identity: string; model: string; lane: ContestantLane }> = [];
for (const spec of opts.contestants) {
const lane = classifyLane(opts.battleType, spec.identity, spec.model, localModels);
const [row] = await sql<{ id: string }[]>`
INSERT INTO contestants (battle_id, identity, model, lane, status)
VALUES (${battleId}, ${spec.identity}, ${spec.model}, ${lane}, 'queued')
RETURNING id
`;
contestantRows.push({ id: row!.id, identity: spec.identity, model: spec.model, lane });
}
// Write initial manifest so the results folder is always populated.
await writeManifest(
battleId, resultsPath, opts.battleType, opts.prompt, battle!.created_at,
contestantRows.map((c) => ({ identity: c.identity, model: c.model, lane: c.lane })),
null,
).catch((err) => {
log.warn({ err: errMsg(err), battleId }, 'arena-runner: initial manifest write failed');
});
publishUser({
type: 'battle_started',
battle_id: battleId,
battle_type: opts.battleType,
prompt: opts.prompt,
contestants: contestantRows.map((c) => ({
id: c.id,
identity: c.identity,
model: c.model,
lane: c.lane,
})),
});
// Dispatch: cloud lane starts all contestants in parallel; local lane starts
// only the first queued contestant (serial queue).
let localStarted = false;
for (const c of contestantRows) {
if (c.lane === 'cloud') {
await dispatchContestant(battleId, opts.projectId, opts.battleType, opts.prompt, c);
} else if (!localStarted) {
await dispatchContestant(battleId, opts.projectId, opts.battleType, opts.prompt, c);
localStarted = true;
// remaining local contestants stay 'queued' until this one finishes
}
}
return { battleId };
}
async function dispatchContestant(
battleId: string,
projectId: string,
battleType: BattleType,
prompt: string,
c: { id: string; identity: string; model: string; lane: ContestantLane },
): Promise<void> {
const { taskId, sessionId } = await dispatch({
projectId,
contestantId: c.id,
prompt,
identity: c.identity,
model: c.model,
battleType,
});
await sql`
UPDATE contestants
SET task_id = ${taskId}, status = 'running', updated_at = clock_timestamp()
WHERE id = ${c.id}
`;
publishContestantFrame(battleId, c.id, { status: 'running' });
// Start the delta bridge in the background; unsubscribe when the contestant
// terminates (teardownDeltaBridge called in handleTaskTerminal).
void setupDeltaBridge(battleId, c.id, taskId, sessionId ?? null);
}
// ─── local-lane advance (serialized per battle) ───────────────────────────
function advanceLocalLane(battleId: string): Promise<void> {
const prev = advanceChain.get(battleId) ?? Promise.resolve();
const next = prev
.catch(() => {})
.then(() =>
advanceLocalLaneInner(battleId).catch((err) => {
log.error({ err: errMsg(err), battleId }, 'arena-runner: advanceLocalLane failed');
}),
);
advanceChain.set(battleId, next);
void next.finally(() => {
if (advanceChain.get(battleId) === next) advanceChain.delete(battleId);
});
return next;
}
async function advanceLocalLaneInner(battleId: string): Promise<void> {
const battle = await loadBattle(battleId);
if (!battle || battle.status !== 'running') return;
const contestants = await loadContestants(battleId);
const slots: ContestantSlot[] = contestants.map((c) => ({
id: c.id,
lane: c.lane,
status: c.status,
}));
// Nothing to do if the local lane is still busy.
const localRunning = slots.some((c) => c.lane === 'local' && c.status === 'running');
if (localRunning) return;
const nextId = nextLocalContestant(slots);
if (!nextId) return; // local queue is exhausted
const next = contestants.find((c) => c.id === nextId)!;
await dispatchContestant(battleId, battle.project_id, battle.battle_type, battle.prompt, {
id: next.id,
identity: next.identity,
model: next.model,
lane: next.lane,
});
}
// ─── handleTaskTerminal ───────────────────────────────────────────────────
function handleTaskTerminal(taskId: string, state: string): void {
void (async () => {
// Look up which contestant owns this task (contestants_task_id_idx).
const [row] = await sql<ContestantRow[]>`
SELECT id, battle_id, identity, model, lane, task_id, worktree_id, status
FROM contestants WHERE task_id = ${taskId}
`;
if (!row) return; // not an arena task — ignore
if (row.status !== 'running') return; // already settled (idempotent)
const battle = await loadBattle(row.battle_id);
// Pull the task row for benchmark + output.
const [task] = await sql<{
chat_id: string | null;
started_at: Date | null;
ended_at: Date | null;
cost_tokens: number | null;
}[]>`SELECT chat_id, started_at, ended_at, cost_tokens FROM tasks WHERE id = ${taskId}`;
const endedAt = task?.ended_at ?? new Date();
if (state === 'completed') {
const startedAt = task?.started_at ?? endedAt;
const bench = computeBenchmark(startedAt, endedAt, task?.cost_tokens ?? null, row.lane);
const output = task?.chat_id ? await readChatOutput(task.chat_id) : '';
const resultPath = battle
? await writeContestantResults(battle, row, output, bench).catch((err) => {
log.warn({ err: errMsg(err), contestantId: row.id }, 'arena-runner: result write failed');
return null;
})
: null;
await sql`
UPDATE contestants
SET status = 'done',
duration_ms = ${Math.round(bench.durationMs)},
tokens_per_sec = ${bench.tokensPerSec},
cost_tokens = ${task?.cost_tokens ?? null},
result_path = ${resultPath},
updated_at = clock_timestamp()
WHERE id = ${row.id} AND status = 'running'
`;
teardownDeltaBridge(row.id);
// Check if this was the last contestant.
const allContestants = await loadContestants(row.battle_id);
const battleDone = isBattleComplete(allContestants);
publishContestantFrame(row.battle_id, row.id, {
status: 'done',
duration_ms: Math.round(bench.durationMs),
...(bench.tokensPerSec !== null ? { tokens_per_sec: bench.tokensPerSec } : {}),
...(battleDone ? { battle_status: 'completed' } : {}),
});
if (battleDone) {
await completeBattle(row.battle_id);
} else if (row.lane === 'local') {
void advanceLocalLane(row.battle_id);
}
} else {
// failed or cancelled — the contest continues; this contestant is error.
const errorMsg = state === 'cancelled' ? 'cancelled' : `task ${state}`;
await sql`
UPDATE contestants
SET status = 'error', error = ${errorMsg}, updated_at = clock_timestamp()
WHERE id = ${row.id} AND status = 'running'
`;
teardownDeltaBridge(row.id);
const allContestants = await loadContestants(row.battle_id);
const battleDone = isBattleComplete(allContestants);
publishContestantFrame(row.battle_id, row.id, {
status: 'error',
error: errorMsg,
...(battleDone ? { battle_status: 'completed' } : {}),
});
if (battleDone) {
await completeBattle(row.battle_id);
} else if (row.lane === 'local') {
void advanceLocalLane(row.battle_id);
}
}
})().catch((err) => {
log.error({ err: errMsg(err), taskId }, 'arena-runner: handleTaskTerminal failed');
});
}
// ─── battle finalization ──────────────────────────────────────────────────
async function completeBattle(battleId: string): Promise<void> {
const updated = await sql`
UPDATE battles SET status = 'completed', updated_at = clock_timestamp()
WHERE id = ${battleId} AND status = 'running'
`;
if (updated.count === 0) return; // already terminal (race guard)
log.info({ battleId }, 'arena-runner: battle completed');
// Update manifest with finished_at timestamp.
const completedBattle = await loadBattle(battleId);
if (completedBattle?.results_path) {
const contestants = await loadContestants(battleId);
await writeManifest(
battleId,
completedBattle.results_path,
completedBattle.battle_type,
completedBattle.prompt,
completedBattle.created_at,
contestants.map((c) => ({ identity: c.identity, model: c.model, lane: c.lane })),
new Date(),
).catch((err) => {
log.warn({ err: errMsg(err), battleId }, 'arena-runner: manifest update failed');
});
}
onBattleComplete(battleId);
}
// ─── manifest writer ─────────────────────────────────────────────────────
async function writeManifest(
battleId: string,
resultsPath: string,
battleType: BattleType,
prompt: string,
createdAt: Date,
contestants: Array<{ identity: string; model: string; lane: ContestantLane }>,
finishedAt: Date | null,
): Promise<void> {
await mkdir(resultsPath, { recursive: true });
const manifest = {
id: battleId,
battle_type: battleType,
prompt,
contestants,
created_at: createdAt.toISOString(),
finished_at: finishedAt?.toISOString() ?? null,
};
await writeFile(join(resultsPath, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
}
// ─── results writer ───────────────────────────────────────────────────────
async function writeContestantResults(
battle: BattleRow,
contestant: { identity: string; model: string; lane: ContestantLane; worktree_id: string | null },
output: string,
bench: { durationMs: number; tokensPerSec: number | null },
): Promise<string> {
const resultsPath = await getOrBuildResultsPath(battle);
if (!resultsPath) throw new Error('cannot resolve results path for battle ' + battle.id);
const contestantDir = buildContestantDir(contestant.identity, contestant.model);
const dir = join(resultsPath, contestantDir);
await mkdir(dir, { recursive: true });
const benchLines = [
`duration: ${bench.durationMs}ms`,
bench.tokensPerSec != null ? `tokens/sec: ${bench.tokensPerSec.toFixed(1)}` : null,
]
.filter(Boolean)
.join('\n');
const resultMd =
`# ${contestant.identity} / ${contestant.model}\n\n` +
`## Benchmark\n\n${benchLines}\n\n` +
`## Output\n\n${output}\n`;
await writeFile(join(dir, 'result.md'), resultMd, 'utf8');
if (battle.battle_type === 'coding' && contestant.worktree_id) {
const [wt] = await sql<{ path: string; base_commit: string | null }[]>`
SELECT path, base_commit FROM worktrees WHERE id = ${contestant.worktree_id}
`;
if (wt) {
const [proj] = await sql<{ path: string }[]>`
SELECT path FROM projects WHERE id = ${battle.project_id}
`;
if (proj) {
const diff = await diffWorktree(wt.path, proj.path, {
baseRef: wt.base_commit ?? undefined,
}).catch(() => '');
await writeFile(join(dir, 'diff.patch'), diff, 'utf8');
}
}
}
return dir;
}
/** Resolve or rebuild results_path for a battle (handles crash-before-UPDATE). */
async function getOrBuildResultsPath(battle: BattleRow): Promise<string | null> {
if (battle.results_path) return battle.results_path;
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${battle.project_id}`;
if (!proj) return null;
const slug = buildBattleSlug(battle.id, battle.battle_type, battle.created_at);
const resultsPath = join(proj.path, 'Arena', slug);
await sql`
UPDATE battles SET results_path = ${resultsPath}, updated_at = clock_timestamp()
WHERE id = ${battle.id}
`;
return resultsPath;
}
// ─── helpers ──────────────────────────────────────────────────────────────
async function readChatOutput(chatId: string): Promise<string> {
const [m] = await sql<{ content: string | null }[]>`
SELECT content FROM messages
WHERE chat_id = ${chatId} AND role = 'assistant'
ORDER BY created_at DESC LIMIT 1
`;
return m?.content ?? '';
}
async function loadBattle(battleId: string): Promise<BattleRow | null> {
const [b] = await sql<BattleRow[]>`
SELECT id, project_id, battle_type, prompt, status, results_path, created_at
FROM battles WHERE id = ${battleId}
`;
return b ?? null;
}
async function loadContestants(battleId: string): Promise<ContestantRow[]> {
return sql<ContestantRow[]>`
SELECT id, battle_id, identity, model, lane, task_id, worktree_id, status
FROM contestants WHERE battle_id = ${battleId}
ORDER BY created_at ASC
`;
}
function publishContestantFrame(
battleId: string,
contestantId: string,
extra: Record<string, unknown>,
): void {
publishUser({
type: 'contestant_updated',
battle_id: battleId,
contestant_id: contestantId,
...extra,
});
}
// ─── initResume ───────────────────────────────────────────────────────────
async function initResume(): Promise<void> {
const battles = await sql<BattleRow[]>`
SELECT id, project_id, battle_type, prompt, status, results_path, created_at
FROM battles WHERE status = 'running'
`;
if (battles.length === 0) return;
log.info({ count: battles.length }, 'arena-runner: resuming in-flight battles on startup');
for (const battle of battles) {
await resumeBattle(battle).catch((err) => {
log.error({ err: errMsg(err), battleId: battle.id }, 'arena-runner: initResume failed for battle');
});
}
}
async function resumeBattle(battle: BattleRow): Promise<void> {
const contestants = await loadContestants(battle.id);
const taskIds = contestants.map((c) => c.task_id).filter((id): id is string => id !== null);
const taskStates = new Map<string, string>();
if (taskIds.length > 0) {
const tasks = await sql<{ id: string; state: string }[]>`
SELECT id, state FROM tasks WHERE id = ANY(${taskIds})
`;
for (const t of tasks) taskStates.set(t.id, t.state);
}
const decisions = reconcileContestants(
contestants.map((c) => ({ contestantId: c.id, taskId: c.task_id, status: c.status })),
taskStates,
);
for (const decision of decisions) {
if (decision.action === 'keep') continue;
const contestant = contestants.find((c) => c.id === decision.contestantId)!;
await applyResumeDecision(battle, contestant, decision.action);
}
// Re-check completion after applying decisions.
const updated = await loadContestants(battle.id);
if (isBattleComplete(updated)) {
await completeBattle(battle.id);
} else {
// Advance local lane in case a slot opened up.
void advanceLocalLane(battle.id);
}
log.info({ battleId: battle.id }, 'arena-runner: battle resumed');
}
async function applyResumeDecision(
battle: BattleRow,
contestant: ContestantRow,
action: ContestantResumeAction,
): Promise<void> {
switch (action) {
case 'keep': break;
case 'mark-done': {
const taskRow = contestant.task_id
? (await sql<{ started_at: Date | null; ended_at: Date | null; cost_tokens: number | null; chat_id: string | null }[]>`
SELECT started_at, ended_at, cost_tokens, chat_id FROM tasks WHERE id = ${contestant.task_id}`)[0]
: null;
const endedAt = taskRow?.ended_at ?? new Date();
const startedAt = taskRow?.started_at ?? endedAt;
const bench = computeBenchmark(startedAt, endedAt, taskRow?.cost_tokens ?? null, contestant.lane);
const output = taskRow?.chat_id ? await readChatOutput(taskRow.chat_id) : '';
const resultPath = battle
? await writeContestantResults(battle, contestant, output, bench).catch((err) => {
log.warn({ err: errMsg(err), contestantId: contestant.id }, 'arena-runner: resume result write failed');
return null;
})
: null;
await sql`
UPDATE contestants
SET status = 'done',
duration_ms = ${Math.round(bench.durationMs)},
tokens_per_sec = ${bench.tokensPerSec},
result_path = ${resultPath},
updated_at = clock_timestamp()
WHERE id = ${contestant.id}
`;
break;
}
case 'mark-error':
await sql`
UPDATE contestants
SET status = 'error', error = 'task failed before callback',
updated_at = clock_timestamp()
WHERE id = ${contestant.id}
`;
break;
case 'mark-cancelled':
await sql`
UPDATE contestants
SET status = 'error', error = 'cancelled before callback',
updated_at = clock_timestamp()
WHERE id = ${contestant.id}
`;
break;
case 're-dispatch': {
const { taskId } = await dispatch({
projectId: battle.project_id,
contestantId: contestant.id,
prompt: battle.prompt,
identity: contestant.identity,
model: contestant.model,
battleType: battle.battle_type,
});
await sql`
UPDATE contestants
SET task_id = ${taskId}, updated_at = clock_timestamp()
WHERE id = ${contestant.id}
`;
log.info(
{ battleId: battle.id, contestantId: contestant.id, taskId },
'arena-runner: contestant re-dispatched on resume',
);
break;
}
}
}
// ─── cancelBattle ─────────────────────────────────────────────────────────
async function cancelBattle(battleId: string): Promise<{ cancelled: boolean; taskIds: string[] }> {
const updated = await sql`
UPDATE battles SET status = 'cancelled', updated_at = clock_timestamp()
WHERE id = ${battleId} AND status = 'running'
`;
if (updated.count === 0) return { cancelled: false, taskIds: [] };
// Mark all non-terminal contestants cancelled and collect in-flight task_ids.
const contestants = await sql<{ id: string; task_id: string | null; status: string }[]>`
SELECT id, task_id, status FROM contestants
WHERE battle_id = ${battleId} AND status NOT IN ('done', 'error')
`;
if (contestants.length > 0) {
await sql`
UPDATE contestants
SET status = 'error', error = 'battle cancelled', updated_at = clock_timestamp()
WHERE battle_id = ${battleId} AND status NOT IN ('done', 'error')
`;
for (const c of contestants) {
publishContestantFrame(battleId, c.id, {
status: 'error',
error: 'battle cancelled',
battle_status: 'cancelled',
});
}
}
const taskIds = contestants
.filter(
(c): c is typeof c & { task_id: string } =>
c.task_id !== null && c.status === 'running',
)
.map((c) => c.task_id);
log.info({ battleId }, 'arena-runner: battle cancelled by request');
return { cancelled: true, taskIds };
}
// ─── triggerAnalysis (Phase 5 seam) ──────────────────────────────────────
async function triggerAnalysis(battleId: string): Promise<{ triggered: boolean }> {
const battle = await loadBattle(battleId);
if (!battle) return { triggered: false };
log.info({ battleId }, 'arena-runner: triggerAnalysis requested');
// Calls the injected onBattleComplete seam — Phase 5 replaces this with the
// real two-stage digest→judge analyzer (see ADR 0002 + plan Phase 5).
onBattleComplete(battleId);
return { triggered: true };
}
// ─── startCrossExam (Phase 5 seam) ───────────────────────────────────────
async function startCrossExam(
battleId: string,
opts: { identity: string; model: string },
): Promise<{ crossExamId: string }> {
const [row] = await sql<{ id: string }[]>`
INSERT INTO cross_examinations (battle_id, identity, model)
VALUES (${battleId}, ${opts.identity}, ${opts.model})
RETURNING id
`;
const crossExamId = row!.id;
log.info({ battleId, crossExamId, ...opts }, 'arena-runner: cross-exam inserted, triggering analyzer');
if (onCrossExamStart) {
try {
onCrossExamStart({ battleId, crossExamId, identity: opts.identity, model: opts.model });
} catch (err) {
log.error({ err: err instanceof Error ? err.message : String(err), battleId, crossExamId }, 'arena-runner: onCrossExamStart threw');
}
}
return { crossExamId };
}
// ─── setWinner (user override) ────────────────────────────────────────────
async function setWinner(
battleId: string,
winnerId: string | null,
): Promise<{ ok: boolean; notFound?: boolean; invalidContestant?: boolean }> {
const [row] = await sql<{ id: string }[]>`SELECT id FROM battles WHERE id = ${battleId}`;
if (!row) return { ok: false, notFound: true };
if (winnerId !== null) {
const [c] = await sql<{ id: string }[]>`
SELECT id FROM contestants WHERE id = ${winnerId} AND battle_id = ${battleId}
`;
if (!c) return { ok: false, invalidContestant: true };
}
await sql`
UPDATE battles SET winner_contestant_id = ${winnerId}, updated_at = clock_timestamp()
WHERE id = ${battleId}
`;
publishUser({ type: 'battle_updated', battle_id: battleId, winner_contestant_id: winnerId });
return { ok: true };
}
return { startBattle, handleTaskTerminal, initResume, cancelBattle, triggerAnalysis, startCrossExam, setWinner };
}
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}

View File

@@ -12,12 +12,48 @@ import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import type { AgentCommand } from './provider-types.js'; import type { AgentCommand } from './provider-types.js';
/** Minimal frontmatter reader — single-line `key: value` between `---` fences. */ /**
* Frontmatter reader between `---` fences. Handles single-line `key: value`
* AND YAML block scalars (`key: >` folded / `key: |` literal) whose value
* spans the following more-indented lines — the shape most plugin SKILL.md
* descriptions use (`description: >`).
*/
function frontmatterField(content: string, field: string): string | undefined { function frontmatterField(content: string, field: string): string | undefined {
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!block?.[1]) return undefined; if (!block?.[1]) return undefined;
const m = block[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm')); const lines = block[1].split(/\r?\n/);
return m?.[1]?.trim().replace(/^["']|["']$/g, '') || undefined; const keyRe = new RegExp(`^(\\s*)${field}:\\s*(.*)$`);
for (let i = 0; i < lines.length; i++) {
const m = lines[i]?.match(keyRe);
if (!m) continue;
const keyIndent = (m[1] ?? '').length;
const inline = (m[2] ?? '').trim();
// Block scalar: `>` (folded) or `|` (literal), optional chomping `+`/`-`.
if (/^[>|][+-]?$/.test(inline)) {
const folded = inline[0] === '>';
const body: string[] = [];
for (let j = i + 1; j < lines.length; j++) {
const line = lines[j] ?? '';
if (line.trim() === '') {
body.push('');
continue;
}
const indent = line.length - line.trimStart().length;
if (indent <= keyIndent) break; // dedent ends the block
body.push(line.slice(keyIndent + 1));
}
const joined = folded
? body
.map((l) => l.trim())
.join(' ')
.replace(/\s+/g, ' ')
.trim()
: body.join('\n').replace(/\n+$/, '');
return joined || undefined;
}
return inline.replace(/^["']|["']$/g, '').trim() || undefined;
}
return undefined;
} }
function readCommandDir(dir: string): AgentCommand[] { function readCommandDir(dir: string): AgentCommand[] {

View File

@@ -4,6 +4,7 @@ import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames'; import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js'; import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
import { asPermissionMode } from './tools/types.js';
import { createCheckpoint } from './checkpoints.js'; import { createCheckpoint } from './checkpoints.js';
import { makeDcpStreamStripper } from './dcp-strip.js'; import { makeDcpStreamStripper } from './dcp-strip.js';
import { dispatchViaAcp } from './acp-dispatch.js'; import { dispatchViaAcp } from './acp-dispatch.js';
@@ -31,7 +32,13 @@ import {
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js'; import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
interface InferenceRunner { interface InferenceRunner {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void; enqueue: (
sessionId: string,
chatId: string,
assistantId: string,
user: string,
permissionMode?: 'plan' | 'ask' | 'bypass',
) => void;
cancel: (sessionId: string, chatId: string) => Promise<boolean>; cancel: (sessionId: string, chatId: string) => Promise<boolean>;
hasActive: (chatId: string) => boolean; hasActive: (chatId: string) => boolean;
} }
@@ -305,10 +312,13 @@ export function createDispatcher(deps: Deps): {
// ─── Path A: Native Inference ─────────────────────────────────────────────── // ─── Path A: Native Inference ───────────────────────────────────────────────
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }): Promise<void> { async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; mode_id: string | null; session_id: string | null }): Promise<void> {
const taskId = task.id; const taskId = task.id;
log.info({ taskId }, 'dispatcher: starting task (path A — native)'); log.info({ taskId }, 'dispatcher: starting task (path A — native)');
// Declared before try so the catch block can write it back on the task row.
let chatId: string | null = null;
try { try {
// Mark running // Mark running
await sql` await sql`
@@ -317,26 +327,29 @@ export function createDispatcher(deps: Deps): {
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
// Create session + chat for this task // Session setup: reuse a pre-created session (e.g. Q&A arena contestants
// whose persona is stamped on the session via agent_id) or create a fresh one.
const model = task.model ?? config.DEFAULT_MODEL; const model = task.model ?? config.DEFAULT_MODEL;
const sessionName = 'Task: ' + task.input.slice(0, 40); let sessionId: string;
if (task.session_id) {
const [session] = await sql<{ id: string }[]>` sessionId = task.session_id;
INSERT INTO sessions (project_id, name, model, status) } else {
VALUES (${task.project_id}, ${sessionName}, ${model}, 'open') const sessionName = 'Task: ' + task.input.slice(0, 40);
RETURNING id const [session] = await sql<{ id: string }[]>`
`; INSERT INTO sessions (project_id, name, model, status)
const sessionId = session!.id; VALUES (${task.project_id}, ${sessionName}, ${model}, 'open')
RETURNING id
`;
sessionId = session!.id;
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
}
const [chat] = await sql<{ id: string }[]>` const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status) INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'Task execution', 'open') VALUES (${sessionId}, 'Task execution', 'open')
RETURNING id RETURNING id
`; `;
const chatId = chat!.id; chatId = chat!.id;
// Link task to session
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
// Create user message + streaming assistant // Create user message + streaming assistant
await sql<{ id: string }[]>` await sql<{ id: string }[]>`
@@ -351,8 +364,9 @@ export function createDispatcher(deps: Deps): {
`; `;
const assistantId = assistantMsg!.id; const assistantId = assistantMsg!.id;
// Enqueue inference // Enqueue inference — pass the native permission gate (plan/ask/bypass)
inference.enqueue(sessionId, chatId, assistantId, 'default'); // through to the write-tool context. Non-unified mode ids → undefined.
inference.enqueue(sessionId, chatId, assistantId, 'default', asPermissionMode(task.mode_id));
// Wait for inference to complete (poll message status) // Wait for inference to complete (poll message status)
const finalStatus = await waitForCompletion(assistantId); const finalStatus = await waitForCompletion(assistantId);
@@ -381,7 +395,7 @@ export function createDispatcher(deps: Deps): {
const summary = (msg?.content ?? '').slice(0, 500); const summary = (msg?.content ?? '').slice(0, 500);
await sql` await sql`
UPDATE tasks UPDATE tasks
SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens} SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}, chat_id = ${chatId}
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)'); log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
@@ -392,7 +406,7 @@ export function createDispatcher(deps: Deps): {
const summary = (msg?.content ?? 'Inference failed').slice(0, 500); const summary = (msg?.content ?? 'Inference failed').slice(0, 500);
await sql` await sql`
UPDATE tasks UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens} SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}, chat_id = ${chatId}
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)'); log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
@@ -402,7 +416,7 @@ export function createDispatcher(deps: Deps): {
log.error({ taskId, err: errMsg }, 'dispatcher: task error (native)'); log.error({ taskId, err: errMsg }, 'dispatcher: task error (native)');
await sql` await sql`
UPDATE tasks UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)} SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}, chat_id = ${chatId}
WHERE id = ${taskId} WHERE id = ${taskId}
`.catch(() => {}); `.catch(() => {});
} }

View File

@@ -0,0 +1,47 @@
// edit-guards-imports — detects dropped imports in edited files.
// Ported from opencode-morph-fast-apply (MIT).
export interface ImportCheckResult {
ok: boolean;
missingImports: string[];
reason?: string;
}
const IMPORT_PATTERNS = [
/^import\s+(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"][^'"]+['"]\s*;?$/m,
/^import\s+['"][^'"]+['"]\s*;?$/m,
/^export\s+.*\s+from\s+['"][^'"]+['"]\s*;?$/m,
/^require\s*\(\s*['"][^'"]+['"]\s*\)\s*;?$/m,
/^import\s+type\s+\{[^}]*\}\s+from\s+['"][^'"]+['"]\s*;?$/m,
];
function extractImportLines(content: string): string[] {
return content.split('\n').filter((line) =>
IMPORT_PATTERNS.some((p) => p.test(line.trim())),
);
}
export function checkDroppedImports(
original: string,
updated: string,
filePath: string,
): ImportCheckResult {
const originalImports = extractImportLines(original);
const updatedImports = extractImportLines(updated);
if (originalImports.length === 0) {
return { ok: true, missingImports: [] };
}
const missing = originalImports.filter((imp) => !updatedImports.includes(imp));
if (missing.length > 0 && originalImports.length > 0) {
return {
ok: false,
missingImports: missing,
reason: `Edit would drop ${missing.length} import(s) from ${filePath}`,
};
}
return { ok: true, missingImports: [] };
}

View File

@@ -0,0 +1,42 @@
// v2.8 Morph safety guards — prevents catastrophic truncation, marker leakage,
// and accidental import deletion during native edit_file application.
// Ported from opencode-morph-fast-apply (MIT) with threshold values preserved.
export interface GuardResult {
ok: boolean;
reason?: string;
charLoss?: number;
lineLoss?: number;
}
const TRUNCATION_CHAR_THRESHOLD = 0.6;
const TRUNCATION_LINE_THRESHOLD = 0.5;
export function validateEditResult(
original: string,
updated: string,
filePath: string,
): GuardResult {
// Check for catastrophic content truncation
if (original.length > 0 && updated.length > 0) {
const charLoss = 1 - updated.length / original.length;
const originalLines = original.split('\n').length;
const updatedLines = updated.split('\n').length;
const lineLoss = 1 - updatedLines / originalLines;
if (charLoss > TRUNCATION_CHAR_THRESHOLD && lineLoss > TRUNCATION_LINE_THRESHOLD) {
return {
ok: false,
reason: `Edit would truncate ${Math.round(charLoss * 100)}% of characters and ${Math.round(lineLoss * 100)}% of lines`,
charLoss,
lineLoss,
};
}
}
return { ok: true };
}
export function formatGuardError(guard: GuardResult, filePath: string): string {
return `Edit guard rejected change to ${filePath}: ${guard.reason ?? 'unknown error'}`;
}

View File

@@ -0,0 +1,23 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
const ARTIFACTS_ROOT = 'data/flow-artifacts';
export function getArtifactPath(flowRunId: string, stepId: string): string {
return join(ARTIFACTS_ROOT, flowRunId, `${stepId}.md`);
}
export async function writeFlowArtifact(
flowRunId: string,
stepId: string,
content: string,
): Promise<string> {
const dir = join(ARTIFACTS_ROOT, flowRunId);
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
const path = getArtifactPath(flowRunId, stepId);
await writeFile(path, content, 'utf8');
return path;
}

View File

@@ -22,7 +22,7 @@
* "Settled" = done skipped excluded. Only settled deps unblock a step; * "Settled" = done skipped excluded. Only settled deps unblock a step;
* an inFlight dep does NOT (the runner waits for its terminal callback). * an inFlight dep does NOT (the runner waits for its terminal callback).
*/ */
import type { Flow, Step, StepContext } from '../conductor/types.js'; import type { Flow, Step, StepContext, TriggerRule } from '../conductor/types.js';
export interface SchedulerState { export interface SchedulerState {
/** step ids that completed successfully (results available) */ /** step ids that completed successfully (results available) */
@@ -62,7 +62,7 @@ export function readySteps(flow: Flow, state: SchedulerState): Step[] {
!state.skipped.has(s.id) && !state.skipped.has(s.id) &&
!state.inFlight.has(s.id) && !state.inFlight.has(s.id) &&
!state.excluded.has(s.id) && !state.excluded.has(s.id) &&
(s.deps ?? []).every((d) => isSatisfied(state, d)), ((s.deps ?? []).length === 0 || evaluateTriggerRule(s.deps ?? [], state.done, state.skipped, state.excluded, s.trigger_rule)),
); );
} }
@@ -167,6 +167,32 @@ export function shouldFailOnMissingAgent(agent: string, modeId: string | null):
return agent === 'qwen' && modeId === 'plan'; return agent === 'qwen' && modeId === 'plan';
} }
/**
* Evaluate a trigger rule against dependency results.
* - all_success: every dep must be done (not skipped/failed)
* - one_success: at least one dep must be done
* - all_done: every dep must be settled regardless of outcome
*/
export function evaluateTriggerRule(
deps: string[],
done: ReadonlySet<string>,
skipped: ReadonlySet<string>,
excluded: ReadonlySet<string>,
rule?: TriggerRule,
): boolean {
if (deps.length === 0) return true;
const satisfied = new Set([...done, ...skipped, ...excluded]);
switch (rule ?? 'all_success') {
case 'all_success':
return deps.every((d) => done.has(d) || skipped.has(d) || excluded.has(d));
case 'one_success':
return deps.some((d) => done.has(d));
case 'all_done':
return deps.every((d) => satisfied.has(d));
}
}
/** /**
* Reconcile every step of an in-flight run for startup resume. Returns one * Reconcile every step of an in-flight run for startup resume. Returns one
* decision per step. Pure — no IO. * decision per step. Pure — no IO.

View File

@@ -346,6 +346,20 @@ export function createFlowRunner(deps: Deps): FlowRunner {
continue; // re-evaluate — code output can unblock the next wave continue; // re-evaluate — code output can unblock the next wave
} }
// Approval gate steps: pause and wait for human decision.
const approvalReady = toRun.filter((s) => s.kind === 'approval');
if (approvalReady.length > 0) {
for (const s of approvalReady) {
await sql`
UPDATE flow_steps SET status = 'blocked', updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${s.id}
`;
await appendStepEvent(sql, runId, s.id, 'paused', { reason: 'awaiting approval' });
publishStep(runId, s.id, 'blocked');
}
return;
}
// Only agent steps remain ready → dispatch the whole parallel wave, then wait. // Only agent steps remain ready → dispatch the whole parallel wave, then wait.
for (const s of toRun) { for (const s of toRun) {
await dispatchAgentStep(runId, run.project_id, model, s, ctx); await dispatchAgentStep(runId, run.project_id, model, s, ctx);
@@ -378,7 +392,8 @@ export function createFlowRunner(deps: Deps): FlowRunner {
// flow's step.run already bakes in the evidence/YAGNI contracts. // flow's step.run already bakes in the evidence/YAGNI contracts.
const persona = step.agent ? await loadPersona(step.agent) : ''; const persona = step.agent ? await loadPersona(step.agent) : '';
const taskPrompt = await step.run(ctx); const taskPrompt = await step.run(ctx);
const fullPrompt = persona ? `${persona}\n\n---\n\n${taskPrompt}` : taskPrompt; const resolvedPrompt = resolveVariables(taskPrompt, ctx.results);
const fullPrompt = persona ? `${persona}\n\n---\n\n${resolvedPrompt}` : resolvedPrompt;
// READ-ONLY (D-4): agent='qwen', mode_id='plan' are hardcoded, never // READ-ONLY (D-4): agent='qwen', mode_id='plan' are hardcoded, never
// user-overridable. The dispatcher's qwen+plan rule forces the PTY hard gate. // user-overridable. The dispatcher's qwen+plan rule forces the PTY hard gate.
@@ -392,6 +407,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
SET task_id = ${task!.id}, status = 'running', input = ${fullPrompt}, updated_at = clock_timestamp() SET task_id = ${task!.id}, status = 'running', input = ${fullPrompt}, updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${step.id} WHERE run_id = ${runId} AND step_id = ${step.id}
`; `;
await appendStepEvent(sql, runId, step.id, 'started', { taskId: task!.id });
} }
/** /**
@@ -438,6 +454,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
WHERE run_id = ${runId} AND step_id = ${stepId} WHERE run_id = ${runId} AND step_id = ${stepId}
`; `;
} }
await appendStepEvent(sql, runId, stepId, status, output ? { outputLength: output.length } : undefined);
} }
// ─── run completion ───────────────────────────────────────────────────────── // ─── run completion ─────────────────────────────────────────────────────────
@@ -483,6 +500,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
if (updated.count === 0) return; if (updated.count === 0) return;
const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run'); const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run');
log.warn({ runId, error }, 'flow-runner: run failed'); log.warn({ runId, error }, 'flow-runner: run failed');
await appendStepEvent(sql, runId, stepId, 'failed', { error });
publishStep(runId, stepId, 'failed', { run_status: 'failed' }); publishStep(runId, stepId, 'failed', { run_status: 'failed' });
} }
@@ -522,7 +540,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
function publishStep( function publishStep(
runId: string, runId: string,
stepId: string, stepId: string,
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled', status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'blocked',
extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string }, extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string },
): void { ): void {
publishUser({ publishUser({
@@ -763,3 +781,40 @@ export function createFlowRunner(deps: Deps): FlowRunner {
function errMsg(e: unknown): string { function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e); return e instanceof Error ? e.message : String(e);
} }
// ─── Event log ───────────────────────────────────────────────────────────────
async function appendStepEvent(
sql: Sql,
runId: string,
stepId: string,
event: string,
payload?: Record<string, unknown>,
): Promise<void> {
await sql`
INSERT INTO flow_step_events (run_id, step_id, event, payload)
VALUES (${runId}, ${stepId}, ${event}, ${payload ? sql.json(payload as never) : null})
`;
}
// ─── Variable substitution ───────────────────────────────────────────────────
const VAR_PATTERN = /\$(\w+)\.output(?:\.(\w+(?:\.\w+)*))?/g;
export function resolveVariables(prompt: string, results: Record<string, string>): string {
return prompt.replace(VAR_PATTERN, (match, stepId, fieldPath) => {
const output = results[stepId];
if (!output) return match;
if (!fieldPath) return output;
try {
const lines = output.split('\n');
for (const line of lines) {
const parsed = line.match(new RegExp(`^${fieldPath}:\\s*(.+)$`, 'i'));
if (parsed) return parsed[1]!.trim();
}
} catch {
// fall through
}
return match;
});
}

View File

@@ -21,7 +21,16 @@
// punctuation to ASCII on both sides; the match is // punctuation to ASCII on both sides; the match is
// mapped back to original offsets. // mapped back to original offsets.
// 4. levenshtein — best line-window by normalized edit-distance // 4. levenshtein — best line-window by normalized edit-distance
// similarity; accepted only at >= SIMILARITY_THRESHOLD. // similarity; accepted only at >= SIMILARITY_THRESHOLD,
// anchored on an exact first+last line for multi-line
// needles, and REFUSED (ambiguous) when a second window
// scores within AMBIGUITY_EPSILON of the best. Like the
// exact/whitespace tiers, this tier fails CLOSED — it
// never splices over a merely-plausible guess, because a
// wrong-window splice corrupts the file (it leaves the
// real target intact and duplicates it). This mirrors
// opencode/cline/qwen, whose fuzzy tiers all keep the
// unique-match requirement rather than picking a winner.
// //
// Pure and dependency-free (Levenshtein is the standard iterative two-row DP), // Pure and dependency-free (Levenshtein is the standard iterative two-row DP),
// reimplemented from the general technique — no vendored source. // reimplemented from the general technique — no vendored source.
@@ -31,8 +40,31 @@ export type MatchResult =
| { kind: 'ambiguous'; count: number } | { kind: 'ambiguous'; count: number }
| { kind: 'not_found' }; | { kind: 'not_found' };
/** Levenshtein similarity floor for the final fuzzy fallback (strategy 4). */ /**
export const SIMILARITY_THRESHOLD = 0.66; * Levenshtein similarity floor for the final fuzzy fallback (strategy 4).
* 0.66 was far too low — at two-thirds similarity a structurally-wrong window
* (e.g. one of three near-identical form blocks) clears the bar and gets spliced
* over, leaving the real target intact and duplicated. Competent agents anchor
* far tighter (opencode's BlockAnchor needs an exact anchor; cline needs exact
* first+last lines). 0.85 keeps genuine quantized-model drift (a typo, an indent
* shift) while refusing a different block.
*/
export const SIMILARITY_THRESHOLD = 0.85;
/**
* If a second candidate window scores within this of the best, the match is
* ambiguous and tier 4 refuses rather than guessing — the same fail-closed
* stance the exact and whitespace tiers take on multiple hits. Repetitive files
* (the duplicate-block corruption case) produce near-tied windows; this is what
* turns that into a clean "add more context" error instead of a wrong splice.
*/
export const AMBIGUITY_EPSILON = 0.05;
/** Multi-line needles at or above this length must anchor on an exact (after
* trim + unicode-fold) first AND last line before similarity is even scored —
* the cline/opencode block-anchor rule. Below it, threshold + uniqueness alone
* guard the match. */
const ANCHOR_MIN_LINES = 3;
export function locateMatch(content: string, needle: string): MatchResult { export function locateMatch(content: string, needle: string): MatchResult {
// Empty needle has no meaningful match. // Empty needle has no meaningful match.
@@ -252,20 +284,39 @@ function locateByLevenshtein(content: string, needle: string): MatchResult | nul
const needleJoined = needleLines.map((l) => l.trim()).join('\n'); const needleJoined = needleLines.map((l) => l.trim()).join('\n');
let best = -1; // Block-anchor gate for multi-line needles: the first and last lines must match
let bestSpan: { start: number; end: number } | null = null; // exactly (after trim + unicode-fold) or the window is not even scored. This
// stops a high interior-similarity from dragging a structurally-wrong window
// over the threshold — the failure that duplicates blocks in repetitive files.
const anchored = n >= ANCHOR_MIN_LINES;
const needleFirst = canonicalize(needleLines[0]!.trim());
const needleLast = canonicalize(needleLines[n - 1]!.trim());
const scored: Array<{ score: number; start: number; end: number }> = [];
for (let i = 0; i + n <= contentLines.length; i++) { for (let i = 0; i + n <= contentLines.length; i++) {
const window = contentLines.slice(i, i + n); const window = contentLines.slice(i, i + n);
const windowJoined = window.map((l) => l.text.trim()).join('\n'); if (anchored) {
const score = similarity(windowJoined, needleJoined); const winFirst = canonicalize(window[0]!.text.trim());
if (score > best) { const winLast = canonicalize(window[n - 1]!.text.trim());
best = score; if (winFirst !== needleFirst || winLast !== needleLast) continue;
bestSpan = { start: window[0]!.start, end: window[n - 1]!.end };
} }
const windowJoined = window.map((l) => l.text.trim()).join('\n');
scored.push({
score: similarity(windowJoined, needleJoined),
start: window[0]!.start,
end: window[n - 1]!.end,
});
} }
if (bestSpan && best >= SIMILARITY_THRESHOLD) { if (scored.length === 0) return null;
return { kind: 'fuzzy', start: bestSpan.start, end: bestSpan.end }; scored.sort((a, b) => b.score - a.score);
} const best = scored[0]!;
return null; if (best.score < SIMILARITY_THRESHOLD) return null;
// Uniqueness guard: refuse when a second window is within epsilon of the best.
// Fail closed (ambiguous) rather than silently splicing one of several lookalikes.
const tied = scored.filter((s) => s.score >= best.score - AMBIGUITY_EPSILON);
if (tied.length > 1) return { kind: 'ambiguous', count: tied.length };
return { kind: 'fuzzy', start: best.start, end: best.end };
} }

View File

@@ -0,0 +1,75 @@
import { createInterface } from 'node:readline';
import type { Readable, Writable } from 'node:stream';
interface RpcRequest {
jsonrpc: '2.0';
id: number;
method: string;
params?: unknown;
}
interface RpcResponse {
jsonrpc: '2.0';
id: number;
result?: unknown;
error?: { code: number; message: string };
}
export class LspClient {
private nextId = 1;
private pending = new Map<number, { resolve: (v: RpcResponse) => void; reject: (e: Error) => void }>();
private buffer = '';
constructor(
private stdin: Writable,
private stdout: Readable,
) {
const rl = createInterface({ input: stdout, crlfDelay: Infinity });
rl.on('line', (line) => this.handleLine(line));
}
private handleLine(line: string): void {
this.buffer += line + '\n';
const match = this.buffer.match(/Content-Length: (\d+)\r?\n\r?\n/);
if (!match || !match[1]) return;
const len = parseInt(match[1], 10);
const headerEnd = match.index! + match[0].length;
const body = this.buffer.slice(headerEnd, headerEnd + len);
if (body.length < len) return;
this.buffer = this.buffer.slice(headerEnd + len);
try {
const msg: RpcResponse = JSON.parse(body);
const cb = this.pending.get(msg.id);
if (cb) {
this.pending.delete(msg.id);
cb.resolve(msg);
}
} catch {
// Malformed JSON, ignore
}
}
async request(method: string, params?: unknown): Promise<unknown> {
const id = this.nextId++;
const req: RpcRequest = { jsonrpc: '2.0', id, method, params };
const body = JSON.stringify(req);
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
return new Promise((resolve, reject) => {
this.pending.set(id, {
resolve: (resp) => {
if (resp.error) reject(new Error(resp.error.message));
else resolve(resp.result);
},
reject,
});
this.stdin.write(header + body);
});
}
async notify(method: string, params?: unknown): Promise<void> {
const body = JSON.stringify({ jsonrpc: '2.0', method, params });
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
this.stdin.write(header + body);
}
}

View File

@@ -0,0 +1,19 @@
export interface LspServerConfig {
command: string;
args: string[];
rootPatterns: string[];
}
const TS_CONFIG: LspServerConfig = {
command: 'typescript-language-server',
args: ['--stdio'],
rootPatterns: ['package.json', 'tsconfig.json'],
};
const SUPPORTED_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs']);
export function getServerConfig(filePath: string): LspServerConfig | null {
const ext = filePath.split('.').pop()?.toLowerCase();
if (ext && SUPPORTED_EXTS.has(ext)) return TS_CONFIG;
return null;
}

View File

@@ -0,0 +1,86 @@
import type { LspClient } from './client.js';
import type { Diagnostic, Location } from './types.js';
function fileUri(filePath: string): string {
return `file://${filePath.startsWith('/') ? '' : '/'}${filePath}`;
}
export async function openDocument(
client: LspClient,
filePath: string,
content: string,
version: number = 1,
): Promise<void> {
const uri = fileUri(filePath);
await client.notify('textDocument/didOpen', {
textDocument: { uri, languageId: 'typescript', version, text: content },
});
}
export async function closeDocument(client: LspClient, filePath: string): Promise<void> {
await client.notify('textDocument/didClose', {
textDocument: { uri: fileUri(filePath) },
});
}
export async function getDiagnostics(
client: LspClient,
filePath: string,
content: string,
): Promise<Diagnostic[]> {
const uri = fileUri(filePath);
await openDocument(client, filePath, content);
const result: any = await client.request('textDocument/diagnostic', {
textDocument: { uri },
});
await closeDocument(client, filePath);
const diagnostics: Diagnostic[] = [];
if (result?.diagnostics) {
for (const d of result.diagnostics) {
diagnostics.push({
range: d.range,
severity: d.severity ?? 1,
message: d.message,
source: d.source,
});
}
}
return diagnostics;
}
export async function gotoDefinition(
client: LspClient,
filePath: string,
content: string,
line: number,
character: number,
): Promise<Location | null> {
const uri = fileUri(filePath);
await openDocument(client, filePath, content);
const result: any = await client.request('textDocument/definition', {
textDocument: { uri },
position: { line, character },
});
await closeDocument(client, filePath);
if (!result) return null;
const loc = Array.isArray(result) ? result[0] : result;
return loc ? { uri: loc.uri, range: loc.range } : null;
}
export async function findReferences(
client: LspClient,
filePath: string,
content: string,
line: number,
character: number,
): Promise<Location[]> {
const uri = fileUri(filePath);
await openDocument(client, filePath, content);
const result: any = await client.request('textDocument/references', {
textDocument: { uri },
position: { line, character },
context: { includeDeclaration: true },
});
await closeDocument(client, filePath);
return (result ?? []).map((loc: any) => ({ uri: loc.uri, range: loc.range }));
}

View File

@@ -0,0 +1,119 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import { LspClient } from './client.js';
import { getServerConfig } from './config.js';
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
const SWEEP_INTERVAL_MS = 30_000;
interface LspInstance {
client: LspClient;
proc: ChildProcess;
lastUsed: number;
timer: ReturnType<typeof setTimeout>;
}
export class LspServerManager {
private instances = new Map<string, LspInstance>();
private sweepTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
this.startSweeper();
}
private startSweeper(): void {
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
this.sweepTimer.unref?.();
}
private findProjectRoot(filePath: string): string | null {
let dir = filePath;
const config = getServerConfig(filePath);
if (!config) return null;
while (true) {
for (const pattern of config.rootPatterns) {
if (existsSync(join(dir, pattern))) return dir;
}
const parent = join(dir, '..');
if (parent === dir) return dir;
dir = parent;
}
}
async getClient(filePath: string): Promise<LspClient | null> {
const config = getServerConfig(filePath);
if (!config) return null;
const projectRoot = this.findProjectRoot(filePath);
if (!projectRoot) return null;
const existing = this.instances.get(projectRoot);
if (existing) {
existing.lastUsed = Date.now();
clearTimeout(existing.timer);
existing.timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
existing.timer.unref?.();
return existing.client;
}
return this.spawn(projectRoot, config.command, config.args);
}
private async spawn(projectRoot: string, command: string, args: string[]): Promise<LspClient> {
const proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: projectRoot });
const client = new LspClient(proc.stdin!, proc.stdout!);
await client.request('initialize', {
processId: process.pid,
rootUri: `file://${projectRoot}`,
capabilities: {
textDocument: {
diagnostic: { dynamicRegistration: false },
definition: { dynamicRegistration: false },
references: { dynamicRegistration: false },
},
},
});
await client.notify('initialized', {});
const timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
timer.unref?.();
this.instances.set(projectRoot, { client, proc, lastUsed: Date.now(), timer });
proc.on('exit', () => this.instances.delete(projectRoot));
return client;
}
private kill(projectRoot: string): void {
const inst = this.instances.get(projectRoot);
if (!inst) return;
this.instances.delete(projectRoot);
inst.proc.kill('SIGTERM');
setTimeout(() => {
if (inst.proc.exitCode === null) inst.proc.kill('SIGKILL');
}, 5000);
}
private sweep(): void {
const now = Date.now();
for (const [root, inst] of this.instances) {
if (now - inst.lastUsed > IDLE_TIMEOUT_MS) {
this.kill(root);
}
}
}
shutdown(): void {
if (this.sweepTimer) clearInterval(this.sweepTimer);
for (const root of [...this.instances.keys()]) {
this.kill(root);
}
}
getActiveCount(): number {
return this.instances.size;
}
}
export const lspManager = new LspServerManager();

View File

@@ -0,0 +1,28 @@
export interface Position {
line: number;
character: number;
}
export interface Range {
start: Position;
end: Position;
}
export interface Location {
uri: string;
range: Range;
}
export interface Diagnostic {
range: Range;
severity: number;
message: string;
source?: string;
}
export interface TextDocumentItem {
uri: string;
languageId: string;
version: number;
text: string;
}

View File

@@ -1,9 +1,120 @@
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises'; import { readFile, writeFile, unlink, mkdir, rename, realpath } from 'node:fs/promises';
import { dirname } from 'node:path'; import { dirname, join, basename } from 'node:path';
import { randomBytes } from 'node:crypto';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import { resolveWritePath } from './write_guard.js'; import { resolveWritePath } from './write_guard.js';
import { locateMatch } from './fuzzy-match.js'; import { locateMatch } from './fuzzy-match.js';
/**
* Write a file atomically: stage to a sibling temp file, then rename over the
* target. rename(2) on the same filesystem is atomic, so a crash mid-write can
* never leave a half-written (truncated/corrupt) source file — readers see
* either the old content or the complete new content. The temp lives in the same
* directory to guarantee a same-filesystem rename.
*
* Symlinks: a plain writeFile FOLLOWS a symlink and writes through to its target;
* a bare rename would REPLACE the link with a regular file. We realpath an
* existing target first so the rename lands on the real file and the link
* survives — preserving the prior follow-through behavior. A missing target
* (create, or a broken link) just writes the literal path.
*/
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
let target = filePath;
try {
target = await realpath(filePath);
} catch {
// ENOENT (new file) or broken link — write the literal path.
}
const tmp = join(dirname(target), `.${basename(target)}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`);
await writeFile(tmp, content, 'utf8');
try {
await rename(tmp, target);
} catch (err) {
await unlink(tmp).catch(() => {});
throw err;
}
}
/** Detect a file's dominant line ending so an edit can preserve it. */
function detectEol(text: string): '\r\n' | '\n' {
return text.includes('\r\n') ? '\r\n' : '\n';
}
/**
* Serialize the read-modify-write of a single file so two concurrent applies
* (e.g. two chat tabs sharing one worktree, or a Bypass write racing an
* apply_pending) can't lose an update. In-process keying is sufficient —
* BooCoder is a single Fastify process. One Map entry per distinct path.
*/
const fileLocks = new Map<string, Promise<void>>();
async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
const prev = fileLocks.get(filePath) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((r) => { release = r; });
fileLocks.set(filePath, prev.then(() => current));
await prev.catch(() => {});
try {
return await fn();
} finally {
release();
}
}
// --- Edit-apply planning (pure, unit-tested) ---------------------------------
/**
* Decision for applying one queued edit to a file's current content. Pulled out
* of `applyOne` so the splice — the part that actually corrupted files — is pure
* and testable without a DB or filesystem. Mirrors how opencode/cline/qwen keep
* their matchers fail-closed and idempotent.
*/
export type EditPlan =
| { kind: 'apply'; updated: string }
| { kind: 'noop'; reason: 'identical' | 'already-applied' }
| { kind: 'ambiguous'; count: number }
| { kind: 'not_found' };
/**
* Decide how (or whether) to apply an `old → new` edit to `content`.
*
* Idempotency is the whole point here: a queued edit can legitimately be
* re-applied (a local model re-emits the same tool call; a turn is retried; the
* same change sits in the queue twice). A naive splice stamps the new text again
* each time — the 23× block duplication. Two guards make re-application a no-op:
*
* - already-applied (anchored insert): when `new` is `old` + an appended block
* (`old="anchor"`, `new="anchor\n<block>"`), `old` still matches uniquely after
* the first apply, so a second apply would duplicate `<block>`. If the full
* `new` text is already present at the match site, the edit is already applied.
* - already-applied (old gone): if `old` can't be located but `new` is already
* in the file, the change landed on a prior pass — treat as a no-op, not an error.
* - identical: the splice would not change the file.
*
* Anything ambiguous or genuinely absent fails CLOSED so the caller surfaces a
* correctable error instead of writing a guess.
*/
export function planEdit(content: string, oldStr: string, newStr: string): EditPlan {
const match = locateMatch(content, oldStr);
if (match.kind === 'ambiguous') return { kind: 'ambiguous', count: match.count };
if (match.kind === 'not_found') {
if (newStr.length > 0 && content.includes(newStr)) {
return { kind: 'noop', reason: 'already-applied' };
}
return { kind: 'not_found' };
}
const updated = content.slice(0, match.start) + newStr + content.slice(match.end);
// No-change splice first (covers old === new), then the anchored re-stamp guard:
// the full replacement already sits at the match site (re-emitted anchored insert).
if (updated === content) return { kind: 'noop', reason: 'identical' };
if (content.slice(match.start, match.start + newStr.length) === newStr) {
return { kind: 'noop', reason: 'already-applied' };
}
return { kind: 'apply', updated };
}
// --- Types ------------------------------------------------------------------- // --- Types -------------------------------------------------------------------
export interface PendingChange { export interface PendingChange {
@@ -47,6 +158,13 @@ export async function queueEdit(
const resolved = resolveWritePath(projectRoot, filePath); const resolved = resolveWritePath(projectRoot, filePath);
const diff = JSON.stringify({ old: oldString, new: newString }); const diff = JSON.stringify({ old: oldString, new: newString });
// Idempotent queue: collapse an identical edit that is still pending. Local
// quantized models re-emit the same edit_file call within a turn, and a retried
// turn re-queues — each duplicate row would apply and stamp another copy. One
// pending row per (session, file, operation, diff) is enough.
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'edit', diff);
if (existing) return existing;
const [row] = await sql<PendingChange[]>` const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent) INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent}) VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent})
@@ -55,6 +173,28 @@ export async function queueEdit(
return row!; return row!;
} }
/** Return an identical still-pending change for this (session, file, op, diff),
* or undefined. Used to keep the queue idempotent against re-emitted edits. */
async function findPendingDuplicate(
sql: Sql,
sessionId: string,
resolvedPath: string,
operation: 'create' | 'edit' | 'delete',
diff: string,
): Promise<PendingChange | undefined> {
const [row] = await sql<PendingChange[]>`
SELECT * FROM pending_changes
WHERE session_id = ${sessionId}
AND file_path = ${resolvedPath}
AND operation = ${operation}
AND diff = ${diff}
AND status = 'pending'
ORDER BY created_at ASC
LIMIT 1
`;
return row;
}
export async function queueCreate( export async function queueCreate(
sql: Sql, sql: Sql,
sessionId: string, sessionId: string,
@@ -68,6 +208,9 @@ export async function queueCreate(
): Promise<PendingChange> { ): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath); const resolved = resolveWritePath(projectRoot, filePath);
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'create', content);
if (existing) return existing;
const [row] = await sql<PendingChange[]>` const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent) INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent}) VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent})
@@ -87,6 +230,9 @@ export async function queueDelete(
): Promise<PendingChange> { ): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath); const resolved = resolveWritePath(projectRoot, filePath);
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'delete', '');
if (existing) return existing;
const [row] = await sql<PendingChange[]>` const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent) INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent}) VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent})
@@ -110,48 +256,60 @@ export async function applyOne(
} }
try { try {
// Re-validate path in case projectRoot has shifted return await withFileLock(change.file_path, async () => {
resolveWritePath(projectRoot, change.file_path); // Re-validate path in case projectRoot has shifted
resolveWritePath(projectRoot, change.file_path);
switch (change.operation) { switch (change.operation) {
case 'create': { case 'create': {
await mkdir(dirname(change.file_path), { recursive: true }); await mkdir(dirname(change.file_path), { recursive: true });
await writeFile(change.file_path, change.diff, 'utf8'); await writeFileAtomic(change.file_path, change.diff);
break; break;
}
case 'edit': {
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
const content = await readFile(change.file_path, 'utf8');
const match = locateMatch(content, oldStr);
if (match.kind === 'ambiguous') {
throw new Error(
`old_string matches ${match.count} locations — add surrounding context to disambiguate`,
);
} }
if (match.kind === 'not_found') { case 'edit': {
throw new Error( const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
'old_string not found in file (even fuzzily) — file may have changed since the edit was queued', const raw = await readFile(change.file_path, 'utf8');
); // Normalize to LF for matching, then write back in the file's native EOL
// so an LF-emitting model doesn't leave a CRLF file with mixed endings.
const eol = detectEol(raw);
const toLf = (t: string) => t.replaceAll('\r\n', '\n');
const plan = planEdit(toLf(raw), toLf(oldStr), toLf(newStr));
if (plan.kind === 'ambiguous') {
throw new Error(
`old_string matches ${plan.count} locations — add surrounding context to disambiguate`,
);
}
if (plan.kind === 'not_found') {
throw new Error(
'old_string not found in file (even fuzzily) — file may have changed since the edit was queued',
);
}
if (plan.kind === 'apply') {
const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated;
await writeFileAtomic(change.file_path, out);
} else {
// noop: the edit is already applied (re-emitted / retried) or a no-change.
// Mark it applied without rewriting so it can't stamp a duplicate.
console.log(`[pending] edit ${change.file_path} is a no-op (${plan.reason}) — not rewriting`);
}
break;
} }
const updated = content.slice(0, match.start) + newStr + content.slice(match.end); case 'delete': {
await writeFile(change.file_path, updated, 'utf8'); // Stash current content in diff for potential rewind
break; try {
} const existing = await readFile(change.file_path, 'utf8');
case 'delete': { await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
// Stash current content in diff for potential rewind } catch {
try { // File may already be gone — proceed with status update
const existing = await readFile(change.file_path, 'utf8'); }
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`; await unlink(change.file_path);
} catch { break;
// File may already be gone — proceed with status update
} }
await unlink(change.file_path);
break;
} }
}
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`; await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true }; return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
});
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message }; return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
@@ -220,13 +378,13 @@ export async function rewindOne(
); );
} }
const reverted = content.slice(0, match.start) + oldStr + content.slice(match.end); const reverted = content.slice(0, match.start) + oldStr + content.slice(match.end);
await writeFile(change.file_path, reverted, 'utf8'); await writeFileAtomic(change.file_path, reverted);
break; break;
} }
case 'delete': { case 'delete': {
// Reverse a delete: recreate the file (diff holds the original content stashed at apply time) // Reverse a delete: recreate the file (diff holds the original content stashed at apply time)
await mkdir(dirname(change.file_path), { recursive: true }); await mkdir(dirname(change.file_path), { recursive: true });
await writeFile(change.file_path, change.diff, 'utf8'); await writeFileAtomic(change.file_path, change.diff);
break; break;
} }
} }

View File

@@ -32,6 +32,18 @@ const QWEN_PTY_MODES: ProviderMode[] = [
{ id: 'yolo', label: 'YOLO', description: 'Auto-approve all tools', isUnattended: true }, { id: 'yolo', label: 'YOLO', description: 'Auto-approve all tools', isUnattended: true },
]; ];
// Native BooCode (llama-swap) has no agent-native mode vocabulary, so we define
// one that matches the unified permission ladder. `bypass` is the only mode that
// changes behavior (auto-apply staged edits after the turn — dispatcher.ts);
// `plan` falls back to `ask` semantics for native (writes still stage to the
// pending-changes queue). External agents map the same three unified modes onto
// THEIR native ids via the `plan`-id / default / `isUnattended` shape.
const BOOCODE_MODES: ProviderMode[] = [
{ id: 'plan', label: 'Plan', description: 'Read-only analysis (native BooCode falls back to Ask)' },
{ id: 'ask', label: 'Ask Permission', description: 'Stage edits to the pending-changes queue for review' },
{ id: 'bypass', label: 'Bypass', description: 'Auto-apply edits to disk after the turn', isUnattended: true },
];
const CLAUDE_THINKING = [ const CLAUDE_THINKING = [
{ id: 'low', label: 'Low' }, { id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' }, { id: 'medium', label: 'Medium' },
@@ -41,6 +53,10 @@ const CLAUDE_THINKING = [
]; ];
export const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> = { export const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> = {
boocode: {
defaultModeId: 'ask',
modes: BOOCODE_MODES,
},
claude: { claude: {
defaultModeId: 'default', defaultModeId: 'default',
modes: CLAUDE_MODES, modes: CLAUDE_MODES,

View File

@@ -122,12 +122,14 @@ async function buildProviderEntry(
}; };
} }
// 2. Native boocode → always ready (llama-swap models). // 2. Native boocode → always ready (llama-swap models). Exposes the unified
// permission modes (plan/ask/bypass) so the composer's permission picker works
// for native BooCode too; `bypass` auto-applies staged edits (dispatcher.ts).
if (isNative) { if (isNative) {
return { return {
name, label: resolved.label, transport, status: 'ready', name, label: resolved.label, transport, status: 'ready',
enabled: true, installed: true, models: withConfigModels(llamaModels), modes: [], enabled: true, installed: true, models: withConfigModels(llamaModels),
defaultModeId: null, commands: manifestCommands, modes: fallbackModes, defaultModeId, commands: manifestCommands,
}; };
} }

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { analyzeMessages } from '../analyzer.js';
describe('analyzeMessages', () => {
it('classifies user messages', () => {
const breakdown = analyzeMessages([{ role: 'user', content: 'hello world' }]);
expect(breakdown.user).toBeGreaterThan(0);
expect(breakdown.total).toBe(breakdown.user);
});
it('counts tool calls', () => {
const parts = [
{ role: 'assistant', content: 'using grep', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: '{"files":[]}', tool_call_id: '1' },
];
const breakdown = analyzeMessages(parts);
expect(breakdown.tools).toBeGreaterThan(0);
expect(breakdown.assistant).toBeGreaterThan(0);
});
it('separates reasoning tokens', () => {
const parts = [
{ role: 'assistant', content: 'short answer', reasoning_parts: [{ text: 'long chain of thought reasoning here' }] },
];
const breakdown = analyzeMessages(parts);
expect(breakdown.reasoning).toBeGreaterThan(0);
expect(breakdown.assistant).toBeLessThan(breakdown.reasoning);
});
});

View File

@@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest';
describe('persistTaskBreakdown', () => {
it('exports functions', async () => {
const mod = await import('../persist.js');
expect(typeof mod.persistTaskBreakdown).toBe('function');
expect(typeof mod.getTaskBreakdown).toBe('function');
expect(typeof mod.analyzeAndPersistTaskBreakdown).toBe('function');
});
});

View File

@@ -0,0 +1,60 @@
// TokenScope analyzer — classifies message parts into category breakdown.
// Ported from opencode-tokenscope (MIT).
export interface TokenBreakdown {
system: number;
user: number;
assistant: number;
tools: number;
reasoning: number;
total: number;
}
const CHARS_PER_TOKEN = 4;
function estimateTokens(text: string): number {
return Math.ceil(text.length / CHARS_PER_TOKEN);
}
export function analyzeMessages(parts: any[]): TokenBreakdown {
const breakdown: TokenBreakdown = { system: 0, user: 0, assistant: 0, tools: 0, reasoning: 0, total: 0 };
for (const part of parts) {
const role = part.role ?? '';
const content = part.content ?? '';
const tokens = estimateTokens(content);
switch (role) {
case 'system':
breakdown.system += tokens;
break;
case 'user':
breakdown.user += tokens;
break;
case 'assistant':
breakdown.assistant += tokens;
if (part.tool_calls) {
for (const tc of part.tool_calls) {
breakdown.tools += estimateTokens(JSON.stringify(tc));
}
}
break;
case 'tool':
breakdown.tools += tokens;
break;
default:
breakdown.assistant += tokens;
}
if (part.reasoning_parts) {
for (const rp of part.reasoning_parts) {
const rTokens = estimateTokens(rp.text ?? '');
breakdown.reasoning += rTokens;
breakdown.assistant -= rTokens;
}
}
}
breakdown.total = breakdown.system + breakdown.user + breakdown.assistant + breakdown.tools + breakdown.reasoning;
return breakdown;
}

View File

@@ -0,0 +1,35 @@
// TokenScope persistence — writes breakdown to task records.
import type { Sql } from '../../db.js';
import type { TokenBreakdown } from './analyzer.js';
export async function persistTaskBreakdown(
sql: Sql,
taskId: string,
breakdown: TokenBreakdown,
): Promise<void> {
await sql`
UPDATE tasks SET token_breakdown = ${sql.json(breakdown as never)}
WHERE id = ${taskId}
`;
}
export async function getTaskBreakdown(
sql: Sql,
taskId: string,
): Promise<TokenBreakdown | null> {
const rows = await sql<{ token_breakdown: any }[]>`
SELECT token_breakdown FROM tasks WHERE id = ${taskId}
`;
return rows[0]?.token_breakdown ?? null;
}
export async function analyzeAndPersistTaskBreakdown(
sql: Sql,
taskId: string,
parts: any[],
): Promise<TokenBreakdown> {
const { analyzeMessages } = await import('./analyzer.js');
const breakdown = analyzeMessages(parts);
await persistTaskBreakdown(sql, taskId, breakdown);
return breakdown;
}

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { runWithInferenceContext, getInferenceContext } from '../inference_context.js';
import type { Sql } from '../../../db.js';
const fakeSql = {} as unknown as Sql;
describe('inference context (AsyncLocalStorage isolation)', () => {
it('throws when read outside a run', () => {
expect(() => getInferenceContext()).toThrow(/outside inference context/);
});
it('keeps each run its own context across overlapping awaits', async () => {
// The race the global `let current` had: run B starts (and would overwrite a
// shared global) while run A is awaiting. After A resumes it must still read
// its OWN sessionId, not B's.
const run = (id: string, delay: number) =>
runWithInferenceContext({ sql: fakeSql, sessionId: id, taskId: null }, async () => {
await new Promise((r) => setTimeout(r, delay));
return getInferenceContext().sessionId;
});
const [a, b] = await Promise.all([run('A', 20), run('B', 5)]);
expect(a).toBe('A');
expect(b).toBe('B');
});
it('carries permissionMode and taskId per run', async () => {
const result = await runWithInferenceContext(
{ sql: fakeSql, sessionId: 's1', taskId: 't1', permissionMode: 'bypass' },
async () => {
await Promise.resolve();
const ctx = getInferenceContext();
return { taskId: ctx.taskId, mode: ctx.permissionMode };
},
);
expect(result).toEqual({ taskId: 't1', mode: 'bypass' });
});
});

View File

@@ -26,6 +26,15 @@ export const applyPendingTool: ToolDef<ApplyPendingInputT> = {
}, },
}, },
async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> { async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
// Under Ask (and Plan) the human approves via the Pending Changes panel — the
// agent must not auto-apply. Bypass and legacy (undefined) may apply.
if (context.permissionMode === 'ask' || context.permissionMode === 'plan') {
return {
status: 'denied',
message:
'Permission mode is Ask — staged changes must be approved by the user in the Pending Changes panel, not applied by the agent.',
};
}
const results = await applyAll(context.sql, context.sessionId, projectRoot); const results = await applyAll(context.sql, context.sessionId, projectRoot);
const succeeded = results.filter((r) => r.success).length; const succeeded = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length; const failed = results.filter((r) => !r.success).length;

View File

@@ -1,6 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js'; import type { ToolDef, ToolContext } from './types.js';
import { queueCreate } from '../pending_changes.js'; import { queueCreate } from '../pending_changes.js';
import { denyReadOnly, finalizeWrite } from './write-gate.js';
const CreateFileInput = z.object({ const CreateFileInput = z.object({
file_path: z.string().min(1), file_path: z.string().min(1),
@@ -32,6 +33,7 @@ export const createFileTool: ToolDef<CreateFileInputT> = {
}, },
}, },
async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> { async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
if (context.permissionMode === 'plan') return denyReadOnly('create_file');
const change = await queueCreate( const change = await queueCreate(
context.sql, context.sql,
context.sessionId, context.sessionId,
@@ -40,12 +42,11 @@ export const createFileTool: ToolDef<CreateFileInputT> = {
input.content, input.content,
projectRoot, projectRoot,
); );
return { return finalizeWrite(
status: 'queued', context,
change_id: change.id, projectRoot,
file_path: change.file_path, change,
operation: 'create', `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
message: `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`, );
};
}, },
}; };

View File

@@ -1,6 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js'; import type { ToolDef, ToolContext } from './types.js';
import { queueDelete } from '../pending_changes.js'; import { queueDelete } from '../pending_changes.js';
import { denyReadOnly, finalizeWrite } from './write-gate.js';
const DeleteFileInput = z.object({ const DeleteFileInput = z.object({
file_path: z.string().min(1), file_path: z.string().min(1),
@@ -30,6 +31,7 @@ export const deleteFileTool: ToolDef<DeleteFileInputT> = {
}, },
}, },
async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> { async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
if (context.permissionMode === 'plan') return denyReadOnly('delete_file');
const change = await queueDelete( const change = await queueDelete(
context.sql, context.sql,
context.sessionId, context.sessionId,
@@ -37,12 +39,11 @@ export const deleteFileTool: ToolDef<DeleteFileInputT> = {
input.file_path, input.file_path,
projectRoot, projectRoot,
); );
return { return finalizeWrite(
status: 'queued', context,
change_id: change.id, projectRoot,
file_path: change.file_path, change,
operation: 'delete', `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
message: `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`, );
};
}, },
}; };

View File

@@ -1,6 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js'; import type { ToolDef, ToolContext } from './types.js';
import { queueEdit } from '../pending_changes.js'; import { queueEdit } from '../pending_changes.js';
import { denyReadOnly, finalizeWrite } from './write-gate.js';
const EditFileInput = z.object({ const EditFileInput = z.object({
file_path: z.string().min(1), file_path: z.string().min(1),
@@ -34,6 +35,7 @@ export const editFileTool: ToolDef<EditFileInputT> = {
}, },
}, },
async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> { async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
if (context.permissionMode === 'plan') return denyReadOnly('edit_file');
const change = await queueEdit( const change = await queueEdit(
context.sql, context.sql,
context.sessionId, context.sessionId,
@@ -43,12 +45,11 @@ export const editFileTool: ToolDef<EditFileInputT> = {
input.new_string, input.new_string,
projectRoot, projectRoot,
); );
return { return finalizeWrite(
status: 'queued', context,
change_id: change.id, projectRoot,
file_path: change.file_path, change,
operation: 'edit', `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
message: `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`, );
};
}, },
}; };

View File

@@ -7,6 +7,9 @@ import { rewindTool } from './rewind.js';
import { newTaskTool } from './new_task.js'; import { newTaskTool } from './new_task.js';
import { listTasksTool } from './list_tasks.js'; import { listTasksTool } from './list_tasks.js';
import { checkTaskStatusTool } from './check_task_status.js'; import { checkTaskStatusTool } from './check_task_status.js';
import { lspDiagnosticsTool } from './lsp_diagnostics.js';
import { lspGotoDefinitionTool } from './lsp_goto_definition.js';
import { lspFindReferencesTool } from './lsp_find_references.js';
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js'; export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
@@ -26,4 +29,16 @@ export const WRITE_TOOLS: readonly ToolDef<any>[] = [
checkTaskStatusTool, checkTaskStatusTool,
]; ];
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool }; // Read-only agent tools for code intelligence.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const READ_TOOLS: readonly ToolDef<any>[] = [
lspDiagnosticsTool,
lspGotoDefinitionTool,
lspFindReferencesTool,
];
export {
editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool,
newTaskTool, listTasksTool, checkTaskStatusTool,
lspDiagnosticsTool, lspGotoDefinitionTool, lspFindReferencesTool,
};

View File

@@ -1,36 +1,49 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { Sql } from '../../db.js'; import type { Sql } from '../../db.js';
import type { PermissionMode } from './types.js';
/** /**
* Module-level inference context for write tools. * Per-run inference context for write tools.
* *
* Set via `setInferenceContext()` before each inference run starts. * Write tools need ambient state (sql, sessionId, the permission gate) that the
* Write tools read it via `getInferenceContext()` during execute. * BooChat tool-phase `execute(input, projectRoot, extraRoots?)` signature can't
* Same pattern as BooChat's `loadConfig()` singleton — tools need * carry. This used to be a single module-level `let current` — but the inference
* ambient state that can't be threaded through the tool-phase execute * runner's `enqueue()` is fire-and-forget, so two overlapping runs (a user
* signature (which is `execute(input, projectRoot, extraRoots?)`). * message racing a dispatcher-polled native task; two chat tabs streaming) would
* clobber each other's context, and `cancel()` cleared it for ALL in-flight runs.
*
* AsyncLocalStorage gives each run its own context: `enqueue()` starts its async
* loop synchronously inside `runWithInferenceContext`, so the store propagates
* through every awaited tool execution in that run — and only that run.
*/ */
export interface InferenceContext { export interface InferenceContext {
sql: Sql; sql: Sql;
sessionId: string; sessionId: string;
taskId: string | null; taskId: string | null;
/** Native-BooCode permission gate, set per run from the request/task mode. */
permissionMode?: PermissionMode;
} }
let current: InferenceContext | null = null; const storage = new AsyncLocalStorage<InferenceContext>();
export function setInferenceContext(ctx: InferenceContext): void { /**
current = ctx; * Bind `ctx` for the duration of the (possibly detached) async chain `fn` starts.
} * The inference runner kicks off its loop synchronously within this call, so all
* downstream `await`s — including write-tool `execute` via the adapter — read the
export function clearInferenceContext(): void { * same store. Concurrent runs each get their own; nothing is shared or cleared
current = null; * out from under an in-flight run.
*/
export function runWithInferenceContext<T>(ctx: InferenceContext, fn: () => T): T {
return storage.run(ctx, fn);
} }
export function getInferenceContext(): InferenceContext { export function getInferenceContext(): InferenceContext {
if (!current) { const ctx = storage.getStore();
if (!ctx) {
throw new Error( throw new Error(
'Write tool called outside inference context — setInferenceContext() was not called before this run', 'Write tool called outside inference context — runWithInferenceContext() did not wrap this run',
); );
} }
return current; return ctx;
} }

View File

@@ -0,0 +1,48 @@
import { z } from 'zod';
import { readFile } from 'node:fs/promises';
import type { ToolDef, ToolContext } from './types.js';
import { resolveWritePath } from '../write_guard.js';
import { lspManager } from '../lsp/server-manager.js';
import { getDiagnostics } from '../lsp/operations.js';
const LspDiagnosticsInput = z.object({
file_path: z.string().describe('Path to the file to check for diagnostics'),
});
type InputT = z.infer<typeof LspDiagnosticsInput>;
export const lspDiagnosticsTool: ToolDef<InputT> = {
name: 'lsp_diagnostics',
description: 'Get TypeScript/JavaScript diagnostics (errors, warnings) for a file. Returns diagnostic messages with severity and location.',
inputSchema: LspDiagnosticsInput,
jsonSchema: {
type: 'function',
function: {
name: 'lsp_diagnostics',
description: 'Get TypeScript/JavaScript diagnostics for a file',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the file' },
},
required: ['file_path'],
},
},
},
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
const resolved = await resolveWritePath(projectRoot, input.file_path);
const content = await readFile(resolved, 'utf8');
const client = await lspManager.getClient(resolved);
if (!client) return { error: 'Unsupported file type for LSP diagnostics' };
const diagnostics = await getDiagnostics(client, resolved, content);
if (diagnostics.length === 0) return { result: 'No diagnostics found.' };
const lines = diagnostics.map((d) => {
const sev = ['', 'error', 'warning', 'info', 'hint'][d.severity] ?? 'unknown';
return `[${sev}] line ${d.range.start.line + 1}:${d.range.start.character + 1} - ${d.message}`;
});
return { result: lines.join('\n') };
},
};

View File

@@ -0,0 +1,49 @@
import { z } from 'zod';
import { readFile } from 'node:fs/promises';
import type { ToolDef, ToolContext } from './types.js';
import { resolveWritePath } from '../write_guard.js';
import { lspManager } from '../lsp/server-manager.js';
import { findReferences } from '../lsp/operations.js';
const LspFindReferencesInput = z.object({
file_path: z.string().describe('Path to the source file'),
line: z.number().int().nonnegative().describe('0-based line number'),
character: z.number().int().nonnegative().describe('0-based character offset'),
});
type InputT = z.infer<typeof LspFindReferencesInput>;
export const lspFindReferencesTool: ToolDef<InputT> = {
name: 'lsp_find_references',
description: 'Find all references to a symbol at a given position in a file.',
inputSchema: LspFindReferencesInput,
jsonSchema: {
type: 'function',
function: {
name: 'lsp_find_references',
description: 'Find all references to symbol at position',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string' },
line: { type: 'number' },
character: { type: 'number' },
},
required: ['file_path', 'line', 'character'],
},
},
},
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
const resolved = await resolveWritePath(projectRoot, input.file_path);
const content = await readFile(resolved, 'utf8');
const client = await lspManager.getClient(resolved);
if (!client) return { error: 'Unsupported file type' };
const refs = await findReferences(client, resolved, content, input.line, input.character);
if (refs.length === 0) return { result: 'No references found.' };
const lines = refs.map((r) => `${r.uri}:${r.range.start.line + 1}:${r.range.start.character + 1}`);
return { result: `Found ${refs.length} reference(s):\n${lines.join('\n')}` };
},
};

View File

@@ -0,0 +1,48 @@
import { z } from 'zod';
import { readFile } from 'node:fs/promises';
import type { ToolDef, ToolContext } from './types.js';
import { resolveWritePath } from '../write_guard.js';
import { lspManager } from '../lsp/server-manager.js';
import { gotoDefinition } from '../lsp/operations.js';
const LspGotoDefinitionInput = z.object({
file_path: z.string().describe('Path to the source file'),
line: z.number().int().nonnegative().describe('0-based line number'),
character: z.number().int().nonnegative().describe('0-based character offset'),
});
type InputT = z.infer<typeof LspGotoDefinitionInput>;
export const lspGotoDefinitionTool: ToolDef<InputT> = {
name: 'lsp_goto_definition',
description: 'Find the definition of a symbol at a given position in a file.',
inputSchema: LspGotoDefinitionInput,
jsonSchema: {
type: 'function',
function: {
name: 'lsp_goto_definition',
description: 'Find definition of symbol at position',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string' },
line: { type: 'number' },
character: { type: 'number' },
},
required: ['file_path', 'line', 'character'],
},
},
},
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
const resolved = await resolveWritePath(projectRoot, input.file_path);
const content = await readFile(resolved, 'utf8');
const client = await lspManager.getClient(resolved);
if (!client) return { error: 'Unsupported file type' };
const loc = await gotoDefinition(client, resolved, content, input.line, input.character);
if (!loc) return { result: 'No definition found.' };
return { result: `Defined at ${loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}` };
},
};

View File

@@ -6,6 +6,7 @@ const NewTaskInput = z.object({
input: z.string().min(1).describe('Task description for the child subtask'), input: z.string().min(1).describe('Task description for the child subtask'),
agent: z.string().optional().describe('Optional: dispatch to a specific agent'), agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
model: z.string().optional().describe('Optional: model override for the subtask'), model: z.string().optional().describe('Optional: model override for the subtask'),
background: z.boolean().optional().describe('If true, return immediately without blocking on completion'),
}); });
type NewTaskInputT = z.infer<typeof NewTaskInput>; type NewTaskInputT = z.infer<typeof NewTaskInput>;
@@ -30,6 +31,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
input: { type: 'string', description: 'Task description for the child subtask' }, input: { type: 'string', description: 'Task description for the child subtask' },
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' }, agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
model: { type: 'string', description: 'Optional: model override for the subtask' }, model: { type: 'string', description: 'Optional: model override for the subtask' },
background: { type: 'boolean', description: 'If true, returns immediately without waiting' },
}, },
required: ['input'], required: ['input'],
}, },
@@ -50,6 +52,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
return { error: 'Cannot determine project_id from current session' }; return { error: 'Cannot determine project_id from current session' };
} }
const isBg = input.background === true;
const [task] = await sql<{ id: string; state: string }[]>` const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, parent_task_id, input, agent, model) INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null}) VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
@@ -57,9 +60,12 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
`; `;
return { return {
message: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`, message: isBg
? `Background subtask created (id: ${task!.id}). It will continue independently.`
: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
task_id: task!.id, task_id: task!.id,
state: task!.state, state: task!.state,
background: isBg,
}; };
}, },
}; };

View File

@@ -1,6 +1,22 @@
import type { z } from 'zod'; import type { z } from 'zod';
import type { Sql } from '../../db.js'; import type { Sql } from '../../db.js';
/**
* Unified permission ladder for native BooCode inference. Gates the write tools:
* plan — read-only: create/edit/delete are denied (no staging).
* ask — stage to the pending-changes queue; `apply_pending` is denied so the
* agent cannot self-apply (the human approves via the Diff panel).
* bypass — apply each write immediately (no queue, no approval).
* Undefined preserves the historical behavior (stage + `apply_pending` allowed).
*/
export type PermissionMode = 'plan' | 'ask' | 'bypass';
/** Narrow a raw task/request mode id to a unified PermissionMode, else undefined
* (e.g. an external agent's native mode id, or null). */
export function asPermissionMode(id: string | null | undefined): PermissionMode | undefined {
return id === 'plan' || id === 'ask' || id === 'bypass' ? id : undefined;
}
export interface ToolJsonSchema { export interface ToolJsonSchema {
type: 'function'; type: 'function';
function: { function: {
@@ -21,6 +37,8 @@ export interface ToolContext {
sql: Sql; sql: Sql;
sessionId: string; sessionId: string;
taskId: string | null; taskId: string | null;
/** Native-BooCode permission gate for write tools (undefined = legacy behavior). */
permissionMode?: PermissionMode;
} }
export interface ToolDef<TInput> { export interface ToolDef<TInput> {

View File

@@ -0,0 +1,53 @@
/**
* Permission-gate helpers for native BooCode write tools. The gate comes from
* the per-run inference context (`ToolContext.permissionMode`):
* plan — deny the write (read-only); nothing is staged.
* bypass — apply the staged change immediately (no queue, no approval).
* ask / undefined — leave it in the pending-changes queue for review.
*/
import type { ToolContext } from './types.js';
import { applyOne } from '../pending_changes.js';
/** Result returned when a write is denied under Plan (read-only) mode. */
export function denyReadOnly(operation: string): unknown {
return {
status: 'denied',
operation,
message: `Read-only (Plan) permission mode — ${operation} is not permitted. Switch to Ask or Bypass to make changes.`,
};
}
/** Finalize a just-staged change per the permission gate: apply now under Bypass,
* otherwise return it as queued for the human to approve. */
export async function finalizeWrite(
context: ToolContext,
projectRoot: string,
change: { id: string; file_path: string; operation: string },
queuedHint: string,
): Promise<unknown> {
if (context.permissionMode === 'bypass') {
const res = await applyOne(context.sql, change.id, projectRoot);
console.log(
`[write-gate] bypass apply ${change.operation} ${change.file_path} -> ${res.success ? 'applied' : 'FAILED: ' + (res.error ?? '?')}`,
);
return {
status: res.success ? 'applied' : 'failed',
change_id: change.id,
file_path: change.file_path,
operation: change.operation,
message: res.success
? `${change.operation} applied to ${change.file_path}.`
: `Apply failed for ${change.file_path}: ${res.error ?? 'unknown error'}. Left in the pending queue.`,
};
}
console.log(
`[write-gate] ${context.permissionMode ?? 'legacy'} queued ${change.operation} ${change.file_path}`,
);
return {
status: 'queued',
change_id: change.id,
file_path: change.file_path,
operation: change.operation,
message: queuedHint,
};
}

View File

@@ -17,6 +17,8 @@
- **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop — only `description` + `inputSchema: jsonSchema(parameters)`. - **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop — only `description` + `inputSchema: jsonSchema(parameters)`.
- **`includeUsage: true` MUST be set on `createOpenAICompatible`** in `provider.ts`. The adapter defaults it false → no `stream_options.include_usage` → llama-swap emits no usage block → `result.usage` resolves `undefined` (NULL token counts). Don't remove during refactor. - **`includeUsage: true` MUST be set on `createOpenAICompatible`** in `provider.ts`. The adapter defaults it false → no `stream_options.include_usage` → llama-swap emits no usage block → `result.usage` resolves `undefined` (NULL token counts). Don't remove during refactor.
- **Tool-call-only turns may emit a leading `\n` text-delta.** `MessageList.flatten`'s `hasText` and `MessageBubble`'s `hasContent` both `.trim()` before the length check, else whitespace-only content renders an empty bubble + ActionRow between tool calls. `buildMessagesPayload` also skips `status='failed'` and complete-but-empty assistant rows (avoids "Cannot have 2 or more assistant messages at the end of the list" upstream rejection after cap-hit + Continue). - **Tool-call-only turns may emit a leading `\n` text-delta.** `MessageList.flatten`'s `hasText` and `MessageBubble`'s `hasContent` both `.trim()` before the length check, else whitespace-only content renders an empty bubble + ActionRow between tool calls. `buildMessagesPayload` also skips `status='failed'` and complete-but-empty assistant rows (avoids "Cannot have 2 or more assistant messages at the end of the list" upstream rejection after cap-hit + Continue).
- **`services/inference/tool-shim.ts`** — Recovers structured tool calls from plain-text model output. Some models (notably Qwen) emit `<tool_call><name>...</name><arguments>...</arguments></tool_call>` inline text instead of structured JSON. `extractToolCalls(text)` parses both XML and JSON inline formats. `hasToolCallMarkup(text)` is a fast pre-check. Used as a fallback in the stream phase when structured `tool_calls` parse fails. Does NOT require `FAST_MODEL` — operates on the existing turn's output text.
- **`services/inference/loop-detectors.ts`** — Six detectors that catch repetitive model behavior: `detectContentRepeat` (same content N times), `detectToolLoop` (same tool called consecutively). `detectDoomLoop` combines both. These are additive to the existing `sentinels.ts` doom-loop detection.
- **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart`; BooCode's OpenAI-shape history lacks it, so a forward-scan builds a `tool_call_id → toolName` map from prior assistant `tool_calls`. Tool outputs wrapped as `{ type: 'json' | 'text', value }` (v6 `ToolResultOutput`). Reasoning emits a `ReasoningPart` first in the content array. - **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart`; BooCode's OpenAI-shape history lacks it, so a forward-scan builds a `tool_call_id → toolName` map from prior assistant `tool_calls`. Tool outputs wrapped as `{ type: 'json' | 'text', value }` (v6 `ToolResultOutput`). Reasoning emits a `ReasoningPart` first in the content array.
- **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn. - **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn.
- **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up. - **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up.

View File

@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { realpath, stat, readdir, access } from 'node:fs/promises'; import { realpath, stat, readdir, access, writeFile, rename } from 'node:fs/promises';
import { basename, resolve, sep } from 'node:path'; import { basename, resolve, sep } from 'node:path';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
@@ -473,7 +473,7 @@ export function registerProjectRoutes(
// Always includes auto_mode (the dirty-state-derived mode) so the client can // Always includes auto_mode (the dirty-state-derived mode) so the client can
// show a suggestion when a pinned mode diverges from what would be auto-selected. // show a suggestion when a pinned mode diverges from what would be auto-selected.
// Returns { git_repo: false } when the path is not a git repository. // Returns { git_repo: false } when the path is not a git repository.
app.get<{ Params: { id: string }; Querystring: { mode?: string } }>( app.get<{ Params: { id: string }; Querystring: { mode?: string; whitespace?: string } }>(
'/api/projects/:id/git/diff', '/api/projects/:id/git/diff',
async (req, reply) => { async (req, reply) => {
const { id } = req.params; const { id } = req.params;
@@ -504,7 +504,8 @@ export function registerProjectRoutes(
rawMode === 'uncommitted' ? 'uncommitted' : rawMode === 'uncommitted' ? 'uncommitted' :
auto_mode; // no mode param → auto-select (FIX 1) auto_mode; // no mode param → auto-select (FIX 1)
const result = await getGitDiff(projectRoot, mode); const ignoreWhitespace = req.query.whitespace === '1';
const result = await getGitDiff(projectRoot, mode, ignoreWhitespace);
if (result === null) { if (result === null) {
return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] }; return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] };
} }
@@ -541,6 +542,11 @@ export function registerProjectRoutes(
).min(1), ).min(1),
}); });
const WriteFileBody = z.object({
path: z.string().min(1),
content: z.string(),
});
// POST /api/projects/:id/git/stage — stage whole files // POST /api/projects/:id/git/stage — stage whole files
app.post<{ Params: { id: string } }>( app.post<{ Params: { id: string } }>(
'/api/projects/:id/git/stage', '/api/projects/:id/git/stage',
@@ -637,6 +643,38 @@ export function registerProjectRoutes(
}, },
); );
// POST /api/projects/:id/write_file — write a file atomically
app.post<{ Params: { id: string } }>(
'/api/projects/:id/write_file',
async (req, reply) => {
const body = WriteFileBody.safeParse(req.body);
if (!body.success) { reply.code(400); return { error: body.error.message }; }
const { id } = req.params;
const projectPath = await selectProjectPath(sql, id);
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
let root: string;
try { root = await resolveProjectRoot(projectPath); }
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
const target = body.data.path.startsWith('/') ? body.data.path : resolve(root, body.data.path);
// Validate path stays within project root
const realTarget = await realpath(target).catch(() => target);
if (!realTarget.startsWith(root + sep) && realTarget !== root) {
reply.code(403);
return { error: 'path escapes project root' };
}
const tmp = target + '.tmp';
try {
await writeFile(tmp, body.data.content, 'utf-8');
await rename(tmp, target);
return { ok: true };
} catch (err) {
// Clean up tmp on failure
await access(tmp).then(() => rename(tmp, target + '.bak').catch(() => {})).catch(() => {});
throw err;
}
},
);
// GET /api/projects/:id/files // GET /api/projects/:id/files
app.get<{ Params: { id: string } }>( app.get<{ Params: { id: string } }>(
'/api/projects/:id/files', '/api/projects/:id/files',

View File

@@ -372,13 +372,12 @@ ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES mess
ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at); CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);
-- tasks table (provider dispatch, arena) -- tasks table (provider dispatch)
CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE, session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
parent_task_id UUID REFERENCES tasks(id), parent_task_id UUID REFERENCES tasks(id),
arena_id UUID,
state TEXT NOT NULL DEFAULT 'pending' state TEXT NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending','running','completed','failed','blocked','cancelled')), CHECK (state IN ('pending','running','completed','failed','blocked','cancelled')),
input TEXT NOT NULL, input TEXT NOT NULL,
@@ -405,3 +404,6 @@ DO $$ BEGIN
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE; FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
END IF; END IF;
END $$; END $$;
-- Remove the v2.0.5 arena_id column (replaced by the new Arena feature).
ALTER TABLE tasks DROP COLUMN IF EXISTS arena_id;

View File

@@ -271,7 +271,9 @@ function buildNumstatMap(
async function getUncommittedDiff( async function getUncommittedDiff(
gitRoot: string, gitRoot: string,
inProgress: string | null, inProgress: string | null,
ignoreWhitespace = false,
): Promise<GitDiffResult> { ): Promise<GitDiffResult> {
const ws = ignoreWhitespace ? ['-w'] : [];
const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null; const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null;
const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] = const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] =
@@ -284,10 +286,10 @@ async function getUncommittedDiff(
: runGit(['diff', '--cached', '--name-status'], gitRoot), : runGit(['diff', '--cached', '--name-status'], gitRoot),
runGit(['ls-files', '--others', '--exclude-standard'], gitRoot), runGit(['ls-files', '--others', '--exclude-standard'], gitRoot),
hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''), hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''),
hasCommits ? runGit(['diff', 'HEAD'], gitRoot) : Promise.resolve(''), hasCommits ? runGit(['diff', ...ws, 'HEAD'], gitRoot) : Promise.resolve(''),
hasCommits hasCommits
? runGit(['diff', '--cached', 'HEAD'], gitRoot) ? runGit(['diff', ...ws, '--cached', 'HEAD'], gitRoot)
: runGit(['diff', '--cached'], gitRoot), : runGit(['diff', ...ws, '--cached'], gitRoot),
]); ]);
const allChanged = parseNameStatus(nameStatusOut ?? ''); const allChanged = parseNameStatus(nameStatusOut ?? '');
@@ -347,11 +349,13 @@ async function getCommittedDiff(
base: string, base: string,
label: string, label: string,
inProgress: string | null, inProgress: string | null,
ignoreWhitespace = false,
): Promise<GitDiffResult> { ): Promise<GitDiffResult> {
const ws = ignoreWhitespace ? ['-w'] : [];
const [nameStatusOut, numstatOut, diffOut] = await Promise.all([ const [nameStatusOut, numstatOut, diffOut] = await Promise.all([
runGit(['diff', '--name-status', base, 'HEAD'], gitRoot), runGit(['diff', '--name-status', base, 'HEAD'], gitRoot),
runGit(['diff', '--numstat', base, 'HEAD'], gitRoot), runGit(['diff', '--numstat', base, 'HEAD'], gitRoot),
runGit(['diff', base, 'HEAD'], gitRoot), runGit(['diff', ...ws, base, 'HEAD'], gitRoot),
]); ]);
const allChanged = parseNameStatus(nameStatusOut ?? ''); const allChanged = parseNameStatus(nameStatusOut ?? '');
@@ -383,23 +387,23 @@ async function getCommittedDiff(
* the directory is not a git repository. On a null committed-mode base, falls * the directory is not a git repository. On a null committed-mode base, falls
* back to uncommitted and labels the result accordingly. * back to uncommitted and labels the result accordingly.
*/ */
export async function getGitDiff(cwd: string, mode: GitDiffMode): Promise<GitDiffResult | null> { export async function getGitDiff(cwd: string, mode: GitDiffMode, ignoreWhitespace?: boolean): Promise<GitDiffResult | null> {
const gitRoot = await resolveGitRoot(cwd); const gitRoot = await resolveGitRoot(cwd);
if (!gitRoot) return null; if (!gitRoot) return null;
const inProgress = await detectInProgress(gitRoot); const inProgress = await detectInProgress(gitRoot);
if (mode === 'uncommitted') { if (mode === 'uncommitted') {
return getUncommittedDiff(gitRoot, inProgress); return getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
} }
const { base, label } = await resolveCommittedBase(gitRoot); const { base, label } = await resolveCommittedBase(gitRoot);
if (!base) { if (!base) {
// Fall back to uncommitted with a descriptive label // Fall back to uncommitted with a descriptive label
const result = await getUncommittedDiff(gitRoot, inProgress); const result = await getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
return { ...result, base_label: label }; return { ...result, base_label: label };
} }
return getCommittedDiff(gitRoot, base, label, inProgress); return getCommittedDiff(gitRoot, base, label, inProgress, ignoreWhitespace ?? false);
} }
// ── Phase 2: Write helpers ───────────────────────────────────────────────── // ── Phase 2: Write helpers ─────────────────────────────────────────────────

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { deduplicate } from '../strategies/deduplication.js';
import type { DcpMessage } from '../messages.js';
describe('deduplicate', () => {
it('removes consecutive identical tool_call+tool_result pairs', () => {
const messages: DcpMessage[] = [
{ role: 'user', content: 'search for x' },
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result1', tool_call_id: '1' },
// Duplicate pair
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result1', tool_call_id: '2' },
];
const { messages: result, stats } = deduplicate(messages);
expect(result).toHaveLength(3); // user + first pair
expect(stats.removedCount).toBe(2);
});
it('preserves non-duplicate content', () => {
const messages: DcpMessage[] = [
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result1', tool_call_id: '1' },
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result2', tool_call_id: '2' }, // Different result
];
const { messages: result, stats } = deduplicate(messages);
expect(result).toHaveLength(4);
expect(stats.removedCount).toBe(0);
});
});

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { toDcpMessages, fromDcpMessages } from '../messages.js';
describe('toDcpMessages', () => {
it('converts user messages', () => {
const result = toDcpMessages([{ role: 'user', content: 'hello' }]);
expect(result[0].role).toBe('user');
expect(result[0].content).toBe('hello');
});
it('marks Error: content as isError', () => {
const result = toDcpMessages([{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' }]);
expect(result[0].isError).toBe(true);
});
});
describe('fromDcpMessages', () => {
it('round-trips messages', () => {
const original = [{ role: 'user', content: 'hello' }];
expect(fromDcpMessages(toDcpMessages(original))).toEqual(original);
});
});

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { purgeErrors } from '../strategies/purge-errors.js';
import type { DcpMessage } from '../messages.js';
describe('purgeErrors', () => {
it('removes tool results where content starts with Error:', () => {
const messages: DcpMessage[] = [
{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' },
{ role: 'tool', content: '{"files":[]}', tool_call_id: '2' },
];
const { messages: result, stats } = purgeErrors(messages);
expect(result).toHaveLength(1);
expect(stats.removedCount).toBe(1);
});
it('removes empty tool results', () => {
const messages: DcpMessage[] = [
{ role: 'tool', content: '', tool_call_id: '1' },
];
const { messages: result, stats } = purgeErrors(messages);
expect(result).toHaveLength(0);
expect(stats.removedCount).toBe(1);
});
it('preserves valid tool results', () => {
const messages: DcpMessage[] = [
{ role: 'tool', content: '{"files":["a.ts"]}', tool_call_id: '1' },
];
const { messages: result, stats } = purgeErrors(messages);
expect(result).toHaveLength(1);
expect(stats.removedCount).toBe(0);
});
});

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { transformMessages } from '../transform.js';
import type { DcpMessage } from '../messages.js';
describe('transformMessages', () => {
it('applies dedup then purge in order', () => {
const input: DcpMessage[] = [
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result', tool_call_id: '1' },
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result', tool_call_id: '2' }, // Dup
];
const { messages, stats } = transformMessages('test-chat', input);
expect(stats.removedCount).toBeGreaterThan(0);
expect(messages.length).toBeLessThan(input.length);
});
it('handles empty input', () => {
const { messages, stats } = transformMessages('empty', []);
expect(messages).toHaveLength(0);
expect(stats.removedCount).toBe(0);
});
});

View File

@@ -0,0 +1,4 @@
export { transformMessages } from './transform.js';
export type { DcpMessage } from './messages.js';
export { toDcpMessages, fromDcpMessages } from './messages.js';
export { getDcpState, clearDcpState } from './state.js';

View File

@@ -0,0 +1,34 @@
// DCP message shape adapter.
// Converts between BooCode MessagePart[] and the DCP internal shape.
// Clean-room implementation — no AGPL source copied.
export interface DcpMessage {
role: 'user' | 'assistant' | 'tool';
content: string;
tool_call_id?: string;
tool_calls?: Array<{ id: string; name: string; arguments: string }>;
isError?: boolean;
}
export function toDcpMessages(parts: any[]): DcpMessage[] {
return parts.map((p: any) => {
const msg: DcpMessage = { role: p.role, content: p.content ?? '' };
if (p.tool_call_id) msg.tool_call_id = p.tool_call_id;
if (p.tool_calls) msg.tool_calls = p.tool_calls;
if (p.isError) msg.isError = true;
if (p.role === 'tool' && p.content && p.content.startsWith('Error:')) {
msg.isError = true;
}
return msg;
});
}
export function fromDcpMessages(msgs: DcpMessage[]): any[] {
return msgs.map((m) => ({
role: m.role,
content: m.content,
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
...(m.isError ? { isError: true } : {}),
}));
}

View File

@@ -0,0 +1,27 @@
// Per-chat session state for DCP.
// Tracks last transform timestamp and message count to avoid re-processing.
interface ChatDcpState {
lastTransformAt: number;
lastMessageCount: number;
}
const chatStates = new Map<string, ChatDcpState>();
export function getDcpState(chatId: string): ChatDcpState | undefined {
return chatStates.get(chatId);
}
export function setDcpState(chatId: string, messageCount: number): void {
chatStates.set(chatId, { lastTransformAt: Date.now(), lastMessageCount: messageCount });
}
export function clearDcpState(chatId: string): void {
chatStates.delete(chatId);
}
export function shouldTransform(chatId: string, messageCount: number): boolean {
const state = chatStates.get(chatId);
if (!state) return true;
return state.lastMessageCount !== messageCount;
}

View File

@@ -0,0 +1,50 @@
import type { DcpMessage } from '../messages.js';
export function deduplicate(messages: DcpMessage[]): { messages: DcpMessage[]; stats: { removedCount: number; freedTokens: number } } {
const result: DcpMessage[] = [];
let removedCount = 0;
let freedTokens = 0;
let i = 0;
while (i < messages.length) {
const current: DcpMessage = messages[i]!;
const next = messages[i + 1];
if (
current.role === 'assistant' &&
current.tool_calls &&
next &&
next.role === 'tool' &&
next.tool_call_id === current.tool_calls[0]?.id
) {
const nextNext = messages[i + 2];
const nextNextNext = messages[i + 3];
if (
nextNext &&
nextNext.role === 'assistant' &&
nextNext.tool_calls &&
nextNextNext &&
nextNextNext.role === 'tool' &&
nextNextNext.tool_call_id === nextNext.tool_calls[0]?.id &&
nextNext.tool_calls[0]?.name === current.tool_calls[0]?.name &&
nextNext.tool_calls[0]?.arguments === current.tool_calls[0]?.arguments &&
nextNextNext.content === next.content
) {
result.push(current, next);
i += 4;
removedCount += 2;
freedTokens += Math.ceil(nextNext.content.length / 4);
freedTokens += Math.ceil(current.content.length / 4);
} else {
result.push(current);
i++;
}
} else {
result.push(current);
i++;
}
}
return { messages: result, stats: { removedCount, freedTokens } };
}

View File

@@ -0,0 +1,34 @@
// Purge-errors strategy — removes failed/empty tool_result entries.
// Clean-room implementation.
import type { DcpMessage } from '../messages.js';
const ERROR_PREFIXES = ['Error:', 'error:', 'Error: '];
const DEFAULT_WINDOW = 5;
export function purgeErrors(
messages: DcpMessage[],
windowSize: number = DEFAULT_WINDOW,
): { messages: DcpMessage[]; stats: { removedCount: number; freedTokens: number } } {
const result: DcpMessage[] = [];
let removedCount = 0;
let freedTokens = 0;
for (const msg of messages) {
if (msg.role === 'tool') {
const shouldRemove =
msg.isError ||
ERROR_PREFIXES.some((p) => msg.content.startsWith(p)) ||
msg.content.trim() === '';
if (shouldRemove) {
removedCount++;
freedTokens += Math.ceil(msg.content.length / 4);
continue; // Skip this message
}
}
result.push(msg);
}
return { messages: result, stats: { removedCount, freedTokens } };
}

View File

@@ -0,0 +1,52 @@
// Transform orchestrator — runs DCP strategies in sequence.
// Clean-room implementation.
import type { DcpMessage } from './messages.js';
import { deduplicate } from './strategies/deduplication.js';
import { purgeErrors } from './strategies/purge-errors.js';
import { getDcpState, setDcpState, shouldTransform } from './state.js';
export interface TransformStats {
removedCount: number;
freedTokens: number;
dedupRemoved: number;
purgeRemoved: number;
}
export interface TransformResult {
messages: DcpMessage[];
stats: TransformStats;
}
export function transformMessages(chatId: string, messages: DcpMessage[]): TransformResult {
if (!shouldTransform(chatId, messages.length)) {
return { messages, stats: { removedCount: 0, freedTokens: 0, dedupRemoved: 0, purgeRemoved: 0 } };
}
let m = messages;
// Step 1: Deduplicate
const dedupResult = deduplicate(m);
m = dedupResult.messages;
const dedupRemoved = dedupResult.stats.removedCount;
// Step 2: Purge errors
const purgeResult = purgeErrors(m);
m = purgeResult.messages;
const purgeRemoved = purgeResult.stats.removedCount;
const totalRemoved = dedupRemoved + purgeRemoved;
const totalFreed = dedupResult.stats.freedTokens + purgeResult.stats.freedTokens;
setDcpState(chatId, messages.length);
return {
messages: m,
stats: {
removedCount: totalRemoved,
freedTokens: totalFreed,
dedupRemoved,
purgeRemoved,
},
};
}

View File

@@ -0,0 +1,68 @@
// Loop detectors — detects repetitive patterns in assistant output
// that indicate a model is stuck in a loop.
export interface LoopDetectionResult {
isLoop: boolean;
reason?: string;
confidence: number; // 0-1
}
const REPEATED_PHRASE_MIN_COUNT = 4;
const REPEATED_TOOL_MIN_COUNT = 3;
export function detectContentRepeat(messages: string[]): LoopDetectionResult {
if (messages.length < REPEATED_PHRASE_MIN_COUNT) {
return { isLoop: false, confidence: 0 };
}
const recent = messages.slice(-REPEATED_PHRASE_MIN_COUNT);
const unique = new Set(recent);
if (unique.size === 1) {
return {
isLoop: true,
reason: `Same content repeated ${REPEATED_PHRASE_MIN_COUNT} times`,
confidence: 0.9,
};
}
if (unique.size <= 2 && recent.length >= 4) {
return {
isLoop: true,
reason: 'Content oscillating between two variants',
confidence: 0.7,
};
}
return { isLoop: false, confidence: 0 };
}
export function detectToolLoop(toolNames: string[]): LoopDetectionResult {
if (toolNames.length < REPEATED_TOOL_MIN_COUNT) return { isLoop: false, confidence: 0 };
const recent = toolNames.slice(-REPEATED_TOOL_MIN_COUNT);
const unique = new Set(recent);
if (unique.size === 1) {
return {
isLoop: true,
reason: `Same tool "${recent[0]}" called ${REPEATED_TOOL_MIN_COUNT} times consecutively`,
confidence: 0.85,
};
}
return { isLoop: false, confidence: 0 };
}
export function detectDoomLoop(
messages: string[],
toolNames: string[],
): LoopDetectionResult {
const contentResult = detectContentRepeat(messages);
if (contentResult.isLoop) return contentResult;
const toolResult = detectToolLoop(toolNames);
if (toolResult.isLoop) return toolResult;
return { isLoop: false, confidence: 0 };
}

View File

@@ -0,0 +1,45 @@
// ToolShim — recovers structured tool calls from plain-text model output.
// When the model emits tool calls as plain text instead of structured JSON,
// this shim attempts to parse and recover them.
export interface ParsedToolCall {
id: string;
name: string;
arguments: string;
}
const TOOL_CALL_PATTERN = /<tool_call>\s*<name>(.+?)<\/name>\s*<arguments>(.+?)<\/arguments>\s*<\/tool_call>/gs;
const JSON_TOOL_PATTERN = /\{\s*"name":\s*"([^"]+)",\s*"arguments":\s*({.+?})\s*\}/gs;
export function extractToolCalls(text: string): ParsedToolCall[] {
const calls: ParsedToolCall[] = [];
let match: RegExpExecArray | null;
// Try XML-style tool calls (common in Qwen output)
const xmlRegex = new RegExp(TOOL_CALL_PATTERN);
while ((match = xmlRegex.exec(text)) !== null) {
calls.push({
id: `call_${calls.length}`,
name: match[1]!.trim(),
arguments: match[2]!.trim(),
});
}
if (calls.length > 0) return calls;
// Try JSON-style tool calls
const jsonRegex = new RegExp(JSON_TOOL_PATTERN);
while ((match = jsonRegex.exec(text)) !== null) {
calls.push({
id: `call_${calls.length}`,
name: match[1]!.trim(),
arguments: match[2]!.trim(),
});
}
return calls;
}
export function hasToolCallMarkup(text: string): boolean {
return TOOL_CALL_PATTERN.test(text) || JSON_TOOL_PATTERN.test(text);
}

View File

@@ -21,6 +21,7 @@ import {
buildMessagesPayload, buildMessagesPayload,
loadContext, loadContext,
} from './payload.js'; } from './payload.js';
import { toDcpMessages, transformMessages, fromDcpMessages } from './dcp/index.js';
import { import {
finalizeCompletion, finalizeCompletion,
finalizeEmpty, finalizeEmpty,
@@ -156,9 +157,20 @@ export async function runAssistantTurn(
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop'); ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
break; break;
} }
const { session: iterSession, project: iterProject, history } = loaded; let { session: iterSession, project: iterProject, history } = loaded;
const projectRoot = await resolveProjectRoot(iterProject.path); const projectRoot = await resolveProjectRoot(iterProject.path);
try {
const dcpMsgs = toDcpMessages(history);
const { messages: pruned, stats } = transformMessages(chatId, dcpMsgs);
if (stats.removedCount > 0) {
ctx.log.info({ chatId, ...stats }, 'dcp: transform removed messages');
history = fromDcpMessages(pruned) as typeof history;
}
} catch (err) {
ctx.log.warn({ err: err instanceof Error ? err.message : String(err), chatId }, 'dcp: transform skipped');
}
// v1.14.0: log step boundary for instrumentation. step_start parts are in // v1.14.0: log step boundary for instrumentation. step_start parts are in
// the schema CHECK but not emitted here — writing to the assistant message // the schema CHECK but not emitted here — writing to the assistant message
// before the stream phase creates a sequence-0 collision with // before the stream phase creates a sequence-0 collision with

View File

@@ -44,7 +44,11 @@ export interface InferenceFrame {
| 'chat_renamed' | 'chat_renamed'
| 'error' | 'error'
| 'flow_run_started' | 'flow_run_started'
| 'flow_run_step_updated'; | 'flow_run_step_updated'
// arena frames
| 'battle_started'
| 'contestant_updated'
| 'battle_updated';
message_id?: string; message_id?: string;
message_ids?: string[]; message_ids?: string[];
chat_id?: string; chat_id?: string;
@@ -84,6 +88,19 @@ export interface InferenceFrame {
status?: string; status?: string;
run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
report?: string; report?: string;
// arena frames
battle_id?: string;
battle_type?: 'coding' | 'qa';
prompt?: string;
contestants?: Array<{ id: string; identity: string; model: string; lane: 'local' | 'cloud' }>;
contestant_id?: string;
battle_status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
duration_ms?: number;
tokens_per_sec?: number;
winner_contestant_id?: string | null;
analysis_ready?: boolean;
cross_exam_id?: string;
delta?: string;
} }
export type FramePublisher = (sessionId: string, frame: InferenceFrame) => void; export type FramePublisher = (sessionId: string, frame: InferenceFrame) => void;

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { Bm25Ranker } from '../bm25.js';
describe('Bm25Ranker', () => {
it('scores documents by term frequency', () => {
const ranker = new Bm25Ranker();
ranker.fit(['the cat sat on the mat', 'the dog chased the cat', 'the bird flew over the mat']);
const results = ranker.rank('cat mat');
expect(results.length).toBeGreaterThan(0);
expect(results[0]!.score).toBeGreaterThan(0);
});
it('returns empty for no matches', () => {
const ranker = new Bm25Ranker();
ranker.fit(['aaa bbb', 'ccc ddd']);
const results = ranker.rank('zzz');
expect(results).toHaveLength(0);
});
it('handles single document corpus', () => {
const ranker = new Bm25Ranker();
ranker.fit(['only document here']);
const results = ranker.rank('document');
expect(results).toHaveLength(1);
});
it('ranks relevant docs higher', () => {
const ranker = new Bm25Ranker();
ranker.fit([
'javascript is a programming language',
'python is also a programming language',
'the weather is nice today',
]);
const results = ranker.rank('javascript programming');
expect(results[0]!.index).toBe(0);
});
});

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { parseMemoryEntries } from '../entries.js';
describe('parseMemoryEntries', () => {
it('parses a single entry with tags', () => {
const md = '## project: Indentation\n> tags: style\n\nUse two-space indentation\n';
const entries = parseMemoryEntries('style.md', md);
expect(entries).toHaveLength(1);
expect(entries[0].title).toBe('Indentation');
expect(entries[0].topic).toBe('project');
expect(entries[0].tags).toEqual(['style']);
expect(entries[0].content).toContain('two-space');
});
it('parses multiple entries', () => {
const md = [
'## project: Style',
'',
'Use tab indentation',
'',
'## user: Preference',
'',
'Prefer pnpm',
'',
].join('\n');
const entries = parseMemoryEntries('mem.md', md);
expect(entries).toHaveLength(2);
expect(entries[0].topic).toBe('project');
expect(entries[1].topic).toBe('user');
});
});

View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { getMemoryRoot, getTopicDir } from '../paths.js';
describe('getMemoryRoot', () => {
it('returns .boocode/memory under project root', () => {
expect(getMemoryRoot('/proj')).toBe('/proj/.boocode/memory');
});
});
describe('getTopicDir', () => {
it('returns project/ under memory root', () => {
expect(getTopicDir('/r/.boocode/memory', 'project')).toBe('/r/.boocode/memory/project');
});
});

View File

@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { formatMemoryBlock } from '../prompt.js';
describe('formatMemoryBlock', () => {
it('wraps entries in boocode-memory tags', () => {
const block = formatMemoryBlock(['Use pnpm', 'Tests in vitest']);
expect(block).toContain('<boocode-memory>');
expect(block).toContain('Use pnpm');
expect(block).toContain('</boocode-memory>');
});
it('returns empty string for no entries', () => {
expect(formatMemoryBlock([])).toBe('');
});
});

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { rankByRelevance } from '../recall.js';
import type { MemoryEntry } from '../entries.js';
describe('rankByRelevance', () => {
it('returns entries matching query keywords', () => {
const entries: MemoryEntry[] = [
{ id: '1', topic: 'project', title: 'Style', content: 'Use two-space indentation', tags: ['style'] },
{ id: '2', topic: 'project', title: 'Tests', content: 'Use vitest for testing', tags: ['testing'] },
];
const result = rankByRelevance('what indentation?', entries);
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Style');
});
});
describe('rankByHybrid', () => {
it('falls back to BM25 when embeddings unavailable', async () => {
const entries: MemoryEntry[] = [
{ id: '1', topic: 'project', title: 'Style', content: 'Use two-space indentation', tags: ['style'] },
{ id: '2', topic: 'project', title: 'Tests', content: 'Use vitest for testing', tags: ['testing'] },
];
const { rankByHybrid } = await import('../recall.js');
const result = await rankByHybrid('indentation style', entries);
expect(result.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,67 @@
// BM25 ranker — pure Okapi BM25 scoring. No external deps.
interface Bm25Config {
k1?: number;
b?: number;
}
export class Bm25Ranker {
private k1: number;
private b: number;
private corpus: string[];
private avgDocLen: number;
private idfCache: Map<string, number>;
private docCount: number;
constructor(config?: Bm25Config) {
this.k1 = config?.k1 ?? 1.5;
this.b = config?.b ?? 0.75;
this.corpus = [];
this.avgDocLen = 0;
this.idfCache = new Map();
this.docCount = 0;
}
fit(docs: string[]): void {
this.corpus = docs;
this.docCount = docs.length;
const lengths = docs.map((d) => d.split(/\s+/).length);
this.avgDocLen = lengths.reduce((a, b) => a + b, 0) / lengths.length;
this.idfCache.clear();
}
private tokenize(text: string): string[] {
return text.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
}
private idf(term: string): number {
const cached = this.idfCache.get(term);
if (cached !== undefined) return cached;
const docsWithTerm = this.corpus.filter((d) => this.tokenize(d).includes(term)).length;
const idf = Math.log(1 + (this.docCount - docsWithTerm + 0.5) / (docsWithTerm + 0.5));
this.idfCache.set(term, idf);
return idf;
}
score(query: string, docIndex: number): number {
if (docIndex < 0 || docIndex >= this.corpus.length) return 0;
const doc = this.corpus[docIndex]!;
const queryTerms = this.tokenize(query);
const docTokens = this.tokenize(doc);
const docLen = docTokens.length;
let total = 0;
for (const term of queryTerms) {
const tf = docTokens.filter((t) => t === term).length;
if (tf === 0) continue;
const idfVal = this.idf(term);
total += idfVal * ((tf * (this.k1 + 1)) / (tf + this.k1 * (1 - this.b + this.b * docLen / this.avgDocLen)));
}
return total;
}
rank(query: string, topN: number = 10): Array<{ index: number; score: number }> {
const scores = this.corpus.map((_, i) => ({ index: i, score: this.score(query, i) }));
return scores.sort((a, b) => b.score - a.score).slice(0, topN).filter((s) => s.score > 0);
}
}

View File

@@ -0,0 +1,55 @@
// Embedding module — ONNX-based local embeddings.
// Falls back gracefully when the model file is not available.
let model: any = null;
let ortModule: any = null;
export function isEmbeddingAvailable(): boolean {
return model !== null;
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const dynamicRequire = typeof require !== 'undefined' ? require : null;
export async function initEmbeddings(modelPath?: string): Promise<boolean> {
try {
if (dynamicRequire) {
try { ortModule = dynamicRequire('onnxruntime-node'); } catch { ortModule = null; }
}
if (!ortModule) {
try { ortModule = await import('onnxruntime-node' as any); } catch { ortModule = null; }
}
if (!ortModule) return false;
const path = modelPath ?? process.env['EMBEDDING_MODEL_PATH'] ?? '';
if (!path) return false;
model = await ortModule.InferenceSession.create(path);
return true;
} catch {
model = null;
return false;
}
}
export async function embed(texts: string[]): Promise<number[][] | null> {
if (!model) return null;
try {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const ort: { Tensor: new (...args: unknown[]) => unknown } | null = ortModule || null;
if (!ort) return null;
const input = new ort.Tensor('string', texts, [texts.length]);
const feeds: Record<string, any> = {};
feeds[model.inputNames[0]] = input;
const results = await model.run(feeds);
const output = results[model.outputNames[0]];
if (!output || !output.data) return null;
const dim = output.dims?.[1] ?? 384;
const data = output.data as Float32Array;
const vectors: number[][] = [];
for (let i = 0; i < texts.length; i++) {
vectors.push(Array.from(data.slice(i * dim, (i + 1) * dim)));
}
return vectors;
} catch {
return null;
}
}

View File

@@ -0,0 +1,54 @@
export interface MemoryEntry {
id: string;
topic: string;
title: string;
content: string;
tags: string[];
}
export function parseMemoryEntries(fileName: string, markdown: string): MemoryEntry[] {
const entries: MemoryEntry[] = [];
const lines = markdown.split('\n');
let currentEntry: Partial<MemoryEntry> | null = null;
let currentContent: string[] = [];
for (const line of lines) {
const headingMatch = line.match(/^##\s+(.+):\s+(.+)$/);
if (headingMatch && headingMatch[1] && headingMatch[2]) {
if (currentEntry && currentEntry.title) {
entries.push({
id: `${fileName}-${entries.length}`,
topic: currentEntry.topic ?? '',
title: currentEntry.title,
content: currentContent.join('\n').trim(),
tags: currentEntry.tags ?? [],
});
}
currentEntry = { topic: headingMatch[1].trim(), title: headingMatch[2].trim(), tags: [] };
currentContent = [];
continue;
}
const tagsMatch = line.match(/^>\s*tags:\s*(.+)$/i);
if (tagsMatch && tagsMatch[1] && currentEntry) {
currentEntry.tags = tagsMatch[1].split(',').map((t) => t.trim());
continue;
}
if (currentEntry) {
currentContent.push(line);
}
}
if (currentEntry && currentEntry.title) {
entries.push({
id: `${fileName}-${entries.length}`,
topic: currentEntry.topic ?? '',
title: currentEntry.title,
content: currentContent.join('\n').trim(),
tags: currentEntry.tags ?? [],
});
}
return entries;
}

View File

@@ -0,0 +1,6 @@
export { loadMemoryForSession } from './recall.js';
export { formatMemoryBlock } from './prompt.js';
export { scanMemoryScopes } from './scan.js';
export { parseMemoryEntries } from './entries.js';
export { ensureMemoryScaffold, getMemoryRoot } from './paths.js';
export type { MemoryEntry } from './entries.js';

View File

@@ -0,0 +1,17 @@
import { join } from 'node:path';
import { mkdir } from 'node:fs/promises';
const TOPICS = ['project', 'user', 'reference'] as const;
export type MemoryTopic = (typeof TOPICS)[number];
export function getMemoryRoot(projectRoot: string): string {
return join(projectRoot, '.boocode', 'memory');
}
export function getTopicDir(root: string, topic: MemoryTopic): string {
return join(root, topic);
}
export async function ensureMemoryScaffold(root: string): Promise<void> {
await Promise.all(TOPICS.map((t) => mkdir(join(root, t), { recursive: true })));
}

View File

@@ -0,0 +1,5 @@
export function formatMemoryBlock(entries: string[]): string {
if (entries.length === 0) return '';
const body = entries.map((e) => `- ${e}`).join('\n');
return `<boocode-memory>\n${body}\n</boocode-memory>`;
}

View File

@@ -0,0 +1,100 @@
import type { MemoryEntry } from './entries.js';
import { scanProjectMemory } from './scan.js';
import { Bm25Ranker } from './bm25.js';
import { embed, isEmbeddingAvailable } from './embeddings.js';
const SEARCH_MODE = process.env['MEMORY_SEARCH'] ?? 'hybrid';
function extractKeywords(query: string): string[] {
return query
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.split(/\s+/)
.filter((w) => w.length > 2);
}
export function rankByRelevance(query: string, entries: MemoryEntry[]): MemoryEntry[] {
const keywords = extractKeywords(query);
if (keywords.length === 0) return entries.slice(0, 5);
const scored = entries.map((entry) => {
let score = 0;
const searchText = `${entry.title} ${entry.content} ${entry.tags.join(' ')}`.toLowerCase();
for (const kw of keywords) {
if (entry.title.toLowerCase().includes(kw)) score += 3;
if (entry.tags.some((t) => t.toLowerCase().includes(kw))) score += 2;
if (entry.content.toLowerCase().includes(kw)) score += 1;
}
return { entry, score };
});
return scored
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 10)
.map((s) => s.entry);
}
export async function rankByHybrid(
query: string,
entries: MemoryEntry[],
): Promise<MemoryEntry[]> {
if (entries.length === 0) return [];
const texts = entries.map((e) => `${e.title} ${e.content} ${e.tags.join(' ')}`);
const bm25 = new Bm25Ranker();
bm25.fit(texts);
const bm25Scores = texts.map((_, i) => bm25.score(query, i));
const maxBm25 = Math.max(...bm25Scores, 1);
const normBm25 = bm25Scores.map((s) => s / maxBm25);
let cosineScores: number[] = [];
if (isEmbeddingAvailable()) {
const vectors = await embed([query, ...texts]);
if (vectors) {
const queryVec = vectors[0]!;
cosineScores = texts.map((_, i) => {
const vec = vectors[i + 1];
if (!vec) return 0;
let dot = 0, nA = 0, nB = 0;
for (let j = 0; j < queryVec.length; j++) {
dot += queryVec[j]! * vec[j]!;
nA += queryVec[j]! * queryVec[j]!;
nB += vec[j]! * vec[j]!;
}
const denom = Math.sqrt(nA) * Math.sqrt(nB);
return denom === 0 ? 0 : dot / denom;
});
}
}
const scored = entries.map((entry, i) => {
const combined = (normBm25[i]! * 0.3) + ((cosineScores[i] ?? 0) * 0.7);
return { entry, score: combined };
});
return scored
.filter((s) => s.score >= 0.15)
.sort((a, b) => b.score - a.score)
.slice(0, 10)
.map((s) => s.entry);
}
export async function loadMemoryForSession(
projectRoot: string,
_sessionId?: string,
query?: string,
): Promise<string[]> {
const entries = await scanProjectMemory(projectRoot);
if (entries.length === 0) return [];
const relevant = query
? SEARCH_MODE === 'keyword'
? rankByRelevance(query, entries)
: await rankByHybrid(query, entries)
: entries.slice(0, 5);
return relevant.map((e) => `[${e.topic}] ${e.title}: ${e.content}`);
}
export { initEmbeddings } from './embeddings.js';

View File

@@ -0,0 +1,72 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import { readFile, readdir } from 'node:fs/promises';
import type { MemoryEntry } from './entries.js';
import { parseMemoryEntries } from './entries.js';
import { getMemoryRoot } from './paths.js';
export interface MemoryScope {
projectRoot: string;
sessionDir?: string;
homeDir?: string;
}
async function scanDirectory(dir: string): Promise<MemoryEntry[]> {
const entries: MemoryEntry[] = [];
try {
const files = await readdir(dir, { withFileTypes: true });
for (const file of files) {
if (file.isFile() && file.name.endsWith('.md')) {
const content = await readFile(join(dir, file.name), 'utf8');
entries.push(...parseMemoryEntries(file.name, content));
}
}
} catch {
// Directory doesn't exist
}
return entries;
}
const MEMORY_TOPICS = ['project', 'user', 'reference'] as const;
async function scanTopicDirs(root: string): Promise<MemoryEntry[]> {
const entries: MemoryEntry[] = [];
for (const topic of MEMORY_TOPICS) {
entries.push(...(await scanDirectory(join(root, topic))));
}
return entries;
}
export async function scanMemoryScopes(scope: MemoryScope): Promise<MemoryEntry[]> {
const allEntries: MemoryEntry[] = [];
// 1. Global (~/.boocode/memory/) - lowest priority
allEntries.push(...(await scanTopicDirs(getMemoryRoot(homedir()))));
// 2. Home ($HOME/.boocode/memory)
const homeDir = scope.homeDir ?? homedir();
const homeRoot = getMemoryRoot(homeDir);
if (homeRoot !== getMemoryRoot(homedir())) {
allEntries.push(...(await scanTopicDirs(homeRoot)));
}
// 3. Project (.boocode/memory/ under project root)
allEntries.push(...(await scanTopicDirs(getMemoryRoot(scope.projectRoot))));
// 4. Session (.boocode/sessions/<id>/memory.md) - highest priority
if (scope.sessionDir) {
try {
const sessionFile = join(scope.sessionDir, 'memory.md');
const content = await readFile(sessionFile, 'utf8');
allEntries.push(...parseMemoryEntries('session-memory', content));
} catch {
// No session memory file
}
}
return allEntries;
}
export async function scanProjectMemory(projectRoot: string): Promise<MemoryEntry[]> {
return scanMemoryScopes({ projectRoot });
}

View File

@@ -0,0 +1,35 @@
import { readFile, writeFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import type { MemoryTopic } from './paths.js';
import { getTopicDir } from './paths.js';
export async function readTopicFiles(root: string, topic: MemoryTopic): Promise<Map<string, string>> {
const dir = getTopicDir(root, topic);
const files = new Map<string, string>();
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.md')) {
const content = await readFile(join(dir, entry.name), 'utf8');
files.set(entry.name, content);
}
}
} catch {
// Directory doesn't exist yet
}
return files;
}
export async function writeEntry(
root: string,
topic: MemoryTopic,
title: string,
content: string,
tags: string[],
): Promise<void> {
const dir = getTopicDir(root, topic);
const tagLine = tags.length > 0 ? `> tags: ${tags.join(', ')}\n\n` : '\n';
const entry = `## ${topic}: ${title}\n${tagLine}${content}\n`;
const filename = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.md';
await writeFile(join(dir, filename), entry, 'utf8');
}

View File

@@ -35,6 +35,7 @@ export const SYNTHESIS_TOOLS: ReadonlySet<string> = new Set([
'get_codebase_overview', 'get_codebase_overview',
'get_framework_analysis', 'get_framework_analysis',
'get_semantic_neighborhoods', 'get_semantic_neighborhoods',
'get_blast_radius',
]); ]);
const TOP_N_FILES = 5; const TOP_N_FILES = 5;

View File

@@ -22,6 +22,8 @@ import { readFile, stat } from 'node:fs/promises';
import type { Agent, Project, Session } from '../types/api.js'; import type { Agent, Project, Session } from '../types/api.js';
import { getAgentsMtimes } from './agents.js'; import { getAgentsMtimes } from './agents.js';
import { resolveRoute } from './inference/provider.js'; import { resolveRoute } from './inference/provider.js';
import { loadMemoryForSession } from './memory/recall.js';
import { formatMemoryBlock } from './memory/prompt.js';
const BASE_SYSTEM_PROMPT = (projectPath: string) => const BASE_SYSTEM_PROMPT = (projectPath: string) =>
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`; `You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
@@ -164,7 +166,11 @@ export async function buildSystemPromptWithFingerprint(
let out = BASE_SYSTEM_PROMPT(project.path); let out = BASE_SYSTEM_PROMPT(project.path);
const guidance = await getContainerGuidance(); const guidance = await getContainerGuidance();
if (guidance) { if (guidance) {
out += `\n\n--- Container guidance ---\n${guidance}\n--- end container guidance ---\n`; out += '\n\n--- Container guidance ---\n' + guidance + '\n--- end container guidance ---\n';
}
const memory = await loadMemoryForSession(project.path, session.id).catch(() => []);
if (memory.length > 0) {
out += '\n\n' + formatMemoryBlock(memory);
} }
if (agent && agent.system_prompt.trim().length > 0) { if (agent && agent.system_prompt.trim().length > 0) {
out += '\n\n' + agent.system_prompt.trim(); out += '\n\n' + agent.system_prompt.trim();

View File

@@ -0,0 +1,31 @@
import { z } from 'zod';
import { makeCodecontextTool } from './factory.js';
export const GetCallGraphInput = z.object({
symbol: z.string().describe('Symbol name to analyze'),
depth: z.number().int().min(1).max(5).optional().describe('Max traversal depth (default 2)'),
});
export type GetCallGraphInputT = z.infer<typeof GetCallGraphInput>;
const DESCRIPTION =
'Returns a call graph for a function or method: callers, callees, and transitive references. ' +
'Use to understand how a symbol is invoked and what it depends on.';
const { toolDef: getCallGraph, execute: executeGetCallGraph } =
makeCodecontextTool<GetCallGraphInputT>({
name: 'get_call_graph',
schema: GetCallGraphInput,
description: DESCRIPTION,
jsonParameters: {
type: 'object',
properties: {
symbol: { type: 'string', description: 'Symbol name to analyze' },
depth: { type: 'number', description: 'Max traversal depth (default 2)' },
},
required: ['symbol'],
additionalProperties: false,
},
mapArgs: (input) => ({ symbol: input.symbol, depth: input.depth ?? 2 }),
});
export { getCallGraph, executeGetCallGraph };

View File

@@ -0,0 +1,31 @@
import { z } from 'zod';
import { makeCodecontextTool } from './factory.js';
export const GetSymbolDetailsInput = z.object({
symbol: z.string().describe('Symbol name to resolve'),
file_path: z.string().optional().describe('Optional file path to narrow search'),
});
export type GetSymbolDetailsInputT = z.infer<typeof GetSymbolDetailsInput>;
const DESCRIPTION =
'Returns type signature, definition location, and usage count for a named symbol. ' +
'Use after get_codebase_overview to dive deeper into specific functions, classes, or variables.';
const { toolDef: getSymbolDetails, execute: executeGetSymbolDetails } =
makeCodecontextTool<GetSymbolDetailsInputT>({
name: 'get_symbol_details',
schema: GetSymbolDetailsInput,
description: DESCRIPTION,
jsonParameters: {
type: 'object',
properties: {
symbol: { type: 'string', description: 'Symbol name to resolve' },
file_path: { type: 'string', description: 'Optional file path to narrow search' },
},
required: ['symbol'],
additionalProperties: false,
},
mapArgs: (input) => ({ symbol: input.symbol, file_path: input.file_path }),
});
export { getSymbolDetails, executeGetSymbolDetails };

View File

@@ -0,0 +1,44 @@
import { z } from 'zod';
import type { ToolDef } from '../tools/types.js';
import { ensureMemoryScaffold, getMemoryRoot } from '../memory/paths.js';
import { writeEntry } from '../memory/store.js';
const ExtractMemoryInput = z.object({
topic: z.enum(['project', 'user', 'reference']).describe('Memory topic category'),
title: z.string().min(1).max(200).describe('Entry title (will be normalized to filename)'),
content: z.string().min(1).describe('Memory content body'),
tags: z.array(z.string()).optional().describe('Optional tags for search'),
});
type InputT = z.infer<typeof ExtractMemoryInput>;
export const extractMemoryTool: ToolDef<InputT> = {
name: 'extract_memory',
description: 'Persist a memory entry to .boocode/memory/ for cross-session recall. Use for project conventions, user preferences, and architectural decisions.',
inputSchema: ExtractMemoryInput,
jsonSchema: {
type: 'function',
function: {
name: 'extract_memory',
description: 'Persist a memory entry for cross-session recall',
parameters: {
type: 'object',
properties: {
topic: { type: 'string', enum: ['project', 'user', 'reference'] },
title: { type: 'string', description: 'Entry title' },
content: { type: 'string', description: 'Memory content' },
tags: { type: 'array', items: { type: 'string' }, description: 'Search tags' },
},
required: ['topic', 'title', 'content'],
},
},
},
async execute(input: InputT, projectRoot: string): Promise<unknown> {
const root = getMemoryRoot(projectRoot);
await ensureMemoryScaffold(root);
await writeEntry(root, input.topic, input.title, input.content, input.tags ?? []);
return {
result: `Memory entry "${input.title}" saved to .boocode/memory/${input.topic}/`,
};
},
};

View File

@@ -0,0 +1,40 @@
import { z } from 'zod';
import type { ToolDef } from '../tools/types.js';
import { scanProjectMemory } from '../memory/scan.js';
import { rankByHybrid } from '../memory/recall.js';
const SearchMemoryInput = z.object({
query: z.string().min(1).describe('Search query to match against memory entries'),
});
type InputT = z.infer<typeof SearchMemoryInput>;
export const searchMemoryTool: ToolDef<InputT> = {
name: 'search_memory',
description: 'Search the .boocode/memory/ store for relevant entries. Returns ranked results matching the query. Use before asking about project conventions or preferences.',
inputSchema: SearchMemoryInput,
jsonSchema: {
type: 'function',
function: {
name: 'search_memory',
description: 'Search memory store for relevant entries',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
},
required: ['query'],
},
},
},
async execute(input: InputT, projectRoot: string): Promise<unknown> {
const entries = await scanProjectMemory(projectRoot);
if (entries.length === 0) return { result: 'No memory entries found.' };
const relevant = await rankByHybrid(input.query, entries);
if (relevant.length === 0) return { result: 'No matching memory entries.' };
const lines = relevant.map((e) => `[${e.topic}] ${e.title}: ${e.content}`);
return { result: `Found ${relevant.length} entry(ies):\n${lines.join('\n')}` };
},
};

View File

@@ -16,6 +16,7 @@ import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRai
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
import { ThemeFx } from '@/components/fx/ThemeFx'; import { ThemeFx } from '@/components/fx/ThemeFx';
import { FlowLauncherDialog } from '@/components/FlowLauncherDialog'; import { FlowLauncherDialog } from '@/components/FlowLauncherDialog';
import { ArenaLauncherDialog } from '@/components/ArenaLauncherDialog';
function SessionRightRail() { function SessionRightRail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -102,6 +103,7 @@ function AppShell() {
</Routes> </Routes>
<Toaster position="bottom-right" /> <Toaster position="bottom-right" />
<FlowLauncherDialog /> <FlowLauncherDialog />
<ArenaLauncherDialog />
</div> </div>
</> </>
); );

Some files were not shown because too many files have changed in this diff Show More