@kenkaiiii/ggcoder 5.29.1 → 5.30.0

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 (124) hide show
  1. package/dist/app-sidecar-auth-broadcast.test.js +14 -3
  2. package/dist/app-sidecar-auth-broadcast.test.js.map +1 -1
  3. package/dist/app-sidecar.js +27 -20
  4. package/dist/app-sidecar.js.map +1 -1
  5. package/dist/cli/command-routing.d.ts +1 -1
  6. package/dist/cli/command-routing.d.ts.map +1 -1
  7. package/dist/cli/command-routing.js +1 -0
  8. package/dist/cli/command-routing.js.map +1 -1
  9. package/dist/cli/command-routing.test.js +1 -0
  10. package/dist/cli/command-routing.test.js.map +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +167 -47
  13. package/dist/cli.js.map +1 -1
  14. package/dist/config.d.ts +2 -0
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +10 -0
  17. package/dist/config.js.map +1 -1
  18. package/dist/config.test.js +10 -1
  19. package/dist/config.test.js.map +1 -1
  20. package/dist/core/agent-session-compaction.test.js +240 -2
  21. package/dist/core/agent-session-compaction.test.js.map +1 -1
  22. package/dist/core/agent-session-crash-durability.test.js +9 -2
  23. package/dist/core/agent-session-crash-durability.test.js.map +1 -1
  24. package/dist/core/agent-session-process-gate.test.js +5 -1
  25. package/dist/core/agent-session-process-gate.test.js.map +1 -1
  26. package/dist/core/agent-session-review-coverage.test.js +5 -1
  27. package/dist/core/agent-session-review-coverage.test.js.map +1 -1
  28. package/dist/core/agent-session.d.ts +10 -3
  29. package/dist/core/agent-session.d.ts.map +1 -1
  30. package/dist/core/agent-session.js +276 -130
  31. package/dist/core/agent-session.js.map +1 -1
  32. package/dist/core/compaction/active-context.test.js +21 -0
  33. package/dist/core/compaction/active-context.test.js.map +1 -1
  34. package/dist/core/compaction/compactor.d.ts +24 -6
  35. package/dist/core/compaction/compactor.d.ts.map +1 -1
  36. package/dist/core/compaction/compactor.js +246 -98
  37. package/dist/core/compaction/compactor.js.map +1 -1
  38. package/dist/core/compaction/compactor.test.js +107 -8
  39. package/dist/core/compaction/compactor.test.js.map +1 -1
  40. package/dist/core/compaction/policy.d.ts +17 -0
  41. package/dist/core/compaction/policy.d.ts.map +1 -0
  42. package/dist/core/compaction/policy.js +22 -0
  43. package/dist/core/compaction/policy.js.map +1 -0
  44. package/dist/core/compaction/policy.test.d.ts +2 -0
  45. package/dist/core/compaction/policy.test.d.ts.map +1 -0
  46. package/dist/core/compaction/policy.test.js +35 -0
  47. package/dist/core/compaction/policy.test.js.map +1 -0
  48. package/dist/core/foreign-session-import.d.ts.map +1 -1
  49. package/dist/core/foreign-session-import.js +5 -1
  50. package/dist/core/foreign-session-import.js.map +1 -1
  51. package/dist/core/ideal-review.d.ts.map +1 -1
  52. package/dist/core/ideal-review.js +4 -0
  53. package/dist/core/ideal-review.js.map +1 -1
  54. package/dist/core/loop-breaker.d.ts.map +1 -1
  55. package/dist/core/loop-breaker.js +1 -0
  56. package/dist/core/loop-breaker.js.map +1 -1
  57. package/dist/core/process-gate.d.ts.map +1 -1
  58. package/dist/core/process-gate.js +9 -1
  59. package/dist/core/process-gate.js.map +1 -1
  60. package/dist/core/process-manager-notifications.test.js +4 -2
  61. package/dist/core/process-manager-notifications.test.js.map +1 -1
  62. package/dist/core/regrounding.d.ts.map +1 -1
  63. package/dist/core/regrounding.js +1 -0
  64. package/dist/core/regrounding.js.map +1 -1
  65. package/dist/core/session-compaction.d.ts +5 -0
  66. package/dist/core/session-compaction.d.ts.map +1 -1
  67. package/dist/core/session-compaction.js +10 -1
  68. package/dist/core/session-compaction.js.map +1 -1
  69. package/dist/core/session-compaction.test.js +16 -1
  70. package/dist/core/session-compaction.test.js.map +1 -1
  71. package/dist/core/session-export.d.ts.map +1 -1
  72. package/dist/core/session-export.js +11 -8
  73. package/dist/core/session-export.js.map +1 -1
  74. package/dist/core/session-history.d.ts +7 -2
  75. package/dist/core/session-history.d.ts.map +1 -1
  76. package/dist/core/session-history.js +52 -9
  77. package/dist/core/session-history.js.map +1 -1
  78. package/dist/core/session-history.test.js +26 -1
  79. package/dist/core/session-history.test.js.map +1 -1
  80. package/dist/core/session-manager.d.ts +106 -0
  81. package/dist/core/session-manager.d.ts.map +1 -1
  82. package/dist/core/session-manager.js +316 -15
  83. package/dist/core/session-manager.js.map +1 -1
  84. package/dist/core/session-manager.test.js +109 -1
  85. package/dist/core/session-manager.test.js.map +1 -1
  86. package/dist/core/session-notification-visibility.test.js +34 -0
  87. package/dist/core/session-notification-visibility.test.js.map +1 -1
  88. package/dist/core/session-preview.d.ts +2 -2
  89. package/dist/core/session-preview.d.ts.map +1 -1
  90. package/dist/core/session-preview.js +8 -5
  91. package/dist/core/session-preview.js.map +1 -1
  92. package/dist/core/session-restore-display.test.js +25 -0
  93. package/dist/core/session-restore-display.test.js.map +1 -1
  94. package/dist/core/session-storage.d.ts.map +1 -1
  95. package/dist/core/session-storage.js +39 -3
  96. package/dist/core/session-storage.js.map +1 -1
  97. package/dist/core/subagent-manager.d.ts.map +1 -1
  98. package/dist/core/subagent-manager.js +9 -1
  99. package/dist/core/subagent-manager.js.map +1 -1
  100. package/dist/core/subagent-manager.test.js +4 -3
  101. package/dist/core/subagent-manager.test.js.map +1 -1
  102. package/dist/interactive.d.ts.map +1 -1
  103. package/dist/interactive.js +17 -3
  104. package/dist/interactive.js.map +1 -1
  105. package/dist/modes/acp-mode.d.ts +82 -0
  106. package/dist/modes/acp-mode.d.ts.map +1 -0
  107. package/dist/modes/acp-mode.js +802 -0
  108. package/dist/modes/acp-mode.js.map +1 -0
  109. package/dist/modes/acp-mode.test.d.ts +2 -0
  110. package/dist/modes/acp-mode.test.d.ts.map +1 -0
  111. package/dist/modes/acp-mode.test.js +611 -0
  112. package/dist/modes/acp-mode.test.js.map +1 -0
  113. package/dist/modes/index.d.ts +1 -0
  114. package/dist/modes/index.d.ts.map +1 -1
  115. package/dist/modes/index.js +1 -0
  116. package/dist/modes/index.js.map +1 -1
  117. package/dist/session.d.ts +24 -0
  118. package/dist/session.d.ts.map +1 -1
  119. package/dist/session.js +32 -0
  120. package/dist/session.js.map +1 -1
  121. package/dist/tools/web-fetch.d.ts +1 -1
  122. package/dist/types.d.ts +7 -0
  123. package/dist/types.d.ts.map +1 -1
  124. package/package.json +4 -4
@@ -11,8 +11,9 @@ import { getClaudeCliUserAgent } from "./claude-code-version.js";
11
11
  import { kimiCodingHeaders, isKimiCodingEndpoint } from "./oauth/kimi.js";
12
12
  import { SessionManager, KEN_TURN_CUSTOM_KIND, AUTOPILOT_MARKER_CUSTOM_KIND, APP_MARKER_CUSTOM_KIND, } from "./session-manager.js";
13
13
  import { ExtensionLoader } from "./extensions/loader.js";
14
- import { shouldCompact, compact } from "./compaction/compactor.js";
14
+ import { shouldCompact, compact, } from "./compaction/compactor.js";
15
15
  import { remapAnchorForCompaction, stripRecordedPosition } from "./session-history.js";
16
+ import { sourceFingerprint as computeSourceFingerprint } from "./session-compaction.js";
16
17
  import { getAuthStorageKeys, getContextWindow, getModel, getToolResultCharLimit, MODELS, } from "./model-registry.js";
17
18
  import { discoverSkills } from "./skills.js";
18
19
  import { ensureAppDirs } from "../config.js";
@@ -30,6 +31,7 @@ import { createToolSearchTool } from "../tools/tool-search.js";
30
31
  import { log } from "./logger.js";
31
32
  import { setEstimatorModel, calibrateEstimatorFromUsage } from "./compaction/token-estimator.js";
32
33
  import { calculateActiveContextTokens } from "./compaction/active-context.js";
34
+ import { resolveCompactionPolicy } from "./compaction/policy.js";
33
35
  import { pruneStaleToolResults } from "./compaction/tool-result-pruner.js";
34
36
  import { discoverAgents } from "./agents.js";
35
37
  import { enhancePrompt } from "../utils/prompt-enhancer.js";
