oomi-ai 0.8.1 → 0.8.2

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
@@ -82,7 +82,9 @@ oomi connect --runtime hermes --name hermes --app-url https://www.oomi.ai
82
82
  oomi connect --runtime command --name my-agent --command '["/absolute/path/to/adapter"]' --app-url https://www.oomi.ai
83
83
  ```
84
84
 
85
- 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. Hermes uses `chat --quiet --query-file -`, reads the final reply from stdout and the exact `session_id` from stderr, and resumes that ID on subsequent turns.
85
+ 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.
86
+
87
+ 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.
86
88
 
87
89
  The command verifies a real local reply, reuses saved credentials or pairs once, starts the bridge, waits for Oomi's subscription, then creates a single-use Auth Link. Failed verification means setup is incomplete: repair the selected runtime, never substitute a different one.
88
90
 
@@ -135,7 +135,9 @@ oomi connect --runtime hermes --name hermes --app-url https://www.oomi.ai
135
135
  oomi connect --runtime command --name my-agent --command '["/absolute/path/to/adapter"]' --app-url https://www.oomi.ai
136
136
  ```
137
137
 
138
- 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. Hermes uses `chat --quiet --query-file -`, reads the final reply from stdout and the exact `session_id` from stderr, and resumes that ID on subsequent turns.
138
+ 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.
139
+
140
+ 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.
139
141
 
140
142
  The first connection verifies a real local reply (reconnects reuse verification), reuses saved credentials or pairs once, starts the bridge, waits for Oomi's subscription, then creates a single-use Auth Link. Failed verification means setup is incomplete: repair the selected runtime, never substitute a different one.
141
143
 
