@jingyi0605/codingns4dsh 0.1.2 → 0.1.7-rc.3

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 (171) hide show
  1. package/README.en.md +4 -4
  2. package/README.md +14 -5
  3. package/data/build/dist/client/account-bar.js +57 -3
  4. package/data/build/dist/client/bundle.js +36246 -268
  5. package/data/build/dist/client/cli-slots.js +60 -18
  6. package/data/build/dist/client/dsh-h5-bootstrap.d.ts +2 -0
  7. package/data/build/dist/client/dsh-h5-bootstrap.js +79 -13
  8. package/data/build/dist/client/features/file-management-panel.d.ts +5 -0
  9. package/data/build/dist/client/features/file-management-panel.js +48 -0
  10. package/data/build/dist/client/features/file-management.d.ts +4 -0
  11. package/data/build/dist/client/features/file-management.js +50 -0
  12. package/data/build/dist/client/features/index.d.ts +10 -1
  13. package/data/build/dist/client/features/index.js +12 -1
  14. package/data/build/dist/client/features/login-protection-panel.js +1 -1
  15. package/data/build/dist/client/features/login-protection-session.d.ts +2 -0
  16. package/data/build/dist/client/features/login-protection-session.js +18 -0
  17. package/data/build/dist/client/features/peer-host.d.ts +4 -0
  18. package/data/build/dist/client/features/peer-host.js +89 -0
  19. package/data/build/dist/client/features/reverse-proxy-panel.js +23 -7
  20. package/data/build/dist/client/features/reverse-proxy.d.ts +1 -2
  21. package/data/build/dist/client/features/reverse-proxy.js +75 -5
  22. package/data/build/dist/client/features/types.d.ts +9 -0
  23. package/data/build/dist/client/features/workspace-session-enhancement-panel.js +10 -0
  24. package/data/build/dist/client/features/workspace-session-enhancement.js +18 -1
  25. package/data/build/dist/client/file-management-dom.d.ts +12 -0
  26. package/data/build/dist/client/file-management-dom.js +527 -0
  27. package/data/build/dist/client/git-management.js +24 -5
  28. package/data/build/dist/client/host-navigation.d.ts +29 -0
  29. package/data/build/dist/client/host-navigation.js +29 -0
  30. package/data/build/dist/client/host-router.d.ts +3 -0
  31. package/data/build/dist/client/host-router.js +2 -0
  32. package/data/build/dist/client/index.d.ts +6 -1
  33. package/data/build/dist/client/index.js +30 -10
  34. package/data/build/dist/client/locale.js +36 -7
  35. package/data/build/dist/client/peer-host-connection-button.d.ts +13 -0
  36. package/data/build/dist/client/peer-host-connection-button.js +69 -0
  37. package/data/build/dist/client/peer-host-management-api.d.ts +40 -0
  38. package/data/build/dist/client/peer-host-management-api.js +33 -0
  39. package/data/build/dist/client/peer-host-management-panel.d.ts +13 -0
  40. package/data/build/dist/client/peer-host-management-panel.js +181 -0
  41. package/data/build/dist/client/peer-host-native-session-ui.d.ts +60 -0
  42. package/data/build/dist/client/peer-host-native-session-ui.js +372 -0
  43. package/data/build/dist/client/peer-host-scoped-client.d.ts +67 -0
  44. package/data/build/dist/client/peer-host-scoped-client.js +316 -0
  45. package/data/build/dist/client/peer-host-session-controller.d.ts +30 -0
  46. package/data/build/dist/client/peer-host-session-controller.js +95 -0
  47. package/data/build/dist/client/quick-phrase-slot.js +14 -0
  48. package/data/build/dist/client/remote-web-context.js +26 -4
  49. package/data/build/dist/client/session-changed-files-view.d.ts +15 -0
  50. package/data/build/dist/client/session-changed-files-view.js +337 -0
  51. package/data/build/dist/client/settings-bridge.d.ts +14 -4
  52. package/data/build/dist/client/settings-bridge.js +59 -12
  53. package/data/build/dist/client/settings-section.js +17 -4
  54. package/data/build/dist/client/subscription-slot.js +21 -8
  55. package/data/build/dist/client/workspace-session-rightbar-dom.d.ts +28 -0
  56. package/data/build/dist/client/workspace-session-rightbar-dom.js +291 -0
  57. package/data/build/dist/dsh-capabilities/client/config-forms-adapter.js +25 -33
  58. package/data/build/dist/dsh-capabilities/client/settings-scope-adapter.d.ts +7 -1
  59. package/data/build/dist/dsh-capabilities/client/settings-scope-adapter.js +34 -11
  60. package/data/build/dist/dsh-capabilities/matrix.js +9 -0
  61. package/data/build/dist/dsh-capabilities/routes.js +35 -0
  62. package/data/build/dist/dsh-capabilities/settings-store.d.ts +12 -0
  63. package/data/build/dist/dsh-capabilities/settings-store.js +34 -0
  64. package/data/build/dist/dsh-capabilities/types.d.ts +1 -1
  65. package/data/build/dist/features/host-router.d.ts +26 -0
  66. package/data/build/dist/features/host-router.js +110 -0
  67. package/data/build/dist/features/registry.js +3 -0
  68. package/data/build/dist/host/cli-adapters/codex-driver.d.ts +13 -1
  69. package/data/build/dist/host/cli-adapters/codex-driver.js +528 -72
  70. package/data/build/dist/host/cli-adapters/command-code-driver.d.ts +21 -2
  71. package/data/build/dist/host/cli-adapters/command-code-driver.js +408 -105
  72. package/data/build/dist/host/cli-adapters/driver.d.ts +2 -0
  73. package/data/build/dist/host/cli-adapters/dsh-message-projector.d.ts +2 -0
  74. package/data/build/dist/host/cli-adapters/dsh-message-projector.js +47 -28
  75. package/data/build/dist/host/cli-adapters/dsh-tool-history.js +37 -0
  76. package/data/build/dist/host/cli-adapters/feature.js +94 -10
  77. package/data/build/dist/host/cli-adapters/json-rpc-process.d.ts +6 -0
  78. package/data/build/dist/host/cli-adapters/json-rpc-process.js +14 -3
  79. package/data/build/dist/host/cli-adapters/kimi-driver.js +3 -3
  80. package/data/build/dist/host/cli-adapters/model-catalog.d.ts +6 -0
  81. package/data/build/dist/host/cli-adapters/model-catalog.js +8 -0
  82. package/data/build/dist/host/cli-adapters/opencode-driver.js +14 -2
  83. package/data/build/dist/host/cli-adapters/pi-driver.js +2 -2
  84. package/data/build/dist/host/cli-adapters/process-utils.d.ts +8 -0
  85. package/data/build/dist/host/cli-adapters/process-utils.js +48 -0
  86. package/data/build/dist/host/cli-adapters/registry.d.ts +6 -1
  87. package/data/build/dist/host/cli-adapters/registry.js +41 -7
  88. package/data/build/dist/host/cli-adapters/rpc-driver-utils.js +23 -4
  89. package/data/build/dist/host/cli-adapters/standard-stream-driver.d.ts +3 -0
  90. package/data/build/dist/host/cli-adapters/standard-stream-driver.js +36 -15
  91. package/data/build/dist/host/dsh-device-runtime.d.ts +2 -0
  92. package/data/build/dist/host/dsh-device-runtime.js +55 -29
  93. package/data/build/dist/host/dsh-runtime-version.d.ts +3 -2
  94. package/data/build/dist/host/dsh-runtime-version.js +71 -2
  95. package/data/build/dist/host/features/auth.js +10 -2
  96. package/data/build/dist/host/features/file-management.d.ts +5 -0
  97. package/data/build/dist/host/features/file-management.js +267 -0
  98. package/data/build/dist/host/features/git-management.js +24 -2
  99. package/data/build/dist/host/features/index.d.ts +2 -0
  100. package/data/build/dist/host/features/index.js +6 -0
  101. package/data/build/dist/host/features/peer-host.d.ts +22 -0
  102. package/data/build/dist/host/features/peer-host.js +322 -0
  103. package/data/build/dist/host/features/types.d.ts +4 -0
  104. package/data/build/dist/host/host-router.d.ts +3 -0
  105. package/data/build/dist/host/host-router.js +2 -0
  106. package/data/build/dist/host/index-injection.d.ts +17 -0
  107. package/data/build/dist/host/index-injection.js +24 -0
  108. package/data/build/dist/host/index.d.ts +4 -1
  109. package/data/build/dist/host/index.js +41 -7
  110. package/data/build/dist/host/lan-access-dsh.d.ts +34 -1
  111. package/data/build/dist/host/lan-access-dsh.js +145 -26
  112. package/data/build/dist/host/modules/peer-host/host-api-proxy-service.d.ts +52 -0
  113. package/data/build/dist/host/modules/peer-host/host-api-proxy-service.js +166 -0
  114. package/data/build/dist/host/modules/peer-host/host-ws-connector.d.ts +31 -0
  115. package/data/build/dist/host/modules/peer-host/host-ws-connector.js +108 -0
  116. package/data/build/dist/host/modules/peer-host/host-ws-proxy-service.d.ts +26 -0
  117. package/data/build/dist/host/modules/peer-host/host-ws-proxy-service.js +159 -0
  118. package/data/build/dist/host/modules/peer-host/peer-host-aggregate-service.d.ts +50 -0
  119. package/data/build/dist/host/modules/peer-host/peer-host-aggregate-service.js +97 -0
  120. package/data/build/dist/host/modules/peer-host/peer-host-diagnostics.d.ts +19 -0
  121. package/data/build/dist/host/modules/peer-host/peer-host-diagnostics.js +54 -0
  122. package/data/build/dist/host/modules/peer-host/peer-host-handshake.d.ts +44 -0
  123. package/data/build/dist/host/modules/peer-host/peer-host-handshake.js +133 -0
  124. package/data/build/dist/host/modules/peer-host/peer-host-relay.d.ts +65 -0
  125. package/data/build/dist/host/modules/peer-host/peer-host-relay.js +196 -0
  126. package/data/build/dist/host/modules/peer-host/peer-host-session.d.ts +42 -0
  127. package/data/build/dist/host/modules/peer-host/peer-host-session.js +152 -0
  128. package/data/build/dist/host/modules/peer-host/peer-host-store.d.ts +92 -0
  129. package/data/build/dist/host/modules/peer-host/peer-host-store.js +323 -0
  130. package/data/build/dist/host/modules/peer-host/peer-host-ws-gateway.d.ts +34 -0
  131. package/data/build/dist/host/modules/peer-host/peer-host-ws-gateway.js +156 -0
  132. package/data/build/dist/host/native-session-bridge.d.ts +18 -2
  133. package/data/build/dist/host/native-session-bridge.js +236 -8
  134. package/data/build/dist/host/remote-web-runtime.js +26 -1
  135. package/data/build/dist/host/rpc.js +8 -3
  136. package/data/build/dist/host/session-changes.d.ts +5 -0
  137. package/data/build/dist/host/session-changes.js +163 -0
  138. package/data/build/dist/host/settings.js +6 -0
  139. package/data/build/dist/index.d.ts +1 -1
  140. package/data/build/dist/index.js +1 -1
  141. package/data/build/dist/shared/contracts/cli-adapter.d.ts +23 -2
  142. package/data/build/dist/shared/contracts/config.d.ts +15 -0
  143. package/data/build/dist/shared/contracts/config.js +10 -0
  144. package/data/build/dist/shared/contracts/dsh-device.d.ts +2 -0
  145. package/data/build/dist/shared/contracts/errors.d.ts +14 -0
  146. package/data/build/dist/shared/contracts/errors.js +15 -0
  147. package/data/build/dist/shared/contracts/feature.d.ts +2 -0
  148. package/data/build/dist/shared/contracts/file-management.d.ts +6 -0
  149. package/data/build/dist/shared/contracts/file-management.js +3 -0
  150. package/data/build/dist/shared/contracts/peer-host.d.ts +116 -15
  151. package/data/build/dist/shared/contracts/peer-host.js +19 -0
  152. package/data/build/dist/shared/contracts/version.d.ts +1 -1
  153. package/data/build/dist/shared/contracts/version.js +1 -1
  154. package/data/build/dist/shared/index.d.ts +4 -3
  155. package/data/build/dist/shared/index.js +2 -2
  156. package/data/build/dist/transport/debug.d.ts +5 -0
  157. package/data/build/dist/transport/debug.js +23 -1
  158. package/data/build/dist/transport/dsh-transport.d.ts +2 -0
  159. package/data/build/dist/transport/dsh-transport.js +11 -0
  160. package/data/build/dist/transport/webrtc-client.d.ts +2 -0
  161. package/data/build/dist/transport/webrtc-client.js +2 -1
  162. package/data/build/dist/transport/webrtc-host.d.ts +2 -0
  163. package/data/build/dist/transport/webrtc-host.js +6 -3
  164. package/data/build/dist/typert.host.d.ts +1 -1
  165. package/data/build/dist/typert.host.js +2 -1
  166. package/dsh.bundle.patch +10 -0
  167. package/package.json +19 -11
  168. package/profile/README.md +2 -2
  169. package/profile/package.json +2 -2
  170. package/profile/version.json +1 -1
  171. package/version.json +1 -1
