baa-conductor


baa-conductor / apps / claude-coded / src
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}