Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/web_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ func (c *webSessionController) registerHTTP(app *fiber.App, group *huma.Group) {
Body struct {
WorktreeID string `json:"worktreeId"`
Agent string `json:"agent"`
ClaudeRuntime string `json:"claudeRuntime"`
Model string `json:"model"`
ReasoningEffort string `json:"reasoningEffort"`
WorkflowMode string `json:"workflowMode"`
Expand Down Expand Up @@ -243,6 +244,7 @@ func (c *webSessionController) registerHTTP(app *fiber.App, group *huma.Group) {
ProjectID: input.ProjectID,
WorktreeID: input.Body.WorktreeID,
Agent: websession.Agent(input.Body.Agent),
ClaudeRuntime: websession.ClaudeRuntime(input.Body.ClaudeRuntime),
Model: input.Body.Model,
ReasoningEffort: websession.ReasoningEffort(input.Body.ReasoningEffort),
WorkflowMode: workflowMode,
Expand Down
3 changes: 1 addition & 2 deletions model/sqlc_gen/schema_sqlite.sql
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ CREATE UNIQUE INDEX "idx_session_type" ON "ai_sessions"("session_id","type");
CREATE INDEX "idx_ai_sessions_deleted_at" ON "ai_sessions"("deleted_at");


CREATE TABLE "web_sessions" ("id" text NOT NULL,"created_at" datetime,"updated_at" datetime,"deleted_at" datetime,"project_id" text NOT NULL,"worktree_id" text,"order_index" real NOT NULL DEFAULT 0,"agent" text NOT NULL,"backend" text NOT NULL DEFAULT "legacy_exec","title" text NOT NULL,"title_auto" boolean NOT NULL DEFAULT false,"model" text,"reasoning_effort" text,"workflow_mode" text NOT NULL DEFAULT "default","permission_level" text NOT NULL DEFAULT "elevated","auto_retry_enabled" boolean NOT NULL DEFAULT false,"auto_retry_scope" text NOT NULL DEFAULT "network_only","auto_retry_preset" text NOT NULL DEFAULT "gentle_stop","permission_mode" text,"cwd" text NOT NULL,"native_session_id" text,"status" text NOT NULL,"assistant_state" text,"has_unread" boolean NOT NULL DEFAULT false,"archived_at" datetime,"activity_at" datetime,"status_updated_at" datetime,"assistant_state_updated_at" datetime,"source_kind" text NOT NULL DEFAULT "codex_app_server","sync_state" text NOT NULL DEFAULT "missing","last_sync_mode" text,"source_created_at" datetime,"source_updated_at" datetime,"last_synced_at" datetime,"thread_path" text,"thread_preview" text,"turn_count" integer NOT NULL DEFAULT 0,"item_count" integer NOT NULL DEFAULT 0,"last_message_at" datetime,"last_event_seq" integer NOT NULL DEFAULT 0,"total_input_tokens" integer NOT NULL DEFAULT 0,"total_cached_input_tokens" integer NOT NULL DEFAULT 0,"total_output_tokens" integer NOT NULL DEFAULT 0,"total_cost" real NOT NULL DEFAULT 0,"last_completed_input_tokens" integer NOT NULL DEFAULT 0,"last_completed_cached_input_tokens" integer NOT NULL DEFAULT 0,"last_completed_output_tokens" integer NOT NULL DEFAULT 0,"latest_turn_input_tokens" integer NOT NULL DEFAULT 0,"latest_turn_cached_input_tokens" integer NOT NULL DEFAULT 0,"latest_turn_output_tokens" integer NOT NULL DEFAULT 0,"latest_turn_usage_updated_at" datetime,"latest_token_count_input_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_cached_input_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_output_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_total_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_updated_at" datetime,"session_context_window_tokens" integer NOT NULL DEFAULT 0,"session_context_window_observed_at" datetime,"context_baseline_input_tokens" integer NOT NULL DEFAULT 0,"context_baseline_cached_input_tokens" integer NOT NULL DEFAULT 0,"context_baseline_output_tokens" integer NOT NULL DEFAULT 0,"last_context_compaction_at" datetime,"auto_retry_attempt" integer NOT NULL DEFAULT 0,"auto_retry_next_at" datetime,"auto_retry_last_error_code" text,"last_error" text,"sync_error" text,PRIMARY KEY ("id"));
CREATE TABLE "web_sessions" ("id" text NOT NULL,"created_at" datetime,"updated_at" datetime,"deleted_at" datetime,"project_id" text NOT NULL,"worktree_id" text,"order_index" real NOT NULL DEFAULT 0,"agent" text NOT NULL,"claude_runtime" text NOT NULL DEFAULT "claude","backend" text NOT NULL DEFAULT "legacy_exec","title" text NOT NULL,"title_auto" boolean NOT NULL DEFAULT false,"model" text,"reasoning_effort" text,"workflow_mode" text NOT NULL DEFAULT "default","permission_level" text NOT NULL DEFAULT "elevated","auto_retry_enabled" boolean NOT NULL DEFAULT false,"auto_retry_scope" text NOT NULL DEFAULT "network_only","auto_retry_preset" text NOT NULL DEFAULT "gentle_stop","permission_mode" text,"cwd" text NOT NULL,"native_session_id" text,"status" text NOT NULL,"assistant_state" text,"has_unread" boolean NOT NULL DEFAULT false,"archived_at" datetime,"activity_at" datetime,"status_updated_at" datetime,"assistant_state_updated_at" datetime,"source_kind" text NOT NULL DEFAULT "codex_app_server","sync_state" text NOT NULL DEFAULT "missing","last_sync_mode" text,"source_created_at" datetime,"source_updated_at" datetime,"last_synced_at" datetime,"thread_path" text,"thread_preview" text,"turn_count" integer NOT NULL DEFAULT 0,"item_count" integer NOT NULL DEFAULT 0,"last_message_at" datetime,"last_event_seq" integer NOT NULL DEFAULT 0,"total_input_tokens" integer NOT NULL DEFAULT 0,"total_cached_input_tokens" integer NOT NULL DEFAULT 0,"total_output_tokens" integer NOT NULL DEFAULT 0,"total_cost" real NOT NULL DEFAULT 0,"last_completed_input_tokens" integer NOT NULL DEFAULT 0,"last_completed_cached_input_tokens" integer NOT NULL DEFAULT 0,"last_completed_output_tokens" integer NOT NULL DEFAULT 0,"latest_turn_input_tokens" integer NOT NULL DEFAULT 0,"latest_turn_cached_input_tokens" integer NOT NULL DEFAULT 0,"latest_turn_output_tokens" integer NOT NULL DEFAULT 0,"latest_turn_usage_updated_at" datetime,"latest_token_count_input_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_cached_input_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_output_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_total_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_updated_at" datetime,"session_context_window_tokens" integer NOT NULL DEFAULT 0,"session_context_window_observed_at" datetime,"context_baseline_input_tokens" integer NOT NULL DEFAULT 0,"context_baseline_cached_input_tokens" integer NOT NULL DEFAULT 0,"context_baseline_output_tokens" integer NOT NULL DEFAULT 0,"last_context_compaction_at" datetime,"auto_retry_attempt" integer NOT NULL DEFAULT 0,"auto_retry_next_at" datetime,"auto_retry_last_error_code" text,"last_error" text,"sync_error" text,PRIMARY KEY ("id"));
CREATE INDEX "idx_web_sessions_source_updated_at" ON "web_sessions"("source_updated_at");
CREATE INDEX "idx_web_sessions_sync_state" ON "web_sessions"("sync_state");
CREATE INDEX "idx_web_sessions_status_updated_at" ON "web_sessions"("status_updated_at");
Expand Down Expand Up @@ -102,4 +102,3 @@ CREATE INDEX "idx_task_ai_sessions_ai_session_id" ON "task_ai_sessions"("ai_sess
CREATE UNIQUE INDEX "idx_task_ai_session" ON "task_ai_sessions"("task_id","ai_session_id");
CREATE INDEX "idx_task_ai_sessions_task_id" ON "task_ai_sessions"("task_id");
CREATE INDEX "idx_task_ai_sessions_deleted_at" ON "task_ai_sessions"("deleted_at");

1 change: 1 addition & 0 deletions model/tables/web_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type WebSessionTable struct {
OrderIndex float64 `gorm:"type:real;not null;default:0;index" json:"orderIndex"`

Agent string `gorm:"type:text;not null;index" json:"agent"`
ClaudeRuntime string `gorm:"type:text;not null;default:claude" json:"claudeRuntime"`
Backend string `gorm:"type:text;not null;default:legacy_exec" json:"-"`
Title string `gorm:"type:text;not null" json:"title"`
TitleAuto bool `gorm:"type:boolean;not null;default:false" json:"-"`
Expand Down
1 change: 1 addition & 0 deletions packages/codekanban-cli/src/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const VALUE_FLAGS = new Set([
'--prompt',
'--text',
'--agent',
'--claude-runtime',
'--model',
'--profile',
'--sandbox',
Expand Down
17 changes: 16 additions & 1 deletion packages/codekanban-cli/src/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ function readFlagValue(argv, index, flag) {
return value;
}

function readRawFlagValue(argv, index, flag) {
const value = argv[index + 1];
if (value == null) {
throw new Error(`${flag} requires a value`);
}
return value;
}

function parseIntegerFlag(value, fieldName) {
if (value == null || value === '') {
return undefined;
Expand Down Expand Up @@ -83,6 +91,10 @@ function parseCliArgs(argv) {
flags.agent = readFlagValue(argv, index, token);
index += 1;
break;
case '--claude-runtime':
flags.claudeRuntime = readFlagValue(argv, index, token);
index += 1;
break;
case '--model':
flags.model = readFlagValue(argv, index, token);
index += 1;
Expand All @@ -108,7 +120,7 @@ function parseCliArgs(argv) {
index += 1;
break;
case '--extra-arg':
flags.extraArgs.push(readFlagValue(argv, index, token));
flags.extraArgs.push(readRawFlagValue(argv, index, token));
index += 1;
break;
case '--title':
Expand Down Expand Up @@ -282,6 +294,7 @@ Common options:
--project-id <id> Project identifier
--path <path> Local project path
--session-id <id> Session identifier
--claude-runtime <rt> Claude launcher for workflow terminal mode: claude or ccr
--help Show this help text

Examples:
Expand Down Expand Up @@ -620,6 +633,7 @@ export async function runCli(argv, options = {}) {
if (scope === 'workflow' && action === 'command') {
const result = buildAgentLaunchSpec({
agent: flags.agent,
claudeRuntime: flags.claudeRuntime,
profile: flags.profile,
permissions: buildPermissions(flags),
extraArgs: flags.extraArgs,
Expand Down Expand Up @@ -652,6 +666,7 @@ export async function runCli(argv, options = {}) {
worktreeId: flags.worktreeId,
prompt: flags.prompt,
agent: flags.agent,
claudeRuntime: flags.claudeRuntime,
profile: flags.profile,
permissions: buildPermissions(flags),
extraArgs: flags.extraArgs,
Expand Down
53 changes: 53 additions & 0 deletions packages/codekanban-cli/test/runtime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,59 @@ test('CLI --help prints usage text', { concurrency: false }, async () => {
assert.equal(result.stderr, '');
});

test('CLI workflow command supports Claude Code Router runtime', { concurrency: false }, async () => {
const result = await runCliCaptured([
'workflow',
'command',
'--agent',
'claude',
'--claude-runtime',
'ccr',
'--extra-arg',
'--model',
'--extra-arg',
'sonnet',
'--prompt',
'Hello',
]);

assert.equal(result.exitCode, 0);
const payload = JSON.parse(result.stdout);
assert.equal(payload.agent, 'claude');
assert.equal(payload.claudeRuntime, 'ccr');
assert.equal(payload.command, 'ccr code --model sonnet');
assert.deepEqual(payload.argv, ['ccr', 'code', '--model', 'sonnet']);
});

test('CLI workflow start forwards Claude runtime to the SDK client', { concurrency: false }, async () => {
const calls = [];
const result = await runCliCaptured([
'workflow',
'start',
'--base-url',
'http://127.0.0.1:3000',
'--project-id',
'p1',
'--agent',
'claude',
'--claude-runtime',
'ccr',
'--prompt',
'Hello',
], {
clientFactory: () => ({
async startWorkflow(input) {
calls.push(input);
return { command: 'ccr code', claudeRuntime: input.claudeRuntime };
},
}),
});

assert.equal(result.exitCode, 0);
assert.equal(calls[0].claudeRuntime, 'ccr');
assert.equal(JSON.parse(result.stdout).claudeRuntime, 'ccr');
});


test('CLI web-session create prints the created session JSON', { concurrency: false }, async () => {
const handlers = new Map([
Expand Down
2 changes: 2 additions & 0 deletions packages/node-sdk/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,7 @@ export class CodeKanbanClient {
body: {
worktreeId: ensureString(worktree?.id, "worktreeId"),
agent,
claudeRuntime: ensureOptionalString(input.claudeRuntime) || "claude",
model:
ensureOptionalString(input.model) || defaultWebSessionModel(agent),
reasoningEffort:
Expand Down Expand Up @@ -1878,6 +1879,7 @@ export class CodeKanbanClient {
worktree,
terminalSession: terminal,
agent: launch.agent,
claudeRuntime: launch.claudeRuntime,
profile: launch.profile,
command: launch.command,
prompt: launch.prompt,
Expand Down
6 changes: 5 additions & 1 deletion packages/node-sdk/src/command-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-acces
export const APPROVAL_POLICIES = ['untrusted', 'on-request', 'never'];
export const WORKFLOW_PROFILES = ['plan', 'standard', 'yolo'];
export const AGENTS = ['codex', 'claude'];
export const CLAUDE_RUNTIMES = ['claude', 'ccr'];

const KNOWN_STRUCTURED_FLAGS = new Set([
'-s',
Expand Down Expand Up @@ -60,15 +61,18 @@ export function buildAgentLaunchSpec(options = {}) {
const extraArgs = ensureArrayOfStrings(options.extraArgs, 'extraArgs');

if (agent === 'claude') {
const claudeRuntime =
validateEnum(options.claudeRuntime || 'claude', CLAUDE_RUNTIMES, 'claudeRuntime') || 'claude';
if (profile !== 'standard') {
throw new CodeKanbanValidationError('claude only supports the standard profile in v1');
}
if (options.permissions) {
throw new CodeKanbanValidationError('structured permissions are only supported for codex in v1');
}
const argv = ['claude', ...extraArgs];
const argv = claudeRuntime === 'ccr' ? ['ccr', 'code', ...extraArgs] : ['claude', ...extraArgs];
return {
agent,
claudeRuntime,
profile,
argv,
command: toCommandString(argv),
Expand Down
11 changes: 11 additions & 0 deletions packages/node-sdk/src/web-session-command-channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export class WebSessionCommandChannel {
pid: ensureString(input.projectId, "projectId"),
wid: ensureOptionalString(input.worktreeId),
ag: ensureString(input.agent, "agent"),
cr: ensureOptionalString(input.claudeRuntime),
md: ensureOptionalString(input.model),
re: ensureOptionalString(input.reasoningEffort),
wm: ensureOptionalString(input.workflowMode),
Expand Down Expand Up @@ -230,6 +231,16 @@ export class WebSessionCommandChannel {
});
}

async updateClaudeRuntime(sessionId, input = {}) {
return await this._executeCommand({
operation: "set_cr",
sessionId,
payload: {
cr: ensureString(input.claudeRuntime, "claudeRuntime"),
},
});
}

async updateReasoningEffort(sessionId, input = {}) {
return await this._executeCommand({
operation: "set_re",
Expand Down
1 change: 1 addition & 0 deletions packages/node-sdk/src/web-session-shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ export function normalizeWebSessionSummaryFromWire(value) {
worktreeId: trimmedString(value?.wid) || null,
orderIndex: numberValue(value?.oi, 0),
agent: trimmedString(value?.ag) || "codex",
claudeRuntime: trimmedString(value?.cr) === "ccr" ? "ccr" : "claude",
title: trimmedString(value?.ttl),
model: trimmedString(value?.md),
reasoningEffort: trimmedString(value?.re) || "default",
Expand Down
27 changes: 27 additions & 0 deletions packages/node-sdk/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,33 @@ test('startWorkflow creates a terminal and sends command plus prompt', async ()
assert.match(FakeWebSocket.instances[0].sent[1].data, /planning mode/i);
});

test('startWorkflow launches Claude through CCR when requested', async () => {
FakeWebSocket.instances.length = 0;
const handlers = new Map([
['GET /api/v1/projects', () => createJsonResponse({ items: [{ id: 'p1', path: 'D:/repo/demo', name: 'demo' }] })],
['GET /api/v1/projects/p1/worktrees', () => createJsonResponse({ items: [{ id: 'w1', path: 'D:/repo/demo', isMain: true }] })],
['POST /api/v1/projects/p1/worktrees/w1/terminals', () => createJsonResponse({ item: { id: 't1', wsPath: '/api/v1/terminal/ws?sessionId=t1', wsUrl: '/api/v1/terminal/ws?sessionId=t1', title: 'demo', projectId: 'p1', worktreeId: 'w1', workingDir: 'D:/repo/demo' } }, 201)],
]);

const client = new CodeKanbanClient({
baseURL: 'http://127.0.0.1:3000',
fetchImpl: createFetchMock(handlers),
WebSocketImpl: FakeWebSocket,
});

const result = await client.startWorkflow({
path: 'D:/repo/demo',
agent: 'claude',
claudeRuntime: 'ccr',
prompt: 'Inspect',
});

assert.equal(result.claudeRuntime, 'ccr');
assert.equal(FakeWebSocket.instances.length, 1);
assert.match(FakeWebSocket.instances[0].sent[0].data, /^ccr code\r$/);
assert.match(FakeWebSocket.instances[0].sent[1].data, /^Inspect\r$/);
});

test('listSessions returns terminal and ai summaries', async () => {
const handlers = new Map([
['GET /api/v1/projects/p1', () => createJsonResponse({ item: { id: 'p1', path: 'D:/repo/demo', name: 'demo' } })],
Expand Down
24 changes: 24 additions & 0 deletions packages/node-sdk/test/command-builder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,27 @@ test('claude only supports standard profile', () => {
/claude only supports the standard profile/i,
);
});

test('buildAgentLaunchSpec builds Claude CCR terminal command', () => {
const result = buildAgentLaunchSpec({
agent: 'claude',
claudeRuntime: 'ccr',
prompt: 'Hello',
extraArgs: ['--model', 'sonnet'],
});

assert.equal(result.command, 'ccr code --model sonnet');
assert.equal(result.claudeRuntime, 'ccr');
});

test('buildAgentLaunchSpec rejects invalid Claude runtime', () => {
assert.throws(
() =>
buildAgentLaunchSpec({
agent: 'claude',
claudeRuntime: 'bad',
prompt: 'Hello',
}),
/claudeRuntime must be one of/i,
);
});
Loading
Loading