@aion0/forge 0.10.6 → 0.10.17

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.
Files changed (41) hide show
  1. package/RELEASE_NOTES.md +5 -5
  2. package/app/api/public-info/[resource]/route.ts +40 -0
  3. package/app/api/skills/install-local/route.ts +2 -1
  4. package/cli/mw.mjs +11 -21
  5. package/components/SettingsModal.tsx +42 -33
  6. package/components/WorkspaceView.tsx +5 -3
  7. package/lib/agents/index.ts +8 -9
  8. package/lib/agents/known-models.ts +75 -0
  9. package/lib/agents/migrate.ts +14 -3
  10. package/lib/dirs.ts +6 -26
  11. package/lib/public-info/fetch.ts +116 -0
  12. package/lib/public-info/types.ts +38 -0
  13. package/lib/public-info/use-models-registry.ts +66 -0
  14. package/lib/settings.ts +34 -4
  15. package/lib/skills.ts +2 -2
  16. package/lib/workspace/watch-manager.ts +5 -1
  17. package/package.json +1 -1
  18. package/lib/__tests__/foreach-batch-yaml.test.ts +0 -33
  19. package/lib/__tests__/foreach-before.test.ts +0 -201
  20. package/lib/__tests__/foreach-parse.test.ts +0 -114
  21. package/lib/__tests__/foreach-snapshot.test.ts +0 -112
  22. package/lib/__tests__/foreach-source.test.ts +0 -105
  23. package/lib/__tests__/foreach-template.test.ts +0 -112
  24. package/lib/workspace/__tests__/state-machine.test.ts +0 -388
  25. package/lib/workspace/__tests__/workspace.test.ts +0 -311
  26. package/scripts/bench/README.md +0 -66
  27. package/scripts/bench/results/.gitignore +0 -2
  28. package/scripts/bench/run.ts +0 -635
  29. package/scripts/bench/tasks/01-text-utils/task.md +0 -26
  30. package/scripts/bench/tasks/01-text-utils/validator.sh +0 -46
  31. package/scripts/bench/tasks/02-pagination/setup.sh +0 -19
  32. package/scripts/bench/tasks/02-pagination/task.md +0 -48
  33. package/scripts/bench/tasks/02-pagination/validator.sh +0 -69
  34. package/scripts/bench/tasks/03-bug-fix/setup.sh +0 -82
  35. package/scripts/bench/tasks/03-bug-fix/task.md +0 -30
  36. package/scripts/bench/tasks/03-bug-fix/validator.sh +0 -29
  37. package/scripts/test-agents-migrate.ts +0 -149
  38. package/scripts/test-mantis.ts +0 -223
  39. package/scripts/test-memory-local.ts +0 -139
  40. package/scripts/test-memory-upsert.ts +0 -106
  41. package/scripts/verify-usage.ts +0 -178
package/RELEASE_NOTES.md CHANGED
@@ -1,11 +1,11 @@
1
- # Forge v0.10.6
1
+ # Forge v0.10.17
2
2
 
3
3
  Released: 2026-05-30
4
4
 
5
- ## Changes since v0.10.5
5
+ ## Changes since v0.10.16
6
6
 
7
- ### Other
8
- - feat(settings): folder picker for Add Project / Add Document Directory
7
+ ### Features
8
+ - feat: API profile model picker uses registry providers
9
9
 
10
10
 
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.5...v0.10.6
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.16...v0.10.17
@@ -0,0 +1,40 @@
1
+ /**
2
+ * GET /api/public-info/<resource>?refresh=1
3
+ *
4
+ * Server-side proxy + cache for files under the public-info repo. UI
5
+ * never hits GitHub directly — avoids CORS / rate-limit headaches and
6
+ * gives us a single cache shared across all browser tabs.
7
+ *
8
+ * Supported resources (extend by adding to RESOURCE_MAP):
9
+ * - `models` → `models/registry.json`
10
+ *
11
+ * The UI passes the short name (`models`), this route translates to the
12
+ * actual path under the repo and pairs it with the right fallback.
13
+ */
14
+
15
+ import { NextRequest, NextResponse } from 'next/server';
16
+ import { fetchPublicInfo } from '@/lib/public-info/fetch';
17
+ import { KNOWN_MODELS_FALLBACK } from '@/lib/agents/known-models';
18
+
19
+ interface ResourceSpec {
20
+ path: string;
21
+ fallback: unknown;
22
+ }
23
+
24
+ const RESOURCE_MAP: Record<string, ResourceSpec> = {
25
+ models: { path: 'models/registry.json', fallback: KNOWN_MODELS_FALLBACK },
26
+ };
27
+
28
+ export async function GET(
29
+ req: NextRequest,
30
+ { params }: { params: Promise<{ resource: string }> },
31
+ ): Promise<NextResponse> {
32
+ const { resource } = await params;
33
+ const spec = RESOURCE_MAP[resource];
34
+ if (!spec) {
35
+ return NextResponse.json({ error: `unknown resource: ${resource}` }, { status: 404 });
36
+ }
37
+ const forceRefresh = req.nextUrl.searchParams.get('refresh') === '1';
38
+ const data = await fetchPublicInfo(spec.path, spec.fallback, { forceRefresh });
39
+ return NextResponse.json(data);
40
+ }
@@ -29,6 +29,7 @@ import AdmZip from 'adm-zip';
29
29
  import { homedir } from 'node:os';
