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 > **Stack:** fastify, go-net-http | none | react | typescript
> **Microservices:** @boocode/contracts, @boocode/ion, @boocode/booterm, @boocode/coder, @boocode/server, @boocode/web, codecontext, @boocode/conductor > **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.** > **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 ## CRUD Resources
- **`/api/battles`** GET | POST | GET/:id → Battle - **`/api/battles`** GET | POST | GET/:id → Battle
- **`/api/plans`** GET | POST | GET/:id | PATCH/:id → Plan
- **`/api/runs`** GET | POST | GET/:id → Run - **`/api/runs`** GET | POST | GET/:id → Run
- **`/api/tasks`** GET | POST | GET/:id → Task - **`/api/tasks`** GET | POST | GET/:id → Task
- **`/api/chats/:id/messages`** GET | POST | GET/:id | DELETE/:id → Message - **`/api/chats/:id/messages`** GET | POST | GET/:id | DELETE/:id → Message
@@ -25,11 +26,16 @@
### fastify ### fastify
- `GET` `/api/term/health` params() - `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/start` params(sid, pid) [auth]
- `POST` `/api/term/sessions/:sid/panes/:pid/kill` 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` `/ws/term/sessions/:sid/panes/:pid` params(sid, pid) [auth]
- `GET` `/api/health` params() [auth, db, queue, ai] - `GET` `/api/health` params() [auth, db, queue, ai]
- `GET` `/api/sessions/:sessionId/agent-sessions` params(sessionId) [auth, db] - `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/generate-prompt` params() [auth, db]
- `POST` `/api/battles/:id/stop` params(id) [auth, db] - `POST` `/api/battles/:id/stop` params(id) [auth, db]
- `GET` `/api/battles/:id/analysis` 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/apply` params(id) [auth, db, queue]
- `POST` `/api/pending/:id/reject` 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] - `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/snapshot` params() [db, cache]
- `GET` `/api/providers/config` params() [db, cache] - `GET` `/api/providers/config` params() [db, cache]
- `PATCH` `/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/sessions/:sessionId` params(sessionId) [auth, db]
- `GET` `/api/ws/user` params() [auth, db] - `GET` `/api/ws/user` params() [auth, db]
- `GET` `/api/projects/:id/agents` params(id) [db, cache] - `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] - `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/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/projects/:project_id/artifacts/:filename` params(project_id, filename) [auth, db]
- `GET` `/api/sessions/:id/chats` params(id) [auth, db] - `GET` `/api/sessions/:id/chats` params(id) [auth, db, queue]
- `POST` `/api/sessions/:id/chats` params(id) [auth, db] - `POST` `/api/sessions/:id/chats` params(id) [auth, db, queue]
- `PATCH` `/api/chats/:id` params(id) [auth, db] - `PATCH` `/api/chats/:id` params(id) [auth, db, queue]
- `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db] - `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db, queue]
- `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db] - `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/archive` params(id) [auth, db] - `POST` `/api/chats/:id/archive` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/unarchive` params(id) [auth, db] - `POST` `/api/chats/:id/unarchive` params(id) [auth, db, queue]
- `DELETE` `/api/chats/:id` params(id) [auth, db] - `DELETE` `/api/chats/:id` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/fork` params(id) [auth, db] - `POST` `/api/chats/:id/fork` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/discard_stale` params(id) [auth, db] - `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] - `GET` `/api/coder/ws/sessions/:sessionId` params(sessionId) [auth]
- `ALL` `/api/coder/*` params() [auth] - `ALL` `/api/coder/*` params() [auth]
- `GET` `/api/settings/inference` params() [cache] - `GET` `/api/settings/inference` params() [cache]
@@ -94,7 +104,9 @@
- `POST` `/api/chats/:id/continue` params(id) [auth, db, queue] - `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/force_send` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/grant_read_access` 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/create` params() [auth, db]
- `POST` `/api/projects/:id/archive` params(id) [auth, db] - `POST` `/api/projects/:id/archive` params(id) [auth, db]
- `POST` `/api/projects/:id/unarchive` params(id) [auth, db] - `POST` `/api/projects/:id/unarchive` params(id) [auth, db]
@@ -122,6 +134,7 @@
- `GET` `/api/skills` params() [auth, db, queue] - `GET` `/api/skills` params() [auth, db, queue]
- `POST` `/api/chats/:id/skill_invoke` params(id) [auth, db, queue] - `POST` `/api/chats/:id/skill_invoke` params(id) [auth, db, queue]
- `GET` `/api/tools/cost_stats` params() [auth, db] - `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] - `GET` `/api/ws/sessions/:id` params(id) [auth, db]
### go-net-http ### go-net-http
@@ -273,6 +286,25 @@
- model: text (required) - model: text (required)
- verdict: text - 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 ### projects
- id: uuid (pk) - id: uuid (pk)
- name: text (required) - name: text (required)
@@ -294,6 +326,8 @@
- content: text (required) - content: text (required)
- status: text (required) - status: text (required)
- last_seq: integer (required) - last_seq: integer (required)
- cache_tokens: integer
- reasoning_tokens: integer
### message_parts ### message_parts
- id: uuid (pk) - id: uuid (pk)
@@ -311,6 +345,45 @@
- name: text - name: text
- status: text (required) - 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 # Components
@@ -325,23 +398,34 @@
- **AttachmentChip** — props: attachment, onRemove, onPreview — `apps/web/src/components/AttachmentChip.tsx` - **AttachmentChip** — props: attachment, onRemove, onPreview — `apps/web/src/components/AttachmentChip.tsx`
- **AttachmentPreviewModal** — props: attachment, onClose — `apps/web/src/components/AttachmentPreviewModal.tsx` - **AttachmentPreviewModal** — props: attachment, onClose — `apps/web/src/components/AttachmentPreviewModal.tsx`
- **BottomSheet** — props: open, onClose, title — `apps/web/src/components/BottomSheet.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` - **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` - **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` - **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` - **ChatThroughput** — props: chatId, className — `apps/web/src/components/ChatThroughput.tsx`
- **CodeBlock** — props: code, lang — `apps/web/src/components/CodeBlock.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` - **ContextMeter** — props: messages, modelContextLimit, sessionCostUsd — `apps/web/src/components/ContextMeter.tsx`
- **CreateProjectModal** — props: open, onOpenChange — `apps/web/src/components/CreateProjectModal.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` - **DoomLoopSentinel** — props: message — `apps/web/src/components/DoomLoopSentinel.tsx`
- **DropOverlay** — props: visible — `apps/web/src/components/DropOverlay.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` - **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` - **FileViewerOverlay** — props: path, content, lang, onClose — `apps/web/src/components/FileViewerOverlay.tsx`
- **FlowLauncherDialog** — `apps/web/src/components/FlowLauncherDialog.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` - **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` - **HtmlArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/HtmlArtifactPane.tsx`
- **InferenceSettings** — `apps/web/src/components/InferenceSettings.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` - **MarkdownArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/MarkdownArtifactPane.tsx`
- **MarkdownRenderer** — props: content — `apps/web/src/components/MarkdownRenderer.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` - **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` - **MessageList** — props: messages, sessionChats — `apps/web/src/components/MessageList.tsx`
- **MobileTabSwitcher** — props: panes, activePaneIdx, chats, onSwitchPane, onRemovePane, onRenameChat — `apps/web/src/components/MobileTabSwitcher.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` - **RequestReadAccessCard** — props: toolCall, toolResult, chatId — `apps/web/src/components/RequestReadAccessCard.tsx`
- **RightRail** — props: projectId, sessionId — `apps/web/src/components/RightRail.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` - **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` - **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` - **StaleStreamBanner** — props: onRetry, onDiscard — `apps/web/src/components/StaleStreamBanner.tsx`
- **StatusDot** — props: chatId, className — `apps/web/src/components/StatusDot.tsx` - **StatusDot** — props: chatId, className — `apps/web/src/components/StatusDot.tsx`
- **ThemePicker** — `apps/web/src/components/ThemePicker.tsx` - **ThemePicker** — `apps/web/src/components/ThemePicker.tsx`
- **ToolCallGroup** — props: runs — `apps/web/src/components/ToolCallGroup.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` - **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` - **AddProviderModal** — props: open, onOpenChange, onAdded — `apps/web/src/components/coder/AddProviderModal.tsx`
- **ProvidersSettings** — `apps/web/src/components/coder/ProvidersSettings.tsx` - **ProvidersSettings** — `apps/web/src/components/coder/ProvidersSettings.tsx`
@@ -367,21 +453,31 @@
- **ThemeFx** — `apps/web/src/components/fx/ThemeFx.tsx` - **ThemeFx** — `apps/web/src/components/fx/ThemeFx.tsx`
- **ClaudeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx` - **ClaudeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
- **OpenCodeIcon** — 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` - **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` - **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` - **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` - **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` - **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` - **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` - **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` - **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` - **TerminalHotkeyBar** — props: ctrlArmed, onSendBytes, onArmCtrl, onFit — `apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx`
- **RightRailDrawerProvider** — `apps/web/src/hooks/useRightRailDrawer.tsx` - **RightRailDrawerProvider** — `apps/web/src/hooks/useRightRailDrawer.tsx`
- **SidebarDrawerProvider** — `apps/web/src/hooks/useSidebarDrawer.tsx` - **SidebarDrawerProvider** — `apps/web/src/hooks/useSidebarDrawer.tsx`
- **PATH_REGEX** — `apps/web/src/lib/linkify-paths.tsx` - **PATH_REGEX** — `apps/web/src/lib/linkify-paths.tsx`
- **Analytics** — `apps/web/src/pages/Analytics.tsx`
- **Home** — `apps/web/src/pages/Home.tsx` - **Home** — `apps/web/src/pages/Home.tsx`
- **Memory** — `apps/web/src/pages/Memory.tsx`
- **Project** — `apps/web/src/pages/Project.tsx` - **Project** — `apps/web/src/pages/Project.tsx`
- **Results** — `apps/web/src/pages/Results.tsx`
- **Session** — `apps/web/src/pages/Session.tsx` - **Session** — `apps/web/src/pages/Session.tsx`
- **Settings** — `apps/web/src/pages/Settings.tsx` - **Settings** — `apps/web/src/pages/Settings.tsx`
@@ -403,8 +499,17 @@
- function ensureSession: (tmuxConfPath, sessionName, projectRoot, log, cols?, rows?) => Promise<void> - function ensureSession: (tmuxConfPath, sessionName, projectRoot, log, cols?, rows?) => Promise<void>
- function killSession: (tmuxConfPath, sessionName) => Promise<boolean> - function killSession: (tmuxConfPath, sessionName) => Promise<boolean>
- function capturePane: (tmuxConfPath, sessionName, lines) => Promise<string> - function capturePane: (tmuxConfPath, sessionName, lines) => Promise<string>
- _...1 more_
- `apps/booterm/src/pty/pty.ts` — function attachPty: (opts) => IPty - `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` - `apps/coder/src/conductor/contracts.ts`
- function produceContract: (contracts) => string - function produceContract: (contracts) => string
- function reviewContract: (contracts) => string - function reviewContract: (contracts) => string
@@ -491,7 +596,7 @@
- function classifyLane: (battleType, _identity, model, localModels) => ContestantLane - function classifyLane: (battleType, _identity, model, localModels) => ContestantLane
- function nextLocalContestant: (contestants) => string | null - function nextLocalContestant: (contestants) => string | null
- function isBattleComplete: (contestants) => boolean - function isBattleComplete: (contestants) => boolean
- function computeBenchmark: (startedAt, endedAt, costTokens, lane) => Benchmark - function computeBenchmark: (startedAt, endedAt, costTokens, lane, tokenBreakdown) => Benchmark
- function sanitizeSlug: (s) => string - function sanitizeSlug: (s) => string
- function buildBattleSlug: (battleId, battleType, createdAt) => string - function buildBattleSlug: (battleId, battleType, createdAt) => string
- _...7 more_ - _...7 more_
@@ -555,6 +660,7 @@
- function stepEndedToUsage: (props) => StepUsage - function stepEndedToUsage: (props) => StepUsage
- interface StepEndedProps - interface StepEndedProps
- interface StepUsage - 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/pushable-iterable.ts` — function createPushable: () => Pushable<T>, interface Pushable
- `apps/coder/src/services/backends/turn-guard.ts` - `apps/coder/src/services/backends/turn-guard.ts`
- function armAbortGuard: (g) => void - function armAbortGuard: (g) => void
@@ -563,6 +669,30 @@
- interface AbortTerminalGuard - 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-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/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/cancel-registry.ts` — function createCancelRegistry: () => CancelRegistry, interface CancelRegistry
- `apps/coder/src/services/checkpoints.ts` - `apps/coder/src/services/checkpoints.ts`
- function buildShadowCommitCommand: (worktreePath, id) => string - function buildShadowCommitCommand: (worktreePath, id) => string
@@ -573,7 +703,15 @@
- interface RestoreCheckpointResult - interface RestoreCheckpointResult
- _...1 more_ - _...1 more_
- `apps/coder/src/services/claude-command-discovery.ts` — function discoverClaudeCommands: () => AgentCommand[] - `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/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` - `apps/coder/src/services/correction-service.ts`
- function recordCorrection: (originalClaim, correction, principleExtracted, persistedTo, basePath?) => Promise<UserCorrectionRecord> - function recordCorrection: (originalClaim, correction, principleExtracted, persistedTo, basePath?) => Promise<UserCorrectionRecord>
- function scanForCorrections: (auditPath) => Promise<UserCorrectionRecord[]> - function scanForCorrections: (auditPath) => Promise<UserCorrectionRecord[]>
@@ -603,10 +741,11 @@
- function partitionReady: (ready, ctx) => void - function partitionReady: (ready, ctx) => void
- function isRunComplete: (flow, state) => boolean - function isRunComplete: (flow, state) => boolean
- function isStuck: (flow, state) => boolean - function isStuck: (flow, state) => boolean
- function reconcileResumeStep: (status, taskId, taskState) => ResumeAction - function buildBatchState: (flow, inFlight) => Map<string,
- _...5 more_ - _...12 more_
- `apps/coder/src/services/flow-runner.ts` - `apps/coder/src/services/flow-runner.ts`
- function createFlowRunner: (deps) => FlowRunner - function createFlowRunner: (deps) => FlowRunner
- function resolveVariables: (prompt, results, string>) => string
- interface LaunchOpts - interface LaunchOpts
- interface FlowRunner - interface FlowRunner
- `apps/coder/src/services/frame-emitter.ts` - `apps/coder/src/services/frame-emitter.ts`
@@ -626,6 +765,19 @@
- function deleteGuideline: (id, basePath?) => Promise<boolean> - function deleteGuideline: (id, basePath?) => Promise<boolean>
- function findGuideline: (content, basePath?) => Promise<Guideline | null> - function findGuideline: (content, basePath?) => Promise<Guideline | null>
- _...14 more_ - _...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/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/client.ts` — class LspClient
- `apps/coder/src/services/lsp/config.ts` — function getServerConfig: (filePath) => LspServerConfig | null, interface LspServerConfig - `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[]> - 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/lsp/server-manager.ts` — class LspServerManager, const lspManager
- `apps/coder/src/services/mcp-server.ts` — function startMcpServer: (sql) => Promise<void> - `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` - `apps/coder/src/services/net/port-utils.ts`
- function reclaimPort: (port) => void - function reclaimPort: (port) => void
- function waitForPortRelease: (port, timeoutMs) => Promise<boolean> - function waitForPortRelease: (port, timeoutMs) => Promise<boolean>
@@ -646,6 +836,13 @@
- function createOrphanWorktreeReaper: (deps) => void - function createOrphanWorktreeReaper: (deps) => void
- interface OrphanWorktreeReaperDeps - interface OrphanWorktreeReaperDeps
- interface OrphanReaperResult - 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` - `apps/coder/src/services/pending_changes.ts`
- function planEdit: (content, oldStr, newStr) => EditPlan - function planEdit: (content, oldStr, newStr) => EditPlan
- function queueEdit: (sql, sessionId, taskId, filePath, oldString, newString, projectRoot, // v2.6 Phase 1-UX) => void - 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 waitForElicitationResponse: (taskId, sessionId, provider, modeId, params, timeoutMs) => Promise<CreateElicitationResponse>
- function cancelPendingPermission: (taskId) => void - function cancelPendingPermission: (taskId) => void
- _...3 more_ - _...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` - `apps/coder/src/services/provider-commands.ts`
- function getManifestCommands: (provider) => AgentCommand[] - function getManifestCommands: (provider) => AgentCommand[]
- function mergeCommands: (...lists) => AgentCommand[] - function mergeCommands: (...lists) => AgentCommand[]
@@ -684,13 +889,13 @@
- interface ProviderManifestEntry - interface ProviderManifestEntry
- const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> - const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry>
- `apps/coder/src/services/provider-snapshot.ts` - `apps/coder/src/services/provider-snapshot.ts`
- function fetchDeepSeekModels: (config) => Promise<ProviderModel[]>
- function fetchLlamaSwapModels: (config) => Promise<ProviderModel[]> - function fetchLlamaSwapModels: (config) => Promise<ProviderModel[]>
- function prefixLlamaSwapModels: (models) => ProviderModel[] - function prefixLlamaSwapModels: (models) => ProviderModel[]
- function mergeModels: (...lists) => ProviderModel[] - function mergeModels: (...lists) => ProviderModel[]
- function getProviderSnapshot: (sql, config, cwd?, force) => Promise<ProviderSnapshotEntry[]> - function getProviderSnapshot: (sql, config, cwd?, force) => Promise<ProviderSnapshotEntry[]>
- function clearProviderSnapshotCache: () => void - function clearProviderSnapshotCache: () => void
- function peekSnapshotEntry: (name, cwd?) => ProviderSnapshotEntry | undefined - _...2 more_
- _...1 more_
- `apps/coder/src/services/pty-dispatch.ts` - `apps/coder/src/services/pty-dispatch.ts`
- function dispatchViaPty: (opts) => Promise<DispatchResult> - function dispatchViaPty: (opts) => Promise<DispatchResult>
- interface DispatchResult - interface DispatchResult
@@ -800,6 +1005,17 @@
- function readSession: (sessionId, projectRoot?) => SessionJson | null - function readSession: (sessionId, projectRoot?) => SessionJson | null
- _...9 more_ - _...9 more_
- `apps/server/src/services/auto_name.ts` — function maybeAutoNameChat: (ctx, chatId, sessionId) => Promise<void> - `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` - `apps/server/src/services/broker.ts`
- function createBroker: (log?) => Broker - function createBroker: (log?) => Broker
- interface Broker - interface Broker
@@ -818,6 +1034,7 @@
- function select: (messages, contextLimit, tailTurns) => SelectResult - function select: (messages, contextLimit, tailTurns) => SelectResult
- function deriveFilesRead: (head) => string[] - function deriveFilesRead: (head) => string[]
- _...8 more_ - _...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_index.ts` — function getProjectFiles: (projectId, projectRoot) => Promise<string[]>
- `apps/server/src/services/file_ops.ts` - `apps/server/src/services/file_ops.ts`
- function listDir: (projectRoot, relPath, opts?) => Promise<ListDirResult> - function listDir: (projectRoot, relPath, opts?) => Promise<ListDirResult>
@@ -842,7 +1059,20 @@
- interface GiteaConfig - interface GiteaConfig
- interface GiteaRepo - interface GiteaRepo
- `apps/server/src/services/grant_resolver.ts` — function resolveGrantRoot: (sql, requestedPath, projectRoot, whitelistRoot) => Promise<GrantResolution>, type GrantResolution - `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/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/content-flusher.ts` — function createContentFlusher: (sql, messageId, getContent) => void, interface ContentFlusher
- `apps/server/src/services/inference/dcp/messages.ts` - `apps/server/src/services/inference/dcp/messages.ts`
- function toDcpMessages: (parts) => DcpMessage[] - function toDcpMessages: (parts) => DcpMessage[]
@@ -882,6 +1112,10 @@
- type FailureKind - type FailureKind
- const MISTAKE_THRESHOLD - const MISTAKE_THRESHOLD
- _...1 more_ - _...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` - `apps/server/src/services/inference/parts.ts`
- function insertParts: (sql, parts) => Promise<void> - function insertParts: (sql, parts) => Promise<void>
- function partsFromAssistantMessage: (args) => void - function partsFromAssistantMessage: (args) => void
@@ -894,10 +1128,13 @@
- function maybeFlagForCompaction: (ctx, chatId, updated) => Promise<void> - function maybeFlagForCompaction: (ctx, chatId, updated) => Promise<void>
- interface OpenAiMessage - interface OpenAiMessage
- `apps/server/src/services/inference/provider.ts` - `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 upstreamModel: (config, modelId, agent?) => LanguageModel
- function resolveModelEndpoint: (config, modelId) => void
- function resetDeepSeekProvider: () => void
- interface RoutingInfo - interface RoutingInfo
- type InferenceRoute - _...1 more_
- `apps/server/src/services/inference/prune.ts` - `apps/server/src/services/inference/prune.ts`
- function selectPruneTargets: (partsNewestFirst, tailStartCreatedAt) => void - function selectPruneTargets: (partsNewestFirst, tailStartCreatedAt) => void
- function prune: (args) => Promise<PruneResult> - function prune: (args) => Promise<PruneResult>
@@ -918,6 +1155,12 @@
- function isAnySentinel: (m) => boolean - function isAnySentinel: (m) => boolean
- const DOOM_LOOP_THRESHOLD - const DOOM_LOOP_THRESHOLD
- _...1 more_ - _...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` - `apps/server/src/services/inference/step-decision.ts`
- function decideStep: (input) => PreStepDecision - function decideStep: (input) => PreStepDecision
- function decidePostToolAction: (action, mistakeTracker) => PostToolDecision - 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 - `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. // tool list sent to the LLM, so the model can't even attempt them.
webToolsEnabled) => Promise<StreamResult> 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` - `apps/server/src/services/inference/tool-call-parser.ts`
- function stripToolMarkup: (text, opts?) => string - function stripToolMarkup: (text, opts?) => string
- function extractToolCallBlocks: (buffer, log?) => ToolCallExtraction - function extractToolCallBlocks: (buffer, log?) => ToolCallExtraction
- interface ParsedCall - interface ParsedCall
- interface ToolCallExtraction - 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` - `apps/server/src/services/inference/tool-shim.ts`
- function extractToolCalls: (text) => ParsedToolCall[] - function extractToolCalls: (text) => ParsedToolCall[]
- function hasToolCallMarkup: (text) => boolean - function hasToolCallMarkup: (text) => boolean
@@ -955,20 +1200,26 @@
- `apps/server/src/services/inference/turn.ts` - `apps/server/src/services/inference/turn.ts`
- function runAssistantTurn: (ctx, args) => Promise<void> - function runAssistantTurn: (ctx, args) => Promise<void>
- function runInference: (ctx, sessionId, chatId, assistantMessageId, signal?) => 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 - function createInferenceRunner: (ctx, 'publishUser'>, publishUserFn, frame) => void
- `apps/server/src/services/mcp-client.ts` - `apps/server/src/services/mcp-client.ts`
- function initialize: (entries, logger) => Promise<void> - function initialize: (entries, logger) => Promise<void>
- function callTool: (prefixedName, args, unknown>) => Promise<unknown> - 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 getTools: () => ToolDef<Record<string, unknown>>[]
- function getMcpServers: () => Array< - _...6 more_
- function shutdown: () => Promise<void>
- function wrapMcpTool: (serverName, mcpTool) => ToolDef<Record<string, unknown>>
- _...2 more_
- `apps/server/src/services/mcp-config.ts` - `apps/server/src/services/mcp-config.ts`
- function substituteEnvVars: (value, log, unsetVars?) => unknown - function substituteEnvVars: (value, log, unsetVars?) => unknown
- function loadMcpConfig: (configPath, log) => McpServerEntry[] - function loadMcpConfig: (configPath, log) => McpServerEntry[]
- interface McpServerEntry - interface McpServerEntry
- type McpServerConfig - 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/entries.ts` — function parseMemoryEntries: (fileName, markdown) => MemoryEntry[], interface MemoryEntry
- `apps/server/src/services/memory/paths.ts` - `apps/server/src/services/memory/paths.ts`
- function getMemoryRoot: (projectRoot) => string - function getMemoryRoot: (projectRoot) => string
@@ -976,7 +1227,10 @@
- function ensureMemoryScaffold: (root) => Promise<void> - function ensureMemoryScaffold: (root) => Promise<void>
- type MemoryTopic - type MemoryTopic
- `apps/server/src/services/memory/prompt.ts` — function formatMemoryBlock: (entries) => string - `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` - `apps/server/src/services/memory/scan.ts`
- function scanMemoryScopes: (scope) => Promise<MemoryEntry[]> - function scanMemoryScopes: (scope) => Promise<MemoryEntry[]>
- function scanProjectMemory: (projectRoot) => Promise<MemoryEntry[]> - function scanProjectMemory: (projectRoot) => Promise<MemoryEntry[]>
@@ -1007,6 +1261,11 @@
- function filterSecretEntries: (entries, pathOf) => void - function filterSecretEntries: (entries, pathOf) => void
- class SecretBlockedError - class SecretBlockedError
- const DEFAULT_SECURITY_IGNORE_FILETYPES: ReadonlyArray<string> - 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` - `apps/server/src/services/skill-invoke.ts`
- function runSkillInvokeTransaction: (sql, args) => Promise< - function runSkillInvokeTransaction: (sql, args) => Promise<
- function buildSkillInvokeSyntheticFrames: (chatId, result, toolCall, skillBody) => SkillInvokeSessionFrame[] - function buildSkillInvokeSyntheticFrames: (chatId, result, toolCall, skillBody) => SkillInvokeSessionFrame[]
@@ -1037,8 +1296,53 @@
- _...2 more_ - _...2 more_
- `apps/server/src/services/task-model.ts` — function taskModelCompletion: (opts) => Promise<string> - `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/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>; - `apps/server/src/services/tools/codecontext/factory.ts` — function makeCodecontextTool: (opts, unknown>;
mapArgs) => void 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/registry.ts` — function appendMcpTools: (mcpTools) => void, function toolJsonSchemas: () => ToolJsonSchema[]
- `apps/server/src/services/tools/tiers.ts` - `apps/server/src/services/tools/tiers.ts`
- function resolveToolTier: (tier) => readonly string[] - function resolveToolTier: (tier) => readonly string[]
@@ -1064,6 +1368,39 @@
- interface WebSearchOutput - interface WebSearchOutput
- type WebSearchInputT - type WebSearchInputT
- const webSearch: ToolDef<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/server/src/utils/string-utils.ts` — function stripQuotes: (s) => string
- `apps/web/src/api/client.ts` - `apps/web/src/api/client.ts`
- class ApiError - class ApiError
@@ -1084,7 +1421,7 @@
- interface TerminalSelectionActions - interface TerminalSelectionActions
- interface TerminalSelection - interface TerminalSelection
- `apps/web/src/hooks/terminal/useTerminalSocket.ts` - `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 - interface TerminalSocket
- type ConnState - type ConnState
- `apps/web/src/hooks/useActivePane.ts` - `apps/web/src/hooks/useActivePane.ts`
@@ -1108,7 +1445,8 @@
- interface ThroughputSample - interface ThroughputSample
- `apps/web/src/hooks/useCoderUserEvents.ts` — function useCoderUserEvents: () => void - `apps/web/src/hooks/useCoderUserEvents.ts` — function useCoderUserEvents: () => void
- `apps/web/src/hooks/useDiffPreferences.ts` — function useDiffPreferences: () => void, interface DiffPreferences - `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/useLongPress.ts` — function useLongPress: (callback) => void
- `apps/web/src/hooks/useProjectGit.ts` — function useProjectGit: (projectId) => GitMeta | null - `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 - `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/useSessions.ts` — function useSessions: (projectId) => void
- `apps/web/src/hooks/useSidebar.ts` — function useSidebar: () => void - `apps/web/src/hooks/useSidebar.ts` — function useSidebar: () => void
- `apps/web/src/hooks/useSkills.ts` — function useSkills: () => 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/useUserEvents.ts` — function useUserEvents: () => void
- `apps/web/src/hooks/useViewport.ts` — function useViewport: () => ViewportSnapshot, interface ViewportSnapshot - `apps/web/src/hooks/useViewport.ts` — function useViewport: () => ViewportSnapshot, interface ViewportSnapshot
- `apps/web/src/hooks/useWorkspacePanes.ts` - `apps/web/src/hooks/useWorkspacePanes.ts`
@@ -1183,7 +1522,16 @@
- interface ThemeMeta - interface ThemeMeta
- type ThemeId - type ThemeId
- _...5 more_ - _...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/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` - `apps/web/src/utils/diff-layout.ts`
- function parseDiff: (diffBody) => ParsedDiffFile[] - function parseDiff: (diffBody) => ParsedDiffFile[]
- function buildSplitRows: (file) => SplitRow[] - function buildSplitRows: (file) => SplitRow[]
@@ -1344,8 +1692,11 @@
- `CONTAINER_GUIDANCE_FILE` **required** — apps/server/src/services/__tests__/system-prompt.test.ts - `CONTAINER_GUIDANCE_FILE` **required** — apps/server/src/services/__tests__/system-prompt.test.ts
- `CONTEXT7_API_KEY` (has default) — .env - `CONTEXT7_API_KEY` (has default) — .env
- `DATABASE_URL` (has default) — .env.example - `DATABASE_URL` (has default) — .env.example
- `DEEPSEEK_API_KEY` (has default) — .env
- `DEEPSEEK_BASE_URL` (has default) — .env
- `DEFAULT_MODEL` (has default) — .env.example - `DEFAULT_MODEL` (has default) — .env.example
- `DEV_REMOTE_USER` **required** — apps/web/vite.config.ts - `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_BASE_URL` (has default) — .env
- `GITEA_SSH_HOST` (has default) — .env - `GITEA_SSH_HOST` (has default) — .env
- `GITEA_TOKEN` (has default) — .env - `GITEA_TOKEN` (has default) — .env
@@ -1353,6 +1704,7 @@
- `LLAMA_SWAP_URL` (has default) — .env.example - `LLAMA_SWAP_URL` (has default) — .env.example
- `MCP_TEST_MISSING` **required** — apps/server/src/services/__tests__/mcp-config.test.ts - `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 - `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 - `NODE_ENV` (has default) — .env.example
- `PORT` (has default) — .env.example - `PORT` (has default) — .env.example
- `POSTGRES_PASSWORD` (has default) — .env.example - `POSTGRES_PASSWORD` (has default) — .env.example
@@ -1368,6 +1720,10 @@
- `apps/web/vite.config.ts` - `apps/web/vite.config.ts`
- `docker-compose.yml` - `docker-compose.yml`
## Key Dependencies
- better-sqlite3: ^11.10.0
--- ---
# Middleware # Middleware
@@ -1379,6 +1735,7 @@
- turn-guard — `apps/coder/src/services/backends/turn-guard.ts` - turn-guard — `apps/coder/src/services/backends/turn-guard.ts`
- get_middleware — `apps/server/src/services/tools/codecontext/get_middleware.ts` - get_middleware — `apps/server/src/services/tools/codecontext/get_middleware.ts`
- authoring — `conductor/src/flows/authoring.ts` - authoring — `conductor/src/flows/authoring.ts`
- spec — `openspec/changes/add-behavioral-engine/specs/audit-middleware/spec.md`
## custom ## custom
- write_guard.test — `apps/coder/src/services/__tests__/write_guard.test.ts` - write_guard.test — `apps/coder/src/services/__tests__/write_guard.test.ts`
@@ -1400,39 +1757,39 @@
## Most Imported Files (change these carefully) ## Most Imported Files (change these carefully)
- `apps/coder/src/db.ts` — imported by **40** files - `apps/coder/src/db.ts` — imported by **44** files
- `apps/server/src/types/api.ts` — imported by **28** files - `apps/server/src/types/api.ts` — imported by **34** files
- `apps/server/src/db.ts` — imported by **25** files - `apps/server/src/db.ts` — imported by **32** files
- `packages/ion/src/cli/utils.ts` — imported by **24** 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/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/agent-backend.ts` — imported by **14** files
- `apps/coder/src/services/acp-tool-snapshot.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/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 - `conductor/src/types.ts` — imported by **13** files
- `apps/coder/src/services/provider-config-registry.ts` — imported by **12** 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/config.ts` — imported by **11** files
- `apps/coder/src/services/provider-types.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/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/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) ## 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/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` +23 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/artifacts.ts`, `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts` +20 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 - `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/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/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/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/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
- `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
--- ---

View File

@@ -10,23 +10,34 @@
- **AttachmentChip** — props: attachment, onRemove, onPreview — `apps/web/src/components/AttachmentChip.tsx` - **AttachmentChip** — props: attachment, onRemove, onPreview — `apps/web/src/components/AttachmentChip.tsx`
- **AttachmentPreviewModal** — props: attachment, onClose — `apps/web/src/components/AttachmentPreviewModal.tsx` - **AttachmentPreviewModal** — props: attachment, onClose — `apps/web/src/components/AttachmentPreviewModal.tsx`
- **BottomSheet** — props: open, onClose, title — `apps/web/src/components/BottomSheet.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` - **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` - **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` - **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` - **ChatThroughput** — props: chatId, className — `apps/web/src/components/ChatThroughput.tsx`
- **CodeBlock** — props: code, lang — `apps/web/src/components/CodeBlock.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` - **ContextMeter** — props: messages, modelContextLimit, sessionCostUsd — `apps/web/src/components/ContextMeter.tsx`
- **CreateProjectModal** — props: open, onOpenChange — `apps/web/src/components/CreateProjectModal.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` - **DoomLoopSentinel** — props: message — `apps/web/src/components/DoomLoopSentinel.tsx`
- **DropOverlay** — props: visible — `apps/web/src/components/DropOverlay.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` - **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` - **FileViewerOverlay** — props: path, content, lang, onClose — `apps/web/src/components/FileViewerOverlay.tsx`
- **FlowLauncherDialog** — `apps/web/src/components/FlowLauncherDialog.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` - **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` - **HtmlArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/HtmlArtifactPane.tsx`
- **InferenceSettings** — `apps/web/src/components/InferenceSettings.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` - **MarkdownArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/MarkdownArtifactPane.tsx`
- **MarkdownRenderer** — props: content — `apps/web/src/components/MarkdownRenderer.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` - **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` - **MessageList** — props: messages, sessionChats — `apps/web/src/components/MessageList.tsx`
- **MobileTabSwitcher** — props: panes, activePaneIdx, chats, onSwitchPane, onRemovePane, onRenameChat — `apps/web/src/components/MobileTabSwitcher.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` - **RequestReadAccessCard** — props: toolCall, toolResult, chatId — `apps/web/src/components/RequestReadAccessCard.tsx`
- **RightRail** — props: projectId, sessionId — `apps/web/src/components/RightRail.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` - **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` - **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` - **StaleStreamBanner** — props: onRetry, onDiscard — `apps/web/src/components/StaleStreamBanner.tsx`
- **StatusDot** — props: chatId, className — `apps/web/src/components/StatusDot.tsx` - **StatusDot** — props: chatId, className — `apps/web/src/components/StatusDot.tsx`
- **ThemePicker** — `apps/web/src/components/ThemePicker.tsx` - **ThemePicker** — `apps/web/src/components/ThemePicker.tsx`
- **ToolCallGroup** — props: runs — `apps/web/src/components/ToolCallGroup.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` - **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` - **AddProviderModal** — props: open, onOpenChange, onAdded — `apps/web/src/components/coder/AddProviderModal.tsx`
- **ProvidersSettings** — `apps/web/src/components/coder/ProvidersSettings.tsx` - **ProvidersSettings** — `apps/web/src/components/coder/ProvidersSettings.tsx`
@@ -52,20 +65,30 @@
- **ThemeFx** — `apps/web/src/components/fx/ThemeFx.tsx` - **ThemeFx** — `apps/web/src/components/fx/ThemeFx.tsx`
- **ClaudeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx` - **ClaudeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
- **OpenCodeIcon** — 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` - **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` - **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` - **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` - **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` - **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` - **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` - **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` - **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` - **TerminalHotkeyBar** — props: ctrlArmed, onSendBytes, onArmCtrl, onFit — `apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx`
- **RightRailDrawerProvider** — `apps/web/src/hooks/useRightRailDrawer.tsx` - **RightRailDrawerProvider** — `apps/web/src/hooks/useRightRailDrawer.tsx`
- **SidebarDrawerProvider** — `apps/web/src/hooks/useSidebarDrawer.tsx` - **SidebarDrawerProvider** — `apps/web/src/hooks/useSidebarDrawer.tsx`
- **PATH_REGEX** — `apps/web/src/lib/linkify-paths.tsx` - **PATH_REGEX** — `apps/web/src/lib/linkify-paths.tsx`
- **Analytics** — `apps/web/src/pages/Analytics.tsx`
- **Home** — `apps/web/src/pages/Home.tsx` - **Home** — `apps/web/src/pages/Home.tsx`
- **Memory** — `apps/web/src/pages/Memory.tsx`
- **Project** — `apps/web/src/pages/Project.tsx` - **Project** — `apps/web/src/pages/Project.tsx`
- **Results** — `apps/web/src/pages/Results.tsx`
- **Session** — `apps/web/src/pages/Session.tsx` - **Session** — `apps/web/src/pages/Session.tsx`
- **Settings** — `apps/web/src/pages/Settings.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 - `CONTAINER_GUIDANCE_FILE` **required** — apps/server/src/services/__tests__/system-prompt.test.ts
- `CONTEXT7_API_KEY` (has default) — .env - `CONTEXT7_API_KEY` (has default) — .env
- `DATABASE_URL` (has default) — .env.example - `DATABASE_URL` (has default) — .env.example
- `DEEPSEEK_API_KEY` (has default) — .env
- `DEEPSEEK_BASE_URL` (has default) — .env
- `DEFAULT_MODEL` (has default) — .env.example - `DEFAULT_MODEL` (has default) — .env.example
- `DEV_REMOTE_USER` **required** — apps/web/vite.config.ts - `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_BASE_URL` (has default) — .env
- `GITEA_SSH_HOST` (has default) — .env - `GITEA_SSH_HOST` (has default) — .env
- `GITEA_TOKEN` (has default) — .env - `GITEA_TOKEN` (has default) — .env
@@ -34,6 +37,7 @@
- `LLAMA_SWAP_URL` (has default) — .env.example - `LLAMA_SWAP_URL` (has default) — .env.example
- `MCP_TEST_MISSING` **required** — apps/server/src/services/__tests__/mcp-config.test.ts - `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 - `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 - `NODE_ENV` (has default) — .env.example
- `PORT` (has default) — .env.example - `PORT` (has default) — .env.example
- `POSTGRES_PASSWORD` (has default) — .env.example - `POSTGRES_PASSWORD` (has default) — .env.example
@@ -48,3 +52,7 @@
- `Dockerfile` - `Dockerfile`
- `apps/web/vite.config.ts` - `apps/web/vite.config.ts`
- `docker-compose.yml` - `docker-compose.yml`
## Key Dependencies
- better-sqlite3: ^11.10.0

View File

@@ -2,36 +2,36 @@
## Most Imported Files (change these carefully) ## Most Imported Files (change these carefully)
- `apps/coder/src/db.ts` — imported by **40** files - `apps/coder/src/db.ts` — imported by **44** files
- `apps/server/src/types/api.ts` — imported by **28** files - `apps/server/src/types/api.ts` — imported by **34** files
- `apps/server/src/db.ts` — imported by **25** files - `apps/server/src/db.ts` — imported by **32** files
- `packages/ion/src/cli/utils.ts` — imported by **24** 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/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/agent-backend.ts` — imported by **14** files
- `apps/coder/src/services/acp-tool-snapshot.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/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 - `conductor/src/types.ts` — imported by **13** files
- `apps/coder/src/services/provider-config-registry.ts` — imported by **12** 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/config.ts` — imported by **11** files
- `apps/coder/src/services/provider-types.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/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/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) ## 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/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` +23 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/artifacts.ts`, `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts` +20 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 - `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/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/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/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/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
- `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

View File

@@ -14,8 +14,17 @@
- function ensureSession: (tmuxConfPath, sessionName, projectRoot, log, cols?, rows?) => Promise<void> - function ensureSession: (tmuxConfPath, sessionName, projectRoot, log, cols?, rows?) => Promise<void>
- function killSession: (tmuxConfPath, sessionName) => Promise<boolean> - function killSession: (tmuxConfPath, sessionName) => Promise<boolean>
- function capturePane: (tmuxConfPath, sessionName, lines) => Promise<string> - function capturePane: (tmuxConfPath, sessionName, lines) => Promise<string>
- _...1 more_
- `apps/booterm/src/pty/pty.ts` — function attachPty: (opts) => IPty - `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` - `apps/coder/src/conductor/contracts.ts`
- function produceContract: (contracts) => string - function produceContract: (contracts) => string
- function reviewContract: (contracts) => string - function reviewContract: (contracts) => string
@@ -102,7 +111,7 @@
- function classifyLane: (battleType, _identity, model, localModels) => ContestantLane - function classifyLane: (battleType, _identity, model, localModels) => ContestantLane
- function nextLocalContestant: (contestants) => string | null - function nextLocalContestant: (contestants) => string | null
- function isBattleComplete: (contestants) => boolean - function isBattleComplete: (contestants) => boolean
- function computeBenchmark: (startedAt, endedAt, costTokens, lane) => Benchmark - function computeBenchmark: (startedAt, endedAt, costTokens, lane, tokenBreakdown) => Benchmark
- function sanitizeSlug: (s) => string - function sanitizeSlug: (s) => string
- function buildBattleSlug: (battleId, battleType, createdAt) => string - function buildBattleSlug: (battleId, battleType, createdAt) => string
- _...7 more_ - _...7 more_
@@ -166,6 +175,7 @@
- function stepEndedToUsage: (props) => StepUsage - function stepEndedToUsage: (props) => StepUsage
- interface StepEndedProps - interface StepEndedProps
- interface StepUsage - 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/pushable-iterable.ts` — function createPushable: () => Pushable<T>, interface Pushable
- `apps/coder/src/services/backends/turn-guard.ts` - `apps/coder/src/services/backends/turn-guard.ts`
- function armAbortGuard: (g) => void - function armAbortGuard: (g) => void
@@ -174,6 +184,30 @@
- interface AbortTerminalGuard - 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-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/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/cancel-registry.ts` — function createCancelRegistry: () => CancelRegistry, interface CancelRegistry
- `apps/coder/src/services/checkpoints.ts` - `apps/coder/src/services/checkpoints.ts`
- function buildShadowCommitCommand: (worktreePath, id) => string - function buildShadowCommitCommand: (worktreePath, id) => string
@@ -184,7 +218,15 @@
- interface RestoreCheckpointResult - interface RestoreCheckpointResult
- _...1 more_ - _...1 more_
- `apps/coder/src/services/claude-command-discovery.ts` — function discoverClaudeCommands: () => AgentCommand[] - `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/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` - `apps/coder/src/services/correction-service.ts`
- function recordCorrection: (originalClaim, correction, principleExtracted, persistedTo, basePath?) => Promise<UserCorrectionRecord> - function recordCorrection: (originalClaim, correction, principleExtracted, persistedTo, basePath?) => Promise<UserCorrectionRecord>
- function scanForCorrections: (auditPath) => Promise<UserCorrectionRecord[]> - function scanForCorrections: (auditPath) => Promise<UserCorrectionRecord[]>
@@ -214,10 +256,11 @@
- function partitionReady: (ready, ctx) => void - function partitionReady: (ready, ctx) => void
- function isRunComplete: (flow, state) => boolean - function isRunComplete: (flow, state) => boolean
- function isStuck: (flow, state) => boolean - function isStuck: (flow, state) => boolean
- function reconcileResumeStep: (status, taskId, taskState) => ResumeAction - function buildBatchState: (flow, inFlight) => Map<string,
- _...5 more_ - _...12 more_
- `apps/coder/src/services/flow-runner.ts` - `apps/coder/src/services/flow-runner.ts`
- function createFlowRunner: (deps) => FlowRunner - function createFlowRunner: (deps) => FlowRunner
- function resolveVariables: (prompt, results, string>) => string
- interface LaunchOpts - interface LaunchOpts
- interface FlowRunner - interface FlowRunner
- `apps/coder/src/services/frame-emitter.ts` - `apps/coder/src/services/frame-emitter.ts`
@@ -237,6 +280,19 @@
- function deleteGuideline: (id, basePath?) => Promise<boolean> - function deleteGuideline: (id, basePath?) => Promise<boolean>
- function findGuideline: (content, basePath?) => Promise<Guideline | null> - function findGuideline: (content, basePath?) => Promise<Guideline | null>
- _...14 more_ - _...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/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/client.ts` — class LspClient
- `apps/coder/src/services/lsp/config.ts` — function getServerConfig: (filePath) => LspServerConfig | null, interface LspServerConfig - `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[]> - 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/lsp/server-manager.ts` — class LspServerManager, const lspManager
- `apps/coder/src/services/mcp-server.ts` — function startMcpServer: (sql) => Promise<void> - `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` - `apps/coder/src/services/net/port-utils.ts`
- function reclaimPort: (port) => void - function reclaimPort: (port) => void
- function waitForPortRelease: (port, timeoutMs) => Promise<boolean> - function waitForPortRelease: (port, timeoutMs) => Promise<boolean>
@@ -257,6 +351,13 @@
- function createOrphanWorktreeReaper: (deps) => void - function createOrphanWorktreeReaper: (deps) => void
- interface OrphanWorktreeReaperDeps - interface OrphanWorktreeReaperDeps
- interface OrphanReaperResult - 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` - `apps/coder/src/services/pending_changes.ts`
- function planEdit: (content, oldStr, newStr) => EditPlan - function planEdit: (content, oldStr, newStr) => EditPlan
- function queueEdit: (sql, sessionId, taskId, filePath, oldString, newString, projectRoot, // v2.6 Phase 1-UX) => void - 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 waitForElicitationResponse: (taskId, sessionId, provider, modeId, params, timeoutMs) => Promise<CreateElicitationResponse>
- function cancelPendingPermission: (taskId) => void - function cancelPendingPermission: (taskId) => void
- _...3 more_ - _...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` - `apps/coder/src/services/provider-commands.ts`
- function getManifestCommands: (provider) => AgentCommand[] - function getManifestCommands: (provider) => AgentCommand[]
- function mergeCommands: (...lists) => AgentCommand[] - function mergeCommands: (...lists) => AgentCommand[]
@@ -295,13 +404,13 @@
- interface ProviderManifestEntry - interface ProviderManifestEntry
- const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> - const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry>
- `apps/coder/src/services/provider-snapshot.ts` - `apps/coder/src/services/provider-snapshot.ts`
- function fetchDeepSeekModels: (config) => Promise<ProviderModel[]>
- function fetchLlamaSwapModels: (config) => Promise<ProviderModel[]> - function fetchLlamaSwapModels: (config) => Promise<ProviderModel[]>
- function prefixLlamaSwapModels: (models) => ProviderModel[] - function prefixLlamaSwapModels: (models) => ProviderModel[]
- function mergeModels: (...lists) => ProviderModel[] - function mergeModels: (...lists) => ProviderModel[]
- function getProviderSnapshot: (sql, config, cwd?, force) => Promise<ProviderSnapshotEntry[]> - function getProviderSnapshot: (sql, config, cwd?, force) => Promise<ProviderSnapshotEntry[]>
- function clearProviderSnapshotCache: () => void - function clearProviderSnapshotCache: () => void
- function peekSnapshotEntry: (name, cwd?) => ProviderSnapshotEntry | undefined - _...2 more_
- _...1 more_
- `apps/coder/src/services/pty-dispatch.ts` - `apps/coder/src/services/pty-dispatch.ts`
- function dispatchViaPty: (opts) => Promise<DispatchResult> - function dispatchViaPty: (opts) => Promise<DispatchResult>
- interface DispatchResult - interface DispatchResult
@@ -411,6 +520,17 @@
- function readSession: (sessionId, projectRoot?) => SessionJson | null - function readSession: (sessionId, projectRoot?) => SessionJson | null
- _...9 more_ - _...9 more_
- `apps/server/src/services/auto_name.ts` — function maybeAutoNameChat: (ctx, chatId, sessionId) => Promise<void> - `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` - `apps/server/src/services/broker.ts`
- function createBroker: (log?) => Broker - function createBroker: (log?) => Broker
- interface Broker - interface Broker
@@ -429,6 +549,7 @@
- function select: (messages, contextLimit, tailTurns) => SelectResult - function select: (messages, contextLimit, tailTurns) => SelectResult
- function deriveFilesRead: (head) => string[] - function deriveFilesRead: (head) => string[]
- _...8 more_ - _...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_index.ts` — function getProjectFiles: (projectId, projectRoot) => Promise<string[]>
- `apps/server/src/services/file_ops.ts` - `apps/server/src/services/file_ops.ts`
- function listDir: (projectRoot, relPath, opts?) => Promise<ListDirResult> - function listDir: (projectRoot, relPath, opts?) => Promise<ListDirResult>
@@ -453,7 +574,20 @@
- interface GiteaConfig - interface GiteaConfig
- interface GiteaRepo - interface GiteaRepo
- `apps/server/src/services/grant_resolver.ts` — function resolveGrantRoot: (sql, requestedPath, projectRoot, whitelistRoot) => Promise<GrantResolution>, type GrantResolution - `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/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/content-flusher.ts` — function createContentFlusher: (sql, messageId, getContent) => void, interface ContentFlusher
- `apps/server/src/services/inference/dcp/messages.ts` - `apps/server/src/services/inference/dcp/messages.ts`
- function toDcpMessages: (parts) => DcpMessage[] - function toDcpMessages: (parts) => DcpMessage[]
@@ -493,6 +627,10 @@
- type FailureKind - type FailureKind
- const MISTAKE_THRESHOLD - const MISTAKE_THRESHOLD
- _...1 more_ - _...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` - `apps/server/src/services/inference/parts.ts`
- function insertParts: (sql, parts) => Promise<void> - function insertParts: (sql, parts) => Promise<void>
- function partsFromAssistantMessage: (args) => void - function partsFromAssistantMessage: (args) => void
@@ -505,10 +643,13 @@
- function maybeFlagForCompaction: (ctx, chatId, updated) => Promise<void> - function maybeFlagForCompaction: (ctx, chatId, updated) => Promise<void>
- interface OpenAiMessage - interface OpenAiMessage
- `apps/server/src/services/inference/provider.ts` - `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 upstreamModel: (config, modelId, agent?) => LanguageModel
- function resolveModelEndpoint: (config, modelId) => void
- function resetDeepSeekProvider: () => void
- interface RoutingInfo - interface RoutingInfo
- type InferenceRoute - _...1 more_
- `apps/server/src/services/inference/prune.ts` - `apps/server/src/services/inference/prune.ts`
- function selectPruneTargets: (partsNewestFirst, tailStartCreatedAt) => void - function selectPruneTargets: (partsNewestFirst, tailStartCreatedAt) => void
- function prune: (args) => Promise<PruneResult> - function prune: (args) => Promise<PruneResult>
@@ -529,6 +670,12 @@
- function isAnySentinel: (m) => boolean - function isAnySentinel: (m) => boolean
- const DOOM_LOOP_THRESHOLD - const DOOM_LOOP_THRESHOLD
- _...1 more_ - _...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` - `apps/server/src/services/inference/step-decision.ts`
- function decideStep: (input) => PreStepDecision - function decideStep: (input) => PreStepDecision
- function decidePostToolAction: (action, mistakeTracker) => PostToolDecision - 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 - `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. // tool list sent to the LLM, so the model can't even attempt them.
webToolsEnabled) => Promise<StreamResult> 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` - `apps/server/src/services/inference/tool-call-parser.ts`
- function stripToolMarkup: (text, opts?) => string - function stripToolMarkup: (text, opts?) => string
- function extractToolCallBlocks: (buffer, log?) => ToolCallExtraction - function extractToolCallBlocks: (buffer, log?) => ToolCallExtraction
- interface ParsedCall - interface ParsedCall
- interface ToolCallExtraction - 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` - `apps/server/src/services/inference/tool-shim.ts`
- function extractToolCalls: (text) => ParsedToolCall[] - function extractToolCalls: (text) => ParsedToolCall[]
- function hasToolCallMarkup: (text) => boolean - function hasToolCallMarkup: (text) => boolean
@@ -566,20 +715,26 @@
- `apps/server/src/services/inference/turn.ts` - `apps/server/src/services/inference/turn.ts`
- function runAssistantTurn: (ctx, args) => Promise<void> - function runAssistantTurn: (ctx, args) => Promise<void>
- function runInference: (ctx, sessionId, chatId, assistantMessageId, signal?) => 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 - function createInferenceRunner: (ctx, 'publishUser'>, publishUserFn, frame) => void
- `apps/server/src/services/mcp-client.ts` - `apps/server/src/services/mcp-client.ts`
- function initialize: (entries, logger) => Promise<void> - function initialize: (entries, logger) => Promise<void>
- function callTool: (prefixedName, args, unknown>) => Promise<unknown> - 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 getTools: () => ToolDef<Record<string, unknown>>[]
- function getMcpServers: () => Array< - _...6 more_
- function shutdown: () => Promise<void>
- function wrapMcpTool: (serverName, mcpTool) => ToolDef<Record<string, unknown>>
- _...2 more_
- `apps/server/src/services/mcp-config.ts` - `apps/server/src/services/mcp-config.ts`
- function substituteEnvVars: (value, log, unsetVars?) => unknown - function substituteEnvVars: (value, log, unsetVars?) => unknown
- function loadMcpConfig: (configPath, log) => McpServerEntry[] - function loadMcpConfig: (configPath, log) => McpServerEntry[]
- interface McpServerEntry - interface McpServerEntry
- type McpServerConfig - 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/entries.ts` — function parseMemoryEntries: (fileName, markdown) => MemoryEntry[], interface MemoryEntry
- `apps/server/src/services/memory/paths.ts` - `apps/server/src/services/memory/paths.ts`
- function getMemoryRoot: (projectRoot) => string - function getMemoryRoot: (projectRoot) => string
@@ -587,7 +742,10 @@
- function ensureMemoryScaffold: (root) => Promise<void> - function ensureMemoryScaffold: (root) => Promise<void>
- type MemoryTopic - type MemoryTopic
- `apps/server/src/services/memory/prompt.ts` — function formatMemoryBlock: (entries) => string - `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` - `apps/server/src/services/memory/scan.ts`
- function scanMemoryScopes: (scope) => Promise<MemoryEntry[]> - function scanMemoryScopes: (scope) => Promise<MemoryEntry[]>
- function scanProjectMemory: (projectRoot) => Promise<MemoryEntry[]> - function scanProjectMemory: (projectRoot) => Promise<MemoryEntry[]>
@@ -618,6 +776,11 @@
- function filterSecretEntries: (entries, pathOf) => void - function filterSecretEntries: (entries, pathOf) => void
- class SecretBlockedError - class SecretBlockedError
- const DEFAULT_SECURITY_IGNORE_FILETYPES: ReadonlyArray<string> - 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` - `apps/server/src/services/skill-invoke.ts`
- function runSkillInvokeTransaction: (sql, args) => Promise< - function runSkillInvokeTransaction: (sql, args) => Promise<
- function buildSkillInvokeSyntheticFrames: (chatId, result, toolCall, skillBody) => SkillInvokeSessionFrame[] - function buildSkillInvokeSyntheticFrames: (chatId, result, toolCall, skillBody) => SkillInvokeSessionFrame[]
@@ -648,8 +811,53 @@
- _...2 more_ - _...2 more_
- `apps/server/src/services/task-model.ts` — function taskModelCompletion: (opts) => Promise<string> - `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/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>; - `apps/server/src/services/tools/codecontext/factory.ts` — function makeCodecontextTool: (opts, unknown>;
mapArgs) => void 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/registry.ts` — function appendMcpTools: (mcpTools) => void, function toolJsonSchemas: () => ToolJsonSchema[]
- `apps/server/src/services/tools/tiers.ts` - `apps/server/src/services/tools/tiers.ts`
- function resolveToolTier: (tier) => readonly string[] - function resolveToolTier: (tier) => readonly string[]
@@ -675,6 +883,39 @@
- interface WebSearchOutput - interface WebSearchOutput
- type WebSearchInputT - type WebSearchInputT
- const webSearch: ToolDef<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/server/src/utils/string-utils.ts` — function stripQuotes: (s) => string
- `apps/web/src/api/client.ts` - `apps/web/src/api/client.ts`
- class ApiError - class ApiError
@@ -695,7 +936,7 @@
- interface TerminalSelectionActions - interface TerminalSelectionActions
- interface TerminalSelection - interface TerminalSelection
- `apps/web/src/hooks/terminal/useTerminalSocket.ts` - `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 - interface TerminalSocket
- type ConnState - type ConnState
- `apps/web/src/hooks/useActivePane.ts` - `apps/web/src/hooks/useActivePane.ts`
@@ -719,7 +960,8 @@
- interface ThroughputSample - interface ThroughputSample
- `apps/web/src/hooks/useCoderUserEvents.ts` — function useCoderUserEvents: () => void - `apps/web/src/hooks/useCoderUserEvents.ts` — function useCoderUserEvents: () => void
- `apps/web/src/hooks/useDiffPreferences.ts` — function useDiffPreferences: () => void, interface DiffPreferences - `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/useLongPress.ts` — function useLongPress: (callback) => void
- `apps/web/src/hooks/useProjectGit.ts` — function useProjectGit: (projectId) => GitMeta | null - `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 - `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/useSessions.ts` — function useSessions: (projectId) => void
- `apps/web/src/hooks/useSidebar.ts` — function useSidebar: () => void - `apps/web/src/hooks/useSidebar.ts` — function useSidebar: () => void
- `apps/web/src/hooks/useSkills.ts` — function useSkills: () => 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/useUserEvents.ts` — function useUserEvents: () => void
- `apps/web/src/hooks/useViewport.ts` — function useViewport: () => ViewportSnapshot, interface ViewportSnapshot - `apps/web/src/hooks/useViewport.ts` — function useViewport: () => ViewportSnapshot, interface ViewportSnapshot
- `apps/web/src/hooks/useWorkspacePanes.ts` - `apps/web/src/hooks/useWorkspacePanes.ts`
@@ -794,7 +1037,16 @@
- interface ThemeMeta - interface ThemeMeta
- type ThemeId - type ThemeId
- _...5 more_ - _...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/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` - `apps/web/src/utils/diff-layout.ts`
- function parseDiff: (diffBody) => ParsedDiffFile[] - function parseDiff: (diffBody) => ParsedDiffFile[]
- function buildSplitRows: (file) => SplitRow[] - function buildSplitRows: (file) => SplitRow[]

