From 9858576abe56c4870f49feade2b36d290ab51296 Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 30 Jan 2026 13:12:24 +0800 Subject: [PATCH 1/7] feat(agent): support custom API key and base URL configuration Add apiKey and baseUrl options to AgentOptions, with support for: - Explicit values via CLI args (--api-key, --base-url) - Provider-specific environment variables (e.g., OPENAI_BASE_URL) - Generic fallback format (PROVIDER_BASE_URL) Priority: CLI args > env vars > library defaults. Co-Authored-By: Claude Opus 4.5 --- src/agent/cli.ts | 14 +++++++++++ src/agent/runner.ts | 59 ++++++++++++++++++++++++++++++++++++++++----- src/agent/types.ts | 4 +++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/agent/cli.ts b/src/agent/cli.ts index ab017acee..0cbd6662a 100644 --- a/src/agent/cli.ts +++ b/src/agent/cli.ts @@ -5,6 +5,8 @@ type CliOptions = { profile?: string | undefined; provider?: string | undefined; model?: string | undefined; + apiKey?: string | undefined; + baseUrl?: string | undefined; system?: string | undefined; thinking?: string | undefined; cwd?: string | undefined; @@ -21,6 +23,8 @@ function printUsage() { console.log(" --profile ID Load agent profile (identity, soul, tools, memory)"); console.log(" --provider NAME LLM provider (e.g., openai, anthropic, kimi)"); console.log(" --model NAME Model name"); + console.log(" --api-key KEY API key (overrides environment variable)"); + console.log(" --base-url URL Custom base URL for the provider"); console.log(" --system TEXT System prompt (ignored if --profile is set)"); console.log(" --thinking LEVEL Thinking level"); console.log(" --cwd DIR Working directory for commands"); @@ -53,6 +57,14 @@ function parseArgs(argv: string[]) { opts.model = args.shift(); continue; } + if (arg === "--api-key") { + opts.apiKey = args.shift(); + continue; + } + if (arg === "--base-url") { + opts.baseUrl = args.shift(); + continue; + } if (arg === "--system") { opts.system = args.shift(); continue; @@ -112,6 +124,8 @@ async function main() { profileId: opts.profile, provider: opts.provider, model: opts.model, + apiKey: opts.apiKey, + baseUrl: opts.baseUrl, systemPrompt: opts.system, thinkingLevel: opts.thinking as any, cwd: opts.cwd, diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 34cb90de2..8c0d7413b 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -13,9 +13,12 @@ import { } from "./context-window/index.js"; /** - * Get API Key based on provider + * Get API Key based on provider. + * Priority: explicit key > provider-specific env var > generic env var format. */ -function resolveApiKey(provider: string): string | undefined { +function resolveApiKey(provider: string, explicitKey?: string): string | undefined { + if (explicitKey) return explicitKey; + const providerEnvMap: Record = { openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", @@ -39,6 +42,36 @@ function resolveApiKey(provider: string): string | undefined { return process.env[`${normalizedProvider}_API_KEY`]; } +/** + * Get Base URL based on provider. + * Priority: explicit URL > provider-specific env var > generic env var format. + */ +function resolveBaseUrl(provider: string, explicitUrl?: string): string | undefined { + if (explicitUrl) return explicitUrl; + + const providerEnvMap: Record = { + openai: "OPENAI_BASE_URL", + anthropic: "ANTHROPIC_BASE_URL", + google: "GOOGLE_BASE_URL", + "google-genai": "GOOGLE_BASE_URL", + kimi: "MOONSHOT_BASE_URL", + "kimi-coding": "MOONSHOT_BASE_URL", + deepseek: "DEEPSEEK_BASE_URL", + groq: "GROQ_BASE_URL", + mistral: "MISTRAL_BASE_URL", + together: "TOGETHER_BASE_URL", + }; + + const envVar = providerEnvMap[provider]; + if (envVar) { + return process.env[envVar]; + } + + // Try generic format: PROVIDER_BASE_URL + const normalizedProvider = provider.toUpperCase().replace(/-/g, "_"); + return process.env[`${normalizedProvider}_BASE_URL`]; +} + export class Agent { private readonly agent: PiAgentCore; private readonly output; @@ -57,7 +90,15 @@ export class Agent { this.output = createAgentOutput({ stdout, stderr }); this.debug = options.debug ?? false; - this.agent = new PiAgentCore(); + // Resolve provider for API key and base URL + const resolvedProvider = options.provider ?? "kimi-coding"; + const apiKey = resolveApiKey(resolvedProvider, options.apiKey); + + this.agent = new PiAgentCore( + apiKey + ? { getApiKey: (_provider: string) => apiKey } + : {}, + ); // Load Agent Profile (if profileId is specified) let systemPrompt: string | undefined; @@ -101,12 +142,18 @@ export class Agent { return tempSession.getMeta(); })(); - const model = options.provider && options.model ? resolveModel(options) : resolveModel({ + let model = options.provider && options.model ? resolveModel(options) : resolveModel({ ...options, provider: storedMeta?.provider, model: storedMeta?.model, }); + // Override base URL if provided via options or environment variable + const baseUrl = resolveBaseUrl(model.provider, options.baseUrl); + if (baseUrl) { + model = { ...model, baseUrl }; + } + // === Context Window Guard === this.contextWindowGuard = checkContextWindow({ modelContextWindow: model.contextWindow, @@ -133,7 +180,7 @@ export class Agent { const compactionMode = options.compactionMode ?? "tokens"; // 默认使用 token 模式 // 获取 API Key(用于 summary 模式) - const apiKey = compactionMode === "summary" ? resolveApiKey(model.provider) : undefined; + const summaryApiKey = compactionMode === "summary" ? resolveApiKey(model.provider, options.apiKey) : undefined; // 创建 SessionManager(带 context window 配置) this.session = new SessionManager({ @@ -147,7 +194,7 @@ export class Agent { minKeepMessages: options.minKeepMessages, // Summary 模式参数 model: compactionMode === "summary" ? model : undefined, - apiKey, + apiKey: summaryApiKey, customInstructions: options.summaryInstructions, }); diff --git a/src/agent/types.ts b/src/agent/types.ts index 94ab9c451..405f0cc74 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -17,6 +17,10 @@ export type AgentOptions = { profileBaseDir?: string | undefined; provider?: string | undefined; model?: string | undefined; + /** Custom API key (overrides environment variable) */ + apiKey?: string | undefined; + /** Custom base URL for the provider endpoint */ + baseUrl?: string | undefined; /** System prompt, if profileId is set will auto-construct from profile */ systemPrompt?: string | undefined; thinkingLevel?: ThinkingLevel | undefined; From 236863acd4d10983986cd3830c2ea92c85e420de Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 30 Jan 2026 13:15:11 +0800 Subject: [PATCH 2/7] feat(agent): support AGENT_PROVIDER and AGENT_MODEL env vars Add environment variable support for provider/model defaults: - AGENT_PROVIDER: default LLM provider (fallback: kimi-coding) - AGENT_MODEL: default model name - Auto-load .env via tsx --env-file in agent:cli script Priority: CLI args > env vars > session meta > defaults. Co-Authored-By: Claude Opus 4.5 --- package.json | 2 +- src/agent/runner.ts | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index dd93b1105..39a420b0c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "main": "dist/index.js", "scripts": { "dev": "tsx src/index.ts", - "agent:cli": "tsx src/agent/cli.ts", + "agent:cli": "tsx --env-file=.env src/agent/cli.ts", "agent:interactive": "tsx src/agent/interactive-cli.ts", "agent:profile": "tsx src/agent/profile-cli.ts", "dev:gateway": "tsx --watch src/gateway/main.ts", diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 8c0d7413b..61be8343f 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -90,8 +90,9 @@ export class Agent { this.output = createAgentOutput({ stdout, stderr }); this.debug = options.debug ?? false; - // Resolve provider for API key and base URL - const resolvedProvider = options.provider ?? "kimi-coding"; + // Resolve provider and model from options > env vars > defaults + const resolvedProvider = options.provider ?? process.env.AGENT_PROVIDER ?? "kimi-coding"; + const resolvedModel = options.model ?? process.env.AGENT_MODEL; const apiKey = resolveApiKey(resolvedProvider, options.apiKey); this.agent = new PiAgentCore( @@ -142,11 +143,9 @@ export class Agent { return tempSession.getMeta(); })(); - let model = options.provider && options.model ? resolveModel(options) : resolveModel({ - ...options, - provider: storedMeta?.provider, - model: storedMeta?.model, - }); + const effectiveProvider = resolvedModel ? resolvedProvider : (options.provider ?? storedMeta?.provider); + const effectiveModel = resolvedModel ?? options.model ?? storedMeta?.model; + let model = resolveModel({ ...options, provider: effectiveProvider, model: effectiveModel }); // Override base URL if provided via options or environment variable const baseUrl = resolveBaseUrl(model.provider, options.baseUrl); From 0d1f48cf53e07daf2f91d166a38b8db153153ca1 Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 30 Jan 2026 13:20:20 +0800 Subject: [PATCH 3/7] refactor(agent): rename env vars to LLM_PROVIDER and provider-specific MODEL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENT_PROVIDER → LLM_PROVIDER - AGENT_MODEL → provider-specific env vars (e.g., OPENAI_MODEL, DEEPSEEK_MODEL) - Add resolveModelId function matching resolveApiKey/resolveBaseUrl pattern Co-Authored-By: Claude Opus 4.5 --- src/agent/runner.ts | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 61be8343f..8eaee194b 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -72,6 +72,36 @@ function resolveBaseUrl(provider: string, explicitUrl?: string): string | undefi return process.env[`${normalizedProvider}_BASE_URL`]; } +/** + * Get Model ID based on provider. + * Priority: explicit model > provider-specific env var > generic env var format. + */ +function resolveModelId(provider: string, explicitModel?: string): string | undefined { + if (explicitModel) return explicitModel; + + const providerEnvMap: Record = { + openai: "OPENAI_MODEL", + anthropic: "ANTHROPIC_MODEL", + google: "GOOGLE_MODEL", + "google-genai": "GOOGLE_MODEL", + kimi: "MOONSHOT_MODEL", + "kimi-coding": "MOONSHOT_MODEL", + deepseek: "DEEPSEEK_MODEL", + groq: "GROQ_MODEL", + mistral: "MISTRAL_MODEL", + together: "TOGETHER_MODEL", + }; + + const envVar = providerEnvMap[provider]; + if (envVar) { + return process.env[envVar]; + } + + // Try generic format: PROVIDER_MODEL + const normalizedProvider = provider.toUpperCase().replace(/-/g, "_"); + return process.env[`${normalizedProvider}_MODEL`]; +} + export class Agent { private readonly agent: PiAgentCore; private readonly output; @@ -91,8 +121,8 @@ export class Agent { this.debug = options.debug ?? false; // Resolve provider and model from options > env vars > defaults - const resolvedProvider = options.provider ?? process.env.AGENT_PROVIDER ?? "kimi-coding"; - const resolvedModel = options.model ?? process.env.AGENT_MODEL; + const resolvedProvider = options.provider ?? process.env.LLM_PROVIDER ?? "kimi-coding"; + const resolvedModel = resolveModelId(resolvedProvider, options.model); const apiKey = resolveApiKey(resolvedProvider, options.apiKey); this.agent = new PiAgentCore( From a9dcde124bba542ee60bf82b9e0a1709986dac55 Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 30 Jan 2026 13:32:01 +0800 Subject: [PATCH 4/7] docs: add .env.example and environment configuration guide - Add .env.example with all supported provider env vars (OpenAI, Anthropic, DeepSeek, Kimi, Groq, Mistral, Together, Google) - Update README with environment setup instructions - Document configuration priority and startup commands - Whitelist .env.example in .gitignore Co-Authored-By: Claude Opus 4.5 --- .env.example | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ .gitignore | 1 + README.md | 43 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..17135cb6d --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# ============================================================================= +# LLM Provider Configuration +# ============================================================================= +# Copy this file to .env and fill in your values: +# cp .env.example .env +# +# Then load before running: +# source .env && pnpm dev:console +# ============================================================================= + +# Default LLM provider (e.g., openai, anthropic, deepseek, kimi-coding, groq, mistral) +export LLM_PROVIDER= + +# --- OpenAI --- +export OPENAI_API_KEY= +export OPENAI_BASE_URL= +export OPENAI_MODEL= + +# --- Anthropic --- +export ANTHROPIC_API_KEY= +export ANTHROPIC_BASE_URL= +export ANTHROPIC_MODEL= + +# --- DeepSeek --- +export DEEPSEEK_API_KEY= +export DEEPSEEK_BASE_URL= +export DEEPSEEK_MODEL= + +# --- Kimi (Moonshot) --- +export MOONSHOT_API_KEY= +export MOONSHOT_BASE_URL= +export MOONSHOT_MODEL= + +# --- Groq --- +export GROQ_API_KEY= +export GROQ_BASE_URL= +export GROQ_MODEL= + +# --- Mistral --- +export MISTRAL_API_KEY= +export MISTRAL_BASE_URL= +export MISTRAL_MODEL= + +# --- Together --- +export TOGETHER_API_KEY= +export TOGETHER_BASE_URL= +export TOGETHER_MODEL= + +# --- Google --- +export GOOGLE_API_KEY= +export GOOGLE_BASE_URL= +export GOOGLE_MODEL= diff --git a/.gitignore b/.gitignore index 118f0f6f1..60d83b648 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ release # env .env* +!.env.example # platform specific *.dmg diff --git a/README.md b/README.md index e9294e9ff..8cac46f3e 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,50 @@ skills/ # Bundled skills (commit, code-review) ```bash pnpm install -pnpm dev ``` +### Environment Configuration + +The Agent requires LLM provider credentials. Copy the example and fill in your values: + +```bash +cp .env.example .env +# Edit .env with your API keys +``` + +Example `.env` for OpenAI: + +```bash +export LLM_PROVIDER=openai +export OPENAI_API_KEY=sk-xxx +export OPENAI_BASE_URL=https://api.openai.com/v1 +export OPENAI_MODEL=gpt-4o +``` + +Load the environment before starting services that use the Agent: + +```bash +# Hub Console (requires LLM env vars) +source .env && pnpm dev:console + +# Agent CLI +source .env && pnpm agent:cli "hello" + +# Gateway (no LLM env vars needed) +pnpm dev:gateway +``` + +See `.env.example` for all supported providers (OpenAI, Anthropic, DeepSeek, Kimi, Groq, Mistral, etc.). + +### Configuration Priority + +Each setting is resolved in order (first match wins): + +1. **CLI argument** — `--provider`, `--model`, `--api-key`, `--base-url` +2. **Environment variable** — `LLM_PROVIDER`, `OPENAI_MODEL`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, etc. +3. **Session metadata** — restored from previous session +4. **Default** — `kimi-coding` provider with `kimi-k2-thinking` model + ## Agent CLI Use the agent module directly from the CLI for isolated testing. From 98f3f7ed50bb730a5d0f1f8f1425944935227aeb Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 30 Jan 2026 13:49:28 +0800 Subject: [PATCH 5/7] chore(agent): remove --env-file from agent:cli script Co-Authored-By: Claude Opus 4.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 39a420b0c..dd93b1105 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "main": "dist/index.js", "scripts": { "dev": "tsx src/index.ts", - "agent:cli": "tsx --env-file=.env src/agent/cli.ts", + "agent:cli": "tsx src/agent/cli.ts", "agent:interactive": "tsx src/agent/interactive-cli.ts", "agent:profile": "tsx src/agent/profile-cli.ts", "dev:gateway": "tsx --watch src/gateway/main.ts", From 36e9acb35f7caa461371468afcce97c1871e14c5 Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 30 Jan 2026 13:49:35 +0800 Subject: [PATCH 6/7] refactor(shared): extract DATA_DIR constant to shared/paths.ts Replace hardcoded ~/.super-multica paths across agent and hub modules with a single DATA_DIR constant exported from shared. Co-Authored-By: Claude Opus 4.5 --- src/agent/profile-cli.ts | 4 ++-- src/agent/profile/storage.ts | 4 ++-- src/agent/session/storage.ts | 4 ++-- src/agent/skills/loader.ts | 4 ++-- src/hub/device.ts | 7 +++---- src/shared/index.ts | 1 + src/shared/paths.ts | 5 +++++ 7 files changed, 17 insertions(+), 12 deletions(-) create mode 100644 src/shared/paths.ts diff --git a/src/agent/profile-cli.ts b/src/agent/profile-cli.ts index e708e2ce5..d3055734c 100644 --- a/src/agent/profile-cli.ts +++ b/src/agent/profile-cli.ts @@ -10,7 +10,6 @@ */ import { existsSync, readdirSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; import { createAgentProfile, @@ -18,8 +17,9 @@ import { getProfileDir, profileExists, } from "./profile/index.js"; +import { DATA_DIR } from "../shared/index.js"; -const DEFAULT_BASE_DIR = join(homedir(), ".super-multica", "agent-profiles"); +const DEFAULT_BASE_DIR = join(DATA_DIR, "agent-profiles"); type Command = "new" | "list" | "show" | "edit" | "help"; diff --git a/src/agent/profile/storage.ts b/src/agent/profile/storage.ts index fc281bf38..fa8493fe6 100644 --- a/src/agent/profile/storage.ts +++ b/src/agent/profile/storage.ts @@ -3,11 +3,11 @@ */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; import { PROFILE_FILES, type AgentProfile } from "./types.js"; +import { DATA_DIR } from "../../shared/index.js"; -const DEFAULT_BASE_DIR = join(homedir(), ".super-multica", "agent-profiles"); +const DEFAULT_BASE_DIR = join(DATA_DIR, "agent-profiles"); export interface StorageOptions { baseDir?: string | undefined; diff --git a/src/agent/session/storage.ts b/src/agent/session/storage.ts index fbffa76a1..b219fd95e 100644 --- a/src/agent/session/storage.ts +++ b/src/agent/session/storage.ts @@ -1,15 +1,15 @@ -import { homedir } from "os"; import { join } from "path"; import { existsSync, mkdirSync, readFileSync } from "fs"; import { appendFile, writeFile } from "fs/promises"; import type { SessionEntry } from "./types.js"; +import { DATA_DIR } from "../../shared/index.js"; export type SessionStorageOptions = { baseDir?: string | undefined; }; export function resolveBaseDir(options?: SessionStorageOptions) { - return options?.baseDir ?? join(homedir(), ".super-multica", "sessions"); + return options?.baseDir ?? join(DATA_DIR, "sessions"); } export function resolveSessionDir(sessionId: string, options?: SessionStorageOptions) { diff --git a/src/agent/skills/loader.ts b/src/agent/skills/loader.ts index a6f11c660..875ad4f7d 100644 --- a/src/agent/skills/loader.ts +++ b/src/agent/skills/loader.ts @@ -5,17 +5,17 @@ */ import { existsSync, readdirSync, statSync } from "node:fs"; -import { homedir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import type { Skill, SkillSource, SkillManagerOptions } from "./types.js"; import { SKILL_FILE, SKILL_SOURCE_PRECEDENCE } from "./types.js"; import { parseSkillFile } from "./parser.js"; +import { DATA_DIR } from "../../shared/index.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); /** Default profile base directory */ -const DEFAULT_PROFILE_BASE_DIR = join(homedir(), ".super-multica", "agent-profiles"); +const DEFAULT_PROFILE_BASE_DIR = join(DATA_DIR, "agent-profiles"); /** Bundled skills directory (relative to package) */ const BUNDLED_DIR = join(__dirname, "../../../skills"); diff --git a/src/hub/device.ts b/src/hub/device.ts index 1b375b1bb..4e637b625 100644 --- a/src/hub/device.ts +++ b/src/hub/device.ts @@ -1,10 +1,9 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; import { v7 as uuidv7 } from "uuid"; +import { DATA_DIR } from "../shared/index.js"; -const MULTICA_DIR = join(homedir(), ".multica"); -const DEVICE_ID_FILE = join(MULTICA_DIR, "device-id"); +const DEVICE_ID_FILE = join(DATA_DIR, "device-id"); /** * 获取当前设备的 ID。 @@ -16,7 +15,7 @@ export function getDeviceId(): string { return readFileSync(DEVICE_ID_FILE, "utf-8").trim(); } catch { const id = uuidv7(); - mkdirSync(MULTICA_DIR, { recursive: true }); + mkdirSync(DATA_DIR, { recursive: true }); writeFileSync(DEVICE_ID_FILE, id, "utf-8"); return id; } diff --git a/src/shared/index.ts b/src/shared/index.ts index 214db21fb..36af41c8e 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,2 +1,3 @@ export * from "./types.js"; +export * from "./paths.js"; export * from "./gateway-sdk/index.js"; diff --git a/src/shared/paths.ts b/src/shared/paths.ts new file mode 100644 index 000000000..3d2e006b7 --- /dev/null +++ b/src/shared/paths.ts @@ -0,0 +1,5 @@ +import { join } from "node:path"; +import { homedir } from "node:os"; + +/** Root data directory: ~/.super-multica */ +export const DATA_DIR = join(homedir(), ".super-multica"); From c6d04766798104f0e62c5408dced315b5188f74c Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 30 Jan 2026 13:56:19 +0800 Subject: [PATCH 7/7] feat(hub): persist agent list to disk for restart recovery Store agent records in ~/.super-multica/agents/agents.json. Hub restores agents on startup and updates the file on create/delete. Co-Authored-By: Claude Opus 4.5 --- src/hub/agent-store.ts | 47 ++++++++++++++++++++++++++++++++++++++++++ src/hub/hub.ts | 21 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/hub/agent-store.ts diff --git a/src/hub/agent-store.ts b/src/hub/agent-store.ts new file mode 100644 index 000000000..c5aabe198 --- /dev/null +++ b/src/hub/agent-store.ts @@ -0,0 +1,47 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { DATA_DIR } from "../shared/index.js"; + +export interface AgentRecord { + id: string; + createdAt: number; +} + +const AGENTS_DIR = join(DATA_DIR, "agents"); +const AGENTS_FILE = join(AGENTS_DIR, "agents.json"); + +function ensureDir(): void { + if (!existsSync(AGENTS_DIR)) { + mkdirSync(AGENTS_DIR, { recursive: true }); + } +} + +export function loadAgentRecords(): AgentRecord[] { + if (!existsSync(AGENTS_FILE)) return []; + try { + const content = readFileSync(AGENTS_FILE, "utf-8"); + return JSON.parse(content) as AgentRecord[]; + } catch { + return []; + } +} + +export function saveAgentRecords(records: AgentRecord[]): void { + ensureDir(); + writeFileSync(AGENTS_FILE, JSON.stringify(records, null, 2), "utf-8"); +} + +export function addAgentRecord(record: AgentRecord): void { + const records = loadAgentRecords(); + if (records.some((r) => r.id === record.id)) return; + records.push(record); + saveAgentRecords(records); +} + +export function removeAgentRecord(id: string): void { + const records = loadAgentRecords(); + const filtered = records.filter((r) => r.id !== id); + if (filtered.length !== records.length) { + saveAgentRecords(filtered); + } +} diff --git a/src/hub/hub.ts b/src/hub/hub.ts index 271955837..77607d0d5 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -3,6 +3,7 @@ import type { ConnectionState } from "../shared/gateway-sdk/types.js"; import { AsyncAgent } from "../agent/async-agent.js"; import { getDeviceId } from "./device.js"; import { GatewayClient } from "../shared/gateway-sdk/client.js"; +import { loadAgentRecords, addAgentRecord, removeAgentRecord } from "./agent-store.js"; export class Hub { private readonly agents = new Map(); @@ -23,6 +24,18 @@ export class Hub { this.deviceId = getDeviceId(); this.client = this.createClient(this.url); this.client.connect(); + this.restoreAgents(); + } + + /** Restore agents from persistent storage */ + private restoreAgents(): void { + const records = loadAgentRecords(); + for (const record of records) { + this.createAgent(record.id, { persist: false }); + } + if (records.length > 0) { + console.log(`[Hub] Restored ${records.length} agent(s)`); + } } private createClient(url: string): GatewayClient { @@ -82,7 +95,7 @@ export class Hub { } /** Create new Agent, or rebuild with existing ID */ - createAgent(id?: string): AsyncAgent { + createAgent(id?: string, options?: { persist?: boolean }): AsyncAgent { if (id) { const existing = this.agents.get(id); if (existing && !existing.closed) { @@ -93,6 +106,11 @@ export class Hub { const agent = new AsyncAgent({ sessionId: id }); this.agents.set(agent.sessionId, agent); + // Persist to agent store (skip during restore to avoid duplicates) + if (options?.persist !== false) { + addAgentRecord({ id: agent.sessionId, createdAt: Date.now() }); + } + // Internally consume messages produced by agent void this.consumeAgent(agent); @@ -130,6 +148,7 @@ export class Hub { agent.close(); this.agents.delete(id); this.agentSenders.delete(id); + removeAgentRecord(id); return true; }