import * as http from 'http';
import {
ChatRequest,
ChatStreamCallbacks,
AuthStatusResponse,
ConfigResponse,
ChatEvent,
CodingPlanSetupResponse,
CreateProviderRequest,
HealthResponse,
LoginPollResponse,
LoginStartResponse,
ModelInfo,
PatchProviderRequest,
PatchThinkingRequest,
ProjectState,
ProviderInfo,
ProvidersResponse,
SessionMeta,
SessionDetail,
CreateSessionResponse,
ChangeDirResponse,
} from './types';
const REST_TIMEOUT = 30000;
export class DaemonClient {
private baseUrl: string;
private host: string;
private port: number;
public onConnectionLost?: () => Promise<boolean>;
constructor(port: number) {
this.port = port;
this.host = '127.0.0.1';
this.baseUrl = `http://${this.host}:${this.port}`;
}
private request<T>(method: string, path: string, body?: unknown): Promise<T> {
return this.requestOnce<T>(method, path, body).catch(async (err) => {
if (err instanceof Error && err.message === 'Daemon not running'
&& this.onConnectionLost
&& path !== '/health' && path !== '/shutdown') {
const restarted = await this.onConnectionLost();
if (restarted) {
return this.requestOnce<T>(method, path, body);
}
}
throw err;
});
}
private requestOnce<T>(method: string, path: string, body?: unknown): Promise<T> {
return new Promise((resolve, reject) => {
const payload = body ? JSON.stringify(body) : undefined;
const options: http.RequestOptions = {
hostname: this.host,
port: this.port,
path,
method,
headers: {
'Content-Type': 'application/json',
'X-AtomCode-Client': 'vscode',
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
},
timeout: REST_TIMEOUT,
};
const req = http.request(options, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
if (!res.statusCode || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}: ${this.errorMessage(raw)}`));
return;
}
try {
resolve(JSON.parse(raw) as T);
} catch {
reject(new Error(`Invalid JSON response: ${raw}`));
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
req.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ECONNREFUSED') {
reject(new Error('Daemon not running'));
} else {
reject(err);
}
});
if (payload) {
req.write(payload);
}
req.end();
});
}
private get<T>(path: string): Promise<T> {
return this.request<T>('GET', path);
}
private post<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>('POST', path, body);
}
private patch<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>('PATCH', path, body);
}
private delete<T>(path: string): Promise<T> {
return this.request<T>('DELETE', path);
}
private errorMessage(raw: string): string {
try {
const parsed = JSON.parse(raw) as { error?: string; message?: string };
return parsed.error || parsed.message || raw;
} catch {
return raw;
}
}
async isRunning(): Promise<boolean> {
try {
await this.health();
return true;
} catch {
return false;
}
}
health(): Promise<HealthResponse> {
return this.get<HealthResponse>('/health');
}
shutdown(): Promise<{ success: boolean }> {
return this.post<{ success: boolean }>('/shutdown');
}
getProject(): Promise<ProjectState> {
return this.get<ProjectState>('/project');
}
changeDir(dir: string): Promise<ChangeDirResponse> {
return this.post<ChangeDirResponse>('/cd', { path: dir });
}
listModels(): Promise<ModelInfo[]> {
return this.get<ModelInfo[]>('/models');
}
getConfig(): Promise<ConfigResponse> {
return this.get<ConfigResponse>('/config');
}
reloadConfig(): Promise<ConfigResponse> {
return this.post<ConfigResponse>('/config/reload');
}
listProviders(): Promise<ProvidersResponse> {
return this.get<ProvidersResponse>('/providers');
}
createProvider(req: CreateProviderRequest): Promise<ProviderInfo> {
return this.post<ProviderInfo>('/providers', req);
}
patchProvider(name: string, req: PatchProviderRequest): Promise<ProviderInfo> {
return this.patch<ProviderInfo>(`/providers/${encodeURIComponent(name)}`, req);
}
deleteProvider(name: string): Promise<ProvidersResponse> {
return this.delete<ProvidersResponse>(`/providers/${encodeURIComponent(name)}`);
}
setDefaultProvider(name: string): Promise<ConfigResponse> {
return this.post<ConfigResponse>(`/providers/${encodeURIComponent(name)}/default`);
}
patchThinking(name: string, req: PatchThinkingRequest): Promise<ProviderInfo> {
return this.patch<ProviderInfo>(`/providers/${encodeURIComponent(name)}/thinking`, req);
}
authStatus(): Promise<AuthStatusResponse> {
return this.get<AuthStatusResponse>('/auth/status');
}
startLogin(openBrowser = true): Promise<LoginStartResponse> {
return this.post<LoginStartResponse>('/auth/login/start', { open_browser: openBrowser });
}
pollLogin(loginId: string): Promise<LoginPollResponse> {
return this.post<LoginPollResponse>(`/auth/login/${encodeURIComponent(loginId)}/poll`);
}
cancelLogin(loginId: string): Promise<{ success: boolean }> {
return this.delete<{ success: boolean }>(`/auth/login/${encodeURIComponent(loginId)}`);
}
logout(): Promise<AuthStatusResponse> {
return this.post<AuthStatusResponse>('/auth/logout');
}
setupCodingPlan(loginId?: string): Promise<CodingPlanSetupResponse> {
return this.post<CodingPlanSetupResponse>('/codingplan/setup', { login_id: loginId });
}
listSessions(): Promise<SessionMeta[]> {
return this.get<SessionMeta[]>('/sessions');
}
getSession(projectHash: string, id: string): Promise<SessionDetail> {
return this.get<SessionDetail>(`/projects/${projectHash}/sessions/${id}`);
}
createSession(name?: string, workingDir?: string): Promise<CreateSessionResponse> {
return this.post<CreateSessionResponse>('/sessions', {
title: name,
working_dir: workingDir,
});
}
renameSession(projectHash: string, id: string, name: string): Promise<string> {
return this.patch<string>(
`/projects/${encodeURIComponent(projectHash)}/sessions/${encodeURIComponent(id)}/rename`,
{ name },
);
}
deleteSession(projectHash: string, id: string): Promise<string> {
return this.delete<string>(
`/projects/${encodeURIComponent(projectHash)}/sessions/${encodeURIComponent(id)}`,
);
}
searchSessions(query: string): Promise<SessionMeta[]> {
return this.get<SessionMeta[]>(`/sessions/search?q=${encodeURIComponent(query)}`);
}
streamChat(req: ChatRequest, callbacks: ChatStreamCallbacks): AbortController {
const controller = new AbortController();
const payload = JSON.stringify(req);
const options: http.RequestOptions = {
hostname: this.host,
port: this.port,
path: '/chat',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'X-AtomCode-Client': 'vscode',
'Content-Length': Buffer.byteLength(payload),
},
};
const httpReq = http.request(options, (res) => {
if (!res.statusCode || res.statusCode >= 400) {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
callbacks.onError(`HTTP ${res.statusCode}: ${this.errorMessage(raw)}`);
});
return;
}
let buffer = '';
res.setEncoding('utf-8');
res.on('data', (chunk: string) => {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith(':')) {
continue;
}
if (trimmed.startsWith('data: ')) {
const data = trimmed.slice(6);
this.handleSSEData(data, callbacks);
}
}
});
res.on('end', () => {
if (buffer.trim().startsWith('data: ')) {
const data = buffer.trim().slice(6);
this.handleSSEData(data, callbacks);
}
});
res.on('error', (err) => {
callbacks.onError(`Stream error: ${err.message}`);
});
});
httpReq.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ECONNREFUSED') {
if (this.onConnectionLost) {
this.onConnectionLost().then((restarted) => {
if (restarted) {
const retryController = this.streamChat(req, callbacks);
controller.signal.addEventListener('abort', () => retryController.abort());
} else {
callbacks.onError('Daemon not running');
}
}).catch(() => {
callbacks.onError('Daemon not running');
});
} else {
callbacks.onError('Daemon not running');
}
} else if (controller.signal.aborted) {
callbacks.onStopped();
} else {
callbacks.onError(`Connection error: ${err.message}`);
}
});
controller.signal.addEventListener('abort', () => {
httpReq.destroy();
});
httpReq.write(payload);
httpReq.end();
return controller;
}
private handleSSEData(data: string, callbacks: ChatStreamCallbacks): void {
let event: ChatEvent;
try {
event = JSON.parse(data) as ChatEvent;
} catch {
return;
}
switch (event.type) {
case 'text':
callbacks.onText(event.content);
break;
case 'tool_batch':
callbacks.onToolBatch(
event.calls.map((c) => ({ id: c.id, name: c.name, args: c.arguments })),
);
break;
case 'tool_start':
callbacks.onToolStart(event.id, event.name, event.arguments);
break;
case 'tool_result':
callbacks.onToolResult(event.id, event.name, event.output, event.success, event.duration_ms);
break;
case 'tokens':
callbacks.onTokens(event.prompt, event.completion, event.total);
break;
case 'artifact_start':
callbacks.onArtifactStart(event.id, event.artifact_type, event.language, event.title);
break;
case 'artifact_content':
callbacks.onArtifactContent(event.id, event.content);
break;
case 'artifact_end':
callbacks.onArtifactEnd(event.id);
break;
case 'done':
callbacks.onDone(event.tokens, event.tool_calls, event.session_id);
break;
case 'stopped':
callbacks.onStopped();
break;
case 'error':
callbacks.onError(event.message);
break;
}
}
stopGeneration(sessionId: string): Promise<{ success: boolean; message: string }> {
return this.post<{ success: boolean; message: string }>('/chat/stop', { session_id: sessionId });
}
activeSessions(): Promise<string[]> {
return this.get<string[]>('/chat/active');
}
}