agentus 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +11 -0
  3. package/README.md +459 -2
  4. package/bin/agentus.mjs +147 -0
  5. package/docs/android-notify-contract.md +658 -0
  6. package/docs/refs/aionui-acp-rules.md +46 -0
  7. package/docs/refs/hermes-studio-ui-ideas.md +191 -0
  8. package/docs/transcript-folding.md +139 -0
  9. package/package.json +75 -5
  10. package/packages/server/mcp/plan-server.mjs +217 -0
  11. package/packages/server/mock/agent.mjs +658 -0
  12. package/packages/server/package.json +22 -0
  13. package/packages/server/src/acp/backends.ts +196 -0
  14. package/packages/server/src/acp/errors.ts +46 -0
  15. package/packages/server/src/acp/registry.ts +579 -0
  16. package/packages/server/src/acp/session-manager.ts +1868 -0
  17. package/packages/server/src/auth.ts +688 -0
  18. package/packages/server/src/dashscope.ts +235 -0
  19. package/packages/server/src/fs.ts +165 -0
  20. package/packages/server/src/hotwords.ts +132 -0
  21. package/packages/server/src/index.ts +1404 -0
  22. package/packages/server/src/notify/center.ts +994 -0
  23. package/packages/server/src/notify/types.ts +222 -0
  24. package/packages/server/src/plan/plan.ts +126 -0
  25. package/packages/server/src/settings.ts +469 -0
  26. package/packages/server/src/store/export.ts +209 -0
  27. package/packages/server/src/store/store.ts +1238 -0
  28. package/packages/server/src/term.ts +160 -0
  29. package/packages/server/src/title.ts +109 -0
  30. package/packages/server/src/voice.ts +251 -0
  31. package/packages/shared/package.json +14 -0
  32. package/packages/shared/src/index.ts +271 -0
  33. package/packages/web/dist/assets/index-B52l55_4.css +1 -0
  34. package/packages/web/dist/assets/index-BRgC7oYt.js +246 -0
  35. package/packages/web/dist/coding-agents/hermes.png +0 -0
  36. package/packages/web/dist/coding-agents/qoder.svg +5 -0
  37. package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
  38. package/packages/web/dist/icons/agentus-icon.svg +301 -0
  39. package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
  40. package/packages/web/dist/icons/agentus-mark.svg +301 -0
  41. package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
  42. package/packages/web/dist/icons/favicon.ico +0 -0
  43. package/packages/web/dist/icons/icon-192.png +0 -0
  44. package/packages/web/dist/icons/icon-512.png +0 -0
  45. package/packages/web/dist/icons/icon-maskable-512.png +0 -0
  46. package/packages/web/dist/index.html +30 -0
  47. package/packages/web/dist/manifest.webmanifest +17 -0
  48. package/packages/web/dist/sw.js +61 -0
  49. package/scripts/make-cert.sh +101 -0
