@raingor/pi-web-switch 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -550,7 +550,7 @@ function decodeProjectName(dirName: string): { projectPath: string; projectName:
550
550
  }
551
551
  // Use the last 1-2 path segments as the project name
552
552
  const segments = displayName.split("/").filter(Boolean);
553
- const projectName = segments.length > 0 ? segments[segments.length - 1] : dirName;
553
+ const projectName = segments.length > 0 ? (segments[segments.length - 1] ?? dirName) : dirName;
554
554
  return { projectPath: decoded, projectName };
555
555
  }
556
556
 
@@ -588,7 +588,7 @@ export function listSessions(): ProjectGroup[] {
588
588
 
589
589
  group.totalSessions = group.sessions.length;
590
590
  if (group.sessions.length > 0) {
591
- group.lastActive = group.sessions[0].timestamp; // already sorted newest-first
591
+ group.lastActive = group.sessions[0]?.timestamp ?? ""; // already sorted newest-first
592
592
  }
593
593
  }
594
594
 
@@ -933,9 +933,78 @@ function isNewerVersion(installed: string, latest: string): boolean {
933
933
  return false;
934
934
  }
935
935
 
936
+ // ─── Outbound fetch with local proxy support ───────────
937
+ // Node's fetch ignores the OS proxy, so requests to some provider hosts fail
938
+ // on machines that rely on a local proxy (e.g. Clash). Detect a proxy from
939
+ // env vars or macOS system settings and route through it via undici's
940
+ // ProxyAgent; fall back to a direct request when no proxy or the proxy fails.
941
+
942
+ let undiciPromise: Promise<typeof import("undici") | null> | null = null;
943
+ function getUndici(): Promise<typeof import("undici") | null> {
944
+ // Dynamic import: require() of bare packages breaks inside Vite's bundled
945
+ // config (esbuild rewrites it to a throwing __require shim in ESM output).
946
+ if (!undiciPromise) {
947
+ undiciPromise = import("undici").catch(() => null);
948
+ }
949
+ return undiciPromise;
950
+ }
951
+
952
+ let proxyCache: { url: string | null; at: number } | null = null;
953
+
954
+ function detectProxyUrl(): string | null {
955
+ if (proxyCache && Date.now() - proxyCache.at < 60000) return proxyCache.url;
956
+ let found: string | null =
957
+ process.env.https_proxy ||
958
+ process.env.HTTPS_PROXY ||
959
+ process.env.http_proxy ||
960
+ process.env.HTTP_PROXY ||
961
+ null;
962
+ if (found && !found.startsWith("http")) found = null; // ProxyAgent needs an HTTP proxy
963
+ if (!found && process.platform === "darwin") {
964
+ try {
965
+ const out = spawnSync("scutil", ["--proxy"], { encoding: "utf8", timeout: 3000 }).stdout || "";
966
+ const get = (k: string) => out.match(new RegExp(`${k} : (\\S+)`))?.[1];
967
+ if (get("HTTPSEnable") === "1" && get("HTTPSProxy")) {
968
+ found = `http://${get("HTTPSProxy")}:${get("HTTPSPort") ?? "80"}`;
969
+ } else if (get("HTTPEnable") === "1" && get("HTTPProxy")) {
970
+ found = `http://${get("HTTPProxy")}:${get("HTTPPort") ?? "80"}`;
971
+ }
972
+ } catch {
973
+ /* scutil unavailable — ignore */
974
+ }
975
+ }
976
+ proxyCache = { url: found, at: Date.now() };
977
+ return found;
978
+ }
979
+
980
+ const proxyAgents = new Map<string, import("undici").ProxyAgent>();
981
+
982
+ async function fetchExternal(
983
+ url: string | URL,
984
+ init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal }
985
+ ): Promise<Response> {
986
+ const target = url instanceof URL ? url.toString() : url;
987
+ const proxy = detectProxyUrl();
988
+ const undici = proxy ? await getUndici() : null;
989
+ if (proxy && undici) {
990
+ try {
991
+ let agent = proxyAgents.get(proxy);
992
+ if (!agent) {
993
+ agent = new undici.ProxyAgent(proxy);
994
+ proxyAgents.set(proxy, agent);
995
+ }
996
+ // Node's built-in fetch rejects a foreign dispatcher — use undici's fetch
997
+ return (await undici.fetch(target, { ...init, dispatcher: agent })) as unknown as Response;
998
+ } catch {
999
+ /* proxy failed — fall through to a direct request */
1000
+ }
1001
+ }
1002
+ return await fetch(target, init);
1003
+ }
1004
+
936
1005
  async function fetchLatestVersion(pkgName: string): Promise<string | null> {
937
1006
  try {
938
- const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkgName)}/latest`, {
1007
+ const res = await fetchExternal(`https://registry.npmjs.org/${encodeURIComponent(pkgName)}/latest`, {
939
1008
  signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
