@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,780 @@
1
+ /**
2
+ * Claude Code CLI Executor
3
+ *
4
+ * Spawns `claude -p --output-format stream-json --input-format stream-json`
5
+ * and communicates via NDJSON on stdin/stdout.
6
+ *
7
+ * Approval gating: when the worker supplies an `onApprovalRequest` callback,
8
+ * we set up a per-run unix-domain-socket server, write a temporary settings
9
+ * file that wires up a `PreToolUse` hook (`claude-code-hook.cjs`), and let
10
+ * claude funnel every Bash/Edit/Write/MultiEdit/NotebookEdit call through
11
+ * the human in the dashboard. When no callback is supplied we keep the
12
+ * legacy `bypassPermissions` behavior so non-interactive callers still work.
13
+ *
14
+ * `onApprovalRequest` is always supplied by the worker. For `rw_allow`
15
+ * the callback auto-accepts immediately; for `r_allow` it awaits Dashboard
16
+ * user decision. Either way the PreToolUse hook is always installed,
17
+ * preventing Claude Code's own permission prompts from hanging on closed
18
+ * stdin.
19
+ */
20
+ import { runProcess } from "../process-runner.js";
21
+ import { randomUUID } from "node:crypto";
22
+ import { createServer } from "node:http";
23
+ import fs from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { safeJsonParse } from "../../shared/parse.js";
28
+ import { emitStatus } from "../reasoning-status.js";
29
+ // Resolve the bundled hook script. After `tsc` the .cjs file is copied next
30
+ // to the compiled module (see package.json `build` script). In `tsx` dev
31
+ // mode the source path also works.
32
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
33
+ function locateHookScript() {
34
+ const candidates = [
35
+ // dist/executor/executors/ → dist/executor/claude-code-hook.cjs
36
+ path.resolve(__dirname, "..", "claude-code-hook.cjs"),
37
+ // src/executor/executors/ → src/executor/claude-code-hook.cjs (tsx dev)
38
+ path.resolve(__dirname, "..", "..", "src", "executor", "claude-code-hook.cjs"),
39
+ ];
40
+ for (const p of candidates) {
41
+ if (fs.existsSync(p))
42
+ return p;
43
+ }
44
+ // Let the caller fail loudly with a useful message.
45
+ return candidates[0];
46
+ }
47
+ const HOOK_TOOL_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit|ExitPlanMode|AskUserQuestion";
48
+ /**
49
+ * 把 cwd 转成 Claude Code 在 ~/.claude/projects/ 下的子目录名。
50
+ * 编码规则(从实际目录观察): 绝对路径里的 `/` 和 `.` 全部替换为 `-`。
51
+ * /Users/kong/ai-work/rotom → -Users-kong-ai-work-rotom
52
+ * /Users/kong/.rotom/artifacts → -Users-kong--rotom-artifacts
53
+ */
54
+ function claudeProjectDir(cwd) {
55
+ const resolved = path.resolve(cwd);
56
+ const encoded = resolved.replace(/[/.]/g, "-");
57
+ return path.join(os.homedir(), ".claude", "projects", encoded);
58
+ }
59
+ /**
60
+ * Claude Code 的 `--resume <id>` 要求该 session 已经存在于当前 cwd 对应的项目
61
+ * 目录中(<project>/<id>.jsonl);否则会抛 "No conversation found..."。首次进入
62
+ * 一个新的工作目录时必须改用 `--session-id <uuid>` 来"创建并使用"。
63
+ */
64
+ function claudeSessionExists(cwd, sessionId) {
65
+ try {
66
+ return fs.existsSync(path.join(claudeProjectDir(cwd), `${sessionId}.jsonl`));
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ export class ClaudeCodeExecutor {
73
+ async execute(prompt, workingDir, onOutput, options) {
74
+ return new Promise((resolve) => {
75
+ const resumeSessionId = options?.sessionId;
76
+ const approvalGate = options?.onApprovalRequest
77
+ ? createApprovalGate(options.onApprovalRequest)
78
+ : null;
79
+ const args = [
80
+ "-p",
81
+ "--output-format", "stream-json",
82
+ "--input-format", "stream-json",
83
+ "--verbose",
84
+ // --include-partial-messages:让 claude 输出 stream_event 类型(message_start /
85
+ // message_delta / content_block_*)。**只有 message_delta 携带真实 usage**,
86
+ // assistant 事件本身的 usage 是 {input:0, output:0} 占位。不加这个 flag
87
+ // 执行过程中拿不到 token 数,只能在 result 终态一次性拿到。
88
+ "--include-partial-messages",
89
+ // 默认 bypassPermissions:让 PreToolUse hook 完整接管审批决策;hook 不存在
90
+ // 时由 claude 自行放行,符合后台无人值守语义。
91
+ // 当 slashCommand === "/plan" 时切到 claude 原生 plan 模式:claude 会先
92
+ // 输出方案并通过 ExitPlanMode 触发审批,复用既有 kind:"plan" 审批链路。
93
+ "--permission-mode", options?.slashCommand === "/plan" ? "plan" : "bypassPermissions",
94
+ ];
95
+ if (approvalGate) {
96
+ args.push("--settings", approvalGate.settingsPath);
97
+ }
98
+ let sessionMode = "new";
99
+ if (resumeSessionId) {
100
+ if (claudeSessionExists(workingDir, resumeSessionId)) {
101
+ args.push("--resume", resumeSessionId);
102
+ sessionMode = "resume";
103
+ }
104
+ else {
105
+ // 调用方期望复用这个 sessionId,但该 cwd 下还没有它对应的 jsonl。
106
+ // 用 --session-id 创建一个新的对话并把 ID 固定下来,这样下次再传同
107
+ // 一个 ID 进来时就能走到上面的 --resume 分支。
108
+ args.push("--session-id", resumeSessionId);
109
+ sessionMode = "session-id";
110
+ }
111
+ }
112
+ const spawnEnv = { ...process.env, ...options?.env };
113
+ if (approvalGate) {
114
+ spawnEnv.ROTOM_APPROVAL_SOCKET = approvalGate.socketPath;
115
+ spawnEnv.ROTOM_APPROVAL_TOKEN = approvalGate.token;
116
+ }
117
+ console.log(`[claude-code] Spawning claude (cwd: ${workingDir}, session: ${resumeSessionId ? `${sessionMode}=${resumeSessionId}` : "new"}, ROTOM_AGENT=${spawnEnv.ROTOM_AGENT}, ROTOM_HOME=${spawnEnv.ROTOM_HOME}, gate=${approvalGate ? "on" : "off"})`);
118
+ const { proc } = runProcess({
119
+ bin: "claude",
120
+ args,
121
+ cwd: workingDir,
122
+ env: spawnEnv,
123
+ label: "claude-code",
124
+ signal: options?.signal,
125
+ });
126
+ // Write structured input (stream-json format)
127
+ // prompt 已经由 worker 用 composePrompt() 拼好(rotom-cli + agent-role +
128
+ // group-basic + cwd + task),executor 不再二次包装,直接喂给 CLI。
129
+ const inputPayload = JSON.stringify({
130
+ type: "user",
131
+ message: {
132
+ role: "user",
133
+ content: [{ type: "text", text: prompt }],
134
+ },
135
+ }) + "\n";
136
+ proc.stdin.write(inputPayload);
137
+ proc.stdin.end();
138
+ let fullOutput = "";
139
+ let sessionId = "";
140
+ let failed = false;
141
+ let capturedUsage;
142
+ let capturedModel;
143
+ // tool_use_id → tag bucket。assistant 阶段记下每个工具属于 exec 还是
144
+ // patch / ask / todo 类,user 阶段拿 tool_result 时按同一 id 决定是否要推
145
+ // [tool-result:exec](patch / ask / todo 类没有配对的 result tag,要么单独
146
+ // 走 [tool-result:ask],要么直接吞掉)。
147
+ const toolUseKinds = new Map();
148
+ // 把跨 chunk 的 NDJSON records 在 buffer 里累积,避免一条很长的 record
149
+ // (如 `Read` 1000 行 diff 后 user tool_result 有 77k 字符)被 stream chunk
150
+ // 边界切断 → split("\n") 切出半截 record → JSON.parse 失败 → 落到 catch
151
+ // 分支被原样 onOutput → 整条 77k 字符 raw record 塞进 message content
152
+ // 当成 narrative 渲染,前端无法折叠。
153
+ let stdoutBuffer = "";
154
+ proc.stdout.on("data", (data) => {
155
+ stdoutBuffer += data.toString();
156
+ // 找最后一个 \n,把 buffer 切成 "已完成的 lines" + "残留的不完整 tail"。
157
+ // \r\n 也兼容(去掉 \r),NDJSON 通常用 \n,但 Windows/某些 tty 配置可能 \r\n。
158
+ const lastNl = stdoutBuffer.lastIndexOf("\n");
159
+ if (lastNl === -1)
160
+ return; // 整个 buffer 还没攒出一条完整 line
161
+ const completed = stdoutBuffer.slice(0, lastNl);
162
+ stdoutBuffer = stdoutBuffer.slice(lastNl + 1);
163
+ const lines = completed.split("\n").map(l => l.endsWith("\r") ? l.slice(0, -1) : l).filter(Boolean);
164
+ for (const line of lines) {
165
+ let parsed = null;
166
+ try {
167
+ parsed = JSON.parse(line);
168
+ }
169
+ catch (err) {
170
+ // Catch 分支不再原样 onOutput —— 那会把半个或整个 raw record
171
+ // (含 user tool_result) 当 narrative 推到 message content,无法折叠。
172
+ // 偶尔的协议错误 warn 一下,丢掉这帧,不让它污染下游展示。
173
+ console.warn(`[claude-code] skipping malformed NDJSON record (${line.length} chars): ` +
174
+ `${err.message}`);
175
+ continue;
176
+ }
177
+ handleRecord(parsed);
178
+ }
179
+ });
180
+ // 处理单个 NDJSON record。把 switch 提到独立函数,粘行切分路径复用。
181
+ function handleRecord(parsed) {
182
+ switch (parsed.type) {
183
+ case "system":
184
+ if (parsed.session_id && !sessionId) {
185
+ sessionId = parsed.session_id;
186
+ }
187
+ break;
188
+ case "stream_event":
189
+ // --include-partial-messages 输出的流式事件(message_start /
190
+ // content_block_delta / message_delta / message_stop)。只关心
191
+ // message_delta —— 它携带当前轮累积 usage(每轮 assistant 回复
192
+ // 结束时触发一次,field 与 result.usage 一致但增量推送)。
193
+ // content_block_delta 等文本流事件不处理(assistant 事件本身
194
+ // 已带完整 content,executor 一次性 onOutput)。
195
+ if (parsed.event?.type === "message_delta" && options?.onUsage) {
196
+ const u = parsed.event.usage;
197
+ if (u && typeof u === "object") {
198
+ const increment = {
199
+ inputTokens: typeof u.input_tokens === "number" ? u.input_tokens : undefined,
200
+ outputTokens: typeof u.output_tokens === "number" ? u.output_tokens : undefined,
201
+ cacheReadTokens: typeof u.cache_read_input_tokens === "number" ? u.cache_read_input_tokens : undefined,
202
+ cacheCreationTokens: typeof u.cache_creation_input_tokens === "number" ? u.cache_creation_input_tokens : undefined,
203
+ };
204
+ // 至少有一个非零字段才推,避免 0/0 占位打爆节流队列
205
+ if ((increment.inputTokens ?? 0) > 0
206
+ || (increment.outputTokens ?? 0) > 0
207
+ || (increment.cacheReadTokens ?? 0) > 0
208
+ || (increment.cacheCreationTokens ?? 0) > 0) {
209
+ try {
210
+ options.onUsage(increment);
211
+ }
212
+ catch { /* swallow callback errors */ }
213
+ }
214
+ }
215
+ }
216
+ break;
217
+ case "assistant":
218
+ // assistant 事件的 message.usage 是 {input:0, output:0} 占位,
219
+ // 真实 token 在 stream_event/message_delta 里(见上)。这里
220
+ // 不再读 usage,避免推 0 值。result 事件终态时仍 capture
221
+ // capturedUsage 用于 ExecuteResult.usage 落 DB。
222
+ if (parsed.message?.content) {
223
+ emitStatus(onOutput, "Working");
224
+ for (const block of parsed.message.content) {
225
+ if (block.type === "text" && block.text) {
226
+ fullOutput += block.text;
227
+ onOutput(block.text);
228
+ }
229
+ else if (block.type === "tool_use" && typeof block.name === "string") {
230
+ // TodoWrite:结构化上报,不走 [tool:exec] 卡片。dashboard
231
+ // 会读 issue.latest_todos 单独渲染常驻面板,时间线也只出
232
+ // 一条极轻量 chip 事件。把 id 登记为 "todo" kind,后续
233
+ // tool_result 也跳过(避免截断 500 字的"Todos written"噪声)。
234
+ if (block.name === "TodoWrite" && options?.onTodos) {
235
+ const rawTodos = (block.input ?? {});
236
+ const todos = normalizeTodos(rawTodos.todos);
237
+ if (todos) {
238
+ if (typeof block.id === "string") {
239
+ toolUseKinds.set(block.id, "todo");
240
+ }
241
+ try {
242
+ options.onTodos(todos);
243
+ }
244
+ catch { /* swallow callback errors */ }
245
+ continue;
246
+ }
247
+ }
248
+ const { kind, label } = describeToolUseForLog(block.name, (block.input ?? {}));
249
+ if (typeof block.id === "string") {
250
+ toolUseKinds.set(block.id, kind);
251
+ }
252
+ if (kind === "patch") {
253
+ onOutput(`[tool:patch]${label}[/tool:patch]\n`);
254
+ emitStatus(onOutput, "Patching");
255
+ }
256
+ else if (kind === "ask") {
257
+ onOutput(`[tool:ask]${label}[/tool:ask]\n`);
258
+ emitStatus(onOutput, "Asking");
259
+ }
260
+ else {
261
+ onOutput(`[tool:exec]${label}[/tool:exec]\n`);
262
+ emitStatus(onOutput, "Running");
263
+ }
264
+ }
265
+ }
266
+ }
267
+ break;
268
+ case "user":
269
+ // claude 把 tool_result 包在 user 消息里回吐。patch 类(Edit/Write/
270
+ // MultiEdit/NotebookEdit)dashboard 上只展示动作不展示结果,跳过;
271
+ // todo 类(TodoWrite)结果就是一句 "Todos written" 之类,完全不展示;
272
+ // exec 类则截断后包成 [tool-result:exec] 与上一条 [tool:exec] 配对。
273
+ if (parsed.message?.content) {
274
+ for (const block of parsed.message.content) {
275
+ if (block.type !== "tool_result")
276
+ continue;
277
+ const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
278
+ const kind = toolUseId ? toolUseKinds.get(toolUseId) : undefined;
279
+ if (kind === "ask") {
280
+ const text = flattenToolResultContent(block.content);
281
+ if (text)
282
+ onOutput(`[tool-result:ask]${text}[/tool-result:ask]\n`);
283
+ emitStatus(onOutput, "Answered");
284
+ continue;
285
+ }
286
+ if (kind === "todo")
287
+ continue;
288
+ if (kind !== "exec")
289
+ continue;
290
+ const text = flattenToolResultContent(block.content);
291
+ if (!text)
292
+ continue;
293
+ const truncated = text.length > TOOL_RESULT_MAX_CHARS
294
+ ? `${text.slice(0, TOOL_RESULT_MAX_CHARS)}...`
295
+ : text;
296
+ onOutput(`[tool-result:exec]${truncated}[/tool-result:exec]\n`);
297
+ // claude 的 tool_result block 不携带 exit_code;默认当
298
+ // "Done",后续 assistant 块来时会被 "Working" 覆盖。
299
+ const isError = block.is_error === true;
300
+ emitStatus(onOutput, isError ? "Failed" : "Done");
301
+ }
302
+ }
303
+ break;
304
+ case "result":
305
+ if (parsed.session_id) {
306
+ sessionId = parsed.session_id;
307
+ }
308
+ if (typeof parsed.model === "string" && parsed.model) {
309
+ capturedModel = parsed.model;
310
+ }
311
+ const usageRaw = parsed.usage;
312
+ if (usageRaw && typeof usageRaw === "object") {
313
+ const u = usageRaw;
314
+ capturedUsage = {
315
+ inputTokens: typeof u.input_tokens === "number" ? u.input_tokens : undefined,
316
+ outputTokens: typeof u.output_tokens === "number" ? u.output_tokens : undefined,
317
+ cacheReadTokens: typeof u.cache_read_input_tokens === "number" ? u.cache_read_input_tokens : undefined,
318
+ cacheCreationTokens: typeof u.cache_creation_input_tokens === "number" ? u.cache_creation_input_tokens : undefined,
319
+ totalCostUsd: typeof parsed.total_cost_usd === "number" ? parsed.total_cost_usd : undefined,
320
+ };
321
+ }
322
+ const text = parsed.result || "";
323
+ if (text) {
324
+ fullOutput = text;
325
+ }
326
+ if (parsed.is_error) {
327
+ failed = true;
328
+ emitStatus(onOutput, "Failed");
329
+ }
330
+ else {
331
+ emitStatus(onOutput, "Answered");
332
+ }
333
+ break;
334
+ }
335
+ }
336
+ proc.stderr.on("data", (data) => {
337
+ const text = data.toString().trim();
338
+ if (text && !text.startsWith("Warning:") && !text.startsWith("Note:")) {
339
+ console.error(`[claude-code] stderr: ${text}`);
340
+ }
341
+ });
342
+ proc.on("close", (code) => {
343
+ // Flush stdoutBuffer 残留的尾部(没有以 \n 结尾的最后一条 record)。
344
+ // 正常情况 on("data") 已经处理完所有完整行,这里只处理尾巴。
345
+ if (stdoutBuffer.length > 0) {
346
+ try {
347
+ handleRecord(JSON.parse(stdoutBuffer));
348
+ }
349
+ catch (err) {
350
+ console.warn(`[claude-code] skipping trailing NDJSON record (${stdoutBuffer.length} chars): ` +
351
+ `${err.message}`);
352
+ }
353
+ stdoutBuffer = "";
354
+ }
355
+ // If resume was requested but claude returned a different session id
356
+ // AND the run failed, the resume did not land — clear sessionId so
357
+ // the caller can retry with a fresh session.
358
+ const reportedSessionId = resolveSessionId(resumeSessionId ?? "", sessionId, failed);
359
+ if (approvalGate)
360
+ approvalGate.cleanup();
361
+ console.log(`[claude-code] Exited code=${code}, output=${fullOutput.length} chars, session=${reportedSessionId}`);
362
+ resolve({
363
+ exitCode: code ?? 1,
364
+ fullOutput,
365
+ sessionId: reportedSessionId || undefined,
366
+ usage: capturedUsage,
367
+ model: capturedModel,
368
+ });
369
+ });
370
+ proc.on("error", (err) => {
371
+ console.error(`[claude-code] Spawn error: ${err.message}`);
372
+ if (approvalGate)
373
+ approvalGate.cleanup();
374
+ resolve({ exitCode: 1, fullOutput, sessionId: sessionId || undefined, usage: capturedUsage, model: capturedModel });
375
+ });
376
+ });
377
+ }
378
+ /**
379
+ * Read the tail of `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`.
380
+ * Each line is a JSON record (user/assistant/tool messages, system events,
381
+ * …). We return the last N lines verbatim — the dashboard renders them as
382
+ * a `<pre>` block. Future enhancement: pretty-print parsed records.
383
+ *
384
+ * Tolerant of missing files (returns empty content) so a "view session"
385
+ * click never 500s on a pruned transcript.
386
+ */
387
+ async readSessionContent(args) {
388
+ const file = path.join(claudeProjectDir(args.workingDir), `${args.sessionId}.jsonl`);
389
+ if (!fs.existsSync(file)) {
390
+ return { format: "jsonl", content: "" };
391
+ }
392
+ const text = fs.readFileSync(file, "utf-8");
393
+ const lines = text.split("\n");
394
+ const tail = args.tailLines ?? 200;
395
+ const sliced = lines.length > tail ? lines.slice(-tail).join("\n") : text;
396
+ return { format: "jsonl", content: sliced };
397
+ }
398
+ }
399
+ /**
400
+ * Decide which session id to report. When resume was requested but claude
401
+ * emitted a fresh, different session id AND the run failed, the resume did
402
+ * not land (claude printed "No conversation found..." to stderr, generated a
403
+ * fresh session, and exited). Return "" so the caller can retry fresh.
404
+ */
405
+ function resolveSessionId(requestedResume, emitted, failed) {
406
+ if (failed && requestedResume && emitted && emitted !== requestedResume) {
407
+ return "";
408
+ }
409
+ return emitted;
410
+ }
411
+ /**
412
+ * Stand up a one-shot unix-domain-socket server + temporary settings.json so
413
+ * claude's PreToolUse hook can ask us for permission. Returns the artifacts
414
+ * the executor needs to pass through to claude (socket path via env,
415
+ * settings path via --settings) plus a cleanup that tears the server down
416
+ * and removes the temp files.
417
+ */
418
+ function createApprovalGate(onApprovalRequest) {
419
+ const id = randomUUID();
420
+ const token = randomUUID();
421
+ // Unix socket paths are length-limited (~104 chars on macOS). Keep names
422
+ // short and lean on os.tmpdir(), which is typically /var/folders/... or
423
+ // /tmp on linux.
424
+ const socketPath = path.join(os.tmpdir(), `rotom-cc-${id.slice(0, 8)}.sock`);
425
+ const settingsPath = path.join(os.tmpdir(), `rotom-cc-${id.slice(0, 8)}.settings.json`);
426
+ // Defensive: a leftover socket from a previous crash would refuse listen.
427
+ try {
428
+ fs.unlinkSync(socketPath);
429
+ }
430
+ catch { /* fine, didn't exist */ }
431
+ const hookScript = locateHookScript();
432
+ const settings = {
433
+ hooks: {
434
+ PreToolUse: [
435
+ {
436
+ matcher: HOOK_TOOL_MATCHER,
437
+ hooks: [
438
+ { type: "command", command: `node ${JSON.stringify(hookScript)}` },
439
+ ],
440
+ },
441
+ ],
442
+ },
443
+ };
444
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
445
+ const server = createServer((req, res) => {
446
+ // Auth: hooks send the token we minted; without it we 401. Helps when
447
+ // some unrelated local process stumbles onto the socket.
448
+ if (req.headers["x-rotom-token"] !== token) {
449
+ res.statusCode = 401;
450
+ res.end("invalid token");
451
+ return;
452
+ }
453
+ if (req.method !== "POST" || req.url !== "/approval") {
454
+ res.statusCode = 404;
455
+ res.end("not found");
456
+ return;
457
+ }
458
+ let body = "";
459
+ req.setEncoding("utf8");
460
+ req.on("data", (c) => { body += c; });
461
+ req.on("end", () => {
462
+ void (async () => {
463
+ const payload = safeJsonParse(body, {});
464
+ const toolName = String(payload.tool_name || "");
465
+ const toolInput = (payload.tool_input || {});
466
+ const input = describeToolCall(toolName, toolInput);
467
+ let result = { decision: "deny" };
468
+ let reason = "User denied via dashboard";
469
+ try {
470
+ result = await onApprovalRequest(input);
471
+ if (input.kind === "ask") {
472
+ // AskUserQuestion: the user "answers" by submitting a deny with
473
+ // structured feedback. We surface the answer to claude as the
474
+ // permissionDecisionReason so the model treats it as the user's
475
+ // reply (the underlying tool call is still denied — claude reads
476
+ // the reason text and continues with the supplied answer).
477
+ const answer = result.decision === "deny" ? result.feedback?.trim() : undefined;
478
+ reason = answer
479
+ ? `[AskUserQuestion 用户答复]\n${answer}`
480
+ : `[AskUserQuestion 用户答复] (用户未填写答案)`;
481
+ }
482
+ else if (result.decision === "accept") {
483
+ reason = "User accepted via dashboard";
484
+ }
485
+ else if (result.feedback?.trim()) {
486
+ reason = `User denied via dashboard: ${result.feedback.trim()}`;
487
+ }
488
+ }
489
+ catch (err) {
490
+ reason = `approval callback error: ${err.message}`;
491
+ }
492
+ const responseBody = JSON.stringify({
493
+ hookSpecificOutput: {
494
+ hookEventName: "PreToolUse",
495
+ permissionDecision: result.decision === "accept" ? "allow" : "deny",
496
+ permissionDecisionReason: reason,
497
+ },
498
+ });
499
+ res.statusCode = 200;
500
+ res.setHeader("content-type", "application/json");
501
+ res.end(responseBody);
502
+ })();
503
+ });
504
+ });
505
+ server.listen(socketPath);
506
+ // Permissions: only the user running this process should be able to talk
507
+ // to the socket. tmpdir on macOS is per-user already, but be explicit.
508
+ try {
509
+ fs.chmodSync(socketPath, 0o600);
510
+ }
511
+ catch { /* socket may already be gone */ }
512
+ let cleaned = false;
513
+ const cleanup = () => {
514
+ if (cleaned)
515
+ return;
516
+ cleaned = true;
517
+ try {
518
+ server.close();
519
+ }
520
+ catch { /* noop */ }
521
+ try {
522
+ fs.unlinkSync(socketPath);
523
+ }
524
+ catch { /* may already be gone */ }
525
+ try {
526
+ fs.unlinkSync(settingsPath);
527
+ }
528
+ catch { /* may already be gone */ }
529
+ };
530
+ return { socketPath, settingsPath, token, cleanup };
531
+ }
532
+ // ── Tool-call streaming helpers ─────────────────────────────────────────
533
+ //
534
+ // Map claude's stream-json tool_use / tool_result blocks onto the same tag
535
+ // vocabulary codex emits, so dashboard/MarkdownContent.tsx can render them as
536
+ // tool-call cards. Write-class tools (Edit/Write/MultiEdit/NotebookEdit) go to
537
+ // [tool:patch] (no result); everything else (Bash, Read, Grep, Glob,
538
+ // WebFetch, Task, TodoWrite, ...) goes to [tool:exec] + [tool-result:exec].
539
+ const PATCH_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
540
+ const TOOL_RESULT_MAX_CHARS = 500;
541
+ // 单次 [tool:patch] 标签 body 上限。Write 一个 5000 行文件能轻松超过 200KB,
542
+ // 浏览器 PatchBlock 渲染会卡;前端只是回看场景,截断到 ~50KB 已足够看清结构。
543
+ const PATCH_LOG_MAX_BYTES = 50_000;
544
+ /** Build a unified-diff-like body for Edit/Write/MultiEdit/NotebookEdit so
545
+ * the timeline's PatchBlock can render add/remove lines instead of just a
546
+ * file path. Not a real unified diff (no line numbers / no context), but
547
+ * the +/- markers + filename headers are enough for visual review. */
548
+ function buildPatchLogBody(name, input) {
549
+ const filePath = (typeof input.file_path === "string" && input.file_path) ||
550
+ (typeof input.notebook_path === "string" && input.notebook_path) ||
551
+ "(unknown file)";
552
+ const out = [];
553
+ const pushMinus = (s) => { for (const line of s.split("\n"))
554
+ out.push(`-${line}`); };
555
+ const pushPlus = (s) => { for (const line of s.split("\n"))
556
+ out.push(`+${line}`); };
557
+ if (name === "Edit") {
558
+ const oldS = typeof input.old_string === "string" ? input.old_string : "";
559
+ const newS = typeof input.new_string === "string" ? input.new_string : "";
560
+ out.push(`--- ${filePath}`, `+++ ${filePath}`, `@@ Edit @@`);
561
+ pushMinus(oldS);
562
+ pushPlus(newS);
563
+ }
564
+ else if (name === "MultiEdit") {
565
+ const edits = Array.isArray(input.edits) ? input.edits : [];
566
+ out.push(`--- ${filePath}`, `+++ ${filePath}`);
567
+ edits.forEach((e, idx) => {
568
+ const rec = (e ?? {});
569
+ const oldS = typeof rec.old_string === "string" ? rec.old_string : "";
570
+ const newS = typeof rec.new_string === "string" ? rec.new_string : "";
571
+ out.push(`@@ MultiEdit ${idx + 1}/${edits.length} @@`);
572
+ pushMinus(oldS);
573
+ pushPlus(newS);
574
+ });
575
+ }
576
+ else if (name === "Write") {
577
+ const content = typeof input.content === "string" ? input.content : "";
578
+ out.push(`--- /dev/null`, `+++ ${filePath}`, `@@ Write @@`);
579
+ pushPlus(content);
580
+ }
581
+ else {
582
+ // NotebookEdit
583
+ const src = typeof input.new_source === "string" ? input.new_source : "";
584
+ out.push(`--- ${filePath}`, `+++ ${filePath}`, `@@ NotebookEdit @@`);
585
+ pushPlus(src);
586
+ }
587
+ let body = out.join("\n");
588
+ const bytes = Buffer.byteLength(body, "utf8");
589
+ if (bytes > PATCH_LOG_MAX_BYTES) {
590
+ body = body.slice(0, Math.floor(body.length * PATCH_LOG_MAX_BYTES / bytes))
591
+ + "\n... (truncated, full diff in the approval card)";
592
+ }
593
+ return body;
594
+ }
595
+ function describeToolUseForLog(name, input) {
596
+ if (PATCH_TOOLS.has(name)) {
597
+ return { kind: "patch", label: buildPatchLogBody(name, input) };
598
+ }
599
+ if (name === "AskUserQuestion") {
600
+ const questions = Array.isArray(input.questions) ? input.questions : [];
601
+ return { kind: "ask", label: JSON.stringify({ questions }) };
602
+ }
603
+ if (name === "Bash") {
604
+ const command = typeof input.command === "string" ? input.command : "";
605
+ return { kind: "exec", label: command || "(empty command)" };
606
+ }
607
+ if (name === "Read") {
608
+ const filePath = typeof input.file_path === "string" ? input.file_path : "";
609
+ return { kind: "exec", label: filePath ? `Read ${filePath}` : "Read" };
610
+ }
611
+ if (name === "Grep" || name === "Glob") {
612
+ const pattern = typeof input.pattern === "string" ? input.pattern : "";
613
+ const pathSuffix = typeof input.path === "string" && input.path ? ` ${input.path}` : "";
614
+ return { kind: "exec", label: pattern ? `${name} ${pattern}${pathSuffix}` : name };
615
+ }
616
+ return { kind: "exec", label: name };
617
+ }
618
+ function flattenToolResultContent(content) {
619
+ if (typeof content === "string")
620
+ return content;
621
+ if (!Array.isArray(content))
622
+ return "";
623
+ const parts = [];
624
+ for (const block of content) {
625
+ if (block && typeof block === "object") {
626
+ const b = block;
627
+ if (b.type === "text" && typeof b.text === "string")
628
+ parts.push(b.text);
629
+ }
630
+ }
631
+ return parts.join("\n");
632
+ }
633
+ /**
634
+ * 把 Claude Code TodoWrite tool_use 的 input.todos 数组规范化成 TodoItem[]。
635
+ *
636
+ * claude 输出可能有两种异常需要兜底:
637
+ * - tool_use 流式期间 input 字段可能还在拼接(早期 chunk),JSON 部分字段缺失
638
+ * - 数组里的项 status 字段值不在三选一时,映射到最近的合法值
639
+ *
640
+ * 返回 null 表示这次输入还没凑齐(或者格式完全错乱),调用方应忽略不要触发回调。
641
+ * 这样能容忍流式过程中的"半成品" input,只在拿到完整 tool_use 时上报。
642
+ */
643
+ function normalizeTodos(raw) {
644
+ if (!Array.isArray(raw))
645
+ return null;
646
+ const out = [];
647
+ for (const item of raw) {
648
+ if (!item || typeof item !== "object")
649
+ continue;
650
+ const r = item;
651
+ const content = typeof r.content === "string" ? r.content : "";
652
+ if (!content)
653
+ continue;
654
+ const statusRaw = typeof r.status === "string" ? r.status : "pending";
655
+ const status = statusRaw === "in_progress" ? "in_progress" :
656
+ statusRaw === "completed" ? "completed" :
657
+ "pending";
658
+ const activeForm = typeof r.activeForm === "string" && r.activeForm ? r.activeForm : undefined;
659
+ out.push({ content, status, ...(activeForm ? { activeForm } : {}) });
660
+ }
661
+ if (out.length === 0)
662
+ return null;
663
+ return out;
664
+ }
665
+ const MAX_DIFF_CONTENT_BYTES = 50_000;
666
+ function truncateForDiff(str, maxBytes) {
667
+ const bytes = Buffer.byteLength(str, "utf8");
668
+ if (bytes <= maxBytes)
669
+ return { text: str, truncated: false };
670
+ const truncated = str.slice(0, Math.floor(str.length * maxBytes / bytes));
671
+ return { text: truncated + "\n... (truncated)", truncated: true };
672
+ }
673
+ /**
674
+ * Translate a claude PreToolUse payload into the worker-facing approval
675
+ * input shape (the same one codex produces). Bash → exec; the edit/write
676
+ * family → file_change.
677
+ */
678
+ function describeToolCall(toolName, toolInput) {
679
+ if (toolName === "Bash") {
680
+ const command = typeof toolInput.command === "string" ? toolInput.command : "";
681
+ const cwd = typeof toolInput.cwd === "string" ? toolInput.cwd : undefined;
682
+ const description = typeof toolInput.description === "string" ? toolInput.description : "";
683
+ const summary = description
684
+ ? `请求执行命令:${description}`
685
+ : command
686
+ ? `请求执行命令:${command.length > 200 ? command.slice(0, 200) + "…" : command}`
687
+ : "请求执行 shell 命令";
688
+ return { kind: "exec", summary, command: command || undefined, cwd };
689
+ }
690
+ if (toolName === "AskUserQuestion") {
691
+ const rawQuestions = Array.isArray(toolInput.questions) ? toolInput.questions : [];
692
+ const questions = rawQuestions
693
+ .filter((q) => !!q && typeof q === "object")
694
+ .map((q) => ({
695
+ question: typeof q.question === "string" ? q.question : "",
696
+ header: typeof q.header === "string" ? q.header : "",
697
+ multiSelect: Boolean(q.multiSelect),
698
+ options: Array.isArray(q.options)
699
+ ? q.options
700
+ .filter((o) => !!o && typeof o === "object")
701
+ .map((o) => ({
702
+ label: typeof o.label === "string" ? o.label : "",
703
+ description: typeof o.description === "string" ? o.description : "",
704
+ }))
705
+ : [],
706
+ }));
707
+ const headline = questions[0]?.question || "AskUserQuestion";
708
+ const more = questions.length > 1 ? `(+${questions.length - 1} 个问题)` : "";
709
+ return {
710
+ kind: "ask",
711
+ summary: `请求询问用户:${headline.length > 80 ? headline.slice(0, 80) + "…" : headline}${more}`,
712
+ questions,
713
+ };
714
+ }
715
+ if (toolName === "ExitPlanMode") {
716
+ const plan = typeof toolInput.plan === "string" ? toolInput.plan : "";
717
+ const firstLine = plan.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
718
+ const headline = firstLine.replace(/^#+\s*/, "");
719
+ const summary = headline
720
+ ? `请求确认方案:${headline.length > 80 ? headline.slice(0, 80) + "…" : headline}`
721
+ : "请求确认方案";
722
+ return { kind: "plan", summary, plan: plan || undefined };
723
+ }
724
+ // Edit / Write / MultiEdit / NotebookEdit
725
+ const filePath = typeof toolInput.file_path === "string"
726
+ ? toolInput.file_path
727
+ : typeof toolInput.notebook_path === "string"
728
+ ? toolInput.notebook_path
729
+ : "";
730
+ const files = filePath ? [filePath] : [];
731
+ let diff;
732
+ let summary;
733
+ if (toolName === "Edit") {
734
+ const oldStr = typeof toolInput.old_string === "string" ? toolInput.old_string : "";
735
+ const newStr = typeof toolInput.new_string === "string" ? toolInput.new_string : "";
736
+ const { text: oldT, truncated: t1 } = truncateForDiff(oldStr, MAX_DIFF_CONTENT_BYTES);
737
+ const { text: newT, truncated: t2 } = truncateForDiff(newStr, MAX_DIFF_CONTENT_BYTES);
738
+ diff = { tool: toolName, hunks: [{ old_string: oldT, new_string: newT }], truncated: t1 || t2 };
739
+ const lines = oldStr.split("\n").length;
740
+ summary = filePath
741
+ ? `请求编辑文件:${filePath} (${lines} 行)`
742
+ : "请求编辑文件";
743
+ }
744
+ else if (toolName === "MultiEdit") {
745
+ const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [];
746
+ let anyTruncated = false;
747
+ const hunks = edits.map((e) => {
748
+ const oldStr = typeof e.old_string === "string" ? e.old_string : "";
749
+ const newStr = typeof e.new_string === "string" ? e.new_string : "";
750
+ const { text: o, truncated: t1 } = truncateForDiff(oldStr, MAX_DIFF_CONTENT_BYTES);
751
+ const { text: n, truncated: t2 } = truncateForDiff(newStr, MAX_DIFF_CONTENT_BYTES);
752
+ if (t1 || t2)
753
+ anyTruncated = true;
754
+ return { old_string: o, new_string: n };
755
+ });
756
+ diff = { tool: toolName, hunks, truncated: anyTruncated };
757
+ summary = filePath
758
+ ? `请求批量编辑文件:${filePath} (${edits.length} 处修改)`
759
+ : `请求批量编辑文件`;
760
+ }
761
+ else if (toolName === "Write") {
762
+ const content = typeof toolInput.content === "string" ? toolInput.content : "";
763
+ const { text, truncated } = truncateForDiff(content, MAX_DIFF_CONTENT_BYTES);
764
+ diff = { tool: toolName, hunks: [], new_content: text, truncated };
765
+ summary = filePath
766
+ ? `请求写入文件:${filePath} (${content.length.toLocaleString()} 字符)`
767
+ : "请求写入文件";
768
+ }
769
+ else {
770
+ // NotebookEdit
771
+ const newSource = typeof toolInput.new_source === "string" ? toolInput.new_source : "";
772
+ const { text, truncated } = truncateForDiff(newSource, MAX_DIFF_CONTENT_BYTES);
773
+ diff = { tool: toolName, hunks: [], new_content: text, truncated };
774
+ const verb = "编辑";
775
+ summary = filePath
776
+ ? `请求${verb}文件:${filePath}`
777
+ : `请求${verb}文件`;
778
+ }
779
+ return { kind: "file_change", summary, files: files.length ? files : undefined, diff };
780
+ }