@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
@@ -8729,7 +8729,7 @@ var import_zod3 = require("zod");
8729
8729
  // package.json
8730
8730
  var package_default = {
8731
8731
  name: "@posthog/agent",
8732
- version: "2.3.387",
8732
+ version: "2.3.398",
8733
8733
  repository: "https://github.com/PostHog/code",
8734
8734
  description: "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
8735
8735
  exports: {
@@ -8781,6 +8781,10 @@ var package_default = {
8781
8781
  types: "./dist/adapters/reasoning-effort.d.ts",
8782
8782
  import: "./dist/adapters/reasoning-effort.js"
8783
8783
  },
8784
+ "./adapters/claude/mcp/tool-metadata": {
8785
+ types: "./dist/adapters/claude/mcp/tool-metadata.d.ts",
8786
+ import: "./dist/adapters/claude/mcp/tool-metadata.js"
8787
+ },
8784
8788
  "./execution-mode": {
8785
8789
  types: "./dist/execution-mode.d.ts",
8786
8790
  import: "./dist/execution-mode.js"
@@ -13776,10 +13780,12 @@ async function fetchMcpToolMetadata(q, logger = new Logger({ debug: false, prefi
13776
13780
  for (const tool of server.tools) {
13777
13781
  const toolKey = buildToolKey(server.name, tool.name);
13778
13782
  const readOnly = tool.annotations?.readOnly === true;
13783
+ const existing = mcpToolMetadataCache.get(toolKey);
13779
13784
  mcpToolMetadataCache.set(toolKey, {
13780
13785
  readOnly,
13781
13786
  name: tool.name,
13782
- description: tool.description
13787
+ description: tool.description,
13788
+ approvalState: existing?.approvalState
13783
13789
  });
13784
13790
  if (readOnly) readOnlyCount++;
13785
13791
  }
@@ -13821,6 +13827,23 @@ function getConnectedMcpServerNames() {
13821
13827
  }
13822
13828
  return [...names];
13823
13829
  }
13830
+ function getMcpToolApprovalState(toolName) {
13831
+ return mcpToolMetadataCache.get(toolName)?.approvalState;
13832
+ }
13833
+ function setMcpToolApprovalStates(approvals) {
13834
+ for (const [toolKey, approvalState] of Object.entries(approvals)) {
13835
+ const existing = mcpToolMetadataCache.get(toolKey);
13836
+ if (existing) {
13837
+ existing.approvalState = approvalState;
13838
+ } else {
13839
+ mcpToolMetadataCache.set(toolKey, {
13840
+ readOnly: false,
13841
+ name: toolKey,
13842
+ approvalState
13843
+ });
13844
+ }
13845
+ }
13846
+ }
13824
13847
 
13825
13848
  // src/adapters/claude/conversion/tool-use-to-acp.ts
13826
13849
  var SYSTEM_REMINDER_REGEX = /\s*<system-reminder>[\s\S]*?<\/system-reminder>/g;
@@ -15516,6 +15539,72 @@ async function handleDefaultPermissionFlow(context) {
15516
15539
  return { behavior: "deny", message, interrupt: !feedback };
15517
15540
  }
15518
15541
  }
15542
+ function parseMcpToolName(toolName) {
15543
+ const parts2 = toolName.split("__");
15544
+ return {
15545
+ serverName: parts2[1] ?? toolName,
15546
+ tool: parts2.slice(2).join("__") || toolName
15547
+ };
15548
+ }
15549
+ async function handleMcpApprovalFlow(context) {
15550
+ const { toolName, toolInput, toolUseID, client, sessionId } = context;
15551
+ const { serverName, tool: displayTool } = parseMcpToolName(toolName);
15552
+ const metadata2 = getMcpToolMetadata(toolName);
15553
+ const description = metadata2?.description ? `
15554
+
15555
+ ${metadata2.description}` : "";
15556
+ const response = await client.requestPermission({
15557
+ options: [
15558
+ { kind: "allow_once", name: "Yes", optionId: "allow" },
15559
+ {
15560
+ kind: "allow_always",
15561
+ name: "Yes, always allow",
15562
+ optionId: "allow_always"
15563
+ },
15564
+ {
15565
+ kind: "reject_once",
15566
+ name: "Type here to tell the agent what to do differently",
15567
+ optionId: "reject",
15568
+ _meta: { customInput: true }
15569
+ }
15570
+ ],
15571
+ sessionId,
15572
+ toolCall: {
15573
+ toolCallId: toolUseID,
15574
+ title: `The agent wants to call ${displayTool} (${serverName})`,
15575
+ kind: "other",
15576
+ content: description ? [{ type: "content", content: text(description) }] : [],
15577
+ rawInput: { ...toolInput, toolName }
15578
+ }
15579
+ });
15580
+ if (context.signal?.aborted || response.outcome?.outcome === "cancelled") {
15581
+ throw new Error("Tool use aborted");
15582
+ }
15583
+ if (response.outcome?.outcome === "selected" && (response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")) {
15584
+ if (response.outcome.optionId === "allow_always") {
15585
+ return {
15586
+ behavior: "allow",
15587
+ updatedInput: toolInput,
15588
+ updatedPermissions: [
15589
+ {
15590
+ type: "addRules",
15591
+ rules: [{ toolName }],
15592
+ behavior: "allow",
15593
+ destination: "localSettings"
15594
+ }
15595
+ ]
15596
+ };
15597
+ }
15598
+ return {
15599
+ behavior: "allow",
15600
+ updatedInput: toolInput
15601
+ };
15602
+ }
15603
+ const feedback = response._meta?.customInput?.trim();
15604
+ const message = feedback ? `User refused permission to run tool with feedback: ${feedback}` : "User refused permission to run tool";
15605
+ await emitToolDenial(context, message);
15606
+ return { behavior: "deny", message, interrupt: !feedback };
15607
+ }
15519
15608
  function handlePlanFileException(context) {
15520
15609
  const { session, toolName, toolInput } = context;
15521
15610
  if (session.permissionMode !== "plan" || !WRITE_TOOLS.has(toolName)) {
@@ -15586,6 +15675,17 @@ async function canUseTool(context) {
15586
15675
  }
15587
15676
  }
15588
15677
  }
15678
+ if (toolName.startsWith("mcp__")) {
15679
+ const approvalState = getMcpToolApprovalState(toolName);
15680
+ if (approvalState === "do_not_use") {
15681
+ const message = "This tool has been blocked. To re-enable it, go to Settings > MCP Servers in PostHog Code.";
15682
+ await emitToolDenial(context, message);
15683
+ return { behavior: "deny", message, interrupt: false };
15684
+ }
15685
+ if (approvalState === "needs_approval") {
15686
+ return handleMcpApprovalFlow(context);
15687
+ }
15688
+ }
15589
15689
  if (isToolAllowedForMode(toolName, session.permissionMode)) {
15590
15690
  return {
15591
15691
  behavior: "allow",
@@ -15691,7 +15791,14 @@ Only enter plan mode (EnterPlanMode) when the user is requesting a significant c
15691
15791
 
15692
15792
  When in doubt, continue executing and incorporate the feedback inline.
15693
15793
  `;
15694
- var APPENDED_INSTRUCTIONS = BRANCH_NAMING + PLAN_MODE;
15794
+ var MCP_TOOLS = `
15795
+ # MCP Tool Access
15796
+
15797
+ 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."
15798
+
15799
+ 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.
15800
+ `;
15801
+ var APPENDED_INSTRUCTIONS = BRANCH_NAMING + PLAN_MODE + MCP_TOOLS;
15695
15802
 
15696
15803
  // src/adapters/claude/session/options.ts
15697
15804
  function buildSystemPrompt(customPrompt) {
@@ -17020,6 +17127,9 @@ var ClaudeAcpAgent = class extends BaseAcpAgent {
17020
17127
  const earlyModelId = settingsManager.getSettings().model || meta?.model || "";
17021
17128
  const mcpServers = supportsMcpInjection(earlyModelId) ? parseMcpServers(params) : {};
17022
17129
  const systemPrompt = buildSystemPrompt(meta?.systemPrompt);
17130
+ if (meta?.mcpToolApprovals) {
17131
+ setMcpToolApprovalStates(meta.mcpToolApprovals);
17132
+ }
17023
17133
  const outputFormat = meta?.jsonSchema && this.options?.onStructuredOutput ? { type: "json_schema", schema: meta.jsonSchema } : void 0;
17024
17134
  this.logger.debug(isResume ? "Resuming session" : "Creating new session", {
17025
17135
  sessionId,
@@ -19165,6 +19275,11 @@ var HandoffCheckpointTracker = class {
19165
19275
  onDivergedBranch: options?.onDivergedBranch
19166
19276
  });
19167
19277
  this.logApplyMetrics(checkpoint, downloads, applyResult.totalBytes);
19278
+ return {
19279
+ packBytes: downloads.pack?.rawBytes ?? 0,
19280
+ indexBytes: downloads.index?.rawBytes ?? 0,
19281
+ totalBytes: applyResult.totalBytes
19282
+ };
19168
19283
  } finally {
19169
19284
  await this.removeIfPresent(packPath);
19170
19285
  await this.removeIfPresent(indexPath);
@@ -19211,22 +19326,22 @@ var HandoffCheckpointTracker = class {
19211
19326
  };
19212
19327
  }
19213
19328
  async uploadArtifacts(specs) {
19214
- const uploads = await Promise.all(
19215
- specs.map(async (spec) => {
19216
- if (!spec.filePath) {
19217
- return [spec.key, void 0];
19218
- }
19219
- return [
19220
- spec.key,
19221
- await this.uploadArtifactFile(
19222
- spec.filePath,
19223
- spec.name,
19224
- spec.contentType
19225
- )
19226
- ];
19227
- })
19228
- );
19229
- return Object.fromEntries(uploads);
19329
+ const results = [];
19330
+ for (const spec of specs) {
19331
+ if (!spec.filePath) {
19332
+ results.push([spec.key, void 0]);
19333
+ continue;
19334
+ }
19335
+ results.push([
19336
+ spec.key,
19337
+ await this.uploadArtifactFile(
19338
+ spec.filePath,
19339
+ spec.name,
19340
+ spec.contentType
19341
+ )
19342
+ ]);
19343
+ }
19344
+ return Object.fromEntries(results);
19230
19345
  }
19231
19346
  async downloadArtifactToFile(artifactPath, filePath, label) {
19232
19347
  if (!this.apiClient) {
@@ -19238,7 +19353,7 @@ var HandoffCheckpointTracker = class {
19238
19353
  artifactPath
19239
19354
  );
19240
19355
  if (!arrayBuffer) {
19241
- throw new Error(`Failed to download ${label}`);
19356
+ throw new Error(`Failed to download ${label} from ${artifactPath}`);
19242
19357
  }
19243
19358
  const base64Content = Buffer.from(arrayBuffer).toString("utf-8");
19244
19359
  const binaryContent = Buffer.from(base64Content, "base64");
@@ -19422,6 +19537,13 @@ var PostHogAPIClient = class {
19422
19537
  `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/`
19423
19538
  );
19424
19539
  }
19540
+ async resumeRunInCloud(taskId, runId) {
19541
+ const teamId = this.getTeamId();
19542
+ return this.apiRequest(
19543
+ `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/resume_in_cloud/`,
19544
+ { method: "POST" }
19545
+ );
19546
+ }
19425
19547
  async updateTaskRun(taskId, runId, payload) {
19426
19548
  const teamId = this.getTeamId();
19427
19549
  return this.apiRequest(
@@ -19569,1215 +19691,1180 @@ function selectRecentTurns(turns, maxTokens = DEFAULT_MAX_TOKENS) {
19569
19691
  return turns.slice(startIndex);
19570
19692
  }
19571
19693
 
19572
- // src/sagas/apply-snapshot-saga.ts
19573
- var import_promises4 = require("fs/promises");
19574
- var import_node_path7 = require("path");
19575
-
19576
- // ../git/dist/sagas/tree.js
19577
- var import_node_fs3 = require("fs");
19578
- var fs12 = __toESM(require("fs/promises"), 1);
19579
- var path15 = __toESM(require("path"), 1);
19580
- var tar = __toESM(require("tar"), 1);
19581
- var CaptureTreeSaga = class extends GitSaga {
19582
- sagaName = "CaptureTreeSaga";
19583
- tempIndexPath = null;
19584
- async executeGitOperations(input) {
19585
- const { baseDir, lastTreeHash, archivePath, signal } = input;
19586
- const tmpDir = path15.join(baseDir, ".git", "posthog-code-tmp");
19587
- await this.step({
19588
- name: "create_tmp_dir",
19589
- execute: () => fs12.mkdir(tmpDir, { recursive: true }),
19590
- rollback: async () => {
19591
- }
19592
- });
19593
- this.tempIndexPath = path15.join(tmpDir, `index-${Date.now()}`);
19594
- const tempIndexGit = this.git.env({
19595
- ...process.env,
19596
- GIT_INDEX_FILE: this.tempIndexPath
19597
- });
19598
- await this.step({
19599
- name: "init_temp_index",
19600
- execute: () => tempIndexGit.raw(["read-tree", "HEAD"]),
19601
- rollback: async () => {
19602
- if (this.tempIndexPath) {
19603
- await fs12.rm(this.tempIndexPath, { force: true }).catch(() => {
19604
- });
19605
- }
19606
- }
19607
- });
19608
- await this.readOnlyStep("stage_files", () => tempIndexGit.raw(["add", "-A"]));
19609
- const treeHash = await this.readOnlyStep("write_tree", () => tempIndexGit.raw(["write-tree"]));
19610
- if (lastTreeHash && treeHash === lastTreeHash) {
19611
- this.log.debug("No changes since last capture", { treeHash });
19612
- await fs12.rm(this.tempIndexPath, { force: true }).catch(() => {
19613
- });
19614
- return { snapshot: null, changed: false };
19694
+ // src/sagas/resume-saga.ts
19695
+ var ResumeSaga = class extends Saga {
19696
+ sagaName = "ResumeSaga";
19697
+ async execute(input) {
19698
+ const { taskId, runId, apiClient } = input;
19699
+ const taskRun = await this.readOnlyStep(
19700
+ "fetch_task_run",
19701
+ () => apiClient.getTaskRun(taskId, runId)
19702
+ );
19703
+ if (!taskRun.log_url) {
19704
+ this.log.info("No log URL found, starting fresh");
19705
+ return this.emptyResult();
19615
19706
  }
19616
- const baseCommit = await this.readOnlyStep("get_base_commit", async () => {
19617
- try {
19618
- return await getHeadSha(baseDir, { abortSignal: signal });
19619
- } catch {
19620
- return null;
19621
- }
19622
- });
19623
- const changes = await this.readOnlyStep("get_changes", () => this.getChanges(this.git, baseCommit, treeHash));
19624
- await fs12.rm(this.tempIndexPath, { force: true }).catch(() => {
19625
- });
19626
- const snapshot = {
19627
- treeHash,
19628
- baseCommit,
19629
- changes,
19630
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
19631
- };
19632
- let createdArchivePath;
19633
- if (archivePath) {
19634
- createdArchivePath = await this.createArchive(baseDir, archivePath, changes);
19707
+ const entries = await this.readOnlyStep(
19708
+ "fetch_logs",
19709
+ () => apiClient.fetchTaskRunLogs(taskRun)
19710
+ );
19711
+ if (entries.length === 0) {
19712
+ this.log.info("No log entries found, starting fresh");
19713
+ return this.emptyResult();
19635
19714
  }
19636
- this.log.info("Tree captured", {
19637
- treeHash,
19638
- changes: changes.length,
19639
- archived: !!createdArchivePath
19640
- });
19641
- return { snapshot, archivePath: createdArchivePath, changed: true };
19642
- }
19643
- async createArchive(baseDir, archivePath, changes) {
19644
- const filesToArchive = changes.filter((c) => c.status !== "D").map((c) => c.path);
19645
- if (filesToArchive.length === 0) {
19646
- return void 0;
19715
+ this.log.info("Fetched log entries", { count: entries.length });
19716
+ const latestSnapshot = await this.readOnlyStep(
19717
+ "find_snapshot",
19718
+ () => Promise.resolve(this.findLatestTreeSnapshot(entries))
19719
+ );
19720
+ const latestGitCheckpoint = await this.readOnlyStep(
19721
+ "find_git_checkpoint",
19722
+ () => Promise.resolve(this.findLatestGitCheckpoint(entries))
19723
+ );
19724
+ if (latestSnapshot) {
19725
+ this.log.info("Found tree snapshot", {
19726
+ treeHash: latestSnapshot.treeHash,
19727
+ hasArchiveUrl: !!latestSnapshot.archiveUrl,
19728
+ changes: latestSnapshot.changes?.length ?? 0
19729
+ });
19647
19730
  }
19648
- const existingFiles = filesToArchive.filter((f) => (0, import_node_fs3.existsSync)(path15.join(baseDir, f)));
19649
- if (existingFiles.length === 0) {
19650
- return void 0;
19731
+ if (latestGitCheckpoint) {
19732
+ this.log.info("Found git checkpoint", {
19733
+ checkpointId: latestGitCheckpoint.checkpointId,
19734
+ branch: latestGitCheckpoint.branch
19735
+ });
19651
19736
  }
19652
- await this.step({
19653
- name: "create_archive",
19654
- execute: async () => {
19655
- const archiveDir = path15.dirname(archivePath);
19656
- await fs12.mkdir(archiveDir, { recursive: true });
19657
- await tar.create({
19658
- gzip: true,
19659
- file: archivePath,
19660
- cwd: baseDir
19661
- }, existingFiles);
19662
- },
19663
- rollback: async () => {
19664
- await fs12.rm(archivePath, { force: true }).catch(() => {
19665
- });
19666
- }
19737
+ const conversation = await this.readOnlyStep(
19738
+ "rebuild_conversation",
19739
+ () => Promise.resolve(this.rebuildConversation(entries))
19740
+ );
19741
+ const lastDevice = await this.readOnlyStep(
19742
+ "find_device",
19743
+ () => Promise.resolve(this.findLastDeviceInfo(entries))
19744
+ );
19745
+ this.log.info("Resume state rebuilt", {
19746
+ turns: conversation.length,
19747
+ hasSnapshot: !!latestSnapshot,
19748
+ hasGitCheckpoint: !!latestGitCheckpoint,
19749
+ interrupted: latestSnapshot?.interrupted ?? false
19667
19750
  });
19668
- return archivePath;
19751
+ return {
19752
+ conversation,
19753
+ latestSnapshot,
19754
+ latestGitCheckpoint,
19755
+ interrupted: latestSnapshot?.interrupted ?? false,
19756
+ lastDevice,
19757
+ logEntryCount: entries.length
19758
+ };
19669
19759
  }
19670
- async getChanges(git, fromRef, toRef) {
19671
- if (!fromRef) {
19672
- const stdout2 = await git.raw(["ls-tree", "-r", "--name-only", toRef]);
19673
- return stdout2.split("\n").filter((p) => p.trim()).map((p) => ({ path: p, status: "A" }));
19674
- }
19675
- const stdout = await git.raw([
19676
- "diff-tree",
19677
- "-r",
19678
- "--name-status",
19679
- fromRef,
19680
- toRef
19681
- ]);
19682
- const changes = [];
19683
- for (const line of stdout.split("\n")) {
19684
- if (!line.trim())
19685
- continue;
19686
- const [status, filePath] = line.split(" ");
19687
- if (!filePath)
19688
- continue;
19689
- let normalizedStatus;
19690
- if (status === "D") {
19691
- normalizedStatus = "D";
19692
- } else if (status === "A") {
19693
- normalizedStatus = "A";
19694
- } else {
19695
- normalizedStatus = "M";
19760
+ emptyResult() {
19761
+ return {
19762
+ conversation: [],
19763
+ latestSnapshot: null,
19764
+ latestGitCheckpoint: null,
19765
+ interrupted: false,
19766
+ logEntryCount: 0
19767
+ };
19768
+ }
19769
+ findLatestTreeSnapshot(entries) {
19770
+ for (let i2 = entries.length - 1; i2 >= 0; i2--) {
19771
+ const entry = entries[i2];
19772
+ if (isNotification(
19773
+ entry.notification?.method,
19774
+ POSTHOG_NOTIFICATIONS.TREE_SNAPSHOT
19775
+ )) {
19776
+ const params = entry.notification.params;
19777
+ if (params?.treeHash) {
19778
+ return params;
19779
+ }
19696
19780
  }
19697
- changes.push({ path: filePath, status: normalizedStatus });
19698
19781
  }
19699
- return changes;
19782
+ return null;
19700
19783
  }
19701
- };
19702
- var ApplyTreeSaga = class extends GitSaga {
19703
- sagaName = "ApplyTreeSaga";
19704
- originalHead = null;
19705
- originalBranch = null;
19706
- extractedFiles = [];
19707
- fileBackups = /* @__PURE__ */ new Map();
19708
- async executeGitOperations(input) {
19709
- const { baseDir, treeHash, baseCommit, changes, archivePath } = input;
19710
- const headInfo = await this.readOnlyStep("get_current_head", async () => {
19711
- let head = null;
19712
- let branch = null;
19713
- try {
19714
- head = await this.git.revparse(["HEAD"]);
19715
- } catch {
19716
- head = null;
19784
+ findLatestGitCheckpoint(entries) {
19785
+ const sdkPrefixedMethod = `_${POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT}`;
19786
+ for (let i2 = entries.length - 1; i2 >= 0; i2--) {
19787
+ const entry = entries[i2];
19788
+ const method = entry.notification?.method;
19789
+ if (method === sdkPrefixedMethod || method === POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT) {
19790
+ const params = entry.notification?.params;
19791
+ if (params?.checkpointId && params?.checkpointRef) {
19792
+ return params;
19793
+ }
19717
19794
  }
19718
- try {
19719
- branch = await this.git.raw(["symbolic-ref", "--short", "HEAD"]);
19720
- } catch {
19721
- branch = null;
19795
+ }
19796
+ return null;
19797
+ }
19798
+ findLastDeviceInfo(entries) {
19799
+ for (let i2 = entries.length - 1; i2 >= 0; i2--) {
19800
+ const entry = entries[i2];
19801
+ const params = entry.notification?.params;
19802
+ if (params?.device) {
19803
+ return params.device;
19722
19804
  }
19723
- return { head, branch };
19724
- });
19725
- this.originalHead = headInfo.head;
19726
- this.originalBranch = headInfo.branch;
19727
- let checkoutPerformed = false;
19728
- if (baseCommit && baseCommit !== this.originalHead) {
19729
- await this.readOnlyStep("check_working_tree", async () => {
19730
- const status = await this.git.status();
19731
- if (!status.isClean()) {
19732
- const changedFiles = status.modified.length + status.staged.length + status.deleted.length;
19733
- throw new Error(`Cannot apply tree: ${changedFiles} uncommitted change(s) exist. Commit or stash your changes first.`);
19734
- }
19735
- });
19736
- await this.step({
19737
- name: "checkout_base",
19738
- execute: async () => {
19739
- await this.git.checkout(baseCommit);
19740
- checkoutPerformed = true;
19741
- this.log.warn("Applied tree from different commit - now in detached HEAD state", {
19742
- originalHead: this.originalHead,
19743
- originalBranch: this.originalBranch,
19744
- baseCommit
19745
- });
19746
- },
19747
- rollback: async () => {
19748
- try {
19749
- if (this.originalBranch) {
19750
- await this.git.checkout(this.originalBranch);
19751
- } else if (this.originalHead) {
19752
- await this.git.checkout(this.originalHead);
19805
+ }
19806
+ return void 0;
19807
+ }
19808
+ rebuildConversation(entries) {
19809
+ const turns = [];
19810
+ let currentAssistantContent = [];
19811
+ let currentToolCalls = [];
19812
+ for (const entry of entries) {
19813
+ const method = entry.notification?.method;
19814
+ const params = entry.notification?.params;
19815
+ if (method === "session/update" && params?.update) {
19816
+ const update = params.update;
19817
+ const sessionUpdate = update.sessionUpdate;
19818
+ switch (sessionUpdate) {
19819
+ case "user_message":
19820
+ case "user_message_chunk": {
19821
+ if (currentAssistantContent.length > 0 || currentToolCalls.length > 0) {
19822
+ turns.push({
19823
+ role: "assistant",
19824
+ content: currentAssistantContent,
19825
+ toolCalls: currentToolCalls.length > 0 ? currentToolCalls : void 0
19826
+ });
19827
+ currentAssistantContent = [];
19828
+ currentToolCalls = [];
19753
19829
  }
19754
- } catch (error) {
19755
- this.log.warn("Failed to rollback checkout", { error });
19830
+ const content = update.content;
19831
+ const contentArray = Array.isArray(content) ? content : [content];
19832
+ turns.push({
19833
+ role: "user",
19834
+ content: contentArray
19835
+ });
19836
+ break;
19756
19837
  }
19757
- }
19758
- });
19759
- }
19760
- if (archivePath) {
19761
- const filesToExtract = changes.filter((c) => c.status !== "D").map((c) => c.path);
19762
- await this.readOnlyStep("backup_existing_files", async () => {
19763
- for (const filePath of filesToExtract) {
19764
- const fullPath = path15.join(baseDir, filePath);
19765
- try {
19766
- const content = await fs12.readFile(fullPath);
19767
- this.fileBackups.set(filePath, content);
19768
- } catch {
19838
+ case "agent_message": {
19839
+ const content = update.content;
19840
+ if (content) {
19841
+ if (content.type === "text" && currentAssistantContent.length > 0 && currentAssistantContent[currentAssistantContent.length - 1].type === "text") {
19842
+ const lastBlock = currentAssistantContent[currentAssistantContent.length - 1];
19843
+ lastBlock.text += content.text;
19844
+ } else {
19845
+ currentAssistantContent.push(content);
19846
+ }
19847
+ }
19848
+ break;
19769
19849
  }
19770
- }
19771
- });
19772
- await this.step({
19773
- name: "extract_archive",
19774
- execute: async () => {
19775
- await tar.extract({
19776
- file: archivePath,
19777
- cwd: baseDir
19778
- });
19779
- this.extractedFiles = filesToExtract;
19780
- },
19781
- rollback: async () => {
19782
- for (const filePath of this.extractedFiles) {
19783
- const fullPath = path15.join(baseDir, filePath);
19784
- const backup = this.fileBackups.get(filePath);
19785
- if (backup) {
19786
- const dir = path15.dirname(fullPath);
19787
- await fs12.mkdir(dir, { recursive: true }).catch(() => {
19788
- });
19789
- await fs12.writeFile(fullPath, backup).catch(() => {
19790
- });
19791
- } else {
19792
- await fs12.rm(fullPath, { force: true }).catch(() => {
19793
- });
19850
+ case "agent_message_chunk": {
19851
+ const content = update.content;
19852
+ if (content) {
19853
+ if (content.type === "text" && currentAssistantContent.length > 0 && currentAssistantContent[currentAssistantContent.length - 1].type === "text") {
19854
+ const lastBlock = currentAssistantContent[currentAssistantContent.length - 1];
19855
+ lastBlock.text += content.text;
19856
+ } else {
19857
+ currentAssistantContent.push(content);
19858
+ }
19794
19859
  }
19860
+ break;
19795
19861
  }
19796
- }
19797
- });
19798
- }
19799
- for (const change of changes.filter((c) => c.status === "D")) {
19800
- const fullPath = path15.join(baseDir, change.path);
19801
- const backupContent = await this.readOnlyStep(`backup_${change.path}`, async () => {
19802
- try {
19803
- return await fs12.readFile(fullPath);
19804
- } catch {
19805
- return null;
19806
- }
19807
- });
19808
- await this.step({
19809
- name: `delete_${change.path}`,
19810
- execute: async () => {
19811
- await fs12.rm(fullPath, { force: true });
19812
- this.log.debug(`Deleted file: ${change.path}`);
19813
- },
19814
- rollback: async () => {
19815
- if (backupContent) {
19816
- const dir = path15.dirname(fullPath);
19817
- await fs12.mkdir(dir, { recursive: true }).catch(() => {
19818
- });
19819
- await fs12.writeFile(fullPath, backupContent).catch(() => {
19820
- });
19862
+ case "tool_call":
19863
+ case "tool_call_update": {
19864
+ const meta = update._meta?.claudeCode;
19865
+ if (meta) {
19866
+ const toolCallId = meta.toolCallId;
19867
+ const toolName = meta.toolName;
19868
+ const toolInput = meta.toolInput;
19869
+ const toolResponse = meta.toolResponse;
19870
+ if (toolCallId && toolName) {
19871
+ let toolCall = currentToolCalls.find(
19872
+ (tc) => tc.toolCallId === toolCallId
19873
+ );
19874
+ if (!toolCall) {
19875
+ toolCall = {
19876
+ toolCallId,
19877
+ toolName,
19878
+ input: toolInput
19879
+ };
19880
+ currentToolCalls.push(toolCall);
19881
+ }
19882
+ if (toolResponse !== void 0) {
19883
+ toolCall.result = toolResponse;
19884
+ }
19885
+ }
19886
+ }
19887
+ break;
19888
+ }
19889
+ case "tool_result": {
19890
+ const meta = update._meta?.claudeCode;
19891
+ if (meta) {
19892
+ const toolCallId = meta.toolCallId;
19893
+ const toolResponse = meta.toolResponse;
19894
+ if (toolCallId) {
19895
+ const toolCall = currentToolCalls.find(
19896
+ (tc) => tc.toolCallId === toolCallId
19897
+ );
19898
+ if (toolCall && toolResponse !== void 0) {
19899
+ toolCall.result = toolResponse;
19900
+ }
19901
+ }
19902
+ }
19903
+ break;
19821
19904
  }
19822
19905
  }
19906
+ }
19907
+ }
19908
+ if (currentAssistantContent.length > 0 || currentToolCalls.length > 0) {
19909
+ turns.push({
19910
+ role: "assistant",
19911
+ content: currentAssistantContent,
19912
+ toolCalls: currentToolCalls.length > 0 ? currentToolCalls : void 0
19823
19913
  });
19824
19914
  }
19825
- const deletedCount = changes.filter((c) => c.status === "D").length;
19826
- this.log.info("Tree applied", {
19827
- treeHash,
19828
- totalChanges: changes.length,
19829
- deletedFiles: deletedCount,
19830
- checkoutPerformed
19831
- });
19832
- return { treeHash, checkoutPerformed };
19915
+ return turns;
19833
19916
  }
19834
19917
  };
19835
19918
 
19836
- // src/sagas/apply-snapshot-saga.ts
19837
- var ApplySnapshotSaga = class extends Saga {
19838
- sagaName = "ApplySnapshotSaga";
19839
- archivePath = null;
19840
- async execute(input) {
19841
- const { snapshot, repositoryPath, apiClient, taskId, runId } = input;
19842
- const tmpDir = (0, import_node_path7.join)(repositoryPath, ".posthog", "tmp");
19843
- if (!snapshot.archiveUrl) {
19844
- throw new Error("Cannot apply snapshot: no archive URL");
19845
- }
19846
- const archiveUrl = snapshot.archiveUrl;
19847
- await this.step({
19848
- name: "create_tmp_dir",
19849
- execute: () => (0, import_promises4.mkdir)(tmpDir, { recursive: true }),
19850
- rollback: async () => {
19851
- }
19919
+ // src/resume.ts
19920
+ async function resumeFromLog(config) {
19921
+ const logger = config.logger || new Logger({ debug: false, prefix: "[Resume]" });
19922
+ logger.info("Resuming from log", {
19923
+ taskId: config.taskId,
19924
+ runId: config.runId
19925
+ });
19926
+ const saga = new ResumeSaga(logger);
19927
+ const result = await saga.run({
19928
+ taskId: config.taskId,
19929
+ runId: config.runId,
19930
+ repositoryPath: config.repositoryPath,
19931
+ apiClient: config.apiClient,
19932
+ logger
19933
+ });
19934
+ if (!result.success) {
19935
+ logger.error("Failed to resume from log", {
19936
+ error: result.error,
19937
+ failedStep: result.failedStep
19852
19938
  });
19853
- const archivePath = (0, import_node_path7.join)(tmpDir, `${snapshot.treeHash}.tar.gz`);
19854
- this.archivePath = archivePath;
19855
- await this.step({
19856
- name: "download_archive",
19857
- execute: async () => {
19858
- const arrayBuffer = await apiClient.downloadArtifact(
19859
- taskId,
19860
- runId,
19861
- archiveUrl
19862
- );
19863
- if (!arrayBuffer) {
19864
- throw new Error("Failed to download archive");
19865
- }
19866
- const base64Content = Buffer.from(arrayBuffer).toString("utf-8");
19867
- const binaryContent = Buffer.from(base64Content, "base64");
19868
- await (0, import_promises4.writeFile)(archivePath, binaryContent);
19869
- this.log.info("Tree archive downloaded", {
19870
- treeHash: snapshot.treeHash,
19871
- snapshotBytes: binaryContent.byteLength,
19872
- snapshotWireBytes: arrayBuffer.byteLength,
19873
- totalBytes: binaryContent.byteLength,
19874
- totalWireBytes: arrayBuffer.byteLength
19875
- });
19876
- },
19877
- rollback: async () => {
19878
- if (this.archivePath) {
19879
- await (0, import_promises4.rm)(this.archivePath, { force: true }).catch(() => {
19880
- });
19939
+ throw new Error(
19940
+ `Failed to resume at step '${result.failedStep}': ${result.error}`
19941
+ );
19942
+ }
19943
+ return {
19944
+ conversation: result.data.conversation,
19945
+ latestSnapshot: result.data.latestSnapshot,
19946
+ latestGitCheckpoint: result.data.latestGitCheckpoint,
19947
+ interrupted: result.data.interrupted,
19948
+ lastDevice: result.data.lastDevice,
19949
+ logEntryCount: result.data.logEntryCount
19950
+ };
19951
+ }
19952
+ var RESUME_HISTORY_TOKEN_BUDGET = 5e4;
19953
+ var TOOL_RESULT_MAX_CHARS = 2e3;
19954
+ var RESUME_CONTEXT_MARKERS = [
19955
+ "You are resuming a previous conversation",
19956
+ "Here is the conversation history from the",
19957
+ "Continue from where you left off"
19958
+ ];
19959
+ function isResumeContextTurn(turn) {
19960
+ if (turn.role !== "user") return false;
19961
+ const text2 = turn.content.filter((b) => b.type === "text").map((b) => b.text).join("");
19962
+ return RESUME_CONTEXT_MARKERS.some((marker) => text2.includes(marker));
19963
+ }
19964
+ function formatConversationForResume(conversation) {
19965
+ const filtered = conversation.filter((turn) => !isResumeContextTurn(turn));
19966
+ const selected = selectRecentTurns(filtered, RESUME_HISTORY_TOKEN_BUDGET);
19967
+ const parts2 = [];
19968
+ if (selected.length < filtered.length) {
19969
+ parts2.push(
19970
+ `*(${filtered.length - selected.length} earlier turns omitted)*`
19971
+ );
19972
+ }
19973
+ for (const turn of selected) {
19974
+ const role = turn.role === "user" ? "User" : "Assistant";
19975
+ const textParts = turn.content.filter((block) => block.type === "text").map((block) => block.text);
19976
+ if (textParts.length > 0) {
19977
+ parts2.push(`**${role}**: ${textParts.join("\n")}`);
19978
+ }
19979
+ if (turn.toolCalls?.length) {
19980
+ const toolSummary = turn.toolCalls.map((tc) => {
19981
+ let resultStr = "";
19982
+ if (tc.result !== void 0) {
19983
+ const raw = typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result);
19984
+ resultStr = raw.length > TOOL_RESULT_MAX_CHARS ? ` \u2192 ${raw.substring(0, TOOL_RESULT_MAX_CHARS)}...(truncated)` : ` \u2192 ${raw}`;
19881
19985
  }
19882
- }
19883
- });
19884
- const gitApplySaga = new ApplyTreeSaga(this.log);
19885
- const applyResult = await gitApplySaga.run({
19886
- baseDir: repositoryPath,
19887
- treeHash: snapshot.treeHash,
19888
- baseCommit: snapshot.baseCommit,
19889
- changes: snapshot.changes,
19890
- archivePath: this.archivePath
19891
- });
19892
- if (!applyResult.success) {
19893
- throw new Error(`Failed to apply tree: ${applyResult.error}`);
19986
+ return ` - ${tc.toolName}${resultStr}`;
19987
+ }).join("\n");
19988
+ parts2.push(`**${role} (tools)**:
19989
+ ${toolSummary}`);
19894
19990
  }
19895
- await (0, import_promises4.rm)(this.archivePath, { force: true }).catch(() => {
19896
- });
19897
- this.log.info("Tree snapshot applied", {
19898
- treeHash: snapshot.treeHash,
19899
- totalChanges: snapshot.changes.length,
19900
- deletedFiles: snapshot.changes.filter((c) => c.status === "D").length
19901
- });
19902
- return { treeHash: snapshot.treeHash };
19903
19991
  }
19904
- };
19992
+ return parts2.join("\n\n");
19993
+ }
19905
19994
 
19906
- // src/sagas/capture-tree-saga.ts
19907
- var import_node_fs4 = require("fs");
19908
- var import_promises5 = require("fs/promises");
19909
- var import_node_path8 = require("path");
19910
- var CaptureTreeSaga2 = class extends Saga {
19911
- sagaName = "CaptureTreeSaga";
19912
- async execute(input) {
19913
- const {
19914
- repositoryPath,
19915
- lastTreeHash,
19916
- interrupted,
19917
- apiClient,
19918
- taskId,
19919
- runId
19920
- } = input;
19921
- const tmpDir = (0, import_node_path8.join)(repositoryPath, ".posthog", "tmp");
19922
- if ((0, import_node_fs4.existsSync)((0, import_node_path8.join)(repositoryPath, ".gitmodules"))) {
19923
- this.log.warn(
19924
- "Repository has submodules - snapshot may not capture submodule state"
19925
- );
19926
- }
19927
- const shouldArchive = !!apiClient;
19928
- const archivePath = shouldArchive ? (0, import_node_path8.join)(tmpDir, `tree-${Date.now()}.tar.gz`) : void 0;
19929
- const gitCaptureSaga = new CaptureTreeSaga(this.log);
19930
- const captureResult = await gitCaptureSaga.run({
19931
- baseDir: repositoryPath,
19932
- lastTreeHash,
19933
- archivePath
19934
- });
19935
- if (!captureResult.success) {
19936
- throw new Error(`Failed to capture tree: ${captureResult.error}`);
19995
+ // src/session-log-writer.ts
19996
+ var import_node_fs3 = __toESM(require("fs"), 1);
19997
+ var import_promises4 = __toESM(require("fs/promises"), 1);
19998
+ var import_node_path7 = __toESM(require("path"), 1);
19999
+ var SessionLogWriter = class _SessionLogWriter {
20000
+ static FLUSH_DEBOUNCE_MS = 500;
20001
+ static FLUSH_MAX_INTERVAL_MS = 5e3;
20002
+ static MAX_FLUSH_RETRIES = 10;
20003
+ static MAX_RETRY_DELAY_MS = 3e4;
20004
+ static SESSIONS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
20005
+ posthogAPI;
20006
+ pendingEntries = /* @__PURE__ */ new Map();
20007
+ flushTimeouts = /* @__PURE__ */ new Map();
20008
+ lastFlushAttemptTime = /* @__PURE__ */ new Map();
20009
+ retryCounts = /* @__PURE__ */ new Map();
20010
+ sessions = /* @__PURE__ */ new Map();
20011
+ flushQueues = /* @__PURE__ */ new Map();
20012
+ logger;
20013
+ localCachePath;
20014
+ constructor(options = {}) {
20015
+ this.posthogAPI = options.posthogAPI;
20016
+ this.localCachePath = options.localCachePath;
20017
+ this.logger = options.logger ?? new Logger({ debug: false, prefix: "[SessionLogWriter]" });
20018
+ }
20019
+ async flushAll() {
20020
+ const flushPromises = [];
20021
+ for (const [sessionId, session] of this.sessions) {
20022
+ this.emitCoalescedMessage(sessionId, session);
20023
+ flushPromises.push(this.flush(sessionId));
19937
20024
  }
19938
- const {
19939
- snapshot: gitSnapshot,
19940
- archivePath: createdArchivePath,
19941
- changed
19942
- } = captureResult.data;
19943
- if (!changed || !gitSnapshot) {
19944
- this.log.debug("No changes since last capture", { lastTreeHash });
19945
- return { snapshot: null, newTreeHash: lastTreeHash };
20025
+ await Promise.all(flushPromises);
20026
+ }
20027
+ register(sessionId, context) {
20028
+ if (this.sessions.has(sessionId)) {
20029
+ return;
19946
20030
  }
19947
- let archiveUrl;
19948
- if (apiClient && createdArchivePath) {
20031
+ this.sessions.set(sessionId, { context, currentTurnMessages: [] });
20032
+ this.lastFlushAttemptTime.set(sessionId, Date.now());
20033
+ if (this.localCachePath) {
20034
+ const sessionDir = import_node_path7.default.join(
20035
+ this.localCachePath,
20036
+ "sessions",
20037
+ context.runId
20038
+ );
19949
20039
  try {
19950
- archiveUrl = await this.uploadArchive(
19951
- createdArchivePath,
19952
- gitSnapshot.treeHash,
19953
- apiClient,
19954
- taskId,
19955
- runId
19956
- );
19957
- } finally {
19958
- await (0, import_promises5.rm)(createdArchivePath, { force: true }).catch(() => {
20040
+ import_node_fs3.default.mkdirSync(sessionDir, { recursive: true });
20041
+ } catch (error) {
20042
+ this.logger.warn("Failed to create local cache directory", {
20043
+ sessionDir,
20044
+ error
19959
20045
  });
19960
20046
  }
19961
20047
  }
19962
- const snapshot = {
19963
- treeHash: gitSnapshot.treeHash,
19964
- baseCommit: gitSnapshot.baseCommit,
19965
- changes: gitSnapshot.changes,
19966
- timestamp: gitSnapshot.timestamp,
19967
- interrupted,
19968
- archiveUrl
19969
- };
19970
- this.log.info("Tree captured", {
19971
- treeHash: snapshot.treeHash,
19972
- changes: snapshot.changes.length,
19973
- interrupted,
19974
- archiveUrl
19975
- });
19976
- return { snapshot, newTreeHash: snapshot.treeHash };
19977
20048
  }
19978
- async uploadArchive(archivePath, treeHash, apiClient, taskId, runId) {
19979
- const archiveUrl = await this.step({
19980
- name: "upload_archive",
19981
- execute: async () => {
19982
- const archiveContent = await (0, import_promises5.readFile)(archivePath);
19983
- const base64Content = archiveContent.toString("base64");
19984
- const snapshotBytes = archiveContent.byteLength;
19985
- const snapshotWireBytes = Buffer.byteLength(base64Content, "utf-8");
19986
- const artifacts = await apiClient.uploadTaskArtifacts(taskId, runId, [
19987
- {
19988
- name: `trees/${treeHash}.tar.gz`,
19989
- type: "tree_snapshot",
19990
- content: base64Content,
19991
- content_type: "application/gzip"
20049
+ isRegistered(sessionId) {
20050
+ return this.sessions.has(sessionId);
20051
+ }
20052
+ appendRawLine(sessionId, line) {
20053
+ const session = this.sessions.get(sessionId);
20054
+ if (!session) {
20055
+ this.logger.warn("appendRawLine called for unregistered session", {
20056
+ sessionId
20057
+ });
20058
+ return;
20059
+ }
20060
+ try {
20061
+ const message = JSON.parse(line);
20062
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
20063
+ if (this.isAgentMessageChunk(message)) {
20064
+ const text2 = this.extractChunkText(message);
20065
+ if (text2) {
20066
+ if (!session.chunkBuffer) {
20067
+ session.chunkBuffer = { text: text2, firstTimestamp: timestamp };
20068
+ } else {
20069
+ session.chunkBuffer.text += text2;
19992
20070
  }
19993
- ]);
19994
- const uploadedArtifact = artifacts[0];
19995
- if (uploadedArtifact?.storage_path) {
19996
- this.log.info("Tree archive uploaded", {
19997
- storagePath: uploadedArtifact.storage_path,
19998
- treeHash,
19999
- snapshotBytes,
20000
- snapshotWireBytes,
20001
- totalBytes: snapshotBytes,
20002
- totalWireBytes: snapshotWireBytes
20003
- });
20004
- return uploadedArtifact.storage_path;
20005
20071
  }
20006
- return void 0;
20007
- },
20008
- rollback: async () => {
20009
- await (0, import_promises5.rm)(archivePath, { force: true }).catch(() => {
20010
- });
20072
+ return;
20011
20073
  }
20012
- });
20013
- return archiveUrl;
20014
- }
20015
- };
20016
-
20017
- // src/tree-tracker.ts
20018
- var TreeTracker = class {
20019
- repositoryPath;
20020
- taskId;
20021
- runId;
20022
- apiClient;
20023
- logger;
20024
- lastTreeHash = null;
20025
- constructor(config) {
20026
- this.repositoryPath = config.repositoryPath;
20027
- this.taskId = config.taskId;
20028
- this.runId = config.runId;
20029
- this.apiClient = config.apiClient;
20030
- this.logger = config.logger || new Logger({ debug: false, prefix: "[TreeTracker]" });
20031
- }
20032
- /**
20033
- * Capture current working tree state as a snapshot.
20034
- * Uses a temporary index to avoid modifying user's staging area.
20035
- * Uses Saga pattern for atomic operation with automatic cleanup on failure.
20036
- */
20037
- async captureTree(options) {
20038
- const saga = new CaptureTreeSaga2(this.logger);
20039
- const result = await saga.run({
20040
- repositoryPath: this.repositoryPath,
20041
- taskId: this.taskId,
20042
- runId: this.runId,
20043
- apiClient: this.apiClient,
20044
- lastTreeHash: this.lastTreeHash,
20045
- interrupted: options?.interrupted
20046
- });
20047
- if (!result.success) {
20048
- this.logger.error("Failed to capture tree", {
20049
- error: result.error,
20050
- failedStep: result.failedStep
20074
+ if (this.isDirectAgentMessage(message) && session.chunkBuffer) {
20075
+ session.chunkBuffer = void 0;
20076
+ } else {
20077
+ this.emitCoalescedMessage(sessionId, session);
20078
+ }
20079
+ const nonChunkAgentText = this.extractAgentMessageText(message);
20080
+ if (nonChunkAgentText) {
20081
+ session.lastAgentMessage = nonChunkAgentText;
20082
+ session.currentTurnMessages.push(nonChunkAgentText);
20083
+ }
20084
+ const entry = {
20085
+ type: "notification",
20086
+ timestamp,
20087
+ notification: message
20088
+ };
20089
+ this.writeToLocalCache(sessionId, entry);
20090
+ if (this.posthogAPI) {
20091
+ const pending = this.pendingEntries.get(sessionId) ?? [];
20092
+ pending.push(entry);
20093
+ this.pendingEntries.set(sessionId, pending);
20094
+ this.scheduleFlush(sessionId);
20095
+ }
20096
+ } catch {
20097
+ this.logger.warn("Failed to parse raw line for persistence", {
20098
+ taskId: session.context.taskId,
20099
+ runId: session.context.runId,
20100
+ lineLength: line.length
20051
20101
  });
20052
- throw new Error(
20053
- `Failed to capture tree at step '${result.failedStep}': ${result.error}`
20054
- );
20055
20102
  }
20056
- if (result.data.newTreeHash !== null) {
20057
- this.lastTreeHash = result.data.newTreeHash;
20103
+ }
20104
+ async flush(sessionId, { coalesce = false } = {}) {
20105
+ if (coalesce) {
20106
+ const session = this.sessions.get(sessionId);
20107
+ if (session) {
20108
+ this.emitCoalescedMessage(sessionId, session);
20109
+ }
20058
20110
  }
20059
- return result.data.snapshot;
20111
+ const prev = this.flushQueues.get(sessionId) ?? Promise.resolve();
20112
+ const next = prev.catch(() => {
20113
+ }).then(() => this._doFlush(sessionId));
20114
+ this.flushQueues.set(sessionId, next);
20115
+ next.finally(() => {
20116
+ if (this.flushQueues.get(sessionId) === next) {
20117
+ this.flushQueues.delete(sessionId);
20118
+ }
20119
+ });
20120
+ return next;
20060
20121
  }
20061
- /**
20062
- * Download and apply a tree snapshot.
20063
- * Uses Saga pattern for atomic operation with rollback on failure.
20064
- */
20065
- async applyTreeSnapshot(snapshot) {
20066
- if (!this.apiClient) {
20067
- throw new Error("Cannot apply snapshot: API client not configured");
20122
+ async _doFlush(sessionId) {
20123
+ const session = this.sessions.get(sessionId);
20124
+ if (!session) {
20125
+ this.logger.warn("flush: no session found", { sessionId });
20126
+ return;
20068
20127
  }
20069
- if (!snapshot.archiveUrl) {
20070
- this.logger.warn("Cannot apply snapshot: no archive URL", {
20071
- treeHash: snapshot.treeHash,
20072
- changes: snapshot.changes.length
20073
- });
20074
- throw new Error("Cannot apply snapshot: no archive URL");
20128
+ const pending = this.pendingEntries.get(sessionId);
20129
+ if (!this.posthogAPI || !pending?.length) {
20130
+ return;
20075
20131
  }
20076
- const saga = new ApplySnapshotSaga(this.logger);
20077
- const result = await saga.run({
20078
- snapshot,
20079
- repositoryPath: this.repositoryPath,
20080
- apiClient: this.apiClient,
20081
- taskId: this.taskId,
20082
- runId: this.runId
20083
- });
20084
- if (!result.success) {
20085
- this.logger.error("Failed to apply tree snapshot", {
20086
- error: result.error,
20087
- failedStep: result.failedStep,
20088
- treeHash: snapshot.treeHash
20089
- });
20090
- throw new Error(
20091
- `Failed to apply snapshot at step '${result.failedStep}': ${result.error}`
20132
+ this.pendingEntries.delete(sessionId);
20133
+ const timeout = this.flushTimeouts.get(sessionId);
20134
+ if (timeout) {
20135
+ clearTimeout(timeout);
20136
+ this.flushTimeouts.delete(sessionId);
20137
+ }
20138
+ this.lastFlushAttemptTime.set(sessionId, Date.now());
20139
+ try {
20140
+ await this.posthogAPI.appendTaskRunLog(
20141
+ session.context.taskId,
20142
+ session.context.runId,
20143
+ pending
20092
20144
  );
20145
+ this.retryCounts.set(sessionId, 0);
20146
+ } catch (error) {
20147
+ const retryCount = (this.retryCounts.get(sessionId) ?? 0) + 1;
20148
+ this.retryCounts.set(sessionId, retryCount);
20149
+ if (retryCount >= _SessionLogWriter.MAX_FLUSH_RETRIES) {
20150
+ this.logger.error(
20151
+ `Dropping ${pending.length} session log entries after ${retryCount} failed flush attempts`,
20152
+ {
20153
+ taskId: session.context.taskId,
20154
+ runId: session.context.runId,
20155
+ error
20156
+ }
20157
+ );
20158
+ this.retryCounts.set(sessionId, 0);
20159
+ } else {
20160
+ if (retryCount === 1) {
20161
+ this.logger.warn(
20162
+ `Failed to persist session logs, will retry (up to ${_SessionLogWriter.MAX_FLUSH_RETRIES} attempts)`,
20163
+ {
20164
+ taskId: session.context.taskId,
20165
+ runId: session.context.runId,
20166
+ error: error instanceof Error ? error.message : String(error)
20167
+ }
20168
+ );
20169
+ }
20170
+ const currentPending = this.pendingEntries.get(sessionId) ?? [];
20171
+ this.pendingEntries.set(sessionId, [...pending, ...currentPending]);
20172
+ this.scheduleFlush(sessionId);
20173
+ }
20093
20174
  }
20094
- this.lastTreeHash = result.data.treeHash;
20095
20175
  }
20096
- /**
20097
- * Get the last captured tree hash.
20098
- */
20099
- getLastTreeHash() {
20100
- return this.lastTreeHash;
20176
+ getSessionUpdateType(message) {
20177
+ if (message.method !== "session/update") return void 0;
20178
+ const params = message.params;
20179
+ const update = params?.update;
20180
+ return update?.sessionUpdate;
20101
20181
  }
20102
- /**
20103
- * Set the last tree hash (used when resuming).
20104
- */
20105
- setLastTreeHash(hash) {
20106
- this.lastTreeHash = hash;
20182
+ isDirectAgentMessage(message) {
20183
+ return this.getSessionUpdateType(message) === "agent_message";
20107
20184
  }
20108
- };
20109
-
20110
- // src/sagas/resume-saga.ts
20111
- var ResumeSaga = class extends Saga {
20112
- sagaName = "ResumeSaga";
20113
- async execute(input) {
20114
- const { taskId, runId, repositoryPath, apiClient } = input;
20115
- const logger = input.logger || new Logger({ debug: false, prefix: "[Resume]" });
20116
- const taskRun = await this.readOnlyStep(
20117
- "fetch_task_run",
20118
- () => apiClient.getTaskRun(taskId, runId)
20119
- );
20120
- if (!taskRun.log_url) {
20121
- this.log.info("No log URL found, starting fresh");
20122
- return this.emptyResult();
20123
- }
20124
- const entries = await this.readOnlyStep(
20125
- "fetch_logs",
20126
- () => apiClient.fetchTaskRunLogs(taskRun)
20127
- );
20128
- if (entries.length === 0) {
20129
- this.log.info("No log entries found, starting fresh");
20130
- return this.emptyResult();
20185
+ isAgentMessageChunk(message) {
20186
+ return this.getSessionUpdateType(message) === "agent_message_chunk";
20187
+ }
20188
+ extractChunkText(message) {
20189
+ const params = message.params;
20190
+ const update = params?.update;
20191
+ const content = update?.content;
20192
+ if (content?.type === "text" && content.text) {
20193
+ return content.text;
20131
20194
  }
20132
- this.log.info("Fetched log entries", { count: entries.length });
20133
- const latestSnapshot = await this.readOnlyStep(
20134
- "find_snapshot",
20135
- () => Promise.resolve(this.findLatestTreeSnapshot(entries))
20136
- );
20137
- const latestGitCheckpoint = await this.readOnlyStep(
20138
- "find_git_checkpoint",
20139
- () => Promise.resolve(this.findLatestGitCheckpoint(entries))
20140
- );
20141
- let snapshotApplied = false;
20142
- if (latestSnapshot?.archiveUrl && repositoryPath) {
20143
- this.log.info("Found tree snapshot", {
20144
- treeHash: latestSnapshot.treeHash,
20145
- hasArchiveUrl: true,
20146
- changes: latestSnapshot.changes?.length ?? 0,
20147
- interrupted: latestSnapshot.interrupted
20148
- });
20149
- await this.step({
20150
- name: "apply_snapshot",
20151
- execute: async () => {
20152
- const treeTracker = new TreeTracker({
20153
- repositoryPath,
20154
- taskId,
20155
- runId,
20156
- apiClient,
20157
- logger: logger.child("TreeTracker")
20158
- });
20159
- try {
20160
- await treeTracker.applyTreeSnapshot(latestSnapshot);
20161
- treeTracker.setLastTreeHash(latestSnapshot.treeHash);
20162
- snapshotApplied = true;
20163
- this.log.info("Tree snapshot applied successfully", {
20164
- treeHash: latestSnapshot.treeHash
20165
- });
20166
- } catch (error) {
20167
- this.log.warn(
20168
- "Failed to apply tree snapshot, continuing without it",
20169
- {
20170
- error: error instanceof Error ? error.message : String(error),
20171
- treeHash: latestSnapshot.treeHash
20172
- }
20173
- );
20195
+ return "";
20196
+ }
20197
+ emitCoalescedMessage(sessionId, session) {
20198
+ if (!session.chunkBuffer) return;
20199
+ const { text: text2, firstTimestamp } = session.chunkBuffer;
20200
+ session.chunkBuffer = void 0;
20201
+ session.lastAgentMessage = text2;
20202
+ session.currentTurnMessages.push(text2);
20203
+ const entry = {
20204
+ type: "notification",
20205
+ timestamp: firstTimestamp,
20206
+ notification: {
20207
+ jsonrpc: "2.0",
20208
+ method: "session/update",
20209
+ params: {
20210
+ update: {
20211
+ sessionUpdate: "agent_message",
20212
+ content: { type: "text", text: text2 }
20174
20213
  }
20175
- },
20176
- rollback: async () => {
20177
- }
20178
- });
20179
- } else if (latestSnapshot?.archiveUrl && !repositoryPath) {
20180
- this.log.warn(
20181
- "Snapshot found but no repositoryPath configured - files cannot be restored",
20182
- {
20183
- treeHash: latestSnapshot.treeHash,
20184
- changes: latestSnapshot.changes?.length ?? 0
20185
- }
20186
- );
20187
- } else if (latestSnapshot) {
20188
- this.log.warn(
20189
- "Snapshot found but has no archive URL - files cannot be restored",
20190
- {
20191
- treeHash: latestSnapshot.treeHash,
20192
- changes: latestSnapshot.changes?.length ?? 0
20193
20214
  }
20194
- );
20195
- }
20196
- const conversation = await this.readOnlyStep(
20197
- "rebuild_conversation",
20198
- () => Promise.resolve(this.rebuildConversation(entries))
20199
- );
20200
- const lastDevice = await this.readOnlyStep(
20201
- "find_device",
20202
- () => Promise.resolve(this.findLastDeviceInfo(entries))
20203
- );
20204
- this.log.info("Resume state rebuilt", {
20205
- turns: conversation.length,
20206
- hasSnapshot: !!latestSnapshot,
20207
- snapshotApplied,
20208
- interrupted: latestSnapshot?.interrupted ?? false
20209
- });
20210
- return {
20211
- conversation,
20212
- latestSnapshot,
20213
- latestGitCheckpoint,
20214
- snapshotApplied,
20215
- interrupted: latestSnapshot?.interrupted ?? false,
20216
- lastDevice,
20217
- logEntryCount: entries.length
20215
+ }
20218
20216
  };
20217
+ this.writeToLocalCache(sessionId, entry);
20218
+ if (this.posthogAPI) {
20219
+ const pending = this.pendingEntries.get(sessionId) ?? [];
20220
+ pending.push(entry);
20221
+ this.pendingEntries.set(sessionId, pending);
20222
+ this.scheduleFlush(sessionId);
20223
+ }
20219
20224
  }
20220
- emptyResult() {
20221
- return {
20222
- conversation: [],
20223
- latestSnapshot: null,
20224
- latestGitCheckpoint: null,
20225
- snapshotApplied: false,
20226
- interrupted: false,
20227
- logEntryCount: 0
20228
- };
20225
+ getLastAgentMessage(sessionId) {
20226
+ return this.sessions.get(sessionId)?.lastAgentMessage;
20229
20227
  }
20230
- findLatestTreeSnapshot(entries) {
20231
- for (let i2 = entries.length - 1; i2 >= 0; i2--) {
20232
- const entry = entries[i2];
20233
- if (isNotification(
20234
- entry.notification?.method,
20235
- POSTHOG_NOTIFICATIONS.TREE_SNAPSHOT
20236
- )) {
20237
- const params = entry.notification.params;
20238
- if (params?.treeHash) {
20239
- return params;
20228
+ getFullAgentResponse(sessionId) {
20229
+ const session = this.sessions.get(sessionId);
20230
+ if (!session || session.currentTurnMessages.length === 0) return void 0;
20231
+ if (session.chunkBuffer) {
20232
+ this.logger.warn(
20233
+ "getFullAgentResponse called with non-empty chunk buffer",
20234
+ {
20235
+ sessionId,
20236
+ bufferedLength: session.chunkBuffer.text.length
20240
20237
  }
20241
- }
20238
+ );
20242
20239
  }
20243
- return null;
20240
+ return session.currentTurnMessages.join("\n\n");
20244
20241
  }
20245
- findLatestGitCheckpoint(entries) {
20246
- const sdkPrefixedMethod = `_${POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT}`;
20247
- for (let i2 = entries.length - 1; i2 >= 0; i2--) {
20248
- const entry = entries[i2];
20249
- const method = entry.notification?.method;
20250
- if (method === sdkPrefixedMethod || method === POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT) {
20251
- const params = entry.notification?.params;
20252
- if (params?.checkpointId && params?.checkpointRef) {
20253
- return params;
20254
- }
20255
- }
20242
+ resetTurnMessages(sessionId) {
20243
+ const session = this.sessions.get(sessionId);
20244
+ if (session) {
20245
+ session.currentTurnMessages = [];
20246
+ }
20247
+ }
20248
+ extractAgentMessageText(message) {
20249
+ if (message.method !== "session/update") {
20250
+ return null;
20251
+ }
20252
+ const params = message.params;
20253
+ const update = params?.update;
20254
+ if (update?.sessionUpdate !== "agent_message") {
20255
+ return null;
20256
+ }
20257
+ const content = update.content;
20258
+ if (content?.type === "text" && typeof content.text === "string") {
20259
+ const trimmed2 = content.text.trim();
20260
+ return trimmed2.length > 0 ? trimmed2 : null;
20261
+ }
20262
+ if (typeof update.message === "string") {
20263
+ const trimmed2 = update.message.trim();
20264
+ return trimmed2.length > 0 ? trimmed2 : null;
20256
20265
  }
20257
20266
  return null;
20258
20267
  }
20259
- findLastDeviceInfo(entries) {
20260
- for (let i2 = entries.length - 1; i2 >= 0; i2--) {
20261
- const entry = entries[i2];
20262
- const params = entry.notification?.params;
20263
- if (params?.device) {
20264
- return params.device;
20265
- }
20268
+ scheduleFlush(sessionId) {
20269
+ const existing = this.flushTimeouts.get(sessionId);
20270
+ if (existing) clearTimeout(existing);
20271
+ const retryCount = this.retryCounts.get(sessionId) ?? 0;
20272
+ const lastAttempt = this.lastFlushAttemptTime.get(sessionId) ?? 0;
20273
+ const elapsed = Date.now() - lastAttempt;
20274
+ let delay3;
20275
+ if (retryCount > 0) {
20276
+ delay3 = Math.min(
20277
+ _SessionLogWriter.FLUSH_DEBOUNCE_MS * 2 ** retryCount,
20278
+ _SessionLogWriter.MAX_RETRY_DELAY_MS
20279
+ );
20280
+ } else if (elapsed >= _SessionLogWriter.FLUSH_MAX_INTERVAL_MS) {
20281
+ delay3 = 0;
20282
+ } else {
20283
+ delay3 = _SessionLogWriter.FLUSH_DEBOUNCE_MS;
20266
20284
  }
20267
- return void 0;
20285
+ const timeout = setTimeout(() => this.flush(sessionId), delay3);
20286
+ this.flushTimeouts.set(sessionId, timeout);
20268
20287
  }
20269
- rebuildConversation(entries) {
20270
- const turns = [];
20271
- let currentAssistantContent = [];
20272
- let currentToolCalls = [];
20273
- for (const entry of entries) {
20274
- const method = entry.notification?.method;
20275
- const params = entry.notification?.params;
20276
- if (method === "session/update" && params?.update) {
20277
- const update = params.update;
20278
- const sessionUpdate = update.sessionUpdate;
20279
- switch (sessionUpdate) {
20280
- case "user_message":
20281
- case "user_message_chunk": {
20282
- if (currentAssistantContent.length > 0 || currentToolCalls.length > 0) {
20283
- turns.push({
20284
- role: "assistant",
20285
- content: currentAssistantContent,
20286
- toolCalls: currentToolCalls.length > 0 ? currentToolCalls : void 0
20287
- });
20288
- currentAssistantContent = [];
20289
- currentToolCalls = [];
20290
- }
20291
- const content = update.content;
20292
- const contentArray = Array.isArray(content) ? content : [content];
20293
- turns.push({
20294
- role: "user",
20295
- content: contentArray
20296
- });
20297
- break;
20298
- }
20299
- case "agent_message": {
20300
- const content = update.content;
20301
- if (content) {
20302
- if (content.type === "text" && currentAssistantContent.length > 0 && currentAssistantContent[currentAssistantContent.length - 1].type === "text") {
20303
- const lastBlock = currentAssistantContent[currentAssistantContent.length - 1];
20304
- lastBlock.text += content.text;
20305
- } else {
20306
- currentAssistantContent.push(content);
20307
- }
20308
- }
20309
- break;
20310
- }
20311
- case "agent_message_chunk": {
20312
- const content = update.content;
20313
- if (content) {
20314
- if (content.type === "text" && currentAssistantContent.length > 0 && currentAssistantContent[currentAssistantContent.length - 1].type === "text") {
20315
- const lastBlock = currentAssistantContent[currentAssistantContent.length - 1];
20316
- lastBlock.text += content.text;
20317
- } else {
20318
- currentAssistantContent.push(content);
20319
- }
20320
- }
20321
- break;
20322
- }
20323
- case "tool_call":
20324
- case "tool_call_update": {
20325
- const meta = update._meta?.claudeCode;
20326
- if (meta) {
20327
- const toolCallId = meta.toolCallId;
20328
- const toolName = meta.toolName;
20329
- const toolInput = meta.toolInput;
20330
- const toolResponse = meta.toolResponse;
20331
- if (toolCallId && toolName) {
20332
- let toolCall = currentToolCalls.find(
20333
- (tc) => tc.toolCallId === toolCallId
20334
- );
20335
- if (!toolCall) {
20336
- toolCall = {
20337
- toolCallId,
20338
- toolName,
20339
- input: toolInput
20340
- };
20341
- currentToolCalls.push(toolCall);
20342
- }
20343
- if (toolResponse !== void 0) {
20344
- toolCall.result = toolResponse;
20345
- }
20346
- }
20347
- }
20348
- break;
20349
- }
20350
- case "tool_result": {
20351
- const meta = update._meta?.claudeCode;
20352
- if (meta) {
20353
- const toolCallId = meta.toolCallId;
20354
- const toolResponse = meta.toolResponse;
20355
- if (toolCallId) {
20356
- const toolCall = currentToolCalls.find(
20357
- (tc) => tc.toolCallId === toolCallId
20358
- );
20359
- if (toolCall && toolResponse !== void 0) {
20360
- toolCall.result = toolResponse;
20361
- }
20362
- }
20363
- }
20364
- break;
20288
+ writeToLocalCache(sessionId, entry) {
20289
+ if (!this.localCachePath) return;
20290
+ const session = this.sessions.get(sessionId);
20291
+ if (!session) return;
20292
+ const logPath = import_node_path7.default.join(
20293
+ this.localCachePath,
20294
+ "sessions",
20295
+ session.context.runId,
20296
+ "logs.ndjson"
20297
+ );
20298
+ try {
20299
+ import_node_fs3.default.appendFileSync(logPath, `${JSON.stringify(entry)}
20300
+ `);
20301
+ } catch (error) {
20302
+ this.logger.warn("Failed to write to local cache", {
20303
+ taskId: session.context.taskId,
20304
+ runId: session.context.runId,
20305
+ logPath,
20306
+ error
20307
+ });
20308
+ }
20309
+ }
20310
+ static async cleanupOldSessions(localCachePath) {
20311
+ const sessionsDir = import_node_path7.default.join(localCachePath, "sessions");
20312
+ let deleted = 0;
20313
+ try {
20314
+ const entries = await import_promises4.default.readdir(sessionsDir);
20315
+ const now = Date.now();
20316
+ for (const entry of entries) {
20317
+ const entryPath = import_node_path7.default.join(sessionsDir, entry);
20318
+ try {
20319
+ const stats = await import_promises4.default.stat(entryPath);
20320
+ if (stats.isDirectory() && now - stats.birthtimeMs > _SessionLogWriter.SESSIONS_MAX_AGE_MS) {
20321
+ await import_promises4.default.rm(entryPath, { recursive: true, force: true });
20322
+ deleted++;
20365
20323
  }
20324
+ } catch {
20366
20325
  }
20367
20326
  }
20327
+ } catch {
20368
20328
  }
20369
- if (currentAssistantContent.length > 0 || currentToolCalls.length > 0) {
20370
- turns.push({
20371
- role: "assistant",
20372
- content: currentAssistantContent,
20373
- toolCalls: currentToolCalls.length > 0 ? currentToolCalls : void 0
20374
- });
20375
- }
20376
- return turns;
20329
+ return deleted;
20377
20330
  }
20378
20331
  };
20379
20332
 
20380
- // src/resume.ts
20381
- async function resumeFromLog(config) {
20382
- const logger = config.logger || new Logger({ debug: false, prefix: "[Resume]" });
20383
- logger.info("Resuming from log", {
20384
- taskId: config.taskId,
20385
- runId: config.runId
20386
- });
20387
- const saga = new ResumeSaga(logger);
20388
- const result = await saga.run({
20389
- taskId: config.taskId,
20390
- runId: config.runId,
20391
- repositoryPath: config.repositoryPath,
20392
- apiClient: config.apiClient,
20393
- logger
20394
- });
20395
- if (!result.success) {
20396
- logger.error("Failed to resume from log", {
20397
- error: result.error,
20398
- failedStep: result.failedStep
20333
+ // src/sagas/apply-snapshot-saga.ts
20334
+ var import_promises5 = require("fs/promises");
20335
+ var import_node_path8 = require("path");
20336
+
20337
+ // ../git/dist/sagas/tree.js
20338
+ var import_node_fs4 = require("fs");
20339
+ var fs13 = __toESM(require("fs/promises"), 1);
20340
+ var path16 = __toESM(require("path"), 1);
20341
+ var tar = __toESM(require("tar"), 1);
20342
+ var CaptureTreeSaga = class extends GitSaga {
20343
+ sagaName = "CaptureTreeSaga";
20344
+ tempIndexPath = null;
20345
+ async executeGitOperations(input) {
20346
+ const { baseDir, lastTreeHash, archivePath, signal } = input;
20347
+ const tmpDir = path16.join(baseDir, ".git", "posthog-code-tmp");
20348
+ await this.step({
20349
+ name: "create_tmp_dir",
20350
+ execute: () => fs13.mkdir(tmpDir, { recursive: true }),
20351
+ rollback: async () => {
20352
+ }
20399
20353
  });
20400
- throw new Error(
20401
- `Failed to resume at step '${result.failedStep}': ${result.error}`
20402
- );
20403
- }
20404
- return {
20405
- conversation: result.data.conversation,
20406
- latestSnapshot: result.data.latestSnapshot,
20407
- latestGitCheckpoint: result.data.latestGitCheckpoint,
20408
- snapshotApplied: result.data.snapshotApplied,
20409
- interrupted: result.data.interrupted,
20410
- lastDevice: result.data.lastDevice,
20411
- logEntryCount: result.data.logEntryCount
20412
- };
20413
- }
20414
- var RESUME_HISTORY_TOKEN_BUDGET = 5e4;
20415
- var TOOL_RESULT_MAX_CHARS = 2e3;
20416
- function formatConversationForResume(conversation) {
20417
- const selected = selectRecentTurns(conversation, RESUME_HISTORY_TOKEN_BUDGET);
20418
- const parts2 = [];
20419
- if (selected.length < conversation.length) {
20420
- parts2.push(
20421
- `*(${conversation.length - selected.length} earlier turns omitted)*`
20422
- );
20423
- }
20424
- for (const turn of selected) {
20425
- const role = turn.role === "user" ? "User" : "Assistant";
20426
- const textParts = turn.content.filter((block) => block.type === "text").map((block) => block.text);
20427
- if (textParts.length > 0) {
20428
- parts2.push(`**${role}**: ${textParts.join("\n")}`);
20429
- }
20430
- if (turn.toolCalls?.length) {
20431
- const toolSummary = turn.toolCalls.map((tc) => {
20432
- let resultStr = "";
20433
- if (tc.result !== void 0) {
20434
- const raw = typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result);
20435
- resultStr = raw.length > TOOL_RESULT_MAX_CHARS ? ` \u2192 ${raw.substring(0, TOOL_RESULT_MAX_CHARS)}...(truncated)` : ` \u2192 ${raw}`;
20354
+ this.tempIndexPath = path16.join(tmpDir, `index-${Date.now()}`);
20355
+ const tempIndexGit = this.git.env({
20356
+ ...process.env,
20357
+ GIT_INDEX_FILE: this.tempIndexPath
20358
+ });
20359
+ await this.step({
20360
+ name: "init_temp_index",
20361
+ execute: () => tempIndexGit.raw(["read-tree", "HEAD"]),
20362
+ rollback: async () => {
20363
+ if (this.tempIndexPath) {
20364
+ await fs13.rm(this.tempIndexPath, { force: true }).catch(() => {
20365
+ });
20436
20366
  }
20437
- return ` - ${tc.toolName}${resultStr}`;
20438
- }).join("\n");
20439
- parts2.push(`**${role} (tools)**:
20440
- ${toolSummary}`);
20367
+ }
20368
+ });
20369
+ await this.readOnlyStep("stage_files", () => tempIndexGit.raw(["add", "-A"]));
20370
+ const treeHash = await this.readOnlyStep("write_tree", () => tempIndexGit.raw(["write-tree"]));
20371
+ if (lastTreeHash && treeHash === lastTreeHash) {
20372
+ this.log.debug("No changes since last capture", { treeHash });
20373
+ await fs13.rm(this.tempIndexPath, { force: true }).catch(() => {
20374
+ });
20375
+ return { snapshot: null, changed: false };
20441
20376
  }
20442
- }
20443
- return parts2.join("\n\n");
20444
- }
20445
-
20446
- // src/session-log-writer.ts
20447
- var import_node_fs5 = __toESM(require("fs"), 1);
20448
- var import_promises6 = __toESM(require("fs/promises"), 1);
20449
- var import_node_path9 = __toESM(require("path"), 1);
20450
- var SessionLogWriter = class _SessionLogWriter {
20451
- static FLUSH_DEBOUNCE_MS = 500;
20452
- static FLUSH_MAX_INTERVAL_MS = 5e3;
20453
- static MAX_FLUSH_RETRIES = 10;
20454
- static MAX_RETRY_DELAY_MS = 3e4;
20455
- static SESSIONS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
20456
- posthogAPI;
20457
- pendingEntries = /* @__PURE__ */ new Map();
20458
- flushTimeouts = /* @__PURE__ */ new Map();
20459
- lastFlushAttemptTime = /* @__PURE__ */ new Map();
20460
- retryCounts = /* @__PURE__ */ new Map();
20461
- sessions = /* @__PURE__ */ new Map();
20462
- flushQueues = /* @__PURE__ */ new Map();
20463
- logger;
20464
- localCachePath;
20465
- constructor(options = {}) {
20466
- this.posthogAPI = options.posthogAPI;
20467
- this.localCachePath = options.localCachePath;
20468
- this.logger = options.logger ?? new Logger({ debug: false, prefix: "[SessionLogWriter]" });
20469
- }
20470
- async flushAll() {
20471
- const flushPromises = [];
20472
- for (const [sessionId, session] of this.sessions) {
20473
- this.emitCoalescedMessage(sessionId, session);
20474
- flushPromises.push(this.flush(sessionId));
20377
+ const baseCommit = await this.readOnlyStep("get_base_commit", async () => {
20378
+ try {
20379
+ return await getHeadSha(baseDir, { abortSignal: signal });
20380
+ } catch {
20381
+ return null;
20382
+ }
20383
+ });
20384
+ const changes = await this.readOnlyStep("get_changes", () => this.getChanges(this.git, baseCommit, treeHash));
20385
+ await fs13.rm(this.tempIndexPath, { force: true }).catch(() => {
20386
+ });
20387
+ const snapshot = {
20388
+ treeHash,
20389
+ baseCommit,
20390
+ changes,
20391
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
20392
+ };
20393
+ let createdArchivePath;
20394
+ if (archivePath) {
20395
+ createdArchivePath = await this.createArchive(baseDir, archivePath, changes);
20475
20396
  }
20476
- await Promise.all(flushPromises);
20397
+ this.log.info("Tree captured", {
20398
+ treeHash,
20399
+ changes: changes.length,
20400
+ archived: !!createdArchivePath
20401
+ });
20402
+ return { snapshot, archivePath: createdArchivePath, changed: true };
20477
20403
  }
20478
- register(sessionId, context) {
20479
- if (this.sessions.has(sessionId)) {
20480
- return;
20404
+ async createArchive(baseDir, archivePath, changes) {
20405
+ const filesToArchive = changes.filter((c) => c.status !== "D").map((c) => c.path);
20406
+ if (filesToArchive.length === 0) {
20407
+ return void 0;
20481
20408
  }
20482
- this.sessions.set(sessionId, { context, currentTurnMessages: [] });
20483
- this.lastFlushAttemptTime.set(sessionId, Date.now());
20484
- if (this.localCachePath) {
20485
- const sessionDir = import_node_path9.default.join(
20486
- this.localCachePath,
20487
- "sessions",
20488
- context.runId
20489
- );
20490
- try {
20491
- import_node_fs5.default.mkdirSync(sessionDir, { recursive: true });
20492
- } catch (error) {
20493
- this.logger.warn("Failed to create local cache directory", {
20494
- sessionDir,
20495
- error
20409
+ const existingFiles = filesToArchive.filter((f) => (0, import_node_fs4.existsSync)(path16.join(baseDir, f)));
20410
+ if (existingFiles.length === 0) {
20411
+ return void 0;
20412
+ }
20413
+ await this.step({
20414
+ name: "create_archive",
20415
+ execute: async () => {
20416
+ const archiveDir = path16.dirname(archivePath);
20417
+ await fs13.mkdir(archiveDir, { recursive: true });
20418
+ await tar.create({
20419
+ gzip: true,
20420
+ file: archivePath,
20421
+ cwd: baseDir
20422
+ }, existingFiles);
20423
+ },
20424
+ rollback: async () => {
20425
+ await fs13.rm(archivePath, { force: true }).catch(() => {
20496
20426
  });
20497
20427
  }
20498
- }
20499
- }
20500
- isRegistered(sessionId) {
20501
- return this.sessions.has(sessionId);
20428
+ });
20429
+ return archivePath;
20502
20430
  }
20503
- appendRawLine(sessionId, line) {
20504
- const session = this.sessions.get(sessionId);
20505
- if (!session) {
20506
- this.logger.warn("appendRawLine called for unregistered session", {
20507
- sessionId
20508
- });
20509
- return;
20431
+ async getChanges(git, fromRef, toRef) {
20432
+ if (!fromRef) {
20433
+ const stdout2 = await git.raw(["ls-tree", "-r", "--name-only", toRef]);
20434
+ return stdout2.split("\n").filter((p) => p.trim()).map((p) => ({ path: p, status: "A" }));
20510
20435
  }
20511
- try {
20512
- const message = JSON.parse(line);
20513
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
20514
- if (this.isAgentMessageChunk(message)) {
20515
- const text2 = this.extractChunkText(message);
20516
- if (text2) {
20517
- if (!session.chunkBuffer) {
20518
- session.chunkBuffer = { text: text2, firstTimestamp: timestamp };
20519
- } else {
20520
- session.chunkBuffer.text += text2;
20521
- }
20522
- }
20523
- return;
20524
- }
20525
- if (this.isDirectAgentMessage(message) && session.chunkBuffer) {
20526
- session.chunkBuffer = void 0;
20436
+ const stdout = await git.raw([
20437
+ "diff-tree",
20438
+ "-r",
20439
+ "--name-status",
20440
+ fromRef,
20441
+ toRef
20442
+ ]);
20443
+ const changes = [];
20444
+ for (const line of stdout.split("\n")) {
20445
+ if (!line.trim())
20446
+ continue;
20447
+ const [status, filePath] = line.split(" ");
20448
+ if (!filePath)
20449
+ continue;
20450
+ let normalizedStatus;
20451
+ if (status === "D") {
20452
+ normalizedStatus = "D";
20453
+ } else if (status === "A") {
20454
+ normalizedStatus = "A";
20527
20455
  } else {
20528
- this.emitCoalescedMessage(sessionId, session);
20456
+ normalizedStatus = "M";
20529
20457
  }
20530
- const nonChunkAgentText = this.extractAgentMessageText(message);
20531
- if (nonChunkAgentText) {
20532
- session.lastAgentMessage = nonChunkAgentText;
20533
- session.currentTurnMessages.push(nonChunkAgentText);
20458
+ changes.push({ path: filePath, status: normalizedStatus });
20459
+ }
20460
+ return changes;
20461
+ }
20462
+ };
20463
+ var ApplyTreeSaga = class extends GitSaga {
20464
+ sagaName = "ApplyTreeSaga";
20465
+ originalHead = null;
20466
+ originalBranch = null;
20467
+ extractedFiles = [];
20468
+ fileBackups = /* @__PURE__ */ new Map();
20469
+ async executeGitOperations(input) {
20470
+ const { baseDir, treeHash, baseCommit, changes, archivePath } = input;
20471
+ const headInfo = await this.readOnlyStep("get_current_head", async () => {
20472
+ let head = null;
20473
+ let branch = null;
20474
+ try {
20475
+ head = await this.git.revparse(["HEAD"]);
20476
+ } catch {
20477
+ head = null;
20534
20478
  }
20535
- const entry = {
20536
- type: "notification",
20537
- timestamp,
20538
- notification: message
20539
- };
20540
- this.writeToLocalCache(sessionId, entry);
20541
- if (this.posthogAPI) {
20542
- const pending = this.pendingEntries.get(sessionId) ?? [];
20543
- pending.push(entry);
20544
- this.pendingEntries.set(sessionId, pending);
20545
- this.scheduleFlush(sessionId);
20479
+ try {
20480
+ branch = await this.git.raw(["symbolic-ref", "--short", "HEAD"]);
20481
+ } catch {
20482
+ branch = null;
20546
20483
  }
20547
- } catch {
20548
- this.logger.warn("Failed to parse raw line for persistence", {
20549
- taskId: session.context.taskId,
20550
- runId: session.context.runId,
20551
- lineLength: line.length
20484
+ return { head, branch };
20485
+ });
20486
+ this.originalHead = headInfo.head;
20487
+ this.originalBranch = headInfo.branch;
20488
+ let checkoutPerformed = false;
20489
+ if (baseCommit && baseCommit !== this.originalHead) {
20490
+ await this.readOnlyStep("check_working_tree", async () => {
20491
+ const status = await this.git.status();
20492
+ if (!status.isClean()) {
20493
+ const changedFiles = status.modified.length + status.staged.length + status.deleted.length;
20494
+ throw new Error(`Cannot apply tree: ${changedFiles} uncommitted change(s) exist. Commit or stash your changes first.`);
20495
+ }
20496
+ });
20497
+ await this.step({
20498
+ name: "checkout_base",
20499
+ execute: async () => {
20500
+ await this.git.checkout(baseCommit);
20501
+ checkoutPerformed = true;
20502
+ this.log.warn("Applied tree from different commit - now in detached HEAD state", {
20503
+ originalHead: this.originalHead,
20504
+ originalBranch: this.originalBranch,
20505
+ baseCommit
20506
+ });
20507
+ },
20508
+ rollback: async () => {
20509
+ try {
20510
+ if (this.originalBranch) {
20511
+ await this.git.checkout(this.originalBranch);
20512
+ } else if (this.originalHead) {
20513
+ await this.git.checkout(this.originalHead);
20514
+ }
20515
+ } catch (error) {
20516
+ this.log.warn("Failed to rollback checkout", { error });
20517
+ }
20518
+ }
20519
+ });
20520
+ }
20521
+ if (archivePath) {
20522
+ const filesToExtract = changes.filter((c) => c.status !== "D").map((c) => c.path);
20523
+ await this.readOnlyStep("backup_existing_files", async () => {
20524
+ for (const filePath of filesToExtract) {
20525
+ const fullPath = path16.join(baseDir, filePath);
20526
+ try {
20527
+ const content = await fs13.readFile(fullPath);
20528
+ this.fileBackups.set(filePath, content);
20529
+ } catch {
20530
+ }
20531
+ }
20532
+ });
20533
+ await this.step({
20534
+ name: "extract_archive",
20535
+ execute: async () => {
20536
+ await tar.extract({
20537
+ file: archivePath,
20538
+ cwd: baseDir
20539
+ });
20540
+ this.extractedFiles = filesToExtract;
20541
+ },
20542
+ rollback: async () => {
20543
+ for (const filePath of this.extractedFiles) {
20544
+ const fullPath = path16.join(baseDir, filePath);
20545
+ const backup = this.fileBackups.get(filePath);
20546
+ if (backup) {
20547
+ const dir = path16.dirname(fullPath);
20548
+ await fs13.mkdir(dir, { recursive: true }).catch(() => {
20549
+ });
20550
+ await fs13.writeFile(fullPath, backup).catch(() => {
20551
+ });
20552
+ } else {
20553
+ await fs13.rm(fullPath, { force: true }).catch(() => {
20554
+ });
20555
+ }
20556
+ }
20557
+ }
20558
+ });
20559
+ }
20560
+ for (const change of changes.filter((c) => c.status === "D")) {
20561
+ const fullPath = path16.join(baseDir, change.path);
20562
+ const backupContent = await this.readOnlyStep(`backup_${change.path}`, async () => {
20563
+ try {
20564
+ return await fs13.readFile(fullPath);
20565
+ } catch {
20566
+ return null;
20567
+ }
20552
20568
  });
20569
+ await this.step({
20570
+ name: `delete_${change.path}`,
20571
+ execute: async () => {
20572
+ await fs13.rm(fullPath, { force: true });
20573
+ this.log.debug(`Deleted file: ${change.path}`);
20574
+ },
20575
+ rollback: async () => {
20576
+ if (backupContent) {
20577
+ const dir = path16.dirname(fullPath);
20578
+ await fs13.mkdir(dir, { recursive: true }).catch(() => {
20579
+ });
20580
+ await fs13.writeFile(fullPath, backupContent).catch(() => {
20581
+ });
20582
+ }
20583
+ }
20584
+ });
20585
+ }
20586
+ const deletedCount = changes.filter((c) => c.status === "D").length;
20587
+ this.log.info("Tree applied", {
20588
+ treeHash,
20589
+ totalChanges: changes.length,
20590
+ deletedFiles: deletedCount,
20591
+ checkoutPerformed
20592
+ });
20593
+ return { treeHash, checkoutPerformed };
20594
+ }
20595
+ };
20596
+
20597
+ // src/sagas/apply-snapshot-saga.ts
20598
+ var ApplySnapshotSaga = class extends Saga {
20599
+ sagaName = "ApplySnapshotSaga";
20600
+ archivePath = null;
20601
+ async execute(input) {
20602
+ const { snapshot, repositoryPath, apiClient, taskId, runId } = input;
20603
+ const tmpDir = (0, import_node_path8.join)(repositoryPath, ".posthog", "tmp");
20604
+ if (!snapshot.archiveUrl) {
20605
+ throw new Error("Cannot apply snapshot: no archive URL");
20553
20606
  }
20554
- }
20555
- async flush(sessionId, { coalesce = false } = {}) {
20556
- if (coalesce) {
20557
- const session = this.sessions.get(sessionId);
20558
- if (session) {
20559
- this.emitCoalescedMessage(sessionId, session);
20607
+ const archiveUrl = snapshot.archiveUrl;
20608
+ await this.step({
20609
+ name: "create_tmp_dir",
20610
+ execute: () => (0, import_promises5.mkdir)(tmpDir, { recursive: true }),
20611
+ rollback: async () => {
20560
20612
  }
20561
- }
20562
- const prev = this.flushQueues.get(sessionId) ?? Promise.resolve();
20563
- const next = prev.catch(() => {
20564
- }).then(() => this._doFlush(sessionId));
20565
- this.flushQueues.set(sessionId, next);
20566
- next.finally(() => {
20567
- if (this.flushQueues.get(sessionId) === next) {
20568
- this.flushQueues.delete(sessionId);
20613
+ });
20614
+ const archivePath = (0, import_node_path8.join)(tmpDir, `${snapshot.treeHash}.tar.gz`);
20615
+ this.archivePath = archivePath;
20616
+ await this.step({
20617
+ name: "download_archive",
20618
+ execute: async () => {
20619
+ const arrayBuffer = await apiClient.downloadArtifact(
20620
+ taskId,
20621
+ runId,
20622
+ archiveUrl
20623
+ );
20624
+ if (!arrayBuffer) {
20625
+ throw new Error("Failed to download archive");
20626
+ }
20627
+ const base64Content = Buffer.from(arrayBuffer).toString("utf-8");
20628
+ const binaryContent = Buffer.from(base64Content, "base64");
20629
+ await (0, import_promises5.writeFile)(archivePath, binaryContent);
20630
+ this.log.info("Tree archive downloaded", {
20631
+ treeHash: snapshot.treeHash,
20632
+ snapshotBytes: binaryContent.byteLength,
20633
+ snapshotWireBytes: arrayBuffer.byteLength,
20634
+ totalBytes: binaryContent.byteLength,
20635
+ totalWireBytes: arrayBuffer.byteLength
20636
+ });
20637
+ },
20638
+ rollback: async () => {
20639
+ if (this.archivePath) {
20640
+ await (0, import_promises5.rm)(this.archivePath, { force: true }).catch(() => {
20641
+ });
20642
+ }
20569
20643
  }
20570
20644
  });
20571
- return next;
20645
+ const gitApplySaga = new ApplyTreeSaga(this.log);
20646
+ const applyResult = await gitApplySaga.run({
20647
+ baseDir: repositoryPath,
20648
+ treeHash: snapshot.treeHash,
20649
+ baseCommit: snapshot.baseCommit,
20650
+ changes: snapshot.changes,
20651
+ archivePath: this.archivePath
20652
+ });
20653
+ if (!applyResult.success) {
20654
+ throw new Error(`Failed to apply tree: ${applyResult.error}`);
20655
+ }
20656
+ await (0, import_promises5.rm)(this.archivePath, { force: true }).catch(() => {
20657
+ });
20658
+ this.log.info("Tree snapshot applied", {
20659
+ treeHash: snapshot.treeHash,
20660
+ totalChanges: snapshot.changes.length,
20661
+ deletedFiles: snapshot.changes.filter((c) => c.status === "D").length
20662
+ });
20663
+ return { treeHash: snapshot.treeHash };
20572
20664
  }
20573
- async _doFlush(sessionId) {
20574
- const session = this.sessions.get(sessionId);
20575
- if (!session) {
20576
- this.logger.warn("flush: no session found", { sessionId });
20577
- return;
20665
+ };
20666
+
20667
+ // src/sagas/capture-tree-saga.ts
20668
+ var import_node_fs5 = require("fs");
20669
+ var import_promises6 = require("fs/promises");
20670
+ var import_node_path9 = require("path");
20671
+ var CaptureTreeSaga2 = class extends Saga {
20672
+ sagaName = "CaptureTreeSaga";
20673
+ async execute(input) {
20674
+ const {
20675
+ repositoryPath,
20676
+ lastTreeHash,
20677
+ interrupted,
20678
+ apiClient,
20679
+ taskId,
20680
+ runId
20681
+ } = input;
20682
+ const tmpDir = (0, import_node_path9.join)(repositoryPath, ".posthog", "tmp");
20683
+ if ((0, import_node_fs5.existsSync)((0, import_node_path9.join)(repositoryPath, ".gitmodules"))) {
20684
+ this.log.warn(
20685
+ "Repository has submodules - snapshot may not capture submodule state"
20686
+ );
20578
20687
  }
20579
- const pending = this.pendingEntries.get(sessionId);
20580
- if (!this.posthogAPI || !pending?.length) {
20581
- return;
20688
+ const shouldArchive = !!apiClient;
20689
+ const archivePath = shouldArchive ? (0, import_node_path9.join)(tmpDir, `tree-${Date.now()}.tar.gz`) : void 0;
20690
+ const gitCaptureSaga = new CaptureTreeSaga(this.log);
20691
+ const captureResult = await gitCaptureSaga.run({
20692
+ baseDir: repositoryPath,
20693
+ lastTreeHash,
20694
+ archivePath
20695
+ });
20696
+ if (!captureResult.success) {
20697
+ throw new Error(`Failed to capture tree: ${captureResult.error}`);
20582
20698
  }
20583
- this.pendingEntries.delete(sessionId);
20584
- const timeout = this.flushTimeouts.get(sessionId);
20585
- if (timeout) {
20586
- clearTimeout(timeout);
20587
- this.flushTimeouts.delete(sessionId);
20699
+ const {
20700
+ snapshot: gitSnapshot,
20701
+ archivePath: createdArchivePath,
20702
+ changed
20703
+ } = captureResult.data;
20704
+ if (!changed || !gitSnapshot) {
20705
+ this.log.debug("No changes since last capture", { lastTreeHash });
20706
+ return { snapshot: null, newTreeHash: lastTreeHash };
20588
20707
  }
20589
- this.lastFlushAttemptTime.set(sessionId, Date.now());
20590
- try {
20591
- await this.posthogAPI.appendTaskRunLog(
20592
- session.context.taskId,
20593
- session.context.runId,
20594
- pending
20595
- );
20596
- this.retryCounts.set(sessionId, 0);
20597
- } catch (error) {
20598
- const retryCount = (this.retryCounts.get(sessionId) ?? 0) + 1;
20599
- this.retryCounts.set(sessionId, retryCount);
20600
- if (retryCount >= _SessionLogWriter.MAX_FLUSH_RETRIES) {
20601
- this.logger.error(
20602
- `Dropping ${pending.length} session log entries after ${retryCount} failed flush attempts`,
20603
- {
20604
- taskId: session.context.taskId,
20605
- runId: session.context.runId,
20606
- error
20607
- }
20708
+ let archiveUrl;
20709
+ if (apiClient && createdArchivePath) {
20710
+ try {
20711
+ archiveUrl = await this.uploadArchive(
20712
+ createdArchivePath,
20713
+ gitSnapshot.treeHash,
20714
+ apiClient,
20715
+ taskId,
20716
+ runId
20608
20717
  );
20609
- this.retryCounts.set(sessionId, 0);
20610
- } else {
20611
- if (retryCount === 1) {
20612
- this.logger.warn(
20613
- `Failed to persist session logs, will retry (up to ${_SessionLogWriter.MAX_FLUSH_RETRIES} attempts)`,
20614
- {
20615
- taskId: session.context.taskId,
20616
- runId: session.context.runId,
20617
- error: error instanceof Error ? error.message : String(error)
20618
- }
20619
- );
20620
- }
20621
- const currentPending = this.pendingEntries.get(sessionId) ?? [];
20622
- this.pendingEntries.set(sessionId, [...pending, ...currentPending]);
20623
- this.scheduleFlush(sessionId);
20718
+ } finally {
20719
+ await (0, import_promises6.rm)(createdArchivePath, { force: true }).catch(() => {
20720
+ });
20624
20721
  }
20625
20722
  }
20723
+ const snapshot = {
20724
+ treeHash: gitSnapshot.treeHash,
20725
+ baseCommit: gitSnapshot.baseCommit,
20726
+ changes: gitSnapshot.changes,
20727
+ timestamp: gitSnapshot.timestamp,
20728
+ interrupted,
20729
+ archiveUrl
20730
+ };
20731
+ this.log.info("Tree captured", {
20732
+ treeHash: snapshot.treeHash,
20733
+ changes: snapshot.changes.length,
20734
+ interrupted,
20735
+ archiveUrl
20736
+ });
20737
+ return { snapshot, newTreeHash: snapshot.treeHash };
20626
20738
  }
20627
- getSessionUpdateType(message) {
20628
- if (message.method !== "session/update") return void 0;
20629
- const params = message.params;
20630
- const update = params?.update;
20631
- return update?.sessionUpdate;
20632
- }
20633
- isDirectAgentMessage(message) {
20634
- return this.getSessionUpdateType(message) === "agent_message";
20635
- }
20636
- isAgentMessageChunk(message) {
20637
- return this.getSessionUpdateType(message) === "agent_message_chunk";
20638
- }
20639
- extractChunkText(message) {
20640
- const params = message.params;
20641
- const update = params?.update;
20642
- const content = update?.content;
20643
- if (content?.type === "text" && content.text) {
20644
- return content.text;
20645
- }
20646
- return "";
20647
- }
20648
- emitCoalescedMessage(sessionId, session) {
20649
- if (!session.chunkBuffer) return;
20650
- const { text: text2, firstTimestamp } = session.chunkBuffer;
20651
- session.chunkBuffer = void 0;
20652
- session.lastAgentMessage = text2;
20653
- session.currentTurnMessages.push(text2);
20654
- const entry = {
20655
- type: "notification",
20656
- timestamp: firstTimestamp,
20657
- notification: {
20658
- jsonrpc: "2.0",
20659
- method: "session/update",
20660
- params: {
20661
- update: {
20662
- sessionUpdate: "agent_message",
20663
- content: { type: "text", text: text2 }
20739
+ async uploadArchive(archivePath, treeHash, apiClient, taskId, runId) {
20740
+ const archiveUrl = await this.step({
20741
+ name: "upload_archive",
20742
+ execute: async () => {
20743
+ const archiveContent = await (0, import_promises6.readFile)(archivePath);
20744
+ const base64Content = archiveContent.toString("base64");
20745
+ const snapshotBytes = archiveContent.byteLength;
20746
+ const snapshotWireBytes = Buffer.byteLength(base64Content, "utf-8");
20747
+ const artifacts = await apiClient.uploadTaskArtifacts(taskId, runId, [
20748
+ {
20749
+ name: `trees/${treeHash}.tar.gz`,
20750
+ type: "tree_snapshot",
20751
+ content: base64Content,
20752
+ content_type: "application/gzip"
20664
20753
  }
20754
+ ]);
20755
+ const uploadedArtifact = artifacts[0];
20756
+ if (uploadedArtifact?.storage_path) {
20757
+ this.log.info("Tree archive uploaded", {
20758
+ storagePath: uploadedArtifact.storage_path,
20759
+ treeHash,
20760
+ snapshotBytes,
20761
+ snapshotWireBytes,
20762
+ totalBytes: snapshotBytes,
20763
+ totalWireBytes: snapshotWireBytes
20764
+ });
20765
+ return uploadedArtifact.storage_path;
20665
20766
  }
20666
- }
20667
- };
20668
- this.writeToLocalCache(sessionId, entry);
20669
- if (this.posthogAPI) {
20670
- const pending = this.pendingEntries.get(sessionId) ?? [];
20671
- pending.push(entry);
20672
- this.pendingEntries.set(sessionId, pending);
20673
- this.scheduleFlush(sessionId);
20674
- }
20767
+ return void 0;
20768
+ },
20769
+ rollback: async () => {
20770
+ await (0, import_promises6.rm)(archivePath, { force: true }).catch(() => {
20771
+ });
20772
+ }
20773
+ });
20774
+ return archiveUrl;
20675
20775
  }
20676
- getLastAgentMessage(sessionId) {
20677
- return this.sessions.get(sessionId)?.lastAgentMessage;
20776
+ };
20777
+
20778
+ // src/tree-tracker.ts
20779
+ var TreeTracker = class {
20780
+ repositoryPath;
20781
+ taskId;
20782
+ runId;
20783
+ apiClient;
20784
+ logger;
20785
+ lastTreeHash = null;
20786
+ constructor(config) {
20787
+ this.repositoryPath = config.repositoryPath;
20788
+ this.taskId = config.taskId;
20789
+ this.runId = config.runId;
20790
+ this.apiClient = config.apiClient;
20791
+ this.logger = config.logger || new Logger({ debug: false, prefix: "[TreeTracker]" });
20678
20792
  }
20679
- getFullAgentResponse(sessionId) {
20680
- const session = this.sessions.get(sessionId);
20681
- if (!session || session.currentTurnMessages.length === 0) return void 0;
20682
- if (session.chunkBuffer) {
20683
- this.logger.warn(
20684
- "getFullAgentResponse called with non-empty chunk buffer",
20685
- {
20686
- sessionId,
20687
- bufferedLength: session.chunkBuffer.text.length
20688
- }
20793
+ /**
20794
+ * Capture current working tree state as a snapshot.
20795
+ * Uses a temporary index to avoid modifying user's staging area.
20796
+ * Uses Saga pattern for atomic operation with automatic cleanup on failure.
20797
+ */
20798
+ async captureTree(options) {
20799
+ const saga = new CaptureTreeSaga2(this.logger);
20800
+ const result = await saga.run({
20801
+ repositoryPath: this.repositoryPath,
20802
+ taskId: this.taskId,
20803
+ runId: this.runId,
20804
+ apiClient: this.apiClient,
20805
+ lastTreeHash: this.lastTreeHash,
20806
+ interrupted: options?.interrupted
20807
+ });
20808
+ if (!result.success) {
20809
+ this.logger.error("Failed to capture tree", {
20810
+ error: result.error,
20811
+ failedStep: result.failedStep
20812
+ });
20813
+ throw new Error(
20814
+ `Failed to capture tree at step '${result.failedStep}': ${result.error}`
20689
20815
  );
20690
20816
  }
20691
- return session.currentTurnMessages.join("\n\n");
20692
- }
20693
- resetTurnMessages(sessionId) {
20694
- const session = this.sessions.get(sessionId);
20695
- if (session) {
20696
- session.currentTurnMessages = [];
20817
+ if (result.data.newTreeHash !== null) {
20818
+ this.lastTreeHash = result.data.newTreeHash;
20697
20819
  }
20820
+ return result.data.snapshot;
20698
20821
  }
20699
- extractAgentMessageText(message) {
20700
- if (message.method !== "session/update") {
20701
- return null;
20702
- }
20703
- const params = message.params;
20704
- const update = params?.update;
20705
- if (update?.sessionUpdate !== "agent_message") {
20706
- return null;
20707
- }
20708
- const content = update.content;
20709
- if (content?.type === "text" && typeof content.text === "string") {
20710
- const trimmed2 = content.text.trim();
20711
- return trimmed2.length > 0 ? trimmed2 : null;
20822
+ /**
20823
+ * Download and apply a tree snapshot.
20824
+ * Uses Saga pattern for atomic operation with rollback on failure.
20825
+ */
20826
+ async applyTreeSnapshot(snapshot) {
20827
+ if (!this.apiClient) {
20828
+ throw new Error("Cannot apply snapshot: API client not configured");
20712
20829
  }
20713
- if (typeof update.message === "string") {
20714
- const trimmed2 = update.message.trim();
20715
- return trimmed2.length > 0 ? trimmed2 : null;
20830
+ if (!snapshot.archiveUrl) {
20831
+ this.logger.warn("Cannot apply snapshot: no archive URL", {
20832
+ treeHash: snapshot.treeHash,
20833
+ changes: snapshot.changes.length
20834
+ });
20835
+ throw new Error("Cannot apply snapshot: no archive URL");
20716
20836
  }
20717
- return null;
20718
- }
20719
- scheduleFlush(sessionId) {
20720
- const existing = this.flushTimeouts.get(sessionId);
20721
- if (existing) clearTimeout(existing);
20722
- const retryCount = this.retryCounts.get(sessionId) ?? 0;
20723
- const lastAttempt = this.lastFlushAttemptTime.get(sessionId) ?? 0;
20724
- const elapsed = Date.now() - lastAttempt;
20725
- let delay3;
20726
- if (retryCount > 0) {
20727
- delay3 = Math.min(
20728
- _SessionLogWriter.FLUSH_DEBOUNCE_MS * 2 ** retryCount,
20729
- _SessionLogWriter.MAX_RETRY_DELAY_MS
20837
+ const saga = new ApplySnapshotSaga(this.logger);
20838
+ const result = await saga.run({
20839
+ snapshot,
20840
+ repositoryPath: this.repositoryPath,
20841
+ apiClient: this.apiClient,
20842
+ taskId: this.taskId,
20843
+ runId: this.runId
20844
+ });
20845
+ if (!result.success) {
20846
+ this.logger.error("Failed to apply tree snapshot", {
20847
+ error: result.error,
20848
+ failedStep: result.failedStep,
20849
+ treeHash: snapshot.treeHash
20850
+ });
20851
+ throw new Error(
20852
+ `Failed to apply snapshot at step '${result.failedStep}': ${result.error}`
20730
20853
  );
20731
- } else if (elapsed >= _SessionLogWriter.FLUSH_MAX_INTERVAL_MS) {
20732
- delay3 = 0;
20733
- } else {
20734
- delay3 = _SessionLogWriter.FLUSH_DEBOUNCE_MS;
20735
20854
  }
20736
- const timeout = setTimeout(() => this.flush(sessionId), delay3);
20737
- this.flushTimeouts.set(sessionId, timeout);
20855
+ this.lastTreeHash = result.data.treeHash;
20738
20856
  }
20739
- writeToLocalCache(sessionId, entry) {
20740
- if (!this.localCachePath) return;
20741
- const session = this.sessions.get(sessionId);
20742
- if (!session) return;
20743
- const logPath = import_node_path9.default.join(
20744
- this.localCachePath,
20745
- "sessions",
20746
- session.context.runId,
20747
- "logs.ndjson"
20748
- );
20749
- try {
20750
- import_node_fs5.default.appendFileSync(logPath, `${JSON.stringify(entry)}
20751
- `);
20752
- } catch (error) {
20753
- this.logger.warn("Failed to write to local cache", {
20754
- taskId: session.context.taskId,
20755
- runId: session.context.runId,
20756
- logPath,
20757
- error
20758
- });
20759
- }
20857
+ /**
20858
+ * Get the last captured tree hash.
20859
+ */
20860
+ getLastTreeHash() {
20861
+ return this.lastTreeHash;
20760
20862
  }
20761
- static async cleanupOldSessions(localCachePath) {
20762
- const sessionsDir = import_node_path9.default.join(localCachePath, "sessions");
20763
- let deleted = 0;
20764
- try {
20765
- const entries = await import_promises6.default.readdir(sessionsDir);
20766
- const now = Date.now();
20767
- for (const entry of entries) {
20768
- const entryPath = import_node_path9.default.join(sessionsDir, entry);
20769
- try {
20770
- const stats = await import_promises6.default.stat(entryPath);
20771
- if (stats.isDirectory() && now - stats.birthtimeMs > _SessionLogWriter.SESSIONS_MAX_AGE_MS) {
20772
- await import_promises6.default.rm(entryPath, { recursive: true, force: true });
20773
- deleted++;
20774
- }
20775
- } catch {
20776
- }
20777
- }
20778
- } catch {
20779
- }
20780
- return deleted;
20863
+ /**
20864
+ * Set the last tree hash (used when resuming).
20865
+ */
20866
+ setLastTreeHash(hash) {
20867
+ this.lastTreeHash = hash;
20781
20868
  }
20782
20869
  };
20783
20870
 
@@ -21253,45 +21340,29 @@ var AgentServer = class {
21253
21340
  });
21254
21341
  await this.autoInitializeSession();
21255
21342
  }
21256
- async autoInitializeSession() {
21257
- const { taskId, runId, mode, projectId } = this.config;
21258
- this.logger.debug("Auto-initializing session", { taskId, runId, mode });
21259
- const resumeRunId = process.env.POSTHOG_RESUME_RUN_ID;
21260
- if (resumeRunId) {
21261
- this.logger.debug("Resuming from previous run", {
21262
- resumeRunId,
21263
- currentRunId: runId
21343
+ async loadResumeState(taskId, resumeRunId, currentRunId) {
21344
+ this.logger.debug("Loading resume state", { resumeRunId, currentRunId });
21345
+ try {
21346
+ this.resumeState = await resumeFromLog({
21347
+ taskId,
21348
+ runId: resumeRunId,
21349
+ repositoryPath: this.config.repositoryPath,
21350
+ apiClient: this.posthogAPI,
21351
+ logger: new Logger({ debug: true, prefix: "[Resume]" })
21264
21352
  });
21265
- try {
21266
- this.resumeState = await resumeFromLog({
21267
- taskId,
21268
- runId: resumeRunId,
21269
- repositoryPath: this.config.repositoryPath,
21270
- apiClient: this.posthogAPI,
21271
- logger: new Logger({ debug: true, prefix: "[Resume]" })
21272
- });
21273
- this.logger.debug("Resume state loaded", {
21274
- conversationTurns: this.resumeState.conversation.length,
21275
- snapshotApplied: this.resumeState.snapshotApplied,
21276
- logEntries: this.resumeState.logEntryCount
21277
- });
21278
- } catch (error) {
21279
- this.logger.debug("Failed to load resume state, starting fresh", {
21280
- error
21281
- });
21282
- this.resumeState = null;
21283
- }
21353
+ this.logger.debug("Resume state loaded", {
21354
+ conversationTurns: this.resumeState.conversation.length,
21355
+ hasSnapshot: !!this.resumeState.latestSnapshot,
21356
+ hasGitCheckpoint: !!this.resumeState.latestGitCheckpoint,
21357
+ gitCheckpointBranch: this.resumeState.latestGitCheckpoint?.branch ?? null,
21358
+ logEntries: this.resumeState.logEntryCount
21359
+ });
21360
+ } catch (error) {
21361
+ this.logger.debug("Failed to load resume state, starting fresh", {
21362
+ error
21363
+ });
21364
+ this.resumeState = null;
21284
21365
  }
21285
- const payload = {
21286
- task_id: taskId,
21287
- run_id: runId,
21288
- team_id: projectId,
21289
- user_id: 0,
21290
- // System-initiated
21291
- distinct_id: "agent-server",
21292
- mode
21293
- };
21294
- await this.initializeSession(payload, null);
21295
21366
  }
21296
21367
  async stop() {
21297
21368
  this.logger.debug("Stopping agent server...");
@@ -21690,29 +21761,11 @@ var AgentServer = class {
21690
21761
  if (!this.resumeState) {
21691
21762
  const resumeRunId = this.getResumeRunId(taskRun);
21692
21763
  if (resumeRunId) {
21693
- this.logger.debug("Resuming from previous run (via TaskRun state)", {
21764
+ await this.loadResumeState(
21765
+ payload.task_id,
21694
21766
  resumeRunId,
21695
- currentRunId: payload.run_id
21696
- });
21697
- try {
21698
- this.resumeState = await resumeFromLog({
21699
- taskId: payload.task_id,
21700
- runId: resumeRunId,
21701
- repositoryPath: this.config.repositoryPath,
21702
- apiClient: this.posthogAPI,
21703
- logger: new Logger({ debug: true, prefix: "[Resume]" })
21704
- });
21705
- this.logger.debug("Resume state loaded (via TaskRun state)", {
21706
- conversationTurns: this.resumeState.conversation.length,
21707
- snapshotApplied: this.resumeState.snapshotApplied,
21708
- logEntries: this.resumeState.logEntryCount
21709
- });
21710
- } catch (error) {
21711
- this.logger.debug("Failed to load resume state, starting fresh", {
21712
- error
21713
- });
21714
- this.resumeState = null;
21715
- }
21767
+ payload.run_id
21768
+ );
21716
21769
  }
21717
21770
  }
21718
21771
  if (this.resumeState && this.resumeState.conversation.length > 0) {
@@ -21771,8 +21824,59 @@ var AgentServer = class {
21771
21824
  const conversationSummary = formatConversationForResume(
21772
21825
  this.resumeState.conversation
21773
21826
  );
21827
+ let snapshotApplied = false;
21828
+ if (this.resumeState.latestSnapshot?.archiveUrl && this.config.repositoryPath && this.posthogAPI) {
21829
+ try {
21830
+ const treeTracker = new TreeTracker({
21831
+ repositoryPath: this.config.repositoryPath,
21832
+ taskId: payload.task_id,
21833
+ runId: payload.run_id,
21834
+ apiClient: this.posthogAPI,
21835
+ logger: this.logger.child("TreeTracker")
21836
+ });
21837
+ await treeTracker.applyTreeSnapshot(this.resumeState.latestSnapshot);
21838
+ treeTracker.setLastTreeHash(this.resumeState.latestSnapshot.treeHash);
21839
+ snapshotApplied = true;
21840
+ this.logger.info("Tree snapshot applied", {
21841
+ treeHash: this.resumeState.latestSnapshot.treeHash,
21842
+ changes: this.resumeState.latestSnapshot.changes?.length ?? 0,
21843
+ hasArchiveUrl: !!this.resumeState.latestSnapshot.archiveUrl
21844
+ });
21845
+ } catch (error) {
21846
+ this.logger.warn("Failed to apply tree snapshot", {
21847
+ error: error instanceof Error ? error.message : String(error),
21848
+ treeHash: this.resumeState.latestSnapshot.treeHash
21849
+ });
21850
+ }
21851
+ }
21852
+ if (this.resumeState.latestGitCheckpoint && this.config.repositoryPath && this.posthogAPI) {
21853
+ try {
21854
+ const checkpointTracker = new HandoffCheckpointTracker({
21855
+ repositoryPath: this.config.repositoryPath,
21856
+ taskId: payload.task_id,
21857
+ runId: payload.run_id,
21858
+ apiClient: this.posthogAPI,
21859
+ logger: this.logger.child("HandoffCheckpoint")
21860
+ });
21861
+ const metrics = await checkpointTracker.applyFromHandoff(
21862
+ this.resumeState.latestGitCheckpoint
21863
+ );
21864
+ this.logger.info("Git checkpoint applied", {
21865
+ branch: this.resumeState.latestGitCheckpoint.branch,
21866
+ head: this.resumeState.latestGitCheckpoint.head,
21867
+ packBytes: metrics.packBytes,
21868
+ indexBytes: metrics.indexBytes,
21869
+ totalBytes: metrics.totalBytes
21870
+ });
21871
+ } catch (error) {
21872
+ this.logger.warn("Failed to apply git checkpoint", {
21873
+ error: error instanceof Error ? error.message : String(error),
21874
+ branch: this.resumeState.latestGitCheckpoint.branch
21875
+ });
21876
+ }
21877
+ }
21774
21878
  const pendingUserPrompt = await this.getPendingUserPrompt(taskRun);
21775
- 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.`;
21879
+ 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.`;
21776
21880
  let resumePromptBlocks;
21777
21881
  if (pendingUserPrompt?.length) {
21778
21882
  resumePromptBlocks = [
@@ -21813,7 +21917,9 @@ Continue from where you left off. The user is waiting for your response.`
21813
21917
  conversationTurns: this.resumeState.conversation.length,
21814
21918
  promptLength: promptBlocksToText(resumePromptBlocks).length,
21815
21919
  hasPendingUserMessage: !!pendingUserPrompt?.length,
21816
- snapshotApplied: this.resumeState.snapshotApplied
21920
+ snapshotApplied,
21921
+ hasGitCheckpoint: !!this.resumeState.latestGitCheckpoint,
21922
+ gitCheckpointBranch: this.resumeState.latestGitCheckpoint?.branch ?? null
21817
21923
  });
21818
21924
  this.resumeState = null;
21819
21925
  this.session.logWriter.resetTurnMessages(payload.run_id);
@@ -21977,6 +22083,24 @@ Continue from where you left off. The user is waiting for your response.`
21977
22083
  const normalizedName = baseName.replace(/[^\w.-]/g, "_");
21978
22084
  return normalizedName.length > 0 ? normalizedName : "attachment";
21979
22085
  }
22086
+ async autoInitializeSession() {
22087
+ const { taskId, runId, mode, projectId } = this.config;
22088
+ this.logger.debug("Auto-initializing session", { taskId, runId, mode });
22089
+ const resumeRunId = process.env.POSTHOG_RESUME_RUN_ID;
22090
+ if (resumeRunId) {
22091
+ await this.loadResumeState(taskId, resumeRunId, runId);
22092
+ }
22093
+ const payload = {
22094
+ task_id: taskId,
22095
+ run_id: runId,
22096
+ team_id: projectId,
22097
+ user_id: 0,
22098
+ // System-initiated
22099
+ distinct_id: "agent-server",
22100
+ mode
22101
+ };
22102
+ await this.initializeSession(payload, null);
22103
+ }
21980
22104
  getResumeRunId(taskRun) {
21981
22105
  const envRunId = process.env.POSTHOG_RESUME_RUN_ID;
21982
22106
  if (envRunId) return envRunId;