Files
openchamber/packages/web/server/lib/agent-activity/runtime.js
T
bot-hermes 05d8e953ca 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.
2026-09-07 22:34:56 +00:00

224 lines
7.2 KiB
JavaScript

// Live activity stream: normalizes upstream SSE message.updated events into
// structured activity events the wrapper can consume in real time. Subscribes
// to the global hub (same pattern as message-queue/runtime.js) and broadcasts
// via broadcastGlobalUiEvent.
//
// Rate-limiting: tool-call events are coalesced to ~1 per 2s per session.
// File-edit and plan-step events always emit immediately.
const COALESCE_WINDOW_MS = 2_000;
const TEXT_PART_CHAR_LIMIT = 200;
const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null);
const asNonEmptyString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
const asList = (value) => (Array.isArray(value) ? value : []);
const asCount = (value) => (Number.isFinite(value) && value >= 0 ? Math.floor(value) : null);
const extractSessionStatus = (payload) => {
if (payload.type !== 'session.status') return null;
const properties = asRecord(payload.properties) ?? {};
const status = asRecord(properties.status) ?? {};
const info = asRecord(properties.info) ?? {};
const sessionId = asNonEmptyString(properties.sessionID);
const type = asNonEmptyString(status.type) || asNonEmptyString(info.type);
if (!sessionId || !type) return null;
return { sessionId, type };
};
const extractMessageUpdate = (payload) => {
if (payload.type !== 'message.updated') return null;
const properties = asRecord(payload.properties) ?? {};
const info = asRecord(properties.info);
if (!info) return null;
const sessionId = asNonEmptyString(info.sessionID);
if (!sessionId) return null;
return { sessionId, info };
};
const isReadLikeTool = (toolName) => {
const name = String(toolName ?? '').toLowerCase();
return name === 'read' || name === 'grep' || name === 'search' || name === 'glob'
|| name === 'list' || name === 'ls';
};
const isFileEditTool = (toolName) => {
const name = String(toolName ?? '').toLowerCase();
return name === 'edit' || name === 'write' || name === 'bash';
};
const normalizeToolPart = (part) => {
const toolName = asNonEmptyString(part?.tool) || asNonEmptyString(part?.name) || '';
if (!toolName) return null;
const input = asRecord(part?.input) ?? asRecord(part?.parameters) ?? {};
const filePath = asNonEmptyString(input?.filePath) || asNonEmptyString(input?.path) || '';
const command = asNonEmptyString(input?.command) || '';
const detail = filePath
? `${toolName} ${filePath}`
: (command ? `${toolName} ${command.slice(0, 80)}` : toolName);
return { tool: toolName, file: filePath, detail };
};
const normalizeTextPart = (part) => {
const text = typeof part?.text === 'string' ? part.text.trim() : '';
if (!text) return null;
return { detail: text.slice(0, TEXT_PART_CHAR_LIMIT) };
};
const normalizeParts = (parts) => {
const activities = [];
for (const part of asList(parts)) {
if (!part || typeof part !== 'object') continue;
if (part.type === 'tool-invocation' || part.type === 'tool') {
const normalized = normalizeToolPart(part);
if (normalized) {
const kind = isFileEditTool(normalized.tool) ? 'file-edit'
: isReadLikeTool(normalized.tool) ? 'tool-call'
: 'tool-call';
activities.push({ kind, ...normalized });
}
} else if (part.type === 'text') {
const normalized = normalizeTextPart(part);
if (normalized) {
activities.push({ kind: 'text-part', tool: '', file: '', ...normalized });
}
}
}
return activities;
};
const extractCardID = (info) => {
const metadata = asRecord(info?.metadata);
const namespace = asRecord(metadata?.openchamber);
return asNonEmptyString(namespace?.cardID) || '';
};
const extractTokens = (info) => {
const tokens = asRecord(info?.tokens);
if (!tokens) return null;
const input = asCount(tokens.input) ?? 0;
const output = asCount(tokens.output) ?? 0;
const cachedRead = asCount(tokens.cache?.read) ?? 0;
return { input, output, cachedRead };
};
export function createAgentActivityRuntime({
globalEventHub,
broadcastGlobalUiEvent,
coalesceWindowMs = COALESCE_WINDOW_MS,
now = Date.now,
}) {
let stopped = false;
/** sessionId → timestamp of last emitted tool-call event */
const lastToolEmit = new Map();
const processPayload = (payload) => {
if (stopped || !payload || typeof payload !== 'object') return;
const messageUpdate = extractMessageUpdate(payload);
if (messageUpdate) {
processMessageUpdate(messageUpdate);
return;
}
const status = extractSessionStatus(payload);
if (status && status.type === 'idle') {
const cardID = ''; // cardID resolved from metadata if available
broadcastGlobalUiEvent?.({
type: 'openchamber:session-completed',
properties: {
sessionID: status.sessionId,
cardID,
ts: now(),
},
});
}
};
const processMessageUpdate = ({ sessionId, info }) => {
const parts = asList(info?.parts);
const activities = normalizeParts(parts);
if (activities.length === 0) return;
const cardID = extractCardID(info);
const tokens = extractTokens(info);
for (const activity of activities) {
const isToolCall = activity.kind === 'tool-call';
const lastEmit = lastToolEmit.get(sessionId);
if (isToolCall && lastEmit !== undefined && (now() - lastEmit) < coalesceWindowMs) {
continue;
}
if (isToolCall) {
lastToolEmit.set(sessionId, now());
}
broadcastGlobalUiEvent?.({
type: 'openchamber:agent-activity',
properties: {
sessionID: sessionId,
cardID,
ts: now(),
kind: activity.kind,
tool: activity.tool,
file: activity.file,
detail: activity.detail,
...(tokens ? { tokens } : {}),
},
});
}
};
const start = () => {
const unsubscribe = globalEventHub.subscribeEvent((event) => {
const raw = event?.payload;
const payload = raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw;
processPayload(payload);
});
return () => { unsubscribe(); };
};
const stop = () => {
stopped = true;
lastToolEmit.clear();
};
return { processPayload, start, stop };
}
export function registerAgentActivityRoutes(app, { getOpenChamberEventClients, writeSseEvent }) {
app.get('/api/openchamber/agent-activity', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
const clients = getOpenChamberEventClients();
clients.add(res);
try {
writeSseEvent(res, {
type: 'openchamber:agent-activity-stream-ready',
properties: { connectedAt: Date.now() },
});
} catch {}
const heartbeat = setInterval(() => {
try {
writeSseEvent(res, {
type: 'openchamber:heartbeat',
properties: { timestamp: Date.now() },
});
} catch {
clearInterval(heartbeat);
clients.delete(res);
}
}, 25_000);
req.on('close', () => {
clearInterval(heartbeat);
clients.delete(res);
});
});
}