@@ -149,6 +151,8 @@ export class AgentSession {
149
151
  compactionOccurred = false;
150
152
  lastCompactionCompacted = false;
151
153
  compactionRetryAfter = 0;
154
+ /** A restored oversized checkpoint must be canonicalized before its first prompt is persisted. */
155
+ deferredCompactionPending = false;
152
156
  /** Latest provider count, anchored to the assistant response it measured. */
153
157
  providerContext = null;
154
158
  originalRequest = "";
@@ -206,6 +210,7 @@ export class AgentSession {
206
210
  /** Extra workspace roots added with `/add-dir` (resolved, de-duplicated). */
207
211
  additionalRoots = [];
208
212
  sessionId = "";
213
+ checkpointGeneration = 0;
209
214
  /** Stable identity shared by compaction and approved-plan checkpoint files. */
210
215
  conversationId = "";
211
216
  /** Original user-authored prompt, retained when internal messages replace history. */
@@ -753,12 +758,16 @@ export class AgentSession {
753
758
  /**
754
759
  * Process user input. Handles slash commands or runs agent loop.
755
760
  */
756
- async prompt(content) {
761
+ async prompt(content, provenance = {
762
+ source: "human",
763
+ kind: "prompt",
764
+ visibility: "transcript",
765
+ }) {
766
+ await this.adoptDeferredCheckpointBeforePrompt();
757
767
  const slash = await this.resolveSlashInput(content);
758
768
  if (slash?.kind === "template") {
759
- // Inject the prompt-template command as a user message to the agent, then
760
- // run as a normal prompt (push message + agent loop).
761
- const userMessage = { role: "user", content: slash.fullPrompt };
769
+ // Prompt templates remain human-originated: the user invoked the command.
770
+ const userMessage = { role: "user", content: slash.fullPrompt, provenance };
762
771
  this.messages.push(userMessage);
763
772
  await this.persistMessage(userMessage);
764
773
  this.lastPersistedIndex = this.messages.length;
@@ -774,7 +783,7 @@ export class AgentSession {
774
783
  return;
775
784
  }
776
785
  // Push user message
777
- const userMessage = { role: "user", content };
786
+ const userMessage = { role: "user", content, provenance };
778
787
  this.messages.push(userMessage);
779
788
  await this.persistMessage(userMessage);
780
789
  this.lastPersistedIndex = this.messages.length;
@@ -788,10 +797,15 @@ export class AgentSession {
788
797
  * attachments are always a direct conversational turn.
789
798
  */
790
799
  async promptWithAttachments(text, attachments) {
800
+ await this.adoptDeferredCheckpointBeforePrompt();
791
801
  const parts = this.buildAttachmentParts(text, attachments);
792
802
  if (parts.length === 0)
793
803
  return;
794
- const userMessage = { role: "user", content: parts };
804
+ const userMessage = {
805
+ role: "user",
806
+ content: parts,
807
+ provenance: { source: "human", kind: "prompt", visibility: "transcript" },
808
+ };
795
809
  this.messages.push(userMessage);
796
810
  await this.persistMessage(userMessage);
797
811
  this.lastPersistedIndex = this.messages.length;
@@ -993,6 +1007,7 @@ export class AgentSession {
993
1007
  ? {
994
1008
  role: "user",
995
1009
  content: buildNotificationSteeringText(notified.map((entry) => entry.text)),
1010
+ provenance: { source: "runtime", kind: "notification", visibility: "hidden" },
996
1011
  }
997
1012
  : null;
998
1013
  if (notificationMessage) {
@@ -1016,8 +1031,13 @@ export class AgentSession {
1016
1031
  // (not merged): each persists as its own user message, so a resumed
1017
1032
  // session shows the same number of bubbles the live run did.
1018
1033
  const steeringMessages = queued.map((m) => {
1034
+ const provenance = {
1035
+ source: "human",
1036
+ kind: "steering",
1037
+ visibility: "transcript",
1038
+ };
1019
1039
  if (m.attachments.length === 0) {
1020
- return { role: "user", content: wrapSteeringText(m.text) };
1040
+ return { role: "user", content: wrapSteeringText(m.text), provenance };
1021
1041
  }
1022
1042
  // Queued attachments ride the same native-block path as a non-queued
1023
1043
  // attachment prompt, prefixed with the steering framing.
@@ -1025,7 +1045,7 @@ export class AgentSession {
1025
1045
  { type: "text", text: STEERING_PREFIX },
1026
1046
  ...this.buildAttachmentParts(m.text, m.attachments),
1027
1047
  ];
1028
- return { role: "user", content: parts };
1048
+ return { role: "user", content: parts, provenance };
1029
1049
  });
1030
1050
  return notificationMessage ? [...steeringMessages, notificationMessage] : steeringMessages;
1031
1051
  }
@@ -1207,7 +1227,11 @@ export class AgentSession {
1207
1227
  : []),
1208
1228
  "Do not describe those files as compiler-clean without other evidence.",
1209
1229
  ];
1210
- return { role: "user", content: `${String(message.content)}\n\n${notes.join(" ")}` };
1230
+ return {
1231
+ role: "user",
1232
+ provenance: message.provenance,
1233
+ content: `${String(message.content)}\n\n${notes.join(" ")}`,
1234
+ };
1211
1235
  }
1212
1236
  /** Auto-compact if needed, run agent loop with auth retry, and persist messages. */
1213
1237
  async runLoop() {
@@ -1247,7 +1271,14 @@ export class AgentSession {
1247
1271
  provider: this.provider,
1248
1272
  accountId: creds.accountId,
1249
1273
  });
1250
- const threshold = this.settingsManager.get("compactThreshold");
1274
+ const policy = resolveCompactionPolicy({
1275
+ provider: this.provider,
1276
+ model: this.model,
1277
+ contextWindow,
1278
+ threshold: this.settingsManager.get("compactThreshold"),
1279
+ accountId: creds.accountId,
1280
+ approvedPlanPath: this.approvedPlanPath,
1281
+ });
1251
1282
  let activeTokens;
1252
1283
  if (this.providerContext) {
1253
1284
  const anchorIndex = this.messages.lastIndexOf(this.providerContext.anchor);
@@ -1261,9 +1292,17 @@ export class AgentSession {
1261
1292
  this.providerContext = null;
1262
1293
  }
1263
1294
  }
1264
- if (shouldCompact(this.messages, contextWindow, threshold, activeTokens)) {
1295
+ log("INFO", "compaction", "Pre-run compaction decision", {
1296
+ provider: this.provider,
1297
+ model: this.model,
1298
+ transport: this.provider === "openai" && creds.accountId ? "codex_oauth" : "public_api",
1299
+ contextWindow: String(contextWindow),
1300
+ activeTokens: activeTokens === undefined ? "estimated" : String(activeTokens),
1301
+ triggerLimit: String(policy.targetTokens),
1302
+ });
1303
+ if (shouldCompact(this.messages, contextWindow, policy.threshold, activeTokens)) {
1265
1304
  try {
1266
- await this.compact(creds);
1305
+ await this.compact(creds, "automatic");
1267
1306
  if (this.lastCompactionCompacted) {
1268
1307
  // Re-grounding hook keys off this — the context was just summarized.
1269
1308
  this.compactionOccurred = true;
@@ -1373,12 +1412,27 @@ export class AgentSession {
1373
1412
  provider: this.provider,
1374
1413
  accountId,
1375
1414
  });
1376
- const threshold = this.settingsManager.get("compactThreshold");
1415
+ const policy = resolveCompactionPolicy({
1416
+ provider: this.provider,
1417
+ model: this.model,
1418
+ contextWindow,
1419
+ threshold: this.settingsManager.get("compactThreshold"),
1420
+ accountId,
1421
+ approvedPlanPath: this.approvedPlanPath,
1422
+ });
1377
1423
  const activeTokens = calculateActiveContextTokens(messages, {
1378
1424
  usage,
1379
1425
  pendingMessages,
1380
1426
  });
1381
- if (!shouldCompact(messages, contextWindow, threshold, activeTokens))
1427
+ log("INFO", "compaction", "In-flight compaction decision", {
1428
+ provider: this.provider,
1429
+ model: this.model,
1430
+ transport: this.provider === "openai" && accountId ? "codex_oauth" : "public_api",
1431
+ contextWindow: String(contextWindow),
1432
+ activeTokens: String(activeTokens),
1433
+ triggerLimit: String(policy.targetTokens),
1434
+ });
1435
+ if (!shouldCompact(messages, contextWindow, policy.threshold, activeTokens))
1382
1436
  return messages;
1383
1437
  }
1384
1438
  // compact() operates on this.messages, while an earlier transform may
@@ -1391,7 +1445,7 @@ export class AgentSession {
1391
1445
  accountId,
1392
1446
  projectId,
1393
1447
  baseUrl: effectiveBaseUrl,
1394
- });
1448
+ }, force ? "forced" : "automatic");
1395
1449
  }
1396
1450
  catch (error) {
1397
1451
  this.messages = messages;
@@ -1640,9 +1694,66 @@ export class AgentSession {
1640
1694
  this.messages.push({
1641
1695
  role: "user",
1642
1696
  content: `[Model switched from ${from} to ${to}. Everything above was produced under the previous model; continue the current task from here.]`,
1697
+ provenance: { source: "runtime", kind: "model_switch", visibility: "hidden" },
1643
1698
  });
1644
1699
  }
1645
- async compact(existingCredentials) {
1700
+ async adoptCompactionCheckpoint(loaded) {
1701
+ const systemMessage = this.messages[0];
1702
+ const loadedMessages = this.sessionManager.getMessages(loaded.entries, loaded.header.leafId);
1703
+ this.messages = [systemMessage, ...loadedMessages];
1704
+ this.sessionId = loaded.header.id;
1705
+ this.conversationId = loaded.header.conversationId ?? loaded.header.id;
1706
+ this.checkpointGeneration = loaded.header.generation ?? 0;
1707
+ this.currentLeafId = loaded.header.leafId;
1708
+ this.setSessionPath(loaded.path);
1709
+ this.kenTurns = this.sessionManager.getKenTurns(loaded.entries, loaded.header.leafId);
1710
+ this.autopilotMarkers = this.sessionManager.getAutopilotMarkers(loaded.entries, loaded.header.leafId);
1711
+ this.appMarkers = this.sessionManager.getAppMarkers(loaded.entries, loaded.header.leafId);
1712
+ this.turnMetrics = this.sessionManager.getTurnMetrics(loaded.entries);
1713
+ this.lastPersistedIndex = this.messages.length;
1714
+ this.providerContext = null;
1715
+ await this.subAgentManager?.rebindParentSession(this.sessionId);
1716
+ }
1717
+ /** Canonicalize a deferred restore before a new prompt can fork stale history. */
1718
+ async adoptDeferredCheckpointBeforePrompt() {
1719
+ if (!this.deferredCompactionPending || !this.conversationId)
1720
+ return;
1721
+ this.deferredCompactionPending = false;
1722
+ const canonicalPath = await this.sessionManager.resolveCanonicalSession(this.conversationId, this.cwd);
1723
+ if (!canonicalPath || canonicalPath === this.sessionPath)
1724
+ return;
1725
+ await this.adoptCompactionCheckpoint(await this.sessionManager.load(canonicalPath));
1726
+ }
1727
+ async persistCompactionCheckpoint(sourceFingerprint, result) {
1728
+ const parentSessionId = this.sessionId || undefined;
1729
+ const session = await this.sessionManager.create(this.cwd, this.provider, this.model, {
1730
+ conversationId: this.conversationId || undefined,
1731
+ generation: this.checkpointGeneration + 1,
1732
+ parentSessionId,
1733
+ sourceFingerprint,
1734
+ preview: this.sessionPreview || undefined,
1735
+ });
1736
+ this.sessionId = session.id;
1737
+ this.checkpointGeneration = session.header.generation ?? 0;
1738
+ this.conversationId = session.header.conversationId ?? session.id;
1739
+ this.currentLeafId = null;
1740
+ this.setSessionPath(session.path);
1741
+ await this.subAgentManager?.rebindParentSession(this.sessionId);
1742
+ for (const message of this.messages) {
1743
+ if (message.role !== "system")
1744
+ await this.persistMessage(message);
1745
+ }
1746
+ this.lastPersistedIndex = this.messages.length;
1747
+ await this.rePersistTurnMetrics();
1748
+ await this.rePersistKenTurns();
1749
+ await this.rePersistAutopilotMarkers();
1750
+ await this.rePersistAppMarkers();
1751
+ await this.persistAppMarker("compaction", {
1752
+ originalCount: result.originalCount,
1753
+ newCount: result.newCount,
1754
+ });
1755
+ }
1756
+ async compact(existingCredentials, mode = "manual") {
1646
1757
  this.lastCompactionCompacted = false;
1647
1758
  const creds = existingCredentials ??
1648
1759
  (await this.authStorage.resolveCredentials(this.provider, {
@@ -1652,8 +1763,17 @@ export class AgentSession {
1652
1763
  provider: this.provider,
1653
1764
  accountId: creds.accountId,
1654
1765
  });
1655
- this.eventBus.emit("compaction_start", { messageCount: this.messages.length });
1656
- const result = await compact(this.messages, {
1766
+ const policy = resolveCompactionPolicy({
1767
+ provider: this.provider,
1768
+ model: this.model,
1769
+ contextWindow,
1770
+ threshold: this.settingsManager.get("compactThreshold"),
1771
+ accountId: creds.accountId,
1772
+ approvedPlanPath: this.approvedPlanPath,
1773
+ });
1774
+ const originalCount = this.messages.length;
1775
+ this.eventBus.emit("compaction_start", { messageCount: originalCount });
1776
+ const runCompactor = () => compact(this.messages, {
1657
1777
  provider: this.provider,
1658
1778
  model: this.model,
1659
1779
  apiKey: creds.accessToken,
@@ -1661,67 +1781,106 @@ export class AgentSession {
1661
1781
  projectId: creds.projectId,
1662
1782
  baseUrl: this.baseUrl ?? creds.baseUrl,
1663
1783
  contextWindow,
1784
+ targetTokens: policy.targetTokens,
1664
1785
  signal: this.opts.signal,
1786
+ approvedPlanPath: this.approvedPlanPath,
1665
1787
  });
1666
- this.messages = result.messages;
1667
- this.lastCompactionCompacted = result.result.compacted;
1668
- if (!result.result.compacted) {
1669
- this.eventBus.emit("compaction_end", {
1670
- compacted: false,
1671
- originalCount: result.result.originalCount,
1672
- newCount: result.result.newCount,
1673
- });
1674
- return;
1675
- }
1676
- this.providerContext = null;
1677
- // Compaction rewrote the message list, so every transcript anchor recorded
1678
- // against the old indices must move with it — otherwise the markers are
1679
- // re-persisted pointing at positions that no longer mean anything and
1680
- // replay far below where they happened (or past the end).
1681
- this.remapMarkerAnchors(result.result.anchorRemap);
1682
- // Transient sessions (Ken chat/autopilot, subagent spawns) must NEVER touch
1683
- // the session store: without this guard, the first auto-compaction called
1684
- // sessionManager.create() and assigned a real sessionPath, silently turning
1685
- // the "in-memory only" session into a persisted one — every later turn (and
1686
- // every further compaction) then leaked a Ken transcript file into the
1687
- // project's session list. Compact in memory only and keep sessionPath empty.
1688
- if (this.opts.transient) {
1689
- this.lastPersistedIndex = this.messages.length;
1788
+ let finalCount = originalCount;
1789
+ if (this.opts.transient || !this.conversationId) {
1790
+ const result = await runCompactor();
1791
+ finalCount = result.result.newCount;
1792
+ this.messages = result.messages;
1793
+ this.lastCompactionCompacted = result.result.compacted;
1794
+ if (result.result.compacted) {
1795
+ this.providerContext = null;
1796
+ this.remapMarkerAnchors(result.result.anchorRemap);
1797
+ this.lastPersistedIndex = this.messages.length;
1798
+ }
1690
1799
  }
1691
1800
  else {
1692
- // Persist compacted messages to a new session file so `ggcoder continue`
1693
- // picks up the compacted state instead of the full original history.
1694
- const session = await this.sessionManager.create(this.cwd, this.provider, this.model, {
1695
- conversationId: this.conversationId || undefined,
1696
- preview: this.sessionPreview || undefined,
1697
- });
1698
- this.sessionId = session.id;
1699
- this.conversationId = session.header.conversationId ?? session.id;
1700
- this.setSessionPath(session.path);
1701
- await this.subAgentManager?.rebindParentSession(this.sessionId);
1702
- // Write compacted messages (skip system — it's rebuilt on load)
1703
- for (const msg of this.messages) {
1704
- if (msg.role === "system")
1705
- continue;
1706
- await this.persistMessage(msg);
1707
- }
1708
- this.lastPersistedIndex = this.messages.length;
1709
- // Carry evidence records into the new file so they survive compaction.
1710
- await this.rePersistTurnMetrics();
1711
- await this.rePersistKenTurns();
1712
- await this.rePersistAutopilotMarkers();
1713
- await this.rePersistAppMarkers();
1714
- // Persist the compaction counts so a resumed session's quiet notice can
1715
- // show the same "N → M messages" summary the live run did.
1716
- await this.persistAppMarker("compaction", {
1717
- originalCount: result.result.originalCount,
1718
- newCount: result.result.newCount,
1801
+ const conversationId = this.conversationId;
1802
+ await this.sessionManager.withCompactionLease(conversationId, this.opts.signal, async () => {
1803
+ let sourceFingerprint = computeSourceFingerprint(this.messages);
1804
+ const canonicalPath = await this.sessionManager.resolveCanonicalSession(conversationId, this.cwd);
1805
+ if (canonicalPath && canonicalPath !== this.sessionPath) {
1806
+ const newest = await this.sessionManager.load(canonicalPath);
1807
+ if (newest.header.sourceFingerprint === sourceFingerprint) {
1808
+ await this.adoptCompactionCheckpoint(newest);
1809
+ this.lastCompactionCompacted = true;
1810
+ finalCount = this.messages.length;
1811
+ return;
1812
+ }
1813
+ // The canonical branch contains different progress. Rebase onto it before
1814
+ // compacting so a stale caller cannot supersede newer history by generation.
1815
+ await this.adoptCompactionCheckpoint(newest);
1816
+ sourceFingerprint = computeSourceFingerprint(this.messages);
1817
+ }
1818
+ const attempt = await this.sessionManager.readCompactionAttemptState(conversationId);
1819
+ const attemptActive = !attempt?.expiresAt || Date.parse(attempt.expiresAt) > Date.now();
1820
+ if (mode === "automatic" &&
1821
+ attempt?.fingerprint === sourceFingerprint &&
1822
+ attempt.policyKey === policy.policyKey &&
1823
+ attemptActive) {
1824
+ if (attempt.outcome === "success" && attempt.checkpointId) {
1825
+ const checkpointPath = await this.sessionManager.findById(this.cwd, attempt.checkpointId);
1826
+ if (checkpointPath) {
1827
+ const checkpoint = await this.sessionManager.load(checkpointPath);
1828
+ if (checkpoint.header.sourceFingerprint === sourceFingerprint) {
1829
+ await this.adoptCompactionCheckpoint(checkpoint);
1830
+ this.lastCompactionCompacted = true;
1831
+ finalCount = this.messages.length;
1832
+ return;
1833
+ }
1834
+ }
1835
+ }
1836
+ else if (attempt.outcome === "failed" || attempt.outcome === "noop") {
1837
+ return;
1838
+ }
1839
+ }
1840
+ try {
1841
+ const result = await runCompactor();
1842
+ finalCount = result.result.newCount;
1843
+ this.messages = result.messages;
1844
+ this.lastCompactionCompacted = result.result.compacted;
1845
+ if (!result.result.compacted) {
1846
+ await this.sessionManager.writeCompactionAttemptState(conversationId, {
1847
+ fingerprint: sourceFingerprint,
1848
+ policyKey: policy.policyKey,
1849
+ outcome: "noop",
1850
+ updatedAt: new Date().toISOString(),
1851
+ expiresAt: new Date(Date.now() + 30_000).toISOString(),
1852
+ });
1853
+ return;
1854
+ }
1855
+ this.providerContext = null;
1856
+ this.remapMarkerAnchors(result.result.anchorRemap);
1857
+ await this.persistCompactionCheckpoint(sourceFingerprint, result.result);
1858
+ await this.sessionManager.writeCompactionAttemptState(conversationId, {
1859
+ fingerprint: sourceFingerprint,
1860
+ policyKey: policy.policyKey,
1861
+ outcome: "success",
1862
+ checkpointId: this.sessionId,
1863
+ updatedAt: new Date().toISOString(),
1864
+ });
1865
+ }
1866
+ catch (error) {
1867
+ await this.sessionManager
1868
+ .writeCompactionAttemptState(conversationId, {
1869
+ fingerprint: sourceFingerprint,
1870
+ policyKey: policy.policyKey,
1871
+ outcome: "failed",
1872
+ updatedAt: new Date().toISOString(),
1873
+ expiresAt: new Date(Date.now() + 30_000).toISOString(),
1874
+ })
1875
+ .catch(() => { });
1876
+ throw error;
1877
+ }
1719
1878
  });
1720
1879
  }
1721
1880
  this.eventBus.emit("compaction_end", {
1722
- compacted: true,
1723
- originalCount: result.result.originalCount,
1724
- newCount: result.result.newCount,
1881
+ compacted: this.lastCompactionCompacted,
1882
+ originalCount,
1883
+ newCount: finalCount,
1725
1884
  });
1726
1885
  }
1727
1886
  async newSession(preserveConversation = false) {
@@ -1729,6 +1888,7 @@ export class AgentSession {
1729
1888
  // explicit new sessions reset the conversation identity.
1730
1889
  if (!preserveConversation) {
1731
1890
  this.conversationId = "";
1891
+ this.checkpointGeneration = 0;
1732
1892
  this.sessionPreview = "";
1733
1893
  }
1734
1894
  // A fresh session drops any in-flight plan state so its prompt is clean.
@@ -1756,6 +1916,7 @@ export class AgentSession {
1756
1916
  if (this.opts.transient) {
1757
1917
  this.sessionId = "";
1758
1918
  this.conversationId = "";
1919
+ this.checkpointGeneration = 0;
1759
1920
  this.sessionPreview = "";
1760
1921
  this.setSessionPath("");
1761
1922
  this.lastPersistedIndex = this.messages.length;
@@ -2400,19 +2561,28 @@ export class AgentSession {
2400
2561
  this.sessionManager.registerActivePath(nextPath);
2401
2562
  }
2402
2563
  async createNewSession() {
2564
+ const continuingConversation = Boolean(this.conversationId && this.sessionId);
2403
2565
  const session = await this.sessionManager.create(this.cwd, this.provider, this.model, {
2404
2566
  conversationId: this.conversationId || undefined,
2567
+ generation: continuingConversation ? this.checkpointGeneration + 1 : 0,
2568
+ parentSessionId: continuingConversation ? this.sessionId : undefined,
2405
2569
  preview: this.sessionPreview || undefined,
2406
2570
  });
2407
2571
  this.sessionId = session.id;
2572
+ this.checkpointGeneration = session.header.generation ?? 0;
2408
2573
  this.conversationId = session.header.conversationId ?? session.id;
2409
2574
  this.setSessionPath(session.path);
2410
2575
  this.lastPersistedIndex = this.messages.length;
2411
2576
  }
2412
2577
  async loadExistingSession(sessionPath) {
2413
- const loaded = await this.sessionManager.load(sessionPath);
2578
+ // A stale physical checkpoint is only an address, not the conversation tip.
2579
+ // Resolve every resume—not just over-threshold/deferred compaction resumes—
2580
+ // before reading history so the next prompt cannot continue an old branch.
2581
+ const canonicalPath = (await this.sessionManager.resolveCanonicalSession(sessionPath, this.cwd)) ?? sessionPath;
2582
+ const loaded = await this.sessionManager.load(canonicalPath);
2414
2583
  // Use the leaf from the header to walk the correct branch
2415
2584
  const loadedMessages = this.sessionManager.getMessages(loaded.entries, loaded.header.leafId);
2585
+ this.checkpointGeneration = loaded.header.generation ?? 0;
2416
2586
  this.conversationId = loaded.header.conversationId ?? loaded.header.id;
2417
2587
  const legacyLabel = [...loaded.entries]
2418
2588
  .reverse()
@@ -2461,67 +2631,41 @@ export class AgentSession {
2461
2631
  provider: this.provider,
2462
2632
  accountId: creds.accountId,
2463
2633
  });
2634
+ this.sessionId = loaded.header.id;
2635
+ this.setSessionPath(loaded.path);
2636
+ this.lastPersistedIndex = this.messages.length;
2637
+ const loadPolicy = resolveCompactionPolicy({
2638
+ provider: this.provider,
2639
+ model: this.model,
2640
+ contextWindow,
2641
+ threshold: this.settingsManager.get("compactThreshold"),
2642
+ accountId: creds.accountId,
2643
+ approvedPlanPath: this.approvedPlanPath,
2644
+ });
2645
+ log("INFO", "compaction", "Restore compaction decision", {
2646
+ provider: this.provider,
2647
+ model: this.model,
2648
+ transport: this.provider === "openai" && creds.accountId ? "codex_oauth" : "public_api",
2649
+ contextWindow: String(contextWindow),
2650
+ activeTokens: "estimated",
2651
+ triggerLimit: String(loadPolicy.targetTokens),
2652
+ });
2464
2653
  const needsLoadCompaction = this.settingsManager.get("autoCompact") &&
2465
- shouldCompact(this.messages, contextWindow, this.settingsManager.get("compactThreshold"));
2654
+ shouldCompact(this.messages, contextWindow, loadPolicy.threshold);
2466
2655
  if (needsLoadCompaction && this.opts.deferLoadCompaction) {
2467
- // Host readiness is gated on initialize() don't block it on a summary
2468
- // LLM call (up to 30s). runLoop()'s pre-run auto-compaction picks this
2469
- // up on the first prompt and emits compaction_start/_end for the UI.
2656
+ // Canonicalize again immediately before the first prompt is persisted:
2657
+ // another process may create the shared checkpoint after this load.
2658
+ this.deferredCompactionPending = true;
2470
2659
  log("INFO", "session", "Restored session exceeds context — deferring compaction to first prompt");
2471
2660
  }
2472
2661
  else if (needsLoadCompaction) {
2473
2662
  await this.subAgentManager?.hydrate(loaded.header.id);
2474
2663
  log("INFO", "session", `Restored session exceeds context — auto-compacting`);
2475
- const compacted = await compact(this.messages, {
2476
- provider: this.provider,
2477
- model: this.model,
2478
- apiKey: creds.accessToken,
2479
- accountId: creds.accountId,
2480
- projectId: creds.projectId,
2481
- baseUrl: this.baseUrl ?? creds.baseUrl,
2482
- contextWindow,
2483
- signal: this.opts.signal,
2484
- });
2485
- this.messages = compacted.messages;
2486
- // Same anchor rebase as compact(): the restored markers were recorded
2487
- // against the pre-compaction transcript.
2488
- this.remapMarkerAnchors(compacted.result.anchorRemap);
2489
- log("INFO", "session", `Auto-compaction complete`, {
2490
- before: String(compacted.result.originalCount),
2491
- after: String(compacted.result.newCount),
2492
- });
2493
- // Compaction rewrote history, so the on-disk file no longer reflects
2494
- // what's in memory — fork a fresh session file for the compacted state
2495
- // (mirrors compact()'s own persistence) so `ggcoder continue` picks up
2496
- // the summary instead of the full original transcript.
2497
- const session = await this.sessionManager.create(this.cwd, this.provider, this.model, {
2498
- conversationId: this.conversationId || undefined,
2499
- preview: this.sessionPreview || undefined,
2500
- });
2501
- this.sessionId = session.id;
2502
- this.conversationId = session.header.conversationId ?? session.id;
2503
- this.setSessionPath(session.path);
2504
- await this.subAgentManager?.rebindParentSession(this.sessionId);
2505
- this.currentLeafId = null;
2506
- // Re-persist (compacted) messages — skip system, it's rebuilt on load
2507
- for (const msg of this.messages) {
2508
- if (msg.role === "system")
2509
- continue;
2510
- await this.persistMessage(msg);
2664
+ await this.compact(creds, "automatic");
2665
+ if (this.lastCompactionCompacted) {
2666
+ await this.recordInterruptedRuns(interruptedRuns);
2667
+ return;
2511
2668
  }
2512
- this.lastPersistedIndex = this.messages.length;
2513
- // Carry restored evidence into the continuation file.
2514
- await this.rePersistTurnMetrics();
2515
- await this.rePersistKenTurns();
2516
- await this.rePersistAutopilotMarkers();
2517
- await this.rePersistAppMarkers();
2518
- // Record this load-time auto-compaction's counts for the resumed notice.
2519
- await this.persistAppMarker("compaction", {
2520
- originalCount: compacted.result.originalCount,
2521
- newCount: compacted.result.newCount,
2522
- });
2523
- await this.recordInterruptedRuns(interruptedRuns);
2524
- return;
2525
2669
  }
2526
2670
  // Plain resume (no compaction needed): keep using the original session
2527
2671
  // file/id and append future turns to it in place. Forking a new file here
@@ -2560,8 +2704,10 @@ export class AgentSession {
2560
2704
  return this.messages;
2561
2705
  }
2562
2706
  async persistMessage(message) {
2563
- if (!this.sessionPreview && message.role === "user") {
2564
- this.sessionPreview = getUserSessionPrompt(message.content) ?? "";
2707
+ if (!this.sessionPreview &&
2708
+ message.role === "user" &&
2709
+ (message.provenance?.source === "human" || !message.provenance)) {
2710
+ this.sessionPreview = getUserSessionPrompt(message.content, message.provenance) ?? "";
2565
2711
  }
2566
2712
  // Transient sessions (subagent spawns) have no session file — skip.
2567
2713
  if (!this.sessionPath)
@@ -2581,7 +2727,7 @@ export class AgentSession {
2581
2727
  createSlashCommandContext() {
2582
2728
  return {
2583
2729
  switchModel: (provider, model) => this.switchModel(provider, model),
2584
- compact: () => this.compact(),
2730
+ compact: () => this.compact(undefined, "manual"),
2585
2731
  newSession: () => this.newSession(),
2586
2732
  listSessions: async () => {
2587
2733
  const sessions = await this.sessionManager.list(this.cwd);