step 3/4: sidecar routing keys become handles — NOT YET VERIFIED LIVE
Committed before restarting, deliberately: this changes the registration wire, and the restart that proves it also bounces officer-claude-code, which is what the session doing the work runs on. An uncommitted 25-file protocol change is worse to inherit than one marked unverified. `capabilities: ['music']` → `handles: ['music']` on the registration message, across 20 sidecars, both plugins, the connector, the registry and the protocol type. findSidecarByCapability → findSidecarHandling, waitForCapability → waitForHandler. The name: a sidecar already has `handleCommand`, so the list is literally what it handles. `provider` was rejected — 390 existing uses and it already means websocket door. `serves` was rejected — collides with HTTP serving, which sidecars also do. THIS IS A BREAKING WIRE CHANGE with no compatibility shim. A platform expecting `handles` reads `undefined` from a sidecar sending `capabilities`, registers it with an empty list, and every sendCommand finds nobody — chat, terminal and music all fail with "No sidecar handling X is connected". So the whole estate has to restart together; there is no rolling upgrade. If it goes wrong: `git revert HEAD` and `pm2 restart all` again. The registry is in-memory and nothing about this touches the database, so a revert is complete. Found a fifth meaning of the word on the way, correctly named and untouched: the pty sidecar's terminfo capability queries (XTGETTCAP escape sequences). That is now five — permissions, the item store, routing keys, Lightning wallet features, and terminfo. tsgo clean, 797 tests, 787 pass, same 7. Not exercised against a live sidecar.
This commit is contained in:
@@ -19,7 +19,7 @@ import type { TurnMessage } from './api/chat/types';
|
||||
type RegisteredSidecar = {
|
||||
id: string;
|
||||
name: string;
|
||||
capabilities: string[];
|
||||
handles: string[];
|
||||
ws: ServerWebSocket<any>;
|
||||
};
|
||||
|
||||
@@ -61,18 +61,18 @@ export function registerSidecar(ws: ServerWebSocket<any>, registration: SidecarR
|
||||
sidecars.set(id, {
|
||||
id,
|
||||
name: registration.name,
|
||||
capabilities: registration.capabilities,
|
||||
handles: registration.handles,
|
||||
ws,
|
||||
});
|
||||
console.log(
|
||||
`[registry] registered sidecar "${registration.name}" (id=${id}, capabilities=[${registration.capabilities.join(', ')}])`,
|
||||
`[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.capabilities.includes('claude')) {
|
||||
if (registration.handles.includes('claude')) {
|
||||
for (const handler of claudeRestartHandlers) {
|
||||
try {
|
||||
handler();
|
||||
@@ -110,7 +110,7 @@ export function unregisterSidecar(id: string): void {
|
||||
}
|
||||
|
||||
// Clear cached state if the claude sidecar disconnects
|
||||
if (sc.capabilities.includes('proxy')) {
|
||||
if (sc.handles.includes('proxy')) {
|
||||
cachedState = null;
|
||||
}
|
||||
}
|
||||
@@ -131,9 +131,9 @@ export function handleSidecarMessage(id: string, msg: SidecarEvent): void {
|
||||
|
||||
// ── Lookup ──
|
||||
|
||||
function findSidecarByCapability(cap: string): RegisteredSidecar | undefined {
|
||||
function findSidecarHandling(kind: string): RegisteredSidecar | undefined {
|
||||
for (const sc of sidecars.values()) {
|
||||
if (sc.capabilities.includes(cap)) return sc;
|
||||
if (sc.handles.includes(kind)) return sc;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -168,11 +168,11 @@ export function on(eventType: string, handler: EventHandler): () => void {
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const LONG_TIMEOUT_MS = 6 * 60 * 1000;
|
||||
|
||||
function sendCommand(cap: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<any> {
|
||||
function sendCommand(kind: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sc = findSidecarByCapability(cap);
|
||||
const sc = findSidecarHandling(kind);
|
||||
if (!sc) {
|
||||
reject(new Error(`No sidecar with capability "${cap}" is connected`));
|
||||
reject(new Error(`No sidecar handling "${kind}" is connected`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -186,8 +186,8 @@ function sendCommand(cap: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEO
|
||||
});
|
||||
}
|
||||
|
||||
function sendFire(cap: string, cmd: SidecarCommand): void {
|
||||
const sc = findSidecarByCapability(cap);
|
||||
function sendFire(kind: string, cmd: SidecarCommand): void {
|
||||
const sc = findSidecarHandling(kind);
|
||||
if (sc) {
|
||||
sc.ws.send(JSON.stringify(cmd));
|
||||
}
|
||||
@@ -220,26 +220,26 @@ function sendFireToSidecar(sc: RegisteredSidecar, cmd: SidecarCommand): void {
|
||||
// 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 CAPABILITY_WAIT_MS = 15_000;
|
||||
const CAPABILITY_POLL_MS = 100;
|
||||
const HANDLER_WAIT_MS = 15_000;
|
||||
const HANDLER_POLL_MS = 100;
|
||||
|
||||
async function waitForCapability(cap: string, timeoutMs = CAPABILITY_WAIT_MS): Promise<RegisteredSidecar> {
|
||||
const existing = findSidecarByCapability(cap);
|
||||
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(CAPABILITY_POLL_MS);
|
||||
const sc = findSidecarByCapability(cap);
|
||||
await Bun.sleep(HANDLER_POLL_MS);
|
||||
const sc = findSidecarHandling(kind);
|
||||
if (sc) return sc;
|
||||
}
|
||||
throw new Error(`No sidecar with capability "${cap}" registered within ${timeoutMs}ms`);
|
||||
throw new Error(`No sidecar handling "${kind}" registered within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
export function isConnected(): boolean {
|
||||
return findSidecarByCapability('proxy') !== undefined;
|
||||
return findSidecarHandling('proxy') !== undefined;
|
||||
}
|
||||
|
||||
export function getCachedState(): ClaudeState | null {
|
||||
@@ -266,14 +266,14 @@ export function getProxySecretSync(): string {
|
||||
return cachedState?.proxySecret ?? '';
|
||||
}
|
||||
|
||||
// ── Claude Code (the `officer-agent` sidecar, capability 'claude') ──
|
||||
// ── Claude Code (the `officer-agent` sidecar, handling 'claude') ──
|
||||
|
||||
// Single-user platform, so there is exactly one agent sidecar and it is found by capability like every
|
||||
// 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 waitForCapability('claude');
|
||||
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);
|
||||
@@ -281,7 +281,7 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
|
||||
}
|
||||
|
||||
export async function spawnClaudeStreaming(params: ClaudeSpawnStreamingParams): Promise<void> {
|
||||
const sc = await waitForCapability('claude');
|
||||
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);
|
||||
@@ -324,7 +324,7 @@ export function interruptClaude(sessionKey: string, userId: number): void {
|
||||
* 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 = findSidecarByCapability('claude');
|
||||
const sc = findSidecarHandling('claude');
|
||||
if (!sc) return [];
|
||||
try {
|
||||
const res = await sendCommandToSidecar(sc, { type: 'claude:list', id: nextId(), userId });
|
||||
@@ -342,7 +342,7 @@ export async function listLiveClaudeSessions(userId: number): Promise<LiveClaude
|
||||
* (an older build) simply contributes nothing to the Live panel rather than breaking it.
|
||||
*/
|
||||
export async function listLiveOpenCodeSessions(): Promise<LiveOpenCodeSession[]> {
|
||||
const sc = findSidecarByCapability('opencode');
|
||||
const sc = findSidecarHandling('opencode');
|
||||
if (!sc) return [];
|
||||
try {
|
||||
const res = await sendCommandToSidecar(sc, { type: 'opencode:list', id: nextId() });
|
||||
@@ -353,7 +353,7 @@ export async function listLiveOpenCodeSessions(): Promise<LiveOpenCodeSession[]>
|
||||
}
|
||||
|
||||
export async function isClaudeGenerating(sessionKey: string, userId: number): Promise<boolean> {
|
||||
const sc = findSidecarByCapability('claude');
|
||||
const sc = findSidecarHandling('claude');
|
||||
if (!sc) return false;
|
||||
try {
|
||||
const res = await sendCommandToSidecar(sc, { type: 'claude:is-generating', id: nextId(), sessionKey, userId });
|
||||
@@ -374,7 +374,7 @@ export async function isClaudeGenerating(sessionKey: string, userId: number): Pr
|
||||
* 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 = findSidecarByCapability('claude');
|
||||
const sc = findSidecarHandling('claude');
|
||||
if (!sc) return null;
|
||||
try {
|
||||
const res = await sendCommandToSidecar(sc, { type: 'claude:find-session', id: nextId(), claudeSessionId, userId });
|
||||
@@ -399,7 +399,7 @@ export function onClaudeMessage(handler: (sessionKey: string, msg: TurnMessage,
|
||||
});
|
||||
}
|
||||
|
||||
// ── OpenCode Code (single sidecar, capability 'opencode') ──
|
||||
// ── 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 });
|
||||
@@ -459,5 +459,5 @@ export function stopVnc(email: string): void {
|
||||
}
|
||||
|
||||
export function isVncConnected(): boolean {
|
||||
return findSidecarByCapability('vnc') !== undefined;
|
||||
return findSidecarHandling('vnc') !== undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user