@townco/agent 0.1.56 → 0.1.58

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.
@@ -25,6 +25,7 @@ export declare class AgentAcpAdapter implements acp.Agent {
25
25
  private agentDescription;
26
26
  private agentSuggestedPrompts;
27
27
  private agentInitialMessage;
28
+ private agentUiConfig;
28
29
  private currentToolOverheadTokens;
29
30
  private currentMcpOverheadTokens;
30
31
  constructor(agent: AgentRunner, connection: acp.AgentSideConnection, agentDir?: string, agentName?: string);
@@ -49,6 +50,11 @@ export declare class AgentAcpAdapter implements acp.Agent {
49
50
  private saveSessionToDisk;
50
51
  initialize(_params: acp.InitializeRequest): Promise<acp.InitializeResponse>;
51
52
  newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse>;
53
+ /**
54
+ * Store an initial message in the session.
55
+ * Called by the HTTP transport after sending the initial message via SSE.
56
+ */
57
+ storeInitialMessage(sessionId: string, content: string): Promise<void>;
52
58
  loadSession(params: acp.LoadSessionRequest): Promise<acp.LoadSessionResponse>;
53
59
  authenticate(_params: acp.AuthenticateRequest): Promise<acp.AuthenticateResponse | undefined>;
54
60
  setSessionMode(_params: acp.SetSessionModeRequest): Promise<acp.SetSessionModeResponse>;
@@ -106,6 +106,7 @@ export class AgentAcpAdapter {
106
106
  agentDescription;
107
107
  agentSuggestedPrompts;
108
108
  agentInitialMessage;
109
+ agentUiConfig;
109
110
  currentToolOverheadTokens = 0; // Track tool overhead for current turn
110
111
  currentMcpOverheadTokens = 0; // Track MCP overhead for current turn
111
112
  constructor(agent, connection, agentDir, agentName) {
@@ -119,6 +120,7 @@ export class AgentAcpAdapter {
119
120
  this.agentDescription = agent.definition.description;
120
121
  this.agentSuggestedPrompts = agent.definition.suggestedPrompts;
121
122
  this.agentInitialMessage = agent.definition.initialMessage;
123
+ this.agentUiConfig = agent.definition.uiConfig;
122
124
  this.noSession = process.env.TOWN_NO_SESSION === "true";
123
125
  this.storage =
124
126
  agentDir && agentName && !this.noSession
@@ -265,6 +267,7 @@ export class AgentAcpAdapter {
265
267
  ...(this.agentInitialMessage
266
268
  ? { initialMessage: this.agentInitialMessage }
267
269
  : {}),
270
+ ...(this.agentUiConfig ? { uiConfig: this.agentUiConfig } : {}),
268
271
  ...(toolsMetadata.length > 0 ? { tools: toolsMetadata } : {}),
269
272
  ...(mcpsMetadata.length > 0 ? { mcps: mcpsMetadata } : {}),
270
273
  ...(subagentsMetadata.length > 0 ? { subagents: subagentsMetadata } : {}),
@@ -285,6 +288,32 @@ export class AgentAcpAdapter {
285
288
  sessionId,
286
289
  };
287
290
  }
291
+ /**
292
+ * Store an initial message in the session.
293
+ * Called by the HTTP transport after sending the initial message via SSE.
294
+ */
295
+ async storeInitialMessage(sessionId, content) {
296
+ const session = this.sessions.get(sessionId);
297
+ if (!session) {
298
+ logger.warn("Cannot store initial message - session not found", {
299
+ sessionId,
300
+ });
301
+ return;
302
+ }
303
+ // Add the initial message as an assistant message
304
+ const initialMessage = {
305
+ role: "assistant",
306
+ content: [{ type: "text", text: content }],
307
+ timestamp: new Date().toISOString(),
308
+ };
309
+ session.messages.push(initialMessage);
310
+ // Save to disk if session persistence is enabled
311
+ await this.saveSessionToDisk(sessionId, session);
312
+ logger.debug("Stored initial message in session", {
313
+ sessionId,
314
+ contentPreview: content.slice(0, 100),
315
+ });
316
+ }
288
317
  async loadSession(params) {
289
318
  if (!this.storage) {
290
319
  throw new Error("Session storage is not configured");
@@ -518,13 +547,11 @@ export class AgentAcpAdapter {
518
547
  ? session.context[session.context.length - 1]
519
548
  : undefined;
520
549
  // Calculate context size for this snapshot
521
- // Build message pointers for the new context (previous messages + new user message)
550
+ // Build message pointers for the new context (previous messages only, NOT the new user message)
551
+ // The new user message will be passed separately via the prompt parameter
522
552
  const messageEntries = previousContext
523
- ? [
524
- ...previousContext.messages,
525
- { type: "pointer", index: session.messages.length - 1 },
526
- ]
527
- : [{ type: "pointer", index: 0 }];
553
+ ? [...previousContext.messages]
554
+ : [];
528
555
  // Resolve message entries to actual messages
529
556
  const contextMessages = [];
530
557
  for (const entry of messageEntries) {
@@ -749,6 +776,23 @@ export class AgentAcpAdapter {
749
776
  toolCallBlock.completedAt = Date.now();
750
777
  }
751
778
  }
779
+ // Forward tool_call_update with _meta to the client (for subagent connection info, etc.)
780
+ if (updateMsg._meta) {
781
+ logger.info("Forwarding tool_call_update with _meta to client", {
782
+ toolCallId: updateMsg.toolCallId,
783
+ status: updateMsg.status,
784
+ _meta: updateMsg._meta,
785
+ });
786
+ this.connection.sessionUpdate({
787
+ sessionId: params.sessionId,
788
+ update: {
789
+ sessionUpdate: "tool_call_update",
790
+ toolCallId: updateMsg.toolCallId,
791
+ status: updateMsg.status,
792
+ _meta: updateMsg._meta,
793
+ },
794
+ });
795
+ }
752
796
  }
753
797
  // Handle tool_output - update ToolCallBlock with output content
754
798
  if ("sessionUpdate" in msg && msg.sessionUpdate === "tool_output") {
@@ -816,33 +860,53 @@ export class AgentAcpAdapter {
816
860
  // Check if we already have a partial assistant message in messages
817
861
  const lastMessage = session.messages[session.messages.length - 1];
818
862
  let partialMessageIndex;
863
+ let userMessageIndex;
864
+ let isFirstToolInTurn;
819
865
  if (lastMessage && lastMessage.role === "assistant") {
820
- // Update existing partial message
866
+ // Update existing partial message (subsequent tool in same turn)
821
867
  session.messages[session.messages.length - 1] =
822
868
  partialAssistantMessage;
823
869
  partialMessageIndex = session.messages.length - 1;
870
+ userMessageIndex = session.messages.length - 2;
871
+ isFirstToolInTurn = false;
824
872
  }
825
873
  else {
826
- // Add new partial message
874
+ // Add new partial message (first tool in this turn)
827
875
  session.messages.push(partialAssistantMessage);
828
876
  partialMessageIndex = session.messages.length - 1;
877
+ userMessageIndex = session.messages.length - 2;
878
+ isFirstToolInTurn = true;
829
879
  }
830
880
  // Get the latest context
831
881
  const latestContext = session.context.length > 0
832
882
  ? session.context[session.context.length - 1]
833
883
  : undefined;
834
884
  // Build message entries for the new context
835
- // Check if we already have a pointer to this message (during mid-turn updates)
836
885
  const existingMessages = latestContext?.messages ?? [];
837
- const lastEntry = existingMessages[existingMessages.length - 1];
838
- const alreadyHasPointer = lastEntry?.type === "pointer" &&
839
- lastEntry.index === partialMessageIndex;
840
- const messageEntries = alreadyHasPointer
841
- ? existingMessages // Don't add duplicate pointer
842
- : [
886
+ // Check if we already have pointers to these messages
887
+ const hasUserPointer = existingMessages.some((entry) => entry.type === "pointer" && entry.index === userMessageIndex);
888
+ const hasAssistantPointer = existingMessages.some((entry) => entry.type === "pointer" &&
889
+ entry.index === partialMessageIndex);
890
+ let messageEntries;
891
+ if (isFirstToolInTurn && !hasUserPointer) {
892
+ // First tool: add both user and assistant pointers
893
+ messageEntries = [
843
894
  ...existingMessages,
895
+ { type: "pointer", index: userMessageIndex },
844
896
  { type: "pointer", index: partialMessageIndex },
845
897
  ];
898
+ }
899
+ else if (!hasAssistantPointer) {
900
+ // Subsequent tool or user already added: just update/add assistant pointer
901
+ messageEntries = [
902
+ ...existingMessages,
903
+ { type: "pointer", index: partialMessageIndex },
904
+ ];
905
+ }
906
+ else {
907
+ // Both pointers already exist (updating existing assistant message)
908
+ messageEntries = existingMessages;
909
+ }
846
910
  // Resolve message entries to actual messages
847
911
  const contextMessages = [];
848
912
  for (const entry of messageEntries) {
@@ -985,13 +1049,19 @@ export class AgentAcpAdapter {
985
1049
  ? session.context[session.context.length - 1]
986
1050
  : undefined;
987
1051
  // Calculate final context size
988
- // Build message pointers for the new context
1052
+ // Build message pointers for the new context (add both user and assistant messages)
1053
+ const userMessageIndex = session.messages.length - 2;
1054
+ const assistantMessageIndex = session.messages.length - 1;
989
1055
  const messageEntries = previousContext
990
1056
  ? [
991
1057
  ...previousContext.messages,
992
- { type: "pointer", index: session.messages.length - 1 },
1058
+ { type: "pointer", index: userMessageIndex },
1059
+ { type: "pointer", index: assistantMessageIndex },
993
1060
  ]
994
- : [{ type: "pointer", index: session.messages.length - 1 }];
1061
+ : [
1062
+ { type: "pointer", index: userMessageIndex },
1063
+ { type: "pointer", index: assistantMessageIndex },
1064
+ ];
995
1065
  // Resolve message entries to actual messages
996
1066
  const contextMessages = [];
997
1067
  for (const entry of messageEntries) {
@@ -66,7 +66,12 @@ export function makeHttpTransport(agent, agentDir, agentName) {
66
66
  const outbound = new TransformStream();
67
67
  const bridge = acp.ndJsonStream(outbound.writable, inbound.readable);
68
68
  const agentRunner = "definition" in agent ? agent : makeRunnerFromDefinition(agent);
69
- new acp.AgentSideConnection((conn) => new AgentAcpAdapter(agentRunner, conn, agentDir, agentName), bridge);
69
+ // Store adapter reference so we can call methods on it (e.g., storeInitialMessage)
70
+ let acpAdapter = null;
71
+ new acp.AgentSideConnection((conn) => {
72
+ acpAdapter = new AgentAcpAdapter(agentRunner, conn, agentDir, agentName);
73
+ return acpAdapter;
74
+ }, bridge);
70
75
  const app = new Hono();
71
76
  // Track active SSE streams by sessionId for direct output delivery
72
77
  const sseStreams = new Map();
@@ -369,6 +374,7 @@ export function makeHttpTransport(agent, agentDir, agentName) {
369
374
  },
370
375
  _meta: {
371
376
  isInitialMessage: true,
377
+ isReplay: true, // Mark as replay so UI adds it to messages
372
378
  },
373
379
  },
374
380
  },
@@ -377,6 +383,10 @@ export function makeHttpTransport(agent, agentDir, agentName) {
377
383
  event: "message",
378
384
  data: JSON.stringify(initialMessage),
379
385
  });
386
+ // Store the initial message in the session for persistence
387
+ if (acpAdapter) {
388
+ await acpAdapter.storeInitialMessage(sessionId, content);
389
+ }
380
390
  logger.info("Sent initial message via SSE", {
381
391
  sessionId,
382
392
  contentPreview: content.slice(0, 100),
@@ -31,6 +31,10 @@ export declare const InitialMessageSchema: z.ZodObject<{
31
31
  enabled: z.ZodBoolean;
32
32
  content: z.ZodString;
33
33
  }, z.core.$strip>;
34
+ /** UI configuration schema for controlling the chat interface appearance. */
35
+ export declare const UiConfigSchema: z.ZodObject<{
36
+ hideTopBar: z.ZodOptional<z.ZodBoolean>;
37
+ }, z.core.$strip>;
34
38
  /** Agent definition schema. */
35
39
  export declare const AgentDefinitionSchema: z.ZodObject<{
36
40
  displayName: z.ZodOptional<z.ZodString>;
@@ -83,4 +87,7 @@ export declare const AgentDefinitionSchema: z.ZodObject<{
83
87
  enabled: z.ZodBoolean;
84
88
  content: z.ZodString;
85
89
  }, z.core.$strip>>;
90
+ uiConfig: z.ZodOptional<z.ZodObject<{
91
+ hideTopBar: z.ZodOptional<z.ZodBoolean>;
92
+ }, z.core.$strip>>;
86
93
  }, z.core.$strip>;
@@ -78,6 +78,11 @@ export const InitialMessageSchema = z.object({
78
78
  /** The content of the initial message to send. Supports template variables like {{.AgentName}}. */
79
79
  content: z.string(),
80
80
  });
81
+ /** UI configuration schema for controlling the chat interface appearance. */
82
+ export const UiConfigSchema = z.object({
83
+ /** Whether to hide the top bar (session switcher, debugger link, settings). Useful for embedded/deployed mode. */
84
+ hideTopBar: z.boolean().optional(),
85
+ });
81
86
  /** Agent definition schema. */
82
87
  export const AgentDefinitionSchema = z.object({
83
88
  /** Human-readable display name for the agent (shown in UI). */
@@ -93,4 +98,6 @@ export const AgentDefinitionSchema = z.object({
93
98
  hooks: z.array(HookConfigSchema).optional(),
94
99
  /** Configuration for an initial message the agent sends when a session starts. */
95
100
  initialMessage: InitialMessageSchema.optional(),
101
+ /** UI configuration for controlling the chat interface appearance. */
102
+ uiConfig: UiConfigSchema.optional(),
96
103
  });
@@ -56,6 +56,9 @@ export declare const zAgentRunnerParams: z.ZodObject<{
56
56
  enabled: z.ZodBoolean;
57
57
  content: z.ZodString;
58
58
  }, z.core.$strip>>;
59
+ uiConfig: z.ZodOptional<z.ZodObject<{
60
+ hideTopBar: z.ZodOptional<z.ZodBoolean>;
61
+ }, z.core.$strip>>;
59
62
  }, z.core.$strip>;
60
63
  export type CreateAgentRunnerParams = z.infer<typeof zAgentRunnerParams>;
61
64
  export interface SessionMessage {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { HookConfigSchema, InitialMessageSchema, McpConfigSchema, } from "../definition";
2
+ import { HookConfigSchema, InitialMessageSchema, McpConfigSchema, UiConfigSchema, } from "../definition";
3
3
  import { zToolType } from "./tools";
4
4
  export const zAgentRunnerParams = z.object({
5
5
  displayName: z.string().optional(),
@@ -12,4 +12,5 @@ export const zAgentRunnerParams = z.object({
12
12
  mcps: z.array(McpConfigSchema).optional(),
13
13
  hooks: z.array(HookConfigSchema).optional(),
14
14
  initialMessage: InitialMessageSchema.optional(),
15
+ uiConfig: UiConfigSchema.optional(),
15
16
  });
@@ -13,6 +13,7 @@ import { makeBrowserTools } from "./tools/browser";
13
13
  import { makeFilesystemTools } from "./tools/filesystem";
14
14
  import { makeGenerateImageTool } from "./tools/generate_image";
15
15
  import { SUBAGENT_TOOL_NAME } from "./tools/subagent";
16
+ import { hashQuery, queryToToolCallId, subagentEvents, } from "./tools/subagent-connections";
16
17
  import { TODO_WRITE_TOOL_NAME, todoWrite } from "./tools/todo";
17
18
  import { makeWebSearchTools } from "./tools/web_search";
18
19
  const _logger = createLogger("agent-runner");
@@ -85,6 +86,56 @@ export class LangchainAgent {
85
86
  telemetry.setBaseAttributes({
86
87
  "agent.session_id": req.sessionId,
87
88
  });
89
+ const subagentUpdateQueue = [];
90
+ let subagentUpdateResolver = null;
91
+ // Listen for subagent connection events - resolve any waiting promise immediately
92
+ const onSubagentConnection = (event) => {
93
+ _logger.info("Received subagent connection event", {
94
+ toolCallId: event.toolCallId,
95
+ port: event.port,
96
+ sessionId: event.sessionId,
97
+ });
98
+ if (subagentUpdateResolver) {
99
+ // If someone is waiting, resolve immediately
100
+ const resolver = subagentUpdateResolver;
101
+ subagentUpdateResolver = null;
102
+ resolver(event);
103
+ }
104
+ else {
105
+ // Otherwise queue for later
106
+ subagentUpdateQueue.push(event);
107
+ }
108
+ };
109
+ subagentEvents.on("connection", onSubagentConnection);
110
+ // Helper to get next subagent update (returns immediately if queued, otherwise waits)
111
+ const waitForSubagentUpdate = () => {
112
+ if (subagentUpdateQueue.length > 0) {
113
+ return Promise.resolve(subagentUpdateQueue.shift());
114
+ }
115
+ return new Promise((resolve) => {
116
+ subagentUpdateResolver = resolve;
117
+ });
118
+ };
119
+ // Helper to check and yield all pending subagent updates
120
+ async function* yieldPendingSubagentUpdates() {
121
+ while (subagentUpdateQueue.length > 0) {
122
+ const update = subagentUpdateQueue.shift();
123
+ _logger.info("Yielding queued subagent connection update", {
124
+ toolCallId: update.toolCallId,
125
+ subagentPort: update.port,
126
+ subagentSessionId: update.sessionId,
127
+ });
128
+ yield {
129
+ sessionUpdate: "tool_call_update",
130
+ toolCallId: update.toolCallId,
131
+ _meta: {
132
+ messageId: req.messageId,
133
+ subagentPort: update.port,
134
+ subagentSessionId: update.sessionId,
135
+ },
136
+ };
137
+ }
138
+ }
88
139
  // Start telemetry span for entire invocation
89
140
  const invocationSpan = telemetry.startSpan("agent.invoke", {
90
141
  "agent.model": this.definition.model,
@@ -425,7 +476,53 @@ export class LangchainAgent {
425
476
  recursionLimit: 200,
426
477
  callbacks: [otelCallbacks],
427
478
  }));
428
- for await (const streamItem of await stream) {
479
+ // Use manual iteration with Promise.race to interleave stream events with subagent updates
480
+ const streamIterator = (await stream)[Symbol.asyncIterator]();
481
+ let streamDone = false;
482
+ let pendingStreamPromise = null;
483
+ while (!streamDone) {
484
+ // Get or create the stream promise (reuse if still pending from last iteration)
485
+ const nextStreamPromise = pendingStreamPromise ?? streamIterator.next();
486
+ pendingStreamPromise = nextStreamPromise; // Track it
487
+ // Create subagent wait promise (only if no queued updates)
488
+ const subagentPromise = waitForSubagentUpdate();
489
+ // Use Promise.race, but we need to handle both outcomes
490
+ const result = await Promise.race([
491
+ nextStreamPromise.then((r) => ({
492
+ type: "stream",
493
+ result: r,
494
+ })),
495
+ subagentPromise.then((u) => ({ type: "subagent", update: u })),
496
+ ]);
497
+ if (result.type === "subagent") {
498
+ // Got a subagent update - yield it immediately
499
+ const update = result.update;
500
+ _logger.info("Yielding subagent connection update (via race)", {
501
+ toolCallId: update.toolCallId,
502
+ subagentPort: update.port,
503
+ subagentSessionId: update.sessionId,
504
+ });
505
+ yield {
506
+ sessionUpdate: "tool_call_update",
507
+ toolCallId: update.toolCallId,
508
+ _meta: {
509
+ messageId: req.messageId,
510
+ subagentPort: update.port,
511
+ subagentSessionId: update.sessionId,
512
+ },
513
+ };
514
+ // Continue - the stream promise is still pending, will be reused
515
+ continue;
516
+ }
517
+ // Got a stream item - clear the pending promise
518
+ pendingStreamPromise = null;
519
+ const { done, value: streamItem } = result.result;
520
+ if (done) {
521
+ streamDone = true;
522
+ break;
523
+ }
524
+ // Also yield any queued subagent updates before processing stream item
525
+ yield* yieldPendingSubagentUpdates();
429
526
  const [streamMode, chunk] = streamItem;
430
527
  if (streamMode === "updates") {
431
528
  const updatesChunk = modelRequestSchema.safeParse(chunk);
@@ -517,6 +614,17 @@ export class LangchainAgent {
517
614
  const subagentConfigs = taskTool?.subagentConfigs;
518
615
  const subagentConfig = subagentConfigs?.find((config) => config.agentName === agentName);
519
616
  prettyName = subagentConfig?.displayName ?? agentName;
617
+ // Register query hash -> toolCallId mapping for subagent connection info
618
+ if ("query" in toolCall.args &&
619
+ typeof toolCall.args.query === "string") {
620
+ const qHash = hashQuery(toolCall.args.query);
621
+ queryToToolCallId.set(qHash, toolCall.id);
622
+ telemetry.log("info", "Registered subagent query hash mapping", {
623
+ queryHash: qHash,
624
+ toolCallId: toolCall.id,
625
+ queryPreview: toolCall.args.query.slice(0, 50),
626
+ });
627
+ }
520
628
  }
521
629
  // Check if we already emitted a preliminary notification from early tool_use block
522
630
  const alreadyEmittedPreliminary = preliminaryToolCallIds.has(toolCall.id);
@@ -750,6 +858,16 @@ export class LangchainAgent {
750
858
  else {
751
859
  throw new Error(`Unhandled stream mode: ${streamMode}`);
752
860
  }
861
+ // Yield any pending subagent connection updates
862
+ yield* yieldPendingSubagentUpdates();
863
+ }
864
+ // Yield any remaining pending subagent connection updates after stream ends
865
+ yield* yieldPendingSubagentUpdates();
866
+ // Clean up subagent connection listener
867
+ subagentEvents.off("connection", onSubagentConnection);
868
+ // Cancel any pending wait
869
+ if (subagentUpdateResolver) {
870
+ subagentUpdateResolver = null;
753
871
  }
754
872
  // Log successful completion
755
873
  telemetry.log("info", "Agent invocation completed", {
@@ -764,6 +882,8 @@ export class LangchainAgent {
764
882
  };
765
883
  }
766
884
  catch (error) {
885
+ // Clean up subagent connection listener on error
886
+ subagentEvents.off("connection", onSubagentConnection);
767
887
  // Log error and end span with error status
768
888
  telemetry.log("error", "Agent invocation failed", {
769
889
  error: error instanceof Error ? error.message : String(error),
@@ -0,0 +1,28 @@
1
+ import { EventEmitter } from "node:events";
2
+ /**
3
+ * Registry for subagent connection info.
4
+ * Maps query hash to connection info so the runner can emit tool_call_update.
5
+ */
6
+ export interface SubagentConnectionInfo {
7
+ port: number;
8
+ sessionId: string;
9
+ }
10
+ /**
11
+ * Event emitter for subagent connection events.
12
+ * The runner listens to these events and emits tool_call_update.
13
+ */
14
+ export declare const subagentEvents: EventEmitter<[never]>;
15
+ /**
16
+ * Maps query hash to toolCallId.
17
+ * Set by the runner when it sees a subagent tool_call.
18
+ */
19
+ export declare const queryToToolCallId: Map<string, string>;
20
+ /**
21
+ * Generate a hash from the query string for correlation.
22
+ */
23
+ export declare function hashQuery(query: string): string;
24
+ /**
25
+ * Called by the subagent tool when connection is established.
26
+ * Emits an event that the runner can listen to.
27
+ */
28
+ export declare function emitSubagentConnection(queryHash: string, connectionInfo: SubagentConnectionInfo): void;
@@ -0,0 +1,58 @@
1
+ import { createHash } from "node:crypto";
2
+ import { EventEmitter } from "node:events";
3
+ import { createLogger } from "@townco/core";
4
+ const logger = createLogger("subagent-connections");
5
+ /**
6
+ * Event emitter for subagent connection events.
7
+ * The runner listens to these events and emits tool_call_update.
8
+ */
9
+ export const subagentEvents = new EventEmitter();
10
+ /**
11
+ * Maps query hash to toolCallId.
12
+ * Set by the runner when it sees a subagent tool_call.
13
+ */
14
+ export const queryToToolCallId = new Map();
15
+ /**
16
+ * Generate a hash from the query string for correlation.
17
+ */
18
+ export function hashQuery(query) {
19
+ const hash = createHash("sha256").update(query).digest("hex").slice(0, 16);
20
+ logger.debug("Generated query hash", {
21
+ queryPreview: query.slice(0, 50),
22
+ hash,
23
+ });
24
+ return hash;
25
+ }
26
+ /**
27
+ * Called by the subagent tool when connection is established.
28
+ * Emits an event that the runner can listen to.
29
+ */
30
+ export function emitSubagentConnection(queryHash, connectionInfo) {
31
+ logger.info("emitSubagentConnection called", {
32
+ queryHash,
33
+ port: connectionInfo.port,
34
+ sessionId: connectionInfo.sessionId,
35
+ registeredHashes: Array.from(queryToToolCallId.keys()),
36
+ });
37
+ const toolCallId = queryToToolCallId.get(queryHash);
38
+ if (toolCallId) {
39
+ logger.info("Found toolCallId for queryHash, emitting connection event", {
40
+ queryHash,
41
+ toolCallId,
42
+ port: connectionInfo.port,
43
+ sessionId: connectionInfo.sessionId,
44
+ });
45
+ subagentEvents.emit("connection", {
46
+ toolCallId,
47
+ ...connectionInfo,
48
+ });
49
+ // Clean up the mapping
50
+ queryToToolCallId.delete(queryHash);
51
+ }
52
+ else {
53
+ logger.warn("No toolCallId found for queryHash", {
54
+ queryHash,
55
+ registeredHashes: Array.from(queryToToolCallId.keys()),
56
+ });
57
+ }
58
+ }
@@ -7,6 +7,7 @@ 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
9
  import { findAvailablePort } from "./port-utils.js";
10
+ import { emitSubagentConnection, hashQuery } from "./subagent-connections.js";
10
11
  /**
11
12
  * Name of the Task tool created by makeSubagentsTool
12
13
  */
@@ -100,7 +101,7 @@ export function makeSubagentsTool(configs) {
100
101
  type: "direct",
101
102
  name: SUBAGENT_TOOL_NAME,
102
103
  prettyName: "Subagent",
103
- icon: "BrainCircuit",
104
+ icon: "CircleDot",
104
105
  description: `Launch a new agent to handle complex, multi-step tasks autonomously.
105
106
 
106
107
  The Task tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
@@ -310,6 +311,9 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
310
311
  if (!sessionId) {
311
312
  throw new Error("No sessionId in session/new response");
312
313
  }
314
+ // Emit connection info so the GUI can connect directly to this subagent's SSE
315
+ const queryHash = hashQuery(query);
316
+ emitSubagentConnection(queryHash, { port, sessionId });
313
317
  // Step 3: Connect to SSE for receiving streaming responses
314
318
  sseAbortController = new AbortController();
315
319
  let responseText = "";