30
30
  import { getDb } from '@/src/core/db/database';
31
31
  import { getDbPath } from '@/src/config';
32
+ import { getClaudeDir } from '@/lib/dirs';
32
33
 
33
34
  /**
34
35
  * Refuse to clobber an existing skill (registry-synced or previously
@@ -52,7 +53,7 @@ function db() { return getDb(getDbPath()); }
52
53
  // ─── Helpers ──────────────────────────────────────────────
53
54
 
54
55
  function getClaudeHome(): string {
55
- return process.env.CLAUDE_HOME || join(homedir(), '.claude');
56
+ return getClaudeDir();
56
57
  }
57
58
 
58
59
  function isValidName(s: string): boolean {
package/cli/mw.mjs CHANGED
@@ -745,7 +745,7 @@ __export(dirs_exports, {
745
745
  });
746
746
  import { homedir as homedir2 } from "node:os";
747
747
  import { join as join3 } from "node:path";
748
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, renameSync, copyFileSync, readFileSync as readFileSync2 } from "node:fs";
748
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, renameSync, copyFileSync } from "node:fs";
749
749
  function getConfigDir() {
750
750
  return join3(homedir2(), ".forge");
751
751
  }
@@ -797,17 +797,7 @@ function migrateDataDir() {
797
797
  console.log("[forge] Migration complete. Old files kept as backup.");
798
798
  }
799
799
  function getClaudeDir() {
800
- if (process.env.CLAUDE_HOME) return process.env.CLAUDE_HOME;
801
- try {
802
- const settingsFile = join3(getDataDir(), "settings.yaml");
803
- if (existsSync3(settingsFile)) {
804
- const raw = readFileSync2(settingsFile, "utf-8");
805
- const m = raw.match(/^\s*claudeHome:\s*["']?(.+?)["']?\s*$/m);
806
- if (m?.[1]) return m[1].replace(/^~/, homedir2());
807
- }
808
- } catch {
809
- }
810
- return join3(homedir2(), ".claude");
800
+ return process.env.CLAUDE_HOME || join3(homedir2(), ".claude");
811
801
  }
812
802
  var MIGRATE_FILES, MIGRATE_DIRS, migrated;
813
803
  var init_dirs = __esm({
@@ -835,10 +825,10 @@ var BASE2 = process.env.MW_URL || `http://localhost:${_cliPort || "3000"}`;
835
825
  var [, , cmd, ...args] = process.argv;
836
826
  async function checkForUpdate() {
837
827
  try {
838
- const { readFileSync: readFileSync3 } = await import("node:fs");
828
+ const { readFileSync: readFileSync2 } = await import("node:fs");
839
829
  const { join: join4, dirname } = await import("node:path");
840
830
  const { fileURLToPath } = await import("node:url");
841
- const pkg = JSON.parse(readFileSync3(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
831
+ const pkg = JSON.parse(readFileSync2(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
842
832
  const current = pkg.version;
843
833
  const controller = new AbortController();
844
834
  const timeout = setTimeout(() => controller.abort(), 3e3);
@@ -872,11 +862,11 @@ async function api3(path, opts) {
872
862
  }
873
863
  async function main() {
874
864
  if (cmd === "--version" || cmd === "-v") {
875
- const { readFileSync: readFileSync3 } = await import("node:fs");
865
+ const { readFileSync: readFileSync2 } = await import("node:fs");
876
866
  const { join: join4, dirname } = await import("node:path");
877
867
  const { fileURLToPath } = await import("node:url");
878
868
  try {
879
- const pkg = JSON.parse(readFileSync3(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
869
+ const pkg = JSON.parse(readFileSync2(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
880
870
  console.log(`@aion0/forge v${pkg.version}`);
881
871
  } catch {
882
872
  console.log("forge (version unknown)");
@@ -1193,14 +1183,14 @@ Resume in CLI:`);
1193
1183
  }
1194
1184
  case "tunnel_code":
1195
1185
  case "tcode": {
1196
- const { readFileSync: readFileSync3, existsSync: existsSync4 } = await import("node:fs");
1186
+ const { readFileSync: readFileSync2, existsSync: existsSync4 } = await import("node:fs");
1197
1187
  const { join: join4 } = await import("node:path");
1198
1188
  const { getDataDir: _gdd } = await Promise.resolve().then(() => (init_dirs(), dirs_exports));
1199
1189
  const dataDir2 = _gdd();
1200
1190
  const codeFile = join4(dataDir2, "session-code.json");
1201
1191
  try {
1202
1192
  if (existsSync4(codeFile)) {
1203
- const data = JSON.parse(readFileSync3(codeFile, "utf-8"));
1193
+ const data = JSON.parse(readFileSync2(codeFile, "utf-8"));
1204
1194
  if (data.code) {
1205
1195
  console.log(`Session code: ${data.code}`);
1206
1196
  } else {
@@ -1212,7 +1202,7 @@ Resume in CLI:`);
1212
1202
  } catch {
1213
1203
  }
1214
1204
  try {
1215
- const tunnelState = JSON.parse(readFileSync3(join4(dataDir2, "tunnel-state.json"), "utf-8"));
1205
+ const tunnelState = JSON.parse(readFileSync2(join4(dataDir2, "tunnel-state.json"), "utf-8"));
1216
1206
  if (tunnelState.url) console.log(`Tunnel URL: ${tunnelState.url}`);
1217
1207
  } catch {
1218
1208
  }
@@ -1356,9 +1346,9 @@ ${task.gitDiff.slice(0, 2e3)}`);
1356
1346
  cwd: homedir3()
1357
1347
  });
1358
1348
  try {
1359
- const { readFileSync: readFileSync3 } = await import("node:fs");
1349
+ const { readFileSync: readFileSync2 } = await import("node:fs");
1360
1350
  const globalRoot = execSync2("npm root -g", { encoding: "utf-8", cwd: homedir3() }).trim();
1361
- const pkg = JSON.parse(readFileSync3(join4(globalRoot, "@aion0", "forge", "package.json"), "utf-8"));
1351
+ const pkg = JSON.parse(readFileSync2(join4(globalRoot, "@aion0", "forge", "package.json"), "utf-8"));
1362
1352
  console.log(`[forge] Upgraded to v${pkg.version}. Run: forge server restart`);
1363
1353
  } catch {
1364
1354
  console.log("[forge] Upgraded. Run: forge server restart");
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import { FolderPicker } from './FolderPicker';
5
+ import { useModelsRegistry, pickerOptions } from '@/lib/public-info/use-models-registry';
5
6
 
6
7
  function SecretInput({ value, onChange, placeholder, className }: {
7
8
  value: string;
@@ -917,6 +918,7 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete, isApi: isApiProp
917
918
  onUpdate: (cfg: any) => void; onDelete: () => void;
918
919
  isApi?: boolean;
919
920
  }) {
921
+ const { registry: modelsRegistry } = useModelsRegistry();
920
922
  const [expanded, setExpanded] = useState(false);
921
923
  const [testing, setTesting] = useState(false);
922
924
  const [testResult, setTestResult] = useState<{ ok: boolean; message?: string; error?: string; duration_ms?: number } | null>(null);
@@ -975,20 +977,23 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete, isApi: isApiProp
975
977
  <label className="text-[8px] text-[var(--text-secondary)]">Model</label>
976
978
  <input value={cfg.model || ''} onChange={e => onUpdate({ ...cfg, model: e.target.value })}
977
979
  list={`profile-model-${id}`} className={inputClass} />
978
- {(cfg.cliType === 'claude-code' || (!cfg.cliType && !cfg.base && !isApi)) && (
979
- <datalist id={`profile-model-${id}`}>
980
- <option value="claude-opus-4-6" />
981
- <option value="claude-sonnet-4-6" />
982
- <option value="claude-haiku-4-5-20251001" />
983
- </datalist>
984
- )}
985
- {(cfg.cliType === 'codex' || cfg.base === 'codex') && (
986
- <datalist id={`profile-model-${id}`}>
987
- <option value="codex-mini" />
988
- <option value="o4-mini" />
989
- <option value="gpt-4o" />
990
- </datalist>
991
- )}
980
+ {/* Datalist sourced from forge-public-info registry adding a new
981
+ model id over there shows up here within 24h, no forge release.
982
+ API profiles look up by provider; CLI profiles look up by cliType. */}
983
+ <datalist id={`profile-model-${id}`}>
984
+ {(() => {
985
+ const models = isApi
986
+ ? modelsRegistry.providers?.[cfg.provider || 'anthropic']?.models
987
+ : modelsRegistry.agents[
988
+ cfg.cliType === 'codex' || cfg.base === 'codex' ? 'codex'
989
+ : cfg.cliType === 'aider' || cfg.base === 'aider' ? 'aider'
990
+ : 'claude-code'
991
+ ]?.models;
992
+ return (models || []).map(m => (
993
+ <option key={m.id} value={m.id} />
994
+ ));
995
+ })()}
996
+ </datalist>
992
997
  </div>