@@ -1,15 +1,22 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { homedir } from 'node:os';
3
- import { basename, join } from 'node:path';
4
- import { JsonRpcProcess } from './json-rpc-process.js';
4
+ import { basename, join, resolve } from 'node:path';
5
+ import { JsonRpcProcess, JsonRpcRequestError } from './json-rpc-process.js';
5
6
  import { detectBinary, emptyCatalog, isRecord, textValue, usageChunk } from './rpc-driver-utils.js';
6
7
  import { CODEX_CATALOG, isProviderDefaultModel } from './model-catalog.js';
7
8
  import { probeStoredSession, readFirstJsonRecord } from './session-probe.js';
8
9
  import { firstToolText, normalizeToolStatus, serializeToolValue } from './tool-observation.js';
9
10
  import { isQuestionEvent, questionAnswersRecord, readAgentQuestions } from './interaction-events.js';
11
+ // 只有 Provider 已明确报告超过窗口时才主动压缩;接近上限仍交给 Codex
12
+ // 自身的 auto-compact,避免对正常的高占用回合重复发起压缩。
13
+ const CODEX_COMPACTION_THRESHOLD = 1;
14
+ const CODEX_COMPACTION_TIMEOUT_MS = 60_000;
10
15
  /** Codex app-server 的 JSON-RPC 驱动,Host 只暴露统一文本流,不暴露线程和 token。 */
