Compare commits
7 Commits
v1.12.0-co
...
v1.12.1-st
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a0a3b1673 | |||
| 48ee63a286 | |||
| d58d553503 | |||
| fce8c06932 | |||
| 684612f3cd | |||
| cc73ed1957 | |||
| 3e1e17ecf6 |
15
CLAUDE.md
15
CLAUDE.md
@@ -33,7 +33,7 @@ npx tsc -p apps/web/tsconfig.app.json --noEmit # web app specifically
|
|||||||
docker compose build --no-cache boocode && docker compose up -d
|
docker compose build --no-cache boocode && docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests: `pnpm -C apps/server test` runs 23 vitest tests. No test harness on `apps/web` (adding it requires installing vitest as a new devDep). Vitest pinned to `^3` because Vite 5 / vitest 4 are incompatible. No linters configured.
|
Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `apps/web` (adding it requires installing vitest as a new devDep). Vitest pinned to `^3` because Vite 5 / vitest 4 are incompatible. No linters configured. Vitest include glob is `src/**/__tests__/**/*.test.ts` (see `apps/server/vitest.config.ts`) — tests outside `src/**/__tests__/` silently won't run; match the per-domain convention (`apps/server/src/services/__tests__/foo.test.ts`).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -46,9 +46,10 @@ Tests: `pnpm -C apps/server test` runs 23 vitest tests. No test harness on `apps
|
|||||||
- **Zod** for request validation and config parsing.
|
- **Zod** for request validation and config parsing.
|
||||||
|
|
||||||
Key services:
|
Key services:
|
||||||
- **`services/inference.ts`** — Streams LLM responses, executes tool loops (max depth 15, see `MAX_TOOL_LOOP_DEPTH`), flushes to DB every 500ms. Publishes `InferenceFrame` events through the broker.
|
- **`services/inference.ts`** — Streams LLM responses, executes tool loops (max depth 15, see `MAX_TOOL_LOOP_DEPTH`), flushes to DB every 500ms. Publishes `InferenceFrame` events through the broker. **`TurnArgs`** is the per-turn state envelope threaded through the `executeToolPhase → runAssistantTurn` recursion (`toolsUsed`, `recentToolCalls`, `assistantMessageId`, `signal`); reset to defaults in `runInference` at the user-message boundary. Cap-hit (`toolsUsed >= budget`) and doom-loop (`detectDoomLoop(recentToolCalls)`) checks both read from this envelope. Add new per-turn state here, not in module-level closures.
|
||||||
- **`services/broker.ts`** — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart.
|
- **`services/broker.ts`** — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart.
|
||||||
- **`services/tools.ts`** — Four read-only file tools exposed as OpenAI function-calling schemas. All file access goes through `path_guard.ts` which resolves against project root.
|
- **`services/tools.ts`** — Tool registry (`ALL_TOOLS`, `READ_ONLY_TOOL_NAMES`, `TOOLS_BY_NAME`). Filesystem tools (view_file/list_dir/grep/find_files) go through three guard layers: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). v1.11.8+ web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (resolved with `project.default_web_search_enabled` fallback) and filtered out of the LLM's tool schema when false.
|
||||||
|
- **`services/compaction.ts`** + **`services/model-context.ts`** — v1.11.0 anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself on each compaction). Triggered when `chats.needs_compaction` is set after an inference turn exceeds `usable(ctx_max) = ctx_max - 20k`. **`ctx_max` comes from `model-context.getModelContext()` which fetches `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx` (the stream completion's `timings` doesn't carry n_ctx; that read was dead code until v1.11.3 ripped it out).
|
||||||
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
||||||
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ Position-shift pattern for panes (legacy `session_panes` table): negate-and-rest
|
|||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`.
|
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context).
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -130,3 +131,9 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
|||||||
- `vite.config.ts` proxy entries are order-sensitive: more-specific prefixes (`/api/term`, `/ws/term`) must come BEFORE `/api`.
|
- `vite.config.ts` proxy entries are order-sensitive: more-specific prefixes (`/api/term`, `/ws/term`) must come BEFORE `/api`.
|
||||||
- Mobile pane URL sync (`Session.tsx`): the `?pane=<id>` effect resets `activePaneIdx` whenever `panes` changes. New-pane creation on mobile must push `?pane=` atomically — `addPaneAndSwitch` is the wrapper that does this. `addSplitPane` returns the new pane id for callers.
|
- Mobile pane URL sync (`Session.tsx`): the `?pane=<id>` effect resets `activePaneIdx` whenever `panes` changes. New-pane creation on mobile must push `?pane=` atomically — `addPaneAndSwitch` is the wrapper that does this. `addSplitPane` returns the new pane id for callers.
|
||||||
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.
|
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.
|
||||||
|
- **New tools** live in their own `services/<name>.ts` file (see `web_search.ts`, `web_fetch.ts`) — exports a pure `executeFoo(input, ...deps)` for direct test access plus a `ToolDef` wrapper that `loadConfig()`s its real dependencies. Register the ToolDef in `tools.ts` `ALL_TOOLS` (and `READ_ONLY_TOOL_NAMES` if applicable). Inject `fetcher: typeof fetch = fetch` rather than `vi.spyOn(globalThis, 'fetch')` — cleanup is simpler and the production call site stays unchanged.
|
||||||
|
- **Sentinels** are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. A new kind requires arms in `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts`, plus a render branch in `apps/web/src/components/MessageBubble.tsx`.
|
||||||
|
- **ReadableStream test stubs** use `pull()` (not `start()`) so chunks are produced lazily — `start()` enqueues everything and calls `controller.close()` before the consumer reads, so a subsequent `reader.cancel()` finds the stream already closed and the `cancel()` callback never fires. Also provide MORE chunks than the test will consume so the source stays in 'readable' state when cancel runs (e.g. cap test reads ~6 chunks, stub provides 10).
|
||||||
|
- Tool-name whitelists must derive from `ALL_TOOLS` in `services/tools.ts`, never hardcoded. `services/agents.ts` `ALL_TOOL_NAMES` had this drift class until v1.12 — same pattern applies to any future tool-aware code.
|
||||||
|
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
|
||||||
|
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ async function main() {
|
|||||||
await applySchema(sql);
|
await applySchema(sql);
|
||||||
app.log.info('database schema applied');
|
app.log.info('database schema applied');
|
||||||
|
|
||||||
|
const swept = await sql<{ count: string }[]>`
|
||||||
|
WITH swept AS (
|
||||||
|
UPDATE messages SET status = 'failed'
|
||||||
|
WHERE status = 'streaming' AND created_at < NOW() - INTERVAL '5 minutes'
|
||||||
|
RETURNING id
|
||||||
|
) SELECT count(*)::text AS count FROM swept
|
||||||
|
`;
|
||||||
|
const sweptCount = Number(swept[0]?.count ?? 0);
|
||||||
|
if (sweptCount > 0) {
|
||||||
|
app.log.info({ sweptCount }, 'swept stale streaming messages to failed');
|
||||||
|
}
|
||||||
|
|
||||||
// v1.11.3: tell the model-context cache where llama-swap lives. Cache
|
// v1.11.3: tell the model-context cache where llama-swap lives. Cache
|
||||||
// lookups go to ${LLAMA_SWAP_URL}/upstream/<model>/props to read
|
// lookups go to ${LLAMA_SWAP_URL}/upstream/<model>/props to read
|
||||||
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
|
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ const CreateBody = z.object({
|
|||||||
agent_id: z.string().min(1).max(200).nullable().optional(),
|
agent_id: z.string().min(1).max(200).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const WorkspacePaneZ = z.object({
|
||||||
|
id: z.string().min(1).max(200),
|
||||||
|
kind: z.enum(['chat', 'terminal', 'agent', 'empty', 'settings']),
|
||||||
|
chatId: z.string().min(1).max(200).optional(),
|
||||||
|
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||||
|
activeChatIdx: z.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const WorkspacePanesBody = z.object({
|
||||||
|
workspace_panes: z.array(WorkspacePaneZ).max(10),
|
||||||
|
});
|
||||||
|
|
||||||
const PatchBody = z.object({
|
const PatchBody = z.object({
|
||||||
name: z.string().min(1).max(200).optional(),
|
name: z.string().min(1).max(200).optional(),
|
||||||
model: z.string().min(1).max(200).optional(),
|
model: z.string().min(1).max(200).optional(),
|
||||||
@@ -44,7 +56,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
|
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
|
||||||
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
|
||||||
@@ -92,7 +104,7 @@ export function registerSessionRoutes(
|
|||||||
const [session] = await tx<Session[]>`
|
const [session] = await tx<Session[]>`
|
||||||
INSERT INTO sessions (project_id, name, model, system_prompt, agent_id)
|
INSERT INTO sessions (project_id, name, model, system_prompt, agent_id)
|
||||||
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}, ${agentId})
|
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}, ${agentId})
|
||||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
|
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
|
||||||
`;
|
`;
|
||||||
await tx`
|
await tx`
|
||||||
INSERT INTO chats (session_id, name, status)
|
INSERT INTO chats (session_id, name, status)
|
||||||
@@ -112,7 +124,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
|
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
|
||||||
FROM sessions WHERE id = ${req.params.id}
|
FROM sessions WHERE id = ${req.params.id}
|
||||||
`;
|
`;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -158,7 +170,7 @@ export function registerSessionRoutes(
|
|||||||
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
|
agent_id, web_search_enabled, workspace_panes
|
||||||
`;
|
`;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
@@ -187,6 +199,36 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.patch<{ Params: { id: string } }>(
|
||||||
|
'/api/sessions/:id/workspace',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = WorkspacePanesBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
const rows = await sql<Session[]>`
|
||||||
|
UPDATE sessions
|
||||||
|
SET workspace_panes = ${sql.json(parsed.data.workspace_panes as never)},
|
||||||
|
updated_at = clock_timestamp()
|
||||||
|
WHERE id = ${req.params.id}
|
||||||
|
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||||
|
agent_id, web_search_enabled, workspace_panes
|
||||||
|
`;
|
||||||
|
if (rows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session not found' };
|
||||||
|
}
|
||||||
|
const session = rows[0]!;
|
||||||
|
broker.publishUser('default', {
|
||||||
|
type: 'session_workspace_updated',
|
||||||
|
session_id: session.id,
|
||||||
|
workspace_panes: session.workspace_panes,
|
||||||
|
});
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// v1.9: bulk-archive every open session in a project. Mirrors the
|
// v1.9: bulk-archive every open session in a project. Mirrors the
|
||||||
// single-archive shape (same broker frame type) so the existing useSidebar
|
// single-archive shape (same broker frame type) so the existing useSidebar
|
||||||
// reducer cases handle it without changes — just N frames instead of 1.
|
// reducer cases handle it without changes — just N frames instead of 1.
|
||||||
@@ -263,7 +305,7 @@ export function registerSessionRoutes(
|
|||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
UPDATE sessions SET status = 'open', updated_at = clock_timestamp()
|
UPDATE sessions SET status = 'open', updated_at = clock_timestamp()
|
||||||
WHERE id = ${req.params.id} AND status = 'archived'
|
WHERE id = ${req.params.id} AND status = 'archived'
|
||||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
|
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
|
||||||
`;
|
`;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
|
|||||||
@@ -47,22 +47,14 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
|
|
||||||
INSERT INTO settings (key, value) VALUES ('default_model', '"qwen3.6-35b-a3b-mxfp4"') ON CONFLICT (key) DO NOTHING;
|
INSERT INTO settings (key, value) VALUES ('default_model', '"qwen3.6-35b-a3b-mxfp4"') ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
-- DEPRECATED: client-side pane state as of v1.2-batch4. Table retained per
|
-- v1.12.1: deprecated session_panes table removed. Workspace pane state now
|
||||||
-- additive schema rule; no writes. Drop in a future destructive migration.
|
-- lives in sessions.workspace_panes (jsonb), see below.
|
||||||
CREATE TABLE IF NOT EXISTS session_panes (
|
DROP TABLE IF EXISTS session_panes;
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
||||||
position INTEGER NOT NULL,
|
|
||||||
kind TEXT NOT NULL CHECK (kind IN ('chat', 'file_browser', 'terminal')),
|
|
||||||
state JSONB NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
||||||
UNIQUE (session_id, position)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id);
|
|
||||||
|
|
||||||
-- v1.4: backfill removed. Pane layout is client-side (localStorage) since v1.2-batch4.
|
-- v1.12.1: server-side workspace pane layout, replaces localStorage so every
|
||||||
-- The CREATE TABLE above is retained for additive-schema discipline; drop is a
|
-- device sees the same panes for a given session. Shape matches
|
||||||
-- future destructive migration.
|
-- WorkspacePane[] from apps/server/src/types/api.ts.
|
||||||
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS workspace_panes JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
-- v1.2: sessions.status (open | archived)
|
-- v1.2: sessions.status (open | archived)
|
||||||
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';
|
||||||
@@ -128,6 +120,19 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
|
-- v1.12.1: drop stale inline CHECK constraints that were superseded by the
|
||||||
|
-- named *_chk variants above. messages_status_check missed 'cancelled' and
|
||||||
|
-- messages_role_check missed 'system' — both narrower than what's in use.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_status_check') THEN
|
||||||
|
ALTER TABLE messages DROP CONSTRAINT messages_status_check;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_role_check') THEN
|
||||||
|
ALTER TABLE messages DROP CONSTRAINT messages_role_check;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
-- v1.2-project-ux: projects.status + projects.gitea_remote
|
-- v1.2-project-ux: projects.status + projects.gitea_remote
|
||||||
-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES
|
-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES
|
||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
||||||
|
|||||||
@@ -295,9 +295,10 @@ describe('executeWebFetch — size + truncation', () => {
|
|||||||
// 1.5M U+1F600 emojis: each is length 2 in UTF-16 (surrogate pair) and
|
// 1.5M U+1F600 emojis: each is length 2 in UTF-16 (surrogate pair) and
|
||||||
// 4 bytes in UTF-8. body.length = 3,000,000 chars (~2.86 MiB by
|
// 4 bytes in UTF-8. body.length = 3,000,000 chars (~2.86 MiB by
|
||||||
// UTF-16 count) but Buffer.byteLength = 6,000,000 bytes (>5 MiB).
|
// UTF-16 count) but Buffer.byteLength = 6,000,000 bytes (>5 MiB).
|
||||||
// Pre-fix the char-count comparison let this through; the byte-count
|
// v1.11.10: streaming reader catches this as body_too_large (was
|
||||||
// check now rejects. No Content-Length header so the pre-flight
|
// response_too_large in the post-consumption check). No
|
||||||
// guard doesn't fire — we're testing the POST-consumption check.
|
// Content-Length header so the pre-flight pass and the streaming
|
||||||
|
// path is the one that rejects.
|
||||||
const heavy = '😀'.repeat(1_500_000);
|
const heavy = '😀'.repeat(1_500_000);
|
||||||
const fakeFetch = vi.fn().mockResolvedValue(
|
const fakeFetch = vi.fn().mockResolvedValue(
|
||||||
new Response(heavy, { status: 200, headers: { 'content-type': 'text/plain' } }),
|
new Response(heavy, { status: 200, headers: { 'content-type': 'text/plain' } }),
|
||||||
@@ -308,9 +309,8 @@ describe('executeWebFetch — size + truncation', () => {
|
|||||||
);
|
);
|
||||||
expect('error' in result).toBe(true);
|
expect('error' in result).toBe(true);
|
||||||
if ('error' in result) {
|
if ('error' in result) {
|
||||||
expect(result.error).toBe('response_too_large');
|
expect(result.error).toBe('body_too_large');
|
||||||
// Error reason should reference bytes, not character count.
|
expect(result.reason).toMatch(/exceeded/);
|
||||||
expect(result.reason).toMatch(/bytes/);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -453,3 +453,138 @@ describe('executeWebFetch — redirect handling', () => {
|
|||||||
expect(fakeFetch.mock.calls[1]![0]).toBe('https://example.com/foo');
|
expect(fakeFetch.mock.calls[1]![0]).toBe('https://example.com/foo');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// v1.11.10: streaming body cap — abort the response stream at MAX_BYTES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// MAX_BYTES is 5 * 1024 * 1024 = 5_242_880. Repeating this here (rather
|
||||||
|
// than importing) so a change to the cap surfaces as a test failure —
|
||||||
|
// the limit is part of the public contract.
|
||||||
|
const MAX_BYTES_TEST = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Build a Response whose body is a real ReadableStream. Uses pull() (not
|
||||||
|
// start()) so chunks are produced lazily — without backpressure, an
|
||||||
|
// unbounded start() enqueues everything and calls controller.close()
|
||||||
|
// before the consumer reads, which means a subsequent reader.cancel()
|
||||||
|
// finds the stream already closed and the cancel callback never fires.
|
||||||
|
// `cancelFlag` lets the test observe whether reader.cancel() reached the
|
||||||
|
// underlying source mid-stream.
|
||||||
|
function streamedResponse(
|
||||||
|
chunks: Uint8Array[],
|
||||||
|
init: { contentType?: string; contentLength?: number | null; cancelFlag?: { cancelled: boolean } } = {},
|
||||||
|
): Response {
|
||||||
|
let idx = 0;
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
pull(controller) {
|
||||||
|
if (idx >= chunks.length) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.enqueue(chunks[idx]!);
|
||||||
|
idx += 1;
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (init.cancelFlag) init.cancelFlag.cancelled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (init.contentType) headers['content-type'] = init.contentType;
|
||||||
|
if (init.contentLength !== undefined && init.contentLength !== null) {
|
||||||
|
headers['content-length'] = String(init.contentLength);
|
||||||
|
}
|
||||||
|
return new Response(stream, { status: 200, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('executeWebFetch — streaming body cap (v1.11.10)', () => {
|
||||||
|
it('aborts the stream when a server lies about Content-Length and emits over the cap', async () => {
|
||||||
|
// Honest header would have failed the pre-flight check. The lie is
|
||||||
|
// the point: pre-flight passes (100 < 5MB) and the streaming reader
|
||||||
|
// has to be the thing that catches the oversized body.
|
||||||
|
//
|
||||||
|
// Chunk count is deliberately higher than what the reader will
|
||||||
|
// consume (10 × 1MB available, but the reader will cancel after ~6
|
||||||
|
// chunks land it over 5MB). That headroom keeps the stream in
|
||||||
|
// 'readable' state at the moment reader.cancel() runs — otherwise
|
||||||
|
// a pull-then-close race could make the source close the stream
|
||||||
|
// before cancel reaches it, and the cancel() callback wouldn't fire.
|
||||||
|
const oneMB = new Uint8Array(1024 * 1024).fill(65); // 'A'
|
||||||
|
const tenMBInChunks = Array.from({ length: 10 }, () => oneMB);
|
||||||
|
const cancelFlag = { cancelled: false };
|
||||||
|
const fakeFetch = vi.fn().mockResolvedValue(
|
||||||
|
streamedResponse(tenMBInChunks, {
|
||||||
|
contentType: 'text/plain',
|
||||||
|
contentLength: 100,
|
||||||
|
cancelFlag,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const result = await executeWebFetch(
|
||||||
|
{ url: 'https://example.com/lying-server' },
|
||||||
|
fakeFetch as unknown as typeof fetch,
|
||||||
|
);
|
||||||
|
expect('error' in result).toBe(true);
|
||||||
|
if ('error' in result) {
|
||||||
|
expect(result.error).toBe('body_too_large');
|
||||||
|
expect(result.reason).toMatch(/exceeded/);
|
||||||
|
}
|
||||||
|
// Critical: reader.cancel() actually fired so the underlying
|
||||||
|
// connection / stream got released. Otherwise the abort would be
|
||||||
|
// notional and the server could keep streaming.
|
||||||
|
expect(cancelFlag.cancelled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches an oversized stream when Content-Length is omitted entirely', async () => {
|
||||||
|
// Many real servers (chunked transfer-encoding, dynamic responses)
|
||||||
|
// never send Content-Length. The pre-flight check has nothing to
|
||||||
|
// gate on; the streaming reader is the only line of defense.
|
||||||
|
// 10 chunks vs the ~6 the reader will consume — same headroom
|
||||||
|
// rationale as the lying-Content-Length test above.
|
||||||
|
const oneMB = new Uint8Array(1024 * 1024).fill(66); // 'B'
|
||||||
|
const tenMBInChunks = Array.from({ length: 10 }, () => oneMB);
|
||||||
|
const fakeFetch = vi.fn().mockResolvedValue(
|
||||||
|
streamedResponse(tenMBInChunks, { contentType: 'text/plain' }),
|
||||||
|
);
|
||||||
|
const result = await executeWebFetch(
|
||||||
|
{ url: 'https://example.com/no-length' },
|
||||||
|
fakeFetch as unknown as typeof fetch,
|
||||||
|
);
|
||||||
|
expect('error' in result && result.error).toBe('body_too_large');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a multi-chunk body that totals just under the cap', async () => {
|
||||||
|
// Boundary case: MAX_BYTES - 1 bytes split across N chunks. The
|
||||||
|
// streaming reader's `total > maxBytes` check is strict-greater so
|
||||||
|
// exactly MAX_BYTES would still succeed; MAX_BYTES + 1 would fail.
|
||||||
|
// - 1 leaves clear headroom without coinciding with the boundary.
|
||||||
|
const targetTotal = MAX_BYTES_TEST - 1;
|
||||||
|
const chunkSize = 256 * 1024; // 256 KiB chunks
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let remaining = targetTotal;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const size = Math.min(chunkSize, remaining);
|
||||||
|
chunks.push(new Uint8Array(size).fill(67)); // 'C'
|
||||||
|
remaining -= size;
|
||||||
|
}
|
||||||
|
const fakeFetch = vi.fn().mockResolvedValue(
|
||||||
|
streamedResponse(chunks, { contentType: 'text/plain' }),
|
||||||
|
);
|
||||||
|
const result = await executeWebFetch(
|
||||||
|
{ url: 'https://example.com/right-at-cap' },
|
||||||
|
fakeFetch as unknown as typeof fetch,
|
||||||
|
);
|
||||||
|
// The streaming reader succeeded — we got a content shape, not an
|
||||||
|
// error. (Downstream truncate() will clamp the final string to
|
||||||
|
// MAX_CHARS_CAP=32000 and set truncated:true; that's the existing
|
||||||
|
// truncation logic and is exercised by its own test. The point of
|
||||||
|
// THIS test is that readBodyCapped didn't trip on a body that
|
||||||
|
// sits just under its byte limit.)
|
||||||
|
expect('content' in result).toBe(true);
|
||||||
|
if ('content' in result) {
|
||||||
|
expect(result.content.length).toBeGreaterThan(0);
|
||||||
|
// All ASCII 'C's, so the leading 200 chars before any truncation
|
||||||
|
// marker should be all C — proves we read real bytes through the
|
||||||
|
// streaming reader rather than getting an empty buffer.
|
||||||
|
expect(result.content.slice(0, 200)).toBe('C'.repeat(200));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -801,6 +801,17 @@ async function handleAbortOrError(
|
|||||||
// genuine errors flip the dot red. v1.8.2: error path also carries a
|
// genuine errors flip the dot red. v1.8.2: error path also carries a
|
||||||
// machine-readable `reason` so the UI can render specifics inline.
|
// machine-readable `reason` so the UI can render specifics inline.
|
||||||
if (isAbort) {
|
if (isAbort) {
|
||||||
|
// v1.12.1: defensive cancellation write. The status=${finalStatus} UPDATE
|
||||||
|
// above already sets 'cancelled' for the AbortError case, but a row can
|
||||||
|
// leak as 'streaming' when the abort fires between the post-tool-phase
|
||||||
|
// INSERT (executeToolPhase) and the next runAssistantTurn's stream setup,
|
||||||
|
// bypassing the try/catch around executeStreamPhase. The status guard
|
||||||
|
// makes this a no-op when the earlier write already landed.
|
||||||
|
await ctx.sql`
|
||||||
|
UPDATE messages
|
||||||
|
SET status = 'cancelled', content = ${accumulated}, finished_at = clock_timestamp()
|
||||||
|
WHERE id = ${args.assistantMessageId} AND status = 'streaming'
|
||||||
|
`;
|
||||||
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
|
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
|
||||||
ctx.publish(sessionId, {
|
ctx.publish(sessionId, {
|
||||||
type: 'message_complete',
|
type: 'message_complete',
|
||||||
@@ -894,6 +905,7 @@ async function executeToolPhase(
|
|||||||
// pre-stamped with output=null as a "pending" sentinel and no tool_result
|
// pre-stamped with output=null as a "pending" sentinel and no tool_result
|
||||||
// frame goes out — the card renders from the tool_call frame alone. Mixed
|
// frame goes out — the card renders from the tool_call frame alone. Mixed
|
||||||
// batches still execute the other tools normally.
|
// batches still execute the other tools normally.
|
||||||
|
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'tool_running', at: new Date().toISOString() });
|
||||||
let pausingForUserInput = false;
|
let pausingForUserInput = false;
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
toolCalls.map(async (tc) => {
|
toolCalls.map(async (tc) => {
|
||||||
@@ -938,13 +950,10 @@ async function executeToolPhase(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (pausingForUserInput) {
|
if (pausingForUserInput) {
|
||||||
// Drop the dot back to idle — the card is the actionable surface now.
|
|
||||||
// The next inference turn fires from POST /api/chats/:id/answer_user_input
|
|
||||||
// once the user submits their answers.
|
|
||||||
ctx.publishUser({
|
ctx.publishUser({
|
||||||
type: 'chat_status',
|
type: 'chat_status',
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
status: 'idle',
|
status: 'waiting_for_input',
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
ctx.log.info(
|
ctx.log.info(
|
||||||
@@ -1677,7 +1686,7 @@ export function createInferenceRunner(
|
|||||||
};
|
};
|
||||||
// v1.8 mobile-tabs: announce working before the async loop starts so
|
// v1.8 mobile-tabs: announce working before the async loop starts so
|
||||||
// every device subscribed to the user channel sees the amber dot.
|
// every device subscribed to the user channel sees the amber dot.
|
||||||
callCtx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'working', at: new Date().toISOString() });
|
callCtx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'streaming', at: new Date().toISOString() });
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let resolveCompleted!: () => void;
|
let resolveCompleted!: () => void;
|
||||||
const completed = new Promise<void>((res) => { resolveCompleted = res; });
|
const completed = new Promise<void>((res) => { resolveCompleted = res; });
|
||||||
|
|||||||
@@ -62,6 +62,39 @@ function stripHtml(html: string): { text: string; title: string | undefined } {
|
|||||||
return { text, title };
|
return { text, title };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.11.10: streaming body reader. Aborts the response stream the instant
|
||||||
|
// cumulative bytes cross maxBytes, so a server that lies about
|
||||||
|
// Content-Length (or omits it entirely) can't make us buffer gigabytes
|
||||||
|
// before the post-read check fires. reader.cancel() releases the
|
||||||
|
// underlying connection on the spot.
|
||||||
|
async function readBodyCapped(
|
||||||
|
res: Response,
|
||||||
|
maxBytes: number,
|
||||||
|
): Promise<{ ok: true; body: string } | { ok: false; bytesRead: number }> {
|
||||||
|
if (!res.body) return { ok: true, body: '' };
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
total += value.byteLength;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
// Best-effort cancel — surfaces on the server side as a closed
|
||||||
|
// connection and (in our tests) fires the ReadableStream's
|
||||||
|
// cancel() callback so we can assert the abort happened.
|
||||||
|
await reader.cancel();
|
||||||
|
return { ok: false, bytesRead: total };
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { reader.releaseLock(); } catch { /* already released by cancel() */ }
|
||||||
|
}
|
||||||
|
return { ok: true, body: Buffer.concat(chunks).toString('utf8') };
|
||||||
|
}
|
||||||
|
|
||||||
function truncate(text: string, max: number): { content: string; truncated: boolean } {
|
function truncate(text: string, max: number): { content: string; truncated: boolean } {
|
||||||
if (text.length <= max) return { content: text, truncated: false };
|
if (text.length <= max) return { content: text, truncated: false };
|
||||||
const omitted = text.length - max;
|
const omitted = text.length - max;
|
||||||
@@ -159,19 +192,20 @@ export async function executeWebFetch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const contentType = (res.headers.get('content-type') ?? '').toLowerCase();
|
const contentType = (res.headers.get('content-type') ?? '').toLowerCase();
|
||||||
// Read body. We rely on the 5MB cap by checking length after consumption
|
// v1.11.10: stream the body with a hard byte cap. Previously we read
|
||||||
// — most malicious or accidental large responses also exceed it via the
|
// res.text() in one shot and then byte-length-checked — a server that
|
||||||
// Content-Length pre-flight above. A truly hostile server that lies
|
// lies about Content-Length (or omits it) could make us buffer
|
||||||
// about length AND streams gigabytes would defeat that; the per-hop
|
// gigabytes before the post-check fired. readBodyCapped aborts the
|
||||||
// 15s timeout is the secondary fence.
|
// stream the instant total bytes cross MAX_BYTES. The Content-Length
|
||||||
const body = await res.text();
|
// pre-flight above stays as a cheap early reject for honest servers.
|
||||||
// v1.11.8 review: byte-count, not char-count. A 5MB cap on body.length
|
const read = await readBodyCapped(res, MAX_BYTES);
|
||||||
// (UTF-16 code units) lets a multi-byte payload (emoji, CJK) pass when
|
if (!read.ok) {
|
||||||
// its wire size already exceeded MAX_BYTES.
|
return {
|
||||||
const bodyBytes = Buffer.byteLength(body, 'utf8');
|
error: 'body_too_large',
|
||||||
if (bodyBytes > MAX_BYTES) {
|
reason: `Response body exceeded ${MAX_BYTES} bytes (read ${read.bytesRead} before abort)`,
|
||||||
return { error: 'response_too_large', reason: `body ${bodyBytes} bytes > ${MAX_BYTES}` };
|
};
|
||||||
}
|
}
|
||||||
|
const body = read.body;
|
||||||
|
|
||||||
let textRaw: string;
|
let textRaw: string;
|
||||||
let title: string | undefined;
|
let title: string | undefined;
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ export interface Session {
|
|||||||
// project.default_web_search_enabled. Plumbed but inert in v1.9 — the
|
// project.default_web_search_enabled. Plumbed but inert in v1.9 — the
|
||||||
// actual web_search tool ships in Batch 8.
|
// actual web_search tool ships in Batch 8.
|
||||||
web_search_enabled: boolean | null;
|
web_search_enabled: boolean | null;
|
||||||
|
// v1.12.1: server-side workspace pane layout. Replaces per-device
|
||||||
|
// localStorage so all devices viewing the session see the same panes.
|
||||||
|
workspace_panes: WorkspacePane[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
|
||||||
|
|
||||||
|
export interface WorkspacePane {
|
||||||
|
id: string;
|
||||||
|
kind: WorkspacePaneKind;
|
||||||
|
chatId?: string;
|
||||||
|
chatIds: string[];
|
||||||
|
activeChatIdx: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.8.1: agents come from two sources. 'global' = /data/AGENTS.md (always
|
// v1.8.1: agents come from two sources. 'global' = /data/AGENTS.md (always
|
||||||
@@ -273,6 +286,11 @@ export interface SessionRenamedFrame {
|
|||||||
session_id: string;
|
session_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
export interface SessionWorkspaceUpdatedFrame {
|
||||||
|
type: 'session_workspace_updated';
|
||||||
|
session_id: string;
|
||||||
|
workspace_panes: WorkspacePane[];
|
||||||
|
}
|
||||||
export interface SessionArchivedFrame {
|
export interface SessionArchivedFrame {
|
||||||
type: 'session_archived';
|
type: 'session_archived';
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -324,7 +342,7 @@ export interface ProjectUpdatedFrame {
|
|||||||
export interface ChatStatusFrame {
|
export interface ChatStatusFrame {
|
||||||
type: 'chat_status';
|
type: 'chat_status';
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
status: 'working' | 'idle' | 'error';
|
status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error';
|
||||||
at: string;
|
at: string;
|
||||||
reason?: ErrorReason;
|
reason?: ErrorReason;
|
||||||
}
|
}
|
||||||
@@ -335,6 +353,7 @@ export type UserStreamFrame =
|
|||||||
| SessionDeletedFrame
|
| SessionDeletedFrame
|
||||||
| SessionUpdatedFrame
|
| SessionUpdatedFrame
|
||||||
| SessionRenamedFrame
|
| SessionRenamedFrame
|
||||||
|
| SessionWorkspaceUpdatedFrame
|
||||||
| SessionArchivedFrame
|
| SessionArchivedFrame
|
||||||
| ChatCreatedFrame
|
| ChatCreatedFrame
|
||||||
| ChatUpdatedFrame
|
| ChatUpdatedFrame
|
||||||
|
|||||||
@@ -143,6 +143,11 @@ export const api = {
|
|||||||
),
|
),
|
||||||
openChatsCount: (id: string) =>
|
openChatsCount: (id: string) =>
|
||||||
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
|
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
|
||||||
|
updateWorkspacePanes: (id: string, panes: Session['workspace_panes']) =>
|
||||||
|
request<Session>(`/api/sessions/${id}/workspace`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ workspace_panes: panes }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
chats: {
|
chats: {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface Session {
|
|||||||
agent_id: string | null;
|
agent_id: string | null;
|
||||||
// v1.9: null = inherit from project.default_web_search_enabled.
|
// v1.9: null = inherit from project.default_web_search_enabled.
|
||||||
web_search_enabled: boolean | null;
|
web_search_enabled: boolean | null;
|
||||||
|
// v1.12.1: server-authoritative pane layout, replaces localStorage.
|
||||||
|
workspace_panes: WorkspacePane[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
|
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
|
||||||
|
|||||||
@@ -6,15 +6,10 @@ interface Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_CLASS: Record<DerivedStatus, string> = {
|
|
||||||
working: 'bg-amber-500 animate-pulse',
|
|
||||||
idle_warm: 'bg-emerald-500',
|
|
||||||
idle_cold: 'bg-muted-foreground/40',
|
|
||||||
error: 'bg-destructive',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABEL: Record<DerivedStatus, string> = {
|
const STATUS_LABEL: Record<DerivedStatus, string> = {
|
||||||
working: 'working',
|
streaming: 'streaming',
|
||||||
|
tool_running: 'running tool',
|
||||||
|
waiting_for_input: 'waiting for input',
|
||||||
idle_warm: 'idle',
|
idle_warm: 'idle',
|
||||||
idle_cold: 'idle',
|
idle_cold: 'idle',
|
||||||
error: 'error',
|
error: 'error',
|
||||||
@@ -22,15 +17,58 @@ const STATUS_LABEL: Record<DerivedStatus, string> = {
|
|||||||
|
|
||||||
export function StatusDot({ chatId, className }: Props) {
|
export function StatusDot({ chatId, className }: Props) {
|
||||||
const status = useChatStatus(chatId);
|
const status = useChatStatus(chatId);
|
||||||
|
|
||||||
|
if (status === 'streaming') {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
aria-label={`Status: ${STATUS_LABEL[status]}`}
|
aria-label="Status: streaming"
|
||||||
title={STATUS_LABEL[status]}
|
title="streaming"
|
||||||
|
className={cn('inline-block relative w-3 h-3 shrink-0', className)}
|
||||||
|
>
|
||||||
|
<span className="absolute inset-0 animate-spin-slow">
|
||||||
|
<span className="absolute top-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-amber-500" />
|
||||||
|
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-amber-500/60" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'tool_running') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label="Status: running tool"
|
||||||
|
title="running tool"
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-block w-1.5 h-1.5 rounded-full shrink-0',
|
'inline-block w-3 h-3 rounded-full border-2 border-sky-500 border-t-transparent animate-spin shrink-0',
|
||||||
STATUS_CLASS[status],
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'waiting_for_input') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label="Status: waiting for input"
|
||||||
|
title="waiting for input"
|
||||||
|
className={cn(
|
||||||
|
'inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-violet-500',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bg =
|
||||||
|
status === 'idle_warm' ? 'bg-emerald-500'
|
||||||
|
: status === 'error' ? 'bg-destructive'
|
||||||
|
: 'bg-muted-foreground/40';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label={`Status: ${STATUS_LABEL[status]}`}
|
||||||
|
title={STATUS_LABEL[status]}
|
||||||
|
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', bg, className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ export interface SessionUpdatedEvent {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionWorkspaceUpdatedEvent {
|
||||||
|
type: 'session_workspace_updated';
|
||||||
|
session_id: string;
|
||||||
|
workspace_panes: import('@/api/types').WorkspacePane[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionLoadedEvent {
|
export interface SessionLoadedEvent {
|
||||||
type: 'session_loaded';
|
type: 'session_loaded';
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -131,7 +137,7 @@ export interface ProjectUpdatedEvent {
|
|||||||
export interface ChatStatusEvent {
|
export interface ChatStatusEvent {
|
||||||
type: 'chat_status';
|
type: 'chat_status';
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
status: 'working' | 'idle' | 'error';
|
status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error';
|
||||||
at: string;
|
at: string;
|
||||||
reason?: ErrorReason;
|
reason?: ErrorReason;
|
||||||
}
|
}
|
||||||
@@ -143,6 +149,7 @@ export type SessionEvent =
|
|||||||
| SessionCreatedEvent
|
| SessionCreatedEvent
|
||||||
| SessionDeletedEvent
|
| SessionDeletedEvent
|
||||||
| SessionUpdatedEvent
|
| SessionUpdatedEvent
|
||||||
|
| SessionWorkspaceUpdatedEvent
|
||||||
| SessionLoadedEvent
|
| SessionLoadedEvent
|
||||||
| OpenFileInBrowserEvent
|
| OpenFileInBrowserEvent
|
||||||
| AttachChatFileEvent
|
| AttachChatFileEvent
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { sessionEvents } from './sessionEvents';
|
import { sessionEvents } from './sessionEvents';
|
||||||
|
|
||||||
export type RawStatus = 'working' | 'idle' | 'error';
|
export type RawStatus = 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error';
|
||||||
export type DerivedStatus = 'working' | 'idle_warm' | 'idle_cold' | 'error';
|
export type DerivedStatus =
|
||||||
|
| 'streaming'
|
||||||
|
| 'tool_running'
|
||||||
|
| 'waiting_for_input'
|
||||||
|
| 'idle_warm'
|
||||||
|
| 'idle_cold'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
// Window during which an idle dot stays green; after this, it fades to gray.
|
// Window during which an idle dot stays green; after this, it fades to gray.
|
||||||
const WARM_WINDOW_MS = 30_000;
|
const WARM_WINDOW_MS = 30_000;
|
||||||
@@ -53,7 +59,9 @@ if (!G.__boocode_chat_status_subscribed) {
|
|||||||
|
|
||||||
function derive(entry: Entry | undefined): DerivedStatus {
|
function derive(entry: Entry | undefined): DerivedStatus {
|
||||||
if (!entry) return 'idle_cold';
|
if (!entry) return 'idle_cold';
|
||||||
if (entry.status === 'working') return 'working';
|
if (entry.status === 'streaming') return 'streaming';
|
||||||
|
if (entry.status === 'tool_running') return 'tool_running';
|
||||||
|
if (entry.status === 'waiting_for_input') return 'waiting_for_input';
|
||||||
if (entry.status === 'error') return 'error';
|
if (entry.status === 'error') return 'error';
|
||||||
const age = Date.now() - new Date(entry.at).getTime();
|
const age = Date.now() - new Date(entry.at).getTime();
|
||||||
return age < WARM_WINDOW_MS ? 'idle_warm' : 'idle_cold';
|
return age < WARM_WINDOW_MS ? 'idle_warm' : 'idle_cold';
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface UseSessionChatsOpts {
|
|||||||
// about pane indexing.
|
// about pane indexing.
|
||||||
openChatInActivePane: (chatId: string) => void;
|
openChatInActivePane: (chatId: string) => void;
|
||||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||||
|
validatePanes: (validChatIds: Set<string>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseSessionChatsResult {
|
export interface UseSessionChatsResult {
|
||||||
@@ -44,12 +45,15 @@ export function useSessionChats(
|
|||||||
openChatInActivePaneRef.current = opts.openChatInActivePane;
|
openChatInActivePaneRef.current = opts.openChatInActivePane;
|
||||||
const initializeFirstChatIfEmptyRef = useRef(opts.initializeFirstChatIfEmpty);
|
const initializeFirstChatIfEmptyRef = useRef(opts.initializeFirstChatIfEmpty);
|
||||||
initializeFirstChatIfEmptyRef.current = opts.initializeFirstChatIfEmpty;
|
initializeFirstChatIfEmptyRef.current = opts.initializeFirstChatIfEmpty;
|
||||||
|
const validatePanesRef = useRef(opts.validatePanes);
|
||||||
|
validatePanesRef.current = opts.validatePanes;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api.chats.listForSession(sessionId).then((list) => {
|
api.chats.listForSession(sessionId).then((list) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setChats(list);
|
setChats(list);
|
||||||
|
validatePanesRef.current(new Set(list.map((c) => c.id)));
|
||||||
const openChat = list.find((c) => c.status === 'open');
|
const openChat = list.find((c) => c.status === 'open');
|
||||||
if (openChat) {
|
if (openChat) {
|
||||||
initializeFirstChatIfEmptyRef.current(openChat.id);
|
initializeFirstChatIfEmptyRef.current(openChat.id);
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
case 'session_loaded':
|
case 'session_loaded':
|
||||||
// activeSessionProjectId is updated in the subscribe callback; no data change here.
|
// activeSessionProjectId is updated in the subscribe callback; no data change here.
|
||||||
return prev;
|
return prev;
|
||||||
|
case 'session_workspace_updated':
|
||||||
|
// Pane layout is consumed by useWorkspacePanes; sidebar has no stake.
|
||||||
|
return prev;
|
||||||
case 'open_file_in_browser':
|
case 'open_file_in_browser':
|
||||||
// Consumed by Workspace (T7); no sidebar state change needed.
|
// Consumed by Workspace (T7); no sidebar state change needed.
|
||||||
return prev;
|
return prev;
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import { toast } from 'sonner';
|
|||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { WorkspacePane } from '@/api/types';
|
import type { WorkspacePane } from '@/api/types';
|
||||||
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||||
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
|
||||||
export const MAX_PANES = 5;
|
export const MAX_PANES = 5;
|
||||||
const STORAGE_KEY = 'boocode.workspace.panes';
|
// v1.12.1: legacy localStorage key. Read once on mount to seed the server
|
||||||
|
// for sessions still on per-device state, then deleted. Server is now
|
||||||
|
// authoritative via sessions.workspace_panes.
|
||||||
|
const LEGACY_STORAGE_KEY = 'boocode.workspace.panes';
|
||||||
|
const SAVE_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@@ -51,9 +56,11 @@ function nonSettingsCount(panes: WorkspacePane[]): number {
|
|||||||
return panes.reduce((n, p) => n + (p.kind === 'settings' ? 0 : 1), 0);
|
return panes.reduce((n, p) => n + (p.kind === 'settings' ? 0 : 1), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadPanes(sessionId: string): WorkspacePane[] | null {
|
// v1.12.1: read legacy per-device localStorage. If present, the caller seeds
|
||||||
|
// the server then deletes the key. One-time migration per session.
|
||||||
|
function readLegacyPanes(sessionId: string): WorkspacePane[] | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
|
const raw = localStorage.getItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const parsed = JSON.parse(raw) as WorkspacePane[];
|
const parsed = JSON.parse(raw) as WorkspacePane[];
|
||||||
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
||||||
@@ -63,15 +70,6 @@ function loadPanes(sessionId: string): WorkspacePane[] | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
`${STORAGE_KEY}.${sessionId}`,
|
|
||||||
JSON.stringify(persistablePanes(panes)),
|
|
||||||
);
|
|
||||||
} catch { /* quota or disabled */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseWorkspacePanesResult {
|
export interface UseWorkspacePanesResult {
|
||||||
panes: WorkspacePane[];
|
panes: WorkspacePane[];
|
||||||
activePaneIdx: number;
|
activePaneIdx: number;
|
||||||
@@ -96,6 +94,7 @@ export interface UseWorkspacePanesResult {
|
|||||||
removePane: (idx: number) => void;
|
removePane: (idx: number) => void;
|
||||||
removeChatFromPanes: (chatId: string) => void;
|
removeChatFromPanes: (chatId: string) => void;
|
||||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||||
|
validatePanes: (validChatIds: Set<string>) => void;
|
||||||
handlePaneDragStart: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
handlePaneDragStart: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
||||||
handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
||||||
handlePaneDragLeave: () => void;
|
handlePaneDragLeave: () => void;
|
||||||
@@ -106,15 +105,85 @@ export interface UseWorkspacePanesResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||||
const [panes, setPanes] = useState<WorkspacePane[]>(() => {
|
const [panes, setPanes] = useState<WorkspacePane[]>(() => [emptyPane()]);
|
||||||
return loadPanes(sessionId) ?? [emptyPane()];
|
|
||||||
});
|
|
||||||
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
||||||
const draggingIdxRef = useRef<number | null>(null);
|
const draggingIdxRef = useRef<number | null>(null);
|
||||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||||
|
// v1.12.1: skip PATCH while hydrating from the server. Without this, the
|
||||||
|
// initial [emptyPane()] would be saved over the server's real state before
|
||||||
|
// the GET resolves.
|
||||||
|
const hydratedRef = useRef(false);
|
||||||
|
// Tracks the last value broadcast by another device (or this one's own
|
||||||
|
// round-trip). If a PATCH would echo this exact payload, we skip the call.
|
||||||
|
const lastRemoteJsonRef = useRef<string>('[]');
|
||||||
|
|
||||||
|
// v1.12.1: hydrate from server on mount, then subscribe to remote updates.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
savePanes(sessionId, panes);
|
hydratedRef.current = false;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const session = await api.sessions.get(sessionId);
|
||||||
|
if (cancelled) return;
|
||||||
|
let initial: WorkspacePane[] = Array.isArray(session.workspace_panes)
|
||||||
|
? session.workspace_panes
|
||||||
|
: [];
|
||||||
|
// One-time migration: if server is empty but legacy localStorage has
|
||||||
|
// a layout, seed the server and delete the local key.
|
||||||
|
if (initial.length === 0) {
|
||||||
|
const legacy = readLegacyPanes(sessionId);
|
||||||
|
if (legacy && legacy.length > 0) {
|
||||||
|
try {
|
||||||
|
const updated = await api.sessions.updateWorkspacePanes(sessionId, legacy);
|
||||||
|
if (cancelled) return;
|
||||||
|
initial = updated.workspace_panes;
|
||||||
|
localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
|
||||||
|
} catch {
|
||||||
|
initial = legacy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next = initial.length > 0 ? initial : [emptyPane()];
|
||||||
|
lastRemoteJsonRef.current = JSON.stringify(persistablePanes(next));
|
||||||
|
setPanes(next);
|
||||||
|
setActivePaneIdx(0);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) hydratedRef.current = true;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// v1.12.1: live cross-device sync. Replace local state when another device
|
||||||
|
// (or our own write echo) lands a session_workspace_updated frame.
|
||||||
|
useEffect(() => {
|
||||||
|
return sessionEvents.subscribe((ev) => {
|
||||||
|
if (ev.type !== 'session_workspace_updated') return;
|
||||||
|
if (ev.session_id !== sessionId) return;
|
||||||
|
const incoming = Array.isArray(ev.workspace_panes) ? ev.workspace_panes : [];
|
||||||
|
const json = JSON.stringify(incoming);
|
||||||
|
if (json === lastRemoteJsonRef.current) return;
|
||||||
|
lastRemoteJsonRef.current = json;
|
||||||
|
setPanes(incoming.length > 0 ? incoming : [emptyPane()]);
|
||||||
|
setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
|
||||||
|
});
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// v1.12.1: debounced PATCH on every change. Settings panes are stripped
|
||||||
|
// before saving (ephemeral per v1.9).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydratedRef.current) return;
|
||||||
|
const payload = persistablePanes(panes);
|
||||||
|
const json = JSON.stringify(payload);
|
||||||
|
if (json === lastRemoteJsonRef.current) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
lastRemoteJsonRef.current = json;
|
||||||
|
api.sessions.updateWorkspacePanes(sessionId, payload).catch(() => {
|
||||||
|
// Non-fatal: next change retries. Persistent failures surface via
|
||||||
|
// the network layer's existing reconnect toast.
|
||||||
|
});
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}, [sessionId, panes]);
|
}, [sessionId, panes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -328,6 +397,23 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const validatePanes = useCallback((validChatIds: Set<string>) => {
|
||||||
|
setPanes((prev) => {
|
||||||
|
const cleaned = prev.map((pane) => {
|
||||||
|
if (pane.kind !== 'chat' || pane.chatIds.length === 0) return pane;
|
||||||
|
const nextIds = pane.chatIds.filter((id) => validChatIds.has(id));
|
||||||
|
if (nextIds.length === pane.chatIds.length) return pane;
|
||||||
|
if (nextIds.length === 0) {
|
||||||
|
return { ...pane, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||||
|
}
|
||||||
|
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
||||||
|
return { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx] };
|
||||||
|
});
|
||||||
|
const unchanged = cleaned.every((p, i) => p === prev[i]);
|
||||||
|
return unchanged ? prev : cleaned;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const removeChatFromPanes = useCallback((chatId: string) => {
|
const removeChatFromPanes = useCallback((chatId: string) => {
|
||||||
setPanes((prev) => prev.map((p) => {
|
setPanes((prev) => prev.map((p) => {
|
||||||
const idx = p.chatIds.indexOf(chatId);
|
const idx = p.chatIds.indexOf(chatId);
|
||||||
@@ -411,6 +497,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
removePane,
|
removePane,
|
||||||
removeChatFromPanes,
|
removeChatFromPanes,
|
||||||
initializeFirstChatIfEmpty,
|
initializeFirstChatIfEmpty,
|
||||||
|
validatePanes,
|
||||||
handlePaneDragStart,
|
handlePaneDragStart,
|
||||||
handlePaneDragOver,
|
handlePaneDragOver,
|
||||||
handlePaneDragLeave,
|
handlePaneDragLeave,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
removePane,
|
removePane,
|
||||||
removeChatFromPanes,
|
removeChatFromPanes,
|
||||||
initializeFirstChatIfEmpty,
|
initializeFirstChatIfEmpty,
|
||||||
|
validatePanes,
|
||||||
} = panesHook;
|
} = panesHook;
|
||||||
|
|
||||||
const openChatInActivePane = useCallback(
|
const openChatInActivePane = useCallback(
|
||||||
@@ -70,6 +71,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
openChatInPane,
|
openChatInPane,
|
||||||
openChatInActivePane,
|
openChatInActivePane,
|
||||||
initializeFirstChatIfEmpty,
|
initializeFirstChatIfEmpty,
|
||||||
|
validatePanes,
|
||||||
});
|
});
|
||||||
const { chats, renameChat } = chatsHook;
|
const { chats, renameChat } = chatsHook;
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
--font-sans: "Inter Variable", "Inter", system-ui, sans-serif;
|
--font-sans: "Inter Variable", "Inter", system-ui, sans-serif;
|
||||||
--font-mono: "JetBrains Mono Variable", ui-monospace, SFMono-Regular, monospace;
|
--font-mono: "JetBrains Mono Variable", ui-monospace, SFMono-Regular, monospace;
|
||||||
|
--animate-spin-slow: spin 1.2s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# BooCode v1.x — Roadmap
|
# BooCode v1.x — Roadmap
|
||||||
|
|
||||||
Last updated: 2026-05-20
|
Last updated: 2026-05-21
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ Live at `https://code.indifferentketchup.com` (Caddy → Authelia → Tailscale
|
|||||||
|
|
||||||
**Architectural commitments:**
|
**Architectural commitments:**
|
||||||
|
|
||||||
- No embeddings. The model uses file-view tools (`view_file`, `list_dir`, `grep`, `find_files`) + sidecar analyzers (codecontext, codesight). Walked away from the RAG pipeline May 2026.
|
- No embeddings. Model uses file-view tools (`view_file`, `list_dir`, `grep`, `find_files`) + sidecar analyzers (codecontext, codesight) + codecontext MCP tools. Walked away from the RAG pipeline May 2026.
|
||||||
- Read-only in v1.x. Write tools land in BooCoder (separate container, post-v1.x).
|
- Read-only in v1.x. Write tools land in BooCoder (separate container, post-v1.x).
|
||||||
- One Postgres (`boocode_db`), one frontend SPA, container-per-service for new capabilities.
|
- One Postgres (`boocode_db`), one frontend SPA, container-per-service for new capabilities.
|
||||||
|
|
||||||
@@ -18,136 +18,87 @@ External code lifted from / referenced in: see `boocode_code_review.md` for full
|
|||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
## Shipped (status as of 2026-05-20)
|
## Shipped (status as of 2026-05-21)
|
||||||
|
|
||||||
| Version | Theme | Notes |
|
| Version | Theme | Tag |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v1.0 | Initial scaffold | live |
|
| v1.0 | Initial scaffold | — |
|
||||||
| Batches 1–4.4 | Markdown, sidebar, panes, chats-inside-sessions, archive, fork/delete, header polish, settings drawer | merged |
|
| Batches 1–4.4 | Markdown, sidebar, panes, chats-inside-sessions, archive, fork/delete, header polish, settings drawer | — |
|
||||||
| v1.5 | resolveProjectPath, BOOTSTRAP_ROOT, vitest pin | merged |
|
| v1.5 | resolveProjectPath, BOOTSTRAP_ROOT, vitest pin | — |
|
||||||
| v1.6, v1.6.1, v1.6.2 | Mobile pass + RightRail mobile drawer | merged |
|
| v1.6, v1.6.1, v1.6.2 | Mobile pass + RightRail mobile drawer | — |
|
||||||
| v1.7 | Drag-drop file + paste-as-attachment | merged |
|
| v1.7 | Drag-drop file + paste-as-attachment | — |
|
||||||
| v1.8, v1.8.1, v1.8.2 | Settings drawer, git_status tool, WS reconnect, **per-turn budget reset + Continue affordance + CapHitSentinel** | merged |
|
| v1.8, v1.8.1, v1.8.2 | Settings drawer, git_status tool, WS reconnect, per-turn budget reset + Continue affordance + CapHitSentinel | — |
|
||||||
| v1.9.1 | Skills system (`/opt/skills/` + `skill_find`/`skill_use`/`skill_resource` tools + `/skill` slash command) | merged |
|
| v1.9.1 | Skills system (`/opt/skills/` + `skill_find` / `skill_use` / `skill_resource` + `/skill` slash command) | `v1.9.1` |
|
||||||
| v1.9.7 | `ask_user_input` elicitation tool | merged |
|
| v1.9.7 | `ask_user_input` elicitation tool | `v1.9.7` |
|
||||||
| **Batch 9 (Agents Tier 2)** | `AGENTS.md` + 6 builtin agents + AgentPicker in ChatInput toolbar + `sessions.agent_id` | **merged in `92bd3b1`**, included in v1.9.1/v1.9.7/v1.10.x tags |
|
| Batch 9 (Agents Tier 2) | `AGENTS.md` + 6 builtin agents + AgentPicker in ChatInput toolbar + `sessions.agent_id` | folded into `v1.9.1`/`v1.9.7` |
|
||||||
| v1.10.0 | BooTerm: separate container, xterm.js + node-pty + tmux | merged |
|
| v1.10.0 | BooTerm: separate container, xterm.js + node-pty + tmux | `v1.10.0` |
|
||||||
| v1.10.1 | BooTerm-user (spawn as samkintop, login bash, Claude Code/opencode PATH) | merged |
|
| v1.10.1 | BooTerm-user (spawn as samkintop, login bash, Claude Code/opencode PATH) | `v1.10.1` |
|
||||||
| v1.10.4, v1.10.5 | Mobile terminal + XML tool-call fallback parser | merged |
|
| v1.10.4, v1.10.5 | Mobile terminal + XML tool-call fallback parser | — |
|
||||||
| **v1.11.0** | **opencode-style compaction port** (auto-overflow, anchored summary, tail preservation) | merged |
|
| v1.11.0 | opencode-style compaction port (auto-overflow, anchored summary, tail preservation) | — |
|
||||||
| v1.11.1 | Compaction follow-up (working indicator during compaction, unit tests, .bak cleanup) | merged |
|
| v1.11.1 | Compaction follow-up (working indicator during compaction, unit tests, .bak cleanup) | — |
|
||||||
| v1.11.2 | ContextBar (persistent context-usage indicator) | merged |
|
| v1.11.2 | ContextBar (persistent context-usage indicator above MessageList) | — |
|
||||||
| v1.11.3 | `ctx_max` capture via `/upstream/<model>/props` (replaces dead `timings.n_ctx` read) | merged |
|
| v1.11.3 | `ctx_max` capture via `/upstream/<model>/props` (replaces dead `timings.n_ctx` read) | `v1.11.3` |
|
||||||
|
| v1.11.5 | ContextBar inline next to agent picker; remove ChatContextPopover; default new sessions to no agent | — |
|
||||||
|
| v1.11.6 | Doom-loop guard from opencode (3 identical tool calls → sentinel, abort recursion) | — |
|
||||||
|
| v1.11.7 | pathGuard secrets filter (continue.dev `DEFAULT_SECURITY_IGNORE_FILETYPES`) | — |
|
||||||
|
| v1.11.8 | web_search + web_fetch tools via SearXNG | — |
|
||||||
|
| v1.11.9 | Manual redirect handling — re-run URL guard on each hop (SSRF hardening) | — |
|
||||||
|
| v1.11.10 | Stream-cap response body at 5MB, abort on overflow | `v1.11.x` |
|
||||||
|
| **v1.12.0** | **codecontext sidecar (Go HTTP shim, NDJSON MCP framing, child.Wait supervisor) + container guidance (BOOCHAT.md/BOOCODER.md) + 7 vendored skills + system-prompt.ts extraction + mtime-watch cache + 8 codecontext tool wrappers + per-agent tool whitelists + .codecontextignore template + agents.ts ALL_TOOL_NAMES single-source-of-truth fix** | `v1.12.0` |
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
## In flight / queued
|
## In flight (uncommitted on disk, 2026-05-21)
|
||||||
|
|
||||||
| Version | Theme | Status |
|
v1.12.1 work — landed today, not yet committed:
|
||||||
|
|
||||||
|
| Item | Status | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ~~v1.11.4~~ | ~~Per-turn budget + Continue affordance~~ | **CANCELLED** — already shipped in v1.8.2 |
|
| Server-side workspace pane sync | Done | `sessions.workspace_panes jsonb` column; PATCH endpoint; `session_workspace_updated` WS frame; localStorage migration on first load; deprecated `session_panes` table dropped |
|
||||||
| **v1.11.5** | ContextBar relocate (above agent-picker row), thicker, always-visible, remove ChatContextPopover | **dispatched** |
|
| Richer status indicators | Done | Five states (`streaming` / `tool_running` / `waiting_for_input` / `idle` / `error`) with distinct visuals: amber orbiting dots for streaming, amber spinning ring for tool execution, blue static for waiting on user, emerald/gray/red for idle/error |
|
||||||
| v1.11.6 | Doom-loop guard from opencode (3 identical tool calls → sentinel, abort recursion) | drafted |
|
| Startup hung-row sweep | Done | `UPDATE messages SET status='failed' WHERE status='streaming' AND created_at < NOW() - INTERVAL '5 minutes'` on server boot |
|
||||||
| v1.11.7 | pathGuard secrets filter (continue.dev's `DEFAULT_SECURITY_IGNORE_FILETYPES`) | drafted |
|
| One stuck row from v1.12.0 smoke | Cleared | Manual UPDATE (`d63c25b1`) |
|
||||||
| v1.11.x | Tag consolidation point (everything since v1.11.0) | queued |
|
| `detectSameNameLoop` code path | Added, never fired | Candidate for revert in next batch — dead code |
|
||||||
|
| Diagnostic logging in inference.ts | Added for debugging | Must come out before commit |
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
## Major work after v1.11.x
|
## v1.12.x cleanup (NEXT — small, immediate)
|
||||||
|
|
||||||
| Version | Theme | LoC est. |
|
Five items. Group them or split them — your call.
|
||||||
|---|---|---|
|
|
||||||
| **v1.12** | codecontext sidecar + tool output truncation + repair tool call (Integration 1 + 3 from May review, fused) | ~600 |
|
|
||||||
| v1.13 | Phase B groundwork — parts table + AI SDK adoption + per-tool `read_only`/`write` tagging | ~1500 |
|
|
||||||
| v1.14 | Phase C — outer agent loop (multi-step until non-tool finish, AGENTS.md `steps` field, reasoning as part type) | ~800 |
|
|
||||||
| v1.15 | Phase D — permission ruleset + MCP client (lays foundation for BooCoder) | ~600 |
|
|
||||||
| v1.16 | Batch 11b — codesight repo_health (call graph, circular deps, dead code) | ~400 |
|
|
||||||
| **v2.0** | Batch 14 — BooCoder pending changes (new container, write tools, plandex pattern) | ~1200 |
|
|
||||||
| v2.1 | Batch 15 — BooCoder runtime isolation (per-session Docker sandbox, OpenHands pattern) | ~600 |
|
|
||||||
| v2.x | Batch 16/17 — Multi-provider LLM (optional, pi-ai) and Workflow graphs (far future, agent-framework concepts) | tbd |
|
|
||||||
|
|
||||||
-----
|
### v1.12.1 — commit consolidation
|
||||||
|
|
||||||
## Roadmap doc deviations and corrections
|
**Action items, in order:**
|
||||||
|
|
||||||
This roadmap was significantly out of sync with reality until 2026-05-20. Key corrections folded in:
|
1. **Remove diagnostic logging** from `apps/server/src/services/inference.ts`. The 12 `ctx.log.info` calls added today proved the inference loop was functioning correctly; the prompts were just slow. Verbose for production. Strip them, keep the file clean.
|
||||||
|
|
||||||
1. **Batch 9 (Agents Tier 2) is done**, not "next up." Shipped as commit `92bd3b1`, included in v1.9.1 forward. The original "Track A: Batch 9 next" recommendation was correct but the doc never got updated.
|
2. **Revert `detectSameNameLoop`.** Three additions in inference.ts:
|
||||||
2. **v1.6.2 merged.** No longer "in flight."
|
- `DOOM_LOOP_SAME_NAME_THRESHOLD = 5` constant
|
||||||
3. **Batch 5 (fork/delete), Batch 6 (drag-drop), Batch 7 (settings drawer), Batch 8 (web search), Batch 10 (BooTerm) all shipped**, scattered across the v1.6–v1.10 version line. Original "Track A polish then agents" plan was abandoned; work happened opportunistically.
|
- `detectSameNameLoop()` function
|
||||||
4. **v1.11.0 was a major unplanned addition** — opencode-style compaction (auto-overflow detection + anchored rolling summary + tail preservation). This is NOT a batch from the old roadmap. It opened a new patch line (v1.11.x) of small follow-ups in front of the original Batches 11–17.
|
- Call site in `runAssistantTurn` immediately after the existing `detectDoomLoop` check
|
||||||
5. **Batch 11 (codecontext sidecar) moves to v1.12.** Bundles with truncation and repair-tool-call lift (both from opencode) since they share concerns and the `tool_choice='required'` confirmation makes repair-tool-call viable.
|
|
||||||
6. **Phase B (parts table + AI SDK + tool-call lifecycle) becomes v1.13.** This absorbs the old Batch 13 (append-only event log) — same outcome (typed message parts), different mental framing.
|
|
||||||
7. **Phase C and Phase D are new** (numbered v1.14/v1.15). They originate from the opencode integration analysis, not from the original 17-batch plan. Phase C delivers the outer agent loop with explicit step boundaries. Phase D delivers the permission ruleset + MCP client needed for codecontext to be useful and for BooCoder to gate writes.
|
|
||||||
8. **BooCoder (v2.0/v2.1)** is the second-major-version line. New container, new safety story (pending changes + per-session Docker sandbox). Maps to original Batches 14/15.
|
|
||||||
|
|
||||||
-----
|
Never fired in any real run today. Dead code. The existing `detectDoomLoop` (identical args, threshold 3) is sufficient.
|
||||||
|
|
||||||
## v1.11.x patches in detail
|
3. **Drop the stale `messages_status_check` CHECK constraint** in `apps/server/src/schema.sql`. Two constraints exist on the table:
|
||||||
|
- `messages_status_check` allows `streaming|complete|failed` (old, stale)
|
||||||
|
- `messages_status_chk` allows `streaming|complete|failed|cancelled` (new)
|
||||||
|
|
||||||
### v1.11.0 — opencode-style compaction port ✅
|
The old one prevents `cancelled` from being written. Drop it with `ALTER TABLE messages DROP CONSTRAINT IF EXISTS messages_status_check;`.
|
||||||
|
|
||||||
**What shipped:** Auto-detection of context overflow (`isOverflow(usage, model)`) triggers compaction on the *next* user turn. Compaction preserves the last 2 turns verbatim and produces an anchored Markdown summary (8-section template lifted verbatim from opencode `compaction.ts`) that replaces older head messages. Summary is rolling — each new compaction updates the prior summary, not stacks. Schema additions: `messages.compacted_at`, `messages.summary`, `messages.tail_start_id`, `chats.needs_compaction`. WS `compacted` frame fires sonner toast on completion.
|
4. **Stop-handler writes terminal status.** When user clicks stop mid-stream, the abort path must `UPDATE messages SET status='cancelled' WHERE id = $assistantMessageId AND status='streaming'`. Currently rows just sit `streaming` forever. The startup sweep catches them on restart, but they should be written immediately. Edit `apps/server/src/services/inference.ts` `handleAbortOrError` to add the UPDATE.
|
||||||
|
|
||||||
**Key divergences from opencode:** Per-chat (not per-session) compaction state because BooCode history is per-chat. UUID `tail_start_id` not BIGINT. No `parent_id` on messages. Context limit comes from `messages.ctx_max` (last-known `n_ctx`), not a `model.context_limit` field.
|
5. **Commit + tag v1.12.1.** Include the workspace pane sync, status indicator overhaul, startup sweep, and items 1–4 above. Single commit per item is fine; tag at end.
|
||||||
|
|
||||||
### v1.11.1 — Compaction follow-up ✅
|
**Estimated:** ~150 LoC net (deletions dominate).
|
||||||
|
|
||||||
Working-state `chat_status: working/idle` frames around the LLM call inside `compaction.process()`. 24 new vitest cases for the six pure functions (`usable`, `isOverflow`, `estimate`, `turns`, `select`, `buildPrompt`). 7 `.bak-v1.11` files deleted.
|
### v1.12.2 — live throughput display (small UX win)
|
||||||
|
|
||||||
### v1.11.2 — ContextBar ✅
|
Surface `tokens_per_second` and `ctx_used` next to the status indicator while streaming. Backend already emits these in the `usage` frame; just consume them in the StatusDot wrapper or a sibling component. ~80 LoC, frontend-only.
|
||||||
|
|
||||||
New `ContextBar.tsx` rendering above MessageList. Shows `{used} / {max} ({pct}%)` with color tiers computed against `max - 20k` reserve (matches `compaction.usable()`): muted <60%, amber 60-80%, orange 80-95%, red ≥95%. Tooltip shows "Auto-compaction at ~N%". Mobile breakpoints: `< 380px` shows "Ctx" + numbers; `380-639px` adds parenthetical %; `≥ 640px` shows full "Context" label.
|
### v1.12.3 — stale-stream frontend banner
|
||||||
|
|
||||||
### v1.11.3 — ctx_max capture fix ✅
|
When a chat has a `streaming` row older than ~60s with no new tokens, the UI should surface a "Previous response didn't complete. [Retry] [Discard]" banner instead of silently queueing new sends. Today's debugging spent four hours misreading slow streams as dead; this is the UX fix that prevents that. ~150 LoC, frontend + small backend endpoint for the discard action.
|
||||||
|
|
||||||
Discovered the dead code at `inference.ts:479-481` and `compaction.ts:300` reading `parsed.timings.n_ctx` never fired — llama-server emits `prompt_n / predicted_n / *_ms / *_per_second` in timings but NOT `n_ctx`. New `model-context.ts` module fetches `GET /upstream/<model>/props` with 3s timeout, positive cache (no TTL), 60s negative cache. Wired into all 4 ctx_max write sites (3 in inference.ts, 1 in compaction.ts). 12 new vitest cases. 7 historical rows backfilled to `ctx_max = 262144` (single-day backfill, only qwen3.6-35b-a3b-mxfp4 in use).
|
|
||||||
|
|
||||||
### v1.11.4 — CANCELLED
|
|
||||||
|
|
||||||
Original scope: per-turn budget reset + Continue affordance + CapHitSentinel card. Recon revealed all three are already shipped (v1.8.2 timestamps in inference.ts comments). Dead version slot.
|
|
||||||
|
|
||||||
### v1.11.5 — ContextBar relocate (DISPATCHED)
|
|
||||||
|
|
||||||
Relocate ContextBar from above MessageList to above the agent-picker row. Bump height from ~4px bar to ~10-12px. Always-visible (zero-state when no assistant messages + use `model_context_limit` from v1.11.3 cache). Remove `ChatContextPopover` entirely (redundant signal; mobile-hostile).
|
|
||||||
|
|
||||||
### v1.11.6 — Doom-loop guard (QUEUED)
|
|
||||||
|
|
||||||
Detect 3 identical tool calls in a row within one turn (same name + same args via JSON.stringify). On detection: abort tool-call recursion, insert `metadata.kind='doom_loop'` sentinel, trigger summary turn via existing `runCapHitSummary` path. New `DoomLoopSentinel.tsx` component (no Continue button — looping shouldn't be retried with same tools). Per-turn sliding window, scoped to current turn's tool-call accumulator.
|
|
||||||
|
|
||||||
**Lift source:** opencode `processor.ts`, `DOOM_LOOP_THRESHOLD = 3` constant.
|
|
||||||
|
|
||||||
### v1.11.7 — pathGuard secrets filter (QUEUED)
|
|
||||||
|
|
||||||
Extend pathGuard with `DEFAULT_SECURITY_IGNORE_FILETYPES` from continue.dev `core/indexing/ignore.ts`. Three-tier matcher: exact basenames (`credentials`, `secrets.yml`), extensions (`.env`, `.pem`, `.key`, `.crt`, etc.), prefix patterns (`id_rsa`, `id_dsa`, `id_ecdsa`, `id_ed25519`). Blocked files appear in `list_dir` and `find_files` results with `(blocked)` annotation. `view_file` returns `{ error: 'blocked_secret_file', ... }`. `grep` cannot read blocked file contents. No override mechanism in v1.x (use host shell).
|
|
||||||
|
|
||||||
**Why it matters:** `/opt:/opt:ro` mount currently exposes `boolab/.env`, `dubdrive/users.json`, `authelia/state`, every other service's secrets to any tool past path validation. Cheap close on that surface area.
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
## v1.12 — codecontext sidecar + truncation + repair tool call
|
|
||||||
|
|
||||||
Three lifts fused because they share concerns:
|
|
||||||
|
|
||||||
1. **codecontext sidecar** — new container, single-instance, path-addressed multi-project. Mount `/opt/projects:/workspace:ro`. 8 tools wired as static `ToolDef` wrappers in `apps/server/src/services/tools/codecontext/` (one file per tool). HTTP client to `http://codecontext:8765`. New module `apps/server/src/services/codecontext_bridge.ts` translates `project_id` → `/workspace/<relative>/` paths.
|
|
||||||
|
|
||||||
2. **Tool output truncation** — opencode `truncate.ts` pattern. Cap at 2000 lines / 50KB. Larger outputs: write full content server-side, return preview + opaque `id`. New tool `view_truncated_output(id)` retrieves full content by server-mapped id. **No pathGuard exception** for `/tmp` directory — the opaque-id approach avoids exposing a writable filesystem location to the model. Only codecontext outputs need truncation; native tools (view_file 200 lines, grep 200 results, list_dir 500 entries, find_files 200 results) already cap reasonably.
|
|
||||||
|
|
||||||
3. **`experimental_repairToolCall` equivalent** — when model emits malformed tool call (JSON parse fails or Zod validation fails), return a synthetic tool result instead of an error: `{ error, raw_args, tool_name, hint: 'Retry with valid JSON arguments.' }`. Model self-corrects on next step. Add one line to system prompt instructing self-correction on malformed-args results. Confirmed working precondition: `tool_choice: "required"` accepted by llama-swap (verified 2026-05-20 against qwen3.6-35b-a3b-mxfp4).
|
|
||||||
|
|
||||||
**Hand-roll, not AI SDK adoption.** AI SDK migration deferred to v1.13.
|
|
||||||
|
|
||||||
**AGENTS.md updates:** Each of the 6 builtin agents gets a curated codecontext tool whitelist:
|
|
||||||
- Architect: all 8
|
|
||||||
- Debugger: `search_symbols`, `get_dependencies`
|
|
||||||
- Code Reviewer: `get_file_analysis`
|
|
||||||
- Refactorer: `get_semantic_neighborhoods`, `get_dependencies`
|
|
||||||
- Security Auditor: `get_file_analysis`, `search_symbols`, `get_dependencies`
|
|
||||||
- Prompt Builder: none (no structural reasoning relevance)
|
|
||||||
|
|
||||||
**Dependencies:** v1.11.x merged. No others.
|
|
||||||
|
|
||||||
**Estimated:** 600 LoC across 3-4 dispatches under the v1.12 umbrella.
|
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
@@ -162,11 +113,15 @@ Three lifts fused because they share concerns:
|
|||||||
3. Tool registry: `ToolDef<T>` gains `category: 'read_only' | 'write'` field. BooCode v1.x rejects any `write` tool at registry time (defense in depth for the BooCoder split). Alpha-sort tool list before sending to model (prompt-cache stability).
|
3. Tool registry: `ToolDef<T>` gains `category: 'read_only' | 'write'` field. BooCode v1.x rejects any `write` tool at registry time (defense in depth for the BooCoder split). Alpha-sort tool list before sending to model (prompt-cache stability).
|
||||||
4. Reasoning content (`reasoning_content` from Qwen3.6) captured as its own part type instead of dropped or inlined.
|
4. Reasoning content (`reasoning_content` from Qwen3.6) captured as its own part type instead of dropped or inlined.
|
||||||
|
|
||||||
**Migration risk:** non-trivial. inference.ts is ~1400 lines with custom XML fallback, SSE parsing, compaction integration. Plan dedicated cutover window. Compaction.ts must update to assemble head from parts.
|
**Migration risk:** non-trivial. `inference.ts` is ~1700 lines with custom XML fallback, SSE parsing, compaction integration. Plan dedicated cutover window. `compaction.ts` must update to assemble head from parts.
|
||||||
|
|
||||||
**Replaces:** Original Batch 13 (append-only event log) — same outcome, different vocabulary.
|
**Replaces:** Original Batch 13 (append-only event log) — same outcome, different vocabulary.
|
||||||
|
|
||||||
**Dependencies:** v1.12 merged.
|
**Today's debugging spike validates this work.** Four hours of confusion came from JSON-blob `tool_calls` / `tool_results` columns hiding state from logs and from the inference state machine being invisible. Typed parts + per-part status would have shown the slow-stream-vs-dead distinction in seconds.
|
||||||
|
|
||||||
|
**Dependencies:** v1.12.x cleanup merged.
|
||||||
|
|
||||||
|
**Estimated:** ~1500 LoC.
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
@@ -179,10 +134,12 @@ Three lifts fused because they share concerns:
|
|||||||
1. Outer loop continues until model returns non-tool finish OR step cap hit. Step ≠ tool call: one step can contain multiple tool calls in parallel.
|
1. Outer loop continues until model returns non-tool finish OR step cap hit. Step ≠ tool call: one step can contain multiple tool calls in parallel.
|
||||||
2. `agent.steps ?? Infinity` per-agent step cap. AGENTS.md gains `steps:` field. Refactorer `steps: 5`, Architect `steps: 20`, etc.
|
2. `agent.steps ?? Infinity` per-agent step cap. AGENTS.md gains `steps:` field. Refactorer `steps: 5`, Architect `steps: 20`, etc.
|
||||||
3. Step-boundary events (`step_start`, `step_finish`) explicit in the parts stream. Per-step snapshot for revert (planned for BooCoder; backend-only in v1.14).
|
3. Step-boundary events (`step_start`, `step_finish`) explicit in the parts stream. Per-step snapshot for revert (planned for BooCoder; backend-only in v1.14).
|
||||||
4. Doom-loop guard (v1.11.6) migrates from "abort recursion" to "raise within loop iteration." Same predicate, different control flow.
|
4. Doom-loop guards (v1.11.6) migrate from "abort recursion" to "raise within loop iteration." Same predicate, different control flow.
|
||||||
|
|
||||||
**Dependencies:** v1.13 merged.
|
**Dependencies:** v1.13 merged.
|
||||||
|
|
||||||
|
**Estimated:** ~800 LoC.
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
## v1.15 — Phase D: permission ruleset + MCP client
|
## v1.15 — Phase D: permission ruleset + MCP client
|
||||||
@@ -200,6 +157,8 @@ Three lifts fused because they share concerns:
|
|||||||
|
|
||||||
**Dependencies:** v1.13 merged (parts table for permission events). Independent of v1.14.
|
**Dependencies:** v1.13 merged (parts table for permission events). Independent of v1.14.
|
||||||
|
|
||||||
|
**Estimated:** ~600 LoC.
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
## v1.16 — Batch 11b: codesight repo_health
|
## v1.16 — Batch 11b: codesight repo_health
|
||||||
@@ -208,6 +167,8 @@ Call graph, circular dependency detection, dead code flagging. Port `analyze.mjs
|
|||||||
|
|
||||||
**Dependencies:** v1.12 merged (can reuse codecontext parse output where overlapping).
|
**Dependencies:** v1.12 merged (can reuse codecontext parse output where overlapping).
|
||||||
|
|
||||||
|
**Estimated:** ~400 LoC.
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
## v2.0 — BooCoder pending changes
|
## v2.0 — BooCoder pending changes
|
||||||
@@ -218,6 +179,8 @@ New container `boocoder` at `100.114.205.53:9502`. Owns write tools (`edit_file`
|
|||||||
|
|
||||||
**Dependencies:** v1.13 (parts) + v1.15 (permissions).
|
**Dependencies:** v1.13 (parts) + v1.15 (permissions).
|
||||||
|
|
||||||
|
**Estimated:** ~1200 LoC.
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
## v2.1 — BooCoder runtime isolation
|
## v2.1 — BooCoder runtime isolation
|
||||||
@@ -228,6 +191,8 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
|
|||||||
|
|
||||||
**Dependencies:** v2.0.
|
**Dependencies:** v2.0.
|
||||||
|
|
||||||
|
**Estimated:** ~600 LoC.
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
## v2.x — Optional / far future
|
## v2.x — Optional / far future
|
||||||
@@ -243,17 +208,18 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
|
|||||||
|
|
||||||
| Container | Port | Mount | Purpose | Status |
|
| Container | Port | Mount | Purpose | Status |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `boocode` | `100.114.205.53:9500` | `/opt:/opt:ro` | Chat + read-only tools + SPA | Live |
|
| `boocode` | `100.114.205.53:9500` | `/opt:/opt` | Chat + read-only tools + SPA | Live |
|
||||||
| `boocode_db` | `127.0.0.1:5500` | `boocode_pgdata` volume | Postgres 16-alpine | Live |
|
| `boocode_db` | `127.0.0.1:5500` | `boocode_pgdata` volume | Postgres 16-alpine | Live |
|
||||||
| `booterm` | `100.114.205.53:9501` | `/opt/repos:/opt/repos:rw` | Terminals (tmux + node-pty) | Live (v1.10.0) |
|
| `booterm` | `100.114.205.53:9501` | `/opt/repos:/opt/repos:rw` | Terminals (tmux + node-pty) | Live (v1.10.0) |
|
||||||
| `codecontext` | `:8765` (internal) | `/opt/projects:/workspace:ro` | MCP server for architect tools | v1.12 |
|
| **`codecontext`** | **`:8765` (internal)** | **`/opt/projects:/workspace:ro`** | **MCP server for architect tools** | **Live (v1.12.0)** |
|
||||||
| `boocoder` | `100.114.205.53:9502` | per-session sandbox | Write tools | v2.0 |
|
| `boocoder` | `100.114.205.53:9502` | per-session sandbox | Write tools | v2.0 |
|
||||||
|
|
||||||
### Schema additions by version
|
### Schema additions by version
|
||||||
|
|
||||||
- **v1.11.0:** `messages.compacted_at`, `messages.summary`, `messages.tail_start_id`, `chats.needs_compaction`
|
- **v1.11.0:** `messages.compacted_at`, `messages.summary`, `messages.tail_start_id`, `chats.needs_compaction`
|
||||||
- **v1.11.7:** none (pathGuard logic, no DB)
|
- **v1.11.7:** none (pathGuard logic, no DB)
|
||||||
- **v1.12:** none (codecontext is stateless on disk; truncation uses in-memory id→path map with TTL cleanup)
|
- **v1.12.0:** none (codecontext stateless; truncation in-memory id-map with TTL cleanup)
|
||||||
|
- **v1.12.1:** `sessions.workspace_panes jsonb` (workspace sync); drop deprecated `session_panes` table; drop stale `messages_status_check` constraint
|
||||||
- **v1.13:** `message_parts` table; `messages` becomes header-only
|
- **v1.13:** `message_parts` table; `messages` becomes header-only
|
||||||
- **v1.14:** `agents.steps` column (or AGENTS.md parser extension; no DB if file-only)
|
- **v1.14:** `agents.steps` column (or AGENTS.md parser extension; no DB if file-only)
|
||||||
- **v1.15:** `permissions` table, `agent_permissions` join, `session_permissions` join
|
- **v1.15:** `permissions` table, `agent_permissions` join, `session_permissions` join
|
||||||
@@ -268,11 +234,11 @@ Full inventory in `boocode_code_review.md`. Headline items:
|
|||||||
|
|
||||||
| Source | Used for | Where |
|
| Source | Used for | Where |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **`sst/opencode`** (MIT, TS) | **Compaction algorithms** | **v1.11.0 (shipped)** |
|
| `sst/opencode` (MIT, TS) | Compaction algorithms | v1.11.0 (shipped) |
|
||||||
| `sst/opencode` (MIT, TS) | Doom-loop guard | v1.11.6 |
|
| `sst/opencode` (MIT, TS) | Doom-loop guard | v1.11.6 (shipped) |
|
||||||
| `sst/opencode` (MIT, TS) | `repairToolCall`, truncate.ts, MCP client, permission evaluate, runLoop | v1.12/v1.13/v1.14/v1.15 |
|
| `sst/opencode` (MIT, TS) | `repairToolCall`, truncate.ts, MCP client, permission evaluate, runLoop | v1.12 (shipped) / v1.13 / v1.14 / v1.15 |
|
||||||
| `continuedev/continue` (Apache-2.0) | `DEFAULT_SECURITY_IGNORE_FILETYPES` | v1.11.7 |
|
| `continuedev/continue` (Apache-2.0) | `DEFAULT_SECURITY_IGNORE_FILETYPES` | v1.11.7 (shipped) |
|
||||||
| `nmakod/codecontext` (MIT, Go) | Architect: codebase map sidecar | v1.12 |
|
| `nmakod/codecontext` (MIT, Go) | Architect: codebase map sidecar | v1.12.0 (shipped) |
|
||||||
| `spirituslab/codesight` (MIT-ish, TS) | Architect: repo health analyzer | v1.16 |
|
| `spirituslab/codesight` (MIT-ish, TS) | Architect: repo health analyzer | v1.16 |
|
||||||
| `Aider-AI/aider` (Apache-2.0) | Fallback `.scm` grammars | v1.12 (fallback) |
|
| `Aider-AI/aider` (Apache-2.0) | Fallback `.scm` grammars | v1.12 (fallback) |
|
||||||
| `cline/cline` (Apache-2.0) | Plan/Act pattern (absorbed into v1.15 permissions) | v1.15 |
|
| `cline/cline` (Apache-2.0) | Plan/Act pattern (absorbed into v1.15 permissions) | v1.15 |
|
||||||
@@ -281,8 +247,6 @@ Full inventory in `boocode_code_review.md`. Headline items:
|
|||||||
| `aimasteracc/tree-sitter-analyzer` (MIT) | Outline-first patterns | v1.12 (alt) |
|
| `aimasteracc/tree-sitter-analyzer` (MIT) | Outline-first patterns | v1.12 (alt) |
|
||||||
| `earendil-works/pi` (MIT) | Multi-provider LLM | v2.x (optional) |
|
| `earendil-works/pi` (MIT) | Multi-provider LLM | v2.x (optional) |
|
||||||
|
|
||||||
**Original Batch 13 (event log from OpenHands) replaced** by v1.13 (parts table). Same outcome, different framing.
|
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
## Decisions log
|
## Decisions log
|
||||||
@@ -293,10 +257,15 @@ Full inventory in `boocode_code_review.md`. Headline items:
|
|||||||
- **Globstar parked** — not an architect tool. Future verify-before-commit candidate only.
|
- **Globstar parked** — not an architect tool. Future verify-before-commit candidate only.
|
||||||
- **codeprysm rejected** — embedding-based. Node/edge taxonomy noted as reference if we ever build our own graph.
|
- **codeprysm rejected** — embedding-based. Node/edge taxonomy noted as reference if we ever build our own graph.
|
||||||
- **Batch 9 decoupled from Batch 7 (2026-05-16); shipped in `92bd3b1`.** Builtin defaults: six agents (Code Reviewer, Debugger, Refactorer, Architect, Security Auditor, Prompt Builder) with no `model` field. Session model wins by default.
|
- **Batch 9 decoupled from Batch 7 (2026-05-16); shipped in `92bd3b1`.** Builtin defaults: six agents (Code Reviewer, Debugger, Refactorer, Architect, Security Auditor, Prompt Builder) with no `model` field. Session model wins by default.
|
||||||
- **opencode lift opened** (2026-05-20). Started with compaction (v1.11.0). Continuing through v1.15. Five distinct algorithms: compaction, doom-loop guard, repairToolCall, runLoop, permission evaluate. Plus `truncate.ts` and `MCP client`. Each lifts the algorithm, not the Effect-TS plumbing.
|
- **opencode lift opened** (2026-05-20). Started with compaction (v1.11.0). Continuing through v1.15. Five distinct algorithms: compaction, doom-loop guard, repairToolCall, runLoop, permission evaluate. Plus `truncate.ts` and MCP client. Each lifts the algorithm, not the Effect-TS plumbing.
|
||||||
- **AI SDK adoption deferred to v1.13.** Hand-roll repairToolCall in v1.12 first. Migrate everything together when parts table lands.
|
- **AI SDK adoption deferred to v1.13.** Hand-roll repairToolCall in v1.12 — not actually done in v1.12.0; truncation also deferred. v1.12.0 shipped codecontext + container guidance + skills only.
|
||||||
- **`tool_choice='required'` confirmed supported** by llama-swap (qwen3.6-35b-a3b-mxfp4, 2026-05-20). Unblocks repair tool call viability.
|
- **`tool_choice='required'` confirmed supported** by llama-swap (qwen3.6-35b-a3b-mxfp4, 2026-05-20).
|
||||||
- **v1.11.4 cancelled** (2026-05-20). Per-turn budget reset + Continue affordance + CapHitSentinel were already shipped in v1.8.2. Roadmap was 14 versions stale at time of recon.
|
- **v1.11.4 cancelled** (2026-05-20). Per-turn budget reset + Continue affordance + CapHitSentinel were already shipped in v1.8.2.
|
||||||
|
- **v1.12.0 shipped** (2026-05-21). codecontext sidecar Track B + container guidance Track A. v1.12 truncation and repairToolCall were deferred into v1.13's AI SDK migration where they get for-free.
|
||||||
|
- **v1.12.1 workspace pane sync** (2026-05-21). Moved pane state from per-device localStorage to `sessions.workspace_panes jsonb` with WS broadcast for cross-device sync. Deprecated `session_panes` table dropped. Legacy localStorage migrates on first load.
|
||||||
|
- **v1.12.1 status indicator overhaul** (2026-05-21). ChatStatusFrame expanded from `working|idle|error` to `streaming|tool_running|waiting_for_input|idle|error`. StatusDot rewritten with distinct animations per state. Added `executeToolPhase`-entry `tool_running` publish.
|
||||||
|
- **detectSameNameLoop reverted** (planned v1.12.1). Added during the 2026-05-21 debugging spike to catch same-tool-name-with-different-args loops. Never fired in any real run because the existing `detectDoomLoop` covers the actual failure modes. Dead code, reverting.
|
||||||
|
- **The 2026-05-21 "freeze" debugging spike taught one lesson**: BooCode has no UI signal for the difference between a slow stream and a dead stream. Diagnostic logging (added today, reverted in v1.12.1) revealed the inference loop was working correctly throughout — what looked like four hours of deterministic hang was multiple instances of qwen3.6 generating 8k tokens of self-doubt at temperature 0.2 on a "find the bug" prompt with no real bug. v1.12.2 (live tok/s display) and v1.12.3 (stale-stream banner) directly address this gap.
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user