talon-agent 3.33.2 → 3.33.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.33.2",
3
+ "version": "3.33.4",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -68,8 +68,8 @@
68
68
  "test:integration": "vitest run --reporter=verbose --reporter=json --outputFile=integration-results.json src/__tests__/integration/talon-mcp-functional.test.ts",
69
69
  "test:integration:all": "vitest run --reporter=verbose src/__tests__/integration/",
70
70
  "test:claude:backend": "vitest run --reporter=verbose --reporter=json --outputFile=claude-backend-results.json src/__tests__/integration/claude-live-discovery.test.ts",
71
- "test:kilo:backend": "vitest run --reporter=verbose --reporter=json --outputFile=kilo-backend-results.json src/__tests__/integration/kilo-live-discovery.test.ts",
72
- "test:opencode:backend": "vitest run --reporter=verbose --reporter=json --outputFile=opencode-backend-results.json src/__tests__/integration/opencode-live-discovery.test.ts",
71
+ "test:kilo:backend": "vitest run --reporter=verbose --reporter=json --outputFile=kilo-backend-results.json src/__tests__/integration/kilo-live-discovery.test.ts src/__tests__/integration/kilo-real-bootstrap.test.ts",
72
+ "test:opencode:backend": "vitest run --reporter=verbose --reporter=json --outputFile=opencode-backend-results.json src/__tests__/integration/opencode-live-discovery.test.ts src/__tests__/integration/opencode-real-bootstrap.test.ts",
73
73
  "test:codex:backend": "vitest run --reporter=verbose --reporter=json --outputFile=codex-backend-results.json src/__tests__/integration/codex-live-discovery.test.ts",
74
74
  "test:openai-agents:backend": "vitest run --reporter=verbose --reporter=json --outputFile=openai-agents-backend-results.json src/__tests__/integration/openai-agents-live-discovery.test.ts",
75
75
  "build:sql": "tsx scripts/embed-sql.ts",
@@ -142,14 +142,14 @@
142
142
  "@types/node": "^26.0.0",
143
143
  "@types/qrcode-terminal": "^0.12.2",
144
144
  "@types/write-file-atomic": "^4.0.3",
145
- "@vitest/coverage-v8": "^4.1.3",
145
+ "@vitest/coverage-v8": "^5.0.0",
146
146
  "dependency-cruiser": "^18.1.0",
147
147
  "fast-check": "^4.6.0",
148
148
  "knip": "^6.3.1",
149
149
  "oxlint": "^1.59.0",
150
150
  "prettier": "^3.8.1",
151
151
  "typescript": "^7.0.2",
152
- "vitest": "^4.1.3"
152
+ "vitest": "^5.0.0"
153
153
  },
