Merge branch 'sidecars-claude' into sidecars

This commit is contained in:
2026-07-30 05:43:05 +00:00
20 changed files with 1129 additions and 182 deletions
@@ -0,0 +1,182 @@
import { describe, test, expect } from 'bun:test';
import { createSessionLogStore, type Delivery, type EventWriter } from './session-log';
import type { ChatEvent, MessageCost } from '../../api/chat/types';
const COST: MessageCost = { inputTokens: 1, outputTokens: 2, totalUSD: 0.0001 };
/**
* A writer that finishes its appends in the WORST possible order — the last one first — so the test
* fails unless the store serialises commits itself. `lastSeq` starts empty (a fresh session) unless a
* seed is given.
*/
function reverseOrderWriter(opts: { seed?: number; failOn?: (n: number) => boolean } = {}) {
const pending: Array<{ resolve: (seq: number) => void; reject: (err: Error) => void }> = [];
let next = 100;
const writes: Array<{ sessionId: string; type: string; prevSeq?: number }> = [];
const writer: EventWriter = {
lastSeq: async () => opts.seed,
append: (sessionId, msg) => {
writes.push({ sessionId, type: msg.type, prevSeq: msg.prevSeq });
return new Promise<number>((resolve, reject) => pending.push({ resolve, reject }));
},
};
// Settle everything queued so far, newest first.
function settleAll() {
const batch = pending.splice(0).reverse();
for (const p of batch) {
const n = next++;
if (opts.failOn?.(n)) p.reject(new Error(`write ${n} failed`));
else p.resolve(n);
}
}
return { writer, settleAll, writes, pendingCount: () => pending.length };
}
function collector() {
const got: Delivery[] = [];
return { got, deliver: (d: Delivery) => got.push(d) };
}
/** Let the store's promise chain drain, settling writes as they queue up. */
async function drain(settleAll: () => void, pendingCount: () => number) {
for (let i = 0; i < 50; i++) {
await Promise.resolve();
if (pendingCount() > 0) settleAll();
}
}
const TURN: ChatEvent[] = [
{ type: 'delta', text: 'Let me look.' },
{ type: 'tool:start', toolCallId: 't1', toolName: 'Read', toolInput: { file: 'a.ts' } },
{ type: 'tool:result', toolCallId: 't1', output: 'ok', isError: false },
{ type: 'delta', text: 'Found it.' },
{ type: 'result', cost: COST },
];
describe('createSessionLogStore', () => {
test('deliveries keep push order even when the writes finish backwards', async () => {
const { got, deliver } = collector();
const { writer, settleAll, pendingCount } = reverseOrderWriter();
const store = createSessionLogStore(deliver, writer);
for (const e of TURN) store.push('s1', e);
await drain(settleAll, pendingCount);
expect(got.map((d) => d.msg.type)).toEqual([
'assistant:delta',
'assistant:text',
'tool:start',
'tool:result',
'assistant:delta',
'assistant:text',
'result',
]);
});
test('cursors ascend and each durable message chains to the one before it', async () => {
const { got, deliver } = collector();
const { writer, settleAll, pendingCount } = reverseOrderWriter();
const store = createSessionLogStore(deliver, writer);
for (const e of TURN) store.push('s1', e);
await drain(settleAll, pendingCount);
const durable = got.filter((d) => d.seq !== undefined);
expect(durable).toHaveLength(5);
const seqs = durable.map((d) => d.seq!);
expect([...seqs].sort((a, b) => a - b)).toEqual(seqs);
// First durable message of a fresh session makes no continuity claim; the rest point at their predecessor.
expect(durable[0]!.msg.prevSeq).toBeUndefined();
for (let i = 1; i < durable.length; i++) {
expect(durable[i]!.msg.prevSeq).toBe(durable[i - 1]!.seq);
}
});
test('a session that predates this process picks the chain up from the stored cursor', async () => {
const { got, deliver } = collector();
const { writer, settleAll, pendingCount } = reverseOrderWriter({ seed: 42 });
const store = createSessionLogStore(deliver, writer);
store.push('s1', { type: 'text', text: 'after a restart' });
await drain(settleAll, pendingCount);
expect(got).toHaveLength(1);
expect(got[0]!.msg.prevSeq).toBe(42);
});
test('deltas are delivered without a cursor', async () => {
const { got, deliver } = collector();
const { writer, settleAll, pendingCount } = reverseOrderWriter();
const store = createSessionLogStore(deliver, writer);
store.push('s1', { type: 'delta', text: 'hi' });
await drain(settleAll, pendingCount);
expect(got).toEqual([{ sessionId: 's1', msg: { type: 'assistant:delta', text: 'hi' } }]);
});
test('durable:false skips the writer entirely but still delivers everything', async () => {
const { got, deliver } = collector();
const { writer, settleAll, writes, pendingCount } = reverseOrderWriter();
const store = createSessionLogStore(deliver, writer);
for (const e of TURN) store.push('job-uuid', e, false);
await drain(settleAll, pendingCount);
expect(writes).toHaveLength(0);
expect(got).toHaveLength(7);
expect(got.every((d) => d.seq === undefined)).toBe(true);
});
test('a failed write delivers live without a cursor and leaves the chain intact', async () => {
const { got, deliver } = collector();
// Fail the second successful-cursor slot, i.e. one write in the middle of the turn.
const { writer, settleAll, pendingCount } = reverseOrderWriter({ failOn: (n) => n === 101 });
const store = createSessionLogStore(deliver, writer);
store.push('s1', { type: 'text', text: 'one' });
await drain(settleAll, pendingCount);
store.push('s1', { type: 'text', text: 'two' });
await drain(settleAll, pendingCount);
store.push('s1', { type: 'text', text: 'three' });
await drain(settleAll, pendingCount);
expect(got.map((d) => d.msg.type)).toEqual(['assistant:text', 'assistant:text', 'assistant:text']);
const [first, failed, third] = got as [Delivery, Delivery, Delivery];
expect(first.seq).toBe(100);
// The message the client cannot replay carries neither a cursor nor a continuity claim.
expect(failed.seq).toBeUndefined();
// ...and the next write chains from the cursor the client actually still holds, not from the hole.
expect(third.msg.prevSeq).toBe(first.seq);
expect(third.seq).toBe(102);
});
test('sessions are independent, and drop forgets a session', async () => {
const { got, deliver } = collector();
const { writer, settleAll, pendingCount } = reverseOrderWriter();
const store = createSessionLogStore(deliver, writer);
store.push('a', { type: 'delta', text: 'from-a' });
store.push('b', { type: 'delta', text: 'from-b' });
store.push('a', { type: 'result', cost: COST });
await drain(settleAll, pendingCount);
// 'a' flushed only its own buffer.
const texts = got.filter((d) => d.msg.type === 'assistant:text');
expect(texts).toHaveLength(1);
expect(texts[0]).toMatchObject({ sessionId: 'a', msg: { text: 'from-a' } });
// After a drop, a new turn on the same key starts from a clean buffer and re-reads the stored cursor.
store.drop('a');
got.length = 0;
store.push('a', { type: 'result', cost: COST });
await drain(settleAll, pendingCount);
expect(got.map((d) => d.msg.type)).toEqual(['result']);
});
});
+121
View File
@@ -0,0 +1,121 @@
import type { ChatEvent, TurnMessage } from '../../api/chat/types';
import { appendChatEvent, getLastChatEventSeq } from 'officerdb';
import { createTurnStream, type TurnOutput } from './turn-stream';
// The agent sidecar is the writer of record for chat output.
//
// It used to push raw ChatEvents at officer over the registration socket and let officer translate and
// persist them. That socket silently drops when officer is down (`connect.ts:send` — no queue, no
// error), so everything the agent produced during a restart was lost: the turn kept running here and
// its output went nowhere. Writing to Postgres here instead means an officer restart costs a replay
// rather than the output, because the durable record no longer travels over the socket that died.
//
// Officer still gets every message live — it just gets it already written, with its cursor id attached,
// and relays it verbatim.
export type Delivery = {
sessionId: string;
msg: TurnMessage;
/** The cursor id under which this message is durable. Absent = ephemeral delta, or the write failed. */
seq?: number;
};
export type SessionLogStore = {
/**
* Translate one parser event and commit + deliver whatever it produces, strictly in order.
* `durable: false` skips the write (see ClaudeSpawnStreamingParams.durable) — the messages are still
* delivered, just without a cursor, because nothing will ever replay them.
*/
push: (sessionId: string, event: ChatEvent, durable?: boolean) => void;
/** Forget a session's buffer and cursor chain (on kill / clear-session). */
drop: (sessionId: string) => void;
};
// The durable store, behind an interface so the ordering guarantee below can be tested against a writer
// whose writes finish out of order. Defaults to Postgres.
export type EventWriter = {
append: (sessionId: string, msg: TurnMessage) => Promise<number>;
lastSeq: (sessionId: string) => Promise<number | undefined>;
};
const postgresWriter: EventWriter = {
append: (sessionId, msg) => appendChatEvent(sessionId, msg),
lastSeq: (sessionId) => getLastChatEventSeq(sessionId),
};
type SessionLog = {
stream: ReturnType<typeof createTurnStream>;
/** Serialises commits so cursor ids are assigned in the order the events actually arrived. */
tail: Promise<void>;
lastSeq: number | undefined;
resolvedLastSeq: boolean;
};
export function createSessionLogStore(
deliver: (d: Delivery) => void,
writer: EventWriter = postgresWriter,
): SessionLogStore {
const logs = new Map<string, SessionLog>();
function logFor(sessionId: string): SessionLog {
let log = logs.get(sessionId);
if (!log) {
log = {
stream: createTurnStream(sessionId),
tail: Promise.resolve(),
lastSeq: undefined,
resolvedLastSeq: false,
};
logs.set(sessionId, log);
}
return log;
}
async function commit(sessionId: string, log: SessionLog, out: TurnOutput, durable: boolean): Promise<void> {
// Deltas are live-only, but still go through the queue: a delta that overtook the `assistant:text`
// or `tool:start` in front of it would make the client commit its stream buffer at the wrong point.
if (!out.durable || !durable) {
deliver({ sessionId, msg: out.msg });
return;
}
// Pick the chain back up after a restart of this process, so `prevSeq` stays meaningful for a
// session that started before it. Once per session; failure just means no continuity claim.
if (!log.resolvedLastSeq) {
log.resolvedLastSeq = true;
try {
log.lastSeq = await writer.lastSeq(sessionId);
} catch (err) {
console.error(`[agent] could not read last event cursor for ${sessionId}:`, err);
}
}
const msg: TurnMessage = log.lastSeq === undefined ? out.msg : { ...out.msg, prevSeq: log.lastSeq };
try {
const seq = await writer.append(sessionId, msg);
log.lastSeq = seq;
deliver({ sessionId, msg, seq });
} catch (err) {
// The write IS the durability guarantee, so don't pretend. Deliver live without a cursor: the
// client sees the message but won't advance its cursor past something it cannot replay, and the
// next successful write chains from the last cursor the client actually holds.
console.error(`[agent] failed to persist chat event for ${sessionId}:`, err);
deliver({ sessionId, msg: out.msg });
}
}
return {
push(sessionId, event, durable = true) {
const log = logFor(sessionId);
// Translation is synchronous and therefore in arrival order; only the commit is queued.
for (const out of log.stream.push(event)) {
log.tail = log.tail.then(() => commit(sessionId, log, out, durable));
}
},
drop(sessionId) {
logs.delete(sessionId);
},
};
}
+25
View File
@@ -101,6 +101,31 @@ export async function flushAndSave(): Promise<void> {
await saveState();
}
/**
* Read the Anthropic proxy secret out of the *proxy* sidecar's state file.
*
* The proxy (`officer-anthropic-proxy`) and the agent (`officer-agent`) keep separate state — see
* `initPaths`: `DATA_PATH/sidecar/` versus `DATA_PATH/<email>/sidecar/` — so the agent cannot reach
* the secret through `getState()`. It used to be handed the secret in env by the main server, and
* needing that handoff is precisely why the agent had to be spawned by `officer` (and therefore died
* with it). Reading it off disk keeps the two processes independent, with the proxy still the only
* writer.
*
* Returns '' when the secret is not on disk yet: `ensureProxySecret` persists through a 30s debounce,
* so a brand-new install has a window where the file exists without it. Callers should treat '' as
* "retry later" rather than fatal.
*/
export function readProxySecretFromDisk(): string {
try {
const proxyStateFile = join(DATA_PATH, 'sidecar', 'claude-state.json');
if (!existsSync(proxyStateFile)) return '';
const parsed = JSON.parse(readFileSync(proxyStateFile, 'utf-8')) as Partial<PersistedState>;
return parsed.proxySecret ?? '';
} catch {
return '';
}
}
// ── Lockfile ──
export function acquireLock(): boolean {
@@ -0,0 +1,147 @@
import { describe, test, expect } from 'bun:test';
import { createTurnStream } from './turn-stream';
import type { ChatEvent, MessageCost } from '../../api/chat/types';
const SESSION = 'sess-1';
const COST: MessageCost = { inputTokens: 10, outputTokens: 20, totalUSD: 0.001 };
// Feed a whole event sequence through one stream and collect what came out, so the assertions read as
// "this turn produced this transcript" rather than per-call plumbing.
function run(events: ChatEvent[]) {
const stream = createTurnStream(SESSION);
const out = events.flatMap((e) => stream.push(e));
return {
all: out,
durable: out.filter((o) => o.durable).map((o) => o.msg),
types: out.map((o) => o.msg.type),
};
}
describe('createTurnStream', () => {
test('deltas are live-only and never durable', () => {
const { all, durable } = run([
{ type: 'delta', text: 'he' },
{ type: 'delta', text: 'llo' },
]);
expect(all).toHaveLength(2);
expect(all.every((o) => o.durable)).toBe(false);
expect(durable).toHaveLength(0);
});
test('an explicit text event wins over the deltas that produced it', () => {
const { durable } = run([
{ type: 'delta', text: 'par' },
{ type: 'delta', text: 'tial' },
{ type: 'text', text: 'partial and complete' },
]);
expect(durable).toEqual([{ type: 'assistant:text', text: 'partial and complete' }]);
});
test('a text event with no text falls back to the accumulated buffer', () => {
const { durable } = run([
{ type: 'delta', text: 'buffered' },
{ type: 'text', text: '' },
]);
expect(durable).toEqual([{ type: 'assistant:text', text: 'buffered' }]);
});
test('an empty text event with an empty buffer produces nothing', () => {
expect(run([{ type: 'text', text: '' }]).all).toHaveLength(0);
});
test('the buffer is flushed as one message before a tool call', () => {
const { durable } = run([
{ type: 'delta', text: 'Let me ' },
{ type: 'delta', text: 'check.' },
{ type: 'tool:start', toolCallId: 't1', toolName: 'Read', toolInput: { file: 'a.ts' } },
]);
expect(durable).toEqual([
{ type: 'assistant:text', text: 'Let me check.' },
{ type: 'tool:start', toolCallId: 't1', toolName: 'Read', toolInput: { file: 'a.ts' } },
]);
});
test('the buffer is flushed before the turn result, and result carries the session id', () => {
const { durable } = run([
{ type: 'delta', text: 'Done.' },
{ type: 'result', cost: COST },
]);
expect(durable).toEqual([
{ type: 'assistant:text', text: 'Done.' },
{ type: 'result', sessionId: SESSION, cost: COST },
]);
});
test('a flushed buffer is not emitted twice', () => {
const { durable } = run([
{ type: 'delta', text: 'once' },
{ type: 'tool:start', toolCallId: 't1', toolName: 'Read', toolInput: {} },
{ type: 'tool:result', toolCallId: 't1', output: 'ok', isError: false },
{ type: 'result', cost: COST },
]);
expect(durable.filter((m) => m.type === 'assistant:text')).toEqual([{ type: 'assistant:text', text: 'once' }]);
});
test('deltas after a flush start a fresh block', () => {
const { durable } = run([
{ type: 'delta', text: 'first' },
{ type: 'tool:start', toolCallId: 't1', toolName: 'Read', toolInput: {} },
{ type: 'delta', text: 'second' },
{ type: 'result', cost: COST },
]);
expect(durable.filter((m) => m.type === 'assistant:text')).toEqual([
{ type: 'assistant:text', text: 'first' },
{ type: 'assistant:text', text: 'second' },
]);
});
test('a full turn keeps the client-visible order', () => {
const { types } = run([
{ type: 'delta', text: 'a' },
{ type: 'tool:start', toolCallId: 't1', toolName: 'Bash', toolInput: { cmd: 'ls' } },
{ type: 'tool:result', toolCallId: 't1', output: 'a.ts', isError: false },
{ type: 'delta', text: 'b' },
{ type: 'result', cost: COST },
]);
expect(types).toEqual([
'assistant:delta',
'assistant:text',
'tool:start',
'tool:result',
'assistant:delta',
'assistant:text',
'result',
]);
});
test('errors and stops are durable, and do not flush a partial answer away', () => {
const { durable } = run([
{ type: 'delta', text: 'half' },
{ type: 'error', message: 'boom' },
]);
expect(durable).toEqual([{ type: 'error', message: 'boom' }]);
expect(run([{ type: 'stopped' }]).durable).toEqual([{ type: 'stopped' }]);
});
test('background task events pass through and are durable — the reason the queue exists', () => {
const { durable } = run([
{ type: 'result', cost: COST },
{ type: 'task:started', taskId: 'bg1', description: 'long job', taskType: 'local_agent' },
{ type: 'task:notification', taskId: 'bg1', status: 'completed', summary: 'all good' },
]);
expect(durable).toEqual([
{ type: 'result', sessionId: SESSION, cost: COST },
{ type: 'task:started', taskId: 'bg1', description: 'long job', taskType: 'local_agent' },
{ type: 'task:notification', taskId: 'bg1', status: 'completed', summary: 'all good' },
]);
});
test('streams are independent', () => {
const a = createTurnStream('a');
const b = createTurnStream('b');
a.push({ type: 'delta', text: 'from-a' });
b.push({ type: 'delta', text: 'from-b' });
expect(a.push({ type: 'result', cost: COST })[0]!.msg).toEqual({ type: 'assistant:text', text: 'from-a' });
expect(b.push({ type: 'result', cost: COST })[0]!.msg).toEqual({ type: 'assistant:text', text: 'from-b' });
});
});
+101
View File
@@ -0,0 +1,101 @@
import type { ChatEvent, TurnMessage } from '../../api/chat/types';
// Translation from the parser's ChatEvent stream to the browser-facing turn messages, moved here from
// the main server (`chat/websocket.ts:createEventHandler`). It lives with the process that produces the
// stream because it is stateful: `delta` events accumulate into a buffer that has to be flushed as one
// `assistant:text` at the next boundary (a tool call, or the end of the turn). A consumer downstream of
// a socket that can drop cannot hold that state correctly.
//
// Pure apart from the buffer, and the buffer is the whole of it — which is what makes it testable.
export type TurnOutput = {
msg: TurnMessage;
/** false only for `assistant:delta`: superseded by the `assistant:text` that follows, never persisted. */
durable: boolean;
};
export type TurnStream = {
push: (event: ChatEvent) => TurnOutput[];
};
export function createTurnStream(sessionId: string): TurnStream {
let buffer = '';
// Emit whatever deltas have accumulated as one complete message. Called at every boundary where the
// assistant stops talking, so the transcript holds text blocks rather than a thousand fragments.
function flush(): TurnOutput[] {
if (!buffer) return [];
const text = buffer;
buffer = '';
return [{ msg: { type: 'assistant:text', text }, durable: true }];
}
function push(event: ChatEvent): TurnOutput[] {
switch (event.type) {
case 'delta':
buffer += event.text;
return [{ msg: { type: 'assistant:delta', text: event.text }, durable: false }];
case 'text': {
// An explicit full text block wins over the accumulated deltas that produced it.
const text = event.text || buffer;
buffer = '';
return text ? [{ msg: { type: 'assistant:text', text }, durable: true }] : [];
}
case 'tool:start':
return [
...flush(),
{
msg: {
type: 'tool:start',
toolCallId: event.toolCallId,
toolName: event.toolName,
toolInput: event.toolInput,
},
durable: true,
},
];
case 'tool:result':
return [
{
msg: { type: 'tool:result', toolCallId: event.toolCallId, output: event.output, isError: event.isError },
durable: true,
},
];
case 'result':
return [...flush(), { msg: { type: 'result', sessionId, cost: event.cost }, durable: true }];
case 'error':
return [{ msg: { type: 'error', message: event.message }, durable: true }];
case 'stopped':
return [{ msg: { type: 'stopped' }, durable: true }];
case 'task:started':
return [
{
msg: {
type: 'task:started',
taskId: event.taskId,
description: event.description,
taskType: event.taskType,
},
durable: true,
},
];
case 'task:notification':
return [
{
msg: { type: 'task:notification', taskId: event.taskId, status: event.status, summary: event.summary },
durable: true,
},
];
}
}
return { push };
}
+74 -27
View File
@@ -2,30 +2,40 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { initPaths, loadState, flushAndSave, acquireLock, releaseLock } from './state';
import { initPaths, loadState, flushAndSave, acquireLock, releaseLock, readProxySecretFromDisk } from './state';
import { createSessionLogStore } from './session-log';
import { setMcpConfigPath } from './claude-manager';
import * as claudeManager from './claude-manager';
import { createSidecarConnector } from '../connect';
import { sign } from '../../jwt';
import { getUserByEmail, getEmailAccounts } from 'officerdb';
import { getUserByEmail, getOwnerUser, getEmailAccounts } from 'officerdb';
const email = process.env.CLAUDE_USER_EMAIL;
if (!email) {
console.error('[user-instance] CLAUDE_USER_EMAIL is required');
process.exit(1);
// PM2 starts this sidecar with no user in its env. Single-user platform, so resolve the owner from the
// database rather than being told who to run as by the main server — one less thing that has to come
// from `officer` before this process can work. CLAUDE_USER_EMAIL still wins when set, for manual runs.
async function resolveOwner() {
const explicit = process.env.CLAUDE_USER_EMAIL?.trim();
for (;;) {
const user = explicit ? await getUserByEmail(explicit) : await getOwnerUser();
if (user) return user;
// Fresh install: wait for POST /auth/bootstrap instead of exiting into a PM2 restart loop.
console.log(`[agent] no ${explicit ? `user "${explicit}"` : 'owner account'} yet — retrying in 5s`);
await Bun.sleep(5_000);
}
}
const dbUser = await resolveOwner();
const email = dbUser.email;
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
const OFFICER_API_URL = process.env.OFFICER_API_URL ?? `http://127.0.0.1:${process.env.PORT ?? '9010'}`;
// Same officer instance for both, so the fallback port has to agree. It used to default to 5000 for the
// WebSocket and 9010 for the REST base, which would have split them apart if PORT were ever unset.
const OFFICER_PORT = process.env.PORT ?? '9010';
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${OFFICER_PORT}`;
const OFFICER_API_URL = process.env.OFFICER_API_URL ?? `http://127.0.0.1:${OFFICER_PORT}`;
const MCP_SERVER_SCRIPT = resolve(import.meta.dir, '../../mcp-tool-server.ts');
// Mint a long-lived JWT for this user so tools (e.g. gmail) can call back to dev-platform as them
const dbUser = await getUserByEmail(email);
if (!dbUser) {
console.error(`[user-instance] no user found for ${email}`);
process.exit(1);
}
const OFFICER_AUTH_TOKEN = await sign({ id: dbUser.id, email, username: dbUser.username }, '30d');
// Single-user platform: the owner runs Claude with no isolation — real HOME, real ~/.claude — so
@@ -49,7 +59,7 @@ process.env.HOME = homeDir;
initPaths(email);
if (!acquireLock()) {
console.error(`[claude:${email}] another instance is already running (lock file exists with live PID)`);
console.error(`[agent] another instance is already running for ${email} (lock file exists with live PID)`);
process.exit(1);
}
@@ -85,6 +95,30 @@ function generateMcpConfig(): string {
return join(contextDir, 'mcp-host.json');
}
// ── Anthropic credentials ──
// The `claude` CLI inherits this process's env (claude-manager spawns with `process.env`), so the proxy
// endpoint and secret have to be set here. Officer used to inject both when it spawned this process;
// reading them ourselves is what lets this sidecar be a PM2 peer instead of a child of the server.
//
// Resolved lazily rather than once at boot: PM2 starts the proxy and the agent together, and
// `ensureProxySecret` persists on a 30s debounce, so on a first-ever boot the secret can be briefly
// absent. Re-checked before every spawn until it lands.
const ANTHROPIC_PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
function ensureAnthropicEnv(): void {
process.env.ANTHROPIC_BASE_URL ??= `http://127.0.0.1:${ANTHROPIC_PROXY_PORT}`;
if (process.env.ANTHROPIC_API_KEY) return;
const secret = readProxySecretFromDisk();
if (secret) {
process.env.ANTHROPIC_API_KEY = secret;
console.log('[agent] anthropic proxy secret loaded from disk');
} else {
console.warn('[agent] anthropic proxy secret not on disk yet — retrying before next spawn');
}
}
// ── Startup ──
// The owner runs un-isolated with HOME as their real home, so the generated container CLAUDE.md is
@@ -92,8 +126,17 @@ function generateMcpConfig(): string {
// terminal `claude` loads too.
setMcpConfigPath(generateMcpConfig());
ensureAnthropicEnv();
console.log(`[claude:${email}] started (HOME=${homeDir})`);
console.log(`[agent] started for ${email} (HOME=${homeDir})`);
// ── Turn output ──
// Every message a turn produces is translated, committed to chat_session_events and only then pushed to
// officer. `connection` is initialised below, before any command can arrive to invoke this.
const sessionLog = createSessionLogStore((d) =>
connection.send({ type: 'claude:message', sessionKey: d.sessionId, msg: d.msg, seq: d.seq }),
);
// ── Command handlers ──
@@ -106,6 +149,7 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
break;
case 'claude:spawn': {
ensureAnthropicEnv();
try {
const result = await claudeManager.spawnClaude(cmd.params);
reply({ type: 'claude:result', id: cmd.id, result });
@@ -116,24 +160,23 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
}
case 'claude:spawn-streaming': {
ensureAnthropicEnv();
reply({ type: 'claude:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey });
const onEvent = (event: import('../../api/chat/types').ChatEvent) => {
connection.send({ type: 'claude:event', sessionKey: cmd.params.sessionKey, event });
};
claudeManager.spawnClaudeStreaming(cmd.params, onEvent).catch((err) => {
connection.send({
type: 'claude:event',
sessionKey: cmd.params.sessionKey,
event: { type: 'error', message: err instanceof Error ? err.message : String(err) },
const { sessionKey, durable = true } = cmd.params;
claudeManager
.spawnClaudeStreaming(cmd.params, (event) => sessionLog.push(sessionKey, event, durable))
.catch((err) => {
// Through the log like any other output, so a failure to start is durable and replayable too.
const message = err instanceof Error ? err.message : String(err);
sessionLog.push(sessionKey, { type: 'error', message }, durable);
});
});
break;
}
case 'claude:kill':
claudeManager.killClaudeSession(cmd.sessionKey);
sessionLog.drop(cmd.sessionKey);
reply({ type: 'claude:killed', id: cmd.id });
break;
@@ -144,6 +187,7 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
case 'claude:clear-session':
claudeManager.clearSession(cmd.sessionKey);
sessionLog.drop(cmd.sessionKey);
reply({ type: 'claude:session-cleared', id: cmd.id });
break;
@@ -158,9 +202,12 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
// ── Connect to API server ──
// A stable identity, not a per-email one. Officer looks this sidecar up by the 'claude' capability, so
// 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.
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: `claude:${email}`,
name: 'agent',
capabilities: ['claude'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
@@ -170,7 +217,7 @@ const connection = createSidecarConnector({
// ── Graceful shutdown ──
async function shutdown(signal: string) {
console.log(`[claude:${email}] ${signal} received, saving state...`);
console.log(`[agent] ${signal} received, saving state...`);
connection.destroy();
await flushAndSave();
releaseLock();
+9 -2
View File
@@ -1,4 +1,4 @@
import type { MessageCost, ChatEvent } from '../api/chat/types';
import type { MessageCost, ChatEvent, TurnMessage } from '../api/chat/types';
// ── Envelope ──
@@ -35,7 +35,10 @@ export type SidecarEvent =
| { type: 'proxy:secret'; id: string; secret: string }
// Claude Code
| { type: 'claude:spawned'; id: string; sessionKey: string }
| { type: 'claude:event'; sessionKey: string; event: ChatEvent }
// A finished, browser-facing turn message. The agent has already committed it to chat_session_events
// and `seq` is its cursor id there; officer relays it verbatim. No `seq` means it is not durable —
// an `assistant:delta` (superseded by the text that follows) or a message whose write failed.
| { type: 'claude:message'; sessionKey: string; msg: TurnMessage; seq?: number }
| { type: 'claude:result'; id: string; result: ClaudeCodeResult }
| { type: 'claude:error'; id: string; error: string }
| { type: 'claude:killed'; id: string }
@@ -93,6 +96,10 @@ export type ClaudeSpawnStreamingParams = {
cwd?: string;
model?: string;
resumeSessionId?: string; // resume this Claude session uuid (from the /chat session list)
// Whether turn output should be committed to chat_session_events (default true). A chat session wants
// it — that is what survives an officer restart. A pipeline step does not: its sessionKey is a throwaway
// uuid no browser will ever replay, and the job's own event log is its record.
durable?: boolean;
};
export type ClaudeCodeResult = {