@kenkaiiii/ggcoder 5.67.1 → 5.68.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 (60) hide show
  1. package/dist/app-sidecar.js +121 -3
  2. package/dist/app-sidecar.js.map +1 -1
  3. package/dist/core/agent-session.d.ts +49 -3
  4. package/dist/core/agent-session.js +297 -21
  5. package/dist/core/agent-session.js.map +1 -1
  6. package/dist/core/ask-user.d.ts +90 -8
  7. package/dist/core/ask-user.js +124 -13
  8. package/dist/core/ask-user.js.map +1 -1
  9. package/dist/core/compaction/plan-step-policy.d.ts +46 -0
  10. package/dist/core/compaction/plan-step-policy.js +57 -0
  11. package/dist/core/compaction/plan-step-policy.js.map +1 -0
  12. package/dist/core/destructive-git-guard.d.ts +63 -0
  13. package/dist/core/destructive-git-guard.js +831 -0
  14. package/dist/core/destructive-git-guard.js.map +1 -0
  15. package/dist/core/event-bus.d.ts +3 -0
  16. package/dist/core/event-bus.js +5 -0
  17. package/dist/core/event-bus.js.map +1 -1
  18. package/dist/core/keep-awake.d.ts +88 -0
  19. package/dist/core/keep-awake.js +251 -0
  20. package/dist/core/keep-awake.js.map +1 -0
  21. package/dist/core/package-threats.d.ts +18 -0
  22. package/dist/core/package-threats.js +168 -0
  23. package/dist/core/package-threats.js.map +1 -0
  24. package/dist/core/session-manager.d.ts +13 -1
  25. package/dist/core/session-manager.js +38 -18
  26. package/dist/core/session-manager.js.map +1 -1
  27. package/dist/core/session-summary-index.d.ts +37 -0
  28. package/dist/core/session-summary-index.js +172 -0
  29. package/dist/core/session-summary-index.js.map +1 -0
  30. package/dist/core/settings-manager.d.ts +2 -0
  31. package/dist/core/settings-manager.js +8 -0
  32. package/dist/core/settings-manager.js.map +1 -1
  33. package/dist/core/shell-threats-popular-packages.d.ts +11 -0
  34. package/dist/core/shell-threats-popular-packages.js +675 -0
  35. package/dist/core/shell-threats-popular-packages.js.map +1 -0
  36. package/dist/core/shell-threats.d.ts +8 -0
  37. package/dist/core/shell-threats.js +186 -0
  38. package/dist/core/shell-threats.js.map +1 -0
  39. package/dist/core/stream-rules.d.ts +30 -0
  40. package/dist/core/stream-rules.js +151 -0
  41. package/dist/core/stream-rules.js.map +1 -0
  42. package/dist/motion-agent/motion-check-tool.js +60 -29
  43. package/dist/motion-agent/motion-check-tool.js.map +1 -1
  44. package/dist/motion-agent/motion-review.d.ts +5 -2
  45. package/dist/motion-agent/motion-review.js +7 -4
  46. package/dist/motion-agent/motion-review.js.map +1 -1
  47. package/dist/tools/ask-user.js +3 -3
  48. package/dist/tools/ask-user.js.map +1 -1
  49. package/dist/tools/bash.js +37 -0
  50. package/dist/tools/bash.js.map +1 -1
  51. package/dist/tools/index.js +1 -1
  52. package/dist/tools/index.js.map +1 -1
  53. package/dist/tools/task-send.d.ts +1 -1
  54. package/dist/tools/task-send.js +15 -1
  55. package/dist/tools/task-send.js.map +1 -1
  56. package/dist/ui/components/ActivityIndicator.js +1 -0
  57. package/dist/ui/components/ActivityIndicator.js.map +1 -1
  58. package/dist/ui/hooks/useAgentLoop.d.ts +1 -1
  59. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  60. package/package.json +4 -4
@@ -1,5 +1,5 @@
1
1
  import { type AgentTool } from "@kenkaiiii/gg-agent";
2
- import { type Message, type MessageProvenance, type Provider, type ThinkingLevel } from "@kenkaiiii/gg-ai";
2
+ import { type Message, type MessageProvenance, type Provider, type Usage, type ThinkingLevel } from "@kenkaiiii/gg-ai";
3
3
  import { EventBus } from "./event-bus.js";
4
4
  import { type CompletionReview } from "./completion-review.js";
5
5
  import { SlashCommandRegistry } from "./slash-commands.js";
