Files
Bohdan Triapitsyn e908db637b feat: agent and CLI control plane for sessions, worktrees, and scheduled tasks (#2408)
Add a shared OpenChamber control service with two thin adapters — a native
`openchamber` tool injected into managed OpenCode, and new CLI commands — so
users can manage parallel sessions, worktrees, and scheduled tasks
conversationally through agents or from the terminal.

Control plane:
- New openchamber-control service owning a fixed action contract:
  projects.list, models.list, session list/create/send/fork/status/messages,
  and schedule list/create/run/delete/toggle. Session and worktree deletion
  and project registration are deliberately not exposed.
- New openchamber-sessions module owning create/worktree/prompt orchestration,
  Goal Mode dispatch, wait semantics (initial idle never counts as completion;
  timeout and cancellation are failures), and explicit partial-failure results.
- Scheduled-task logic extracted into a service shared by routes, CLI, and the
  agent tool.

Agent tool:
- Managed OpenCode gets a materialized plugin registering one typed tool with
  a loopback-only callback, per-child ephemeral bearer (timing-safe, never
  persisted or logged), and abort propagation into the service.
- The ~1.5k-token schema applies progressive disclosure: short descriptions,
  server-side validation returning actionable usage errors, and intent
  guardrails — created sessions/tasks are user-facing work (not age
  self-delegation); worktree/goal/agent/variant/wait are omit-by-default;
  dispatches produce no completion notification, and later result r
  to session.messages, which now returns the authoritative sessionStatus.
- session.create without a user-named model picks from favorites/re
  send/fork omit the selection and the service reuses the target session's
  last user-message model, agent, and variant before falling back t
- An "Agent control tool" setting (default on, Save + Reload to apply)
  disables plugin injection entirely.

CLI:
- New `openchamber session`, `schedule`, `projects`, and `models` commands
  with automatic instance targeting, --wait/--timeout/--last-assist
  worktree flags, and Goal Mode, preserving interactive, non-TTY, --quiet,
  and --json contracts. The control HTTP timeout derives from the w
  instead of the 4-second default.

UI:
- New built-in "Schedule a Task" starter (/schedule-task) running a
  dialogue that defines a task and offers to create it via the tool after
  explicit confirmation; Craft a Goal and Feature Planning gain the
  handoff offer, and guided starters reserve the question tool for concrete
  option choices. Localized in all 10 locales, migrated into custom
  starter lists, hidden on VS Code.
- Sidebar shows CLI/agent-created sessions live via the control eve
- openchamber tool calls render with per-action titles and metadata.
2026-07-24 21:54:28 +03:00

341 lines
16 KiB
JavaScript

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,
};