154
154
  "overrides": {
155
155
  "@anthropic-ai/sdk": "^0.104.1",
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Register every built-in backend with the registry.
3
+ *
4
+ * Each backend's `factory.ts` calls `registerBackend` as a side effect of
5
+ * being imported, so "loading" is importing. One list, used by the
6
+ * daemon's bootstrap, by `talon doctor` (which runs standalone and needs
7
+ * the factories' doctor checks), and by tests that exercise the registry.
8
+ * Adding a backend is adding a line here.
9
+ */
10
+ export async function loadBuiltinBackends(): Promise<void> {
11
+ await import("./claude-sdk/factory.js");
12
+ await import("./opencode/factory.js");
13
+ await import("./kilo/factory.js");
14
+ await import("./codex/factory.js");
15
+ await import("./openai-agents/factory.js");
16
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `talon doctor` checks for the Claude SDK backend: the CLI binary, and —
3
+ * only for the backend actually serving chats, since it spawns a probe —
4
+ * the models pinned in config against the static catalog.
5
+ */
6
+
7
+ import type {
8
+ DoctorCheck,
9
+ DoctorConfigSlice,
10
+ } from "../../core/doctor-types.js";
11
+ import { getModels } from "../../core/models/catalog.js";
12
+ import { binaryOnPath } from "../../util/binary-on-path.js";
13
+ import { resolveModel } from "./model-provider.js";
14
+ import { registerClaudeModelsStatic } from "./models/discovery.js";
15
+ import { CLAUDE_MODELS_STATIC } from "./models/static.js";
16
+
17
+ export async function claudeDoctorChecks(
18
+ config: DoctorConfigSlice | undefined,
19
+ isActive: boolean,
20
+ ): Promise<DoctorCheck[]> {
21
+ const checks: DoctorCheck[] = [];
22
+ if (config?.claudeBinary) {
23
+ checks.push(
24
+ binaryOnPath(config.claudeBinary)
25
+ ? {
26
+ label: "Claude Code binary",
27
+ status: "ok",
28
+ detail: config.claudeBinary,
29
+ }
30
+ : {
31
+ label: "Claude Code binary not found",
32
+ status: "fail",
33
+ detail: config.claudeBinary,
34
+ },
35
+ );
36
+ } else {
37
+ checks.push(
38
+ binaryOnPath("claude")
39
+ ? { label: "Claude Code installed", status: "ok" }
40
+ : { label: "Claude Code not found", status: "fail" },
41
+ );
42
+ }
43
+ // Model resolution spawns a probe — worth it for the backend actually
44
+ // serving chats, wasteful for one nobody is using.
45
+ if (isActive) checks.push(...(await checkConfiguredModels(config)));
46
+ return checks;
47
+ }
48
+
49
+ /**
50
+ * Validate models pinned in config against the static catalog. A model
51
+ * that has been withdrawn from the catalog (it happens: deprecations,
52
+ * policy pulls) makes every turn silently run the backend default while
53
+ * the config keeps naming the dead id — this check is where that finally
54
+ * becomes visible. Claude-only: this catalog is a static import; other
55
+ * backends need a live server and are audited at boot instead
56
+ * (core/engine/model-audit.ts).
57
+ */
58
+ async function checkConfiguredModels(
59
+ config: DoctorConfigSlice | undefined,
60
+ ): Promise<DoctorCheck[]> {
61
+ const checks: DoctorCheck[] = [];
62
+ const targets: Array<{ key: string; model?: string; backendId?: string }> = [
63
+ { key: "model", model: config?.model, backendId: config?.backend },
64
+ {
65
+ key: "heartbeatModel",
66
+ model: config?.heartbeatModel,
67
+ backendId: config?.heartbeatBackend ?? config?.backend,
68
+ },
69
+ ];
70
+ try {
71
+ // Doctor runs standalone — live SDK model discovery hasn't happened,
72
+ // so the registry may be empty. Seed the static catalog (the same
73
+ // list the setup wizard offers) so alias pins like "opus" resolve.
74
+ // The static list is a snapshot: a model can exist upstream and not
75
+ // here, which is why findings are warn-level, never fail.
76
+ if (getModels("anthropic").length === 0) {
77
+ registerClaudeModelsStatic(CLAUDE_MODELS_STATIC);
78
+ }
79
+ for (const { key, model, backendId } of targets) {
80
+ if (!model || model === "default") continue;
81
+ if ((backendId ?? "claude") !== "claude") continue;
82
+ const res = await resolveModel(model);
83
+ if (res.kind === "exact") {
84
+ checks.push({
85
+ label: `Model (${key}): ${model} → ${res.model.displayName}`,
86
+ status: "ok",
87
+ });
88
+ } else if (res.kind === "ambiguous") {
89
+ checks.push({
90
+ label: `Model (${key}): "${model}" is ambiguous`,
91
+ status: "warn",
92
+ detail: res.matches.map((m) => m.displayName).join(", "),
93
+ issue: true,
94
+ });
95
+ } else {
96
+ checks.push({
97
+ label: `Model (${key}): "${model}" not selectable on claude`,
98
+ status: "warn",
99
+ detail: "turns silently run the backend default — update config.json",
100
+ issue: true,
101
+ });
102
+ }
103
+ }
104
+ } catch {
105
+ /* catalog unavailable — skip, never break doctor */
106
+ }
107
+ return checks;
108
+ }
@@ -43,6 +43,7 @@ import {
43
43
  import { waitForMcpServersReady } from "./mcp-ready.js";
44
44
 
45
45
  import * as modelProvider from "./model-provider.js";
46
+ import { claudeDoctorChecks } from "./doctor.js";
46
47
 
47
48
  const claudeSdkFactory: BackendFactory = {
48
49
  // The config schema uses `"claude"` for backward compatibility with
@@ -50,6 +51,7 @@ const claudeSdkFactory: BackendFactory = {
50
51
  // no migration is needed.
51
52
  id: "claude",
52
53
  label: "Anthropic",
54
+ doctor: (config, isActive) => claudeDoctorChecks(config, isActive),
53
55
 
54
56
  async init(config, ctx) {
55
57
  await initClaudeAgent(config, ctx.getBridgePort);
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `talon doctor` checks for the Codex backend: the CLI binary and how it
3
+ * will authenticate.
4
+ */
5
+
6
+ import type {
7
+ DoctorCheck,
8
+ DoctorConfigSlice,
9
+ } from "../../core/doctor-types.js";
10
+ import { binaryOnPath } from "../../util/binary-on-path.js";
11
+ import { detectCodexAuth } from "./auth.js";
12
+
13
+ export async function codexDoctorChecks(
14
+ config: DoctorConfigSlice | undefined,
15
+ ): Promise<DoctorCheck[]> {
16
+ const checks: DoctorCheck[] = [];
17
+ if (!binaryOnPath("codex")) {
18
+ checks.push({
19
+ label: "Codex CLI not found",
20
+ status: "fail",
21
+ detail: "npm i -g @openai/codex",
22
+ });
23
+ return checks;
24
+ }
25
+ checks.push({ label: "Codex CLI installed", status: "ok" });
26
+ const auth = detectCodexAuth({
27
+ codexApiKey: config?.codexApiKey,
28
+ openaiApiKey: config?.openaiApiKey,
29
+ openaiBaseUrl: config?.openaiBaseUrl,
30
+ });
31
+ for (const diagnostic of auth.diagnostics) {
32
+ checks.push({ label: diagnostic, status: "warn" });
33
+ }
34
+ if (auth.mode !== "none") {
35
+ checks.push({
36
+ label: "Codex auth",
37
+ status: "ok",
38
+ detail: auth.baseUrl ? `${auth.source} (${auth.baseUrl})` : auth.source,
39
+ });
40
+ } else {
41
+ checks.push({
42
+ label: "Codex auth missing",
43
+ status: "warn",
44
+ detail:
45
+ "set CODEX_API_KEY, TALON_CODEX_KEY, codexApiKey, or run `codex login`",
46
+ issue: true,
47
+ });
48
+ }
49
+ return checks;
50
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { registerBackend } from "../../core/agent-runtime/backend-registry.js";
10
+ import { codexDoctorChecks } from "./doctor.js";
10
11
  import type { BackendFactory } from "../../core/agent-runtime/backend-registry.js";
11
12
  import { log } from "../../util/log.js";
12
13
  import { handlerToEvents } from "../shared/handler-to-events.js";
@@ -41,6 +42,7 @@ import {
41
42
  const codexFactory: BackendFactory = {
42
43
  id: "codex",
43
44
  label: "Codex",
45
+ doctor: (config) => codexDoctorChecks(config),
44
46
 
45
47
  async init(config, ctx) {
46
48
  initCodexAgent(config, ctx.getBridgePort, ctx.frontendName);
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `talon doctor` checks for the OpenAI Agents backend: the SDK is bundled,
3
+ * so this is about the API key and which endpoint it will talk to.
4
+ */
5
+
6
+ import type {
7
+ DoctorCheck,
8
+ DoctorConfigSlice,
9
+ } from "../../core/doctor-types.js";
10
+
11
+ export async function openAIAgentsDoctorChecks(
12
+ config: DoctorConfigSlice | undefined,
13
+ ): Promise<DoctorCheck[]> {
14
+ const checks: DoctorCheck[] = [
15
+ { label: "OpenAI Agents SDK bundled", status: "ok" },
16
+ ];
17
+ const hasEnvKey = Boolean(process.env.OPENAI_API_KEY);
18
+ const hasCfgKey = Boolean(config?.openaiApiKey);
19
+ if (hasEnvKey || hasCfgKey) {
20
+ const sources: string[] = [];
21
+ if (hasEnvKey) sources.push("OPENAI_API_KEY env");
22
+ if (hasCfgKey) sources.push("openaiApiKey in talon.json");
23
+ checks.push({
24
+ label: "OpenAI Agents auth",
25
+ status: "ok",
26
+ detail: sources.join(", "),
27
+ });
28
+ } else {
29
+ checks.push({
30
+ label: "OpenAI Agents auth missing",
31
+ status: "warn",
32
+ detail: "set OPENAI_API_KEY or openaiApiKey in talon.json",
33
+ issue: true,
34
+ });
35
+ }
36
+ const envBase = process.env.OPENAI_BASE_URL;
37
+ const cfgBase = config?.openaiBaseUrl;
38
+ if (envBase || cfgBase) {
39
+ checks.push({
40
+ label: "OpenAI-compatible endpoint",
41
+ status: "ok",
42
+ detail: envBase ? `env (${envBase})` : `config (${cfgBase})`,
43
+ });
44
+ } else {
45
+ checks.push({
46
+ label: "Endpoint: api.openai.com (default)",
47
+ status: "info",
48
+ });
49
+ }
50
+ return checks;
51
+ }
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { registerBackend } from "../../core/agent-runtime/backend-registry.js";
13
+ import { openAIAgentsDoctorChecks } from "./doctor.js";
13
14
  import type { BackendFactory } from "../../core/agent-runtime/backend-registry.js";
14
15
  import { log } from "../../util/log.js";
15
16
  import { handlerToEvents } from "../shared/handler-to-events.js";
@@ -39,6 +40,7 @@ import {
39
40
  const openAIAgentsFactory: BackendFactory = {
40
41
  id: "openai-agents",
41
42
  label: "OpenAI Agents",
43
+ doctor: (config) => openAIAgentsDoctorChecks(config),
42
44
 
43
45
  async init(config, ctx) {
44
46
  initOpenAIAgentsAgent(config, ctx.getBridgePort, ctx.frontendName);
@@ -26,6 +26,7 @@ import {
26
26
  } from "../../core/agent-runtime/capabilities.js";
27
27
  import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
28
28
  import type { TalonConfig } from "../../util/config.js";
29
+ import { binaryOnPath } from "../../util/binary-on-path.js";
29
30
  import { log } from "../../util/log.js";
30
31
  import { handlerToEvents } from "../shared/handler-to-events.js";
31
32
  import type { QueryParams, QueryResult } from "../shared/handler-types.js";
@@ -64,6 +65,22 @@ export function createRemoteBackendFactory(
64
65
  return {
65
66
  id,
66
67
  label,
68
+ // The SDK ships as an npm dep but only talks to a server it spawns from
69
+ // the CLI of the same name (`cross-spawn` → PATH lookup). A present
70
+ // package with an absent binary fails at the first turn with a bare
71
+ // ENOENT, so doctor checks what actually gets executed.
72
+ async doctor() {
73
+ return [
74
+ binaryOnPath(id)
75
+ ? { label: `${label} CLI installed`, status: "ok" }
76
+ : {
77
+ label: `${label} CLI not found`,
78
+ status: "fail",
79
+ detail: `the ${label} SDK spawns \`${id}\` — install it or this backend cannot start`,
80
+ issue: true,
81
+ },
82
+ ];
83
+ },
67
84
  async init(config, ctx) {
68
85
  inputs.init(config, ctx.getBridgePort, ctx.frontendName);
69
86
  log("bot", `Backend: ${label} (${inputs.sdkPackage})`);
package/src/bootstrap.ts CHANGED
@@ -171,14 +171,11 @@ export async function initBackendAndDispatcher(
171
171
  ): Promise<BackendAndDispatcherResult> {
172
172
  const frontends = normalizeFrontends(frontend);
173
173
 
174
- // Register all built-in backends via side-effect import. Adding a new
175
- // backend is now strictly additive: drop a `factory.ts` under the new
176
- // backend dir and import it here. No conditionals here change.
177
- await import("./backend/claude-sdk/factory.js");
178
- await import("./backend/opencode/factory.js");
179
- await import("./backend/kilo/factory.js");
180
- await import("./backend/codex/factory.js");
181
- await import("./backend/openai-agents/factory.js");
174
+ // Register all built-in backends. Adding a new backend is strictly
175
+ // additive: drop a `factory.ts` under the new backend dir and list it in
176
+ // backend/builtins.ts. No conditionals here change.
177
+ const { loadBuiltinBackends } = await import("./backend/builtins.js");
178
+ await loadBuiltinBackends();
182
179
 
183
180
  const {
184
181
  initBackendPool,
package/src/cli/doctor.ts CHANGED
@@ -20,6 +20,10 @@ export async function runDoctor(): Promise<void> {
20
20
  printBanner();
21
21
  console.log(` ${pc.bold("Environment check")}\n`);
22
22
  const { collectDoctorReport } = await import("../core/doctor.js");
23
+ // Doctor composes each backend's own checks off the registry, and the
24
+ // CLI runs standalone — nothing else has registered them yet.
25
+ const { loadBuiltinBackends } = await import("../backend/builtins.js");
26
+ await loadBuiltinBackends();
23
27
  const hasConfigFile = existsSync(CONFIG_FILE);
24
28
  const report = await collectDoctorReport({
25
29
  config: hasConfigFile ? loadConfig() : undefined,
@@ -30,6 +30,7 @@
30
30
  */
31
31
 
32
32
  import type { Backend } from "./capabilities.js";
33
+ import type { DoctorCheck, DoctorConfigSlice } from "../doctor-types.js";
33
34
  import type { TalonConfig } from "../../util/config.js";
34
35
 
35
36
  // ── Types ───────────────────────────────────────────────────────────────────
@@ -77,6 +78,17 @@ export interface BackendFactory {
77
78
  label: string;
78
79
  /** Initialise the backend; called exactly once per Talon process. */
79
80
  init(config: TalonConfig, ctx: BackendInitContext): Promise<BackendInstance>;
81
+ /**
82
+ * `talon doctor` checks for this backend — binary, auth, catalog probes.
83
+ * `isActive` is true for the backend serving chats; probes that cost a
84
+ * process spawn (model resolution) should run only then. Doctor composes
85
+ * whatever is registered, so a backend without this slot is reported as
86
+ * having nothing to check rather than silently skipped.
87
+ */
88
+ doctor?(
89
+ config: DoctorConfigSlice | undefined,
90
+ isActive: boolean,
91
+ ): Promise<DoctorCheck[]>;
80
92
  }
81
93
 
82
94
  // ── State ───────────────────────────────────────────────────────────────────
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The doctor's data shapes, on their own so a backend factory can declare
3
+ * its checks (`BackendFactory.doctor`) without importing the collector —
4
+ * `core/doctor.ts` reads the registry, so the types living there would
5
+ * close a cycle. Checks are pure data (label / status / detail); renderers
6
+ * decide presentation.
7
+ */
8
+
9
+ type DoctorStatus = "ok" | "warn" | "fail" | "info";
10
+
11
+ export interface DoctorCheck {
12
+ label: string;
13
+ status: DoctorStatus;
14
+ detail?: string;
15
+ /**
16
+ * Counts toward the issue total even when status is "warn" — used
17
+ * for soft failures like missing backend auth where the bot still
18
+ * starts but a backend won't work.
19
+ */
20
+ issue?: boolean;
21
+ /**
22
+ * A backend that is configured but not serving chats. Renderers group
23
+ * these away from the environment so a handful of idle providers can't
24
+ * bury the checks that describe the running deployment.
25
+ */
26
+ inactive?: boolean;
27
+ }
28
+
29
+ /** One embedded native module: provenance plus a live self-test result. */
30
+ export interface NativeModuleCheck {
31
+ name: string;
32
+ /** Source language ("Rust", "Zig", "C", "C++", "Gleam"). */
33
+ language: string;
34
+ /** Compile target ("wasm32-unknown-unknown", "wasm32-freestanding", "JavaScript"). */
35
+ target: string;
36
+ /** Embedded artifact size, when the module ships as wasm bytes. */
37
+ sizeBytes?: number;
38
+ ok: boolean;
39
+ /** Failure detail when !ok. */
40
+ note?: string;
41
+ }
42
+
43
+ export interface DoctorReport {
44
+ checks: DoctorCheck[];
45
+ native: NativeModuleCheck[];
46
+ /** Failed checks + warn-with-issue checks + failed native modules. */
47
+ issues: number;
48
+ }
49
+
50
+ /**
51
+ * The slice of config doctor reads. Both the CLI's local Config and
52
+ * TalonConfig satisfy this structurally.
53
+ */
54
+ export interface DoctorConfigSlice {
55
+ frontend: string | string[];
56
+ backend?: string;
57
+ /** Backends config exposes. Empty / absent means every registered one. */
58
+ enabledBackends?: string[];
59
+ model?: string;
60
+ heartbeatModel?: string;
61
+ heartbeatBackend?: string;
62
+ botToken?: string;
63
+ teamsWebhookUrl?: string;
64
+ discord?: { botToken?: string };
65
+ whatsapp?: object;
66
+ claudeBinary?: string;
67
+ codexApiKey?: string;
68
+ openaiApiKey?: string;
69
+ openaiBaseUrl?: string;
70
+ mempalace?: {
71
+ enabled?: boolean;
72
+ pythonPath?: string;
73
+ version?: string;
74
+ };
75
+ playwright?: {
76
+ enabled?: boolean;
77
+ browser?: string;
78
+ endpoint?: string;
79
+ endpointFile?: string;
80
+ };
81
+ github?: { enabled?: boolean; imageTag?: string };
82
+ }
@@ -13,84 +13,22 @@
13
13
 
14
14
  import { existsSync } from "node:fs";
15
15
  import { stat } from "node:fs/promises";
16
- import { execFileSync } from "node:child_process";
17
16
  import { NATIVE_MODULES } from "../native/registry.js";
18
17
  import { dirs } from "../util/paths.js";
19
-
20
- type DoctorStatus = "ok" | "warn" | "fail" | "info";
21
-
22
- export interface DoctorCheck {
23
- label: string;
24
- status: DoctorStatus;
25
- detail?: string;
26
- /**
27
- * Counts toward the issue total even when status is "warn" — used
28
- * for soft failures like missing backend auth where the bot still
29
- * starts but a backend won't work.
30
- */
31
- issue?: boolean;
32
- /**
33
- * A backend that is configured but not serving chats. Renderers group
34
- * these away from the environment so a handful of idle providers can't
35
- * bury the checks that describe the running deployment.
36
- */
37
- inactive?: boolean;
38
- }
39
-
40
- /** One embedded native module: provenance plus a live self-test result. */
41
- export interface NativeModuleCheck {
42
- name: string;
43
- /** Source language ("Rust", "Zig", "C", "C++", "Gleam"). */
44
- language: string;
45
- /** Compile target ("wasm32-unknown-unknown", "wasm32-freestanding", "JavaScript"). */
46
- target: string;
47
- /** Embedded artifact size, when the module ships as wasm bytes. */
48
- sizeBytes?: number;
49
- ok: boolean;
50
- /** Failure detail when !ok. */
51
- note?: string;
52
- }
53
-
54
- export interface DoctorReport {
55
- checks: DoctorCheck[];
56
- native: NativeModuleCheck[];
57
- /** Failed checks + warn-with-issue checks + failed native modules. */
58
- issues: number;
59
- }
60
-
61
- /**
62
- * The slice of config doctor reads. Both the CLI's local Config and
63
- * TalonConfig satisfy this structurally.
64
- */
65
- export interface DoctorConfigSlice {
66
- frontend: string | string[];
67
- backend?: string;
68
- /** Backends config exposes. Empty / absent means every registered one. */
69
- enabledBackends?: string[];
70
- model?: string;
71
- heartbeatModel?: string;
72
- heartbeatBackend?: string;
73
- botToken?: string;
74
- teamsWebhookUrl?: string;
75
- discord?: { botToken?: string };
76
- whatsapp?: object;
77
- claudeBinary?: string;
78
- codexApiKey?: string;
79
- openaiApiKey?: string;
80
- openaiBaseUrl?: string;
81
- mempalace?: {
82
- enabled?: boolean;
83
- pythonPath?: string;
84
- version?: string;
85
- };
86
- playwright?: {
87
- enabled?: boolean;
88
- browser?: string;
89
- endpoint?: string;
90
- endpointFile?: string;
91
- };
92
- github?: { enabled?: boolean; imageTag?: string };
93
- }
18
+ import { getBackend, listBackends } from "./agent-runtime/backend-registry.js";
19
+ import type {
20
+ DoctorCheck,
21
+ DoctorConfigSlice,
22
+ DoctorReport,
23
+ NativeModuleCheck,
24
+ } from "./doctor-types.js";
25
+
26
+ export type {
27
+ DoctorCheck,
28
+ DoctorConfigSlice,
29
+ DoctorReport,
30
+ NativeModuleCheck,
31
+ } from "./doctor-types.js";
94
32
 
95
33
  function errorNote(err: unknown): string {
96
34
  return err instanceof Error ? err.message : String(err);
@@ -230,85 +168,6 @@ async function checkNamespaceDir(): Promise<DoctorCheck> {
230
168
  };
231
169
  }
232
170
 
233
- function binaryOnPath(name: string): boolean {
234
- try {
235
- const lookupCmd = process.platform === "win32" ? "where" : "which";
236
- execFileSync(lookupCmd, [name], { stdio: "pipe" });
237
- return true;
238
- } catch {
239
- return false;
240
- }
241
- }
242
-
243
- /**
244
- * Validate models pinned in config against the Claude backend's static
245
- * catalog. A model that has been withdrawn from the catalog (it
246
- * happens: deprecations, policy pulls) makes every turn silently run
247
- * the backend default while the config keeps naming the dead id —
248
- * this check is where that finally becomes visible. Claude-only: this
249
- * catalog is a static import; other backends need a live server and
250
- * are audited at boot instead (core/engine/model-audit.ts).
251
- */
252
- async function checkClaudeConfiguredModels(
253
- config: DoctorConfigSlice | undefined,
254
- ): Promise<DoctorCheck[]> {
255
- const checks: DoctorCheck[] = [];
256
- const targets: Array<{ key: string; model?: string; backendId?: string }> = [
257
- { key: "model", model: config?.model, backendId: config?.backend },
258
- {
259
- key: "heartbeatModel",
260
- model: config?.heartbeatModel,
261
- backendId: config?.heartbeatBackend ?? config?.backend,
262
- },
263
- ];
264
- try {
265
- const { resolveModel } =
266
- await import("../backend/claude-sdk/model-provider.js");
267
- // Doctor runs standalone — live SDK model discovery hasn't happened,
268
- // so the registry may be empty. Seed the static catalog (the same
269
- // list the setup wizard offers) so alias pins like "opus" resolve.
270
- // The static list is a snapshot: a model can exist upstream and not
271
- // here, which is why findings are warn-level, never fail.
272
- const { getModels } = await import("./models/catalog.js");
273
- if (getModels("anthropic").length === 0) {
274
- const [{ registerClaudeModelsStatic }, { CLAUDE_MODELS_STATIC }] =
275
- await Promise.all([
276
- import("../backend/claude-sdk/models/discovery.js"),
277
- import("../backend/claude-sdk/models/static.js"),
278
- ]);
279
- registerClaudeModelsStatic(CLAUDE_MODELS_STATIC);
280
- }
281
- for (const { key, model, backendId } of targets) {
282
- if (!model || model === "default") continue;
283
- if ((backendId ?? "claude") !== "claude") continue;
284
- const res = await resolveModel(model);
285
- if (res.kind === "exact") {
286
- checks.push({
287
- label: `Model (${key}): ${model} → ${res.model.displayName}`,
288
- status: "ok",
289
- });
290
- } else if (res.kind === "ambiguous") {
291
- checks.push({
292
- label: `Model (${key}): "${model}" is ambiguous`,
293
- status: "warn",
294
- detail: res.matches.map((m) => m.displayName).join(", "),
295
- issue: true,
296
- });
297
- } else {
298
- checks.push({
299
- label: `Model (${key}): "${model}" not selectable on claude`,
300
- status: "warn",
301
- detail: "turns silently run the backend default — update config.json",
302
- issue: true,
303
- });
304
- }
305
- }
306
- } catch {
307
- /* catalog unavailable — skip, never break doctor */
308
- }
309
- return checks;
310
- }
311
-
312
171
  /**
313
172
  * Native plugin runtimes (MemPalace's venv, Playwright's browser build,
314
173
  * GitHub's Docker image) — the artifacts the provisioners own. The
@@ -337,17 +196,12 @@ async function checkPluginRuntimes(
337
196
  return checks;
338
197
  }
339
198
 
340
- /** Every backend id doctor knows how to inspect. */
341
- const KNOWN_BACKENDS = [
342
- "claude",
343
- "codex",
344
- "kilo",
345
- "opencode",
346
- "openai-agents",
347
- ] as const;
348
-
349
199
  /**
350
- * Binary / auth checks across every backend the config exposes.
200
+ * Binary / auth checks across every backend the config exposes, each
201
+ * supplied by its own factory (`BackendFactory.doctor`) — doctor composes
202
+ * whatever is registered and hardcodes nothing about any backend. The
203
+ * caller must have loaded the factories (`loadBuiltinBackends`); an active
204
+ * backend that is not registered is reported as such, loudly.
351
205
  *
352
206
  * Only the active one counts toward the issue total; the rest are reported
353
207
  * so a switch doesn't have to be the thing that discovers a backend can't
@@ -361,11 +215,11 @@ async function checkBackend(
361
215
  const active = config?.backend ?? "claude";
362
216
  const exposed = config?.enabledBackends?.length
363
217
  ? config.enabledBackends
364
- : [...KNOWN_BACKENDS];
218
+ : listBackends().map((b) => b.id);
365
219
 
366
220
  const checks = await checkOneBackend(active, config, true);
367
221
  for (const id of exposed) {
368
- if (id === active || !KNOWN_BACKENDS.includes(id as never)) continue;
222
+ if (id === active || !getBackend(id)) continue;
369
223
  // An idle backend's missing binary is a heads-up, not a fault of this
370
224
  // deployment: downgrade it and keep it out of the issue count.
371
225
  for (const check of await checkOneBackend(id, config, false)) {
@@ -385,121 +239,35 @@ async function checkOneBackend(
385
239
  config: DoctorConfigSlice | undefined,
386
240
  isActive: boolean,
387
241
  ): Promise<DoctorCheck[]> {
388
- const checks: DoctorCheck[] = [];
389
-
390
- if (backend === "claude") {
391
- if (config?.claudeBinary) {
392
- checks.push(
393
- binaryOnPath(config.claudeBinary)
394
- ? {
395
- label: "Claude Code binary",
396
- status: "ok",
397
- detail: config.claudeBinary,
398
- }
399
- : {
400
- label: "Claude Code binary not found",
401
- status: "fail",
402
- detail: config.claudeBinary,
403
- },
404
- );
405
- } else {
406
- checks.push(
407
- binaryOnPath("claude")
408
- ? { label: "Claude Code installed", status: "ok" }
409
- : { label: "Claude Code not found", status: "fail" },
410
- );
411
- }
412
- // Model resolution spawns a probe — worth it for the backend actually
413
- // serving chats, wasteful for one nobody is using.
414
- if (isActive) checks.push(...(await checkClaudeConfiguredModels(config)));
415
- } else if (backend === "codex") {
416
- if (!binaryOnPath("codex")) {
417
- checks.push({
418
- label: "Codex CLI not found",
242
+ const factory = getBackend(backend);
243
+ if (!factory) {
244
+ const known = listBackends().map((b) => b.id);
245
+ return [
246
+ {
247
+ label: `Backend "${backend}" is not registered`,
419
248
  status: "fail",
420
- detail: "npm i -g @openai/codex",
421
- });
422
- return checks;
423
- }
424
- checks.push({ label: "Codex CLI installed", status: "ok" });
425
- const { detectCodexAuth } = await import("../backend/codex/auth.js");
426
- const auth = detectCodexAuth({
427
- codexApiKey: config?.codexApiKey,
428
- openaiApiKey: config?.openaiApiKey,
429
- openaiBaseUrl: config?.openaiBaseUrl,
430
- });
431
- for (const diagnostic of auth.diagnostics) {
432
- checks.push({ label: diagnostic, status: "warn" });
433
- }
434
- if (auth.mode !== "none") {
435
- checks.push({
436
- label: "Codex auth",
437
- status: "ok",
438
- detail: auth.baseUrl ? `${auth.source} (${auth.baseUrl})` : auth.source,
439
- });
440
- } else {
441
- checks.push({
442
- label: "Codex auth missing",
443
- status: "warn",
444
- detail:
445
- "set CODEX_API_KEY, TALON_CODEX_KEY, codexApiKey, or run `codex login`",
249
+ detail: known.length
250
+ ? `known: ${known.join(", ")}`
251
+ : "no backends loaded — call loadBuiltinBackends() first",
446
252
  issue: true,
447
- });
448
- }
449
- } else if (backend === "kilo" || backend === "opencode") {
450
- // The SDK ships as an npm dep but only talks to a server it spawns from
451
- // the CLI of the same name (`cross-spawn` → PATH lookup). A present
452
- // package with an absent binary fails at the first turn with a bare
453
- // ENOENT, so check what actually gets executed.
454
- const label = backend === "kilo" ? "Kilo" : "OpenCode";
455
- checks.push(
456
- binaryOnPath(backend)
457
- ? { label: `${label} CLI installed`, status: "ok" }
458
- : {
459
- label: `${label} CLI not found`,
460
- status: "fail",
461
- detail: `the ${label} SDK spawns \`${backend}\` — install it or this backend cannot start`,
462
- issue: true,
463
- },
464
- );
465
- } else if (backend === "openai-agents") {
466
- checks.push({ label: "OpenAI Agents SDK bundled", status: "ok" });
467
- const hasEnvKey = Boolean(process.env.OPENAI_API_KEY);
468
- const hasCfgKey = Boolean(config?.openaiApiKey);
469
- if (hasEnvKey || hasCfgKey) {
470
- const sources: string[] = [];
471
- if (hasEnvKey) sources.push("OPENAI_API_KEY env");
472
- if (hasCfgKey) sources.push("openaiApiKey in talon.json");
473
- checks.push({
474
- label: "OpenAI Agents auth",
475
- status: "ok",
476
- detail: sources.join(", "),
477
- });
478
- } else {
479
- checks.push({
480
- label: "OpenAI Agents auth missing",
253
+ },
254
+ ];
255
+ }
256
+ if (!factory.doctor) {
257
+ return [{ label: `${factory.label}: no doctor checks`, status: "info" }];
258
+ }
259
+ try {
260
+ return await factory.doctor(config, isActive);
261
+ } catch (err) {
262
+ return [
263
+ {
264
+ label: `${factory.label} doctor check errored`,
481
265
  status: "warn",
482
- detail: "set OPENAI_API_KEY or openaiApiKey in talon.json",
483
- issue: true,
484
- });
485
- }
486
- const envBase = process.env.OPENAI_BASE_URL;
487
- const cfgBase = config?.openaiBaseUrl;
488
- if (envBase || cfgBase) {
489
- checks.push({
490
- label: "OpenAI-compatible endpoint",
491
- status: "ok",
492
- detail: envBase ? `env (${envBase})` : `config (${cfgBase})`,
493
- });
494
- } else {
495
- checks.push({
496
- label: "Endpoint: api.openai.com (default)",
497
- status: "info",
498
- });
499
- }
266
+ detail: errorNote(err),
267
+ issue: isActive,
268
+ },
269
+ ];
500
270
  }
501
-
502
- return checks;
503
271
  }
504
272
 
505
273
  /**
@@ -26,7 +26,7 @@ import { dirs } from "../../util/paths.js";
26
26
  * The github-mcp-server image tag Talon runs. Bump deliberately, with
27
27
  * the canary workflow green — see .github/workflows/native-provision.yml.
28
28
  */
29
- export const GITHUB_MCP_PINNED_TAG = "v1.11.0";
29
+ export const GITHUB_MCP_PINNED_TAG = "v1.12.1";
30
30
 
31
31
  const GITHUB_MCP_IMAGE = "ghcr.io/github/github-mcp-server";
32
32
 
@@ -0,0 +1,12 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ /** Whether `name` resolves on PATH (`which` / `where`), without running it. */
4
+ export function binaryOnPath(name: string): boolean {
5
+ try {
6
+ const lookupCmd = process.platform === "win32" ? "where" : "which";
7
+ execFileSync(lookupCmd, [name], { stdio: "pipe" });
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }