Compare commits

...

3 Commits

Author SHA1 Message Date
792bbb9da3 v2.3.0-sampling-params-ask-user: agent sampling params, ask_user_input in CoderPane, UX polish
Add top_p/top_k/min_p/presence_penalty to AGENTS.md frontmatter and thread
through inference (agents.ts parser → Agent type → stream-phase → sentinel
summaries). Null means omit from request body, preserving provider defaults.

Wire ask_user_input interactive card into both BooCoder frontends: the
CoderPane in BooChat's SPA (CoderMessageList now renders AskUserInputCard
instead of ToolCallLine for ask_user_input tool calls) and the standalone
coder SPA (MessageBubble + new AskUserInputCard + shadcn ui primitives).

Additional fixes: SessionLandingPage uses ChatInput with slash-command
support and lazy chat creation; Session.tsx hydrate-race fix for empty pane
promotion; AgentPicker wider dropdown with line-clamp; ModelPicker min-width;
Textarea converted to forwardRef; Recon agent added to AGENTS.md; codecontext
host port exposed in docker-compose.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 21:02:21 +00:00
31e1b32be1 v2.2.2-xml-placeholder-reject: drop placeholder XML tool calls at parse time
Reject qwen3.6 spurious <invoke> tails with path "..." or empty args before
they enter toolCalls, preventing duplicate assistant answers. Dropped blocks
append to flushed text; four new xml-parser tests. DEFERRED-WORK §6 for
console.debug → pino cleanup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 16:22:43 +00:00
314adaae48 docs: reconcile roadmap, README, and deferred work for v2.2 ship state
Mark v2.2/v2.2.1 shipped and v2.3 planned in roadmap and README; fix
DEFERRED-WORK §2 (ACP probe skip is planned, not resolved).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 15:27:16 +00:00
28 changed files with 925 additions and 114 deletions

View File

@@ -2,6 +2,10 @@
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.2.2-xml-placeholder-reject — 2026-05-26
Reject placeholder XML tool args at parse time in `extractToolCallBlocks` (`xml-parser.ts`). Drops calls when any string arg is `...`, empty/whitespace, `<path>`, `<file>`, `placeholder`, or angle-bracket sentinels; appends the raw XML block to flushed prose instead of silently deleting it. Fixes qwen3.6 answer-then-spurious-tools tail that caused duplicate assistant rows (full answer + failed `xml_call_*` tools + regenerated answer). Four new tests in `xml-parser.test.ts`. Known nit: rejection logs via `console.debug` instead of pino — filed in `docs/DEFERRED-WORK.md` §6 for a later cleanup.
## v2.2.1-pane-scoped-chats — 2026-05-26
Follow-up fixes on the v2.2 Paseo provider stack. Pane-scoped chat resolution: `resolveChatId(sql, sessionId, paneId)` reads `sessions.workspace_panes`, requires `pane_id` on coder POST routes, and creates a scoped chat per coder/terminal pane instead of falling back to the session's first open chat (which fused BooCoder writes into the BooChat pane). Client `useWorkspacePanes` seeds new coder/terminal panes with dedicated chats on create, hydrate, and workspace sync; `CoderPane` blocks send until seeded and filters WS frames + `GET /messages?chat_id=` to that chat. External-agent tool UI: new `CoderMessageList` renders BooChat-style `ToolCallLine` timeline (tools before answer text on combined ACP rows). WS user-delta handling replaces content instead of appending (fixes garbled duplicate user messages when optimistic UI met full-body deltas). BooChat inference: `buildMessagesPayload` strips orphan assistant `tool_calls` without matching `tool` rows and skips stray tool rows when the owning assistant turn is incomplete (fixes "Tool results are missing for tool calls" on shared chats with ACP history). Pairs with `v2.2-paseo-providers`.

View File

@@ -5,6 +5,6 @@ Last updated: 2026-05-26
- **Batch:** v2.3-provider-lifecycle (openspec drafted; not started)
- **Branch:** `main`
- **Blockers:** none
- **Last shipped:** `v2.2.1-pane-scoped-chats` (pairs with `v2.2-paseo-providers` on same commit)
- **Last shipped:** `v2.2.2-xml-placeholder-reject`
Update this file when starting or finishing a batch. Agents: read this first for session intent; if stale vs `CHANGELOG.md`, trust CHANGELOG for shipped state.

View File

@@ -2,6 +2,8 @@
Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals).
**Latest release:** `v2.2.1-pane-scoped-chats` (2026-05-26) · [`CHANGELOG.md`](CHANGELOG.md) · **Current focus:** [`CURRENT.md`](CURRENT.md)
**Agent navigation:** [`AGENTS.md`](AGENTS.md) · **Architecture:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · **Engineering reference:** [`CLAUDE.md`](CLAUDE.md)
## Stack
@@ -53,6 +55,14 @@ docker compose up --build -d
Binds to `100.114.205.53:9500` (Tailscale). Authelia is expected to gate the
upstream and inject `Remote-User`. Postgres binds loopback only.
BooCoder runs as a **host systemd service** (`boocoder.service`, port `:9502`), not in Docker:
```bash
pnpm -C apps/server build && pnpm -C apps/coder build
sudo systemctl restart boocoder
curl http://100.114.205.53:9502/api/health
```
## Services
|Service|Port|Description|
@@ -65,6 +75,12 @@ upstream and inject `Remote-User`. Postgres binds loopback only.
## What's shipped
- **BooChat**: streaming chat, file-read tools, compaction, reasoning support, HTML/Markdown artifact panes, cross-repo read grants, MCP client (Context7 + multi-server), tool-cost tracking, skills system, agent registry, provider picker with model discovery
See [`boocode_roadmap.md`](boocode_roadmap.md) for full version history. Highlights as of **v2.2.1**:
- **BooChat**: streaming chat, file-read tools, compaction, reasoning support, HTML/Markdown artifact panes, cross-repo read grants, MCP client (multi-server + stdio), tool-cost tracking, skills system, builtin agent registry, multi-pane workspace (chat / terminal / coder)
- **BooTerm**: in-browser terminal panes via tmux + xterm.js, per-session tmux sessions, SSH-out support
- **BooCoder**: write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`), pending-changes queue with diff UI, ACP/PTY dual-path agent dispatch, MCP server (6 tools, stdio), CLI client, human inbox, Boomerang orchestration, path-guard fuzz suite
- **BooCoder (v2.2)**: write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`), pending-changes queue with diff UI, Paseo-style provider snapshot (7 providers: boocode, cursor, claude, opencode, goose, qwen, copilot), `AgentComposerBar` (provider / mode / model / thinking), ACP dispatch with inline permission prompts + tool/reasoning streaming, PTY fallback, Arena, MCP server (6 tools, stdio), CLI client, human inbox, Boomerang orchestration, path-guard fuzz suite, **pane-scoped chats** (v2.2.1 — each coder/terminal pane owns its chat)
## Planned
- **v2.3 provider lifecycle** — config-backed provider registry (`/data/coder-providers.json`), enable/disable toggles, two-tier probe (openspec drafted). See [`CURRENT.md`](CURRENT.md).

View File

