talon-agent 3.33.1 → 3.33.3
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 +3 -3
- package/src/backend/builtins.ts +16 -0
- package/src/backend/claude-sdk/doctor.ts +108 -0
- package/src/backend/claude-sdk/factory.ts +2 -0
- package/src/backend/codex/doctor.ts +50 -0
- package/src/backend/codex/factory.ts +2 -0
- package/src/backend/openai-agents/doctor.ts +51 -0
- package/src/backend/openai-agents/factory.ts +2 -0
- package/src/backend/remote-server/factory.ts +17 -0
- package/src/bootstrap.ts +5 -8
- package/src/cli/doctor.ts +4 -0
- package/src/core/agent-runtime/backend-registry.ts +12 -0
- package/src/core/doctor-types.ts +82 -0
- package/src/core/doctor.ts +46 -278
- package/src/plugins/github/provision.ts +1 -1
- package/src/util/binary-on-path.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "3.33.
|
|
3
|
+
"version": "3.33.3",
|
|
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",
|
|
@@ -106,9 +106,9 @@
|
|
|
106
106
|
"@kilocode/sdk": "^7.2.22",
|
|
107
107
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
108
108
|
"@openai/agents": "^0.17.0",
|
|
109
|
-
"@openai/codex-sdk": "^0.
|
|
109
|
+
"@openai/codex-sdk": "^0.153.2",
|
|
110
110
|
"@opencode-ai/sdk": "^1.17.4",
|
|
111
|
-
"@playwright/mcp": "0.0.
|
|
111
|
+
"@playwright/mcp": "0.0.80",
|
|
112
112
|
"@types/cross-spawn": "^6.0.6",
|
|
113
113
|
"@types/qrcode": "^1.5.6",
|
|
114
114
|
"baileys": "^7.0.0-rc14",
|
|
@@ -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
|
|
175
|
-
//
|
|
176
|
-
// backend
|
|
177
|
-
await import("./backend/
|
|
178
|
-
await
|
|
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
|
+
}
|
package/src/core/doctor.ts
CHANGED
|
@@ -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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
:
|
|
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 || !
|
|
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
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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:
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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:
|
|
483
|
-
issue:
|
|
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.
|
|
29
|
+
export const GITHUB_MCP_PINNED_TAG = "v1.12.0";
|
|
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
|
+
}
|