@townco/agent 0.1.54 → 0.1.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import * as acp from "@agentclientprotocol/sdk";
2
2
  import { context, trace } from "@opentelemetry/api";
3
- import { createLogger } from "@townco/core";
3
+ import { createLogger } from "../logger.js";
4
4
  import { HookExecutor, loadHookCallback } from "../runner/hooks";
5
5
  import { telemetry } from "../telemetry/index.js";
6
6
  import { calculateContextSize, } from "../utils/context-size-calculator.js";
@@ -3,13 +3,14 @@ import { join, resolve } from "node:path";
3
3
  import { gzipSync } from "node:zlib";
4
4
  import * as acp from "@agentclientprotocol/sdk";
5
5
  import { PGlite } from "@electric-sql/pglite";
6
- import { configureLogsDir, createLogger } from "@townco/core";
6
+ import { configureLogsDir } from "@townco/core";
7
7
  import { Hono } from "hono";
8
8
  import { cors } from "hono/cors";
9
9
  import { streamSSE } from "hono/streaming";
10
+ import { createLogger, isSubagent } from "../logger.js";
10
11
  import { makeRunnerFromDefinition } from "../runner";
11
12
  import { AgentAcpAdapter } from "./adapter";
12
- const logger = createLogger("agent");
13
+ const logger = createLogger("http");
13
14
  /**
14
15
  * Compress a payload using gzip if it's too large for PostgreSQL NOTIFY
15
16
  * Returns an object with the payload and metadata about compression
@@ -49,11 +50,16 @@ function safeChannelName(prefix, id) {
49
50
  return `${prefix}_${hash}`;
50
51
  }
51
52
  export function makeHttpTransport(agent, agentDir, agentName) {
52
- // Configure logger to write to .logs/ directory if agentDir is provided
53
- if (agentDir) {
54
- const logsDir = join(agentDir, ".logs");
53
+ // Configure logger to write to .logs/ directory
54
+ // Use TOWN_LOGS_DIR env var if set (for subagents), otherwise use agentDir
55
+ const logsDir = process.env.TOWN_LOGS_DIR ||
56
+ (agentDir ? join(agentDir, ".logs") : undefined);
57
+ if (logsDir) {
55
58
  configureLogsDir(logsDir);
56
- logger.info("Configured logs directory", { logsDir });
59
+ logger.info("Configured logs directory", {
60
+ logsDir,
61
+ isSubagent: isSubagent(),
62
+ });
57
63
  }
58
64
  const inbound = new TransformStream();
59
65
  const outbound = new TransformStream();
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { basename } from "node:path";
2
- import { createLogger } from "@townco/core";
3
2
  import { makeHttpTransport, makeStdioTransport } from "./acp-server";
3
+ import { createLogger } from "./logger.js";
4
4
  import { initializeOpenTelemetryFromEnv } from "./telemetry/setup.js";
5
5
  import { makeSubagentsTool } from "./utils";
6
6
  // Re-export telemetry configuration for library users
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Logger utilities for the agent package.
3
+ * Provides subagent-aware service name generation.
4
+ */
5
+ import { createLogger as coreCreateLogger } from "@townco/core";
6
+ /**
7
+ * Check if running as a subagent (detected via TOWN_LOGS_DIR env var)
8
+ */
9
+ export declare function isSubagent(): boolean;
10
+ /**
11
+ * Create a logger with subagent-aware service name.
12
+ * When running as a subagent, the service name is prefixed with "subagent:{port}:{name}:".
13
+ *
14
+ * @param service - The service name (e.g., "adapter", "hook-executor")
15
+ * @returns A logger instance with the appropriate service name
16
+ *
17
+ * @example
18
+ * // In main agent: creates logger with service "adapter"
19
+ * // In subagent on port 4001 named "researcher": creates logger with service "subagent:4001:researcher:adapter"
20
+ * const logger = createLogger("adapter");
21
+ */
22
+ export declare function createLogger(service: string): import("@townco/core").Logger;
23
+ /**
24
+ * Re-export the core createLogger for cases where subagent prefix is not wanted
25
+ */
26
+ export { coreCreateLogger as createCoreLogger };
package/dist/logger.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Logger utilities for the agent package.
3
+ * Provides subagent-aware service name generation.
4
+ */
5
+ import { createLogger as coreCreateLogger } from "@townco/core";
6
+ /**
7
+ * Check if running as a subagent (detected via TOWN_LOGS_DIR env var)
8
+ */
9
+ export function isSubagent() {
10
+ return !!process.env.TOWN_LOGS_DIR;
11
+ }
12
+ /**
13
+ * Get the subagent prefix if running as a subagent.
14
+ * Returns format: "subagent:{port}:{name}:" or empty string if not a subagent.
15
+ */
16
+ function getSubagentPrefix() {
17
+ if (!isSubagent()) {
18
+ return "";
19
+ }
20
+ const port = process.env.PORT || "unknown";
21
+ const name = process.env.TOWN_SUBAGENT_NAME || "agent";
22
+ return `subagent:${port}:${name}:`;
23
+ }
24
+ /**
25
+ * Create a logger with subagent-aware service name.
26
+ * When running as a subagent, the service name is prefixed with "subagent:{port}:{name}:".
27
+ *
28
+ * @param service - The service name (e.g., "adapter", "hook-executor")
29
+ * @returns A logger instance with the appropriate service name
30
+ *
31
+ * @example
32
+ * // In main agent: creates logger with service "adapter"
33
+ * // In subagent on port 4001 named "researcher": creates logger with service "subagent:4001:researcher:adapter"
34
+ * const logger = createLogger("adapter");
35
+ */
36
+ export function createLogger(service) {
37
+ const prefix = getSubagentPrefix();
38
+ return coreCreateLogger(`${prefix}${service}`);
39
+ }
40
+ /**
41
+ * Re-export the core createLogger for cases where subagent prefix is not wanted
42
+ */
43
+ export { coreCreateLogger as createCoreLogger };
@@ -1,4 +1,4 @@
1
- import { createLogger } from "@townco/core";
1
+ import { createLogger } from "../../logger.js";
2
2
  import { DEFAULT_CONTEXT_SIZE, MODEL_CONTEXT_WINDOWS } from "./constants";
