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:
@@ -474,7 +474,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'music',
|
name: 'music',
|
||||||
capabilities: ['music'],
|
handles: ['music'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'headscale',
|
name: 'headscale',
|
||||||
capabilities: ['headscale'],
|
handles: ['headscale'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-5
@@ -146,7 +146,7 @@ const handlers: Record<string, any> = {
|
|||||||
sidecar: sidecarWebsocket,
|
sidecar: sidecarWebsocket,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Both doors into the platform, checked against the capability registry before either opens.
|
// Both doors into the platform, checked against the permission registry before either opens.
|
||||||
//
|
//
|
||||||
// This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated
|
// This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated
|
||||||
// cannot be reached even once. The HTTP half comes from hono.ts's own mount table and the socket half
|
// cannot be reached even once. The HTTP half comes from hono.ts's own mount table and the socket half
|
||||||
@@ -283,14 +283,14 @@ async function upgradeWs(
|
|||||||
if (await isTokenBlacklisted(user.jti)) return new Response('Unauthorized', { status: 401 });
|
if (await isTokenBlacklisted(user.jti)) return new Response('Unauthorized', { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// The capability backstop, applied to sockets. Everything above this line AUTHENTICATES — it proves
|
// The permission backstop, applied to sockets. Everything above this line AUTHENTICATES — it proves
|
||||||
// who is calling and never asks what they may reach. That is why a Member with a valid token could
|
// who is calling and never asks what they may reach. That is why a Member with a valid token could
|
||||||
// open a terminal here in the same minute it was 403'd on GET /api/tasks.
|
// open a terminal here in the same minute it was 403'd on GET /api/tasks.
|
||||||
//
|
//
|
||||||
// This resolves against the same registry as the HTTP door rather than a parallel list, which is the
|
// This resolves against the same registry as the HTTP door rather than a parallel list, which is the
|
||||||
// whole point: the two doors cannot disagree about what a role holds, because there is only one
|
// whole point: the two doors cannot disagree about what a role holds, because there is only one
|
||||||
// declaration to read. `terminal`, `chat`, `task-runner`, `pipeline` and `desktop` are refused here
|
// declaration to read. `terminal`, `chat`, `task-runner`, `pipeline` and `desktop` are refused here
|
||||||
// by being `execution` capabilities, not by being absent from an array someone has to maintain.
|
// by being `execution` permissions, not by being absent from an array someone has to maintain.
|
||||||
if (!(await isWsProviderAllowed(user.id, provider))) {
|
if (!(await isWsProviderAllowed(user.id, provider))) {
|
||||||
return new Response('Forbidden', { status: 403 });
|
return new Response('Forbidden', { status: 403 });
|
||||||
}
|
}
|
||||||
@@ -316,14 +316,14 @@ async function upgradeWs(
|
|||||||
|
|
||||||
// The chat socket's owner-only refusal was removed on 2026-08-12, with `api/chat/chat.ts`'s in the same
|
// The chat socket's owner-only refusal was removed on 2026-08-12, with `api/chat/chat.ts`'s in the same
|
||||||
// commit — they were always one guard in two places. A member's turn now runs as their own Linux account
|
// commit — they were always one guard in two places. A member's turn now runs as their own Linux account
|
||||||
// with their own credential and their own transcripts; the capability check above is what gates it.
|
// with their own credential and their own transcripts; the permission check above is what gates it.
|
||||||
|
|
||||||
if (provider === 'terminal') {
|
if (provider === 'terminal') {
|
||||||
const resolved = await resolveHomeDir(user.id);
|
const resolved = await resolveHomeDir(user.id);
|
||||||
if (!resolved.ok) return new Response('Forbidden', { status: 403 });
|
if (!resolved.ok) return new Response('Forbidden', { status: 403 });
|
||||||
if (!resolved.isOwner) {
|
if (!resolved.isOwner) {
|
||||||
const dbUser = await getUserById(user.id);
|
const dbUser = await getUserById(user.id);
|
||||||
// A confined capability is only granted to an account with an OS user, so this should not happen —
|
// A confined permission is only granted to an account with an OS user, so this should not happen —
|
||||||
// and if it ever does, refusing beats opening the owner's shell.
|
// and if it ever does, refusing beats opening the owner's shell.
|
||||||
if (!dbUser?.osUser) return new Response('Forbidden', { status: 403 });
|
if (!dbUser?.osUser) return new Response('Forbidden', { status: 403 });
|
||||||
url.searchParams.set('osUser', dbUser.osUser);
|
url.searchParams.set('osUser', dbUser.osUser);
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ function silentSocket() {
|
|||||||
const registered: string[] = [];
|
const registered: string[] = [];
|
||||||
|
|
||||||
/** Register a fake sidecar and remember it, so a failed assertion can't leak it into the next test. */
|
/** Register a fake sidecar and remember it, so a failed assertion can't leak it into the next test. */
|
||||||
function register(name: string, capabilities: string[]) {
|
function register(name: string, handles: string[]) {
|
||||||
const socket = silentSocket();
|
const socket = silentSocket();
|
||||||
const id = registerSidecar(socket.ws, { type: 'register', name, capabilities });
|
const id = registerSidecar(socket.ws, { type: 'register', name, handles });
|
||||||
registered.push(id);
|
registered.push(id);
|
||||||
return { ...socket, id };
|
return { ...socket, id };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type { TurnMessage } from './api/chat/types';
|
|||||||
type RegisteredSidecar = {
|
type RegisteredSidecar = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
capabilities: string[];
|
handles: string[];
|
||||||
ws: ServerWebSocket<any>;
|
ws: ServerWebSocket<any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,18 +61,18 @@ export function registerSidecar(ws: ServerWebSocket<any>, registration: SidecarR
|
|||||||
sidecars.set(id, {
|
sidecars.set(id, {
|
||||||
id,
|
id,
|
||||||
name: registration.name,
|
name: registration.name,
|
||||||
capabilities: registration.capabilities,
|
handles: registration.handles,
|
||||||
ws,
|
ws,
|
||||||
});
|
});
|
||||||
console.log(
|
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
|
// 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
|
// 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
|
// 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.
|
// 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) {
|
for (const handler of claudeRestartHandlers) {
|
||||||
try {
|
try {
|
||||||
handler();
|
handler();
|
||||||
@@ -110,7 +110,7 @@ export function unregisterSidecar(id: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear cached state if the claude sidecar disconnects
|
// Clear cached state if the claude sidecar disconnects
|
||||||
if (sc.capabilities.includes('proxy')) {
|
if (sc.handles.includes('proxy')) {
|
||||||
cachedState = null;
|
cachedState = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,9 +131,9 @@ export function handleSidecarMessage(id: string, msg: SidecarEvent): void {
|
|||||||
|
|
||||||
// ── Lookup ──
|
// ── Lookup ──
|
||||||
|
|
||||||
function findSidecarByCapability(cap: string): RegisteredSidecar | undefined {
|
function findSidecarHandling(kind: string): RegisteredSidecar | undefined {
|
||||||
for (const sc of sidecars.values()) {
|
for (const sc of sidecars.values()) {
|
||||||
if (sc.capabilities.includes(cap)) return sc;
|
if (sc.handles.includes(kind)) return sc;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -168,11 +168,11 @@ export function on(eventType: string, handler: EventHandler): () => void {
|
|||||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||||
const LONG_TIMEOUT_MS = 6 * 60 * 1000;
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const sc = findSidecarByCapability(cap);
|
const sc = findSidecarHandling(kind);
|
||||||
if (!sc) {
|
if (!sc) {
|
||||||
reject(new Error(`No sidecar with capability "${cap}" is connected`));
|
reject(new Error(`No sidecar handling "${kind}" is connected`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,8 +186,8 @@ function sendCommand(cap: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEO
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendFire(cap: string, cmd: SidecarCommand): void {
|
function sendFire(kind: string, cmd: SidecarCommand): void {
|
||||||
const sc = findSidecarByCapability(cap);
|
const sc = findSidecarHandling(kind);
|
||||||
if (sc) {
|
if (sc) {
|
||||||
sc.ws.send(JSON.stringify(cmd));
|
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
|
// 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`,
|
// request. (This replaces ~77 lines of spawn-and-poll: `ensureClaudeSidecar`,
|
||||||
// `spawnAndWaitForRegistration`, and the per-email `claudeProcs`/`claudeSpawnWaiters` maps.)
|
// `spawnAndWaitForRegistration`, and the per-email `claudeProcs`/`claudeSpawnWaiters` maps.)
|
||||||
const CAPABILITY_WAIT_MS = 15_000;
|
const HANDLER_WAIT_MS = 15_000;
|
||||||
const CAPABILITY_POLL_MS = 100;
|
const HANDLER_POLL_MS = 100;
|
||||||
|
|
||||||
async function waitForCapability(cap: string, timeoutMs = CAPABILITY_WAIT_MS): Promise<RegisteredSidecar> {
|
async function waitForHandler(kind: string, timeoutMs = HANDLER_WAIT_MS): Promise<RegisteredSidecar> {
|
||||||
const existing = findSidecarByCapability(cap);
|
const existing = findSidecarHandling(kind);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
await Bun.sleep(CAPABILITY_POLL_MS);
|
await Bun.sleep(HANDLER_POLL_MS);
|
||||||
const sc = findSidecarByCapability(cap);
|
const sc = findSidecarHandling(kind);
|
||||||
if (sc) return sc;
|
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 ──
|
// ── Public API ──
|
||||||
|
|
||||||
export function isConnected(): boolean {
|
export function isConnected(): boolean {
|
||||||
return findSidecarByCapability('proxy') !== undefined;
|
return findSidecarHandling('proxy') !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCachedState(): ClaudeState | null {
|
export function getCachedState(): ClaudeState | null {
|
||||||
@@ -266,14 +266,14 @@ export function getProxySecretSync(): string {
|
|||||||
return cachedState?.proxySecret ?? '';
|
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
|
// 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.
|
// paths — but officer no longer uses it to *locate* anything.
|
||||||
|
|
||||||
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
|
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);
|
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:result') return res.result;
|
||||||
if (res.type === 'claude:error') throw new Error(res.error);
|
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> {
|
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 });
|
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn-streaming', id: nextId(), params });
|
||||||
if (res.type === 'claude:spawned') return;
|
if (res.type === 'claude:spawned') return;
|
||||||
if (res.type === 'claude:error') throw new Error(res.error);
|
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.
|
* 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[]> {
|
export async function listLiveClaudeSessions(userId: number): Promise<LiveClaudeSession[]> {
|
||||||
const sc = findSidecarByCapability('claude');
|
const sc = findSidecarHandling('claude');
|
||||||
if (!sc) return [];
|
if (!sc) return [];
|
||||||
try {
|
try {
|
||||||
const res = await sendCommandToSidecar(sc, { type: 'claude:list', id: nextId(), userId });
|
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.
|
* (an older build) simply contributes nothing to the Live panel rather than breaking it.
|
||||||
*/
|
*/
|
||||||
export async function listLiveOpenCodeSessions(): Promise<LiveOpenCodeSession[]> {
|
export async function listLiveOpenCodeSessions(): Promise<LiveOpenCodeSession[]> {
|
||||||
const sc = findSidecarByCapability('opencode');
|
const sc = findSidecarHandling('opencode');
|
||||||
if (!sc) return [];
|
if (!sc) return [];
|
||||||
try {
|
try {
|
||||||
const res = await sendCommandToSidecar(sc, { type: 'opencode:list', id: nextId() });
|
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> {
|
export async function isClaudeGenerating(sessionKey: string, userId: number): Promise<boolean> {
|
||||||
const sc = findSidecarByCapability('claude');
|
const sc = findSidecarHandling('claude');
|
||||||
if (!sc) return false;
|
if (!sc) return false;
|
||||||
try {
|
try {
|
||||||
const res = await sendCommandToSidecar(sc, { type: 'claude:is-generating', id: nextId(), sessionKey, userId });
|
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.
|
* 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> {
|
export async function findClaudeSessionKey(claudeSessionId: string, userId: number): Promise<string | null> {
|
||||||
const sc = findSidecarByCapability('claude');
|
const sc = findSidecarHandling('claude');
|
||||||
if (!sc) return null;
|
if (!sc) return null;
|
||||||
try {
|
try {
|
||||||
const res = await sendCommandToSidecar(sc, { type: 'claude:find-session', id: nextId(), claudeSessionId, userId });
|
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> {
|
export async function spawnOpenCodeStreaming(params: OpenCodeRunParams): Promise<void> {
|
||||||
const res = await sendCommand('opencode', { type: 'opencode:run-streaming', id: nextId(), params });
|
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 {
|
export function isVncConnected(): boolean {
|
||||||
return findSidecarByCapability('vnc') !== undefined;
|
return findSidecarHandling('vnc') !== undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'caldav',
|
name: 'caldav',
|
||||||
capabilities: ['caldav'],
|
handles: ['caldav'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'proxy',
|
name: 'proxy',
|
||||||
capabilities: ['proxy'],
|
handles: ['proxy'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -334,13 +334,13 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
|
|
||||||
// ── Connect to API server ──
|
// ── Connect to API server ──
|
||||||
|
|
||||||
// A stable identity, not a per-email one. Officer looks this sidecar up by the 'claude' capability, so
|
// A stable identity, not a per-email one. Officer looks this sidecar up by the 'claude' handler, so
|
||||||
// it no longer needs to know which user is running to find it — that was the last thing tying the
|
// it no longer needs to know which user is running to find it — that was the last thing tying the
|
||||||
// registry's claude verbs to an email argument.
|
// registry's claude verbs to an email argument.
|
||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'agent',
|
name: 'agent',
|
||||||
capabilities: ['claude'],
|
handles: ['claude'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type AnyEvent = SidecarEvent;
|
|||||||
type SidecarConnectorConfig = {
|
type SidecarConnectorConfig = {
|
||||||
apiUrl: string; // ws://127.0.0.1:9000/api/sidecar/register
|
apiUrl: string; // ws://127.0.0.1:9000/api/sidecar/register
|
||||||
name: string;
|
name: string;
|
||||||
capabilities: string[];
|
handles: string[];
|
||||||
onCommand: (cmd: AnyCommand, reply: (msg: AnyEvent) => void) => void;
|
onCommand: (cmd: AnyCommand, reply: (msg: AnyEvent) => void) => void;
|
||||||
onConnected?: () => void;
|
onConnected?: () => void;
|
||||||
onDisconnected?: () => void;
|
onDisconnected?: () => void;
|
||||||
@@ -47,7 +47,7 @@ export function createSidecarConnector(config: SidecarConnectorConfig): SidecarC
|
|||||||
const registration: SidecarRegistration = {
|
const registration: SidecarRegistration = {
|
||||||
type: 'register',
|
type: 'register',
|
||||||
name: config.name,
|
name: config.name,
|
||||||
capabilities: config.capabilities,
|
handles: config.handles,
|
||||||
};
|
};
|
||||||
ws!.send(JSON.stringify(registration));
|
ws!.send(JSON.stringify(registration));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const serverPort = startEmailServer();
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'email',
|
name: 'email',
|
||||||
capabilities: ['email'],
|
handles: ['email'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as Record<string, unknown>, reply as ReplyFn);
|
handleCommand(cmd as Record<string, unknown>, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'gitea',
|
name: 'gitea',
|
||||||
capabilities: ['gitea'],
|
handles: ['gitea'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'invoiceshelf',
|
name: 'invoiceshelf',
|
||||||
capabilities: ['invoiceshelf'],
|
handles: ['invoiceshelf'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'jellyfin',
|
name: 'jellyfin',
|
||||||
capabilities: ['jellyfin'],
|
handles: ['jellyfin'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'memos',
|
name: 'memos',
|
||||||
capabilities: ['memos'],
|
handles: ['memos'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ console.log(
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'notify',
|
name: 'notify',
|
||||||
capabilities: ['notify'],
|
handles: ['notify'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
const c = cmd as SidecarCommand;
|
const c = cmd as SidecarCommand;
|
||||||
if (c.type === 'ping') return reply({ type: 'pong', id: c.id });
|
if (c.type === 'ping') return reply({ type: 'pong', id: c.id });
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'opencode',
|
name: 'opencode',
|
||||||
capabilities: ['opencode'],
|
handles: ['opencode'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'photos',
|
name: 'photos',
|
||||||
capabilities: ['photos'],
|
handles: ['photos'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function connect() {
|
|||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
reconnectAttempt = 0;
|
reconnectAttempt = 0;
|
||||||
console.log('[pty-sidecar] connected, sending registration...');
|
console.log('[pty-sidecar] connected, sending registration...');
|
||||||
sendJson({ type: 'register', name: 'pty', capabilities: ['terminal'] });
|
sendJson({ type: 'register', name: 'pty', handles: ['terminal'] });
|
||||||
// Re-announce on every reconnect: officer forgets the port when the socket drops, and this process
|
// Re-announce on every reconnect: officer forgets the port when the socket drops, and this process
|
||||||
// keeps the same listener across officer restarts.
|
// keeps the same listener across officer restarts.
|
||||||
if (serverPort) sendJson({ type: 'pty:server', port: serverPort });
|
if (serverPort) sendJson({ type: 'pty:server', port: serverPort });
|
||||||
|
|||||||
@@ -4,7 +4,17 @@
|
|||||||
export type SidecarRegistration = {
|
export type SidecarRegistration = {
|
||||||
type: 'register';
|
type: 'register';
|
||||||
name: string; // 'process' | 'pty' | custom
|
name: string; // 'process' | 'pty' | custom
|
||||||
capabilities: string[]; // ['claude', 'pi', 'queue', 'proxy'] or ['terminal']
|
/**
|
||||||
|
* What this sidecar can be sent. `sendCommand('claude', …)` finds the process that HANDLES that kind,
|
||||||
|
* which is why the list is not the same thing as the sidecar's name — one process can handle several.
|
||||||
|
*
|
||||||
|
* Called `handles` since 2026-08-15. It was `capabilities`, which collided with the two other things
|
||||||
|
* that word means here: the permission registry (now `permissions/`) and the item store under
|
||||||
|
* `$OFFICER_ROOT/capabilities/`, which is what capabilities actually are. Nothing about routing a
|
||||||
|
* command to a process was ever a capability in that sense; it is a list of what the process answers to,
|
||||||
|
* and `handleCommand` in every sidecar is where it answers.
|
||||||
|
*/
|
||||||
|
handles: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// API server → sidecar ack
|
// API server → sidecar ack
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'slskd',
|
name: 'slskd',
|
||||||
capabilities: ['slskd'],
|
handles: ['slskd'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'transmission',
|
name: 'transmission',
|
||||||
capabilities: ['transmission'],
|
handles: ['transmission'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'vault',
|
name: 'vault',
|
||||||
capabilities: ['vault'],
|
handles: ['vault'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'vnc',
|
name: 'vnc',
|
||||||
capabilities: ['vnc'],
|
handles: ['vnc'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'wallet',
|
name: 'wallet',
|
||||||
capabilities: ['wallet'],
|
handles: ['wallet'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user