import { TunnelCliError, EXIT_CODE } from './cli-errors.js'; import { resolveTargetPort } from './cli-api-target.js'; import { parseGoalTokenBudget } from './cli-goal.js'; import { requestControlAction } from './cli-control.js'; import { intro as clackIntro, outro as clackOutro, isJsonMode, isQuietMode, printJson, logStatus, } from '../cli-output.js'; const asNonEmptyString = (value) => { if (typeof value !== 'string') return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; }; const validateModel = (model) => { const normalized = asNonEmptyString(model); if (!normalized) return null; const slashIndex = normalized.indexOf('/'); if (slashIndex <= 0 || slashIndex === normalized.length - 1) { throw new TunnelCliError('--model must be in provider/model format.', EXIT_CODE.USAGE_ERROR); } return normalized; }; const normalizeLimit = (value, fallback = 10) => { if (value === undefined || value === null) return fallback; const parsed = Number.parseInt(String(value), 10); if (!Number.isInteger(parsed) || parsed < 1) { throw new TunnelCliError('Invalid limit value. Provide a positive integer.', EXIT_CODE.USAGE_ERROR); } return parsed; }; const assertSessionTarget = (options = {}) => { const sessionId = asNonEmptyString(options.session); const directory = asNonEmptyString(options.directory); if (!sessionId) { throw new TunnelCliError('Missing required --session.', EXIT_CODE.USAGE_ERROR); } if (!directory) { throw new TunnelCliError('Missing required --dir.', EXIT_CODE.USAGE_ERROR); } return { sessionId, directory }; }; const normalizeMessageRole = (value) => { const role = asNonEmptyString(value) || 'all'; if (!['all', 'user', 'assistant'].includes(role)) { throw new TunnelCliError('--role must be one of: all, user, assistant.', EXIT_CODE.USAGE_ERROR); } return role; }; const formatTextMessage = (message) => { const label = message.role === 'user' ? 'User' : 'Assistant'; const timestamp = message.createdAt ? new Date(message.createdAt).toISOString() : ''; const details = [timestamp, message.model].filter(Boolean).join(' '); return `**${label}**${details ? `\n\n*${details}*` : ''}\n\n${message.text}`; }; const formatSessionModel = (session) => { const model = session?.model; const providerID = asNonEmptyString(model?.providerID) || asNonEmptyString(model?.providerId); const modelID = asNonEmptyString(model?.id) || asNonEmptyString(model?.modelID) || asNonEmptyString(model?.modelId); return providerID && modelID ? `${providerID}/${modelID}` : null; }; const formatSessionLine = (session) => { const title = asNonEmptyString(session?.title) || asNonEmptyString(session?.slug) || asNonEmptyString(session?.id) || 'untitled'; const model = formatSessionModel(session) || 'unknown-model'; const agent = asNonEmptyString(session?.agent) || 'unknown-agent'; const variant = asNonEmptyString(session?.model?.variant); const directory = asNonEmptyString(session?.directory) || 'unknown-directory'; const selections = [`\`${model}\``, `\`${agent}\``]; if (variant && variant !== 'default') selections.push(`\`${variant}\``); const status = asNonEmptyString(session?.status?.type); return `- \`${title}\` — ${selections.join(', ')}${status ? ` — status:${status}` : ''} — \`${directory}\``; }; const buildSessionCreatePayload = (options = {}) => { const directory = asNonEmptyString(options.directory); const projectId = asNonEmptyString(options.project); if (!directory && !projectId) { throw new TunnelCliError('Missing required --dir or --project.', EXIT_CODE.USAGE_ERROR); } if (directory && projectId) { throw new TunnelCliError('Provide only one of --dir or --project.', EXIT_CODE.USAGE_ERROR); } const prompt = asNonEmptyString(options.prompt); const model = validateModel(options.model); const goalEnabled = options.goal === true; const goalTokenBudget = parseGoalTokenBudget(options); if (goalEnabled && !prompt) { throw new TunnelCliError('--goal requires --prompt.', EXIT_CODE.USAGE_ERROR); } const title = asNonEmptyString(options.title) || asNonEmptyString(options.name); const agent = asNonEmptyString(options.agent); const variant = asNonEmptyString(options.variant); const worktree = asNonEmptyString(options.worktree); const branch = asNonEmptyString(options.branch); const startRef = asNonEmptyString(options.startRef); return { ...(directory ? { directory } : {}), ...(projectId ? { projectId } : {}), ...(title ? { title } : {}), ...(worktree ? { worktree: { name: worktree, ...(branch ? { branchName: branch } : {}), ...(startRef ? { startRef } : {}) } } : {}), ...(prompt ? { prompt } : {}), ...(model ? { model } : {}), ...(agent ? { agent } : {}), ...(variant ? { variant } : {}), ...(goalEnabled ? { goal: true } : {}), ...(goalTokenBudget !== undefined ? { goalTokenBudget } : {}), ...(typeof options.setUpstream === 'boolean' ? { setUpstream: options.setUpstream } : {}), }; }; const buildSessionPromptPayload = (options = {}, action) => { const { directory } = assertSessionTarget(options); const prompt = asNonEmptyString(options.prompt); if (!prompt) throw new TunnelCliError('Missing required --prompt.', EXIT_CODE.USAGE_ERROR); const model = validateModel(options.model); const agent = asNonEmptyString(options.agent); const variant = asNonEmptyString(options.variant); const messageId = asNonEmptyString(options.message); if (messageId && action !== 'fork') { throw new TunnelCliError('--message is only valid for session fork.', EXIT_CODE.USAGE_ERROR); } const goalEnabled = options.goal === true; const goalTokenBudget = parseGoalTokenBudget(options); return { directory, prompt, ...(messageId ? { messageId } : {}), ...(model ? { model } : {}), ...(agent ? { agent } : {}), ...(variant ? { variant } : {}), ...(goalEnabled ? { goal: true } : {}), ...(goalTokenBudget !== undefined ? { goalTokenBudget } : {}), }; }; const validateActionWaitOptions = (options, action) => { if (options.timeout !== undefined && !options.wait) { throw new TunnelCliError('--timeout requires --wait.', EXIT_CODE.USAGE_ERROR); } if (options.lastAssistant && !options.wait) { throw new TunnelCliError(`--last-assistant requires --wait for session ${action}.`, EXIT_CODE.USAGE_ERROR); } }; async function sessionCommand(options = {}, action = 'help') { if (action === 'help') { process.stdout.write(`OpenChamber Session Commands\n\nUSAGE:\n openchamber session list [--dir ] [--limit ] [--with-status] [OPTIONS]\n openchamber session create --dir [--title ] [--wait] [OPTIONS]\n openchamber session create --project <projectId> [--title <title>] [--wait] [OPTIONS]\n openchamber session send --session <id> --dir <path> --prompt <text> [--wait] [OPTIONS]\n openchamber session fork --session <id> --dir <path> --prompt <text> [--message <id>] [--wait] [OPTIONS]\n openchamber session status --session <id> --dir <path> [OPTIONS]\n openchamber session messages --session <id> --dir <path> [--wait] [OPTIONS]\n\nLIST OPTIONS:\n --dir <path> Filter sessions by directory\n --limit <count> Maximum sessions to show (default: 10)\n --all Include archived sessions\n --with-status Include authoritative idle/busy/retry status\n\nACTION OPTIONS:\n --session <id> Source or target session id\n --dir <path> Authoritative session directory\n --prompt <text> Prompt to send to the session\n --message <id> Fork from this message (fork only; default: latest)\n --model <provider/model> Model for the prompt (defaults to configured selection)\n --agent <id> Agent for the prompt (defaults to configured selection)\n --variant <id> Model variant for the prompt\n --goal Run the prompt as a new goal\n --goal-token-budget <n> Goal token budget (1000-100000000; requires --goal)\n --wait Wait for the dispatched activity to become idle\n --last-assistant Include the last assistant text after waiting\n --timeout <seconds> Wait timeout in seconds (default: 600, max: 86400)\n\nCREATE OPTIONS:\n --worktree <name> Create a git worktree before creating the session\n --branch <name> Branch name for --worktree\n --start-ref, --base <ref> Start ref for --worktree\n --upstream Set upstream for the worktree branch\n --no-upstream Do not set upstream for the worktree branch\n --name <title> Alias for --title\n\nSTATUS/MESSAGES OPTIONS:\n --last Return only the latest text-bearing message\n --last-assistant Shorthand for --last --role assistant\n --limit <count> Maximum text messages to return (default: 10)\n --all Return all text-bearing messages\n --role <role> Filter messages: all, user, assistant\n\nOUTPUT OPTIONS:\n -p, --port <port> OpenChamber server port\n --json Output machine-readable JSON\n -q, --quiet Print compact output\n`); return; } if (action === 'list') { const limit = normalizeLimit(options.limit); const port = await resolveTargetPort(options); const body = await requestControlAction(port, 'session.list', { ...(asNonEmptyString(options.directory) ? { directory: options.directory.trim() } : {}), limit, all: options.all === true, withStatus: options.withStatus === true, }, options); const sessions = Array.isArray(body?.sessions) ? body.sessions : []; if (isJsonMode(options)) { printJson(body); return; } process.stdout.write(sessions.length > 0 ? `${sessions.map(formatSessionLine).join('\n')}\n` : 'No sessions found.\n'); return; } if (action === 'status') { const { sessionId, directory } = assertSessionTarget(options); const port = await resolveTargetPort(options); const result = await requestControlAction(port, 'session.status', { sessionId, directory }, options); const status = result.sessionStatus; if (isJsonMode(options)) { printJson(result); return; } if (isQuietMode(options)) { process.stdout.write(`${status.type}\n`); return; } process.stdout.write(`${sessionId} status:${status.type} directory:${directory}\n`); return; } if (action === 'messages') { const { sessionId, directory } = assertSessionTarget(options); if (options.timeout !== undefined && !options.wait) { throw new TunnelCliError('--timeout requires --wait.', EXIT_CODE.USAGE_ERROR); } if (options.lastAssistant && options.role && options.role !== 'assistant') { throw new TunnelCliError('--last-assistant cannot be combined with a non-assistant --role.', EXIT_CODE.USAGE_ERROR); } const role = options.lastAssistant ? 'assistant' : normalizeMessageRole(options.role); const last = options.last || options.lastAssistant; if (options.all && (last || options.limit !== undefined)) { throw new TunnelCliError('--all cannot be combined with --last or --limit.', EXIT_CODE.USAGE_ERROR); } if (last && options.limit !== undefined) { throw new TunnelCliError('--last cannot be combined with --limit.', EXIT_CODE.USAGE_ERROR); } const limit = options.all ? undefined : (last ? undefined : normalizeLimit(options.limit)); const port = await resolveTargetPort(options); const result = await requestControlAction(port, 'session.messages', { sessionId, directory, role, all: options.all === true, last, ...(limit !== undefined ? { limit } : {}), wait: options.wait === true, ...(options.timeout !== undefined ? { timeout: Number(options.timeout) } : {}), lastAssistant: options.lastAssistant === true, }, options); const messages = Array.isArray(result?.messages) ? result.messages : []; if (isJsonMode(options)) { printJson(result); return; } if (messages.length === 0) { process.stdout.write('No text messages found.\n'); return; } if (isQuietMode(options)) { process.stdout.write(`${messages.map((message) => message.text).join('\n\n')}\n`); return; } process.stdout.write(`${messages.map(formatTextMessage).join('\n\n---\n\n')}\n`); return; } if (action === 'send' || action === 'fork') { const { sessionId } = assertSessionTarget(options); const payload = buildSessionPromptPayload(options, action); validateActionWaitOptions(options, action); const port = await resolveTargetPort(options); const result = await requestControlAction(port, `session.${action}`, { ...payload, sessionId, wait: options.wait === true, ...(options.timeout !== undefined ? { timeout: Number(options.timeout) } : {}), lastAssistant: options.lastAssistant === true, }, options); const { sessionStatus, lastAssistantMessage } = result; if (isJsonMode(options)) { printJson(result); return; } if (isQuietMode(options)) { process.stdout.write(`${result?.sessionId || ''}\n`); if (lastAssistantMessage?.text) process.stdout.write(`${lastAssistantMessage.text}\n`); return; } clackIntro(action === 'fork' ? 'Session Forked' : 'Session Prompt Sent'); logStatus('success', result?.sessionId || `${action} completed`, `directory: ${result?.directory || 'unknown'}`); if (result?.promptDispatched) { logStatus('info', result.dispatchedAsCommand ? 'command dispatched' : 'prompt dispatched'); } if (result?.goalEnabled) { logStatus('info', 'goal mode active', result.goalTokenBudget ? `budget: ${result.goalTokenBudget}` : undefined); } if (sessionStatus) logStatus('info', `session status: ${sessionStatus.type}`); clackOutro(action === 'fork' ? 'forked' : 'sent'); if (lastAssistantMessage) process.stdout.write(`\n${formatTextMessage(lastAssistantMessage)}\n`); return; } if (action !== 'create') { throw new TunnelCliError(`Unknown session command '${action}'.`, EXIT_CODE.USAGE_ERROR); } const payload = buildSessionCreatePayload(options); validateActionWaitOptions(options, 'create'); const port = await resolveTargetPort(options); const controlPayload = { ...payload }; if (payload.worktree) { controlPayload.worktree = payload.worktree.name; controlPayload.branch = payload.worktree.branchName; controlPayload.startRef = payload.worktree.startRef; } const result = await requestControlAction(port, 'session.create', { ...controlPayload, wait: options.wait === true, ...(options.timeout !== undefined ? { timeout: Number(options.timeout) } : {}), lastAssistant: options.lastAssistant === true, }, options); const { sessionStatus, lastAssistantMessage } = result; if (isJsonMode(options)) { printJson(result); return; } if (isQuietMode(options)) { process.stdout.write(`${result?.sessionId || ''}\n`); if (lastAssistantMessage?.text) process.stdout.write(`${lastAssistantMessage.text}\n`); return; } clackIntro('Session Created'); logStatus('success', result?.sessionId || 'session created', `directory: ${result?.directory || 'unknown'}`); if (result?.worktree?.path) { logStatus('info', `worktree: ${result.worktree.branch || result.worktree.name || 'created'}`, result.worktree.path); } if (result?.promptDispatched) { logStatus('info', result.dispatchedAsCommand ? 'initial command dispatched' : 'initial prompt dispatched'); } if (result?.goalEnabled) { logStatus('info', 'goal mode active', result.goalTokenBudget ? `budget: ${result.goalTokenBudget}` : undefined); } if (sessionStatus) { logStatus('info', `session status: ${sessionStatus.type}`); } clackOutro('created'); if (lastAssistantMessage) { process.stdout.write(`\n${formatTextMessage(lastAssistantMessage)}\n`); } } export { sessionCommand, buildSessionCreatePayload, buildSessionPromptPayload, formatSessionLine, };