Compare commits

..

4 Commits

Author SHA1 Message Date
631af5dd4c docs(changelog): v2.6.8-agent-attribution
CHANGELOG + roadmap shipped record (through v2.6.8) + openspec v2-6 Phase 1-UX checked off (U.1-U.6; Smoke U pending the frontend Docker rebuild).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:07:32 +00:00
5db6551361 feat(web): Phase 1-UX frontend — DiffPanel agent badges + resumed/new-session chip
DiffPanel renders a per-row agent badge (icon+label; null -> 'manual') + a 'Changes from X, Y' note when the pending set spans >1 agent. AgentComposerBar gains an optional sessionId prop -> resumed/history/new-session chip beside the Provider picker (gated, so BooChat callers are unchanged), driven by a new useAgentSessions hook (refetch on message-complete). providerIcon extracted to shared components/coder/providerIcons.tsx; api.coder gains agentSessions(sessionId); PendingChange type gains agent. web tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:07:26 +00:00
c060778258 feat(coder): Phase 1-UX backend — agent attribution + agent-sessions route + opencode usage
pending_changes.agent stamped at every queue site (native -> 'boocode', dispatched external -> task.agent, manual RightRail -> NULL) + flows through listPending. New GET /api/sessions/:id/agent-sessions -> [{agent,status,has_session,last_active_at}] per (chat,agent). opencode warm server consumes session.next.step.ended, accumulating input_tokens/output_tokens/cost onto agent_sessions (new idempotent columns) via a pure opencode-usage.ts mapper. Tests: agent-sessions.routes (3) + opencode-usage (6); tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:07:14 +00:00
48c1d70baf Merge f1-interrupt-guard: F.1 opencode post-interrupt stale-terminal guard + doc reconciliation (v2.6.7) 2026-05-31 21:32:25 +00:00
18 changed files with 620 additions and 41 deletions

View File

