@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.
- package/README.md +417 -0
- package/bin/mesh-master.sh +439 -0
- package/bin/rotom +29 -0
- package/bin/rotom-link.sh +136 -0
- package/bin/rotom-send-with-status +57 -0
- package/bin/rotom-up.sh +428 -0
- package/dist/cli/ask.js +62 -0
- package/dist/cli/common.js +321 -0
- package/dist/cli/config.js +65 -0
- package/dist/cli/directory.js +17 -0
- package/dist/cli/executor.js +58 -0
- package/dist/cli/fed.js +91 -0
- package/dist/cli/group.js +273 -0
- package/dist/cli/identity.js +62 -0
- package/dist/cli/init.js +268 -0
- package/dist/cli/issue.js +202 -0
- package/dist/cli/join.js +170 -0
- package/dist/cli/link.js +47 -0
- package/dist/cli/master.js +51 -0
- package/dist/cli/memory.js +307 -0
- package/dist/cli/note.js +68 -0
- package/dist/cli/repo.js +77 -0
- package/dist/cli/rotom.js +277 -0
- package/dist/cli/routes.js +118 -0
- package/dist/cli/run.js +45 -0
- package/dist/cli/schedule.js +237 -0
- package/dist/cli/skill.js +173 -0
- package/dist/cli/team.js +106 -0
- package/dist/executor/claude-code-hook.cjs +80 -0
- package/dist/executor/cli-executor.js +8 -0
- package/dist/executor/executors/claude-code.js +780 -0
- package/dist/executor/executors/codex.js +719 -0
- package/dist/executor/executors/hermes-cli.js +855 -0
- package/dist/executor/executors/openclaw.js +467 -0
- package/dist/executor/executors/pi.js +514 -0
- package/dist/executor/index.js +269 -0
- package/dist/executor/jsonrpc-transport.js +125 -0
- package/dist/executor/process-runner.js +101 -0
- package/dist/executor/reasoning-status.js +83 -0
- package/dist/executor/repo-cache.js +502 -0
- package/dist/executor/session-store.js +188 -0
- package/dist/executor/worker-chat.js +257 -0
- package/dist/executor/worker-connection.js +89 -0
- package/dist/executor/worker-issue.js +264 -0
- package/dist/executor/worker.js +877 -0
- package/dist/link/pending-requests.js +72 -0
- package/dist/link/server.js +233 -0
- package/dist/link/visibility-store.js +58 -0
- package/dist/master/api/agents.js +333 -0
- package/dist/master/api/artifacts.js +271 -0
- package/dist/master/api/domains.js +64 -0
- package/dist/master/api/groups.js +635 -0
- package/dist/master/api/guidance-templates.js +147 -0
- package/dist/master/api/index.js +89 -0
- package/dist/master/api/issues-patrol.js +172 -0
- package/dist/master/api/issues.js +663 -0
- package/dist/master/api/links-patrol.js +168 -0
- package/dist/master/api/links.js +114 -0
- package/dist/master/api/memory.js +259 -0
- package/dist/master/api/messages.js +157 -0
- package/dist/master/api/notes.js +77 -0
- package/dist/master/api/schedule-patterns.js +133 -0
- package/dist/master/api/schedules.js +272 -0
- package/dist/master/api/sessions.js +158 -0
- package/dist/master/api/share.js +269 -0
- package/dist/master/api/skills.js +190 -0
- package/dist/master/api/teams.js +122 -0
- package/dist/master/api/uploads.js +245 -0
- package/dist/master/auth.js +134 -0
- package/dist/master/dashboard/animations/calico-dozing.apng +0 -0
- package/dist/master/dashboard/animations/calico-error.apng +0 -0
- package/dist/master/dashboard/animations/calico-happy.apng +0 -0
- package/dist/master/dashboard/animations/calico-notification.apng +0 -0
- package/dist/master/dashboard/animations/calico-sleeping.apng +0 -0
- package/dist/master/dashboard/animations/calico-thinking.apng +0 -0
- package/dist/master/dashboard/animations/calico-waking.apng +0 -0
- package/dist/master/dashboard/assets/ApprovalCard-C38VV6ko.css +1 -0
- package/dist/master/dashboard/assets/ApprovalCard-CHPh2dmE.js +17 -0
- package/dist/master/dashboard/assets/ArtifactPanel-P_2gAP7v.js +1 -0
- package/dist/master/dashboard/assets/ArtifactPanel-aGHySny5.css +1 -0
- package/dist/master/dashboard/assets/css.worker-DaIe3gwK.js +84 -0
- package/dist/master/dashboard/assets/editor.worker-BCzxt1at.js +12 -0
- package/dist/master/dashboard/assets/html.worker-CKrFyw_2.js +461 -0
- package/dist/master/dashboard/assets/index-CChrTn81.css +32 -0
- package/dist/master/dashboard/assets/index-Dhu4SN1z.js +181 -0
- package/dist/master/dashboard/assets/json.worker-B7c_PmGb.js +49 -0
- package/dist/master/dashboard/assets/markdown-CeN5IgdF.js +29 -0
- package/dist/master/dashboard/assets/monaco-core-DyX1CsEw.css +1 -0
- package/dist/master/dashboard/assets/monaco-core-oQiQUisy.js +833 -0
- package/dist/master/dashboard/assets/monaco-setup-CiOPQdmo.js +1 -0
- package/dist/master/dashboard/assets/react-vendor-C8IxlyCR.js +67 -0
- package/dist/master/dashboard/assets/ts.worker-BhkL8olL.js +51334 -0
- package/dist/master/dashboard/assets/useMonaco-ILb4vyPh.js +12 -0
- package/dist/master/dashboard/assets/vite-preload-CxJPbCTl.js +1 -0
- package/dist/master/dashboard/debug-auth.html +197 -0
- package/dist/master/dashboard/favicon.ico +0 -0
- package/dist/master/dashboard/index.html +20 -0
- package/dist/master/dashboard/rotom-avatar.png +0 -0
- package/dist/master/db/agent-sessions.js +60 -0
- package/dist/master/db/agent-visibility.js +64 -0
- package/dist/master/db/agents.js +119 -0
- package/dist/master/db/ask-bridges.js +157 -0
- package/dist/master/db/build-update.js +59 -0
- package/dist/master/db/core.js +82 -0
- package/dist/master/db/domains.js +80 -0
- package/dist/master/db/groups.js +316 -0
- package/dist/master/db/guidance-templates.js +58 -0
- package/dist/master/db/index.js +12 -0
- package/dist/master/db/internal.js +45 -0
- package/dist/master/db/issues-patrol.js +81 -0
- package/dist/master/db/issues.js +373 -0
- package/dist/master/db/links.js +221 -0
- package/dist/master/db/master-node.js +43 -0
- package/dist/master/db/memory.js +272 -0
- package/dist/master/db/messages.js +210 -0
- package/dist/master/db/notes.js +55 -0
- package/dist/master/db/schedule-patterns.js +56 -0
- package/dist/master/db/schedules.js +135 -0
- package/dist/master/db/skills.js +144 -0
- package/dist/master/db/team.js +88 -0
- package/dist/master/db/types.js +10 -0
- package/dist/master/db.js +12 -0
- package/dist/master/embedded.js +133 -0
- package/dist/master/federation/client.js +283 -0
- package/dist/master/federation/identity.js +133 -0
- package/dist/master/federation/manager.js +267 -0
- package/dist/master/federation/publisher.js +87 -0
- package/dist/master/federation/self-publisher.js +69 -0
- package/dist/master/federation/server.js +487 -0
- package/dist/master/group-paths.js +208 -0
- package/dist/master/offline-queue.js +38 -0
- package/dist/master/opc-bootstrap.js +245 -0
- package/dist/master/patrol-terminal.js +275 -0
- package/dist/master/repo-scan.js +188 -0
- package/dist/master/router.js +214 -0
- package/dist/master/scheduler-handlers.js +510 -0
- package/dist/master/scheduler.js +201 -0
- package/dist/master/server.js +203 -0
- package/dist/master/services/link-collector.js +82 -0
- package/dist/master/services/link-patrol-bootstrap.js +50 -0
- package/dist/master/services/memory-extract-prompt.js +34 -0
- package/dist/master/services/patrol-bootstrap.js +63 -0
- package/dist/master/share-tokens.js +56 -0
- package/dist/master/terminal-hub.js +300 -0
- package/dist/master/uploads.js +108 -0
- package/dist/master/util/fs.js +100 -0
- package/dist/master/util/paths.js +50 -0
- package/dist/master/util/persona.js +10 -0
- package/dist/master/ws-hub/connection.js +928 -0
- package/dist/master/ws-hub/conversation.js +290 -0
- package/dist/master/ws-hub/directory.js +70 -0
- package/dist/master/ws-hub/dispatch-enrich.js +34 -0
- package/dist/master/ws-hub/hub.js +136 -0
- package/dist/master/ws-hub/index.js +9 -0
- package/dist/master/ws-hub/internal.js +35 -0
- package/dist/master/ws-hub/routing.js +295 -0
- package/dist/master/ws-hub/sessions.js +130 -0
- package/dist/master/ws-hub.js +11 -0
- package/dist/shared/agent-profile.js +44 -0
- package/dist/shared/constants.js +55 -0
- package/dist/shared/dedup.js +33 -0
- package/dist/shared/group-context.js +62 -0
- package/dist/shared/json-codec.js +33 -0
- package/dist/shared/logger.js +136 -0
- package/dist/shared/mention.js +22 -0
- package/dist/shared/network.js +40 -0
- package/dist/shared/parse.js +18 -0
- package/dist/shared/prompt-composer.js +171 -0
- package/dist/shared/protocol/client-messages.js +8 -0
- package/dist/shared/protocol/enums.js +6 -0
- package/dist/shared/protocol/federation.js +62 -0
- package/dist/shared/protocol/guards.js +87 -0
- package/dist/shared/protocol/server-messages.js +8 -0
- package/dist/shared/protocol/types.js +8 -0
- package/dist/shared/protocol.js +19 -0
- package/dist/shared/readonly-allowlist.js +122 -0
- package/dist/shared/rotom-cli-prompt.js +23 -0
- package/dist/shared/skill-context.js +19 -0
- package/dist/shared/skill-md.js +43 -0
- package/dist/shared/slash-commands.js +50 -0
- package/dist/shared/time.js +80 -0
- package/dist/shared/title.js +46 -0
- package/dist/shared/url-extractor.js +99 -0
- package/migrations/001-schema.sql +942 -0
- package/package.json +68 -0
- package/scripts/fix-node-pty-perms.mjs +46 -0
- package/skill/rotom-a2a-communicate/SKILL.md +257 -0
- package/skill/rotom-bus-host/SKILL.md +78 -0
- 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
|
+
}
|