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 +3 -1
- package/agent_instructions.md +3 -1
- package/bin/oomi-ai.js +1 -1
- package/lib/hermesGateway.js +184 -0
- package/lib/localAgent.js +35 -6
- package/package.json +4 -3
- package/skills/oomi/SKILL.md +3 -1
- package/skills/oomi/agent_instructions.md +3 -1
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.
|
|
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
|
|
package/agent_instructions.md
CHANGED
|
@@ -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.
|
|
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,
|
|
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)
|
|
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) {
|
|
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.
|
|
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
|
}
|
package/skills/oomi/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|