omo-ai 5.1.12 → 5.1.14
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/bin/lib/child-process.js +2 -2
- package/bin/lib/daemon-adopt.js +125 -0
- package/bin/lib/daemon-args.js +2 -32
- package/bin/lib/daemon-operations.js +10 -2
- package/bin/lib/daemon-status.js +21 -2
- package/bin/lib/daemon.js +32 -33
- package/bin/lib/host-status.js +59 -0
- package/bin/lib/launcher.js +31 -8
- package/bin/lib/provider-map.json +1 -1
- package/bin/lib/thread-args.js +54 -0
- package/bin/lib/thread-output.js +40 -0
- package/bin/lib/thread.js +257 -0
- package/package.json +2 -2
- package/plugin/CHANGELOG.md +84 -0
- package/plugin/extensions/gateway-store-worker.mjs +2 -0
- package/plugin/extensions/memory-run-supervisor.mjs +2 -2
- package/plugin/extensions/omo-computer-use.js +2 -2
- package/plugin/extensions/omo-init-deep-advisor.js +2 -2
- package/plugin/extensions/omo-member.js +1 -1
- package/plugin/extensions/omo-task.js +2 -2
- package/plugin/extensions/omo.js +2 -2
- package/plugin/package.json +1 -1
- package/plugin/runtime/agent-toolkit-sdk/sdk.js +2 -2
- package/plugin/runtime/ast-grep-mcp/manifest.json +1 -1
- package/plugin/runtime/category-coverage/index.js +3 -3
- package/plugin/runtime/lsp-daemon/dist/.omo-runtime-manifest.json +1 -1
- package/plugin/runtime/rollback-migrate.js +1 -1
- package/plugin/runtime/thread-sdk/sdk.js +2 -0
- package/plugin/scripts/install.mjs +2 -0
- package/plugin/skills/browser/SKILL.md +26 -2
- package/plugin/skills/browser/references/commands.md +10 -1
- package/plugin/skills/browser/references/install.md +7 -0
- package/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
- package/plugin/skills/browser/scripts/browser-engine-guard.mjs +506 -0
- package/plugin/skills/browser/scripts/omowright.mjs +29 -2
- package/plugin/skills/ultrawork/SKILL.md +4 -7
package/bin/lib/child-process.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process"
|
|
2
2
|
|
|
3
|
-
// POSIX launchers prefer execve and leave no wrapper. This async fallback remains for Windows
|
|
4
|
-
// missing/failed execve
|
|
3
|
+
// POSIX launchers prefer execve and leave no wrapper. This async fallback remains for Windows
|
|
4
|
+
// and missing/failed execve.
|
|
5
5
|
// Signals a terminal or supervisor sends to the launcher, and what the launcher does with each.
|
|
6
6
|
// SIGTERM and SIGHUP reach one process, so the child has to be told. SIGINT is delivered to the
|
|
7
7
|
// whole foreground process group by the tty, so the child already has it and forwarding would
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { DAEMON_EXIT } from "./daemon-args.js"
|
|
2
|
+
import { parseArgs } from "./thread-args.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `omo daemon adopt <session>`: take a session a host holds into this terminal. The session is found
|
|
6
|
+
* through the thread address book (it must be alive on an `rpc_host` endpoint), the host hands it
|
|
7
|
+
* over with senpi `release_session {reason:"takeover"}`, and the caller then resumes it here with
|
|
8
|
+
* the interactive launch on `--session <path>` (a path; `--resume` is the picker).
|
|
9
|
+
*
|
|
10
|
+
* What the release takes out never vanishes: `dropped.deliveries` are gateway rows the new owner's
|
|
11
|
+
* inbox drain applies again (nothing to do here), and `dropped.user_messages` - the user's queued
|
|
12
|
+
* input an `--interrupt` took out - become this terminal's first prompts in their queued order, or,
|
|
13
|
+
* when the release is refused after all, are printed so the user can send them again.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const USAGE = "usage: omo daemon adopt <session-id|name> [--interrupt] [--force] [--json]"
|
|
17
|
+
|
|
18
|
+
function refusalMessage(code, errorData, interrupt) {
|
|
19
|
+
const busy = errorData?.busy?.length ? ` (${errorData.busy.join(", ")})` : ""
|
|
20
|
+
switch (code) {
|
|
21
|
+
case "turn_active":
|
|
22
|
+
return interrupt
|
|
23
|
+
? `turn_active: the session is still busy after the interrupt${busy}`
|
|
24
|
+
: `turn_active: the session is running a turn or owes one${busy}; pass --interrupt to stop it and take the session`
|
|
25
|
+
case "session_busy":
|
|
26
|
+
return `session_busy: the host is still working on the session${busy}; retry when it is done`
|
|
27
|
+
case "attached":
|
|
28
|
+
return `attached: ${errorData?.attachments ?? "?"} client(s) - a Desktop thread or another client owns it; pass --force to take it anyway`
|
|
29
|
+
case "host_draining":
|
|
30
|
+
return "host_draining: the host is shutting down; retry once it has handed its sessions over"
|
|
31
|
+
case "session_closing":
|
|
32
|
+
return "session_closing: the host is already closing the session"
|
|
33
|
+
case "release_unsupported":
|
|
34
|
+
return "release_unsupported: the host cannot hand this session over (a worker session or one without a file)"
|
|
35
|
+
case "release_failed":
|
|
36
|
+
return `release_failed: ${errorData?.detail ?? "the host could not write the hand-over"}`
|
|
37
|
+
case "unknown_session":
|
|
38
|
+
return "unknown_session: the host no longer holds the session"
|
|
39
|
+
default:
|
|
40
|
+
return `${code}: ${errorData?.hint ?? "the host refused the release"}`
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function refusalExit(code) {
|
|
45
|
+
if (code === "unknown_session" || code === "host_unavailable") return DAEMON_EXIT.notRunning
|
|
46
|
+
if (code === "release_failed" || code === "internal_error") return DAEMON_EXIT.engineRefused
|
|
47
|
+
return DAEMON_EXIT.unsupported
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function dropped(...replies) {
|
|
51
|
+
const deliveries = []
|
|
52
|
+
const userMessages = []
|
|
53
|
+
for (const reply of replies) {
|
|
54
|
+
const value = reply?.success === true ? reply.data.dropped : reply?.errorData?.dropped
|
|
55
|
+
deliveries.push(...(value?.deliveries ?? []))
|
|
56
|
+
userMessages.push(...(value?.user_messages ?? []).filter((message) => message.trim() !== ""))
|
|
57
|
+
}
|
|
58
|
+
return { deliveries, user_messages: userMessages }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `release_failed` is re-asked once: a teardown that got far enough answers `unknown_session`, which means the session is released. */
|
|
62
|
+
async function release(sdk, thread, request) {
|
|
63
|
+
const first = await sdk.release(thread, request)
|
|
64
|
+
if (first.success || first.error !== "release_failed") return { reply: first, dropped: dropped(first) }
|
|
65
|
+
const second = await sdk.release(thread, {})
|
|
66
|
+
if (second.success) return { reply: second, dropped: dropped(first, second) }
|
|
67
|
+
if (second.error === "unknown_session" && thread.session_path !== null) {
|
|
68
|
+
return { reply: { success: true, data: { released: true, session_path: thread.session_path, attachments: 0, dropped: dropped(first) } }, dropped: dropped(first) }
|
|
69
|
+
}
|
|
70
|
+
return { reply: first, dropped: dropped(first, second) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @returns an exit code, or `{ launch, cwd }`: the interactive launch arguments that resume the
|
|
75
|
+
* released session in this terminal, and the directory it runs in.
|
|
76
|
+
*/
|
|
77
|
+
export async function runAdoptCommand(args, { sdk, stdout, stderr }) {
|
|
78
|
+
// `--json` among the options, before `--`, is read before the parse: a usage error still answers in JSON.
|
|
79
|
+
const end = args.indexOf("--")
|
|
80
|
+
const json = (end === -1 ? args : args.slice(0, end)).includes("--json")
|
|
81
|
+
const outcome = (payload, code) => {
|
|
82
|
+
if (json) stdout.write(`${JSON.stringify(payload)}\n`)
|
|
83
|
+
return code
|
|
84
|
+
}
|
|
85
|
+
const parsed = parseArgs(args, { booleans: ["--interrupt", "--force", "--json"] })
|
|
86
|
+
if (parsed.error !== undefined || parsed.positionals.length !== 1) {
|
|
87
|
+
stderr.write(`omo daemon adopt: ${parsed.error ?? "expects one session"}\n${USAGE}\n`)
|
|
88
|
+
return outcome({ kind: "refused", error: "usage" }, DAEMON_EXIT.usage)
|
|
89
|
+
}
|
|
90
|
+
const interrupt = parsed.flags.has("--interrupt")
|
|
91
|
+
|
|
92
|
+
const located = await sdk.locate({ thread: parsed.positionals[0], all_scope: true })
|
|
93
|
+
if (located.kind !== "ok") {
|
|
94
|
+
stderr.write(`omo daemon adopt: ${located.error.code}: ${located.error.message}\n`)
|
|
95
|
+
return outcome({ kind: "refused", error: located.error.code }, located.error.code === "ambiguous_target" ? DAEMON_EXIT.usage : DAEMON_EXIT.notRunning)
|
|
96
|
+
}
|
|
97
|
+
const thread = located.thread
|
|
98
|
+
if (thread.surface === "tui" || thread.endpoint?.kind === "tui") {
|
|
99
|
+
stderr.write(`omo daemon adopt: ${thread.thread_id} is already a terminal session\n`)
|
|
100
|
+
return outcome({ kind: "refused", error: "already_terminal", thread_id: thread.thread_id }, DAEMON_EXIT.unsupported)
|
|
101
|
+
}
|
|
102
|
+
if (!thread.alive || thread.endpoint === null) {
|
|
103
|
+
stderr.write(`omo daemon adopt: no running host holds ${thread.thread_id}; resume it with omo --session ${thread.session_path ?? thread.thread_id}\n`)
|
|
104
|
+
return outcome({ kind: "refused", error: "not_running", thread_id: thread.thread_id }, DAEMON_EXIT.notRunning)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const request = { ...(interrupt ? { interrupt: true } : {}), ...(parsed.flags.has("--force") ? { force: true } : {}) }
|
|
108
|
+
const { reply, dropped: taken } = await release(sdk, thread, request)
|
|
109
|
+
if (!reply.success) {
|
|
110
|
+
stderr.write(`omo daemon adopt: ${refusalMessage(reply.error, reply.errorData, interrupt)}\n`)
|
|
111
|
+
for (const message of taken.user_messages) stderr.write(` queued message taken out by --interrupt, not delivered: ${message}\n`)
|
|
112
|
+
return outcome({ kind: "refused", error: reply.error, thread_id: thread.thread_id, dropped: taken }, refusalExit(reply.error))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// senpi reads a leading `@` as a file argument even after `--`, so such a message is shown, not replayed.
|
|
116
|
+
const replayable = taken.user_messages.filter((message) => !message.startsWith("@"))
|
|
117
|
+
for (const message of taken.user_messages.filter((entry) => entry.startsWith("@"))) stderr.write(` queued message not replayed (starts with @), send it again: ${message}\n`)
|
|
118
|
+
const path = reply.data.session_path
|
|
119
|
+
stderr.write(`omo daemon adopt: ${thread.thread_id} released from ${thread.endpoint.socket}; resuming it here${replayable.length > 0 ? ` with ${replayable.length} queued message(s)` : ""}\n`)
|
|
120
|
+
// The relaunch carries them as argv, so they are also printed: a launch that fails after the release still leaves them on screen.
|
|
121
|
+
for (const message of replayable) stderr.write(` queued message taken out by --interrupt, replayed as a prompt: ${message}\n`)
|
|
122
|
+
if (taken.deliveries.length > 0) stderr.write(` ${taken.deliveries.length} gateway deliver${taken.deliveries.length === 1 ? "y returns" : "ies return"} to the session's inbox\n`)
|
|
123
|
+
outcome({ kind: "released", thread_id: thread.thread_id, session_path: path, attachments: reply.data.attachments, dropped: taken }, DAEMON_EXIT.ok)
|
|
124
|
+
return { launch: ["--session", path, ...(replayable.length > 0 ? ["--", ...replayable] : [])], cwd: thread.cwd }
|
|
125
|
+
}
|
package/bin/lib/daemon-args.js
CHANGED
|
@@ -1,24 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `omo daemon` command-line contract:
|
|
3
|
-
*
|
|
2
|
+
* The `omo daemon` command-line contract: its exit codes and the small argv helpers
|
|
3
|
+
* `runDaemonCommand` uses. Moved verbatim out of `daemon.js`.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/** Flags this wrapper consumes itself; anything else after `attach` belongs to the launch. */
|
|
7
|
-
const DAEMON_FLAGS = new Set([
|
|
8
|
-
"--json",
|
|
9
|
-
"--no-upgrade",
|
|
10
|
-
"--persistent",
|
|
11
|
-
"--foreground",
|
|
12
|
-
"--include-workers",
|
|
13
|
-
"--drain",
|
|
14
|
-
"--all",
|
|
15
|
-
"--wait",
|
|
16
|
-
"--dry-run",
|
|
17
|
-
"--allow-missing-index",
|
|
18
|
-
"--prune-store-index",
|
|
19
|
-
])
|
|
20
|
-
const DAEMON_VALUE_FLAGS = new Set(["--store", "--timeout"])
|
|
21
|
-
|
|
22
6
|
/** A named code per outcome, so a script never has to parse the message to know what happened. */
|
|
23
7
|
export const DAEMON_EXIT = {
|
|
24
8
|
ok: 0,
|
|
@@ -35,20 +19,6 @@ export function readTimeoutSeconds(args) {
|
|
|
35
19
|
return Number.isFinite(value) && value >= 0 ? value : 600
|
|
36
20
|
}
|
|
37
21
|
|
|
38
|
-
export function attachLaunchArgs(args) {
|
|
39
|
-
const launchArgs = []
|
|
40
|
-
for (let index = 1; index < args.length; index += 1) {
|
|
41
|
-
const argument = args[index]
|
|
42
|
-
if (DAEMON_FLAGS.has(argument)) continue
|
|
43
|
-
if (DAEMON_VALUE_FLAGS.has(argument)) {
|
|
44
|
-
index += 1
|
|
45
|
-
continue
|
|
46
|
-
}
|
|
47
|
-
launchArgs.push(argument)
|
|
48
|
-
}
|
|
49
|
-
return launchArgs
|
|
50
|
-
}
|
|
51
|
-
|
|
52
22
|
export function blockingPause(milliseconds) {
|
|
53
23
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds)
|
|
54
24
|
}
|
|
@@ -38,13 +38,18 @@ export function runStatus({ engine, agentDir, env, json, stdout, stderr, _test }
|
|
|
38
38
|
return { legacy: false, exitCode: all.result.exitCode, endpoints: all.endpoints }
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** A terminal (`tui`) endpoint is owned by its terminal: no lifecycle command ensures, hands off, drains, stops or waits on it. */
|
|
42
|
+
function operable(endpoint) {
|
|
43
|
+
return endpoint.endpoint_kind !== "tui"
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
function liveEndpoints(endpoints) {
|
|
42
|
-
return endpoints.filter((endpoint) => endpoint.reachable && typeof endpoint.socket === "string")
|
|
47
|
+
return endpoints.filter((endpoint) => operable(endpoint) && endpoint.reachable && typeof endpoint.socket === "string")
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
function ownedEndpoints(endpoints) {
|
|
46
51
|
return endpoints.filter((endpoint) => {
|
|
47
|
-
if (typeof endpoint.socket !== "string") return false
|
|
52
|
+
if (!operable(endpoint) || typeof endpoint.socket !== "string") return false
|
|
48
53
|
return endpoint.reachable ||
|
|
49
54
|
(endpoint.generations ?? []).some((generation) => generation.alive) ||
|
|
50
55
|
(endpoint.claims_live ?? 0) > 0
|
|
@@ -93,6 +98,9 @@ export function runHandoff({ engine, pluginRoot, agentDir, env, policy, config,
|
|
|
93
98
|
}
|
|
94
99
|
let refused = false
|
|
95
100
|
const commandEnv = hostCommandEnvironment(env, agentDir, config)
|
|
101
|
+
for (const endpoint of all.endpoints) {
|
|
102
|
+
if (!operable(endpoint) && typeof endpoint.socket === "string") stdout.write(`${endpoint.socket}: skipped (tui endpoint, owned by its terminal)\n`)
|
|
103
|
+
}
|
|
96
104
|
for (const endpoint of liveEndpoints(all.endpoints)) {
|
|
97
105
|
const daemon = basename(endpoint.socket) === "rpc.sock"
|
|
98
106
|
const args = daemon
|
package/bin/lib/daemon-status.js
CHANGED
|
@@ -26,10 +26,15 @@ export function decorateEndpoints(endpoints, agentDir) {
|
|
|
26
26
|
})
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
function isTerminal(endpoint) {
|
|
30
|
+
return endpoint.endpoint_kind === "tui"
|
|
31
|
+
}
|
|
32
|
+
|
|
29
33
|
export function aggregateEndpoints(endpoints) {
|
|
30
|
-
const live = endpoints.filter((endpoint) => endpoint.reachable)
|
|
34
|
+
const live = endpoints.filter((endpoint) => endpoint.reachable && !isTerminal(endpoint))
|
|
31
35
|
return {
|
|
32
36
|
live: live.length,
|
|
37
|
+
terminals: endpoints.filter((endpoint) => isTerminal(endpoint) && endpoint.reachable).length,
|
|
33
38
|
shards: live.filter((endpoint) => endpoint.shard?.kind === "p").length,
|
|
34
39
|
threads: live.filter((endpoint) => endpoint.shard?.kind === "i").length,
|
|
35
40
|
sessions: live.reduce((total, endpoint) => total + (endpoint.sessions?.total ?? 0), 0),
|
|
@@ -40,6 +45,7 @@ export function aggregateEndpoints(endpoints) {
|
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
function endpointKind(endpoint) {
|
|
48
|
+
if (isTerminal(endpoint)) return "tui"
|
|
43
49
|
if (endpoint.shard?.kind === "p") return "shard"
|
|
44
50
|
if (endpoint.shard?.kind === "i") return "thread"
|
|
45
51
|
if (typeof endpoint.socket === "string" && basename(endpoint.socket) === "rpc.sock") return "daemon"
|
|
@@ -89,9 +95,22 @@ function generationLine(generation, endpoint) {
|
|
|
89
95
|
return ` gen ${generation.generation} (${state}) pid ${metric(generation.pid)} · engine ${generation.engineVersion ?? "?"} · ${sessions} session(s) · rss ${metric(generation.rss_mb)} MB (host ${metric(generation.host_rss_mb)} MB)`
|
|
90
96
|
}
|
|
91
97
|
|
|
98
|
+
/** A terminal row: `tui <name> pid <n> cwd <path>`, named by its session, else by its socket. */
|
|
99
|
+
function terminalLine(endpoint) {
|
|
100
|
+
const owner = endpoint.owner ?? {}
|
|
101
|
+
const socketName = typeof endpoint.socket === "string" ? basename(endpoint.socket, ".sock") : basename(endpoint.dir ?? "unknown")
|
|
102
|
+
const line = `tui ${owner.session?.name ?? socketName} pid ${metric(owner.pid)} cwd ${owner.cwd ?? "?"}`
|
|
103
|
+
if (endpoint.alive !== false && endpoint.reachable) return line
|
|
104
|
+
return `${line}: not responding (${endpoint.reason ?? "unreachable"}; omo daemon gc reaps it once its process is gone)`
|
|
105
|
+
}
|
|
106
|
+
|
|
92
107
|
export function formatEndpointLines(endpoints) {
|
|
93
108
|
const lines = []
|
|
94
109
|
for (const endpoint of endpoints) {
|
|
110
|
+
if (isTerminal(endpoint)) {
|
|
111
|
+
lines.push(terminalLine(endpoint))
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
95
114
|
const name = endpointName(endpoint)
|
|
96
115
|
if (!endpoint.reachable) {
|
|
97
116
|
lines.push(`${name}: not running (retained state kept; run omo daemon gc)`)
|
|
@@ -104,7 +123,7 @@ export function formatEndpointLines(endpoints) {
|
|
|
104
123
|
}
|
|
105
124
|
const aggregate = aggregateEndpoints(endpoints)
|
|
106
125
|
lines.push(
|
|
107
|
-
`hosts: ${aggregate.live} live (${aggregate.shards} shards, ${aggregate.threads} threads) · ${aggregate.sessions} session(s) · rss ${aggregate.rss_mb} MB (host ${aggregate.host_rss_mb} MB) · crashes ${aggregate.crashes} (recorded, newest 50 per endpoint)`,
|
|
126
|
+
`hosts: ${aggregate.live} live (${aggregate.shards} shards, ${aggregate.threads} threads)${aggregate.terminals > 0 ? ` · ${aggregate.terminals} terminal(s)` : ""} · ${aggregate.sessions} session(s) · rss ${aggregate.rss_mb} MB (host ${aggregate.host_rss_mb} MB) · crashes ${aggregate.crashes} (recorded, newest 50 per endpoint)`,
|
|
108
127
|
)
|
|
109
128
|
return lines
|
|
110
129
|
}
|
package/bin/lib/daemon.js
CHANGED
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
runStopAll,
|
|
11
11
|
} from "./daemon-operations.js"
|
|
12
12
|
import { runRollbackPrepare } from "./daemon-rollback.js"
|
|
13
|
-
import {
|
|
13
|
+
import { runAdoptCommand } from "./daemon-adopt.js"
|
|
14
|
+
import { loadThreadSdk } from "./thread.js"
|
|
15
|
+
import { blockingPause, DAEMON_EXIT, readTimeoutSeconds } from "./daemon-args.js"
|
|
14
16
|
import { readDaemonConfig } from "./daemon-config.js"
|
|
15
17
|
|
|
16
18
|
export { DAEMON_EXIT } from "./daemon-args.js"
|
|
@@ -18,7 +20,7 @@ export { daemonReportLines } from "./daemon-doctor-report.js"
|
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* `omo daemon` - the operator's view of every engine host in one agent dir: the operator daemon on
|
|
21
|
-
* `rpc.sock` (the only endpoint `run`
|
|
23
|
+
* `rpc.sock` (the only endpoint `run` ensures), each session's task host (`p-*`), each
|
|
22
24
|
* Desktop thread host (`i-*`), and any other endpoint the engine enumerates. `status`, `gc`,
|
|
23
25
|
* `handoff`, `stop --all` and `rollback-prepare` cover all of them.
|
|
24
26
|
*
|
|
@@ -29,15 +31,15 @@ export { daemonReportLines } from "./daemon-doctor-report.js"
|
|
|
29
31
|
* gets an exit code it can branch on without reading prose.
|
|
30
32
|
*/
|
|
31
33
|
|
|
32
|
-
const SUBCOMMANDS = new Set(["run", "
|
|
34
|
+
const SUBCOMMANDS = new Set(["run", "adopt", "status", "stop", "handoff", "gc", "rollback-prepare"])
|
|
33
35
|
/** The subcommands that can bring a host into existence, and therefore need omo's argv source. */
|
|
34
|
-
const NEEDS_SPEC = new Set(["run", "
|
|
36
|
+
const NEEDS_SPEC = new Set(["run", "handoff"])
|
|
35
37
|
|
|
36
38
|
const USAGE = [
|
|
37
|
-
"usage: omo daemon <run|
|
|
39
|
+
"usage: omo daemon <run|adopt|status|stop|handoff|gc|rollback-prepare> [options]",
|
|
38
40
|
"",
|
|
39
41
|
" run ensure a daemon is serving this agent dir (start, reuse, or hand off)",
|
|
40
|
-
"
|
|
42
|
+
" adopt take a session a host holds into this terminal (--interrupt, --force)",
|
|
41
43
|
" status report who is serving and which sessions exist",
|
|
42
44
|
" stop end the daemon; --drain lets in-flight work finish first",
|
|
43
45
|
" handoff hand the socket to this build, keeping live sessions",
|
|
@@ -63,15 +65,14 @@ function resolvePolicy(args, config) {
|
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
/**
|
|
66
|
-
* The engine owns four subcommands - ensure, status, stop, handoff. `run`
|
|
67
|
-
*
|
|
68
|
-
* environment to reach it. Mapping them here is what keeps this file from inventing a fifth.
|
|
68
|
+
* The engine owns four subcommands - ensure, status, stop, handoff. `run` is omo's word for the
|
|
69
|
+
* ensure. Mapping them here is what keeps this file from inventing a fifth.
|
|
69
70
|
*/
|
|
70
|
-
const ENGINE_SUBCOMMAND = { run: "ensure",
|
|
71
|
+
const ENGINE_SUBCOMMAND = { run: "ensure", status: "status", stop: "stop", handoff: "handoff" }
|
|
71
72
|
|
|
72
73
|
function buildArgs(subcommand, args, { specPath, policy }) {
|
|
73
74
|
const engineArgs = ["host", ENGINE_SUBCOMMAND[subcommand], "--json"]
|
|
74
|
-
if (subcommand === "run" || subcommand === "
|
|
75
|
+
if (subcommand === "run" || subcommand === "handoff") {
|
|
75
76
|
engineArgs.push("--launch-spec", specPath, "--policy", policy)
|
|
76
77
|
}
|
|
77
78
|
if (subcommand === "stop" && args.includes("--drain")) engineArgs.push("--drain")
|
|
@@ -79,14 +80,6 @@ function buildArgs(subcommand, args, { specPath, policy }) {
|
|
|
79
80
|
return engineArgs
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
/** What a child process needs in its environment to reach this daemon rather than start its own. */
|
|
83
|
-
function attachEnv(parsed, agentDir) {
|
|
84
|
-
return {
|
|
85
|
-
OMO_ENABLE_SHARED_HOST: "1",
|
|
86
|
-
OMO_RPC_SOCKET: parsed?.socket ?? join(agentDir, "rpc", "rpc.sock"),
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
83
|
function summarize(subcommand, parsed, exitCode) {
|
|
91
84
|
if (subcommand === "status") {
|
|
92
85
|
if (exitCode === DAEMON_EXIT.notRunning || parsed === undefined) return "daemon: not running"
|
|
@@ -99,6 +92,21 @@ function summarize(subcommand, parsed, exitCode) {
|
|
|
99
92
|
return `daemon: ${parsed.action ?? subcommand}${pid}`
|
|
100
93
|
}
|
|
101
94
|
|
|
95
|
+
/** `omo daemon adopt`: the thread SDK finds and releases the session; the result is an exit code or a launch. */
|
|
96
|
+
async function adopt(args, options) {
|
|
97
|
+
const loaded = await (options.threadSdk ?? loadThreadSdk)({ ...options, cwd: options.cwd ?? process.cwd() })
|
|
98
|
+
if (loaded.error !== undefined) {
|
|
99
|
+
options.stderr.write(`omo daemon adopt: ${loaded.error}\n`)
|
|
100
|
+
return DAEMON_EXIT.unsupported
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return await runAdoptCommand(args, { sdk: loaded.sdk, stdout: options.stdout, stderr: options.stderr })
|
|
104
|
+
} finally {
|
|
105
|
+
// The release already happened; a store that fails to close is logged, never a new outcome.
|
|
106
|
+
await loaded.sdk.dispose().catch((error) => options.stderr.write(`omo daemon adopt: closing the gateway store failed: ${error instanceof Error ? error.message : String(error)}\n`))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
102
110
|
/**
|
|
103
111
|
* @param args argv after `omo daemon`
|
|
104
112
|
* @param options.engine something that can run the engine CLI; injected so tests never spawn one
|
|
@@ -117,7 +125,9 @@ export function runDaemonCommand(args, options) {
|
|
|
117
125
|
return DAEMON_EXIT.usage
|
|
118
126
|
}
|
|
119
127
|
if (!SUBCOMMANDS.has(subcommand)) {
|
|
120
|
-
|
|
128
|
+
// Scripts written for the removed shared-host join get a pointer to what replaced it.
|
|
129
|
+
const removed = subcommand === "attach" ? "omo daemon: 'attach' was removed; to continue a host session in this terminal, run omo daemon adopt <session>\n" : ""
|
|
130
|
+
stderr.write(`omo daemon: unknown subcommand '${subcommand}'\n${removed}${USAGE}\n`)
|
|
121
131
|
return DAEMON_EXIT.usage
|
|
122
132
|
}
|
|
123
133
|
// A named pipe is per-process on win32: there is no socket for a second client to attach to,
|
|
@@ -133,6 +143,8 @@ export function runDaemonCommand(args, options) {
|
|
|
133
143
|
|
|
134
144
|
// Only the subcommands that may START something need the spec; asking who is serving, or
|
|
135
145
|
// asking it to stop, must still work on an install whose plugin payload was never built.
|
|
146
|
+
if (subcommand === "adopt") return adopt(args.slice(1), options)
|
|
147
|
+
|
|
136
148
|
const specPath = join(pluginRoot, "daemon-launch-spec.json")
|
|
137
149
|
if (NEEDS_SPEC.has(subcommand) && !existsSync(specPath)) {
|
|
138
150
|
stderr.write(`omo daemon: launch spec missing at ${specPath}\n`)
|
|
@@ -219,19 +231,6 @@ export function runDaemonCommand(args, options) {
|
|
|
219
231
|
|
|
220
232
|
if (result.stderr) stderr.write(result.stderr)
|
|
221
233
|
|
|
222
|
-
if (subcommand === "attach") {
|
|
223
|
-
if (result.exitCode !== DAEMON_EXIT.ok) return result.exitCode
|
|
224
|
-
const daemonEnv = attachEnv(parsed, agentDir)
|
|
225
|
-
// `omo daemon attach --model x`: the trailing args are a normal omo launch that should run
|
|
226
|
-
// against the daemon, so the caller gets the merged environment back and continues with them.
|
|
227
|
-
const launchArgs = attachLaunchArgs(args)
|
|
228
|
-
if (launchArgs.length > 0) return { passthrough: true, args: launchArgs, env: { ...env, ...daemonEnv } }
|
|
229
|
-
const payload = { ...(parsed ?? {}), env: daemonEnv }
|
|
230
|
-
if (args.includes("--json")) stdout.write(`${JSON.stringify(payload)}\n`)
|
|
231
|
-
else for (const [key, value] of Object.entries(daemonEnv)) stdout.write(`${key}=${value}\n`)
|
|
232
|
-
return DAEMON_EXIT.ok
|
|
233
|
-
}
|
|
234
|
-
|
|
235
234
|
if (args.includes("--json") && result.stdout) stdout.write(result.stdout.trim() + "\n")
|
|
236
235
|
else stdout.write(`${summarize(subcommand, parsed, result.exitCode)}\n`)
|
|
237
236
|
return result.exitCode
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { pathToFileURL } from "node:url"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `omo host status --all`: the engine's inventory line, with `last_activity_at` added to every
|
|
6
|
+
* terminal (`endpoint_kind: "tui"`) row. The value is the newest entry timestamp of the row's session
|
|
7
|
+
* file (`owner.session.path`), read by the thread SDK's `readSessionFacts` - the value `omo thread
|
|
8
|
+
* list` shows as `updated_at` when the endpoint reports none - and `null` when the row names no
|
|
9
|
+
* session or the bounded reader cannot prove the final complete entry's timestamp. Every other
|
|
10
|
+
* field, row and the exit code are the engine's.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Set on the engine call the compiled binary makes by re-running itself, so that call is not enriched again. */
|
|
14
|
+
export const HOST_STATUS_RAW_ENV = "OMO_HOST_STATUS_RAW"
|
|
15
|
+
|
|
16
|
+
export function isHostStatusAll(args, env) {
|
|
17
|
+
return args[0] === "host" && args[1] === "status" && args.includes("--all") && env[HOST_STATUS_RAW_ENV] !== "1"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function sessionActivityReader(pluginRoot) {
|
|
21
|
+
let module
|
|
22
|
+
try {
|
|
23
|
+
module = await import(pathToFileURL(join(pluginRoot, "runtime", "thread-sdk", "sdk.js")).href)
|
|
24
|
+
} catch {
|
|
25
|
+
return () => null
|
|
26
|
+
}
|
|
27
|
+
if (typeof module.readSessionFacts !== "function") return () => null
|
|
28
|
+
return (path) => module.readSessionFacts(path)?.updated_at ?? null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function withLastActivity(payload, readActivity) {
|
|
32
|
+
return {
|
|
33
|
+
...payload,
|
|
34
|
+
endpoints: payload.endpoints.map((row) => {
|
|
35
|
+
if (row === null || typeof row !== "object" || row.endpoint_kind !== "tui") return row
|
|
36
|
+
const path = row.owner?.session?.path
|
|
37
|
+
return { ...row, last_activity_at: typeof path === "string" ? readActivity(path) : null }
|
|
38
|
+
}),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function inventory(stdout) {
|
|
43
|
+
const line = stdout.trim().split("\n").filter((candidate) => candidate.trim() !== "").pop()
|
|
44
|
+
if (line === undefined) return undefined
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(line)
|
|
47
|
+
return parsed !== null && typeof parsed === "object" && Array.isArray(parsed.endpoints) ? parsed : undefined
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runHostStatusAll(args, { engine, env, stdout, stderr, readActivity }) {
|
|
54
|
+
const result = engine.run(args, { env: { ...env, [HOST_STATUS_RAW_ENV]: "1" } })
|
|
55
|
+
if (result.stderr !== "") stderr.write(result.stderr)
|
|
56
|
+
const payload = inventory(result.stdout)
|
|
57
|
+
stdout.write(payload === undefined ? result.stdout : `${JSON.stringify(withLastActivity(payload, readActivity))}\n`)
|
|
58
|
+
return result.exitCode
|
|
59
|
+
}
|
package/bin/lib/launcher.js
CHANGED
|
@@ -6,6 +6,8 @@ import { doctorCoverageLines } from "./category-coverage.js"
|
|
|
6
6
|
import { doctorComputerUseLines } from "./computer-use-doctor.js"
|
|
7
7
|
import { doctorConfigLines } from "./config-doctor.js"
|
|
8
8
|
import { runDaemonCommand } from "./daemon.js"
|
|
9
|
+
import { runThreadCommand } from "./thread.js"
|
|
10
|
+
import { isHostStatusAll, runHostStatusAll, sessionActivityReader } from "./host-status.js"
|
|
9
11
|
import { runDoctor } from "./doctor.js"
|
|
10
12
|
import { ensureEnginePrepared, preparePluginLaunchSpec } from "./engine-prepare.js"
|
|
11
13
|
import { migrateLegacyBunGlobalManifest } from "./legacy-bun-global-migration.js"
|
|
@@ -231,10 +233,23 @@ export async function runLauncher(args = process.argv.slice(2)) {
|
|
|
231
233
|
process.exitCode = 2
|
|
232
234
|
return
|
|
233
235
|
}
|
|
236
|
+
if (command === "thread") {
|
|
237
|
+
process.exitCode = await runThreadCommand(args.slice(1), {
|
|
238
|
+
engine: { run: engineHostCall },
|
|
239
|
+
pluginRoot: join(packageRoot, "plugin"),
|
|
240
|
+
agentDir: canonicalAgentDir(),
|
|
241
|
+
env: process.env,
|
|
242
|
+
cwd: process.cwd(),
|
|
243
|
+
stdout: process.stdout,
|
|
244
|
+
stderr: process.stderr,
|
|
245
|
+
platform: process.platform,
|
|
246
|
+
})
|
|
247
|
+
return
|
|
248
|
+
}
|
|
234
249
|
// The daemon is the engine's to run; omo only supplies the launch spec, the task settings from
|
|
235
250
|
// the omo config (daemon-config.js), and an exit code the caller can branch on.
|
|
236
251
|
if (command === "daemon") {
|
|
237
|
-
const outcome = runDaemonCommand(args.slice(1), {
|
|
252
|
+
const outcome = await runDaemonCommand(args.slice(1), {
|
|
238
253
|
engine: { run: engineHostCall },
|
|
239
254
|
migration: { run: rollbackMigrateCall },
|
|
240
255
|
pluginRoot: join(packageRoot, "plugin"),
|
|
@@ -244,13 +259,10 @@ export async function runLauncher(args = process.argv.slice(2)) {
|
|
|
244
259
|
stderr: process.stderr,
|
|
245
260
|
platform: process.platform,
|
|
246
261
|
})
|
|
247
|
-
// `omo daemon
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
await spawnNode(senpi.cliPath, ["--extension", join(packageRoot, "plugin"), ...outcome.args], {
|
|
252
|
-
env: { ...senpiEnvironment(senpi.packageRoot), ...outcome.env },
|
|
253
|
-
})
|
|
262
|
+
// `omo daemon adopt`: the host released the session, so it resumes here as a normal launch, in its own directory.
|
|
263
|
+
if (typeof outcome === "object" && Array.isArray(outcome.launch)) {
|
|
264
|
+
if (typeof outcome.cwd === "string" && existsSync(outcome.cwd)) process.chdir(outcome.cwd)
|
|
265
|
+
await spawnSenpi(outcome.launch, true)
|
|
254
266
|
return
|
|
255
267
|
}
|
|
256
268
|
process.exitCode = outcome
|
|
@@ -303,6 +315,17 @@ export async function runLauncher(args = process.argv.slice(2)) {
|
|
|
303
315
|
await spawnSenpi(args.includes("--no-extensions") ? args : [...args, "--extension", join(packageRoot, "plugin")], false)
|
|
304
316
|
return
|
|
305
317
|
}
|
|
318
|
+
// `host status --all` stays the engine's answer; omo only adds each terminal row's last activity.
|
|
319
|
+
if (isHostStatusAll(args, process.env)) {
|
|
320
|
+
process.exitCode = await runHostStatusAll(args, {
|
|
321
|
+
engine: { run: engineHostCall },
|
|
322
|
+
env: process.env,
|
|
323
|
+
stdout: process.stdout,
|
|
324
|
+
stderr: process.stderr,
|
|
325
|
+
readActivity: await sessionActivityReader(join(packageRoot, "plugin")),
|
|
326
|
+
})
|
|
327
|
+
return
|
|
328
|
+
}
|
|
306
329
|
if (earlyCommands.has(command) || command === "update") {
|
|
307
330
|
await spawnSenpi(args, false)
|
|
308
331
|
return
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "Derived from @code-yeongyu/senpi 2026.10.
|
|
2
|
+
"_comment": "Derived from @code-yeongyu/senpi 2026.10.5 builtinProviders(). builtinProviderIds and oauthProviderIds are the engine registry verbatim; providers maps a source harness id onto the omo provider that serves the same endpoint; oauthLogins maps a source OAuth id onto the omo provider to run /login for. Re-derive all four whenever the senpi pin changes.",
|
|
3
3
|
"builtinProviderIds": [
|
|
4
4
|
"alibaba-token-plan",
|
|
5
5
|
"amazon-bedrock",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `omo thread` command-line contract: its exit codes and the one argv parser every subcommand
|
|
3
|
+
* uses. Exit codes follow `omo daemon` (`daemon-args.js`): 2 usage, 3 nothing answering, 4 not
|
|
4
|
+
* possible here; a refusal the gateway answered as data is 1 (its `error.code` is in the JSON).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const THREAD_EXIT = {
|
|
8
|
+
ok: 0,
|
|
9
|
+
refused: 1,
|
|
10
|
+
usage: 2,
|
|
11
|
+
unavailable: 3,
|
|
12
|
+
unsupported: 4,
|
|
13
|
+
failed: 5,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Splits argv into positionals, boolean flags and value flags. An unknown `--flag`, a value flag
|
|
18
|
+
* without its value, or a repeated value flag is a usage error; `--` ends flag parsing, so a text
|
|
19
|
+
* that starts with `-` can still be sent.
|
|
20
|
+
*/
|
|
21
|
+
export function parseArgs(args, { booleans = [], values = [] } = {}) {
|
|
22
|
+
const flags = new Set()
|
|
23
|
+
const options = {}
|
|
24
|
+
const positionals = []
|
|
25
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
26
|
+
const argument = args[index]
|
|
27
|
+
if (argument === "--") {
|
|
28
|
+
positionals.push(...args.slice(index + 1))
|
|
29
|
+
break
|
|
30
|
+
}
|
|
31
|
+
if (!argument.startsWith("--")) {
|
|
32
|
+
positionals.push(argument)
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (booleans.includes(argument)) {
|
|
36
|
+
flags.add(argument)
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (!values.includes(argument)) return { error: `unknown option '${argument}'` }
|
|
40
|
+
const value = args[index + 1]
|
|
41
|
+
if (value === undefined) return { error: `${argument} needs a value` }
|
|
42
|
+
if (Object.hasOwn(options, argument)) return { error: `${argument} was given twice` }
|
|
43
|
+
options[argument] = value
|
|
44
|
+
index += 1
|
|
45
|
+
}
|
|
46
|
+
return { flags, options, positionals }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A non-negative integer flag value, or undefined when the flag is absent; NaN marks a bad value. */
|
|
50
|
+
export function integerOption(options, name) {
|
|
51
|
+
const raw = options[name]
|
|
52
|
+
if (raw === undefined) return undefined
|
|
53
|
+
return /^\d+$/.test(raw) ? Number(raw) : Number.NaN
|
|
54
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The human summary `omo thread` prints without `--json`: one line per thread, binding, row or
|
|
3
|
+
* transcript item. Scripts use `--json`; these lines are for a person and may change.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function bindingLine(binding) {
|
|
7
|
+
return `${binding.binding_id} r${binding.revision} ${binding.status} ${binding.platform}:${binding.account_id}/${binding.chat_id}/${binding.thread_id} -> ${binding.session_durable_id}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function rowLine(row) {
|
|
11
|
+
const token = row.reply_token === null ? "" : ` (reply token ${row.reply_token})`
|
|
12
|
+
return `#${row.cursor} ${row.event} [${row.state}] ${row.text}${token}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const RENDER = {
|
|
16
|
+
list: (result) =>
|
|
17
|
+
result.threads.length === 0
|
|
18
|
+
? [`no threads in ${result.scope === "all" ? "any workspace" : "this workspace"} (try --all-scope)`]
|
|
19
|
+
: result.threads.map((thread) => `${thread.thread_id} ${thread.surface ?? "?"} ${thread.status} ${thread.name || "(unnamed)"} ${thread.cwd}`),
|
|
20
|
+
send: (result) => [`${result.delivery?.kind ?? "sent"}: delivery ${result.delivery_id} to ${result.thread_id}${result.deduplicated ? " (already sent)" : ""}`],
|
|
21
|
+
read: (result) => [
|
|
22
|
+
...result.items.map((item) => `[${item.role}] ${item.content}`),
|
|
23
|
+
...(result.truncated ? [`(truncated; continue with --cursor ${result.next_cursor})`] : []),
|
|
24
|
+
],
|
|
25
|
+
bind: (result) => [`bound ${bindingLine(result.binding)}${result.deduplicated ? " (already bound)" : ""}`],
|
|
26
|
+
unbind: (result) => [`unbound ${bindingLine(result.binding)}${result.already_closed ? " (already closed)" : ""}`],
|
|
27
|
+
rebind: (result) => [`rebound ${bindingLine(result.binding)}`],
|
|
28
|
+
bindings: (result) => [...result.bindings.map(bindingLine), ...(result.next_cursor === null ? [] : [`(more: --cursor ${result.next_cursor})`])],
|
|
29
|
+
report: (result) => [`reported ${result.event} to ${result.binding_id} at cursor ${result.cursor}${result.reply_token ? ` (reply token ${result.reply_token})` : ""}`],
|
|
30
|
+
answer: (result) => [`answered through ${result.binding_id} (cursor ${result.cursor})`],
|
|
31
|
+
outbox: (result) => [
|
|
32
|
+
...result.rows.map(rowLine),
|
|
33
|
+
`next cursor ${result.next_cursor}, acked through ${result.acked === undefined || result.acked === null ? result.acked_cursor : result.acked.acked_cursor}`,
|
|
34
|
+
],
|
|
35
|
+
ack: (result) => [`acked ${result.binding_id} through ${result.acked_cursor}${result.changed ? "" : " (no change)"}`],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function humanLines(name, result) {
|
|
39
|
+
return RENDER[name](result)
|
|
40
|
+
}
|