negotium 0.1.7 → 0.1.8

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 (256) hide show
  1. package/README.md +2 -2
  2. package/dist/chunk-0ay6cjdy.js +1331 -0
  3. package/dist/chunk-0ay6cjdy.js.map +16 -0
  4. package/dist/chunk-1k7t7bnx.js +97 -0
  5. package/dist/chunk-1k7t7bnx.js.map +10 -0
  6. package/dist/chunk-5ahkew9c.js +806 -0
  7. package/dist/chunk-5ahkew9c.js.map +17 -0
  8. package/dist/chunk-6cpn7ht2.js +287 -0
  9. package/dist/chunk-6cpn7ht2.js.map +13 -0
  10. package/dist/chunk-6kctn20e.js +3529 -0
  11. package/dist/chunk-6kctn20e.js.map +20 -0
  12. package/dist/chunk-7xqj7aex.js +77 -0
  13. package/dist/chunk-7xqj7aex.js.map +11 -0
  14. package/dist/chunk-9p0ww3tk.js +41 -0
  15. package/dist/chunk-9p0ww3tk.js.map +10 -0
  16. package/dist/chunk-a92a7nrk.js +252 -0
  17. package/dist/chunk-a92a7nrk.js.map +10 -0
  18. package/dist/chunk-c500xe9z.js +33 -0
  19. package/dist/chunk-c500xe9z.js.map +10 -0
  20. package/dist/chunk-cds1qxsf.js +1644 -0
  21. package/dist/chunk-cds1qxsf.js.map +17 -0
  22. package/dist/chunk-e4407g8x.js +274 -0
  23. package/dist/chunk-e4407g8x.js.map +10 -0
  24. package/dist/chunk-e5pbggsh.js +363 -0
  25. package/dist/chunk-e5pbggsh.js.map +11 -0
  26. package/dist/chunk-edb07sjp.js +567 -0
  27. package/dist/chunk-edb07sjp.js.map +14 -0
  28. package/dist/chunk-exd0v74a.js +3012 -0
  29. package/dist/chunk-exd0v74a.js.map +32 -0
  30. package/dist/chunk-fgrme9tr.js +4398 -0
  31. package/dist/chunk-fgrme9tr.js.map +47 -0
  32. package/dist/chunk-fka9eqe1.js +581 -0
  33. package/dist/chunk-fka9eqe1.js.map +11 -0
  34. package/dist/chunk-g82jygez.js +108 -0
  35. package/dist/chunk-g82jygez.js.map +10 -0
  36. package/dist/chunk-ghs2j1fr.js +312 -0
  37. package/dist/chunk-ghs2j1fr.js.map +10 -0
  38. package/dist/chunk-gmy11efb.js +125 -0
  39. package/dist/chunk-gmy11efb.js.map +10 -0
  40. package/dist/chunk-gxma4c70.js +110 -0
  41. package/dist/chunk-gxma4c70.js.map +10 -0
  42. package/dist/chunk-hkdqhm34.js +61 -0
  43. package/dist/chunk-hkdqhm34.js.map +10 -0
  44. package/dist/chunk-hpa0cpzh.js +134 -0
  45. package/dist/chunk-hpa0cpzh.js.map +10 -0
  46. package/dist/chunk-hwy3n31w.js +21 -0
  47. package/dist/chunk-hwy3n31w.js.map +9 -0
  48. package/dist/chunk-j2behpy7.js +214 -0
  49. package/dist/chunk-j2behpy7.js.map +10 -0
  50. package/dist/chunk-jc0at0tc.js +102 -0
  51. package/dist/chunk-jc0at0tc.js.map +10 -0
  52. package/dist/chunk-jkqq5ef6.js +243 -0
  53. package/dist/chunk-jkqq5ef6.js.map +11 -0
  54. package/dist/chunk-jzkw2f01.js +51 -0
  55. package/dist/chunk-jzkw2f01.js.map +10 -0
  56. package/dist/chunk-jzz91jwh.js +70 -0
  57. package/dist/chunk-jzz91jwh.js.map +10 -0
  58. package/dist/chunk-k9z47mab.js +247 -0
  59. package/dist/chunk-k9z47mab.js.map +12 -0
  60. package/dist/chunk-n17yxpa8.js +23 -0
  61. package/dist/chunk-n17yxpa8.js.map +10 -0
  62. package/dist/chunk-naa09ags.js +2143 -0
  63. package/dist/chunk-naa09ags.js.map +15 -0
  64. package/dist/chunk-ns3ke88n.js +142 -0
  65. package/dist/chunk-ns3ke88n.js.map +10 -0
  66. package/dist/chunk-p3cr2ejg.js +1626 -0
  67. package/dist/chunk-p3cr2ejg.js.map +15 -0
  68. package/dist/chunk-q5bcpf23.js +2925 -0
  69. package/dist/chunk-q5bcpf23.js.map +20 -0
  70. package/dist/chunk-rafc55bs.js +424 -0
  71. package/dist/chunk-rafc55bs.js.map +11 -0
  72. package/dist/chunk-rbrxjvbj.js +2830 -0
  73. package/dist/chunk-rbrxjvbj.js.map +23 -0
  74. package/dist/chunk-sa874j5c.js +9 -0
  75. package/dist/chunk-sa874j5c.js.map +10 -0
  76. package/dist/chunk-sgsz2fpq.js +289 -0
  77. package/dist/chunk-sgsz2fpq.js.map +10 -0
  78. package/dist/chunk-sm6fh5c5.js +569 -0
  79. package/dist/chunk-sm6fh5c5.js.map +14 -0
  80. package/dist/chunk-t9zcg8f1.js +499 -0
  81. package/dist/chunk-t9zcg8f1.js.map +13 -0
  82. package/dist/chunk-vp2db42d.js +121 -0
  83. package/dist/chunk-vp2db42d.js.map +10 -0
  84. package/dist/chunk-w13jjf4a.js +46 -0
  85. package/dist/chunk-w13jjf4a.js.map +10 -0
  86. package/dist/chunk-x1arr7ze.js +63 -0
  87. package/dist/chunk-x1arr7ze.js.map +10 -0
  88. package/dist/chunk-xfq68rch.js +583 -0
  89. package/dist/chunk-xfq68rch.js.map +11 -0
  90. package/dist/main.js +132 -20
  91. package/dist/main.js.map +4 -4
  92. package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
  93. package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
  94. package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
  95. package/dist/runtime/src/agents/archiver.ts +396 -0
  96. package/dist/runtime/src/agents/auth-check.ts +64 -0
  97. package/dist/runtime/src/agents/claude-provider.ts +737 -0
  98. package/dist/runtime/src/agents/claude-registry.ts +115 -0
  99. package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
  100. package/dist/runtime/src/agents/codex-provider.ts +785 -0
  101. package/dist/runtime/src/agents/codex-registry.ts +115 -0
  102. package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
  103. package/dist/runtime/src/agents/contracts.ts +69 -0
  104. package/dist/runtime/src/agents/deep-map.ts +13 -0
  105. package/dist/runtime/src/agents/execution-host.ts +91 -0
  106. package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
  107. package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
  108. package/dist/runtime/src/agents/fork.ts +65 -0
  109. package/dist/runtime/src/agents/hosted-agent.ts +48 -0
  110. package/dist/runtime/src/agents/idle-archiver.ts +128 -0
  111. package/dist/runtime/src/agents/index.ts +205 -0
  112. package/dist/runtime/src/agents/maestro-provider.ts +206 -0
  113. package/dist/runtime/src/agents/maestro-registry.ts +31 -0
  114. package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
  115. package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
  116. package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
  117. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
  118. package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
  119. package/dist/runtime/src/agents/model-catalog.ts +151 -0
  120. package/dist/runtime/src/agents/registry.ts +24 -0
  121. package/dist/runtime/src/agents/rollout/claude.ts +331 -0
  122. package/dist/runtime/src/agents/rollout/codex.ts +569 -0
  123. package/dist/runtime/src/agents/rollout/shared.ts +205 -0
  124. package/dist/runtime/src/agents/self-config-core.ts +415 -0
  125. package/dist/runtime/src/agents/task-events.ts +44 -0
  126. package/dist/runtime/src/agents/tool-format.ts +352 -0
  127. package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
  128. package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
  129. package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
  130. package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
  131. package/dist/runtime/src/application/submit-user-message.ts +47 -0
  132. package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
  133. package/dist/runtime/src/application/switch-topic-model.ts +79 -0
  134. package/dist/runtime/src/application/topic-service.ts +139 -0
  135. package/dist/runtime/src/application/vault-command.ts +170 -0
  136. package/dist/runtime/src/bus.ts +319 -0
  137. package/dist/runtime/src/index.ts +333 -0
  138. package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
  139. package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
  140. package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
  141. package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
  142. package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
  143. package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
  144. package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
  145. package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
  146. package/dist/runtime/src/mcp/runtime-spec.ts +134 -0
  147. package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
  148. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
  149. package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
  150. package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
  151. package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
  152. package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
  153. package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
  154. package/dist/runtime/src/mcp/system-health-server.ts +130 -0
  155. package/dist/runtime/src/mcp/task-server.ts +173 -0
  156. package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
  157. package/dist/runtime/src/mcp/vault-http.ts +219 -0
  158. package/dist/runtime/src/mcp/vault-run.ts +153 -0
  159. package/dist/runtime/src/mcp/vault-server.ts +104 -0
  160. package/dist/runtime/src/mcp/wiki-server.ts +755 -0
  161. package/dist/runtime/src/media/file-events.ts +33 -0
  162. package/dist/runtime/src/media/text-extractor.ts +312 -0
  163. package/dist/runtime/src/media/video.ts +92 -0
  164. package/dist/runtime/src/outbox/file-ops.ts +210 -0
  165. package/dist/runtime/src/outbox/index.ts +8 -0
  166. package/dist/runtime/src/outbox/utils.ts +51 -0
  167. package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
  168. package/dist/runtime/src/platform/config.ts +371 -0
  169. package/dist/runtime/src/platform/constants.ts +18 -0
  170. package/dist/runtime/src/platform/debug-users.ts +14 -0
  171. package/dist/runtime/src/platform/delay.ts +1 -0
  172. package/dist/runtime/src/platform/error.ts +9 -0
  173. package/dist/runtime/src/platform/file-utils.ts +16 -0
  174. package/dist/runtime/src/platform/jsonl.ts +202 -0
  175. package/dist/runtime/src/platform/lifecycle.ts +174 -0
  176. package/dist/runtime/src/platform/logger.ts +31 -0
  177. package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
  178. package/dist/runtime/src/platform/mcp-config.ts +865 -0
  179. package/dist/runtime/src/platform/modules.ts +145 -0
  180. package/dist/runtime/src/platform/node-plugins.ts +46 -0
  181. package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
  182. package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
  183. package/dist/runtime/src/prompts/builders.ts +357 -0
  184. package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
  185. package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
  186. package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
  187. package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
  188. package/dist/runtime/src/query/active-rooms.ts +532 -0
  189. package/dist/runtime/src/query/control.ts +313 -0
  190. package/dist/runtime/src/query/index.ts +24 -0
  191. package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
  192. package/dist/runtime/src/query/session-inbox-path.ts +42 -0
  193. package/dist/runtime/src/query/state.ts +94 -0
  194. package/dist/runtime/src/query/types.ts +83 -0
  195. package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
  196. package/dist/runtime/src/runtime/attachments.ts +151 -0
  197. package/dist/runtime/src/runtime/background-sessions.ts +83 -0
  198. package/dist/runtime/src/runtime/channel-context.ts +107 -0
  199. package/dist/runtime/src/runtime/errors.ts +69 -0
  200. package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
  201. package/dist/runtime/src/runtime/file-hooks.ts +81 -0
  202. package/dist/runtime/src/runtime/footer.ts +23 -0
  203. package/dist/runtime/src/runtime/inbox.ts +946 -0
  204. package/dist/runtime/src/runtime/tasks.ts +56 -0
  205. package/dist/runtime/src/runtime/topic-config.ts +29 -0
  206. package/dist/runtime/src/runtime/turn-runner.ts +2286 -0
  207. package/dist/runtime/src/runtime/usage-alert.ts +84 -0
  208. package/dist/runtime/src/runtime/visual-html.ts +109 -0
  209. package/dist/runtime/src/runtime/visual-store.ts +430 -0
  210. package/dist/runtime/src/runtime/visuals.ts +171 -0
  211. package/dist/runtime/src/security/sanitize.ts +27 -0
  212. package/dist/runtime/src/security/sensitive-path.ts +34 -0
  213. package/dist/runtime/src/storage/activity-log.ts +152 -0
  214. package/dist/runtime/src/storage/api-messages.ts +617 -0
  215. package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
  216. package/dist/runtime/src/storage/api-topic-config.ts +192 -0
  217. package/dist/runtime/src/storage/api-topics.ts +844 -0
  218. package/dist/runtime/src/storage/app-settings.ts +44 -0
  219. package/dist/runtime/src/storage/browser-profiles.ts +133 -0
  220. package/dist/runtime/src/storage/conversations.ts +268 -0
  221. package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
  222. package/dist/runtime/src/storage/forum/index.ts +4 -0
  223. package/dist/runtime/src/storage/forum/repository.ts +283 -0
  224. package/dist/runtime/src/storage/forum/schema.ts +300 -0
  225. package/dist/runtime/src/storage/forum-db.ts +23 -0
  226. package/dist/runtime/src/storage/runtime-events.ts +130 -0
  227. package/dist/runtime/src/storage/runtime-leases.ts +189 -0
  228. package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
  229. package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
  230. package/dist/runtime/src/storage/runtime-turn-requests.ts +300 -0
  231. package/dist/runtime/src/storage/self-schedules.ts +298 -0
  232. package/dist/runtime/src/storage/session-asks.ts +347 -0
  233. package/dist/runtime/src/storage/sqlite.ts +129 -0
  234. package/dist/runtime/src/storage/tasks.ts +179 -0
  235. package/dist/runtime/src/storage/token-stats.ts +144 -0
  236. package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
  237. package/dist/runtime/src/storage/topic-archive.ts +74 -0
  238. package/dist/runtime/src/storage/topic-settings.ts +67 -0
  239. package/dist/runtime/src/storage/topic-transcript.ts +98 -0
  240. package/dist/runtime/src/storage/vault-crypto.ts +65 -0
  241. package/dist/runtime/src/storage/vault.ts +225 -0
  242. package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
  243. package/dist/runtime/src/storage/wiki.ts +85 -0
  244. package/dist/runtime/src/topics/create.ts +123 -0
  245. package/dist/runtime/src/topics/derive.ts +398 -0
  246. package/dist/runtime/src/topics/lifecycle.ts +311 -0
  247. package/dist/runtime/src/topics/links.ts +7 -0
  248. package/dist/runtime/src/topics/personal-general.ts +65 -0
  249. package/dist/runtime/src/topics/session.ts +381 -0
  250. package/dist/runtime/src/types/api.ts +497 -0
  251. package/dist/runtime/src/types.ts +255 -0
  252. package/dist/runtime/src/version.ts +1 -0
  253. package/dist/runtime/tsconfig.json +13 -0
  254. package/package.json +16 -3
  255. package/dist/main.d.ts +0 -3
  256. package/dist/main.d.ts.map +0 -1
