tailii-host 0.1.1 → 0.3.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 (348) hide show
  1. package/README.md +27 -10
  2. package/dist/{broker.d.ts → approval/broker.d.ts} +9 -5
  3. package/dist/{broker.js → approval/broker.js} +42 -6
  4. package/dist/approval/broker.js.map +1 -0
  5. package/dist/{hook.d.ts → approval/hook.d.ts} +48 -7
  6. package/dist/{hook.js → approval/hook.js} +359 -50
  7. package/dist/approval/hook.js.map +1 -0
  8. package/dist/backend/herdr.d.ts +226 -0
  9. package/dist/backend/herdr.js +749 -0
  10. package/dist/backend/herdr.js.map +1 -0
  11. package/dist/backend/sessionBackend.d.ts +81 -0
  12. package/dist/backend/sessionBackend.js +114 -0
  13. package/dist/backend/sessionBackend.js.map +1 -0
  14. package/dist/backend/tmux.d.ts +125 -0
  15. package/dist/backend/tmux.js +392 -0
  16. package/dist/backend/tmux.js.map +1 -0
  17. package/dist/{chatTailController.d.ts → chat/chatTailController.d.ts} +29 -6
  18. package/dist/{chatTailController.js → chat/chatTailController.js} +94 -18
  19. package/dist/chat/chatTailController.js.map +1 -0
  20. package/dist/{imageService.d.ts → chat/imageService.d.ts} +12 -1
  21. package/dist/{imageService.js → chat/imageService.js} +39 -11
  22. package/dist/chat/imageService.js.map +1 -0
  23. package/dist/chat/remoteQuestionMonitor.d.ts +27 -0
  24. package/dist/chat/remoteQuestionMonitor.js +128 -0
  25. package/dist/chat/remoteQuestionMonitor.js.map +1 -0
  26. package/dist/chat/subagentTailer.d.ts +30 -0
  27. package/dist/chat/subagentTailer.js +704 -0
  28. package/dist/chat/subagentTailer.js.map +1 -0
  29. package/dist/chat/subagentTranscript.d.ts +20 -0
  30. package/dist/chat/subagentTranscript.js +273 -0
  31. package/dist/chat/subagentTranscript.js.map +1 -0
  32. package/dist/{transcriptTailer.d.ts → chat/transcriptTailer.d.ts} +12 -1
  33. package/dist/{transcriptTailer.js → chat/transcriptTailer.js} +183 -10
  34. package/dist/chat/transcriptTailer.js.map +1 -0
  35. package/dist/cli.js +15 -10
  36. package/dist/cli.js.map +1 -1
  37. package/dist/codex/codexAccountUsage.d.ts +22 -0
  38. package/dist/codex/codexAccountUsage.js +128 -0
  39. package/dist/codex/codexAccountUsage.js.map +1 -0
  40. package/dist/codex/codexAppServer.d.ts +166 -0
  41. package/dist/codex/codexAppServer.js +1297 -0
  42. package/dist/codex/codexAppServer.js.map +1 -0
  43. package/dist/codex/codexNativeTurnController.d.ts +153 -0
  44. package/dist/codex/codexNativeTurnController.js +673 -0
  45. package/dist/codex/codexNativeTurnController.js.map +1 -0
  46. package/dist/codex/codexRolloutTailer.d.ts +100 -0
  47. package/dist/codex/codexRolloutTailer.js +568 -0
  48. package/dist/codex/codexRolloutTailer.js.map +1 -0
  49. package/dist/{codexSessionStore.d.ts → codex/codexSessionStore.d.ts} +10 -2
  50. package/dist/{codexSessionStore.js → codex/codexSessionStore.js} +184 -15
  51. package/dist/codex/codexSessionStore.js.map +1 -0
  52. package/dist/codex/codexSubagentTracker.d.ts +21 -0
  53. package/dist/codex/codexSubagentTracker.js +281 -0
  54. package/dist/codex/codexSubagentTracker.js.map +1 -0
  55. package/dist/codex/codexToolActivity.d.ts +69 -0
  56. package/dist/codex/codexToolActivity.js +425 -0
  57. package/dist/codex/codexToolActivity.js.map +1 -0
  58. package/dist/{codexUsage.d.ts → codex/codexUsage.d.ts} +1 -1
  59. package/dist/{codexUsage.js → codex/codexUsage.js} +19 -26
  60. package/dist/codex/codexUsage.js.map +1 -0
  61. package/dist/commands/doctor.d.ts +65 -0
  62. package/dist/commands/doctor.js +425 -0
  63. package/dist/commands/doctor.js.map +1 -0
  64. package/dist/{hookSettings.d.ts → commands/hookSettings.d.ts} +14 -1
  65. package/dist/{hookSettings.js → commands/hookSettings.js} +96 -12
  66. package/dist/commands/hookSettings.js.map +1 -0
  67. package/dist/{kick.js → commands/kick.js} +16 -1
  68. package/dist/commands/kick.js.map +1 -0
  69. package/dist/{launch.d.ts → commands/launch.d.ts} +67 -5
  70. package/dist/commands/launch.js +716 -0
  71. package/dist/commands/launch.js.map +1 -0
  72. package/dist/commands/legacyHomeMigration.js.map +1 -0
  73. package/dist/{pairingCode.d.ts → commands/pairingCode.d.ts} +29 -2
  74. package/dist/{pairingCode.js → commands/pairingCode.js} +120 -11
  75. package/dist/commands/pairingCode.js.map +1 -0
  76. package/dist/{pushTokenCommand.d.ts → commands/pushTokenCommand.d.ts} +1 -1
  77. package/dist/{pushTokenCommand.js → commands/pushTokenCommand.js} +2 -2
  78. package/dist/commands/pushTokenCommand.js.map +1 -0
  79. package/dist/{setup.d.ts → commands/setup.d.ts} +17 -6
  80. package/dist/{setup.js → commands/setup.js} +133 -75
  81. package/dist/commands/setup.js.map +1 -0
  82. package/dist/engine/context.d.ts +145 -0
  83. package/dist/engine/context.js +184 -0
  84. package/dist/engine/context.js.map +1 -0
  85. package/dist/engine/engine.d.ts +121 -0
  86. package/dist/engine/engine.js +799 -0
  87. package/dist/engine/engine.js.map +1 -0
  88. package/dist/engine/handlers/accountUsage.d.ts +53 -0
  89. package/dist/engine/handlers/accountUsage.js +203 -0
  90. package/dist/engine/handlers/accountUsage.js.map +1 -0
  91. package/dist/engine/handlers/claudeModels.d.ts +2 -0
  92. package/dist/engine/handlers/claudeModels.js +23 -0
  93. package/dist/engine/handlers/claudeModels.js.map +1 -0
  94. package/dist/engine/handlers/codex.d.ts +2 -0
  95. package/dist/engine/handlers/codex.js +97 -0
  96. package/dist/engine/handlers/codex.js.map +1 -0
  97. package/dist/engine/handlers/conversation.d.ts +2 -0
  98. package/dist/engine/handlers/conversation.js +259 -0
  99. package/dist/engine/handlers/conversation.js.map +1 -0
  100. package/dist/engine/handlers/core.d.ts +2 -0
  101. package/dist/engine/handlers/core.js +66 -0
  102. package/dist/engine/handlers/core.js.map +1 -0
  103. package/dist/engine/handlers/git.d.ts +2 -0
  104. package/dist/engine/handlers/git.js +127 -0
  105. package/dist/engine/handlers/git.js.map +1 -0
  106. package/dist/engine/handlers/index.d.ts +2 -0
  107. package/dist/engine/handlers/index.js +32 -0
  108. package/dist/engine/handlers/index.js.map +1 -0
  109. package/dist/engine/handlers/mode.d.ts +2 -0
  110. package/dist/engine/handlers/mode.js +194 -0
  111. package/dist/engine/handlers/mode.js.map +1 -0
  112. package/dist/engine/handlers/officialApps.d.ts +2 -0
  113. package/dist/engine/handlers/officialApps.js +79 -0
  114. package/dist/engine/handlers/officialApps.js.map +1 -0
  115. package/dist/engine/handlers/preview.d.ts +2 -0
  116. package/dist/engine/handlers/preview.js +59 -0
  117. package/dist/engine/handlers/preview.js.map +1 -0
  118. package/dist/engine/handlers/session.d.ts +2 -0
  119. package/dist/engine/handlers/session.js +385 -0
  120. package/dist/engine/handlers/session.js.map +1 -0
  121. package/dist/engine/handlers/usage.d.ts +2 -0
  122. package/dist/engine/handlers/usage.js +96 -0
  123. package/dist/engine/handlers/usage.js.map +1 -0
  124. package/dist/engine/handlers/workspace.d.ts +2 -0
  125. package/dist/engine/handlers/workspace.js +110 -0
  126. package/dist/engine/handlers/workspace.js.map +1 -0
  127. package/dist/engine/slashCommands.d.ts +3 -0
  128. package/dist/engine/slashCommands.js +211 -0
  129. package/dist/engine/slashCommands.js.map +1 -0
  130. package/dist/hub/engineRelaySocket.d.ts +58 -0
  131. package/dist/hub/engineRelaySocket.js +161 -0
  132. package/dist/hub/engineRelaySocket.js.map +1 -0
  133. package/dist/hub/hubClient.d.ts +28 -0
  134. package/dist/hub/hubClient.js +187 -0
  135. package/dist/hub/hubClient.js.map +1 -0
  136. package/dist/hub/hubDaemon.d.ts +69 -0
  137. package/dist/hub/hubDaemon.js +466 -0
  138. package/dist/hub/hubDaemon.js.map +1 -0
  139. package/dist/hub/hubProtocol.d.ts +161 -0
  140. package/dist/hub/hubProtocol.js +379 -0
  141. package/dist/hub/hubProtocol.js.map +1 -0
  142. package/dist/hub/panePreviewPump.d.ts +65 -0
  143. package/dist/hub/panePreviewPump.js +187 -0
  144. package/dist/hub/panePreviewPump.js.map +1 -0
  145. package/dist/hub/questionInjection.d.ts +19 -0
  146. package/dist/hub/questionInjection.js +126 -0
  147. package/dist/hub/questionInjection.js.map +1 -0
  148. package/dist/hub/reaper.d.ts +74 -0
  149. package/dist/hub/reaper.js +333 -0
  150. package/dist/hub/reaper.js.map +1 -0
  151. package/dist/hub/sessionHub.d.ts +203 -0
  152. package/dist/hub/sessionHub.js +1862 -0
  153. package/dist/hub/sessionHub.js.map +1 -0
  154. package/dist/protocol/common.d.ts +18 -0
  155. package/dist/protocol/common.js +78 -0
  156. package/dist/protocol/common.js.map +1 -0
  157. package/dist/protocol/decode.d.ts +3 -0
  158. package/dist/protocol/decode.js +1036 -0
  159. package/dist/protocol/decode.js.map +1 -0
  160. package/dist/protocol/encode.d.ts +3 -0
  161. package/dist/protocol/encode.js +84 -0
  162. package/dist/protocol/encode.js.map +1 -0
  163. package/dist/protocol/messages.d.ts +1002 -0
  164. package/dist/protocol/messages.js +8 -0
  165. package/dist/protocol/messages.js.map +1 -0
  166. package/dist/protocol.d.ts +4 -329
  167. package/dist/protocol.js +13 -531
  168. package/dist/protocol.js.map +1 -1
  169. package/dist/push/apnsConfigStore.js.map +1 -0
  170. package/dist/push/apnsJwtProvider.js.map +1 -0
  171. package/dist/{apnsSender.js.map → push/apnsSender.js.map} +1 -1
  172. package/dist/{approvalPushNotifier.js → push/approvalPushNotifier.js} +1 -1
  173. package/dist/push/approvalPushNotifier.js.map +1 -0
  174. package/dist/push/deviceTokenStore.js.map +1 -0
  175. package/dist/{pushTypes.js → push/pushTypes.js} +1 -1
  176. package/dist/push/pushTypes.js.map +1 -0
  177. package/dist/push/sendLog.js.map +1 -0
  178. package/dist/services/accountIdentity.d.ts +44 -0
  179. package/dist/services/accountIdentity.js +184 -0
  180. package/dist/services/accountIdentity.js.map +1 -0
  181. package/dist/services/claudeCredentialRefresh.d.ts +30 -0
  182. package/dist/services/claudeCredentialRefresh.js +345 -0
  183. package/dist/services/claudeCredentialRefresh.js.map +1 -0
  184. package/dist/services/claudeModelCatalog.d.ts +21 -0
  185. package/dist/services/claudeModelCatalog.js +181 -0
  186. package/dist/services/claudeModelCatalog.js.map +1 -0
  187. package/dist/{dirLister.d.ts → services/dirLister.d.ts} +9 -4
  188. package/dist/services/dirLister.js +169 -0
  189. package/dist/services/dirLister.js.map +1 -0
  190. package/dist/services/fileService.d.ts +10 -0
  191. package/dist/services/fileService.js +123 -0
  192. package/dist/services/fileService.js.map +1 -0
  193. package/dist/services/gitService.d.ts +79 -0
  194. package/dist/services/gitService.js +648 -0
  195. package/dist/services/gitService.js.map +1 -0
  196. package/dist/services/hostVersions.d.ts +38 -0
  197. package/dist/services/hostVersions.js +120 -0
  198. package/dist/services/hostVersions.js.map +1 -0
  199. package/dist/services/officialApps.d.ts +148 -0
  200. package/dist/services/officialApps.js +1079 -0
  201. package/dist/services/officialApps.js.map +1 -0
  202. package/dist/services/planUsageFetcher.d.ts +119 -0
  203. package/dist/services/planUsageFetcher.js +357 -0
  204. package/dist/services/planUsageFetcher.js.map +1 -0
  205. package/dist/services/previewServer.d.ts +31 -0
  206. package/dist/services/previewServer.js +233 -0
  207. package/dist/services/previewServer.js.map +1 -0
  208. package/dist/services/quicGateway.d.ts +143 -0
  209. package/dist/services/quicGateway.js +292 -0
  210. package/dist/services/quicGateway.js.map +1 -0
  211. package/dist/services/serveService.d.ts +50 -0
  212. package/dist/services/serveService.js +302 -0
  213. package/dist/services/serveService.js.map +1 -0
  214. package/dist/services/usageAggregator.js.map +1 -0
  215. package/dist/sessions/claudeSessionStore.d.ts +80 -0
  216. package/dist/sessions/claudeSessionStore.js +477 -0
  217. package/dist/sessions/claudeSessionStore.js.map +1 -0
  218. package/dist/sessions/heartbeat.d.ts +24 -0
  219. package/dist/sessions/heartbeat.js +86 -0
  220. package/dist/sessions/heartbeat.js.map +1 -0
  221. package/dist/sessions/liveSessionJoin.d.ts +20 -0
  222. package/dist/sessions/liveSessionJoin.js +80 -0
  223. package/dist/sessions/liveSessionJoin.js.map +1 -0
  224. package/dist/sessions/sessionActivityProvider.d.ts +14 -0
  225. package/dist/sessions/sessionActivityProvider.js +57 -0
  226. package/dist/sessions/sessionActivityProvider.js.map +1 -0
  227. package/dist/{sessionListService.d.ts → sessions/sessionListService.d.ts} +3 -3
  228. package/dist/{sessionListService.js → sessions/sessionListService.js} +23 -6
  229. package/dist/sessions/sessionListService.js.map +1 -0
  230. package/dist/{sessionMetadataStore.d.ts → sessions/sessionMetadataStore.d.ts} +18 -0
  231. package/dist/{sessionMetadataStore.js → sessions/sessionMetadataStore.js} +36 -1
  232. package/dist/sessions/sessionMetadataStore.js.map +1 -0
  233. package/dist/{sessionSearch.d.ts → sessions/sessionSearch.d.ts} +1 -1
  234. package/dist/sessions/sessionSearch.js.map +1 -0
  235. package/dist/shared/hostDisplayName.d.ts +11 -0
  236. package/dist/shared/hostDisplayName.js +50 -0
  237. package/dist/shared/hostDisplayName.js.map +1 -0
  238. package/dist/{lineWriter.d.ts → shared/lineWriter.d.ts} +1 -1
  239. package/dist/{lineWriter.js → shared/lineWriter.js} +1 -1
  240. package/dist/shared/lineWriter.js.map +1 -0
  241. package/dist/{observationLog.js.map → shared/observationLog.js.map} +1 -1
  242. package/dist/{paths.d.ts → shared/paths.d.ts} +7 -0
  243. package/dist/{paths.js → shared/paths.js} +9 -0
  244. package/dist/shared/paths.js.map +1 -0
  245. package/dist/shared/permissionMode.d.ts +10 -0
  246. package/dist/shared/permissionMode.js +51 -0
  247. package/dist/shared/permissionMode.js.map +1 -0
  248. package/dist/shared/skillInjection.d.ts +9 -0
  249. package/dist/shared/skillInjection.js +16 -0
  250. package/dist/shared/skillInjection.js.map +1 -0
  251. package/dist/shared/sleep.js.map +1 -0
  252. package/dist/{socketPath.d.ts → shared/socketPath.d.ts} +4 -0
  253. package/dist/{socketPath.js → shared/socketPath.js} +22 -0
  254. package/dist/shared/socketPath.js.map +1 -0
  255. package/dist/shared/version.js.map +1 -0
  256. package/package.json +13 -7
  257. package/dist/apnsConfigStore.js.map +0 -1
  258. package/dist/apnsJwtProvider.js.map +0 -1
  259. package/dist/approvalPushNotifier.js.map +0 -1
  260. package/dist/broker.js.map +0 -1
  261. package/dist/chatTailController.js.map +0 -1
  262. package/dist/claudeSessionStore.d.ts +0 -15
  263. package/dist/claudeSessionStore.js +0 -201
  264. package/dist/claudeSessionStore.js.map +0 -1
  265. package/dist/codexRolloutTailer.d.ts +0 -56
  266. package/dist/codexRolloutTailer.js +0 -303
  267. package/dist/codexRolloutTailer.js.map +0 -1
  268. package/dist/codexSessionStore.js.map +0 -1
  269. package/dist/codexUsage.js.map +0 -1
  270. package/dist/deviceTokenStore.js.map +0 -1
  271. package/dist/dirLister.js +0 -98
  272. package/dist/dirLister.js.map +0 -1
  273. package/dist/doctor.d.ts +0 -36
  274. package/dist/doctor.js +0 -162
  275. package/dist/doctor.js.map +0 -1
  276. package/dist/engine.d.ts +0 -85
  277. package/dist/engine.js +0 -962
  278. package/dist/engine.js.map +0 -1
  279. package/dist/hook.js.map +0 -1
  280. package/dist/hookSettings.js.map +0 -1
  281. package/dist/idleReaper.d.ts +0 -13
  282. package/dist/idleReaper.js +0 -30
  283. package/dist/idleReaper.js.map +0 -1
  284. package/dist/imageService.js.map +0 -1
  285. package/dist/kick.js.map +0 -1
  286. package/dist/launch.js +0 -396
  287. package/dist/launch.js.map +0 -1
  288. package/dist/legacyHomeMigration.js.map +0 -1
  289. package/dist/lineWriter.js.map +0 -1
  290. package/dist/mdnsAdvertiser.d.ts +0 -46
  291. package/dist/mdnsAdvertiser.js +0 -268
  292. package/dist/mdnsAdvertiser.js.map +0 -1
  293. package/dist/pairingCode.js.map +0 -1
  294. package/dist/paths.js.map +0 -1
  295. package/dist/permissionMode.d.ts +0 -8
  296. package/dist/permissionMode.js +0 -32
  297. package/dist/permissionMode.js.map +0 -1
  298. package/dist/planUsageFetcher.d.ts +0 -35
  299. package/dist/planUsageFetcher.js +0 -172
  300. package/dist/planUsageFetcher.js.map +0 -1
  301. package/dist/pushTokenCommand.js.map +0 -1
  302. package/dist/pushTypes.js.map +0 -1
  303. package/dist/sendLog.js.map +0 -1
  304. package/dist/sessionActivityProvider.d.ts +0 -7
  305. package/dist/sessionActivityProvider.js +0 -44
  306. package/dist/sessionActivityProvider.js.map +0 -1
  307. package/dist/sessionIdleTracker.d.ts +0 -19
  308. package/dist/sessionIdleTracker.js +0 -42
  309. package/dist/sessionIdleTracker.js.map +0 -1
  310. package/dist/sessionListService.js.map +0 -1
  311. package/dist/sessionMetadataStore.js.map +0 -1
  312. package/dist/sessionSearch.js.map +0 -1
  313. package/dist/setup.js.map +0 -1
  314. package/dist/sleep.js.map +0 -1
  315. package/dist/socketPath.js.map +0 -1
  316. package/dist/subagentTailer.d.ts +0 -15
  317. package/dist/subagentTailer.js +0 -399
  318. package/dist/subagentTailer.js.map +0 -1
  319. package/dist/tmux.d.ts +0 -62
  320. package/dist/tmux.js +0 -163
  321. package/dist/tmux.js.map +0 -1
  322. package/dist/transcriptTailer.js.map +0 -1
  323. package/dist/usageAggregator.js.map +0 -1
  324. package/dist/version.js.map +0 -1
  325. /package/dist/{kick.d.ts → commands/kick.d.ts} +0 -0
  326. /package/dist/{legacyHomeMigration.d.ts → commands/legacyHomeMigration.d.ts} +0 -0
  327. /package/dist/{legacyHomeMigration.js → commands/legacyHomeMigration.js} +0 -0
  328. /package/dist/{apnsConfigStore.d.ts → push/apnsConfigStore.d.ts} +0 -0
  329. /package/dist/{apnsConfigStore.js → push/apnsConfigStore.js} +0 -0
  330. /package/dist/{apnsJwtProvider.d.ts → push/apnsJwtProvider.d.ts} +0 -0
  331. /package/dist/{apnsJwtProvider.js → push/apnsJwtProvider.js} +0 -0
  332. /package/dist/{apnsSender.d.ts → push/apnsSender.d.ts} +0 -0
  333. /package/dist/{apnsSender.js → push/apnsSender.js} +0 -0
  334. /package/dist/{approvalPushNotifier.d.ts → push/approvalPushNotifier.d.ts} +0 -0
  335. /package/dist/{deviceTokenStore.d.ts → push/deviceTokenStore.d.ts} +0 -0
  336. /package/dist/{deviceTokenStore.js → push/deviceTokenStore.js} +0 -0
  337. /package/dist/{pushTypes.d.ts → push/pushTypes.d.ts} +0 -0
  338. /package/dist/{sendLog.d.ts → push/sendLog.d.ts} +0 -0
  339. /package/dist/{sendLog.js → push/sendLog.js} +0 -0
  340. /package/dist/{usageAggregator.d.ts → services/usageAggregator.d.ts} +0 -0
  341. /package/dist/{usageAggregator.js → services/usageAggregator.js} +0 -0
  342. /package/dist/{sessionSearch.js → sessions/sessionSearch.js} +0 -0
  343. /package/dist/{observationLog.d.ts → shared/observationLog.d.ts} +0 -0
  344. /package/dist/{observationLog.js → shared/observationLog.js} +0 -0
  345. /package/dist/{sleep.d.ts → shared/sleep.d.ts} +0 -0
  346. /package/dist/{sleep.js → shared/sleep.js} +0 -0
  347. /package/dist/{version.d.ts → shared/version.d.ts} +0 -0
  348. /package/dist/{version.js → shared/version.js} +0 -0
