`sendFire` looked up a sidecar, and if none was registered it did nothing. No log, no error, no return value — the command simply stopped existing. That window is real and entirely predictable: every `pm2 restart officer` has one while the sidecars reconnect, and somebody clicking a button has no idea they are in it. The cost landed hardest on answering a question. `answerClaudeQuestion` in the agent documents this exact failure in a careful comment and cannot report it, because the command never arrives to be reported. So the question card kept spinning, which from the person's side is indistinguishable from a slow agent, and the only move left is to send another message — the worst possible thing to do to a waiting tool call. Observed tonight, on a restart I ran myself while a commander was mid-question. So `sendFire` returns whether anyone took the command and warns when nobody did, and the two paths where a person is actively waiting — a question answer and a plan decision — tell them instead of leaving the card to spin. The other five callers are unchanged and now leave a log line, which is the part that was missing when this was diagnosed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
558 lines
22 KiB
TypeScript
558 lines
22 KiB
TypeScript
import type { ServerWebSocket } from 'bun';
|
|
import type {
|
|
SidecarCommand,
|
|
SidecarEvent,
|
|
ClaudeState,
|
|
ClaudeSpawnParams,
|
|
ClaudeSpawnStreamingParams,
|
|
ClaudeCodeResult,
|
|
LiveClaudeSession,
|
|
LiveOpenCodeSession,
|
|
OpenCodeRunParams,
|
|
} from './sidecar/protocol';
|
|
import type { SidecarRegistration } from './sidecar/registration-protocol';
|
|
import type { TurnMessage } from './api/chat/types';
|
|
|
|
// ── Types ──
|
|
|
|
type RegisteredSidecar = {
|
|
id: string;
|
|
name: string;
|
|
handles: string[];
|
|
ws: ServerWebSocket<any>;
|
|
};
|
|
|
|
type PendingRequest = {
|
|
/** Which sidecar this request was sent to, so a disconnect only fails that sidecar's own work. */
|
|
sidecarId: string;
|
|
resolve: (value: any) => void;
|
|
reject: (error: Error) => void;
|
|
timer: Timer;
|
|
};
|
|
|
|
type EventHandler = (event: SidecarEvent) => void;
|
|
|
|
// ── State ──
|
|
|
|
const sidecars = new Map<string, RegisteredSidecar>();
|
|
const pending = new Map<string, PendingRequest>();
|
|
const eventHandlers = new Map<string, Set<EventHandler>>();
|
|
let cachedState: ClaudeState | null = null;
|
|
let idCounter = 0;
|
|
|
|
function nextId(): string {
|
|
return `sr_${Date.now()}_${++idCounter}`;
|
|
}
|
|
|
|
// ── Registration ──
|
|
|
|
/** The live socket for a sidecar registered under this name, or null. For pushes that are not commands. */
|
|
export function socketForSidecar(name: string): ServerWebSocket<any> | null {
|
|
for (const sc of sidecars.values()) if (sc.name === name) return sc.ws;
|
|
return null;
|
|
}
|
|
|
|
export function registerSidecar(ws: ServerWebSocket<any>, registration: SidecarRegistration): string {
|
|
// If a sidecar with the same name is already registered, unregister it first
|
|
for (const [id, sc] of sidecars) {
|
|
if (sc.name === registration.name) {
|
|
console.log(`[registry] replacing existing sidecar "${registration.name}" (id=${id})`);
|
|
unregisterSidecar(id);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const id = `sc_${registration.name}_${Date.now()}`;
|
|
sidecars.set(id, {
|
|
id,
|
|
name: registration.name,
|
|
handles: registration.handles,
|
|
ws,
|
|
});
|
|
console.log(
|
|
`[registry] registered sidecar "${registration.name}" (id=${id}, handles=[${registration.handles.join(', ')}])`,
|
|
);
|
|
|
|
// A registration socket lives and dies with its process, so an agent showing up here is an agent that
|
|
// has just started — and whatever it was running before is gone. Announced rather than inferred from
|
|
// the *disconnect*, which is the wrong signal entirely: every `pm2 restart officer` drops these sockets
|
|
// while the sidecars, and their turns, carry on perfectly well.
|
|
if (registration.handles.includes('claude')) {
|
|
for (const handler of claudeRestartHandlers) {
|
|
try {
|
|
handler();
|
|
} catch (err) {
|
|
console.error('[registry] claude-restart handler failed', err);
|
|
}
|
|
}
|
|
}
|
|
return id;
|
|
}
|
|
|
|
const claudeRestartHandlers = new Set<() => void>();
|
|
|
|
/** Notified when the agent sidecar registers — i.e. when a new agent process has come up. */
|
|
export function onClaudeSidecarStarted(handler: () => void): () => void {
|
|
claudeRestartHandlers.add(handler);
|
|
return () => claudeRestartHandlers.delete(handler);
|
|
}
|
|
|
|
export function unregisterSidecar(id: string): void {
|
|
const sc = sidecars.get(id);
|
|
if (!sc) return;
|
|
sidecars.delete(id);
|
|
console.log(`[registry] unregistered sidecar "${sc.name}" (id=${id})`);
|
|
|
|
// Reject the pending requests belonging to THIS sidecar, and only those. Until the pending entries
|
|
// carried a sidecarId this loop rejected the whole map, so restarting any one sidecar failed in-flight
|
|
// work on every other — `pm2 restart officer-music` could kill a running agent turn with the message
|
|
// `Sidecar "music" disconnected`, which is the sort of failure nobody traces back to its cause.
|
|
for (const [reqId, req] of pending) {
|
|
if (req.sidecarId !== id) continue;
|
|
clearTimeout(req.timer);
|
|
req.reject(new Error(`Sidecar "${sc.name}" disconnected`));
|
|
pending.delete(reqId);
|
|
}
|
|
|
|
// Clear cached state if the claude sidecar disconnects
|
|
if (sc.handles.includes('proxy')) {
|
|
cachedState = null;
|
|
}
|
|
}
|
|
|
|
export function handleSidecarMessage(id: string, msg: SidecarEvent): void {
|
|
// Check if this is a response to a pending request
|
|
if ('id' in msg && msg.id && pending.has(msg.id)) {
|
|
const req = pending.get(msg.id)!;
|
|
pending.delete(msg.id);
|
|
clearTimeout(req.timer);
|
|
req.resolve(msg);
|
|
return;
|
|
}
|
|
|
|
// Otherwise dispatch as event
|
|
dispatchEvent(msg);
|
|
}
|
|
|
|
// ── Lookup ──
|
|
|
|
function findSidecarHandling(kind: string): RegisteredSidecar | undefined {
|
|
for (const sc of sidecars.values()) {
|
|
if (sc.handles.includes(kind)) return sc;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// ── Event dispatch ──
|
|
|
|
function dispatchEvent(msg: SidecarEvent) {
|
|
const handlers = eventHandlers.get(msg.type);
|
|
if (handlers) {
|
|
for (const handler of handlers) {
|
|
try {
|
|
handler(msg);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function on(eventType: string, handler: EventHandler): () => void {
|
|
if (!eventHandlers.has(eventType)) {
|
|
eventHandlers.set(eventType, new Set());
|
|
}
|
|
eventHandlers.get(eventType)!.add(handler);
|
|
return () => {
|
|
eventHandlers.get(eventType)?.delete(handler);
|
|
};
|
|
}
|
|
|
|
// ── Command sending ──
|
|
//
|
|
// ── The generic door, for plugins ──
|
|
//
|
|
// `sendCommand` below is private and its `SidecarCommand` union names every message the PLATFORM knows
|
|
// about. That was fine while every sidecar was the platform's. A plugin's sidecar speaks a vocabulary
|
|
// this file must not learn — the desktop extraction is what made that concrete: `startVnc`,
|
|
// `ensureVncPassword` and a dozen `vnc:*` variants lived here, in the platform, describing a plugin.
|
|
//
|
|
// So a plugin drives its own sidecar through `sendSidecarCommand`, which knows only that a command has a
|
|
// type and an id and that a reply carries the same id. The vocabulary stays in the plugin, and nothing
|
|
// here has to be edited when a plugin adds a message.
|
|
|
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
const LONG_TIMEOUT_MS = 6 * 60 * 1000;
|
|
|
|
/** A message to a sidecar. `id` is what the reply is matched on; everything else is the plugin's. */
|
|
export type PluginSidecarCommand = { type: string; id: string } & Record<string, unknown>;
|
|
|
|
/**
|
|
* Send a command to whichever sidecar handles `kind`, and wait for the reply with the same id.
|
|
*
|
|
* The plugin-facing half of `sendCommand`. Rejects when no sidecar handles that kind — which for a
|
|
* plugin means its own process is not running, and is a better error than a timeout.
|
|
*/
|
|
export function sendSidecarCommand<T = unknown>(
|
|
kind: string,
|
|
cmd: PluginSidecarCommand,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
): Promise<T> {
|
|
return sendCommand(kind, cmd as unknown as SidecarCommand, timeoutMs) as Promise<T>;
|
|
}
|
|
|
|
/** Is a sidecar handling `kind` connected right now? */
|
|
export function isSidecarConnected(kind: string): boolean {
|
|
return !!findSidecarHandling(kind);
|
|
}
|
|
|
|
function sendCommand(kind: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
const sc = findSidecarHandling(kind);
|
|
if (!sc) {
|
|
reject(new Error(`No sidecar handling "${kind}" is connected`));
|
|
return;
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
pending.delete((cmd as any).id);
|
|
reject(new Error(`Sidecar command ${cmd.type} timed out`));
|
|
}, timeoutMs);
|
|
|
|
pending.set((cmd as any).id, { sidecarId: sc.id, resolve, reject, timer });
|
|
sc.ws.send(JSON.stringify(cmd));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fire a command at whichever sidecar handles this kind. Returns whether one was there to take it.
|
|
*
|
|
* It used to return void and drop the command when no sidecar was registered — no log, no error, nothing.
|
|
* That window is real and predictable: every `pm2 restart officer` has one while the sidecars reconnect, and
|
|
* a person clicking a button has no idea they are in it.
|
|
*
|
|
* The cost landed on `claude:question-answer`, whose own handler documents this exact failure and cannot
|
|
* report it, because the message never arrives to be reported. From the person's side the question card just
|
|
* kept spinning — indistinguishable from a slow agent, so the only move left was to send another message.
|
|
*
|
|
* Callers that can tell somebody are now able to. The boolean is the whole mechanism; the warning is for
|
|
* whoever reads the log afterwards wondering why one click did nothing.
|
|
*/
|
|
function sendFire(kind: string, cmd: SidecarCommand): boolean {
|
|
const sc = findSidecarHandling(kind);
|
|
if (!sc) {
|
|
console.warn(`[sidecar] dropped ${cmd.type}: no sidecar registered for "${kind}"`);
|
|
return false;
|
|
}
|
|
sc.ws.send(JSON.stringify(cmd));
|
|
return true;
|
|
}
|
|
|
|
function sendCommandToSidecar(
|
|
sc: RegisteredSidecar,
|
|
cmd: SidecarCommand,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
pending.delete((cmd as any).id);
|
|
reject(new Error(`Sidecar command ${cmd.type} timed out`));
|
|
}, timeoutMs);
|
|
|
|
pending.set((cmd as any).id, { sidecarId: sc.id, resolve, reject, timer });
|
|
sc.ws.send(JSON.stringify(cmd));
|
|
});
|
|
}
|
|
|
|
function sendFireToSidecar(sc: RegisteredSidecar, cmd: SidecarCommand): void {
|
|
sc.ws.send(JSON.stringify(cmd));
|
|
}
|
|
|
|
// ── Waiting for a sidecar to appear ──
|
|
|
|
// Officer no longer spawns any sidecar; PM2 owns every one of them. The only thing left to handle is
|
|
// startup order — PM2 brings `officer` and its peers up together, so the first request after a boot can
|
|
// arrive a beat before the sidecar has finished dialling in. Wait briefly rather than failing the
|
|
// request. (This replaces ~77 lines of spawn-and-poll: `ensureClaudeSidecar`,
|
|
// `spawnAndWaitForRegistration`, and the per-email `claudeProcs`/`claudeSpawnWaiters` maps.)
|
|
const HANDLER_WAIT_MS = 15_000;
|
|
const HANDLER_POLL_MS = 100;
|
|
|
|
async function waitForHandler(kind: string, timeoutMs = HANDLER_WAIT_MS): Promise<RegisteredSidecar> {
|
|
const existing = findSidecarHandling(kind);
|
|
if (existing) return existing;
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
await Bun.sleep(HANDLER_POLL_MS);
|
|
const sc = findSidecarHandling(kind);
|
|
if (sc) return sc;
|
|
}
|
|
throw new Error(`No sidecar handling "${kind}" registered within ${timeoutMs}ms`);
|
|
}
|
|
|
|
// ── Public API ──
|
|
|
|
export function isConnected(): boolean {
|
|
return findSidecarHandling('proxy') !== undefined;
|
|
}
|
|
|
|
export function getCachedState(): ClaudeState | null {
|
|
return cachedState;
|
|
}
|
|
|
|
export async function syncState(): Promise<ClaudeState> {
|
|
const res = await sendCommand('proxy', { type: 'state:sync', id: nextId() });
|
|
if (res.type === 'state:sync') {
|
|
cachedState = res.state;
|
|
return res.state;
|
|
}
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
export async function getProxySecret(): Promise<string> {
|
|
if (cachedState?.proxySecret) return cachedState.proxySecret;
|
|
const res = await sendCommand('proxy', { type: 'proxy:secret', id: nextId() });
|
|
if (res.type === 'proxy:secret') return res.secret;
|
|
throw new Error('Failed to get proxy secret');
|
|
}
|
|
|
|
export function getProxySecretSync(): string {
|
|
return cachedState?.proxySecret ?? '';
|
|
}
|
|
|
|
// ── Claude Code (the `officer-agent` sidecar, handling 'claude') ──
|
|
|
|
// Single-user platform, so there is exactly one agent sidecar and it is found by what it handles, like every
|
|
// other one. The `email` on the params is still passed through to the sidecar — it needs it to resolve
|
|
// paths — but officer no longer uses it to *locate* anything.
|
|
|
|
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
|
|
const sc = await waitForHandler('claude');
|
|
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn', id: nextId(), params }, LONG_TIMEOUT_MS);
|
|
if (res.type === 'claude:result') return res.result;
|
|
if (res.type === 'claude:error') throw new Error(res.error);
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
export async function spawnClaudeStreaming(params: ClaudeSpawnStreamingParams): Promise<void> {
|
|
const sc = await waitForHandler('claude');
|
|
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn-streaming', id: nextId(), params });
|
|
if (res.type === 'claude:spawned') return;
|
|
if (res.type === 'claude:error') throw new Error(res.error);
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
export function killClaude(sessionKey: string, userId: number): void {
|
|
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey, userId });
|
|
}
|
|
|
|
/**
|
|
* Kill a session and wait for the agent to confirm its child process is gone.
|
|
*
|
|
* For callers that then delete the session's FILES. The transcript belongs to the `claude` CLI, not to
|
|
* officer, so removing it while the CLI is still unwinding deletes a file that is written straight back —
|
|
* which resurrects a conversation the user deleted, as an untitled ordinary chat that nothing filters out.
|
|
*
|
|
* Never fatal: a sidecar that is down or slow must not block a delete. The caller proceeds and accepts the
|
|
* race it was already running, which is no worse than before this existed.
|
|
*/
|
|
export async function killClaudeAndWait(sessionKey: string, userId: number): Promise<boolean> {
|
|
try {
|
|
const sc = await waitForHandler('claude');
|
|
const res = await sendCommandToSidecar(sc, { type: 'claude:kill', id: nextId(), sessionKey, userId });
|
|
return res.type === 'claude:killed';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Interrupt the current turn but keep the persistent session warm (the "stop" button).
|
|
export function interruptClaude(sessionKey: string, userId: number): void {
|
|
sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey, userId });
|
|
}
|
|
|
|
// Plan mode, both directions. Fire-and-forget on purpose: the agent's answer to either one comes back
|
|
// through the session log as a `plan:mode` / `plan:decided` message, which is durable, ordered with the
|
|
// rest of the turn, and delivered to every socket watching — none of which a command reply would be.
|
|
export function setClaudePlanMode(sessionKey: string, userId: number, enabled: boolean): void {
|
|
sendFire('claude', { type: 'claude:set-plan-mode', id: nextId(), sessionKey, userId, enabled });
|
|
}
|
|
|
|
export function decideClaudePlan(params: {
|
|
sessionKey: string;
|
|
userId: number;
|
|
requestId: string;
|
|
approve: boolean;
|
|
feedback?: string;
|
|
}): boolean {
|
|
return sendFire('claude', { type: 'claude:plan-decision', id: nextId(), ...params });
|
|
}
|
|
|
|
// The user's answer to an AskUserQuestion. Fire-and-forget for the same reason as the plan decision: the
|
|
// visible result is the tool's own output arriving in the durable log, where every socket watching this
|
|
// conversation sees it — including the one that did not click.
|
|
export function answerClaudeQuestion(params: {
|
|
sessionKey: string;
|
|
userId: number;
|
|
toolCallId: string;
|
|
answer: string;
|
|
}): boolean {
|
|
return sendFire('claude', { type: 'claude:question-answer', id: nextId(), ...params });
|
|
}
|
|
|
|
/**
|
|
* Is the agent still running a turn for this session?
|
|
*
|
|
* Asked of the sidecar because it is the only party that can answer: officer's own memory of a turn dies
|
|
* with `pm2 restart officer` while the turn itself carries on, so "we don't remember it" is not evidence
|
|
* of anything. Used on reconnect to tell those two apart — a turn that outlived a restart from one whose
|
|
* process is gone and is never going to produce another token.
|
|
*
|
|
* Fails *toward alive*: no answer means we don't know, and wrongly telling someone their turn died while
|
|
* it is still typing is worse than leaving a spinner up a little longer. Only a registered agent that
|
|
* says "no", or no agent at all, counts as dead.
|
|
*/
|
|
/**
|
|
* Every session the agent is holding IN MEMORY right now — distinct from `listClaudeSessions` in
|
|
* `api/chat/claude-sessions`, which lists conversations from transcripts on disk. Those are the history;
|
|
* these are the ones with a process behind them.
|
|
*
|
|
* Officer's own session records live in memory and die with `pm2 restart officer`, while the agent — a
|
|
* PM2 peer, not a child — keeps running and keeps committing to chat_session_events. Until this existed
|
|
* a surviving session was invisible: `adoptOrphanedSession` only fires when a browser reconnects to one
|
|
* *by id*, so nothing could answer "what is still running".
|
|
*
|
|
* Fails *toward empty*, unlike `isClaudeGenerating` which fails toward alive. The asymmetry is
|
|
* deliberate: there, not knowing means leaving a spinner up; here, not knowing would mean inventing
|
|
* sessions, and an enumeration that reports things that may not exist is worse than a short one.
|
|
*/
|
|
export async function listLiveClaudeSessions(userId: number): Promise<LiveClaudeSession[]> {
|
|
const sc = findSidecarHandling('claude');
|
|
if (!sc) return [];
|
|
try {
|
|
const res = await sendCommandToSidecar(sc, { type: 'claude:list', id: nextId(), userId });
|
|
return res.type === 'claude:sessions' ? res.sessions : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The OpenCode turns running right now, from the sidecar's own map.
|
|
*
|
|
* Fails toward EMPTY, matching `listLiveClaudeSessions` and for the same reason: an enumeration that
|
|
* invents sessions is worse than a short one. A sidecar that is down or does not understand the verb
|
|
* (an older build) simply contributes nothing to the Live panel rather than breaking it.
|
|
*/
|
|
export async function listLiveOpenCodeSessions(): Promise<LiveOpenCodeSession[]> {
|
|
const sc = findSidecarHandling('opencode');
|
|
if (!sc) return [];
|
|
try {
|
|
const res = await sendCommandToSidecar(sc, { type: 'opencode:list', id: nextId() });
|
|
return res.type === 'opencode:sessions' ? res.sessions : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function isClaudeGenerating(sessionKey: string, userId: number): Promise<boolean> {
|
|
const sc = findSidecarHandling('claude');
|
|
if (!sc) return false;
|
|
try {
|
|
const res = await sendCommandToSidecar(sc, { type: 'claude:is-generating', id: nextId(), sessionKey, userId });
|
|
return res.type === 'claude:generating' ? res.generating : true;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Officer's session key for a Claude transcript uuid, or null if the agent has never seen it.
|
|
*
|
|
* The browser only ever has the uuid after a refresh — it is what the URL carries — and officer's own
|
|
* key is not derivable from it. The agent's on-disk map is the single record that relates them, so this
|
|
* is the hinge the whole reattach path turns on.
|
|
*
|
|
* Fails toward null: no agent, no answer, or a timeout all mean "cannot re-bind", and the caller falls
|
|
* back to today's behaviour of leaving the socket unattached rather than binding it to a guess.
|
|
*/
|
|
export async function findClaudeSessionKey(claudeSessionId: string, userId: number): Promise<string | null> {
|
|
const sc = findSidecarHandling('claude');
|
|
if (!sc) return null;
|
|
try {
|
|
const res = await sendCommandToSidecar(sc, { type: 'claude:find-session', id: nextId(), claudeSessionId, userId });
|
|
return res.type === 'claude:session-key' ? res.sessionKey : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function clearClaudeSession(sessionKey: string, userId: number): void {
|
|
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey, userId });
|
|
}
|
|
|
|
// Turn output arrives finished and already durable: the agent translated it and committed it to
|
|
// chat_session_events, and `seq` is its cursor id there. Officer relays it — it no longer builds or
|
|
// persists chat messages for this harness.
|
|
export function onClaudeMessage(handler: (sessionKey: string, msg: TurnMessage, seq?: number) => void): () => void {
|
|
return on('claude:message', (ev) => {
|
|
if (ev.type !== 'claude:message') return;
|
|
const msg = ev as SidecarEvent & { type: 'claude:message' };
|
|
handler(msg.sessionKey, msg.msg, msg.seq);
|
|
});
|
|
}
|
|
|
|
// See `PersistentSession.notify` in claude-manager.ts — a separate, non-durable ping, not part of the
|
|
// transcript the message above relays.
|
|
export function onClaudeNotify(
|
|
handler: (sessionKey: string, userId: number, kind: 'turn-ended' | 'input-needed') => void,
|
|
): () => void {
|
|
return on('claude:notify', (ev) => {
|
|
if (ev.type !== 'claude:notify') return;
|
|
handler(ev.sessionKey, ev.userId, ev.kind);
|
|
});
|
|
}
|
|
|
|
// ── OpenCode Code (single sidecar, handling 'opencode') ──
|
|
|
|
export async function spawnOpenCodeStreaming(params: OpenCodeRunParams): Promise<void> {
|
|
const res = await sendCommand('opencode', { type: 'opencode:run-streaming', id: nextId(), params });
|
|
if (res.type === 'opencode:spawned') return;
|
|
if (res.type === 'opencode:error') throw new Error(res.error);
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
export function killOpenCode(sessionKey: string): void {
|
|
sendFire('opencode', { type: 'opencode:kill', id: nextId(), sessionKey });
|
|
}
|
|
|
|
export function onOpenCodeMessage(handler: (sessionKey: string, msg: TurnMessage, seq?: number) => void): () => void {
|
|
return on('opencode:message', (ev) => {
|
|
if (ev.type !== 'opencode:message') return;
|
|
const msg = ev as SidecarEvent & { type: 'opencode:message' };
|
|
handler(msg.sessionKey, msg.msg, msg.seq);
|
|
});
|
|
}
|
|
|
|
export function onOpenCodeSession(handler: (sessionKey: string, sessionId: string) => void): () => void {
|
|
return on('opencode:session', (msg) => {
|
|
if (msg.type === 'opencode:session') {
|
|
handler(
|
|
(msg as SidecarEvent & { type: 'opencode:session' }).sessionKey,
|
|
(msg as SidecarEvent & { type: 'opencode:session' }).sessionId,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Terminal (PTY sidecar) ──
|
|
//
|
|
// Nothing here any more. The pty sidecar serves its own listener; `/api/terminal/*` is a proxy and
|
|
// `/api/terminal/ws` a byte relay, both keyed off the `pty:server` port like every other HTTP sidecar.
|
|
|
|
// The VNC helpers that were here — startVnc, ensureVncPassword, stopVnc, isVncConnected — left with the
|
|
// desktop plugin on 2026-08-21. They were the clearest example of the platform knowing what a plugin was
|
|
// about: four functions and a dozen `vnc:*` message types describing a feature that is not the
|
|
// platform's. What replaced them is `sendSidecarCommand` above, which is generic.
|