opencode-acp 1.13.6 → 1.13.7

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/README.md CHANGED
@@ -475,6 +475,22 @@ For the complete list with root cause analysis, see the [bug tracker](https://gi
475
475
 
476
476
  ## Changelog
477
477
 
478
+ ### v1.13.7 — Per-Session State + Inactive Block Fixes + Preserve First User Message (PRs #184, #193, #196)
479
+
480
+ **Problem**: Three bugs since v1.13.6. (1) **Subagent state isolation failure** (PR #184): ACP stored a single global `SessionState` per plugin instance. When subagent (child) sessions ran interleaved with the parent, the child's state overwrote the parent's `modelContextLimit` — losing the 1M context window and falling back to the 6K adaptive floor, causing over-aggressive nudging in the parent. The `compressionTiming` tracker was also shared across sessions, risking cross-session collision. (2) **Inactive block opacity** (PR #193): `decompress` rejected inactive blocks with "not active — may have already been decompressed", and `acp_status` hid consumed/inactive blocks entirely. Users could not decompress blocks that were GC'd or consumed by secondary compression, and could not see them in status output. (3) **Zero-user session freeze** (PR #196): When compression pruned all user-role messages (all fell inside compressed ranges), zhipuai-lb rejected the request with HTTP 400 code 1214 (`"messages 参数非法"`, `isRetryable: false`), freezing the session. The v1.13.2 `preserve-last-user` fix searched the messages array for a pruned user to restore — but after OpenCode compaction removes pruned messages from the array, the search finds nothing and zero-user requests slip through.
481
+
482
+ **Fix**: (1) PR #184 — Introduced `SessionStateRegistry` in `lib/state/state.ts`: a `Map<sessionID, SessionState>` with a shared `compressionTiming` tracker and soft-cap eviction (32 sessions). Each session now resolves its own state via `registry.getOrCreate(sessionID)`, isolating subagent state from the parent. The system-prompt hook gracefully handles missing state (returns early). Also reverted the over-aggressive `baseline = 0` change back to `baseline = currentTokens` (system prompt is not growth). 851 tests pass. (2) PR #193 — Removed the "not active" rejection from `lib/compress/decompress.ts` and the `/acp decompress` slash command; standalone inactive blocks (user-decompressed, GC'd, orphaned) now decompress successfully. `acp_status` compressed scope now lists ALL blocks (active + inactive) with an `[inactive]` marker and "N active, M inactive/consumed" summary line. Fixed `toFile` fallback to use `targets[0].blocks[0].summary` instead of the undefined `activeBlocks[0].summary`. 859 tests pass after 3 rounds of dual-agent review. (3) PR #196 — Replaced `preserve-last-user` with `preserve-first-user` in `lib/messages/prune.ts`: the first user message (session's original task, always present in the array) is unconditionally force-preserved (`survive[firstUserIdx] = true`) regardless of prune state. Simpler and more reliable — does not depend on pruned messages remaining in the array after OpenCode compaction. Trade-off: may produce two adjacent user messages (first user + a later surviving user), which all major providers accept. Dual-agent reviewed (Oracle + General, both APPROVE). 846 tests pass.
483
+
484
+ Files: `lib/state/state.ts`, `lib/hooks.ts`, `lib/compress/types.ts`, `lib/compress/decompress.ts`, `lib/compress/status.ts`, `lib/commands/decompress.ts`, `lib/messages/prune.ts`, `lib/messages/inject/inject.ts`. Tests: `tests/registry.test.ts`, `tests/inactive-block-decompress.test.ts`, `tests/acp-status.test.ts`, `tests/decompress-logic.test.ts`, `tests/prune.test.ts`, `tests/e2e-message-transform.test.ts`.
485
+
486
+ ### v1.13.7-dev.1 — Dev Prerelease Sync (master @ v1.13.6)
487
+
488
+ **Purpose**: Sync the `dev` npm tag (stuck at `1.12.10-dev.1`) with current master. The `dev` tag had fallen far behind `latest` (1.13.6), making it impossible for early adopters to test the latest fixes via `opencode-acp@dev`.
489
+
490
+ **Content**: Identical to v1.13.6 stable (master HEAD `5d67b84`). No new code changes. This is a dev-tag-only release to bring `opencode-acp@dev` up to parity with `opencode-acp@latest`.
491
+
492
+ Files: `package.json`, `README.md`, `README.zh-CN.md`. 846 tests pass (no source changes).
493
+
478
494
  ### v1.13.6 — Force-Protect Compress Tool Regardless of User Config (PR #188)
479
495
 
480
496
  **Problem**: `compress.protectedTools` uses a replace merge policy (PR #177): a user setting `protectedTools: ["skill"]` or `protectedTools: []` silently removed `"compress"` from the protected list. This made compress summaries — the sole record of compressed conversation — vulnerable to being pruned by subsequent sequential compressions, causing irreversible data loss.
package/README.zh-CN.md CHANGED
@@ -443,6 +443,22 @@ ACP 在首次启动时自动将配置从 `dcp.jsonc` 迁移到 `acp.jsonc`,将
443
443
 
444
444
  ## 更新日志
445
445
 
446
+ ### v1.13.7 — 每会话状态隔离 + 失活块修复 + 保留首条用户消息(PR #184、#193、#196)
447
+
448
+ **问题**:v1.13.6 之后的三个 bug。(1)**子代理状态隔离失败**(PR #184):ACP 为每个插件实例存储单个全局 `SessionState`。当子代理(child)会话与父会话交替运行时,子会话的状态覆盖了父会话的 `modelContextLimit` —— 丢失 1M 上下文窗口,回退到 6K 自适应下限,导致父会话中过度触发压缩提醒。`compressionTiming` 追踪器也跨会话共享,存在跨会话碰撞风险。(2)**失活块不可见**(PR #193):`decompress` 拒绝失活块("not active — may have already been decompressed"),`acp_status` 完全隐藏被消费/失活的块。用户无法解压被 GC 回收或被二次压缩消费的块,也无法在状态输出中看到它们。(3)**零用户消息会话冻结**(PR #196):当压缩剪枝了所有 user 角色消息(全部落在压缩范围内)时,zhipuai-lb 拒绝请求(HTTP 400,code 1214,`"messages 参数非法"`,`isRetryable: false`),冻结会话。v1.13.2 的 `preserve-last-user` 修复在消息数组中搜索被剪枝的用户消息来恢复 —— 但 OpenCode 压缩移除被剪枝的消息后,搜索找不到任何内容,零用户请求仍然漏过。
449
+
450
+ **修复**:(1)PR #184 —— 在 `lib/state/state.ts` 引入 `SessionStateRegistry`:一个 `Map<sessionID, SessionState>`,带共享 `compressionTiming` 追踪器和软上限驱逐(32 个会话)。每个会话通过 `registry.getOrCreate(sessionID)` 解析自己的状态,将子代理状态与父会话隔离。系统提示钩子优雅处理缺失状态(提前返回)。同时将过于激进的 `baseline = 0` 改回 `baseline = currentTokens`(系统提示不算增长)。851 项测试通过。(2)PR #193 —— 移除 `lib/compress/decompress.ts` 和 `/acp decompress` 斜杠命令中的 "not active" 拒绝;独立的失活块(用户解压、GC 回收、孤立)现在可以成功解压。`acp_status` 压缩作用域现在列出所有块(活跃 + 失活),带 `[inactive]` 标记和"N active, M inactive/consumed"汇总行。修复 `toFile` 回退使用 `targets[0].blocks[0].summary` 而非未定义的 `activeBlocks[0].summary`。经 3 轮双 Agent 审查后 859 项测试通过。(3)PR #196 —— 在 `lib/messages/prune.ts` 用 `preserve-first-user` 替换 `preserve-last-user`:首条用户消息(会话的原始任务,始终存在于数组中)被无条件强制保留(`survive[firstUserIdx] = true`),无论剪枝状态如何。更简单、更可靠 —— 不依赖于被剪枝消息在 OpenCode 压缩后仍留在数组中。权衡:可能产生两个相邻的用户消息(首条用户 + 后续存活的用户),所有主流 provider 均可接受。经双 Agent 审查(Oracle + General,均 APPROVE)。846 项测试通过。
451
+
452
+ 文件:`lib/state/state.ts`、`lib/hooks.ts`、`lib/compress/types.ts`、`lib/compress/decompress.ts`、`lib/compress/status.ts`、`lib/commands/decompress.ts`、`lib/messages/prune.ts`、`lib/messages/inject/inject.ts`。测试:`tests/registry.test.ts`、`tests/inactive-block-decompress.test.ts`、`tests/acp-status.test.ts`、`tests/decompress-logic.test.ts`、`tests/prune.test.ts`、`tests/e2e-message-transform.test.ts`。
453
+
454
+ ### v1.13.7-dev.1 — Dev 预发布同步(master @ v1.13.6)
455
+
456
+ **目的**:将 npm `dev` 标签(卡在 `1.12.10-dev.1`)同步到当前 master。`dev` 标签已远远落后于 `latest`(1.13.6),导致早期采用者无法通过 `opencode-acp@dev` 测试最新修复。
457
+
458
+ **内容**:与 v1.13.6 稳定版完全相同(master HEAD `5d67b84`)。无新代码变更。仅为 dev 标签发布,使 `opencode-acp@dev` 与 `opencode-acp@latest` 保持一致。
459
+
460
+ 文件:`package.json`、`README.md`、`README.zh-CN.md`。846 项测试通过(无源码变更)。
461
+
446
462
  ### v1.13.6 — 强制保护 compress 工具,无视用户配置(PR #188)
447
463
 
448
464
  **问题**:`compress.protectedTools` 使用替换式合并策略(PR #177):用户设置 `protectedTools: ["skill"]` 或 `protectedTools: []` 会静默地从保护列表中移除 `"compress"`。这使得 compress 摘要 — 压缩对话的唯一记录 — 容易被后续的顺序压缩裁剪,导致不可恢复的数据丢失。
package/dist/index.js CHANGED
@@ -1948,6 +1948,23 @@ Using previous/default values`
1948
1948
  return config;
1949
1949
  }
1950
1950
 
1951
+ // lib/compress/types.ts
1952
+ function resolveToolContext(factoryCtx, sessionID) {
1953
+ const state = factoryCtx.registry.get(sessionID);
1954
+ if (!state) {
1955
+ throw new Error(
1956
+ `ACP: session ${sessionID} has no initialized state. messages.transform must run before a compress tool call.`
1957
+ );
1958
+ }
1959
+ return {
1960
+ client: factoryCtx.client,
1961
+ state,
1962
+ logger: factoryCtx.logger,
1963
+ config: factoryCtx.config,
1964
+ prompts: factoryCtx.prompts
1965
+ };
1966
+ }
1967
+
1951
1968
  // lib/compress/message.ts
1952
1969
  import { tool as tool2 } from "@opencode-ai/plugin";
1953
1970
 
@@ -2648,8 +2665,8 @@ function buildSearchPreview(text, firstTerm) {
2648
2665
  }
2649
2666
  return text.substring(0, 200) + (text.length > 200 ? "..." : "");
2650
2667
  }
2651
- function createSearchContextTool(ctx) {
2652
- ctx.prompts.reload();
2668
+ function createSearchContextTool(factoryCtx) {
2669
+ factoryCtx.prompts.reload();
2653
2670
  return tool({
2654
2671
  description: SEARCH_CONTEXT_TOOL_DESCRIPTION,
2655
2672
  args: {
@@ -2657,7 +2674,8 @@ function createSearchContextTool(ctx) {
2657
2674
  limit: tool.schema.number().optional().describe("Maximum results to return (default: 10)"),
2658
2675
  deep: tool.schema.boolean().optional().describe("If true, also search visible (uncompressed) messages. Slower but more thorough (default: false)")
2659
2676
  },
2660
- async execute(args) {
2677
+ async execute(args, toolCtx) {
2678
+ const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID);
2661
2679
  const query = (args.query || "").toLowerCase().trim();
2662
2680
  const limit = args.limit ?? 10;
2663
2681
  if (!query) {
@@ -3976,12 +3994,6 @@ async function loadAllSessionStats(logger) {
3976
3994
  function buildCompressionTimingKey(messageId, callId) {
3977
3995
  return `${messageId}:${callId}`;
3978
3996
  }
3979
- function consumeCompressionStart(state, messageId, callId) {
3980
- const key = buildCompressionTimingKey(messageId, callId);
3981
- const start = state.compressionTiming.startsByCallId.get(key);
3982
- state.compressionTiming.startsByCallId.delete(key);
3983
- return start;
3984
- }
3985
3997
  function resolveCompressionDuration(startedAt, eventTime, partTime) {
3986
3998
  const runningAt = typeof partTime?.start === "number" && Number.isFinite(partTime.start) ? partTime.start : eventTime;
3987
3999
  const pendingToRunningMs = typeof startedAt === "number" && typeof runningAt === "number" ? Math.max(0, runningAt - startedAt) : void 0;
@@ -4368,28 +4380,7 @@ function rebuildCompressionState(state, messages, config, logger) {
4368
4380
  }
4369
4381
 
4370
4382
  // lib/state/state.ts
4371
- var checkSession = async (client, state, logger, messages, manualModeDefault, config) => {
4372
- const lastUserMessage = getLastUserMessage(messages);
4373
- if (!lastUserMessage) {
4374
- return;
4375
- }
4376
- const lastSessionId = lastUserMessage.info.sessionID;
4377
- if (state.sessionId === null || state.sessionId !== lastSessionId) {
4378
- logger.info(`Session changed: ${state.sessionId} -> ${lastSessionId}`);
4379
- try {
4380
- await ensureSessionInitialized(
4381
- client,
4382
- state,
4383
- lastSessionId,
4384
- logger,
4385
- messages,
4386
- manualModeDefault,
4387
- config
4388
- );
4389
- } catch (err) {
4390
- logger.error("Failed to initialize session state", { error: err.message });
4391
- }
4392
- }
4383
+ async function updatePerTurnState(state, logger, messages) {
4393
4384
  const lastCompactionTimestamp = findLastCompactionTimestamp(messages);
4394
4385
  if (lastCompactionTimestamp > state.lastCompaction) {
4395
4386
  state.lastCompaction = lastCompactionTimestamp;
@@ -4404,6 +4395,66 @@ var checkSession = async (client, state, logger, messages, manualModeDefault, co
4404
4395
  });
4405
4396
  }
4406
4397
  state.currentTurn = countTurns(state, messages);
4398
+ }
4399
+ var REGISTRY_SOFT_CAP = 32;
4400
+ var SessionStateRegistry = class {
4401
+ constructor(logger) {
4402
+ this.logger = logger;
4403
+ }
4404
+ logger;
4405
+ states = /* @__PURE__ */ new Map();
4406
+ compressionTiming = {
4407
+ startsByCallId: /* @__PURE__ */ new Map(),
4408
+ pendingByCallId: /* @__PURE__ */ new Map()
4409
+ };
4410
+ get(sessionId) {
4411
+ return this.states.get(sessionId);
4412
+ }
4413
+ all() {
4414
+ return Array.from(this.states.values());
4415
+ }
4416
+ get size() {
4417
+ return this.states.size;
4418
+ }
4419
+ // Idempotent: ensureSessionInitialized returns immediately once
4420
+ // state.sessionId === sessionId (assigned synchronously before any await),
4421
+ // so repeat calls for the same session never re-reset.
4422
+ async getOrCreate(client, sessionId, messages, manualModeDefault, config) {
4423
+ let state = this.states.get(sessionId);
4424
+ if (!state) {
4425
+ state = createSessionState();
4426
+ state.compressionTiming = this.compressionTiming;
4427
+ this.states.set(sessionId, state);
4428
+ this.enforceSoftCap();
4429
+ }
4430
+ try {
4431
+ await ensureSessionInitialized(
4432
+ client,
4433
+ state,
4434
+ sessionId,
4435
+ this.logger,
4436
+ messages,
4437
+ manualModeDefault,
4438
+ config
4439
+ );
4440
+ } catch (err) {
4441
+ this.logger.error("Failed to initialize session state", {
4442
+ error: err.message
4443
+ });
4444
+ }
4445
+ return state;
4446
+ }
4447
+ enforceSoftCap() {
4448
+ if (this.states.size <= REGISTRY_SOFT_CAP) return;
4449
+ const oldest = this.states.keys().next().value;
4450
+ if (oldest !== void 0) {
4451
+ this.states.delete(oldest);
4452
+ this.logger.info("SessionStateRegistry evicted session (soft cap)", {
4453
+ sessionId: oldest,
4454
+ remaining: this.states.size
4455
+ });
4456
+ }
4457
+ }
4407
4458
  };
4408
4459
  function createSessionState() {
4409
4460
  return {
@@ -6407,13 +6458,14 @@ function buildSchema(maxSummaryLengthHard) {
6407
6458
  acknowledgeRisk: tool2.schema.boolean().optional()
6408
6459
  };
6409
6460
  }
6410
- function createCompressMessageTool(ctx) {
6411
- ctx.prompts.reload();
6412
- const runtimePrompts = ctx.prompts.getRuntimePrompts();
6461
+ function createCompressMessageTool(factoryCtx) {
6462
+ factoryCtx.prompts.reload();
6463
+ const runtimePrompts = factoryCtx.prompts.getRuntimePrompts();
6413
6464
  return tool2({
6414
6465
  description: runtimePrompts.compressMessage + MESSAGE_FORMAT_EXTENSION,
6415
- args: buildSchema(ctx.config.compress.maxSummaryLengthHard),
6466
+ args: buildSchema(factoryCtx.config.compress.maxSummaryLengthHard),
6416
6467
  async execute(args, toolCtx) {
6468
+ const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID);
6417
6469
  const input = args;
6418
6470
  validateArgs(input);
6419
6471
  const maxLen = args.summaryMaxChars ?? ctx.config.compress.maxSummaryLengthHard;
@@ -6620,13 +6672,14 @@ function buildSchema2(maxSummaryLengthHard) {
6620
6672
  acknowledgeRisk: tool3.schema.boolean().optional()
6621
6673
  };
6622
6674
  }
6623
- function createCompressRangeTool(ctx) {
6624
- ctx.prompts.reload();
6625
- const runtimePrompts = ctx.prompts.getRuntimePrompts();
6675
+ function createCompressRangeTool(factoryCtx) {
6676
+ factoryCtx.prompts.reload();
6677
+ const runtimePrompts = factoryCtx.prompts.getRuntimePrompts();
6626
6678
  return tool3({
6627
6679
  description: runtimePrompts.compressRange + RANGE_FORMAT_EXTENSION,
6628
- args: buildSchema2(ctx.config.compress.maxSummaryLengthHard),
6680
+ args: buildSchema2(factoryCtx.config.compress.maxSummaryLengthHard),
6629
6681
  async execute(args, toolCtx) {
6682
+ const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID);
6630
6683
  const input = args;
6631
6684
  validateArgs2(input);
6632
6685
  const maxLen = args.summaryMaxChars ?? ctx.config.compress.maxSummaryLengthHard;
@@ -6922,16 +6975,9 @@ var filterCompressedRanges = (state, messages) => {
6922
6975
  }
6923
6976
  return false;
6924
6977
  });
6925
- const anyUserSurvives = messages.some(
6926
- (msg, i) => survive[i] && msg.info.role === "user"
6927
- );
6928
- if (!anyUserSurvives) {
6929
- for (let i = messages.length - 1; i >= 0; i--) {
6930
- if (messages[i].info.role === "user" && !survive[i]) {
6931
- survive[i] = true;
6932
- break;
6933
- }
6934
- }
6978
+ const firstUserIdx = messages.findIndex((msg) => msg.info.role === "user");
6979
+ if (firstUserIdx >= 0) {
6980
+ survive[firstUserIdx] = true;
6935
6981
  }
6936
6982
  const result = [];
6937
6983
  for (let i = 0; i < messages.length; i++) {
@@ -9080,11 +9126,12 @@ function extractMessageText2(m) {
9080
9126
  return `[${role}]
9081
9127
  ${content}`;
9082
9128
  }
9083
- function createDecompressTool(ctx) {
9129
+ function createDecompressTool(factoryCtx) {
9084
9130
  return tool4({
9085
9131
  description: TOOL_DESCRIPTION,
9086
9132
  args: buildSchema3(),
9087
9133
  async execute(args, toolCtx) {
9134
+ const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID);
9088
9135
  const { rawMessages } = await prepareDecompressSession(ctx, toolCtx);
9089
9136
  const contextUsageBefore = ctx.state.modelContextLimit ? Math.round(
9090
9137
  getCurrentTokenUsage(ctx.state, rawMessages) / ctx.state.modelContextLimit * 100
@@ -9437,8 +9484,8 @@ function renderCompressedDrilldown(blocks, sort, limit) {
9437
9484
  );
9438
9485
  return lines;
9439
9486
  }
9440
- function createAcpStatusTool(ctx) {
9441
- ctx.prompts.reload();
9487
+ function createAcpStatusTool(factoryCtx) {
9488
+ factoryCtx.prompts.reload();
9442
9489
  return tool5({
9443
9490
  description: ACP_STATUS_TOOL_DESCRIPTION,
9444
9491
  args: {
@@ -9453,6 +9500,7 @@ function createAcpStatusTool(ctx) {
9453
9500
  limit: tool5.schema.number().optional().describe("Max items to list (default 30)")
9454
9501
  },
9455
9502
  async execute(args, toolCtx) {
9503
+ const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID);
9456
9504
  const scope = args.scope === "compressed" || args.scope === "uncompressed" ? args.scope : void 0;
9457
9505
  const view = args.view === "messages" ? "messages" : "ranges";
9458
9506
  const toolFilter = typeof args.tool === "string" ? args.tool : void 0;
@@ -9514,13 +9562,14 @@ Call this tool to re-fetch a specific block's summary without decompressing the
9514
9562
 
9515
9563
  Args:
9516
9564
  - blockId: optional block number (e.g., 5). If omitted, lists all active blocks with brief info.`;
9517
- function createAcpContextRecapTool(ctx) {
9565
+ function createAcpContextRecapTool(factoryCtx) {
9518
9566
  return tool6({
9519
9567
  description: RECAP_TOOL_DESCRIPTION,
9520
9568
  args: {
9521
9569
  blockId: tool6.schema.number().optional().describe("Block number to retrieve (e.g., 5). If omitted, lists all active blocks.")
9522
9570
  },
9523
- async execute(args) {
9571
+ async execute(args, toolCtx) {
9572
+ const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID);
9524
9573
  const msgState = ctx.state.prune.messages;
9525
9574
  const activeIds = Array.from(msgState.activeBlockIds).sort((a, b) => a - b);
9526
9575
  if (activeIds.length === 0) {
@@ -9567,8 +9616,8 @@ Unlike compress (which creates summaries), prune directly strips tool call outpu
9567
9616
  Args:
9568
9617
  - toolType: tool name to prune (e.g., "todowrite", "bash", "edit")
9569
9618
  - keepLatest: how many recent calls to keep visible (default 3)`;
9570
- function createPruneTool(ctx) {
9571
- ctx.prompts.reload();
9619
+ function createPruneTool(factoryCtx) {
9620
+ factoryCtx.prompts.reload();
9572
9621
  return tool7({
9573
9622
  description: PRUNE_TOOL_DESCRIPTION,
9574
9623
  args: {
@@ -9576,6 +9625,7 @@ function createPruneTool(ctx) {
9576
9625
  keepLatest: tool7.schema.number().optional().describe("How many recent calls to keep visible (default 3)")
9577
9626
  },
9578
9627
  async execute(args, toolCtx) {
9628
+ const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID);
9579
9629
  const keepLatest = args.keepLatest ?? 3;
9580
9630
  const { rawMessages } = await prepareSession(
9581
9631
  ctx,
@@ -11617,12 +11667,13 @@ function isInternalAgentRequest(messages) {
11617
11667
  const agent = lastUserMessage.info.agent;
11618
11668
  return typeof agent === "string" && INTERNAL_AGENT_NAMES.has(agent);
11619
11669
  }
11620
- function createSystemPromptHandler(state, logger, config, prompts) {
11670
+ function createSystemPromptHandler(registry3, logger, config, prompts) {
11621
11671
  return async (input, output) => {
11622
- if (input.model?.limit?.context) {
11672
+ const state = input.sessionID ? registry3.get(input.sessionID) : void 0;
11673
+ if (state && input.model?.limit?.context) {
11623
11674
  state.modelContextLimit = input.model.limit.context;
11624
11675
  }
11625
- if (state.isSubAgent && !config.experimental.allowSubAgents) {
11676
+ if (!state || state.isSubAgent && !config.experimental.allowSubAgents) {
11626
11677
  return;
11627
11678
  }
11628
11679
  const systemText = output.system.join("\n");
@@ -11630,7 +11681,7 @@ function createSystemPromptHandler(state, logger, config, prompts) {
11630
11681
  logger.info("Skipping DCP system prompt injection for internal agent");
11631
11682
  return;
11632
11683
  }
11633
- const effectivePermission = input.sessionID && state.sessionId === input.sessionID ? compressPermission(state, config) : config.compress.permission;
11684
+ const effectivePermission = compressPermission(state, config);
11634
11685
  if (effectivePermission === "deny") {
11635
11686
  return;
11636
11687
  }
@@ -11674,7 +11725,7 @@ function runMajorGC(state, config, logger, messages) {
11674
11725
  });
11675
11726
  }
11676
11727
  }
11677
- function createChatMessageTransformHandler(client, state, logger, config, prompts, hostPermissions) {
11728
+ function createChatMessageTransformHandler(client, registry3, logger, config, prompts, hostPermissions) {
11678
11729
  return async (input, output) => {
11679
11730
  const receivedMessages = Array.isArray(output.messages) ? output.messages.length : 0;
11680
11731
  const messages = filterMessagesInPlace(output.messages);
@@ -11688,7 +11739,20 @@ function createChatMessageTransformHandler(client, state, logger, config, prompt
11688
11739
  logger.debug("Skipping message transform for internal agent request");
11689
11740
  return;
11690
11741
  }
11691
- await checkSession(client, state, logger, output.messages, config.manualMode.enabled, config);
11742
+ const lastUserMessage = getLastUserMessage(messages);
11743
+ let state;
11744
+ if (!lastUserMessage) {
11745
+ state = createSessionState();
11746
+ } else {
11747
+ state = await registry3.getOrCreate(
11748
+ client,
11749
+ lastUserMessage.info.sessionID,
11750
+ messages,
11751
+ config.manualMode.enabled,
11752
+ config
11753
+ );
11754
+ await updatePerTurnState(state, logger, messages);
11755
+ }
11692
11756
  syncCompressPermissionState(state, config, hostPermissions, output.messages);
11693
11757
  if (state.isSubAgent && !config.experimental.allowSubAgents) {
11694
11758
  return;
@@ -11753,7 +11817,7 @@ ${text}`);
11753
11817
  }
11754
11818
  };
11755
11819
  }
11756
- function createCommandExecuteHandler(client, state, logger, config, workingDirectory, hostPermissions) {
11820
+ function createCommandExecuteHandler(client, registry3, logger, config, workingDirectory, hostPermissions) {
11757
11821
  return async (input, output) => {
11758
11822
  if (!config.commands.enabled) {
11759
11823
  return;
@@ -11763,11 +11827,9 @@ function createCommandExecuteHandler(client, state, logger, config, workingDirec
11763
11827
  path: { id: input.sessionID }
11764
11828
  });
11765
11829
  const messages = filterMessages(messagesResponse.data || messagesResponse);
11766
- await ensureSessionInitialized(
11830
+ const state = await registry3.getOrCreate(
11767
11831
  client,
11768
- state,
11769
11832
  input.sessionID,
11770
- logger,
11771
11833
  messages,
11772
11834
  config.manualMode.enabled,
11773
11835
  config
@@ -11851,7 +11913,7 @@ function createTextCompleteHandler() {
11851
11913
  output.text = stripHallucinationsFromString(output.text);
11852
11914
  };
11853
11915
  }
11854
- function createEventHandler(state, logger) {
11916
+ function createEventHandler(registry3, logger) {
11855
11917
  return async (input) => {
11856
11918
  const eventTime = typeof input.event?.time === "number" && Number.isFinite(input.event.time) ? input.event.time : typeof input.event?.properties?.time === "number" && Number.isFinite(input.event.properties.time) ? input.event.properties.time : void 0;
11857
11919
  if (input.event.type !== "message.part.updated") {
@@ -11861,16 +11923,17 @@ function createEventHandler(state, logger) {
11861
11923
  if (part?.type !== "tool" || part.tool !== "compress") {
11862
11924
  return;
11863
11925
  }
11926
+ const timing = registry3.compressionTiming;
11864
11927
  if (part.state.status === "pending") {
11865
11928
  if (typeof part.callID !== "string" || typeof part.messageID !== "string") {
11866
11929
  return;
11867
11930
  }
11868
11931
  const startedAt = eventTime ?? Date.now();
11869
11932
  const key = buildCompressionTimingKey(part.messageID, part.callID);
11870
- if (state.compressionTiming.startsByCallId.has(key)) {
11933
+ if (timing.startsByCallId.has(key)) {
11871
11934
  return;
11872
11935
  }
11873
- state.compressionTiming.startsByCallId.set(key, startedAt);
11936
+ timing.startsByCallId.set(key, startedAt);
11874
11937
  logger.debug("Recorded compression start", {
11875
11938
  messageID: part.messageID,
11876
11939
  callID: part.callID,
@@ -11883,34 +11946,36 @@ function createEventHandler(state, logger) {
11883
11946
  return;
11884
11947
  }
11885
11948
  const key = buildCompressionTimingKey(part.messageID, part.callID);
11886
- const start = consumeCompressionStart(state, part.messageID, part.callID);
11949
+ const start = timing.startsByCallId.get(key);
11950
+ timing.startsByCallId.delete(key);
11887
11951
  const durationMs = resolveCompressionDuration(start, eventTime, part.state.time);
11888
11952
  if (typeof durationMs !== "number") {
11889
11953
  return;
11890
11954
  }
11891
- state.compressionTiming.pendingByCallId.set(key, {
11955
+ timing.pendingByCallId.set(key, {
11892
11956
  messageId: part.messageID,
11893
11957
  callId: part.callID,
11894
11958
  durationMs
11895
11959
  });
11896
- const updates = applyPendingCompressionDurations(state);
11897
- if (updates === 0) {
11898
- return;
11960
+ for (const state of registry3.all()) {
11961
+ const updates = applyPendingCompressionDurations(state);
11962
+ if (updates > 0) {
11963
+ await saveSessionState(state, logger);
11964
+ logger.info("Attached compression time to blocks", {
11965
+ messageID: part.messageID,
11966
+ callID: part.callID,
11967
+ blocks: updates,
11968
+ durationMs
11969
+ });
11970
+ }
11899
11971
  }
11900
- await saveSessionState(state, logger);
11901
- logger.info("Attached compression time to blocks", {
11902
- messageID: part.messageID,
11903
- callID: part.callID,
11904
- blocks: updates,
11905
- durationMs
11906
- });
11907
11972
  return;
11908
11973
  }
11909
11974
  if (part.state.status === "running") {
11910
11975
  return;
11911
11976
  }
11912
11977
  if (typeof part.callID === "string" && typeof part.messageID === "string") {
11913
- state.compressionTiming.startsByCallId.delete(
11978
+ timing.startsByCallId.delete(
11914
11979
  buildCompressionTimingKey(part.messageID, part.callID)
11915
11980
  );
11916
11981
  }
@@ -12097,7 +12162,7 @@ var server = (async (ctx) => {
12097
12162
  return {};
12098
12163
  }
12099
12164
  const logger = new Logger(config.debug);
12100
- const state = createSessionState();
12165
+ const registry3 = new SessionStateRegistry(logger);
12101
12166
  const prompts = new PromptStore(logger, ctx.directory, config.experimental.customPrompts);
12102
12167
  const hostPermissions = {
12103
12168
  global: void 0,
@@ -12112,21 +12177,21 @@ var server = (async (ctx) => {
12112
12177
  startAutoUpdate(ctx, config.autoUpdate);
12113
12178
  const compressToolContext = {
12114
12179
  client: ctx.client,
12115
- state,
12180
+ registry: registry3,
12116
12181
  logger,
12117
12182
  config,
12118
12183
  prompts
12119
12184
  };
12120
12185
  return {
12121
12186
  "experimental.chat.system.transform": createSystemPromptHandler(
12122
- state,
12187
+ registry3,
12123
12188
  logger,
12124
12189
  config,
12125
12190
  prompts
12126
12191
  ),
12127
12192
  "experimental.chat.messages.transform": createChatMessageTransformHandler(
12128
12193
  ctx.client,
12129
- state,
12194
+ registry3,
12130
12195
  logger,
12131
12196
  config,
12132
12197
  prompts,
@@ -12135,13 +12200,13 @@ var server = (async (ctx) => {
12135
12200
  "experimental.text.complete": createTextCompleteHandler(),
12136
12201
  "command.execute.before": createCommandExecuteHandler(
12137
12202
  ctx.client,
12138
- state,
12203
+ registry3,
12139
12204
  logger,
12140
12205
  config,
12141
12206
  ctx.directory,
12142
12207
  hostPermissions
12143
12208
  ),
12144
- event: createEventHandler(state, logger),
12209
+ event: createEventHandler(registry3, logger),
12145
12210
  tool: {
12146
12211
  ...config.compress.permission !== "deny" && {
12147
12212
  compress: config.compress.mode === "message" ? createCompressMessageTool(compressToolContext) : createCompressRangeTool(compressToolContext),