@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
@@ -8605,7 +8605,7 @@ import { z as z4 } from "zod";
8605
8605
  // package.json
8606
8606
  var package_default = {
8607
8607
  name: "@posthog/agent",
8608
- version: "2.3.387",
8608
+ version: "2.3.398",
8609
8609
  repository: "https://github.com/PostHog/code",
8610
8610
  description: "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
8611
8611
  exports: {
@@ -8657,6 +8657,10 @@ var package_default = {
8657
8657
  types: "./dist/adapters/reasoning-effort.d.ts",
8658
8658
  import: "./dist/adapters/reasoning-effort.js"
8659
8659
  },
8660
+ "./adapters/claude/mcp/tool-metadata": {
8661
+ types: "./dist/adapters/claude/mcp/tool-metadata.d.ts",
8662
+ import: "./dist/adapters/claude/mcp/tool-metadata.js"
8663
+ },
8660
8664
  "./execution-mode": {
8661
8665
  types: "./dist/execution-mode.d.ts",
8662
8666
  import: "./dist/execution-mode.js"
@@ -13657,10 +13661,12 @@ async function fetchMcpToolMetadata(q, logger = new Logger({ debug: false, prefi
13657
13661
  for (const tool of server.tools) {
13658
13662
  const toolKey = buildToolKey(server.name, tool.name);
13659
13663
  const readOnly = tool.annotations?.readOnly === true;
13664
+ const existing = mcpToolMetadataCache.get(toolKey);
13660
13665
  mcpToolMetadataCache.set(toolKey, {
13661
13666
  readOnly,
13662
13667
  name: tool.name,
13663
- description: tool.description
13668
+ description: tool.description,
13669
+ approvalState: existing?.approvalState
13664
13670
  });
13665
13671
  if (readOnly) readOnlyCount++;
13666
13672
  }
@@ -13702,6 +13708,23 @@ function getConnectedMcpServerNames() {
13702
13708
  }
13703
13709
  return [...names];
13704
13710
  }
13711
+ function getMcpToolApprovalState(toolName) {
13712
+ return mcpToolMetadataCache.get(toolName)?.approvalState;
13713
+ }
13714
+ function setMcpToolApprovalStates(approvals) {
13715
+ for (const [toolKey, approvalState] of Object.entries(approvals)) {
13716
+ const existing = mcpToolMetadataCache.get(toolKey);
13717
+ if (existing) {
13718
+ existing.approvalState = approvalState;
13719
+ } else {
13720
+ mcpToolMetadataCache.set(toolKey, {
13721
+ readOnly: false,
13722
+ name: toolKey,
13723
+ approvalState
13724
+ });
13725
+ }
13726
+ }
13727
+ }
13705
13728
 
13706
13729
  // src/adapters/claude/conversion/tool-use-to-acp.ts
13707
13730
  var SYSTEM_REMINDER_REGEX = /\s*<system-reminder>[\s\S]*?<\/system-reminder>/g;
@@ -15397,6 +15420,72 @@ async function handleDefaultPermissionFlow(context) {
15397
15420
  return { behavior: "deny", message, interrupt: !feedback };
15398
15421
  }
15399
15422
  }
15423
+ function parseMcpToolName(toolName) {
15424
+ const parts2 = toolName.split("__");
15425
+ return {
15426
+ serverName: parts2[1] ?? toolName,
15427
+ tool: parts2.slice(2).join("__") || toolName
15428
+ };
15429
+ }
15430
+ async function handleMcpApprovalFlow(context) {
15431
+ const { toolName, toolInput, toolUseID, client, sessionId } = context;
15432
+ const { serverName, tool: displayTool } = parseMcpToolName(toolName);
15433
+ const metadata2 = getMcpToolMetadata(toolName);
15434
+ const description = metadata2?.description ? `
15435
+
15436
+ ${metadata2.description}` : "";
15437
+ const response = await client.requestPermission({
15438
+ options: [
15439
+ { kind: "allow_once", name: "Yes", optionId: "allow" },
15440
+ {
15441
+ kind: "allow_always",
15442
+ name: "Yes, always allow",
15443
+ optionId: "allow_always"
15444
+ },
15445
+ {
15446
+ kind: "reject_once",
15447
+ name: "Type here to tell the agent what to do differently",
15448
+ optionId: "reject",
15449
+ _meta: { customInput: true }
15450
+ }
15451
+ ],
15452
+ sessionId,
15453
+ toolCall: {
15454
+ toolCallId: toolUseID,
15455
+ title: `The agent wants to call ${displayTool} (${serverName})`,
15456
+ kind: "other",
15457
+ content: description ? [{ type: "content", content: text(description) }] : [],
15458
+ rawInput: { ...toolInput, toolName }
15459
+ }
15460
+ });
15461
+ if (context.signal?.aborted || response.outcome?.outcome === "cancelled") {
15462
+ throw new Error("Tool use aborted");
15463
+ }
15464
+ if (response.outcome?.outcome === "selected" && (response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")) {
15465
+ if (response.outcome.optionId === "allow_always") {
15466
+ return {
15467
+ behavior: "allow",
15468
+ updatedInput: toolInput,
15469
+ updatedPermissions: [
15470
+ {
15471
+ type: "addRules",
15472
+ rules: [{ toolName }],
15473
+ behavior: "allow",
15474
+ destination: "localSettings"
15475
+ }
15476
+ ]
15477
+ };
15478
+ }
15479
+ return {
15480
+ behavior: "allow",
15481
+ updatedInput: toolInput
15482
+ };
15483
+ }
15484
+ const feedback = response._meta?.customInput?.trim();
15485
+ const message = feedback ? `User refused permission to run tool with feedback: ${feedback}` : "User refused permission to run tool";
15486
+ await emitToolDenial(context, message);
15487
+ return { behavior: "deny", message, interrupt: !feedback };
15488
+ }
15400
15489
  function handlePlanFileException(context) {
15401
15490
  const { session, toolName, toolInput } = context;
15402
15491
  if (session.permissionMode !== "plan" || !WRITE_TOOLS.has(toolName)) {
@@ -15467,6 +15556,17 @@ async function canUseTool(context) {
15467
15556
  }
15468
15557
  }
15469
15558
  }
15559
+ if (toolName.startsWith("mcp__")) {
15560
+ const approvalState = getMcpToolApprovalState(toolName);
15561
+ if (approvalState === "do_not_use") {
15562
+ const message = "This tool has been blocked. To re-enable it, go to Settings > MCP Servers in PostHog Code.";
15563
+ await emitToolDenial(context, message);
15564
+ return { behavior: "deny", message, interrupt: false };
15565
+ }
15566
+ if (approvalState === "needs_approval") {
15567
+ return handleMcpApprovalFlow(context);
15568
+ }
15569
+ }
15470
15570
  if (isToolAllowedForMode(toolName, session.permissionMode)) {
15471
15571
  return {
15472
15572
  behavior: "allow",
@@ -15679,7 +15779,14 @@ Only enter plan mode (EnterPlanMode) when the user is requesting a significant c
15679
15779
 
15680
15780
  When in doubt, continue executing and incorporate the feedback inline.
15681
15781
  `;
15682
- var APPENDED_INSTRUCTIONS = BRANCH_NAMING + PLAN_MODE;
15782
+ var MCP_TOOLS = `
15783
+ # MCP Tool Access
15784
+
15785
+ 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."
15786
+
15787
+ If an MCP tool call returns an error, treat it as a normal tool error \u2014 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.
15788
+ `;
15789
+ var APPENDED_INSTRUCTIONS = BRANCH_NAMING + PLAN_MODE + MCP_TOOLS;
15683
15790
 
15684
15791
  // src/adapters/claude/session/options.ts
15685
15792
  function buildSystemPrompt(customPrompt) {
@@ -17008,6 +17115,9 @@ var ClaudeAcpAgent = class extends BaseAcpAgent {
17008
17115
  const earlyModelId = settingsManager.getSettings().model || meta?.model || "";
17009
17116
  const mcpServers = supportsMcpInjection(earlyModelId) ? parseMcpServers(params) : {};
17010
17117
  const systemPrompt = buildSystemPrompt(meta?.systemPrompt);
17118
+ if (meta?.mcpToolApprovals) {
17119
+ setMcpToolApprovalStates(meta.mcpToolApprovals);
17120
+ }
17011
17121
  const outputFormat = meta?.jsonSchema && this.options?.onStructuredOutput ? { type: "json_schema", schema: meta.jsonSchema } : void 0;
17012
17122
  this.logger.debug(isResume ? "Resuming session" : "Creating new session", {
17013
17123
  sessionId,
@@ -19157,6 +19267,11 @@ var HandoffCheckpointTracker = class {
19157
19267
  onDivergedBranch: options?.onDivergedBranch
19158
19268
  });
19159
19269
  this.logApplyMetrics(checkpoint, downloads, applyResult.totalBytes);
19270
+ return {
19271
+ packBytes: downloads.pack?.rawBytes ?? 0,
19272
+ indexBytes: downloads.index?.rawBytes ?? 0,
19273
+ totalBytes: applyResult.totalBytes
19274
+ };
19160
19275
  } finally {
19161
19276
  await this.removeIfPresent(packPath);
19162
19277
  await this.removeIfPresent(indexPath);
@@ -19203,22 +19318,22 @@ var HandoffCheckpointTracker = class {
19203
19318
  };
19204
19319
  }
19205
19320
  async uploadArtifacts(specs) {
19206
- const uploads = await Promise.all(
19207
- specs.map(async (spec) => {
19208
- if (!spec.filePath) {
19209
- return [spec.key, void 0];
19210
- }
19211
- return [
19212
- spec.key,
19213
- await this.uploadArtifactFile(
19214
- spec.filePath,
19215
- spec.name,
19216
- spec.contentType
19217
- )
19218
- ];
19219
- })
19220
- );
19221
- return Object.fromEntries(uploads);
19321
+ const results = [];
19322
+ for (const spec of specs) {
19323
+ if (!spec.filePath) {
19324
+ results.push([spec.key, void 0]);
19325
+ continue;
19326
+ }
19327
+ results.push([
19328
+ spec.key,
19329
+ await this.uploadArtifactFile(
19330
+ spec.filePath,
19331
+ spec.name,
19332
+ spec.contentType
19333
+ )
19334
+ ]);
19335
+ }
19336
+ return Object.fromEntries(results);
19222
19337
  }
19223
19338
  async downloadArtifactToFile(artifactPath, filePath, label) {
19224
19339
  if (!this.apiClient) {
@@ -19230,7 +19345,7 @@ var HandoffCheckpointTracker = class {
19230
19345
  artifactPath
19231
19346
  );
19232
19347
  if (!arrayBuffer) {
19233
- throw new Error(`Failed to download ${label}`);
19348
+ throw new Error(`Failed to download ${label} from ${artifactPath}`);
19234
19349
  }
19235
19350
  const base64Content = Buffer.from(arrayBuffer).toString("utf-8");
19236
19351
  const binaryContent = Buffer.from(base64Content, "base64");
@@ -19414,6 +19529,13 @@ var PostHogAPIClient = class {
19414
19529
  `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/`
19415
19530
  );
19416
19531
  }
19532
+ async resumeRunInCloud(taskId, runId) {
19533
+ const teamId = this.getTeamId();
19534
+ return this.apiRequest(
19535
+ `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/resume_in_cloud/`,
19536
+ { method: "POST" }
19537
+ );
19538
+ }
19417
19539
  async updateTaskRun(taskId, runId, payload) {
19418
19540
  const teamId = this.getTeamId();
19419
19541
  return this.apiRequest(
@@ -19561,1215 +19683,1180 @@ function selectRecentTurns(turns, maxTokens = DEFAULT_MAX_TOKENS) {
19561
19683
  return turns.slice(startIndex);
19562
19684
  }
19563
19685
 
19564
- // src/sagas/apply-snapshot-saga.ts
19565
- import { mkdir as mkdir7, rm as rm6, writeFile as writeFile5 } from "fs/promises";
19566
- import { join as join12 } from "path";
19567
-
19568
- // ../git/dist/sagas/tree.js
19569
- import { existsSync as existsSync5 } from "fs";
19570
- import * as fs12 from "fs/promises";
19571
- import * as path15 from "path";
19572
- import * as tar from "tar";
19573
- var CaptureTreeSaga = class extends GitSaga {
19574
- sagaName = "CaptureTreeSaga";
19575
- tempIndexPath = null;
19576
- async executeGitOperations(input) {
19577
- const { baseDir, lastTreeHash, archivePath, signal } = input;
19578
- const tmpDir = path15.join(baseDir, ".git", "posthog-code-tmp");
19579
- await this.step({
19580
- name: "create_tmp_dir",
19581
- execute: () => fs12.mkdir(tmpDir, { recursive: true }),
19582
- rollback: async () => {
19583
- }
19584
- });
19585
- this.tempIndexPath = path15.join(tmpDir, `index-${Date.now()}`);
19586
- const tempIndexGit = this.git.env({
19587
- ...process.env,
19588
- GIT_INDEX_FILE: this.tempIndexPath
19589
- });
19590
- await this.step({
19591
- name: "init_temp_index",
19592
- execute: () => tempIndexGit.raw(["read-tree", "HEAD"]),
19593
- rollback: async () => {
19594
- if (this.tempIndexPath) {
19595
- await fs12.rm(this.tempIndexPath, { force: true }).catch(() => {
19596
- });
19597
- }
19598
- }
19599
- });
19600
- await this.readOnlyStep("stage_files", () => tempIndexGit.raw(["add", "-A"]));
19601
- const treeHash = await this.readOnlyStep("write_tree", () => tempIndexGit.raw(["write-tree"]));
19602
- if (lastTreeHash && treeHash === lastTreeHash) {
19603
- this.log.debug("No changes since last capture", { treeHash });
19604
- await fs12.rm(this.tempIndexPath, { force: true }).catch(() => {
19605
- });
19606
- return { snapshot: null, changed: false };
19686
+ // src/sagas/resume-saga.ts
19687
+ var ResumeSaga = class extends Saga {
19688
+ sagaName = "ResumeSaga";
19689
+ async execute(input) {
19690
+ const { taskId, runId, apiClient } = input;
19691
+ const taskRun = await this.readOnlyStep(
19692
+ "fetch_task_run",
19693
+ () => apiClient.getTaskRun(taskId, runId)
19694
+ );
19695
+ if (!taskRun.log_url) {
19696
+ this.log.info("No log URL found, starting fresh");
19697
+ return this.emptyResult();
19607
19698
  }
19608
- const baseCommit = await this.readOnlyStep("get_base_commit", async () => {
19609
- try {
19610
- return await getHeadSha(baseDir, { abortSignal: signal });
19611
- } catch {
19612
- return null;
19613
- }
19614
- });
19615
- const changes = await this.readOnlyStep("get_changes", () => this.getChanges(this.git, baseCommit, treeHash));
19616
- await fs12.rm(this.tempIndexPath, { force: true }).catch(() => {
19617
- });
19618
- const snapshot = {
19619
- treeHash,
19620
- baseCommit,
19621
- changes,
19622
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
19623
- };
19624
- let createdArchivePath;
19625
- if (archivePath) {
19626
- createdArchivePath = await this.createArchive(baseDir, archivePath, changes);
19699
+ const entries = await this.readOnlyStep(
19700
+ "fetch_logs",
19701
+ () => apiClient.fetchTaskRunLogs(taskRun)
19702
+ );
19703
+ if (entries.length === 0) {
19704
+ this.log.info("No log entries found, starting fresh");
19705
+ return this.emptyResult();
19627
19706
  }
19628
- this.log.info("Tree captured", {
19629
- treeHash,
19630
- changes: changes.length,
19631
- archived: !!createdArchivePath
19632
- });
19633
- return { snapshot, archivePath: createdArchivePath, changed: true };
19634
- }
19635
- async createArchive(baseDir, archivePath, changes) {
19636
- const filesToArchive = changes.filter((c) => c.status !== "D").map((c) => c.path);
19637
- if (filesToArchive.length === 0) {
19638
- return void 0;
19707
+ this.log.info("Fetched log entries", { count: entries.length });
19708
+ const latestSnapshot = await this.readOnlyStep(
19709
+ "find_snapshot",
19710
+ () => Promise.resolve(this.findLatestTreeSnapshot(entries))
19711
+ );
19712
+ const latestGitCheckpoint = await this.readOnlyStep(
19713
+ "find_git_checkpoint",
19714
+ () => Promise.resolve(this.findLatestGitCheckpoint(entries))
19715
+ );
19716
+ if (latestSnapshot) {
19717
+ this.log.info("Found tree snapshot", {
19718
+ treeHash: latestSnapshot.treeHash,
19719
+ hasArchiveUrl: !!latestSnapshot.archiveUrl,
19720
+ changes: latestSnapshot.changes?.length ?? 0
19721
+ });
19639
19722
  }
19640
- const existingFiles = filesToArchive.filter((f) => existsSync5(path15.join(baseDir, f)));
19641
- if (existingFiles.length === 0) {
19642
- return void 0;
19723
+ if (latestGitCheckpoint) {
19724
+ this.log.info("Found git checkpoint", {
19725
+ checkpointId: latestGitCheckpoint.checkpointId,
19726
+ branch: latestGitCheckpoint.branch
19727
+ });
19643
19728
  }
19644
- await this.step({
19645
- name: "create_archive",
19646
- execute: async () => {
19647
- const archiveDir = path15.dirname(archivePath);
19648
- await fs12.mkdir(archiveDir, { recursive: true });
19649
- await tar.create({
19650
- gzip: true,
19651
- file: archivePath,
19652
- cwd: baseDir
19653
- }, existingFiles);
19654
- },
19655
- rollback: async () => {
19656
- await fs12.rm(archivePath, { force: true }).catch(() => {
19657
- });
19658
- }
19729
+ const conversation = await this.readOnlyStep(
19730
+ "rebuild_conversation",
19731
+ () => Promise.resolve(this.rebuildConversation(entries))
19732
+ );
19733
+ const lastDevice = await this.readOnlyStep(
19734
+ "find_device",
19735
+ () => Promise.resolve(this.findLastDeviceInfo(entries))
19736
+ );
19737
+ this.log.info("Resume state rebuilt", {
19738
+ turns: conversation.length,
19739
+ hasSnapshot: !!latestSnapshot,
19740
+ hasGitCheckpoint: !!latestGitCheckpoint,
19741
+ interrupted: latestSnapshot?.interrupted ?? false
19659
19742
  });
19660
- return archivePath;
19743
+ return {
19744
+ conversation,
19745
+ latestSnapshot,
19746
+ latestGitCheckpoint,
19747
+ interrupted: latestSnapshot?.interrupted ?? false,
19748
+ lastDevice,
19749
+ logEntryCount: entries.length
19750
+ };
19661
19751
  }
19662
- async getChanges(git, fromRef, toRef) {
19663
- if (!fromRef) {
19664
- const stdout2 = await git.raw(["ls-tree", "-r", "--name-only", toRef]);
19665
- return stdout2.split("\n").filter((p) => p.trim()).map((p) => ({ path: p, status: "A" }));
19666
- }
19667
- const stdout = await git.raw([
19668
- "diff-tree",
19669
- "-r",
19670
- "--name-status",
19671
- fromRef,
19672
- toRef
19673
- ]);
19674
- const changes = [];
19675
- for (const line of stdout.split("\n")) {
19676
- if (!line.trim())
19677
- continue;
19678
- const [status, filePath] = line.split(" ");
19679
- if (!filePath)
19680
- continue;
19681
- let normalizedStatus;
19682
- if (status === "D") {
19683
- normalizedStatus = "D";
19684
- } else if (status === "A") {
19685
- normalizedStatus = "A";
19686
- } else {
19687
- normalizedStatus = "M";
19752
+ emptyResult() {
19753
+ return {
19754
+ conversation: [],
19755
+ latestSnapshot: null,
19756
+ latestGitCheckpoint: null,
19757
+ interrupted: false,
19758
+ logEntryCount: 0
19759
+ };
19760
+ }
19761
+ findLatestTreeSnapshot(entries) {
19762
+ for (let i2 = entries.length - 1; i2 >= 0; i2--) {
19763
+ const entry = entries[i2];
19764
+ if (isNotification(
19765
+ entry.notification?.method,
19766
+ POSTHOG_NOTIFICATIONS.TREE_SNAPSHOT
19767
+ )) {
19768
+ const params = entry.notification.params;
19769
+ if (params?.treeHash) {
19770
+ return params;
19771
+ }
19688
19772
  }
19689
- changes.push({ path: filePath, status: normalizedStatus });
19690
19773
  }
19691
- return changes;
19774
+ return null;
19692
19775
  }
19693
- };
19694
- var ApplyTreeSaga = class extends GitSaga {
19695
- sagaName = "ApplyTreeSaga";
19696
- originalHead = null;
19697
- originalBranch = null;
19698
- extractedFiles = [];
19699
- fileBackups = /* @__PURE__ */ new Map();
19700
- async executeGitOperations(input) {
19701
- const { baseDir, treeHash, baseCommit, changes, archivePath } = input;
19702
- const headInfo = await this.readOnlyStep("get_current_head", async () => {
19703
- let head = null;
19704
- let branch = null;
19705
- try {
19706
- head = await this.git.revparse(["HEAD"]);
19707
- } catch {
19708
- head = null;
19776
+ findLatestGitCheckpoint(entries) {
19777
+ const sdkPrefixedMethod = `_${POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT}`;
19778
+ for (let i2 = entries.length - 1; i2 >= 0; i2--) {
19779
+ const entry = entries[i2];
19780
+ const method = entry.notification?.method;
19781
+ if (method === sdkPrefixedMethod || method === POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT) {
19782
+ const params = entry.notification?.params;
19783
+ if (params?.checkpointId && params?.checkpointRef) {
19784
+ return params;
19785
+ }
19709
19786
  }
19710
- try {
19711
- branch = await this.git.raw(["symbolic-ref", "--short", "HEAD"]);
19712
- } catch {
19713
- branch = null;
19787
+ }
19788
+ return null;
19789
+ }
19790
+ findLastDeviceInfo(entries) {
19791
+ for (let i2 = entries.length - 1; i2 >= 0; i2--) {
19792
+ const entry = entries[i2];
19793
+ const params = entry.notification?.params;
19794
+ if (params?.device) {
19795
+ return params.device;
19714
19796
  }
19715
- return { head, branch };
19716
- });
19717
- this.originalHead = headInfo.head;
19718
- this.originalBranch = headInfo.branch;
19719
- let checkoutPerformed = false;
19720
- if (baseCommit && baseCommit !== this.originalHead) {
19721
- await this.readOnlyStep("check_working_tree", async () => {
19722
- const status = await this.git.status();
19723
- if (!status.isClean()) {
19724
- const changedFiles = status.modified.length + status.staged.length + status.deleted.length;
19725
- throw new Error(`Cannot apply tree: ${changedFiles} uncommitted change(s) exist. Commit or stash your changes first.`);
19726
- }
19727
- });
19728
- await this.step({
19729
- name: "checkout_base",
19730
- execute: async () => {
19731
- await this.git.checkout(baseCommit);
19732
- checkoutPerformed = true;
19733
- this.log.warn("Applied tree from different commit - now in detached HEAD state", {
19734
- originalHead: this.originalHead,
19735
- originalBranch: this.originalBranch,
19736
- baseCommit
19737
- });
19738
- },
19739
- rollback: async () => {
19740
- try {
19741
- if (this.originalBranch) {
19742
- await this.git.checkout(this.originalBranch);
19743
- } else if (this.originalHead) {
19744
- await this.git.checkout(this.originalHead);
19797
+ }
19798
+ return void 0;
19799
+ }
19800
+ rebuildConversation(entries) {
19801
+ const turns = [];
19802
+ let currentAssistantContent = [];
19803
+ let currentToolCalls = [];
19804
+ for (const entry of entries) {
19805
+ const method = entry.notification?.method;
19806
+ const params = entry.notification?.params;
19807
+ if (method === "session/update" && params?.update) {
19808
+ const update = params.update;
19809
+ const sessionUpdate = update.sessionUpdate;
19810
+ switch (sessionUpdate) {
19811
+ case "user_message":
19812
+ case "user_message_chunk": {
19813
+ if (currentAssistantContent.length > 0 || currentToolCalls.length > 0) {
19814
+ turns.push({
19815
+ role: "assistant",
19816
+ content: currentAssistantContent,
19817
+ toolCalls: currentToolCalls.length > 0 ? currentToolCalls : void 0
19818
+ });
19819
+ currentAssistantContent = [];
19820
+ currentToolCalls = [];
19745
19821
  }
19746
- } catch (error) {
19747
- this.log.warn("Failed to rollback checkout", { error });
19822
+ const content = update.content;
19823
+ const contentArray = Array.isArray(content) ? content : [content];
19824
+ turns.push({
19825
+ role: "user",
19826
+ content: contentArray
19827
+ });
19828
+ break;
19748
19829
  }
19749
- }
19750
- });
19751
- }
19752
- if (archivePath) {
19753
- const filesToExtract = changes.filter((c) => c.status !== "D").map((c) => c.path);
19754
- await this.readOnlyStep("backup_existing_files", async () => {
19755
- for (const filePath of filesToExtract) {
19756
- const fullPath = path15.join(baseDir, filePath);
19757
- try {
19758
- const content = await fs12.readFile(fullPath);
19759
- this.fileBackups.set(filePath, content);
19760
- } catch {
19830
+ case "agent_message": {
19831
+ const content = update.content;
19832
+ if (content) {
19833
+ if (content.type === "text" && currentAssistantContent.length > 0 && currentAssistantContent[currentAssistantContent.length - 1].type === "text") {
19834
+ const lastBlock = currentAssistantContent[currentAssistantContent.length - 1];
19835
+ lastBlock.text += content.text;
19836
+ } else {
19837
+ currentAssistantContent.push(content);
19838
+ }
19839
+ }
19840
+ break;
19761
19841
  }
19762
- }
19763
- });
19764
- await this.step({
19765
- name: "extract_archive",
19766
- execute: async () => {
19767
- await tar.extract({
19768
- file: archivePath,
19769
- cwd: baseDir
19770
- });
19771
- this.extractedFiles = filesToExtract;
19772
- },
19773
- rollback: async () => {
19774
- for (const filePath of this.extractedFiles) {
19775
- const fullPath = path15.join(baseDir, filePath);
19776
- const backup = this.fileBackups.get(filePath);
19777
- if (backup) {
19778
- const dir = path15.dirname(fullPath);
19779
- await fs12.mkdir(dir, { recursive: true }).catch(() => {
19780
- });
19781
- await fs12.writeFile(fullPath, backup).catch(() => {
19782
- });
19783
- } else {
19784
- await fs12.rm(fullPath, { force: true }).catch(() => {
19785
- });
19842
+ case "agent_message_chunk": {
19843
+ const content = update.content;
19844
+ if (content) {
19845
+ if (content.type === "text" && currentAssistantContent.length > 0 && currentAssistantContent[currentAssistantContent.length - 1].type === "text") {
19846
+ const lastBlock = currentAssistantContent[currentAssistantContent.length - 1];
19847
+ lastBlock.text += content.text;
19848
+ } else {
19849
+ currentAssistantContent.push(content);
19850
+ }
19786
19851
  }
19852
+ break;
19787
19853
  }
19788
- }
19789
- });
19790
- }
19791
- for (const change of changes.filter((c) => c.status === "D")) {
19792
- const fullPath = path15.join(baseDir, change.path);
19793
- const backupContent = await this.readOnlyStep(`backup_${change.path}`, async () => {
19794
- try {
19795
- return await fs12.readFile(fullPath);
19796
- } catch {
19797
- return null;
19798
- }
19799
- });
19800
- await this.step({
19801
- name: `delete_${change.path}`,
19802
- execute: async () => {
19803
- await fs12.rm(fullPath, { force: true });
19804
- this.log.debug(`Deleted file: ${change.path}`);
19805
- },
19806
- rollback: async () => {
19807
- if (backupContent) {
19808
- const dir = path15.dirname(fullPath);
19809
- await fs12.mkdir(dir, { recursive: true }).catch(() => {
19810
- });
19811
- await fs12.writeFile(fullPath, backupContent).catch(() => {
19812
- });
19854
+ case "tool_call":
19855
+ case "tool_call_update": {
19856
+ const meta = update._meta?.claudeCode;
19857
+ if (meta) {
19858
+ const toolCallId = meta.toolCallId;
19859
+ const toolName = meta.toolName;
19860
+ const toolInput = meta.toolInput;
19861
+ const toolResponse = meta.toolResponse;
19862
+ if (toolCallId && toolName) {
19863
+ let toolCall = currentToolCalls.find(
19864
+ (tc) => tc.toolCallId === toolCallId
19865
+ );
19866
+ if (!toolCall) {
19867
+ toolCall = {
19868
+ toolCallId,
19869
+ toolName,
19870
+ input: toolInput
19871
+ };
19872
+ currentToolCalls.push(toolCall);
19873
+ }
19874
+ if (toolResponse !== void 0) {
19875
+ toolCall.result = toolResponse;
19876
+ }
19877
+ }
19878
+ }
19879
+ break;
19880
+ }
19881
+ case "tool_result": {
19882
+ const meta = update._meta?.claudeCode;
19883
+ if (meta) {
19884
+ const toolCallId = meta.toolCallId;
19885
+ const toolResponse = meta.toolResponse;
19886
+ if (toolCallId) {
19887
+ const toolCall = currentToolCalls.find(
19888
+ (tc) => tc.toolCallId === toolCallId
19889
+ );
19890
+ if (toolCall && toolResponse !== void 0) {
19891
+ toolCall.result = toolResponse;
19892
+ }
19893
+ }
19894
+ }
19895
+ break;
19813
19896
  }
19814
19897
  }
19898
+ }
19899
+ }
19900
+ if (currentAssistantContent.length > 0 || currentToolCalls.length > 0) {
19901
+ turns.push({
19902
+ role: "assistant",
19903
+ content: currentAssistantContent,
19904
+ toolCalls: currentToolCalls.length > 0 ? currentToolCalls : void 0
19815
19905
  });
19816
19906
  }
19817
- const deletedCount = changes.filter((c) => c.status === "D").length;
19818
- this.log.info("Tree applied", {
19819
- treeHash,
19820
- totalChanges: changes.length,
19821
- deletedFiles: deletedCount,
19822
- checkoutPerformed
19823
- });
19824
- return { treeHash, checkoutPerformed };
19907
+ return turns;
19825
19908
  }
19826
19909
  };
19827
19910
 
19828
- // src/sagas/apply-snapshot-saga.ts
19829
- var ApplySnapshotSaga = class extends Saga {
19830
- sagaName = "ApplySnapshotSaga";
19831
- archivePath = null;
19832
- async execute(input) {
19833
- const { snapshot, repositoryPath, apiClient, taskId, runId } = input;
19834
- const tmpDir = join12(repositoryPath, ".posthog", "tmp");
19835
- if (!snapshot.archiveUrl) {
19836
- throw new Error("Cannot apply snapshot: no archive URL");
19837
- }
19838
- const archiveUrl = snapshot.archiveUrl;
19839
- await this.step({
19840
- name: "create_tmp_dir",
19841
- execute: () => mkdir7(tmpDir, { recursive: true }),
19842
- rollback: async () => {
19843
- }
19844
- });
19845
- const archivePath = join12(tmpDir, `${snapshot.treeHash}.tar.gz`);
19846
- this.archivePath = archivePath;
19847
- await this.step({
19848
- name: "download_archive",
19849
- execute: async () => {
19850
- const arrayBuffer = await apiClient.downloadArtifact(
19851
- taskId,
19852
- runId,
19853
- archiveUrl
19854
- );
19855
- if (!arrayBuffer) {
19856
- throw new Error("Failed to download archive");
19857
- }
19858
- const base64Content = Buffer.from(arrayBuffer).toString("utf-8");
19859
- const binaryContent = Buffer.from(base64Content, "base64");
19860
- await writeFile5(archivePath, binaryContent);
19861
- this.log.info("Tree archive downloaded", {
19862
- treeHash: snapshot.treeHash,
19863
- snapshotBytes: binaryContent.byteLength,
19864
- snapshotWireBytes: arrayBuffer.byteLength,
19865
- totalBytes: binaryContent.byteLength,
19866
- totalWireBytes: arrayBuffer.byteLength
19867
- });
19868
- },
19869
- rollback: async () => {
19870
- if (this.archivePath) {
19871
- await rm6(this.archivePath, { force: true }).catch(() => {
19872
- });
19873
- }
19874
- }
19875
- });
19876
- const gitApplySaga = new ApplyTreeSaga(this.log);
19877
- const applyResult = await gitApplySaga.run({
19878
- baseDir: repositoryPath,
19879
- treeHash: snapshot.treeHash,
19880
- baseCommit: snapshot.baseCommit,
19881
- changes: snapshot.changes,
19882
- archivePath: this.archivePath
19911
+ // src/resume.ts
19912
+ async function resumeFromLog(config) {
19913
+ const logger = config.logger || new Logger({ debug: false, prefix: "[Resume]" });
19914
+ logger.info("Resuming from log", {
19915
+ taskId: config.taskId,
19916
+ runId: config.runId
19917
+ });
19918
+ const saga = new ResumeSaga(logger);
19919
+ const result = await saga.run({
19920
+ taskId: config.taskId,
19921
+ runId: config.runId,
19922
+ repositoryPath: config.repositoryPath,
19923
+ apiClient: config.apiClient,
19924
+ logger
19925
+ });
19926
+ if (!result.success) {
19927
+ logger.error("Failed to resume from log", {
19928
+ error: result.error,
19929
+ failedStep: result.failedStep
19883
19930
  });
19884
- if (!applyResult.success) {
19885
- throw new Error(`Failed to apply tree: ${applyResult.error}`);
19931
+ throw new Error(
19932
+ `Failed to resume at step '${result.failedStep}': ${result.error}`
19933
+ );
19934
+ }
19935
+ return {
19936
+ conversation: result.data.conversation,
19937
+ latestSnapshot: result.data.latestSnapshot,
19938
+ latestGitCheckpoint: result.data.latestGitCheckpoint,
19939
+ interrupted: result.data.interrupted,
19940
+ lastDevice: result.data.lastDevice,
19941
+ logEntryCount: result.data.logEntryCount
19942
+ };
19943
+ }
19944
+ var RESUME_HISTORY_TOKEN_BUDGET = 5e4;
19945
+ var TOOL_RESULT_MAX_CHARS = 2e3;
19946
+ var RESUME_CONTEXT_MARKERS = [
19947
+ "You are resuming a previous conversation",
19948
+ "Here is the conversation history from the",
19949
+ "Continue from where you left off"
19950
+ ];
19951
+ function isResumeContextTurn(turn) {
19952
+ if (turn.role !== "user") return false;
19953
+ const text2 = turn.content.filter((b) => b.type === "text").map((b) => b.text).join("");
19954
+ return RESUME_CONTEXT_MARKERS.some((marker) => text2.includes(marker));
19955
+ }
19956
+ function formatConversationForResume(conversation) {
19957
+ const filtered = conversation.filter((turn) => !isResumeContextTurn(turn));
19958
+ const selected = selectRecentTurns(filtered, RESUME_HISTORY_TOKEN_BUDGET);
19959
+ const parts2 = [];
19960
+ if (selected.length < filtered.length) {
19961
+ parts2.push(
19962
+ `*(${filtered.length - selected.length} earlier turns omitted)*`
19963
+ );
19964
+ }
19965
+ for (const turn of selected) {
19966
+ const role = turn.role === "user" ? "User" : "Assistant";
19967
+ const textParts = turn.content.filter((block) => block.type === "text").map((block) => block.text);
19968
+ if (textParts.length > 0) {
19969
+ parts2.push(`**${role}**: ${textParts.join("\n")}`);
19970
+ }
19971
+ if (turn.toolCalls?.length) {
19972
+ const toolSummary = turn.toolCalls.map((tc) => {
19973
+ let resultStr = "";
19974
+ if (tc.result !== void 0) {
19975
+ const raw = typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result);
19976
+ resultStr = raw.length > TOOL_RESULT_MAX_CHARS ? ` \u2192 ${raw.substring(0, TOOL_RESULT_MAX_CHARS)}...(truncated)` : ` \u2192 ${raw}`;
19977
+ }
19978
+ return ` - ${tc.toolName}${resultStr}`;
19979
+ }).join("\n");
19980
+ parts2.push(`**${role} (tools)**:
19981
+ ${toolSummary}`);
19886
19982
  }
19887
- await rm6(this.archivePath, { force: true }).catch(() => {
19888
- });
19889
- this.log.info("Tree snapshot applied", {
19890
- treeHash: snapshot.treeHash,
19891
- totalChanges: snapshot.changes.length,
19892
- deletedFiles: snapshot.changes.filter((c) => c.status === "D").length
19893
- });
19894
- return { treeHash: snapshot.treeHash };
19895
19983
  }
19896
- };
19984
+ return parts2.join("\n\n");
19985
+ }
19897
19986
 
19898
- // src/sagas/capture-tree-saga.ts
19899
- import { existsSync as existsSync6 } from "fs";
19900
- import { readFile as readFile6, rm as rm7 } from "fs/promises";
19901
- import { join as join13 } from "path";
19902
- var CaptureTreeSaga2 = class extends Saga {
19903
- sagaName = "CaptureTreeSaga";
19904
- async execute(input) {
19905
- const {
19906
- repositoryPath,
19907
- lastTreeHash,
19908
- interrupted,
19909
- apiClient,
19910
- taskId,
19911
- runId
19912
- } = input;
19913
- const tmpDir = join13(repositoryPath, ".posthog", "tmp");
19914
- if (existsSync6(join13(repositoryPath, ".gitmodules"))) {
19915
- this.log.warn(
19916
- "Repository has submodules - snapshot may not capture submodule state"
19917
- );
19918
- }
19919
- const shouldArchive = !!apiClient;
19920
- const archivePath = shouldArchive ? join13(tmpDir, `tree-${Date.now()}.tar.gz`) : void 0;
19921
- const gitCaptureSaga = new CaptureTreeSaga(this.log);
19922
- const captureResult = await gitCaptureSaga.run({
19923
- baseDir: repositoryPath,
19924
- lastTreeHash,
19925
- archivePath
19926
- });
19927
- if (!captureResult.success) {
19928
- throw new Error(`Failed to capture tree: ${captureResult.error}`);
19987
+ // src/session-log-writer.ts
19988
+ import fs12 from "fs";
19989
+ import fsp from "fs/promises";
19990
+ import path15 from "path";
19991
+ var SessionLogWriter = class _SessionLogWriter {
19992
+ static FLUSH_DEBOUNCE_MS = 500;
19993
+ static FLUSH_MAX_INTERVAL_MS = 5e3;
19994
+ static MAX_FLUSH_RETRIES = 10;
19995
+ static MAX_RETRY_DELAY_MS = 3e4;
19996
+ static SESSIONS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
19997
+ posthogAPI;
19998
+ pendingEntries = /* @__PURE__ */ new Map();
19999
+ flushTimeouts = /* @__PURE__ */ new Map();
20000
+ lastFlushAttemptTime = /* @__PURE__ */ new Map();
20001
+ retryCounts = /* @__PURE__ */ new Map();
20002
+ sessions = /* @__PURE__ */ new Map();
20003
+ flushQueues = /* @__PURE__ */ new Map();
20004
+ logger;
20005
+ localCachePath;
20006
+ constructor(options = {}) {
20007
+ this.posthogAPI = options.posthogAPI;
20008
+ this.localCachePath = options.localCachePath;
20009
+ this.logger = options.logger ?? new Logger({ debug: false, prefix: "[SessionLogWriter]" });
20010
+ }
20011
+ async flushAll() {
20012
+ const flushPromises = [];
20013
+ for (const [sessionId, session] of this.sessions) {
20014
+ this.emitCoalescedMessage(sessionId, session);
20015
+ flushPromises.push(this.flush(sessionId));
19929
20016
  }
19930
- const {
19931
- snapshot: gitSnapshot,
19932
- archivePath: createdArchivePath,
19933
- changed
19934
- } = captureResult.data;
19935
- if (!changed || !gitSnapshot) {
19936
- this.log.debug("No changes since last capture", { lastTreeHash });
19937
- return { snapshot: null, newTreeHash: lastTreeHash };
20017
+ await Promise.all(flushPromises);
20018
+ }
20019
+ register(sessionId, context) {
20020
+ if (this.sessions.has(sessionId)) {
20021
+ return;
19938
20022
  }
19939
- let archiveUrl;
19940
- if (apiClient && createdArchivePath) {
20023
+ this.sessions.set(sessionId, { context, currentTurnMessages: [] });
20024
+ this.lastFlushAttemptTime.set(sessionId, Date.now());
20025
+ if (this.localCachePath) {
20026
+ const sessionDir = path15.join(
20027
+ this.localCachePath,
20028
+ "sessions",
20029
+ context.runId
20030
+ );
19941
20031
  try {
19942
- archiveUrl = await this.uploadArchive(
19943
- createdArchivePath,
19944
- gitSnapshot.treeHash,
19945
- apiClient,
19946
- taskId,
19947
- runId
19948
- );
19949
- } finally {
19950
- await rm7(createdArchivePath, { force: true }).catch(() => {
20032
+ fs12.mkdirSync(sessionDir, { recursive: true });
20033
+ } catch (error) {
20034
+ this.logger.warn("Failed to create local cache directory", {
20035
+ sessionDir,
20036
+ error
19951
20037
  });
19952
20038
  }
19953
20039
  }
19954
- const snapshot = {
19955
- treeHash: gitSnapshot.treeHash,
19956
- baseCommit: gitSnapshot.baseCommit,
19957
- changes: gitSnapshot.changes,
19958
- timestamp: gitSnapshot.timestamp,
19959
- interrupted,
19960
- archiveUrl
19961
- };
19962
- this.log.info("Tree captured", {
19963
- treeHash: snapshot.treeHash,
19964
- changes: snapshot.changes.length,
19965
- interrupted,
19966
- archiveUrl
19967
- });
19968
- return { snapshot, newTreeHash: snapshot.treeHash };
19969
20040
  }
19970
- async uploadArchive(archivePath, treeHash, apiClient, taskId, runId) {
19971
- const archiveUrl = await this.step({
19972
- name: "upload_archive",
19973
- execute: async () => {
19974
- const archiveContent = await readFile6(archivePath);
19975
- const base64Content = archiveContent.toString("base64");
19976
- const snapshotBytes = archiveContent.byteLength;
19977
- const snapshotWireBytes = Buffer.byteLength(base64Content, "utf-8");
19978
- const artifacts = await apiClient.uploadTaskArtifacts(taskId, runId, [
19979
- {
19980
- name: `trees/${treeHash}.tar.gz`,
19981
- type: "tree_snapshot",
19982
- content: base64Content,
19983
- content_type: "application/gzip"
20041
+ isRegistered(sessionId) {
20042
+ return this.sessions.has(sessionId);
20043
+ }
20044
+ appendRawLine(sessionId, line) {
20045
+ const session = this.sessions.get(sessionId);
20046
+ if (!session) {
20047
+ this.logger.warn("appendRawLine called for unregistered session", {
20048
+ sessionId
20049
+ });
20050
+ return;
20051
+ }
20052
+ try {
20053
+ const message = JSON.parse(line);
20054
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
20055
+ if (this.isAgentMessageChunk(message)) {
20056
+ const text2 = this.extractChunkText(message);
20057
+ if (text2) {
20058
+ if (!session.chunkBuffer) {
20059
+ session.chunkBuffer = { text: text2, firstTimestamp: timestamp };
20060
+ } else {
20061
+ session.chunkBuffer.text += text2;
19984
20062
  }
19985
- ]);
19986
- const uploadedArtifact = artifacts[0];
19987
- if (uploadedArtifact?.storage_path) {
19988
- this.log.info("Tree archive uploaded", {
19989
- storagePath: uploadedArtifact.storage_path,
19990
- treeHash,
19991
- snapshotBytes,
19992
- snapshotWireBytes,
19993
- totalBytes: snapshotBytes,
19994
- totalWireBytes: snapshotWireBytes
19995
- });
19996
- return uploadedArtifact.storage_path;
19997
20063
  }
19998
- return void 0;
19999
- },
20000
- rollback: async () => {
20001
- await rm7(archivePath, { force: true }).catch(() => {
20002
- });
20064
+ return;
20003
20065
  }
20004
- });
20005
- return archiveUrl;
20006
- }
20007
- };
20008
-
20009
- // src/tree-tracker.ts
20010
- var TreeTracker = class {
20011
- repositoryPath;
20012
- taskId;
20013
- runId;
20014
- apiClient;
20015
- logger;
20016
- lastTreeHash = null;
20017
- constructor(config) {
20018
- this.repositoryPath = config.repositoryPath;
20019
- this.taskId = config.taskId;
20020
- this.runId = config.runId;
20021
- this.apiClient = config.apiClient;
20022
- this.logger = config.logger || new Logger({ debug: false, prefix: "[TreeTracker]" });
20023
- }
20024
- /**
20025
- * Capture current working tree state as a snapshot.
20026
- * Uses a temporary index to avoid modifying user's staging area.
20027
- * Uses Saga pattern for atomic operation with automatic cleanup on failure.
20028
- */
20029
- async captureTree(options) {
20030
- const saga = new CaptureTreeSaga2(this.logger);
20031
- const result = await saga.run({
20032
- repositoryPath: this.repositoryPath,
20033
- taskId: this.taskId,
20034
- runId: this.runId,
20035
- apiClient: this.apiClient,
20036
- lastTreeHash: this.lastTreeHash,
20037
- interrupted: options?.interrupted
20038
- });
20039
- if (!result.success) {
20040
- this.logger.error("Failed to capture tree", {
20041
- error: result.error,
20042
- failedStep: result.failedStep
20066
+ if (this.isDirectAgentMessage(message) && session.chunkBuffer) {
20067
+ session.chunkBuffer = void 0;
20068
+ } else {
20069
+ this.emitCoalescedMessage(sessionId, session);
20070
+ }
20071
+ const nonChunkAgentText = this.extractAgentMessageText(message);
20072
+ if (nonChunkAgentText) {
20073
+ session.lastAgentMessage = nonChunkAgentText;
20074
+ session.currentTurnMessages.push(nonChunkAgentText);
20075
+ }
20076
+ const entry = {
20077
+ type: "notification",
20078
+ timestamp,
20079
+ notification: message
20080
+ };
20081
+ this.writeToLocalCache(sessionId, entry);
20082
+ if (this.posthogAPI) {
20083
+ const pending = this.pendingEntries.get(sessionId) ?? [];
20084
+ pending.push(entry);
20085
+ this.pendingEntries.set(sessionId, pending);
20086
+ this.scheduleFlush(sessionId);
20087
+ }
20088
+ } catch {
20089
+ this.logger.warn("Failed to parse raw line for persistence", {
20090
+ taskId: session.context.taskId,
20091
+ runId: session.context.runId,
20092
+ lineLength: line.length
20043
20093
  });
20044
- throw new Error(
20045
- `Failed to capture tree at step '${result.failedStep}': ${result.error}`
20046
- );
20047
20094
  }
20048
- if (result.data.newTreeHash !== null) {
20049
- this.lastTreeHash = result.data.newTreeHash;
20095
+ }
20096
+ async flush(sessionId, { coalesce = false } = {}) {
20097
+ if (coalesce) {
20098
+ const session = this.sessions.get(sessionId);
20099
+ if (session) {
20100
+ this.emitCoalescedMessage(sessionId, session);
20101
+ }
20050
20102
  }
20051
- return result.data.snapshot;
20103
+ const prev = this.flushQueues.get(sessionId) ?? Promise.resolve();
20104
+ const next = prev.catch(() => {
20105
+ }).then(() => this._doFlush(sessionId));
20106
+ this.flushQueues.set(sessionId, next);
20107
+ next.finally(() => {
20108
+ if (this.flushQueues.get(sessionId) === next) {
20109
+ this.flushQueues.delete(sessionId);
20110
+ }
20111
+ });
20112
+ return next;
20052
20113
  }
20053
- /**
20054
- * Download and apply a tree snapshot.
20055
- * Uses Saga pattern for atomic operation with rollback on failure.
20056
- */
20057
- async applyTreeSnapshot(snapshot) {
20058
- if (!this.apiClient) {
20059
- throw new Error("Cannot apply snapshot: API client not configured");
20114
+ async _doFlush(sessionId) {
20115
+ const session = this.sessions.get(sessionId);
20116
+ if (!session) {
20117
+ this.logger.warn("flush: no session found", { sessionId });
20118
+ return;
20060
20119
  }
20061
- if (!snapshot.archiveUrl) {
20062
- this.logger.warn("Cannot apply snapshot: no archive URL", {
20063
- treeHash: snapshot.treeHash,
20064
- changes: snapshot.changes.length
20065
- });
20066
- throw new Error("Cannot apply snapshot: no archive URL");
20120
+ const pending = this.pendingEntries.get(sessionId);
20121
+ if (!this.posthogAPI || !pending?.length) {
20122
+ return;
20067
20123
  }
20068
- const saga = new ApplySnapshotSaga(this.logger);
20069
- const result = await saga.run({
20070
- snapshot,
20071
- repositoryPath: this.repositoryPath,
20072
- apiClient: this.apiClient,
20073
- taskId: this.taskId,
20074
- runId: this.runId
20075
- });
20076
- if (!result.success) {
20077
- this.logger.error("Failed to apply tree snapshot", {
20078
- error: result.error,
20079
- failedStep: result.failedStep,
20080
- treeHash: snapshot.treeHash
20081
- });
20082
- throw new Error(
20083
- `Failed to apply snapshot at step '${result.failedStep}': ${result.error}`
20124
+ this.pendingEntries.delete(sessionId);
20125
+ const timeout = this.flushTimeouts.get(sessionId);
20126
+ if (timeout) {
20127
+ clearTimeout(timeout);
20128
+ this.flushTimeouts.delete(sessionId);
20129
+ }
20130
+ this.lastFlushAttemptTime.set(sessionId, Date.now());
20131
+ try {
20132
+ await this.posthogAPI.appendTaskRunLog(
20133
+ session.context.taskId,
20134
+ session.context.runId,
20135
+ pending
20084
20136
  );
20137
+ this.retryCounts.set(sessionId, 0);
20138
+ } catch (error) {
20139
+ const retryCount = (this.retryCounts.get(sessionId) ?? 0) + 1;
20140
+ this.retryCounts.set(sessionId, retryCount);
20141
+ if (retryCount >= _SessionLogWriter.MAX_FLUSH_RETRIES) {
20142
+ this.logger.error(
20143
+ `Dropping ${pending.length} session log entries after ${retryCount} failed flush attempts`,
20144
+ {
20145
+ taskId: session.context.taskId,
20146
+ runId: session.context.runId,
20147
+ error
20148
+ }
20149
+ );
20150
+ this.retryCounts.set(sessionId, 0);
20151
+ } else {
20152
+ if (retryCount === 1) {
20153
+ this.logger.warn(
20154
+ `Failed to persist session logs, will retry (up to ${_SessionLogWriter.MAX_FLUSH_RETRIES} attempts)`,
20155
+ {
20156
+ taskId: session.context.taskId,
20157
+ runId: session.context.runId,
20158
+ error: error instanceof Error ? error.message : String(error)
20159
+ }
20160
+ );
20161
+ }
20162
+ const currentPending = this.pendingEntries.get(sessionId) ?? [];
20163
+ this.pendingEntries.set(sessionId, [...pending, ...currentPending]);
20164
+ this.scheduleFlush(sessionId);
20165
+ }
20085
20166
  }
20086
- this.lastTreeHash = result.data.treeHash;
20087
20167
  }
20088
- /**
20089
- * Get the last captured tree hash.
20090
- */
20091
- getLastTreeHash() {
20092
- return this.lastTreeHash;
20168
+ getSessionUpdateType(message) {
20169
+ if (message.method !== "session/update") return void 0;
20170
+ const params = message.params;
20171
+ const update = params?.update;
20172
+ return update?.sessionUpdate;
20093
20173
  }
20094
- /**
20095
- * Set the last tree hash (used when resuming).
20096
- */
20097
- setLastTreeHash(hash) {
20098
- this.lastTreeHash = hash;
20174
+ isDirectAgentMessage(message) {
20175
+ return this.getSessionUpdateType(message) === "agent_message";
20099
20176
  }
20100
- };
20101
-
20102
- // src/sagas/resume-saga.ts
20103
- var ResumeSaga = class extends Saga {
20104
- sagaName = "ResumeSaga";
20105
- async execute(input) {
20106
- const { taskId, runId, repositoryPath, apiClient } = input;
20107
- const logger = input.logger || new Logger({ debug: false, prefix: "[Resume]" });
20108
- const taskRun = await this.readOnlyStep(
20109
- "fetch_task_run",
20110
- () => apiClient.getTaskRun(taskId, runId)
20111
- );
20112
- if (!taskRun.log_url) {
20113
- this.log.info("No log URL found, starting fresh");
20114
- return this.emptyResult();
20177
+ isAgentMessageChunk(message) {
20178
+ return this.getSessionUpdateType(message) === "agent_message_chunk";
20179
+ }
20180
+ extractChunkText(message) {
20181
+ const params = message.params;
20182
+ const update = params?.update;
20183
+ const content = update?.content;
20184
+ if (content?.type === "text" && content.text) {
20185
+ return content.text;
20115
20186
  }
20116
- const entries = await this.readOnlyStep(
20117
- "fetch_logs",
20118
- () => apiClient.fetchTaskRunLogs(taskRun)
20119
- );
20120
- if (entries.length === 0) {
20121
- this.log.info("No log entries found, starting fresh");
20122
- return this.emptyResult();
20187
+ return "";
20188
+ }
20189
+ emitCoalescedMessage(sessionId, session) {
20190
+ if (!session.chunkBuffer) return;
20191
+ const { text: text2, firstTimestamp } = session.chunkBuffer;
20192
+ session.chunkBuffer = void 0;
20193
+ session.lastAgentMessage = text2;
20194
+ session.currentTurnMessages.push(text2);
20195
+ const entry = {
20196
+ type: "notification",
20197
+ timestamp: firstTimestamp,
20198
+ notification: {
20199
+ jsonrpc: "2.0",
20200
+ method: "session/update",
20201
+ params: {
20202
+ update: {
20203
+ sessionUpdate: "agent_message",
20204
+ content: { type: "text", text: text2 }
20205
+ }
20206
+ }
20207
+ }
20208
+ };
20209
+ this.writeToLocalCache(sessionId, entry);
20210
+ if (this.posthogAPI) {
20211
+ const pending = this.pendingEntries.get(sessionId) ?? [];
20212
+ pending.push(entry);
20213
+ this.pendingEntries.set(sessionId, pending);
20214
+ this.scheduleFlush(sessionId);
20123
20215
  }
20124
- this.log.info("Fetched log entries", { count: entries.length });
20125
- const latestSnapshot = await this.readOnlyStep(
20126
- "find_snapshot",
20127
- () => Promise.resolve(this.findLatestTreeSnapshot(entries))
20128
- );
20129
- const latestGitCheckpoint = await this.readOnlyStep(
20130
- "find_git_checkpoint",
20131
- () => Promise.resolve(this.findLatestGitCheckpoint(entries))
20132
- );
20133
- let snapshotApplied = false;
20134
- if (latestSnapshot?.archiveUrl && repositoryPath) {
20135
- this.log.info("Found tree snapshot", {
20136
- treeHash: latestSnapshot.treeHash,
20137
- hasArchiveUrl: true,
20138
- changes: latestSnapshot.changes?.length ?? 0,
20139
- interrupted: latestSnapshot.interrupted
20140
- });
20141
- await this.step({
20142
- name: "apply_snapshot",
20143
- execute: async () => {
20144
- const treeTracker = new TreeTracker({
20145
- repositoryPath,
20146
- taskId,
20147
- runId,
20148
- apiClient,
20149
- logger: logger.child("TreeTracker")
20150
- });
20151
- try {
20152
- await treeTracker.applyTreeSnapshot(latestSnapshot);
20153
- treeTracker.setLastTreeHash(latestSnapshot.treeHash);
20154
- snapshotApplied = true;
20155
- this.log.info("Tree snapshot applied successfully", {
20156
- treeHash: latestSnapshot.treeHash
20157
- });
20158
- } catch (error) {
20159
- this.log.warn(
20160
- "Failed to apply tree snapshot, continuing without it",
20161
- {
20162
- error: error instanceof Error ? error.message : String(error),
20163
- treeHash: latestSnapshot.treeHash
20164
- }
20165
- );
20166
- }
20167
- },
20168
- rollback: async () => {
20169
- }
20170
- });
20171
- } else if (latestSnapshot?.archiveUrl && !repositoryPath) {
20172
- this.log.warn(
20173
- "Snapshot found but no repositoryPath configured - files cannot be restored",
20216
+ }
20217
+ getLastAgentMessage(sessionId) {
20218
+ return this.sessions.get(sessionId)?.lastAgentMessage;
20219
+ }
20220
+ getFullAgentResponse(sessionId) {
20221
+ const session = this.sessions.get(sessionId);
20222
+ if (!session || session.currentTurnMessages.length === 0) return void 0;
20223
+ if (session.chunkBuffer) {
20224
+ this.logger.warn(
20225
+ "getFullAgentResponse called with non-empty chunk buffer",
20174
20226
  {
20175
- treeHash: latestSnapshot.treeHash,
20176
- changes: latestSnapshot.changes?.length ?? 0
20227
+ sessionId,
20228
+ bufferedLength: session.chunkBuffer.text.length
20177
20229
  }
20178
20230
  );
20179
- } else if (latestSnapshot) {
20180
- this.log.warn(
20181
- "Snapshot found but has no archive URL - files cannot be restored",
20182
- {
20183
- treeHash: latestSnapshot.treeHash,
20184
- changes: latestSnapshot.changes?.length ?? 0
20185
- }
20231
+ }
20232
+ return session.currentTurnMessages.join("\n\n");
20233
+ }
20234
+ resetTurnMessages(sessionId) {
20235
+ const session = this.sessions.get(sessionId);
20236
+ if (session) {
20237
+ session.currentTurnMessages = [];
20238
+ }
20239
+ }
20240
+ extractAgentMessageText(message) {
20241
+ if (message.method !== "session/update") {
20242
+ return null;
20243
+ }
20244
+ const params = message.params;
20245
+ const update = params?.update;
20246
+ if (update?.sessionUpdate !== "agent_message") {
20247
+ return null;
20248
+ }
20249
+ const content = update.content;
20250
+ if (content?.type === "text" && typeof content.text === "string") {
20251
+ const trimmed2 = content.text.trim();
20252
+ return trimmed2.length > 0 ? trimmed2 : null;
20253
+ }
20254
+ if (typeof update.message === "string") {
20255
+ const trimmed2 = update.message.trim();
20256
+ return trimmed2.length > 0 ? trimmed2 : null;
20257
+ }
20258
+ return null;
20259
+ }
20260
+ scheduleFlush(sessionId) {
20261
+ const existing = this.flushTimeouts.get(sessionId);
20262
+ if (existing) clearTimeout(existing);
20263
+ const retryCount = this.retryCounts.get(sessionId) ?? 0;
20264
+ const lastAttempt = this.lastFlushAttemptTime.get(sessionId) ?? 0;
20265
+ const elapsed = Date.now() - lastAttempt;
20266
+ let delay3;
20267
+ if (retryCount > 0) {
20268
+ delay3 = Math.min(
20269
+ _SessionLogWriter.FLUSH_DEBOUNCE_MS * 2 ** retryCount,
20270
+ _SessionLogWriter.MAX_RETRY_DELAY_MS
20186
20271
  );
20272
+ } else if (elapsed >= _SessionLogWriter.FLUSH_MAX_INTERVAL_MS) {
20273
+ delay3 = 0;
20274
+ } else {
20275
+ delay3 = _SessionLogWriter.FLUSH_DEBOUNCE_MS;
20187
20276
  }
20188
- const conversation = await this.readOnlyStep(
20189
- "rebuild_conversation",
20190
- () => Promise.resolve(this.rebuildConversation(entries))
20191
- );
20192
- const lastDevice = await this.readOnlyStep(
20193
- "find_device",
20194
- () => Promise.resolve(this.findLastDeviceInfo(entries))
20195
- );
20196
- this.log.info("Resume state rebuilt", {
20197
- turns: conversation.length,
20198
- hasSnapshot: !!latestSnapshot,
20199
- snapshotApplied,
20200
- interrupted: latestSnapshot?.interrupted ?? false
20201
- });
20202
- return {
20203
- conversation,
20204
- latestSnapshot,
20205
- latestGitCheckpoint,
20206
- snapshotApplied,
20207
- interrupted: latestSnapshot?.interrupted ?? false,
20208
- lastDevice,
20209
- logEntryCount: entries.length
20210
- };
20277
+ const timeout = setTimeout(() => this.flush(sessionId), delay3);
20278
+ this.flushTimeouts.set(sessionId, timeout);
20211
20279
  }
20212
- emptyResult() {
20213
- return {
20214
- conversation: [],
20215
- latestSnapshot: null,
20216
- latestGitCheckpoint: null,
20217
- snapshotApplied: false,
20218
- interrupted: false,
20219
- logEntryCount: 0
20220
- };
20280
+ writeToLocalCache(sessionId, entry) {
20281
+ if (!this.localCachePath) return;
20282
+ const session = this.sessions.get(sessionId);
20283
+ if (!session) return;
20284
+ const logPath = path15.join(
20285
+ this.localCachePath,
20286
+ "sessions",
20287
+ session.context.runId,
20288
+ "logs.ndjson"
20289
+ );
20290
+ try {
20291
+ fs12.appendFileSync(logPath, `${JSON.stringify(entry)}
20292
+ `);
20293
+ } catch (error) {
20294
+ this.logger.warn("Failed to write to local cache", {
20295
+ taskId: session.context.taskId,
20296
+ runId: session.context.runId,
20297
+ logPath,
20298
+ error
20299
+ });
20300
+ }
20221
20301
  }
20222
- findLatestTreeSnapshot(entries) {
20223
- for (let i2 = entries.length - 1; i2 >= 0; i2--) {
20224
- const entry = entries[i2];
20225
- if (isNotification(
20226
- entry.notification?.method,
20227
- POSTHOG_NOTIFICATIONS.TREE_SNAPSHOT
20228
- )) {
20229
- const params = entry.notification.params;
20230
- if (params?.treeHash) {
20231
- return params;
20302
+ static async cleanupOldSessions(localCachePath) {
20303
+ const sessionsDir = path15.join(localCachePath, "sessions");
20304
+ let deleted = 0;
20305
+ try {
20306
+ const entries = await fsp.readdir(sessionsDir);
20307
+ const now = Date.now();
20308
+ for (const entry of entries) {
20309
+ const entryPath = path15.join(sessionsDir, entry);
20310
+ try {
20311
+ const stats = await fsp.stat(entryPath);
20312
+ if (stats.isDirectory() && now - stats.birthtimeMs > _SessionLogWriter.SESSIONS_MAX_AGE_MS) {
20313
+ await fsp.rm(entryPath, { recursive: true, force: true });
20314
+ deleted++;
20315
+ }
20316
+ } catch {
20232
20317
  }
20233
20318
  }
20319
+ } catch {
20234
20320
  }
20235
- return null;
20321
+ return deleted;
20236
20322
  }
20237
- findLatestGitCheckpoint(entries) {
20238
- const sdkPrefixedMethod = `_${POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT}`;
20239
- for (let i2 = entries.length - 1; i2 >= 0; i2--) {
20240
- const entry = entries[i2];
20241
- const method = entry.notification?.method;
20242
- if (method === sdkPrefixedMethod || method === POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT) {
20243
- const params = entry.notification?.params;
20244
- if (params?.checkpointId && params?.checkpointRef) {
20245
- return params;
20323
+ };
20324
+
20325
+ // src/sagas/apply-snapshot-saga.ts
20326
+ import { mkdir as mkdir7, rm as rm6, writeFile as writeFile5 } from "fs/promises";
20327
+ import { join as join12 } from "path";
20328
+
20329
+ // ../git/dist/sagas/tree.js
20330
+ import { existsSync as existsSync5 } from "fs";
20331
+ import * as fs13 from "fs/promises";
20332
+ import * as path16 from "path";
20333
+ import * as tar from "tar";
20334
+ var CaptureTreeSaga = class extends GitSaga {
20335
+ sagaName = "CaptureTreeSaga";
20336
+ tempIndexPath = null;
20337
+ async executeGitOperations(input) {
20338
+ const { baseDir, lastTreeHash, archivePath, signal } = input;
20339
+ const tmpDir = path16.join(baseDir, ".git", "posthog-code-tmp");
20340
+ await this.step({
20341
+ name: "create_tmp_dir",
20342
+ execute: () => fs13.mkdir(tmpDir, { recursive: true }),
20343
+ rollback: async () => {
20344
+ }
20345
+ });
20346
+ this.tempIndexPath = path16.join(tmpDir, `index-${Date.now()}`);
20347
+ const tempIndexGit = this.git.env({
20348
+ ...process.env,
20349
+ GIT_INDEX_FILE: this.tempIndexPath
20350
+ });
20351
+ await this.step({
20352
+ name: "init_temp_index",
20353
+ execute: () => tempIndexGit.raw(["read-tree", "HEAD"]),
20354
+ rollback: async () => {
20355
+ if (this.tempIndexPath) {
20356
+ await fs13.rm(this.tempIndexPath, { force: true }).catch(() => {
20357
+ });
20246
20358
  }
20247
20359
  }
20360
+ });
20361
+ await this.readOnlyStep("stage_files", () => tempIndexGit.raw(["add", "-A"]));
20362
+ const treeHash = await this.readOnlyStep("write_tree", () => tempIndexGit.raw(["write-tree"]));
20363
+ if (lastTreeHash && treeHash === lastTreeHash) {
20364
+ this.log.debug("No changes since last capture", { treeHash });
20365
+ await fs13.rm(this.tempIndexPath, { force: true }).catch(() => {
20366
+ });
20367
+ return { snapshot: null, changed: false };
20248
20368
  }
20249
- return null;
20369
+ const baseCommit = await this.readOnlyStep("get_base_commit", async () => {
20370
+ try {
20371
+ return await getHeadSha(baseDir, { abortSignal: signal });
20372
+ } catch {
20373
+ return null;
20374
+ }
20375
+ });
20376
+ const changes = await this.readOnlyStep("get_changes", () => this.getChanges(this.git, baseCommit, treeHash));
20377
+ await fs13.rm(this.tempIndexPath, { force: true }).catch(() => {
20378
+ });
20379
+ const snapshot = {
20380
+ treeHash,
20381
+ baseCommit,
20382
+ changes,
20383
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
20384
+ };
20385
+ let createdArchivePath;
20386
+ if (archivePath) {
20387
+ createdArchivePath = await this.createArchive(baseDir, archivePath, changes);
20388
+ }
20389
+ this.log.info("Tree captured", {
20390
+ treeHash,
20391
+ changes: changes.length,
20392
+ archived: !!createdArchivePath
20393
+ });
20394
+ return { snapshot, archivePath: createdArchivePath, changed: true };
20250
20395
  }
20251
- findLastDeviceInfo(entries) {
20252
- for (let i2 = entries.length - 1; i2 >= 0; i2--) {
20253
- const entry = entries[i2];
20254
- const params = entry.notification?.params;
20255
- if (params?.device) {
20256
- return params.device;
20396
+ async createArchive(baseDir, archivePath, changes) {
20397
+ const filesToArchive = changes.filter((c) => c.status !== "D").map((c) => c.path);
20398
+ if (filesToArchive.length === 0) {
20399
+ return void 0;
20400
+ }
20401
+ const existingFiles = filesToArchive.filter((f) => existsSync5(path16.join(baseDir, f)));
20402
+ if (existingFiles.length === 0) {
20403
+ return void 0;
20404
+ }
20405
+ await this.step({
20406
+ name: "create_archive",
20407
+ execute: async () => {
20408
+ const archiveDir = path16.dirname(archivePath);
20409
+ await fs13.mkdir(archiveDir, { recursive: true });
20410
+ await tar.create({
20411
+ gzip: true,
20412
+ file: archivePath,
20413
+ cwd: baseDir
20414
+ }, existingFiles);
20415
+ },
20416
+ rollback: async () => {
20417
+ await fs13.rm(archivePath, { force: true }).catch(() => {
20418
+ });
20257
20419
  }
20420
+ });
20421
+ return archivePath;
20422
+ }
20423
+ async getChanges(git, fromRef, toRef) {
20424
+ if (!fromRef) {
20425
+ const stdout2 = await git.raw(["ls-tree", "-r", "--name-only", toRef]);
20426
+ return stdout2.split("\n").filter((p) => p.trim()).map((p) => ({ path: p, status: "A" }));
20258
20427
  }
20259
- return void 0;
20428
+ const stdout = await git.raw([
20429
+ "diff-tree",
20430
+ "-r",
20431
+ "--name-status",
20432
+ fromRef,
20433
+ toRef
20434
+ ]);
20435
+ const changes = [];
20436
+ for (const line of stdout.split("\n")) {
20437
+ if (!line.trim())
20438
+ continue;
20439
+ const [status, filePath] = line.split(" ");
20440
+ if (!filePath)
20441
+ continue;
20442
+ let normalizedStatus;
20443
+ if (status === "D") {
20444
+ normalizedStatus = "D";
20445
+ } else if (status === "A") {
20446
+ normalizedStatus = "A";
20447
+ } else {
20448
+ normalizedStatus = "M";
20449
+ }
20450
+ changes.push({ path: filePath, status: normalizedStatus });
20451
+ }
20452
+ return changes;
20260
20453
  }
20261
- rebuildConversation(entries) {
20262
- const turns = [];
20263
- let currentAssistantContent = [];
20264
- let currentToolCalls = [];
20265
- for (const entry of entries) {
20266
- const method = entry.notification?.method;
20267
- const params = entry.notification?.params;
20268
- if (method === "session/update" && params?.update) {
20269
- const update = params.update;
20270
- const sessionUpdate = update.sessionUpdate;
20271
- switch (sessionUpdate) {
20272
- case "user_message":
20273
- case "user_message_chunk": {
20274
- if (currentAssistantContent.length > 0 || currentToolCalls.length > 0) {
20275
- turns.push({
20276
- role: "assistant",
20277
- content: currentAssistantContent,
20278
- toolCalls: currentToolCalls.length > 0 ? currentToolCalls : void 0
20279
- });
20280
- currentAssistantContent = [];
20281
- currentToolCalls = [];
20282
- }
20283
- const content = update.content;
20284
- const contentArray = Array.isArray(content) ? content : [content];
20285
- turns.push({
20286
- role: "user",
20287
- content: contentArray
20288
- });
20289
- break;
20290
- }
20291
- case "agent_message": {
20292
- const content = update.content;
20293
- if (content) {
20294
- if (content.type === "text" && currentAssistantContent.length > 0 && currentAssistantContent[currentAssistantContent.length - 1].type === "text") {
20295
- const lastBlock = currentAssistantContent[currentAssistantContent.length - 1];
20296
- lastBlock.text += content.text;
20297
- } else {
20298
- currentAssistantContent.push(content);
20299
- }
20300
- }
20301
- break;
20302
- }
20303
- case "agent_message_chunk": {
20304
- const content = update.content;
20305
- if (content) {
20306
- if (content.type === "text" && currentAssistantContent.length > 0 && currentAssistantContent[currentAssistantContent.length - 1].type === "text") {
20307
- const lastBlock = currentAssistantContent[currentAssistantContent.length - 1];
20308
- lastBlock.text += content.text;
20309
- } else {
20310
- currentAssistantContent.push(content);
20311
- }
20312
- }
20313
- break;
20314
- }
20315
- case "tool_call":
20316
- case "tool_call_update": {
20317
- const meta = update._meta?.claudeCode;
20318
- if (meta) {
20319
- const toolCallId = meta.toolCallId;
20320
- const toolName = meta.toolName;
20321
- const toolInput = meta.toolInput;
20322
- const toolResponse = meta.toolResponse;
20323
- if (toolCallId && toolName) {
20324
- let toolCall = currentToolCalls.find(
20325
- (tc) => tc.toolCallId === toolCallId
20326
- );
20327
- if (!toolCall) {
20328
- toolCall = {
20329
- toolCallId,
20330
- toolName,
20331
- input: toolInput
20332
- };
20333
- currentToolCalls.push(toolCall);
20334
- }
20335
- if (toolResponse !== void 0) {
20336
- toolCall.result = toolResponse;
20337
- }
20338
- }
20454
+ };
20455
+ var ApplyTreeSaga = class extends GitSaga {
20456
+ sagaName = "ApplyTreeSaga";
20457
+ originalHead = null;
20458
+ originalBranch = null;
20459
+ extractedFiles = [];
20460
+ fileBackups = /* @__PURE__ */ new Map();
20461
+ async executeGitOperations(input) {
20462
+ const { baseDir, treeHash, baseCommit, changes, archivePath } = input;
20463
+ const headInfo = await this.readOnlyStep("get_current_head", async () => {
20464
+ let head = null;
20465
+ let branch = null;
20466
+ try {
20467
+ head = await this.git.revparse(["HEAD"]);
20468
+ } catch {
20469
+ head = null;
20470
+ }
20471
+ try {
20472
+ branch = await this.git.raw(["symbolic-ref", "--short", "HEAD"]);
20473
+ } catch {
20474
+ branch = null;
20475
+ }
20476
+ return { head, branch };
20477
+ });
20478
+ this.originalHead = headInfo.head;
20479
+ this.originalBranch = headInfo.branch;
20480
+ let checkoutPerformed = false;
20481
+ if (baseCommit && baseCommit !== this.originalHead) {
20482
+ await this.readOnlyStep("check_working_tree", async () => {
20483
+ const status = await this.git.status();
20484
+ if (!status.isClean()) {
20485
+ const changedFiles = status.modified.length + status.staged.length + status.deleted.length;
20486
+ throw new Error(`Cannot apply tree: ${changedFiles} uncommitted change(s) exist. Commit or stash your changes first.`);
20487
+ }
20488
+ });
20489
+ await this.step({
20490
+ name: "checkout_base",
20491
+ execute: async () => {
20492
+ await this.git.checkout(baseCommit);
20493
+ checkoutPerformed = true;
20494
+ this.log.warn("Applied tree from different commit - now in detached HEAD state", {
20495
+ originalHead: this.originalHead,
20496
+ originalBranch: this.originalBranch,
20497
+ baseCommit
20498
+ });
20499
+ },
20500
+ rollback: async () => {
20501
+ try {
20502
+ if (this.originalBranch) {
20503
+ await this.git.checkout(this.originalBranch);
20504
+ } else if (this.originalHead) {
20505
+ await this.git.checkout(this.originalHead);
20339
20506
  }
20340
- break;
20507
+ } catch (error) {
20508
+ this.log.warn("Failed to rollback checkout", { error });
20341
20509
  }
20342
- case "tool_result": {
20343
- const meta = update._meta?.claudeCode;
20344
- if (meta) {
20345
- const toolCallId = meta.toolCallId;
20346
- const toolResponse = meta.toolResponse;
20347
- if (toolCallId) {
20348
- const toolCall = currentToolCalls.find(
20349
- (tc) => tc.toolCallId === toolCallId
20350
- );
20351
- if (toolCall && toolResponse !== void 0) {
20352
- toolCall.result = toolResponse;
20353
- }
20354
- }
20510
+ }
20511
+ });
20512
+ }
20513
+ if (archivePath) {
20514
+ const filesToExtract = changes.filter((c) => c.status !== "D").map((c) => c.path);
20515
+ await this.readOnlyStep("backup_existing_files", async () => {
20516
+ for (const filePath of filesToExtract) {
20517
+ const fullPath = path16.join(baseDir, filePath);
20518
+ try {
20519
+ const content = await fs13.readFile(fullPath);
20520
+ this.fileBackups.set(filePath, content);
20521
+ } catch {
20522
+ }
20523
+ }
20524
+ });
20525
+ await this.step({
20526
+ name: "extract_archive",
20527
+ execute: async () => {
20528
+ await tar.extract({
20529
+ file: archivePath,
20530
+ cwd: baseDir
20531
+ });
20532
+ this.extractedFiles = filesToExtract;
20533
+ },
20534
+ rollback: async () => {
20535
+ for (const filePath of this.extractedFiles) {
20536
+ const fullPath = path16.join(baseDir, filePath);
20537
+ const backup = this.fileBackups.get(filePath);
20538
+ if (backup) {
20539
+ const dir = path16.dirname(fullPath);
20540
+ await fs13.mkdir(dir, { recursive: true }).catch(() => {
20541
+ });
20542
+ await fs13.writeFile(fullPath, backup).catch(() => {
20543
+ });
20544
+ } else {
20545
+ await fs13.rm(fullPath, { force: true }).catch(() => {
20546
+ });
20355
20547
  }
20356
- break;
20357
20548
  }
20358
20549
  }
20359
- }
20550
+ });
20360
20551
  }
20361
- if (currentAssistantContent.length > 0 || currentToolCalls.length > 0) {
20362
- turns.push({
20363
- role: "assistant",
20364
- content: currentAssistantContent,
20365
- toolCalls: currentToolCalls.length > 0 ? currentToolCalls : void 0
20552
+ for (const change of changes.filter((c) => c.status === "D")) {
20553
+ const fullPath = path16.join(baseDir, change.path);
20554
+ const backupContent = await this.readOnlyStep(`backup_${change.path}`, async () => {
20555
+ try {
20556
+ return await fs13.readFile(fullPath);
20557
+ } catch {
20558
+ return null;
20559
+ }
20560
+ });
20561
+ await this.step({
20562
+ name: `delete_${change.path}`,
20563
+ execute: async () => {
20564
+ await fs13.rm(fullPath, { force: true });
20565
+ this.log.debug(`Deleted file: ${change.path}`);
20566
+ },
20567
+ rollback: async () => {
20568
+ if (backupContent) {
20569
+ const dir = path16.dirname(fullPath);
20570
+ await fs13.mkdir(dir, { recursive: true }).catch(() => {
20571
+ });
20572
+ await fs13.writeFile(fullPath, backupContent).catch(() => {
20573
+ });
20574
+ }
20575
+ }
20366
20576
  });
20367
20577
  }
20368
- return turns;
20578
+ const deletedCount = changes.filter((c) => c.status === "D").length;
20579
+ this.log.info("Tree applied", {
20580
+ treeHash,
20581
+ totalChanges: changes.length,
20582
+ deletedFiles: deletedCount,
20583
+ checkoutPerformed
20584
+ });
20585
+ return { treeHash, checkoutPerformed };
20369
20586
  }
20370
20587
  };
20371
20588
 
20372
- // src/resume.ts
20373
- async function resumeFromLog(config) {
20374
- const logger = config.logger || new Logger({ debug: false, prefix: "[Resume]" });
20375
- logger.info("Resuming from log", {
20376
- taskId: config.taskId,
20377
- runId: config.runId
20378
- });
20379
- const saga = new ResumeSaga(logger);
20380
- const result = await saga.run({
20381
- taskId: config.taskId,
20382
- runId: config.runId,
20383
- repositoryPath: config.repositoryPath,
20384
- apiClient: config.apiClient,
20385
- logger
20386
- });
20387
- if (!result.success) {
20388
- logger.error("Failed to resume from log", {
20389
- error: result.error,
20390
- failedStep: result.failedStep
20391
- });
20392
- throw new Error(
20393
- `Failed to resume at step '${result.failedStep}': ${result.error}`
20394
- );
20395
- }
20396
- return {
20397
- conversation: result.data.conversation,
20398
- latestSnapshot: result.data.latestSnapshot,
20399
- latestGitCheckpoint: result.data.latestGitCheckpoint,
20400
- snapshotApplied: result.data.snapshotApplied,
20401
- interrupted: result.data.interrupted,
20402
- lastDevice: result.data.lastDevice,
20403
- logEntryCount: result.data.logEntryCount
20404
- };
20405
- }
20406
- var RESUME_HISTORY_TOKEN_BUDGET = 5e4;
20407
- var TOOL_RESULT_MAX_CHARS = 2e3;
20408
- function formatConversationForResume(conversation) {
20409
- const selected = selectRecentTurns(conversation, RESUME_HISTORY_TOKEN_BUDGET);
20410
- const parts2 = [];
20411
- if (selected.length < conversation.length) {
20412
- parts2.push(
20413
- `*(${conversation.length - selected.length} earlier turns omitted)*`
20414
- );
20415
- }
20416
- for (const turn of selected) {
20417
- const role = turn.role === "user" ? "User" : "Assistant";
20418
- const textParts = turn.content.filter((block) => block.type === "text").map((block) => block.text);
20419
- if (textParts.length > 0) {
20420
- parts2.push(`**${role}**: ${textParts.join("\n")}`);
20589
+ // src/sagas/apply-snapshot-saga.ts
20590
+ var ApplySnapshotSaga = class extends Saga {
20591
+ sagaName = "ApplySnapshotSaga";
20592
+ archivePath = null;
20593
+ async execute(input) {
20594
+ const { snapshot, repositoryPath, apiClient, taskId, runId } = input;
20595
+ const tmpDir = join12(repositoryPath, ".posthog", "tmp");
20596
+ if (!snapshot.archiveUrl) {
20597
+ throw new Error("Cannot apply snapshot: no archive URL");
20421
20598
  }
20422
- if (turn.toolCalls?.length) {
20423
- const toolSummary = turn.toolCalls.map((tc) => {
20424
- let resultStr = "";
20425
- if (tc.result !== void 0) {
20426
- const raw = typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result);
20427
- resultStr = raw.length > TOOL_RESULT_MAX_CHARS ? ` \u2192 ${raw.substring(0, TOOL_RESULT_MAX_CHARS)}...(truncated)` : ` \u2192 ${raw}`;
20599
+ const archiveUrl = snapshot.archiveUrl;
20600
+ await this.step({
20601
+ name: "create_tmp_dir",
20602
+ execute: () => mkdir7(tmpDir, { recursive: true }),
20603
+ rollback: async () => {
20604
+ }
20605
+ });
20606
+ const archivePath = join12(tmpDir, `${snapshot.treeHash}.tar.gz`);
20607
+ this.archivePath = archivePath;
20608
+ await this.step({
20609
+ name: "download_archive",
20610
+ execute: async () => {
20611
+ const arrayBuffer = await apiClient.downloadArtifact(
20612
+ taskId,
20613
+ runId,
20614
+ archiveUrl
20615
+ );
20616
+ if (!arrayBuffer) {
20617
+ throw new Error("Failed to download archive");
20428
20618
  }
20429
- return ` - ${tc.toolName}${resultStr}`;
20430
- }).join("\n");
20431
- parts2.push(`**${role} (tools)**:
20432
- ${toolSummary}`);
20433
- }
20434
- }
20435
- return parts2.join("\n\n");
20436
- }
20437
-
20438
- // src/session-log-writer.ts
20439
- import fs13 from "fs";
20440
- import fsp from "fs/promises";
20441
- import path16 from "path";
20442
- var SessionLogWriter = class _SessionLogWriter {
20443
- static FLUSH_DEBOUNCE_MS = 500;
20444
- static FLUSH_MAX_INTERVAL_MS = 5e3;
20445
- static MAX_FLUSH_RETRIES = 10;
20446
- static MAX_RETRY_DELAY_MS = 3e4;
20447
- static SESSIONS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
20448
- posthogAPI;
20449
- pendingEntries = /* @__PURE__ */ new Map();
20450
- flushTimeouts = /* @__PURE__ */ new Map();
20451
- lastFlushAttemptTime = /* @__PURE__ */ new Map();
20452
- retryCounts = /* @__PURE__ */ new Map();
20453
- sessions = /* @__PURE__ */ new Map();
20454
- flushQueues = /* @__PURE__ */ new Map();
20455
- logger;
20456
- localCachePath;
20457
- constructor(options = {}) {
20458
- this.posthogAPI = options.posthogAPI;
20459
- this.localCachePath = options.localCachePath;
20460
- this.logger = options.logger ?? new Logger({ debug: false, prefix: "[SessionLogWriter]" });
20461
- }
20462
- async flushAll() {
20463
- const flushPromises = [];
20464
- for (const [sessionId, session] of this.sessions) {
20465
- this.emitCoalescedMessage(sessionId, session);
20466
- flushPromises.push(this.flush(sessionId));
20467
- }
20468
- await Promise.all(flushPromises);
20469
- }
20470
- register(sessionId, context) {
20471
- if (this.sessions.has(sessionId)) {
20472
- return;
20473
- }
20474
- this.sessions.set(sessionId, { context, currentTurnMessages: [] });
20475
- this.lastFlushAttemptTime.set(sessionId, Date.now());
20476
- if (this.localCachePath) {
20477
- const sessionDir = path16.join(
20478
- this.localCachePath,
20479
- "sessions",
20480
- context.runId
20481
- );
20482
- try {
20483
- fs13.mkdirSync(sessionDir, { recursive: true });
20484
- } catch (error) {
20485
- this.logger.warn("Failed to create local cache directory", {
20486
- sessionDir,
20487
- error
20619
+ const base64Content = Buffer.from(arrayBuffer).toString("utf-8");
20620
+ const binaryContent = Buffer.from(base64Content, "base64");
20621
+ await writeFile5(archivePath, binaryContent);
20622
+ this.log.info("Tree archive downloaded", {
20623
+ treeHash: snapshot.treeHash,
20624
+ snapshotBytes: binaryContent.byteLength,
20625
+ snapshotWireBytes: arrayBuffer.byteLength,
20626
+ totalBytes: binaryContent.byteLength,
20627
+ totalWireBytes: arrayBuffer.byteLength
20488
20628
  });
20489
- }
20490
- }
20491
- }
20492
- isRegistered(sessionId) {
20493
- return this.sessions.has(sessionId);
20494
- }
20495
- appendRawLine(sessionId, line) {
20496
- const session = this.sessions.get(sessionId);
20497
- if (!session) {
20498
- this.logger.warn("appendRawLine called for unregistered session", {
20499
- sessionId
20500
- });
20501
- return;
20502
- }
20503
- try {
20504
- const message = JSON.parse(line);
20505
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
20506
- if (this.isAgentMessageChunk(message)) {
20507
- const text2 = this.extractChunkText(message);
20508
- if (text2) {
20509
- if (!session.chunkBuffer) {
20510
- session.chunkBuffer = { text: text2, firstTimestamp: timestamp };
20511
- } else {
20512
- session.chunkBuffer.text += text2;
20513
- }
20629
+ },
20630
+ rollback: async () => {
20631
+ if (this.archivePath) {
20632
+ await rm6(this.archivePath, { force: true }).catch(() => {
20633
+ });
20514
20634
  }
20515
- return;
20516
- }
20517
- if (this.isDirectAgentMessage(message) && session.chunkBuffer) {
20518
- session.chunkBuffer = void 0;
20519
- } else {
20520
- this.emitCoalescedMessage(sessionId, session);
20521
- }
20522
- const nonChunkAgentText = this.extractAgentMessageText(message);
20523
- if (nonChunkAgentText) {
20524
- session.lastAgentMessage = nonChunkAgentText;
20525
- session.currentTurnMessages.push(nonChunkAgentText);
20526
- }
20527
- const entry = {
20528
- type: "notification",
20529
- timestamp,
20530
- notification: message
20531
- };
20532
- this.writeToLocalCache(sessionId, entry);
20533
- if (this.posthogAPI) {
20534
- const pending = this.pendingEntries.get(sessionId) ?? [];
20535
- pending.push(entry);
20536
- this.pendingEntries.set(sessionId, pending);
20537
- this.scheduleFlush(sessionId);
20538
- }
20539
- } catch {
20540
- this.logger.warn("Failed to parse raw line for persistence", {
20541
- taskId: session.context.taskId,
20542
- runId: session.context.runId,
20543
- lineLength: line.length
20544
- });
20545
- }
20546
- }
20547
- async flush(sessionId, { coalesce = false } = {}) {
20548
- if (coalesce) {
20549
- const session = this.sessions.get(sessionId);
20550
- if (session) {
20551
- this.emitCoalescedMessage(sessionId, session);
20552
20635
  }
20636
+ });
20637
+ const gitApplySaga = new ApplyTreeSaga(this.log);
20638
+ const applyResult = await gitApplySaga.run({
20639
+ baseDir: repositoryPath,
20640
+ treeHash: snapshot.treeHash,
20641
+ baseCommit: snapshot.baseCommit,
20642
+ changes: snapshot.changes,
20643
+ archivePath: this.archivePath
20644
+ });
20645
+ if (!applyResult.success) {
20646
+ throw new Error(`Failed to apply tree: ${applyResult.error}`);
20553
20647
  }
20554
- const prev = this.flushQueues.get(sessionId) ?? Promise.resolve();
20555
- const next = prev.catch(() => {
20556
- }).then(() => this._doFlush(sessionId));
20557
- this.flushQueues.set(sessionId, next);
20558
- next.finally(() => {
20559
- if (this.flushQueues.get(sessionId) === next) {
20560
- this.flushQueues.delete(sessionId);
20561
- }
20648
+ await rm6(this.archivePath, { force: true }).catch(() => {
20562
20649
  });
20563
- return next;
20650
+ this.log.info("Tree snapshot applied", {
20651
+ treeHash: snapshot.treeHash,
20652
+ totalChanges: snapshot.changes.length,
20653
+ deletedFiles: snapshot.changes.filter((c) => c.status === "D").length
20654
+ });
20655
+ return { treeHash: snapshot.treeHash };
20564
20656
  }
20565
- async _doFlush(sessionId) {
20566
- const session = this.sessions.get(sessionId);
20567
- if (!session) {
20568
- this.logger.warn("flush: no session found", { sessionId });
20569
- return;
20657
+ };
20658
+
20659
+ // src/sagas/capture-tree-saga.ts
20660
+ import { existsSync as existsSync6 } from "fs";
20661
+ import { readFile as readFile6, rm as rm7 } from "fs/promises";
20662
+ import { join as join13 } from "path";
20663
+ var CaptureTreeSaga2 = class extends Saga {
20664
+ sagaName = "CaptureTreeSaga";
20665
+ async execute(input) {
20666
+ const {
20667
+ repositoryPath,
20668
+ lastTreeHash,
20669
+ interrupted,
20670
+ apiClient,
20671
+ taskId,
20672
+ runId
20673
+ } = input;
20674
+ const tmpDir = join13(repositoryPath, ".posthog", "tmp");
20675
+ if (existsSync6(join13(repositoryPath, ".gitmodules"))) {
20676
+ this.log.warn(
20677
+ "Repository has submodules - snapshot may not capture submodule state"
20678
+ );
20570
20679
  }
20571
- const pending = this.pendingEntries.get(sessionId);
20572
- if (!this.posthogAPI || !pending?.length) {
20573
- return;
20680
+ const shouldArchive = !!apiClient;
20681
+ const archivePath = shouldArchive ? join13(tmpDir, `tree-${Date.now()}.tar.gz`) : void 0;
20682
+ const gitCaptureSaga = new CaptureTreeSaga(this.log);
20683
+ const captureResult = await gitCaptureSaga.run({
20684
+ baseDir: repositoryPath,
20685
+ lastTreeHash,
20686
+ archivePath
20687
+ });
20688
+ if (!captureResult.success) {
20689
+ throw new Error(`Failed to capture tree: ${captureResult.error}`);
20574
20690
  }
20575
- this.pendingEntries.delete(sessionId);
20576
- const timeout = this.flushTimeouts.get(sessionId);
20577
- if (timeout) {
20578
- clearTimeout(timeout);
20579
- this.flushTimeouts.delete(sessionId);
20691
+ const {
20692
+ snapshot: gitSnapshot,
20693
+ archivePath: createdArchivePath,
20694
+ changed
20695
+ } = captureResult.data;
20696
+ if (!changed || !gitSnapshot) {
20697
+ this.log.debug("No changes since last capture", { lastTreeHash });
20698
+ return { snapshot: null, newTreeHash: lastTreeHash };
20580
20699
  }
20581
- this.lastFlushAttemptTime.set(sessionId, Date.now());
20582
- try {
20583
- await this.posthogAPI.appendTaskRunLog(
20584
- session.context.taskId,
20585
- session.context.runId,
20586
- pending
20587
- );
20588
- this.retryCounts.set(sessionId, 0);
20589
- } catch (error) {
20590
- const retryCount = (this.retryCounts.get(sessionId) ?? 0) + 1;
20591
- this.retryCounts.set(sessionId, retryCount);
20592
- if (retryCount >= _SessionLogWriter.MAX_FLUSH_RETRIES) {
20593
- this.logger.error(
20594
- `Dropping ${pending.length} session log entries after ${retryCount} failed flush attempts`,
20595
- {
20596
- taskId: session.context.taskId,
20597
- runId: session.context.runId,
20598
- error
20599
- }
20700
+ let archiveUrl;
20701
+ if (apiClient && createdArchivePath) {
20702
+ try {
20703
+ archiveUrl = await this.uploadArchive(
20704
+ createdArchivePath,
20705
+ gitSnapshot.treeHash,
20706
+ apiClient,
20707
+ taskId,
20708
+ runId
20600
20709
  );
20601
- this.retryCounts.set(sessionId, 0);
20602
- } else {
20603
- if (retryCount === 1) {
20604
- this.logger.warn(
20605
- `Failed to persist session logs, will retry (up to ${_SessionLogWriter.MAX_FLUSH_RETRIES} attempts)`,
20606
- {
20607
- taskId: session.context.taskId,
20608
- runId: session.context.runId,
20609
- error: error instanceof Error ? error.message : String(error)
20610
- }
20611
- );
20612
- }
20613
- const currentPending = this.pendingEntries.get(sessionId) ?? [];
20614
- this.pendingEntries.set(sessionId, [...pending, ...currentPending]);
20615
- this.scheduleFlush(sessionId);
20710
+ } finally {
20711
+ await rm7(createdArchivePath, { force: true }).catch(() => {
20712
+ });
20616
20713
  }
20617
20714
  }
20715
+ const snapshot = {
20716
+ treeHash: gitSnapshot.treeHash,
20717
+ baseCommit: gitSnapshot.baseCommit,
20718
+ changes: gitSnapshot.changes,
20719
+ timestamp: gitSnapshot.timestamp,
20720
+ interrupted,
20721
+ archiveUrl
20722
+ };
20723
+ this.log.info("Tree captured", {
20724
+ treeHash: snapshot.treeHash,
20725
+ changes: snapshot.changes.length,
20726
+ interrupted,
20727
+ archiveUrl
20728
+ });
20729
+ return { snapshot, newTreeHash: snapshot.treeHash };
20618
20730
  }
20619
- getSessionUpdateType(message) {
20620
- if (message.method !== "session/update") return void 0;
20621
- const params = message.params;
20622
- const update = params?.update;
20623
- return update?.sessionUpdate;
20624
- }
20625
- isDirectAgentMessage(message) {
20626
- return this.getSessionUpdateType(message) === "agent_message";
20627
- }
20628
- isAgentMessageChunk(message) {
20629
- return this.getSessionUpdateType(message) === "agent_message_chunk";
20630
- }
20631
- extractChunkText(message) {
20632
- const params = message.params;
20633
- const update = params?.update;
20634
- const content = update?.content;
20635
- if (content?.type === "text" && content.text) {
20636
- return content.text;
20637
- }
20638
- return "";
20639
- }
20640
- emitCoalescedMessage(sessionId, session) {
20641
- if (!session.chunkBuffer) return;
20642
- const { text: text2, firstTimestamp } = session.chunkBuffer;
20643
- session.chunkBuffer = void 0;
20644
- session.lastAgentMessage = text2;
20645
- session.currentTurnMessages.push(text2);
20646
- const entry = {
20647
- type: "notification",
20648
- timestamp: firstTimestamp,
20649
- notification: {
20650
- jsonrpc: "2.0",
20651
- method: "session/update",
20652
- params: {
20653
- update: {
20654
- sessionUpdate: "agent_message",
20655
- content: { type: "text", text: text2 }
20731
+ async uploadArchive(archivePath, treeHash, apiClient, taskId, runId) {
20732
+ const archiveUrl = await this.step({
20733
+ name: "upload_archive",
20734
+ execute: async () => {
20735
+ const archiveContent = await readFile6(archivePath);
20736
+ const base64Content = archiveContent.toString("base64");
20737
+ const snapshotBytes = archiveContent.byteLength;
20738
+ const snapshotWireBytes = Buffer.byteLength(base64Content, "utf-8");
20739
+ const artifacts = await apiClient.uploadTaskArtifacts(taskId, runId, [
20740
+ {
20741
+ name: `trees/${treeHash}.tar.gz`,
20742
+ type: "tree_snapshot",
20743
+ content: base64Content,
20744
+ content_type: "application/gzip"
20656
20745
  }
20746
+ ]);
20747
+ const uploadedArtifact = artifacts[0];
20748
+ if (uploadedArtifact?.storage_path) {
20749
+ this.log.info("Tree archive uploaded", {
20750
+ storagePath: uploadedArtifact.storage_path,
20751
+ treeHash,
20752
+ snapshotBytes,
20753
+ snapshotWireBytes,
20754
+ totalBytes: snapshotBytes,
20755
+ totalWireBytes: snapshotWireBytes
20756
+ });
20757
+ return uploadedArtifact.storage_path;
20657
20758
  }
20759
+ return void 0;
20760
+ },
20761
+ rollback: async () => {
20762
+ await rm7(archivePath, { force: true }).catch(() => {
20763
+ });
20658
20764
  }
20659
- };
20660
- this.writeToLocalCache(sessionId, entry);
20661
- if (this.posthogAPI) {
20662
- const pending = this.pendingEntries.get(sessionId) ?? [];
20663
- pending.push(entry);
20664
- this.pendingEntries.set(sessionId, pending);
20665
- this.scheduleFlush(sessionId);
20666
- }
20765
+ });
20766
+ return archiveUrl;
20667
20767
  }
20668
- getLastAgentMessage(sessionId) {
20669
- return this.sessions.get(sessionId)?.lastAgentMessage;
20768
+ };
20769
+
20770
+ // src/tree-tracker.ts
20771
+ var TreeTracker = class {
20772
+ repositoryPath;
20773
+ taskId;
20774
+ runId;
20775
+ apiClient;
20776
+ logger;
20777
+ lastTreeHash = null;
20778
+ constructor(config) {
20779
+ this.repositoryPath = config.repositoryPath;
20780
+ this.taskId = config.taskId;
20781
+ this.runId = config.runId;
20782
+ this.apiClient = config.apiClient;
20783
+ this.logger = config.logger || new Logger({ debug: false, prefix: "[TreeTracker]" });
20670
20784
  }
20671
- getFullAgentResponse(sessionId) {
20672
- const session = this.sessions.get(sessionId);
20673
- if (!session || session.currentTurnMessages.length === 0) return void 0;
20674
- if (session.chunkBuffer) {
20675
- this.logger.warn(
20676
- "getFullAgentResponse called with non-empty chunk buffer",
20677
- {
20678
- sessionId,
20679
- bufferedLength: session.chunkBuffer.text.length
20680
- }
20785
+ /**
20786
+ * Capture current working tree state as a snapshot.
20787
+ * Uses a temporary index to avoid modifying user's staging area.
20788
+ * Uses Saga pattern for atomic operation with automatic cleanup on failure.
20789
+ */
20790
+ async captureTree(options) {
20791
+ const saga = new CaptureTreeSaga2(this.logger);
20792
+ const result = await saga.run({
20793
+ repositoryPath: this.repositoryPath,
20794
+ taskId: this.taskId,
20795
+ runId: this.runId,
20796
+ apiClient: this.apiClient,
20797
+ lastTreeHash: this.lastTreeHash,
20798
+ interrupted: options?.interrupted
20799
+ });
20800
+ if (!result.success) {
20801
+ this.logger.error("Failed to capture tree", {
20802
+ error: result.error,
20803
+ failedStep: result.failedStep
20804
+ });
20805
+ throw new Error(
20806
+ `Failed to capture tree at step '${result.failedStep}': ${result.error}`
20681
20807
  );
20682
20808
  }
20683
- return session.currentTurnMessages.join("\n\n");
20684
- }
20685
- resetTurnMessages(sessionId) {
20686
- const session = this.sessions.get(sessionId);
20687
- if (session) {
20688
- session.currentTurnMessages = [];
20809
+ if (result.data.newTreeHash !== null) {
20810
+ this.lastTreeHash = result.data.newTreeHash;
20689
20811
  }
20812
+ return result.data.snapshot;
20690
20813
  }
20691
- extractAgentMessageText(message) {
20692
- if (message.method !== "session/update") {
20693
- return null;
20694
- }
20695
- const params = message.params;
20696
- const update = params?.update;
20697
- if (update?.sessionUpdate !== "agent_message") {
20698
- return null;
20699
- }
20700
- const content = update.content;
20701
- if (content?.type === "text" && typeof content.text === "string") {
20702
- const trimmed2 = content.text.trim();
20703
- return trimmed2.length > 0 ? trimmed2 : null;
20814
+ /**
20815
+ * Download and apply a tree snapshot.
20816
+ * Uses Saga pattern for atomic operation with rollback on failure.
20817
+ */
20818
+ async applyTreeSnapshot(snapshot) {
20819
+ if (!this.apiClient) {
20820
+ throw new Error("Cannot apply snapshot: API client not configured");
20704
20821
  }
20705
- if (typeof update.message === "string") {
20706
- const trimmed2 = update.message.trim();
20707
- return trimmed2.length > 0 ? trimmed2 : null;
20822
+ if (!snapshot.archiveUrl) {
20823
+ this.logger.warn("Cannot apply snapshot: no archive URL", {
20824
+ treeHash: snapshot.treeHash,
20825
+ changes: snapshot.changes.length
20826
+ });
20827
+ throw new Error("Cannot apply snapshot: no archive URL");
20708
20828
  }
20709
- return null;
20710
- }
20711
- scheduleFlush(sessionId) {
20712
- const existing = this.flushTimeouts.get(sessionId);
20713
- if (existing) clearTimeout(existing);
20714
- const retryCount = this.retryCounts.get(sessionId) ?? 0;
20715
- const lastAttempt = this.lastFlushAttemptTime.get(sessionId) ?? 0;
20716
- const elapsed = Date.now() - lastAttempt;
20717
- let delay3;
20718
- if (retryCount > 0) {
20719
- delay3 = Math.min(
20720
- _SessionLogWriter.FLUSH_DEBOUNCE_MS * 2 ** retryCount,
20721
- _SessionLogWriter.MAX_RETRY_DELAY_MS
20829
+ const saga = new ApplySnapshotSaga(this.logger);
20830
+ const result = await saga.run({
20831
+ snapshot,
20832
+ repositoryPath: this.repositoryPath,
20833
+ apiClient: this.apiClient,
20834
+ taskId: this.taskId,
20835
+ runId: this.runId
20836
+ });
20837
+ if (!result.success) {
20838
+ this.logger.error("Failed to apply tree snapshot", {
20839
+ error: result.error,
20840
+ failedStep: result.failedStep,
20841
+ treeHash: snapshot.treeHash
20842
+ });
20843
+ throw new Error(
20844
+ `Failed to apply snapshot at step '${result.failedStep}': ${result.error}`
20722
20845
  );
20723
- } else if (elapsed >= _SessionLogWriter.FLUSH_MAX_INTERVAL_MS) {
20724
- delay3 = 0;
20725
- } else {
20726
- delay3 = _SessionLogWriter.FLUSH_DEBOUNCE_MS;
20727
20846
  }
20728
- const timeout = setTimeout(() => this.flush(sessionId), delay3);
20729
- this.flushTimeouts.set(sessionId, timeout);
20847
+ this.lastTreeHash = result.data.treeHash;
20730
20848
  }
20731
- writeToLocalCache(sessionId, entry) {
20732
- if (!this.localCachePath) return;
20733
- const session = this.sessions.get(sessionId);
20734
- if (!session) return;
20735
- const logPath = path16.join(
20736
- this.localCachePath,
20737
- "sessions",
20738
- session.context.runId,
20739
- "logs.ndjson"
20740
- );
20741
- try {
20742
- fs13.appendFileSync(logPath, `${JSON.stringify(entry)}
20743
- `);
20744
- } catch (error) {
20745
- this.logger.warn("Failed to write to local cache", {
20746
- taskId: session.context.taskId,
20747
- runId: session.context.runId,
20748
- logPath,
20749
- error
20750
- });
20751
- }
20849
+ /**
20850
+ * Get the last captured tree hash.
20851
+ */
20852
+ getLastTreeHash() {
20853
+ return this.lastTreeHash;
20752
20854
  }
20753
- static async cleanupOldSessions(localCachePath) {
20754
- const sessionsDir = path16.join(localCachePath, "sessions");
20755
- let deleted = 0;
20756
- try {
20757
- const entries = await fsp.readdir(sessionsDir);
20758
- const now = Date.now();
20759
- for (const entry of entries) {
20760
- const entryPath = path16.join(sessionsDir, entry);
20761
- try {
20762
- const stats = await fsp.stat(entryPath);
20763
- if (stats.isDirectory() && now - stats.birthtimeMs > _SessionLogWriter.SESSIONS_MAX_AGE_MS) {
20764
- await fsp.rm(entryPath, { recursive: true, force: true });
20765
- deleted++;
20766
- }
20767
- } catch {
20768
- }
20769
- }
20770
- } catch {
20771
- }
20772
- return deleted;
20855
+ /**
20856
+ * Set the last tree hash (used when resuming).
20857
+ */
20858
+ setLastTreeHash(hash) {
20859
+ this.lastTreeHash = hash;
20773
20860
  }
20774
20861
  };
20775
20862
 
@@ -21245,45 +21332,29 @@ var AgentServer = class {
21245
21332
  });
21246
21333
  await this.autoInitializeSession();
21247
21334
  }
21248
- async autoInitializeSession() {
21249
- const { taskId, runId, mode, projectId } = this.config;
21250
- this.logger.debug("Auto-initializing session", { taskId, runId, mode });
21251
- const resumeRunId = process.env.POSTHOG_RESUME_RUN_ID;
21252
- if (resumeRunId) {
21253
- this.logger.debug("Resuming from previous run", {
21254
- resumeRunId,
21255
- currentRunId: runId
21335
+ async loadResumeState(taskId, resumeRunId, currentRunId) {
21336
+ this.logger.debug("Loading resume state", { resumeRunId, currentRunId });
21337
+ try {
21338
+ this.resumeState = await resumeFromLog({
21339
+ taskId,
21340
+ runId: resumeRunId,
21341
+ repositoryPath: this.config.repositoryPath,
21342
+ apiClient: this.posthogAPI,
21343
+ logger: new Logger({ debug: true, prefix: "[Resume]" })
21256
21344
  });
21257
- try {
21258
- this.resumeState = await resumeFromLog({
21259
- taskId,
21260
- runId: resumeRunId,
21261
- repositoryPath: this.config.repositoryPath,
21262
- apiClient: this.posthogAPI,
21263
- logger: new Logger({ debug: true, prefix: "[Resume]" })
21264
- });
21265
- this.logger.debug("Resume state loaded", {
21266
- conversationTurns: this.resumeState.conversation.length,
21267
- snapshotApplied: this.resumeState.snapshotApplied,
21268
- logEntries: this.resumeState.logEntryCount
21269
- });
21270
- } catch (error) {
21271
- this.logger.debug("Failed to load resume state, starting fresh", {
21272
- error
21273
- });
21274
- this.resumeState = null;
21275
- }
21345
+ this.logger.debug("Resume state loaded", {
21346
+ conversationTurns: this.resumeState.conversation.length,
21347
+ hasSnapshot: !!this.resumeState.latestSnapshot,
21348
+ hasGitCheckpoint: !!this.resumeState.latestGitCheckpoint,
21349
+ gitCheckpointBranch: this.resumeState.latestGitCheckpoint?.branch ?? null,
21350
+ logEntries: this.resumeState.logEntryCount
21351
+ });
21352
+ } catch (error) {
21353
+ this.logger.debug("Failed to load resume state, starting fresh", {
21354
+ error
21355
+ });
21356
+ this.resumeState = null;
21276
21357
  }
21277
- const payload = {
21278
- task_id: taskId,
21279
- run_id: runId,
21280
- team_id: projectId,
21281
- user_id: 0,
21282
- // System-initiated
21283
- distinct_id: "agent-server",
21284
- mode
21285
- };
21286
- await this.initializeSession(payload, null);
21287
21358
  }
21288
21359
  async stop() {
21289
21360
  this.logger.debug("Stopping agent server...");
@@ -21682,29 +21753,11 @@ var AgentServer = class {
21682
21753
  if (!this.resumeState) {
21683
21754
  const resumeRunId = this.getResumeRunId(taskRun);
21684
21755
  if (resumeRunId) {
21685
- this.logger.debug("Resuming from previous run (via TaskRun state)", {
21756
+ await this.loadResumeState(
21757
+ payload.task_id,
21686
21758
  resumeRunId,
21687
- currentRunId: payload.run_id
21688
- });
21689
- try {
21690
- this.resumeState = await resumeFromLog({
21691
- taskId: payload.task_id,
21692
- runId: resumeRunId,
21693
- repositoryPath: this.config.repositoryPath,
21694
- apiClient: this.posthogAPI,
21695
- logger: new Logger({ debug: true, prefix: "[Resume]" })
21696
- });
21697
- this.logger.debug("Resume state loaded (via TaskRun state)", {
21698
- conversationTurns: this.resumeState.conversation.length,
21699
- snapshotApplied: this.resumeState.snapshotApplied,
21700
- logEntries: this.resumeState.logEntryCount
21701
- });
21702
- } catch (error) {
21703
- this.logger.debug("Failed to load resume state, starting fresh", {
21704
- error
21705
- });
21706
- this.resumeState = null;
21707
- }
21759
+ payload.run_id
21760
+ );
21708
21761
  }
21709
21762
  }
21710
21763
  if (this.resumeState && this.resumeState.conversation.length > 0) {
@@ -21763,8 +21816,59 @@ var AgentServer = class {
21763
21816
  const conversationSummary = formatConversationForResume(
21764
21817
  this.resumeState.conversation
21765
21818
  );
21819
+ let snapshotApplied = false;
21820
+ if (this.resumeState.latestSnapshot?.archiveUrl && this.config.repositoryPath && this.posthogAPI) {
21821
+ try {
21822
+ const treeTracker = new TreeTracker({
21823
+ repositoryPath: this.config.repositoryPath,
21824
+ taskId: payload.task_id,
21825
+ runId: payload.run_id,
21826
+ apiClient: this.posthogAPI,
21827
+ logger: this.logger.child("TreeTracker")
21828
+ });
21829
+ await treeTracker.applyTreeSnapshot(this.resumeState.latestSnapshot);
21830
+ treeTracker.setLastTreeHash(this.resumeState.latestSnapshot.treeHash);
21831
+ snapshotApplied = true;
21832
+ this.logger.info("Tree snapshot applied", {
21833
+ treeHash: this.resumeState.latestSnapshot.treeHash,
21834
+ changes: this.resumeState.latestSnapshot.changes?.length ?? 0,
21835
+ hasArchiveUrl: !!this.resumeState.latestSnapshot.archiveUrl
21836
+ });
21837
+ } catch (error) {
21838
+ this.logger.warn("Failed to apply tree snapshot", {
21839
+ error: error instanceof Error ? error.message : String(error),
21840
+ treeHash: this.resumeState.latestSnapshot.treeHash
21841
+ });
21842
+ }
21843
+ }
21844
+ if (this.resumeState.latestGitCheckpoint && this.config.repositoryPath && this.posthogAPI) {
21845
+ try {
21846
+ const checkpointTracker = new HandoffCheckpointTracker({
21847
+ repositoryPath: this.config.repositoryPath,
21848
+ taskId: payload.task_id,
21849
+ runId: payload.run_id,
21850
+ apiClient: this.posthogAPI,
21851
+ logger: this.logger.child("HandoffCheckpoint")
21852
+ });
21853
+ const metrics = await checkpointTracker.applyFromHandoff(
21854
+ this.resumeState.latestGitCheckpoint
21855
+ );
21856
+ this.logger.info("Git checkpoint applied", {
21857
+ branch: this.resumeState.latestGitCheckpoint.branch,
21858
+ head: this.resumeState.latestGitCheckpoint.head,
21859
+ packBytes: metrics.packBytes,
21860
+ indexBytes: metrics.indexBytes,
21861
+ totalBytes: metrics.totalBytes
21862
+ });
21863
+ } catch (error) {
21864
+ this.logger.warn("Failed to apply git checkpoint", {
21865
+ error: error instanceof Error ? error.message : String(error),
21866
+ branch: this.resumeState.latestGitCheckpoint.branch
21867
+ });
21868
+ }
21869
+ }
21766
21870
  const pendingUserPrompt = await this.getPendingUserPrompt(taskRun);
21767
- const sandboxContext = this.resumeState.snapshotApplied ? `The workspace environment (all files, packages, and code changes) has been fully restored from where you left off.` : `The workspace files from the previous session were not restored (the file snapshot may have expired), so you are starting with a fresh environment. Your conversation history is fully preserved below.`;
21871
+ const sandboxContext = snapshotApplied ? `The workspace environment (all files, packages, and code changes) has been fully restored from where you left off.` : `The workspace files from the previous session were not restored (the file snapshot may have expired), so you are starting with a fresh environment. Your conversation history is fully preserved below.`;
21768
21872
  let resumePromptBlocks;
21769
21873
  if (pendingUserPrompt?.length) {
21770
21874
  resumePromptBlocks = [
@@ -21805,7 +21909,9 @@ Continue from where you left off. The user is waiting for your response.`
21805
21909
  conversationTurns: this.resumeState.conversation.length,
21806
21910
  promptLength: promptBlocksToText(resumePromptBlocks).length,
21807
21911
  hasPendingUserMessage: !!pendingUserPrompt?.length,
21808
- snapshotApplied: this.resumeState.snapshotApplied
21912
+ snapshotApplied,
21913
+ hasGitCheckpoint: !!this.resumeState.latestGitCheckpoint,
21914
+ gitCheckpointBranch: this.resumeState.latestGitCheckpoint?.branch ?? null
21809
21915
  });
21810
21916
  this.resumeState = null;
21811
21917
  this.session.logWriter.resetTurnMessages(payload.run_id);
@@ -21969,6 +22075,24 @@ Continue from where you left off. The user is waiting for your response.`
21969
22075
  const normalizedName = baseName.replace(/[^\w.-]/g, "_");
21970
22076
  return normalizedName.length > 0 ? normalizedName : "attachment";
21971
22077
  }
22078
+ async autoInitializeSession() {
22079
+ const { taskId, runId, mode, projectId } = this.config;
22080
+ this.logger.debug("Auto-initializing session", { taskId, runId, mode });
22081
+ const resumeRunId = process.env.POSTHOG_RESUME_RUN_ID;
22082
+ if (resumeRunId) {
22083
+ await this.loadResumeState(taskId, resumeRunId, runId);
22084
+ }
22085
+ const payload = {
22086
+ task_id: taskId,
22087
+ run_id: runId,
22088
+ team_id: projectId,
22089
+ user_id: 0,
22090
+ // System-initiated
22091
+ distinct_id: "agent-server",
22092
+ mode
22093
+ };
22094
+ await this.initializeSession(payload, null);
22095
+ }
21972
22096
  getResumeRunId(taskRun) {
21973
22097
  const envRunId = process.env.POSTHOG_RESUME_RUN_ID;
21974
22098
  if (envRunId) return envRunId;