@posthog/agent 2.3.388 → 2.3.401

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.
Files changed (34) hide show
  1. package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -1
  2. package/dist/adapters/claude/mcp/tool-metadata.d.ts +24 -0
  3. package/dist/adapters/claude/mcp/tool-metadata.js +165 -0
  4. package/dist/adapters/claude/mcp/tool-metadata.js.map +1 -0
  5. package/dist/adapters/claude/tools.js.map +1 -1
  6. package/dist/agent.js +113 -3
  7. package/dist/agent.js.map +1 -1
  8. package/dist/handoff-checkpoint.d.ts +1 -0
  9. package/dist/handoff-checkpoint.js +17 -1
  10. package/dist/handoff-checkpoint.js.map +1 -1
  11. package/dist/index.d.ts +7 -9
  12. package/dist/index.js.map +1 -1
  13. package/dist/posthog-api.js +5 -1
  14. package/dist/posthog-api.js.map +1 -1
  15. package/dist/server/agent-server.js +258 -101
  16. package/dist/server/agent-server.js.map +1 -1
  17. package/dist/server/bin.cjs +248 -98
  18. package/dist/server/bin.cjs.map +1 -1
  19. package/dist/tree-tracker.js +128 -97
  20. package/dist/tree-tracker.js.map +1 -1
  21. package/package.json +7 -3
  22. package/src/adapters/claude/claude-agent.ts +5 -0
  23. package/src/adapters/claude/mcp/tool-metadata.test.ts +93 -0
  24. package/src/adapters/claude/mcp/tool-metadata.ts +33 -0
  25. package/src/adapters/claude/permissions/permission-handlers.test.ts +165 -0
  26. package/src/adapters/claude/permissions/permission-handlers.ts +105 -0
  27. package/src/adapters/claude/session/instructions.ts +9 -1
  28. package/src/adapters/claude/types.ts +2 -0
  29. package/src/handoff-checkpoint.test.ts +1 -0
  30. package/src/handoff-checkpoint.ts +17 -1
  31. package/src/sagas/apply-snapshot-saga.test.ts +1 -0
  32. package/src/sagas/apply-snapshot-saga.ts +68 -54
  33. package/src/sagas/capture-tree-saga.test.ts +18 -0
  34. package/src/sagas/capture-tree-saga.ts +64 -49
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.388",
3
+ "version": "2.3.401",
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"
@@ -103,8 +107,8 @@
103
107
  "typescript": "^5.5.0",
104
108
  "vitest": "^2.1.8",
105
109
  "@posthog/git": "1.0.0",
106
- "@posthog/shared": "1.0.0",
107
- "@posthog/enricher": "1.0.0"
110
+ "@posthog/enricher": "1.0.0",
111
+ "@posthog/shared": "1.0.0"
108
112
  },
109
113
  "dependencies": {
110
114
  "@agentclientprotocol/sdk": "0.19.0",
@@ -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
- export const APPENDED_INSTRUCTIONS = BRANCH_NAMING + PLAN_MODE;
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[];
@@ -179,5 +179,6 @@ describe("HandoffCheckpointTracker", () => {
179
179
  expect(status).toContain("M tracked.txt");
180
180
  expect(status).toContain(" M unstaged.txt");
181
181
  expect(status).toContain("?? untracked.txt");
182
+ expect(localRepo.exists(".posthog/tmp")).toBe(false);
182
183
  });
183
184
  });
@@ -1,4 +1,11 @@
1
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import {
2
+ mkdir,
3
+ readdir,
4
+ readFile,
5
+ rm,
6
+ rmdir,
7
+ writeFile,
8
+ } from "node:fs/promises";
2
9
  import { join } from "node:path";
3
10
  import {
4
11
  type GitHandoffBranchDivergence,
@@ -161,6 +168,7 @@ export class HandoffCheckpointTracker {
161
168
  } finally {
162
169
  await this.removeIfPresent(packPath);
163
170
  await this.removeIfPresent(indexPath);
171
+ await this.removeTmpDirIfEmpty(tmpDir);
164
172
  }
165
173
  }
166
174
 
@@ -364,4 +372,12 @@ export class HandoffCheckpointTracker {
364
372
  }
365
373
  await rm(filePath, { force: true }).catch(() => {});
366
374
  }
375
+
376
+ private async removeTmpDirIfEmpty(tmpDir: string): Promise<void> {
377
+ const entries = await readdir(tmpDir).catch(() => null);
378
+ if (!entries || entries.length > 0) {
379
+ return;
380
+ }
381
+ await rmdir(tmpDir).catch(() => {});
382
+ }
367
383
  }
@@ -328,6 +328,7 @@ describe("ApplySnapshotSaga", () => {
328
328
  });
329
329
 
330
330
  expect(repo.exists(".posthog/tmp/test-tree-hash.tar.gz")).toBe(false);
331
+ expect(repo.exists(".posthog/tmp")).toBe(false);
331
332
  });
332
333
 
333
334
  it("cleans up downloaded archive on checkout failure (rollback verification)", async () => {