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:
Bohdan Triapitsyn
2026-07-24 21:54:28 +03:00
committed by GitHub
parent 484fe8bc18
commit e908db637b
85 changed files with 5375 additions and 298 deletions
+73
View File
@@ -95,6 +95,10 @@ import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js';
import { attachRealtimeProxy } from './lib/realtime-proxy.js';
import { createRelayService } from './lib/relay/service.js';
import { createRelayHostLock } from './lib/relay/host-lock.js';
import { createAgentToolRuntime } from './lib/agent-tool/runtime.js';
import { createOpenChamberSessionService } from './lib/openchamber-sessions/routes.js';
import { createScheduledTaskService } from './lib/scheduled-tasks/service.js';
import { createOpenChamberControlService } from './lib/openchamber-control/service.js';
import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
import webPush from 'web-push';
@@ -264,6 +268,7 @@ const themeRuntime = createThemeRuntime({
const readCustomThemesFromDisk = (...args) => themeRuntime.readCustomThemesFromDisk(...args);
let notificationTemplateRuntime = null;
let agentToolRuntime = null;
const createTimeoutSignal = (...args) => notificationTemplateRuntime.createTimeoutSignal(...args);
const formatProjectLabel = (...args) => notificationTemplateRuntime.formatProjectLabel(...args);
@@ -952,6 +957,7 @@ const bootstrapRuntime = createBootstrapRuntime({
registerTtsRoutes,
registerNotificationRoutes,
registerOpenChamberRoutes,
registerAgentToolRoutes: (app, options) => options.agentToolRuntime.registerRoutes(app, options.express),
express,
});
const tunnelWiringRuntime = createTunnelWiringRuntime({
@@ -1048,6 +1054,11 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
buildManagedOpenCodePath,
getManagedOpenCodeShellEnvSnapshot: getLoginShellEnvSnapshot,
getActiveSessionCount,
getManagedOpenCodeEnv: async () => {
const settings = await readSettingsFromDiskMigrated().catch(() => null);
if (settings?.agentControlToolEnabled === false) return {};
return agentToolRuntime?.prepareManagedOpenCodeEnv() || {};
},
});
const restartOpenCode = (...args) => openCodeLifecycleRuntime.restartOpenCode(...args);
@@ -1086,6 +1097,50 @@ const scheduledTasksRuntime = createScheduledTasksRuntime({
},
logger: console,
});
const emitSessionCreatedEvent = (event) => {
for (const client of uiOpenChamberEventClients) {
try {
writeSseEvent(client, {
type: 'openchamber:session-created',
properties: {
sessionId: event.sessionID,
directory: event.directory,
createdAt: event.createdAt,
promptDispatched: event.promptDispatched === true,
dispatchedAsCommand: event.dispatchedAsCommand === true,
...(event.projectID ? { projectId: event.projectID } : {}),
...(event.title ? { title: event.title } : {}),
},
});
} catch {
uiOpenChamberEventClients.delete(client);
}
}
};
const scheduledTaskService = createScheduledTaskService({
readSettingsFromDiskMigrated,
sanitizeProjects,
projectConfigRuntime,
scheduledTasksRuntime,
});
const openChamberSessionService = createOpenChamberSessionService({
readSettingsFromDiskMigrated,
sanitizeProjects,
validateDirectoryPath,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
waitForOpenCodeReady,
emitSessionCreatedEvent,
});
const openChamberControlService = createOpenChamberControlService({
readSettingsFromDiskMigrated,
sanitizeProjects,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
waitForOpenCodeReady,
sessionService: openChamberSessionService,
scheduledTaskService,
});
const ensureGlobalWatcherStarted = async () => {
if (globalWatcherStartPromise) {
@@ -1173,6 +1228,18 @@ async function main(options = {}) {
|| (typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0
? process.env.OPENCHAMBER_HOST.trim()
: '127.0.0.1');
agentToolRuntime = createAgentToolRuntime({
crypto,
fsPromises,
path,
dataDir: OPENCHAMBER_DATA_DIR,
env: process.env,
executeAction: (...args) => openChamberControlService.execute(...args),
getActivePort: () => {
const address = server?.address?.();
return typeof address === 'object' && address ? address.port : null;
},
});
// Pairing transports advertised to the create-device dialog. LAN reachability is
// derived from the SERVER's actual bind (a wildcard bind → the machine's LAN IP;
@@ -1455,6 +1522,7 @@ async function main(options = {}) {
fetchFreeZenModels,
getCachedZenModels,
setAutoAcceptSession,
agentToolRuntime,
});
uiAuthController = bootstrapResult.uiAuthController;
realtimeProxyRuntime = attachRealtimeProxy({
@@ -1531,6 +1599,11 @@ async function main(options = {}) {
buildAugmentedPath,
projectConfigRuntime,
scheduledTasksRuntime,
scheduledTaskService,
openChamberSessionService,
openChamberControlService,
waitForOpenCodeReady,
emitSessionCreatedEvent,
getOpenChamberEventClients: () => uiOpenChamberEventClients,
writeSseEvent,
permissionAutoAcceptRuntime,
@@ -0,0 +1,90 @@
# Managed OpenChamber Agent Tool
## Purpose
This module exposes OpenChamber orchestration to agents as one typed OpenCode
custom tool named `openchamber`. It is injected only when OpenChamber launches
and owns the OpenCode process, and only while the persisted
`agentControlToolEnabled` setting is not `false` (default on; toggled in
Settings → General → OpenCode CLI and applied on the next managed OpenCode
restart).
## Runtime flow
1. The OpenChamber HTTP listener binds and publishes its authoritative port.
2. `prepareManagedOpenCodeEnv()` materializes the plugin under
`<openchamber-data-dir>/agent-tool/` and appends its `file://` URL to
`OPENCODE_CONFIG_CONTENT` without replacing existing plugin entries.
3. A random per-child token and loopback callback URL are added only to the
managed OpenCode child environment.
4. The plugin calls `POST /api/openchamber/agent-tool` with its typed input and
OpenCode's authoritative session directory.
5. The route delegates the fixed action allowlist directly to the shared
OpenChamber control service. The CLI uses the same service through its
authenticated HTTP adapter, so Goal Mode ordering, wait behavior,
partial-failure reporting, and scheduled-task contracts have one owner.
6. Each action definition owns a short presentation title and a separate
agent-facing description. The generated schema uses the description to state
required inputs or one non-obvious behavior, while completed calls use the
short title in native tool metadata.
## Agent context budget
- The tool exposes one shared parameter object rather than repeating parameters
in a large per-action union. Action descriptions carry only required inputs,
defaults, or one non-obvious semantic detail.
- Obvious fields rely on their names and JSON types. Parameter descriptions are
reserved for formats, dependencies, scope, and behavior that cannot be safely
inferred from the field name.
- Session dispatches do not wait by default. Agents are told to set `wait` only
when the user asks or the next step requires the completed result.
- The tool exposes only agent-relevant actions
(`OPENCHAMBER_AGENT_TOOL_ACTIONS`): `schedule.status` stays CLI-only because
`schedule.list` already returns scheduler status, and enable/disable are one
`schedule.toggle` action driven by the `disabled` boolean.
- The tool description frames intent: created sessions and scheduled tasks are
user-facing work the user follows up with, never a channel for the agent to
delegate parts of its own current task.
- Optional behavior switches (`worktree`, `goal`, `agent`, `variant`, `wait`)
state their default and an explicit "only when the user asks" rule so agents
do not invent worktrees, goal mode, or waits the user never requested.
- Detailed combination rules are enforced by the shared control service and
returned as actionable usage errors only after an invalid call. Per-action
examples and a repeated per-action parameter schema are intentionally omitted.
## Security invariants
- The callback accepts loopback requests only and requires the current
per-child bearer token using a timing-safe comparison.
- The token is never persisted, logged, returned to the UI, or written into
the materialized plugin.
- Inputs map to a fixed action and parameter allowlist. There is no arbitrary
CLI, shell, route, or URL forwarding.
- Session/worktree deletion and project-path registration are not exposed.
- An aborted tool request propagates an abort signal into the shared service.
## Result contract
Every completed call returns JSON:
```json
{
"schemaVersion": 1,
"ok": true,
"action": "session.create",
"data": {}
}
```
Command and operational failures use the same envelope with `ok: false` and
an `error` object. OpenCode-level cancellation can still produce a native tool
error state.
## Runtime parity
- Web and Desktop managed OpenCode: injected automatically.
- External OpenCode selected with `OPENCODE_HOST` or skip-start: not injected,
because OpenChamber does not control that process environment.
- VS Code: not injected; the extension owns a separate OpenCode lifecycle.
- Hosted and Capacitor mobile clients use the server's managed OpenCode tool
when connected to such a server; no tool runs in the client runtime.
@@ -0,0 +1,256 @@
import { parse as parseJsonc } from 'jsonc-parser';
import { pathToFileURL } from 'node:url';
import {
OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS,
OPENCHAMBER_AGENT_TOOL_ACTIONS,
} from '../openchamber-control/actions.js';
const TOOL_SCHEMA_VERSION = 1;
const ACTIONS = new Set(OPENCHAMBER_AGENT_TOOL_ACTIONS);
const AGENT_TOOL_ACTION_TITLES = Object.fromEntries(
OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS.map(({ action, title }) => [action, title]),
);
const PLUGIN_PARAMETER_PROPERTIES = {
projectId: { type: 'string', description: 'Configured project ID; do not combine with directory' },
directory: { type: 'string', description: 'Absolute checkout or session directory; defaults to the current session directory' },
sessionId: { type: 'string' },
messageId: { type: 'string', description: 'Optional fork boundary message ID' },
taskId: { type: 'string' },
title: { type: 'string' },
prompt: { type: 'string' },
model: { type: 'string', description: 'Model in provider/model format. When the user names no model: for session.create pick a suitable one from models.list favorites or recents (omit if there are none); for send and fork omit it — the session reuses its previous model' },
agent: { type: 'string', description: 'OpenCode agent name; new sessions default to the build agent and existing sessions keep their previous one. Set only when the user explicitly requests a different agent' },
variant: { type: 'string', description: 'Model variant; use only when the user explicitly requests it' },
worktree: { type: 'string', description: 'New worktree name for session.create. Omit by default; use only when the user explicitly asks for an isolated worktree. Uncommitted changes do not carry over into a new worktree' },
branch: { type: 'string', description: 'Branch name for the new worktree' },
startRef: { type: 'string', description: 'Git ref used to create the new worktree' },
setUpstream: { type: 'boolean', description: 'Make the new worktree branch track its upstream' },
goal: { type: 'boolean', description: 'Run the dispatched prompt in Goal Mode; use only when the user explicitly requests it' },
goalTokenBudget: { type: 'integer', minimum: 1000, maximum: 100_000_000, description: 'Goal token budget; requires goal' },
wait: { type: 'boolean', description: 'Wait for current session activity to become idle. Omit by default; use only when the user asks or the next step requires the completed result' },
timeout: { type: 'integer', minimum: 1, maximum: 86_400, description: 'Wait timeout in seconds (default 600); requires wait' },
lastAssistant: { type: 'boolean', description: 'Return the last assistant text; create/send/fork require wait' },
limit: { type: 'integer', minimum: 1, description: 'Maximum sessions or messages to return (default 10)' },
all: { type: 'boolean', description: 'Include archived sessions or all messages, depending on the action' },
last: { type: 'boolean', description: 'Return only the last matching session message' },
withStatus: { type: 'boolean', description: 'Include authoritative status in session.list' },
role: { type: 'string', enum: ['all', 'user', 'assistant'], description: 'Message role filter' },
name: { type: 'string' },
daily: { type: 'string', description: 'Daily run time in HH:mm format' },
weekly: { type: 'string', description: 'Comma-separated weekdays; 0=Sunday and 6=Saturday' },
once: { type: 'string', description: 'One-time run date in YYYY-MM-DD format' },
time: { type: 'string', description: 'Weekly or one-time run time in HH:mm format' },
cron: { type: 'string', description: 'Cron expression' },
timezone: { type: 'string', description: 'IANA timezone' },
disabled: { type: 'boolean', description: 'true disables and false enables; required for schedule.toggle' },
};
const asNonEmptyString = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const createResult = ({ ok, action, data, error, exitCode }) => ({
schemaVersion: TOOL_SCHEMA_VERSION,
ok,
action: action || 'unknown',
...(data !== undefined ? { data } : {}),
...(error ? { error } : {}),
...(Number.isInteger(exitCode) ? { exitCode } : {}),
});
const isLoopbackAddress = (value) => {
const address = typeof value === 'string' ? value.toLowerCase() : '';
return address === '127.0.0.1'
|| address === '::1'
|| address === '::ffff:127.0.0.1';
};
const createPluginSource = () => String.raw`
export const OpenChamberPlugin = async () => ({
tool: {
openchamber: {
description: "Control OpenChamber projects, sessions, and scheduled tasks on the user's behalf. Sessions and scheduled tasks you create are for the user to follow and interact with; never use this tool to delegate parts of your own current task. Use one action per call. Scope with projectId or directory; omit both to use the current session directory. Session dispatches return immediately by default and you receive no notification when a dispatched session finishes, so never promise to report back on it; the user follows it in OpenChamber; a dispatched session needs no follow-up from you. If the user later asks how it went, use session.messages (add wait to block until it is idle, lastAssistant for just the final answer) — session.send always sends a NEW prompt and never just waits. Set wait only when the user asks or the next step requires the completed result. Session and worktree deletion are unavailable.",
args: {
action: { type: "string", enum: ${JSON.stringify(OPENCHAMBER_AGENT_TOOL_ACTIONS)}, oneOf: ${JSON.stringify(OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS.map(({ action, description }) => ({ const: action, description })))}, description: "OpenChamber action to perform" },
parameters: { type: "object", properties: ${JSON.stringify(PLUGIN_PARAMETER_PROPERTIES)}, additionalProperties: false, description: "Inputs for the action; use an empty object when none are needed" },
},
async execute(input, context) {
const args = { ...(input.parameters ?? {}), action: input.action }
const actionTitles = ${JSON.stringify(AGENT_TOOL_ACTION_TITLES)}
const title = Object.hasOwn(actionTitles, args.action) ? actionTitles[args.action] : args.action
context.metadata({
title,
metadata: {
openchamber: {
schemaVersion: ${TOOL_SCHEMA_VERSION},
action: args.action,
description: title,
},
},
})
const endpoint = process.env.OPENCHAMBER_AGENT_TOOL_URL
const token = process.env.OPENCHAMBER_AGENT_TOOL_TOKEN
const failure = (payload) => ({
title,
output: JSON.stringify(payload),
metadata: { openchamber: { schemaVersion: ${TOOL_SCHEMA_VERSION}, action: args.action, description: title, ok: false } },
})
if (!endpoint || !token) {
return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: "OpenChamber managed tool connection is unavailable" } })
}
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
authorization: "Bearer " + token,
"content-type": "application/json",
},
body: JSON.stringify({ input: args, contextDirectory: context.directory }),
signal: context.abort,
})
const output = await response.text()
let result = null
try { result = JSON.parse(output) } catch {}
const valid = result?.schemaVersion === ${TOOL_SCHEMA_VERSION} && typeof result?.ok === "boolean" && typeof result?.action === "string"
context.metadata({
title,
metadata: {
openchamber: {
schemaVersion: ${TOOL_SCHEMA_VERSION},
action: args.action,
description: title,
ok: valid && result.ok === true,
},
},
})
if (valid) return { title, output, metadata: { openchamber: { schemaVersion: ${TOOL_SCHEMA_VERSION}, action: args.action, description: title, ok: result.ok === true } } }
return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: "OpenChamber returned an invalid response", kind: "runtime", status: response.status } })
} catch (error) {
if (context.abort.aborted) throw error
return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: error instanceof Error ? error.message : String(error), kind: "runtime" } })
}
},
},
},
})
`;
const mergePluginConfig = (rawConfig, pluginUrl) => {
const errors = [];
const parsed = asNonEmptyString(rawConfig) ? parseJsonc(rawConfig, errors, { allowTrailingComma: true }) : {};
if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its managed tool');
}
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) {
throw new Error('OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its managed tool');
}
const configured = Array.isArray(parsed.plugin) ? parsed.plugin : [];
parsed.plugin = [
...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)),
pluginUrl,
];
return JSON.stringify(parsed);
};
export const createAgentToolRuntime = (dependencies) => {
const {
crypto,
fsPromises,
path,
dataDir,
getActivePort,
executeAction,
env = process.env,
} = dependencies;
const pluginDirectory = path.join(dataDir, 'agent-tool');
const pluginPath = path.join(pluginDirectory, 'openchamber-plugin.js');
let activeToken = null;
const prepareManagedOpenCodeEnv = async () => {
const port = getActivePort();
if (!Number.isInteger(port) || port <= 0) {
throw new Error('OpenChamber listener port is unavailable for managed tool injection');
}
await fsPromises.mkdir(pluginDirectory, { recursive: true });
await fsPromises.writeFile(pluginPath, createPluginSource(), { mode: 0o600 });
activeToken = crypto.randomBytes(32).toString('base64url');
const pluginUrl = pathToFileURL(pluginPath).href;
return {
OPENCODE_CONFIG_CONTENT: mergePluginConfig(env.OPENCODE_CONFIG_CONTENT, pluginUrl),
OPENCHAMBER_AGENT_TOOL_URL: `http://127.0.0.1:${port}/api/openchamber/agent-tool`,
OPENCHAMBER_AGENT_TOOL_TOKEN: activeToken,
};
};
const authorize = (req) => {
if (!activeToken || !isLoopbackAddress(req.socket?.remoteAddress)) return false;
const header = asNonEmptyString(req.headers?.authorization);
if (!header?.startsWith('Bearer ')) return false;
const provided = Buffer.from(header.slice(7));
const expected = Buffer.from(activeToken);
return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
};
const execute = async (payload = {}, options = {}) => {
const action = asNonEmptyString(payload.input?.action);
if (!action || !ACTIONS.has(action)) {
return createResult({ ok: false, action, error: { message: `Unsupported OpenChamber action: ${action || 'missing'}`, kind: 'usage' } });
}
if (typeof executeAction !== 'function') {
return createResult({ ok: false, action, error: { message: 'OpenChamber control service is unavailable', kind: 'runtime' } });
}
try {
const data = await executeAction(action, payload.input, payload.contextDirectory, options);
return createResult({ ok: true, action, data });
} catch (error) {
return createResult({
ok: false,
action,
...(error?.partial === true ? { data: {
partial: true,
partialAction: error.partialAction,
sessionId: error.sessionId,
directory: error.directory,
} } : {}),
error: {
message: error instanceof Error ? error.message : String(error),
kind: Number(error?.statusCode) >= 400 && Number(error?.statusCode) < 499 ? 'usage' : 'runtime',
},
});
}
};
const registerRoutes = (app, express) => {
app.post('/api/openchamber/agent-tool', express.json({ limit: '1mb' }), async (req, res) => {
if (!authorize(req)) return res.status(401).json({ error: 'Unauthorized' });
const controller = new AbortController();
const abortOnDisconnect = () => {
if (!res.writableEnded) controller.abort();
};
req.once('aborted', abortOnDisconnect);
res.once('close', abortOnDisconnect);
try {
return res.json(await execute(req.body, { signal: controller.signal }));
} catch (error) {
return res.json(createResult({
ok: false,
action: req.body?.input?.action,
error: { message: error instanceof Error ? error.message : String(error), kind: 'runtime' },
}));
} finally {
req.off('aborted', abortOnDisconnect);
res.off('close', abortOnDisconnect);
}
});
};
return {
prepareManagedOpenCodeEnv,
registerRoutes,
execute,
};
};
@@ -0,0 +1,236 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { pathToFileURL } from 'node:url';
import express from 'express';
import request from 'supertest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createAgentToolRuntime } from './runtime.js';
import { OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS, OPENCHAMBER_CONTROL_ACTION_DEFINITIONS } from '../openchamber-control/actions.js';
const temporaryDirectories = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
});
const createRuntime = async (overrides = {}) => {
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-agent-tool-'));
temporaryDirectories.push(dataDir);
const executeAction = vi.fn(async () => ({ projects: [] }));
const env = {};
const runtime = createAgentToolRuntime({
crypto,
fsPromises: fs,
path,
dataDir,
getActivePort: () => 3901,
executeAction,
env,
...overrides,
});
return { runtime, dataDir, executeAction, env };
};
describe('agent tool action allowlist', () => {
it('defines a short title and agent description for every action', () => {
expect(OPENCHAMBER_CONTROL_ACTION_DEFINITIONS.every(({ action, title, description }) => action && title && description)).toBe(true);
});
it.each([
'projects.list',
'models.list',
'session.list',
'session.create',
'session.send',
'session.fork',
'session.status',
'session.messages',
'schedule.list',
'schedule.create',
'schedule.run',
'schedule.delete',
'schedule.toggle',
])('delegates %s to the shared control service', async (action) => {
const { runtime, executeAction } = await createRuntime();
const input = { action, projectId: 'project-1' };
await runtime.execute({ input, contextDirectory: '/work/project' });
expect(executeAction).toHaveBeenCalledWith(action, input, '/work/project', {});
});
it.each([
'session.delete',
'schedule.status',
])('rejects %s outside the agent allowlist without invoking the service', async (action) => {
const { runtime, executeAction } = await createRuntime();
await expect(runtime.execute({ input: { action } })).resolves.toEqual(expect.objectContaining({
ok: false,
action,
error: expect.objectContaining({ kind: 'usage' }),
}));
expect(executeAction).not.toHaveBeenCalled();
});
});
describe('managed agent tool runtime', () => {
it('materializes the plugin and preserves configured plugin entries', async () => {
const { runtime, dataDir, env } = await createRuntime();
env.OPENCODE_CONFIG_CONTENT = '{ // existing\n "plugin": ["file:///existing.js", ["example-plugin", {"flag": true}]], "model": "test/model" }';
const preparedEnv = await runtime.prepareManagedOpenCodeEnv();
const config = JSON.parse(preparedEnv.OPENCODE_CONFIG_CONTENT);
const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
const source = await fs.readFile(pluginPath, 'utf8');
expect(config.model).toBe('test/model');
expect(config.plugin).toEqual([
'file:///existing.js',
['example-plugin', { flag: true }],
expect.stringContaining('/agent-tool/openchamber-plugin.js'),
]);
expect(preparedEnv.OPENCHAMBER_AGENT_TOOL_URL).toBe('http://127.0.0.1:3901/api/openchamber/agent-tool');
expect(preparedEnv.OPENCHAMBER_AGENT_TOOL_TOKEN).toMatch(/^[A-Za-z0-9_-]+$/);
expect(source).toContain('openchamber: {');
for (const { action, description } of OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS) {
expect(source).toContain(JSON.stringify({ const: action, description }));
}
expect(source).not.toContain('"schedule.status"');
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?schema=${Date.now()}`);
const hooks = await pluginModule.OpenChamberPlugin();
expect(hooks.tool.openchamber.description).toContain('Session dispatches return immediately by default');
expect(hooks.tool.openchamber.description).toContain('Set wait only when the user asks or the next step requires the completed result');
expect(hooks.tool.openchamber.args.action.oneOf).toContainEqual({
const: 'session.messages',
description: 'Read text-only messages and current sessionStatus for sessionId; directory and limit 10 are defaults',
});
expect(hooks.tool.openchamber.args.parameters.properties.wait.description).toBe(
'Wait for current session activity to become idle. Omit by default; use only when the user asks or the next step requires the completed result',
);
expect(hooks.tool.openchamber.args.parameters.properties.sessionId).toEqual({ type: 'string' });
expect(source).not.toContain('title: "OpenChamber"');
expect(source).not.toContain('@opencode-ai/plugin');
expect(source).not.toContain(preparedEnv.OPENCHAMBER_AGENT_TOOL_TOKEN);
});
it('executes actions through the shared control service', async () => {
const executeAction = vi.fn(async () => ({ projects: [] }));
const { runtime } = await createRuntime({ executeAction });
const result = await runtime.execute({
input: { action: 'projects.list' },
contextDirectory: '/work/project',
});
expect(result).toEqual({
schemaVersion: 1,
ok: true,
action: 'projects.list',
data: { projects: [] },
});
expect(executeAction).toHaveBeenCalledWith('projects.list', { action: 'projects.list' }, '/work/project', {});
});
it('keeps service failures as structured tool results', async () => {
const error = Object.assign(new Error('Task not found'), { statusCode: 404 });
const { runtime } = await createRuntime({ executeAction: vi.fn(async () => { throw error; }) });
await expect(runtime.execute({
input: { action: 'schedule.run', taskId: 'missing' },
contextDirectory: '/work/project',
})).resolves.toEqual(expect.objectContaining({
schemaVersion: 1,
ok: false,
action: 'schedule.run',
error: { message: 'Task not found', kind: 'usage' },
}));
});
it('forwards cancellation to the shared control service', async () => {
const executeAction = vi.fn(async (_action, _input, _directory, options) => {
await new Promise((resolve, reject) => {
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('OpenChamber action was cancelled'), { statusCode: 499 })), { once: true });
});
});
const { runtime } = await createRuntime({ executeAction });
const controller = new AbortController();
const pending = runtime.execute({ input: { action: 'projects.list' } }, { signal: controller.signal });
controller.abort();
await expect(pending).resolves.toEqual(expect.objectContaining({
ok: false,
action: 'projects.list',
error: { message: 'OpenChamber action was cancelled', kind: 'runtime' },
}));
expect(executeAction).toHaveBeenCalledWith('projects.list', { action: 'projects.list' }, undefined, { signal: controller.signal });
});
it('requires the per-child token on the loopback route', async () => {
const { runtime } = await createRuntime();
const env = await runtime.prepareManagedOpenCodeEnv();
const app = express();
runtime.registerRoutes(app, express);
await request(app)
.post('/api/openchamber/agent-tool')
.send({ input: { action: 'projects.list' } })
.expect(401);
const response = await request(app)
.post('/api/openchamber/agent-tool')
.set('authorization', `Bearer ${env.OPENCHAMBER_AGENT_TOOL_TOKEN}`)
.send({ input: { action: 'projects.list' } })
.expect(200);
expect(response.body).toEqual(expect.objectContaining({ ok: true, action: 'projects.list' }));
});
it('executes through the materialized plugin and authenticated callback', async () => {
let activePort = null;
const { runtime, dataDir } = await createRuntime({ getActivePort: () => activePort });
const app = express();
runtime.registerRoutes(app, express);
const server = await new Promise((resolve) => {
const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
});
activePort = server.address().port;
const previousUrl = process.env.OPENCHAMBER_AGENT_TOOL_URL;
const previousToken = process.env.OPENCHAMBER_AGENT_TOOL_TOKEN;
try {
const env = await runtime.prepareManagedOpenCodeEnv();
process.env.OPENCHAMBER_AGENT_TOOL_URL = env.OPENCHAMBER_AGENT_TOOL_URL;
process.env.OPENCHAMBER_AGENT_TOOL_TOKEN = env.OPENCHAMBER_AGENT_TOOL_TOKEN;
const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?test=${Date.now()}`);
const hooks = await pluginModule.OpenChamberPlugin();
const metadata = vi.fn();
const result = await hooks.tool.openchamber.execute(
{ action: 'projects.list', parameters: {} },
{ directory: '/work/project', abort: new AbortController().signal, metadata },
);
expect(JSON.parse(result.output)).toEqual({
schemaVersion: 1,
ok: true,
action: 'projects.list',
data: { projects: [] },
});
expect(result.title).toBe('List configured projects');
expect(result.metadata.openchamber.description).toBe('List configured projects');
expect(metadata).toHaveBeenCalledWith(expect.objectContaining({
title: 'List configured projects',
metadata: expect.objectContaining({
openchamber: expect.objectContaining({ description: 'List configured projects' }),
}),
}));
} finally {
if (previousUrl === undefined) delete process.env.OPENCHAMBER_AGENT_TOOL_URL;
else process.env.OPENCHAMBER_AGENT_TOOL_URL = previousUrl;
if (previousToken === undefined) delete process.env.OPENCHAMBER_AGENT_TOOL_TOKEN;
else process.env.OPENCHAMBER_AGENT_TOOL_TOKEN = previousToken;
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
});
@@ -0,0 +1,47 @@
# OpenChamber Control Service
## Purpose
This module owns the typed control contract shared by the OpenChamber CLI and
the managed OpenCode `openchamber` tool. Both adapters delegate to
`createOpenChamberControlService()`; neither adapter may call or spawn the
other.
## Boundaries
- `service.js` validates and executes the fixed project, model, session, and
scheduled-task action allowlist. `actions.js` marks CLI-only actions with
`agentExposed: false` (currently `schedule.status`); the agent tool consumes
the filtered `OPENCHAMBER_AGENT_TOOL_*` exports. `schedule.toggle` requires
the `disabled` boolean and replaces separate enable/disable actions;
`schedule.list` also returns scheduler status as `scheduler`.
- `routes.js` is the authenticated CLI HTTP adapter. It forwards one action,
preserves service status and partial-result details, and propagates request
cancellation.
- `../agent-tool/runtime.js` is the managed-tool adapter. It wraps service
results in the versioned native-tool envelope and uses a separate ephemeral
loopback credential.
- `../openchamber-sessions/routes.js` and `../scheduled-tasks/service.js` own
their domain operations and are composed into this service.
## Invariants
- Session status and messages come from official directory-scoped OpenCode
APIs. Message output includes only ordered `text` parts.
- Wait never treats an initial idle response as completion after dispatch. It
requires observed activity or a newly completed assistant message.
- Timeout and cancellation are failures, never authoritative idle results.
- Validation that protects side effects runs before session creation or
dispatch.
- Send and fork dispatches without an explicit model/agent/variant reuse the
target session's last user-message selection before falling back to the
configured defaults; only session creation resolves defaults directly.
- Usage errors name the missing or conflicting input so CLI and agent-tool
callers can correct an invalid request without an upfront usage manual.
- Explicit `projectId` or `directory` scope takes precedence over the managed
tool's current-session directory fallback; the fallback never creates a
conflicting second scope.
- One failed directory status lookup produces `unknown` for only that
directory and does not erase other session results.
- Destructive session/worktree deletion and project-path registration are not
part of the action contract.
@@ -0,0 +1,28 @@
export const OPENCHAMBER_CONTROL_ACTION_DEFINITIONS = Object.freeze([
{ action: 'projects.list', title: 'List configured projects', description: 'List configured projects; no parameters' },
{ action: 'models.list', title: 'Show model preferences', description: 'Show default, favorite, and recent model preferences; no parameters' },
{ action: 'session.list', title: 'List sessions', description: 'List sessions; optional directory, limit (default 10), all, or withStatus' },
{ action: 'session.create', title: 'Create a session', description: 'Create a session in the current directory by default; prompt is optional' },
{ action: 'session.send', title: 'Send a prompt', description: 'Send a new prompt to sessionId; scope with projectId or directory' },
{ action: 'session.fork', title: 'Fork a session', description: 'Fork sessionId; messageId selects the boundary; prompt is optional' },
{ action: 'session.status', title: 'Check session status', description: 'Check sessionId status; directory defaults to the current session' },
{ action: 'session.messages', title: 'Read session messages', description: 'Read text-only messages and current sessionStatus for sessionId; directory and limit 10 are defaults' },
{ action: 'schedule.status', title: 'Check scheduler status', description: 'Check scheduler status; no parameters', agentExposed: false },
{ action: 'schedule.list', title: 'List scheduled tasks', description: 'List tasks and scheduler status; scope with projectId or directory' },
{ action: 'schedule.create', title: 'Create a scheduled task', description: 'Create task; requires name, prompt, model, and one schedule selector' },
{ action: 'schedule.run', title: 'Run a scheduled task', description: 'Run taskId; scope with projectId or directory' },
{ action: 'schedule.delete', title: 'Delete a scheduled task', description: 'Delete taskId; scope with projectId or directory' },
{ action: 'schedule.toggle', title: 'Enable or disable a scheduled task', description: 'Enable or disable taskId; requires the disabled boolean' },
]);
export const OPENCHAMBER_CONTROL_ACTIONS = Object.freeze(
OPENCHAMBER_CONTROL_ACTION_DEFINITIONS.map(({ action }) => action),
);
export const OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS = Object.freeze(
OPENCHAMBER_CONTROL_ACTION_DEFINITIONS.filter(({ agentExposed }) => agentExposed !== false),
);
export const OPENCHAMBER_AGENT_TOOL_ACTIONS = Object.freeze(
OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS.map(({ action }) => action),
);
@@ -0,0 +1,16 @@
export class OpenChamberControlError extends Error {
constructor(message, statusCode = 500, details = {}) {
super(message);
this.name = 'OpenChamberControlError';
this.statusCode = statusCode;
Object.assign(this, details);
}
}
export const asControlError = (error, fallbackMessage, fallbackStatus = 500) => {
if (error instanceof OpenChamberControlError) return error;
const message = error instanceof Error ? error.message : fallbackMessage;
return new OpenChamberControlError(message || fallbackMessage, Number(error?.statusCode) || fallbackStatus, {
...(error?.goalConfigured === true ? { goalConfigured: true } : {}),
});
};
@@ -0,0 +1,36 @@
import express from 'express';
import { asControlError } from './error.js';
export const registerOpenChamberControlRoutes = (app, { controlService }) => {
app.post('/api/openchamber/control', express.json({ limit: '1mb' }), async (req, res) => {
const controller = new AbortController();
const abortOnDisconnect = () => {
if (!res.writableEnded) controller.abort();
};
req.once('aborted', abortOnDisconnect);
res.once('close', abortOnDisconnect);
try {
const action = typeof req.body?.action === 'string' ? req.body.action : '';
const requestInput = req.body?.input;
const input = requestInput && typeof requestInput === 'object' && !Array.isArray(requestInput)
? requestInput
: {};
const data = await controlService.execute(action, input, req.body?.contextDirectory, { signal: controller.signal });
return res.json(data);
} catch (error) {
const controlError = asControlError(error, 'OpenChamber control action failed');
return res.status(controlError.statusCode).json({
error: controlError.message,
...(controlError.partial === true ? {
partial: true,
partialAction: controlError.partialAction,
sessionId: controlError.sessionId,
directory: controlError.directory,
} : {}),
});
} finally {
req.off('aborted', abortOnDisconnect);
res.off('close', abortOnDisconnect);
}
});
};
@@ -0,0 +1,46 @@
import express from 'express';
import request from 'supertest';
import { describe, expect, it, vi } from 'vitest';
import { OpenChamberControlError } from './error.js';
import { registerOpenChamberControlRoutes } from './routes.js';
const createApp = (execute) => {
const app = express();
registerOpenChamberControlRoutes(app, { controlService: { execute } });
return app;
};
describe('OpenChamber control route', () => {
it('is a thin adapter over the control service', async () => {
const execute = vi.fn(async () => ({ projects: [] }));
const response = await request(createApp(execute))
.post('/api/openchamber/control')
.send({ action: 'projects.list', input: {}, contextDirectory: '/repo' })
.expect(200);
expect(response.body).toEqual({ projects: [] });
expect(execute).toHaveBeenCalledWith('projects.list', {}, '/repo', expect.objectContaining({ signal: expect.any(AbortSignal) }));
});
it('preserves service status and partial-result details', async () => {
const execute = vi.fn(async () => {
throw new OpenChamberControlError('dispatch failed', 500, {
partial: true,
partialAction: 'fork-created',
sessionId: 'ses_fork',
directory: '/repo',
});
});
const response = await request(createApp(execute))
.post('/api/openchamber/control')
.send({ action: 'session.fork', input: {} })
.expect(500);
expect(response.body).toEqual({
error: 'dispatch failed',
partial: true,
partialAction: 'fork-created',
sessionId: 'ses_fork',
directory: '/repo',
});
});
});
@@ -0,0 +1,398 @@
import path from 'node:path';
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { OpenChamberControlError, asControlError } from './error.js';
import { OPENCHAMBER_CONTROL_ACTIONS } from './actions.js';
const DEFAULT_WAIT_TIMEOUT_SECONDS = 600;
const MAX_WAIT_TIMEOUT_SECONDS = 86_400;
const WAIT_POLL_INTERVAL_MS = 500;
const CONTROL_ACTIONS = new Set(OPENCHAMBER_CONTROL_ACTIONS);
const SCHEDULE_TASK_ID_ACTIONS = new Set([
'schedule.run',
'schedule.delete',
'schedule.toggle',
]);
const asNonEmptyString = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const positiveInteger = (value, fallback, field) => {
if (value === undefined || value === null) return fallback;
const number = Number(value);
if (!Number.isSafeInteger(number) || number < 1) {
throw new OpenChamberControlError(`${field} must be a positive integer`, 400);
}
return number;
};
const normalizeWaitTimeoutMs = (value) => {
const seconds = value === undefined || value === null ? DEFAULT_WAIT_TIMEOUT_SECONDS : Number(value);
if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > MAX_WAIT_TIMEOUT_SECONDS) {
throw new OpenChamberControlError(`timeout must be from 1 to ${MAX_WAIT_TIMEOUT_SECONDS} seconds`, 400);
}
return seconds * 1000;
};
const extractTextMessages = (messages, role = 'all') => {
const result = [];
for (const record of Array.isArray(messages) ? messages : []) {
const info = record?.info;
const messageRole = info?.role;
if ((messageRole !== 'user' && messageRole !== 'assistant') || (role !== 'all' && role !== messageRole)) continue;
const text = Array.isArray(record?.parts)
? record.parts.filter((part) => part?.type === 'text' && typeof part.text === 'string').map((part) => part.text).join('').trim()
: '';
if (!text) continue;
const providerID = asNonEmptyString(info.providerID);
const modelID = asNonEmptyString(info.modelID);
result.push({
id: asNonEmptyString(info.id) || '',
role: messageRole,
createdAt: Number.isFinite(info?.time?.created) ? info.time.created : null,
completedAt: Number.isFinite(info?.time?.completed) ? info.time.completed : null,
model: providerID && modelID ? `${providerID}/${modelID}` : null,
text,
});
}
return result.sort((left, right) => (left.createdAt || 0) - (right.createdAt || 0));
};
const parseModel = (value) => {
const model = asNonEmptyString(value);
if (!model) throw new OpenChamberControlError('model is required', 400);
const slashIndex = model.indexOf('/');
if (slashIndex <= 0 || slashIndex === model.length - 1) {
throw new OpenChamberControlError('model must be in provider/model format', 400);
}
return { providerID: model.slice(0, slashIndex), modelID: model.slice(slashIndex + 1) };
};
const parseWeekdays = (value) => {
const raw = asNonEmptyString(value);
if (!raw) throw new OpenChamberControlError('weekly is required', 400);
const weekdays = raw.split(',').map((entry) => Number.parseInt(entry.trim(), 10));
if (weekdays.some((entry) => !Number.isInteger(entry) || entry < 0 || entry > 6)) {
throw new OpenChamberControlError('weekly must contain weekdays from 0 to 6', 400);
}
return Array.from(new Set(weekdays)).sort((a, b) => a - b);
};
const buildSchedule = (input) => {
const daily = asNonEmptyString(input.daily);
const weekly = asNonEmptyString(input.weekly);
const once = asNonEmptyString(input.once);
const cron = asNonEmptyString(input.cron);
const selectors = [daily, weekly, once, cron].filter(Boolean);
if (selectors.length !== 1) {
throw new OpenChamberControlError('Provide exactly one of daily, weekly, once, or cron', 400);
}
const timezone = asNonEmptyString(input.timezone);
if (daily) return { kind: 'daily', times: [daily], ...(timezone ? { timezone } : {}) };
if (weekly) {
const time = asNonEmptyString(input.time);
if (!time) throw new OpenChamberControlError('time is required with weekly', 400);
return { kind: 'weekly', weekdays: parseWeekdays(weekly), times: [time], ...(timezone ? { timezone } : {}) };
}
if (once) {
const time = asNonEmptyString(input.time);
if (!time) throw new OpenChamberControlError('time is required with once', 400);
return { kind: 'once', date: once, time, ...(timezone ? { timezone } : {}) };
}
return { kind: 'cron', cron, ...(timezone ? { timezone } : {}) };
};
const buildScheduledTask = (input) => {
const name = asNonEmptyString(input.name);
const prompt = asNonEmptyString(input.prompt);
if (!name) throw new OpenChamberControlError('name is required', 400);
if (!prompt) throw new OpenChamberControlError('prompt is required', 400);
const model = parseModel(input.model);
const goalTokenBudget = input.goalTokenBudget;
if (goalTokenBudget !== undefined && input.goal !== true) {
throw new OpenChamberControlError('goalTokenBudget requires goal', 400);
}
if (goalTokenBudget !== undefined && (!Number.isSafeInteger(goalTokenBudget) || goalTokenBudget < 1000 || goalTokenBudget > 100_000_000)) {
throw new OpenChamberControlError('goalTokenBudget must be from 1000 to 100000000', 400);
}
return {
name,
enabled: input.disabled !== true,
schedule: buildSchedule(input),
execution: {
prompt,
...model,
...(asNonEmptyString(input.agent) ? { agent: input.agent.trim() } : {}),
...(asNonEmptyString(input.variant) ? { variant: input.variant.trim() } : {}),
...(input.goal === true ? { goalEnabled: true } : {}),
...(goalTokenBudget !== undefined ? { goalTokenBudget } : {}),
},
};
};
export const createOpenChamberControlService = (dependencies) => {
const {
readSettingsFromDiskMigrated,
sanitizeProjects,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
waitForOpenCodeReady,
sessionService,
scheduledTaskService,
createClient = createOpencodeClient,
sleep = (duration) => new Promise((resolve) => setTimeout(resolve, duration)),
now = Date.now,
} = dependencies;
const wait = (duration, signal) => {
if (!signal) return sleep(duration);
if (signal.aborted) return Promise.reject(new OpenChamberControlError('OpenChamber action was cancelled', 499));
return new Promise((resolve, reject) => {
const onAbort = () => {
signal.removeEventListener('abort', onAbort);
reject(new OpenChamberControlError('OpenChamber action was cancelled', 499));
};
signal.addEventListener('abort', onAbort, { once: true });
sleep(duration).then(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, (error) => {
signal.removeEventListener('abort', onAbort);
reject(error);
});
});
};
const getClient = async () => {
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
return createClient({
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
headers: getOpenCodeAuthHeaders(),
});
};
const projects = async () => {
const settings = await readSettingsFromDiskMigrated();
return sanitizeProjects(settings?.projects || []).map((project) => ({
id: project.id,
path: path.resolve(project.path),
label: asNonEmptyString(project.label) || path.basename(project.path) || project.path,
}));
};
const models = async () => {
const settings = await readSettingsFromDiskMigrated();
return {
defaultModel: asNonEmptyString(settings?.defaultModel),
defaultVariant: asNonEmptyString(settings?.defaultVariant),
defaultAgent: asNonEmptyString(settings?.defaultAgent),
favoriteModels: Array.isArray(settings?.favoriteModels) ? settings.favoriteModels : [],
recentModels: Array.isArray(settings?.recentModels) ? settings.recentModels : [],
};
};
const sessionStatus = async (client, sessionID, directory) => {
const response = await client.session.status({ directory });
const statuses = response?.data;
if (!statuses || typeof statuses !== 'object' || Array.isArray(statuses)) {
throw new OpenChamberControlError('Invalid session status response', 500);
}
return statuses[sessionID] || { type: 'idle' };
};
const sessionMessages = async (client, sessionID, directory, role, limit) => {
const fetchLimit = limit === undefined ? undefined : Math.max(100, limit * 4);
let response = await client.session.messages({ sessionID, directory, ...(fetchLimit ? { limit: fetchLimit } : {}) });
let raw = Array.isArray(response?.data) ? response.data : [];
let messages = extractTextMessages(raw, role);
if (limit !== undefined && messages.length < limit && raw.length >= fetchLimit) {
response = await client.session.messages({ sessionID, directory });
raw = Array.isArray(response?.data) ? response.data : [];
messages = extractTextMessages(raw, role);
}
return limit === undefined ? messages : messages.slice(-limit);
};
const waitForIdle = async ({ client, sessionID, directory, timeoutMs, requireActivity, baselineMessageID, startedAt, signal }) => {
const deadline = now() + timeoutMs;
let observedActivity = false;
while (true) {
if (signal?.aborted) throw new OpenChamberControlError('OpenChamber action was cancelled', 499);
const status = await sessionStatus(client, sessionID, directory);
if (status.type === 'busy' || status.type === 'retry') {
observedActivity = true;
} else if (!requireActivity || observedActivity) {
return status;
} else {
const messages = await sessionMessages(client, sessionID, directory, 'assistant', 1);
const message = messages[0];
if (message?.completedAt && (baselineMessageID ? message.id !== baselineMessageID : message.completedAt >= startedAt)) {
return status;
}
}
const remaining = deadline - now();
if (remaining <= 0) {
throw new OpenChamberControlError(`Session did not become idle within ${Math.ceil(timeoutMs / 1000)} seconds`, 500);
}
await wait(Math.min(WAIT_POLL_INTERVAL_MS, remaining), signal);
}
};
const executeSessionAction = async (action, input, contextDirectory, signal) => {
if (input.timeout !== undefined && input.wait !== true) throw new OpenChamberControlError('timeout requires wait', 400);
if (input.lastAssistant === true && input.wait !== true) throw new OpenChamberControlError('lastAssistant requires wait', 400);
const directory = asNonEmptyString(input.directory) || (!input.projectId ? asNonEmptyString(contextDirectory) : null);
const payload = {
...(directory ? { directory } : {}),
...(asNonEmptyString(input.projectId) ? { projectId: input.projectId.trim() } : {}),
...(asNonEmptyString(input.title) ? { title: input.title.trim() } : {}),
...(asNonEmptyString(input.prompt) ? { prompt: input.prompt.trim() } : {}),
...(asNonEmptyString(input.model) ? { model: input.model.trim() } : {}),
...(asNonEmptyString(input.agent) ? { agent: input.agent.trim() } : {}),
...(asNonEmptyString(input.variant) ? { variant: input.variant.trim() } : {}),
...(input.goal === true ? { goal: true } : {}),
...(input.goalTokenBudget !== undefined ? { goalTokenBudget: input.goalTokenBudget } : {}),
...(asNonEmptyString(input.worktree) ? { worktree: {
name: input.worktree.trim(),
...(asNonEmptyString(input.branch) ? { branchName: input.branch.trim() } : {}),
...(asNonEmptyString(input.startRef) ? { startRef: input.startRef.trim() } : {}),
} } : {}),
...(typeof input.setUpstream === 'boolean' ? { setUpstream: input.setUpstream } : {}),
...(asNonEmptyString(input.messageId) ? { messageId: input.messageId.trim() } : {}),
};
const sessionID = asNonEmptyString(input.sessionId);
const startedAt = now();
let result;
if (action === 'session.create') {
result = await sessionService.create(payload);
} else {
if (!sessionID) throw new OpenChamberControlError('sessionId is required', 400);
if (action === 'session.send') {
result = await sessionService.send(sessionID, payload);
} else {
result = await sessionService.fork(sessionID, payload);
}
}
if (input.wait !== true) {
const publicResult = { ...result };
delete publicResult.baselineAssistantMessageId;
return publicResult;
}
const client = await getClient();
const status = await waitForIdle({
client,
sessionID: result.sessionId,
directory: result.directory,
timeoutMs: normalizeWaitTimeoutMs(input.timeout),
requireActivity: result.promptDispatched === true,
baselineMessageID: result.baselineAssistantMessageId,
startedAt,
signal,
});
const publicResult = { ...result, sessionStatus: status };
delete publicResult.baselineAssistantMessageId;
if (input.lastAssistant === true) {
publicResult.lastAssistantMessage = (await sessionMessages(client, result.sessionId, result.directory, 'assistant', 1))[0] || null;
}
return publicResult;
};
const execute = async (action, input = {}, contextDirectory, options = {}) => {
try {
if (!CONTROL_ACTIONS.has(action)) {
throw new OpenChamberControlError(`Unsupported OpenChamber action: ${action || 'missing'}`, 400);
}
if (action === 'projects.list') return { projects: await projects() };
if (action === 'models.list') return models();
if (action === 'schedule.status') return scheduledTaskService.status();
if (action.startsWith('schedule.')) {
const taskID = asNonEmptyString(input.taskId);
if (SCHEDULE_TASK_ID_ACTIONS.has(action) && !taskID) {
throw new OpenChamberControlError('taskId is required', 400);
}
const explicitProjectID = asNonEmptyString(input.projectId);
const explicitDirectory = asNonEmptyString(input.directory);
const contextDirectoryFallback = explicitProjectID
? undefined
: asNonEmptyString(contextDirectory) || undefined;
const projectID = await scheduledTaskService.resolveProjectID({
projectId: explicitProjectID || undefined,
directory: explicitDirectory || contextDirectoryFallback,
});
switch (action) {
case 'schedule.list':
return { scheduler: await scheduledTaskService.status(), tasks: await scheduledTaskService.list(projectID) };
case 'schedule.create': {
const result = await scheduledTaskService.upsert(projectID, buildScheduledTask(input));
return { task: result.task, created: result.created };
}
case 'schedule.run':
return scheduledTaskService.run(projectID, taskID);
case 'schedule.delete':
return { deleted: true, tasks: await scheduledTaskService.remove(projectID, taskID) };
case 'schedule.toggle': {
if (typeof input.disabled !== 'boolean') {
throw new OpenChamberControlError('disabled is required for schedule.toggle', 400);
}
const enabled = input.disabled === false;
return { task: await scheduledTaskService.setEnabled(projectID, taskID, enabled), enabled };
}
}
}
if (action === 'session.create' || action === 'session.send' || action === 'session.fork') {
return executeSessionAction(action, input, contextDirectory, options.signal);
}
if (action.startsWith('session.')) {
const directory = asNonEmptyString(input.directory) || asNonEmptyString(contextDirectory);
const sessionID = asNonEmptyString(input.sessionId);
const client = await getClient();
if (action === 'session.list') {
const limit = positiveInteger(input.limit, 10, 'limit');
const response = await client.session.list(directory ? { directory } : {});
let sessions = Array.isArray(response?.data) ? response.data : [];
if (input.all !== true) sessions = sessions.filter((session) => !session?.time?.archived);
sessions = sessions.slice(0, limit);
if (input.withStatus === true) {
const cache = new Map();
sessions = await Promise.all(sessions.map(async (session) => {
const sessionDirectory = asNonEmptyString(session?.directory);
if (!sessionDirectory) return { ...session, status: { type: 'unknown' } };
if (!cache.has(sessionDirectory)) {
const statusRequest = client.session.status({ directory: sessionDirectory }).catch(() => null);
cache.set(sessionDirectory, statusRequest);
}
const statusResponse = await cache.get(sessionDirectory);
return { ...session, status: statusResponse?.data?.[session.id] || (statusResponse ? { type: 'idle' } : { type: 'unknown' }) };
}));
}
return { sessions, limit, directory, archived: input.all === true ? 'included' : 'excluded' };
}
if (!sessionID) throw new OpenChamberControlError('sessionId is required', 400);
if (!directory) throw new OpenChamberControlError('directory is required', 400);
if (action === 'session.status') {
return { sessionId: sessionID, directory, sessionStatus: await sessionStatus(client, sessionID, directory) };
}
if (action === 'session.messages') {
if (input.timeout !== undefined && input.wait !== true) throw new OpenChamberControlError('timeout requires wait', 400);
const role = input.lastAssistant === true ? 'assistant' : (asNonEmptyString(input.role) || 'all');
if (!['all', 'user', 'assistant'].includes(role)) throw new OpenChamberControlError('role must be all, user, or assistant', 400);
const last = input.last === true || input.lastAssistant === true;
if (input.all === true && (last || input.limit !== undefined)) throw new OpenChamberControlError('all cannot be combined with last or limit', 400);
if (last && input.limit !== undefined) throw new OpenChamberControlError('last cannot be combined with limit', 400);
const currentStatus = input.wait === true
? await waitForIdle({ client, sessionID, directory, timeoutMs: normalizeWaitTimeoutMs(input.timeout), requireActivity: false, startedAt: now(), signal: options.signal })
: await sessionStatus(client, sessionID, directory);
const limit = input.all === true ? undefined : (last ? 1 : positiveInteger(input.limit, 10, 'limit'));
return { sessionId: sessionID, directory, role, sessionStatus: currentStatus, messages: await sessionMessages(client, sessionID, directory, role, limit) };
}
}
throw new OpenChamberControlError(`Unsupported OpenChamber action: ${action || 'missing'}`, 400);
} catch (error) {
throw asControlError(error, `Failed to execute ${action || 'OpenChamber action'}`);
}
};
return { execute };
};
@@ -0,0 +1,226 @@
import { describe, expect, it, vi } from 'vitest';
import { createOpenChamberControlService } from './service.js';
const createService = (overrides = {}) => {
const client = {
session: {
list: vi.fn(async () => ({ data: [] })),
status: vi.fn(async () => ({ data: {} })),
messages: vi.fn(async () => ({ data: [] })),
},
};
const sessionService = {
create: vi.fn(async () => ({ sessionId: 'ses_1', directory: '/repo', promptDispatched: false })),
send: vi.fn(),
fork: vi.fn(),
};
const scheduledTaskService = {
status: vi.fn(async () => ({ enabledScheduledTasksCount: 0 })),
resolveProjectID: vi.fn(async () => 'project-1'),
list: vi.fn(async () => []),
upsert: vi.fn(),
run: vi.fn(),
remove: vi.fn(),
setEnabled: vi.fn(),
};
const service = createOpenChamberControlService({
readSettingsFromDiskMigrated: vi.fn(async () => ({
projects: [{ id: 'project-1', path: '/repo', label: 'Repo' }],
defaultModel: 'provider/model',
favoriteModels: [],
recentModels: [],
})),
sanitizeProjects: (projects) => projects,
buildOpenCodeUrl: () => 'http://127.0.0.1:4096/',
getOpenCodeAuthHeaders: () => ({ authorization: 'Basic test' }),
waitForOpenCodeReady: vi.fn(),
createClient: vi.fn(() => client),
sessionService,
scheduledTaskService,
...overrides,
});
return { service, client, sessionService, scheduledTaskService };
};
describe('OpenChamber control service', () => {
it('serves project and model projections without an HTTP or CLI round trip', async () => {
const { service } = createService();
await expect(service.execute('projects.list')).resolves.toEqual({
projects: [{ id: 'project-1', path: '/repo', label: 'Repo' }],
});
await expect(service.execute('models.list')).resolves.toEqual(expect.objectContaining({
defaultModel: 'provider/model',
favoriteModels: [],
}));
});
it('maps schedule creation into the shared scheduled-task service', async () => {
const { service, scheduledTaskService } = createService();
scheduledTaskService.upsert.mockResolvedValue({ task: { id: 'task-1' }, created: true });
await expect(service.execute('schedule.create', {
directory: '/repo',
name: 'Daily',
prompt: 'Run checks',
model: 'provider/model',
daily: ' 09:00 ',
goal: true,
goalTokenBudget: 5000,
})).resolves.toEqual({ task: { id: 'task-1' }, created: true });
expect(scheduledTaskService.resolveProjectID).toHaveBeenCalledWith({ projectId: undefined, directory: '/repo' });
expect(scheduledTaskService.upsert).toHaveBeenCalledWith('project-1', expect.objectContaining({
name: 'Daily',
schedule: { kind: 'daily', times: ['09:00'] },
execution: expect.objectContaining({ providerID: 'provider', modelID: 'model', goalEnabled: true, goalTokenBudget: 5000 }),
}));
});
it('does not combine an explicit schedule project with the tool context directory', async () => {
const { service, scheduledTaskService } = createService();
await service.execute('schedule.list', { projectId: ' project-1 ' }, '/current-session');
expect(scheduledTaskService.resolveProjectID).toHaveBeenCalledWith({ projectId: 'project-1', directory: undefined });
});
it('includes scheduler status alongside listed tasks', async () => {
const { service, scheduledTaskService } = createService();
scheduledTaskService.list.mockResolvedValue([{ id: 'task-1' }]);
await expect(service.execute('schedule.list', {}, '/repo')).resolves.toEqual({
scheduler: { enabledScheduledTasksCount: 0 },
tasks: [{ id: 'task-1' }],
});
});
it('toggles a scheduled task through the required disabled boolean', async () => {
const { service, scheduledTaskService } = createService();
scheduledTaskService.setEnabled.mockResolvedValue({ id: 'task-1', enabled: false });
await expect(service.execute('schedule.toggle', { taskId: 'task-1' }, '/repo')).rejects.toThrow('disabled is required for schedule.toggle');
await expect(service.execute('schedule.toggle', { taskId: 'task-1', disabled: true }, '/repo')).resolves.toEqual({
task: { id: 'task-1', enabled: false },
enabled: false,
});
expect(scheduledTaskService.setEnabled).toHaveBeenCalledWith('project-1', 'task-1', false);
});
it('returns an actionable taskId error before resolving schedule scope', async () => {
const { service, scheduledTaskService } = createService();
await expect(service.execute('schedule.run', {}, '/repo')).rejects.toThrow('taskId is required');
expect(scheduledTaskService.resolveProjectID).not.toHaveBeenCalled();
expect(scheduledTaskService.run).not.toHaveBeenCalled();
});
it('validates wait modifiers before creating a session', async () => {
const { service, sessionService } = createService();
await expect(service.execute('session.create', { directory: '/repo', timeout: 30 })).rejects.toThrow('timeout requires wait');
expect(sessionService.create).not.toHaveBeenCalled();
});
it('uses the tool context directory for session actions', async () => {
const { service, sessionService } = createService();
await service.execute('session.create', { title: 'From tool' }, '/repo');
expect(sessionService.create).toHaveBeenCalledWith({ directory: '/repo', title: 'From tool' });
});
it.each([
['session.send', 'send'],
['session.fork', 'fork'],
])('delegates %s directly to the session service', async (action, method) => {
const { service, sessionService } = createService();
sessionService[method].mockResolvedValue({ sessionId: 'ses_1', directory: '/repo' });
await service.execute(action, { sessionId: 'ses_1', directory: '/repo', prompt: 'Continue' });
expect(sessionService[method]).toHaveBeenCalledWith('ses_1', { directory: '/repo', prompt: 'Continue' });
});
it('waits past initial idle until a completed assistant result appears', async () => {
let timestamp = 1000;
const { service, client, sessionService } = createService({
now: () => timestamp,
sleep: async (duration) => { timestamp += duration; },
});
sessionService.create.mockResolvedValue({
sessionId: 'ses_1',
directory: '/repo',
promptDispatched: true,
baselineAssistantMessageId: 'msg_old',
});
client.session.status.mockResolvedValue({ data: { ses_1: { type: 'idle' } } });
client.session.messages
.mockResolvedValueOnce({ data: [{ info: { id: 'msg_old', role: 'assistant', time: { completed: 900 } }, parts: [{ type: 'text', text: 'old' }] }] })
.mockResolvedValueOnce({ data: [{ info: { id: 'msg_new', role: 'assistant', time: { completed: 1500 } }, parts: [{ type: 'text', text: 'done' }] }] })
.mockResolvedValueOnce({ data: [{ info: { id: 'msg_new', role: 'assistant', time: { completed: 1500 } }, parts: [{ type: 'text', text: 'done' }] }] });
await expect(service.execute('session.create', {
directory: '/repo',
prompt: 'work',
wait: true,
lastAssistant: true,
timeout: 2,
})).resolves.toEqual(expect.objectContaining({
sessionStatus: { type: 'idle' },
lastAssistantMessage: expect.objectContaining({ id: 'msg_new', text: 'done' }),
}));
expect(client.session.status).toHaveBeenCalledTimes(2);
});
it('filters archived sessions and adds directory-scoped statuses', async () => {
const { service, client } = createService();
client.session.list.mockResolvedValue({ data: [
{ id: 'ses_active', directory: '/repo', time: {} },
{ id: 'ses_archived', directory: '/repo', time: { archived: 100 } },
{ id: 'ses_other', directory: '/other', time: {} },
] });
client.session.status
.mockResolvedValueOnce({ data: { ses_active: { type: 'busy' } } })
.mockRejectedValueOnce(new Error('unavailable'));
await expect(service.execute('session.list', { limit: 10, withStatus: true })).resolves.toEqual({
sessions: [
{ id: 'ses_active', directory: '/repo', time: {}, status: { type: 'busy' } },
{ id: 'ses_other', directory: '/other', time: {}, status: { type: 'unknown' } },
],
limit: 10,
directory: null,
archived: 'excluded',
});
});
it('names limit in positive-integer validation errors', async () => {
const { service, client } = createService();
await expect(service.execute('session.list', { limit: 0 })).rejects.toThrow('limit must be a positive integer');
expect(client.session.list).not.toHaveBeenCalled();
});
it('projects only ordered text parts from session messages', async () => {
const { service, client } = createService();
client.session.messages.mockResolvedValue({ data: [
{
info: { id: 'msg_assistant', role: 'assistant', providerID: 'openai', modelID: 'gpt-5.4-mini', time: { created: 20, completed: 30 } },
parts: [{ type: 'reasoning', text: 'hidden' }, { type: 'text', text: 'First ' }, { type: 'tool' }, { type: 'text', text: 'answer' }],
},
{ info: { id: 'msg_user', role: 'user', time: { created: 10 } }, parts: [{ type: 'text', text: 'Question' }] },
{ info: { id: 'msg_tool', role: 'assistant', time: { created: 15 } }, parts: [{ type: 'tool' }] },
] });
await expect(service.execute('session.messages', {
sessionId: 'ses_1',
directory: '/repo',
role: 'all',
all: true,
})).resolves.toEqual({
sessionId: 'ses_1',
directory: '/repo',
role: 'all',
sessionStatus: { type: 'idle' },
messages: [
{ id: 'msg_user', role: 'user', createdAt: 10, completedAt: null, model: null, text: 'Question' },
{ id: 'msg_assistant', role: 'assistant', createdAt: 20, completedAt: 30, model: 'openai/gpt-5.4-mini', text: 'First answer' },
],
});
});
it('rejects actions outside the fixed contract', async () => {
const { service } = createService();
await expect(service.execute('session.delete')).rejects.toThrow('Unsupported OpenChamber action');
});
});
@@ -0,0 +1,714 @@
import express from 'express';
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { createWorktree } from '../git/index.js';
import { expandSnippets } from '../opencode/snippets.js';
import { parseScheduledCommandPrompt } from '../scheduled-tasks/runtime.js';
import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js';
import { OpenChamberControlError, asControlError } from '../openchamber-control/error.js';
const asNonEmptyString = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const splitModel = (value) => {
const model = asNonEmptyString(value);
if (!model) return null;
const slashIndex = model.indexOf('/');
if (slashIndex <= 0 || slashIndex === model.length - 1) return null;
return {
providerID: model.slice(0, slashIndex),
modelID: model.slice(slashIndex + 1),
};
};
const resolveRequestedModel = (payload) => {
const model = splitModel(payload?.model);
if (model) return model;
const providerID = asNonEmptyString(payload?.providerID);
const modelID = asNonEmptyString(payload?.modelID);
return providerID && modelID ? { providerID, modelID } : null;
};
const FALLBACK_PROVIDER_ID = 'opencode';
const FALLBACK_MODEL_ID = 'big-pickle';
const MIN_GOAL_TOKEN_BUDGET = 1_000;
const MAX_GOAL_TOKEN_BUDGET = 100_000_000;
const resolveGoalInput = (payload, prompt) => {
const enabled = payload?.goal === true;
if (payload?.goalTokenBudget !== undefined && !enabled) {
return { ok: false, error: 'goalTokenBudget requires goal' };
}
if (enabled && !prompt) {
return { ok: false, error: 'prompt is required when goal is enabled' };
}
if (payload?.goalTokenBudget === undefined) {
return { ok: true, enabled, tokenBudget: null };
}
const tokenBudget = payload.goalTokenBudget;
if (!Number.isSafeInteger(tokenBudget)
|| tokenBudget < MIN_GOAL_TOKEN_BUDGET
|| tokenBudget > MAX_GOAL_TOKEN_BUDGET) {
return { ok: false, error: `goalTokenBudget must be an integer from ${MIN_GOAL_TOKEN_BUDGET} to ${MAX_GOAL_TOKEN_BUDGET}` };
}
return { ok: true, enabled, tokenBudget };
};
const isPrimaryAgentMode = (mode) => !mode || mode === 'primary' || mode === 'all';
const providerModels = (provider) => {
if (Array.isArray(provider?.models)) return provider.models;
if (provider?.models && typeof provider.models === 'object') return Object.values(provider.models);
return [];
};
const hasProviderModel = (providers, providerID, modelID) => {
return providers.some((provider) => provider?.id === providerID
&& providerModels(provider).some((model) => model?.id === modelID));
};
const resolveVariant = (providers, providerID, modelID, variant) => {
const normalized = asNonEmptyString(variant);
if (!normalized) return undefined;
const provider = providers.find((entry) => entry?.id === providerID);
const model = providerModels(provider).find((entry) => entry?.id === modelID);
return model?.variants && Object.prototype.hasOwnProperty.call(model.variants, normalized)
? normalized
: undefined;
};
const parseConfigModel = (value) => splitModel(value);
const buildDirectoryHeaders = (directory) => ({
...(directory ? { 'x-opencode-directory': directory } : {}),
});
const fetchJson = async (url, authHeaders, fallback, directory) => {
const response = await fetch(url.toString(), {
headers: { ...authHeaders, ...buildDirectoryHeaders(directory), accept: 'application/json' },
});
if (!response.ok) return fallback;
return response.json().catch(() => fallback);
};
const fetchSelectionInputs = async ({ buildOpenCodeUrl, authHeaders, directory, readSettingsFromDiskMigrated }) => {
const settings = await readSettingsFromDiskMigrated();
const providersUrl = new URL(buildOpenCodeUrl('/config/providers', ''));
providersUrl.searchParams.set('directory', directory);
const agentsUrl = new URL(buildOpenCodeUrl('/agent', ''));
agentsUrl.searchParams.set('directory', directory);
const configUrl = new URL(buildOpenCodeUrl('/config', ''));
configUrl.searchParams.set('directory', directory);
const [providersBody, agentsBody, configBody] = await Promise.all([
fetchJson(providersUrl, authHeaders, { providers: [] }, directory),
fetchJson(agentsUrl, authHeaders, [], directory),
fetchJson(configUrl, authHeaders, {}, directory),
]);
return {
settings,
providers: Array.isArray(providersBody?.providers) ? providersBody.providers : [],
agents: Array.isArray(agentsBody) ? agentsBody : [],
opencodeDefaultAgent: asNonEmptyString(configBody?.default_agent) || asNonEmptyString(configBody?.defaultAgent),
opencodeDefaultModel: asNonEmptyString(configBody?.model),
};
};
const resolveDefaultSelection = ({ agents, providers, settings, opencodeDefaultAgent, opencodeDefaultModel }) => {
const primaryAgents = agents.filter((agent) => isPrimaryAgentMode(agent?.mode) && agent?.hidden !== true);
let resolvedAgent = null;
const settingsDefaultAgent = asNonEmptyString(settings?.defaultAgent);
if (settingsDefaultAgent) {
resolvedAgent = agents.find((agent) => agent?.name === settingsDefaultAgent) || null;
}
if (!resolvedAgent && opencodeDefaultAgent) {
const candidate = agents.find((agent) => agent?.name === opencodeDefaultAgent) || null;
if (candidate && isPrimaryAgentMode(candidate.mode) && candidate.hidden !== true) {
resolvedAgent = candidate;
}
}
if (!resolvedAgent) {
resolvedAgent = primaryAgents.find((agent) => agent?.name === 'build') || primaryAgents[0] || agents[0] || null;
}
let model = null;
let variant;
const settingsDefaultModel = parseConfigModel(settings?.defaultModel);
if (settingsDefaultModel && hasProviderModel(providers, settingsDefaultModel.providerID, settingsDefaultModel.modelID)) {
model = settingsDefaultModel;
variant = resolveVariant(providers, model.providerID, model.modelID, settings?.defaultVariant);
}
if (!model && resolvedAgent?.model?.providerID && resolvedAgent?.model?.modelID
&& hasProviderModel(providers, resolvedAgent.model.providerID, resolvedAgent.model.modelID)) {
model = { providerID: resolvedAgent.model.providerID, modelID: resolvedAgent.model.modelID };
variant = resolveVariant(providers, model.providerID, model.modelID, resolvedAgent.variant);
}
const opencodeModel = parseConfigModel(opencodeDefaultModel);
if (!model && opencodeModel && hasProviderModel(providers, opencodeModel.providerID, opencodeModel.modelID)) {
model = opencodeModel;
}
if (!model && hasProviderModel(providers, FALLBACK_PROVIDER_ID, FALLBACK_MODEL_ID)) {
model = { providerID: FALLBACK_PROVIDER_ID, modelID: FALLBACK_MODEL_ID };
}
if (!model) {
const provider = providers[0];
const firstModel = providerModels(provider)[0];
if (provider?.id && firstModel?.id) {
model = { providerID: provider.id, modelID: firstModel.id };
}
}
return {
agent: resolvedAgent?.name,
model,
variant,
};
};
const runPromptAsync = async ({ baseUrl, authHeaders, sessionID, directory, payload }) => {
const promptUrl = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}/prompt_async`);
promptUrl.searchParams.set('directory', directory);
const response = await fetch(promptUrl.toString(), {
method: 'POST',
headers: {
...authHeaders,
...buildDirectoryHeaders(directory),
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`prompt_async failed (${response.status})${body ? `: ${body}` : ''}`);
}
};
const createSession = async ({ baseUrl, authHeaders, directory, title }) => {
const sessionUrl = new URL(`${baseUrl}/session`);
sessionUrl.searchParams.set('directory', directory);
const response = await fetch(sessionUrl.toString(), {
method: 'POST',
headers: {
...authHeaders,
...buildDirectoryHeaders(directory),
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({ directory, ...(title ? { title } : {}) }),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`session create failed (${response.status})${body ? `: ${body}` : ''}`);
}
const body = await response.json().catch(() => null);
const sessionID = body?.id || body?.data?.id;
if (!sessionID) {
throw new Error('failed to create session');
}
return sessionID;
};
const forkSession = async ({ client, sessionID, directory, messageID }) => {
const response = await client.session.fork({
sessionID,
directory,
...(messageID ? { messageID } : {}),
});
const session = response?.data;
if (!session?.id) {
throw new Error('failed to fork session');
}
return session;
};
const latestCompletedAssistantMessageID = async ({ client, sessionID, directory }) => {
let response;
try {
response = await client.session.messages({ sessionID, directory, limit: 100 });
} catch {
return null;
}
const messages = Array.isArray(response?.data) ? response.data : [];
let latest = null;
for (const message of messages) {
const info = message?.info;
if (info?.role !== 'assistant' || !Number.isFinite(info?.time?.completed)) continue;
if (!latest || (info.time.created || 0) >= (latest.time?.created || 0)) latest = info;
}
return asNonEmptyString(latest?.id);
};
const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated, sanitizeProjects, validateDirectoryPath }) => {
const projectID = asNonEmptyString(payload?.projectId) || asNonEmptyString(payload?.projectID);
if (projectID) {
const settings = await readSettingsFromDiskMigrated();
const projects = sanitizeProjects(settings?.projects || []);
const project = projects.find((entry) => entry.id === projectID) || null;
if (!project?.path) {
return { ok: false, status: 404, error: 'Project not found' };
}
const validated = await validateDirectoryPath(project.path);
return validated.ok
? { ok: true, directory: validated.directory, projectId: projectID }
: { ok: false, status: 400, error: validated.error || 'Invalid project directory' };
}
const directory = asNonEmptyString(payload?.directory);
const validated = await validateDirectoryPath(directory);
return validated.ok
? { ok: true, directory: validated.directory }
: { ok: false, status: 400, error: validated.error || 'Invalid directory' };
};
const resolveWorktreeInput = (payload) => {
if (!payload?.worktree || typeof payload.worktree !== 'object') return null;
const name = asNonEmptyString(payload.worktree.name);
if (!name) return null;
const branchName = asNonEmptyString(payload.worktree.branchName);
const startRef = asNonEmptyString(payload.worktree.startRef);
return {
mode: 'new',
name,
...(branchName ? { branchName } : {}),
...(startRef ? { startRef } : {}),
...(typeof payload.setUpstream === 'boolean' ? { setUpstream: payload.setUpstream } : {}),
};
};
export const createOpenChamberSessionService = (dependencies) => {
const {
readSettingsFromDiskMigrated,
sanitizeProjects,
validateDirectoryPath,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
waitForOpenCodeReady,
emitSessionCreatedEvent,
createSessionGoal: createSessionGoalOverride,
} = dependencies;
// Last user message of an existing session, as a selection to reuse. Returns
// null when the session has no user message carrying a model.
const fetchLastUserSelection = async ({ client, sessionID, directory }) => {
try {
const response = await client.session.messages({ sessionID, directory, limit: 20 });
const records = Array.isArray(response?.data) ? response.data : [];
for (let index = records.length - 1; index >= 0; index -= 1) {
const info = records[index]?.info;
if (info?.role !== 'user') continue;
const providerID = asNonEmptyString(info.model?.providerID);
const modelID = asNonEmptyString(info.model?.modelID);
if (!providerID || !modelID) continue;
return {
model: { providerID, modelID },
agent: asNonEmptyString(info.agent),
variant: asNonEmptyString(info.model?.variant),
};
}
} catch {
}
return null;
};
const dispatchPrompt = async ({
client,
baseUrl,
authHeaders,
sessionID,
directory,
prompt,
goalInput,
requestedModel,
requestedAgent,
requestedVariant,
reuseSessionSelection = false,
}) => {
let model = requestedModel;
let agent = requestedAgent;
let variant = requestedVariant;
if (reuseSessionSelection && (!model || !agent)) {
const previous = await fetchLastUserSelection({ client, sessionID, directory });
if (previous) {
if (!model && previous.model) {
model = previous.model;
if (variant == null) variant = previous.variant ?? undefined;
}
if (!agent && previous.agent) agent = previous.agent;
}
}
if (!model || !agent) {
const inputs = await fetchSelectionInputs({
buildOpenCodeUrl,
authHeaders,
directory,
readSettingsFromDiskMigrated,
});
const defaults = resolveDefaultSelection(inputs);
if (!model) {
model = defaults.model;
if (variant == null) variant = defaults.variant;
}
agent = agent || defaults.agent;
}
if (!model) {
const error = new Error('No model is configured or available for the requested directory');
error.statusCode = 400;
throw error;
}
const expandedPrompt = expandSnippets(prompt, directory);
if (goalInput.enabled) {
await (createSessionGoalOverride || createSessionGoal)({
baseUrl,
authHeaders,
sessionID,
directory,
objective: expandedPrompt,
tokenBudget: goalInput.tokenBudget,
providerID: model.providerID,
modelID: model.modelID,
onWarning: (message, error) => console.warn(`[OpenChamberSessions] ${message}:`, error?.message || error),
});
}
const markGoalPartial = (error) => {
if (goalInput.enabled && error && typeof error === 'object') error.goalConfigured = true;
return error;
};
let dispatchedAsCommand = false;
const parsedCommand = parseScheduledCommandPrompt(prompt);
if (parsedCommand) {
let commandExists = false;
try {
const response = await client.command.list({ directory });
const commands = Array.isArray(response?.data) ? response.data : [];
commandExists = commands.some((command) => command?.name === parsedCommand.command);
} catch {
}
if (commandExists) {
try {
await client.session.command({
sessionID,
directory,
command: parsedCommand.command,
arguments: parsedCommand.arguments,
...(agent ? { agent } : {}),
model: `${model.providerID}/${model.modelID}`,
...(variant ? { variant } : {}),
});
} catch (error) {
throw markGoalPartial(error);
}
dispatchedAsCommand = true;
}
}
if (!dispatchedAsCommand) {
try {
await runPromptAsync({
baseUrl,
authHeaders,
sessionID,
directory,
payload: {
model,
...(agent ? { agent } : {}),
...(variant ? { variant } : {}),
parts: [
{ type: 'text', text: expandedPrompt },
...(goalInput.enabled
? [{ type: 'text', text: buildGoalIntroText(goalInput.tokenBudget), synthetic: true }]
: []),
],
},
});
} catch (error) {
throw markGoalPartial(error);
}
}
return { model, agent, variant, promptDispatched: true, dispatchedAsCommand };
};
const create = async (payload = {}) => {
const title = asNonEmptyString(payload.title);
const prompt = asNonEmptyString(payload.prompt);
const goalInput = resolveGoalInput(payload, prompt);
if (!goalInput.ok) {
throw new OpenChamberControlError(goalInput.error, 400);
}
const model = resolveRequestedModel(payload);
const agent = asNonEmptyString(payload.agent);
const variant = asNonEmptyString(payload.variant);
const resolvedDirectory = await resolveRequestedDirectory({
payload,
readSettingsFromDiskMigrated,
sanitizeProjects,
validateDirectoryPath,
});
if (!resolvedDirectory.ok) {
throw new OpenChamberControlError(resolvedDirectory.error, resolvedDirectory.status || 400);
}
const worktreeInput = resolveWorktreeInput(payload);
let worktree = null;
let sessionDirectory = resolvedDirectory.directory;
if (payload?.worktree && !worktreeInput) {
throw new OpenChamberControlError('worktree.name is required when worktree is provided', 400);
}
if (worktreeInput) {
worktree = await createWorktree(resolvedDirectory.directory, worktreeInput);
sessionDirectory = worktree.path;
}
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
const authHeaders = getOpenCodeAuthHeaders();
const client = createOpencodeClient({ baseUrl, headers: authHeaders });
const sessionID = await createSession({
client,
baseUrl,
authHeaders,
directory: sessionDirectory,
...(title ? { title } : {}),
});
let dispatch = { model, agent, variant, promptDispatched: false, dispatchedAsCommand: false };
if (prompt) {
dispatch = await dispatchPrompt({
client,
baseUrl,
authHeaders,
sessionID,
directory: sessionDirectory,
prompt,
goalInput,
requestedModel: model,
requestedAgent: agent,
requestedVariant: variant,
});
}
const result = {
sessionId: sessionID,
directory: sessionDirectory,
...(resolvedDirectory.projectId ? { projectId: resolvedDirectory.projectId } : {}),
...(title ? { title } : {}),
...(worktree ? { worktree } : {}),
...(prompt && dispatch.model ? { model: dispatch.model } : {}),
...(prompt && dispatch.agent ? { agent: dispatch.agent } : {}),
...(prompt && dispatch.variant ? { variant: dispatch.variant } : {}),
promptDispatched: dispatch.promptDispatched,
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
};
try {
emitSessionCreatedEvent?.({
sessionID,
directory: sessionDirectory,
...(resolvedDirectory.projectId ? { projectID: resolvedDirectory.projectId } : {}),
...(title ? { title } : {}),
...(worktree ? { worktree } : {}),
...(prompt && dispatch.model ? { model: dispatch.model } : {}),
...(prompt && dispatch.agent ? { agent: dispatch.agent } : {}),
...(prompt && dispatch.variant ? { variant: dispatch.variant } : {}),
promptDispatched: dispatch.promptDispatched,
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
createdAt: Date.now(),
});
} catch {
}
return result;
};
const runExisting = async (action, sourceSessionId, payload = {}) => {
const sourceSessionID = asNonEmptyString(sourceSessionId);
const prompt = asNonEmptyString(payload.prompt);
if (!sourceSessionID) throw new OpenChamberControlError('sessionId is required', 400);
if (!prompt) throw new OpenChamberControlError('prompt is required', 400);
const goalInput = resolveGoalInput(payload, prompt);
if (!goalInput.ok) throw new OpenChamberControlError(goalInput.error, 400);
const requestedModel = resolveRequestedModel(payload);
let targetSessionID = sourceSessionID;
let targetSession = null;
let directory = null;
try {
const resolvedDirectory = await resolveRequestedDirectory({
payload,
readSettingsFromDiskMigrated,
sanitizeProjects,
validateDirectoryPath,
});
if (!resolvedDirectory.ok) {
throw new OpenChamberControlError(resolvedDirectory.error, resolvedDirectory.status || 400);
}
directory = resolvedDirectory.directory;
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
const authHeaders = getOpenCodeAuthHeaders();
const client = createOpencodeClient({ baseUrl, headers: authHeaders });
if (action === 'fork') {
targetSession = await forkSession({
client,
sessionID: sourceSessionID,
directory,
messageID: asNonEmptyString(payload.messageId) || undefined,
});
targetSessionID = targetSession.id;
}
const baselineAssistantMessageId = await latestCompletedAssistantMessageID({
client,
sessionID: targetSessionID,
directory,
});
const dispatch = await dispatchPrompt({
client,
baseUrl,
authHeaders,
sessionID: targetSessionID,
directory,
prompt,
goalInput,
requestedModel,
requestedAgent: asNonEmptyString(payload.agent),
requestedVariant: asNonEmptyString(payload.variant),
reuseSessionSelection: true,
});
const result = {
action,
sessionId: targetSessionID,
directory,
...(action === 'fork' ? { sourceSessionId: sourceSessionID } : {}),
...(targetSession?.title ? { title: targetSession.title } : {}),
...(baselineAssistantMessageId ? { baselineAssistantMessageId } : {}),
model: dispatch.model,
...(dispatch.agent ? { agent: dispatch.agent } : {}),
...(dispatch.variant ? { variant: dispatch.variant } : {}),
promptDispatched: true,
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
};
if (action === 'fork') {
try {
emitSessionCreatedEvent?.({
sessionID: targetSessionID,
directory,
sourceSessionID,
...(targetSession?.title ? { title: targetSession.title } : {}),
model: dispatch.model,
...(dispatch.agent ? { agent: dispatch.agent } : {}),
...(dispatch.variant ? { variant: dispatch.variant } : {}),
promptDispatched: true,
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
createdAt: Date.now(),
});
} catch {
}
}
return result;
} catch (error) {
const statusCode = Number(error?.statusCode) || 500;
const forkCreated = action === 'fork' && targetSessionID !== sourceSessionID;
const goalConfigured = error?.goalConfigured === true;
throw new OpenChamberControlError(
error instanceof Error ? error.message : `Failed to ${action} session`,
statusCode,
{
...(forkCreated || goalConfigured
? {
partial: true,
partialAction: forkCreated ? 'fork-created' : 'goal-configured',
sessionId: targetSessionID,
directory,
}
: {}),
},
);
}
};
return {
create,
send: (sessionID, payload) => runExisting('send', sessionID, payload),
fork: (sessionID, payload) => runExisting('fork', sessionID, payload),
};
};
const sendServiceError = (res, error, fallback) => {
const controlError = asControlError(error, fallback);
return res.status(controlError.statusCode).json({
error: controlError.message,
...(controlError.partial === true ? {
partial: true,
partialAction: controlError.partialAction,
sessionId: controlError.sessionId,
directory: controlError.directory,
} : {}),
});
};
export const registerOpenChamberSessionRoutes = (app, dependencies) => {
const service = dependencies.sessionService || createOpenChamberSessionService(dependencies);
app.post('/api/openchamber/sessions', express.json({ limit: '1mb' }), async (req, res) => {
try {
return res.json(await service.create(req.body && typeof req.body === 'object' ? req.body : {}));
} catch (error) {
console.error('[OpenChamberSessions] failed to create session:', error);
return sendServiceError(res, error, 'Failed to create session');
}
});
app.post(
'/api/openchamber/sessions/:sessionId/send',
express.json({ limit: '1mb' }),
async (req, res) => {
try {
return res.json(await service.send(req.params.sessionId, req.body));
} catch (error) {
console.error('[OpenChamberSessions] failed to send session:', error);
return sendServiceError(res, error, 'Failed to send session');
}
},
);
app.post(
'/api/openchamber/sessions/:sessionId/fork',
express.json({ limit: '1mb' }),
async (req, res) => {
try {
return res.json(await service.fork(req.params.sessionId, req.body));
} catch (error) {
console.error('[OpenChamberSessions] failed to fork session:', error);
return sendServiceError(res, error, 'Failed to fork session');
}
},
);
};
@@ -0,0 +1,574 @@
import express from 'express';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const createWorktreeMock = vi.fn(async () => ({
head: 'abc123',
name: 'side-task',
branch: 'openchamber/side-task',
path: '/repo/worktrees/side-task',
}));
const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } }));
const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } }));
const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
const sessionCommandMock = vi.fn(async () => ({ data: {} }));
const commandListMock = vi.fn(async () => ({ data: [] }));
globalThis.__openchamberCreateWorktreeMock = createWorktreeMock;
let registerOpenChamberSessionRoutes;
vi.mock('@opencode-ai/sdk/v2', () => ({
createOpencodeClient: () => ({
session: {
create: sessionCreateMock,
fork: sessionForkMock,
messages: sessionMessagesMock,
command: sessionCommandMock,
},
command: {
list: commandListMock,
},
}),
}));
vi.mock('../git/index.js', () => ({
createWorktree: (...args) => globalThis.__openchamberCreateWorktreeMock(...args),
}));
const createApp = (overrides = {}, options = {}) => {
const app = express();
if (options.globalJson !== false) {
app.use(express.json());
}
const calls = [];
registerOpenChamberSessionRoutes(app, {
readSettingsFromDiskMigrated: async () => ({ projects: [{ id: 'proj_1', path: '/repo/app' }] }),
sanitizeProjects: (projects) => projects,
validateDirectoryPath: async (directory) => ({ ok: true, directory }),
buildOpenCodeUrl: (route) => `http://opencode.test${route}`,
getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test' }),
waitForOpenCodeReady: vi.fn(async () => undefined),
...overrides,
});
return { app, calls };
};
describe('openchamber session routes', () => {
beforeAll(async () => {
({ registerOpenChamberSessionRoutes } = await import('./routes.js'));
});
beforeEach(() => {
createWorktreeMock.mockClear();
sessionCreateMock.mockClear();
sessionForkMock.mockClear();
sessionMessagesMock.mockReset();
sessionMessagesMock.mockResolvedValue({ data: [] });
sessionCommandMock.mockReset();
sessionCommandMock.mockResolvedValue({ data: {} });
commandListMock.mockReset();
commandListMock.mockResolvedValue({ data: [] });
});
it('creates a session for a directory', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
try {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', title: 'Side task' })
.expect(200);
expect(response.body.sessionId).toBeTruthy();
expect(response.body.sessionId).toBe('ses_123');
expect(response.body.directory).toBe('/repo/app');
expect(response.body.promptDispatched).toBe(false);
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://opencode.test/session?directory=%2Frepo%2Fapp',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ directory: '/repo/app', title: 'Side task' }),
}),
);
expect(sessionCreateMock).not.toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
});
it('parses JSON body without global middleware', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
try {
const { app } = createApp({}, { globalJson: false });
const response = await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app' })
.expect(200);
expect(response.body.sessionId).toBe('ses_123');
expect(response.body.directory).toBe('/repo/app');
} finally {
globalThis.fetch = originalFetch;
}
});
it('emits a session-created event after creating a session', async () => {
const originalFetch = globalThis.fetch;
const emitSessionCreatedEvent = vi.fn();
globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
try {
const { app } = createApp({ emitSessionCreatedEvent });
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', title: 'Side task' })
.expect(200);
expect(emitSessionCreatedEvent).toHaveBeenCalledWith(expect.objectContaining({
sessionID: 'ses_123',
directory: '/repo/app',
title: 'Side task',
promptDispatched: false,
dispatchedAsCommand: false,
}));
} finally {
globalThis.fetch = originalFetch;
}
});
it('resolves default model and agent when prompt omits them', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => {
const text = String(url);
if (text.includes('/prompt_async')) {
return { ok: true, text: async () => '' };
}
if (text.includes('/config/providers')) {
return { ok: true, json: async () => ({ providers: [{ id: 'openai', models: { 'gpt-5.5': { id: 'gpt-5.5' } } }] }) };
}
if (text.includes('/agent')) {
return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }] };
}
if (text.includes('/config')) {
return { ok: true, json: async () => ({}) };
}
return { ok: true, json: async () => ({ id: 'ses_123' }) };
});
globalThis.fetch = fetchMock;
const { app } = createApp({
readSettingsFromDiskMigrated: async () => ({
defaultModel: 'openai/gpt-5.5',
defaultAgent: 'build',
projects: [{ id: 'proj_1', path: '/repo/app' }],
}),
});
try {
const response = await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run this' })
.expect(200);
expect(response.body.model).toEqual({ providerID: 'openai', modelID: 'gpt-5.5' });
expect(response.body.agent).toBe('build');
expect(fetchMock).toHaveBeenCalledWith(
'http://opencode.test/config/providers?directory=%2Frepo%2Fapp',
expect.any(Object),
);
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
expect(JSON.parse(promptCall?.[1]?.body)).toMatchObject({
model: { providerID: 'openai', modelID: 'gpt-5.5' },
agent: 'build',
});
} finally {
globalThis.fetch = originalFetch;
}
});
it('dispatches an initial prompt when model is provided', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => {
if (String(url).includes('/prompt_async')) {
return { ok: true, text: async () => '' };
}
return { ok: true, json: async () => ({ id: 'ses_123' }) };
});
globalThis.fetch = fetchMock;
try {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
.expect(200);
expect(response.body.sessionId).toBe('ses_123');
expect(response.body.promptDispatched).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
'http://opencode.test/session/ses_123/prompt_async?directory=%2Frepo%2Fapp',
expect.objectContaining({ method: 'POST' }),
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('creates goal metadata before dispatching the initial goal prompt', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => {
if (String(url).includes('/prompt_async')) return { ok: true, text: async () => '' };
return { ok: true, json: async () => ({ id: 'ses_123' }) };
});
const createSessionGoal = vi.fn(async () => undefined);
globalThis.fetch = fetchMock;
try {
const { app } = createApp({ createSessionGoal });
const response = await request(app)
.post('/api/openchamber/sessions')
.send({
directory: '/repo/app',
prompt: 'Finish and verify the migration',
model: 'openai/gpt-5.5',
goal: true,
goalTokenBudget: 200000,
})
.expect(200);
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
const promptPayload = JSON.parse(promptCall[1].body);
expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
sessionID: 'ses_123',
directory: '/repo/app',
objective: 'Finish and verify the migration',
tokenBudget: 200000,
providerID: 'openai',
modelID: 'gpt-5.5',
}));
expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder.at(-1));
expect(promptPayload.parts).toEqual([
{ type: 'text', text: 'Finish and verify the migration' },
expect.objectContaining({ type: 'text', synthetic: true }),
]);
expect(response.body).toMatchObject({ goalEnabled: true, goalTokenBudget: 200000, promptDispatched: true });
} finally {
globalThis.fetch = originalFetch;
}
});
it('rejects invalid goal requests before creating a session', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn();
globalThis.fetch = fetchMock;
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', goal: true })
.expect(400, { error: 'prompt is required when goal is enabled' });
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run', goalTokenBudget: 200000 })
.expect(400, { error: 'goalTokenBudget requires goal' });
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run', goal: true, goalTokenBudget: 999 })
.expect(400, { error: 'goalTokenBudget must be an integer from 1000 to 100000000' });
expect(fetchMock).not.toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
});
it('creates a worktree before creating a session', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async (url) => {
if (String(url).includes('/prompt_async')) {
return { ok: true, text: async () => '' };
}
return { ok: true, json: async () => ({ id: 'ses_123' }) };
});
try {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions')
.send({
directory: '/repo/app',
worktree: { name: 'side-task', branchName: 'openchamber/side-task', startRef: 'main' },
setUpstream: false,
prompt: 'Run this',
model: 'openai/gpt-5.5',
})
.expect(200);
expect(createWorktreeMock).toHaveBeenCalledWith('/repo/app', {
mode: 'new',
name: 'side-task',
branchName: 'openchamber/side-task',
startRef: 'main',
setUpstream: false,
});
expect(response.body.directory).toBe('/repo/worktrees/side-task');
expect(response.body.worktree.path).toBe('/repo/worktrees/side-task');
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://opencode.test/session/ses_123/prompt_async?directory=%2Frepo%2Fworktrees%2Fside-task',
expect.objectContaining({ method: 'POST' }),
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('sends a goal prompt to an existing session after creating goal metadata', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '' }));
const createSessionGoal = vi.fn(async () => undefined);
globalThis.fetch = fetchMock;
try {
sessionMessagesMock.mockResolvedValue({
data: [{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } }],
});
const { app } = createApp({ createSessionGoal });
const response = await request(app)
.post('/api/openchamber/sessions/ses_source/send')
.send({
directory: '/repo/app',
prompt: 'Apply and verify the review feedback',
model: 'openai/gpt-5.5',
agent: 'build',
variant: 'high',
goal: true,
goalTokenBudget: 200000,
})
.expect(200);
expect(response.body).toMatchObject({
action: 'send',
sessionId: 'ses_source',
directory: '/repo/app',
promptDispatched: true,
goalEnabled: true,
baselineAssistantMessageId: 'msg_before',
});
expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
sessionID: 'ses_source',
directory: '/repo/app',
objective: 'Apply and verify the review feedback',
}));
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
expect(promptCall?.[0]).toBe('http://opencode.test/session/ses_source/prompt_async?directory=%2Frepo%2Fapp');
expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder.at(-1));
} finally {
globalThis.fetch = originalFetch;
}
});
it('reuses the previous session selection when send omits model, agent, and variant', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '' }));
globalThis.fetch = fetchMock;
try {
sessionMessagesMock.mockResolvedValue({
data: [
{
info: {
id: 'msg_user',
role: 'user',
agent: 'plan',
model: { providerID: 'anthropic', modelID: 'claude-sonnet-5', variant: 'high' },
time: { created: 5 },
},
},
{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } },
],
});
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions/ses_source/send')
.send({ directory: '/repo/app', prompt: 'Continue where you left off' })
.expect(200);
expect(response.body).toMatchObject({
action: 'send',
sessionId: 'ses_source',
model: { providerID: 'anthropic', modelID: 'claude-sonnet-5' },
agent: 'plan',
variant: 'high',
promptDispatched: true,
});
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
const promptBody = JSON.parse(promptCall[1].body);
expect(promptBody).toMatchObject({
model: { providerID: 'anthropic', modelID: 'claude-sonnet-5' },
agent: 'plan',
variant: 'high',
});
// The default-selection inputs (config/providers/agents) must not be consulted.
expect(fetchMock.mock.calls.every(([url]) => String(url).includes('/prompt_async'))).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});
it('forks from a message, dispatches the prompt, and emits the new session', async () => {
const originalFetch = globalThis.fetch;
const emitSessionCreatedEvent = vi.fn();
globalThis.fetch = vi.fn(async () => ({ ok: true, text: async () => '' }));
try {
const { app } = createApp({ emitSessionCreatedEvent });
const response = await request(app)
.post('/api/openchamber/sessions/ses_source/fork')
.send({
directory: '/repo/app',
messageId: 'msg_branch_point',
prompt: 'Try the alternative implementation',
model: 'openai/gpt-5.5',
agent: 'build',
variant: 'high',
})
.expect(200);
expect(sessionForkMock).toHaveBeenCalledWith({
sessionID: 'ses_source',
directory: '/repo/app',
messageID: 'msg_branch_point',
});
expect(response.body).toMatchObject({
action: 'fork',
sourceSessionId: 'ses_source',
sessionId: 'ses_fork',
directory: '/repo/app',
promptDispatched: true,
});
expect(sessionMessagesMock).toHaveBeenCalledWith({
sessionID: 'ses_fork',
directory: '/repo/app',
limit: 100,
});
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://opencode.test/session/ses_fork/prompt_async?directory=%2Frepo%2Fapp',
expect.objectContaining({ method: 'POST' }),
);
expect(emitSessionCreatedEvent).toHaveBeenCalledWith(expect.objectContaining({
sessionID: 'ses_fork',
sourceSessionID: 'ses_source',
directory: '/repo/app',
promptDispatched: true,
}));
} finally {
globalThis.fetch = originalFetch;
}
});
it('rejects send and fork requests without a prompt before calling OpenCode', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn();
globalThis.fetch = fetchMock;
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions/ses_source/send')
.send({ directory: '/repo/app' })
.expect(400, { error: 'prompt is required' });
await request(app)
.post('/api/openchamber/sessions/ses_source/fork')
.send({ directory: '/repo/app' })
.expect(400, { error: 'prompt is required' });
expect(fetchMock).not.toHaveBeenCalled();
expect(sessionForkMock).not.toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
});
it('reports the forked session when prompt dispatch fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => ({ ok: false, status: 500, text: async () => 'dispatch failed' }));
try {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions/ses_source/fork')
.send({
directory: '/repo/app',
prompt: 'Try another approach',
model: 'openai/gpt-5.5',
agent: 'build',
variant: 'high',
})
.expect(500);
expect(response.body).toMatchObject({
partial: true,
partialAction: 'fork-created',
sessionId: 'ses_fork',
directory: '/repo/app',
});
} finally {
globalThis.fetch = originalFetch;
}
});
it('does not apply a default variant to an explicitly requested model', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => {
const text = String(url);
if (text.includes('/prompt_async')) return { ok: true, text: async () => '' };
if (text.includes('/config/providers')) {
return {
ok: true,
json: async () => ({
providers: [
{ id: 'openai', models: { requested: { id: 'requested' }, default: { id: 'default', variants: { high: {} } } } },
],
}),
};
}
if (text.includes('/agent')) return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }] };
if (text.includes('/config')) return { ok: true, json: async () => ({}) };
return { ok: true, json: async () => ({ id: 'ses_123' }) };
});
globalThis.fetch = fetchMock;
try {
const { app } = createApp({
readSettingsFromDiskMigrated: async () => ({
defaultModel: 'openai/default',
defaultVariant: 'high',
projects: [{ id: 'proj_1', path: '/repo/app' }],
}),
});
await request(app)
.post('/api/openchamber/sessions/ses_source/send')
.send({ directory: '/repo/app', prompt: 'Continue', model: 'openai/requested', agent: 'build' })
.expect(200);
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
expect(JSON.parse(promptCall[1].body)).not.toHaveProperty('variant');
} finally {
globalThis.fetch = originalFetch;
}
});
it('does not retry a failed slash command as a normal prompt', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn();
commandListMock.mockResolvedValue({ data: [{ name: 'review' }] });
sessionCommandMock.mockRejectedValue(new Error('command response failed'));
globalThis.fetch = fetchMock;
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions/ses_source/send')
.send({
directory: '/repo/app',
prompt: '/review fix this',
model: 'openai/gpt-5.5',
agent: 'build',
variant: 'high',
})
.expect(500);
expect(sessionCommandMock).toHaveBeenCalledTimes(1);
expect(fetchMock).not.toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
});
});
@@ -28,6 +28,7 @@ This module provides OpenCode server integration utilities for the web server ru
- `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics.
- `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service/routes composition runtime and active-port wiring for main server startup.
- `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow.
- `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch.
- `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers.
- `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update and models metadata route registration.
- `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching.
@@ -113,6 +114,11 @@ The runtime maintains active-session count incrementally from idempotent activit
- `waitForPortRelease(port, timeoutMs, hostname?)`
- `killProcessOnPort(port)`
Managed OpenCode launch also merges the environment returned by the agent-tool
runtime. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
be replaced by injected values. External OpenCode processes receive no
OpenChamber tool injection.
## Public exports (env-runtime.js)
- `createOpenCodeEnvRuntime(dependencies)`: creates runtime that owns OpenCode CLI environment and binary discovery state.
- OpenCode CLI resolution order is persisted settings, environment overrides, bundled Desktop CLI when available, PATH, known install locations, then platform shell discovery.
@@ -311,6 +317,10 @@ The runtime maintains active-session count incrementally from idempotent activit
- Returned API:
- `run(options)`
The pipeline binds the OpenChamber listener and publishes its active port
before starting managed OpenCode. The managed custom tool therefore receives
an authoritative loopback callback URL even when OpenChamber binds port `0`.
## Public exports (openchamber-routes.js)
- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints:
- `GET /api/openchamber/update-check`
+4
View File
@@ -7,6 +7,7 @@ export const createBootstrapRuntime = (dependencies) => {
registerTtsRoutes,
registerNotificationRoutes,
registerOpenChamberRoutes,
registerAgentToolRoutes = () => {},
express,
} = dependencies;
@@ -59,6 +60,7 @@ export const createBootstrapRuntime = (dependencies) => {
fetchFreeZenModels,
getCachedZenModels,
setAutoAcceptSession,
agentToolRuntime,
} = options;
const uiAuthController = createUiAuth({
@@ -85,6 +87,8 @@ export const createBootstrapRuntime = (dependencies) => {
registerCommonRequestMiddleware(app, { express, verboseRequestLogs });
registerAgentToolRoutes(app, { express, agentToolRuntime });
registerAuthAndAccessRoutes(app, {
express,
tunnelAuthController,
@@ -11,6 +11,8 @@ import { registerConfigEntityRoutes } from './config-entity-routes.js';
import { registerSettingsUtilityRoutes } from './core-routes.js';
import { registerProjectIconRoutes } from './project-icon-routes.js';
import { registerScheduledTaskRoutes } from '../scheduled-tasks/routes.js';
import { registerOpenChamberSessionRoutes } from '../openchamber-sessions/routes.js';
import { registerOpenChamberControlRoutes } from '../openchamber-control/routes.js';
import { registerSkillRoutes } from './skill-routes.js';
import { registerPluginRoutes } from './plugin-routes.js';
import { getNpmInfo, clearCache as clearNpmCache } from './npm-registry.js';
@@ -97,8 +99,13 @@ export const createFeatureRoutesRuntime = (dependencies) => {
buildAugmentedPath,
projectConfigRuntime,
scheduledTasksRuntime,
scheduledTaskService,
openChamberSessionService,
openChamberControlService,
waitForOpenCodeReady,
getOpenChamberEventClients,
writeSseEvent,
emitSessionCreatedEvent,
permissionAutoAcceptRuntime,
} = routeDependencies;
@@ -146,10 +153,24 @@ export const createFeatureRoutesRuntime = (dependencies) => {
sanitizeProjects,
projectConfigRuntime,
scheduledTasksRuntime,
scheduledTaskService,
getOpenChamberEventClients,
writeSseEvent,
});
registerOpenChamberSessionRoutes(app, {
readSettingsFromDiskMigrated,
sanitizeProjects,
validateDirectoryPath,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
waitForOpenCodeReady,
emitSessionCreatedEvent,
sessionService: openChamberSessionService,
});
registerOpenChamberControlRoutes(app, { controlService: openChamberControlService });
registerConfigEntityRoutes(app, {
resolveProjectDirectory,
resolveOptionalProjectDirectory,
@@ -38,6 +38,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
buildAugmentedPath,
buildManagedOpenCodePath,
getManagedOpenCodeShellEnvSnapshot,
getManagedOpenCodeEnv = async () => ({}),
getActiveSessionCount = () => 0,
} = deps;
@@ -474,14 +475,16 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
await applyOpencodeBinaryFromSettings({ strict: true });
ensureOpencodeCliEnv();
const openCodePassword = await ensureLocalOpenCodeServerPassword({ rotateManaged: true });
const envPath = typeof buildManagedOpenCodePath === 'function'
? buildManagedOpenCodePath()
: typeof buildAugmentedPath === 'function'
? buildAugmentedPath()
: process.env.PATH;
let envPath = process.env.PATH;
if (typeof buildManagedOpenCodePath === 'function') {
envPath = buildManagedOpenCodePath();
} else if (typeof buildAugmentedPath === 'function') {
envPath = buildAugmentedPath();
}
const shellEnv = typeof getManagedOpenCodeShellEnvSnapshot === 'function'
? getManagedOpenCodeShellEnvSnapshot() || {}
: {};
const managedOpenCodeEnv = await getManagedOpenCodeEnv();
try {
const serverInstance = await createManagedOpenCodeServerProcess({
@@ -493,6 +496,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
env: {
...shellEnv,
...process.env,
...managedOpenCodeEnv,
PATH: envPath,
OPENCODE_SERVER_PASSWORD: openCodePassword,
},
@@ -128,6 +128,34 @@ describe('OpenCode lifecycle', () => {
await server.close();
});
it('adds managed OpenChamber tool environment without allowing it to replace launch invariants', async () => {
const child = createMockChild();
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
});
return child;
});
const getManagedOpenCodeEnv = vi.fn(async () => ({
OPENCODE_CONFIG_CONTENT: '{"plugin":["file:///tool.js"]}',
OPENCHAMBER_AGENT_TOOL_TOKEN: 'ephemeral',
PATH: '/untrusted/path',
OPENCODE_SERVER_PASSWORD: 'untrusted-password',
}));
const runtime = createRuntime({ getManagedOpenCodeEnv });
const server = await runtime.startOpenCode();
const [, , options] = spawnMock.mock.calls[0];
expect(getManagedOpenCodeEnv).toHaveBeenCalledOnce();
expect(options.env.OPENCODE_CONFIG_CONTENT).toBe('{"plugin":["file:///tool.js"]}');
expect(options.env.OPENCHAMBER_AGENT_TOOL_TOKEN).toBe('ephemeral');
expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin');
expect(options.env.OPENCODE_SERVER_PASSWORD).toBe('password');
await server.close();
});
it('falls back to buildAugmentedPath when buildManagedOpenCodePath is not provided', async () => {
delete process.env.OPENCODE_BINARY;
const child = createMockChild();
@@ -248,6 +248,12 @@ export const createSettingsHelpers = (dependencies) => {
}
result.draftStarters = starters;
}
if (typeof candidate.draftStartersCraftGoalAdded === 'boolean') {
result.draftStartersCraftGoalAdded = candidate.draftStartersCraftGoalAdded;
}
if (typeof candidate.draftStartersScheduleTaskAdded === 'boolean') {
result.draftStartersScheduleTaskAdded = candidate.draftStartersScheduleTaskAdded;
}
if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) {
@@ -479,6 +485,9 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.showOpenCodeUpdateNotifications === 'boolean') {
result.showOpenCodeUpdateNotifications = candidate.showOpenCodeUpdateNotifications;
}
if (typeof candidate.agentControlToolEnabled === 'boolean') {
result.agentControlToolEnabled = candidate.agentControlToolEnabled;
}
if (typeof candidate.openCodeUpdateToastDismissedVersion === 'string') {
const version = candidate.openCodeUpdateToastDismissedVersion.trim();
result.openCodeUpdateToastDismissedVersion = version.slice(0, VERSION_STRING_MAX_LENGTH);
@@ -98,8 +98,6 @@ export const createStartupPipelineRuntime = (dependencies) => {
});
setupProxy(app);
scheduleOpenCodeApiDetection();
void bootstrapOpenCodeAtStartup();
if (apiOnly) {
staticRoutesRuntime.registerApiOnlyFallbackRoutes(app);
@@ -132,6 +130,8 @@ export const createStartupPipelineRuntime = (dependencies) => {
onTunnelReady,
});
tunnelRuntimeContext.setActivePort(startupResult.activePort);
scheduleOpenCodeApiDetection();
void bootstrapOpenCodeAtStartup();
serverStartupRuntime.attachProcessHandlers({ attachSignals });
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import { createStartupPipelineRuntime } from './startup-pipeline-runtime.js';
describe('startup pipeline runtime', () => {
it('publishes the listening port before bootstrapping managed OpenCode', async () => {
const order = [];
const runtime = createStartupPipelineRuntime({
createTerminalRuntime: () => ({}),
createDictationRuntime: () => ({}),
createMessageStreamWsRuntime: () => ({}),
createServerStartupRuntime: () => ({
resolveBindHost: () => '127.0.0.1',
startListeningAndMaybeTunnel: async () => {
order.push('listen');
return { activePort: 3901 };
},
attachProcessHandlers: vi.fn(),
}),
});
await runtime.run({
app: {},
setupProxy: vi.fn(),
staticRoutesRuntime: { registerStaticRoutes: vi.fn() },
apiOnly: false,
tunnelRuntimeContext: {
setActivePort: (port) => order.push(`port:${port}`),
},
scheduleOpenCodeApiDetection: () => order.push('detect'),
bootstrapOpenCodeAtStartup: () => order.push('bootstrap'),
process: {},
crypto: {},
server: {},
attachSignals: false,
});
expect(order).toEqual(['listen', 'port:3901', 'detect', 'bootstrap']);
});
});
@@ -17,14 +17,9 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
scheduledTasksRuntime,
getOpenChamberEventClients,
writeSseEvent,
scheduledTaskService = createScheduledTaskService(dependencies),
} = dependencies;
const findProjectByID = async (projectID) => {
const settings = await readSettingsFromDiskMigrated();
const projects = sanitizeProjects(settings?.projects || []);
return projects.find((project) => project.id === projectID) || null;
};
app.get('/api/projects/:projectId/scheduled-tasks', async (req, res) => {
const projectID = parseProjectID(req);
if (!projectID) {
@@ -32,14 +27,10 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
}
try {
const project = await findProjectByID(projectID);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const tasks = await projectConfigRuntime.listScheduledTasks(projectID);
const tasks = await scheduledTaskService.list(projectID);
return res.json({ tasks });
} catch (error) {
if (error?.statusCode) return res.status(error.statusCode).json({ error: error.message });
console.error('[ScheduledTasks] failed to load tasks:', error);
return res.status(500).json({ error: 'Failed to load scheduled tasks' });
}
@@ -57,22 +48,9 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
}
try {
const project = await findProjectByID(projectID);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const upserted = await projectConfigRuntime.upsertScheduledTask(projectID, taskInput);
await scheduledTasksRuntime.syncProject(projectID);
const freshTasks = await projectConfigRuntime.listScheduledTasks(projectID);
const freshTask = freshTasks.find((task) => task.id === upserted.task.id) || upserted.task;
return res.json({
tasks: freshTasks,
task: freshTask,
created: upserted.created,
});
return res.json(await scheduledTaskService.upsert(projectID, taskInput));
} catch (error) {
if (error?.statusCode) return res.status(error.statusCode).json({ error: error.message });
const message = error instanceof Error ? error.message : 'Failed to save scheduled task';
const statusCode = message.toLowerCase().includes('required') || message.toLowerCase().includes('invalid')
? 400
@@ -95,19 +73,9 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
}
try {
const project = await findProjectByID(projectID);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const result = await projectConfigRuntime.deleteScheduledTask(projectID, taskID);
if (!result.deleted) {
return res.status(404).json({ error: 'Task not found' });
}
await scheduledTasksRuntime.syncProject(projectID);
const freshTasks = await projectConfigRuntime.listScheduledTasks(projectID);
return res.json({ tasks: freshTasks });
return res.json({ tasks: await scheduledTaskService.remove(projectID, taskID) });
} catch (error) {
if (error?.statusCode) return res.status(error.statusCode).json({ error: error.message });
console.error('[ScheduledTasks] failed to delete task:', error);
return res.status(500).json({ error: 'Failed to delete scheduled task' });
}
@@ -124,31 +92,9 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
}
try {
const project = await findProjectByID(projectID);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const result = await scheduledTasksRuntime.runNow(projectID, taskID);
if (result.running || result.queued) {
return res.status(409).json({ error: result.error || 'Task already running' });
}
if (result.skipped) {
return res.status(404).json({ error: 'Task not found or disabled' });
}
if (!result.ok) {
return res.status(500).json({
error: result.error || 'Task run failed',
task: result.task,
});
}
return res.json({
ok: true,
task: result.task,
sessionId: result.sessionID,
});
return res.json({ ok: true, ...await scheduledTaskService.run(projectID, taskID) });
} catch (error) {
if (error?.statusCode) return res.status(error.statusCode).json({ error: error.message, ...(error.task ? { task: error.task } : {}) });
console.error('[ScheduledTasks] failed to run task:', error);
return res.status(500).json({ error: 'Failed to run scheduled task' });
}
@@ -156,37 +102,7 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
app.get('/api/openchamber/scheduled-tasks/status', async (_req, res) => {
try {
if (typeof scheduledTasksRuntime.getStatus === 'function') {
return res.json(scheduledTasksRuntime.getStatus());
}
const settings = await readSettingsFromDiskMigrated();
const projects = sanitizeProjects(settings?.projects || []);
let enabledCount = 0;
let runningCount = 0;
for (const project of projects) {
try {
const tasks = await projectConfigRuntime.listScheduledTasks(project.id);
for (const task of tasks) {
if (task?.enabled) {
enabledCount += 1;
}
if (task?.state?.lastStatus === 'running') {
runningCount += 1;
}
}
} catch {
}
}
return res.json({
hasEnabledScheduledTasks: enabledCount > 0,
hasRunningScheduledTasks: runningCount > 0,
enabledScheduledTasksCount: enabledCount,
runningScheduledTasksCount: runningCount,
});
return res.json(await scheduledTaskService.status());
} catch (error) {
console.error('[ScheduledTasks] failed to resolve scheduled task status:', error);
return res.status(500).json({ error: 'Failed to resolve scheduled task status' });
@@ -233,3 +149,4 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
});
});
};
import { createScheduledTaskService } from './service.js';
@@ -2,6 +2,7 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { DateTime } from 'luxon';
import parser from 'cron-parser';
import { expandSnippets } from '../opencode/snippets.js';
import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js';
const DEFAULT_GLOBAL_CONCURRENCY = 4;
const DEFAULT_PROJECT_CONCURRENCY = 2;
@@ -407,21 +408,6 @@ export const createScheduledTasksRuntime = (deps) => {
return projectRunning < maxProjectConcurrency;
};
// Same instruction the composer attaches on an armed goal send: the agent
// must know goal mode is on from turn one, and each turn has to end with a
// factual report for the independent audit.
const buildGoalIntroText = (tokenBudget) => {
const budgetLine = tokenBudget
? ` A token budget of ${tokenBudget} tokens applies to this goal.`
: '';
return '<system-reminder>\n'
+ 'Goal mode is active for this session. The user message above defines the goal objective. '
+ 'Work toward it across turns; whenever you stop before the objective is verifiably complete, the system will automatically prompt you to continue. '
+ 'Progress is evaluated independently after each turn, so end every turn with a clear, factual statement of what is done, what was verified, and what remains.'
+ budgetLine
+ '\n</system-reminder>';
};
const buildPromptAsyncPayload = (task, projectPath) => ({
model: {
providerID: task.execution.providerID,
@@ -440,88 +426,6 @@ export const createScheduledTasksRuntime = (deps) => {
],
});
// Scheduled goal runs: stamp the goal onto the fresh session's metadata
// before the prompt goes out; the session-goal runtime picks the loop up
// from session events like any other goal.
const createTaskGoal = async ({ baseUrl, authHeaders, sessionID, projectPath, task }) => {
const now = Date.now();
// File-backed objective keyed by session id: metadata stays light, the
// full expanded prompt lives under the OpenChamber data dir. If the file
// write fails, fall back to an inline (clamped) objective.
// Oversized prompts are distilled into audit criteria by the small model
// (the working agent gets the full prompt in chat anyway); on distill
// failure a head+tail excerpt keeps intent and acceptance criteria.
let objectiveText = expandSnippets(task.execution.prompt, projectPath);
if (objectiveText.length > 5000) {
let distilled = null;
try {
const { generateSmallModelText } = await import('../small-model/index.js');
const generated = await generateSmallModelText({
restrictToPreferredProvider: true,
prompt: objectiveText,
system: [
'You distill a large task description into the COMPLETION CRITERIA a progress auditor will judge against.',
'Return ONLY the criteria text — no preamble, no headers, no markdown fences.',
'Capture: the end goals, what must exist and work when the task is fully done, and how each major part is verified. Omit implementation steps.',
'Preserve verbatim any file paths, commands, and identifiers that define the task.',
'Stay under 4000 characters.',
'Write in the same language as the task text.',
].join('\n'),
directory: projectPath,
preferredProviderID: task.execution.providerID,
preferredModelID: task.execution.modelID,
});
distilled = typeof generated?.text === 'string' ? generated.text.trim() : null;
} catch (error) {
console.warn('[scheduled-tasks] goal objective distillation failed:', error?.message || error);
}
if (distilled) {
objectiveText = distilled;
} else {
const marker = '\n\n[… objective trimmed for the auditor — the full prompt was delivered in the chat message …]\n\n';
const half = Math.max(0, Math.floor((5000 - marker.length) / 2));
objectiveText = `${objectiveText.slice(0, half)}${marker}${objectiveText.slice(-half)}`;
}
}
let objectiveFile = false;
try {
const { writeObjective } = await import('../session-goal/objectives.js');
await writeObjective(sessionID, objectiveText);
objectiveFile = true;
} catch (error) {
console.warn('[scheduled-tasks] goal objective file write failed, falling back to inline:', error?.message || error);
}
const goal = {
id: `${now.toString(36)}${Math.random().toString(36).slice(2, 8)}`,
objective: objectiveFile ? '' : objectiveText.slice(0, 5000),
objectiveFile,
status: 'active',
tokenBudget: task.execution.goalTokenBudget || null,
tokensUsed: 0,
turnsUsed: 0,
blockedStreak: 0,
note: '',
statusReason: '',
lastAccountedMessageID: '',
createdAt: now,
updatedAt: now,
};
const url = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}`);
url.searchParams.set('directory', projectPath);
const response = await fetch(url.toString(), {
method: 'PATCH',
headers: {
...authHeaders,
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({ metadata: { openchamber: { goal } } }),
});
if (!response.ok) {
throw new Error(`goal metadata patch failed (${response.status})`);
}
};
const runPromptAsync = async ({ baseUrl, authHeaders, sessionID, projectPath, task }) => {
const promptUrl = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}/prompt_async`);
promptUrl.searchParams.set('directory', projectPath);
@@ -624,7 +528,17 @@ export const createScheduledTasksRuntime = (deps) => {
}
if (task.execution.goalEnabled) {
await createTaskGoal({ baseUrl, authHeaders, sessionID, projectPath, task });
await createSessionGoal({
baseUrl,
authHeaders,
sessionID,
directory: projectPath,
objective: expandSnippets(task.execution.prompt, projectPath),
tokenBudget: task.execution.goalTokenBudget,
providerID: task.execution.providerID,
modelID: task.execution.modelID,
onWarning: (message, error) => console.warn(`[scheduled-tasks] ${message}:`, error?.message || error),
});
}
const executedAsCommand = await runScheduledCommandIfApplicable({
@@ -0,0 +1,145 @@
import path from 'node:path';
import { OpenChamberControlError } from '../openchamber-control/error.js';
const asNonEmptyString = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
export const createScheduledTaskService = (dependencies) => {
const {
readSettingsFromDiskMigrated,
sanitizeProjects,
projectConfigRuntime,
scheduledTasksRuntime,
} = dependencies;
const listProjects = async () => {
const settings = await readSettingsFromDiskMigrated();
return sanitizeProjects(settings?.projects || []);
};
const findProjectByID = async (projectID) => {
const normalized = asNonEmptyString(projectID);
if (!normalized) throw new OpenChamberControlError('projectId is required', 400);
const projects = await listProjects();
const project = projects.find((entry) => entry.id === normalized) || null;
if (!project) throw new OpenChamberControlError('Project not found', 404);
return project;
};
const resolveProjectID = async ({ projectId, directory } = {}) => {
const requestedProjectID = asNonEmptyString(projectId);
const requestedDirectory = asNonEmptyString(directory);
if (requestedProjectID && requestedDirectory) {
throw new OpenChamberControlError('Provide only one of projectId or directory', 400);
}
if (requestedProjectID) {
await findProjectByID(requestedProjectID);
return requestedProjectID;
}
if (!requestedDirectory) throw new OpenChamberControlError('projectId or directory is required', 400);
const resolvedDirectory = path.resolve(requestedDirectory);
const projects = await listProjects();
const project = projects.find((entry) => path.resolve(entry.path) === resolvedDirectory);
if (!project) throw new OpenChamberControlError(`Project not found for directory: ${resolvedDirectory}`, 404);
return project.id;
};
const list = async (projectID) => {
await findProjectByID(projectID);
return projectConfigRuntime.listScheduledTasks(projectID);
};
const upsert = async (projectID, taskInput) => {
await findProjectByID(projectID);
if (!taskInput || typeof taskInput !== 'object') {
throw new OpenChamberControlError('task payload is required', 400);
}
let upserted;
try {
upserted = await projectConfigRuntime.upsertScheduledTask(projectID, taskInput);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to save scheduled task';
const invalid = message.toLowerCase().includes('required') || message.toLowerCase().includes('invalid');
throw new OpenChamberControlError(message, invalid ? 400 : 500);
}
await scheduledTasksRuntime.syncProject(projectID);
const tasks = await projectConfigRuntime.listScheduledTasks(projectID);
return {
tasks,
task: tasks.find((task) => task.id === upserted.task.id) || upserted.task,
created: upserted.created,
};
};
const remove = async (projectID, taskID) => {
await findProjectByID(projectID);
const normalizedTaskID = asNonEmptyString(taskID);
if (!normalizedTaskID) throw new OpenChamberControlError('taskId is required', 400);
const result = await projectConfigRuntime.deleteScheduledTask(projectID, normalizedTaskID);
if (!result.deleted) throw new OpenChamberControlError('Task not found', 404);
await scheduledTasksRuntime.syncProject(projectID);
return projectConfigRuntime.listScheduledTasks(projectID);
};
const run = async (projectID, taskID) => {
await findProjectByID(projectID);
const normalizedTaskID = asNonEmptyString(taskID);
if (!normalizedTaskID) throw new OpenChamberControlError('taskId is required', 400);
const result = await scheduledTasksRuntime.runNow(projectID, normalizedTaskID);
if (result.running || result.queued) {
throw new OpenChamberControlError(result.error || 'Task already running', 409);
}
if (result.skipped) throw new OpenChamberControlError('Task not found or disabled', 404);
if (!result.ok) {
throw new OpenChamberControlError(result.error || 'Task run failed', 500, { task: result.task });
}
return { task: result.task, sessionId: result.sessionID };
};
const setEnabled = async (projectID, taskID, enabled) => {
const tasks = await list(projectID);
const task = tasks.find((entry) => entry?.id === taskID);
if (!task) throw new OpenChamberControlError('Task not found', 404);
const result = await upsert(projectID, { ...task, enabled });
return result.task;
};
const status = async () => {
if (typeof scheduledTasksRuntime.getStatus === 'function') {
return scheduledTasksRuntime.getStatus();
}
const projects = await listProjects();
let enabledCount = 0;
let runningCount = 0;
for (const project of projects) {
try {
const tasks = await projectConfigRuntime.listScheduledTasks(project.id);
for (const task of tasks) {
if (task?.enabled) enabledCount += 1;
if (task?.state?.lastStatus === 'running') runningCount += 1;
}
} catch {
}
}
return {
hasEnabledScheduledTasks: enabledCount > 0,
hasRunningScheduledTasks: runningCount > 0,
enabledScheduledTasksCount: enabledCount,
runningScheduledTasksCount: runningCount,
};
};
return {
listProjects,
resolveProjectID,
list,
upsert,
remove,
run,
setEnabled,
status,
};
};
@@ -66,8 +66,9 @@ before touching the filesystem). Rationale: metadata rides every
- UI display fetches content via the GET route
(`useGoalObjectiveContent`); in VS Code the route is unavailable, so the
strip degrades to the audit note (display-only fallback by design).
- Scheduled goal tasks write the file server-side directly via
`objectives.js`.
- Server-created goals write the file through `create.js`, which also owns
objective fitting, inline fallback, metadata creation, and the synthetic
first-turn reminder shared by scheduled tasks and CLI-created sessions.
## Flow
@@ -174,6 +175,24 @@ stamp `metadata.openchamber.goal` onto the fresh session (objective = the
expanded task prompt) and attach the goal-mode intro part to the prompt.
The loop here picks it up from session events like any other goal.
## CLI-created goals
`openchamber session create --prompt <text> --goal` uses the explicit
`POST /api/openchamber/sessions` orchestration route. The server creates the
session, fits and stores the expanded prompt as its objective, patches active
goal metadata, appends the synthetic goal reminder, and only then dispatches
the prompt. `--goal-token-budget` applies the same optional budget contract as
scheduled goals. Slash commands retain command dispatch semantics and cannot
carry the synthetic prompt part; the goal metadata is still installed before
the command runs.
`openchamber session send --goal` and `openchamber session fork --goal` use
the same server-owned prompt orchestration. Send installs a fresh goal on the
target session; fork first uses the official OpenCode fork operation (at the
optional message boundary), then installs the goal on the new session. Both
preserve the objective-file-before-metadata and metadata-before-dispatch
ordering used by create and scheduled goals.
## Limitations
- Web-server feature: VS Code (extension-only) renders goal state via
@@ -0,0 +1,112 @@
import { GOAL_OBJECTIVE_CHAR_LIMIT, writeObjective } from './objectives.js';
const TRIM_MARKER = '\n\n[… objective trimmed for the auditor — the full prompt was delivered in the chat message …]\n\n';
export const buildGoalIntroText = (tokenBudget) => {
const budgetLine = tokenBudget
? ` A token budget of ${tokenBudget} tokens applies to this goal.`
: '';
return '<system-reminder>\n'
+ 'Goal mode is active for this session. The user message above defines the goal objective. '
+ 'Work toward it across turns; whenever you stop before the objective is verifiably complete, the system will automatically prompt you to continue. '
+ 'Progress is evaluated independently after each turn, so end every turn with a clear, factual statement of what is done, what was verified, and what remains.'
+ budgetLine
+ '\n</system-reminder>';
};
const fitObjective = async ({ objective, directory, providerID, modelID, warn }) => {
if (objective.length <= GOAL_OBJECTIVE_CHAR_LIMIT) return objective;
let distilled = null;
try {
const { generateSmallModelText } = await import('../small-model/index.js');
const generated = await generateSmallModelText({
restrictToPreferredProvider: true,
prompt: objective,
system: [
'You distill a large task description into the COMPLETION CRITERIA a progress auditor will judge against.',
'Return ONLY the criteria text — no preamble, no headers, no markdown fences.',
'Capture: the end goals, what must exist and work when the task is fully done, and how each major part is verified. Omit implementation steps.',
'Preserve verbatim any file paths, commands, and identifiers that define the task.',
'Stay under 4000 characters.',
'Write in the same language as the task text.',
].join('\n'),
directory,
preferredProviderID: providerID,
preferredModelID: modelID,
});
distilled = typeof generated?.text === 'string' ? generated.text.trim() : null;
} catch (error) {
warn('goal objective distillation failed', error);
}
if (distilled) return distilled.slice(0, GOAL_OBJECTIVE_CHAR_LIMIT);
const half = Math.max(0, Math.floor((GOAL_OBJECTIVE_CHAR_LIMIT - TRIM_MARKER.length) / 2));
return `${objective.slice(0, half)}${TRIM_MARKER}${objective.slice(-half)}`;
};
export const createSessionGoal = async ({
baseUrl,
authHeaders,
sessionID,
directory,
objective,
tokenBudget = null,
providerID,
modelID,
onWarning,
}) => {
const warn = (message, error) => {
if (typeof onWarning === 'function') {
onWarning(message, error);
return;
}
console.warn(`[session-goal] ${message}:`, error?.message || error);
};
const objectiveText = await fitObjective({
objective: String(objective ?? '').trim(),
directory,
providerID,
modelID,
warn,
});
if (!objectiveText) throw new Error('goal objective is required');
let objectiveFile = false;
try {
await writeObjective(sessionID, objectiveText);
objectiveFile = true;
} catch (error) {
warn('goal objective file write failed, falling back to inline', error);
}
const now = Date.now();
const goal = {
id: `${now.toString(36)}${Math.random().toString(36).slice(2, 8)}`,
objective: objectiveFile ? '' : objectiveText.slice(0, GOAL_OBJECTIVE_CHAR_LIMIT),
objectiveFile,
status: 'active',
tokenBudget: tokenBudget || null,
tokensUsed: 0,
turnsUsed: 0,
blockedStreak: 0,
note: '',
statusReason: '',
lastAccountedMessageID: '',
createdAt: now,
updatedAt: now,
};
const url = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}`);
url.searchParams.set('directory', directory);
const response = await fetch(url.toString(), {
method: 'PATCH',
headers: {
...authHeaders,
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({ metadata: { openchamber: { goal } } }),
});
if (!response.ok) throw new Error(`goal metadata patch failed (${response.status})`);
return goal;
};
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const writeObjectiveMock = vi.fn(async () => undefined);
const generateSmallModelTextMock = vi.fn(async () => ({ text: '' }));
vi.mock('./objectives.js', () => ({
GOAL_OBJECTIVE_CHAR_LIMIT: 5_000,
writeObjective: writeObjectiveMock,
}));
vi.mock('../small-model/index.js', () => ({
generateSmallModelText: generateSmallModelTextMock,
}));
const { buildGoalIntroText, createSessionGoal } = await import('./create.js');
describe('session goal creation', () => {
beforeEach(() => {
writeObjectiveMock.mockReset().mockResolvedValue(undefined);
generateSmallModelTextMock.mockReset().mockResolvedValue({ text: '' });
});
it('writes the objective before patching active goal metadata', async () => {
const fetchMock = vi.fn(async () => ({ ok: true }));
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchMock;
try {
const goal = await createSessionGoal({
baseUrl: 'http://opencode.test',
authHeaders: { Authorization: 'Bearer test' },
sessionID: 'ses_123',
directory: '/repo/app',
objective: 'Finish and verify the migration',
tokenBudget: 200_000,
providerID: 'openai',
modelID: 'gpt-5.5',
});
expect(writeObjectiveMock).toHaveBeenCalledWith('ses_123', 'Finish and verify the migration');
expect(writeObjectiveMock.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder[0]);
expect(goal).toMatchObject({ objective: '', objectiveFile: true, status: 'active', tokenBudget: 200_000 });
expect(fetchMock).toHaveBeenCalledWith(
'http://opencode.test/session/ses_123?directory=%2Frepo%2Fapp',
expect.objectContaining({ method: 'PATCH' }),
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('falls back to inline metadata when objective storage fails', async () => {
writeObjectiveMock.mockRejectedValueOnce(new Error('disk unavailable'));
const fetchMock = vi.fn(async () => ({ ok: true }));
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchMock;
try {
await createSessionGoal({
baseUrl: 'http://opencode.test',
authHeaders: {},
sessionID: 'ses_123',
directory: '/repo/app',
objective: 'Finish the migration',
onWarning: vi.fn(),
});
const payload = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(payload.metadata.openchamber.goal).toMatchObject({
objective: 'Finish the migration',
objectiveFile: false,
});
} finally {
globalThis.fetch = originalFetch;
}
});
it('builds the same goal intro with an optional budget', () => {
expect(buildGoalIntroText(null)).toContain('Goal mode is active for this session.');
expect(buildGoalIntroText(200_000)).toContain('A token budget of 200000 tokens applies to this goal.');
});
});