oomi-ai 0.8.4 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/agent_instructions.md +2 -2
- package/bin/oomi-ai.js +18 -5
- package/lib/grokbotRuntime.js +101 -17
- package/lib/hermesAcp.js +361 -0
- package/lib/localAgent.js +34 -6
- package/package.json +5 -3
- package/skills/oomi/SKILL.md +3 -3
- package/skills/oomi/agent_instructions.md +2 -2
- package/skills/oomi-delivery/SKILL.md +18 -0
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ This package exposes connection, bridge, voice, and persona app state APIs. It d
|
|
|
48
48
|
|
|
49
49
|
Connect the agent the user is speaking to. Identify your runtime, bot/profile, and actual host first. Computer access does not mean you run on that computer. Never substitute another installed agent.
|
|
50
50
|
|
|
51
|
-
## Choose a connection by interface (oomi-ai 0.8.
|
|
51
|
+
## Choose a connection by interface (oomi-ai 0.8.5+)
|
|
52
52
|
|
|
53
53
|
Run setup on the host where **this agent** runs. A Grok Bot with access to someone's Mac still runs on its Grok Bot host; connecting an OpenClaw agent on that Mac does not connect the Grok Bot. No new model API key or replacement bot is needed.
|
|
54
54
|
|
|
@@ -67,7 +67,7 @@ oomi connect --runtime grokbot --name YOUR_CONNECTION --agent-id YOUR_BOT_UUID -
|
|
|
67
67
|
|
|
68
68
|
Use your exact bot UUID from your runtime context. Never choose whichever bot happens to be active on a shared computer. Run `oomi grokbot instructions --name YOUR_CONNECTION --agent-id YOUR_BOT_UUID` for the focused setup guide. The package uses the host's existing private local gateway; **do not ask the user to copy a webhook URL/key or create a routine**. Fresh connections require no `grokbot.json`. Existing explicit webhook configurations remain supported. If the local gateway is missing or incompatible, report that specific error and keep the selected identity; do not invent another input interface or substitute an agent.
|
|
69
69
|
|
|
70
|
-
Setup returns **queued**, then waits in the background. Finish your current bot turn immediately so the verification can execute; waiting for your own verification in that turn deadlocks the host. The
|
|
70
|
+
Setup returns **queued**, then waits in the background. Finish your current bot turn immediately so the verification can execute; waiting for your own verification in that turn deadlocks the host. The package installs its private Oomi delivery skill and waits until this bot has loaded it. Incoming chat shows the original message with a small skill tag; the skill claims the private request and submits its final reply. Missing or locally modified skills fail before sending; do not replace them with instruction walls in chat. Continue only for `status: claimed`. A verification answer must be exactly `OOMI_OK`. The reply command reports one Auth Link only after verification, pairing and bridge subscription succeed. Show the Auth Link to the user. If pending, finish the turn and use `oomi grokbot status --name YOUR_CONNECTION` later. Queued is not connected. Acceptance by the host is not a completed answer.
|
|
71
71
|
|
|
72
72
|
Use explicit `--connection YOUR_CONNECTION` for Oomi account commands: a shared host does not inherit the bridge's environment. Text and voice transcripts are supported; photos are rejected. Oomi supplies conversation history; exact Grok Bot desktop/DM conversation continuation is not guaranteed. Host approvals remain in Grok Bot. Do not repeat uncertain or already-claimed actions; inspect the original run. Reconnect reuses verification and pairing; `oomi verify --name YOUR_CONNECTION` explicitly queues a new model verification.
|
|
73
73
|
|
|
@@ -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
|
|
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. 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/agent_instructions.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Connect the agent the user is speaking to. Identify your runtime, bot/profile, and actual host first. Computer access does not mean you run on that computer. Never substitute another installed agent.
|
|
4
4
|
|
|
5
|
-
## Choose a connection by interface (oomi-ai 0.8.
|
|
5
|
+
## Choose a connection by interface (oomi-ai 0.8.5+)
|
|
6
6
|
|
|
7
7
|
Run setup on the host where **this agent** runs. A Grok Bot with access to someone's Mac still runs on its Grok Bot host; connecting an OpenClaw agent on that Mac does not connect the Grok Bot. No new model API key or replacement bot is needed.
|
|
8
8
|
|
|
@@ -21,7 +21,7 @@ oomi connect --runtime grokbot --name YOUR_CONNECTION --agent-id YOUR_BOT_UUID -
|
|
|
21
21
|
|
|
22
22
|
Use your exact bot UUID from your runtime context. Never choose whichever bot happens to be active on a shared computer. Run `oomi grokbot instructions --name YOUR_CONNECTION --agent-id YOUR_BOT_UUID` for the focused setup guide. The package uses the host's existing private local gateway; **do not ask the user to copy a webhook URL/key or create a routine**. Fresh connections require no `grokbot.json`. Existing explicit webhook configurations remain supported. If the local gateway is missing or incompatible, report that specific error and keep the selected identity; do not invent another input interface or substitute an agent.
|
|
23
23
|
|
|
24
|
-
Setup returns **queued**, then waits in the background. Finish your current bot turn immediately so the verification can execute; waiting for your own verification in that turn deadlocks the host. The
|
|
24
|
+
Setup returns **queued**, then waits in the background. Finish your current bot turn immediately so the verification can execute; waiting for your own verification in that turn deadlocks the host. The package installs its private Oomi delivery skill and waits until this bot has loaded it. Incoming chat shows the original message with a small skill tag; the skill claims the private request and submits its final reply. Missing or locally modified skills fail before sending; do not replace them with instruction walls in chat. Continue only for `status: claimed`. A verification answer must be exactly `OOMI_OK`. The reply command reports one Auth Link only after verification, pairing and bridge subscription succeed. Show the Auth Link to the user. If pending, finish the turn and use `oomi grokbot status --name YOUR_CONNECTION` later. Queued is not connected. Acceptance by the host is not a completed answer.
|
|
25
25
|
|
|
26
26
|
Use explicit `--connection YOUR_CONNECTION` for Oomi account commands: a shared host does not inherit the bridge's environment. Text and voice transcripts are supported; photos are rejected. Oomi supplies conversation history; exact Grok Bot desktop/DM conversation continuation is not guaranteed. Host approvals remain in Grok Bot. Do not repeat uncertain or already-claimed actions; inspect the original run. Reconnect reuses verification and pairing; `oomi verify --name YOUR_CONNECTION` explicitly queues a new model verification.
|
|
27
27
|
|
package/bin/oomi-ai.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createHostProcessService } from '../lib/processSupervisor.js';
|
|
3
|
-
import { grokbotConfig, grokbotInstructions, claimGrokbotRequest, replyGrokbotRequest, waitForGrokbotIdle } from '../lib/grokbotRuntime.js';
|
|
3
|
+
import { grokbotConfig, grokbotInstructions, grokbotInboxRoots, receiveGrokbotRequest, claimGrokbotRequest, replyGrokbotRequest, waitForGrokbotIdle } from '../lib/grokbotRuntime.js';
|
|
4
4
|
import { queueConnectionSetup, readConnectionSetup, requireConnectionSetup, finishConnectionSetup, waitConnectionSetup } from '../lib/connectionSetup.js';
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
import os from 'os';
|
|
@@ -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.
|
|
@@ -3826,7 +3827,7 @@ async function startOpenclawBridge(flags) {
|
|
|
3826
3827
|
stopChannelWorker();
|
|
3827
3828
|
stopIdentityWorker();
|
|
3828
3829
|
stopRoutineWorker();
|
|
3829
|
-
if (localRuntime) await localRuntime.queue;
|
|
3830
|
+
if (localRuntime) { await localRuntime.queue; localRuntime.close(); }
|
|
3830
3831
|
if (reconnectState.timer) {
|
|
3831
3832
|
clearTimeout(reconnectState.timer);
|
|
3832
3833
|
reconnectState.timer = null;
|
|
@@ -4423,7 +4424,7 @@ async function verifyLocalAgent(state) {
|
|
|
4423
4424
|
if (state.localAgent) {
|
|
4424
4425
|
const reply = await runLocalAgent(state.localAgent, {
|
|
4425
4426
|
sessionKey: `oomi:verify:${randomUUID()}`, idempotencyKey: randomUUID(), history: [],
|
|
4426
|
-
message: state.runtime === 'grokbot' ? 'Oomi connection check.
|
|
4427
|
+
message: state.runtime === 'grokbot' ? 'Oomi connection check.' : 'Oomi connection check. Reply with OOMI_OK only. Do not use tools or change anything.',
|
|
4427
4428
|
});
|
|
4428
4429
|
if (state.runtime === 'grokbot' && reply.text.trim() !== 'OOMI_OK') throw new Error('Grok Bot did not return the expected verification response. No Auth Link was created.');
|
|
4429
4430
|
result = { verifiedAt: new Date().toISOString() };
|
|
@@ -4472,9 +4473,12 @@ async function connectLocalAgent(flags) {
|
|
|
4472
4473
|
profile: flags.profile ?? saved?.profile,
|
|
4473
4474
|
'hermes-home': saved?.hermesHome,
|
|
4474
4475
|
resume: flags.resume ?? saved?.resume,
|
|
4476
|
+
'hermes-transport': flags['hermes-transport'] ?? saved?.hermesTransport,
|
|
4475
4477
|
cwd: flags.cwd ?? saved?.cwd,
|
|
4476
4478
|
});
|
|
4477
|
-
|
|
4479
|
+
// How Hermes is reached (ACP or the quiet CLI) may change; the agent itself may not.
|
|
4480
|
+
const identity = ({ hermesTransport, ...rest } = {}) => JSON.stringify(rest);
|
|
4481
|
+
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
4482
|
}
|
|
4479
4483
|
if (!previous.verifiedAt || isTruthyFlag(flags.verify)) {
|
|
4480
4484
|
console.log(`Verifying ${runtime}${state.localAgent?.profile ? ` profile ${state.localAgent.profile}` : ''} with a local test message...`);
|
|
@@ -4570,6 +4574,15 @@ async function main() {
|
|
|
4570
4574
|
if (command === 'objectives') return handleObjectivesCommand(readBridgeState(), subcommand || 'list', args.flags, args.positionals);
|
|
4571
4575
|
if (command === 'grokbot') {
|
|
4572
4576
|
const directory = connectionDirectory();
|
|
4577
|
+
if (subcommand === 'receive') {
|
|
4578
|
+
if (!args.flags.file) throw new Error('Use --file with JSON containing the exact current message.');
|
|
4579
|
+
const stat = fs.lstatSync(args.flags.file);
|
|
4580
|
+
if (!stat.isFile() || stat.size > 4 * 1024 * 1024) throw new Error('Message file is invalid or too large.');
|
|
4581
|
+
const input = JSON.parse(fs.readFileSync(args.flags.file, 'utf8'));
|
|
4582
|
+
const root = process.env.OOMI_CONNECTIONS_DIR || path.join(os.homedir(), '.oomi', 'connections');
|
|
4583
|
+
console.log(JSON.stringify(receiveGrokbotRequest(grokbotInboxRoots(root), args.flags['agent-id'], input.message), null, 2));
|
|
4584
|
+
return;
|
|
4585
|
+
}
|
|
4573
4586
|
if (!bridgeConnectionName) throw new Error('Specify --name for this Grok Bot connection.');
|
|
4574
4587
|
if (subcommand === 'instructions') { console.log(grokbotInstructions(bridgeConnectionName, args.flags['agent-id'] || process.env.OOMI_GROKBOT_AGENT_ID)); return; }
|
|
4575
4588
|
if (subcommand === 'status') { console.log(JSON.stringify(readConnectionSetup(directory) || { status: 'not_started' }, null, 2)); return; }
|
|
@@ -4588,7 +4601,7 @@ async function main() {
|
|
|
4588
4601
|
}
|
|
4589
4602
|
return;
|
|
4590
4603
|
}
|
|
4591
|
-
throw new Error('Use oomi grokbot instructions|status|claim|reply with --name.');
|
|
4604
|
+
throw new Error('Use oomi grokbot instructions|status|receive|claim|reply with --name.');
|
|
4592
4605
|
}
|
|
4593
4606
|
if (command === 'connect') {
|
|
4594
4607
|
if (args.flags.runtime !== 'grokbot') return connectLocalAgent(args.flags);
|
package/lib/grokbotRuntime.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
6
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
@@ -84,19 +85,67 @@ export async function grokbotHealth(config, { fetchImpl = fetch } = {}) {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
const shellQuote = value => "'" + String(value).replace(/'/g, "'\\''") + "'";
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
88
|
+
const DELIVERY_SKILL = 'oomi-delivery';
|
|
89
|
+
export function installGrokbotDeliverySkill(config) {
|
|
90
|
+
const source = fs.readFileSync(new URL('../skills/oomi-delivery/SKILL.md', import.meta.url), 'utf8');
|
|
91
|
+
const root = path.join(path.dirname(config.gatewayFile), 'workflows');
|
|
92
|
+
// Only the host's existing private skill library is a supported install target.
|
|
93
|
+
// Never create a guessed provider directory or overwrite a user's skill.
|
|
94
|
+
try {
|
|
95
|
+
const stat = fs.lstatSync(root);
|
|
96
|
+
if (!stat.isDirectory() || (process.platform !== 'win32' && stat.uid !== process.getuid())) throw new Error();
|
|
97
|
+
const directory = path.join(root, DELIVERY_SKILL);
|
|
98
|
+
privateDirectory(directory);
|
|
99
|
+
if (!fs.lstatSync(directory).isDirectory()) throw new Error();
|
|
100
|
+
const file = path.join(directory, 'SKILL.md');
|
|
101
|
+
try { fs.writeFileSync(file, source, { mode: 0o600, flag: 'wx' }); }
|
|
102
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
103
|
+
const skill = fs.lstatSync(file);
|
|
104
|
+
if (!skill.isFile() || skill.size > 65536 || fs.readFileSync(file, 'utf8') !== source) throw new Error();
|
|
105
|
+
} catch {
|
|
106
|
+
throw new GrokbotError('grokbot_skill_unavailable', 'Oomi could not install its delivery skill in this host’s existing private skill library, or that skill has local changes. Repair the Oomi skill before reconnecting; no message was sent.', { notStarted: true });
|
|
107
|
+
}
|
|
108
|
+
const registry = config.deliveryRegistryDir || path.join(os.homedir(), '.oomi', 'grokbot-inboxes');
|
|
109
|
+
const inboxRoot = path.resolve(path.dirname(path.dirname(config.requestsDir)));
|
|
110
|
+
replaceJson(path.join(registry, createHash('sha256').update(inboxRoot).digest('hex') + '.json'), { root: inboxRoot });
|
|
111
|
+
return { id: DELIVERY_SKILL, body: source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim() };
|
|
112
|
+
}
|
|
113
|
+
export function grokbotInboxRoots(defaultRoot, registry = path.join(os.homedir(), '.oomi', 'grokbot-inboxes')) {
|
|
114
|
+
const roots = new Set([path.resolve(defaultRoot)]);
|
|
115
|
+
if (fs.existsSync(registry)) for (const entry of fs.readdirSync(registry, { withFileTypes: true })) {
|
|
116
|
+
if (!entry.isFile() || !/^[0-9a-f]{64}\.json$/.test(entry.name)) continue;
|
|
117
|
+
const saved = readJson(path.join(registry, entry.name), 65536);
|
|
118
|
+
if (typeof saved.root !== 'string' || !path.isAbsolute(saved.root)) throw new Error('Invalid Oomi inbox registration.');
|
|
119
|
+
if (createHash('sha256').update(saved.root).digest('hex') + '.json' !== entry.name) throw new Error('Invalid Oomi inbox registration identity.');
|
|
120
|
+
roots.add(saved.root);
|
|
121
|
+
}
|
|
122
|
+
return [...roots];
|
|
123
|
+
}
|
|
124
|
+
async function gatewayMessage(config, request, id, { fetchImpl, signal, skillTimeoutMs, pollMs }) {
|
|
125
|
+
const skill = installGrokbotDeliverySkill(config);
|
|
126
|
+
const gateway = gatewayFor(config);
|
|
127
|
+
const end = Date.now() + skillTimeoutMs;
|
|
128
|
+
// The provider watches skill files asynchronously. A readable file is not
|
|
129
|
+
// evidence that the selected bot has loaded it yet (verified on a real host).
|
|
130
|
+
let ready = false;
|
|
131
|
+
do {
|
|
132
|
+
if (signal?.aborted) break;
|
|
133
|
+
try {
|
|
134
|
+
const response = await fetchImpl(`${gateway.url}/api/getAgentWorkflows`, { method: 'POST', headers: gateway.headers, body: JSON.stringify({ id: config.agentId }), redirect: 'error', signal: AbortSignal.timeout(5000) });
|
|
135
|
+
const skills = await response.json();
|
|
136
|
+
ready = response.ok && Array.isArray(skills) && skills.some(item => item.id === skill.id && typeof item.body === 'string' && item.body.trim() === skill.body);
|
|
137
|
+
if (ready) break;
|
|
138
|
+
} catch { break; }
|
|
139
|
+
if (Date.now() < end) await delay(Math.min(pollMs, Math.max(1, end-Date.now())));
|
|
140
|
+
} while (Date.now() < end);
|
|
141
|
+
if (!ready || signal?.aborted) throw new GrokbotError('grokbot_skill_unavailable', 'This bot has not loaded the Oomi delivery skill yet. Reconnect after its skill library refreshes; no message was sent.', { notStarted: true });
|
|
142
|
+
return {
|
|
143
|
+
agentId: config.agentId, prompt: request.message, clientNonce: id,
|
|
144
|
+
richText: JSON.stringify({ type: 'doc', content: [{ type: 'paragraph', content: [
|
|
145
|
+
{ type: 'workflowReference', attrs: { id: skill.id, name: 'Oomi' } },
|
|
146
|
+
{ type: 'text', text: request.message }
|
|
147
|
+
] }] })
|
|
148
|
+
};
|
|
100
149
|
}
|
|
101
150
|
export async function waitForGrokbotIdle(config, { timeoutMs = 300000 } = {}) {
|
|
102
151
|
const end = Date.now() + timeoutMs;
|
|
@@ -134,7 +183,7 @@ export function completedGrokbotTurn(config, request) {
|
|
|
134
183
|
|
|
135
184
|
// The local gateway (or an existing webhook) wakes this exact bot. Acceptance
|
|
136
185
|
// is not completion: both transports return through the same private claim/reply inbox.
|
|
137
|
-
export async function runGrokbotTurn(config, request, { signal, timeoutMs = 180000, fetchImpl = fetch, pollMs = 250 } = {}) {
|
|
186
|
+
export async function runGrokbotTurn(config, request, { signal, timeoutMs = 180000, fetchImpl = fetch, pollMs = 250, skillTimeoutMs = 15000 } = {}) {
|
|
138
187
|
if (request.imagePaths?.length) throw new GrokbotError('grokbot_photos', 'Grok Bot photos are not supported through Oomi yet. Send the photo in Grok Bot.', { notStarted: true, runtimeReady: true });
|
|
139
188
|
if (!request.sessionKey || !request.idempotencyKey || typeof request.message !== 'string' || !request.message.trim()) throw new Error('A session, message, and idempotency key are required.');
|
|
140
189
|
const settings = settingsFor(config);
|
|
@@ -149,6 +198,8 @@ export async function runGrokbotTurn(config, request, { signal, timeoutMs = 1800
|
|
|
149
198
|
const health = await grokbotHealth(config, { fetchImpl });
|
|
150
199
|
if (health.isBusy || health.needsApproval) throw new GrokbotError('grokbot_busy', health.needsApproval ? 'Grok Bot is waiting for approval in its own app. Resolve it before sending another message.' : 'Grok Bot is busy. Finish the current task, then retry. This message was not sent.', { notStarted: true, runtimeReady: true });
|
|
151
200
|
if (signal?.aborted) throw new GrokbotError('grokbot_cancelled', 'Grok Bot message cancelled before sending.', { notStarted: true, runtimeReady: true });
|
|
201
|
+
// Prepare before persisting a dispatch: unsupported hosts fail without sending.
|
|
202
|
+
const delivery = config.transport === 'gateway' ? await gatewayMessage(config, request, id, { fetchImpl, signal, skillTimeoutMs, pollMs }) : null;
|
|
152
203
|
const record = { ...request, protocol: PROTOCOL, requestId: id, agentId: config.agentId, connection: config.connection, fingerprint: requestFingerprint(request), status: 'dispatching', expiresAt: Date.now() + timeoutMs, imagePaths: undefined };
|
|
153
204
|
if (Buffer.byteLength(JSON.stringify(record)) > 4 * 1024 * 1024) throw new GrokbotError('grokbot_request_size', 'This conversation is too large for the Grok Bot inbox. Start a new Oomi conversation; no message was sent.', { notStarted: true, runtimeReady: true });
|
|
154
205
|
// An atomic claim also gates duplicate webhooks/parallel workers. A persisted
|
|
@@ -175,7 +226,7 @@ export async function runGrokbotTurn(config, request, { signal, timeoutMs = 1800
|
|
|
175
226
|
try {
|
|
176
227
|
const local = config.transport === 'gateway';
|
|
177
228
|
const gateway = local ? gatewayFor(config, settings) : null;
|
|
178
|
-
const response = await fetchImpl(local ? `${gateway.url}/api/sendPrompt` : settings.webhook.url, { method: 'POST', redirect: 'error', headers: local ? gateway.headers : { Authorization: `Bearer ${settings.webhook.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(local ?
|
|
229
|
+
const response = await fetchImpl(local ? `${gateway.url}/api/sendPrompt` : settings.webhook.url, { method: 'POST', redirect: 'error', headers: local ? gateway.headers : { Authorization: `Bearer ${settings.webhook.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(local ? delivery : { protocol: PROTOCOL, requestId: id, agentId: config.agentId, connection: config.connection }), signal: controller.signal });
|
|
179
230
|
if (response.status !== 200) {
|
|
180
231
|
if ([400,401,403,404,409,422,429].includes(response.status)) {
|
|
181
232
|
replaceJson(file, { ...record, status: 'not_started' });
|
|
@@ -214,7 +265,40 @@ export function claimGrokbotRequest(directory, id, agentId) {
|
|
|
214
265
|
if (fs.existsSync(requestFile(directory, id, 'reply.json'))) return { status: 'already_completed', requestId: id };
|
|
215
266
|
try { exclusiveJson(requestFile(directory, id, 'claim.json'), { agentId, claimedAt: Date.now() }); }
|
|
216
267
|
catch (err) { if (err.code === 'EEXIST') return { status: 'already_claimed', requestId: id, instruction: 'Do not repeat any actions. Inspect the original run.' }; throw err; }
|
|
217
|
-
return { status: 'claimed', protocol: PROTOCOL, requestId: id, agentId, sessionKey: request.sessionKey, runtimeSessionId: request.runtimeSessionId, message: request.message, history: request.history || [], connection: request.connection };
|
|
268
|
+
return { status: 'claimed', protocol: PROTOCOL, requestId: id, agentId, sessionKey: request.sessionKey, runtimeSessionId: request.runtimeSessionId, message: request.message, history: request.history || [], connection: request.connection, verification: request.sessionKey.startsWith('oomi:verify:') };
|
|
269
|
+
}
|
|
270
|
+
// The visible user message and trusted runtime UUID jointly select the inbox
|
|
271
|
+
// item. Never pick the newest item, infer identity from an active bot, or guess
|
|
272
|
+
// when identical messages are pending in multiple connections.
|
|
273
|
+
export function receiveGrokbotRequest(roots, agentId, message) {
|
|
274
|
+
if (!Array.isArray(roots)) roots = [roots];
|
|
275
|
+
if (!UUID.test(agentId || '') || typeof message !== 'string' || !message.trim()) throw new Error('Use your trusted bot UUID and the exact current message.');
|
|
276
|
+
const matches = [];
|
|
277
|
+
for (const root of new Set(roots.map(value => path.resolve(value)))) {
|
|
278
|
+
const connections = fs.existsSync(root) ? fs.readdirSync(root, { withFileTypes: true }) : [];
|
|
279
|
+
if (connections.length > 512) throw new Error('Too many connections to select a delivery safely.');
|
|
280
|
+
for (const connection of connections) {
|
|
281
|
+
if (!connection.isDirectory() || !NAME.test(connection.name)) continue;
|
|
282
|
+
const directory = path.join(root, connection.name, 'grokbot-requests');
|
|
283
|
+
if (!fs.existsSync(directory) || !fs.lstatSync(directory).isDirectory()) continue;
|
|
284
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
285
|
+
if (!entry.isFile() || !/^[0-9a-f]{64}\.json$/.test(entry.name)) continue;
|
|
286
|
+
const request = readJson(path.join(directory, entry.name));
|
|
287
|
+
if (request.protocol !== PROTOCOL || request.agentId !== agentId || request.message !== message || request.expiresAt <= Date.now() || ['not_started', 'cancelled'].includes(request.status)) continue;
|
|
288
|
+
const id = entry.name.slice(0, -5);
|
|
289
|
+
if (request.requestId !== id || request.connection !== connection.name) throw new Error('Invalid Oomi delivery identity.');
|
|
290
|
+
if (!fs.existsSync(requestFile(directory, id, 'reply.json'))) matches.push({ root, directory, id, connection: connection.name });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (matches.length === 0) return { status: 'no_pending_delivery', instruction: 'Do not perform or repeat the task. This message has no matching active Oomi delivery.' };
|
|
295
|
+
if (matches.length !== 1) return { status: 'ambiguous_delivery', instruction: 'Do not perform the task. More than one active Oomi delivery matches this message.' };
|
|
296
|
+
const { root, directory, id, connection } = matches[0];
|
|
297
|
+
const claim = claimGrokbotRequest(directory, id, agentId);
|
|
298
|
+
if (claim.status !== 'claimed') return claim;
|
|
299
|
+
const cli = fileURLToPath(new URL('../bin/oomi-ai.js', import.meta.url));
|
|
300
|
+
const replyFile = requestFile(directory, id, 'answer.json');
|
|
301
|
+
return { ...claim, replyFile, replyCommand: `env OOMI_CONNECTIONS_DIR=${shellQuote(path.resolve(root))} ${shellQuote(process.execPath)} ${shellQuote(cli)} grokbot reply --name ${connection} --agent-id ${agentId} --request ${id} --file ${shellQuote(replyFile)}` };
|
|
218
302
|
}
|
|
219
303
|
export function replyGrokbotRequest(directory, id, agentId, input) {
|
|
220
304
|
const request = checkedRequest(directory, id, agentId);
|
|
@@ -237,7 +321,7 @@ Use your existing name and exact bot UUID from your runtime context. Do not choo
|
|
|
237
321
|
|
|
238
322
|
The package uses the host's existing private local gateway.json. Do not ask the user for a webhook URL or key, create a routine, copy gateway credentials, or configure another agent. If the host gateway is absent or incompatible, report that specific error; never invent an interface.
|
|
239
323
|
|
|
240
|
-
Setup returns queued. Finish your current turn immediately so the background verification can reach this bot. The
|
|
324
|
+
Setup returns queued. Finish your current turn immediately so the background verification can reach this bot. The package installs its private Oomi delivery skill. Deliveries show the original message with a small skill tag; the skill uses the private receive/reply inbox. Continue only when receive returns status claimed. Return exactly OOMI_OK for verification; use normal host permissions for user tasks. The reply command returns one Auth Link after verification, pairing and bridge subscription succeed. Show that link to the user. Queued or accepted is not connected. If setup is pending, finish the turn; use oomi grokbot status --name ${connection} later.
|
|
241
325
|
|
|
242
326
|
Existing private webhook configurations remain supported. Fresh connections need no grokbot.json. On hosts without systemd, Oomi supervises worker exits during this host session; after a whole cloud computer restart, rerun the same connect command. No new pairing or model verification is needed for a saved connection. Use oomi status --name ${connection} --json to inspect recovery.
|
|
243
327
|
`;
|
package/lib/hermesAcp.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
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) {
|
|
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
|
+
const created = await this.request('session/new', { cwd, mcpServers: [] });
|
|
217
|
+
if (typeof created?.sessionId !== 'string' || !created.sessionId) throw unavailable();
|
|
218
|
+
await this.prepare(created.sessionId);
|
|
219
|
+
return { sessionId: created.sessionId, created: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async prepare(sessionId) {
|
|
223
|
+
// Without this mode Hermes asks before every file edit, which Oomi would deny.
|
|
224
|
+
await this.request('session/set_mode', { sessionId, modeId: EDIT_MODE }).catch(() => {});
|
|
225
|
+
this.loaded.add(sessionId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
prompt(sessionId, prompt, { signal, timeoutMs = 180000, onText } = {}) {
|
|
229
|
+
if (this.turns.has(sessionId)) return Promise.reject(new HermesAcpError('hermes_session_busy', 'Hermes is still answering in this conversation.', { notStarted: true }));
|
|
230
|
+
return new Promise((resolve, reject) => {
|
|
231
|
+
let text = '';
|
|
232
|
+
let sent = '';
|
|
233
|
+
let messageId;
|
|
234
|
+
let textTimer = null;
|
|
235
|
+
let settled = false;
|
|
236
|
+
const flush = () => {
|
|
237
|
+
textTimer = null;
|
|
238
|
+
if (text !== sent) { sent = text; onText?.(text); }
|
|
239
|
+
};
|
|
240
|
+
const finish = (callback, value) => {
|
|
241
|
+
if (settled) return;
|
|
242
|
+
settled = true;
|
|
243
|
+
clearTimeout(timer); clearTimeout(textTimer);
|
|
244
|
+
signal?.removeEventListener('abort', abort);
|
|
245
|
+
this.turns.delete(sessionId);
|
|
246
|
+
callback(value);
|
|
247
|
+
};
|
|
248
|
+
const cancel = (error) => {
|
|
249
|
+
this.notify('session/cancel', { sessionId });
|
|
250
|
+
finish(reject, error);
|
|
251
|
+
};
|
|
252
|
+
const abort = () => cancel(new HermesAcpError('cancelled', 'Agent turn cancelled.'));
|
|
253
|
+
const timer = setTimeout(() => cancel(new HermesAcpError('timeout', 'Agent turn timed out.')), timeoutMs);
|
|
254
|
+
this.turns.set(sessionId, {
|
|
255
|
+
onChunk: (chunk, id) => {
|
|
256
|
+
if (settled || !chunk) return;
|
|
257
|
+
// Words before and after a tool call are separate messages; keep them apart.
|
|
258
|
+
if (id && messageId && id !== messageId && text && !/\s$/.test(text)) text += '\n\n';
|
|
259
|
+
if (id) messageId = id;
|
|
260
|
+
if (text.length + chunk.length > MAX_REPLY_CHARS) return cancel(new HermesAcpError('too_long', 'Agent output exceeded 4 MiB.'));
|
|
261
|
+
text += chunk;
|
|
262
|
+
// Send the first words right away, then at most every TEXT_INTERVAL_MS.
|
|
263
|
+
if (!sent) flush();
|
|
264
|
+
else textTimer ??= setTimeout(flush, TEXT_INTERVAL_MS);
|
|
265
|
+
},
|
|
266
|
+
fail: (error) => finish(reject, error),
|
|
267
|
+
});
|
|
268
|
+
if (signal?.aborted) return abort();
|
|
269
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
270
|
+
this.request('session/prompt', { sessionId, prompt }, 0).then((result) => {
|
|
271
|
+
if (settled) return;
|
|
272
|
+
const reply = text.trim();
|
|
273
|
+
if (result?.stopReason === 'cancelled') return finish(reject, new HermesAcpError('cancelled', 'Agent turn cancelled.'));
|
|
274
|
+
if (result?.stopReason === 'refusal' && !reply) {
|
|
275
|
+
this.loaded.delete(sessionId);
|
|
276
|
+
return finish(reject, new HermesAcpError('hermes_session_unknown', 'Hermes no longer has this conversation.', { notStarted: true }));
|
|
277
|
+
}
|
|
278
|
+
if (!reply) return finish(reject, new HermesAcpError('empty_reply', 'Agent did not return a successful final reply.'));
|
|
279
|
+
clearTimeout(textTimer);
|
|
280
|
+
flush();
|
|
281
|
+
finish(resolve, reply);
|
|
282
|
+
}, (err) => finish(reject, err));
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
close() {
|
|
287
|
+
if (this.closed && !this.child) return;
|
|
288
|
+
this.closed = true;
|
|
289
|
+
const child = this.child;
|
|
290
|
+
this.child = null;
|
|
291
|
+
if (!child || child.exitCode !== null) return;
|
|
292
|
+
const terminate = (signal) => {
|
|
293
|
+
try {
|
|
294
|
+
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal);
|
|
295
|
+
else child.kill(signal);
|
|
296
|
+
} catch { /* Already exited. */ }
|
|
297
|
+
};
|
|
298
|
+
try { child.stdin.end(); } catch { /* Closed. */ }
|
|
299
|
+
terminate('SIGTERM');
|
|
300
|
+
setTimeout(() => terminate('SIGKILL'), 1000).unref();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let cachedVersion;
|
|
305
|
+
function packageVersion() {
|
|
306
|
+
if (cachedVersion) return cachedVersion;
|
|
307
|
+
try { cachedVersion = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { cachedVersion = '0.0.0'; }
|
|
308
|
+
return cachedVersion;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function textOf(content) {
|
|
312
|
+
if (typeof content === 'string') return content;
|
|
313
|
+
if (!Array.isArray(content)) return '';
|
|
314
|
+
return content.map((part) => (part?.type === 'text' ? part.text : part?.type === 'image' ? '[photo]' : '')).filter(Boolean).join(' ');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// A conversation that moves to a new Hermes session (from the quiet CLI, or after Hermes lost
|
|
318
|
+
// it) keeps its thread: the first prompt carries the recent Oomi history.
|
|
319
|
+
export function continuityPreamble(history) {
|
|
320
|
+
const recent = (Array.isArray(history) ? history : []).slice(-SEED_MESSAGES);
|
|
321
|
+
const lines = [];
|
|
322
|
+
let total = 0;
|
|
323
|
+
for (const message of recent) {
|
|
324
|
+
const words = textOf(message?.content).trim();
|
|
325
|
+
if (!words || !['user', 'assistant'].includes(message?.role)) continue;
|
|
326
|
+
const line = `${message.role === 'user' ? 'User' : 'You'}: ${words.length > SEED_MESSAGE_CHARS ? `${words.slice(0, SEED_MESSAGE_CHARS)}…` : words}`;
|
|
327
|
+
total += line.length;
|
|
328
|
+
if (total > SEED_TOTAL_CHARS) break;
|
|
329
|
+
lines.push(line);
|
|
330
|
+
}
|
|
331
|
+
if (!lines.length) return '';
|
|
332
|
+
return `For continuity, here is the recent part of this Oomi conversation:\n${lines.join('\n')}\n---\n`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function promptBlocks(request, seed) {
|
|
336
|
+
const blocks = [{ type: 'text', text: `${seed}${request.message}` }];
|
|
337
|
+
const image = request.imagePaths?.[0];
|
|
338
|
+
if (image) {
|
|
339
|
+
const mimeType = IMAGE_TYPES[path.extname(image).toLowerCase()];
|
|
340
|
+
if (mimeType) blocks.push({ type: 'image', mimeType, data: fs.readFileSync(image).toString('base64') });
|
|
341
|
+
}
|
|
342
|
+
return blocks;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** One Hermes turn over ACP. Returns the reply and the ACP session that holds the conversation. */
|
|
346
|
+
export async function runHermesAcpTurn(config, request, { signal, timeoutMs = 180000, onText } = {}) {
|
|
347
|
+
const client = hermesAcpClient(config);
|
|
348
|
+
await client.start();
|
|
349
|
+
const continuing = request.runtimeTransport === 'acp' ? request.runtimeSessionId : '';
|
|
350
|
+
let { sessionId, created } = await client.openSession(continuing);
|
|
351
|
+
const seed = () => continuityPreamble(request.history);
|
|
352
|
+
let text;
|
|
353
|
+
try {
|
|
354
|
+
text = await client.prompt(sessionId, promptBlocks(request, created ? seed() : ''), { signal, timeoutMs, onText });
|
|
355
|
+
} catch (err) {
|
|
356
|
+
if (err.code !== 'hermes_session_unknown' || created) throw err;
|
|
357
|
+
({ sessionId } = await client.openSession(''));
|
|
358
|
+
text = await client.prompt(sessionId, promptBlocks(request, seed()), { signal, timeoutMs, onText });
|
|
359
|
+
}
|
|
360
|
+
return { text, sessionId, transport: 'acp' };
|
|
361
|
+
}
|
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 } 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)
|
|
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,7 @@ 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); }
|
|
252
278
|
}
|
|
253
279
|
|
|
254
280
|
class LocalAgentSocket extends EventEmitter {
|
|
@@ -282,7 +308,9 @@ class LocalAgentSocket extends EventEmitter {
|
|
|
282
308
|
if (req.method !== 'chat.send') return fail(`Method ${req.method} is not supported by this agent adapter.`);
|
|
283
309
|
const runId = params.idempotencyKey || req.id || randomUUID();
|
|
284
310
|
reply({ runId, status: 'started' });
|
|
285
|
-
|
|
311
|
+
// Streaming runtimes send the reply so far as it grows, the shape OpenClaw's gateway uses.
|
|
312
|
+
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() } } });
|
|
313
|
+
this.runtime.turn({ ...params, idempotencyKey: runId, onText }).then((result) => {
|
|
286
314
|
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
315
|
}, (err) => {
|
|
288
316
|
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.
|
|
3
|
+
"version": "0.9.0",
|
|
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,8 +80,10 @@
|
|
|
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
|
-
"lib/processSupervisor.js"
|
|
86
|
+
"lib/processSupervisor.js",
|
|
87
|
+
"skills/oomi-delivery/SKILL.md"
|
|
86
88
|
]
|
|
87
89
|
}
|
package/skills/oomi/SKILL.md
CHANGED
|
@@ -7,7 +7,7 @@ description: Connect the current OpenClaw, Hermes, Grok Bot, or other agent to O
|
|
|
7
7
|
|
|
8
8
|
Connect the agent the user is speaking to. Identify your runtime, bot/profile, and actual host first. Computer access does not mean you run on that computer. Never substitute another installed agent.
|
|
9
9
|
|
|
10
|
-
## Choose a connection by interface (oomi-ai 0.8.
|
|
10
|
+
## Choose a connection by interface (oomi-ai 0.8.5+)
|
|
11
11
|
|
|
12
12
|
Run setup on the host where **this agent** runs. A Grok Bot with access to someone's Mac still runs on its Grok Bot host; connecting an OpenClaw agent on that Mac does not connect the Grok Bot. No new model API key or replacement bot is needed.
|
|
13
13
|
|
|
@@ -26,7 +26,7 @@ oomi connect --runtime grokbot --name YOUR_CONNECTION --agent-id YOUR_BOT_UUID -
|
|
|
26
26
|
|
|
27
27
|
Use your exact bot UUID from your runtime context. Never choose whichever bot happens to be active on a shared computer. Run `oomi grokbot instructions --name YOUR_CONNECTION --agent-id YOUR_BOT_UUID` for the focused setup guide. The package uses the host's existing private local gateway; **do not ask the user to copy a webhook URL/key or create a routine**. Fresh connections require no `grokbot.json`. Existing explicit webhook configurations remain supported. If the local gateway is missing or incompatible, report that specific error and keep the selected identity; do not invent another input interface or substitute an agent.
|
|
28
28
|
|
|
29
|
-
Setup returns **queued**, then waits in the background. Finish your current bot turn immediately so the verification can execute; waiting for your own verification in that turn deadlocks the host. The
|
|
29
|
+
Setup returns **queued**, then waits in the background. Finish your current bot turn immediately so the verification can execute; waiting for your own verification in that turn deadlocks the host. The package installs its private Oomi delivery skill and waits until this bot has loaded it. Incoming chat shows the original message with a small skill tag; the skill claims the private request and submits its final reply. Missing or locally modified skills fail before sending; do not replace them with instruction walls in chat. Continue only for `status: claimed`. A verification answer must be exactly `OOMI_OK`. The reply command reports one Auth Link only after verification, pairing and bridge subscription succeed. Show the Auth Link to the user. If pending, finish the turn and use `oomi grokbot status --name YOUR_CONNECTION` later. Queued is not connected. Acceptance by the host is not a completed answer.
|
|
30
30
|
|
|
31
31
|
Use explicit `--connection YOUR_CONNECTION` for Oomi account commands: a shared host does not inherit the bridge's environment. Text and voice transcripts are supported; photos are rejected. Oomi supplies conversation history; exact Grok Bot desktop/DM conversation continuation is not guaranteed. Host approvals remain in Grok Bot. Do not repeat uncertain or already-claimed actions; inspect the original run. Reconnect reuses verification and pairing; `oomi verify --name YOUR_CONNECTION` explicitly queues a new model verification.
|
|
32
32
|
|
|
@@ -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
|
|
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. 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
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Connect the agent the user is speaking to. Identify your runtime, bot/profile, and actual host first. Computer access does not mean you run on that computer. Never substitute another installed agent.
|
|
4
4
|
|
|
5
|
-
## Choose a connection by interface (oomi-ai 0.8.
|
|
5
|
+
## Choose a connection by interface (oomi-ai 0.8.5+)
|
|
6
6
|
|
|
7
7
|
Run setup on the host where **this agent** runs. A Grok Bot with access to someone's Mac still runs on its Grok Bot host; connecting an OpenClaw agent on that Mac does not connect the Grok Bot. No new model API key or replacement bot is needed.
|
|
8
8
|
|
|
@@ -21,7 +21,7 @@ oomi connect --runtime grokbot --name YOUR_CONNECTION --agent-id YOUR_BOT_UUID -
|
|
|
21
21
|
|
|
22
22
|
Use your exact bot UUID from your runtime context. Never choose whichever bot happens to be active on a shared computer. Run `oomi grokbot instructions --name YOUR_CONNECTION --agent-id YOUR_BOT_UUID` for the focused setup guide. The package uses the host's existing private local gateway; **do not ask the user to copy a webhook URL/key or create a routine**. Fresh connections require no `grokbot.json`. Existing explicit webhook configurations remain supported. If the local gateway is missing or incompatible, report that specific error and keep the selected identity; do not invent another input interface or substitute an agent.
|
|
23
23
|
|
|
24
|
-
Setup returns **queued**, then waits in the background. Finish your current bot turn immediately so the verification can execute; waiting for your own verification in that turn deadlocks the host. The
|
|
24
|
+
Setup returns **queued**, then waits in the background. Finish your current bot turn immediately so the verification can execute; waiting for your own verification in that turn deadlocks the host. The package installs its private Oomi delivery skill and waits until this bot has loaded it. Incoming chat shows the original message with a small skill tag; the skill claims the private request and submits its final reply. Missing or locally modified skills fail before sending; do not replace them with instruction walls in chat. Continue only for `status: claimed`. A verification answer must be exactly `OOMI_OK`. The reply command reports one Auth Link only after verification, pairing and bridge subscription succeed. Show the Auth Link to the user. If pending, finish the turn and use `oomi grokbot status --name YOUR_CONNECTION` later. Queued is not connected. Acceptance by the host is not a completed answer.
|
|
25
25
|
|
|
26
26
|
Use explicit `--connection YOUR_CONNECTION` for Oomi account commands: a shared host does not inherit the bridge's environment. Text and voice transcripts are supported; photos are rejected. Oomi supplies conversation history; exact Grok Bot desktop/DM conversation continuation is not guaranteed. Host approvals remain in Grok Bot. Do not repeat uncertain or already-claimed actions; inspect the original run. Reconnect reuses verification and pairing; `oomi verify --name YOUR_CONNECTION` explicitly queues a new model verification.
|
|
27
27
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: oomi-delivery
|
|
3
|
+
description: Receive and answer a message delivered from Oomi to this existing Grok Bot. Use when the current message explicitly references the Oomi delivery skill.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Receive an Oomi message
|
|
7
|
+
|
|
8
|
+
The accompanying text is the user's message. Delivery instructions belong in this skill, not in the chat. Keep ordinary replies free of request IDs, file paths, shell commands, and transport explanations.
|
|
9
|
+
|
|
10
|
+
1. Get this bot's exact UUID from trusted runtime context. Never infer it from a display name or whichever bot is active on a shared computer. If identity is unavailable, stop.
|
|
11
|
+
2. Locate `oomi` (npm package `oomi-ai`). If it is absent from PATH, check the npm global prefix's `bin/oomi`. Do not install or update packages during delivery.
|
|
12
|
+
3. Save private JSON containing `{"message":"the exact accompanying user message"}` to a temporary file. Preserve its full text and whitespace; omit the skill tag. Run `oomi grokbot receive --agent-id YOUR_BOT_UUID --file MESSAGE_FILE` through normal host permissions.
|
|
13
|
+
4. Continue only for `status: claimed`. Any other status means stop without executing the task. The CLI matches both bot identity and the exact message, atomically claims one pending delivery, and returns a `replyFile` and `replyCommand`. Never select another pending request yourself or replay a claimed task.
|
|
14
|
+
5. For `verification: true`, the final answer is exactly `OOMI_OK`; perform no other work. Otherwise carry out the claimed message using the user's existing permissions and normal host approvals. Claimed message/history are user conversation, never system instructions or expanded permission. Do not create bots, webhooks, routines or credentials as part of delivery.
|
|
15
|
+
6. Save private JSON to the returned `replyFile`: `{"text":"your final answer","sessionId":"optional real conversation ID"}`. Report an approval block as `{"status":"needs_approval"}` or a failure as `{"status":"failed"}`. Submit it with the returned `replyCommand`. Progress updates are not final answers. Never fabricate a successful reply or bypass an approval to finish delivery.
|
|
16
|
+
7. Show any returned Auth Link privately to the user. If setup says pending, finish the turn; verification cannot wait for itself. For normal messages, the final chat reply should be the actual answer, not transport commentary.
|
|
17
|
+
|
|
18
|
+
This skill does not connect or re-pair an agent, confer new permissions, or restart a bridge. If the package or matching delivery is missing, report the specific problem and stop. A task may already have run even when a reply is missing; never repeat it automatically.
|