omo-ai 5.1.11 → 5.1.13

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.
@@ -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, and daemon attach, whose adapter still launches a child.
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
+ }
@@ -1,24 +1,8 @@
1
1
  /**
2
- * The `omo daemon` command-line contract: the flags this wrapper consumes, its exit codes, and the
3
- * small argv helpers `runDaemonCommand` uses. Moved verbatim out of `daemon.js`.
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
@@ -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 { attachLaunchArgs, blockingPause, DAEMON_EXIT, readTimeoutSeconds } from "./daemon-args.js"
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` and `attach` ensure), each session's task host (`p-*`), each
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", "attach", "status", "stop", "handoff", "gc", "rollback-prepare"])
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", "attach", "handoff"])
36
+ const NEEDS_SPEC = new Set(["run", "handoff"])
35
37
 
36
38
  const USAGE = [
37
- "usage: omo daemon <run|attach|status|stop|handoff|gc|rollback-prepare> [options]",
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
- " attach print the environment a child needs to reach the daemon",
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` and `attach` are omo's
67
- * words for the same ensure: one asks for a daemon, the other asks for a daemon plus the
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", attach: "ensure", status: "status", stop: "stop", handoff: "handoff" }
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 === "attach" || subcommand === "handoff") {
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
- stderr.write(`omo daemon: unknown subcommand '${subcommand}'\n${USAGE}\n`)
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
+ }
@@ -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 attach <launch args>`: the daemon is reachable, so this becomes a normal launch
248
- // whose environment points the engine at the shared socket instead of starting its own.
249
- if (typeof outcome === "object") {
250
- const senpi = preparedSenpi()
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.1-3 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.",
2
+ "_comment": "Derived from @code-yeongyu/senpi 2026.10.3 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
+ }