@@ -1,4 +1,4 @@
import type { Project, Session, Chat, Message, PendingChange } from './types';
import type { Project, Session, Chat, Message, PendingChange, AskUserAnswer } from './types';
export class ApiError extends Error {
constructor(
@@ -52,6 +52,14 @@ export const api = {
method: 'POST',
body: JSON.stringify(body ?? {}),
}),
answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) =>
request<{ tool_message_id: string; assistant_message_id: string }>(
`/api/chats/${chatId}/answer_user_input`,
{
method: 'POST',
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
},
),
},
messages: {

View File

@@ -32,16 +32,37 @@ export interface Chat {
export interface ToolCall {
id: string;
name: string;
arguments: string;
args: unknown;
}
export interface ToolResult {
tool_call_id: string;
output: string;
output: unknown;
truncated?: boolean;
error?: boolean;
}
// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the
// same order. AskUserInputCard renders questions and POSTs answers.
export type AskUserQuestionType = 'single_select' | 'multi_select';
export interface AskUserQuestion {
question: string;
type: AskUserQuestionType;
options: string[];
}
export interface AskUserAnswer {
question: string;
selected_options: string[];
free_text: string | null;
}
export interface AskUserAnswerSet {
answers: AskUserAnswer[];
}
export interface Message {
id: string;
session_id: string;

View File

@@ -0,0 +1,323 @@
import { useMemo, useState } from 'react';
import { Check } from 'lucide-react';
import { api } from '@/api/client';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Button } from '@/components/ui/button';
import type {
AskUserAnswer,
AskUserAnswerSet,
AskUserQuestion,
ToolCall,
ToolResult,
} from '@/api/types';
// Batch 9.7: Inline interactive picker. Renders inside MessageList in place of
// the standard ToolCallLine when the assistant emits an ask_user_input tool
// call. While the tool result is null (server pre-stamps a sentinel with
// output=null), shows the form; once the WS tool_result frame arrives with a
// real AnswerSet, flips to read-only review mode.
interface Props {
toolCall: ToolCall;
toolResult: ToolResult | null;
chatId: string;
}
function parseQuestions(raw: unknown): AskUserQuestion[] {
if (!raw || typeof raw !== 'object' || !('questions' in raw)) return [];
const arr = (raw as { questions: unknown }).questions;
if (!Array.isArray(arr)) return [];
const out: AskUserQuestion[] = [];
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const q = item as { question?: unknown; type?: unknown; options?: unknown };
if (typeof q.question !== 'string') continue;
if (q.type !== 'single_select' && q.type !== 'multi_select') continue;
if (!Array.isArray(q.options)) continue;
const opts = q.options.filter((o): o is string => typeof o === 'string');
if (opts.length < 2) continue;
out.push({ question: q.question, type: q.type, options: opts });
}
return out;
}
function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null;
const arr = (raw as { answers: unknown }).answers;
if (!Array.isArray(arr)) return null;
const answers: AskUserAnswer[] = [];
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown };
if (typeof a.question !== 'string') continue;
if (!Array.isArray(a.selected_options)) continue;
if (a.free_text !== null && typeof a.free_text !== 'string') continue;
const sel = a.selected_options.filter((s): s is string => typeof s === 'string');
answers.push({
question: a.question,
selected_options: sel,
free_text: (a.free_text as string | null) ?? null,
});
}
return { answers };
}
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
if (questions.length === 0) {
return (
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
ask_user_input: malformed tool args
</div>
);
}
// Tool result with a non-null output means the answer is already submitted.
// The pending sentinel uses output=null, so this branch only triggers after
// the real WS tool_result frame lands.
const answered = toolResult && toolResult.output !== null;
if (answered) {
const answerSet = parseAnswerSet(toolResult!.output);
return <AnsweredView questions={questions} answers={answerSet} />;
}
return (
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
);
}
function PendingView({
questions,
toolCallId,
chatId,
}: {
questions: AskUserQuestion[];
toolCallId: string;
chatId: string;
}) {
// Per-question selections + free text. Selections are option arrays so the
// multi_select case is uniform; single_select just constrains to length 1.
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
const [submitting, setSubmitting] = useState(false);
const singleQuestion = questions.length === 1;
const anyFreeText = freeTexts.some((t) => t.trim().length > 0);
// Submit button shows when:
// - more than one question (always batched), OR
// - one question and the user has typed free text (committing it needs an
// explicit Submit so an accidental Tab/click doesn't lose it).
// For one question with no free text, clicking an option submits inline.
const showSubmitButton = !singleQuestion || anyFreeText;
// Every question must have at least one of (option, free text).
const allComplete = questions.every((_, i) => {
return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0;
});
function buildAnswers(): AskUserAnswer[] {
return questions.map((q, i) => {
const freeText = freeTexts[i]!.trim();
return {
question: q.question,
selected_options: selections[i]!,
free_text: freeText.length > 0 ? freeText : null,
};
});
}
async function submit(answers: AskUserAnswer[]) {
if (submitting) return;
setSubmitting(true);
try {
await api.chats.answerUserInput(chatId, toolCallId, answers);
// Card stays mounted; the incoming WS tool_result frame will flip it
// into AnsweredView via the parent prop change.
} catch (err) {
console.error('ask_user_input submit failed:', err instanceof Error ? err.message : err);
setSubmitting(false);
}
}
function pickSingle(qIdx: number, option: string) {
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
// Immediate submit for the single-question single-select shortcut. Only
// fires when no free text exists anywhere — once the user typed, the
// Submit button takes over so the typed text isn't silently dropped.
if (singleQuestion && !anyFreeText) {
const answers: AskUserAnswer[] = [
{
question: questions[0]!.question,
selected_options: [option],
free_text: null,
},
];
void submit(answers);
}
}
function toggleMulti(qIdx: number, option: string) {
setSelections((prev) =>
prev.map((arr, i) => {
if (i !== qIdx) return arr;
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
}),
);
}
function setFreeText(qIdx: number, value: string) {
setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t)));
}
return (
<div className="rounded-lg border bg-muted/20 text-sm">
<div className="px-4 py-3 space-y-4">
{questions.map((q, i) => (
<div key={i} className="space-y-2">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
{q.type === 'single_select' ? (
<RadioGroup
value={selections[i]![0] ?? ''}
onValueChange={(v) => pickSingle(i, v)}
disabled={submitting}
className="gap-1.5"
>
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span>{opt}</span>
</label>
);
})}
</RadioGroup>
) : (
<div className="grid gap-1.5">
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
const checked = selections[i]!.includes(opt);
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<input
id={id}
type="checkbox"
checked={checked}
disabled={submitting}
onChange={() => toggleMulti(i, opt)}
className="mt-1 size-3.5 rounded border-input accent-primary"
/>
<span>{opt}</span>
</label>
);
})}
</div>
)}
<div className="pt-1 space-y-1">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Or type a custom answer
</div>
<input
type="text"
value={freeTexts[i]}
disabled={submitting}
placeholder="Free text…"
onChange={(e) => setFreeText(i, e.target.value)}
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
/>
</div>
</div>
))}
</div>
{showSubmitButton && (
<div className="flex justify-end gap-2 border-t px-4 py-2">
<Button
type="button"
size="sm"
disabled={!allComplete || submitting}
onClick={() => void submit(buildAnswers())}
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
)}
</div>
);
}
function AnsweredView({
questions,
answers,
}: {
questions: AskUserQuestion[];
answers: AskUserAnswerSet | null;
}) {
if (!answers) {
return (
<div className="rounded-lg border bg-muted/20 text-xs px-4 py-3 text-muted-foreground">
ask_user_input: answers unavailable
</div>
);
}
return (
<div className="rounded-lg border bg-muted/10 text-sm">
<div className="px-4 py-3 space-y-3">
{questions.map((q, i) => {
const a = answers.answers[i];
if (!a) return null;
return (
<div key={i} className="space-y-1.5">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
<div className="space-y-0.5">
{q.options.map((opt, j) => {
const selected = a.selected_options.includes(opt);
return (
<div
key={j}
className={
selected
? 'flex items-start gap-2 text-sm leading-snug text-foreground'
: 'flex items-start gap-2 text-sm leading-snug text-muted-foreground/60 line-through'
}
>
<span className="mt-0.5 size-3.5 shrink-0 inline-flex items-center justify-center">
{selected && <Check className="size-3 text-primary" />}
</span>
<span>{opt}</span>
</div>
);
})}
</div>
{a.free_text && (
<div className="rounded bg-background border px-2 py-1 text-xs font-mono whitespace-pre-wrap">
{a.free_text}
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Square } from 'lucide-react';
import type { Message } from '@/api/types';
import type { Message, ToolResult } from '@/api/types';
import { api } from '@/api/client';
import { MessageBubble } from './MessageBubble';
@@ -66,6 +66,14 @@ export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }
// Filter out system messages for display (sentinels)
const visibleMessages = messages.filter((m) => m.role !== 'system');
// Build a lookup map from tool_call_id -> ToolResult for all messages
const toolResultsMap: Record<string, ToolResult> = {};
for (const msg of messages) {
if (msg.tool_results) {
toolResultsMap[msg.tool_results.tool_call_id] = msg.tool_results;
}
}
return (
<div className="flex flex-col h-full">
{/* Connection indicator */}
@@ -88,7 +96,7 @@ export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }
</div>
)}
{visibleMessages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
<MessageBubble key={msg.id} message={msg} chatId={msg.chat_id} toolResultsMap={toolResultsMap} />
))}
<div ref={messagesEndRef} />
</div>

View File

@@ -1,13 +1,16 @@
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { Message } from '@/api/types';
import type { Message, ToolResult } from '@/api/types';
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
import { AskUserInputCard } from './AskUserInputCard';
interface Props {
message: Message;
chatId: string;
toolResultsMap: Record<string, ToolResult>;
}
export function MessageBubble({ message }: Props) {
export function MessageBubble({ message, chatId }: Props) {
if (message.role === 'tool') {
return <ToolResultBubble message={message} />;
}
@@ -34,18 +37,31 @@ export function MessageBubble({ message }: Props) {
{message.tool_calls && message.tool_calls.length > 0 && (
<div className="mb-2 space-y-1">
{message.tool_calls.map((tc) => (
<div
key={tc.id}
className="flex items-center gap-1.5 text-xs text-zinc-400 bg-zinc-900/50 rounded px-2 py-1"
>
<Wrench size={11} />
<span className="font-mono">{tc.name}</span>
<span className="text-zinc-500 truncate max-w-[200px]">
{truncateArgs(tc.arguments)}
</span>
</div>
))}
{message.tool_calls.map((tc) => {
if (tc.name === 'ask_user_input') {
const result = message.tool_results ?? null;
return (
<AskUserInputCard
key={tc.id}
toolCall={tc}
toolResult={result}
chatId={chatId}
/>
);
}
return (
<div
key={tc.id}
className="flex items-center gap-1.5 text-xs text-zinc-400 bg-zinc-900/50 rounded px-2 py-1"
>
<Wrench size={11} />
<span className="font-mono">{tc.name}</span>
<span className="text-zinc-500 truncate max-w-[200px]">
{truncateArgs(tc.args)}
</span>
</div>
);
})}
</div>
)}
@@ -70,12 +86,12 @@ export function MessageBubble({ message }: Props) {
);
}
function ToolResultBubble({ message }: Props) {
function ToolResultBubble({ message }: { message: Message }) {
const result = message.tool_results;
if (!result) return null;
const isError = result.error;
const output = result.output || '';
const output = result.output != null ? String(result.output) : '';
const displayOutput =
output.length > 300 ? output.slice(0, 300) + '...' : output;
@@ -99,17 +115,21 @@ function ToolResultBubble({ message }: Props) {
);
}
function truncateArgs(args: string): string {
function truncateArgs(args: unknown): string {
if (!args) return '';
try {
const parsed = JSON.parse(args);
const keys = Object.keys(parsed);
if (keys.length === 0) return '';
const first = keys[0]!;
const val = String(parsed[first]);
const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
return `${first}: ${display}`;
if (typeof args === 'object' && args !== null) {
const obj = args as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) return '';
const first = keys[0]!;
const val = String(obj[first] ?? '');
const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
return `${first}: ${display}`;
}
const str = String(args);
return str.length > 50 ? str.slice(0, 50) + '...' : str;
} catch {
return args.length > 50 ? args.slice(0, 50) + '...' : args;
return String(args).length > 50 ? String(args).slice(0, 50) + '...' : String(args);
}
}

View File

@@ -0,0 +1,35 @@
import * as React from 'react';
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
}
const variantClasses: Record<string, string> = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
};
const sizeClasses: Record<string, string> = {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
};
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
const base =
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-60';
const cls = [base, variantClasses[variant] ?? '', sizeClasses[size] ?? '', className ?? ''].join(' ');
return <button className={cls} ref={ref} {...props} />;
},
);
Button.displayName = 'Button';
export { Button };

View File

@@ -0,0 +1,56 @@
import * as React from 'react';
const RadioGroupContext = React.createContext<{
value: string | undefined;
onValueChange: (v: string) => void;
disabled?: boolean;
} | null>(null);
interface RadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {
value?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
}
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
({ className, value, onValueChange, disabled, ...props }, ref) => {
const ctx = React.useMemo(() => ({ value, onValueChange: onValueChange ?? (() => {}), disabled }), [value, onValueChange, disabled]);
return (
<RadioGroupContext.Provider value={ctx}>
<div
ref={ref}
role="radiogroup"
className={className}
{...props}
/>
</RadioGroupContext.Provider>
);
},
);
RadioGroup.displayName = 'RadioGroup';
interface RadioGroupItemProps extends React.InputHTMLAttributes<HTMLInputElement> {
value: string;
}
const RadioGroupItem = React.forwardRef<HTMLInputElement, RadioGroupItemProps>(
({ className, value, ...props }, ref) => {
const ctx = React.useContext(RadioGroupContext);
if (!ctx) return <input ref={ref} type="radio" className={className} value={value} {...props} />;
const checked = ctx.value === value;
return (
<input
ref={ref}
type="radio"
checked={checked}
disabled={ctx.disabled}
onChange={() => ctx.onValueChange(value)}
className={className}
{...props}
/>
);
},
);
RadioGroupItem.displayName = 'RadioGroupItem';
export { RadioGroup, RadioGroupItem };

