oomi-ai 0.8.5 → 0.9.1

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 CHANGED
@@ -109,7 +109,7 @@ oomi connect --runtime hermes --name hermes --app-url https://www.oomi.ai
109
109
  oomi connect --runtime command --name my-agent --command '["/absolute/path/to/adapter"]' --app-url https://www.oomi.ai
110
110
  ```
111
111
 
112
- Use the user's app URL when provided. Hermes supports `--profile <profile>` and `--resume <exact-session-id>`. Without `--resume`, Oomi starts a persistent conversation with the same configured agent. Do not use `latest` or claim that an existing chat session was transferred unless its exact ID was selected. With oomi-ai 0.8.2+, an already-open Hermes conversation uses its existing local gateway owner and exact profile/session. Oomi attaches as another client; it never closes desktop, clears history, deletes a session lock, or substitutes a new chat. When no live owner exists, Hermes uses `chat --quiet --query-file -` and resumes the exact returned `session_id` on subsequent turns.
112
+ Use the user's app URL when provided. Hermes supports `--profile <profile>` and `--resume <exact-session-id>`. Without `--resume`, Oomi starts a persistent conversation with the same configured agent. Do not use `latest` or claim that an existing chat session was transferred unless its exact ID was selected. With oomi-ai 0.8.2+, an already-open Hermes conversation uses its existing local gateway owner and exact profile/session. Oomi attaches as another client; it never closes desktop, clears history, deletes a session lock, or substitutes a new chat. When no live owner exists, an Oomi-owned conversation (no `--resume`) runs on a warm `hermes acp` process with oomi-ai 0.9.0+: replies stream as they are written and stop when the person interrupts. From 0.9.1 the bridge starts Hermes and reopens recent conversations ahead of time (at bridge start and when a call opens), so the first message doesn't wait for Hermes to start. Hermes permission requests are denied, as `chat --quiet` denies dangerous commands; ordinary file edits run as they do in quiet mode. A conversation moving from the CLI starts a new Hermes session that receives the recent Oomi history. `--resume` conversations, Hermes without ACP, and `--hermes-transport cli` use `chat --quiet --query-file -` and resume the exact returned `session_id` on subsequent turns.
113
113
 
114
114
  Shared desktop conversations require Hermes's loopback WebSocket gateway, active-session discovery, and durable prompt/reply row IDs. Busy conversations ask you to retry after the current task. If a simultaneous desktop send queues your message without a reply receipt, or the connection drops after sending, check the same Hermes chat before resending. Oomi cannot safely repeat an uncertain action. Human approvals stay in the existing Hermes desktop UI. Shared photo submission is not yet supported; use Hermes desktop for that photo. Older or terminal-only owners without a shared gateway receive an actionable error, never a competing process. The shared owner keeps its existing environment, so use the explicit Oomi `--connection NAME` for account commands.
115
115
 
package/bin/oomi-ai.js CHANGED
@@ -210,6 +210,7 @@ Commands:
210
210
  connect --runtime openclaw|hermes|grokbot|command [--name CONNECTION] [--agent-name "Your name"] [--app-url URL]
211
211
  Verify the selected local agent, pair once, start its bridge, and return an Auth Link.
212
212
  Hermes: --profile PROFILE and --resume SESSION select the intended agent/session.
213
+ Hermes: --hermes-transport auto|acp|cli (default auto streams replies over ACP when Hermes supports it).
213
214
  Other agents: --command '["/absolute/path/to/adapter"]' (JSON stdin / JSON stdout).
214
215
  --cwd PATH selects the agent working directory; --name isolates multiple agents.
215
216
  Installs automatic startup on macOS/Linux. --no-service opts into external supervision.
@@ -2538,6 +2539,7 @@ async function startOpenclawBridge(flags) {
2538
2539
  const localRuntime = bridgeState.localAgent
2539
2540
  ? new LocalAgentRuntime({ ...bridgeState.localAgent, connection: bridgeConnectionName }, path.dirname(resolveBridgeStatePath()), (health) => updateBridgeStatus(health))
2540
2541
  : null;
2542
+ localRuntime?.warm();
2541
2543
  const gateway = localRuntime ? { gatewayUrl: 'local-agent:', configPath: resolveBridgeStatePath() } : readOpenclawGatewayConfig();
2542
2544
  if (!localRuntime && !gateway.token && !gateway.password) {
2543
2545
  throw new Error(`Gateway auth token/password not found in ${gateway.configPath}.`);
@@ -3599,6 +3601,8 @@ async function startOpenclawBridge(flags) {
3599
3601
  const sessionId = String(payload.sessionId || '').trim();
3600
3602
  if (!sessionId) return;
3601
3603
  bridgeDebugLog(`[bridge] client.open ${sessionId}`);
3604
+ // A call is starting: make sure the agent is awake before the caller's first words.
3605
+ if (classifyBridgeSessionScope(sessionId) === 'voice') localRuntime?.warm();
3602
3606
  getOrCreateGatewaySession(sessionId);
3603
3607
  return;
3604
3608
  }
@@ -3826,7 +3830,7 @@ async function startOpenclawBridge(flags) {
3826
3830
  stopChannelWorker();
3827
3831
  stopIdentityWorker();
3828
3832
  stopRoutineWorker();
3829
- if (localRuntime) await localRuntime.queue;
3833
+ if (localRuntime) { await localRuntime.queue; localRuntime.close(); }
3830
3834
  if (reconnectState.timer) {
3831
3835
  clearTimeout(reconnectState.timer);
3832
3836
  reconnectState.timer = null;
@@ -4472,9 +4476,12 @@ async function connectLocalAgent(flags) {
4472
4476
  profile: flags.profile ?? saved?.profile,
4473
4477
  'hermes-home': saved?.hermesHome,
4474
4478
  resume: flags.resume ?? saved?.resume,
4479
+ 'hermes-transport': flags['hermes-transport'] ?? saved?.hermesTransport,
4475
4480
  cwd: flags.cwd ?? saved?.cwd,
4476
4481
  });
4477
- if (saved && JSON.stringify(saved) !== JSON.stringify(state.localAgent)) throw new Error('This name already identifies a different agent configuration. Use a new --name to change its command, profile, session, or working directory.');
4482
+ // How Hermes is reached (ACP or the quiet CLI) may change; the agent itself may not.
4483
+ const identity = ({ hermesTransport, ...rest } = {}) => JSON.stringify(rest);
4484
+ if (saved && identity(saved) !== identity(state.localAgent)) throw new Error('This name already identifies a different agent configuration. Use a new --name to change its command, profile, session, or working directory.');
4478
4485
  }
4479
4486
  if (!previous.verifiedAt || isTruthyFlag(flags.verify)) {
4480
4487
  console.log(`Verifying ${runtime}${state.localAgent?.profile ? ` profile ${state.localAgent.profile}` : ''} with a local test message...`);
@@ -0,0 +1,372 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+
5
+ // Hermes over the Agent Client Protocol (`hermes acp`): one warm process per Hermes
6
+ // profile, kept between turns, whose replies stream as they are written and can be
7
+ // cancelled mid-turn. JSON-RPC 2.0, one message per line, over stdio.
8
+ //
9
+ // Approvals stay at least as strict as `hermes chat --quiet`, which denies dangerous
10
+ // commands without asking: Oomi denies every permission request Hermes sends. Sessions
11
+ // run in Hermes's "Don't ask" edit mode, so ordinary file edits work as they do in quiet
12
+ // mode and only sensitive paths (.git, .ssh, .env) reach that denial.
13
+
14
+ const PROTOCOL_VERSION = 1;
15
+ const START_TIMEOUT_MS = 20000;
16
+ const CONTROL_TIMEOUT_MS = 30000;
17
+ const UNAVAILABLE_BACKOFF_MS = 10 * 60 * 1000;
18
+ const TEXT_INTERVAL_MS = 80;
19
+ const MAX_REPLY_CHARS = 4 * 1024 * 1024;
20
+ const EDIT_MODE = 'dont_ask';
21
+ const IMAGE_TYPES = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
22
+ const SEED_MESSAGES = 12;
23
+ const SEED_MESSAGE_CHARS = 800;
24
+ const SEED_TOTAL_CHARS = 6000;
25
+
26
+ // Only fixed messages cross the bridge; Hermes diagnostics may hold credentials or tool output.
27
+ export class HermesAcpError extends Error {
28
+ constructor(code, message, { notStarted = false, runtimeReady = true } = {}) {
29
+ super(message); this.code = code; this.notStarted = notStarted; this.runtimeReady = runtimeReady;
30
+ }
31
+ }
32
+ const unavailable = () => new HermesAcpError('hermes_acp_unavailable', 'Hermes could not start its streaming connection.', { notStarted: true, runtimeReady: false });
33
+ const exited = () => new HermesAcpError('hermes_acp_exited', 'Hermes stopped while answering. Check Hermes on that computer before sending this message again.', { runtimeReady: false });
34
+
35
+ const clients = new Map();
36
+ const unavailableUntil = new Map();
37
+
38
+ function clientKey(config) {
39
+ return JSON.stringify([config.command, config.hermesHome || '', config.profile || '', config.cwd || '', config.connection || '']);
40
+ }
41
+
42
+ /** False while a recent start attempt failed, so a broken install doesn't delay every turn. */
43
+ export function hermesAcpAvailable(config) {
44
+ return (unavailableUntil.get(clientKey(config)) || 0) <= Date.now();
45
+ }
46
+
47
+ /** Drop the warm process, for example after another Hermes client changed the conversation. */
48
+ export function resetHermesAcp(config) {
49
+ const key = clientKey(config);
50
+ clients.get(key)?.close();
51
+ clients.delete(key);
52
+ }
53
+
54
+ export function closeHermesAcpClients() {
55
+ for (const client of clients.values()) client.close();
56
+ clients.clear();
57
+ unavailableUntil.clear();
58
+ }
59
+
60
+ function hermesAcpClient(config) {
61
+ const key = clientKey(config);
62
+ let client = clients.get(key);
63
+ if (!client || client.closed) {
64
+ client = new HermesAcpClient(config, () => unavailableUntil.set(key, Date.now() + UNAVAILABLE_BACKOFF_MS));
65
+ clients.set(key, client);
66
+ }
67
+ return client;
68
+ }
69
+
70
+ class HermesAcpClient {
71
+ constructor(config, onUnavailable) {
72
+ this.config = config;
73
+ this.onUnavailable = onUnavailable;
74
+ this.nextId = 0;
75
+ this.pending = new Map();
76
+ this.turns = new Map();
77
+ this.loaded = new Set();
78
+ this.replayed = new Map();
79
+ this.closed = false;
80
+ this.ready = null;
81
+ }
82
+
83
+ start() {
84
+ if (this.ready) return this.ready;
85
+ this.ready = this.spawnAndInitialize().catch((err) => {
86
+ this.onUnavailable();
87
+ this.close();
88
+ throw err instanceof HermesAcpError ? err : unavailable();
89
+ });
90
+ return this.ready;
91
+ }
92
+
93
+ async spawnAndInitialize() {
94
+ const { config } = this;
95
+ const args = config.command.slice(1);
96
+ if (config.profile) args.push('--profile', config.profile);
97
+ args.push('acp');
98
+ // The agent's own `oomi` commands (notify, routines, spaces) use this connection by default.
99
+ const env = { ...process.env, ...(config.hermesHome ? { HERMES_HOME: config.hermesHome } : {}), ...(config.connection ? { OOMI_CONNECTION: config.connection } : {}) };
100
+ const child = spawn(config.command[0], args, { env, cwd: config.cwd, shell: false, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
101
+ this.child = child;
102
+ let buffered = '';
103
+ child.stdout.setEncoding('utf8');
104
+ child.stdout.on('data', (data) => {
105
+ buffered += data;
106
+ let newline;
107
+ while ((newline = buffered.indexOf('\n')) >= 0) {
108
+ const line = buffered.slice(0, newline).trim();
109
+ buffered = buffered.slice(newline + 1);
110
+ if (!line) continue;
111
+ let message;
112
+ try { message = JSON.parse(line); } catch { continue; } // Stray output is not protocol.
113
+ this.handle(message);
114
+ }
115
+ });
116
+ // Diagnostics may contain credentials or tool output; never forward them to Oomi.
117
+ child.stderr.resume();
118
+ child.stdin.on('error', () => {});
119
+ const spawnFailed = new Promise((_, reject) => child.once('error', () => reject(unavailable())));
120
+ spawnFailed.catch(() => {});
121
+ child.once('exit', () => this.exited());
122
+
123
+ const initialized = await Promise.race([
124
+ this.request('initialize', {
125
+ protocolVersion: PROTOCOL_VERSION,
126
+ clientCapabilities: {},
127
+ clientInfo: { name: 'oomi-ai', title: 'Oomi', version: packageVersion() },
128
+ }, START_TIMEOUT_MS),
129
+ spawnFailed,
130
+ ]).catch(() => { throw unavailable(); });
131
+ if (initialized?.protocolVersion !== PROTOCOL_VERSION) throw unavailable();
132
+ }
133
+
134
+ request(method, params, timeoutMs = CONTROL_TIMEOUT_MS) {
135
+ if (this.closed || !this.child) return Promise.reject(exited());
136
+ const id = ++this.nextId;
137
+ return new Promise((resolve, reject) => {
138
+ const timer = timeoutMs ? setTimeout(() => { this.pending.delete(id); reject(new HermesAcpError('hermes_acp_timeout', 'Hermes did not respond in time.')); }, timeoutMs) : null;
139
+ this.pending.set(id, { resolve, reject, timer });
140
+ this.write({ jsonrpc: '2.0', id, method, params });
141
+ });
142
+ }
143
+
144
+ notify(method, params) {
145
+ if (!this.closed && this.child) this.write({ jsonrpc: '2.0', method, params });
146
+ }
147
+
148
+ write(message) {
149
+ try { this.child.stdin.write(JSON.stringify(message) + '\n'); } catch { /* The exit handler reports it. */ }
150
+ }
151
+
152
+ handle(message) {
153
+ if (message.method && message.id !== undefined) return this.answer(message);
154
+ if (message.method === 'session/update') return this.update(message.params);
155
+ if (message.id === undefined || !this.pending.has(message.id)) return;
156
+ const pending = this.pending.get(message.id);
157
+ this.pending.delete(message.id);
158
+ clearTimeout(pending.timer);
159
+ if (message.error) pending.reject(Object.assign(new HermesAcpError('hermes_acp_rpc', 'Hermes rejected the request.'), { rpcCode: message.error.code }));
160
+ else pending.resolve(message.result ?? null);
161
+ }
162
+
163
+ // Requests from Hermes. Oomi offers no file or terminal access of its own, and denies
164
+ // every permission request (see the note at the top).
165
+ answer(message) {
166
+ if (message.method === 'session/request_permission') {
167
+ const options = Array.isArray(message.params?.options) ? message.params.options : [];
168
+ const deny = options.find((option) => option?.kind === 'reject_once') || options.find((option) => option?.kind === 'reject_always');
169
+ const outcome = deny ? { outcome: 'selected', optionId: deny.optionId } : { outcome: 'cancelled' };
170
+ this.write({ jsonrpc: '2.0', id: message.id, result: { outcome } });
171
+ return;
172
+ }
173
+ this.write({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Not supported by Oomi.' } });
174
+ }
175
+
176
+ update(params) {
177
+ const sessionId = params?.sessionId;
178
+ if (!sessionId) return;
179
+ if (this.replayed.has(sessionId)) this.replayed.set(sessionId, this.replayed.get(sessionId) + 1);
180
+ const turn = this.turns.get(sessionId);
181
+ const update = params.update;
182
+ if (!turn || update?.sessionUpdate !== 'agent_message_chunk' || update.content?.type !== 'text') return;
183
+ turn.onChunk(String(update.content.text || ''), update.messageId);
184
+ }
185
+
186
+ exited() {
187
+ const error = exited();
188
+ this.closed = true;
189
+ for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); }
190
+ this.pending.clear();
191
+ for (const turn of this.turns.values()) turn.fail(error);
192
+ this.turns.clear();
193
+ }
194
+
195
+ /**
196
+ * The ACP session for a conversation in this process: the one it already has, a stored one
197
+ * reloaded from Hermes's history, or a new one. `created` says whether history was lost.
198
+ */
199
+ async openSession(sessionId, { create = true } = {}) {
200
+ const cwd = this.config.cwd;
201
+ if (sessionId && this.loaded.has(sessionId)) return { sessionId, created: false };
202
+ if (sessionId) {
203
+ // Hermes answers a load for an unknown session with an empty success, so a session
204
+ // counts as found only when its history is replayed.
205
+ this.replayed.set(sessionId, 0);
206
+ try {
207
+ await this.request('session/load', { sessionId, cwd, mcpServers: [] });
208
+ if (this.replayed.get(sessionId) > 0) {
209
+ await this.prepare(sessionId);
210
+ return { sessionId, created: false };
211
+ }
212
+ } catch (err) {
213
+ if (err.code === 'hermes_acp_exited') throw err;
214
+ } finally { this.replayed.delete(sessionId); }
215
+ }
216
+ if (!create) return null;
217
+ const created = await this.request('session/new', { cwd, mcpServers: [] });
218
+ if (typeof created?.sessionId !== 'string' || !created.sessionId) throw unavailable();
219
+ await this.prepare(created.sessionId);
220
+ return { sessionId: created.sessionId, created: true };
221
+ }
222
+
223
+ async prepare(sessionId) {
224
+ // Without this mode Hermes asks before every file edit, which Oomi would deny.
225
+ await this.request('session/set_mode', { sessionId, modeId: EDIT_MODE }).catch(() => {});
226
+ this.loaded.add(sessionId);
227
+ }
228
+
229
+ prompt(sessionId, prompt, { signal, timeoutMs = 180000, onText } = {}) {
230
+ if (this.turns.has(sessionId)) return Promise.reject(new HermesAcpError('hermes_session_busy', 'Hermes is still answering in this conversation.', { notStarted: true }));
231
+ return new Promise((resolve, reject) => {
232
+ let text = '';
233
+ let sent = '';
234
+ let messageId;
235
+ let textTimer = null;
236
+ let settled = false;
237
+ const flush = () => {
238
+ textTimer = null;
239
+ if (text !== sent) { sent = text; onText?.(text); }
240
+ };
241
+ const finish = (callback, value) => {
242
+ if (settled) return;
243
+ settled = true;
244
+ clearTimeout(timer); clearTimeout(textTimer);
245
+ signal?.removeEventListener('abort', abort);
246
+ this.turns.delete(sessionId);
247
+ callback(value);
248
+ };
249
+ const cancel = (error) => {
250
+ this.notify('session/cancel', { sessionId });
251
+ finish(reject, error);
252
+ };
253
+ const abort = () => cancel(new HermesAcpError('cancelled', 'Agent turn cancelled.'));
254
+ const timer = setTimeout(() => cancel(new HermesAcpError('timeout', 'Agent turn timed out.')), timeoutMs);
255
+ this.turns.set(sessionId, {
256
+ onChunk: (chunk, id) => {
257
+ if (settled || !chunk) return;
258
+ // Words before and after a tool call are separate messages; keep them apart.
259
+ if (id && messageId && id !== messageId && text && !/\s$/.test(text)) text += '\n\n';
260
+ if (id) messageId = id;
261
+ if (text.length + chunk.length > MAX_REPLY_CHARS) return cancel(new HermesAcpError('too_long', 'Agent output exceeded 4 MiB.'));
262
+ text += chunk;
263
+ // Send the first words right away, then at most every TEXT_INTERVAL_MS.
264
+ if (!sent) flush();
265
+ else textTimer ??= setTimeout(flush, TEXT_INTERVAL_MS);
266
+ },
267
+ fail: (error) => finish(reject, error),
268
+ });
269
+ if (signal?.aborted) return abort();
270
+ signal?.addEventListener('abort', abort, { once: true });
271
+ this.request('session/prompt', { sessionId, prompt }, 0).then((result) => {
272
+ if (settled) return;
273
+ const reply = text.trim();
274
+ if (result?.stopReason === 'cancelled') return finish(reject, new HermesAcpError('cancelled', 'Agent turn cancelled.'));
275
+ if (result?.stopReason === 'refusal' && !reply) {
276
+ this.loaded.delete(sessionId);
277
+ return finish(reject, new HermesAcpError('hermes_session_unknown', 'Hermes no longer has this conversation.', { notStarted: true }));
278
+ }
279
+ if (!reply) return finish(reject, new HermesAcpError('empty_reply', 'Agent did not return a successful final reply.'));
280
+ clearTimeout(textTimer);
281
+ flush();
282
+ finish(resolve, reply);
283
+ }, (err) => finish(reject, err));
284
+ });
285
+ }
286
+
287
+ close() {
288
+ if (this.closed && !this.child) return;
289
+ this.closed = true;
290
+ const child = this.child;
291
+ this.child = null;
292
+ if (!child || child.exitCode !== null) return;
293
+ const terminate = (signal) => {
294
+ try {
295
+ if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal);
296
+ else child.kill(signal);
297
+ } catch { /* Already exited. */ }
298
+ };
299
+ try { child.stdin.end(); } catch { /* Closed. */ }
300
+ terminate('SIGTERM');
301
+ setTimeout(() => terminate('SIGKILL'), 1000).unref();
302
+ }
303
+ }
304
+
305
+ let cachedVersion;
306
+ function packageVersion() {
307
+ if (cachedVersion) return cachedVersion;
308
+ try { cachedVersion = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { cachedVersion = '0.0.0'; }
309
+ return cachedVersion;
310
+ }
311
+
312
+ function textOf(content) {
313
+ if (typeof content === 'string') return content;
314
+ if (!Array.isArray(content)) return '';
315
+ return content.map((part) => (part?.type === 'text' ? part.text : part?.type === 'image' ? '[photo]' : '')).filter(Boolean).join(' ');
316
+ }
317
+
318
+ // A conversation that moves to a new Hermes session (from the quiet CLI, or after Hermes lost
319
+ // it) keeps its thread: the first prompt carries the recent Oomi history.
320
+ export function continuityPreamble(history) {
321
+ const recent = (Array.isArray(history) ? history : []).slice(-SEED_MESSAGES);
322
+ const lines = [];
323
+ let total = 0;
324
+ for (const message of recent) {
325
+ const words = textOf(message?.content).trim();
326
+ if (!words || !['user', 'assistant'].includes(message?.role)) continue;
327
+ const line = `${message.role === 'user' ? 'User' : 'You'}: ${words.length > SEED_MESSAGE_CHARS ? `${words.slice(0, SEED_MESSAGE_CHARS)}…` : words}`;
328
+ total += line.length;
329
+ if (total > SEED_TOTAL_CHARS) break;
330
+ lines.push(line);
331
+ }
332
+ if (!lines.length) return '';
333
+ return `For continuity, here is the recent part of this Oomi conversation:\n${lines.join('\n')}\n---\n`;
334
+ }
335
+
336
+ function promptBlocks(request, seed) {
337
+ const blocks = [{ type: 'text', text: `${seed}${request.message}` }];
338
+ const image = request.imagePaths?.[0];
339
+ if (image) {
340
+ const mimeType = IMAGE_TYPES[path.extname(image).toLowerCase()];
341
+ if (mimeType) blocks.push({ type: 'image', mimeType, data: fs.readFileSync(image).toString('base64') });
342
+ }
343
+ return blocks;
344
+ }
345
+
346
+ /**
347
+ * Starts Hermes ahead of the first message and reopens recent conversations, so a call's first
348
+ * turn doesn't wait for Hermes to start (about 3.5 s on the maintainer's Mac).
349
+ */
350
+ export async function warmHermesAcp(config, sessionIds = []) {
351
+ const client = hermesAcpClient(config);
352
+ await client.start();
353
+ for (const sessionId of sessionIds) await client.openSession(sessionId, { create: false }).catch(() => null);
354
+ }
355
+
356
+ /** One Hermes turn over ACP. Returns the reply and the ACP session that holds the conversation. */
357
+ export async function runHermesAcpTurn(config, request, { signal, timeoutMs = 180000, onText } = {}) {
358
+ const client = hermesAcpClient(config);
359
+ await client.start();
360
+ const continuing = request.runtimeTransport === 'acp' ? request.runtimeSessionId : '';
361
+ let { sessionId, created } = await client.openSession(continuing);
362
+ const seed = () => continuityPreamble(request.history);
363
+ let text;
364
+ try {
365
+ text = await client.prompt(sessionId, promptBlocks(request, created ? seed() : ''), { signal, timeoutMs, onText });
366
+ } catch (err) {
367
+ if (err.code !== 'hermes_session_unknown' || created) throw err;
368
+ ({ sessionId } = await client.openSession(''));
369
+ text = await client.prompt(sessionId, promptBlocks(request, seed()), { signal, timeoutMs, onText });
370
+ }
371
+ return { text, sessionId, transport: 'acp' };
372
+ }
package/lib/localAgent.js CHANGED
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process';
5
5
  import { EventEmitter } from 'node:events';
6
6
  import { createHash, randomUUID } from 'node:crypto';
7
7
  import { runSharedHermesTurn, hermesRefusal } from './hermesGateway.js';
8
+ import { runHermesAcpTurn, hermesAcpAvailable, resetHermesAcp, warmHermesAcp } from './hermesAcp.js';
8
9
  import { grokbotConfig, runGrokbotTurn, completedGrokbotTurn } from './grokbotRuntime.js';
9
10
 
10
11
  const IMAGE_EXTENSIONS = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' };
@@ -81,6 +82,9 @@ export function localAgentConfig(flags) {
81
82
  if (!Array.isArray(command) || !command.length || command.some((arg) => typeof arg !== 'string') || !command[0]) throw new Error('--command must contain an executable and optional arguments.');
82
83
  }
83
84
  if (flags.resume === 'latest') throw new Error('Use an exact Hermes session ID, never latest.');
85
+ // Unset means auto: ACP when Hermes supports it, otherwise the quiet CLI.
86
+ const hermesTransport = flags['hermes-transport'] ? String(flags['hermes-transport']) : undefined;
87
+ if (hermesTransport && !['auto', 'acp', 'cli'].includes(hermesTransport)) throw new Error('--hermes-transport must be auto, acp, or cli.');
84
88
  let profile = flags.profile || '';
85
89
  let hermesHome;
86
90
  if (runtime === 'hermes') {
@@ -97,7 +101,15 @@ export function localAgentConfig(flags) {
97
101
  profile ||= 'default';
98
102
  if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(profile)) throw new Error('Specify a valid Hermes --profile.');
99
103
  }
100
- return { runtime, command, profile, hermesHome, resume: flags.resume || '', cwd: path.resolve(flags.cwd || process.cwd()) };
104
+ return { runtime, command, profile, hermesHome, resume: flags.resume || '', ...(runtime === 'hermes' && hermesTransport ? { hermesTransport } : {}), cwd: path.resolve(flags.cwd || process.cwd()) };
105
+ }
106
+
107
+ // Hermes streams over ACP for conversations Oomi owns. A conversation pinned to an exact
108
+ // existing Hermes session (--resume) stays on the quiet CLI, because ACP can only reopen
109
+ // sessions it created.
110
+ function usesHermesAcp(config) {
111
+ if (config.hermesTransport === 'cli' || config.resume) return false;
112
+ return config.hermesTransport === 'acp' || hermesAcpAvailable(config);
101
113
  }
102
114
 
103
115
  // No shell interpolation. Message bodies travel over stdin, never as executable code.
@@ -105,7 +117,18 @@ export async function runLocalAgent(config, request, options = {}) {
105
117
  if (config.runtime === 'grokbot') return runGrokbotTurn(config, request, options);
106
118
  if (config.runtime === 'hermes') {
107
119
  const shared = await runSharedHermesTurn(config, request, options);
108
- if (shared) return shared;
120
+ if (shared) {
121
+ // Hermes desktop answered in this conversation; the warm ACP copy is now behind.
122
+ if (request.runtimeTransport === 'acp') resetHermesAcp(config);
123
+ return { ...shared, ...(request.runtimeTransport ? { transport: request.runtimeTransport } : {}) };
124
+ }
125
+ if (usesHermesAcp(config)) {
126
+ try { return await runHermesAcpTurn(config, request, options); }
127
+ catch (err) {
128
+ // Only a Hermes that can't start ACP falls back, and only before anything was sent.
129
+ if (err.code !== 'hermes_acp_unavailable' || config.hermesTransport === 'acp') throw err;
130
+ }
131
+ }
109
132
  }
110
133
  try { return await runAgentProcess(config, request, options); }
111
134
  catch (err) {
@@ -120,6 +143,7 @@ export async function runLocalAgent(config, request, options = {}) {
120
143
  }
121
144
 
122
145
  function runAgentProcess(config, request, { signal, timeoutMs = 180000 } = {}) {
146
+ if (config.runtime === 'hermes') request = { ...request, transport: 'cli' };
123
147
  return new Promise((resolve, reject) => {
124
148
  const args = config.command.slice(1);
125
149
  if (config.runtime === 'hermes') {
@@ -176,7 +200,7 @@ function runAgentProcess(config, request, { signal, timeoutMs = 180000 } = {}) {
176
200
  ? { text: output, sessionId: diagnostics.match(/(?:^|\n)session_id:\s*([A-Za-z0-9_-]+)\s*(?:\n|$)/)?.[1] }
177
201
  : JSON.parse(output);
178
202
  if (!result || (config.runtime === 'hermes' && !result.sessionId) || typeof result.text !== 'string' || !result.text.trim()) throw new Error();
179
- resolve({ text: result.text.trim(), sessionId: result.session_id || result.sessionId || request.runtimeSessionId || '' });
203
+ resolve({ text: result.text.trim(), sessionId: result.session_id || result.sessionId || request.runtimeSessionId || '', ...(request.transport ? { transport: request.transport } : {}) });
180
204
  } catch { reject(new Error('Agent did not return a successful final reply. Hermes must emit its quiet-mode session_id; command adapters must return {"text":"..."}.')); }
181
205
  });
182
206
  child.stdin.end(config.runtime === 'hermes' ? request.message : JSON.stringify(request) + '\n');
@@ -194,7 +218,7 @@ export class LocalAgentRuntime {
194
218
  }
195
219
  file(sessionKey) { return path.join(this.directory, 'conversations', createHash('sha256').update(sessionKey).digest('hex') + '.json'); }
196
220
  history(sessionKey) { return readJson(this.file(sessionKey), { messages: [], receipts: {} }); }
197
- async turn({ sessionKey, message, idempotencyKey, attachments = [], retryInterrupted = false }) {
221
+ async turn({ sessionKey, message, idempotencyKey, attachments = [], retryInterrupted = false, onText }) {
198
222
  const images = readImageAttachments(attachments);
199
223
  const text = typeof message === 'string' ? message : '';
200
224
  if (typeof sessionKey !== 'string' || !sessionKey || typeof idempotencyKey !== 'string' || !idempotencyKey || (!text.trim() && !images.length)) throw new Error('A session, message, and idempotency key are required.');
@@ -222,8 +246,9 @@ export class LocalAgentRuntime {
222
246
  let scratch;
223
247
  try {
224
248
  scratch = images.length ? writeTurnImages(images) : null;
225
- const result = resumedReply || await runLocalAgent(this.config, { sessionKey, message: prompt, idempotencyKey, history: state.messages, runtimeSessionId: state.runtimeSessionId || this.config.resume, imagePaths: scratch?.paths || [] }, { signal: controller.signal });
249
+ const result = resumedReply || await runLocalAgent(this.config, { sessionKey, message: prompt, idempotencyKey, history: state.messages, runtimeSessionId: state.runtimeSessionId || this.config.resume, runtimeTransport: state.runtimeTransport, imagePaths: scratch?.paths || [] }, { signal: controller.signal, onText });
226
250
  state.runtimeSessionId = result.sessionId;
251
+ if (result.transport) state.runtimeTransport = result.transport;
227
252
  state.messages.push({ role: 'user', content: userContent(text, images), timestamp: Date.now() }, { role: 'assistant', content: [{ type: 'text', text: result.text }], timestamp: Date.now() });
228
253
  state.receipts[receiptKey] = { message, result };
229
254
  writePrivateJson(this.file(sessionKey), state);
@@ -249,6 +274,25 @@ export class LocalAgentRuntime {
249
274
  return aborted;
250
275
  }
251
276
  socket() { return new LocalAgentSocket(this); }
277
+ close() { if (this.config.runtime === 'hermes') resetHermesAcp(this.config); }
278
+ /** Gets Hermes ready before a message arrives: at bridge start and when a call opens. */
279
+ warm() {
280
+ if (this.config.runtime !== 'hermes' || !usesHermesAcp(this.config)) return Promise.resolve();
281
+ return warmHermesAcp(this.config, this.recentAcpSessions(2)).catch(() => {});
282
+ }
283
+ recentAcpSessions(limit) {
284
+ const directory = path.join(this.directory, 'conversations');
285
+ let files;
286
+ try { files = fs.readdirSync(directory).filter((name) => name.endsWith('.json')); } catch { return []; }
287
+ return files
288
+ .map((name) => { const file = path.join(directory, name); try { return { file, modified: fs.statSync(file).mtimeMs }; } catch { return null; } })
289
+ .filter(Boolean)
290
+ .sort((a, b) => b.modified - a.modified)
291
+ .map(({ file }) => readJson(file, {}))
292
+ .filter((state) => state.runtimeTransport === 'acp' && state.runtimeSessionId)
293
+ .slice(0, limit)
294
+ .map((state) => state.runtimeSessionId);
295
+ }
252
296
  }
253
297
 
254
298
  class LocalAgentSocket extends EventEmitter {
@@ -282,7 +326,9 @@ class LocalAgentSocket extends EventEmitter {
282
326
  if (req.method !== 'chat.send') return fail(`Method ${req.method} is not supported by this agent adapter.`);
283
327
  const runId = params.idempotencyKey || req.id || randomUUID();
284
328
  reply({ runId, status: 'started' });
285
- this.runtime.turn({ ...params, idempotencyKey: runId }).then((result) => {
329
+ // Streaming runtimes send the reply so far as it grows, the shape OpenClaw's gateway uses.
330
+ const onText = (text) => this.frame({ type: 'event', event: 'chat', payload: { runId, sessionKey: params.sessionKey, state: 'delta', message: { role: 'assistant', content: [{ type: 'text', text }], timestamp: Date.now() } } });
331
+ this.runtime.turn({ ...params, idempotencyKey: runId, onText }).then((result) => {
286
332
  this.frame({ type: 'event', event: 'chat', payload: { runId, sessionKey: params.sessionKey, state: 'final', message: { role: 'assistant', content: [{ type: 'text', text: result.text }], timestamp: Date.now() } } });
287
333
  }, (err) => {
288
334
  this.frame({ type: 'event', event: 'chat', payload: { runId, sessionKey: params.sessionKey, state: 'error', errorMessage: err.message, ...(typeof err.runtimeReady === 'boolean' ? { runtimeReady: err.runtimeReady } : {}) } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.8.5",
3
+ "version": "0.9.1",
4
4
  "description": "Connect OpenClaw, Hermes, Grok Bot, and other agents to Oomi with verified runtime adapters",
5
5
  "bin": {
6
6
  "oomi": "bin/oomi-ai.js"
@@ -49,7 +49,7 @@
49
49
  "url": "https://github.com/crispcode-io/oomi/issues"
50
50
  },
51
51
  "scripts": {
52
- "check": "node --check bin/oomi-ai.js && node --check lib/bridgeService.js && node --check lib/bridgeMaintenance.js && node --check lib/localAgent.js && node --check lib/hermesGateway.js && node --check lib/grokbotRuntime.js && node --check lib/connectionSetup.js && node --check lib/processSupervisor.js",
52
+ "check": "node --check bin/oomi-ai.js && node --check lib/bridgeService.js && node --check lib/bridgeMaintenance.js && node --check lib/localAgent.js && node --check lib/hermesGateway.js && node --check lib/hermesAcp.js && node --check lib/grokbotRuntime.js && node --check lib/connectionSetup.js && node --check lib/processSupervisor.js",
53
53
  "test": "node --test test/*.test.mjs"
54
54
  },
55
55
  "dependencies": {
@@ -80,6 +80,7 @@
80
80
  "lib/bridgeService.js",
81
81
  "lib/bridgeMaintenance.js",
82
82
  "lib/hermesGateway.js",
83
+ "lib/hermesAcp.js",
83
84
  "lib/grokbotRuntime.js",
84
85
  "lib/connectionSetup.js",
85
86
  "lib/processSupervisor.js",
@@ -188,7 +188,7 @@ oomi connect --runtime hermes --name hermes --app-url https://www.oomi.ai
188
188
  oomi connect --runtime command --name my-agent --command '["/absolute/path/to/adapter"]' --app-url https://www.oomi.ai
189
189
  ```
