@posthog/agent 2.3.46 → 2.3.62

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.
package/dist/types.d.ts CHANGED
@@ -74,6 +74,7 @@ interface ProcessSpawnedCallback {
74
74
  sessionId?: string;
75
75
  }) => void;
76
76
  onProcessExited?: (pid: number) => void;
77
+ onMcpServersReady?: (serverNames: string[]) => void;
77
78
  }
78
79
  interface TaskExecutionOptions {
79
80
  repositoryPath?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.46",
3
+ "version": "2.3.62",
4
4
  "repository": "https://github.com/PostHog/code",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -55,7 +55,10 @@ import {
55
55
  handleSystemMessage,
56
56
  handleUserAssistantMessage,
57
57
  } from "./conversion/sdk-to-acp";
58
- import { fetchMcpToolMetadata } from "./mcp/tool-metadata";
58
+ import {
59
+ fetchMcpToolMetadata,
60
+ getConnectedMcpServerNames,
61
+ } from "./mcp/tool-metadata";
59
62
  import { canUseTool } from "./permissions/permission-handlers";
60
63
  import { getAvailableSlashCommands } from "./session/commands";
61
64
  import { parseMcpServers } from "./session/mcp-config";
@@ -101,6 +104,7 @@ function sanitizeTitle(text: string): string {
101
104
  export interface ClaudeAcpAgentOptions {
102
105
  onProcessSpawned?: (info: ProcessSpawnedInfo) => void;
103
106
  onProcessExited?: (pid: number) => void;
107
+ onMcpServersReady?: (serverNames: string[]) => void;
104
108
  }
105
109
 
106
110
  export class ClaudeAcpAgent extends BaseAcpAgent {
@@ -1020,11 +1024,17 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1020
1024
  * Both populate caches used later — neither is needed to return configOptions.
1021
1025
  */
1022
1026
  private deferBackgroundFetches(q: Query): void {
1027
+ this.logger.info("Starting background fetches (commands + MCP metadata)");
1023
1028
  Promise.all([
1024
1029
  new Promise<void>((resolve) => setTimeout(resolve, 10)).then(() =>
1025
1030
  this.sendAvailableCommandsUpdate(),
1026
1031
  ),
1027
- fetchMcpToolMetadata(q, this.logger),
1032
+ fetchMcpToolMetadata(q, this.logger).then(() => {
1033
+ const serverNames = getConnectedMcpServerNames();
1034
+ if (serverNames.length > 0) {
1035
+ this.options?.onMcpServersReady?.(serverNames);
1036
+ }
1037
+ }),
1028
1038
  ]).catch((err) =>
1029
1039
  this.logger.error("Background fetch failed", { error: err }),
1030
1040
  );
@@ -56,6 +56,8 @@ type ChunkHandlerContext = {
56
56
  registerHooks?: boolean;
57
57
  supportsTerminalOutput?: boolean;
58
58
  cwd?: string;
59
+ /** Raw MCP tool result from SDKUserMessage.tool_use_result (contains content, structuredContent, _meta) */
60
+ mcpToolUseResult?: Record<string, unknown>;
59
61
  };
60
62
 
61
63
  export interface MessageHandlerContext {
@@ -348,7 +350,16 @@ function handleToolResultChunk(
348
350
  toolCallId: chunk.tool_use_id,
349
351
  sessionUpdate: "tool_call_update",
350
352
  status: chunk.is_error ? "failed" : "completed",
351
- rawOutput: chunk.content,
353
+ rawOutput: ctx.mcpToolUseResult
354
+ ? { ...ctx.mcpToolUseResult, isError: chunk.is_error ?? false }
355
+ : {
356
+ content: Array.isArray(chunk.content)
357
+ ? chunk.content
358
+ : typeof chunk.content === "string"
359
+ ? [{ type: "text" as const, text: chunk.content }]
360
+ : [],
361
+ isError: chunk.is_error ?? false,
362
+ },
352
363
  ...toolUpdate,
353
364
  });
354
365
 
@@ -435,6 +446,7 @@ function toAcpNotifications(
435
446
  registerHooks?: boolean,
436
447
  supportsTerminalOutput?: boolean,
437
448
  cwd?: string,
449
+ mcpToolUseResult?: Record<string, unknown>,
438
450
  ): SessionNotification[] {
439
451
  if (typeof content === "string") {
440
452
  const update: SessionUpdate = {
@@ -461,6 +473,7 @@ function toAcpNotifications(
461
473
  registerHooks,
462
474
  supportsTerminalOutput,
463
475
  cwd,
476
+ mcpToolUseResult,
464
477
  };
465
478
  const output: SessionNotification[] = [];
466
479
 
@@ -829,6 +842,13 @@ export async function handleUserAssistantMessage(
829
842
  ? (message.parent_tool_use_id ?? undefined)
830
843
  : undefined;
831
844
 
845
+ // Pass the raw MCP tool result (contains content, structuredContent, _meta)
846
+ // so it can be forwarded as-is to the renderer for MCP Apps
847
+ const mcpToolUseResult =
848
+ message.type === "user" && message.tool_use_result != null
849
+ ? (message.tool_use_result as Record<string, unknown>)
850
+ : undefined;
851
+
832
852
  for (const notification of toAcpNotifications(
833
853
  contentToProcess as typeof content,
834
854
  message.message.role,
@@ -841,6 +861,7 @@ export async function handleUserAssistantMessage(
841
861
  context.registerHooks,
842
862
  context.supportsTerminalOutput,
843
863
  session.cwd,
864
+ mcpToolUseResult,
844
865
  )) {
845
866
  await client.sessionUpdate(notification);
846
867
  session.notificationHistory.push(notification);
@@ -48,6 +48,7 @@ export async function fetchMcpToolMetadata(
48
48
  for (const tool of server.tools) {
49
49
  const toolKey = buildToolKey(server.name, tool.name);
50
50
  const readOnly = tool.annotations?.readOnly === true;
51
+
51
52
  mcpToolMetadataCache.set(toolKey, {
52
53
  readOnly,
53
54
  name: tool.name,
@@ -94,6 +95,15 @@ export function isMcpToolReadOnly(toolName: string): boolean {
94
95
  return metadata?.readOnly === true;
95
96
  }
96
97
 
98
+ export function getConnectedMcpServerNames(): string[] {
99
+ const names = new Set<string>();
100
+ for (const key of mcpToolMetadataCache.keys()) {
101
+ const parts = key.split("__");
102
+ if (parts.length >= 3) names.add(parts[1]);
103
+ }
104
+ return [...names];
105
+ }
106
+
97
107
  export function clearMcpToolMetadataCache(): void {
98
108
  mcpToolMetadataCache.clear();
99
109
  }
package/src/index.ts CHANGED
@@ -1 +1,5 @@
1
- export { isMcpToolReadOnly } from "./adapters/claude/mcp/tool-metadata";
1
+ export {
2
+ getMcpToolMetadata,
3
+ isMcpToolReadOnly,
4
+ type McpToolMetadata,
5
+ } from "./adapters/claude/mcp/tool-metadata";
@@ -162,6 +162,12 @@ export class AgentServer {
162
162
  private questionRelayedToSlack = false;
163
163
  private detectedPrUrl: string | null = null;
164
164
  private resumeState: ResumeState | null = null;
165
+ // Guards against concurrent session initialization. autoInitializeSession() and
166
+ // the GET /events SSE handler can both call initializeSession() — the SSE connection
167
+ // often arrives while newSession() is still awaited (this.session is still null),
168
+ // causing a second session to be created and duplicate Slack messages to be sent.
169
+ private initializationPromise: Promise<void> | null = null;
170
+ private pendingEvents: Record<string, unknown>[] = [];
165
171
 
166
172
  private emitConsoleLog = (
167
173
  level: LogLevel,
@@ -264,6 +270,7 @@ export class AgentServer {
264
270
  await this.initializeSession(payload, sseController);
265
271
  } else {
266
272
  this.session.sseController = sseController;
273
+ this.replayPendingEvents();
267
274
  }
268
275
 
269
276
  this.sendSseEvent(sseController, {
@@ -483,6 +490,8 @@ export class AgentServer {
483
490
  `Processing user message (detectedPrUrl=${this.detectedPrUrl ?? "none"}): ${content.substring(0, 100)}...`,
484
491
  );
485
492
 
493
+ this.session.logWriter.resetTurnMessages(this.session.payload.run_id);
494
+
486
495
  const result = await this.session.clientConnection.prompt({
487
496
  sessionId: this.session.acpSessionId,
488
497
  prompt: [{ type: "text", text: content }],
@@ -501,7 +510,31 @@ export class AgentServer {
501
510
 
502
511
  this.broadcastTurnComplete(result.stopReason);
503
512
 
504
- return { stopReason: result.stopReason };
513
+ if (result.stopReason === "end_turn") {
514
+ // Relay the response to Slack. For follow-ups this is the primary
515
+ // delivery path — the HTTP caller only handles reactions.
516
+ this.relayAgentResponse(this.session.payload).catch((err) =>
517
+ this.logger.warn("Failed to relay follow-up response", err),
518
+ );
519
+ }
520
+
521
+ // Flush logs and include the assistant's response text so callers
522
+ // (e.g. Slack follow-up forwarding) can extract it without racing
523
+ // against async log persistence to object storage.
524
+ let assistantMessage: string | undefined;
525
+ try {
526
+ await this.session.logWriter.flush(this.session.payload.run_id);
527
+ assistantMessage = this.session.logWriter.getFullAgentResponse(
528
+ this.session.payload.run_id,
529
+ );
530
+ } catch {
531
+ this.logger.warn("Failed to extract assistant message from logs");
532
+ }
533
+
534
+ return {
535
+ stopReason: result.stopReason,
536
+ ...(assistantMessage && { assistant_message: assistantMessage }),
537
+ };
505
538
  }
506
539
 
507
540
  case POSTHOG_NOTIFICATIONS.CANCEL:
@@ -530,6 +563,40 @@ export class AgentServer {
530
563
  private async initializeSession(
531
564
  payload: JwtPayload,
532
565
  sseController: SseController | null,
566
+ ): Promise<void> {
567
+ // Race condition guard: autoInitializeSession() starts first, but while it awaits
568
+ // newSession() (which takes ~1-2s for MCP metadata fetch), the Temporal relay connects
569
+ // to GET /events. That handler sees this.session === null and calls initializeSession()
570
+ // again, creating a duplicate session that sends the same prompt twice — resulting in
571
+ // duplicate Slack messages. This lock ensures the second caller waits for the first
572
+ // initialization to finish and reuses the session.
573
+ if (this.initializationPromise) {
574
+ this.logger.info("Waiting for in-progress initialization", {
575
+ runId: payload.run_id,
576
+ });
577
+ await this.initializationPromise;
578
+ // After waiting, just attach the SSE controller if needed
579
+ if (this.session && sseController) {
580
+ this.session.sseController = sseController;
581
+ this.replayPendingEvents();
582
+ }
583
+ return;
584
+ }
585
+
586
+ this.initializationPromise = this._doInitializeSession(
587
+ payload,
588
+ sseController,
589
+ );
590
+ try {
591
+ await this.initializationPromise;
592
+ } finally {
593
+ this.initializationPromise = null;
594
+ }
595
+ }
596
+
597
+ private async _doInitializeSession(
598
+ payload: JwtPayload,
599
+ sseController: SseController | null,
533
600
  ): Promise<void> {
534
601
  if (this.session) {
535
602
  await this.cleanupSession();
@@ -770,6 +837,8 @@ export class AgentServer {
770
837
  usedInitialPromptOverride: !!initialPromptOverride,
771
838
  });
772
839
 
840
+ this.session.logWriter.resetTurnMessages(payload.run_id);
841
+
773
842
  const result = await this.session.clientConnection.prompt({
774
843
  sessionId: this.session.acpSessionId,
775
844
  prompt: [{ type: "text", text: initialPrompt }],
@@ -809,8 +878,8 @@ export class AgentServer {
809
878
  const pendingUserMessage = this.getPendingUserMessage(taskRun);
810
879
 
811
880
  const sandboxContext = this.resumeState.snapshotApplied
812
- ? `The sandbox environment (all files, packages, and code changes) has been fully restored from a snapshot.`
813
- : `The sandbox could not be restored from a snapshot (it may have expired). You are starting with a fresh environment but have the full conversation history below.`;
881
+ ? `The workspace environment (all files, packages, and code changes) has been fully restored from where you left off.`
882
+ : `The workspace files from the previous session were not restored (the file snapshot may have expired), so you are starting with a fresh environment. Your conversation history is fully preserved below.`;
814
883
 
815
884
  let resumePrompt: string;
816
885
  if (pendingUserMessage) {
@@ -842,6 +911,8 @@ export class AgentServer {
842
911
  // Clear resume state so it's not reused
843
912
  this.resumeState = null;
844
913
 
914
+ this.session.logWriter.resetTurnMessages(payload.run_id);
915
+
845
916
  const result = await this.session.clientConnection.prompt({
846
917
  sessionId: this.session.acpSessionId,
847
918
  prompt: [{ type: "text", text: resumePrompt }],
@@ -852,6 +923,10 @@ export class AgentServer {
852
923
  });
853
924
 
854
925
  this.broadcastTurnComplete(result.stopReason);
926
+
927
+ if (result.stopReason === "end_turn") {
928
+ await this.relayAgentResponse(payload);
929
+ }
855
930
  } catch (error) {
856
931
  this.logger.error("Failed to send resume message", error);
857
932
  if (this.session) {
@@ -992,6 +1067,27 @@ Important:
992
1067
  `;
993
1068
  }
994
1069
 
1070
+ if (!this.config.repositoryPath) {
1071
+ return `
1072
+ # Cloud Task Execution — No Repository Mode
1073
+
1074
+ You are a helpful assistant with access to PostHog via MCP tools. You can help with both code tasks and data/analytics questions.
1075
+
1076
+ When the user asks about analytics, data, metrics, events, funnels, dashboards, feature flags, experiments, or anything PostHog-related:
1077
+ - Use your PostHog MCP tools to query data, search insights, and provide real answers
1078
+ - Do NOT tell the user to check an external analytics platform — you ARE the analytics platform
1079
+ - Use tools like insight-query, query-run, event-definitions-list, and others to answer questions directly
1080
+
1081
+ When the user asks for code changes or software engineering tasks:
1082
+ - Let them know you can help but don't have a repository connected for this session
1083
+ - Offer to write code snippets, scripts, or provide guidance
1084
+
1085
+ Important:
1086
+ - Do NOT create branches, commits, or pull requests in this mode.
1087
+ - Prefer using MCP tools to answer questions with real data over giving generic advice.
1088
+ `;
1089
+ }
1090
+
995
1091
  return `
996
1092
  # Cloud Task Execution
997
1093
 
@@ -1124,6 +1220,12 @@ Important:
1124
1220
  },
1125
1221
  };
1126
1222
  },
1223
+ extNotification: async (
1224
+ method: string,
1225
+ params: Record<string, unknown>,
1226
+ ) => {
1227
+ this.logger.debug("Extension notification", { method, params });
1228
+ },
1127
1229
  sessionUpdate: async (params: {
1128
1230
  sessionId: string;
1129
1231
  update?: Record<string, unknown>;
@@ -1176,7 +1278,7 @@ Important:
1176
1278
  });
1177
1279
  }
1178
1280
 
1179
- const message = this.session.logWriter.getLastAgentMessage(payload.run_id);
1281
+ const message = this.session.logWriter.getFullAgentResponse(payload.run_id);
1180
1282
  if (!message) {
1181
1283
  this.logger.warn("No agent message found for Slack relay", {
1182
1284
  taskId: payload.task_id,
@@ -1385,6 +1487,7 @@ Important:
1385
1487
  this.session.sseController.close();
1386
1488
  }
1387
1489
 
1490
+ this.pendingEvents = [];
1388
1491
  this.session = null;
1389
1492
  }
1390
1493
 
@@ -1444,6 +1547,18 @@ Important:
1444
1547
  private broadcastEvent(event: Record<string, unknown>): void {
1445
1548
  if (this.session?.sseController) {
1446
1549
  this.sendSseEvent(this.session.sseController, event);
1550
+ } else if (this.session) {
1551
+ // Buffer events during initialization (sseController not yet attached)
1552
+ this.pendingEvents.push(event);
1553
+ }
1554
+ }
1555
+
1556
+ private replayPendingEvents(): void {
1557
+ if (!this.session?.sseController || this.pendingEvents.length === 0) return;
1558
+ const events = this.pendingEvents;
1559
+ this.pendingEvents = [];
1560
+ for (const event of events) {
1561
+ this.sendSseEvent(this.session.sseController, event);
1447
1562
  }
1448
1563
  }
1449
1564
 
@@ -248,7 +248,7 @@ describe("Question relay", () => {
248
248
  payload: TEST_PAYLOAD,
249
249
  logWriter: {
250
250
  flush: vi.fn().mockResolvedValue(undefined),
251
- getLastAgentMessage: vi.fn().mockReturnValue("agent response"),
251
+ getFullAgentResponse: vi.fn().mockReturnValue("agent response"),
252
252
  isRegistered: vi.fn().mockReturnValue(true),
253
253
  },
254
254
  };
@@ -269,7 +269,7 @@ describe("Question relay", () => {
269
269
  payload: TEST_PAYLOAD,
270
270
  logWriter: {
271
271
  flush: vi.fn().mockResolvedValue(undefined),
272
- getLastAgentMessage: vi.fn().mockReturnValue("agent response"),
272
+ getFullAgentResponse: vi.fn().mockReturnValue("agent response"),
273
273
  isRegistered: vi.fn().mockReturnValue(true),
274
274
  },
275
275
  };
@@ -293,7 +293,7 @@ describe("Question relay", () => {
293
293
  payload: TEST_PAYLOAD,
294
294
  logWriter: {
295
295
  flush: vi.fn().mockResolvedValue(undefined),
296
- getLastAgentMessage: vi.fn().mockReturnValue(null),
296
+ getFullAgentResponse: vi.fn().mockReturnValue(null),
297
297
  isRegistered: vi.fn().mockReturnValue(true),
298
298
  },
299
299
  };
@@ -323,6 +323,13 @@ describe("Question relay", () => {
323
323
  payload: TEST_PAYLOAD,
324
324
  acpSessionId: "acp-session",
325
325
  clientConnection: { prompt: promptSpy },
326
+ logWriter: {
327
+ flushAll: vi.fn().mockResolvedValue(undefined),
328
+ getFullAgentResponse: vi.fn().mockReturnValue(null),
329
+ resetTurnMessages: vi.fn(),
330
+ flush: vi.fn().mockResolvedValue(undefined),
331
+ isRegistered: vi.fn().mockReturnValue(true),
332
+ },
326
333
  };
327
334
 
328
335
  await server.sendInitialTaskMessage(TEST_PAYLOAD);
@@ -350,6 +357,13 @@ describe("Question relay", () => {
350
357
  payload: TEST_PAYLOAD,
351
358
  acpSessionId: "acp-session",
352
359
  clientConnection: { prompt: promptSpy },
360
+ logWriter: {
361
+ flushAll: vi.fn().mockResolvedValue(undefined),
362
+ getFullAgentResponse: vi.fn().mockReturnValue(null),
363
+ resetTurnMessages: vi.fn(),
364
+ flush: vi.fn().mockResolvedValue(undefined),
365
+ isRegistered: vi.fn().mockReturnValue(true),
366
+ },
353
367
  };
354
368
 
355
369
  await server.sendInitialTaskMessage(TEST_PAYLOAD);
@@ -24,6 +24,7 @@ interface SessionState {
24
24
  context: SessionContext;
25
25
  chunkBuffer?: ChunkBuffer;
26
26
  lastAgentMessage?: string;
27
+ currentTurnMessages: string[];
27
28
  }
28
29
 
29
30
  export class SessionLogWriter {
@@ -69,7 +70,7 @@ export class SessionLogWriter {
69
70
  taskId: context.taskId,
70
71
  runId: context.runId,
71
72
  });
72
- this.sessions.set(sessionId, { context });
73
+ this.sessions.set(sessionId, { context, currentTurnMessages: [] });
73
74
 
74
75
  this.lastFlushAttemptTime.set(sessionId, Date.now());
75
76
 
@@ -127,6 +128,7 @@ export class SessionLogWriter {
127
128
  const nonChunkAgentText = this.extractAgentMessageText(message);
128
129
  if (nonChunkAgentText) {
129
130
  session.lastAgentMessage = nonChunkAgentText;
131
+ session.currentTurnMessages.push(nonChunkAgentText);
130
132
  }
131
133
 
132
134
  const entry: StoredNotification = {
@@ -240,6 +242,7 @@ export class SessionLogWriter {
240
242
  const { text, firstTimestamp } = session.chunkBuffer;
241
243
  session.chunkBuffer = undefined;
242
244
  session.lastAgentMessage = text;
245
+ session.currentTurnMessages.push(text);
243
246
 
244
247
  const entry: StoredNotification = {
245
248
  type: "notification",
@@ -270,6 +273,19 @@ export class SessionLogWriter {
270
273
  return this.sessions.get(sessionId)?.lastAgentMessage;
271
274
  }
272
275
 
276
+ getFullAgentResponse(sessionId: string): string | undefined {
277
+ const session = this.sessions.get(sessionId);
278
+ if (!session || session.currentTurnMessages.length === 0) return undefined;
279
+ return session.currentTurnMessages.join("\n\n");
280
+ }
281
+
282
+ resetTurnMessages(sessionId: string): void {
283
+ const session = this.sessions.get(sessionId);
284
+ if (session) {
285
+ session.currentTurnMessages = [];
286
+ }
287
+ }
288
+
273
289
  private extractAgentMessageText(
274
290
  message: Record<string, unknown>,
275
291
  ): string | null {
package/src/types.ts CHANGED
@@ -103,6 +103,7 @@ export interface ProcessSpawnedCallback {
103
103
  sessionId?: string;
104
104
  }) => void;
105
105
  onProcessExited?: (pid: number) => void;
106
+ onMcpServersReady?: (serverNames: string[]) => void;
106
107
  }
107
108
 
108
109
  export interface TaskExecutionOptions {