verbalcoding 0.2.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 (85) hide show
  1. package/.env.example +83 -0
  2. package/LICENSE +21 -0
  3. package/README.md +157 -0
  4. package/app-node/agent_adapters.mjs +576 -0
  5. package/app-node/agent_adapters.test.mjs +455 -0
  6. package/app-node/agent_contract.mjs +45 -0
  7. package/app-node/barge_in.mjs +148 -0
  8. package/app-node/barge_in.test.mjs +179 -0
  9. package/app-node/bridge_logger.mjs +66 -0
  10. package/app-node/bridge_logger.test.mjs +73 -0
  11. package/app-node/bridge_state.mjs +104 -0
  12. package/app-node/bridge_state.test.mjs +64 -0
  13. package/app-node/cli_install.test.mjs +97 -0
  14. package/app-node/deferred_queue.mjs +12 -0
  15. package/app-node/deferred_queue.test.mjs +20 -0
  16. package/app-node/discord_invite_cli.test.mjs +31 -0
  17. package/app-node/discord_text.mjs +29 -0
  18. package/app-node/discord_text.test.mjs +32 -0
  19. package/app-node/hermes_profiles.mjs +164 -0
  20. package/app-node/hermes_profiles.test.mjs +276 -0
  21. package/app-node/install_config.mjs +263 -0
  22. package/app-node/install_config.test.mjs +205 -0
  23. package/app-node/instance_doctor.mjs +137 -0
  24. package/app-node/instance_doctor.test.mjs +128 -0
  25. package/app-node/instance_profile_lifecycle.mjs +16 -0
  26. package/app-node/instances.mjs +153 -0
  27. package/app-node/instances.test.mjs +102 -0
  28. package/app-node/language_config.mjs +73 -0
  29. package/app-node/language_config.test.mjs +51 -0
  30. package/app-node/latency_metrics.mjs +133 -0
  31. package/app-node/latency_metrics.test.mjs +71 -0
  32. package/app-node/main.mjs +1771 -0
  33. package/app-node/mcp_tools.mjs +198 -0
  34. package/app-node/mcp_tools.test.mjs +39 -0
  35. package/app-node/progress_cache.mjs +7 -0
  36. package/app-node/progress_cache.test.mjs +23 -0
  37. package/app-node/progress_speech.mjs +102 -0
  38. package/app-node/progress_speech.test.mjs +48 -0
  39. package/app-node/project_sessions.mjs +148 -0
  40. package/app-node/project_sessions.test.mjs +77 -0
  41. package/app-node/restart_notice.mjs +57 -0
  42. package/app-node/restart_notice.test.mjs +37 -0
  43. package/app-node/restart_policy.mjs +27 -0
  44. package/app-node/restart_policy.test.mjs +33 -0
  45. package/app-node/text_routing.mjs +8 -0
  46. package/app-node/text_routing.test.mjs +18 -0
  47. package/app-node/tts_backends.mjs +251 -0
  48. package/app-node/tts_backends.test.mjs +400 -0
  49. package/app-node/tts_chunks.mjs +57 -0
  50. package/app-node/tts_chunks.test.mjs +35 -0
  51. package/app-node/tts_prefetch.mjs +38 -0
  52. package/app-node/tts_prefetch.test.mjs +49 -0
  53. package/app-node/tts_settings.mjs +72 -0
  54. package/app-node/tts_settings.test.mjs +127 -0
  55. package/app-node/tts_voice_config.mjs +127 -0
  56. package/app-node/tts_voice_config.test.mjs +64 -0
  57. package/app-node/voice_clone_capture.mjs +76 -0
  58. package/app-node/voice_clone_capture.test.mjs +51 -0
  59. package/app-node/voice_messages.mjs +62 -0
  60. package/app-node/voice_messages.test.mjs +33 -0
  61. package/docs/CONFIGURATION.md +183 -0
  62. package/docs/FRESH_INSTALL.md +193 -0
  63. package/docs/MULTI_INSTANCE.md +183 -0
  64. package/docs/RELEASE.md +72 -0
  65. package/docs/USAGE.md +108 -0
  66. package/docs/assets/figures/verbalcoding-flow.svg +63 -0
  67. package/docs/i18n/README.es.md +121 -0
  68. package/docs/i18n/README.fr.md +121 -0
  69. package/docs/i18n/README.ja.md +121 -0
  70. package/docs/i18n/README.ko.md +121 -0
  71. package/docs/i18n/README.ru.md +121 -0
  72. package/docs/i18n/README.zh.md +121 -0
  73. package/package.json +58 -0
  74. package/run.sh +82 -0
  75. package/scripts/bootstrap_prereqs.sh +193 -0
  76. package/scripts/cli.mjs +369 -0
  77. package/scripts/docker_ubuntu_smoke.sh +76 -0
  78. package/scripts/doctor.mjs +134 -0
  79. package/scripts/install.mjs +108 -0
  80. package/scripts/install.sh +44 -0
  81. package/scripts/mcp-server.mjs +84 -0
  82. package/scripts/openvoice_smoke.py +34 -0
  83. package/scripts/openvoice_synth.py +103 -0
  84. package/scripts/setup_openvoice.sh +34 -0
  85. package/scripts/setup_supertonic.sh +18 -0
