im_wower
·
2026-03-28
local-service.ts
1import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
2import type { AddressInfo } from "node:net";
3
4import { ClaudeCodedDaemon, type ClaudeCodedDaemonOptions } from "./daemon.js";
5import type {
6 ClaudeCodedResolvedConfig,
7 ClaudeCodedStatusSnapshot,
8 ClaudeCodedStreamEvent
9} from "./contracts.js";
10
11interface ClaudeCodedHttpResponse {
12 body: string;
13 headers: Record<string, string>;
14 status: number;
15}
16
17type JsonRecord = Record<string, unknown>;
18
19export interface ClaudeCodedDescribeRoute {
20 description: string;
21 method: "GET" | "POST";
22 path: string;
23}
24
25export interface ClaudeCodedDescribeResponse {
26 ok: true;
27 name: string;
28 surface: string;
29 description: string;
30 mode: {
31 daemon: string;
32 supervisor: string;
33 transport: string;
34 conductor_role: string;
35 };
36 base_url: string;
37 routes: ClaudeCodedDescribeRoute[];
38 capabilities: {
39 health_probe: boolean;
40 ask: boolean;
41 ask_stream: boolean;
42 status: boolean;
43 };
44 notes: string[];
45}
46
47const CLAUDE_CODED_FORMAL_ROUTES: ClaudeCodedDescribeRoute[] = [
48 {
49 description: "Lightweight health probe for the local daemon.",
50 method: "GET",
51 path: "/healthz"
52 },
53 {
54 description: "Machine-readable description of the official claude-coded surface.",
55 method: "GET",
56 path: "/describe"
57 },
58 {
59 description: "Current daemon and child process status.",
60 method: "GET",
61 path: "/v1/claude-coded/status"
62 },
63 {
64 description: "Submit a prompt and wait for the complete response.",
65 method: "POST",
66 path: "/v1/claude-coded/ask"
67 },
68 {
69 description: "Submit a prompt and receive SSE streamed events.",
70 method: "POST",
71 path: "/v1/claude-coded/ask/stream"
72 }
73];
74
75export interface ClaudeCodedLocalServiceRuntimeInfo {
76 configuredBaseUrl: string;
77 listening: boolean;
78 resolvedBaseUrl: string | null;
79}
80
81export interface ClaudeCodedLocalServiceStatus {
82 service: ClaudeCodedLocalServiceRuntimeInfo;
83 snapshot: ClaudeCodedStatusSnapshot;
84}
85
86class ClaudeCodedHttpError extends Error {
87 constructor(
88 readonly status: number,
89 message: string
90 ) {
91 super(message);
92 this.name = "ClaudeCodedHttpError";
93 }
94}
95
96export class ClaudeCodedLocalService {
97 private readonly daemon: ClaudeCodedDaemon;
98 private resolvedBaseUrl: string | null = null;
99 private server: Server | null = null;
100
101 constructor(
102 private readonly config: ClaudeCodedResolvedConfig,
103 options: ClaudeCodedDaemonOptions = {}
104 ) {
105 this.daemon = new ClaudeCodedDaemon(config, options);
106 }
107
108 getDaemon(): ClaudeCodedDaemon {
109 return this.daemon;
110 }
111
112 getStatus(): ClaudeCodedLocalServiceStatus {
113 return {
114 service: this.getRuntimeInfo(),
115 snapshot: this.daemon.getStatusSnapshot()
116 };
117 }
118
119 getDescribe(): ClaudeCodedDescribeResponse {
120 const baseUrl = this.resolvedBaseUrl ?? this.config.service.localApiBase;
121
122 return {
123 base_url: baseUrl,
124 capabilities: {
125 health_probe: true,
126 ask: true,
127 ask_stream: true,
128 status: true
129 },
130 description:
131 "Independent local Claude Code daemon for prompt submission, status reads, and SSE streaming.",
132 mode: {
133 conductor_role: "proxy",
134 daemon: "independent",
135 supervisor: "launchd",
136 transport: "claude-code stream-json"
137 },
138 name: "claude-coded",
139 notes: [
140 "Use GET /describe first when an AI client needs to discover the official local claude-coded surface.",
141 "claude-coded is the long-running Claude Code runtime; conductor-daemon only proxies this service.",
142 "This surface is limited to health, status, and prompt ask/stream."
143 ],
144 ok: true,
145 routes: CLAUDE_CODED_FORMAL_ROUTES.map((route) => ({ ...route })),
146 surface: "local-api"
147 };
148 }
149
150 async start(): Promise<ClaudeCodedLocalServiceStatus> {
151 if (this.server != null) {
152 return this.getStatus();
153 }
154
155 await this.daemon.start();
156
157 const listenConfig = resolveLocalListenConfig(this.config.service.localApiBase);
158 const server = createServer((request, response) => {
159 void this.handleRequest(request, response);
160 });
161
162 try {
163 await new Promise<void>((resolve, reject) => {
164 const onError = (error: Error) => {
165 server.off("listening", onListening);
166 reject(error);
167 };
168 const onListening = () => {
169 server.off("error", onError);
170 resolve();
171 };
172
173 server.once("error", onError);
174 server.once("listening", onListening);
175 server.listen({
176 host: listenConfig.host,
177 port: listenConfig.port
178 });
179 });
180 } catch (error) {
181 await this.daemon.stop();
182 throw error;
183 }
184
185 const address = server.address();
186
187 if (address == null || typeof address === "string") {
188 server.close();
189 await this.daemon.stop();
190 throw new Error("claude-coded local service started without a TCP address.");
191 }
192
193 this.server = server;
194 this.resolvedBaseUrl = formatLocalApiBaseUrl(address.address, (address as AddressInfo).port);
195 return this.getStatus();
196 }
197
198 async stop(): Promise<ClaudeCodedLocalServiceStatus> {
199 if (this.server != null) {
200 const server = this.server;
201 this.server = null;
202
203 await new Promise<void>((resolve, reject) => {
204 server.close((error) => {
205 if (error) {
206 reject(error);
207 return;
208 }
209
210 resolve();
211 });
212 server.closeAllConnections?.();
213 });
214 }
215
216 const snapshot = await this.daemon.stop();
217 this.resolvedBaseUrl = null;
218
219 return {
220 service: this.getRuntimeInfo(),
221 snapshot
222 };
223 }
224
225 private getRuntimeInfo(): ClaudeCodedLocalServiceRuntimeInfo {
226 return {
227 configuredBaseUrl: this.config.service.localApiBase,
228 listening: this.server != null,
229 resolvedBaseUrl: this.resolvedBaseUrl
230 };
231 }
232
233 private async handleRequest(
234 request: IncomingMessage,
235 response: ServerResponse<IncomingMessage>
236 ): Promise<void> {
237 try {
238 const result = await this.routeHttpRequest({
239 body: await readIncomingRequestBody(request),
240 method: request.method ?? "GET",
241 path: request.url ?? "/",
242 response
243 });
244
245 if (result != null) {
246 writeHttpResponse(response, result);
247 }
248 } catch (error) {
249 const status = error instanceof ClaudeCodedHttpError ? error.status : 500;
250 writeHttpResponse(
251 response,
252 jsonResponse(status, {
253 error: status >= 500 ? "internal_error" : "bad_request",
254 message: error instanceof Error ? error.message : String(error),
255 ok: false
256 })
257 );
258 }
259 }
260
261 private async routeHttpRequest(input: {
262 body: string | null;
263 method: string;
264 path: string;
265 response: ServerResponse<IncomingMessage>;
266 }): Promise<ClaudeCodedHttpResponse | null> {
267 const method = input.method.toUpperCase();
268 const url = new URL(input.path, "http://127.0.0.1");
269 const pathname = normalizePathname(url.pathname);
270 const body = parseJsonObject(input.body);
271
272 if (method === "GET" && pathname === "/healthz") {
273 return jsonResponse(200, {
274 ok: true,
275 service: this.getRuntimeInfo(),
276 status: "ok"
277 });
278 }
279
280 if (method === "GET" && pathname === "/describe") {
281 return jsonResponse(200, this.getDescribe());
282 }
283
284 if (method === "GET" && pathname === "/v1/claude-coded/status") {
285 return jsonResponse(200, {
286 data: this.getStatus(),
287 ok: true
288 });
289 }
290
291 if (method === "POST" && pathname === "/v1/claude-coded/ask") {
292 const prompt = readRequiredString(body.prompt, "prompt");
293 const result = await this.daemon.ask(prompt);
294 return jsonResponse(200, {
295 data: result,
296 ok: true
297 });
298 }
299
300 if (method === "POST" && pathname === "/v1/claude-coded/ask/stream") {
301 const prompt = readRequiredString(body.prompt, "prompt");
302 await this.handleAskStream(input.response, prompt);
303 return null;
304 }
305
306 throw new ClaudeCodedHttpError(404, `Unknown claude-coded route ${method} ${pathname}.`);
307 }
308
309 private async handleAskStream(
310 response: ServerResponse<IncomingMessage>,
311 prompt: string
312 ): Promise<void> {
313 response.statusCode = 200;
314 response.setHeader("content-type", "text/event-stream; charset=utf-8");
315 response.setHeader("cache-control", "no-store");
316 response.setHeader("connection", "keep-alive");
317 response.flushHeaders();
318
319 try {
320 const { events, result } = this.daemon.askStream(prompt);
321
322 for await (const event of events) {
323 const data = JSON.stringify(event);
324 response.write(`data: ${data}\n\n`);
325 }
326
327 const askResult = await result;
328 response.write(`event: result\ndata: ${JSON.stringify(askResult)}\n\n`);
329 } catch (error) {
330 const errorData = JSON.stringify({
331 error: error instanceof Error ? error.message : String(error),
332 ok: false
333 });
334 response.write(`event: error\ndata: ${errorData}\n\n`);
335 }
336
337 response.end();
338 }
339}
340
341function formatLocalApiBaseUrl(hostname: string, port: number): string {
342 const formattedHost = hostname.includes(":") ? `[${hostname}]` : hostname;
343 return `http://${formattedHost}${port === 80 ? "" : `:${port}`}`;
344}
345
346function isLoopbackHost(hostname: string): boolean {
347 return hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost";
348}
349
350function jsonResponse(status: number, payload: unknown): ClaudeCodedHttpResponse {
351 return {
352 body: `${JSON.stringify(payload, null, 2)}\n`,
353 headers: {
354 "cache-control": "no-store",
355 "content-type": "application/json; charset=utf-8"
356 },
357 status
358 };
359}
360
361function normalizePathname(value: string): string {
362 const normalized = value.replace(/\/+$/u, "");
363 return normalized === "" ? "/" : normalized;
364}
365
366function parseJsonObject(body: string | null): JsonRecord {
367 if (body == null || body.trim() === "") {
368 return {};
369 }
370
371 let parsed: unknown;
372
373 try {
374 parsed = JSON.parse(body);
375 } catch {
376 throw new ClaudeCodedHttpError(400, "Request body must be valid JSON.");
377 }
378
379 if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
380 throw new ClaudeCodedHttpError(400, "Request body must be a JSON object.");
381 }
382
383 return parsed as JsonRecord;
384}
385
386function readRequiredString(value: unknown, field: string): string {
387 if (typeof value !== "string") {
388 throw new ClaudeCodedHttpError(400, `${field} must be a non-empty string.`);
389 }
390
391 const normalized = value.trim();
392
393 if (normalized === "") {
394 throw new ClaudeCodedHttpError(400, `${field} must be a non-empty string.`);
395 }
396
397 return normalized;
398}
399
400async function readIncomingRequestBody(request: IncomingMessage): Promise<string | null> {
401 if (request.method == null || request.method.toUpperCase() === "GET") {
402 return null;
403 }
404
405 return await new Promise((resolve, reject) => {
406 let body = "";
407 request.setEncoding?.("utf8");
408 request.on?.("data", (chunk) => {
409 body += typeof chunk === "string" ? chunk : String(chunk);
410 });
411 request.on?.("end", () => {
412 resolve(body === "" ? null : body);
413 });
414 request.on?.("error", (error) => {
415 reject(error);
416 });
417 });
418}
419
420function resolveLocalListenConfig(localApiBase: string): { host: string; port: number } {
421 let url: URL;
422
423 try {
424 url = new URL(localApiBase);
425 } catch {
426 throw new Error("claude-coded localApiBase must be a valid absolute http:// URL.");
427 }
428
429 if (url.protocol !== "http:") {
430 throw new Error("claude-coded localApiBase must use the http:// scheme.");
431 }
432
433 if (!isLoopbackHost(url.hostname)) {
434 throw new Error("claude-coded localApiBase must use a loopback host.");
435 }
436
437 if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
438 throw new Error("claude-coded localApiBase must not include path, query, or hash.");
439 }
440
441 return {
442 host: url.hostname === "localhost" ? "127.0.0.1" : url.hostname,
443 port: url.port === "" ? 80 : Number.parseInt(url.port, 10)
444 };
445}
446
447function writeHttpResponse(
448 response: ServerResponse<IncomingMessage>,
449 payload: ClaudeCodedHttpResponse
450): void {
451 response.statusCode = payload.status;
452
453 for (const [name, value] of Object.entries(payload.headers)) {
454 response.setHeader(name, value);
455 }
456
457 response.end(payload.body);
458}