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.
This commit is contained in:
committed by
GitHub
parent
484fe8bc18
commit
e908db637b
+53
-2
@@ -22,6 +22,7 @@ import {
|
||||
import {
|
||||
parseArgs,
|
||||
showHelp,
|
||||
showControlHelp,
|
||||
showStartupHelp,
|
||||
showConnectUrlHelp,
|
||||
showTunnelHelp,
|
||||
@@ -33,6 +34,10 @@ import { resolveExplicitBinary, searchPathFor } from './lib/cli-executables.js';
|
||||
import { startupCommand } from './lib/commands-startup.js';
|
||||
import { logsCommand } from './lib/commands-logs.js';
|
||||
import { statusCommand } from './lib/commands-status.js';
|
||||
import { scheduleCommand } from './lib/commands-schedule.js';
|
||||
import { sessionCommand } from './lib/commands-session.js';
|
||||
import { modelsCommand } from './lib/commands-models.js';
|
||||
import { projectsCommand } from './lib/commands-projects.js';
|
||||
import { createUpdateCommand } from './lib/commands-update.js';
|
||||
import { createConnectUrlCommand } from './lib/commands-connect-url.js';
|
||||
import { createLifecycleCommands } from './lib/commands-lifecycle.js';
|
||||
@@ -176,6 +181,13 @@ const commands = {
|
||||
|
||||
status: statusCommand,
|
||||
|
||||
schedule: scheduleCommand,
|
||||
|
||||
session: sessionCommand,
|
||||
|
||||
models: modelsCommand,
|
||||
|
||||
projects: projectsCommand,
|
||||
|
||||
logs: logsCommand,
|
||||
|
||||
@@ -219,7 +231,7 @@ commands.update = createUpdateCommand({
|
||||
|
||||
async function main() {
|
||||
const parsed = parseArgs();
|
||||
const { command, subcommand, tunnelAction, startupAction, options, removedFlagErrors, helpRequested, versionRequested } = parsed;
|
||||
const { command, subcommand, tunnelAction, startupAction, scheduleAction, sessionAction, controlAction, options, removedFlagErrors, helpRequested, versionRequested } = parsed;
|
||||
activeCommandOptions = options;
|
||||
|
||||
if (versionRequested) {
|
||||
@@ -255,6 +267,16 @@ async function main() {
|
||||
showStartupHelp();
|
||||
} else if (command === 'connect-url') {
|
||||
showConnectUrlHelp();
|
||||
} else if (command === 'schedule') {
|
||||
await commands.schedule(options, 'help');
|
||||
} else if (command === 'session') {
|
||||
await commands.session(options, 'help');
|
||||
} else if (command === 'models') {
|
||||
await commands.models(options, 'help');
|
||||
} else if (command === 'projects') {
|
||||
await commands.projects(options, 'help');
|
||||
} else if (command === 'control') {
|
||||
showControlHelp();
|
||||
} else {
|
||||
showHelp();
|
||||
}
|
||||
@@ -271,8 +293,36 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'schedule') {
|
||||
await commands.schedule(options, scheduleAction);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'session') {
|
||||
await commands.session(options, sessionAction);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'models') {
|
||||
await commands.models(options, 'show');
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'projects') {
|
||||
await commands.projects(options, 'list');
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'control') {
|
||||
if (controlAction !== 'help') {
|
||||
throw new TunnelCliError(`Unknown control command '${controlAction}'.`, EXIT_CODE.USAGE_ERROR);
|
||||
}
|
||||
showControlHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!commands[command]) {
|
||||
const knownCommands = ['serve', 'stop', 'restart', 'status', 'tunnel', 'startup', 'logs', 'update'];
|
||||
const knownCommands = ['serve', 'stop', 'restart', 'status', 'schedule', 'session', 'models', 'projects', 'control', 'tunnel', 'startup', 'logs', 'update'];
|
||||
const suggestion = findClosestMatch(command, knownCommands);
|
||||
const hint = suggestion ? ` Did you mean '${suggestion}'?` : '';
|
||||
if (isJsonMode(options)) {
|
||||
@@ -372,6 +422,7 @@ if (isCliExecution) {
|
||||
}
|
||||
|
||||
export {
|
||||
main,
|
||||
commands,
|
||||
parseArgs,
|
||||
assertAuthenticatedNetworkExposure,
|
||||
|
||||
@@ -9,7 +9,18 @@ import { pathToFileURL } from 'url';
|
||||
|
||||
import { isModuleCliExecution, normalizeCliEntryPath } from './cli-entry.js';
|
||||
import { requestJson } from './lib/cli-http.js';
|
||||
import { requestControlAction } from './lib/cli-control.js';
|
||||
import { inspectTunnelAttachability } from './lib/cli-lifecycle.js';
|
||||
import { formatGoal } from './lib/commands-schedule.js';
|
||||
import {
|
||||
buildSessionCreatePayload,
|
||||
buildSessionPromptPayload,
|
||||
formatSessionLine,
|
||||
sessionCommand,
|
||||
} from './lib/commands-session.js';
|
||||
import { formatModelsOutput } from './lib/commands-models.js';
|
||||
import { formatProjectLine } from './lib/commands-projects.js';
|
||||
import { resolveTargetPort } from './lib/cli-api-target.js';
|
||||
import { DEFAULT_TUNNEL_PROVIDER_CAPABILITIES } from './lib/cli-tunnel-capabilities.js';
|
||||
import {
|
||||
TUNNEL_PROVIDER_CLOUDFLARE,
|
||||
@@ -229,6 +240,351 @@ describe('cli args', () => {
|
||||
expect(parsed.options.port).toBe(3002);
|
||||
});
|
||||
|
||||
it('parses schedule commands and options', () => {
|
||||
const parsed = parseArgs([
|
||||
'schedule',
|
||||
'create',
|
||||
'--project',
|
||||
'proj_1',
|
||||
'--name',
|
||||
'Daily review',
|
||||
'--prompt',
|
||||
'Review the repo',
|
||||
'--model',
|
||||
'openai/gpt-5.5',
|
||||
'--daily',
|
||||
'09:30',
|
||||
'--timezone',
|
||||
'Europe/Kyiv',
|
||||
]);
|
||||
|
||||
expect(parsed.command).toBe('schedule');
|
||||
expect(parsed.scheduleAction).toBe('create');
|
||||
expect(parsed.options.project).toBe('proj_1');
|
||||
expect(parsed.options.name).toBe('Daily review');
|
||||
expect(parsed.options.prompt).toBe('Review the repo');
|
||||
expect(parsed.options.model).toBe('openai/gpt-5.5');
|
||||
expect(parsed.options.daily).toBe('09:30');
|
||||
expect(parsed.options.timezone).toBe('Europe/Kyiv');
|
||||
});
|
||||
|
||||
it('parses goal-enabled scheduled task options', () => {
|
||||
const parsed = parseArgs([
|
||||
'schedule',
|
||||
'create',
|
||||
'--dir',
|
||||
'/repo',
|
||||
'--name',
|
||||
'Finish migration',
|
||||
'--prompt',
|
||||
'Complete and verify the migration',
|
||||
'--model',
|
||||
'openai/gpt-5.5',
|
||||
'--daily',
|
||||
'09:30',
|
||||
'--goal',
|
||||
'--goal-token-budget',
|
||||
'200000',
|
||||
]);
|
||||
|
||||
expect(parsed.options.goal).toBe(true);
|
||||
expect(parsed.options.goalTokenBudget).toBe('200000');
|
||||
});
|
||||
|
||||
it('formats scheduled goal state compactly', () => {
|
||||
expect(formatGoal({})).toBe('goal:no');
|
||||
expect(formatGoal({ goalEnabled: true })).toBe('goal:yes');
|
||||
expect(formatGoal({ goalEnabled: true, goalTokenBudget: 200000 })).toBe('goal:yes budget:200000');
|
||||
});
|
||||
|
||||
it('parses session create options', () => {
|
||||
const parsed = parseArgs([
|
||||
'session',
|
||||
'create',
|
||||
'--dir',
|
||||
'.',
|
||||
'--name',
|
||||
'Side task',
|
||||
'--prompt',
|
||||
'Investigate cache invalidation',
|
||||
'--model',
|
||||
'openai/gpt-5.5',
|
||||
'--worktree',
|
||||
'side-task',
|
||||
'--branch',
|
||||
'openchamber/side-task',
|
||||
'--base',
|
||||
'main',
|
||||
'--no-upstream',
|
||||
]);
|
||||
|
||||
expect(parsed.command).toBe('session');
|
||||
expect(parsed.sessionAction).toBe('create');
|
||||
expect(parsed.options.directory).toBe('.');
|
||||
expect(parsed.options.name).toBe('Side task');
|
||||
expect(parsed.options.prompt).toBe('Investigate cache invalidation');
|
||||
expect(parsed.options.model).toBe('openai/gpt-5.5');
|
||||
expect(parsed.options.worktree).toBe('side-task');
|
||||
expect(parsed.options.branch).toBe('openchamber/side-task');
|
||||
expect(parsed.options.startRef).toBe('main');
|
||||
expect(parsed.options.setUpstream).toBe(false);
|
||||
});
|
||||
|
||||
it('parses control help command', () => {
|
||||
const parsed = parseArgs(['control', 'help']);
|
||||
expect(parsed.command).toBe('control');
|
||||
expect(parsed.controlAction).toBe('help');
|
||||
});
|
||||
|
||||
it('builds session create payloads from CLI options', () => {
|
||||
expect(buildSessionCreatePayload({
|
||||
directory: '.',
|
||||
name: 'Side task',
|
||||
prompt: 'Investigate cache invalidation',
|
||||
model: 'openai/gpt-5.5',
|
||||
agent: 'build',
|
||||
worktree: 'side-task',
|
||||
branch: 'openchamber/side-task',
|
||||
startRef: 'main',
|
||||
setUpstream: true,
|
||||
})).toEqual({
|
||||
directory: '.',
|
||||
title: 'Side task',
|
||||
worktree: {
|
||||
name: 'side-task',
|
||||
branchName: 'openchamber/side-task',
|
||||
startRef: 'main',
|
||||
},
|
||||
prompt: 'Investigate cache invalidation',
|
||||
model: 'openai/gpt-5.5',
|
||||
agent: 'build',
|
||||
setUpstream: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows session create prompts without an explicit model', () => {
|
||||
expect(buildSessionCreatePayload({
|
||||
directory: '.',
|
||||
prompt: 'Investigate cache invalidation',
|
||||
})).toEqual({
|
||||
directory: '.',
|
||||
prompt: 'Investigate cache invalidation',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds goal-enabled session create payloads', () => {
|
||||
const parsed = parseArgs([
|
||||
'session',
|
||||
'create',
|
||||
'--dir',
|
||||
'/repo',
|
||||
'--prompt',
|
||||
'Finish and verify the migration',
|
||||
'--goal',
|
||||
'--goal-token-budget',
|
||||
'200000',
|
||||
]);
|
||||
|
||||
expect(buildSessionCreatePayload(parsed.options)).toEqual({
|
||||
directory: '/repo',
|
||||
prompt: 'Finish and verify the migration',
|
||||
goal: true,
|
||||
goalTokenBudget: 200000,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates session goal options before HTTP', () => {
|
||||
expect(() => buildSessionCreatePayload({ directory: '/repo', goal: true })).toThrow('--goal requires --prompt.');
|
||||
expect(() => buildSessionCreatePayload({
|
||||
directory: '/repo',
|
||||
prompt: 'Run',
|
||||
goalTokenBudget: '200000',
|
||||
})).toThrow('--goal-token-budget requires --goal.');
|
||||
for (const value of ['999', '1.5', '100000001', 'nope']) {
|
||||
expect(() => buildSessionCreatePayload({
|
||||
directory: '/repo',
|
||||
prompt: 'Run',
|
||||
goal: true,
|
||||
goalTokenBudget: value,
|
||||
})).toThrow('--goal-token-budget must be an integer from 1000 to 100000000.');
|
||||
}
|
||||
});
|
||||
|
||||
it('parses session list filters', () => {
|
||||
const parsed = parseArgs(['session', 'list', '--dir', '/repo', '--limit', '5']);
|
||||
|
||||
expect(parsed.command).toBe('session');
|
||||
expect(parsed.sessionAction).toBe('list');
|
||||
expect(parsed.options.directory).toBe('/repo');
|
||||
expect(parsed.options.limit).toBe(5);
|
||||
});
|
||||
|
||||
it('parses session status and message options', () => {
|
||||
const status = parseArgs(['session', 'status', '--session', 'ses_123', '--dir', '/repo']);
|
||||
expect(status.sessionAction).toBe('status');
|
||||
expect(status.options.session).toBe('ses_123');
|
||||
expect(status.options.directory).toBe('/repo');
|
||||
|
||||
const messages = parseArgs([
|
||||
'session',
|
||||
'messages',
|
||||
'--session',
|
||||
'ses_123',
|
||||
'--dir',
|
||||
'/repo',
|
||||
'--last',
|
||||
'--role',
|
||||
'assistant',
|
||||
]);
|
||||
expect(messages.sessionAction).toBe('messages');
|
||||
expect(messages.options.last).toBe(true);
|
||||
expect(messages.options.role).toBe('assistant');
|
||||
|
||||
const waiting = parseArgs([
|
||||
'session',
|
||||
'messages',
|
||||
'--session',
|
||||
'ses_123',
|
||||
'--dir',
|
||||
'/repo',
|
||||
'--wait',
|
||||
'--timeout',
|
||||
'30',
|
||||
'--last-assistant',
|
||||
]);
|
||||
expect(waiting.options.wait).toBe(true);
|
||||
expect(waiting.options.timeout).toBe('30');
|
||||
expect(waiting.options.lastAssistant).toBe(true);
|
||||
|
||||
const list = parseArgs(['session', 'list', '--dir', '/repo', '--with-status']);
|
||||
expect(list.options.withStatus).toBe(true);
|
||||
});
|
||||
|
||||
it('parses session send and fork actions', () => {
|
||||
const send = parseArgs([
|
||||
'session', 'send', '--session', 'ses_123', '--dir', '/repo', '--prompt', 'Continue',
|
||||
'--goal', '--wait', '--last-assistant',
|
||||
]);
|
||||
expect(send.sessionAction).toBe('send');
|
||||
expect(send.options).toMatchObject({
|
||||
session: 'ses_123',
|
||||
directory: '/repo',
|
||||
prompt: 'Continue',
|
||||
goal: true,
|
||||
wait: true,
|
||||
lastAssistant: true,
|
||||
});
|
||||
|
||||
const fork = parseArgs([
|
||||
'session', 'fork', '--session', 'ses_123', '--dir', '/repo', '--message', 'msg_123',
|
||||
'--prompt', 'Try another approach',
|
||||
]);
|
||||
expect(fork.sessionAction).toBe('fork');
|
||||
expect(fork.options.message).toBe('msg_123');
|
||||
});
|
||||
|
||||
it('builds session send and fork prompt payloads', () => {
|
||||
expect(buildSessionPromptPayload({
|
||||
session: 'ses_123',
|
||||
directory: '/repo',
|
||||
prompt: 'Continue',
|
||||
model: 'openai/gpt-5.5',
|
||||
agent: 'build',
|
||||
goal: true,
|
||||
goalTokenBudget: '200000',
|
||||
}, 'send')).toEqual({
|
||||
directory: '/repo',
|
||||
prompt: 'Continue',
|
||||
model: 'openai/gpt-5.5',
|
||||
agent: 'build',
|
||||
goal: true,
|
||||
goalTokenBudget: 200000,
|
||||
});
|
||||
expect(buildSessionPromptPayload({
|
||||
session: 'ses_123',
|
||||
directory: '/repo',
|
||||
message: 'msg_123',
|
||||
prompt: 'Try another approach',
|
||||
}, 'fork')).toEqual({
|
||||
directory: '/repo',
|
||||
messageId: 'msg_123',
|
||||
prompt: 'Try another approach',
|
||||
});
|
||||
});
|
||||
|
||||
it('validates session message selectors before HTTP', async () => {
|
||||
await expect(sessionCommand({ session: 'ses_123', directory: '/repo', all: true, last: true }, 'messages'))
|
||||
.rejects.toThrow('--all cannot be combined with --last or --limit.');
|
||||
await expect(sessionCommand({ session: 'ses_123', directory: '/repo', role: 'tool' }, 'messages'))
|
||||
.rejects.toThrow('--role must be one of: all, user, assistant.');
|
||||
await expect(sessionCommand({ session: 'ses_123' }, 'status'))
|
||||
.rejects.toThrow('Missing required --dir.');
|
||||
await expect(sessionCommand({ session: 'ses_123', directory: '/repo', timeout: '30' }, 'messages'))
|
||||
.rejects.toThrow('--timeout requires --wait.');
|
||||
await expect(sessionCommand({ directory: '/repo', lastAssistant: true }, 'create'))
|
||||
.rejects.toThrow('--last-assistant requires --wait for session create.');
|
||||
await expect(sessionCommand({ directory: '/repo', timeout: '30' }, 'create'))
|
||||
.rejects.toThrow('--timeout requires --wait.');
|
||||
await expect(sessionCommand({ session: 'ses_123', directory: '/repo' }, 'send'))
|
||||
.rejects.toThrow('Missing required --prompt.');
|
||||
await expect(sessionCommand({ session: 'ses_123', directory: '/repo', prompt: 'Run', message: 'msg_1' }, 'send'))
|
||||
.rejects.toThrow('--message is only valid for session fork.');
|
||||
await expect(sessionCommand({ session: 'ses_123', directory: '/repo', prompt: 'Run', lastAssistant: true }, 'fork'))
|
||||
.rejects.toThrow('--last-assistant requires --wait for session fork.');
|
||||
});
|
||||
|
||||
it('parses models command', () => {
|
||||
const parsed = parseArgs(['models', '--json']);
|
||||
|
||||
expect(parsed.command).toBe('models');
|
||||
expect(parsed.options.json).toBe(true);
|
||||
});
|
||||
|
||||
it('parses projects command', () => {
|
||||
const parsed = parseArgs(['projects', '--json']);
|
||||
|
||||
expect(parsed.command).toBe('projects');
|
||||
expect(parsed.options.json).toBe(true);
|
||||
});
|
||||
|
||||
it('formats projects compactly', () => {
|
||||
expect(formatProjectLine({
|
||||
id: 'path_repo',
|
||||
label: 'Openchamber',
|
||||
path: '/repo/openchamber',
|
||||
})).toBe('- `Openchamber` — `path_repo` — `/repo/openchamber`');
|
||||
});
|
||||
|
||||
it('formats model defaults and favorites compactly', () => {
|
||||
expect(formatModelsOutput({
|
||||
defaultModel: 'opencode-go/deepseek-v4-flash',
|
||||
defaultAgent: 'build',
|
||||
favoriteModels: [
|
||||
{ providerID: 'openai', modelID: 'gpt-5.5' },
|
||||
{ providerID: 'opencode-go', modelID: 'deepseek-v4-pro' },
|
||||
],
|
||||
recentModels: [
|
||||
{ providerID: 'zai-coding-plan', modelID: 'glm-5.2' },
|
||||
],
|
||||
})).toBe('Default: `opencode-go/deepseek-v4-flash` / `build`\n\nFavorites:\n- `openai/gpt-5.5`\n- `opencode-go/deepseek-v4-pro`\n\nRecent:\n- `zai-coding-plan/glm-5.2`\n');
|
||||
});
|
||||
|
||||
it('formats compact session list lines', () => {
|
||||
expect(formatSessionLine({
|
||||
title: 'Default model smoke test',
|
||||
agent: 'build',
|
||||
directory: '/repo',
|
||||
model: { providerID: 'opencode-go', id: 'deepseek-v4-flash', variant: 'default' },
|
||||
})).toBe('- `Default model smoke test` — `opencode-go/deepseek-v4-flash`, `build` — `/repo`');
|
||||
expect(formatSessionLine({
|
||||
title: 'Working session',
|
||||
agent: 'build',
|
||||
directory: '/repo',
|
||||
model: { providerID: 'openai', id: 'gpt-5.4-mini' },
|
||||
status: { type: 'busy' },
|
||||
})).toContain('status:busy');
|
||||
});
|
||||
|
||||
it('parses tunnel auto-start server options', () => {
|
||||
const parsed = parseArgs(['tunnel', 'start', '--port', '3002', '--api-only', '--lan', '--ui-password', 'secret']);
|
||||
|
||||
@@ -261,6 +617,50 @@ describe('cli args', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('cli API target resolution', () => {
|
||||
it('uses an explicit port without discovery', async () => {
|
||||
await expect(resolveTargetPort(
|
||||
{ explicitPort: true, port: 4567 },
|
||||
{
|
||||
discoverDesktopInstance: async () => { throw new Error('should not discover desktop'); },
|
||||
discoverLifecycleInstances: async () => { throw new Error('should not discover lifecycle'); },
|
||||
},
|
||||
)).resolves.toBe(4567);
|
||||
});
|
||||
|
||||
it('prefers a desktop instance when no port is explicit', async () => {
|
||||
await expect(resolveTargetPort({}, {
|
||||
discoverDesktopInstance: async () => ({ port: 4500 }),
|
||||
discoverLifecycleInstances: async () => [{ port: 3001 }],
|
||||
isServerHealthReady: async () => false,
|
||||
})).resolves.toBe(4500);
|
||||
});
|
||||
|
||||
it('uses the only discovered lifecycle instance', async () => {
|
||||
await expect(resolveTargetPort({}, {
|
||||
discoverDesktopInstance: async () => null,
|
||||
discoverLifecycleInstances: async () => [{ port: 3002 }],
|
||||
isServerHealthReady: async () => false,
|
||||
})).resolves.toBe(3002);
|
||||
});
|
||||
|
||||
it('uses healthy default port when discovery finds no instances', async () => {
|
||||
await expect(resolveTargetPort({}, {
|
||||
discoverDesktopInstance: async () => null,
|
||||
discoverLifecycleInstances: async () => [],
|
||||
isServerHealthReady: async (port) => port === 3000,
|
||||
})).resolves.toBe(3000);
|
||||
});
|
||||
|
||||
it('fails when multiple non-default instances are running', async () => {
|
||||
await expect(resolveTargetPort({}, {
|
||||
discoverDesktopInstance: async () => null,
|
||||
discoverLifecycleInstances: async () => [{ port: 3001 }, { port: 3002 }],
|
||||
isServerHealthReady: async () => false,
|
||||
})).rejects.toThrow('Multiple OpenChamber instances are running');
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-exposed auth validation', () => {
|
||||
it('allows loopback without a UI password', () => {
|
||||
expect(() => assertAuthenticatedNetworkExposure({ host: '127.0.0.1' })).not.toThrow();
|
||||
@@ -333,8 +733,9 @@ describe('compatibility exports', () => {
|
||||
|
||||
it('includes ngrok in fallback tunnel providers when no server is reachable', async () => {
|
||||
await withTempOpenChamberDataDir(async () => {
|
||||
const port = await allocateLoopbackPort();
|
||||
const output = await captureStdout(async () => {
|
||||
await commands.tunnel({ json: true }, 'providers');
|
||||
await commands.tunnel({ json: true, explicitPort: true, port }, 'providers');
|
||||
});
|
||||
|
||||
const body = JSON.parse(output);
|
||||
@@ -368,6 +769,27 @@ describe('compatibility exports', () => {
|
||||
});
|
||||
|
||||
describe('CLI HTTP helpers', () => {
|
||||
it('sends one typed request to the shared control endpoint', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
expect(new URL(String(url)).pathname).toBe('/api/openchamber/control');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({
|
||||
action: 'session.status',
|
||||
input: { sessionId: 'ses_1', directory: '/repo' },
|
||||
});
|
||||
return createMockJsonResponse({ status: 'ok', sessionStatus: { type: 'idle' } });
|
||||
};
|
||||
try {
|
||||
await expect(requestControlAction(45677, 'session.status', {
|
||||
sessionId: 'ses_1',
|
||||
directory: '/repo',
|
||||
})).resolves.toEqual({ status: 'ok', sessionStatus: { type: 'idle' } });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('retries UI-authenticated API requests with the stored instance password', async () => {
|
||||
await withTempOpenChamberDataDir(async () => {
|
||||
const port = 45678;
|
||||
@@ -412,6 +834,74 @@ describe('CLI HTTP helpers', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the stored instance password over a non-explicit env password', async () => {
|
||||
await withTempOpenChamberDataDir(async () => {
|
||||
const port = 45679;
|
||||
fs.writeFileSync(await getInstanceFilePath(port), JSON.stringify({ port, uiPassword: 'stored-secret' }, null, 2));
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
if (String(url).endsWith('/auth/session')) {
|
||||
expect(JSON.parse(options.body)).toEqual({ password: 'stored-secret' });
|
||||
return {
|
||||
ok: true,
|
||||
headers: { getSetCookie: () => ['oc_ui_session=session-token; Path=/; HttpOnly'] },
|
||||
json: async () => ({ authenticated: true }),
|
||||
};
|
||||
}
|
||||
if (options.headers?.Cookie === 'oc_ui_session=session-token') {
|
||||
return createMockJsonResponse({ ok: true });
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: 'UI authentication required', locked: true }),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const { response, body } = await requestJson(port, '/api/openchamber/scheduled-tasks/status', {
|
||||
uiPassword: 'stale-env-secret',
|
||||
explicitUiPassword: false,
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(body).toEqual({ ok: true });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('authenticates desktop-local API requests with the stored client token', async () => {
|
||||
await withTempOpenChamberDataDir(async (dir) => {
|
||||
const port = 57123;
|
||||
fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({
|
||||
desktopLocalPort: port,
|
||||
desktopLocalClientToken: 'oc_client_test',
|
||||
}, null, 2));
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (_url, options = {}) => {
|
||||
if (options.headers?.Authorization === 'Bearer oc_client_test') {
|
||||
return createMockJsonResponse({ ok: true });
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: 'Client authentication required', locked: true }),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const { response, body } = await requestJson(port, '/api/openchamber/scheduled-tasks/status');
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(body).toEqual({ ok: true });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cli entry detection', () => {
|
||||
|
||||
@@ -25,6 +25,22 @@ Command modules implement user-facing commands and preserve output contracts acr
|
||||
- Implements `openchamber status`.
|
||||
- Formats discovered instances and tunnel readiness/status for human, quiet, and JSON output.
|
||||
|
||||
- `commands-session.js`
|
||||
- Implements `openchamber session create`, `send`, `fork`, `list`, `status`, and `messages`.
|
||||
- Maps CLI options to shared control-service inputs and owns only human, quiet, and JSON presentation.
|
||||
- Message projection matches Export Markdown semantics: only ordered `text` parts are exposed; tool, reasoning, file, and other parts are omitted.
|
||||
- The server control service owns create/worktree/prompt orchestration, official OpenCode reads, Goal Mode, wait semantics, and partial failures.
|
||||
|
||||
- `commands-schedule.js`
|
||||
- Implements scheduled task status/list/create/run/delete/enable/disable.
|
||||
- Maps options to control-service inputs and renders results; project resolution, validation, persistence, and execution remain server-owned.
|
||||
|
||||
- `commands-models.js`
|
||||
- Prints OpenChamber default, favorite, and recent model settings.
|
||||
|
||||
- `commands-projects.js`
|
||||
- Prints configured project labels, ids, and directories for later control-plane calls.
|
||||
|
||||
- `commands-logs.js`
|
||||
- Implements `openchamber logs`.
|
||||
- Resolves log files, tails recent lines, and follows log output.
|
||||
@@ -70,6 +86,17 @@ These modules hold reusable, non-presentational logic for commands.
|
||||
|
||||
- `cli-http.js`
|
||||
- HTTP helpers for health checks, shutdown requests, JSON API calls, tunnel provider fetches, and system info fetches.
|
||||
- Owns local desktop bearer auth and managed CLI-instance UI password retry for control-plane requests.
|
||||
|
||||
- `cli-control.js`
|
||||
- Sends one typed action request to the authenticated OpenChamber control endpoint and maps HTTP failures to CLI exit behavior.
|
||||
- Must not reproduce session, scheduled-task, project-resolution, or wait orchestration.
|
||||
|
||||
- `cli-api-target.js`
|
||||
- Resolves the target OpenChamber runtime for control-plane commands, preferring desktop unless a port is explicit.
|
||||
|
||||
- `cli-goal.js`
|
||||
- Owns shared Goal Mode token-budget validation for session and schedule commands.
|
||||
|
||||
- `cli-network.js`
|
||||
- Host resolution, URL building, LAN detection, unsafe browser port validation, and UI password/network exposure checks.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { DEFAULT_PORT } from './cli-args.js';
|
||||
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
|
||||
import { discoverDesktopInstance, discoverLifecycleInstances } from './cli-lifecycle.js';
|
||||
import { isServerHealthReady } from './cli-http.js';
|
||||
|
||||
const uniquePorts = (entries) => {
|
||||
const seen = new Set();
|
||||
const ports = [];
|
||||
for (const entry of entries) {
|
||||
const port = typeof entry === 'number' ? entry : entry?.port;
|
||||
if (!Number.isFinite(port) || port <= 0 || seen.has(port)) continue;
|
||||
seen.add(port);
|
||||
ports.push(port);
|
||||
}
|
||||
return ports;
|
||||
};
|
||||
|
||||
async function resolveTargetPort(options = {}, deps = {}) {
|
||||
if (options.explicitPort && Number.isFinite(options.port) && options.port > 0) {
|
||||
return options.port;
|
||||
}
|
||||
|
||||
const discoverDesktop = typeof deps.discoverDesktopInstance === 'function'
|
||||
? deps.discoverDesktopInstance
|
||||
: discoverDesktopInstance;
|
||||
const discoverLifecycle = typeof deps.discoverLifecycleInstances === 'function'
|
||||
? deps.discoverLifecycleInstances
|
||||
: discoverLifecycleInstances;
|
||||
const isHealthy = typeof deps.isServerHealthReady === 'function'
|
||||
? deps.isServerHealthReady
|
||||
: isServerHealthReady;
|
||||
|
||||
const [desktopInstance, lifecycleInstances] = await Promise.all([
|
||||
discoverDesktop(),
|
||||
discoverLifecycle(options),
|
||||
]);
|
||||
|
||||
if (desktopInstance?.port) {
|
||||
return desktopInstance.port;
|
||||
}
|
||||
|
||||
const lifecycleDesktop = Array.isArray(lifecycleInstances)
|
||||
? lifecycleInstances.find((entry) => entry?.runtime === 'desktop' && Number.isFinite(entry.port) && entry.port > 0)
|
||||
: null;
|
||||
if (lifecycleDesktop?.port) {
|
||||
return lifecycleDesktop.port;
|
||||
}
|
||||
|
||||
const ports = uniquePorts(Array.isArray(lifecycleInstances) ? lifecycleInstances : []);
|
||||
if (ports.length === 1) {
|
||||
return ports[0];
|
||||
}
|
||||
|
||||
if (ports.length > 1) {
|
||||
if (ports.includes(DEFAULT_PORT) && await isHealthy(DEFAULT_PORT, 1200)) {
|
||||
return DEFAULT_PORT;
|
||||
}
|
||||
throw new TunnelCliError(
|
||||
`Multiple OpenChamber instances are running (ports: ${ports.join(', ')}). Choose one with --port <port>.`,
|
||||
EXIT_CODE.USAGE_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
if (await isHealthy(DEFAULT_PORT, 1200)) {
|
||||
return DEFAULT_PORT;
|
||||
}
|
||||
|
||||
throw new TunnelCliError(
|
||||
'No running OpenChamber server found. Start one with `openchamber serve`, or pass --port <port>.',
|
||||
EXIT_CODE.GENERAL_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
export { resolveTargetPort };
|
||||
@@ -63,10 +63,12 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
all: false,
|
||||
follow: true,
|
||||
lines: DEFAULT_TAIL_LINES,
|
||||
limit: undefined,
|
||||
provider: undefined,
|
||||
mode: undefined,
|
||||
profile: undefined,
|
||||
name: undefined,
|
||||
title: undefined,
|
||||
configPath: undefined,
|
||||
token: undefined,
|
||||
tokenFile: undefined,
|
||||
@@ -88,6 +90,30 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
foreground: false,
|
||||
lan: false,
|
||||
apiOnly: false,
|
||||
project: undefined,
|
||||
task: undefined,
|
||||
session: undefined,
|
||||
message: undefined,
|
||||
prompt: undefined,
|
||||
model: undefined,
|
||||
daily: undefined,
|
||||
weekly: undefined,
|
||||
once: undefined,
|
||||
time: undefined,
|
||||
cron: undefined,
|
||||
timezone: undefined,
|
||||
agent: undefined,
|
||||
variant: undefined,
|
||||
disabled: false,
|
||||
goal: false,
|
||||
goalTokenBudget: undefined,
|
||||
directory: undefined,
|
||||
role: undefined,
|
||||
last: false,
|
||||
wait: false,
|
||||
timeout: undefined,
|
||||
lastAssistant: false,
|
||||
withStatus: false,
|
||||
};
|
||||
|
||||
const removedFlagErrors = [];
|
||||
@@ -194,6 +220,140 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
options.name = typeof value === 'string' ? value : options.name;
|
||||
break;
|
||||
}
|
||||
case 'title': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.title = typeof value === 'string' ? value : options.title;
|
||||
break;
|
||||
}
|
||||
case 'worktree': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.worktree = typeof value === 'string' ? value : options.worktree;
|
||||
break;
|
||||
}
|
||||
case 'branch': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.branch = typeof value === 'string' ? value : options.branch;
|
||||
break;
|
||||
}
|
||||
case 'start-ref':
|
||||
case 'base': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.startRef = typeof value === 'string' ? value : options.startRef;
|
||||
break;
|
||||
}
|
||||
case 'upstream':
|
||||
options.setUpstream = true;
|
||||
break;
|
||||
case 'no-upstream':
|
||||
options.setUpstream = false;
|
||||
break;
|
||||
case 'project': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.project = typeof value === 'string' ? value : options.project;
|
||||
break;
|
||||
}
|
||||
case 'dir':
|
||||
case 'directory': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.directory = typeof value === 'string' ? value : options.directory;
|
||||
break;
|
||||
}
|
||||
case 'task': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.task = typeof value === 'string' ? value : options.task;
|
||||
break;
|
||||
}
|
||||
case 'session': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.session = typeof value === 'string' ? value : options.session;
|
||||
break;
|
||||
}
|
||||
case 'message': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.message = typeof value === 'string' ? value : options.message;
|
||||
break;
|
||||
}
|
||||
case 'prompt': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.prompt = typeof value === 'string' ? value : options.prompt;
|
||||
break;
|
||||
}
|
||||
case 'model': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.model = typeof value === 'string' ? value : options.model;
|
||||
break;
|
||||
}
|
||||
case 'daily': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.daily = typeof value === 'string' ? value : options.daily;
|
||||
break;
|
||||
}
|
||||
case 'weekly': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.weekly = typeof value === 'string' ? value : options.weekly;
|
||||
break;
|
||||
}
|
||||
case 'once': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.once = typeof value === 'string' ? value : options.once;
|
||||
break;
|
||||
}
|
||||
case 'time': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.time = typeof value === 'string' ? value : options.time;
|
||||
break;
|
||||
}
|
||||
case 'cron': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.cron = typeof value === 'string' ? value : options.cron;
|
||||
break;
|
||||
}
|
||||
case 'timezone': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.timezone = typeof value === 'string' ? value : options.timezone;
|
||||
break;
|
||||
}
|
||||
case 'agent': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.agent = typeof value === 'string' ? value : options.agent;
|
||||
break;
|
||||
}
|
||||
case 'variant': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.variant = typeof value === 'string' ? value : options.variant;
|
||||
break;
|
||||
}
|
||||
case 'disabled':
|
||||
options.disabled = true;
|
||||
break;
|
||||
case 'goal':
|
||||
options.goal = true;
|
||||
break;
|
||||
case 'goal-token-budget': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.goalTokenBudget = value;
|
||||
break;
|
||||
}
|
||||
case 'config': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
@@ -249,6 +409,30 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
case 'all':
|
||||
options.all = true;
|
||||
break;
|
||||
case 'last':
|
||||
options.last = true;
|
||||
break;
|
||||
case 'last-assistant':
|
||||
options.lastAssistant = true;
|
||||
break;
|
||||
case 'wait':
|
||||
options.wait = true;
|
||||
break;
|
||||
case 'timeout': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.timeout = typeof value === 'string' ? value : options.timeout;
|
||||
break;
|
||||
}
|
||||
case 'with-status':
|
||||
options.withStatus = true;
|
||||
break;
|
||||
case 'role': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.role = typeof value === 'string' ? value : options.role;
|
||||
break;
|
||||
}
|
||||
case 'no-follow':
|
||||
options.follow = false;
|
||||
break;
|
||||
@@ -264,6 +448,16 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'limit': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
const parsed = parseInt(value ?? '', 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
throw new TunnelCliError('Invalid limit value. Provide a positive integer.', EXIT_CODE.USAGE_ERROR);
|
||||
}
|
||||
options.limit = parsed;
|
||||
break;
|
||||
}
|
||||
case 'relay':
|
||||
options.relay = true;
|
||||
break;
|
||||
@@ -342,6 +536,9 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
const subcommand = command === 'tunnel' ? (positional[1] || 'help') : null;
|
||||
const tunnelAction = command === 'tunnel' ? (positional[2] || null) : null;
|
||||
const startupAction = command === 'startup' ? (positional[1] || 'status') : null;
|
||||
const scheduleAction = command === 'schedule' ? (positional[1] || 'help') : null;
|
||||
const sessionAction = command === 'session' ? (positional[1] || 'help') : null;
|
||||
const controlAction = command === 'control' ? (positional[1] || 'help') : null;
|
||||
|
||||
if (options.lan && typeof options.host !== 'string') {
|
||||
options.host = '0.0.0.0';
|
||||
@@ -356,6 +553,9 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
subcommand,
|
||||
tunnelAction,
|
||||
startupAction,
|
||||
scheduleAction,
|
||||
sessionAction,
|
||||
controlAction,
|
||||
options,
|
||||
removedFlagErrors,
|
||||
helpRequested,
|
||||
@@ -375,6 +575,11 @@ COMMANDS:
|
||||
stop Stop running instance(s)
|
||||
restart Stop and start the server
|
||||
status Show server status
|
||||
schedule Manage scheduled tasks
|
||||
session Create, inspect, and read OpenChamber sessions
|
||||
models Show default and favorite models
|
||||
projects Show configured projects and IDs
|
||||
control Show OpenChamber control-plane commands
|
||||
tunnel Tunnel lifecycle commands
|
||||
startup Manage launch at system startup
|
||||
logs Tail OpenChamber logs
|
||||
@@ -412,12 +617,52 @@ EXAMPLES:
|
||||
openchamber serve --foreground # Start in foreground (for systemd Type=simple)
|
||||
openchamber connect-url --port 3000 --qr
|
||||
openchamber connect-url --server https://openchamber.example.com
|
||||
openchamber control # Show control-plane commands for agents/scripts
|
||||
openchamber startup enable # Start OpenChamber at user login
|
||||
openchamber tunnel help # Show tunnel lifecycle help
|
||||
openchamber logs # Follow logs for latest running instance
|
||||
`);
|
||||
}
|
||||
|
||||
function showControlHelp() {
|
||||
console.log(`
|
||||
OpenChamber Control Commands
|
||||
|
||||
USAGE:
|
||||
openchamber <COMMAND> [OPTIONS]
|
||||
|
||||
COMMANDS:
|
||||
status Show running OpenChamber runtimes
|
||||
session Create, inspect, and read sessions
|
||||
models Show default and favorite models
|
||||
projects Show configured projects and IDs
|
||||
schedule Manage scheduled tasks
|
||||
tunnel Inspect tunnel status/readiness
|
||||
logs Tail logs for CLI-managed runtimes
|
||||
|
||||
DETAILED HELP:
|
||||
openchamber session --help Show session creation, status, and message options
|
||||
openchamber models --help Show model defaults and favorites help
|
||||
openchamber projects --help Show project list help
|
||||
openchamber schedule --help Show scheduled task actions and schedule options
|
||||
openchamber tunnel help Show tunnel lifecycle/status commands
|
||||
openchamber status --help Show runtime status options
|
||||
|
||||
COMMON OPTIONS:
|
||||
--json Output machine-readable JSON
|
||||
-q, --quiet Print minimal output
|
||||
-p, --port <port> Target a specific OpenChamber runtime
|
||||
--ui-password <password> Authenticate to a password-protected runtime
|
||||
|
||||
EXAMPLES:
|
||||
openchamber status
|
||||
openchamber models
|
||||
openchamber projects
|
||||
openchamber session --help
|
||||
openchamber schedule --help
|
||||
`);
|
||||
}
|
||||
|
||||
function showStartupHelp() {
|
||||
console.log(`
|
||||
OpenChamber Startup Commands
|
||||
@@ -579,7 +824,7 @@ _openchamber_tunnel() {
|
||||
cur="\${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
commands="serve stop restart status tunnel logs update"
|
||||
commands="serve stop restart status schedule session models projects tunnel logs update"
|
||||
tunnel_commands="help providers ready doctor status start stop profile completion"
|
||||
profile_commands="list show add remove"
|
||||
common_flags="--port --foreground --no-daemon --json --all --help --version --plain --quiet"
|
||||
@@ -631,6 +876,10 @@ _openchamber() {
|
||||
'stop:Stop running instance(s)'
|
||||
'restart:Stop and start the server'
|
||||
'status:Show server status'
|
||||
'schedule:Manage scheduled tasks'
|
||||
'session:Create sessions'
|
||||
'models:Show default and favorite models'
|
||||
'projects:Show configured projects and IDs'
|
||||
'tunnel:Tunnel lifecycle commands'
|
||||
'logs:Tail OpenChamber logs'
|
||||
'update:Check for and install updates'
|
||||
@@ -728,6 +977,7 @@ export {
|
||||
DEFAULT_PORT,
|
||||
parseArgs,
|
||||
showHelp,
|
||||
showControlHelp,
|
||||
showStartupHelp,
|
||||
showConnectUrlHelp,
|
||||
showTunnelHelp,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { TunnelCliError, EXIT_CODE } from './cli-errors.js';
|
||||
import { requestJson } from './cli-http.js';
|
||||
|
||||
const asNonEmptyString = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const DEFAULT_WAIT_TIMEOUT_SECONDS = 600;
|
||||
const WAIT_HTTP_TIMEOUT_BUFFER_MS = 30_000;
|
||||
|
||||
// The control service blocks server-side while wait is set, so the client
|
||||
// HTTP timeout must outlive the requested wait window instead of the short
|
||||
// default used for instant control calls.
|
||||
export const resolveControlTimeoutMs = (input, options) => {
|
||||
if (Number.isFinite(options?.timeoutMs) && options.timeoutMs > 0) return options.timeoutMs;
|
||||
if (input?.wait !== true) return undefined;
|
||||
const waitSeconds = Number(input?.timeout) > 0 ? Number(input.timeout) : DEFAULT_WAIT_TIMEOUT_SECONDS;
|
||||
return (waitSeconds * 1000) + WAIT_HTTP_TIMEOUT_BUFFER_MS;
|
||||
};
|
||||
|
||||
export const requestControlAction = async (port, action, input, options = {}) => {
|
||||
const timeoutMs = resolveControlTimeoutMs(input, options);
|
||||
const { response, body } = await requestJson(port, '/api/openchamber/control', {
|
||||
...options,
|
||||
...(timeoutMs ? { timeoutMs } : {}),
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, input }),
|
||||
});
|
||||
if (response?.ok) return body;
|
||||
const isPartial = body?.partial === true;
|
||||
const partialSessionId = isPartial ? asNonEmptyString(body?.sessionId) : null;
|
||||
const partialDirectory = isPartial ? asNonEmptyString(body?.directory) : null;
|
||||
const partialSubject = body?.partialAction === 'goal-configured' ? 'Goal on session' : 'Forked session';
|
||||
const partial = partialSessionId
|
||||
? ` ${partialSubject} ${partialSessionId} remains available${partialDirectory ? ` in ${partialDirectory}` : ''}.`
|
||||
: '';
|
||||
const message = `${asNonEmptyString(body?.error) || `Failed to execute ${action}`}${partial}`;
|
||||
const status = Number(response?.status);
|
||||
throw new TunnelCliError(message, status === 400 || status === 404 ? EXIT_CODE.USAGE_ERROR : EXIT_CODE.GENERAL_ERROR);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveControlTimeoutMs } from './cli-control.js';
|
||||
|
||||
describe('resolveControlTimeoutMs', () => {
|
||||
it('keeps the short default HTTP timeout for instant control calls', () => {
|
||||
expect(resolveControlTimeoutMs({}, {})).toBeUndefined();
|
||||
expect(resolveControlTimeoutMs({ wait: false, timeout: 30 }, {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('outlives the default server wait window when wait is set', () => {
|
||||
expect(resolveControlTimeoutMs({ wait: true }, {})).toBe(630_000);
|
||||
});
|
||||
|
||||
it('derives the HTTP timeout from an explicit wait timeout in seconds', () => {
|
||||
expect(resolveControlTimeoutMs({ wait: true, timeout: 30 }, {})).toBe(60_000);
|
||||
});
|
||||
|
||||
it('never shrinks an explicitly requested HTTP timeout', () => {
|
||||
expect(resolveControlTimeoutMs({ wait: true, timeout: 30 }, { timeoutMs: 5000 })).toBe(5000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { TunnelCliError, EXIT_CODE } from './cli-errors.js';
|
||||
|
||||
export const parseGoalTokenBudget = (options) => {
|
||||
if (options.goalTokenBudget === undefined) return undefined;
|
||||
if (options.goal !== true) {
|
||||
throw new TunnelCliError('--goal-token-budget requires --goal.', EXIT_CODE.USAGE_ERROR);
|
||||
}
|
||||
const raw = String(options.goalTokenBudget).trim();
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
throw new TunnelCliError('--goal-token-budget must be an integer from 1000 to 100000000.', EXIT_CODE.USAGE_ERROR);
|
||||
}
|
||||
const budget = Number(raw);
|
||||
if (!Number.isSafeInteger(budget) || budget < 1000 || budget > 100000000) {
|
||||
throw new TunnelCliError('--goal-token-budget must be an integer from 1000 to 100000000.', EXIT_CODE.USAGE_ERROR);
|
||||
}
|
||||
return budget;
|
||||
};
|
||||
@@ -1,24 +1,44 @@
|
||||
import { buildLocalUrl } from './cli-network.js';
|
||||
import { readDesktopLocalClientTokenFromSettings, readDesktopLocalPortFromSettings } from './cli-paths.js';
|
||||
import { getInstanceFilePath, readInstanceOptions } from './cli-process.js';
|
||||
|
||||
const UI_SESSION_COOKIE_NAME = 'oc_ui_session';
|
||||
|
||||
function extractUiSessionCookie(response) {
|
||||
const setCookie = response?.headers?.get?.('set-cookie');
|
||||
if (typeof setCookie !== 'string' || setCookie.length === 0) {
|
||||
return null;
|
||||
const values = [];
|
||||
const direct = response?.headers?.get?.('set-cookie');
|
||||
if (typeof direct === 'string' && direct.length > 0) {
|
||||
values.push(direct);
|
||||
}
|
||||
const match = setCookie.match(new RegExp(`(?:^|,\\s*)(${UI_SESSION_COOKIE_NAME}=[^;]+)`));
|
||||
return match?.[1] || null;
|
||||
const getSetCookie = response?.headers?.getSetCookie;
|
||||
if (typeof getSetCookie === 'function') {
|
||||
const setCookies = getSetCookie.call(response.headers);
|
||||
if (Array.isArray(setCookies)) {
|
||||
values.push(...setCookies.filter((value) => typeof value === 'string' && value.length > 0));
|
||||
}
|
||||
}
|
||||
const raw = response?.headers?.raw?.();
|
||||
if (Array.isArray(raw?.['set-cookie'])) {
|
||||
values.push(...raw['set-cookie'].filter((value) => typeof value === 'string' && value.length > 0));
|
||||
}
|
||||
|
||||
for (const setCookie of values) {
|
||||
const match = setCookie.match(new RegExp(`(?:^|,\\s*)(${UI_SESSION_COOKIE_NAME}=[^;]+)`));
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveUiPasswordForPort(port, options = {}) {
|
||||
if (typeof options.uiPassword === 'string' && options.uiPassword.trim().length > 0) {
|
||||
if (options.explicitUiPassword && typeof options.uiPassword === 'string' && options.uiPassword.trim().length > 0) {
|
||||
return options.uiPassword;
|
||||
}
|
||||
const instanceOptions = readInstanceOptions(await getInstanceFilePath(port));
|
||||
return typeof instanceOptions?.uiPassword === 'string' && instanceOptions.uiPassword.trim().length > 0
|
||||
? instanceOptions.uiPassword
|
||||
if (typeof instanceOptions?.uiPassword === 'string' && instanceOptions.uiPassword.trim().length > 0) {
|
||||
return instanceOptions.uiPassword;
|
||||
}
|
||||
return typeof options.uiPassword === 'string' && options.uiPassword.trim().length > 0
|
||||
? options.uiPassword
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -49,6 +69,18 @@ async function createUiSessionCookie(port, password, timeoutMs) {
|
||||
}
|
||||
}
|
||||
|
||||
function getDesktopLocalAuthHeader(port, requestHeaders) {
|
||||
if (requestHeaders.Authorization || requestHeaders.authorization) {
|
||||
return null;
|
||||
}
|
||||
const desktopPort = readDesktopLocalPortFromSettings();
|
||||
if (desktopPort !== port) {
|
||||
return null;
|
||||
}
|
||||
const token = readDesktopLocalClientTokenFromSettings();
|
||||
return token ? `Bearer ${token}` : null;
|
||||
}
|
||||
|
||||
async function requestServerShutdown(port, hostOverride) {
|
||||
if (!Number.isFinite(port) || port <= 0) return false;
|
||||
const controller = new AbortController();
|
||||
@@ -83,6 +115,10 @@ async function requestJson(port, endpoint, options = {}) {
|
||||
...(fetchOptions.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(fetchOptions.headers || {}),
|
||||
};
|
||||
const desktopAuth = getDesktopLocalAuthHeader(port, requestHeaders);
|
||||
if (desktopAuth) {
|
||||
requestHeaders.Authorization = desktopAuth;
|
||||
}
|
||||
const response = await fetch(requestUrl, {
|
||||
...fetchOptions,
|
||||
headers: requestHeaders,
|
||||
|
||||
@@ -35,6 +35,17 @@ function readDesktopLocalPortFromSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
function readDesktopLocalClientTokenFromSettings() {
|
||||
try {
|
||||
const raw = fs.readFileSync(getSettingsFilePath(), 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const value = parsed?.desktopLocalClientToken;
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLogsDir() {
|
||||
fs.mkdirSync(getLogsDir(), { recursive: true });
|
||||
}
|
||||
@@ -103,6 +114,7 @@ function getRunDir() {
|
||||
export {
|
||||
getDataDir,
|
||||
readDesktopLocalPortFromSettings,
|
||||
readDesktopLocalClientTokenFromSettings,
|
||||
ensureLogsDir,
|
||||
getLogFilePath,
|
||||
getTunnelProfilesFilePath,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { TunnelCliError, EXIT_CODE } from './cli-errors.js';
|
||||
import { resolveTargetPort } from './cli-api-target.js';
|
||||
import { requestControlAction } from './cli-control.js';
|
||||
import { isJsonMode, printJson } from '../cli-output.js';
|
||||
|
||||
const asNonEmptyString = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const formatModelRef = (entry) => {
|
||||
const providerID = asNonEmptyString(entry?.providerID) || asNonEmptyString(entry?.providerId);
|
||||
const modelID = asNonEmptyString(entry?.modelID) || asNonEmptyString(entry?.modelId) || asNonEmptyString(entry?.id);
|
||||
return providerID && modelID ? `${providerID}/${modelID}` : null;
|
||||
};
|
||||
|
||||
const formatDefaultLine = ({ defaultModel, defaultAgent, defaultVariant }) => {
|
||||
const model = asNonEmptyString(defaultModel) || 'none';
|
||||
const agent = asNonEmptyString(defaultAgent) || 'none';
|
||||
const variant = asNonEmptyString(defaultVariant);
|
||||
return `Default: \`${model}${variant ? ` (${variant})` : ''}\` / \`${agent}\``;
|
||||
};
|
||||
|
||||
const formatModelsOutput = (settings = {}) => {
|
||||
const favorites = Array.isArray(settings.favoriteModels)
|
||||
? settings.favoriteModels.map(formatModelRef).filter(Boolean)
|
||||
: [];
|
||||
const recent = Array.isArray(settings.recentModels)
|
||||
? settings.recentModels.map(formatModelRef).filter(Boolean)
|
||||
: [];
|
||||
const lines = [
|
||||
formatDefaultLine(settings),
|
||||
'',
|
||||
'Favorites:',
|
||||
...(favorites.length > 0 ? favorites.map((model) => `- \`${model}\``) : ['- none']),
|
||||
'',
|
||||
'Recent:',
|
||||
...(recent.length > 0 ? recent.map((model) => `- \`${model}\``) : ['- none']),
|
||||
];
|
||||
return `${lines.join('\n')}\n`;
|
||||
};
|
||||
|
||||
async function modelsCommand(options = {}, action = 'show') {
|
||||
if (action === 'help') {
|
||||
process.stdout.write(`OpenChamber Models Commands\n\nUSAGE:\n openchamber models [OPTIONS]\n\nOUTPUT OPTIONS:\n -p, --port <port> OpenChamber server port\n --json Output machine-readable JSON\n`);
|
||||
return;
|
||||
}
|
||||
if (action !== 'show') {
|
||||
throw new TunnelCliError(`Unknown models command '${action}'.`, EXIT_CODE.USAGE_ERROR);
|
||||
}
|
||||
|
||||
const port = await resolveTargetPort(options);
|
||||
const result = await requestControlAction(port, 'models.list', {}, options);
|
||||
|
||||
if (isJsonMode(options)) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(formatModelsOutput(result));
|
||||
}
|
||||
|
||||
export { modelsCommand, formatModelsOutput, formatDefaultLine, formatModelRef };
|
||||
@@ -0,0 +1,28 @@
|
||||
import { TunnelCliError, EXIT_CODE } from './cli-errors.js';
|
||||
import { resolveTargetPort } from './cli-api-target.js';
|
||||
import { requestControlAction } from './cli-control.js';
|
||||
import { isJsonMode, printJson } from '../cli-output.js';
|
||||
|
||||
const formatProjectLine = (project) => `- \`${project.label}\` — \`${project.id}\` — \`${project.path}\``;
|
||||
|
||||
async function projectsCommand(options = {}, action = 'list') {
|
||||
if (action === 'help') {
|
||||
process.stdout.write(`OpenChamber Projects Commands\n\nUSAGE:\n openchamber projects [OPTIONS]\n\nOUTPUT OPTIONS:\n -p, --port <port> OpenChamber server port\n --json Output machine-readable JSON\n`);
|
||||
return;
|
||||
}
|
||||
if (action !== 'list') {
|
||||
throw new TunnelCliError(`Unknown projects command '${action}'.`, EXIT_CODE.USAGE_ERROR);
|
||||
}
|
||||
|
||||
const port = await resolveTargetPort(options);
|
||||
const { projects = [] } = await requestControlAction(port, 'projects.list', {}, options);
|
||||
if (isJsonMode(options)) {
|
||||
printJson({ projects });
|
||||
return;
|
||||
}
|
||||
process.stdout.write(projects.length > 0
|
||||
? `${projects.map(formatProjectLine).join('\n')}\n`
|
||||
: 'No projects found.\n');
|
||||
}
|
||||
|
||||
export { projectsCommand, formatProjectLine };
|
||||
@@ -0,0 +1,195 @@
|
||||
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 assertRequired = (value, flagName) => {
|
||||
const normalized = asNonEmptyString(value);
|
||||
if (!normalized) {
|
||||
throw new TunnelCliError(`Missing required ${flagName}.`, EXIT_CODE.USAGE_ERROR);
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const formatGoal = (execution) => {
|
||||
if (execution?.goalEnabled !== true) return 'goal:no';
|
||||
return Number.isFinite(execution.goalTokenBudget)
|
||||
? `goal:yes budget:${execution.goalTokenBudget}`
|
||||
: 'goal:yes';
|
||||
};
|
||||
|
||||
const formatSchedule = (schedule) => {
|
||||
if (!schedule || typeof schedule !== 'object') return 'unknown';
|
||||
if (schedule.kind === 'daily') return `daily ${Array.isArray(schedule.times) ? schedule.times.join(',') : ''}`.trim();
|
||||
if (schedule.kind === 'weekly') return `weekly days:${Array.isArray(schedule.weekdays) ? schedule.weekdays.join(',') : ''} time:${Array.isArray(schedule.times) ? schedule.times.join(',') : ''}`;
|
||||
if (schedule.kind === 'once') return `once ${schedule.date || ''} ${schedule.time || ''}`.trim();
|
||||
if (schedule.kind === 'cron') return `cron ${schedule.cron || ''}`.trim();
|
||||
return schedule.kind || 'unknown';
|
||||
};
|
||||
|
||||
const outputTasks = (options, tasks) => {
|
||||
const normalizedTasks = Array.isArray(tasks) ? tasks : [];
|
||||
if (isJsonMode(options)) {
|
||||
printJson({ tasks: normalizedTasks });
|
||||
return;
|
||||
}
|
||||
if (isQuietMode(options)) {
|
||||
for (const task of normalizedTasks) {
|
||||
process.stdout.write(`${task.id} enabled:${task.enabled === false ? 'no' : 'yes'} ${formatGoal(task.execution)} status:${task.state?.lastStatus || 'idle'} ${formatSchedule(task.schedule)} ${task.name || ''}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clackIntro('Scheduled Tasks');
|
||||
if (normalizedTasks.length === 0) {
|
||||
logStatus('info', 'No scheduled tasks found');
|
||||
clackOutro('0 tasks');
|
||||
return;
|
||||
}
|
||||
for (const task of normalizedTasks) {
|
||||
const status = task.enabled === false ? 'warning' : 'success';
|
||||
const detail = `id: ${task.id}; ${formatGoal(task.execution)}; status: ${task.state?.lastStatus || 'idle'}; ${formatSchedule(task.schedule)}`;
|
||||
logStatus(status, task.name || task.id, detail);
|
||||
}
|
||||
clackOutro(`${normalizedTasks.length} task(s)`);
|
||||
};
|
||||
|
||||
async function scheduleCommand(options = {}, action = 'help') {
|
||||
if (action === 'help') {
|
||||
process.stdout.write(`OpenChamber Schedule Commands\n\nUSAGE:\n openchamber schedule status [OPTIONS]\n openchamber schedule list (--project <projectId> | --dir <path>) [OPTIONS]\n openchamber schedule create (--project <projectId> | --dir <path>) --name <name> --prompt <prompt> --model <provider/model> (--daily <HH:mm> | --weekly <0,1,2> --time <HH:mm> | --once <YYYY-MM-DD> --time <HH:mm> | --cron <expr>) [OPTIONS]\n openchamber schedule run (--project <projectId> | --dir <path>) --task <taskId> [OPTIONS]\n openchamber schedule delete (--project <projectId> | --dir <path>) --task <taskId> [OPTIONS]\n openchamber schedule enable (--project <projectId> | --dir <path>) --task <taskId> [OPTIONS]\n openchamber schedule disable (--project <projectId> | --dir <path>) --task <taskId> [OPTIONS]\n\nOPTIONS:\n --project <projectId> Project id from openchamber projects\n --dir <path> Resolve project by directory\n -p, --port <port> OpenChamber server port\n --timezone <zone> IANA timezone for created tasks\n --agent <id> Agent to use when running task\n --variant <id> Model variant to use when running task\n --goal Continue the scheduled session toward a goal\n --goal-token-budget <n> Goal token budget (1000-100000000; requires --goal)\n --disabled Create task disabled\n --json Output machine-readable JSON\n -q, --quiet Print concise output\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const port = await resolveTargetPort(options);
|
||||
const target = {
|
||||
...(asNonEmptyString(options.project) ? { projectId: options.project.trim() } : {}),
|
||||
...(asNonEmptyString(options.directory) ? { directory: options.directory.trim() } : {}),
|
||||
};
|
||||
|
||||
if (action === 'status') {
|
||||
const body = await requestControlAction(port, 'schedule.status', {}, options);
|
||||
if (isJsonMode(options)) {
|
||||
printJson(body || {});
|
||||
return;
|
||||
}
|
||||
if (isQuietMode(options)) {
|
||||
process.stdout.write(`enabled:${body?.enabledScheduledTasksCount ?? 0} running:${body?.runningScheduledTasksCount ?? 0}\n`);
|
||||
return;
|
||||
}
|
||||
clackIntro('Scheduled Task Status');
|
||||
logStatus(body?.hasEnabledScheduledTasks ? 'success' : 'info', `enabled: ${body?.enabledScheduledTasksCount ?? 0}`);
|
||||
logStatus(body?.hasRunningScheduledTasks ? 'success' : 'info', `running: ${body?.runningScheduledTasksCount ?? 0}`);
|
||||
clackOutro('status loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'list') {
|
||||
const body = await requestControlAction(port, 'schedule.list', target, options);
|
||||
outputTasks(options, body?.tasks);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
const goalTokenBudget = parseGoalTokenBudget(options);
|
||||
const input = {
|
||||
...target,
|
||||
name: options.name,
|
||||
prompt: options.prompt,
|
||||
model: options.model,
|
||||
daily: options.daily,
|
||||
weekly: options.weekly,
|
||||
once: options.once,
|
||||
time: options.time,
|
||||
cron: options.cron,
|
||||
timezone: options.timezone,
|
||||
agent: options.agent,
|
||||
variant: options.variant,
|
||||
goal: options.goal === true,
|
||||
...(goalTokenBudget !== undefined ? { goalTokenBudget } : {}),
|
||||
disabled: options.disabled === true,
|
||||
};
|
||||
const body = await requestControlAction(port, 'schedule.create', input, options);
|
||||
if (isJsonMode(options)) {
|
||||
printJson({ task: body?.task, created: body?.created === true });
|
||||
return;
|
||||
}
|
||||
if (isQuietMode(options)) {
|
||||
process.stdout.write(`${body?.task?.id || ''}\n`);
|
||||
return;
|
||||
}
|
||||
clackIntro('Scheduled Task Created');
|
||||
logStatus('success', body?.task?.name || options.name, `id: ${body?.task?.id || 'unknown'}; ${formatGoal(body?.task?.execution)}; ${formatSchedule(body?.task?.schedule)}`);
|
||||
clackOutro('created');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'run') {
|
||||
const taskID = assertRequired(options.task, '--task');
|
||||
const body = await requestControlAction(port, 'schedule.run', { ...target, taskId: taskID }, options);
|
||||
if (isJsonMode(options)) {
|
||||
printJson({ task: body?.task, sessionId: body?.sessionId });
|
||||
return;
|
||||
}
|
||||
if (isQuietMode(options)) {
|
||||
process.stdout.write(`${body?.sessionId || ''}\n`);
|
||||
return;
|
||||
}
|
||||
clackIntro('Scheduled Task Run');
|
||||
logStatus('success', body?.task?.name || taskID, `session: ${body?.sessionId || 'unknown'}`);
|
||||
clackOutro('started');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
const taskID = assertRequired(options.task, '--task');
|
||||
const body = await requestControlAction(port, 'schedule.delete', { ...target, taskId: taskID }, options);
|
||||
if (isJsonMode(options)) {
|
||||
printJson({ deleted: true, tasks: body?.tasks || [] });
|
||||
return;
|
||||
}
|
||||
if (isQuietMode(options)) {
|
||||
process.stdout.write(`deleted ${taskID}\n`);
|
||||
return;
|
||||
}
|
||||
clackIntro('Scheduled Task Deleted');
|
||||
logStatus('success', `deleted ${taskID}`);
|
||||
clackOutro('deleted');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'enable' || action === 'disable') {
|
||||
const taskID = assertRequired(options.task, '--task');
|
||||
const enabled = action === 'enable';
|
||||
const { task } = await requestControlAction(port, 'schedule.toggle', { ...target, taskId: taskID, disabled: !enabled }, options);
|
||||
if (isJsonMode(options)) {
|
||||
printJson({ task, enabled });
|
||||
return;
|
||||
}
|
||||
if (isQuietMode(options)) {
|
||||
process.stdout.write(`${taskID} enabled:${enabled ? 'yes' : 'no'}\n`);
|
||||
return;
|
||||
}
|
||||
clackIntro(enabled ? 'Scheduled Task Enabled' : 'Scheduled Task Disabled');
|
||||
logStatus('success', task?.name || taskID, `enabled: ${enabled ? 'yes' : 'no'}`);
|
||||
clackOutro(enabled ? 'enabled' : 'disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
throw new TunnelCliError(`Unknown schedule command '${action}'.`, EXIT_CODE.USAGE_ERROR);
|
||||
}
|
||||
|
||||
export { scheduleCommand, formatGoal };
|
||||
@@ -0,0 +1,340 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user