@@ -0,0 +1,576 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { agentAdapterCapabilities, createAgentRunResult } from './agent_contract.mjs';
6
+
7
+ export function shellSplit(s) {
8
+ const out = [];
9
+ let cur = '', quote = null, esc = false;
10
+ for (const ch of String(s || '')) {
11
+ if (esc) { cur += ch; esc = false; continue; }
12
+ if (ch === '\\') { esc = true; continue; }
13
+ if (quote) { if (ch === quote) quote = null; else cur += ch; continue; }
14
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
15
+ if (/\s/.test(ch)) { if (cur) { out.push(cur); cur = ''; } continue; }
16
+ cur += ch;
17
+ }
18
+ if (cur) out.push(cur);
19
+ return out;
20
+ }
21
+
22
+ export function voiceBridgePrompt(text, options = {}) {
23
+ const english = /^en/i.test(String(options.language || ''));
24
+ const lines = english ? [
25
+ 'This is a user utterance from a Discord voice call.',
26
+ 'Answer in English. For simple conversation/status questions, do not use tools; answer directly in 1-3 sentences.',
27
+ 'Use tools only for real work requests such as file edits, command execution, log checks, or web/search tasks.',
28
+ 'If code changes are made, do not read diffs or full code aloud; summarize outcome and next checks briefly.',
29
+ 'Do not include CLI metadata or session_id in the answer.',
30
+ ] : [
31
+ 'Discord 음성 대화로 들어온 사용자 발화다.',
32
+ '단순 대화/상태 질문이면 도구를 쓰지 말고 1~3문장으로 바로 한국어 답변해라.',
33
+ '파일 수정, 실행, 로그 확인, 검색 같은 실제 작업 지시일 때만 필요한 도구를 사용해라.',
34
+ '코드 변경을 수행했다면 음성 답변에는 diff나 코드 전문을 읽지 말고, 작업 결과와 다음 확인 사항만 짧게 말해라.',
35
+ 'CLI 메타정보나 session_id는 답변에 포함하지 마라.',
36
+ ];
37
+ if (options.verboseProgress) {
38
+ if (english) {
39
+ lines.push(
40
+ 'VERBOSE progress sharing mode is enabled.',
41
+ 'For important intermediate steps during long work, output one line in the format `VERBALCODING_PROGRESS: <short English step>`.',
42
+ 'Examples: `VERBALCODING_PROGRESS: reading files app-node/main.mjs`, `VERBALCODING_PROGRESS: searching web VerbalCoding setup`, `VERBALCODING_PROGRESS: running terminal commands npm test`, `VERBALCODING_PROGRESS: using tools read_file`, `VERBALCODING_PROGRESS: loading skills discord-voice-hermes-bridge`.',
43
+ 'Never include tokens, API keys, passwords, connection strings, or personal identifiers in progress logs.',
44
+ 'Keep progress logs short: reading files, searching web, running terminal commands, running tests, using tools, or loading skills.',
45
+ );
46
+ } else {
47
+ lines.push(
48
+ 'VERBOSE 진행 공유 모드가 켜져 있다.',
49
+ '긴 작업에서 중요한 중간 동작을 할 때마다 한 줄로 `VERBALCODING_PROGRESS: <짧은 한국어 단계>` 형식을 출력해라.',
50
+ '예: `VERBALCODING_PROGRESS: 파일 읽기 app-node/main.mjs`, `VERBALCODING_PROGRESS: 웹 검색 VerbalCoding setup`, `VERBALCODING_PROGRESS: 터미널 실행 npm test`, `VERBALCODING_PROGRESS: 툴 사용 read_file`, `VERBALCODING_PROGRESS: 스킬 사용 discord-voice-hermes-bridge`.',
51
+ '토큰, API 키, 비밀번호, 연결 문자열, 개인 식별자는 절대 진행 로그에 쓰지 마라.',
52
+ '진행 로그는 파일 읽기, 웹 검색, 터미널 실행, 테스트 실행, 툴 사용, 스킬 사용 같은 항목만 짧게 써라.',
53
+ );
54
+ }
55
+ }
56
+ if (options.projectContext) {
57
+ lines.push(english ? 'Route this turn through the following project/session context:' : '이 턴은 아래 프로젝트/세션 컨텍스트로 처리해라.');
58
+ lines.push(String(options.projectContext).trim());
59
+ }
60
+ return lines.concat(['', text]).join('\n');
61
+ }
62
+
63
+ export function sanitizeAgentOutput(text) {
64
+ const raw = stripAnsi(String(text || ''));
65
+ const boxed = extractHermesBoxedResponse(raw);
66
+ return String(boxed || raw)
67
+ .split(/\r?\n/)
68
+ .filter(line => !/^session_id:\s*\S+\s*$/.test(line.trim()))
69
+ .filter(line => !/^Session:\s*\S+\s*$/.test(line.trim()))
70
+ .filter(line => !/^↻\s*Resumed session\s+\S+/.test(line.trim()))
71
+ .filter(line => !/^VERBALCODING_PROGRESS\s*:/i.test(line.trim()))
72
+ .filter(line => !/^Resume this session with:/i.test(line.trim()))
73
+ .join('\n')
74
+ .replace(/\n{3,}/g, '\n\n')
75
+ .trim();
76
+ }
77
+
78
+ function stripAnsi(text) {
79
+ return String(text || '').replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '');
80
+ }
81
+
82
+ function extractHermesBoxedResponse(text) {
83
+ const lines = String(text || '').split(/\r?\n/);
84
+ const start = lines.findIndex(line => /╭─\s*⚕\s*Hermes\s*─/.test(line));
85
+ if (start < 0) return '';
86
+ const body = [];
87
+ for (let i = start + 1; i < lines.length; i += 1) {
88
+ const line = lines[i];
89
+ if (/^╰/.test(line)) break;
90
+ body.push(line.replace(/^\s*│\s?/, '').replace(/\s*│\s*$/, '').trimEnd());
91
+ }
92
+ return body.join('\n').trim();
93
+ }
94
+
95
+ function compactProgressText(text) {
96
+ return String(text || '')
97
+ .replace(/[\r\n]+/g, ' ')
98
+ .replace(/[`"']/g, '')
99
+ .replace(/\b(?:token|api[_-]?key|password|secret|authorization)\b\s*[:=]?\s*\S+/gi, '$1 [REDACTED]')
100
+ .replace(/\s+/g, ' ')
101
+ .trim()
102
+ .slice(0, 140);
103
+ }
104
+
105
+ export function extractVerboseProgressEvents(text, { language = 'ko' } = {}) {
106
+ const events = [];
107
+ const seen = new Set();
108
+ const english = /^en/i.test(String(language || ''));
109
+ const labels = english ? {
110
+ web: 'searching web', skill: 'loading skills', read: 'reading files', edit: 'editing files', terminal: 'running terminal commands', tool: 'using tools',
111
+ } : {
112
+ web: '웹 검색 실행', skill: '스킬 사용', read: '파일 읽기', edit: '파일 수정', terminal: '터미널 명령 실행', tool: '툴 사용',
113
+ };
114
+ function add(event) {
115
+ const cleaned = compactProgressText(event);
116
+ if (!cleaned || seen.has(cleaned)) return;
117
+ seen.add(cleaned);
118
+ events.push(cleaned);
119
+ }
120
+ for (const raw of String(text || '').split(/\r?\n/)) {
121
+ const line = raw.trim();
122
+ if (!line) continue;
123
+ const explicit = /^VERBALCODING_PROGRESS\s*:\s*(.+)$/i.exec(line);
124
+ if (explicit) { add(explicit[1]); continue; }
125
+ const hermesPreview = /┊\s*(?:[^\s]+\s+)?(?:\$\s*)?([a-zA-Z_][\w-]*)\b/.exec(line);
126
+ if (hermesPreview) { add(toolProgressLabel(hermesPreview[1], { language })); continue; }
127
+ const lower = line.toLowerCase();
128
+ if (/web_search|browser_search|web search|search web|functions\.web_search/.test(lower)) add(labels.web);
129
+ else if (/skill_view|skills_list|skill_manage|functions\.skill_|스킬 사용|스킬 확인/.test(lower)) add(labels.skill);
130
+ else if (/read_file|functions\.read_file|reading file|file read|파일 읽/.test(lower)) add(labels.read);
131
+ else if (/write_file|patch|functions\.patch|editing file|파일 수정|파일 쓰/.test(lower)) add(labels.edit);
132
+ else if (/terminal|execute_code|shell|command=|npm test|pytest|터미널|명령 실행/.test(lower)) add(labels.terminal);
133
+ else if (/tool_use|calling tool|functions\./.test(lower)) add(labels.tool);
134
+ }
135
+ return events;
136
+ }
137
+
138
+ function toolProgressLabel(name, { language = 'ko' } = {}) {
139
+ const tool = String(name || '').trim();
140
+ const english = /^en/i.test(String(language || ''));
141
+ if (/^(web_search|web_extract|browser_)/.test(tool)) return english ? 'searching web' : '웹 검색 실행';
142
+ if (/^(read_file|search_files)$/.test(tool)) return english ? `using file tool ${tool}` : `파일 도구 사용 ${tool}`;
143
+ if (/^(write_file|patch)$/.test(tool)) return english ? `editing files with ${tool}` : `파일 수정 도구 사용 ${tool}`;
144
+ if (/^(terminal|execute_code|process)$/.test(tool)) return english ? `running terminal tool ${tool}` : `터미널 도구 사용 ${tool}`;
145
+ if (/^(skill_view|skills_list|skill_manage)$/.test(tool)) return english ? `loading skills with ${tool}` : `스킬 도구 사용 ${tool}`;
146
+ return english ? `using tool ${tool}` : `툴 사용 ${tool}`;
147
+ }
148
+
149
+ export function isPatchLikeOutput(text) {
150
+ const s = String(text || '');
151
+ if (!s.trim()) return false;
152
+ const patchMarkers = [
153
+ /^\s*┊\s*review diff\b/m,
154
+ /^diff --git\b/m,
155
+ /^@@\s+-\d+/m,
156
+ /^a\/[^\n]+\s+→\s+b\//m,
157
+ /^[-+]{3}\s+[ab]\//m,
158
+ ];
159
+ const markerHit = patchMarkers.some(re => re.test(s));
160
+ const changedLines = s.split(/\r?\n/).filter(line => /^[+-](?![+-])/.test(line)).length;
161
+ return markerHit || changedLines >= 8;
162
+ }
163
+
164
+ export function interruptedAgentMessage(label, hadPatchLikeOutput = false, language = 'ko') {
165
+ const english = /^en/i.test(String(language || ''));
166
+ if (hadPatchLikeOutput) {
167
+ if (english) return `${label} was interrupted or timed out, and the partial output looked like a code diff. I will not read the diff aloud; check the text channel for files and test status.`;
168
+ return `${label} 작업이 제한 시간에 걸렸고 코드 diff 출력이 감지됐어. diff는 음성으로 읽지 않을게. 변경 파일과 테스트 상태를 확인해서 이어서 정리할게.`;
169
+ }
170
+ if (english) return `${label} was interrupted or timed out before I could verify the final result.`;
171
+ return `${label} 작업이 제한 시간이나 끼어들기로 중단됐어. 출력이 비어 있어서 결과를 확인하지 못했어.`;
172
+ }
173
+
174
+ function agentProgressEvent(label, kind, language = 'ko') {
175
+ const english = /^en/i.test(String(language || ''));
176
+ if (kind === 'start') return english ? `calling the agent ${label}` : `${label} 호출 시작`;
177
+ if (kind === 'done') return english ? `received agent response ${label}` : `${label} 응답 수신`;
178
+ return english ? `agent ${label}` : `${label}`;
179
+ }
180
+
181
+ function emptyAgentMessage(language = 'ko') {
182
+ return /^en/i.test(String(language || '')) ? 'The response was empty.' : '응답이 비어 있어.';
183
+ }
184
+
185
+ function failedAgentMessage(label, detail, language = 'ko') {
186
+ return /^en/i.test(String(language || ''))
187
+ ? `${label} failed: ${detail}`
188
+ : `${label} 실행에 실패했어: ${detail}`;
189
+ }
190
+
191
+ export function extractHermesSessionId(text) {
192
+ return /^session_id:\s*(\S+)/m.exec(text || '')?.[1]
193
+ || /^Session:\s*(\S+)/m.exec(text || '')?.[1]
194
+ || null;
195
+ }
196
+
197
+ export function resolveExecTimeout(value) {
198
+ const n = Number(value);
199
+ return Number.isFinite(n) && n > 0 ? n : undefined;
200
+ }
201
+
202
+ export function buildHermesSpawnOptions({ parentEnv = process.env, instanceEnv = {} } = {}) {
203
+ const env = { ...parentEnv };
204
+ if (instanceEnv.HERMES_HOME) env.HERMES_HOME = instanceEnv.HERMES_HOME;
205
+ return { env };
206
+ }
207
+
208
+ export function buildAgentSettings({ ROOT, env = process.env } = {}) {
209
+ const root = ROOT || process.cwd();
210
+ const backend = String(env.AGENT_BACKEND || env.AGENT_PROVIDER || 'hermes').trim().toLowerCase();
211
+ const defaults = {
212
+ hermes: {
213
+ label: 'Hermes Agent',
214
+ command: env.HERMES_COMMAND || 'hermes chat -Q -q',
215
+ sessionFile: env.HERMES_SESSION_FILE || path.join(root, '.verbalcoding-session'),
216
+ supportsHermesSession: true,
217
+ },
218
+ claude: {
219
+ label: 'Claude Code',
220
+ command: env.CLAUDE_COMMAND || 'claude -p',
221
+ sessionFile: env.AGENT_SESSION_FILE || path.join(root, '.agent-sessions', 'claude'),
222
+ supportsHermesSession: false,
223
+ },
224
+ 'claude-code': {
225
+ label: 'Claude Code',
226
+ command: env.CLAUDE_COMMAND || 'claude -p',
227
+ sessionFile: env.AGENT_SESSION_FILE || path.join(root, '.agent-sessions', 'claude'),
228
+ supportsHermesSession: false,
229
+ },
230
+ codex: {
231
+ label: 'Codex',
232
+ command: env.CODEX_COMMAND || 'codex exec',
233
+ sessionFile: env.AGENT_SESSION_FILE || path.join(root, '.agent-sessions', 'codex'),
234
+ supportsHermesSession: false,
235
+ },
236
+ gemini: {
237
+ label: 'Gemini',
238
+ command: env.GEMINI_COMMAND || 'gemini -p',
239
+ sessionFile: env.AGENT_SESSION_FILE || path.join(root, '.agent-sessions', 'gemini'),
240
+ supportsHermesSession: false,
241
+ },
242
+ opencode: {
243
+ label: 'OpenCode',
244
+ command: env.OPENCODE_COMMAND || 'opencode run',
245
+ sessionFile: env.AGENT_SESSION_FILE || path.join(root, '.agent-sessions', 'opencode'),
246
+ supportsHermesSession: false,
247
+ },
248
+ openclaw: {
249
+ label: 'OpenClaw',
250
+ command: env.OPENCLAW_COMMAND || 'openclaw run',
251
+ sessionFile: env.AGENT_SESSION_FILE || path.join(root, '.agent-sessions', 'openclaw'),
252
+ supportsHermesSession: false,
253
+ },
254
+ custom: {
255
+ label: env.AGENT_LABEL || 'Custom Agent',
256
+ command: env.AGENT_COMMAND || '',
257
+ sessionFile: env.AGENT_SESSION_FILE || path.join(root, '.agent-sessions', 'custom'),
258
+ supportsHermesSession: false,
259
+ },
260
+ };
261
+ const selected = defaults[backend] || defaults.custom;
262
+ const label = env.AGENT_LABEL || selected.label;
263
+ const command = env.AGENT_COMMAND || selected.command;
264
+ if (!command) throw new Error('AGENT_COMMAND is required when AGENT_BACKEND=custom');
265
+ return {
266
+ backend,
267
+ label,
268
+ command,
269
+ sessionFile: selected.sessionFile,
270
+ supportsHermesSession: selected.supportsHermesSession,
271
+ cwd: env.AGENT_WORKDIR || env.AGENT_CWD || env.HERMES_WORKDIR || root,
272
+ projectContext: env.AGENT_PROJECT_CONTEXT || env.HERMES_PROJECT_CONTEXT || '',
273
+ taskTimeoutMs: Number(env.AGENT_TASK_TIMEOUT_MS || env.HERMES_TASK_TIMEOUT_MS || '0'),
274
+ chatTimeoutMs: Number(env.AGENT_CHAT_TIMEOUT_MS || env.HERMES_CHAT_TIMEOUT_MS || '45000'),
275
+ verboseProgress: ['1', 'true', 'yes', 'on'].includes(String(env.AGENT_VERBOSE_PROGRESS || env.VERBALCODING_VERBOSE_PROGRESS || '0').toLowerCase()),
276
+ };
277
+ }
278
+
279
+ export function createAgentAdapter(settings, deps = {}) {
280
+ settings = {
281
+ ...settings,
282
+ supportsHermesSession: settings.supportsHermesSession ?? settings.backend === 'hermes',
283
+ };
284
+ const execFileAsync = deps.execFileAsync;
285
+ if (!execFileAsync) throw new Error('execFileAsync dependency is required');
286
+ const fileApi = {
287
+ readFileSync: deps.readFileSync || fs.readFileSync,
288
+ writeFileSync: deps.writeFileSync || fs.writeFileSync,
289
+ mkdirSync: deps.mkdirSync || fs.mkdirSync,
290
+ };
291
+ const log = deps.log || (() => {});
292
+ const warn = deps.warn || (() => {});
293
+ const env = deps.env || process.env;
294
+ const hermesSessionsDir = deps.hermesSessionsDir || path.join(os.homedir(), '.hermes', 'sessions');
295
+ const spawnProcess = deps.spawn;
296
+ const onProgress = deps.onProgress || (() => {});
297
+ const emittedProgress = new Set();
298
+ let activeProgressLanguage = settings.language;
299
+ const capabilities = agentAdapterCapabilities(settings);
300
+
301
+ function emitVerboseProgress(text) {
302
+ if (!text) return;
303
+ for (const event of extractVerboseProgressEvents(text, { language: activeProgressLanguage })) {
304
+ if (emittedProgress.has(event)) continue;
305
+ emittedProgress.add(event);
306
+ try { onProgress(event); } catch (e) { warn('verbose progress callback failed', e?.stack || e); }
307
+ }
308
+ }
309
+
310
+ function execWithOptionalProgress(cmd, args, options, verbose) {
311
+ if (!verbose || !spawnProcess) return execFileAsync(cmd, args, options);
312
+ return new Promise((resolve, reject) => {
313
+ const child = spawnProcess(cmd, args, {
314
+ env: options.env,
315
+ cwd: options.cwd,
316
+ stdio: ['ignore', 'pipe', 'pipe'],
317
+ });
318
+ let stdout = '';
319
+ let stderr = '';
320
+ let settled = false;
321
+ let timeoutId = null;
322
+ function finishError(error) {
323
+ if (settled) return;
324
+ settled = true;
325
+ if (timeoutId) clearTimeout(timeoutId);
326
+ error.stdout = stdout;
327
+ error.stderr = stderr;
328
+ reject(error);
329
+ }
330
+ if (options.signal) {
331
+ if (options.signal.aborted) {
332
+ const err = new Error('The operation was aborted');
333
+ err.name = 'AbortError';
334
+ finishError(err);
335
+ return;
336
+ }
337
+ options.signal.addEventListener('abort', () => {
338
+ try { child.kill('SIGTERM'); } catch {}
339
+ const err = new Error('The operation was aborted');
340
+ err.name = 'AbortError';
341
+ err.code = 'ABORT_ERR';
342
+ finishError(err);
343
+ }, { once: true });
344
+ }
345
+ if (options.timeout) {
346
+ timeoutId = setTimeout(() => {
347
+ try { child.kill('SIGTERM'); } catch {}
348
+ const err = new Error(`Command timed out after ${options.timeout}ms`);
349
+ err.signal = 'SIGTERM';
350
+ finishError(err);
351
+ }, options.timeout);
352
+ }
353
+ child.stdout?.on('data', chunk => {
354
+ const s = chunk.toString();
355
+ stdout += s;
356
+ emitVerboseProgress(s);
357
+ if (stdout.length + stderr.length > options.maxBuffer) {
358
+ const err = new Error('maxBuffer exceeded');
359
+ err.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER';
360
+ try { child.kill('SIGTERM'); } catch {}
361
+ finishError(err);
362
+ }
363
+ });
364
+ child.stderr?.on('data', chunk => {
365
+ const s = chunk.toString();
366
+ stderr += s;
367
+ emitVerboseProgress(s);
368
+ if (stdout.length + stderr.length > options.maxBuffer) {
369
+ const err = new Error('maxBuffer exceeded');
370
+ err.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER';
371
+ try { child.kill('SIGTERM'); } catch {}
372
+ finishError(err);
373
+ }
374
+ });
375
+ child.on('error', finishError);
376
+ child.on('close', (code, signal) => {
377
+ if (settled) return;
378
+ settled = true;
379
+ if (timeoutId) clearTimeout(timeoutId);
380
+ if (code === 0) resolve({ stdout, stderr });
381
+ else {
382
+ const err = new Error(`Command failed: ${cmd}`);
383
+ err.code = code;
384
+ err.signal = signal;
385
+ err.stdout = stdout;
386
+ err.stderr = stderr;
387
+ reject(err);
388
+ }
389
+ });
390
+ });
391
+ }
392
+
393
+ function makeCodexOutputPath() {
394
+ const base = deps.tmpdir || os.tmpdir();
395
+ return path.join(base, `verbalcoding-codex-last-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
396
+ }
397
+
398
+ function addCodexOutputCapture(args) {
399
+ if (settings.backend !== 'codex') return { args, outputPath: null };
400
+ if (args.includes('-o') || args.includes('--output-last-message')) return { args, outputPath: null };
401
+ const outputPath = makeCodexOutputPath();
402
+ return { args: args.slice(0, -1).concat(['--output-last-message', outputPath, args.at(-1)]), outputPath };
403
+ }
404
+
405
+ function readAndCleanupCodexOutput(outputPath) {
406
+ if (!outputPath) return '';
407
+ try {
408
+ return fileApi.readFileSync(outputPath, 'utf8');
409
+ } catch {
410
+ return '';
411
+ } finally {
412
+ try { fs.rmSync(outputPath, { force: true }); } catch {}
413
+ }
414
+ }
415
+
416
+ function readSessionId() {
417
+ if (!settings.supportsHermesSession || !settings.sessionFile) return null;
418
+ try {
419
+ const id = fileApi.readFileSync(settings.sessionFile, 'utf8').trim();
420
+ return id || null;
421
+ } catch {
422
+ return null;
423
+ }
424
+ }
425
+
426
+ function writeSessionId(id) {
427
+ if (!settings.supportsHermesSession || !settings.sessionFile || !id) return;
428
+ try {
429
+ fileApi.mkdirSync(path.dirname(settings.sessionFile), { recursive: true });
430
+ fileApi.writeFileSync(settings.sessionFile, `${id}\n`, { mode: 0o600 });
431
+ } catch (e) {
432
+ warn('write agent session id failed', e?.stack || e);
433
+ }
434
+ }
435
+
436
+ function contentToText(content) {
437
+ if (typeof content === 'string') return content;
438
+ if (Array.isArray(content)) {
439
+ return content.map(item => {
440
+ if (typeof item === 'string') return item;
441
+ if (item && typeof item === 'object') return item.text || item.content || '';
442
+ return '';
443
+ }).filter(Boolean).join('\n');
444
+ }
445
+ if (content && typeof content === 'object') return content.text || content.content || '';
446
+ return '';
447
+ }
448
+
449
+ function readHermesSessionFinalAnswer(sessionId) {
450
+ if (!settings.supportsHermesSession || !sessionId) return '';
451
+ try {
452
+ const sessionPath = path.join(hermesSessionsDir, `session_${sessionId}.json`);
453
+ const data = JSON.parse(fileApi.readFileSync(sessionPath, 'utf8'));
454
+ const messages = Array.isArray(data.messages) ? data.messages : [];
455
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
456
+ const message = messages[i];
457
+ if (message?.role !== 'assistant') continue;
458
+ const text = sanitizeAgentOutput(contentToText(message.content));
459
+ if (text) return text;
460
+ }
461
+ } catch (e) {
462
+ warn('read Hermes session final answer failed', sessionId, e?.message || e);
463
+ }
464
+ return '';
465
+ }
466
+
467
+ function buildArgs(text, options = {}) {
468
+ const argv = shellSplit(settings.command);
469
+ const cmd = argv[0];
470
+ const query = voiceBridgePrompt(text, { verboseProgress: options.verboseProgress, language: options.language, projectContext: options.projectContext });
471
+ let args = argv.slice(1);
472
+ if (settings.backend === 'hermes' && options.verboseProgress) {
473
+ // Hermes quiet mode intentionally suppresses tool previews. In verbose
474
+ // voice mode we drop only the quiet flag so stdout can stream safe `┊ ...`
475
+ // progress lines; final-answer cleanup below extracts the Hermes box.
476
+ args = args.filter(arg => arg !== '-Q' && arg !== '--quiet');
477
+ }
478
+ args = args.concat([query]);
479
+ const sessionId = readSessionId();
480
+ if (sessionId) {
481
+ const qIndex = args.lastIndexOf('-q');
482
+ const insertAt = qIndex >= 0 ? qIndex : args.length - 1;
483
+ args = args.slice(0, insertAt).concat(['--resume', sessionId], args.slice(insertAt));
484
+ }
485
+ return { cmd, args, sessionId };
486
+ }
487
+
488
+ async function run(request, signal, plan = { task: true, label: settings.label }) {
489
+ const text = typeof request === 'string' ? request : request?.text;
490
+ const verboseProgress = Boolean(plan.verboseProgress ?? settings.verboseProgress);
491
+ const language = plan.language || settings.language;
492
+ activeProgressLanguage = language;
493
+ const projectContext = plan.projectContext || settings.projectContext || '';
494
+ emittedProgress.clear();
495
+ const { cmd, args, sessionId } = buildArgs(text, { verboseProgress, language, projectContext });
496
+ const start = Date.now();
497
+ const label = plan.label || settings.label;
498
+ const { args: finalArgs, outputPath } = addCodexOutputCapture(args);
499
+ log('Agent CLI start', label, cmd, finalArgs.slice(0, -1).join(' '), sessionId ? `resume=${sessionId}` : 'new-session', 'verbose', verboseProgress);
500
+ if (verboseProgress) onProgress(agentProgressEvent(label, 'start', language));
501
+ try {
502
+ const { env: hermesEnv } = buildHermesSpawnOptions({ instanceEnv: env });
503
+ const { stdout, stderr } = await execWithOptionalProgress(cmd, finalArgs, {
504
+ timeout: resolveExecTimeout(plan.task ? settings.taskTimeoutMs : settings.chatTimeoutMs),
505
+ maxBuffer: 4 * 1024 * 1024,
506
+ env: { ...hermesEnv, PYTHONUNBUFFERED: '1' },
507
+ cwd: plan.cwd || settings.cwd || process.cwd(),
508
+ signal,
509
+ }, verboseProgress);
510
+ const codexLastMessage = readAndCleanupCodexOutput(outputPath);
511
+ const combined = `${stdout || ''}\n${stderr || ''}`;
512
+ const newSessionId = extractHermesSessionId(combined);
513
+ if (newSessionId) {
514
+ writeSessionId(newSessionId);
515
+ log('Agent session saved', settings.backend, newSessionId);
516
+ }
517
+ log('Agent CLI done', label, 'ms', Date.now() - start);
518
+ if (verboseProgress) onProgress(agentProgressEvent(label, 'done', language));
519
+ const stdoutAnswer = sanitizeAgentOutput(codexLastMessage) || sanitizeAgentOutput(stdout) || sanitizeAgentOutput(stderr);
520
+ if (stdoutAnswer) return createAgentRunResult({ status: 'ok', answer: stdoutAnswer, backend: settings.backend, label, elapsedMs: Date.now() - start, sessionId: newSessionId || sessionId });
521
+ const sessionAnswer = readHermesSessionFinalAnswer(newSessionId || sessionId);
522
+ if (sessionAnswer) {
523
+ log('Agent answer recovered from Hermes session file', label, 'chars', sessionAnswer.length);
524
+ return createAgentRunResult({ status: 'ok', answer: sessionAnswer, backend: settings.backend, label, elapsedMs: Date.now() - start, sessionId: newSessionId || sessionId, recoveredFromSession: true });
525
+ }
526
+ warn('Agent CLI produced empty sanitized answer', 'stdoutLen', stdout.length, 'stderrLen', stderr.length, 'stdoutTail', stdout.slice(-500), 'stderrTail', stderr.slice(-500));
527
+ return createAgentRunResult({ status: 'empty', answer: emptyAgentMessage(language), backend: settings.backend, label, elapsedMs: Date.now() - start, sessionId: newSessionId || sessionId });
528
+ } catch (e) {
529
+ if (e?.name === 'AbortError' || e?.code === 'ABORT_ERR') throw e;
530
+ const stderr = (e.stderr || '').toString().trim();
531
+ const stdout = (e.stdout || '').toString().trim();
532
+ const combined = `${stdout || ''}\n${stderr || ''}`;
533
+ const codexLastMessage = readAndCleanupCodexOutput(outputPath);
534
+ const newSessionId = extractHermesSessionId(combined);
535
+ if (newSessionId) {
536
+ writeSessionId(newSessionId);
537
+ log('Agent session saved after failure', settings.backend, newSessionId);
538
+ }
539
+ const cleanedPartial = sanitizeAgentOutput(stdout) || sanitizeAgentOutput(stderr);
540
+ const patchLikePartial = isPatchLikeOutput(cleanedPartial);
541
+ const message = String(e.message || '');
542
+ warn('Agent CLI failed', 'backend', settings.backend, 'label', label, 'ms', Date.now() - start, 'code', e.code, 'signal', e.signal, 'stdout', stdout.slice(-500), 'stderr', stderr.slice(-500), 'message', message.slice(-500));
543
+ if ((e.signal === 'SIGINT' || e.signal === 'SIGTERM') && cleanedPartial && !patchLikePartial) {
544
+ log('Agent CLI returned partial output after signal; using sanitized partial answer', 'chars', cleanedPartial.length);
545
+ return createAgentRunResult({ status: 'partial', answer: cleanedPartial, backend: settings.backend, label, elapsedMs: Date.now() - start, sessionId: newSessionId || sessionId, error: { signal: e.signal } });
546
+ }
547
+ if (e.signal === 'SIGINT' || e.signal === 'SIGTERM') {
548
+ return createAgentRunResult({ status: 'interrupted', answer: interruptedAgentMessage(label, patchLikePartial, language), backend: settings.backend, label, elapsedMs: Date.now() - start, sessionId: newSessionId || sessionId, error: { signal: e.signal } });
549
+ }
550
+ return createAgentRunResult({
551
+ status: 'error',
552
+ answer: failedAgentMessage(label, sanitizeAgentOutput(stderr || stdout || message || e.code || 'unknown error').slice(0, 700), language),
553
+ backend: settings.backend,
554
+ label,
555
+ elapsedMs: Date.now() - start,
556
+ sessionId: newSessionId || sessionId,
557
+ error: { code: e.code, signal: e.signal },
558
+ });
559
+ }
560
+ }
561
+
562
+ async function ask(text, signal, plan = { task: true, label: settings.label }) {
563
+ const result = await run(text, signal, plan);
564
+ return result.answer;
565
+ }
566
+
567
+ return {
568
+ backend: settings.backend,
569
+ label: settings.label,
570
+ capabilities,
571
+ run,
572
+ ask,
573
+ buildArgs,
574
+ readSessionId,
575
+ };
576
+ }