pi-ui-extend 1.0.39 → 1.0.41

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 (113) hide show
  1. package/README.md +1 -1
  2. package/dist/app/commands/command-registry.js +2 -2
  3. package/dist/app/commands/command-session-actions.d.ts +0 -1
  4. package/dist/app/commands/command-session-actions.js +22 -13
  5. package/dist/app/icons.d.ts +14 -0
  6. package/dist/app/icons.js +33 -0
  7. package/dist/app/rendering/conversation-tool-renderer.js +2 -2
  8. package/dist/app/rendering/dcp-stats.d.ts +6 -1
  9. package/dist/app/rendering/dcp-stats.js +214 -46
  10. package/dist/app/rendering/editor-panels.js +8 -5
  11. package/dist/app/session/lazy-session-manager.js +12 -1
  12. package/dist/app/session/tabs-controller.d.ts +2 -5
  13. package/dist/app/session/tabs-controller.js +12 -21
  14. package/dist/app/subagents/subagents-model.d.ts +14 -1
  15. package/dist/app/subagents/subagents-model.js +34 -15
  16. package/dist/app/types.d.ts +2 -0
  17. package/dist/bundled-extensions/session-title/config.js +1 -1
  18. package/dist/markdown-format.js +27 -9
  19. package/dist/schemas/pi-tools-suite-schema.d.ts +29 -16
  20. package/dist/schemas/pi-tools-suite-schema.js +46 -31
  21. package/external/pi-tools-suite/README.md +392 -52
  22. package/external/pi-tools-suite/docs/browser-qa-subagent.md +31 -21
  23. package/external/pi-tools-suite/docs/context-gateway-p00-adr.md +216 -0
  24. package/external/pi-tools-suite/docs/context-gateway-p01n-gate-review.md +122 -0
  25. package/external/pi-tools-suite/docs/context-gateway-p01n-measurement.md +133 -0
  26. package/external/pi-tools-suite/docs/context-gateway-p01r-ra-evidence.md +111 -0
  27. package/external/pi-tools-suite/docs/context-gateway-p01r-rb-evidence.md +100 -0
  28. package/external/pi-tools-suite/docs/context-gateway-p01r-rc-evidence.md +69 -0
  29. package/external/pi-tools-suite/docs/context-gateway-p01r-rd-evidence.md +100 -0
  30. package/external/pi-tools-suite/docs/context-gateway-p01r-re-evidence.md +74 -0
  31. package/external/pi-tools-suite/docs/context-gateway-p01r-rf-evidence.md +153 -0
  32. package/external/pi-tools-suite/docs/context-gateway-p01r-rg-evidence.md +235 -0
  33. package/external/pi-tools-suite/docs/evals.md +684 -0
  34. package/external/pi-tools-suite/docs/subagent-model-pools.md +109 -0
  35. package/external/pi-tools-suite/package.json +10 -3
  36. package/external/pi-tools-suite/src/async-subagents/{private-skills → agents}/browser-qa/scripts/browser-qa-runner.mjs +82 -1
  37. package/external/pi-tools-suite/src/async-subagents/{private-skills/browser-qa/SKILL.md → agents/browser-qa.md} +261 -12
  38. package/external/pi-tools-suite/src/async-subagents/agents/implement.md +20 -0
  39. package/external/pi-tools-suite/src/async-subagents/agents/oracle.md +16 -0
  40. package/external/pi-tools-suite/src/async-subagents/agents/research.md +18 -0
  41. package/external/pi-tools-suite/src/async-subagents/agents/verify.md +18 -0
  42. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +27 -243
  43. package/external/pi-tools-suite/src/async-subagents/commands.ts +6 -2
  44. package/external/pi-tools-suite/src/async-subagents/core/agent-catalog.ts +41 -0
  45. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +13 -93
  46. package/external/pi-tools-suite/src/async-subagents/core/agents-dir.ts +494 -0
  47. package/external/pi-tools-suite/src/async-subagents/core/browser-qa.ts +9 -0
  48. package/external/pi-tools-suite/src/async-subagents/core/config.ts +200 -143
  49. package/external/pi-tools-suite/src/async-subagents/core/model-fallback.ts +1 -1
  50. package/external/pi-tools-suite/src/async-subagents/core/model-selection.ts +54 -0
  51. package/external/pi-tools-suite/src/async-subagents/core/prompt.ts +7 -6
  52. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +52 -45
  53. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +12 -4
  54. package/external/pi-tools-suite/src/async-subagents/index.ts +11 -1
  55. package/external/pi-tools-suite/src/async-subagents/lib.ts +6 -2
  56. package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +46 -18
  57. package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +3 -2
  58. package/external/pi-tools-suite/src/async-subagents/types.ts +2 -0
  59. package/external/pi-tools-suite/src/coding-discipline/index.ts +41 -142
  60. package/external/pi-tools-suite/src/config.ts +1 -22
  61. package/external/pi-tools-suite/src/context-gateway/accounting.ts +151 -0
  62. package/external/pi-tools-suite/src/context-gateway/config.ts +111 -0
  63. package/external/pi-tools-suite/src/context-gateway/index.ts +160 -0
  64. package/external/pi-tools-suite/src/context-gateway/metadata-normalization.ts +88 -0
  65. package/external/pi-tools-suite/src/context-gateway/storeless-capabilities.ts +89 -0
  66. package/external/pi-tools-suite/src/context-gateway/telemetry.ts +429 -0
  67. package/external/pi-tools-suite/src/context-gateway/test-output-parser.ts +326 -0
  68. package/external/pi-tools-suite/src/context-gateway/types.ts +152 -0
  69. package/external/pi-tools-suite/src/dcp/auto-compress-budget.ts +106 -0
  70. package/external/pi-tools-suite/src/dcp/auto-compress.ts +810 -106
  71. package/external/pi-tools-suite/src/dcp/commands.ts +64 -139
  72. package/external/pi-tools-suite/src/dcp/compress-tool.ts +369 -35
  73. package/external/pi-tools-suite/src/dcp/compression-blocks.ts +510 -64
  74. package/external/pi-tools-suite/src/dcp/compression-preview.ts +113 -0
  75. package/external/pi-tools-suite/src/dcp/compression-progress.ts +70 -0
  76. package/external/pi-tools-suite/src/dcp/config.ts +36 -61
  77. package/external/pi-tools-suite/src/dcp/conversation-index.ts +421 -0
  78. package/external/pi-tools-suite/src/dcp/debug-log.ts +7 -5
  79. package/external/pi-tools-suite/src/dcp/index.ts +617 -203
  80. package/external/pi-tools-suite/src/dcp/journal.ts +566 -0
  81. package/external/pi-tools-suite/src/dcp/progress-controller.ts +244 -0
  82. package/external/pi-tools-suite/src/dcp/prompts.ts +10 -7
  83. package/external/pi-tools-suite/src/dcp/provider-tool-results.ts +189 -0
  84. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +298 -78
  85. package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +173 -281
  86. package/external/pi-tools-suite/src/dcp/pruner-emergency.ts +2 -4
  87. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +17 -5
  88. package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +11 -1
  89. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +30 -82
  90. package/external/pi-tools-suite/src/dcp/pruner-tools.ts +22 -133
  91. package/external/pi-tools-suite/src/dcp/pruner.ts +18 -33
  92. package/external/pi-tools-suite/src/dcp/recovery.ts +129 -0
  93. package/external/pi-tools-suite/src/dcp/shadow-plan.ts +127 -0
  94. package/external/pi-tools-suite/src/dcp/state-transaction.ts +102 -0
  95. package/external/pi-tools-suite/src/dcp/state.ts +158 -580
  96. package/external/pi-tools-suite/src/dcp/ui.ts +1 -0
  97. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +55 -220
  98. package/external/pi-tools-suite/src/index.ts +9 -0
  99. package/external/pi-tools-suite/src/model-tools/index.ts +76 -42
  100. package/external/pi-tools-suite/src/repo-discovery/index.ts +84 -18
  101. package/external/pi-tools-suite/src/repo-discovery/native-compact.ts +458 -0
  102. package/external/pi-tools-suite/src/session-recovery/index.ts +189 -43
  103. package/external/pi-tools-suite/src/tool-descriptions.ts +43 -38
  104. package/external/pi-tools-suite/src/truncation-metadata-normalizer/index.ts +17 -0
  105. package/package.json +6 -6
  106. package/schemas/pi-tools-suite.json +159 -78
  107. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/auth-scaffold-spec.md +0 -78
  108. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-design.md +0 -223
  109. package/external/pi-tools-suite/src/dcp/state-persistence.ts +0 -195
  110. /package/external/pi-tools-suite/src/async-subagents/{private-skills/browser-qa/references → agents/browser-qa/examples}/qa-auth.example.jsonc +0 -0
  111. /package/external/pi-tools-suite/src/async-subagents/{private-skills/browser-qa/references → agents/browser-qa/examples}/qa-flow.example.jsonc +0 -0
  112. /package/external/pi-tools-suite/src/async-subagents/{private-skills → agents}/browser-qa/vendor/fflate.LICENSE +0 -0
  113. /package/external/pi-tools-suite/src/async-subagents/{private-skills → agents}/browser-qa/vendor/fflate.mjs +0 -0
