@posthog/agent 2.1.150 → 2.1.156

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.1.150",
3
+ "version": "2.1.156",
4
4
  "repository": "https://github.com/PostHog/twig",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -74,8 +74,8 @@
74
74
  "tsx": "^4.20.6",
75
75
  "typescript": "^5.5.0",
76
76
  "vitest": "^2.1.8",
77
- "@twig/git": "1.0.0",
78
- "@posthog/shared": "1.0.0"
77
+ "@posthog/shared": "1.0.0",
78
+ "@twig/git": "1.0.0"
79
79
  },
80
80
  "dependencies": {
81
81
  "@agentclientprotocol/sdk": "^0.14.0",
@@ -192,6 +192,7 @@ function handleToolUseChunk(
192
192
  const toolInfo = toolInfoFromToolUse(chunk, {
193
193
  supportsTerminalOutput: ctx.supportsTerminalOutput,
194
194
  toolUseId: chunk.id,
195
+ cachedFileContent: ctx.fileContentCache,
195
196
  });
196
197
 
197
198
  const meta: Record<string, unknown> = {
@@ -221,6 +222,66 @@ function handleToolUseChunk(
221
222
  };
222
223
  }
223
224
 
225
+ function extractTextFromContent(content: unknown): string | null {
226
+ if (Array.isArray(content)) {
227
+ const parts: string[] = [];
228
+ for (const item of content) {
229
+ if (
230
+ typeof item === "object" &&
231
+ item !== null &&
232
+ "text" in item &&
233
+ typeof (item as Record<string, unknown>).text === "string"
234
+ ) {
235
+ parts.push((item as { text: string }).text);
236
+ }
237
+ }
238
+ return parts.length > 0 ? parts.join("") : null;
239
+ }
240
+ if (typeof content === "string") {
241
+ return content;
242
+ }
243
+ return null;
244
+ }
245
+
246
+ function stripCatLineNumbers(text: string): string {
247
+ return text.replace(/^ *\d+[\t→]/gm, "");
248
+ }
249
+
250
+ function updateFileContentCache(
251
+ toolUse: { name: string; input: unknown },
252
+ chunk: { content?: unknown },
253
+ ctx: ChunkHandlerContext,
254
+ ): void {
255
+ const input = toolUse.input as Record<string, unknown> | undefined;
256
+ const filePath = input?.file_path ? String(input.file_path) : undefined;
257
+ if (!filePath) return;
258
+
259
+ if (toolUse.name === "Read" && !input?.limit && !input?.offset) {
260
+ const fileText = extractTextFromContent(chunk.content);
261
+ if (fileText !== null) {
262
+ ctx.fileContentCache[filePath] = stripCatLineNumbers(fileText);
263
+ }
264
+ } else if (toolUse.name === "Write") {
265
+ const content = input?.content;
266
+ if (typeof content === "string") {
267
+ ctx.fileContentCache[filePath] = content;
268
+ }
269
+ } else if (toolUse.name === "Edit") {
270
+ const oldString = input?.old_string;
271
+ const newString = input?.new_string;
272
+ if (
273
+ typeof oldString === "string" &&
274
+ typeof newString === "string" &&
275
+ filePath in ctx.fileContentCache
276
+ ) {
277
+ const current = ctx.fileContentCache[filePath];
278
+ ctx.fileContentCache[filePath] = input?.replace_all
279
+ ? current.replaceAll(oldString, newString)
280
+ : current.replace(oldString, newString);
281
+ }
282
+ }
283
+ }
284
+
224
285
  function handleToolResultChunk(
225
286
  chunk: AnthropicContentChunk & {
226
287
  tool_use_id: string;
@@ -241,12 +302,17 @@ function handleToolResultChunk(
241
302
  return [];
242
303
  }
243
304
 
305
+ if (!chunk.is_error) {
306
+ updateFileContentCache(toolUse, chunk, ctx);
307
+ }
308
+
244
309
  const { _meta: resultMeta, ...toolUpdate } = toolUpdateFromToolResult(
245
310
  chunk as Parameters<typeof toolUpdateFromToolResult>[0],
246
311
  toolUse,
247
312
  {
248
313
  supportsTerminalOutput: ctx.supportsTerminalOutput,
249
314
  toolUseId: chunk.tool_use_id,
315
+ cachedFileContent: ctx.fileContentCache,
250
316
  },
251
317
  );
252
318
 
@@ -34,7 +34,11 @@ type ToolInfo = Pick<ToolCall, "title" | "kind" | "content" | "locations">;
34
34
 
35
35
  export function toolInfoFromToolUse(
36
36
  toolUse: Pick<ToolUseBlock, "name" | "input">,
37
- options?: { supportsTerminalOutput?: boolean; toolUseId?: string },
37
+ options?: {
38
+ supportsTerminalOutput?: boolean;
39
+ toolUseId?: string;
40
+ cachedFileContent?: Record<string, string>;
41
+ },
38
42
  ): ToolInfo {
39
43
  const name = toolUse.name;
40
44
  const input = toolUse.input as Record<string, unknown> | undefined;
@@ -144,8 +148,24 @@ export function toolInfoFromToolUse(
144
148
 
145
149
  case "Edit": {
146
150
  const path = input?.file_path ? String(input.file_path) : undefined;
147
- const oldText = input?.old_string ? String(input.old_string) : null;
148
- const newText = input?.new_string ? String(input.new_string) : "";
151
+ let oldText: string | null = input?.old_string
152
+ ? String(input.old_string)
153
+ : null;
154
+ let newText: string = input?.new_string ? String(input.new_string) : "";
155
+
156
+ // If we have cached file content, show a full-file diff
157
+ if (
158
+ path &&
159
+ options?.cachedFileContent &&
160
+ path in options.cachedFileContent
161
+ ) {
162
+ const oldContent = options.cachedFileContent[path];
163
+ const newContent = input?.replace_all
164
+ ? oldContent.replaceAll(oldText ?? "", newText)
165
+ : oldContent.replace(oldText ?? "", newText);
166
+ oldText = oldContent;
167
+ newText = newContent;
168
+ }
149
169
 
150
170
  return {
151
171
  title: path ? `Edit \`${path}\`` : "Edit",
@@ -170,8 +190,12 @@ export function toolInfoFromToolUse(
170
190
  const filePath = input?.file_path ? String(input.file_path) : undefined;
171
191
  const contentStr = input?.content ? String(input.content) : undefined;
172
192
  if (filePath) {
193
+ const oldContent =
194
+ options?.cachedFileContent && filePath in options.cachedFileContent
195
+ ? options.cachedFileContent[filePath]
196
+ : null;
173
197
  contentResult = toolContent()
174
- .diff(filePath, null, contentStr ?? "")
198
+ .diff(filePath, oldContent, contentStr ?? "")
175
199
  .build();
176
200
  } else if (contentStr) {
177
201
  contentResult = toolContent().text(contentStr).build();
@@ -453,7 +477,11 @@ export function toolUpdateFromToolResult(
453
477
  | BetaRequestMCPToolResultBlockParam
454
478
  | BetaToolSearchToolResultBlockParam,
455
479
  toolUse: Pick<ToolUseBlock, "name" | "input"> | undefined,
456
- options?: { supportsTerminalOutput?: boolean; toolUseId?: string },
480
+ options?: {
481
+ supportsTerminalOutput?: boolean;
482
+ toolUseId?: string;
483
+ cachedFileContent?: Record<string, string>;
484
+ },
457
485
  ): Pick<ToolCallUpdate, "title" | "content" | "locations" | "_meta"> {
458
486
  if (
459
487
  "is_error" in toolResult &&
@@ -17,6 +17,7 @@ import { TreeTracker } from "../tree-tracker.js";
17
17
  import type {
18
18
  AgentMode,
19
19
  DeviceInfo,
20
+ LogLevel,
20
21
  TaskRun,
21
22
  TreeSnapshotEvent,
22
23
  } from "../types.js";
@@ -155,6 +156,35 @@ export class AgentServer {
155
156
  private questionRelayedToSlack = false;
156
157
  private detectedPrUrl: string | null = null;
157
158
 
159
+ private emitConsoleLog = (
160
+ level: LogLevel,
161
+ _scope: string,
162
+ message: string,
163
+ data?: unknown,
164
+ ): void => {
165
+ if (!this.session) return;
166
+
167
+ const formatted =
168
+ data !== undefined ? `${message} ${JSON.stringify(data)}` : message;
169
+
170
+ const notification = {
171
+ jsonrpc: "2.0",
172
+ method: POSTHOG_NOTIFICATIONS.CONSOLE,
173
+ params: { level, message: formatted },
174
+ };
175
+
176
+ this.broadcastEvent({
177
+ type: "notification",
178
+ timestamp: new Date().toISOString(),
179
+ notification,
180
+ });
181
+
182
+ this.session.logWriter.appendRawLine(
183
+ this.session.payload.run_id,
184
+ JSON.stringify(notification),
185
+ );
186
+ };
187
+
158
188
  constructor(config: AgentServerConfig) {
159
189
  this.config = config;
160
190
  this.logger = new Logger({ debug: true, prefix: "[AgentServer]" });
@@ -565,7 +595,7 @@ export class AgentServer {
565
595
 
566
596
  const sessionResponse = await clientConnection.newSession({
567
597
  cwd: this.config.repositoryPath,
568
- mcpServers: [],
598
+ mcpServers: this.config.mcpServers ?? [],
569
599
  _meta: {
570
600
  sessionId: payload.run_id,
571
601
  taskRunId: payload.run_id,
@@ -590,6 +620,17 @@ export class AgentServer {
590
620
  logWriter,
591
621
  };
592
622
 
623
+ this.logger = new Logger({
624
+ debug: true,
625
+ prefix: "[AgentServer]",
626
+ onLog: (level, scope, message, data) => {
627
+ // Preserve console output (onLog suppresses default console.*)
628
+ const _formatted =
629
+ data !== undefined ? `${message} ${JSON.stringify(data)}` : message;
630
+ this.emitConsoleLog(level, scope, message, data);
631
+ },
632
+ });
633
+
593
634
  this.logger.info("Session initialized successfully");
594
635
 
595
636
  // Signal in_progress so the UI can start polling for updates
@@ -1103,15 +1144,29 @@ Important:
1103
1144
  ...snapshot,
1104
1145
  device: this.session.deviceInfo,
1105
1146
  };
1147
+
1148
+ const notification = {
1149
+ jsonrpc: "2.0" as const,
1150
+ method: POSTHOG_NOTIFICATIONS.TREE_SNAPSHOT,
1151
+ params: snapshotWithDevice,
1152
+ };
1153
+
1106
1154
  this.broadcastEvent({
1107
1155
  type: "notification",
1108
1156
  timestamp: new Date().toISOString(),
1109
- notification: {
1110
- jsonrpc: "2.0",
1111
- method: POSTHOG_NOTIFICATIONS.TREE_SNAPSHOT,
1112
- params: snapshotWithDevice,
1113
- },
1157
+ notification,
1114
1158
  });
1159
+
1160
+ // Persist to log writer so cloud runs have tree snapshots
1161
+ const { archiveUrl: _, ...paramsWithoutArchive } = snapshotWithDevice;
1162
+ const logNotification = {
1163
+ ...notification,
1164
+ params: paramsWithoutArchive,
1165
+ };
1166
+ this.session.logWriter.appendRawLine(
1167
+ this.session.payload.run_id,
1168
+ JSON.stringify(logNotification),
1169
+ );
1115
1170
  }
1116
1171
  } catch (error) {
1117
1172
  this.logger.error("Failed to capture tree state", error);
package/src/server/bin.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { Command } from "commander";
3
3
  import { z } from "zod";
4
4
  import { AgentServer } from "./agent-server.js";
5
+ import { mcpServersSchema } from "./schemas.js";
5
6
 
6
7
  const envSchema = z.object({
7
8
  JWT_PUBLIC_KEY: z
@@ -45,6 +46,10 @@ program
45
46
  .requiredOption("--repositoryPath <path>", "Path to the repository")
46
47
  .requiredOption("--taskId <id>", "Task ID")
47
48
  .requiredOption("--runId <id>", "Task run ID")
49
+ .option(
50
+ "--mcpServers <json>",
51
+ "MCP servers config as JSON array (ACP McpServer[] format)",
52
+ )
48
53
  .action(async (options) => {
49
54
  const envResult = envSchema.safeParse(process.env);
50
55
 
@@ -60,6 +65,29 @@ program
60
65
 
61
66
  const mode = options.mode === "background" ? "background" : "interactive";
62
67
 
68
+ let mcpServers: z.infer<typeof mcpServersSchema> | undefined;
69
+ if (options.mcpServers) {
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = JSON.parse(options.mcpServers);
73
+ } catch {
74
+ program.error("--mcpServers must be valid JSON");
75
+ return;
76
+ }
77
+
78
+ const result = mcpServersSchema.safeParse(parsed);
79
+ if (!result.success) {
80
+ const errors = result.error.issues
81
+ .map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`)
82
+ .join("\n");
83
+ program.error(
84
+ `--mcpServers validation failed (only remote http/sse servers are supported):\n${errors}`,
85
+ );
86
+ return;
87
+ }
88
+ mcpServers = result.data;
89
+ }
90
+
63
91
  const server = new AgentServer({
64
92
  port: parseInt(options.port, 10),
65
93
  jwtPublicKey: env.JWT_PUBLIC_KEY,
@@ -70,6 +98,7 @@ program
70
98
  mode,
71
99
  taskId: options.taskId,
72
100
  runId: options.runId,
101
+ mcpServers,
73
102
  });
74
103
 
75
104
  process.on("SIGINT", async () => {
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mcpServersSchema } from "./schemas.js";
3
+
4
+ describe("mcpServersSchema", () => {
5
+ it("accepts a valid HTTP server", () => {
6
+ const result = mcpServersSchema.safeParse([
7
+ {
8
+ type: "http",
9
+ name: "my-server",
10
+ url: "https://mcp.example.com",
11
+ headers: [{ name: "Authorization", value: "Bearer tok" }],
12
+ },
13
+ ]);
14
+ expect(result.success).toBe(true);
15
+ expect(result.data).toEqual([
16
+ {
17
+ type: "http",
18
+ name: "my-server",
19
+ url: "https://mcp.example.com",
20
+ headers: [{ name: "Authorization", value: "Bearer tok" }],
21
+ },
22
+ ]);
23
+ });
24
+
25
+ it("accepts a valid SSE server", () => {
26
+ const result = mcpServersSchema.safeParse([
27
+ {
28
+ type: "sse",
29
+ name: "sse-server",
30
+ url: "https://sse.example.com/events",
31
+ headers: [],
32
+ },
33
+ ]);
34
+ expect(result.success).toBe(true);
35
+ });
36
+
37
+ it("defaults headers to empty array when omitted", () => {
38
+ const result = mcpServersSchema.safeParse([
39
+ { type: "http", name: "no-headers", url: "https://example.com" },
40
+ ]);
41
+ expect(result.success).toBe(true);
42
+ expect(result.data?.[0].headers).toEqual([]);
43
+ });
44
+
45
+ it("accepts multiple servers", () => {
46
+ const result = mcpServersSchema.safeParse([
47
+ { type: "http", name: "a", url: "https://a.com" },
48
+ { type: "sse", name: "b", url: "https://b.com" },
49
+ ]);
50
+ expect(result.success).toBe(true);
51
+ expect(result.data).toHaveLength(2);
52
+ });
53
+
54
+ it("accepts an empty array", () => {
55
+ const result = mcpServersSchema.safeParse([]);
56
+ expect(result.success).toBe(true);
57
+ expect(result.data).toEqual([]);
58
+ });
59
+
60
+ it("rejects stdio servers", () => {
61
+ const result = mcpServersSchema.safeParse([
62
+ {
63
+ type: "stdio",
64
+ name: "local",
65
+ command: "/usr/bin/mcp",
66
+ args: [],
67
+ },
68
+ ]);
69
+ expect(result.success).toBe(false);
70
+ });
71
+
72
+ it("rejects servers with no type", () => {
73
+ const result = mcpServersSchema.safeParse([
74
+ { name: "missing-type", url: "https://example.com" },
75
+ ]);
76
+ expect(result.success).toBe(false);
77
+ });
78
+
79
+ it("rejects servers with empty name", () => {
80
+ const result = mcpServersSchema.safeParse([
81
+ { type: "http", name: "", url: "https://example.com" },
82
+ ]);
83
+ expect(result.success).toBe(false);
84
+ });
85
+
86
+ it("rejects servers with invalid url", () => {
87
+ const result = mcpServersSchema.safeParse([
88
+ { type: "http", name: "bad-url", url: "not-a-url" },
89
+ ]);
90
+ expect(result.success).toBe(false);
91
+ });
92
+
93
+ it("rejects servers with missing url", () => {
94
+ const result = mcpServersSchema.safeParse([
95
+ { type: "http", name: "no-url" },
96
+ ]);
97
+ expect(result.success).toBe(false);
98
+ });
99
+
100
+ it("rejects non-array input", () => {
101
+ expect(mcpServersSchema.safeParse("not-array").success).toBe(false);
102
+ expect(mcpServersSchema.safeParse({}).success).toBe(false);
103
+ expect(mcpServersSchema.safeParse(null).success).toBe(false);
104
+ });
105
+
106
+ it("rejects headers with missing fields", () => {
107
+ const result = mcpServersSchema.safeParse([
108
+ {
109
+ type: "http",
110
+ name: "bad-headers",
111
+ url: "https://example.com",
112
+ headers: [{ name: "X-Key" }],
113
+ },
114
+ ]);
115
+ expect(result.success).toBe(false);
116
+ });
117
+ });
@@ -1,5 +1,21 @@
1
1
  import { z } from "zod";
2
2
 
3
+ const httpHeaderSchema = z.object({
4
+ name: z.string(),
5
+ value: z.string(),
6
+ });
7
+
8
+ const remoteMcpServerSchema = z.object({
9
+ type: z.enum(["http", "sse"]),
10
+ name: z.string().min(1, "MCP server name is required"),
11
+ url: z.string().url("MCP server url must be a valid URL"),
12
+ headers: z.array(httpHeaderSchema).default([]),
13
+ });
14
+
15
+ export const mcpServersSchema = z.array(remoteMcpServerSchema);
16
+
17
+ export type RemoteMcpServer = z.infer<typeof remoteMcpServerSchema>;
18
+
3
19
  export const jsonRpcRequestSchema = z.object({
4
20
  jsonrpc: z.literal("2.0"),
5
21
  method: z.string(),
@@ -1,4 +1,5 @@
1
1
  import type { AgentMode } from "../types.js";
2
+ import type { RemoteMcpServer } from "./schemas.js";
2
3
 
3
4
  export interface AgentServerConfig {
4
5
  port: number;
@@ -11,4 +12,5 @@ export interface AgentServerConfig {
11
12
  taskId: string;
12
13
  runId: string;
13
14
  version?: string;
15
+ mcpServers?: RemoteMcpServer[];
14
16
  }