3
3
  const logger = createLogger("hook-executor");
4
4
  /**
@@ -1,5 +1,5 @@
1
1
  import { resolve } from "node:path";
2
- import { createLogger } from "@townco/core";
2
+ import { createLogger } from "../../logger.js";
3
3
  import { getPredefinedHook, isPredefinedHook } from "./registry";
4
4
  const logger = createLogger("hook-loader");
5
5
  /**
@@ -1,6 +1,6 @@
1
1
  import { ChatAnthropic } from "@langchain/anthropic";
2
2
  import { HumanMessage, SystemMessage } from "@langchain/core/messages";
3
- import { createLogger } from "@townco/core";
3
+ import { createLogger } from "../../../logger.js";
4
4
  import { createContextEntry, createFullMessageEntry, } from "../types";
5
5
  const logger = createLogger("compaction-tool");
6
6
  /**
@@ -1,6 +1,6 @@
1
1
  import { ChatAnthropic } from "@langchain/anthropic";
2
2
  import { HumanMessage, SystemMessage } from "@langchain/core/messages";
3
- import { createLogger } from "@townco/core";
3
+ import { createLogger } from "../../../logger.js";
4
4
  import { countToolResultTokens } from "../../../utils/token-counter.js";
5
5
  const logger = createLogger("tool-response-compactor");
6
6
  // Haiku 4.5 for compaction (fast and cost-effective)
@@ -1,9 +1,9 @@
1
1
  import { MultiServerMCPClient } from "@langchain/mcp-adapters";
2
2
  import { context, propagation, trace } from "@opentelemetry/api";
3
- import { createLogger } from "@townco/core";
4
3
  import { AIMessageChunk, createAgent, ToolMessage, tool, } from "langchain";
5
4
  import { z } from "zod";
6
5
  import { SUBAGENT_MODE_KEY } from "../../acp-server/adapter";
6
+ import { createLogger } from "../../logger.js";
7
7
  import { telemetry } from "../../telemetry/index.js";
8
8
  import { loadCustomToolModule, } from "../tool-loader.js";
9
9
  import { createModelFromString, detectProvider } from "./model-factory.js";
@@ -1,7 +1,7 @@
1
1
  import { ChatAnthropic } from "@langchain/anthropic";
2
2
  import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
3
3
  import { ChatVertexAI } from "@langchain/google-vertexai";
4
- import { createLogger } from "@townco/core";
4
+ import { createLogger } from "../../logger.js";
5
5
  const logger = createLogger("model-factory");
6
6
  /**
7
7
  * Detects the provider from a model string and returns the appropriate
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Check if a port is available
3
+ */
4
+ export declare function isPortAvailable(port: number): Promise<boolean>;
5
+ /**
6
+ * Find the next available port starting from the given port
7
+ */
8
+ export declare function findAvailablePort(startPort: number, maxAttempts?: number): Promise<number>;
@@ -0,0 +1,35 @@
1
+ import { createServer } from "node:net";
2
+ /**
3
+ * Check if a port is available
4
+ */
5
+ export async function isPortAvailable(port) {
6
+ return new Promise((resolve) => {
7
+ const server = createServer();
8
+ server.once("error", (err) => {
9
+ if (err.code === "EADDRINUSE") {
10
+ resolve(false);
11
+ }
12
+ else {
13
+ resolve(false);
14
+ }
15
+ });
16
+ server.once("listening", () => {
17
+ server.close();
18
+ resolve(true);
19
+ });
20
+ server.listen(port);
21
+ });
22
+ }
23
+ /**
24
+ * Find the next available port starting from the given port
25
+ */
26
+ export async function findAvailablePort(startPort, maxAttempts = 100) {
27
+ for (let i = 0; i < maxAttempts; i++) {
28
+ const port = startPort + i;
29
+ const available = await isPortAvailable(port);
30
+ if (available) {
31
+ return port;
32
+ }
33
+ }
34
+ throw new Error(`Could not find an available port between ${startPort} and ${startPort + maxAttempts - 1}`);
35
+ }
@@ -1,15 +1,44 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import { Readable, Writable } from "node:stream";
5
- import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, } from "@agentclientprotocol/sdk";
4
+ import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
6
5
  import { context, propagation, trace } from "@opentelemetry/api";