940
1009
  headers: { accept: "application/json" },
941
1010
  });
@@ -1062,7 +1131,7 @@ export async function testProviderConnection(
1062
1131
 
1063
1132
  const started = Date.now();
1064
1133
  try {
1065
- const res = await fetch(url, {
1134
+ const res = await fetchExternal(url, {
1066
1135
  headers,
1067
1136
  signal: AbortSignal.timeout(10000),
1068
1137
  });
@@ -1075,3 +1144,680 @@ export async function testProviderConnection(
1075
1144
  return { success: false, latencyMs, message: msg };
1076
1145
  }
1077
1146
  }
1147
+
1148
+
1149
+ /**
1150
+ * Fetch the model list from a provider's endpoint server-side.
1151
+ * Supports multiple source types:
1152
+ * - OpenAI-compatible /models (default)
1153
+ * - OpenRouter /models (returns pricing, modality, context_length, top_provider.max_completion_tokens)
1154
+ * - Ollama /api/tags (returns model names + capabilities via /api/show)
1155
+ *
1156
+ * Returns full model metadata so the frontend can prefill the add-model form:
1157
+ * { id, name, contextWindow, maxTokens, reasoning, vision, cost }
1158
+ *
1159
+ * Reasoning / vision / contextWindow are also heuristically inferred from
1160
+ * the model id when the endpoint doesn't report them.
1161
+ */
1162
+ export interface FetchedModel {
1163
+ id: string;
1164
+ name?: string;
1165
+ contextWindow?: number;
1166
+ maxTokens?: number;
1167
+ reasoning?: boolean;
1168
+ vision?: boolean;
1169
+ audio?: boolean;
1170
+ cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
1171
+ source: string; // "openai" | "openrouter" | "ollama" | "heuristic"
1172
+ }
1173
+
1174
+ // Parse a numeric value, handling K/M suffixes (e.g. "128k" → 128000)
1175
+ function toNum(v: any): number | undefined {
1176
+ if (typeof v === "number") return v;
1177
+ if (typeof v !== "string") return undefined;
1178
+ const m = v.match(/^([0-9]+)([KkMm]?)$/);
1179
+ if (!m) return undefined;
1180
+ const n = parseInt(m[1] ?? "0", 10);
1181
+ const u = (m[2] ?? "").toUpperCase();
1182
+ return u === "K" ? n * 1000 : u === "M" ? n * 1_000_000 : n;
1183
+ }
1184
+
1185
+ // Heuristic reasoning detection (server-side; mirrors client guessModelMeta)
1186
+ const REASONING_RE = /(^|[/_\-])(r1|o1|o3|o4|z1|reasoner|reasoning|qwq|deepseek-r|think)([/_\-:]|$)/i;
1187
+ const VISION_RE = /(vision|[-_]vl\b|multimodal|gpt-4o|gpt-5|claude-(sonnet|opus)|gemini|llama-.*vision|qwen.*vl|glm-.*v\b)/i;
1188
+ const AUDIO_RE = /(audio|whisper|tts|speech)/i;
1189
+ function heuristicFlags(id: string): { reasoning?: boolean; vision?: boolean; audio?: boolean; contextWindow?: number } {
1190
+ const k = id.toLowerCase();
1191
+ const reasoning = REASONING_RE.test(k);
1192
+ const vision = VISION_RE.test(k);
1193
+ const audio = AUDIO_RE.test(k);
1194
+ let contextWindow: number | undefined;
1195
+ if (/[-_](1m|1024k|1048576)\b/i.test(k)) contextWindow = 1_048_576;
1196
+ else if (/[-_](256k)\b/i.test(k)) contextWindow = 262_144;
1197
+ else if (/[-_](128k)\b/i.test(k)) contextWindow = 131_072;
1198
+ else if (/[-_](64k)\b/i.test(k)) contextWindow = 65_536;
1199
+ else if (/[-_](32k)\b/i.test(k)) contextWindow = 32_768;
1200
+ else if (/[-_](16k)\b/i.test(k)) contextWindow = 16_384;
1201
+ else if (/[-_](8k)\b/i.test(k)) contextWindow = 8192;
1202
+ return { reasoning, vision, audio, contextWindow };
1203
+ }
1204
+
1205
+ function isOpenRouter(baseUrl: string, host: string): boolean {
1206
+ return host === "openrouter.ai" || host.endsWith(".openrouter.ai") ||
1207
+ baseUrl.includes("openrouter.ai");
1208
+ }
1209
+
1210
+ async function fetchJson(url: URL, headers: Record<string, string>, timeoutMs = 15000) {
1211
+ const res = await fetchExternal(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
1212
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1213
+ const text = await res.text();
1214
+ const trimmed = text.trimStart();
1215
+ const ctype = res.headers.get("content-type") ?? "";
1216
+ if (trimmed.startsWith("<") || ctype.includes("text/html")) {
1217
+ // The endpoint returned an HTML page (often a site root / 404 served as 200)
1218
+ // instead of JSON — almost always a wrong base URL (missing /v1 prefix, etc.).
1219
+ throw new Error(`endpoint returned HTML, not JSON (check base URL): ${url.toString()}`);
1220
+ }
1221
+ try {
1222
+ return JSON.parse(text);
1223
+ } catch {
1224
+ throw new Error(`invalid JSON from ${url.toString()}`);
1225
+ }
1226
+ }
1227
+
1228
+ function makeHeaders(key: string, providerId?: string, host?: string): Record<string, string> {
1229
+ const headers: Record<string, string> = {};
1230
+ if (!key) return headers;
1231
+ if (providerId === "anthropic" || (host && host.endsWith("api.anthropic.com"))) {
1232
+ headers["x-api-key"] = key;
1233
+ headers["anthropic-version"] = "2023-06-01";
1234
+ } else if (providerId === "google" || (host && host.endsWith("generativelanguage.googleapis.com"))) {
1235
+ // Google uses query param; caller handles it
1236
+ } else {
1237
+ headers["Authorization"] = `Bearer ${key}`;
1238
+ }
1239
+ return headers;
1240
+ }
1241
+
1242
+ export async function fetchProviderModels(
1243
+ baseUrl: string,
1244
+ apiKey?: string,
1245
+ providerId?: string
1246
+ ): Promise<{ models: FetchedModel[]; error?: string }> {
1247
+ let key = apiKey ?? "";
1248
+ if (key.startsWith("$")) key = process.env[key.slice(1)] ?? "";
1249
+
1250
+ let base: URL;
1251
+ try {
1252
+ base = new URL(baseUrl.replace(/\/+$/, ""));
1253
+ } catch {
1254
+ return { models: [], error: "invalid URL" };
1255
+ }
1256
+ if (base.protocol !== "http:" && base.protocol !== "https:") {
1257
+ return { models: [], error: "invalid URL" };
1258
+ }
1259
+ const host = base.hostname;
1260
+
1261
+ try {
1262
+ // ── Ollama: /api/tags (no /models) ──────────────────
1263
+ // Heuristic: local port 11434 or hostname "localhost" + path includes ollama
1264
+ const isOllama = host === "localhost" && base.port === "11434";
1265
+ if (isOllama) {
1266
+ const tagsUrl = new URL("/api/tags", base);
1267
+ const data = await fetchJson(tagsUrl, {});
1268
+ const models: FetchedModel[] = [];
1269
+ const models_ = data?.models ?? [];
1270
+ for (const m of models_) {
1271
+ const id = typeof m === "string" ? m : m.name ?? m.model ?? "";
1272
+ if (!id) continue;
1273
+ const flags = heuristicFlags(id);
1274
+ // Ollama details: try /api/show for richer info (best-effort, ignore errors)
1275
+ let cw: number | undefined = flags.contextWindow;
1276
+ let mt: number | undefined;
1277
+ try {
1278
+ const showUrl = new URL("/api/show", base);
1279
+ const show = await fetch(showUrl.toString(), {
1280
+ method: "POST",
1281
+ headers: { "Content-Type": "application/json" },
1282
+ body: JSON.stringify({ name: id }),
1283
+ signal: AbortSignal.timeout(5000),
1284
+ }).then((r) => r.json());
1285
+ cw = toNum(show?.model_info?.[`${show?.modelfile?.split("\n").find((l: string) => l.startsWith("FROM")) ?? ""}`]) ?? cw;
1286
+ if (show?.context_length) cw = toNum(show.context_length) ?? cw;
1287
+ } catch { /* ignore */ }
1288
+ models.push({
1289
+ id,
1290
+ contextWindow: cw,
1291
+ maxTokens: mt,
1292
+ reasoning: flags.reasoning,
1293
+ vision: flags.vision,
1294
+ audio: flags.audio,
1295
+ source: "ollama",
1296
+ });
1297
+ }
1298
+ return { models };
1299
+ }
1300
+
1301
+ // ── OpenRouter /models returns rich metadata ────────
1302
+ // Append to the full base path (preserve prefixes like /v1 or /v1beta).
1303
+ // Using new URL("/models", base) would resolve against the origin and drop the prefix.
1304
+ const modelsUrl = new URL(base.toString().replace(/\/+$/, "") + "/models");
1305
+ if (providerId === "google" || host.endsWith("generativelanguage.googleapis.com")) {
1306
+ if (key) modelsUrl.searchParams.set("key", key);
1307
+ }
1308
+ const headers = makeHeaders(key, providerId, host);
1309
+ const data = await fetchJson(modelsUrl, headers, isOpenRouter(baseUrl, host) ? 20000 : 15000);
1310
+
1311
+ const seen = new Set<string>();
1312
+ const models: FetchedModel[] = [];
1313
+ const pushModel = (m: FetchedModel) => {
1314
+ const v = (m.id ?? "").trim();
1315
+ if (!v || seen.has(v)) return;
1316
+ seen.add(v);
1317
+ // Apply heuristic defaults for fields the endpoint didn't provide
1318
+ const flags = heuristicFlags(v);
1319
+ m.reasoning = m.reasoning ?? flags.reasoning;
1320
+ m.vision = m.vision ?? flags.vision;
1321
+ m.audio = m.audio ?? flags.audio;
1322
+ m.contextWindow = m.contextWindow ?? flags.contextWindow;
1323
+ models.push(m);
1324
+ };
1325
+
1326
+ // Vision / modality detectors (vendor-specific shapes)
1327
+ const visionOf = (item: any): boolean | undefined => {
1328
+ if (!item || typeof item !== "object") return undefined;
1329
+ if (typeof item.capabilities?.vision === "boolean") return item.capabilities.vision;
1330
+ if (item.supports_vision === true || item.vision === true) return true;
1331
+ const mods = item.architecture?.input_modalities ?? item.input_modalities ?? item.modalities;
1332
+ if (Array.isArray(mods)) return mods.includes("image");
1333
+ const modality = item.architecture?.modality;
1334
+ if (typeof modality === "string") {
1335
+ return (modality.split("->")[0] ?? "").includes("image");
1336
+ }
1337
+ return undefined;
1338
+ };
1339
+ const audioOf = (item: any): boolean | undefined => {
1340
+ const mods = item.architecture?.input_modalities ?? item.input_modalities ?? item.modalities;
1341
+ if (Array.isArray(mods)) return mods.includes("audio");
1342
+ return undefined;
1343
+ };
1344
+ const reasoningOf = (item: any): boolean | undefined => {
1345
+ // OpenRouter doesn't directly expose a reasoning flag, but some providers
1346
+ // indicate it via architecture or the id contains reasoner/r1/o1/o3.
1347
+ if (item?.reasoning === true || item?.supports_reasoning === true) return true;
1348
+ return undefined;
1349
+ };
1350
+ const parseCost = (pricing: any): FetchedModel["cost"] | undefined => {
1351
+ if (!pricing) return undefined;
1352
+ // OpenRouter: pricing.prompt, pricing.completion, pricing.cache_read, pricing.cache_write
1353
+ // Values are per-token; multiply by 1e6 for $/M.
1354
+ const toDollar = (v: any) => typeof v === "string" ? parseFloat(v) * 1_000_000 : (typeof v === "number" ? v * 1_000_000 : undefined);
1355
+ const input = toDollar(pricing.prompt ?? pricing.input);
1356
+ const output = toDollar(pricing.completion ?? pricing.output);
1357
+ const cacheRead = toDollar(pricing.cache_read ?? pricing.cacheRead);
1358
+ const cacheWrite = toDollar(pricing.cache_write ?? pricing.cacheWrite);
1359
+ if (input === undefined && output === undefined) return undefined;
1360
+ return { input: input ?? 0, output: output ?? 0, cacheRead, cacheWrite };
1361
+ };
1362
+
1363
+ const parseItem = (item: any) => {
1364
+ const rawId = typeof item === "string" ? item : item?.id ?? item?.model ?? item?.name ?? "";
1365
+ const id = typeof rawId === "string" ? rawId.replace(/^models\//, "") : "";
1366
+ if (!id) return;
1367
+ const name = typeof item?.name === "string" ? item.name : undefined;
1368
+ const cw =
1369
+ toNum(item?.context_length) ??
1370
+ toNum(item?.max_context) ??
1371
+ toNum(item?.context_window) ??
1372
+ toNum(item?.inputTokenLimit) ??
1373
+ toNum(item?.max_tokens) ??
1374
+ undefined;
1375
+ const mt =
1376
+ toNum(item?.max_output_tokens) ??
1377
+ toNum(item?.top_provider?.max_completion_tokens) ??
1378
+ toNum(item?.max_completion_tokens) ??
1379
+ toNum(item?.outputTokenLimit) ??
1380
+ toNum(item?.max_tokens) ??
1381
+ undefined;
1382
+ const isOR = isOpenRouter(baseUrl, host);
1383
+ pushModel({
1384
+ id,
1385
+ name: name !== id ? name : undefined,
1386
+ contextWindow: cw,
1387
+ maxTokens: mt,
1388
+ reasoning: reasoningOf(item),
1389
+ vision: visionOf(item),
1390
+ audio: audioOf(item),
1391
+ cost: isOR ? parseCost(item.pricing) : undefined,
1392
+ source: isOR ? "openrouter" : "openai",
1393
+ });
1394
+ };
1395
+
1396
+ if (Array.isArray(data)) {
1397
+ data.forEach(parseItem);
1398
+ } else if (data && typeof data === "object") {
1399
+ const dataArr = data.data ?? data.models ?? data.models_list ?? null;
1400
+ if (Array.isArray(dataArr)) dataArr.forEach(parseItem);
1401
+ }
1402
+
1403
+ return { models };
1404
+ } catch (e: any) {
1405
+ const msg = e?.name === "TimeoutError" ? "timeout" : e?.cause?.code || e?.message || String(e);
1406
+ return { models: [], error: msg };
1407
+ }
1408
+ }
1409
+
1410
+
1411
+ /**
1412
+ * Send a minimal /chat/completions request with a specific model ID to verify
1413
+ * the model is usable. Returns { success, latencyMs, message }.
1414
+ */
1415
+ export async function testModel(
1416
+ baseUrl: string,
1417
+ modelId: string,
1418
+ apiKey?: string,
1419
+ apiType: string = "openai-completions"
1420
+ ): Promise<ProviderTestResult> {
1421
+ let url: URL;
1422
+ try {
1423
+ url = new URL(baseUrl.replace(/\/+$/, "") + "/chat/completions");
1424
+ } catch {
1425
+ return { success: false, message: "invalid URL" };
1426
+ }
1427
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
1428
+ return { success: false, message: "invalid URL" };
1429
+ }
1430
+
1431
+ let key = apiKey ?? "";
1432
+ if (key.startsWith("$")) key = process.env[key.slice(1)] ?? "";
1433
+
1434
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
1435
+ if (key) headers["Authorization"] = `Bearer ${key}`;
1436
+
1437
+ // Build a minimal, lightweight completion payload
1438
+ const body: Record<string, any> = {
1439
+ model: modelId,
1440
+ messages: [{ role: "user", content: "Reply with a single word: ok" }],
1441
+ max_tokens: 4,
1442
+ temperature: 0,
1443
+ };
1444
+
1445
+ const started = Date.now();
1446
+ try {
1447
+ const res = await fetchExternal(url, {
1448
+ method: "POST",
1449
+ headers,
1450
+ body: JSON.stringify(body),
1451
+ signal: AbortSignal.timeout(15000),
1452
+ });
1453
+ const latencyMs = Date.now() - started;
1454
+ if (res.ok) {
1455
+ // Validate response — accept if we got valid JSON back
1456
+ const data = await res.json();
1457
+ const choice = data?.choices?.[0];
1458
+ const hasContent = choice && (choice.message?.content || choice.delta?.content);
1459
+ if (hasContent) {
1460
+ return { success: true, latencyMs };
1461
+ }
1462
+ // Got valid JSON but no choice content — check for alternative response formats
1463
+ const hasUsage = !!data?.usage;
1464
+ if (hasUsage) {
1465
+ return { success: true, latencyMs, message: "response received (no content)" };
1466
+ }
1467
+ return { success: false, latencyMs, message: "invalid response: " + JSON.stringify(data).slice(0, 150) };
1468
+ }
1469
+ // Capture the status line from the body if possible
1470
+ try {
1471
+ const d = await res.json();
1472
+ return { success: false, status: res.status, latencyMs, message: d?.error?.message || `HTTP ${res.status}` };
1473
+ } catch {
1474
+ return { success: false, status: res.status, latencyMs, message: `HTTP ${res.status}` };
1475
+ }
1476
+ } catch (e: any) {
1477
+ const latencyMs = Date.now() - started;
1478
+ const msg = e?.name === "TimeoutError" ? "timeout" : e?.message || String(e);
1479
+ return { success: false, latencyMs, message: msg };
1480
+ }
1481
+ }
1482
+
1483
+ // ─── Subagents ────────────────────────────────────────────
1484
+
1485
+ const AGENTS_DIR = join(PI_DIR, "agents");
1486
+ const CHAINS_DIR = join(PI_DIR, "chains");
1487
+ const RUN_HISTORY_PATH = join(PI_DIR, "run-history.jsonl");
1488
+
1489
+ /** Parse YAML frontmatter from an agent/chain .md file. */
1490
+ function parseFrontmatter(raw: string): { frontmatter: Record<string, any>; body: string } {
1491
+ const frontmatter: Record<string, any> = {};
1492
+ const first = raw.indexOf("---");
1493
+ if (first !== 0) return { frontmatter, body: raw };
1494
+ const second = raw.indexOf("---", 3);
1495
+ if (second === -1) return { frontmatter, body: raw };
1496
+ const yamlLines = raw.slice(3, second).trim().split("\n");
1497
+ const body = raw.slice(second + 3).trim();
1498
+
1499
+ for (const line of yamlLines) {
1500
+ const colonIdx = line.indexOf(":");
1501
+ if (colonIdx === -1) continue;
1502
+ const key = line.slice(0, colonIdx).trim();
1503
+ let value: any = line.slice(colonIdx + 1).trim();
1504
+
1505
+ // Array value: "[item1, item2]" or multiline "key:\n - item"
1506
+ if (value.startsWith("[") && value.endsWith("]")) {
1507
+ value = value.slice(1, -1).split(",").map((s: string) => s.trim().replace(/^["']|["']$/g, ""));
1508
+ } else if (value === "true" || value === "false") {
1509
+ value = value === "true";
1510
+ } else if (/^\d+$/.test(value)) {
1511
+ value = parseInt(value, 10);
1512
+ } else if (/^\d+\.\d+$/.test(value)) {
1513
+ value = parseFloat(value);
1514
+ } else {
1515
+ value = value.replace(/^["']|["']$/g, "");
1516
+ }
1517
+
1518
+ frontmatter[key] = value;
1519
+ }
1520
+
1521
+ return { frontmatter, body };
1522
+ }
1523
+
1524
+ export interface AgentDef {
1525
+ name: string;
1526
+ fileName: string;
1527
+ filePath: string;
1528
+ package: string;
1529
+ description: string;
1530
+ model?: string;
1531
+ tools?: string[];
1532
+ thinking?: string;
1533
+ systemPromptMode?: string;
1534
+ inheritProjectContext?: boolean;
1535
+ inheritSkills?: boolean;
1536
+ input?: string[];
1537
+ body: string;
1538
+ }
1539
+
1540
+ export function listAgents(): AgentDef[] {
1541
+ try {
1542
+ if (!existsSync(AGENTS_DIR)) return [];
1543
+ const files = readdirSync(AGENTS_DIR).filter((f) => f.endsWith(".md"));
1544
+ return files.map((fileName) => {
1545
+ const filePath = join(AGENTS_DIR, fileName);
1546
+ try {
1547
+ const raw = readFileSync(filePath, "utf-8");
1548
+ const { frontmatter, body } = parseFrontmatter(raw);
1549
+ function splitMaybe(val: unknown): string[] | undefined {
1550
+ if (Array.isArray(val)) return val.map(String);
1551
+ if (typeof val === "string" && val.trim()) return val.split(/\s*,\s*/).filter(Boolean);
1552
+ return undefined;
1553
+ }
1554
+ return {
1555
+ name: frontmatter.name || fileName.replace(/\.md$/, ""),
1556
+ fileName,
1557
+ filePath,
1558
+ package: frontmatter.package || "custom",
1559
+ description: frontmatter.description || "",
1560
+ model: frontmatter.model,
1561
+ tools: splitMaybe(frontmatter.tools),
1562
+ thinking: frontmatter.thinking,
1563
+ systemPromptMode: frontmatter.systemPromptMode,
1564
+ inheritProjectContext: frontmatter.inheritProjectContext,
1565
+ inheritSkills: frontmatter.inheritSkills,
1566
+ input: splitMaybe(frontmatter.input),
1567
+ body: body.slice(0, 500),
1568
+ };
1569
+ } catch {
1570
+ return null;
1571
+ }
1572
+ }).filter(Boolean) as AgentDef[];
1573
+ } catch {
1574
+ return [];
1575
+ }
1576
+ }
1577
+
1578
+ export interface ChainStep {
1579
+ agent: string;
1580
+ phase?: string;
1581
+ label?: string;
1582
+ output?: string;
1583
+ as?: string;
1584
+ task?: string;
1585
+ }
1586
+
1587
+ export interface ChainDef {
1588
+ name: string;
1589
+ fileName: string;
1590
+ filePath: string;
1591
+ description: string;
1592
+ steps: ChainStep[];
1593
+ body: string;
1594
+ }
1595
+
1596
+ export function listChains(): ChainDef[] {
1597
+ try {
1598
+ if (!existsSync(CHAINS_DIR)) return [];
1599
+ const files = readdirSync(CHAINS_DIR).filter((f) => f.endsWith(".chain.md"));
1600
+ return files.map((fileName) => {
1601
+ const filePath = join(CHAINS_DIR, fileName);
1602
+ try {
1603
+ const raw = readFileSync(filePath, "utf-8");
1604
+ const { frontmatter, body } = parseFrontmatter(raw);
1605
+ const steps: ChainStep[] = [];
1606
+
1607
+ // Parse chain steps: "## agent-name" blocks
1608
+ const stepRegex = /##\s+(\([^)]+\)\s*\|[^\n]+|[^\n]+)/g;
1609
+ let match;
1610
+ while ((match = stepRegex.exec(body)) !== null) {
1611
+ const header = match[1]!.trim();
1612
+ // "## (web-agents.前端 | web-agents.后端)" parallel steps
1613
+ // "## web-agents.需求" single step
1614
+ // "## web-agents.测试" single step
1615
+ // Extract agent name(s) from header
1616
+ const parallelMatch = header.match(/^\(([^)]+)\)/);
1617
+ if (parallelMatch) {
1618
+ const agents = parallelMatch[1]!.split("|").map((s) => s.trim());
1619
+ agents.forEach((agent) => steps.push({ agent }));
1620
+ } else {
1621
+ steps.push({ agent: header });
1622
+ }
1623
+ }
1624
+
1625
+ return {
1626
+ name: frontmatter.name || fileName.replace(/\.chain\.md$/, ""),
1627
+ fileName,
1628
+ filePath,
1629
+ description: frontmatter.description || "",
1630
+ steps,
1631
+ body: raw.slice(0, 300),
1632
+ };
1633
+ } catch {
1634
+ return null;
1635
+ }
1636
+ }).filter(Boolean) as ChainDef[];
1637
+ } catch {
1638
+ return [];
1639
+ }
1640
+ }
1641
+
1642
+ export interface RunRecord {
1643
+ agent: string;
1644
+ ts: number;
1645
+ status: string;
1646
+ duration?: number;
1647
+ exit?: number;
1648
+ taskHash?: string;
1649
+ }
1650
+
1651
+ export function readRunHistory(limit = 100): RunRecord[] {
1652
+ try {
1653
+ if (!existsSync(RUN_HISTORY_PATH)) return [];
1654
+ const raw = readFileSync(RUN_HISTORY_PATH, "utf-8");
1655
+ const lines = raw.split("\n").filter(Boolean);
1656
+ return lines
1657
+ .slice(-limit)
1658
+ .map((line) => {
1659
+ try {
1660
+ return JSON.parse(line) as RunRecord;
1661
+ } catch {
1662
+ return null;
1663
+ }
1664
+ })
1665
+ .filter((r): r is RunRecord => r !== null)
1666
+ .reverse();
1667
+ } catch {
1668
+ return [];
1669
+ }
1670
+ }
1671
+
1672
+ export interface SubagentsData {
1673
+ agents: AgentDef[];
1674
+ chains: ChainDef[];
1675
+ runHistory: RunRecord[];
1676
+ }
1677
+
1678
+ export function readSubagents(): SubagentsData {
1679
+ return {
1680
+ agents: listAgents(),
1681
+ chains: listChains(),
1682
+ runHistory: readRunHistory(),
1683
+ };
1684
+ }
1685
+
1686
+ // ─── Built-in Provider Catalog (from the local pi install) ───
1687
+ // pi ships its full model catalog (same source as pi.dev/models) inside
1688
+ // @earendil-works/pi-ai as dist/providers/data/*.json. Reading it locally
1689
+ // keeps the builtin provider list in sync with the installed pi version
1690
+ // instead of maintaining a hand-written copy.
1691
+
1692
+ interface CatalogModel {
1693
+ id: string;
1694
+ name?: string;
1695
+ reasoning?: boolean;
1696
+ input?: string[];
1697
+ contextWindow?: number;
1698
+ maxTokens?: number;
1699
+ cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
1700
+ }
1701
+
1702
+ interface CatalogProvider {
1703
+ id: string;
1704
+ name: string;
1705
+ type: "builtin";
1706
+ api?: string;
1707
+ baseUrl?: string;
1708
+ hasAuth: boolean;
1709
+ authMethod: "env";
1710
+ models: CatalogModel[];
1711
+ }
1712
+
1713
+ /** Locate @earendil-works/pi-ai's dist/providers directory of the active pi install. */
1714
+ function findPiAiProvidersDir(): string | null {
1715
+ const home = homedir();
1716
+ const roots: string[] = [];
1717
+
1718
+ // Resolve the pi binary symlink → .../pi-coding-agent/dist/cli.js
1719
+ const which = spawnSync("which", ["pi"], { encoding: "utf8", timeout: 5000 });
1720
+ const bin = which.status === 0 ? which.stdout.trim() : "";
1721
+ if (bin) {
1722
+ const real = spawnSync("readlink", ["-f", bin], { encoding: "utf8", timeout: 5000 });
1723
+ const cli = real.status === 0 ? real.stdout.trim() : "";
1724
+ if (cli) roots.push(resolve(dirname(cli), "..")); // package root
1725
+ }
1726
+
1727
+ // Known install locations as fallback
1728
+ const piNode = join(home, ".local", "share", "pi-node");
1729
+ try {
1730
+ for (const v of readdirSync(piNode)) {
1731
+ roots.push(join(piNode, v, "lib", "node_modules", PI_CORE_PACKAGE));
1732
+ }
1733
+ } catch {
1734
+ // pi-node dir absent
1735
+ }
1736
+
1737
+ for (const root of roots) {
1738
+ const dir = join(root, "node_modules", "@earendil-works", "pi-ai", "dist", "providers");
1739
+ if (existsSync(join(dir, "data"))) return dir;
1740
+ }
1741
+ return null;
1742
+ }
1743
+
1744
+ let catalogCache: { providers: CatalogProvider[]; at: number } | null = null;
1745
+
1746
+ export function readBuiltinCatalog(): CatalogProvider[] | null {
1747
+ if (catalogCache && Date.now() - catalogCache.at < 300000) return catalogCache.providers;
1748
+ const dir = findPiAiProvidersDir();
1749
+ if (!dir) return null;
1750
+
1751
+ const providers: CatalogProvider[] = [];
1752
+ let files: string[];
1753
+ try {
1754
+ files = readdirSync(join(dir, "data")).filter(
1755
+ (f) => f.endsWith(".json") && !f.startsWith(".")
1756
+ );
1757
+ } catch {
1758
+ return null;
1759
+ }
1760
+
1761
+ for (const file of files) {
1762
+ const id = file.replace(/\.json$/, "");
1763
+ const data = readJsonFile<Record<string, Record<string, any>>>(join(dir, "data", file));
1764
+ if (!data) continue;
1765
+
1766
+ const models: CatalogModel[] = [];
1767
+ let baseUrl: string | undefined;
1768
+ let api: string | undefined;
1769
+ for (const apiKey of Object.keys(data)) {
1770
+ for (const m of Object.values(data[apiKey] ?? {})) {
1771
+ if (!m?.id) continue;
1772
+ baseUrl = baseUrl ?? m.baseUrl;
1773
+ api = api ?? m.api;
1774
+ models.push({
1775
+ id: m.id,
1776
+ name: m.name,
1777
+ reasoning: !!m.reasoning,
1778
+ input: Array.isArray(m.input) ? m.input : ["text"],
1779
+ contextWindow: m.contextWindow,
1780
+ maxTokens: m.maxTokens,
1781
+ cost: m.cost,
1782
+ });
1783
+ }
1784
+ }
1785
+ if (models.length === 0) continue;
1786
+
1787
+ // Display name lives in dist/providers/<id>.js: createProvider({ id: "…", name: "…" }).
1788
+ // Anchor on the id to avoid matching auth-method names like "Anthropic API key".
1789
+ let name = "";
1790
+ try {
1791
+ const src = readFileSync(join(dir, `${id}.js`), "utf-8");
1792
+ const esc = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1793
+ name =
1794
+ src.match(new RegExp(`id:\\s*"${esc}",\\s*name:\\s*"([^"]+)"`))?.[1] ??
1795
+ src.match(/createProvider\(\{[^}]*?name:\s*"([^"]+)"/)?.[1] ??
1796
+ "";
1797
+ } catch {
1798
+ // provider module absent — derive from id
1799
+ }
1800
+ if (!name) {
1801
+ name = id
1802
+ .split("-")
1803
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
1804
+ .join(" ");
1805
+ }
1806
+
1807
+ providers.push({
1808
+ id,
1809
+ name,
1810
+ type: "builtin",
1811
+ api,
1812
+ baseUrl,
1813
+ hasAuth: false,
1814
+ authMethod: "env",
1815
+ models: models.sort((a, b) => a.id.localeCompare(b.id)),
1816
+ });
1817
+ }
1818
+
1819
+ if (providers.length === 0) return null;
1820
+ providers.sort((a, b) => a.id.localeCompare(b.id));
1821
+ catalogCache = { providers, at: Date.now() };
1822
+ return providers;
1823
+ }