993
998
  </div>
994
999
  {isApi ? (
@@ -1127,6 +1132,7 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
1127
1132
  baseAgents: AgentEntry[];
1128
1133
  onAdd: (id: string, cfg: any) => void;
1129
1134
  }) {
1135
+ const { registry: modelsRegistry } = useModelsRegistry();
1130
1136
  const [open, setOpen] = useState(false);
1131
1137
  const [id, setId] = useState('');
1132
1138
  const [name, setName] = useState('');
@@ -1228,22 +1234,17 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
1228
1234
  <div className="flex-1">
1229
1235
  <label className="text-[8px] text-[var(--text-secondary)]">Model</label>
1230
1236
  <input value={model} onChange={e => setModel(e.target.value)}
1231
- placeholder={base === 'claude' ? 'claude-sonnet-4-6' : base === 'codex' ? 'codex-mini' : ''}
1237
+ placeholder={
1238
+ base === 'claude' ? modelsRegistry.agents['claude-code']?.default || ''
1239
+ : base === 'codex' ? modelsRegistry.agents['codex']?.default || ''
1240
+ : ''
1241
+ }
1232
1242
  list={`model-list-${base}`} className={inputClass} />
1233
- {base === 'claude' && (
1234
- <datalist id="model-list-claude">
1235
- <option value="claude-opus-4-6" />
1236
- <option value="claude-sonnet-4-6" />
1237
- <option value="claude-haiku-4-5-20251001" />
1238
- </datalist>
1239
- )}
1240
- {base === 'codex' && (
1241
- <datalist id="model-list-codex">
1242
- <option value="codex-mini" />
1243
- <option value="o4-mini" />
1244
- <option value="gpt-4o" />
1245
- </datalist>
1246
- )}
1243
+ <datalist id={`model-list-${base}`}>
1244
+ {(modelsRegistry.agents[base === 'claude' ? 'claude-code' : base]?.models || []).map(m => (
1245
+ <option key={m.id} value={m.id} />
1246
+ ))}
1247
+ </datalist>
1247
1248
  </div>
1248
1249
  </div>
1249
1250
  <div>
@@ -1275,7 +1276,14 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
1275
1276
  </div>
1276
1277
  <div className="flex-1">
1277
1278
  <label className="text-[8px] text-[var(--text-secondary)]">Model</label>
1278
- <input value={model} onChange={e => setModel(e.target.value)} placeholder="claude-sonnet-4-6" className={inputClass} />
1279
+ <input value={model} onChange={e => setModel(e.target.value)}
1280
+ placeholder={modelsRegistry.providers?.[provider]?.default || ''}
1281
+ list={`api-model-list-${provider}`} className={inputClass} />
1282
+ <datalist id={`api-model-list-${provider}`}>
1283
+ {(modelsRegistry.providers?.[provider]?.models || []).map(m => (
1284
+ <option key={m.id} value={m.id} />
1285
+ ))}
1286
+ </datalist>
1279
1287
  </div>
1280
1288
  </div>
1281
1289
  <div>
@@ -1314,6 +1322,7 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
1314
1322
  }
