@konglx/rotom 2.21.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 (189) hide show
  1. package/README.md +417 -0
  2. package/bin/mesh-master.sh +439 -0
  3. package/bin/rotom +29 -0
  4. package/bin/rotom-link.sh +136 -0
  5. package/bin/rotom-send-with-status +57 -0
  6. package/bin/rotom-up.sh +428 -0
  7. package/dist/cli/ask.js +62 -0
  8. package/dist/cli/common.js +321 -0
  9. package/dist/cli/config.js +65 -0
  10. package/dist/cli/directory.js +17 -0
  11. package/dist/cli/executor.js +58 -0
  12. package/dist/cli/fed.js +91 -0
  13. package/dist/cli/group.js +273 -0
  14. package/dist/cli/identity.js +62 -0
  15. package/dist/cli/init.js +268 -0
  16. package/dist/cli/issue.js +202 -0
  17. package/dist/cli/join.js +170 -0
  18. package/dist/cli/link.js +47 -0
  19. package/dist/cli/master.js +51 -0
  20. package/dist/cli/memory.js +307 -0
  21. package/dist/cli/note.js +68 -0
  22. package/dist/cli/repo.js +77 -0
  23. package/dist/cli/rotom.js +277 -0
  24. package/dist/cli/routes.js +118 -0
  25. package/dist/cli/run.js +45 -0
  26. package/dist/cli/schedule.js +237 -0
  27. package/dist/cli/skill.js +173 -0
  28. package/dist/cli/team.js +106 -0
  29. package/dist/executor/claude-code-hook.cjs +80 -0
  30. package/dist/executor/cli-executor.js +8 -0
  31. package/dist/executor/executors/claude-code.js +780 -0
  32. package/dist/executor/executors/codex.js +719 -0
  33. package/dist/executor/executors/hermes-cli.js +855 -0
  34. package/dist/executor/executors/openclaw.js +467 -0
  35. package/dist/executor/executors/pi.js +514 -0
  36. package/dist/executor/index.js +269 -0
  37. package/dist/executor/jsonrpc-transport.js +125 -0
  38. package/dist/executor/process-runner.js +101 -0
  39. package/dist/executor/reasoning-status.js +83 -0
  40. package/dist/executor/repo-cache.js +502 -0
  41. package/dist/executor/session-store.js +188 -0
  42. package/dist/executor/worker-chat.js +257 -0
  43. package/dist/executor/worker-connection.js +89 -0
  44. package/dist/executor/worker-issue.js +264 -0
  45. package/dist/executor/worker.js +877 -0
  46. package/dist/link/pending-requests.js +72 -0
  47. package/dist/link/server.js +233 -0
  48. package/dist/link/visibility-store.js +58 -0
  49. package/dist/master/api/agents.js +333 -0
  50. package/dist/master/api/artifacts.js +271 -0
  51. package/dist/master/api/domains.js +64 -0
  52. package/dist/master/api/groups.js +635 -0
  53. package/dist/master/api/guidance-templates.js +147 -0
  54. package/dist/master/api/index.js +89 -0
  55. package/dist/master/api/issues-patrol.js +172 -0
  56. package/dist/master/api/issues.js +663 -0
  57. package/dist/master/api/links-patrol.js +168 -0
  58. package/dist/master/api/links.js +114 -0
  59. package/dist/master/api/memory.js +259 -0
  60. package/dist/master/api/messages.js +157 -0
  61. package/dist/master/api/notes.js +77 -0
  62. package/dist/master/api/schedule-patterns.js +133 -0
  63. package/dist/master/api/schedules.js +272 -0
  64. package/dist/master/api/sessions.js +158 -0
  65. package/dist/master/api/share.js +269 -0
  66. package/dist/master/api/skills.js +190 -0
  67. package/dist/master/api/teams.js +122 -0
  68. package/dist/master/api/uploads.js +245 -0
  69. package/dist/master/auth.js +134 -0
  70. package/dist/master/dashboard/animations/calico-dozing.apng +0 -0
  71. package/dist/master/dashboard/animations/calico-error.apng +0 -0
  72. package/dist/master/dashboard/animations/calico-happy.apng +0 -0
  73. package/dist/master/dashboard/animations/calico-notification.apng +0 -0
  74. package/dist/master/dashboard/animations/calico-sleeping.apng +0 -0
  75. package/dist/master/dashboard/animations/calico-thinking.apng +0 -0
  76. package/dist/master/dashboard/animations/calico-waking.apng +0 -0
  77. package/dist/master/dashboard/assets/ApprovalCard-C38VV6ko.css +1 -0
  78. package/dist/master/dashboard/assets/ApprovalCard-CHPh2dmE.js +17 -0
  79. package/dist/master/dashboard/assets/ArtifactPanel-P_2gAP7v.js +1 -0
  80. package/dist/master/dashboard/assets/ArtifactPanel-aGHySny5.css +1 -0
  81. package/dist/master/dashboard/assets/css.worker-DaIe3gwK.js +84 -0
  82. package/dist/master/dashboard/assets/editor.worker-BCzxt1at.js +12 -0
  83. package/dist/master/dashboard/assets/html.worker-CKrFyw_2.js +461 -0
  84. package/dist/master/dashboard/assets/index-CChrTn81.css +32 -0
  85. package/dist/master/dashboard/assets/index-Dhu4SN1z.js +181 -0
  86. package/dist/master/dashboard/assets/json.worker-B7c_PmGb.js +49 -0
  87. package/dist/master/dashboard/assets/markdown-CeN5IgdF.js +29 -0
  88. package/dist/master/dashboard/assets/monaco-core-DyX1CsEw.css +1 -0
  89. package/dist/master/dashboard/assets/monaco-core-oQiQUisy.js +833 -0
  90. package/dist/master/dashboard/assets/monaco-setup-CiOPQdmo.js +1 -0
  91. package/dist/master/dashboard/assets/react-vendor-C8IxlyCR.js +67 -0
  92. package/dist/master/dashboard/assets/ts.worker-BhkL8olL.js +51334 -0
  93. package/dist/master/dashboard/assets/useMonaco-ILb4vyPh.js +12 -0
  94. package/dist/master/dashboard/assets/vite-preload-CxJPbCTl.js +1 -0
  95. package/dist/master/dashboard/debug-auth.html +197 -0
  96. package/dist/master/dashboard/favicon.ico +0 -0
  97. package/dist/master/dashboard/index.html +20 -0
  98. package/dist/master/dashboard/rotom-avatar.png +0 -0
  99. package/dist/master/db/agent-sessions.js +60 -0
  100. package/dist/master/db/agent-visibility.js +64 -0
  101. package/dist/master/db/agents.js +119 -0
  102. package/dist/master/db/ask-bridges.js +157 -0
  103. package/dist/master/db/build-update.js +59 -0
  104. package/dist/master/db/core.js +82 -0
  105. package/dist/master/db/domains.js +80 -0
  106. package/dist/master/db/groups.js +316 -0
  107. package/dist/master/db/guidance-templates.js +58 -0
  108. package/dist/master/db/index.js +12 -0
  109. package/dist/master/db/internal.js +45 -0
  110. package/dist/master/db/issues-patrol.js +81 -0
  111. package/dist/master/db/issues.js +373 -0
  112. package/dist/master/db/links.js +221 -0
  113. package/dist/master/db/master-node.js +43 -0
  114. package/dist/master/db/memory.js +272 -0
  115. package/dist/master/db/messages.js +210 -0
  116. package/dist/master/db/notes.js +55 -0
  117. package/dist/master/db/schedule-patterns.js +56 -0
  118. package/dist/master/db/schedules.js +135 -0
  119. package/dist/master/db/skills.js +144 -0
  120. package/dist/master/db/team.js +88 -0
  121. package/dist/master/db/types.js +10 -0
  122. package/dist/master/db.js +12 -0
  123. package/dist/master/embedded.js +133 -0
  124. package/dist/master/federation/client.js +283 -0
  125. package/dist/master/federation/identity.js +133 -0
  126. package/dist/master/federation/manager.js +267 -0
  127. package/dist/master/federation/publisher.js +87 -0
  128. package/dist/master/federation/self-publisher.js +69 -0
  129. package/dist/master/federation/server.js +487 -0
  130. package/dist/master/group-paths.js +208 -0
  131. package/dist/master/offline-queue.js +38 -0
  132. package/dist/master/opc-bootstrap.js +245 -0
  133. package/dist/master/patrol-terminal.js +275 -0
  134. package/dist/master/repo-scan.js +188 -0
  135. package/dist/master/router.js +214 -0
  136. package/dist/master/scheduler-handlers.js +510 -0
  137. package/dist/master/scheduler.js +201 -0
  138. package/dist/master/server.js +203 -0
  139. package/dist/master/services/link-collector.js +82 -0
  140. package/dist/master/services/link-patrol-bootstrap.js +50 -0
  141. package/dist/master/services/memory-extract-prompt.js +34 -0
  142. package/dist/master/services/patrol-bootstrap.js +63 -0
  143. package/dist/master/share-tokens.js +56 -0
  144. package/dist/master/terminal-hub.js +300 -0
  145. package/dist/master/uploads.js +108 -0
  146. package/dist/master/util/fs.js +100 -0
  147. package/dist/master/util/paths.js +50 -0
  148. package/dist/master/util/persona.js +10 -0
  149. package/dist/master/ws-hub/connection.js +928 -0
  150. package/dist/master/ws-hub/conversation.js +290 -0
  151. package/dist/master/ws-hub/directory.js +70 -0
  152. package/dist/master/ws-hub/dispatch-enrich.js +34 -0
  153. package/dist/master/ws-hub/hub.js +136 -0
  154. package/dist/master/ws-hub/index.js +9 -0
  155. package/dist/master/ws-hub/internal.js +35 -0
  156. package/dist/master/ws-hub/routing.js +295 -0
  157. package/dist/master/ws-hub/sessions.js +130 -0
  158. package/dist/master/ws-hub.js +11 -0
  159. package/dist/shared/agent-profile.js +44 -0
  160. package/dist/shared/constants.js +55 -0
  161. package/dist/shared/dedup.js +33 -0
  162. package/dist/shared/group-context.js +62 -0
  163. package/dist/shared/json-codec.js +33 -0
  164. package/dist/shared/logger.js +136 -0
  165. package/dist/shared/mention.js +22 -0
  166. package/dist/shared/network.js +40 -0
  167. package/dist/shared/parse.js +18 -0
  168. package/dist/shared/prompt-composer.js +171 -0
  169. package/dist/shared/protocol/client-messages.js +8 -0
  170. package/dist/shared/protocol/enums.js +6 -0
  171. package/dist/shared/protocol/federation.js +62 -0
  172. package/dist/shared/protocol/guards.js +87 -0
  173. package/dist/shared/protocol/server-messages.js +8 -0
  174. package/dist/shared/protocol/types.js +8 -0
  175. package/dist/shared/protocol.js +19 -0
  176. package/dist/shared/readonly-allowlist.js +122 -0
  177. package/dist/shared/rotom-cli-prompt.js +23 -0
  178. package/dist/shared/skill-context.js +19 -0
  179. package/dist/shared/skill-md.js +43 -0
  180. package/dist/shared/slash-commands.js +50 -0
  181. package/dist/shared/time.js +80 -0
  182. package/dist/shared/title.js +46 -0
  183. package/dist/shared/url-extractor.js +99 -0
  184. package/migrations/001-schema.sql +942 -0
  185. package/package.json +68 -0
  186. package/scripts/fix-node-pty-perms.mjs +46 -0
  187. package/skill/rotom-a2a-communicate/SKILL.md +257 -0
  188. package/skill/rotom-bus-host/SKILL.md +78 -0
  189. package/skill/rotom-bus-host/scripts/poll-replies.sh +148 -0
