@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.
Files changed (37) 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 +120 -3
  7. package/dist/agent.js.map +1 -1
  8. package/dist/handoff-checkpoint.d.ts +5 -1
  9. package/dist/handoff-checkpoint.js +22 -17
  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.d.ts +1 -0
  14. package/dist/posthog-api.js +12 -1
  15. package/dist/posthog-api.js.map +1 -1
  16. package/dist/resume.d.ts +1 -7
  17. package/dist/resume.js +251 -6513
  18. package/dist/resume.js.map +1 -1
  19. package/dist/server/agent-server.d.ts +2 -1
  20. package/dist/server/agent-server.js +1305 -1181
  21. package/dist/server/agent-server.js.map +1 -1
  22. package/dist/server/bin.cjs +1303 -1179
  23. package/dist/server/bin.cjs.map +1 -1
  24. package/package.json +5 -1
  25. package/src/adapters/claude/claude-agent.ts +5 -0
  26. package/src/adapters/claude/mcp/tool-metadata.test.ts +93 -0
  27. package/src/adapters/claude/mcp/tool-metadata.ts +33 -0
  28. package/src/adapters/claude/permissions/permission-handlers.test.ts +165 -0
  29. package/src/adapters/claude/permissions/permission-handlers.ts +105 -0
  30. package/src/adapters/claude/session/instructions.ts +9 -1
  31. package/src/adapters/claude/types.ts +2 -0
  32. package/src/handoff-checkpoint.ts +25 -19
  33. package/src/posthog-api.ts +8 -0
  34. package/src/resume.ts +20 -11
  35. package/src/sagas/resume-saga.test.ts +7 -47
  36. package/src/sagas/resume-saga.ts +10 -64
  37. 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.387",
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
- 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[];
@@ -113,7 +113,7 @@ export class HandoffCheckpointTracker {
113
113
  divergence: GitHandoffBranchDivergence,
114
114
  ) => Promise<boolean>;
115
115
  },
116
- ): Promise<void> {
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 uploads = await Promise.all(
211
- specs.map(async (spec) => {
212
- if (!spec.filePath) {
213
- return [spec.key, undefined] as const;
214
- }
215
- return [
216
- spec.key,
217
- await this.uploadArtifactFile(
218
- spec.filePath,
219
- spec.name,
220
- spec.contentType,
221
- ),
222
- ] as const;
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(uploads) as Uploads;
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);
@@ -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 selected = selectRecentTurns(conversation, RESUME_HISTORY_TOKEN_BUDGET);
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 < conversation.length) {
142
+ if (selected.length < filtered.length) {
134
143
  parts.push(
135
- `*(${conversation.length - selected.length} earlier turns omitted)*`,
144
+ `*(${filtered.length - selected.length} earlier turns omitted)*`,
136
145
  );
137
146
  }
138
147