6
+ import { createLogger as coreCreateLogger } from "@townco/core";
7
7
  import { z } from "zod";
8
8
  import { SUBAGENT_MODE_KEY } from "../../../acp-server/adapter.js";
9
+ import { findAvailablePort } from "./port-utils.js";
9
10
  /**
10
11
  * Name of the Task tool created by makeSubagentsTool
11
12
  */
12
13
  export const SUBAGENT_TOOL_NAME = "subagent";
14
+ /**
15
+ * Base port for subagent HTTP servers (avoid conflict with main agents at 3100+)
16
+ */
17
+ const SUBAGENT_BASE_PORT = 4000;
18
+ /**
19
+ * Wait for HTTP server to be ready by polling health endpoint
20
+ */
21
+ async function waitForServerReady(port, timeoutMs = 30000) {
22
+ const startTime = Date.now();
23
+ const baseDelay = 50;
24
+ let attempt = 0;
25
+ while (Date.now() - startTime < timeoutMs) {
26
+ try {
27
+ const response = await fetch(`http://localhost:${port}/health`, {
28
+ signal: AbortSignal.timeout(1000),
29
+ });
30
+ if (response.ok) {
31
+ return;
32
+ }
33
+ }
34
+ catch {
35
+ // Server not ready yet
36
+ }
37
+ await new Promise((r) => setTimeout(r, baseDelay * Math.pow(1.5, attempt)));
38
+ attempt++;
39
+ }
40
+ throw new Error(`Subagent server at port ${port} did not become ready within ${timeoutMs}ms`);
41
+ }
13
42
  /**
14
43
  * Creates a DirectTool that delegates work to one of multiple configured subagents.
15
44
  *
@@ -145,14 +174,14 @@ assistant: "I'm going to use the Task tool to launch the greeting-responder agen
145
174
  if (!agent) {
146
175
  throw new Error(`Unknown agent: ${agentName}`);
147
176
  }
148
- return await querySubagent(agent.agentPath, agent.agentDir, query);
177
+ return await querySubagent(agentName, agent.agentPath, agent.agentDir, query);
149
178
  },
150
179
  };
151
180
  }
152
181
  /**
153
- * Internal function that spawns a subagent process and collects its response.
182
+ * Internal function that spawns a subagent HTTP server and queries it.
154
183
  */