@@ -0,0 +1,579 @@
1
+ // Operator-managed backend registry (M6).
2
+ //
3
+ // "which hermes" turned out to be THREE independent choices, not one:
4
+ // 1. which CODE — the command (`hermes` vs `hermes-dev`) and/or PYTHONPATH (official
5
+ // runtime tree vs the operator's fork under ~/Project/hermes-agent);
6
+ // 2. which DATA — HERMES_HOME (live ~/.hermes vs a disposable test home);
7
+ // 3. which PROFILE— `hermes -p <profile>` inside that home.
8
+ // Until now 1 and 2 were env vars fixed at server start (AGENTUS_HERMES_CMD /
9
+ // AGENTUS_HERMES_HOME) and 3 was impossible, so isolating a slot meant restarting the
10
+ // cockpit. Rows live in the store, are edited from the UI, and are resolved per spawn.
11
+ //
12
+ // backends.ts stays the isolation CORE (buildSpawnEnv + the fail-closed live-home rule) and
13
+ // the source of the builtin seed rows. Every row is converted into a BackendSpec and goes
14
+ // through that same guard, so adding a row can never bypass isolation.
15
+ import { execFile } from "node:child_process";
16
+ import fs from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import path from "node:path";
19
+ import { promisify } from "node:util";
20
+ import { BACKENDS, buildSpawnEnv, expandHome, DEFAULT_NATIVE_PLAN_SOURCE, type BackendSpec, type NativePlanSource } from "./backends.js";
21
+
22
+ const run = promisify(execFile);
23
+
24
+ export type BackendKind = "hermes" | "qoder" | "mock";
25
+ export const BACKEND_KINDS: BackendKind[] = ["hermes", "qoder", "mock"];
26
+
27
+ /** The operator's real home. Not a禁区 any more: it is the DEFAULT home for a hermes row. */
28
+ export const HERMES_LIVE_HOME = path.join(homedir(), ".hermes");
29
+ /** Where a hermes row goes when it does not name a home itself: the operator's own home. */
30
+ export const HERMES_DEFAULT_HOME = process.env.AGENTUS_HERMES_HOME || HERMES_LIVE_HOME;
31
+
32
+ /** One registry row: the whole definition of "how to spawn this kind of agent". */
33
+ export interface BackendRow {
34
+ id: string;
35
+ label: string;
36
+ /** spawn family — decides the arg shape and whether a home is injected */
37
+ kind: BackendKind;
38
+ /**
39
+ * Which channel carries this agent's plan: `acp` (it emits plan frames itself, so we inject
40
+ * nothing) or `none` (we inject the `agentus-plan` MCP server at handshake time). A declaration
41
+ * the operator can flip when an agent gains or loses the native ability — the alternative is a
42
+ * code change per agent, which is exactly what this design exists to avoid.
43
+ */
44
+ nativePlanSource: NativePlanSource;
45
+ cmd: string;
46
+ /** args WITHOUT the profile flag (effectiveArgs() composes that) */
47
+ args: string[];
48
+ /** extra env handed to the child (e.g. PYTHONPATH=<fork tree>); the home always wins */
49
+ env: Record<string, string>;
50
+ /** HERMES_HOME for kind=hermes (null/empty = HERMES_DEFAULT_HOME, the operator's ~/.hermes) */
51
+ home: string | null;
52
+ /** hermes `-p <profile>` — a profile inside that home, i.e. a second isolation axis */
53
+ profile: string | null;
54
+ /** default working directory for new slots (null = the dialog asks) */
55
+ cwd: string | null;
56
+ notes: string;
57
+ /** seeded row (the ones that used to be env-driven); editable, but flagged in the UI */
58
+ builtin: boolean;
59
+ /**
60
+ * Last known health (system-managed, never accepted from a request body). Kept on the row so
61
+ * the list can answer "is this usable, and since when" without re-running anything — the
62
+ * point being that a failure stays visible after the tab is closed.
63
+ */
64
+ health: BackendHealth;
65
+ /** What the agent advertised the last time a slot really started from this row. */
66
+ handshake: BackendHandshake | null;
67
+ createdAt: number;
68
+ updatedAt: number;
69
+ }
70
+
71
+ export interface BackendPlan {
72
+ home: string | null;
73
+ warnings: string[];
74
+ }
75
+
76
+ // ---- health snapshot ---------------------------------------------------------------------
77
+ //
78
+ // A single boolean is not enough to be useful: "it failed" is only actionable together with
79
+ // WHAT failed (a structured code), WHEN it last worked, and HOW hard the check looked. Check
80
+ // depth matters because the three kinds are not equal evidence:
81
+ // startup — command resolves on PATH (milliseconds, never spawns anything);
82
+ // manual — the 探测 button: `--version` (+ `acp --check` for hermes);
83
+ // session — a real slot reached `ready`, or died in the handshake: the only proof.
84
+
85
+ export type CheckStatus = "online" | "offline" | "missing" | "unchecked";
86
+ export type CheckKind = "startup" | "manual" | "session";
87
+ export type CheckErrorCode =
88
+ | "command_not_found"
89
+ | "spawn_failed"
90
+ | "version_failed"
91
+ | "acp_check_failed"
92
+ | "acp_init_failed"
93
+ | "auth_required"
94
+ | "timeout"
95
+ | "unknown";
96
+
97
+ export interface BackendHealth {
98
+ status: CheckStatus;
99
+ kind: CheckKind | null;
100
+ errorCode: CheckErrorCode | null;
101
+ message: string | null;
102
+ /** what the operator should DO about it (the code alone is a riddle) */
103
+ guidance: string | null;
104
+ latencyMs: number | null;
105
+ at: number | null;
106
+ lastSuccessAt: number | null;
107
+ lastFailureAt: number | null;
108
+ }
109
+
110
+ export function emptyHealth(): BackendHealth {
111
+ return {
112
+ status: "unchecked", kind: null, errorCode: null, message: null, guidance: null,
113
+ latencyMs: null, at: null, lastSuccessAt: null, lastFailureAt: null,
114
+ };
115
+ }
116
+
117
+ /** One line per code, addressed to the operator, saying what to fix. */
118
+ export const ERROR_GUIDANCE: Record<CheckErrorCode, string> = {
119
+ command_not_found:
120
+ "命令不在 PATH 里,也不是可执行的绝对路径。先确认装了没有,或把命令写成绝对路径。",
121
+ spawn_failed: "命令在,但进程起不来(缺解释器、没执行权限之类)。看下面原文。",
122
+ version_failed:
123
+ "`--version` 跑不通。多半是这行的环境变量(例如 PYTHONPATH)指向的代码树不完整,或该命令不支持 --version。",
124
+ acp_check_failed:
125
+ "`acp --check` 没过:这个 home 里的配置/凭据可能不对。注意:过了也不代表能起会话(--check 在适配器模块导入之前就返回了)。",
126
+ acp_init_failed:
127
+ "ACP 握手失败(initialize / newSession)。这才是真正的“能不能用”判据,看下面原文。",
128
+ auth_required: "看着像没登录/凭据不对,先给这个后端做一次登录(例如 qodercli login),再探测。",
129
+ timeout: "探测超时:命令可能卡住(等输入、等网络)。行本身没坏,重试或换个命令试试。",
130
+ unknown: "没归类的错误,看下面原文。",
131
+ };
132
+
133
+ const AUTH_RE = /(401|403|unauthor|forbidden|api[_ -]?key|not logged in|please log ?in|invalid[_ -]?token|token .*(expired|invalid)|credential)/i;
134
+ const NOT_FOUND_RE = /(ENOENT|command not found|not found|no such file)/i;
135
+ const TIMEOUT_RE = /(timed out|timeout|ETIMEDOUT|killed)/i;
136
+
137
+ /** Map a raw failure (stderr, exception message…) onto the code the UI can act on. */
138
+ export function classifyMessage(text: string, fallback: CheckErrorCode = "unknown"): CheckErrorCode {
139
+ if (AUTH_RE.test(text)) return "auth_required";
140
+ if (TIMEOUT_RE.test(text)) return "timeout";
141
+ if (NOT_FOUND_RE.test(text)) return "command_not_found";
142
+ return fallback;
143
+ }
144
+
145
+ /** The classification of a spawn/handshake exception (session-kind checks). */
146
+ export function classifyError(e: unknown): CheckErrorCode {
147
+ const text = e instanceof Error ? `${e.message}` : String(e);
148
+ return classifyMessage(text, "acp_init_failed");
149
+ }
150
+
151
+ /**
152
+ * Fold one check result into the row's snapshot: `at` always moves, success/failure stamps only
153
+ * move for their own outcome, and a failure keeps its code + the guidance that goes with it.
154
+ */
155
+ export function checkedHealth(
156
+ prev: BackendHealth | null | undefined,
157
+ result: {
158
+ status: CheckStatus;
159
+ kind: CheckKind;
160
+ errorCode?: CheckErrorCode | null;
161
+ message?: string | null;
162
+ latencyMs?: number | null;
163
+ },
164
+ now = Date.now(),
165
+ ): BackendHealth {
166
+ const base = prev ?? emptyHealth();
167
+ const failed = result.status === "offline" || result.status === "missing";
168
+ return {
169
+ status: result.status,
170
+ kind: result.kind,
171
+ errorCode: result.errorCode ?? null,
172
+ message: result.message ?? null,
173
+ guidance: result.errorCode ? ERROR_GUIDANCE[result.errorCode] : null,
174
+ latencyMs: result.latencyMs ?? null,
175
+ at: now,
176
+ lastSuccessAt: result.status === "online" ? now : base.lastSuccessAt,
177
+ // "unchecked" is not a failure — it is the absence of evidence (e.g. a blocked row).
178
+ lastFailureAt: failed ? now : base.lastFailureAt,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * What the agent said about itself when a slot last started from this row. Cached on the row
184
+ * because it is a property of (command, env, home, profile) — not of one conversation — and
185
+ * because "which controls may we draw" has to be answerable without a live session.
186
+ */
187
+ export interface BackendHandshake {
188
+ at: number;
189
+ protocolVersion: number | null;
190
+ /** agentCapabilities.loadSession */
191
+ loadSession: boolean | null;
192
+ /** agentCapabilities.sessionCapabilities.fork */
193
+ fork: boolean | null;
194
+ modes: { currentModeId: string | null; available: string[] } | null;
195
+ configOptions: { id: string; name: string | null; currentValue: string | null }[];
196
+ models: { currentModelId: string | null; available: string[] } | null;
197
+ /** slash commands the agent announced (session/update or newSession) */
198
+ commands: string[];
199
+ }
200
+
201
+ /** The rows that reproduce the pre-M6 behaviour, seeded once on an empty registry. */
202
+ export function seedRows(now = Date.now()): BackendRow[] {
203
+ const notes: Record<string, string> = {
204
+ hermes: "Studio 自带的官方 runtime(默认,改源码不影响它)",
205
+ qoder: "Qoder CLI(需 qodercli login)",
206
+ mock: "本地 mock 代理,QA 用,不花 token",
207
+ };
208
+ return Object.entries(BACKENDS).map(([id, spec]) => {
209
+ const kind: BackendKind = id === "hermes" ? "hermes" : id === "qoder" ? "qoder" : "mock";
210
+ return {
211
+ id,
212
+ label: spec.label,
213
+ kind,
214
+ // Every backend is MCP-driven by default — including Hermes, whose frames the cockpit
215
+ // deliberately ignores so the card always reflects the plan OBJECT (which survives a restart).
216
+ // A row may still opt into `acp` by hand; see NativePlanSource.
217
+ nativePlanSource: DEFAULT_NATIVE_PLAN_SOURCE,
218
+ cmd: spec.cmd,
219
+ args: [...spec.args],
220
+ env: {},
221
+ // null = "whatever the default home is" — today the operator's own ~/.hermes, so the row
222
+ // follows the default instead of freezing a path that was only ever a dev-time choice.
223
+ home: null,
224
+ profile: null,
225
+ cwd: null,
226
+ notes: notes[id] ?? "",
227
+ builtin: true,
228
+ health: emptyHealth(),
229
+ handshake: null,
230
+ createdAt: now,
231
+ updatedAt: now,
232
+ };
233
+ });
234
+ }
235
+
236
+ /** Registry row → spawn spec (what backends.ts/buildSpawnEnv expects). */
237
+ export function rowToSpec(row: BackendRow): BackendSpec {
238
+ const spec: BackendSpec = {
239
+ id: row.id,
240
+ kind: row.kind,
241
+ nativePlanSource: row.nativePlanSource,
242
+ label: row.label,
243
+ cmd: expandHome(row.cmd),
244
+ args: [...row.args],
245
+ check: row.kind === "hermes" ? ["acp", "--check"] : null,
246
+ needsLoginHint: row.kind === "qoder" ? "qodercli login" : undefined,
247
+ env: { ...(row.env ?? {}) },
248
+ profile: row.profile,
249
+ };
250
+ if (row.kind === "hermes") {
251
+ spec.isolation = {
252
+ homeVar: "HERMES_HOME",
253
+ homeDefault: row.home && row.home.trim() ? row.home : HERMES_DEFAULT_HOME,
254
+ };
255
+ }
256
+ return spec;
257
+ }
258
+
259
+ /**
260
+ * The argv actually handed to spawn. `hermes -p <profile> acp`: the profile flag is GLOBAL and
261
+ * belongs before the subcommand (verified against the real CLI: `hermes -p default acp --check`
262
+ * → "Hermes ACP check OK"; `hermes acp --profile …` does not parse).
263
+ */
264
+ export function effectiveArgs(row: BackendRow): string[] {
265
+ const args = [...row.args];
266
+ if (row.profile && row.profile.trim() && row.kind === "hermes") {
267
+ return ["-p", row.profile.trim(), ...args];
268
+ }
269
+ return args;
270
+ }
271
+
272
+ /** The home (and any warning about it) one row will spawn with — same code path as the spawn. */
273
+ export function planFor(row: BackendRow): BackendPlan {
274
+ try {
275
+ const plan = buildSpawnEnv(rowToSpec(row));
276
+ return { home: plan.home, warnings: plan.warnings };
277
+ } catch (e) {
278
+ // Nothing here should throw any more (the guard is gone); keep the list alive if it ever does.
279
+ return {
280
+ home: row.home ? expandHome(row.home) : null,
281
+ warnings: [e instanceof Error ? e.message : String(e)],
282
+ };
283
+ }
284
+ }
285
+
286
+ const ID_RE = /^[a-z0-9][a-z0-9._-]{0,40}$/;
287
+
288
+ /** Shape an arbitrary request body into a row. `base` = the row being edited (PATCH). */
289
+ export function coerceRow(body: Record<string, unknown>, base?: BackendRow): { row?: BackendRow; error?: string } {
290
+ const now = Date.now();
291
+ const s = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
292
+ const id = s(body.id) || base?.id || "";
293
+ if (!ID_RE.test(id)) return { error: `id must match ${ID_RE} (lowercase, no spaces)` };
294
+ const kind = (s(body.kind) || base?.kind || "hermes") as BackendKind;
295
+ if (!BACKEND_KINDS.includes(kind)) return { error: `unknown kind: ${kind}` };
296
+ const cmd = s(body.cmd) || base?.cmd || "";
297
+ if (!cmd) return { error: "cmd is required" };
298
+ let args = base?.args ?? [];
299
+ if (Array.isArray(body.args)) args = body.args.map((a) => String(a));
300
+ else if (typeof body.args === "string" && body.args.trim()) {
301
+ // accept a shell-ish string so the UI can offer one input box
302
+ args = (body.args.trim().match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((a) => a.replace(/^["']|["']$/g, ""));
303
+ }
304
+ let env: Record<string, string> = base?.env ?? {};
305
+ if (body.env && typeof body.env === "object" && !Array.isArray(body.env)) {
306
+ env = {};
307
+ for (const [k, v] of Object.entries(body.env as Record<string, unknown>)) {
308
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) env[k] = String(v);
309
+ }
310
+ } else if (typeof body.env === "string") {
311
+ env = {};
312
+ for (const line of body.env.split("\n")) {
313
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
314
+ if (m) env[m[1]] = m[2].trim();
315
+ }
316
+ }
317
+ // Spawn-relevant edits only: renaming a row or writing a note does not invalidate the
318
+ // evidence, but changing the command/args/env/home/profile/kind does (the row may no longer
319
+ // be the thing that was measured).
320
+ const edited = body.cmd !== undefined || body.args !== undefined || body.env !== undefined
321
+ || body.home !== undefined || body.profile !== undefined || body.kind !== undefined;
322
+ return {
323
+ row: {
324
+ id,
325
+ label: s(body.label) || base?.label || id,
326
+ kind,
327
+ // Absent in a body means "keep what the row had"; a NEW row defaults to the MCP channel
328
+ // (nobody's frames are trusted by default any more). Unknown values fall back rather than
329
+ // persist a typo.
330
+ nativePlanSource: (() => {
331
+ const raw = s(body.nativePlanSource) || base?.nativePlanSource || DEFAULT_NATIVE_PLAN_SOURCE;
332
+ return raw === "none" ? "none" : "acp";
333
+ })(),
334
+ cmd,
335
+ args,
336
+ env,
337
+ home: body.home === undefined ? (base?.home ?? null) : (s(body.home) || null),
338
+ profile: body.profile === undefined ? (base?.profile ?? null) : (s(body.profile) || null),
339
+ cwd: body.cwd === undefined ? (base?.cwd ?? null) : (s(body.cwd) || null),
340
+ notes: body.notes === undefined ? (base?.notes ?? "") : s(body.notes),
341
+ builtin: base?.builtin ?? false,
342
+ // Health is evidence about the CURRENT spawn definition, so changing that definition
343
+ // invalidates it — otherwise the list would keep showing "online" for a row that was
344
+ // just edited into something else. Never taken from the body: only checks write it.
345
+ health: edited ? emptyHealth() : (base?.health ?? emptyHealth()),
346
+ handshake: edited ? null : (base?.handshake ?? null),
347
+ createdAt: base?.createdAt ?? now,
348
+ updatedAt: now,
349
+ },
350
+ };
351
+ }
352
+
353
+ // ---- inspection: "which tree does this row actually run?" --------------------------------
354
+ //
355
+ // This automates the reconnaissance that used to be done by hand on this box:
356
+ // `<cmd> --version` prints "Install directory: …", which names the tree the process will
357
+ // import its code from (an editable install reports the source checkout; a runtime venv
358
+ // reports its own bundled checkout). Combined with the resolved HERMES_HOME it answers the
359
+ // only two questions that matter: which code, and which data.
360
+ //
361
+ // NOTE for the UI copy: `acp --check` is NOT a gate. It returns OK before the adapter's
362
+ // server module is imported (measured: a deliberately syntax-broken acp_adapter/server.py
363
+ // still passed `--check`), so a passing check says "the pieces are installed", not "a
364
+ // session will start". The honest gate is to create a session and run one empty turn.
365
+
366
+ export interface BackendInspect {
367
+ ok: boolean;
368
+ id: string;
369
+ cmd: string;
370
+ resolved: string | null;
371
+ args: string[];
372
+ home: string | null;
373
+ homeExists: boolean;
374
+ homeEntries: number | null;
375
+ stateDb: { path: string; bytes: number; mtime: number } | null;
376
+ profile: string | null;
377
+ envKeys: string[];
378
+ version: string | null;
379
+ installDir: string | null;
380
+ acpCheck: { ok: boolean; output: string } | null;
381
+ warnings: string[];
382
+ error: string | null;
383
+ /** structured verdict — what the UI stores, colour-codes and turns into advice */
384
+ status: CheckStatus;
385
+ errorCode: CheckErrorCode | null;
386
+ guidance: string | null;
387
+ latencyMs: number;
388
+ }
389
+
390
+ async function probe(
391
+ cmd: string,
392
+ args: string[],
393
+ env: NodeJS.ProcessEnv,
394
+ timeoutMs: number,
395
+ ): Promise<{ ok: boolean; out: string; timedOut: boolean }> {
396
+ try {
397
+ const { stdout, stderr } = await run(cmd, args, { env, timeout: timeoutMs, maxBuffer: 4 << 20 });
398
+ return { ok: true, out: `${stdout}${stderr}`.trim(), timedOut: false };
399
+ } catch (e) {
400
+ const err = e as { stdout?: string; stderr?: string; message?: string; killed?: boolean; signal?: string };
401
+ return {
402
+ ok: false,
403
+ out: `${err.stdout ?? ""}${err.stderr ?? ""}`.trim() || String(err.message ?? e),
404
+ timedOut: Boolean(err.killed) || err.signal === "SIGTERM",
405
+ };
406
+ }
407
+ }
408
+
409
+ function resolveCmd(cmd: string): string | null {
410
+ const expanded = expandHome(cmd);
411
+ if (expanded.includes("/")) return fs.existsSync(expanded) ? expanded : null;
412
+ for (const dir of (process.env.PATH || "").split(":")) {
413
+ if (!dir) continue;
414
+ const p = path.join(dir, expanded);
415
+ try {
416
+ fs.accessSync(p, fs.constants.X_OK);
417
+ return p;
418
+ } catch { /* keep looking */ }
419
+ }
420
+ return null;
421
+ }
422
+
423
+ function firstMatch(text: string, re: RegExp): string | null {
424
+ const m = text.match(re);
425
+ return m ? m[1].trim() : null;
426
+ }
427
+
428
+ export async function inspectRow(row: BackendRow, timeoutMs = 30_000): Promise<BackendInspect> {
429
+ const started = Date.now();
430
+ const plan = planFor(row);
431
+ const args = effectiveArgs(row);
432
+ const result: BackendInspect = {
433
+ ok: false,
434
+ id: row.id,
435
+ cmd: row.cmd,
436
+ resolved: resolveCmd(row.cmd),
437
+ args,
438
+ home: plan.home,
439
+ homeExists: false,
440
+ homeEntries: null,
441
+ stateDb: null,
442
+ profile: row.profile,
443
+ envKeys: Object.keys(row.env ?? {}),
444
+ version: null,
445
+ installDir: null,
446
+ acpCheck: null,
447
+ warnings: plan.warnings,
448
+ error: null,
449
+ status: "unchecked",
450
+ errorCode: null,
451
+ guidance: null,
452
+ latencyMs: 0,
453
+ };
454
+
455
+ if (plan.home) {
456
+ try {
457
+ result.homeExists = fs.existsSync(plan.home) && fs.statSync(plan.home).isDirectory();
458
+ if (result.homeExists) result.homeEntries = fs.readdirSync(plan.home).length;
459
+ const db = path.join(plan.home, "state.db");
460
+ if (fs.existsSync(db)) {
461
+ const st = fs.statSync(db);
462
+ result.stateDb = { path: db, bytes: st.size, mtime: st.mtimeMs };
463
+ }
464
+ } catch { /* a home we cannot stat is worth reporting as "not there" */ }
465
+ }
466
+
467
+ let env: NodeJS.ProcessEnv;
468
+ try {
469
+ env = buildSpawnEnv(rowToSpec(row)).env;
470
+ } catch (e) {
471
+ result.error = e instanceof Error ? e.message : String(e);
472
+ result.status = "offline";
473
+ result.errorCode = "spawn_failed";
474
+ result.guidance = ERROR_GUIDANCE.spawn_failed;
475
+ result.latencyMs = Date.now() - started;
476
+ return result;
477
+ }
478
+
479
+ if (!result.resolved) {
480
+ result.error = `command not found: ${row.cmd}`;
481
+ result.status = "missing";
482
+ result.errorCode = "command_not_found";
483
+ result.guidance = ERROR_GUIDANCE.command_not_found;
484
+ result.latencyMs = Date.now() - started;
485
+ return result;
486
+ }
487
+
488
+ const v = await probe(result.resolved, ["--version"], env, timeoutMs);
489
+ if (v.ok) {
490
+ result.version = v.out.split("\n")[0] ?? null;
491
+ result.installDir = firstMatch(v.out, /^Install directory:\s*(.+)$/m);
492
+ } else {
493
+ result.error = `--version failed: ${v.out.split("\n").slice(0, 3).join(" / ")}`;
494
+ result.errorCode = v.timedOut ? "timeout" : classifyMessage(v.out, "version_failed");
495
+ }
496
+
497
+ if (row.kind === "hermes") {
498
+ const c = await probe(result.resolved, [...args.slice(0, -1), "acp", "--check"], env, timeoutMs);
499
+ result.acpCheck = { ok: c.ok && /ACP check OK/i.test(c.out), output: c.out.split("\n").slice(-3).join(" / ") };
500
+ if (!result.acpCheck.ok && !result.errorCode) {
501
+ result.error = result.error ?? `acp --check failed: ${result.acpCheck.output}`;
502
+ result.errorCode = c.timedOut ? "timeout" : classifyMessage(c.out, "acp_check_failed");
503
+ }
504
+ }
505
+
506
+ result.ok = Boolean(result.version) && (result.acpCheck?.ok ?? true);
507
+ result.status = result.ok ? "online" : "offline";
508
+ if (!result.ok && !result.errorCode) result.errorCode = "unknown";
509
+ result.guidance = result.errorCode ? ERROR_GUIDANCE[result.errorCode] : null;
510
+ result.latencyMs = Date.now() - started;
511
+ return result;
512
+ }
513
+
514
+ /**
515
+ * The cheap, side-effect-free half of inspectRow: does the command resolve at all? Used by the
516
+ * boot sweep, which must never spawn anything (a real check belongs to the 探测 button and, for
517
+ * proof, to a session).
518
+ */
519
+ export function startupCheck(row: BackendRow): BackendHealth {
520
+ const started = Date.now();
521
+ const resolved = resolveCmd(row.cmd);
522
+ if (!resolved) {
523
+ return checkedHealth(row.health, {
524
+ status: "missing", kind: "startup", errorCode: "command_not_found",
525
+ message: `command not found: ${row.cmd}`, latencyMs: Date.now() - started,
526
+ });
527
+ }
528
+ return checkedHealth(row.health, {
529
+ status: "online", kind: "startup", message: resolved, latencyMs: Date.now() - started,
530
+ });
531
+ }
532
+
533
+ /**
534
+ * The handshake a live session observed, shaped for the row. `modes`/`configOptions`/`models`
535
+ * are passed through defensively: the SDK types cover only part of what the wire carries.
536
+ */
537
+ export function handshakeFrom(
538
+ init: unknown,
539
+ session: unknown,
540
+ now = Date.now(),
541
+ ): BackendHandshake {
542
+ const i = (init ?? {}) as { protocolVersion?: number; agentCapabilities?: Record<string, unknown> };
543
+ const caps = (i.agentCapabilities ?? {}) as {
544
+ loadSession?: boolean;
545
+ sessionCapabilities?: { fork?: unknown };
546
+ };
547
+ const s = (session ?? {}) as {
548
+ modes?: { currentModeId?: string; availableModes?: { id: string }[] } | null;
549
+ configOptions?: { id: string; name?: string; currentValue?: unknown }[] | null;
550
+ models?: { currentModelId?: string; availableModels?: { modelId?: string; id?: string }[] } | null;
551
+ availableCommands?: { name: string }[] | null;
552
+ };
553
+ const modes = s.modes
554
+ ? {
555
+ currentModeId: s.modes.currentModeId ?? null,
556
+ available: (s.modes.availableModes ?? []).map((m) => m.id),
557
+ }
558
+ : null;
559
+ const models = s.models
560
+ ? {
561
+ currentModelId: s.models.currentModelId ?? null,
562
+ available: (s.models.availableModels ?? []).map((m) => m.modelId ?? m.id ?? "").filter(Boolean),
563
+ }
564
+ : null;
565
+ return {
566
+ at: now,
567
+ protocolVersion: typeof i.protocolVersion === "number" ? i.protocolVersion : null,
568
+ loadSession: typeof caps.loadSession === "boolean" ? caps.loadSession : null,
569
+ fork: caps.sessionCapabilities ? Boolean(caps.sessionCapabilities.fork) : null,
570
+ modes,
571
+ configOptions: (s.configOptions ?? []).map((o) => ({
572
+ id: o.id,
573
+ name: o.name ?? null,
574
+ currentValue: o.currentValue === undefined || o.currentValue === null ? null : String(o.currentValue),
575
+ })),
576
+ models,
577
+ commands: (s.availableCommands ?? []).map((c) => c.name),
578
+ };
579
+ }