@aion0/forge 0.9.19 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/RELEASE_NOTES.md CHANGED
@@ -1,18 +1,11 @@
1
- # Forge v0.9.19
1
+ # Forge v0.10.3
2
2
 
3
- Released: 2026-05-29
3
+ Released: 2026-05-30
4
4
 
5
- ## Changes since v0.9.18
5
+ ## Changes since v0.10.2
6
6
 
7
7
  ### Other
8
- - feat(memory): writeEpisode dual-write + Memory drawer in /chat web
9
- - feat(monitor): D3 — Memory Worker row in Settings Monitor
10
- - feat(chat): Phase C — agent-loop reads summarized memory + token-budget history
11
- - feat(memory): B11 — spawn memory-standalone from forge-server + dev supervisor
12
- - fix(memory): bypass ai-sdk for summarizer LLM call — raw fetch instead
13
- - feat(memory): Phase B — memory-standalone process + Temper Summary sub-task
14
- - feat(memory): Phase A — key conventions + buildMemoryContext + OR-match search
15
- - fix(chat): cap LLM history at last 40 messages and drop orphan tool_results
8
+ - fix(settings): unmask apiProfiles.*.apiKey on POST (regression in v0.10.0)
16
9
 
17
10
 
18
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.18...v0.9.19
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.2...v0.10.3
@@ -35,7 +35,9 @@ interface TestResult {
35
35
  export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
36
36
  const { id } = await params;
37
37
  const settings = loadSettings();
38
- const saved = (settings.agents || {})[id] as any | undefined;
38
+ // Post-migration API profiles live in settings.apiProfiles; fall back to
39
+ // settings.agents for the migration window or for legacy entries.
40
+ const saved = (settings.apiProfiles || {})[id] || (settings.agents || {})[id] as any | undefined;
39
41
 
40
42
  let override: any = {};
41
43
  try { override = (await req.json()) ?? {}; } catch { override = {}; }
@@ -53,7 +55,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
53
55
  apiKey: unmask(override.apiKey, saved?.apiKey) ?? saved?.apiKey,
54
56
  baseUrl: override.baseUrl ?? saved?.baseUrl,
55
57
  model: override.model ?? saved?.model,
56
- env: saved?.env,
58
+ env: (saved as any)?.env, // legacy agent entries may have env; apiProfiles don't
57
59
  };
58
60
 