@@ -0,0 +1,719 @@
1
+ /**
2
+ * Codex CLI Executor
3
+ *
4
+ * Spawns `codex app-server --listen stdio://` and drives the Codex JSON-RPC 2.0
5
+ * protocol over stdin/stdout. Mirrors Multica's Go reference implementation in
6
+ * server/pkg/agent/codex.go.
7
+ *
8
+ * Lifecycle:
9
+ * 1. initialize handshake
10
+ * 2. `initialized` notification
11
+ * 3. thread/start (or thread/resume when sessionId is given) → threadId
12
+ * 4. turn/start with threadId + prompt
13
+ * 5. wait for turn/completed (raw v2) or task_complete (legacy codex/event)
14
+ *
15
+ * Two notification dialects are supported:
16
+ * • Legacy: { method: "codex/event", params: { msg: { type, ... } } }
17
+ * • Raw v2: { method: "turn/started" | "turn/completed" | "item/<phase>", ... }
18
+ *
19
+ * Server-initiated approval requests:
20
+ * • exec / file change → routed to options.onApprovalRequest when provided
21
+ * (so a human can Accept/Deny via the dashboard); otherwise auto-accepted
22
+ * to keep daemon-style runs unblocked.
23
+ * • MCP elicitation → always auto-accepted (internal protocol chatter).
24
+ *
25
+ * Whether `onApprovalRequest` is supplied is decided by the worker based on
26
+ * `issue.approval_policy`: `r_allow`(默认)→ 传 callback;`rw_allow` → 不传,
27
+ * 走 auto-accept 路径。
28
+ */
29
+ import { runProcess } from "../process-runner.js";
30
+ import { createJsonRpcTransport } from "../jsonrpc-transport.js";
31
+ import fs from "node:fs";
32
+ import os from "node:os";
33
+ import path from "node:path";
34
+ import { buildPlanModeInstruction } from "../../shared/slash-commands.js";
35
+ import { emitStatus } from "../reasoning-status.js";
36
+ // ── Executor ────────────────────────────────────────────────────────────
37
+ export class CodexExecutor {
38
+ async execute(prompt, workingDir, onOutput, options) {
39
+ return new Promise((resolve) => {
40
+ const resumeSessionId = options?.sessionId || "";
41
+ // prompt 已经由 worker 用 composePrompt() 拼好,executor 不再二次包装。
42
+ const args = ["app-server", "--listen", "stdio://"];
43
+ const spawnEnv = { ...process.env, ...options?.env };
44
+ const { proc, done: procDone } = runProcess({
45
+ bin: "codex",
46
+ args,
47
+ cwd: workingDir,
48
+ env: spawnEnv,
49
+ label: "codex",
50
+ signal: options?.signal,
51
+ });
52
+ // ── Per-run state ──
53
+ let fullOutput = "";
54
+ let threadId = "";
55
+ let settled = false;
56
+ let failed = false;
57
+ let turnError = "";
58
+ let sessionPoisoned = false;
59
+ let turnDoneResolve = null;
60
+ let notificationProtocol = "unknown";
61
+ let turnStarted = false;
62
+ // 终端状态去重:codex v2 协议下可能从多个路径到达终态(item/completed
63
+ // agentMessage final_answer / turn/completed / finish()),dashboard
64
+ // 端 hoistStatus 已经只保留最后一个 tag,但重复 emit 既浪费流量也
65
+ // 干扰调试日志,这里用同一个 flag 集中拦截。
66
+ let terminalEmitted = false;
67
+ const completedTurnIds = new Set();
68
+ let capturedUsage;
69
+ let capturedModel;
70
+ const turnDone = new Promise((res) => { turnDoneResolve = res; });
71
+ function signalTurnDone(aborted) {
72
+ if (turnDoneResolve) {
73
+ const r = turnDoneResolve;
74
+ turnDoneResolve = null;
75
+ r(aborted);
76
+ }
77
+ }
78
+ function setTurnError(msg) {
79
+ if (msg && !turnError)
80
+ turnError = msg;
81
+ // Detect the upstream OpenAI invariant violation that pins a chat
82
+ // history forever once an assistant `tool_calls` message exists
83
+ // without matching tool responses. Once this is set, callers should
84
+ // drop the cached sessionId so the next run starts fresh — otherwise
85
+ // every resume keeps replaying the poisoned history.
86
+ if (msg && /tool[_ ]?calls?|tool[_ ]message|tool_call_id/i.test(msg)) {
87
+ sessionPoisoned = true;
88
+ }
89
+ }
90
+ // ── JSON-RPC transport (line framing + pending map + onRequest/onNotification) ──
91
+ // The transport owns the readline loop and routes each frame to the
92
+ // matching callback. We just plug in our domain handlers below.
93
+ const transport = createJsonRpcTransport({
94
+ stdin: proc.stdin,
95
+ stdout: proc.stdout,
96
+ label: "codex",
97
+ onRequest: (method, params, id) => handleServerRequest(method, params, id),
98
+ onNotification: (method, params) => handleNotification(method, params),
99
+ });
100
+ function request(method, params) {
101
+ return transport.request(method, params);
102
+ }
103
+ function notify(method, params) {
104
+ transport.notify(method, params);
105
+ }
106
+ function respond(id, result) {
107
+ transport.respond(id, result);
108
+ }
109
+ function respondError(id, code, message) {
110
+ transport.respondError(id, code, message);
111
+ }
112
+ // ── Server → client requests (auto-approve) ──
113
+ function handleServerRequest(method, rawParams, id) {
114
+ const params = (rawParams ?? {});
115
+ switch (method) {
116
+ case "item/commandExecution/requestApproval":
117
+ case "execCommandApproval": {
118
+ const input = extractExecApprovalInput(params);
119
+ routeApproval(id, input);
120
+ return;
121
+ }
122
+ case "item/fileChange/requestApproval":
123
+ case "applyPatchApproval": {
124
+ const input = extractFileApprovalInput(params);
125
+ routeApproval(id, input);
126
+ return;
127
+ }
128
+ case "mcpServer/elicitation/request":
129
+ // MCP elicitations stay auto-accepted — they're internal protocol
130
+ // chatter that the human user does not need to vet.
131
+ respond(id, { action: "accept", content: null, _meta: null });
132
+ return;
133
+ default:
134
+ console.warn(`[codex] unhandled server request: ${method}`);
135
+ respondError(id, -32601, `unhandled server request: ${method}`);
136
+ }
137
+ }
138
+ // Bridge to the worker's approval pipeline. If no callback is wired,
139
+ // fall back to the legacy auto-accept so daemon contexts keep working.
140
+ function routeApproval(id, input) {
141
+ if (!options?.onApprovalRequest) {
142
+ respond(id, { decision: "accept" });
143
+ return;
144
+ }
145
+ // Fire-and-forget — we intentionally don't await this. codex stays
146
+ // parked on the JSON-RPC request until we call respond() below.
147
+ void (async () => {
148
+ let result = { decision: "deny" };
149
+ try {
150
+ result = await options.onApprovalRequest(input);
151
+ }
152
+ catch (err) {
153
+ console.warn(`[codex] approval callback threw, defaulting to deny: ${err.message}`);
154
+ }
155
+ // Send the optional user-supplied feedback as `reason` on denials so
156
+ // codex can surface it back to the model. Unknown fields are ignored
157
+ // by JSON-RPC peers, so this is safe even on codex builds that don't
158
+ // read `reason`.
159
+ if (result.decision === "deny" && result.feedback?.trim()) {
160
+ respond(id, { decision: "deny", reason: result.feedback.trim() });
161
+ }
162
+ else {
163
+ respond(id, { decision: result.decision });
164
+ }
165
+ })();
166
+ }
167
+ // ── Notification handling ──
168
+ function handleNotification(method, rawParams) {
169
+ const params = (rawParams ?? {});
170
+ if (process.env.ROTOM_CODEX_DEBUG) {
171
+ console.log(`[codex DEBUG] notification method=${method} params=${JSON.stringify(params).slice(0, 600)}`);
172
+ }
173
+ // Legacy: codex/event
174
+ if (method === "codex/event" || method.startsWith("codex/event/")) {
175
+ notificationProtocol = "legacy";
176
+ // 新版 codex 可能把 event_msg payload 直接放在 params 顶层(无 msg 包装),
177
+ // 旧版放在 params.msg。两种都接受,避免 token_count / session_meta 这类
178
+ // 事件被静默丢弃。
179
+ const msg = (params.msg ?? params);
180
+ if (msg && typeof msg === "object" && "type" in msg)
181
+ handleLegacyEvent(msg);
182
+ return;
183
+ }
184
+ if (notificationProtocol === "legacy")
185
+ return;
186
+ if (notificationProtocol === "unknown" &&
187
+ (method === "turn/started" ||
188
+ method === "turn/completed" ||
189
+ method === "thread/started" ||
190
+ method.startsWith("item/"))) {
191
+ notificationProtocol = "raw";
192
+ }
193
+ if (notificationProtocol === "raw")
194
+ handleRawNotification(method, params);
195
+ }
196
+ function handleLegacyEvent(msg) {
197
+ const type = msg.type;
198
+ switch (type) {
199
+ case "task_started":
200
+ turnStarted = true;
201
+ emitStatus(onOutput, "Working");
202
+ return;
203
+ case "agent_message": {
204
+ const text = msg.message;
205
+ if (text) {
206
+ fullOutput += text;
207
+ onOutput(text);
208
+ // 不要在每个 chunk 都 emit "Working" — turn/started 已经发过了,
209
+ // 这里再发反而会把 "Running" 状态打回 "Working",导致工具调用期间
210
+ // pill 在两个状态之间闪烁。
211
+ }
212
+ return;
213
+ }
214
+ case "exec_command_begin": {
215
+ const command = msg.command;
216
+ onOutput(`[tool:exec]${prettyCommand(command ?? "")}[/tool:exec]\n`);
217
+ emitStatus(onOutput, "Running");
218
+ return;
219
+ }
220
+ case "exec_command_end": {
221
+ const output = msg.output ?? "";
222
+ const truncated = output.length > 500 ? `${output.slice(0, 500)}...` : output;
223
+ if (truncated)
224
+ onOutput(`[tool-result:exec]${truncated}[/tool-result:exec]\n`);
225
+ // codex 不在工具完成时发 "Done" — tool 完成后模型进入 thinking,
226
+ // 下一个 event (新 tool call / final_answer) 会自己覆盖 status。
227
+ return;
228
+ }
229
+ case "patch_apply_begin":
230
+ onOutput(`[tool:patch]apply[/tool:patch]\n`);
231
+ emitStatus(onOutput, "Patching");
232
+ return;
233
+ case "patch_apply_end":
234
+ // 同上:patch 完成不发 "Patched",让 status 保持上一个状态,等下一个
235
+ // event 覆盖。
236
+ return;
237
+ case "task_complete":
238
+ signalTurnDone(false);
239
+ return;
240
+ case "turn_aborted":
241
+ signalTurnDone(true);
242
+ return;
243
+ }
244
+ }
245
+ function handleRawNotification(method, params) {
246
+ // Codex multiplexes child threads on the same stdio pipe; ignore
247
+ // notifications that don't belong to the thread we started.
248
+ const eventThreadId = params.threadId;
249
+ if (eventThreadId && threadId && eventThreadId !== threadId)
250
+ return;
251
+ switch (method) {
252
+ case "thread/started": {
253
+ // thread/started 的 params.thread.modelProvider 是后端 provider 名
254
+ // (e.g. "deepseek" / "openai"),用作 model 展示。codex 实际模型名
255
+ // (e.g. "deepseek-v4-flash")只在某些 warning 文本里出现,不稳。
256
+ const threadObj = (params.thread ?? {});
257
+ const provider = threadObj.modelProvider;
258
+ if (provider && !capturedModel)
259
+ capturedModel = provider;
260
+ // 落到下方 turn/started 之外的处理:thread/started 不算 turn 开始,
261
+ // 不发 Working 状态。直接 return,不要触发 turn/started 的逻辑。
262
+ return;
263
+ }
264
+ case "turn/started":
265
+ turnStarted = true;
266
+ emitStatus(onOutput, "Working");
267
+ return;
268
+ case "thread/tokenUsage/updated": {
269
+ // codex v2 真正的 usage 通知。params.tokenUsage.total 是该 thread
270
+ // 累计用量(input + 历史 turns),last 是本轮。我们存 total —— 一个
271
+ // issue 可能多次 turn,最终值就是整个 issue 执行的总量。
272
+ // 字段是 camelCase: inputTokens / outputTokens / cachedInputTokens /
273
+ // reasoningOutputTokens(注意不是 cache_read_input_tokens)。
274
+ const tokenUsage = (params.tokenUsage ?? {});
275
+ const total = (tokenUsage.total ?? {});
276
+ capturedUsage = {
277
+ inputTokens: typeof total.inputTokens === "number" ? total.inputTokens : undefined,
278
+ outputTokens: typeof total.outputTokens === "number" ? total.outputTokens : undefined,
279
+ cacheReadTokens: typeof total.cachedInputTokens === "number" ? total.cachedInputTokens : undefined,
280
+ cacheCreationTokens: undefined,
281
+ totalCostUsd: undefined,
282
+ };
283
+ return;
284
+ }
285
+ case "turn/completed": {
286
+ const turn = (params.turn ?? {});
287
+ const turnId = turn.id ?? "";
288
+ const status = turn.status ?? "";
289
+ const aborted = status === "cancelled" ||
290
+ status === "canceled" ||
291
+ status === "aborted" ||
292
+ status === "interrupted";
293
+ if (process.env.ROTOM_CODEX_DEBUG) {
294
+ console.log(`[codex DEBUG] turn/completed params=${JSON.stringify(params).slice(0, 800)}`);
295
+ }
296
+ if (status === "failed") {
297
+ const err = turn.error ?? {};
298
+ setTurnError(err.message || "codex turn failed");
299
+ failed = true;
300
+ if (!terminalEmitted) {
301
+ emitStatus(onOutput, "Failed");
302
+ terminalEmitted = true;
303
+ }
304
+ }
305
+ if (turnId) {
306
+ if (completedTurnIds.has(turnId))
307
+ return;
308
+ completedTurnIds.add(turnId);
309
+ }
310
+ // 兜底:agentMessage final_answer 没拿到 phase / 没下发时,
311
+ // 仍然需要一个终态 pill,否则 dashboard 一直停在 "Working"。
312
+ // aborted 留给上一次的 non-terminal 状态(pill 视觉上停在"被打断
313
+ // 那一刻"),不强行覆盖。
314
+ if (!terminalEmitted && !aborted && status !== "failed") {
315
+ emitStatus(onOutput, "Answered");
316
+ terminalEmitted = true;
317
+ }
318
+ signalTurnDone(aborted);
319
+ return;
320
+ }
321
+ case "error": {
322
+ const willRetry = params.willRetry === true;
323
+ const errMsg = params.error?.message ||
324
+ params.message ||
325
+ "";
326
+ if (errMsg) {
327
+ console.warn(`[codex] error notification: ${errMsg} (willRetry=${willRetry})`);
328
+ if (!willRetry) {
329
+ setTurnError(errMsg);
330
+ failed = true;
331
+ }
332
+ }
333
+ return;
334
+ }
335
+ case "thread/status/changed": {
336
+ const statusType = params.status?.type ?? "";
337
+ if (statusType === "idle" && turnStarted)
338
+ signalTurnDone(false);
339
+ return;
340
+ }
341
+ default:
342
+ if (method.startsWith("item/"))
343
+ handleItemNotification(method, params);
344
+ }
345
+ }
346
+ function handleItemNotification(method, params) {
347
+ const item = params.item;
348
+ if (!item)
349
+ return;
350
+ const itemType = item.type;
351
+ const itemId = item.id;
352
+ if (process.env.ROTOM_CODEX_DEBUG) {
353
+ console.log(`[codex DEBUG] handleItemNotification method=${method} itemType=${itemType} itemId=${itemId} phase=${item.phase ?? "(none)"} command=${String(item.command).slice(0, 60)}`);
354
+ }
355
+ // 用字段存在性判断 item 类型,而不是严格匹配 `type` 字符串 — 不同 codex
356
+ // 版本对 enum variant 的序列化格式不一样(camelCase / snake_case / 别名),
357
+ // 而且 codex 还在持续迭代(2026.5 的 v2 协议又引入了新 ThreadItem 变体),
358
+ // 紧耦合 type 字符串会让这里脆弱。commandExecution 一定有 `command` 字段
359
+ // (string 或 string[]),fileChange 一定有 `changes` / `patch` 字段。
360
+ const looksLikeCommandExec = typeof item.command !== "undefined";
361
+ const looksLikeFileChange = item.changes !== undefined ||
362
+ item.patch !== undefined;
363
+ if (method === "item/started" && looksLikeCommandExec) {
364
+ const rawCmd = item.command;
365
+ const command = Array.isArray(rawCmd)
366
+ ? rawCmd.map((p) => String(p)).join(" ")
367
+ : (rawCmd ?? "");
368
+ onOutput(`[tool:exec]${prettyCommand(command)}[/tool:exec]\n`);
369
+ emitStatus(onOutput, "Running");
370
+ if (process.env.ROTOM_CODEX_DEBUG) {
371
+ console.log(`[codex DEBUG] emitted [tool:exec] + Running status for command=${command.slice(0, 60)}`);
372
+ }
373
+ return;
374
+ }
375
+ if (method === "item/completed" && looksLikeCommandExec) {
376
+ const output = item.aggregatedOutput ??
377
+ item.output ??
378
+ "";
379
+ const truncated = output.length > 500 ? `${output.slice(0, 500)}...` : output;
380
+ if (truncated)
381
+ onOutput(`[tool-result:exec]${truncated}[/tool-result:exec]\n`);
382
+ // codex 不在 tool 完成时发 "Done" — 让 status 保持上一个状态,
383
+ // 等下一个 event (新 tool / final_answer) 自己覆盖。
384
+ // exitCode != 0 走 turn/completed 的 failed 分支,这里不再发 Failed,
385
+ // 避免一个失败的 tool 就把整个 turn 标红。
386
+ void item.exitCode;
387
+ return;
388
+ }
389
+ if (method === "item/started" && looksLikeFileChange) {
390
+ onOutput(`[tool:patch]apply[/tool:patch]\n`);
391
+ emitStatus(onOutput, "Patching");
392
+ return;
393
+ }
394
+ if (method === "item/completed" && looksLikeFileChange) {
395
+ // 同上:patch 完成后不发 "Patched"。
396
+ return;
397
+ }
398
+ // codex v2 协议下 agent message 是流式推送的,文本通过
399
+ // `item/agentMessage/delta` 增量进来(delta 字段),完整消息在
400
+ // `item/completed` 时带 phase 标识(Commentary / FinalAnswer)。
401
+ // 不处理 delta 的话,dashboard 端 streaming 期间看不到 agent 文字,
402
+ // pill 也永远卡在 "Working"(因为 turn/started 后没有新 status emit)。
403
+ if (method === "item/agentMessage/delta") {
404
+ const delta = params.delta ?? "";
405
+ if (delta) {
406
+ fullOutput += delta;
407
+ onOutput(delta);
408
+ }
409
+ return;
410
+ }
411
+ if (method === "item/completed" && itemType === "agentMessage") {
412
+ const text = item.text ?? "";
413
+ if (text) {
414
+ // 兜底:有些 codex 版本把完整文本塞在 item.completed 的 text 字段,
415
+ // 而不用 delta 流推。这里只在 fullOutput 还没有这段文本时 push,
416
+ // 避免和 delta 重复输出。
417
+ if (!fullOutput.endsWith(text)) {
418
+ fullOutput += text;
419
+ onOutput(text);
420
+ }
421
+ }
422
+ const phase = item.phase;
423
+ // codex v2 协议 phase 取值是 PascalCase("FinalAnswer"/"Commentary"),
424
+ // 见上方注释。大小写都接受,避免某个 minor 版本切回 snake_case 时
425
+ // 又把 "Answered" 丢掉。
426
+ if (phase && phase.toLowerCase() === "final_answer") {
427
+ // "Answered" 是 terminal state,pill 会停在这里。
428
+ if (!terminalEmitted) {
429
+ emitStatus(onOutput, "Answered");
430
+ terminalEmitted = true;
431
+ }
432
+ if (turnStarted)
433
+ signalTurnDone(false);
434
+ }
435
+ return;
436
+ }
437
+ // itemId is reserved for future per-tool tracking; reference to silence lint.
438
+ void itemId;
439
+ }
440
+ // ── Line router ──
441
+ // (The transport above already routes server requests / notifications;
442
+ // responses are matched against the transport's pending map and resolve
443
+ // the corresponding `transport.request(...)` promises automatically.)
444
+ proc.stderr.on("data", (data) => {
445
+ const text = data.toString().trim();
446
+ if (text)
447
+ console.error(`[codex] stderr: ${text}`);
448
+ });
449
+ function finish(exitCode) {
450
+ if (settled)
451
+ return;
452
+ settled = true;
453
+ // Reject any leftover pending requests so dangling promises don't keep the event loop alive.
454
+ transport.rejectPending(new Error("codex process exited"));
455
+ // 最后一道防线:某些 codex 边角场景下进程干净退出但 turn/completed
456
+ // 路径没走到(或没匹配上),pill 会一直停在 "Working"。这里在 exit
457
+ // code 0 且没下发过任何终态时,补发 "Answered"。fail / 非零退出
458
+ // 路径交给 worker 通过 failed flag 标红,不在这里掺合。
459
+ if (!terminalEmitted && exitCode === 0) {
460
+ emitStatus(onOutput, "Answered");
461
+ terminalEmitted = true;
462
+ }
463
+ const reportedSessionId = resolveSessionId(resumeSessionId, threadId, failed || exitCode !== 0);
464
+ const finalCode = failed && exitCode === 0 ? 1 : exitCode;
465
+ console.log(`[codex] Exited code=${exitCode}, output=${fullOutput.length} chars, session=${reportedSessionId}, poisoned=${sessionPoisoned}`);
466
+ resolve({
467
+ exitCode: finalCode,
468
+ fullOutput,
469
+ sessionId: sessionPoisoned ? undefined : (reportedSessionId || undefined),
470
+ invalidateSession: sessionPoisoned || undefined,
471
+ usage: capturedUsage,
472
+ model: capturedModel,
473
+ });
474
+ }
475
+ proc.on("close", (code) => finish(code ?? 1));
476
+ proc.on("error", (err) => {
477
+ console.error(`[codex] Spawn error: ${err.message}`);
478
+ finish(1);
479
+ });
480
+ // ── Drive the protocol ──
481
+ void (async () => {
482
+ try {
483
+ await request("initialize", {
484
+ clientInfo: {
485
+ name: "open-a2a-gateway",
486
+ title: "Open A2A WORKSPACE",
487
+ version: "0.1.0",
488
+ },
489
+ capabilities: { experimentalApi: true },
490
+ });
491
+ notify("initialized");
492
+ threadId = await startOrResumeThread(request, resumeSessionId, workingDir, options?.slashCommand);
493
+ await request("turn/start", {
494
+ threadId,
495
+ input: [{ type: "text", text: prompt }],
496
+ });
497
+ const aborted = await turnDone;
498
+ if (aborted) {
499
+ failed = true;
500
+ setTurnError("turn was aborted");
501
+ }
502
+ else if (turnError) {
503
+ failed = true;
504
+ }
505
+ }
506
+ catch (err) {
507
+ failed = true;
508
+ const msg = err.message;
509
+ console.error(`[codex] lifecycle error: ${msg}`);
510
+ setTurnError(msg);
511
+ onOutput(`[error] ${msg}\n`);
512
+ }
513
+ finally {
514
+ try {
515
+ proc.stdin?.end();
516
+ }
517
+ catch { /* noop */ }
518
+ }
519
+ })();
520
+ });
521
+ }
522
+ /**
523
+ * Read the tail of codex's session transcript from its local JSONL store.
524
+ * Codex writes one file per session under
525
+ * `~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<ISO>-<sessionId>.jsonl`
526
+ * with the sessionId as the trailing UUID before the `.jsonl` extension. We
527
+ * walk the tree (find by suffix is cheaper than maintaining a date index)
528
+ * and return the last N lines verbatim — the dashboard renders them as a
529
+ * `<pre>` block.
530
+ *
531
+ * Tolerant of missing files (codex may prune its sessions directory) —
532
+ * returns empty content + an explanatory `error` so the dashboard can
533
+ * distinguish "file gone" from "session started but no output yet".
534
+ */
535
+ async readSessionContent(args) {
536
+ const file = findCodexRolloutFile(args.sessionId);
537
+ if (!file) {
538
+ return {
539
+ format: "jsonl",
540
+ content: "",
541
+ error: "codex session 文件不存在(可能已被 codex CLI 清理)",
542
+ };
543
+ }
544
+ const text = fs.readFileSync(file, "utf-8");
545
+ const lines = text.split("\n");
546
+ const tail = args.tailLines ?? 200;
547
+ const sliced = lines.length > tail ? lines.slice(-tail).join("\n") : text;
548
+ return { format: "jsonl", content: sliced };
549
+ }
550
+ }
551
+ // ── Codex session-file lookup ───────────────────────────────────────────
552
+ /**
553
+ * Walk ~/.codex/sessions/<YYYY>/<MM>/<DD>/ and find the rollout JSONL whose
554
+ * filename ends with `-<sessionId>.jsonl`. Returns the absolute path or null
555
+ * if no match. The 3-level date directory is what codex's `~/.codex/sessions`
556
+ * layout uses in 2026.5+; on older builds the same files live flat under
557
+ * `~/.codex/sessions/` so we walk two levels deep just in case.
558
+ */
559
+ function findCodexRolloutFile(sessionId) {
560
+ const root = path.join(os.homedir(), ".codex", "sessions");
561
+ if (!fs.existsSync(root))
562
+ return null;
563
+ const suffix = `-${sessionId}.jsonl`;
564
+ const stack = [root];
565
+ while (stack.length > 0) {
566
+ const dir = stack.pop();
567
+ let entries;
568
+ try {
569
+ entries = fs.readdirSync(dir, { withFileTypes: true });
570
+ }
571
+ catch {
572
+ continue;
573
+ }
574
+ for (const e of entries) {
575
+ const full = path.join(dir, e.name);
576
+ if (e.isDirectory()) {
577
+ stack.push(full);
578
+ }
579
+ else if (e.isFile() && e.name.endsWith(suffix)) {
580
+ return full;
581
+ }
582
+ }
583
+ }
584
+ return null;
585
+ }
586
+ // ── Helpers ─────────────────────────────────────────────────────────────
587
+ async function startOrResumeThread(request, resumeSessionId, cwd, slashCommand) {
588
+ // /plan → codex 没有原生 plan 模式,靠 developerInstructions 注入开发者级
589
+ // 系统提示,引导其"先方案后落盘"。注册表见 src/shared/slash-commands.ts。
590
+ const developerInstructions = slashCommand === "/plan" ? buildPlanModeInstruction() : null;
591
+ if (resumeSessionId) {
592
+ try {
593
+ const res = (await request("thread/resume", {
594
+ threadId: resumeSessionId,
595
+ cwd,
596
+ model: null,
597
+ developerInstructions,
598
+ // 同 thread/start:codex 默认 read-only 沙箱挡 127.0.0.1 回环,
599
+ // rotom CLI 子命令全部 fetch failed。chat 路径默认走 resume
600
+ // (~/.rotom/sessions.json 缓存),不补这行修复永远不生效。
601
+ // 详见 docs/codex-sandbox-network-blocked.md。
602
+ sandbox: "danger-full-access",
603
+ }));
604
+ const id = extractThreadId(res);
605
+ if (id)
606
+ return id;
607
+ console.warn(`[codex] thread/resume returned no thread id; falling back to thread/start (prior=${resumeSessionId})`);
608
+ }
609
+ catch (err) {
610
+ console.warn(`[codex] thread/resume failed; falling back to thread/start: ${err.message}`);
611
+ }
612
+ }
613
+ const res = (await request("thread/start", {
614
+ model: null,
615
+ modelProvider: null,
616
+ profile: null,
617
+ cwd,
618
+ approvalPolicy: null,
619
+ // codex CLI 默认 read-only 沙箱挡掉所有出站网络,包括 127.0.0.1 回环 ——
620
+ // rotom CLI 任何子命令都打不到 master,统一报 "fetch failed"。worker 是
621
+ // 受信进程、cwd 已隔离、命令经 dashboard 审批,沙箱那层防护是冗余的,
622
+ // 这里拉满。详见 docs/codex-sandbox-network-blocked.md。
623
+ sandbox: "danger-full-access",
624
+ config: null,
625
+ baseInstructions: null,
626
+ developerInstructions,
627
+ compactPrompt: null,
628
+ includeApplyPatchTool: null,
629
+ experimentalRawEvents: false,
630
+ persistExtendedHistory: true,
631
+ }));
632
+ const id = extractThreadId(res);
633
+ if (!id)
634
+ throw new Error("codex thread/start returned no thread id");
635
+ return id;
636
+ }
637
+ function extractThreadId(result) {
638
+ if (!result || typeof result !== "object")
639
+ return "";
640
+ const r = result;
641
+ const thread = r.thread;
642
+ if (thread && typeof thread.id === "string")
643
+ return thread.id;
644
+ return "";
645
+ }
646
+ /**
647
+ * Decide which session id to report. When resume was requested but codex emitted
648
+ * a fresh, different thread id AND the run failed, the resume did not land —
649
+ * return "" so the caller can retry fresh.
650
+ */
651
+ function resolveSessionId(requestedResume, emitted, failed) {
652
+ if (failed && requestedResume && emitted && emitted !== requestedResume) {
653
+ return "";
654
+ }
655
+ return emitted;
656
+ }
657
+ // Strip common shell wrappers so the dashboard renders the user-meaningful
658
+ // command instead of `/bin/bash -lc '<actual>'`.
659
+ function prettyCommand(raw) {
660
+ let s = raw.trim();
661
+ const wrapper = /^(?:\/bin\/)?(?:ba|z)?sh\s+-lc\s+(['"])([\s\S]+)\1$/;
662
+ const m = s.match(wrapper);
663
+ if (m)
664
+ s = m[2].trim();
665
+ return s;
666
+ }
667
+ // ── Approval payload extraction ─────────────────────────────────────────
668
+ //
669
+ // Codex's app-server has shifted its exec/file approval params several times
670
+ // (top-level vs nested under `item`, command as string vs array). These
671
+ // helpers normalize the shapes we've seen into the worker-facing
672
+ // ApprovalRequestInput. When fields are missing we still build a usable
673
+ // summary so the human reviewer is never left with a blank card.
674
+ function extractExecApprovalInput(params) {
675
+ const source = params.item ?? params;
676
+ const rawCmd = source.command;
677
+ let command = "";
678
+ if (typeof rawCmd === "string") {
679
+ command = prettyCommand(rawCmd);
680
+ }
681
+ else if (Array.isArray(rawCmd)) {
682
+ command = prettyCommand(rawCmd.map((p) => String(p)).join(" "));
683
+ }
684
+ const cwd = typeof source.cwd === "string" ? source.cwd : undefined;
685
+ const reason = typeof source.reason === "string" ? source.reason : undefined;
686
+ const summary = command
687
+ ? `请求执行命令:${command.length > 200 ? command.slice(0, 200) + "…" : command}`
688
+ : reason || "请求执行 shell 命令";
689
+ return { kind: "exec", summary, command: command || undefined, cwd };
690
+ }
691
+ function extractFileApprovalInput(params) {
692
+ const source = params.item ?? params;
693
+ const files = [];
694
+ const collect = (changes) => {
695
+ if (!Array.isArray(changes))
696
+ return;
697
+ for (const c of changes) {
698
+ if (!c)
699
+ continue;
700
+ if (typeof c === "string") {
701
+ files.push(c);
702
+ continue;
703
+ }
704
+ if (typeof c === "object") {
705
+ const rec = c;
706
+ const p = (rec.path ?? rec.file ?? rec.targetPath);
707
+ if (typeof p === "string")
708
+ files.push(p);
709
+ }
710
+ }
711
+ };
712
+ collect(source.changes);
713
+ collect(source.files);
714
+ collect(source.patch?.changes);
715
+ const summary = files.length
716
+ ? `请求修改文件:${files.slice(0, 3).join("、")}${files.length > 3 ? `(共 ${files.length} 项)` : ""}`
717
+ : "请求修改文件";
718
+ return { kind: "file_change", summary, files: files.length ? files : undefined };
719
+ }