handmux 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (327) hide show
  1. package/README.md +31 -8
  2. package/README.zh-CN.md +31 -8
  3. package/bin/handmux.js +14 -688
  4. package/bin/node-version.js +20 -0
  5. package/dist/bin/handmux.js +1015 -0
  6. package/dist/connectors/bridgeClient.js +385 -0
  7. package/dist/connectors/claude/index.js +476 -0
  8. package/dist/connectors/pi/bridgeClient.js +8 -0
  9. package/dist/connectors/pi/index.js +851 -0
  10. package/dist/hooks/handmux-notify.sh +64 -0
  11. package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
  12. package/dist/hooks/handmux-write.cjs +264 -0
  13. package/dist/package.json +127 -0
  14. package/dist/public/assets/index-CTrqTqtT.css +32 -0
  15. package/dist/public/assets/index-DmjeMSQA.js +346 -0
  16. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
  17. package/{public → dist/public}/index.html +5 -4
  18. package/dist/src/agent-runtime/adapter.js +204 -0
  19. package/dist/src/agent-runtime/bridge.js +1006 -0
  20. package/dist/src/agent-runtime/bridgeStore.js +25 -0
  21. package/dist/src/agent-runtime/bridgeTransport.js +619 -0
  22. package/dist/src/agent-runtime/bridgeTypes.js +13 -0
  23. package/dist/src/agent-runtime/builtinRuntime.js +193 -0
  24. package/dist/src/agent-runtime/conversation.js +2179 -0
  25. package/dist/src/agent-runtime/conversationActivation.js +75 -0
  26. package/dist/src/agent-runtime/conversationActivity.js +69 -0
  27. package/dist/src/agent-runtime/conversationControls.js +206 -0
  28. package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
  29. package/dist/src/agent-runtime/conversationStore.js +352 -0
  30. package/dist/src/agent-runtime/conversationTypes.js +1 -0
  31. package/dist/src/agent-runtime/conversationValidation.js +339 -0
  32. package/dist/src/agent-runtime/inbox.js +973 -0
  33. package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
  34. package/dist/src/agent-runtime/inboxStore.js +25 -0
  35. package/dist/src/agent-runtime/inboxTypes.js +1 -0
  36. package/dist/src/agent-runtime/interaction.js +829 -0
  37. package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
  38. package/dist/src/agent-runtime/interactionStore.js +18 -0
  39. package/dist/src/agent-runtime/interactionTypes.js +1 -0
  40. package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
  41. package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
  42. package/dist/src/agent-runtime/resourceTypes.js +1 -0
  43. package/dist/src/agent-runtime/resources.js +245 -0
  44. package/dist/src/agent-runtime/run.js +328 -0
  45. package/dist/src/agent-runtime/runtime.js +793 -0
  46. package/dist/src/agent-runtime/sessionControl.js +164 -0
  47. package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
  48. package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
  49. package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
  50. package/dist/src/agents/claude.js +203 -0
  51. package/dist/src/agents/claudeConversation.js +362 -0
  52. package/dist/src/agents/claudeInteraction.js +134 -0
  53. package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
  54. package/dist/src/agents/codex.js +262 -0
  55. package/dist/src/agents/codexConversation.js +1103 -0
  56. package/dist/src/agents/codexConversationActivation.js +121 -0
  57. package/dist/src/agents/codexConversationControls.js +256 -0
  58. package/dist/src/agents/codexInteraction.js +306 -0
  59. package/dist/src/agents/codexSessionControl.js +118 -0
  60. package/dist/src/agents/codexSubscriptionUsage.js +313 -0
  61. package/dist/src/agents/codexTerminalControl.js +23 -0
  62. package/dist/src/agents/conversationProjectionSafety.js +416 -0
  63. package/dist/src/agents/index.js +39 -0
  64. package/dist/src/agents/nativeInbox.js +264 -0
  65. package/dist/src/agents/nativeInboxSources.js +115 -0
  66. package/dist/src/agents/pi.js +28 -0
  67. package/dist/src/agents/piConversation.js +523 -0
  68. package/dist/src/agents/piConversationHistory.js +502 -0
  69. package/dist/src/agents/piInboxBridge.js +368 -0
  70. package/dist/src/agents/piSessionControl.js +32 -0
  71. package/dist/src/agents/processIdentity.js +94 -0
  72. package/dist/src/agents/scanUtils.js +262 -0
  73. package/dist/src/apiAccountProviders.js +194 -0
  74. package/dist/src/apiAccounts.js +592 -0
  75. package/dist/src/apiErrors.js +86 -0
  76. package/{src → dist/src}/appName.js +12 -13
  77. package/dist/src/asr/iflyConfig.js +7 -0
  78. package/dist/src/asr/iflySign.js +14 -0
  79. package/dist/src/auth.js +27 -0
  80. package/dist/src/browser/bootstrap.js +72 -0
  81. package/dist/src/browser/cookieProfiles.js +413 -0
  82. package/dist/src/browser/coordinator.js +51 -0
  83. package/dist/src/browser/hammerheadCompat.js +104 -0
  84. package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
  85. package/dist/src/browser/manager.js +560 -0
  86. package/dist/src/browser/originLabel.js +15 -0
  87. package/dist/src/browser/profilePersistence.js +235 -0
  88. package/dist/src/browser/publicProxy.js +176 -0
  89. package/dist/src/browser/routes.js +159 -0
  90. package/dist/src/browser/sessionStore.js +72 -0
  91. package/dist/src/browser/siteVersion.js +121 -0
  92. package/dist/src/browser/targetPolicy.js +163 -0
  93. package/{src → dist/src}/browser/worker.js +14 -18
  94. package/dist/src/browser/workerClient.js +286 -0
  95. package/dist/src/browser/workerServer.js +91 -0
  96. package/dist/src/captureBackground.js +95 -0
  97. package/dist/src/claudeEvents.js +525 -0
  98. package/dist/src/cli/agentIntegration.js +131 -0
  99. package/dist/src/cli/agentLauncher.js +26 -0
  100. package/dist/src/cli/claudeHooks.js +290 -0
  101. package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
  102. package/dist/src/cli/cloudflared.js +106 -0
  103. package/dist/src/cli/codexManaged.js +80 -0
  104. package/{src → dist/src}/cli/cpolarUrl.js +8 -9
  105. package/dist/src/cli/drivers.js +98 -0
  106. package/dist/src/cli/hookScaffold.js +39 -0
  107. package/dist/src/cli/i18n/en.js +392 -0
  108. package/{src → dist/src}/cli/i18n/index.js +27 -25
  109. package/dist/src/cli/i18n/zh.js +391 -0
  110. package/dist/src/cli/legacyCodexHooks.js +52 -0
  111. package/{src → dist/src}/cli/natappUrl.js +2 -3
  112. package/dist/src/cli/options.js +245 -0
  113. package/dist/src/cli/piExtension.js +155 -0
  114. package/dist/src/cli/probe.js +16 -0
  115. package/{src → dist/src}/cli/prompt.js +4 -6
  116. package/dist/src/cli/pushCmd.js +102 -0
  117. package/{src → dist/src}/cli/qr.js +19 -19
  118. package/dist/src/cli/service.js +154 -0
  119. package/dist/src/cli/setupModel.js +194 -0
  120. package/dist/src/cli/setupWizard.js +647 -0
  121. package/dist/src/cli/shortcutEditor.js +351 -0
  122. package/dist/src/cli/sshTunnel.js +18 -0
  123. package/dist/src/cli/state.js +117 -0
  124. package/dist/src/cli/statusLine.js +115 -0
  125. package/dist/src/cli/supervisor.js +278 -0
  126. package/dist/src/cli/supervisorHealth.js +21 -0
  127. package/dist/src/cli/supervisorLaunch.js +148 -0
  128. package/dist/src/cli/supervisorProcesses.js +62 -0
  129. package/dist/src/cli/supervisorState.js +62 -0
  130. package/dist/src/cli/tmuxVersion.js +53 -0
  131. package/{src → dist/src}/cli/tunlite.js +7 -10
  132. package/dist/src/cli/tunnelClients.js +72 -0
  133. package/{src → dist/src}/cli/updateCheck.js +98 -69
  134. package/{src → dist/src}/cli/urlHost.js +8 -4
  135. package/dist/src/cli/workspaceCmd.js +347 -0
  136. package/dist/src/codexAppServer.js +3436 -0
  137. package/dist/src/codexConversationProjection.js +199 -0
  138. package/dist/src/codexDiff.js +118 -0
  139. package/dist/src/codexMessageIdentity.js +18 -0
  140. package/dist/src/codexPlan.js +30 -0
  141. package/dist/src/codexQueueProtocol.js +137 -0
  142. package/dist/src/codexStreamProtocol.js +207 -0
  143. package/dist/src/codexToolProtocol.js +68 -0
  144. package/dist/src/codexTranscriptParse.js +835 -0
  145. package/dist/src/codexUsageSnapshot.js +228 -0
  146. package/dist/src/config.js +8 -0
  147. package/{src → dist/src}/docPath.js +22 -24
  148. package/dist/src/docs.js +337 -0
  149. package/dist/src/git.js +284 -0
  150. package/dist/src/healthProtocol.js +97 -0
  151. package/dist/src/httpApi.js +96 -0
  152. package/dist/src/jsonStore.js +15 -0
  153. package/{src → dist/src}/keyNames.js +3 -3
  154. package/dist/src/notifications.js +111 -0
  155. package/dist/src/orphans.js +167 -0
  156. package/dist/src/paneInput.js +42 -0
  157. package/dist/src/pendingPrompt.js +194 -0
  158. package/dist/src/previewServer.js +202 -0
  159. package/dist/src/previews.js +179 -0
  160. package/dist/src/privateStateStore.js +124 -0
  161. package/dist/src/projectTask/backup.js +224 -0
  162. package/dist/src/projectTask/lock.js +108 -0
  163. package/dist/src/projectTask/migrations.js +90 -0
  164. package/dist/src/projectTask/routes.js +155 -0
  165. package/dist/src/projectTask/runtime.js +182 -0
  166. package/dist/src/projectTask/schema.js +56 -0
  167. package/dist/src/projectTask/store.js +457 -0
  168. package/dist/src/push.js +224 -0
  169. package/dist/src/routes/agents.js +631 -0
  170. package/dist/src/routes/apiAccounts.js +82 -0
  171. package/dist/src/routes/files.js +230 -0
  172. package/dist/src/routes/git.js +89 -0
  173. package/dist/src/routes/health.js +16 -0
  174. package/dist/src/routes/notifications.js +16 -0
  175. package/dist/src/routes/previews.js +43 -0
  176. package/dist/src/routes/push.js +145 -0
  177. package/dist/src/routes/sessions.js +244 -0
  178. package/dist/src/routes/system.js +220 -0
  179. package/dist/src/routes/terminal.js +244 -0
  180. package/dist/src/routes/workspace.js +267 -0
  181. package/dist/src/server.js +315 -0
  182. package/dist/src/shortcutConfig.js +83 -0
  183. package/{src → dist/src}/staticCache.js +5 -3
  184. package/{src → dist/src}/staticCompression.js +5 -6
  185. package/dist/src/terminalStream.js +441 -0
  186. package/{src → dist/src}/tmux/commands.js +185 -172
  187. package/dist/src/tmux/format.js +47 -0
  188. package/dist/src/transcriptParse.js +261 -0
  189. package/dist/src/transcriptReader.js +209 -0
  190. package/{src → dist/src}/trimCapture.js +11 -11
  191. package/dist/src/uploadTypes.js +31 -0
  192. package/dist/src/urlPolicy.js +18 -0
  193. package/dist/src/usagePaths.js +15 -0
  194. package/dist/src/workspace/agentRunner.js +301 -0
  195. package/dist/src/workspace/atomicJson.js +31 -0
  196. package/dist/src/workspace/capture.js +139 -0
  197. package/dist/src/workspace/checkpointer.js +228 -0
  198. package/dist/src/workspace/environment.js +71 -0
  199. package/dist/src/workspace/lock.js +137 -0
  200. package/dist/src/workspace/mapping.js +128 -0
  201. package/dist/src/workspace/operations.js +271 -0
  202. package/dist/src/workspace/paths.js +15 -0
  203. package/dist/src/workspace/planner.js +292 -0
  204. package/dist/src/workspace/restore.js +363 -0
  205. package/dist/src/workspace/runtime.js +318 -0
  206. package/dist/src/workspace/schema.js +156 -0
  207. package/dist/src/workspace/store.js +464 -0
  208. package/dist/src/workspace/tmuxAdapter.js +704 -0
  209. package/dist/src/workspaceProtocol.js +158 -0
  210. package/package.json +30 -17
  211. package/hooks/handmux-notify.sh +0 -22
  212. package/hooks/handmux-write.cjs +0 -78
  213. package/public/assets/index-C3N7OkyP.css +0 -32
  214. package/public/assets/index-D1DWcC5u.js +0 -337
  215. package/src/.gitkeep +0 -0
  216. package/src/agents/claude.js +0 -159
  217. package/src/agents/codex.js +0 -168
  218. package/src/agents/index.js +0 -21
  219. package/src/agents/processIdentity.js +0 -72
  220. package/src/agents/scanUtils.js +0 -225
  221. package/src/asr/iflyConfig.js +0 -10
  222. package/src/asr/iflySign.js +0 -16
  223. package/src/auth.js +0 -30
  224. package/src/browser/bootstrap.js +0 -68
  225. package/src/browser/cookieProfiles.js +0 -403
  226. package/src/browser/coordinator.js +0 -47
  227. package/src/browser/hammerheadCompat.js +0 -86
  228. package/src/browser/hammerheadRedirectCompat.js +0 -38
  229. package/src/browser/manager.js +0 -556
  230. package/src/browser/originLabel.js +0 -17
  231. package/src/browser/profilePersistence.js +0 -241
  232. package/src/browser/publicProxy.js +0 -175
  233. package/src/browser/routes.js +0 -154
  234. package/src/browser/sessionStore.js +0 -78
  235. package/src/browser/siteVersion.js +0 -124
  236. package/src/browser/targetPolicy.js +0 -137
  237. package/src/browser/workerClient.js +0 -253
  238. package/src/browser/workerServer.js +0 -98
  239. package/src/captureBackground.js +0 -93
  240. package/src/claudeEvents.js +0 -399
  241. package/src/cli/claudeHooks.js +0 -206
  242. package/src/cli/cloudflared.js +0 -99
  243. package/src/cli/codexManaged.js +0 -77
  244. package/src/cli/drivers.js +0 -97
  245. package/src/cli/hookScaffold.js +0 -33
  246. package/src/cli/i18n/en.js +0 -385
  247. package/src/cli/i18n/zh.js +0 -384
  248. package/src/cli/legacyCodexHooks.js +0 -49
  249. package/src/cli/options.js +0 -211
  250. package/src/cli/probe.js +0 -16
  251. package/src/cli/pushCmd.js +0 -60
  252. package/src/cli/service.js +0 -126
  253. package/src/cli/setupModel.js +0 -154
  254. package/src/cli/setupWizard.js +0 -506
  255. package/src/cli/shortcutEditor.js +0 -288
  256. package/src/cli/sshTunnel.js +0 -12
  257. package/src/cli/state.js +0 -90
  258. package/src/cli/statusLine.js +0 -90
  259. package/src/cli/supervisor.js +0 -183
  260. package/src/cli/supervisorProcesses.js +0 -57
  261. package/src/cli/tmuxVersion.js +0 -49
  262. package/src/cli/tunnelClients.js +0 -62
  263. package/src/cli/workspaceCmd.js +0 -300
  264. package/src/codexAppServer.js +0 -1620
  265. package/src/codexDiff.js +0 -108
  266. package/src/codexTranscriptParse.js +0 -413
  267. package/src/codexUsageSnapshot.cjs +0 -129
  268. package/src/config.js +0 -9
  269. package/src/docs.js +0 -228
  270. package/src/git.js +0 -208
  271. package/src/httpApi.js +0 -72
  272. package/src/jsonStore.js +0 -24
  273. package/src/notifications.js +0 -68
  274. package/src/orphans.js +0 -152
  275. package/src/pendingPrompt.js +0 -163
  276. package/src/previewServer.js +0 -168
  277. package/src/previews.js +0 -116
  278. package/src/push.js +0 -183
  279. package/src/routes/codex.js +0 -473
  280. package/src/routes/files.js +0 -172
  281. package/src/routes/git.js +0 -57
  282. package/src/routes/notifications.js +0 -19
  283. package/src/routes/previews.js +0 -35
  284. package/src/routes/push.js +0 -119
  285. package/src/routes/sessions.js +0 -168
  286. package/src/routes/system.js +0 -132
  287. package/src/routes/terminal.js +0 -210
  288. package/src/routes/transcript.js +0 -171
  289. package/src/routes/workspace.js +0 -267
  290. package/src/server.js +0 -186
  291. package/src/shortcutConfig.js +0 -75
  292. package/src/terminalStream.js +0 -391
  293. package/src/tmux/format.js +0 -45
  294. package/src/transcriptParse.js +0 -180
  295. package/src/transcriptReader.js +0 -101
  296. package/src/uploadTypes.js +0 -30
  297. package/src/urlPolicy.js +0 -14
  298. package/src/usage.js +0 -260
  299. package/src/workspace/agentRunner.js +0 -188
  300. package/src/workspace/atomicJson.js +0 -32
  301. package/src/workspace/capture.js +0 -139
  302. package/src/workspace/checkpointer.js +0 -187
  303. package/src/workspace/environment.js +0 -75
  304. package/src/workspace/lock.js +0 -128
  305. package/src/workspace/mapping.js +0 -124
  306. package/src/workspace/operations.js +0 -252
  307. package/src/workspace/paths.js +0 -16
  308. package/src/workspace/planner.js +0 -247
  309. package/src/workspace/restore.js +0 -279
  310. package/src/workspace/runtime.js +0 -314
  311. package/src/workspace/schema.js +0 -136
  312. package/src/workspace/store.js +0 -382
  313. package/src/workspace/tmuxAdapter.js +0 -554
  314. /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  315. /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
  316. /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
  317. /package/{public → dist/public}/icons/badge-96.png +0 -0
  318. /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
  319. /package/{public → dist/public}/icons/icon-192.png +0 -0
  320. /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
  321. /package/{public → dist/public}/icons/icon-512.png +0 -0
  322. /package/{public → dist/public}/icons/logo.svg +0 -0
  323. /package/{public → dist/public}/manifest.webmanifest +0 -0
  324. /package/{public → dist/public}/offline.html +0 -0
  325. /package/{public → dist/public}/sw.js +0 -0
  326. /package/{src → dist/src}/browser/protocol.js +0 -0
  327. /package/{src → dist/src}/cli/cfNamed.js +0 -0
