one-code-extension 0.1.3 → 0.3.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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -202
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -28,16 +28,20 @@
28
28
  * prefix thinking-cleared while this call sends full thinking blocks, and the
29
29
  * mismatch invalidates the message-block cache (system+tools still read).
30
30
  *
31
+ * Not every compaction can replay — `fit.ts` explains when and why the
32
+ * standalone shape (the doomed span alone, trimmed to the window) is used.
33
+ *
31
34
  * Any failure returns nothing, so pi's own compaction serves — a different
32
35
  * summary style, never a broken compaction. CC_COMPACTION=0 opts out.
33
36
  */
34
37
 
35
38
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
36
- import type { Api, Model, Tool } from "@earendil-works/pi-ai";
39
+ import type { Api, Context, Message, Model, Tool } from "@earendil-works/pi-ai";
37
40
  import { completeSimple } from "@earendil-works/pi-ai/compat";
38
41
  import {
39
42
  convertToLlm,
40
43
  type ExtensionAPI,
44
+ type ExtensionContext,
41
45
  type SessionBeforeCompactEvent,
42
46
  } from "@earendil-works/pi-coding-agent";
43
47
  import {
@@ -46,14 +50,26 @@ import {
46
50
  clearThinkingApplies,
47
51
  clearThinkingEnabled,
48
52
  isAnthropicOAuth,
49
- looksLikeAnthropicRequest,
50
53
  withClearThinking,
51
54
  } from "../context-management/index.ts";
55
+ import { looksLikeAnthropicRequest } from "../lib/anthropic-payload.ts";
56
+ import { forcedReasoningLevel } from "../lib/model-policy.ts";
57
+ import { fitToBudget, replayFits, withoutUsage } from "./fit.ts";
52
58
  import { buildCompactionInstruction, COMPACTION_MAX_TOKENS, continuationSummary, extractSummary } from "./prompt.ts";
53
59
 
54
60
  /** Compaction reads a whole context window; give it more room than the classifier's 30s. */
55
61
  const COMPACTION_TIMEOUT_MS = 120_000;
56
62
 
63
+ /**
64
+ * The system prompt of a standalone (non-replay) request. The session's own is
65
+ * left out along with the tools: neither helps a request that cannot hit the
66
+ * cache, and together they cost ~20k tokens the doomed span needs on an overflow.
67
+ */
68
+ export const STANDALONE_SYSTEM_PROMPT =
69
+ "You are the coding assistant from the conversation below, asked to summarize it so work can continue in a fresh context. Respond with text only.";
70
+
71
+ type Preparation = Pick<SessionBeforeCompactEvent["preparation"], "messagesToSummarize" | "turnPrefixMessages" | "isSplitTurn" | "previousSummary">;
72
+
57
73
  export default function compactionExtension(pi: ExtensionAPI) {
58
74
  /**
59
75
  * The exact message array the session last handed the provider, captured
@@ -68,8 +84,8 @@ export default function compactionExtension(pi: ExtensionAPI) {
68
84
  * `convertToLlm` on the captured array, so the message prefix we send is
69
85
  * byte-identical to the turn's, and appending only the instruction keeps that
70
86
  * whole prefix reusable. Reconstructing from session entries instead (the
71
- * fallback) diverges at message one, because those injected reminders never
72
- * become entries — so it cannot reproduce the cached prefix at all.
87
+ * standalone path) diverges at message one, because those injected reminders
88
+ * never become entries — so it cannot reproduce the cached prefix at all.
73
89
  *
74
90
  * We hold the reference, not a copy: `emitContext` hands each handler a fresh
75
91
  * structuredClone the session never mutates again, and compaction is the
@@ -80,39 +96,53 @@ export default function compactionExtension(pi: ExtensionAPI) {
80
96
  pi.on("context", (event) => {
81
97
  capturedMessages = event.messages;
82
98
  });
99
+ // After a compaction the capture describes the pre-compaction request. A
100
+ // second /compact before any turn would otherwise re-summarize history the
101
+ // first one already folded away (and mis-scope the kept tail); the standalone
102
+ // reconstruction from entries serves until the next request recaptures.
103
+ pi.on("session_compact", () => {
104
+ capturedMessages = undefined;
105
+ });
106
+ // A /tree branch switch stays in the same process and fires no `context`
107
+ // event (navigateTree rebuilds the messages itself), so the capture would
108
+ // still describe the abandoned branch — and a /compact before the next turn
109
+ // summarized work the kept branch never did (review H1). Same remedy.
110
+ pi.on("session_tree", () => {
111
+ capturedMessages = undefined;
112
+ });
83
113
 
84
114
  pi.on("session_before_compact", async (event, ctx) => {
85
115
  if (process.env.CC_COMPACTION === "0") return undefined;
86
116
 
87
117
  const model = ctx.model;
88
118
  if (!model) return undefined;
119
+ // Snapshot before the first await so the capture and pi's preparation
120
+ // describe the same request even if a context event lands meanwhile.
121
+ const captured = capturedMessages;
89
122
 
90
123
  try {
91
124
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
92
125
  if (!auth.ok) return undefined;
93
126
  const baseUrl = (auth as { baseUrl?: string }).baseUrl;
94
127
 
95
- // Prefer the captured request prefix — it is cache-aligned and already
96
- // carries the leading compactionSummary message on a re-compaction.
97
- // Fall back to reconstructing from session entries only when nothing
98
- // has been captured yet (e.g. /compact as the first action in a freshly
99
- // resumed session, before any turn ran); that path cannot hit the cache.
100
- const messages = capturedMessages ?? reconstructFromEntries(event.preparation);
101
-
102
- const instruction = buildCompactionInstruction({
103
- reason: event.reason,
104
- customInstructions: event.customInstructions,
105
- });
128
+ const maxTokens = Math.min(COMPACTION_MAX_TOKENS, model.maxTokens || COMPACTION_MAX_TOKENS);
106
129
 
107
- // The active tool definitions, in their active order — kept in the
108
- // request purely so the cached prefix (tools come first) still matches.
109
- // A mismatch costs cache hits, never correctness.
110
- const byName = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));
111
- const tools = pi
112
- .getActiveTools()
113
- .map((name) => byName.get(name))
114
- .filter((tool) => tool !== undefined)
115
- .map(({ name, description, parameters }) => ({ name, description, parameters }) as Tool);
130
+ // Prefer the captured request prefix — it is cache-aligned and already
131
+ // carries the leading compactionSummary message on a re-compaction — as
132
+ // long as pi's max_tokens clamp leaves it room for the reply (fit.ts).
133
+ // Otherwise (no capture yet, an overflow, a nearly full window) the
134
+ // standalone shape summarizes the doomed span pi isolated. An overflow
135
+ // never replays, so its replay request is not even built.
136
+ const replay = captured && event.reason !== "overflow" ? replayRequest(pi, ctx, captured, event) : undefined;
137
+ const request =
138
+ replay && replayFits(event.reason, model, replay, maxTokens)
139
+ ? replay
140
+ : standaloneRequest(
141
+ model,
142
+ event.preparation,
143
+ buildCompactionInstruction({ reason: event.reason, customInstructions: event.customInstructions }),
144
+ maxTokens,
145
+ );
116
146
 
117
147
  // When context-management (clear_thinking) is active for this session,
118
148
  // the agent loop's cached message prefix has old thinking blocks cleared
@@ -134,48 +164,42 @@ export default function compactionExtension(pi: ExtensionAPI) {
134
164
  : auth.headers;
135
165
 
136
166
  const timeout = AbortSignal.timeout(COMPACTION_TIMEOUT_MS);
137
- const result = await completeSimple(
138
- baseUrl ? ({ ...model, baseUrl } as Model<Api>) : model,
139
- {
140
- systemPrompt: ctx.getSystemPrompt(),
141
- messages: [...convertToLlm(messages), { role: "user", content: instruction, timestamp: Date.now() }],
142
- tools,
143
- },
144
- {
145
- apiKey: auth.apiKey,
146
- headers,
147
- env: auth.env,
148
- signal: AbortSignal.any([event.signal, timeout]),
149
- maxTokens: Math.min(COMPACTION_MAX_TOKENS, model.maxTokens || COMPACTION_MAX_TOKENS),
150
- // The session id is the prompt-cache key / routing affinity on the
151
- // providers that use one (openai-codex prompt_cache_key, session
152
- // headers). Without it the replayed prefix cannot hit the session's
153
- // cache no matter how well the bytes match.
154
- sessionId: ctx.sessionManager.getSessionId(),
155
- // Reasoning config is part of the cache identity on the Responses
156
- // API: with a byte-identical prefix, prompt_cache_key, tools, and
157
- // system prompt, omitting `reasoning` on a reasoning-configured
158
- // session still misses the cache entirely (measured: cacheRead 0).
159
- // Mirror the session's own thinking level so the request matches
160
- // the prefix the session cached. Unlike the auto-mode classifier —
161
- // which may run a *different*, unvalidated model where reasoning
162
- // fails closed — this is the session's own model, already proven to
163
- // accept this level, and streamSimple clamps an unsupported level
164
- // down rather than erroring, so mirroring never fails closed here.
165
- // "off" carries no reasoning (matching what such a session sends).
166
- reasoning: ctx.thinkingLevel === "off" ? undefined : ctx.thinkingLevel,
167
- // The clear_thinking body edit, matching the session's requests (see
168
- // the header note above). Only attached on Anthropic requests that
169
- // carry thinking, exactly as the context-management extension gates it.
170
- onPayload: clearThinking
171
- ? (payload: unknown) =>
172
- looksLikeAnthropicRequest(payload) &&
173
- clearThinkingApplies(payload as Record<string, unknown>, cmModel.compat?.forceAdaptiveThinking === true)
174
- ? withClearThinking(payload as Record<string, unknown>)
175
- : payload
176
- : undefined,
177
- },
178
- );
167
+ const result = await completeSimple(baseUrl ? ({ ...model, baseUrl } as Model<Api>) : model, request, {
168
+ apiKey: auth.apiKey,
169
+ headers,
170
+ env: auth.env,
171
+ signal: AbortSignal.any([event.signal, timeout]),
172
+ maxTokens,
173
+ // The session id is the prompt-cache key / routing affinity on the
174
+ // providers that use one (openai-codex prompt_cache_key, session
175
+ // headers). Without it the replayed prefix cannot hit the session's
176
+ // cache no matter how well the bytes match.
177
+ sessionId: ctx.sessionManager.getSessionId(),
178
+ // Reasoning config is part of the cache identity on the Responses
179
+ // API: with a byte-identical prefix, prompt_cache_key, tools, and
180
+ // system prompt, omitting `reasoning` on a reasoning-configured
181
+ // session still misses the cache entirely (measured: cacheRead 0).
182
+ // Mirror the session's own thinking level so the request matches
183
+ // the prefix the session cached. Unlike the auto-mode classifier —
184
+ // which may run a *different*, unvalidated model where reasoning
185
+ // fails closed — this is the session's own model, already proven to
186
+ // accept this level, and streamSimple clamps an unsupported level
187
+ // down rather than erroring, so mirroring never fails closed here.
188
+ // "off" carries no reasoning (matching what such a session sends) —
189
+ // unless the model cannot disable thinking, where an off-request
190
+ // would 400 (forcedReasoningLevel sends its lowest level instead).
191
+ reasoning: ctx.thinkingLevel === "off" ? forcedReasoningLevel(model) : ctx.thinkingLevel,
192
+ // The clear_thinking body edit, matching the session's requests (see
193
+ // the header note above). Only attached on Anthropic requests that
194
+ // carry thinking, exactly as the context-management extension gates it.
195
+ onPayload: clearThinking
196
+ ? (payload: unknown) =>
197
+ looksLikeAnthropicRequest(payload) &&
198
+ clearThinkingApplies(payload as Record<string, unknown>, cmModel.compat?.forceAdaptiveThinking === true)
199
+ ? withClearThinking(payload as Record<string, unknown>)
200
+ : payload
201
+ : undefined,
202
+ });
179
203
 
180
204
  const text = result.content
181
205
  .filter((block): block is { type: "text"; text: string } => block.type === "text")
@@ -201,10 +225,91 @@ export default function compactionExtension(pi: ExtensionAPI) {
201
225
  }
202
226
 
203
227
  /**
204
- * Fallback message construction from session entries, used only when no live
205
- * request was captured. This cannot hit the prompt cache (the request-time
206
- * <system-reminder> injections are absent from entries), but it still yields a
207
- * correct summary — and if it fails, pi's own compaction serves.
228
+ * The cache-aligned shape: the session's system prompt, the active tool
229
+ * definitions in their active order — kept purely so the cached prefix (tools
230
+ * come first) still matches; a mismatch costs cache hits, never correctness —
231
+ * the captured request verbatim, and the instruction as one more user message.
232
+ *
233
+ * pi keeps the tail after the cut point verbatim (keepRecentTokens, ~20k
234
+ * tokens); the captured request still carries that tail, so the instruction
235
+ * names it (`keptTailOf`). Cutting the tail off the request instead was
236
+ * rejected: Anthropic checks cache hits only ~20 blocks back from the
237
+ * breakpoint, so a request ending well before the last cached block misses
238
+ * the cache the whole replay exists to hit.
239
+ */
240
+ function replayRequest(pi: ExtensionAPI, ctx: ExtensionContext, captured: AgentMessage[], event: SessionBeforeCompactEvent): Context {
241
+ const byName = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));
242
+ const tools = pi
243
+ .getActiveTools()
244
+ .map((name) => byName.get(name))
245
+ .filter((tool) => tool !== undefined)
246
+ .map(({ name, description, parameters }) => ({ name, description, parameters }) as Tool);
247
+ const instruction = buildCompactionInstruction({
248
+ reason: event.reason,
249
+ customInstructions: event.customInstructions,
250
+ keptTail: keptTailOf(captured, event.preparation),
251
+ });
252
+ return { systemPrompt: ctx.getSystemPrompt(), messages: [...convertToLlm(captured), instructionMessage(instruction)], tools };
253
+ }
254
+
255
+ /**
256
+ * The standalone shape: the doomed span reconstructed from session entries,
257
+ * its assistant usage zeroed and its largest tool results cleared until pi's
258
+ * clamp leaves room for the summary (`fit.ts`), no tools, a one-line system
259
+ * prompt. Gets no kept-tail note: it holds only the doomed span. If it fails,
260
+ * pi's own compaction serves. Exported for the unit test.
261
+ */
262
+ export function standaloneRequest(model: Model<Api>, preparation: Preparation, instruction: string, maxTokens: number): Context {
263
+ const messages = [...withoutUsage(convertToLlm(reconstructFromEntries(preparation))), instructionMessage(instruction)];
264
+ return fitToBudget(model, { systemPrompt: STANDALONE_SYSTEM_PROMPT, messages }, maxTokens).request;
265
+ }
266
+
267
+ const instructionMessage = (instruction: string): Message => ({ role: "user", content: instruction, timestamp: Date.now() });
268
+
269
+ /**
270
+ * The verbatim-kept tail of the captured request: everything after the doomed
271
+ * span. pi builds the context as `[compactionSummary?] + one message per entry`
272
+ * (`sessionEntryToContextMessages`) and `prepareCompaction` derives
273
+ * `messagesToSummarize`/`turnPrefixMessages` with the same mapping, so the
274
+ * doomed span is a prefix of the captured array. Alignment is checked role by
275
+ * role; on any mismatch (an extension that inserted a message) no note is made
276
+ * rather than a wrong one. Exported for the unit test.
277
+ */
278
+ export function keptTailOf(
279
+ captured: readonly { role: string; content?: unknown }[],
280
+ preparation: Preparation,
281
+ ): { count: number; opening?: string } | undefined {
282
+ const doomed: { role: string }[] = [
283
+ ...(preparation.previousSummary ? [{ role: "compactionSummary" }] : []),
284
+ ...preparation.messagesToSummarize,
285
+ ...(preparation.isSplitTurn ? preparation.turnPrefixMessages : []),
286
+ ];
287
+ if (doomed.length === 0 || doomed.length > captured.length) return undefined;
288
+ for (let i = 0; i < doomed.length; i++) {
289
+ if (captured[i].role !== doomed[i].role) return undefined;
290
+ }
291
+ const count = captured.length - doomed.length;
292
+ if (count === 0) return { count };
293
+ return { count, opening: openingText(captured[doomed.length]) };
294
+ }
295
+
296
+ /** The first ~80 characters of a message's text, single-line, for the landmark quote. */
297
+ function openingText(message: { content?: unknown }): string | undefined {
298
+ const content = message.content;
299
+ const text =
300
+ typeof content === "string"
301
+ ? content
302
+ : Array.isArray(content)
303
+ ? (content.find((b) => b && typeof b === "object" && (b as { type?: string }).type === "text") as { text?: string } | undefined)?.text
304
+ : undefined;
305
+ if (!text) return undefined;
306
+ const line = text.replace(/\s+/g, " ").trim();
307
+ return line.length > 80 ? `${line.slice(0, 80)}…` : line;
308
+ }
309
+
310
+ /**
311
+ * The doomed span as messages, from session entries — the standalone path's
312
+ * input.
208
313
  *
209
314
  * A previous compaction's summary is excluded from messagesToSummarize
210
315
  * (prepareCompaction starts after that entry), yet the live context carries it
@@ -213,7 +318,7 @@ export default function compactionExtension(pi: ExtensionAPI) {
213
318
  * turn's discarded prefix has no separate summary on this path, so it is
214
319
  * summarized with the rest.
215
320
  */
