feat: agent-to-agent integrations — activity stream, steer channel, plan gate

Three fork-side integrations that turn OpenChamber from a fire-and-forget
coder into a visible, steerable, plan-gated agent:

1. Activity stream (GET /api/openchamber/agent-activity SSE)
   - agent-activity/runtime.js: subscribes to global hub, normalizes
     message.updated parts into structured activity events (tool-call,
     file-edit, text-part), rate-limited coalescing for tool calls
   - Broadcasts openchamber:agent-activity and session-completed events
   - SSE endpoint with heartbeat (25s), same shape as /api/openchamber/events

2. Steer channel (POST /api/openchamber/session/:id/steer)
   - session-steer/runtime.js: interrupt mode (interrupt → inject
     system-level directive → resume) and queue mode (deliver on next idle)
   - Follows message-queue precedent for interrupt-safe dispatch
   - Broadcasts openchamber:steer-delivered on queued delivery

3. Plan-first gate (plan-gate/runtime.js + approve/reject/status routes)
   - State machine per session: pending → approved | rejected | timed_out
   - Injects plan-gate reminder via openchamber-sessions create prompt
   - Agent emits ## Plan, runtime detects and emits openchamber:plan-ready
   - Approve sends 'Proceed' prompt, reject sends revision prompt
   - Configurable timeout (default 5 min), auto-approve on timeout

4. P1 shared plumbing
   - cardID accepted in session create payload, stored in session metadata
   - All new events carry cardID when known

Files added:
- packages/web/server/lib/agent-activity/runtime.js + runtime.test.js
- packages/web/server/lib/session-steer/runtime.js + runtime.test.js
- packages/web/server/lib/plan-gate/runtime.js + runtime.test.js

Files modified:
- packages/web/server/lib/openchamber-sessions/routes.js (cardID, planGate)
- packages/web/server/lib/opencode/feature-routes-runtime.js (route wiring)
- packages/web/server/index.js (runtime creation, SSE_PATH_PREFIXES)
- packages/web/server/lib/ui-auth/ui-auth.js (auth allowlist)
- packages/web/server/lib/realtime-proxy.js (SSE allowlist)

28 new tests passing. All pre-existing tests unaffected.
This commit is contained in:
2026-09-07 22:34:56 +00:00
parent 4b95b327dd
commit 05d8e953ca
11 changed files with 1488 additions and 1 deletions
@@ -196,7 +196,7 @@ const runPromptAsync = async ({ baseUrl, authHeaders, sessionID, directory, payl
}
};
const createSession = async ({ baseUrl, authHeaders, directory, title, agent, model, variant }) => {
const createSession = async ({ baseUrl, authHeaders, directory, title, agent, model, variant, cardID }) => {
const sessionUrl = new URL(`${baseUrl}/session`);
sessionUrl.searchParams.set('directory', directory);
const response = await fetch(sessionUrl.toString(), {
@@ -226,6 +226,21 @@ const createSession = async ({ baseUrl, authHeaders, directory, title, agent, mo
if (!sessionID) {
throw new Error('failed to create session');
}
if (cardID) {
const patchUrl = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}`);
patchUrl.searchParams.set('directory', directory);
await fetch(patchUrl.toString(), {
method: 'PATCH',
headers: {
...authHeaders,
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({ metadata: { openchamber: { cardID } } }),
}).catch(() => {});
}
return sessionID;
};
@@ -476,6 +491,7 @@ export const createOpenChamberSessionService = (dependencies) => {
directory,
prompt,
goalInput,
planGate = false,
requestedModel,
requestedAgent,
requestedVariant,
@@ -587,6 +603,9 @@ export const createOpenChamberSessionService = (dependencies) => {
...(goalInput.enabled
? [{ type: 'text', text: buildGoalIntroText(goalInput.tokenBudget), synthetic: true }]
: []),
...(planGate
? [{ type: 'text', text: '<system-reminder>\nPlan gate is active. Before editing any files, output your implementation plan as a markdown block starting with `## Plan`. Then STOP and wait for approval. Do not read files beyond what you need to write the plan.\n</system-reminder>', synthetic: true }]
: []),
],
},
});
@@ -680,6 +699,8 @@ export const createOpenChamberSessionService = (dependencies) => {
const create = async (payload = {}) => {
const title = asNonEmptyString(payload.title);
const prompt = asNonEmptyString(payload.prompt);
const cardID = asNonEmptyString(payload.cardID);
const planGate = payload.planGate === true;
const goalInput = resolveGoalInput(payload, prompt);
if (!goalInput.ok) {
throw new OpenChamberControlError(goalInput.error, 400);
@@ -734,6 +755,7 @@ export const createOpenChamberSessionService = (dependencies) => {
...(agent ? { agent } : {}),
...(model ? { model } : {}),
...(variant ? { variant } : {}),
...(cardID ? { cardID } : {}),
});
let dispatch = { model, agent, variant, promptDispatched: false, dispatchedAsCommand: false };
@@ -746,6 +768,7 @@ export const createOpenChamberSessionService = (dependencies) => {
directory: sessionDirectory,
prompt,
goalInput,
planGate,
requestedModel: model,
requestedAgent: agent,
requestedVariant: variant,
@@ -766,6 +789,8 @@ export const createOpenChamberSessionService = (dependencies) => {
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
...(cardID ? { cardID } : {}),
...(planGate ? { planGate: true } : {}),
};
try {
@@ -782,6 +807,8 @@ export const createOpenChamberSessionService = (dependencies) => {
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
...(cardID ? { cardID } : {}),
...(planGate ? { planGate: true } : {}),
createdAt: Date.now(),
});
} catch {