@posthog/agent 2.3.387 → 2.3.398
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/adapters/claude/conversion/tool-use-to-acp.js.map +1 -1
- package/dist/adapters/claude/mcp/tool-metadata.d.ts +24 -0
- package/dist/adapters/claude/mcp/tool-metadata.js +165 -0
- package/dist/adapters/claude/mcp/tool-metadata.js.map +1 -0
- package/dist/adapters/claude/tools.js.map +1 -1
- package/dist/agent.js +120 -3
- package/dist/agent.js.map +1 -1
- package/dist/handoff-checkpoint.d.ts +5 -1
- package/dist/handoff-checkpoint.js +22 -17
- package/dist/handoff-checkpoint.js.map +1 -1
- package/dist/index.d.ts +7 -9
- package/dist/index.js.map +1 -1
- package/dist/posthog-api.d.ts +1 -0
- package/dist/posthog-api.js +12 -1
- package/dist/posthog-api.js.map +1 -1
- package/dist/resume.d.ts +1 -7
- package/dist/resume.js +251 -6513
- package/dist/resume.js.map +1 -1
- package/dist/server/agent-server.d.ts +2 -1
- package/dist/server/agent-server.js +1305 -1181
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +1303 -1179
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +5 -1
- package/src/adapters/claude/claude-agent.ts +5 -0
- package/src/adapters/claude/mcp/tool-metadata.test.ts +93 -0
- package/src/adapters/claude/mcp/tool-metadata.ts +33 -0
- package/src/adapters/claude/permissions/permission-handlers.test.ts +165 -0
- package/src/adapters/claude/permissions/permission-handlers.ts +105 -0
- package/src/adapters/claude/session/instructions.ts +9 -1
- package/src/adapters/claude/types.ts +2 -0
- package/src/handoff-checkpoint.ts +25 -19
- package/src/posthog-api.ts +8 -0
- package/src/resume.ts +20 -11
- package/src/sagas/resume-saga.test.ts +7 -47
- package/src/sagas/resume-saga.ts +10 -64
- package/src/server/agent-server.ts +119 -69
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@posthog/agent",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.398",
|
|
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": {
|
|
@@ -52,6 +52,10 @@
|
|
|
52
52
|
"types": "./dist/adapters/reasoning-effort.d.ts",
|
|
53
53
|
"import": "./dist/adapters/reasoning-effort.js"
|
|
54
54
|
},
|
|
55
|
+
"./adapters/claude/mcp/tool-metadata": {
|
|
56
|
+
"types": "./dist/adapters/claude/mcp/tool-metadata.d.ts",
|
|
57
|
+
"import": "./dist/adapters/claude/mcp/tool-metadata.js"
|
|
58
|
+
},
|
|
55
59
|
"./execution-mode": {
|
|
56
60
|
"types": "./dist/execution-mode.d.ts",
|
|
57
61
|
"import": "./dist/execution-mode.js"
|
|
@@ -72,6 +72,7 @@ import type { EnrichedReadCache } from "./hooks";
|
|
|
72
72
|
import {
|
|
73
73
|
fetchMcpToolMetadata,
|
|
74
74
|
getConnectedMcpServerNames,
|
|
75
|
+
setMcpToolApprovalStates,
|
|
75
76
|
} from "./mcp/tool-metadata";
|
|
76
77
|
import { canUseTool } from "./permissions/permission-handlers";
|
|
77
78
|
import { getAvailableSlashCommands } from "./session/commands";
|
|
@@ -1091,6 +1092,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
|
|
|
1091
1092
|
: {};
|
|
1092
1093
|
const systemPrompt = buildSystemPrompt(meta?.systemPrompt);
|
|
1093
1094
|
|
|
1095
|
+
if (meta?.mcpToolApprovals) {
|
|
1096
|
+
setMcpToolApprovalStates(meta.mcpToolApprovals);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1094
1099
|
// Configure structured output via SDK's native outputFormat
|
|
1095
1100
|
const outputFormat =
|
|
1096
1101
|
meta?.jsonSchema && this.options?.onStructuredOutput
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
clearMcpToolMetadataCache,
|
|
4
|
+
getMcpToolApprovalState,
|
|
5
|
+
getMcpToolMetadata,
|
|
6
|
+
isMcpToolReadOnly,
|
|
7
|
+
sanitizeMcpServerName,
|
|
8
|
+
setMcpToolApprovalStates,
|
|
9
|
+
} from "./tool-metadata";
|
|
10
|
+
|
|
11
|
+
describe("tool-metadata approval states", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
clearMcpToolMetadataCache();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("setMcpToolApprovalStates", () => {
|
|
17
|
+
it("creates entries for unknown tools", () => {
|
|
18
|
+
setMcpToolApprovalStates({
|
|
19
|
+
mcp__server__tool1: "approved",
|
|
20
|
+
mcp__server__tool2: "do_not_use",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(getMcpToolApprovalState("mcp__server__tool1")).toBe("approved");
|
|
24
|
+
expect(getMcpToolApprovalState("mcp__server__tool2")).toBe("do_not_use");
|
|
25
|
+
|
|
26
|
+
const meta = getMcpToolMetadata("mcp__server__tool1");
|
|
27
|
+
expect(meta).toBeDefined();
|
|
28
|
+
expect(meta?.readOnly).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("merges with existing entries preserving readOnly", () => {
|
|
32
|
+
setMcpToolApprovalStates({
|
|
33
|
+
mcp__server__ro_tool: "needs_approval",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const before = getMcpToolMetadata("mcp__server__ro_tool");
|
|
37
|
+
expect(before?.readOnly).toBe(false);
|
|
38
|
+
expect(before?.approvalState).toBe("needs_approval");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("updates approval state on existing entries without overwriting other fields", () => {
|
|
42
|
+
setMcpToolApprovalStates({
|
|
43
|
+
mcp__server__tool: "approved",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
setMcpToolApprovalStates({
|
|
47
|
+
mcp__server__tool: "do_not_use",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(getMcpToolApprovalState("mcp__server__tool")).toBe("do_not_use");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("getMcpToolApprovalState", () => {
|
|
55
|
+
it("returns undefined for unknown tools", () => {
|
|
56
|
+
expect(getMcpToolApprovalState("mcp__server__unknown")).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns the correct state", () => {
|
|
60
|
+
setMcpToolApprovalStates({
|
|
61
|
+
mcp__s__t: "needs_approval",
|
|
62
|
+
});
|
|
63
|
+
expect(getMcpToolApprovalState("mcp__s__t")).toBe("needs_approval");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("isMcpToolReadOnly with approval states", () => {
|
|
68
|
+
it("returns false for tools that only have approval state", () => {
|
|
69
|
+
setMcpToolApprovalStates({
|
|
70
|
+
mcp__server__tool: "approved",
|
|
71
|
+
});
|
|
72
|
+
expect(isMcpToolReadOnly("mcp__server__tool")).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("sanitizeMcpServerName", () => {
|
|
77
|
+
it("passes through simple alphanumeric names", () => {
|
|
78
|
+
expect(sanitizeMcpServerName("HubSpot")).toBe("HubSpot");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("replaces spaces with underscores", () => {
|
|
82
|
+
expect(sanitizeMcpServerName("My Server")).toBe("My_Server");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("replaces special characters with underscores", () => {
|
|
86
|
+
expect(sanitizeMcpServerName("server@v2.0!")).toBe("server_v2_0_");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("preserves hyphens and underscores", () => {
|
|
90
|
+
expect(sanitizeMcpServerName("my-server_v2")).toBe("my-server_v2");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
import { Logger } from "../../../utils/logger";
|
|
3
3
|
|
|
4
|
+
export type McpToolApprovalState = "approved" | "needs_approval" | "do_not_use";
|
|
5
|
+
|
|
6
|
+
/** Maps MCP tool keys (e.g. `mcp__server__tool`) to their backend approval state. */
|
|
7
|
+
export type McpToolApprovals = Record<string, McpToolApprovalState>;
|
|
8
|
+
|
|
4
9
|
export interface McpToolMetadata {
|
|
5
10
|
readOnly: boolean;
|
|
6
11
|
name: string;
|
|
7
12
|
description?: string;
|
|
13
|
+
approvalState?: McpToolApprovalState;
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
const mcpToolMetadataCache: Map<string, McpToolMetadata> = new Map();
|
|
@@ -12,6 +18,10 @@ const mcpToolMetadataCache: Map<string, McpToolMetadata> = new Map();
|
|
|
12
18
|
const PENDING_RETRY_INTERVAL_MS = 1_000;
|
|
13
19
|
const PENDING_MAX_RETRIES = 10;
|
|
14
20
|
|
|
21
|
+
export function sanitizeMcpServerName(name: string): string {
|
|
22
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
function buildToolKey(serverName: string, toolName: string): string {
|
|
16
26
|
return `mcp__${serverName}__${toolName}`;
|
|
17
27
|
}
|
|
@@ -49,10 +59,12 @@ export async function fetchMcpToolMetadata(
|
|
|
49
59
|
const toolKey = buildToolKey(server.name, tool.name);
|
|
50
60
|
const readOnly = tool.annotations?.readOnly === true;
|
|
51
61
|
|
|
62
|
+
const existing = mcpToolMetadataCache.get(toolKey);
|
|
52
63
|
mcpToolMetadataCache.set(toolKey, {
|
|
53
64
|
readOnly,
|
|
54
65
|
name: tool.name,
|
|
55
66
|
description: tool.description,
|
|
67
|
+
approvalState: existing?.approvalState,
|
|
56
68
|
});
|
|
57
69
|
if (readOnly) readOnlyCount++;
|
|
58
70
|
}
|
|
@@ -104,6 +116,27 @@ export function getConnectedMcpServerNames(): string[] {
|
|
|
104
116
|
return [...names];
|
|
105
117
|
}
|
|
106
118
|
|
|
119
|
+
export function getMcpToolApprovalState(
|
|
120
|
+
toolName: string,
|
|
121
|
+
): McpToolApprovalState | undefined {
|
|
122
|
+
return mcpToolMetadataCache.get(toolName)?.approvalState;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function setMcpToolApprovalStates(approvals: McpToolApprovals): void {
|
|
126
|
+
for (const [toolKey, approvalState] of Object.entries(approvals)) {
|
|
127
|
+
const existing = mcpToolMetadataCache.get(toolKey);
|
|
128
|
+
if (existing) {
|
|
129
|
+
existing.approvalState = approvalState;
|
|
130
|
+
} else {
|
|
131
|
+
mcpToolMetadataCache.set(toolKey, {
|
|
132
|
+
readOnly: false,
|
|
133
|
+
name: toolKey,
|
|
134
|
+
approvalState,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
107
140
|
export function clearMcpToolMetadataCache(): void {
|
|
108
141
|
mcpToolMetadataCache.clear();
|
|
109
142
|
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
clearMcpToolMetadataCache,
|
|
4
|
+
setMcpToolApprovalStates,
|
|
5
|
+
} from "../mcp/tool-metadata";
|
|
6
|
+
import { canUseTool } from "./permission-handlers";
|
|
7
|
+
|
|
8
|
+
function createContext(
|
|
9
|
+
toolName: string,
|
|
10
|
+
overrides: Record<string, unknown> = {},
|
|
11
|
+
) {
|
|
12
|
+
return {
|
|
13
|
+
session: {
|
|
14
|
+
permissionMode: "default" as const,
|
|
15
|
+
settingsManager: {
|
|
16
|
+
getRepoRoot: vi.fn().mockReturnValue("/repo"),
|
|
17
|
+
},
|
|
18
|
+
...((overrides.session as Record<string, unknown>) ?? {}),
|
|
19
|
+
},
|
|
20
|
+
toolName,
|
|
21
|
+
toolInput: {},
|
|
22
|
+
toolUseID: "test-tool-use-id",
|
|
23
|
+
suggestions: undefined,
|
|
24
|
+
signal: undefined,
|
|
25
|
+
client: {
|
|
26
|
+
sessionUpdate: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
requestPermission: vi.fn().mockResolvedValue({
|
|
28
|
+
outcome: { outcome: "selected", optionId: "allow" },
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
sessionId: "test-session",
|
|
32
|
+
fileContentCache: {},
|
|
33
|
+
logger: {
|
|
34
|
+
info: vi.fn(),
|
|
35
|
+
warn: vi.fn(),
|
|
36
|
+
error: vi.fn(),
|
|
37
|
+
debug: vi.fn(),
|
|
38
|
+
},
|
|
39
|
+
updateConfigOption: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
...overrides,
|
|
41
|
+
} as unknown as Parameters<typeof canUseTool>[0];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("canUseTool MCP approval enforcement", () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
clearMcpToolMetadataCache();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("denies do_not_use MCP tools with correct message", async () => {
|
|
50
|
+
setMcpToolApprovalStates({
|
|
51
|
+
mcp__server__blocked_tool: "do_not_use",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const result = await canUseTool(createContext("mcp__server__blocked_tool"));
|
|
55
|
+
|
|
56
|
+
expect(result.behavior).toBe("deny");
|
|
57
|
+
if (result.behavior === "deny") {
|
|
58
|
+
expect(result.message).toContain("Settings > MCP Servers");
|
|
59
|
+
expect(result.message).toContain("PostHog Code");
|
|
60
|
+
expect(result.interrupt).toBe(false);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("routes needs_approval MCP tools to permission dialog with descriptive title", async () => {
|
|
65
|
+
setMcpToolApprovalStates({
|
|
66
|
+
mcp__HubSpot__search_crm_objects: "needs_approval",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const context = createContext("mcp__HubSpot__search_crm_objects");
|
|
70
|
+
const result = await canUseTool(context);
|
|
71
|
+
|
|
72
|
+
expect(result.behavior).toBe("allow");
|
|
73
|
+
expect(context.client.requestPermission).toHaveBeenCalledWith(
|
|
74
|
+
expect.objectContaining({
|
|
75
|
+
toolCall: expect.objectContaining({
|
|
76
|
+
title: "The agent wants to call search_crm_objects (HubSpot)",
|
|
77
|
+
}),
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("allows approved MCP tools through normal flow", async () => {
|
|
83
|
+
setMcpToolApprovalStates({
|
|
84
|
+
mcp__server__approved_tool: "approved",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const result = await canUseTool(
|
|
88
|
+
createContext("mcp__server__approved_tool"),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Approved falls through to isToolAllowedForMode; MCP tools without
|
|
92
|
+
// readOnly annotation are not auto-allowed, so they go to the default
|
|
93
|
+
// permission flow which calls requestPermission
|
|
94
|
+
expect(result.behavior).toBe("allow");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("falls through for MCP tools with no approval state", async () => {
|
|
98
|
+
const context = createContext("mcp__server__unknown_tool");
|
|
99
|
+
const result = await canUseTool(context);
|
|
100
|
+
|
|
101
|
+
// No approval state → falls through to isToolAllowedForMode → not allowed
|
|
102
|
+
// in default mode → goes to default permission flow
|
|
103
|
+
expect(result.behavior).toBe("allow");
|
|
104
|
+
expect(context.client.requestPermission).toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("blocks do_not_use even on read-only MCP tools", async () => {
|
|
108
|
+
setMcpToolApprovalStates({
|
|
109
|
+
mcp__server__readonly_blocked: "do_not_use",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const result = await canUseTool(
|
|
113
|
+
createContext("mcp__server__readonly_blocked"),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(result.behavior).toBe("deny");
|
|
117
|
+
if (result.behavior === "deny") {
|
|
118
|
+
expect(result.message).toContain("blocked");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("blocks do_not_use even in bypassPermissions mode", async () => {
|
|
123
|
+
setMcpToolApprovalStates({
|
|
124
|
+
mcp__server__blocked_bypass: "do_not_use",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const result = await canUseTool(
|
|
128
|
+
createContext("mcp__server__blocked_bypass", {
|
|
129
|
+
session: { permissionMode: "bypassPermissions" },
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(result.behavior).toBe("deny");
|
|
134
|
+
if (result.behavior === "deny") {
|
|
135
|
+
expect(result.message).toContain("blocked");
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("does not affect non-MCP tools", async () => {
|
|
140
|
+
const result = await canUseTool(createContext("Read"));
|
|
141
|
+
|
|
142
|
+
// Read is in the auto-allowed set for default mode
|
|
143
|
+
expect(result.behavior).toBe("allow");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("emits tool denial notification for do_not_use", async () => {
|
|
147
|
+
setMcpToolApprovalStates({
|
|
148
|
+
mcp__server__denied_tool: "do_not_use",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const context = createContext("mcp__server__denied_tool");
|
|
152
|
+
await canUseTool(context);
|
|
153
|
+
|
|
154
|
+
expect(context.client.sessionUpdate).toHaveBeenCalledWith(
|
|
155
|
+
expect.objectContaining({
|
|
156
|
+
sessionId: "test-session",
|
|
157
|
+
update: expect.objectContaining({
|
|
158
|
+
sessionUpdate: "tool_call_update",
|
|
159
|
+
toolCallId: "test-tool-use-id",
|
|
160
|
+
status: "failed",
|
|
161
|
+
}),
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -9,6 +9,10 @@ import type {
|
|
|
9
9
|
import { text } from "../../../utils/acp-content";
|
|
10
10
|
import type { Logger } from "../../../utils/logger";
|
|
11
11
|
import { toolInfoFromToolUse } from "../conversion/tool-use-to-acp";
|
|
12
|
+
import {
|
|
13
|
+
getMcpToolApprovalState,
|
|
14
|
+
getMcpToolMetadata,
|
|
15
|
+
} from "../mcp/tool-metadata";
|
|
12
16
|
import {
|
|
13
17
|
getClaudePlansDir,
|
|
14
18
|
getLatestAssistantText,
|
|
@@ -408,6 +412,92 @@ async function handleDefaultPermissionFlow(
|
|
|
408
412
|
}
|
|
409
413
|
}
|
|
410
414
|
|
|
415
|
+
function parseMcpToolName(toolName: string): {
|
|
416
|
+
serverName: string;
|
|
417
|
+
tool: string;
|
|
418
|
+
} {
|
|
419
|
+
const parts = toolName.split("__");
|
|
420
|
+
return {
|
|
421
|
+
serverName: parts[1] ?? toolName,
|
|
422
|
+
tool: parts.slice(2).join("__") || toolName,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function handleMcpApprovalFlow(
|
|
427
|
+
context: ToolHandlerContext,
|
|
428
|
+
): Promise<ToolPermissionResult> {
|
|
429
|
+
const { toolName, toolInput, toolUseID, client, sessionId } = context;
|
|
430
|
+
|
|
431
|
+
const { serverName, tool: displayTool } = parseMcpToolName(toolName);
|
|
432
|
+
const metadata = getMcpToolMetadata(toolName);
|
|
433
|
+
const description = metadata?.description
|
|
434
|
+
? `\n\n${metadata.description}`
|
|
435
|
+
: "";
|
|
436
|
+
|
|
437
|
+
const response = await client.requestPermission({
|
|
438
|
+
options: [
|
|
439
|
+
{ kind: "allow_once", name: "Yes", optionId: "allow" },
|
|
440
|
+
{
|
|
441
|
+
kind: "allow_always",
|
|
442
|
+
name: "Yes, always allow",
|
|
443
|
+
optionId: "allow_always",
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
kind: "reject_once",
|
|
447
|
+
name: "Type here to tell the agent what to do differently",
|
|
448
|
+
optionId: "reject",
|
|
449
|
+
_meta: { customInput: true },
|
|
450
|
+
},
|
|
451
|
+
],
|
|
452
|
+
sessionId,
|
|
453
|
+
toolCall: {
|
|
454
|
+
toolCallId: toolUseID,
|
|
455
|
+
title: `The agent wants to call ${displayTool} (${serverName})`,
|
|
456
|
+
kind: "other",
|
|
457
|
+
content: description
|
|
458
|
+
? [{ type: "content" as const, content: text(description) }]
|
|
459
|
+
: [],
|
|
460
|
+
rawInput: { ...(toolInput as Record<string, unknown>), toolName },
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
if (context.signal?.aborted || response.outcome?.outcome === "cancelled") {
|
|
465
|
+
throw new Error("Tool use aborted");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (
|
|
469
|
+
response.outcome?.outcome === "selected" &&
|
|
470
|
+
(response.outcome.optionId === "allow" ||
|
|
471
|
+
response.outcome.optionId === "allow_always")
|
|
472
|
+
) {
|
|
473
|
+
if (response.outcome.optionId === "allow_always") {
|
|
474
|
+
return {
|
|
475
|
+
behavior: "allow",
|
|
476
|
+
updatedInput: toolInput as Record<string, unknown>,
|
|
477
|
+
updatedPermissions: [
|
|
478
|
+
{
|
|
479
|
+
type: "addRules",
|
|
480
|
+
rules: [{ toolName }],
|
|
481
|
+
behavior: "allow",
|
|
482
|
+
destination: "localSettings",
|
|
483
|
+
},
|
|
484
|
+
],
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
behavior: "allow",
|
|
489
|
+
updatedInput: toolInput as Record<string, unknown>,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const feedback = (response._meta?.customInput as string | undefined)?.trim();
|
|
494
|
+
const message = feedback
|
|
495
|
+
? `User refused permission to run tool with feedback: ${feedback}`
|
|
496
|
+
: "User refused permission to run tool";
|
|
497
|
+
await emitToolDenial(context, message);
|
|
498
|
+
return { behavior: "deny", message, interrupt: !feedback };
|
|
499
|
+
}
|
|
500
|
+
|
|
411
501
|
function handlePlanFileException(
|
|
412
502
|
context: ToolHandlerContext,
|
|
413
503
|
): ToolPermissionResult | null {
|
|
@@ -510,6 +600,21 @@ export async function canUseTool(
|
|
|
510
600
|
}
|
|
511
601
|
}
|
|
512
602
|
|
|
603
|
+
if (toolName.startsWith("mcp__")) {
|
|
604
|
+
const approvalState = getMcpToolApprovalState(toolName);
|
|
605
|
+
|
|
606
|
+
if (approvalState === "do_not_use") {
|
|
607
|
+
const message =
|
|
608
|
+
"This tool has been blocked. To re-enable it, go to Settings > MCP Servers in PostHog Code.";
|
|
609
|
+
await emitToolDenial(context, message);
|
|
610
|
+
return { behavior: "deny", message, interrupt: false };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (approvalState === "needs_approval") {
|
|
614
|
+
return handleMcpApprovalFlow(context);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
513
618
|
if (isToolAllowedForMode(toolName, session.permissionMode)) {
|
|
514
619
|
return {
|
|
515
620
|
behavior: "allow",
|
|
@@ -16,4 +16,12 @@ Only enter plan mode (EnterPlanMode) when the user is requesting a significant c
|
|
|
16
16
|
When in doubt, continue executing and incorporate the feedback inline.
|
|
17
17
|
`;
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
const MCP_TOOLS = `
|
|
20
|
+
# MCP Tool Access
|
|
21
|
+
|
|
22
|
+
If an MCP tool call is explicitly denied with a message, relay that denial message to the user exactly as given. Do NOT suggest checking "Claude Code settings."
|
|
23
|
+
|
|
24
|
+
If an MCP tool call returns an error, treat it as a normal tool error — troubleshoot, retry, or inform the user about the specific error. Do NOT assume it is a permissions issue and do NOT direct the user to any settings page.
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
export const APPENDED_INSTRUCTIONS = BRANCH_NAMING + PLAN_MODE + MCP_TOOLS;
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
11
11
|
import type { Pushable } from "../../utils/streams";
|
|
12
12
|
import type { BaseSession } from "../base-acp-agent";
|
|
13
|
+
import type { McpToolApprovals } from "./mcp/tool-metadata";
|
|
13
14
|
import type { SettingsManager } from "./session/settings";
|
|
14
15
|
import type { CodeExecutionMode } from "./tools";
|
|
15
16
|
|
|
@@ -117,6 +118,7 @@ export type NewSessionMeta = {
|
|
|
117
118
|
/** Model ID to use for this session (e.g. "claude-sonnet-4-6") */
|
|
118
119
|
model?: string;
|
|
119
120
|
jsonSchema?: Record<string, unknown> | null;
|
|
121
|
+
mcpToolApprovals?: McpToolApprovals;
|
|
120
122
|
claudeCode?: {
|
|
121
123
|
options?: Options;
|
|
122
124
|
emitRawSDKMessages?: boolean | SDKMessageFilter[];
|
|
@@ -113,7 +113,7 @@ export class HandoffCheckpointTracker {
|
|
|
113
113
|
divergence: GitHandoffBranchDivergence,
|
|
114
114
|
) => Promise<boolean>;
|
|
115
115
|
},
|
|
116
|
-
): Promise<
|
|
116
|
+
): Promise<{ packBytes: number; indexBytes: number; totalBytes: number }> {
|
|
117
117
|
if (!this.apiClient) {
|
|
118
118
|
throw new Error(
|
|
119
119
|
"Cannot apply handoff checkpoint: API client not configured",
|
|
@@ -152,6 +152,12 @@ export class HandoffCheckpointTracker {
|
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
this.logApplyMetrics(checkpoint, downloads, applyResult.totalBytes);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
packBytes: downloads.pack?.rawBytes ?? 0,
|
|
158
|
+
indexBytes: downloads.index?.rawBytes ?? 0,
|
|
159
|
+
totalBytes: applyResult.totalBytes,
|
|
160
|
+
};
|
|
155
161
|
} finally {
|
|
156
162
|
await this.removeIfPresent(packPath);
|
|
157
163
|
await this.removeIfPresent(indexPath);
|
|
@@ -207,23 +213,24 @@ export class HandoffCheckpointTracker {
|
|
|
207
213
|
}
|
|
208
214
|
|
|
209
215
|
private async uploadArtifacts(specs: UploadArtifactSpec[]): Promise<Uploads> {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
216
|
+
const results: Array<readonly [ArtifactKey, UploadedArtifact | undefined]> =
|
|
217
|
+
[];
|
|
218
|
+
for (const spec of specs) {
|
|
219
|
+
if (!spec.filePath) {
|
|
220
|
+
results.push([spec.key, undefined] as const);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
results.push([
|
|
224
|
+
spec.key,
|
|
225
|
+
await this.uploadArtifactFile(
|
|
226
|
+
spec.filePath,
|
|
227
|
+
spec.name,
|
|
228
|
+
spec.contentType,
|
|
229
|
+
),
|
|
230
|
+
] as const);
|
|
231
|
+
}
|
|
225
232
|
|
|
226
|
-
return Object.fromEntries(
|
|
233
|
+
return Object.fromEntries(results) as Uploads;
|
|
227
234
|
}
|
|
228
235
|
|
|
229
236
|
private async downloadArtifactToFile(
|
|
@@ -241,9 +248,8 @@ export class HandoffCheckpointTracker {
|
|
|
241
248
|
artifactPath,
|
|
242
249
|
);
|
|
243
250
|
if (!arrayBuffer) {
|
|
244
|
-
throw new Error(`Failed to download ${label}`);
|
|
251
|
+
throw new Error(`Failed to download ${label} from ${artifactPath}`);
|
|
245
252
|
}
|
|
246
|
-
|
|
247
253
|
const base64Content = Buffer.from(arrayBuffer).toString("utf-8");
|
|
248
254
|
const binaryContent = Buffer.from(base64Content, "base64");
|
|
249
255
|
await writeFile(filePath, binaryContent);
|
package/src/posthog-api.ts
CHANGED
|
@@ -153,6 +153,14 @@ export class PostHogAPIClient {
|
|
|
153
153
|
);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
async resumeRunInCloud(taskId: string, runId: string): Promise<TaskRun> {
|
|
157
|
+
const teamId = this.getTeamId();
|
|
158
|
+
return this.apiRequest<TaskRun>(
|
|
159
|
+
`/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/resume_in_cloud/`,
|
|
160
|
+
{ method: "POST" },
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
156
164
|
async updateTaskRun(
|
|
157
165
|
taskId: string,
|
|
158
166
|
runId: string,
|
package/src/resume.ts
CHANGED
|
@@ -30,8 +30,6 @@ export interface ResumeState {
|
|
|
30
30
|
conversation: ConversationTurn[];
|
|
31
31
|
latestSnapshot: TreeSnapshotEvent | null;
|
|
32
32
|
latestGitCheckpoint: GitCheckpointEvent | null;
|
|
33
|
-
/** Whether the tree snapshot was successfully applied (files restored) */
|
|
34
|
-
snapshotApplied: boolean;
|
|
35
33
|
interrupted: boolean;
|
|
36
34
|
lastDevice?: DeviceInfo;
|
|
37
35
|
logEntryCount: number;
|
|
@@ -61,11 +59,7 @@ export interface ResumeConfig {
|
|
|
61
59
|
/**
|
|
62
60
|
* Resume a task from its persisted log.
|
|
63
61
|
* Returns the rebuilt state for the agent to continue from.
|
|
64
|
-
*
|
|
65
|
-
* Uses Saga pattern internally for atomic operations.
|
|
66
|
-
* Note: snapshotApplied field indicates if files were actually restored -
|
|
67
|
-
* even if latestSnapshot is non-null, files may not have been restored if
|
|
68
|
-
* the snapshot had no archive URL or download/extraction failed.
|
|
62
|
+
* Snapshot and checkpoint application happens in the agent server after SSE connects.
|
|
69
63
|
*/
|
|
70
64
|
export async function resumeFromLog(
|
|
71
65
|
config: ResumeConfig,
|
|
@@ -102,7 +96,6 @@ export async function resumeFromLog(
|
|
|
102
96
|
conversation: result.data.conversation as ConversationTurn[],
|
|
103
97
|
latestSnapshot: result.data.latestSnapshot,
|
|
104
98
|
latestGitCheckpoint: result.data.latestGitCheckpoint,
|
|
105
|
-
snapshotApplied: result.data.snapshotApplied,
|
|
106
99
|
interrupted: result.data.interrupted,
|
|
107
100
|
lastDevice: result.data.lastDevice,
|
|
108
101
|
logEntryCount: result.data.logEntryCount,
|
|
@@ -124,15 +117,31 @@ export function conversationToPromptHistory(
|
|
|
124
117
|
const RESUME_HISTORY_TOKEN_BUDGET = 50_000;
|
|
125
118
|
const TOOL_RESULT_MAX_CHARS = 2000;
|
|
126
119
|
|
|
120
|
+
const RESUME_CONTEXT_MARKERS = [
|
|
121
|
+
"You are resuming a previous conversation",
|
|
122
|
+
"Here is the conversation history from the",
|
|
123
|
+
"Continue from where you left off",
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
function isResumeContextTurn(turn: ConversationTurn): boolean {
|
|
127
|
+
if (turn.role !== "user") return false;
|
|
128
|
+
const text = turn.content
|
|
129
|
+
.filter((b) => b.type === "text")
|
|
130
|
+
.map((b) => (b as { type: "text"; text: string }).text)
|
|
131
|
+
.join("");
|
|
132
|
+
return RESUME_CONTEXT_MARKERS.some((marker) => text.includes(marker));
|
|
133
|
+
}
|
|
134
|
+
|
|
127
135
|
export function formatConversationForResume(
|
|
128
136
|
conversation: ConversationTurn[],
|
|
129
137
|
): string {
|
|
130
|
-
const
|
|
138
|
+
const filtered = conversation.filter((turn) => !isResumeContextTurn(turn));
|
|
139
|
+
const selected = selectRecentTurns(filtered, RESUME_HISTORY_TOKEN_BUDGET);
|
|
131
140
|
const parts: string[] = [];
|
|
132
141
|
|
|
133
|
-
if (selected.length <
|
|
142
|
+
if (selected.length < filtered.length) {
|
|
134
143
|
parts.push(
|
|
135
|
-
`*(${
|
|
144
|
+
`*(${filtered.length - selected.length} earlier turns omitted)*`,
|
|
136
145
|
);
|
|
137
146
|
}
|
|
138
147
|
|