216
- function reconstructFromEntries(preparation: SessionBeforeCompactEvent["preparation"]): AgentMessage[] {
321
+ function reconstructFromEntries(preparation: Preparation): AgentMessage[] {
217
322
  const doomed = preparation.isSplitTurn
218
323
  ? [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages]
219
324
  : preparation.messagesToSummarize;
@@ -132,6 +132,13 @@ REMINDER: Do NOT call any tools. Respond with plain text only — an <analysis>
132
132
  export function buildCompactionInstruction(options: {
133
133
  reason: "manual" | "threshold" | "overflow";
134
134
  customInstructions?: string;
135
+ /**
136
+ * How many trailing messages of the conversation pi keeps verbatim after
137
+ * the cut (its `keepRecentTokens` tail). When set and > 0 the instruction
138
+ * says so, so the summary covers the part that is actually discarded
139
+ * instead of re-telling what the model will still see in full.
140
+ */
141
+ keptTail?: { count: number; opening?: string };
135
142
  }): string {
136
143
  const notice =
137
144
  options.reason === "manual"
@@ -141,10 +148,23 @@ export function buildCompactionInstruction(options: {
141
148
  if (options.customInstructions?.trim()) {
142
149
  parts.push(`<system-reminder>\n## Compact Instructions\n${options.customInstructions.trim()}\n</system-reminder>`);
143
150
  }
144
- parts.push(`<system-reminder>\n${notice}\n${COMPACTION_INSTRUCTION}\n</system-reminder>`);
151
+ const tail = options.keptTail && options.keptTail.count > 0 ? keptTailNote(options.keptTail) : "";
152
+ parts.push(`<system-reminder>\n${notice}${tail}\n${COMPACTION_INSTRUCTION}\n</system-reminder>`);
145
153
  return parts.join("\n\n");
146
154
  }
147
155
 
156
+ /**
157
+ * The scope note for pi's kept tail: the summary replaces only what precedes
158
+ * it. Quoting how the first kept message opens gives the model a landmark it
159
+ * can find in the transcript, since a bare count is easy to miscount.
160
+ */
161
+ export function keptTailNote(tail: { count: number; opening?: string }): string {
162
+ const landmark = tail.opening?.trim() ? `, beginning with the message that opens "${tail.opening.trim()}"` : "";
163
+ return ` Only the earlier part of the conversation is being summarized: the final ${tail.count} ${
164
+ tail.count === 1 ? "message" : "messages"
165
+ }${landmark}, stay in context verbatim after the summary. Do not summarize them; cover everything before them, and use them only to judge which earlier details still matter.`;
166
+ }
167
+
148
168
  /**
149
169
  * Claude Code's continuation preamble, baked into the *stored* summary so
150
170
  * every future context — and the reattached compactionSummary message on a
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Claude Code's `<total_tokens>N tokens left</total_tokens>` signal, pure half.
3
+ *
4
+ * What the number is (read off a live Claude Code session and the 2.1.261
5
+ * capture, findings §14): a PER-TURN token budget, not the context window. It
6
+ * reads 15,000,000 at every turn start — the last line of the system prompt
7
+ * before the gitStatus block, and the end of every user message — and counts
8
+ * down on the tool results within the turn by roughly what each round added
9
+ * (tool results, assistant output); the next user message resets it. Constant
10
+ * at the cached-prefix positions, variable only on the tail, so it never busts
11
+ * the prefix. One Code approximates the countdown as the growth of the context
12
+ * since the turn started (pi's context-usage estimate), the same quantity to
13
+ * within the estimator's error. `CC_TOTAL_TOKENS_BUDGET` overrides the figure.
14
+ */
15
+
16
+ /** Claude Code's per-turn budget as observed; the meaning of the figure is CC's max tokens per turn with compaction. */
17
+ export const DEFAULT_TURN_TOKEN_BUDGET = 15_000_000;
18
+
19
+ /** The configured budget: `CC_TOTAL_TOKENS_BUDGET` when it is a positive number, else Claude Code's default. */
20
+ export function turnTokenBudget(env: Record<string, string | undefined> = process.env): number {
21
+ const raw = env.CC_TOTAL_TOKENS_BUDGET;
22
+ const n = raw === undefined || raw.trim() === "" ? Number.NaN : Number(raw);
23
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : DEFAULT_TURN_TOKEN_BUDGET;
24
+ }
25
+
26
+ function finite(n: number | null | undefined): number | undefined {
27
+ return typeof n === "number" && Number.isFinite(n) ? n : undefined;
28
+ }
29
+
30
+ /**
31
+ * One turn's countdown. `startTurn` takes the context size the turn begins
32
+ * from; `left` takes the size now and returns what remains of the budget. The
33
+ * value never rises within a turn (as CC's does not), the first known size
34
+ * becomes the baseline when the turn started without an estimate, and an
35
+ * unknown size repeats the last value.
36
+ */
37
+ export class TurnBudget {
38
+ private start: number | undefined;
39
+ private spent = 0;
40
+
41
+ constructor(readonly budget: number) {}
42
+
43
+ startTurn(contextTokens: number | null | undefined): void {
44
+ this.start = finite(contextTokens);
45
+ this.spent = 0;
46
+ }
47
+
48
+ left(contextTokens: number | null | undefined): number {
49
+ const now = finite(contextTokens);
50
+ if (now !== undefined) {
51
+ if (this.start === undefined) this.start = now;
52
+ this.spent = Math.max(this.spent, now - this.start);
53
+ }
54
+ return Math.max(0, this.budget - this.spent);
55
+ }
56
+ }
57
+
58
+ /** The bare block Claude Code appends, byte-for-byte. */
59
+ export function totalTokensBlock(left: number): string {
60
+ return `<total_tokens>${left} tokens left</total_tokens>`;
61
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * context-budget extension — Claude Code's `<total_tokens>N tokens left</total_tokens>`
3
+ * line, in the three places CC puts it (budget.ts says what the number is):
4
+ *
5
+ * - the end of every user message: the constant budget, a raw `sticky-append`
6
+ * reminder keyed once per session (`since: 0`, so a resumed session's earlier
7
+ * user messages carry it too — byte-identical to what they carried before);
8
+ * - every tool result: the countdown, a RAW `last-append` one-shot queued at
9
+ * `tool_result` time and written into the stored result by system-reminder
10
+ * (so it persists in the session like CC's and no later request re-caches the
11
+ * message). That is why this extension sits BEFORE `system-reminder` in the
12
+ * package manifest — pi runs `tool_result` handlers in load order, and the
13
+ * block must be queued before system-reminder's handler takes the one-shots.
14
+ * It emits only at runtime, never at load, so the "listeners load first" rule
15
+ * for the bus is not at stake;
16
+ * - the system prompt, before the gitStatus block — appended by the
17
+ * system-prompt extension from the same pure module.
18
+ *
19
+ * A turn starts at the first `agent_start` after a settle (whoever opened it: a
20
+ * prompt or a harness notification) and its baseline is pi's context estimate
21
+ * at that moment. CC_TOTAL_TOKENS=0 turns all three off.
22
+ */
23
+
24
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
+ import { REMINDER_CHANNEL, type ReminderPayload } from "../lib/reminders.ts";
26
+ import { totalTokensBlock, TurnBudget, turnTokenBudget } from "./budget.ts";
27
+
28
+ const USER_MESSAGE_KEY = "total-tokens";
29
+
30
+ export default function contextBudgetExtension(pi: ExtensionAPI) {
31
+ const enabled = () => process.env.CC_TOTAL_TOKENS !== "0";
32
+ const budget = new TurnBudget(turnTokenBudget());
33
+ /** True between the first agent_start of a turn and agent_settled. */
34
+ let busy = false;
35
+
36
+ pi.on("session_start", () => {
37
+ busy = false;
38
+ if (!enabled()) return;
39
+ pi.events.emit(REMINDER_CHANNEL, {
40
+ text: totalTokensBlock(budget.budget),
41
+ scope: "every-turn",
42
+ key: USER_MESSAGE_KEY,
43
+ placement: "sticky-append",
44
+ raw: true,
45
+ since: 0,
46
+ } satisfies ReminderPayload);
47
+ });
48
+
49
+ pi.on("agent_start", (_event, ctx) => {
50
+ if (busy) return;
51
+ busy = true;
52
+ budget.startTurn(ctx.getContextUsage()?.tokens);
53
+ });
54
+ pi.on("agent_settled", () => {
55
+ busy = false;
56
+ });
57
+
58
+ pi.on("tool_result", (_event, ctx) => {
59
+ if (!enabled()) return;
60
+ pi.events.emit(REMINDER_CHANNEL, {
61
+ text: totalTokensBlock(budget.left(ctx.getContextUsage()?.tokens)),
62
+ placement: "last-append",
63
+ raw: true,
64
+ } satisfies ReminderPayload);
65
+ });
66
+ }
@@ -1,10 +1,16 @@
1
1
  /**
2
- * context-management extension — asks Anthropic to drop old thinking blocks.
2
+ * context-management extension — asks Anthropic to KEEP old thinking blocks.
3
3
  *
4
4
  * Claude Code sends `context_management: { edits: [{ type: "clear_thinking_20251015",
5
- * keep: "all" }] }` on every request, which keeps long sessions from carrying
6
- * every past reasoning block. pi does not, so a long One Code session
7
- * accumulates more context than Claude Code would.
5
+ * keep: "all" }] }` on every request. Despite the edit's name, `keep: "all"` is
6
+ * a cache preserver, not a trimmer: models that strip previous-turn thinking by
7
+ * default (Haiku through 4.5, Sonnet <= 4.5, Opus <= 4.1) would otherwise
8
+ * invalidate the messages cache at the first stripped block; with the edit the
9
+ * API keeps every block and the cached prefix survives (Anthropic's docs: "keeps
10
+ * all thinking blocks (maximizes cache hits)"). It does not reduce context. pi
11
+ * does not send it, so a long One Code session on such a model would re-cache
12
+ * its history every turn without this extension — do not "optimize" the
13
+ * parameter away (findings §9, CACHE-REVIEW-2026-09-04 L1).
8
14
  *
9
15
  * Two things the parameter needs, both found empirically against the live API
10
16
  * (2026-08-05, api.anthropic.com):
@@ -14,9 +20,11 @@
14
20
  * Setting the header from `before_provider_headers` REPLACES the value pi
15
21
  * computes at client creation (extension headers merge last), so we must
16
22
  * rebuild pi's own beta list and append ours — see anthropicBetas(). That
17
- * replicates pi-ai's createClient logic (pinned v0.83): OAuth identity betas,
18
- * fine-grained tool streaming for models without eager input streaming, and
19
- * interleaved thinking for non-adaptive models. Re-check on pi upgrades.
23
+ * replicates pi-ai's createClient logic: OAuth identity betas, fine-grained
24
+ * tool streaming for models without eager input streaming, interleaved
25
+ * thinking for non-adaptive models, and server-side fallback for models with
26
+ * `compat.allowedFallbackModels` (pi also puts a `fallbacks` body field on
27
+ * those, which 400s if this beta is missing). Re-check on pi upgrades.
20
28
  *
21
29
  * - `thinking` enabled or an adaptive-thinking model, or the API 400s with
22
30
  * "`clear_thinking_20251015` strategy requires `thinking` to be enabled or
@@ -33,6 +41,8 @@
33
41
  import { readFileSync, statSync } from "node:fs";
34
42
  import { join } from "node:path";
35
43
  import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
44
+ import { looksLikeAnthropicRequest } from "../lib/anthropic-payload.ts";
45
+ import { CACHE_RETENTION_ENV, mainSessionCacheRetention } from "../lib/cache-retention.ts";
36
46
 
37
47
  const CLEAR_THINKING_EDIT = { type: "clear_thinking_20251015", keep: "all" };
38
48
  const CONTEXT_MANAGEMENT_BETA = "context-management-2025-06-27";
@@ -42,10 +52,18 @@ const CONTEXT_MANAGEMENT_BETA = "context-management-2025-06-27";
42
52
  const OAUTH_BETAS = ["claude-code-20250219", "oauth-2025-04-20"];
43
53
  const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14";
44
54
  const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";
55
+ // Newer pi versions add a `fallbacks` body field for models whose compat
56
+ // carries `allowedFallbackModels` (server-side fallback, e.g. opus-5 →
57
+ // opus-4-8), gated on this beta. Because we overwrite the whole header, we must
58
+ // re-add the beta whenever pi added the body field, or the API 400s with
59
+ // "fallbacks: Extra inputs are not permitted" (verified live, api.anthropic.com
60
+ // 2026-09-01). Mirrors pi's shouldUseServerSideFallbackBeta.
61
+ const SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01";
45
62
 
46
63
  export interface AnthropicModelCompat {
47
64
  forceAdaptiveThinking?: boolean;
48
65
  supportsEagerToolInputStreaming?: boolean;
66
+ allowedFallbackModels?: unknown[];
49
67
  }
50
68
 
51
69
  /** pi's beta list for this model/auth, with the context-management beta appended. */
@@ -55,19 +73,15 @@ export function anthropicBetas(oauth: boolean, compat: AnthropicModelCompat | un
55
73
  // pi sends this only when tools are present; we always register tools.
56
74
  if (compat?.supportsEagerToolInputStreaming === false) betas.push(FINE_GRAINED_TOOL_STREAMING_BETA);
57
75
  if (compat?.forceAdaptiveThinking !== true) betas.push(INTERLEAVED_THINKING_BETA);
76
+ // pi puts a `fallbacks` body field on these models; the beta must ride along.
77
+ if ((compat?.allowedFallbackModels?.length ?? 0) > 0) betas.push(SERVER_SIDE_FALLBACK_BETA);
58
78
  betas.push(CONTEXT_MANAGEMENT_BETA);
59
79
  return betas.join(",");
60
80
  }
61
81
 
62
- /** Anthropic Messages API shape: `messages` + `max_tokens`, and not an OpenAI `input`. */
63
- export function looksLikeAnthropicRequest(payload: unknown): boolean {
64
- if (!payload || typeof payload !== "object") return false;
65
- const record = payload as Record<string, unknown>;
66
- if (!Array.isArray(record.messages)) return false;
67
- if ("input" in record) return false;
68
- const model = typeof record.model === "string" ? record.model : "";
69
- return model.includes("claude") || typeof record.max_tokens === "number";
70
- }
82
+ // The payload predicate lives in lib (pure, shared with compaction and
83
+ // tool-search); re-exported so existing importers keep working.
84
+ export { looksLikeAnthropicRequest } from "../lib/anthropic-payload.ts";
71
85
 
72
86
  /** The edit is rejected unless thinking is enabled or the model is adaptive. */
73
87
  export function clearThinkingApplies(payload: Record<string, unknown>, forceAdaptiveThinking: boolean): boolean {
@@ -130,6 +144,16 @@ export function clearThinkingEnabled(flag: string | undefined, model: Anthropici
130
144
  export default function contextManagementExtension(pi: ExtensionAPI) {
131
145
  const flag = () => process.env.CC_CLEAR_THINKING;
132
146
 
147
+ // Prompt-cache TTL for the main session: one hour in the interactive modes,
148
+ // the way Claude Code caches (a pause between turns no longer re-writes the
149
+ // context); a `-p`/json run keeps pi's 5-minute default, and so do
150
+ // in-process children (lib/cache-retention.ts). pi reads the variable per
151
+ // request, so setting it here, before the first prompt, is early enough.
152
+ pi.on("session_start", (_event, ctx) => {
153
+ const value = mainSessionCacheRetention(ctx.mode, process.env[CACHE_RETENTION_ENV]);
154
+ if (value) process.env[CACHE_RETENTION_ENV] = value;
155
+ });
156
+
133
157
  pi.on("before_provider_headers", (event, ctx) => {
134
158
  const model = ctx.model as AnthropicishModel | undefined;
135
159
  if (!clearThinkingEnabled(flag(), model)) return;