Compare commits
6 Commits
v2.5.13-pr
...
v2.6.0-pha
| Author | SHA1 | Date | |
|---|---|---|---|
| 140ff26204 | |||
| a97293b5d9 | |||
| 63adb218e6 | |||
| d0334ca544 | |||
| 024ffc0b92 | |||
| 691eef1b30 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,5 +16,5 @@ data/*
|
||||
!data/AGENTS.md
|
||||
!data/skills/
|
||||
!data/mcp.json
|
||||
!data/coder-providers.json
|
||||
!data/coder-providers.example.json
|
||||
codecontext/fork.tar.gz
|
||||
|
||||
@@ -44,7 +44,7 @@ BooCoder's coding agents are a **config-backed registry**: built-ins live in `pr
|
||||
|
||||
### Config file: `data/coder-providers.json`
|
||||
|
||||
Resolved from `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`; dev/host path `/opt/boocode/data/coder-providers.json`). It is **tracked in git** via a `.gitignore` exception (the rest of `data/*` is ignored). A missing file, invalid JSON, or a schema mismatch all fall back to built-ins-only — loading never throws at startup.
|
||||
Resolved from `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`; dev/host path `/opt/boocode/data/coder-providers.json`). It is **gitignored** — it's live runtime config that the coder reads *and writes* (UI toggles `PATCH` it), so tracking it would churn `git status`. The tracked reference is `data/coder-providers.example.json`; copy it to `coder-providers.json` to seed overrides. A missing file, invalid JSON, or a schema mismatch all fall back to built-ins-only — loading never throws at startup.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||
|
||||
## v2.5.15-acp-path-guard — 2026-05-29
|
||||
|
||||
Security fix + repo hygiene. Fixes a path-traversal in the ACP filesystem bridge (`acp-client-fs.ts`, flagged by the automated push security review): the worktree guard used an unbounded `startsWith(resolve(worktreePath))`, so a sibling path sharing the worktree as a string prefix (`<worktree>-evil/…`) escaped the scope — and `writeWorktreeTextFile` writes to disk directly (no `pending_changes` gate), so a confused/buggy ACP agent could write outside its worktree. Now uses a separator-bounded check matching `write_guard.ts` (`resolve()` + `startsWith(root + sep)` / `=== root`) via a shared `resolveInWorktree`, with a regression test covering `../` traversal and the sibling-prefix bug. Symlink-swap/`O_NOFOLLOW` hardening was intentionally skipped — consistent with `write_guard`'s no-realpath stance, and the agent already runs with host FS access so this is a containment guard, not a trust boundary. Separately, stops tracking the live `data/coder-providers.json` (it's runtime config the UI reads *and writes* on provider toggles, which churned `git status`) — it's now gitignored with a tracked `data/coder-providers.example.json` reference; the loader falls back to built-ins-only when the live file is absent. The provider-type duplication (coder ↔ web) stays guarded by the existing text-identity `provider-types-parity.test.ts` — a shared package was considered and declined (drift is already prevented; not worth the Docker/build-order risk at solo scale).
|
||||
|
||||
## v2.5.14-claude-md — 2026-05-29
|
||||
|
||||
Docs-only — CLAUDE.md session-learnings update, no code. Adds gotchas surfaced while shipping the v2.3 provider-lifecycle batch: the host `boocoder.service` keeps running the old process after `pnpm -C apps/coder build` (stale-process tell = new routes 404 while old routes 200, restart don't re-debug); the `boocode` container `build: .` deploys the working tree, so web edits are live on the Vite dev server but not production until `docker compose up --build -d boocode`; `PATCH /api/providers/config` replaces a provider's override wholesale (send `{...existing, enabled}` or a custom ACP entry's command is wiped) and `data/coder-providers.json` is live config not to be committed as code; external agents dispatch one-shot with no context/token tracking (only native `boocode` tracks ctx; OpenCode-as-server is the unshipped `v2-6-persistent-agent-sessions` plan); the `ui/` primitive inventory with `button role=switch` / Dialog fallbacks for the absent switch/sheet; and the mobile Dialog-with-list scroll-containment recipe. Also backfills previously-uncommitted doc bullets for the `v2.5.7`–`v2.5.11` coder work (provider-type parity test, async ACP command discovery, AgentComposerBar `installed` filter, provider-registry path disambiguation).
|
||||
|
||||
## v2.5.13-provider-lifecycle-phase5 — 2026-05-29
|
||||
|
||||
Closeout of the v2.3 provider-lifecycle batch — the web UI (Phase 5) plus docs (Phase 6). Provider management moved into **Settings → Providers**: a tab listing every registered provider with a status badge (Available / Disabled / Not installed / Error / Loading), an enable/disable toggle, a per-provider refresh, and a plaintext diagnostic; toggling sends the provider's *full* override (preserving a custom ACP entry's command under the wholesale-replace PATCH merge) then refetches the snapshot. The composer's provider picker now filters to `enabled && (status === 'ready' || 'loading')`, so disabled and unavailable providers drop out of the picker and are managed only in settings (native `boocode` always shows). A curated ACP catalog (`apps/web/src/data/acp-provider-catalog.ts`) + `AddProviderModal` register custom providers via `PATCH /api/providers/config` then a subset refresh, and the web client gained `getProvidersConfig` / `patchProvidersConfig` / `refreshProviders` / `getProviderDiagnostic`. Two mobile fixes ship alongside: the Settings pane is now reachable on phones (opening it pushes `?pane=` atomically so the mobile URL-sync effect keeps it active instead of snapping back to the chat pane), and the Add-provider modal caps to the viewport with a single `overscroll-contain` scroll region so the list scrolls instead of dragging the whole modal. This completes the arc begun in `v2.5.4-provider-lifecycle-phase1` (config-backed registry over the built-ins) → `v2.5.5-provider-lifecycle-phase2` (loading/unavailable snapshot lifecycle + tier-2 probe TTL gate) → `v2.5.6-provider-lifecycle-phase3` (generic `resolveLaunchSpec` ACP dispatch) → `v2.5.12-provider-lifecycle-phase4` (config GET/PATCH, subset refresh, diagnostic HTTP API). Docs landed in `BOOCODER.md` (config file, refresh contract, enable/disable, custom ACP, the honest subset-refresh known limitation) and `docs/DEFERRED-WORK.md` §2 is marked addressed; the remaining Tier-2 follow-ups (WS `provider_snapshot_updated` frame, `available_agents.enabled` column, shared types package, MCP provider tools) stay deferred.
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -68,9 +68,9 @@ Key services:
|
||||
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. v1.13.20 dropped the legacy `messages.tool_calls` / `messages.tool_results` JSON columns; the view now reads parts-only subselects. Writes target `message_parts` exclusively via `insertParts` (or via the helpers `partsFromAssistantMessage` / `partsFromToolMessage`). The `Message` wire type still carries `tool_calls?` / `tool_results?` because the view synthesizes them from parts — frontend reads are unchanged. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returning `id` followed by SELECT from the view — RETURNING off the bare `messages` table no longer carries the tool fields.
|
||||
- **`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/provider-registry.ts`** — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
|
||||
- **`services/agent-probe.ts`** — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
|
||||
- **`routes/providers.ts`** — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference).
|
||||
- **`apps/coder/src/services/provider-registry.ts`** (BooCoder, NOT apps/server) — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
|
||||
- **`apps/coder/src/services/agent-probe.ts`** (BooCoder) — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
|
||||
- **`apps/coder/src/routes/providers.ts`** (BooCoder) — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference). The apps/server side of this flow is the "Provider picker dispatch" bullet below.
|
||||
- **Provider picker dispatch**: when `provider !== 'boocode'`, the message route creates a `tasks` row (with `session_id` set) instead of calling `inference.enqueue`. The dispatcher picks it up and dispatches via ACP or PTY using the agent's `install_path`.
|
||||
|
||||
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
|
||||
@@ -80,12 +80,16 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
|
||||
- Write-capable coding agent. Runs as a **systemd service on the host** (`boocoder.service`), NOT in Docker. Fastify server at port 9502, connects to postgres at `127.0.0.1:5500`.
|
||||
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST.
|
||||
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
|
||||
- After `pnpm -C apps/coder build` the host `boocoder.service` keeps running the OLD process until `sudo systemctl restart boocoder` — a stale process shows **new routes 404 with `{error:'not found'}` while old routes still 200** (the `/api` not-found handler returns that shape). Restart, don't re-debug.
|
||||
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
|
||||
- systemd hardening: only `NoNewPrivileges=true` is safe. `ProtectSystem`, `ProtectHome`, `PrivateTmp` all break agent dispatch (agents need full filesystem access to read configs, write to worktrees).
|
||||
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
|
||||
- Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates).
|
||||
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to the boocoder host service (`BOOCODER_URL` env var, default `http://100.114.205.53:9502`). WS connects directly to `:9502`.
|
||||
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
|
||||
- **Provider snapshot lifecycle** (`apps/coder/src/services/`): `provider-config.ts` (Zod config, never-throws on bad input) → `provider-config-registry.ts` (`buildResolvedRegistry`, singleton) → `provider-snapshot.ts` (two-tier probe: tier-1 fast presence, tier-2 cold ACP probe skipped unless force / stale `PROVIDER_PROBE_TTL_MS` 24h / dbEmpty; cached). Verify live: `curl http://100.114.205.53:9502/api/providers/snapshot` — returns providers + models + commands, the exact shape `AgentComposerBar` renders.
|
||||
- `PATCH /api/providers/config` replaces a provider id's override object **wholesale** (per-id shallow merge) — to flip one field send `{...existing, enabled}`, or a custom ACP entry's `command`/`label` is wiped and it drops out of the resolved registry. `data/coder-providers.json` is **gitignored** (it's live runtime config — the coder reads AND writes it on UI toggles); the tracked reference is `data/coder-providers.example.json`. The loader falls back to `{providers:{}}` (built-ins only) when the live file is absent, so a fresh checkout needs no copy.
|
||||
- External agents dispatch **one-shot** (`opencode acp` / `goose acp` / `qwen --acp`) and report no context-window/token usage; only native `boocode` (llama-swap engine) tracks ctx. OpenCode-as-HTTP-server (warm process + `@opencode-ai/sdk`, the source of a real context bar) is the **planned, unshipped** `openspec/changes/v2-6-persistent-agent-sessions` batch; Paseo's per-provider native clients (design §12) were deliberately not ported.
|
||||
|
||||
### Frontend (`apps/web/src/`)
|
||||
|
||||
@@ -145,6 +149,7 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
||||
- Tag naming: `vMAJOR.MINOR.PATCH-slug` (e.g. `v1.13.13-ws-publish`). Monotonic per minor — the slug describes the batch's content so the tag name alone is enough to recall what shipped. No letter suffixes (`-a`/`-b`), no pseudo-ranges (`v1.11.x`), no slug-only sub-versions sharing a number (`v1.13.15-tools` + `-openspec` + `-agentlint` — split into sequential patches instead).
|
||||
- `CHANGELOG.md` is the per-tag release log, most-recent on top. When a new tag is created, add a `## <tag> — <YYYY-MM-DD>` section with a 3–6 sentence paragraph summarizing what shipped, drawn from the commit body. Cross-reference other tags by name when the batch builds on, fixes, or pairs with prior work (e.g. "pairs with `v1.13.12-ws-schemas`", "fixed in `v1.13.5-stability-bundle`"). No nested bullets — one paragraph.
|
||||
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
||||
- The `boocode` container is `build: .` — it builds web+server from the **working tree**, so uncommitted changes deploy. Web edits are live on the Vite dev server (HMR) but NOT on production (`:9500` / code.indifferentketchup.com) until `docker compose up --build -d boocode`.
|
||||
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. `psql` is not on the host PATH — for an interactive query use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
||||
@@ -172,10 +177,12 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
||||
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
||||
- **Adding a new WS frame type** requires updating BOTH the server's `InferenceFrame` (loose `type:` union + optional fields in `services/inference/turn.ts`) AND the web `WsFrame` (strict discriminated union in `apps/web/src/api/types.ts`). Server publish is permissive; the frontend type is the wire-format gate. The `'usage'` frame added in v1.12.2 needed both sides; missing the web side silently drops the frame at JSON-parse.
|
||||
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
|
||||
- `ui/` primitives present: button, card, context-menu, dialog, dropdown-menu, input, label, radio-group, sonner, textarea. No switch/sheet/drawer/badge/checkbox — use a `<button role="switch" aria-checked>` toggle (a hand-rolled `Switch` already lives in `SettingsPane.tsx`) and a Dialog-based panel for "drawers".
|
||||
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
|
||||
- Two UI event buses: `hooks/sessionEvents.ts` for DB-state events (chat_created, session_updated); `lib/events.ts` for ephemeral UI (`sendToTerminal`, `terminalsRegistry`). Don't merge — different subscriber lifecycles.
|
||||
- `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.
|
||||
- A scrollable list inside a Dialog on mobile: cap `DialogContent` (`max-h-[85vh]` + `grid-rows-[auto_minmax(0,1fr)_auto]`) and make the list the single scroll region with `overscroll-contain` — otherwise touch-scroll drags the whole fixed modal / chains to the page.
|
||||
- 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`.
|
||||
@@ -191,6 +198,9 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
||||
- **CoderPane uses ChatInput** (`components/panes/CoderPane.tsx`): shares the same `ChatInput` component as BooChat for full parity — attachments, paste-to-chip, auto-grow textarea, queued messages during send. CoderPane's `sendOneMessage` is the send callback; queued messages drain via `useEffect` when `sending` goes false.
|
||||
- **Adding a new `SessionEvent` type**: add the interface, add it to the `SessionEvent` union, add a `case` in `useSidebar.ts` `applyEvent` switch (no-op `return prev` is fine), and subscribe in any hook that needs it (e.g. `useSessionStream` for `refetch_messages`).
|
||||
- **BooCoder provider registry** (`apps/coder/src/services/provider-registry.ts`): static list of provider defs (boocode, opencode, goose, claude, qwen). `PROBED_AGENT_NAMES` derives from it. Adding/removing providers means editing this file, not the frontend.
|
||||
- **AgentComposerBar filters `e.installed`**: provider snapshot entries with `installed:false` (loading/unavailable) are dropped from the dropdown. `getProviderSnapshot` must await the full build — returning synchronous `loading` placeholders makes every provider vanish (the v2.5.7 "no providers showing up" regression); surfacing loading states needs a client poll.
|
||||
- **Coder↔web provider-type parity** (`apps/coder/src/services/provider-types.ts` ↔ `apps/web/src/api/types.ts`): enforced by runtime `provider-types-parity.test.ts` (compile-time cross-import is blocked by TS6307 on web's composite tsconfig). Mirror of the ws-frames parity pattern — edit both copies together or the test fails.
|
||||
- **ACP command discovery is async**: `acp-probe.ts` must poll after `newSession` for `available_commands_update` (commands arrive in a later notification; reading synchronously captures 0). PTY providers (claude) instead discover from disk via `claude-command-discovery.ts` (`~/.claude/commands` + `enabledPlugins` `skills/`+`commands/`, bare names, deduped). `AgentCommand.kind` tags `'command'` vs `'skill'`; `CoderPane`'s `slashGroups` splits them into icon'd groups. `SlashCommandPicker`'s `groups?` prop is opt-in — BooChat passes flat `items` (unchanged).
|
||||
- **Pane header architecture (mobile vs desktop)**: Desktop coder pane header (BooCode label + [+] [×]) lives in `Workspace.tsx` gated by `isCoder && !isMobile`. Mobile coder controls (● ×) live in `Session.tsx` header row next to `MobileTabSwitcher`/`NewPaneMenu`. `AgentComposerBar` (provider/mode/model pickers) renders inside `CoderPane.tsx` on both. The ● status dot is passed via `connected` prop from CoderPane to AgentComposerBar.
|
||||
- **MessageBubble shared between BooChat and BooCoder** (`components/MessageBubble.tsx`): accepts optional `actions?: MessageActions` callbacks (onRegenerate, onResend, onFork, onDelete) and `hideActions?: ('fork'|'delete'|'openInPane')[]`. Defaults use BooChat API; CoderPane overrides via `CoderMessageList` props. `CoderTextBubble` was removed. **`CoderMessageList` passes `CoderMessageWire as unknown as Message`** — the coder wire shape lacks `metadata`/`kind`/`summary`, so those fields are `undefined` (not `null`) on coder messages. Null-guards on any `Message` field MUST use loose `!= null`, not strict `!== null` (`undefined !== null` is `true` → `.kind` throws → blank-screen crash). The `as unknown as` cast hides this from tsc; build + typecheck pass while runtime crashes.
|
||||
- **llama-sidecar** (`/opt/forks/llama-sidecar/`): Go daemon for per-agent llama-server process pool. Cross-compile: `GOOS=windows GOARCH=amd64 /snap/go/current/bin/go build -o bin/llama-sidecar.exe ./cmd/llama-sidecar`. Gitea: `indifferentketchup/llama-sidecar`. Windows child process gotchas: use `context.Background()` for child lifetime (not request ctx), `os.Open(os.DevNull)` for stdin, `os.Pipe()` for stdout with drain goroutine, `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags. SSH to sam-desktop: `ssh samki@100.101.41.16`; use `schtasks` for persistent process spawning (SSH `start /B` doesn't survive session close).
|
||||
|
||||
@@ -33,6 +33,7 @@ import { registerProviderRoutes } from './routes/providers.js';
|
||||
import { registerWebSocket } from './routes/ws.js';
|
||||
// Phase 4: dispatcher + agent probe
|
||||
import { createDispatcher } from './services/dispatcher.js';
|
||||
import { agentPool } from './services/agent-pool.js';
|
||||
import { probeAgents } from './services/agent-probe.js';
|
||||
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
||||
import { setPermissionHooks } from './services/permission-waiter.js';
|
||||
@@ -178,7 +179,12 @@ async function main() {
|
||||
// Phase 4: dispatcher — polls tasks table and runs inference
|
||||
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
|
||||
dispatcher.start();
|
||||
app.addHook('onClose', () => dispatcher.stop());
|
||||
app.addHook('onClose', async () => {
|
||||
// stop() first so in-flight dispatcher turns settle, then drain the pool.
|
||||
// Pool is empty in Phase 0 (nothing spawns yet) — dispose() is inert.
|
||||
await dispatcher.stop();
|
||||
await agentPool.dispose();
|
||||
});
|
||||
|
||||
// Register routes
|
||||
registerMessageRoutes(app, sql, broker, inferenceApi);
|
||||
|
||||
@@ -76,6 +76,32 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
|
||||
|
||||
-- v2.6: one shared worktree per session (all agents/panes in the session operate in it).
|
||||
CREATE TABLE IF NOT EXISTS session_worktrees (
|
||||
session_id UUID PRIMARY KEY REFERENCES sessions(id),
|
||||
worktree_path TEXT NOT NULL,
|
||||
base_commit TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
-- v2.6: one backend session per (session, agent); resumed on switch-back.
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
session_id UUID NOT NULL REFERENCES sessions(id),
|
||||
agent TEXT NOT NULL,
|
||||
backend TEXT NOT NULL,
|
||||
agent_session_id TEXT,
|
||||
server_port INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'idle',
|
||||
last_active_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
PRIMARY KEY (session_id, agent),
|
||||
CONSTRAINT agent_sessions_backend_chk CHECK (backend IN ('opencode_server', 'acp_warm')),
|
||||
CONSTRAINT agent_sessions_status_chk CHECK (status IN ('idle', 'active', 'crashed', 'closed'))
|
||||
);
|
||||
|
||||
-- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this).
|
||||
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
|
||||
|
||||
-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
|
||||
-- new_task tool, arena, MCP server) fires pg_notify('tasks_new') in the same
|
||||
-- transaction, so the dispatcher reacts immediately instead of waiting for the
|
||||
|
||||
50
apps/coder/src/services/__tests__/acp-client-fs.test.ts
Normal file
50
apps/coder/src/services/__tests__/acp-client-fs.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { readWorktreeTextFile, writeWorktreeTextFile } from '../acp-client-fs.js';
|
||||
|
||||
const created: string[] = [];
|
||||
function freshWorktree(): string {
|
||||
const wt = mkdtempSync(join(tmpdir(), 'acp-wt-'));
|
||||
created.push(wt);
|
||||
return wt;
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const d of created.splice(0)) {
|
||||
try {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
rmSync(`${d}-evil`, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('acp-client-fs worktree scoping', () => {
|
||||
it('writes then reads a file inside the worktree', async () => {
|
||||
const wt = freshWorktree();
|
||||
await writeWorktreeTextFile(wt, 'sub/dir/note.txt', 'hello');
|
||||
expect(await readWorktreeTextFile(wt, 'sub/dir/note.txt')).toBe('hello');
|
||||
});
|
||||
|
||||
it('rejects ../ traversal on read', async () => {
|
||||
const wt = freshWorktree();
|
||||
await expect(readWorktreeTextFile(wt, '../../etc/passwd')).rejects.toThrow(/escapes worktree/);
|
||||
});
|
||||
|
||||
it('rejects ../ traversal on write', async () => {
|
||||
const wt = freshWorktree();
|
||||
await expect(writeWorktreeTextFile(wt, '../escape.txt', 'x')).rejects.toThrow(/escapes worktree/);
|
||||
});
|
||||
|
||||
it('rejects a sibling-prefix path (the unbounded-startsWith bug)', async () => {
|
||||
const wt = freshWorktree();
|
||||
// Absolute path that shares the worktree as a STRING prefix but is a sibling
|
||||
// dir: `<wt>-evil/...`. A bare `startsWith(<wt>)` wrongly admits it.
|
||||
await expect(readWorktreeTextFile(wt, `${wt}-evil/secret.txt`)).rejects.toThrow(/escapes worktree/);
|
||||
await expect(writeWorktreeTextFile(wt, `${wt}-evil/secret.txt`, 'x')).rejects.toThrow(
|
||||
/escapes worktree/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,25 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { dirname, isAbsolute, resolve, sep } from 'node:path';
|
||||
|
||||
/**
|
||||
* Resolve an ACP-supplied path against the agent worktree and reject anything
|
||||
* that escapes it. Mirrors `write_guard.ts`'s check: `resolve()` to normalize
|
||||
* `../` segments, then a **separator-bounded** prefix test — a bare
|
||||
* `startsWith(root)` wrongly admits a sibling dir like `<root>-evil/...`.
|
||||
*
|
||||
* No realpath (consistent with `write_guard.ts`: the target may not exist yet on
|
||||
* write). This is a containment guard for the ACP fs bridge, not a hard trust
|
||||
* boundary — the agent process already runs with host FS access; symlink-swap
|
||||
* hardening (`O_NOFOLLOW`/realpath) is out of scope here.
|
||||
*/
|
||||
function resolveInWorktree(worktreePath: string, filePath: string): string {
|
||||
const root = resolve(worktreePath);
|
||||
const absolute = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
|
||||
if (absolute !== root && !absolute.startsWith(root + sep)) {
|
||||
throw new Error(`path escapes worktree: ${filePath}`);
|
||||
}
|
||||
return absolute;
|
||||
}
|
||||
|
||||
/** Resolve an ACP path against the agent worktree and read a slice of lines. */
|
||||
export async function readWorktreeTextFile(
|
||||
@@ -8,10 +28,7 @@ export async function readWorktreeTextFile(
|
||||
line?: number | null,
|
||||
limit?: number | null,
|
||||
): Promise<string> {
|
||||
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
||||
if (!absolute.startsWith(resolve(worktreePath))) {
|
||||
throw new Error(`path escapes worktree: ${filePath}`);
|
||||
}
|
||||
const absolute = resolveInWorktree(worktreePath, filePath);
|
||||
const raw = await fs.readFile(absolute, 'utf8');
|
||||
if (!line && !limit) return raw;
|
||||
const lines = raw.split(/\r?\n/);
|
||||
@@ -26,10 +43,7 @@ export async function writeWorktreeTextFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
||||
if (!absolute.startsWith(resolve(worktreePath))) {
|
||||
throw new Error(`path escapes worktree: ${filePath}`);
|
||||
}
|
||||
const absolute = resolveInWorktree(worktreePath, filePath);
|
||||
await fs.mkdir(dirname(absolute), { recursive: true });
|
||||
await fs.writeFile(absolute, content, 'utf8');
|
||||
}
|
||||
|
||||
85
apps/coder/src/services/agent-backend.ts
Normal file
85
apps/coder/src/services/agent-backend.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* v2.6 — AgentBackend abstraction (Phase 0 scaffold; types only, zero runtime logic).
|
||||
*
|
||||
* The core abstraction for persistent agent sessions. Two implementations land
|
||||
* later: `OpenCodeServerBackend` (Phase 1, opencode HTTP server) and
|
||||
* `WarmAcpBackend` (Phase 2, long-lived ACP process). Backends emit
|
||||
* transport-agnostic `AgentEvent`s; the dispatcher maps them to WS frames.
|
||||
*
|
||||
* Nothing imports this file yet — it must compile standalone.
|
||||
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2.
|
||||
*/
|
||||
import type { AcpToolSnapshot } from './acp-tool-snapshot.js';
|
||||
import type { AgentCommand } from './provider-types.js';
|
||||
|
||||
/** Backend transport kind. Mirrors `agent_sessions.backend` CHECK in schema.sql. */
|
||||
export type AgentBackendKind = 'opencode_server' | 'acp_warm';
|
||||
|
||||
/**
|
||||
* Normalized, transport-agnostic events a backend emits during a turn (§2).
|
||||
* Derived from acp-dispatch's session-update handling, but WITHOUT the WS
|
||||
* envelope (message_id/chat_id) — the dispatcher owns frame mapping.
|
||||
*
|
||||
* `tool_call` vs `tool_update` are kept distinct on purpose: acp-dispatch
|
||||
* currently merges both into one snapshot frame, but opencode's SSE
|
||||
* distinguishes tool-start from tool-result, so the contract carries both.
|
||||
* `commands` mirrors the ACP `available_commands_update` path (v2.5.10).
|
||||
*/
|
||||
export type AgentEvent =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'reasoning'; text: string }
|
||||
| { type: 'tool_call'; toolCall: AcpToolSnapshot }
|
||||
| { type: 'tool_update'; toolCall: AcpToolSnapshot }
|
||||
| { type: 'commands'; commands: AgentCommand[] };
|
||||
|
||||
/** Params to establish (or look up) a backend session (§2). */
|
||||
export interface EnsureSessionOpts {
|
||||
agent: string;
|
||||
/** Resolved model id. */
|
||||
model: string;
|
||||
/** Shared per-session worktree (one per `sessions.id`, not per pane). */
|
||||
worktreePath: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/** Opaque handle to a live backend session, persisted to `agent_sessions` (§2). */
|
||||
export interface AgentSessionHandle {
|
||||
sessionId: string;
|
||||
agent: string;
|
||||
backend: AgentBackendKind;
|
||||
/** Provider's own session id (resume token); null until the backend assigns one. */
|
||||
agentSessionId: string | null;
|
||||
/** opencode HTTP server port; null for ACP backends. */
|
||||
serverPort: number | null;
|
||||
}
|
||||
|
||||
/** Per-turn context passed to `prompt` (§2). */
|
||||
export interface PromptCtx {
|
||||
worktreePath: string;
|
||||
model: string;
|
||||
signal: AbortSignal;
|
||||
onEvent: (e: AgentEvent) => void;
|
||||
}
|
||||
|
||||
/** Result of a completed turn (§2). Diff/persist happen outside the backend. */
|
||||
export interface TurnResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The core backend abstraction (§2). Implementations: OpenCodeServerBackend
|
||||
* (Phase 1), WarmAcpBackend (Phase 2).
|
||||
*/
|
||||
export interface AgentBackend {
|
||||
/** Lazy: spawn server / warm process if not already up for this (session, agent). §2 */
|
||||
ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle>;
|
||||
/** Send a prompt; stream events via ctx.onEvent; resolves when the turn completes. §2 */
|
||||
prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult>;
|
||||
/** Graceful teardown of one session (session close or idle timeout). §2 */
|
||||
closeSession(handle: AgentSessionHandle): Promise<void>;
|
||||
/** Full teardown — kills all spawned servers/processes. §2 */
|
||||
dispose(): Promise<void>;
|
||||
/** Liveness for health endpoint + dispatcher fallback decision. §2 */
|
||||
health(): 'up' | 'down';
|
||||
}
|
||||
44
apps/coder/src/services/agent-pool.ts
Normal file
44
apps/coder/src/services/agent-pool.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* v2.6 — AgentPool (Phase 0 scaffold).
|
||||
*
|
||||
* Lazy get-or-create registry of `AgentBackend` instances keyed by
|
||||
* `${sessionId}:${agent}`. Phase 0 ships the skeleton only: an in-memory Map,
|
||||
* lookup / register / health, and clean disposal wired to the server's onClose.
|
||||
* Spawning lands in Phase 1/2; nothing populates the map yet.
|
||||
*
|
||||
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2.
|
||||
*/
|
||||
import type { AgentBackend } from './agent-backend.js';
|
||||
|
||||
export class AgentPool {
|
||||
private readonly backends = new Map<string, AgentBackend>();
|
||||
|
||||
private key(sessionId: string, agent: string): string {
|
||||
return `${sessionId}:${agent}`;
|
||||
}
|
||||
|
||||
/** Map lookup only. Spawning is Phase 1/2 — never creates here. */
|
||||
get(sessionId: string, agent: string): AgentBackend | undefined {
|
||||
return this.backends.get(this.key(sessionId, agent));
|
||||
}
|
||||
|
||||
/** Store a backend instance for this (session, agent). */
|
||||
register(sessionId: string, agent: string, backend: AgentBackend): void {
|
||||
this.backends.set(this.key(sessionId, agent), backend);
|
||||
}
|
||||
|
||||
/** Summary for the health endpoint. */
|
||||
health(): { size: number } {
|
||||
return { size: this.backends.size };
|
||||
}
|
||||
|
||||
/** Dispose every backend and clear the map. Tolerates throwing backends. */
|
||||
async dispose(): Promise<void> {
|
||||
const entries = [...this.backends.values()];
|
||||
this.backends.clear();
|
||||
await Promise.allSettled(entries.map((b) => b.dispose()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Single shared instance — referenced only by the server's onClose hook in Phase 0. */
|
||||
export const agentPool = new AgentPool();
|
||||
12
data/coder-providers.example.json
Normal file
12
data/coder-providers.example.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"providers": {
|
||||
"goose": { "enabled": false },
|
||||
"amp-acp": {
|
||||
"extends": "acp",
|
||||
"label": "Amp",
|
||||
"description": "ACP wrapper for Amp",
|
||||
"command": ["amp-acp"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"providers": {}
|
||||
}
|
||||
Reference in New Issue
Block a user