diff --git a/README.md b/README.md index f0061f1..9618e4b 100644 --- a/README.md +++ b/README.md @@ -174,15 +174,16 @@ agent-workflow logs --run --tail 200 ### `agent-workflow web` -Start a local read-only web server to monitor agent executions, runs, cycles, and logs in real-time. +Start a local web console to monitor agent executions, runs, cycles, and logs in real-time, and to manage host-wide executor model overrides. ```bash agent-workflow web [--port ] ``` - **`--port `** — specify a custom port to listen on (default: `4317`). -- **Security & Scope** — The server binds exclusively to the loopback interface (`127.0.0.1`) for local-only access. It refuses any external or remote listener configurations. Since executor logs can contain sensitive code, configuration details, or tokens/credentials, binding to `127.0.0.1` ensures that the console remains local. -- **Observability Boundaries & Limits** — The console is completely read-only and provides no endpoints to modify or control workflows. It directly parses and displays the execution progress, cycles, timeline, and stdout/stderr output. It cannot recover or display private reasoning tokens/processes of underlying models that are not output to the console. +- **Security & Scope** — The server binds exclusively to the loopback interface (`127.0.0.1`) for local-only access. It refuses any external or remote listener configurations. Since executor logs can contain sensitive code, configuration details, or tokens/credentials, binding to `127.0.0.1` ensures that the console remains local. Mutation endpoints require same-origin localhost requests, JSON content type, bounded bodies, and validated model values. Credentials are never returned to the browser. +- **Workflow monitoring remains read-only** — The console provides no endpoints to start, stop, decide, or otherwise control workflow runs. It parses and displays execution progress, cycles, timeline, and stdout/stderr output. It cannot recover or display private reasoning tokens/processes of underlying models that are not output to the console. +- **Writable global model settings** — The Chinese “全局执行器模型配置” view can independently set Claude, Agy, and Reasonix models. Discovery uses the current CLI environment: Claude via Anthropic `/v1/models` (with configured-default fallback), Agy via `agy models`, and Reasonix via `reasonix doctor --json` provider names. Discovery failures are isolated per executor, and manual entry always remains available. Saving atomically updates only `executors..model` in `~/.agent-workflow/config.json`, preserves unrelated fields, and clears an override when the value is emptied. Active executors are not interrupted; saved models take effect on later cycles, scope remediation, extensions, and executor retries through the existing reload path. Project configuration continues to override global values. - **Interaction with `logs --follow`** — The web server directly reads the persisted run files without taking any logs follow locks or interfering with the main executor loop, allowing you to use both the CLI `logs --follow` and the web console concurrently. ### `agent-workflow abort` diff --git a/skills/agent-workflow-host/SKILL.md b/skills/agent-workflow-host/SKILL.md index 0a0a7fe..be2a387 100644 --- a/skills/agent-workflow-host/SKILL.md +++ b/skills/agent-workflow-host/SKILL.md @@ -115,7 +115,7 @@ Executor has been running for ~2m. Current activity: Editing src/parser.ts (PID Start that `logs --follow` command at most once for a given Run in the current Codex thread. If it reports that a follower is already active, do not retry the same command; use `agent-workflow status --run --json` for progress instead. -The `agent-workflow web` command starts a local read-only web server that reads workflow state files without taking follower locks or interrupting the main execution loop. It only binds to the local loopback interface (127.0.0.1) for safety, as logs can contain sensitive code and environment details. The web interface displays status, execution events, cycle summaries, and live log updates. The console displays the CLI's actual stdout/stderr outputs and parsed structured activity; it cannot reconstruct unlogged private model reasoning. +The `agent-workflow web` command starts a local web server that reads workflow state files without taking follower locks or interrupting the main execution loop. Workflow monitoring remains read-only: it displays status, execution events, cycle summaries, and live log updates, and cannot reconstruct unlogged private model reasoning. The console also exposes a Chinese global model settings view that can write only `executors..model` overrides in `~/.agent-workflow/config.json`. Discovery uses each executor's current CLI environment, isolates failures per executor, never returns credentials, and always allows manual model entry. Project configuration can still override global values, and saved models take effect on later cycles/retries without restarting active executors. The server binds only to 127.0.0.1. `timeoutSeconds` is a consecutive inactivity timeout. Observable executor output refreshes the deadline, so do not treat total runtime beyond that value as a timeout while activity continues. diff --git a/src/workflow-config.ts b/src/workflow-config.ts index c941247..31509ad 100644 --- a/src/workflow-config.ts +++ b/src/workflow-config.ts @@ -62,6 +62,65 @@ export function loadGlobalWorkflowConfig(): WorkflowProjectConfig | undefined { return validateProjectConfig(raw, filePath); } +/** + * Atomically update executors..model in the global config. + * Pass `null` or `undefined` to remove the model override for an executor. + * Preserves all unrelated fields, including per-executor binary/agent/profile/budget. + */ +export function saveGlobalExecutorModels(models: Partial>): void { + const filePath = globalConfigPath(); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + + let existing: WorkflowProjectConfig | undefined; + try { + existing = loadGlobalWorkflowConfig(); + } catch (err) { + // If the existing file is corrupt or otherwise unreadable, fail instead of silently overwriting it. + throw new WorkflowConfigError(`Cannot update global config: ${(err as Error).message}`); + } + + const base: WorkflowProjectConfig = existing + ? { + ...existing, + executors: existing.executors + ? Object.fromEntries( + Object.entries(existing.executors).map(([kind, cfg]) => [kind, { ...(cfg as ExecutorConfig) }]), + ) + : {}, + } + : { version: "1", executors: {} }; + + if (!base.executors) base.executors = {}; + for (const kind of EXECUTOR_KINDS) { + if (!Object.prototype.hasOwnProperty.call(models, kind)) continue; + const value = models[kind]; + if (!base.executors[kind]) { + (base.executors as Record)[kind] = {}; + } + const exec = base.executors[kind] as Record; + if (value === null || value === undefined) { + delete exec.model; + } else { + const trimmed = value.trim(); + if (!trimmed) { + throw new WorkflowConfigError(`Executor "${kind}" model must be a non-empty string`); + } + exec.model = trimmed; + } + if (Object.keys(exec).length === 0) { + delete (base.executors as Record)[kind]; + } + } + if (base.executors && Object.keys(base.executors).length === 0) { + delete base.executors; + } + + const tmp = `${filePath}.tmp.${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(base, null, 2) + "\n", "utf8"); + fs.renameSync(tmp, filePath); +} + function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectConfig { if (!raw || typeof raw !== "object") { throw new WorkflowConfigError(`${filePath}: must be a JSON object`); diff --git a/src/workflow-model-discovery.test.ts b/src/workflow-model-discovery.test.ts new file mode 100644 index 0000000..f5477a7 --- /dev/null +++ b/src/workflow-model-discovery.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { extractModelIds, discoverExecutorModels } from "./workflow-model-discovery.js"; +import { saveGlobalExecutorModels, loadGlobalWorkflowConfig } from "./workflow-config.js"; + +describe("workflow-model-discovery helpers", () => { + it("extracts model ids from data[] shape", () => { + expect(extractModelIds({ + data: [ + { id: "claude-sonnet-4" }, + { id: "claude-opus-4" }, + { name: "ignored" }, + ], + })).toEqual(["claude-sonnet-4", "claude-opus-4"]); + }); + + it("extracts model ids from models[] shape", () => { + expect(extractModelIds({ + models: [ + { id: "model-a" }, + "model-b", + ], + })).toEqual(["model-a", "model-b"]); + }); +}); + +describe("saveGlobalExecutorModels", () => { + let tmpHome: string; + let originalHome: string | undefined; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-global-config-")); + originalHome = process.env.HOME; + process.env.HOME = tmpHome; + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("atomically updates only executor models and preserves unrelated fields", () => { + const configDir = path.join(tmpHome, ".agent-workflow"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({ + version: "1", + defaultExecutor: "reasonix", + maxCycles: 7, + executors: { + reasonix: { binary: "/usr/local/bin/reasonix", model: "old-model", profile: "fast" }, + claude: { binary: "claude" }, + }, + }, null, 2)); + + saveGlobalExecutorModels({ + reasonix: "new-reasonix", + claude: "claude-sonnet-4", + agy: null, + }); + + const loaded = loadGlobalWorkflowConfig()!; + expect(loaded.defaultExecutor).toBe("reasonix"); + expect(loaded.maxCycles).toBe(7); + expect(loaded.executors?.reasonix).toEqual({ + binary: "/usr/local/bin/reasonix", + model: "new-reasonix", + profile: "fast", + }); + expect(loaded.executors?.claude).toEqual({ + binary: "claude", + model: "claude-sonnet-4", + }); + expect(loaded.executors?.agy).toBeUndefined(); + }); + + it("null clears a model override without dropping other executor fields", () => { + const configDir = path.join(tmpHome, ".agent-workflow"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({ + version: "1", + executors: { + agy: { binary: "agy", model: "to-remove", agent: "code-assistant" }, + }, + })); + + saveGlobalExecutorModels({ agy: null }); + const loaded = loadGlobalWorkflowConfig()!; + expect(loaded.executors?.agy).toEqual({ binary: "agy", agent: "code-assistant" }); + }); +}); + +describe("discoverExecutorModels isolation", () => { + let tmpHome: string; + let originalHome: string | undefined; + let originalFetch: typeof globalThis.fetch | undefined; + const ANTHROPIC_VARS = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + ] as const; + const savedVars: Record = {}; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-discovery-")); + originalHome = process.env.HOME; + process.env.HOME = tmpHome; + originalFetch = globalThis.fetch; + for (const key of ANTHROPIC_VARS) { + savedVars[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + for (const key of ANTHROPIC_VARS) { + if (savedVars[key] === undefined) delete process.env[key]; + else process.env[key] = savedVars[key]; + } + if (originalFetch) globalThis.fetch = originalFetch; + else delete (globalThis as { fetch?: typeof fetch }).fetch; + fs.rmSync(tmpHome, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + function writeClaudeSettings(env: Record): void { + const claudeDir = path.join(tmpHome, ".claude"); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, "settings.json"), JSON.stringify({ env })); + } + + it("keeps Claude discovery isolated and never returns credentials", async () => { + process.env.ANTHROPIC_API_KEY = "secret-key-value"; + process.env.ANTHROPIC_BASE_URL = "https://user:pass@api.example.com/v1?token=abc#frag"; + globalThis.fetch = vi.fn(async () => { + throw new Error("network down secret-key-value"); + }) as unknown as typeof fetch; + + const result = await discoverExecutorModels(); + expect(result.claude.status).toBe("error"); + expect(result.claude.error).toBeTruthy(); + expect(JSON.stringify(result)).not.toContain("secret-key-value"); + expect(JSON.stringify(result)).not.toContain("user:pass"); + expect(result.agy.status === "ok" || result.agy.status === "error").toBe(true); + expect(result.reasonix.status === "ok" || result.reasonix.status === "error").toBe(true); + }); + + it("resolves Claude settings from ~/.claude/settings.json when process env is unset", async () => { + // Process env is fully cleared in beforeEach; settings.json is the only source. + writeClaudeSettings({ + ANTHROPIC_BASE_URL: "https://gateway.internal.example", + ANTHROPIC_AUTH_TOKEN: "token-from-settings", + ANTHROPIC_DEFAULT_SONNET_MODEL: "gateway-sonnet", + ANTHROPIC_DEFAULT_OPUS_MODEL: "gateway-opus", + }); + + let capturedUrl = ""; + let capturedAuth: string | undefined; + let capturedApiKey: string | undefined; + globalThis.fetch = vi.fn(async (input: any, init: any) => { + capturedUrl = String(input); + const headers = new Headers(init?.headers); + capturedAuth = headers.get("authorization") ?? undefined; + capturedApiKey = headers.get("x-api-key") ?? undefined; + return { + ok: true, + status: 200, + json: async () => ({ data: [{ id: "gateway-model-1" }, { id: "gateway-model-2" }] }), + }; + }) as unknown as typeof fetch; + + const result = await discoverExecutorModels(); + expect(result.claude.status).toBe("ok"); + expect(capturedUrl.startsWith("https://gateway.internal.example")).toBe(true); + // Auth token must use the Authorization: Bearer header, not x-api-key. + expect(capturedAuth).toBe("Bearer token-from-settings"); + expect(capturedApiKey == null).toBe(true); + expect(result.claude.models).toContain("gateway-model-1"); + // Configured default model candidates are surfaced. + expect(result.claude.defaults).toContain("gateway-sonnet"); + expect(result.claude.defaults).toContain("gateway-opus"); + // The resolved token must never appear anywhere in the browser-visible result. + expect(JSON.stringify(result)).not.toContain("token-from-settings"); + }); + + it("redacts a settings.json auth token from browser-visible errors", async () => { + writeClaudeSettings({ + ANTHROPIC_BASE_URL: "https://gateway.internal.example", + ANTHROPIC_AUTH_TOKEN: "token-from-settings", + }); + globalThis.fetch = vi.fn(async () => { + throw new Error("upstream failed for token-from-settings"); + }) as unknown as typeof fetch; + + const result = await discoverExecutorModels(); + expect(result.claude.status).toBe("error"); + expect(result.claude.error).toBeTruthy(); + expect(JSON.stringify(result)).not.toContain("token-from-settings"); + }); +}); diff --git a/src/workflow-model-discovery.ts b/src/workflow-model-discovery.ts new file mode 100644 index 0000000..696180b --- /dev/null +++ b/src/workflow-model-discovery.ts @@ -0,0 +1,386 @@ +/** + * Executor model discovery. + * + * Discovers available models for each supported executor by querying the same + * binaries/configuration the CLI uses. Discovery failures are isolated per + * executor and never expose credentials to callers. + */ + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { ExecutorConfig, ExecutorKind } from "./workflow-types.js"; +import { EXECUTOR_KINDS } from "./workflow-types.js"; +import { loadGlobalWorkflowConfig } from "./workflow-config.js"; + +/** Bounded discovery timeout so web requests cannot hang indefinitely. */ +const DISCOVERY_TIMEOUT_MS = 3_000; + +export interface ExecutorModelDiscovery { + status: "ok" | "error"; + /** Discovered model identifiers (executor-specific). */ + models?: string[]; + /** Suggested default candidates used when discovery fails or is unavailable. */ + defaults?: string[]; + /** Human-readable failure message when status is "error". */ + error?: string; +} + +export interface ModelDiscoveryResult { + claude: ExecutorModelDiscovery; + agy: ExecutorModelDiscovery; + reasonix: ExecutorModelDiscovery; + /** Current global model overrides, if any. */ + current: Partial>; +} + +function getExecutorConfig(kind: ExecutorKind): ExecutorConfig { + const global = loadGlobalWorkflowConfig(); + return (global?.executors?.[kind] as ExecutorConfig) ?? {}; +} + +function getBinary(config: ExecutorConfig, defaultBin: string): string { + return config.binary || defaultBin; +} + +/** Anthropic connection settings resolved for Claude discovery. */ +interface ClaudeSettings { + baseUrl: string; + /** Credential value, if any (api key or auth token). */ + credential?: string; + /** Which authentication header to use for the resolved credential. */ + authHeader?: "x-api-key" | "authorization"; + /** Configured default model candidates from ANTHROPIC_DEFAULT_*_MODEL. */ + defaultModels: string[]; + /** Every resolved credential value that must be redacted from errors. */ + secrets: string[]; +} + +/** Read ~/.claude/settings.json's `env` block, if present. */ +function readClaudeSettingsEnv(): Record { + const settingsPath = path.join(process.env.HOME || os.homedir(), ".claude", "settings.json"); + try { + if (!fs.existsSync(settingsPath)) return {}; + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object") return {}; + const env = (parsed as Record).env; + if (!env || typeof env !== "object") return {}; + const result: Record = {}; + for (const [key, value] of Object.entries(env as Record)) { + if (typeof value === "string") result[key] = value; + } + return result; + } catch { + return {}; + } +} + +/** + * Resolve Claude discovery settings from the running process environment first, + * then fall back to ~/.claude/settings.json (the same settings the CLI reads). + */ +function resolveClaudeSettings(): ClaudeSettings { + const settingsEnv = readClaudeSettingsEnv(); + const pick = (key: string): string | undefined => { + const fromProcess = process.env[key]?.trim(); + if (fromProcess) return fromProcess; + const fromSettings = settingsEnv[key]?.trim(); + return fromSettings || undefined; + }; + + const rawBaseUrl = pick("ANTHROPIC_BASE_URL") || "https://api.anthropic.com"; + const baseUrl = stripUrlCredentials(rawBaseUrl); + + const apiKey = pick("ANTHROPIC_API_KEY"); + const authToken = pick("ANTHROPIC_AUTH_TOKEN"); + + const secrets: string[] = []; + if (apiKey) secrets.push(apiKey); + if (authToken) secrets.push(authToken); + + let credential: string | undefined; + let authHeader: "x-api-key" | "authorization" | undefined; + // Prefer an explicit API key; otherwise use the auth token as a bearer credential. + if (apiKey) { + credential = apiKey; + authHeader = "x-api-key"; + } else if (authToken) { + credential = authToken; + authHeader = "authorization"; + } + + const defaultModels: string[] = []; + for (const key of ["ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL"]) { + const value = pick(key); + if (value && !defaultModels.includes(value)) defaultModels.push(value); + } + + return { baseUrl, credential, authHeader, defaultModels, secrets }; +} + +function redactSecrets(message: string, secrets: string[]): string { + let result = message; + for (const secret of secrets) { + if (secret && secret.length > 0) { + result = result.split(secret).join("[redacted]"); + } + } + return result; +} + +function cleanError(err: unknown, secrets: string[] = []): string { + let message = err instanceof Error ? err.message : String(err); + message = message.replace(/\r?\n/g, " "); + // Never echo resolved credentials that may appear in fetch/network error text. + message = redactSecrets(message, secrets); + message = message.replace(/https?:\/\/[^/\s]*:[^/\s]*@/gi, "https://[redacted]@"); + return message; +} + +function runCommand( + command: string, + args: string[], + timeoutMs = DISCOVERY_TIMEOUT_MS, +): Promise<{ status: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + shell: false, + }); + let stdout = ""; + let stderr = ""; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGKILL"); + reject(new Error(`Timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout += chunk.toString(); + if (stdout.length > 1_000_000) stdout = stdout.slice(-1_000_000); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + if (stderr.length > 1_000_000) stderr = stderr.slice(-1_000_000); + }); + child.on("error", (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(err); + }); + child.on("close", (status) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ status, stdout, stderr }); + }); + }); +} + +function stripUrlCredentials(urlStr: string): string { + try { + const url = new URL(urlStr); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return urlStr; + } +} + +function parseLineDelimitedModels(output: string): string[] { + return output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); +} + +export function extractModelIds(data: unknown): string[] { + if (!data || typeof data !== "object") return []; + const candidates: string[] = []; + const obj = data as Record; + + if (Array.isArray(obj.data)) { + for (const item of obj.data) { + if (item && typeof item === "object" && typeof (item as Record).id === "string") { + candidates.push((item as Record).id as string); + } + } + } + + if (candidates.length === 0 && Array.isArray(obj.models)) { + for (const item of obj.models) { + if (item && typeof item === "object" && typeof (item as Record).id === "string") { + candidates.push((item as Record).id as string); + } else if (typeof item === "string") { + candidates.push(item); + } + } + } + + return candidates; +} + +function extractReasonixProviders(data: unknown): string[] { + if (!data || typeof data !== "object") return []; + const obj = data as Record; + const providers: string[] = []; + + if (Array.isArray(obj.providers)) { + for (const provider of obj.providers) { + if (provider && typeof provider === "object" && typeof (provider as Record).name === "string") { + providers.push((provider as Record).name as string); + } + } + } + + if (providers.length === 0 && Array.isArray(obj.models)) { + for (const item of obj.models) { + if (typeof item === "string") { + providers.push(item); + } else if (item && typeof item === "object" && typeof (item as Record).provider === "string") { + providers.push((item as Record).provider as string); + } + } + } + + return [...new Set(providers)]; +} + +const CLAUDE_DEFAULTS = [ + "claude-sonnet-4", + "claude-opus-4", + "claude-sonnet-4-20250514", + "claude-opus-4-20250514", + "claude-3-7-sonnet-20250219", + "claude-3-5-sonnet-20241022", +]; + +async function discoverClaudeModels(config: ExecutorConfig): Promise { + const settings = resolveClaudeSettings(); + // Configured ANTHROPIC_DEFAULT_*_MODEL candidates take precedence over the built-in list. + const defaults = [...new Set([...settings.defaultModels, ...CLAUDE_DEFAULTS])]; + + // Without a resolved credential we cannot query the gateway; surface the fallback candidates. + if (!settings.credential || !settings.authHeader) { + return { status: "ok", models: defaults, defaults }; + } + + try { + const url = new URL("/v1/models", settings.baseUrl); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS); + + const headers: Record = { + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }; + if (settings.authHeader === "x-api-key") { + headers["x-api-key"] = settings.credential; + } else { + headers["authorization"] = `Bearer ${settings.credential}`; + } + + let res: Response; + try { + res = await fetch(url.toString(), { method: "GET", headers, signal: controller.signal }); + } finally { + clearTimeout(timer); + } + + if (!res.ok) { + throw new Error(`Anthropic API returned ${res.status}`); + } + + const data = (await res.json()) as unknown; + const models = extractModelIds(data); + return { status: "ok", models: models.length > 0 ? models : defaults, defaults }; + } catch (err) { + return { status: "error", error: cleanError(err, settings.secrets), defaults }; + } +} + +async function discoverAgyModels(config: ExecutorConfig): Promise { + const defaults: string[] = []; + const bin = getBinary(config, "agy"); + + try { + const result = await runCommand(bin, ["models"]); + if (result.status !== 0) { + throw new Error(result.stderr?.trim() || `agy models exited with code ${result.status ?? "unknown"}`); + } + + const stdout = result.stdout?.trim() ?? ""; + let models: string[] = []; + if (stdout.startsWith("{") || stdout.startsWith("[")) { + try { + const parsed = JSON.parse(stdout) as unknown; + models = extractModelIds(parsed); + if (models.length === 0 && Array.isArray(parsed)) { + models = parsed.filter((item): item is string => typeof item === "string"); + } + } catch { + models = parseLineDelimitedModels(stdout); + } + } else { + models = parseLineDelimitedModels(stdout); + } + return { status: "ok", models: models.length > 0 ? models : defaults, defaults }; + } catch (err) { + return { status: "error", error: cleanError(err), defaults }; + } +} + +async function discoverReasonixModels(config: ExecutorConfig): Promise { + const defaults: string[] = []; + const bin = getBinary(config, "reasonix"); + + try { + const result = await runCommand(bin, ["doctor", "--json"]); + if (result.status !== 0) { + throw new Error(result.stderr?.trim() || `reasonix doctor exited with code ${result.status ?? "unknown"}`); + } + + let data: unknown; + try { + data = JSON.parse(result.stdout?.trim() ?? "{}"); + } catch (err) { + throw new Error(`Failed to parse reasonix doctor output: ${cleanError(err)}`); + } + + const providers = extractReasonixProviders(data); + return { status: "ok", models: providers.length > 0 ? providers : defaults, defaults }; + } catch (err) { + return { status: "error", error: cleanError(err), defaults }; + } +} + +/** Discover available models for Claude, Agy, and Reasonix. */ +export async function discoverExecutorModels(): Promise { + const global = loadGlobalWorkflowConfig(); + const current: Partial> = {}; + for (const kind of EXECUTOR_KINDS) { + const model = global?.executors?.[kind]?.model; + current[kind] = model ?? null; + } + + const configs = { + claude: getExecutorConfig("claude"), + agy: getExecutorConfig("agy"), + reasonix: getExecutorConfig("reasonix"), + }; + + const [claude, agy, reasonix] = await Promise.all([ + discoverClaudeModels(configs.claude), + discoverAgyModels(configs.agy), + discoverReasonixModels(configs.reasonix), + ]); + + return { claude, agy, reasonix, current }; +} diff --git a/src/workflow-web-assets.ts b/src/workflow-web-assets.ts index 0832332..a5929b6 100644 --- a/src/workflow-web-assets.ts +++ b/src/workflow-web-assets.ts @@ -79,6 +79,210 @@ export function getAssetsHtml(): string { gap: 10px; } + .sidebar-header-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; + } + + .icon-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + width: 34px; + height: 34px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + } + + .icon-btn:hover { + color: var(--text); + border-color: var(--accent); + background-color: var(--bg-card); + } + + .settings-overlay { + position: fixed; + inset: 0; + background: rgba(7, 9, 16, 0.72); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; + } + + .settings-overlay.open { + display: flex; + } + + .settings-panel { + width: min(920px, 100%); + max-height: min(90vh, 900px); + overflow: auto; + background: var(--bg-sidebar); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45); + } + + .settings-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + padding: 20px 24px; + border-bottom: 1px solid var(--border); + } + + .settings-header h2 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 6px; + } + + .settings-subtitle { + color: var(--text-muted); + font-size: 0.9rem; + line-height: 1.5; + } + + .settings-body { + padding: 20px 24px 8px; + display: flex; + flex-direction: column; + gap: 16px; + } + + .executor-settings-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + } + + .executor-settings-title { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 12px; + } + + .executor-settings-title h3 { + font-size: 1rem; + font-weight: 600; + } + + .status-pill { + font-size: 0.75rem; + padding: 3px 8px; + border-radius: 999px; + font-weight: 600; + white-space: nowrap; + } + + .status-pill.ok { + background: rgba(16, 185, 129, 0.15); + color: #10b981; + } + + .status-pill.error { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + } + + .status-pill.loading { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + } + + .settings-field { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; + } + + .settings-field label { + font-size: 0.85rem; + color: var(--text-muted); + } + + .settings-field-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + } + + .settings-help { + font-size: 0.8rem; + color: var(--text-muted); + line-height: 1.45; + } + + .settings-error { + font-size: 0.82rem; + color: #f87171; + margin-top: 4px; + } + + .settings-footer { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 12px; + padding: 16px 24px 24px; + border-top: 1px solid var(--border); + } + + .settings-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + + .primary-btn { + background: var(--accent); + border: 1px solid var(--accent); + color: white; + padding: 8px 14px; + border-radius: 8px; + cursor: pointer; + font-family: inherit; + font-size: 0.9rem; + } + + .primary-btn:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + } + + .primary-btn:disabled, + .control-btn:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + .settings-message { + min-height: 1.2em; + font-size: 0.85rem; + color: var(--text-muted); + } + + .settings-message.success { + color: #34d399; + } + + .settings-message.error { + color: #f87171; + } + .sidebar-header h1 { font-size: 1.25rem; font-weight: 600; @@ -502,6 +706,20 @@ export function getAssetsHtml(): string { .control-group { justify-content: space-between; } + .settings-field-row { + grid-template-columns: 1fr; + } + .settings-footer { + flex-direction: column; + align-items: stretch; + } + .settings-actions { + width: 100%; + } + .settings-actions .control-btn, + .settings-actions .primary-btn { + flex: 1; + } } @@ -515,6 +733,14 @@ export function getAssetsHtml(): string {

智能体执行监控

+
@@ -684,10 +910,94 @@ export function getAssetsHtml(): string {
+ +