View File

@@ -270,6 +270,44 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
expect(result.flushed).toBe(input);
expect(result.remaining).toBe('');
});
describe('placeholder arg rejection (qwen3.6 answer-then-spurious-tools)', () => {
it('rejects <invoke> with path "..." — 0 calls, block in flushed', () => {
const block = '<invoke name="view_file"><parameter name="path">...</parameter></invoke>';
const result = extractToolCallBlocks(`Answer text.\n${block}`);
expect(result.calls).toEqual([]);
expect(result.flushed).toContain('Answer text.');
expect(result.flushed).toContain(block);
expect(result.remaining).toBe('');
});
it('rejects <invoke> with empty path — 0 calls, block in flushed', () => {
const block = '<invoke name="view_file"><parameter name="path"></parameter></invoke>';
const result = extractToolCallBlocks(block);
expect(result.calls).toEqual([]);
expect(result.flushed).toBe(block);
expect(result.remaining).toBe('');
});
it('rejects <invoke> with path "<path>" — 0 calls', () => {
const block = '<invoke name="view_file"><parameter name="path"><path></parameter></invoke>';
const result = extractToolCallBlocks(block);
expect(result.calls).toEqual([]);
expect(result.flushed).toBe(block);
});
it('returns 1 valid call and flushes placeholder block when mixed in same buffer', () => {
const valid =
'<invoke name="view_file"><parameter name="path">/opt/boocode/README.md</parameter></invoke>';
const placeholder =
'<invoke name="view_file"><parameter name="path">...</parameter></invoke>';
const result = extractToolCallBlocks(`${valid} tail ${placeholder}`);
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/opt/boocode/README.md' } }]);
expect(result.flushed).toContain('tail');
expect(result.flushed).toContain(placeholder);
expect(result.remaining).toBe('');
});
});
});
describe('levenshtein', () => {

View File

@@ -83,6 +83,10 @@ export function slugify(name: string): string {
interface ParsedFrontmatter {
temperature?: number;
top_p?: number;
top_k?: number;
min_p?: number;
presence_penalty?: number;
tools?: string[];
description?: string;
model?: string;
@@ -132,6 +136,46 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
const n = Number(valueRaw);
if (Number.isFinite(n)) data.temperature = n;
else errors.push(`temperature must be a number (got "${valueRaw}")`);
} else if (key === 'top_p') {
const n = Number(valueRaw);
if (Number.isFinite(n)) {
data.top_p = n;
if (n < 0 || n > 1) {
console.warn(`agents: top_p ${n} out of range 0-1, ignoring (falling back to default)`);
}
} else {
errors.push(`top_p must be a number (got "${valueRaw}")`);
}
} else if (key === 'top_k') {
const n = Number(valueRaw);
if (Number.isInteger(n)) {
data.top_k = n;
if (n < 0 || n > 200) {
console.warn(`agents: top_k ${n} out of range 0-200, ignoring (falling back to default)`);
}
} else {
errors.push(`top_k must be an integer (got "${valueRaw}")`);
}
} else if (key === 'min_p') {
const n = Number(valueRaw);
if (Number.isFinite(n)) {
data.min_p = n;
if (n < 0 || n > 1) {
console.warn(`agents: min_p ${n} out of range 0-1, ignoring (falling back to default)`);
}
} else {
errors.push(`min_p must be a number (got "${valueRaw}")`);
}
} else if (key === 'presence_penalty') {
const n = Number(valueRaw);
if (Number.isFinite(n)) {
data.presence_penalty = n;
if (n < -2 || n > 2) {
console.warn(`agents: presence_penalty ${n} out of range -2-2, ignoring (falling back to default)`);
}
} else {
errors.push(`presence_penalty must be a number (got "${valueRaw}")`);
}
} else if (key === 'tools') {
if (valueRaw === '') {
data.tools = [];
@@ -276,6 +320,10 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
description: fm.description ?? '',
system_prompt: systemPrompt,
temperature: typeof fm.temperature === 'number' ? fm.temperature : DEFAULT_TEMPERATURE,
top_p: typeof fm.top_p === 'number' ? fm.top_p : null,
top_k: typeof fm.top_k === 'number' ? fm.top_k : null,
min_p: typeof fm.min_p === 'number' ? fm.min_p : null,
presence_penalty: typeof fm.presence_penalty === 'number' ? fm.presence_penalty : null,
tools: filteredTools,
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,

View File

@@ -86,7 +86,7 @@ export async function runCapHitSummary(
ctx,
session.model,
messages,
{ tools: null, temperature: agent?.temperature },
{ tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined },
(delta) => {
accumulated += delta;
ctx.publish(sessionId, {
@@ -346,7 +346,7 @@ export async function runDoomLoopSummary(
ctx,
session.model,
messages,
{ tools: null, temperature: agent?.temperature },
{ tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined },
(delta) => {
accumulated += delta;
ctx.publish(sessionId, {
@@ -545,7 +545,7 @@ export async function runStepCapSummary(
ctx,
session.model,
messages,
{ tools: null, temperature: agent?.temperature },
{ tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined },
(delta) => {
accumulated += delta;
ctx.publish(sessionId, {

View File

@@ -31,6 +31,10 @@ interface StreamOptions {
// (rare; we still omit from the request body to avoid OpenAI 400).
tools: ToolJsonSchema[] | null;
temperature?: number;
top_p?: number | null;
top_k?: number | null;
min_p?: number | null;
presence_penalty?: number | null;
}
// v1.13.1-A: convert BooCode's OpenAI-shaped history into AI SDK
@@ -199,6 +203,9 @@ export async function streamCompletion(
? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall }
: {}),
...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
...(typeof opts.top_p === 'number' ? { topP: opts.top_p } : {}),
...(typeof opts.top_k === 'number' ? { topK: opts.top_k } : {}),
...(typeof opts.presence_penalty === 'number' ? { presencePenalty: opts.presence_penalty } : {}),
abortSignal: signal,
});
@@ -388,6 +395,10 @@ export async function executeStreamPhase(
: toolJsonSchemas()
).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name));
const effectiveTemperature = agent?.temperature;
const effectiveTopP = agent?.top_p ?? undefined;
const effectiveTopK = agent?.top_k ?? undefined;
const effectiveMinP = agent?.min_p ?? undefined;
const effectivePresencePenalty = agent?.presence_penalty ?? undefined;
// v1.12.2: ctx_max lookup is cached after the first hit per model, so this
// is a Map probe in steady state. We capture nCtx once at the top of the
@@ -425,7 +436,7 @@ export async function executeStreamPhase(
ctx,
session.model,
messages,
{ tools: effectiveTools, temperature: effectiveTemperature },
{ tools: effectiveTools, temperature: effectiveTemperature, top_p: effectiveTopP, top_k: effectiveTopK, min_p: effectiveMinP, presence_penalty: effectivePresencePenalty },
(delta) => {
state.accumulated += delta;
ctx.publish(sessionId, {

View File

@@ -24,6 +24,34 @@ export interface ParsedCall {
args: Record<string, unknown>;
}
const PLACEHOLDER_LITERALS = new Set(['...', 'placeholder', '<path>', '<file>']);
const ANGLE_BRACKET_SENTINEL_RE = /^<[^>]+>$/;
/** True when a string arg looks like a model placeholder, not a real path/value. */
export function isPlaceholderArgValue(value: unknown): boolean {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
if (trimmed === '') return true;
if (PLACEHOLDER_LITERALS.has(trimmed)) return true;
if (ANGLE_BRACKET_SENTINEL_RE.test(trimmed)) return true;
return false;
}
function hasPlaceholderArgs(args: Record<string, unknown>): boolean {
for (const value of Object.values(args)) {
if (isPlaceholderArgValue(value)) return true;
}
return false;
}
function logRejectedPlaceholder(parsed: ParsedCall): void {
// Pure helper — no Fastify logger here (stream-phase.ts stays unchanged).
console.debug(
{ toolName: parsed.name, args: parsed.args },
'rejected placeholder tool call at parse time',
);
}
// v1.10.5: Qwen-flavor parser. Tightened in v1.13.16 to tolerate whitespace
// around `=` (e.g. `<function = view_file>`). Name capture is non-whitespace,
// non-`>` so a stray space doesn't get absorbed into the function name.
@@ -152,7 +180,14 @@ export function extractToolCallBlocks(buffer: string): ToolCallExtraction {
const blockEnd = next.closeIdx + next.spec.close.length;
const block = buffer.slice(next.openIdx, blockEnd);
const parsed = next.spec.parse(block);
if (parsed) calls.push(parsed);
if (parsed) {
if (hasPlaceholderArgs(parsed.args)) {
logRejectedPlaceholder(parsed);
flushed += block;
} else {
calls.push(parsed);
}
}
pos = blockEnd;
}

View File

@@ -99,6 +99,10 @@ export interface Agent {
description: string;
system_prompt: string;
temperature: number;
top_p: number | null; // null means omit from request body
top_k: number | null; // null means omit from request body
min_p: number | null; // null means omit from request body
presence_penalty: number | null; // null means omit from request body
tools: string[]; // whitelist of tool names; empty = no tools allowed
model: string | null; // null means "session.model wins"
source: AgentSource;

View File

@@ -96,7 +96,7 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
<ChevronDown className="size-3 opacity-70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-80 overflow-y-auto w-72">
<DropdownMenuContent align="start" className="max-h-80 overflow-y-auto w-96">
{error && (
<div className="px-2 py-1.5 text-xs text-destructive">{error}</div>
)}
@@ -128,7 +128,7 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
<span className="font-medium">{a.name}</span>
</div>
{a.description && (
<span className="text-muted-foreground pl-[18px] truncate w-full">
<span className="text-muted-foreground pl-[18px] line-clamp-2 w-full">
{a.description}
</span>
)}

View File

@@ -107,7 +107,7 @@ export function ModelPicker({ value, onChange }: Props) {
<ChevronDown className="size-3 opacity-70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-72 overflow-y-auto">
<DropdownMenuContent align="end" className="max-h-72 min-w-[16rem] overflow-y-auto">
{error && (
<div className="px-2 py-1.5 text-xs text-destructive">{error}</div>
)}

View File

@@ -1,8 +1,10 @@
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Chat } from '@/api/types';
import { api } from '@/api/client';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { ChatInput } from '@/components/ChatInput';
import {
ContextMenu,
ContextMenuContent,
@@ -25,6 +27,8 @@ interface Props {
chats: Chat[];
onOpenChat: (chatId: string) => void;
onSend: (content: string) => void;
/** Create a chat and return its id. Used by slash-command handler. */
createChat: () => Promise<{ id: string }>;
onReopenChat: (chatId: string) => Promise<void>;
onArchiveChat: (chatId: string) => Promise<void>;
onRenameChat: (chatId: string, name: string) => Promise<void>;
@@ -153,12 +157,15 @@ export function SessionLandingPage({
chats,
onOpenChat,
onSend,
projectId,
createChat,
onReopenChat,
onArchiveChat,
onRenameChat,
onDeleteChat,
}: Props) {
const [composerValue, setComposerValue] = useState('');
const [chatId, setChatId] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
@@ -172,13 +179,43 @@ export function SessionLandingPage({
.filter((c) => c.status === 'archived')
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
function handleSend() {
// Create a chat lazily on first send or slash command.
const ensureChat = useCallback(async (): Promise<string> => {
if (chatId) return chatId;
try {
const chat = await createChat();
setChatId(chat.id);
return chat.id;
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
throw err;
}
}, [chatId, createChat]);
async function handleSend() {
const text = composerValue.trim();
if (!text) return;
onSend(text);
setComposerValue('');
try {
const cid = await ensureChat();
onSend(text);
setComposerValue('');
} catch {
// Error already surfaced via toast.
}
}
// v2.3: slash-command dispatch on landing page. Creates a chat first if
// one doesn't exist, then invokes the skill on that chat.
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
try {
const cid = await ensureChat();
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null);
setComposerValue('');
} catch (err) {
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
}
}, [ensureChat]);
function startRename(chat: Chat) {
setRenamingId(chat.id);
setRenameValue(chat.name ?? '');
@@ -293,33 +330,17 @@ export function SessionLandingPage({
)}
</div>
<div className="border-t px-4 py-3 flex items-end gap-2 shrink-0">
<Textarea
value={composerValue}
onChange={(e) => setComposerValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSend();
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Start a new chat..."
rows={2}
className="resize-none min-h-[52px] max-h-[160px]"
{/* v2.3: ChatInput with slash-command support replaces the bare Textarea.
chatId is created lazily on first send/slash. */}
<div className="border-t px-4 py-3 shrink-0">
<ChatInput
projectId={projectId}
onSend={handleSend}
onSlashCommand={handleSlashCommand}
chatId={chatId ?? undefined}
chatLabel={chatId ? undefined : 'Chat'}
disabled={false}
/>
<Button
onClick={handleSend}
disabled={!composerValue.trim()}
size="icon-lg"
aria-label="Send"
>
<Send />
</Button>
</div>
<Dialog open={archiveConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveConfirm(null); }}>

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react';
import { api } from '@/api/client';
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
import { MAX_PANES, activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
@@ -386,6 +387,7 @@ export function Workspace({
sessionId={sessionId}
projectId={projectId}
chats={chats}
createChat={() => api.chats.create(sessionId)}
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
onSend={(content) => void handleLandingSend(idx, content)}
onReopenChat={async (chatId) => {

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
import { ToolCallGroup } from '@/components/ToolCallGroup';
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
import { AskUserInputCard } from '@/components/AskUserInputCard';
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
export interface CoderMessageWire {
@@ -116,6 +117,11 @@ function groupToolRuns(items: RenderItem[]): RenderItem[] {
continue;
}
const name = item.run.call.name;
if (name === 'ask_user_input') {
out.push(item);
i += 1;
continue;
}
let j = i + 1;
while (
j < items.length &&
@@ -178,10 +184,11 @@ function CoderTextBubble({ message }: { message: CoderMessageWire }) {
interface Props {
messages: CoderTimelineWire[];
chatId?: string;
footer?: ReactNode;
}
export function CoderMessageList({ messages, footer }: Props) {
export function CoderMessageList({ messages, chatId, footer }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
@@ -216,6 +223,16 @@ export function CoderMessageList({ messages, footer }: Props) {
return <CoderTextBubble key={item.message.id} message={item.message} />;
}
if (item.kind === 'tool_run') {
if (item.run.call.name === 'ask_user_input' && chatId) {
return (
<AskUserInputCard
key={item.key}
toolCall={item.run.call}
toolResult={item.run.result}
chatId={chatId}
/>
);
}
return <ToolCallLine key={item.key} run={item.run} />;
}
return <ToolCallGroup key={item.key} runs={item.runs} />;

View File

@@ -703,6 +703,7 @@ export function CoderPane({
) : (
<CoderMessageList
messages={messages as CoderTimelineWire[]}
chatId={chatId}
footer={
activeTaskId && !permissionPrompt && sending === false ? (
<p className="text-xs text-muted-foreground animate-pulse">Agent running</p>

View File

@@ -2,9 +2,10 @@ import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => (
<textarea
ref={ref}
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
@@ -13,6 +14,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Link,
useLocation,
@@ -75,6 +75,19 @@ function SessionInner({ sessionId }: { sessionId: string }) {
});
const { chats, renameChat } = chatsHook;
// v2.3: fix hydrate race — if workspace hydrate clobbers the chat-pane
// promotion (panes[0] is still 'empty' while an open chat exists),
// re-promote immediately. Guarded by a ref to avoid infinite loops.
const promotedRef = useRef(false);
useEffect(() => {
if (panes.length !== 1 || panes[0]?.kind !== 'empty') return;
const openChat = chats.find((c) => c.status === 'open');
if (!openChat) return;
if (promotedRef.current) return;
promotedRef.current = true;
initializeFirstChatIfEmpty(openChat.id);
}, [panes, chats, initializeFirstChatIfEmpty]);
// v1.8 Level 1: branch indicator. Polls every 30s; server caches the same
// span so back-to-back loads are cheap. Returns null until the first fetch
// resolves or if the project isn't a git repo.

View File

@@ -1,6 +1,6 @@
# BooCode v1.x — Roadmap
# BooCode roadmap (v1.xv2.x)
Last updated: 2026-05-25
Last updated: 2026-05-26
> **Companion doc:** `boocode_code_review.md` holds the full external-repo inventory, lift rationale, and license analysis. This document is the canonical source for shipping state, version ordering, and what's planned vs. shipped.
@@ -9,7 +9,7 @@ Last updated: 2026-05-25
BooCode is a **3-app monorepo** at `/opt/boocode/` (locked 2026-05-22):
- **BooChat** (`apps/server` + `apps/web`, port `9500`, `code.indifferentketchup.com`) — read-only chat with file-inspection tools. Backend in `apps/server`, SPA in `apps/web`. Database `boochat` (renamed from `boocode` at v2.0).
- **BooCoder** (`apps/coder`, port `9502`, `coder.indifferentketchup.com`) — write tools + external-CLI dispatch. **Shipped v2.0.0v2.1.0.** Host systemd service (not Docker since v2.1.0). In-process inference (with `pending_changes` table) AND ACP-dispatched external agents (opencode/goose) with PTY fallback (claude/qwen).
- **BooCoder** (`apps/coder`, port `9502`, `coder.indifferentketchup.com`) — write tools + external-CLI dispatch. **Shipped v2.0.0v2.2.1.** Host systemd service (not Docker since v2.1.0). In-process inference (with `pending_changes` table) AND Paseo-style ACP dispatch for seven providers (cursor, opencode, goose, claude, qwen, copilot + native boocode) with PTY fallback where ACP is unavailable.
- **BooTerm** (`apps/booterm`, port `9501`) — PTY/tmux/xterm.js. **Live since May 2026.** bookworm-slim + node-pty + tmux + xterm.js. Tmux session per pane (`bc-<uuid>`), SSH-out works (openssh-client + gosu in the image). Shares Postgres database `boochat`.
Caddy → Authelia → Tailscale → `100.114.205.53` → 9500/9501/9502. Three apps, **one shared Postgres** (Docker service `boocode_db`, database name `boochat`).
@@ -21,13 +21,13 @@ Caddy → Authelia → Tailscale → `100.114.205.53` → 9500/9501/9502. Three
- **Mount strategy: blanket `/opt:rw`, permission gating at the write-tool layer.** Per-project scoping is policy, not mount. Path-guard correctness is the #1 test target for v2.0.
- **External CLI agents (`opencode`/`claude`/`goose`/`pi`) live on the host, not in containers.** BooCoder shells out via local-exec PTY or ACP subprocess. Host install inherits Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs.
- **Protocol roles locked (2026-05-22):** **BooChat = MCP client only** (read-only tool consumer, never enables write-capable MCP servers). **BooCoder = MCP client + MCP server + ACP client (host) + ACP agent (driveable)** — full matrix. BooCoder's ACP-client role replaces raw-PTY dispatch for ACP-capable agents (opencode `opencode acp`, goose `goose acp`); PTY fallback retained for claude/pi/smallcode.
- **Strategic target: Paseo-equivalent dispatcher inside BooCode** (2026-05-22 pivot). Paseo (`getpaseo/paseo`) is AGPL-3.0 — incompatible with BooCode's MIT license and network-served deployment. Reproduce the architecture using only license-clean patterns. Primary architectural template: `Dominic789654/agent-hub` (Apache-2.0). Critical context-management primitive: Roo Code Boomerang Tasks pattern. Observation pattern: Claude Code hooks (siropkin/budi reference).
- **Paseo-equivalent dispatcher inside BooCode** (2026-05-22 pivot, **shipped v2.2**). Paseo (`getpaseo/paseo`) is AGPL-3.0 — incompatible with BooCode's MIT license and network-served deployment. BooCode reproduces the architecture using license-clean patterns only (`provider-snapshot.ts`, ACP merge/stream/persist, `AgentComposerBar`). Primary architectural template: `Dominic789654/agent-hub` (Apache-2.0). Critical context-management primitive: Roo Code Boomerang Tasks pattern. Observation pattern: Claude Code hooks (siropkin/budi reference).
External code lifted from / referenced in: see `boocode_code_review.md` for full inventory.
-----
## Shipped (status as of 2026-05-23)
## Shipped (status as of 2026-05-26)
|Version |Theme |Tag |
|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------|
@@ -263,7 +263,7 @@ Independent batch — ships clean any time after v1.13. Low leverage unless Sam
**Major version bump.** New app `apps/coder/` inside the existing monorepo (not a separate repo). Shipped with database rename `boocode``boochat` and subdomain split (`code.indifferentketchup.com` → BooChat, `coder.indifferentketchup.com` → BooCoder).
**Shipped v2.0.0v2.0.4.** All 8 phases complete. See retrospective below.
**Shipped v2.0.0v2.0.5.** All 8 phases complete plus v2.0.5 (Arena, FAST_MODEL, Qwen dispatch). See retrospective below.
**Three protocol roles in one surface:**
@@ -318,7 +318,7 @@ Independent batch — ships clean any time after v1.13. Low leverage unless Sam
**Estimated:** ~1500 LoC for Path A + Path B + shared schema, plus ~400 LoC for the MCP-server role, plus ~300 LoC for the ACP-client role. Multiple sub-versions: v2.0.0 native + ACP, v2.0.1 MCP server, v2.0.2 polish.
**Retrospective (2026-05-25):** All 8 phases shipped. v2.0.0-alpha through v2.0.4-hardening. The full BooCoder line is complete: write tools with pending-changes queue, dispatcher with ACP/PTY dual paths, MCP server (6 tools, stdio transport, 10-question eval passed), CLI client, human inbox, Boomerang `new_task` orchestration, and path-guard fuzz suite (34 traversal-attack tests). Runtime isolation (v2.1) remains optional pending production bake.
**Retrospective (2026-05-25, updated 2026-05-26):** All 8 phases shipped. v2.0.0-alpha through v2.0.5. The full BooCoder foundation is complete: write tools with pending-changes queue, dispatcher with ACP/PTY dual paths, MCP server (6 tools, stdio transport, 10-question eval passed), CLI client, human inbox, Boomerang `new_task` orchestration, and path-guard fuzz suite (34 traversal-attack tests). v2.1 added host systemd + provider picker; **v2.2 shipped the Paseo provider/dispatch stack** (`v2.2-paseo-providers` + `v2.2.1-pane-scoped-chats`). Runtime isolation (v2.1 optional batch) remains optional pending production bake.
-----
@@ -338,7 +338,27 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
-----
## v2.2 — BooCoder as ACP agent (driveable from external editors)
-----
## v2.2 — Paseo-style providers
**Shipped `v2.2-paseo-providers` (2026-05-26).** Paseo-equivalent provider stack for BooCoder. Seven providers (boocode, cursor, claude, opencode, goose, qwen, copilot) with snapshot API (`provider-snapshot.ts`, ACP cold probe, per-provider model merge, cursor models from ACP). Frontend `AgentComposerBar` replaces v2.1's `ProviderPicker` — provider / mode / model / thinking in the coder composer; `SlashCommandPicker` + `useProviderSnapshot`. ACP dispatch rewritten (`acp-dispatch.ts`, `acp-stream.ts`, `acp-spawn.ts`, `agent-turn-persist.ts`, `acp-tool-snapshot.ts`) with Paseo merge/stream/persist, inline `PermissionCard` prompts, and `reasoning_delta` WS frames. Agent slash-command hints via ACP `available_commands_update` + `AgentCommandsHint`. Arena and MCP entry points accept `mode_id` / `thinking_option_id`. SSH helpers removed; host exec via `host-exec.ts`. Openspec archived to `openspec/changes/archived/v2.2-paseo-providers.md`.
**Shipped `v2.2.1-pane-scoped-chats` (2026-05-26, same commit).** Follow-up fixes: pane-scoped chat resolution (`resolveChatId` requires `pane_id`; client seeds dedicated chats per coder/terminal pane), `CoderMessageList` BooChat-style tool timeline, WS user-delta replace-not-append, inference `buildMessagesPayload` orphan `tool_calls` stripping for shared chats with ACP history.
-----
## v2.3 — Provider lifecycle (Paseo-style registry)
**Planned.** Config-backed provider registry (`/data/coder-providers.json`), merged built-ins + overrides, enable/disable toggles, two-tier probe (fast binary vs slow ACP session), generic ACP spawn from config without new code paths. Depends on v2.2 snapshot wire shape. Openspec: `openspec/changes/v2-3-provider-lifecycle/`. See `CURRENT.md`.
**Lift source:** Paseo provider docs (design only — no AGPL code lift).
**Dependencies:** v2.2.
-----
## v2.4 — BooCoder as ACP agent (driveable from external editors)
**Goal:** expose `boocoder acp` so Zed, JetBrains, Avante.nvim, CodeCompanion.nvim can drive BooCoder as their agent. Outbound exposure of the BooCoder write-tool surface to ACP-compatible editors.
@@ -348,11 +368,11 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
1. BooCoder UI features remain optional: editor drives session via ACP; pending-changes queue still gates writes; user can approve/reject from either BooCoder's web UI or the editor's permission dialog (whichever responds first).
1. Same auth model as the rest of BooCoder — editor must be reachable on the Tailscale mesh, or BooCoder is invoked with a short-lived token.
**Why this is v2.2, not v2.0:** outbound ACP-agent role is cheap once the inbound ACP-client side is implemented (same protocol library, server side), but it's a *different product surface* — driving BooCoder from external editors. Ship it after BooCoder's own surface stabilizes.
**Why v2.4, not v2.0:** outbound ACP-agent role is cheap once the inbound ACP-client side is implemented (same protocol library, server side), but it's a *different product surface* — driving BooCoder from external editors. Ship it after BooCoder's own surface stabilizes. (The v2.2 version number was used for the Paseo provider/dispatch batch shipped 2026-05-26.)
**Lift source:** `zed-industries/codex-acp` (Apache-2.0) as a server-side ACP reference implementation.
**Dependencies:** v2.0 + v2.1 (recommended; ACP-driven sessions inside a sandbox are stronger).
**Dependencies:** v2.0 + v2.2 (recommended; v2.1 runtime isolation optional).
**Estimated:** ~400 LoC.
@@ -360,7 +380,9 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
## v2.1.0 — Provider picker + model discovery
**Shipped `v2.1.0-provider-picker`.** Provider registry with 5 providers (boocode, opencode, goose, claude, qwen). Model discovery via `LLAMA_SWAP_URL/upstream/<model>/props`. `/api/providers` route returns installed providers with models. `ProviderPicker` frontend component in workspace toolbar. Agent-probe startup probe discovers installed agents on host, their versions, ACP support, and models. Booterm SSH host configurable via `BOOTERM_SSH_HOST`/`BOOTERM_SSH_USER` env vars.
**Shipped `v2.1.0-provider-picker`.** Provider registry with 5 providers (boocode, opencode, goose, claude, qwen). Model discovery via `LLAMA_SWAP_URL/upstream/<model>/props`. `/api/providers` route returns installed providers with models. v2.1 `ProviderPicker` UI **superseded by `AgentComposerBar` in v2.2.** Agent-probe startup probe discovers installed agents on host, their versions, ACP support, and models. Booterm SSH host configurable via `BOOTERM_SSH_HOST`/`BOOTERM_SSH_USER` env vars.
**Shipped `v2.1.1-roadmap-cleanup`.** Roadmap reconciliation, README updates, openspec archive housekeeping. No runtime behavior changes.
-----
@@ -390,7 +412,7 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
|-------------------------------|---------------------|-----------------------------|------------------------------------------------------------------------|----------------------|
|`boochat` (was `boocode`) |`100.114.205.53:9500`|`/opt:/opt:ro` |Read-only chat + SPA host + MCP client |Live (renames at v2.0)|
|`booterm` |`100.114.205.53:9501`|`/opt:/opt` |PTY/tmux terminal sessions |**Live (May 2026)** |
|`boocoder` |`100.114.205.53:9502`|`/opt:/opt:rw` (policy-gated)|Write tools + ACP host + MCP client + MCP server + external-CLI dispatch|**Shipped v2.0.0v2.0.4** |
|`boocoder` (host systemd) |`100.114.205.53:9502`|full host FS (policy-gated) |Write tools + ACP client + MCP client + MCP server + external-CLI dispatch|**Shipped v2.0.0v2.2.1** (systemd since v2.1.0) |
|**`boochat`** (Docker service `boocode_db`)|`127.0.0.1:5500` |`boocode_pgdata` volume |Postgres 16-alpine (shared by all three) |**Live** (DB renamed from `boocode` at v2.0)|
|`codecontext` |`:8080` (internal, Docker network) |`/opt:/opt:ro`|Go HTTP sidecar for code graph tools |**Live (v1.12.0)** |
@@ -436,7 +458,8 @@ term.indifferentketchup.com → booterm :9501 (or routed under code.
- **v1.15:** `permissions` table, `agent_permissions` join, `session_permissions` join, `mcp_servers (name, type, transport, url_or_command, enabled, config_hash, last_probed_at)` registry
- **v1.16:** `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`
- **v2.0 (shipped):** `pending_changes`, `tasks`, `available_agents`, `human_inbox` view; database renamed `boocode``boochat`
- **v2.2:** none (`boocoder acp` is a new entry point, not a schema change)
- **v2.2 (shipped):** none (provider snapshot + ACP dispatch are runtime/services; pane chat scoping uses existing `sessions.workspace_panes` + `chats`)
- **v2.4:** none (`boocoder acp` is a new entry point, not a schema change)
-----
@@ -471,10 +494,10 @@ Full inventory and rationale in `boocode_code_review.md`. Headline items below;
|`spirituslab/codesight` |MIT-ish |Repo health analyzer (`analyze.mjs`) |v1.16 |
|`plandex-ai/plandex` |MIT |Pending-changes data model + diff/apply/rewind UX |v2.0 |
|`Dominic789654/agent-hub` |Apache-2.0 |**Task DAG schema, dispatcher worker, project registry, human inbox** — primary architectural template for v2.0 dispatcher|v2.0 |
|`getpaseo/paseo` |AGPL-3.0 (**design only, no code lift**)|Daemon+clients arch, CLI verb shape, worktree flag, three skills concept |v2.0 / v2.x |
|**`agentclientprotocol.com` spec + `@zed-industries/agent-client-protocol` SDK**|**Apache-2.0** |**ACP client (host) — replaces raw-PTY dispatch for opencode/goose** |**v2.0** |
|`getpaseo/paseo` |AGPL-3.0 (**design only, no code lift**)|Daemon+clients arch, CLI verb shape, worktree flag, provider snapshot/dispatch patterns |**v2.2 (shipped)** / v2.x |
|**`agentclientprotocol.com` spec + `@zed-industries/agent-client-protocol` SDK**|**Apache-2.0** |**ACP client (host) — replaces raw-PTY dispatch for opencode/goose/cursor** |**v2.0 → v2.2** |
|**anthropics/skills `mcp-builder`** |**MIT** |**MCP server build workflow + 10-question evaluation framework** |**v2.0 (BooCoder MCP server)** |
|**`zed-industries/codex-acp`** |**Apache-2.0** |**ACP server-side reference for `boocoder acp`** |**v2.2** |
|**`zed-industries/codex-acp`** |**Apache-2.0** |**ACP server-side reference for `boocoder acp`** |**v2.4** |
|Roo Code: Boomerang Tasks |Apache-2.0 (pattern only) |Orchestrator capability restriction + down-pass/up-pass context discipline |v1.14 (AGENTS.md) → v2.0 (real delegation) |
|`covibes/zeroshot` |MIT (pattern only) |Blind-validation invariant + complexity-classification conductor |v1.14 (AGENTS.md) → v2.0 (verify gate) |
|`OpenHands/OpenHands` |MIT |Sandbox runtime contract |v2.1 |
@@ -509,29 +532,29 @@ Full inventory and rationale in `boocode_code_review.md`. Headline items below;
- **MCP client role:** inherits v1.15 client; write-capable servers allowed but writes route through `pending_changes` queue.
- **MCP server role:** BooCoder exposes its own task primitives (`boocoder.create_task` etc.) so external `opencode` sessions in Termius become BooCoder-aware. Stdio for local, HTTP gated on OAuth+secret storage.
- **ACP client (host) role:** replaces raw-PTY dispatch for ACP-capable agents (opencode, goose). PTY retained as fallback for claude/pi/smallcode. Critical pattern: ACP clients auto-forward MCP `context_servers` to the dispatched agent (per goose docs) — one MCP config drives every dispatched agent.
- **ACP agent role:** `boocoder acp` exposes BooCoder to Zed/JetBrains/Avante.nvim. Deferred to v2.2.
- **ACP agent role:** `boocoder acp` exposes BooCoder to Zed/JetBrains/Avante.nvim. Deferred to **v2.4**.
- **Why BooChat doesn't get ACP:** ACP standardizes the editor→agent direction. BooChat doesn't drive agents; it *is* the chat. Adding ACP-agent to BooChat would convert it into an opencode-equivalent — different product. Skip.
- **MCP/ACP integration phasing:** v1.14.x (single-server MCP-client PoC against Context7) → v1.15 (full MCP client + permissions) → v2.0 (BooCoder full matrix: write-capable MCP client + MCP server + ACP client) → v2.2 (BooCoder ACP agent for external editor drive).
- **MCP/ACP integration phasing:** v1.14.x (single-server MCP-client PoC against Context7) → v1.15 (full MCP client + permissions) → v2.0 (BooCoder full matrix: write-capable MCP client + MCP server + ACP client) → **v2.2 (Paseo provider snapshot + ACP dispatch — shipped)** → v2.4 (BooCoder ACP agent for external editor drive).
- **Reference materials:** anthropics `mcp-builder` skill (4-phase build workflow + 10-question eval framework — required for BooCoder's MCP server before shipping), opencode MCP/ACP docs as JSON-schema interop reference, goose ACP docs for the `context_servers` auto-forward pattern, `agentclientprotocol.com` spec (note: remote ACP via HTTP/WS still WIP, v2.0 uses stdio only).
- **v1 MCP scope limit (security):** local-stdio MCP servers + Context7-style API-key remote only. Remote OAuth MCP servers (Sentry, Atlassian, etc.) deferred until BooCode has a real secret-storage primitive — token leakage from a PostgreSQL dump or Authelia bypass is a real attack surface that doesn't exist with local-stdio MCP.
### Monorepo / multi-app structure (2026-05-22, locked)
- **BooCode is a 3-surface monorepo** at `/opt/boocode/`: BooChat (`apps/server` + `apps/web`, :9500), BooCoder (`apps/coder`, :9502, **shipped v2.0v2.1.0**, host systemd), BooTerm (`apps/booterm`, :9501, live since May 2026). One React SPA hosts chat, coder, and terminal panes.
- **BooCode is a 3-surface monorepo** at `/opt/boocode/`: BooChat (`apps/server` + `apps/web`, :9500), BooCoder (`apps/coder`, :9502, **shipped v2.0v2.2.1**, host systemd), BooTerm (`apps/booterm`, :9501, live since May 2026). One React SPA hosts chat, coder, and terminal panes.
- **Single shared database `boochat`.** Docker service `boocode_db`, all three surfaces connect to the same Postgres. Cross-surface joins are valuable (coder task → originating chat → term debugging session).
- **Mount strategy: blanket `/opt:rw`, policy enforcement at the write-tool layer.** Per-project scoping is logic, not mount. Path-guard correctness becomes the highest-priority test target for v2.0 — fuzz it, property-test it, every traversal-attack pattern (including MCP-served filesystem writes).
- **External CLI agents on the host, not in containers.** BooCoder shells out via local-exec PTY or ACP subprocess (`node-pty`, host shell, or `child_process.spawn('opencode', ['acp'])`). Host install inherits Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs without re-mounting. Containerize later only if a concrete reason emerges.
### Strategic pivot: Paseo-equivalent dispatcher (2026-05-22)
### Strategic pivot: Paseo-equivalent dispatcher (2026-05-22, **shipped v2.2**)
Sam wants BooCode to function like Paseo without using Paseo itself. **Paseo is AGPL-3.0** — incompatible with BooCode's MIT license and its network-served deployment at `code.indifferentketchup.com`. Solution: **reproduce the architecture in BooCode's existing Fastify + TS + PostgreSQL + React stack, using only license-clean patterns**.
Sam wanted BooCode to function like Paseo without using Paseo itself. **Paseo is AGPL-3.0** — incompatible with BooCode's MIT license and its network-served deployment at `code.indifferentketchup.com`. Solution: **reproduce the architecture in BooCode's existing Fastify + TS + PostgreSQL + React stack, using only license-clean patterns**.
- **Primary architectural template:** `Dominic789654/agent-hub` (Apache-2.0) — three-process model (board server + dispatcher + assistant terminal) and schema (tasks/projects/templates/pipelines/human_inbox).
- **Critical context-management primitive:** Roo Code Boomerang Tasks pattern — orchestrator with intentional capability restriction, down-pass/up-pass context discipline, no implicit inheritance.
- **Observation pattern:** Claude Code hooks (siropkin/budi reference) — register BooCode as the hook receiver for `SessionStart`/`UserPromptSubmit`/`PostToolUse`/`SubagentStart`/`Stop`.
- **Protocol-level Paseo equivalence:** the ACP client + MCP server combination in BooCoder is the protocol-spelled version of Paseo's daemon. ACP gives multi-agent dispatch with structured events instead of free-form PTY output. MCP server gives BooCoder-as-task-board, callable from any MCP client (Termius-based opencode, future editors). One MCP config feeds every dispatched agent (via `context_servers` auto-forward).
- **Protocol-level Paseo equivalence (shipped v2.2):** the ACP client + MCP server combination in BooCoder is the protocol-spelled version of Paseo's daemon. ACP gives multi-agent dispatch with structured events instead of free-form PTY output. MCP server gives BooCoder-as-task-board, callable from any MCP client (Termius-based opencode, future editors). One MCP config feeds every dispatched agent (via `context_servers` auto-forward). v2.2 added provider snapshot, mode/thinking, permission prompts, and Paseo-style stream/persist.
This is now the dominant roadmap direction, **ahead of v1.13.x cleanup batches in importance** but **behind them in sequence** (v1.13 finishing now; Paseo-equivalent work is v2.0+).
**Next on this track:** v2.3 provider lifecycle (config-backed registry, enable/disable, two-tier probe). See openspec `v2-3-provider-lifecycle`.
### BooCoder execution: both Option A AND Option B, full-featured (2026-05-22)
@@ -541,13 +564,20 @@ Earlier May 18 chat recommended Option A (thin orchestration shell over OpenCode
The v1.13.x cleanup line shipped 21 batches over a single intense window in `vMAJOR.MINOR.PATCH-slug` form: **v1.13.0-ai-sdk-v6 ✅ → v1.13.1-cleanup-bundle ✅ → v1.13.2-compaction-prune ✅ → v1.13.3-truncate ✅ → v1.13.4-reasoning-fix ✅ → v1.13.5-stability-bundle ✅ → v1.13.6-prefix-stability ✅ → v1.13.7-compaction-trigger ✅ → v1.13.8-tool-cost ✅ → v1.13.9-agentlint ✅ → v1.13.10-openspec ✅ → v1.13.11-tools ✅ → v1.13.12-ws-schemas ✅ → v1.13.13-ws-publish ✅ → v1.13.14-skills-audit ✅ → v1.13.15-codecontext-synth ✅ → v1.13.16-xml-parser ✅ → v1.13.17-cross-repo-reads ✅ → v1.13.18-codecontext-file-path ✅ → v1.13.19-html-artifact-panes ✅ → v1.13.20-drop-legacy-cols ✅** → umbrella `v1.13` ✅. **Do not fold** was the discipline — each batch has a distinct rollback surface, and bisecting a 750-LoC merge across four unrelated changes is worse than four separate dispatches. Held throughout; CHANGELOG.md is the per-tag canonical record.
### v1.14v2.1 shipped (2026-05-25)
### v1.14v2.2 shipped (2026-05-26)
- **v1.14.0-outer-loop** ✅ — explicit `while` loop, per-agent `steps:` cap, doom-loop migration
- **v1.14.1-mcp-poc** ✅ — Context7 MCP client validated
- **v1.15.0-mcp-multi** ✅ — multi-server MCP client, stdio transport, per-agent tool globs
- **v2.0.0-alpha through v2.0.4-hardening** ✅ — full BooCoder line: write tools, dispatcher (ACP/PTY), MCP server (6 tools, stdio, 10-question eval passed), CLI client, human inbox, Boomerang `new_task` orchestration, path-guard fuzz suite (34 traversal-attack tests)
- **v2.1.0-provider-picker** ✅ — 5-provider registry, model discovery, `/api/providers` route, `ProviderPicker` UI, agent-probe startup probe
- **v2.0.0-alpha through v2.0.5** ✅ — full BooCoder line: write tools, dispatcher (ACP/PTY), MCP server (6 tools, stdio, 10-question eval passed), CLI client, human inbox, Boomerang `new_task` orchestration, path-guard fuzz suite (34 traversal-attack tests), Arena, FAST_MODEL
- **v2.1.0-provider-picker** ✅ — 5-provider registry, model discovery, `/api/providers` route, agent-probe startup probe, BooCoder host systemd migration
- **v2.1.1-roadmap-cleanup** ✅ — roadmap/README/openspec archive housekeeping
- **v2.2-paseo-providers** ✅ — 7-provider snapshot, `AgentComposerBar`, ACP dispatch rewrite, permission prompts, agent commands, cursor/copilot providers
- **v2.2.1-pane-scoped-chats** ✅ — pane-scoped chat resolution, `CoderMessageList` tool UI, WS user-delta fix, inference orphan tool_call stripping
### In flight
- **v2.3-provider-lifecycle** — config-backed provider registry, enable/disable, two-tier probe (openspec drafted; not started). See `CURRENT.md`.
### Numbering and scope-revision discipline during v1.13.x (2026-05-23)

View File

@@ -2,7 +2,11 @@
## Code Reviewer
---
temperature: 0.3
temperature: 0.6
top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
description: Reviews code for bugs, security issues, and maintainability. Read-only.
---
@@ -37,7 +41,11 @@ Codecontext usage:
## Debugger
---
temperature: 0.4
temperature: 0.6
top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
description: Diagnoses bugs from error messages, logs, or described symptoms.
---
@@ -58,7 +66,11 @@ Rules:
## Refactorer
---
temperature: 0.3
temperature: 0.6
top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
steps: 5
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
@@ -97,7 +109,11 @@ Codecontext usage:
## Architect
---
temperature: 0.5
temperature: 1.0
top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 1.5
steps: 20
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
description: Designs new features, modules, or architectural changes. Outputs a build plan.
@@ -136,7 +152,11 @@ Codecontext usage:
## Security Auditor
---
temperature: 0.2
temperature: 0.6
top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
description: Audits code for security vulnerabilities. Read-only.
---
@@ -177,7 +197,11 @@ Codecontext usage:
## Prompt Builder
---
temperature: 0.4
temperature: 0.6
top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [view_file, list_dir, grep, find_files]
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
---
@@ -208,3 +232,29 @@ Rules:
- For BooLab frontend prompts, always include the "verify shadcn primitives exist" preflight
Output: the prompt, ready to paste. Nothing else.
## Recon
---
temperature: 0.6
top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
description: Discovers and maps unfamiliar codebases. Reads architecture, traces data flow, identifies key symbols.
---
You map codebases. Start broad, then drill into specifics.
Process:
1. get_codebase_overview for the big picture — file count, languages, top-level structure.
2. list_dir the top-level directories to understand the layout.
3. get_semantic_neighborhoods and get_hot_files to find core modules and high-impact files.
4. Trace data flow: entry points → handlers → services → data stores.
5. Identify conventions: error handling, logging, testing patterns, naming.
Output:
- Architecture overview (one paragraph)
- Key files and their roles
- Data flow map (entry → transform → output)
- Conventions observed
- Areas that need deeper investigation

View File

@@ -105,6 +105,8 @@ services:
build:
context: ./codecontext
container_name: boocode_codecontext
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
networks:
- boocode_net

View File

@@ -11,10 +11,11 @@ Last updated: 2026-05-26
| Item | Category | User impact | Effort | Risk if left alone |
|------|----------|-------------|--------|-------------------|
| Task cancel → abort ACP/PTY child | Correctness / UX | High — Stop does not kill external agents | Medium | Zombie processes, stuck `running` tasks, orphaned worktrees |
| ~~Skip ACP cold probe when DB fresh~~ | ~~Performance~~ | ~~Medium~~ | ~~Medium~~ | **RESOLVED — v2.3 provider lifecycle** |
| Skip ACP cold probe when DB fresh | Performance | Medium — composer open can stall 530s on cache miss | Medium (v2.3 batch) | Slow provider picker; repeated ACP spawns on every snapshot rebuild |
| Unified `packages/types` | Maintainability | Low (dev-only) | MediumHigh | Type drift between server, coder, web |
| Large file splits | Maintainability | None directly | Medium per file | Harder reviews, merge conflicts |
| Retire `apps/coder/web/` fallback SPA | Scope / ops | Low — Sam uses CoderPane | Medium | Dual UI maintenance, divergent API client |
| `console.debug` in xml-parser placeholder rejection | Maintainability | None (logs only) | Trivial | Placeholder rejections miss pino pipeline / level filters |
---
@@ -108,9 +109,42 @@ There is also **no frontend** calling task cancel today (`grep` across `apps/web
---
## 2. ~~Skip ACP cold probe when DB models are fresh~~ **RESOLVED — v2.3 provider lifecycle**
## 2. Skip ACP cold probe when DB models are fresh
Addressed in [`openspec/changes/v2-3-provider-lifecycle/`](../openspec/changes/v2-3-provider-lifecycle/design.md). The v2.3 snapshot module (`provider-snapshot.ts`) uses DB `available_agents` models as the warm path and only cold-probes on explicit `POST /api/providers/refresh`. Opening the provider picker no longer triggers any probe. `PROVIDER_PROBE_TTL_MS` env var (default 24h) controls stale-model self-heal.
**Status:** Planned [`openspec/changes/v2-3-provider-lifecycle/`](../openspec/changes/v2-3-provider-lifecycle/proposal.md). **Not shipped** (no `v2.3` tag; all tasks unchecked).
### Current behavior (v2.2)
`apps/coder/src/services/provider-snapshot.ts` on cache miss:
1. Reads `available_agents.models` as a **fallback** when building each entry
2. Still **cold-probes every installed ACP provider** on every rebuild (`probeAcpProvider` in `buildProviderEntry`) — DB models do not skip the probe
3. In-memory snapshot cache TTL is **5 minutes** (`CACHE_TTL_MS`); opening `AgentComposerBar` calls `GET /api/providers/snapshot` via `useProviderSnapshot`
4. `POST /api/providers/refresh` clears cache and forces a full rebuild (all probes again)
5. Uninstalled agents are **omitted** from the snapshot (`return null`) — not listed as `unavailable`
6. No config-file enable/disable; providers are hardcoded in `provider-registry.ts`
`persistProbedModels()` writes probe results back to `available_agents` (including `last_probed_at`), but nothing reads `last_probed_at` to skip tier-2 probes yet. There is no `PROVIDER_PROBE_TTL_MS` env var.
### Planned behavior (v2.3)
See [`design.md`](../openspec/changes/v2-3-provider-lifecycle/design.md):
- Tier-1 fast binary check + tier-2 ACP session only when stale or explicitly refreshed
- `PROVIDER_PROBE_TTL_MS` (default 24h) gate on `available_agents.last_probed_at`
- Return `loading` synchronously on cache miss; complete via inflight promise
- Always list registered providers (`ready` | `unavailable` | `error` | `loading`); respect `enabled` from `/data/coder-providers.json`
- Cold probe only on `POST /api/providers/refresh` (or TTL expiry), not on every composer open
### Why deferred past v2.2
v2.2 shipped the snapshot wire shape and ACP dispatch stack. Lifecycle semantics (config registry, enable/disable, probe TTL, settings UI) were scoped as the follow-on **v2.3** batch to avoid mixing two large behavior changes in one tag.
### Acceptance criteria (when v2.3 ships)
- Second `GET /api/providers/snapshot` within TTL does not invoke `probeAcpProvider` (mock assert in tests)
- Disabled provider visible in settings, absent from composer
- Explicit refresh repopulates models; warm open is sub-second
---
@@ -292,15 +326,26 @@ Standalone Vite React app (`@boocode/coder-web`) built into `apps/coder/web/dist
---
## 6. xml-parser placeholder rejection — structured logging (v2.2.2 cleanup)
**Shipped (uncommitted deploy):** `extractToolCallBlocks` rejects placeholder XML tool args at parse time; dropped blocks append to `flushed`.
**Nit:** rejection path uses `console.debug` instead of the Fastify/pino `log.debug({ toolName, args }, '…')` pattern used elsewhere. Cosmetic — behavior is correct; logs won't appear in the usual structured pipeline or respect `LOG_LEVEL`.
**Fix:** pass an optional logger into `extractToolCallBlocks` from `stream-phase.ts` executeStreamPhase (one call site), or use a module-level debug hook. Target tag: **v2.2.2** cleanup batch, not a blocker.
---
## Suggested batch ordering
If picking these up as openspec batches:
1. **Task cancel abort** — highest correctness gap; unblocks honest Stop button in CoderPane
2. **ACP probe skip** — quick win for provider picker latency once semantics agreed
2. **v2.3 provider lifecycle** — probe TTL, config registry, enable/disable (includes §2 above)
3. **CoderPane hook extraction** — natural follow-on when adding cancel UI
4. **Zod parity or packages/types** — when next WS/provider field is added
5. **Retire coder/web** — only after explicit “I dont use :9502 UI” confirmation
6. **v2.2.2 xml-parser log uniformity**`console.debug` → pino (§6)
---
@@ -308,5 +353,6 @@ If picking these up as openspec batches:
- [`STALE-DEPRECATED.md`](./STALE-DEPRECATED.md) — resolved stale items
- [`ARCHITECTURE.md`](./ARCHITECTURE.md) — BooChat / BooCoder surfaces
- [`openspec/changes/v2-2-paseo-providers/design.md`](../openspec/changes/v2-2-paseo-providers/design.md) — provider snapshot API
- [`openspec/changes/archived/v2.2-paseo-providers.md`](../openspec/changes/archived/v2.2-paseo-providers.md) — shipped v2.2 provider snapshot API
- [`openspec/changes/v2-3-provider-lifecycle/`](../openspec/changes/v2-3-provider-lifecycle/) — planned probe lifecycle + config registry (§2)
- [`BOOCODER.md`](../BOOCODER.md) — dispatch, worktrees, pending changes