@@ -0,0 +1,1103 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { stat } from 'node:fs/promises';
3
+ import { transcriptReader } from '../transcriptReader.js';
4
+ import { createCodexTranscriptParser, } from '../codexTranscriptParse.js';
5
+ import { projectCodexConversationMutation } from '../codexConversationProjection.js';
6
+ import { parseCodexStreamEvent } from '../codexStreamProtocol.js';
7
+ import { resolveCodexRollout, sessionsDir } from './codex.js';
8
+ import { clipConversationText, safeProviderPathLabel, sanitizeToolInputWithMetadata, sanitizeToolResultText, } from './conversationProjectionSafety.js';
9
+ const DURABLE_QUIET_MS = 100;
10
+ const DEFAULT_DURABLE_SETTLE_TIMEOUT_MS = 15_000;
11
+ const MAX_OPENING_NATIVE_EVENTS = 4_096;
12
+ const MAX_SEEN_GOAL_LIFECYCLES = 2_048;
13
+ const MAX_DURABLE_CHECKPOINTS = 64;
14
+ const HISTORY_HASH_SEED = createHash('sha256').update('codex-conversation-v1').digest('hex');
15
+ function goalIdentity(item) {
16
+ if (item.kind !== 'notice' || item.code !== 'goal_updated')
17
+ return null;
18
+ const goal = item.extensions?.['conversation.goal'];
19
+ if (!goal || typeof goal !== 'object' || Array.isArray(goal))
20
+ return null;
21
+ const event = item.extensions?.['conversation.goalEvent'];
22
+ const objective = typeof goal.objective === 'string' ? goal.objective : null;
23
+ const status = typeof goal.status === 'string' ? goal.status : null;
24
+ const createdAt = typeof goal.createdAt === 'number' && Number.isFinite(goal.createdAt)
25
+ ? goal.createdAt : null;
26
+ if (!objective || !status || typeof event !== 'string')
27
+ return null;
28
+ return { objective, status, event, createdAt };
29
+ }
30
+ function sameGoal(left, right) {
31
+ const lifecycle = (goal) => (goal.status === 'active' && ['set', 'restarted', 'active'].includes(goal.event)
32
+ ? 'active' : goal.event);
33
+ return left.objective === right.objective && left.status === right.status
34
+ && lifecycle(left) === lifecycle(right)
35
+ // The injected durable Goal context does not carry App Server's createdAt. If both sides have it,
36
+ // use it to distinguish a genuinely repeated Goal with the same objective and lifecycle.
37
+ && (left.createdAt === null || right.createdAt === null || left.createdAt === right.createdAt);
38
+ }
39
+ function durableOwnsSettledItem(pending, durable) {
40
+ if (durable.id !== pending.id || durable.kind !== pending.kind)
41
+ return false;
42
+ if (pending.kind === 'message' && durable.kind === 'message') {
43
+ return pending.role === durable.role
44
+ && JSON.stringify(pending.content) === JSON.stringify(durable.content);
45
+ }
46
+ const pendingGoal = goalIdentity(pending);
47
+ const durableGoal = goalIdentity(durable);
48
+ if (pendingGoal || durableGoal) {
49
+ return pendingGoal !== null && durableGoal !== null && sameGoal(pendingGoal, durableGoal);
50
+ }
51
+ return true;
52
+ }
53
+ function durableMatchCount(pending, durableItems) {
54
+ // A Goal's immediate App Server card uses its lifecycle timestamp, while the injected rollout
55
+ // context uses the source item identity. Match that one documented identity handoff semantically.
56
+ const pendingGoal = goalIdentity(pending);
57
+ if (pendingGoal) {
58
+ return durableItems.filter((item) => {
59
+ const durableGoal = goalIdentity(item);
60
+ return durableGoal !== null && sameGoal(pendingGoal, durableGoal);
61
+ }).length;
62
+ }
63
+ return durableItems.filter((item) => item.id === pending.id).length;
64
+ }
65
+ function waitsForNextTurn(item) {
66
+ return goalIdentity(item)?.status === 'active';
67
+ }
68
+ function sameDurableItem(left, right) {
69
+ return JSON.stringify(left) === JSON.stringify(right);
70
+ }
71
+ function trustedCheckpointLength(checkpoint, cutoff, baseline) {
72
+ if (!checkpoint || checkpoint.file !== cutoff.file
73
+ || checkpoint.snapshot.items.length > baseline.items.length)
74
+ return null;
75
+ return checkpoint.snapshot.items.every((item, index) => (sameDurableItem(item, baseline.items[index]))) ? checkpoint.snapshot.items.length : null;
76
+ }
77
+ function goalLifecycleKey(sessionId, messageId) {
78
+ return `${sessionId}\0${messageId}`;
79
+ }
80
+ function rememberGoalLifecycle(seen, sessionId, messageId) {
81
+ const key = goalLifecycleKey(sessionId, messageId);
82
+ // Refresh insertion order so this bounded Set behaves like a small LRU. Reconnect repair only needs
83
+ // recent lifecycle knowledge; evicting an old ID safely falls back to showing a possibly duplicate card.
84
+ seen.delete(key);
85
+ seen.add(key);
86
+ if (seen.size <= MAX_SEEN_GOAL_LIFECYCLES)
87
+ return;
88
+ const oldest = seen.values().next().value;
89
+ if (typeof oldest === 'string')
90
+ seen.delete(oldest);
91
+ }
92
+ function suppressedOpeningEventIndexes(values, sessionId, baseline, knownLength, durablyOwnedGoalLifecycleIds) {
93
+ const groups = new Map();
94
+ values.forEach((value, index) => {
95
+ const event = parseCodexStreamEvent(value);
96
+ if (!event || event.type === 'ready' || event.type === 'cursorReset'
97
+ || event.type === 'conversationSnapshot' || event.type === 'disconnected'
98
+ || event.type === 'error')
99
+ return;
100
+ const mutation = projectCodexConversationMutation(event);
101
+ if (!mutation || mutation.operation !== 'upsert')
102
+ return;
103
+ const draft = liveDraft(mutation.message);
104
+ if (!draft)
105
+ return;
106
+ let group = groups.get(mutation.message.id);
107
+ if (!group) {
108
+ group = {
109
+ messageId: mutation.message.id,
110
+ messageType: mutation.message.type,
111
+ indexes: [],
112
+ draft: null,
113
+ text: null,
114
+ settled: null,
115
+ repairOnly: event.observationSnapshot === true,
116
+ };
117
+ groups.set(mutation.message.id, group);
118
+ }
119
+ group.indexes.push(index);
120
+ // A stable ID can appear as both a setup snapshot and a real journal lifecycle. Such a mixed group
121
+ // is real lifecycle evidence and must never inherit the snapshot's weaker semantic suppression rule.
122
+ group.repairOnly &&= event.observationSnapshot === true;
123
+ if (mutation.message.type === 'text') {
124
+ group.text = mutation.mode === 'append'
125
+ ? `${group.text ?? ''}${mutation.message.text}` : mutation.message.text;
126
+ group.draft = liveDraft({ ...mutation.message, text: group.text });
127
+ }
128
+ else {
129
+ group.draft = draft;
130
+ }
131
+ if (mutation.message.completed) {
132
+ group.settled = finalItem(mutation.message.id, sessionId, group.draft ?? draft);
133
+ }
134
+ });
135
+ const suppressed = new Set();
136
+ const durablyOwnedGoalIds = new Set();
137
+ const claimedDurableIndexes = new Set();
138
+ const suppress = (group) => {
139
+ for (const index of group.indexes)
140
+ suppressed.add(index);
141
+ };
142
+ // Exact native identity is authoritative. Reserve those durable occurrences globally and drop every
143
+ // lifecycle event for that entity, not merely the terminal event after an opened card already flashed.
144
+ for (const group of groups.values()) {
145
+ const projected = group.settled ?? (group.messageType === 'text' && group.draft
146
+ ? finalItem(group.messageId, sessionId, group.draft) : null);
147
+ if (!projected)
148
+ continue;
149
+ const durableIndex = baseline.items.findIndex((item, index) => (!claimedDurableIndexes.has(index)
150
+ && durableOwnsSettledItem(projected, item)));
151
+ if (durableIndex < 0)
152
+ continue;
153
+ claimedDurableIndexes.add(durableIndex);
154
+ if (goalIdentity(projected))
155
+ durablyOwnedGoalIds.add(group.messageId);
156
+ suppress(group);
157
+ }
158
+ // A tagged Goal is a repair of current App Server state, not evidence of a newly created historical
159
+ // lifecycle. Semantic suppression is allowed only after a prior durable reconciliation proved that this
160
+ // stable lifecycle ID owns an occurrence; otherwise an old same-objective Goal could swallow a new one.
161
+ for (const group of groups.values()) {
162
+ if (!group.repairOnly || !group.settled
163
+ || !durablyOwnedGoalLifecycleIds.has(goalLifecycleKey(sessionId, group.messageId))
164
+ || group.indexes.every((index) => suppressed.has(index)))
165
+ continue;
166
+ const pendingGoal = goalIdentity(group.settled);
167
+ if (!pendingGoal)
168
+ continue;
169
+ const candidates = baseline.items.flatMap((item, index) => {
170
+ if (claimedDurableIndexes.has(index))
171
+ return [];
172
+ const durableGoal = goalIdentity(item);
173
+ return durableGoal !== null && sameGoal(pendingGoal, durableGoal) ? [index] : [];
174
+ });
175
+ const durableIndex = candidates.find((index) => (group.settled.groupingId
176
+ && baseline.items[index]?.groupingId === group.settled.groupingId)) ?? candidates[0] ?? -1;
177
+ if (durableIndex < 0)
178
+ continue;
179
+ claimedDurableIndexes.add(durableIndex);
180
+ suppress(group);
181
+ }
182
+ // Active Goal context uses a rollout item identity rather than App Server's timestamp identity. Only a
183
+ // proven addition after a previously read durable prefix may bridge that identity, one occurrence each.
184
+ // With no trustworthy checkpoint, retaining the native card is safer than swallowing a repeated Goal.
185
+ if (knownLength === null)
186
+ return { suppressed, durablyOwnedGoalIds };
187
+ const semanticGroups = [...groups.values()].filter((group) => (!group.repairOnly && group.settled
188
+ && !group.indexes.every((index) => suppressed.has(index))
189
+ && goalIdentity(group.settled) !== null));
190
+ // Reserve correlation matches before FIFO fallback, so an earlier unanchored Goal cannot steal the
191
+ // durable occurrence explicitly tied to a later opening Goal.
192
+ for (const group of semanticGroups) {
193
+ const pendingGoal = goalIdentity(group.settled);
194
+ const groupingId = group.settled.groupingId;
195
+ if (!pendingGoal || !groupingId)
196
+ continue;
197
+ const durableIndex = baseline.items.findIndex((item, index) => {
198
+ if (index < knownLength || claimedDurableIndexes.has(index)
199
+ || item.groupingId !== groupingId)
200
+ return false;
201
+ const durableGoal = goalIdentity(item);
202
+ return durableGoal !== null && sameGoal(pendingGoal, durableGoal);
203
+ });
204
+ if (durableIndex < 0)
205
+ continue;
206
+ claimedDurableIndexes.add(durableIndex);
207
+ durablyOwnedGoalIds.add(group.messageId);
208
+ suppress(group);
209
+ }
210
+ for (const group of semanticGroups) {
211
+ if (group.indexes.every((index) => suppressed.has(index)))
212
+ continue;
213
+ const pendingGoal = goalIdentity(group.settled);
214
+ if (!pendingGoal)
215
+ continue;
216
+ const durableIndex = baseline.items.findIndex((item, index) => {
217
+ if (index < knownLength || claimedDurableIndexes.has(index))
218
+ return false;
219
+ const durableGoal = goalIdentity(item);
220
+ return durableGoal !== null && sameGoal(pendingGoal, durableGoal);
221
+ });
222
+ if (durableIndex < 0)
223
+ continue;
224
+ claimedDurableIndexes.add(durableIndex);
225
+ durablyOwnedGoalIds.add(group.messageId);
226
+ suppress(group);
227
+ }
228
+ return { suppressed, durablyOwnedGoalIds };
229
+ }
230
+ function coveredPendingItemIds(pendingItems, durableItems) {
231
+ const covered = new Set();
232
+ const claimedDurableIndexes = new Set();
233
+ // Reserve stable-ID ownership globally before any Goal is allowed to claim a semantic occurrence.
234
+ // Otherwise an earlier Goal can steal the exact durable item that belongs to a later pending Goal.
235
+ for (const [pendingId, pending] of pendingItems) {
236
+ const exactIndex = durableItems.findIndex((item, index) => (!claimedDurableIndexes.has(index) && durableOwnsSettledItem(pending.item, item)));
237
+ if (exactIndex >= 0) {
238
+ claimedDurableIndexes.add(exactIndex);
239
+ covered.add(pendingId);
240
+ }
241
+ }
242
+ for (const [pendingId, pending] of pendingItems) {
243
+ if (covered.has(pendingId))
244
+ continue;
245
+ const pendingGoal = goalIdentity(pending.item);
246
+ if (!pendingGoal)
247
+ continue;
248
+ const candidates = durableItems.flatMap((item, index) => {
249
+ const durableGoal = goalIdentity(item);
250
+ return durableGoal && sameGoal(pendingGoal, durableGoal) ? [index] : [];
251
+ });
252
+ // The first N equivalent occurrences were already in the committed baseline when this live card
253
+ // settled. Allocate only a later occurrence, and allocate every durable occurrence at most once.
254
+ const candidate = candidates.slice(pending.baselineMatches)
255
+ .find((index) => !claimedDurableIndexes.has(index));
256
+ if (candidate === undefined)
257
+ continue;
258
+ claimedDurableIndexes.add(candidate);
259
+ covered.add(pendingId);
260
+ }
261
+ return [...covered];
262
+ }
263
+ function isRun(target) {
264
+ return 'runId' in target;
265
+ }
266
+ function viewId(sessionId) {
267
+ return `codex-thread:${sessionId}`;
268
+ }
269
+ function stableId(message, suffix = '') {
270
+ const base = typeof message.id === 'string' && message.id
271
+ ? message.id : `codex:line-${message.i}:${message.type}`;
272
+ return suffix ? `${base}:${suffix}` : base;
273
+ }
274
+ function sourceTime(value) {
275
+ if (typeof value !== 'string')
276
+ return undefined;
277
+ const parsed = Date.parse(value);
278
+ return Number.isFinite(parsed) ? parsed : undefined;
279
+ }
280
+ function json(value) {
281
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
282
+ return value;
283
+ if (typeof value === 'number')
284
+ return Number.isFinite(value) ? value : String(value);
285
+ if (Array.isArray(value))
286
+ return value.map(json);
287
+ if (value && typeof value === 'object') {
288
+ const out = {};
289
+ for (const [key, entry] of Object.entries(value)) {
290
+ if (entry !== undefined && typeof entry !== 'function' && typeof entry !== 'symbol')
291
+ out[key] = json(entry);
292
+ }
293
+ return out;
294
+ }
295
+ return String(value ?? '');
296
+ }
297
+ function itemBase(message, sessionId, id = stableId(message)) {
298
+ const createdAt = sourceTime(message.ts);
299
+ return {
300
+ id,
301
+ sessionId,
302
+ status: 'complete',
303
+ ...(createdAt === undefined ? {} : { sourceCreatedAt: createdAt }),
304
+ ...(typeof message.turnId === 'string' && message.turnId
305
+ ? { groupingId: message.turnId } : {}),
306
+ ...(typeof message.correlationId === 'string' && message.correlationId
307
+ ? { correlationId: message.correlationId } : {}),
308
+ };
309
+ }
310
+ function projectDurableMessage(message, sessionId) {
311
+ if (message.type === 'text' && message.role && typeof message.text === 'string') {
312
+ const output = clipConversationText(message.text);
313
+ return [{
314
+ ...itemBase(message, sessionId), kind: 'message', role: message.role,
315
+ content: [{ type: 'text', text: output.text }],
316
+ ...(output.truncated ? {
317
+ status: 'truncated',
318
+ truncation: { reason: 'size_limit', originalBytes: output.originalBytes },
319
+ } : {}),
320
+ }];
321
+ }
322
+ if (message.type === 'thinking' && typeof message.text === 'string') {
323
+ // Codex rollout stores the provider's public reasoning summary, not hidden reasoning content.
324
+ const output = clipConversationText(message.text);
325
+ return [{
326
+ ...itemBase(message, sessionId), kind: 'reasoning_summary', text: output.text,
327
+ ...(output.truncated ? {
328
+ status: 'truncated',
329
+ truncation: { reason: 'size_limit', originalBytes: output.originalBytes },
330
+ } : {}),
331
+ }];
332
+ }
333
+ if (message.type === 'tool' && message.tool) {
334
+ const callId = stableId(message, 'call');
335
+ const inputProjection = sanitizeToolInputWithMetadata(message.tool.input);
336
+ const input = inputProjection.value;
337
+ const name = clipConversationText(message.tool.name || 'tool', 512).text || 'tool';
338
+ const rawResult = message.tool.result === null || message.tool.result === undefined
339
+ ? null : String(message.tool.result);
340
+ const result = rawResult === null ? null : sanitizeToolResultText(rawResult);
341
+ const extensionInput = input !== null && typeof input === 'object' ? input : {};
342
+ const diffProjection = message.tool.diff === undefined
343
+ ? undefined : sanitizeToolInputWithMetadata(message.tool.diff);
344
+ const safeDiff = diffProjection?.value;
345
+ const toolProjectionBytes = inputProjection.originalBytes + (diffProjection?.originalBytes ?? 0);
346
+ const toolProjectionTruncated = inputProjection.truncated || diffProjection?.truncated === true;
347
+ const outcomes = ['running', 'success', 'failed', 'declined', 'completed'];
348
+ const outcome = typeof message.tool.outcome === 'string' && outcomes.includes(message.tool.outcome)
349
+ ? message.tool.outcome : undefined;
350
+ const items = [{
351
+ ...itemBase(message, sessionId, callId), kind: 'tool_call', callId,
352
+ name, ...(input === undefined ? {} : { input }),
353
+ ...(toolProjectionTruncated ? {
354
+ status: 'truncated',
355
+ truncation: { reason: 'size_limit', originalBytes: toolProjectionBytes },
356
+ } : {}),
357
+ extensions: {
358
+ 'conversation.tool': {
359
+ name, input: extensionInput, result: result?.text ?? null,
360
+ isError: message.tool.isError === true,
361
+ ...(outcome === undefined ? {} : { outcome }),
362
+ ...(safeDiff === undefined ? {} : { diff: safeDiff }),
363
+ },
364
+ },
365
+ }];
366
+ // A successful tool may legitimately produce no stdout. Do not manufacture an empty durable text
367
+ // block: Core intentionally rejects those because they have no user-visible content.
368
+ if (rawResult !== null && rawResult.length > 0 && result) {
369
+ items.push({
370
+ ...itemBase(message, sessionId, stableId(message, 'result')),
371
+ kind: 'tool_result', callId,
372
+ content: [{ type: 'text', text: result.text }],
373
+ ...(message.tool.isError ? { isError: true } : {}),
374
+ ...(result.truncated ? {
375
+ status: 'truncated',
376
+ truncation: { reason: 'size_limit', originalBytes: result.originalBytes },
377
+ } : {}),
378
+ });
379
+ }
380
+ const rawInput = message.tool.input && typeof message.tool.input === 'object'
381
+ && !Array.isArray(message.tool.input) ? message.tool.input : {};
382
+ if (message.tool.diff) {
383
+ const rawPath = rawInput.file_path ?? rawInput.path;
384
+ const path = safeProviderPathLabel(rawPath);
385
+ const patch = typeof rawInput.patch === 'string'
386
+ ? sanitizeToolResultText(rawInput.patch) : undefined;
387
+ items.push({
388
+ ...itemBase(message, sessionId, stableId(message, 'diff')),
389
+ kind: 'diff',
390
+ ...(path === undefined ? {} : { path }),
391
+ ...(patch === undefined ? {} : { patch: patch.text }),
392
+ summary: `+${message.tool.diff.added} -${message.tool.diff.removed}`,
393
+ ...(patch?.truncated || diffProjection?.truncated ? {
394
+ status: 'truncated',
395
+ truncation: {
396
+ reason: 'size_limit',
397
+ originalBytes: (patch?.originalBytes ?? 0) + (diffProjection?.originalBytes ?? 0),
398
+ },
399
+ } : {}),
400
+ });
401
+ }
402
+ return items;
403
+ }
404
+ if (message.type === 'compact') {
405
+ const summary = typeof message.summary === 'string' && message.summary
406
+ ? clipConversationText(message.summary) : null;
407
+ const truncated = message.summaryTruncated === true || summary?.truncated === true;
408
+ const originalBytes = typeof message.summaryOriginalBytes === 'number'
409
+ ? message.summaryOriginalBytes : summary?.originalBytes;
410
+ return [{
411
+ ...itemBase(message, sessionId), kind: 'compaction',
412
+ ...(summary ? { summary: summary.text } : {}),
413
+ ...(truncated ? {
414
+ status: 'truncated',
415
+ truncation: { reason: 'size_limit', originalBytes: originalBytes ?? 0 },
416
+ } : {}),
417
+ }];
418
+ }
419
+ if (message.type === 'interrupt') {
420
+ return [{ ...itemBase(message, sessionId), kind: 'interrupt', actor: 'user' }];
421
+ }
422
+ if (message.type === 'slash') {
423
+ return [{
424
+ ...itemBase(message, sessionId), kind: 'notice', level: 'info', code: 'slash_command',
425
+ message: `${message.name ?? '/command'}${message.args ? ` ${message.args}` : ''}`,
426
+ extensions: {
427
+ 'conversation.slash': json({
428
+ name: message.name ?? '/command',
429
+ ...(typeof message.args === 'string' ? { args: message.args } : {}),
430
+ ...(typeof message.result === 'string' ? { result: message.result } : {}),
431
+ }),
432
+ },
433
+ }];
434
+ }
435
+ if (message.type === 'plan') {
436
+ return [{
437
+ ...itemBase(message, sessionId), kind: 'notice', level: 'info', code: 'plan_updated',
438
+ message: typeof message.explanation === 'string' && message.explanation
439
+ ? message.explanation : 'Plan updated',
440
+ extensions: { 'conversation.plan': json(message.plan ?? []) },
441
+ }];
442
+ }
443
+ if (message.type === 'goal') {
444
+ const goal = message.goal && typeof message.goal === 'object' ? message.goal : {};
445
+ const objective = typeof goal.objective === 'string' ? goal.objective : 'Goal updated';
446
+ const status = typeof goal.status === 'string' ? goal.status : message.event ?? 'updated';
447
+ return [{
448
+ ...itemBase(message, sessionId), kind: 'notice', level: 'info', code: 'goal_updated',
449
+ message: `${objective} · ${status}`,
450
+ extensions: {
451
+ 'conversation.goal': json(goal),
452
+ ...(typeof message.event === 'string' ? { 'conversation.goalEvent': message.event } : {}),
453
+ },
454
+ }];
455
+ }
456
+ return [];
457
+ }
458
+ function liveDraft(message) {
459
+ const grouping = typeof message.turnId === 'string' && message.turnId
460
+ ? { groupingId: message.turnId } : {};
461
+ if (message.type === 'text') {
462
+ return {
463
+ kind: 'message', role: 'assistant',
464
+ content: [{ type: 'text', text: message.text ?? '' }],
465
+ ...grouping,
466
+ };
467
+ }
468
+ if (message.type === 'goal') {
469
+ const goal = message.goal && typeof message.goal === 'object'
470
+ ? message.goal : {};
471
+ const objective = typeof goal.objective === 'string' ? goal.objective : 'Goal updated';
472
+ const status = typeof goal.status === 'string' ? goal.status : 'updated';
473
+ return {
474
+ kind: 'notice', level: 'info', code: 'goal_updated', message: `${objective} · ${status}`,
475
+ ...grouping,
476
+ extensions: {
477
+ 'conversation.goal': json(goal),
478
+ ...(typeof message.event === 'string' ? { 'conversation.goalEvent': message.event } : {}),
479
+ },
480
+ };
481
+ }
482
+ return null;
483
+ }
484
+ function finalItem(provisionalId, sessionId, draft) {
485
+ return { id: provisionalId, sessionId, status: 'complete', ...draft };
486
+ }
487
+ export function createCodexConversationAdapter({ app, sessionsRoot = sessionsDir(), reader = transcriptReader, findRollout = resolveCodexRollout, rolloutSize = async (file) => {
488
+ try {
489
+ const snapshot = await stat(file);
490
+ return snapshot.isFile() ? snapshot.size : null;
491
+ }
492
+ catch {
493
+ return null;
494
+ }
495
+ }, durablePollMs = 500, durableSettleTimeoutMs = DEFAULT_DURABLE_SETTLE_TIMEOUT_MS, }) {
496
+ if (!app || typeof app.discover !== 'function' || typeof app.observeConversation !== 'function') {
497
+ throw new TypeError('Codex Conversation adapter requires App Server');
498
+ }
499
+ if (!Number.isFinite(durablePollMs) || durablePollMs <= 0) {
500
+ throw new TypeError('Codex Conversation adapter requires a positive durable poll interval');
501
+ }
502
+ if (!Number.isFinite(durableSettleTimeoutMs) || durableSettleTimeoutMs <= 0) {
503
+ throw new TypeError('Codex Conversation adapter requires a positive durable settle timeout');
504
+ }
505
+ if (!reader.readPrefix) {
506
+ throw new TypeError('Codex Conversation adapter requires immutable transcript prefix reads');
507
+ }
508
+ const readPrefix = reader.readPrefix.bind(reader);
509
+ const durableCheckpoints = new Map();
510
+ const durablyOwnedGoalLifecycleIds = new Set();
511
+ // Core reads its authoritative opening page immediately after observeNative returns. Freeze that one
512
+ // read at the same byte cutoff as the adapter baseline so later rollout suffixes cannot appear in both
513
+ // the page and the already-buffered native stream.
514
+ const openingSnapshots = new Map();
515
+ function cloneCheckpoint(checkpoint) {
516
+ return {
517
+ ...checkpoint,
518
+ itemOffsets: [...checkpoint.itemOffsets],
519
+ historyHashes: [...checkpoint.historyHashes],
520
+ snapshot: { ...checkpoint.snapshot, items: [...checkpoint.snapshot.items] },
521
+ };
522
+ }
523
+ function rememberCheckpoint(sessionId, checkpoint) {
524
+ durableCheckpoints.delete(sessionId);
525
+ durableCheckpoints.set(sessionId, checkpoint);
526
+ while (durableCheckpoints.size > MAX_DURABLE_CHECKPOINTS) {
527
+ const oldest = durableCheckpoints.keys().next().value;
528
+ if (oldest === undefined)
529
+ break;
530
+ durableCheckpoints.delete(oldest);
531
+ }
532
+ }
533
+ function snapshotFromMessages(session, parsed, file, version, readerGeneration, changedFrom, checkpoint) {
534
+ const sameReader = readerGeneration === undefined || checkpoint?.readerGeneration === undefined
535
+ || checkpoint.readerGeneration === readerGeneration;
536
+ const reusable = checkpoint?.file === file && sameReader && changedFrom !== null
537
+ && changedFrom >= 0 && changedFrom <= checkpoint.messageCount
538
+ && changedFrom <= parsed.length;
539
+ // Build transactionally. A backwards/truncated read must not corrupt the last readable checkpoint
540
+ // before the floor check rejects it.
541
+ const next = reusable && checkpoint ? cloneCheckpoint(checkpoint) : undefined;
542
+ const from = next ? changedFrom : 0;
543
+ const durableFloor = checkpoint?.snapshot.items.length ?? 0;
544
+ const items = next?.snapshot.items ?? [];
545
+ const itemOffsets = next?.itemOffsets ?? [0];
546
+ const historyHashes = next?.historyHashes ?? [];
547
+ const prefixItems = itemOffsets[from] ?? 0;
548
+ items.length = prefixItems;
549
+ itemOffsets.length = from + 1;
550
+ historyHashes.length = from;
551
+ let historyHash = historyHashes.at(-1) ?? HISTORY_HASH_SEED;
552
+ for (let index = from; index < parsed.length; index++) {
553
+ const projected = projectDurableMessage(parsed[index], session.sessionId);
554
+ items.push(...projected);
555
+ itemOffsets.push(items.length);
556
+ historyHash = createHash('sha256')
557
+ .update(historyHash)
558
+ .update('\0')
559
+ .update(JSON.stringify(projected))
560
+ .digest('hex');
561
+ historyHashes.push(historyHash);
562
+ }
563
+ if (items.length < durableFloor) {
564
+ throw new Error('Codex durable rollout projection moved backwards');
565
+ }
566
+ return {
567
+ file,
568
+ ...(version === undefined ? {} : { version }),
569
+ ...(readerGeneration === undefined ? {} : { readerGeneration }),
570
+ messageCount: parsed.length,
571
+ itemOffsets,
572
+ historyHashes,
573
+ snapshot: {
574
+ sessionId: session.sessionId,
575
+ sourceViewId: viewId(session.sessionId),
576
+ sourceHistoryToken: historyHash,
577
+ items,
578
+ },
579
+ };
580
+ }
581
+ async function readDurableSnapshot(session) {
582
+ const file = await findRollout(sessionsRoot, session.sessionId);
583
+ const read = file && reader.readSnapshot
584
+ ? await reader.readSnapshot(file, createCodexTranscriptParser)
585
+ : {
586
+ messages: file ? await reader.read(file, createCodexTranscriptParser) : [],
587
+ version: undefined,
588
+ changedFrom: 0,
589
+ generation: undefined,
590
+ };
591
+ const known = durableCheckpoints.get(session.sessionId);
592
+ if (known && known.file === file && read.version !== undefined && known.version === read.version) {
593
+ rememberCheckpoint(session.sessionId, known);
594
+ return known.snapshot;
595
+ }
596
+ const sameReader = read.generation === undefined || known?.readerGeneration === undefined
597
+ || known.readerGeneration === read.generation;
598
+ if (known && known.file === file && sameReader && read.changedFrom === null) {
599
+ if (read.version === undefined)
600
+ delete known.version;
601
+ else
602
+ known.version = read.version;
603
+ rememberCheckpoint(session.sessionId, known);
604
+ return known.snapshot;
605
+ }
606
+ const checkpoint = snapshotFromMessages(session, read.messages, file, read.version, read.generation, read.changedFrom, known);
607
+ rememberCheckpoint(session.sessionId, checkpoint);
608
+ return checkpoint.snapshot;
609
+ }
610
+ async function captureDurableCutoff(sessionId) {
611
+ const file = await findRollout(sessionsRoot, sessionId);
612
+ if (!file)
613
+ return { file: null, endExclusive: 0 };
614
+ const endExclusive = await rolloutSize(file);
615
+ return typeof endExclusive === 'number' && Number.isSafeInteger(endExclusive) && endExclusive >= 0
616
+ ? { file, endExclusive }
617
+ : { file: null, endExclusive: 0 };
618
+ }
619
+ async function readDurablePrefix(session, cutoff) {
620
+ const known = durableCheckpoints.get(session.sessionId);
621
+ const working = known ? cloneCheckpoint(known) : undefined;
622
+ const read = cutoff.file && reader.readPrefixSnapshot
623
+ ? await reader.readPrefixSnapshot(cutoff.file, cutoff.endExclusive, createCodexTranscriptParser)
624
+ : {
625
+ messages: cutoff.file
626
+ ? await readPrefix(cutoff.file, cutoff.endExclusive, createCodexTranscriptParser) : [],
627
+ version: undefined,
628
+ changedFrom: 0,
629
+ generation: undefined,
630
+ };
631
+ const checkpoint = working && working.file === cutoff.file
632
+ && read.version !== undefined && working.version === read.version
633
+ ? working
634
+ : snapshotFromMessages(session, read.messages, cutoff.file, read.version, read.generation, read.changedFrom, working);
635
+ return {
636
+ checkpoint,
637
+ snapshot: { ...checkpoint.snapshot, items: [...checkpoint.snapshot.items] },
638
+ };
639
+ }
640
+ async function readPage(session, request) {
641
+ const opening = request.beforeSourceCursor === undefined
642
+ ? openingSnapshots.get(session.sessionId) : undefined;
643
+ const snapshot = opening?.snapshot ?? await readDurableSnapshot(session);
644
+ let end = snapshot.items.length;
645
+ if (request.beforeSourceCursor !== undefined) {
646
+ if (!/^\d+$/.test(request.beforeSourceCursor))
647
+ throw new Error('Invalid Codex source cursor');
648
+ const cursor = Number(request.beforeSourceCursor);
649
+ if (!Number.isSafeInteger(cursor))
650
+ throw new Error('Invalid Codex source cursor');
651
+ end = Math.min(cursor, snapshot.items.length);
652
+ }
653
+ const start = Math.max(0, end - request.limit);
654
+ const page = {
655
+ sessionId: snapshot.sessionId,
656
+ sourceViewId: snapshot.sourceViewId,
657
+ sourceHistoryToken: snapshot.sourceHistoryToken,
658
+ items: snapshot.items.slice(start, end),
659
+ ...(start > 0 ? { previousSourceCursor: String(start) } : {}),
660
+ hasMore: start > 0,
661
+ };
662
+ if (opening && openingSnapshots.get(session.sessionId) === opening) {
663
+ openingSnapshots.delete(session.sessionId);
664
+ opening.release();
665
+ }
666
+ return page;
667
+ }
668
+ return {
669
+ apiVersion: 1,
670
+ async discoverNative(target) {
671
+ if (target.agentId !== 'codex' || !target.sessionId)
672
+ return null;
673
+ if (isRun(target)) {
674
+ const discovered = await app.discover(target.paneId);
675
+ if (!discovered?.managed || discovered.threadId !== target.sessionId)
676
+ return null;
677
+ return {
678
+ session: { agentId: 'codex', sessionId: target.sessionId },
679
+ run: target,
680
+ sourceViewId: viewId(target.sessionId),
681
+ capabilities: {
682
+ history: true, live: 'delta', sendable: true, steer: true,
683
+ send: ['prompt'], interrupt: true,
684
+ },
685
+ };
686
+ }
687
+ const file = await findRollout(sessionsRoot, target.sessionId);
688
+ if (!file)
689
+ return null;
690
+ return {
691
+ session: target,
692
+ sourceViewId: viewId(target.sessionId),
693
+ capabilities: { history: true, live: 'poll' },
694
+ };
695
+ },
696
+ readNativePage: readPage,
697
+ async observeNative(run, sink) {
698
+ if (!run.ref.sessionId)
699
+ throw new Error('Codex run has no thread');
700
+ const sessionId = run.ref.sessionId;
701
+ const knownCheckpoint = durableCheckpoints.get(sessionId);
702
+ const live = {
703
+ sink, sourceSequence: 0, closed: false, provisional: new Map(),
704
+ pendingDurableItems: new Map(),
705
+ observedTurnIds: new Set(),
706
+ tail: Promise.resolve(), closeNative: undefined, durableToken: undefined,
707
+ durableItems: [],
708
+ durableCandidateToken: undefined, durableCandidateAt: undefined,
709
+ pollTimer: undefined, pollDueAt: undefined,
710
+ openingPagePending: true,
711
+ };
712
+ const emit = async (event) => {
713
+ if (live.closed)
714
+ return;
715
+ await sink({ ...event, sourceSequence: ++live.sourceSequence });
716
+ };
717
+ const closeLive = async () => {
718
+ if (live.closed)
719
+ return;
720
+ live.closed = true;
721
+ if (live.pollTimer)
722
+ clearTimeout(live.pollTimer);
723
+ live.pollTimer = undefined;
724
+ live.pollDueAt = undefined;
725
+ live.durableCandidateToken = undefined;
726
+ live.durableCandidateAt = undefined;
727
+ live.provisional.clear();
728
+ live.pendingDurableItems.clear();
729
+ live.observedTurnIds.clear();
730
+ live.durableItems = [];
731
+ const opening = openingSnapshots.get(sessionId);
732
+ if (opening?.owner === live)
733
+ openingSnapshots.delete(sessionId);
734
+ await live.closeNative?.();
735
+ };
736
+ const failClosed = async () => {
737
+ if (live.closed)
738
+ return;
739
+ try {
740
+ await emit({ type: 'stream.gap', afterSourceSequence: live.sourceSequence });
741
+ }
742
+ catch { /* the sink itself is already unavailable */ }
743
+ await closeLive();
744
+ };
745
+ const reconcileDurable = async (retryReadFailure) => {
746
+ if (live.closed || live.openingPagePending)
747
+ return;
748
+ const deadlineReached = (pending, now) => (waitsForNextTurn(pending.item)
749
+ ? pending.uncoveredAdvanceAt !== undefined
750
+ && now - pending.uncoveredAdvanceAt >= durableSettleTimeoutMs
751
+ : now - pending.settledAt >= durableSettleTimeoutMs);
752
+ let page;
753
+ try {
754
+ page = await readDurableSnapshot({ agentId: 'codex', sessionId });
755
+ }
756
+ catch (error) {
757
+ // A writer can transiently rotate or replace the rollout. Periodic reconciliation retries
758
+ // read failures; the initial baseline still fails the open because Core has no safe checkpoint.
759
+ if (retryReadFailure) {
760
+ if ([...live.pendingDurableItems.values()].some((pending) => (deadlineReached(pending, Date.now()))))
761
+ throw new Error('Codex settled item did not become durable');
762
+ live.durableCandidateToken = undefined;
763
+ live.durableCandidateAt = undefined;
764
+ return;
765
+ }
766
+ throw error;
767
+ }
768
+ if (live.closed)
769
+ return;
770
+ const now = Date.now();
771
+ if (live.durableToken === undefined) {
772
+ live.durableToken = page.sourceHistoryToken;
773
+ live.durableItems = [...page.items];
774
+ live.durableCandidateToken = undefined;
775
+ live.durableCandidateAt = undefined;
776
+ return;
777
+ }
778
+ let coveredPendingIds = [];
779
+ const rememberCoveredGoals = (ids) => {
780
+ for (const id of ids) {
781
+ const pending = live.pendingDurableItems.get(id);
782
+ if (pending && goalIdentity(pending.item)) {
783
+ rememberGoalLifecycle(durablyOwnedGoalLifecycleIds, sessionId, id);
784
+ }
785
+ }
786
+ };
787
+ if (live.pendingDurableItems.size > 0) {
788
+ coveredPendingIds = coveredPendingItemIds(live.pendingDurableItems, page.items);
789
+ // A settled live item remains part of the visible frontier until an equivalent durable item
790
+ // is readable. Publishing an earlier user/tool-only token would erase that reply on page seed.
791
+ if (coveredPendingIds.length !== live.pendingDurableItems.size) {
792
+ const covered = new Set(coveredPendingIds);
793
+ let expired = false;
794
+ for (const [id, pending] of live.pendingDurableItems) {
795
+ if (covered.has(id))
796
+ continue;
797
+ if (waitsForNextTurn(pending.item)) {
798
+ const nextTurnVisible = [...live.observedTurnIds].some((turnId) => (!pending.baselineTurnIds.has(turnId))) || page.items.some((item) => (typeof item.groupingId === 'string' && item.groupingId
799
+ && !pending.baselineTurnIds.has(item.groupingId)));
800
+ if (!nextTurnVisible) {
801
+ delete pending.uncoveredAdvanceAt;
802
+ continue;
803
+ }
804
+ pending.uncoveredAdvanceAt ??= now;
805
+ }
806
+ if (deadlineReached(pending, now))
807
+ expired = true;
808
+ }
809
+ // An item that never becomes durable must not poison every later history token forever.
810
+ // An active Goal starts this deadline only after a new native turn or durable correlation;
811
+ // the current turn may keep appending long after the Goal was set. Fail closed via Core's gap.
812
+ if (expired)
813
+ throw new Error('Codex settled item did not become durable');
814
+ live.durableCandidateToken = undefined;
815
+ live.durableCandidateAt = undefined;
816
+ return;
817
+ }
818
+ }
819
+ if (page.sourceHistoryToken === live.durableToken) {
820
+ rememberCoveredGoals(coveredPendingIds);
821
+ for (const id of coveredPendingIds)
822
+ live.pendingDurableItems.delete(id);
823
+ live.durableCandidateToken = undefined;
824
+ live.durableCandidateAt = undefined;
825
+ return;
826
+ }
827
+ // Do not advance the durable barrier while a provisional reply is still streaming: the page seed
828
+ // would otherwise replace a live frontier whose remaining native deltas are not durable yet.
829
+ if (live.provisional.size > 0) {
830
+ live.durableCandidateToken = undefined;
831
+ live.durableCandidateAt = undefined;
832
+ return;
833
+ }
834
+ // Require the same readable token twice. Codex appends several adjacent rollout records per
835
+ // action; this quiet-read barrier avoids publishing a token that changes before Core verifies it.
836
+ if (live.durableCandidateToken !== page.sourceHistoryToken) {
837
+ live.durableCandidateToken = page.sourceHistoryToken;
838
+ live.durableCandidateAt = Date.now();
839
+ return;
840
+ }
841
+ if (live.durableCandidateAt === undefined
842
+ || Date.now() - live.durableCandidateAt < DURABLE_QUIET_MS)
843
+ return;
844
+ live.durableToken = page.sourceHistoryToken;
845
+ live.durableItems = [...page.items];
846
+ rememberCoveredGoals(coveredPendingIds);
847
+ for (const id of coveredPendingIds)
848
+ live.pendingDurableItems.delete(id);
849
+ live.durableCandidateToken = undefined;
850
+ live.durableCandidateAt = undefined;
851
+ await emit({
852
+ type: 'history.committed', sourceViewId: page.sourceViewId,
853
+ sourceHistoryToken: page.sourceHistoryToken,
854
+ });
855
+ };
856
+ const armDurablePoll = (delay = durablePollMs) => {
857
+ if (live.closed || live.openingPagePending)
858
+ return;
859
+ const dueAt = Date.now() + delay;
860
+ if (live.pollTimer && live.pollDueAt !== undefined && live.pollDueAt <= dueAt)
861
+ return;
862
+ if (live.pollTimer)
863
+ clearTimeout(live.pollTimer);
864
+ live.pollDueAt = dueAt;
865
+ live.pollTimer = setTimeout(() => {
866
+ live.pollTimer = undefined;
867
+ live.pollDueAt = undefined;
868
+ const pending = live.tail.then(() => reconcileDurable(true));
869
+ live.tail = pending.catch(failClosed);
870
+ void live.tail.finally(() => {
871
+ armDurablePoll(live.durableCandidateToken ? DURABLE_QUIET_MS : durablePollMs);
872
+ }).catch(() => { });
873
+ }, delay);
874
+ live.pollTimer.unref?.();
875
+ };
876
+ const process = async (value, suppressMutation = false) => {
877
+ const event = parseCodexStreamEvent(value);
878
+ if (!event || live.closed)
879
+ return;
880
+ if ('turnId' in event && typeof event.turnId === 'string' && event.turnId) {
881
+ live.observedTurnIds.add(event.turnId);
882
+ const observedAt = Date.now();
883
+ for (const pending of live.pendingDurableItems.values()) {
884
+ if (waitsForNextTurn(pending.item) && !pending.baselineTurnIds.has(event.turnId)) {
885
+ pending.uncoveredAdvanceAt ??= observedAt;
886
+ }
887
+ }
888
+ }
889
+ if (event.type === 'cursorReset' || event.type === 'disconnected' || event.type === 'error') {
890
+ await emit({ type: 'stream.gap', afterSourceSequence: live.sourceSequence });
891
+ return;
892
+ }
893
+ if (event.type === 'conversationSnapshot') {
894
+ await reconcileDurable(true);
895
+ armDurablePoll(DURABLE_QUIET_MS);
896
+ return;
897
+ }
898
+ if (event.type === 'turnCompleted') {
899
+ for (const [provisionalId, current] of live.provisional) {
900
+ if (current.turnId !== event.turnId)
901
+ continue;
902
+ live.provisional.delete(provisionalId);
903
+ await emit({
904
+ type: 'item.cancelled', provisionalId,
905
+ reason: event.status === 'interrupted' ? 'interrupted' : 'stream_reset',
906
+ });
907
+ }
908
+ await reconcileDurable(true);
909
+ armDurablePoll(DURABLE_QUIET_MS);
910
+ return;
911
+ }
912
+ if (event.type === 'goalCleared') {
913
+ for (const [id, pending] of live.pendingDurableItems) {
914
+ if (waitsForNextTurn(pending.item) && pending.uncoveredAdvanceAt === undefined) {
915
+ live.pendingDurableItems.delete(id);
916
+ }
917
+ }
918
+ await reconcileDurable(true);
919
+ armDurablePoll(DURABLE_QUIET_MS);
920
+ return;
921
+ }
922
+ if (event.type === 'ready')
923
+ return;
924
+ const mutation = projectCodexConversationMutation(event);
925
+ if (!mutation || mutation.operation !== 'upsert')
926
+ return;
927
+ const message = mutation.message;
928
+ const draft = liveDraft(message);
929
+ if (!draft)
930
+ return;
931
+ const provisionalId = message.id;
932
+ const current = live.provisional.get(provisionalId);
933
+ const completedItem = message.completed ? finalItem(provisionalId, sessionId, draft) : null;
934
+ if (completedItem && goalIdentity(completedItem)) {
935
+ // App Server has only one current Goal. A later Goal lifecycle supersedes any older active card
936
+ // that never reached rollout, otherwise the impossible old occurrence blocks every future token.
937
+ for (const [id, pending] of live.pendingDurableItems) {
938
+ if (id !== provisionalId && waitsForNextTurn(pending.item)
939
+ && pending.uncoveredAdvanceAt === undefined) {
940
+ live.pendingDurableItems.delete(id);
941
+ }
942
+ }
943
+ }
944
+ if (suppressMutation)
945
+ return;
946
+ if (completedItem
947
+ && live.durableItems.some((item) => durableOwnsSettledItem(completedItem, item))) {
948
+ if (goalIdentity(completedItem)) {
949
+ rememberGoalLifecycle(durablyOwnedGoalLifecycleIds, sessionId, provisionalId);
950
+ }
951
+ // The durable baseline can race ahead of a native event with the same stable item identity.
952
+ // The page already owns that position, so do not append a redundant live card.
953
+ if (current) {
954
+ live.provisional.delete(provisionalId);
955
+ await emit({ type: 'item.cancelled', provisionalId, reason: 'superseded' });
956
+ }
957
+ return;
958
+ }
959
+ if (!current) {
960
+ live.provisional.set(provisionalId, { draft, turnId: message.turnId });
961
+ await emit({ type: 'item.opened', provisionalId, draft });
962
+ }
963
+ else if (mutation.mode === 'append' && message.type === 'text') {
964
+ await emit({
965
+ type: 'item.delta', provisionalId,
966
+ delta: { op: 'text.append', target: 'message.content', blockIndex: 0, text: message.text },
967
+ });
968
+ }
969
+ else {
970
+ live.provisional.set(provisionalId, { draft, turnId: message.turnId });
971
+ await emit({ type: 'item.delta', provisionalId, delta: { op: 'item.replace', draft } });
972
+ }
973
+ if (message.completed) {
974
+ const settled = live.provisional.get(provisionalId)?.draft ?? draft;
975
+ live.provisional.delete(provisionalId);
976
+ const item = finalItem(provisionalId, sessionId, settled);
977
+ live.pendingDurableItems.set(provisionalId, {
978
+ item,
979
+ baselineMatches: durableMatchCount(item, live.durableItems),
980
+ settledAt: Date.now(),
981
+ baselineTurnIds: new Set([
982
+ ...live.observedTurnIds,
983
+ ...live.durableItems.flatMap((durable) => (durable.groupingId ? [durable.groupingId] : [])),
984
+ ]),
985
+ });
986
+ await emit({
987
+ type: 'item.settled', provisionalId, durableItemId: provisionalId,
988
+ item,
989
+ });
990
+ await reconcileDurable(true);
991
+ armDurablePoll(DURABLE_QUIET_MS);
992
+ }
993
+ };
994
+ const enqueueNative = (event, suppressMutation = false) => {
995
+ live.tail = live.tail.then(() => process(event, suppressMutation)).catch(failClosed);
996
+ };
997
+ // The native observer must exist before the durable cutoff/read, but no event may calculate its
998
+ // Goal occurrence baseline until that prefix finishes. Buffer this short opening window explicitly.
999
+ let opening = true;
1000
+ let openingOverflow = false;
1001
+ const openingEvents = [];
1002
+ try {
1003
+ const native = await app.observeConversation(run.ref.paneId, sessionId, (event) => {
1004
+ if (live.closed)
1005
+ return;
1006
+ if (opening) {
1007
+ if (openingEvents.length >= MAX_OPENING_NATIVE_EVENTS)
1008
+ openingOverflow = true;
1009
+ else
1010
+ openingEvents.push(event);
1011
+ return;
1012
+ }
1013
+ enqueueNative(event);
1014
+ });
1015
+ live.closeNative = native.close;
1016
+ if (live.closed) {
1017
+ await native.close();
1018
+ throw new Error('Codex Conversation observation failed while opening');
1019
+ }
1020
+ if (openingOverflow)
1021
+ throw new Error('Codex Conversation opening event buffer overflowed');
1022
+ // Capture the append-only byte boundary only after the observer owns the native suffix. Anything
1023
+ // appended later belongs to that buffered suffix or the first post-open durable reconciliation.
1024
+ const cutoff = await captureDurableCutoff(sessionId);
1025
+ const openingRead = await readDurablePrefix({ agentId: 'codex', sessionId }, cutoff);
1026
+ const baseline = openingRead.snapshot;
1027
+ live.durableToken = baseline.sourceHistoryToken;
1028
+ live.durableItems = [...baseline.items];
1029
+ if (openingOverflow)
1030
+ throw new Error('Codex Conversation opening event buffer overflowed');
1031
+ const knownLength = trustedCheckpointLength(knownCheckpoint, cutoff, baseline);
1032
+ const openingSuppression = suppressedOpeningEventIndexes(openingEvents, sessionId, baseline, knownLength, durablyOwnedGoalLifecycleIds);
1033
+ for (const id of openingSuppression.durablyOwnedGoalIds) {
1034
+ rememberGoalLifecycle(durablyOwnedGoalLifecycleIds, sessionId, id);
1035
+ }
1036
+ rememberCheckpoint(sessionId, openingRead.checkpoint);
1037
+ opening = false;
1038
+ openingEvents.forEach((event, index) => (enqueueNative(event, openingSuppression.suppressed.has(index))));
1039
+ openingEvents.length = 0;
1040
+ await live.tail;
1041
+ if (live.closed)
1042
+ throw new Error('Codex Conversation observation failed while opening');
1043
+ const release = () => {
1044
+ if (live.closed || !live.openingPagePending)
1045
+ return;
1046
+ live.openingPagePending = false;
1047
+ armDurablePoll(0);
1048
+ };
1049
+ openingSnapshots.set(sessionId, { owner: live, snapshot: baseline, release });
1050
+ }
1051
+ catch (error) {
1052
+ opening = false;
1053
+ openingEvents.length = 0;
1054
+ await closeLive();
1055
+ throw error;
1056
+ }
1057
+ return {
1058
+ checkpoint: { sourceViewId: viewId(sessionId), sourceSequence: 0 },
1059
+ close: closeLive,
1060
+ };
1061
+ },
1062
+ async dispatchPrompt(run, request) {
1063
+ if (!run.ref.sessionId) {
1064
+ return { outcome: 'rejected', nativeMutation: false, reason: 'invalid_request' };
1065
+ }
1066
+ const result = await app.dispatchPrompt(run.ref.paneId, run.ref.sessionId, request.text, request.clientRequestId);
1067
+ const record = result && typeof result === 'object' ? result : {};
1068
+ if (record.busy === true)
1069
+ return { outcome: 'busy', nativeMutation: false };
1070
+ const body = record.result && typeof record.result === 'object'
1071
+ ? record.result : {};
1072
+ const turn = body.turn && typeof body.turn === 'object'
1073
+ ? body.turn : body;
1074
+ return {
1075
+ outcome: 'accepted',
1076
+ ...(typeof turn.id === 'string' ? { nativeId: turn.id } : {}),
1077
+ };
1078
+ },
1079
+ async dispatchSteer(run, request) {
1080
+ if (!run.ref.sessionId) {
1081
+ return { outcome: 'rejected', nativeMutation: false, reason: 'invalid_request' };
1082
+ }
1083
+ const result = await app.dispatchSteer(run.ref.paneId, run.ref.sessionId, request.text, request.clientRequestId, request.plan);
1084
+ const record = result && typeof result === 'object' ? result : {};
1085
+ if (record.busy === true)
1086
+ return { outcome: 'busy', nativeMutation: false };
1087
+ const body = record.result && typeof record.result === 'object'
1088
+ ? record.result : {};
1089
+ const turn = body.turn && typeof body.turn === 'object'
1090
+ ? body.turn : body;
1091
+ return { outcome: 'accepted', ...(typeof turn.id === 'string' ? { nativeId: turn.id } : {}) };
1092
+ },
1093
+ async dispatchInterrupt(run) {
1094
+ if (!run.ref.sessionId)
1095
+ return { status: 'rejected', reason: 'invalid_request' };
1096
+ const result = await app.interrupt(run.ref.paneId, run.ref.sessionId);
1097
+ const record = result && typeof result === 'object' ? result : {};
1098
+ return record.interrupted === true
1099
+ ? { status: 'accepted' }
1100
+ : { status: 'rejected', reason: 'provider_rejected' };
1101
+ },
1102
+ };
1103
+ }