11
16
  export class CodexAppServerDriver {
12
17
  descriptor = { id: 'codex', name: 'Codex', protocol: 'json-rpc', capabilities: ['models', 'stream', 'resume', 'interrupt', 'tool-events', 'reasoning', 'usage', 'permission', 'questions', 'steer'] };
18
+ // Codex 一个 Provider turn 可能同时包含多个工具调用。只有 assistant item
19
+ // 切换后才分段,不能在每个工具完成后注入下一 step。
13
20
  supportsSegmentedTurns = true;
14
21
  binaries;
15
22
  runSpawnSync;
@@ -72,11 +79,12 @@ export class CodexAppServerDriver {
72
79
  const command = this.cachedBinary ?? (await this.detect()).command;
73
80
  if (command === null)
74
81
  throw new Error('Codex 未安装');
75
- if (input.splitToolSteps) {
82
+ if (input.splitToolSteps && this.supportsSegmentedTurns) {
76
83
  yield* this.executeSegmentedTurn(input, command);
77
84
  return;
78
85
  }
79
86
  const session = await this.getSession(input, command);
87
+ prepareCodexSession(session, input);
80
88
  const rpc = session.rpc;
81
89
  try {
82
90
  if (session.threadId === '') {
@@ -86,13 +94,28 @@ export class CodexAppServerDriver {
86
94
  session.threadId = readId(thread) ?? input.providerSessionId ?? input.sessionId;
87
95
  session.providerSessionId = session.threadId;
88
96
  }
97
+ await this.ensureContextCapacity(session, input);
98
+ yield* drainCompactionEvents(session);
89
99
  yield { type: 'session-binding', providerSessionId: session.providerSessionId };
90
100
  const eventQueue = createCodexTurnEventQueue();
91
101
  let activeTurnId = null;
92
102
  let turnStartResolved = false;
93
103
  const notificationsBeforeTurnStart = [];
94
104
  let terminalReason = null;
105
+ let sawMeaningfulEvent = false;
95
106
  const acceptNotification = (message, allowUnidentifiedTool) => {
107
+ // Codex 自动压缩可能在独立的内部 turn 中运行。它不属于当前用户 turn,
108
+ // 但压缩 item 和 thread/compacted 必须继续进入公共投影层,否则压缩虽已发生,
109
+ // 原生会话不会显示任何 compaction 活动。
110
+ if (isCodexCompactionNotification(message, session.threadId)) {
111
+ const notificationTurnId = readTurnId(message);
112
+ // 显式 thread/compact/start 由 waitForCompaction 独占消费通知并生成
113
+ // start/summary/end,当前 turn 队列不能再次投影同一组事件。
114
+ if (!session.suppressCompactionNotifications
115
+ && (notificationTurnId === null || !session.suppressedCompactionTurnIds.has(notificationTurnId)))
116
+ eventQueue.push(message);
117
+ return;
118
+ }
96
119
  if (!isCodexNotificationForTurn(message, session.threadId, activeTurnId, allowUnidentifiedTool))
97
120
  return;
98
121
  const turnId = readTurnId(message);
@@ -129,20 +152,18 @@ export class CodexAppServerDriver {
129
152
  else
130
153
  input.signal?.addEventListener('abort', onAbort, { once: true });
131
154
  try {
132
- const response = await rpc.request('turn/start', {
133
- threadId: session.threadId,
134
- input: [{ type: 'text', text: input.prompt }],
135
- ...(input.effortId ? { effort: input.effortId } : {}),
136
- }, { signal: input.signal, killOnAbort: false });
155
+ const response = await this.startTurn(session, input);
156
+ // turn/start 可能因上下文超限内部触发 compact;压缩事件在重试成功后
157
+ // 仍需先交给公共投影器,不能留在驱动的暂存队列里。
158
+ yield* drainCompactionEvents(session);
137
159
  const responseTurnId = readTurnId(response);
138
160
  if (responseTurnId !== null) {
139
161
  activeTurnId = responseTurnId;
140
162
  session.turnId = responseTurnId;
141
163
  }
142
164
  turnStartResolved = true;
143
- // 响应前缓存的通知可能来自 thread/resume 的旧历史。没有 turnId 的工具
144
- // 事件无法证明属于本次回合,因此只能丢弃;有明确 turnId 的事件仍按
145
- // 原有规则处理。
165
+ // 响应前缓存的通知可能来自 thread/resume 的旧历史。没有 turnId 的事件
166
+ // 无法证明属于本次回合,因此只能丢弃;有明确 turnId 的事件仍按原有规则处理。
146
167
  for (const message of notificationsBeforeTurnStart.splice(0))
147
168
  acceptNotification(message, false);
148
169
  // 某些 app-server 会直接在 turn/start 响应中返回终态。父仓库把它
@@ -151,9 +172,13 @@ export class CodexAppServerDriver {
151
172
  if (responseTerminal !== null)
152
173
  onNotification(responseTerminal);
153
174
  for await (const message of eventQueue.iterable) {
154
- const chunk = codexMessageToChunk(message);
155
- if (chunk !== null)
156
- yield chunk;
175
+ const rawChunk = codexMessageToChunk(message);
176
+ if (rawChunk === null)
177
+ continue;
178
+ if (rawChunk.type !== 'finish')
179
+ sawMeaningfulEvent = true;
180
+ const chunk = stabilizeCodexEvent(session, rawChunk);
181
+ yield chunk;
157
182
  }
158
183
  if (input.signal?.aborted)
159
184
  await this.interrupt(input.sessionId);
@@ -168,18 +193,28 @@ export class CodexAppServerDriver {
168
193
  removeNotificationListener();
169
194
  eventQueue.close();
170
195
  }
171
- yield { type: 'finish', reason: input.signal?.aborted ? 'cancel' : terminalReason ?? 'stop' };
196
+ // 终态 chunk 之后调用方不会再拉动本迭代器,turnId 必须在产出 finish
197
+ // 前复位,否则下一轮 steer/interrupt 会指向已经结束的 turn。
198
+ session.turnId = null;
199
+ if (!input.signal?.aborted && terminalReason !== 'cancel' && !sawMeaningfulEvent) {
200
+ yield { type: 'text-delta', text: 'CODINGNS_PROVIDER_EMPTY_RESPONSE: Codex Provider 未返回任何有效事件。' };
201
+ yield { type: 'finish', reason: 'error' };
202
+ }
203
+ else {
204
+ yield { type: 'finish', reason: input.signal?.aborted ? 'cancel' : terminalReason ?? 'stop' };
205
+ }
172
206
  }
173
207
  finally { /* app-server 在会话结束前保持连接。 */ }
174
208
  }
175
209
  /**
176
- * 将一个 Codex turn 按工具完成点切成多个 DSH step。
210
+ * 将一个 Codex turn 按 assistant item 切成多个 DSH step。
177
211
  *
178
212
  * DSH 的 step 边界只能由 Agent Loop 提交;这里仅提前结束本次 llm/stream,
179
213
  * 保留同一个 Codex turn 的通知队列,下一次 llm/stream 再继续消费它。
180
214
  */
181
215
  async *executeSegmentedTurn(input, command) {
182
216
  const session = await this.getSession(input, command);
217
+ prepareCodexSession(session, input);
183
218
  const rpc = session.rpc;
184
219
  let active;
185
220
  try {
@@ -190,13 +225,31 @@ export class CodexAppServerDriver {
190
225
  session.threadId = readId(thread) ?? input.providerSessionId ?? input.sessionId;
191
226
  session.providerSessionId = session.threadId;
192
227
  }
193
- active = session.segmentedTurn ?? await this.startSegmentedTurn(session, input);
194
- const isNew = session.segmentedTurn === undefined;
228
+ // 只有 Host 显式声明续段时才复用挂起的 Provider 运行。新用户回合、注入
229
+ // 失败或取消后的下一次执行必须丢弃旧段:旧段要么已经跑完,要么其队列
230
+ // 已关闭,复用只会立刻产出 stop,把新消息变成空回合。
231
+ const suspended = input.resumeSegmentedTurn === true
232
+ && session.segmentedTurn !== undefined
233
+ && !session.segmentedTurn.done
234
+ ? session.segmentedTurn
235
+ : undefined;
236
+ if (suspended === undefined && session.segmentedTurn !== undefined) {
237
+ this.closeSegmentedTurn(session, session.segmentedTurn);
238
+ }
239
+ // 分段 step 仍属于同一个 Provider turn;恢复它时不能插入 compact turn。
240
+ const hasSuspendedTurn = suspended !== undefined;
241
+ if (!hasSuspendedTurn)
242
+ await this.ensureContextCapacity(session, input);
243
+ if (!hasSuspendedTurn)
244
+ yield* drainCompactionEvents(session);
245
+ active = suspended ?? await this.startSegmentedTurn(session, input);
246
+ yield* drainCompactionEvents(session);
247
+ const isNew = suspended === undefined;
195
248
  if (isNew)
196
249
  session.segmentedTurn = active;
197
250
  if (isNew)
198
251
  yield { type: 'session-binding', providerSessionId: session.providerSessionId };
199
- for await (const chunk of this.consumeSegment(active, input))
252
+ for await (const chunk of this.consumeSegment(session, active, input))
200
253
  yield chunk;
201
254
  if (active.done && session.segmentedTurn === active)
202
255
  session.segmentedTurn = undefined;
@@ -219,9 +272,21 @@ export class CodexAppServerDriver {
219
272
  removeNotificationListener: () => undefined,
220
273
  terminalReason: null,
221
274
  done: false,
275
+ pendingChunk: undefined,
276
+ currentAssistantMessageId: undefined,
277
+ sawCompletedTool: false,
222
278
  removeAbortListener: () => undefined,
223
279
  };
224
280
  const acceptNotification = (message, allowUnidentifiedTool) => {
281
+ if (isCodexCompactionNotification(message, session.threadId)) {
282
+ const notificationTurnId = readTurnId(message);
283
+ // 显式 thread/compact/start 由 waitForCompaction 独占消费通知并生成
284
+ // start/summary/end,当前分段 turn 不能再次投影同一组事件。
285
+ if (!session.suppressCompactionNotifications
286
+ && (notificationTurnId === null || !session.suppressedCompactionTurnIds.has(notificationTurnId)))
287
+ eventQueue.push(message);
288
+ return;
289
+ }
225
290
  if (!isCodexNotificationForTurn(message, session.threadId, activeTurnId, allowUnidentifiedTool))
226
291
  return;
227
292
  const turnId = readTurnId(message);
@@ -254,11 +319,7 @@ export class CodexAppServerDriver {
254
319
  else
255
320
  input.signal?.addEventListener('abort', onAbort, { once: true });
256
321
  try {
257
- const response = await session.rpc.request('turn/start', {
258
- threadId: session.threadId,
259
- input: [{ type: 'text', text: input.prompt }],
260
- ...(input.effortId ? { effort: input.effortId } : {}),
261
- }, { signal: input.signal, killOnAbort: false });
322
+ const response = await this.startTurn(session, input);
262
323
  const responseTurnId = readTurnId(response);
263
324
  if (responseTurnId !== null) {
264
325
  activeTurnId = responseTurnId;
@@ -277,28 +338,56 @@ export class CodexAppServerDriver {
277
338
  throw error;
278
339
  }
279
340
  }
280
- async *consumeSegment(active, input) {
341
+ async *consumeSegment(session, active, input) {
281
342
  while (true) {
282
- const next = await active.queue.next();
283
- if (next.done) {
343
+ let chunk;
344
+ if (active.pendingChunk !== undefined) {
345
+ chunk = active.pendingChunk;
346
+ active.pendingChunk = undefined;
347
+ }
348
+ else {
349
+ const next = await active.queue.next();
350
+ if (next.done) {
351
+ if (input.signal?.aborted)
352
+ throw new Error('请求已取消');
353
+ active.done = true;
354
+ session.turnId = null;
355
+ // finish 是调用方看到的最后一个 chunk,之后不会再拉动本迭代器。
356
+ // 悬挂段必须在产出终态前解除,否则它会被当成下一轮的可续段复用。
357
+ if (session.segmentedTurn === active)
358
+ session.segmentedTurn = undefined;
359
+ active.removeAbortListener();
360
+ active.removeNotificationListener();
361
+ yield { type: 'finish', reason: active.terminalReason ?? 'stop' };
362
+ return;
363
+ }
284
364
  if (input.signal?.aborted)
285
365
  throw new Error('请求已取消');
286
- active.done = true;
287
- active.removeAbortListener();
288
- active.removeNotificationListener();
289
- yield { type: 'finish', reason: active.terminalReason ?? 'stop' };
290
- return;
366
+ chunk = codexMessageToChunk(next.value);
291
367
  }
292
- if (input.signal?.aborted)
293
- throw new Error('请求已取消');
294
- const chunk = codexMessageToChunk(next.value);
295
368
  if (chunk === null)
296
369
  continue;
297
- yield chunk;
298
- if (chunk.type === 'tool-event' && (chunk.status === 'completed' || chunk.status === 'failed')) {
299
- yield { type: 'step-boundary' };
300
- return;
370
+ // 一个 assistant item 可能在多个工具调用之间切换。把新 item 的首个
371
+ // 正文留给下一次 llm/stream,当前流只返回边界,确保 DSH 先创建新 step。
372
+ if ((chunk.type === 'text-delta' || chunk.type === 'reasoning-delta')
373
+ && chunk.messageId !== undefined) {
374
+ const previousMessageId = active.currentAssistantMessageId;
375
+ if (previousMessageId !== undefined
376
+ && previousMessageId !== chunk.messageId
377
+ && active.sawCompletedTool) {
378
+ active.pendingChunk = chunk;
379
+ active.currentAssistantMessageId = chunk.messageId;
380
+ active.sawCompletedTool = false;
381
+ yield { type: 'step-boundary' };
382
+ return;
383
+ }
384
+ active.currentAssistantMessageId = chunk.messageId;
301
385
  }
386
+ const stabilizedChunk = stabilizeCodexEvent(session, chunk);
387
+ if (stabilizedChunk.type === 'tool-event' && (stabilizedChunk.status === 'completed' || stabilizedChunk.status === 'failed')) {
388
+ active.sawCompletedTool = true;
389
+ }
390
+ yield stabilizedChunk;
302
391
  }
303
392
  }
304
393
  closeSegmentedTurn(session, active) {
@@ -309,6 +398,144 @@ export class CodexAppServerDriver {
309
398
  if (session.segmentedTurn === active)
310
399
  session.segmentedTurn = undefined;
311
400
  }
401
+ /** 在下一轮开始前主动压缩,避免把已满的线程直接交给 turn/start。 */
402
+ async ensureContextCapacity(session, input) {
403
+ if (session.contextWindow === undefined || session.contextTokens === undefined)
404
+ return;
405
+ // 达到窗口上限时下一轮还会追加用户输入,必须在 turn/start 前压缩;
406
+ // 只用严格大于会把恰好满窗的会话直接送进超限错误路径。
407
+ if (session.contextTokens < session.contextWindow * CODEX_COMPACTION_THRESHOLD)
408
+ return;
409
+ await this.compactThread(session, input);
410
+ }
411
+ /** turn/start 遇到上下文超限时压缩一次并重试,兼容关闭自动压缩的 Codex 配置。 */
412
+ async startTurn(session, input) {
413
+ try {
414
+ return await session.rpc.request('turn/start', codexTurnStartParams(input, session.threadId), { signal: input.signal, killOnAbort: false });
415
+ }
416
+ catch (error) {
417
+ if (!isContextWindowError(error))
418
+ throw error;
419
+ await this.compactThread(session, input);
420
+ return session.rpc.request('turn/start', codexTurnStartParams(input, session.threadId), { signal: input.signal, killOnAbort: false });
421
+ }
422
+ }
423
+ /** thread/compact/start 立即返回,真正完成由 contextCompaction turn 的终态通知表示。 */
424
+ async compactThread(session, input) {
425
+ if (session.compacting !== undefined)
426
+ return session.compacting;
427
+ // 在创建异步等待器前同步设置,避免多个 RPC 监听器交错处理同一批通知时
428
+ // 另一个监听器重复投影显式压缩事件。
429
+ session.suppressCompactionNotifications = true;
430
+ const compacting = this.waitForCompaction(session, input);
431
+ session.compacting = compacting;
432
+ try {
433
+ await compacting;
434
+ session.contextTokens = 0;
435
+ }
436
+ finally {
437
+ if (session.compacting === compacting)
438
+ session.compacting = undefined;
439
+ session.suppressCompactionNotifications = false;
440
+ }
441
+ }
442
+ async waitForCompaction(session, input) {
443
+ const compactionId = `codex-compaction-${randomUUID()}`;
444
+ session.pendingCompactionEvents.push({ type: 'context-compaction', phase: 'start', compactionId });
445
+ let compactTurnId = null;
446
+ let sawCompaction = false;
447
+ let summary;
448
+ let shadowedTokenCount;
449
+ let shadowedItemCount;
450
+ let resolveDone;
451
+ let rejectDone;
452
+ const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; });
453
+ const onNotification = (message) => {
454
+ const params = isRecord(message.params) ? message.params : null;
455
+ if (readScopedId(params, ['threadId', 'thread_id'], 'thread') !== session.threadId)
456
+ return;
457
+ if (message.method === 'thread/compacted') {
458
+ const details = compactionDetails(params);
459
+ summary = details.summary ?? summary;
460
+ shadowedTokenCount = details.shadowedTokenCount ?? shadowedTokenCount;
461
+ shadowedItemCount = details.shadowedItemCount ?? shadowedItemCount;
462
+ resolveDone?.();
463
+ return;
464
+ }
465
+ if (message.method === 'item/started') {
466
+ const item = isRecord(params?.item) ? params.item : null;
467
+ if (isCodexCompactionItemType(item?.type)) {
468
+ sawCompaction = true;
469
+ compactTurnId = readTurnId(message);
470
+ if (compactTurnId !== null)
471
+ session.suppressedCompactionTurnIds.add(compactTurnId);
472
+ }
473
+ return;
474
+ }
475
+ if (message.method === 'item/completed') {
476
+ const item = isRecord(params?.item) ? params.item : null;
477
+ if (isCodexCompactionItemType(item?.type)) {
478
+ const itemTurnId = readTurnId(message);
479
+ if (itemTurnId !== null)
480
+ session.suppressedCompactionTurnIds.add(itemTurnId);
481
+ const details = compactionDetails(item);
482
+ summary = details.summary ?? summary;
483
+ shadowedTokenCount = details.shadowedTokenCount ?? shadowedTokenCount;
484
+ shadowedItemCount = details.shadowedItemCount ?? shadowedItemCount;
485
+ }
486
+ return;
487
+ }
488
+ if (message.method !== 'turn/completed')
489
+ return;
490
+ const turnId = readTurnId(message);
491
+ if (sawCompaction && compactTurnId !== null && turnId !== compactTurnId)
492
+ return;
493
+ if (!sawCompaction && compactTurnId === null)
494
+ return;
495
+ const turn = isRecord(params?.turn) ? params.turn : null;
496
+ if (turn?.status === 'failed' || turn?.status === 'interrupted' || turn?.status === 'cancelled') {
497
+ rejectDone?.(new Error('Codex 上下文压缩失败'));
498
+ }
499
+ else {
500
+ resolveDone?.();
501
+ }
502
+ };
503
+ const removeListener = session.rpc.addNotificationListener(onNotification);
504
+ const timer = setTimeout(() => rejectDone?.(new Error('Codex 上下文压缩超时')), CODEX_COMPACTION_TIMEOUT_MS);
505
+ timer.unref?.();
506
+ try {
507
+ await session.rpc.request('thread/compact/start', { threadId: session.threadId }, { signal: input.signal, killOnAbort: false });
508
+ await done;
509
+ if (summary !== undefined || shadowedTokenCount !== undefined || shadowedItemCount !== undefined) {
510
+ // Codex 的 app-server 通知通常不携带被压缩 token 数。DSH 的
511
+ // contextPressure 必须用该数扣除已被替换的 surface;这里使用压缩前
512
+ // 最后一次 Provider prompt 规模作为保守锚点,不能把缺失值写成 0。
513
+ const resolvedShadowedTokenCount = shadowedTokenCount ?? session.contextTokens;
514
+ session.pendingCompactionEvents.push({
515
+ type: 'context-compaction',
516
+ phase: 'summary',
517
+ compactionId,
518
+ ...(summary === undefined ? {} : { summary }),
519
+ ...(resolvedShadowedTokenCount === undefined ? {} : { shadowedTokenCount: resolvedShadowedTokenCount }),
520
+ ...(shadowedItemCount === undefined ? {} : { shadowedItemCount }),
521
+ });
522
+ }
523
+ session.pendingCompactionEvents.push({ type: 'context-compaction', phase: 'end', compactionId });
524
+ }
525
+ catch (error) {
526
+ session.pendingCompactionEvents.push({
527
+ type: 'context-compaction',
528
+ phase: 'end',
529
+ compactionId,
530
+ error: error instanceof Error ? error.message : String(error),
531
+ });
532
+ throw error;
533
+ }
534
+ finally {
535
+ clearTimeout(timer);
536
+ removeListener();
537
+ }
538
+ }
312
539
  /** 将新输入 steer 到当前 Codex turn。 */
313
540
  async steer(sessionId, prompt) {
314
541
  const session = this.sessions.get(sessionId);
@@ -329,17 +556,35 @@ export class CodexAppServerDriver {
329
556
  }
330
557
  catch { /* app-server 可能已经发出 turn/completed */ }
331
558
  }
559
+ /**
560
+ * 丢弃等待下一个 DSH step 的分段运行(取消、注入失败或切换适配器时)。
561
+ * 队列关闭后旧回合的通知不会再被投影进下一轮,续段标记同时解除。
562
+ */
563
+ discardSegmentedTurn(sessionId) {
564
+ const session = this.sessions.get(sessionId);
565
+ if (session?.segmentedTurn === undefined)
566
+ return;
567
+ this.closeSegmentedTurn(session, session.segmentedTurn);
568
+ }
332
569
  /** 回传原生权限请求;审批值只在 Host 进程中流转。 */
333
570
  respondPermission(sessionId, response) {
334
571
  const session = this.sessions.get(sessionId);
335
- const resolve = session?.pendingPermissions.get(response.requestId);
336
- if (resolve === undefined || session === undefined)
572
+ const pending = session?.pendingPermissions.get(response.requestId);
573
+ if (pending === undefined || session === undefined)
337
574
  throw new Error('Codex 权限请求不存在');
338
575
  session.pendingPermissions.delete(response.requestId);
339
- resolve({
340
- approved: response.approved,
341
- ...(response.reason?.trim() ? { reason: response.reason.trim().slice(0, 512) } : {}),
342
- });
576
+ if (pending.response === 'file-change') {
577
+ pending.resolve({ decision: response.approved ? 'accept' : 'decline' });
578
+ }
579
+ else if (pending.response === 'command') {
580
+ pending.resolve({ decision: response.approved ? 'accept' : 'decline' });
581
+ }
582
+ else {
583
+ pending.resolve({
584
+ approved: response.approved,
585
+ ...(response.reason?.trim() ? { reason: response.reason.trim().slice(0, 512) } : {}),
586
+ });
587
+ }
343
588
  }
344
589
  /** 回传 requestUserInput 的结构化回答。 */
345
590
  respondQuestion(sessionId, response) {
@@ -354,8 +599,8 @@ export class CodexAppServerDriver {
354
599
  for (const session of this.sessions.values()) {
355
600
  if (session.segmentedTurn !== undefined)
356
601
  this.closeSegmentedTurn(session, session.segmentedTurn);
357
- for (const resolve of session.pendingPermissions.values())
358
- resolve({ approved: false });
602
+ for (const pending of session.pendingPermissions.values())
603
+ pending.resolve({ approved: false });
359
604
  session.pendingPermissions.clear();
360
605
  for (const resolve of session.pendingQuestions.values())
361
606
  resolve({ answers: {} });
@@ -377,16 +622,23 @@ export class CodexAppServerDriver {
377
622
  const session = {
378
623
  rpc,
379
624
  cwd: input.cwd,
625
+ modelId: input.modelId,
626
+ contextWindow: undefined,
627
+ contextTokens: undefined,
380
628
  threadId: '',
381
629
  turnId: null,
382
630
  providerSessionId: input.providerSessionId ?? input.sessionId,
383
631
  pendingPermissions: new Map(),
384
632
  pendingQuestions: new Map(),
385
633
  segmentedTurn: undefined,
634
+ compacting: undefined,
635
+ suppressCompactionNotifications: false,
636
+ suppressedCompactionTurnIds: new Set(),
637
+ pendingCompactionEvents: [],
386
638
  };
387
639
  this.processes.add(rpc);
388
640
  this.sessions.set(input.sessionId, session);
389
- await rpc.request('initialize', { clientInfo: { name: 'codingns4dsh', version: '0.1.1' }, capabilities: {} }, { signal: input.signal, killOnAbort: false });
641
+ await rpc.request('initialize', { clientInfo: { name: 'codingns4dsh', version: '0.1.1' }, capabilities: { experimentalApi: true } }, { signal: input.signal, killOnAbort: false });
390
642
  rpc.notify('initialized', {});
391
643
  rpc.setServerRequestHandler((request) => {
392
644
  const requestId = readRequestId(request);
@@ -399,11 +651,60 @@ export class CodexAppServerDriver {
399
651
  if (isQuestionEvent(`${method} ${type}`)) {
400
652
  return new Promise((resolve) => session.pendingQuestions.set(requestId, resolve));
401
653
  }
402
- return new Promise((resolve) => session.pendingPermissions.set(requestId, resolve));
654
+ return new Promise((resolve) => session.pendingPermissions.set(requestId, {
655
+ resolve,
656
+ response: permissionResponseKind(method, params),
657
+ }));
403
658
  });
404
659
  return session;
405
660
  }
406
661
  }
662
+ function stabilizeCodexUsage(session, event) {
663
+ if (event.type !== 'usage')
664
+ return event;
665
+ if (event.contextTokens !== undefined)
666
+ session.contextTokens = event.contextTokens;
667
+ if (event.contextWindow === undefined)
668
+ return event;
669
+ if (session.contextWindow === undefined) {
670
+ session.contextWindow = event.contextWindow;
671
+ return event;
672
+ }
673
+ if (session.contextWindow === event.contextWindow)
674
+ return event;
675
+ // 同一 Codex thread/model 的 usage 通知可能带有全局默认窗口或迟到旧值。
676
+ // 它不能覆盖首个已确认窗口,否则 DSH 会把 256K 错显示成 1M,并跳过压缩。
677
+ const contextTokens = event.contextTokens ?? event.inputTokens;
678
+ return {
679
+ ...event,
680
+ contextWindow: session.contextWindow,
681
+ contextTokens,
682
+ contextUsageRatio: Number(Math.min(1, contextTokens / session.contextWindow).toFixed(6)),
683
+ };
684
+ }
685
+ function stabilizeCodexEvent(session, event) {
686
+ if (event.type !== 'context-compaction')
687
+ return stabilizeCodexUsage(session, event);
688
+ // 自动压缩的 item 通知没有独立 usage。压缩结束后清零本地状态,避免下一轮
689
+ // 把压缩前的旧 prompt 规模误认为当前上下文并再次发起压缩。
690
+ if (event.phase === 'end') {
691
+ session.contextTokens = 0;
692
+ return event;
693
+ }
694
+ if (event.phase !== 'summary' || event.shadowedTokenCount !== undefined || session.contextTokens === undefined)
695
+ return event;
696
+ return { ...event, shadowedTokenCount: session.contextTokens };
697
+ }
698
+ function prepareCodexSession(session, input) {
699
+ // 未提供 modelId 表示沿用当前 Codex thread 的模型,不能因为配置字段缺省
700
+ // 就把已经确认的上下文容量清空。
701
+ if (input.modelId === undefined || session.modelId === input.modelId)
702
+ return;
703
+ // 模型切换意味着上下文容量可能改变;只有这类明确路由变化才允许重置
704
+ // 稳定窗口,同一模型的迟到 usage 不能触发重置。
705
+ session.modelId = input.modelId;
706
+ session.contextWindow = undefined;
707
+ }
407
708
  function parseCodexCatalog(value) {
408
709
  const root = isRecord(value) && isRecord(value.data) ? value.data : value;
409
710
  const models = Array.isArray(root)
@@ -449,6 +750,39 @@ function codexMessageToChunk(message) {
449
750
  const method = typeof message.method === 'string' ? message.method : '';
450
751
  const type = typeof item.type === 'string' ? item.type : '';
451
752
  const text = textValue(params.delta ?? params.text ?? params.content ?? params.message);
753
+ if (isCodexCompactionItemType(type)) {
754
+ const details = compactionDetails(item);
755
+ const compactionId = firstToolText(item.id, params.itemId);
756
+ const phase = method === 'thread/compacted'
757
+ ? 'end'
758
+ : method.includes('started') || method.includes('start')
759
+ ? 'start'
760
+ : method.includes('completed') || method.includes('compacted') || method.includes('summary')
761
+ ? 'summary'
762
+ : 'end';
763
+ return {
764
+ type: 'context-compaction',
765
+ phase,
766
+ ...(compactionId === undefined ? {} : { compactionId }),
767
+ ...(details.summary === undefined ? {} : { summary: details.summary }),
768
+ ...(details.shadowedTokenCount === undefined ? {} : { shadowedTokenCount: details.shadowedTokenCount }),
769
+ ...(details.shadowedItemCount === undefined ? {} : { shadowedItemCount: details.shadowedItemCount }),
770
+ };
771
+ }
772
+ if (method === 'thread/compacted') {
773
+ const details = compactionDetails(params);
774
+ return {
775
+ type: 'context-compaction',
776
+ phase: 'end',
777
+ ...(details.summary === undefined ? {} : { summary: details.summary }),
778
+ ...(details.shadowedTokenCount === undefined ? {} : { shadowedTokenCount: details.shadowedTokenCount }),
779
+ ...(details.shadowedItemCount === undefined ? {} : { shadowedItemCount: details.shadowedItemCount }),
780
+ };
781
+ }
782
+ if (method === 'turn/completed' && isContextCompactionTurn(params.turn)) {
783
+ const compactionId = readTurnId(message);
784
+ return { type: 'context-compaction', phase: 'end', ...(compactionId === null ? {} : { compactionId }) };
785
+ }
452
786
  if (isQuestionEvent(`${method} ${type}`)) {
453
787
  const requestId = readRequestId(message) ?? readRequestId(params);
454
788
  const questions = readAgentQuestions(params.questions ?? item.questions ?? params);
@@ -457,9 +791,17 @@ function codexMessageToChunk(message) {
457
791
  }
458
792
  if (method.includes('permission') || method.includes('Approval') || type.includes('permission') || type.includes('approval')) {
459
793
  const requestId = readRequestId(message) ?? readRequestId(params);
460
- const detail = text ?? (typeof params.command === 'string' ? params.command : typeof params.description === 'string' ? params.description : null);
794
+ const permission = codexPermissionDetails(method, params, item);
795
+ const detail = permission.detail ?? text ?? undefined;
461
796
  if (requestId !== null)
462
- return { type: 'permission-request', requestId, kind: typeof params.kind === 'string' ? params.kind : 'unknown', ...(detail ? { detail } : {}) };
797
+ return {
798
+ type: 'permission-request',
799
+ requestId,
800
+ kind: permission.kind,
801
+ ...(permission.toolName === undefined ? {} : { toolName: permission.toolName }),
802
+ ...(permission.callId === undefined ? {} : { callId: permission.callId }),
803
+ ...(detail === undefined ? {} : { detail }),
804
+ };
463
805
  }
464
806
  const messageId = firstToolText(params.itemId, item.id);
465
807
  if (method.includes('agentMessage') || method.includes('message') && (type.includes('text') || type === '')) {
@@ -469,10 +811,10 @@ function codexMessageToChunk(message) {
469
811
  return text ? { type: 'reasoning-delta', text, ...(messageId === undefined ? {} : { messageId }) } : null;
470
812
  }
471
813
  if (isCodexToolEvent(method, type)) {
472
- const name = firstToolText(item.name, item.toolName, item.tool, item.command !== undefined ? 'command_execution' : undefined, type);
814
+ const name = firstToolText(isFileChangeType(type) ? 'edit_file' : undefined, item.name, item.toolName, item.tool, item.command !== undefined ? 'command_execution' : undefined, type);
473
815
  const callId = firstToolText(item.callId, item.call_id, item.toolCallId, item.id, params.itemId);
474
816
  const agentId = firstToolText(item.agentId, item.agent_id, type.includes('agent') || type.includes('collab') ? item.id : undefined);
475
- const input = serializeToolValue(item.arguments ?? item.input ?? item.command);
817
+ const input = serializeToolValue(isFileChangeType(type) ? fileChangeToolInput(item) : item.arguments ?? item.input ?? item.command);
476
818
  const rawOutput = item.result ?? item.output ?? item.aggregated_output;
477
819
  const explicitError = serializeToolValue(item.error);
478
820
  const output = serializeToolValue(rawOutput);
@@ -496,7 +838,71 @@ function codexMessageToChunk(message) {
496
838
  }
497
839
  return codexUsageChunk(params);
498
840
  }
499
- /** Codex app-server 的 tokenUsage 使用未缓存输入和缓存输入两个独立桶。 */
841
+ function* drainCompactionEvents(session) {
842
+ while (session.pendingCompactionEvents.length > 0) {
843
+ const event = session.pendingCompactionEvents.shift();
844
+ if (event !== undefined)
845
+ yield event;
846
+ }
847
+ }
848
+ function compactionDetails(value) {
849
+ if (!isRecord(value))
850
+ return {};
851
+ const nested = isRecord(value.compaction) ? value.compaction : isRecord(value.item) ? value.item : value;
852
+ const summary = textValue(nested.summary ?? nested.summaryText ?? nested.compactionSummary);
853
+ const shadowedTokenCount = optionalToken(nested.shadowedTokenCount ?? nested.shadowed_tokens ?? nested.compactedTokens);
854
+ const shadowedItemCount = optionalToken(nested.shadowedItemCount ?? nested.shadowed_items ?? nested.compactedItems);
855
+ return {
856
+ ...(summary === null || summary.trim() === '' ? {} : { summary: summary.trim() }),
857
+ ...(shadowedTokenCount === undefined ? {} : { shadowedTokenCount }),
858
+ ...(shadowedItemCount === undefined ? {} : { shadowedItemCount }),
859
+ };
860
+ }
861
+ function isContextCompactionTurn(value) {
862
+ if (!isRecord(value))
863
+ return false;
864
+ const kind = String(value.type ?? value.kind ?? value.name ?? '').toLowerCase();
865
+ return kind.includes('contextcompaction') || kind.includes('compaction');
866
+ }
867
+ function codexPermissionDetails(method, params, item) {
868
+ const fileChange = method.includes('fileChange') || method.includes('file_change') || String(item.type ?? '').toLowerCase() === 'filechange';
869
+ const command = typeof params.command === 'string' ? params.command : typeof item.command === 'string' ? item.command : undefined;
870
+ const reason = typeof params.reason === 'string' ? params.reason : typeof params.description === 'string' ? params.description : undefined;
871
+ const grantRoot = typeof params.grantRoot === 'string' ? params.grantRoot : undefined;
872
+ const paths = permissionPaths(params.fileChanges ?? params.file_changes ?? item.changes);
873
+ const callId = firstToolText(params.callId, params.call_id, params.itemId, item.id);
874
+ const detail = [reason, command, paths.length > 0 ? `文件: ${paths.join(', ')}` : undefined, grantRoot ? `允许写入: ${grantRoot}` : undefined]
875
+ .filter((value) => value !== undefined && value.trim() !== '')
876
+ .join(';');
877
+ return {
878
+ kind: typeof params.kind === 'string' ? params.kind : fileChange ? 'file_change' : 'command',
879
+ ...(fileChange ? { toolName: 'edit' } : {}),
880
+ ...(callId === undefined ? {} : { callId }),
881
+ ...(detail === '' ? {} : { detail }),
882
+ };
883
+ }
884
+ function permissionPaths(value) {
885
+ if (Array.isArray(value))
886
+ return value.flatMap((entry) => permissionPaths(entry));
887
+ if (!isRecord(value))
888
+ return [];
889
+ const path = firstToolText(value.path, value.filePath, value.file_path);
890
+ return path === undefined ? [] : [path];
891
+ }
892
+ function isFileChangeType(type) {
893
+ return type.replace(/[_-]/gu, '').toLowerCase() === 'filechange';
894
+ }
895
+ function fileChangeToolInput(item) {
896
+ const changes = Array.isArray(item.changes) ? item.changes : [];
897
+ return {
898
+ changes: changes.map((change) => isRecord(change) ? {
899
+ file_path: firstToolText(change.path, change.filePath, change.file_path) ?? '',
900
+ ...(typeof change.kind === 'string' ? { kind: change.kind } : {}),
901
+ ...(typeof change.diff === 'string' ? { diff: change.diff } : typeof change.patch === 'string' ? { diff: change.patch } : {}),
902
+ } : change),
903
+ };
904
+ }
905
+ /** Codex app-server 的 inputTokens 是含缓存读取的完整输入,cachedInputTokens 是其中已命中的子集。 */
500
906
  function codexUsageChunk(params) {
501
907
  const tokenUsage = isRecord(params.tokenUsage) ? params.tokenUsage : isRecord(params.token_usage) ? params.token_usage : null;
502
908
  if (tokenUsage === null)
@@ -510,11 +916,10 @@ function codexUsageChunk(params) {
510
916
  : isRecord(tokenUsage.total)
511
917
  ? tokenUsage.total
512
918
  : tokenUsage;
513
- const uncachedInputTokens = optionalToken(latest.inputTokens ?? latest.input_tokens);
919
+ const inputTokens = optionalToken(latest.inputTokens ?? latest.input_tokens);
514
920
  const cacheReadTokens = optionalToken(latest.cachedInputTokens ?? latest.cached_input_tokens);
515
921
  const usage = usageChunk({
516
- inputTokens: uncachedInputTokens ?? 0,
517
- ...(uncachedInputTokens === undefined ? {} : { uncachedInputTokens }),
922
+ inputTokens: inputTokens ?? 0,
518
923
  ...(latest.outputTokens === undefined && latest.output_tokens === undefined ? {} : { outputTokens: latest.outputTokens ?? latest.output_tokens }),
519
924
  ...(cacheReadTokens === undefined ? {} : { cacheReadTokens }),
520
925
  ...(latest.totalTokens === undefined && latest.total_tokens === undefined ? {} : { totalTokens: latest.totalTokens ?? latest.total_tokens }),
@@ -534,7 +939,7 @@ function codexUsageChunk(params) {
534
939
  ?? params.contextWindow
535
940
  ?? params.context_window);
536
941
  const contextWindow = contextWindowValue !== undefined && contextWindowValue > 0 ? contextWindowValue : undefined;
537
- const contextTokens = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
942
+ const contextTokens = usage.inputTokens;
538
943
  return {
539
944
  ...usage,
540
945
  ...(contextWindow === undefined ? {} : {
@@ -616,12 +1021,46 @@ function isCodexNotificationForTurn(message, threadId, turnId, allowUnidentified
616
1021
  const notificationTurnId = readTurnId(message);
617
1022
  if (notificationThreadId !== null && notificationThreadId !== threadId)
618
1023
  return false;
619
- // turn/start 响应前的无 turnId 工具通知无法证明属于本次回合,通常是
620
- // thread/resume 的迟到历史;响应后则把它交给当前开放的 DSH step 处理。
621
- if (notificationTurnId === null && isCodexToolNotification(message) && !allowUnidentifiedTool)
1024
+ // 权限/问题审批是 Codex 发起的 JSON-RPC 请求,必须先交给交互层,不能因
1025
+ // 它的 turnId 尚未出现在 turn/start 响应中而丢弃。
1026
+ if (message.id !== undefined && message.id !== null)
1027
+ return true;
1028
+ // 响应返回前 activeTurnId 为空;带 turnId 的旧通知不能提前关闭新回合队列。
1029
+ if (turnId === null && notificationTurnId !== null)
1030
+ return false;
1031
+ // turn/start 响应前的无 turnId 通知无法证明属于本次回合,尤其不能让迟到的
1032
+ // 无标识 turn/completed 直接关闭新回合;响应后再交给当前开放的 DSH step。
1033
+ if (notificationTurnId === null && !allowUnidentifiedTool)
622
1034
  return false;
623
1035
  return turnId === null || notificationTurnId === null || notificationTurnId === turnId;
624
1036
  }
1037
+ function isCodexCompactionNotification(message, threadId) {
1038
+ const params = isRecord(message.params) ? message.params : null;
1039
+ if (readScopedId(params, ['threadId', 'thread_id'], 'thread') !== null
1040
+ && readScopedId(params, ['threadId', 'thread_id'], 'thread') !== threadId)
1041
+ return false;
1042
+ if (message.method === 'thread/compacted')
1043
+ return true;
1044
+ if (message.method !== 'item/started' && message.method !== 'item/completed')
1045
+ return false;
1046
+ const item = isRecord(params?.item) ? params.item : null;
1047
+ return isCodexCompactionItemType(item?.type);
1048
+ }
1049
+ function isCodexCompactionItemType(value) {
1050
+ return value === 'contextCompaction' || value === 'context_compaction' || value === 'context-compaction';
1051
+ }
1052
+ function isContextWindowError(error) {
1053
+ if (error instanceof JsonRpcRequestError)
1054
+ return hasContextWindowError(error.data);
1055
+ return hasContextWindowError(error);
1056
+ }
1057
+ function hasContextWindowError(value) {
1058
+ if (typeof value === 'string')
1059
+ return /context[_-]?window[_-]?exceeded|context window exceeded/iu.test(value);
1060
+ if (!isRecord(value))
1061
+ return false;
1062
+ return Object.entries(value).some(([key, child]) => /context.?window.?exceeded/iu.test(key) || hasContextWindowError(child));
1063
+ }
625
1064
  function readCodexTerminalReason(message) {
626
1065
  if (message.method === 'error') {
627
1066
  const params = isRecord(message.params) ? message.params : null;
@@ -631,7 +1070,7 @@ function readCodexTerminalReason(message) {
631
1070
  return null;
632
1071
  const params = isRecord(message.params) ? message.params : null;
633
1072
  const turn = isRecord(params?.turn) ? params.turn : null;
634
- if (turn?.status === 'failed')
1073
+ if (turn?.status === 'failed' || hasContextWindowError(turn?.error))
635
1074
  return 'error';
636
1075
  if (turn?.status === 'interrupted' || turn?.status === 'cancelled')
637
1076
  return 'cancel';
@@ -661,16 +1100,33 @@ function isCodexToolEvent(method, type) {
661
1100
  const normalized = type.replace(/[_-]/gu, '').toLowerCase();
662
1101
  return ['commandexecution', 'filechange', 'mcptoolcall', 'functioncall', 'customtoolcall', 'dynamictoolcall'].includes(normalized);
663
1102
  }
664
- function isCodexToolNotification(message) {
665
- // 带 id 的消息是 Codex 发起的 JSON-RPC 服务请求(例如权限审批),
666
- // 必须交给 serverRequestHandler,不能被当成实时工具通知过滤掉。
667
- if (message.id !== undefined && message.id !== null)
668
- return false;
669
- const params = isRecord(message.params) ? message.params : null;
670
- const item = isRecord(params?.item) ? params.item : params;
671
- const method = typeof message.method === 'string' ? message.method : '';
672
- const type = typeof item?.type === 'string' ? item.type : '';
673
- return isCodexToolEvent(method, type);
1103
+ function codexTurnStartParams(input, threadId) {
1104
+ const cwd = resolve(input.cwd ?? process.cwd());
1105
+ return {
1106
+ threadId,
1107
+ input: [{ type: 'text', text: input.prompt }],
1108
+ cwd,
1109
+ // DSH 的 workspace-write 只约束自身工具沙箱,不会自动传递给 Codex
1110
+ // app-server。显式声明当前工作区,避免 Codex 将文件编辑误判为只读越权。
1111
+ sandboxPolicy: {
1112
+ type: 'workspaceWrite',
1113
+ writableRoots: [cwd],
1114
+ networkAccess: false,
1115
+ excludeTmpdirEnvVar: false,
1116
+ excludeSlashTmp: false,
1117
+ },
1118
+ approvalPolicy: 'on-request',
1119
+ ...(input.effortId ? { effort: input.effortId } : {}),
1120
+ };
1121
+ }
1122
+ function permissionResponseKind(method, params) {
1123
+ if (method.includes('fileChange') || method.includes('file_change'))
1124
+ return 'file-change';
1125
+ // 新版 Codex 带 threadId/turnId/itemId,并要求 decision;旧版测试和旧
1126
+ // app-server 使用 approved 字段,按请求形状保持向后兼容。
1127
+ if (method.includes('commandExecution') && (params.threadId !== undefined || params.turnId !== undefined || params.itemId !== undefined))
1128
+ return 'command';
1129
+ return 'legacy';
674
1130
  }
675
1131
  function readId(value) {
676
1132
  if (!isRecord(value))