Compare commits

...

13 Commits

Author SHA1 Message Date
6fde7002aa docs: boocode-lift-analysis, openspec change docs, codesight cache, deps
- Add boocode-lift-analysis.md: comprehensive 30-repo lift matrix across 25 domains
- Add openspec/ change docs: domain2-code-intelligence, domain3-multi-agent, impeccable-wave, streaming-codeblocks
- Update .gitignore: .impeccable/, .omo/, bun.lock, DESIGN.md, PRODUCT.md
- Update dependencies in package.json + pnpm-lock.yaml
- Update .codesight/ analysis cache
2026-06-08 03:49:26 +00:00
50de80ee75 feat(web): workspace components — ComparePane, Memory page, McpDialog, error boundaries, message-parts
- Add ComparePane.tsx: side-by-side AI response comparison
- Add Memory.tsx: memory management page with CRUD UI
- Add McpPermissionDialog.tsx: MCP tool permission approval dialog
- Add McpResponseDisplay.tsx: MCP response visualization
- Add MessageBoundary.tsx + MessageListErrorBoundary.tsx: error resilience
- Add EmptyState.tsx: contextual empty state component
- Add KeyboardShortcutsDialog.tsx: keyboard shortcut reference
- Add message-parts/: ActionRow, CompactCard, MistakeRecoverySentinel, ReasoningBlock, SendToTerminalMenu, StatsLine, SummaryCard
- Add useDraftPersistence.ts: draft message persistence hook
- Add useTerminals.ts: terminal session management hook
- Add keyboard-shortcuts.ts + tool-utils.ts: shared utilities
- Extend components: ChatInput, MessageBubble, MessageList, Workspace, panes
- Extend hooks: useTerminalSocket, useSessionStream test suite
- Update pages: Home, Project — workspace layout and session flow
2026-06-08 03:49:22 +00:00
51733c1338 feat(contracts): ws-frames and message-metadata extensions
- Extend WsFrameSchema: new frame types for memory, state-graph events
- Extend MessageMetadata: AgentSessionConfig, ErrorReason variants
2026-06-08 03:49:06 +00:00
fa07b01567 feat(booterm): PTY session metadata, terminal registry, WS attach enhancements
- Add PTY session metadata tracking (title, description, parent agent)
- Extend terminal registry: structured session metadata
- Extend WS attach: session-aware WebSocket lifecycle
- Extend routes: terminals and sessions with metadata
2026-06-08 03:49:02 +00:00
e2d6a6b6cd feat(coder): flow-runner decisions, conductor types, collision detection tests
- Add flow-runner-decisions.ts: decision-aware step execution
- Extend flow-runner.ts: dynamic step decisions
- Extend conductor types: additional flow state types
- Add collision-detector.test.ts: edit collision unit tests
- Add conflict-index.test.ts: conflict resolution index tests
2026-06-08 03:48:58 +00:00
381b97f78a feat(server): inference state-graph + supervisor, memory tools, MCP client, schema, routes
- Add state-graph.ts: typed state machine for inference lifecycle
- Add supervisor.ts: agent supervisor pattern for multi-agent coordination
- Add export-formatter.ts: structured export formatting
- Add manage_memory.ts: memory CRUD tool for agent persistence
- Add get_wiki_article.ts: codecontext wiki article retrieval
- Extend memory/index.ts: 3-tier memory (context/daily/core)
- Extend MCP client: mcp-config.ts env-var substitution
- Update schema.sql: agent_sessions, tasks, pending_changes extensions
- Update API types: MessageMetadata, ErrorReason, AgentSessionConfig
- Update routes: chats, messages, sessions — column renames and agent_session_id
- Update inference: error handler, payload builder, stream phase, turn orchestrator
2026-06-08 03:48:47 +00:00
9e2b0a7dc0 docs: guidance audit — refusals up front, version anchors, failure modes, resolution order, drift guards
Apply 7 proposed edits from guidance improver audit:
- CLAUDE.md: refusal rails up front, version anchor, resolution order
- BOOCHAT.md: resolution order section
- BOOCODER.md: tool reliability callouts
- data/AGENTS.md: tool list drift guard, failure modes preamble
2026-06-08 03:20:33 +00:00
51f2f4284f docs: changelog + roadmap for v2.8.19-v2.8.20 2026-06-08 03:14:46 +00:00
45a1140fd3 feat: phase 3-5 — workflow engine, background subagents, multi-modal, cache shape, inline diff
Phase 3: Dynamic Workflow Engine
- VM sandbox (node:vm) with agent/parallel/pipeline API, Claude Code compatible
- Workflow file discovery (.boocode/workflows/*.js + ~/.boocode/workflows/*.js)
- Workflow manager with session/chat creation and inference dispatch
- Built-in catalog: deep-research, review-code, find-issues
- Resumability cache: SHA-256 hash of agent spec, in-memory Map

Phase 4: Background Subagents
- background-task.ts service: spawn/poll/cancel lifecycle
- spawn_subagent, subagent_status, subagent_result tools in ALL_TOOLS

Phase 5: Multi-modal + Cache Shape
- Multi-modal stub with type defs and hook point in payload.ts
- CacheShapeBadge component in trace viewer (colored bar + %)
2026-06-08 03:11:39 +00:00
74da084521 feat(conductor): Wave 2 — parallel batch execution + SWITCH branching step
- Parallel batch execution: batch field on Step, batchConfig on Flow,
  batch-aware readySteps with maxConcurrent gating, getReadyInBatch helper
- SWITCH branching step: new 'switch' StepKind with cases/programmed conditions,
  resolveSwitch() pure function, switch-excluded steps tracked in
  SchedulerState, non-selected branches excluded from execution
2026-06-08 03:00:06 +00:00
c860b6c4b7 feat: Wave 1 complete — state machine, Paseo hub, collision detection, PTY search
- Task state machine: TIMED_OUT state, retriable steps, timeout detection
- Paseo hub: paseo-client.ts (HTTP+CLI), PaseoBackend (AgentBackend), 14 tests
- Collision detection: collision-detector.ts, conflict-index.ts, ws-frames type
- PTY search: ring buffer, search route, capture-pane fallback
2026-06-08 02:45:17 +00:00
c4ee377dbc feat(conductor): task state machine — TIMED_OUT state and retriable steps
- Add 'timed_out' to flow_runs/flow_steps CHECK constraints
- Add retry_count and max_retries columns to flow_steps
- Add timeout detection in advanceInner loop (configurable FLOW_STEP_TIMEOUT_MS)
- Add retriable logic: re-dispatch on timeout if maxRetries > 0 and retryCount < maxRetries
- Add isRetriable() + shouldRetry() pure decision functions
- Add timed_out handling to reconcileResumeStep and reconcileRun
- Add 'timed_out' to ws-frames enum, publishStep status type
2026-06-08 02:43:45 +00:00
f2401352a8 chore: update pnpm-lock.yaml for @ai-sdk/deepseek 2026-06-08 02:28:32 +00:00
149 changed files with 14315 additions and 531 deletions

View File

@@ -3,9 +3,9 @@
> **Stack:** fastify, go-net-http | none | react | typescript
> **Microservices:** @boocode/contracts, @boocode/ion, @boocode/booterm, @boocode/coder, @boocode/server, @boocode/web, codecontext, @boocode/conductor
> 131 routes (9 inferred) + 9 ws | 18 models | 69 components | 247 lib files | 39 env vars | 16 middleware
> 147 routes (9 inferred) + 9 ws | 23 models | 92 components | 296 lib files | 43 env vars | 17 middleware
> **Token savings:** this file is ~0 tokens. Without it, AI exploration would cost ~0 tokens. **Saves ~0 tokens per conversation.**
> **Last scanned:** 2026-06-07 21:09 — re-run after significant changes
> **Last scanned:** 2026-06-08 03:49 — re-run after significant changes
---
@@ -14,6 +14,7 @@
## CRUD Resources
- **`/api/battles`** GET | POST | GET/:id → Battle
- **`/api/plans`** GET | POST | GET/:id | PATCH/:id → Plan
- **`/api/runs`** GET | POST | GET/:id → Run
- **`/api/tasks`** GET | POST | GET/:id → Task
- **`/api/chats/:id/messages`** GET | POST | GET/:id | DELETE/:id → Message
@@ -25,11 +26,16 @@
### fastify
- `GET` `/api/term/health` params()
- `GET` `/api/term/sessions/:sid/panes/:pid/search` params(sid, pid) [auth]
- `GET` `/api/term/sessions` params() [auth]
- `POST` `/api/term/sessions/:sid/panes/:pid/start` params(sid, pid) [auth]
- `POST` `/api/term/sessions/:sid/panes/:pid/kill` params(sid, pid) [auth]
- `GET` `/ws/term/sessions/:sid/panes/:pid` params(sid, pid) [auth]
- `GET` `/api/health` params() [auth, db, queue, ai]
- `GET` `/api/sessions/:sessionId/agent-sessions` params(sessionId) [auth, db]
- `GET` `/api/analytics/summary` params() [auth, db]
- `GET` `/api/analytics/sessions` params() [auth, db]
- `GET` `/api/analytics/token-breakdown` params() [auth, db]
- `POST` `/api/battles/generate-prompt` params() [auth, db]
- `POST` `/api/battles/:id/stop` params(id) [auth, db]
- `GET` `/api/battles/:id/analysis` params(id) [auth, db]
@@ -53,6 +59,7 @@
- `POST` `/api/pending/:id/apply` params(id) [auth, db, queue]
- `POST` `/api/pending/:id/reject` params(id) [auth, db, queue]
- `POST` `/api/pending/:id/rewind` params(id) [auth, db, queue]
- `GET` `/api/plans/active` params() [db]
- `GET` `/api/providers/snapshot` params() [db, cache]
- `GET` `/api/providers/config` params() [db, cache]
- `PATCH` `/api/providers/config` params() [db, cache]
@@ -70,19 +77,22 @@
- `GET` `/api/ws/sessions/:sessionId` params(sessionId) [auth, db]
- `GET` `/api/ws/user` params() [auth, db]
- `GET` `/api/projects/:id/agents` params(id) [db, cache]
- `GET` `/api/analytics/context` params() [auth, db]
- `POST` `/api/chats/:id/messages/:msg_id/artifacts/download` params(id, msg_id) [auth, db]
- `GET` `/api/chats/:id/messages/:msg_id/html_artifact` params(id, msg_id) [auth, db]
- `GET` `/api/projects/:project_id/artifacts/:filename` params(project_id, filename) [auth, db]
- `GET` `/api/sessions/:id/chats` params(id) [auth, db]
- `POST` `/api/sessions/:id/chats` params(id) [auth, db]
- `PATCH` `/api/chats/:id` params(id) [auth, db]
- `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db]
- `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db]
- `POST` `/api/chats/:id/archive` params(id) [auth, db]
- `POST` `/api/chats/:id/unarchive` params(id) [auth, db]
- `DELETE` `/api/chats/:id` params(id) [auth, db]
- `POST` `/api/chats/:id/fork` params(id) [auth, db]
- `POST` `/api/chats/:id/discard_stale` params(id) [auth, db]
- `GET` `/api/sessions/:id/chats` params(id) [auth, db, queue]
- `POST` `/api/sessions/:id/chats` params(id) [auth, db, queue]
- `PATCH` `/api/chats/:id` params(id) [auth, db, queue]
- `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db, queue]
- `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/archive` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/unarchive` params(id) [auth, db, queue]
- `DELETE` `/api/chats/:id` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/fork` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/discard_stale` params(id) [auth, db, queue]
- `GET` `/api/chats/:id/export` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/compare` params(id) [auth, db, queue]
- `GET` `/api/coder/ws/sessions/:sessionId` params(sessionId) [auth]
- `ALL` `/api/coder/*` params() [auth]
- `GET` `/api/settings/inference` params() [cache]
@@ -94,7 +104,9 @@
- `POST` `/api/chats/:id/continue` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/force_send` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/grant_read_access` params(id) [auth, db, queue]
- `GET` `/api/models` params()
- `POST` `/api/chats/:id/mcp-approve` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/messages/:message_id/feedback` params(id, message_id) [auth, db, queue]
- `GET` `/api/models` params() [auth]
- `POST` `/api/projects/create` params() [auth, db]
- `POST` `/api/projects/:id/archive` params(id) [auth, db]
- `POST` `/api/projects/:id/unarchive` params(id) [auth, db]
@@ -122,6 +134,7 @@
- `GET` `/api/skills` params() [auth, db, queue]
- `POST` `/api/chats/:id/skill_invoke` params(id) [auth, db, queue]
- `GET` `/api/tools/cost_stats` params() [auth, db]
- `GET` `/api/chats/:id/traces` params(id) [db]
- `GET` `/api/ws/sessions/:id` params(id) [auth, db]
### go-net-http
@@ -273,6 +286,25 @@
- model: text (required)
- verdict: text
### flow_step_events
- id: uuid (pk)
- run_id: uuid (required, fk)
- step_id: varchar (required, fk)
- event: varchar (required)
- payload: jsonb
### plans
- id: uuid (pk)
- project_id: uuid (required, fk)
- title: text (required)
- description: text
- status: text (required)
- flow_run_id: uuid (fk)
- progress_pct: integer (required)
- items_total: integer (required)
- items_completed: integer (required)
- metadata: jsonb
### projects
- id: uuid (pk)
- name: text (required)
@@ -294,6 +326,8 @@
- content: text (required)
- status: text (required)
- last_seq: integer (required)
- cache_tokens: integer
- reasoning_tokens: integer
### message_parts
- id: uuid (pk)
@@ -311,6 +345,45 @@
- name: text
- status: text (required)
### tool_traces
- id: uuid (pk)
- session_id: uuid (required, fk)
- chat_id: uuid (required, fk)
- message_id: uuid (fk)
- turn_number: integer (required)
- tool_name: text (required)
- tool_input: jsonb (required)
- tool_output: text
- started_at: timestamp(tz) (required)
- finished_at: timestamp(tz)
- latency_ms: integer
- tokens_used: integer
- cache_tokens: integer
- reasoning_tokens: integer
- error: text
- outcome: text
### tool_trace_states
- id: uuid (pk)
- session_id: uuid (required, fk)
- chat_id: uuid (required, fk)
- message_id: uuid (fk)
- turn_number: integer (required)
- tool_name: text (required)
- tool_input: jsonb (required)
- started_at: timestamp(tz) (required)
### agent_snapshots
- id: uuid (pk)
- session_id: uuid (required, fk)
- chat_id: uuid (required, fk)
- model: text (required)
- agent: text
- mode: text
- turn_number: integer (required)
- messages: jsonb (required)
- tool_states: jsonb (required)
---
# Components
@@ -325,23 +398,34 @@
- **AttachmentChip** — props: attachment, onRemove, onPreview — `apps/web/src/components/AttachmentChip.tsx`
- **AttachmentPreviewModal** — props: attachment, onClose — `apps/web/src/components/AttachmentPreviewModal.tsx`
- **BottomSheet** — props: open, onClose, title — `apps/web/src/components/BottomSheet.tsx`
- **CacheShapeBadge** — props: cacheTokens, totalTokens — `apps/web/src/components/CacheShapeBadge.tsx`
- **CapHitSentinel** — props: message, capHitPosition, isLatest — `apps/web/src/components/CapHitSentinel.tsx`
- **ChatInput** — props: disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop — `apps/web/src/components/ChatInput.tsx`
- **ChatTabBar** — props: pane, tabs, tabNumbers, onSwitchTab, onRemoveTab, onCloseOthers, onCloseToRight, onCloseAll, onNewTab, onSplitPane — `apps/web/src/components/ChatTabBar.tsx`
- **ChatThroughput** — props: chatId, className — `apps/web/src/components/ChatThroughput.tsx`
- **CodeBlock** — props: code, lang — `apps/web/src/components/CodeBlock.tsx`
- **ComparePane** — props: models, responses, onClose — `apps/web/src/components/ComparePane.tsx`
- **ContextMeter** — props: messages, modelContextLimit, sessionCostUsd — `apps/web/src/components/ContextMeter.tsx`
- **CreateProjectModal** — props: open, onOpenChange — `apps/web/src/components/CreateProjectModal.tsx`
- **DiffSnippet** — props: diff — `apps/web/src/components/DiffSnippet.tsx`
- **DiffSplitView** — props: file, wrapLines — `apps/web/src/components/DiffSplitView.tsx`
- **DoomLoopSentinel** — props: message — `apps/web/src/components/DoomLoopSentinel.tsx`
- **DropOverlay** — props: visible — `apps/web/src/components/DropOverlay.tsx`
- **EmptyState** — props: icon, title, description, action, className — `apps/web/src/components/EmptyState.tsx`
- **FileMentionPopover** — props: query, files, anchorRect, onSelect, onClose — `apps/web/src/components/FileMentionPopover.tsx`
- **FileViewerOverlay** — props: path, content, lang, onClose — `apps/web/src/components/FileViewerOverlay.tsx`
- **FlowLauncherDialog** — `apps/web/src/components/FlowLauncherDialog.tsx`
- **GitDiffView** — props: result, loading, error, mode, onSelectMode, onRefresh, mutating, mutateError, onStage, onUnstage — `apps/web/src/components/GitDiffView.tsx`
- **HtmlArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/HtmlArtifactPane.tsx`
- **InferenceSettings** — `apps/web/src/components/InferenceSettings.tsx`
- **InlineReviewEditor** — props: initialBody, onSave, onCancel — `apps/web/src/components/InlineReviewEditor.tsx`
- **InlineReviewGutterCell** — props: lineNumber, type, hasComments, canComment, onClick — `apps/web/src/components/InlineReviewGutterCell.tsx`
- **InlineReviewThread** — props: comments, onEditComment, onDeleteComment — `apps/web/src/components/InlineReviewThread.tsx`
- **KeyboardShortcutsDialog** — props: open, onOpenChange — `apps/web/src/components/KeyboardShortcutsDialog.tsx`
- **MarkdownArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/MarkdownArtifactPane.tsx`
- **MarkdownRenderer** — props: content — `apps/web/src/components/MarkdownRenderer.tsx`
- **McpPermissionDialog** — props: toolCallId, toolName, toolArgs, chatId, open, onClose — `apps/web/src/components/McpPermissionDialog.tsx`
- **McpResponseDisplay** — props: toolCall, toolResult — `apps/web/src/components/McpResponseDisplay.tsx`
- **MessageBubble** — props: message, sessionChats, capHitInfo, actions, hideActions, hasCheckpoint, restoreDisabled — `apps/web/src/components/MessageBubble.tsx`
- **MessageList** — props: messages, sessionChats — `apps/web/src/components/MessageList.tsx`
- **MobileTabSwitcher** — props: panes, activePaneIdx, chats, onSwitchPane, onRemovePane, onRenameChat — `apps/web/src/components/MobileTabSwitcher.tsx`
@@ -353,12 +437,14 @@
- **RequestReadAccessCard** — props: toolCall, toolResult, chatId — `apps/web/src/components/RequestReadAccessCard.tsx`
- **RightRail** — props: projectId, sessionId — `apps/web/src/components/RightRail.tsx`
- **SessionLandingPage** — props: projectId, sessionId, agentId, onAgentChange, onSend, onSkillInvoke, createChat, chats, onOpenChat, onUnarchiveChat — `apps/web/src/components/SessionLandingPage.tsx`
- **SessionTimeline** — props: messages, onClose, onScrollToMessage — `apps/web/src/components/SessionTimeline.tsx`
- **SlashCommandPicker** — props: query, items, groups, inputRef, onSelect, onClose, emptyLabel — `apps/web/src/components/SlashCommandPicker.tsx`
- **StaleStreamBanner** — props: onRetry, onDiscard — `apps/web/src/components/StaleStreamBanner.tsx`
- **StatusDot** — props: chatId, className — `apps/web/src/components/StatusDot.tsx`
- **ThemePicker** — `apps/web/src/components/ThemePicker.tsx`
- **ToolCallGroup** — props: runs — `apps/web/src/components/ToolCallGroup.tsx`
- **ToolCallLine** — props: run, insideGroup — `apps/web/src/components/ToolCallLine.tsx`
- **ToolCallLine** — props: run, insideGroup, chatId`apps/web/src/components/ToolCallLine.tsx`
- **TraceViewer** — props: chatId — `apps/web/src/components/TraceViewer.tsx`
- **Workspace** — props: sessionId, projectId, agentId, onAgentChange, panesHook, chatsHook, session, project, onAddPane — `apps/web/src/components/Workspace.tsx`
- **AddProviderModal** — props: open, onOpenChange, onAdded — `apps/web/src/components/coder/AddProviderModal.tsx`
- **ProvidersSettings** — `apps/web/src/components/coder/ProvidersSettings.tsx`
@@ -367,21 +453,31 @@
- **ThemeFx** — `apps/web/src/components/fx/ThemeFx.tsx`
- **ClaudeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
- **OpenCodeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
- **ActionRow** — props: message, actions, hiddenSet, hasCheckpoint, restoreDisabled — `apps/web/src/components/message-parts/ActionRow.tsx`
- **CompactCard** — props: message, sessionChats — `apps/web/src/components/message-parts/CompactCard.tsx`
- **MistakeRecoverySentinel** — props: message — `apps/web/src/components/message-parts/MistakeRecoverySentinel.tsx`
- **ReasoningBlock** — props: text, streaming — `apps/web/src/components/message-parts/ReasoningBlock.tsx`
- **SendToTerminalMenu** — `apps/web/src/components/message-parts/SendToTerminalMenu.tsx`
- **StatsLine** — props: message — `apps/web/src/components/message-parts/StatsLine.tsx`
- **SummaryCard** — props: message — `apps/web/src/components/message-parts/SummaryCard.tsx`
- **ArenaPane** — props: state, onClose — `apps/web/src/components/panes/ArenaPane.tsx`
- **ChatPane** — props: sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled — `apps/web/src/components/panes/ChatPane.tsx`
- **CoderMessageList** — props: messages, chatId, footer, actions, checkpointMessageIds, restoreDisabled — `apps/web/src/components/panes/CoderMessageList.tsx`
- **CoderPane** — props: sessionId, paneId, chatId, chatPending, projectPath, onConnectedChange, onAgentLabelChange — `apps/web/src/components/panes/CoderPane.tsx`
- **OrchestratorPane** — props: state, onClose — `apps/web/src/components/panes/OrchestratorPane.tsx`
- **SettingsPane** — props: session, project, maximized, onToggleMaximize, onClose, isMobile — `apps/web/src/components/panes/SettingsPane.tsx`
- **TerminalPane** — props: sessionId, paneId, label, active — `apps/web/src/components/panes/TerminalPane.tsx`
- **TerminalPane** — props: sessionId, paneId, label, description, parentAgent, active — `apps/web/src/components/panes/TerminalPane.tsx`
- **FloatingMenu** — props: x, y, hasSelection, chatInputs, onCopy, onPaste, onSelectAll, onSearch, onSendToChat, onDismiss — `apps/web/src/components/panes/terminal/FloatingMenu.tsx`
- **SearchBar** — props: searchRef, theme, onClose — `apps/web/src/components/panes/terminal/SearchBar.tsx`
- **TerminalHotkeyBar** — props: ctrlArmed, onSendBytes, onArmCtrl, onFit — `apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx`
- **RightRailDrawerProvider** — `apps/web/src/hooks/useRightRailDrawer.tsx`
- **SidebarDrawerProvider** — `apps/web/src/hooks/useSidebarDrawer.tsx`
- **PATH_REGEX** — `apps/web/src/lib/linkify-paths.tsx`
- **Analytics** — `apps/web/src/pages/Analytics.tsx`
- **Home** — `apps/web/src/pages/Home.tsx`
- **Memory** — `apps/web/src/pages/Memory.tsx`
- **Project** — `apps/web/src/pages/Project.tsx`
- **Results** — `apps/web/src/pages/Results.tsx`
- **Session** — `apps/web/src/pages/Session.tsx`
- **Settings** — `apps/web/src/pages/Settings.tsx`
@@ -403,8 +499,17 @@
- function ensureSession: (tmuxConfPath, sessionName, projectRoot, log, cols?, rows?) => Promise<void>
- function killSession: (tmuxConfPath, sessionName) => Promise<boolean>
- function capturePane: (tmuxConfPath, sessionName, lines) => Promise<string>
- _...1 more_
- `apps/booterm/src/pty/pty.ts` — function attachPty: (opts) => IPty
- `apps/booterm/src/ws/attach.ts` — function registerWsAttachRoute: (app, tmuxConfPath) => void
- `apps/booterm/src/pty/registry.ts`
- function register: (sessionId, paneId, projectPath, title?, opts?) => void
- function unregister: (paneId) => void
- function touchActivity: (paneId) => void
- function list: () => SessionMeta[]
- function get: (paneId) => SessionMeta | undefined
- function setPendingMetadata: (paneId, meta) => void
- _...8 more_
- `apps/booterm/src/ws/attach.ts` — function registerWsAttachRoute: (app, tmuxConfPath, idleTimeoutSeconds?, absoluteTimeoutSeconds?) => void
- `apps/coder/src/conductor/contracts.ts`
- function produceContract: (contracts) => string
- function reviewContract: (contracts) => string
@@ -491,7 +596,7 @@
- function classifyLane: (battleType, _identity, model, localModels) => ContestantLane
- function nextLocalContestant: (contestants) => string | null
- function isBattleComplete: (contestants) => boolean
- function computeBenchmark: (startedAt, endedAt, costTokens, lane) => Benchmark
- function computeBenchmark: (startedAt, endedAt, costTokens, lane, tokenBreakdown) => Benchmark
- function sanitizeSlug: (s) => string
- function buildBattleSlug: (battleId, battleType, createdAt) => string
- _...7 more_
@@ -555,6 +660,7 @@
- function stepEndedToUsage: (props) => StepUsage
- interface StepEndedProps
- interface StepUsage
- `apps/coder/src/services/backends/paseo.ts` — class PaseoBackend, interface PaseoBackendDeps
- `apps/coder/src/services/backends/pushable-iterable.ts` — function createPushable: () => Pushable<T>, interface Pushable
- `apps/coder/src/services/backends/turn-guard.ts`
- function armAbortGuard: (g) => void
@@ -563,6 +669,30 @@
- interface AbortTerminalGuard
- `apps/coder/src/services/backends/warm-acp-routing.ts` — function shouldUseWarmBackend: (task) => boolean, function isTurnOkForStopReason: (stopReason) => boolean
- `apps/coder/src/services/backends/warm-acp.ts` — class WarmAcpBackend, interface WarmAcpBackendDeps
- `apps/coder/src/services/behavioral/generation.ts`
- function createExecutionPlan: (observational, actionable, previouslyApplied, disambiguationGroups, lowCriticality) => BatchExecutionPlan[]
- function getRetryTemperatures: (baseTemp, maxAttempts) => number[]
- class SchematicGenerator
- class DefaultSchematicGenerator
- interface ObservationalOutput
- interface ActionableOutput
- _...7 more_
- `apps/coder/src/services/behavioral/matching.ts`
- function matchWithRetry: (fn) => void
- function executeBatchesParallel: (batches, _generationInfo) => Promise<GuidelineMatchingResult>
- function createScoredMatch: (guidelineId, score, rationale) => ScoredMatch
- class GuidelineMatchingBatchError
- class ObservationalGuidelineMatchingBatch
- class ActionableGuidelineMatchingBatch
- _...25 more_
- `apps/coder/src/services/behavioral/resolver.ts`
- class RelationalResolver
- interface RelationshipEntity
- interface Relationship
- interface RelationshipStore
- interface ResolvedEntity
- interface Resolution
- _...8 more_
- `apps/coder/src/services/cancel-registry.ts` — function createCancelRegistry: () => CancelRegistry, interface CancelRegistry
- `apps/coder/src/services/checkpoints.ts`
- function buildShadowCommitCommand: (worktreePath, id) => string
@@ -573,7 +703,15 @@
- interface RestoreCheckpointResult
- _...1 more_
- `apps/coder/src/services/claude-command-discovery.ts` — function discoverClaudeCommands: () => AgentCommand[]
- `apps/coder/src/services/collision-detector.ts`
- function findConflicts: (changedFiles, worktreeId, /** Approximate line range for the proposed changes, keyed by file path */
changedRanges, {...}, conflictIndex) => ConflictVerdict[]
- interface ConflictVerdict
- interface ConflictEntry
- type ConflictSeverity
- type ConflictIndexData
- `apps/coder/src/services/command-availability.ts` — function isCommandAvailable: (binary) => Promise<boolean>
- `apps/coder/src/services/conflict-index.ts` — class ConflictIndex, const conflictIndex
- `apps/coder/src/services/correction-service.ts`
- function recordCorrection: (originalClaim, correction, principleExtracted, persistedTo, basePath?) => Promise<UserCorrectionRecord>
- function scanForCorrections: (auditPath) => Promise<UserCorrectionRecord[]>
@@ -603,10 +741,11 @@
- function partitionReady: (ready, ctx) => void
- function isRunComplete: (flow, state) => boolean
- function isStuck: (flow, state) => boolean
- function reconcileResumeStep: (status, taskId, taskState) => ResumeAction
- _...5 more_
- function buildBatchState: (flow, inFlight) => Map<string,
- _...12 more_
- `apps/coder/src/services/flow-runner.ts`
- function createFlowRunner: (deps) => FlowRunner
- function resolveVariables: (prompt, results, string>) => string
- interface LaunchOpts
- interface FlowRunner
- `apps/coder/src/services/frame-emitter.ts`
@@ -626,6 +765,19 @@
- function deleteGuideline: (id, basePath?) => Promise<boolean>
- function findGuideline: (content, basePath?) => Promise<Guideline | null>
- _...14 more_
- `apps/coder/src/services/hashline/hash-computation.ts`
- function computeLineHash: (lineNumber, content) => string
- function computeLegacyLineHash: (lineNumber, content) => string
- function formatHashLine: (lineNumber, content) => string
- function formatHashLines: (content) => string
- `apps/coder/src/services/hashline/validation.ts`
- function normalizeLineRef: (ref) => string
- function parseLineRef: (ref) => LineRef
- function validateLineRef: (lines, ref) => void
- function validateLineRefs: (lines, refs) => void
- class HashlineMismatchError
- interface LineRef
- `apps/coder/src/services/hashline/xxhash32.ts` — function hashXxh32: (input, seed) => number
- `apps/coder/src/services/host-exec.ts` — function hostExec: (command, opts?) => Promise<HostExecResult>, interface HostExecResult
- `apps/coder/src/services/lsp/client.ts` — class LspClient
- `apps/coder/src/services/lsp/config.ts` — function getServerConfig: (filePath) => LspServerConfig | null, interface LspServerConfig
@@ -637,6 +789,44 @@
- function findReferences: (client, filePath, content, line, character) => Promise<Location[]>
- `apps/coder/src/services/lsp/server-manager.ts` — class LspServerManager, const lspManager
- `apps/coder/src/services/mcp-server.ts` — function startMcpServer: (sql) => Promise<void>
- `apps/coder/src/services/model-resolution/connected-providers-cache.ts`
- function readConnectedProvidersCache: () => string[] | null
- function findProviderModelMetadata: (_providerID, _modelID) => ModelMetadata | undefined
- function readProviderModelsCache: () => ProviderModelsCache | null
- interface ProviderModelsCache
- interface ConnectedProvidersAdapter
- const connectedProvidersAdapter: ConnectedProvidersAdapter
- `apps/coder/src/services/model-resolution/fallback-chain-from-models.ts`
- function parseFallbackModelEntry: (model, contextProviderID, defaultProviderID) => FallbackEntry | undefined
- function parseFallbackModelObjectEntry: (obj, contextProviderID, defaultProviderID) => FallbackEntry | undefined
- function findMostSpecificFallbackEntry: (providerID, modelID, chain) => FallbackEntry | undefined
- function buildFallbackChainFromModels: (fallbackModels) => void
- `apps/coder/src/services/model-resolution/model-availability.ts` — function fuzzyMatchModel: (target, available, providers?) => string | null, function isModelAvailable: (targetModel, availableModels) => boolean
- `apps/coder/src/services/model-resolution/model-error-classifier.ts`
- function isRetryableModelError: (error) => boolean
- function shouldRetryError: (error) => boolean
- function getNextFallback: (fallbackChain, attemptCount) => FallbackEntry | undefined
- function hasMoreFallbacks: (fallbackChain, attemptCount) => boolean
- function selectFallbackProvider: (providers, preferredProviderID?) => string
- function selectFallbackProviderWithCache: (providers, providerCache, preferredProviderID?) => string
- _...1 more_
- `apps/coder/src/services/model-resolution/model-normalization.ts` — function normalizeModel: (model?) => string | undefined, function normalizeModelID: (modelID) => string
- `apps/coder/src/services/model-resolution/model-resolution-pipeline.ts`
- function _setModelResolutionLogImplementationForTesting: (logImplementation) => void
- function resolveModelPipeline: (request, providerCache) => void
- type ModelResolutionRequest
- type ModelResolutionProvenance
- type ModelResolutionResult
- type ModelResolutionDeps
- `apps/coder/src/services/model-resolution/model-resolver.ts`
- function resolveModel: (input) => string | undefined
- function resolveModelWithFallback: (input, connectedProvidersAdapter) => ModelResolutionResult | undefined
- function normalizeFallbackModels: (models) => void
- function flattenToFallbackModelStrings: (models) => void
- type ModelResolutionInput
- type ModelSource
- _...2 more_
- `apps/coder/src/services/model-resolution/provider-model-id-transform.ts` — function transformModelForProvider: (provider, model) => string, function transformModelForProviderDisplay: (provider, model) => string
- `apps/coder/src/services/net/port-utils.ts`
- function reclaimPort: (port) => void
- function waitForPortRelease: (port, timeoutMs) => Promise<boolean>
@@ -646,6 +836,13 @@
- function createOrphanWorktreeReaper: (deps) => void
- interface OrphanWorktreeReaperDeps
- interface OrphanReaperResult
- `apps/coder/src/services/paseo-client.ts`
- class PaseoClientError
- class PaseoClient
- interface PaseoAgentListItem
- interface PaseoAgentDetail
- interface PaseoSendResult
- interface PaseoClientConfig
- `apps/coder/src/services/pending_changes.ts`
- function planEdit: (content, oldStr, newStr) => EditPlan
- function queueEdit: (sql, sessionId, taskId, filePath, oldString, newString, projectRoot, // v2.6 Phase 1-UX) => void
@@ -662,6 +859,14 @@
- function waitForElicitationResponse: (taskId, sessionId, provider, modeId, params, timeoutMs) => Promise<CreateElicitationResponse>
- function cancelPendingPermission: (taskId) => void
- _...3 more_
- `apps/coder/src/services/plan-store.ts`
- function createPlan: (sql, opts) => Promise<Plan>
- function getPlan: (sql, planId) => Promise<Plan | null>
- function listPlans: (sql, projectId) => Promise<Plan[]>
- function listActivePlans: (sql, projectId) => Promise<Plan[]>
- function updatePlan: (sql, planId, opts) => Promise<Plan | null>
- function updatePlanFromRun: (sql, runId, runStatus) => Promise<boolean>
- _...5 more_
- `apps/coder/src/services/provider-commands.ts`
- function getManifestCommands: (provider) => AgentCommand[]
- function mergeCommands: (...lists) => AgentCommand[]
@@ -684,13 +889,13 @@
- interface ProviderManifestEntry
- const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry>
- `apps/coder/src/services/provider-snapshot.ts`
- function fetchDeepSeekModels: (config) => Promise<ProviderModel[]>
- function fetchLlamaSwapModels: (config) => Promise<ProviderModel[]>
- function prefixLlamaSwapModels: (models) => ProviderModel[]
- function mergeModels: (...lists) => ProviderModel[]
- function getProviderSnapshot: (sql, config, cwd?, force) => Promise<ProviderSnapshotEntry[]>
- function clearProviderSnapshotCache: () => void
- function peekSnapshotEntry: (name, cwd?) => ProviderSnapshotEntry | undefined
- _...1 more_
- _...2 more_
- `apps/coder/src/services/pty-dispatch.ts`
- function dispatchViaPty: (opts) => Promise<DispatchResult>
- interface DispatchResult
@@ -800,6 +1005,17 @@
- function readSession: (sessionId, projectRoot?) => SessionJson | null
- _...9 more_
- `apps/server/src/services/auto_name.ts` — function maybeAutoNameChat: (ctx, chatId, sessionId) => Promise<void>
- `apps/server/src/services/background-task.ts`
- function setBackgroundInferenceEnqueuer: (enqueue, chatId, assistantMessageId, user) => void
- function spawnBackgroundTask: (sql, log, projectId, input, model, agent?, label?) => Promise<BackgroundTask>
- function getBackgroundTaskStatus: (sql, taskId) => Promise<BackgroundTask | null>
- function getBackgroundTaskResult: (sql, taskId, chatId) => Promise<
- function cancelBackgroundTask: (sql, taskId) => Promise<boolean>
- interface BackgroundTask
- `apps/server/src/services/boocontext_client.ts`
- function callBoocontext: (req, log?, msg) => void
- interface BoocontextRequest
- interface BoocontextResponse
- `apps/server/src/services/broker.ts`
- function createBroker: (log?) => Broker
- interface Broker
@@ -818,6 +1034,7 @@
- function select: (messages, contextLimit, tailTurns) => SelectResult
- function deriveFilesRead: (head) => string[]
- _...8 more_
- `apps/server/src/services/export-formatter.ts` — function formatJson: (chat, messages, model) => string, function formatMarkdown: (chat, messages, model) => string
- `apps/server/src/services/file_index.ts` — function getProjectFiles: (projectId, projectRoot) => Promise<string[]>
- `apps/server/src/services/file_ops.ts`
- function listDir: (projectRoot, relPath, opts?) => Promise<ListDirResult>
@@ -842,7 +1059,20 @@
- interface GiteaConfig
- interface GiteaRepo
- `apps/server/src/services/grant_resolver.ts` — function resolveGrantRoot: (sql, requestedPath, projectRoot, whitelistRoot) => Promise<GrantResolution>, type GrantResolution
- `apps/server/src/services/hooks.ts`
- function loadHooksConfig: (path) => HooksConfig
- function reloadHooksConfig: () => HooksConfig
- function createHookRunner: () => HookRunner
- interface HookConfig
- interface HooksConfig
- interface PreToolUsePayload
- _...10 more_
- `apps/server/src/services/inference/budget.ts` — function resolveToolBudget: (agent) => number
- `apps/server/src/services/inference/compute-diff.ts`
- function computeDiff: (oldStr, newStr, filePath) => string
- function isWriteTool: (name) => boolean
- function diffFromToolArgs: (name, args, unknown>, filePath?) => string
- const WRITE_TOOL_NAMES
- `apps/server/src/services/inference/content-flusher.ts` — function createContentFlusher: (sql, messageId, getContent) => void, interface ContentFlusher
- `apps/server/src/services/inference/dcp/messages.ts`
- function toDcpMessages: (parts) => DcpMessage[]
@@ -882,6 +1112,10 @@
- type FailureKind
- const MISTAKE_THRESHOLD
- _...1 more_
- `apps/server/src/services/inference/multi-modal.ts`
- function hasImageAttachments: (_message) => boolean
- function imageAttachmentsToParts: (attachments) => Array<
- interface ImageAttachment
- `apps/server/src/services/inference/parts.ts`
- function insertParts: (sql, parts) => Promise<void>
- function partsFromAssistantMessage: (args) => void
@@ -894,10 +1128,13 @@
- function maybeFlagForCompaction: (ctx, chatId, updated) => Promise<void>
- interface OpenAiMessage
- `apps/server/src/services/inference/provider.ts`
- function resolveRoute: (agent, config?) => RoutingInfo
- function isDeepSeekModel: (modelId) => boolean
- function resolveRoute: (agent, config?, modelId?) => RoutingInfo
- function upstreamModel: (config, modelId, agent?) => LanguageModel
- function resolveModelEndpoint: (config, modelId) => void
- function resetDeepSeekProvider: () => void
- interface RoutingInfo
- type InferenceRoute
- _...1 more_
- `apps/server/src/services/inference/prune.ts`
- function selectPruneTargets: (partsNewestFirst, tailStartCreatedAt) => void
- function prune: (args) => Promise<PruneResult>
@@ -918,6 +1155,12 @@
- function isAnySentinel: (m) => boolean
- const DOOM_LOOP_THRESHOLD
- _...1 more_
- `apps/server/src/services/inference/state-graph.ts`
- function createDefaultGraph: () => GraphNode[]
- function runGraph: (ctx, args, extra) => Promise<GraphResult>
- interface GraphState
- interface GraphResult
- type GraphNodeType
- `apps/server/src/services/inference/step-decision.ts`
- function decideStep: (input) => PreStepDecision
- function decidePostToolAction: (action, mistakeTracker) => PostToolDecision
@@ -934,12 +1177,14 @@
- `apps/server/src/services/inference/stream-phase.ts` — function executeStreamPhase: (ctx, args, session, messages, state, agent, // v1.11.8, web_search and web_fetch are stripped from the
// tool list sent to the LLM, so the model can't even attempt them.
webToolsEnabled) => Promise<StreamResult>
- `apps/server/src/services/inference/supervisor.ts` — function resolveSupervisorTurn: (latestUserMessage, agents, fallbackModel?) => Promise<SupervisorRoute | null>, interface SupervisorRoute
- `apps/server/src/services/inference/tool-call-parser.ts`
- function stripToolMarkup: (text, opts?) => string
- function extractToolCallBlocks: (buffer, log?) => ToolCallExtraction
- interface ParsedCall
- interface ToolCallExtraction
- `apps/server/src/services/inference/tool-phase.ts` — function executeToolPhase: (ctx, args, result, startedAt, session, projectRoot, agent?) => Promise<ToolPhaseResult>, interface ToolPhaseResult
- `apps/server/src/services/inference/tool-input-repair.ts` — function repairToolInput: (schema, unknown> | undefined, args, unknown>) => void, interface ToolInputRepair
- `apps/server/src/services/inference/tool-phase.ts` — function executeToolPhase: (ctx, args, result, startedAt, session, projectRoot, agent?, turnNumber?) => Promise<ToolPhaseResult>, interface ToolPhaseResult
- `apps/server/src/services/inference/tool-shim.ts`
- function extractToolCalls: (text) => ParsedToolCall[]
- function hasToolCallMarkup: (text) => boolean
@@ -955,20 +1200,26 @@
- `apps/server/src/services/inference/turn.ts`
- function runAssistantTurn: (ctx, args) => Promise<void>
- function runInference: (ctx, sessionId, chatId, assistantMessageId, signal?) => Promise<void>
- function runInferenceWithModel: (ctx, sessionId, chatId, assistantMessageId, modelOverride, compareGroupId, signal?) => Promise<void>
- function createInferenceRunner: (ctx, 'publishUser'>, publishUserFn, frame) => void
- `apps/server/src/services/mcp-client.ts`
- function initialize: (entries, logger) => Promise<void>
- function callTool: (prefixedName, args, unknown>) => Promise<unknown>
- function getServerPermission: (prefixedToolName) => McpPermission
- function setServerPermission: (serverName, permission) => void
- function getServerName: (prefixedToolName) => string | null
- function getTools: () => ToolDef<Record<string, unknown>>[]
- function getMcpServers: () => Array<
- function shutdown: () => Promise<void>
- function wrapMcpTool: (serverName, mcpTool) => ToolDef<Record<string, unknown>>
- _...2 more_
- _...6 more_
- `apps/server/src/services/mcp-config.ts`
- function substituteEnvVars: (value, log, unsetVars?) => unknown
- function loadMcpConfig: (configPath, log) => McpServerEntry[]
- interface McpServerEntry
- type McpServerConfig
- `apps/server/src/services/memory/bm25.ts` — class Bm25Ranker
- `apps/server/src/services/memory/embeddings.ts`
- function isEmbeddingAvailable: () => boolean
- function initEmbeddings: (modelPath?) => Promise<boolean>
- function embed: (texts) => Promise<number[][] | null>
- `apps/server/src/services/memory/entries.ts` — function parseMemoryEntries: (fileName, markdown) => MemoryEntry[], interface MemoryEntry
- `apps/server/src/services/memory/paths.ts`
- function getMemoryRoot: (projectRoot) => string
@@ -976,7 +1227,10 @@
- function ensureMemoryScaffold: (root) => Promise<void>
- type MemoryTopic
- `apps/server/src/services/memory/prompt.ts` — function formatMemoryBlock: (entries) => string
- `apps/server/src/services/memory/recall.ts` — function rankByRelevance: (query, entries) => MemoryEntry[], function loadMemoryForSession: (projectRoot, _sessionId?, query?) => Promise<string[]>
- `apps/server/src/services/memory/recall.ts`
- function rankByRelevance: (query, entries) => MemoryEntry[]
- function rankByHybrid: (query, entries) => Promise<MemoryEntry[]>
- function loadMemoryForSession: (projectRoot, _sessionId?, query?) => Promise<string[]>
- `apps/server/src/services/memory/scan.ts`
- function scanMemoryScopes: (scope) => Promise<MemoryEntry[]>
- function scanProjectMemory: (projectRoot) => Promise<MemoryEntry[]>
@@ -1007,6 +1261,11 @@
- function filterSecretEntries: (entries, pathOf) => void
- class SecretBlockedError
- const DEFAULT_SECURITY_IGNORE_FILETYPES: ReadonlyArray<string>
- `apps/server/src/services/session-snapshots.ts`
- function saveAgentSnapshot: (sql, chatId, data) => Promise<void>
- function loadAgentSnapshot: (sql, chatId) => Promise<AgentSnapshot | null>
- function deleteAgentSnapshot: (sql, chatId) => Promise<void>
- interface AgentSnapshot
- `apps/server/src/services/skill-invoke.ts`
- function runSkillInvokeTransaction: (sql, args) => Promise<
- function buildSkillInvokeSyntheticFrames: (chatId, result, toolCall, skillBody) => SkillInvokeSessionFrame[]
@@ -1037,8 +1296,53 @@
- _...2 more_
- `apps/server/src/services/task-model.ts` — function taskModelCompletion: (opts) => Promise<string>
- `apps/server/src/services/task-search-rewrite.ts` — function rewriteSearchQuery: (userMessage) => Promise<string>
- `apps/server/src/services/tool-traces.ts`
- function insertToolTrace: (sql, insert) => Promise<ToolTrace>
- function updateToolTrace: (sql, id, updates) => Promise<ToolTrace | null>
- interface ToolTrace
- interface ToolTraceInsert
- interface ToolTraceUpdate
- `apps/server/src/services/tools/background-subagent-tools.ts`
- function executeSpawnSubagent: (input, sql, sessionId) => Promise<Record<string, unknown>>
- function executeSubagentStatus: (input, sql) => Promise<Record<string, unknown>>
- function executeSubagentResult: (input, sql) => Promise<Record<string, unknown>>
- type SpawnSubagentInputT
- type SubagentStatusInputT
- type SubagentResultInputT
- _...6 more_
- `apps/server/src/services/tools/codecontext/factory.ts` — function makeCodecontextTool: (opts, unknown>;
mapArgs) => void
- `apps/server/src/services/tools/codecontext/get_code_health.ts`
- function executeGetCodeHealth: (input, projectPath) => Promise<string>
- type GetCodeHealthInputT
- const GetCodeHealthInput
- const getCodeHealth: ToolDef<GetCodeHealthInputT>
- `apps/server/src/services/tools/codecontext/get_code_impact.ts`
- function executeGetCodeImpact: (input, projectPath) => Promise<CodecontextResponse>
- type GetCodeImpactInputT
- const GetCodeImpactInput
- const getCodeImpact: ToolDef<GetCodeImpactInputT>
- `apps/server/src/services/tools/codecontext/get_code_map.ts`
- function executeGetCodeMap: (input, projectRoot) => Promise<CodeMapResponse>
- interface CodeMapResponse
- type GetCodeMapInputT
- const GetCodeMapInput
- const getCodeMap: ToolDef<GetCodeMapInputT>
- `apps/server/src/services/tools/codecontext/get_type_info.ts`
- function executeGetTypeInfo: (input, _projectPath?) => Promise<CodecontextResponse>
- type GetTypeInfoInputT
- const GetTypeInfoInput
- const getTypeInfo: ToolDef<GetTypeInfoInputT>
- `apps/server/src/services/tools/codecontext/get_wiki_article.ts`
- function executeGetWikiArticle: (input, projectPath) => Promise<string>
- type GetWikiArticleInputT
- const GetWikiArticleInput
- const getWikiArticle: ToolDef<GetWikiArticleInputT>
- `apps/server/src/services/tools/execute-command.ts`
- function executeRunCommand: (input, projectRoot) => Promise<RunCommandOutput>
- type RunCommandInputT
- type RunCommandOutput
- const runCommand: ToolDef<RunCommandInputT>
- `apps/server/src/services/tools/registry.ts` — function appendMcpTools: (mcpTools) => void, function toolJsonSchemas: () => ToolJsonSchema[]
- `apps/server/src/services/tools/tiers.ts`
- function resolveToolTier: (tier) => readonly string[]
@@ -1064,6 +1368,39 @@
- interface WebSearchOutput
- type WebSearchInputT
- const webSearch: ToolDef<WebSearchInputT>
- `apps/server/src/services/workflow/catalog.ts`
- function fingerprintAgentTask: (prompt, spec, unknown>, args) => string
- function getBuiltinWorkflows: () => BuiltinWorkflow[]
- function getBuiltinWorkflow: (name) => BuiltinWorkflow | undefined
- function mergeBuiltinWorkflows: (fileWorkflows) => Array<
- interface BuiltinWorkflow
- const meta
- `apps/server/src/services/workflow/discovery.ts`
- function isBuiltinWorkflow: (meta) => boolean
- function discoverWorkflows: (projectRoot) => WorkflowMeta[]
- function findWorkflow: (name, projectRoot) => WorkflowMeta | undefined
- function isValidWorkflowPath: (filePath) => boolean
- interface WorkflowMeta
- `apps/server/src/services/workflow/manager.ts`
- class WorkflowManager
- interface WorkflowMetaInfo
- type WorkflowEventHandler
- `apps/server/src/services/workflow/resumability.ts`
- function cacheKey: (spec, args) => string
- function getCachedResult: (key) => CachedResult | null
- function setCachedResult: (key, result) => void
- function invalidateRun: (runKey) => void
- function clearCache: () => void
- function cacheSize: () => number
- _...1 more_
- `apps/server/src/services/workflow/sandbox.ts`
- function transformEsmToCjs: (code) => string
- function name: (...) => void
- function isEsmSyntax: (code) => boolean
- function buildSandbox: (context) => Record<string, unknown>
- function loadWorkflowScript: (sourceFile, context) => (...args: unknown[]) => Promise<unknown>
- function loadWorkflowScriptFromCode: (code, context, filename?) => (...args: unknown[]) => Promise<unknown>
- _...3 more_
- `apps/server/src/utils/string-utils.ts` — function stripQuotes: (s) => string
- `apps/web/src/api/client.ts`
- class ApiError
@@ -1084,7 +1421,7 @@
- interface TerminalSelectionActions
- interface TerminalSelection
- `apps/web/src/hooks/terminal/useTerminalSocket.ts`
- function useTerminalSocket: ({...}, sessionId, paneId, fit, getSize, setSize, }) => TerminalSocket
- function useTerminalSocket: ({...}, sessionId, paneId, description, parentAgent, fit, getSize, setSize, }) => TerminalSocket
- interface TerminalSocket
- type ConnState
- `apps/web/src/hooks/useActivePane.ts`
@@ -1108,7 +1445,8 @@
- interface ThroughputSample
- `apps/web/src/hooks/useCoderUserEvents.ts` — function useCoderUserEvents: () => void
- `apps/web/src/hooks/useDiffPreferences.ts` — function useDiffPreferences: () => void, interface DiffPreferences
- `apps/web/src/hooks/useGitDiff.ts` — function useGitDiff: (projectId) => void
- `apps/web/src/hooks/useDraftPersistence.ts` — function useDraftPersistence: (chatId) => DraftPersistenceResult, interface DraftPersistenceResult
- `apps/web/src/hooks/useGitDiff.ts` — function useGitDiff: (projectId, hideWhitespace) => void
- `apps/web/src/hooks/useLongPress.ts` — function useLongPress: (callback) => void
- `apps/web/src/hooks/useProjectGit.ts` — function useProjectGit: (projectId) => GitMeta | null
- `apps/web/src/hooks/useProviderSnapshot.ts` — function refreshProviderSnapshot: (cwd?) => Promise<ProviderSnapshotEntry[]>, function useProviderSnapshot: (cwd?) => ProviderSnapshotEntry[] | null
@@ -1121,6 +1459,7 @@
- `apps/web/src/hooks/useSessions.ts` — function useSessions: (projectId) => void
- `apps/web/src/hooks/useSidebar.ts` — function useSidebar: () => void
- `apps/web/src/hooks/useSkills.ts` — function useSkills: () => void
- `apps/web/src/hooks/useTerminals.ts` — function useTerminals: () => TerminalRegistration[]
- `apps/web/src/hooks/useUserEvents.ts` — function useUserEvents: () => void
- `apps/web/src/hooks/useViewport.ts` — function useViewport: () => ViewportSnapshot, interface ViewportSnapshot
- `apps/web/src/hooks/useWorkspacePanes.ts`
@@ -1183,7 +1522,16 @@
- interface ThemeMeta
- type ThemeId
- _...5 more_
- `apps/web/src/lib/tool-utils.ts`
- function isMcpTool: (name) => boolean
- function extractServerName: (name) => string | null
- function extractToolName: (name) => string | null
- const BUILT_IN_TOOLS
- `apps/web/src/lib/utils.ts` — function cn: (...inputs) => void
- `apps/web/src/stores/useDiffCommentStore.ts`
- function useDiffComments: (sessionId, mode) => void
- interface DiffComment
- interface DiffCommentTarget
- `apps/web/src/utils/diff-layout.ts`
- function parseDiff: (diffBody) => ParsedDiffFile[]
- function buildSplitRows: (file) => SplitRow[]
@@ -1344,8 +1692,11 @@
- `CONTAINER_GUIDANCE_FILE` **required** — apps/server/src/services/__tests__/system-prompt.test.ts
- `CONTEXT7_API_KEY` (has default) — .env
- `DATABASE_URL` (has default) — .env.example
- `DEEPSEEK_API_KEY` (has default) — .env
- `DEEPSEEK_BASE_URL` (has default) — .env
- `DEFAULT_MODEL` (has default) — .env.example
- `DEV_REMOTE_USER` **required** — apps/web/vite.config.ts
- `EMBEDDING_MODEL_PATH` **required** — apps/server/src/services/memory/embeddings.ts
- `GITEA_BASE_URL` (has default) — .env
- `GITEA_SSH_HOST` (has default) — .env
- `GITEA_TOKEN` (has default) — .env
@@ -1353,6 +1704,7 @@
- `LLAMA_SWAP_URL` (has default) — .env.example
- `MCP_TEST_MISSING` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
- `MCP_TEST_SECRET` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
- `MEMORY_SEARCH` **required** — apps/server/src/services/memory/recall.ts
- `NODE_ENV` (has default) — .env.example
- `PORT` (has default) — .env.example
- `POSTGRES_PASSWORD` (has default) — .env.example
@@ -1368,6 +1720,10 @@
- `apps/web/vite.config.ts`
- `docker-compose.yml`
## Key Dependencies
- better-sqlite3: ^11.10.0
---
# Middleware
@@ -1379,6 +1735,7 @@
- turn-guard — `apps/coder/src/services/backends/turn-guard.ts`
- get_middleware — `apps/server/src/services/tools/codecontext/get_middleware.ts`
- authoring — `conductor/src/flows/authoring.ts`
- spec — `openspec/changes/add-behavioral-engine/specs/audit-middleware/spec.md`
## custom
- write_guard.test — `apps/coder/src/services/__tests__/write_guard.test.ts`
@@ -1400,39 +1757,39 @@
## Most Imported Files (change these carefully)
- `apps/coder/src/db.ts` — imported by **40** files
- `apps/server/src/types/api.ts` — imported by **28** files
- `apps/server/src/db.ts` — imported by **25** files
- `apps/coder/src/db.ts` — imported by **44** files
- `apps/server/src/types/api.ts` — imported by **34** files
- `apps/server/src/db.ts` — imported by **32** files
- `packages/ion/src/cli/utils.ts` — imported by **24** files
- `apps/coder/src/services/tools/types.ts` — imported by **18** files
- `apps/coder/src/conductor/types.ts` — imported by **14** files
- `apps/coder/src/conductor/types.ts` — imported by **16** files
- `apps/server/src/services/tools.ts` — imported by **15** files
- `apps/coder/src/services/agent-backend.ts` — imported by **14** files
- `apps/coder/src/services/acp-tool-snapshot.ts` — imported by **14** files
- `apps/server/src/config.ts` — imported by **14** files
- `apps/server/src/services/tools/codecontext/factory.ts` — imported by **14** files
- `apps/server/src/services/tools.ts` — imported by **13** files
- `apps/server/src/services/tools/types.ts` — imported by **13** files
- `conductor/src/types.ts` — imported by **13** files
- `apps/coder/src/services/provider-config-registry.ts` — imported by **12** files
- `apps/server/src/config.ts` — imported by **12** files
- `apps/coder/src/config.ts` — imported by **11** files
- `apps/coder/src/services/provider-types.ts` — imported by **11** files
- `apps/server/src/services/broker.ts` — imported by **10** files
- `apps/server/src/services/agents.ts` — imported by **10** files
- `apps/server/src/services/path_guard.ts` — imported by **10** files
- `apps/coder/src/services/pending_changes.ts` — imported by **9** files
- `apps/server/src/services/broker.ts` — imported by **9** files
- `apps/server/src/services/path_guard.ts` — imported by **9** files
- `apps/server/src/services/inference/payload.ts` — imported by **9** files
## Import Map (who imports what)
- `apps/coder/src/db.ts``apps/coder/src/index.ts`, `apps/coder/src/routes/__tests__/agent-sessions.routes.test.ts`, `apps/coder/src/routes/__tests__/chat-resolve.test.ts`, `apps/coder/src/routes/__tests__/providers.routes.test.ts`, `apps/coder/src/routes/agent-sessions.ts` +35 more
- `apps/server/src/types/api.ts``apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts`, `apps/server/src/routes/projects.ts`, `apps/server/src/routes/sessions.ts` +23 more
- `apps/server/src/db.ts``apps/server/src/index.ts`, `apps/server/src/routes/agents.ts`, `apps/server/src/routes/artifacts.ts`, `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts` +20 more
- `apps/coder/src/db.ts``apps/coder/src/index.ts`, `apps/coder/src/routes/__tests__/agent-sessions.routes.test.ts`, `apps/coder/src/routes/__tests__/chat-resolve.test.ts`, `apps/coder/src/routes/__tests__/providers.routes.test.ts`, `apps/coder/src/routes/agent-sessions.ts` +39 more
- `apps/server/src/types/api.ts``apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts`, `apps/server/src/routes/projects.ts`, `apps/server/src/routes/sessions.ts` +29 more
- `apps/server/src/db.ts``apps/server/src/index.ts`, `apps/server/src/routes/agents.ts`, `apps/server/src/routes/analytics.ts`, `apps/server/src/routes/artifacts.ts`, `apps/server/src/routes/chats.ts` +27 more
- `packages/ion/src/cli/utils.ts``packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/cleanup.ts` +19 more
- `apps/coder/src/services/tools/types.ts``apps/coder/src/routes/messages.ts`, `apps/coder/src/services/dispatcher.ts`, `apps/coder/src/services/tools/adapter.ts`, `apps/coder/src/services/tools/apply_pending.ts`, `apps/coder/src/services/tools/check_task_status.ts` +13 more
- `apps/coder/src/conductor/types.ts``apps/coder/src/conductor/flows/_util.ts`, `apps/coder/src/conductor/flows/architectural-analysis.ts`, `apps/coder/src/conductor/flows/authoring.ts`, `apps/coder/src/conductor/flows/code-review.ts`, `apps/coder/src/conductor/flows/discovery.ts` +9 more
- `apps/coder/src/conductor/types.ts``apps/coder/src/conductor/flows/_util.ts`, `apps/coder/src/conductor/flows/architectural-analysis.ts`, `apps/coder/src/conductor/flows/authoring.ts`, `apps/coder/src/conductor/flows/code-review.ts`, `apps/coder/src/conductor/flows/discovery.ts` +11 more
- `apps/server/src/services/tools.ts``apps/server/src/index.ts`, `apps/server/src/services/__tests__/agent-allowlist.test.ts`, `apps/server/src/services/agents.ts`, `apps/server/src/services/inference/stream-phase-adapter.ts`, `apps/server/src/services/inference/stream-phase.ts` +10 more
- `apps/coder/src/services/agent-backend.ts``apps/coder/src/routes/lifecycle.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-event-map.ts`, `apps/coder/src/services/agent-pool.ts`, `apps/coder/src/services/backends/__tests__/claude-sdk-map.test.ts` +9 more
- `apps/coder/src/services/acp-tool-snapshot.ts``apps/coder/src/services/__tests__/acp-event-map.test.ts`, `apps/coder/src/services/__tests__/frame-emitter.test.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-dispatch.ts`, `apps/coder/src/services/acp-event-map.ts` +9 more
- `apps/server/src/services/tools/codecontext/factory.ts``apps/server/src/services/tools/codecontext/get_blast_radius.ts`, `apps/server/src/services/tools/codecontext/get_call_graph.ts`, `apps/server/src/services/tools/codecontext/get_codebase_overview.ts`, `apps/server/src/services/tools/codecontext/get_dependencies.ts`, `apps/server/src/services/tools/codecontext/get_file_analysis.ts` +9 more
- `apps/server/src/services/tools.ts``apps/server/src/index.ts`, `apps/server/src/services/__tests__/agent-allowlist.test.ts`, `apps/server/src/services/agents.ts`, `apps/server/src/services/inference/stream-phase-adapter.ts`, `apps/server/src/services/inference/stream-phase.ts` +8 more
- `apps/server/src/config.ts``apps/server/src/db.ts`, `apps/server/src/index.ts`, `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts` +9 more
---

View File

@@ -10,23 +10,34 @@
- **AttachmentChip** — props: attachment, onRemove, onPreview — `apps/web/src/components/AttachmentChip.tsx`
- **AttachmentPreviewModal** — props: attachment, onClose — `apps/web/src/components/AttachmentPreviewModal.tsx`
- **BottomSheet** — props: open, onClose, title — `apps/web/src/components/BottomSheet.tsx`
- **CacheShapeBadge** — props: cacheTokens, totalTokens — `apps/web/src/components/CacheShapeBadge.tsx`
- **CapHitSentinel** — props: message, capHitPosition, isLatest — `apps/web/src/components/CapHitSentinel.tsx`
- **ChatInput** — props: disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop — `apps/web/src/components/ChatInput.tsx`
- **ChatTabBar** — props: pane, tabs, tabNumbers, onSwitchTab, onRemoveTab, onCloseOthers, onCloseToRight, onCloseAll, onNewTab, onSplitPane — `apps/web/src/components/ChatTabBar.tsx`
- **ChatThroughput** — props: chatId, className — `apps/web/src/components/ChatThroughput.tsx`
- **CodeBlock** — props: code, lang — `apps/web/src/components/CodeBlock.tsx`
- **ComparePane** — props: models, responses, onClose — `apps/web/src/components/ComparePane.tsx`
- **ContextMeter** — props: messages, modelContextLimit, sessionCostUsd — `apps/web/src/components/ContextMeter.tsx`
- **CreateProjectModal** — props: open, onOpenChange — `apps/web/src/components/CreateProjectModal.tsx`
- **DiffSnippet** — props: diff — `apps/web/src/components/DiffSnippet.tsx`
- **DiffSplitView** — props: file, wrapLines — `apps/web/src/components/DiffSplitView.tsx`
- **DoomLoopSentinel** — props: message — `apps/web/src/components/DoomLoopSentinel.tsx`
- **DropOverlay** — props: visible — `apps/web/src/components/DropOverlay.tsx`
- **EmptyState** — props: icon, title, description, action, className — `apps/web/src/components/EmptyState.tsx`
- **FileMentionPopover** — props: query, files, anchorRect, onSelect, onClose — `apps/web/src/components/FileMentionPopover.tsx`
- **FileViewerOverlay** — props: path, content, lang, onClose — `apps/web/src/components/FileViewerOverlay.tsx`
- **FlowLauncherDialog** — `apps/web/src/components/FlowLauncherDialog.tsx`
- **GitDiffView** — props: result, loading, error, mode, onSelectMode, onRefresh, mutating, mutateError, onStage, onUnstage — `apps/web/src/components/GitDiffView.tsx`
- **HtmlArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/HtmlArtifactPane.tsx`
- **InferenceSettings** — `apps/web/src/components/InferenceSettings.tsx`
- **InlineReviewEditor** — props: initialBody, onSave, onCancel — `apps/web/src/components/InlineReviewEditor.tsx`
- **InlineReviewGutterCell** — props: lineNumber, type, hasComments, canComment, onClick — `apps/web/src/components/InlineReviewGutterCell.tsx`
- **InlineReviewThread** — props: comments, onEditComment, onDeleteComment — `apps/web/src/components/InlineReviewThread.tsx`
- **KeyboardShortcutsDialog** — props: open, onOpenChange — `apps/web/src/components/KeyboardShortcutsDialog.tsx`
- **MarkdownArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/MarkdownArtifactPane.tsx`
- **MarkdownRenderer** — props: content — `apps/web/src/components/MarkdownRenderer.tsx`
- **McpPermissionDialog** — props: toolCallId, toolName, toolArgs, chatId, open, onClose — `apps/web/src/components/McpPermissionDialog.tsx`
- **McpResponseDisplay** — props: toolCall, toolResult — `apps/web/src/components/McpResponseDisplay.tsx`
- **MessageBubble** — props: message, sessionChats, capHitInfo, actions, hideActions, hasCheckpoint, restoreDisabled — `apps/web/src/components/MessageBubble.tsx`
- **MessageList** — props: messages, sessionChats — `apps/web/src/components/MessageList.tsx`
- **MobileTabSwitcher** — props: panes, activePaneIdx, chats, onSwitchPane, onRemovePane, onRenameChat — `apps/web/src/components/MobileTabSwitcher.tsx`
@@ -38,12 +49,14 @@
- **RequestReadAccessCard** — props: toolCall, toolResult, chatId — `apps/web/src/components/RequestReadAccessCard.tsx`
- **RightRail** — props: projectId, sessionId — `apps/web/src/components/RightRail.tsx`
- **SessionLandingPage** — props: projectId, sessionId, agentId, onAgentChange, onSend, onSkillInvoke, createChat, chats, onOpenChat, onUnarchiveChat — `apps/web/src/components/SessionLandingPage.tsx`
- **SessionTimeline** — props: messages, onClose, onScrollToMessage — `apps/web/src/components/SessionTimeline.tsx`
- **SlashCommandPicker** — props: query, items, groups, inputRef, onSelect, onClose, emptyLabel — `apps/web/src/components/SlashCommandPicker.tsx`
- **StaleStreamBanner** — props: onRetry, onDiscard — `apps/web/src/components/StaleStreamBanner.tsx`
- **StatusDot** — props: chatId, className — `apps/web/src/components/StatusDot.tsx`
- **ThemePicker** — `apps/web/src/components/ThemePicker.tsx`
- **ToolCallGroup** — props: runs — `apps/web/src/components/ToolCallGroup.tsx`
- **ToolCallLine** — props: run, insideGroup — `apps/web/src/components/ToolCallLine.tsx`
- **ToolCallLine** — props: run, insideGroup, chatId`apps/web/src/components/ToolCallLine.tsx`
- **TraceViewer** — props: chatId — `apps/web/src/components/TraceViewer.tsx`
- **Workspace** — props: sessionId, projectId, agentId, onAgentChange, panesHook, chatsHook, session, project, onAddPane — `apps/web/src/components/Workspace.tsx`
- **AddProviderModal** — props: open, onOpenChange, onAdded — `apps/web/src/components/coder/AddProviderModal.tsx`
- **ProvidersSettings** — `apps/web/src/components/coder/ProvidersSettings.tsx`
@@ -52,20 +65,30 @@
- **ThemeFx** — `apps/web/src/components/fx/ThemeFx.tsx`
- **ClaudeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
- **OpenCodeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
- **ActionRow** — props: message, actions, hiddenSet, hasCheckpoint, restoreDisabled — `apps/web/src/components/message-parts/ActionRow.tsx`
- **CompactCard** — props: message, sessionChats — `apps/web/src/components/message-parts/CompactCard.tsx`
- **MistakeRecoverySentinel** — props: message — `apps/web/src/components/message-parts/MistakeRecoverySentinel.tsx`
- **ReasoningBlock** — props: text, streaming — `apps/web/src/components/message-parts/ReasoningBlock.tsx`
- **SendToTerminalMenu** — `apps/web/src/components/message-parts/SendToTerminalMenu.tsx`
- **StatsLine** — props: message — `apps/web/src/components/message-parts/StatsLine.tsx`
- **SummaryCard** — props: message — `apps/web/src/components/message-parts/SummaryCard.tsx`
- **ArenaPane** — props: state, onClose — `apps/web/src/components/panes/ArenaPane.tsx`
- **ChatPane** — props: sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled — `apps/web/src/components/panes/ChatPane.tsx`
- **CoderMessageList** — props: messages, chatId, footer, actions, checkpointMessageIds, restoreDisabled — `apps/web/src/components/panes/CoderMessageList.tsx`
- **CoderPane** — props: sessionId, paneId, chatId, chatPending, projectPath, onConnectedChange, onAgentLabelChange — `apps/web/src/components/panes/CoderPane.tsx`
- **OrchestratorPane** — props: state, onClose — `apps/web/src/components/panes/OrchestratorPane.tsx`
- **SettingsPane** — props: session, project, maximized, onToggleMaximize, onClose, isMobile — `apps/web/src/components/panes/SettingsPane.tsx`
- **TerminalPane** — props: sessionId, paneId, label, active — `apps/web/src/components/panes/TerminalPane.tsx`
- **TerminalPane** — props: sessionId, paneId, label, description, parentAgent, active — `apps/web/src/components/panes/TerminalPane.tsx`
- **FloatingMenu** — props: x, y, hasSelection, chatInputs, onCopy, onPaste, onSelectAll, onSearch, onSendToChat, onDismiss — `apps/web/src/components/panes/terminal/FloatingMenu.tsx`
- **SearchBar** — props: searchRef, theme, onClose — `apps/web/src/components/panes/terminal/SearchBar.tsx`
- **TerminalHotkeyBar** — props: ctrlArmed, onSendBytes, onArmCtrl, onFit — `apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx`
- **RightRailDrawerProvider** — `apps/web/src/hooks/useRightRailDrawer.tsx`
- **SidebarDrawerProvider** — `apps/web/src/hooks/useSidebarDrawer.tsx`
- **PATH_REGEX** — `apps/web/src/lib/linkify-paths.tsx`
- **Analytics** — `apps/web/src/pages/Analytics.tsx`
- **Home** — `apps/web/src/pages/Home.tsx`
- **Memory** — `apps/web/src/pages/Memory.tsx`
- **Project** — `apps/web/src/pages/Project.tsx`
- **Results** — `apps/web/src/pages/Results.tsx`
- **Session** — `apps/web/src/pages/Session.tsx`
- **Settings** — `apps/web/src/pages/Settings.tsx`

View File

@@ -25,8 +25,11 @@
- `CONTAINER_GUIDANCE_FILE` **required** — apps/server/src/services/__tests__/system-prompt.test.ts
- `CONTEXT7_API_KEY` (has default) — .env
- `DATABASE_URL` (has default) — .env.example
- `DEEPSEEK_API_KEY` (has default) — .env
- `DEEPSEEK_BASE_URL` (has default) — .env
- `DEFAULT_MODEL` (has default) — .env.example
- `DEV_REMOTE_USER` **required** — apps/web/vite.config.ts
- `EMBEDDING_MODEL_PATH` **required** — apps/server/src/services/memory/embeddings.ts
- `GITEA_BASE_URL` (has default) — .env
- `GITEA_SSH_HOST` (has default) — .env
- `GITEA_TOKEN` (has default) — .env
@@ -34,6 +37,7 @@
- `LLAMA_SWAP_URL` (has default) — .env.example
- `MCP_TEST_MISSING` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
- `MCP_TEST_SECRET` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
- `MEMORY_SEARCH` **required** — apps/server/src/services/memory/recall.ts
- `NODE_ENV` (has default) — .env.example
- `PORT` (has default) — .env.example
- `POSTGRES_PASSWORD` (has default) — .env.example
@@ -48,3 +52,7 @@
- `Dockerfile`
- `apps/web/vite.config.ts`
- `docker-compose.yml`
## Key Dependencies
- better-sqlite3: ^11.10.0

View File

@@ -2,36 +2,36 @@
## Most Imported Files (change these carefully)
- `apps/coder/src/db.ts` — imported by **40** files
- `apps/server/src/types/api.ts` — imported by **28** files
- `apps/server/src/db.ts` — imported by **25** files
- `apps/coder/src/db.ts` — imported by **44** files
- `apps/server/src/types/api.ts` — imported by **34** files
- `apps/server/src/db.ts` — imported by **32** files
- `packages/ion/src/cli/utils.ts` — imported by **24** files
- `apps/coder/src/services/tools/types.ts` — imported by **18** files
- `apps/coder/src/conductor/types.ts` — imported by **14** files
- `apps/coder/src/conductor/types.ts` — imported by **16** files
- `apps/server/src/services/tools.ts` — imported by **15** files
- `apps/coder/src/services/agent-backend.ts` — imported by **14** files
- `apps/coder/src/services/acp-tool-snapshot.ts` — imported by **14** files
- `apps/server/src/config.ts` — imported by **14** files
- `apps/server/src/services/tools/codecontext/factory.ts` — imported by **14** files
- `apps/server/src/services/tools.ts` — imported by **13** files
- `apps/server/src/services/tools/types.ts` — imported by **13** files
- `conductor/src/types.ts` — imported by **13** files
- `apps/coder/src/services/provider-config-registry.ts` — imported by **12** files
- `apps/server/src/config.ts` — imported by **12** files
- `apps/coder/src/config.ts` — imported by **11** files
- `apps/coder/src/services/provider-types.ts` — imported by **11** files
- `apps/server/src/services/broker.ts` — imported by **10** files
- `apps/server/src/services/agents.ts` — imported by **10** files
- `apps/server/src/services/path_guard.ts` — imported by **10** files
- `apps/coder/src/services/pending_changes.ts` — imported by **9** files
- `apps/server/src/services/broker.ts` — imported by **9** files
- `apps/server/src/services/path_guard.ts` — imported by **9** files
- `apps/server/src/services/inference/payload.ts` — imported by **9** files
## Import Map (who imports what)
- `apps/coder/src/db.ts``apps/coder/src/index.ts`, `apps/coder/src/routes/__tests__/agent-sessions.routes.test.ts`, `apps/coder/src/routes/__tests__/chat-resolve.test.ts`, `apps/coder/src/routes/__tests__/providers.routes.test.ts`, `apps/coder/src/routes/agent-sessions.ts` +35 more
- `apps/server/src/types/api.ts``apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts`, `apps/server/src/routes/projects.ts`, `apps/server/src/routes/sessions.ts` +23 more
- `apps/server/src/db.ts``apps/server/src/index.ts`, `apps/server/src/routes/agents.ts`, `apps/server/src/routes/artifacts.ts`, `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts` +20 more
- `apps/coder/src/db.ts``apps/coder/src/index.ts`, `apps/coder/src/routes/__tests__/agent-sessions.routes.test.ts`, `apps/coder/src/routes/__tests__/chat-resolve.test.ts`, `apps/coder/src/routes/__tests__/providers.routes.test.ts`, `apps/coder/src/routes/agent-sessions.ts` +39 more
- `apps/server/src/types/api.ts``apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts`, `apps/server/src/routes/projects.ts`, `apps/server/src/routes/sessions.ts` +29 more
- `apps/server/src/db.ts``apps/server/src/index.ts`, `apps/server/src/routes/agents.ts`, `apps/server/src/routes/analytics.ts`, `apps/server/src/routes/artifacts.ts`, `apps/server/src/routes/chats.ts` +27 more
- `packages/ion/src/cli/utils.ts``packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/cleanup.ts` +19 more
- `apps/coder/src/services/tools/types.ts``apps/coder/src/routes/messages.ts`, `apps/coder/src/services/dispatcher.ts`, `apps/coder/src/services/tools/adapter.ts`, `apps/coder/src/services/tools/apply_pending.ts`, `apps/coder/src/services/tools/check_task_status.ts` +13 more
- `apps/coder/src/conductor/types.ts``apps/coder/src/conductor/flows/_util.ts`, `apps/coder/src/conductor/flows/architectural-analysis.ts`, `apps/coder/src/conductor/flows/authoring.ts`, `apps/coder/src/conductor/flows/code-review.ts`, `apps/coder/src/conductor/flows/discovery.ts` +9 more
- `apps/coder/src/conductor/types.ts``apps/coder/src/conductor/flows/_util.ts`, `apps/coder/src/conductor/flows/architectural-analysis.ts`, `apps/coder/src/conductor/flows/authoring.ts`, `apps/coder/src/conductor/flows/code-review.ts`, `apps/coder/src/conductor/flows/discovery.ts` +11 more
- `apps/server/src/services/tools.ts``apps/server/src/index.ts`, `apps/server/src/services/__tests__/agent-allowlist.test.ts`, `apps/server/src/services/agents.ts`, `apps/server/src/services/inference/stream-phase-adapter.ts`, `apps/server/src/services/inference/stream-phase.ts` +10 more
- `apps/coder/src/services/agent-backend.ts``apps/coder/src/routes/lifecycle.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-event-map.ts`, `apps/coder/src/services/agent-pool.ts`, `apps/coder/src/services/backends/__tests__/claude-sdk-map.test.ts` +9 more
- `apps/coder/src/services/acp-tool-snapshot.ts``apps/coder/src/services/__tests__/acp-event-map.test.ts`, `apps/coder/src/services/__tests__/frame-emitter.test.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-dispatch.ts`, `apps/coder/src/services/acp-event-map.ts` +9 more
- `apps/server/src/services/tools/codecontext/factory.ts``apps/server/src/services/tools/codecontext/get_blast_radius.ts`, `apps/server/src/services/tools/codecontext/get_call_graph.ts`, `apps/server/src/services/tools/codecontext/get_codebase_overview.ts`, `apps/server/src/services/tools/codecontext/get_dependencies.ts`, `apps/server/src/services/tools/codecontext/get_file_analysis.ts` +9 more
- `apps/server/src/services/tools.ts``apps/server/src/index.ts`, `apps/server/src/services/__tests__/agent-allowlist.test.ts`, `apps/server/src/services/agents.ts`, `apps/server/src/services/inference/stream-phase-adapter.ts`, `apps/server/src/services/inference/stream-phase.ts` +8 more
- `apps/server/src/config.ts``apps/server/src/db.ts`, `apps/server/src/index.ts`, `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts` +9 more

View File

@@ -14,8 +14,17 @@
- function ensureSession: (tmuxConfPath, sessionName, projectRoot, log, cols?, rows?) => Promise<void>
- function killSession: (tmuxConfPath, sessionName) => Promise<boolean>
- function capturePane: (tmuxConfPath, sessionName, lines) => Promise<string>
- _...1 more_
- `apps/booterm/src/pty/pty.ts` — function attachPty: (opts) => IPty
- `apps/booterm/src/ws/attach.ts` — function registerWsAttachRoute: (app, tmuxConfPath) => void
- `apps/booterm/src/pty/registry.ts`
- function register: (sessionId, paneId, projectPath, title?, opts?) => void
- function unregister: (paneId) => void
- function touchActivity: (paneId) => void
- function list: () => SessionMeta[]
- function get: (paneId) => SessionMeta | undefined
- function setPendingMetadata: (paneId, meta) => void
- _...8 more_
- `apps/booterm/src/ws/attach.ts` — function registerWsAttachRoute: (app, tmuxConfPath, idleTimeoutSeconds?, absoluteTimeoutSeconds?) => void
- `apps/coder/src/conductor/contracts.ts`
- function produceContract: (contracts) => string
- function reviewContract: (contracts) => string
@@ -102,7 +111,7 @@
- function classifyLane: (battleType, _identity, model, localModels) => ContestantLane
- function nextLocalContestant: (contestants) => string | null
- function isBattleComplete: (contestants) => boolean
- function computeBenchmark: (startedAt, endedAt, costTokens, lane) => Benchmark
- function computeBenchmark: (startedAt, endedAt, costTokens, lane, tokenBreakdown) => Benchmark
- function sanitizeSlug: (s) => string
- function buildBattleSlug: (battleId, battleType, createdAt) => string
- _...7 more_
@@ -166,6 +175,7 @@
- function stepEndedToUsage: (props) => StepUsage
- interface StepEndedProps
- interface StepUsage
- `apps/coder/src/services/backends/paseo.ts` — class PaseoBackend, interface PaseoBackendDeps
- `apps/coder/src/services/backends/pushable-iterable.ts` — function createPushable: () => Pushable<T>, interface Pushable
- `apps/coder/src/services/backends/turn-guard.ts`
- function armAbortGuard: (g) => void
@@ -174,6 +184,30 @@
- interface AbortTerminalGuard
- `apps/coder/src/services/backends/warm-acp-routing.ts` — function shouldUseWarmBackend: (task) => boolean, function isTurnOkForStopReason: (stopReason) => boolean
- `apps/coder/src/services/backends/warm-acp.ts` — class WarmAcpBackend, interface WarmAcpBackendDeps
- `apps/coder/src/services/behavioral/generation.ts`
- function createExecutionPlan: (observational, actionable, previouslyApplied, disambiguationGroups, lowCriticality) => BatchExecutionPlan[]
- function getRetryTemperatures: (baseTemp, maxAttempts) => number[]
- class SchematicGenerator
- class DefaultSchematicGenerator
- interface ObservationalOutput
- interface ActionableOutput
- _...7 more_
- `apps/coder/src/services/behavioral/matching.ts`
- function matchWithRetry: (fn) => void
- function executeBatchesParallel: (batches, _generationInfo) => Promise<GuidelineMatchingResult>
- function createScoredMatch: (guidelineId, score, rationale) => ScoredMatch
- class GuidelineMatchingBatchError
- class ObservationalGuidelineMatchingBatch
- class ActionableGuidelineMatchingBatch
- _...25 more_
- `apps/coder/src/services/behavioral/resolver.ts`
- class RelationalResolver
- interface RelationshipEntity
- interface Relationship
- interface RelationshipStore
- interface ResolvedEntity
- interface Resolution
- _...8 more_
- `apps/coder/src/services/cancel-registry.ts` — function createCancelRegistry: () => CancelRegistry, interface CancelRegistry
- `apps/coder/src/services/checkpoints.ts`
- function buildShadowCommitCommand: (worktreePath, id) => string
@@ -184,7 +218,15 @@
- interface RestoreCheckpointResult
- _...1 more_
- `apps/coder/src/services/claude-command-discovery.ts` — function discoverClaudeCommands: () => AgentCommand[]
- `apps/coder/src/services/collision-detector.ts`
- function findConflicts: (changedFiles, worktreeId, /** Approximate line range for the proposed changes, keyed by file path */
changedRanges, {...}, conflictIndex) => ConflictVerdict[]
- interface ConflictVerdict
- interface ConflictEntry
- type ConflictSeverity
- type ConflictIndexData
- `apps/coder/src/services/command-availability.ts` — function isCommandAvailable: (binary) => Promise<boolean>
- `apps/coder/src/services/conflict-index.ts` — class ConflictIndex, const conflictIndex
- `apps/coder/src/services/correction-service.ts`
- function recordCorrection: (originalClaim, correction, principleExtracted, persistedTo, basePath?) => Promise<UserCorrectionRecord>
- function scanForCorrections: (auditPath) => Promise<UserCorrectionRecord[]>
@@ -214,10 +256,11 @@
- function partitionReady: (ready, ctx) => void
- function isRunComplete: (flow, state) => boolean
- function isStuck: (flow, state) => boolean
- function reconcileResumeStep: (status, taskId, taskState) => ResumeAction
- _...5 more_
- function buildBatchState: (flow, inFlight) => Map<string,
- _...12 more_
- `apps/coder/src/services/flow-runner.ts`
- function createFlowRunner: (deps) => FlowRunner
- function resolveVariables: (prompt, results, string>) => string
- interface LaunchOpts
- interface FlowRunner
- `apps/coder/src/services/frame-emitter.ts`
@@ -237,6 +280,19 @@
- function deleteGuideline: (id, basePath?) => Promise<boolean>
- function findGuideline: (content, basePath?) => Promise<Guideline | null>
- _...14 more_
- `apps/coder/src/services/hashline/hash-computation.ts`
- function computeLineHash: (lineNumber, content) => string
- function computeLegacyLineHash: (lineNumber, content) => string
- function formatHashLine: (lineNumber, content) => string
- function formatHashLines: (content) => string
- `apps/coder/src/services/hashline/validation.ts`
- function normalizeLineRef: (ref) => string
- function parseLineRef: (ref) => LineRef
- function validateLineRef: (lines, ref) => void
- function validateLineRefs: (lines, refs) => void
- class HashlineMismatchError
- interface LineRef
- `apps/coder/src/services/hashline/xxhash32.ts` — function hashXxh32: (input, seed) => number
- `apps/coder/src/services/host-exec.ts` — function hostExec: (command, opts?) => Promise<HostExecResult>, interface HostExecResult
- `apps/coder/src/services/lsp/client.ts` — class LspClient
- `apps/coder/src/services/lsp/config.ts` — function getServerConfig: (filePath) => LspServerConfig | null, interface LspServerConfig
@@ -248,6 +304,44 @@
- function findReferences: (client, filePath, content, line, character) => Promise<Location[]>
- `apps/coder/src/services/lsp/server-manager.ts` — class LspServerManager, const lspManager
- `apps/coder/src/services/mcp-server.ts` — function startMcpServer: (sql) => Promise<void>
- `apps/coder/src/services/model-resolution/connected-providers-cache.ts`
- function readConnectedProvidersCache: () => string[] | null
- function findProviderModelMetadata: (_providerID, _modelID) => ModelMetadata | undefined
- function readProviderModelsCache: () => ProviderModelsCache | null
- interface ProviderModelsCache
- interface ConnectedProvidersAdapter
- const connectedProvidersAdapter: ConnectedProvidersAdapter
- `apps/coder/src/services/model-resolution/fallback-chain-from-models.ts`
- function parseFallbackModelEntry: (model, contextProviderID, defaultProviderID) => FallbackEntry | undefined
- function parseFallbackModelObjectEntry: (obj, contextProviderID, defaultProviderID) => FallbackEntry | undefined
- function findMostSpecificFallbackEntry: (providerID, modelID, chain) => FallbackEntry | undefined
- function buildFallbackChainFromModels: (fallbackModels) => void
- `apps/coder/src/services/model-resolution/model-availability.ts` — function fuzzyMatchModel: (target, available, providers?) => string | null, function isModelAvailable: (targetModel, availableModels) => boolean
- `apps/coder/src/services/model-resolution/model-error-classifier.ts`
- function isRetryableModelError: (error) => boolean
- function shouldRetryError: (error) => boolean
- function getNextFallback: (fallbackChain, attemptCount) => FallbackEntry | undefined
- function hasMoreFallbacks: (fallbackChain, attemptCount) => boolean
- function selectFallbackProvider: (providers, preferredProviderID?) => string
- function selectFallbackProviderWithCache: (providers, providerCache, preferredProviderID?) => string
- _...1 more_
- `apps/coder/src/services/model-resolution/model-normalization.ts` — function normalizeModel: (model?) => string | undefined, function normalizeModelID: (modelID) => string
- `apps/coder/src/services/model-resolution/model-resolution-pipeline.ts`
- function _setModelResolutionLogImplementationForTesting: (logImplementation) => void
- function resolveModelPipeline: (request, providerCache) => void
- type ModelResolutionRequest
- type ModelResolutionProvenance
- type ModelResolutionResult
- type ModelResolutionDeps
- `apps/coder/src/services/model-resolution/model-resolver.ts`
- function resolveModel: (input) => string | undefined
- function resolveModelWithFallback: (input, connectedProvidersAdapter) => ModelResolutionResult | undefined
- function normalizeFallbackModels: (models) => void
- function flattenToFallbackModelStrings: (models) => void
- type ModelResolutionInput
- type ModelSource
- _...2 more_
- `apps/coder/src/services/model-resolution/provider-model-id-transform.ts` — function transformModelForProvider: (provider, model) => string, function transformModelForProviderDisplay: (provider, model) => string
- `apps/coder/src/services/net/port-utils.ts`
- function reclaimPort: (port) => void
- function waitForPortRelease: (port, timeoutMs) => Promise<boolean>
@@ -257,6 +351,13 @@
- function createOrphanWorktreeReaper: (deps) => void
- interface OrphanWorktreeReaperDeps
- interface OrphanReaperResult
- `apps/coder/src/services/paseo-client.ts`
- class PaseoClientError
- class PaseoClient
- interface PaseoAgentListItem
- interface PaseoAgentDetail
- interface PaseoSendResult
- interface PaseoClientConfig
- `apps/coder/src/services/pending_changes.ts`
- function planEdit: (content, oldStr, newStr) => EditPlan
- function queueEdit: (sql, sessionId, taskId, filePath, oldString, newString, projectRoot, // v2.6 Phase 1-UX) => void
@@ -273,6 +374,14 @@
- function waitForElicitationResponse: (taskId, sessionId, provider, modeId, params, timeoutMs) => Promise<CreateElicitationResponse>
- function cancelPendingPermission: (taskId) => void
- _...3 more_
- `apps/coder/src/services/plan-store.ts`
- function createPlan: (sql, opts) => Promise<Plan>
- function getPlan: (sql, planId) => Promise<Plan | null>
- function listPlans: (sql, projectId) => Promise<Plan[]>
- function listActivePlans: (sql, projectId) => Promise<Plan[]>
- function updatePlan: (sql, planId, opts) => Promise<Plan | null>
- function updatePlanFromRun: (sql, runId, runStatus) => Promise<boolean>
- _...5 more_
- `apps/coder/src/services/provider-commands.ts`
- function getManifestCommands: (provider) => AgentCommand[]
- function mergeCommands: (...lists) => AgentCommand[]
@@ -295,13 +404,13 @@
- interface ProviderManifestEntry
- const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry>
- `apps/coder/src/services/provider-snapshot.ts`
- function fetchDeepSeekModels: (config) => Promise<ProviderModel[]>
- function fetchLlamaSwapModels: (config) => Promise<ProviderModel[]>
- function prefixLlamaSwapModels: (models) => ProviderModel[]
- function mergeModels: (...lists) => ProviderModel[]
- function getProviderSnapshot: (sql, config, cwd?, force) => Promise<ProviderSnapshotEntry[]>
- function clearProviderSnapshotCache: () => void
- function peekSnapshotEntry: (name, cwd?) => ProviderSnapshotEntry | undefined
- _...1 more_
- _...2 more_
- `apps/coder/src/services/pty-dispatch.ts`
- function dispatchViaPty: (opts) => Promise<DispatchResult>
- interface DispatchResult
@@ -411,6 +520,17 @@
- function readSession: (sessionId, projectRoot?) => SessionJson | null
- _...9 more_
- `apps/server/src/services/auto_name.ts` — function maybeAutoNameChat: (ctx, chatId, sessionId) => Promise<void>
- `apps/server/src/services/background-task.ts`
- function setBackgroundInferenceEnqueuer: (enqueue, chatId, assistantMessageId, user) => void
- function spawnBackgroundTask: (sql, log, projectId, input, model, agent?, label?) => Promise<BackgroundTask>
- function getBackgroundTaskStatus: (sql, taskId) => Promise<BackgroundTask | null>
- function getBackgroundTaskResult: (sql, taskId, chatId) => Promise<
- function cancelBackgroundTask: (sql, taskId) => Promise<boolean>
- interface BackgroundTask
- `apps/server/src/services/boocontext_client.ts`
- function callBoocontext: (req, log?, msg) => void
- interface BoocontextRequest
- interface BoocontextResponse
- `apps/server/src/services/broker.ts`
- function createBroker: (log?) => Broker
- interface Broker
@@ -429,6 +549,7 @@
- function select: (messages, contextLimit, tailTurns) => SelectResult
- function deriveFilesRead: (head) => string[]
- _...8 more_
- `apps/server/src/services/export-formatter.ts` — function formatJson: (chat, messages, model) => string, function formatMarkdown: (chat, messages, model) => string
- `apps/server/src/services/file_index.ts` — function getProjectFiles: (projectId, projectRoot) => Promise<string[]>
- `apps/server/src/services/file_ops.ts`
- function listDir: (projectRoot, relPath, opts?) => Promise<ListDirResult>
@@ -453,7 +574,20 @@
- interface GiteaConfig
- interface GiteaRepo
- `apps/server/src/services/grant_resolver.ts` — function resolveGrantRoot: (sql, requestedPath, projectRoot, whitelistRoot) => Promise<GrantResolution>, type GrantResolution
- `apps/server/src/services/hooks.ts`
- function loadHooksConfig: (path) => HooksConfig
- function reloadHooksConfig: () => HooksConfig
- function createHookRunner: () => HookRunner
- interface HookConfig
- interface HooksConfig
- interface PreToolUsePayload
- _...10 more_
- `apps/server/src/services/inference/budget.ts` — function resolveToolBudget: (agent) => number
- `apps/server/src/services/inference/compute-diff.ts`
- function computeDiff: (oldStr, newStr, filePath) => string
- function isWriteTool: (name) => boolean
- function diffFromToolArgs: (name, args, unknown>, filePath?) => string
- const WRITE_TOOL_NAMES
- `apps/server/src/services/inference/content-flusher.ts` — function createContentFlusher: (sql, messageId, getContent) => void, interface ContentFlusher
- `apps/server/src/services/inference/dcp/messages.ts`
- function toDcpMessages: (parts) => DcpMessage[]
@@ -493,6 +627,10 @@
- type FailureKind
- const MISTAKE_THRESHOLD
- _...1 more_
- `apps/server/src/services/inference/multi-modal.ts`
- function hasImageAttachments: (_message) => boolean
- function imageAttachmentsToParts: (attachments) => Array<
- interface ImageAttachment
- `apps/server/src/services/inference/parts.ts`
- function insertParts: (sql, parts) => Promise<void>
- function partsFromAssistantMessage: (args) => void
@@ -505,10 +643,13 @@
- function maybeFlagForCompaction: (ctx, chatId, updated) => Promise<void>
- interface OpenAiMessage
- `apps/server/src/services/inference/provider.ts`
- function resolveRoute: (agent, config?) => RoutingInfo
- function isDeepSeekModel: (modelId) => boolean
- function resolveRoute: (agent, config?, modelId?) => RoutingInfo
- function upstreamModel: (config, modelId, agent?) => LanguageModel
- function resolveModelEndpoint: (config, modelId) => void
- function resetDeepSeekProvider: () => void
- interface RoutingInfo
- type InferenceRoute
- _...1 more_
- `apps/server/src/services/inference/prune.ts`
- function selectPruneTargets: (partsNewestFirst, tailStartCreatedAt) => void
- function prune: (args) => Promise<PruneResult>
@@ -529,6 +670,12 @@
- function isAnySentinel: (m) => boolean
- const DOOM_LOOP_THRESHOLD
- _...1 more_
- `apps/server/src/services/inference/state-graph.ts`
- function createDefaultGraph: () => GraphNode[]
- function runGraph: (ctx, args, extra) => Promise<GraphResult>
- interface GraphState
- interface GraphResult
- type GraphNodeType
- `apps/server/src/services/inference/step-decision.ts`
- function decideStep: (input) => PreStepDecision
- function decidePostToolAction: (action, mistakeTracker) => PostToolDecision
@@ -545,12 +692,14 @@
- `apps/server/src/services/inference/stream-phase.ts` — function executeStreamPhase: (ctx, args, session, messages, state, agent, // v1.11.8, web_search and web_fetch are stripped from the
// tool list sent to the LLM, so the model can't even attempt them.
webToolsEnabled) => Promise<StreamResult>
- `apps/server/src/services/inference/supervisor.ts` — function resolveSupervisorTurn: (latestUserMessage, agents, fallbackModel?) => Promise<SupervisorRoute | null>, interface SupervisorRoute
- `apps/server/src/services/inference/tool-call-parser.ts`
- function stripToolMarkup: (text, opts?) => string
- function extractToolCallBlocks: (buffer, log?) => ToolCallExtraction
- interface ParsedCall
- interface ToolCallExtraction
- `apps/server/src/services/inference/tool-phase.ts` — function executeToolPhase: (ctx, args, result, startedAt, session, projectRoot, agent?) => Promise<ToolPhaseResult>, interface ToolPhaseResult
- `apps/server/src/services/inference/tool-input-repair.ts` — function repairToolInput: (schema, unknown> | undefined, args, unknown>) => void, interface ToolInputRepair
- `apps/server/src/services/inference/tool-phase.ts` — function executeToolPhase: (ctx, args, result, startedAt, session, projectRoot, agent?, turnNumber?) => Promise<ToolPhaseResult>, interface ToolPhaseResult
- `apps/server/src/services/inference/tool-shim.ts`
- function extractToolCalls: (text) => ParsedToolCall[]
- function hasToolCallMarkup: (text) => boolean
@@ -566,20 +715,26 @@
- `apps/server/src/services/inference/turn.ts`
- function runAssistantTurn: (ctx, args) => Promise<void>
- function runInference: (ctx, sessionId, chatId, assistantMessageId, signal?) => Promise<void>
- function runInferenceWithModel: (ctx, sessionId, chatId, assistantMessageId, modelOverride, compareGroupId, signal?) => Promise<void>
- function createInferenceRunner: (ctx, 'publishUser'>, publishUserFn, frame) => void
- `apps/server/src/services/mcp-client.ts`
- function initialize: (entries, logger) => Promise<void>
- function callTool: (prefixedName, args, unknown>) => Promise<unknown>
- function getServerPermission: (prefixedToolName) => McpPermission
- function setServerPermission: (serverName, permission) => void
- function getServerName: (prefixedToolName) => string | null
- function getTools: () => ToolDef<Record<string, unknown>>[]
- function getMcpServers: () => Array<
- function shutdown: () => Promise<void>
- function wrapMcpTool: (serverName, mcpTool) => ToolDef<Record<string, unknown>>
- _...2 more_
- _...6 more_
- `apps/server/src/services/mcp-config.ts`
- function substituteEnvVars: (value, log, unsetVars?) => unknown
- function loadMcpConfig: (configPath, log) => McpServerEntry[]
- interface McpServerEntry
- type McpServerConfig
- `apps/server/src/services/memory/bm25.ts` — class Bm25Ranker
- `apps/server/src/services/memory/embeddings.ts`
- function isEmbeddingAvailable: () => boolean
- function initEmbeddings: (modelPath?) => Promise<boolean>
- function embed: (texts) => Promise<number[][] | null>
- `apps/server/src/services/memory/entries.ts` — function parseMemoryEntries: (fileName, markdown) => MemoryEntry[], interface MemoryEntry
- `apps/server/src/services/memory/paths.ts`
- function getMemoryRoot: (projectRoot) => string
@@ -587,7 +742,10 @@
- function ensureMemoryScaffold: (root) => Promise<void>
- type MemoryTopic
- `apps/server/src/services/memory/prompt.ts` — function formatMemoryBlock: (entries) => string
- `apps/server/src/services/memory/recall.ts` — function rankByRelevance: (query, entries) => MemoryEntry[], function loadMemoryForSession: (projectRoot, _sessionId?, query?) => Promise<string[]>
- `apps/server/src/services/memory/recall.ts`
- function rankByRelevance: (query, entries) => MemoryEntry[]
- function rankByHybrid: (query, entries) => Promise<MemoryEntry[]>
- function loadMemoryForSession: (projectRoot, _sessionId?, query?) => Promise<string[]>
- `apps/server/src/services/memory/scan.ts`
- function scanMemoryScopes: (scope) => Promise<MemoryEntry[]>
- function scanProjectMemory: (projectRoot) => Promise<MemoryEntry[]>
@@ -618,6 +776,11 @@
- function filterSecretEntries: (entries, pathOf) => void
- class SecretBlockedError
- const DEFAULT_SECURITY_IGNORE_FILETYPES: ReadonlyArray<string>
- `apps/server/src/services/session-snapshots.ts`
- function saveAgentSnapshot: (sql, chatId, data) => Promise<void>
- function loadAgentSnapshot: (sql, chatId) => Promise<AgentSnapshot | null>
- function deleteAgentSnapshot: (sql, chatId) => Promise<void>
- interface AgentSnapshot
- `apps/server/src/services/skill-invoke.ts`
- function runSkillInvokeTransaction: (sql, args) => Promise<
- function buildSkillInvokeSyntheticFrames: (chatId, result, toolCall, skillBody) => SkillInvokeSessionFrame[]
@@ -648,8 +811,53 @@
- _...2 more_
- `apps/server/src/services/task-model.ts` — function taskModelCompletion: (opts) => Promise<string>
- `apps/server/src/services/task-search-rewrite.ts` — function rewriteSearchQuery: (userMessage) => Promise<string>
- `apps/server/src/services/tool-traces.ts`
- function insertToolTrace: (sql, insert) => Promise<ToolTrace>
- function updateToolTrace: (sql, id, updates) => Promise<ToolTrace | null>
- interface ToolTrace
- interface ToolTraceInsert
- interface ToolTraceUpdate
- `apps/server/src/services/tools/background-subagent-tools.ts`
- function executeSpawnSubagent: (input, sql, sessionId) => Promise<Record<string, unknown>>
- function executeSubagentStatus: (input, sql) => Promise<Record<string, unknown>>
- function executeSubagentResult: (input, sql) => Promise<Record<string, unknown>>
- type SpawnSubagentInputT
- type SubagentStatusInputT
- type SubagentResultInputT
- _...6 more_
- `apps/server/src/services/tools/codecontext/factory.ts` — function makeCodecontextTool: (opts, unknown>;
mapArgs) => void
- `apps/server/src/services/tools/codecontext/get_code_health.ts`
- function executeGetCodeHealth: (input, projectPath) => Promise<string>
- type GetCodeHealthInputT
- const GetCodeHealthInput
- const getCodeHealth: ToolDef<GetCodeHealthInputT>
- `apps/server/src/services/tools/codecontext/get_code_impact.ts`
- function executeGetCodeImpact: (input, projectPath) => Promise<CodecontextResponse>
- type GetCodeImpactInputT
- const GetCodeImpactInput
- const getCodeImpact: ToolDef<GetCodeImpactInputT>
- `apps/server/src/services/tools/codecontext/get_code_map.ts`
- function executeGetCodeMap: (input, projectRoot) => Promise<CodeMapResponse>
- interface CodeMapResponse
- type GetCodeMapInputT
- const GetCodeMapInput
- const getCodeMap: ToolDef<GetCodeMapInputT>
- `apps/server/src/services/tools/codecontext/get_type_info.ts`
- function executeGetTypeInfo: (input, _projectPath?) => Promise<CodecontextResponse>
- type GetTypeInfoInputT
- const GetTypeInfoInput
- const getTypeInfo: ToolDef<GetTypeInfoInputT>
- `apps/server/src/services/tools/codecontext/get_wiki_article.ts`
- function executeGetWikiArticle: (input, projectPath) => Promise<string>
- type GetWikiArticleInputT
- const GetWikiArticleInput
- const getWikiArticle: ToolDef<GetWikiArticleInputT>
- `apps/server/src/services/tools/execute-command.ts`
- function executeRunCommand: (input, projectRoot) => Promise<RunCommandOutput>
- type RunCommandInputT
- type RunCommandOutput
- const runCommand: ToolDef<RunCommandInputT>
- `apps/server/src/services/tools/registry.ts` — function appendMcpTools: (mcpTools) => void, function toolJsonSchemas: () => ToolJsonSchema[]
- `apps/server/src/services/tools/tiers.ts`
- function resolveToolTier: (tier) => readonly string[]
@@ -675,6 +883,39 @@
- interface WebSearchOutput
- type WebSearchInputT
- const webSearch: ToolDef<WebSearchInputT>
- `apps/server/src/services/workflow/catalog.ts`
- function fingerprintAgentTask: (prompt, spec, unknown>, args) => string
- function getBuiltinWorkflows: () => BuiltinWorkflow[]
- function getBuiltinWorkflow: (name) => BuiltinWorkflow | undefined
- function mergeBuiltinWorkflows: (fileWorkflows) => Array<
- interface BuiltinWorkflow
- const meta
- `apps/server/src/services/workflow/discovery.ts`
- function isBuiltinWorkflow: (meta) => boolean
- function discoverWorkflows: (projectRoot) => WorkflowMeta[]
- function findWorkflow: (name, projectRoot) => WorkflowMeta | undefined
- function isValidWorkflowPath: (filePath) => boolean
- interface WorkflowMeta
- `apps/server/src/services/workflow/manager.ts`
- class WorkflowManager
- interface WorkflowMetaInfo
- type WorkflowEventHandler
- `apps/server/src/services/workflow/resumability.ts`
- function cacheKey: (spec, args) => string
- function getCachedResult: (key) => CachedResult | null
- function setCachedResult: (key, result) => void
- function invalidateRun: (runKey) => void
- function clearCache: () => void
- function cacheSize: () => number
- _...1 more_
- `apps/server/src/services/workflow/sandbox.ts`
- function transformEsmToCjs: (code) => string
- function name: (...) => void
- function isEsmSyntax: (code) => boolean
- function buildSandbox: (context) => Record<string, unknown>
- function loadWorkflowScript: (sourceFile, context) => (...args: unknown[]) => Promise<unknown>
- function loadWorkflowScriptFromCode: (code, context, filename?) => (...args: unknown[]) => Promise<unknown>
- _...3 more_
- `apps/server/src/utils/string-utils.ts` — function stripQuotes: (s) => string
- `apps/web/src/api/client.ts`
- class ApiError
@@ -695,7 +936,7 @@
- interface TerminalSelectionActions
- interface TerminalSelection
- `apps/web/src/hooks/terminal/useTerminalSocket.ts`
- function useTerminalSocket: ({...}, sessionId, paneId, fit, getSize, setSize, }) => TerminalSocket
- function useTerminalSocket: ({...}, sessionId, paneId, description, parentAgent, fit, getSize, setSize, }) => TerminalSocket
- interface TerminalSocket
- type ConnState
- `apps/web/src/hooks/useActivePane.ts`
@@ -719,7 +960,8 @@
- interface ThroughputSample
- `apps/web/src/hooks/useCoderUserEvents.ts` — function useCoderUserEvents: () => void
- `apps/web/src/hooks/useDiffPreferences.ts` — function useDiffPreferences: () => void, interface DiffPreferences
- `apps/web/src/hooks/useGitDiff.ts` — function useGitDiff: (projectId) => void
- `apps/web/src/hooks/useDraftPersistence.ts` — function useDraftPersistence: (chatId) => DraftPersistenceResult, interface DraftPersistenceResult
- `apps/web/src/hooks/useGitDiff.ts` — function useGitDiff: (projectId, hideWhitespace) => void
- `apps/web/src/hooks/useLongPress.ts` — function useLongPress: (callback) => void
- `apps/web/src/hooks/useProjectGit.ts` — function useProjectGit: (projectId) => GitMeta | null
- `apps/web/src/hooks/useProviderSnapshot.ts` — function refreshProviderSnapshot: (cwd?) => Promise<ProviderSnapshotEntry[]>, function useProviderSnapshot: (cwd?) => ProviderSnapshotEntry[] | null
@@ -732,6 +974,7 @@
- `apps/web/src/hooks/useSessions.ts` — function useSessions: (projectId) => void
- `apps/web/src/hooks/useSidebar.ts` — function useSidebar: () => void
- `apps/web/src/hooks/useSkills.ts` — function useSkills: () => void
- `apps/web/src/hooks/useTerminals.ts` — function useTerminals: () => TerminalRegistration[]
- `apps/web/src/hooks/useUserEvents.ts` — function useUserEvents: () => void
- `apps/web/src/hooks/useViewport.ts` — function useViewport: () => ViewportSnapshot, interface ViewportSnapshot
- `apps/web/src/hooks/useWorkspacePanes.ts`
@@ -794,7 +1037,16 @@
- interface ThemeMeta
- type ThemeId
- _...5 more_
- `apps/web/src/lib/tool-utils.ts`
- function isMcpTool: (name) => boolean
- function extractServerName: (name) => string | null
- function extractToolName: (name) => string | null
- const BUILT_IN_TOOLS
- `apps/web/src/lib/utils.ts` — function cn: (...inputs) => void
- `apps/web/src/stores/useDiffCommentStore.ts`
- function useDiffComments: (sessionId, mode) => void
- interface DiffComment
- interface DiffCommentTarget
- `apps/web/src/utils/diff-layout.ts`
- function parseDiff: (diffBody) => ParsedDiffFile[]
- function buildSplitRows: (file) => SplitRow[]

View File

@@ -7,6 +7,7 @@
- turn-guard — `apps/coder/src/services/backends/turn-guard.ts`
- get_middleware — `apps/server/src/services/tools/codecontext/get_middleware.ts`
- authoring — `conductor/src/flows/authoring.ts`
- spec — `openspec/changes/add-behavioral-engine/specs/audit-middleware/spec.md`
## custom
- write_guard.test — `apps/coder/src/services/__tests__/write_guard.test.ts`

View File

@@ -3,6 +3,7 @@
## CRUD Resources
- **`/api/battles`** GET | POST | GET/:id → Battle
- **`/api/plans`** GET | POST | GET/:id | PATCH/:id → Plan
- **`/api/runs`** GET | POST | GET/:id → Run
- **`/api/tasks`** GET | POST | GET/:id → Task
- **`/api/chats/:id/messages`** GET | POST | GET/:id | DELETE/:id → Message
@@ -14,11 +15,16 @@
### fastify
- `GET` `/api/term/health` params()
- `GET` `/api/term/sessions/:sid/panes/:pid/search` params(sid, pid) [auth]
- `GET` `/api/term/sessions` params() [auth]
- `POST` `/api/term/sessions/:sid/panes/:pid/start` params(sid, pid) [auth]
- `POST` `/api/term/sessions/:sid/panes/:pid/kill` params(sid, pid) [auth]
- `GET` `/ws/term/sessions/:sid/panes/:pid` params(sid, pid) [auth]
- `GET` `/api/health` params() [auth, db, queue, ai]
- `GET` `/api/sessions/:sessionId/agent-sessions` params(sessionId) [auth, db]
- `GET` `/api/analytics/summary` params() [auth, db]
- `GET` `/api/analytics/sessions` params() [auth, db]
- `GET` `/api/analytics/token-breakdown` params() [auth, db]
- `POST` `/api/battles/generate-prompt` params() [auth, db]
- `POST` `/api/battles/:id/stop` params(id) [auth, db]
- `GET` `/api/battles/:id/analysis` params(id) [auth, db]
@@ -42,6 +48,7 @@
- `POST` `/api/pending/:id/apply` params(id) [auth, db, queue]
- `POST` `/api/pending/:id/reject` params(id) [auth, db, queue]
- `POST` `/api/pending/:id/rewind` params(id) [auth, db, queue]
- `GET` `/api/plans/active` params() [db]
- `GET` `/api/providers/snapshot` params() [db, cache]
- `GET` `/api/providers/config` params() [db, cache]
- `PATCH` `/api/providers/config` params() [db, cache]
@@ -59,19 +66,22 @@
- `GET` `/api/ws/sessions/:sessionId` params(sessionId) [auth, db]
- `GET` `/api/ws/user` params() [auth, db]
- `GET` `/api/projects/:id/agents` params(id) [db, cache]
- `GET` `/api/analytics/context` params() [auth, db]
- `POST` `/api/chats/:id/messages/:msg_id/artifacts/download` params(id, msg_id) [auth, db]
- `GET` `/api/chats/:id/messages/:msg_id/html_artifact` params(id, msg_id) [auth, db]
- `GET` `/api/projects/:project_id/artifacts/:filename` params(project_id, filename) [auth, db]
- `GET` `/api/sessions/:id/chats` params(id) [auth, db]
- `POST` `/api/sessions/:id/chats` params(id) [auth, db]
- `PATCH` `/api/chats/:id` params(id) [auth, db]
- `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db]
- `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db]
- `POST` `/api/chats/:id/archive` params(id) [auth, db]
- `POST` `/api/chats/:id/unarchive` params(id) [auth, db]
- `DELETE` `/api/chats/:id` params(id) [auth, db]
- `POST` `/api/chats/:id/fork` params(id) [auth, db]
- `POST` `/api/chats/:id/discard_stale` params(id) [auth, db]
- `GET` `/api/sessions/:id/chats` params(id) [auth, db, queue]
- `POST` `/api/sessions/:id/chats` params(id) [auth, db, queue]
- `PATCH` `/api/chats/:id` params(id) [auth, db, queue]
- `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db, queue]
- `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/archive` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/unarchive` params(id) [auth, db, queue]
- `DELETE` `/api/chats/:id` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/fork` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/discard_stale` params(id) [auth, db, queue]
- `GET` `/api/chats/:id/export` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/compare` params(id) [auth, db, queue]
- `GET` `/api/coder/ws/sessions/:sessionId` params(sessionId) [auth]
- `ALL` `/api/coder/*` params() [auth]
- `GET` `/api/settings/inference` params() [cache]
@@ -83,7 +93,9 @@
- `POST` `/api/chats/:id/continue` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/force_send` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/grant_read_access` params(id) [auth, db, queue]
- `GET` `/api/models` params()
- `POST` `/api/chats/:id/mcp-approve` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/messages/:message_id/feedback` params(id, message_id) [auth, db, queue]
- `GET` `/api/models` params() [auth]
- `POST` `/api/projects/create` params() [auth, db]
- `POST` `/api/projects/:id/archive` params(id) [auth, db]
- `POST` `/api/projects/:id/unarchive` params(id) [auth, db]
@@ -111,6 +123,7 @@
- `GET` `/api/skills` params() [auth, db, queue]
- `POST` `/api/chats/:id/skill_invoke` params(id) [auth, db, queue]
- `GET` `/api/tools/cost_stats` params() [auth, db]
- `GET` `/api/chats/:id/traces` params(id) [db]
- `GET` `/api/ws/sessions/:id` params(id) [auth, db]
### go-net-http

View File

@@ -118,6 +118,25 @@
- model: text (required)
- verdict: text
### flow_step_events
- id: uuid (pk)
- run_id: uuid (required, fk)
- step_id: varchar (required, fk)
- event: varchar (required)
- payload: jsonb
### plans
- id: uuid (pk)
- project_id: uuid (required, fk)
- title: text (required)
- description: text
- status: text (required)
- flow_run_id: uuid (fk)
- progress_pct: integer (required)
- items_total: integer (required)
- items_completed: integer (required)
- metadata: jsonb
### projects
- id: uuid (pk)
- name: text (required)
@@ -139,6 +158,8 @@
- content: text (required)
- status: text (required)
- last_seq: integer (required)
- cache_tokens: integer
- reasoning_tokens: integer
### message_parts
- id: uuid (pk)
@@ -155,3 +176,42 @@
- session_id: uuid (required, fk)
- name: text
- status: text (required)
### tool_traces
- id: uuid (pk)
- session_id: uuid (required, fk)
- chat_id: uuid (required, fk)
- message_id: uuid (fk)
- turn_number: integer (required)
- tool_name: text (required)
- tool_input: jsonb (required)
- tool_output: text
- started_at: timestamp(tz) (required)
- finished_at: timestamp(tz)
- latency_ms: integer
- tokens_used: integer
- cache_tokens: integer
- reasoning_tokens: integer
- error: text
- outcome: text
### tool_trace_states
- id: uuid (pk)
- session_id: uuid (required, fk)
- chat_id: uuid (required, fk)
- message_id: uuid (fk)
- turn_number: integer (required)
- tool_name: text (required)
- tool_input: jsonb (required)
- started_at: timestamp(tz) (required)
### agent_snapshots
- id: uuid (pk)
- session_id: uuid (required, fk)
- chat_id: uuid (required, fk)
- model: text (required)
- agent: text
- mode: text
- turn_number: integer (required)
- messages: jsonb (required)
- tool_states: jsonb (required)

10
.gitignore vendored
View File

@@ -21,3 +21,13 @@ data/*
!data/coder-providers.example.json
codecontext/fork.tar.gz
/Arena
# Auto-generated & scratch artifacts
.impeccable/
.omo/
bun.lock
DESIGN.md
PRODUCT.md
# codesight auto-generated analysis cache
apps/web/.codesight/

View File

@@ -0,0 +1,55 @@
# Dynamic Workflow Engine — Design
## Architecture
```
User writes workflow JS file:
.boocode/workflows/my-flow.js
Workflow Runtime (apps/server)
├── isolated-vm sandbox (or node:vm)
├── API surface: agent(), parallel(), pipeline(), phase(), budget()
├── Tool bridge → BooCode's existing tool set
├── Workflow manager (concurrency, lifecycle)
├── Resumability cache (SHA-256 of agent spec)
└── Catalog (built-in workflows: deep-research, review-code)
Workflow execution:
1. User triggers workflow (slash command or Orchestrator panel)
2. File discovery finds .boocode/workflows/<name>.js
3. Sandbox compiles and executes the script
4. agent() calls go through tool bridge → existing inference pipeline
5. parallel() spawns concurrent agent calls (max 3 default)
6. Results stream via existing WS frames
7. Completed agents cached by hash for resume
API Surface (Claude Code compatible):
agent(prompt, { label?, schema?, model?, capabilities?, max_tool_calls? })
parallel([() => agent(...), () => agent(...)])
pipeline(items, ...stages)
phase(title)
log(message)
budget.total / budget.spent() / budget.remaining()
args
workflow(name, args?) — one level of nesting
```
## Implementation Plan
### Phase 1: Core Runtime (this session)
- Sandbox using Node's `vm` module (no extra deps)
- `agent()` function that creates a task and waits for completion
- Workflow file discovery
- Basic workflow manager
### Phase 2: Advanced Primitives
- `parallel()` with concurrency limits
- `pipeline()` streaming
- `budget()` token tracking
- Workflow resumability cache
### Phase 3: UI + Polish
- Integration with Orchestrator panel
- Built-in workflow catalog
- Workflow editor
- Error recovery

View File

@@ -1,4 +1,4 @@
# BooChat
# BooChat — v2.7.17 (2026-06-08)
## Capabilities
@@ -9,6 +9,9 @@
- `ask_user_input` (interactive option chips)
- Opt-in per chat: `web_search`, `web_fetch` (SearXNG-backed, SSRF-guarded)
## Guidance resolution order
When multiple sources conflict: inline file guidance (this file) → per-session `system_prompt` → agent definition → model default. Last wins on samplers, first wins on refusals.
## You cannot
- Write, edit, or delete files
@@ -44,6 +47,11 @@
Always-true rules (process discipline, refusals, behavior contracts) live here in `BOOCHAT.md` — and in `BOOCODER.md` / `CLAUDE.md` per their scopes — where they are 100% present in every turn. On-demand recipes (specific procedures, scaffolds, checklists) live in `/data/skills/` and invoke roughly 6% of the time in clean multi-turn flow (Codeminer42 measurement, 2026). Don't file workflow rules as skills — they silently misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for the canonical conventions.
## Cross-file invariants
- **Tool capability lists**: `BOOCHAT.md:5-10` (read-only tools) must stay in sync with `apps/server/src/services/tools/registry.ts` `ALL_TOOLS`. If a tool is added to the registry but not listed here, models won't know to reach for it.
- **Capability refusals**: `BOOCHAT.md:12-17` ("You cannot") mirrors the path/secret/url guards in `apps/server/src/services/{path_guard,secret_guard,url_guard}.ts`. Adding a new guard type should update this refusal list.
## Verification discipline
- When assessing implementation status, verify against the running container (`curl /api/health`) and latest git commit (`git log --oneline -3`), not just source file contents. Source files can be mid-edit. The deployed state is the truth.

View File

@@ -1,4 +1,4 @@
# BooCoder — Container Guidance
# BooCoder — Container Guidance — v2.7.x (last meaningful update: 2026-06)
You are BooCoder, a write-capable coding agent. You can read AND modify files within the project scope.
@@ -19,6 +19,10 @@ You are BooCoder, a write-capable coding agent. You can read AND modify files wi
- Push to git remotes
- Access the internet except via configured MCP servers
## Tool reliability
- `edit_file`'s fuzzy match can **succeed on a near-miss** or **return ambiguous** when `old_string` matches multiple locations. Always verify the queued diff before calling `apply_pending` — the diff preview is authoritative, the tool's "success" return is not.
- The external agent's worktree diff only shows changes since the **last turn**, not since the project baseline. The DiffPanel merges these, but if you call `git diff` directly, you'll get incomplete results.
## Pending changes discipline
Every file modification queues in `pending_changes` before touching disk. The user sees a diff preview and approves/rejects each change. Never bypass this queue — it is the safety boundary between inference and the filesystem.

View File

@@ -2,6 +2,14 @@
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.20-paseo-orchestrator-ph3-5 — 2026-06-08
Completes the Paseo-like Orchestrator with phases 35. Phase 3 ships a Dynamic Workflow Engine built on Node's `vm` sandbox — Claude Code compatible JavaScript workflows with `agent()`, `parallel()`, `pipeline()`, `phase()`, and `budget()` primitives. Includes a built-in workflow catalog (`deep-research`, `review-code`, `find-issues`) with SHA-256 hash-based resumability cache that skips completed steps on re-run. Phase 4 adds background subagents — `spawn_subagent` returns immediately, `subagent_status` and `subagent_result` tools let the model poll and collect results. Phase 5 adds a cache shape telemetry badge to the trace viewer (colored bar + hit rate percentage) and a multi-modal attachment stub. Also ships inline diff snippets in the chat stream after write tool calls, and the `run_command` tool with auto-fix loop that detects build failures after edits and injects errors for self-correction.
## v2.8.19-paseo-orchestrator-ph1-2 — 2026-06-08
Ships the trace system and session persistence backbone. Every tool call is now timed via `tool_traces` DB table with latency, token counts, cache/reasoning breakdowns, and WS frames streamed live to a new trace viewer pane. Agent sessions survive browser refresh — `agent_snapshots` table persists state on turn boundaries and restores on WebSocket reconnect. A session timeline view shows agent turn history with scroll-to and restore. New frontend components: `TraceViewer` (collapsible panel with timing bars) and `SessionTimeline` (vertical timeline).
## v2.8.18-deepseek-whale-lift — 2026-06-08
Integrates DeepSeek API directly into BooChat and BooCoder via `@ai-sdk/deepseek`, replacing the generic `openai-compatible` wrapper. DeepSeek V4 models (`deepseek-v4-flash`, `deepseek-v4-pro`) with configurable thinking effort levels appear in both chat and coder pane model pickers. Full token tracking — cache hit tokens and reasoning tokens — flow from the API through new DB columns and WS frames into the UI message stats line. Lifts three high-value features from the Whale codebase: a schema-based tool input repair system that coerces types and unwraps markdown autolinks before Zod validation, a shell-based lifecycle hooks system (PreToolUse, PostToolUse, Stop, PreCompact, PostCompact) with JSON stdin/stdout contract, and per-MCP-server permissions (allow/ask/deny) gating tool execution.

View File

@@ -1,5 +1,13 @@
# CLAUDE.md
<!-- Last meaningful update: 2026-06-08 (v2.8.20-paseo-orchestrator-ph3-5) -->
## You cannot
- Write, edit, or delete files (BooChat only — use BooCoder for writes)
- Run shell commands (use booterm terminal panes)
- Make commits, push, or pull (Sam reviews and commits manually)
- `git add -A` (stage only files you changed)
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
**Cursor agents:** start with `docs/ARCHITECTURE.md` (diagram); this file is the deep engineering reference. `data/AGENTS.md` is the agent *registry*, not navigation (the root navigation `AGENTS.md` was removed).
@@ -51,6 +59,9 @@ Detailed engineering notes live in per-app `CLAUDE.md` files, **auto-loaded when
Cross-app contracts (WS-frame & provider-type parity, sentinels) and everything below stay here.
### Guidance resolution order
When multiple sources conflict: `CLAUDE.md` (repo root) → `BOOCHAT.md` / `BOOCODER.md` (per-surface) → per-app `CLAUDE.md` (auto-loaded by file context) → `data/AGENTS.md` (agent preamble beats per-agent body) → session `system_prompt` → user prompt. Last-encountered wins on samplers; refusals cascade downward (you cannot do what any layer forbids).
### Data flow for chat
1. User sends message → POST `/api/sessions/:id/messages` creates user + assistant (status=streaming) rows

View File

@@ -7,6 +7,8 @@ const ConfigSchema = z.object({
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.string().default('info'),
TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'),
PTY_IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(0),
PTY_ABSOLUTE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(0),
});
type Config = z.infer<typeof ConfigSchema>;

View File

@@ -14,12 +14,13 @@ interface SessionInfo {
id: string;
project_id: string;
project_path: string;
name: string | null;
}
export async function getSessionInfo(sessionId: string): Promise<SessionInfo | null> {
if (!pool) throw new Error('db pool not initialized');
const res = await pool.query<SessionInfo>(
`SELECT s.id, s.project_id, p.path AS project_path
`SELECT s.id, s.project_id, p.path AS project_path, s.name
FROM sessions s
JOIN projects p ON p.id = s.project_id
WHERE s.id = $1`,

View File

@@ -5,6 +5,7 @@ import { getPool, closeDb } from './db.js';
import { registerHealthRoutes } from './routes/health.js';
import { registerTerminalRoutes } from './routes/terminals.js';
import { registerSessionRoutes } from './routes/sessions.js';
import { registerSearchRoutes } from './routes/search.js';
import { registerWsAttachRoute } from './ws/attach.js';
async function main(): Promise<void> {
@@ -35,6 +36,7 @@ async function main(): Promise<void> {
registerHealthRoutes(app);
registerTerminalRoutes(app, config.TMUX_CONF_PATH);
registerSessionRoutes(app);
registerSearchRoutes(app, config.TMUX_CONF_PATH);
registerWsAttachRoute(app, config.TMUX_CONF_PATH);
const shutdown = async (signal: string) => {

View File

@@ -1,5 +1,6 @@
import { spawn } from 'node:child_process';
import type { FastifyBaseLogger } from 'fastify';
import * as registry from './registry.js';
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
@@ -162,3 +163,36 @@ export async function capturePane(
if (res.code !== 0) return '';
return res.stdout.replace(/(?:\r?\n)+$/, '');
}
/**
* Sweep the registry for expired sessions and kill the underlying tmux sessions.
* Logs each kill with the expiry reason (idle timeout vs absolute timeout).
* Returns the list of paneIds that were killed.
*/
export async function sweepExpired(
tmuxConfPath: string,
log: FastifyBaseLogger,
): Promise<string[]> {
const expired = registry.getTimedOutSessions();
const killed: string[] = [];
for (const meta of expired) {
const reason =
meta.idleExpiresAt &&
(!meta.absoluteExpiresAt || meta.idleExpiresAt.getTime() <= meta.absoluteExpiresAt.getTime())
? 'idle timeout'
: 'absolute timeout';
log.info({ paneId: meta.paneId, reason }, 'sweeping expired PTY session');
const sessionName = tmuxSessionName(meta.paneId);
try {
const ok = await killSession(tmuxConfPath, sessionName);
if (!ok) {
log.warn({ paneId: meta.paneId, sessionName }, 'killSession returned false during sweep');
}
} catch (err) {
log.warn({ paneId: meta.paneId, err }, 'killSession threw during sweep');
}
registry.unregister(meta.paneId);
killed.push(meta.paneId);
}
return killed;
}

View File

@@ -3,17 +3,30 @@ export interface SessionMeta {
sessionId: string;
projectPath: string;
title?: string;
description?: string;
parentAgent?: string;
createdAt: Date;
lastActivityAt: Date;
timeoutSeconds?: number;
idleExpiresAt?: Date;
absoluteExpiresAt?: Date;
}
const sessions = new Map<string, SessionMeta>();
export interface RegisterOpts {
timeoutSeconds?: number;
absoluteTimeoutSeconds?: number;
description?: string;
parentAgent?: string;
}
export function register(
sessionId: string,
paneId: string,
projectPath: string,
title?: string,
opts?: RegisterOpts,
): void {
const now = new Date();
const existing = sessions.get(paneId);
@@ -21,18 +34,42 @@ export function register(
existing.lastActivityAt = now;
return;
}
const idleExpiresAt = opts?.timeoutSeconds && opts.timeoutSeconds > 0
? new Date(now.getTime() + opts.timeoutSeconds * 1000)
: undefined;
const absoluteExpiresAt = opts?.absoluteTimeoutSeconds && opts.absoluteTimeoutSeconds > 0
? new Date(now.getTime() + opts.absoluteTimeoutSeconds * 1000)
: undefined;
sessions.set(paneId, {
paneId,
sessionId,
projectPath,
title,
description: opts?.description,
parentAgent: opts?.parentAgent,
createdAt: now,
lastActivityAt: now,
timeoutSeconds: opts?.timeoutSeconds,
idleExpiresAt,
absoluteExpiresAt,
});
}
export function unregister(paneId: string): void {
sessions.delete(paneId);
ringBuffers.delete(paneId);
}
/**
* Bump the lastActivityAt timestamp for a pane.
* Called on every PTY data write so the idle-timeout sweep knows when a session
* was last active.
*/
export function touchActivity(paneId: string): void {
const meta = sessions.get(paneId);
if (meta) {
meta.lastActivityAt = new Date();
}
}
export function list(): SessionMeta[] {
@@ -42,3 +79,162 @@ export function list(): SessionMeta[] {
export function get(paneId: string): SessionMeta | undefined {
return sessions.get(paneId);
}
// ── Pending metadata (POST /start → WS attach handoff) ──────────────────────
//
// The POST /start route stores optional description/parentAgent here; the WS
// attach handler consumes it when calling register(). This avoids coupling the
// HTTP route to the WS lifecycle while keeping the handoff single-process and
// ephemeral (no DB writes).
const pendingMetadata = new Map<string, { description?: string; parentAgent?: string }>();
export function setPendingMetadata(
paneId: string,
meta: { description?: string; parentAgent?: string },
): void {
pendingMetadata.set(paneId, meta);
}
export function consumePendingMetadata(
paneId: string,
): { description?: string; parentAgent?: string } | undefined {
const meta = pendingMetadata.get(paneId);
if (meta) pendingMetadata.delete(paneId);
return meta;
}
// ── Ring buffer for PTY output search ──────────────────────────────────────
export interface SearchMatch {
line: number;
content: string;
contextBefore: string[];
contextAfter: string[];
}
const ringBuffers = new Map<string, string[]>();
/**
* Append raw PTY data to the ring buffer for a given pane.
* Splits incoming data on newlines and pushes each line into the buffer,
* trimming to `maxLines` (default 5000) from the tail.
*/
export function appendOutput(
paneId: string,
data: string,
maxLines: number = 5000,
): void {
let buf = ringBuffers.get(paneId);
if (!buf) {
buf = [];
ringBuffers.set(paneId, buf);
}
// Split on newlines — each chunk may contain multiple complete lines and
// potentially a trailing partial line (which we store as-is; the next chunk
// will either complete it or be another partial).
const lines = data.split('\n');
// The first element of `lines` may be a continuation of the last partial
// line from the previous append. If the buffer is non-empty and the last
// stored entry is a partial (no trailing newline previously), glue them.
// We detect "partial" by checking whether `data` ended with '\n' — if it
// did, the last element after split is '' (empty) which we drop.
const endedWithNewline = data.endsWith('\n');
if (endedWithNewline) {
// The final empty-string element is discarded.
lines.pop();
}
if (buf.length > 0 && lines.length > 0) {
// Concatenate the last partial line in the buffer with the first split
// segment. This avoids splitting ANSI sequences or text across chunks.
buf[buf.length - 1] = (buf[buf.length - 1] ?? '') + (lines[0] ?? '');
lines.shift();
}
for (const line of lines) {
buf.push(line);
}
// Trim from head if over maxLines
if (buf.length > maxLines) {
buf = buf.slice(buf.length - maxLines);
ringBuffers.set(paneId, buf);
}
}
/**
* Search the ring buffer for a pane using a regex pattern.
* Returns matches with optional context lines before and after each match.
*/
export function searchRingBuffer(
paneId: string,
pattern: string,
opts?: { limit?: number; context?: number },
): SearchMatch[] {
const buf = ringBuffers.get(paneId);
if (!buf || buf.length === 0) return [];
const limit = opts?.limit ?? 50;
const context = opts?.context ?? 0;
let re: RegExp;
try {
re = new RegExp(pattern, 'u');
} catch {
return []; // invalid regex — caller should validate, but be defensive
}
const results: SearchMatch[] = [];
for (let i = 0; i < buf.length; i++) {
if (results.length >= limit) break;
if (re.test(buf[i]!)) {
const contextBefore: string[] = [];
const contextAfter: string[] = [];
for (let c = 1; c <= context; c++) {
const ci = i - c;
if (ci >= 0) contextBefore.unshift(buf[ci]!);
}
for (let c = 1; c <= context; c++) {
const ci = i + c;
if (ci < buf.length) contextAfter.push(buf[ci]!);
}
results.push({
line: i + 1, // 1-based line number for display
content: buf[i]!,
contextBefore,
contextAfter,
});
}
}
return results;
}
/**
* Remove the ring buffer for a pane. Called on session kill / pane close.
*/
export function clearBuffer(paneId: string): void {
ringBuffers.delete(paneId);
}
/**
* Return all sessions whose idle-expiry or absolute-expiry has passed.
* A session with no timeout configured is never included.
* Called by the sweepExpired interval in manager.ts.
*/
export function getTimedOutSessions(): SessionMeta[] {
const now = Date.now();
const result: SessionMeta[] = [];
for (const meta of sessions.values()) {
const idleHit = meta.idleExpiresAt && now >= meta.idleExpiresAt.getTime();
const absoluteHit = meta.absoluteExpiresAt && now >= meta.absoluteExpiresAt.getTime();
if (idleHit || absoluteHit) {
result.push(meta);
}
}
return result;
}

View File

@@ -0,0 +1,167 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { sanitizeId, tmuxSessionName, capturePane } from '../pty/manager.js';
import { searchRingBuffer, clearBuffer } from '../pty/registry.js';
const ParamsSchema = z.object({
sid: z.string(),
pid: z.string(),
});
const MAX_PATTERN_LENGTH = 200;
// Zod-refined string: reject empty and overly-long patterns to prevent ReDoS
const PatternQuerySchema = z
.string()
.min(1, 'pattern is required')
.max(MAX_PATTERN_LENGTH, `pattern must not exceed ${MAX_PATTERN_LENGTH} characters`);
const QuerySchema = z.object({
pattern: PatternQuerySchema,
limit: z.coerce.number().int().min(1).max(500).default(50),
context: z.coerce.number().int().min(0).max(50).default(0),
});
interface SearchMatch {
line: number;
content: string;
contextBefore: string[];
contextAfter: string[];
}
interface SearchResponse {
matches: SearchMatch[];
total: number;
truncated: boolean;
source: 'ring' | 'capture';
}
/**
* Search a captured pane buffer using a regex. This is the fallback path
* when the ring buffer doesn't have enough matches.
*/
function grepBuffer(
text: string,
pattern: string,
limit: number,
context: number,
): SearchMatch[] {
let re: RegExp;
try {
re = new RegExp(pattern, 'u');
} catch {
return [];
}
const lines = text.split('\n');
const results: SearchMatch[] = [];
for (let i = 0; i < lines.length; i++) {
if (results.length >= limit) break;
if (re.test(lines[i]!)) {
const contextBefore: string[] = [];
const contextAfter: string[] = [];
for (let c = 1; c <= context; c++) {
const ci = i - c;
if (ci >= 0) contextBefore.unshift(lines[ci]!);
}
for (let c = 1; c <= context; c++) {
const ci = i + c;
if (ci < lines.length) contextAfter.push(lines[ci]!);
}
results.push({
line: i + 1,
content: lines[i]!,
contextBefore,
contextAfter,
});
}
}
return results;
}
export function registerSearchRoutes(app: FastifyInstance, tmuxConfPath: string): void {
app.get<{
Params: { sid: string; pid: string };
Querystring: { pattern?: string; limit?: string; context?: string };
}>(
'/api/term/sessions/:sid/panes/:pid/search',
async (req, reply) => {
const p = ParamsSchema.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
const sid = sanitizeId(p.data.sid);
const pid = sanitizeId(p.data.pid);
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
const q = QuerySchema.safeParse(req.query);
if (!q.success) {
return reply.code(400).send({
error: 'bad_query',
details: q.error.flatten().fieldErrors,
});
}
const { pattern, limit, context } = q.data;
// ── Path 1: ring buffer search (fast, no tmux interaction) ──
const ringMatches = searchRingBuffer(pid, pattern, { limit, context });
if (ringMatches.length >= limit) {
return reply.code(200).send({
matches: ringMatches,
total: ringMatches.length,
truncated: ringMatches.length >= limit,
source: 'ring' as const,
});
}
// ── Path 2: capture-pane + grep fallback (10s timeout) ──
const sessionName = tmuxSessionName(pid);
let capture: string;
try {
capture = await withTimeout(
capturePane(tmuxConfPath, sessionName, 5000),
10_000,
);
} catch (err) {
req.log.warn({ err, pid }, 'capture-pane timed out or failed');
return reply.code(200).send({
matches: ringMatches,
total: ringMatches.length,
truncated: false,
source: 'ring' as const,
});
}
if (!capture) {
// tmux pane may no longer exist — return whatever ring had
return reply.code(200).send({
matches: ringMatches,
total: ringMatches.length,
truncated: false,
source: 'ring' as const,
});
}
const captureMatches = grepBuffer(capture, pattern, limit, context);
return reply.code(200).send({
matches: captureMatches,
total: captureMatches.length,
truncated: captureMatches.length >= limit,
source: 'capture' as const,
});
},
);
}
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), ms),
),
]);
}

View File

@@ -10,6 +10,8 @@ export function registerSessionRoutes(app: FastifyInstance): void {
sessionId: s.sessionId,
projectPath: s.projectPath,
title: s.title ?? null,
description: s.description ?? null,
parentAgent: s.parentAgent ?? null,
createdAt: s.createdAt.toISOString(),
lastActivityAt: s.lastActivityAt.toISOString(),
})),

View File

@@ -8,6 +8,7 @@ import {
killSession,
hasSession,
} from '../pty/manager.js';
import { setPendingMetadata } from '../pty/registry.js';
const ParamsSchema = z.object({ sid: z.string(), pid: z.string() });
// v1.10.8c: optional cols/rows on /start so the per-pane tmux session is
@@ -17,6 +18,8 @@ const StartBodySchema = z
.object({
cols: z.coerce.number().int().min(1).max(2000).optional(),
rows: z.coerce.number().int().min(1).max(2000).optional(),
description: z.string().max(500).optional(),
parentAgent: z.string().max(100).optional(),
})
.partial()
.optional();
@@ -29,7 +32,7 @@ export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: strin
// errors as HTTP responses (vs WS 1011 close codes).
app.post<{
Params: { sid: string; pid: string };
Body: { cols?: number; rows?: number } | undefined;
Body: { cols?: number; rows?: number; description?: string; parentAgent?: string } | undefined;
}>(
'/api/term/sessions/:sid/panes/:pid/start',
async (req, reply) => {
@@ -43,6 +46,14 @@ export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: strin
const cols = b.success ? b.data?.cols : undefined;
const rows = b.success ? b.data?.rows : undefined;
// Store optional metadata for the WS attach handler to consume
if (b.success && b.data) {
const { description, parentAgent } = b.data;
if (description || parentAgent) {
setPendingMetadata(pid, { description, parentAgent });
}
}
const session = await getSessionInfo(sid);
if (!session) return reply.code(404).send({ error: 'unknown_session' });

View File

@@ -9,9 +9,14 @@ import {
} from '../pty/manager.js';
import { attachPty } from '../pty/pty.js';
import { getUser } from '../auth.js';
import { register, unregister } from '../pty/registry.js';
import { register, unregister, appendOutput, touchActivity, consumePendingMetadata } from '../pty/registry.js';
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
export function registerWsAttachRoute(
app: FastifyInstance,
tmuxConfPath: string,
idleTimeoutSeconds?: number,
absoluteTimeoutSeconds?: number,
): void {
app.get<{
Params: { sid: string; pid: string };
Querystring: { cols?: string; rows?: string };
@@ -58,7 +63,25 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
return;
}
register(sid, pid, session.project_path);
const pendingMeta = consumePendingMetadata(pid);
const regOpts: {
timeoutSeconds?: number;
absoluteTimeoutSeconds?: number;
description?: string;
parentAgent?: string;
} = {};
if (idleTimeoutSeconds && idleTimeoutSeconds > 0) regOpts.timeoutSeconds = idleTimeoutSeconds;
if (absoluteTimeoutSeconds && absoluteTimeoutSeconds > 0) regOpts.absoluteTimeoutSeconds = absoluteTimeoutSeconds;
if (pendingMeta) {
if (pendingMeta.description) regOpts.description = pendingMeta.description;
if (pendingMeta.parentAgent) regOpts.parentAgent = pendingMeta.parentAgent;
}
const hasRegOpts =
regOpts.timeoutSeconds !== undefined ||
regOpts.absoluteTimeoutSeconds !== undefined ||
regOpts.description !== undefined ||
regOpts.parentAgent !== undefined;
register(sid, pid, session.project_path, session.name ?? undefined, hasRegOpts ? regOpts : undefined);
let handle: IPty;
try {
@@ -106,6 +129,10 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
} catch (err) {
req.log.warn({ err }, 'ws send failed');
}
// Feed the ring buffer for pattern-based search
appendOutput(pid, data);
// Bump activity timestamp for idle-timeout tracking
touchActivity(pid);
};
handle.onData(onData);

View File

@@ -36,12 +36,44 @@ export interface StepContext {
* Falls back to a default in render functions when absent.
*/
readonly model?: string;
/**
* Inter-agent messaging within the same flow run.
* `publish` broadcasts on the user WS channel and delivers to in-process
* subscribers via the broker. `subscribe` registers a handler scoped to the
* run and channel; returns an unsubscribe function.
* Undefined in contexts without a run id (manifest-only contexts).
*/
readonly messaging?: {
publish(channel: string, message: unknown): void;
subscribe(channel: string, handler: (msg: unknown) => void): () => void;
};
}
export type StepKind = 'agent' | 'code' | 'approval';
export type StepKind = 'agent' | 'code' | 'approval' | 'switch' | 'do_while';
/**
* One branch of a SWITCH step. The first case whose condition evaluates to true
* is selected; all other branches' stepIds are excluded from execution.
*/
export interface SwitchCase {
/** Human-readable label for this branch (reported in switch output). */
label: string;
/** Pure guard — called with the current step context to decide this branch. */
condition: (ctx: StepContext) => boolean;
/** stepIds belonging to this branch. */
stepIds: string[];
}
export type TriggerRule = 'all_success' | 'one_success' | 'all_done';
/** Possible statuses for a flow step (persisted in flow_steps.status). */
export type StepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'timed_out';
/** Retry policy for a step that times out. */
export interface RetryConfig {
maxRetries: number;
}
export interface Step {
/** unique id within the flow; other steps depend on it by this id */
id: string;
@@ -55,10 +87,25 @@ export interface Step {
/**
* For kind:'agent', returns the worker PROMPT (task + any prior outputs).
* For kind:'code', returns the step RESULT directly (the fold/transform).
* For kind:'switch', unused (the runner evaluates cases internally).
*/
run: (ctx: StepContext) => string | Promise<string>;
/** optional guard — when it returns false the step is skipped (e.g. no repo) */
when?: (ctx: StepContext) => boolean;
/** max retries on timeout (0 or unset = no retry) */
maxRetries?: number;
/** batch group id; steps sharing the same batch are gated by batchConfig.maxConcurrent */
batch?: string;
/** for kind:'switch' — ordered list of branches evaluated in declaration order */
cases?: SwitchCase[];
/** for kind:'switch' — fallback step ids when no case matches */
defaultBranch?: string[];
/** for kind:'do_while' — step IDs in the loop body (re-evaluated each iteration) */
loopBody?: string[];
/** for kind:'do_while' — guard evaluated each iteration; terminates when false */
loopCondition?: (ctx: StepContext) => boolean;
/** for kind:'do_while' — cap on total iterations (default 100) */
loopMaxIterations?: number;
}
export interface Flow {
@@ -69,6 +116,8 @@ export interface Flow {
render: (ctx: StepContext) => string;
/** optional output filename for the artifact, derived from input */
output?: (ctx: StepContext) => string;
/** batch parallelism control — gates concurrent dispatch of steps sharing the same batch id */
batchConfig?: { maxConcurrent: number; timeoutMs?: number; joinRule?: TriggerRule };
}
export interface RunResult {

View File

@@ -52,6 +52,9 @@ const ConfigSchema = z.object({
ORPHAN_WORKTREE_GRACE_MS: z.coerce.number().int().positive().default(3_600_000),
DEEPSEEK_API_KEY: z.string().optional(),
DEEPSEEK_BASE_URL: z.string().url().default('https://api.deepseek.com'),
// v2.9.x: flow step timeout (default 5 min). When a 'running' step exceeds
// this duration, it is marked 'timed_out' and may be retried.
FLOW_STEP_TIMEOUT_MS: z.coerce.number().int().positive().default(300_000),
});
export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -266,7 +266,7 @@ CREATE INDEX IF NOT EXISTS claude_session_entries_key_idx ON claude_session_entr
-- replaces it with the three-value list).
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_backend_chk;
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', 'paseo'));
-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
-- new_task tool, MCP server) fires pg_notify('tasks_new') in the same
@@ -340,11 +340,12 @@ CREATE INDEX IF NOT EXISTS flow_steps_task_id_idx ON flow_steps(task_id);
-- edits above are no-ops on the existing DB (CREATE TABLE IF NOT EXISTS skips an
-- existing table) — widen via the repo's DROP-IF-EXISTS → guarded-ADD discipline.
-- Pure ADD of a new allowed value, so no row UPDATE is needed (no value renamed).
-- v2.9.x: widen status CHECKs to include 'timed_out' for Task State Machine.
ALTER TABLE flow_runs DROP CONSTRAINT IF EXISTS flow_runs_status_chk;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'flow_runs_status_chk') THEN
ALTER TABLE flow_runs ADD CONSTRAINT flow_runs_status_chk
CHECK (status IN ('running', 'completed', 'failed', 'cancelled'));
CHECK (status IN ('running', 'completed', 'failed', 'cancelled', 'timed_out'));
END IF;
END $$;
@@ -352,10 +353,14 @@ ALTER TABLE flow_steps DROP CONSTRAINT IF EXISTS flow_steps_status_chk;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'flow_steps_status_chk') THEN
ALTER TABLE flow_steps ADD CONSTRAINT flow_steps_status_chk
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped', 'cancelled'));
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped', 'cancelled', 'timed_out'));
END IF;
END $$;
-- Task State Machine: retry columns for flow_steps.
ALTER TABLE flow_steps ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE flow_steps ADD COLUMN IF NOT EXISTS max_retries INTEGER;
-- 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.

View File

@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { findConflicts } from '../collision-detector.js';
import type { ConflictEntry, ConflictIndexData } from '../collision-detector.js';
function entry(worktreeId: string, agent: string, start?: number, end?: number): ConflictEntry {
return {
worktreeId,
agent,
lineRange: start !== undefined && end !== undefined ? { start, end } : undefined,
status: 'pending' as const,
timestamp: 1000,
};
}
function index(entries: Array<[string, ConflictEntry[]]>): ConflictIndexData {
return new Map(entries.map(([path, es]) => [path, new Set(es)] as const));
}
describe('findConflicts', () => {
it('returns empty when no files in index', () => {
const result = findConflicts(['src/a.ts'], 'wt-1', new Map(), new Map());
expect(result).toEqual([]);
});
it('returns empty when only own worktree has the file', () => {
const idx = index([['src/a.ts', [entry('wt-1', 'agent-a', 1, 10)]]]);
const result = findConflicts(['src/a.ts'], 'wt-1', new Map(), idx);
expect(result).toEqual([]);
});
it('detects same_file conflict from another worktree', () => {
const idx = index([['src/a.ts', [entry('wt-2', 'agent-b', 5, 15)]]]);
const result = findConflicts(['src/a.ts'], 'wt-1', new Map(), idx);
expect(result).toHaveLength(1);
expect(result[0]!.filePath).toBe('src/a.ts');
expect(result[0]!.worktrees).toEqual(['wt-2']);
expect(result[0]!.agents).toEqual(['agent-b']);
});
it('reports same_line severity when ranges overlap', () => {
const idx = index([['src/a.ts', [entry('wt-2', 'agent-b', 10, 20)]]]);
const ranges = new Map([['src/a.ts', { start: 15, end: 25 }]]);
const result = findConflicts(['src/a.ts'], 'wt-1', ranges, idx);
expect(result[0]!.severity).toBe('same_line');
});
it('reports different_area severity when ranges are far apart', () => {
const idx = index([['src/a.ts', [entry('wt-2', 'agent-b', 1, 10)]]]);
const ranges = new Map([['src/a.ts', { start: 100, end: 200 }]]);
const result = findConflicts(['src/a.ts'], 'wt-1', ranges, idx);
expect(result[0]!.severity).toBe('different_area');
});
it('reports adjacent_line severity when ranges are 3 lines apart', () => {
const idx = index([['src/a.ts', [entry('wt-2', 'agent-b', 10, 15)]]]);
const ranges = new Map([['src/a.ts', { start: 19, end: 25 }]]);
const result = findConflicts(['src/a.ts'], 'wt-1', ranges, idx);
expect(result[0]!.severity).toBe('adjacent_line');
});
it('returns entry for each conflicting file', () => {
const idx = index([
['src/a.ts', [entry('wt-2', 'agent-b', 1, 10)]],
['src/b.ts', [entry('wt-3', 'agent-c', 1, 10)]],
]);
const result = findConflicts(['src/a.ts', 'src/b.ts', 'src/c.ts'], 'wt-1', new Map(), idx);
expect(result).toHaveLength(2);
expect(result.map((v) => v.filePath).sort()).toEqual(['src/a.ts', 'src/b.ts']);
});
it('excludes entries from the same worktree', () => {
const idx = index([['src/a.ts', [entry('wt-1', 'agent-a', 1, 10), entry('wt-2', 'agent-b', 5, 15)]]]);
const result = findConflicts(['src/a.ts'], 'wt-1', new Map(), idx);
expect(result).toHaveLength(1);
expect(result[0]!.worktrees).toEqual(['wt-2']);
});
it('deduplicates worktree IDs in verdict', () => {
const idx = index([['src/a.ts', [entry('wt-2', 'agent-b', 1, 5), entry('wt-2', 'agent-b', 10, 15)]]]);
const result = findConflicts(['src/a.ts'], 'wt-1', new Map(), idx);
expect(result[0]!.worktrees).toEqual(['wt-2']);
});
it('reports same_line when no lineRange on either side (create/delete conflates)', () => {
const idx = index([['src/a.ts', [entry('wt-2', 'agent-b')]]]);
const result = findConflicts(['src/a.ts'], 'wt-1', new Map(), idx);
expect(result).toHaveLength(1);
expect(result[0]!.severity).toBe('different_area');
});
});

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ConflictIndex } from '../conflict-index.js';
describe('ConflictIndex', () => {
let idx: ConflictIndex;
beforeEach(() => {
idx = new ConflictIndex();
});
describe('registerChange', () => {
it('adds an entry for a file path', () => {
idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 });
const entries = idx.getEntriesFor('src/a.ts');
expect(entries.size).toBe(1);
const entry = [...entries][0]!;
expect(entry.worktreeId).toBe('wt-1');
expect(entry.agent).toBe('agent-a');
expect(entry.lineRange).toEqual({ start: 1, end: 10 });
expect(entry.status).toBe('pending');
expect(entry.timestamp).toBeGreaterThan(0);
});
it('supports multiple entries for the same file path', () => {
idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 });
idx.registerChange('src/a.ts', 'wt-2', 'agent-b', { start: 20, end: 30 });
expect(idx.getEntriesFor('src/a.ts').size).toBe(2);
});
it('allows a worktree to have multiple entries (several edits to same file)', () => {
idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 });
idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 20, end: 30 });
// Duplicate entries with same fields — the Set dedupes by ref,
// so a second identical call is still a distinct object (allowed).
expect(idx.getEntriesFor('src/a.ts').size).toBe(2);
});
it('separates files into distinct keys', () => {
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
idx.registerChange('src/b.ts', 'wt-2', 'agent-b');
expect(idx.getEntriesFor('src/a.ts').size).toBe(1);
expect(idx.getEntriesFor('src/b.ts').size).toBe(1);
});
});
describe('removeWorktree', () => {
it('removes all entries for a given worktree', () => {
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
idx.registerChange('src/a.ts', 'wt-2', 'agent-b');
idx.registerChange('src/b.ts', 'wt-1', 'agent-a');
idx.removeWorktree('wt-1');
expect(idx.getEntriesFor('src/a.ts').size).toBe(1);
expect([...idx.getEntriesFor('src/a.ts')][0]!.worktreeId).toBe('wt-2');
expect(idx.getEntriesFor('src/b.ts').size).toBe(0);
});
it('is a no-op when worktree has no entries', () => {
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
idx.removeWorktree('wt-ghost');
expect(idx.getEntriesFor('src/a.ts').size).toBe(1);
});
it('cleans up file key when last entry is removed', () => {
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
idx.removeWorktree('wt-1');
// After removal the key should be gone
expect(idx.snapshot().has('src/a.ts')).toBe(false);
});
});
describe('sweepStale', () => {
it('removes entries older than maxAgeMs', async () => {
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
idx.registerChange('src/b.ts', 'wt-2', 'agent-b');
// Wait a tick so timestamps diverge
await new Promise((r) => setTimeout(r, 10));
idx.registerChange('src/c.ts', 'wt-3', 'agent-c');
const removed = idx.sweepStale(5); // 5ms cutoff — entries from before the await are stale
expect(removed).toBeGreaterThanOrEqual(1);
});
it('removes file key when all entries swept', async () => {
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
// Wait so timestamp is definitely older than cutoff
await new Promise((r) => setTimeout(r, 10));
const removed = idx.sweepStale(5);
expect(removed).toBe(1);
expect(idx.snapshot().has('src/a.ts')).toBe(false);
});
it('returns 0 when no entries are stale', () => {
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
const removed = idx.sweepStale(86_400_000); // 24h
expect(removed).toBe(0);
});
});
describe('getConflictsFor', () => {
it('returns conflicts between worktrees', () => {
idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 });
idx.registerChange('src/a.ts', 'wt-2', 'agent-b', { start: 5, end: 15 });
const conflicts = idx.getConflictsFor('src/a.ts');
expect(conflicts).toHaveLength(1);
expect(conflicts[0]!.filePath).toBe('src/a.ts');
// getConflictsFor doesn't know the caller's line range,
// so severity defaults to 'different_area'
expect(conflicts[0]!.severity).toBe('different_area');
});
it('returns empty for files with only one worktree', () => {
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
expect(idx.getConflictsFor('src/a.ts')).toEqual([]);
});
it('returns empty for files not in index', () => {
expect(idx.getConflictsFor('src/never-touched.ts')).toEqual([]);
});
});
describe('query', () => {
it('delegates to findConflicts with proper data', () => {
idx.registerChange('src/a.ts', 'wt-2', 'agent-b', { start: 5, end: 15 });
const ranges = new Map([['src/a.ts', { start: 10, end: 20 }]]);
const result = idx.query(['src/a.ts'], 'wt-1', ranges);
expect(result).toHaveLength(1);
expect(result[0]!.severity).toBe('same_line');
});
it('returns empty when no conflicts', () => {
idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 });
const result = idx.query(['src/a.ts'], 'wt-1', new Map());
expect(result).toEqual([]);
});
});
describe('snapshot', () => {
it('returns a copy of the internal map', () => {
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
const snap = idx.snapshot();
expect(snap.has('src/a.ts')).toBe(true);
// Mutating the snapshot doesn't affect the original
idx.removeWorktree('wt-1');
expect(snap.has('src/a.ts')).toBe(true);
});
});
});

View File

@@ -1,16 +1,20 @@
import { describe, it, expect } from 'vitest';
import type { Flow, Step, StepContext } from '../../conductor/types.js';
import {
buildBatchState,
getReadyInBatch,
manifestSteps,
readySteps,
partitionReady,
readySteps,
isRunComplete,
isStuck,
reconcileResumeStep,
reconcileRun,
resolveSwitch,
shouldFailOnMissingAgent,
type SchedulerState,
} from '../flow-runner-decisions.js';
import type { StepContext } from '../../conductor/types.js';
/**
* The DB-driven flow-runner replaces the Phase-1 in-memory wave scheduler
@@ -52,6 +56,8 @@ const emptyState = (over: Partial<SchedulerState> = {}): SchedulerState => ({
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
...over,
});
@@ -237,6 +243,442 @@ describe('isRunComplete / isStuck', () => {
});
});
// ─── SWITCH branching (v2.9) ─────────────────────────────────────────────────
describe('resolveSwitch', () => {
const baseCtx: StepContext = { input: { question: 'q', band: 'small' }, results: {} };
it('selects the first matching case and excludes other branches', () => {
const step: Step = {
id: 'router',
kind: 'switch',
run: () => '',
cases: [
{ label: 'a', condition: () => false, stepIds: ['a1', 'a2'] },
{ label: 'b', condition: () => true, stepIds: ['b1', 'b2'] },
{ label: 'c', condition: () => true, stepIds: ['c1', 'c2'] },
],
};
const result = resolveSwitch(step, baseCtx);
expect(result.chosenCase).toBe('b');
expect(result.excluded).toEqual(['a1', 'a2', 'c1', 'c2']);
});
it('falls back to defaultBranch when no case matches', () => {
const step: Step = {
id: 'router',
kind: 'switch',
run: () => '',
cases: [
{ label: 'x', condition: () => false, stepIds: ['x1'] },
{ label: 'y', condition: () => false, stepIds: ['y1'] },
],
defaultBranch: ['z1', 'z2'],
};
const result = resolveSwitch(step, baseCtx);
expect(result.chosenCase).toBeNull();
// Only case branch steps are excluded; default steps are not.
expect(result.excluded).toEqual(['x1', 'y1']);
});
it('excludes all branch steps when no case matches and no default', () => {
const step: Step = {
id: 'router',
kind: 'switch',
run: () => '',
cases: [
{ label: 'p', condition: () => false, stepIds: ['p1'] },
{ label: 'q', condition: () => false, stepIds: ['q1', 'q2'] },
],
};
const result = resolveSwitch(step, baseCtx);
expect(result.chosenCase).toBeNull();
expect(result.excluded).toEqual(['p1', 'q1', 'q2']);
});
it('excludes defaultBranch when a case matched', () => {
const step: Step = {
id: 'router',
kind: 'switch',
run: () => '',
cases: [
{ label: 'hit', condition: () => true, stepIds: ['h1'] },
{ label: 'miss', condition: () => false, stepIds: ['m1'] },
],
defaultBranch: ['d1'],
};
const result = resolveSwitch(step, baseCtx);
expect(result.chosenCase).toBe('hit');
expect(result.excluded).toEqual(['m1', 'd1']);
});
it('returns empty excluded for a degenerate switch with no cases and no default', () => {
const step: Step = {
id: 'noop',
kind: 'switch',
run: () => '',
};
const result = resolveSwitch(step, baseCtx);
expect(result.chosenCase).toBeNull();
expect(result.excluded).toEqual([]);
});
it('uses ctx.results in condition evaluation', () => {
const step: Step = {
id: 'router',
kind: 'switch',
run: () => '',
cases: [
{ label: 'has', condition: (ctx) => ctx.results['prev'] === 'yes', stepIds: ['yes-branch'] },
{ label: 'no', condition: () => true, stepIds: ['no-branch'] },
],
};
const ctxWithResult: StepContext = { input: { question: 'q', band: 'small' }, results: { prev: 'yes' } };
const result = resolveSwitch(step, ctxWithResult);
expect(result.chosenCase).toBe('has');
expect(result.excluded).toEqual(['no-branch']);
});
});
describe('readySteps with switch-excluded steps', () => {
// Flow: switch router → branch-a/branch-b → fold
function switchFlow(): Flow {
const steps: Step[] = [
{
id: 'switch', kind: 'switch', run: () => '',
cases: [
{ label: 'a', condition: () => true, stepIds: ['branch-a'] },
{ label: 'b', condition: () => false, stepIds: ['branch-b'] },
],
},
{ id: 'branch-a', kind: 'agent', agent: 'x', deps: ['switch'], run: () => 'p' },
{ id: 'branch-b', kind: 'agent', agent: 'y', deps: ['switch'], run: () => 'q' },
{ id: 'fold', kind: 'code', deps: ['branch-a', 'branch-b'], run: () => 'r' },
];
return { name: 'switch-demo', description: '', steps, render: () => '' };
}
it('excludes non-selected branch steps and treats them as satisfied deps', () => {
const flow = switchFlow();
// switch completed, branch-b excluded by switch (branch-a selected)
const switchResult = new Map<string, { chosenCase: string | null; excluded: Set<string> }>([
['switch', { chosenCase: 'a', excluded: new Set(['branch-b']) }],
]);
const state: SchedulerState = {
done: new Set(['switch']),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: switchResult,
};
const ready = readySteps(flow, state).map((s) => s.id);
// branch-a is ready (dep switch is done), branch-b is excluded
expect(ready).toContain('branch-a');
expect(ready).not.toContain('branch-b');
});
it('fold unblocks once selected branch completes (excluded branch satisfied)', () => {
const flow = switchFlow();
const switchResult = new Map<string, { chosenCase: string | null; excluded: Set<string> }>([
['switch', { chosenCase: 'a', excluded: new Set(['branch-b']) }],
]);
const state: SchedulerState = {
done: new Set(['switch', 'branch-a']),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: switchResult,
};
const ready = readySteps(flow, state).map((s) => s.id);
// fold's deps: branch-a done, branch-b excluded (via switch) → satisfied
expect(ready).toContain('fold');
});
it('fold stays blocked until selected branch completes, even with excluded dep', () => {
const flow = switchFlow();
const switchResult = new Map<string, { chosenCase: string | null; excluded: Set<string> }>([
['switch', { chosenCase: 'a', excluded: new Set(['branch-b']) }],
]);
const state: SchedulerState = {
done: new Set(['switch']),
skipped: new Set(),
inFlight: new Set(['branch-a']),
excluded: new Set(),
timedOut: new Set(),
switchResults: switchResult,
};
const ready = readySteps(flow, state).map((s) => s.id);
// branch-a in flight, branch-b excluded — only branch-a offered
expect(ready).not.toContain('fold');
});
it('isRunComplete returns true when switch-excluded steps are the only unsettled', () => {
const flow = switchFlow();
// All non-excluded steps done; branch-b is excluded via switch
const switchResult = new Map<string, { chosenCase: string | null; excluded: Set<string> }>([
['switch', { chosenCase: 'a', excluded: new Set(['branch-b']) }],
]);
const state: SchedulerState = {
done: new Set(['switch', 'branch-a', 'fold']),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: switchResult,
};
expect(isRunComplete(flow, state)).toBe(true);
expect(isStuck(flow, state)).toBe(false);
});
it('combines static excluded with switch-excluded', () => {
const flow = switchFlow();
// band gating excludes branch-b at launch, AND switch also excludes it
const switchResult = new Map<string, { chosenCase: string | null; excluded: Set<string> }>([
['switch', { chosenCase: 'a', excluded: new Set(['branch-b']) }],
]);
const state: SchedulerState = {
done: new Set(['switch', 'branch-a']),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(['branch-b']),
timedOut: new Set(),
switchResults: switchResult,
};
// branch-b excluded both ways; fold sees branch-a done, branch-b excluded
const ready = readySteps(flow, state).map((s) => s.id);
expect(ready).toContain('fold');
});
});
// ─── Batch parallelism (v2.8.22) ─────────────────────────────────────────────
describe('buildBatchState', () => {
it('returns empty map when flow has no batchConfig', () => {
const flow: Flow = {
name: 'no-batch',
description: '',
steps: [
{ id: 'a', kind: 'agent', agent: 'x', run: () => 'p' },
{ id: 'b', kind: 'code', deps: ['a'], run: () => 'r' },
],
render: () => '',
};
const bs = buildBatchState(flow, new Set());
expect(bs.size).toBe(0);
});
it('maps each batch group to its running set and config', () => {
const flow: Flow = {
name: 'batched',
description: '',
steps: [
{ id: 'a1', kind: 'agent', agent: 'x', batch: 'review', run: () => 'p' },
{ id: 'a2', kind: 'agent', agent: 'y', batch: 'review', run: () => 'q' },
{ id: 'b1', kind: 'agent', agent: 'z', batch: 'check', run: () => 'r' },
{ id: 'fold', kind: 'code', deps: ['a1', 'a2', 'b1'], run: () => 's' },
],
render: () => '',
batchConfig: { maxConcurrent: 2 },
};
// a1 is in flight → review batch has 1 running, check has 0.
const bs = buildBatchState(flow, new Set(['a1']));
expect(bs.size).toBe(2);
const review = bs.get('review');
expect(review).toBeDefined();
expect([...review!.running]).toEqual(['a1']);
expect(review!.maxConcurrent).toBe(2);
expect(review!.joinRule).toBe('all_success');
const check = bs.get('check');
expect(check).toBeDefined();
expect(check!.running.size).toBe(0);
expect(check!.maxConcurrent).toBe(2);
});
it('uses joinRule from batchConfig when provided', () => {
const flow: Flow = {
name: 'join',
description: '',
steps: [
{ id: 'x', kind: 'agent', agent: 'a', batch: 'g1', run: () => 'p' },
],
render: () => '',
batchConfig: { maxConcurrent: 1, joinRule: 'one_success' },
};
const bs = buildBatchState(flow, new Set());
expect(bs.get('g1')!.joinRule).toBe('one_success');
});
it('ignores steps without a batch field', () => {
const flow: Flow = {
name: 'mixed',
description: '',
steps: [
{ id: 'a', kind: 'agent', agent: 'x', run: () => 'p' },
{ id: 'b', kind: 'agent', agent: 'y', batch: 'g1', run: () => 'q' },
],
render: () => '',
batchConfig: { maxConcurrent: 3 },
};
const bs = buildBatchState(flow, new Set(['a', 'b']));
// a is inFlight but has no batch — it does not create an entry
expect(bs.size).toBe(1);
expect(bs.has('g1')).toBe(true);
expect(bs.get('g1')!.running.has('b')).toBe(true);
// a is not in any batch entry
for (const entry of bs.values()) {
expect(entry.running.has('a')).toBe(false);
}
});
});
describe('getReadyInBatch', () => {
function makeBatchState(
overrides?: Map<string, { running: Set<string>; maxConcurrent: number; joinRule: TriggerRule }>,
): Map<string, { running: Set<string>; maxConcurrent: number; joinRule: TriggerRule }> {
return overrides ?? new Map();
}
it('passes all steps through when batchState is empty', () => {
const steps: Step[] = [
{ id: 'a', kind: 'agent', agent: 'x', run: () => 'p' },
{ id: 'b', kind: 'agent', agent: 'y', batch: 'g1', run: () => 'q' },
];
const state: SchedulerState = {
done: new Set(),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
batchState: makeBatchState(),
};
const result = getReadyInBatch(steps, state, {} as Flow);
expect(result.map((s) => s.id)).toEqual(['a', 'b']);
});
it('passes non-batched steps through regardless of batch capacity', () => {
const batchState = new Map();
batchState.set('g1', { running: new Set(['a']), maxConcurrent: 1, joinRule: 'all_success' });
const steps: Step[] = [
{ id: 'nobatch', kind: 'agent', agent: 'z', run: () => 'r' },
{ id: 'batched', kind: 'agent', agent: 'x', batch: 'g1', run: () => 'p' },
];
const state: SchedulerState = {
done: new Set(),
skipped: new Set(),
inFlight: new Set(['a']),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
batchState,
};
const result = getReadyInBatch(steps, state, {} as Flow);
// nobatch passes, batched is at maxConcurrent=1 with a already running → blocked
expect(result.map((s) => s.id)).toEqual(['nobatch']);
});
it('allows batch steps up to maxConcurrent', () => {
const batchState = new Map();
batchState.set('g1', { running: new Set(), maxConcurrent: 2, joinRule: 'all_success' });
const steps: Step[] = [
{ id: 's1', kind: 'agent', agent: 'x', batch: 'g1', run: () => 'p' },
{ id: 's2', kind: 'agent', agent: 'y', batch: 'g1', run: () => 'q' },
{ id: 's3', kind: 'agent', agent: 'z', batch: 'g1', run: () => 'r' },
];
const state: SchedulerState = {
done: new Set(),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
batchState,
};
// All 0 running, maxConcurrent=2 → all 3 pass through (readySteps would return them,
// but the flow-runner dispatches them one-by-one in the agent dispatch loop; getReadyInBatch
// is called each tick to allow up to maxConcurrent. Since batch is empty on this tick,
// all are allowed — the runner's dispatch loop will put 2 in flight, then next tick blocks.)
const result = getReadyInBatch(steps, state, {} as Flow);
expect(result.map((s) => s.id)).toEqual(['s1', 's2', 's3']);
});
it('blocks batch steps when at capacity', () => {
const batchState = new Map();
batchState.set('g1', { running: new Set(['a', 'b']), maxConcurrent: 2, joinRule: 'all_success' });
const steps: Step[] = [
{ id: 'c', kind: 'agent', agent: 'x', batch: 'g1', run: () => 'p' },
{ id: 'd', kind: 'agent', agent: 'y', batch: 'g1', run: () => 'q' },
];
const state: SchedulerState = {
done: new Set(),
skipped: new Set(),
inFlight: new Set(['a', 'b']),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
batchState,
};
// Both batches at capacity → everything filtered out
expect(getReadyInBatch(steps, state, {} as Flow)).toEqual([]);
});
it('handles multiple independent batch groups', () => {
const batchState = new Map();
batchState.set('g1', { running: new Set(['a']), maxConcurrent: 1, joinRule: 'all_success' });
batchState.set('g2', { running: new Set(), maxConcurrent: 5, joinRule: 'all_success' });
const steps: Step[] = [
{ id: 'b', kind: 'agent', agent: 'x', batch: 'g1', run: () => 'p' }, // g1 at capacity → blocked
{ id: 'c', kind: 'agent', agent: 'y', batch: 'g2', run: () => 'q' }, // g2 has room → passes
{ id: 'd', kind: 'agent', agent: 'z', batch: 'g2', run: () => 'r' }, // g2 has room → passes
];
const state: SchedulerState = {
done: new Set(),
skipped: new Set(),
inFlight: new Set(['a']),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
batchState,
};
expect(getReadyInBatch(steps, state, {} as Flow).map((s) => s.id)).toEqual(['c', 'd']);
});
it('lets a step pass when its batch group is known but has no running steps yet', () => {
const batchState = new Map();
batchState.set('g1', { running: new Set(), maxConcurrent: 2, joinRule: 'all_success' });
const steps: Step[] = [
{ id: 'first', kind: 'agent', agent: 'x', batch: 'g1', run: () => 'p' },
];
const state: SchedulerState = {
done: new Set(),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
batchState,
};
expect(getReadyInBatch(steps, state, {} as Flow).map((s) => s.id)).toEqual(['first']);
});
it('handles empty step list gracefully', () => {
const state: SchedulerState = {
done: new Set(),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
batchState: makeBatchState(),
};
expect(getReadyInBatch([], state, {} as Flow)).toEqual([]);
});
});
// ─── Resume reconciliation (D-9) ─────────────────────────────────────────────
describe('reconcileResumeStep', () => {

View File

@@ -0,0 +1,195 @@
import { describe, it, expect, vi } from 'vitest';
import { PaseoClient, PaseoClientError } from '../paseo-client.js';
/**
* Create a PaseoClient whose runCli method is replaced with a mock.
* The mock is returned as the second tuple element so tests can
* control and inspect it directly.
*/
function makeClient(config?: { paseoBin?: string; cliHost?: string }): {
client: PaseoClient;
mockRunCli: ReturnType<typeof vi.fn>;
} {
const client = new PaseoClient(config);
const mockRunCli = vi.fn();
(client as any).runCli = mockRunCli;
return { client, mockRunCli };
}
describe('PaseoClient', () => {
describe('listAgents', () => {
it('returns parsed agent list from paseo ls --json', async () => {
const agents = [
{ id: 'abc-123', shortId: 'abc', name: 'Agent 1', provider: 'opencode', status: 'running' },
{ id: 'def-456', shortId: 'def', name: 'Agent 2', provider: 'claude', status: 'idle' },
];
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue(JSON.stringify(agents));
const result = await client.listAgents();
expect(mockRunCli).toHaveBeenCalledWith(['ls', '--json']);
expect(result).toEqual(agents);
});
it('throws PaseoClientError on non-JSON output', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue('not json');
await expect(client.listAgents()).rejects.toThrow(PaseoClientError);
await expect(client.listAgents()).rejects.toThrow(/invalid JSON/);
});
it('propagates runCli rejection as-is', async () => {
const { client, mockRunCli } = makeClient();
const err = new PaseoClientError('ls failed: connection refused', 'ls', 1, 'connection refused');
mockRunCli.mockRejectedValue(err);
await expect(client.listAgents()).rejects.toThrow(PaseoClientError);
await expect(client.listAgents()).rejects.toThrow(/ls failed/);
});
});
describe('getAgentStatus', () => {
it('returns parsed agent detail from paseo inspect --json', async () => {
const detail = {
Id: 'abc-123', Name: 'Agent 1', Provider: 'opencode',
Status: 'idle', Archived: false,
CreatedAt: '2026-01-01T00:00:00Z', UpdatedAt: '2026-01-01T01:00:00Z',
};
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue(JSON.stringify(detail));
const result = await client.getAgentStatus('abc-123');
expect(mockRunCli).toHaveBeenCalledWith(['inspect', '--json', 'abc-123']);
expect(result.Id).toBe('abc-123');
expect(result.Status).toBe('idle');
});
});
describe('health', () => {
it('returns ok when paseo ls succeeds', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue('[]');
const result = await client.health();
expect(result).toEqual({ status: 'ok' });
});
it('returns error when runCli throws', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockRejectedValue(new Error('connection refused'));
const result = await client.health();
expect(result).toEqual({ status: 'error' });
});
});
describe('importAgent', () => {
it('calls paseo import with provider and labels', async () => {
const agentResult = { Id: 'new-789', Name: 'Imported', Provider: 'opencode', Status: 'idle' };
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue(JSON.stringify(agentResult));
const result = await client.importAgent('ses-001', 'opencode', {
origin: 'boocode',
project: 'proj-1',
});
expect(mockRunCli).toHaveBeenCalledWith([
'import', '--json',
'--provider', 'opencode',
'--label', 'origin=boocode',
'--label', 'project=proj-1',
'ses-001',
]);
expect(result.Id).toBe('new-789');
});
it('works without labels', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue(JSON.stringify({ Id: 'new-789' }));
const result = await client.importAgent('ses-001', 'claude');
expect(mockRunCli).toHaveBeenCalledWith([
'import', '--json',
'--provider', 'claude',
'ses-001',
]);
expect(result.Id).toBe('new-789');
});
});
describe('archiveAgent', () => {
it('calls paseo archive --json', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue('{}');
await client.archiveAgent('abc-123');
expect(mockRunCli).toHaveBeenCalledWith(['archive', '--json', 'abc-123']);
});
});
describe('sendPrompt', () => {
it('sends prompt and parses JSON result', async () => {
const sendResult = { text: 'Hello!', ok: true };
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue(JSON.stringify(sendResult));
const result = await client.sendPrompt('abc-123', 'Hello');
expect(mockRunCli).toHaveBeenCalledWith(['send', '--json', 'abc-123', 'Hello'], undefined);
expect(result).toEqual(sendResult);
});
it('falls back to plain text on non-JSON output', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue('plain text response');
const result = await client.sendPrompt('abc-123', 'Hi');
expect(result).toEqual({ text: 'plain text response', ok: true });
});
it('supports --no-wait flag', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue('{}');
await client.sendPrompt('abc-123', 'Hi', { noWait: true });
expect(mockRunCli).toHaveBeenCalledWith([
'send', '--json', '--no-wait',
'abc-123', 'Hi',
], undefined);
});
});
describe('stopAgent', () => {
it('calls paseo stop', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue('');
await client.stopAgent('abc-123');
expect(mockRunCli).toHaveBeenCalledWith(['stop', 'abc-123']);
});
});
describe('cliHost config', () => {
it('includes --host flag in args when cliHost is set', async () => {
const { client, mockRunCli } = makeClient({ cliHost: 'tcp://localhost:6767?ssl=true' });
mockRunCli.mockResolvedValue('[]');
await client.listAgents();
expect(mockRunCli).toHaveBeenCalledWith([
'ls', '--json', '--host', 'tcp://localhost:6767?ssl=true',
]);
});
});
});

View File

@@ -13,7 +13,7 @@ import type { AcpToolSnapshot } from './acp-tool-snapshot.js';
import type { AgentCommand } from './provider-types.js';
/** Backend transport kind. Mirrors `agent_sessions.backend` CHECK in schema.sql. */
export type AgentBackendKind = 'opencode_server' | 'acp_warm' | 'claude_sdk';
export type AgentBackendKind = 'opencode_server' | 'acp_warm' | 'claude_sdk' | 'paseo';
/**
* Normalized, transport-agnostic events a backend emits during a turn (§2).

View File

@@ -0,0 +1,254 @@
/**
* v2.10 — PaseoBackend: Paseo agent integration for the agent-pool.
*
* Wraps the Paseo CLI daemon as an AgentBackend. Each Paseo agent maps to one
* (chat_id, agent) pair and is persisted via `paseo import` (which registers
* an agent with the Paseo daemon). Prompts are sent via `paseo send`, and
* the session is cleaned up via `paseo archive`.
*
* Paseo is a meta-agent hub — it wraps provider sessions (opencode, claude,
* acp, etc.). The `provider` option in `EnsureSessionOpts` selects which
* provider Paseo delegates to.
*
* Backend kind: 'paseo' (must be added to agent_sessions_backend_chk).
*
* Spec: openspec/changes/v2-10-paseo-integration/design.md.
*/
import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../../db.js';
import { PaseoClient, type PaseoSendResult } from '../paseo-client.js';
import type {
AgentBackend,
AgentSessionHandle,
EnsureSessionOpts,
PromptCtx,
TurnResult,
} from '../agent-backend.js';
/** Default provider to use when Paseo wraps a generic agent. */
const DEFAULT_PASEO_PROVIDER = 'opencode';
export interface PaseoBackendDeps {
sql: Sql;
log: FastifyBaseLogger;
/** The (chat, agent) this backend serves — its pool identity + DB key. */
chatId: string;
/** Agent name (e.g. 'opencode', 'claude', 'paseo'). */
agent: string;
/** Resolved PaseoClient instance. */
client: PaseoClient;
/** Provider string to pass to `paseo import --provider`. */
provider: string;
}
export class PaseoBackend implements AgentBackend {
readonly backend = 'paseo' as const;
private readonly sql: Sql;
private readonly log: FastifyBaseLogger;
private readonly chatId: string;
private readonly agent: string;
private readonly client: PaseoClient;
private readonly provider: string;
/** Map of BooCode sessionId → Paseo agent ID. */
private readonly agentIds = new Map<string, string>();
/** True between prompt() start and settle. */
private busy = false;
private up = false;
constructor(deps: PaseoBackendDeps) {
this.sql = deps.sql;
this.log = deps.log;
this.chatId = deps.chatId;
this.agent = deps.agent;
this.client = deps.client;
this.provider = deps.provider || DEFAULT_PASEO_PROVIDER;
}
/** §2: liveness for the health endpoint + dispatcher fallback decision. */
health(): 'up' | 'down' {
return this.up ? 'up' : 'down';
}
/** Phase 3: busy iff a turn is in flight (pool never evicts a busy backend). */
isBusy(): boolean {
return this.busy;
}
// ─── ensureSession: create/import a Paseo agent ─────────────────────────────
async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> {
// Check if we already have a Paseo agent ID for this session.
let paseoId = this.agentIds.get(sessionId);
if (!paseoId) {
// Resolve existing agent_session_id from DB (e.g. after a restart).
const [row] = await this.sql<{ agent_session_id: string | null }[]>`
SELECT agent_session_id FROM agent_sessions
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent} AND backend = 'paseo'
`;
if (row?.agent_session_id) {
paseoId = row.agent_session_id;
this.agentIds.set(sessionId, paseoId);
}
}
if (!paseoId) {
// Import a new Paseo agent. Use the session UUID as the provider session id.
const labels: Record<string, string> = {
origin: 'boocode',
project: opts.projectId,
chat: opts.chatId,
worktree: opts.worktreeId,
agent: this.agent,
};
try {
const agent = await this.client.importAgent(sessionId, this.provider, labels);
paseoId = agent.Id;
this.agentIds.set(sessionId, paseoId);
this.log.info(
{ paseoId, agent: this.agent, chatId: this.chatId },
'paseo: imported agent',
);
} catch (err) {
this.log.error(
{ err: String(err), agent: this.agent, chatId: this.chatId },
'paseo: importAgent failed',
);
throw err;
}
}
// Upsert the agent_sessions row.
await this.sql`
INSERT INTO agent_sessions
(chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at)
VALUES
(${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'paseo', ${paseoId}, NULL, 'active', clock_timestamp())
ON CONFLICT (chat_id, agent) DO UPDATE SET
session_id = EXCLUDED.session_id,
worktree_id = EXCLUDED.worktree_id,
backend = 'paseo',
agent_session_id = COALESCE(EXCLUDED.agent_session_id, agent_sessions.agent_session_id),
server_port = NULL,
status = 'active',
last_active_at = clock_timestamp()
`.catch((err) => {
this.log.warn(
{ err: String(err), chatId: opts.chatId, agent: opts.agent },
'paseo: agent_sessions upsert failed (non-fatal)',
);
});
this.up = true;
return {
sessionId,
agent: opts.agent,
backend: 'paseo',
chatId: opts.chatId,
worktreeId: opts.worktreeId,
agentSessionId: paseoId,
serverPort: null,
};
}
// ─── prompt: send a message to the Paseo agent ─────────────────────────────
async prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult> {
const paseoId = handle.agentSessionId;
if (!paseoId) {
return { ok: false, error: 'paseo: no agent session id in handle' };
}
this.busy = true;
try {
// Use streamSend for real-time text output via onEvent.
const result: PaseoSendResult = await this.client.streamSend(
paseoId,
input,
(event) => {
ctx.onEvent(event);
},
ctx.signal,
);
// Update last_active_at.
await this.sql`
UPDATE agent_sessions
SET last_active_at = clock_timestamp()
WHERE chat_id = ${handle.chatId} AND agent = ${handle.agent}
`.catch(() => { /* non-fatal */ });
if (result.error) {
return { ok: false, error: result.error };
}
return { ok: true };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// Check if abortion
if (ctx.signal.aborted) {
return { ok: false, error: 'cancelled' };
}
return { ok: false, error: `paseo: ${msg}` };
} finally {
this.busy = false;
}
}
// ─── closeSession: archive the Paseo agent ─────────────────────────────────
async closeSession(handle: AgentSessionHandle): Promise<void> {
const paseoId = handle.agentSessionId;
if (!paseoId) return;
try {
await this.client.archiveAgent(paseoId);
this.log.info({ paseoId, agent: handle.agent }, 'paseo: archived agent');
} catch (err) {
this.log.warn(
{ err: String(err), paseoId, agent: handle.agent },
'paseo: archiveAgent failed (non-fatal)',
);
}
this.agentIds.delete(handle.sessionId);
// Update DB row.
await this.sql`
UPDATE agent_sessions
SET status = 'closed', last_active_at = clock_timestamp()
WHERE chat_id = ${handle.chatId} AND agent = ${handle.agent}
`.catch(() => { /* non-fatal */ });
}
// ─── dispose: archive all tracked agents ───────────────────────────────────
async dispose(): Promise<void> {
const ids = [...this.agentIds.values()];
this.agentIds.clear();
for (const paseoId of ids) {
try {
await this.client.archiveAgent(paseoId);
} catch {
// Best-effort cleanup during shutdown.
}
}
this.up = false;
}
/** Phase 3: periodic health tick — probes the Paseo daemon. */
async tickHealth(_now?: number): Promise<void> {
try {
const h = await this.client.health();
this.up = h.status === 'ok';
} catch {
this.up = false;
}
}
}

View File

@@ -0,0 +1,115 @@
// v2.8 Collision detection — pure functions that find file overlaps between
// worktrees/agents editing the same files concurrently. Advisory only; writes
// are never blocked, but the collision info surfaces in the UI and logs.
//
// Severity levels:
// same_line — the same file, exact same line region
// adjacent_line — the same file, lines touch or are within 5 lines
// different_area — the same file, distant lines
//
// Pure functions, no side effects. Testable in isolation.
export type ConflictSeverity = 'same_line' | 'adjacent_line' | 'different_area';
export interface ConflictVerdict {
filePath: string;
worktrees: string[];
severity: ConflictSeverity;
agents: string[];
}
/**
* Registry entry for a single file change recorded by a worktree.
* Stored in the ConflictIndex Map value for each file path.
*/
export interface ConflictEntry {
worktreeId: string;
agent: string;
/**
* Approximate line range touched by the change. undefined when the change
* creates or deletes the file (full-file collision vs. same-line).
*/
lineRange?: { start: number; end: number };
status: 'pending' | 'applied' | 'reverted';
timestamp: number;
}
/**
* Shape of the conflict index consumed by findConflicts.
* File path → set of entries from different worktrees/agents.
*/
export type ConflictIndexData = ReadonlyMap<string, ReadonlySet<ConflictEntry>>;
/**
* Find file overlaps between `changedFiles` and the conflict index, excluding
* the caller's own worktree.
*
* Returns one ConflictVerdict per file that has entries from other worktrees.
* Severity is the highest found (same_line > adjacent_line > different_area).
*/
export function findConflicts(
changedFiles: string[],
worktreeId: string,
/** Approximate line range for the proposed changes, keyed by file path */
changedRanges: Map<string, { start: number; end: number }>,
conflictIndex: ConflictIndexData,
): ConflictVerdict[] {
const verdicts: ConflictVerdict[] = [];
for (const filePath of changedFiles) {
const entries = conflictIndex.get(filePath);
if (!entries || entries.size === 0) continue;
// Filter to entries from OTHER worktrees
const otherEntries = [...entries].filter((e) => e.worktreeId !== worktreeId);
if (otherEntries.length === 0) continue;
const myRange = changedRanges.get(filePath);
let severity: ConflictSeverity = 'different_area';
for (const entry of otherEntries) {
if (!myRange || !entry.lineRange) {
// Full-file changes (create/delete) always hit at least different_area
continue;
}
const sev = lineOverlapSeverity(myRange, entry.lineRange);
if (sev === 'same_line') {
severity = 'same_line';
break; // Can't get higher than this
}
if (sev === 'adjacent_line' && severity === 'different_area') {
severity = 'adjacent_line';
}
}
const worktrees = [...new Set(otherEntries.map((e) => e.worktreeId))];
const agents = [...new Set(otherEntries.map((e) => e.agent))];
verdicts.push({ filePath, worktrees, severity, agents });
}
return verdicts;
}
const ADJACENT_LINE_THRESHOLD = 5;
/**
* Determine severity of overlap between two line ranges.
*/
function lineOverlapSeverity(
a: { start: number; end: number },
b: { start: number; end: number },
): ConflictSeverity {
// Same_line: ranges intersect
if (a.start <= b.end && b.start <= a.end) {
return 'same_line';
}
// Adjacent: ranges are within ADJACENT_LINE_THRESHOLD lines of each other
const gap = a.start > b.end ? a.start - b.end : b.start - a.end;
if (gap <= ADJACENT_LINE_THRESHOLD) {
return 'adjacent_line';
}
return 'different_area';
}

View File

@@ -0,0 +1,151 @@
// v2.8 In-memory conflict index — tracks which worktrees/agents are editing
// which files so the collision detector can find overlaps.
//
// Singleton exported as `conflictIndex`; imported by pending_changes.ts to
// register changes at queue time and unregister on worktree teardown.
//
// NOT persisted — survives only as long as the BooCoder process. Postgres
// is the durable record (pending_changes table); this is the hot in-memory
// probe for concurrent edit warnings.
import type { ConflictEntry, ConflictVerdict } from './collision-detector.js';
import { findConflicts } from './collision-detector.js';
export class ConflictIndex {
/**
* filePath → Set of ConflictEntry from various worktrees.
* A single worktree may have multiple entries for the same file
* (several pending edits to the same file in one session).
*/
#map = new Map<string, Set<ConflictEntry>>();
// ---- mutation -------------------------------------------------------
/**
* Register that `worktreeId` (agent) is touching `filePath`.
* Creates an entry in the index so subsequent callers see it as a conflict.
*/
registerChange(
filePath: string,
worktreeId: string,
agent: string,
lineRange?: { start: number; end: number },
): void {
let entries = this.#map.get(filePath);
if (!entries) {
entries = new Set();
this.#map.set(filePath, entries);
}
entries.add({
worktreeId,
agent,
lineRange,
status: 'pending' as const,
timestamp: Date.now(),
});
}
/**
* Remove all entries for a given worktree. Called on worktree teardown
* so stale entries don't trigger false warnings.
*/
removeWorktree(worktreeId: string): void {
for (const [filePath, entries] of this.#map) {
const before = entries.size;
for (const entry of entries) {
if (entry.worktreeId === worktreeId) {
entries.delete(entry);
}
}
if (entries.size === 0) {
this.#map.delete(filePath);
}
}
}
/**
* Remove entries older than `maxAgeMs`. Useful as a periodic cleanup
* when worktree teardown was missed (crash, unclean exit).
*/
sweepStale(maxAgeMs: number): number {
const cutoff = Date.now() - maxAgeMs;
let removed = 0;
for (const [filePath, entries] of this.#map) {
for (const entry of entries) {
if (entry.timestamp < cutoff) {
entries.delete(entry);
removed++;
}
}
if (entries.size === 0) {
this.#map.delete(filePath);
}
}
return removed;
}
// ---- query ----------------------------------------------------------
/**
* Query the raw ConflictEntry set for a file path. Returns empty set
* when there are no entries (never mutated the file).
*/
getEntriesFor(filePath: string): ReadonlySet<ConflictEntry> {
return this.#map.get(filePath) ?? new Set();
}
/**
* Get all conflict verdicts for a given file path — which other
* worktrees are touching it. Returns empty when only one worktree
* has entries (no actual conflict).
*/
getConflictsFor(filePath: string): ConflictVerdict[] {
const entries = this.#map.get(filePath);
if (!entries || entries.size === 0) return [];
// Determine distinct worktree IDs. If only one, no conflict.
const worktreeIds = new Set<string>();
for (const e of entries) worktreeIds.add(e.worktreeId);
if (worktreeIds.size <= 1) return [];
// Use the first worktree as the "caller" so findConflicts excludes
// its entries and returns only entries from OTHER worktrees.
const caller = [...worktreeIds][0]!;
return findConflicts(
[filePath],
caller,
new Map(),
this.#toIndexData(),
);
}
/**
* Get conflicts for a set of file changes from a specific worktree.
* Delegates to the pure findConflicts function.
*/
query(
changedFiles: string[],
worktreeId: string,
changedRanges: Map<string, { start: number; end: number }>,
): ConflictVerdict[] {
return findConflicts(changedFiles, worktreeId, changedRanges, this.#toIndexData());
}
/**
* Snapshot the current map for testing/inspection.
*/
snapshot(): Map<string, ReadonlySet<ConflictEntry>> {
return new Map(this.#map);
}
// ---- private --------------------------------------------------------
#toIndexData(): ReadonlyMap<string, ReadonlySet<ConflictEntry>> {
return this.#map as ReadonlyMap<string, ReadonlySet<ConflictEntry>>;
}
}
// Singleton — the whole BooCoder process shares one conflict index.
export const conflictIndex = new ConflictIndex();

View File

@@ -33,11 +33,52 @@ export interface SchedulerState {
readonly inFlight: ReadonlySet<string>;
/** step ids pre-skipped at launch (band/when gating) — never given a row */
readonly excluded: ReadonlySet<string>;
/** step ids that timed out (terminal — no retries remaining or not retriable) */
readonly timedOut: ReadonlySet<string>;
/**
* Per-batch running sets, populated by buildBatchState from the flow definition
* and the current inFlight set. Only read by getReadyInBatch; never mutated by
* decision functions (the caller maintains it across ticks).
*/
readonly batchState?: Map<string, { running: Set<string>; maxConcurrent: number; joinRule: TriggerRule }>;
/**
* Per-switch-step routing results. Populated when a SWITCH step completes.
* Step ids in any result's `excluded` set are treated as excluded for the
* remainder of the run — they won't execute and won't block dependents.
*/
readonly switchResults: ReadonlyMap<string, { chosenCase: string | null; excluded: ReadonlySet<string> }>;
/** Per-DO_WHILE iteration count; presence in the map indicates an active loop */
readonly loopIterations: ReadonlyMap<string, number>;
}
/** A dependency is satisfied once it is done, skipped, or excluded. */
/** A dependency is satisfied once it is done, skipped, excluded, or timed out.
* Dependencies on a running DO_WHILE step are also satisfied so body steps
* execute during an active loop iteration. */
function isSatisfied(state: SchedulerState, id: string): boolean {
return state.done.has(id) || state.skipped.has(id) || state.excluded.has(id);
const effectiveExcluded = getEffectiveExcluded(state);
if (state.done.has(id) || state.skipped.has(id) || effectiveExcluded.has(id) || state.timedOut.has(id)) {
return true;
}
// A dependency on a running DO_WHILE step is satisfied (body runs during the loop).
if (state.loopIterations.has(id) && state.inFlight.has(id)) return true;
return false;
}
/**
* The union of the static `excluded` set and every switch result's excluded
* step ids. Steps excluded by a SWITCH evaluation act exactly like launch-time
* excluded steps: they never run and they don't block dependents.
*/
function getEffectiveExcluded(state: SchedulerState): ReadonlySet<string> {
// Fast path: no switch results → static excluded only.
if (state.switchResults.size === 0) return state.excluded;
const combined = new Set(state.excluded);
for (const result of state.switchResults.values()) {
for (const id of result.excluded) {
combined.add(id);
}
}
return combined;
}
/**
@@ -56,13 +97,14 @@ export function manifestSteps(flow: Flow, launchCtx: StepContext): Step[] {
* Faithful to `conductor/flow.ts:27-36`. Pure.
*/
export function readySteps(flow: Flow, state: SchedulerState): Step[] {
const effectiveExcluded = getEffectiveExcluded(state);
return flow.steps.filter(
(s) =>
!state.done.has(s.id) &&
!state.skipped.has(s.id) &&
!state.inFlight.has(s.id) &&
!state.excluded.has(s.id) &&
((s.deps ?? []).length === 0 || evaluateTriggerRule(s.deps ?? [], state.done, state.skipped, state.excluded, s.trigger_rule)),
!effectiveExcluded.has(s.id) &&
((s.deps ?? []).length === 0 || evaluateTriggerRule(s.deps ?? [], state.done, state.skipped, effectiveExcluded, s.trigger_rule)),
);
}
@@ -102,6 +144,57 @@ export function isStuck(flow: Flow, state: SchedulerState): boolean {
);
}
// ─── Batch parallelism (v2.8.22) ─────────────────────────────────────────────
/**
* Build the batchState Map from the flow definition and the current inFlight set.
* Only steps with a `batch` field are tracked. Empty map when `flow.batchConfig`
* is absent or no steps belong to a batch. Pure — no IO.
*/
export function buildBatchState(
flow: Flow,
inFlight: ReadonlySet<string>,
): Map<string, { running: Set<string>; maxConcurrent: number; joinRule: TriggerRule }> {
const result = new Map<string, { running: Set<string>; maxConcurrent: number; joinRule: TriggerRule }>();
if (!flow.batchConfig) return result;
// Collect every unique batch group referenced by the flow's steps.
const groups = new Set<string>();
for (const s of flow.steps) {
if (s.batch) groups.add(s.batch);
}
const { maxConcurrent, joinRule } = flow.batchConfig;
for (const batch of groups) {
const running = new Set<string>(
flow.steps.filter((s) => s.batch === batch && inFlight.has(s.id)).map((s) => s.id),
);
result.set(batch, { running, maxConcurrent, joinRule: joinRule ?? 'all_success' });
}
return result;
}
/**
* Gate a ready step list by batch parallelism limits. Steps without a `batch`
* field always pass through. Steps belonging to a batch are only included if
* that batch's currently-running count is below its `maxConcurrent` cap.
*
* This is ADDITIVE to the existing wave scheduler: pure dep-based readiness
* is computed first (readySteps), then this function applies the batch ceiling.
* Steps excluded here remain pending and will be picked up on the next tick
* when a running batch step completes.
*/
export function getReadyInBatch(ready: readonly Step[], state: SchedulerState, _flow: Flow): Step[] {
const batchState = state.batchState;
if (!batchState || batchState.size === 0) return [...ready];
return ready.filter((s) => {
if (!s.batch) return true;
const bs = batchState.get(s.batch);
if (!bs) return true;
return bs.running.size < bs.maxConcurrent;
});
}
// ─── Resume reconciliation (D-9) ─────────────────────────────────────────────
/**
@@ -118,25 +211,50 @@ export function isStuck(flow: Flow, state: SchedulerState): boolean {
* - 'mark-cancelled': task was cancelled before the callback ran; propagate so
* advance() cancels the run.
*/
/**
* True when the step definition allows retries on timeout.
* Pure — no IO.
*/
export function isRetriable(step: { maxRetries?: number }): boolean {
return (step.maxRetries ?? 0) > 0;
}
/**
* True when the step has retries remaining.
* Pure — no IO.
*/
export function shouldRetry(maxRetries: number | undefined | null, retryCount: number): boolean {
return retryCount < (maxRetries ?? 0);
}
export type ResumeAction =
| 'keep'
| 're-dispatch'
| 'mark-done'
| 'mark-failed'
| 'mark-cancelled';
| 'mark-cancelled'
| 'retry';
/**
* Decide what to do with ONE flow step during startup resume (D-9). Pure.
*
* @param status - flow_steps.status
* @param taskId - flow_steps.task_id (null for code steps or unstarted agent steps)
* @param taskState - tasks.state for taskId, or null if the task row is absent
* @param status - flow_steps.status
* @param taskId - flow_steps.task_id (null for code steps or unstarted agent steps)
* @param taskState - tasks.state for taskId, or null if the task row is absent
* @param retryCount - flow_steps.retry_count (default 0)
* @param maxRetries - flow_steps.max_retries (null = no retry)
*/
export function reconcileResumeStep(
status: string,
taskId: string | null,
taskState: string | null,
retryCount?: number,
maxRetries?: number | null,
): ResumeAction {
if (status === 'timed_out') {
if (shouldRetry(maxRetries, retryCount ?? 0)) return 'retry';
return 'mark-failed';
}
if (status !== 'running') return 'keep';
// Running step: decide by its task's current state.
if (!taskId || taskState === null) return 're-dispatch'; // task gone or never created
@@ -167,6 +285,60 @@ export function shouldFailOnMissingAgent(agent: string, modeId: string | null):
return agent === 'qwen' && modeId === 'plan';
}
/**
* Evaluate a SWITCH step: iterate cases in declaration order and return the
* label of the first matching case plus every step id that belongs to a
* non-selected branch. When no case matches, the defaultBranch (if present)
* is the effective choice. If there is no default, all branch steps are
* excluded and the switch returns `chosenCase: null`.
*
* Pure — no IO. The caller adds the returned `excluded` ids to the scheduler
* state's switchResults so downstream decision functions see them as excluded.
*/
export function resolveSwitch(
step: Step,
ctx: StepContext,
): { chosenCase: string | null; excluded: string[] } {
const cases = step.cases;
if (!cases || cases.length === 0) {
// Degenerate switch — nothing to evaluate.
return { chosenCase: null, excluded: [] };
}
// Evaluate conditions in order.
for (const c of cases) {
if (c.condition(ctx)) {
// This case matches — exclude all OTHER branches.
const excluded: string[] = [];
for (const other of cases) {
if (other.label !== c.label) {
excluded.push(...other.stepIds);
}
}
// The default branch is also excluded when a case matched.
if (step.defaultBranch) excluded.push(...step.defaultBranch);
return { chosenCase: c.label, excluded };
}
}
// No case matched — use default branch if present.
if (step.defaultBranch) {
// Default is the chosen branch: exclude all explicit case branches.
const excluded: string[] = [];
for (const c of cases) {
excluded.push(...c.stepIds);
}
return { chosenCase: null, excluded };
}
// No case matched and no default — exclude everything.
const excluded: string[] = [];
for (const c of cases) {
excluded.push(...c.stepIds);
}
return { chosenCase: null, excluded };
}
/**
* Evaluate a trigger rule against dependency results.
* - all_success: every dep must be done (not skipped/failed)
@@ -198,7 +370,7 @@ export function evaluateTriggerRule(
* decision per step. Pure — no IO.
*/
export function reconcileRun(
steps: ReadonlyArray<{ stepId: string; taskId: string | null; status: string }>,
steps: ReadonlyArray<{ stepId: string; taskId: string | null; status: string; retryCount?: number; maxRetries?: number | null }>,
taskStates: ReadonlyMap<string, string>,
): StepResumeDecision[] {
return steps.map((step) => ({
@@ -207,6 +379,22 @@ export function reconcileRun(
step.status,
step.taskId,
step.taskId ? (taskStates.get(step.taskId) ?? null) : null,
step.retryCount,
step.maxRetries,
),
}));
}
/**
* True when a DO_WHILE loop should stop: the condition returned false or the
* iteration cap was reached. Pure — no IO.
*
* @param step - the DO_WHILE step definition
* @param ctx - current step context (input + accumulated results)
* @param iterations - number of completed iterations so far
*/
export function isLoopTerminated(step: Step, ctx: StepContext, iterations: number): boolean {
if (iterations >= (step.loopMaxIterations ?? 100)) return true;
if (step.loopCondition) return !step.loopCondition(ctx);
return false;
}

View File

@@ -32,7 +32,7 @@
* already emits. (Phase 8 wires the OrchestratorPane's subscription to both.)
*/
import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker';
import type { Broker, Frame, Listener } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { FastifyBaseLogger } from 'fastify';
import type { Config } from '../config.js';
@@ -40,11 +40,15 @@ import { getFlow } from '../conductor/flows/index.js';
import { loadPersona } from '../conductor/persona-loader.js';
import type { Band, DispatchFn, Flow, FlowInput, Step, StepContext } from '../conductor/types.js';
import {
buildBatchState,
getReadyInBatch,
isLoopTerminated,
isRunComplete,
manifestSteps,
partitionReady,
readySteps,
reconcileRun,
resolveSwitch,
type SchedulerState,
type StepResumeDecision,
} from './flow-runner-decisions.js';
@@ -95,11 +99,14 @@ interface Deps {
interface FlowStepRow {
step_id: string;
kind: 'agent' | 'code';
kind: 'agent' | 'code' | 'switch' | 'do_while';
agent: string | null;
status: string;
chat_id: string | null;
output: string | null;
updated_at: string | null;
retry_count: number | null;
max_retries: number | null;
}
export function createFlowRunner(deps: Deps): FlowRunner {
@@ -112,6 +119,10 @@ export function createFlowRunner(deps: Deps): FlowRunner {
// taskId → resolver map. These tasks have NO flow_steps row; handleTaskTerminal
// resolves them here instead of advancing a run.
const subDispatchWaiters = new Map<string, (output: string) => void>();
/** Per-DO_WHILE step iteration count; persists across advance() calls. */
const loopIterations = new Map<string, number>();
/** Per-run messaging subscriptions; cleaned up when the run terminates. */
const messagingCleanups = new Map<string, Set<() => void>>();
function publishUser(frame: Record<string, unknown>): void {
broker.publishUserFrame('default', frame as unknown as WsFrame);
@@ -128,8 +139,42 @@ export function createFlowRunner(deps: Deps): FlowRunner {
results: Record<string, string>,
model: string,
dispatch?: DispatchFn,
runId?: string,
stepId?: string,
): StepContext {
return { input, results, model, dispatch };
let messaging: StepContext['messaging'] = undefined;
if (runId) {
if (!messagingCleanups.has(runId)) {
messagingCleanups.set(runId, new Set());
}
const subs = messagingCleanups.get(runId)!;
messaging = {
publish(channel: string, message: unknown) {
const content = typeof message === 'string' ? message : JSON.stringify(message);
const topic = `run:${runId}:${channel}`;
const frame = {
type: 'agent_message' as const,
run_id: runId,
sender_step_id: stepId ?? '',
content,
...(channel ? { channel } : {}),
};
broker.publishUserFrame('default', frame as unknown as WsFrame);
broker.publish(topic, frame as unknown as Frame);
},
subscribe(channel: string, handler: (msg: unknown) => void) {
const topic = `run:${runId}:${channel}`;
const listener: Listener = (f) => { handler(f); };
const unsub = broker.subscribe(topic, listener);
subs.add(unsub);
return () => {
unsub();
subs.delete(unsub);
};
},
};
}
return { input, results, model, dispatch, messaging };
}
/** Latest assistant message text for a chat — the FULL worker output (≤50k as
@@ -263,7 +308,8 @@ export function createFlowRunner(deps: Deps): FlowRunner {
const dispatch: DispatchFn = (agent, task) => dispatchSubAgent(run.project_id, model, agent, task);
const rows = await sql<FlowStepRow[]>`
SELECT step_id, kind, agent, status, chat_id, output FROM flow_steps WHERE run_id = ${runId}
SELECT step_id, kind, agent, status, chat_id, output, updated_at, retry_count, max_retries
FROM flow_steps WHERE run_id = ${runId}
`;
// Re-derive the excluded set (band/when pre-skips) from the flow def + input —
@@ -275,6 +321,9 @@ export function createFlowRunner(deps: Deps): FlowRunner {
const done = new Set<string>();
const skipped = new Set<string>();
const inFlight = new Set<string>();
const timedOut = new Set<string>();
/** Per-switch routing results — maps switch step id → resolved branch details */
const switchExcluded = new Map<string, { chosenCase: string | null; excluded: Set<string> }>();
const results: Record<string, string> = {};
for (const r of rows) {
switch (r.status) {
@@ -288,6 +337,9 @@ export function createFlowRunner(deps: Deps): FlowRunner {
case 'running':
inFlight.add(r.step_id);
break;
case 'timed_out':
timedOut.add(r.step_id);
break;
case 'failed':
// A failed worker makes the deterministic report untrustworthy — fail the
// whole run (matches the Phase-1 CLI, which throws on a dispatch failure).
@@ -300,19 +352,120 @@ export function createFlowRunner(deps: Deps): FlowRunner {
}
}
// ─── Timeout detection ───────────────────────────────────────────────────────
// Check running steps. If a step has been 'running' longer than
// FLOW_STEP_TIMEOUT_MS, mark it timed_out or re-dispatch if retriable.
// Build a context here so the timeout retry path can re-dispatch the step.
const timeoutCtx = buildCtx(input, results, model, dispatch);
const timeoutMs = config.FLOW_STEP_TIMEOUT_MS;
const nowDate = new Date();
let detectedTimedOut = false;
for (const r of rows) {
if (r.status !== 'running') continue;
if (!r.updated_at) continue;
const elapsed = nowDate.getTime() - new Date(r.updated_at).getTime();
if (elapsed <= timeoutMs) continue;
// Step has exceeded the timeout
detectedTimedOut = true;
const retryCount = r.retry_count ?? 0;
const maxRetries = r.max_retries ?? 0;
if (maxRetries > 0 && retryCount < maxRetries) {
// Retriable: re-dispatch the step with an incremented retry_count
const step = flow.steps.find((s) => s.id === r.step_id);
if (!step || step.kind !== 'agent') {
// Non-agent steps can't be retried via dispatch
inFlight.delete(r.step_id);
await failRun(runId, flow, input, model,
`step '${r.step_id}' timed out (non-retriable kind)`, r.step_id);
return;
}
inFlight.delete(r.step_id);
await sql`
UPDATE flow_steps
SET retry_count = ${retryCount + 1}, updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${r.step_id} AND status = 'running'
`;
await dispatchAgentStep(runId, run.project_id, model, step, timeoutCtx);
inFlight.add(r.step_id);
log.warn({ runId, stepId: r.step_id, retry: retryCount + 1, maxRetries },
'flow-runner: step timed out, retrying');
} else {
// Not retriable — mark as timed_out, fail the run
inFlight.delete(r.step_id);
await sql`
UPDATE flow_steps SET status = 'timed_out', updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${r.step_id} AND status = 'running'
`;
timedOut.add(r.step_id);
publishStep(runId, r.step_id, 'timed_out');
await failRun(runId, flow, input, model,
`step '${r.step_id}' timed out`, r.step_id);
return;
}
}
// If we modified any steps, re-query so the state sets reflect the latest DB.
if (detectedTimedOut) {
// Continue with the in-memory state we already adjusted above (inFlight/timedOut
// were mutated directly). No re-query needed.
}
// Drain ready skips + code steps (synchronous), re-evaluating after each batch,
// then dispatch the full ready agent wave and wait for their terminal callbacks.
for (;;) {
const state: SchedulerState = { done, skipped, inFlight, excluded };
// Build per-batch state from the current inFlight set for batch parallelism gating.
const batchState = buildBatchState(flow, inFlight);
const state: SchedulerState = { done, skipped, inFlight, excluded, timedOut, batchState, switchResults: switchExcluded, loopIterations };
if (isRunComplete(flow, state)) {
await finishRun(runId, flow, input, results, model, dispatch);
return;
}
const ready = readySteps(flow, state);
const ready = getReadyInBatch(readySteps(flow, state), state, flow);
if (ready.length === 0) {
if (inFlight.size > 0) return; // agents in flight will re-enter via the hook
// Before declaring stuck, check for running DO_WHILE steps whose body
// is fully done — triggers the next loop iteration or terminates.
if (inFlight.size > 0) {
let doWhileReEval = false;
for (const s of flow.steps) {
if (s.kind !== 'do_while' || !s.loopBody || s.loopBody.length === 0) continue;
if (!inFlight.has(s.id)) continue;
if (!s.loopBody.every((bId) => done.has(bId))) continue;
doWhileReEval = true;
const iterations = loopIterations.get(s.id) ?? 0;
const dwCtx = buildCtx(input, results, model, dispatch);
if (isLoopTerminated(s, dwCtx, iterations)) {
await markStep(runId, s.id, 'completed');
done.add(s.id);
results[s.id] = '';
inFlight.delete(s.id);
publishStep(runId, s.id, 'completed');
} else {
await sql`
UPDATE flow_steps SET status = 'running', updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${s.id}
`;
inFlight.add(s.id);
loopIterations.set(s.id, iterations + 1);
for (const bodyId of s.loopBody) {
done.delete(bodyId);
delete results[bodyId];
await sql`
UPDATE flow_steps
SET status = 'pending', output = NULL, updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${bodyId}
`;
}
publishStep(runId, s.id, 'running');
}
break; // one DO_WHILE at a time
}
if (doWhileReEval) continue;
return; // genuine inFlight agents with no ready steps
}
await failRun(runId, flow, input, model, 'unsatisfiable dependencies / cycle');
return;
}
@@ -329,6 +482,74 @@ export function createFlowRunner(deps: Deps): FlowRunner {
continue; // re-evaluate — a skip can settle a fan-in step's deps
}
// SWITCH steps run synchronously — evaluate conditions, update the excluded
// set in SchedulerState, and mark themselves complete. Non-selected branch
// step ids are excluded from ever running.
const switchReady = toRun.filter((s) => s.kind === 'switch');
if (switchReady.length > 0) {
for (const s of switchReady) {
let result: { chosenCase: string | null; excluded: string[] };
try {
result = resolveSwitch(s, buildCtx(input, results, model, dispatch));
} catch (err) {
await failRun(runId, flow, input, model, `switch step '${s.id}' threw: ${errMsg(err)}`, s.id);
return;
}
switchExcluded.set(s.id, {
chosenCase: result.chosenCase,
excluded: new Set(result.excluded),
});
const outputText = result.chosenCase ? `branch:${result.chosenCase}` : '';
await markStep(runId, s.id, 'completed', outputText);
results[s.id] = outputText;
done.add(s.id);
}
continue; // re-evaluate — excluded steps may unblock dependents
}
// DO_WHILE steps: first-activation only (ready to run for the first time).
// Re-evaluation of running DO_WHILE steps whose body is complete is handled
// in the `ready.length === 0` block above (Path 1) — this avoids duplicate
// SQL updates and competing state mutations.
const doWhileReady = toRun.filter((s) => s.kind === 'do_while');
if (doWhileReady.length > 0) {
for (const s of doWhileReady) {
const iterations = loopIterations.get(s.id) ?? 0;
const dwCtx = buildCtx(input, results, model, dispatch);
if (isLoopTerminated(s, dwCtx, iterations)) {
// Loop done — mark DO_WHILE completed. Body steps stay in their
// current state (already done from the last iteration).
await markStep(runId, s.id, 'completed');
done.add(s.id);
results[s.id] = '';
inFlight.delete(s.id);
publishStep(runId, s.id, 'completed');
} else {
// Start or continue the loop.
await sql`
UPDATE flow_steps SET status = 'running', updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${s.id}
`;
inFlight.add(s.id);
loopIterations.set(s.id, iterations + 1);
// On re-iteration, reset body steps from 'completed' back to 'pending'.
if (iterations > 0 && s.loopBody) {
for (const bodyId of s.loopBody) {
done.delete(bodyId);
delete results[bodyId];
await sql`
UPDATE flow_steps
SET status = 'pending', output = NULL, updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${bodyId}
`;
}
}
publishStep(runId, s.id, 'running');
}
}
continue; // re-evaluate — body steps may be newly pending
}
const codeReady = toRun.filter((s) => s.kind === 'code');
if (codeReady.length > 0) {
for (const s of codeReady) {
@@ -336,7 +557,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
try {
// Code steps run IN-PROCESS (fold / synthesis-fold / code-review verify).
// verify uses ctx.dispatch → dispatchSubAgent (read-only qwen workers).
out = await s.run(buildCtx(input, results, model, dispatch));
out = await s.run(buildCtx(input, results, model, dispatch, runId, s.id));
} catch (err) {
await failRun(runId, flow, input, model, `code step '${s.id}' threw: ${errMsg(err)}`, s.id);
return;
@@ -459,6 +680,14 @@ export function createFlowRunner(deps: Deps): FlowRunner {
await appendStepEvent(sql, runId, stepId, status, output ? { outputLength: output.length } : undefined);
}
function cleanupMessaging(runId: string): void {
const cleanups = messagingCleanups.get(runId);
if (cleanups) {
for (const fn of cleanups) fn();
messagingCleanups.delete(runId);
}
}
// ─── run completion ─────────────────────────────────────────────────────────
async function finishRun(
@@ -480,12 +709,16 @@ export function createFlowRunner(deps: Deps): FlowRunner {
UPDATE flow_runs SET status = 'completed', report = ${report}, updated_at = clock_timestamp()
WHERE id = ${runId} AND status = 'running'
`;
if (updated.count === 0) return; // already terminal (e.g. cancelled) — don't publish
if (updated.count === 0) {
cleanupMessaging(runId);
return; // already terminal (e.g. cancelled) — don't publish
}
deps.onRunTerminal?.(runId, 'completed');
publishStep(runId, lastAgentStepId(flow, input, model), 'completed', {
run_status: 'completed',
report,
});
cleanupMessaging(runId);
}
async function failRun(
@@ -506,6 +739,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
log.warn({ runId, error }, 'flow-runner: run failed');
await appendStepEvent(sql, runId, stepId, 'failed', { error });
publishStep(runId, stepId, 'failed', { run_status: 'failed' });
cleanupMessaging(runId);
}
async function cancelRun(runId: string): Promise<void> {
@@ -533,6 +767,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
}
}
log.info({ runId }, 'flow-runner: run cancelled');
cleanupMessaging(runId);
}
/** The terminal agent step in roster order — a valid roster step_id to carry the
@@ -545,7 +780,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
function publishStep(
runId: string,
stepId: string,
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'blocked',
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'blocked' | 'timed_out',
extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string },
): void {
publishUser({
@@ -683,6 +918,38 @@ export function createFlowRunner(deps: Deps): FlowRunner {
log.info({ runId, stepId: step.step_id, taskId: task!.id }, 'flow-runner: step re-dispatched on resume');
break;
}
case 'retry': {
// Like re-dispatch but increments retry_count and sets status to 'running'.
if (!step.input) {
await sql`
UPDATE flow_steps
SET status = 'failed', error = 'retry: no stored prompt',
updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${step.step_id}
`;
break;
}
const chatIdR = step.chat_id;
const [chatR] = chatIdR
? await sql<{ session_id: string }[]>`SELECT session_id FROM chats WHERE id = ${chatIdR}`
: [];
const sessionIdR = chatR?.session_id ?? null;
const [taskR] = await sql<{ id: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, session_id, chat_id)
VALUES (${projectId}, ${step.input}, 'qwen', ${model}, 'plan', ${sessionIdR}, ${chatIdR})
RETURNING id
`;
await sql`
UPDATE flow_steps
SET task_id = ${taskR!.id}, retry_count = retry_count + 1, status = 'running',
updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${step.step_id}
`;
log.info({ runId, stepId: step.step_id, taskId: taskR!.id },
'flow-runner: step retried on resume');
break;
}
}
}
@@ -697,7 +964,9 @@ export function createFlowRunner(deps: Deps): FlowRunner {
status: string;
chat_id: string | null;
input: string | null;
}[]>`SELECT step_id, task_id, status, chat_id, input FROM flow_steps WHERE run_id = ${run.id}`;
retry_count: number | null;
max_retries: number | null;
}[]>`SELECT step_id, task_id, status, chat_id, input, retry_count, max_retries FROM flow_steps WHERE run_id = ${run.id}`;
// Load task states for all referenced tasks in one query.
const taskIds = rows.map((r) => r.task_id).filter((id): id is string => id !== null);
@@ -710,7 +979,13 @@ export function createFlowRunner(deps: Deps): FlowRunner {
}
const decisions = reconcileRun(
rows.map((r) => ({ stepId: r.step_id, taskId: r.task_id, status: r.status })),
rows.map((r) => ({
stepId: r.step_id,
taskId: r.task_id,
status: r.status,
retryCount: r.retry_count ?? undefined,
maxRetries: r.max_retries,
})),
taskStates,
);
@@ -752,13 +1027,13 @@ export function createFlowRunner(deps: Deps): FlowRunner {
// Mark all non-terminal steps cancelled and collect in-flight task_ids.
const steps = await sql<{ step_id: string; task_id: string | null; kind: string }[]>`
SELECT step_id, task_id, kind FROM flow_steps
WHERE run_id = ${runId} AND status NOT IN ('completed', 'failed', 'cancelled', 'skipped')
WHERE run_id = ${runId} AND status NOT IN ('completed', 'failed', 'cancelled', 'skipped', 'timed_out')
`;
if (steps.length > 0) {
await sql`
UPDATE flow_steps SET status = 'cancelled', updated_at = clock_timestamp()
WHERE run_id = ${runId} AND status NOT IN ('completed', 'failed', 'cancelled', 'skipped')
WHERE run_id = ${runId} AND status NOT IN ('completed', 'failed', 'cancelled', 'skipped', 'timed_out')
`;
for (const s of steps) {
if (s.kind === 'agent') publishStep(runId, s.step_id, 'cancelled', { run_status: 'cancelled' });
@@ -778,6 +1053,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
.map((s) => s.task_id);
log.info({ runId }, 'flow-runner: run cancelled by request');
cleanupMessaging(runId);
return { cancelled: true, taskIds };
}

View File

@@ -0,0 +1,341 @@
/**
* v2.10 — PaseoClient: thin CLI-based client for the Paseo daemon.
*
* Paseo is a multi-agent hub daemon running at a configurable address
* (default Unix socket / localhost:6767). This client wraps the `paseo` CLI
* via child_process spawn for all operations (the daemon does not expose a
* separate REST API for write operations). Read operations (listAgents,
* getAgentStatus) use `paseo ls --json` / `paseo inspect --json`; write
* operations (import, archive, send) use the corresponding subcommands.
*
* Spec: openspec/changes/v2-10-paseo-integration/design.md.
*/
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { createInterface } from 'node:readline';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Listing entry from `paseo ls --json`. Fields are lowercase. */
export interface PaseoAgentListItem {
id: string;
shortId: string;
name: string;
provider: string;
status: string;
cwd?: string;
created?: string;
thinking?: string;
}
/** Detailed agent info from `paseo inspect --json`. Fields are PascalCase. */
export interface PaseoAgentDetail {
Id: string;
Name: string;
Provider: string;
Model?: string;
Status: string;
Thinking?: string;
Archived: boolean;
ArchivedAt?: string | null;
Cwd?: string;
CreatedAt: string;
UpdatedAt: string;
Mode?: string;
AvailableModes?: Array<{ id: string; label: string }>;
Capabilities?: {
Streaming?: boolean;
Persistence?: boolean;
DynamicModes?: boolean;
McpServers?: boolean;
};
Labels?: Record<string, string>;
Worktree?: string | null;
ParentAgentId?: string | null;
}
/** Result of `paseo send --json`. */
export interface PaseoSendResult {
/** The agent's textual response. */
text?: string;
/** Structured output if the agent produced any. */
output?: unknown;
/** Error message if the turn failed. */
error?: string;
/** True if the turn completed successfully. */
ok?: boolean;
}
export interface PaseoClientConfig {
/** Path to the paseo binary. Default: auto-resolved from PATH. */
paseoBin: string;
/**
* Explicit `--host <host>` value for CLI calls.
* Format: `host:port` or `tcp://host:port?ssl=true&password=secret`.
* Omit to use the CLI default (Unix socket, fallback localhost:6767).
*/
cliHost?: string;
}
const DEFAULT_PASEO_BIN = 'paseo';
// ─── Client ──────────────────────────────────────────────────────────────────
export class PaseoClientError extends Error {
constructor(
message: string,
public readonly command: string,
public readonly exitCode: number | null,
public readonly stderr: string,
) {
super(message);
this.name = 'PaseoClientError';
}
}
export class PaseoClient {
/** @internal visible for testing */
readonly bin: string;
private readonly hostArgs: string[];
constructor(config?: Partial<PaseoClientConfig>) {
this.bin = config?.paseoBin ?? DEFAULT_PASEO_BIN;
this.hostArgs = config?.cliHost ? ['--host', config.cliHost] : [];
}
// ─── Read operations (CLI `ls --json`, `inspect --json`) ──────────────────
/** List all non-archived agents. */
async listAgents(): Promise<PaseoAgentListItem[]> {
const raw = await this.runJson(['ls', '--json', ...this.hostArgs]);
return raw as PaseoAgentListItem[];
}
/** Get detailed status for a single agent by ID or prefix. */
async getAgentStatus(agentId: string): Promise<PaseoAgentDetail> {
const raw = await this.runJson(['inspect', '--json', agentId, ...this.hostArgs]);
return raw as PaseoAgentDetail;
}
/**
* Quick liveness check — runs `paseo ls --json --limit 1` and returns success.
* The daemon is healthy if the CLI exits 0.
*/
async health(): Promise<{ status: string }> {
try {
await this.runCli(['ls', '--json', '--limit', '1', ...this.hostArgs]);
return { status: 'ok' };
} catch {
return { status: 'error' };
}
}
// ─── Write operations (CLI subcommands) ───────────────────────────────────
/**
* Import a provider session as a Paseo agent.
* Uses `paseo import <sessionId> --provider <provider> [--label k=v]`.
*/
async importAgent(
sessionId: string,
provider: string,
labels?: Record<string, string>,
): Promise<PaseoAgentDetail> {
const args: string[] = ['import', '--json', ...this.hostArgs];
if (provider) {
args.push('--provider', provider);
}
if (labels) {
for (const [k, v] of Object.entries(labels)) {
args.push('--label', `${k}=${v}`);
}
}
args.push(sessionId);
const raw = await this.runJson(args);
return raw as PaseoAgentDetail;
}
/** Archive (soft-delete) a Paseo agent by ID or prefix. */
async archiveAgent(agentId: string): Promise<void> {
await this.runCli(['archive', '--json', ...this.hostArgs, agentId]);
}
/**
* Send a prompt to an existing agent.
*
* By default waits for the agent to complete the turn (streams text events
* via the optional `onEvent` callback) and returns the structured result.
* Pass `noWait: true` to fire-and-forget.
*/
async sendPrompt(
agentId: string,
prompt: string,
options?: {
noWait?: boolean;
onEvent?: (event: { type: 'text' | 'reasoning'; text: string }) => void;
signal?: AbortSignal;
},
): Promise<PaseoSendResult> {
const args: string[] = ['send', '--json', ...this.hostArgs];
if (options?.noWait) {
args.push('--no-wait');
}
args.push(agentId, prompt);
// With --json and no --no-wait, the output is JSON after completion.
// For streaming, we read stderr without --json for real-time text.
const raw = await this.runCli(args, options?.signal);
try {
return JSON.parse(raw) as PaseoSendResult;
} catch {
return { text: raw, ok: true };
}
}
/**
* Stream-send: runs `paseo send` WITHOUT `--json`, forward text/reasoning
* lines to onEvent in real time. Use when the caller wants to stream agent
* output as it arrives rather than wait for the full JSON result.
*/
async streamSend(
agentId: string,
prompt: string,
onEvent: (event: { type: 'text' | 'reasoning'; text: string }) => void,
signal?: AbortSignal,
): Promise<PaseoSendResult> {
return new Promise<PaseoSendResult>((resolve, reject) => {
const args = ['send', ...this.hostArgs, agentId, prompt];
const child = spawn(this.bin, args, {
stdio: ['ignore', 'pipe', 'pipe'],
signal,
});
let stdout = '';
let stderr = '';
if (child.stdout) {
const rl = createInterface({ input: child.stdout });
rl.on('line', (line: string) => {
stdout += line + '\n';
// Forward as text event for real-time display
onEvent({ type: 'text', text: line + '\n' });
});
}
if (child.stderr) {
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
}
once(child, 'close').then((raw) => {
const exitCode = (raw[0] as number | null) ?? 0;
if (exitCode !== 0) {
reject(
new PaseoClientError(
`paseo send failed (exit ${exitCode}): ${stderr.trim()}`,
'send',
exitCode,
stderr,
),
);
return;
}
resolve({ text: stdout, ok: true });
});
child.on('error', reject);
});
}
/** Interrupt/stop a running agent. */
async stopAgent(agentId: string): Promise<void> {
await this.runCli(['stop', ...this.hostArgs, agentId]);
}
// ─── Private helpers ───────────────────────────────────────────────────────
/**
* Run a CLI command and return stdout as a string.
* Throws PaseoClientError on non-zero exit.
*/
private async runCli(
args: string[],
signal?: AbortSignal,
): Promise<string> {
return new Promise<string>((resolve, reject) => {
const child = spawn(this.bin, args, {
stdio: ['ignore', 'pipe', 'pipe'],
signal,
});
let stdout = '';
let stderr = '';
if (child.stdout) {
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
}
if (child.stderr) {
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
}
child.on('error', (err: Error) => {
// If signal aborted, treat as cancellation not error
if (signal?.aborted) {
resolve('');
return;
}
reject(err);
});
once(child, 'close').then((raw) => {
const exitCode = (raw[0] as number | null) ?? 0;
if (signal?.aborted) {
resolve('');
return;
}
if (exitCode !== 0) {
const msg = stderr.trim() || `exit code ${exitCode}`;
reject(
new PaseoClientError(
`paseo ${args[0] ?? '?'} failed: ${msg}`,
args[0] ?? '?',
exitCode,
stderr,
),
);
return;
}
resolve(stdout);
});
});
}
/**
* Run a CLI command and parse stdout as JSON.
* Throws PaseoClientError on non-zero exit or parse failure.
*/
private async runJson(args: string[]): Promise<unknown> {
const stdout = await this.runCli(args);
try {
return JSON.parse(stdout);
} catch (err) {
throw new PaseoClientError(
`paseo ${args[0] ?? '?'} returned invalid JSON: ${(stdout || '<empty>').slice(0, 200)}`,
args[0] ?? '?',
0,
stdout,
);
}
}
}

View File

@@ -4,6 +4,8 @@ import { randomBytes } from 'node:crypto';
import type { Sql } from '../db.js';
import { resolveWritePath } from './write_guard.js';
import { locateMatch } from './fuzzy-match.js';
import { conflictIndex } from './conflict-index.js';
import { findConflicts } from './collision-detector.js';
/**
* Write a file atomically: stage to a sibling temp file, then rename over the
@@ -170,6 +172,10 @@ export async function queueEdit(
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent})
RETURNING *
`;
// Register in the conflict index so concurrent worktrees see this edit.
conflictIndex.registerChange(resolved, sessionId, agent ?? 'unknown');
return row!;
}
@@ -216,6 +222,9 @@ export async function queueCreate(
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent})
RETURNING *
`;
conflictIndex.registerChange(resolved, sessionId, agent ?? 'unknown');
return row!;
}
@@ -238,6 +247,9 @@ export async function queueDelete(
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent})
RETURNING *
`;
conflictIndex.registerChange(resolved, sessionId, agent ?? 'unknown');
return row!;
}
@@ -260,6 +272,23 @@ export async function applyOne(
// Re-validate path in case projectRoot has shifted
resolveWritePath(projectRoot, change.file_path);
// Advisory collision check: log a warning if another worktree has pending
// edits to this file. Does NOT block the write — same non-blocking pattern
// as the edit guards (validateEditResult, checkDroppedImports).
{
const conflicts = conflictIndex.query(
[change.file_path],
change.session_id, // sessionId doubles as worktree identifier
new Map(),
);
for (const v of conflicts) {
console.log(
`[collision] ${v.filePath} — conflict with worktrees [${v.worktrees.join(', ')}] ` +
`agents [${v.agents.join(', ')}] severity=${v.severity}`,
);
}
}
switch (change.operation) {
case 'create': {
await mkdir(dirname(change.file_path), { recursive: true });

View File

@@ -1,7 +1,12 @@
# apps/server — BooChat backend (deep reference)
# apps/server — BooChat backend (deep reference) — v2.7.x (last meaningful update: 2026-06)
> Per-app engineering notes for `apps/server/src/`. Cross-cutting commands, database, environment, workflow, and cross-app contracts (WS-frame / provider-type parity, sentinels) live in the **root `CLAUDE.md`**. This file auto-loads when you read/edit files under `apps/server/`.
## These gotchas are load-bearing — do not remove or refactor without understanding why
- Do NOT remove the abort-signal pinning comment in `stream-phase.ts``fullStream` exits cleanly on abort without throwing; the post-iteration `if (signal?.aborted)` check is the only thing that distinguishes cancelled from complete.
- Do NOT remove `includeUsage: true` from `provider.ts` — the adapter defaults it false; without it, token counts are always NULL.
- Do NOT add raw `broker.publish()`/`publishUser()` calls — always use `publishFrame`/`publishUserFrame` which Zod-validate against `WsFrameSchema`.
## Stack
- **Fastify** with `@fastify/websocket` and `@fastify/static` (serves the built frontend).

View File

@@ -21,10 +21,11 @@ import { registerSkillsRoutes } from './routes/skills.js';
import { registerTraceRoutes } from './routes/traces.js';
import { registerToolsRoutes } from './routes/tools.js';
import { registerAnalyticsRoutes } from './routes/analytics.js';
import { registerMemoryRoutes } from './routes/memory.js';
import { registerInferenceSettingsRoutes } from './routes/inference-settings.js';
import { createInferenceRunner } from './services/inference/index.js';
import { createInferenceRunner, runInferenceWithModel } from './services/inference/index.js';
import { createBroker } from './services/broker.js';
import { setBackgroundInferenceEnqueuer } from './services/background-task.js';
import { listSkills } from './services/skills.js';
import * as compaction from './services/compaction.js';
import { configureModelContext } from './services/model-context.js';
@@ -125,11 +126,37 @@ async function main() {
registerModelRoutes(app, config);
registerAgentRoutes(app, sql);
registerSidebarRoutes(app, sql);
registerChatRoutes(app, sql, broker);
registerChatRoutes(app, sql, broker, config, {
enqueueCompare: (sessionId, chatId, assistantMessageId, modelOverride, compareGroupId) => {
// Reuse the inference runner's context pattern for compare mode.
// Each compare run gets its own AbortController; cancellation keyed by
// chatId (cancels ALL parallel runs in that compare group).
const compareCtx: import('./services/inference/types.js').InferenceContext = {
sql,
config,
log: app.log,
publish: (sid, frame) => {
broker.publishFrame(sid, frame as unknown as import('@boocode/contracts/ws-frames').WsFrame);
},
publishUser: (frame) => {
broker.publishUserFrame('default', frame as unknown as import('@boocode/contracts/ws-frames').WsFrame);
},
broker,
hooks: hasHooks ? hookRunner : undefined,
};
compareCtx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'streaming', at: new Date().toISOString() });
void runInferenceWithModel(compareCtx, sessionId, chatId, assistantMessageId, modelOverride, compareGroupId).catch(
(err: Error) => app.log.error({ err, chatId, modelOverride }, 'compare inference failed'),
);
},
cancelInference: async (_sessionId, chatId) => {
return inference.cancel(_sessionId, chatId);
},
hasActiveInference: (chatId) => inference.hasActive(chatId),
});
registerTraceRoutes(app, sql);
registerToolsRoutes(app, sql);
registerAnalyticsRoutes(app, sql);
registerMemoryRoutes(app, sql);
registerInferenceSettingsRoutes(app);
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
@@ -167,6 +194,13 @@ async function main() {
broker.publishUserFrame(user, frame as unknown as import('@boocode/contracts/ws-frames').WsFrame);
}
);
// v2.x: wire the background subagent task system to the inference runner.
// Tools (spawn_subagent) dispatch fire-and-forget inference via this
// module-level reference — no import cycle through the tool registry.
setBackgroundInferenceEnqueuer((sessionId, chatId, assistantId, user) => {
inference.enqueue(sessionId, chatId, assistantId, user);
});
registerMessageRoutes(app, sql, config, broker, {
enqueueInference: (sessionId, chatId, assistantId, user) => {
inference.enqueue(sessionId, chatId, assistantId, user);

View File

@@ -1,18 +1,33 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import crypto from 'node:crypto';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js';
import type { Chat, Message } from '../types/api.js';
import { getModelContext } from '../services/model-context.js';
import { notifyCoderClose } from '../services/coder-notify.js';
import { MESSAGE_COLUMNS } from '../services/message-columns.js';
import { formatJson, formatMarkdown } from '../services/export-formatter.js';
export interface CompareHandlers {
enqueueCompare: (
sessionId: string,
chatId: string,
assistantMessageId: string,
modelOverride: string,
compareGroupId: string,
) => void;
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
hasActiveInference: (chatId: string) => boolean;
}
const CreateBody = z.object({
name: z.string().min(1).max(200).optional(),
});
const PatchBody = z.object({
name: z.string().min(1).max(200),
name: z.string().min(1).max(200).optional(),
model: z.string().min(1).optional(),
});
const ForkBody = z.object({
@@ -26,10 +41,17 @@ const DiscardStaleBody = z.object({
const STALE_MIN_AGE_SECONDS = 60;
const CompareBody = z.object({
message: z.string().min(1).max(64_000),
models: z.array(z.string().min(1)).min(2).max(3),
});
export function registerChatRoutes(
app: FastifyInstance,
sql: Sql,
broker: Broker
broker: Broker,
config?: Config,
compareHandlers?: CompareHandlers,
): void {
app.get<{ Params: { id: string }; Querystring: { status?: string } }>(
'/api/sessions/:id/chats',
@@ -122,12 +144,15 @@ export function registerChatRoutes(
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { name, model } = parsed.data;
const sets: Array<ReturnType<typeof sql>> = [sql`updated_at = clock_timestamp()`];
if (name !== undefined) sets.push(sql`name = ${name}`);
if (model !== undefined) sets.push(sql`model = ${model}`);
const rows = await sql<Chat[]>`
UPDATE chats
SET name = ${parsed.data.name},
updated_at = clock_timestamp()
SET ${(sql as any).join(sets, sql`, `)}
WHERE id = ${req.params.id}
RETURNING id, session_id, name, status, created_at, updated_at
RETURNING id, session_id, name, model, status, created_at, updated_at
`;
if (rows.length === 0) {
reply.code(404);
@@ -448,4 +473,128 @@ export function registerChatRoutes(
return rows;
}
);
app.get<{ Params: { id: string }; Querystring: { format?: string } }>(
'/api/chats/:id/export',
async (req, reply) => {
const format = req.query.format ?? 'json';
if (format !== 'json' && format !== 'markdown') {
reply.code(400);
return { error: 'format must be json or markdown' };
}
const chat = await sql<Chat[]>`SELECT * FROM chats WHERE id = ${req.params.id}`;
if (chat.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const messages = await sql<Message[]>`
SELECT ${sql.unsafe(MESSAGE_COLUMNS)}
FROM messages_with_parts
WHERE chat_id = ${req.params.id}
ORDER BY created_at ASC, id ASC
`;
if (format === 'markdown') {
reply.header('Content-Type', 'text/markdown');
return formatMarkdown(chat[0]!, messages, chat[0]!.model);
}
reply.header('Content-Type', 'application/json');
return formatJson(chat[0]!, messages, chat[0]!.model);
}
);
// v2.8-compare: send the same message to N models and stream back parallel
// responses. Creates N assistant messages (one per model) and launches N
// parallel inference runs with model overrides. Each publishes frames
// scoped to the shared compare_group_id so the frontend can group them.
if (config && compareHandlers) {
app.post<{ Params: { id: string } }>(
'/api/chats/:id/compare',
async (req, reply) => {
const parsed = CompareBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { message, models } = parsed.data;
// Check for active inference first.
if (compareHandlers.hasActiveInference(req.params.id)) {
reply.code(409);
return { error: 'chat is currently streaming; stop it first' };
}
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
const compareGroupId = crypto.randomUUID();
// Insert user message + N assistant messages in a single transaction.
const result = await sql.begin(async (tx) => {
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at, metadata)
VALUES (${sessionId}, ${chat.id}, 'user', ${message}, 'complete', clock_timestamp(), NULL)
RETURNING id
`;
const responses: Array<{ model: string; assistant_message_id: string }> = [];
for (const model of models) {
const [asst] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at, metadata)
VALUES (
${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp(),
${tx.json({ compare_group_id: compareGroupId, model } as never)}
)
RETURNING id
`;
responses.push({ model, assistant_message_id: asst!.id });
}
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return { user_message_id: userMsg!.id, responses };
});
// Publish user message frames.
broker.publishFrame(sessionId, {
type: 'message_started',
message_id: result.user_message_id,
chat_id: chat.id,
role: 'user',
});
broker.publishFrame(sessionId, {
type: 'delta',
message_id: result.user_message_id,
chat_id: chat.id,
content: message,
});
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: result.user_message_id,
chat_id: chat.id,
});
// Enqueue N parallel inference runs with model overrides.
for (const resp of result.responses) {
compareHandlers.enqueueCompare(
sessionId, chat.id, resp.assistant_message_id, resp.model, compareGroupId,
);
}
reply.code(202);
return { compare_group_id: compareGroupId, ...result };
},
);
}
}

View File

@@ -3,12 +3,13 @@ import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js';
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
import type { Chat, Message, MessageMetadata, Session, ToolCall } from '../types/api.js';
// v1.13.17-cross-repo-reads: grant_read_access resolves the grant root at
// decision time (not at request time) so concurrent project changes don't
// stale-bind the resolution.
import { resolveGrantRoot } from '../services/grant_resolver.js';
import { MESSAGE_COLUMNS } from '../services/message-columns.js';
import { setServerPermission, getServerName } from '../services/mcp-client.js';
// Shared lookup for the answer_user_input + grant_read_access pause-resume
// endpoints. Finds the originating assistant tool_call by id in message_parts,
@@ -846,4 +847,117 @@ export function registerMessageRoutes(
};
},
);
// v1.15.0-mcp-permission: approve/deny MCP tool calls for 'ask' state servers.
const McpApproveBody = z.object({
tool_call_id: z.string().min(1),
permission: z.enum(['allow_once', 'allow_always', 'deny']),
});
app.post<{ Params: { id: string } }>(
'/api/chats/:id/mcp-approve',
async (req, reply) => {
const parsed = McpApproveBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { tool_call_id, permission } = parsed.data;
const chatRows = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat_not_found' };
}
// Look up the tool call to get the prefixed tool name
const callerRows = await sql<{
payload: { name: string };
}[]>`
SELECT p.payload
FROM message_parts p
JOIN messages m ON m.id = p.message_id
WHERE m.chat_id = ${req.params.id}
AND m.role = 'assistant'
AND p.kind = 'tool_call'
AND p.payload->>'id' = ${tool_call_id}
ORDER BY m.created_at DESC
LIMIT 1
`;
const callerRow = callerRows[0];
if (!callerRow) {
reply.code(404);
return { error: 'tool_call_not_found' };
}
const toolName = callerRow.payload.name;
const serverName = getServerName(toolName);
if (!serverName) {
reply.code(400);
return { error: 'not_an_mcp_tool', detail: `tool '${toolName}' is not from an MCP server` };
}
if (permission === 'allow_always' || permission === 'allow_once') {
setServerPermission(serverName, 'allow');
} else if (permission === 'deny') {
setServerPermission(serverName, 'deny');
}
return { ok: true };
},
);
const FeedbackBody = z.object({
value: z.enum(['up', 'down']),
});
app.post<{ Params: { id: string; message_id: string } }>(
'/api/chats/:id/messages/:message_id/feedback',
async (req, reply) => {
const parsed = FeedbackBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { id: chatId, message_id: messageId } = req.params;
const { value } = parsed.data;
const msg = await sql<{ id: string; role: string; metadata: MessageMetadata | null }[]>`
SELECT id, role, metadata FROM messages WHERE id = ${messageId} AND chat_id = ${chatId}
`;
if (msg.length === 0) {
reply.code(404);
return { error: 'message not found' };
}
// Only allow feedback on assistant messages.
if (msg[0]!.role !== 'assistant') {
reply.code(400);
return { error: 'only assistant messages can receive feedback' };
}
// Check if feedback already exists
const existingMeta = msg[0]!.metadata;
if (existingMeta && existingMeta.kind === 'feedback') {
reply.code(409);
return { error: 'feedback already recorded' };
}
const feedbackMeta: MessageMetadata = {
kind: 'feedback',
value,
chat_id: chatId,
};
await sql`
UPDATE messages
SET metadata = ${sql.json(feedbackMeta as never)}, updated_at = clock_timestamp()
WHERE id = ${messageId}
`;
return { ok: true };
},
);
}

View File

@@ -145,7 +145,7 @@ export function registerSessionRoutes(
}
const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths, state_graph_enabled
FROM sessions
WHERE project_id = ${req.params.id} AND status = ${status}
ORDER BY updated_at DESC
@@ -213,7 +213,7 @@ export function registerSessionRoutes(
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths, state_graph_enabled
FROM sessions WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
@@ -349,10 +349,10 @@ export function registerSessionRoutes(
const rows = await sql<Session[]>`
UPDATE sessions
SET workspace_panes = ${sql.json(envelope as never)},
updated_at = clock_timestamp()
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled, workspace_panes, allowed_read_paths
agent_id, web_search_enabled, workspace_panes, allowed_read_paths, state_graph_enabled
`;
if (rows.length === 0) {
reply.code(404);

View File

@@ -234,6 +234,7 @@ ALTER TABLE sessions ADD COLUMN IF NOT EXISTS workspace_panes JSONB NOT NULL DEF
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
-- v1.2: chats table
-- per-chat-model-switching v2.x: ALTER below adds the model override column.
CREATE TABLE IF NOT EXISTS chats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
@@ -244,6 +245,9 @@ CREATE TABLE IF NOT EXISTS chats (
);
CREATE INDEX IF NOT EXISTS idx_chats_session_status ON chats (session_id, status, updated_at DESC);
-- v2.7.x: per-chat model override. NULL = inherit from session.model.
ALTER TABLE chats ADD COLUMN IF NOT EXISTS model TEXT;
-- v1.2: messages.chat_id + messages.kind
ALTER TABLE messages ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'message';
@@ -320,6 +324,9 @@ BEGIN
END IF;
END $$;
-- per-chat-model-switching: per-chat model override. NULL = inherit from session model.
ALTER TABLE chats ADD COLUMN IF NOT EXISTS model TEXT;
-- v1.x-batch9: per-session agent reference. Agent definitions are not stored in
-- the DB; they live in builtins (services/agents.ts) and a per-project AGENTS.md.
-- agent_id is the slugified agent name. NULL means "use BooCode defaults".
@@ -355,6 +362,11 @@ INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (k
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
-- v[state-graph]: optional declarative state-graph engine flag. Default OFF
-- (existing procedural while loop). When ON, runAssistantTurn routes
-- through runGraph in state-graph.ts for node-based execution.
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS state_graph_enabled BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE sessions DROP COLUMN IF EXISTS tags;
-- v1.11: anchored rolling compaction.

View File

@@ -0,0 +1,260 @@
// v2.x: Background subagent task service.
// Creates and tracks background tasks that run as independent inference
// sessions. The spawner creates a session+chat, inserts messages, and
// dispatches inference asynchronously. Callers poll status and retrieve
// results via the companion tools (background-subagent-tools.ts).
//
// Module-level inference enqueuer: set at server startup so tools can
// dispatch background inference without importing the runner directly.
import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify';
export interface BackgroundTask {
id: string;
session_id: string;
chat_id: string;
agent: string | null;
model: string;
input: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
output_summary: string | null;
created_at: string;
finished_at: string | null;
}
// Module-level reference to the inference enqueuer, set at server startup.
let _enqueueInference:
| ((sessionId: string, chatId: string, assistantMessageId: string, user: string) => void)
| null = null;
export function setBackgroundInferenceEnqueuer(
enqueue: (
sessionId: string,
chatId: string,
assistantMessageId: string,
user: string,
) => void,
): void {
_enqueueInference = enqueue;
}
function mapTaskState(state: string): BackgroundTask['status'] {
switch (state) {
case 'pending':
return 'pending';
case 'running':
return 'running';
case 'completed':
return 'completed';
case 'failed':
return 'failed';
case 'blocked':
return 'pending'; // blocked is internal — surface as pending
case 'cancelled':
return 'cancelled';
default:
return 'pending';
}
}
// Spawn a background subagent task: create session + chat + messages + tasks
// row, then fire-and-forget the inference. Returns immediately with the task
// metadata — inference runs asynchronously.
export async function spawnBackgroundTask(
sql: Sql,
log: FastifyBaseLogger,
projectId: string,
input: string,
model: string,
agent?: string,
label?: string,
): Promise<BackgroundTask> {
const sessionName =
label != null && label.length > 0
? `Subagent: ${label}`
: `Background: ${input.slice(0, 50)}${input.length > 50 ? '...' : ''}`;
const result = await sql.begin(async (tx) => {
// 1. Create session for the background task
const [sess] = await tx<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, system_prompt)
VALUES (${projectId}, ${sessionName}, ${model}, '')
RETURNING id
`;
const sessionId = sess!.id;
// 2. Create chat in that session
const [ch] = await tx<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, ${label ?? null}, 'open')
RETURNING id
`;
const chatId = ch!.id;
// 3. Insert user message with the task input
await tx`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${input}, 'complete', clock_timestamp())
`;
// 4. Insert streaming assistant message (inference fills it)
const [assistantRow] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
const assistantMessageId = assistantRow!.id;
// 5. Insert tasks row for tracking
const [task] = await tx<{ id: string; created_at: string }[]>`
INSERT INTO tasks (project_id, session_id, state, input, agent, model)
VALUES (${projectId}, ${sessionId}, 'running', ${input}, ${agent ?? null}, ${model})
RETURNING id, created_at
`;
return { sessionId, chatId, assistantMessageId, task: task! };
});
// After the transaction commits, fire-and-forget inference dispatch.
if (_enqueueInference) {
try {
_enqueueInference(result.sessionId, result.chatId, result.assistantMessageId, 'default');
} catch (err) {
log.warn(
{ err, taskId: result.task.id },
'background inference enqueue failed',
);
}
}
log.info(
{
taskId: result.task.id,
sessionId: result.sessionId,
chatId: result.chatId,
model,
agent,
},
'spawned background subagent task',
);
return {
id: result.task.id,
session_id: result.sessionId,
chat_id: result.chatId,
agent: agent ?? null,
model,
input,
status: 'running',
output_summary: null,
created_at: result.task.created_at,
finished_at: null,
};
}
// Look up a background task by its tasks.id. Includes the status from the
// tasks table and the chat_id from the linked chat.
export async function getBackgroundTaskStatus(
sql: Sql,
taskId: string,
): Promise<BackgroundTask | null> {
const rows = await sql<
{
id: string;
session_id: string;
state: string;
input: string;
agent: string | null;
model: string | null;
output_summary: string | null;
created_at: string;
ended_at: string | null;
}[]
>`
SELECT id, session_id, state, input, agent, model, output_summary, created_at, ended_at
FROM tasks
WHERE id = ${taskId}
`;
if (rows.length === 0) return null;
const r = rows[0]!;
// Find the chat_id from the session (background sessions have exactly one chat).
const chatRows = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${r.session_id} LIMIT 2
`;
return {
id: r.id,
session_id: r.session_id,
chat_id: chatRows[0]?.id ?? '',
agent: r.agent,
model: r.model ?? '',
input: r.input,
status: mapTaskState(r.state),
output_summary: r.output_summary,
created_at: r.created_at,
finished_at: r.ended_at,
};
}
// Retrieve the full output and token usage from a completed background task.
// Returns null if the task has no completed assistant message.
export async function getBackgroundTaskResult(
sql: Sql,
taskId: string,
chatId: string,
): Promise<{
output: string;
token_usage: { prompt: number; completion: number } | null;
} | null> {
// Verify the task exists and chatId belongs to it.
const taskRows = await sql<{ session_id: string }[]>`
SELECT session_id FROM tasks WHERE id = ${taskId}
`;
if (taskRows.length === 0) return null;
// Read the last complete assistant message (the one with content).
const msgRows = await sql<
{
content: string;
tokens_used: number | null;
ctx_used: number | null;
}[]
>`
SELECT content, tokens_used, ctx_used
FROM messages
WHERE chat_id = ${chatId}
AND role = 'assistant'
AND status = 'complete'
AND content <> ''
ORDER BY created_at DESC
LIMIT 1
`;
if (msgRows.length === 0) return null;
const m = msgRows[0]!;
return {
output: m.content,
token_usage:
m.tokens_used != null || m.ctx_used != null
? { prompt: m.ctx_used ?? 0, completion: m.tokens_used ?? 0 }
: null,
};
}
// Cancel a pending or running background task. Returns true if a row was
// actually updated (the task existed and was in a cancellable state).
export async function cancelBackgroundTask(
sql: Sql,
taskId: string,
): Promise<boolean> {
const rows = await sql<{ id: string }[]>`
UPDATE tasks
SET state = 'cancelled', ended_at = clock_timestamp()
WHERE id = ${taskId}
AND state IN ('pending', 'running')
RETURNING id
`;
return rows.length > 0;
}

View File

@@ -0,0 +1,93 @@
import type { Chat, Message } from '../types/api.js';
interface ExportMessage {
role: string;
content: string;
model: string | null;
created_at: string;
tokens_used: number | null;
status: string;
kind: string;
tool_calls: Record<string, unknown>[] | null;
}
interface ExportJson {
chat: {
id: string;
name: string | null;
model: string | null;
created_at: string;
};
messages: ExportMessage[];
}
export function formatJson(
chat: Chat,
messages: Message[],
model: string | null,
): string {
const data: ExportJson = {
chat: {
id: chat.id,
name: chat.name,
model,
created_at: chat.created_at,
},
messages: messages.map((m) => ({
role: m.role,
content: m.content,
model: m.model ?? null,
created_at: m.created_at,
tokens_used: m.tokens_used,
status: m.status,
kind: m.kind,
tool_calls: m.tool_calls as Record<string, unknown>[] | null,
})),
};
return JSON.stringify(data, null, 2);
}
export function formatMarkdown(
chat: Chat,
messages: Message[],
model: string | null,
): string {
const parts: string[] = [];
parts.push(`# ${chat.name ?? 'Untitled Chat'}`);
parts.push(`Model: ${model ?? 'unknown'}`);
parts.push('');
parts.push('---');
parts.push('');
for (const msg of messages) {
// Skip system/sentinel messages for a cleaner transcript
if (msg.role === 'system') continue;
const label =
msg.role === 'user'
? 'User'
: msg.role === 'assistant'
? 'Assistant'
: 'Tool';
parts.push(`## ${label}`);
parts.push('');
if (msg.content) {
parts.push(msg.content);
parts.push('');
}
if (msg.tool_calls && msg.tool_calls.length > 0) {
for (const tc of msg.tool_calls) {
parts.push(`> \`${tc.name}\``);
parts.push('');
parts.push('```json');
parts.push(JSON.stringify(tc.args, null, 2));
parts.push('```');
parts.push('');
}
}
}
return parts.join('\n');
}

View File

@@ -0,0 +1,132 @@
/**
* Compact unified-diff generator for write-tool results.
*
* Produces a minimal unified diff string (---/+++ header + +/- lines) from
* old/new text pairs so the frontend can render an inline diff snippet
* without pulling in a full diff library.
*/
// Write-tool names that can produce file diffs.
export const WRITE_TOOL_NAMES = new Set([
'edit_file',
'create_file',
'delete_file',
'apply_pending',
]);
/**
* Compute a compact unified diff from old → new text.
*
* @param oldStr The original text (empty for creates)
* @param newStr The replacement text (empty for deletes)
* @param filePath Display path for the file header
* @returns A unified-diff string, or empty string if old === new
*/
export function computeDiff(oldStr: string, newStr: string, filePath: string): string {
if (oldStr === newStr) return '';
const oldLines = oldStr.split('\n');
const newLines = newStr.split('\n');
// For empty old → new file (create), show all lines as additions
if (oldStr.length === 0 && newStr.length > 0) {
const header = `--- /dev/null\n+++ b/${filePath}\n`;
const body = newLines.map((line) => `+${line}`).join('\n');
return header + body;
}
// For old → empty (delete), show all lines as removals
if (newStr.length === 0 && oldStr.length > 0) {
const header = `--- a/${filePath}\n+++ /dev/null\n`;
const body = oldLines.map((line) => `-${line}`).join('\n');
return header + body;
}
// Simple line-by-line diff for edit: collect changed lines with context.
// Uses a straightforward algorithm: find the first differing line and the
// last differing line, then output the block with +/- markers.
const header = `--- a/${filePath}\n+++ b/${filePath}\n`;
const maxLen = Math.max(oldLines.length, newLines.length);
let firstDiff = -1;
let lastDiff = -1;
for (let i = 0; i < maxLen; i++) {
const a = i < oldLines.length ? oldLines[i] : undefined;
const b = i < newLines.length ? newLines[i] : undefined;
if (a !== b) {
if (firstDiff === -1) firstDiff = i;
lastDiff = i;
}
}
if (firstDiff === -1) return '';
// Add context lines around the changed block (up to 2 lines each side)
const contextBefore = 2;
const contextAfter = 2;
const start = Math.max(0, firstDiff - contextBefore);
const end = Math.min(maxLen - 1, lastDiff + contextAfter);
// Build the unified diff hunk
const hunkLines: string[] = [];
const hunkOldStart = start + 1; // 1-indexed
const hunkNewStart = start + 1;
const hunkOldLen = end - start + 1;
const hunkNewLen = end - start + 1;
for (let i = start; i <= end; i++) {
const oldLine = i < oldLines.length ? oldLines[i] : undefined;
const newLine = i < newLines.length ? newLines[i] : undefined;
if (oldLine === newLine) {
hunkLines.push(` ${oldLine ?? ''}`);
} else {
if (oldLine !== undefined) {
hunkLines.push(`-${oldLine}`);
}
if (newLine !== undefined) {
hunkLines.push(`+${newLine}`);
}
}
}
const hunkHeader = `@@ -${hunkOldStart},${hunkOldLen} +${hunkNewStart},${hunkNewLen} @@\n`;
return header + hunkHeader + hunkLines.join('\n');
}
/**
* Check whether a tool name corresponds to a file-modifying write tool
* that should produce a diff in its tool result.
*/
export function isWriteTool(name: string): boolean {
return WRITE_TOOL_NAMES.has(name);
}
/**
* Extract a diff string from tool call args for write tools.
* Returns empty string if the tool doesn't produce diffs or args are missing.
*/
export function diffFromToolArgs(name: string, args: Record<string, unknown>, filePath?: string): string {
switch (name) {
case 'edit_file': {
const oldStr = String(args.old_string ?? '');
const newStr = String(args.new_string ?? '');
const path = filePath ?? String(args.file_path ?? 'file');
return computeDiff(oldStr, newStr, path);
}
case 'create_file': {
const content = String(args.content ?? '');
const path = filePath ?? String(args.file_path ?? 'file');
return computeDiff('', content, path);
}
case 'delete_file':
// No content available at queue time — actual content is read at apply time.
return '';
case 'apply_pending':
// Meta-tool — individual changes produce their own diffs.
return '';
default:
return '';
}
}

View File

@@ -74,6 +74,7 @@ export async function handleAbortOrError(
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
});
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
} else {
@@ -90,6 +91,7 @@ export async function handleAbortOrError(
chat_id: chatId,
error: errMsg,
reason: 'llm_provider_error',
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
});
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
}
@@ -125,6 +127,7 @@ export async function finalizeStreamedRow(
cacheTokens?: number | null;
reasoningTokens?: number | null;
beforeComplete?: () => Promise<void>;
compareGroupId?: string;
},
): Promise<void> {
// v1.11.3: see executeToolPhase for the rationale.
@@ -158,6 +161,7 @@ export async function finalizeStreamedRow(
started_at: opts.startedAt,
finished_at: updated?.finished_at ?? null,
model: opts.model,
...(opts.compareGroupId ? { compare_group_id: opts.compareGroupId } : {}),
});
}
@@ -182,6 +186,7 @@ export async function finalizeEmpty(
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
});
}
@@ -281,6 +286,7 @@ export async function finalizeCompletion(
started_at: startedAt,
finished_at: updated?.finished_at ?? null,
model: session.model,
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
});
ctx.log.info(
{

View File

@@ -8,6 +8,7 @@ export {
createInferenceRunner,
MAX_STEPS,
runInference,
runInferenceWithModel,
} from './turn.js';
// P5: the shared pipeline types moved from turn.ts to types.ts (breaking the
// hub-and-leaf near-cycle). Re-exported here so the public surface is unchanged.
@@ -21,3 +22,4 @@ export type {
export type { ToolPhaseResult } from './tool-phase.js';
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
export { buildMessagesPayload } from './payload.js';
export { runGraph, type GraphNodeType, type GraphState, type GraphResult } from './state-graph.js';

View File

@@ -0,0 +1,56 @@
// vDeepSeek (stub): multi-modal (image) attachment support.
//
// When a message carries images, DeepSeek V4 models can process them
// natively via the @ai-sdk/deepseek provider. This module provides the
// helper types and functions to detect and convert image attachments.
//
// FULL INTEGRATION requires:
// 1. Storing image data alongside messages (message_parts with kind='image'
// or a dedicated attachments table with base64-encoded data).
// 2. Extending OpenAiMessage.content from `string | null` to
// `string | null | Array<{ type: 'text'; text: string } | { type: 'image'; image: string }>`
// in apps/server/src/services/inference/payload.ts.
// 3. Updating toModelMessages() in stream-phase-adapter.ts to emit AI SDK
// content arrays with image parts for multimodal user messages.
//
// None of the above is done yet — this file is a type scaffold.
import type { Message } from '../../types/api.js';
/** Shape of a decoded image attachment ready for the AI SDK. */
export interface ImageAttachment {
/** Base64-encoded image data (no data URI prefix — raw bytes). */
data: string;
/** MIME type (e.g. 'image/png', 'image/jpeg', 'image/webp'). */
mimeType: string;
}
/**
* Check if a user message has image content that can be forwarded to a
* multimodal model. Currently a stub — always returns false until the
* message-pipeline stores image attachments addressably.
*/
export function hasImageAttachments(_message: Message): boolean {
// TODO(vDeepSeek): scan message_parts for kind='image' or inspect
// message.content for inline data URIs (data:image/...).
return false;
}
/**
* Convert internal image attachments to the format expected by the AI SDK
* ModelMessage content array.
*
* The @ai-sdk/deepseek provider accepts images as:
* { type: 'image'; image: 'data:image/png;base64,...' }
*
* @param attachments — List of decoded image attachments.
* @returns AI SDK inline file parts suitable for ModelMessage.content.
*/
export function imageAttachmentsToParts(
attachments: ImageAttachment[],
): Array<{ type: 'image'; image: string }> {
return attachments.map((a) => ({
type: 'image' as const,
image: `data:${a.mimeType};base64,${a.data}`,
}));
}

View File

@@ -194,6 +194,14 @@ export async function buildMessagesPayload(
out.push(msg);
continue;
}
// TODO(vDeepSeek): when m has image attachments, use a content array
// with text + image parts (see multi-modal.ts:imageAttachmentsToParts).
// The AI SDK ModelMessage content shape supports:
// content: [
// { type: 'text', text: '...' },
// { type: 'image', image: 'data:image/png;base64,...' }
// ]
// The @ai-sdk/deepseek provider handles the image parts natively.
out.push({ role: 'user', content: m.content });
}
return out;
@@ -206,7 +214,7 @@ export async function loadContext(
): Promise<{ session: Session; project: Project; history: Message[] } | null> {
const sessionRows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled, allowed_read_paths
agent_id, web_search_enabled, allowed_read_paths, state_graph_enabled
FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) return null;

View File

@@ -0,0 +1,531 @@
// P5: Optional declarative state graph engine for the inference turn loop.
//
// Replaces the procedural `while (stepNumber < effectiveCap)` in turn.ts
// with a node-based execution model. Default OFF via
// session.state_graph_enabled — zero behavior change when disabled.
//
// Nodes wrap EXISTING infrastructure (no new I/O patterns):
// PLAN → top-of-loop gate, compaction, loadContext, buildMessagesPayload,
// executeStreamPhase
// CALL_TOOL → executeToolPhase
// OBSERVE → process tool results, update loop locals
// REFLECT → decidePostToolAction, sentinel insertion, mistake tracker
// SYNTHESIZE → terminal (graph loop exits)
import type { Agent, Project, Session, ToolCall } from '../../types/api.js';
import { resolveProjectRoot } from '../path_guard.js';
import { rewriteSearchQuery } from '../task-search-rewrite.js';
import * as compaction from '../compaction.js';
import { decideStep, decidePostToolAction } from './step-decision.js';
import {
recordStep,
MISTAKE_RECOVERY_NOTE,
type MistakeState,
} from './mistake-tracker.js';
import {
buildMessagesPayload,
loadContext,
} from './payload.js';
import { toDcpMessages, transformMessages, fromDcpMessages } from './dcp/index.js';
import {
finalizeCompletion,
finalizeEmpty,
handleAbortOrError,
} from './error-handler.js';
import {
executeStreamPhase,
} from './stream-phase.js';
import { executeToolPhase, type ToolPhaseResult } from './tool-phase.js';
import type {
InferenceContext,
StreamPhaseState,
StreamResult,
TurnArgs,
} from './types.js';
import {
runCapHitSummary,
runDoomLoopSummary,
insertMistakeRecoverySentinel,
} from './sentinel-summaries.js';
import { execFile } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
const BUILD_TIMEOUT_MS = 60_000;
const BUILD_OUTPUT_CAP = 8_000;
async function detectAndRunBuild(
ctx: InferenceContext,
projectRoot: string,
sessionId: string,
chatId: string,
model: string,
existingNote: string | undefined,
): Promise<string | undefined> {
if (!model.startsWith('deepseek-')) return undefined;
const pkgPath = join(projectRoot, 'package.json');
if (!existsSync(pkgPath)) return undefined;
let buildCmd: string | null = null;
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> };
if (pkg.scripts?.build) buildCmd = 'build';
else if (pkg.scripts?.compile) buildCmd = 'compile';
else if (pkg.scripts?.typecheck) buildCmd = 'typecheck';
} catch {
return undefined;
}
if (!buildCmd) return undefined;
const hasPnpm = existsSync(join(projectRoot, 'pnpm-lock.yaml'));
const hasYarn = existsSync(join(projectRoot, 'yarn.lock'));
const pm = hasPnpm ? 'pnpm' : hasYarn ? 'yarn' : 'npm';
try {
const out = await new Promise<string>((resolve, reject) => {
execFile(pm, ['run', buildCmd!], { cwd: projectRoot, timeout: BUILD_TIMEOUT_MS, maxBuffer: BUILD_OUTPUT_CAP * 2 },
(err, stdout, stderr) => {
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
resolve('');
return;
}
const merged = (stdout + '\n' + stderr).trim();
resolve(merged.slice(0, BUILD_OUTPUT_CAP));
},
);
});
if (!out) return undefined;
ctx.log.info({ sessionId, chatId, buildCmd, outputLen: out.length }, 'auto-fix: build failed');
const combined = existingNote
? existingNote + '\n\n--- Build error ---\n' + out.slice(0, BUILD_OUTPUT_CAP - existingNote.length)
: '--- Build error ---\n' + out.slice(0, BUILD_OUTPUT_CAP);
return combined;
} catch {
return undefined;
}
}
// -- Types ----------------------------------------------------------------
export type GraphNodeType = 'PLAN' | 'CALL_TOOL' | 'OBSERVE' | 'REFLECT' | 'SYNTHESIZE';
export interface GraphState {
stepNumber: number;
toolsUsed: number;
recentToolCalls: ToolCall[];
assistantMessageId: string;
mistakeTracker: MistakeState;
pendingRecoveryNote?: string;
effectiveCap: number;
budget: number;
projectRoot: string;
iterSession?: Session;
iterProject?: Project;
streamResult?: StreamResult;
startedAt?: string | null;
toolPhaseResult?: ToolPhaseResult;
shouldStop: boolean;
}
interface GraphNode {
type: GraphNodeType;
edges: Array<{ to: GraphNodeType; condition: (state: GraphState) => boolean }>;
execute: (
ctx: InferenceContext,
args: TurnArgs,
state: GraphState,
agent: Agent | null,
) => Promise<void>;
}
export interface GraphResult {
stepNumber: number;
assistantMessageId: string;
toolsUsed: number;
recentToolCalls: ToolCall[];
mistakeTracker: MistakeState;
}
// -- Default graph --------------------------------------------------------
export function createDefaultGraph(): GraphNode[] {
return [
{
type: 'PLAN',
edges: [
{ to: 'CALL_TOOL', condition: (s) => !!s.streamResult && s.streamResult.toolCalls.length > 0 },
{ to: 'SYNTHESIZE', condition: () => true },
],
execute: planNode,
},
{
type: 'CALL_TOOL',
edges: [
{ to: 'OBSERVE', condition: () => true },
],
execute: callToolNode,
},
{
type: 'OBSERVE',
edges: [
{ to: 'REFLECT', condition: (s) => s.toolPhaseResult?.action === 'continue' },
{ to: 'SYNTHESIZE', condition: () => true },
],
execute: observeNode,
},
{
type: 'REFLECT',
edges: [
{ to: 'PLAN', condition: (s) => s.stepNumber < s.effectiveCap },
{ to: 'SYNTHESIZE', condition: () => true },
],
execute: reflectNode,
},
{
type: 'SYNTHESIZE',
edges: [],
execute: async () => {},
},
];
}
// -- Graph runner ---------------------------------------------------------
export async function runGraph(
ctx: InferenceContext,
args: TurnArgs,
extra: { effectiveCap: number; budget: number; agent: Agent | null; projectRoot: string },
): Promise<GraphResult> {
const { effectiveCap, budget, agent } = extra;
const state: GraphState = {
stepNumber: 0,
toolsUsed: args.toolsUsed,
recentToolCalls: args.recentToolCalls,
assistantMessageId: args.assistantMessageId,
mistakeTracker: args.mistakeTracker,
pendingRecoveryNote: args.pendingRecoveryNote,
effectiveCap,
budget,
projectRoot: extra.projectRoot,
shouldStop: false,
};
const graph = createDefaultGraph();
let currentNode: GraphNodeType = 'PLAN';
while (currentNode !== 'SYNTHESIZE' && !state.shouldStop) {
const node = graph.find((n) => n.type === currentNode)!;
await node.execute(ctx, args, state, agent);
if (state.shouldStop) break;
const nextEdge = node.edges.find((e) => e.condition(state));
if (!nextEdge) break;
currentNode = nextEdge.to;
}
return {
stepNumber: state.stepNumber,
assistantMessageId: state.assistantMessageId,
toolsUsed: state.toolsUsed,
recentToolCalls: state.recentToolCalls,
mistakeTracker: state.mistakeTracker,
};
}
// -- PLAN node ------------------------------------------------------------
// Top-of-loop gate → compaction → loadContext → DCP → buildPayload → stream
async function planNode(
ctx: InferenceContext,
args: TurnArgs,
state: GraphState,
agent: Agent | null,
): Promise<void> {
const { sessionId, chatId, signal } = args;
// 1. Top-of-loop gate: doom-loop, then budget (pure decisions)
const decision = decideStep({
recentToolCalls: state.recentToolCalls,
toolsUsed: state.toolsUsed,
budget: state.budget,
});
if (decision.kind === 'doom') {
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) {
const dlSession = args.modelOverride ? { ...loaded.session, model: args.modelOverride } : loaded.session;
const iterArgs: TurnArgs = {
sessionId, chatId, assistantMessageId: state.assistantMessageId,
toolsUsed: state.toolsUsed, recentToolCalls: state.recentToolCalls,
mistakeTracker: state.mistakeTracker, signal,
};
await runDoomLoopSummary(ctx, iterArgs, dlSession, loaded.project, loaded.history, agent, decision.loop);
}
state.shouldStop = true;
return;
}
if (decision.kind === 'budget') {
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) {
const bhSession = args.modelOverride ? { ...loaded.session, model: args.modelOverride } : loaded.session;
const iterArgs: TurnArgs = {
sessionId, chatId, assistantMessageId: state.assistantMessageId,
toolsUsed: state.toolsUsed, recentToolCalls: state.recentToolCalls,
mistakeTracker: state.mistakeTracker, signal,
};
await runCapHitSummary(ctx, iterArgs, bhSession, loaded.project, loaded.history, agent, state.budget);
}
state.shouldStop = true;
return;
}
// decision.kind === 'stream' → proceed.
// 2. Compaction check
const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>`
SELECT needs_compaction FROM chats WHERE id = ${chatId}
`;
if (chatFlag[0]?.needs_compaction) {
try {
await compaction.process({
sql: ctx.sql, config: ctx.config, log: ctx.log,
broker: ctx.broker, chatId, hooks: ctx.hooks,
});
} catch (err) {
ctx.log.warn({ err, chatId }, 'auto-compaction failed; clearing flag and proceeding');
await ctx.sql`UPDATE chats SET needs_compaction = false WHERE id = ${chatId}`;
}
}
// 3. Load context (must re-load each iteration — new messages)
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) {
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
state.shouldStop = true;
return;
}
let { session: iterSession, project: iterProject, history } = loaded;
if (args.modelOverride) {
iterSession = { ...iterSession, model: args.modelOverride };
}
state.iterSession = iterSession;
state.iterProject = iterProject;
const projectRoot = await resolveProjectRoot(iterProject.path);
state.projectRoot = projectRoot;
// 4. DCP transform
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');
}
// 5. Log step boundary
ctx.log.info(
{ sessionId, chatId, step: state.stepNumber, assistantMessageId: state.assistantMessageId },
'step_start',
);
// 6. Build messages + stream phase
const messages = await buildMessagesPayload(iterSession, iterProject, history, agent, ctx.log);
const webToolsEnabled =
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
if (state.stepNumber === 0 && webToolsEnabled && messages.length >= 2) {
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
if (lastUserMsg?.content) {
const hint = await rewriteSearchQuery(lastUserMsg.content);
if (hint && messages[0]?.role === 'system' && messages[0].content) {
messages[0].content += `\n\nThe user's search intent can be summarized as: "${hint}"`;
}
}
}
if (state.pendingRecoveryNote) {
messages.push({ role: 'system', content: state.pendingRecoveryNote });
state.pendingRecoveryNote = undefined;
}
// 7. Stream phase
const iterArgs: TurnArgs = {
sessionId, chatId, assistantMessageId: state.assistantMessageId,
toolsUsed: state.toolsUsed, recentToolCalls: state.recentToolCalls,
mistakeTracker: state.mistakeTracker, signal,
};
const streamState: StreamPhaseState = { accumulated: '', startedAt: null };
try {
const result = await executeStreamPhase(ctx, iterArgs, iterSession, messages, streamState, agent, webToolsEnabled);
state.streamResult = result;
state.startedAt = streamState.startedAt;
// Non-tool finish: Stop hook + finalize here (edge from PLAN → SYNTHESIZE
// will break the graph loop after this node returns).
if (result.toolCalls.length === 0) {
if (ctx.hooks) {
ctx.hooks.run('Stop', {
event: 'Stop',
session_id: sessionId,
chat_id: chatId,
last_assistant_text: result.content.slice(0, 500),
turn: state.stepNumber,
}).catch(() => {});
}
await finalizeCompletion(ctx, iterArgs, result, streamState.startedAt, iterSession);
}
} catch (err) {
await handleAbortOrError(ctx, iterArgs, streamState.accumulated, err);
state.shouldStop = true;
}
}
// -- CALL_TOOL node -------------------------------------------------------
// Executes the tool phase and stores the result for OBSERVE.
async function callToolNode(
ctx: InferenceContext,
args: TurnArgs,
state: GraphState,
agent: Agent | null,
): Promise<void> {
const { sessionId, chatId } = args;
const result = state.streamResult;
if (!result) {
ctx.log.warn({ sessionId }, 'state-graph: CALL_TOOL without stream result');
state.shouldStop = true;
return;
}
const session = state.iterSession;
if (!session) {
ctx.log.warn({ sessionId }, 'state-graph: CALL_TOOL without iterSession');
state.shouldStop = true;
return;
}
try {
state.toolPhaseResult = await executeToolPhase(
ctx, args, result, state.startedAt ?? null,
session, state.projectRoot, agent, state.stepNumber,
);
} catch (err) {
ctx.log.error({ err, sessionId, chatId, step: state.stepNumber }, 'tool phase threw unexpectedly');
state.shouldStop = true;
}
}
// -- OBSERVE node ---------------------------------------------------------
// Processes tool results: updates loop locals, mistake tracking, build errors.
async function observeNode(
ctx: InferenceContext,
args: TurnArgs,
state: GraphState,
_agent: Agent | null,
): Promise<void> {
const { sessionId, chatId } = args;
const tpr = state.toolPhaseResult;
if (!tpr) {
state.shouldStop = true;
return;
}
// Update loop locals (mirrors the existing while-loop post-tool logic)
state.toolsUsed += tpr.toolCallCount;
state.recentToolCalls = [...state.recentToolCalls, ...tpr.toolCalls];
state.stepNumber++;
// Fold tool outcomes into the mistake tracker
for (const o of tpr.outcomes) {
recordStep(state.mistakeTracker, o);
}
// Auto-fix: after write tools, attempt build and inject errors.
const WRITE_TOOLS = new Set(['edit_file', 'create_file', 'delete_file', 'apply_pending']);
const hasWriteTools = tpr.toolCalls.some((tc) => WRITE_TOOLS.has(tc.name));
if (hasWriteTools && state.iterSession) {
detectAndRunBuild(ctx, state.projectRoot, sessionId, chatId, state.iterSession.model, state.pendingRecoveryNote)
.then((buildError) => {
if (buildError) state.pendingRecoveryNote = buildError;
})
.catch(() => {});
}
}
// -- REFLECT node ---------------------------------------------------------
// Post-tool decision: decidePostToolAction, nudge/escalate/continue handling.
async function reflectNode(
ctx: InferenceContext,
args: TurnArgs,
state: GraphState,
_agent: Agent | null,
): Promise<void> {
const { sessionId, chatId, signal } = args;
const tpr = state.toolPhaseResult;
if (!tpr) {
state.shouldStop = true;
return;
}
const post = decidePostToolAction(tpr.action, state.mistakeTracker);
if (post === 'stop') {
state.shouldStop = true;
return;
}
if (post === 'nudge') {
state.pendingRecoveryNote = MISTAKE_RECOVERY_NOTE;
const failureKinds = [...state.mistakeTracker.run];
await insertMistakeRecoverySentinel(ctx, sessionId, chatId, {
failureKinds,
count: failureKinds.length,
escalated: false,
canContinue: true,
});
state.mistakeTracker.nudges += 1;
state.mistakeTracker.run = [];
ctx.log.info(
{ sessionId, chatId, step: state.stepNumber, nudges: state.mistakeTracker.nudges, failureKinds },
'mistake_recovery nudge',
);
// Continue to next PLAN node — edges check step < cap.
if (state.assistantMessageId !== tpr.nextAssistantId && tpr.nextAssistantId) {
state.assistantMessageId = tpr.nextAssistantId;
}
return;
}
if (post === 'escalate') {
const failureKinds = [...state.mistakeTracker.run];
if (tpr.nextAssistantId) {
state.assistantMessageId = tpr.nextAssistantId;
}
const escalateArgs: TurnArgs = {
sessionId, chatId, assistantMessageId: state.assistantMessageId,
toolsUsed: state.toolsUsed, recentToolCalls: state.recentToolCalls,
mistakeTracker: state.mistakeTracker, signal,
};
await finalizeEmpty(ctx, escalateArgs);
await insertMistakeRecoverySentinel(ctx, sessionId, chatId, {
failureKinds,
count: failureKinds.length,
escalated: true,
canContinue: true,
});
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
ctx.log.info(
{ sessionId, chatId, step: state.stepNumber, failureKinds },
'mistake_recovery escalate — stopping turn',
);
state.shouldStop = true;
return;
}
// 'continue' — advance to next assistant message.
if (tpr.nextAssistantId) {
state.assistantMessageId = tpr.nextAssistantId;
}
}

View File

@@ -56,6 +56,7 @@ export async function executeStreamPhase(
message_id: assistantMessageId,
chat_id: chatId,
role: 'assistant',
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
});
const flusher = createContentFlusher(ctx.sql, assistantMessageId, () => state.accumulated);
@@ -119,6 +120,7 @@ export async function executeStreamPhase(
message_id: assistantMessageId,
chat_id: chatId,
content: delta,
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
});
ctx.log.debug({ sessionId, delta }, 'inference delta');
flusher.scheduleFlush();

View File

@@ -0,0 +1,75 @@
// Supervisor agent: routes user requests to the best agent via a cheap LLM
// classification call. Activated when session.agent_id === 'supervisor'.
import type { Agent } from '../../types/api.js';
import { taskModelCompletion } from '../task-model.js';
export interface SupervisorRoute {
agent_id: string;
confidence: number;
reasoning: string;
}
const SUPERVISOR_SYSTEM_PROMPT = `You are a router. Given the user's request and the available agents, choose the best agent to handle the request.
Rules:
- Match the request to the agent whose description and toolset best fits the task.
- For code review / bug finding requests → code-reviewer
- For debugging / diagnosing failures → debugger
- For refactoring / simplifying code → refactorer
- For architecture / design / planning → architect or planner
- For security audits → security-auditor
- For building prompts for other agents → prompt-builder
- For exploring / understanding unfamiliar code → recon
- For implementing / writing code changes → builder
- Respond with ONLY the agent id (e.g. "builder") or "none" if no agent fits.
- Do not include any other text, punctuation, or explanation.`;
const MAX_ROUTING_TOKENS = 30;
/**
* Given the user's latest message and available agents, classifies which agent
* should handle this turn. Returns null to fall through to default (no agent).
*/
export async function resolveSupervisorTurn(
latestUserMessage: string,
agents: Agent[],
fallbackModel?: string,
): Promise<SupervisorRoute | null> {
// Build agent listing — skip the supervisor itself to avoid self-routing.
const agentList = agents
.filter((a) => a.id !== 'supervisor')
.map((a) => `- ${a.id}: ${a.description} (${a.tools.length} tools)`)
.join('\n');
if (!agentList) {
return null;
}
const userPrompt = `Available agents:\n${agentList}\n\nUser request: ${latestUserMessage.slice(0, 2000)}`;
const response = await taskModelCompletion({
system: SUPERVISOR_SYSTEM_PROMPT,
user: userPrompt,
maxTokens: MAX_ROUTING_TOKENS,
temperature: 0.1,
fallbackModel,
});
const agentId = response.trim().toLowerCase();
if (!agentId || agentId === 'none') {
return null;
}
// Map back to a real agent to validate the id.
const matched = agents.find((a) => a.id === agentId);
if (!matched) {
return null;
}
return {
agent_id: matched.id,
confidence: 1,
reasoning: `supervisor routed to "${matched.name}" based on request classification`,
};
}

View File

@@ -19,6 +19,7 @@ import { formatUnknownToolError } from './tool-suggestions.js';
import { resolveGrantRoot } from '../grant_resolver.js';
import { stripToolMarkup } from './tool-call-parser.js';
import { repairToolInput } from './tool-input-repair.js';
import { diffFromToolArgs, isWriteTool } from './compute-diff.js';
import type { FailureKind } from './mistake-tracker.js';
import { insertToolTrace, updateToolTrace } from '../tool-traces.js';
import type {
@@ -445,6 +446,16 @@ export async function executeToolPhase(
if (SYNTHESIS_TOOLS.has(tc.name)) {
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
}
// v2.8: compute a compact unified diff for successful write-tool results.
// The diff is derived from tool call args (old_string/new_string for
// edit_file, content for create_file) and included in the WS frame so
// the frontend can render a DiffSnippet inline. Not persisted to message_parts
// (the args alone are enough to reproduce it on reload if needed).
const toolDiff =
!tres.error && tres.outcome === 'success' && isWriteTool(tc.name)
? diffFromToolArgs(tc.name, tc.args as Record<string, unknown>)
: undefined;
const stored = {
tool_call_id: tc.id,
output: tres.output,
@@ -467,6 +478,7 @@ export async function executeToolPhase(
output: tres.output,
truncated: tres.truncated,
...(tres.error ? { error: tres.error } : {}),
...(toolDiff ? { diff: toolDiff } : {}),
});
})
);

View File

@@ -8,7 +8,7 @@ import type {
import { resolveProjectRoot } from '../path_guard.js';
import { maybeAutoNameChat } from '../auto_name.js';
import { rewriteSearchQuery } from '../task-search-rewrite.js';
import { getAgentById } from '../agents.js';
import { getAgentById, getAgentsForProject } from '../agents.js';
import * as compaction from '../compaction.js';
import { resolveTurnConfig } from './turn-config.js';
import { decideStep, decidePostToolAction } from './step-decision.js';
@@ -49,6 +49,8 @@ import {
runStepCapSummary,
insertMistakeRecoverySentinel,
} from './sentinel-summaries.js';
import { resolveSupervisorTurn } from './supervisor.js';
import { runGraph } from './state-graph.js';
// vWhale: auto-fix — detect build command from package.json, run it, return
// error text for injection into next iteration. Best-effort, never throws.
@@ -149,10 +151,39 @@ export async function runAssistantTurn(
ctx.log.warn({ sessionId }, 'inference: session or project missing');
return;
}
const { session, project } = initialLoaded;
const agent = session.agent_id
let { session, project, history: initialHistory } = initialLoaded;
if (args.modelOverride) {
session = { ...session, model: args.modelOverride };
}
let agent = session.agent_id
? await getAgentById(project.path, session.agent_id)
: null;
// vSupervisor: if the session is set to supervisor mode, resolve the real
// agent via a cheap classification call. Falls through to default (no agent)
// if routing returns null.
if (agent?.id === 'supervisor') {
const { agents: availableAgents } = await getAgentsForProject(project.path);
const latestUser = [...initialHistory].reverse().find((m) => m.role === 'user');
const userMessage = latestUser?.content ?? '';
if (userMessage) {
const route = await resolveSupervisorTurn(userMessage, availableAgents, session.model ?? undefined);
if (route) {
ctx.log.info(
{ sessionId, chatId, resolvedAgent: route.agent_id, reasoning: route.reasoning },
'supervisor: routed turn',
);
agent = await getAgentById(project.path, route.agent_id);
} else {
ctx.log.info({ sessionId, chatId }, 'supervisor: no agent matched, falling through to default');
agent = null;
}
} else {
ctx.log.info({ sessionId, chatId }, 'supervisor: no user message found, falling through to default');
agent = null;
}
}
// P5: pure per-turn config (budget + cap math + text-only flag).
const { effectiveCap, budget, isTextOnly } = resolveTurnConfig(agent);
@@ -162,7 +193,8 @@ export async function runAssistantTurn(
if (isTextOnly) {
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) {
await runTextOnlyTurn(ctx, args, loaded.session, loaded.project, loaded.history, agent);
const txtSession = args.modelOverride ? { ...loaded.session, model: args.modelOverride } : loaded.session;
await runTextOnlyTurn(ctx, args, txtSession, loaded.project, loaded.history, agent);
}
return;
}
@@ -178,228 +210,244 @@ export async function runAssistantTurn(
const mistakeTracker = args.mistakeTracker;
let pendingRecoveryNote: string | undefined = args.pendingRecoveryNote;
while (stepNumber < effectiveCap) {
// ---- top-of-loop gate: doom-loop, then budget (pure decision) ----
const decision = decideStep({ recentToolCalls, toolsUsed, budget });
if (decision.kind === 'doom') {
// Need fresh history for the summary.
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) {
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
await runDoomLoopSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, decision.loop);
if (session.state_graph_enabled) {
// ---- optional state graph path ----
const gProjectRoot = await resolveProjectRoot(project.path);
const graphResult = await runGraph(ctx, args, { effectiveCap, budget, agent, projectRoot: gProjectRoot });
stepNumber = graphResult.stepNumber;
toolsUsed = graphResult.toolsUsed;
recentToolCalls = graphResult.recentToolCalls;
assistantMessageId = graphResult.assistantMessageId;
// mistakeTracker is the same object reference (mutated in place by the graph).
} else {
while (stepNumber < effectiveCap) {
// ---- top-of-loop gate: doom-loop, then budget (pure decision) ----
const decision = decideStep({ recentToolCalls, toolsUsed, budget });
if (decision.kind === 'doom') {
// Need fresh history for the summary.
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) {
const dlSession = args.modelOverride ? { ...loaded.session, model: args.modelOverride } : loaded.session;
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
await runDoomLoopSummary(ctx, iterArgs, dlSession, loaded.project, loaded.history, agent, decision.loop);
}
break;
}
break;
}
if (decision.kind === 'budget') {
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) {
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
await runCapHitSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, budget);
if (decision.kind === 'budget') {
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) {
const bhSession = args.modelOverride ? { ...loaded.session, model: args.modelOverride } : loaded.session;
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
await runCapHitSummary(ctx, iterArgs, bhSession, loaded.project, loaded.history, agent, budget);
}
break;
}
break;
}
// decision.kind === 'stream' → proceed with compaction + stream + tools.
// decision.kind === 'stream' → proceed with compaction + stream + tools.
// ---- compaction check ----
// v1.11: if the prior turn flagged this chat for compaction, run it
// before loadContext so we read post-compaction history. Swallow
// failures and proceed with un-compacted history.
const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>`
SELECT needs_compaction FROM chats WHERE id = ${chatId}
`;
if (chatFlag[0]?.needs_compaction) {
try {
await compaction.process({
sql: ctx.sql,
config: ctx.config,
log: ctx.log,
broker: ctx.broker,
chatId,
hooks: ctx.hooks,
});
} catch (err) {
ctx.log.warn({ err, chatId }, 'auto-compaction failed; clearing flag and proceeding');
await ctx.sql`UPDATE chats SET needs_compaction = false WHERE id = ${chatId}`;
}
}
// ---- load context (must re-load each iteration — new messages since last step) ----
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) {
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
break;
}
let { session: iterSession, project: iterProject, history } = loaded;
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
// the schema CHECK but not emitted here — writing to the assistant message
// before the stream phase creates a sequence-0 collision with
// partsFromAssistantMessage. A WS frame or structured log is sufficient
// since the frontend doesn't render step boundaries in v1.14.
ctx.log.info({ sessionId, chatId, step: stepNumber, assistantMessageId }, 'step_start');
// ---- build messages + stream phase ----
const messages = await buildMessagesPayload(iterSession, iterProject, history, agent, ctx.log);
const webToolsEnabled =
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
if (stepNumber === 0 && webToolsEnabled && messages.length >= 2) {
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
if (lastUserMsg?.content) {
const hint = await rewriteSearchQuery(lastUserMsg.content);
if (hint && messages[0]?.role === 'system' && messages[0].content) {
messages[0].content += `\n\nThe user's search intent can be summarized as: "${hint}"`;
// ---- compaction check ----
// v1.11: if the prior turn flagged this chat for compaction, run it
// before loadContext so we read post-compaction history. Swallow
// failures and proceed with un-compacted history.
const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>`
SELECT needs_compaction FROM chats WHERE id = ${chatId}
`;
if (chatFlag[0]?.needs_compaction) {
try {
await compaction.process({
sql: ctx.sql,
config: ctx.config,
log: ctx.log,
broker: ctx.broker,
chatId,
hooks: ctx.hooks,
});
} catch (err) {
ctx.log.warn({ err, chatId }, 'auto-compaction failed; clearing flag and proceeding');
await ctx.sql`UPDATE chats SET needs_compaction = false WHERE id = ${chatId}`;
}
}
}
// v#12 MistakeTracker: if the prior iteration's nudge fired, append the
// transient recovery note to THIS payload (consumed exactly once, then
// cleared). Never persisted — same lifecycle as the cap-hit/doom-loop
// summary notes, which live only inside the in-memory messages array.
if (pendingRecoveryNote) {
messages.push({ role: 'system', content: pendingRecoveryNote });
pendingRecoveryNote = undefined;
}
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
const state: StreamPhaseState = { accumulated: '', startedAt: null };
let result: StreamResult;
try {
result = await executeStreamPhase(ctx, iterArgs, iterSession, messages, state, agent, webToolsEnabled);
} catch (err) {
await handleAbortOrError(ctx, iterArgs, state.accumulated, err);
break;
}
// ---- non-tool finish → finalize and exit ----
if (result.toolCalls.length === 0) {
// vWhale: Stop hook (best-effort, non-blocking).
if (ctx.hooks) {
ctx.hooks.run('Stop', {
event: 'Stop',
session_id: sessionId,
chat_id: chatId,
last_assistant_text: result.content.slice(0, 500),
turn: stepNumber,
}).catch(() => {});
// ---- load context (must re-load each iteration — new messages since last step) ----
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) {
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
break;
}
await finalizeCompletion(ctx, iterArgs, result, state.startedAt, iterSession);
break;
}
let { session: iterSession, project: iterProject, history } = loaded;
if (args.modelOverride) {
iterSession = { ...iterSession, model: args.modelOverride };
}
const projectRoot = await resolveProjectRoot(iterProject.path);
// ---- steps: 0 edge case ----
// effectiveCap check above guarantees we're inside the loop, but this
// guard handles the theoretical case where the model emits tool calls
// on step 0 when effectiveCap would have been 0 (impossible since the
// while condition prevents entry, but kept for safety). If effectiveCap
// is 1 and we're on step 0, tool calls ARE executed — steps counts
// iterations, not post-first-stream.
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');
}
// ---- tool phase ----
let toolPhaseResult: ToolPhaseResult;
try {
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent, stepNumber);
} catch (err) {
// Tool phase errors are unexpected (individual tool failures are
// caught inside executeToolPhase). Log and break.
ctx.log.error({ err, sessionId, chatId, step: stepNumber }, 'tool phase threw unexpectedly');
break;
}
// 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
// before the stream phase creates a sequence-0 collision with
// partsFromAssistantMessage. A WS frame or structured log is sufficient
// since the frontend doesn't render step boundaries in v1.14.
ctx.log.info({ sessionId, chatId, step: stepNumber, assistantMessageId }, 'step_start');
// ---- update loop locals ----
toolsUsed += toolPhaseResult.toolCallCount;
recentToolCalls = [...recentToolCalls, ...toolPhaseResult.toolCalls];
stepNumber++;
// ---- build messages + stream phase ----
const messages = await buildMessagesPayload(iterSession, iterProject, history, agent, ctx.log);
const webToolsEnabled =
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
// v#12 MistakeTracker: fold this iteration's tool outcomes into the
// tracker, in order. recordStep mutates `mistakeTracker` in place (it is
// the same object referenced by args). A 'success' clears the streak.
for (const o of toolPhaseResult.outcomes) {
recordStep(mistakeTracker, o);
}
if (stepNumber === 0 && webToolsEnabled && messages.length >= 2) {
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
if (lastUserMsg?.content) {
const hint = await rewriteSearchQuery(lastUserMsg.content);
if (hint && messages[0]?.role === 'system' && messages[0].content) {
messages[0].content += `\n\nThe user's search intent can be summarized as: "${hint}"`;
}
}
}
// vWhale: auto-fix — after write tools, attempt build and inject errors.
const WRITE_TOOLS = new Set(['edit_file', 'create_file', 'delete_file', 'apply_pending']);
const hasWriteTools = toolPhaseResult.toolCalls.some((tc) => WRITE_TOOLS.has(tc.name));
if (hasWriteTools) {
detectAndRunBuild(ctx, projectRoot, sessionId, chatId, iterSession.model, pendingRecoveryNote)
.then((buildError) => {
if (buildError) pendingRecoveryNote = buildError;
})
.catch(() => {});
}
// v#12 MistakeTracker: if the prior iteration's nudge fired, append the
// transient recovery note to THIS payload (consumed exactly once, then
// cleared). Never persisted — same lifecycle as the cap-hit/doom-loop
// summary notes, which live only inside the in-memory messages array.
if (pendingRecoveryNote) {
messages.push({ role: 'system', content: pendingRecoveryNote });
pendingRecoveryNote = undefined;
}
// v#12 MistakeTracker: post-tool decision (pure). 'stop' = the tool phase
// returned a non-'continue' action ('paused' for user input, or
// 'synthesis_done') — neither a nudge nor an escalate would change the
// control flow, so the mistake check is skipped. On 'continue' the
// heterogeneous-failure pattern gates nudge/escalate/continue. Complements
// the doom-loop gate above, which only catches *identical* repeats.
const post = decidePostToolAction(toolPhaseResult.action, mistakeTracker);
if (post === 'stop') {
break;
}
if (post === 'nudge') {
// Soft intervention: inject model-facing recovery guidance into the NEXT
// step's payload, drop a UI sentinel, bump nudges, reset the streak, and
// continue. The note is consumed (and cleared) at the top of the next
// iteration's payload build.
pendingRecoveryNote = MISTAKE_RECOVERY_NOTE;
const failureKinds = [...mistakeTracker.run];
await insertMistakeRecoverySentinel(ctx, sessionId, chatId, {
failureKinds,
count: failureKinds.length,
escalated: false,
canContinue: true,
});
mistakeTracker.nudges += 1;
mistakeTracker.run = [];
ctx.log.info(
{ sessionId, chatId, step: stepNumber, nudges: mistakeTracker.nudges, failureKinds },
'mistake_recovery nudge',
);
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
const state: StreamPhaseState = { accumulated: '', startedAt: null };
let result: StreamResult;
try {
result = await executeStreamPhase(ctx, iterArgs, iterSession, messages, state, agent, webToolsEnabled);
} catch (err) {
await handleAbortOrError(ctx, iterArgs, state.accumulated, err);
break;
}
// ---- non-tool finish → finalize and exit ----
if (result.toolCalls.length === 0) {
// vWhale: Stop hook (best-effort, non-blocking).
if (ctx.hooks) {
ctx.hooks.run('Stop', {
event: 'Stop',
session_id: sessionId,
chat_id: chatId,
last_assistant_text: result.content.slice(0, 500),
turn: stepNumber,
}).catch(() => {});
}
await finalizeCompletion(ctx, iterArgs, result, state.startedAt, iterSession);
break;
}
// ---- steps: 0 edge case ----
// effectiveCap check above guarantees we're inside the loop, but this
// guard handles the theoretical case where the model emits tool calls
// on step 0 when effectiveCap would have been 0 (impossible since the
// while condition prevents entry, but kept for safety). If effectiveCap
// is 1 and we're on step 0, tool calls ARE executed — steps counts
// iterations, not post-first-stream.
// ---- tool phase ----
let toolPhaseResult: ToolPhaseResult;
try {
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent, stepNumber);
} catch (err) {
// Tool phase errors are unexpected (individual tool failures are
// caught inside executeToolPhase). Log and break.
ctx.log.error({ err, sessionId, chatId, step: stepNumber }, 'tool phase threw unexpectedly');
break;
}
// ---- update loop locals ----
toolsUsed += toolPhaseResult.toolCallCount;
recentToolCalls = [...recentToolCalls, ...toolPhaseResult.toolCalls];
stepNumber++;
// v#12 MistakeTracker: fold this iteration's tool outcomes into the
// tracker, in order. recordStep mutates `mistakeTracker` in place (it is
// the same object referenced by args). A 'success' clears the streak.
for (const o of toolPhaseResult.outcomes) {
recordStep(mistakeTracker, o);
}
// vWhale: auto-fix — after write tools, attempt build and inject errors.
const WRITE_TOOLS = new Set(['edit_file', 'create_file', 'delete_file', 'apply_pending']);
const hasWriteTools = toolPhaseResult.toolCalls.some((tc) => WRITE_TOOLS.has(tc.name));
if (hasWriteTools) {
detectAndRunBuild(ctx, projectRoot, sessionId, chatId, iterSession.model, pendingRecoveryNote)
.then((buildError) => {
if (buildError) pendingRecoveryNote = buildError;
})
.catch(() => {});
}
// v#12 MistakeTracker: post-tool decision (pure). 'stop' = the tool phase
// returned a non-'continue' action ('paused' for user input, or
// 'synthesis_done') — neither a nudge nor an escalate would change the
// control flow, so the mistake check is skipped. On 'continue' the
// heterogeneous-failure pattern gates nudge/escalate/continue. Complements
// the doom-loop gate above, which only catches *identical* repeats.
const post = decidePostToolAction(toolPhaseResult.action, mistakeTracker);
if (post === 'stop') {
break;
}
if (post === 'nudge') {
// Soft intervention: inject model-facing recovery guidance into the NEXT
// step's payload, drop a UI sentinel, bump nudges, reset the streak, and
// continue. The note is consumed (and cleared) at the top of the next
// iteration's payload build.
pendingRecoveryNote = MISTAKE_RECOVERY_NOTE;
const failureKinds = [...mistakeTracker.run];
await insertMistakeRecoverySentinel(ctx, sessionId, chatId, {
failureKinds,
count: failureKinds.length,
escalated: false,
canContinue: true,
});
mistakeTracker.nudges += 1;
mistakeTracker.run = [];
ctx.log.info(
{ sessionId, chatId, step: stepNumber, nudges: mistakeTracker.nudges, failureKinds },
'mistake_recovery nudge',
);
assistantMessageId = toolPhaseResult.nextAssistantId!;
continue;
}
if (post === 'escalate') {
// The nudge didn't break the failure run — stop the turn (cap-hit-style)
// to avoid burning the whole step budget on heterogeneous failures. The
// next assistant row is still 'streaming'; finalize it as an empty
// complete row so the slot doesn't dangle, then drop the escalate
// sentinel.
const failureKinds = [...mistakeTracker.run];
assistantMessageId = toolPhaseResult.nextAssistantId!;
const escalateArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
await finalizeEmpty(ctx, escalateArgs);
await insertMistakeRecoverySentinel(ctx, sessionId, chatId, {
failureKinds,
count: failureKinds.length,
escalated: true,
canContinue: true,
});
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
ctx.log.info(
{ sessionId, chatId, step: stepNumber, failureKinds },
'mistake_recovery escalate — stopping turn',
);
break;
}
// 'continue' — advance to next assistant message.
assistantMessageId = toolPhaseResult.nextAssistantId!;
continue;
}
if (post === 'escalate') {
// The nudge didn't break the failure run — stop the turn (cap-hit-style)
// to avoid burning the whole step budget on heterogeneous failures. The
// next assistant row is still 'streaming'; finalize it as an empty
// complete row so the slot doesn't dangle, then drop the escalate
// sentinel.
const failureKinds = [...mistakeTracker.run];
assistantMessageId = toolPhaseResult.nextAssistantId!;
const escalateArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
await finalizeEmpty(ctx, escalateArgs);
await insertMistakeRecoverySentinel(ctx, sessionId, chatId, {
failureKinds,
count: failureKinds.length,
escalated: true,
canContinue: true,
});
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
ctx.log.info(
{ sessionId, chatId, step: stepNumber, failureKinds },
'mistake_recovery escalate — stopping turn',
);
break;
}
// 'continue' — advance to next assistant message.
assistantMessageId = toolPhaseResult.nextAssistantId!;
}
// vWhale: Stop hook at post-loop exit (best-effort, non-blocking).
@@ -438,8 +486,9 @@ export async function runAssistantTurn(
if (stepNumber >= effectiveCap && effectiveCap < Infinity) {
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) {
const scSession = args.modelOverride ? { ...loaded.session, model: args.modelOverride } : loaded.session;
const capArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
await runStepCapSummary(ctx, capArgs, loaded.session, loaded.project, loaded.history, agent, stepNumber, effectiveCap);
await runStepCapSummary(ctx, capArgs, scSession, loaded.project, loaded.history, agent, stepNumber, effectiveCap);
}
}
}
@@ -510,6 +559,31 @@ export async function runInference(
});
}
// v2.8-compare: run inference with a model override and compare group id.
// Used by the compare endpoint to run the same message through N models in
// parallel. Each call publishes frames scoped to its compare_group_id.
export async function runInferenceWithModel(
ctx: InferenceContext,
sessionId: string,
chatId: string,
assistantMessageId: string,
modelOverride: string,
compareGroupId: string,
signal?: AbortSignal,
): Promise<void> {
return runAssistantTurn(ctx, {
sessionId,
chatId,
assistantMessageId,
toolsUsed: 0,
recentToolCalls: [],
mistakeTracker: freshMistakeState(),
modelOverride,
compareGroupId,
signal,
});
}
// v1.8.2: cap-hit summary flow. Called instead of erroring when the loop
// hits its budget. Reuses the in-flight assistant message slot to stream a
// short wrap-up reply with the synthetic note prepended and tools disabled,

View File

@@ -52,7 +52,9 @@ export interface InferenceFrame {
// arena frames
| 'battle_started'
| 'contestant_updated'
| 'battle_updated';
| 'battle_updated'
// inter-agent message
| 'agent_message';
message_id?: string;
message_ids?: string[];
chat_id?: string;
@@ -103,6 +105,11 @@ export interface InferenceFrame {
status?: string;
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
report?: string;
// v2.8-compare: groups messages belonging to the same compare operation.
compare_group_id?: string;
// inter-agent message
sender_step_id?: string;
channel?: string;
// arena frames
battle_id?: string;
battle_type?: 'coding' | 'qa';
@@ -177,5 +184,10 @@ export interface TurnArgs {
// Never persisted — mirrors how the cap-hit/doom-loop notes live only inside
// the summary call's messages array.
pendingRecoveryNote?: string;
// v2.8-compare: when set, overrides the session model for this single turn.
// Used by the compare endpoint to run the same message through N models.
modelOverride?: string;
// v2.8-compare: opaque group id that rides on every published frame.
compareGroupId?: string;
signal: AbortSignal | undefined;
}

View File

@@ -148,6 +148,19 @@ export function getServerPermission(prefixedToolName: string): McpPermission {
return state?.permission ?? 'allow';
}
/** Override the permission for a server. Used by the approval flow. */
export function setServerPermission(serverName: string, permission: McpPermission): void {
const state = servers.get(serverName);
if (state) {
state.permission = permission;
}
}
/** Get the server name from a prefixed tool name. Returns null if not an MCP tool. */
export function getServerName(prefixedToolName: string): string | null {
return toolToServer.get(prefixedToolName) ?? null;
}
/** Return all wrapped ToolDefs from all connected servers, flattened. */
export function getTools(): ToolDef<Record<string, unknown>>[] {
const all: ToolDef<Record<string, unknown>>[] = [];

View File

@@ -0,0 +1,305 @@
// v2.x: Background subagent tools. Three tools that let the model spawn
// non-blocking subagent tasks, poll their status, and retrieve results.
//
// spawn_subagent — Create a background session+chat, dispatch inference,
// return immediately with a task_id.
// subagent_status — Poll the status of a previously spawned task.
// subagent_result — Retrieve the full output of a completed task.
//
// These tools reuse the existing sessions/chats/messages/tables and the
// inference pipeline — no new tables or services needed.
//
// Registered in tools.ts ALL_TOOLS. Lives in its own file so tests can
// import executors without dragging in the full tool registry.
//
// Follows the read_tab_by_number.ts pattern: a pure executor function plus
// a ToolDef wrapper. Type-only import from tools.ts to dodge runtime cycles.
import { z } from 'zod';
import type { Sql } from '../../db.js';
import type { ToolDef, ToolExecCtx } from '../tools.js';
import {
spawnBackgroundTask,
getBackgroundTaskStatus,
getBackgroundTaskResult,
} from '../background-task.js';
// ---------------------------------------------------------------------------
// spawn_subagent
// ---------------------------------------------------------------------------
export const SpawnSubagentInput = z.object({
input: z.string().min(1).describe('The task to execute in the background'),
model: z
.string()
.min(1)
.optional()
.describe('Model to use (defaults to session model)'),
agent: z
.string()
.min(1)
.optional()
.describe('Agent to use (defaults to boocode)'),
label: z
.string()
.max(100)
.optional()
.describe('Human-readable label for display'),
});
export type SpawnSubagentInputT = z.infer<typeof SpawnSubagentInput>;
export async function executeSpawnSubagent(
input: SpawnSubagentInputT,
sql: Sql,
sessionId: string,
): Promise<Record<string, unknown>> {
// Resolve project_id + model from the current session.
const sessRows = await sql<
{ project_id: string; model: string }[]
>`
SELECT project_id, model FROM sessions WHERE id = ${sessionId}
`;
if (sessRows.length === 0) {
return { error: 'current session not found' };
}
const projectId = sessRows[0]!.project_id;
const model = input.model ?? sessRows[0]!.model;
const task = await spawnBackgroundTask(
sql,
// We pass a minimal logger shim — the real logger is wired by the
// inference pipeline. This keeps the tool's execute signature clean.
{ info: () => {}, warn: () => {}, error: () => {} } as unknown as import('fastify').FastifyBaseLogger,
projectId,
input.input,
model,
input.agent,
input.label,
);
// Elapsed time since creation is negligible (task was just spawned).
return {
task_id: task.id,
status: task.status,
session_id: task.session_id,
chat_id: task.chat_id,
created_at: task.created_at,
};
}
export const spawnSubagent: ToolDef<SpawnSubagentInputT> = {
name: 'spawn_subagent',
description:
'Spawn a background subagent task. Creates a new session and chat, dispatches inference asynchronously, and returns immediately with a task_id. Use subagent_status to poll for completion and subagent_result to retrieve the full output. Non-blocking — the model continues while the subagent works in the background.',
inputSchema: SpawnSubagentInput,
jsonSchema: {
type: 'function',
function: {
name: 'spawn_subagent',
description:
'Spawn a background subagent task. Returns immediately with a task_id — poll with subagent_status.',
parameters: {
type: 'object',
properties: {
input: {
type: 'string',
description: 'The task to execute in the background',
},
model: {
type: 'string',
description: 'Model to use (defaults to session model)',
},
agent: {
type: 'string',
description: 'Agent to use (defaults to boocode)',
},
label: {
type: 'string',
maxLength: 100,
description: 'Human-readable label for display',
},
},
required: ['input'],
additionalProperties: false,
},
},
},
async execute(input, _projectRoot, _extraRoots, toolCtx?: ToolExecCtx) {
if (!toolCtx) {
return { error: 'spawn_subagent unavailable: no session context' };
}
try {
return await executeSpawnSubagent(input, toolCtx.sql, toolCtx.sessionId);
} catch (err) {
return {
error: `spawn_subagent failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
},
};
// ---------------------------------------------------------------------------
// subagent_status
// ---------------------------------------------------------------------------
export const SubagentStatusInput = z.object({
task_id: z.string().uuid().describe('Task ID from spawn_subagent'),
});
export type SubagentStatusInputT = z.infer<typeof SubagentStatusInput>;
export async function executeSubagentStatus(
input: SubagentStatusInputT,
sql: Sql,
): Promise<Record<string, unknown>> {
const task = await getBackgroundTaskStatus(sql, input.task_id);
if (!task) {
return { error: 'task not found', task_id: input.task_id };
}
// Compute elapsed time from created_at (ISO string).
let elapsed_seconds: number | null = null;
try {
const created = new Date(task.created_at).getTime();
const finished = task.finished_at
? new Date(task.finished_at).getTime()
: Date.now();
elapsed_seconds = Math.round((finished - created) / 1000);
} catch {
elapsed_seconds = null;
}
return {
task_id: task.id,
status: task.status,
output_summary: task.output_summary,
finished_at: task.finished_at,
elapsed_seconds,
};
}
export const subagentStatus: ToolDef<SubagentStatusInputT> = {
name: 'subagent_status',
description:
'Poll the status of a background subagent task by task_id. Returns the current status (running/completed/failed/cancelled), an output summary if completed, and elapsed time. Useful after spawn_subagent to check if work is done.',
inputSchema: SubagentStatusInput,
jsonSchema: {
type: 'function',
function: {
name: 'subagent_status',
description:
'Poll the status of a background subagent task. Returns status, output summary, and elapsed time.',
parameters: {
type: 'object',
properties: {
task_id: {
type: 'string',
format: 'uuid',
description: 'Task ID from spawn_subagent',
},
},
required: ['task_id'],
additionalProperties: false,
},
},
},
async execute(input, _projectRoot, _extraRoots, toolCtx?: ToolExecCtx) {
if (!toolCtx) {
return { error: 'subagent_status unavailable: no session context' };
}
try {
return await executeSubagentStatus(input, toolCtx.sql);
} catch (err) {
return {
error: `subagent_status failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
},
};
// ---------------------------------------------------------------------------
// subagent_result
// ---------------------------------------------------------------------------
export const SubagentResultInput = z.object({
task_id: z.string().uuid().describe('Task ID from spawn_subagent'),
});
export type SubagentResultInputT = z.infer<typeof SubagentResultInput>;
export async function executeSubagentResult(
input: SubagentResultInputT,
sql: Sql,
): Promise<Record<string, unknown>> {
const task = await getBackgroundTaskStatus(sql, input.task_id);
if (!task) {
return { error: 'task not found', task_id: input.task_id };
}
if (task.status !== 'completed') {
return {
task_id: task.id,
status: task.status,
error: `task is not yet completed (status: ${task.status})`,
};
}
if (!task.chat_id) {
return { error: 'task has no chat data', task_id: input.task_id };
}
const result = await getBackgroundTaskResult(sql, input.task_id, task.chat_id);
if (!result) {
return {
task_id: task.id,
status: task.status,
error: 'task completed but no output message found',
};
}
return {
task_id: task.id,
output: result.output,
token_usage: result.token_usage,
};
}
export const subagentResult: ToolDef<SubagentResultInputT> = {
name: 'subagent_result',
description:
'Retrieve the full output of a completed background subagent task by task_id. Returns the response text and token usage. The task must be in completed status — poll with subagent_status first.',
inputSchema: SubagentResultInput,
jsonSchema: {
type: 'function',
function: {
name: 'subagent_result',
description:
'Retrieve the full output of a completed background subagent task. Returns output text and token usage.',
parameters: {
type: 'object',
properties: {
task_id: {
type: 'string',
format: 'uuid',
description: 'Task ID from spawn_subagent',
},
},
required: ['task_id'],
additionalProperties: false,
},
},
},
async execute(input, _projectRoot, _extraRoots, toolCtx?: ToolExecCtx) {
if (!toolCtx) {
return { error: 'subagent_result unavailable: no session context' };
}
try {
return await executeSubagentResult(input, toolCtx.sql);
} catch (err) {
return {
error: `subagent_result failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
},
};

View File

@@ -0,0 +1,61 @@
import { z } from 'zod';
import type { ToolDef } from '../types.js';
import { callBoocontext } from '../../boocontext_client.js';
export const GetWikiArticleInput = z.object({
article: z.string().min(1).describe('Article name (e.g. "auth", "database", "routes")'),
directory: z.string().optional().describe('Project directory'),
});
export type GetWikiArticleInputT = z.infer<typeof GetWikiArticleInput>;
const DESCRIPTION =
'Returns a persistent codebase wiki article by name (auth, database, routes, etc.). ' +
'Generated on first request and cached to disk. Avoids running expensive full-scan tools for targeted documentation.';
/**
* Standalone execute function — calls the boocontext MCP server's
* codesight_get_wiki_article tool and returns the article text.
*
* Structured for direct test access: accepts input + projectPath,
* no side effects beyond the MCP call.
*/
export async function executeGetWikiArticle(
input: GetWikiArticleInputT,
projectPath: string,
): Promise<string> {
const args: Record<string, unknown> = { article: input.article };
if (input.directory) args['directory'] = input.directory!;
const resp = await callBoocontext({ toolName: 'codesight_get_wiki_article', args });
return resp.result;
}
export const getWikiArticle: ToolDef<GetWikiArticleInputT> = {
name: 'get_wiki_article',
description: DESCRIPTION,
inputSchema: GetWikiArticleInput,
jsonSchema: {
type: 'function',
function: {
name: 'get_wiki_article',
description: DESCRIPTION,
parameters: {
type: 'object',
properties: {
article: {
type: 'string',
description: 'Article name (e.g. "auth", "database", "routes")',
},
directory: {
type: 'string',
description: 'Project directory',
},
},
required: ['article'],
additionalProperties: false,
},
},
},
async execute(input, projectRoot) {
return executeGetWikiArticle(input, projectRoot);
},
};

View File

@@ -0,0 +1,160 @@
import { z } from 'zod';
import { existsSync } from 'node:fs';
import { writeFile, unlink } from 'node:fs/promises';
import { join } from 'node:path';
import type { ToolDef } from '../tools/types.js';
import { ensureMemoryScaffold, getMemoryRoot } from '../memory/paths.js';
import { writeEntry, readTopicFiles } from '../memory/store.js';
const ManageMemoryInput = z.object({
topic: z.enum(['project', 'user', 'reference']).describe('Memory topic category'),
title: z.string().min(1).max(200).describe('Entry title (used as identifier for update/delete)'),
content: z.string().optional().describe('Memory content body (required for create/update)'),
tags: z.array(z.string()).optional().describe('Optional tags for search'),
action: z.enum(['create', 'update', 'delete']).describe('Action to perform'),
});
type InputT = z.infer<typeof ManageMemoryInput>;
function titleToFilename(title: string): string {
return (
title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '') + '.md'
);
}
/**
* Try to update the CoreTier SQLite database in addition to the file store.
* This is best-effort — CoreTier is optional (file store is primary).
*/
async function syncCoreTier(
_root: string,
_topic: string,
_title: string,
_content: string,
_tags: string[],
): Promise<void> {
// CoreTier SQLite backend is not available in this build — file store only.
}
export const manageMemoryTool: ToolDef<InputT> = {
name: 'manage_memory',
description:
'Create, update, or delete memory entries in .boocode/memory/ for cross-session recall. ' +
'Use to persist project conventions, user preferences, and architectural decisions. ' +
'Actions: create (write new entry), update (modify existing entry), delete (remove entry).',
inputSchema: ManageMemoryInput,
jsonSchema: {
type: 'function',
function: {
name: 'manage_memory',
description: 'Manage memory entries — create, update, or delete',
parameters: {
type: 'object',
properties: {
topic: {
type: 'string',
enum: ['project', 'user', 'reference'],
description: 'Memory topic category',
},
title: { type: 'string', description: 'Entry title (identifier for update/delete)' },
content: {
type: 'string',
description: 'Memory content body (required for create/update)',
},
tags: {
type: 'array',
items: { type: 'string' },
description: 'Optional tags for search',
},
action: {
type: 'string',
enum: ['create', 'update', 'delete'],
description: 'Action to perform',
},
},
required: ['topic', 'title', 'action'],
},
},
},
async execute(input: InputT, projectRoot: string): Promise<unknown> {
const root = getMemoryRoot(projectRoot);
await ensureMemoryScaffold(root);
const filename = titleToFilename(input.title);
if (input.action === 'create') {
if (!input.content) {
return { error: 'Content is required for create action.' };
}
await writeEntry(root, input.topic, input.title, input.content, input.tags ?? []);
await syncCoreTier(root, input.topic, input.title, input.content, input.tags ?? []);
return {
result: `Memory entry "${input.title}" created in .boocode/memory/${input.topic}/`,
};
}
if (input.action === 'update') {
if (!input.content) {
return { error: 'Content is required for update action.' };
}
// Resolve target file path — try computed filename first, then heading match
let targetPath = join(root, input.topic, filename);
if (!existsSync(targetPath)) {
const files = await readTopicFiles(root, input.topic);
const matched = [...files.keys()].find((name) => {
const content = files.get(name);
return content?.trimStart().startsWith(`## ${input.topic}: ${input.title}`);
});
if (matched) {
targetPath = join(root, input.topic, matched);
} else {
return {
error: `Memory entry "${input.title}" not found in .boocode/memory/${input.topic}/`,
};
}
}
const tagLine =
(input.tags ?? []).length > 0
? `> tags: ${(input.tags ?? []).join(', ')}\n\n`
: '\n';
const entry = `## ${input.topic}: ${input.title}\n${tagLine}${input.content}\n`;
await writeFile(targetPath, entry, 'utf8');
await syncCoreTier(root, input.topic, input.title, input.content, input.tags ?? []);
return {
result: `Memory entry "${input.title}" updated in .boocode/memory/${input.topic}/`,
};
}
if (input.action === 'delete') {
// Resolve target file path
let targetPath = join(root, input.topic, filename);
if (!existsSync(targetPath)) {
const files = await readTopicFiles(root, input.topic);
const matched = [...files.keys()].find((name) => {
const content = files.get(name);
return content?.trimStart().startsWith(`## ${input.topic}: ${input.title}`);
});
if (matched) {
targetPath = join(root, input.topic, matched);
} else {
return {
error: `Memory entry "${input.title}" not found in .boocode/memory/${input.topic}/`,
};
}
}
await unlink(targetPath);
return {
result: `Memory entry "${input.title}" deleted from .boocode/memory/${input.topic}/`,
};
}
return { error: `Unknown action: ${input.action}` };
},
};

View File

@@ -40,6 +40,13 @@ import { searchMemoryTool } from './search_memory.js';
// vWhale: command execution tool. Spawns processes in the project worktree
// with timeout and output cap. No shell — args are passed as array.
import { runCommand } from './execute-command.js';
// v2.x: background subagent tools. Non-blocking subagent execution with
// spawn/poll/collect lifecycle. Reuses existing sessions/chats/messages/tasks.
import {
spawnSubagent,
subagentStatus,
subagentResult,
} from './background-subagent-tools.js';
// v1.13.3: alpha-sorted by tool.name at module load. llama.cpp's prompt
// cache hits on byte-identical prefixes; the tool list lives near the top
@@ -105,6 +112,10 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
// Read-write; use with guard: restricted to project root via path_guard,
// no shell injection (execFile, not exec).
runCommand as ToolDef<unknown>,
// v2.x: background subagent tools. Non-blocking spawn/poll/collect lifecycle.
spawnSubagent as ToolDef<unknown>,
subagentStatus as ToolDef<unknown>,
subagentResult as ToolDef<unknown>,
].sort((a, b) => a.name.localeCompare(b.name));
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(

View File

@@ -0,0 +1,376 @@
// v2.8.0: Workflow catalog — built-in workflow definitions that ship with
// BooCode. Each workflow is a metadata object with name, description, and a
// factory function that returns the workflow script source code.
//
// Built-in workflows are merged into the discovery list alongside file-based
// workflows from .boocode/workflows/. They take precedence over user-defined
// workflows with the same name.
import { createHash } from 'node:crypto';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* A built-in workflow definition shipped with BooCode.
*/
export interface BuiltinWorkflow {
/** Unique workflow name (used to invoke via `WorkflowManager`). */
name: string;
/** Human-readable description of what this workflow does. */
description: string;
/** Optional ordered phases for UI progress display. */
phases?: Array<{ title: string; detail?: string }>;
/**
* Generate the workflow script source code for this workflow.
* The returned string must be valid JS that exports `meta` and a `default`
* async function matching the `WorkflowScript` shape.
*
* @param args - Optional arguments provided when the workflow is started.
*/
generateScript: (args?: Record<string, unknown>) => string;
}
// ---------------------------------------------------------------------------
// Script templates (shared helpers)
// ---------------------------------------------------------------------------
/**
* Stable JSON serialisation for generating deterministic cache keys from
* structured arguments. Keys are sorted so the same data always produces
* the same string regardless of property insertion order.
*/
function stableJson(value: unknown): string {
if (value === null) return 'null';
if (typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map(stableJson).join(',')}]`;
}
const keys = Object.keys(value as Record<string, unknown>).sort();
const pairs = keys.map((k) => `${JSON.stringify(k)}:${stableJson((value as Record<string, unknown>)[k])}`);
return `{${pairs.join(',')}}`;
}
/**
* Compute a deterministic SHA-256 fingerprint for a combined spec + args
* payload. Used by the resumability cache to detect unchanged agent tasks.
*
* Exported for testing.
*/
export function fingerprintAgentTask(
prompt: string,
spec: Record<string, unknown>,
args: string,
): string {
return createHash('sha256')
.update(stableJson({ prompt, spec, args }))
.digest('hex');
}
// ---------------------------------------------------------------------------
// Built-in workflow definitions
// ---------------------------------------------------------------------------
function generateDeepResearchScript(_args?: Record<string, unknown>): string {
return `
export const meta = {
name: 'deep-research',
description: 'Multi-phase deep research: scope, search, fetch, verify, synthesise.',
phases: [
{ title: 'Scope', detail: 'Define the research question and search criteria' },
{ title: 'Search', detail: 'Query web sources in parallel' },
{ title: 'Fetch', detail: 'Retrieve full content from top sources' },
{ title: 'Verify', detail: 'Cross-reference and validate findings' },
{ title: 'Synthesise', detail: 'Produce a final structured report' },
],
};
export default async function main(args) {
const query = args?.query ?? 'No query provided';
log('deep-research: starting with query: ' + query);
// Phase 1: Scope
phase('Scope');
const scope = await agent(
'Analyse this research query and produce a search plan with 3-5 key sub-questions: ' + query,
{ label: 'scope-analysis', phase: 'scope' },
);
log('Scope completed');
// Phase 2: Search
phase('Search');
const searchResults = await agent(
'Based on the scope, search for authoritative sources. Return a list of 3-5 URLs with brief annotations.',
{ label: 'web-search', phase: 'search' },
);
log('Search completed');
// Phase 3: Fetch
phase('Fetch');
const fetchedContent = await agent(
'Extract and summarise the key information from these sources: ' + JSON.stringify(searchResults),
{ label: 'content-fetch', phase: 'fetch' },
);
log('Fetch completed');
// Phase 4: Verify
phase('Verify');
const verified = await agent(
'Cross-reference the fetched information. Note any contradictions, gaps, or weak sources: ' + JSON.stringify(fetchedContent),
{ label: 'verification', phase: 'verify' },
);
log('Verify completed');
// Phase 5: Synthesise
phase('Synthesise');
const report = await agent(
'Synthesise the verified information into a structured report with findings, sources, and confidence levels: ' + JSON.stringify(verified),
{ label: 'synthesis', phase: 'synthesise' },
);
log('deep-research: completed');
return {
ok: true,
output: report,
phases: { scope, searchResults, fetchedContent, verified, report },
};
}
`.trim();
}
function generateReviewCodeScript(_args?: Record<string, unknown>): string {
return `
export const meta = {
name: 'review-code',
description: 'Multi-perspective code review: correctness, security, performance, then synthesise.',
phases: [
{ title: 'Correctness', detail: 'Check logic, edge cases, and correctness' },
{ title: 'Security', detail: 'Analyse for vulnerabilities and unsafe patterns' },
{ title: 'Performance', detail: 'Identify performance bottlenecks and optimisation opportunities' },
{ title: 'Synthesise', detail: 'Merge perspectives into a unified review report' },
],
};
export default async function main(args) {
const target = args?.target ?? args?.path ?? '';
log('review-code: starting review of: ' + (target || '(no target specified)'));
const context = await agent(
'Read the code at ' + (target || 'the provided context') + ' and produce a summary of its structure and purpose.',
{ label: 'read-context', phase: 'context' },
);
// Phase 1: Correctness
phase('Correctness');
const correctness = await agent(
'Review this code for correctness. Check logical errors, edge cases, type safety, and concurrency issues:\\n' + JSON.stringify(context),
{ label: 'correctness-review', phase: 'correctness' },
);
// Phase 2: Security
phase('Security');
const security = await agent(
'Review this code for security vulnerabilities. Check for injection, auth bypasses, unsafe deserialisation, secret exposure:\\n' + JSON.stringify(context),
{ label: 'security-review', phase: 'security' },
);
// Phase 3: Performance
phase('Performance');
const performance = await agent(
'Review this code for performance issues. Check algorithmic complexity, unnecessary allocations, I/O patterns, caching opportunities:\\n' + JSON.stringify(context),
{ label: 'performance-review', phase: 'performance' },
);
// Phase 4: Synthesise
phase('Synthesise');
const report = await agent(
'Merge these three review perspectives into one structured report with severity-ranked findings:\\n' +
'--- Correctness ---\\n' + JSON.stringify(correctness) + '\\n' +
'--- Security ---\\n' + JSON.stringify(security) + '\\n' +
'--- Performance ---\\n' + JSON.stringify(performance),
{ label: 'synthesis', phase: 'synthesise' },
);
log('review-code: completed');
return {
ok: true,
output: report,
reviews: { correctness, security, performance },
};
}
`.trim();
}
function generateFindIssuesScript(_args?: Record<string, unknown>): string {
return `
export const meta = {
name: 'find-issues',
description: 'Iterative issue discovery — keep surfacing issues until consecutive rounds find nothing new.',
phases: [
{ title: 'Analyse', detail: 'Analyse the codebase for issues' },
{ title: 'Check dry', detail: 'Verify no new issues remain' },
],
};
export default async function main(args) {
const target = args?.target ?? args?.path ?? '.';
const maxRounds = args?.maxRounds ?? 5;
log('find-issues: starting on ' + target + ' (max ' + maxRounds + ' rounds)');
const allIssues = [];
let dryRounds = 0;
let round = 0;
while (dryRounds < 2 && round < maxRounds) {
round++;
phase('Analyse');
const context = allIssues.length > 0
? 'Previously found issues (exclude these):\\n' + JSON.stringify(allIssues)
: 'No issues found yet.';
const newIssues = await agent(
'Analyse ' + target + ' for bugs, code smells, and anti-patterns.\\n' + context + '\\nReturn a JSON array of issues. If none found, return an empty array.',
{ label: 'round-' + round + '-analysis', phase: 'analyse' },
);
let parsed: unknown[] = [];
try {
if (typeof newIssues === 'string') {
parsed = JSON.parse(newIssues);
} else if (Array.isArray(newIssues)) {
parsed = newIssues;
}
} catch {
parsed = [];
}
if (parsed.length === 0) {
dryRounds++;
phase('Check dry');
log('Round ' + round + ': no new issues found (dry run ' + dryRounds + '/2)');
} else {
dryRounds = 0;
for (const issue of parsed) {
allIssues.push(issue);
}
log('Round ' + round + ': found ' + parsed.length + ' new issue(s)');
}
}
log('find-issues: completed after ' + round + ' rounds, ' + allIssues.length + ' total issues');
return {
ok: true,
output: allIssues,
totalRounds: round,
totalIssues: allIssues.length,
};
}
`.trim();
}
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
/**
* All built-in workflow definitions shipped with BooCode.
*/
const BUILTIN_WORKFLOWS: BuiltinWorkflow[] = [
{
name: 'deep-research',
description:
'Performs multi-phase deep research: scope the question, search web sources in parallel, fetch full content, verify findings, and synthesise a structured report.',
phases: [
{ title: 'Scope', detail: 'Define the research question and search criteria' },
{ title: 'Search', detail: 'Query web sources in parallel' },
{ title: 'Fetch', detail: 'Retrieve full content from top sources' },
{ title: 'Verify', detail: 'Cross-reference and validate findings' },
{ title: 'Synthesise', detail: 'Produce a final structured report' },
],
generateScript: generateDeepResearchScript,
},
{
name: 'review-code',
description:
'Multi-perspective code review that analyses code for correctness, security vulnerabilities, and performance issues in parallel, then merges findings into a unified severity-ranked report.',
phases: [
{ title: 'Correctness', detail: 'Check logic, edge cases, and correctness' },
{ title: 'Security', detail: 'Analyse for vulnerabilities and unsafe patterns' },
{ title: 'Performance', detail: 'Identify performance bottlenecks' },
{ title: 'Synthesise', detail: 'Merge perspectives into a unified report' },
],
generateScript: generateReviewCodeScript,
},
{
name: 'find-issues',
description:
'Iterative issue discovery that runs analysis rounds until two consecutive passes find nothing new, ensuring comprehensive coverage without infinite loops.',
phases: [
{ title: 'Analyse', detail: 'Analyse the codebase for issues' },
{ title: 'Check dry', detail: 'Verify no new issues remain' },
],
generateScript: generateFindIssuesScript,
},
];
/**
* Read-only map of built-in workflows keyed by name.
*/
const BUILTIN_WORKFLOW_MAP = new Map<string, BuiltinWorkflow>(
BUILTIN_WORKFLOWS.map((w) => [w.name, w]),
);
/**
* Return all built-in workflow definitions.
*/
export function getBuiltinWorkflows(): BuiltinWorkflow[] {
return BUILTIN_WORKFLOWS;
}
/**
* Look up a built-in workflow by name.
*
* @param name - Workflow name (e.g. 'deep-research').
* @returns The built-in workflow, or undefined if not found.
*/
export function getBuiltinWorkflow(name: string): BuiltinWorkflow | undefined {
return BUILTIN_WORKFLOW_MAP.get(name);
}
/**
* Merge built-in workflow metadata into a list of file-discovered workflow
* entries. Built-in entries take precedence — if a user has a file-based
* workflow with the same name, the built-in version wins.
*
* @param fileWorkflows - Workflow metadata discovered from the filesystem.
* @returns Merged array with built-in workflows injected and duplicate names
* resolved (built-in wins).
*/
export function mergeBuiltinWorkflows(
fileWorkflows: Array<{ name: string; description: string; sourceFile?: string }>,
): Array<{ name: string; description: string; sourceFile?: string }> {
const seen = new Set<string>();
const result: Array<{ name: string; description: string; sourceFile?: string }> = [];
// Built-in workflows first (they take precedence)
for (const builtin of BUILTIN_WORKFLOWS) {
seen.add(builtin.name);
result.push({
name: builtin.name,
description: builtin.description,
// No sourceFile — built-in workflows are generated, not read from disk
});
}
// File-discovered workflows — skip any name already claimed by built-in
for (const fw of fileWorkflows) {
if (seen.has(fw.name)) continue;
seen.add(fw.name);
result.push(fw);
}
return result;
}

View File

@@ -0,0 +1,134 @@
// v2.8.0: Workflow file discovery — walks project-local and global workflow
// directories to find runnable scripts. Built-in workflows from the catalog
// are merged into the results (they take precedence over user-defined files).
// All functions exported for testing.
import { readdirSync, existsSync } from 'node:fs';
import { join, basename, extname } from 'node:path';
import { homedir } from 'node:os';
import { getBuiltinWorkflows, getBuiltinWorkflow } from './catalog.js';
/**
* Sentinel prefix used in `sourceFile` for built-in workflows from the
* catalog so callers (e.g. WorkflowManager) can detect and handle them
* by calling `generateScript()` instead of reading a file from disk.
*/
const BUILTIN_PREFIX = 'builtin:';
/**
* Metadata about a discovered workflow file (or built-in workflow).
*/
export interface WorkflowMeta {
/** Workflow name (file stem without .js extension). */
name: string;
/** Description loaded from the workflow module's `meta.description`.
* Empty string until loadWorkflowMeta() resolves it. */
description: string;
/** Absolute path to the .js file.
* For built-in workflows this is `'builtin:<name>'` — the caller
* should use `getBuiltinWorkflow(name)` and `generateScript()`
* instead of reading this path from disk. */
sourceFile: string;
}
/**
* Test whether a `WorkflowMeta.sourceFile` points to a built-in workflow
* (rather than a file on disk).
*
* @param meta - The workflow metadata to check.
*/
export function isBuiltinWorkflow(meta: WorkflowMeta): boolean {
return meta.sourceFile.startsWith(BUILTIN_PREFIX);
}
/**
* Find all workflow .js files in the standard search paths, merged with
* built-in workflows from the catalog.
*
* Priority order (first match wins for same-named workflows):
* 1. Built-in catalog (always takes precedence)
* 2. <projectRoot>/.boocode/workflows/ (project-local)
* 3. ~/.boocode/workflows/ (global, per-user)
*
* @param projectRoot - Absolute path to the current project root.
*/
export function discoverWorkflows(projectRoot: string): WorkflowMeta[] {
const seen = new Set<string>();
const results: WorkflowMeta[] = [];
// 1. Built-in workflows (highest priority)
for (const builtin of getBuiltinWorkflows()) {
seen.add(builtin.name);
results.push({
name: builtin.name,
description: builtin.description,
sourceFile: `${BUILTIN_PREFIX}${builtin.name}`,
});
}
// 2. Project-local + global file-based workflows
const dirs = [
join(projectRoot, '.boocode', 'workflows'),
join(homedir(), '.boocode', 'workflows'),
];
for (const dir of dirs) {
if (!existsSync(dir)) continue;
try {
const entries = readdirSync(dir);
for (const f of entries) {
if (!f.endsWith('.js')) continue;
const name = basename(f, '.js');
if (seen.has(name)) continue; // built-in shadows project-local,
// project-local shadows global
seen.add(name);
results.push({
name,
description: '',
sourceFile: join(dir, f),
});
}
} catch {
// Permission error on directory — skip silently
continue;
}
}
return results;
}
/**
* Find a single workflow by name across built-in catalog and search paths.
*
* Priority: built-in > project-local > global.
*
* @param name - Workflow name (without .js extension).
* @param projectRoot - Absolute path to the current project root.
*/
export function findWorkflow(
name: string,
projectRoot: string,
): WorkflowMeta | undefined {
// Check built-in catalog first
const builtin = getBuiltinWorkflow(name);
if (builtin) {
return {
name: builtin.name,
description: builtin.description,
sourceFile: `${BUILTIN_PREFIX}${builtin.name}`,
};
}
// Fall back to file-based discovery
return discoverWorkflows(projectRoot).find((w) => w.name === name);
}
/**
* Validate a candidate workflow file path.
* Checks that the file exists and has a .js extension.
*
* @param filePath - Absolute path to check.
*/
export function isValidWorkflowPath(filePath: string): boolean {
return extname(filePath) === '.js' && existsSync(filePath);
}

View File

@@ -0,0 +1,54 @@
// v2.8.0: Dynamic Workflow Engine — public surface.
//
// Re-exports all types and classes from the workflow sub-modules so consumers
// import from a single entry point:
//
// ```typescript
// import { WorkflowManager } from './services/workflow/index.js';
// ```
export { WorkflowManager } from './manager.js';
export type { WorkflowMetaInfo } from './manager.js';
export type { WorkflowEventHandler } from './manager.js';
export { discoverWorkflows, findWorkflow, isValidWorkflowPath, isBuiltinWorkflow } from './discovery.js';
export type { WorkflowMeta } from './discovery.js';
export {
loadWorkflowScript,
loadWorkflowScriptFromCode,
executeWorkflowScript,
executeWorkflowScriptFromCode,
buildSandbox,
transformEsmToCjs,
isEsmSyntax,
} from './sandbox.js';
export {
getBuiltinWorkflows,
getBuiltinWorkflow,
mergeBuiltinWorkflows,
fingerprintAgentTask,
} from './catalog.js';
export type { BuiltinWorkflow } from './catalog.js';
export {
cacheKey,
getCachedResult,
setCachedResult,
invalidateRun,
clearCache,
cacheSize,
} from './resumability.js';
export type { CachedResult } from './resumability.js';
export type {
WorkflowScript,
WorkflowScriptMeta,
WorkflowContext,
AgentTaskSpec,
AgentTaskResult,
WorkflowRun,
WorkflowRunStatus,
WorkflowEvent,
} from './types.js';

View File

@@ -0,0 +1,659 @@
// v2.8.0: WorkflowManager — ties discovery, sandbox, and inference dispatch
// together into a single orchestrator for multi-agent workflow scripts.
//
// Creates isolated sessions+chats for each agent() call within a workflow,
// dispatches inference via the existing pipeline, polls for completion, and
// returns structured results. All failures are returned as errors rather than
// thrown exceptions (catch-safe API).
import { randomUUID } from 'node:crypto';
import type { Sql } from '../../db.js';
import type { Config } from '../../config.js';
import type { FastifyBaseLogger } from 'fastify';
import type { Broker } from '../broker.js';
import type { UserStreamFrame } from '../../types/api.js';
import type {
WorkflowRun,
WorkflowRunStatus,
WorkflowContext,
WorkflowEvent,
AgentTaskSpec,
AgentTaskResult,
WorkflowScriptMeta,
} from './types.js';
import { discoverWorkflows, findWorkflow, isBuiltinWorkflow } from './discovery.js';
import { getBuiltinWorkflow } from './catalog.js';
import { cacheKey, getCachedResult, setCachedResult } from './resumability.js';
import {
executeWorkflowScript,
executeWorkflowScriptFromCode,
isEsmSyntax,
transformEsmToCjs,
} from './sandbox.js';
import { runInference } from '../inference/index.js';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
/**
* Maximum time to wait for a single agent task to complete (5 minutes).
* Beyond this, the task is treated as failed/timed out.
*/
const AGENT_TASK_TIMEOUT_MS = 300_000;
/**
* Polling interval when waiting for an agent task to finish.
*/
const POLL_INTERVAL_MS = 500;
/**
* Maximum time for the entire workflow run (30 minutes).
*/
const WORKFLOW_TIMEOUT_MS = 1_800_000;
/**
* Token budget tracker. Tracks total token spend across agent calls.
*/
class BudgetTracker {
total: number | null;
#spent = 0;
constructor(total: number | null) {
this.total = total;
}
spend(amount: number): void {
this.#spent += amount;
}
spent(): number {
return this.#spent;
}
remaining(): number {
if (this.total === null) return Infinity;
return Math.max(0, this.total - this.#spent);
}
}
/**
* Creates a no-op bounded publish function that avoids WS dependency
* for background workflow agent tasks. Messages are still persisted to DB.
*/
function noopPublish(): void {
/* intentional no-op */
}
function noopPublishUser(): void {
/* intentional no-op */
}
/**
* Callback type for workflow lifecycle events.
*/
export type WorkflowEventHandler = (event: WorkflowEvent) => void;
/**
* WorkflowManager — the orchestrator for sandboxed multi-agent workflows.
*/
export class WorkflowManager {
/** Active workflow runs by run ID. */
readonly #runs = new Map<string, WorkflowRunState>();
/** Registered event listeners. */
readonly #listeners = new Set<WorkflowEventHandler>();
constructor(
private sql: Sql,
private config: Config,
private log: FastifyBaseLogger,
private projectRoot: string,
private projectId: string,
private broker: Broker,
) {}
// ---- public API ----
/**
* Discover all available workflow scripts.
*/
listWorkflows(): WorkflowMetaInfo[] {
return discoverWorkflows(this.projectRoot).map((m) => ({
name: m.name,
sourceFile: m.sourceFile,
}));
}
/**
* Find a specific workflow by name.
*/
getWorkflow(name: string): WorkflowMetaInfo | undefined {
const found = findWorkflow(name, this.projectRoot);
if (!found) return undefined;
return { name: found.name, sourceFile: found.sourceFile };
}
/**
* Load the metadata (name, description, phases) from a workflow file
* without executing it.
*
* @param name - Workflow name.
* @returns The script's meta, or undefined if not found.
*/
async loadWorkflowMeta(name: string): Promise<WorkflowScriptMeta | undefined> {
const found = findWorkflow(name, this.projectRoot);
if (!found) return undefined;
// Built-in workflows: return meta directly from the catalog
if (isBuiltinWorkflow(found)) {
const builtin = getBuiltinWorkflow(name);
if (!builtin) return { name, description: '' };
return {
name: builtin.name,
description: builtin.description,
phases: builtin.phases,
};
}
try {
// Load meta by executing the script in a throwaway context
const context = this.#createMinimalContext('meta-loader');
const code = readFileSync(found.sourceFile, 'utf8');
const finalCode = isEsmSyntax(code) ? transformEsmToCjs(code) : code;
const sandboxData: Record<string, unknown> & {
module: { exports: Record<string, unknown> };
} = {
...context,
console: { log: () => {} },
module: { exports: {} },
exports: {},
};
vm.createContext(sandboxData as unknown as vm.Context);
new vm.Script(finalCode).runInContext(sandboxData as unknown as vm.Context, {
timeout: 10_000,
filename: found.sourceFile,
});
const meta = sandboxData.module.exports.meta as WorkflowScriptMeta | undefined;
return meta ?? { name, description: '' };
} catch {
return { name, description: '' };
}
}
/**
* Execute a workflow by name.
*
* @param name - The workflow name (without .js extension).
* @param args - Optional arguments to pass to the workflow function.
* @returns The run ID for tracking.
*/
async runWorkflow(
name: string,
args?: Record<string, unknown>,
): Promise<{ runId: string }> {
const found = findWorkflow(name, this.projectRoot);
if (!found) {
throw new Error(`Workflow not found: "${name}". ` +
`Check .boocode/workflows/ or ~/.boocode/workflows/ for a ${name}.js file.`);
}
const runId = randomUUID();
const startedAt = new Date().toISOString();
const state: WorkflowRunState = {
id: runId,
name,
status: 'running',
startedAt,
abortController: new AbortController(),
};
this.#runs.set(runId, state);
this.#emit({ type: 'run_started', runId, name });
// Run asynchronously — caller receives the runId immediately.
void this.#executeRun(state, found.sourceFile, args ?? {});
return { runId };
}
/**
* Get the current status of a workflow run.
*/
getRunStatus(runId: string): WorkflowRun | undefined {
const state = this.#runs.get(runId);
if (!state) return undefined;
return {
id: state.id,
name: state.name,
status: state.status,
started_at: state.startedAt,
finished_at: state.finishedAt,
error: state.error,
};
}
/**
* Cancel a running workflow. Best-effort — agent tasks in-flight will be
* aborted via AbortSignal.
*
* @param runId - The workflow run ID.
* @returns true if the workflow was found and cancelled.
*/
cancelRun(runId: string): boolean {
const state = this.#runs.get(runId);
if (!state || state.status !== 'running') return false;
state.status = 'cancelled';
state.finishedAt = new Date().toISOString();
state.abortController.abort();
this.#emit({ type: 'run_cancelled', runId, name: state.name });
return true;
}
/**
* Subscribe to workflow lifecycle events.
* Returns an unsubscribe function.
*/
onEvent(handler: WorkflowEventHandler): () => void {
this.#listeners.add(handler);
return () => {
this.#listeners.delete(handler);
};
}
// ---- internal execution ----
/**
* Execute the workflow script in the sandbox.
*/
async #executeRun(
state: WorkflowRunState,
sourceFile: string,
args: Record<string, unknown>,
): Promise<void> {
const BULTIN_MARKER = 'builtin:';
const budgetTracker = new BudgetTracker(null); // no fixed total yet
const runId = state.id;
try {
const context: WorkflowContext = {
agent: (prompt, opts) =>
this.#handleAgentCall(runId, prompt, opts ?? { prompt }, state.abortController.signal),
parallel: (thunks) =>
Promise.all(thunks.map((t) => t())),
pipeline: async (items, ...stages) => {
let result = [...items];
for (const stage of stages) {
result = await Promise.all(result.map(stage));
}
return result;
},
phase: (title) => {
this.#emit({ type: 'phase', runId, title });
},
log: (message) => {
this.#emit({ type: 'log', runId, message });
},
budget: {
total: budgetTracker.total,
spent: () => budgetTracker.spent(),
remaining: () => budgetTracker.remaining(),
},
args,
workflow: (nestedName, nestedArgs) =>
this.#handleNestedWorkflow(runId, nestedName, nestedArgs ?? {}, state.abortController.signal),
};
let result: unknown;
if (sourceFile.startsWith(BULTIN_MARKER)) {
// Built-in workflow: generate script from catalog and execute
const workflowName = sourceFile.slice(BULTIN_MARKER.length);
const builtin = getBuiltinWorkflow(workflowName);
if (!builtin) {
throw new Error(`Built-in workflow "${workflowName}" not found in catalog`);
}
const scriptCode = builtin.generateScript(args);
result = await executeWorkflowScriptFromCode(scriptCode, context, args, sourceFile);
} else {
result = await executeWorkflowScript(sourceFile, context, args);
}
// Only update to completed if we haven't been cancelled mid-flight.
if (state.status !== 'cancelled') {
state.status = 'completed';
state.finishedAt = new Date().toISOString();
}
// Store result
state.result = result;
this.#emit({ type: 'run_completed', runId, name: state.name });
} catch (err) {
if (state.status === 'cancelled') return; // already handled
const message = err instanceof Error ? err.message : String(err);
state.status = 'failed';
state.finishedAt = new Date().toISOString();
state.error = message;
this.#emit({ type: 'run_failed', runId, name: state.name, error: message });
}
}
/**
* Handle an `agent()` call from within a workflow.
* Creates a session + chat, dispatches inference, polls for completion.
*/
async #handleAgentCall(
runId: string,
prompt: string,
spec: AgentTaskSpec,
signal: AbortSignal,
): Promise<unknown> {
const label = spec.label ?? `agent-${prompt.slice(0, 40).replace(/\s+/g, '_')}`;
this.#emit({ type: 'agent_task_started', runId, label });
try {
const result = await this.executeAgentTask(prompt, spec, signal);
this.#emit({ type: 'agent_task_completed', runId, label });
return result;
} catch (err) {
this.#emit({ type: 'agent_task_completed', runId, label });
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
output: null,
error: message,
} satisfies AgentTaskResult;
}
}
/**
* Core agent task execution: create session/chat, dispatch inference, poll.
*
* Exported as a public method for testing.
*/
async executeAgentTask(
prompt: string,
spec: AgentTaskSpec,
signal?: AbortSignal,
): Promise<unknown> {
// ---- 0. Check resumability cache before creating a new task ----
const cacheKeyStr = cacheKey(spec, '');
const cached = getCachedResult(cacheKeyStr);
if (cached) {
return { ...cached, cached: true } satisfies AgentTaskResult;
}
const model = spec.model ?? null;
// ---- 1. Create a session for this agent task ----
const sessionName = `workflow-agent-${spec.label ?? 'task'}`;
const sessionResult = await this.sql.begin(async (tx) => {
const [session] = await tx<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model)
VALUES (${this.projectId}, ${sessionName}, ${model ?? 'qwen3.6-35b-a3b-mxfp4'})
RETURNING id
`;
if (!session) throw new Error('Failed to create workflow agent session');
return session;
});
const sessionId = sessionResult.id;
// ---- 2. Create a chat in this session ----
const chatResult = await this.sql.begin(async (tx) => {
const [chat] = await tx<{ id: string }[]>`
INSERT INTO chats (session_id, name)
VALUES (${sessionId}, ${spec.label ?? null})
RETURNING id
`;
if (!chat) throw new Error('Failed to create workflow agent chat');
return chat;
});
const chatId = chatResult.id;
// ---- 3. Insert user message + streaming assistant message ----
const { userMessageId, assistantMessageId } = await this.sql.begin(async (tx) => {
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${prompt}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
return {
userMessageId: userMsg!.id,
assistantMessageId: assistantMsg!.id,
};
});
// ---- 4. Dispatch inference ----
// Create a bounded InferenceContext that won't crash on missing WS
const ctx: import('../inference/types.js').InferenceContext = {
sql: this.sql,
config: this.config,
log: this.log,
publish: noopPublish as unknown as import('../inference/types.js').FramePublisher,
publishUser: noopPublishUser as unknown as (frame: UserStreamFrame) => void,
broker: this.broker,
};
// Create a merged signal (workflow cancellation + optional caller signal)
const mergedController = new AbortController();
const onAbort = () => mergedController.abort();
signal?.addEventListener('abort', onAbort, { once: true });
const inferencePromise = runInference(
ctx,
sessionId,
chatId,
assistantMessageId,
mergedController.signal,
).finally(() => {
signal?.removeEventListener('abort', onAbort);
});
// ---- 5. Poll for completion ----
try {
const result = await this.#pollForCompletion(
chatId,
assistantMessageId,
inferencePromise,
mergedController.signal,
);
// Cache successful results for resumability
if (typeof result === 'object' && result !== null && (result as Record<string, unknown>).ok === true) {
setCachedResult(cacheKeyStr, {
ok: true,
output: (result as Record<string, unknown>).output,
token_usage: (result as Record<string, unknown>).token_usage as
| { prompt: number; completion: number }
| undefined,
});
}
return result;
} catch (err) {
if ((err as Error)?.message === 'cancelled') {
return { ok: false, output: null, error: 'Task was cancelled' } satisfies AgentTaskResult;
}
return {
ok: false,
output: null,
error: err instanceof Error ? err.message : String(err),
} satisfies AgentTaskResult;
}
}
/**
* Poll the messages table until the assistant message status changes
* from 'streaming' to 'complete' / 'failed' / 'cancelled'.
*/
async #pollForCompletion(
chatId: string,
assistantMessageId: string,
inferencePromise: Promise<void>,
signal: AbortSignal,
): Promise<unknown> {
// Wait for either inference to finish or timeout
const timeout = new Promise<never>((_, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Agent task timed out after ${AGENT_TASK_TIMEOUT_MS}ms`));
}, AGENT_TASK_TIMEOUT_MS);
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(new Error('cancelled'));
}, { once: true });
});
// Poll loop — runs until inference completes, timeout, or cancellation
const pollLoop = (async () => {
// eslint-disable-next-line no-constant-condition
while (true) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
const rows = await this.sql<{
status: string;
content: string;
tool_calls: unknown;
tokens_used: number | null;
}[]>`
SELECT m.status, m.content, m.role,
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
FROM message_parts p
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL) AS tool_calls,
m.tokens_used
FROM messages m
WHERE m.id = ${assistantMessageId}
`;
const msg = rows[0];
if (!msg) {
throw new Error(`Assistant message ${assistantMessageId} not found`);
}
if (msg.status === 'complete') {
return {
ok: true,
output: msg.content,
token_usage: msg.tokens_used ? { prompt: 0, completion: msg.tokens_used } : undefined,
};
}
if (msg.status === 'failed' || msg.status === 'cancelled') {
return {
ok: false,
output: msg.content || null,
error: `Assistant message ended with status: ${msg.status}`,
};
}
// Still streaming — continue polling
}
})();
// Race: polling vs timeout vs inference error vs cancellation
try {
return await Promise.race([pollLoop, timeout]);
} finally {
// Ensure inference is settled (but don't block on it)
inferencePromise.catch(() => {});
}
}
/**
* Handle a nested `workflow()` call from within a workflow.
* Runs the named workflow with the given args and returns its result.
*/
async #handleNestedWorkflow(
parentRunId: string,
name: string,
args: Record<string, unknown>,
signal: AbortSignal,
): Promise<unknown> {
const found = findWorkflow(name, this.projectRoot);
if (!found) {
return { ok: false, output: null, error: `Nested workflow not found: "${name}"` };
}
const nestedRunId = randomUUID();
const startedAt = new Date().toISOString();
const nestedState: WorkflowRunState = {
id: nestedRunId,
name,
status: 'running',
startedAt,
abortController: new AbortController(),
};
this.#runs.set(nestedRunId, nestedState);
this.#emit({ type: 'run_started', runId: nestedRunId, name });
// Link parent cancellation to nested
signal.addEventListener('abort', () => {
nestedState.abortController.abort();
}, { once: true });
await this.#executeRun(nestedState, found.sourceFile, args);
if (nestedState.status === 'cancelled') {
return { ok: false, output: null, error: 'Nested workflow cancelled' };
}
if (nestedState.status === 'failed') {
return { ok: false, output: null, error: nestedState.error };
}
return { ok: true, output: nestedState.result };
}
/**
* Create a minimal WorkflowContext for non-execution purposes
* (e.g. loading meta).
*/
#createMinimalContext(runId: string): Record<string, unknown> {
return {
agent: () => Promise.reject(new Error('Not available in this context')),
parallel: () => Promise.reject(new Error('Not available in this context')),
pipeline: () => Promise.reject(new Error('Not available in this context')),
phase: () => {},
log: () => {},
budget: { total: null, spent: () => 0, remaining: () => Infinity },
args: {},
workflow: () => Promise.reject(new Error('Not available in this context')),
};
}
/**
* Emit a workflow event to all registered listeners.
*/
#emit(event: WorkflowEvent): void {
for (const handler of this.#listeners) {
try {
handler(event);
} catch {
// Swallow listener errors — one bad listener shouldn't break others
}
}
}
}
// ---- internal types ----
/**
* Metadata returned from listWorkflows / getWorkflow.
*/
export interface WorkflowMetaInfo {
name: string;
sourceFile: string;
}
/**
* Internal mutable state for an active workflow run.
*/
interface WorkflowRunState {
id: string;
name: string;
status: WorkflowRunStatus;
startedAt: string;
finishedAt?: string;
error?: string;
result?: unknown;
abortController: AbortController;
}

View File

@@ -0,0 +1,195 @@
// v2.8.0: Workflow resumability cache — SHA-256 hash-based in-memory cache
// for completed agent task results. When a workflow re-runs, completed agents
// with unchanged specs skip execution and return cached results.
//
// The cache is purely in-memory (Map). No DB persistence for v1.
// All functions are exported for testing.
import { createHash } from 'node:crypto';
import type { AgentTaskSpec } from './types.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Shape of a cached agent task result. Mirrors the successful fields of
* `AgentTaskResult` without the runtime-only `cached` flag.
*/
export interface CachedResult {
ok: boolean;
output: unknown;
error?: string;
token_usage?: { prompt: number; completion: number };
}
/**
* Internal cache entry with insertion timestamp for TTL support.
*/
interface CacheEntry {
result: CachedResult;
insertedAt: number;
}
// ---------------------------------------------------------------------------
// Cache store
// ---------------------------------------------------------------------------
/**
* Default TTL for cached entries (30 minutes).
* After this period entries are considered stale and are evicted on access.
*/
const DEFAULT_TTL_MS = 1_800_000;
/**
* Maximum number of entries before the cache starts evicting oldest entries.
*/
const MAX_ENTRIES = 500;
/**
* In-memory cache store: SHA-256 hash → cached result.
*/
const cache = new Map<string, CacheEntry>();
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Build a deterministic SHA-256 hash for an agent task specification.
*
* The hash is computed from a stable-ordered JSON serialisation of the spec
* (prompt + options) so that identical specs always produce the same key
* regardless of JavaScript property insertion order.
*
* @param spec - The agent task specification (prompt, options, etc.).
* @param args - Additional arguments string (e.g. workflow args fingerprint).
* @returns A 64-character hex SHA-256 digest.
*/
export function cacheKey(spec: AgentTaskSpec, args: string): string {
const hash = createHash('sha256');
// Stable-sorted serialisation of the spec
hash.update(stableJson(spec));
// Append the args fingerprint
hash.update('\0');
hash.update(args);
return hash.digest('hex');
}
/**
* Look up a cached result by its cache key.
*
* Returns `null` when:
* - The key doesn't exist in the cache.
* - The cached entry has exceeded the TTL (evicted silently).
*
* @param key - The SHA-256 hex key returned by `cacheKey()`.
* @returns The cached result, or `null` if not found or expired.
*/
export function getCachedResult(key: string): CachedResult | null {
const entry = cache.get(key);
if (!entry) return null;
// TTL check — stale entries are evicted on access
if (Date.now() - entry.insertedAt > DEFAULT_TTL_MS) {
cache.delete(key);
return null;
}
return entry.result;
}
/**
* Store an agent task result in the cache.
*
* If the cache has reached `MAX_ENTRIES`, the oldest entry (by insertion time)
* is evicted first. This is a simple FIFO eviction — not a full LRU — because
* workflow runs are expected to exhibit high temporal locality (recently
* completed steps in the current run are the most likely to be re-queried).
*
* @param key - The SHA-256 hex key returned by `cacheKey()`.
* @param result - The result to cache.
*/
export function setCachedResult(key: string, result: CachedResult): void {
// Evict oldest entry if at capacity
if (cache.size >= MAX_ENTRIES) {
let oldestKey: string | undefined;
let oldestTime = Infinity;
for (const [k, entry] of cache) {
if (entry.insertedAt < oldestTime) {
oldestTime = entry.insertedAt;
oldestKey = k;
}
}
if (oldestKey) {
cache.delete(oldestKey);
}
}
cache.set(key, {
result,
insertedAt: Date.now(),
});
}
/**
* Invalidate all cached entries that were produced during a specific workflow
* run. The `runKey` is matched as a prefix of the cache key — this works
* because `cacheKey()` incorporates the args string, and the caller passes
* a run-specific token as the `args` parameter.
*
* @param runKey - The run-specific key prefix to invalidate.
*/
export function invalidateRun(runKey: string): void {
for (const key of cache.keys()) {
if (key.startsWith(runKey)) {
cache.delete(key);
}
}
}
/**
* Clear the entire cache. Used for testing and manual reset.
*/
export function clearCache(): void {
cache.clear();
}
/**
* Return the current number of entries in the cache.
* Useful for testing assertions.
*/
export function cacheSize(): number {
return cache.size;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Stable JSON serialisation that produces the same output string for the same
* data regardless of JavaScript object property insertion order.
*
* - Object keys are sorted lexicographically.
* - Arrays preserve their element order.
* - Primitives are serialised via `JSON.stringify`.
*/
function stableJson(value: unknown): string {
if (value === null) return 'null';
if (typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map(stableJson).join(',')}]`;
}
const keys = Object.keys(value as Record<string, unknown>).sort();
const pairs = keys.map(
(k) =>
`${JSON.stringify(k)}:${stableJson((value as Record<string, unknown>)[k])}`,
);
return `{${pairs.join(',')}}`;
}

View File

@@ -0,0 +1,284 @@
// v2.8.0: VM sandbox for executing workflow scripts in an isolated Node.js
// context with a restricted global scope. Uses Node's built-in `vm` module
// (zero additional dependencies).
//
// Workflow scripts can use either CommonJS (`module.exports`) or ESM syntax
// (`export const` / `export default`). ESM syntax is automatically transformed
// to CJS before execution via a lightweight regex transform.
import vm from 'node:vm';
import { readFileSync } from 'node:fs';
import type { WorkflowContext } from './types.js';
/**
* Shared timeout for all sandboxed script execution.
* Prevents runaway workflows from blocking the server indefinitely.
*/
const EXECUTION_TIMEOUT_MS = 30_000;
/**
* Regex-based ESM-to-CJS transform for workflow scripts.
*
* Handles:
* - `export const|let|var <name> = <value>;` → `<name> = <value>;`
* - `export default <expression>;` → `default = <expression>;`
* - `export default function <name>(...) {...}` → `default = function <name>(...) {...}`
* - `export { <name1>, <name2> }` → removed (inline assignment)
*
* @param code - Raw source code (ESM or CJS).
* @returns Code transformed to CJS assignments suitable for vm.Script.
*/
export function transformEsmToCjs(code: string): string {
// Remove `export ` prefix from declarations and `export default` assignments.
// Order matters: handle `export default function` before bare `export default`.
let transformed = code
// export default async function name(...) {...} → default = async function name(...) {...}
.replace(
/export\s+default\s+(async\s+)?function\s*\**\s*(\w+)?\s*\(/g,
(_, asyncKw, _name) => {
return `default = ${asyncKw ?? ''}function ${_name ?? ''}(`;
},
)
// export default class Name {...} → default = class Name {...}
.replace(/export\s+default\s+(class\s+\w+)/g, 'default = $1')
// export default <expression>; → default = <expression>;
.replace(/export\s+default\s+/g, 'default = ')
// export const|let|var name = value → name = value
.replace(
/export\s+(const|let|var)\s+(\w+)\s*=/g,
(_, _decl, name) => `${name} =`,
)
// export function name(...) {...} → (hoisted, keep as-is but remove export)
.replace(/^export\s+(function\s+\w+)/gm, '$1')
// export class Name {...} → keep but remove export
.replace(/^export\s+(class\s+\w+)/gm, '$1')
// export { a, b, c } → (remove line)
.replace(/^export\s+\{[^}]*\}\s*;?\s*$/gm, '')
// export { a, b as c } → (remove line)
.replace(/^export\s+\{[^}]*\s+as\s+\w+[^}]*\}\s*;?\s*$/gm, '');
return transformed;
}
/**
* Determine whether code uses ESM export syntax (export keyword at line start
* or after optional whitespace).
*/
export function isEsmSyntax(code: string): boolean {
return /^\s*export\s+(const|let|var|function|class|default|\{)/m.test(code);
}
/**
* Build a restricted sandbox object with the workflow runtime API.
*
* @param context - The WorkflowContext methods to expose to the script.
* @returns A plain object suitable for vm.createContext().
*/
export function buildSandbox(context: WorkflowContext): Record<string, unknown> {
return {
// --- Workflow API (from context) ---
agent: context.agent,
parallel: context.parallel,
pipeline: context.pipeline,
phase: context.phase,
log: context.log,
budget: context.budget,
args: context.args,
workflow: context.workflow,
// --- Safe built-ins ---
console: {
log: context.log,
warn: context.log,
error: context.log,
},
setTimeout,
clearTimeout,
setInterval: undefined, // intentionally disabled
clearInterval: undefined, // intentionally disabled
Promise,
JSON,
Math,
Date,
RegExp,
Error,
Array,
Object,
String,
Number,
Boolean,
Map,
Set,
WeakMap,
WeakSet,
parseInt,
parseFloat,
isNaN,
isFinite,
Symbol,
BigInt,
undefined,
null: null,
true: true,
false: false,
// --- CommonJS interop ---
module: { exports: {} },
exports: {},
require: undefined, // intentionally disabled
global: undefined, // prevent escape via `globalThis`
};
}
/**
* Execute a workflow script in the sandbox and return its default export
* (the main async function).
*
* @param sourceFile - Absolute path to the .js workflow file.
* @param context - The WorkflowContext to expose to the script.
* @returns The workflow's default export function.
* @throws {Error} If the script doesn't export a default async function,
* or if execution fails.
*/
export function loadWorkflowScript(
sourceFile: string,
context: WorkflowContext,
): (...args: unknown[]) => Promise<unknown> {
const code = readFileSync(sourceFile, 'utf8');
const finalCode = isEsmSyntax(code) ? transformEsmToCjs(code) : code;
const rawSandbox = buildSandbox(context);
const sandbox = rawSandbox as Record<string, unknown> & {
module: { exports: Record<string, unknown> };
};
vm.createContext(sandbox);
try {
const script = new vm.Script(finalCode);
script.runInContext(sandbox, {
timeout: EXECUTION_TIMEOUT_MS,
filename: sourceFile,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Workflow script execution failed: ${msg}`);
}
// Check module.exports first (CJS), then sandbox.default (ESM transform)
const exported = sandbox.module.exports.default ?? sandbox.default;
// Also support `module.exports = async function(...)` (direct assignment)
const mainFn =
typeof sandbox.module.exports === 'function'
? sandbox.module.exports
: exported;
if (typeof mainFn !== 'function') {
const exportedKeys = Object.keys({
...sandbox.module.exports,
...(sandbox.default ? { default: true } : {}),
});
throw new Error(
`Workflow script must export a default async function. ` +
`Found exports: ${exportedKeys.join(', ') || '(none)'}. ` +
`Make sure your script has "export default async function main(args) {...}".`,
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return mainFn as (...args: unknown[]) => Promise<unknown>;
}
/**
* Load a workflow script from a source code string (rather than a file).
* Useful for built-in workflows from the catalog that don't have a
* corresponding .js file on disk.
*
* @param code - The JavaScript source code of the workflow.
* @param context - The WorkflowContext to expose.
* @param filename - Virtual filename for stack traces (e.g. 'builtin://deep-research').
* @returns The workflow's default export function.
* @throws {Error} If the script doesn't export a default async function.
*/
export function loadWorkflowScriptFromCode(
code: string,
context: WorkflowContext,
filename?: string,
): (...args: unknown[]) => Promise<unknown> {
const finalCode = isEsmSyntax(code) ? transformEsmToCjs(code) : code;
const rawSandbox = buildSandbox(context);
const sandbox = rawSandbox as Record<string, unknown> & {
module: { exports: Record<string, unknown> };
};
vm.createContext(sandbox);
try {
const script = new vm.Script(finalCode);
script.runInContext(sandbox, {
timeout: EXECUTION_TIMEOUT_MS,
filename: filename ?? 'workflow:<anonymous>',
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Workflow script execution failed: ${msg}`);
}
const exported = sandbox.module.exports.default ?? sandbox.default;
const mainFn =
typeof sandbox.module.exports === 'function'
? sandbox.module.exports
: exported;
if (typeof mainFn !== 'function') {
const exportedKeys = Object.keys({
...sandbox.module.exports,
...(sandbox.default ? { default: true } : {}),
});
throw new Error(
`Workflow script must export a default async function. ` +
`Found exports: ${exportedKeys.join(', ') || '(none)'}.`,
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return mainFn as (...args: unknown[]) => Promise<unknown>;
}
/**
* High-level convenience: load and execute a workflow script in a single call.
*
* @param sourceFile - Absolute path to the .js workflow file.
* @param context - The WorkflowContext to expose.
* @param args - Optional arguments passed to the workflow function.
* @returns The workflow's return value.
*/
export async function executeWorkflowScript(
sourceFile: string,
context: WorkflowContext,
args?: Record<string, unknown>,
): Promise<unknown> {
const mainFn = loadWorkflowScript(sourceFile, context);
return mainFn(args);
}
/**
* Execute a workflow from source code (string) rather than a file.
* Convenience wrapper around `loadWorkflowScriptFromCode`.
*
* @param code - The JavaScript source code of the workflow.
* @param context - The WorkflowContext to expose.
* @param args - Optional arguments passed to the workflow function.
* @param filename - Virtual filename for stack traces.
* @returns The workflow's return value.
*/
export async function executeWorkflowScriptFromCode(
code: string,
context: WorkflowContext,
args?: Record<string, unknown>,
filename?: string,
): Promise<unknown> {
const mainFn = loadWorkflowScriptFromCode(code, context, filename);
return mainFn(args);
}

View File

@@ -0,0 +1,128 @@
// v2.8.0: Dynamic Workflow Engine — types for the sandboxed multi-agent
// orchestration runtime. All types are exported for testing.
/**
* The expected shape of a workflow script module.
* Workflow files are plain .js files that export `meta` and `default`:
*
* ```js
* export const meta = {
* name: 'my-workflow',
* description: 'Does something useful in phases',
* phases: [
* { title: 'Research', detail: 'Gather context' },
* { title: 'Implement', detail: 'Make changes' },
* ],
* };
*
* export default async function main(args) {
* const result = await agent('...');
* return result;
* }
* ```
*/
export interface WorkflowScriptMeta {
name: string;
description: string;
phases?: Array<{ title: string; detail?: string }>;
}
export interface WorkflowScript {
meta: WorkflowScriptMeta;
default: (args?: Record<string, unknown>) => Promise<unknown>;
}
/**
* Specification for dispatching a single agent task within a workflow.
*/
export interface AgentTaskSpec {
/** The instruction prompt for the agent. */
prompt: string;
/** Optional human-readable label for this task (shown in UI). */
label?: string;
/** Phase identifier for grouping tasks. */
phase?: string;
/** Model override (defaults to session/chat model). */
model?: string;
/** Zod-style JSON schema for structured output validation. */
schema?: Record<string, unknown>;
/** Required capabilities the agent must have. */
capabilities?: string[];
/** Per-agent tool-call budget ceiling. */
max_tool_calls?: number;
/** Per-agent step cap for the inference loop. */
max_tool_iters?: number;
}
/**
* Result returned after an agent task completes.
*/
export interface AgentTaskResult {
ok: boolean;
output: unknown;
error?: string;
token_usage?: { prompt: number; completion: number };
/** True when this result was served from the resumability cache
* rather than re-executing the agent task. */
cached?: boolean;
}
/**
* Runtime context passed into every workflow script's default function.
* Mirrors the Claude Code-compatible API surface.
*/
export interface WorkflowContext {
/** Dispatch a single agent prompt. Returns the assistant's reply content. */
agent: (prompt: string, opts?: AgentTaskSpec) => Promise<unknown>;
/** Run multiple independent tasks concurrently. Returns results in order. */
parallel: (thunks: Array<() => Promise<unknown>>) => Promise<unknown[]>;
/** Pass items through a sequence of transform stages. */
pipeline: (
items: unknown[],
...stages: Array<(item: unknown) => Promise<unknown>>
) => Promise<unknown[]>;
/** Announce the current execution phase (for UI progress). */
phase: (title: string) => void;
/** Emit a log message for this workflow run. */
log: (message: string) => void;
/** Token budget tracker for the current run. */
budget: {
total: number | null;
spent: () => number;
remaining: () => number;
};
/** The arguments passed when this workflow was started. */
args: Record<string, unknown>;
/** Call another workflow from within a workflow (nested). */
workflow: (name: string, args?: Record<string, unknown>) => Promise<unknown>;
}
/**
* Status of a workflow execution run.
*/
export type WorkflowRunStatus = 'running' | 'completed' | 'failed' | 'cancelled';
/**
* Persistent record of a workflow run.
*/
export interface WorkflowRun {
id: string;
name: string;
status: WorkflowRunStatus;
started_at: string;
finished_at?: string;
error?: string;
}
/**
* Event emitted by the workflow manager for subscribers.
*/
export type WorkflowEvent =
| { type: 'run_started'; runId: string; name: string }
| { type: 'run_completed'; runId: string; name: string }
| { type: 'run_failed'; runId: string; name: string; error: string }
| { type: 'run_cancelled'; runId: string; name: string }
| { type: 'phase'; runId: string; title: string }
| { type: 'log'; runId: string; message: string }
| { type: 'agent_task_started'; runId: string; label?: string }
| { type: 'agent_task_completed'; runId: string; label?: string };

View File

@@ -52,6 +52,9 @@ export interface Session {
// path_guard's extraRoots check consults this list before refusing reads
// outside the primary project root.
allowed_read_paths: string[];
// v[state-graph]: optional declarative state-graph engine. Default false
// when the column is absent on existing DBs (ALTER TABLE .. DEFAULT FALSE).
state_graph_enabled: boolean;
}
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added.
@@ -153,6 +156,7 @@ export interface Chat {
id: string;
session_id: string;
name: string | null;
model: string | null;
status: ChatStatus;
created_at: string;
updated_at: string;

View File

@@ -1,4 +1,4 @@
# apps/web — BooChat frontend (deep reference)
# apps/web — BooChat frontend (deep reference) — v2.7.x (last meaningful update: 2026-06)
> Per-app engineering notes for `apps/web/src/`. The frontend is a single React SPA that also hosts the BooCoder `'coder'` pane. Cross-cutting commands, database, environment, workflow, and cross-app contracts (WS-frame / provider-type parity, sentinels) live in the **root `CLAUDE.md`**. This file auto-loads when you read/edit files under `apps/web/`.

View File

@@ -20,12 +20,14 @@
"@xterm/xterm": "5.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.40.0",
"lucide-react": "^1.16.0",
"radix-ui": "^1.4.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.26.0",
"react-virtuoso": "^4.18.7",
"remark-gfm": "^4.0.1",
"shiki": "^1.29.2",
"sonner": "^2.0.7",
@@ -39,10 +41,12 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"jsdom": "^29.1.1",
"shadcn": "^4.7.0",
"tailwindcss": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.3.4"
"vite": "^5.3.4",
"vitest": "^3.2.4"
},
"license": "MIT"
}

View File

@@ -275,7 +275,7 @@ export const api = {
method: 'POST',
body: JSON.stringify(body ?? {}),
}),
update: (chatId: string, body: { name: string }) =>
update: (chatId: string, body: { name?: string; model?: string }) =>
request<Chat>(`/api/chats/${chatId}`, {
method: 'PATCH',
body: JSON.stringify(body),
@@ -331,6 +331,17 @@ export const api = {
method: 'POST',
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
}),
// v2.8-compare: send the same message to N models and stream back
// parallel responses. Returns compare_group_id + per-model message ids.
compare: (chatId: string, message: string, models: string[]) =>
request<{
compare_group_id: string;
user_message_id: string;
responses: Array<{ model: string; assistant_message_id: string }>;
}>(`/api/chats/${chatId}/compare`, {
method: 'POST',
body: JSON.stringify({ message, models }),
}),
// v1.13.17-cross-repo-reads: resume a paused request_read_access. On
// 'allow' the server re-resolves the grant root and appends it to
// sessions.allowed_read_paths; the returned list reflects the post-
@@ -348,6 +359,14 @@ export const api = {
request<ToolTraceResponse>(
`/api/chats/${chatId}/traces?limit=${limit}&offset=${offset}`,
),
exportChat: (chatId: string, format: 'json' | 'markdown') =>
request<string>(`/api/chats/${chatId}/export?format=${format}`),
// MCP permission: approve/deny a tool call from an 'ask' state server.
mcpApprove: (chatId: string, toolCallId: string, permission: 'allow_once' | 'allow_always' | 'deny') =>
request<{ ok: true }>(`/api/chats/${chatId}/mcp-approve`, {
method: 'POST',
body: JSON.stringify({ tool_call_id: toolCallId, permission }),
}),
},
messages: {
@@ -388,6 +407,11 @@ export const api = {
request<{ html_content: string; char_count: number; title: string }>(
`/api/chats/${chatId}/messages/${messageId}/html_artifact`,
),
feedback: (chatId: string, messageId: string, value: 'up' | 'down') =>
request<{ ok: boolean }>(
`/api/chats/${chatId}/messages/${messageId}/feedback`,
{ method: 'POST', body: JSON.stringify({ value }) },
),
},
models: () => request<ModelInfo[]>('/api/models'),
@@ -654,17 +678,27 @@ export const api = {
// cols/rows are optional. When passed, booterm sizes the per-pane tmux
// session at creation time so the inner bash (and any TUI it spawns) is
// born with the correct PTY dimensions instead of tmux's 80x24 default.
start: (sessionId: string, paneId: string, cols?: number, rows?: number) =>
request<{ tmux_session: string }>(
start: (
sessionId: string,
paneId: string,
cols?: number,
rows?: number,
description?: string,
parentAgent?: string,
) => {
const body: Record<string, unknown> = {};
if (cols !== undefined) body.cols = cols;
if (rows !== undefined) body.rows = rows;
if (description !== undefined) body.description = description;
if (parentAgent !== undefined) body.parentAgent = parentAgent;
return request<{ tmux_session: string }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/start`,
{
method: 'POST',
body:
cols !== undefined && rows !== undefined
? JSON.stringify({ cols, rows })
: undefined,
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,
},
),
);
},
kill: (sessionId: string, paneId: string) =>
request<{ ok: true }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/kill`,

View File

@@ -101,6 +101,7 @@ export interface Chat {
id: string;
session_id: string;
name: string | null;
model: string | null;
status: ChatStatus;
created_at: string;
updated_at: string;
@@ -131,6 +132,10 @@ export interface ToolResult {
output: unknown;
truncated: boolean;
error?: string;
// v2.8: unified diff snippet for write-tool results. Present when the tool
// modified files (edit_file, create_file, etc.) and the backend computed a
// diff. Rendered inline by DiffSnippet.
diff?: string;
}
// v1.8.2 / v1.11.6: ErrorReason + MessageMetadata single-sourced in
@@ -172,6 +177,10 @@ export interface Message {
// (CoderPane/CoderMessageList) and streams it live via reasoning_delta
// frames. MessageBubble reads whichever of the two is present.
reasoning_text?: string | null;
// v2.8-compare: compare group id. Set when the message is part of a
// multi-model compare response. All assistant messages in the same compare
// group share this id, keyed to the user message that triggered the compare.
compare_group_id?: string;
// v1.11: anchored rolling compaction fields. Optional on the wire so that
// older API responses (or test fixtures) parse without explicit nulls.
// summary — true on the assistant row that holds the active
@@ -513,8 +522,8 @@ export interface WorkspaceState {
export type WsFrame =
| { type: 'snapshot'; messages: Message[] }
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole }
| { type: 'delta'; message_id: string; chat_id?: string; content: string }
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole; compare_group_id?: string }
| { type: 'delta'; message_id: string; chat_id?: string; content: string; compare_group_id?: string }
| { type: 'tool_call'; message_id: string; chat_id?: string; tool_call: ToolCall }
| {
type: 'tool_result';
@@ -524,6 +533,7 @@ export type WsFrame =
output: unknown;
truncated: boolean;
error?: string;
diff?: string;
}
| {
type: 'message_complete';
@@ -547,6 +557,7 @@ export type WsFrame =
// 'cancelled' on a user Stop / stall and 'failed' on a thrown error so the
// reducer renders a muted "Stopped" / failed state — no new frame type.
status?: 'complete' | 'cancelled' | 'failed';
compare_group_id?: string;
}
// v1.12.2: live throughput frame, published mid-stream every ~500ms with
// the latest token + ctx counts so ChatThroughput can render tok/s and
@@ -576,7 +587,7 @@ export type WsFrame =
| { type: 'compacted'; session_id: string; chat_id: string; summary_message_id: string }
// v1.8.2: `reason` discriminates structured failures (the UI prefers it
// over `error` text when present).
| { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason }
| { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason; compare_group_id?: string }
// agent-status-normalize (#10): BooCoder publishes a normalized per-(chat,agent)
// lifecycle status for external coding agents on the per-session channel. The
// CoderPane tracks the latest status per (chat_id, agent) and resets on chat
@@ -612,6 +623,14 @@ export type WsFrame =
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
report?: string;
}
// inter-agent message frame
| {
type: 'agent_message';
run_id: string;
sender_step_id: string;
content: string;
channel?: string;
}
// tool trace frames: per-tool-call lifecycle tracking
| {
type: 'tool_trace_start';
@@ -674,11 +693,13 @@ export type WsFrame =
message_id?: string;
chat_id?: string;
content?: string;
compare_group_id?: string;
tool_call?: ToolCall;
tool_message_id?: string;
tool_call_id?: string;
output?: unknown;
truncated?: boolean;
diff?: string;
error?: string;
reason?: string;
status?: 'running' | 'complete' | 'cancelled' | 'failed';

View File

@@ -90,7 +90,7 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground motion-reduce:transition-none transition-colors hover:bg-muted hover:text-foreground active:scale-[0.97] max-md:min-h-[36px] max-md:min-w-[36px]"
title={selectedAgent?.name ? `Agent: ${selectedAgent.name}` : 'No agent'}
aria-label={`Agent: ${triggerLabel}`}
>

View File

@@ -20,7 +20,7 @@ export function AttachmentChip({ attachment, onRemove, onPreview }: Props) {
<button
type="button"
onClick={() => onPreview(attachment)}
className="flex items-center gap-1.5 hover:bg-muted/60 transition-colors min-w-0"
className="flex items-center gap-1.5 hover:bg-muted/60 motion-reduce:transition-none transition-colors active:scale-[0.97] min-w-0"
>
<FileText className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate max-w-[200px]">{label}</span>

View File

@@ -66,7 +66,7 @@ export function BottomSheet({ open, onClose, children, title }: Props) {
aria-modal="true"
className={cn(
'fixed inset-x-0 bottom-0 z-50 rounded-t-2xl border-t border-border bg-popover text-popover-foreground shadow-2xl',
'transition-transform duration-150 will-change-transform',
'motion-reduce:transition-none transition-transform duration-150 will-change-transform',
'max-h-[70vh] flex flex-col',
)}
style={{

View File

@@ -0,0 +1,38 @@
// vDeepSeek: cache shape telemetry badge. Displays cache token count with
// a colored hit-rate bar in the trace viewer. Color thresholds are relative
// to output tokens (tokens_used) since the trace doesn't carry prompt miss
// tokens separately: green > 50%, yellow > 10%, red ≤ 10%.
export interface CacheShapeBadgeProps {
cacheTokens: number | null | undefined;
totalTokens: number | null | undefined;
}
function hitRate(cache: number, total: number): number {
if (cache <= 0 || total <= 0) return 0;
return cache / (cache + total);
}
function barColor(rate: number): string {
if (rate > 0.5) return 'bg-green-500';
if (rate > 0.1) return 'bg-yellow-500';
return 'bg-red-500';
}
export function CacheShapeBadge({ cacheTokens, totalTokens }: CacheShapeBadgeProps) {
if (cacheTokens == null || cacheTokens <= 0) return null;
const rate = hitRate(cacheTokens, totalTokens ?? 0);
const pct = Math.round(rate * 100);
const color = barColor(rate);
return (
<span className="shrink-0 inline-flex items-center gap-1 font-mono tabular-nums text-[10px] text-muted-foreground/60" title={`cache hit rate ${pct}%`}>
<span className={`inline-block w-1.5 h-3 rounded-sm ${color}`} />
<span>{cacheTokens}c</span>
{totalTokens != null && totalTokens > 0 && (
<span className="text-muted-foreground/40">{pct}%</span>
)}
</span>
);
}

View File

@@ -694,7 +694,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
disabled={disabled || busy || attachments.length >= MAX_ATTACHMENTS}
aria-label="Attach file"
title="Attach file"
className="inline-flex items-center justify-center rounded-full border border-border px-2.5 py-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50 max-md:min-h-[36px] max-md:min-w-[36px]"
className="inline-flex items-center justify-center rounded-full border border-border px-2.5 py-1 text-muted-foreground motion-reduce:transition-none transition-colors hover:bg-muted hover:text-foreground active:scale-[0.97] disabled:opacity-50 max-md:min-h-[36px] max-md:min-w-[36px]"
>
<Paperclip className="size-3.5" />
</button>
@@ -707,7 +707,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
aria-expanded={cmdMenuOpen}
aria-label="Slash commands"
title="Slash commands"
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground motion-reduce:transition-none transition-colors hover:bg-muted hover:text-foreground active:scale-[0.97] aria-expanded:bg-muted aria-expanded:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
>
<SquareSlash className="size-3.5" />
<span className="max-md:hidden">{slashItems.length}</span>
@@ -720,7 +720,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
onClick={() => sessionEvents.emit({ type: 'open_flow_launcher', project_id: projectId })}
aria-label="Flow launcher"
title="Open flow launcher"
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground motion-reduce:transition-none transition-colors hover:bg-muted hover:text-foreground active:scale-[0.97] max-md:min-h-[36px] max-md:min-w-[36px]"
>
<Workflow className="size-3.5" />
<span className="max-md:hidden">Flows</span>
@@ -739,7 +739,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
}}
aria-pressed={webSearchEnabled === true}
title="Web search & fetch"
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors max-md:min-h-[36px] max-md:min-w-[36px] ${
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs motion-reduce:transition-none transition-colors active:scale-[0.97] max-md:min-h-[36px] max-md:min-w-[36px] ${
webSearchEnabled === true
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, Copy, Moon, Sun, WrapText } from 'lucide-react';
import { codeToHtml } from 'shiki';
// NOTE: spec calls for syntax-highlighted code blocks. Added Shiki in v1.1.
@@ -45,35 +45,111 @@ const LANG_MAP: Record<string, string> = {
css: 'css',
};
const SHIKI_THEME = 'github-dark';
// ── LRU highlight cache (module-scoped) ──────────────────────────
// Key = `${code}|${theme}|${mappedLang}`, max 50 entries.
// Avoids redundant codeToHtml calls when the same code/theme/lang
// combination is rendered multiple times (e.g. across messages).
const HIGHLIGHT_CACHE = new Map<string, string>();
const MAX_CACHE_ENTRIES = 50;
function cacheGet(key: string): string | undefined {
if (!HIGHLIGHT_CACHE.has(key)) return undefined;
const val = HIGHLIGHT_CACHE.get(key)!;
// LRU touch — delete & re-set to move to end (most recently used)
HIGHLIGHT_CACHE.delete(key);
HIGHLIGHT_CACHE.set(key, val);
return val;
}
function cacheSet(key: string, html: string): void {
if (HIGHLIGHT_CACHE.size >= MAX_CACHE_ENTRIES) {
const oldest = HIGHLIGHT_CACHE.keys().next().value;
if (oldest !== undefined) HIGHLIGHT_CACHE.delete(oldest);
}
HIGHLIGHT_CACHE.set(key, html);
}
export function CodeBlock({ code, lang }: Props) {
const [copied, setCopied] = useState(false);
const [html, setHtml] = useState<string | null>(null);
const highlightRef = useRef<HTMLDivElement | null>(null);
const [theme, setTheme] = useState<'github-dark' | 'github-light'>(() => {
try {
if (localStorage.getItem('codeblock-theme') === 'github-light') return 'github-light';
} catch {
/* localStorage unavailable */
}
return 'github-dark';
});
const [wordWrap, setWordWrap] = useState(false);
const [expanded, setExpanded] = useState(false);
const highlightRef = useRef<HTMLDivElement>(null);
// ── Derived state ──────────────────────────────────────────────
// Diff mode: detect `diff-` prefix (e.g. diff-ts, diff-py).
// The actual lang for highlighting is the part after `diff-`.
const isDiff = !!lang && lang.startsWith('diff-');
const actualLang = isDiff && lang ? lang.slice('diff-'.length) : lang ?? '';
const mappedLang = actualLang ? (LANG_MAP[actualLang.toLowerCase()] ?? null) : null;
// Strip leading `+`/`-` from code lines when in diff mode.
// The markers are rendered in the gutter instead.
const cleanCode = useMemo(
() => (isDiff ? code.replace(/^[+-]/gm, '') : code),
[code, isDiff],
);
const codeLines = useMemo(() => code.split('\n'), [code]);
const totalLines = codeLines.length;
// Gutter is hidden entirely when code has >= 1000 lines.
const showGutter = totalLines < 1000;
// Collapsible: auto-collapse to 15 lines when >= 30 lines total.
const isLong = totalLines >= 30;
const collapsed = isLong && !expanded;
const visibleLines = collapsed ? codeLines.slice(0, 15) : codeLines;
// Diff marker array: '+' / '-' / '' per line for the gutter.
const diffMarkers = useMemo(
() => (isDiff ? codeLines.map((l) => (l[0] === '+' ? '+' : l[0] === '-' ? '-' : '')) : null),
[isDiff, codeLines],
);
// ── Shiki highlighting ─────────────────────────────────────────
useEffect(() => {
let cancelled = false;
const mappedLang = (lang && LANG_MAP[lang.toLowerCase()]) ?? null;
if (!mappedLang) {
setHtml(null);
return;
}
const cacheKey = `${cleanCode}|${theme}|${mappedLang}`;
const cached = cacheGet(cacheKey);
if (cached !== undefined) {
setHtml(cached);
return;
}
(async () => {
try {
const result = await codeToHtml(code, { lang: mappedLang, theme: SHIKI_THEME });
const result = await codeToHtml(cleanCode, { lang: mappedLang, theme });
cacheSet(cacheKey, result);
if (!cancelled) setHtml(result);
} catch (err) {
console.warn('shiki failed', err);
console.warn('shiki highlight failed:', err);
if (!cancelled) setHtml(null);
}
})();
return () => {
cancelled = true;
};
}, [code, lang]);
}, [cleanCode, mappedLang, theme]);
// Inject Shiki HTML via ref; output is compiler-generated, not user input.
// Inject Shiki HTML via ref (output is compiler-generated, not user input)
useEffect(() => {
if (highlightRef.current) {
// Shiki generates sanitized HTML spans — not user-supplied content.
@@ -82,39 +158,138 @@ export function CodeBlock({ code, lang }: Props) {
}
}, [html]);
async function copy() {
// Sync word-wrap state to the injected <pre> element inside shiki's output
useEffect(() => {
const pre = highlightRef.current?.querySelector('pre');
if (pre) {
pre.style.whiteSpace = wordWrap ? 'pre-wrap' : 'nowrap';
}
}, [html, wordWrap]);
// ── Handlers ───────────────────────────────────────────────────
const handleToggleTheme = useCallback(() => {
setTheme((prev) => {
const next = prev === 'github-dark' ? 'github-light' : 'github-dark';
try {
localStorage.setItem('codeblock-theme', next);
} catch {
/* noop */
}
return next;
});
}, []);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(code);
await navigator.clipboard.writeText(cleanCode);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
/* ignore */
}
}
}, [cleanCode]);
// ── Shared class segments ──────────────────────────────────────
const preBaseClass = 'overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed';
const shikiWrapperClass = `${preBaseClass} [&>pre]:!bg-transparent [&>pre]:!m-0 [&>pre]:!p-0`;
const collapsedClass = collapsed ? 'max-h-[calc(15*1.625em)] overflow-hidden' : '';
// ── Render ─────────────────────────────────────────────────────
return (
<div className="rounded-md border bg-muted/40 overflow-hidden text-sm my-1">
{/* ── Toolbar ──────────────────────────────────────────── */}
<div className="flex items-center justify-between px-2 py-1 border-b text-xs text-muted-foreground">
<span className="font-mono">{lang || 'code'}</span>
<span className="font-mono">{actualLang || lang || 'code'}</span>
<div className="flex items-center gap-0.5">
{/* Theme toggle — persists to localStorage key 'codeblock-theme' */}
<button
type="button"
onClick={handleToggleTheme}
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
aria-label={`Switch to ${theme === 'github-dark' ? 'light' : 'dark'} theme`}
>
{theme === 'github-dark' ? <Sun className="size-3" /> : <Moon className="size-3" />}
</button>
{/* Word-wrap toggle */}
<button
type="button"
onClick={() => setWordWrap((prev) => !prev)}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground ${wordWrap ? 'bg-muted' : ''}`}
aria-label={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
>
<WrapText className="size-3" />
</button>
{/* Copy button — existing behavior (Check icon, 1200ms revert) */}
<button
type="button"
onClick={() => void handleCopy()}
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
aria-label="Copy code"
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
</div>
{/* ── Code body (flex row: gutter + code) ──────────────── */}
<div className="flex">
{/* Gutter — line numbers or diff markers */}
{showGutter && (
<div
className="flex-none select-none text-right text-muted-foreground/50 font-mono text-xs leading-relaxed py-2 border-r border-border/30"
aria-hidden="true"
>
{visibleLines.map((line, i) => {
const isPlus = diffMarkers?.[i] === '+';
const isMinus = diffMarkers?.[i] === '-';
let gutterCellClass = 'px-2 leading-relaxed';
if (isPlus) gutterCellClass += ' bg-green-500/10 border-l-2 border-green-500';
if (isMinus) gutterCellClass += ' bg-red-500/10 border-l-2 border-red-500';
const content = diffMarkers ? diffMarkers[i] : String(i + 1);
return (
<div key={i} className={gutterCellClass}>
{content}
</div>
);
})}
</div>
)}
{/* Code area */}
<div className="relative flex-1 min-w-0">
{html !== null ? (
<div ref={highlightRef} className={`${shikiWrapperClass} ${collapsedClass}`} />
) : (
<pre
className={`${preBaseClass} ${collapsedClass}`}
style={{ whiteSpace: wordWrap ? 'pre-wrap' : 'nowrap' }}
>
{collapsed ? codeLines.slice(0, 15).join('\n') : code}
</pre>
)}
{/* Gradient fade overlay for collapsed state */}
{collapsed && (
<div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-b from-transparent to-background pointer-events-none" />
)}
</div>
</div>
{/* "Show N more" button for collapsed state */}
{collapsed && (
<button
type="button"
onClick={() => void copy()}
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
aria-label="Copy code"
onClick={() => setExpanded(true)}
className="w-full text-xs text-muted-foreground hover:text-foreground py-1 border-t border-border/30 bg-muted/20"
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
Show {totalLines - 15} more {totalLines - 15 === 1 ? 'line' : 'lines'}
</button>
</div>
{html !== null ? (
<div
ref={highlightRef}
className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed [&>pre]:!bg-transparent [&>pre]:!m-0 [&>pre]:!p-0"
/>
) : (
<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
{code}
</pre>
)}
</div>
);

View File

@@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef } from 'react';
import { Loader2 } from 'lucide-react';
import { MarkdownRenderer } from './MarkdownRenderer';
import { cn } from '@/lib/utils';
export interface CompareResponse {
model: string;
assistantMessageId: string;
content: string;
status: 'streaming' | 'complete' | 'error';
}
interface Props {
models: string[];
responses: CompareResponse[];
onClose: () => void;
}
export function ComparePane({ models, responses, onClose }: Props) {
const panelsRef = useRef<(HTMLDivElement | null)[]>([]);
const isSyncingRef = useRef(false);
// Build a map for quick lookup
const responseMap = new Map<string, CompareResponse>();
for (const r of responses) {
responseMap.set(r.model, r);
}
// Synced scroll: when one panel scrolls, scroll all others to the same ratio
const handleScroll = useCallback((sourceIndex: number) => {
if (isSyncingRef.current) return;
const source = panelsRef.current[sourceIndex];
if (!source) return;
const ratio = source.scrollTop / (source.scrollHeight - source.clientHeight);
isSyncingRef.current = true;
for (let i = 0; i < panelsRef.current.length; i++) {
if (i === sourceIndex) continue;
const target = panelsRef.current[i];
if (!target) continue;
const targetMax = target.scrollHeight - target.clientHeight;
target.scrollTop = targetMax > 0 ? ratio * targetMax : 0;
}
isSyncingRef.current = false;
}, []);
// Refresh scroll sync when content changes (streaming updates)
useEffect(() => {
if (responses.length === 0) return;
const anyStreaming = responses.some((r) => r.status === 'streaming');
if (!anyStreaming) return;
// Sync scroll to bottom when any panel is still streaming
if (!isSyncingRef.current) {
for (const panel of panelsRef.current) {
if (!panel) continue;
panel.scrollTop = panel.scrollHeight;
}
}
}, [responses]);
const gridCols = models.length === 2 ? 'grid-cols-2' : 'grid-cols-3';
return (
<div className="flex flex-col h-full min-h-0 relative">
{/* Header */}
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-2 shrink-0">
<span className="text-sm font-medium">Compare Models</span>
<span className="text-xs text-muted-foreground">
{models.length} models
</span>
<div className="ml-auto flex items-center gap-1 shrink-0">
<button
type="button"
onClick={onClose}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-muted"
>
Back to single
</button>
</div>
</div>
{/* Grid of response panels */}
<div className={cn('flex-1 grid min-h-0', gridCols)}>
{models.map((model, idx) => {
const resp = responseMap.get(model) ?? {
model,
assistantMessageId: '',
content: '',
status: 'streaming' as const,
};
const isStreaming = resp.status === 'streaming';
const isError = resp.status === 'error';
return (
<div
key={model}
ref={(el) => { panelsRef.current[idx] = el; }}
onScroll={() => handleScroll(idx)}
className={cn(
'overflow-y-auto border-r border-border/50 last:border-r-0',
'flex flex-col',
)}
>
{/* Model header */}
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border/50 px-3 py-2 text-xs font-medium text-foreground truncate">
{model}
</div>
{/* Empty / loading state */}
{resp.content.length === 0 && isStreaming && (
<div className="flex items-center justify-center flex-1 gap-2 text-sm text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
Generating
</div>
)}
{/* Error state */}
{isError && resp.content.length === 0 && (
<div className="flex items-center justify-center flex-1 text-sm text-destructive px-3">
Failed to generate
</div>
)}
{/* Content */}
{resp.content.length > 0 && (
<div className="px-3 py-2 text-sm">
<MarkdownRenderer content={resp.content} />
</div>
)}
{/* Streaming indicator at bottom */}
{isStreaming && resp.content.length > 0 && (
<div className="sticky bottom-0 bg-background/80 backdrop-blur px-3 py-1.5 flex items-center gap-1.5 text-xs text-muted-foreground border-t border-border/30">
<Loader2 size={10} className="animate-spin" />
Streaming
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -118,7 +118,7 @@ export function ContextMeter({ messages, modelContextLimit, sessionCostUsd }: Pr
cy={CENTER}
r={RADIUS}
fill="none"
className={cn('transition-all duration-300', progressClass)}
className={cn('transition-all duration-200 motion-reduce:transition-none', progressClass)}
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}

View File

@@ -0,0 +1,88 @@
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, FileCode } from 'lucide-react';
interface Props {
diff: string;
}
const INITIAL_LINES = 10;
export function DiffSnippet({ diff }: Props) {
const [expanded, setExpanded] = useState(false);
const lines = useMemo(() => diff.split('\n'), [diff]);
const totalLines = lines.length;
// Find the first and last content lines (skip leading ---/+++ headers)
const firstContentIdx = lines.findIndex(
(l) => l.startsWith('+') || l.startsWith('-') || l.startsWith(' '),
);
// Count content lines that are either +, -, or context lines
const contentLineCount = lines.filter(
(l) => l.startsWith('+') || l.startsWith('-') || l.startsWith(' '),
).length;
// Show first N content lines, plus header lines
const displayLines = useMemo(() => {
const sliceEnd = expanded ? lines.length : Math.min(firstContentIdx + INITIAL_LINES + contentLineCount, lines.length);
return lines.slice(0, sliceEnd);
}, [lines, expanded, firstContentIdx, contentLineCount]);
const hasMore = totalLines > displayLines.length;
if (totalLines === 0) return null;
return (
<div className="mt-1 rounded border border-border/40 bg-muted/20 overflow-hidden">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1.5 w-full px-2 py-1 text-left hover:bg-muted/30 text-[10px] font-mono text-muted-foreground"
>
<FileCode className="size-3 shrink-0" />
<span className="font-medium">diff</span>
<span className="text-muted-foreground/60">
{contentLineCount} line{contentLineCount === 1 ? '' : 's'} changed
</span>
<span className="ml-auto shrink-0">
{expanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</span>
</button>
<div className="px-0 pb-0.5">
{displayLines.map((line, i) => {
// Determine color class based on line prefix
let colorClass = 'text-muted-foreground/60';
if (line.startsWith('+')) colorClass = 'text-emerald-600 dark:text-emerald-400';
else if (line.startsWith('-')) colorClass = 'text-red-500 dark:text-red-400';
else if (line.startsWith('@@')) colorClass = 'text-muted-foreground';
else if (line.startsWith('---') || line.startsWith('+++')) colorClass = 'text-muted-foreground/50';
return (
<div
key={i}
className={`leading-[1.3] px-2 text-[10px] font-mono whitespace-pre ${colorClass} ${
line.startsWith('+')
? 'bg-emerald-500/5'
: line.startsWith('-')
? 'bg-red-500/5'
: ''
}`}
>
{line}
</div>
);
})}
{hasMore && !expanded && (
<button
type="button"
onClick={() => setExpanded(true)}
className="w-full text-left px-2 py-0.5 text-[10px] font-mono text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/30"
>
Show {totalLines - displayLines.length} more lines
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import type { ReactNode } from 'react';
import { Inbox } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface EmptyStateProps {
/** Optional icon node shown above the title. Defaults to a muted Inbox icon. */
icon?: ReactNode;
/** Main heading text (bold, base font-size). */
title: string;
/** Optional descriptive text shown below the title (muted, sm font-size). */
description?: string;
/** Optional CTA button rendered below the description. */
action?: {
label: string;
onClick: () => void;
variant?: 'default' | 'outline';
};
className?: string;
}
/**
* Reusable empty state for lists, search results, and landing pages.
*
* Renders a centered column with:
* 1. Optional icon (default: `Inbox` from lucide-react, drawn at 50% muted
* opacity so it sits subtly in the background).
* 2. Bold title.
* 3. Optional description (constrained to `max-w-sm` for readability).
* 4. Optional action button (outline by default).
*
* Design follows The Data Terminal aesthetic: charcoal canvas, low-chrome
* muted icon, high-contrast text, and an outline button that gets an ember
* glow on interaction.
*/
export function EmptyState({
icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center text-center gap-3 px-4 py-12',
className,
)}
>
<div className="text-muted-foreground/50">
{icon ?? <Inbox size={40} strokeWidth={1.5} />}
</div>
<h3 className="text-base font-semibold text-foreground">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground max-w-sm">{description}</p>
)}
{action && (
<Button
variant={action.variant ?? 'outline'}
onClick={action.onClick}
className="mt-1"
>
{action.label}
</Button>
)}
</div>
);
}

View File

@@ -37,11 +37,11 @@ function Switch({ checked, onCheckedChange, id }: {
role="switch"
aria-checked={checked}
onClick={() => onCheckedChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors ${
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full motion-reduce:transition-none transition-colors ${
checked ? 'bg-primary' : 'bg-muted'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
<span className={`inline-block h-4 w-4 transform rounded-full bg-background motion-reduce:transition-none transition-transform ${
checked ? 'translate-x-[1.125rem]' : 'translate-x-0.5'
}`} />
</button>

View File

@@ -0,0 +1,64 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { KEYBOARD_SHORTCUTS } from '@/lib/keyboard-shortcuts';
function KeyboardShortcutsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const isMac =
typeof navigator !== 'undefined' &&
navigator.platform.toLowerCase().includes('mac');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Keyboard Shortcuts</DialogTitle>
</DialogHeader>
<div className="-mx-4 -mb-4 max-h-[70vh] overflow-y-auto overscroll-contain px-4 pb-4">
<div className="space-y-6">
{KEYBOARD_SHORTCUTS.map((group) => (
<div key={group.category}>
<h4 className="mb-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{group.category}
</h4>
<div className="space-y-2">
{group.shortcuts.map((shortcut, i) => (
<div
key={i}
className="flex items-center justify-between gap-4"
>
<span className="text-sm">{shortcut.description}</span>
<span className="flex shrink-0 items-center gap-1">
{shortcut.keys.map((key, ki) => (
<span key={ki} className="flex items-center gap-0.5">
{ki > 0 && (
<span className="text-muted-foreground/50">+</span>
)}
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">
{key === 'Ctrl' ? (isMac ? '⌘' : 'Ctrl') : key}
</kbd>
</span>
))}
</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
</DialogContent>
</Dialog>
);
}
export { KeyboardShortcutsDialog };

View File

@@ -8,6 +8,7 @@ import Markdown from 'react-markdown';
import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { CodeBlock } from './CodeBlock';
import { MessageBoundary } from './MessageBoundary';
import { linkifyPaths } from '@/lib/linkify-paths';
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
@@ -40,7 +41,11 @@ const codeRenderer = (props: { children?: unknown; className?: string }) => {
const langMatch = /language-([\w-]+)/.exec(className ?? '');
const isBlock = !!langMatch || text.includes('\n');
if (isBlock) {
return <CodeBlock code={text} lang={langMatch?.[1]} />;
return (
<MessageBoundary fallback={<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">{text}</pre>}>
<CodeBlock code={text} lang={langMatch?.[1]} />
</MessageBoundary>
);
}
return (
<code
@@ -102,8 +107,10 @@ const MARKDOWN_COMPONENTS: Components = {
export const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) {
return (
<Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
{content}
</Markdown>
<MessageBoundary>
<Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
{content}
</Markdown>
</MessageBoundary>
);
});

View File

@@ -0,0 +1,113 @@
import { useState } from 'react';
import { AlertTriangle, Check, X } from 'lucide-react';
import { api } from '@/api/client';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface Props {
toolCallId: string;
toolName: string;
toolArgs: Record<string, unknown>;
chatId: string;
open: boolean;
onClose: () => void;
}
function parseServerName(toolName: string): string {
// Tool name format is <serverName>_<originalToolName>
const idx = toolName.indexOf('_');
if (idx === -1) return toolName;
return toolName.slice(0, idx);
}
function parseToolShortName(toolName: string): string {
const idx = toolName.indexOf('_');
if (idx === -1) return toolName;
return toolName.slice(idx + 1);
}
function summarizeArgs(args: Record<string, unknown>): string {
const keys = Object.keys(args);
if (keys.length === 0) return 'no arguments';
const first = keys[0]!;
const val = typeof args[first] === 'string' ? args[first] as string : JSON.stringify(args[first]);
return `${first}: ${val.length > 60 ? val.slice(0, 59) + '…' : val}`;
}
export function McpPermissionDialog({ toolCallId, toolName, toolArgs, chatId, open, onClose }: Props) {
const [loading, setLoading] = useState<string | null>(null);
const serverName = parseServerName(toolName);
const shortName = parseToolShortName(toolName);
const argsSummary = summarizeArgs(toolArgs);
const handleApprove = async (permission: 'allow_once' | 'allow_always' | 'deny') => {
setLoading(permission);
try {
await api.chats.mcpApprove(chatId, toolCallId, permission);
onClose();
} catch {
// Error is already surfaced by api client
} finally {
setLoading(null);
}
};
return (
<Dialog open={open} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-4 text-amber-500" />
MCP Tool Approval
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-lg bg-muted/50 p-3 text-xs space-y-1.5">
<div>
<span className="text-muted-foreground">Server:</span>{' '}
<span className="font-mono font-medium">{serverName}</span>
</div>
<div>
<span className="text-muted-foreground">Tool:</span>{' '}
<span className="font-mono">{shortName}</span>
</div>
<div>
<span className="text-muted-foreground">Args:</span>{' '}
<span className="font-mono text-muted-foreground">{argsSummary}</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
This MCP server requires approval before running tools. Choose how to proceed:
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
variant="outline"
size="sm"
onClick={() => handleApprove('allow_once')}
disabled={loading !== null}
>
{loading === 'allow_once' ? '…' : <><Check className="size-3 mr-1" /> Allow Once</>}
</Button>
<Button
variant="default"
size="sm"
onClick={() => handleApprove('allow_always')}
disabled={loading !== null}
>
{loading === 'allow_always' ? '…' : <><Check className="size-3 mr-1" /> Always Allow</>}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleApprove('deny')}
disabled={loading !== null}
>
{loading === 'deny' ? '…' : <><X className="size-3 mr-1" /> Deny</>}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,212 @@
import { useState } from 'react';
import type { ToolCall, ToolResult } from '@/api/types';
import { linkifyPaths } from '@/lib/linkify-paths';
import { MarkdownRenderer } from './MarkdownRenderer';
import { extractServerName, extractToolName } from '@/lib/tool-utils';
interface McpResponseDisplayProps {
toolCall: ToolCall;
toolResult: ToolResult;
}
type DisplayMode = 'plain' | 'markdown' | 'rich';
const URL_REGEX = /https?:\/\/[^\s<>"']+/g;
const IMAGE_EXT_REGEX = /\.(png|jpg|jpeg|gif|webp|svg)(\?.*)?$/i;
function isImageUrl(url: string): boolean {
return IMAGE_EXT_REGEX.test(url);
}
interface ContentSegment {
type: 'text' | 'image' | 'link' | 'image_line';
content: string;
url?: string;
}
/**
* Splits tool output into content segments for rich display mode:
* - Standalone image URLs → <img> tag
* - Markdown image syntax `![alt](url)` → <img> tag
* - Standalone non-image URLs → link card
* - Everything else → text paragraph
*/
function parseRichContent(output: string): ContentSegment[] {
const segments: ContentSegment[] = [];
const lines = output.split('\n');
for (const line of lines) {
const trimmed = line.trimEnd();
// Empty line
if (!trimmed) {
segments.push({ type: 'text', content: line });
continue;
}
// Markdown image syntax: ![alt](url)
const inlineImgMatch = trimmed.match(/^!\[.*?\]\((https?:\/\/[^\s)]+)\)$/);
if (inlineImgMatch) {
segments.push({ type: 'image', content: '', url: inlineImgMatch[1] });
continue;
}
// Standalone URL
const urlMatch = trimmed.match(URL_REGEX);
if (urlMatch && urlMatch[0] === trimmed) {
const url = urlMatch[0];
if (isImageUrl(url)) {
segments.push({ type: 'image', content: '', url });
} else {
segments.push({ type: 'link', content: url });
}
continue;
}
// Inline image URLs on their own line (no markdown, just raw URL)
const imgMatch = trimmed.match(IMAGE_EXT_REGEX);
if (imgMatch) {
const possibleUrl = trimmed.match(URL_REGEX);
if (possibleUrl && possibleUrl[0].length >= trimmed.length * 0.8) {
segments.push({ type: 'image', content: '', url: possibleUrl[0] });
continue;
}
}
// Inline URLs in text — detect and wrap them
const inlineUrls = trimmed.match(URL_REGEX);
if (inlineUrls) {
// Render as text with linkified URLs
segments.push({ type: 'text', content: trimmed });
} else {
segments.push({ type: 'text', content: line });
}
}
return segments;
}
export function McpResponseDisplay({ toolCall, toolResult }: McpResponseDisplayProps) {
const [mode, setMode] = useState<DisplayMode>('plain');
const serverName = extractServerName(toolCall.name);
const toolDisplayName = extractToolName(toolCall.name) ?? toolCall.name;
const output =
typeof toolResult.output === 'string'
? toolResult.output
: JSON.stringify(toolResult.output, null, 2);
const modes: { key: DisplayMode; label: string }[] = [
{ key: 'plain', label: 'Plain' },
{ key: 'markdown', label: 'MD' },
{ key: 'rich', label: 'Rich' },
];
return (
<div className="space-y-1.5">
{/* Server badge + tool name + permission dot */}
<div className="flex items-center gap-1.5 flex-wrap">
{serverName && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono bg-primary/10 text-primary">
{serverName}
</span>
)}
<span className="text-[11px] font-mono text-muted-foreground">{toolDisplayName}</span>
<span
className="size-2 rounded-full bg-emerald-500 shrink-0"
aria-label="allowed"
title="Permission: allowed"
/>
</div>
{/* Display mode toggle */}
<div className="flex items-center gap-0.5">
{modes.map((m) => (
<button
key={m.key}
type="button"
onClick={() => setMode(m.key)}
className={`px-2 py-0.5 text-[10px] font-mono rounded transition-colors ${
mode === m.key
? 'bg-primary/15 text-primary'
: 'text-muted-foreground/50 hover:text-muted-foreground'
}`}
>
{m.label}
</button>
))}
</div>
{/* Content */}
{mode === 'plain' && (
<pre className="text-[11px] font-mono whitespace-pre-wrap bg-muted/30 rounded px-2 py-1 max-h-72 overflow-y-auto">
{toolResult.error ? (
<span className="text-destructive">{toolResult.error}</span>
) : (
linkifyPaths(output)
)}
{toolResult.truncated && (
<div className="text-muted-foreground/60 mt-1"> output truncated </div>
)}
</pre>
)}
{mode === 'markdown' && (
<div className="text-[13px] bg-muted/30 rounded px-2 py-1 max-h-96 overflow-y-auto">
{toolResult.error ? (
<span className="text-destructive">{toolResult.error}</span>
) : (
<MarkdownRenderer content={output} />
)}
{toolResult.truncated && (
<div className="text-muted-foreground/60 mt-1 text-xs"> output truncated </div>
)}
</div>
)}
{mode === 'rich' && (
<div className="bg-muted/30 rounded px-2 py-1 max-h-96 overflow-y-auto space-y-1.5">
{toolResult.error ? (
<span className="text-destructive text-[11px]">{toolResult.error}</span>
) : (
parseRichContent(output).map((seg, i) => {
if (seg.type === 'image') {
return (
<div key={i} className="rounded overflow-hidden">
<img
src={seg.url}
alt="Tool result image"
className="max-w-full h-auto rounded"
loading="lazy"
/>
</div>
);
}
if (seg.type === 'link') {
return (
<a
key={i}
href={seg.content}
target="_blank"
rel="noreferrer"
className="block text-[11px] font-mono text-primary underline hover:text-primary/80 bg-muted/20 rounded px-2 py-1.5 break-all"
>
{seg.content}
</a>
);
}
return (
<p key={i} className="text-[11px] leading-relaxed">
{linkifyPaths(seg.content)}
</p>
);
})
)}
{toolResult.truncated && (
<div className="text-muted-foreground/60 mt-1 text-xs"> output truncated </div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
error: Error | null;
}
export class MessageBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn('MessageBoundary caught:', error.message, info.componentStack);
}
handleRetry = () => {
this.setState({ error: null });
};
render() {
if (this.state.error) {
if (this.props.fallback !== undefined) {
return <>{this.props.fallback}</>;
}
return (
<div className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs">
<AlertCircle className="size-3.5 text-destructive shrink-0" />
<span className="text-muted-foreground flex-1">Rendering failed</span>
<button
type="button"
onClick={this.handleRetry}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-foreground hover:bg-muted"
aria-label="Retry rendering"
title="Retry"
>
<RefreshCw className="size-3" />
Retry
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -299,7 +299,7 @@ function ActionRow({
return (
<>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity max-md:opacity-100">
<div className="flex gap-1 opacity-0 group-hover:opacity-100 motion-reduce:transition-none transition-opacity max-md:opacity-100">
<button
type="button"
onClick={() => void copy()}

View File

@@ -274,7 +274,7 @@ export function MessageList({ messages, sessionChats }: Props) {
chatId={item.chatId}
/>
) : (
<ToolCallLine run={item.run} />
<ToolCallLine run={item.run} chatId={item.chatId} />
)
) : (
<ToolCallGroup runs={item.runs} />

View File

@@ -0,0 +1,56 @@
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { AlertCircle } from 'lucide-react';
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class MessageListErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('MessageListErrorBoundary caught:', error.message, info.componentStack);
}
handleRetry = () => {
this.setState({ error: null });
};
render() {
if (this.state.error) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 px-4">
<AlertCircle className="size-8 text-destructive" />
<div className="text-center space-y-1">
<p className="text-sm font-medium">Something went wrong</p>
<p className="text-xs text-muted-foreground">
The message list encountered an unexpected error.
</p>
</div>
<button
type="button"
onClick={this.handleRetry}
className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-muted"
aria-label="Try again"
title="Try again"
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -13,7 +13,7 @@ import { useViewport } from '@/hooks/useViewport';
import { formatModelLabel } from '@/lib/model-label';
interface Props {
value: string;
value: string | null;
onChange: (model: string) => void | Promise<void>;
}
@@ -27,7 +27,7 @@ function ModelList({
}: {
models: ModelInfo[] | null;
error: string | null;
value: string;
value: string | null;
onPick: (id: string) => void;
}) {
if (error) {
@@ -82,8 +82,8 @@ export function ModelPicker({ value, onChange }: Props) {
<button
type="button"
onClick={() => setOpen(true)}
aria-label={`Model: ${value}`}
title={value}
aria-label={`Model: ${value ?? 'default'}`}
title={value ?? undefined}
className="inline-flex items-center justify-center min-h-[36px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
>
<Cpu className="size-4" />
@@ -104,7 +104,7 @@ export function ModelPicker({ value, onChange }: Props) {
type="button"
className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60"
>
{formatModelLabel(value)}
{value ? formatModelLabel(value) : 'Model'}
<ChevronDown className="size-3 opacity-70" />
</button>
</DropdownMenuTrigger>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Code, History, MessageSquare, Plus, Terminal, Workflow } from 'lucide-react';
import { Code, History, MessageSquare, Plus, Swords, Terminal, Workflow } from 'lucide-react';
import { api } from '@/api/client';
import type { FlowRunRow } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
@@ -90,6 +90,19 @@ export function NewPaneMenu({ onAddPane, disabled, projectId }: Props) {
<Workflow size={14} /> New Orchestrator
</DropdownMenuItem>
)}
{projectId && (
<DropdownMenuItem
onSelect={() =>
sessionEvents.emit({
type: 'open_arena_launcher',
project_id: projectId,
placement: 'new',
})
}
>
<Swords size={14} /> New Arena
</DropdownMenuItem>
)}
{projectId && (
<>

View File

@@ -273,7 +273,7 @@ export function ProjectSidebar() {
const asideCls = isMobile
? cn(
'fixed inset-y-0 left-0 z-40 w-60 border-r bg-sidebar text-sidebar-foreground flex flex-col',
'transition-transform duration-200 ease-out',
'motion-reduce:transition-none transition-transform duration-200 ease-out',
drawerOpen ? 'translate-x-0' : '-translate-x-full',
)
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen';
@@ -333,7 +333,7 @@ export function ProjectSidebar() {
className="flex items-center justify-center text-[10px] uppercase tracking-wide text-muted-foreground border-b overflow-hidden shrink-0"
style={{
height: pull.refreshing ? 32 : Math.min(pull.pullDist, 80),
transition: pull.pullDist === 0 && !pull.refreshing ? 'height 0.2s ease' : undefined,
transition: pull.pullDist === 0 && !pull.refreshing ? 'height 0.2s ease-out' : undefined,
}}
aria-live="polite"
>

View File

@@ -282,7 +282,7 @@ export function RightRail({ projectId, sessionId }: Props) {
const asideCls = isMobile
? cn(
'fixed inset-y-0 right-0 z-40 w-[85vw] max-w-sm border-l bg-sidebar flex flex-col overflow-hidden',
'transition-transform duration-200 ease-out',
'motion-reduce:transition-none transition-transform duration-200 ease-out',
drawerOpen ? 'translate-x-0' : 'translate-x-full',
)
: 'w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden';

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { Archive, ChevronLeft, Code, MessageSquare, RotateCcw, Terminal, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import mascot from '@/assets/brand/banner-mascot.png';
import { EmptyState } from '@/components/EmptyState';
import { ChatInput } from '@/components/ChatInput';
import { Button } from '@/components/ui/button';
import {
@@ -167,9 +168,11 @@ export function SessionLandingPage({
<h2 className="text-sm font-medium ml-auto mr-1">Session history</h2>
</div>
{isEmpty ? (
<p className="text-sm text-muted-foreground text-center py-8">
No conversations yet. Send a message to start.
</p>
<EmptyState
icon={<MessageSquare size={40} strokeWidth={1.5} />}
title="No conversations"
description="Your chat history will appear here"
/>
) : (<>
{openChats.length > 0 && (
<>
@@ -200,7 +203,7 @@ export function SessionLandingPage({
{formatRelative(c.updated_at)}
</span>
</button>
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 max-md:opacity-100 transition-opacity">
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 max-md:opacity-100 motion-reduce:transition-none transition-opacity">
<button
type="button"
onClick={(e) => { e.stopPropagation(); void onArchiveChat(c.id); }}
@@ -254,7 +257,7 @@ export function SessionLandingPage({
<button
type="button"
onClick={(e) => { e.stopPropagation(); setDeleteConfirm({ id: c.id, name: c.name }); }}
className="shrink-0 inline-flex items-center justify-center size-7 rounded hover:bg-destructive/20 hover:text-destructive max-md:size-9 opacity-0 group-hover/arch:opacity-100 focus-within:opacity-100 max-md:opacity-100 transition-opacity"
className="shrink-0 inline-flex items-center justify-center size-7 rounded hover:bg-destructive/20 hover:text-destructive max-md:size-9 opacity-0 group-hover/arch:opacity-100 focus-within:opacity-100 max-md:opacity-100 motion-reduce:transition-none transition-opacity"
aria-label="Delete chat"
title="Delete"
>

View File

@@ -132,13 +132,13 @@ export function ThemePicker() {
onClick={() => setAnimBg(!animOn)}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent',
'transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'motion-reduce:transition-none transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
animOn ? 'bg-primary' : 'bg-input',
)}
>
<span
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform duration-200',
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 motion-reduce:transition-none transition-transform duration-200',
animOn ? 'translate-x-4' : 'translate-x-0',
)}
/>

View File

@@ -33,11 +33,20 @@ export function ToolCallGroup({ runs }: Props) {
<div className="rounded border border-border/60 bg-muted/20 text-xs">
<button
type="button"
tabIndex={0}
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-1.5 px-2 py-1 hover:bg-muted/40 text-left"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setOpen((v) => !v);
} else if (e.key === 'Escape') {
setOpen(false);
}
}}
className="w-full flex items-center gap-1.5 px-2 py-1 hover:bg-muted/40 text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
>
<ChevronRight
className={`size-3 text-muted-foreground/60 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
className={`size-3 text-muted-foreground/60 shrink-0 motion-reduce:transition-none transition-transform ${open ? 'rotate-90' : ''}`}
/>
<span className="text-muted-foreground/60 select-none shrink-0"></span>
<span className="font-mono text-foreground/90">

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