Compare commits
4 Commits
v2.5.14-cl
...
v2.6.0-pha
| Author | SHA1 | Date | |
|---|---|---|---|
| 140ff26204 | |||
| a97293b5d9 | |||
| 63adb218e6 | |||
| d0334ca544 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,5 +16,5 @@ data/*
|
|||||||
!data/AGENTS.md
|
!data/AGENTS.md
|
||||||
!data/skills/
|
!data/skills/
|
||||||
!data/mcp.json
|
!data/mcp.json
|
||||||
!data/coder-providers.json
|
!data/coder-providers.example.json
|
||||||
codecontext/fork.tar.gz
|
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`
|
### 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
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
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
|
## 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).
|
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).
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
|
|||||||
- 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`.
|
- 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.
|
- `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.
|
- **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 the live config (tracked via a `.gitignore` exception, bind-mounted); UI toggles mutate it on disk → working-tree drift, don't commit it as a code change.
|
- `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.
|
- 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/`)
|
### Frontend (`apps/web/src/`)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { registerProviderRoutes } from './routes/providers.js';
|
|||||||
import { registerWebSocket } from './routes/ws.js';
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
// Phase 4: dispatcher + agent probe
|
// Phase 4: dispatcher + agent probe
|
||||||
import { createDispatcher } from './services/dispatcher.js';
|
import { createDispatcher } from './services/dispatcher.js';
|
||||||
|
import { agentPool } from './services/agent-pool.js';
|
||||||
import { probeAgents } from './services/agent-probe.js';
|
import { probeAgents } from './services/agent-probe.js';
|
||||||
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
||||||
import { setPermissionHooks } from './services/permission-waiter.js';
|
import { setPermissionHooks } from './services/permission-waiter.js';
|
||||||
@@ -178,7 +179,12 @@ async function main() {
|
|||||||
// Phase 4: dispatcher — polls tasks table and runs inference
|
// Phase 4: dispatcher — polls tasks table and runs inference
|
||||||
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
|
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
|
||||||
dispatcher.start();
|
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
|
// Register routes
|
||||||
registerMessageRoutes(app, sql, broker, inferenceApi);
|
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 thinking_option_id TEXT;
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
|
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,
|
-- 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
|
-- 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
|
-- 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 { 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. */
|
/** Resolve an ACP path against the agent worktree and read a slice of lines. */
|
||||||
export async function readWorktreeTextFile(
|
export async function readWorktreeTextFile(
|
||||||
@@ -8,10 +28,7 @@ export async function readWorktreeTextFile(
|
|||||||
line?: number | null,
|
line?: number | null,
|
||||||
limit?: number | null,
|
limit?: number | null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
const absolute = resolveInWorktree(worktreePath, filePath);
|
||||||
if (!absolute.startsWith(resolve(worktreePath))) {
|
|
||||||
throw new Error(`path escapes worktree: ${filePath}`);
|
|
||||||
}
|
|
||||||
const raw = await fs.readFile(absolute, 'utf8');
|
const raw = await fs.readFile(absolute, 'utf8');
|
||||||
if (!line && !limit) return raw;
|
if (!line && !limit) return raw;
|
||||||
const lines = raw.split(/\r?\n/);
|
const lines = raw.split(/\r?\n/);
|
||||||
@@ -26,10 +43,7 @@ export async function writeWorktreeTextFile(
|
|||||||
filePath: string,
|
filePath: string,
|
||||||
content: string,
|
content: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
const absolute = resolveInWorktree(worktreePath, filePath);
|
||||||
if (!absolute.startsWith(resolve(worktreePath))) {
|
|
||||||
throw new Error(`path escapes worktree: ${filePath}`);
|
|
||||||
}
|
|
||||||
await fs.mkdir(dirname(absolute), { recursive: true });
|
await fs.mkdir(dirname(absolute), { recursive: true });
|
||||||
await fs.writeFile(absolute, content, 'utf8');
|
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