@@ -299,6 +299,12 @@ export declare class AgentSession {
299
299
  private postTurnCompaction?;
300
300
  private lastCompactionCompacted;
301
301
  private compactionRetryAfter;
302
+ /**
303
+ * SoL-Pi plan-step compaction bookkeeping (see compaction/plan-step-policy.ts).
304
+ * Step progress comes from `[DONE:n]` markers in assistant text — the same
305
+ * contract the approved-plan UI tracks.
306
+ */
307
+ private planStepState;
302
308
  /** A restored oversized checkpoint must be canonicalized before its first prompt is persisted. */
303
309
  private deferredCompactionPending;
304
310
  /** Latest provider count, anchored to the assistant response it measured. */
@@ -601,8 +607,11 @@ export declare class AgentSession {
601
607
  private getHookFollowUpMessages;
602
608
  private reviewLspEvidence;
603
609
  private withReviewLspEvidence;
604
- /** Auto-compact if needed, run agent loop with auth retry, and persist messages. */
610
+ /** Wraps the real run: cancels an in-flight cache prewarm and tracks run
611
+ * activity / last real request time for {@link prewarm}. */
605
612
  private runLoop;
613
+ /** Auto-compact if needed, run agent loop with auth retry, and persist messages. */
614
+ private runLoopInner;
606
615
  switchModel(provider: string, model: string): Promise<void>;
607
616
  /**
608
617
  * Record the switch as its own trailing message at the point it happened,
@@ -629,6 +638,20 @@ export declare class AgentSession {
629
638
  * protect it.
630
639
  */
631
640
  private settlePostTurnCompaction;
641
+ /**
642
+ * Advance plan-step bookkeeping over the messages added since the last
643
+ * observation and, when a plan step was newly completed (`[DONE:n]`), run
644
+ * the SoL-Pi cost rule. Called between model steps (in-flight) and once
645
+ * after the run; `scanIndex` and `doneSteps` make each message and each
646
+ * step count once across both paths. Returns undefined when no step
647
+ * completed since the last observation.
648
+ */
649
+ private observePlanStepProgress;
650
+ /**
651
+ * Plan-step bookkeeping after ANY successful compaction (as in the bench:
652
+ * every compaction leaves a cache-write cost to be repaid by later savings).
653
+ */
654
+ private recordPlanStepCompaction;
632
655
  /**
633
656
  * Post-turn compaction: once the final response has been delivered, compact
634
657
  * in the background while the user reads the answer, instead of making the
@@ -637,7 +660,9 @@ export declare class AgentSession {
637
660
  * Codex `model_post_turn_compact_threshold_percent` guards: skip when user
638
661
  * input is already queued (it would race the next turn), when the run was
639
662
  * aborted, or during the failure cooldown — and never let a compaction
640
- * error surface in the completed turn.
663
+ * error surface in the completed turn. Besides the size trigger, a newly
664
+ * completed approved-plan step may compact when the cache-cost rule in
665
+ * compaction/plan-step-policy.ts says the shrink pays for itself.
641
666
  */
642
667
  private maybeCompactPostTurn;
643
668
  compact(existingCredentials?: {
@@ -789,6 +814,11 @@ export declare class AgentSession {
789
814
  * the standard prompt therefore needs a rebuild. Custom and sub-agent
790
815
  * prompts never render packs, so detection is skipped for them.
791
816
  */
817
+ /**
818
+ * Bring the system prompt to the exact state the next provider request will
819
+ * send. Shared by real runs and {@link prewarm} so both see one prefix.
820
+ */
821
+ private prepareSystemPromptForRequest;
792
822
  private refreshActiveLanguages;
793
823
  /** Rebuild messages[0] from current plan-mode + approved-plan state. */
794
824
  private rebuildSystemPromptInPlace;
@@ -924,6 +954,22 @@ export declare class AgentSession {
924
954
  /** Replace the abort signal (e.g. after cancellation). */
925
955
  setSignal(signal: AbortSignal): void;
926
956
  private bindManagerCancellation;
957
+ private runLoopDepth;
958
+ private lastRealRequestAt;
959
+ private lastPrewarmAt;
960
+ private prewarmController;
961
+ /**
962
+ * Best-effort Anthropic prompt-cache prewarm before the user's next turn
963
+ * (desktop app calls this on the first keystroke after opening a chat or an
964
+ * idle pause). Sends the exact request prefix the next real turn will use —
965
+ * same system/tools/thinking/cache options — with `max_tokens: 1`, so the
966
+ * first real reply is a cache read instead of a cold write.
967
+ */
968
+ prewarm(signal?: AbortSignal): Promise<{
969
+ ok: boolean;
970
+ reason: string;
971
+ usage?: Usage;
972
+ }>;
927
973
  /** True when speedProfile is "optimized" (1-h cache TTL + pre-warm), or the
928
974
  * session was constructed with `forceLongCacheRetention` (Ken sessions). */
929
975
  private isSpeedOptimized;
@@ -1,4 +1,4 @@
1
- import { agentLoop, isAbortError, isUsageLimitError, } from "@kenkaiiii/gg-agent";
1
+ import { agentLoop, isAbortError, isUsageLimitError, repairToolPairingAdjacent, } from "@kenkaiiii/gg-agent";
2
2
  import { ProviderError, stream, } from "@kenkaiiii/gg-ai";
3
3
  import { EventBus } from "./event-bus.js";
4
4
  import { COMPLETION_REVIEW_STATE_KIND, } from "./completion-review.js";
@@ -36,9 +36,12 @@ import { createToolSearchTool } from "../tools/tool-search.js";
36
36
  import { createSessionStatsTool } from "../tools/session-stats.js";
37
37
  import { createDiagnoseCommand, isInternalDiagnosticsEnabled, SessionDiagnosticsRecorder, } from "./internal-diagnostics.js";
38
38
  import { log } from "./logger.js";
39
- import { setEstimatorModel, calibrateEstimatorFromUsage } from "./compaction/token-estimator.js";
39
+ import { setEstimatorModel, calibrateEstimatorFromUsage, estimateConversationTokens, } from "./compaction/token-estimator.js";
40
40
  import { calculateActiveContextTokens } from "./compaction/active-context.js";
41
41
  import { resolveCompactionPolicy } from "./compaction/policy.js";
42
+ import { decidePlanStepCompaction, DEFAULT_CACHE_WRITE_READ_RATIO, PLAN_STEP_KEEP_TOKENS, } from "./compaction/plan-step-policy.js";
43
+ import { extractPlanSteps, findCompletedMarkers } from "../utils/plan-steps.js";
44
+ import { readFileSync } from "node:fs";
42
45
  import { clampThinkingForPlanMode } from "./thinking-level.js";
43
46
  import { pruneStaleToolResults } from "./compaction/tool-result-pruner.js";
44
47
  import { discoverAgents } from "./agents.js";
@@ -57,6 +60,7 @@ import { classifyVerificationCommand } from "./verification-evidence.js";
57
60
  import { captureVerificationSnapshot } from "./verification-snapshot.js";
58
61
  import { findUserSessionPrompt, getUserSessionPrompt } from "./session-preview.js";
59
62
  import { normalizeMessageImages } from "./message-images.js";
63
+ import { loadStreamRules } from "./stream-rules.js";
60
64
  import crypto from "node:crypto";
61
65
  import fs from "node:fs/promises";
62
66
  import os from "node:os";
@@ -216,6 +220,25 @@ export class AgentSession {
216
220
  postTurnCompaction;
217
221
  lastCompactionCompacted = false;
218
222
  compactionRetryAfter = 0;
223
+ /**
224
+ * SoL-Pi plan-step compaction bookkeeping (see compaction/plan-step-policy.ts).
225
+ * Step progress comes from `[DONE:n]` markers in assistant text — the same
226
+ * contract the approved-plan UI tracks.
227
+ */
228
+ planStepState = {
229
+ planPath: undefined,
230
+ scanIndex: 0,
231
+ doneSteps: new Set(),
232
+ requestsInCompletedSteps: 0,
233
+ requestsThisStep: 0,
234
+ requests: 0,
235
+ grownTokens: 0,
236
+ lastContextTokens: 0,
237
+ compactions: 0,
238
+ writeCostBalance: 0,
239
+ savingPerRequest: 0,
240
+ requestsSinceLastCompaction: 0,
241
+ };
219
242
  /** A restored oversized checkpoint must be canonicalized before its first prompt is persisted. */
220
243
  deferredCompactionPending = false;
221
244
  /** Latest provider count, anchored to the assistant response it measured. */
@@ -923,6 +946,7 @@ export class AgentSession {
923
946
  kind: "prompt",
924
947
  visibility: "transcript",
925
948
  }, options = {}) {
949
+ this.prewarmController?.abort();
926
950
  await this.settlePostTurnCompaction();
927
951
  await this.adoptDeferredCheckpointBeforePrompt();
928
952
  const slash = await this.resolveSlashInput(content);
@@ -958,6 +982,7 @@ export class AgentSession {
958
982
  * attachments are always a direct conversational turn.
959
983
  */
960
984
  async promptWithAttachments(text, attachments) {
985
+ this.prewarmController?.abort();
961
986
  await this.settlePostTurnCompaction();
962
987
  await this.adoptDeferredCheckpointBeforePrompt();
963
988
  const parts = this.buildAttachmentParts(text, attachments);
@@ -2035,14 +2060,22 @@ export class AgentSession {
2035
2060
  content: `${String(message.content)}\n\n${notes.join(" ")}`,
2036
2061
  };
2037
2062
  }
2038
- /** Auto-compact if needed, run agent loop with auth retry, and persist messages. */
2063
+ /** Wraps the real run: cancels an in-flight cache prewarm and tracks run
2064
+ * activity / last real request time for {@link prewarm}. */
2039
2065
  async runLoop(options = {}) {
2040
- // Languages are re-detected at each task boundary so a project scaffolded
2041
- // during the previous turn gets its packs; the prompt is rebuilt only when
2042
- // the set grows, keeping the cached prefix stable otherwise.
2043
- if (this.refreshActiveLanguages())
2044
- await this.rebuildSystemPromptInPlace();
2045
- this.refreshSystemPromptTail();
2066
+ this.prewarmController?.abort();
2067
+ this.runLoopDepth++;
2068
+ try {
2069
+ await this.runLoopInner(options);
2070
+ }
2071
+ finally {
2072
+ this.runLoopDepth--;
2073
+ this.lastRealRequestAt = Date.now();
2074
+ }
2075
+ }
2076
+ /** Auto-compact if needed, run agent loop with auth retry, and persist messages. */
2077
+ async runLoopInner(options = {}) {
2078
+ await this.prepareSystemPromptForRequest();
2046
2079
  // One-shot cache-key marker per session so turn_end cacheRead numbers
2047
2080
  // in the log can be traced back to a specific routing namespace —
2048
2081
  // particularly useful when sub-agents inherit `parentKey:subagent`.
@@ -2137,11 +2170,14 @@ export class AgentSession {
2137
2170
  }
2138
2171
  const userAgent = this.provider === "anthropic" ? await getClaudeCliUserAgent() : undefined;
2139
2172
  const loopMessages = await this.prepareDynamicContext();
2173
+ // Re-read every run so rule-file edits apply on the next turn; a few small files.
2174
+ const streamRules = await loadStreamRules(this.cwd);
2140
2175
  const runAgentLoop = async (apiKey, accountId, projectId) => {
2141
2176
  lastResolvedAccessToken = apiKey;
2142
2177
  const modelInfo = getModel(this.model);
2143
2178
  const effectiveBaseUrl = this.baseUrl ?? creds.baseUrl;
2144
2179
  const generator = agentLoop(loopMessages, {
2180
+ ...(streamRules.length > 0 ? { streamRules: { rules: streamRules } } : {}),
2145
2181
  provider: this.provider,
2146
2182
  model: this.model,
2147
2183
  tools: options.disableTools ? [] : this.tools,
@@ -2265,6 +2301,9 @@ export class AgentSession {
2265
2301
  usage,
2266
2302
  pendingMessages,
2267
2303
  });
2304
+ // An approved plan runs as ONE run, so step boundaries are only
2305
+ // visible here, between model steps — not after the run ends.
2306
+ const planStep = this.observePlanStepProgress(messages, contextWindow, activeTokens);
2268
2307
  log("INFO", "compaction", "In-flight compaction decision", {
2269
2308
  provider: this.provider,
2270
2309
  model: this.model,
@@ -2272,8 +2311,10 @@ export class AgentSession {
2272
2311
  contextWindow: String(contextWindow),
2273
2312
  activeTokens: String(activeTokens),
2274
2313
  triggerLimit: String(policy.targetTokens),
2314
+ ...(planStep ? { planStep: `${planStep.compact} (${planStep.reason})` } : {}),
2275
2315
  });
2276
- if (!shouldCompact(messages, contextWindow, policy.threshold, activeTokens, policy.targetTokens))
2316
+ if (!shouldCompact(messages, contextWindow, policy.threshold, activeTokens, policy.targetTokens) &&
2317
+ !planStep?.compact)
2277
2318
  return messages;
2278
2319
  }
2279
2320
  // compact() operates on this.messages, while an earlier transform may
@@ -2594,7 +2635,7 @@ export class AgentSession {
2594
2635
  const canonicalPath = await this.sessionManager.resolveCanonicalSession(this.conversationId, this.cwd);
2595
2636
  if (!canonicalPath || canonicalPath === this.sessionPath)
2596
2637
  return;
2597
- await this.adoptCompactionCheckpoint(await this.sessionManager.load(canonicalPath));
2638
+ await this.adoptCompactionCheckpoint(await this.sessionManager.load(canonicalPath, { canonical: true }));
2598
2639
  }
2599
2640
  async persistCompactionCheckpoint(sourceFingerprint, result) {
2600
2641
  const parentSessionId = this.sessionId || undefined;
@@ -2643,6 +2684,99 @@ export class AgentSession {
2643
2684
  if (this.postTurnCompaction)
2644
2685
  await this.postTurnCompaction;
2645
2686
  }
2687
+ /**
2688
+ * Advance plan-step bookkeeping over the messages added since the last
2689
+ * observation and, when a plan step was newly completed (`[DONE:n]`), run
2690
+ * the SoL-Pi cost rule. Called between model steps (in-flight) and once
2691
+ * after the run; `scanIndex` and `doneSteps` make each message and each
2692
+ * step count once across both paths. Returns undefined when no step
2693
+ * completed since the last observation.
2694
+ */
2695
+ observePlanStepProgress(messages, contextWindow, activeTokens) {
2696
+ const st = this.planStepState;
2697
+ const planPath = this.approvedPlanPath;
2698
+ if (st.planPath !== planPath) {
2699
+ st.planPath = planPath;
2700
+ st.doneSteps = new Set();
2701
+ st.requestsInCompletedSteps = 0;
2702
+ st.requestsThisStep = 0;
2703
+ }
2704
+ if (st.scanIndex > messages.length)
2705
+ st.scanIndex = 0;
2706
+ let requests = 0;
2707
+ const newlyDone = [];
2708
+ for (let i = st.scanIndex; i < messages.length; i++) {
2709
+ const msg = messages[i];
2710
+ if (msg?.role !== "assistant")
2711
+ continue;
2712
+ requests++;
2713
+ const text = typeof msg.content === "string"
2714
+ ? msg.content
2715
+ : msg.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
2716
+ for (const step of findCompletedMarkers(text)) {
2717
+ if (!st.doneSteps.has(step))
2718
+ newlyDone.push(step);
2719
+ }
2720
+ }
2721
+ st.scanIndex = messages.length;
2722
+ const contextTokens = activeTokens ?? estimateConversationTokens(messages);
2723
+ if (st.lastContextTokens > 0)
2724
+ st.grownTokens += Math.max(0, contextTokens - st.lastContextTokens);
2725
+ st.lastContextTokens = contextTokens;
2726
+ st.requests += requests;
2727
+ st.requestsThisStep += requests;
2728
+ st.requestsSinceLastCompaction += requests;
2729
+ if (st.writeCostBalance > 0)
2730
+ st.writeCostBalance -= st.savingPerRequest * requests;
2731
+ if (!planPath || newlyDone.length === 0)
2732
+ return undefined;
2733
+ for (const step of newlyDone)
2734
+ st.doneSteps.add(step);
2735
+ st.requestsInCompletedSteps += st.requestsThisStep;
2736
+ st.requestsThisStep = 0;
2737
+ let planText;
2738
+ try {
2739
+ planText = readFileSync(planPath, "utf8");
2740
+ }
2741
+ catch {
2742
+ return undefined;
2743
+ }
2744
+ const totalSteps = extractPlanSteps(planText).length;
2745
+ const decision = decidePlanStepCompaction({
2746
+ contextTokens,
2747
+ keptTailTokens: PLAN_STEP_KEEP_TOKENS,
2748
+ contextWindow,
2749
+ // The model registry carries no cache price fields; use the SoL-Pi default.
2750
+ cacheWriteReadRatio: DEFAULT_CACHE_WRITE_READ_RATIO,
2751
+ stepsCompleted: st.doneSteps.size,
2752
+ stepsRemaining: Math.max(0, totalSteps - st.doneSteps.size),
2753
+ requestsInCompletedSteps: st.requestsInCompletedSteps,
2754
+ requests: st.requests,
2755
+ grownTokens: st.grownTokens,
2756
+ priorCompactions: st.compactions,
2757
+ writeCostBalance: st.writeCostBalance,
2758
+ requestsSinceLastCompaction: st.compactions > 0 ? st.requestsSinceLastCompaction : undefined,
2759
+ });
2760
+ log("INFO", "compaction", "Plan-step compaction decision", {
2761
+ compact: String(decision.compact),
2762
+ reason: decision.reason,
2763
+ });
2764
+ return decision;
2765
+ }
2766
+ /**
2767
+ * Plan-step bookkeeping after ANY successful compaction (as in the bench:
2768
+ * every compaction leaves a cache-write cost to be repaid by later savings).
2769
+ */
2770
+ recordPlanStepCompaction(contextBefore) {
2771
+ const st = this.planStepState;
2772
+ const after = estimateConversationTokens(this.messages);
2773
+ st.scanIndex = this.messages.length;
2774
+ st.lastContextTokens = after;
2775
+ st.requestsSinceLastCompaction = 0;
2776
+ st.compactions++;
2777
+ st.writeCostBalance += after * (DEFAULT_CACHE_WRITE_READ_RATIO - 1);
2778
+ st.savingPerRequest = Math.max(0, contextBefore - after);
2779
+ }
2646
2780
  /**
2647
2781
  * Post-turn compaction: once the final response has been delivered, compact
2648
2782
  * in the background while the user reads the answer, instead of making the
@@ -2651,7 +2785,9 @@ export class AgentSession {
2651
2785
  * Codex `model_post_turn_compact_threshold_percent` guards: skip when user
2652
2786
  * input is already queued (it would race the next turn), when the run was
2653
2787
  * aborted, or during the failure cooldown — and never let a compaction
2654
- * error surface in the completed turn.
2788
+ * error surface in the completed turn. Besides the size trigger, a newly
2789
+ * completed approved-plan step may compact when the cache-cost rule in
2790
+ * compaction/plan-step-policy.ts says the shrink pays for itself.
2655
2791
  */
2656
2792
  maybeCompactPostTurn(creds) {
2657
2793
  if (!this.settingsManager.get("autoCompact"))
@@ -2664,15 +2800,18 @@ export class AgentSession {
2664
2800
  return;
2665
2801
  if (Date.now() < this.compactionRetryAfter)
2666
2802
  return;
2667
- // One compaction per turn boundary: a pre-run or overflow-recovery
2668
- // compaction already shrank this run's history — re-probing right after
2669
- // the final response would only re-derive that decision.
2670
- if (this.compactionOccurred)
2671
- return;
2672
2803
  const contextWindow = getContextWindow(this.model, {
2673
2804
  provider: this.provider,
2674
2805
  accountId: creds.accountId,
2675
2806
  });
2807
+ // One compaction per turn boundary: a pre-run, in-flight or overflow
2808
+ // compaction already shrank this run's history — re-probing right after
2809
+ // the final response would only re-derive that decision. Still record the
2810
+ // final response's `[DONE:n]` steps so plan bookkeeping stays current.
2811
+ if (this.compactionOccurred) {
2812
+ this.observePlanStepProgress(this.messages, contextWindow, undefined);
2813
+ return;
2814
+ }
2676
2815
  const policy = resolveCompactionPolicy({
2677
2816
  provider: this.provider,
2678
2817
  model: this.model,
@@ -2691,9 +2830,12 @@ export class AgentSession {
2691
2830
  });
2692
2831
  }
2693
2832
  }
2694
- if (!shouldCompact(this.messages, contextWindow, policy.threshold, activeTokens, policy.targetTokens))
2833
+ const planStep = this.observePlanStepProgress(this.messages, contextWindow, activeTokens);
2834
+ if (!shouldCompact(this.messages, contextWindow, policy.threshold, activeTokens, policy.targetTokens) &&
2835
+ !planStep?.compact)
2695
2836
  return;
2696
2837
  log("INFO", "compaction", "Post-turn compaction decision — compacting in background", {
2838
+ trigger: planStep?.compact ? `plan-step (${planStep.reason})` : "size",
2697
2839
  provider: this.provider,
2698
2840
  model: this.model,
2699
2841
  transport: this.provider === "openai" && creds.accountId ? "codex_oauth" : "public_api",
@@ -2747,6 +2889,7 @@ export class AgentSession {
2747
2889
  approvedPlanPath: this.approvedPlanPath,
2748
2890
  });
2749
2891
  const originalCount = this.messages.length;
2892
+ const contextTokensBefore = estimateConversationTokens(this.messages);
2750
2893
  this.eventBus.emit("compaction_start", { messageCount: originalCount });
2751
2894
  let contextSelection;
2752
2895
  const runCompactor = async () => {
@@ -2784,7 +2927,7 @@ export class AgentSession {
2784
2927
  let sourceFingerprint = computeSourceFingerprint(this.messages);
2785
2928
  const canonicalPath = await this.sessionManager.resolveCanonicalSession(conversationId, this.cwd);
2786
2929
  if (canonicalPath && canonicalPath !== this.sessionPath) {
2787
- const newest = await this.sessionManager.load(canonicalPath);
2930
+ const newest = await this.sessionManager.load(canonicalPath, { canonical: true });
2788
2931
  if (newest.header.sourceFingerprint === sourceFingerprint) {
2789
2932
  await this.adoptCompactionCheckpoint(newest);
2790
2933
  this.lastCompactionCompacted = true;
@@ -2858,6 +3001,8 @@ export class AgentSession {
2858
3001
  }
2859
3002
  });
2860
3003
  }
3004
+ if (this.lastCompactionCompacted)
3005
+ this.recordPlanStepCompaction(contextTokensBefore);
2861
3006
  this.eventBus.emit("compaction_end", {
2862
3007
  compacted: this.lastCompactionCompacted,
2863
3008
  originalCount,
@@ -3243,6 +3388,18 @@ export class AgentSession {
3243
3388
  * the standard prompt therefore needs a rebuild. Custom and sub-agent
3244
3389
  * prompts never render packs, so detection is skipped for them.
3245
3390
  */
3391
+ /**
3392
+ * Bring the system prompt to the exact state the next provider request will
3393
+ * send. Shared by real runs and {@link prewarm} so both see one prefix.
3394
+ */
3395
+ async prepareSystemPromptForRequest() {
3396
+ // Languages are re-detected at each task boundary so a project scaffolded
3397
+ // during the previous turn gets its packs; the prompt is rebuilt only when
3398
+ // the set grows, keeping the cached prefix stable otherwise.
3399
+ if (this.refreshActiveLanguages())
3400
+ await this.rebuildSystemPromptInPlace();
3401
+ this.refreshSystemPromptTail();
3402
+ }
3246
3403
  refreshActiveLanguages() {
3247
3404
  if (this.customSystemPrompt || this.agentPrompt !== undefined)
3248
3405
  return false;
@@ -3726,6 +3883,123 @@ export class AgentSession {
3726
3883
  if (signal?.aborted)
3727
3884
  this.managerAbortHandler();
3728
3885
  }
3886
+ runLoopDepth = 0;
3887
+ lastRealRequestAt = 0;
3888
+ lastPrewarmAt = 0;
3889
+ prewarmController = null;
3890
+ /**
3891
+ * Best-effort Anthropic prompt-cache prewarm before the user's next turn
3892
+ * (desktop app calls this on the first keystroke after opening a chat or an
3893
+ * idle pause). Sends the exact request prefix the next real turn will use —
3894
+ * same system/tools/thinking/cache options — with `max_tokens: 1`, so the
3895
+ * first real reply is a cache read instead of a cold write.
3896
+ */
3897
+ async prewarm(signal) {
3898
+ if (this.provider !== "anthropic")
3899
+ return { ok: false, reason: "provider" };
3900
+ if (this.settingsManager?.get("cachePrewarm") === false) {
3901
+ return { ok: false, reason: "disabled" };
3902
+ }
3903
+ if (this.runLoopDepth > 0)
3904
+ return { ok: false, reason: "run_active" };
3905
+ if (this.prewarmController)
3906
+ return { ok: false, reason: "in_flight" };
3907
+ const cacheRetention = this.isSpeedOptimized() ? "long" : "short";
3908
+ const ttlMs = (cacheRetention === "long" ? 60 : 5) * 60_000;
3909
+ const now = Date.now();
3910
+ if (now - Math.max(this.lastPrewarmAt, this.lastRealRequestAt) < ttlMs) {
3911
+ return { ok: false, reason: "cache_fresh" };
3912
+ }
3913
+ // Same preparation as the real run, BEFORE copying history: otherwise the
3914
+ // warmed system block differs from the next request (language packs are
3915
+ // detected at run start) and everything after it misses the cache.
3916
+ await this.prepareSystemPromptForRequest();
3917
+ // The await above yields: re-check that no run or other prewarm began.
3918
+ if (this.runLoopDepth > 0)
3919
+ return { ok: false, reason: "run_active" };
3920
+ if (this.prewarmController)
3921
+ return { ok: false, reason: "in_flight" };
3922
+ // End at the last user/tool message: the next real turn appends a new user
3923
+ // message after the trailing assistant reply, and its cache lookback hits
3924
+ // the entry written at this boundary. A request must end on a user turn.
3925
+ let end = this.messages.length;
3926
+ while (end > 0 && this.messages[end - 1]?.role === "assistant")
3927
+ end--;
3928
+ // The loop repairs tool pairing in place before every request; apply the
3929
+ // same repair to a copy so the warmed prefix is byte-identical to it.
3930
+ const messages = structuredClone(this.messages.slice(0, end));
3931
+ repairToolPairingAdjacent(messages);
3932
+ if (!messages.some((m) => m.role === "user" || m.role === "tool")) {
3933
+ return { ok: false, reason: "no_history" };
3934
+ }
3935
+ const tokens = estimateConversationTokens(messages);
3936
+ if (tokens < 4_000)
3937
+ return { ok: false, reason: "too_small" };
3938
+ const controller = new AbortController();
3939
+ const onAbort = () => controller.abort();
3940
+ signal?.addEventListener("abort", onAbort, { once: true });
3941
+ this.prewarmController = controller;
3942
+ const started = Date.now();
3943
+ try {
3944
+ const creds = await this.authStorage.resolveCredentials(this.provider, {
3945
+ storageKeys: this.currentAuthStorageKeys(),
3946
+ });
3947
+ if (controller.signal.aborted || this.runLoopDepth > 0) {
3948
+ return { ok: false, reason: "aborted" };
3949
+ }
3950
+ const modelInfo = getModel(this.model);
3951
+ const result = stream({
3952
+ provider: this.provider,
3953
+ model: this.model,
3954
+ messages,
3955
+ tools: this.tools,
3956
+ webSearch: true,
3957
+ maxTokens: this.maxTokens,
3958
+ thinking: this.planModeRef.current
3959
+ ? clampThinkingForPlanMode(this.thinkingLevel)
3960
+ : this.thinkingLevel,
3961
+ apiKey: creds.accessToken,
3962
+ baseUrl: this.baseUrl ?? creds.baseUrl,
3963
+ accountId: creds.accountId,
3964
+ transportSessionId: this.sessionId || this.transportSessionId,
3965
+ cacheRetention,
3966
+ promptCacheKey: this.getPromptCacheKey(),
3967
+ supportsImages: modelInfo?.supportsImages,
3968
+ supportsVideo: modelInfo?.supportsVideo,
3969
+ userAgent: await getClaudeCliUserAgent(),
3970
+ prewarm: true,
3971
+ signal: controller.signal,
3972
+ });
3973
+ const response = await result.response;
3974
+ const usage = response.usage;
3975
+ if (usage.inputTokens === 0 && usage.outputTokens === 0) {
3976
+ // gg-ai sent nothing: budget thinking can't stay identical at max_tokens 1.
3977
+ log("INFO", "prewarm", "Cache prewarm skipped: budget thinking", {
3978
+ model: this.model,
3979
+ });
3980
+ return { ok: false, reason: "thinking_budget_incompatible" };
3981
+ }
3982
+ this.lastPrewarmAt = Date.now();
3983
+ log("INFO", "prewarm", "Cache prewarm complete", {
3984
+ tokens: String(tokens),
3985
+ cacheRead: String(usage.cacheRead ?? 0),
3986
+ cacheWrite: String(usage.cacheWrite ?? 0),
3987
+ ms: String(Date.now() - started),
3988
+ });
3989
+ return { ok: true, reason: "warmed", usage };
3990
+ }
3991
+ catch (error) {
3992
+ if (isAbortError(error) || controller.signal.aborted)
3993
+ return { ok: false, reason: "aborted" };
3994
+ log("WARN", "prewarm", `Cache prewarm failed: ${error instanceof Error ? error.message : String(error)}`);
3995
+ return { ok: false, reason: "error" };
3996
+ }
3997
+ finally {
3998
+ signal?.removeEventListener("abort", onAbort);
3999
+ if (this.prewarmController === controller)
4000
+ this.prewarmController = null;
4001
+ }
4002
+ }
3729
4003
  /** True when speedProfile is "optimized" (1-h cache TTL + pre-warm), or the
3730
4004
  * session was constructed with `forceLongCacheRetention` (Ken sessions). */
3731
4005
  isSpeedOptimized() {
@@ -3799,8 +4073,10 @@ export class AgentSession {
3799
4073
  // A stale physical checkpoint is only an address, not the conversation tip.
3800
4074
  // Resolve every resume—not just over-threshold/deferred compaction resumes—
3801
4075
  // before reading history so the next prompt cannot continue an old branch.
3802
- const canonicalPath = (await this.sessionManager.resolveCanonicalSession(sessionPath, this.cwd)) ?? sessionPath;
3803
- const loaded = await this.sessionManager.load(canonicalPath);
4076
+ const resolvedPath = await this.sessionManager.resolveCanonicalSession(sessionPath, this.cwd);
4077
+ const loaded = resolvedPath
4078
+ ? await this.sessionManager.load(resolvedPath, { canonical: true })
4079
+ : await this.sessionManager.load(sessionPath);
3804
4080
  // Use the leaf from the header to walk the correct branch
3805
4081
  const loadedMessages = this.sessionManager.getMessages(loaded.entries, loaded.header.leafId);
3806
4082
  const savedCompletionReview = [...loaded.entries]