diff --git a/src/board/boardRoutes.ts b/src/board/boardRoutes.ts
index 0f154303..5a24453c 100644
--- a/src/board/boardRoutes.ts
+++ b/src/board/boardRoutes.ts
@@ -9,11 +9,19 @@ import { MetadataService } from '../services/MetadataService';
import { CommentService } from '../services/CommentService';
import { TaskBlockService } from '../services/TaskBlockService';
import { ExportImportService, ExportData } from '../services/ExportImportService';
-import { ClaudeProcessService } from '../services/ClaudeProcessService';
+import type { IProcessService } from '../services/IProcessService';
import { TaskStatus, isPriority, Priority } from '../models';
import { ConflictError } from '../errors';
import { StorageBackend } from '../db/types/repository';
-import { readBoardConfig, writeBoardConfig, DETAIL_PANE_MAX_WIDTH, VALID_THEMES, ThemePreference } from './boardConfig';
+import {
+ readBoardConfig,
+ writeBoardConfig,
+ DETAIL_PANE_MAX_WIDTH,
+ VALID_THEMES,
+ ThemePreference,
+ VALID_LLMS,
+ LlmPreference,
+} from './boardConfig';
import { daysAgoIso } from '../utils/date';
import {
buildTasksByStatus,
@@ -34,7 +42,7 @@ export type BoardServices = {
database: StorageBackend;
boardTitle?: string;
configDir: string;
- claudeProcessService?: ClaudeProcessService;
+ processService?: IProcessService;
};
type TaskPatchBody = {
@@ -373,7 +381,7 @@ export function registerConfigApiRoutes(app: Hono, configDir: string): void {
});
app.put('/api/config', async (c) => {
- const body = await c.req.json<{ board?: { detailPaneWidth?: unknown; theme?: unknown } }>();
+ const body = await c.req.json<{ board?: { detailPaneWidth?: unknown; theme?: unknown; llm?: unknown } }>();
const boardBody = body?.board ?? {};
if (boardBody.detailPaneWidth !== undefined) {
@@ -395,6 +403,14 @@ export function registerConfigApiRoutes(app: Hono, configDir: string): void {
writeBoardConfig(configDir, { theme: theme as ThemePreference });
}
+ if (boardBody.llm !== undefined) {
+ const llm = boardBody.llm;
+ if (typeof llm !== 'string' || !(VALID_LLMS as string[]).includes(llm)) {
+ return c.json({ error: 'llm must be one of: ' + VALID_LLMS.join(', ') }, 400);
+ }
+ writeBoardConfig(configDir, { llm: llm as LlmPreference });
+ }
+
return c.json({ success: true });
});
}
@@ -437,7 +453,7 @@ function parseBoardCardFilters(query: {
return filters;
}
-function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts: TaskService): void {
+function registerProcessRoutes(app: Hono, processService: IProcessService, ts: TaskService): void {
app.post('/api/claude/tasks/:taskId/run', async (c) => {
const taskId = Number(c.req.param('taskId'));
if (isNaN(taskId)) return c.json({ error: 'Invalid taskId' }, 400);
@@ -453,11 +469,11 @@ function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts
: `Task ID: ${taskId}\n/agkan-subtask-direct`;
try {
- claudeProcess.startProcess(taskId, prompt, command);
+ processService.startProcess(taskId, prompt, command);
} catch (e) {
if (e instanceof ConflictError) {
console.error(
- `[boardRoutes] 409 already running taskId=${taskId} command=${command} running=${JSON.stringify(claudeProcess.listRunningTasks())}`
+ `[boardRoutes] 409 already running taskId=${taskId} command=${command} running=${JSON.stringify(processService.listRunningTasks())}`
);
return c.json({ error: e.message }, 409);
}
@@ -466,7 +482,7 @@ function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts
if (command === 'pr' || command === 'run') {
const targetStatus = command === 'pr' ? 'review' : 'done';
- const unsubscribe = claudeProcess.subscribeOutput(taskId, (evt) => {
+ const unsubscribe = processService.subscribeOutput(taskId, (evt) => {
if (evt.kind === 'done' && evt.exitCode === 0) {
ts.updateTask(taskId, { status: targetStatus });
}
@@ -482,7 +498,7 @@ function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts
app.delete('/api/claude/tasks/:taskId/run', (c) => {
const taskId = Number(c.req.param('taskId'));
if (isNaN(taskId)) return c.json({ error: 'Invalid taskId' }, 400);
- const stopped = claudeProcess.stopProcess(taskId);
+ const stopped = processService.stopProcess(taskId);
if (!stopped) return c.json({ error: 'No running process for this taskId' }, 404);
return c.json({ success: true });
});
@@ -500,7 +516,7 @@ function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts
return new TextEncoder().encode(payload);
};
- const stop = claudeProcess.subscribeOutput(taskId, (evt) => {
+ const stop = processService.subscribeOutput(taskId, (evt) => {
if (evt.kind === 'text') {
controller.enqueue(encode('text', { text: evt.text }));
} else if (evt.kind === 'tool_use') {
@@ -531,7 +547,7 @@ function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts
});
app.get('/api/running-tasks', (c) => {
- const tasks = claudeProcess.listRunningTasks();
+ const tasks = processService.listRunningTasks();
return c.json({ tasks });
});
@@ -539,7 +555,7 @@ function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts
const taskId = Number(c.req.param('taskId'));
if (isNaN(taskId)) return c.json({ error: 'Invalid taskId' }, 400);
if (!ts.getTask(taskId)) return c.json({ error: 'Task not found' }, 404);
- const logs = claudeProcess.getRunLogs(taskId);
+ const logs = processService.getRunLogs(taskId);
return c.json({ logs });
});
}
@@ -592,7 +608,7 @@ export function registerBoardRoutes(app: Hono, services: BoardServices): void {
});
registerTaskApiRoutes(app, services);
registerConfigApiRoutes(app, configDir);
- if (services.claudeProcessService) {
- registerClaudeRoutes(app, services.claudeProcessService, ts);
+ if (services.processService) {
+ registerProcessRoutes(app, services.processService, ts);
}
}
diff --git a/src/board/client/burgerMenu.ts b/src/board/client/burgerMenu.ts
index 07787523..346affa6 100644
--- a/src/board/client/burgerMenu.ts
+++ b/src/board/client/burgerMenu.ts
@@ -1,8 +1,14 @@
-// Burger menu, purge tasks, version info, and dark mode functionality
+// Burger menu, purge tasks, version info, settings, and dark mode functionality
import { initDarkMode } from './darkMode';
import { refreshBoardCards } from './boardPolling';
+type LlmPreference = 'codex' | 'claude';
+
+function normalizeLlm(value: unknown): LlmPreference {
+ return value === 'codex' ? 'codex' : 'claude';
+}
+
function initBurgerToggle(burgerBtn: HTMLButtonElement, burgerDropdown: HTMLElement): void {
burgerBtn.addEventListener('click', (e: MouseEvent) => {
e.stopPropagation();
@@ -133,6 +139,72 @@ function initVersionModal(burgerDropdown: HTMLElement): void {
});
}
+function initSettingsModal(burgerDropdown: HTMLElement): void {
+ const settingsModal = document.getElementById('settings-modal') as HTMLElement | null;
+ const settingsSelect = document.getElementById('settings-llm-select') as HTMLSelectElement | null;
+ const settingsResultEl = document.getElementById('settings-result') as HTMLElement | null;
+ const settingsCancelBtn = document.getElementById('settings-cancel-btn') as HTMLButtonElement | null;
+ const settingsSaveBtn = document.getElementById('settings-save-btn') as HTMLButtonElement | null;
+
+ if (!settingsModal || !settingsSelect || !settingsResultEl || !settingsCancelBtn || !settingsSaveBtn) {
+ return;
+ }
+
+ document.getElementById('burger-settings')?.addEventListener('click', async () => {
+ burgerDropdown.classList.remove('open');
+ settingsResultEl.textContent = '';
+ settingsResultEl.style.color = '#64748b';
+ settingsSelect.value = 'claude';
+ settingsModal.classList.add('show');
+
+ try {
+ const res = await fetch('/api/config');
+ if (res.ok) {
+ const data = (await res.json()) as { board?: { llm?: unknown } };
+ settingsSelect.value = normalizeLlm(data?.board?.llm);
+ }
+ } catch {
+ // Ignore load errors and keep default
+ }
+ });
+
+ settingsCancelBtn.addEventListener('click', () => {
+ settingsModal.classList.remove('show');
+ });
+
+ settingsSaveBtn.addEventListener('click', async () => {
+ const selected = normalizeLlm(settingsSelect.value);
+ settingsSaveBtn.disabled = true;
+ settingsSaveBtn.textContent = 'Saving...';
+
+ try {
+ const res = await fetch('/api/config', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ board: { llm: selected } }),
+ });
+
+ if (res.ok) {
+ settingsResultEl.textContent = 'Saved.';
+ settingsResultEl.style.color = '#16a34a';
+ setTimeout(() => {
+ settingsModal.classList.remove('show');
+ }, 250);
+ } else {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ settingsResultEl.textContent = data.error || 'Failed to save settings.';
+ settingsResultEl.style.color = '#dc2626';
+ }
+ } catch {
+ settingsResultEl.textContent = 'Failed to save settings.';
+ settingsResultEl.style.color = '#dc2626';
+ } finally {
+ settingsSaveBtn.disabled = false;
+ settingsSaveBtn.textContent = 'Save';
+ }
+ });
+}
+
function initExportModal(burgerDropdown: HTMLElement): void {
document.getElementById('burger-export-tasks')?.addEventListener('click', () => {
burgerDropdown.classList.remove('open');
@@ -250,6 +322,7 @@ export function initBurgerMenu(): void {
initArchiveModal(burgerDropdown);
initExportModal(burgerDropdown);
initImportModal(burgerDropdown);
+ initSettingsModal(burgerDropdown);
initVersionModal(burgerDropdown);
initDarkMode();
}
diff --git a/src/board/server.ts b/src/board/server.ts
index 433baafa..0e93a4d6 100644
--- a/src/board/server.ts
+++ b/src/board/server.ts
@@ -7,11 +7,13 @@ import { TagService } from '../services/TagService';
import { MetadataService } from '../services/MetadataService';
import { CommentService } from '../services/CommentService';
import { TaskBlockService } from '../services/TaskBlockService';
-import { ClaudeProcessService } from '../services/ClaudeProcessService';
+import { ProcessServiceFactory } from '../services/ProcessServiceFactory';
+import type { IProcessService } from '../services/IProcessService';
import { getStorageBackend } from '../db/connection';
import { StorageBackend } from '../db/types/repository';
import { getDefaultDirName } from '../db/config';
import { registerBoardRoutes, BoardServices } from './boardRoutes';
+import { readBoardConfig } from './boardConfig';
export function createBoardApp(
taskService?: TaskService,
@@ -23,7 +25,7 @@ export function createBoardApp(
configDir?: string,
commentService?: CommentService,
taskBlockService?: TaskBlockService,
- claudeProcessService?: ClaudeProcessService
+ processService?: IProcessService
): Hono {
const app = new Hono();
const resolvedConfigDir = configDir ?? path.join(process.cwd(), getDefaultDirName());
@@ -38,24 +40,29 @@ export function createBoardApp(
database: resolvedDb,
boardTitle,
configDir: resolvedConfigDir,
- claudeProcessService,
+ processService,
};
registerBoardRoutes(app, services);
return app;
}
export function startBoardServer(port: number, boardTitle?: string): void {
+ const configDir = path.join(process.cwd(), getDefaultDirName());
+ const config = readBoardConfig(configDir);
+ const db = getStorageBackend();
+ const processService = ProcessServiceFactory.create(config.llm, db);
+
const app = createBoardApp(
undefined,
undefined,
undefined,
- undefined,
+ db,
boardTitle,
undefined,
+ configDir,
undefined,
undefined,
- undefined,
- new ClaudeProcessService(getStorageBackend())
+ processService
);
serve(
{
diff --git a/src/services/ClaudeProcessService.ts b/src/services/ClaudeProcessService.ts
index 697a474d..f79d393b 100644
--- a/src/services/ClaudeProcessService.ts
+++ b/src/services/ClaudeProcessService.ts
@@ -3,6 +3,7 @@ import type { StorageBackend } from '../db/types/repository';
import type { RunLogRow } from '../db/types/repository';
import { verboseLog } from '../utils/logger';
import { ConflictError } from '../errors';
+import type { IProcessService, SubscribeCallback as IProcessServiceSubscribeCallback } from './IProcessService';
function resolveClaudePath(): string {
try {
@@ -40,7 +41,8 @@ export type OutputEvent =
| { kind: 'done'; exitCode: number }
| { kind: 'error'; message: string };
-export type SubscribeCallback = (event: OutputEvent) => void;
+// Re-export for backward compatibility
+export type SubscribeCallback = IProcessServiceSubscribeCallback;
export interface RunLog {
id: number;
@@ -71,8 +73,9 @@ interface ProcessInfo {
* ClaudeProcessService
* Manages claude CLI processes running in stream-json mode.
* Keyed by taskId, supports start/stop/subscribe/list operations.
+ * Implements IProcessService for use with ProcessServiceFactory.
*/
-export class ClaudeProcessService {
+export class ClaudeProcessService implements IProcessService {
private processes: Map
= new Map();
private db: StorageBackend | null;
diff --git a/src/services/CodexProcessService.ts b/src/services/CodexProcessService.ts
new file mode 100644
index 00000000..846cc564
--- /dev/null
+++ b/src/services/CodexProcessService.ts
@@ -0,0 +1,318 @@
+import { spawn, ChildProcess } from 'child_process';
+import type { StorageBackend } from '../db/types/repository';
+import type { RunLogRow } from '../db/types/repository';
+import { verboseLog } from '../utils/logger';
+import { ConflictError } from '../errors';
+import type { IProcessService } from './IProcessService';
+import type { OutputEvent, RunLog, SubscribeCallback } from './ClaudeProcessService';
+
+// Codex output event types from JSONL
+type CodexStreamEvent =
+ | { type: 'thread.started'; thread_id: string; [key: string]: unknown }
+ | {
+ type: 'item.completed';
+ item: {
+ type: string;
+ content?: string;
+ [key: string]: unknown;
+ };
+ [key: string]: unknown;
+ }
+ | { type: 'turn.completed'; [key: string]: unknown }
+ | { type: string; [key: string]: unknown };
+
+interface ProcessInfo {
+ taskId: number;
+ command: string;
+ process: ChildProcess;
+ startedAt: Date;
+ status: 'running' | 'stopped';
+ outputBuffer: CodexStreamEvent[];
+ processedEvents: OutputEvent[];
+ subscribers: Set;
+ runLogId: number | null;
+ sessionId: string | null;
+}
+
+/**
+ * CodexProcessService
+ * Manages codex CLI processes running in JSON mode.
+ * Parses JSONL output from codex and converts to OutputEvent format.
+ * Implements IProcessService for use with ProcessServiceFactory.
+ */
+export class CodexProcessService implements IProcessService {
+ private processes: Map = new Map();
+ private db: StorageBackend | null;
+
+ constructor(db?: StorageBackend | null) {
+ this.db = db ?? null;
+ }
+
+ /**
+ * Start a codex process for the given taskId and prompt.
+ * Prevents duplicate processes for the same taskId.
+ */
+ startProcess(taskId: number, prompt: string, command: string = 'run'): void {
+ if (this.processes.has(taskId)) {
+ const existing = this.processes.get(taskId)!;
+ const pid = existing.process.pid;
+ const killed = existing.process.killed;
+ const exitCode = existing.process.exitCode;
+ const signalCode = existing.process.signalCode;
+ const aliveMs = Date.now() - existing.startedAt.getTime();
+ verboseLog(
+ `[CodexProcessService] startProcess DUPLICATE taskId=${taskId} existing pid=${pid} killed=${killed} exitCode=${exitCode} signalCode=${signalCode} aliveMs=${aliveMs} command=${existing.command}`
+ );
+ throw new ConflictError(`Process for taskId ${taskId} is already running`);
+ }
+
+ verboseLog(`[CodexProcessService] startProcess taskId=${taskId} command=${command}`);
+
+ const child = spawn('codex', ['exec', '--json', '--dangerously-bypass-approvals-and-sandbox', prompt], {
+ cwd: process.cwd(),
+ env: process.env,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ const info: ProcessInfo = {
+ taskId,
+ command,
+ process: child,
+ startedAt: new Date(),
+ status: 'running',
+ outputBuffer: [],
+ processedEvents: [],
+ subscribers: new Set(),
+ runLogId: null,
+ sessionId: null,
+ };
+
+ this.processes.set(taskId, info);
+ verboseLog(`[CodexProcessService] process added to map taskId=${taskId} total=${this.processes.size}`);
+
+ if (this.db) {
+ info.runLogId = this.db.runLogs.create(info.taskId, info.startedAt.toISOString());
+ verboseLog(`[CodexProcessService] run log created id=${info.runLogId} taskId=${taskId}`);
+ }
+
+ let lineBuffer = '';
+ let spawnError = false;
+
+ child.on('error', (err: NodeJS.ErrnoException) => {
+ spawnError = true;
+ const message = err.code === 'ENOENT' ? 'codex CLI not found in PATH' : err.message;
+ console.error(`[CodexProcessService] spawn error for taskId=${taskId}: ${message}`, err);
+ const errorEvent: OutputEvent = { kind: 'error', message };
+ info.processedEvents.push(errorEvent);
+ info.subscribers.forEach((cb) => cb(errorEvent));
+
+ const doneEvent: OutputEvent = { kind: 'done', exitCode: 1 };
+ info.subscribers.forEach((cb) => cb(doneEvent));
+
+ if (this.db && info.runLogId) {
+ const finishedAt = new Date().toISOString();
+ this.db.runLogs.updateFinished(info.runLogId, finishedAt, 1, JSON.stringify(info.processedEvents));
+ verboseLog(`[CodexProcessService] run log updated (error) id=${info.runLogId} taskId=${taskId}`);
+ }
+
+ if (this.processes.get(taskId) === info) {
+ this.processes.delete(taskId);
+ verboseLog(
+ `[CodexProcessService] process removed from map (error) taskId=${taskId} total=${this.processes.size}`
+ );
+ } else {
+ verboseLog(`[CodexProcessService] process error skipped map delete (stale entry) taskId=${taskId}`);
+ }
+ });
+
+ child.stdout?.on('data', (chunk: Buffer) => {
+ lineBuffer += chunk.toString();
+ const lines = lineBuffer.split('\n');
+ // Keep the last (potentially incomplete) line in the buffer
+ lineBuffer = lines.pop() ?? '';
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+
+ let parsed: CodexStreamEvent;
+ try {
+ parsed = JSON.parse(trimmed) as CodexStreamEvent;
+ } catch {
+ // Skip non-JSON lines
+ continue;
+ }
+
+ info.outputBuffer.push(parsed);
+ this._notifySubscribers(info, parsed);
+ }
+ });
+
+ let stderrBuffer = '';
+ child.stderr?.on('data', (chunk: Buffer) => {
+ stderrBuffer += chunk.toString();
+ });
+
+ child.on('close', (code) => {
+ if (spawnError) return;
+ // Process any remaining buffered line
+ const remaining = lineBuffer.trim();
+ if (remaining) {
+ try {
+ const parsed = JSON.parse(remaining) as CodexStreamEvent;
+ info.outputBuffer.push(parsed);
+ this._notifySubscribers(info, parsed);
+ } catch {
+ // Ignore
+ }
+ }
+
+ if (stderrBuffer) {
+ console.error(`[CodexProcessService] stderr for taskId=${taskId}:\n${stderrBuffer}`);
+ const errorEvent: OutputEvent = { kind: 'error', message: stderrBuffer };
+ info.processedEvents.push(errorEvent);
+ info.subscribers.forEach((cb) => cb(errorEvent));
+ }
+
+ const exitCode = code ?? 0;
+ verboseLog(`[CodexProcessService] process close taskId=${taskId} exitCode=${exitCode}`);
+ if (exitCode !== 0) {
+ console.error(`[CodexProcessService] process exited with code ${exitCode} for taskId=${taskId}`);
+ }
+ const doneEvent: OutputEvent = { kind: 'done', exitCode };
+ info.subscribers.forEach((cb) => cb(doneEvent));
+
+ // Finalize log in DB before removing process
+ if (this.db && info.runLogId) {
+ const finishedAt = new Date().toISOString();
+ this.db.runLogs.updateFinished(info.runLogId, finishedAt, exitCode, JSON.stringify(info.processedEvents));
+ verboseLog(`[CodexProcessService] run log finalized id=${info.runLogId} taskId=${taskId} exitCode=${exitCode}`);
+ // Rotate: keep only latest 5 per task
+ const ids = this.db.runLogs.findIdsByTaskId(info.taskId);
+ if (ids.length > 5) {
+ const toDelete = ids.slice(5);
+ this.db.runLogs.deleteMany(toDelete);
+ verboseLog(`[CodexProcessService] rotated run logs taskId=${taskId} deleted=${toDelete.length}`);
+ }
+ }
+
+ if (this.processes.get(taskId) === info) {
+ this.processes.delete(taskId);
+ verboseLog(
+ `[CodexProcessService] process removed from map (close) taskId=${taskId} total=${this.processes.size}`
+ );
+ } else {
+ verboseLog(`[CodexProcessService] process close skipped map delete (stale entry) taskId=${taskId}`);
+ }
+ });
+ }
+
+ /**
+ * Stop the process for the given taskId.
+ * Returns true if the process was found and signalled, false otherwise.
+ */
+ stopProcess(taskId: number): boolean {
+ const info = this.processes.get(taskId);
+ if (!info) {
+ verboseLog(`[CodexProcessService] stopProcess taskId=${taskId} not found`);
+ return false;
+ }
+ verboseLog(`[CodexProcessService] stopProcess taskId=${taskId} sending SIGTERM`);
+ info.process.kill('SIGTERM');
+ this.processes.delete(taskId);
+ verboseLog(`[CodexProcessService] process removed from map (stop) taskId=${taskId} total=${this.processes.size}`);
+ return true;
+ }
+
+ /**
+ * List all currently running tasks with their command type.
+ */
+ listRunningTasks(): { taskId: number; command: string }[] {
+ return Array.from(this.processes.values()).map((info) => ({ taskId: info.taskId, command: info.command }));
+ }
+
+ /**
+ * Subscribe to output events for a given taskId.
+ * If process is running: replay past events and subscribe to future events.
+ * If no process but DB available: replay last saved log from DB.
+ * Returns an unsubscribe function.
+ */
+ subscribeOutput(taskId: number, callback: SubscribeCallback): () => void {
+ const info = this.processes.get(taskId);
+ if (!info) {
+ // Try to replay from DB
+ if (this.db) {
+ const row = this.db.runLogs.findLatestByTaskId(taskId);
+
+ if (row) {
+ const events = JSON.parse(row.events) as OutputEvent[];
+ events.forEach((evt) => callback(evt));
+ callback({ kind: 'done', exitCode: row.exit_code ?? 0 });
+ return () => {};
+ }
+ }
+
+ // No process and no log found — emit error
+ callback({ kind: 'error', message: `No running process for taskId ${taskId}` });
+ return () => {};
+ }
+
+ // Replay past events to the new subscriber before registering
+ info.processedEvents.forEach((evt) => callback(evt));
+ info.subscribers.add(callback);
+ return () => {
+ info.subscribers.delete(callback);
+ };
+ }
+
+ /**
+ * Get saved run logs for a task from DB (most recent first, up to 5).
+ */
+ getRunLogs(taskId: number): RunLog[] {
+ if (!this.db) return [];
+ const rows = this.db.runLogs.findByTaskId(taskId, 5);
+ return rows.map((r: RunLogRow) => ({
+ id: r.id,
+ task_id: r.task_id,
+ started_at: r.started_at,
+ finished_at: r.finished_at,
+ exit_code: r.exit_code,
+ session_id: r.session_id,
+ events: JSON.parse(r.events) as OutputEvent[],
+ }));
+ }
+
+ // ---- Private helpers ----
+
+ private _notifySubscribers(info: ProcessInfo, event: CodexStreamEvent): void {
+ // Map Codex events to OutputEvent format
+ if (event.type === 'thread.started') {
+ // Extract thread_id as session_id
+ const threadId = (event as Extract).thread_id;
+ if (threadId && !info.sessionId) {
+ info.sessionId = threadId;
+ if (this.db && info.runLogId) {
+ this.db.runLogs.updateSessionId(info.runLogId, info.sessionId);
+ }
+ }
+ }
+
+ if (event.type === 'item.completed') {
+ const itemEvent = event as Extract;
+ const item = itemEvent.item;
+
+ // Treat agent_message items as text output
+ if (item.type === 'agent_message' && item.content) {
+ const outputEvent: OutputEvent = { kind: 'text', text: String(item.content) };
+ info.processedEvents.push(outputEvent);
+ info.subscribers.forEach((cb) => cb(outputEvent));
+
+ if (this.db && info.runLogId) {
+ this.db.runLogs.updateEvents(info.runLogId, JSON.stringify(info.processedEvents));
+ }
+ }
+ }
+ // 'turn.completed' events are buffered but not forwarded as OutputEvents
+ // (done/error are sent on process close)
+ }
+}
diff --git a/src/services/IProcessService.ts b/src/services/IProcessService.ts
new file mode 100644
index 00000000..2cd90fc2
--- /dev/null
+++ b/src/services/IProcessService.ts
@@ -0,0 +1,48 @@
+// These types are defined in ClaudeProcessService and re-exported here for use by IProcessService
+import type { OutputEvent, RunLog } from './ClaudeProcessService';
+
+// SubscribeCallback is defined inline here since it uses OutputEvent which may be shared across implementations
+export type SubscribeCallback = (event: OutputEvent) => void;
+
+/**
+ * IProcessService
+ * Common interface for managing LLM processes (Claude, Codex, etc.)
+ * Abstracts the details of process spawning, output parsing, and event streaming.
+ */
+export interface IProcessService {
+ /**
+ * Start a process for the given taskId with the provided prompt.
+ * @param taskId Unique task identifier
+ * @param prompt The input prompt for the LLM
+ * @param command Type of command (run, planning, pr, etc.)
+ * @throws ConflictError if a process is already running for this taskId
+ */
+ startProcess(taskId: number, prompt: string, command?: string): void;
+
+ /**
+ * Stop the process for the given taskId.
+ * @param taskId Unique task identifier
+ * @returns true if the process was found and stopped, false if no process was running
+ */
+ stopProcess(taskId: number): boolean;
+
+ /**
+ * List all currently running tasks with their command type.
+ */
+ listRunningTasks(): { taskId: number; command: string }[];
+
+ /**
+ * Subscribe to output events for a given taskId.
+ * If process is running: replays past events and subscribes to future events.
+ * If no process but storage available: replays last saved log from storage.
+ * @param taskId Unique task identifier
+ * @param callback Function called for each output event
+ * @returns Unsubscribe function
+ */
+ subscribeOutput(taskId: number, callback: SubscribeCallback): () => void;
+
+ /**
+ * Get saved run logs for a task from storage (most recent first, up to 5).
+ */
+ getRunLogs(taskId: number): RunLog[];
+}
diff --git a/src/services/ProcessServiceFactory.ts b/src/services/ProcessServiceFactory.ts
new file mode 100644
index 00000000..257f7e73
--- /dev/null
+++ b/src/services/ProcessServiceFactory.ts
@@ -0,0 +1,26 @@
+import type { LlmPreference } from '../board/boardConfig';
+import type { StorageBackend } from '../db/types/repository';
+import type { IProcessService } from './IProcessService';
+import { ClaudeProcessService } from './ClaudeProcessService';
+import { CodexProcessService } from './CodexProcessService';
+
+/**
+ * ProcessServiceFactory
+ * Creates the appropriate IProcessService implementation based on LlmPreference.
+ * Defaults to ClaudeProcessService if llm is invalid or undefined.
+ */
+export class ProcessServiceFactory {
+ /**
+ * Create a process service for the given LLM preference.
+ * @param llm The LLM preference ('claude' or 'codex')
+ * @param db Optional database backend for persisting run logs
+ * @returns An IProcessService implementation
+ */
+ static create(llm: LlmPreference | undefined, db?: StorageBackend | null): IProcessService {
+ // Default to claude if not specified or invalid
+ if (llm === 'codex') {
+ return new CodexProcessService(db);
+ }
+ return new ClaudeProcessService(db);
+ }
+}
diff --git a/src/services/index.ts b/src/services/index.ts
index 0fc220a5..eda653b3 100644
--- a/src/services/index.ts
+++ b/src/services/index.ts
@@ -14,3 +14,6 @@ export { ExportImportService } from './ExportImportService';
export type { ExportData, ExportedTask, ExportedComment, ImportResult } from './ExportImportService';
export { ClaudeProcessService } from './ClaudeProcessService';
export type { ClaudeStreamEvent, OutputEvent, SubscribeCallback } from './ClaudeProcessService';
+export { CodexProcessService } from './CodexProcessService';
+export type { IProcessService } from './IProcessService';
+export { ProcessServiceFactory } from './ProcessServiceFactory';
diff --git a/tests/TaskService.test.ts b/tests/TaskService.test.ts
index 4c115674..d25cf2c3 100644
--- a/tests/TaskService.test.ts
+++ b/tests/TaskService.test.ts
@@ -604,13 +604,40 @@ describe('TaskService', () => {
});
it('ソートオプションでupdated_at降順にソートできる', () => {
- const task1 = taskService.createTask({ title: 'Task A', status: 'backlog' });
- taskService.createTask({ title: 'Task B', status: 'backlog' });
- // Update task1 to make its updated_at newer
- taskService.updateTask(task1.id, { title: 'Task A Updated' });
+ vi.useFakeTimers();
+ try {
+ vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
+ const task1 = taskService.createTask({ title: 'Task A', status: 'backlog' });
+
+ vi.setSystemTime(new Date('2026-01-01T00:00:01.000Z'));
+ taskService.createTask({ title: 'Task B', status: 'backlog' });
+
+ // task1 を明示的に最新時刻へ更新して、updated_at 降順の先頭を確定させる
+ vi.setSystemTime(new Date('2026-01-01T00:00:02.000Z'));
+ taskService.updateTask(task1.id, { title: 'Task A Updated' });
+
+ const tasks = taskService.listTasks({}, 'updated_at', 'desc');
+ expect(tasks[0].title).toBe('Task A Updated');
+ } finally {
+ vi.useRealTimers();
+ }
+ });
- const tasks = taskService.listTasks({}, 'updated_at', 'desc');
- expect(tasks[0].title).toBe('Task A Updated');
+ it('updated_atが同値の場合はid降順でソートされる', () => {
+ vi.useFakeTimers();
+ try {
+ const fixedNow = new Date('2026-01-01T00:00:00.000Z');
+ vi.setSystemTime(fixedNow);
+ const task1 = taskService.createTask({ title: 'Task A', status: 'backlog' });
+ const task2 = taskService.createTask({ title: 'Task B', status: 'backlog' });
+ taskService.updateTask(task1.id, { title: 'Task A Updated' });
+
+ const tasks = taskService.listTasks({}, 'updated_at', 'desc');
+ expect(tasks[0].id).toBe(task2.id);
+ expect(tasks[1].id).toBe(task1.id);
+ } finally {
+ vi.useRealTimers();
+ }
});
it('デフォルトソートはcreated_at降順のまま', () => {
diff --git a/tests/board/boardConfig.test.ts b/tests/board/boardConfig.test.ts
index 6e650506..9593b96a 100644
--- a/tests/board/boardConfig.test.ts
+++ b/tests/board/boardConfig.test.ts
@@ -6,7 +6,13 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
-import { readBoardConfig, writeBoardConfig, DETAIL_PANE_MAX_WIDTH, VALID_THEMES } from '../../src/board/boardConfig';
+import {
+ readBoardConfig,
+ writeBoardConfig,
+ DETAIL_PANE_MAX_WIDTH,
+ VALID_THEMES,
+ VALID_LLMS,
+} from '../../src/board/boardConfig';
describe('boardConfig', () => {
const testConfigDir = path.join(process.cwd(), '.agkan-test');
@@ -27,9 +33,9 @@ describe('boardConfig', () => {
});
describe('readBoardConfig', () => {
- it('should return empty config when config file does not exist', () => {
+ it('should return default llm when config file does not exist', () => {
const config = readBoardConfig(testConfigDir);
- expect(config).toEqual({});
+ expect(config.llm).toBe('claude');
});
it('should read detailPaneWidth from config file', () => {
@@ -38,30 +44,32 @@ describe('boardConfig', () => {
const config = readBoardConfig(testConfigDir);
expect(config.detailPaneWidth).toBe(500);
+ expect(config.llm).toBe('claude');
});
- it('should return empty config when config file is invalid YAML', () => {
+ it('should return default llm when config file is invalid YAML', () => {
fs.mkdirSync(testConfigDir, { recursive: true });
fs.writeFileSync(testConfigFile, 'invalid: yaml: content: [broken', 'utf8');
const config = readBoardConfig(testConfigDir);
- expect(config).toEqual({});
+ expect(config).toEqual({ llm: 'claude' });
});
- it('should return empty config when board section is missing', () => {
+ it('should return default llm when board section is missing', () => {
fs.mkdirSync(testConfigDir, { recursive: true });
fs.writeFileSync(testConfigFile, yaml.dump({ other: 'data' }), 'utf8');
const config = readBoardConfig(testConfigDir);
- expect(config).toEqual({});
+ expect(config).toEqual({ llm: 'claude' });
});
- it('should return empty config when detailPaneWidth is not a number', () => {
+ it('should ignore detailPaneWidth when it is not a number', () => {
fs.mkdirSync(testConfigDir, { recursive: true });
fs.writeFileSync(testConfigFile, yaml.dump({ board: { detailPaneWidth: 'not-a-number' } }), 'utf8');
const config = readBoardConfig(testConfigDir);
- expect(config).toEqual({});
+ expect(config.detailPaneWidth).toBeUndefined();
+ expect(config.llm).toBe('claude');
});
it('should ignore detailPaneWidth when it exceeds DETAIL_PANE_MAX_WIDTH', () => {
@@ -70,6 +78,7 @@ describe('boardConfig', () => {
const config = readBoardConfig(testConfigDir);
expect(config.detailPaneWidth).toBeUndefined();
+ expect(config.llm).toBe('claude');
});
it('should accept detailPaneWidth equal to DETAIL_PANE_MAX_WIDTH', () => {
@@ -110,14 +119,31 @@ describe('boardConfig', () => {
const config = readBoardConfig(testConfigDir);
expect(config.theme).toBeUndefined();
+ expect(config.llm).toBe('claude');
});
- it('should ignore theme when value is not a string', () => {
+ it('should read llm "codex" from config file', () => {
fs.mkdirSync(testConfigDir, { recursive: true });
- fs.writeFileSync(testConfigFile, yaml.dump({ board: { theme: 123 } }), 'utf8');
+ fs.writeFileSync(testConfigFile, yaml.dump({ board: { llm: 'codex' } }), 'utf8');
const config = readBoardConfig(testConfigDir);
- expect(config.theme).toBeUndefined();
+ expect(config.llm).toBe('codex');
+ });
+
+ it('should read llm "claude" from config file', () => {
+ fs.mkdirSync(testConfigDir, { recursive: true });
+ fs.writeFileSync(testConfigFile, yaml.dump({ board: { llm: 'claude' } }), 'utf8');
+
+ const config = readBoardConfig(testConfigDir);
+ expect(config.llm).toBe('claude');
+ });
+
+ it('should default llm to "claude" when llm value is invalid', () => {
+ fs.mkdirSync(testConfigDir, { recursive: true });
+ fs.writeFileSync(testConfigFile, yaml.dump({ board: { llm: 'invalid-llm' } }), 'utf8');
+
+ const config = readBoardConfig(testConfigDir);
+ expect(config.llm).toBe('claude');
});
});
@@ -163,6 +189,14 @@ describe('boardConfig', () => {
expect(parsed.board?.theme).toBe('dark');
});
+ it('should write llm to config file', () => {
+ writeBoardConfig(testConfigDir, { llm: 'codex' });
+
+ const content = fs.readFileSync(testConfigFile, 'utf8');
+ const parsed = yaml.load(content) as { board?: { llm?: string } };
+ expect(parsed.board?.llm).toBe('codex');
+ });
+
it('should preserve detailPaneWidth when writing theme', () => {
fs.mkdirSync(testConfigDir, { recursive: true });
fs.writeFileSync(testConfigFile, yaml.dump({ board: { detailPaneWidth: 500 } }), 'utf8');
@@ -189,4 +223,11 @@ describe('boardConfig', () => {
expect(VALID_THEMES).toContain('system');
});
});
+
+ describe('VALID_LLMS', () => {
+ it('should contain codex and claude', () => {
+ expect(VALID_LLMS).toContain('codex');
+ expect(VALID_LLMS).toContain('claude');
+ });
+ });
});
diff --git a/tests/board/boardRoutes.test.ts b/tests/board/boardRoutes.test.ts
index 325633cc..38653cdd 100644
--- a/tests/board/boardRoutes.test.ts
+++ b/tests/board/boardRoutes.test.ts
@@ -807,7 +807,7 @@ describe('GET /api/config', () => {
const res = await app.fetch(new Request('http://localhost/api/config'));
expect(res.status).toBe(200);
const data = (await res.json()) as { board: Record };
- expect(data.board).toEqual({});
+ expect(data.board).toEqual({ llm: 'claude' });
});
});
@@ -931,6 +931,58 @@ describe('PUT /api/config', () => {
);
expect(res.status).toBe(400);
});
+
+ it('returns success when updating llm to codex', async () => {
+ const app = buildApp(buildServices());
+ const res = await app.fetch(
+ new Request('http://localhost/api/config', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ board: { llm: 'codex' } }),
+ })
+ );
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean };
+ expect(data.success).toBe(true);
+ });
+
+ it('returns success when updating llm to claude', async () => {
+ const app = buildApp(buildServices());
+ const res = await app.fetch(
+ new Request('http://localhost/api/config', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ board: { llm: 'claude' } }),
+ })
+ );
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean };
+ expect(data.success).toBe(true);
+ });
+
+ it('returns 400 when llm is invalid', async () => {
+ const app = buildApp(buildServices());
+ const res = await app.fetch(
+ new Request('http://localhost/api/config', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ board: { llm: 'invalid' } }),
+ })
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it('returns 400 when llm is not a string', async () => {
+ const app = buildApp(buildServices());
+ const res = await app.fetch(
+ new Request('http://localhost/api/config', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ board: { llm: 123 } }),
+ })
+ );
+ expect(res.status).toBe(400);
+ });
});
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/tests/board/claudeRoutes.test.ts b/tests/board/claudeRoutes.test.ts
index ef8f6eff..31ecc39b 100644
--- a/tests/board/claudeRoutes.test.ts
+++ b/tests/board/claudeRoutes.test.ts
@@ -14,25 +14,24 @@ import { MetadataService } from '../../src/services/MetadataService';
import { CommentService } from '../../src/services/CommentService';
import { TaskBlockService } from '../../src/services/TaskBlockService';
import { ConflictError } from '../../src/errors';
-import { ClaudeProcessService, SubscribeCallback } from '../../src/services/ClaudeProcessService';
+import type { IProcessService, SubscribeCallback } from '../../src/services/IProcessService';
import { getStorageBackend } from '../../src/db/connection';
import { registerBoardRoutes, BoardServices } from '../../src/board/boardRoutes';
const TEST_CONFIG_DIR = path.join(process.cwd(), '.agkan-test-claude-routes-' + process.pid);
-function buildMockClaudeProcessService(): ClaudeProcessService {
+function buildMockProcessService(): IProcessService {
const mock = {
startProcess: vi.fn(),
stopProcess: vi.fn().mockReturnValue(true),
listRunningTasks: vi.fn().mockReturnValue([]),
subscribeOutput: vi.fn().mockReturnValue(() => {}),
- getOutputBuffer: vi.fn().mockReturnValue([]),
getRunLogs: vi.fn().mockReturnValue([]),
- } as unknown as ClaudeProcessService;
+ } as unknown as IProcessService;
return mock;
}
-function buildServices(claudeProcessService?: ClaudeProcessService): BoardServices {
+function buildServices(processService?: IProcessService): BoardServices {
const database = getStorageBackend();
return {
ts: new TaskService(database),
@@ -43,7 +42,7 @@ function buildServices(claudeProcessService?: ClaudeProcessService): BoardServic
tbs: new TaskBlockService(database),
database,
configDir: TEST_CONFIG_DIR,
- claudeProcessService,
+ processService,
};
}
@@ -71,7 +70,7 @@ afterEach(() => {
// ─────────────────────────────────────────────────────────────────────────────
describe('POST /api/claude/tasks/:taskId/run', () => {
it('returns 201 and starts a process for a valid task', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
const services = buildServices(mock);
const task = services.ts.createTask({ title: 'Test Task', status: 'backlog' });
const app = buildApp(services);
@@ -92,7 +91,7 @@ describe('POST /api/claude/tasks/:taskId/run', () => {
});
it('uses planning prompt when command is planning', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
const services = buildServices(mock);
const task = services.ts.createTask({ title: 'Planning Task', status: 'backlog' });
const app = buildApp(services);
@@ -110,7 +109,7 @@ describe('POST /api/claude/tasks/:taskId/run', () => {
});
it('defaults to run command when no command specified', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
const services = buildServices(mock);
const task = services.ts.createTask({ title: 'Default Task', status: 'backlog' });
const app = buildApp(services);
@@ -128,7 +127,7 @@ describe('POST /api/claude/tasks/:taskId/run', () => {
});
it('returns 400 for invalid taskId', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
const app = buildApp(buildServices(mock));
const res = await app.fetch(
@@ -145,7 +144,7 @@ describe('POST /api/claude/tasks/:taskId/run', () => {
});
it('returns 404 when task does not exist', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
const app = buildApp(buildServices(mock));
const res = await app.fetch(
@@ -162,7 +161,7 @@ describe('POST /api/claude/tasks/:taskId/run', () => {
});
it('returns 409 when process is already running', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
(mock.startProcess as ReturnType).mockImplementation(() => {
throw new ConflictError('Process for taskId 1 is already running');
});
@@ -206,7 +205,7 @@ describe('POST /api/claude/tasks/:taskId/run', () => {
describe('POST /api/claude/tasks/:taskId/run - status auto-update', () => {
it('updates status to done when run command completes with exitCode 0', async () => {
let capturedCallback: SubscribeCallback | null = null;
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
(mock.subscribeOutput as ReturnType).mockImplementation((_taskId: number, cb: SubscribeCallback) => {
capturedCallback = cb;
return () => {};
@@ -233,7 +232,7 @@ describe('POST /api/claude/tasks/:taskId/run - status auto-update', () => {
it('updates status to review when pr command completes with exitCode 0', async () => {
let capturedCallback: SubscribeCallback | null = null;
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
(mock.subscribeOutput as ReturnType).mockImplementation((_taskId: number, cb: SubscribeCallback) => {
capturedCallback = cb;
return () => {};
@@ -260,7 +259,7 @@ describe('POST /api/claude/tasks/:taskId/run - status auto-update', () => {
it('does not update status when exitCode is non-zero', async () => {
let capturedCallback: SubscribeCallback | null = null;
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
(mock.subscribeOutput as ReturnType).mockImplementation((_taskId: number, cb: SubscribeCallback) => {
capturedCallback = cb;
return () => {};
@@ -286,7 +285,7 @@ describe('POST /api/claude/tasks/:taskId/run - status auto-update', () => {
});
it('does not update status for planning command', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
const services = buildServices(mock);
const task = services.ts.createTask({ title: 'Planning Task', status: 'ready' });
const app = buildApp(services);
@@ -312,7 +311,7 @@ describe('POST /api/claude/tasks/:taskId/run - status auto-update', () => {
// ─────────────────────────────────────────────────────────────────────────────
describe('DELETE /api/claude/tasks/:taskId/run', () => {
it('returns 200 and stops the process', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
(mock.stopProcess as ReturnType).mockReturnValue(true);
const app = buildApp(buildServices(mock));
@@ -329,7 +328,7 @@ describe('DELETE /api/claude/tasks/:taskId/run', () => {
});
it('returns 400 for invalid taskId', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
const app = buildApp(buildServices(mock));
const res = await app.fetch(
@@ -344,7 +343,7 @@ describe('DELETE /api/claude/tasks/:taskId/run', () => {
});
it('returns 404 when process does not exist', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
(mock.stopProcess as ReturnType).mockReturnValue(false);
const app = buildApp(buildServices(mock));
@@ -377,7 +376,7 @@ describe('DELETE /api/claude/tasks/:taskId/run', () => {
// ─────────────────────────────────────────────────────────────────────────────
describe('GET /api/running-tasks', () => {
it('returns 200 with empty tasks array when nothing is running', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
(mock.listRunningTasks as ReturnType).mockReturnValue([]);
const app = buildApp(buildServices(mock));
@@ -389,7 +388,7 @@ describe('GET /api/running-tasks', () => {
});
it('returns list of running tasks with command info', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
(mock.listRunningTasks as ReturnType).mockReturnValue([
{ taskId: 1, command: 'run' },
{ taskId: 2, command: 'planning' },
@@ -422,7 +421,7 @@ describe('GET /api/running-tasks', () => {
// ─────────────────────────────────────────────────────────────────────────────
describe('GET /api/claude/tasks/:taskId/stream', () => {
it('returns SSE response with correct headers', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
const app = buildApp(buildServices(mock));
const res = await app.fetch(new Request('http://localhost/api/claude/tasks/1/stream'));
@@ -434,7 +433,7 @@ describe('GET /api/claude/tasks/:taskId/stream', () => {
});
it('returns 400 for invalid taskId', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
const app = buildApp(buildServices(mock));
const res = await app.fetch(new Request('http://localhost/api/claude/tasks/abc/stream'));
@@ -444,7 +443,7 @@ describe('GET /api/claude/tasks/:taskId/stream', () => {
it('sends SSE events from subscribeOutput callback', async () => {
let capturedCallback: SubscribeCallback | null = null;
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
(mock.subscribeOutput as ReturnType).mockImplementation((_taskId: number, cb: SubscribeCallback) => {
capturedCallback = cb;
return () => {};
@@ -483,7 +482,7 @@ describe('GET /api/claude/tasks/:taskId/stream', () => {
// ─────────────────────────────────────────────────────────────────────────────
describe('GET /api/claude/tasks/:taskId/run-logs', () => {
it('returns 200 with empty logs when no runs exist', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
const services = buildServices(mock);
const task = services.ts.createTask({ title: 'Test Task', status: 'done' });
const app = buildApp(services);
@@ -497,7 +496,7 @@ describe('GET /api/claude/tasks/:taskId/run-logs', () => {
});
it('returns 200 with logs from getRunLogs', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
const fakeLogs = [
{
id: 1,
@@ -524,7 +523,7 @@ describe('GET /api/claude/tasks/:taskId/run-logs', () => {
});
it('returns 400 for invalid taskId', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
const app = buildApp(buildServices(mock));
const res = await app.fetch(new Request('http://localhost/api/claude/tasks/abc/run-logs'));
@@ -533,7 +532,7 @@ describe('GET /api/claude/tasks/:taskId/run-logs', () => {
});
it('returns 404 when task does not exist', async () => {
- const mock = buildMockClaudeProcessService();
+ const mock = buildMockProcessService();
const app = buildApp(buildServices(mock));
const res = await app.fetch(new Request('http://localhost/api/claude/tasks/9999/run-logs'));
diff --git a/tests/board/client/burgerMenuSettings.test.ts b/tests/board/client/burgerMenuSettings.test.ts
new file mode 100644
index 00000000..89a6ac75
--- /dev/null
+++ b/tests/board/client/burgerMenuSettings.test.ts
@@ -0,0 +1,144 @@
+/**
+ * @vitest-environment jsdom
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { initBurgerMenu } from '../../../src/board/client/burgerMenu';
+
+function setupDOM(): void {
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.restoreAllMocks();
+ setupDOM();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('burger menu settings modal', () => {
+ it('opens settings modal from burger menu', async () => {
+ vi.spyOn(global, 'fetch').mockResolvedValue(
+ new Response(JSON.stringify({ board: { llm: 'claude' } }), { status: 200 })
+ );
+
+ initBurgerMenu();
+ document.getElementById('burger-settings')!.click();
+
+ const modal = document.getElementById('settings-modal')!;
+ expect(modal.classList.contains('show')).toBe(true);
+ });
+
+ it('loads llm selection from server config', async () => {
+ vi.spyOn(global, 'fetch').mockResolvedValue(
+ new Response(JSON.stringify({ board: { llm: 'codex' } }), { status: 200 })
+ );
+
+ initBurgerMenu();
+ document.getElementById('burger-settings')!.click();
+
+ await vi.waitFor(() => {
+ expect((document.getElementById('settings-llm-select') as HTMLSelectElement).value).toBe('codex');
+ });
+ });
+
+ it('uses claude as fallback when llm is missing', async () => {
+ vi.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({ board: {} }), { status: 200 }));
+
+ initBurgerMenu();
+ document.getElementById('burger-settings')!.click();
+
+ await vi.waitFor(() => {
+ expect((document.getElementById('settings-llm-select') as HTMLSelectElement).value).toBe('claude');
+ });
+ });
+
+ it('saves selected llm via /api/config', async () => {
+ const fetchMock = vi
+ .spyOn(global, 'fetch')
+ .mockResolvedValueOnce(new Response(JSON.stringify({ board: { llm: 'claude' } }), { status: 200 }))
+ .mockResolvedValueOnce(new Response(JSON.stringify({ success: true }), { status: 200 }));
+
+ initBurgerMenu();
+ document.getElementById('burger-settings')!.click();
+
+ const select = document.getElementById('settings-llm-select') as HTMLSelectElement;
+ select.value = 'codex';
+
+ document.getElementById('settings-save-btn')!.click();
+
+ await vi.waitFor(() => {
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 2,
+ '/api/config',
+ expect.objectContaining({
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ board: { llm: 'codex' } }),
+ })
+ );
+ });
+
+ vi.advanceTimersByTime(300);
+ expect(document.getElementById('settings-modal')!.classList.contains('show')).toBe(false);
+ });
+});
diff --git a/tests/board/server.test.ts b/tests/board/server.test.ts
index 4a466ec0..2a751326 100644
--- a/tests/board/server.test.ts
+++ b/tests/board/server.test.ts
@@ -1860,6 +1860,7 @@ describe('createBoardApp', () => {
const data = (await res.json()) as { board: { detailPaneWidth?: number } };
expect(data.board).toBeDefined();
expect(data.board.detailPaneWidth).toBeUndefined();
+ expect((data.board as { llm?: string }).llm).toBe('claude');
});
it('should return detailPaneWidth from config file when it exists', async () => {
@@ -1934,6 +1935,32 @@ describe('createBoardApp', () => {
expect(saved.board?.detailPaneWidth).toBe(450);
});
+ it('should save llm to config file', async () => {
+ const app = createBoardApp(
+ taskService,
+ taskTagService,
+ metadataService,
+ undefined,
+ undefined,
+ undefined,
+ testConfigDir
+ );
+ const res = await app.fetch(
+ new Request('http://localhost/api/config', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ board: { llm: 'codex' } }),
+ })
+ );
+
+ expect(res.status).toBe(200);
+
+ const configFile = path.join(testConfigDir, 'config.yml');
+ expect(fs.existsSync(configFile)).toBe(true);
+ const saved = yaml.load(fs.readFileSync(configFile, 'utf8')) as { board?: { llm?: string } };
+ expect(saved.board?.llm).toBe('codex');
+ });
+
it('should return 400 when detailPaneWidth exceeds DETAIL_PANE_MAX_WIDTH', async () => {
const app = createBoardApp(
taskService,
diff --git a/tests/services/index.test.ts b/tests/services/index.test.ts
index cfecd0bd..3193be80 100644
--- a/tests/services/index.test.ts
+++ b/tests/services/index.test.ts
@@ -47,8 +47,23 @@ describe('services/index', () => {
expect(typeof servicesIndex.ClaudeProcessService).toBe('function');
});
- it('should export exactly 9 named exports', () => {
+ it('should export CodexProcessService', () => {
+ expect(servicesIndex.CodexProcessService).toBeDefined();
+ expect(typeof servicesIndex.CodexProcessService).toBe('function');
+ });
+
+ it('should export IProcessService type', () => {
+ // Type exports don't appear as runtime values, but we can verify the import works
+ expect(servicesIndex).toBeDefined();
+ });
+
+ it('should export ProcessServiceFactory', () => {
+ expect(servicesIndex.ProcessServiceFactory).toBeDefined();
+ expect(typeof servicesIndex.ProcessServiceFactory).toBe('function');
+ });
+
+ it('should export exactly 11 named exports', () => {
const exportedKeys = Object.keys(servicesIndex);
- expect(exportedKeys).toHaveLength(9);
+ expect(exportedKeys).toHaveLength(11);
});
});