package/bin/oomi-ai.js CHANGED
@@ -3360,7 +3360,7 @@ async function startOpenclawBridge(flags) {
3360
3360
  if (gatewayPayload?.event === 'chat' && gatewayPayload.payload?.state === 'final') {
3361
3361
  updateBridgeStatus({ runtimeReady: true, lastReplyAt: bridgeNowIso() });
3362
3362
  } else if (gatewayPayload?.event === 'chat' && gatewayPayload.payload?.state === 'error') {
3363
- updateBridgeStatus({ status: 'degraded', runtimeReady: false, lastErrorMessage: gatewayPayload.payload.errorMessage || 'The local agent failed to answer.' });
3363
+ updateBridgeStatus({ ...(localRuntime && gatewayPayload.payload.runtimeReady === true ? { runtimeReady: true } : { status: 'degraded', runtimeReady: false }), lastErrorMessage: gatewayPayload.payload.errorMessage || 'The local agent failed to answer.' });
3364
3364
  }
3365
3365
 
3366
3366
  if (isGatewayRunStartedFrame(frame)) {
@@ -0,0 +1,184 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import WebSocket from 'ws';
4
+
5
+ // Only fixed, sanitized messages cross the bridge. Gateway diagnostics may contain
6
+ // credentials, private history or tool output; never interpolate them into errors.
7
+ export class HermesTurnError extends Error {
8
+ constructor(code, message, { notStarted = false, runtimeReady = true } = {}) {
9
+ super(message); this.code = code; this.notStarted = notStarted; this.runtimeReady = runtimeReady;
10
+ }
11
+ }
12
+ const refused = (code, message) => new HermesTurnError(code, message, { notStarted: true });
13
+ export function hermesRefusal(reason) {
14
+ if (reason === 'SESSION_NOT_OWNED') return refused('hermes_session_owned', 'This conversation is open in Hermes, but Oomi could not attach to it. Keep the same chat and update Hermes and Oomi on that computer, then retry.');
15
+ if (reason === 'MAX_CONCURRENT_SESSIONS') return refused('hermes_session_limit', 'Hermes has reached its active conversation limit. Free a conversation slot or raise the limit in Hermes, then retry.');
16
+ if (reason === 'SESSION_COORDINATION_UNAVAILABLE') return refused('hermes_coordination_unavailable', 'Hermes could not verify conversation ownership. Check Hermes on that computer, then retry.');
17
+ return null;
18
+ }
19
+ function readLocalJson(file, missing) {
20
+ try {
21
+ if (fs.statSync(file).size > 1024 * 1024) throw new Error();
22
+ return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
23
+ } catch (err) {
24
+ if (err.code === 'ENOENT') return missing;
25
+ throw hermesRefusal('SESSION_COORDINATION_UNAVAILABLE');
26
+ }
27
+ }
28
+ function alive(pid) {
29
+ try { process.kill(pid, 0); return true; } catch (err) { return err.code !== 'ESRCH'; }
30
+ }
31
+ function findOwner(config, sessionId) {
32
+ const profile = config.profile || 'default';
33
+ if (!config.hermesHome || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(profile)) throw hermesRefusal('SESSION_COORDINATION_UNAVAILABLE');
34
+ const home = profile === 'default' ? config.hermesHome : path.join(config.hermesHome, 'profiles', profile);
35
+ const raw = readLocalJson(path.join(home, 'runtime', 'active_sessions.json'), []);
36
+ const entries = Array.isArray(raw) ? raw : raw?.entries;
37
+ if (!Array.isArray(entries) || entries.some(e => !e || typeof e.session_id !== 'string' || !Number.isInteger(e.pid) || e.pid <= 0)) throw hermesRefusal('SESSION_COORDINATION_UNAVAILABLE');
38
+ const owners = entries.filter(e => e.session_id === sessionId && alive(e.pid));
39
+ if (!owners.length) return null; // CLI still acquires Hermes's own exclusive lease.
40
+ if (owners.length !== 1) throw hermesRefusal('SESSION_COORDINATION_UNAVAILABLE');
41
+ const owner = owners[0];
42
+ const ledger = readLocalJson(path.join(config.hermesHome, 'spawn-ledger.json'), []);
43
+ const hosts = new Set(['', '0.0.0.0', '127.0.0.1', '::', '::1', 'localhost']);
44
+ const candidates = Array.isArray(ledger) ? ledger.filter(e => e?.pid === owner.pid && e.isolated !== true && ['serve', 'dashboard'].includes(e.purpose) && hosts.has(e.host || '') && Number.isInteger(e.port) && e.port > 0 && e.port <= 65535) : [];
45
+ if (!owner.metadata?.live_session_id || candidates.length !== 1) throw hermesRefusal('SESSION_NOT_OWNED');
46
+ const backend = candidates[0];
47
+ return { pid: owner.pid, liveId: owner.metadata.live_session_id, profile, base: `http://${backend.host === '::1' ? '[::1]' : '127.0.0.1'}:${backend.port}` };
48
+ }
49
+
50
+ async function servedToken(base, signal) {
51
+ // Match Hermes Desktop's supported loopback discovery. Never follow redirects,
52
+ // read credential stores, or bypass a gateway login when no token is served.
53
+ const response = await fetch(`${base}/`, { signal, redirect: 'error' });
54
+ if (!response.ok) throw new Error();
55
+ let html = '';
56
+ for await (const chunk of response.body) {
57
+ html += Buffer.from(chunk).toString('utf8');
58
+ if (html.length > 1024 * 1024) throw new Error();
59
+ }
60
+ const match = html.match(/window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/);
61
+ const token = match && JSON.parse(match[1]);
62
+ if (typeof token !== 'string' || !token || token.length > 8192) throw new Error();
63
+ return token;
64
+ }
65
+
66
+ function connect(owner, token, signal, onEvent) {
67
+ const socket = new WebSocket(`${owner.base.replace(/^http/, 'ws')}/api/ws?token=${encodeURIComponent(token)}`, { handshakeTimeout: 5000, maxPayload: 4 * 1024 * 1024, followRedirects: false });
68
+ const pending = new Map();
69
+ let sequence = 0, failure;
70
+ let readyResolve, readyReject;
71
+ const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
72
+ ready.catch(() => {});
73
+ const readyTimer = setTimeout(() => { fail(); socket.terminate(); }, 5000);
74
+ const fail = (err = new Error()) => {
75
+ if (failure) return;
76
+ failure = err; clearTimeout(readyTimer); readyReject(err);
77
+ for (const p of pending.values()) { clearTimeout(p.timer); p.reject(err); }
78
+ pending.clear(); onEvent(null, err);
79
+ };
80
+ const abort = () => { fail(); socket.terminate(); };
81
+ signal.addEventListener('abort', abort, { once: true });
82
+ socket.on('error', () => fail());
83
+ socket.on('close', () => fail());
84
+ socket.on('message', data => {
85
+ try {
86
+ for (const line of data.toString().split('\n').filter(s => s.trim())) {
87
+ const frame = JSON.parse(line);
88
+ if (frame.method === 'event') {
89
+ if (frame.params?.type === 'gateway.ready') { clearTimeout(readyTimer); readyResolve(); }
90
+ else onEvent(frame.params);
91
+ } else if (pending.has(frame.id)) {
92
+ const p = pending.get(frame.id); pending.delete(frame.id); clearTimeout(p.timer);
93
+ if (frame.error) p.reject(hermesRefusal(p.method === 'prompt.submit' && frame.error.code === 4090 && frame.error.data?.reason) || new Error());
94
+ else p.resolve(frame.result);
95
+ }
96
+ }
97
+ } catch { fail(); }
98
+ });
99
+ return {
100
+ ready,
101
+ rpc(method, params = {}) {
102
+ if (failure || signal.aborted) return Promise.reject(failure || new Error());
103
+ const id = ++sequence;
104
+ return new Promise((resolve, reject) => {
105
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error()); }, 10000);
106
+ pending.set(id, { resolve, reject, timer, method });
107
+ socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params: { ...params, profile: owner.profile } }), err => { if (err) fail(); });
108
+ });
109
+ },
110
+ close() { signal.removeEventListener('abort', abort); fail(); socket.terminate(); },
111
+ };
112
+ }
113
+
114
+ /** Attach to the exact existing owner, or return null when CLI lease acquisition is appropriate. */
115
+ export async function runSharedHermesTurn(config, request, { signal, timeoutMs = 180000 } = {}) {
116
+ if (!request.runtimeSessionId) return null;
117
+ const owner = findOwner(config, request.runtimeSessionId);
118
+ if (!owner) return null;
119
+ // Hermes's image.attach stages media globally for the next sender. Refuse until
120
+ // the gateway supports atomic message+media admission; never steal desktop photos.
121
+ if (request.imagePaths?.length) throw refused('hermes_shared_photo', 'Photos in an open Hermes desktop conversation are not supported yet. Send this photo from Hermes desktop in the same chat.');
122
+ const controller = new AbortController();
123
+ const abort = () => controller.abort();
124
+ if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true });
125
+ const timer = setTimeout(abort, timeoutMs);
126
+ let client, heartbeat, dispatched = false, userRowId;
127
+ const early = [];
128
+ let completeResolve, completeReject;
129
+ const completed = new Promise((resolve, reject) => { completeResolve = resolve; completeReject = reject; });
130
+ completed.catch(() => {});
131
+ const final = payload => {
132
+ if (payload?.persisted_turn?.user_row_id !== userRowId) return;
133
+ if (payload.status !== 'complete' || !Number.isSafeInteger(payload.persisted_turn.final_assistant_row_id) || typeof payload.text !== 'string' || !payload.text.trim()) {
134
+ completeReject(new HermesTurnError('hermes_turn_failed', 'Hermes did not finish this message. Check the same conversation in Hermes for its error or approval request before retrying.'));
135
+ } else completeResolve({ text: payload.text.trim(), sessionId: request.runtimeSessionId });
136
+ };
137
+ try {
138
+ if (controller.signal.aborted) throw new Error();
139
+ const authTimer = setTimeout(abort, Math.min(timeoutMs, 5000));
140
+ let token;
141
+ try { token = await servedToken(owner.base, controller.signal); } finally { clearTimeout(authTimer); }
142
+ client = connect(owner, token, controller.signal, (event, err) => {
143
+ if (err) { completeReject(err); return; }
144
+ if (!dispatched || event?.type !== 'message.complete' || event.session_id !== owner.liveId) return;
145
+ if (userRowId) final(event.payload);
146
+ else { if (early.length >= 32) early.shift(); early.push(event.payload); }
147
+ });
148
+ await client.ready;
149
+ const list = await client.rpc('session.active_list');
150
+ const matches = list?.sessions?.filter(s => s.id === owner.liveId && s.session_key === request.runtimeSessionId);
151
+ if (matches?.length !== 1) throw hermesRefusal('SESSION_NOT_OWNED');
152
+ if (matches[0].status !== 'idle') throw refused('hermes_session_busy', 'Hermes is busy in this conversation. Let its current task finish, then retry here. Your message has not been sent.');
153
+ const attached = await client.rpc('session.activate', { session_id: owner.liveId, omit_messages: true });
154
+ if (attached?.session_id !== owner.liveId || attached.session_key !== request.runtimeSessionId) throw hermesRefusal('SESSION_NOT_OWNED');
155
+ if (attached.running || attached.queued || attached.status !== 'idle') throw refused('hermes_session_busy', 'Hermes is busy in this conversation. Let its current task finish, then retry here. Your message has not been sent.');
156
+ // No server_requests capability: existing Hermes clients retain their normal
157
+ // approval UI. Oomi never answers or auto-approves another client's requests.
158
+ heartbeat = setInterval(() => { client.rpc('gateway.ping').catch(completeReject); }, 15000);
159
+ dispatched = true;
160
+ const accepted = await client.rpc('prompt.submit', { session_id: owner.liveId, text: request.message, queued: true });
161
+ // A concurrent desktop send may win after the idle check. Hermes queues ours
162
+ // without interruption but currently returns no durable row ID for that path.
163
+ // Do not misattribute the desktop reply or replay a possibly accepted action.
164
+ if (accepted?.status === 'queued') throw new HermesTurnError('hermes_message_queued', 'Hermes queued your message behind another task. Check the same conversation in Hermes for the reply; do not send this message again.');
165
+ if (accepted?.status !== 'streaming' || !Number.isSafeInteger(accepted.user_row_id) || accepted.user_row_id <= 0) throw new HermesTurnError('hermes_receipt_unavailable', 'Hermes may have accepted your message, but could not confirm its reply address. Check the same conversation in Hermes before retrying. Update Hermes on that computer for shared chat support.');
166
+ userRowId = accepted.user_row_id;
167
+ for (const payload of early) final(payload);
168
+ const result = await completed;
169
+ // Compaction can rotate Hermes's durable session key while keeping the same
170
+ // live owner. Follow that exact owner, never a 'latest' conversation lookup.
171
+ try {
172
+ const current = await client.rpc('session.active_list');
173
+ const session = current?.sessions?.find(s => s.id === owner.liveId);
174
+ if (typeof session?.session_key === 'string' && /^[A-Za-z0-9_-]+$/.test(session.session_key)) result.sessionId = session.session_key;
175
+ } catch { /* The confirmed reply remains valid when a follow-up read fails. */ }
176
+ return result;
177
+ } catch (err) {
178
+ if (err instanceof HermesTurnError) throw err;
179
+ if (dispatched) throw new HermesTurnError('hermes_reply_uncertain', 'Oomi lost track of this reply. Hermes may still be working in the same conversation. Check it there before sending this message again.');
180
+ throw refused('hermes_attach_failed', 'Oomi could not attach to the open Hermes conversation. Check Hermes on that computer and its local connection, then retry. Your message has not been sent.');
181
+ } finally {
182
+ clearTimeout(timer); clearInterval(heartbeat); signal?.removeEventListener('abort', abort); controller.abort(); client?.close();
183
+ }
184
+ }
package/lib/localAgent.js CHANGED
@@ -4,6 +4,7 @@ import os from 'node:os';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { EventEmitter } from 'node:events';
6
6
  import { createHash, randomUUID } from 'node:crypto';