@@ -0,0 +1,1862 @@
1
+ // sessionHub.ts
2
+ // tailii (TS host) — connection 非依存 Session Hub コア。
3
+ import { reaperTick } from "./reaper.js";
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import { bumpHeartbeat, listHeartbeatSessions, readHeartbeat, writeHeartbeat } from "../sessions/heartbeat.js";
7
+ import { ensureDirectory0700 } from "../shared/paths.js";
8
+ import { decodeHubClientLine, encodeHubMessage, } from "./hubProtocol.js";
9
+ import { HISTORY_DONE_STREAM_ID } from "../chat/transcriptTailer.js";
10
+ import { PROTOCOL_V1, PROTOCOL_V2 } from "../protocol.js";
11
+ import { CodexAppServerManager } from "../codex/codexAppServer.js";
12
+ import { CodexNativeTurnController, chatContentKey, } from "../codex/codexNativeTurnController.js";
13
+ import { CONTEXT_STREAM_ID as CODEX_CONTEXT_STREAM_ID, CONTEXT_WINDOW_STREAM_ID as CODEX_CONTEXT_WINDOW_STREAM_ID, MODEL_STREAM_ID as CODEX_MODEL_STREAM_ID, } from "../codex/codexRolloutTailer.js";
14
+ const DELIVERED_RECEIPT_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
15
+ const MAX_DELIVERED_RECEIPTS_PER_SESSION = 10_000;
16
+ export class SessionHub {
17
+ options;
18
+ clients = new Map();
19
+ /** 一覧 Mission Control の watcher(処理中会話全体の pane_preview 配信先)。 */
20
+ previewWatchers = new Set();
21
+ actors = new Map();
22
+ now;
23
+ replayLimit;
24
+ injectionsInFlight = 0;
25
+ codexStartsInFlight = 0;
26
+ modePushSeq = 0;
27
+ chatReceiptsDirty = false;
28
+ activeCodexTurns = new Set();
29
+ codexTurnController = null;
30
+ constructor(options) {
31
+ this.options = options;
32
+ this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
33
+ this.replayLimit = options.replayLimit ?? 500;
34
+ }
35
+ get clientCount() { return this.clients.size; }
36
+ get hasPendingQuestions() {
37
+ return [...this.actors.values()].some((actor) => actor.pendingQuestion !== null);
38
+ }
39
+ get hasInjectionsInFlight() { return this.injectionsInFlight > 0; }
40
+ get hasCodexTurnsInFlight() {
41
+ return this.codexStartsInFlight > 0 || this.activeCodexTurns.size > 0;
42
+ }
43
+ /** 新しく接続した engine が処理中会話を直ちに購読するための軽量スナップショット。 */
44
+ get processingSessionNames() {
45
+ return [...this.actors]
46
+ .filter(([, actor]) => actor.processingSince !== null)
47
+ .map(([session]) => session);
48
+ }
49
+ /** Hub 再起動前から active の heartbeat を actor の処理中状態へ戻す。 */
50
+ restoreFromHeartbeats() {
51
+ for (const session of listHeartbeatSessions(this.options.heartbeatDir)) {
52
+ const heartbeat = readHeartbeat(this.options.heartbeatDir, session);
53
+ if (heartbeat?.state !== "active")
54
+ continue;
55
+ // codex の turn は hub 内 controller と運命共同体で、再起動を越えた active は駆動者を
56
+ // 失った死んだ turn。復元して bump すると「bump 停止=ターン死亡」の reaper シグナルが
57
+ // 壊れて kill されなくなるため復元しない(reaper.ts 冒頭の設計原則)。
58
+ if (this.options.metadataStore.get(session)?.agent === "codex")
59
+ continue;
60
+ // 鮮度切れの active はクラッシュ残骸。復元すると tick の bump で計時が止まるため捨てる
61
+ // (稼働中の hub は毎 tick bump しているので、直前まで生きていた heartbeat は必ず新しい)。
62
+ if (this.now() - heartbeat.ts >= this.options.timeoutSeconds)
63
+ continue;
64
+ this.actor(session).processingSince = heartbeat.ts;
65
+ }
66
+ }
67
+ /** 永続化済み設問を復元する。App Server の request handle は再起動を越せないため TUI のみ対象。 */
68
+ restorePendingQuestions() {
69
+ const restored = this.readPendingQuestions();
70
+ let changed = false;
71
+ for (const [session, pending] of Object.entries(restored)) {
72
+ if (pending.answerRoute === "codex_native") {
73
+ changed = true;
74
+ this.broadcast({ type: "question_event", session, event: "dismiss", id: pending.id });
75
+ continue;
76
+ }
77
+ this.actor(session).pendingQuestion = pending;
78
+ }
79
+ if (changed)
80
+ this.persistPendingQuestions();
81
+ }
82
+ /** daemon 再起動前に durable enqueue 済みだった chat_send と receipt を復元する。 */
83
+ restoreChatReceipts() {
84
+ const persisted = this.readChatReceipts();
85
+ let discardedStaleSession = false;
86
+ for (const [session, state] of Object.entries(persisted)) {
87
+ const currentSessionIdentity = receiptSessionIdentity(this.options.metadataStore.get(session));
88
+ if (state.sessionIdentity !== undefined && state.sessionIdentity !== currentSessionIdentity) {
89
+ // session 名は再利用される。旧世代の durable queue を同名の新 pane へ注入しない。
90
+ // metadata が消失・破損した場合も identity を証明できないため安全側で破棄する。
91
+ discardedStaleSession = true;
92
+ this.options.log?.(`stale chat receipt 破棄 session=${session}`);
93
+ continue;
94
+ }
95
+ const actor = this.actor(session);
96
+ const restoredAt = Date.now();
97
+ for (const clientMessageId of state.delivered) {
98
+ actor.deliveredChatMessageIds.set(clientMessageId, state.deliveredAtMs?.[clientMessageId] ?? restoredAt);
99
+ }
100
+ for (const clientMessageId of state.deliveredCodex) {
101
+ actor.deliveredCodexMessageIds.set(clientMessageId, state.deliveredCodexAtMs?.[clientMessageId] ?? restoredAt);
102
+ }
103
+ for (const clientMessageId of state.deleted ?? []) {
104
+ actor.deletedChatMessageIds.set(clientMessageId, state.deletedAtMs?.[clientMessageId] ?? restoredAt);
105
+ }
106
+ for (const clientMessageId of state.deletedCodex ?? []) {
107
+ actor.deletedCodexMessageIds.set(clientMessageId, state.deletedCodexAtMs?.[clientMessageId] ?? restoredAt);
108
+ }
109
+ compactDeliveredReceipts(actor.deliveredChatMessageIds, restoredAt);
110
+ compactDeliveredReceipts(actor.deliveredCodexMessageIds, restoredAt);
111
+ compactDeliveredReceipts(actor.deletedChatMessageIds, restoredAt);
112
+ compactDeliveredReceipts(actor.deletedCodexMessageIds, restoredAt);
113
+ for (const message of state.queued) {
114
+ if (hasDeliveredReceipt(actor.deliveredChatMessageIds, message.clientMessageId, restoredAt) ||
115
+ hasDeliveredReceipt(actor.deletedChatMessageIds, message.clientMessageId, restoredAt) ||
116
+ actor.pendingChatMessages.has(message.clientMessageId))
117
+ continue;
118
+ const entry = { message, waiters: [] };
119
+ actor.pendingChatMessages.set(message.clientMessageId, entry);
120
+ actor.chatQueue.push(entry);
121
+ }
122
+ for (const message of state.injecting) {
123
+ if (!hasDeliveredReceipt(actor.deliveredChatMessageIds, message.clientMessageId, restoredAt) &&
124
+ !hasDeliveredReceipt(actor.deletedChatMessageIds, message.clientMessageId, restoredAt)) {
125
+ // crash 時点で tmux 注入の前後を判定できない。自動再注入は二重実行を生むため、
126
+ // 同じ ID を uncertain として保持し、利用者起点の明示的な新規送信に委ねる。
127
+ actor.uncertainChatMessages.set(message.clientMessageId, message);
128
+ }
129
+ }
130
+ const chatIds = new Set([
131
+ ...actor.uncertainChatMessages.keys(), ...actor.pendingChatMessages.keys(),
132
+ ]);
133
+ actor.chatOrder = restoreOrder(state.chatOrder, [...state.injecting.map((message) => message.clientMessageId),
134
+ ...state.queued.map((message) => message.clientMessageId)], chatIds);
135
+ actor.chatQueue.sort((left, right) => actor.chatOrder.indexOf(left.message.clientMessageId) -
136
+ actor.chatOrder.indexOf(right.message.clientMessageId));
137
+ for (const message of state.queuedCodex ?? []) {
138
+ if (hasDeliveredReceipt(actor.deliveredCodexMessageIds, message.clientUserMessageId, restoredAt) ||
139
+ hasDeliveredReceipt(actor.deletedCodexMessageIds, message.clientUserMessageId, restoredAt) ||
140
+ actor.pendingCodexTurns.has(message.clientUserMessageId))
141
+ continue;
142
+ const entry = { message, waiters: [] };
143
+ actor.pendingCodexTurns.set(message.clientUserMessageId, entry);
144
+ actor.codexQueue.push(entry);
145
+ }
146
+ for (const message of state.startingCodex) {
147
+ if (!hasDeliveredReceipt(actor.deliveredCodexMessageIds, message.clientUserMessageId, restoredAt) &&
148
+ !hasDeliveredReceipt(actor.deletedCodexMessageIds, message.clientUserMessageId, restoredAt)) {
149
+ actor.uncertainCodexMessages.set(message.clientUserMessageId, message);
150
+ }
151
+ }
152
+ const codexIds = new Set([
153
+ ...actor.uncertainCodexMessages.keys(), ...actor.pendingCodexTurns.keys(),
154
+ ]);
155
+ actor.codexOrder = restoreOrder(state.codexOrder, [...state.startingCodex.map((message) => message.clientUserMessageId),
156
+ ...(state.queuedCodex ?? []).map((message) => message.clientUserMessageId)], codexIds);
157
+ actor.codexQueue.sort((left, right) => actor.codexOrder.indexOf(left.message.clientUserMessageId) -
158
+ actor.codexOrder.indexOf(right.message.clientUserMessageId));
159
+ void this.drainChatQueue(session, actor);
160
+ void this.drainCodexQueue(session, actor);
161
+ }
162
+ if (discardedStaleSession)
163
+ this.persistChatReceipts();
164
+ }
165
+ registerClient(client, write) { this.clients.set(client, write); }
166
+ unregisterClient(client) {
167
+ this.clients.delete(client);
168
+ const wasWatcher = this.previewWatchers.delete(client);
169
+ for (const [session, actor] of this.actors) {
170
+ actor.focusedBy.delete(client);
171
+ if (actor.runtimeClaim?.client === client)
172
+ actor.runtimeClaim = null;
173
+ if (actor.subscribers.has(client))
174
+ this.unsubscribe(client, session, actor);
175
+ }
176
+ // 最後の watcher の切断で、watch 起因の pump を止める。
177
+ if (wasWatcher)
178
+ this.syncAllPreviews();
179
+ }
180
+ broadcast(message) {
181
+ const line = encodeHubMessage(message);
182
+ for (const write of this.clients.values()) {
183
+ try {
184
+ write(line);
185
+ }
186
+ catch { /* 切断検知は transport に任せる。 */ }
187
+ }
188
+ }
189
+ sendTo(client, message) {
190
+ try {
191
+ this.clients.get(client)?.(encodeHubMessage(message));
192
+ }
193
+ catch { /* 同上。 */ }
194
+ }
195
+ handleRelayMessage(message) {
196
+ if (message.type === "question_event") {
197
+ const actor = this.actor(message.session);
198
+ this.setPendingQuestion(message.session, actor, message.event === "prompt"
199
+ ? { id: message.id, questions: message.questions ?? [], answerRoute: "tui" } : null);
200
+ if (message.event === "dismiss")
201
+ void this.drainChatQueue(message.session, actor);
202
+ }
203
+ else if (message.type === "session_processing")
204
+ this.applyProcessing(message.session, message.state);
205
+ this.broadcast(message);
206
+ }
207
+ handleClientMessage(client, line) {
208
+ const message = decodeHubClientLine(line);
209
+ if (message === null || message.type === "hub_hello")
210
+ return;
211
+ if (message.type === "conversation_subscribe") {
212
+ // preview は後から加わった内部フラグ。省略する旧 engine は従来どおり前面購読として扱う。
213
+ this.subscribe(client, message.session, message.afterSeq, message.newerThanMs, message.preview ?? true);
214
+ return;
215
+ }
216
+ if (message.type === "conversation_unsubscribe") {
217
+ const actor = this.actor(message.session);
218
+ this.unsubscribe(client, message.session, actor);
219
+ return;
220
+ }
221
+ if (message.type === "session_retire") {
222
+ this.retireSession(message.session);
223
+ return;
224
+ }
225
+ if (message.type === "session_preview_watch") {
226
+ // 一覧 Mission Control: watch 中は処理中会話すべての pane_preview をこの client へ流す。
227
+ const changed = message.enabled
228
+ ? !this.previewWatchers.has(client) && (this.previewWatchers.add(client), true)
229
+ : this.previewWatchers.delete(client);
230
+ if (changed) {
231
+ this.options.log?.(`audit preview-watch ${message.enabled ? "start" : "stop"} watchers=${this.previewWatchers.size}`);
232
+ this.syncAllPreviews();
233
+ }
234
+ return;
235
+ }
236
+ if (message.type === "conversation_subagent_transcript_request") {
237
+ const actor = this.actors.get(message.session);
238
+ const transcript = actor?.tail?.subagentTranscript?.(message.nodeId) ?? {
239
+ entries: [],
240
+ omitted: 0,
241
+ };
242
+ this.sendTo(client, {
243
+ type: "conversation_subagent_transcript_response",
244
+ id: message.id,
245
+ session: message.session,
246
+ payload: {
247
+ type: "subagent_transcript_response",
248
+ v: PROTOCOL_V2,
249
+ id: message.id,
250
+ nodeId: message.nodeId,
251
+ entries: transcript.entries,
252
+ omitted: transcript.omitted,
253
+ },
254
+ });
255
+ return;
256
+ }
257
+ if (message.type === "hub_state_request") {
258
+ const actor = this.actor(message.session);
259
+ this.sendTo(client, { type: "hub_state_response", id: message.id, session: message.session,
260
+ pendingQuestion: actor.pendingQuestion === null ? null : {
261
+ id: actor.pendingQuestion.id, questions: actor.pendingQuestion.questions,
262
+ }, processing: actor.processingSince !== null });
263
+ return;
264
+ }
265
+ if (message.type === "presence_request") {
266
+ const subscriberCount = this.actors.get(message.session)?.subscribers.size ?? 0;
267
+ this.sendTo(client, {
268
+ type: "presence_response",
269
+ id: message.id,
270
+ session: message.session,
271
+ subscriberCount,
272
+ });
273
+ return;
274
+ }
275
+ if (message.type === "question_answer_submit") {
276
+ const actor = this.actors.get(message.session);
277
+ if (actor === undefined) {
278
+ this.sendTo(client, { type: "question_answer_result", id: message.id, status: "unknown" });
279
+ }
280
+ else if (actor.pendingQuestion?.id !== message.questionId) {
281
+ this.sendTo(client, { type: "question_answer_result", id: message.id, status: "already_resolved" });
282
+ }
283
+ else {
284
+ // first-wins の判定と clear は await を挟まず同期的に行い、勝者だけが注入する。
285
+ const answerRoute = actor.pendingQuestion.answerRoute;
286
+ // 注入失敗時の復元用スナップショット(clear 前に取る)。
287
+ const restoreSnapshot = actor.pendingQuestion;
288
+ this.setPendingQuestion(message.session, actor, null);
289
+ this.broadcast({ type: "question_event", session: message.session, event: "dismiss", id: message.questionId });
290
+ this.sendTo(client, { type: "question_answer_result", id: message.id, status: "accepted" });
291
+ if (answerRoute === "codex_native") {
292
+ try {
293
+ if (this.codexTurnController?.answerQuestion?.(message.questionId, message.answers) !== true) {
294
+ this.options.log?.(`Codex native 設問回答失敗: pending 不在 (${message.questionId})`);
295
+ }
296
+ }
297
+ catch (error) {
298
+ this.options.log?.(`Codex native 設問回答失敗: ${String(error)}`);
299
+ }
300
+ void this.drainChatQueue(message.session, actor);
301
+ }
302
+ else {
303
+ this.injectionsInFlight += 1;
304
+ void (this.options.questionInjector?.(message.answers, message.session) ?? Promise.resolve())
305
+ .catch((error) => {
306
+ this.options.log?.(`設問回答注入失敗: ${String(error)}`);
307
+ // 自己修復(question-answer-retry): pendingQuestion を失ったまま TUI ダイアログが
308
+ // 残ると、アプリから再回答する手段が無く会話が詰む。失敗の度に必ず復元し、
309
+ // 「host が設問で止まっている」の権威を保つ(会話を開き直せば emitPendingQuestion が
310
+ // question_prompt を再送し、設問シートが再提示される)。新しい設問が既に来ていれば触らない。
311
+ // prompt の再配信(能動的な再提示)は設問ごとに1回まで: 構造的に注入が通らない
312
+ // ダイアログで「再提示→失敗」を無限に繰り返さない。2回目以降は会話内に
313
+ // system メッセージで状況を伝え、TUI/Mac 側での解消へ倒す。
314
+ if (this.actors.get(message.session) === actor && actor.pendingQuestion === null) {
315
+ const firstFailure = restoreSnapshot.restoredAfterInjectionFailure !== true;
316
+ this.setPendingQuestion(message.session, actor, { ...restoreSnapshot, restoredAfterInjectionFailure: true });
317
+ if (firstFailure) {
318
+ this.broadcast({
319
+ type: "question_event", session: message.session, event: "prompt",
320
+ id: restoreSnapshot.id, questions: restoreSnapshot.questions,
321
+ });
322
+ }
323
+ else {
324
+ // publishCodexMarker は名前こそ codex 由来だが、実体は汎用の
325
+ // chat_output(system) マーカー発行なのでそのまま使う。
326
+ this.publishCodexMarker(message.session, `question-inject-error-${restoreSnapshot.id}`, "⚠️ 設問回答の反映に失敗しました。会話を開き直して再回答するか、Mac 側の画面で回答してください。");
327
+ }
328
+ }
329
+ })
330
+ .finally(() => {
331
+ this.injectionsInFlight -= 1;
332
+ void this.drainChatQueue(message.session, actor);
333
+ });
334
+ }
335
+ }
336
+ return;
337
+ }
338
+ if (message.type === "codex_turn_submit") {
339
+ const actor = this.actor(message.session);
340
+ if (hasDeliveredReceipt(actor.deletedCodexMessageIds, message.clientUserMessageId)) {
341
+ this.sendTo(client, {
342
+ type: "codex_turn_result", id: message.id, status: "failed",
343
+ error: "pending_message_deleted",
344
+ });
345
+ return;
346
+ }
347
+ if (hasDeliveredReceipt(actor.deliveredCodexMessageIds, message.clientUserMessageId)) {
348
+ this.sendTo(client, { type: "codex_turn_result", id: message.id, status: "duplicate" });
349
+ return;
350
+ }
351
+ const uncertain = actor.uncertainCodexMessages.get(message.clientUserMessageId);
352
+ if (uncertain !== undefined) {
353
+ if (message.explicitRetry !== true) {
354
+ this.sendTo(client, {
355
+ type: "codex_turn_result", id: message.id, status: "failed",
356
+ error: "Previous Codex turn/start outcome is uncertain; explicit retry is required",
357
+ });
358
+ return;
359
+ }
360
+ if (actor.codexOrder[0] !== message.clientUserMessageId) {
361
+ this.sendTo(client, {
362
+ type: "codex_turn_result", id: message.id, status: "failed",
363
+ error: "An earlier uncertain turn must be resolved first",
364
+ });
365
+ return;
366
+ }
367
+ if (!sameCodexRetry(uncertain, message)) {
368
+ this.sendTo(client, {
369
+ type: "codex_turn_result", id: message.id, status: "failed",
370
+ error: "Explicit retry payload does not match the original turn",
371
+ });
372
+ return;
373
+ }
374
+ actor.uncertainCodexMessages.delete(message.clientUserMessageId);
375
+ const retryEntry = { message, waiters: [{ client, id: message.id }] };
376
+ actor.pendingCodexTurns.set(message.clientUserMessageId, retryEntry);
377
+ actor.codexQueue.unshift(retryEntry);
378
+ if (!this.persistChatReceipts()) {
379
+ actor.codexQueue.shift();
380
+ actor.pendingCodexTurns.delete(message.clientUserMessageId);
381
+ actor.uncertainCodexMessages.set(message.clientUserMessageId, uncertain);
382
+ this.sendTo(client, {
383
+ type: "codex_turn_result", id: message.id, status: "failed",
384
+ error: "Session Hub explicit Codex retry receipt write failed",
385
+ });
386
+ return;
387
+ }
388
+ actor.codexDrainBlocked = false;
389
+ void this.drainCodexQueue(message.session, actor);
390
+ return;
391
+ }
392
+ const pending = actor.pendingCodexTurns.get(message.clientUserMessageId);
393
+ if (pending !== undefined) {
394
+ pending.waiters.push({ client, id: message.id });
395
+ actor.codexDrainBlocked = false;
396
+ void this.drainCodexQueue(message.session, actor);
397
+ return;
398
+ }
399
+ const entry = { message, waiters: [{ client, id: message.id }] };
400
+ actor.pendingCodexTurns.set(message.clientUserMessageId, entry);
401
+ actor.codexQueue.push(entry);
402
+ actor.codexOrder.push(message.clientUserMessageId);
403
+ if (!this.persistChatReceipts()) {
404
+ actor.pendingCodexTurns.delete(message.clientUserMessageId);
405
+ actor.codexQueue.pop();
406
+ actor.codexOrder.pop();
407
+ this.sendTo(client, {
408
+ type: "codex_turn_result", id: message.id, status: "failed",
409
+ error: "Session Hub Codex starting receipt write failed",
410
+ });
411
+ return;
412
+ }
413
+ actor.codexDrainBlocked = false;
414
+ void this.drainCodexQueue(message.session, actor);
415
+ return;
416
+ }
417
+ if (message.type === "codex_turn_interrupt") {
418
+ void this.codexTurnController?.interruptTurn?.(message.session).catch((error) => {
419
+ this.publishCodexMarker(message.session, `codex-interrupt-error-${message.id}`, `⚠️ Codex turn 中断失敗: ${String(error)}`);
420
+ this.options.log?.(`codex turn 中断失敗 session=${message.session}: ${String(error)}`);
421
+ });
422
+ return;
423
+ }
424
+ if (message.type === "chat_send") {
425
+ const actor = this.actor(message.session);
426
+ if (hasDeliveredReceipt(actor.deletedChatMessageIds, message.clientMessageId)) {
427
+ this.sendTo(client, {
428
+ type: "chat_send_result", id: message.id, status: "failed",
429
+ error: "pending_message_deleted",
430
+ });
431
+ return;
432
+ }
433
+ if (hasDeliveredReceipt(actor.deliveredChatMessageIds, message.clientMessageId)) {
434
+ this.sendTo(client, { type: "chat_send_result", id: message.id, status: "duplicate" });
435
+ return;
436
+ }
437
+ const uncertain = actor.uncertainChatMessages.get(message.clientMessageId);
438
+ if (uncertain !== undefined) {
439
+ if (message.explicitRetry !== true) {
440
+ this.sendTo(client, {
441
+ type: "chat_send_result", id: message.id, status: "failed",
442
+ error: "Previous tmux injection outcome is uncertain; explicit retry is required",
443
+ });
444
+ return;
445
+ }
446
+ if (actor.chatOrder[0] !== message.clientMessageId) {
447
+ this.sendTo(client, {
448
+ type: "chat_send_result", id: message.id, status: "failed",
449
+ error: "An earlier uncertain message must be resolved first",
450
+ });
451
+ return;
452
+ }
453
+ if (uncertain.text !== message.text) {
454
+ this.sendTo(client, {
455
+ type: "chat_send_result", id: message.id, status: "failed",
456
+ error: "Explicit retry text does not match the original message",
457
+ });
458
+ return;
459
+ }
460
+ actor.uncertainChatMessages.delete(message.clientMessageId);
461
+ const retryEntry = { message, waiters: [{ client, id: message.id }] };
462
+ actor.pendingChatMessages.set(message.clientMessageId, retryEntry);
463
+ actor.chatQueue.unshift(retryEntry);
464
+ if (!this.persistChatReceipts()) {
465
+ actor.chatQueue.shift();
466
+ actor.pendingChatMessages.delete(message.clientMessageId);
467
+ actor.uncertainChatMessages.set(message.clientMessageId, uncertain);
468
+ this.sendTo(client, {
469
+ type: "chat_send_result", id: message.id, status: "failed",
470
+ error: "Session Hub explicit retry receipt write failed",
471
+ });
472
+ return;
473
+ }
474
+ actor.chatDrainBlocked = false;
475
+ void this.drainChatQueue(message.session, actor);
476
+ return;
477
+ }
478
+ const pending = actor.pendingChatMessages.get(message.clientMessageId);
479
+ if (pending !== undefined) {
480
+ if (pending.message.text !== message.text) {
481
+ this.sendTo(client, {
482
+ type: "chat_send_result", id: message.id, status: "failed",
483
+ error: "Queued message payload does not match the original message",
484
+ });
485
+ return;
486
+ }
487
+ // durable queue 内の同一入力。まだ注入完了ではないため duplicate と確定せず、
488
+ // 完了 ACK を待つ RPC waiter として合流させる。
489
+ pending.waiters.push({ client, id: message.id });
490
+ actor.chatDrainBlocked = false;
491
+ void this.drainChatQueue(message.session, actor);
492
+ return;
493
+ }
494
+ const entry = { message, waiters: [{ client, id: message.id }] };
495
+ actor.pendingChatMessages.set(message.clientMessageId, entry);
496
+ actor.chatQueue.push(entry);
497
+ actor.chatOrder.push(message.clientMessageId);
498
+ if (!this.persistChatReceipts()) {
499
+ actor.pendingChatMessages.delete(message.clientMessageId);
500
+ actor.chatQueue.pop();
501
+ actor.chatOrder.pop();
502
+ this.sendTo(client, {
503
+ type: "chat_send_result", id: message.id, status: "failed",
504
+ error: "Session Hub durable queue write failed",
505
+ });
506
+ return;
507
+ }
508
+ actor.chatDrainBlocked = false;
509
+ void this.drainChatQueue(message.session, actor);
510
+ return;
511
+ }
512
+ if (message.type === "pending_message_delete") {
513
+ // 対象不在も成功だが、client 保存前 crash で同じ Outbox が復元されても実行しないよう
514
+ // 削除 tombstone は durable に残す。actor 不在でもここだけは軽量 actor を作る。
515
+ const actor = this.actor(message.session);
516
+ if (message.kind === "chat") {
517
+ this.deletePendingChatMessage(client, message.id, message.session, message.clientMessageId, actor);
518
+ }
519
+ else {
520
+ this.deletePendingCodexTurn(client, message.id, message.session, message.clientMessageId, actor);
521
+ }
522
+ return;
523
+ }
524
+ if (message.type === "input_claim") {
525
+ const actor = this.actor(message.session);
526
+ const duplicate = this.claimInput(actor, message.clientMessageId);
527
+ this.sendTo(client, { type: "input_claim_result", id: message.id,
528
+ status: duplicate ? "duplicate" : "granted" });
529
+ return;
530
+ }
531
+ if (message.type === "runtime_claim") {
532
+ const actor = this.actor(message.session);
533
+ if (actor.runtimeClaim !== null && actor.runtimeClaim.expiresAt <= this.now())
534
+ actor.runtimeClaim = null;
535
+ const granted = actor.runtimeClaim === null || actor.runtimeClaim.client === client;
536
+ if (granted)
537
+ actor.runtimeClaim = { client, expiresAt: this.now() + 15 };
538
+ else
539
+ this.options.log?.(`audit runtime_claim_held session=${auditValue(message.session)} holder_present=true`);
540
+ this.sendTo(client, { type: "runtime_claim_result", id: message.id, status: granted ? "granted" : "held" });
541
+ return;
542
+ }
543
+ if (message.type === "runtime_claim_release") {
544
+ const actor = this.actors.get(message.session);
545
+ if (actor?.runtimeClaim?.client === client)
546
+ actor.runtimeClaim = null;
547
+ return;
548
+ }
549
+ this.applyProcessing(message.session, message.state);
550
+ this.broadcast(message);
551
+ }
552
+ async tick() {
553
+ for (const [session, actor] of this.actors) {
554
+ if (actor.focusedBy.size > 0)
555
+ this.bumpSafe(session, "hub-tick");
556
+ if (actor.processingSince !== null)
557
+ this.bumpSafe(session, "hub-processing", "active");
558
+ if (actor.pendingQuestion !== null)
559
+ this.bumpSafe(session, "hub-question", "active");
560
+ }
561
+ const result = await reaperTick({ ...this.options, now: this.now() });
562
+ // reaper の判定と actor の処理中フラグを同期する。demote(プロセス死亡)や kill 後も
563
+ // processingSince が残ると、tick の bump が ts を更新し続けて計時が永遠に進まない
564
+ // (=ゾンビ tmux の不死化 / 死んだセッションの heartbeat 再生成)。
565
+ for (const session of result.killed)
566
+ this.retireSession(session, "Session was killed");
567
+ // demote は agent process 消滅、reclaim は tmux session 消滅を表す。どちらも現在の
568
+ // pane へ安全に配送できず、同名再作成後へ旧 queue を持ち越してはならない。
569
+ for (const session of result.demoted)
570
+ this.retireSession(session, "Agent process ended");
571
+ for (const session of result.reclaimed)
572
+ this.retireSession(session, "Session disappeared");
573
+ if (this.chatReceiptsDirty && this.persistChatReceipts()) {
574
+ // queued→injecting の保存だけが一時失敗した actor を、disk 回復後に自動再開する。
575
+ for (const [session, actor] of this.actors) {
576
+ if (actor.chatDrainBlocked) {
577
+ actor.chatDrainBlocked = false;
578
+ void this.drainChatQueue(session, actor);
579
+ }
580
+ if (actor.codexDrainBlocked) {
581
+ actor.codexDrainBlocked = false;
582
+ void this.drainCodexQueue(session, actor);
583
+ }
584
+ }
585
+ }
586
+ return result;
587
+ }
588
+ close() {
589
+ for (const actor of this.actors.values()) {
590
+ if (actor.tailRetryTimer !== null)
591
+ clearTimeout(actor.tailRetryTimer);
592
+ actor.tailRetryTimer = null;
593
+ }
594
+ this.codexTurnController?.close();
595
+ this.codexTurnController = null;
596
+ this.activeCodexTurns.clear();
597
+ }
598
+ /** tmux セッションの確定 kill に追従し、同名で将来作られる会話へ状態を持ち越さない。 */
599
+ retireSession(session, reason = "Session was retired") {
600
+ // live-pill Phase 2: 一覧 watcher へ死亡を push する(iOS は「一覧を取り直せ」の合図として使う)。
601
+ // ここは tick の killed/demoted/reclaimed と engine 発 kill が集まる全死亡経路の合流点。
602
+ // actor 不在(購読も処理もされないまま外部で死んだ会話)でも一覧の表示は古くなるため、
603
+ // 下の early return より前で送る。誕生イベントは出さない(一覧は起動時に自分で取り直す)。
604
+ for (const watcher of this.previewWatchers) {
605
+ this.sendTo(watcher, { type: "conversation_liveness", session, alive: false });
606
+ }
607
+ const actor = this.actors.get(session);
608
+ if (actor === undefined)
609
+ return;
610
+ if (actor.tailRetryTimer !== null)
611
+ clearTimeout(actor.tailRetryTimer);
612
+ actor.tailRetryTimer = null;
613
+ actor.tail?.stop();
614
+ actor.tail = null;
615
+ actor.previewPump?.stop();
616
+ actor.previewPump = null;
617
+ for (const tail of actor.backfillTails.values())
618
+ tail.stop();
619
+ actor.backfillTails.clear();
620
+ this.activeCodexTurns.delete(session);
621
+ this.codexTurnController?.closeSession(session);
622
+ if (actor.pendingQuestion !== null) {
623
+ this.broadcast({ type: "question_event", session, event: "dismiss", id: actor.pendingQuestion.id });
624
+ }
625
+ for (const entry of actor.pendingChatMessages.values()) {
626
+ for (const waiter of entry.waiters) {
627
+ this.sendTo(waiter.client, {
628
+ type: "chat_send_result", id: waiter.id, status: "failed",
629
+ error: `${reason} before delivery completed`,
630
+ });
631
+ }
632
+ entry.waiters.length = 0;
633
+ }
634
+ for (const entry of actor.pendingCodexTurns.values()) {
635
+ for (const waiter of entry.waiters) {
636
+ this.sendTo(waiter.client, {
637
+ type: "codex_turn_result", id: waiter.id, status: "failed",
638
+ error: `${reason} before turn start completed`,
639
+ });
640
+ }
641
+ entry.waiters.length = 0;
642
+ }
643
+ // drain が injector / App Server の await 中でも、復帰後に同じ actor 参照から次の旧入力へ
644
+ // 進めないよう durable queue の実体も空にする。現在実行中の1件は復帰時のidentity検査で捨てる。
645
+ actor.chatQueue.length = 0;
646
+ actor.chatOrder.length = 0;
647
+ actor.pendingChatMessages.clear();
648
+ actor.injectingChatMessageIds.clear();
649
+ actor.uncertainChatMessages.clear();
650
+ actor.deletedChatMessageIds.clear();
651
+ actor.codexQueue.length = 0;
652
+ actor.codexOrder.length = 0;
653
+ actor.pendingCodexTurns.clear();
654
+ actor.startingCodexMessageIds.clear();
655
+ actor.uncertainCodexMessages.clear();
656
+ actor.deletedCodexMessageIds.clear();
657
+ actor.pendingQuestion = null;
658
+ actor.focusedBy.clear();
659
+ actor.subscribers.clear();
660
+ this.actors.delete(session);
661
+ this.persistPendingQuestions();
662
+ this.persistChatReceipts();
663
+ }
664
+ subscribe(client, session, afterSeq, newerThanMs, preview) {
665
+ const actor = this.actor(session);
666
+ // ライブビュー消灯(preview 購読者不在)の事後解析用: 誰がどのフラグで購読したかを残す。
667
+ this.options.log?.(`audit subscribe session=${session} preview=${preview}` +
668
+ ` afterSeq=${afterSeq ?? "-"} newerThanMs=${newerThanMs ?? "-"}` +
669
+ ` existing=${actor.subscribers.has(client)} subscribers=${actor.subscribers.size}`);
670
+ const existing = actor.subscribers.get(client);
671
+ if (existing !== undefined) {
672
+ const wasPreview = existing.preview;
673
+ existing.preview = preview;
674
+ if (preview) {
675
+ actor.focusedBy.add(client);
676
+ if (!wasPreview)
677
+ this.bumpSafe(session, "chat-open");
678
+ }
679
+ else {
680
+ actor.focusedBy.delete(client);
681
+ }
682
+ // 初回購読が session_start/resume の metadata 保存より先に届くと、startSharedTail は
683
+ // metadata=null で開始できない。一覧へ戻るまで購読自体は残るため、同一 client の
684
+ // 再購読を単なる no-op にせず、共有 tail がまだ無い場合は開始を再試行する。
685
+ // Codex の App Server live は actor.tail が null でも codexLive を持つため重複起動しない。
686
+ if (actor.tail === null && actor.codexLive === null) {
687
+ // preview pump を履歴再生より先に起動する。socket writer 側の優先配送と組み合わせ、
688
+ // pane capture の初回フレームが大量の履歴行の後ろへ並ぶのを防ぐ。
689
+ this.syncPreview(session, actor);
690
+ this.startSharedTail(session, actor, newerThanMs ?? null);
691
+ }
692
+ else {
693
+ this.syncPreview(session, actor);
694
+ }
695
+ // preview=false 中に engine が route できなかった image/subagent event を、同じ
696
+ // subscriber の前面昇格時にも afterSeq から回収する。既存購読だからと no-op にしない。
697
+ if (!existing.backfilling && afterSeq !== undefined) {
698
+ if (this.canReplay(actor, afterSeq)) {
699
+ for (const event of actor.replayBuffer) {
700
+ if (event.serverSeq > afterSeq) {
701
+ this.sendTo(client, { type: "conversation_event", session, ...event });
702
+ }
703
+ }
704
+ }
705
+ else {
706
+ this.startBackfill(client, session, actor, newerThanMs ?? null);
707
+ }
708
+ }
709
+ return;
710
+ }
711
+ const first = actor.subscribers.size === 0;
712
+ actor.subscribers.set(client, { preview, backfilling: false });
713
+ if (preview) {
714
+ actor.focusedBy.add(client);
715
+ this.bumpSafe(session, "chat-open");
716
+ }
717
+ // 初回 backfill が同期的に多数の行を生成する前に pane capture を開始する。
718
+ this.syncPreview(session, actor);
719
+ if (first) {
720
+ this.startSharedTail(session, actor, newerThanMs ?? null);
721
+ // processing 完了で一度 unsubscribe された後も、actor の replay buffer が残る間は
722
+ // foreground の afterSeq から ephemeral image/subagent event を回収する。
723
+ if (afterSeq !== undefined && this.canReplay(actor, afterSeq)) {
724
+ for (const event of actor.replayBuffer) {
725
+ if (event.serverSeq > afterSeq) {
726
+ this.sendTo(client, { type: "conversation_event", session, ...event });
727
+ }
728
+ }
729
+ }
730
+ }
731
+ else if (afterSeq !== undefined && this.canReplay(actor, afterSeq)) {
732
+ for (const event of actor.replayBuffer) {
733
+ if (event.serverSeq > afterSeq)
734
+ this.sendTo(client, { type: "conversation_event", session, ...event });
735
+ }
736
+ }
737
+ else {
738
+ this.startBackfill(client, session, actor, newerThanMs ?? null);
739
+ }
740
+ }
741
+ unsubscribe(client, session, actor) {
742
+ // 離脱は購読の有無に関わらず計時リセット(旧 touchHeartbeat の chat-leave 相当)。
743
+ // session_idle_hint は同一接続で open していない会話にも届きうる。bump は既存 state を
744
+ // 保持するため、処理中(active)を idle へ降格させない。
745
+ // preview=false はフォーカス外ログ同期。ユーザーの open/leave ではなく、処理中状態の
746
+ // heartbeat を上書きして reaper のアイドル時計を動かしてはならない。
747
+ if (actor.subscribers.get(client)?.preview !== false)
748
+ this.bumpSafe(session, "chat-leave");
749
+ if (!actor.subscribers.delete(client))
750
+ return;
751
+ actor.focusedBy.delete(client);
752
+ actor.backfillTails.get(client)?.stop();
753
+ actor.backfillTails.delete(client);
754
+ this.syncPreview(session, actor);
755
+ if (actor.subscribers.size === 0) {
756
+ if (actor.tailRetryTimer !== null)
757
+ clearTimeout(actor.tailRetryTimer);
758
+ actor.tailRetryTimer = null;
759
+ actor.tail?.stop();
760
+ actor.tail = null;
761
+ actor.codexLive = null;
762
+ if (!this.activeCodexTurns.has(session))
763
+ this.codexTurnController?.closeSession(session);
764
+ }
765
+ }
766
+ startSharedTail(session, actor, newerThanMs) {
767
+ const meta = this.options.metadataStore.get(session);
768
+ if (meta === null) {
769
+ // VS Code 等の外部クライアント由来会話を初めて resume すると、Hub 購読が
770
+ // launchCore の metadata 永続化よりわずかに先行し得る。購読を保持したまま
771
+ // metadata の出現を待ち、一覧へ戻らなくても初回表示を開始できるようにする。
772
+ if (actor.tailRetryTimer === null && actor.subscribers.size > 0) {
773
+ actor.tailRetryTimer = setTimeout(() => {
774
+ actor.tailRetryTimer = null;
775
+ if (actor.subscribers.size > 0 && actor.tail === null && actor.codexLive === null) {
776
+ this.startSharedTail(session, actor, newerThanMs);
777
+ }
778
+ }, 100);
779
+ actor.tailRetryTimer.unref();
780
+ }
781
+ return;
782
+ }
783
+ if (actor.tailRetryTimer !== null)
784
+ clearTimeout(actor.tailRetryTimer);
785
+ actor.tailRetryTimer = null;
786
+ if (meta.agent === "codex" && meta.providerSessionId) {
787
+ this.startCodexSharedStream(session, actor, meta.cwd, meta.providerSessionId, newerThanMs);
788
+ return;
789
+ }
790
+ if (this.options.tailFactory === undefined)
791
+ return;
792
+ const tail = this.options.tailFactory((payload) => {
793
+ this.publishConversationEvent(session, actor, payload);
794
+ });
795
+ actor.tail = tail;
796
+ tail.open(meta.cwd, meta.providerSessionId ?? meta.claudeSessionId ?? null, newerThanMs, meta.agent ?? "claude");
797
+ }
798
+ startCodexSharedStream(session, actor, cwd, threadId, newerThanMs) {
799
+ const state = {
800
+ phase: "starting",
801
+ initialItemIds: new Set(),
802
+ initialContentCounts: new Map(),
803
+ buffered: [],
804
+ seenItemIds: new Set(),
805
+ publishedContentCounts: new Map(),
806
+ scanContentCounts: new Map(),
807
+ fallbackBaselineCounts: new Map(),
808
+ disconnected: false,
809
+ };
810
+ actor.codexLive = state;
811
+ const controller = this.ensureCodexTurnController();
812
+ const subscribe = controller.subscribeSession;
813
+ if (subscribe === undefined) {
814
+ this.startCodexFallback(session, actor, cwd, threadId, newerThanMs, false);
815
+ return;
816
+ }
817
+ void subscribe.call(controller, { session, threadId, cwd }).then((snapshot) => {
818
+ if (actor.codexLive !== state || actor.subscribers.size === 0)
819
+ return;
820
+ state.initialItemIds = snapshot.itemIds;
821
+ state.initialContentCounts = snapshot.contentCounts;
822
+ if (!snapshot.liveSubscribed) {
823
+ this.options.log?.("Codex App Server は未materialize threadをlive購読できないため、rollout fallbackへ移行");
824
+ this.startCodexFallback(session, actor, cwd, threadId, newerThanMs, false);
825
+ return;
826
+ }
827
+ state.phase = "backfill";
828
+ this.openCodexRollout(session, actor, cwd, threadId, newerThanMs);
829
+ }, (error) => {
830
+ if (actor.codexLive !== state || actor.subscribers.size === 0)
831
+ return;
832
+ this.options.log?.(`Codex App Server 購読失敗、rollout fallback へ移行: ${String(error)}`);
833
+ this.startCodexFallback(session, actor, cwd, threadId, newerThanMs, false);
834
+ });
835
+ }
836
+ openCodexRollout(session, actor, cwd, threadId, newerThanMs) {
837
+ if (this.options.tailFactory === undefined)
838
+ return;
839
+ const state = actor.codexLive;
840
+ if (state === null)
841
+ return;
842
+ let tail = null;
843
+ tail = this.options.tailFactory((payload) => {
844
+ if (actor.codexLive !== state)
845
+ return;
846
+ const isHistoryDone = payload.type === "chat_output" && payload.streamId === HISTORY_DONE_STREAM_ID;
847
+ const contentKey = chatContentKey(payload);
848
+ if (state.phase === "live")
849
+ return; // 正常時の live 本文は App Server のみ。
850
+ if (state.phase === "fallback-scan") {
851
+ if (isHistoryDone) {
852
+ state.phase = "fallback-live";
853
+ return; // 通常 backfill の pc:history-done を再送しない。
854
+ }
855
+ if (contentKey === null)
856
+ return; // fallback の model/token marker は controller 系統と混ぜない。
857
+ const occurrence = (state.scanContentCounts.get(contentKey) ?? 0) + 1;
858
+ state.scanContentCounts.set(contentKey, occurrence);
859
+ if (occurrence <= (state.fallbackBaselineCounts.get(contentKey) ?? 0))
860
+ return;
861
+ this.publishCodexContent(session, actor, state, payload);
862
+ return;
863
+ }
864
+ if (state.phase === "fallback-live") {
865
+ if (contentKey !== null)
866
+ this.publishCodexContent(session, actor, state, payload);
867
+ return;
868
+ }
869
+ // App Server 購読を先に確立して通知を buffer し、その後 rollout を EOF まで読む。
870
+ // resume 応答の item ID / content occurrence を境界スナップショットとして使い、
871
+ // EOF 後は rollout にまだ無い buffered item だけを flush する。event_msg 自体には
872
+ // item ID が無いため、同文は Set ではなく occurrence count で照合する。
873
+ this.publishConversationEvent(session, actor, payload);
874
+ if (contentKey !== null)
875
+ incrementCount(state.publishedContentCounts, contentKey);
876
+ if (!isHistoryDone)
877
+ return;
878
+ if (state.disconnected) {
879
+ // fallback 確定前に届いた App Server item は、この継続 rollout と同じ内容を
880
+ // 別 streamId で持ち得る。rollout を唯一の一次ソースにした時点で破棄し、
881
+ // history 完了時に flush して会話全体を二重表示しない。
882
+ state.buffered.length = 0;
883
+ state.phase = "fallback-live"; // この tail は既に同じ EOF 境界にいるので継続利用する。
884
+ }
885
+ else {
886
+ this.flushCodexBuffer(session, actor, state);
887
+ state.phase = "live";
888
+ // 本文は以後 App Server のみを採用するが、rollout tail 自体は lifecycle の
889
+ // 副監視として残す。ここで止めると、後続 turn の App Server `turn/completed`
890
+ // 欠落時に terminal event(task_complete / turn_aborted)を観測できず、
891
+ // 処理中状態を自己修復できない。
892
+ }
893
+ }, (event) => {
894
+ if (actor.codexLive !== state || event.state !== "done")
895
+ return;
896
+ if (this.codexTurnController?.reconcileCompletedTurn?.(session, event.turnId) === true) {
897
+ this.options.log?.(`Codex rollout terminal event で処理完了を補完 session=${session} turn=${event.turnId}`);
898
+ }
899
+ });
900
+ actor.tail = tail;
901
+ tail.open(cwd, threadId, newerThanMs, "codex");
902
+ }
903
+ handleCodexChatItem(session, itemId, payload) {
904
+ const actor = this.actors.get(session);
905
+ const state = actor?.codexLive;
906
+ if (actor === undefined || state == null || actor.subscribers.size === 0)
907
+ return;
908
+ if (state.seenItemIds.has(itemId))
909
+ return;
910
+ state.seenItemIds.add(itemId);
911
+ // subscribe 自体は接続を保持していても、未materialize thread は thread/resume が
912
+ // 成立しておらず live 通知の完全性を保証できない。fallback 選択後は rollout だけを
913
+ // 一次ソースにし、運良く届いた App Server item を backfill buffer へ混ぜて重複させない。
914
+ if (state.disconnected)
915
+ return;
916
+ if (state.phase === "starting" || state.phase === "backfill") {
917
+ // tool_activity は buffer せず捨てる。履歴は rollout(backfill)側が同じカードを
918
+ // 供給するのが正で、live 由来と rollout 由来のカードは id が一致しないため
919
+ // snapshot occurrence 照合に混ぜると開くたびに重複しうる。backfill 中に完了した
920
+ // カードが落ちても、次回オープンの rollout 履歴で必ず表示される。
921
+ if (payload.type === "tool_activity")
922
+ return;
923
+ state.buffered.push({ itemId, payload });
924
+ return;
925
+ }
926
+ if (state.phase === "live")
927
+ this.publishCodexContent(session, actor, state, payload);
928
+ // fallback 中は上の disconnected guard で、遅着した旧接続通知を破棄する。
929
+ }
930
+ flushCodexBuffer(session, actor, state) {
931
+ const remainingSnapshotCounts = new Map(state.initialContentCounts);
932
+ const rolloutPostSnapshotCounts = new Map();
933
+ for (const [key, rolloutCount] of state.publishedContentCounts) {
934
+ const snapshotCount = remainingSnapshotCounts.get(key) ?? 0;
935
+ const remaining = Math.max(0, snapshotCount - rolloutCount);
936
+ if (remaining === 0)
937
+ remainingSnapshotCounts.delete(key);
938
+ else
939
+ remainingSnapshotCounts.set(key, remaining);
940
+ const postSnapshotCount = Math.max(0, rolloutCount - snapshotCount);
941
+ if (postSnapshotCount > 0)
942
+ rolloutPostSnapshotCounts.set(key, postSnapshotCount);
943
+ }
944
+ for (const item of state.buffered) {
945
+ const key = chatContentKey(item.payload);
946
+ const isSnapshotItem = state.initialItemIds.has(item.itemId);
947
+ // snapshot item は rollout がその content occurrence まで含んでいれば重複。含まなければ
948
+ // resume と rollout flush の時間差なので App Server item で欠落を補完する。
949
+ if (isSnapshotItem && key !== null && (remainingSnapshotCounts.get(key) ?? 0) === 0)
950
+ continue;
951
+ if (isSnapshotItem && key !== null) {
952
+ const remaining = (remainingSnapshotCounts.get(key) ?? 0) - 1;
953
+ if (remaining <= 0)
954
+ remainingSnapshotCounts.delete(key);
955
+ else
956
+ remainingSnapshotCounts.set(key, remaining);
957
+ }
958
+ // resume snapshot の後に App Server から届いた item も、rollout が EOF までに
959
+ // 同じ occurrence を取り込んでいれば既に配信済み。snapshot 以前の同文だけでは
960
+ // 新着を消さないよう、rolloutCount - snapshotCount の超過分だけを消費する。
961
+ if (!isSnapshotItem && key !== null) {
962
+ const covered = rolloutPostSnapshotCounts.get(key) ?? 0;
963
+ if (covered > 0) {
964
+ if (covered === 1)
965
+ rolloutPostSnapshotCounts.delete(key);
966
+ else
967
+ rolloutPostSnapshotCounts.set(key, covered - 1);
968
+ continue;
969
+ }
970
+ }
971
+ this.publishCodexContent(session, actor, state, item.payload);
972
+ }
973
+ state.buffered.length = 0;
974
+ }
975
+ handleCodexDisconnect(session, error) {
976
+ const actor = this.actors.get(session);
977
+ const state = actor?.codexLive;
978
+ if (actor === undefined || state == null || actor.subscribers.size === 0)
979
+ return;
980
+ this.options.log?.(`Codex App Server 接続断、rollout fallback へ移行: ${String(error)}`);
981
+ state.disconnected = true;
982
+ if (state.phase === "starting" || state.phase === "backfill")
983
+ return;
984
+ if (state.phase !== "live")
985
+ return;
986
+ const meta = this.options.metadataStore.get(session);
987
+ if (meta === null)
988
+ return;
989
+ this.startCodexFallback(session, actor, meta.cwd, meta.providerSessionId ?? meta.claudeSessionId ?? null, null, true);
990
+ }
991
+ startCodexFallback(session, actor, cwd, threadId, newerThanMs, rescan) {
992
+ const state = actor.codexLive;
993
+ if (state === null)
994
+ return;
995
+ actor.tail?.stop();
996
+ state.phase = rescan ? "fallback-scan" : "backfill";
997
+ state.disconnected = true;
998
+ state.buffered.length = 0;
999
+ state.scanContentCounts.clear();
1000
+ state.fallbackBaselineCounts = new Map(state.publishedContentCounts);
1001
+ this.openCodexRollout(session, actor, cwd, threadId, newerThanMs);
1002
+ }
1003
+ publishCodexContent(session, actor, state, payload) {
1004
+ const key = chatContentKey(payload);
1005
+ if (key !== null)
1006
+ incrementCount(state.publishedContentCounts, key);
1007
+ this.publishConversationEvent(session, actor, payload);
1008
+ }
1009
+ publishConversationEvent(session, actor, payload) {
1010
+ const event = { serverSeq: actor.nextServerSeq++, payload };
1011
+ actor.replayBuffer.push(event);
1012
+ if (actor.replayBuffer.length > this.replayLimit) {
1013
+ actor.replayBuffer.splice(0, actor.replayBuffer.length - this.replayLimit);
1014
+ }
1015
+ for (const [subscriber, subscriberState] of actor.subscribers) {
1016
+ if (!subscriberState.backfilling) {
1017
+ this.sendTo(subscriber, { type: "conversation_event", session, ...event });
1018
+ }
1019
+ }
1020
+ }
1021
+ startBackfill(client, session, actor, newerThanMs) {
1022
+ if (this.options.tailFactory === undefined)
1023
+ return;
1024
+ const meta = this.options.metadataStore.get(session);
1025
+ if (meta === null)
1026
+ return;
1027
+ actor.subscribers.get(client).backfilling = true;
1028
+ const boundarySeq = actor.nextServerSeq - 1;
1029
+ const liveCountsAtStart = new Map(actor.codexLive?.publishedContentCounts ?? []);
1030
+ const backfillCounts = new Map();
1031
+ let completedSynchronously = false;
1032
+ let tail = null;
1033
+ const finish = () => {
1034
+ const state = actor.subscribers.get(client);
1035
+ if (state === undefined)
1036
+ return;
1037
+ // 共有 tail の emit 時点でイベントは transcript に存在する。その後 EOF まで読む
1038
+ // 履歴 tail に必ず含まれるため、同じ同期処理で live へ切り替えても取りこぼさない。
1039
+ state.backfilling = false;
1040
+ if (actor.codexLive !== null) {
1041
+ const representedAfterBoundary = new Map();
1042
+ for (const [key, count] of backfillCounts) {
1043
+ const excess = count - (liveCountsAtStart.get(key) ?? 0);
1044
+ if (excess > 0)
1045
+ representedAfterBoundary.set(key, excess);
1046
+ }
1047
+ for (const event of actor.replayBuffer) {
1048
+ if (event.serverSeq <= boundarySeq)
1049
+ continue;
1050
+ const key = chatContentKey(event.payload);
1051
+ const represented = key === null ? 0 : (representedAfterBoundary.get(key) ?? 0);
1052
+ if (key !== null && represented > 0) {
1053
+ representedAfterBoundary.set(key, represented - 1);
1054
+ continue;
1055
+ }
1056
+ this.sendTo(client, { type: "conversation_event", session, ...event });
1057
+ }
1058
+ }
1059
+ completedSynchronously = tail === null;
1060
+ tail?.stop();
1061
+ actor.backfillTails.delete(client);
1062
+ };
1063
+ tail = this.options.tailFactory((payload) => {
1064
+ this.sendTo(client, { type: "conversation_event", session, serverSeq: 0, payload });
1065
+ const key = chatContentKey(payload);
1066
+ if (key !== null)
1067
+ incrementCount(backfillCounts, key);
1068
+ if (payload.type === "chat_output" && payload.streamId === HISTORY_DONE_STREAM_ID)
1069
+ finish();
1070
+ });
1071
+ if (!completedSynchronously)
1072
+ actor.backfillTails.set(client, tail);
1073
+ tail.open(meta.cwd, meta.providerSessionId ?? meta.claudeSessionId ?? null, newerThanMs, meta.agent ?? "claude");
1074
+ if (completedSynchronously)
1075
+ tail.stop();
1076
+ }
1077
+ canReplay(actor, afterSeq) {
1078
+ const first = actor.replayBuffer[0]?.serverSeq ?? actor.nextServerSeq;
1079
+ const last = actor.nextServerSeq - 1;
1080
+ return afterSeq >= first - 1 && afterSeq <= last;
1081
+ }
1082
+ /** watch 状態・処理中遷移で全 actor の pump 要否を見直す(対象は疎なので全走査で足りる)。 */
1083
+ syncAllPreviews() {
1084
+ for (const [session, actor] of this.actors)
1085
+ this.syncPreview(session, actor);
1086
+ }
1087
+ syncPreview(session, actor) {
1088
+ const wantsForeground = [...actor.subscribers.values()].some((state) => state.preview);
1089
+ // 一覧 Mission Control: watcher がいる間、処理中会話は前面購読が無くても pump する。
1090
+ const wantsWatch = this.previewWatchers.size > 0 && actor.processingSince !== null;
1091
+ if (!wantsForeground && !wantsWatch) {
1092
+ if (actor.previewPump !== null)
1093
+ this.options.log?.(`audit preview-pump stop session=${session}`);
1094
+ actor.previewPump?.stop();
1095
+ actor.previewPump = null;
1096
+ return;
1097
+ }
1098
+ if (actor.previewPump !== null || this.options.previewPumpFactory === undefined)
1099
+ return;
1100
+ this.options.log?.(`audit preview-pump start session=${session}`);
1101
+ const pump = this.options.previewPumpFactory((payload) => {
1102
+ const outgoingPayload = reconcileCodexInterruptedPreview(payload, this.options.metadataStore.get(session)?.agent === "codex", actor.processingSince !== null);
1103
+ const delivered = new Set();
1104
+ for (const [client, state] of actor.subscribers) {
1105
+ if (state.preview) {
1106
+ this.sendTo(client, {
1107
+ type: "conversation_pane_preview",
1108
+ session,
1109
+ payload: outgoingPayload,
1110
+ });
1111
+ delivered.add(client);
1112
+ }
1113
+ }
1114
+ // watcher へは処理中の会話だけ流す(前面都合で動く pump のアイドル画面は一覧に不要)。
1115
+ // 消灯フレーム(active=false)だけは処理完了直後でも届け、一覧側の表示を確実に落とす。
1116
+ const inactiveFrame = outgoingPayload.type === "pane_preview" && !outgoingPayload.active;
1117
+ if (actor.processingSince !== null || inactiveFrame) {
1118
+ for (const watcher of this.previewWatchers) {
1119
+ if (!delivered.has(watcher)) {
1120
+ this.sendTo(watcher, {
1121
+ type: "conversation_pane_preview",
1122
+ session,
1123
+ payload: outgoingPayload,
1124
+ });
1125
+ }
1126
+ }
1127
+ }
1128
+ }, (mode) => {
1129
+ // tmux 側 Shift+Tab の permission mode 切替をクライアント表示へ反映する
1130
+ // (iOS は mode_set_response をそのまま現況として採用する)。
1131
+ this.modePushSeq += 1;
1132
+ const payload = {
1133
+ type: "mode_set_response", v: PROTOCOL_V1, id: `mode-watch-${this.modePushSeq}`, mode,
1134
+ };
1135
+ for (const [client, state] of actor.subscribers) {
1136
+ if (state.preview)
1137
+ this.sendTo(client, { type: "conversation_mode", session, payload });
1138
+ }
1139
+ },
1140
+ // 前面購読が無い(一覧 watch だけの)間は低頻度で capture し、多重 pump のコストを抑える。
1141
+ () => ([...actor.subscribers.values()].some((state) => state.preview) ? 250 : 1000));
1142
+ actor.previewPump = pump;
1143
+ pump.start(session, this.options.metadataStore.get(session)?.agent === "codex" ? "codex_terminal" : "claude_status",
1144
+ // 処理中の pump 起動(=一覧 watch 起因が典型)は初回フレームから送る。静止 pane
1145
+ //(承認ダイアログ等)で一覧カードが空のままになるのを防ぐ。
1146
+ { emitInitial: actor.processingSince !== null });
1147
+ }
1148
+ actor(session) {
1149
+ let actor = this.actors.get(session);
1150
+ if (actor === undefined) {
1151
+ actor = { pendingQuestion: null, processingSince: null, focusedBy: new Set(), subscribers: new Map(),
1152
+ nextServerSeq: 1, replayBuffer: [], tail: null, tailRetryTimer: null,
1153
+ previewPump: null, backfillTails: new Map(),
1154
+ seenClientMessageIds: new Set(), deliveredChatMessageIds: new Map(),
1155
+ deliveredCodexMessageIds: new Map(),
1156
+ deletedChatMessageIds: new Map(), deletedCodexMessageIds: new Map(),
1157
+ pendingChatMessages: new Map(), injectingChatMessageIds: new Set(),
1158
+ uncertainChatMessages: new Map(), uncertainCodexMessages: new Map(),
1159
+ pendingCodexTurns: new Map(), startingCodexMessageIds: new Set(),
1160
+ runtimeClaim: null, codexLive: null,
1161
+ chatQueue: [], chatOrder: [], chatDrainRunning: false, chatDrainBlocked: false,
1162
+ codexQueue: [], codexOrder: [], codexDrainRunning: false, codexDrainBlocked: false };
1163
+ this.actors.set(session, actor);
1164
+ }
1165
+ return actor;
1166
+ }
1167
+ claimInput(actor, clientMessageId) {
1168
+ const duplicate = actor.seenClientMessageIds.has(clientMessageId);
1169
+ if (duplicate)
1170
+ return true;
1171
+ actor.seenClientMessageIds.add(clientMessageId);
1172
+ if (actor.seenClientMessageIds.size > 200) {
1173
+ const oldest = actor.seenClientMessageIds.values().next().value;
1174
+ if (oldest !== undefined)
1175
+ actor.seenClientMessageIds.delete(oldest);
1176
+ }
1177
+ return false;
1178
+ }
1179
+ deletePendingChatMessage(client, requestId, session, clientMessageId, actor) {
1180
+ if (actor.injectingChatMessageIds.has(clientMessageId) ||
1181
+ actor.uncertainChatMessages.has(clientMessageId) ||
1182
+ hasDeliveredReceipt(actor.deliveredChatMessageIds, clientMessageId)) {
1183
+ this.sendTo(client, {
1184
+ type: "pending_message_delete_result", id: requestId, status: "processing",
1185
+ });
1186
+ return;
1187
+ }
1188
+ const entry = actor.pendingChatMessages.get(clientMessageId);
1189
+ if (entry === undefined) {
1190
+ if (!hasDeliveredReceipt(actor.deletedChatMessageIds, clientMessageId)) {
1191
+ recordDeliveredReceipt(actor.deletedChatMessageIds, clientMessageId);
1192
+ if (!this.persistChatReceipts()) {
1193
+ actor.deletedChatMessageIds.delete(clientMessageId);
1194
+ this.sendTo(client, {
1195
+ type: "pending_message_delete_result",
1196
+ id: requestId,
1197
+ status: "failed",
1198
+ error: "Session Hub missing message delete receipt write failed",
1199
+ });
1200
+ return;
1201
+ }
1202
+ }
1203
+ this.sendTo(client, {
1204
+ type: "pending_message_delete_result", id: requestId, status: "not_found",
1205
+ });
1206
+ return;
1207
+ }
1208
+ const previousQueue = actor.chatQueue;
1209
+ const previousOrder = actor.chatOrder;
1210
+ actor.pendingChatMessages.delete(clientMessageId);
1211
+ actor.chatQueue = previousQueue.filter((queued) => queued.message.clientMessageId !== clientMessageId);
1212
+ actor.chatOrder = previousOrder.filter((id) => id !== clientMessageId);
1213
+ recordDeliveredReceipt(actor.deletedChatMessageIds, clientMessageId);
1214
+ if (!this.persistChatReceipts()) {
1215
+ actor.deletedChatMessageIds.delete(clientMessageId);
1216
+ actor.pendingChatMessages.set(clientMessageId, entry);
1217
+ actor.chatQueue = previousQueue;
1218
+ actor.chatOrder = previousOrder;
1219
+ this.sendTo(client, {
1220
+ type: "pending_message_delete_result",
1221
+ id: requestId,
1222
+ status: "failed",
1223
+ error: "Session Hub pending message delete receipt write failed",
1224
+ });
1225
+ return;
1226
+ }
1227
+ for (const waiter of entry.waiters) {
1228
+ this.sendTo(waiter.client, {
1229
+ type: "chat_send_result",
1230
+ id: waiter.id,
1231
+ status: "failed",
1232
+ error: "Message was deleted before processing",
1233
+ });
1234
+ }
1235
+ this.sendTo(client, {
1236
+ type: "pending_message_delete_result", id: requestId, status: "deleted",
1237
+ });
1238
+ actor.chatDrainBlocked = false;
1239
+ void this.drainChatQueue(session, actor);
1240
+ }
1241
+ deletePendingCodexTurn(client, requestId, session, clientMessageId, actor) {
1242
+ if (actor.startingCodexMessageIds.has(clientMessageId) ||
1243
+ actor.uncertainCodexMessages.has(clientMessageId) ||
1244
+ hasDeliveredReceipt(actor.deliveredCodexMessageIds, clientMessageId)) {
1245
+ this.sendTo(client, {
1246
+ type: "pending_message_delete_result", id: requestId, status: "processing",
1247
+ });
1248
+ return;
1249
+ }
1250
+ const entry = actor.pendingCodexTurns.get(clientMessageId);
1251
+ if (entry === undefined) {
1252
+ if (!hasDeliveredReceipt(actor.deletedCodexMessageIds, clientMessageId)) {
1253
+ recordDeliveredReceipt(actor.deletedCodexMessageIds, clientMessageId);
1254
+ if (!this.persistChatReceipts()) {
1255
+ actor.deletedCodexMessageIds.delete(clientMessageId);
1256
+ this.sendTo(client, {
1257
+ type: "pending_message_delete_result",
1258
+ id: requestId,
1259
+ status: "failed",
1260
+ error: "Session Hub missing Codex turn delete receipt write failed",
1261
+ });
1262
+ return;
1263
+ }
1264
+ }
1265
+ this.sendTo(client, {
1266
+ type: "pending_message_delete_result", id: requestId, status: "not_found",
1267
+ });
1268
+ return;
1269
+ }
1270
+ const previousQueue = actor.codexQueue;
1271
+ const previousOrder = actor.codexOrder;
1272
+ actor.pendingCodexTurns.delete(clientMessageId);
1273
+ actor.codexQueue = previousQueue.filter((queued) => queued.message.clientUserMessageId !== clientMessageId);
1274
+ actor.codexOrder = previousOrder.filter((id) => id !== clientMessageId);
1275
+ recordDeliveredReceipt(actor.deletedCodexMessageIds, clientMessageId);
1276
+ if (!this.persistChatReceipts()) {
1277
+ actor.deletedCodexMessageIds.delete(clientMessageId);
1278
+ actor.pendingCodexTurns.set(clientMessageId, entry);
1279
+ actor.codexQueue = previousQueue;
1280
+ actor.codexOrder = previousOrder;
1281
+ this.sendTo(client, {
1282
+ type: "pending_message_delete_result",
1283
+ id: requestId,
1284
+ status: "failed",
1285
+ error: "Session Hub pending Codex turn delete receipt write failed",
1286
+ });
1287
+ return;
1288
+ }
1289
+ for (const waiter of entry.waiters) {
1290
+ this.sendTo(waiter.client, {
1291
+ type: "codex_turn_result",
1292
+ id: waiter.id,
1293
+ status: "failed",
1294
+ error: "Codex turn was deleted before processing",
1295
+ });
1296
+ }
1297
+ this.sendTo(client, {
1298
+ type: "pending_message_delete_result", id: requestId, status: "deleted",
1299
+ });
1300
+ actor.codexDrainBlocked = false;
1301
+ void this.drainCodexQueue(session, actor);
1302
+ }
1303
+ async drainChatQueue(session, actor) {
1304
+ if (actor.chatDrainRunning || actor.chatDrainBlocked || actor.pendingQuestion !== null)
1305
+ return;
1306
+ actor.chatDrainRunning = true;
1307
+ try {
1308
+ while (actor.pendingQuestion === null) {
1309
+ const entry = actor.chatQueue[0];
1310
+ if (entry === undefined)
1311
+ break;
1312
+ const { message } = entry;
1313
+ // journal のセッション順序を権威とし、先頭が crash 復元の uncertain なら
1314
+ // 後続 queued だけを先に実行しない。明示 retry が先頭を queued へ戻すまで停止する。
1315
+ if (actor.chatOrder[0] !== message.clientMessageId)
1316
+ break;
1317
+ actor.chatQueue.shift();
1318
+ this.injectionsInFlight += 1;
1319
+ try {
1320
+ actor.injectingChatMessageIds.add(message.clientMessageId);
1321
+ if (!this.persistChatReceipts()) {
1322
+ actor.injectingChatMessageIds.delete(message.clientMessageId);
1323
+ actor.chatQueue.unshift(entry);
1324
+ actor.chatDrainBlocked = true;
1325
+ for (const waiter of entry.waiters) {
1326
+ this.sendTo(waiter.client, {
1327
+ type: "chat_send_result", id: waiter.id, status: "failed",
1328
+ error: "Session Hub injecting receipt write failed",
1329
+ });
1330
+ }
1331
+ continue;
1332
+ }
1333
+ await (this.options.chatInjector?.(message.text, session) ?? Promise.resolve());
1334
+ // 明示 kill / reaper kill が注入 await 中に actor を廃棄した場合、同名の新 actorへ
1335
+ // 古い receipt を復活させない。waiter は retireSession が既に失敗で解放している。
1336
+ if (this.actors.get(session) !== actor)
1337
+ continue;
1338
+ actor.injectingChatMessageIds.delete(message.clientMessageId);
1339
+ actor.pendingChatMessages.delete(message.clientMessageId);
1340
+ removeOrderedID(actor.chatOrder, message.clientMessageId);
1341
+ recordDeliveredReceipt(actor.deliveredChatMessageIds, message.clientMessageId);
1342
+ const receiptPersisted = this.persistChatReceipts();
1343
+ if (!receiptPersisted) {
1344
+ // pane 注入済みというメモリ上の権威結果を返す。failed を返すと利用者の明示再送で
1345
+ // 二重実行になるため、durability は dirty retry と監査ログで回復させる。
1346
+ this.options.log?.(`chat delivered receipt 遅延永続化 session=${session} clientMessageId=${message.clientMessageId}`);
1347
+ }
1348
+ for (const waiter of entry.waiters) {
1349
+ this.sendTo(waiter.client, { type: "chat_send_result", id: waiter.id, status: "accepted" });
1350
+ }
1351
+ }
1352
+ catch (error) {
1353
+ // retire が注入 await 中に actor を廃棄した場合、catch から同名 actor や
1354
+ // 古い会話のエラーマーカーを復活させない。
1355
+ if (this.actors.get(session) !== actor)
1356
+ continue;
1357
+ actor.injectingChatMessageIds.delete(message.clientMessageId);
1358
+ actor.pendingChatMessages.delete(message.clientMessageId);
1359
+ actor.uncertainChatMessages.set(message.clientMessageId, message);
1360
+ this.persistChatReceipts();
1361
+ for (const waiter of entry.waiters) {
1362
+ this.sendTo(waiter.client, {
1363
+ type: "chat_send_result", id: waiter.id, status: "failed", error: String(error),
1364
+ });
1365
+ }
1366
+ this.publishCodexMarker(session, `chat-send-error-${message.id}`, `⚠️ メッセージ送信失敗: ${String(error)}`);
1367
+ this.options.log?.(`chat 注入失敗 session=${session}: ${String(error)}`);
1368
+ }
1369
+ finally {
1370
+ this.injectionsInFlight -= 1;
1371
+ }
1372
+ }
1373
+ }
1374
+ finally {
1375
+ actor.chatDrainRunning = false;
1376
+ if (!actor.chatDrainBlocked && actor.pendingQuestion === null && actor.chatQueue.length > 0 &&
1377
+ actor.chatOrder[0] === actor.chatQueue[0]?.message.clientMessageId) {
1378
+ void this.drainChatQueue(session, actor);
1379
+ }
1380
+ }
1381
+ }
1382
+ ensureCodexTurnController() {
1383
+ if (this.codexTurnController !== null)
1384
+ return this.codexTurnController;
1385
+ const appServer = (this.options.codexAppServerFactory ?? (() => new CodexAppServerManager()))();
1386
+ const create = this.options.codexTurnControllerFactory ??
1387
+ ((options) => new CodexNativeTurnController(options));
1388
+ this.codexTurnController = create({
1389
+ appServer,
1390
+ onProcessing: (session, state) => {
1391
+ if (state === "active")
1392
+ this.activeCodexTurns.add(session);
1393
+ else
1394
+ this.activeCodexTurns.delete(session);
1395
+ this.applyProcessing(session, state);
1396
+ this.broadcast({ type: "session_processing", session, state });
1397
+ if (state === "done" && (this.actors.get(session)?.subscribers.size ?? 0) === 0) {
1398
+ this.codexTurnController?.closeSession(session);
1399
+ }
1400
+ },
1401
+ onModel: (session, model) => this.publishCodexMarker(session, CODEX_MODEL_STREAM_ID, model),
1402
+ onTokenUsage: (session, totalTokens, contextWindow) => {
1403
+ this.publishCodexMarker(session, CODEX_CONTEXT_STREAM_ID, String(totalTokens));
1404
+ if (contextWindow !== null) {
1405
+ this.publishCodexMarker(session, CODEX_CONTEXT_WINDOW_STREAM_ID, String(contextWindow));
1406
+ }
1407
+ },
1408
+ onQuestion: ({ session, id, questions }) => {
1409
+ const actor = this.actor(session);
1410
+ this.setPendingQuestion(session, actor, { id, questions, answerRoute: "codex_native" });
1411
+ this.broadcast({ type: "question_event", session, event: "prompt", id, questions });
1412
+ },
1413
+ onQuestionDismiss: (session, id) => {
1414
+ const actor = this.actors.get(session);
1415
+ // answerQuestion は first-wins 側が既に clear+broadcast 済み。controller 自発 dismiss
1416
+ // (turn 完了/close)のときだけここで状態とイベントを更新する。
1417
+ if (actor?.pendingQuestion?.id !== id)
1418
+ return;
1419
+ this.setPendingQuestion(session, actor, null);
1420
+ this.broadcast({ type: "question_event", session, event: "dismiss", id });
1421
+ void this.drainChatQueue(session, actor);
1422
+ },
1423
+ onChatItem: ({ session, itemId, payload }) => this.handleCodexChatItem(session, itemId, payload),
1424
+ onDisconnect: (session, error) => this.handleCodexDisconnect(session, error),
1425
+ onThreadTitle: ({ session, threadId, title, source, attempts, error }) => {
1426
+ if (error !== null) {
1427
+ this.options.log?.(`Codex title 生成失敗 session=${session} thread=${threadId} attempts=${attempts}: ${error}`);
1428
+ return;
1429
+ }
1430
+ const attemptSuffix = attempts > 1 ? ` attempts=${attempts}` : "";
1431
+ this.options.log?.(title === null
1432
+ ? `Codex title 生成スキップ session=${session} thread=${threadId}${attemptSuffix}`
1433
+ : source === "promptFallback"
1434
+ ? `Codex title 先頭文fallback session=${session} thread=${threadId}${attemptSuffix} title=${title}`
1435
+ : `Codex title AI生成成功 session=${session} thread=${threadId}${attemptSuffix} title=${title}`);
1436
+ },
1437
+ });
1438
+ return this.codexTurnController;
1439
+ }
1440
+ async drainCodexQueue(session, actor) {
1441
+ if (actor.codexDrainRunning || actor.codexDrainBlocked)
1442
+ return;
1443
+ actor.codexDrainRunning = true;
1444
+ try {
1445
+ while (true) {
1446
+ const entry = actor.codexQueue[0];
1447
+ if (entry === undefined)
1448
+ break;
1449
+ const id = entry.message.clientUserMessageId;
1450
+ if (actor.codexOrder[0] !== id)
1451
+ break;
1452
+ actor.codexQueue.shift();
1453
+ actor.startingCodexMessageIds.add(id);
1454
+ if (!this.persistChatReceipts()) {
1455
+ actor.startingCodexMessageIds.delete(id);
1456
+ actor.codexQueue.unshift(entry);
1457
+ actor.codexDrainBlocked = true;
1458
+ for (const waiter of entry.waiters) {
1459
+ this.sendTo(waiter.client, {
1460
+ type: "codex_turn_result", id: waiter.id, status: "failed",
1461
+ error: "Session Hub Codex starting receipt write failed",
1462
+ });
1463
+ }
1464
+ break;
1465
+ }
1466
+ this.codexStartsInFlight += 1;
1467
+ try {
1468
+ await this.startCodexTurn(entry, actor);
1469
+ }
1470
+ finally {
1471
+ this.codexStartsInFlight -= 1;
1472
+ }
1473
+ }
1474
+ }
1475
+ finally {
1476
+ actor.codexDrainRunning = false;
1477
+ if (!actor.codexDrainBlocked && actor.codexQueue.length > 0 &&
1478
+ actor.codexOrder[0] === actor.codexQueue[0]?.message.clientUserMessageId) {
1479
+ void this.drainCodexQueue(session, actor);
1480
+ }
1481
+ }
1482
+ }
1483
+ async startCodexTurn(entry, expectedActor) {
1484
+ const { message } = entry;
1485
+ try {
1486
+ await this.ensureCodexTurnController().startTurn({
1487
+ session: message.session, threadId: message.threadId, cwd: message.cwd,
1488
+ text: message.text, clientUserMessageId: message.clientUserMessageId,
1489
+ effort: message.effort,
1490
+ approvalPolicy: message.approvalPolicy,
1491
+ sandbox: message.sandbox,
1492
+ });
1493
+ const actor = this.actors.get(message.session);
1494
+ if (actor === undefined || actor !== expectedActor)
1495
+ return;
1496
+ actor.startingCodexMessageIds.delete(message.clientUserMessageId);
1497
+ actor.pendingCodexTurns.delete(message.clientUserMessageId);
1498
+ removeOrderedID(actor.codexOrder, message.clientUserMessageId);
1499
+ recordDeliveredReceipt(actor.deliveredCodexMessageIds, message.clientUserMessageId);
1500
+ const receiptPersisted = this.persistChatReceipts();
1501
+ if (!receiptPersisted) {
1502
+ this.options.log?.(`Codex delivered receipt 遅延永続化 session=${message.session} clientUserMessageId=${message.clientUserMessageId}`);
1503
+ }
1504
+ for (const waiter of entry.waiters) {
1505
+ this.sendTo(waiter.client, { type: "codex_turn_result", id: waiter.id, status: "started" });
1506
+ }
1507
+ }
1508
+ catch (error) {
1509
+ const actor = this.actors.get(message.session);
1510
+ if (actor === undefined || actor !== expectedActor)
1511
+ return;
1512
+ actor.startingCodexMessageIds.delete(message.clientUserMessageId);
1513
+ actor.pendingCodexTurns.delete(message.clientUserMessageId);
1514
+ actor.uncertainCodexMessages.set(message.clientUserMessageId, message);
1515
+ this.persistChatReceipts();
1516
+ for (const waiter of entry.waiters) {
1517
+ this.sendTo(waiter.client, {
1518
+ type: "codex_turn_result", id: waiter.id, status: "failed", error: String(error),
1519
+ });
1520
+ }
1521
+ this.publishCodexMarker(message.session, `codex-turn-error-${message.id}`, `⚠️ Codex turn 開始失敗: ${String(error)}`);
1522
+ this.options.log?.(`codex turn 開始失敗 session=${message.session}: ${String(error)}`);
1523
+ }
1524
+ }
1525
+ publishCodexMarker(session, streamId, text) {
1526
+ const actor = this.actor(session);
1527
+ this.publishConversationEvent(session, actor, {
1528
+ type: "chat_output", v: PROTOCOL_V1, streamId, role: "system", text, eof: true,
1529
+ });
1530
+ }
1531
+ applyProcessing(session, state) {
1532
+ const actor = this.actor(session);
1533
+ actor.processingSince = state === "active" ? this.now() : null;
1534
+ // 一覧 watch 中は処理開始/完了で pump を起動/停止する(前面購読とは独立)。
1535
+ this.syncPreview(session, actor);
1536
+ try {
1537
+ writeHeartbeat(this.options.heartbeatDir, session, { ts: this.now(), state: state === "active" ? "active" : "idle",
1538
+ event: state === "active" ? "hub-processing" : "hub-processing-done" });
1539
+ }
1540
+ catch (error) {
1541
+ this.options.log?.(`heartbeat 書込失敗: ${String(error)}`);
1542
+ }
1543
+ if (state === "done" && actor.pendingQuestion !== null) {
1544
+ const id = actor.pendingQuestion.id;
1545
+ this.setPendingQuestion(session, actor, null);
1546
+ this.broadcast({ type: "question_event", session, event: "dismiss", id });
1547
+ void this.drainChatQueue(session, actor);
1548
+ }
1549
+ }
1550
+ bumpSafe(session, event, fallbackState = "idle") {
1551
+ try {
1552
+ bumpHeartbeat(this.options.heartbeatDir, session, this.now(), event, fallbackState);
1553
+ }
1554
+ catch (error) {
1555
+ this.options.log?.(`heartbeat 書込失敗: ${String(error)}`);
1556
+ }
1557
+ }
1558
+ setPendingQuestion(session, actor, pending) {
1559
+ actor.pendingQuestion = pending;
1560
+ this.persistPendingQuestions();
1561
+ }
1562
+ persistPendingQuestions() {
1563
+ const target = this.options.pendingQuestionsPath;
1564
+ if (target === undefined)
1565
+ return;
1566
+ const persisted = {};
1567
+ for (const [session, actor] of this.actors) {
1568
+ if (actor.pendingQuestion !== null)
1569
+ persisted[session] = actor.pendingQuestion;
1570
+ }
1571
+ try {
1572
+ ensureDirectory0700(path.dirname(target));
1573
+ const tmp = `${target}.tmp-${process.pid}`;
1574
+ fs.writeFileSync(tmp, JSON.stringify(persisted), { mode: 0o600 });
1575
+ fs.renameSync(tmp, target);
1576
+ }
1577
+ catch (error) {
1578
+ this.options.log?.(`pending question 書込失敗: ${String(error)}`);
1579
+ }
1580
+ }
1581
+ readPendingQuestions() {
1582
+ const target = this.options.pendingQuestionsPath;
1583
+ if (target === undefined)
1584
+ return {};
1585
+ try {
1586
+ const parsed = JSON.parse(fs.readFileSync(target, "utf8"));
1587
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
1588
+ return {};
1589
+ const result = {};
1590
+ for (const [session, value] of Object.entries(parsed)) {
1591
+ if (!isPersistedPendingQuestion(value))
1592
+ continue;
1593
+ result[session] = value;
1594
+ }
1595
+ return result;
1596
+ }
1597
+ catch {
1598
+ return {};
1599
+ }
1600
+ }
1601
+ persistChatReceipts() {
1602
+ const target = this.options.chatReceiptsPath;
1603
+ if (target === undefined) {
1604
+ this.chatReceiptsDirty = false;
1605
+ return true;
1606
+ }
1607
+ const sessions = {};
1608
+ for (const [session, actor] of this.actors) {
1609
+ compactDeliveredReceipts(actor.deliveredChatMessageIds);
1610
+ compactDeliveredReceipts(actor.deliveredCodexMessageIds);
1611
+ compactDeliveredReceipts(actor.deletedChatMessageIds);
1612
+ compactDeliveredReceipts(actor.deletedCodexMessageIds);
1613
+ const queued = actor.chatQueue.map((entry) => entry.message);
1614
+ // shift 済みで注入中の entry も pending map には残るため必ず journal に含める。
1615
+ for (const entry of actor.pendingChatMessages.values()) {
1616
+ if (!actor.injectingChatMessageIds.has(entry.message.clientMessageId) &&
1617
+ !queued.some((message) => message.clientMessageId === entry.message.clientMessageId)) {
1618
+ queued.unshift(entry.message);
1619
+ }
1620
+ }
1621
+ const injecting = [...actor.pendingChatMessages.values()]
1622
+ .filter((entry) => actor.injectingChatMessageIds.has(entry.message.clientMessageId))
1623
+ .map((entry) => entry.message)
1624
+ .concat([...actor.uncertainChatMessages.values()]);
1625
+ const queuedCodex = actor.codexQueue.map((entry) => entry.message);
1626
+ for (const entry of actor.pendingCodexTurns.values()) {
1627
+ if (!actor.startingCodexMessageIds.has(entry.message.clientUserMessageId) &&
1628
+ !queuedCodex.some((message) => message.clientUserMessageId === entry.message.clientUserMessageId)) {
1629
+ queuedCodex.unshift(entry.message);
1630
+ }
1631
+ }
1632
+ const startingCodex = [...actor.pendingCodexTurns.values()]
1633
+ .filter((entry) => actor.startingCodexMessageIds.has(entry.message.clientUserMessageId))
1634
+ .map((entry) => entry.message)
1635
+ .concat([...actor.uncertainCodexMessages.values()]);
1636
+ if (queued.length === 0 && injecting.length === 0 && actor.deliveredChatMessageIds.size === 0 &&
1637
+ actor.deletedChatMessageIds.size === 0 && queuedCodex.length === 0 &&
1638
+ startingCodex.length === 0 && actor.deliveredCodexMessageIds.size === 0 &&
1639
+ actor.deletedCodexMessageIds.size === 0)
1640
+ continue;
1641
+ const sessionIdentity = receiptSessionIdentity(this.options.metadataStore.get(session));
1642
+ sessions[session] = {
1643
+ ...(sessionIdentity !== undefined ? { sessionIdentity } : {}),
1644
+ delivered: [...actor.deliveredChatMessageIds.keys()],
1645
+ deliveredAtMs: Object.fromEntries(actor.deliveredChatMessageIds),
1646
+ ...(actor.deletedChatMessageIds.size > 0 ? {
1647
+ deleted: [...actor.deletedChatMessageIds.keys()],
1648
+ deletedAtMs: Object.fromEntries(actor.deletedChatMessageIds),
1649
+ } : {}),
1650
+ queued, injecting, chatOrder: [...actor.chatOrder],
1651
+ deliveredCodex: [...actor.deliveredCodexMessageIds.keys()],
1652
+ deliveredCodexAtMs: Object.fromEntries(actor.deliveredCodexMessageIds),
1653
+ ...(actor.deletedCodexMessageIds.size > 0 ? {
1654
+ deletedCodex: [...actor.deletedCodexMessageIds.keys()],
1655
+ deletedCodexAtMs: Object.fromEntries(actor.deletedCodexMessageIds),
1656
+ } : {}),
1657
+ queuedCodex, startingCodex, codexOrder: [...actor.codexOrder],
1658
+ };
1659
+ }
1660
+ try {
1661
+ const contents = JSON.stringify({ version: 1, sessions });
1662
+ if (this.options.chatReceiptsWriter !== undefined) {
1663
+ this.options.chatReceiptsWriter(target, contents);
1664
+ }
1665
+ else {
1666
+ ensureDirectory0700(path.dirname(target));
1667
+ const tmp = `${target}.tmp-${process.pid}`;
1668
+ fs.writeFileSync(tmp, contents, { mode: 0o600 });
1669
+ fs.renameSync(tmp, target);
1670
+ }
1671
+ this.chatReceiptsDirty = false;
1672
+ return true;
1673
+ }
1674
+ catch (error) {
1675
+ this.chatReceiptsDirty = true;
1676
+ this.options.log?.(`chat receipt 書込失敗: ${String(error)}`);
1677
+ return false;
1678
+ }
1679
+ }
1680
+ readChatReceipts() {
1681
+ const target = this.options.chatReceiptsPath;
1682
+ if (target === undefined)
1683
+ return {};
1684
+ try {
1685
+ const parsed = JSON.parse(fs.readFileSync(target, "utf8"));
1686
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
1687
+ return {};
1688
+ const root = parsed;
1689
+ if (root["version"] !== 1 || typeof root["sessions"] !== "object" ||
1690
+ root["sessions"] === null || Array.isArray(root["sessions"]))
1691
+ return {};
1692
+ const result = {};
1693
+ for (const [session, value] of Object.entries(root["sessions"])) {
1694
+ if (!isPersistedChatReceipt(value))
1695
+ continue;
1696
+ result[session] = value;
1697
+ }
1698
+ return result;
1699
+ }
1700
+ catch {
1701
+ return {};
1702
+ }
1703
+ }
1704
+ }
1705
+ /**
1706
+ * Codex TUI 0.145 は abort 済みでも `Conversation interrupted` の下に古い
1707
+ * `Working (... esc to interrupt)` 行を残すことがある。Hub の turn lifecycle が
1708
+ * idle を確定済みなら、その矛盾フレームを消灯へ正規化し、旧 iOS でもライブ表示を
1709
+ * 復活させない。番号付き choice prompt など通常の idle pane はそのまま通す。
1710
+ */
1711
+ function reconcileCodexInterruptedPreview(payload, isCodex, processing) {
1712
+ if (!isCodex || processing || payload.type !== "pane_preview" || !payload.active)
1713
+ return payload;
1714
+ const lower = payload.text.toLowerCase();
1715
+ const interruptedIndex = lower.lastIndexOf("conversation interrupted");
1716
+ const workingIndex = lower.lastIndexOf("esc to interrupt");
1717
+ if (interruptedIndex < 0 || workingIndex <= interruptedIndex)
1718
+ return payload;
1719
+ // 中断後に新しい user prompt が在れば、その下の Working は新 turn の正当な表示。
1720
+ // stale 事例は interrupted → Working が直結し、入力欄プレースホルダは Working の後ろに在る。
1721
+ const between = lower.slice(interruptedIndex, workingIndex);
1722
+ if (/^[ \t]*[›❯][ \t]*\S/mu.test(between))
1723
+ return payload;
1724
+ return { ...payload, active: false, text: "" };
1725
+ }
1726
+ /** 監査行を必ず 1 行の key=value として保つ。 */
1727
+ function auditValue(value) {
1728
+ return value.replace(/[\s=]+/g, "_");
1729
+ }
1730
+ function isPersistedPendingQuestion(value) {
1731
+ if (typeof value !== "object" || value === null || Array.isArray(value))
1732
+ return false;
1733
+ const record = value;
1734
+ if (typeof record["id"] !== "string" || !Array.isArray(record["questions"]) ||
1735
+ (record["answerRoute"] !== "tui" && record["answerRoute"] !== "codex_native"))
1736
+ return false;
1737
+ return record["questions"].every((question) => {
1738
+ if (typeof question !== "object" || question === null || Array.isArray(question))
1739
+ return false;
1740
+ const item = question;
1741
+ return typeof item["header"] === "string" && typeof item["question"] === "string" &&
1742
+ Array.isArray(item["options"]) && typeof item["multiSelect"] === "boolean";
1743
+ });
1744
+ }
1745
+ function isPersistedChatReceipt(value) {
1746
+ if (typeof value !== "object" || value === null || Array.isArray(value))
1747
+ return false;
1748
+ const record = value;
1749
+ if (record["sessionIdentity"] !== undefined &&
1750
+ (typeof record["sessionIdentity"] !== "string" || record["sessionIdentity"].length === 0))
1751
+ return false;
1752
+ if (!Array.isArray(record["delivered"]) ||
1753
+ !record["delivered"].every((id) => typeof id === "string" && id.length > 0) ||
1754
+ (record["deleted"] !== undefined && (!Array.isArray(record["deleted"]) ||
1755
+ !record["deleted"].every((id) => typeof id === "string" && id.length > 0))) ||
1756
+ !Array.isArray(record["queued"]) || !Array.isArray(record["injecting"]) ||
1757
+ !Array.isArray(record["deliveredCodex"]) ||
1758
+ !record["deliveredCodex"].every((id) => typeof id === "string" && id.length > 0) ||
1759
+ (record["deletedCodex"] !== undefined && (!Array.isArray(record["deletedCodex"]) ||
1760
+ !record["deletedCodex"].every((id) => typeof id === "string" && id.length > 0))) ||
1761
+ !Array.isArray(record["startingCodex"]))
1762
+ return false;
1763
+ if (!isOptionalTimestampRecord(record["deliveredAtMs"]) ||
1764
+ !isOptionalTimestampRecord(record["deletedAtMs"]) ||
1765
+ !isOptionalTimestampRecord(record["deliveredCodexAtMs"]) ||
1766
+ !isOptionalTimestampRecord(record["deletedCodexAtMs"]) ||
1767
+ !isOptionalStringArray(record["chatOrder"]) || !isOptionalStringArray(record["codexOrder"]) ||
1768
+ (record["queuedCodex"] !== undefined && !Array.isArray(record["queuedCodex"])))
1769
+ return false;
1770
+ const validChat = [...record["queued"], ...record["injecting"]].every((item) => {
1771
+ if (typeof item !== "object" || item === null || Array.isArray(item))
1772
+ return false;
1773
+ const message = item;
1774
+ return message["type"] === "chat_send" && typeof message["id"] === "string" &&
1775
+ typeof message["session"] === "string" && typeof message["clientMessageId"] === "string" &&
1776
+ typeof message["text"] === "string" && message["text"].length > 0;
1777
+ });
1778
+ const validCodex = [...(record["queuedCodex"] ?? []),
1779
+ ...record["startingCodex"]].every((item) => {
1780
+ if (typeof item !== "object" || item === null || Array.isArray(item))
1781
+ return false;
1782
+ const message = item;
1783
+ return message["type"] === "codex_turn_submit" && typeof message["id"] === "string" &&
1784
+ typeof message["session"] === "string" && typeof message["clientUserMessageId"] === "string" &&
1785
+ typeof message["text"] === "string" && typeof message["threadId"] === "string" &&
1786
+ typeof message["cwd"] === "string";
1787
+ });
1788
+ return validChat && validCodex;
1789
+ }
1790
+ function isOptionalTimestampRecord(value) {
1791
+ if (value === undefined)
1792
+ return true;
1793
+ if (typeof value !== "object" || value === null || Array.isArray(value))
1794
+ return false;
1795
+ return Object.entries(value).every(([id, timestamp]) => id.length > 0 && typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp >= 0);
1796
+ }
1797
+ function isOptionalStringArray(value) {
1798
+ return value === undefined || (Array.isArray(value) &&
1799
+ value.every((id) => typeof id === "string" && id.length > 0));
1800
+ }
1801
+ /** session 名の再利用を durable receipt 上で区別する世代 ID。 */
1802
+ function receiptSessionIdentity(meta) {
1803
+ if (meta === null)
1804
+ return undefined;
1805
+ return JSON.stringify([
1806
+ meta.createdAt,
1807
+ meta.agent ?? "claude",
1808
+ meta.providerSessionId ?? null,
1809
+ meta.tmuxPaneId ?? meta.herdrPaneId ?? null,
1810
+ meta.cwd,
1811
+ ]);
1812
+ }
1813
+ function restoreOrder(persisted, fallback, valid) {
1814
+ const result = [];
1815
+ for (const id of [...(persisted ?? fallback), ...fallback, ...valid]) {
1816
+ if (valid.has(id) && !result.includes(id))
1817
+ result.push(id);
1818
+ }
1819
+ return result;
1820
+ }
1821
+ function hasDeliveredReceipt(receipts, id, now = Date.now()) {
1822
+ const deliveredAt = receipts.get(id);
1823
+ if (deliveredAt === undefined)
1824
+ return false;
1825
+ if (now - deliveredAt <= DELIVERED_RECEIPT_TTL_MS)
1826
+ return true;
1827
+ receipts.delete(id);
1828
+ return false;
1829
+ }
1830
+ function recordDeliveredReceipt(receipts, id, now = Date.now()) {
1831
+ receipts.delete(id);
1832
+ receipts.set(id, now);
1833
+ compactDeliveredReceipts(receipts, now);
1834
+ }
1835
+ function compactDeliveredReceipts(receipts, now = Date.now()) {
1836
+ for (const [id, deliveredAt] of receipts) {
1837
+ if (now - deliveredAt > DELIVERED_RECEIPT_TTL_MS)
1838
+ receipts.delete(id);
1839
+ }
1840
+ while (receipts.size > MAX_DELIVERED_RECEIPTS_PER_SESSION) {
1841
+ const oldest = receipts.keys().next().value;
1842
+ if (oldest === undefined)
1843
+ break;
1844
+ receipts.delete(oldest);
1845
+ }
1846
+ }
1847
+ function removeOrderedID(order, id) {
1848
+ const index = order.indexOf(id);
1849
+ if (index >= 0)
1850
+ order.splice(index, 1);
1851
+ }
1852
+ function sameCodexRetry(left, right) {
1853
+ return left.session === right.session && left.text === right.text &&
1854
+ left.clientUserMessageId === right.clientUserMessageId && left.effort === right.effort &&
1855
+ (left.approvalPolicy ?? null) === (right.approvalPolicy ?? null) &&
1856
+ left.sandbox === right.sandbox &&
1857
+ left.threadId === right.threadId && left.cwd === right.cwd;
1858
+ }
1859
+ function incrementCount(counts, key) {
1860
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1861
+ }
1862
+ //# sourceMappingURL=sessionHub.js.map