@posthog/agent 2.3.510 → 2.3.517
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/agent.js +131 -104
- package/dist/agent.js.map +1 -1
- package/dist/handoff-checkpoint.js +212 -18
- package/dist/handoff-checkpoint.js.map +1 -1
- package/dist/posthog-api.js +1 -1
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.d.ts +2 -1
- package/dist/server/agent-server.js +409 -160
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +389 -141
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +3 -3
- package/src/adapters/claude/session/mcp-config.test.ts +112 -0
- package/src/adapters/claude/session/mcp-config.ts +45 -0
- package/src/adapters/claude/session/options.ts +4 -0
- package/src/server/agent-server.test.ts +59 -1
- package/src/server/agent-server.ts +54 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@posthog/agent",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.517",
|
|
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,9 +102,9 @@
|
|
|
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",
|
|
107
|
-
"@posthog/enricher": "1.0.0"
|
|
106
|
+
"@posthog/enricher": "1.0.0",
|
|
107
|
+
"@posthog/shared": "1.0.0"
|
|
108
108
|
},
|
|
109
109
|
"dependencies": {
|
|
110
110
|
"@agentclientprotocol/sdk": "0.19.0",
|
|
@@ -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(
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { createTestRepo, type TestRepo } from "../test/fixtures/api";
|
|
14
14
|
import { createPostHogHandlers } from "../test/mocks/msw-handlers";
|
|
15
15
|
import type { TaskRun } from "../types";
|
|
16
|
-
import { AgentServer } from "./agent-server";
|
|
16
|
+
import { AgentServer, SSE_KEEPALIVE_INTERVAL_MS } from "./agent-server";
|
|
17
17
|
import { type JwtPayload, SANDBOX_CONNECTION_AUDIENCE } from "./jwt";
|
|
18
18
|
|
|
19
19
|
interface TestableServer {
|
|
@@ -274,6 +274,64 @@ describe("AgentServer HTTP Mode", () => {
|
|
|
274
274
|
expect(response.status).toBe(200);
|
|
275
275
|
expect(response.headers.get("content-type")).toBe("text/event-stream");
|
|
276
276
|
}, 20000);
|
|
277
|
+
|
|
278
|
+
it("emits transport keepalive comments while idle", async () => {
|
|
279
|
+
const keepaliveCallback: { current: (() => void) | null } = {
|
|
280
|
+
current: null,
|
|
281
|
+
};
|
|
282
|
+
const setIntervalSpy = vi
|
|
283
|
+
.spyOn(globalThis, "setInterval")
|
|
284
|
+
.mockImplementation(
|
|
285
|
+
(callback: (_: undefined) => void, timeout?: number) => {
|
|
286
|
+
if (timeout === SSE_KEEPALIVE_INTERVAL_MS) {
|
|
287
|
+
keepaliveCallback.current = () => callback(undefined);
|
|
288
|
+
}
|
|
289
|
+
return setTimeout(() => undefined, 60_000);
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
294
|
+
try {
|
|
295
|
+
await createServer().start();
|
|
296
|
+
const token = createToken();
|
|
297
|
+
|
|
298
|
+
const response = await fetch(`http://localhost:${port}/events`, {
|
|
299
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
expect(response.status).toBe(200);
|
|
303
|
+
expect(response.body).not.toBeNull();
|
|
304
|
+
reader = response.body?.getReader() ?? null;
|
|
305
|
+
expect(reader).not.toBeNull();
|
|
306
|
+
if (!reader) {
|
|
307
|
+
throw new Error("Expected SSE response body reader");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
await vi.waitFor(() =>
|
|
311
|
+
expect(keepaliveCallback.current).not.toBeNull(),
|
|
312
|
+
);
|
|
313
|
+
const emitKeepalive = keepaliveCallback.current;
|
|
314
|
+
if (!emitKeepalive) {
|
|
315
|
+
throw new Error("Expected keepalive callback to be registered");
|
|
316
|
+
}
|
|
317
|
+
emitKeepalive();
|
|
318
|
+
|
|
319
|
+
const decoder = new TextDecoder();
|
|
320
|
+
let streamText = "";
|
|
321
|
+
for (let attempts = 0; attempts < 5; attempts++) {
|
|
322
|
+
const { done, value } = await reader.read();
|
|
323
|
+
if (done) break;
|
|
324
|
+
streamText += decoder.decode(value, { stream: true });
|
|
325
|
+
if (streamText.includes(": keepalive\n\n")) break;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
expect(streamText).toContain(": keepalive\n\n");
|
|
329
|
+
expect(streamText).not.toContain('"type":"keepalive"');
|
|
330
|
+
} finally {
|
|
331
|
+
await reader?.cancel();
|
|
332
|
+
setIntervalSpy.mockRestore();
|
|
333
|
+
}
|
|
334
|
+
}, 20000);
|
|
277
335
|
});
|
|
278
336
|
|
|
279
337
|
describe("POST /command", () => {
|
|
@@ -73,6 +73,8 @@ const errorWithClassificationSchema = z.object({
|
|
|
73
73
|
|
|
74
74
|
type MessageCallback = (message: unknown) => void;
|
|
75
75
|
|
|
76
|
+
export const SSE_KEEPALIVE_INTERVAL_MS = 25_000;
|
|
77
|
+
|
|
76
78
|
class NdJsonTap {
|
|
77
79
|
private decoder = new TextDecoder();
|
|
78
80
|
private buffer = "";
|
|
@@ -329,41 +331,73 @@ export class AgentServer {
|
|
|
329
331
|
);
|
|
330
332
|
}
|
|
331
333
|
|
|
334
|
+
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
|
335
|
+
const clearKeepalive = (): void => {
|
|
336
|
+
if (keepaliveInterval) {
|
|
337
|
+
clearInterval(keepaliveInterval);
|
|
338
|
+
keepaliveInterval = null;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
332
342
|
const stream = new ReadableStream({
|
|
333
343
|
start: async (controller) => {
|
|
334
|
-
|
|
344
|
+
let sseController: SseController | null = null;
|
|
345
|
+
const encoder = new TextEncoder();
|
|
346
|
+
const detachCurrentSseController = (): void => {
|
|
347
|
+
if (sseController) {
|
|
348
|
+
this.detachSseController(sseController);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
const enqueueSseFrame = (frame: string): void => {
|
|
352
|
+
try {
|
|
353
|
+
controller.enqueue(encoder.encode(frame));
|
|
354
|
+
} catch {
|
|
355
|
+
clearKeepalive();
|
|
356
|
+
detachCurrentSseController();
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
sseController = {
|
|
335
361
|
send: (data: unknown) => {
|
|
336
|
-
|
|
337
|
-
controller.enqueue(
|
|
338
|
-
new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`),
|
|
339
|
-
);
|
|
340
|
-
} catch {
|
|
341
|
-
this.detachSseController(sseController);
|
|
342
|
-
}
|
|
362
|
+
enqueueSseFrame(`data: ${JSON.stringify(data)}\n\n`);
|
|
343
363
|
},
|
|
344
364
|
close: () => {
|
|
345
365
|
try {
|
|
366
|
+
clearKeepalive();
|
|
346
367
|
controller.close();
|
|
347
368
|
} catch {
|
|
348
|
-
|
|
369
|
+
detachCurrentSseController();
|
|
349
370
|
}
|
|
350
371
|
},
|
|
351
372
|
};
|
|
352
373
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
374
|
+
keepaliveInterval = setInterval(() => {
|
|
375
|
+
enqueueSseFrame(": keepalive\n\n");
|
|
376
|
+
}, SSE_KEEPALIVE_INTERVAL_MS);
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
if (
|
|
380
|
+
!this.session ||
|
|
381
|
+
this.session.payload.run_id !== payload.run_id
|
|
382
|
+
) {
|
|
383
|
+
await this.initializeSession(payload, sseController);
|
|
384
|
+
} else {
|
|
385
|
+
this.session.sseController = sseController;
|
|
386
|
+
this.session.hasDesktopConnected = true;
|
|
387
|
+
this.replayPendingEvents();
|
|
388
|
+
}
|
|
360
389
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
390
|
+
this.sendSseEvent(sseController, {
|
|
391
|
+
type: "connected",
|
|
392
|
+
run_id: payload.run_id,
|
|
393
|
+
});
|
|
394
|
+
} catch (error) {
|
|
395
|
+
clearKeepalive();
|
|
396
|
+
throw error;
|
|
397
|
+
}
|
|
365
398
|
},
|
|
366
399
|
cancel: () => {
|
|
400
|
+
clearKeepalive();
|
|
367
401
|
this.logger.debug("SSE connection closed");
|
|
368
402
|
if (this.session?.sseController) {
|
|
369
403
|
this.session.sseController = null;
|