7
+ import { runSharedHermesTurn, hermesRefusal } from './hermesGateway.js';
7
8
 
8
9
  const IMAGE_EXTENSIONS = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' };
9
10
  const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
@@ -98,7 +99,24 @@ export function localAgentConfig(flags) {
98
99
  }
99
100
 
100
101
  // No shell interpolation. Message bodies travel over stdin, never as executable code.
101
- export function runLocalAgent(config, request, { signal, timeoutMs = 180000 } = {}) {
102
+ export async function runLocalAgent(config, request, options = {}) {
103
+ if (config.runtime === 'hermes') {
104
+ const shared = await runSharedHermesTurn(config, request, options);
105
+ if (shared) return shared;
106
+ }
107
+ try { return await runAgentProcess(config, request, options); }
108
+ catch (err) {
109
+ // Ownership may change between discovery and CLI lease acquisition. Only a
110
+ // machine-readable pre-start refusal permits trying the live owner once.
111
+ if (config.runtime === 'hermes' && err.code === 'hermes_session_owned') {
112
+ const shared = await runSharedHermesTurn(config, request, options);
113
+ if (shared) return shared;
114
+ }
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ function runAgentProcess(config, request, { signal, timeoutMs = 180000 } = {}) {
102
120
  return new Promise((resolve, reject) => {
103
121
  const args = config.command.slice(1);
104
122
  if (config.runtime === 'hermes') {
@@ -112,6 +130,7 @@ export function runLocalAgent(config, request, { signal, timeoutMs = 180000 } =
112
130
  const child = spawn(config.command[0], args, { env, cwd: config.cwd, shell: false, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
113
131
  let output = '';
114
132
  let diagnostics = '';
133
+ let diagnosticsTruncated = false;
115
134
  child.stdout.setEncoding('utf8');
116
135
  child.stderr.setEncoding('utf8');
117
136
  let bytes = 0;
@@ -139,13 +158,16 @@ export function runLocalAgent(config, request, { signal, timeoutMs = 180000 } =
139
158
  output += data.toString();
140
159
  });
141
160
  // Diagnostics may contain credentials or tool output; never forward them to Oomi.
142
- child.stderr.on('data', (data) => { diagnostics = (diagnostics + data).slice(-8192); });
161
+ child.stderr.on('data', (data) => { diagnosticsTruncated ||= diagnostics.length + data.length > 8192; diagnostics = (diagnostics + data).slice(-8192); });
143
162
  child.stdin.on('error', () => {});
144
163
  child.on('error', (err) => { error = new Error(`Could not start ${config.runtime} agent (${err.code || 'spawn failed'}). Check the executable and working directory.`); });
145
164
  child.on('close', (code) => {
146
165
  clearTimeout(timer); clearTimeout(killer); signal?.removeEventListener('abort', abort);
147
166
  if (error) return reject(error);
148
- if (code !== 0) return reject(new Error(`Agent exited with code ${code}. Check local runtime credentials and logs.`));
167
+ if (code !== 0) {
168
+ const reason = config.runtime === 'hermes' && code === 1 && !diagnosticsTruncated && !output.trim() && diagnostics.match(/^hermes-refusal-reason:\s*([A-Z_]+)(?:\r?\n|$)/)?.[1];
169
+ return reject(hermesRefusal(reason) || new Error(`Agent exited with code ${code}. Check local runtime credentials and logs.`));
170
+ }
149
171
  try {
150
172
  const result = config.runtime === 'hermes'
151
173
  ? { text: output, sessionId: diagnostics.match(/(?:^|\n)session_id:\s*([A-Za-z0-9_-]+)\s*(?:\n|$)/)?.[1] }
@@ -189,7 +211,7 @@ export class LocalAgentRuntime {
189
211
  if (previous?.message !== undefined && previous.message !== message) throw new Error('Idempotency key was reused for a different message.');
190
212
  if (previous?.result) return previous.result;
191
213
  // A crash after a side effect cannot safely be retried by running the agent again.
192
- if (previous && !retryInterrupted) throw new Error('The previous turn was interrupted. Check the agent before sending a new message.');
214
+ if (previous && previous.status !== 'not_started' && !retryInterrupted) throw new Error('The previous turn was interrupted. Check the agent before sending a new message.');
193
215
  if (controller.signal.aborted) throw new Error('Agent turn cancelled.');
194
216
  state.receipts[receiptKey] = { message, status: 'running' };
195
217
  writePrivateJson(this.file(sessionKey), state);
@@ -203,7 +225,14 @@ export class LocalAgentRuntime {
203
225
  writePrivateJson(this.file(sessionKey), state);
204
226
  this.onHealth({ runtimeReady: true, lastReplyAt: new Date().toISOString(), lastErrorMessage: '' });
205
227
  return result;
206
- } catch (err) { this.onHealth({ runtimeReady: false, status: 'degraded', lastErrorMessage: err.message }); throw err; }
228
+ } catch (err) {
229
+ if (err.notStarted === true) {
230
+ state.receipts[receiptKey] = { message, status: 'not_started' };
231
+ writePrivateJson(this.file(sessionKey), state);
232
+ }
233
+ this.onHealth({ runtimeReady: err.runtimeReady === true, ...(err.runtimeReady === true ? {} : { status: 'degraded' }), lastErrorMessage: err.message });
234
+ throw err;
235
+ }
207
236
  finally { if (scratch) fs.rmSync(scratch.directory, { recursive: true, force: true }); }
208
237
  });
209
238
  this.queue = promise.catch(() => {});
@@ -252,7 +281,7 @@ class LocalAgentSocket extends EventEmitter {
252
281
  this.runtime.turn({ ...params, idempotencyKey: runId }).then((result) => {
253
282
  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() } } });
254
283
  }, (err) => {
255
- this.frame({ type: 'event', event: 'chat', payload: { runId, sessionKey: params.sessionKey, state: 'error', errorMessage: err.message } });
284
+ this.frame({ type: 'event', event: 'chat', payload: { runId, sessionKey: params.sessionKey, state: 'error', errorMessage: err.message, ...(typeof err.runtimeReady === 'boolean' ? { runtimeReady: err.runtimeReady } : {}) } });
256
285
  });
257
286
  }
258
287
  close(code = 1000, reason = '') { if (this.readyState === 3) return; this.readyState = 3; this.emit('close', code, reason); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Connect OpenClaw, Hermes, and other local 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",
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",
53
53
  "test": "node --test test/*.test.mjs"
54
54
  },
55
55
  "dependencies": {
@@ -78,6 +78,7 @@
78
78
  "lib/spaces.js",
79
79
  "lib/objectives.js",
80
80
  "lib/bridgeService.js",
81
- "lib/bridgeMaintenance.js"
81
+ "lib/bridgeMaintenance.js",
82
+ "lib/hermesGateway.js"
82
83
  ]
83
84
  }
@@ -161,7 +161,9 @@ oomi connect --runtime hermes --name hermes --app-url https://www.oomi.ai
161
161
  oomi connect --runtime command --name my-agent --command '["/absolute/path/to/adapter"]' --app-url https://www.oomi.ai
162
162
  ```
163
163
 
164
- 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. Hermes uses `chat --quiet --query-file -`, reads the final reply from stdout and the exact `session_id` from stderr, and resumes that ID on subsequent turns.
164
+ 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.
165
+
166
+ 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.
165
167
 
166
168
  The command verifies a real local reply, reuses saved credentials or pairs once, starts the bridge, waits for Oomi's subscription, then creates a single-use Auth Link. Failed verification means setup is incomplete: repair the selected runtime, never substitute a different one.
167
169
 
@@ -135,7 +135,9 @@ oomi connect --runtime hermes --name hermes --app-url https://www.oomi.ai
135
135
  oomi connect --runtime command --name my-agent --command '["/absolute/path/to/adapter"]' --app-url https://www.oomi.ai
136
136
  ```
137
137
 
138
- 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. Hermes uses `chat --quiet --query-file -`, reads the final reply from stdout and the exact `session_id` from stderr, and resumes that ID on subsequent turns.
138
+ 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.
139
+
140
+ 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.
139
141
 
140
142
  The first connection verifies a real local reply (reconnects reuse verification), reuses saved credentials or pairs once, starts the bridge, waits for Oomi's subscription, then creates a single-use Auth Link. Failed verification means setup is incomplete: repair the selected runtime, never substitute a different one.
141
143