View File

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

View File

@@ -3,6 +3,7 @@
## CRUD Resources ## CRUD Resources
- **`/api/battles`** GET | POST | GET/:id → Battle - **`/api/battles`** GET | POST | GET/:id → Battle
- **`/api/plans`** GET | POST | GET/:id | PATCH/:id → Plan
- **`/api/runs`** GET | POST | GET/:id → Run - **`/api/runs`** GET | POST | GET/:id → Run
- **`/api/tasks`** GET | POST | GET/:id → Task - **`/api/tasks`** GET | POST | GET/:id → Task
- **`/api/chats/:id/messages`** GET | POST | GET/:id | DELETE/:id → Message - **`/api/chats/:id/messages`** GET | POST | GET/:id | DELETE/:id → Message
@@ -14,11 +15,16 @@
### fastify ### fastify
- `GET` `/api/term/health` params() - `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/start` params(sid, pid) [auth]
- `POST` `/api/term/sessions/:sid/panes/:pid/kill` 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` `/ws/term/sessions/:sid/panes/:pid` params(sid, pid) [auth]
- `GET` `/api/health` params() [auth, db, queue, ai] - `GET` `/api/health` params() [auth, db, queue, ai]
- `GET` `/api/sessions/:sessionId/agent-sessions` params(sessionId) [auth, db] - `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/generate-prompt` params() [auth, db]
- `POST` `/api/battles/:id/stop` params(id) [auth, db] - `POST` `/api/battles/:id/stop` params(id) [auth, db]
- `GET` `/api/battles/:id/analysis` 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/apply` params(id) [auth, db, queue]
- `POST` `/api/pending/:id/reject` 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] - `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/snapshot` params() [db, cache]
- `GET` `/api/providers/config` params() [db, cache] - `GET` `/api/providers/config` params() [db, cache]
- `PATCH` `/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/sessions/:sessionId` params(sessionId) [auth, db]
- `GET` `/api/ws/user` params() [auth, db] - `GET` `/api/ws/user` params() [auth, db]
- `GET` `/api/projects/:id/agents` params(id) [db, cache] - `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] - `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/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/projects/:project_id/artifacts/:filename` params(project_id, filename) [auth, db]
- `GET` `/api/sessions/:id/chats` params(id) [auth, db] - `GET` `/api/sessions/:id/chats` params(id) [auth, db, queue]
- `POST` `/api/sessions/:id/chats` params(id) [auth, db] - `POST` `/api/sessions/:id/chats` params(id) [auth, db, queue]
- `PATCH` `/api/chats/:id` params(id) [auth, db] - `PATCH` `/api/chats/:id` params(id) [auth, db, queue]
- `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db] - `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db, queue]
- `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db] - `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/archive` params(id) [auth, db] - `POST` `/api/chats/:id/archive` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/unarchive` params(id) [auth, db] - `POST` `/api/chats/:id/unarchive` params(id) [auth, db, queue]
- `DELETE` `/api/chats/:id` params(id) [auth, db] - `DELETE` `/api/chats/:id` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/fork` params(id) [auth, db] - `POST` `/api/chats/:id/fork` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/discard_stale` params(id) [auth, db] - `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] - `GET` `/api/coder/ws/sessions/:sessionId` params(sessionId) [auth]
- `ALL` `/api/coder/*` params() [auth] - `ALL` `/api/coder/*` params() [auth]
- `GET` `/api/settings/inference` params() [cache] - `GET` `/api/settings/inference` params() [cache]
@@ -83,7 +93,9 @@
- `POST` `/api/chats/:id/continue` params(id) [auth, db, queue] - `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/force_send` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/grant_read_access` 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/create` params() [auth, db]
- `POST` `/api/projects/:id/archive` params(id) [auth, db] - `POST` `/api/projects/:id/archive` params(id) [auth, db]
- `POST` `/api/projects/:id/unarchive` params(id) [auth, db] - `POST` `/api/projects/:id/unarchive` params(id) [auth, db]
@@ -111,6 +123,7 @@
- `GET` `/api/skills` params() [auth, db, queue] - `GET` `/api/skills` params() [auth, db, queue]
- `POST` `/api/chats/:id/skill_invoke` params(id) [auth, db, queue] - `POST` `/api/chats/:id/skill_invoke` params(id) [auth, db, queue]
- `GET` `/api/tools/cost_stats` params() [auth, db] - `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] - `GET` `/api/ws/sessions/:id` params(id) [auth, db]
### go-net-http ### go-net-http

View File

@@ -118,6 +118,25 @@
- model: text (required) - model: text (required)
- verdict: text - 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 ### projects
- id: uuid (pk) - id: uuid (pk)
- name: text (required) - name: text (required)
@@ -139,6 +158,8 @@
- content: text (required) - content: text (required)
- status: text (required) - status: text (required)
- last_seq: integer (required) - last_seq: integer (required)
- cache_tokens: integer
- reasoning_tokens: integer
### message_parts ### message_parts
- id: uuid (pk) - id: uuid (pk)
@@ -155,3 +176,42 @@
- session_id: uuid (required, fk) - session_id: uuid (required, fk)
- name: text - name: text
- status: text (required) - 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 !data/coder-providers.example.json
codecontext/fork.tar.gz codecontext/fork.tar.gz
/Arena /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 ## Capabilities
@@ -9,6 +9,9 @@
- `ask_user_input` (interactive option chips) - `ask_user_input` (interactive option chips)
- Opt-in per chat: `web_search`, `web_fetch` (SearXNG-backed, SSRF-guarded) - 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 ## You cannot
- Write, edit, or delete files - 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. 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 ## 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. - 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. 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 - Push to git remotes
- Access the internet except via configured MCP servers - 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 ## 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. 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. 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 ## 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. 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 # 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. 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). **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. 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 ### Data flow for chat
1. User sends message → POST `/api/sessions/:id/messages` creates user + assistant (status=streaming) rows 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(), DATABASE_URL: z.string().url(),
LOG_LEVEL: z.string().default('info'), LOG_LEVEL: z.string().default('info'),
TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'), 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>; type Config = z.infer<typeof ConfigSchema>;

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import * as registry from './registry.js';
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/; const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
@@ -162,3 +163,36 @@ export async function capturePane(
if (res.code !== 0) return ''; if (res.code !== 0) return '';
return res.stdout.replace(/(?:\r?\n)+$/, ''); 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; sessionId: string;
projectPath: string; projectPath: string;
title?: string; title?: string;
description?: string;
parentAgent?: string;
createdAt: Date; createdAt: Date;
lastActivityAt: Date; lastActivityAt: Date;
timeoutSeconds?: number;
idleExpiresAt?: Date;
absoluteExpiresAt?: Date;
} }
const sessions = new Map<string, SessionMeta>(); const sessions = new Map<string, SessionMeta>();
export interface RegisterOpts {
timeoutSeconds?: number;
absoluteTimeoutSeconds?: number;
description?: string;
parentAgent?: string;
}
export function register( export function register(
sessionId: string, sessionId: string,
paneId: string, paneId: string,
projectPath: string, projectPath: string,
title?: string, title?: string,
opts?: RegisterOpts,
): void { ): void {
const now = new Date(); const now = new Date();
const existing = sessions.get(paneId); const existing = sessions.get(paneId);
@@ -21,18 +34,42 @@ export function register(
existing.lastActivityAt = now; existing.lastActivityAt = now;
return; 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, { sessions.set(paneId, {
paneId, paneId,
sessionId, sessionId,
projectPath, projectPath,
title, title,
description: opts?.description,
parentAgent: opts?.parentAgent,
createdAt: now, createdAt: now,
lastActivityAt: now, lastActivityAt: now,
timeoutSeconds: opts?.timeoutSeconds,
idleExpiresAt,
absoluteExpiresAt,
}); });
} }
export function unregister(paneId: string): void { export function unregister(paneId: string): void {
sessions.delete(paneId); 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[] { export function list(): SessionMeta[] {
@@ -42,3 +79,162 @@ export function list(): SessionMeta[] {
export function get(paneId: string): SessionMeta | undefined { export function get(paneId: string): SessionMeta | undefined {
return sessions.get(paneId); 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, sessionId: s.sessionId,
projectPath: s.projectPath, projectPath: s.projectPath,
title: s.title ?? null, title: s.title ?? null,
description: s.description ?? null,
parentAgent: s.parentAgent ?? null,
createdAt: s.createdAt.toISOString(), createdAt: s.createdAt.toISOString(),
lastActivityAt: s.lastActivityAt.toISOString(), lastActivityAt: s.lastActivityAt.toISOString(),
})), })),

View File

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

View File

@@ -9,9 +9,14 @@ import {
} from '../pty/manager.js'; } from '../pty/manager.js';
import { attachPty } from '../pty/pty.js'; import { attachPty } from '../pty/pty.js';
import { getUser } from '../auth.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<{ app.get<{
Params: { sid: string; pid: string }; Params: { sid: string; pid: string };
Querystring: { cols?: string; rows?: string }; Querystring: { cols?: string; rows?: string };
@@ -58,7 +63,25 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
return; 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; let handle: IPty;
try { try {
@@ -106,6 +129,10 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
} catch (err) { } catch (err) {
req.log.warn({ err }, 'ws send failed'); 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); handle.onData(onData);

View File

@@ -36,12 +36,44 @@ export interface StepContext {
* Falls back to a default in render functions when absent. * Falls back to a default in render functions when absent.
*/ */
readonly model?: string; 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'; 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 { export interface Step {
/** unique id within the flow; other steps depend on it by this id */ /** unique id within the flow; other steps depend on it by this id */
id: string; id: string;
@@ -55,10 +87,25 @@ export interface Step {
/** /**
* For kind:'agent', returns the worker PROMPT (task + any prior outputs). * For kind:'agent', returns the worker PROMPT (task + any prior outputs).
* For kind:'code', returns the step RESULT directly (the fold/transform). * 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>; run: (ctx: StepContext) => string | Promise<string>;
/** optional guard — when it returns false the step is skipped (e.g. no repo) */ /** optional guard — when it returns false the step is skipped (e.g. no repo) */
when?: (ctx: StepContext) => boolean; 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 { export interface Flow {
@@ -69,6 +116,8 @@ export interface Flow {
render: (ctx: StepContext) => string; render: (ctx: StepContext) => string;
/** optional output filename for the artifact, derived from input */ /** optional output filename for the artifact, derived from input */
output?: (ctx: StepContext) => string; 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 { 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), ORPHAN_WORKTREE_GRACE_MS: z.coerce.number().int().positive().default(3_600_000),
DEEPSEEK_API_KEY: z.string().optional(), DEEPSEEK_API_KEY: z.string().optional(),
DEEPSEEK_BASE_URL: z.string().url().default('https://api.deepseek.com'), 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>; 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). -- replaces it with the three-value list).
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_backend_chk; ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_backend_chk;
ALTER TABLE agent_sessions ADD CONSTRAINT 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, -- 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 -- 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 -- 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. -- 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). -- 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; ALTER TABLE flow_runs DROP CONSTRAINT IF EXISTS flow_runs_status_chk;
DO $$ BEGIN DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'flow_runs_status_chk') THEN 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 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 IF;
END $$; END $$;
@@ -352,10 +353,14 @@ ALTER TABLE flow_steps DROP CONSTRAINT IF EXISTS flow_steps_status_chk;
DO $$ BEGIN DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'flow_steps_status_chk') THEN 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 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 IF;
END $$; 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. -- Arena: battles + contestants + cross_examinations.
-- project_id carries no FK (matches tasks.project_id + flow_runs.project_id convention). -- 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. -- 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 { describe, it, expect } from 'vitest';
import type { Flow, Step, StepContext } from '../../conductor/types.js'; import type { Flow, Step, StepContext } from '../../conductor/types.js';
import { import {
buildBatchState,
getReadyInBatch,
manifestSteps, manifestSteps,
readySteps,
partitionReady, partitionReady,
readySteps,
isRunComplete, isRunComplete,
isStuck, isStuck,
reconcileResumeStep, reconcileResumeStep,
reconcileRun, reconcileRun,
resolveSwitch,
shouldFailOnMissingAgent, shouldFailOnMissingAgent,
type SchedulerState, type SchedulerState,
} from '../flow-runner-decisions.js'; } 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 * 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(), skipped: new Set(),
inFlight: new Set(), inFlight: new Set(),
excluded: new Set(), excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
...over, ...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) ───────────────────────────────────────────── // ─── Resume reconciliation (D-9) ─────────────────────────────────────────────
describe('reconcileResumeStep', () => { 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'; import type { AgentCommand } from './provider-types.js';
/** Backend transport kind. Mirrors `agent_sessions.backend` CHECK in schema.sql. */ /** 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). * 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>; readonly inFlight: ReadonlySet<string>;
/** step ids pre-skipped at launch (band/when gating) — never given a row */ /** step ids pre-skipped at launch (band/when gating) — never given a row */
readonly excluded: ReadonlySet<string>; 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 { 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. * Faithful to `conductor/flow.ts:27-36`. Pure.
*/ */
export function readySteps(flow: Flow, state: SchedulerState): Step[] { export function readySteps(flow: Flow, state: SchedulerState): Step[] {
const effectiveExcluded = getEffectiveExcluded(state);
return flow.steps.filter( return flow.steps.filter(
(s) => (s) =>
!state.done.has(s.id) && !state.done.has(s.id) &&
!state.skipped.has(s.id) && !state.skipped.has(s.id) &&
!state.inFlight.has(s.id) && !state.inFlight.has(s.id) &&
!state.excluded.has(s.id) && !effectiveExcluded.has(s.id) &&
((s.deps ?? []).length === 0 || evaluateTriggerRule(s.deps ?? [], state.done, state.skipped, state.excluded, s.trigger_rule)), ((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) ───────────────────────────────────────────── // ─── 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 * - 'mark-cancelled': task was cancelled before the callback ran; propagate so
* advance() cancels the run. * 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 = export type ResumeAction =
| 'keep' | 'keep'
| 're-dispatch' | 're-dispatch'
| 'mark-done' | 'mark-done'
| 'mark-failed' | 'mark-failed'
| 'mark-cancelled'; | 'mark-cancelled'
| 'retry';
/** /**
* Decide what to do with ONE flow step during startup resume (D-9). Pure. * Decide what to do with ONE flow step during startup resume (D-9). Pure.
* *
* @param status - flow_steps.status * @param status - flow_steps.status
* @param taskId - flow_steps.task_id (null for code steps or unstarted agent steps) * @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 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( export function reconcileResumeStep(
status: string, status: string,
taskId: string | null, taskId: string | null,
taskState: string | null, taskState: string | null,
retryCount?: number,
maxRetries?: number | null,
): ResumeAction { ): ResumeAction {
if (status === 'timed_out') {
if (shouldRetry(maxRetries, retryCount ?? 0)) return 'retry';
return 'mark-failed';
}
if (status !== 'running') return 'keep'; if (status !== 'running') return 'keep';
// Running step: decide by its task's current state. // Running step: decide by its task's current state.
if (!taskId || taskState === null) return 're-dispatch'; // task gone or never created 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'; 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. * Evaluate a trigger rule against dependency results.
* - all_success: every dep must be done (not skipped/failed) * - all_success: every dep must be done (not skipped/failed)
@@ -198,7 +370,7 @@ export function evaluateTriggerRule(
* decision per step. Pure — no IO. * decision per step. Pure — no IO.
*/ */
export function reconcileRun( 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>, taskStates: ReadonlyMap<string, string>,
): StepResumeDecision[] { ): StepResumeDecision[] {
return steps.map((step) => ({ return steps.map((step) => ({
@@ -207,6 +379,22 @@ export function reconcileRun(
step.status, step.status,
step.taskId, step.taskId,
step.taskId ? (taskStates.get(step.taskId) ?? null) : null, 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.) * already emits. (Phase 8 wires the OrchestratorPane's subscription to both.)
*/ */
import type { Sql } from '../db.js'; 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 { WsFrame } from '@boocode/contracts/ws-frames';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import type { Config } from '../config.js'; 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 { loadPersona } from '../conductor/persona-loader.js';
import type { Band, DispatchFn, Flow, FlowInput, Step, StepContext } from '../conductor/types.js'; import type { Band, DispatchFn, Flow, FlowInput, Step, StepContext } from '../conductor/types.js';
import { import {
buildBatchState,
getReadyInBatch,
isLoopTerminated,
isRunComplete, isRunComplete,
manifestSteps, manifestSteps,
partitionReady, partitionReady,
readySteps, readySteps,
reconcileRun, reconcileRun,
resolveSwitch,
type SchedulerState, type SchedulerState,
type StepResumeDecision, type StepResumeDecision,
} from './flow-runner-decisions.js'; } from './flow-runner-decisions.js';
@@ -95,11 +99,14 @@ interface Deps {
interface FlowStepRow { interface FlowStepRow {
step_id: string; step_id: string;
kind: 'agent' | 'code'; kind: 'agent' | 'code' | 'switch' | 'do_while';
agent: string | null; agent: string | null;
status: string; status: string;
chat_id: string | null; chat_id: string | null;
output: string | null; output: string | null;
updated_at: string | null;
retry_count: number | null;
max_retries: number | null;
} }
export function createFlowRunner(deps: Deps): FlowRunner { 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 // taskId → resolver map. These tasks have NO flow_steps row; handleTaskTerminal
// resolves them here instead of advancing a run. // resolves them here instead of advancing a run.
const subDispatchWaiters = new Map<string, (output: string) => void>(); 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 { function publishUser(frame: Record<string, unknown>): void {
broker.publishUserFrame('default', frame as unknown as WsFrame); broker.publishUserFrame('default', frame as unknown as WsFrame);
@@ -128,8 +139,42 @@ export function createFlowRunner(deps: Deps): FlowRunner {
results: Record<string, string>, results: Record<string, string>,
model: string, model: string,
dispatch?: DispatchFn, dispatch?: DispatchFn,
runId?: string,
stepId?: string,
): StepContext { ): 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 /** 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 dispatch: DispatchFn = (agent, task) => dispatchSubAgent(run.project_id, model, agent, task);
const rows = await sql<FlowStepRow[]>` 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 — // 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 done = new Set<string>();
const skipped = new Set<string>(); const skipped = new Set<string>();
const inFlight = 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> = {}; const results: Record<string, string> = {};
for (const r of rows) { for (const r of rows) {
switch (r.status) { switch (r.status) {
@@ -288,6 +337,9 @@ export function createFlowRunner(deps: Deps): FlowRunner {
case 'running': case 'running':
inFlight.add(r.step_id); inFlight.add(r.step_id);
break; break;
case 'timed_out':
timedOut.add(r.step_id);
break;
case 'failed': case 'failed':
// A failed worker makes the deterministic report untrustworthy — fail the // A failed worker makes the deterministic report untrustworthy — fail the
// whole run (matches the Phase-1 CLI, which throws on a dispatch failure). // 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, // Drain ready skips + code steps (synchronous), re-evaluating after each batch,
// then dispatch the full ready agent wave and wait for their terminal callbacks. // then dispatch the full ready agent wave and wait for their terminal callbacks.
for (;;) { 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)) { if (isRunComplete(flow, state)) {
await finishRun(runId, flow, input, results, model, dispatch); await finishRun(runId, flow, input, results, model, dispatch);
return; return;
} }
const ready = readySteps(flow, state); const ready = getReadyInBatch(readySteps(flow, state), state, flow);
if (ready.length === 0) { 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'); await failRun(runId, flow, input, model, 'unsatisfiable dependencies / cycle');
return; return;
} }
@@ -329,6 +482,74 @@ export function createFlowRunner(deps: Deps): FlowRunner {
continue; // re-evaluate — a skip can settle a fan-in step's deps 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'); const codeReady = toRun.filter((s) => s.kind === 'code');
if (codeReady.length > 0) { if (codeReady.length > 0) {
for (const s of codeReady) { for (const s of codeReady) {
@@ -336,7 +557,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
try { try {
// Code steps run IN-PROCESS (fold / synthesis-fold / code-review verify). // Code steps run IN-PROCESS (fold / synthesis-fold / code-review verify).
// verify uses ctx.dispatch → dispatchSubAgent (read-only qwen workers). // 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) { } catch (err) {
await failRun(runId, flow, input, model, `code step '${s.id}' threw: ${errMsg(err)}`, s.id); await failRun(runId, flow, input, model, `code step '${s.id}' threw: ${errMsg(err)}`, s.id);
return; return;
@@ -459,6 +680,14 @@ export function createFlowRunner(deps: Deps): FlowRunner {
await appendStepEvent(sql, runId, stepId, status, output ? { outputLength: output.length } : undefined); 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 ───────────────────────────────────────────────────────── // ─── run completion ─────────────────────────────────────────────────────────
async function finishRun( 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() UPDATE flow_runs SET status = 'completed', report = ${report}, updated_at = clock_timestamp()
WHERE id = ${runId} AND status = 'running' 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'); deps.onRunTerminal?.(runId, 'completed');
publishStep(runId, lastAgentStepId(flow, input, model), 'completed', { publishStep(runId, lastAgentStepId(flow, input, model), 'completed', {
run_status: 'completed', run_status: 'completed',
report, report,
}); });
cleanupMessaging(runId);
} }
async function failRun( async function failRun(
@@ -506,6 +739,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
log.warn({ runId, error }, 'flow-runner: run failed'); log.warn({ runId, error }, 'flow-runner: run failed');
await appendStepEvent(sql, runId, stepId, 'failed', { error }); await appendStepEvent(sql, runId, stepId, 'failed', { error });
publishStep(runId, stepId, 'failed', { run_status: 'failed' }); publishStep(runId, stepId, 'failed', { run_status: 'failed' });
cleanupMessaging(runId);
} }
async function cancelRun(runId: string): Promise<void> { async function cancelRun(runId: string): Promise<void> {
@@ -533,6 +767,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
} }
} }
log.info({ runId }, 'flow-runner: run cancelled'); log.info({ runId }, 'flow-runner: run cancelled');
cleanupMessaging(runId);
} }
/** The terminal agent step in roster order — a valid roster step_id to carry the /** 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( function publishStep(
runId: string, runId: string,
stepId: 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 }, extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string },
): void { ): void {
publishUser({ 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'); log.info({ runId, stepId: step.step_id, taskId: task!.id }, 'flow-runner: step re-dispatched on resume');
break; 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; status: string;
chat_id: string | null; chat_id: string | null;
input: 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. // 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); 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( 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, taskStates,
); );
@@ -752,13 +1027,13 @@ export function createFlowRunner(deps: Deps): FlowRunner {
// Mark all non-terminal steps cancelled and collect in-flight task_ids. // 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 }[]>` const steps = await sql<{ step_id: string; task_id: string | null; kind: string }[]>`
SELECT step_id, task_id, kind FROM flow_steps 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) { if (steps.length > 0) {
await sql` await sql`
UPDATE flow_steps SET status = 'cancelled', updated_at = clock_timestamp() 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) { for (const s of steps) {
if (s.kind === 'agent') publishStep(runId, s.step_id, 'cancelled', { run_status: 'cancelled' }); 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); .map((s) => s.task_id);
log.info({ runId }, 'flow-runner: run cancelled by request'); log.info({ runId }, 'flow-runner: run cancelled by request');
cleanupMessaging(runId);
return { cancelled: true, taskIds }; 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 type { Sql } from '../db.js';
import { resolveWritePath } from './write_guard.js'; import { resolveWritePath } from './write_guard.js';
import { locateMatch } from './fuzzy-match.js'; import { locateMatch } from './fuzzy-match.js';
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 * 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}) VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent})
RETURNING * RETURNING *
`; `;
// Register in the conflict index so concurrent worktrees see this edit.
conflictIndex.registerChange(resolved, sessionId, agent ?? 'unknown');
return row!; return row!;
} }
@@ -216,6 +222,9 @@ export async function queueCreate(
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent}) VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent})
RETURNING * RETURNING *
`; `;
conflictIndex.registerChange(resolved, sessionId, agent ?? 'unknown');
return row!; return row!;
} }
@@ -238,6 +247,9 @@ export async function queueDelete(
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent}) VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent})
RETURNING * RETURNING *
`; `;
conflictIndex.registerChange(resolved, sessionId, agent ?? 'unknown');
return row!; return row!;
} }
@@ -260,6 +272,23 @@ export async function applyOne(
// Re-validate path in case projectRoot has shifted // Re-validate path in case projectRoot has shifted
resolveWritePath(projectRoot, change.file_path); 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) { switch (change.operation) {
case 'create': { case 'create': {
await mkdir(dirname(change.file_path), { recursive: true }); 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/`. > 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 ## Stack
- **Fastify** with `@fastify/websocket` and `@fastify/static` (serves the built frontend). - **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 { registerTraceRoutes } from './routes/traces.js';
import { registerToolsRoutes } from './routes/tools.js'; import { registerToolsRoutes } from './routes/tools.js';
import { registerAnalyticsRoutes } from './routes/analytics.js'; import { registerAnalyticsRoutes } from './routes/analytics.js';
import { registerMemoryRoutes } from './routes/memory.js';
import { registerInferenceSettingsRoutes } from './routes/inference-settings.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 { createBroker } from './services/broker.js';
import { setBackgroundInferenceEnqueuer } from './services/background-task.js';
import { listSkills } from './services/skills.js'; import { listSkills } from './services/skills.js';
import * as compaction from './services/compaction.js'; import * as compaction from './services/compaction.js';
import { configureModelContext } from './services/model-context.js'; import { configureModelContext } from './services/model-context.js';
@@ -125,11 +126,37 @@ async function main() {
registerModelRoutes(app, config); registerModelRoutes(app, config);
registerAgentRoutes(app, sql); registerAgentRoutes(app, sql);
registerSidebarRoutes(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); registerTraceRoutes(app, sql);
registerToolsRoutes(app, sql); registerToolsRoutes(app, sql);
registerAnalyticsRoutes(app, sql); registerAnalyticsRoutes(app, sql);
registerMemoryRoutes(app, sql);
registerInferenceSettingsRoutes(app); registerInferenceSettingsRoutes(app);
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or // 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); 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, { registerMessageRoutes(app, sql, config, broker, {
enqueueInference: (sessionId, chatId, assistantId, user) => { enqueueInference: (sessionId, chatId, assistantId, user) => {
inference.enqueue(sessionId, chatId, assistantId, user); inference.enqueue(sessionId, chatId, assistantId, user);

View File

@@ -1,18 +1,33 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import crypto from 'node:crypto';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js'; import type { Broker } from '../services/broker.js';
import type { Chat, Message } from '../types/api.js'; import type { Chat, Message } from '../types/api.js';
import { getModelContext } from '../services/model-context.js'; import { getModelContext } from '../services/model-context.js';
import { notifyCoderClose } from '../services/coder-notify.js'; import { notifyCoderClose } from '../services/coder-notify.js';
import { MESSAGE_COLUMNS } from '../services/message-columns.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({ const CreateBody = z.object({
name: z.string().min(1).max(200).optional(), name: z.string().min(1).max(200).optional(),
}); });
const PatchBody = z.object({ 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({ const ForkBody = z.object({
@@ -26,10 +41,17 @@ const DiscardStaleBody = z.object({
const STALE_MIN_AGE_SECONDS = 60; 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( export function registerChatRoutes(
app: FastifyInstance, app: FastifyInstance,
sql: Sql, sql: Sql,
broker: Broker broker: Broker,
config?: Config,
compareHandlers?: CompareHandlers,
): void { ): void {
app.get<{ Params: { id: string }; Querystring: { status?: string } }>( app.get<{ Params: { id: string }; Querystring: { status?: string } }>(
'/api/sessions/:id/chats', '/api/sessions/:id/chats',
@@ -122,12 +144,15 @@ export function registerChatRoutes(
reply.code(400); reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() }; 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[]>` const rows = await sql<Chat[]>`
UPDATE chats UPDATE chats
SET name = ${parsed.data.name}, SET ${(sql as any).join(sets, sql`, `)}
updated_at = clock_timestamp()
WHERE id = ${req.params.id} 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) { if (rows.length === 0) {
reply.code(404); reply.code(404);
@@ -448,4 +473,128 @@ export function registerChatRoutes(
return rows; 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 { Sql } from '../db.js';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import type { Broker } from '../services/broker.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 // 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 // decision time (not at request time) so concurrent project changes don't
// stale-bind the resolution. // stale-bind the resolution.
import { resolveGrantRoot } from '../services/grant_resolver.js'; import { resolveGrantRoot } from '../services/grant_resolver.js';
import { MESSAGE_COLUMNS } from '../services/message-columns.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 // Shared lookup for the answer_user_input + grant_read_access pause-resume
// endpoints. Finds the originating assistant tool_call by id in message_parts, // 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 status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Session[]>` 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 FROM sessions
WHERE project_id = ${req.params.id} AND status = ${status} WHERE project_id = ${req.params.id} AND status = ${status}
ORDER BY updated_at DESC ORDER BY updated_at DESC
@@ -213,7 +213,7 @@ export function registerSessionRoutes(
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => { app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
const rows = await sql<Session[]>` 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} FROM sessions WHERE id = ${req.params.id}
`; `;
if (rows.length === 0) { if (rows.length === 0) {
@@ -349,10 +349,10 @@ export function registerSessionRoutes(
const rows = await sql<Session[]>` const rows = await sql<Session[]>`
UPDATE sessions UPDATE sessions
SET workspace_panes = ${sql.json(envelope as never)}, SET workspace_panes = ${sql.json(envelope as never)},
updated_at = clock_timestamp() updated_at = clock_timestamp()
WHERE id = ${req.params.id} WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, 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) { if (rows.length === 0) {
reply.code(404); 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'; ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
-- v1.2: chats table -- v1.2: chats table
-- per-chat-model-switching v2.x: ALTER below adds the model override column.
CREATE TABLE IF NOT EXISTS chats ( CREATE TABLE IF NOT EXISTS chats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, 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); 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 -- 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 chat_id UUID REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'message'; ALTER TABLE messages ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'message';
@@ -320,6 +324,9 @@ BEGIN
END IF; END IF;
END $$; 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 -- 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. -- 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". -- 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_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 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; 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; ALTER TABLE sessions DROP COLUMN IF EXISTS tags;
-- v1.11: anchored rolling compaction. -- 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', type: 'message_complete',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId, chat_id: chatId,
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
}); });
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled'); ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
} else { } else {
@@ -90,6 +91,7 @@ export async function handleAbortOrError(
chat_id: chatId, chat_id: chatId,
error: errMsg, error: errMsg,
reason: 'llm_provider_error', reason: 'llm_provider_error',
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
}); });
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed'); ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
} }
@@ -125,6 +127,7 @@ export async function finalizeStreamedRow(
cacheTokens?: number | null; cacheTokens?: number | null;
reasoningTokens?: number | null; reasoningTokens?: number | null;
beforeComplete?: () => Promise<void>; beforeComplete?: () => Promise<void>;
compareGroupId?: string;
}, },
): Promise<void> { ): Promise<void> {
// v1.11.3: see executeToolPhase for the rationale. // v1.11.3: see executeToolPhase for the rationale.
@@ -158,6 +161,7 @@ export async function finalizeStreamedRow(
started_at: opts.startedAt, started_at: opts.startedAt,
finished_at: updated?.finished_at ?? null, finished_at: updated?.finished_at ?? null,
model: opts.model, model: opts.model,
...(opts.compareGroupId ? { compare_group_id: opts.compareGroupId } : {}),
}); });
} }
@@ -182,6 +186,7 @@ export async function finalizeEmpty(
type: 'message_complete', type: 'message_complete',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId, chat_id: chatId,
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
}); });
} }
@@ -281,6 +286,7 @@ export async function finalizeCompletion(
started_at: startedAt, started_at: startedAt,
finished_at: updated?.finished_at ?? null, finished_at: updated?.finished_at ?? null,
model: session.model, model: session.model,
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
}); });
ctx.log.info( ctx.log.info(
{ {

View File

@@ -8,6 +8,7 @@ export {
createInferenceRunner, createInferenceRunner,
MAX_STEPS, MAX_STEPS,
runInference, runInference,
runInferenceWithModel,
} from './turn.js'; } from './turn.js';
// P5: the shared pipeline types moved from turn.ts to types.ts (breaking the // 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. // 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 type { ToolPhaseResult } from './tool-phase.js';
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js'; export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
export { buildMessagesPayload } from './payload.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); out.push(msg);
continue; 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 }); out.push({ role: 'user', content: m.content });
} }
return out; return out;
@@ -206,7 +214,7 @@ export async function loadContext(
): Promise<{ session: Session; project: Project; history: Message[] } | null> { ): Promise<{ session: Session; project: Project; history: Message[] } | null> {
const sessionRows = await sql<Session[]>` const sessionRows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, 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} FROM sessions WHERE id = ${sessionId}
`; `;
if (sessionRows.length === 0) return null; 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, message_id: assistantMessageId,
chat_id: chatId, chat_id: chatId,
role: 'assistant', role: 'assistant',
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
}); });
const flusher = createContentFlusher(ctx.sql, assistantMessageId, () => state.accumulated); const flusher = createContentFlusher(ctx.sql, assistantMessageId, () => state.accumulated);
@@ -119,6 +120,7 @@ export async function executeStreamPhase(
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId, chat_id: chatId,
content: delta, content: delta,
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
}); });
ctx.log.debug({ sessionId, delta }, 'inference delta'); ctx.log.debug({ sessionId, delta }, 'inference delta');
flusher.scheduleFlush(); 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 { resolveGrantRoot } from '../grant_resolver.js';
import { stripToolMarkup } from './tool-call-parser.js'; import { stripToolMarkup } from './tool-call-parser.js';
import { repairToolInput } from './tool-input-repair.js'; import { repairToolInput } from './tool-input-repair.js';
import { diffFromToolArgs, isWriteTool } from './compute-diff.js';
import type { FailureKind } from './mistake-tracker.js'; import type { FailureKind } from './mistake-tracker.js';
import { insertToolTrace, updateToolTrace } from '../tool-traces.js'; import { insertToolTrace, updateToolTrace } from '../tool-traces.js';
import type { import type {
@@ -445,6 +446,16 @@ export async function executeToolPhase(
if (SYNTHESIS_TOOLS.has(tc.name)) { if (SYNTHESIS_TOOLS.has(tc.name)) {
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) }); 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 = { const stored = {
tool_call_id: tc.id, tool_call_id: tc.id,
output: tres.output, output: tres.output,
@@ -467,6 +478,7 @@ export async function executeToolPhase(
output: tres.output, output: tres.output,
truncated: tres.truncated, truncated: tres.truncated,
...(tres.error ? { error: tres.error } : {}), ...(tres.error ? { error: tres.error } : {}),
...(toolDiff ? { diff: toolDiff } : {}),
}); });
}) })
); );

View File

@@ -8,7 +8,7 @@ import type {
import { resolveProjectRoot } from '../path_guard.js'; import { resolveProjectRoot } from '../path_guard.js';
import { maybeAutoNameChat } from '../auto_name.js'; import { maybeAutoNameChat } from '../auto_name.js';
import { rewriteSearchQuery } from '../task-search-rewrite.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 * as compaction from '../compaction.js';
import { resolveTurnConfig } from './turn-config.js'; import { resolveTurnConfig } from './turn-config.js';
import { decideStep, decidePostToolAction } from './step-decision.js'; import { decideStep, decidePostToolAction } from './step-decision.js';
@@ -49,6 +49,8 @@ import {
runStepCapSummary, runStepCapSummary,
insertMistakeRecoverySentinel, insertMistakeRecoverySentinel,
} from './sentinel-summaries.js'; } 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 // vWhale: auto-fix — detect build command from package.json, run it, return
// error text for injection into next iteration. Best-effort, never throws. // 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'); ctx.log.warn({ sessionId }, 'inference: session or project missing');
return; return;
} }
const { session, project } = initialLoaded; let { session, project, history: initialHistory } = initialLoaded;
const agent = session.agent_id if (args.modelOverride) {
session = { ...session, model: args.modelOverride };
}
let agent = session.agent_id
? await getAgentById(project.path, session.agent_id) ? await getAgentById(project.path, session.agent_id)
: null; : 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). // P5: pure per-turn config (budget + cap math + text-only flag).
const { effectiveCap, budget, isTextOnly } = resolveTurnConfig(agent); const { effectiveCap, budget, isTextOnly } = resolveTurnConfig(agent);
@@ -162,7 +193,8 @@ export async function runAssistantTurn(
if (isTextOnly) { if (isTextOnly) {
const loaded = await loadContext(ctx.sql, sessionId, chatId); const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) { 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; return;
} }
@@ -178,228 +210,244 @@ export async function runAssistantTurn(
const mistakeTracker = args.mistakeTracker; const mistakeTracker = args.mistakeTracker;
let pendingRecoveryNote: string | undefined = args.pendingRecoveryNote; let pendingRecoveryNote: string | undefined = args.pendingRecoveryNote;
while (stepNumber < effectiveCap) { if (session.state_graph_enabled) {
// ---- top-of-loop gate: doom-loop, then budget (pure decision) ---- // ---- optional state graph path ----
const decision = decideStep({ recentToolCalls, toolsUsed, budget }); const gProjectRoot = await resolveProjectRoot(project.path);
if (decision.kind === 'doom') { const graphResult = await runGraph(ctx, args, { effectiveCap, budget, agent, projectRoot: gProjectRoot });
// Need fresh history for the summary. stepNumber = graphResult.stepNumber;
const loaded = await loadContext(ctx.sql, sessionId, chatId); toolsUsed = graphResult.toolsUsed;
if (loaded) { recentToolCalls = graphResult.recentToolCalls;
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal }; assistantMessageId = graphResult.assistantMessageId;
await runDoomLoopSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, decision.loop); // 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 (decision.kind === 'budget') { if (loaded) {
const loaded = await loadContext(ctx.sql, sessionId, chatId); const bhSession = args.modelOverride ? { ...loaded.session, model: args.modelOverride } : loaded.session;
if (loaded) { const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal }; await runCapHitSummary(ctx, iterArgs, bhSession, loaded.project, loaded.history, agent, budget);
await runCapHitSummary(ctx, iterArgs, loaded.session, 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 ---- // ---- compaction check ----
// v1.11: if the prior turn flagged this chat for compaction, run it // v1.11: if the prior turn flagged this chat for compaction, run it
// before loadContext so we read post-compaction history. Swallow // before loadContext so we read post-compaction history. Swallow
// failures and proceed with un-compacted history. // failures and proceed with un-compacted history.
const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>` const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>`
SELECT needs_compaction FROM chats WHERE id = ${chatId} SELECT needs_compaction FROM chats WHERE id = ${chatId}
`; `;
if (chatFlag[0]?.needs_compaction) { if (chatFlag[0]?.needs_compaction) {
try { try {
await compaction.process({ await compaction.process({
sql: ctx.sql, sql: ctx.sql,
config: ctx.config, config: ctx.config,
log: ctx.log, log: ctx.log,
broker: ctx.broker, broker: ctx.broker,
chatId, chatId,
hooks: ctx.hooks, hooks: ctx.hooks,
}); });
} catch (err) { } catch (err) {
ctx.log.warn({ err, chatId }, 'auto-compaction failed; clearing flag and proceeding'); ctx.log.warn({ err, chatId }, 'auto-compaction failed; clearing flag and proceeding');
await ctx.sql`UPDATE chats SET needs_compaction = false WHERE id = ${chatId}`; 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}"`;
} }
} }
}
// v#12 MistakeTracker: if the prior iteration's nudge fired, append the // ---- load context (must re-load each iteration — new messages since last step) ----
// transient recovery note to THIS payload (consumed exactly once, then const loaded = await loadContext(ctx.sql, sessionId, chatId);
// cleared). Never persisted — same lifecycle as the cap-hit/doom-loop if (!loaded) {
// summary notes, which live only inside the in-memory messages array. ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
if (pendingRecoveryNote) { break;
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(() => {});
} }
await finalizeCompletion(ctx, iterArgs, result, state.startedAt, iterSession); let { session: iterSession, project: iterProject, history } = loaded;
break; if (args.modelOverride) {
} iterSession = { ...iterSession, model: args.modelOverride };
}
const projectRoot = await resolveProjectRoot(iterProject.path);
// ---- steps: 0 edge case ---- try {
// effectiveCap check above guarantees we're inside the loop, but this const dcpMsgs = toDcpMessages(history);
// guard handles the theoretical case where the model emits tool calls const { messages: pruned, stats } = transformMessages(chatId, dcpMsgs);
// on step 0 when effectiveCap would have been 0 (impossible since the if (stats.removedCount > 0) {
// while condition prevents entry, but kept for safety). If effectiveCap ctx.log.info({ chatId, ...stats }, 'dcp: transform removed messages');
// is 1 and we're on step 0, tool calls ARE executed — steps counts history = fromDcpMessages(pruned) as typeof history;
// iterations, not post-first-stream. }
} catch (err) {
ctx.log.warn({ err: err instanceof Error ? err.message : String(err), chatId }, 'dcp: transform skipped');
}
// ---- tool phase ---- // v1.14.0: log step boundary for instrumentation. step_start parts are in
let toolPhaseResult: ToolPhaseResult; // the schema CHECK but not emitted here — writing to the assistant message
try { // before the stream phase creates a sequence-0 collision with
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent, stepNumber); // partsFromAssistantMessage. A WS frame or structured log is sufficient
} catch (err) { // since the frontend doesn't render step boundaries in v1.14.
// Tool phase errors are unexpected (individual tool failures are ctx.log.info({ sessionId, chatId, step: stepNumber, assistantMessageId }, 'step_start');
// caught inside executeToolPhase). Log and break.
ctx.log.error({ err, sessionId, chatId, step: stepNumber }, 'tool phase threw unexpectedly');
break;
}
// ---- update loop locals ---- // ---- build messages + stream phase ----
toolsUsed += toolPhaseResult.toolCallCount; const messages = await buildMessagesPayload(iterSession, iterProject, history, agent, ctx.log);
recentToolCalls = [...recentToolCalls, ...toolPhaseResult.toolCalls]; const webToolsEnabled =
stepNumber++; iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
// v#12 MistakeTracker: fold this iteration's tool outcomes into the if (stepNumber === 0 && webToolsEnabled && messages.length >= 2) {
// tracker, in order. recordStep mutates `mistakeTracker` in place (it is const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
// the same object referenced by args). A 'success' clears the streak. if (lastUserMsg?.content) {
for (const o of toolPhaseResult.outcomes) { const hint = await rewriteSearchQuery(lastUserMsg.content);
recordStep(mistakeTracker, o); 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. // v#12 MistakeTracker: if the prior iteration's nudge fired, append the
const WRITE_TOOLS = new Set(['edit_file', 'create_file', 'delete_file', 'apply_pending']); // transient recovery note to THIS payload (consumed exactly once, then
const hasWriteTools = toolPhaseResult.toolCalls.some((tc) => WRITE_TOOLS.has(tc.name)); // cleared). Never persisted — same lifecycle as the cap-hit/doom-loop
if (hasWriteTools) { // summary notes, which live only inside the in-memory messages array.
detectAndRunBuild(ctx, projectRoot, sessionId, chatId, iterSession.model, pendingRecoveryNote) if (pendingRecoveryNote) {
.then((buildError) => { messages.push({ role: 'system', content: pendingRecoveryNote });
if (buildError) pendingRecoveryNote = buildError; pendingRecoveryNote = undefined;
}) }
.catch(() => {});
}
// v#12 MistakeTracker: post-tool decision (pure). 'stop' = the tool phase const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
// returned a non-'continue' action ('paused' for user input, or const state: StreamPhaseState = { accumulated: '', startedAt: null };
// 'synthesis_done') — neither a nudge nor an escalate would change the let result: StreamResult;
// control flow, so the mistake check is skipped. On 'continue' the try {
// heterogeneous-failure pattern gates nudge/escalate/continue. Complements result = await executeStreamPhase(ctx, iterArgs, iterSession, messages, state, agent, webToolsEnabled);
// the doom-loop gate above, which only catches *identical* repeats. } catch (err) {
const post = decidePostToolAction(toolPhaseResult.action, mistakeTracker); await handleAbortOrError(ctx, iterArgs, state.accumulated, err);
if (post === 'stop') { break;
break; }
}
if (post === 'nudge') { // ---- non-tool finish → finalize and exit ----
// Soft intervention: inject model-facing recovery guidance into the NEXT if (result.toolCalls.length === 0) {
// step's payload, drop a UI sentinel, bump nudges, reset the streak, and // vWhale: Stop hook (best-effort, non-blocking).
// continue. The note is consumed (and cleared) at the top of the next if (ctx.hooks) {
// iteration's payload build. ctx.hooks.run('Stop', {
pendingRecoveryNote = MISTAKE_RECOVERY_NOTE; event: 'Stop',
const failureKinds = [...mistakeTracker.run]; session_id: sessionId,
await insertMistakeRecoverySentinel(ctx, sessionId, chatId, { chat_id: chatId,
failureKinds, last_assistant_text: result.content.slice(0, 500),
count: failureKinds.length, turn: stepNumber,
escalated: false, }).catch(() => {});
canContinue: true, }
}); await finalizeCompletion(ctx, iterArgs, result, state.startedAt, iterSession);
mistakeTracker.nudges += 1; break;
mistakeTracker.run = []; }
ctx.log.info(
{ sessionId, chatId, step: stepNumber, nudges: mistakeTracker.nudges, failureKinds }, // ---- steps: 0 edge case ----
'mistake_recovery nudge', // 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!; 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). // vWhale: Stop hook at post-loop exit (best-effort, non-blocking).
@@ -438,8 +486,9 @@ export async function runAssistantTurn(
if (stepNumber >= effectiveCap && effectiveCap < Infinity) { if (stepNumber >= effectiveCap && effectiveCap < Infinity) {
const loaded = await loadContext(ctx.sql, sessionId, chatId); const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) { if (loaded) {
const scSession = args.modelOverride ? { ...loaded.session, model: args.modelOverride } : loaded.session;
const capArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal }; 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 // 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 // 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, // short wrap-up reply with the synthetic note prepended and tools disabled,

View File

@@ -52,7 +52,9 @@ export interface InferenceFrame {
// arena frames // arena frames
| 'battle_started' | 'battle_started'
| 'contestant_updated' | 'contestant_updated'
| 'battle_updated'; | 'battle_updated'
// inter-agent message
| 'agent_message';
message_id?: string; message_id?: string;
message_ids?: string[]; message_ids?: string[];
chat_id?: string; chat_id?: string;
@@ -103,6 +105,11 @@ export interface InferenceFrame {
status?: string; status?: string;
run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
report?: string; 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 // arena frames
battle_id?: string; battle_id?: string;
battle_type?: 'coding' | 'qa'; battle_type?: 'coding' | 'qa';
@@ -177,5 +184,10 @@ export interface TurnArgs {
// Never persisted — mirrors how the cap-hit/doom-loop notes live only inside // Never persisted — mirrors how the cap-hit/doom-loop notes live only inside
// the summary call's messages array. // the summary call's messages array.
pendingRecoveryNote?: string; 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; signal: AbortSignal | undefined;
} }

View File

@@ -148,6 +148,19 @@ export function getServerPermission(prefixedToolName: string): McpPermission {
return state?.permission ?? 'allow'; 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. */ /** Return all wrapped ToolDefs from all connected servers, flattened. */
export function getTools(): ToolDef<Record<string, unknown>>[] { export function getTools(): ToolDef<Record<string, unknown>>[] {
const all: 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 // vWhale: command execution tool. Spawns processes in the project worktree
// with timeout and output cap. No shell — args are passed as array. // with timeout and output cap. No shell — args are passed as array.
import { runCommand } from './execute-command.js'; 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 // 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 // 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, // Read-write; use with guard: restricted to project root via path_guard,
// no shell injection (execFile, not exec). // no shell injection (execFile, not exec).
runCommand as ToolDef<unknown>, 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)); ].sort((a, b) => a.name.localeCompare(b.name));
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries( 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 // path_guard's extraRoots check consults this list before refusing reads
// outside the primary project root. // outside the primary project root.
allowed_read_paths: string[]; 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. // v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added.
@@ -153,6 +156,7 @@ export interface Chat {
id: string; id: string;
session_id: string; session_id: string;
name: string | null; name: string | null;
model: string | null;
status: ChatStatus; status: ChatStatus;
created_at: string; created_at: string;
updated_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/`. > 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", "@xterm/xterm": "5.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.40.0",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
"react-virtuoso": "^4.18.7",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"shiki": "^1.29.2", "shiki": "^1.29.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -39,10 +41,12 @@
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"jsdom": "^29.1.1",
"shadcn": "^4.7.0", "shadcn": "^4.7.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vite": "^5.3.4" "vite": "^5.3.4",
"vitest": "^3.2.4"
}, },
"license": "MIT" "license": "MIT"
} }

View File

@@ -275,7 +275,7 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify(body ?? {}), body: JSON.stringify(body ?? {}),
}), }),
update: (chatId: string, body: { name: string }) => update: (chatId: string, body: { name?: string; model?: string }) =>
request<Chat>(`/api/chats/${chatId}`, { request<Chat>(`/api/chats/${chatId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -331,6 +331,17 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }), 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 // 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 // 'allow' the server re-resolves the grant root and appends it to
// sessions.allowed_read_paths; the returned list reflects the post- // sessions.allowed_read_paths; the returned list reflects the post-
@@ -348,6 +359,14 @@ export const api = {
request<ToolTraceResponse>( request<ToolTraceResponse>(
`/api/chats/${chatId}/traces?limit=${limit}&offset=${offset}`, `/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: { messages: {
@@ -388,6 +407,11 @@ export const api = {
request<{ html_content: string; char_count: number; title: string }>( request<{ html_content: string; char_count: number; title: string }>(
`/api/chats/${chatId}/messages/${messageId}/html_artifact`, `/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'), models: () => request<ModelInfo[]>('/api/models'),
@@ -654,17 +678,27 @@ export const api = {
// cols/rows are optional. When passed, booterm sizes the per-pane tmux // 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 // 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. // born with the correct PTY dimensions instead of tmux's 80x24 default.
start: (sessionId: string, paneId: string, cols?: number, rows?: number) => start: (
request<{ tmux_session: string }>( 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`, `/api/term/sessions/${sessionId}/panes/${paneId}/start`,
{ {
method: 'POST', method: 'POST',
body: body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,
cols !== undefined && rows !== undefined
? JSON.stringify({ cols, rows })
: undefined,
}, },
), );
},
kill: (sessionId: string, paneId: string) => kill: (sessionId: string, paneId: string) =>
request<{ ok: true }>( request<{ ok: true }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/kill`, `/api/term/sessions/${sessionId}/panes/${paneId}/kill`,

View File

@@ -101,6 +101,7 @@ export interface Chat {
id: string; id: string;
session_id: string; session_id: string;
name: string | null; name: string | null;
model: string | null;
status: ChatStatus; status: ChatStatus;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -131,6 +132,10 @@ export interface ToolResult {
output: unknown; output: unknown;
truncated: boolean; truncated: boolean;
error?: string; 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 // 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 // (CoderPane/CoderMessageList) and streams it live via reasoning_delta
// frames. MessageBubble reads whichever of the two is present. // frames. MessageBubble reads whichever of the two is present.
reasoning_text?: string | null; 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 // v1.11: anchored rolling compaction fields. Optional on the wire so that
// older API responses (or test fixtures) parse without explicit nulls. // older API responses (or test fixtures) parse without explicit nulls.
// summary — true on the assistant row that holds the active // summary — true on the assistant row that holds the active
@@ -513,8 +522,8 @@ export interface WorkspaceState {
export type WsFrame = export type WsFrame =
| { type: 'snapshot'; messages: Message[] } | { type: 'snapshot'; messages: Message[] }
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole } | { 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 } | { 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_call'; message_id: string; chat_id?: string; tool_call: ToolCall }
| { | {
type: 'tool_result'; type: 'tool_result';
@@ -524,6 +533,7 @@ export type WsFrame =
output: unknown; output: unknown;
truncated: boolean; truncated: boolean;
error?: string; error?: string;
diff?: string;
} }
| { | {
type: 'message_complete'; type: 'message_complete';
@@ -547,6 +557,7 @@ export type WsFrame =
// 'cancelled' on a user Stop / stall and 'failed' on a thrown error so the // '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. // reducer renders a muted "Stopped" / failed state — no new frame type.
status?: 'complete' | 'cancelled' | 'failed'; status?: 'complete' | 'cancelled' | 'failed';
compare_group_id?: string;
} }
// v1.12.2: live throughput frame, published mid-stream every ~500ms with // v1.12.2: live throughput frame, published mid-stream every ~500ms with
// the latest token + ctx counts so ChatThroughput can render tok/s and // 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 } | { type: 'compacted'; session_id: string; chat_id: string; summary_message_id: string }
// v1.8.2: `reason` discriminates structured failures (the UI prefers it // v1.8.2: `reason` discriminates structured failures (the UI prefers it
// over `error` text when present). // 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) // agent-status-normalize (#10): BooCoder publishes a normalized per-(chat,agent)
// lifecycle status for external coding agents on the per-session channel. The // 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 // 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'; run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
report?: string; 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 // tool trace frames: per-tool-call lifecycle tracking
| { | {
type: 'tool_trace_start'; type: 'tool_trace_start';
@@ -674,11 +693,13 @@ export type WsFrame =
message_id?: string; message_id?: string;
chat_id?: string; chat_id?: string;
content?: string; content?: string;
compare_group_id?: string;
tool_call?: ToolCall; tool_call?: ToolCall;
tool_message_id?: string; tool_message_id?: string;
tool_call_id?: string; tool_call_id?: string;
output?: unknown; output?: unknown;
truncated?: boolean; truncated?: boolean;
diff?: string;
error?: string; error?: string;
reason?: string; reason?: string;
status?: 'running' | 'complete' | 'cancelled' | 'failed'; status?: 'running' | 'complete' | 'cancelled' | 'failed';

View File

@@ -90,7 +90,7 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="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'} title={selectedAgent?.name ? `Agent: ${selectedAgent.name}` : 'No agent'}
aria-label={`Agent: ${triggerLabel}`} aria-label={`Agent: ${triggerLabel}`}
> >

View File

@@ -20,7 +20,7 @@ export function AttachmentChip({ attachment, onRemove, onPreview }: Props) {
<button <button
type="button" type="button"
onClick={() => onPreview(attachment)} 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" /> <FileText className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate max-w-[200px]">{label}</span> <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" aria-modal="true"
className={cn( className={cn(
'fixed inset-x-0 bottom-0 z-50 rounded-t-2xl border-t border-border bg-popover text-popover-foreground shadow-2xl', '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', 'max-h-[70vh] flex flex-col',
)} )}
style={{ 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} disabled={disabled || busy || attachments.length >= MAX_ATTACHMENTS}
aria-label="Attach file" aria-label="Attach file"
title="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" /> <Paperclip className="size-3.5" />
</button> </button>
@@ -707,7 +707,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
aria-expanded={cmdMenuOpen} aria-expanded={cmdMenuOpen}
aria-label="Slash commands" aria-label="Slash commands"
title="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" /> <SquareSlash className="size-3.5" />
<span className="max-md:hidden">{slashItems.length}</span> <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 })} onClick={() => sessionEvents.emit({ type: 'open_flow_launcher', project_id: projectId })}
aria-label="Flow launcher" aria-label="Flow launcher"
title="Open 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" /> <Workflow className="size-3.5" />
<span className="max-md:hidden">Flows</span> <span className="max-md:hidden">Flows</span>
@@ -739,7 +739,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
}} }}
aria-pressed={webSearchEnabled === true} aria-pressed={webSearchEnabled === true}
title="Web search & fetch" 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 webSearchEnabled === true
? 'border-primary/40 bg-primary/10 text-primary' ? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground' : 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, Copy } from 'lucide-react'; import { Check, Copy, Moon, Sun, WrapText } from 'lucide-react';
import { codeToHtml } from 'shiki'; import { codeToHtml } from 'shiki';
// NOTE: spec calls for syntax-highlighted code blocks. Added Shiki in v1.1. // 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', 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) { export function CodeBlock({ code, lang }: Props) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [html, setHtml] = useState<string | null>(null); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const mappedLang = (lang && LANG_MAP[lang.toLowerCase()]) ?? null;
if (!mappedLang) { if (!mappedLang) {
setHtml(null); setHtml(null);
return; return;
} }
const cacheKey = `${cleanCode}|${theme}|${mappedLang}`;
const cached = cacheGet(cacheKey);
if (cached !== undefined) {
setHtml(cached);
return;
}
(async () => { (async () => {
try { 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); if (!cancelled) setHtml(result);
} catch (err) { } catch (err) {
console.warn('shiki failed', err); console.warn('shiki highlight failed:', err);
if (!cancelled) setHtml(null); if (!cancelled) setHtml(null);
} }
})(); })();
return () => { return () => {
cancelled = true; 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(() => { useEffect(() => {
if (highlightRef.current) { if (highlightRef.current) {
// Shiki generates sanitized HTML spans — not user-supplied content. // Shiki generates sanitized HTML spans — not user-supplied content.
@@ -82,39 +158,138 @@ export function CodeBlock({ code, lang }: Props) {
} }
}, [html]); }, [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 { try {
await navigator.clipboard.writeText(code); await navigator.clipboard.writeText(cleanCode);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1200); setTimeout(() => setCopied(false), 1200);
} catch { } catch {
/* ignore */ /* 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 ( return (
<div className="rounded-md border bg-muted/40 overflow-hidden text-sm my-1"> <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"> <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 <button
type="button" type="button"
onClick={() => void copy()} onClick={() => setExpanded(true)}
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground" className="w-full text-xs text-muted-foreground hover:text-foreground py-1 border-t border-border/30 bg-muted/20"
aria-label="Copy code"
> >
{copied ? <Check className="size-3" /> : <Copy className="size-3" />} Show {totalLines - 15} more {totalLines - 15 === 1 ? 'line' : 'lines'}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button> </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> </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} cy={CENTER}
r={RADIUS} r={RADIUS}
fill="none" fill="none"
className={cn('transition-all duration-300', progressClass)} className={cn('transition-all duration-200 motion-reduce:transition-none', progressClass)}
strokeWidth={STROKE} strokeWidth={STROKE}
strokeLinecap="round" strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE} 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" role="switch"
aria-checked={checked} aria-checked={checked}
onClick={() => onCheckedChange(!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' 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' checked ? 'translate-x-[1.125rem]' : 'translate-x-0.5'
}`} /> }`} />
</button> </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 type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { CodeBlock } from './CodeBlock'; import { CodeBlock } from './CodeBlock';
import { MessageBoundary } from './MessageBoundary';
import { linkifyPaths } from '@/lib/linkify-paths'; import { linkifyPaths } from '@/lib/linkify-paths';
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode { 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 langMatch = /language-([\w-]+)/.exec(className ?? '');
const isBlock = !!langMatch || text.includes('\n'); const isBlock = !!langMatch || text.includes('\n');
if (isBlock) { 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 ( return (
<code <code
@@ -102,8 +107,10 @@ const MARKDOWN_COMPONENTS: Components = {
export const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) { export const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) {
return ( return (
<Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}> <MessageBoundary>
{content} <Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
</Markdown> {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 ( 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 <button
type="button" type="button"
onClick={() => void copy()} onClick={() => void copy()}

View File

@@ -274,7 +274,7 @@ export function MessageList({ messages, sessionChats }: Props) {
chatId={item.chatId} chatId={item.chatId}
/> />
) : ( ) : (
<ToolCallLine run={item.run} /> <ToolCallLine run={item.run} chatId={item.chatId} />
) )
) : ( ) : (
<ToolCallGroup runs={item.runs} /> <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'; import { formatModelLabel } from '@/lib/model-label';
interface Props { interface Props {
value: string; value: string | null;
onChange: (model: string) => void | Promise<void>; onChange: (model: string) => void | Promise<void>;
} }
@@ -27,7 +27,7 @@ function ModelList({
}: { }: {
models: ModelInfo[] | null; models: ModelInfo[] | null;
error: string | null; error: string | null;
value: string; value: string | null;
onPick: (id: string) => void; onPick: (id: string) => void;
}) { }) {
if (error) { if (error) {
@@ -82,8 +82,8 @@ export function ModelPicker({ value, onChange }: Props) {
<button <button
type="button" type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label={`Model: ${value}`} aria-label={`Model: ${value ?? 'default'}`}
title={value} title={value ?? undefined}
className="inline-flex items-center justify-center min-h-[36px] min-w-[44px] rounded text-muted-foreground hover:text-foreground" className="inline-flex items-center justify-center min-h-[36px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
> >
<Cpu className="size-4" /> <Cpu className="size-4" />
@@ -104,7 +104,7 @@ export function ModelPicker({ value, onChange }: Props) {
type="button" 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" 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" /> <ChevronDown className="size-3 opacity-70" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

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

View File

@@ -273,7 +273,7 @@ export function ProjectSidebar() {
const asideCls = isMobile const asideCls = isMobile
? cn( ? cn(
'fixed inset-y-0 left-0 z-40 w-60 border-r bg-sidebar text-sidebar-foreground flex flex-col', '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', drawerOpen ? 'translate-x-0' : '-translate-x-full',
) )
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen'; : '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" className="flex items-center justify-center text-[10px] uppercase tracking-wide text-muted-foreground border-b overflow-hidden shrink-0"
style={{ style={{
height: pull.refreshing ? 32 : Math.min(pull.pullDist, 80), 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" aria-live="polite"
> >

View File

@@ -282,7 +282,7 @@ export function RightRail({ projectId, sessionId }: Props) {
const asideCls = isMobile const asideCls = isMobile
? cn( ? cn(
'fixed inset-y-0 right-0 z-40 w-[85vw] max-w-sm border-l bg-sidebar flex flex-col overflow-hidden', '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', drawerOpen ? 'translate-x-0' : 'translate-x-full',
) )
: 'w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden'; : '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 { Archive, ChevronLeft, Code, MessageSquare, RotateCcw, Terminal, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import mascot from '@/assets/brand/banner-mascot.png'; import mascot from '@/assets/brand/banner-mascot.png';
import { EmptyState } from '@/components/EmptyState';
import { ChatInput } from '@/components/ChatInput'; import { ChatInput } from '@/components/ChatInput';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -167,9 +168,11 @@ export function SessionLandingPage({
<h2 className="text-sm font-medium ml-auto mr-1">Session history</h2> <h2 className="text-sm font-medium ml-auto mr-1">Session history</h2>
</div> </div>
{isEmpty ? ( {isEmpty ? (
<p className="text-sm text-muted-foreground text-center py-8"> <EmptyState
No conversations yet. Send a message to start. icon={<MessageSquare size={40} strokeWidth={1.5} />}
</p> title="No conversations"
description="Your chat history will appear here"
/>
) : (<> ) : (<>
{openChats.length > 0 && ( {openChats.length > 0 && (
<> <>
@@ -200,7 +203,7 @@ export function SessionLandingPage({
{formatRelative(c.updated_at)} {formatRelative(c.updated_at)}
</span> </span>
</button> </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 <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); void onArchiveChat(c.id); }} onClick={(e) => { e.stopPropagation(); void onArchiveChat(c.id); }}
@@ -254,7 +257,7 @@ export function SessionLandingPage({
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); setDeleteConfirm({ id: c.id, name: c.name }); }} 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" aria-label="Delete chat"
title="Delete" title="Delete"
> >

View File

@@ -132,13 +132,13 @@ export function ThemePicker() {
onClick={() => setAnimBg(!animOn)} onClick={() => setAnimBg(!animOn)}
className={cn( className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent', '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', animOn ? 'bg-primary' : 'bg-input',
)} )}
> >
<span <span
className={cn( 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', 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"> <div className="rounded border border-border/60 bg-muted/20 text-xs">
<button <button
type="button" type="button"
tabIndex={0}
onClick={() => setOpen((v) => !v)} 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 <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="text-muted-foreground/60 select-none shrink-0"></span>
<span className="font-mono text-foreground/90"> <span className="font-mono text-foreground/90">

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