opencode-acp 1.18.4 → 1.18.5-pr.413.228

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.
package/dist/index.js CHANGED
@@ -921,6 +921,7 @@ var VALID_CONFIG_KEYS = /* @__PURE__ */ new Set([
921
921
  "compress.images.minBytes",
922
922
  "compress.images.ttlDays",
923
923
  "compress.completionReserveTokens",
924
+ "compress.emergencyTruncation",
924
925
  "gc",
925
926
  "gc.algorithm",
926
927
  "gc.promotionThreshold",
@@ -1350,6 +1351,13 @@ function validateConfigTypes(config) {
1350
1351
  actual: typeof compress.completionReserveTokens
1351
1352
  });
1352
1353
  }
1354
+ if (compress.emergencyTruncation !== void 0 && typeof compress.emergencyTruncation !== "boolean") {
1355
+ errors.push({
1356
+ key: "compress.emergencyTruncation",
1357
+ expected: "boolean",
1358
+ actual: typeof compress.emergencyTruncation
1359
+ });
1360
+ }
1353
1361
  if (typeof compress.completionReserveTokens === "number" && compress.completionReserveTokens < 0) {
1354
1362
  errors.push({
1355
1363
  key: "compress.completionReserveTokens",
@@ -1422,6 +1430,7 @@ function validateConfigTypes(config) {
1422
1430
  const OVERRIDE_FIELD_TYPES = {
1423
1431
  showCompression: "boolean",
1424
1432
  summaryBuffer: "boolean",
1433
+ emergencyTruncation: "boolean",
1425
1434
  protectTags: "boolean",
1426
1435
  protectUserMessages: "boolean",
1427
1436
  lastSegmentSoftBlock: "boolean",
@@ -1809,6 +1818,7 @@ var defaultConfig = {
1809
1818
  permission: "allow",
1810
1819
  showCompression: true,
1811
1820
  summaryBuffer: true,
1821
+ emergencyTruncation: false,
1812
1822
  candidates: false,
1813
1823
  maxContextLimit: "80%",
1814
1824
  minContextLimit: "80%",
@@ -2017,7 +2027,8 @@ function mergeCompress(base, override) {
2017
2027
  minBytes: override.images?.minBytes ?? base.images?.minBytes ?? DEFAULT_COMPRESS_IMAGES.minBytes,
2018
2028
  ttlDays: override.images?.ttlDays ?? base.images?.ttlDays ?? DEFAULT_COMPRESS_IMAGES.ttlDays
2019
2029
  },
2020
- completionReserveTokens: override.completionReserveTokens ?? base.completionReserveTokens
2030
+ completionReserveTokens: override.completionReserveTokens ?? base.completionReserveTokens,
2031
+ emergencyTruncation: override.emergencyTruncation ?? base.emergencyTruncation
2021
2032
  };
2022
2033
  }
2023
2034
  function mergeCommands(base, override) {
@@ -2955,7 +2966,8 @@ function saveSessionState(sessionState, logger, sessionName) {
2955
2966
  modelContextLimit: sessionState.modelContextLimit,
2956
2967
  modelProviderID: sessionState.modelProviderID,
2957
2968
  modelID: sessionState.modelID,
2958
- imageArchive: sessionState.imageArchive
2969
+ imageArchive: sessionState.imageArchive,
2970
+ stickyTruncations: sessionState.stickyTruncations
2959
2971
  };
2960
2972
  const key = saveQueueKey(sessionState.sessionId, sessionState.storageDir);
2961
2973
  let queue = saveQueues.get(key);
@@ -4978,6 +4990,30 @@ async function updatePerTurnState(state, logger, messages) {
4978
4990
  state.currentTurn = countTurns(state, messages);
4979
4991
  }
4980
4992
  var REGISTRY_SOFT_CAP = 32;
4993
+ function createSessionGuard() {
4994
+ const locks = /* @__PURE__ */ new Map();
4995
+ return async (sessionId, fn) => {
4996
+ const previous = locks.get(sessionId) ?? Promise.resolve();
4997
+ let release;
4998
+ const released = new Promise((resolve2) => {
4999
+ release = resolve2;
5000
+ });
5001
+ const chain = previous.then(() => released);
5002
+ locks.set(sessionId, chain);
5003
+ try {
5004
+ await previous;
5005
+ } catch {
5006
+ }
5007
+ try {
5008
+ return await fn();
5009
+ } finally {
5010
+ release();
5011
+ if (locks.get(sessionId) === chain) {
5012
+ locks.delete(sessionId);
5013
+ }
5014
+ }
5015
+ };
5016
+ }
4981
5017
  var SessionStateRegistry = class {
4982
5018
  constructor(logger, projectDir) {
4983
5019
  this.logger = logger;
@@ -5037,9 +5073,17 @@ var SessionStateRegistry = class {
5037
5073
  get size() {
5038
5074
  return this.states.size;
5039
5075
  }
5076
+ // [Issue #404] Per-session FIFO mutex serializing same-session mutations
5077
+ // (message transforms, compress/decompress tools, event-hook saves,
5078
+ // system-hook limit writes). Shared factory so test stubs compose the
5079
+ // exact same implementation instead of drifting.
5080
+ // Not readonly: the session-guard wiring test wraps this to observe
5081
+ // acquisition order (Issue #404).
5082
+ withSessionGuard = createSessionGuard();
5040
5083
  // Idempotent: ensureSessionInitialized returns immediately once
5041
- // state.sessionId === sessionId (assigned synchronously before any await),
5042
- // so repeat calls for the same session never re-reset.
5084
+ // state.sessionId === sessionId, and coalesces concurrent inits per state
5085
+ // object ([Issue #404]), so racing calls for the same session never
5086
+ // re-reset or observe partially initialized state.
5043
5087
  async getOrCreate(client, sessionId, messages, config) {
5044
5088
  let state = this.states.get(sessionId);
5045
5089
  if (!state) {
@@ -5124,7 +5168,8 @@ function createSessionState() {
5124
5168
  qualityGateRetryPending: false,
5125
5169
  noContextLimitWarned: false,
5126
5170
  imageArchive: {},
5127
- imagePruneDone: false
5171
+ imagePruneDone: false,
5172
+ stickyTruncations: {}
5128
5173
  };
5129
5174
  }
5130
5175
  function resetSessionState(state) {
@@ -5170,11 +5215,35 @@ function resetSessionState(state) {
5170
5215
  state.noContextLimitWarned = false;
5171
5216
  state.imageArchive = {};
5172
5217
  state.imagePruneDone = false;
5218
+ state.stickyTruncations = {};
5173
5219
  }
5220
+ var inflightInits = /* @__PURE__ */ new WeakMap();
5174
5221
  async function ensureSessionInitialized(client, state, sessionId, logger, messages, config, projectDir) {
5222
+ const inflight = inflightInits.get(state);
5223
+ if (inflight) {
5224
+ await inflight;
5225
+ return;
5226
+ }
5175
5227
  if (state.sessionId === sessionId) {
5176
5228
  return;
5177
5229
  }
5230
+ const run = runSessionInitialization(
5231
+ client,
5232
+ state,
5233
+ sessionId,
5234
+ logger,
5235
+ messages,
5236
+ config,
5237
+ projectDir
5238
+ );
5239
+ inflightInits.set(state, run);
5240
+ try {
5241
+ await run;
5242
+ } finally {
5243
+ inflightInits.delete(state);
5244
+ }
5245
+ }
5246
+ async function runSessionInitialization(client, state, sessionId, logger, messages, config, projectDir) {
5178
5247
  resetSessionState(state);
5179
5248
  state.sessionId = sessionId;
5180
5249
  state.storageDir = config?.storagePath ? resolveStorageDir(config.storagePath, projectDir ?? cwd()) : void 0;
@@ -5198,6 +5267,13 @@ async function ensureSessionInitialized(client, state, sessionId, logger, messag
5198
5267
  if (parentSessionId) {
5199
5268
  try {
5200
5269
  const parent = await loadSessionState(parentSessionId, logger);
5270
+ if (parent?.stickyTruncations) {
5271
+ for (const [callID, output] of Object.entries(parent.stickyTruncations)) {
5272
+ if (typeof output === "string") {
5273
+ state.stickyTruncations[callID] = output;
5274
+ }
5275
+ }
5276
+ }
5201
5277
  const response = parent ? await client.session.messages({ path: { id: parentSessionId } }) : void 0;
5202
5278
  const parentMessages = Array.isArray(response?.data) ? response.data : Array.isArray(response) ? response : [];
5203
5279
  if (parent && parentMessages.length > 0) {
@@ -5263,6 +5339,14 @@ async function ensureSessionInitialized(client, state, sessionId, logger, messag
5263
5339
  }
5264
5340
  }
5265
5341
  }
5342
+ const persistedSticky = persisted.stickyTruncations;
5343
+ if (persistedSticky && typeof persistedSticky === "object") {
5344
+ for (const [callID, output] of Object.entries(persistedSticky)) {
5345
+ if (typeof output === "string") {
5346
+ state.stickyTruncations[callID] = output;
5347
+ }
5348
+ }
5349
+ }
5266
5350
  const persistedAny = persisted;
5267
5351
  if (persistedAny._persistedMessageIds) {
5268
5352
  state.messageIds = {
@@ -6537,7 +6621,7 @@ function restoreCompressionState(state, snapshot) {
6537
6621
  state.prune.messages = structuredClone(snapshot.messages);
6538
6622
  state.stats = { ...snapshot.stats };
6539
6623
  }
6540
- async function prepareSession(ctx, toolCtx, title) {
6624
+ async function requestCompressPermission(toolCtx, title) {
6541
6625
  await toolCtx.ask({
6542
6626
  permission: "compress",
6543
6627
  patterns: ["*"],
@@ -6545,6 +6629,11 @@ async function prepareSession(ctx, toolCtx, title) {
6545
6629
  metadata: {}
6546
6630
  });
6547
6631
  toolCtx.metadata({ title });
6632
+ }
6633
+ async function prepareSession(ctx, toolCtx, title, opts) {
6634
+ if (!opts?.preApproved) {
6635
+ await requestCompressPermission(toolCtx, title);
6636
+ }
6548
6637
  const rawMessages = await fetchSessionMessages(ctx.client, toolCtx.sessionID);
6549
6638
  await ensureSessionInitialized(
6550
6639
  ctx.client,
@@ -8170,240 +8259,246 @@ function createCompressRangeTool(factoryCtx) {
8170
8259
  }
8171
8260
  }
8172
8261
  const callId = typeof toolCtx.callID === "string" ? toolCtx.callID : void 0;
8173
- const { rawMessages, searchContext } = await prepareSession(
8174
- ctx0,
8175
- toolCtx,
8176
- `Compress Range: ${input.topic ?? "(batch)"}`
8177
- );
8178
- const { providerId, modelId } = getModelInfo(rawMessages);
8179
- const ctx = {
8180
- ...ctx0,
8181
- config: applyCompressOverrides(ctx0.config, providerId, modelId)
8182
- };
8183
- const needsSmartPlan = requiresSmartPlan(
8184
- ctx.config.compress.smartPlanRequired === true,
8185
- input.content
8186
- );
8187
- if (needsSmartPlan && input.content.length !== 1) {
8188
- throw new Error(
8189
- 'Raw-message compression accepts exactly one canonical SMART PLAN range per call. Use the latest ACP nudge, or call acp_status({scope:"uncompressed"}) to refresh it.'
8190
- );
8191
- }
8192
- const { plans: filteredPlans, totalChars } = prepareExecutableRangePlans(
8193
- input,
8194
- searchContext,
8195
- ctx.state,
8196
- ctx.config,
8197
- ctx.logger
8198
- );
8199
- if (needsSmartPlan) {
8200
- validateSmartPlan(
8201
- toolCtx.sessionID,
8202
- filteredPlans[0].entry.startId,
8203
- filteredPlans[0].entry.endId,
8204
- filteredPlans[0].selection.messageIds,
8205
- totalChars,
8206
- ctx.state.prune.messages.structureVersion ?? 0
8207
- );
8208
- }
8209
- const notifications = [];
8210
- let preparedPlans = [];
8211
- let totalCompressedMessages = 0;
8212
- for (const plan of filteredPlans) {
8213
- const parsedPlaceholders = parseBlockPlaceholders(plan.entry.summary);
8214
- validateSummaryPlaceholders(
8215
- parsedPlaceholders,
8216
- plan.selection.requiredBlockIds,
8217
- plan.selection.startReference,
8218
- plan.selection.endReference,
8219
- searchContext.summaryByBlockId,
8220
- ctx.logger
8221
- );
8222
- const injected = injectBlockPlaceholders(
8223
- plan.entry.summary,
8224
- parsedPlaceholders,
8225
- searchContext.summaryByBlockId,
8226
- plan.selection.startReference,
8227
- plan.selection.endReference
8262
+ const title = `Compress Range: ${input.topic ?? "(batch)"}`;
8263
+ const run = async () => {
8264
+ const { rawMessages, searchContext } = await prepareSession(
8265
+ ctx0,
8266
+ toolCtx,
8267
+ title,
8268
+ { preApproved: true }
8228
8269
  );
8229
- const summaryWithUsers = appendProtectedUserMessages(
8230
- injected.expandedSummary,
8231
- plan.selection,
8232
- searchContext,
8233
- ctx.state,
8234
- ctx.config.compress.protectUserMessages
8270
+ const { providerId, modelId } = getModelInfo(rawMessages);
8271
+ const ctx = {
8272
+ ...ctx0,
8273
+ config: applyCompressOverrides(ctx0.config, providerId, modelId)
8274
+ };
8275
+ const needsSmartPlan = requiresSmartPlan(
8276
+ ctx.config.compress.smartPlanRequired === true,
8277
+ input.content
8235
8278
  );
8236
- const summaryWithPromptInfo = appendProtectedPromptInfo(
8237
- summaryWithUsers,
8238
- plan.selection,
8279
+ if (needsSmartPlan && input.content.length !== 1) {
8280
+ throw new Error(
8281
+ 'Raw-message compression accepts exactly one canonical SMART PLAN range per call. Use the latest ACP nudge, or call acp_status({scope:"uncompressed"}) to refresh it.'
8282
+ );
8283
+ }
8284
+ const { plans: filteredPlans, totalChars } = prepareExecutableRangePlans(
8285
+ input,
8239
8286
  searchContext,
8240
8287
  ctx.state,
8241
- ctx.config.compress.protectTags
8242
- );
8243
- const summaryWithTools = await appendProtectedTools(
8244
- ctx.client,
8245
- ctx.state,
8246
- summaryWithPromptInfo,
8247
- plan.selection,
8248
- searchContext,
8249
- ctx.config.compress.protectedTools,
8250
- ctx.config.protectedFilePatterns
8251
- );
8252
- const completedSummary = appendMissingBlockSummaries(
8253
- summaryWithTools,
8254
- [],
8255
- searchContext.summaryByBlockId,
8256
- injected.consumedBlockIds
8257
- );
8258
- const boundaryConsumed = extractBoundaryConsumedBlocks2(
8259
- plan.selection.startReference,
8260
- plan.selection.endReference
8288
+ ctx.config,
8289
+ ctx.logger
8261
8290
  );
8262
- const seenConsumed = /* @__PURE__ */ new Set();
8263
- const mergeConsumedBlockIds = [
8264
- ...plan.selection.requiredBlockIds,
8265
- ...boundaryConsumed
8266
- ].filter((id) => {
8267
- if (seenConsumed.has(id)) return false;
8268
- seenConsumed.add(id);
8269
- return true;
8270
- });
8271
- preparedPlans.push({
8272
- entry: plan.entry,
8273
- selection: plan.selection,
8274
- anchorMessageId: plan.anchorMessageId,
8275
- finalSummary: appendArchivedImagePointers(
8276
- completedSummary.expandedSummary,
8291
+ if (needsSmartPlan) {
8292
+ validateSmartPlan(
8293
+ toolCtx.sessionID,
8294
+ filteredPlans[0].entry.startId,
8295
+ filteredPlans[0].entry.endId,
8296
+ filteredPlans[0].selection.messageIds,
8297
+ totalChars,
8298
+ ctx.state.prune.messages.structureVersion ?? 0
8299
+ );
8300
+ }
8301
+ const notifications = [];
8302
+ let preparedPlans = [];
8303
+ let totalCompressedMessages = 0;
8304
+ for (const plan of filteredPlans) {
8305
+ const parsedPlaceholders = parseBlockPlaceholders(plan.entry.summary);
8306
+ validateSummaryPlaceholders(
8307
+ parsedPlaceholders,
8308
+ plan.selection.requiredBlockIds,
8309
+ plan.selection.startReference,
8310
+ plan.selection.endReference,
8311
+ searchContext.summaryByBlockId,
8312
+ ctx.logger
8313
+ );
8314
+ const injected = injectBlockPlaceholders(
8315
+ plan.entry.summary,
8316
+ parsedPlaceholders,
8317
+ searchContext.summaryByBlockId,
8318
+ plan.selection.startReference,
8319
+ plan.selection.endReference
8320
+ );
8321
+ const summaryWithUsers = appendProtectedUserMessages(
8322
+ injected.expandedSummary,
8277
8323
  plan.selection,
8324
+ searchContext,
8278
8325
  ctx.state,
8279
- ctx.config.compress.images?.enabled !== false
8326
+ ctx.config.compress.protectUserMessages
8327
+ );
8328
+ const summaryWithPromptInfo = appendProtectedPromptInfo(
8329
+ summaryWithUsers,
8330
+ plan.selection,
8331
+ searchContext,
8332
+ ctx.state,
8333
+ ctx.config.compress.protectTags
8334
+ );
8335
+ const summaryWithTools = await appendProtectedTools(
8336
+ ctx.client,
8337
+ ctx.state,
8338
+ summaryWithPromptInfo,
8339
+ plan.selection,
8340
+ searchContext,
8341
+ ctx.config.compress.protectedTools,
8342
+ ctx.config.protectedFilePatterns
8343
+ );
8344
+ const completedSummary = appendMissingBlockSummaries(
8345
+ summaryWithTools,
8346
+ [],
8347
+ searchContext.summaryByBlockId,
8348
+ injected.consumedBlockIds
8349
+ );
8350
+ const boundaryConsumed = extractBoundaryConsumedBlocks2(
8351
+ plan.selection.startReference,
8352
+ plan.selection.endReference
8353
+ );
8354
+ const seenConsumed = /* @__PURE__ */ new Set();
8355
+ const mergeConsumedBlockIds = [
8356
+ ...plan.selection.requiredBlockIds,
8357
+ ...boundaryConsumed
8358
+ ].filter((id) => {
8359
+ if (seenConsumed.has(id)) return false;
8360
+ seenConsumed.add(id);
8361
+ return true;
8362
+ });
8363
+ preparedPlans.push({
8364
+ entry: plan.entry,
8365
+ selection: plan.selection,
8366
+ anchorMessageId: plan.anchorMessageId,
8367
+ finalSummary: appendArchivedImagePointers(
8368
+ completedSummary.expandedSummary,
8369
+ plan.selection,
8370
+ ctx.state,
8371
+ ctx.config.compress.images?.enabled !== false
8372
+ ),
8373
+ consumedBlockIds: mergeConsumedBlockIds
8374
+ });
8375
+ }
8376
+ const partition = partitionPhantomPlans(
8377
+ identifyPhantomPlans(
8378
+ ctx.state,
8379
+ preparedPlans.map((p) => ({
8380
+ messageIds: p.selection.messageIds,
8381
+ consumedBlockIds: p.consumedBlockIds
8382
+ }))
8280
8383
  ),
8281
- consumedBlockIds: mergeConsumedBlockIds
8282
- });
8283
- }
8284
- const partition = partitionPhantomPlans(
8285
- identifyPhantomPlans(
8286
- ctx.state,
8287
- preparedPlans.map((p) => ({
8288
- messageIds: p.selection.messageIds,
8289
- consumedBlockIds: p.consumedBlockIds
8290
- }))
8291
- ),
8292
- preparedPlans.length
8293
- );
8294
- let phantomSkipNotice = null;
8295
- if (partition.kind === "all-phantom") {
8296
- ctx.logger.warn("Batch compress: ALL entries phantom", {
8297
- totalCount: preparedPlans.length,
8298
- details: partition.details
8299
- });
8300
- throw new Error(buildPhantomErrorMessage(partition.details));
8301
- }
8302
- if (partition.kind === "partial") {
8303
- ctx.logger.warn("Batch compress: phantom entries detected", {
8304
- phantomCount: partition.dropIndices.length,
8305
- totalCount: preparedPlans.length,
8306
- phantomIndices: partition.dropIndices,
8307
- details: partition.details
8308
- });
8309
- const dropSet = new Set(partition.dropIndices);
8310
- preparedPlans = preparedPlans.filter((_, i) => !dropSet.has(i));
8311
- phantomSkipNotice = partition.notice;
8312
- }
8313
- const acknowledgeRisk = args.acknowledgeRisk === true;
8314
- const qualityGateRetryPendingBefore = ctx.state.qualityGateRetryPending;
8315
- const bypassQuality = acknowledgeRisk && ctx.state.qualityGateRetryPending;
8316
- const ignoredAcknowledgeRisk = acknowledgeRisk && !ctx.state.qualityGateRetryPending;
8317
- if (ignoredAcknowledgeRisk) {
8318
- ctx.logger.warn(
8319
- "compress: acknowledgeRisk ignored \u2014 no quality gate rejection pending"
8384
+ preparedPlans.length
8320
8385
  );
8321
- }
8322
- ctx.state.qualityGateRetryPending = false;
8323
- if (!bypassQuality) {
8324
- for (const plan of preparedPlans) {
8325
- const result = evaluatePreCommitQuality(
8326
- rawMessages,
8327
- plan.selection.messageIds,
8328
- plan.selection.messageTokenById,
8329
- plan.finalSummary,
8330
- ctx.config,
8331
- ctx.logger
8386
+ let phantomSkipNotice = null;
8387
+ if (partition.kind === "all-phantom") {
8388
+ ctx.logger.warn("Batch compress: ALL entries phantom", {
8389
+ totalCount: preparedPlans.length,
8390
+ details: partition.details
8391
+ });
8392
+ throw new Error(buildPhantomErrorMessage(partition.details));
8393
+ }
8394
+ if (partition.kind === "partial") {
8395
+ ctx.logger.warn("Batch compress: phantom entries detected", {
8396
+ phantomCount: partition.dropIndices.length,
8397
+ totalCount: preparedPlans.length,
8398
+ phantomIndices: partition.dropIndices,
8399
+ details: partition.details
8400
+ });
8401
+ const dropSet = new Set(partition.dropIndices);
8402
+ preparedPlans = preparedPlans.filter((_, i) => !dropSet.has(i));
8403
+ phantomSkipNotice = partition.notice;
8404
+ }
8405
+ const acknowledgeRisk = args.acknowledgeRisk === true;
8406
+ const qualityGateRetryPendingBefore = ctx.state.qualityGateRetryPending;
8407
+ const bypassQuality = acknowledgeRisk && ctx.state.qualityGateRetryPending;
8408
+ const ignoredAcknowledgeRisk = acknowledgeRisk && !ctx.state.qualityGateRetryPending;
8409
+ if (ignoredAcknowledgeRisk) {
8410
+ ctx.logger.warn(
8411
+ "compress: acknowledgeRisk ignored \u2014 no quality gate rejection pending"
8332
8412
  );
8333
- if (result && !result.passed) {
8334
- ctx.state.qualityGateRetryPending = true;
8335
- throw buildQualityRejectionError(
8336
- {
8337
- startId: plan.entry.startId,
8338
- endId: plan.entry.endId,
8339
- summary: plan.finalSummary,
8340
- messageIds: plan.selection.messageIds,
8341
- messageTokenById: plan.selection.messageTokenById
8342
- },
8343
- result,
8413
+ }
8414
+ ctx.state.qualityGateRetryPending = false;
8415
+ if (!bypassQuality) {
8416
+ for (const plan of preparedPlans) {
8417
+ const result = evaluatePreCommitQuality(
8418
+ rawMessages,
8419
+ plan.selection.messageIds,
8420
+ plan.selection.messageTokenById,
8421
+ plan.finalSummary,
8422
+ ctx.config,
8344
8423
  ctx.logger
8345
8424
  );
8425
+ if (result && !result.passed) {
8426
+ ctx.state.qualityGateRetryPending = true;
8427
+ throw buildQualityRejectionError(
8428
+ {
8429
+ startId: plan.entry.startId,
8430
+ endId: plan.entry.endId,
8431
+ summary: plan.finalSummary,
8432
+ messageIds: plan.selection.messageIds,
8433
+ messageTokenById: plan.selection.messageTokenById
8434
+ },
8435
+ result,
8436
+ ctx.logger
8437
+ );
8438
+ }
8346
8439
  }
8347
8440
  }
8348
- }
8349
- const snapshot = snapshotCompressionState(ctx.state);
8350
- const runId = allocateRunId(ctx.state);
8351
- try {
8352
- for (const preparedPlan of preparedPlans) {
8353
- const blockId = allocateBlockId(ctx.state);
8354
- const keepResult = resolveKeepMarkers(
8355
- preparedPlan.finalSummary,
8356
- rawMessages,
8357
- ctx.state,
8358
- ctx.config
8359
- );
8360
- preparedPlan.finalSummary = keepResult.summary;
8361
- const storedSummary = wrapCompressedSummary(blockId, preparedPlan.finalSummary);
8362
- const summaryTokens = countTokens2(storedSummary);
8363
- const applied = applyCompressionState(
8364
- ctx.state,
8365
- {
8366
- topic: preparedPlan.entry.topic ?? input.topic ?? "",
8367
- batchTopic: input.topic,
8368
- startId: preparedPlan.entry.startId,
8369
- endId: preparedPlan.entry.endId,
8370
- mode: "range",
8441
+ const snapshot = snapshotCompressionState(ctx.state);
8442
+ const runId = allocateRunId(ctx.state);
8443
+ try {
8444
+ for (const preparedPlan of preparedPlans) {
8445
+ const blockId = allocateBlockId(ctx.state);
8446
+ const keepResult = resolveKeepMarkers(
8447
+ preparedPlan.finalSummary,
8448
+ rawMessages,
8449
+ ctx.state,
8450
+ ctx.config
8451
+ );
8452
+ preparedPlan.finalSummary = keepResult.summary;
8453
+ const storedSummary = wrapCompressedSummary(blockId, preparedPlan.finalSummary);
8454
+ const summaryTokens = countTokens2(storedSummary);
8455
+ const applied = applyCompressionState(
8456
+ ctx.state,
8457
+ {
8458
+ topic: preparedPlan.entry.topic ?? input.topic ?? "",
8459
+ batchTopic: input.topic,
8460
+ startId: preparedPlan.entry.startId,
8461
+ endId: preparedPlan.entry.endId,
8462
+ mode: "range",
8463
+ runId,
8464
+ compressMessageId: toolCtx.messageID,
8465
+ compressCallId: callId,
8466
+ summaryTokens
8467
+ },
8468
+ preparedPlan.selection,
8469
+ preparedPlan.anchorMessageId,
8470
+ blockId,
8471
+ storedSummary,
8472
+ preparedPlan.consumedBlockIds,
8473
+ ctx.config.gc
8474
+ );
8475
+ totalCompressedMessages += applied.messageIds.length;
8476
+ notifications.push({
8477
+ blockId,
8371
8478
  runId,
8372
- compressMessageId: toolCtx.messageID,
8373
- compressCallId: callId,
8479
+ summary: preparedPlan.finalSummary,
8374
8480
  summaryTokens
8375
- },
8376
- preparedPlan.selection,
8377
- preparedPlan.anchorMessageId,
8378
- blockId,
8379
- storedSummary,
8380
- preparedPlan.consumedBlockIds,
8381
- ctx.config.gc
8382
- );
8383
- totalCompressedMessages += applied.messageIds.length;
8384
- notifications.push({
8385
- blockId,
8386
- runId,
8387
- summary: preparedPlan.finalSummary,
8388
- summaryTokens
8389
- });
8481
+ });
8482
+ }
8483
+ await finalizeSession(ctx, toolCtx, rawMessages, notifications, input.topic);
8484
+ if (needsSmartPlan) consumeSmartPlan(toolCtx.sessionID);
8485
+ } catch (error) {
8486
+ restoreCompressionState(ctx.state, snapshot);
8487
+ ctx.state.qualityGateRetryPending = qualityGateRetryPendingBefore;
8488
+ throw error;
8390
8489
  }
8391
- await finalizeSession(ctx, toolCtx, rawMessages, notifications, input.topic);
8392
- if (needsSmartPlan) consumeSmartPlan(toolCtx.sessionID);
8393
- } catch (error) {
8394
- restoreCompressionState(ctx.state, snapshot);
8395
- ctx.state.qualityGateRetryPending = qualityGateRetryPendingBefore;
8396
- throw error;
8397
- }
8398
- const skippedNote = phantomSkipNotice !== null ? `
8490
+ const skippedNote = phantomSkipNotice !== null ? `
8399
8491
  \u26A0\uFE0F ${phantomSkipNotice}
8400
8492
  ` : "";
8401
- const ackNote = ignoredAcknowledgeRisk ? `
8493
+ const ackNote = ignoredAcknowledgeRisk ? `
8402
8494
  \u26A0\uFE0F acknowledgeRisk was ignored: no quality gate rejection was pending, so quality checks ran normally. Only pass it when retrying immediately after a quality gate rejection.
8403
8495
  ` : "";
8404
- return `Compressed ${totalCompressedMessages} messages into ${COMPRESSED_BLOCK_HEADER}.${skippedNote}${ackNote}
8496
+ return `Compressed ${totalCompressedMessages} messages into ${COMPRESSED_BLOCK_HEADER}.${skippedNote}${ackNote}
8405
8497
  IMPORTANT: This was an automatic context compression. You MUST continue your previous task exactly where you left off. Do NOT ask the user what to do next.
8406
8498
  \u{1F4A1} Tip: Use search_context('keyword') to find compressed content when you need it later.`;
8499
+ };
8500
+ await requestCompressPermission(toolCtx, title);
8501
+ return factoryCtx.registry.withSessionGuard(toolCtx.sessionID, () => run());
8407
8502
  }
8408
8503
  });
8409
8504
  }
@@ -10255,14 +10350,10 @@ function extractTextContent(msg) {
10255
10350
  }
10256
10351
 
10257
10352
  // lib/compress/decompress.ts
10258
- async function prepareDecompressSession(ctx, toolCtx) {
10259
- await toolCtx.ask({
10260
- permission: "compress",
10261
- patterns: ["*"],
10262
- always: ["*"],
10263
- metadata: {}
10264
- });
10265
- toolCtx.metadata({ title: "Decompress" });
10353
+ async function prepareDecompressSession(ctx, toolCtx, opts) {
10354
+ if (!opts?.preApproved) {
10355
+ await requestCompressPermission(toolCtx, "Decompress");
10356
+ }
10266
10357
  const rawMessages = await fetchSessionMessages(ctx.client, toolCtx.sessionID);
10267
10358
  await ensureSessionInitialized(
10268
10359
  ctx.client,
@@ -10439,139 +10530,143 @@ function createDecompressTool(factoryCtx) {
10439
10530
  args: buildSchema2(),
10440
10531
  async execute(args, toolCtx) {
10441
10532
  const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID);
10442
- const { rawMessages } = await prepareDecompressSession(ctx, toolCtx);
10443
- const effectiveLimitBefore = resolveEffectiveContextLimit(ctx.state, ctx.config);
10444
- const contextUsageBefore = effectiveLimitBefore ? Math.round(
10445
- getCurrentTokenUsage(ctx.state, rawMessages) / effectiveLimitBefore.limit * 100
10446
- ) : void 0;
10447
- const resolved = resolveTargets(args, ctx.state, rawMessages, ctx.logger);
10448
- if (!resolved.ok) {
10449
- return resolved.error;
10450
- }
10451
- const targets = resolved.targets;
10452
- const messagesState = ctx.state.prune.messages;
10453
- const activeBlocks = [];
10454
- for (const target of targets) {
10455
- for (const block of target.blocks) {
10456
- if (block.active) {
10457
- activeBlocks.push(block);
10458
- }
10533
+ const run = async () => {
10534
+ const { rawMessages } = await prepareDecompressSession(ctx, toolCtx, { preApproved: true });
10535
+ const effectiveLimitBefore = resolveEffectiveContextLimit(ctx.state, ctx.config);
10536
+ const contextUsageBefore = effectiveLimitBefore ? Math.round(
10537
+ getCurrentTokenUsage(ctx.state, rawMessages) / effectiveLimitBefore.limit * 100
10538
+ ) : void 0;
10539
+ const resolved = resolveTargets(args, ctx.state, rawMessages, ctx.logger);
10540
+ if (!resolved.ok) {
10541
+ return resolved.error;
10459
10542
  }
10460
- }
10461
- if (args.toFile) {
10462
- const targetPath = args.toFile;
10463
- const os = await import("os");
10464
- const path = await import("path");
10465
- const allowedDirs = [
10466
- os.tmpdir() + "/",
10467
- path.join(os.homedir(), ".cache", "opencode") + "/"
10468
- ];
10469
- const resolvedPath = path.resolve(targetPath);
10470
- const isAllowed = allowedDirs.some((dir) => {
10471
- const rel = path.relative(dir, resolvedPath);
10472
- return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
10473
- });
10474
- if (!isAllowed) {
10475
- return `Error: toFile path must be under ${os.tmpdir()} or ~/.cache/opencode/. Got: ${targetPath}`;
10543
+ const targets = resolved.targets;
10544
+ const messagesState = ctx.state.prune.messages;
10545
+ const activeBlocks = [];
10546
+ for (const target of targets) {
10547
+ for (const block of target.blocks) {
10548
+ if (block.active) {
10549
+ activeBlocks.push(block);
10550
+ }
10551
+ }
10476
10552
  }
10477
- const msgIdSet = /* @__PURE__ */ new Set();
10478
- for (const block of activeBlocks) {
10479
- for (const id of block.effectiveMessageIds ?? []) {
10480
- msgIdSet.add(id);
10553
+ if (args.toFile) {
10554
+ const targetPath = args.toFile;
10555
+ const os = await import("os");
10556
+ const path = await import("path");
10557
+ const allowedDirs = [
10558
+ os.tmpdir() + "/",
10559
+ path.join(os.homedir(), ".cache", "opencode") + "/"
10560
+ ];
10561
+ const resolvedPath = path.resolve(targetPath);
10562
+ const isAllowed = allowedDirs.some((dir) => {
10563
+ const rel = path.relative(dir, resolvedPath);
10564
+ return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
10565
+ });
10566
+ if (!isAllowed) {
10567
+ return `Error: toFile path must be under ${os.tmpdir()} or ~/.cache/opencode/. Got: ${targetPath}`;
10568
+ }
10569
+ const msgIdSet = /* @__PURE__ */ new Set();
10570
+ for (const block of activeBlocks) {
10571
+ for (const id of block.effectiveMessageIds ?? []) {
10572
+ msgIdSet.add(id);
10573
+ }
10574
+ }
10575
+ const blockMessages = rawMessages.filter((m) => msgIdSet.has(extractMessageId(m)));
10576
+ const lines2 = blockMessages.map(extractMessageText2);
10577
+ const { writeFile: writeFile4 } = await import("fs/promises");
10578
+ let fileContent;
10579
+ let sourceNote;
10580
+ if (lines2.length > 0) {
10581
+ fileContent = lines2.join("\n\n---\n\n");
10582
+ sourceNote = `${blockMessages.length} original message${blockMessages.length === 1 ? "" : "s"}`;
10583
+ } else {
10584
+ fileContent = targets[0]?.blocks[0]?.summary ?? "(no content available)";
10585
+ sourceNote = `no original messages found; wrote block summary (${targets.length} block${targets.length === 1 ? "" : "s"})`;
10481
10586
  }
10587
+ await writeFile4(targetPath, fileContent, "utf-8");
10588
+ const displayIds2 = targets.map((t) => `b${t.displayId}`).join(", ");
10589
+ return `Block(s) ${displayIds2} content (${sourceNote}, ${fileContent.length} chars) written to ${targetPath}. Block(s) stay compressed \u2014 context unchanged. Use read tool to access specific parts.`;
10482
10590
  }
10483
- const blockMessages = rawMessages.filter((m) => msgIdSet.has(extractMessageId(m)));
10484
- const lines2 = blockMessages.map(extractMessageText2);
10485
- const { writeFile: writeFile4 } = await import("fs/promises");
10486
- let fileContent;
10487
- let sourceNote;
10488
- if (lines2.length > 0) {
10489
- fileContent = lines2.join("\n\n---\n\n");
10490
- sourceNote = `${blockMessages.length} original message${blockMessages.length === 1 ? "" : "s"}`;
10491
- } else {
10492
- fileContent = targets[0]?.blocks[0]?.summary ?? "(no content available)";
10493
- sourceNote = `no original messages found; wrote block summary (${targets.length} block${targets.length === 1 ? "" : "s"})`;
10494
- }
10495
- await writeFile4(targetPath, fileContent, "utf-8");
10496
- const displayIds2 = targets.map((t) => `b${t.displayId}`).join(", ");
10497
- return `Block(s) ${displayIds2} content (${sourceNote}, ${fileContent.length} chars) written to ${targetPath}. Block(s) stay compressed \u2014 context unchanged. Use read tool to access specific parts.`;
10498
- }
10499
- const availability = checkDecompressSourceAvailability(
10500
- messagesState,
10501
- targets,
10502
- { full: args.full === true },
10503
- new Set(rawMessages.map((m) => m.info.id))
10504
- );
10505
- if (availability.missingMessageIds.length > 0) {
10506
- const displayIds2 = targets.map((t) => `b${t.displayId}`).join(", ");
10507
- const missingRefs = availability.missingMessageIds.map(
10508
- (id) => ctx.state.messageIds.byRawId.get(id) ?? id
10591
+ const availability = checkDecompressSourceAvailability(
10592
+ messagesState,
10593
+ targets,
10594
+ { full: args.full === true },
10595
+ new Set(rawMessages.map((m) => m.info.id))
10596
+ );
10597
+ if (availability.missingMessageIds.length > 0) {
10598
+ const displayIds2 = targets.map((t) => `b${t.displayId}`).join(", ");
10599
+ const missingRefs = availability.missingMessageIds.map(
10600
+ (id) => ctx.state.messageIds.byRawId.get(id) ?? id
10601
+ );
10602
+ const shownRefs = missingRefs.slice(0, 10).join(", ");
10603
+ const extraCount = missingRefs.length - Math.min(missingRefs.length, 10);
10604
+ ctx.logger.warn("Decompress aborted: source messages missing from host history", {
10605
+ targetDisplayIds: displayIds2,
10606
+ full: args.full === true,
10607
+ requiredCount: availability.requiredMessageIds.length,
10608
+ missingCount: availability.missingMessageIds.length,
10609
+ missingRefs
10610
+ });
10611
+ return `Error: Cannot decompress ${displayIds2}: ${availability.missingMessageIds.length} of ${availability.requiredMessageIds.length} source message(s) are no longer present in session history (removed by native compaction or external deletion). Missing: ${shownRefs}` + (extraCount > 0 ? ` (+${extraCount} more)` : "") + `. Block(s) stay compressed \u2014 the summary remains available and nothing was changed. This is a missing-source condition, not a transient fetch failure.`;
10612
+ }
10613
+ const activeMessagesBefore = snapshotActiveMessages(messagesState);
10614
+ const activeBlockIdsBefore = new Set(messagesState.activeBlockIds);
10615
+ for (const target of targets) {
10616
+ deactivateCompressionTarget(messagesState, target, { full: args.full === true });
10617
+ }
10618
+ syncCompressionBlocks(ctx.state, ctx.logger, rawMessages);
10619
+ const { restoredMessageCount, restoredTokens } = computeRestoredMessages(
10620
+ messagesState,
10621
+ activeMessagesBefore
10622
+ );
10623
+ const reactivatedBlockIds = computeReactivatedBlockIds(
10624
+ messagesState,
10625
+ activeBlockIdsBefore
10626
+ );
10627
+ ctx.state.stats.totalPruneTokens = Math.max(
10628
+ 0,
10629
+ ctx.state.stats.totalPruneTokens - restoredTokens
10630
+ );
10631
+ const effectiveLimitAfter = resolveEffectiveContextLimit(ctx.state, ctx.config);
10632
+ const contextUsageAfter = effectiveLimitAfter ? Math.round(
10633
+ getCurrentTokenUsage(ctx.state, rawMessages) / effectiveLimitAfter.limit * 100
10634
+ ) : void 0;
10635
+ await finalizeDecompressSession(ctx);
10636
+ const restoredContentPreview = buildRestoredContentPreview(
10637
+ rawMessages,
10638
+ activeMessagesBefore,
10639
+ messagesState
10640
+ );
10641
+ const displayIds = targets.map((t) => `b${t.displayId}`).join(", ");
10642
+ const lines = [];
10643
+ const headerNoun = targets.length === 1 ? "block" : "blocks";
10644
+ lines.push(
10645
+ `Decompressed ${headerNoun} ${displayIds}. Restored ${restoredMessageCount} message(s) (~${formatTokenCount(restoredTokens)}).`
10509
10646
  );
10510
- const shownRefs = missingRefs.slice(0, 10).join(", ");
10511
- const extraCount = missingRefs.length - Math.min(missingRefs.length, 10);
10512
- ctx.logger.warn("Decompress aborted: source messages missing from host history", {
10513
- targetDisplayIds: displayIds2,
10514
- full: args.full === true,
10515
- requiredCount: availability.requiredMessageIds.length,
10516
- missingCount: availability.missingMessageIds.length,
10517
- missingRefs
10647
+ if (contextUsageBefore !== void 0 && contextUsageAfter !== void 0) {
10648
+ lines.push(`Context usage: ${contextUsageBefore}% \u2192 ${contextUsageAfter}%.`);
10649
+ }
10650
+ if (reactivatedBlockIds.length > 0) {
10651
+ const refs = reactivatedBlockIds.map((id) => `b${id}`).join(", ");
10652
+ lines.push(`Also restored nested block(s): ${refs}.`);
10653
+ }
10654
+ if (restoredContentPreview) {
10655
+ lines.push("");
10656
+ lines.push("RESTORED CONTENT (condensed):");
10657
+ lines.push(restoredContentPreview);
10658
+ }
10659
+ ctx.logger.info("Decompress tool completed", {
10660
+ mode: typeof args.startId === "string" ? "range" : "block",
10661
+ targetBlockIds: targets.map((t) => t.displayId),
10662
+ restoredMessageCount,
10663
+ restoredTokens,
10664
+ reactivatedBlockIds
10518
10665
  });
10519
- return `Error: Cannot decompress ${displayIds2}: ${availability.missingMessageIds.length} of ${availability.requiredMessageIds.length} source message(s) are no longer present in session history (removed by native compaction or external deletion). Missing: ${shownRefs}` + (extraCount > 0 ? ` (+${extraCount} more)` : "") + `. Block(s) stay compressed \u2014 the summary remains available and nothing was changed. This is a missing-source condition, not a transient fetch failure.`;
10520
- }
10521
- const activeMessagesBefore = snapshotActiveMessages(messagesState);
10522
- const activeBlockIdsBefore = new Set(messagesState.activeBlockIds);
10523
- for (const target of targets) {
10524
- deactivateCompressionTarget(messagesState, target, { full: args.full === true });
10525
- }
10526
- syncCompressionBlocks(ctx.state, ctx.logger, rawMessages);
10527
- const { restoredMessageCount, restoredTokens } = computeRestoredMessages(
10528
- messagesState,
10529
- activeMessagesBefore
10530
- );
10531
- const reactivatedBlockIds = computeReactivatedBlockIds(
10532
- messagesState,
10533
- activeBlockIdsBefore
10534
- );
10535
- ctx.state.stats.totalPruneTokens = Math.max(
10536
- 0,
10537
- ctx.state.stats.totalPruneTokens - restoredTokens
10538
- );
10539
- const effectiveLimitAfter = resolveEffectiveContextLimit(ctx.state, ctx.config);
10540
- const contextUsageAfter = effectiveLimitAfter ? Math.round(
10541
- getCurrentTokenUsage(ctx.state, rawMessages) / effectiveLimitAfter.limit * 100
10542
- ) : void 0;
10543
- await finalizeDecompressSession(ctx);
10544
- const restoredContentPreview = buildRestoredContentPreview(
10545
- rawMessages,
10546
- activeMessagesBefore,
10547
- messagesState
10548
- );
10549
- const displayIds = targets.map((t) => `b${t.displayId}`).join(", ");
10550
- const lines = [];
10551
- const headerNoun = targets.length === 1 ? "block" : "blocks";
10552
- lines.push(
10553
- `Decompressed ${headerNoun} ${displayIds}. Restored ${restoredMessageCount} message(s) (~${formatTokenCount(restoredTokens)}).`
10554
- );
10555
- if (contextUsageBefore !== void 0 && contextUsageAfter !== void 0) {
10556
- lines.push(`Context usage: ${contextUsageBefore}% \u2192 ${contextUsageAfter}%.`);
10557
- }
10558
- if (reactivatedBlockIds.length > 0) {
10559
- const refs = reactivatedBlockIds.map((id) => `b${id}`).join(", ");
10560
- lines.push(`Also restored nested block(s): ${refs}.`);
10561
- }
10562
- if (restoredContentPreview) {
10563
- lines.push("");
10564
- lines.push("RESTORED CONTENT (condensed):");
10565
- lines.push(restoredContentPreview);
10566
- }
10567
- ctx.logger.info("Decompress tool completed", {
10568
- mode: typeof args.startId === "string" ? "range" : "block",
10569
- targetBlockIds: targets.map((t) => t.displayId),
10570
- restoredMessageCount,
10571
- restoredTokens,
10572
- reactivatedBlockIds
10573
- });
10574
- return lines.join("\n");
10666
+ return lines.join("\n");
10667
+ };
10668
+ await requestCompressPermission(toolCtx, "Decompress");
10669
+ return factoryCtx.registry.withSessionGuard(toolCtx.sessionID, () => run());
10575
10670
  }
10576
10671
  });
10577
10672
  }
@@ -11329,7 +11424,7 @@ import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
11329
11424
  import { join as join5 } from "path";
11330
11425
  import { existsSync as existsSync5 } from "fs";
11331
11426
  import { homedir as homedir3 } from "os";
11332
- var LOG_VERSION = true ? "1.18.4" : "dev";
11427
+ var LOG_VERSION = true ? "1.18.5-pr.413.228" : "dev";
11333
11428
  var LEVEL_RANK = {
11334
11429
  debug: 10,
11335
11430
  info: 20,
@@ -12184,6 +12279,7 @@ function truncateLargeToolOutputs(state, config, logger, messages) {
12184
12279
  const part = parts[pi];
12185
12280
  if (part?.type !== "tool") continue;
12186
12281
  if (part.state?.status !== "completed") continue;
12282
+ if (part.tool === "compress") continue;
12187
12283
  const content = extractCompletedToolOutput(part);
12188
12284
  if (content === void 0) continue;
12189
12285
  if (content === "[Old tool result content cleared]") continue;
@@ -12224,6 +12320,74 @@ function truncateLargeToolOutputs(state, config, logger, messages) {
12224
12320
  }
12225
12321
  }
12226
12322
 
12323
+ // lib/messages/sticky-truncation.ts
12324
+ function collectToolParts(messages) {
12325
+ const parts = [];
12326
+ for (const message of messages) {
12327
+ const messageParts = Array.isArray(message.parts) ? message.parts : [];
12328
+ for (const part of messageParts) {
12329
+ if (part?.type === "tool") {
12330
+ parts.push(part);
12331
+ }
12332
+ }
12333
+ }
12334
+ return parts;
12335
+ }
12336
+ function applyStickyTruncations(state, messages, logger) {
12337
+ const frozen = state.stickyTruncations;
12338
+ if (Object.keys(frozen).length === 0) return;
12339
+ let replayed = 0;
12340
+ const seen = /* @__PURE__ */ new Set();
12341
+ for (const part of collectToolParts(messages)) {
12342
+ const callID = typeof part.callID === "string" ? part.callID : void 0;
12343
+ if (!callID) continue;
12344
+ seen.add(callID);
12345
+ const recorded = frozen[callID];
12346
+ if (recorded === void 0) continue;
12347
+ if (!part.state) continue;
12348
+ if (part.state.output === recorded) continue;
12349
+ part.state.output = recorded;
12350
+ replayed++;
12351
+ }
12352
+ for (const callID of Object.keys(frozen)) {
12353
+ if (!seen.has(callID)) delete frozen[callID];
12354
+ }
12355
+ if (replayed > 0) {
12356
+ logger.debug("Sticky truncation: replayed frozen tool outputs", {
12357
+ replayed,
12358
+ frozen: Object.keys(frozen).length
12359
+ });
12360
+ }
12361
+ }
12362
+ function snapshotToolOutputs(messages) {
12363
+ const snapshot = /* @__PURE__ */ new Map();
12364
+ for (const part of collectToolParts(messages)) {
12365
+ const callID = typeof part.callID === "string" ? part.callID : void 0;
12366
+ if (!callID) continue;
12367
+ const content = extractCompletedToolOutput(part);
12368
+ if (content === void 0) continue;
12369
+ snapshot.set(callID, content);
12370
+ }
12371
+ return snapshot;
12372
+ }
12373
+ function recordStickyTruncations(state, messages, before, logger) {
12374
+ let recorded = 0;
12375
+ for (const part of collectToolParts(messages)) {
12376
+ const callID = typeof part.callID === "string" ? part.callID : void 0;
12377
+ if (!callID) continue;
12378
+ if (typeof part.state?.output !== "string") continue;
12379
+ const prior = before.get(callID);
12380
+ if (prior === void 0 || prior === part.state.output) continue;
12381
+ if (state.stickyTruncations[callID] === part.state.output) continue;
12382
+ state.stickyTruncations[callID] = part.state.output;
12383
+ recorded++;
12384
+ }
12385
+ if (recorded > 0) {
12386
+ logger.info("Sticky truncation: froze truncated tool outputs", { recorded });
12387
+ }
12388
+ return recorded;
12389
+ }
12390
+
12227
12391
  // lib/messages/enforce-budget.ts
12228
12392
  var DEFAULT_COMPLETION_RESERVE_TOKENS = 32768;
12229
12393
  var TRUNCATION_MARKER2 = "[truncated for context space";
@@ -13393,22 +13557,24 @@ function createSystemPromptHandler(registry4, logger, config, prompts) {
13393
13557
  logger.info("Skipping DCP system prompt injection for internal agent");
13394
13558
  return;
13395
13559
  }
13396
- if (input.model?.limit?.context) {
13397
- const limit = input.model.limit.context;
13398
- const providerID = input.model?.providerID;
13399
- const modelID = input.model?.id;
13400
- const changed = state.modelContextLimit !== limit || providerID !== void 0 && state.modelProviderID !== providerID || modelID !== void 0 && state.modelID !== modelID;
13401
- state.modelContextLimit = limit;
13402
- if (providerID !== void 0) {
13403
- state.modelProviderID = providerID;
13404
- }
13405
- if (modelID !== void 0) {
13406
- state.modelID = modelID;
13407
- }
13408
- if (changed) {
13409
- saveSessionState(state, logger).catch(() => {
13410
- });
13411
- }
13560
+ if (input.model?.limit?.context && input.sessionID) {
13561
+ await registry4.withSessionGuard(input.sessionID, () => {
13562
+ const limit = input.model.limit.context;
13563
+ const providerID = input.model?.providerID;
13564
+ const modelID = input.model?.id;
13565
+ const changed = state.modelContextLimit !== limit || providerID !== void 0 && state.modelProviderID !== providerID || modelID !== void 0 && state.modelID !== modelID;
13566
+ state.modelContextLimit = limit;
13567
+ if (providerID !== void 0) {
13568
+ state.modelProviderID = providerID;
13569
+ }
13570
+ if (modelID !== void 0) {
13571
+ state.modelID = modelID;
13572
+ }
13573
+ if (changed) {
13574
+ saveSessionState(state, logger).catch(() => {
13575
+ });
13576
+ }
13577
+ });
13412
13578
  }
13413
13579
  const effectivePermission = compressPermission(state, config);
13414
13580
  if (effectivePermission === "deny") {
@@ -13443,11 +13609,172 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
13443
13609
  return;
13444
13610
  }
13445
13611
  const lastUserMessage = getLastUserMessage(messages);
13446
- let state;
13612
+ const runPipeline = async (state) => {
13613
+ syncCompressPermissionState(state, config, hostPermissions, output.messages);
13614
+ if (state.isSubAgent && !config.allowSubAgents) {
13615
+ return;
13616
+ }
13617
+ stripHallucinations(output.messages);
13618
+ const dropReasoningModel = lastUserMessage?.info?.model;
13619
+ const reasoningConfig = applyCompressOverrides(
13620
+ config,
13621
+ dropReasoningModel?.providerID ?? state.modelProviderID,
13622
+ dropReasoningModel?.modelID ?? state.modelID
13623
+ ).compress.reasoning;
13624
+ if (reasoningConfig?.drop !== false) {
13625
+ const droppedReasoning = dropCompressReasoning(
13626
+ output.messages,
13627
+ reasoningConfig?.threshold ?? DEFAULT_COMPRESS_REASONING.threshold
13628
+ );
13629
+ if (droppedReasoning > 0) {
13630
+ logger.debug("compress.reasoning: dropped oversized reasoning parts", {
13631
+ dropped: droppedReasoning,
13632
+ threshold: reasoningConfig?.threshold ?? DEFAULT_COMPRESS_REASONING.threshold
13633
+ });
13634
+ }
13635
+ }
13636
+ ensureBuiltinFiltersRegistered();
13637
+ const effectiveLimit = resolveEffectiveContextLimit(state, config);
13638
+ applyMessageFilters(output.messages, config.messageFilters, logger, {
13639
+ sessionId: state.sessionId ?? "",
13640
+ isSubAgent: state.isSubAgent,
13641
+ modelContextLimit: effectiveLimit?.limit
13642
+ });
13643
+ cacheSystemPromptTokens(state, output.messages);
13644
+ assignMessageRefs(state, output.messages);
13645
+ const imagesConfig = config.compress.images ?? DEFAULT_COMPRESS_IMAGES;
13646
+ if (imagesConfig.enabled) {
13647
+ const archivedImages = archiveSessionImages(
13648
+ state,
13649
+ output.messages,
13650
+ imagesConfig,
13651
+ logger
13652
+ );
13653
+ if (archivedImages > 0) {
13654
+ saveSessionState(state, logger).catch(() => {
13655
+ });
13656
+ }
13657
+ pruneImageArchive(state, imagesConfig, logger);
13658
+ }
13659
+ const activeBlockCountBefore = state.prune.messages.activeBlockIds.size;
13660
+ const compressionStateChanged = syncCompressionBlocks(state, logger, output.messages);
13661
+ if (compressionStateChanged || state.prune.messages.activeBlockIds.size !== activeBlockCountBefore) {
13662
+ saveSessionState(state, logger).catch(() => {
13663
+ });
13664
+ }
13665
+ syncToolCache(state, config, logger, output.messages);
13666
+ buildToolIdList(state, output.messages);
13667
+ const batchResult = runBatchCleanup(state, config, logger, output.messages);
13668
+ if (batchResult.mergedCount > 0) {
13669
+ saveSessionState(state, logger).catch(() => {
13670
+ });
13671
+ }
13672
+ const prePruneTokens = getCurrentTokenUsage(state, output.messages);
13673
+ const candidateMessages = config.compress.candidates === true ? output.messages.slice() : void 0;
13674
+ prune(state, logger, config, output.messages);
13675
+ hideConsumedCompressCalls(state, output.messages);
13676
+ assignMessageRefs(state, output.messages);
13677
+ const compressionPriorities = buildPriorityMap(config, state, output.messages);
13678
+ prompts.reload();
13679
+ injectCompressNudges(
13680
+ state,
13681
+ config,
13682
+ logger,
13683
+ output.messages,
13684
+ prompts.getRuntimePrompts(),
13685
+ compressionPriorities,
13686
+ config.debug ? (text) => {
13687
+ logger.debug(`[ACP Debug] Nudge injected:
13688
+ ${text}`);
13689
+ client.tui.showToast({
13690
+ body: {
13691
+ title: "ACP: Nudge Injected",
13692
+ message: text.slice(0, 500),
13693
+ variant: "info",
13694
+ duration: 5e3
13695
+ }
13696
+ }).catch(() => {
13697
+ });
13698
+ } : void 0,
13699
+ prePruneTokens,
13700
+ candidateMessages
13701
+ );
13702
+ const emergencyTruncationEnabled = applyCompressOverrides(
13703
+ config,
13704
+ dropReasoningModel?.providerID ?? state.modelProviderID,
13705
+ dropReasoningModel?.modelID ?? state.modelID
13706
+ ).compress.emergencyTruncation;
13707
+ if (emergencyTruncationEnabled === true) {
13708
+ applyStickyTruncations(state, output.messages, logger);
13709
+ const toolOutputsBefore = snapshotToolOutputs(output.messages);
13710
+ truncateLargeToolOutputs(
13711
+ state,
13712
+ config,
13713
+ logger,
13714
+ output.messages.filter((message) => !isSyntheticMessage(message))
13715
+ );
13716
+ enforceContextBudget(state, config, logger, output.messages);
13717
+ const frozenCount = recordStickyTruncations(
13718
+ state,
13719
+ output.messages,
13720
+ toolOutputsBefore,
13721
+ logger
13722
+ );
13723
+ if (frozenCount > 0) {
13724
+ saveSessionState(state, logger).catch(() => {
13725
+ });
13726
+ }
13727
+ }
13728
+ injectMessageIds(state, config, output.messages, compressionPriorities);
13729
+ hideFailedCompressCalls(output.messages);
13730
+ stripStaleMetadata(output.messages);
13731
+ dropEmptyMessages(output.messages);
13732
+ const smartPlanEnabled = applyCompressOverrides(
13733
+ config,
13734
+ state.modelProviderID,
13735
+ state.modelID
13736
+ ).compress.smartPlanRequired;
13737
+ if (state.sessionId && smartPlanEnabled === true) {
13738
+ recordVisibleMessages(state.sessionId, output.messages);
13739
+ }
13740
+ const postTokens = getCurrentTokenUsage(state, output.messages);
13741
+ if (postTokens !== void 0 && effectiveLimit) {
13742
+ const budget = effectiveLimit.limit - (state.systemPromptTokens ?? 0) - OUTPUT_RESERVE_TOKENS;
13743
+ if (postTokens > budget) {
13744
+ logger.error(
13745
+ "ACP hard guard: context exceeds model budget after in-flight reduction",
13746
+ {
13747
+ session: state.sessionId,
13748
+ postTokens,
13749
+ budget,
13750
+ contextLimit: effectiveLimit.limit,
13751
+ contextLimitSource: effectiveLimit.source,
13752
+ hint: "request will likely be rejected; run /compact or start a new session"
13753
+ }
13754
+ );
13755
+ }
13756
+ }
13757
+ logger.info("Chat transform complete", {
13758
+ session: state.sessionId,
13759
+ model: state.modelID,
13760
+ messages: output.messages.length,
13761
+ prePruneTokens,
13762
+ postTokens,
13763
+ contextLimit: effectiveLimit?.limit,
13764
+ contextLimitSource: effectiveLimit?.source,
13765
+ usagePct: postTokens !== void 0 && effectiveLimit ? `${(postTokens / effectiveLimit.limit * 100).toFixed(1)}%` : void 0,
13766
+ nudged: state.nudges.shouldInjectThisTurn
13767
+ });
13768
+ if (state.sessionId) {
13769
+ await logger.saveContext(state.sessionId, output.messages);
13770
+ }
13771
+ };
13447
13772
  if (!lastUserMessage) {
13448
- state = createSessionState();
13449
- } else {
13450
- state = await registry4.getOrCreate(
13773
+ await runPipeline(createSessionState());
13774
+ return;
13775
+ }
13776
+ await registry4.withSessionGuard(lastUserMessage.info.sessionID, async () => {
13777
+ const state = await registry4.getOrCreate(
13451
13778
  client,
13452
13779
  lastUserMessage.info.sessionID,
13453
13780
  messages,
@@ -13504,146 +13831,8 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
13504
13831
  }
13505
13832
  );
13506
13833
  }
13507
- }
13508
- syncCompressPermissionState(state, config, hostPermissions, output.messages);
13509
- if (state.isSubAgent && !config.allowSubAgents) {
13510
- return;
13511
- }
13512
- stripHallucinations(output.messages);
13513
- const dropReasoningModel = lastUserMessage?.info?.model;
13514
- const reasoningConfig = applyCompressOverrides(
13515
- config,
13516
- dropReasoningModel?.providerID ?? state.modelProviderID,
13517
- dropReasoningModel?.modelID ?? state.modelID
13518
- ).compress.reasoning;
13519
- if (reasoningConfig?.drop !== false) {
13520
- const droppedReasoning = dropCompressReasoning(
13521
- output.messages,
13522
- reasoningConfig?.threshold ?? DEFAULT_COMPRESS_REASONING.threshold
13523
- );
13524
- if (droppedReasoning > 0) {
13525
- logger.debug("compress.reasoning: dropped oversized reasoning parts", {
13526
- dropped: droppedReasoning,
13527
- threshold: reasoningConfig?.threshold ?? DEFAULT_COMPRESS_REASONING.threshold
13528
- });
13529
- }
13530
- }
13531
- ensureBuiltinFiltersRegistered();
13532
- const effectiveLimit = resolveEffectiveContextLimit(state, config);
13533
- applyMessageFilters(output.messages, config.messageFilters, logger, {
13534
- sessionId: state.sessionId ?? "",
13535
- isSubAgent: state.isSubAgent,
13536
- modelContextLimit: effectiveLimit?.limit
13834
+ await runPipeline(state);
13537
13835
  });
13538
- cacheSystemPromptTokens(state, output.messages);
13539
- assignMessageRefs(state, output.messages);
13540
- const imagesConfig = config.compress.images ?? DEFAULT_COMPRESS_IMAGES;
13541
- if (imagesConfig.enabled) {
13542
- const archivedImages = archiveSessionImages(
13543
- state,
13544
- output.messages,
13545
- imagesConfig,
13546
- logger
13547
- );
13548
- if (archivedImages > 0) {
13549
- saveSessionState(state, logger).catch(() => {
13550
- });
13551
- }
13552
- pruneImageArchive(state, imagesConfig, logger);
13553
- }
13554
- const activeBlockCountBefore = state.prune.messages.activeBlockIds.size;
13555
- const compressionStateChanged = syncCompressionBlocks(state, logger, output.messages);
13556
- if (compressionStateChanged || state.prune.messages.activeBlockIds.size !== activeBlockCountBefore) {
13557
- saveSessionState(state, logger).catch(() => {
13558
- });
13559
- }
13560
- syncToolCache(state, config, logger, output.messages);
13561
- buildToolIdList(state, output.messages);
13562
- const batchResult = runBatchCleanup(state, config, logger, output.messages);
13563
- if (batchResult.mergedCount > 0) {
13564
- saveSessionState(state, logger).catch(() => {
13565
- });
13566
- }
13567
- const prePruneTokens = getCurrentTokenUsage(state, output.messages);
13568
- const candidateMessages = config.compress.candidates === true ? output.messages.slice() : void 0;
13569
- prune(state, logger, config, output.messages);
13570
- hideConsumedCompressCalls(state, output.messages);
13571
- assignMessageRefs(state, output.messages);
13572
- const compressionPriorities = buildPriorityMap(config, state, output.messages);
13573
- prompts.reload();
13574
- injectCompressNudges(
13575
- state,
13576
- config,
13577
- logger,
13578
- output.messages,
13579
- prompts.getRuntimePrompts(),
13580
- compressionPriorities,
13581
- config.debug ? (text) => {
13582
- logger.debug(`[ACP Debug] Nudge injected:
13583
- ${text}`);
13584
- client.tui.showToast({
13585
- body: {
13586
- title: "ACP: Nudge Injected",
13587
- message: text.slice(0, 500),
13588
- variant: "info",
13589
- duration: 5e3
13590
- }
13591
- }).catch(() => {
13592
- });
13593
- } : void 0,
13594
- prePruneTokens,
13595
- candidateMessages
13596
- );
13597
- truncateLargeToolOutputs(
13598
- state,
13599
- config,
13600
- logger,
13601
- output.messages.filter((message) => !isSyntheticMessage(message))
13602
- );
13603
- enforceContextBudget(state, config, logger, output.messages);
13604
- injectMessageIds(state, config, output.messages, compressionPriorities);
13605
- hideFailedCompressCalls(output.messages);
13606
- stripStaleMetadata(output.messages);
13607
- dropEmptyMessages(output.messages);
13608
- const smartPlanEnabled = applyCompressOverrides(
13609
- config,
13610
- state.modelProviderID,
13611
- state.modelID
13612
- ).compress.smartPlanRequired;
13613
- if (state.sessionId && smartPlanEnabled === true) {
13614
- recordVisibleMessages(state.sessionId, output.messages);
13615
- }
13616
- const postTokens = getCurrentTokenUsage(state, output.messages);
13617
- if (postTokens !== void 0 && effectiveLimit) {
13618
- const budget = effectiveLimit.limit - (state.systemPromptTokens ?? 0) - OUTPUT_RESERVE_TOKENS;
13619
- if (postTokens > budget) {
13620
- logger.error(
13621
- "ACP hard guard: context exceeds model budget after in-flight reduction",
13622
- {
13623
- session: state.sessionId,
13624
- postTokens,
13625
- budget,
13626
- contextLimit: effectiveLimit.limit,
13627
- contextLimitSource: effectiveLimit.source,
13628
- hint: "request will likely be rejected; run /compact or start a new session"
13629
- }
13630
- );
13631
- }
13632
- }
13633
- logger.info("Chat transform complete", {
13634
- session: state.sessionId,
13635
- model: state.modelID,
13636
- messages: output.messages.length,
13637
- prePruneTokens,
13638
- postTokens,
13639
- contextLimit: effectiveLimit?.limit,
13640
- contextLimitSource: effectiveLimit?.source,
13641
- usagePct: postTokens !== void 0 && effectiveLimit ? `${(postTokens / effectiveLimit.limit * 100).toFixed(1)}%` : void 0,
13642
- nudged: state.nudges.shouldInjectThisTurn
13643
- });
13644
- if (state.sessionId) {
13645
- await logger.saveContext(state.sessionId, output.messages);
13646
- }
13647
13836
  };
13648
13837
  }
13649
13838
  function buildHelpText() {
@@ -13670,8 +13859,11 @@ function createCommandExecuteHandler(client, registry4, logger, config, workingD
13670
13859
  path: { id: input.sessionID }
13671
13860
  });
13672
13861
  const messages = filterMessages(messagesResponse.data || messagesResponse);
13673
- const state = await registry4.getOrCreate(client, input.sessionID, messages, config);
13674
- syncCompressPermissionState(state, config, hostPermissions, messages);
13862
+ const state = await registry4.withSessionGuard(input.sessionID, async () => {
13863
+ const s = await registry4.getOrCreate(client, input.sessionID, messages, config);
13864
+ syncCompressPermissionState(s, config, hostPermissions, messages);
13865
+ return s;
13866
+ });
13675
13867
  const commandCtx = {
13676
13868
  client,
13677
13869
  state,
@@ -13775,18 +13967,27 @@ function createEventHandler(registry4, logger) {
13775
13967
  callId: part.callID,
13776
13968
  durationMs
13777
13969
  });
13970
+ const settles = [];
13778
13971
  for (const state of registry4.all()) {
13779
- const updates = applyPendingCompressionDurations(state);
13780
- if (updates > 0) {
13781
- await saveSessionState(state, logger);
13782
- logger.info("Attached compression time to blocks", {
13783
- messageID: part.messageID,
13784
- callID: part.callID,
13785
- blocks: updates,
13786
- durationMs
13787
- });
13972
+ if (!state.sessionId) {
13973
+ continue;
13788
13974
  }
13975
+ settles.push(
13976
+ registry4.withSessionGuard(state.sessionId, async () => {
13977
+ const updates = applyPendingCompressionDurations(state);
13978
+ if (updates > 0) {
13979
+ await saveSessionState(state, logger);
13980
+ logger.info("Attached compression time to blocks", {
13981
+ messageID: part.messageID,
13982
+ callID: part.callID,
13983
+ blocks: updates,
13984
+ durationMs
13985
+ });
13986
+ }
13987
+ })
13988
+ );
13789
13989
  }
13990
+ await Promise.allSettled(settles);
13790
13991
  return;
13791
13992
  }
13792
13993
  if (part.state.status === "running") {
@@ -14039,7 +14240,7 @@ var server = (async (ctx) => {
14039
14240
  }
14040
14241
  const logger = new Logger(config.debug, config.debug ? "debug" : config.logLevel);
14041
14242
  logger.info("ACP plugin initialized", {
14042
- version: true ? "1.18.4" : "dev",
14243
+ version: true ? "1.18.5-pr.413.228" : "dev",
14043
14244
  workspace: ctx.directory,
14044
14245
  logLevel: logger.level,
14045
14246
  debug: config.debug,