Files
openchamber/packages/web/bin/lib/commands-session.js
T

341 lines
16 KiB
JavaScript
Raw Normal View History

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 <path>] [--limit <count>] [--with-status] [OPTIONS]\n openchamber session create --dir <path> [--title <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,
};