@@ -0,0 +1,785 @@
1
+ import { existsSync } from "node:fs";
2
+ import type {
3
+ FileChangeItem,
4
+ McpToolCallItem,
5
+ ModelReasoningEffort,
6
+ SandboxMode,
7
+ ThreadOptions,
8
+ } from "@openai/codex-sdk";
9
+ import { Codex } from "@openai/codex-sdk";
10
+ import {
11
+ ensureCodexModelCache,
12
+ writeCodexCatalogWithNativeMultiAgentDisabled,
13
+ } from "#agents/codex-native-multi-agent";
14
+ import {
15
+ acquireCodexSpawnLock,
16
+ findNewCodexChildren,
17
+ killCodexTrees,
18
+ registerOwnedCodexPids,
19
+ snapshotCodexChildren,
20
+ unregisterOwnedCodexPids,
21
+ } from "#agents/codex-tree-kill";
22
+ import { hostedCodexAuthFilePath, hostedMcpServers } from "#agents/execution-host";
23
+ import {
24
+ migrateCodexRolloutNativeMultiAgentMetadata,
25
+ readLatestCodexContextUsage,
26
+ } from "#agents/rollout/codex";
27
+ import { extractFileEvents } from "#media/file-events";
28
+ import { errMsg } from "#platform/error";
29
+ import { logger } from "#platform/logger";
30
+ import {
31
+ browserOwnerCapability,
32
+ browserOwnerForContext,
33
+ CODEX_BROWSER_CAPABILITY_ENV,
34
+ } from "#platform/mcp-config";
35
+ import type { AgentQueryOptions, EffortLevel, UnifiedEvent } from "#types";
36
+
37
+ /**
38
+ * Pass Otium's EffortLevel through to Codex's ModelReasoningEffort.
39
+ *
40
+ * Storage validation (`getRegistry("codex").validateEffort` in the repository
41
+ * setters) guarantees that any effort persisted on a codex topic is one of
42
+ * codex's accepted set (`low | medium | high | xhigh`), so the SDK shape and
43
+ * the stored shape are identical for codex topics. A `max` or `minimal`
44
+ * reaching this function means storage validation was bypassed — we throw
45
+ * instead of silently demoting so the bug surfaces rather than masking itself.
46
+ */
47
+ function mapEffort(effort?: EffortLevel): ModelReasoningEffort | undefined {
48
+ if (!effort) return undefined;
49
+ if (effort === "max" || (effort as string) === "minimal") {
50
+ throw new Error(
51
+ `codexProvider received effort='${effort}', which codex does not support — storage validation was bypassed`,
52
+ );
53
+ }
54
+ return effort as ModelReasoningEffort;
55
+ }
56
+
57
+ /**
58
+ * Convert Otium's mcp-config output (Claude SDK shape) to Codex's
59
+ * `config.mcp_servers` shape. Codex supports two transports:
60
+ * - stdio: `{ command, args?, env? }`
61
+ * - streamable HTTP: `{ url }` (verified via `codex mcp add --url`)
62
+ *
63
+ * Entries with the SSE transport shape (`{ type: "sse", url }`) are dropped:
64
+ * codex doesn't speak SSE. Callers must produce the streamable HTTP shape
65
+ * for codex by passing `agent: "codex"` into the `playwright.build()` arm
66
+ * in `mcp-config.ts` (which switches `/sse` → `/mcp`).
67
+ */
68
+ type CodexMcpTimeouts = {
69
+ enabled?: boolean;
70
+ startup_timeout_sec?: number;
71
+ tool_timeout_sec?: number;
72
+ };
73
+ type CodexStdioServer = {
74
+ command: string;
75
+ args?: string[];
76
+ env?: Record<string, string>;
77
+ } & CodexMcpTimeouts;
78
+ type CodexHttpServer = {
79
+ url: string;
80
+ http_headers?: Record<string, string>;
81
+ env_http_headers?: Record<string, string>;
82
+ } & CodexMcpTimeouts;
83
+ type CodexMcpServer = CodexStdioServer | CodexHttpServer;
84
+
85
+ const CODEX_MCP_SERVER_NAME_OVERRIDES: Record<string, string> = {
86
+ // Codex merges per-turn `--config mcp_servers.<name>.*` overrides with the
87
+ // user's global ~/.codex/config.toml. `playwright` is a common global stdio
88
+ // server name, and adding Otium's per-topic HTTP `url` to that same table
89
+ // makes Codex fail config parsing with "url is not supported for stdio".
90
+ playwright: "otium_playwright",
91
+ };
92
+
93
+ function codexMcpServerName(name: string): string {
94
+ return CODEX_MCP_SERVER_NAME_OVERRIDES[name] ?? name;
95
+ }
96
+
97
+ function parsePositiveInt(value: string | undefined, fallback: number): number {
98
+ if (!value) return fallback;
99
+ const parsed = Number.parseInt(value, 10);
100
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
101
+ }
102
+
103
+ function withCodexMcpServerOverrides(name: string, server: CodexMcpServer): CodexMcpServer {
104
+ if (name === "paddleocr") {
105
+ return {
106
+ ...server,
107
+ startup_timeout_sec: parsePositiveInt(process.env.PADDLEOCR_MCP_STARTUP_TIMEOUT_SEC, 30),
108
+ tool_timeout_sec: parsePositiveInt(process.env.PADDLEOCR_MCP_TOOL_TIMEOUT_SEC, 300),
109
+ };
110
+ }
111
+ // For codex, Otium launches MCP servers via node + tsx (see serverLaunch in
112
+ // mcp-config.ts), which transpiles the .ts on the fly — cold start is slower
113
+ // than `bun run`, so give the handshake a generous startup window. codex's
114
+ // default startup timeout is short enough that a heavier server can miss it.
115
+ return {
116
+ startup_timeout_sec: parsePositiveInt(process.env.CODEX_MCP_STARTUP_TIMEOUT_SEC, 30),
117
+ ...server,
118
+ };
119
+ }
120
+
121
+ export function toCodexMcpServers(
122
+ claudeShape: Record<string, unknown>,
123
+ ): Record<string, CodexMcpServer> {
124
+ // Codex merges turn config with ~/.codex/config.toml instead of replacing it.
125
+ // Disable common global browser stdio servers so browser state can only flow
126
+ // through Negotium's long-lived, owner-scoped HTTP server.
127
+ const disabledBrowserStdio = (): CodexStdioServer => ({
128
+ command: process.execPath,
129
+ args: ["-e", "process.exit(0)"],
130
+ enabled: false,
131
+ });
132
+ const out: Record<string, CodexMcpServer> = {
133
+ playwright: disabledBrowserStdio(),
134
+ "browser-rs": disabledBrowserStdio(),
135
+ patchright: disabledBrowserStdio(),
136
+ };
137
+ for (const [name, srv] of Object.entries(claudeShape)) {
138
+ if (!srv || typeof srv !== "object") continue;
139
+ const s = srv as Record<string, unknown>;
140
+ const codexName = codexMcpServerName(name);
141
+ if (typeof s.command === "string") {
142
+ out[codexName] = withCodexMcpServerOverrides(name, {
143
+ command: s.command,
144
+ ...(Array.isArray(s.args) ? { args: s.args as string[] } : {}),
145
+ ...(s.env && typeof s.env === "object" ? { env: s.env as Record<string, string> } : {}),
146
+ });
147
+ continue;
148
+ }
149
+ if (typeof s.url === "string" && s.type !== "sse") {
150
+ out[codexName] = withCodexMcpServerOverrides(name, {
151
+ url: s.url,
152
+ ...(s.http_headers && typeof s.http_headers === "object"
153
+ ? { http_headers: s.http_headers as Record<string, string> }
154
+ : {}),
155
+ ...(s.env_http_headers && typeof s.env_http_headers === "object"
156
+ ? { env_http_headers: s.env_http_headers as Record<string, string> }
157
+ : {}),
158
+ });
159
+ }
160
+ }
161
+ return out;
162
+ }
163
+
164
+ function isMissingCodexRolloutError(err: unknown): boolean {
165
+ const msg = errMsg(err);
166
+ return /no rollout found|thread\/resume failed/i.test(msg);
167
+ }
168
+
169
+ /**
170
+ * Render an `mcp_tool_call` item's terminal payload into a short string for
171
+ * the unified `tool_result` event. The SDK shape is
172
+ * `result?: { content: ContentBlock[]; structured_content: unknown }` on
173
+ * success, or `error?: { message: string }` on failure — neither is a raw
174
+ * string, which is why the previous `typeof result === "string"` branch was
175
+ * unreachable. Truncate to 200 chars to match command_execution behavior.
176
+ */
177
+ function summarizeMcpToolCallResult(item: McpToolCallItem): string {
178
+ if (item.error) return item.error.message.slice(0, 200);
179
+ if (!item.result) return "";
180
+ const blocks = Array.isArray(item.result.content) ? item.result.content : [];
181
+ const text = blocks
182
+ .map((b) => {
183
+ if (b && typeof b === "object" && "type" in b && b.type === "text" && "text" in b) {
184
+ return String((b as { text?: unknown }).text ?? "");
185
+ }
186
+ return "";
187
+ })
188
+ .filter(Boolean)
189
+ .join("\n");
190
+ if (text) return text.slice(0, 200);
191
+ // Fall back to a JSON dump of structured_content for non-text content blocks.
192
+ return JSON.stringify(item.result.structured_content ?? "").slice(0, 200);
193
+ }
194
+
195
+ function fileChangeEvents(item: FileChangeItem): UnifiedEvent[] {
196
+ return item.changes.flatMap((change, index) => {
197
+ const name = change.kind === "add" ? "Write" : change.kind === "delete" ? "Delete" : "Edit";
198
+ const toolUseId = `${item.id}:${index}`;
199
+ const success = item.status === "completed";
200
+ return [
201
+ {
202
+ type: "tool_use" as const,
203
+ name,
204
+ input: { file_path: change.path, change_kind: change.kind },
205
+ toolUseId,
206
+ },
207
+ {
208
+ type: "tool_result" as const,
209
+ toolUseId,
210
+ content: success
211
+ ? `${change.kind} applied: ${change.path}`
212
+ : `${change.kind} failed: ${change.path}`,
213
+ },
214
+ ];
215
+ });
216
+ }
217
+
218
+ function promptForThread(opts: AgentQueryOptions, includeSystemPrompt: boolean): string {
219
+ return includeSystemPrompt && opts.systemPrompt
220
+ ? `[System Instructions]\n${opts.systemPrompt}\n\n${opts.prompt}`
221
+ : opts.prompt;
222
+ }
223
+
224
+ /**
225
+ * Start a streamed turn and identify the codex PIDs we spawned, holding the
226
+ * global spawn lock across the full window from baseline → first event.
227
+ *
228
+ * Why hold the lock across iteration: `@openai/codex-sdk`'s `runStreamed()`
229
+ * returns a lazy async generator object. The actual `child_process.spawn()`
230
+ * call happens on the first `iterator.next()`, not when `runStreamed()` is
231
+ * awaited (see `node_modules/@openai/codex-sdk/dist/index.js:51-93,238`).
232
+ * If we released the lock after `await runStreamed(...)`, a concurrent
233
+ * codex caller's baseline would race against our actual spawn and one
234
+ * caller would mis-attribute the other's PID.
235
+ *
236
+ * Returns the iterator (positioned just past the first event) plus the
237
+ * first event itself, so the streaming loop can re-emit it before
238
+ * resuming `iterator.next()`.
239
+ */
240
+ type StartResult = {
241
+ iter: AsyncIterator<unknown>;
242
+ firstEvent: unknown;
243
+ done: boolean;
244
+ };
245
+
246
+ async function closeIterator(iter: AsyncIterator<unknown>): Promise<void> {
247
+ try {
248
+ await iter.return?.();
249
+ } catch (err) {
250
+ logger.warn({ err }, "codexProvider: failed to close codex event iterator");
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Bound the spawn→first-event window. `thread.started` normally arrives within
256
+ * a few seconds, so if NOTHING comes back in this window the codex turn is
257
+ * genuinely stuck at startup (the bare-`/codex` hang symptom). We surface a
258
+ * clear error fast instead of waiting out the much longer mid-stream heartbeat
259
+ * (8 min) — a stuck startup never recovers, and retrying it is futile. Once the
260
+ * first event arrives, the normal heartbeat governs long-running work.
261
+ */
262
+ const CODEX_STARTUP_TIMEOUT_MS = 90_000;
263
+
264
+ async function startStreamedWithTracking(
265
+ thread: ReturnType<Codex["startThread"]>,
266
+ prompt: string,
267
+ abortSignal: AbortSignal | undefined,
268
+ trackedPids: { pids: number[] },
269
+ ): Promise<StartResult> {
270
+ const release = await acquireCodexSpawnLock();
271
+ try {
272
+ const baseline = snapshotCodexChildren();
273
+ const runResult = await thread.runStreamed(prompt, {
274
+ ...(abortSignal ? { signal: abortSignal } : {}),
275
+ });
276
+ const iter = (runResult.events as AsyncIterable<unknown>)[Symbol.asyncIterator]();
277
+ // Force the first iterator step — this is when the SDK actually
278
+ // child_process.spawn()s codex. After this awaits, the codex PID is
279
+ // visible to pgrep as a child of our process.
280
+ let first: IteratorResult<unknown>;
281
+ // Startup watchdog: race the first event against CODEX_STARTUP_TIMEOUT_MS so
282
+ // a codex that spawns but never emits anything can't hang the turn. On
283
+ // timeout the iter.next() promise loses the race and we fall into the catch
284
+ // below, which kills the spawned tree, closes the iterator, and rethrows the
285
+ // clear error — surfaced to the user as an `error` event (no infinite wait).
286
+ let startupTimer: ReturnType<typeof setTimeout> | undefined;
287
+ try {
288
+ const startupTimeout = new Promise<never>((_, reject) => {
289
+ startupTimer = setTimeout(
290
+ () =>
291
+ reject(
292
+ new Error(
293
+ `Codex가 ${CODEX_STARTUP_TIMEOUT_MS / 1000}초 내 응답을 시작하지 않아 중단했습니다. 다시 시도해주세요.`,
294
+ ),
295
+ ),
296
+ CODEX_STARTUP_TIMEOUT_MS,
297
+ );
298
+ });
299
+ // Swallow a late rejection from the losing promise: if the startup
300
+ // timeout wins the race, this dangling next() may settle later with
301
+ // nobody awaiting it (would surface as an unhandledRejection).
302
+ const nextP = iter.next();
303
+ nextP.catch(() => {});
304
+ first = await Promise.race([nextP, startupTimeout]);
305
+ } catch (err) {
306
+ // Abort OR startup-timeout after the SDK spawned codex but before the
307
+ // first event resolves: the normal abort listener still sees no tracked
308
+ // PID, so diff the baseline here and kill anything attributable before
309
+ // propagating.
310
+ const novel = findNewCodexChildren(baseline);
311
+ if (novel.length > 0) {
312
+ killCodexTrees(novel);
313
+ }
314
+ await closeIterator(iter);
315
+ throw err;
316
+ } finally {
317
+ if (startupTimer) clearTimeout(startupTimer);
318
+ }
319
+ const novel = findNewCodexChildren(baseline);
320
+ trackedPids.pids = novel;
321
+ registerOwnedCodexPids(novel);
322
+ return { iter, firstEvent: first.value, done: !!first.done };
323
+ } finally {
324
+ release();
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Wrap an iterator into an AsyncIterable that first yields a pre-captured
330
+ * `firstEvent` and then drains the underlying iterator. Lets the streaming
331
+ * `for await` loop keep its existing switch over events without special-casing
332
+ * the first iteration we already consumed during PID tracking.
333
+ */
334
+ function prependFirstEvent(
335
+ iter: AsyncIterator<unknown>,
336
+ firstEvent: unknown,
337
+ alreadyDone: boolean,
338
+ ): AsyncIterable<unknown> {
339
+ return {
340
+ [Symbol.asyncIterator]() {
341
+ let emittedFirst = false;
342
+ let exhausted = alreadyDone;
343
+ return {
344
+ async next(): Promise<IteratorResult<unknown>> {
345
+ if (!emittedFirst) {
346
+ emittedFirst = true;
347
+ if (alreadyDone) return { value: undefined, done: true };
348
+ return { value: firstEvent, done: false };
349
+ }
350
+ if (exhausted) return { value: undefined, done: true };
351
+ const r = await iter.next();
352
+ if (r.done) exhausted = true;
353
+ return r;
354
+ },
355
+ async return(value?: unknown): Promise<IteratorResult<unknown>> {
356
+ exhausted = true;
357
+ if (iter.return) return iter.return(value);
358
+ return { value, done: true };
359
+ },
360
+ };
361
+ },
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Core Codex provider.
367
+ * Same UnifiedEvent contract as claudeProvider. Uses @openai/codex-sdk.
368
+ *
369
+ * Notes:
370
+ * - Codex has no `systemPrompt` option; prepend the current topic/system
371
+ * instructions on every invocation, including resumed synthetic threads.
372
+ * - Per-turn MCP isolation is achieved via CodexOptions.config.mcp_servers,
373
+ * so the global ~/.codex/config.toml stays untouched.
374
+ * - Streaming token-by-token text deltas are not exposed by the SDK; only
375
+ * final agent_message and per-item updates. Callers that rely on
376
+ * text_delta should still work because we yield a single text/result
377
+ * event when the assistant message completes.
378
+ */
379
+ export async function* codexProvider(opts: AgentQueryOptions): AsyncGenerator<UnifiedEvent> {
380
+ // Up-front auth file check — gives the user an actionable message
381
+ // ("`codex login` 으로 다시 로그인해주세요") immediately instead of waiting
382
+ // for the @openai/codex-sdk to spawn the codex binary and surface an
383
+ // opaque OAuth failure on the first turn. Path is the default codex
384
+ // location unless NEGOTIUM_CODEX_AUTH_FILE relocates it for this host.
385
+ const codexAuthPath = hostedCodexAuthFilePath();
386
+ if (!existsSync(codexAuthPath)) {
387
+ yield {
388
+ type: "error",
389
+ content: `Codex auth file not found at ${codexAuthPath}. Run \`codex login\` to authenticate.`,
390
+ };
391
+ return;
392
+ }
393
+
394
+ let codexModelCatalogPath: string;
395
+ try {
396
+ await ensureCodexModelCache(codexAuthPath);
397
+ codexModelCatalogPath = writeCodexCatalogWithNativeMultiAgentDisabled(codexAuthPath);
398
+ if (opts.sessionId) migrateCodexRolloutNativeMultiAgentMetadata(opts.sessionId);
399
+ } catch (err) {
400
+ yield {
401
+ type: "error",
402
+ content: `Failed to enforce the Codex native multi-agent policy: ${errMsg(err)}`,
403
+ };
404
+ return;
405
+ }
406
+
407
+ const codexMcpServers = toCodexMcpServers(hostedMcpServers(opts));
408
+ const browserOwner = browserOwnerForContext(opts);
409
+ const scopedBrowserCapability =
410
+ opts.playwrightCapability && browserOwner
411
+ ? browserOwnerCapability(opts.playwrightCapability, browserOwner)
412
+ : undefined;
413
+
414
+ // Startup trace: codexProvider was otherwise silent until its first error or
415
+ // heartbeat (8 min), so a stuck turn left "0 lines of codex" in the logs and
416
+ // was impossible to localize (bug B). Record the resolved model/effort/cwd +
417
+ // whether this is a fresh thread or a resume, so a future hang is pinpointed
418
+ // to setup vs. the first stream step.
419
+ logger.info(
420
+ {
421
+ model: opts.model ?? "(sdk default)",
422
+ effort: opts.effort ?? "(off)",
423
+ cwd: opts.cwd,
424
+ cwdExists: existsSync(opts.cwd),
425
+ resume: Boolean(opts.sessionId),
426
+ mcpServerCount: Object.keys(codexMcpServers).length,
427
+ },
428
+ "codexProvider: starting turn",
429
+ );
430
+
431
+ const codex = new Codex({
432
+ ...(scopedBrowserCapability
433
+ ? {
434
+ env: {
435
+ ...Object.fromEntries(
436
+ Object.entries(process.env).filter(
437
+ (entry): entry is [string, string] => typeof entry[1] === "string",
438
+ ),
439
+ ),
440
+ [CODEX_BROWSER_CAPABILITY_ENV]: scopedBrowserCapability,
441
+ },
442
+ }
443
+ : {}),
444
+ config: {
445
+ // Otium exposes delegation through runtime.spawn_subagent so child work
446
+ // gets its own room/card and its result is routed back to the parent.
447
+ // Codex enables its provider-native collaboration tools from the user's
448
+ // global config by default; explicitly turn that feature off so tools
449
+ // such as spawn_agent/send_message cannot bypass Otium's orchestration
450
+ // and so subagent rooms cannot recursively fan out through Codex.
451
+ // Codex 0.144 lets model metadata override these flags. The authoritative
452
+ // catalog above sets multi_agent_version=disabled as the hard stop; keep
453
+ // all feature switches off as a second layer and for future CLI versions.
454
+ features: { multi_agent: false, multi_agent_v2: false, enable_fanout: false },
455
+ model_catalog_json: codexModelCatalogPath,
456
+ mcp_servers: codexMcpServers,
457
+ },
458
+ });
459
+
460
+ const threadOptions: ThreadOptions = {
461
+ workingDirectory: opts.cwd,
462
+ skipGitRepoCheck: true,
463
+ // Codex sandbox is intentionally disabled: Otium already assigns a managed
464
+ // workspace cwd for the turn, and the bot is expected to act on it freely on
465
+ // the user's behalf. SDK-level sandboxing would only constrain reach inside
466
+ // that already-owned directory, which is not the threat model.
467
+ sandboxMode: "danger-full-access" as SandboxMode,
468
+ approvalPolicy: "never",
469
+ ...(opts.model ? { model: opts.model } : {}),
470
+ ...(opts.effort ? { modelReasoningEffort: mapEffort(opts.effort) } : {}),
471
+ };
472
+
473
+ let currentSessionId = opts.sessionId;
474
+
475
+ // Codex SDK has no systemPrompt option. A resumed thread may have been
476
+ // synthesized from a captured shell, so always refresh the current runtime
477
+ // instructions instead of trusting historical developer/environment data.
478
+ let thread = currentSessionId
479
+ ? codex.resumeThread(currentSessionId, threadOptions)
480
+ : codex.startThread(threadOptions);
481
+ let prompt = promptForThread(opts, true);
482
+
483
+ let agentTextSoFar = "";
484
+ let currentAgentMessageId: string | undefined;
485
+ let finalText = "";
486
+ let stopReason = "end_turn";
487
+
488
+ // Tree-kill instrumentation. The codex SDK spawns the `codex` binary as a
489
+ // direct child of our process but does not expose the PID or place it in
490
+ // its own process group. We identify our PIDs by diffing pgrep snapshots
491
+ // around the SDK's first iterator step (when codex is actually spawned),
492
+ // and SIGTERM the whole tree on abort with SIGKILL fallback inside
493
+ // killCodexTrees().
494
+ const trackedPids: { pids: number[] } = { pids: [] };
495
+ const abortSignal = opts.abortController?.signal;
496
+ const onAbortKill = () => {
497
+ if (trackedPids.pids.length > 0) {
498
+ killCodexTrees(trackedPids.pids);
499
+ }
500
+ };
501
+ abortSignal?.addEventListener("abort", onAbortKill, { once: true });
502
+
503
+ // Heartbeat: if the Codex API emits no events within the window, abort the
504
+ // current stream and resume the thread with a short continuation prompt.
505
+ // The interval doubles each attempt (up to HEARTBEAT_MAX_MS) to avoid
506
+ // interrupting genuinely long-running work on the first timeout.
507
+ let heartbeatMs = 8 * 60 * 1000;
508
+ let heartbeatAttempts = 0;
509
+ const HEARTBEAT_MAX_MS = 16 * 60 * 1000;
510
+ const HEARTBEAT_MAX_ATTEMPTS = 2;
511
+ const HEARTBEAT_CONTINUATION = "계속 진행해줘.";
512
+
513
+ try {
514
+ while (true) {
515
+ if (abortSignal?.aborted) return;
516
+
517
+ // Per-attempt abort controller: lets the heartbeat timer abort only
518
+ // this attempt's stream without touching the user-facing outer signal.
519
+ const attemptAbort = new AbortController();
520
+ const propagateOuter = () => attemptAbort.abort();
521
+ abortSignal?.addEventListener("abort", propagateOuter, { once: true });
522
+
523
+ trackedPids.pids = [];
524
+ let heartbeatTriggered = false;
525
+ let attemptCompleted = false;
526
+ let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
527
+
528
+ const scheduleHeartbeat = () => {
529
+ if (heartbeatTimer !== null) clearTimeout(heartbeatTimer);
530
+ if (abortSignal?.aborted || attemptAbort.signal.aborted) return;
531
+ heartbeatTimer = setTimeout(() => {
532
+ heartbeatTriggered = true;
533
+ if (trackedPids.pids.length > 0) killCodexTrees(trackedPids.pids);
534
+ attemptAbort.abort();
535
+ }, heartbeatMs);
536
+ };
537
+
538
+ try {
539
+ scheduleHeartbeat();
540
+
541
+ let startResult!: StartResult;
542
+ try {
543
+ startResult = await startStreamedWithTracking(
544
+ thread,
545
+ prompt,
546
+ attemptAbort.signal,
547
+ trackedPids,
548
+ );
549
+ } catch (err) {
550
+ if (err instanceof Error && err.name === "AbortError") throw err;
551
+ if (!currentSessionId || !isMissingCodexRolloutError(err)) throw err;
552
+ logger.warn(
553
+ { staleSessionId: currentSessionId, err: errMsg(err) },
554
+ "codexProvider: stale/missing rollout, restarting fresh thread",
555
+ );
556
+ thread = codex.startThread(threadOptions);
557
+ prompt = promptForThread(opts, true);
558
+ currentSessionId = undefined;
559
+ startResult = await startStreamedWithTracking(
560
+ thread,
561
+ prompt,
562
+ attemptAbort.signal,
563
+ trackedPids,
564
+ );
565
+ }
566
+
567
+ // Cover the narrow window where abort fired between PID registration
568
+ // and the abort listener actually running.
569
+ if (attemptAbort.signal.aborted) {
570
+ killCodexTrees(trackedPids.pids);
571
+ await closeIterator(startResult.iter);
572
+ throw Object.assign(new Error("heartbeat abort"), { name: "AbortError" });
573
+ }
574
+
575
+ const eventStream = prependFirstEvent(
576
+ startResult.iter,
577
+ startResult.firstEvent,
578
+ startResult.done,
579
+ );
580
+
581
+ for await (const rawEvent of eventStream) {
582
+ scheduleHeartbeat();
583
+
584
+ const event = rawEvent as {
585
+ type: string;
586
+ thread_id?: string;
587
+ item?: {
588
+ type: string;
589
+ command?: string;
590
+ tool?: string;
591
+ arguments?: unknown;
592
+ text?: string;
593
+ id?: string;
594
+ aggregated_output?: string;
595
+ message?: string;
596
+ };
597
+ usage?: {
598
+ input_tokens: number;
599
+ output_tokens: number;
600
+ cached_input_tokens?: number;
601
+ };
602
+ error?: { message?: string };
603
+ };
604
+
605
+ switch (event.type) {
606
+ case "thread.started": {
607
+ if (event.thread_id) {
608
+ currentSessionId = event.thread_id;
609
+ yield { type: "session", sessionId: event.thread_id };
610
+ }
611
+ break;
612
+ }
613
+
614
+ case "item.started": {
615
+ const item = event.item;
616
+ if (!item) break;
617
+ if (item.type === "command_execution") {
618
+ yield {
619
+ type: "tool_use",
620
+ name: "Bash",
621
+ input: { command: String(item.command ?? "") },
622
+ toolUseId: String(item.id ?? ""),
623
+ };
624
+ } else if (item.type === "mcp_tool_call") {
625
+ yield {
626
+ type: "tool_use",
627
+ name: String(item.tool ?? "unknown"),
628
+ input:
629
+ item.arguments && typeof item.arguments === "object"
630
+ ? (item.arguments as Record<string, unknown>)
631
+ : {},
632
+ toolUseId: String(item.id ?? ""),
633
+ };
634
+ }
635
+ break;
636
+ }
637
+
638
+ case "item.updated": {
639
+ const item = event.item;
640
+ if (!item) break;
641
+ if (item.type === "agent_message") {
642
+ const messageId = item.id != null ? String(item.id) : undefined;
643
+ if (messageId !== currentAgentMessageId) {
644
+ currentAgentMessageId = messageId;
645
+ agentTextSoFar = "";
646
+ }
647
+ const text = String(item.text ?? "");
648
+ const newChars = text.slice(agentTextSoFar.length);
649
+ if (newChars) {
650
+ yield { type: "text_delta", content: newChars };
651
+ agentTextSoFar = text;
652
+ }
653
+ }
654
+ break;
655
+ }
656
+
657
+ case "item.completed": {
658
+ const item = event.item;
659
+ if (!item) break;
660
+ if (item.type === "agent_message") {
661
+ finalText = String(item.text ?? "");
662
+ agentTextSoFar = finalText;
663
+ yield { type: "text", content: finalText };
664
+ yield* extractFileEvents(finalText, "text");
665
+ } else if (item.type === "reasoning") {
666
+ const reasoning = String(item.text ?? "").trim();
667
+ if (reasoning) yield { type: "reasoning", content: reasoning };
668
+ } else if (item.type === "mcp_tool_call") {
669
+ yield {
670
+ type: "tool_result",
671
+ toolUseId: String(item.id ?? ""),
672
+ content: summarizeMcpToolCallResult(item as unknown as McpToolCallItem),
673
+ };
674
+ } else if (item.type === "command_execution") {
675
+ yield {
676
+ type: "tool_result",
677
+ toolUseId: String(item.id ?? ""),
678
+ content: String(item.aggregated_output ?? "").slice(0, 200),
679
+ };
680
+ } else if (item.type === "file_change") {
681
+ for (const fileEvent of fileChangeEvents(item as unknown as FileChangeItem)) {
682
+ yield fileEvent;
683
+ }
684
+ } else if (item.type === "error") {
685
+ yield { type: "error", content: String(item.message ?? "") };
686
+ }
687
+ break;
688
+ }
689
+
690
+ case "turn.completed": {
691
+ const usage = event.usage;
692
+ if (!usage) break;
693
+ attemptCompleted = true;
694
+ const contextUsage = currentSessionId
695
+ ? readLatestCodexContextUsage(currentSessionId)
696
+ : undefined;
697
+ yield {
698
+ type: "result",
699
+ content: finalText,
700
+ stopReason,
701
+ usage: {
702
+ inputTokens: usage.input_tokens,
703
+ outputTokens: usage.output_tokens,
704
+ cacheReadInputTokens: usage.cached_input_tokens,
705
+ ...contextUsage,
706
+ },
707
+ };
708
+ if (finalText) yield* extractFileEvents(finalText, "result");
709
+ break;
710
+ }
711
+
712
+ case "turn.failed": {
713
+ stopReason = "error";
714
+ yield {
715
+ type: "error",
716
+ content: event.error?.message || "Codex turn failed",
717
+ };
718
+ break;
719
+ }
720
+
721
+ default:
722
+ break;
723
+ }
724
+ }
725
+ } catch (err) {
726
+ if (abortSignal?.aborted) return;
727
+ if (err instanceof Error && err.name === "AbortError") {
728
+ if (!heartbeatTriggered) return;
729
+ // Heartbeat abort — fall through to retry logic below
730
+ } else {
731
+ logger.error({ err }, "codexProvider: attempt failed");
732
+ yield { type: "error", content: errMsg(err) };
733
+ break;
734
+ }
735
+ } finally {
736
+ if (heartbeatTimer !== null) clearTimeout(heartbeatTimer);
737
+ abortSignal?.removeEventListener("abort", propagateOuter);
738
+ if (trackedPids.pids.length > 0) unregisterOwnedCodexPids(trackedPids.pids);
739
+ }
740
+
741
+ if (attemptCompleted || abortSignal?.aborted) break;
742
+ if (!heartbeatTriggered) break;
743
+
744
+ heartbeatAttempts++;
745
+ if (heartbeatAttempts > HEARTBEAT_MAX_ATTEMPTS) {
746
+ logger.warn(
747
+ { attempts: heartbeatAttempts },
748
+ "codexProvider: max heartbeat attempts reached",
749
+ );
750
+ yield {
751
+ type: "error",
752
+ content: `Codex가 ${HEARTBEAT_MAX_ATTEMPTS}회 재시도 후에도 응답하지 않았습니다.`,
753
+ };
754
+ break;
755
+ }
756
+
757
+ heartbeatMs = Math.min(heartbeatMs * 2, HEARTBEAT_MAX_MS);
758
+ logger.info(
759
+ { attempt: heartbeatAttempts, nextIntervalMs: heartbeatMs },
760
+ "codexProvider: heartbeat fired, resuming thread with continuation prompt",
761
+ );
762
+
763
+ // The resumed turn's agent_message grows from "" again. Stale lengths
764
+ // from the aborted attempt would mis-slice deltas (silence until the new
765
+ // text outgrows the old, then an arbitrary mid-string fragment), and a
766
+ // resumed turn that ends without an agent_message would ship the
767
+ // previous attempt's finalText as its result.
768
+ agentTextSoFar = "";
769
+ finalText = "";
770
+
771
+ thread = currentSessionId
772
+ ? codex.resumeThread(currentSessionId, threadOptions)
773
+ : codex.startThread(threadOptions);
774
+ prompt = currentSessionId ? HEARTBEAT_CONTINUATION : promptForThread(opts, true);
775
+ }
776
+ } catch (err) {
777
+ if (err instanceof Error && err.name === "AbortError") return;
778
+ if (abortSignal?.aborted) return;
779
+ logger.error({ err }, "codexProvider: setup failed");
780
+ yield { type: "error", content: errMsg(err) };
781
+ } finally {
782
+ abortSignal?.removeEventListener("abort", onAbortKill);
783
+ if (trackedPids.pids.length > 0) unregisterOwnedCodexPids(trackedPids.pids);
784
+ }
785
+ }