@posthog/agent 2.3.508 → 2.3.513

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.3.508",
3
+ "version": "2.3.513",
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": {
@@ -102,8 +102,8 @@
102
102
  "tsx": "^4.20.6",
103
103
  "typescript": "^5.5.0",
104
104
  "vitest": "^2.1.8",
105
- "@posthog/shared": "1.0.0",
106
105
  "@posthog/git": "1.0.0",
106
+ "@posthog/shared": "1.0.0",
107
107
  "@posthog/enricher": "1.0.0"
108
108
  },
109
109
  "dependencies": {
@@ -103,6 +103,7 @@ import type {
103
103
  SDKMessageFilter,
104
104
  Session,
105
105
  ToolUseCache,
106
+ ToolUseStreamCache,
106
107
  } from "./types";
107
108
 
108
109
  const SESSION_VALIDATION_TIMEOUT_MS = 30_000;
@@ -145,6 +146,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
145
146
  readonly adapterName = "claude";
146
147
  declare session: Session;
147
148
  toolUseCache: ToolUseCache;
149
+ toolUseStreamCache: ToolUseStreamCache;
148
150
  backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
149
151
  clientCapabilities?: ClientCapabilities;
150
152
  private options?: ClaudeAcpAgentOptions;
@@ -155,6 +157,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
155
157
  super(client);
156
158
  this.options = options;
157
159
  this.toolUseCache = {};
160
+ this.toolUseStreamCache = new Map();
158
161
  this.logger = new Logger({ debug: true, prefix: "[ClaudeAcpAgent]" });
159
162
  this.enrichment = createEnrichment(options?.posthogApiConfig, this.logger);
160
163
  }
@@ -403,6 +406,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
403
406
  sessionId: params.sessionId,
404
407
  client: this.client,
405
408
  toolUseCache: this.toolUseCache,
409
+ toolUseStreamCache: this.toolUseStreamCache,
406
410
  fileContentCache: this.fileContentCache,
407
411
  enrichedReadCache: this.enrichedReadCache,
408
412
  logger: this.logger,
@@ -768,6 +772,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
768
772
  }
769
773
  throw error;