155
- async function querySubagent(agentPath, agentWorkingDirectory, query) {
184
+ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query) {
156
185
  // Validate that the agent exists
157
186
  try {
158
187
  await fs.access(agentPath);
@@ -160,153 +189,227 @@ async function querySubagent(agentPath, agentWorkingDirectory, query) {
160
189
  catch (_error) {
161
190
  throw new Error(`Agent not found at ${agentPath}. Make sure the agent exists and has an index.ts file.`);
162
191
  }
192
+ // Find an available port for this subagent
193
+ const port = await findAvailablePort(SUBAGENT_BASE_PORT, 100);
194
+ // Create a logger for this subagent instance with port prefix
195
+ // Use core logger directly since service name already identifies the subagent
196
+ const logger = coreCreateLogger(`subagent:${port}:${agentName}`);
163
197
  let agentProcess = null;
164
- let connection = null;
198
+ let sseAbortController = null;
165
199
  try {
166
- // Spawn the agent process
167
- agentProcess = spawn("bun", [agentPath], {
200
+ // Get the parent's logs directory to pass to the subagent
201
+ const parentLogsDir = process.env.TOWN_LOGS_DIR || path.join(process.cwd(), ".logs");
202
+ // Spawn the agent process in HTTP mode
203
+ agentProcess = spawn("bun", [agentPath, "http"], {
168
204
  cwd: agentWorkingDirectory,
169
- env: { ...process.env },
205
+ env: {
206
+ ...process.env,
207
+ PORT: String(port),
208
+ TOWN_LOGS_DIR: parentLogsDir,
209
+ TOWN_SUBAGENT_NAME: agentName,
210
+ },
170
211
  stdio: ["pipe", "pipe", "pipe"],
171
212
  });
172
- if (!agentProcess.stdin || !agentProcess.stdout || !agentProcess.stderr) {
173
- throw new Error("Failed to create stdio pipes for agent process");
213
+ if (!agentProcess.stderr) {
214
+ throw new Error("Failed to create stderr pipe for agent process");
174
215
  }
175
- // Convert Node.js streams to Web streams
176
- const outputStream = Writable.toWeb(agentProcess.stdin);
177
- const inputStream = Readable.toWeb(agentProcess.stdout);
178
- // Create the bidirectional stream using ndJsonStream
179
- const stream = ndJsonStream(outputStream, inputStream);
180
- // Track accumulated response text
181
- let responseText = "";
182
- // Create ACP client implementation factory
183
- const clientFactory = (_agent) => {
184
- return {
185
- async requestPermission(_params) {
186
- // Deny all permission requests from the subagent
187
- return { outcome: { outcome: "cancelled" } };
188
- },
189
- async sessionUpdate(params) {
190
- // Handle session updates from the agent
191
- const paramsExtended = params;
192
- const update = paramsExtended.update;
193
- // Reset accumulated text when a tool call starts (marks a new message boundary)
194
- if (update?.sessionUpdate === "tool_call") {
195
- responseText = "";
196
- }
197
- // Accumulate agent_message_chunk text content
198
- if (update?.sessionUpdate === "agent_message_chunk") {
199
- const content = update.content;
200
- if (content &&
201
- content.type === "text" &&
202
- typeof content.text === "string") {
203
- responseText += content.text;
204
- }
205
- }
206
- },
207
- async writeTextFile() {
208
- // Subagents should not write files outside their scope
209
- throw new Error("Subagent attempted to write files, which is not allowed");
210
- },
211
- async readTextFile() {
212
- // Subagents should not read files outside their scope
213
- throw new Error("Subagent attempted to read files, which is not allowed");
214
- },
215
- };
216
- };
217
- // Create the client-side connection
218
- connection = new ClientSideConnection(clientFactory, stream);
219
- // Set up timeout for the entire operation
220
- const timeoutMs = 5 * 60 * 1000; // 5 minutes
221
- const timeoutPromise = new Promise((_resolve, reject) => {
222
- setTimeout(() => {
223
- reject(new Error(`Subagent query timed out after ${timeoutMs / 1000} seconds`));
224
- }, timeoutMs);
225
- });
226
- // Handle process errors and exit
227
- const processExitPromise = new Promise((_resolve, reject) => {
228
- agentProcess?.on("exit", (code, signal) => {
229
- if (code !== 0 && code !== null) {
230
- reject(new Error(`Agent process exited with code ${code} and signal ${signal}`));
216
+ // Capture stdout and forward to logger
217
+ if (agentProcess.stdout) {
218
+ agentProcess.stdout.on("data", (data) => {
219
+ const lines = data
220
+ .toString()
221
+ .split("\n")
222
+ .filter((line) => line.trim());
223
+ for (const line of lines) {
224
+ logger.info(line);
231
225
  }
232
226
  });
227
+ }
228
+ // Capture stderr and forward to logger as errors
229
+ agentProcess.stderr.on("data", (data) => {
230
+ const lines = data
231
+ .toString()
232
+ .split("\n")
233
+ .filter((line) => line.trim());
234
+ for (const line of lines) {
235
+ logger.error(line);
236
+ }
237
+ });
238
+ // Handle process errors
239
+ const processErrorPromise = new Promise((_, reject) => {
233
240
  agentProcess?.on("error", (error) => {
241
+ logger.error(`Process error: ${error.message}`);
234
242
  reject(new Error(`Agent process error: ${error.message}`));
235
243
  });
244
+ agentProcess?.on("exit", (code, signal) => {
245
+ if (code !== 0 && code !== null) {
246
+ logger.error(`Process exited with code ${code}, signal ${signal}`);
247
+ reject(new Error(`Agent process exited unexpectedly with code ${code}, signal ${signal}`));
248
+ }
249
+ });
236
250
  });
237
- // Run the query with timeout and error handling
238
- const queryPromise = (async () => {
239
- // Initialize the connection
240
- await connection?.initialize({
241
- protocolVersion: PROTOCOL_VERSION,
242
- clientCapabilities: {
243
- fs: {
244
- readTextFile: false,
245
- writeTextFile: false,
251
+ logger.info(`Starting subagent HTTP server on port ${port}`);
252
+ // Wait for server to be ready
253
+ await Promise.race([waitForServerReady(port), processErrorPromise]);
254
+ logger.info(`Subagent server ready on port ${port}`);
255
+ const baseUrl = `http://localhost:${port}`;
256
+ // Step 1: Initialize ACP connection
257
+ const initResponse = await fetch(`${baseUrl}/rpc`, {
258
+ method: "POST",
259
+ headers: { "Content-Type": "application/json" },
260
+ body: JSON.stringify({
261
+ jsonrpc: "2.0",
262
+ id: "init-1",
263
+ method: "initialize",
264
+ params: {
265
+ protocolVersion: PROTOCOL_VERSION,
266
+ clientCapabilities: {
267
+ fs: { readTextFile: false, writeTextFile: false },
246
268
  },
247
269
  },
248
- });
249
- // Prepare OpenTelemetry trace context to propagate to the subagent.
250
- // We inject from the current active context so the subagent's root
251
- // invocation span can be a child of whatever span is active when
252
- // this Task tool runs (ideally the agent.tool_call span).
253
- if (process.env.DEBUG_TELEMETRY === "true") {
254
- console.log(`[querySubagent] Tool function executing for agent: ${agentPath}`);
255
- }
256
- const otelCarrier = {};
257
- const activeCtx = context.active();
258
- const activeSpan = trace.getSpan(activeCtx);
259
- if (process.env.DEBUG_TELEMETRY === "true") {
260
- console.log(`[querySubagent] Active span when tool executes:`, activeSpan?.spanContext());
261
- }
262
- const ctxForInjection = activeSpan
263
- ? trace.setSpan(activeCtx, activeSpan)
264
- : activeCtx;
270
+ }),
271
+ });
272
+ if (!initResponse.ok) {
273
+ throw new Error(`Initialize failed: HTTP ${initResponse.status}`);
274
+ }
275
+ // Step 2: Create new session with subagent mode and OTEL context
276
+ // Prepare OpenTelemetry trace context to propagate to the subagent
277
+ const otelCarrier = {};
278
+ const activeCtx = context.active();
279
+ const activeSpan = trace.getSpan(activeCtx);
280
+ if (process.env.DEBUG_TELEMETRY === "true") {
281
+ console.log(`[querySubagent] Active span when tool executes:`, activeSpan?.spanContext());
282
+ }
283
+ if (activeSpan) {
284
+ const ctxForInjection = trace.setSpan(activeCtx, activeSpan);
265
285
  propagation.inject(ctxForInjection, otelCarrier);
266
- const hasOtelContext = Object.keys(otelCarrier).length > 0;
267
- // Create a new session with subagent mode flag and OTEL trace context
268
- const sessionResponse = await connection?.newSession({
269
- cwd: agentWorkingDirectory,
270
- mcpServers: [],
271
- _meta: {
272
- [SUBAGENT_MODE_KEY]: true,
273
- ...(hasOtelContext ? { otelTraceContext: otelCarrier } : {}),
286
+ }
287
+ const hasOtelContext = Object.keys(otelCarrier).length > 0;
288
+ const sessionResponse = await fetch(`${baseUrl}/rpc`, {
289
+ method: "POST",
290
+ headers: { "Content-Type": "application/json" },
291
+ body: JSON.stringify({
292
+ jsonrpc: "2.0",
293
+ id: "session-1",
294
+ method: "session/new",
295
+ params: {
296
+ cwd: agentWorkingDirectory,
297
+ mcpServers: [],
298
+ _meta: {
299
+ [SUBAGENT_MODE_KEY]: true,
300
+ ...(hasOtelContext ? { otelTraceContext: otelCarrier } : {}),
301
+ },
274
302
  },
303
+ }),
304
+ });
305
+ if (!sessionResponse.ok) {
306
+ throw new Error(`Session creation failed: HTTP ${sessionResponse.status}`);
307
+ }
308
+ const sessionResult = (await sessionResponse.json());
309
+ const sessionId = sessionResult.result?.sessionId;
310
+ if (!sessionId) {
311
+ throw new Error("No sessionId in session/new response");
312
+ }
313
+ // Step 3: Connect to SSE for receiving streaming responses
314
+ sseAbortController = new AbortController();
315
+ let responseText = "";
316
+ const ssePromise = (async () => {
317
+ const sseResponse = await fetch(`${baseUrl}/events`, {
318
+ headers: { "X-Session-ID": sessionId },
319
+ signal: sseAbortController.signal,
275
320
  });
276
- // Send the prompt
277
- await connection?.prompt({
278
- sessionId: sessionResponse.sessionId,
279
- prompt: [
280
- {
281
- type: "text",
282
- text: query,
321
+ if (!sseResponse.ok || !sseResponse.body) {
322
+ throw new Error(`SSE connection failed: HTTP ${sseResponse.status}`);
323
+ }
324
+ const reader = sseResponse.body.getReader();
325
+ const decoder = new TextDecoder();
326
+ let buffer = "";
327
+ while (true) {
328
+ const { done, value } = await reader.read();
329
+ if (done)
330
+ break;
331
+ buffer += decoder.decode(value, { stream: true });
332
+ const lines = buffer.split("\n");
333
+ buffer = lines.pop() || "";
334
+ for (const line of lines) {
335
+ if (line.startsWith("data:")) {
336
+ const data = line.substring(5).trim();
337
+ if (!data)
338
+ continue;
339
+ try {
340
+ const message = JSON.parse(data);
341
+ // Handle session/update notifications for agent_message_chunk
342
+ if (message.method === "session/update" &&
343
+ message.params?.update?.sessionUpdate === "agent_message_chunk") {
344
+ const content = message.params.update.content;
345
+ if (content?.type === "text" &&
346
+ typeof content.text === "string") {
347
+ responseText += content.text;
348
+ }
349
+ }
350
+ // Reset on tool_call (marks new message boundary)
351
+ if (message.params?.update?.sessionUpdate === "tool_call") {
352
+ responseText = "";
353
+ }
354
+ }
355
+ catch {
356
+ // Ignore malformed SSE data
357
+ }
358
+ }
359
+ }
360
+ }
361
+ })();
362
+ // Step 4: Send the prompt with timeout
363
+ const timeoutMs = 5 * 60 * 1000; // 5 minutes
364
+ const promptPromise = (async () => {
365
+ const promptResponse = await fetch(`${baseUrl}/rpc`, {
366
+ method: "POST",
367
+ headers: { "Content-Type": "application/json" },
368
+ body: JSON.stringify({
369
+ jsonrpc: "2.0",
370
+ id: "prompt-1",
371
+ method: "session/prompt",
372
+ params: {
373
+ sessionId,
374
+ prompt: [{ type: "text", text: query }],
283
375
  },
284
- ],
376
+ }),
285
377
  });
286
- return responseText;
378
+ if (!promptResponse.ok) {
379
+ throw new Error(`Prompt failed: HTTP ${promptResponse.status}`);
380
+ }
381
+ // Wait for prompt to complete (this blocks until agent finishes processing)
382
+ const promptResult = (await promptResponse.json());
383
+ if (promptResult.error) {
384
+ throw new Error(`Prompt error: ${promptResult.error.message || JSON.stringify(promptResult.error)}`);
385
+ }
287
386
  })();
288
- // Race between query execution, timeout, and process exit
289
- return await Promise.race([
290
- queryPromise,
291
- timeoutPromise,
292
- processExitPromise,
387
+ const timeoutPromise = new Promise((_, reject) => {
388
+ setTimeout(() => {
389
+ reject(new Error(`Subagent query timed out after ${timeoutMs / 1000} seconds`));
390
+ }, timeoutMs);
391
+ });
392
+ // Wait for prompt to complete with timeout
393
+ await Promise.race([promptPromise, timeoutPromise, processErrorPromise]);
394
+ // Give SSE a moment to flush remaining messages
395
+ await new Promise((r) => setTimeout(r, 100));
396
+ // Abort SSE connection
397
+ sseAbortController.abort();
398
+ // Wait for SSE to finish (with timeout)
399
+ await Promise.race([
400
+ ssePromise.catch(() => { }), // Ignore abort errors
401
+ new Promise((r) => setTimeout(r, 1000)),
293
402
  ]);
403
+ return responseText;
294
404
  }
295
405
  finally {
296
- // Cleanup: kill process and close connection
406
+ // Cleanup: abort SSE and kill process
407
+ logger.info(`Shutting down subagent on port ${port}`);
408
+ if (sseAbortController) {
409
+ sseAbortController.abort();
410
+ }
297
411
  if (agentProcess) {
298
412
  agentProcess.kill();
299
413
  }
300
- if (connection) {
301
- try {
302
- await Promise.race([
303
- connection.closed,
304
- new Promise((resolve) => setTimeout(resolve, 1000)),
305
- ]);
306
- }
307
- catch {
308
- // Ignore cleanup errors
309
- }
310
- }
311
414
  }
312
415
  }
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readdir, rm, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
- import { createLogger } from "@townco/core";
4
+ import { createLogger } from "../logger.js";
5
5
  const AGENTS_DIR = join(homedir(), ".config", "town", "agents");
6
6
  const logger = createLogger("storage");
7
7
  /**