@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,855 @@
1
+ /**
2
+ * Hermes CLI Executor
3
+ *
4
+ * Spawns `hermes acp` and communicates via ACP (Agent Communication Protocol)
5
+ * JSON-RPC 2.0 over stdin/stdout. Follows the same lifecycle as the Go
6
+ * reference implementation in Multica:
7
+ *
8
+ * 1. initialize handshake
9
+ * 2. session/new
10
+ * 3. session/prompt (streams updates via session/update notifications)
11
+ * 4. auto-approve permission requests
12
+ */
13
+ import { runProcess } from "../process-runner.js";
14
+ import { createJsonRpcTransport } from "../jsonrpc-transport.js";
15
+ import { createInterface } from "node:readline";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import fs from "node:fs";
19
+ import { createReasoningStatusBuffer, emitStatus, } from "../reasoning-status.js";
20
+ // ── Provider error detection ────────────────────────────────────────────
21
+ // Mirrors multica's acpProviderErrorSniffer / acpAgentOutputTerminalRe
22
+ // (server/pkg/agent/hermes.go). Hermes emits its final response
23
+ // (`"API call failed after N retries: ..."`) via
24
+ // acp_adapter/server.py:1634 as a regular `agent_message_chunk`, so without
25
+ // sniffing we'd happily stream "API call failed after 3 retries: Connection
26
+ // error." to the dashboard as if the agent had actually said that.
27
+ //
28
+ // We match in TWO places:
29
+ // 1. stderr lines (hermes logs the same failure there at WARNING level).
30
+ // 2. `agent_message_chunk.text` (the user-visible "reply").
31
+ // First hit flips `providerError.matched`; once set we stop accumulating
32
+ // `fullOutput` for matching chunks and surface a clean error instead.
33
+ const PROVIDER_ERROR_PATTERNS = [
34
+ // "API call failed after 3 retries: Connection error." (hermes primary)
35
+ /API call failed after \d+ retr(?:y|ies)/i,
36
+ // SDK-level error names — backup signal in case the summary line is
37
+ // truncated or absent.
38
+ /\bAPIConnectionError\b/,
39
+ /\bBadRequestError\b/,
40
+ /\bAuthenticationError\b/,
41
+ /\bRateLimitError\b/,
42
+ // "Non-retryable …" prefix hermes logs on unrecoverable failures.
43
+ /Non-retryable/i,
44
+ // hermes also prints bracketed ERROR markers via conversation_loop.
45
+ /\[ERROR\]/,
46
+ // 4xx/5xx in the same line as an error keyword (covers HTTP 401/429/500/…).
47
+ /\bHTTP\s+[45]\d{2}\b.*(?:error|fail|denied|forbidden|unauthor)/i,
48
+ ];
49
+ function matchProviderError(text) {
50
+ for (const re of PROVIDER_ERROR_PATTERNS) {
51
+ const m = text.match(re);
52
+ if (m)
53
+ return m;
54
+ }
55
+ return null;
56
+ }
57
+ // Pull a *clean* error reason out of a hermes stderr line, in priority order:
58
+ //
59
+ // 1. `summary=Connection error.` → "Connection error"
60
+ // 2. `❌ API failed after N retries — Connection error.` →
61
+ // "API failed after N retries — Connection error"
62
+ // 3. `💀 Final error: Connection error.` → "Final error: Connection error"
63
+ // 4. `API call failed after N retries. <Reason>.` →
64
+ // "API call failed after N retries: <Reason>"
65
+ // 5. fallback → "provider error"
66
+ //
67
+ // The matched line itself is almost always a structured log record (timestamp
68
+ // + level + thread + provider metadata); we don't want to surface that
69
+ // verbatim — see the worker.ts "[错误] 模型调用失败: …" branch.
70
+ const CLEAN_ERROR_PATTERNS = [
71
+ // `summary=...` 字段是 hermes 的归一化错误信息,几乎所有 WARNING/ERROR 行都有
72
+ /\bsummary=([^\s|]+(?:[ ][^\s|]+)*?)(?:\s*\||\s*$)/,
73
+ // prettier 错误横幅
74
+ /❌\s*API\s+failed\s+after\s+\d+\s+retries?\s*[—–-]\s*([^\n]+?)\.?\s*$/,
75
+ /💀\s*Final\s+error:\s*([^\n]+?)\.?\s*$/,
76
+ // ERROR 行的 "API call failed after N retries. <Reason>."
77
+ /API\s+call\s+failed\s+after\s+\d+\s+retries?\.?\s*([^|.]*?)\s*\.?\s*$/,
78
+ ];
79
+ function extractCleanErrorReason(text) {
80
+ for (const re of CLEAN_ERROR_PATTERNS) {
81
+ const m = text.match(re);
82
+ if (m && m[1]) {
83
+ const reason = m[1].trim().replace(/\s+/g, " ");
84
+ if (reason && reason.length < 200)
85
+ return reason;
86
+ }
87
+ }
88
+ return "provider error";
89
+ }
90
+ // ── Executor ────────────────────────────────────────────────────────────
91
+ /**
92
+ * Build the env passed to the hermes subprocess.
93
+ *
94
+ * Why not just spread `process.env`? The rotom executor daemon is launched
95
+ * from a shell that may have local-proxy / IDE-vars polluting env
96
+ * (ANTHROPIC_BASE_URL=http://127.0.0.1:58082, ANTHROPIC_AUTH_TOKEN=sk-cp-...,
97
+ * CCV_PROXY_MODE=1, plus ANTHROPIC_DEFAULT_*_MODEL overrides). When those
98
+ * leak into the hermes subprocess they cause ACP `session/resume`'s 2nd
99
+ * turn to fail with `APIConnectionError` to whatever URL those vars
100
+ * point at (the connection is alive enough to consume the request but
101
+ * not enough to deliver a response). Verified 2026-06-14:
102
+ *
103
+ * env with CCV leak + ACP session/new + 2nd session/prompt
104
+ * → 2nd turn: "API call failed after 3 retries: Connection error."
105
+ * same scenario with CCV vars stripped
106
+ * → 2nd turn: normal reply, history replayed correctly
107
+ *
108
+ * `hermes-agent` is configured via `~/.hermes/config.yaml` (model +
109
+ * base_url) and `~/.hermes/.env` (ANTGROUP_API_KEY); it does not read
110
+ * ANTHROPIC_* from env. The Anthropic SDK inside hermes, however, does
111
+ * pick up ANTHROPIC_BASE_URL as a transport-level fallback for some
112
+ * paths, which is enough to misroute the 2nd connection.
113
+ *
114
+ * We strip the leaky vars here and let hermes read its own config.
115
+ */
116
+ function buildHermesEnv(parentEnv, optionsEnv, mergedPath) {
117
+ // We strip by *prefix* for the Claude Code / Anthropic env family because
118
+ // the SDK picks up any of `ANTHROPIC_*`, `CLAUDE*`, `CLAUDECODE` and the
119
+ // exact list grows over time. Anything that smells like Claude Code
120
+ // session bookkeeping (CLAUDE_CODE_EXECUTABLE, _SSE_PORT, _SUBAGENT_MODEL,
121
+ // CLAUDECODE, ...) should NOT leak into a hermes subprocess — those are
122
+ // signals about the rotom daemon's own claude-code execution, not hermes.
123
+ const STRIPPED_PREFIXES = [
124
+ "ANTHROPIC_",
125
+ "CLAUDE_CODE_",
126
+ "CLAUDECODE",
127
+ ];
128
+ const STRIPPED_EXACT = new Set([
129
+ "CCV_PROXY_MODE",
130
+ ]);
131
+ const out = {};
132
+ for (const [k, v] of Object.entries(parentEnv)) {
133
+ if (STRIPPED_EXACT.has(k))
134
+ continue;
135
+ if (STRIPPED_PREFIXES.some((p) => k === p || k.startsWith(p)))
136
+ continue;
137
+ out[k] = v;
138
+ }
139
+ if (optionsEnv)
140
+ Object.assign(out, optionsEnv);
141
+ out.PATH = mergedPath ?? parentEnv.PATH ?? "";
142
+ out.HERMES_YOLO_MODE = "1";
143
+ console.log(`[hermes-cli] buildHermesEnv: stripping results in ${Object.keys(out).length} keys:`);
144
+ for (const k of Object.keys(out).sort()) {
145
+ console.log(`[hermes-cli] env.${k} = ${(out[k] ?? "").toString().slice(0, 100)}`);
146
+ }
147
+ return out;
148
+ }
149
+ export class HermesCliExecutor {
150
+ async execute(prompt, workingDir, onOutput, options) {
151
+ return new Promise((resolve) => {
152
+ const args = ["acp"];
153
+ const resumeSessionId = options?.sessionId || "";
154
+ console.log(`[hermes-cli] Spawning hermes acp (cwd: ${workingDir})`);
155
+ // hermes lives in a venv (`~/hermes-agent/venv/bin`) and is not on the
156
+ // default PATH of a daemonised master. Prepend the candidate locations
157
+ // so `spawn("hermes")` finds it.
158
+ const extraPath = [
159
+ path.join(os.homedir(), "hermes-agent", "venv", "bin"),
160
+ ].filter((p) => fs.existsSync(p)).join(":");
161
+ const mergedPath = extraPath
162
+ ? `${extraPath}:${process.env.PATH ?? ""}`
163
+ : process.env.PATH;
164
+ const { proc } = runProcess({
165
+ bin: "hermes",
166
+ args,
167
+ cwd: workingDir,
168
+ env: buildHermesEnv(process.env, options?.env, mergedPath),
169
+ label: "hermes-cli",
170
+ signal: options?.signal,
171
+ });
172
+ let fullOutput = "";
173
+ const pendingTools = new Map();
174
+ let sessionId = "";
175
+ let settled = false;
176
+ let inThinking = false;
177
+ // Set when we receive the ACP turn_end notification, so finish() can
178
+ // distinguish "model finished cleanly" (already emitted "Answered")
179
+ // from "process died before turn_end ever arrived" (needs a terminal
180
+ // emit to keep the dashboard status pill from sticking on "Working").
181
+ let turnEndSeen = false;
182
+ // Set when a terminal provider/model error is detected in stderr or
183
+ // in an agent_message_chunk — see matchProviderError() above. When
184
+ // set, finish() returns `failed: true` so the worker surfaces a
185
+ // clean error and drops the cached sessionId (next turn starts
186
+ // fresh with session/new, which is the only path that currently
187
+ // works for the session/resume + second-prompt bug).
188
+ // `message` is the user-facing reason (extracted via
189
+ // extractCleanErrorReason so we never surface raw log records).
190
+ let providerError = {
191
+ matched: false,
192
+ message: "",
193
+ };
194
+ // Buffer agent_message_chunk text so a split error string
195
+ // ("API call failed " + "after 3 retries: …") still matches. We
196
+ // only ever inspect this buffer for the regex; the chunks
197
+ // themselves still stream to onOutput.
198
+ let agentTextBuffer = "";
199
+ // Hermes 的 agent_message_chunk 切得很细(中文甚至逐字),每次 chunk
200
+ // 后都 emitStatus("Working") 会把正文切成一堆被 [status:thinking] 包围的
201
+ // 短段,即使前端能合并渲染,持久化进 DB 的 content 仍然会污染。
202
+ // 用一个本地 lastEmitted 跟踪上一次 emit 的 status,值不变就不再 emit。
203
+ let lastStatusEmitted = "";
204
+ function emitStatusDedup(text) {
205
+ if (text === lastStatusEmitted)
206
+ return;
207
+ lastStatusEmitted = text;
208
+ emitStatus(onOutput, text);
209
+ }
210
+ // 从 reasoning 流里抽第一个 **Header**,emit 为 [status:thinking] 标签,
211
+ // 在 dashboard 顶部以 shimmer pill 形式展示。完全对齐 codex-rs/tui 的
212
+ // extract_first_bold + set_status_header 模式。
213
+ const reasoningStatus = createReasoningStatusBuffer((tag) => onOutput(tag));
214
+ // ── Chunk coalescing ────────────────────────────────────────────
215
+ // hermes ACP 把 agent_message_chunk / agent_thought_chunk 切得超细(英文
216
+ // 逐词、中文甚至逐字)。原代码对每个 chunk 都直接 onOutput,worker 侧每
217
+ // 个 chunk 触发一次 sendUpdate("in_progress", chunk),master 侧落一行
218
+ // progress 事件 + 广播 notifyIssueChanged。实测一个 80s 的 issue 累积
219
+ // 2840 条 progress 事件,其中 2207 条长度 ≤5 字节("The"/" user"/...)。
220
+ // 这里用 200ms 时间窗口把相邻 chunk 合并成一次 onOutput,DB / WS 事件
221
+ // 密度从 ~35 条/s 降到 ~5 条/s。
222
+ //
223
+ // message 和 thought 两个 buffer 互斥(inThinking 状态机保证同一时刻只
224
+ // 在一个模式),flush 时按 inThinking 决定 flush 哪个。
225
+ // 边界事件(thinking 开闭 / tool_call / turn_end / 进程退出)立即 flush,
226
+ // 保证结构化标记 ([thinking]/[tool:exec]/[status:thinking]) 不被跨界
227
+ // 合并、不乱序。
228
+ const FLUSH_INTERVAL_MS = 500;
229
+ let messageBuffer = "";
230
+ let thoughtBuffer = "";
231
+ let flushTimer = null;
232
+ function doFlush() {
233
+ if (inThinking) {
234
+ if (thoughtBuffer) {
235
+ onOutput(thoughtBuffer);
236
+ thoughtBuffer = "";
237
+ }
238
+ }
239
+ else {
240
+ if (messageBuffer) {
241
+ onOutput(messageBuffer);
242
+ messageBuffer = "";
243
+ }
244
+ }
245
+ }
246
+ function flushNow() {
247
+ if (flushTimer) {
248
+ clearTimeout(flushTimer);
249
+ flushTimer = null;
250
+ }
251
+ doFlush();
252
+ }
253
+ function scheduleFlush() {
254
+ if (flushTimer)
255
+ return;
256
+ flushTimer = setTimeout(() => {
257
+ flushTimer = null;
258
+ doFlush();
259
+ }, FLUSH_INTERVAL_MS);
260
+ }
261
+ // 新版 hermes ACP 在 session/resume 里会同步 replay 整段对话历史
262
+ // (user/assistant/thought chunks,跟 live chunk 类型完全相同)。
263
+ // hermes 是 await 完 replay 才返回 session/resume 的 RPC 响应,所以
264
+ // 我们用这一段时间窗口作为屏蔽:replayActive=true 时所有 session_update
265
+ // 全部静默吞掉,避免把历史重复推给前端。
266
+ let replayActive = false;
267
+ let capturedUsage;
268
+ let capturedModel;
269
+ // 新版 hermes 把思考内容拆成很多小 chunk 流式下发,必须把连续的
270
+ // thought chunk 合并到同一个 [thinking]...[/thinking] 块里,否则
271
+ // 前端解析器会把每个 chunk 渲染成独立的 "💭 思考" 折叠块。
272
+ function closeThinkingIfOpen() {
273
+ if (inThinking) {
274
+ // 把还在 thoughtBuffer 里的内容先 flush 出去,再 emit 关闭标签,
275
+ // 否则 [thinking] 内容会被 200ms 延迟到 [/thinking] 之后。
276
+ flushNow();
277
+ onOutput(`[/thinking]`);
278
+ inThinking = false;
279
+ }
280
+ // reasoning section 结束,清掉 reasoningStatus 的 lastEmitted
281
+ // 记忆。下次再有 thought chunk 且抽出同一个 **Header**,也会
282
+ // 重新 emit 一次(pill 视觉上保持,因为 dashboard 端只保留最新
283
+ // 的 [status:thinking] tag)。
284
+ reasoningStatus.reset();
285
+ }
286
+ // ── JSON-RPC transport ──
287
+ // The transport owns the readline loop, the pending-request map, and
288
+ // the JSON frame formatting. We plug our domain handlers into the
289
+ // onRequest / onNotification callbacks below.
290
+ const transport = createJsonRpcTransport({
291
+ stdin: proc.stdin,
292
+ stdout: proc.stdout,
293
+ label: "hermes-cli",
294
+ onRequest: (method, params, id) => handleAgentRequest(method, params, id),
295
+ onNotification: (method, params) => handleNotification(method, params),
296
+ });
297
+ // ── Helpers ──
298
+ function send(msg) {
299
+ transport.send(msg);
300
+ }
301
+ function request(method, params) {
302
+ return transport.request(method, params);
303
+ }
304
+ function finish(exitCode) {
305
+ if (settled)
306
+ return;
307
+ settled = true;
308
+ // 进程退出前必须把还在 buffer 里的 chunk emit 出去,否则用户会丢失
309
+ // 最后一批 message / thought 内容。closeThinkingIfOpen 里也会 flushNow,
310
+ // 但只在 inThinking=true 时触发;message 模式退出时靠这一行兜底。
311
+ flushNow();
312
+ closeThinkingIfOpen();
313
+ // Fallback terminal status: if the process died without us ever
314
+ // seeing a turn_end (e.g. spawn error, model 400, broken pipe),
315
+ // emit something so the dashboard's status pill doesn't stay on
316
+ // "Working" forever. Successful turns already emitted "Answered"
317
+ // in the turn_end case, so this is a no-op for the happy path.
318
+ if (!turnEndSeen && !providerError.matched) {
319
+ emitStatus(onOutput, exitCode === 0 ? "Done" : "Failed");
320
+ }
321
+ // Provider error path — return failed/invalidateSession so the
322
+ // worker surfaces a clean error and drops the cached sessionId
323
+ // (next turn starts fresh with session/new, which is the only
324
+ // path that currently works for the session/resume bug).
325
+ if (providerError.matched) {
326
+ console.warn(`[hermes-cli] Provider error → returning failed. exitCode=${exitCode} message="${providerError.message}"`);
327
+ }
328
+ console.log(`[hermes-cli] Exited code=${exitCode}, output=${fullOutput.length} chars, session=${sessionId}, model=${capturedModel ?? "(none)"}, usage=${capturedUsage ? JSON.stringify(capturedUsage) : "(none)"}`);
329
+ resolve({
330
+ exitCode,
331
+ fullOutput,
332
+ sessionId: providerError.matched ? undefined : (sessionId || undefined),
333
+ invalidateSession: providerError.matched || undefined,
334
+ failed: providerError.matched || undefined,
335
+ errorMessage: providerError.matched ? providerError.message : undefined,
336
+ usage: capturedUsage,
337
+ model: capturedModel,
338
+ });
339
+ }
340
+ // ── Handle agent → client requests (auto-approve permissions) ──
341
+ // ACP 各版本对 permission 选项的命名不一样(approve_for_session /
342
+ // allow_always / allow_once / approve_once …)。直接读 params.options,
343
+ // 按优先级匹配一个"允许"类的 option;找不到就退回到 options[0],最坏
344
+ // 也比硬编码一个不存在的 ID 让 agent 卡住等审批要好。
345
+ function pickApproveOption(params) {
346
+ const options = params?.options;
347
+ if (!Array.isArray(options) || options.length === 0) {
348
+ return "approve_for_session";
349
+ }
350
+ const ids = options
351
+ .map((o) => o?.optionId)
352
+ .filter((id) => typeof id === "string");
353
+ const priority = [
354
+ /^approve_for_session$/i,
355
+ /^allow_always$/i,
356
+ /^always_allow$/i,
357
+ /^allow_for_session$/i,
358
+ /^approve$/i,
359
+ /^allow_once$/i,
360
+ /^approve_once$/i,
361
+ /allow/i,
362
+ /approve/i,
363
+ ];
364
+ for (const re of priority) {
365
+ const hit = ids.find((id) => re.test(id));
366
+ if (hit)
367
+ return hit;
368
+ }
369
+ return ids[0] ?? "approve_for_session";
370
+ }
371
+ function handleAgentRequest(method, rawParams, id) {
372
+ let resp;
373
+ if (method === "session/request_permission") {
374
+ const optionId = pickApproveOption(rawParams);
375
+ console.log(`[hermes-cli] auto-approve permission → ${optionId}`);
376
+ resp = {
377
+ jsonrpc: "2.0",
378
+ id,
379
+ result: {
380
+ outcome: {
381
+ outcome: "selected",
382
+ optionId,
383
+ },
384
+ },
385
+ };
386
+ }
387
+ else {
388
+ console.warn(`[hermes-cli] unhandled agent→client method: ${method} (params=${JSON.stringify(rawParams).slice(0, 200)})`);
389
+ resp = {
390
+ jsonrpc: "2.0",
391
+ id,
392
+ error: { code: -32601, message: `method not found: ${method}` },
393
+ };
394
+ }
395
+ send(resp);
396
+ }
397
+ // ── Handle JSON-RPC responses ──
398
+ // (The transport's pending map owns this — responses matching a
399
+ // transport.request() id resolve the corresponding promise.)
400
+ // ── ACP notification handling ──
401
+ function normalizeUpdateType(raw) {
402
+ if (typeof raw === "object" && raw !== null) {
403
+ const obj = raw;
404
+ const key = obj.sessionUpdate ??
405
+ obj.type;
406
+ if (key)
407
+ return normalizeTypeKey(key);
408
+ // Externally tagged: { agentMessageChunk: { ... } }
409
+ const keys = Object.keys(obj);
410
+ if (keys.length === 1)
411
+ return normalizeTypeKey(keys[0]);
412
+ }
413
+ return "";
414
+ }
415
+ function normalizeTypeKey(t) {
416
+ const k = t.replace(/[-_]/g, "").toLowerCase().trim();
417
+ switch (k) {
418
+ case "agentmessagechunk": return "agent_message_chunk";
419
+ case "agentthoughtchunk": return "agent_thought_chunk";
420
+ case "toolcall": return "tool_call";
421
+ case "toolcallupdate": return "tool_call_update";
422
+ case "usageupdate": return "usage_update";
423
+ case "turnend":
424
+ case "endturn": return "turn_end";
425
+ default: return "";
426
+ }
427
+ }
428
+ // hermes 给的 `title` 通常是 `terminal: $ rotom ...`,直接塞进
429
+ // [tool:exec] 会被前端 ToolCallBlock 渲染成 `$ $ rotom ...`(block
430
+ // 本身就前置一个 `$` 提示符)。这里把开头的 `$` 去掉,让渲染干净。
431
+ function stripLeadingDollarPrompt(s) {
432
+ return s.replace(/^\$\s*/, "");
433
+ }
434
+ function toolNameFromTitle(title, kind) {
435
+ if (title === "execute code")
436
+ return "execute_code";
437
+ const idx = title.indexOf(":");
438
+ if (idx > 0) {
439
+ const name = title.slice(0, idx).trim();
440
+ const map = {
441
+ terminal: "terminal",
442
+ read: "read_file",
443
+ write: "write_file",
444
+ search: "search_files",
445
+ "web search": "web_search",
446
+ extract: "web_extract",
447
+ delegate: "delegate_task",
448
+ "analyze image": "vision_analyze",
449
+ };
450
+ if (map[name])
451
+ return map[name];
452
+ if (name.startsWith("patch"))
453
+ return "patch";
454
+ return name;
455
+ }
456
+ const kindMap = {
457
+ read: "read_file",
458
+ edit: "write_file",
459
+ execute: "terminal",
460
+ search: "search_files",
461
+ fetch: "web_search",
462
+ think: "thinking",
463
+ };
464
+ return kindMap[kind] ?? title ?? kind;
465
+ }
466
+ // 新版 hermes 把"polished"工具(read_file/terminal/skill_view/...)
467
+ // 的参数写在 content[].content.text 里而不是 rawInput(见
468
+ // acp_adapter/tools.py:_POLISHED_TOOLS)。我们之前只读 rawInput
469
+ // 所以这些工具全显示成 `[tool] read_file: undefined`。
470
+ // 这里把 content 里第一段非空文本块当成参数展示来源。
471
+ function extractArgsFromContent(update) {
472
+ const content = update.content;
473
+ if (!Array.isArray(content))
474
+ return undefined;
475
+ for (const block of content) {
476
+ const b = block;
477
+ const inner = b?.content;
478
+ const text = inner?.text;
479
+ if (typeof text === "string" && text.trim())
480
+ return text;
481
+ }
482
+ return undefined;
483
+ }
484
+ function handleNotification(method, rawParams) {
485
+ if (method !== "session/update" && method !== "session/notification")
486
+ return;
487
+ const params = rawParams;
488
+ const update = params?.update;
489
+ if (!update)
490
+ return;
491
+ // session/resume 期间到达的全是历史 replay(user/assistant/thought
492
+ // chunks 形态与 live 完全相同),直接吞掉。等 resume RPC 返回,
493
+ // replayActive 会被置 false,后续 session/prompt 的 update 才会进 switch。
494
+ if (replayActive)
495
+ return;
496
+ const updateType = normalizeUpdateType(update);
497
+ if (process.env.ROTOM_HERMES_DEBUG) {
498
+ const obj = update;
499
+ const rawKey = obj.sessionUpdate ?? obj.type ?? Object.keys(obj)[0] ?? "(none)";
500
+ console.log(`[hermes DEBUG] session/update updateType=${updateType || "(unhandled)"} rawKey=${rawKey} keys=${Object.keys(obj).slice(0, 8).join(",")}`);
501
+ }
502
+ switch (updateType) {
503
+ case "agent_message_chunk": {
504
+ const content = update.content;
505
+ const text = content?.text;
506
+ if (text) {
507
+ // Buffer-then-check so split chunks ("API call failed " +
508
+ // "after 3 retries: …") still trigger the sniffer. Cap the
509
+ // buffer at 1 KiB — anything bigger is clearly not just
510
+ // an error string and we don't want to grow it forever.
511
+ agentTextBuffer = (agentTextBuffer + text).slice(-1024);
512
+ if (!providerError.matched) {
513
+ const m = matchProviderError(agentTextBuffer);
514
+ if (m) {
515
+ providerError = { matched: true, message: extractCleanErrorReason(agentTextBuffer) };
516
+ emitStatus(onOutput, "Failed");
517
+ console.error(`[hermes-cli] provider error detected in agent_message_chunk: ${m[0]}`);
518
+ }
519
+ }
520
+ closeThinkingIfOpen();
521
+ // If this chunk is part of a provider-error reply, do NOT
522
+ // accumulate it into fullOutput — the worker uses fullOutput
523
+ // as the assistant's "answer" and we don't want
524
+ // "API call failed after 3 retries: …" rendered as such.
525
+ // We still stream it to onOutput so the dashboard sees the
526
+ // raw event for debugging, and the live status pill flips
527
+ // to "Failed" via emitStatus above.
528
+ if (!providerError.matched) {
529
+ fullOutput += text;
530
+ }
531
+ messageBuffer += text;
532
+ // 模型已经从「思考」切到「回答」,状态 pill 切回 "Working"。
533
+ // 当 sniffer 已经标记失败时,跳过这条 emit,保留上面 "Failed"
534
+ // 作为最后一个状态,避免 dashboard pill 被覆盖回 "Working"。
535
+ if (!providerError.matched) {
536
+ emitStatusDedup("Working");
537
+ }
538
+ scheduleFlush();
539
+ }
540
+ break;
541
+ }
542
+ case "agent_thought_chunk": {
543
+ const content = update.content;
544
+ const text = content?.text;
545
+ if (text) {
546
+ if (!inThinking) {
547
+ // 切到 thinking:先 flush message buffer(可能还有未发的正文),
548
+ // 再 emit 开始标签,否则 [thinking] 会插到正文前面。
549
+ flushNow();
550
+ onOutput(`[thinking]`);
551
+ inThinking = true;
552
+ }
553
+ thoughtBuffer += text;
554
+ // 把 chunk 累加到 reasoningStatus,首次抽出 **Header** 时自动
555
+ // emit 一个 [status:thinking] 标签(reasoningStatus 自己 emit,
556
+ // 不走我们的 buffer,标签会立即出现,与 chunk 频率解耦)。
557
+ reasoningStatus.append(text);
558
+ scheduleFlush();
559
+ }
560
+ break;
561
+ }
562
+ case "tool_call": {
563
+ closeThinkingIfOpen();
564
+ // flush message buffer:确保前面的 message 内容先 emit,
565
+ // 否则 [tool:exec] 卡片会跑到正文前面,前端顺序乱。
566
+ flushNow();
567
+ const u = update;
568
+ const toolCallId = u.toolCallId;
569
+ const title = u.title;
570
+ const kind = u.kind;
571
+ const rawInput = (u.rawInput ?? u.input ?? u.parameters);
572
+ // polished 工具的参数走 content text block,rawInput 会是 null
573
+ const contentArgs = extractArgsFromContent(u);
574
+ const toolName = toolNameFromTitle(title ?? "", kind ?? "");
575
+ if (rawInput && Object.keys(rawInput).length > 0) {
576
+ pendingTools.set(toolCallId, { toolName, input: rawInput, argsText: "", emitted: true });
577
+ onOutput(`[tool:exec]${stripLeadingDollarPrompt(JSON.stringify(rawInput))}[/tool:exec]\n`);
578
+ }
579
+ else if (contentArgs) {
580
+ pendingTools.set(toolCallId, { toolName, argsText: contentArgs, emitted: true });
581
+ onOutput(`[tool:exec]${stripLeadingDollarPrompt(contentArgs)}[/tool:exec]\n`);
582
+ }
583
+ else {
584
+ pendingTools.set(toolCallId, { toolName, argsText: "", emitted: false });
585
+ }
586
+ break;
587
+ }
588
+ case "tool_call_update": {
589
+ const u = update;
590
+ const toolCallId = u.toolCallId;
591
+ const status = u.status;
592
+ const title = (u.title ?? u.name);
593
+ const kind = u.kind;
594
+ const rawInput = (u.rawInput ?? u.input ?? u.parameters);
595
+ const output = (u.rawOutput ?? u.output);
596
+ if (status !== "completed" && status !== "failed") {
597
+ // Mid-stream update — buffer args from content
598
+ const pt = pendingTools.get(toolCallId);
599
+ if (pt && !pt.emitted) {
600
+ const buffered = extractArgsFromContent(u);
601
+ if (buffered)
602
+ pt.argsText = buffered;
603
+ }
604
+ return;
605
+ }
606
+ // Completed — emit deferred tool use if needed
607
+ closeThinkingIfOpen();
608
+ // tool_call_update 完成时同样需要先 flush message buffer,
609
+ // 保证 [tool:exec] / [tool-result:exec] 不跑在正文前面。
610
+ flushNow();
611
+ const pt = pendingTools.get(toolCallId);
612
+ pendingTools.delete(toolCallId);
613
+ if (!pt?.emitted) {
614
+ const toolName = pt?.toolName ?? toolNameFromTitle(title ?? "", kind ?? "");
615
+ // 优先级:之前缓冲的 input → 缓冲的 argsText → 完成包里的
616
+ // rawInput → 完成包 content 里的 text → "(no args)"
617
+ let argsRepr;
618
+ if (pt?.input)
619
+ argsRepr = JSON.stringify(pt.input);
620
+ else if (pt?.argsText)
621
+ argsRepr = pt.argsText;
622
+ else if (rawInput)
623
+ argsRepr = JSON.stringify(rawInput);
624
+ else
625
+ argsRepr = extractArgsFromContent(u) ?? "(no args)";
626
+ onOutput(`[tool:exec]${stripLeadingDollarPrompt(argsRepr)}[/tool:exec]\n`);
627
+ }
628
+ if (output) {
629
+ onOutput(`[tool-result:exec]${output.slice(0, 500)}${output.length > 500 ? "..." : ""}[/tool-result:exec]\n`);
630
+ }
631
+ break;
632
+ }
633
+ case "turn_end": {
634
+ // ACP turn-end notification — the assistant has finished its
635
+ // turn. Close any open thinking block and emit a terminal
636
+ // status so the dashboard's status pill (which hoists the
637
+ // last `[status:thinking]` tag) settles to a non-Working label
638
+ // instead of leaving "Working" stuck above the finished reply.
639
+ closeThinkingIfOpen();
640
+ // flush 最后一批 message 内容,确保用户看到完整回答后 pill 才翻 "Answered"。
641
+ flushNow();
642
+ turnEndSeen = true;
643
+ emitStatus(onOutput, "Answered");
644
+ break;
645
+ }
646
+ // usage_update 通知 hermes 实际会发,但 payload 只有 {size, used,
647
+ // sessionUpdate} —— 只有累计 token 和 context window size,没有
648
+ // input/output 拆分。usage 的来源走 stderr parser(see below):
649
+ // agent.conversation_loop: API call #N: in=X out=Y total=Z
650
+ // 那行有 input/output 拆分,信息更全。不要在这里再写 capturedUsage,
651
+ // 否则会覆盖 stderr parser 累积的好数据。
652
+ default: {
653
+ // 新版 hermes 可能引入了我们还没适配的 update 类型;如果它包含
654
+ // 文本内容(content.text),直接当成 message chunk 透传,避免
655
+ // 用户看到"思考完就卡住"。同时打日志方便后续根因。
656
+ const obj = update;
657
+ const rawKey = obj.sessionUpdate ?? obj.type ?? Object.keys(obj)[0] ?? "(none)";
658
+ const content = obj.content;
659
+ const text = content?.text;
660
+ if (typeof text === "string" && text) {
661
+ closeThinkingIfOpen();
662
+ fullOutput += text;
663
+ onOutput(text);
664
+ console.warn(`[hermes-cli] unhandled update "${rawKey}" with text — passthrough (${text.length} chars)`);
665
+ }
666
+ else {
667
+ console.warn(`[hermes-cli] unhandled update "${rawKey}" keys=${Object.keys(obj).join(",")}`);
668
+ }
669
+ break;
670
+ }
671
+ }
672
+ }
673
+ // ── Wire up stdout reader ──
674
+ // (The transport owns the readline loop above; it routes each frame
675
+ // into onRequest → handleAgentRequest or onNotification → handleNotification.)
676
+ // ── stderr logging + provider error sniffing ──
677
+ // ── stderr logging + provider error sniffing + usage extraction ──
678
+ // 用 readline 按行 buffer(stderr 的 data 事件按 chunk 来,一条日志
679
+ // 被劈成两半时正则匹配不上)。
680
+ const stderrRl = createInterface({ input: proc.stderr });
681
+ stderrRl.on("line", (line) => {
682
+ const text = line.trim();
683
+ if (!text)
684
+ return;
685
+ console.error(`[hermes-cli] stderr: ${text}`);
686
+ // Sniff for terminal provider/model errors. hermes logs the same
687
+ // failure at WARNING/ERROR level on stderr (see
688
+ // agent.conversation_loop), so we can flip the flag from the
689
+ // first line that matches — usually well before the
690
+ // agent_message_chunk carrying the user-facing summary arrives.
691
+ if (!providerError.matched && matchProviderError(text)) {
692
+ providerError = { matched: true, message: extractCleanErrorReason(text) };
693
+ emitStatus(onOutput, "Failed");
694
+ console.error(`[hermes-cli] provider error detected in stderr: ${text}`);
695
+ }
696
+ // 从 hermes adapter 自己的 stderr 日志抽 model + usage(ACP 协议
697
+ // 不发 usage_update 通知,这些只在 adapter 的日志里)。两行关键:
698
+ // agent.turn_context: ... model=deepseek-v4-flash provider=custom ...
699
+ // agent.conversation_loop: API call #1: model=... in=18059 out=321 total=18380 ...
700
+ // 每个 API call 累加进 capturedUsage,最终值就是整个 issue 执行的累计。
701
+ const turnMatch = text.match(/agent\.turn_context:.*\bmodel=(\S+)/);
702
+ if (turnMatch && !capturedModel)
703
+ capturedModel = turnMatch[1];
704
+ const apiMatch = text.match(/agent\.conversation_loop:\s*API call #\d+:\s*model=(\S+).*?\bin=(\d+)\s+out=(\d+)\s+total=(\d+)/);
705
+ if (apiMatch) {
706
+ if (!capturedModel)
707
+ capturedModel = apiMatch[1];
708
+ const inN = parseInt(apiMatch[2], 10);
709
+ const outN = parseInt(apiMatch[3], 10);
710
+ // hermes 的 in= 是 input tokens,out= 是 output tokens,total= 是
711
+ // input+output。累加,因为一个 turn 可能多次 API call。
712
+ capturedUsage = {
713
+ inputTokens: (capturedUsage?.inputTokens ?? 0) + inN,
714
+ outputTokens: (capturedUsage?.outputTokens ?? 0) + outN,
715
+ };
716
+ }
717
+ });
718
+ // ── ACP lifecycle ──
719
+ async function runLifecycle() {
720
+ try {
721
+ // 1. Initialize
722
+ await request("initialize", {
723
+ protocolVersion: 1,
724
+ clientInfo: { name: "open-a2a-gateway", version: "0.1.0" },
725
+ clientCapabilities: {},
726
+ });
727
+ // 2. Create or resume session
728
+ if (resumeSessionId) {
729
+ replayActive = true;
730
+ let resumeResult;
731
+ try {
732
+ resumeResult = (await request("session/resume", {
733
+ cwd: workingDir || ".",
734
+ sessionId: resumeSessionId,
735
+ }));
736
+ }
737
+ finally {
738
+ // 不管 resume 成不成功都关掉,避免后续 live update 被误吞。
739
+ replayActive = false;
740
+ }
741
+ // Server may return a different sessionId if the original was lost
742
+ sessionId = resumeResult?.sessionId || resumeSessionId;
743
+ console.log(`[hermes-cli] session resumed: ${sessionId}${sessionId !== resumeSessionId ? ` (original: ${resumeSessionId})` : ""}`);
744
+ }
745
+ else {
746
+ const sessionResult = (await request("session/new", {
747
+ cwd: workingDir || ".",
748
+ mcpServers: [],
749
+ }));
750
+ sessionId = sessionResult?.sessionId ?? "";
751
+ if (!sessionId) {
752
+ console.error("[hermes-cli] session/new returned no session ID");
753
+ }
754
+ console.log(`[hermes-cli] session created: ${sessionId}`);
755
+ }
756
+ // 3. Send prompt
757
+ // prompt 已经由 worker 用 composePrompt() 拼好,executor 不再二次包装。
758
+ await request("session/prompt", {
759
+ sessionId,
760
+ prompt: [{ type: "text", text: prompt }],
761
+ });
762
+ }
763
+ catch (err) {
764
+ console.error(`[hermes-cli] ACP lifecycle error: ${err.message}`);
765
+ }
766
+ finally {
767
+ proc.stdin.end();
768
+ }
769
+ }
770
+ proc.on("close", (code) => {
771
+ finish(code ?? 1);
772
+ });
773
+ proc.on("error", (err) => {
774
+ console.error(`[hermes-cli] Spawn error: ${err.message}`);
775
+ finish(1);
776
+ });
777
+ void runLifecycle();
778
+ });
779
+ }
780
+ /**
781
+ * Read the tail of a hermes session from its on-disk transcript at
782
+ * `~/.hermes/sessions/session_<sessionId>.json`
783
+ * The file is a single JSON document with `{ messages: [{role, content}, …] }`.
784
+ * We render the last N messages as `role: text` blocks so the dashboard
785
+ * `<pre>` view stays readable — the raw JSON would be too noisy.
786
+ *
787
+ * Tolerant of missing files (hermes may prune its sessions directory) —
788
+ * returns empty content + an explanatory `error` so the dashboard can
789
+ * distinguish "file gone" from "session started but no messages yet".
790
+ */
791
+ async readSessionContent(args) {
792
+ const file = path.join(os.homedir(), ".hermes", "sessions", `session_${args.sessionId}.json`);
793
+ if (!fs.existsSync(file)) {
794
+ return {
795
+ format: "text",
796
+ content: "",
797
+ error: "hermes session 文件不存在(可能已被 hermes daemon 清理)",
798
+ };
799
+ }
800
+ let parsed;
801
+ try {
802
+ parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
803
+ }
804
+ catch {
805
+ return {
806
+ format: "text",
807
+ content: "",
808
+ error: "hermes session 文件存在但 JSON 解析失败",
809
+ };
810
+ }
811
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
812
+ const tail = args.tailLines ?? 200;
813
+ const window = messages.length > tail ? messages.slice(-tail) : messages;
814
+ const rendered = window
815
+ .map((m) => `[${m.role ?? "?"}] ${stringifyContent(m.content)}`)
816
+ .join("\n\n");
817
+ return { format: "text", content: rendered };
818
+ }
819
+ }
820
+ // ── Hermes content rendering ────────────────────────────────────────────
821
+ /**
822
+ * Coerce a hermes message `content` (string | array-of-blocks | object) into
823
+ * a printable single string. We keep this conservative — anything we don't
824
+ * recognise falls back to JSON.stringify so nothing is silently dropped.
825
+ */
826
+ function stringifyContent(content) {
827
+ if (typeof content === "string")
828
+ return content;
829
+ if (Array.isArray(content)) {
830
+ return content
831
+ .map((b) => {
832
+ if (b && typeof b === "object") {
833
+ const rec = b;
834
+ if (typeof rec.text === "string")
835
+ return rec.text;
836
+ }
837
+ try {
838
+ return JSON.stringify(b);
839
+ }
840
+ catch {
841
+ return String(b);
842
+ }
843
+ })
844
+ .join("\n");
845
+ }
846
+ if (content && typeof content === "object") {
847
+ try {
848
+ return JSON.stringify(content);
849
+ }
850
+ catch {
851
+ return String(content);
852
+ }
853
+ }
854
+ return String(content ?? "");
855
+ }