@@ -8,16 +8,18 @@ import {
8
8
  createState,
9
9
  resetState,
10
10
  createInputFingerprint,
11
- restoreState,
12
- inheritCompressionBlocks,
13
11
  } from "./state.js"
14
12
  import {
15
- cleanupStaleDcpStateFiles,
16
- loadDcpState,
17
- loadDcpStateFromSessionFile,
18
- resetDcpPersistenceDedup,
19
- saveDcpState,
20
- } from "./state-persistence.js"
13
+ appendDcpJournalDelta,
14
+ appendDcpJournalOperation,
15
+ buildDcpJournalDelta,
16
+ createDcpJournalInit,
17
+ createDcpJournalMirror,
18
+ DcpJournalError,
19
+ readDcpJournalBranch,
20
+ replayDcpJournal,
21
+ type DcpJournalMirror,
22
+ } from "./journal.js"
21
23
  import {
22
24
  SYSTEM_PROMPT,
23
25
  MANUAL_MODE_SYSTEM_PROMPT,
@@ -28,9 +30,9 @@ import {
28
30
  } from "./prompts.js"
29
31
  import {
30
32
  applyPruning,
31
- injectNudge,
32
33
  getNudgeType,
33
34
  detectCompressionCandidate,
35
+ detectEmergencyCompressionCandidate,
34
36
  detectMessageCompressionCandidates,
35
37
  analyzeEmergencyCurrentTurn,
36
38
  emergencyPressureState,
@@ -39,25 +41,37 @@ import {
39
41
  appendConcreteNudgeGuidance,
40
42
  applyAnchoredNudges,
41
43
  clearDcpNudgeAnchors,
44
+ hasCacheSafeNudgeCarrier,
42
45
  nudgeTypeLabel,
43
46
  upsertNudgeAnchor,
44
47
  getActiveSummaryTokenEstimate,
45
48
  resolveContextThresholds,
46
49
  estimateTokens,
47
50
  } from "./pruner.js"
48
- import { stripStaleDcpMetadataFromMessage } from "./pruner-metadata.js"
51
+ import { estimateMessageTokens, stripStaleDcpMetadataFromMessage } from "./pruner-metadata.js"
49
52
  import { summarizeDcpState, writeDcpDebugLog } from "./debug-log.js"
50
53
  import type { DcpNudgeType } from "./pruner-types.js"
51
54
  import { registerCompressTool } from "./compress-tool.js"
52
- import { decideAutoCompress, createAutoCompressionBlock } from "./auto-compress.js"
55
+ import {
56
+ AutoCompressionBlockedError,
57
+ decideAutoCompress,
58
+ createAutoCompressionBlock,
59
+ } from "./auto-compress.js"
53
60
  import { DCP_STATS_MESSAGE_TYPE, registerCommands } from "./commands.js"
54
61
  import { normalizeDcpContextUsage } from "./ui.js"
55
62
  import { safeGetContextUsage } from "../context-usage.js"
56
63
  import {
57
64
  collectProviderToolResultEvidence,
58
65
  providerPayloadIncludesToolResult,
66
+ providerPayloadIncludesReminder,
67
+ providerPayloadRevision,
68
+ ProviderEvidenceTracker,
59
69
  } from "./provider-tool-results.js"
60
- import { reconcileInheritedCompressionBlocks } from "./pruner-compression-blocks.js"
70
+ import { rehydrateToolRecordsFromMessages } from "./recovery.js"
71
+ import { inferDcpBlockedReason, planDcpBudget } from "./progress-controller.js"
72
+ import { createBudgetedAutoCompressionBlock } from "./auto-compress-budget.js"
73
+ import { outstandingCompressionTokens, resetCompressionProgress, routineRecoveryTokens, trackCompressionProgress } from "./compression-progress.js"
74
+ import { captureDcpTransactionGuard, cloneDcpTransactionState, runDcpStateTransaction, invalidateDcpStateOwner } from "./state-transaction.js"
61
75
 
62
76
  // ---------------------------------------------------------------------------
63
77
  // Helpers
@@ -103,10 +117,8 @@ function isUserVisibleOnlyMessage(message: any): boolean {
103
117
  return message.details?.userVisibleOnly === true
104
118
  }
105
119
 
106
- // Control-plane custom message types filtered out of the transcript.
107
- // `dcp-message-ids` is retained only for backward-compat with logs written by
108
- // the removed inline control-message path.
109
- const DCP_CONTROL_PLANE_CUSTOM_TYPES = new Set(["dcp-state", "dcp-nudge", "dcp-message-ids"])
120
+ // Diagnostic DCP custom messages are never provider context.
121
+ const DCP_CONTROL_PLANE_CUSTOM_TYPES = new Set(["dcp-nudge"])
110
122
  const SUMMARY_BUFFER_MAX_CONTEXT_BONUS = 0.05
111
123
 
112
124
  function isDcpControlPlaneMessage(message: any): boolean {
@@ -117,9 +129,9 @@ function isDcpControlPlaneMessage(message: any): boolean {
117
129
  // Module export
118
130
  // ---------------------------------------------------------------------------
119
131
 
120
- export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
132
+ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config?: ReturnType<typeof loadConfig>; state?: ReturnType<typeof createState> } = {}): Promise<void> {
121
133
  // ── 1. Load config ────────────────────────────────────────────────────────
122
- const config = loadConfig()
134
+ const config = dependencies.config ?? loadConfig()
123
135
  const configForContext = (ctx: unknown) => resolveModelConfig(config, modelKeysFromContext(ctx))
124
136
  const hasEnabledModelOverride = Object.values(config.modelOverrides).some(
125
137
  (override) => override.enabled === true,
@@ -128,9 +140,123 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
128
140
  if (!config.enabled && !hasEnabledModelOverride) return
129
141
 
130
142
  // ── 2. Create state ───────────────────────────────────────────────────────
131
- const state = createState()
132
- let pendingInheritedBlockReconciliation = false
133
- const pendingProviderToolIds = new Set<string>()
143
+ const state = dependencies.state ?? createState()
144
+ let journalMirror: DcpJournalMirror | undefined
145
+ let journalSupported = false
146
+ let journalBlockedReason: string | undefined
147
+ let journalPersistent = false
148
+ const providerEvidenceTracker = new ProviderEvidenceTracker()
149
+ let providerEvidenceCommitQueue = Promise.resolve()
150
+ let latestProviderOpportunityAvailable = false
151
+ let latestProviderOpportunityKind: "routine" | "emergency" | undefined
152
+ let latestProviderReminder: string | undefined
153
+ const warnedProgress = new Set<string>()
154
+ const isJournalSessionSupported = () => journalSupported && journalBlockedReason === undefined
155
+ const persistJournalState = async (
156
+ ctx: ExtensionContext,
157
+ targetState: ReturnType<typeof createState>,
158
+ publication: { beforePublish?: () => void; onPublished?: () => void } = {},
159
+ ): Promise<void> => {
160
+ if (!isJournalSessionSupported() || !journalMirror) {
161
+ throw new DcpJournalError(journalBlockedReason ?? "DCP journal is not initialized for this session")
162
+ }
163
+ if (!journalPersistent) {
164
+ const delta = buildDcpJournalDelta(targetState, journalMirror)
165
+ if (!delta) return
166
+ publication.beforePublish?.()
167
+ journalMirror = createDcpJournalMirror(targetState, delta.operationId)
168
+ publication.onPublished?.()
169
+ return
170
+ }
171
+ journalMirror = appendDcpJournalDelta(pi, ctx, targetState, journalMirror, publication)
172
+ }
173
+ const branchHasConversation = (branch: readonly any[]): boolean => branch.some((entry) =>
174
+ entry?.type === "message" || entry?.type === "custom_message" || entry?.type === "compaction" || entry?.type === "branch_summary",
175
+ )
176
+ const loadJournalState = async (ctx: ExtensionContext, allowInitialize: boolean): Promise<void> => {
177
+ journalMirror = undefined
178
+ journalSupported = false
179
+ journalBlockedReason = undefined
180
+ journalPersistent = false
181
+ const branch = await readDcpJournalBranch(ctx)
182
+ const manager = ctx.sessionManager as any
183
+ const persistentSession = (typeof manager.isPersisted === "function" && manager.isPersisted())
184
+ || (typeof manager.getSessionFile === "function" && Boolean(manager.getSessionFile()))
185
+ try {
186
+ const replay = replayDcpJournal(branch, state)
187
+ if (replay.initialized && replay.lastOperationId) {
188
+ journalSupported = true
189
+ journalPersistent = persistentSession
190
+ journalMirror = createDcpJournalMirror(state, replay.lastOperationId)
191
+ return
192
+ }
193
+ if (allowInitialize && !branchHasConversation(branch)) {
194
+ const init = createDcpJournalInit()
195
+ if (persistentSession) appendDcpJournalOperation(pi, ctx, init)
196
+ journalSupported = true
197
+ journalPersistent = persistentSession
198
+ journalMirror = createDcpJournalMirror(state, init.operationId)
199
+ return
200
+ }
201
+ // Clean break: a pre-journal conversation is deliberately unsupported.
202
+ // Do not inspect external state, infer prior blocks, or create a
203
+ // compatibility checkpoint from existing history.
204
+ journalSupported = false
205
+ } catch (error) {
206
+ journalBlockedReason = error instanceof Error ? error.message : String(error)
207
+ journalSupported = false
208
+ journalMirror = undefined
209
+ }
210
+ }
211
+ const ensureEphemeralJournal = (ctx: ExtensionContext): void => {
212
+ if (journalSupported || journalBlockedReason) return
213
+ const manager = ctx.sessionManager as any
214
+ const persisted = typeof manager.isPersisted === "function" ? manager.isPersisted() : false
215
+ const sessionFile = typeof manager.getSessionFile === "function" ? manager.getSessionFile() : undefined
216
+ if (persisted || sessionFile) return
217
+ let branch: unknown[] = []
218
+ try {
219
+ const current = manager.getBranch?.()
220
+ branch = Array.isArray(current) ? current : []
221
+ } catch { return }
222
+ if (branch.length > 0) return
223
+ const init = createDcpJournalInit()
224
+ journalSupported = true
225
+ journalPersistent = false
226
+ journalMirror = createDcpJournalMirror(state, init.operationId)
227
+ }
228
+ const warnProgress = (ctx: ExtensionContext, reason: string, message: string) => {
229
+ const key = `${state.sessionEpoch}:${reason}`
230
+ if (warnedProgress.has(key)) return
231
+ warnedProgress.add(key)
232
+ writeDcpDebugLog(configForContext(ctx), "context.progress_warning", { reason, message }, ctx)
233
+ try {
234
+ pi.appendEntry("dcp-nudge", {
235
+ event: "progress-blocked", reason, message,
236
+ ignoredOpportunities: state.consecutiveIgnoredNudges,
237
+ remainingRecoveryTokens: state.compressionProgress?.remainingTokens,
238
+ createdAt: Date.now(),
239
+ })
240
+ } catch { /* Diagnostic only; do not turn an explicit blocked state into an extension failure. */ }
241
+ try { ctx.ui.notify(message, "warning") } catch { /* Headless/notification failure is not a persistence failure. */ }
242
+ }
243
+ const invalidateOwner = () => {
244
+ invalidateDcpStateOwner(state)
245
+ providerEvidenceTracker.reset()
246
+ latestProviderOpportunityAvailable = false
247
+ latestProviderOpportunityKind = undefined
248
+ latestProviderReminder = undefined
249
+ resetCompressionProgress(state)
250
+ warnedProgress.clear()
251
+ }
252
+ pi.on("model_select", invalidateOwner)
253
+ pi.on("session_tree", async (_event, ctx) => {
254
+ invalidateOwner()
255
+ resetState(state)
256
+ if (config.manualMode.enabled) state.manualMode = true
257
+ await loadJournalState(ctx, false)
258
+ })
259
+ pi.on("session_compact", invalidateOwner)
134
260
  const appendNudgeTelemetry = (
135
261
  event: "emitted" | "upgraded" | "reapplied",
136
262
  type: DcpNudgeType,
@@ -164,72 +290,59 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
164
290
  }
165
291
 
166
292
  // ── 3. Register compress tool ─────────────────────────────────────────────
167
- registerCompressTool(pi, state, config)
293
+ registerCompressTool(pi, state, config, {
294
+ persistState: persistJournalState,
295
+ isSessionSupported: isJournalSessionSupported,
296
+ })
168
297
 
169
298
  // ── 4. Register /dcp commands ─────────────────────────────────────────────
170
- registerCommands(pi, state, config)
299
+ registerCommands(pi, state, config, {
300
+ persistState: persistJournalState,
301
+ isSessionSupported: isJournalSessionSupported,
302
+ })
171
303
 
172
304
  // ── 5. session_start: restore state from session entries ──────────────────
173
305
  pi.on("session_start", async (event, ctx) => {
174
- // Reset to a clean slate first.
175
306
  resetState(state)
176
- pendingInheritedBlockReconciliation = false
177
-
178
- // Reset dedup hash before loading the sidecar state for this session.
179
- resetDcpPersistenceDedup()
180
-
181
- // Re-apply config baseline so manual mode survives a session_start reset.
182
- if (config.manualMode.enabled) {
183
- state.manualMode = true
184
- }
185
-
186
- // Restore from an overwrite sidecar file keyed by session id. Legacy
187
- // append-only custom `dcp-state` entries are intentionally ignored.
188
- void cleanupStaleDcpStateFiles(ctx).catch(() => {
189
- // Cleanup is opportunistic; stale sidecars must not block session startup.
190
- })
191
- restoreState(state, await loadDcpState(ctx))
192
- pendingInheritedBlockReconciliation = state.compressionBlocks.length > 0
193
-
194
- // fork/resume/new sessions inherit the source conversation but get a fresh
195
- // sidecar; inherit the previous session's compression blocks so they are
196
- // not silently lost (which previously forced re-compressing all history).
197
- if (state.compressionBlocks.length === 0 && event.previousSessionFile) {
307
+ providerEvidenceTracker.reset()
308
+ latestProviderOpportunityAvailable = false
309
+ latestProviderOpportunityKind = undefined
310
+ latestProviderReminder = undefined
311
+ warnedProgress.clear()
312
+ if (config.manualMode.enabled) state.manualMode = true
313
+ await loadJournalState(ctx, event.reason === "new" || event.reason === "startup")
314
+ writeDcpDebugLog(configForContext(ctx), "session_start.journal", {
315
+ reason: event.reason,
316
+ supported: journalSupported,
317
+ blockedReason: journalBlockedReason,
318
+ operations: journalMirror ? "replayed" : "none",
319
+ }, ctx)
320
+ if (!journalSupported && !journalBlockedReason) {
198
321
  try {
199
- const inherited = await loadDcpStateFromSessionFile(event.previousSessionFile)
200
- const added = inheritCompressionBlocks(state, inherited)
201
- if (added > 0) {
202
- pendingInheritedBlockReconciliation = true
203
- writeDcpDebugLog(configForContext(ctx), "session_start.inherited_blocks", {
204
- reason: event.reason,
205
- previousSessionFile: event.previousSessionFile,
206
- added,
207
- totalBlocks: state.compressionBlocks.length,
208
- }, ctx)
209
- // Persist inherited state into this session's own sidecar so a later
210
- // reload restores it directly.
211
- await saveDcpState(ctx, state)
212
- }
213
- } catch {
214
- // Inheritance is best-effort; never block session startup.
215
- }
322
+ ctx.ui.notify(
323
+ "DCP is disabled for this pre-journal session. Start a new session to use the current DCP implementation.",
324
+ "warning",
325
+ )
326
+ } catch { /* Headless mode. */ }
216
327
  }
217
-
218
- // Headless by design: no extension status/footer/widgets are rendered.
219
328
  })
220
329
 
221
- // ── 6. session_shutdown: save state ───────────────────────────────────────
222
- pi.on("session_shutdown", async (_event, ctx) => {
223
- // Force-flush: bypass the dedup hash so the final snapshot is always
224
- // written, guaranteeing the next session_start can restore it.
225
- resetDcpPersistenceDedup()
226
- await saveDcpState(ctx, state)
330
+ // Journal operations are committed at the mutation boundary; shutdown does
331
+ // not write a full runtime snapshot.
332
+ pi.on("session_shutdown", async () => {
333
+ journalMirror = undefined
334
+ journalSupported = false
335
+ journalBlockedReason = undefined
336
+ journalPersistent = false
227
337
  })
228
338
 
229
339
  // ── 7. before_agent_start: inject system prompt ───────────────────────────
230
340
  pi.on("before_agent_start", async (event, _ctx) => {
231
341
  const effectiveConfig = configForContext(_ctx)
232
342
  if (!effectiveConfig.enabled) return { systemPrompt: event.systemPrompt }
343
+ ensureEphemeralJournal(_ctx)
344
+ if (journalBlockedReason) throw new DcpJournalError(`DCP journal is blocked: ${journalBlockedReason}`)
345
+ if (!journalSupported) return { systemPrompt: event.systemPrompt }
233
346
 
234
347
  const promptAddition = state.manualMode
235
348
  ? MANUAL_MODE_SYSTEM_PROMPT
@@ -295,7 +408,9 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
295
408
 
296
409
  // ── 10. context: apply pruning and inject nudges ──────────────────────────
297
410
  pi.on("context", async (event, ctx) => {
411
+ const contextEpoch = state.sessionEpoch
298
412
  const effectiveConfig = configForContext(ctx)
413
+ ensureEphemeralJournal(ctx)
299
414
  const contextMessages = event.messages
300
415
  .filter((message: any) => !isUserVisibleOnlyMessage(message) && !isDcpControlPlaneMessage(message))
301
416
  .map((message: any) => stripStaleDcpMetadataFromMessage(message))
@@ -306,7 +421,7 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
306
421
  filteredMessages: contextMessages.length,
307
422
  outputMessages: messages.length,
308
423
  messageIdControl: "distributed-carriers",
309
- state: summarizeDcpState(state),
424
+ state: summarizeDcpState(state, effectiveConfig),
310
425
  ...details,
311
426
  }, ctx)
312
427
  return { messages }
@@ -317,6 +432,7 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
317
432
  filteredMessages: contextMessages.length,
318
433
  filteredDcpControlPlaneMessages: event.messages.length - contextMessages.length,
319
434
  }, ctx)
435
+ latestProviderOpportunityAvailable = false
320
436
  if (!effectiveConfig.enabled) {
321
437
  writeDcpDebugLog(effectiveConfig, "context.disabled", {
322
438
  inputMessages: event.messages.length,
@@ -324,59 +440,47 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
324
440
  }, ctx)
325
441
  return { messages: contextMessages }
326
442
  }
443
+ if (journalBlockedReason) {
444
+ throw new DcpJournalError(`DCP journal is blocked: ${journalBlockedReason}`)
445
+ }
446
+ if (!journalSupported) {
447
+ return { messages: event.messages.filter((message: any) => !isUserVisibleOnlyMessage(message)) }
448
+ }
449
+ latestProviderOpportunityKind = undefined
450
+ latestProviderReminder = undefined
327
451
  annotateMessagesWithBranchEntryIds(contextMessages, ctx)
328
- if (pendingInheritedBlockReconciliation) {
329
- pendingInheritedBlockReconciliation = false
330
- const reconciliation = reconcileInheritedCompressionBlocks(contextMessages, state)
331
- writeDcpDebugLog(
332
- effectiveConfig,
333
- "context.reconciled_inherited_blocks",
334
- { ...reconciliation },
335
- ctx,
336
- )
337
- if (
338
- reconciliation.activatedBlockIds.length > 0 ||
339
- reconciliation.deactivatedBlockIds.length > 0
340
- ) {
341
- await saveDcpState(ctx, state)
342
- }
452
+ const rehydration = rehydrateToolRecordsFromMessages(contextMessages, state)
453
+ if (rehydration.recordsUpdated > 0) {
454
+ writeDcpDebugLog(effectiveConfig, "context.rehydrated_tool_records", { ...rehydration }, ctx)
343
455
  }
344
- const prunedToolCountBeforeCheckpoint = state.prunedToolIds.size
345
456
  let prunedMessages = applyPruning(contextMessages, state, effectiveConfig)
346
- const automaticPrunesCommitted = state.prunedToolIds.size - prunedToolCountBeforeCheckpoint
347
- if (automaticPrunesCommitted > 0) {
348
- const clearedAnchors = clearDcpNudgeAnchors(state)
349
- await saveDcpState(ctx, state)
350
- writeDcpDebugLog(effectiveConfig, "prune.tool_checkpoint", {
351
- committed: automaticPrunesCommitted,
352
- clearedAnchors,
353
- turn: state.currentTurn,
354
- blockId: state.lastAutomaticPruneBlockId,
355
- state: summarizeDcpState(state),
356
- }, ctx)
357
- }
457
+ // Stable IDs and any pruning decisions that affect this provider-visible
458
+ // projection must be committed before the request can use them.
459
+ await persistJournalState(ctx, state)
358
460
  let candidate = null as ReturnType<typeof detectCompressionCandidate>
461
+ let emergencyCompressionCandidate = null as ReturnType<typeof detectEmergencyCompressionCandidate>
359
462
  let messageCandidates = [] as ReturnType<typeof detectMessageCompressionCandidates>
360
463
  let emergencySelection = null as ReturnType<typeof analyzeEmergencyCurrentTurn> | null
361
464
  let emergencyPruneResult = null as ReturnType<typeof pruneEmergencyCurrentTurn> | null
362
465
 
363
- // In manual mode we still apply pruning strategies (if
364
- // automaticStrategies is on) but skip routine autonomous nudges. Emergency
365
- // max-context nudges are still allowed, matching the manual-mode prompt.
366
- const usage = normalizeDcpContextUsage(safeGetContextUsage(ctx))
466
+ // Manual mode skips routine autonomous nudges and automatic summary
467
+ // creation. The bounded emergency safety path remains separate, matching
468
+ // the manual-mode prompt.
469
+ const nativeUsage = normalizeDcpContextUsage(safeGetContextUsage(ctx))
470
+ const ctxModel = (ctx as any).model
471
+ const fallbackContextWindow = nativeUsage?.contextWindow ?? (
472
+ typeof ctxModel?.contextWindow === "number" && Number.isFinite(ctxModel.contextWindow) && ctxModel.contextWindow > 0
473
+ ? ctxModel.contextWindow
474
+ : undefined
475
+ )
476
+ const repoProjectedTokens = prunedMessages.reduce(
477
+ (sum, message) => sum + estimateMessageTokens(message),
478
+ 0,
479
+ )
480
+ const usage = nativeUsage ?? (fallbackContextWindow
481
+ ? { tokens: null, contextWindow: fallbackContextWindow, percent: null }
482
+ : undefined)
367
483
  if (usage) {
368
- const contextPercent = typeof usage.percent === "number" && Number.isFinite(usage.percent)
369
- ? usage.percent / 100
370
- : typeof usage.tokens === "number"
371
- ? usage.tokens / usage.contextWindow
372
- : undefined
373
-
374
- if (contextPercent === undefined) {
375
- const clearedAnchors = clearDcpNudgeAnchors(state)
376
- if (clearedAnchors > 0) await saveDcpState(ctx, state)
377
- return finishContext("unknown-context-percent", prunedMessages, { clearedAnchors })
378
- }
379
-
380
484
  // Record the observed context window on EVERY context event (before
381
485
  // any early return) so a mid-session model/window downgrade is
382
486
  // detectable even when earlier passes were below threshold. We
@@ -392,34 +496,49 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
392
496
  state.lastContextWindow = currentContextWindow
393
497
  }
394
498
 
395
- const ctxModel = (ctx as any).model
396
499
  const provider = ctxModel?.provider ?? ctxModel?.providerId ?? ctxModel?.providerID
397
500
  const model = ctxModel?.id ?? ctxModel?.model ?? ctxModel?.modelId ?? ctxModel?.modelID
398
501
  const thresholds = resolveContextThresholds(effectiveConfig, [
399
502
  provider && model ? `${provider}/${model}` : undefined,
400
503
  model,
401
504
  ], usage.contextWindow)
402
- if (effectiveConfig.compress.summaryBuffer) {
403
- const summaryBonus = getActiveSummaryTokenEstimate(state) / usage.contextWindow
404
- thresholds.maxContextPercent += Math.min(summaryBonus, SUMMARY_BUFFER_MAX_CONTEXT_BONUS)
405
- }
406
-
407
505
  const emergencySettings = effectiveConfig.strategies.emergencyCurrentTurnPruning
408
- const {
409
- hardEmergencyReached,
410
- contextLimitReached,
411
- emergencyPressureReached,
412
- } = emergencyPressureState(
506
+ const estimatorMarginTokens = Math.max(256, Math.ceil(usage.contextWindow * 0.0025))
507
+ const budget = planDcpBudget({
508
+ providerUsageTokens: nativeUsage?.tokens,
509
+ repoProjectedTokens,
510
+ contextWindow: usage.contextWindow,
511
+ reservedOutputTokens: typeof ctxModel?.maxTokens === "number" ? ctxModel.maxTokens : 0,
512
+ reservedToolTokens: 0,
513
+ maxContextPercent: thresholds.maxContextPercent,
514
+ hardContextPercent: emergencySettings.hardContextPercent,
515
+ targetContextPercent: Math.min(
516
+ Math.max(0, emergencySettings.targetContextPercent),
517
+ Math.max(0, thresholds.maxContextPercent * 0.9),
518
+ ),
519
+ summaryBufferEnabled: effectiveConfig.compress.summaryBuffer,
520
+ activeSummaryTokens: getActiveSummaryTokenEstimate(state),
521
+ summaryBufferMaxBonusRatio: SUMMARY_BUFFER_MAX_CONTEXT_BONUS,
522
+ estimatorMarginTokens,
523
+ })
524
+ thresholds.maxContextPercent = budget.softHeadroomTokens / usage.contextWindow
525
+ const contextPercent = budget.projectedBeforeTokens / usage.contextWindow
526
+ const nativePressure = emergencyPressureState(
413
527
  contextPercent,
414
528
  thresholds.maxContextPercent,
415
529
  emergencySettings.hardContextPercent,
416
530
  )
531
+ const hardEmergencyReached = nativePressure.hardEmergencyReached || budget.hardPressure
532
+ const contextLimitReached = nativePressure.contextLimitReached || budget.pressured
533
+ const emergencyPressureReached = nativePressure.emergencyPressureReached || budget.pressured
417
534
  const routineNudgesAllowed = contextPercent > thresholds.minContextPercent
535
+ if (!budget.capacityExceeded && state.progressRecovery) state.progressRecovery = undefined
418
536
  if (!emergencyPressureReached && !routineNudgesAllowed) {
419
537
  const clearedAnchors = clearDcpNudgeAnchors(state)
420
- const resetEmergencyPasses = state.consecutiveIgnoredStrongNudges > 0
421
- state.consecutiveIgnoredStrongNudges = 0
422
- if (clearedAnchors > 0 || resetEmergencyPasses) await saveDcpState(ctx, state)
538
+ const resetEmergencyPasses = state.consecutiveIgnoredStrongNudges > 0 || state.consecutiveIgnoredNudges > 0 || !!state.compressionProgress
539
+ resetCompressionProgress(state)
540
+ warnedProgress.clear()
541
+ if (clearedAnchors > 0) await persistJournalState(ctx, state)
423
542
  return finishContext("below-threshold", prunedMessages, {
424
543
  contextPercent,
425
544
  thresholds,
@@ -455,9 +574,40 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
455
574
  typeof currentContextWindow === "number" &&
456
575
  Number.isFinite(currentContextWindow) &&
457
576
  currentContextWindow !== previousContextWindow
458
- if (contextWindowChanged) state.consecutiveIgnoredStrongNudges = 0
577
+ if (contextWindowChanged) resetCompressionProgress(state)
578
+
579
+ const requestedRecoveryTokens = emergencyPressureReached
580
+ ? budget.requiredSavingsTokens
581
+ : routineRecoveryTokens(budget.projectedBeforeTokens, usage.contextWindow, thresholds.minContextPercent)
582
+ const progressInput = {
583
+ projectedTokens: repoProjectedTokens,
584
+ observedTokens: budget.projectedBeforeTokens,
585
+ contextWindow: usage.contextWindow,
586
+ requiredTokens: requestedRecoveryTokens,
587
+ kind: emergencyPressureReached ? "emergency" as const : "routine" as const,
588
+ targetHeadroomTokens: emergencyPressureReached ? budget.targetHeadroomTokens : undefined,
589
+ }
590
+ const remainingRecoveryTokens = outstandingCompressionTokens(state, progressInput)
591
+ if (remainingRecoveryTokens === 0) {
592
+ resetCompressionProgress(state)
593
+ warnedProgress.clear()
594
+ }
595
+ const planningRecoveryTokens = remainingRecoveryTokens !== undefined && remainingRecoveryTokens > 0
596
+ ? state.compressionProgress?.kind === progressInput.kind ? remainingRecoveryTokens : Math.max(remainingRecoveryTokens, requestedRecoveryTokens)
597
+ : requestedRecoveryTokens
598
+ const routineEscalated = !state.manualMode && routineNudgesAllowed &&
599
+ state.consecutiveIgnoredNudges > Math.max(0, Math.floor(effectiveConfig.compress.autoCompress.patience))
600
+ const progressPressureReached = emergencyPressureReached || routineEscalated
601
+ // A marathon's first useful reminder must not depend on already having
602
+ // ignored a reminder. Plan a closed, provider-seen same-turn prefix in
603
+ // the routine zone too; only automatic publication waits for patience.
604
+ const routineSameTurnPlanning = !state.manualMode && routineNudgesAllowed && effectiveConfig.compress.autoCandidates.enabled
605
+ const sameTurnPlanningAllowed = emergencyPressureReached || routineSameTurnPlanning
606
+ const planningThreshold = emergencyPressureReached
607
+ ? Math.min(thresholds.maxContextPercent, emergencySettings.hardContextPercent)
608
+ : thresholds.minContextPercent
459
609
 
460
- const nudgeType = hardEmergencyReached && !contextLimitReached
610
+ const nudgeType = routineEscalated || (hardEmergencyReached && !contextLimitReached)
461
611
  ? "context-strong"
462
612
  : windowDowngraded
463
613
  ? "context-strong"
@@ -479,6 +629,9 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
479
629
  state,
480
630
  effectiveConfig,
481
631
  contextPercent,
632
+ progressPressureReached
633
+ ? { requiredSavingsTokens: planningRecoveryTokens }
634
+ : undefined,
482
635
  )
483
636
  messageCandidates = detectMessageCompressionCandidates(
484
637
  prunedMessages,
@@ -489,10 +642,11 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
489
642
  writeDcpDebugLog(effectiveConfig, "context.candidates", {
490
643
  contextPercent,
491
644
  thresholds,
645
+ budget,
492
646
  nudgeType,
493
647
  candidate,
494
648
  messageCandidates,
495
- state: summarizeDcpState(state),
649
+ state: summarizeDcpState(state, effectiveConfig),
496
650
  }, ctx)
497
651
  }
498
652
 
@@ -500,7 +654,7 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
500
654
  candidate !== null || messageCandidates.length > 0
501
655
  if (
502
656
  emergencySettings.enabled &&
503
- emergencyPressureReached &&
657
+ sameTurnPlanningAllowed &&
504
658
  !manualEmergencyOnly &&
505
659
  !hasNormalCompressionSuggestion
506
660
  ) {
@@ -513,79 +667,184 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
513
667
  emergencySelection,
514
668
  effectiveConfig,
515
669
  )
670
+ }
671
+
672
+ if (sameTurnPlanningAllowed && !manualEmergencyOnly && candidate === null) {
673
+ emergencyCompressionCandidate = detectEmergencyCompressionCandidate(
674
+ prunedMessages,
675
+ state,
676
+ effectiveConfig,
677
+ contextPercent,
678
+ planningThreshold,
679
+ { requiredSavingsTokens: planningRecoveryTokens },
680
+ )
681
+ if (emergencyCompressionCandidate) {
682
+ writeDcpDebugLog(effectiveConfig, "context.emergency_compression_candidate", {
683
+ contextPercent,
684
+ thresholds,
685
+ candidate: emergencyCompressionCandidate,
686
+ ...(emergencySelection?.stats ?? {}),
687
+ state: summarizeDcpState(state, effectiveConfig),
688
+ }, ctx)
689
+ }
690
+ }
691
+
692
+ if (
693
+ emergencySelection &&
694
+ candidate === null &&
695
+ emergencyCompressionCandidate === null
696
+ ) {
516
697
  writeDcpDebugLog(effectiveConfig, "context.strong_nudge_without_candidate", {
517
698
  contextPercent,
518
699
  thresholds,
519
700
  nudgeType,
520
701
  emergencyCandidates: messageCandidates.length,
521
702
  ...emergencySelection.stats,
522
- state: summarizeDcpState(state),
703
+ state: summarizeDcpState(state, effectiveConfig),
523
704
  }, ctx)
524
705
  }
525
706
 
526
707
  let hasCompressionSuggestion =
527
- candidate !== null || messageCandidates.length > 0
708
+ candidate !== null || emergencyCompressionCandidate !== null || messageCandidates.length > 0
709
+ if (!manualEmergencyOnly && (hasCompressionSuggestion || emergencyPressureReached)) {
710
+ trackCompressionProgress(state, progressInput)
711
+ latestProviderOpportunityKind = emergencyPressureReached ? "emergency" : !state.manualMode ? "routine" : undefined
712
+ }
713
+ const blockedReason = inferDcpBlockedReason({
714
+ pressured: progressPressureReached,
715
+ candidateAvailable: candidate !== null || emergencyCompressionCandidate !== null,
716
+ messageCandidateCount: messageCandidates.length,
717
+ requiredSavingsTokens: planningRecoveryTokens,
718
+ capacityExceeded: budget.capacityExceeded,
719
+ emergencyStats: emergencySelection?.stats,
720
+ })
721
+ if (blockedReason) {
722
+ writeDcpDebugLog(effectiveConfig, "context.progress_blocked", {
723
+ phase: "blocked",
724
+ blocked_reason: blockedReason,
725
+ contextPercent,
726
+ thresholds,
727
+ budget,
728
+ ...(emergencySelection?.stats ?? {}),
729
+ state: summarizeDcpState(state, effectiveConfig),
730
+ }, ctx)
731
+ if (routineEscalated) warnProgress(ctx, blockedReason,
732
+ `DCP cannot safely meet its compression goal: ${blockedReason}. Context has not been recovered.`)
733
+ }
734
+ if (blockedReason && budget.capacityExceeded) {
735
+ state.progressRecovery = {
736
+ blockedReason,
737
+ projectedBeforeTokens: budget.projectedBeforeTokens,
738
+ inputCapacityTokens: budget.inputCapacityTokens,
739
+ requiredSavingsTokens: budget.requiredSavingsTokens,
740
+ contextWindow: budget.contextWindow,
741
+ createdAt: Date.now(),
742
+ }
743
+ clearDcpNudgeAnchors(state)
744
+ await persistJournalState(ctx, state)
745
+ const abortSupported = typeof (ctx as any).abort === "function"
746
+ if (abortSupported) (ctx as any).abort()
747
+ writeDcpDebugLog(effectiveConfig, "context.progress_handoff", {
748
+ phase: "blocked",
749
+ blocked_reason: blockedReason,
750
+ handoff: abortSupported ? "abort-current-agent-operation" : "abort-unavailable",
751
+ budget,
752
+ state: summarizeDcpState(state, effectiveConfig),
753
+ }, ctx)
754
+ return finishContext("progress.blocked_handoff", prunedMessages, {
755
+ blocked_reason: blockedReason,
756
+ handoff: abortSupported ? "abort-current-agent-operation" : "abort-unavailable",
757
+ budget,
758
+ })
759
+ }
528
760
  if (!manualEmergencyOnly && !emergencyPressureReached && !hasCompressionSuggestion) {
529
761
  const clearedAnchors = clearDcpNudgeAnchors(state)
530
- if (clearedAnchors > 0) await saveDcpState(ctx, state)
762
+ if (clearedAnchors > 0) await persistJournalState(ctx, state)
531
763
  if (nudgeType || clearedAnchors > 0) {
532
764
  writeDcpDebugLog(effectiveConfig, "context.no_compression_candidate", {
533
765
  contextPercent,
534
766
  thresholds,
535
767
  nudgeType,
536
768
  clearedAnchors,
537
- state: summarizeDcpState(state),
769
+ state: summarizeDcpState(state, effectiveConfig),
538
770
  }, ctx)
539
771
  }
540
772
  }
541
773
 
542
- // Emergency pressure must advance even when normal candidates are
543
- // absent; otherwise the current-turn fallback can never reach patience.
544
- if (emergencyPressureReached) {
545
- state.consecutiveIgnoredStrongNudges += 1
546
- } else {
547
- state.consecutiveIgnoredStrongNudges = 0
548
- }
774
+ // E05 patience advances only after a correlated main-provider response
775
+ // completes. Repeated context transforms merely make the reminder
776
+ // available; they do not consume another model opportunity.
777
+ if (!emergencyPressureReached) state.consecutiveIgnoredStrongNudges = 0
549
778
 
550
- // Auto-compress fallback: if the model has ignored enough strong
551
- // nudges while above the emergency threshold, DCP creates a
552
- // compression block itself instead of nudging again.
779
+ // Completed actionable routine opportunities also have a bound. Never
780
+ // silently enable automatic summarization when the user disabled it.
781
+ if (routineEscalated && !effectiveConfig.compress.autoCompress.enabled) {
782
+ warnProgress(ctx, "auto-disabled", "DCP compression has not made enough progress. Automatic compression is disabled; use compress or enable compress.autoCompress explicitly.")
783
+ }
553
784
  if (!manualEmergencyOnly) {
785
+ const autoCandidate = candidate ?? emergencyCompressionCandidate
786
+ const cacheSafeReminderAvailable = hasCacheSafeNudgeCarrier(prunedMessages)
554
787
  const autoDecision = decideAutoCompress(
555
788
  state,
556
789
  effectiveConfig,
557
790
  contextPercent,
558
791
  thresholds.maxContextPercent,
559
- candidate,
792
+ autoCandidate,
793
+ {
794
+ routinePressure: routineEscalated,
795
+ hardPressure: hardEmergencyReached,
796
+ reminderUnavailable: !cacheSafeReminderAvailable,
797
+ },
560
798
  )
561
- if (contextLimitReached && candidate === null) {
799
+ if (contextLimitReached && autoCandidate === null) {
562
800
  writeDcpDebugLog(effectiveConfig, "compress.auto_blocked_no_candidate", {
563
801
  autoCompressEnabled: effectiveConfig.compress.autoCompress.enabled,
564
802
  decisionReason: autoDecision.reason,
803
+ blocked_reason: blockedReason,
565
804
  contextPercent,
566
805
  thresholds,
567
806
  consecutiveEmergencyPasses: state.consecutiveIgnoredStrongNudges,
568
807
  ...(emergencySelection?.stats ?? {}),
569
- state: summarizeDcpState(state),
808
+ state: summarizeDcpState(state, effectiveConfig),
570
809
  }, ctx)
571
810
  }
572
- if (autoDecision.shouldFire && candidate) {
811
+ if (autoDecision.shouldFire && autoCandidate) {
573
812
  try {
574
- const autoResult = await createAutoCompressionBlock({
575
- candidate,
813
+ const autoOperationEpoch = state.sessionEpoch
814
+ const largestSafeCandidate = candidate
815
+ ? detectCompressionCandidate(prunedMessages, state, effectiveConfig, contextPercent)
816
+ : detectEmergencyCompressionCandidate(prunedMessages, state, effectiveConfig, contextPercent, planningThreshold)
817
+ let preparedProjection: any[] | undefined
818
+ const autoResult = await createBudgetedAutoCompressionBlock({
819
+ candidate: autoCandidate,
576
820
  topic: "Auto-compressed slice",
577
821
  state,
578
822
  config: effectiveConfig,
579
823
  messages: prunedMessages,
580
824
  modelRegistry: (ctx as any).modelRegistry,
581
825
  signal: (ctx as any).signal,
582
- })
826
+ cwd: (ctx as any).cwd,
827
+ requiredGainTokens: state.compressionProgress?.remainingTokens ?? planningRecoveryTokens,
828
+ allowPartialGain: true,
829
+ persistState: (preparedState, publication) => persistJournalState(ctx, preparedState, publication),
830
+ prepareProjection: (preparedState) => {
831
+ clearDcpNudgeAnchors(preparedState)
832
+ preparedState.progressRecovery = undefined
833
+ preparedProjection = applyPruning(contextMessages, preparedState, effectiveConfig)
834
+ return preparedProjection
835
+ },
836
+ }, largestSafeCandidate)
837
+ if (autoResult.ownerChangedAfterPublication || state.sessionEpoch !== autoOperationEpoch) {
838
+ // The captured owner was durably committed; do not label it
839
+ // failed or mutate/nudge the replacement session.
840
+ return { messages: contextMessages }
841
+ }
583
842
  // Re-apply pruning so the new block takes effect on this
584
843
  // same context pass instead of the next one.
585
- prunedMessages = applyPruning(prunedMessages, state, effectiveConfig)
844
+ prunedMessages = preparedProjection ?? applyPruning(contextMessages, state, effectiveConfig)
586
845
  const clearedAnchors = clearDcpNudgeAnchors(state)
587
- state.consecutiveIgnoredStrongNudges = 0
588
- await saveDcpState(ctx, state)
846
+ warnedProgress.clear()
847
+ state.progressRecovery = undefined
589
848
  writeDcpDebugLog(effectiveConfig, "compress.auto", {
590
849
  trigger: autoDecision.reason,
591
850
  blockId: `b${autoResult.blockId}`,
@@ -594,25 +853,54 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
594
853
  summarizerAttempts: autoResult.summarizerAttempts,
595
854
  summaryTokens: autoResult.summaryTokens,
596
855
  removedTokenEstimate: autoResult.removedTokenEstimate,
597
- candidate,
856
+ fullProjectionGain: autoResult.fullProjectionGain,
857
+ pressureRelieved: autoResult.pressureRelieved,
858
+ candidate: autoResult.effectiveCandidate,
598
859
  clearedAnchors,
599
- state: summarizeDcpState(state),
860
+ state: summarizeDcpState(state, effectiveConfig),
600
861
  }, ctx)
601
862
  return finishContext("compress.auto", prunedMessages, {
602
- candidate,
863
+ candidate: autoResult.effectiveCandidate,
603
864
  messageCandidates,
604
865
  contextPercent,
605
866
  thresholds,
606
867
  clearedAnchors,
607
868
  })
608
869
  } catch (error) {
870
+ if (state.sessionEpoch !== contextEpoch || ctx.signal?.aborted) return { messages: contextMessages }
871
+ const autoBlockedReason = error instanceof AutoCompressionBlockedError
872
+ ? error.blockedReason
873
+ : undefined
609
874
  writeDcpDebugLog(effectiveConfig, "compress.auto_failed", {
610
875
  trigger: autoDecision.reason,
876
+ phase: autoBlockedReason ? "blocked" : "degraded",
877
+ blocked_reason: autoBlockedReason,
611
878
  error: error instanceof Error ? error.message : String(error),
612
- candidate,
613
- state: summarizeDcpState(state),
879
+ candidate: autoCandidate,
880
+ state: summarizeDcpState(state, effectiveConfig),
614
881
  }, ctx)
615
- // Fall through to normal nudge emission on failure.
882
+ if (routineEscalated) warnProgress(ctx, autoBlockedReason ?? "auto-failed",
883
+ `DCP automatic compression did not recover the context: ${autoBlockedReason ?? "preparation failed"}. The existing context was preserved.`)
884
+ if (autoBlockedReason && budget.capacityExceeded) {
885
+ state.progressRecovery = {
886
+ blockedReason: autoBlockedReason,
887
+ projectedBeforeTokens: budget.projectedBeforeTokens,
888
+ inputCapacityTokens: budget.inputCapacityTokens,
889
+ requiredSavingsTokens: budget.requiredSavingsTokens,
890
+ contextWindow: budget.contextWindow,
891
+ createdAt: Date.now(),
892
+ }
893
+ clearDcpNudgeAnchors(state)
894
+ await persistJournalState(ctx, state)
895
+ const abortSupported = typeof (ctx as any).abort === "function"
896
+ if (abortSupported) (ctx as any).abort()
897
+ return finishContext("progress.blocked_handoff", prunedMessages, {
898
+ blocked_reason: autoBlockedReason,
899
+ handoff: abortSupported ? "abort-current-agent-operation" : "abort-unavailable",
900
+ budget,
901
+ })
902
+ }
903
+ // Recoverable failures fall through to normal nudge emission.
616
904
  }
617
905
  }
618
906
  }
@@ -648,10 +936,20 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
648
936
  prunedMessages = applyPruning(contextMessages, state, effectiveConfig)
649
937
  const clearedAnchors = clearDcpNudgeAnchors(state)
650
938
  state.consecutiveIgnoredStrongNudges = 0
939
+ state.progressRecovery = undefined
651
940
  emergencySelection = analyzeEmergencyCurrentTurn(prunedMessages, state, effectiveConfig)
652
941
  messageCandidates = emergencyCurrentTurnMessageCandidates(emergencySelection, effectiveConfig)
653
- hasCompressionSuggestion = candidate !== null || messageCandidates.length > 0
654
- await saveDcpState(ctx, state)
942
+ emergencyCompressionCandidate = detectEmergencyCompressionCandidate(
943
+ prunedMessages,
944
+ state,
945
+ effectiveConfig,
946
+ contextPercent,
947
+ thresholds.maxContextPercent,
948
+ { requiredSavingsTokens: budget.requiredSavingsTokens },
949
+ )
950
+ hasCompressionSuggestion =
951
+ candidate !== null || emergencyCompressionCandidate !== null || messageCandidates.length > 0
952
+ await persistJournalState(ctx, state)
655
953
  writeDcpDebugLog(effectiveConfig, "prune.emergency_current_turn", {
656
954
  trigger: hardEmergencyReached ? "hard-context-percent" : "ignored-emergency-reminders",
657
955
  contextPercent,
@@ -671,15 +969,16 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
671
969
  eligibleExhausted:
672
970
  emergencyPruneResult.prunedToolCallIds.length >= selectionStatsBeforePrune.eligiblePairs,
673
971
  ...selectionStatsBeforePrune,
674
- state: summarizeDcpState(state),
972
+ state: summarizeDcpState(state, effectiveConfig),
675
973
  }, ctx)
676
974
  }
677
975
  }
678
976
 
679
977
  if (nudgeType && !manualEmergencyOnly && (hasCompressionSuggestion || emergencyPressureReached)) {
978
+ latestProviderOpportunityAvailable = latestProviderOpportunityKind !== undefined
680
979
  const nudgeText = appendConcreteNudgeGuidance(
681
980
  baseNudgeText(nudgeType),
682
- candidate,
981
+ candidate ?? emergencyCompressionCandidate,
683
982
  messageCandidates,
684
983
  state,
685
984
  )
@@ -699,7 +998,7 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
699
998
  usage,
700
999
  toolCallsSinceLastUser,
701
1000
  )
702
- await saveDcpState(ctx, state)
1001
+ await persistJournalState(ctx, state)
703
1002
  } else {
704
1003
  // Anchor already exists at >= priority; the reminder text is
705
1004
  // re-applied below via applyAnchoredNudges on every context
@@ -717,9 +1016,15 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
717
1016
  )
718
1017
  }
719
1018
  } else {
720
- // No safe existing message could be anchored (rare); keep the older
721
- // synthetic reminder fallback so DCP never silently drops a nudge.
722
- injectNudge(prunedMessages, nudgeText)
1019
+ // Do not rewrite an already-sent user message and do not create an
1020
+ // ephemeral synthetic tail that disappears from the next provider
1021
+ // continuation. The reminder is deferred until a fresh user tail; hard
1022
+ // pressure is handled by the bounded emergency path above.
1023
+ writeDcpDebugLog(effectiveConfig, "nudge.deferred_for_cache", {
1024
+ type: nudgeType,
1025
+ contextPercent,
1026
+ toolCallsSinceLastUser,
1027
+ }, ctx)
723
1028
  }
724
1029
  state.nudgeCounter = 0
725
1030
  state.lastNudgeTurn = state.currentTurn
@@ -729,7 +1034,7 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
729
1034
 
730
1035
  // Persist patience/window changes even when an existing anchor was only
731
1036
  // re-applied (that path intentionally emits telemetry without updating it).
732
- await saveDcpState(ctx, state)
1037
+ await persistJournalState(ctx, state)
733
1038
  }
734
1039
 
735
1040
  const anchorsBeforeFinalization = state.nudgeAnchors.length
@@ -739,10 +1044,19 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
739
1044
  )
740
1045
  }
741
1046
  const nudgeApplication = applyAnchoredNudges(prunedMessages, state, (anchor) =>
742
- appendConcreteNudgeGuidance(baseNudgeText(anchor.type), candidate, messageCandidates, state),
1047
+ appendConcreteNudgeGuidance(
1048
+ baseNudgeText(anchor.type),
1049
+ candidate ?? emergencyCompressionCandidate,
1050
+ messageCandidates,
1051
+ state,
1052
+ ),
743
1053
  )
1054
+ if (nudgeApplication.rendered && latestProviderOpportunityKind) {
1055
+ latestProviderOpportunityAvailable = true
1056
+ latestProviderReminder = state.nudgeAnchors[0]?.renderedReminder
1057
+ }
744
1058
  if (state.nudgeAnchors.length !== anchorsBeforeFinalization || nudgeApplication.stateChanged) {
745
- await saveDcpState(ctx, state)
1059
+ await persistJournalState(ctx, state)
746
1060
  }
747
1061
 
748
1062
  return finishContext("complete", prunedMessages, {
@@ -753,28 +1067,50 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
753
1067
  })
754
1068
  })
755
1069
 
756
- // ── 10b. before_provider_request: observe accepted tool-result payloads ────
1070
+ // ── 10b. provider lifecycle evidence ─────────────────────────────────────
757
1071
  pi.on("before_provider_request", async (event, ctx) => {
758
1072
  const effectiveConfig = configForContext(ctx)
759
- pendingProviderToolIds.clear()
760
- if (!effectiveConfig.enabled) return undefined
1073
+ if (!effectiveConfig.enabled) {
1074
+ providerEvidenceTracker.reset()
1075
+ return undefined
1076
+ }
761
1077
 
762
1078
  const providerEvidence = collectProviderToolResultEvidence(event.payload)
1079
+ const pendingToolIds = new Set<string>()
763
1080
  for (const meta of state.messageMetaSnapshot.values()) {
764
1081
  if (meta.role !== "toolResult") continue
765
1082
  if (!meta.toolCallId || state.prunedToolIds.has(meta.toolCallId)) continue
766
1083
  if (state.providerSeenToolIds.has(meta.toolCallId)) continue
767
1084
  const record = state.toolCalls.get(meta.toolCallId)
768
1085
  if (record && providerPayloadIncludesToolResult(providerEvidence, record)) {
769
- pendingProviderToolIds.add(meta.toolCallId)
1086
+ pendingToolIds.add(meta.toolCallId)
770
1087
  }
771
1088
  }
772
1089
 
1090
+ const model = (ctx as any)?.model
1091
+ const reminderDelivered = latestProviderOpportunityAvailable &&
1092
+ providerPayloadIncludesReminder(event.payload, latestProviderReminder)
1093
+ const pending = providerEvidenceTracker.begin({
1094
+ sessionEpoch: state.sessionEpoch,
1095
+ provider: typeof model?.provider === "string" ? model.provider : undefined,
1096
+ model: typeof model?.id === "string" ? model.id : undefined,
1097
+ contentRevision: providerPayloadRevision(event.payload),
1098
+ toolIds: pendingToolIds,
1099
+ opportunityAvailable: reminderDelivered,
1100
+ opportunityKind: reminderDelivered ? latestProviderOpportunityKind : undefined,
1101
+ })
1102
+
773
1103
  writeDcpDebugLog(effectiveConfig, "provider_payload.message_ids", {
774
1104
  injected: false,
775
1105
  delivery: "distributed-carriers",
776
- pendingToolResults: pendingProviderToolIds.size,
777
- state: summarizeDcpState(state),
1106
+ pendingToolResults: pendingToolIds.size,
1107
+ attempts: pending.attempts,
1108
+ ambiguous: pending.ambiguous,
1109
+ opportunityAvailable: pending.opportunityAvailable,
1110
+ opportunityKind: pending.opportunityKind,
1111
+ provider: pending.provider,
1112
+ model: pending.model,
1113
+ state: summarizeDcpState(state, effectiveConfig),
778
1114
  }, ctx)
779
1115
  // IDs are already attached to deterministic user/tool-result context
780
1116
  // carriers. Replacing the payload here would move metadata from the old
@@ -782,33 +1118,111 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
782
1118
  return undefined
783
1119
  })
784
1120
 
785
- // Promote pending IDs only after the provider accepted the exact payload.
786
- // Failed/aborted requests leave results fresh and therefore ineligible.
1121
+ // HTTP acceptance is deliberately diagnostic only. The SDK fires this hook
1122
+ // before consuming the response body, so 2xx cannot prove that the provider
1123
+ // actually completed the assistant stream.
787
1124
  pi.on("after_provider_response", async (event, ctx) => {
788
1125
  const effectiveConfig = configForContext(ctx)
789
- const accepted = event.status >= 200 && event.status < 300
790
- let newlySeenToolResults = 0
791
- if (effectiveConfig.enabled && accepted) {
792
- for (const toolCallId of pendingProviderToolIds) {
793
- if (!state.providerSeenToolIds.has(toolCallId)) {
794
- state.providerSeenToolIds.add(toolCallId)
795
- newlySeenToolResults++
796
- }
797
- }
798
- if (newlySeenToolResults > 0) await saveDcpState(ctx, state)
799
- }
800
- writeDcpDebugLog(effectiveConfig, "provider_payload.tool_results_seen", {
1126
+ const pending = providerEvidenceTracker.snapshot()
1127
+ writeDcpDebugLog(effectiveConfig, "provider_payload.http_response", {
801
1128
  status: event.status,
802
- accepted,
803
- pendingToolResults: pendingProviderToolIds.size,
804
- newlySeenToolResults,
805
- state: summarizeDcpState(state),
1129
+ accepted: event.status >= 200 && event.status < 300,
1130
+ pendingToolResults: pending?.toolIds.size ?? 0,
1131
+ attempts: pending?.attempts ?? 0,
1132
+ ambiguous: pending?.ambiguous ?? false,
1133
+ state: summarizeDcpState(state, effectiveConfig),
806
1134
  }, ctx)
807
- pendingProviderToolIds.clear()
808
1135
  })
809
1136
 
810
- // ── 11. agent_end: persist state after each agent run ────────────────────
811
- pi.on("agent_end", async (_event, ctx) => {
812
- await saveDcpState(ctx, state)
1137
+ // message_end is emitted for the finalized assistant message after the
1138
+ // response stream settles. Without a provider request ID in SDK 0.85.1, only
1139
+ // one unambiguous request shape (or identical retries) is safe to promote.
1140
+ pi.on("message_end", async (event, ctx) => {
1141
+ if (event.message?.role !== "assistant") return
1142
+ const effectiveConfig = configForContext(ctx)
1143
+ if (!effectiveConfig.enabled) {
1144
+ providerEvidenceTracker.reset()
1145
+ return
1146
+ }
1147
+
1148
+ const message = event.message as any
1149
+ const completion = providerEvidenceTracker.complete({
1150
+ sessionEpoch: state.sessionEpoch,
1151
+ provider: typeof message.provider === "string" ? message.provider : undefined,
1152
+ model: typeof message.model === "string" ? message.model : undefined,
1153
+ stopReason: typeof message.stopReason === "string" ? message.stopReason : undefined,
1154
+ })
1155
+ latestProviderOpportunityAvailable = false
1156
+
1157
+ if (completion.status !== "promote") {
1158
+ writeDcpDebugLog(effectiveConfig, "provider_payload.tool_results_not_promoted", {
1159
+ reason: completion.reason,
1160
+ attempts: completion.attempts,
1161
+ stopReason: message.stopReason,
1162
+ state: summarizeDcpState(state, effectiveConfig),
1163
+ }, ctx)
1164
+ return
1165
+ }
1166
+
1167
+ // Charge the completed opportunity before tool execution. A failed or
1168
+ // insufficient compress call cannot erase it; only a sufficient durable
1169
+ // commit settles the savings goal and resets patience.
1170
+ const ignoredCompressionOpportunity =
1171
+ completion.opportunityAvailable && message.stopReason !== "deferred"
1172
+
1173
+ const commit = providerEvidenceCommitQueue
1174
+ .catch(() => {
1175
+ // Keep later evidence commits moving after a prior transaction failure.
1176
+ })
1177
+ .then(() => runDcpStateTransaction(state, async () => {
1178
+ if (state.sessionEpoch !== completion.sessionEpoch) {
1179
+ writeDcpDebugLog(effectiveConfig, "provider_payload.tool_results_not_promoted", {
1180
+ reason: "stale-session",
1181
+ attempts: completion.attempts,
1182
+ state: summarizeDcpState(state, effectiveConfig),
1183
+ }, ctx)
1184
+ return
1185
+ }
1186
+
1187
+ const newlySeen = [...completion.toolIds].filter((toolCallId) => !state.providerSeenToolIds.has(toolCallId))
1188
+ if (newlySeen.length === 0 && !ignoredCompressionOpportunity) return
1189
+ const assertCurrent = captureDcpTransactionGuard(state, effectiveConfig, undefined, ctx)
1190
+ const workingState = cloneDcpTransactionState(state)
1191
+ for (const toolCallId of newlySeen) workingState.providerSeenToolIds.add(toolCallId)
1192
+ if (ignoredCompressionOpportunity) {
1193
+ workingState.consecutiveIgnoredNudges = state.consecutiveIgnoredNudges + 1
1194
+ if (completion.opportunityKind !== "routine") {
1195
+ workingState.consecutiveIgnoredStrongNudges = state.consecutiveIgnoredStrongNudges + 1
1196
+ }
1197
+ }
1198
+
1199
+ // Provider-delivery evidence is intentionally runtime-only. After a
1200
+ // restart absence means unknown, never "seen". Persisting it would add
1201
+ // high-frequency journal churn without changing the durable projection.
1202
+ assertCurrent()
1203
+
1204
+ if (state.sessionEpoch !== completion.sessionEpoch) {
1205
+ writeDcpDebugLog(effectiveConfig, "provider_payload.tool_results_not_promoted", {
1206
+ reason: "stale-session",
1207
+ attempts: completion.attempts,
1208
+ state: summarizeDcpState(state, effectiveConfig),
1209
+ }, ctx)
1210
+ return
1211
+ }
1212
+
1213
+ state.providerSeenToolIds = workingState.providerSeenToolIds
1214
+ state.consecutiveIgnoredStrongNudges = workingState.consecutiveIgnoredStrongNudges
1215
+ state.consecutiveIgnoredNudges = workingState.consecutiveIgnoredNudges
1216
+ writeDcpDebugLog(effectiveConfig, "provider_payload.tool_results_seen", {
1217
+ attempts: completion.attempts,
1218
+ newlySeenToolResults: newlySeen.length,
1219
+ ignoredCompressionOpportunity,
1220
+ ignoredOpportunities: state.consecutiveIgnoredStrongNudges,
1221
+ allIgnoredOpportunities: state.consecutiveIgnoredNudges,
1222
+ state: summarizeDcpState(state, effectiveConfig),
1223
+ }, ctx)
1224
+ }))
1225
+ providerEvidenceCommitQueue = commit
1226
+ await commit
813
1227
  })
814
1228
  }