1315
1323
 
1316
1324
  function AgentsSection({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
1325
+ const { registry: modelsRegistry } = useModelsRegistry();
1317
1326
  const [agents, setAgents] = useState<AgentEntry[]>([]);
1318
1327
  const [loading, setLoading] = useState(true);
1319
1328
  const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
@@ -1649,9 +1658,9 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1649
1658
  <span className="text-[8px] text-[var(--text-secondary)]">Presets:</span>
1650
1659
  {((() => {
1651
1660
  const ct = (settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic');
1652
- if (ct === 'claude-code') return ['default', 'sonnet', 'opus', 'haiku', 'claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5-20251001'];
1653
- if (ct === 'codex') return ['default', 'o3-mini', 'o4-mini', 'gpt-4.1'];
1654
- return ['default'];
1661
+ // Models pulled from forge-public-info repo (lib/public-info/fetch.ts).
1662
+ // Add new IDs there and they show up here within 24h with no forge release.
1663
+ return pickerOptions(modelsRegistry, ct);
1655
1664
  })()).map(preset => (
1656
1665
  <button
1657
1666
  key={preset}
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useEffect, useCallback, useMemo, useRef, forwardRef, useImperativeHandle, lazy, Suspense } from 'react';
4
4
  import { TerminalSessionPickerLazy, fetchAgentSessions, type PickerSelection } from './TerminalLauncher';
5
+ import { useModelsRegistry } from '@/lib/public-info/use-models-registry';
5
6
  import {
6
7
  ReactFlow, Background, Controls, Handle, Position, useReactFlow, ReactFlowProvider,
7
8
  type Node, type NodeProps, MarkerType, type NodeChange,
@@ -715,6 +716,7 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
715
716
  onConfirm: (cfg: Omit<AgentConfig, 'id'>) => void;
716
717
  onCancel: () => void;
717
718
  }) {
719
+ const { registry: modelsRegistry } = useModelsRegistry();
718
720
  const [label, setLabel] = useState(initial.label || '');
719
721
  const [icon, setIcon] = useState(initial.icon || '🤖');
720
722
  const [role, setRole] = useState(initial.role || '');
@@ -1160,9 +1162,9 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
1160
1162
  list="workspace-model-list"
1161
1163
  className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] font-mono" />
1162
1164
  <datalist id="workspace-model-list">
1163
- <option value="claude-sonnet-4-6" />
1164
- <option value="claude-opus-4-6" />
1165
- <option value="claude-haiku-4-5-20251001" />
1165
+ {(modelsRegistry.agents['claude-code']?.models || []).map(m => (
1166
+ <option key={m.id} value={m.id} />
1167
+ ))}
1166
1168
  </datalist>
1167
1169
  </div>
1168
1170
  );
@@ -91,7 +91,7 @@ export function listAgents(): AgentConfig[] {
91
91
  enabled: cfg.enabled !== false,
92
92
  isProfile: true,
93
93
  backendType: 'cli',
94
- model: cfg.model || cfg.models?.task,
94
+ model: cfg.models?.task,
95
95
  skipPermissionsFlag: cfg.skipPermissionsFlag || baseAgent?.skipPermissionsFlag,
96
96
  env: cfg.env,
97
97
  cliType: (baseAgent as any)?.cliType || 'generic',
@@ -117,7 +117,7 @@ export function listAgents(): AgentConfig[] {
117
117
  base: cfg.base,
118
118
  isProfile: true,
119
119
  backendType: 'cli',
120
- model: cfg.model || cfg.models?.task,
120
+ model: cfg.models?.task,
121
121
  skipPermissionsFlag: cfg.skipPermissionsFlag || baseAgent?.skipPermissionsFlag,
122
122
  env: cfg.env,
123
123
  cliType: cfg.cliType || (baseAgent as any)?.cliType || 'generic',
@@ -262,20 +262,19 @@ export function resolveTerminalLaunch(agentId?: string): TerminalLaunchInfo {
262
262
  || (cliType === 'codex' ? probeCodexSkipFlag(agentCfg.path || 'codex') : cli.skip)
263
263
  || undefined;
264
264
 
265
- // Resolve env/model: either from this agent's own profile fields, or from linked profile
265
+ // Resolve env/model from this agent's per-use models sub-table.
266
+ // 顶层 model 字段已在 0.10 起被 migrate.ts 清理,这里只读 models.terminal。
266
267
  let env: Record<string, string> | undefined;
267
268
  let model: string | undefined;
268
- if (agentCfg.base || agentCfg.env || agentCfg.model || agentCfg.models) {
269
- // This agent has profile-like config — read env/model directly
269
+ if (agentCfg.base || agentCfg.env || agentCfg.models) {
270
270
  if (agentCfg.env) env = { ...agentCfg.env };
271
- model = agentCfg.model || agentCfg.models?.terminal;
272
- if (model === 'default') model = undefined; // 'default' means no override
271
+ model = agentCfg.models?.terminal;
272
+ if (model === 'default') model = undefined; // 'default' = 不加 --model
273
273
  } else if (agentCfg.profile) {
274
- // Agent links to a separate profile — read from that
275
274
  const profileCfg = settings.agents?.[agentCfg.profile];
276
275
  if (profileCfg) {
277
276
  if (profileCfg.env) env = { ...profileCfg.env };
278
- model = profileCfg.model || profileCfg.models?.terminal;
277
+ model = profileCfg.models?.terminal;
279
278
  if (model === 'default') model = undefined;
280
279
  }
281
280
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Built-in fallback model list. Forge prefers the public-info repo's
3
+ * registry (see `lib/public-info/fetch.ts`), but falls back to this
4
+ * when the network is unreachable, the file is malformed, or the
5
+ * user is on first-run before the cache is populated.
6
+ *
7
+ * Keep this list reasonably current — if the registry is permanently
8
+ * unreachable, this is what users see. New models in the wild should
9
+ * land in the public-info repo first (no code change required), and
10
+ * trickle into this fallback whenever the next forge release happens.
11
+ */
12
+
13
+ import type { ModelsRegistry } from '../public-info/types';
14
+
15
+ export const KNOWN_MODELS_FALLBACK: ModelsRegistry = {
16
+ version: 1,
17
+ updatedAt: '2026-05-30',
18
+ note: 'Bundled fallback — actual current list lives in forge-public-info/models/registry.json',
19
+ agents: {
20
+ 'claude-code': {
21
+ displayName: 'Claude Code',
22
+ default: 'claude-sonnet-4-6',
23
+ aliases: [
24
+ { id: 'default', label: 'default (CLI decides)' },
25
+ { id: 'sonnet', label: 'sonnet (alias)' },
26
+ { id: 'opus', label: 'opus (alias)' },
27
+ { id: 'haiku', label: 'haiku (alias)' },
28
+ ],
29
+ models: [
30
+ { id: 'claude-opus-4-8', label: 'Opus 4.8', tier: 'premium' },
31
+ { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', tier: 'standard', default: true },
32
+ { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', tier: 'fast' },
33
+ ],
34
+ },
35
+ codex: {
36
+ displayName: 'OpenAI Codex',
37
+ default: 'o4-mini',
38
+ aliases: [{ id: 'default', label: 'default (CLI decides)' }],
39
+ models: [
40
+ { id: 'o4-mini', label: 'o4-mini', tier: 'fast', default: true },
41
+ { id: 'o3-mini', label: 'o3-mini', tier: 'fast' },
42
+ { id: 'gpt-4.1', label: 'GPT-4.1', tier: 'standard' },
43
+ ],
44
+ },
45
+ aider: {
46
+ displayName: 'Aider',
47
+ default: 'default',
48
+ aliases: [{ id: 'default', label: 'default (CLI decides)' }],
49
+ models: [],
50
+ },
51
+ },
52
+ providers: {
53
+ anthropic: {
54
+ displayName: 'Anthropic API',
55
+ default: 'claude-sonnet-4-6',
56
+ aliases: [],
57
+ models: [
58
+ { id: 'claude-opus-4-8', label: 'Opus 4.8', tier: 'premium' },
59
+ { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', tier: 'standard', default: true },
60
+ { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', tier: 'fast' },
61
+ ],
62
+ },
63
+ openai: {
64
+ displayName: 'OpenAI API',
65
+ default: 'gpt-4.1',
66
+ aliases: [],
67
+ models: [
68
+ { id: 'gpt-4.1', label: 'GPT-4.1', tier: 'premium', default: true },
69
+ { id: 'gpt-4o', label: 'GPT-4o', tier: 'standard' },
70
+ { id: 'o4-mini', label: 'o4-mini', tier: 'fast' },
71
+ { id: 'o3-mini', label: 'o3-mini', tier: 'fast' },
72
+ ],
73
+ },
74
+ },
75
+ };
@@ -74,9 +74,19 @@ export function migrateAgentsFlatten(settings: Settings): boolean {
74
74
  }
75
75
  if (raw.type !== undefined) { delete raw.type; mutated = true; }
76
76
 
77
- // 老嵌套 models.task 扁平 model(如果 model 字段空)
78
- if (!raw.model && raw.models?.task && raw.models.task !== 'default') {
79
- raw.model = raw.models.task;
77
+ // 顶层 model 字段已被 per-use models 子表完整取代(terminal / task /
78
+ // telegram / help / mobile 各自可配)。0.10 之前的迁移逻辑反方向复制了
79
+ // models.task → model,导致 resolveTerminalLaunch 错把"task 模型"
80
+ // 当成"全场景兜底",新建项目终端被强插 --model。
81
+ //
82
+ // 这里反向收尾:把残留的 model 搬回 models.task(只在 task 为空或
83
+ // 'default' 时填,避免覆盖用户更近期的选择),然后彻底删字段。
84
+ if (raw.model !== undefined) {
85
+ if (!raw.models) raw.models = {};
86
+ if ((!raw.models.task || raw.models.task === 'default') && raw.model !== 'default') {
87
+ raw.models.task = raw.model;
88
+ }
89
+ delete raw.model;
80
90
  mutated = true;
81
91
  }
82
92
  }
@@ -116,6 +126,7 @@ function detectsLegacyShape(settings: Settings): boolean {
116
126
  if (cfg.type === 'api') return true;
117
127
  if (cfg.base !== undefined) return true;
118
128
  if (cfg.cliType !== undefined) return true;
129
+ if (cfg.model !== undefined) return true; // 顶层 model 已废,改 models.task
119
130
  // 没有 tool + 是 builtin id → 也算需要迁移(补 tool 字段)
120
131
  }
121
132
  // 内置 agent 没有 tool 字段也需要补
package/lib/dirs.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { homedir } from 'node:os';
11
11
  import { join } from 'node:path';
12
- import { existsSync, mkdirSync, renameSync, copyFileSync, readFileSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, renameSync, copyFileSync } from 'node:fs';
13
13
 
14
14
  /** Shared config directory — only binaries, fixed at ~/.forge/ */
15
15
  export function getConfigDir(): string {
@@ -85,30 +85,10 @@ export function migrateDataDir(): void {
85
85
  console.log('[forge] Migration complete. Old files kept as backup.');
86
86
  }
87
87
 
88
- /** Claude Code home directory — skills, commands, sessions.
89
- *
90
- * Circular-dep note: dirs settings (settings.ts imports getDataDir).
91
- * Used to do `require('./settings')` here as a lazy hack but that
92
- * fires ReferenceError under ESM concurrent loads, and dirs is on
93
- * the hot path for every task / connector / pipeline node.
94
- *
95
- * Cut the cycle by reading the YAML directly here. No parser needed:
96
- * we only need one field (`claudeHome`), which is plain "key: value".
97
- * Falls back to ~/.claude if anything goes sideways.
98
- */
88
+ /** Claude config directory — skills, commands, sessions, etc. Override
89
+ * via the `CLAUDE_HOME` env var (same name claude CLI uses); defaults
90
+ * to `~/.claude`. The settings.yaml `claudeHome` field was removed in
91
+ * 0.10.10 set the env var if you need a non-default location. */
99
92
  export function getClaudeDir(): string {
100
- if (process.env.CLAUDE_HOME) return process.env.CLAUDE_HOME;
101
- try {
102
- const settingsFile = join(getDataDir(), 'settings.yaml');
103
- if (existsSync(settingsFile)) {
104
- const raw = readFileSync(settingsFile, 'utf-8');
105
- // Match a top-level `claudeHome: <value>` line. Tolerates quoted /
106
- // unquoted strings and stray whitespace. Not a full YAML parser
107
- // because we explicitly want to avoid loading the YAML module
108
- // (and settings.ts) from this hot path.
109
- const m = raw.match(/^\s*claudeHome:\s*["']?(.+?)["']?\s*$/m);
110
- if (m?.[1]) return m[1].replace(/^~/, homedir());
111
- }
112
- } catch {}
113
- return join(homedir(), '.claude');
93
+ return process.env.CLAUDE_HOME || join(homedir(), '.claude');
114
94
  }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Generic fetch+cache helper for `forge-public-info` resources.
3
+ *
4
+ * Pattern: GitHub-hosted JSON files live under
5
+ * `<publicInfoRepoUrl>/<resourceDir>/<file>.json`
6
+ *
7
+ * On read:
8
+ * 1. If a fresh cached copy exists (< TTL), return it.
9
+ * 2. Otherwise fetch from the configured `publicInfoRepoUrl`.
10
+ * 3. On any error (offline, 4xx, malformed JSON), return the bundled
11
+ * fallback so the UI never breaks.
12
+ *
13
+ * Cache lives at `<dataDir>/public-info-cache/<resource>.json`. Each
14
+ * file has a sidecar `<resource>.meta.json` carrying the fetch
15
+ * timestamp; we don't trust HTTP `Last-Modified` because GitHub raw
16
+ * serves through a CDN that strips it.
17
+ */
18
+
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { getDataDir } from '../dirs';
22
+ import { loadSettings } from '../settings';
23
+
24
+ const DEFAULT_REPO_URL = 'https://raw.githubusercontent.com/aiwatching/forge-public-info/main';
25
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
26
+ const FETCH_TIMEOUT_MS = 5000;
27
+
28
+ interface CacheMeta { fetchedAt: number; etag?: string }
29
+
30
+ function cacheDir(): string {
31
+ const dir = join(getDataDir(), 'public-info-cache');
32
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
33
+ return dir;
34
+ }
35
+
36
+ function repoUrl(): string {
37
+ return (loadSettings() as any).publicInfoRepoUrl || DEFAULT_REPO_URL;
38
+ }
39
+
40
+ function cacheKey(resourcePath: string): string {
41
+ // public/info/models/registry.json → models-registry
42
+ return resourcePath.replace(/\.json$/, '').replace(/[^a-zA-Z0-9]/g, '-').replace(/^-+|-+$/g, '');
43
+ }
44
+
45
+ function readCache(resourcePath: string): { data: unknown; meta: CacheMeta } | null {
46
+ const key = cacheKey(resourcePath);
47
+ const dataPath = join(cacheDir(), `${key}.json`);
48
+ const metaPath = join(cacheDir(), `${key}.meta.json`);
49
+ if (!existsSync(dataPath) || !existsSync(metaPath)) return null;
50
+ try {
51
+ return {
52
+ data: JSON.parse(readFileSync(dataPath, 'utf-8')),
53
+ meta: JSON.parse(readFileSync(metaPath, 'utf-8')),
54
+ };
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function writeCache(resourcePath: string, data: unknown): void {
61
+ const key = cacheKey(resourcePath);
62
+ try {
63
+ writeFileSync(join(cacheDir(), `${key}.json`), JSON.stringify(data, null, 2), 'utf-8');
64
+ writeFileSync(
65
+ join(cacheDir(), `${key}.meta.json`),
66
+ JSON.stringify({ fetchedAt: Date.now() } satisfies CacheMeta, null, 2),
67
+ 'utf-8',
68
+ );
69
+ } catch (err) {
70
+ console.warn('[public-info] cache write failed:', err instanceof Error ? err.message : err);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Fetch a public-info resource with built-in cache + fallback.
76
+ *
77
+ * @param resourcePath Path under the repo, e.g. `"models/registry.json"`
78
+ * @param fallback Used when network + cache both fail
79
+ * @param opts.forceRefresh Skip cache TTL check (e.g., user-triggered refresh)
80
+ */
81
+ export async function fetchPublicInfo<T>(
82
+ resourcePath: string,
83
+ fallback: T,
84
+ opts: { forceRefresh?: boolean } = {},
85
+ ): Promise<T> {
86
+ const now = Date.now();
87
+
88
+ // 1. Cache hit (fresh)
89
+ if (!opts.forceRefresh) {
90
+ const cached = readCache(resourcePath);
91
+ if (cached && now - cached.meta.fetchedAt < CACHE_TTL_MS) {
92
+ return cached.data as T;
93
+ }
94
+ }
95
+
96
+ // 2. Network fetch
97
+ const url = `${repoUrl()}/${resourcePath}`;
98
+ try {
99
+ const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
100
+ if (res.ok) {
101
+ const data = await res.json();
102
+ writeCache(resourcePath, data);
103
+ return data as T;
104
+ }
105
+ console.warn(`[public-info] ${url} returned HTTP ${res.status}, using fallback`);
106
+ } catch (err) {
107
+ console.warn(`[public-info] ${url} fetch failed:`, err instanceof Error ? err.message : err);
108
+ }
109
+
110
+ // 3. Stale cache (better than fallback)
111
+ const cached = readCache(resourcePath);
112
+ if (cached) return cached.data as T;
113
+
114
+ // 4. Bundled fallback
115
+ return fallback;
116
+ }