@aion0/forge 0.9.19 → 0.10.2
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 +4 -14
- package/app/api/agents/[id]/test/route.ts +4 -2
- package/app/api/agents/route.ts +26 -6
- package/app/api/schedules/extract/route.ts +8 -6
- package/components/SettingsModal.tsx +87 -68
- package/lib/agents/claude-adapter.ts +6 -1
- package/lib/agents/generic-adapter.ts +2 -1
- package/lib/agents/index.ts +23 -19
- package/lib/agents/migrate.ts +159 -0
- package/lib/chat/agent-loop.ts +14 -16
- package/lib/init.ts +9 -0
- package/lib/settings.ts +84 -22
- package/lib/workspace/skill-installer.ts +26 -6
- package/package.json +1 -1
- package/scripts/test-agents-migrate.ts +149 -0
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,18 +1,8 @@
|
|
|
1
|
-
# Forge v0.
|
|
1
|
+
# Forge v0.10.2
|
|
2
2
|
|
|
3
|
-
Released: 2026-05-
|
|
3
|
+
Released: 2026-05-30
|
|
4
4
|
|
|
5
|
-
## Changes since v0.
|
|
5
|
+
## Changes since v0.10.1
|
|
6
6
|
|
|
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
|
|
16
7
|
|
|
17
|
-
|
|
18
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.18...v0.9.19
|
|
8
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.1...v0.10.2
|
|
@@ -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
|
-
|
|
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) {
|
package/app/api/agents/route.ts
CHANGED
|
@@ -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
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
: include === 'api'
|
|
23
|
-
|
|
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
|
|
104
|
-
const candidates = Object.entries(
|
|
105
|
-
if (!
|
|
106
|
-
return
|
|
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
|
|
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 =
|
|
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({
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
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,
|
|
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
|
|
1452
|
-
|
|
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 =>
|
|
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-... 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)]">
|
|
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
|
-
{
|
|
1749
|
-
|
|
1750
|
-
|
|
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.
|
|
1791
|
+
const updated = { ...((settings as any).apiProfiles || {}) };
|
|
1755
1792
|
delete updated[id];
|
|
1756
|
-
setSettings({ ...settings,
|
|
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 —
|
|
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.
|
|
1783
|
-
|
|
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
|
-
|
|
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.
|
|
1957
|
-
.filter(([, a]: [string, any]) => a && a.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/lib/agents/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
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: '
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
+
}
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -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
|
|
154
|
+
const profiles = settings.apiProfiles || {};
|
|
155
155
|
|
|
156
|
-
// Candidate API profiles:
|
|
157
|
-
const candidates = Object.entries(
|
|
158
|
-
if (!
|
|
159
|
-
|
|
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
|
|
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
|
|
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 =
|
|
169
|
-
?
|
|
167
|
+
const profile = profiles[preferredId] && profiles[preferredId].enabled !== false
|
|
168
|
+
? profiles[preferredId]
|
|
170
169
|
: candidates[0]![1];
|
|
171
|
-
const name =
|
|
170
|
+
const name = (profiles[preferredId] && profiles[preferredId].enabled !== false) ? preferredId : candidates[0]![0];
|
|
172
171
|
|
|
173
|
-
const adapter =
|
|
174
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|