190
190
 
191
- Use the user's app URL when provided. Hermes supports `--profile <profile>` and `--resume <exact-session-id>`. Without `--resume`, Oomi starts a persistent conversation with the same configured agent. Do not use `latest` or claim that an existing chat session was transferred unless its exact ID was selected. With oomi-ai 0.8.2+, an already-open Hermes conversation uses its existing local gateway owner and exact profile/session. Oomi attaches as another client; it never closes desktop, clears history, deletes a session lock, or substitutes a new chat. When no live owner exists, Hermes uses `chat --quiet --query-file -` and resumes the exact returned `session_id` on subsequent turns.
191
+ Use the user's app URL when provided. Hermes supports `--profile <profile>` and `--resume <exact-session-id>`. Without `--resume`, Oomi starts a persistent conversation with the same configured agent. Do not use `latest` or claim that an existing chat session was transferred unless its exact ID was selected. With oomi-ai 0.8.2+, an already-open Hermes conversation uses its existing local gateway owner and exact profile/session. Oomi attaches as another client; it never closes desktop, clears history, deletes a session lock, or substitutes a new chat. When no live owner exists, an Oomi-owned conversation (no `--resume`) runs on a warm `hermes acp` process with oomi-ai 0.9.0+: replies stream as they are written and stop when the person interrupts. From 0.9.1 the bridge starts Hermes and reopens recent conversations ahead of time (at bridge start and when a call opens), so the first message doesn't wait for Hermes to start. Hermes permission requests are denied, as `chat --quiet` denies dangerous commands; ordinary file edits run as they do in quiet mode. A conversation moving from the CLI starts a new Hermes session that receives the recent Oomi history. `--resume` conversations, Hermes without ACP, and `--hermes-transport cli` use `chat --quiet --query-file -` and resume the exact returned `session_id` on subsequent turns.
192
192
 
193
193
  Shared desktop conversations require Hermes's loopback WebSocket gateway, active-session discovery, and durable prompt/reply row IDs. Busy conversations ask you to retry after the current task. If a simultaneous desktop send queues your message without a reply receipt, or the connection drops after sending, check the same Hermes chat before resending. Oomi cannot safely repeat an uncertain action. Human approvals stay in the existing Hermes desktop UI. Shared photo submission is not yet supported; use Hermes desktop for that photo. Older or terminal-only owners without a shared gateway receive an actionable error, never a competing process. The shared owner keeps its existing environment, so use the explicit Oomi `--connection NAME` for account commands.
194
194