omo-ai 5.1.0 → 5.1.1

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.
@@ -97,7 +97,7 @@ export async function engineAvailableModels(input) {
97
97
  }
98
98
 
99
99
  /**
100
- * @typedef {{ name: string, providers: string[] }} UnusableCategory
100
+ * @typedef {{ name: string, providers: string[], unlistedProviderModel?: string }} UnusableCategory
101
101
  * @typedef {{ usable: string[], unusable: UnusableCategory[] }} CategoryCoverage
102
102
  * @typedef {{
103
103
  * models: EngineModel[],
@@ -120,6 +120,10 @@ function total(coverage) {
120
120
 
121
121
  // The category-unavailable notice's wording (omo-senpi components/task/category-unavailable-warning.ts).
122
122
  function gapLine(gap) {
123
+ if (gap.unlistedProviderModel !== undefined) {
124
+ const gateway = gap.unlistedProviderModel.slice(0, gap.unlistedProviderModel.indexOf("/"))
125
+ return `Category "${gap.name}" is unavailable: only ${gateway}, which its chain does not list, serves its models, and omo never bills a builtin category to an unlisted provider. Connect one of its providers (${gap.providers.join(", ")}) with /login <provider>, or opt in with categories.${gap.name}.model = "${gap.unlistedProviderModel}" in omo.json.`
126
+ }
123
127
  if (gap.providers.length === 0) {
124
128
  return `Category "${gap.name}" has no usable model: your connected providers serve none of its fallback-chain models. Pin categories.${gap.name}.model in omo.json.`
125
129
  }
@@ -0,0 +1,54 @@
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`.
4
+ */
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
+ /** A named code per outcome, so a script never has to parse the message to know what happened. */
23
+ export const DAEMON_EXIT = {
24
+ ok: 0,
25
+ usage: 2,
26
+ notRunning: 3,
27
+ unsupported: 4,
28
+ engineRefused: 5,
29
+ }
30
+
31
+ export function readTimeoutSeconds(args) {
32
+ const index = args.indexOf("--timeout")
33
+ if (index === -1) return 600
34
+ const value = Number(args[index + 1])
35
+ return Number.isFinite(value) && value >= 0 ? value : 600
36
+ }
37
+
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
+ export function blockingPause(milliseconds) {
53
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds)
54
+ }
@@ -0,0 +1,30 @@
1
+ import { DAEMON_EXIT } from "./daemon-args.js"
2
+ import { parseEngineLine, readAllEndpoints } from "./daemon-operations.js"
3
+ import { formatDoctorLines } from "./daemon-status.js"
4
+
5
+ /**
6
+ * The `omo doctor` view: one INFO line, never a FAIL - a machine without a daemon is healthy,
7
+ * it just has nothing shared to report. Returned as lines so doctor can place it with the rest.
8
+ */
9
+ export function daemonReportLines({ engine, pluginRoot, agentDir, env, platform }) {
10
+ if (platform === "win32") return ["INFO Daemon: unavailable on win32 (no unix socket to share)"]
11
+ const all = readAllEndpoints(engine, agentDir, env)
12
+ if (all.kind === "all") {
13
+ if (all.endpoints.length === 0) return ["INFO Daemon: not running"]
14
+ return formatDoctorLines(all.endpoints)
15
+ }
16
+ const result = engine.run(["host", "status", "--json"], { env: { ...env, OMO_AGENT_DIR: agentDir } })
17
+ const parsed = parseEngineLine(result.stdout ?? "")
18
+ if (result.exitCode !== DAEMON_EXIT.ok || parsed === undefined) {
19
+ return ["INFO Daemon: not running", "INFO Hosts: engine too old to enumerate"]
20
+ }
21
+ const sessions = parsed.sessions?.total ?? parsed.sessions?.length ?? 0
22
+ const parts = [
23
+ `pid ${parsed.pid}`,
24
+ parsed.instanceId === undefined ? undefined : `instance ${parsed.instanceId}`,
25
+ parsed.engineVersion === undefined ? undefined : `engine ${parsed.engineVersion}`,
26
+ `${sessions} session(s)`,
27
+ `zombies ${parsed.zombies ?? 0}`,
28
+ ].filter(Boolean)
29
+ return [`INFO Daemon: running ${parts.join(" · ")}`, "INFO Hosts: engine too old to enumerate"]
30
+ }
@@ -0,0 +1,190 @@
1
+ import { existsSync, rmSync } from "node:fs"
2
+ import { basename, join, resolve } from "node:path"
3
+
4
+ import { decorateEndpoints, formatEndpointLines, statusPayload } from "./daemon-status.js"
5
+
6
+ export function parseEngineLine(stdout) {
7
+ const line = stdout.trim().split("\n").filter(Boolean).pop()
8
+ if (line === undefined) return undefined
9
+ try {
10
+ return JSON.parse(line)
11
+ } catch {
12
+ return undefined
13
+ }
14
+ }
15
+
16
+ export function readAllEndpoints(engine, agentDir, env) {
17
+ const result = engine.run(["host", "status", "--json", "--all", "--include-workers"], {
18
+ env: { ...env, OMO_AGENT_DIR: agentDir },
19
+ })
20
+ const parsed = parseEngineLine(result.stdout ?? "")
21
+ if (result.exitCode === 2 || !Array.isArray(parsed?.endpoints)) return { kind: "legacy", result }
22
+ const endpoints = decorateEndpoints(parsed.endpoints, agentDir)
23
+ return { kind: "all", result, endpoints }
24
+ }
25
+
26
+ export function runStatus({ engine, agentDir, env, json, stdout, stderr, _test }) {
27
+ const all = readAllEndpoints(engine, agentDir, env)
28
+ if (all.kind === "legacy") return { legacy: true, result: all.result }
29
+ if (all.result.stderr) stderr.write(all.result.stderr)
30
+ _test?.afterRead?.({ agentDir, endpoints: all.endpoints })
31
+ if (all.endpoints.length === 0 && all.result.exitCode !== 0) {
32
+ if (json) stdout.write(`${JSON.stringify(statusPayload([]))}\n`)
33
+ else stdout.write("daemon: not running\n")
34
+ return { legacy: false, exitCode: all.result.exitCode, endpoints: [] }
35
+ }
36
+ if (json) stdout.write(`${JSON.stringify(statusPayload(all.endpoints))}\n`)
37
+ else stdout.write(`${formatEndpointLines(all.endpoints).join("\n")}\n`)
38
+ return { legacy: false, exitCode: all.result.exitCode, endpoints: all.endpoints }
39
+ }
40
+
41
+ function liveEndpoints(endpoints) {
42
+ return endpoints.filter((endpoint) => endpoint.reachable && typeof endpoint.socket === "string")
43
+ }
44
+
45
+ function ownedEndpoints(endpoints) {
46
+ return endpoints.filter((endpoint) => {
47
+ if (typeof endpoint.socket !== "string") return false
48
+ return endpoint.reachable ||
49
+ (endpoint.generations ?? []).some((generation) => generation.alive) ||
50
+ (endpoint.claims_live ?? 0) > 0
51
+ })
52
+ }
53
+
54
+ function endpointCall(engine, args, agentDir, env) {
55
+ const result = engine.run(args, { env: { ...env, OMO_AGENT_DIR: agentDir } })
56
+ return { result, parsed: parseEngineLine(result.stdout ?? "") }
57
+ }
58
+
59
+ export function runGc({ engine, migration, agentDir, env, json, pruneStoreIndex, stdout, stderr }) {
60
+ const { result, parsed } = endpointCall(engine, ["host", "gc", "--json"], agentDir, env)
61
+ if (result.stderr) stderr.write(result.stderr)
62
+ const removed = Array.isArray(parsed?.removed) ? parsed.removed : []
63
+ const kept = Array.isArray(parsed?.kept) ? parsed.kept : []
64
+ const reapedMeta = []
65
+ for (const entry of removed) {
66
+ if (typeof entry.socket !== "string") continue
67
+ const meta = entry.socket.replace(/\.sock$/, ".meta.json")
68
+ if (!existsSync(meta)) continue
69
+ rmSync(meta, { force: true })
70
+ reapedMeta.push(meta)
71
+ }
72
+ const prunedStores = pruneStoreIndex
73
+ ? migration.run({ operation: "prune-store-index", indexPath: join(agentDir, "rpc", "task-stores.json") }).removed
74
+ : []
75
+ const payload = { removed, kept, reaped_meta: reapedMeta, pruned_stores: prunedStores }
76
+ if (json) stdout.write(`${JSON.stringify(payload)}\n`)
77
+ else stdout.write(`reaped: ${removed.length}\n`)
78
+ return result.exitCode
79
+ }
80
+
81
+ export function runHandoff({ engine, pluginRoot, agentDir, env, policy, config, stdout, stderr }) {
82
+ const all = readAllEndpoints(engine, agentDir, env)
83
+ if (all.kind === "legacy") {
84
+ const call = endpointCall(
85
+ engine,
86
+ buildEnsureArgs("handoff", undefined, pluginRoot, policy),
87
+ agentDir,
88
+ hostCommandEnvironment(env, agentDir, config),
89
+ )
90
+ if (call.result.stderr) stderr.write(call.result.stderr)
91
+ stdout.write(`daemon: ${call.parsed?.action ?? "handoff"}\n`)
92
+ return call.result.exitCode
93
+ }
94
+ let refused = false
95
+ const commandEnv = hostCommandEnvironment(env, agentDir, config)
96
+ for (const endpoint of liveEndpoints(all.endpoints)) {
97
+ const daemon = basename(endpoint.socket) === "rpc.sock"
98
+ const args = daemon
99
+ ? buildEnsureArgs("handoff", undefined, pluginRoot, policy)
100
+ : buildEnsureArgs("ensure", endpoint.socket, pluginRoot, policy)
101
+ const call = endpointCall(engine, args, agentDir, commandEnv)
102
+ if (call.result.stderr) stderr.write(call.result.stderr)
103
+ if (call.result.exitCode !== 0 || call.parsed?.action === "refuse") refused = true
104
+ stdout.write(`${endpoint.socket}: ${call.parsed?.action ?? "refuse"}\n`)
105
+ }
106
+ return refused ? 3 : 0
107
+ }
108
+
109
+ function buildEnsureArgs(command, socket, pluginRoot, policy) {
110
+ const args = ["host", command, "--json"]
111
+ if (socket !== undefined) args.push("--socket", socket)
112
+ args.push("--launch-spec", join(pluginRoot, "daemon-launch-spec.json"), "--policy", policy)
113
+ return args
114
+ }
115
+
116
+ export function hostCommandEnvironment(env, agentDir, config) {
117
+ const result = { ...env, OMO_AGENT_DIR: agentDir }
118
+ const idleExitMs = config?.task?.host_idle_exit_ms
119
+ if (typeof idleExitMs === "number" && Number.isFinite(idleExitMs) && idleExitMs > 0) {
120
+ result.SENPI_RPC_HOST_IDLE_EXIT_MS = String(Math.trunc(idleExitMs))
121
+ }
122
+ return result
123
+ }
124
+
125
+ export function runStopAll({ engine, agentDir, env, drain, wait, timeoutSeconds, stdout, stderr, now, pause }) {
126
+ const all = readAllEndpoints(engine, agentDir, env)
127
+ if (all.kind === "legacy") return undefined
128
+ let failed = false
129
+ for (const endpoint of ownedEndpoints(all.endpoints)) {
130
+ const args = ["host", "stop", "--json", "--socket", endpoint.socket]
131
+ if (drain) args.push("--drain")
132
+ const call = endpointCall(engine, args, agentDir, env)
133
+ if (call.result.stderr) stderr.write(call.result.stderr)
134
+ if (!wait) {
135
+ if (call.result.exitCode !== 0) failed = true
136
+ stdout.write(`${endpoint.socket}: requested (not awaited)\n`)
137
+ continue
138
+ }
139
+ const action = call.parsed?.action
140
+ const refusal = action === "refuse" ? call.parsed?.reason : action
141
+ const liveAtRequest = (endpoint.generations ?? []).some((generation) => generation.alive)
142
+ const claimsAtRequest = (endpoint.claims_live ?? 0) > 0
143
+ const ownedAtRequest = liveAtRequest || claimsAtRequest
144
+ if (
145
+ call.result.exitCode !== 0 &&
146
+ !(ownedAtRequest && (refusal === "drain_unsupported" || refusal === "unknown_owner"))
147
+ ) {
148
+ if (!ownedAtRequest) {
149
+ stdout.write(`${endpoint.socket}: already drained\n`)
150
+ continue
151
+ }
152
+ failed = true
153
+ stdout.write(`${endpoint.socket}: refused\n`)
154
+ continue
155
+ }
156
+ const result = waitForEndpoint(engine, endpoint.socket, agentDir, env, timeoutSeconds, now, pause)
157
+ if (result.done) stdout.write(`${endpoint.socket}: drained (${result.polls} polls)\n`)
158
+ else {
159
+ failed = true
160
+ stdout.write(`${endpoint.socket}: TIMEOUT: still live (${result.summary})\n`)
161
+ }
162
+ }
163
+ return failed ? 3 : 0
164
+ }
165
+
166
+ function waitForEndpoint(engine, socket, agentDir, env, timeoutSeconds, now, pause) {
167
+ const deadline = now() + timeoutSeconds * 1_000
168
+ let polls = 0
169
+ let latest
170
+ while (now() <= deadline) {
171
+ const call = endpointCall(engine, ["host", "status", "--json", "--socket", socket, "--include-workers"], agentDir, env)
172
+ latest = call.parsed
173
+ polls += 1
174
+ const alive = (latest?.generations ?? []).some((generation) => generation.alive)
175
+ if (!alive && (latest?.claims_live ?? 0) === 0) return { done: true, polls }
176
+ pause(250)
177
+ }
178
+ const pid = latest?.pid ?? "?"
179
+ const sessions = latest?.sessions?.total ?? 0
180
+ const claims = latest?.claims_live ?? 0
181
+ return { done: false, polls, summary: `pid ${pid}, ${sessions} sessions, ${claims} claims` }
182
+ }
183
+
184
+ export function parseStoreArgs(args) {
185
+ const stores = []
186
+ for (let index = 0; index < args.length; index += 1) {
187
+ if (args[index] === "--store" && args[index + 1] !== undefined) stores.push(resolve(args[index + 1]))
188
+ }
189
+ return stores
190
+ }
@@ -0,0 +1,158 @@
1
+ import { createHash } from "node:crypto"
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
3
+ import { basename, join, resolve } from "node:path"
4
+
5
+ import { parseEngineLine, readAllEndpoints } from "./daemon-operations.js"
6
+
7
+ function readJson(path) {
8
+ try {
9
+ return JSON.parse(readFileSync(path, "utf8"))
10
+ } catch {
11
+ return undefined
12
+ }
13
+ }
14
+
15
+ function sidecarRoots(agentDir) {
16
+ const alt = createHash("sha256").update(agentDir, "utf8").digest("hex").slice(0, 8)
17
+ return [join(agentDir, "rpc", "shards"), `/tmp/omo-rpc-${alt}`]
18
+ }
19
+
20
+ function sidecars(agentDir) {
21
+ const rows = []
22
+ for (const root of sidecarRoots(agentDir)) {
23
+ let names
24
+ try {
25
+ names = readdirSync(root)
26
+ } catch {
27
+ continue
28
+ }
29
+ for (const name of names.filter((entry) => entry.endsWith(".meta.json")).toSorted()) {
30
+ const path = join(root, name)
31
+ const document = readJson(path)
32
+ if (document !== undefined) rows.push({ path, document })
33
+ }
34
+ }
35
+ return rows
36
+ }
37
+
38
+ function indexStores(agentDir) {
39
+ const path = join(agentDir, "rpc", "task-stores.json")
40
+ if (!existsSync(path)) return { present: false, stores: [] }
41
+ const document = readJson(path)
42
+ if (document?.version !== 1 || document.stores === null || typeof document.stores !== "object") {
43
+ return { present: false, stores: [] }
44
+ }
45
+ return { present: true, stores: Object.keys(document.stores).map((store) => resolve(store)) }
46
+ }
47
+
48
+ function collectStores(agentDir, explicitStores) {
49
+ const index = indexStores(agentDir)
50
+ const metadata = sidecars(agentDir)
51
+ const fromSidecars = metadata.flatMap(({ document }) => Array.isArray(document.stores) ? document.stores : [])
52
+ const stores = [
53
+ ...new Set([...index.stores, ...fromSidecars, ...explicitStores].map((store) => resolve(store))),
54
+ ].toSorted()
55
+ return {
56
+ index,
57
+ metadata,
58
+ fromSidecars: [...new Set(fromSidecars.map((store) => resolve(store)))],
59
+ stores,
60
+ }
61
+ }
62
+
63
+ function statusForSocket(engine, socket, agentDir, env) {
64
+ const result = engine.run(
65
+ ["host", "status", "--json", "--socket", socket, "--include-workers"],
66
+ { env: { ...env, OMO_AGENT_DIR: agentDir } },
67
+ )
68
+ const parsed = parseEngineLine(result.stdout ?? "")
69
+ if (
70
+ parsed === undefined ||
71
+ !Array.isArray(parsed.generations) ||
72
+ typeof parsed.claims_live !== "number"
73
+ ) {
74
+ return { readable: false }
75
+ }
76
+ return { readable: true, report: parsed }
77
+ }
78
+
79
+ export function runRollbackPrepare({
80
+ engine,
81
+ migration,
82
+ agentDir,
83
+ env,
84
+ explicitStores,
85
+ allowMissingIndex,
86
+ dryRun,
87
+ json,
88
+ stdout,
89
+ stderr,
90
+ }) {
91
+ const discovered = collectStores(agentDir, explicitStores)
92
+ const endpointSweep = readAllEndpoints(engine, agentDir, env)
93
+ const endpointEvidence = endpointSweep.kind === "all" && endpointSweep.endpoints.some((endpoint) => {
94
+ if (typeof endpoint.socket !== "string") return true
95
+ return basename(endpoint.socket) !== "rpc.sock"
96
+ })
97
+ if (
98
+ !discovered.index.present &&
99
+ (discovered.metadata.length > 0 || endpointEvidence) &&
100
+ !allowMissingIndex &&
101
+ explicitStores.length === 0
102
+ ) {
103
+ stderr.write("store index missing: pass --store <dir> for every project that ran task children, or --allow-missing-index\n")
104
+ return 3
105
+ }
106
+
107
+ const missing = discovered.stores.filter((store) => !existsSync(store))
108
+ const present = discovered.stores.filter((store) => existsSync(store))
109
+ const plans = present.map((storeDir) => migration.run({ storeDir, to: join(agentDir, "rpc", "rpc.sock"), planOnly: true }))
110
+ const sockets = [...new Set(plans.flatMap((plan) => plan.sockets))].toSorted()
111
+ const deadEndpoints = []
112
+ for (const socket of sockets) {
113
+ const statusResult = statusForSocket(engine, socket, agentDir, env)
114
+ if (!statusResult.readable) {
115
+ stderr.write(`rollback refused: endpoint status unreadable ${socket}\n`)
116
+ return 3
117
+ }
118
+ const status = statusResult.report
119
+ const alive = (status?.generations ?? []).some((generation) => generation.alive)
120
+ if (alive || (status?.claims_live ?? 0) > 0) {
121
+ stderr.write(`rollback refused: endpoint still live ${socket}\n`)
122
+ return 3
123
+ }
124
+ deadEndpoints.push(socket)
125
+ }
126
+
127
+ const results = present.map((storeDir) => migration.run({
128
+ storeDir,
129
+ to: join(agentDir, "rpc", "rpc.sock"),
130
+ deadEndpoints,
131
+ dryRun,
132
+ }))
133
+ const storesWithoutMap = discovered.metadata
134
+ .filter(({ document }) => !Array.isArray(document.stores))
135
+ .map(({ document, path }) => document.socket ?? basename(path))
136
+ const payload = {
137
+ dry_run: dryRun,
138
+ stores: results,
139
+ coverage: {
140
+ discovered: discovered.stores.length,
141
+ from_index: discovered.index.stores.length,
142
+ from_sidecars: discovered.fromSidecars.length,
143
+ from_store: explicitStores.length,
144
+ missing,
145
+ endpoints_without_store_map: storesWithoutMap,
146
+ },
147
+ }
148
+ if (json) stdout.write(`${JSON.stringify(payload)}\n`)
149
+ else {
150
+ for (const result of results) {
151
+ stdout.write(`${result.store_dir}: migrated: ${dryRun ? result.migrate : result.migrated}, skipped: ${result.skipped}\n`)
152
+ }
153
+ stdout.write(
154
+ `stores: ${payload.coverage.discovered} discovered (${payload.coverage.from_sidecars} from sidecars, ${payload.coverage.from_store} from --store), ${missing.length} missing, endpoints without a store map: ${storesWithoutMap.join(", ") || "none"}\n`,
155
+ )
156
+ }
157
+ return 0
158
+ }
@@ -0,0 +1,128 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { basename, dirname, join } from "node:path"
3
+
4
+ function readJson(path) {
5
+ if (!existsSync(path)) return undefined
6
+ try {
7
+ return JSON.parse(readFileSync(path, "utf8"))
8
+ } catch {
9
+ return undefined
10
+ }
11
+ }
12
+
13
+ function sidecarPath(endpoint, agentDir) {
14
+ if (endpoint.shard === null || endpoint.shard === undefined) return undefined
15
+ if (typeof endpoint.socket === "string") {
16
+ return join(dirname(endpoint.socket), `${endpoint.shard.kind}-${endpoint.shard.key}.meta.json`)
17
+ }
18
+ return join(agentDir, "rpc", "shards", `${endpoint.shard.kind}-${endpoint.shard.key}.meta.json`)
19
+ }
20
+
21
+ export function decorateEndpoints(endpoints, agentDir) {
22
+ return endpoints.map((endpoint) => {
23
+ const path = sidecarPath(endpoint, agentDir)
24
+ const owner = path === undefined ? undefined : readJson(path)
25
+ return owner === undefined ? endpoint : { ...endpoint, owner }
26
+ })
27
+ }
28
+
29
+ export function aggregateEndpoints(endpoints) {
30
+ const live = endpoints.filter((endpoint) => endpoint.reachable)
31
+ return {
32
+ live: live.length,
33
+ shards: live.filter((endpoint) => endpoint.shard?.kind === "p").length,
34
+ threads: live.filter((endpoint) => endpoint.shard?.kind === "i").length,
35
+ sessions: live.reduce((total, endpoint) => total + (endpoint.sessions?.total ?? 0), 0),
36
+ rss_mb: live.reduce((total, endpoint) => total + (endpoint.rss_mb ?? 0), 0),
37
+ host_rss_mb: live.reduce((total, endpoint) => total + (endpoint.host_rss_mb ?? 0), 0),
38
+ crashes: endpoints.reduce((total, endpoint) => total + (endpoint.crashes ?? 0), 0),
39
+ }
40
+ }
41
+
42
+ function endpointKind(endpoint) {
43
+ if (endpoint.shard?.kind === "p") return "shard"
44
+ if (endpoint.shard?.kind === "i") return "thread"
45
+ if (typeof endpoint.socket === "string" && basename(endpoint.socket) === "rpc.sock") return "daemon"
46
+ return "endpoint"
47
+ }
48
+
49
+ function endpointName(endpoint) {
50
+ const kind = endpointKind(endpoint)
51
+ if (kind === "daemon") return "daemon"
52
+ if (kind === "shard") {
53
+ const owner = endpoint.owner?.owner_session_id
54
+ const file = endpoint.owner?.owner_session_file
55
+ const suffix = [owner === undefined ? undefined : `parent ${owner.slice(0, 8)}`, file === undefined ? undefined : basename(file)]
56
+ .filter(Boolean)
57
+ .join(", ")
58
+ return `shard p-${endpoint.shard.key}${suffix === "" ? "" : ` (${suffix})`}`
59
+ }
60
+ if (kind === "thread") {
61
+ const owner = endpoint.owner?.owner_session_id
62
+ return `thread i-${endpoint.shard.key}${owner === undefined ? "" : ` (thread ${owner.slice(0, 8)})`}`
63
+ }
64
+ return `endpoint ${typeof endpoint.socket === "string" ? basename(endpoint.socket) : basename(endpoint.dir ?? "unknown")}`
65
+ }
66
+
67
+ function metric(value) {
68
+ return value === null || value === undefined ? "?" : String(value)
69
+ }
70
+
71
+ function runningDetails(endpoint, includeGeneration = true) {
72
+ const parts = [
73
+ `running pid ${metric(endpoint.pid)}`,
74
+ includeGeneration && endpoint.generation !== null && endpoint.generation !== undefined
75
+ ? `gen ${endpoint.generation}`
76
+ : undefined,
77
+ endpoint.engineVersion === null || endpoint.engineVersion === undefined ? undefined : `engine ${endpoint.engineVersion}`,
78
+ `${endpoint.sessions?.total ?? 0} session(s)`,
79
+ `rss ${metric(endpoint.rss_mb)} MB (host ${metric(endpoint.host_rss_mb)} MB)`,
80
+ `fds ${metric(endpoint.open_fds)}`,
81
+ `crashes ${endpoint.crashes ?? 0}`,
82
+ ].filter(Boolean)
83
+ return parts.join(" · ")
84
+ }
85
+
86
+ function generationLine(generation, endpoint) {
87
+ const state = generation.current ? "current" : "draining"
88
+ const sessions = generation.sessions ?? endpoint.sessions?.total ?? 0
89
+ 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
+ }
91
+
92
+ export function formatEndpointLines(endpoints) {
93
+ const lines = []
94
+ for (const endpoint of endpoints) {
95
+ const name = endpointName(endpoint)
96
+ if (!endpoint.reachable) {
97
+ lines.push(`${name}: not running (retained state kept; run omo daemon gc)`)
98
+ continue
99
+ }
100
+ lines.push(`${name}: ${runningDetails(endpoint, endpointKind(endpoint) !== "daemon")}`)
101
+ if ((endpoint.generations?.length ?? 0) > 1) {
102
+ for (const generation of endpoint.generations) lines.push(generationLine(generation, endpoint))
103
+ }
104
+ }
105
+ const aggregate = aggregateEndpoints(endpoints)
106
+ 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)`,
108
+ )
109
+ return lines
110
+ }
111
+
112
+ export function formatDoctorLines(endpoints) {
113
+ return formatEndpointLines(endpoints).map((line) => {
114
+ const prefix = line.startsWith("hosts:")
115
+ ? "INFO Hosts:"
116
+ : line.includes(": not running")
117
+ ? "WARN "
118
+ : line.startsWith(" gen ")
119
+ ? "INFO "
120
+ : "INFO "
121
+ if (line.startsWith("hosts:")) return `${prefix}${line.slice("hosts:".length)}`
122
+ return `${prefix}${line[0]?.toUpperCase() ?? ""}${line.slice(1)}`
123
+ })
124
+ }
125
+
126
+ export function statusPayload(endpoints) {
127
+ return { endpoints, aggregate: aggregateEndpoints(endpoints) }
128
+ }