59
61
  if (!profile.provider) {
@@ -1,5 +1,6 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import { listAgents, getDefaultAgentId, resolveTerminalLaunch } from '@/lib/agents';
3
+ import { loadSettings } from '@/lib/settings';
3
4
 
4
5
  export async function GET(req: Request) {
5
6
  const url = new URL(req.url);
@@ -16,12 +17,31 @@ export async function GET(req: Request) {
16
17
  // default hides them. WorkspaceView's API mode passes ?include=all
17
18
  // (or ?include=api) to surface them when needed.
18
19
  const include = (url.searchParams.get('include') || 'cli').toLowerCase();
19
- const all = listAgents();
20
- const agents = include === 'all'
21
- ? all
22
- : include === 'api'
23
- ? all.filter((a: any) => a.backendType === 'api')
24
- : all.filter((a: any) => a.backendType !== 'api');
20
+ const cliAgents = listAgents();
21
+ const apiAgents = include === 'cli' ? [] : apiProfilesAsAgents();
22
+ const agents = include === 'cli' ? cliAgents
23
+ : include === 'api' ? apiAgents
24
+ : [...cliAgents, ...apiAgents];
25
25
  const defaultAgent = getDefaultAgentId();
26
26
  return NextResponse.json({ agents, defaultAgent });
27
27
  }
28
+
29
+ // Render settings.apiProfiles as agent-shaped entries so the workspace
30
+ // API-mode picker can list them (Phase 2 moved them out of listAgents).
31
+ function apiProfilesAsAgents() {
32
+ const profiles = loadSettings().apiProfiles || {};
33
+ return Object.entries(profiles)
34
+ .filter(([, p]: [string, any]) => p && p.enabled !== false)
35
+ .map(([id, p]: [string, any]) => ({
36
+ id,
37
+ name: p.name || id,
38
+ path: '',
39
+ enabled: true,
40
+ type: 'generic' as const,
41
+ capabilities: { supportsResume: false, supportsStreamJson: false, supportsModel: false, supportsSkipPermissions: false, hasSessionFiles: false, requiresTTY: false },
42
+ isProfile: true,
43
+ backendType: 'api' as const,
44
+ provider: p.provider,
45
+ model: p.model,
46
+ }));
47
+ }
@@ -100,19 +100,21 @@ export async function POST(req: Request) {
100
100
  if (!text) return NextResponse.json({ ok: false, error: 'text required' }, { status: 400 });
101
101
 
102
102
  const settings = loadSettings();
103
- const agents = settings.agents || {};
104
- const candidates = Object.entries(agents).filter(([_, a]) => {
105
- if (!a || a.type !== 'api' || a.enabled === false) return false;
106
- return pickApiKey(a, inferAdapter(a.provider)).length > 0;
103
+ const profiles = settings.apiProfiles || {};
104
+ const candidates = Object.entries(profiles).filter(([_, p]: [string, any]) => {
105
+ if (!p || p.enabled === false) return false;
106
+ return (p.apiKey || '').length > 0;
107
107
  });
108
108
  if (candidates.length === 0) {
109
109
  return NextResponse.json({
110
110
  ok: false,
111
- error: 'No API agent profile with an API key. Add one under Settings → Agents (type: API).',
111
+ error: 'No API profile with an API key. Add one under Settings → API Profiles.',
112
112
  }, { status: 400 });
113
113
  }
114
114
  const preferredId = settings.chatAgent || candidates[0]![0];
115
- const profile = agents[preferredId]?.type === 'api' ? agents[preferredId] : candidates[0]![1];
115
+ const profile: any = profiles[preferredId] && profiles[preferredId].enabled !== false
116
+ ? profiles[preferredId]
117
+ : candidates[0]![1];
116
118
  const adapter = inferAdapter(profile.provider);
117
119
  if (adapter !== 'anthropic') {
118
120
  return NextResponse.json({
@@ -65,6 +65,17 @@ export async function PUT(req: Request) {
65
65
  }
66
66
  }
67
67
  }
68
+ // Same for apiProfiles (the canonical location post-migration). Without
69
+ // this, editing any unrelated setting wipes every saved apiKey.
70
+ if (updated.apiProfiles) {
71
+ for (const [id, p] of Object.entries(updated.apiProfiles)) {
72
+ const newKey = (p as any)?.apiKey;
73
+ if (newKey === '••••••••') {
74
+ const existing = settings.apiProfiles?.[id]?.apiKey;
75
+ if (existing) (p as any).apiKey = existing;
76
+ }
77
+ }
78
+ }
68
79
 
69
80
  // Remove internal fields
70
81
  delete (updated as any)._secretStatus;
@@ -882,14 +882,16 @@ interface AgentEntry {
882
882
  backendType?: string;
883
883
  }
884
884
 
885
- function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete }: {
885
+ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete, isApi: isApiProp }: {
886
886
  id: string; cfg: any; inputClass: string;
887
887
  onUpdate: (cfg: any) => void; onDelete: () => void;
888
+ isApi?: boolean;
888
889
  }) {
889
890
  const [expanded, setExpanded] = useState(false);
890
891
  const [testing, setTesting] = useState(false);
891
892
  const [testResult, setTestResult] = useState<{ ok: boolean; message?: string; error?: string; duration_ms?: number } | null>(null);
892
- const isApi = cfg.type === 'api';
893
+ // Post-migration apiProfile entries no longer carry `type: 'api'`. Caller passes isApi explicitly.
894
+ const isApi = isApiProp ?? (cfg.type === 'api');
893
895
 
894
896
  async function runTest() {
895
897
  setTesting(true);
@@ -1379,16 +1381,16 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1379
1381
  const agentsCfg: Record<string, any> = { ...(prev.agents || {}) };
1380
1382
  for (const a of updated) {
1381
1383
  const existing = agentsCfg[a.id] || {};
1382
- // NOTE: do NOT write `cliType` from the entry here. cliType is the
1383
- // load-time discriminator between agents and CLI profiles (see the
1384
- // filter ~line 1349 `if (cfg.cliType || cfg.base) continue`).
1385
- // The Agents-add form seeds newAgent.cliType for cliDefaults lookup
1386
- // (taskFlags / resumeFlag presets), but persisting it would make
1387
- // the new agent show up as a profile on reload. `...existing`
1388
- // carries forward cliType for detected agents whose inline selector
1389
- // wrote it via setSettings directly (line ~1540).
1384
+ // Persist `tool` (which CLI binary to wrap) listAgents reads this
1385
+ // post-migration to clone the matching builtin's adapter. cliType in
1386
+ // the AgentEntry is the form's selector value; map it to tool.
1387
+ const cliType = (a as any).cliType || existing.cliType;
1388
+ const tool = existing.tool
1389
+ || (cliType === 'claude-code' ? 'claude' : cliType === 'codex' ? 'codex' : cliType === 'aider' ? 'aider' : undefined)
1390
+ || (['claude', 'codex', 'aider', 'opencode'].includes(a.id) ? a.id : undefined);
1390
1391
  agentsCfg[a.id] = {
1391
- ...existing, // preserve profile-specific fields + any prior cliType
1392
+ ...existing,
1393
+ ...(tool ? { tool } : {}),
1392
1394
  name: a.name,
1393
1395
  path: a.path,
1394
1396
  enabled: a.enabled,
@@ -1447,10 +1449,9 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1447
1449
  // settings.agents is one map keyed by id — agents + CLI profiles + API
1448
1450
  // profiles all coexist. A duplicate id silently overwrites whichever
1449
1451
  // entry was there first. Block the add and tell the user why.
1450
- if (agents.some(a => a.id === newAgent.id) || settings.agents?.[newAgent.id]) {
1451
- const existing = settings.agents?.[newAgent.id] as any;
1452
- const kind = existing?.type === 'api' ? 'API profile' : existing?.cliType || existing?.base ? 'CLI profile' : 'agent';
1453
- alert(`id "${newAgent.id}" is already in use by a ${kind}. Pick a different id.`);
1452
+ if (agents.some(a => a.id === newAgent.id) || settings.agents?.[newAgent.id] || (settings as any).apiProfiles?.[newAgent.id]) {
1453
+ const kind = (settings as any).apiProfiles?.[newAgent.id] ? 'API profile' : 'agent';
1454
+ alert(`id "${newAgent.id}" is already in use by an ${kind}. Pick a different id.`);
1454
1455
  return;
1455
1456
  }
1456
1457
  const entry: AgentEntry = {
@@ -1552,8 +1553,12 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1552
1553
  </div>
1553
1554
  <div className="w-36">
1554
1555
  <label className="text-[9px] text-[var(--text-secondary)]">CLI Type</label>
1555
- <select value={(settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic')}
1556
- onChange={e => setSettings({ ...settings, agents: { ...settings.agents, [a.id]: { ...(settings.agents?.[a.id] || {}), cliType: e.target.value } } })}
1556
+ <select value={(settings.agents?.[a.id] as any)?.tool || (settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic')}
1557
+ onChange={e => {
1558
+ const v = e.target.value;
1559
+ const tool = v === 'claude-code' ? 'claude' : v === 'generic' ? undefined : v;
1560
+ setSettings({ ...settings, agents: { ...settings.agents, [a.id]: { ...(settings.agents?.[a.id] || {}), ...(tool ? { tool } : {}), cliType: v } } });
1561
+ }}
1557
1562
  className={inputClass}>
1558
1563
  <option value="claude-code">Claude Code</option>
1559
1564
  <option value="codex">Codex</option>
@@ -1647,24 +1652,58 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1647
1652
  Requires terminal environment (TTY)
1648
1653
  <span className="text-[8px]">— enable for agents that need a terminal to run (e.g. Codex)</span>
1649
1654
  </label>
1655
+
1656
+ {/* Env vars — was the CLI Profile feature, now lives on the agent itself */}
1657
+ {(() => {
1658
+ const envObj: Record<string, string> = ((settings.agents?.[a.id] as any)?.env) || {};
1659
+ const envStr = Object.entries(envObj).map(([k, v]) => `${k}=${v}`).join('\n');
1660
+ const tool = (settings.agents?.[a.id] as any)?.tool || (settings.agents?.[a.id] as any)?.cliType;
1661
+ const TEMPLATES: Record<string, string> = {
1662
+ claude: 'ANTHROPIC_AUTH_TOKEN=\nANTHROPIC_BASE_URL=\nANTHROPIC_SMALL_FAST_MODEL=\nCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=true\nDISABLE_TELEMETRY=true\nDISABLE_ERROR_REPORTING=true\nDISABLE_AUTOUPDATER=true\nDISABLE_NON_ESSENTIAL_MODEL_CALLS=true',
1663
+ 'claude-code': 'ANTHROPIC_AUTH_TOKEN=\nANTHROPIC_BASE_URL=\nANTHROPIC_SMALL_FAST_MODEL=\nCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=true\nDISABLE_TELEMETRY=true\nDISABLE_ERROR_REPORTING=true\nDISABLE_AUTOUPDATER=true\nDISABLE_NON_ESSENTIAL_MODEL_CALLS=true',
1664
+ codex: 'OPENAI_API_KEY=\nOPENAI_BASE_URL=',
1665
+ aider: 'ANTHROPIC_API_KEY=\nOPENAI_API_KEY=',
1666
+ };
1667
+ const tpl = tool ? TEMPLATES[tool] : '';
1668
+ return (
1669
+ <div>
1670
+ <div className="flex items-center gap-2">
1671
+ <label className="text-[9px] text-[var(--text-secondary)]">Environment Variables <span className="text-[8px]">(KEY=VALUE per line, applied to this agent's spawned process)</span></label>
1672
+ {tpl && (
1673
+ <button onClick={() => {
1674
+ const merged: Record<string, string> = {};
1675
+ for (const line of tpl.split('\n')) {
1676
+ const eq = line.indexOf('=');
1677
+ if (eq > 0) merged[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
1678
+ }
1679
+ Object.assign(merged, envObj); // existing values win
1680
+ setSettings({ ...settings, agents: { ...settings.agents, [a.id]: { ...(settings.agents?.[a.id] || {}), env: merged } } });
1681
+ }} className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)] hover:bg-[var(--accent)]/20">
1682
+ Fill {tool} template
1683
+ </button>
1684
+ )}
1685
+ </div>
1686
+ <textarea
1687
+ value={envStr}
1688
+ onChange={e => {
1689
+ const next: Record<string, string> = {};
1690
+ for (const line of e.target.value.split('\n')) {
1691
+ const eq = line.indexOf('=');
1692
+ if (eq > 0) next[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
1693
+ }
1694
+ setSettings({ ...settings, agents: { ...settings.agents, [a.id]: { ...(settings.agents?.[a.id] || {}), env: Object.keys(next).length > 0 ? next : undefined } } });
1695
+ }}
1696
+ rows={4}
1697
+ placeholder="ANTHROPIC_AUTH_TOKEN=sk-...&#10;ANTHROPIC_BASE_URL=https://..."
1698
+ className={inputClass + ' resize-none font-mono'}
1699
+ />
1700
+ </div>
1701
+ );
1702
+ })()}
1703
+
1650
1704
  {a.id !== 'claude' && (
1651
1705
  <button onClick={() => removeAgent(a.id)} className="text-[9px] text-red-400 hover:underline">Remove Agent</button>
1652
1706
  )}
1653
-
1654
- {/* Profile selector */}
1655
- <div>
1656
- <label className="text-[9px] text-[var(--text-secondary)]">Profile <span className="text-[8px]">— select to override model, env vars, API endpoint</span></label>
1657
- <select
1658
- value={(settings.agents?.[a.id] as any)?.profile || ''}
1659
- onChange={e => setSettings({ ...settings, agents: { ...settings.agents, [a.id]: { ...(settings.agents?.[a.id] || {}), profile: e.target.value || undefined } } })}
1660
- className={inputClass}
1661
- >
1662
- <option value="">Default (no profile)</option>
1663
- {Object.entries(settings.agents || {}).filter(([, cfg]: [string, any]) => cfg.cliType || cfg.base || cfg.type === 'profile' || cfg.type === 'api').map(([pid, cfg]: [string, any]) => (
1664
- <option key={pid} value={pid}>{cfg.name || pid}{cfg.model ? ` (${cfg.model})` : ''}</option>
1665
- ))}
1666
- </select>
1667
- </div>
1668
1707
  </div>
1669
1708
  )}
1670
1709
  </div>
@@ -1738,54 +1777,34 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1738
1777
  </div>
1739
1778
  )}
1740
1779
 
1741
- {/* ── Profiles Section ── */}
1780
+ {/* ── API Profiles Section ── */}
1742
1781
  <div className="mt-4 pt-3 border-t border-[var(--border)]">
1743
1782
  <div className="flex items-center gap-2 mb-2">
1744
- <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">Profiles</label>
1745
- <span className="text-[8px] text-[var(--text-secondary)]">Shared across workspace and terminal override model, env vars, API endpoint</span>
1783
+ <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">API Profiles</label>
1784
+ <span className="text-[8px] text-[var(--text-secondary)]">HTTP endpoints used by chat / web / Telegram (separate from CLI agents above)</span>
1746
1785
  </div>
1747
1786
 
1748
- {/* All profiles (CLI + API). CLI profile marker is `cliType`
1749
- (new) or `base` (legacy); API profile is `type: 'api'`. */}
1750
- {Object.entries(settings.agents || {}).filter(([, cfg]: [string, any]) => cfg.cliType || cfg.base || cfg.type === 'api').map(([id, cfg]: [string, any]) => (
1751
- <ProfileRow key={id} id={id} cfg={cfg} inputClass={inputClass}
1752
- onUpdate={(updated) => setSettings({ ...settings, agents: { ...settings.agents, [id]: updated } })}
1787
+ {Object.entries((settings as any).apiProfiles || {}).map(([id, cfg]: [string, any]) => (
1788
+ <ProfileRow key={id} id={id} cfg={cfg} inputClass={inputClass} isApi
1789
+ onUpdate={(updated) => setSettings({ ...settings, apiProfiles: { ...((settings as any).apiProfiles || {}), [id]: updated } })}
1753
1790
  onDelete={() => {
1754
- const updated = { ...settings.agents };
1791
+ const updated = { ...((settings as any).apiProfiles || {}) };
1755
1792
  delete updated[id];
1756
- setSettings({ ...settings, agents: updated });
1793
+ setSettings({ ...settings, apiProfiles: updated });
1757
1794
  }}
1758
1795
  />
1759
1796
  ))}
1760
1797
 
1761
1798
  <div className="flex gap-2 mt-1">
1762
- {/* Functional setSettings — non-functional form captures a stale
1763
- `settings` snapshot, which clobbers any agent that was saved
1764
- between this component's last render and the Add click
1765
- (debouncedSave for the Agents section fires 1s later, so the
1766
- window is real). Also: settings.agents is a single map keyed
1767
- by id — agents + CLI profiles + API profiles share it — so
1768
- reusing an id silently overwrites. Block dups with an alert. */}
1769
- <AddProfileForm type="cli" baseAgents={agents.filter(a => !a.isProfile && a.detected)} onAdd={(id, cfg) => {
1770
- setSettings((prev: any) => {
1771
- if (prev.agents?.[id]) {
1772
- const existing = prev.agents[id] as any;
1773
- const kind = existing.type === 'api' ? 'API profile' : existing.cliType || existing.base ? 'CLI profile' : 'agent';
1774
- alert(`id "${id}" is already in use by a ${kind}. Pick a different id.`);
1775
- return prev;
1776
- }
1777
- return { ...prev, agents: { ...(prev.agents || {}), [id]: cfg } };
1778
- });
1779
- }} />
1799
+ {/* Functional setSettings — captures stale snapshot otherwise (debouncedSave window is real). */}
1780
1800
  <AddProfileForm type="api" baseAgents={[]} onAdd={(id, cfg) => {
1781
1801
  setSettings((prev: any) => {
1782
- if (prev.agents?.[id]) {
1783
- const existing = prev.agents[id] as any;
1784
- const kind = existing.type === 'api' ? 'API profile' : existing.cliType || existing.base ? 'CLI profile' : 'agent';
1785
- alert(`id "${id}" is already in use by a ${kind}. Pick a different id.`);
1802
+ if ((prev.apiProfiles || {})[id]) {
1803
+ alert(`API profile id "${id}" already exists. Pick a different id.`);
1786
1804
  return prev;
1787
1805
  }
1788
- return { ...prev, agents: { ...(prev.agents || {}), [id]: cfg } };
1806
+ const { type, ...rest } = cfg; // drop legacy type:'api' field; not in new shape
1807
+ return { ...prev, apiProfiles: { ...(prev.apiProfiles || {}), [id]: { ...rest, enabled: true } } };
1789
1808
  });
1790
1809
  }} />
1791
1810
  </div>
@@ -1953,8 +1972,8 @@ function TemperSection({ settings, setSettings, secretStatus, setEditingSecret }
1953
1972
  // Telegram /chat) picks the API profile listed here. The user can override
1954
1973
  // per-session by setting Session.provider to a profile id.
1955
1974
  function ChatAgentSelect({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
1956
- const apiProfiles = Object.entries(settings.agents || {})
1957
- .filter(([, a]: [string, any]) => a && a.type === 'api' && a.enabled !== false);
1975
+ const apiProfiles = Object.entries((settings as any).apiProfiles || {})
1976
+ .filter(([, a]: [string, any]) => a && a.enabled !== false);
1958
1977
 
1959
1978
  return (
1960
1979
  <div className="mt-4 pt-3 border-t border-[var(--border)]">
@@ -1974,7 +1993,7 @@ function ChatAgentSelect({ settings, setSettings }: { settings: any; setSettings
1974
1993
  </div>
1975
1994
  {apiProfiles.length === 0 ? (
1976
1995
  <div className="text-[9px] text-[var(--text-secondary)] mt-1.5">
1977
- No API profile yet. Add one in the Profiles section above (type: API). LiteLLM proxies are supported via the Base URL field.
1996
+ No API profile yet. Add one in the API Profiles section above. LiteLLM proxies are supported via the Base URL field.
1978
1997
  </div>
1979
1998
  ) : (
1980
1999
  <div className="text-[9px] text-[var(--text-secondary)] mt-1.5">
@@ -72,7 +72,12 @@ export function createClaudeAdapter(config: AgentConfig): AgentAdapter {
72
72
 
73
73
  args.push(opts.prompt);
74
74
 
75
- return { cmd: resolved.cmd, args };
75
+ // Forward agent-level env (e.g. ANTHROPIC_AUTH_TOKEN/BASE_URL on a
76
+ // user-named CLI agent that wraps claude). task-manager merges this
77
+ // into the child env, letting one agent point claude at a custom
78
+ // endpoint without touching the user's global ~/.claude config.
79
+ const env = (config as any).env as Record<string, string> | undefined;
80
+ return { cmd: resolved.cmd, args, ...(env && Object.keys(env).length ? { env } : {}) };
76
81
  },
77
82
 
78
83
  buildTerminalCommand(opts) {
@@ -31,7 +31,8 @@ export function createGenericAdapter(config: AgentConfig): AgentAdapter {
31
31
  args.push(...opts.extraFlags);
32
32
  }
33
33
 
34
- return { cmd: config.path, args };
34
+ const env = (config as any).env as Record<string, string> | undefined;
35
+ return { cmd: config.path, args, ...(env && Object.keys(env).length ? { env } : {}) };
35
36
  },
36
37
 
37
38
  buildTerminalCommand(opts) {
@@ -73,35 +73,39 @@ export function listAgents(): AgentConfig[] {
73
73
  agents.push({ ...aider, enabled: aiderConfig?.enabled !== false, detected: true, skipPermissionsFlag: aiderConfig?.skipPermissionsFlag || '--yes', cliType: 'aider' } as any);
74
74
  }
75
75
 
76
- // Custom agents + profiles from settings
76
+ // User-named CLI agents from settings (post-migration shape: tool field)
77
77
  if (settings.agents) {
78
78
  for (const [id, cfg] of Object.entries(settings.agents)) {
79
- if (['claude', 'codex', 'aider'].includes(id)) continue;
80
-
81
- // API profile no CLI detection needed. Tagged with
82
- // backendType='api' so CLI-only dropdowns (Terminal launcher,
83
- // NewTaskModal etc.) can filter them out via
84
- // `agents.filter(a => a.backendType !== 'api')`. WorkspaceView's
85
- // API mode keeps them visible. Chat backend doesn't go through
86
- // this path at all — it reads settings.agents directly.
87
- if (cfg.type === 'api') {
79
+ if (['claude', 'codex', 'aider'].includes(id)) continue; // builtins handled above
80
+
81
+ // Post-migration: tool field identifies which CLI to wrap.
82
+ // Find the matching builtin we already detected and clone its
83
+ // adapter shape, overriding model / env / etc. from this entry.
84
+ if (cfg.tool) {
85
+ const baseAgent = agents.find(a => a.id === cfg.tool);
88
86
  agents.push({
87
+ ...(baseAgent || { type: 'generic' as const, capabilities: { supportsResume: false, supportsStreamJson: false, supportsModel: false, supportsSkipPermissions: false, hasSessionFiles: false, requiresTTY: false } }),
89
88
  id,
90
89
  name: cfg.name || id,
91
- path: '',
90
+ path: baseAgent?.path || '',
92
91
  enabled: cfg.enabled !== false,
93
- type: 'generic' as const,
94
- capabilities: { supportsResume: false, supportsStreamJson: false, supportsModel: false, supportsSkipPermissions: false, hasSessionFiles: false, requiresTTY: false },
95
92
  isProfile: true,
96
- backendType: 'api',
97
- provider: cfg.provider,
98
- model: cfg.model,
99
- apiKey: cfg.apiKey,
93
+ backendType: 'cli',
94
+ model: cfg.model || cfg.models?.task,
95
+ skipPermissionsFlag: cfg.skipPermissionsFlag || baseAgent?.skipPermissionsFlag,
96
+ env: cfg.env,
97
+ cliType: (baseAgent as any)?.cliType || 'generic',
100
98
  } as any);
101
99
  continue;
102
100
  }
103
101
 
104
- // CLI profile (has base) — inherit from base agent
102
+ // === Legacy fallback (pre-migration / un-migrated) ===
103
+ // type: 'api' — already moved to settings.apiProfiles by migration,
104
+ // but keep this branch for the migration window in case someone hits
105
+ // it before migrateAgentsFlatten runs. After 1-2 versions, remove.
106
+ if (cfg.type === 'api') continue; // silently skip — apiProfiles is canonical now
107
+
108
+ // CLI profile via base (legacy) — same path as tool branch above
105
109
  if (cfg.base) {
106
110
  const baseAgent = agents.find(a => a.id === cfg.base);
107
111
  agents.push({
@@ -121,7 +125,7 @@ export function listAgents(): AgentConfig[] {
121
125
  continue;
122
126
  }
123
127
 
124
- // Custom agent (not a profile) detect binary
128
+ // Custom agent — explicit path
125
129
  if (!cfg.path) continue;
126
130
  const flags = cfg.taskFlags ? cfg.taskFlags.split(/\s+/).filter(Boolean) : cfg.flags;
127
131
  const detected = detectAgent(id, cfg.name || id, cfg.path, flags);
@@ -0,0 +1,159 @@
1
+ /**
2
+ * One-time migration: agents flat dict → agents (CLI) + apiProfiles (API).
3
+ *
4
+ * Pre-v0.9.20 `settings.agents` held both CLI agent configs (claude /
5
+ * codex / aider / user-named "CLI profiles" with `base:`) AND HTTP API
6
+ * profiles (`type: 'api'`). Two distinct concepts crammed into one
7
+ * dict with field overlap → the new-install pipeline-uses-API-model
8
+ * bug (see todo_pipeline_model_resolution memory).
9
+ *
10
+ * Migration walks settings.agents once on startup:
11
+ * - `type: 'api'` entries → moved to settings.apiProfiles
12
+ * - CLI entries: best-effort flatten `base` / `cliType` → `tool`
13
+ * (if neither can be inferred, entry stays put; UI will skip it
14
+ * and the user re-adds manually)
15
+ * - defaultAgent / chatAgent get validated against the new dicts and
16
+ * downgraded to safe defaults if they point to the wrong kind
17
+ *
18
+ * Before writing anything we drop a backup at
19
+ * `<dataDir>/settings.backup.agents-<ts>.yaml` containing just the
20
+ * agent-related slices of settings.yaml, so a botched migration can be
21
+ * rolled back by hand.
22
+ *
23
+ * Idempotent: once the deprecated fields are gone, the next call is a
24
+ * no-op (returns false, no save).
25
+ */
26
+
27
+ import { writeFileSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import YAML from 'yaml';
30
+ import { getDataDir } from '../dirs';
31
+ import type { Settings } from '../settings';
32
+
33
+ const BUILTIN_TOOLS = new Set(['claude', 'codex', 'aider', 'opencode']);
34
+
35
+ /** Returns true if anything was mutated (caller should save). */
36
+ export function migrateAgentsFlatten(settings: Settings): boolean {
37
+ const needsMigration = detectsLegacyShape(settings);
38
+ if (!needsMigration) return false;
39
+
40
+ writeBackup(settings);
41
+
42
+ let mutated = false;
43
+ (settings as any).apiProfiles ??= {};
44
+
45
+ for (const [id, raw] of Object.entries(settings.agents ?? {}) as [string, any][]) {
46
+ // === 必须 — type='api' 搬到 apiProfiles ===
47
+ if (raw.type === 'api') {
48
+ (settings as any).apiProfiles[id] = {
49
+ name: raw.name ?? id,
50
+ enabled: raw.enabled !== false,
51
+ provider: mapProvider(raw.provider),
52
+ model: raw.model ?? '',
53
+ apiKey: raw.apiKey ?? '',
54
+ baseUrl: raw.baseUrl ?? '',
55
+ };
56
+ delete settings.agents[id];
57
+ mutated = true;
58
+ continue;
59
+ }
60
+
61
+ // === Best-effort — 推断 tool 字段 ===
62
+ if (!raw.tool) {
63
+ if (raw.base && BUILTIN_TOOLS.has(raw.base)) raw.tool = raw.base;
64
+ else if (raw.cliType === 'claude-code') raw.tool = 'claude';
65
+ else if (raw.cliType && BUILTIN_TOOLS.has(raw.cliType)) raw.tool = raw.cliType;
66
+ else if (BUILTIN_TOOLS.has(id)) raw.tool = id;
67
+ if (raw.tool) mutated = true;
68
+ // 推断不出 → 留原样,listAgents 会跳过,用户接受手动重加
69
+ }
70
+
71
+ // 清掉已过期的字段(只有迁移产生过 tool 才删 base/cliType,避免无 tool 的孤儿丢标识)
72
+ if (raw.tool) {
73
+ if (raw.base !== undefined) { delete raw.base; mutated = true; }
74
+ if (raw.cliType !== undefined) { delete raw.cliType; mutated = true; }
75
+ }
76
+ if (raw.type !== undefined) { delete raw.type; mutated = true; }
77
+
78
+ // 老嵌套 models.task → 扁平 model(如果 model 字段空)
79
+ if (!raw.model && raw.models?.task && raw.models.task !== 'default') {
80
+ raw.model = raw.models.task;
81
+ mutated = true;
82
+ }
83
+ }
84
+
85
+ // === 兜底校验 defaultAgent / chatAgent ===
86
+ if (settings.defaultAgent && !settings.agents?.[settings.defaultAgent]) {
87
+ const wasApi = (settings as any).apiProfiles[settings.defaultAgent];
88
+ console.warn(
89
+ `[migrate-agents] defaultAgent="${settings.defaultAgent}" ` +
90
+ (wasApi ? 'is an API profile, not allowed for tasks. ' : 'not found. ') +
91
+ `Falling back to 'claude'.`
92
+ );
93
+ settings.defaultAgent = 'claude';
94
+ mutated = true;
95
+ }
96
+ if (settings.chatAgent && !(settings as any).apiProfiles[settings.chatAgent]) {
97
+ const wasCli = settings.agents?.[settings.chatAgent];
98
+ console.warn(
99
+ `[migrate-agents] chatAgent="${settings.chatAgent}" ` +
100
+ (wasCli ? 'is a CLI agent, not allowed for chat. ' : 'not found. ') +
101
+ `Falling back.`
102
+ );
103
+ const firstApi = Object.entries((settings as any).apiProfiles)
104
+ .find(([, p]: any) => p.enabled)?.[0];
105
+ settings.chatAgent = firstApi ?? '';
106
+ mutated = true;
107
+ }
108
+
109
+ if (mutated) {
110
+ console.log('[migrate-agents] migrated settings.agents → agents + apiProfiles');
111
+ }
112
+ return mutated;
113
+ }
114
+
115
+ function detectsLegacyShape(settings: Settings): boolean {
116
+ for (const cfg of Object.values(settings.agents ?? {}) as any[]) {
117
+ if (cfg.type === 'api') return true;
118
+ if (cfg.base !== undefined) return true;
119
+ if (cfg.cliType !== undefined) return true;
120
+ // 没有 tool + 是 builtin id → 也算需要迁移(补 tool 字段)
121
+ }
122
+ // 内置 agent 没有 tool 字段也需要补
123
+ for (const [id, cfg] of Object.entries(settings.agents ?? {}) as [string, any][]) {
124
+ if (!cfg.tool && BUILTIN_TOOLS.has(id)) return true;
125
+ }
126
+ // defaultAgent 指向不存在的 entry 也需要兜底
127
+ if (settings.defaultAgent && !settings.agents?.[settings.defaultAgent]) {
128
+ return true;
129
+ }
130
+ return false;
131
+ }
132
+
133
+ function writeBackup(settings: Settings): void {
134
+ try {
135
+ const ts = Date.now();
136
+ const backupPath = join(getDataDir(), `settings.backup.agents-${ts}.yaml`);
137
+ const slice = {
138
+ _backup_note: 'Pre-migration snapshot of agent-related settings. Safe to delete after verifying migration worked.',
139
+ _backup_ts: new Date(ts).toISOString(),
140
+ agents: settings.agents,
141
+ defaultAgent: settings.defaultAgent,
142
+ chatAgent: settings.chatAgent,
143
+ telegramAgent: settings.telegramAgent,
144
+ docsAgent: settings.docsAgent,
145
+ telegramModel: settings.telegramModel,
146
+ taskModel: settings.taskModel,
147
+ pipelineModel: settings.pipelineModel,
148
+ };
149
+ writeFileSync(backupPath, YAML.stringify(slice), 'utf-8');
150
+ console.log(`[migrate-agents] backup → ${backupPath}`);
151
+ } catch (err) {
152
+ console.warn('[migrate-agents] backup write failed (continuing anyway):', err instanceof Error ? err.message : err);
153
+ }
154
+ }
155
+
156
+ function mapProvider(p?: string): 'anthropic' | 'openai-compatible' {
157
+ if (!p) return 'openai-compatible';
158
+ return /anthropic/i.test(p) ? 'anthropic' : 'openai-compatible';
159
+ }
@@ -151,36 +151,34 @@ export function pickApiKey(profile: { apiKey?: string; env?: Record<string, stri
151
151
 
152
152
  export function resolveProvider(sessionProvider: string | null, sessionModel: string | null): ProviderResolution | { error: string } {
153
153
  const settings = loadSettings();
154
- const agents = settings.agents || {};
154
+ const profiles = settings.apiProfiles || {};
155
155
 
156
- // Candidate API profiles: type=='api', enabled, with a usable apiKey (direct or via env).
157
- const candidates = Object.entries(agents).filter(([_, a]) => {
158
- if (!a || a.type !== 'api' || a.enabled === false) return false;
159
- const adapter = inferAdapter(a.provider);
160
- return pickApiKey(a, adapter).length > 0;
156
+ // Candidate API profiles: enabled + has apiKey.
157
+ const candidates = Object.entries(profiles).filter(([_, p]) => {
158
+ if (!p || p.enabled === false) return false;
159
+ return (p.apiKey || '').length > 0;
161
160
  });
162
161
  if (candidates.length === 0) {
163
- return { error: 'No API agent profile with an API key. Add one under Settings → Agents (type: API) and pick it as the chat agent.' };
162
+ return { error: 'No API profile with an API key. Add one under Settings → API Profiles and pick it as the chat profile.' };
164
163
  }
165
164
 
166
- // Preferred: session.provider (an agent profile id), then settings.chatAgent, else first candidate.
165
+ // Preferred: session.provider (an apiProfile id), then settings.chatAgent, else first candidate.
167
166
  const preferredId = sessionProvider || settings.chatAgent || candidates[0]![0];
168
- const profile = agents[preferredId] && agents[preferredId].type === 'api'
169
- ? agents[preferredId]
167
+ const profile = profiles[preferredId] && profiles[preferredId].enabled !== false
168
+ ? profiles[preferredId]
170
169
  : candidates[0]![1];
171
- const name = agents[preferredId]?.type === 'api' ? preferredId : candidates[0]![0];
170
+ const name = (profiles[preferredId] && profiles[preferredId].enabled !== false) ? preferredId : candidates[0]![0];
172
171
 
173
- const adapter = inferAdapter(profile.provider);
174
- const apiKey = pickApiKey(profile, adapter);
175
- if (!apiKey) return { error: `Agent profile "${name}" has no apiKey (direct or via env).` };
172
+ const adapter: 'anthropic' | 'openai' = profile.provider === 'anthropic' ? 'anthropic' : 'openai';
173
+ if (!profile.apiKey) return { error: `API profile "${name}" has no apiKey.` };
176
174
 
177
175
  const model = sessionModel || profile.model || (adapter === 'anthropic' ? 'claude-sonnet-4-6' : 'gpt-4o-mini');
178
176
 
179
177
  return {
180
178
  name,
181
179
  type: adapter,
182
- apiKey,
183
- baseUrl: pickBaseUrl(profile, adapter),
180
+ apiKey: profile.apiKey,
181
+ baseUrl: profile.baseUrl || defaultBaseUrl(profile.provider),
184
182
  model,
185
183
  };
186
184
  }
package/lib/init.ts CHANGED
@@ -94,6 +94,15 @@ export function ensureInitialized() {
94
94
  catch (e) { console.warn('[init] ensureScratchProject failed:', (e as Error).message); }
95
95
  });
96
96
  time('migrateSecrets', migrateSecrets);
97
+ time('migrateAgentsFlatten', () => {
98
+ try {
99
+ const { migrateAgentsFlatten } = require('./agents/migrate');
100
+ const settings = loadSettings();
101
+ if (migrateAgentsFlatten(settings)) {
102
+ saveSettings(settings);
103
+ }
104
+ } catch (e) { console.warn('[init] migrateAgentsFlatten failed:', (e as Error).message); }
105
+ });
97
106
  time('migratePluginSecrets', () => {
98
107
  try {
99
108
  const { migratePluginSecrets } = require('./plugins/registry');
package/lib/settings.ts CHANGED
@@ -7,29 +7,62 @@ import { getDataDir } from './dirs';
7
7
  const DATA_DIR = getDataDir();
8
8
  const SETTINGS_FILE = join(DATA_DIR, 'settings.yaml');
9
9
 
10
+ /**
11
+ * AgentEntry — a CLI agent configuration (one CLI binary + its env / model).
12
+ *
13
+ * As of v0.9.20 the agents dict is CLI-only; API endpoints live in the
14
+ * sibling `apiProfiles` dict. The `tool` field is the discriminator
15
+ * (which CLI binary to invoke). Multiple entries with the same `tool`
16
+ * are allowed (used to be "CLI profiles" — now just additional named
17
+ * agents with their own env).
18
+ *
19
+ * Deprecated fields (`base` / `cliType` / `type` / `provider` / `apiKey`
20
+ * / `baseUrl`) are migrated away by migrateAgentsFlatten on first load
21
+ * and removed from the schema in a later release.
22
+ */
10
23
  export interface AgentEntry {
11
- // Base agent fields (for detected agents like claude, codex, aider)
24
+ tool?: 'claude' | 'codex' | 'aider' | 'opencode'; // which CLI binary
12
25
  path?: string; name?: string; enabled?: boolean;
13
26
  flags?: string[]; taskFlags?: string; interactiveCmd?: string; resumeFlag?: string; outputFormat?: string;
14
27
  models?: { terminal?: string; task?: string; telegram?: string; help?: string; mobile?: string };
15
28
  skipPermissionsFlag?: string;
16
29
  requiresTTY?: boolean;
17
- // Profile fields (for profiles that extend a base agent)
18
- base?: string; // base agent ID (e.g., 'claude') — makes this a profile
19
- // API profile fields
20
- type?: 'cli' | 'api'; // 'api' = API mode, default = 'cli'
21
- provider?: string; // API provider label (e.g., 'anthropic', 'openai', 'litellm', 'grok', 'google')
22
- model?: string; // model override (for both CLI and API profiles)
23
- apiKey?: string; // per-profile API key (encrypted)
24
- /**
25
- * Base URL override for API profiles. Use this for LiteLLM / Azure /
26
- * self-hosted proxies. Empty = default for the provider's protocol.
27
- * For CLI profiles, set base URL via `env` (ANTHROPIC_BASE_URL etc.).
28
- */
29
- baseUrl?: string;
30
+ model?: string; // flat model override
30
31
  env?: Record<string, string>; // environment variables injected when spawning CLI
31
- cliType?: 'claude-code' | 'codex' | 'aider' | 'generic'; // CLI tool type — determines session support, resume flags, etc.
32
- profile?: string; // linked profile ID overrides model, env, etc. when launching
32
+
33
+ // === Deprecated (kept for migration compat; removed in a later release) ===
34
+ /** @deprecated migrated to `tool` */
35
+ base?: string;
36
+ /** @deprecated migrated to `tool` */
37
+ cliType?: 'claude-code' | 'codex' | 'aider' | 'generic';
38
+ /** @deprecated 'api' entries migrated to apiProfiles dict */
39
+ type?: 'cli' | 'api';
40
+ /** @deprecated moved to apiProfiles entry */
41
+ provider?: string;
42
+ /** @deprecated moved to apiProfiles entry */
43
+ apiKey?: string;
44
+ /** @deprecated moved to apiProfiles entry */
45
+ baseUrl?: string;
46
+ /** @deprecated unused */
47
+ profile?: string;
48
+ }
49
+
50
+ /**
51
+ * ApiProfile — a direct HTTP LLM endpoint (anthropic / openai-compat).
52
+ *
53
+ * Used by chat / summarizer / telegram chat surface — anything that
54
+ * needs a single-shot or streaming LLM call without a subprocess.
55
+ * Stored in `settings.apiProfiles`, not `settings.agents`. The two
56
+ * dicts never cross: tasks/pipelines/terminal pick from agents,
57
+ * chat/summarizer pick from apiProfiles. UI enforces the boundary.
58
+ */
59
+ export interface ApiProfile {
60
+ name?: string;
61
+ enabled?: boolean;
62
+ provider: 'anthropic' | 'openai-compatible';
63
+ model: string;
64
+ apiKey: string;
65
+ baseUrl?: string;
33
66
  }
34
67
 
35
68
  /**
@@ -108,6 +141,9 @@ export interface Settings {
108
141
  */
109
142
  memoryBackend: 'auto' | 'local' | 'temper';
110
143
  agents: Record<string, AgentEntry>;
144
+ /** API endpoint profiles — chat / summarizer / telegram-chat target.
145
+ * Separate from `agents` (CLI subprocess) so type guard is structural. */
146
+ apiProfiles: Record<string, ApiProfile>;
111
147
  /** Extra MCP servers merged into each project's .mcp.json (chrome-devtools-mcp etc.) */
112
148
  mcpServers: Record<string, McpServerConfig>;
113
149
  /**
@@ -177,6 +213,7 @@ const defaults: Settings = {
177
213
  temperNamespace: '',
178
214
  memoryBackend: 'auto',
179
215
  agents: {},
216
+ apiProfiles: {},
180
217
  mcpServers: {},
181
218
  timezone: '',
182
219
  smtpHost: '',
@@ -191,25 +228,35 @@ const defaults: Settings = {
191
228
  pipelineTmpGcIntervalHours: 6,
192
229
  };
193
230
 
194
- /** Decrypt nested apiKey fields in agent profiles */
231
+ /** Decrypt nested apiKey fields in agents (legacy migration window) +
232
+ * apiProfiles (current canonical location). */
195
233
  function decryptNestedSecrets(settings: Settings): void {
234
+ // Legacy: agent entries may still carry apiKey until migration runs
196
235
  if (settings.agents) {
197
236
  for (const [id, a] of Object.entries(settings.agents)) {
198
237
  if (a.apiKey && isEncrypted(a.apiKey)) {
199
238
  a.apiKey = decryptSecret(a.apiKey);
200
239
  }
201
- // Defensive: a past bug let the masked placeholder get encrypted
202
- // and written back. Clear it so callers see "no key" instead of
203
- // trying to send '••••••••' as a Bearer header.
204
240
  if (a.apiKey && /^•+$/.test(a.apiKey)) {
205
241
  console.warn(`[settings] agent '${id}' has a placeholder apiKey — clearing. Re-enter it via Settings.`);
206
242
  a.apiKey = '';
207
243
  }
208
244
  }
209
245
  }
246
+ if (settings.apiProfiles) {
247
+ for (const [id, p] of Object.entries(settings.apiProfiles)) {
248
+ if (p.apiKey && isEncrypted(p.apiKey)) {
249
+ p.apiKey = decryptSecret(p.apiKey);
250
+ }
251
+ if (p.apiKey && /^•+$/.test(p.apiKey)) {
252
+ console.warn(`[settings] apiProfile '${id}' has a placeholder apiKey — clearing. Re-enter it via Settings.`);
253
+ p.apiKey = '';
254
+ }
255
+ }
256
+ }
210
257
  }
211
258
 
212
- /** Encrypt nested apiKey fields in agent profiles */
259
+ /** Encrypt nested apiKey fields */
213
260
  function encryptNestedSecrets(settings: Settings): void {
214
261
  if (settings.agents) {
215
262
  for (const a of Object.values(settings.agents)) {
@@ -218,6 +265,13 @@ function encryptNestedSecrets(settings: Settings): void {
218
265
  }
219
266
  }
220
267
  }
268
+ if (settings.apiProfiles) {
269
+ for (const p of Object.values(settings.apiProfiles)) {
270
+ if (p.apiKey && !isEncrypted(p.apiKey)) {
271
+ p.apiKey = encryptSecret(p.apiKey);
272
+ }
273
+ }
274
+ }
221
275
  }
222
276
 
223
277
  /** Load settings with secrets decrypted (for internal use) */
@@ -248,7 +302,7 @@ export function loadSettingsMasked(): Settings & { _secretStatus: Record<string,
248
302
  status[field] = !!settings[field];
249
303
  settings[field] = settings[field] ? '••••••••' : '';
250
304
  }
251
- // Mask nested apiKeys (agent profiles only)
305
+ // Mask nested apiKeys — agents (legacy) + apiProfiles (canonical)
252
306
  if (settings.agents) {
253
307
  for (const [name, a] of Object.entries(settings.agents)) {
254
308
  if (a.apiKey) {
@@ -257,6 +311,14 @@ export function loadSettingsMasked(): Settings & { _secretStatus: Record<string,
257
311
  }
258
312
  }
259
313
  }
314
+ if (settings.apiProfiles) {
315
+ for (const [name, p] of Object.entries(settings.apiProfiles)) {
316
+ if (p.apiKey) {
317
+ status[`apiProfiles.${name}.apiKey`] = true;
318
+ p.apiKey = '••••••••';
319
+ }
320
+ }
321
+ }
260
322
  return { ...settings, _secretStatus: status };
261
323
  }
262
324
 
@@ -177,6 +177,11 @@ const FORGE_HOOK_MARKER = '# forge-stop-hook';
177
177
  * When Claude Code finishes a turn, the hook notifies Forge via HTTP.
178
178
  * Preserves existing user hooks. Creates backup before modifying.
179
179
  */
180
+ // Auto-prune leaves this many timestamped backups behind. `forge-backup-manual`
181
+ // (or anything not matching the YYYYMMDD-HHMM suffix) is left alone.
182
+ const FORGE_BACKUP_KEEP = 5;
183
+ const FORGE_BACKUP_RE = /^settings\.json\.forge-backup-\d{8}-\d{4}$/;
184
+
180
185
  function installForgeStopHook(forgePort: number): void {
181
186
  const settingsFile = join(homedir(), '.claude', 'settings.json');
182
187
  const now = new Date();
@@ -191,11 +196,11 @@ function installForgeStopHook(forgePort: number): void {
191
196
 
192
197
  try {
193
198
  let settings: any = {};
199
+ let raw = '';
194
200
  if (existsSync(settingsFile)) {
195
- const raw = readFileSync(settingsFile, 'utf-8');
201
+ raw = readFileSync(settingsFile, 'utf-8');
196
202
  settings = JSON.parse(raw);
197
203
 
198
- // Check if hook already installed
199
204
  // Remove old forge hook if present (will re-add with latest version)
200
205
  if (settings.hooks?.Stop) {
201
206
  settings.hooks.Stop = settings.hooks.Stop.filter((h: any) => {
@@ -204,9 +209,6 @@ function installForgeStopHook(forgePort: number): void {
204
209
  return true;
205
210
  });
206
211
  }
207
-
208
- // Backup before modifying
209
- writeFileSync(backupFile, raw, 'utf-8');
210
212
  }
211
213
 
212
214
  if (!settings.hooks) settings.hooks = {};
@@ -222,14 +224,32 @@ function installForgeStopHook(forgePort: number): void {
222
224
  }],
223
225
  });
224
226
 
227
+ const next = JSON.stringify(settings, null, 2) + '\n';
228
+ if (next === raw) return; // no-op — settings already had the current hook
229
+
225
230
  mkdirSync(join(homedir(), '.claude'), { recursive: true });
226
- writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
231
+ if (raw) writeFileSync(backupFile, raw, 'utf-8');
232
+ writeFileSync(settingsFile, next, 'utf-8');
233
+ pruneForgeBackups();
227
234
  console.log('[skills] Installed Forge Stop hook in ~/.claude/settings.json');
228
235
  } catch (err: any) {
229
236
  console.error('[skills] Failed to install Stop hook:', err.message);
230
237
  }
231
238
  }
232
239
 
240
+ function pruneForgeBackups(): void {
241
+ try {
242
+ const dir = join(homedir(), '.claude');
243
+ const stamped = readdirSync(dir)
244
+ .filter(f => FORGE_BACKUP_RE.test(f))
245
+ .sort(); // lexicographic == chronological (YYYYMMDD-HHMM)
246
+ const stale = stamped.slice(0, -FORGE_BACKUP_KEEP);
247
+ for (const f of stale) {
248
+ try { unlinkSync(join(dir, f)); } catch {}
249
+ }
250
+ } catch {}
251
+ }
252
+
233
253
  /**
234
254
  * Remove Forge Stop hook from user-level settings (cleanup).
235
255
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.9.19",
3
+ "version": "0.10.3",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Migration smoke test — feed several known-shape settings through
3
+ * migrateAgentsFlatten and assert post-state. Runs offline, no real
4
+ * data needed.
5
+ *
6
+ * npx tsx scripts/test-agents-migrate.ts
7
+ */
8
+
9
+ import { migrateAgentsFlatten } from '../lib/agents/migrate';
10
+ import type { Settings } from '../lib/settings';
11
+
12
+ let failures = 0;
13
+ const fail = (msg: string) => { console.log(` ✗ ${msg}`); failures += 1; };
14
+ const pass = (msg: string) => console.log(` ✓ ${msg}`);
15
+
16
+ function baseSettings(): Settings {
17
+ return {
18
+ projectRoots: [], docRoots: [], claudePath: '', claudeHome: '',
19
+ telegramBotToken: '', telegramChatId: '',
20
+ notifyOnComplete: true, notifyOnFailure: true,
21
+ tunnelAutoStart: false, telegramTunnelPassword: '',
22
+ taskModel: 'default', pipelineModel: 'default', telegramModel: 'sonnet',
23
+ skipPermissions: false, manageClaudeConfig: true,
24
+ notificationRetentionDays: 30,
25
+ skillsRepoUrl: '', connectorsRepoUrl: '', workflowRepoUrl: '',
26
+ maxConcurrentPipelines: 5,
27
+ displayName: '', displayEmail: '', favoriteProjects: [],
28
+ defaultAgent: 'claude', telegramAgent: '', docsAgent: '', chatAgent: '',
29
+ temperUrl: '', temperKey: '', temperNamespace: '', memoryBackend: 'auto',
30
+ agents: {}, apiProfiles: {}, mcpServers: {}, timezone: '',
31
+ smtpHost: '', smtpPort: 587, smtpSecure: false, smtpUser: '', smtpPassword: '', smtpFrom: '',
32
+ pipelineTmpCleanDoneImmediate: true,
33
+ pipelineTmpKeepFailedDays: 3, pipelineTmpKeepCancelledDays: 3,
34
+ pipelineTmpGcIntervalHours: 6,
35
+ } as Settings;
36
+ }
37
+
38
+ // ── Test 1: type='api' moves to apiProfiles ──────────────────────────
39
+ {
40
+ console.log('Test 1 — API profile moves out of agents');
41
+ const s = baseSettings();
42
+ s.agents = {
43
+ claude: { enabled: true, path: '/usr/local/bin/claude' },
44
+ 'forti-api': { type: 'api', provider: 'litellm', model: 'DeepSeek-V4-Pro', apiKey: 'sk-...', baseUrl: 'https://x' } as any,
45
+ };
46
+ s.chatAgent = 'forti-api';
47
+ const mutated = migrateAgentsFlatten(s);
48
+ if (!mutated) fail('expected mutated=true');
49
+ if (s.agents['forti-api']) fail('forti-api still in agents');
50
+ else pass('forti-api removed from agents');
51
+ const p = (s as any).apiProfiles?.['forti-api'];
52
+ if (!p) fail('forti-api not in apiProfiles');
53
+ else if (p.provider !== 'openai-compatible') fail(`provider mapped wrong: ${p.provider}`);
54
+ else if (p.model !== 'DeepSeek-V4-Pro') fail('model lost');
55
+ else pass(`forti-api in apiProfiles (provider=${p.provider}, model=${p.model})`);
56
+ if (s.chatAgent !== 'forti-api') fail(`chatAgent corrupted: ${s.chatAgent}`);
57
+ else pass('chatAgent unchanged');
58
+ }
59
+
60
+ // ── Test 2: base/cliType → tool flattening ──────────────────────────
61
+ {
62
+ console.log('Test 2 — CLI profile flattens to tool field');
63
+ const s = baseSettings();
64
+ s.agents = {
65
+ claude: { enabled: true, path: '/usr/local/bin/claude' },
66
+ 'forti-coder': { base: 'claude', model: 'sonnet', env: { ANTHROPIC_AUTH_TOKEN: 'tok' } } as any,
67
+ 'codex-dev': { cliType: 'codex', env: { OPENAI_API_KEY: 'k' } } as any,
68
+ };
69
+ migrateAgentsFlatten(s);
70
+ if (s.agents['claude']?.tool !== 'claude') fail('claude builtin tool not inferred');
71
+ else pass('claude tool inferred from id');
72
+ if (s.agents['forti-coder']?.tool !== 'claude') fail(`forti-coder tool wrong: ${s.agents['forti-coder']?.tool}`);
73
+ else pass('forti-coder tool inferred from base');
74
+ if ((s.agents['forti-coder'] as any).base) fail('base field not cleaned');
75
+ else pass('base field removed');
76
+ if (s.agents['codex-dev']?.tool !== 'codex') fail(`codex-dev tool wrong: ${s.agents['codex-dev']?.tool}`);
77
+ else pass('codex-dev tool inferred from cliType');
78
+ }
79
+
80
+ // ── Test 3: defaultAgent pointing to API profile downgrades ──────────
81
+ {
82
+ console.log('Test 3 — defaultAgent → apiProfile downgrades to claude');
83
+ const s = baseSettings();
84
+ s.agents = {
85
+ claude: { enabled: true, path: '' },
86
+ 'forti-api': { type: 'api', provider: 'anthropic', model: 'm', apiKey: 'k' } as any,
87
+ };
88
+ s.defaultAgent = 'forti-api'; // wrong! API id as task default
89
+ migrateAgentsFlatten(s);
90
+ if (s.defaultAgent !== 'claude') fail(`expected 'claude', got '${s.defaultAgent}'`);
91
+ else pass('defaultAgent downgraded to claude');
92
+ }
93
+
94
+ // ── Test 4: chatAgent pointing to CLI downgrades ─────────────────────
95
+ {
96
+ console.log('Test 4 — chatAgent → CLI agent downgrades to first apiProfile');
97
+ const s = baseSettings();
98
+ s.agents = {
99
+ claude: { enabled: true, path: '' },
100
+ 'forti-coder': { base: 'claude', model: 'sonnet' } as any,
101
+ };
102
+ (s as any).apiProfiles = {
103
+ 'real-api': { provider: 'anthropic', model: 'claude-sonnet-4-6', apiKey: 'k', enabled: true },
104
+ };
105
+ s.chatAgent = 'forti-coder'; // wrong! CLI as chat default
106
+ migrateAgentsFlatten(s);
107
+ if (s.chatAgent !== 'real-api') fail(`expected 'real-api', got '${s.chatAgent}'`);
108
+ else pass('chatAgent downgraded to real-api');
109
+ }
110
+
111
+ // ── Test 5: idempotent on already-migrated shape ────────────────────
112
+ {
113
+ console.log('Test 5 — idempotent');
114
+ const s = baseSettings();
115
+ s.agents = {
116
+ claude: { tool: 'claude', enabled: true, path: '' },
117
+ 'forti-coder': { tool: 'claude', enabled: true, model: 'sonnet', env: {} },
118
+ };
119
+ (s as any).apiProfiles = {
120
+ 'forti-api': { provider: 'openai-compatible', model: 'X', apiKey: 'k', enabled: true },
121
+ };
122
+ s.defaultAgent = 'claude';
123
+ s.chatAgent = 'forti-api';
124
+ const mutated = migrateAgentsFlatten(s);
125
+ if (mutated) fail('expected no mutation on already-migrated input');
126
+ else pass('idempotent (no-op)');
127
+ }
128
+
129
+ // ── Test 6: orphan entry (no inference possible) is left alone ──────
130
+ {
131
+ console.log('Test 6 — orphan entry preserved');
132
+ const s = baseSettings();
133
+ s.agents = {
134
+ 'mystery-thing': { enabled: true, model: 'whatever' } as any,
135
+ };
136
+ migrateAgentsFlatten(s);
137
+ if (!s.agents['mystery-thing']) fail('mystery-thing got deleted');
138
+ else if ((s.agents['mystery-thing'] as any).tool) fail('mystery-thing tool inferred when it shouldn\'t');
139
+ else pass('orphan preserved without tool (UI will skip, user re-adds)');
140
+ }
141
+
142
+ console.log('');
143
+ if (failures === 0) {
144
+ console.log('✓ All migration smoke checks passed');
145
+ process.exit(0);
146
+ } else {
147
+ console.log(`✗ ${failures} check(s) failed`);
148
+ process.exit(1);
149
+ }