oomi-ai 0.8.0 → 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 +19 -4
- package/agent_instructions.md +18 -3
- package/bin/oomi-ai.js +78 -11
- package/lib/bridgeMaintenance.js +64 -0
- package/lib/bridgeService.js +9 -2
- package/lib/hermesGateway.js +184 -0
- package/lib/localAgent.js +38 -7
- package/package.json +5 -3
- package/skills/oomi/SKILL.md +18 -3
- package/skills/oomi/agent_instructions.md +18 -3
package/README.md
CHANGED
|
@@ -48,9 +48,22 @@ 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 and profile first. Never choose OpenClaw merely because it is installed on the same machine as Hermes.
|
|
50
50
|
|
|
51
|
+
## Updating Oomi safely
|
|
52
|
+
|
|
53
|
+
When the user asks you to update Oomi, read `oomi instructions` first; it supersedes copied setup/restart recipes. Keep the same connection on the same computer.
|
|
54
|
+
|
|
55
|
+
1. Run `oomi update --connection NAME` (omit the selector for default OpenClaw). This only checks versions. If already current, report that and stop: do not reinstall or restart "to be sure."
|
|
56
|
+
2. If an update exists, run the returned exact-version npm install command **alone**, through the runtime's normal approval flow. Do not append redirections, status checks or restarts. If installation is denied, fails, or cannot be verified, leave the bridge alone and explain the approval/error. Never disable security checks, retry through another executor, or enable blanket unattended approvals.
|
|
57
|
+
3. After successful installation, check `npm list -g oomi-ai --depth=0` and `oomi update` separately. Only then run `oomi bridge repair --connection NAME`.
|
|
58
|
+
4. A running 0.8.1+ supervised bridge queues its restart until active replies finish and queued phone replies are saved. If the result is `queued`, finish your reply now; do not wait for your own turn to end, poll in a loop, or claim completion. `oomi status --connection NAME --json` subsequently reports maintenance `completed`/`failed` and the running `packageVersion`. Pairing and conversations stay intact.
|
|
59
|
+
|
|
60
|
+
For packages before 0.8.1 (no `oomi update` command), read the published guide, check `npm list -g oomi-ai --depth=0` and `npm view oomi-ai version` in separate commands. If equal, stop. Otherwise install the exact returned version through normal approvals. The first restart of an older or unsupervised bridge must happen from an independent host terminal/agent session, outside the Oomi conversation it would terminate. New CLI commands refuse to stop their own older host. After that bootstrap, in-chat restarts use the deferred path.
|
|
61
|
+
|
|
62
|
+
Hermes single-query mode has no interactive approval surface. If its package/security check requires a human, tell the user to approve/run the exact install in an approval-capable host session, then continue there. A blocked install is not a reason to restart. Oomi does not override host permissions. `bridge repair` is also available for an actual connection fault when no package update is needed.
|
|
63
|
+
|
|
51
64
|
## Standard Connect Flow
|
|
52
65
|
|
|
53
|
-
|
|
66
|
+
For a new installation (for an existing installation, use the safe update flow below):
|
|
54
67
|
|
|
55
68
|
```bash
|
|
56
69
|
npm install -g oomi-ai@latest
|
|
@@ -69,7 +82,9 @@ oomi connect --runtime hermes --name hermes --app-url https://www.oomi.ai
|
|
|
69
82
|
oomi connect --runtime command --name my-agent --command '["/absolute/path/to/adapter"]' --app-url https://www.oomi.ai
|
|
70
83
|
```
|
|
71
84
|
|
|
72
|
-
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.
|
|
73
88
|
|
|
74
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.
|
|
75
90
|
|
|
@@ -129,7 +144,7 @@ oomi invite --name hermes --app-url https://www.oomi.ai
|
|
|
129
144
|
|
|
130
145
|
Do not pair again to refresh an expired Auth Link. Re-running `connect` reuses saved pairing and verification; only the first connection or an explicit `oomi verify` sends a model probe. A different command, profile, or working directory requires a new connection name, so one agent cannot silently replace another. Named connections store private state and logs under `~/.oomi/connections/<name>`; default OpenClaw keeps `~/.openclaw`. Since 0.8.0, `connect` installs a separate automatic-start service for each connection: macOS launchd or Linux systemd user. It restarts an exited worker and starts at the next login/user session. Linux servers can start at boot and remain running after logout when the host administrator enables user lingering (`loginctl enable-linger <user>`); `oomi bridge service status --name hermes` reports whether this is enabled. WSL needs systemd and a running WSL instance; native Windows does not yet have an automatic service installer. A sleeping, powered-off, or disconnected computer remains unavailable.
|
|
131
146
|
|
|
132
|
-
Run `oomi bridge list` to find saved connection names and their startup protection on this computer. To
|
|
147
|
+
Run `oomi bridge list` to find saved connection names and their startup protection on this computer. To update an existing connection on **each computer**, follow Updating Oomi safely above. Repair preserves pairing and conversations. On a running 0.8.1+ supervised bridge it queues a safe restart and reports pending work; on a stopped bridge it installs/starts supervision and waits for the broker connection. It does not send a model probe or switch agents. Do not copy connection credentials between computers.
|
|
133
148
|
|
|
134
149
|
Use `oomi bridge service status --name hermes --json` to inspect startup protection. `oomi bridge stop --name hermes` disables automatic startup and stops only that connection; `oomi bridge start --name hermes` enables it again. `oomi bridge service uninstall --name hermes` removes startup management without deleting saved state. The legacy `oomi openclaw bridge service` command remains supported.
|
|
135
150
|
|
|
@@ -333,7 +348,7 @@ STT works but the assistant does not reply:
|
|
|
333
348
|
|
|
334
349
|
Bridge keeps using an old package after update:
|
|
335
350
|
|
|
336
|
-
- Action:
|
|
351
|
+
- Action: follow Updating Oomi safely above, then inspect `oomi status --json` for running version and maintenance result.
|
|
337
352
|
- If still stale: run `oomi bridge repair` for default OpenClaw, or add the existing `--name` for a named connection.
|
|
338
353
|
- On macOS service installs: run `oomi openclaw bridge service restart`.
|
|
339
354
|
- Confirm with `oomi openclaw bridge ps`.
|
package/agent_instructions.md
CHANGED
|
@@ -101,9 +101,22 @@ Owner-originated identity changes are a deliberate exception: one notice per ide
|
|
|
101
101
|
|
|
102
102
|
The legacy persona-app commands below remain compatibility tools; use Spaces for new agent-composed personal views.
|
|
103
103
|
|
|
104
|
+
## Updating Oomi safely
|
|
105
|
+
|
|
106
|
+
When the user asks you to update Oomi, read `oomi instructions` first; it supersedes copied setup/restart recipes. Keep the same connection on the same computer.
|
|
107
|
+
|
|
108
|
+
1. Run `oomi update --connection NAME` (omit the selector for default OpenClaw). This only checks versions. If already current, report that and stop: do not reinstall or restart "to be sure."
|
|
109
|
+
2. If an update exists, run the returned exact-version npm install command **alone**, through the runtime's normal approval flow. Do not append redirections, status checks or restarts. If installation is denied, fails, or cannot be verified, leave the bridge alone and explain the approval/error. Never disable security checks, retry through another executor, or enable blanket unattended approvals.
|
|
110
|
+
3. After successful installation, check `npm list -g oomi-ai --depth=0` and `oomi update` separately. Only then run `oomi bridge repair --connection NAME`.
|
|
111
|
+
4. A running 0.8.1+ supervised bridge queues its restart until active replies finish and queued phone replies are saved. If the result is `queued`, finish your reply now; do not wait for your own turn to end, poll in a loop, or claim completion. `oomi status --connection NAME --json` subsequently reports maintenance `completed`/`failed` and the running `packageVersion`. Pairing and conversations stay intact.
|
|
112
|
+
|
|
113
|
+
For packages before 0.8.1 (no `oomi update` command), read the published guide, check `npm list -g oomi-ai --depth=0` and `npm view oomi-ai version` in separate commands. If equal, stop. Otherwise install the exact returned version through normal approvals. The first restart of an older or unsupervised bridge must happen from an independent host terminal/agent session, outside the Oomi conversation it would terminate. New CLI commands refuse to stop their own older host. After that bootstrap, in-chat restarts use the deferred path.
|
|
114
|
+
|
|
115
|
+
Hermes single-query mode has no interactive approval surface. If its package/security check requires a human, tell the user to approve/run the exact install in an approval-capable host session, then continue there. A blocked install is not a reason to restart. Oomi does not override host permissions. `bridge repair` is also available for an actual connection fault when no package update is needed.
|
|
116
|
+
|
|
104
117
|
## Standard Connect Flow
|
|
105
118
|
|
|
106
|
-
|
|
119
|
+
For a new installation (for an existing installation, use the safe update flow below):
|
|
107
120
|
|
|
108
121
|
```bash
|
|
109
122
|
npm install -g oomi-ai@latest
|
|
@@ -122,7 +135,9 @@ oomi connect --runtime hermes --name hermes --app-url https://www.oomi.ai
|
|
|
122
135
|
oomi connect --runtime command --name my-agent --command '["/absolute/path/to/adapter"]' --app-url https://www.oomi.ai
|
|
123
136
|
```
|
|
124
137
|
|
|
125
|
-
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.
|
|
126
141
|
|
|
127
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.
|
|
128
143
|
|
|
@@ -182,7 +197,7 @@ oomi invite --name hermes --app-url https://www.oomi.ai
|
|
|
182
197
|
|
|
183
198
|
Do not pair again to refresh an expired Auth Link. Re-running `connect` reuses saved pairing and verification; only the first connection or an explicit `oomi verify` sends a model probe. A different command, profile, or working directory requires a new connection name, so one agent cannot silently replace another. Named connections store private state and logs under `~/.oomi/connections/<name>`; default OpenClaw keeps `~/.openclaw`. Since 0.8.0, `connect` installs a separate automatic-start service for each connection: macOS launchd or Linux systemd user. It restarts an exited worker and starts at the next login/user session. Linux servers can start at boot and remain running after logout when the host administrator enables user lingering (`loginctl enable-linger <user>`); `oomi bridge service status --name hermes` reports whether this is enabled. WSL needs systemd and a running WSL instance; native Windows does not yet have an automatic service installer. A sleeping, powered-off, or disconnected computer remains unavailable.
|
|
184
199
|
|
|
185
|
-
Run `oomi bridge list` to find saved connection names and their startup protection on this computer. To
|
|
200
|
+
Run `oomi bridge list` to find saved connection names and their startup protection on this computer. To update an existing connection on **each computer**, follow Updating Oomi safely above. Repair preserves pairing and conversations. On a running 0.8.1+ supervised bridge it queues a safe restart and reports pending work; on a stopped bridge it installs/starts supervision and waits for the broker connection. It does not send a model probe or switch agents. Do not copy connection credentials between computers.
|
|
186
201
|
|
|
187
202
|
Use `oomi bridge service status --name hermes --json` to inspect startup protection. `oomi bridge stop --name hermes` disables automatic startup and stops only that connection; `oomi bridge start --name hermes` enables it again. `oomi bridge service uninstall --name hermes` removes startup management without deleting saved state. The legacy `oomi openclaw bridge service` command remains supported.
|
|
188
203
|
|
package/bin/oomi-ai.js
CHANGED
|
@@ -8,6 +8,7 @@ import net from 'net';
|
|
|
8
8
|
import { lookup as dnsLookup } from 'dns/promises';
|
|
9
9
|
import { fileURLToPath } from 'url';
|
|
10
10
|
import WebSocket from 'ws';
|
|
11
|
+
import { planPackageUpdate, isWorkerDescendant, queueRestart, readMaintenance, createMaintenanceController } from '../lib/bridgeMaintenance.js';
|
|
11
12
|
import { bridgeServiceConfig, createBridgeService, renderLaunchAgent } from '../lib/bridgeService.js';
|
|
12
13
|
import { LOCAL_PROTOCOL_MIN, LOCAL_PROTOCOL_MAX, probeGateway } from '../lib/gatewayProbe.js';
|
|
13
14
|
import { LocalAgentRuntime, localAgentConfig, runLocalAgent, writePrivateJson, startChannelWorker } from '../lib/localAgent.js';
|
|
@@ -193,8 +194,7 @@ async function maybeNotifyUpdate(command) {
|
|
|
193
194
|
|
|
194
195
|
if (compareVersions(currentVersion, latestVersion) < 0) {
|
|
195
196
|
console.warn(`[oomi] Update available: oomi-ai ${currentVersion} -> ${latestVersion}`);
|
|
196
|
-
console.warn('[oomi]
|
|
197
|
-
console.warn('[oomi] Fallback update command: npm install -g oomi-ai@latest');
|
|
197
|
+
console.warn('[oomi] Read the safe update plan: oomi update');
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
|
|
@@ -202,6 +202,8 @@ function usage() {
|
|
|
202
202
|
console.log(`oomi <command>
|
|
203
203
|
|
|
204
204
|
Commands:
|
|
205
|
+
update
|
|
206
|
+
Check for a newer package and return a safe, approval-preserving update plan.
|
|
205
207
|
connect --runtime openclaw|hermes|command [--name CONNECTION] [--agent-name "Your name"] [--app-url URL]
|
|
206
208
|
Verify the selected local agent, pair once, start its bridge, and return an Auth Link.
|
|
207
209
|
Hermes: --profile PROFILE and --resume SESSION select the intended agent/session.
|
|
@@ -704,6 +706,9 @@ async function handleContextHealthSyncCommand(flags = {}) {
|
|
|
704
706
|
async function refreshBridgeForUpdate(flags = {}, options = {}) {
|
|
705
707
|
const logger = options.logger || null;
|
|
706
708
|
const service = currentBridgeService();
|
|
709
|
+
const deferred = deferLiveRestart({ print: false });
|
|
710
|
+
if (deferred) return { queued: true, maintenance: deferred };
|
|
711
|
+
assertIndependentMaintenance();
|
|
707
712
|
if (service.status().installed) {
|
|
708
713
|
logger?.(`Restarting supervised bridge ${service.config.target}.`);
|
|
709
714
|
await stopSupervisedBridge(service);
|
|
@@ -771,6 +776,7 @@ async function handleOpenclawRefreshCommand(flags = {}) {
|
|
|
771
776
|
return;
|
|
772
777
|
}
|
|
773
778
|
|
|
779
|
+
if (payload.bridge.queued) { console.log(payload.bridge.maintenance.message); return; }
|
|
774
780
|
console.log(`Oomi refresh complete (${payload.currentVersion})`);
|
|
775
781
|
if (payload.bridge.restarted) {
|
|
776
782
|
console.log(`Bridge restarted (${payload.bridge.mode}).`);
|
|
@@ -2582,15 +2588,24 @@ async function startOpenclawBridge(flags) {
|
|
|
2582
2588
|
consecutiveFailures: 0,
|
|
2583
2589
|
pid: process.pid,
|
|
2584
2590
|
startedAt: bridgeNowIso(),
|
|
2591
|
+
packageVersion: currentPackageVersion(),
|
|
2592
|
+
maintenanceProtocol: 1,
|
|
2585
2593
|
});
|
|
2586
2594
|
|
|
2595
|
+
try { refreshSelectedSkill(bridgeState); } catch (err) { console.warn('Oomi skill refresh failed:', err.message); }
|
|
2587
2596
|
console.log(`Starting ${bridgeState.runtime || 'openclaw'} bridge: device=${deviceId}`);
|
|
2597
|
+
let backgroundTurns = 0;
|
|
2598
|
+
const deliverAgentEvent = async (turn) => {
|
|
2599
|
+
backgroundTurns++;
|
|
2600
|
+
try { return await (localRuntime ? localRuntime.turn(turn) : probeGateway({ gateway, deviceIdentity: gatewayDeviceIdentity, prepareFrame: prepareGatewayFrameForLocalGateway, turn, timeoutMs: 180000 })); }
|
|
2601
|
+
finally { backgroundTurns--; }
|
|
2602
|
+
};
|
|
2588
2603
|
const stopChannelWorker = localRuntime ? startChannelWorker(bridgeState, localRuntime) : () => {};
|
|
2589
2604
|
const stopIdentityWorker = startIdentityWorker({
|
|
2590
2605
|
state: { ...bridgeState, brokerHttp, deviceToken },
|
|
2591
2606
|
receiptPath: path.join(path.dirname(resolveBridgeStatePath()), 'oomi-identity-receipt.json'),
|
|
2592
2607
|
// Applying a specific settings revision is idempotent, unlike a user turn.
|
|
2593
|
-
deliver:
|
|
2608
|
+
deliver: deliverAgentEvent,
|
|
2594
2609
|
acknowledgeCommand: (identity) => `oomi identity acknowledge${bridgeConnectionName ? ` --connection ${bridgeConnectionName}` : ''} --identity-id ${identity.identityId} --revision ${identity.revision}`,
|
|
2595
2610
|
onError: () => updateBridgeStatus({ identitySyncError: 'Identity notice was interrupted. Read oomi identity get in your agent to apply and acknowledge the pending revision; no model retry was sent.' }),
|
|
2596
2611
|
});
|
|
@@ -2598,7 +2613,7 @@ async function startOpenclawBridge(flags) {
|
|
|
2598
2613
|
state: { ...bridgeState, brokerHttp, deviceToken },
|
|
2599
2614
|
connection: bridgeConnectionName,
|
|
2600
2615
|
receiptPath: path.join(path.dirname(resolveBridgeStatePath()), 'oomi-routine-receipt.json'),
|
|
2601
|
-
deliver:
|
|
2616
|
+
deliver: deliverAgentEvent,
|
|
2602
2617
|
onError: () => updateBridgeStatus({ routineSyncError: 'Routine updates are waiting for the runtime. They will retry.' }),
|
|
2603
2618
|
});
|
|
2604
2619
|
console.log(`Local gateway: ${gateway.gatewayUrl}`);
|
|
@@ -2608,6 +2623,19 @@ async function startOpenclawBridge(flags) {
|
|
|
2608
2623
|
);
|
|
2609
2624
|
|
|
2610
2625
|
const activeGatewaySockets = new Map();
|
|
2626
|
+
const activeGatewayRuns = new Set();
|
|
2627
|
+
let currentBrokerSocket;
|
|
2628
|
+
const maintenanceTick = createMaintenanceController({
|
|
2629
|
+
directory: path.dirname(resolveBridgeStatePath()),
|
|
2630
|
+
isIdle: () => !backgroundTurns && !localRuntime?.active.size && !localRuntime?.deliveries && !activeGatewayRuns.size
|
|
2631
|
+
&& !currentBrokerSocket?.bufferedAmount
|
|
2632
|
+
&& [...activeGatewaySockets.values()].every((session) => !session.pendingRequests?.size),
|
|
2633
|
+
isConnected: () => readBridgeStatus().status === 'connected',
|
|
2634
|
+
restart: () => currentBridgeService().restart(),
|
|
2635
|
+
});
|
|
2636
|
+
const maintenanceTimer = setInterval(() => {
|
|
2637
|
+
try { maintenanceTick(); } catch (err) { console.error('[bridge] maintenance:', err.message); }
|
|
2638
|
+
}, 250);
|
|
2611
2639
|
const reconnectState = {
|
|
2612
2640
|
attempt: 0,
|
|
2613
2641
|
timer: null,
|
|
@@ -2960,6 +2988,7 @@ async function startOpenclawBridge(flags) {
|
|
|
2960
2988
|
wsUrl.searchParams.set('token', deviceToken);
|
|
2961
2989
|
|
|
2962
2990
|
const brokerSocket = new WebSocket(wsUrl.toString());
|
|
2991
|
+
currentBrokerSocket = brokerSocket;
|
|
2963
2992
|
let actionCableHeartbeat = null;
|
|
2964
2993
|
|
|
2965
2994
|
const clearChallengeTimer = (sessionBridge) => {
|
|
@@ -3322,10 +3351,16 @@ async function startOpenclawBridge(flags) {
|
|
|
3322
3351
|
}
|
|
3323
3352
|
}
|
|
3324
3353
|
|
|
3354
|
+
if (gatewayPayload?.type === 'res' && gatewayPayload.ok && gatewayPayload.payload?.status === 'started' && gatewayPayload.payload?.runId) {
|
|
3355
|
+
activeGatewayRuns.add(gatewayPayload.payload.runId);
|
|
3356
|
+
}
|
|
3357
|
+
if (gatewayPayload?.event === 'chat' && ['final', 'error', 'aborted'].includes(gatewayPayload.payload?.state)) {
|
|
3358
|
+
activeGatewayRuns.delete(gatewayPayload.payload.runId);
|
|
3359
|
+
}
|
|
3325
3360
|
if (gatewayPayload?.event === 'chat' && gatewayPayload.payload?.state === 'final') {
|
|
3326
3361
|
updateBridgeStatus({ runtimeReady: true, lastReplyAt: bridgeNowIso() });
|
|
3327
3362
|
} else if (gatewayPayload?.event === 'chat' && gatewayPayload.payload?.state === 'error') {
|
|
3328
|
-
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.' });
|
|
3329
3364
|
}
|
|
3330
3365
|
|
|
3331
3366
|
if (isGatewayRunStartedFrame(frame)) {
|
|
@@ -3775,6 +3810,7 @@ async function startOpenclawBridge(flags) {
|
|
|
3775
3810
|
|
|
3776
3811
|
const markStopped = async (signal) => {
|
|
3777
3812
|
reconnectState.stopped = true;
|
|
3813
|
+
clearInterval(maintenanceTimer);
|
|
3778
3814
|
stopChannelWorker();
|
|
3779
3815
|
stopIdentityWorker();
|
|
3780
3816
|
stopRoutineWorker();
|
|
@@ -3898,11 +3934,6 @@ async function pairAndStartOpenclawBridge(flags) {
|
|
|
3898
3934
|
console.log('');
|
|
3899
3935
|
console.log('Share this exact response with the user (no extra text):');
|
|
3900
3936
|
console.log('----------------------------------------');
|
|
3901
|
-
const skillSource = resolveSkillSource();
|
|
3902
|
-
if (runtime === 'hermes') installSkill(skillSource, [path.join(state.localAgent.hermesHome, ...(state.localAgent.profile === 'default' ? [] : ['profiles', state.localAgent.profile]), 'skills')]);
|
|
3903
|
-
else if (runtime === 'openclaw') installSkill(skillSource, [resolveOpenclawSkillsDir()]);
|
|
3904
|
-
else if (flags['skills-dir']) installSkill(skillSource, [flags['skills-dir']]);
|
|
3905
|
-
console.log('Oomi tools: run oomi capabilities, then oomi instructions. Add --connection ' + (bridgeConnectionName || '(omit for default)') + ' to commands. Passive updates do not invoke your model.');
|
|
3906
3937
|
console.log('Oomi Connect Ready');
|
|
3907
3938
|
console.log(`Auth Link: ${inviteUrl}`);
|
|
3908
3939
|
console.log('----------------------------------------');
|
|
@@ -4019,6 +4050,7 @@ function printOpenclawBridgeStatus(flags) {
|
|
|
4019
4050
|
},
|
|
4020
4051
|
runtime: runtimeStatus,
|
|
4021
4052
|
supervision: currentBridgeService().status(),
|
|
4053
|
+
maintenance: readMaintenance(path.dirname(resolveBridgeStatePath())),
|
|
4022
4054
|
};
|
|
4023
4055
|
|
|
4024
4056
|
if (jsonOutput) {
|
|
@@ -4026,6 +4058,7 @@ function printOpenclawBridgeStatus(flags) {
|
|
|
4026
4058
|
return;
|
|
4027
4059
|
}
|
|
4028
4060
|
|
|
4061
|
+
if (payload.maintenance) console.log(`Maintenance: ${payload.maintenance.status} — ${payload.maintenance.message || ''}`);
|
|
4029
4062
|
console.log('Oomi Bridge Status');
|
|
4030
4063
|
const supervision = payload.supervision;
|
|
4031
4064
|
console.log(`Automatic startup: ${supervision.installed && supervision.enabled ? supervision.manager + ' (' + supervision.startsAt + ')' : 'off'}`);
|
|
@@ -4168,7 +4201,25 @@ function requireSavedBridge() {
|
|
|
4168
4201
|
if (!readBridgeState().deviceToken) throw new Error('No saved connection. Run oomi connect with an explicit runtime first.');
|
|
4169
4202
|
}
|
|
4170
4203
|
|
|
4204
|
+
function assertIndependentMaintenance() {
|
|
4205
|
+
const running = findRunningBridgeProcess();
|
|
4206
|
+
if (running && isWorkerDescendant(running.pid)) {
|
|
4207
|
+
throw new Error('This command would stop the agent carrying this conversation. Use bridge repair/restart with a running 0.8.1+ bridge, or perform the first upgrade from an independent host terminal. The bridge was left unchanged.');
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4210
|
+
|
|
4211
|
+
function deferLiveRestart({ print = true } = {}) {
|
|
4212
|
+
const status = readBridgeStatus();
|
|
4213
|
+
const service = currentBridgeService().status();
|
|
4214
|
+
if (status.maintenanceProtocol !== 1 || !status.pid || !isBridgeProcess(status.pid)
|
|
4215
|
+
|| !service.running || service.pid !== status.pid || !service.enabled) return false;
|
|
4216
|
+
const request = queueRestart(path.dirname(resolveBridgeStatePath()), status.pid);
|
|
4217
|
+
if (print) console.log(JSON.stringify(request, null, 2));
|
|
4218
|
+
return request;
|
|
4219
|
+
}
|
|
4220
|
+
|
|
4171
4221
|
async function stopSupervisedBridge(service) {
|
|
4222
|
+
assertIndependentMaintenance();
|
|
4172
4223
|
// Unload/disable before signalling the worker, otherwise KeepAlive immediately recreates it.
|
|
4173
4224
|
service.stop();
|
|
4174
4225
|
const stopped = await stopBridgeProcesses();
|
|
@@ -4187,6 +4238,8 @@ async function handleBridgeServiceCommand(actionRaw = '', flags = {}) {
|
|
|
4187
4238
|
throw new Error('Use: oomi bridge service [install|start|stop|restart|status|uninstall] [--name NAME]');
|
|
4188
4239
|
}
|
|
4189
4240
|
if (['install', 'start', 'restart'].includes(action)) requireSavedBridge();
|
|
4241
|
+
if (['install', 'restart'].includes(action) && !isTruthyFlag(flags['no-start']) && deferLiveRestart()) return;
|
|
4242
|
+
if (action !== 'start') assertIndependentMaintenance();
|
|
4190
4243
|
if (action === 'install') {
|
|
4191
4244
|
service.install({ noStart: true });
|
|
4192
4245
|
await stopSupervisedBridge(service);
|
|
@@ -4209,6 +4262,7 @@ async function handleBridgeServiceCommand(actionRaw = '', flags = {}) {
|
|
|
4209
4262
|
|
|
4210
4263
|
async function repairBridge(flags = {}) {
|
|
4211
4264
|
requireSavedBridge();
|
|
4265
|
+
if (!isTruthyFlag(flags['no-start']) && deferLiveRestart()) return;
|
|
4212
4266
|
await handleBridgeServiceCommand('install', flags);
|
|
4213
4267
|
if (!isTruthyFlag(flags['no-start'])) {
|
|
4214
4268
|
await waitForBridgeConnection({});
|
|
@@ -4291,6 +4345,8 @@ async function handleBridgeLifecycleCommand(flags = {}, actionRaw = '') {
|
|
|
4291
4345
|
return handleBridgeServiceCommand(action, flags);
|
|
4292
4346
|
}
|
|
4293
4347
|
|
|
4348
|
+
if (['stop', 'restart'].includes(action)) assertIndependentMaintenance();
|
|
4349
|
+
|
|
4294
4350
|
if (action === 'start' || action === 'ensure') {
|
|
4295
4351
|
await startBridgeLifecycle(flags);
|
|
4296
4352
|
return;
|
|
@@ -4364,7 +4420,16 @@ async function verifyLocalAgent(state) {
|
|
|
4364
4420
|
return { runtime: state.runtime || 'openclaw', ...result };
|
|
4365
4421
|
}
|
|
4366
4422
|
|
|
4423
|
+
function refreshSelectedSkill(state, flags = {}) {
|
|
4424
|
+
const source = resolveSkillSource();
|
|
4425
|
+
if (state.runtime === 'hermes' && state.localAgent?.hermesHome) {
|
|
4426
|
+
installSkill(source, [path.join(state.localAgent.hermesHome, ...(state.localAgent.profile === 'default' ? [] : ['profiles', state.localAgent.profile]), 'skills')]);
|
|
4427
|
+
} else if (!state.runtime || state.runtime === 'openclaw') installSkill(source, [resolveOpenclawSkillsDir()]);
|
|
4428
|
+
else if (flags['skills-dir']) installSkill(source, [flags['skills-dir']]);
|
|
4429
|
+
}
|
|
4430
|
+
|
|
4367
4431
|
async function connectLocalAgent(flags) {
|
|
4432
|
+
assertIndependentMaintenance();
|
|
4368
4433
|
const agentName = flags['agent-name'] === undefined ? undefined : validateAgentName(flags['agent-name']);
|
|
4369
4434
|
const runtime = String(flags.runtime || '');
|
|
4370
4435
|
if (!['openclaw', 'hermes', 'command'].includes(runtime)) throw new Error('Specify --runtime openclaw, hermes, or command. Never select a different agent just because it is installed.');
|
|
@@ -4436,6 +4501,7 @@ async function connectLocalAgent(flags) {
|
|
|
4436
4501
|
console.log(unmanaged ? 'Automatic startup: off (--no-service); your host supervisor must manage recovery.'
|
|
4437
4502
|
: `Automatic startup: ${service.config.platform === 'darwin' ? 'macOS login service' : 'Linux user service'}; restarts after worker exits.`);
|
|
4438
4503
|
const invite = await requestConnectInviteLink({ backendHttp: state.brokerHttp, appUrl, sessionKey: state.sessionKey, deviceToken: state.deviceToken });
|
|
4504
|
+
refreshSelectedSkill(state, flags);
|
|
4439
4505
|
console.log(`Verified agent: ${runtime}${state.localAgent?.profile ? ` (${state.localAgent.profile})` : ''}`);
|
|
4440
4506
|
console.log('Oomi Connect Ready');
|
|
4441
4507
|
console.log(`Auth Link: ${invite.inviteUrl}`);
|
|
@@ -4457,9 +4523,10 @@ async function main() {
|
|
|
4457
4523
|
if (connection && !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(connection)) throw new Error('Connection name must use lowercase letters, numbers, underscores, or hyphens.');
|
|
4458
4524
|
bridgeConnectionName = connection || '';
|
|
4459
4525
|
|
|
4526
|
+
if (command === 'update') { console.log(JSON.stringify(planPackageUpdate(currentPackageVersion(), await fetchLatestPublishedVersion('oomi-ai')), null, 2)); return; }
|
|
4460
4527
|
if (command === 'instructions') { console.log(fs.readFileSync(resolveInstructionsFile(), 'utf8')); return; }
|
|
4461
4528
|
if (command === 'capabilities') {
|
|
4462
|
-
console.log(JSON.stringify({ instructions: 'oomi instructions', skill: path.join(PACKAGE_ROOT, 'skills/oomi/SKILL.md'), connectionSelector: '--connection NAME',
|
|
4529
|
+
console.log(JSON.stringify({ instructions: 'oomi instructions', packageUpdate: 'oomi update', skill: path.join(PACKAGE_ROOT, 'skills/oomi/SKILL.md'), connectionSelector: '--connection NAME',
|
|
4463
4530
|
spaces: { catalog: 'oomi spaces catalog', list: 'oomi spaces list', create: 'oomi spaces create --file space.json', update: 'oomi spaces update ID --file change.json', revisions: 'oomi spaces revisions ID', upgrade: 'oomi spaces upgrade ID --file upgrade.json', guide: 'oomi instructions' },
|
|
4464
4531
|
health: { steps: 'oomi context health --window week --metric steps --json', calendarWeek: 'oomi context health --window range --from YYYY-MM-DD --to YYYY-MM-DD --metric steps --json', largeResults: '--output new-file.json', plugins: 'oomi context plugins', missing: 'unknown, never zero', week: 'rolling seven days; use explicit range for calendar week' },
|
|
4465
4532
|
objectives: { list: 'oomi objectives list', show: 'oomi objectives show ID', history: 'oomi objectives history ID [--before YYYY-MM-DD]', create: 'oomi objectives create --file goal.json', update: 'oomi objectives update ID --file change.json', respond: 'oomi objectives respond ID --file response.json', calendar: 'daily/weekly/monthly/once; stored time zone', streaks: 'optional goal references in the Space catalog' },
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { readJson, writePrivateJson } from './localAgent.js';
|
|
5
|
+
|
|
6
|
+
export const maintenancePath = (directory) => path.join(directory, 'oomi-bridge-maintenance.json');
|
|
7
|
+
export const readMaintenance = (directory) => readJson(maintenancePath(directory), null);
|
|
8
|
+
const pending = (request) => ['queued', 'restarting'].includes(request?.status);
|
|
9
|
+
|
|
10
|
+
// This is a plan, never an installer: the runtime keeps its normal package approval gate.
|
|
11
|
+
export function planPackageUpdate(current, latest) {
|
|
12
|
+
const valid = (v) => typeof v === 'string' && /^\d+\.\d+\.\d+$/.test(v);
|
|
13
|
+
if (!valid(current) || !valid(latest)) throw new Error('Could not verify the published version. Leave the package and bridge unchanged; try again later.');
|
|
14
|
+
const a = current.split('.').map(Number), b = latest.split('.').map(Number);
|
|
15
|
+
const difference = b.map((v, i) => v - a[i]).find((v) => v !== 0) || 0;
|
|
16
|
+
return { currentVersion: current, latestVersion: latest, status: difference > 0 ? 'update_available' : 'up_to_date',
|
|
17
|
+
...(difference > 0 ? { installCommand: `npm install -g oomi-ai@${latest}`,
|
|
18
|
+
instruction: 'Run this command alone through your normal approval flow. If blocked or unsuccessful, stop and report the approval needed; do not restart or change security settings. After success, verify the installed version, then run oomi bridge repair for this connection.' }
|
|
19
|
+
: { instruction: 'Already current. Do not reinstall or restart. Check oomi status only if the user also reports a connection problem.' }) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isWorkerDescendant(workerPid, { pid = process.pid, run = spawnSync } = {}) {
|
|
23
|
+
if (!Number.isInteger(workerPid) || workerPid <= 0) return false;
|
|
24
|
+
const result = run('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf8', timeout: 5000 });
|
|
25
|
+
if (result.status !== 0) throw new Error('Cannot establish whether this command would stop its own agent. Run maintenance from an independent host terminal.');
|
|
26
|
+
const parents = new Map(String(result.stdout).trim().split('\n').map((line) => line.trim().split(/\s+/).map(Number)));
|
|
27
|
+
const visited = new Set();
|
|
28
|
+
while (pid > 1 && !visited.has(pid)) {
|
|
29
|
+
if (pid === workerPid) return true;
|
|
30
|
+
visited.add(pid); pid = parents.get(pid);
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function queueRestart(directory, workerPid, now = Date.now()) {
|
|
36
|
+
const previous = readMaintenance(directory);
|
|
37
|
+
if (pending(previous) && previous.workerPid === workerPid && previous.expiresAt > now) return previous;
|
|
38
|
+
const request = { id: randomUUID(), status: 'queued', action: 'restart', workerPid, requestedAt: now, expiresAt: now + 300000,
|
|
39
|
+
message: 'Restart queued. Finish this reply; the bridge will restart after active replies are delivered. Check oomi status afterward; queued is not completed.' };
|
|
40
|
+
writePrivateJson(maintenancePath(directory), request);
|
|
41
|
+
return request;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// One host supervisor owns the replacement. Never disable/unload the service from its own job.
|
|
45
|
+
export function createMaintenanceController({ directory, pid = process.pid, isIdle, isConnected, restart, now = Date.now }) {
|
|
46
|
+
let idleSince = null;
|
|
47
|
+
return () => {
|
|
48
|
+
const request = readMaintenance(directory);
|
|
49
|
+
if (!pending(request)) { idleSince = null; return; }
|
|
50
|
+
const finish = (status, message) => writePrivateJson(maintenancePath(directory), { ...request, status, message, finishedAt: now() });
|
|
51
|
+
if (request.workerPid !== pid) {
|
|
52
|
+
if (isConnected()) finish('completed', 'Bridge restarted and reconnected.');
|
|
53
|
+
else if (now() > request.expiresAt) finish('failed', 'The replacement bridge has not reconnected. Check oomi status and the host connection.');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (now() > request.expiresAt) { finish('failed', 'Restart expired while waiting for replies; no further restart will be attempted.'); return; }
|
|
57
|
+
if (request.status !== 'queued') return;
|
|
58
|
+
if (!isIdle()) { idleSince = null; return; }
|
|
59
|
+
idleSince ??= now();
|
|
60
|
+
if (now() - idleSince < 1000) return;
|
|
61
|
+
writePrivateJson(maintenancePath(directory), { ...request, status: 'restarting' });
|
|
62
|
+
try { restart(); } catch (err) { finish('failed', err.message); }
|
|
63
|
+
};
|
|
64
|
+
}
|
package/lib/bridgeService.js
CHANGED
|
@@ -18,7 +18,7 @@ export function bridgeServiceConfig({ name = '', stateDirectory, logPath, script
|
|
|
18
18
|
if (name && !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) throw new Error('Invalid connection name.');
|
|
19
19
|
const label = `ai.oomi.bridge${name ? '.' + name : ''}`;
|
|
20
20
|
const directory = path.resolve(stateDirectory);
|
|
21
|
-
const environment = { OOMI_SKIP_UPDATE_CHECK: '1', OOMI_CONNECTION: name, PATH: env.PATH || '/usr/local/bin:/usr/bin:/bin',
|
|
21
|
+
const environment = { HOME: home, OOMI_SKIP_UPDATE_CHECK: '1', OOMI_CONNECTION: name, PATH: env.PATH || '/usr/local/bin:/usr/bin:/bin',
|
|
22
22
|
// Always pin the state root. A login service must not resolve another connection from its environment.
|
|
23
23
|
OPENCLAW_HOME: path.resolve(env.OPENCLAW_HOME || path.join(home, '.openclaw')) };
|
|
24
24
|
if (name) environment.OOMI_CONNECTIONS_DIR = path.dirname(directory);
|
|
@@ -158,6 +158,13 @@ export function createBridgeService(config, { run = spawnSync } = {}) {
|
|
|
158
158
|
else if (!current.running) invoke(['kickstart', config.target]);
|
|
159
159
|
} else invoke(['enable', '--now', config.target]);
|
|
160
160
|
}
|
|
161
|
+
function restart() {
|
|
162
|
+
preflight();
|
|
163
|
+
const current = status();
|
|
164
|
+
if (!current.installed || !current.loaded || !current.enabled) throw new Error('Restart requires an enabled, loaded service. Repair it from an independent host terminal.');
|
|
165
|
+
// Submit one supervisor-owned replacement, so killing the caller cannot strand the service.
|
|
166
|
+
invoke(mac ? ['kickstart', '-k', config.target] : ['--no-block', 'restart', config.target]);
|
|
167
|
+
}
|
|
161
168
|
function install({ noStart = false } = {}) {
|
|
162
169
|
assertAvailable();
|
|
163
170
|
writeDefinition();
|
|
@@ -169,5 +176,5 @@ export function createBridgeService(config, { run = spawnSync } = {}) {
|
|
|
169
176
|
if (fs.existsSync(config.file)) fs.unlinkSync(config.file);
|
|
170
177
|
if (!mac) invoke(['daemon-reload']);
|
|
171
178
|
}
|
|
172
|
-
return { config, availability, preflight, status, install, start, stop, uninstall };
|
|
179
|
+
return { config, availability, preflight, status, install, start, stop, restart, uninstall };
|
|
173
180
|
}
|
|
@@ -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] }
|
|
@@ -165,6 +187,7 @@ export class LocalAgentRuntime {
|
|
|
165
187
|
this.onHealth = onHealth;
|
|
166
188
|
this.queue = Promise.resolve();
|
|
167
189
|
this.active = new Map();
|
|
190
|
+
this.deliveries = 0;
|
|
168
191
|
}
|
|
169
192
|
file(sessionKey) { return path.join(this.directory, 'conversations', createHash('sha256').update(sessionKey).digest('hex') + '.json'); }
|
|
170
193
|
history(sessionKey) { return readJson(this.file(sessionKey), { messages: [], receipts: {} }); }
|
|
@@ -188,7 +211,7 @@ export class LocalAgentRuntime {
|
|
|
188
211
|
if (previous?.message !== undefined && previous.message !== message) throw new Error('Idempotency key was reused for a different message.');
|
|
189
212
|
if (previous?.result) return previous.result;
|
|
190
213
|
// A crash after a side effect cannot safely be retried by running the agent again.
|
|
191
|
-
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.');
|
|
192
215
|
if (controller.signal.aborted) throw new Error('Agent turn cancelled.');
|
|
193
216
|
state.receipts[receiptKey] = { message, status: 'running' };
|
|
194
217
|
writePrivateJson(this.file(sessionKey), state);
|
|
@@ -202,7 +225,14 @@ export class LocalAgentRuntime {
|
|
|
202
225
|
writePrivateJson(this.file(sessionKey), state);
|
|
203
226
|
this.onHealth({ runtimeReady: true, lastReplyAt: new Date().toISOString(), lastErrorMessage: '' });
|
|
204
227
|
return result;
|
|
205
|
-
} 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
|
+
}
|
|
206
236
|
finally { if (scratch) fs.rmSync(scratch.directory, { recursive: true, force: true }); }
|
|
207
237
|
});
|
|
208
238
|
this.queue = promise.catch(() => {});
|
|
@@ -251,7 +281,7 @@ class LocalAgentSocket extends EventEmitter {
|
|
|
251
281
|
this.runtime.turn({ ...params, idempotencyKey: runId }).then((result) => {
|
|
252
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() } } });
|
|
253
283
|
}, (err) => {
|
|
254
|
-
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 } : {}) } });
|
|
255
285
|
});
|
|
256
286
|
}
|
|
257
287
|
close(code = 1000, reason = '') { if (this.readyState === 3) return; this.readyState = 3; this.emit('close', code, reason); }
|
|
@@ -269,6 +299,7 @@ export function startChannelWorker(state, runtime) {
|
|
|
269
299
|
return response.json();
|
|
270
300
|
};
|
|
271
301
|
const poll = async () => {
|
|
302
|
+
runtime.deliveries++;
|
|
272
303
|
try {
|
|
273
304
|
const { messages = [] } = await post('poll', { limit: 1 });
|
|
274
305
|
for (const message of messages) {
|
|
@@ -286,7 +317,7 @@ export function startChannelWorker(state, runtime) {
|
|
|
286
317
|
}
|
|
287
318
|
} catch (err) {
|
|
288
319
|
runtime.onHealth({ status: 'degraded', lastErrorMessage: err.message });
|
|
289
|
-
} finally { if (!stopped) timer = setTimeout(poll, 3000); }
|
|
320
|
+
} finally { runtime.deliveries--; if (!stopped) timer = setTimeout(poll, 3000); }
|
|
290
321
|
};
|
|
291
322
|
timer = setTimeout(poll, 0);
|
|
292
323
|
return () => { stopped = true; clearTimeout(timer); for (const entry of runtime.active.values()) entry.controller.abort(); };
|
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",
|
|
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": {
|
|
@@ -77,6 +77,8 @@
|
|
|
77
77
|
"lib/routines.js",
|
|
78
78
|
"lib/spaces.js",
|
|
79
79
|
"lib/objectives.js",
|
|
80
|
-
"lib/bridgeService.js"
|
|
80
|
+
"lib/bridgeService.js",
|
|
81
|
+
"lib/bridgeMaintenance.js",
|
|
82
|
+
"lib/hermesGateway.js"
|
|
81
83
|
]
|
|
82
84
|
}
|
package/skills/oomi/SKILL.md
CHANGED
|
@@ -127,9 +127,22 @@ Owner-originated identity changes are a deliberate exception: one notice per ide
|
|
|
127
127
|
|
|
128
128
|
The legacy persona-app commands below remain compatibility tools; use Spaces for new agent-composed personal views.
|
|
129
129
|
|
|
130
|
+
## Updating Oomi safely
|
|
131
|
+
|
|
132
|
+
When the user asks you to update Oomi, read `oomi instructions` first; it supersedes copied setup/restart recipes. Keep the same connection on the same computer.
|
|
133
|
+
|
|
134
|
+
1. Run `oomi update --connection NAME` (omit the selector for default OpenClaw). This only checks versions. If already current, report that and stop: do not reinstall or restart "to be sure."
|
|
135
|
+
2. If an update exists, run the returned exact-version npm install command **alone**, through the runtime's normal approval flow. Do not append redirections, status checks or restarts. If installation is denied, fails, or cannot be verified, leave the bridge alone and explain the approval/error. Never disable security checks, retry through another executor, or enable blanket unattended approvals.
|
|
136
|
+
3. After successful installation, check `npm list -g oomi-ai --depth=0` and `oomi update` separately. Only then run `oomi bridge repair --connection NAME`.
|
|
137
|
+
4. A running 0.8.1+ supervised bridge queues its restart until active replies finish and queued phone replies are saved. If the result is `queued`, finish your reply now; do not wait for your own turn to end, poll in a loop, or claim completion. `oomi status --connection NAME --json` subsequently reports maintenance `completed`/`failed` and the running `packageVersion`. Pairing and conversations stay intact.
|
|
138
|
+
|
|
139
|
+
For packages before 0.8.1 (no `oomi update` command), read the published guide, check `npm list -g oomi-ai --depth=0` and `npm view oomi-ai version` in separate commands. If equal, stop. Otherwise install the exact returned version through normal approvals. The first restart of an older or unsupervised bridge must happen from an independent host terminal/agent session, outside the Oomi conversation it would terminate. New CLI commands refuse to stop their own older host. After that bootstrap, in-chat restarts use the deferred path.
|
|
140
|
+
|
|
141
|
+
Hermes single-query mode has no interactive approval surface. If its package/security check requires a human, tell the user to approve/run the exact install in an approval-capable host session, then continue there. A blocked install is not a reason to restart. Oomi does not override host permissions. `bridge repair` is also available for an actual connection fault when no package update is needed.
|
|
142
|
+
|
|
130
143
|
## Standard Connect Flow
|
|
131
144
|
|
|
132
|
-
|
|
145
|
+
For a new installation (for an existing installation, use the safe update flow below):
|
|
133
146
|
|
|
134
147
|
```bash
|
|
135
148
|
npm install -g oomi-ai@latest
|
|
@@ -148,7 +161,9 @@ oomi connect --runtime hermes --name hermes --app-url https://www.oomi.ai
|
|
|
148
161
|
oomi connect --runtime command --name my-agent --command '["/absolute/path/to/adapter"]' --app-url https://www.oomi.ai
|
|
149
162
|
```
|
|
150
163
|
|
|
151
|
-
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.
|
|
152
167
|
|
|
153
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.
|
|
154
169
|
|
|
@@ -208,7 +223,7 @@ oomi invite --name hermes --app-url https://www.oomi.ai
|
|
|
208
223
|
|
|
209
224
|
Do not pair again to refresh an expired Auth Link. Re-running `connect` reuses saved pairing and verification; only the first connection or an explicit `oomi verify` sends a model probe. A different command, profile, or working directory requires a new connection name, so one agent cannot silently replace another. Named connections store private state and logs under `~/.oomi/connections/<name>`; default OpenClaw keeps `~/.openclaw`. Since 0.8.0, `connect` installs a separate automatic-start service for each connection: macOS launchd or Linux systemd user. It restarts an exited worker and starts at the next login/user session. Linux servers can start at boot and remain running after logout when the host administrator enables user lingering (`loginctl enable-linger <user>`); `oomi bridge service status --name hermes` reports whether this is enabled. WSL needs systemd and a running WSL instance; native Windows does not yet have an automatic service installer. A sleeping, powered-off, or disconnected computer remains unavailable.
|
|
210
225
|
|
|
211
|
-
Run `oomi bridge list` to find saved connection names and their startup protection on this computer. To
|
|
226
|
+
Run `oomi bridge list` to find saved connection names and their startup protection on this computer. To update an existing connection on **each computer**, follow Updating Oomi safely above. Repair preserves pairing and conversations. On a running 0.8.1+ supervised bridge it queues a safe restart and reports pending work; on a stopped bridge it installs/starts supervision and waits for the broker connection. It does not send a model probe or switch agents. Do not copy connection credentials between computers.
|
|
212
227
|
|
|
213
228
|
Use `oomi bridge service status --name hermes --json` to inspect startup protection. `oomi bridge stop --name hermes` disables automatic startup and stops only that connection; `oomi bridge start --name hermes` enables it again. `oomi bridge service uninstall --name hermes` removes startup management without deleting saved state. The legacy `oomi openclaw bridge service` command remains supported.
|
|
214
229
|
|
|
@@ -101,9 +101,22 @@ Owner-originated identity changes are a deliberate exception: one notice per ide
|
|
|
101
101
|
|
|
102
102
|
The legacy persona-app commands below remain compatibility tools; use Spaces for new agent-composed personal views.
|
|
103
103
|
|
|
104
|
+
## Updating Oomi safely
|
|
105
|
+
|
|
106
|
+
When the user asks you to update Oomi, read `oomi instructions` first; it supersedes copied setup/restart recipes. Keep the same connection on the same computer.
|
|
107
|
+
|
|
108
|
+
1. Run `oomi update --connection NAME` (omit the selector for default OpenClaw). This only checks versions. If already current, report that and stop: do not reinstall or restart "to be sure."
|
|
109
|
+
2. If an update exists, run the returned exact-version npm install command **alone**, through the runtime's normal approval flow. Do not append redirections, status checks or restarts. If installation is denied, fails, or cannot be verified, leave the bridge alone and explain the approval/error. Never disable security checks, retry through another executor, or enable blanket unattended approvals.
|
|
110
|
+
3. After successful installation, check `npm list -g oomi-ai --depth=0` and `oomi update` separately. Only then run `oomi bridge repair --connection NAME`.
|
|
111
|
+
4. A running 0.8.1+ supervised bridge queues its restart until active replies finish and queued phone replies are saved. If the result is `queued`, finish your reply now; do not wait for your own turn to end, poll in a loop, or claim completion. `oomi status --connection NAME --json` subsequently reports maintenance `completed`/`failed` and the running `packageVersion`. Pairing and conversations stay intact.
|
|
112
|
+
|
|
113
|
+
For packages before 0.8.1 (no `oomi update` command), read the published guide, check `npm list -g oomi-ai --depth=0` and `npm view oomi-ai version` in separate commands. If equal, stop. Otherwise install the exact returned version through normal approvals. The first restart of an older or unsupervised bridge must happen from an independent host terminal/agent session, outside the Oomi conversation it would terminate. New CLI commands refuse to stop their own older host. After that bootstrap, in-chat restarts use the deferred path.
|
|
114
|
+
|
|
115
|
+
Hermes single-query mode has no interactive approval surface. If its package/security check requires a human, tell the user to approve/run the exact install in an approval-capable host session, then continue there. A blocked install is not a reason to restart. Oomi does not override host permissions. `bridge repair` is also available for an actual connection fault when no package update is needed.
|
|
116
|
+
|
|
104
117
|
## Standard Connect Flow
|
|
105
118
|
|
|
106
|
-
|
|
119
|
+
For a new installation (for an existing installation, use the safe update flow below):
|
|
107
120
|
|
|
108
121
|
```bash
|
|
109
122
|
npm install -g oomi-ai@latest
|
|
@@ -122,7 +135,9 @@ oomi connect --runtime hermes --name hermes --app-url https://www.oomi.ai
|
|
|
122
135
|
oomi connect --runtime command --name my-agent --command '["/absolute/path/to/adapter"]' --app-url https://www.oomi.ai
|
|
123
136
|
```
|
|
124
137
|
|
|
125
|
-
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.
|
|
126
141
|
|
|
127
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.
|
|
128
143
|
|
|
@@ -182,7 +197,7 @@ oomi invite --name hermes --app-url https://www.oomi.ai
|
|
|
182
197
|
|
|
183
198
|
Do not pair again to refresh an expired Auth Link. Re-running `connect` reuses saved pairing and verification; only the first connection or an explicit `oomi verify` sends a model probe. A different command, profile, or working directory requires a new connection name, so one agent cannot silently replace another. Named connections store private state and logs under `~/.oomi/connections/<name>`; default OpenClaw keeps `~/.openclaw`. Since 0.8.0, `connect` installs a separate automatic-start service for each connection: macOS launchd or Linux systemd user. It restarts an exited worker and starts at the next login/user session. Linux servers can start at boot and remain running after logout when the host administrator enables user lingering (`loginctl enable-linger <user>`); `oomi bridge service status --name hermes` reports whether this is enabled. WSL needs systemd and a running WSL instance; native Windows does not yet have an automatic service installer. A sleeping, powered-off, or disconnected computer remains unavailable.
|
|
184
199
|
|
|
185
|
-
Run `oomi bridge list` to find saved connection names and their startup protection on this computer. To
|
|
200
|
+
Run `oomi bridge list` to find saved connection names and their startup protection on this computer. To update an existing connection on **each computer**, follow Updating Oomi safely above. Repair preserves pairing and conversations. On a running 0.8.1+ supervised bridge it queues a safe restart and reports pending work; on a stopped bridge it installs/starts supervision and waits for the broker connection. It does not send a model probe or switch agents. Do not copy connection credentials between computers.
|
|
186
201
|
|
|
187
202
|
Use `oomi bridge service status --name hermes --json` to inspect startup protection. `oomi bridge stop --name hermes` disables automatic startup and stops only that connection; `oomi bridge start --name hermes` enables it again. `oomi bridge service uninstall --name hermes` removes startup management without deleting saved state. The legacy `oomi openclaw bridge service` command remains supported.
|
|
188
203
|
|