770
774
  } finally {
775
+ // Drop any leftover streaming-input buffers. Normally cleared per index
776
+ // on `content_block_stop`, but a cancelled or errored turn may leave
777
+ // entries behind; without this they'd carry over into the next turn
778
+ // and collide with new content-block indices.
779
+ this.toolUseStreamCache.clear();
771
780
  if (!handedOff) {
772
781
  this.session.promptRunning = false;
773
782
  // Resolve all remaining pending prompts so no callers get stuck.
@@ -1528,6 +1537,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1528
1537
  sessionId,
1529
1538
  client: this.client,
1530
1539
  toolUseCache: this.toolUseCache,
1540
+ toolUseStreamCache: this.toolUseStreamCache,
1531
1541
  fileContentCache: this.fileContentCache,
1532
1542
  enrichedReadCache: this.enrichedReadCache,
1533
1543
  logger: this.logger,
@@ -21,8 +21,14 @@ import { POSTHOG_NOTIFICATIONS } from "@/acp-extensions";
21
21
  import { image, text } from "../../../utils/acp-content";
22
22
  import { unreachable } from "../../../utils/common";
23
23
  import type { Logger } from "../../../utils/logger";
24
+ import { tryParsePartialJson } from "../../../utils/partial-json";
24
25
  import { type EnrichedReadCache, registerHookCallback } from "../hooks";
25
- import type { Session, ToolUpdateMeta, ToolUseCache } from "../types";
26
+ import type {
27
+ Session,
28
+ ToolUpdateMeta,
29
+ ToolUseCache,
30
+ ToolUseStreamCache,
31
+ } from "../types";
26
32
  import {
27
33
  type ClaudePlanEntry,
28
34
  planEntries,
@@ -67,6 +73,8 @@ export interface MessageHandlerContext {
67
73
  sessionId: string;
68
74
  client: AgentSideConnection;
69
75
  toolUseCache: ToolUseCache;
76
+ /** Buffers `input_json_delta` partial JSON per content-block index. */
77
+ toolUseStreamCache: ToolUseStreamCache;
70
78
  fileContentCache: { [key: string]: string };
71
79
  enrichedReadCache?: EnrichedReadCache;
72
80
  logger: Logger;
@@ -496,6 +504,7 @@ function streamEventToAcpNotifications(
496
504
  message: SDKPartialAssistantMessage,
497
505
  sessionId: string,
498
506
  toolUseCache: ToolUseCache,
507
+ toolUseStreamCache: ToolUseStreamCache,
499
508
  fileContentCache: { [key: string]: string },
500
509
  client: AgentSideConnection,
501
510
  logger: Logger,
@@ -507,9 +516,16 @@ function streamEventToAcpNotifications(
507
516
  ): SessionNotification[] {
508
517
  const event = message.event;
509
518
  switch (event.type) {
510
- case "content_block_start":
519
+ case "content_block_start": {
520
+ const block = event.content_block;
521
+ if (block.type === "tool_use" || block.type === "mcp_tool_use") {
522
+ toolUseStreamCache.set(event.index, {
523
+ toolUseId: block.id,
524
+ partialJson: "",
525
+ });
526
+ }
511
527
  return toAcpNotifications(
512
- [event.content_block],
528
+ [block],
513
529
  "assistant",
514
530
  sessionId,
515
531
  toolUseCache,
@@ -523,7 +539,16 @@ function streamEventToAcpNotifications(
523
539
  undefined,
524
540
  enrichedReadCache,
525
541
  );
526
- case "content_block_delta":
542
+ }
543
+ case "content_block_delta": {
544
+ if (event.delta.type === "input_json_delta") {
545
+ return inputJsonDeltaToAcpNotifications(
546
+ event.index,
547
+ event.delta.partial_json,
548
+ sessionId,
549
+ toolUseStreamCache,
550
+ );
551
+ }
527
552
  return toAcpNotifications(
528
553
  [event.delta],
529
554
  "assistant",
@@ -539,10 +564,13 @@ function streamEventToAcpNotifications(
539
564
  undefined,
540
565
  enrichedReadCache,
541
566
  );
567
+ }
568
+ case "content_block_stop":
569
+ toolUseStreamCache.delete(event.index);
570
+ return [];
542
571
  case "message_start":
543
572
  case "message_delta":
544
573
  case "message_stop":
545
- case "content_block_stop":
546
574
  return [];
547
575
 
548
576
  default:
@@ -551,6 +579,31 @@ function streamEventToAcpNotifications(
551
579
  }
552
580
  }
553
581
 
582
+ function inputJsonDeltaToAcpNotifications(
583
+ index: number,
584
+ partialJson: string,
585
+ sessionId: string,
586
+ toolUseStreamCache: ToolUseStreamCache,
587
+ ): SessionNotification[] {
588
+ const entry = toolUseStreamCache.get(index);
589
+ if (!entry) return [];
590
+ entry.partialJson += partialJson;
591
+
592
+ const parsed = tryParsePartialJson(entry.partialJson);
593
+ if (!parsed || typeof parsed !== "object") return [];
594
+
595
+ return [
596
+ {
597
+ sessionId,
598
+ update: {
599
+ sessionUpdate: "tool_call_update" as const,
600
+ toolCallId: entry.toolUseId,
601
+ rawInput: parsed as Record<string, unknown>,
602
+ },
603
+ },
604
+ ];
605
+ }
606
+
554
607
  export async function handleSystemMessage(
555
608
  message: Extract<SDKMessage, { type: "system" }>,
556
609
  context: MessageHandlerContext,
@@ -743,13 +796,21 @@ export async function handleStreamEvent(
743
796
  message: SDKPartialAssistantMessage,
744
797
  context: MessageHandlerContext,
745
798
  ): Promise<void> {
746
- const { sessionId, client, toolUseCache, fileContentCache, logger } = context;
799
+ const {
800
+ sessionId,
801
+ client,
802
+ toolUseCache,
803
+ toolUseStreamCache,
804
+ fileContentCache,
805
+ logger,
806
+ } = context;
747
807
  const parentToolCallId = message.parent_tool_use_id ?? undefined;
748
808
 
749
809
  for (const notification of streamEventToAcpNotifications(
750
810
  message,
751
811
  sessionId,
752
812
  toolUseCache,
813
+ toolUseStreamCache,
753
814
  fileContentCache,
754
815
  client,
755
816
  logger,
@@ -0,0 +1,112 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { loadUserClaudeJsonMcpServers } from "./mcp-config";
6
+
7
+ describe("loadUserClaudeJsonMcpServers", () => {
8
+ let tmpHome: string;
9
+
10
+ beforeEach(() => {
11
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "claude-json-test-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(tmpHome, { recursive: true, force: true });
16
+ });
17
+
18
+ it.each([
19
+ { name: "~/.claude.json is missing", setup: () => undefined },
20
+ {
21
+ name: "~/.claude.json contains invalid JSON",
22
+ setup: (home: string) =>
23
+ fs.writeFileSync(path.join(home, ".claude.json"), "not json"),
24
+ },
25
+ ])("returns empty when $name", ({ setup }) => {
26
+ setup(tmpHome);
27
+ expect(
28
+ loadUserClaudeJsonMcpServers("/some/cwd", undefined, tmpHome),
29
+ ).toEqual({});
30
+ });
31
+
32
+ it("returns top-level mcpServers", () => {
33
+ const cfg = {
34
+ mcpServers: {
35
+ top: { type: "stdio", command: "npx", args: ["pkg"] },
36
+ },
37
+ };
38
+ fs.writeFileSync(path.join(tmpHome, ".claude.json"), JSON.stringify(cfg));
39
+ const servers = loadUserClaudeJsonMcpServers(
40
+ "/some/cwd",
41
+ undefined,
42
+ tmpHome,
43
+ );
44
+ expect(servers.top).toBeDefined();
45
+ });
46
+
47
+ it("returns project-scoped mcpServers when cwd matches a project entry", () => {
48
+ const cwd = "/Users/jane/proj";
49
+ const cfg = {
50
+ projects: {
51
+ [cwd]: {
52
+ mcpServers: {
53
+ playwright: {
54
+ type: "stdio",
55
+ command: "npx",
56
+ args: ["@playwright/mcp@latest"],
57
+ },
58
+ },
59
+ },
60
+ },
61
+ };
62
+ fs.writeFileSync(path.join(tmpHome, ".claude.json"), JSON.stringify(cfg));
63
+ const servers = loadUserClaudeJsonMcpServers(cwd, undefined, tmpHome);
64
+ expect(servers.playwright).toBeDefined();
65
+ });
66
+
67
+ it("project-scoped servers override top-level on key collision", () => {
68
+ const cwd = "/Users/jane/proj";
69
+ const cfg = {
70
+ mcpServers: {
71
+ shared: { type: "stdio", command: "global", args: [] },
72
+ },
73
+ projects: {
74
+ [cwd]: {
75
+ mcpServers: {
76
+ shared: { type: "stdio", command: "scoped", args: [] },
77
+ },
78
+ },
79
+ },
80
+ };
81
+ fs.writeFileSync(path.join(tmpHome, ".claude.json"), JSON.stringify(cfg));
82
+ const servers = loadUserClaudeJsonMcpServers(cwd, undefined, tmpHome);
83
+ expect((servers.shared as { command: string }).command).toBe("scoped");
84
+ });
85
+
86
+ it("ignores CLAUDE_CONFIG_DIR redirect (reads real ~/.claude.json)", () => {
87
+ const altDir = fs.mkdtempSync(path.join(os.tmpdir(), "alt-claude-"));
88
+ fs.writeFileSync(
89
+ path.join(altDir, ".claude.json"),
90
+ JSON.stringify({
91
+ mcpServers: { wrong: { type: "stdio", command: "x" } },
92
+ }),
93
+ );
94
+ fs.writeFileSync(
95
+ path.join(tmpHome, ".claude.json"),
96
+ JSON.stringify({
97
+ mcpServers: { right: { type: "stdio", command: "y" } },
98
+ }),
99
+ );
100
+ const original = process.env.CLAUDE_CONFIG_DIR;
101
+ process.env.CLAUDE_CONFIG_DIR = altDir;
102
+ try {
103
+ const servers = loadUserClaudeJsonMcpServers("/cwd", undefined, tmpHome);
104
+ expect(servers.right).toBeDefined();
105
+ expect(servers.wrong).toBeUndefined();
106
+ } finally {
107
+ if (original === undefined) delete process.env.CLAUDE_CONFIG_DIR;
108
+ else process.env.CLAUDE_CONFIG_DIR = original;
109
+ fs.rmSync(altDir, { recursive: true, force: true });
110
+ }
111
+ });
112
+ });
@@ -1,5 +1,50 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
1
4
  import type { NewSessionRequest } from "@agentclientprotocol/sdk";
2
5
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
6
+ import type { Logger } from "../../../utils/logger";
7
+
8
+ export function loadUserClaudeJsonMcpServers(
9
+ cwd: string,
10
+ logger?: Logger,
11
+ homeDir: string = os.homedir(),
12
+ ): Record<string, McpServerConfig> {
13
+ const claudeJsonPath = path.join(homeDir, ".claude.json");
14
+
15
+ let raw: string;
16
+ try {
17
+ raw = fs.readFileSync(claudeJsonPath, "utf8");
18
+ } catch {
19
+ return {};
20
+ }
21
+
22
+ let cfg: {
23
+ mcpServers?: unknown;
24
+ projects?: Record<string, { mcpServers?: unknown }>;
25
+ };
26
+ try {
27
+ cfg = JSON.parse(raw);
28
+ } catch (err) {
29
+ logger?.warn("Failed to parse ~/.claude.json", {
30
+ error: err instanceof Error ? err.message : String(err),
31
+ });
32
+ return {};
33
+ }
34
+
35
+ const topLevel =
36
+ cfg.mcpServers && typeof cfg.mcpServers === "object"
37
+ ? (cfg.mcpServers as Record<string, McpServerConfig>)
38
+ : {};
39
+
40
+ const project = cfg.projects?.[cwd];
41
+ const projectScoped =
42
+ project?.mcpServers && typeof project.mcpServers === "object"
43
+ ? (project.mcpServers as Record<string, McpServerConfig>)
44
+ : {};
45
+
46
+ return { ...topLevel, ...projectScoped };
47
+ }
3
48
 
4
49
  export function parseMcpServers(
5
50
  params: Pick<NewSessionRequest, "mcpServers">,
@@ -24,6 +24,7 @@ import {
24
24
  import type { CodeExecutionMode } from "../tools";
25
25
  import type { EffortLevel } from "../types";
26
26
  import { APPENDED_INSTRUCTIONS } from "./instructions";
27
+ import { loadUserClaudeJsonMcpServers } from "./mcp-config";
27
28
  import { DEFAULT_MODEL } from "./models";
28
29
  import type { SettingsManager } from "./settings";
29
30
 
@@ -91,8 +92,10 @@ export function buildSystemPrompt(
91
92
  function buildMcpServers(
92
93
  userServers: Record<string, McpServerConfig> | undefined,
93
94
  acpServers: Record<string, McpServerConfig>,
95
+ projectScopedServers: Record<string, McpServerConfig>,
94
96
  ): Record<string, McpServerConfig> {
95
97
  return {
98
+ ...projectScopedServers,
96
99
  ...(userServers || {}),
97
100
  ...acpServers,
98
101
  };
@@ -330,6 +333,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
330
333
  mcpServers: buildMcpServers(
331
334
  params.userProvidedOptions?.mcpServers,
332
335
  params.mcpServers,
336
+ loadUserClaudeJsonMcpServers(params.cwd, params.logger),
333
337
  ),
334
338
  env: buildEnvironment(),
335
339
  hooks: buildHooks(
@@ -76,6 +76,16 @@ export type ToolUseCache = {
76
76
  };
77
77
  };
78
78
 
79
+ /**
80
+ * Per-content-block-index buffer for tool inputs streamed via
81
+ * `input_json_delta` events. Keyed by the Anthropic content block index
82
+ * (which resets per assistant message). Cleared on `content_block_stop`.
83
+ */
84
+ export type ToolUseStreamCache = Map<
85
+ number,
86
+ { toolUseId: string; partialJson: string }
87
+ >;
88
+
79
89
  export type TerminalInfo = {
80
90
  terminal_id: string;
81
91
  };
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { tryParsePartialJson } from "./partial-json";
3
+
4
+ describe("tryParsePartialJson", () => {
5
+ it("returns null for empty / whitespace input", () => {
6
+ expect(tryParsePartialJson("")).toBeNull();
7
+ expect(tryParsePartialJson(" ")).toBeNull();
8
+ });
9
+
10
+ it("parses complete JSON unchanged", () => {
11
+ expect(tryParsePartialJson('{"a":1}')).toEqual({ a: 1 });
12
+ expect(tryParsePartialJson("[1,2,3]")).toEqual([1, 2, 3]);
13
+ expect(tryParsePartialJson('"hello"')).toBe("hello");
14
+ });
15
+
16
+ it("closes a single open object", () => {
17
+ expect(tryParsePartialJson("{")).toEqual({});
18
+ });
19
+
20
+ it("closes a partial string value and the surrounding object", () => {
21
+ expect(tryParsePartialJson('{"command": "call execute-')).toEqual({
22
+ command: "call execute-",
23
+ });
24
+ });
25
+
26
+ it("closes a complete string value with no closing brace", () => {
27
+ expect(tryParsePartialJson('{"command": "tools"')).toEqual({
28
+ command: "tools",
29
+ });
30
+ });
31
+
32
+ it("strips a trailing comma after a complete entry", () => {
33
+ expect(tryParsePartialJson('{"a": 1,')).toEqual({ a: 1 });
34
+ });
35
+
36
+ it("drops a trailing partial key with no value", () => {
37
+ expect(tryParsePartialJson('{"a": 1, "b":')).toEqual({ a: 1 });
38
+ expect(tryParsePartialJson('{"a": 1, "b"')).toEqual({ a: 1 });
39
+ });
40
+
41
+ it("handles nested objects and arrays mid-stream", () => {
42
+ expect(tryParsePartialJson('{"q": {"sql": "SELECT 1')).toEqual({
43
+ q: { sql: "SELECT 1" },
44
+ });
45
+ expect(tryParsePartialJson('{"items": [1, 2, 3')).toEqual({
46
+ items: [1, 2, 3],
47
+ });
48
+ });
49
+
50
+ it("respects escaped quotes inside strings", () => {
51
+ expect(tryParsePartialJson('{"q": "say \\"hi\\"')).toEqual({
52
+ q: 'say "hi"',
53
+ });
54
+ });
55
+
56
+ it("returns null when nothing parseable can be reconstructed", () => {
57
+ // Garbage that can't be balanced into valid JSON.
58
+ expect(tryParsePartialJson("not json at all")).toBeNull();
59
+ });
60
+
61
+ it("parses a typical exec command incrementally", () => {
62
+ // Simulate growth of a streamed { command: "call dashboard-update {...}" }
63
+ expect(tryParsePartialJson('{"command":')).toEqual({});
64
+ expect(tryParsePartialJson('{"command": "ca')).toEqual({ command: "ca" });
65
+ expect(
66
+ tryParsePartialJson('{"command": "call dashboard-update {\\"id\\":'),
67
+ ).toEqual({ command: 'call dashboard-update {"id":' });
68
+ expect(
69
+ tryParsePartialJson('{"command": "call dashboard-update {\\"id\\": 1}"}'),
70
+ ).toEqual({ command: 'call dashboard-update {"id": 1}' });
71
+ });
72
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Best-effort parser for incomplete JSON streamed via Anthropic's
3
+ * `input_json_delta` events. Used to surface tool inputs while they are still
4
+ * being generated so the UI can show the args during execution instead of
5
+ * waiting for the finalized assistant message.
6
+ *
7
+ * Strategy: walk the input tracking open `{`/`[` and quote/escape state, then
8
+ * try a few completions in order of likelihood (close any open string, drop
9
+ * trailing commas/colons or partial keys, then close any open brackets).
10
+ * Returns `null` when no completion parses — callers should silently skip
11
+ * that delta and wait for more input.
12
+ */
13
+ export function tryParsePartialJson(s: string): unknown {
14
+ const trimmed = s.trim();
15
+ if (!trimmed) return null;
16
+
17
+ // Fast path: complete JSON.
18
+ try {
19
+ return JSON.parse(trimmed);
20
+ } catch {}
21
+
22
+ const closers: string[] = [];
23
+ let inString = false;
24
+ let escaped = false;
25
+
26
+ for (let i = 0; i < trimmed.length; i++) {
27
+ const ch = trimmed[i];
28
+ if (inString) {
29
+ if (escaped) {
30
+ escaped = false;
31
+ } else if (ch === "\\") {
32
+ escaped = true;
33
+ } else if (ch === '"') {
34
+ inString = false;
35
+ }
36
+ continue;
37
+ }
38
+ if (ch === '"') inString = true;
39
+ else if (ch === "{") closers.push("}");
40
+ else if (ch === "[") closers.push("]");
41
+ else if (ch === "}" || ch === "]") closers.pop();
42
+ }
43
+
44
+ const closeBrackets = (str: string): string => {
45
+ let out = str;
46
+ for (let i = closers.length - 1; i >= 0; i--) out += closers[i];
47
+ return out;
48
+ };
49
+
50
+ const candidates: string[] = [];
51
+
52
+ // 1. Close any open string + brackets.
53
+ const closedString = inString ? `${trimmed}"` : trimmed;
54
+ candidates.push(closeBrackets(closedString));
55
+
56
+ // 2. Drop trailing partial token (comma, colon, or `"key":`/`"key"`)
57
+ // and close brackets.
58
+ let stripped = closedString.replace(/[,:]\s*$/, "");
59
+ stripped = stripped.replace(/,?\s*"[^"]*"\s*:?\s*$/, "");
60
+ candidates.push(closeBrackets(stripped));
61
+
62
+ for (const candidate of candidates) {
63
+ try {
64
+ return JSON.parse(candidate);
65
+ } catch {}
66
+ }
67
+ return null;
68
+ }