@@ -2,6 +2,10 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. 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.6.8-agent-attribution — 2026-05-31
v2.6 Phase 1-UX: agent attribution + switch affordances over the already-shipped `pending_changes.agent` column and `agent_sessions` table (read+display, no new backend capability). **Backend:** `pending_changes.agent` is now stamped at every queue site (native write tools → `'boocode'`, dispatched external agents → the task's agent, manual RightRail create → `NULL`) and flows through `listPending`; a new `GET /api/sessions/:id/agent-sessions` route returns `[{agent,status,has_session,last_active_at}]` per `(chat,agent)` for the session's chats; and the opencode warm-server backend consumes opencode's `session.next.step.ended` events, accumulating `input_tokens`/`output_tokens`/`cost` onto the `agent_sessions` row (new columns, idempotent). **Frontend:** the BooCoder DiffPanel renders a per-row agent badge (provider icon + label; `null` → "manual") with a "Changes from X, Y" note when a pending set spans multiple agents, and the AgentComposerBar shows a resumed / history / new-session chip beside the Provider picker — gated on an optional `sessionId` prop so BooChat is unaffected — driven by a new `useAgentSessions` hook that refetches on message-complete; `providerIcon` was extracted to a shared `components/coder/providerIcons.tsx`. Built by three parallel subagents over disjoint file sets; web + coder typecheck clean, 165 coder tests pass (9 new across `opencode-usage` and `agent-sessions.routes`). U.6's persisted token totals are conversation-cumulative and not yet surfaced in the UI (deferred). Implements the U.1U.6 "remaining" plan from the v2.6 openspec reconciliation; Phase 2 (warm ACP goose/qwen) + Phase 3 (lifecycle hardening) remain.
## v2.6.7-interrupt-guard — 2026-05-31 ## v2.6.7-interrupt-guard — 2026-05-31
Fixes a post-interrupt correctness bug in the `v2.6.1-phase1-opencode` warm-server backend, made one-click reachable by `v2.6.5-panes-tabs-composer`'s Send→Stop composer. `opencode-server.ts` settled an in-flight turn on opencode's `session.idle`/`session.error` by calling `activeTurn.settle()` on whatever turn currently held the session slot — but opencode emits one trailing terminal event for a *cancelled* turn after `client.session.abort()`, and those events carry only a `sessionID` (no turn id). So after the user hit Stop and immediately sent another message, the aborted turn's orphan `session.idle` settled the *new* turn early as success (Paseo hit and fixed the same class in `1d38aac`). The fix adds a small pure guard (`turn-guard.ts`: `armAbortGuard`/`noteTurnActivity`/`consumeTerminal` over a per-session `swallowNextTerminal` flag): abort arms it, the next terminal is swallowed once, and a new turn's first delta self-heals the flag so a never-arriving orphan can't strand a real turn. Implemented test-first — three regression tests in `turn-guard.test.ts` (swallow-the-orphan, settle-when-no-abort, self-heal); full coder suite green (156 passed). This is the F.1 "fix-next" item from the v2.6 openspec reconciliation; Phase 1-UX / Phase 2 / Phase 3 remain. Fixes a post-interrupt correctness bug in the `v2.6.1-phase1-opencode` warm-server backend, made one-click reachable by `v2.6.5-panes-tabs-composer`'s Send→Stop composer. `opencode-server.ts` settled an in-flight turn on opencode's `session.idle`/`session.error` by calling `activeTurn.settle()` on whatever turn currently held the session slot — but opencode emits one trailing terminal event for a *cancelled* turn after `client.session.abort()`, and those events carry only a `sessionID` (no turn id). So after the user hit Stop and immediately sent another message, the aborted turn's orphan `session.idle` settled the *new* turn early as success (Paseo hit and fixed the same class in `1d38aac`). The fix adds a small pure guard (`turn-guard.ts`: `armAbortGuard`/`noteTurnActivity`/`consumeTerminal` over a per-session `swallowNextTerminal` flag): abort arms it, the next terminal is swallowed once, and a new turn's first delta self-heals the flag so a never-arriving orphan can't strand a real turn. Implemented test-first — three regression tests in `turn-guard.test.ts` (swallow-the-orphan, settle-when-no-abort, self-heal); full coder suite green (156 passed). This is the F.1 "fix-next" item from the v2.6 openspec reconciliation; Phase 1-UX / Phase 2 / Phase 3 remain.

View File

@@ -25,6 +25,7 @@ import { setInferenceContext, clearInferenceContext } from './services/tools/inf
import { registerMessageRoutes } from './routes/messages.js'; import { registerMessageRoutes } from './routes/messages.js';
import { registerSkillRoutes } from './routes/skills.js'; import { registerSkillRoutes } from './routes/skills.js';
import { registerPendingRoutes } from './routes/pending.js'; import { registerPendingRoutes } from './routes/pending.js';
import { registerAgentSessionRoutes } from './routes/agent-sessions.js';
import { registerTaskRoutes } from './routes/tasks.js'; import { registerTaskRoutes } from './routes/tasks.js';
import { registerInboxRoutes } from './routes/inbox.js'; import { registerInboxRoutes } from './routes/inbox.js';
import { registerStatsRoutes } from './routes/stats.js'; import { registerStatsRoutes } from './routes/stats.js';
@@ -191,6 +192,7 @@ async function main() {
registerMessageRoutes(app, sql, broker, inferenceApi); registerMessageRoutes(app, sql, broker, inferenceApi);
registerSkillRoutes(app, sql, broker, inferenceApi); registerSkillRoutes(app, sql, broker, inferenceApi);
registerPendingRoutes(app, sql); registerPendingRoutes(app, sql);
registerAgentSessionRoutes(app, sql);
registerTaskRoutes(app, sql, inferenceApi); registerTaskRoutes(app, sql, inferenceApi);
registerInboxRoutes(app, sql); registerInboxRoutes(app, sql);
registerStatsRoutes(app, sql); registerStatsRoutes(app, sql);

View File

@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import { registerAgentSessionRoutes } from '../agent-sessions.js';
import type { Sql } from '../../db.js';
// Mock the porsager surface this route uses: a tagged-template `sql` dispatched by
// query substring. Two queries: the session-existence check and the agent_sessions
// JOIN. We return post-coercion shapes (booleans/strings) exactly as porsager would
// hand them to the route — `has_session` already a JS boolean, `last_active_at` a
// string|null — so the asserted JSON matches the API contract end-to-end.
interface MockState {
sessionExists: boolean;
rows: Array<{ agent: string; status: string; has_session: boolean; last_active_at: string | null }>;
}
function mockSql(state: MockState): Sql {
return ((strings: TemplateStringsArray) => {
const q = strings.join('');
if (q.includes('SELECT id FROM sessions')) {
return Promise.resolve(state.sessionExists ? [{ id: 'session-1' }] : []);
}
if (q.includes('FROM agent_sessions')) {
return Promise.resolve(state.rows);
}
return Promise.resolve([]);
}) as unknown as Sql;
}
function buildApp(state: MockState): FastifyInstance {
const app = Fastify();
registerAgentSessionRoutes(app, mockSql(state));
return app;
}
describe('GET /api/sessions/:id/agent-sessions', () => {
it('returns the per-(chat,agent) rows in the contracted shape', async () => {
const app = buildApp({
sessionExists: true,
rows: [
{ agent: 'opencode', status: 'active', has_session: true, last_active_at: '2026-05-31T12:00:00.000Z' },
{ agent: 'goose', status: 'idle', has_session: false, last_active_at: null },
],
});
const res = await app.inject({ method: 'GET', url: '/api/sessions/session-1/agent-sessions' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toEqual([
{ agent: 'opencode', status: 'active', has_session: true, last_active_at: '2026-05-31T12:00:00.000Z' },
{ agent: 'goose', status: 'idle', has_session: false, last_active_at: null },
]);
// Contract field types.
expect(typeof body[0].agent).toBe('string');
expect(typeof body[0].status).toBe('string');
expect(typeof body[0].has_session).toBe('boolean');
expect(body[1].last_active_at).toBeNull();
await app.close();
});
it('returns an empty array when the session has no agent_sessions rows', async () => {
const app = buildApp({ sessionExists: true, rows: [] });
const res = await app.inject({ method: 'GET', url: '/api/sessions/session-1/agent-sessions' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual([]);
await app.close();
});
it('404s when the session does not exist', async () => {
const app = buildApp({ sessionExists: false, rows: [] });
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/agent-sessions' });
expect(res.statusCode).toBe(404);
expect(res.json()).toEqual({ error: 'session not found' });
await app.close();
});
});

View File

@@ -0,0 +1,51 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
// v2.6 Phase 1-UX (design §9b): chat-scoped "resumed vs new session" indicator.
// `agent_sessions` is keyed (chat_id, agent) — the tab/chat is the agent-context
// unit (P1.5-b). The route param is a SESSION id, so we resolve every chat in the
// session and return the union of their agent_sessions rows. A session with two
// opencode tabs yields two rows (one per chat); the frontend keys the chip per
// chat, but the wire shape is a flat per-(chat,agent) list.
//
// has_session = agent_session_id IS NOT NULL — i.e. a native backend session id
// (opencode/ACP) was created and stored, so switching back resumes rather than
// starts fresh.
export interface AgentSessionRow {
agent: string;
status: string;
has_session: boolean;
last_active_at: string | null;
}
export function registerAgentSessionRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/sessions/:sessionId/agent-sessions — list the agent-session rows for
// every chat in the session (drives the AgentComposerBar resumed/new chip).
app.get<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/agent-sessions',
async (req, reply) => {
const sessionId = req.params.sessionId;
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
if (session.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
// Join through chats so the session-scoped param resolves to its (chat,agent)
// rows. last_active_at first → the frontend reads the freshest activity.
const rows = await sql<AgentSessionRow[]>`
SELECT
a.agent AS agent,
a.status AS status,
(a.agent_session_id IS NOT NULL) AS has_session,
a.last_active_at AS last_active_at
FROM agent_sessions a
JOIN chats c ON c.id = a.chat_id
WHERE c.session_id = ${sessionId}
ORDER BY a.last_active_at DESC NULLS LAST, a.agent ASC
`;
return rows;
},
);
}

View File

@@ -90,6 +90,8 @@ export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
parsed.data.file_path, parsed.data.file_path,
parsed.data.content, parsed.data.content,
projectRoot, projectRoot,
// Manual RightRail create — no agent staged it; renders as "manual".
null,
); );
return change; return change;
} catch (err) { } catch (err) {

View File

@@ -131,6 +131,17 @@ END $$;
-- v2.6: config fingerprint for stale-session detection (auto-recover on model change). -- v2.6: config fingerprint for stale-session detection (auto-recover on model change).
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT; ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT;
-- v2.6 Phase 1-UX (U.6): opencode token/cost usage, ACCUMULATED per (chat_id, agent).
-- opencode's warm server emits `session.next.step.ended` once per LLM step (several
-- per multi-tool turn) carrying {tokens{input,output,reasoning,cache},cost}. We sum
-- each step's normalized {input,output,cost} onto the session row — running totals
-- for the whole conversation context, not last-step. Backend-only; no route/UI yet.
-- input_tokens folds in cache read+write; output_tokens folds in reasoning (see
-- backends/opencode-usage.ts). Defaults 0 so accumulation (col + delta) is well-defined.
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS input_tokens BIGINT NOT NULL DEFAULT 0;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS output_tokens BIGINT NOT NULL DEFAULT 0;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS cost DOUBLE PRECISION NOT NULL DEFAULT 0;
-- ─── P1.5-b (corrected): worktrees entity + re-key agent_sessions to (chat_id, agent) ─── -- ─── P1.5-b (corrected): worktrees entity + re-key agent_sessions to (chat_id, agent) ───
-- The TAB (a chat) is the context unit: two opencode tabs in one session = two -- The TAB (a chat) is the context unit: two opencode tabs in one session = two
-- independent contexts sharing one worktree. So agent_sessions keys on -- independent contexts sharing one worktree. So agent_sessions keys on

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { stepEndedToUsage } from '../opencode-usage.js';
describe('stepEndedToUsage (U.6)', () => {
it('folds cache read+write into input and reasoning into output', () => {
const u = stepEndedToUsage({
cost: 0.0123,
tokens: { input: 100, output: 50, reasoning: 20, cache: { read: 10, write: 5 } },
});
expect(u).toEqual({ input: 115, output: 70, cost: 0.0123 });
});
it('handles a step with no cache and no reasoning', () => {
const u = stepEndedToUsage({
cost: 0,
tokens: { input: 8, output: 4, reasoning: 0, cache: { read: 0, write: 0 } },
});
expect(u).toEqual({ input: 8, output: 4, cost: 0 });
});
it('is defensive against a missing tokens block', () => {
const u = stepEndedToUsage({ cost: 0.5 } as never);
expect(u).toEqual({ input: 0, output: 0, cost: 0.5 });
});
it('is defensive against undefined props', () => {
expect(stepEndedToUsage(undefined)).toEqual({ input: 0, output: 0, cost: 0 });
});
it('drops NaN / negative noise to zero rather than poisoning the accumulated total', () => {
const u = stepEndedToUsage({
cost: Number.NaN,
tokens: {
input: -5,
output: Number.NaN,
reasoning: 3,
cache: { read: Number.POSITIVE_INFINITY, write: 2 },
},
});
// input: (-5→0) + (Inf→0) + 2 = 2; output: (NaN→0) + 3 = 3; cost: NaN→0
expect(u).toEqual({ input: 2, output: 3, cost: 0 });
});
it('rounds fractional token counts', () => {
const u = stepEndedToUsage({
cost: 1.5,
tokens: { input: 10.6, output: 4.4, reasoning: 0, cache: { read: 0, write: 0 } },
});
expect(u).toEqual({ input: 11, output: 4, cost: 1.5 });
});
});

View File

@@ -38,6 +38,7 @@ import type { ToolCallStatus } from '@agentclientprotocol/sdk';
import type { Sql } from '../../db.js'; import type { Sql } from '../../db.js';
import type { AcpToolSnapshot } from '../acp-tool-snapshot.js'; import type { AcpToolSnapshot } from '../acp-tool-snapshot.js';
import { armAbortGuard, noteTurnActivity, consumeTerminal } from './turn-guard.js'; import { armAbortGuard, noteTurnActivity, consumeTerminal } from './turn-guard.js';
import { stepEndedToUsage, type StepUsage } from './opencode-usage.js';
import type { import type {
AgentBackend, AgentBackend,
AgentEvent, AgentEvent,
@@ -282,6 +283,19 @@ export class OpenCodeServerBackend implements AgentBackend {
st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap }); st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap });
return; return;
} }
// ─── per-step usage (U.6) — token/cost accounting for opencode sessions ──
case 'session.next.step.ended': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
// Accumulate this step's normalized usage onto the (chat_id, agent) row.
// Fire-and-forget: a DB hiccup must not stall the turn. opencode emits this
// once per LLM step, so a multi-tool turn sums several deltas.
const usage = stepEndedToUsage(p);
void this.accumulateUsage(st, usage);
return;
}
// ─── message.part.* — terminal/post-hoc events (dedup gate) ──────────── // ─── message.part.* — terminal/post-hoc events (dedup gate) ────────────
case 'message.part.delta': { case 'message.part.delta': {
const p = ev.properties; const p = ev.properties;
@@ -428,6 +442,33 @@ export class OpenCodeServerBackend implements AgentBackend {
} }
} }
// ─── per-step usage persistence (U.6) ────────────────────────────────────────
/**
* Accumulate one `session.next.step.ended`'s normalized usage onto the session's
* agent_sessions row, keyed by the resumed `agent_session_id` (unique per active
* row — the dispatcher's `(chat_id, agent)` lookup wrote it). Running totals for
* the whole conversation context (not last-step). Zero-delta steps are skipped to
* avoid a no-op write. Errors are swallowed: usage telemetry must never fail a turn.
*/
private async accumulateUsage(st: SessionState, u: StepUsage): Promise<void> {
if (u.input === 0 && u.output === 0 && u.cost === 0) return;
try {
await this.sql`
UPDATE agent_sessions SET
input_tokens = input_tokens + ${u.input},
output_tokens = output_tokens + ${u.output},
cost = cost + ${u.cost}
WHERE agent_session_id = ${st.agentSessionId}
`;
} catch (err) {
this.log.warn(
{ err: errMsg(err), agentSessionId: st.agentSessionId },
'opencode-server: failed to persist step usage (non-fatal)',
);
}
}
// ─── ensureSession: create-or-resume against agent_sessions (1.5) ──────────── // ─── ensureSession: create-or-resume against agent_sessions (1.5) ────────────
async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> { async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> {

View File

@@ -0,0 +1,77 @@
/**
* v2.6 Phase 1-UX (U.6) — pure mapper for opencode's per-step usage event.
*
* opencode's warm server emits `session.next.step.ended` once per completed LLM
* step (so a multi-tool turn fires it several times). Its `properties` carry the
* step's token + cost accounting:
*
* {
* timestamp: number;
* sessionID: string;
* finish: string;
* cost: number; // USD for this step
* tokens: {
* input: number; output: number; reasoning: number;
* cache: { read: number; write: number };
* };
* snapshot?: string;
* }
*
* (Verified against @opencode-ai/sdk@1.15.12 — `EventSessionNextStepEnded` in
* `dist/v2/gen/types.gen.d.ts`, a member of the `Event` union the SSE loop
* switches on.)
*
* We normalize to the review's target slice `{input, output, cost}` (the
* provider-agnostic `AgentUsage` shape lands later). cache read/write tokens are
* folded into `input` so the persisted input count reflects the real context the
* model billed for; reasoning tokens are folded into `output` since that's what
* the provider counts them as for generation. This keeps the persisted totals a
* faithful sum of what opencode reported, without inventing extra columns yet.
*/
/** The `properties` shape of a `session.next.step.ended` event (subset we read). */
export interface StepEndedProps {
cost: number;
tokens: {
input: number;
output: number;
reasoning: number;
cache: { read: number; write: number };
};
}
/** Normalized per-step usage delta persisted onto the agent_sessions row. */
export interface StepUsage {
input: number;
output: number;
cost: number;
}
/** Coerce a possibly-missing/NaN number to a non-negative finite integer (tokens). */
function n(v: unknown): number {
const x = typeof v === 'number' ? v : Number(v);
return Number.isFinite(x) && x > 0 ? Math.round(x) : 0;
}
/** Coerce a possibly-missing/NaN number to a non-negative finite float (cost USD). */
function f(v: unknown): number {
const x = typeof v === 'number' ? v : Number(v);
return Number.isFinite(x) && x > 0 ? x : 0;
}
/**
* Map a `session.next.step.ended` payload → the normalized `{input, output, cost}`
* delta. Defensive against missing/partial token blocks (the wire is trusted but
* we never want a NaN to poison the accumulated DB total). `input` folds in cache
* read+write; `output` folds in reasoning.
*/
export function stepEndedToUsage(props: Partial<StepEndedProps> | undefined): StepUsage {
const t = props?.tokens;
const cacheRead = n(t?.cache?.read);
const cacheWrite = n(t?.cache?.write);
return {
input: n(t?.input) + cacheRead + cacheWrite,
output: n(t?.output) + n(t?.reasoning),
cost: f(props?.cost),
};
}

View File

@@ -441,10 +441,11 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
const diff = await diffWorktree(worktreePath, projectPath, { signal: ac.signal }); const diff = await diffWorktree(worktreePath, projectPath, { signal: ac.signal });
if (diff) { if (diff) {
// Queue a single pending_change entry with the full unified diff // Queue a single pending_change entry with the full unified diff, stamped
// with the dispatched agent for DiffPanel attribution (v2.6 Phase 1-UX).
await sql` await sql`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff) INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${projectPath}, 'edit', ${diff}) VALUES (${sessionId}, ${taskId}, ${projectPath}, 'edit', ${diff}, ${agent})
`; `;
log.info({ taskId, diffLength: diff.length }, 'dispatcher: diff queued as pending change'); log.info({ taskId, diffLength: diff.length }, 'dispatcher: diff queued as pending change');
} else { } else {

View File

@@ -13,6 +13,10 @@ export interface PendingChange {
operation: 'create' | 'edit' | 'delete'; operation: 'create' | 'edit' | 'delete';
diff: string; diff: string;
status: 'pending' | 'applied' | 'rejected' | 'reverted'; status: 'pending' | 'applied' | 'rejected' | 'reverted';
// v2.6 Phase 1-UX: which agent staged this change (DiffPanel attribution).
// Native boocode write tools stamp 'boocode'; the manual RightRail create path
// passes null (renders as "manual"). NULL on legacy rows queued pre-v2.6.
agent: string | null;
created_at: string; created_at: string;
} }
@@ -34,13 +38,17 @@ export async function queueEdit(
oldString: string, oldString: string,
newString: string, newString: string,
projectRoot: string, projectRoot: string,
// v2.6 Phase 1-UX: attribution. Defaults to 'boocode' because the only callers
// that omit it are the native write tools (edit_file/create_file/delete_file).
// Pass null explicitly for the manual RightRail create path.
agent: string | null = 'boocode',
): Promise<PendingChange> { ): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath); const resolved = resolveWritePath(projectRoot, filePath);
const diff = JSON.stringify({ old: oldString, new: newString }); const diff = JSON.stringify({ old: oldString, new: newString });
const [row] = await sql<PendingChange[]>` const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff) INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}) VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent})
RETURNING * RETURNING *
`; `;
return row!; return row!;
@@ -53,12 +61,15 @@ export async function queueCreate(
filePath: string, filePath: string,
content: string, content: string,
projectRoot: string, projectRoot: string,
// See queueEdit: defaults to 'boocode' for the native write tools; the manual
// RightRail create route passes null.
agent: string | null = 'boocode',
): Promise<PendingChange> { ): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath); const resolved = resolveWritePath(projectRoot, filePath);
const [row] = await sql<PendingChange[]>` const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff) INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}) VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent})
RETURNING * RETURNING *
`; `;
return row!; return row!;
@@ -70,12 +81,14 @@ export async function queueDelete(
taskId: string | null, taskId: string | null,
filePath: string, filePath: string,
projectRoot: string, projectRoot: string,
// See queueEdit: defaults to 'boocode' for the native write tools.
agent: string | null = 'boocode',
): Promise<PendingChange> { ): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath); const resolved = resolveWritePath(projectRoot, filePath);
const [row] = await sql<PendingChange[]>` const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff) INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '') VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent})
RETURNING * RETURNING *
`; `;
return row!; return row!;

View File

@@ -25,6 +25,17 @@ import type {
WorkspaceState, WorkspaceState,
} from './types'; } from './types';
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by
// GET /api/coder/sessions/:id/agent-sessions; drives the AgentComposerBar
// resumed/new-session chip via useAgentSessions. `has_session` is true when a
// resumable backend session id exists for that agent in the chat.
export interface AgentSessionInfo {
agent: string;
status: string;
has_session: boolean;
last_active_at: string | null;
}
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
public status: number, public status: number,
@@ -363,6 +374,11 @@ export const api = {
request<CoderMessageWire[]>( request<CoderMessageWire[]>(
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`, `/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
), ),
// v2.6 Phase 1-UX §9b: per-(chat,agent) backend-session state for the
// resumed/new-session chip. Chat-scoped (NOT foldable into the project-level
// provider snapshot). Proxied to boocoder at /api/sessions/:id/agent-sessions.
agentSessions: (sessionId: string) =>
request<AgentSessionInfo[]>(`/api/coder/sessions/${sessionId}/agent-sessions`),
skillInvoke: ( skillInvoke: (
sessionId: string, sessionId: string,
paneId: string, paneId: string,

View File

@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react'; import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bot } from 'lucide-react';
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types'; import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot'; import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
import { providerIcon } from '@/components/coder/providerIcons';
import { useAgentSessions } from '@/hooks/useAgentSessions';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -172,9 +173,36 @@ interface Props {
onChange: (next: AgentSessionConfig) => void; onChange: (next: AgentSessionConfig) => void;
onProviderCommandsChange?: (commands: AgentCommand[]) => void; onProviderCommandsChange?: (commands: AgentCommand[]) => void;
connected?: boolean; connected?: boolean;
// v2.6 Phase 1-UX §9b: chat id for the resumed/new-session chip. Optional so
// BooChat and any other AgentComposerBar caller renders no chip and is
// otherwise unaffected. When present + connected + the chat has ≥1 prior
// turn, a chip right of the Provider picker reports whether switching to the
// current provider resumes an agent session, replays history (boocode), or
// starts fresh.
sessionId?: string;
// True once the chat has at least one prior turn — gates the chip so it stays
// hidden on a brand-new chat. Defaults to false (no chip).
hasPriorTurn?: boolean;
} }
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) { // Relative-time formatter for the resumed-chip title (e.g. "3m ago").
function relativeTime(iso: string | null): string {
if (!iso) return 'unknown';
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return 'unknown';
const diffMs = Date.now() - then;
if (diffMs < 0) return 'just now';
const sec = Math.floor(diffMs / 1000);
if (sec < 60) return 'just now';
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn }: Props) {
const allEntries = useProviderSnapshot(projectPath); const allEntries = useProviderSnapshot(projectPath);
// 5.5 — the composer picker only offers ENABLED providers that are ready (or // 5.5 — the composer picker only offers ENABLED providers that are ready (or
// still loading). Disabled (enabled:false) and unavailable/error providers are // still loading). Disabled (enabled:false) and unavailable/error providers are
@@ -186,6 +214,13 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
); );
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows for the resumed/new
// chip. Hook is unconditional (hooks rule); it self-no-ops when sessionId is
// undefined or the chat has no prior turn, so BooChat callers cost nothing.
const { sessions: agentSessions } = useAgentSessions(
sessionId && hasPriorTurn ? sessionId : undefined,
);
const hydratedRef = useRef(false); const hydratedRef = useRef(false);
useEffect(() => { useEffect(() => {
@@ -294,21 +329,30 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
); );
} }
const providerIcon = (name: string) => {
switch (name) {
case 'claude': return <ClaudeIcon size={13} className="shrink-0" />;
case 'opencode': return <OpenCodeIcon size={13} className="shrink-0" />;
case 'goose': return <Bird size={13} className="shrink-0" />;
case 'qwen': return <TermIcon size={13} className="shrink-0" />;
default: return <Dog size={13} className="shrink-0" />;
}
};
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label })); const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label })); const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label })); const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label })); const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
// v2.6 Phase 1-UX §9b: resumed / history / new-session chip. Only meaningful
// when this is a real chat (sessionId), the WS is connected, and the chat has
// ≥1 prior turn — otherwise render nothing so fresh chats and non-coder
// callers stay clean.
const sessionRow = agentSessions.find((s) => s.agent === value.provider);
const sessionChip: { label: string; title: string } | null =
sessionId && hasPriorTurn && connected
? value.provider === 'boocode'
? // Native boocode never holds an agent_sessions row — it reconstructs
// the conversation from the chat transcript each turn.
{ label: 'history', title: 'BooCode replays the chat transcript each turn' }
: sessionRow?.has_session
? {
label: 'resumed',
title: `Resuming ${value.provider} · last active ${relativeTime(sessionRow.last_active_at)}`,
}
: { label: 'new session', title: `${value.provider} starts a fresh session this turn` }
: null;
return ( return (
<div className="flex flex-wrap items-center gap-1 px-2 py-1 border-b border-border bg-muted/20 shrink-0"> <div className="flex flex-wrap items-center gap-1 px-2 py-1 border-b border-border bg-muted/20 shrink-0">
<CompactPicker <CompactPicker
@@ -322,6 +366,14 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
: providerIcon(value.provider) : providerIcon(value.provider)
} }
/> />
{sessionChip && (
<span
title={sessionChip.title}
className="inline-flex items-center rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground shrink-0"
>
{sessionChip.label}
</span>
)}
<CompactPicker <CompactPicker
label="Mode" label="Mode"
value={value.modeId ?? ''} value={value.modeId ?? ''}

View File

@@ -0,0 +1,56 @@
// Shared provider icon + label helpers for BooCoder UI.
//
// Single source of truth for the per-provider glyph used in the
// AgentComposerBar picker and the CoderPane DiffPanel agent-attribution
// badges (v2.6 Phase 1-UX §9a/§9b). Extracted from AgentComposerBar's local
// `providerIcon` switch so both call sites stay in sync.
import type { ReactNode } from 'react';
import { Bird, Dog, Terminal as TermIcon } from 'lucide-react';
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
/**
* Glyph for a provider/agent name. Mirrors AgentComposerBar's original
* `providerIcon` switch verbatim — `boocode` (native) falls through to the
* neutral dog like any unmapped name, preserving the composer's prior look.
* Sized to match the picker (13px) by default; pass a different size for
* inline badges.
*/
export function providerIcon(name: string | null, size = 13): ReactNode {
switch (name) {
case 'claude':
return <ClaudeIcon size={size} className="shrink-0" />;
case 'opencode':
return <OpenCodeIcon size={size} className="shrink-0" />;
case 'goose':
return <Bird size={size} className="shrink-0" />;
case 'qwen':
return <TermIcon size={size} className="shrink-0" />;
default:
return <Dog size={size} className="shrink-0" />;
}
}
/**
* Human label for a provider/agent name. `null` → "manual" (a RightRail-staged
* change with no dispatching agent, per §9a). Unknown names pass through
* verbatim so a future provider still reads sensibly.
*/
export function providerLabel(name: string | null): string {
switch (name) {
case null:
return 'manual';
case 'boocode':
return 'BooCode';
case 'opencode':
return 'opencode';
case 'claude':
return 'Claude';
case 'goose':
return 'goose';
case 'qwen':
return 'Qwen';
default:
return name;
}
}

View File

@@ -16,6 +16,8 @@ import { toast } from 'sonner';
import { isSlashCommandToken, mergeCommandsByName, parseSlashInput, slashQuery } from '@/lib/slash-command'; import { isSlashCommandToken, mergeCommandsByName, parseSlashInput, slashQuery } from '@/lib/slash-command';
import { mergeWireToolCall } from '@/lib/coder-tools'; import { mergeWireToolCall } from '@/lib/coder-tools';
import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList'; import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList';
import { providerIcon, providerLabel } from '@/components/coder/providerIcons';
import { refreshAgentSessions } from '@/hooks/useAgentSessions';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -56,6 +58,10 @@ interface PendingChange {
diff?: string; diff?: string;
new_content?: string; new_content?: string;
status: 'pending' | 'approved' | 'rejected'; status: 'pending' | 'approved' | 'rejected';
// v2.6 Phase 1-UX §9a: which agent staged this change. 'boocode' for native
// write tools, the dispatched agent for worktree edits, null for a manual
// RightRail-staged create (renders as a neutral "manual" badge).
agent: string | null;
} }
interface Props { interface Props {
@@ -394,6 +400,15 @@ function DiffPanel({
}) { }) {
const pending = changes.filter((c) => c.status === 'pending'); const pending = changes.filter((c) => c.status === 'pending');
// v2.6 Phase 1-UX §9a: when pending changes span >1 distinct agent, surface a
// one-line "Changes from <a>, <b>" note so mixed provenance is obvious. Null
// (manual) counts as its own bucket and renders as "manual".
const distinctAgents = Array.from(new Set(pending.map((c) => c.agent)));
const mixedNote =
distinctAgents.length > 1
? `Changes from ${distinctAgents.map((a) => providerLabel(a)).join(', ')}`
: null;
return ( return (
<div className="flex flex-col h-full border-t border-border"> <div className="flex flex-col h-full border-t border-border">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30"> <div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30">
@@ -410,6 +425,11 @@ function DiffPanel({
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} /> <RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
</button> </button>
</div> </div>
{mixedNote && (
<div className="px-3 py-1 border-b border-border bg-muted/10 text-[11px] text-muted-foreground truncate">
{mixedNote}
</div>
)}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{pending.length === 0 ? ( {pending.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground"> <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
@@ -420,14 +440,25 @@ function DiffPanel({
{pending.map((change) => ( {pending.map((change) => (
<div key={change.id} className="px-3 py-2"> <div key={change.id} className="px-3 py-2">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-xs font-mono text-foreground truncate flex-1 mr-2"> <span className="text-xs font-mono text-foreground truncate flex-1 mr-2 inline-flex items-center min-w-0">
<span
className="inline-flex items-center gap-1 rounded border border-border bg-muted/40 px-1 py-px mr-1.5 text-[10px] font-medium text-muted-foreground shrink-0"
title={
change.agent === null
? 'Manually staged (no dispatching agent)'
: `Staged by ${providerLabel(change.agent)}`
}
>
{providerIcon(change.agent, 11)}
<span>{providerLabel(change.agent)}</span>
</span>
<span className={cn( <span className={cn(
'inline-block w-1.5 h-1.5 rounded-full mr-1.5', 'inline-block w-1.5 h-1.5 rounded-full mr-1.5 shrink-0',
change.operation === 'create' && 'bg-green-500', change.operation === 'create' && 'bg-green-500',
change.operation === 'modify' && 'bg-yellow-500', change.operation === 'modify' && 'bg-yellow-500',
change.operation === 'delete' && 'bg-red-500', change.operation === 'delete' && 'bg-red-500',
)} /> )} />
{change.file_path} <span className="truncate">{change.file_path}</span>
</span> </span>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button <button
@@ -586,15 +617,24 @@ export function CoderPane({
// dispatch returns — so queueing/stop must key on this combined signal. // dispatch returns — so queueing/stop must key on this combined signal.
const generating = sending || activeTaskId !== null; const generating = sending || activeTaskId !== null;
// Refresh pending changes when a message_complete arrives // Refresh pending changes (and agent-session state for the §9b chip) when a
// message_complete arrives — same trigger usePendingChanges already uses.
useEffect(() => { useEffect(() => {
const lastAssistant = [...messages].reverse().find( const lastAssistant = [...messages].reverse().find(
(m): m is CoderMessage => m.role === 'assistant', (m): m is CoderMessage => m.role === 'assistant',
); );
if (lastAssistant?.status === 'complete') { if (lastAssistant?.status === 'complete') {
refresh(); refresh();
void refreshAgentSessions(sessionId);
} }
}, [messages, refresh]); }, [messages, refresh, sessionId]);
// The §9b chip only shows once the chat has ≥1 prior turn (a completed
// assistant message). Hidden on a brand-new chat.
const hasPriorTurn = useMemo(
() => messages.some((m) => m.role === 'assistant' && (m as CoderMessage).status === 'complete'),
[messages],
);
// Poll fallbacks when WS is disconnected (reconnect uses WS as source of truth) // Poll fallbacks when WS is disconnected (reconnect uses WS as source of truth)
useEffect(() => { useEffect(() => {
@@ -834,6 +874,8 @@ export function CoderPane({
onChange={setAgentConfig} onChange={setAgentConfig}
onProviderCommandsChange={handleProviderCommandsChange} onProviderCommandsChange={handleProviderCommandsChange}
connected={connected} connected={connected}
sessionId={sessionId}
hasPriorTurn={hasPriorTurn}
/> />
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */} {/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">

View File

@@ -0,0 +1,88 @@
// v2.6 Phase 1-UX §9b — chat-scoped agent-session state.
//
// Reads GET /api/coder/sessions/:id/agent-sessions (the per-(chat,agent)
// backend-session rows) and drives the AgentComposerBar resumed/new-session
// chip. Module-singleton external store keyed by sessionId — same shape as
// useProviderSnapshot — so the two consumers (CoderPane, which owns the
// message_complete WS signal, and AgentComposerBar, which renders the chip)
// share one cache and one fetch per chat. CoderPane calls
// refreshAgentSessions(sessionId) on each message_complete (the same trigger
// usePendingChanges already keys off); the chip then reflects the freshly
// resumed/created session.
import { useEffect, useSyncExternalStore } from 'react';
import { api, type AgentSessionInfo } from '@/api/client';
type Entry = {
data: AgentSessionInfo[];
inflight: Promise<AgentSessionInfo[]> | null;
};
const store = new Map<string, Entry>();
const listeners = new Set<() => void>();
const EMPTY: AgentSessionInfo[] = [];
function notify(): void {
for (const fn of listeners) fn();
}
function subscribe(fn: () => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
function getEntry(sessionId: string): Entry {
let entry = store.get(sessionId);
if (!entry) {
entry = { data: EMPTY, inflight: null };
store.set(sessionId, entry);
}
return entry;
}
async function doFetch(sessionId: string): Promise<AgentSessionInfo[]> {
const data = await api.coder.agentSessions(sessionId);
const entry = getEntry(sessionId);
entry.data = data;
entry.inflight = null;
notify();
return data;
}
function ensureLoaded(sessionId: string): void {
const entry = getEntry(sessionId);
if (entry.data !== EMPTY || entry.inflight) return;
entry.inflight = doFetch(sessionId).catch(() => {
// boocoder may be down or the chat has no agent-session rows yet; treat as
// empty (the chip falls back to "new session" / hides).
const e = getEntry(sessionId);
e.inflight = null;
return EMPTY;
});
}
/** Force a refetch for one chat. Wired to message_complete by CoderPane. */
export function refreshAgentSessions(sessionId: string): Promise<AgentSessionInfo[]> {
const entry = getEntry(sessionId);
entry.inflight = null;
return doFetch(sessionId);
}
/**
* Chat-scoped agent-session rows. Pass `undefined` to opt out (no fetch, empty
* result) — AgentComposerBar does this for BooChat callers and fresh chats so
* the chip stays hidden. Fetches on mount (and on sessionId change); refetch on
* message_complete is driven externally via refreshAgentSessions.
*/
export function useAgentSessions(sessionId: string | undefined): {
sessions: AgentSessionInfo[];
} {
const sessions = useSyncExternalStore(
subscribe,
() => (sessionId ? getEntry(sessionId).data : EMPTY),
);
useEffect(() => {
if (sessionId) ensureLoaded(sessionId);
}, [sessionId]);
return { sessions: sessionId ? sessions : EMPTY };
}

View File

@@ -348,7 +348,7 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
----- -----
## Shipped (v2.2.2v2.6.7 — interactive ACP, provider lifecycle, persistent agent sessions, workspace UX) ## Shipped (v2.2.2v2.6.8 — interactive ACP, provider lifecycle, persistent agent sessions, workspace UX)
All tags `vMAJOR.MINOR.PATCH-slug`, monotonic per minor, assigned at ship time (planning slugs differ — see the numbering-discipline note below). `CHANGELOG.md` is the canonical per-tag record. **Note on numbering divergence:** the *planned-feature* "v2.3 — Provider lifecycle" actually shipped under the **v2.5.4v2.5.13** tags; the *planned-feature* "v2.4 — BooCoder as ACP agent" remains **unshipped** even though v2.4.0/v2.4.1 *tags* shipped unrelated content (Unsloth lifts, sidecar routing). The patch-tag thread and the conceptual-milestone thread have diverged — read tags as the ship record, the `## v2.x` feature sections below as the milestone plan. The v2.3.0v2.5.1 tags were never CHANGELOG-backfilled; summarized here from commit bodies. All tags `vMAJOR.MINOR.PATCH-slug`, monotonic per minor, assigned at ship time (planning slugs differ — see the numbering-discipline note below). `CHANGELOG.md` is the canonical per-tag record. **Note on numbering divergence:** the *planned-feature* "v2.3 — Provider lifecycle" actually shipped under the **v2.5.4v2.5.13** tags; the *planned-feature* "v2.4 — BooCoder as ACP agent" remains **unshipped** even though v2.4.0/v2.4.1 *tags* shipped unrelated content (Unsloth lifts, sidecar routing). The patch-tag thread and the conceptual-milestone thread have diverged — read tags as the ship record, the `## v2.x` feature sections below as the milestone plan. The v2.3.0v2.5.1 tags were never CHANGELOG-backfilled; summarized here from commit bodies.
@@ -382,6 +382,7 @@ All tags `vMAJOR.MINOR.PATCH-slug`, monotonic per minor, assigned at ship time (
- `v2.6.5-panes-tabs-composer`**workspace UX batch (BooChat panes/tabs/composer + the persistence that backs it).** *Panes/tabs:* open a chat in a fresh pane (ChatTabBar "Open in new pane" + fork-beside-original via a new `open_chat_in_new_pane` event), per-pane `[+]` → New BooChat/BooTerm/BooCode menu, closing a chat pane relocates its tabs (in order) to the oldest chat/empty pane (reopen strips restored chatIds from every live pane first → no dup), stable session-scoped tab numbers (assigned on open, retired on close, never reused, map-keyed render), and the empty/landing pane became a real session history (open + separately-fetched archived chats). Removed the per-message "Open in pane" artifact button. *Persistence:* `sessions.workspace_panes` widened from bare `WorkspacePane[]` → a `WorkspaceState` envelope (`panes` + `tabNumbers`/`nextTabNumber` + `closedPaneStack`); PATCH validator zod-unions legacy-array-or-envelope and migrates on write; `session_workspace_updated` WS frame widened (web+server byte-identical, parity test green). *Composer:* morphing **Send → Stop → Queue** button keyed on `sending || activeTaskId` (folds in the standalone Stop pill, adds `cancelTask`); pasted chips trail the typed text so a leading slash stays first. *Tooling:* new read-only `read_tab_by_number` tool + an optional `ToolExecCtx` (`{ sql, sessionId }`) 4th arg on `ToolDef.execute` - `v2.6.5-panes-tabs-composer`**workspace UX batch (BooChat panes/tabs/composer + the persistence that backs it).** *Panes/tabs:* open a chat in a fresh pane (ChatTabBar "Open in new pane" + fork-beside-original via a new `open_chat_in_new_pane` event), per-pane `[+]` → New BooChat/BooTerm/BooCode menu, closing a chat pane relocates its tabs (in order) to the oldest chat/empty pane (reopen strips restored chatIds from every live pane first → no dup), stable session-scoped tab numbers (assigned on open, retired on close, never reused, map-keyed render), and the empty/landing pane became a real session history (open + separately-fetched archived chats). Removed the per-message "Open in pane" artifact button. *Persistence:* `sessions.workspace_panes` widened from bare `WorkspacePane[]` → a `WorkspaceState` envelope (`panes` + `tabNumbers`/`nextTabNumber` + `closedPaneStack`); PATCH validator zod-unions legacy-array-or-envelope and migrates on write; `session_workspace_updated` WS frame widened (web+server byte-identical, parity test green). *Composer:* morphing **Send → Stop → Queue** button keyed on `sending || activeTaskId` (folds in the standalone Stop pill, adds `cancelTask`); pasted chips trail the typed text so a leading slash stays first. *Tooling:* new read-only `read_tab_by_number` tool + an optional `ToolExecCtx` (`{ sql, sessionId }`) 4th arg on `ToolDef.execute`
- `v2.6.6-claude-md` — docs-only CLAUDE.md session-learnings from the v2.6.5 batch: the `WorkspaceState` envelope migration, the `ToolExecCtx` plumb (`read_tab_by_number` as reference), the two-schema-files-one-DB ownership split + idempotent `confdeltype` FK-action-flip pattern, and React-StrictMode nested-`setState` idempotency - `v2.6.6-claude-md` — docs-only CLAUDE.md session-learnings from the v2.6.5 batch: the `WorkspaceState` envelope migration, the `ToolExecCtx` plumb (`read_tab_by_number` as reference), the two-schema-files-one-DB ownership split + idempotent `confdeltype` FK-action-flip pattern, and React-StrictMode nested-`setState` idempotency
- `v2.6.7-interrupt-guard`**F.1 fix:** post-interrupt stale-terminal bug in the opencode warm-server backend (one-click reachable since `v2.6.5`'s Stop button). opencode emits one trailing `session.idle`/`session.error` for a cancelled turn (sessionID only, no turn id) that settled the *next* turn early as success. Pure per-session guard (`backends/turn-guard.ts` — arm-on-abort / swallow-one-orphan / self-heal-on-activity) wired into `opencode-server.ts`; 3 regression tests (TDD). First item of the v2.6 openspec "remaining" plan; Phase 1-UX / 2 / 3 still open - `v2.6.7-interrupt-guard`**F.1 fix:** post-interrupt stale-terminal bug in the opencode warm-server backend (one-click reachable since `v2.6.5`'s Stop button). opencode emits one trailing `session.idle`/`session.error` for a cancelled turn (sessionID only, no turn id) that settled the *next* turn early as success. Pure per-session guard (`backends/turn-guard.ts` — arm-on-abort / swallow-one-orphan / self-heal-on-activity) wired into `opencode-server.ts`; 3 regression tests (TDD). First item of the v2.6 openspec "remaining" plan; Phase 1-UX / 2 / 3 still open
- `v2.6.8-agent-attribution`**v2.6 Phase 1-UX** (U.1U.6), built by 3 parallel subagents over disjoint files. Backend: `pending_changes.agent` stamped at every queue site + flows through `listPending`; new `GET /api/sessions/:id/agent-sessions` route; opencode warm-server consumes `session.next.step.ended` → accumulates `input_tokens`/`output_tokens`/`cost` on `agent_sessions`. Frontend: DiffPanel per-row agent badges + multi-agent note; AgentComposerBar resumed/history/new-session chip (gated on optional `sessionId`, BooChat unaffected); shared `providerIcons.tsx` + `useAgentSessions` hook. 9 new tests; web+coder tsc clean. **Backend deploys via boocoder restart; frontend awaits the `boocode` Docker rebuild.** Phase 2/3 remain
----- -----

View File

@@ -29,20 +29,16 @@ ACP follows; hardening last.
- [x] P1.5-a **Per-session SSE** (`v2.6.2-delete-guard-and-sse`): one `event.subscribe({directory})` per live opencode session, each with an `AbortController`; `sessionID` demux guard + zombie-loop fix — replaces task 1.3's single global loop. Bundled: session-delete work-loss guard (`/worktree-risk`). - [x] P1.5-a **Per-session SSE** (`v2.6.2-delete-guard-and-sse`): one `event.subscribe({directory})` per live opencode session, each with an `AbortController`; `sessionID` demux guard + zombie-loop fix — replaces task 1.3's single global loop. Bundled: session-delete work-loss guard (`/worktree-risk`).
- [x] P1.5-b **Re-key `agent_sessions` → `(chat_id, agent)`** + first-class `worktrees` table (`v2.6.3-chatkey-and-skills`); `tasks.chat_id` threaded; `runOpenCodeServerTask` resolve-or-creates a chat for session-less creators; cross-chunk dcp-strip. FK convergence to `SET NULL` (`v2.6.4-agent-sessions-fk`). - [x] P1.5-b **Re-key `agent_sessions` → `(chat_id, agent)`** + first-class `worktrees` table (`v2.6.3-chatkey-and-skills`); `tasks.chat_id` threaded; `runOpenCodeServerTask` resolve-or-creates a chat for session-less creators; cross-chunk dcp-strip. FK convergence to `SET NULL` (`v2.6.4-agent-sessions-fk`).
## Phase 1 (UX) — Attribution & switch affordances (design §9) — ⬜ REMAINING (pure read+display over already-shipped `pending_changes.agent` + `agent_sessions`) ## Phase 1 (UX) — Attribution & switch affordances (design §9) — ✅ SHIPPED `v2.6.8-agent-attribution` (Smoke U pending live frontend deploy)
- [ ] U.1 Stamp `pending_changes.agent` at queue time (worktree path → task agent; - [x] U.1 Stamp `pending_changes.agent` at queue time — native tools default `'boocode'`, dispatched external`task.agent`, manual RightRail → `NULL` (`pending_changes.ts`, `dispatcher.ts`).
native write tools → `'boocode'`; manual RightRail create → NULL). - [x] U.2 `agent` flows through `listPending` + backend & frontend `PendingChange` types.
- [ ] U.2 Add `agent` to `listPending` response + frontend `PendingChange` type. - [x] U.3 Shared `components/coder/providerIcons.tsx`; DiffPanel per-row agent badge + "Changes from X, Y" multi-agent note (§9a).
- [ ] U.3 Extract `providerIcon()` to a shared helper; DiffPanel renders an agent badge - [x] U.4 `GET /api/sessions/:id/agent-sessions` route + `api.coder.agentSessions` + `useAgentSessions` hook (refetch on message-complete) (§9b).
per row + a "Changes from X, Y" note when the pending set spans >1 agent (§9a). - [x] U.5 `AgentComposerBar` optional `sessionId` prop → resumed/history/new-session chip; hidden on fresh chats + other callers (§9b).
- [ ] U.4 `GET /api/sessions/:id/agent-sessions` route + `api.coder.agentSessions` + - [x] U.6 Consume opencode `session.next.step.ended` → accumulate `input_tokens`/`output_tokens`/`cost` on `agent_sessions` (new cols). Backend persist only; UI surfacing deferred.
`useAgentSessions(sessionId)` (refetch on `message_complete`) (§9b).
- [ ] U.5 `AgentComposerBar` optional `sessionId` prop → resumed / history / new-session
chip beside the Provider picker; hidden on fresh chats and other callers (§9b).
- [ ] U.6 Consume opencode `session.next.step.ended` `{tokens, cost}` → fill ctx/token usage for opencode sessions (SDK already installed; closes the "no usage for external agents" gap; surface beside the §9b chip). Source: `boocode_code_review_v2.md` §1 #8, design §10.
- [ ] **Smoke U:** stage edits with opencode then boocode → DiffPanel badges each row to the - [ ] **Smoke U:** stage edits with opencode then boocode → DiffPanel badges each row to the
right agent; composer shows "resumed" when re-selecting opencode, "new session" for goose. right agent; composer shows "resumed" when re-selecting opencode, "new session" for goose. *(pending live frontend deploy — Docker container rebuild)*
## Phase 2 — Warm ACP backend (goose, qwen) — ⬜ REMAINING ## Phase 2 — Warm ACP backend (goose, qwen) — ⬜ REMAINING
@@ -102,7 +98,7 @@ ACP follows; hardening last.
## Remaining — recommended order (implementation plan, 2026-05-31) ## Remaining — recommended order (implementation plan, 2026-05-31)
1. ~~**F.1 interrupt-bug fix**~~ — ✅ shipped `v2.6.7-interrupt-guard` (3 regression tests, TDD). 1. ~~**F.1 interrupt-bug fix**~~ — ✅ shipped `v2.6.7-interrupt-guard` (3 regression tests, TDD).
2. **Phase 1-UX** (U.1U.6) — pure read+display over already-shipped `pending_changes.agent` + `agent_sessions`; no dispatch-logic or backend change, so it ships value on data that already exists. U.6 (token/ctx usage) rides the same opencode SSE. 2. ~~**Phase 1-UX** (U.1U.6)~~ — ✅ shipped `v2.6.8-agent-attribution` (3 parallel agents, disjoint files; 9 new tests). Smoke U pending the frontend Docker rebuild.
3. **Phase 2 — warm ACP, qwen first then goose** — qwen has a validated `--acp` reference; goose's missing resume is the open design question, so qwen de-risks the pattern. Smoke 2 + 2b (the switch round-trip success criterion). 3. **Phase 2 — warm ACP, qwen first then goose** — qwen has a validated `--acp` reference; goose's missing resume is the open design question, so qwen de-risks the pattern. Smoke 2 + 2b (the switch round-trip success criterion).
4. **Phase 3 — lifecycle hardening** — lift openchamber's state machine; do crash-recovery (3.1/3.2/3.6) + worktree reaper (3.3/3.4 + LRU) together (shared supervision loop). Closes the two ⬜ success criteria (server-crash recovery, close→cleanup). 4. **Phase 3 — lifecycle hardening** — lift openchamber's state machine; do crash-recovery (3.1/3.2/3.6) + worktree reaper (3.3/3.4 + LRU) together (shared supervision loop). Closes the two ⬜ success criteria (server-crash recovery, close→cleanup).
5. **Tests T.1T.3 + `BOOCODER.md` (D.1 remainder)** — backfill alongside each phase, not at the end. 5. **Tests T.1T.3 + `BOOCODER.md` (D.1 remainder)** — backfill alongside each phase, not at the end.