@aion0/forge 0.10.12 → 0.10.18
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 +3 -3
- package/app/api/public-info/[resource]/route.ts +40 -0
- package/components/ProjectDetail.tsx +1 -1
- package/components/SettingsModal.tsx +42 -33
- package/components/WebTerminal.tsx +13 -5
- package/components/WorkspaceView.tsx +5 -3
- package/lib/agents/index.ts +6 -1
- package/lib/agents/known-models.ts +75 -0
- package/lib/chat/tool-dispatcher.ts +33 -0
- package/lib/help-docs/05-pipelines.md +9 -0
- package/lib/public-info/fetch.ts +116 -0
- package/lib/public-info/types.ts +38 -0
- package/lib/public-info/use-models-registry.ts +66 -0
- package/lib/settings.ts +9 -0
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
- package/lib/__tests__/foreach-batch-yaml.test.ts +0 -33
- package/lib/__tests__/foreach-before.test.ts +0 -201
- package/lib/__tests__/foreach-parse.test.ts +0 -114
- package/lib/__tests__/foreach-snapshot.test.ts +0 -112
- package/lib/__tests__/foreach-source.test.ts +0 -105
- package/lib/__tests__/foreach-template.test.ts +0 -112
- package/lib/workspace/__tests__/state-machine.test.ts +0 -388
- package/lib/workspace/__tests__/workspace.test.ts +0 -311
- package/scripts/bench/README.md +0 -66
- package/scripts/bench/results/.gitignore +0 -2
- package/scripts/bench/run.ts +0 -635
- package/scripts/bench/tasks/01-text-utils/task.md +0 -26
- package/scripts/bench/tasks/01-text-utils/validator.sh +0 -46
- package/scripts/bench/tasks/02-pagination/setup.sh +0 -19
- package/scripts/bench/tasks/02-pagination/task.md +0 -48
- package/scripts/bench/tasks/02-pagination/validator.sh +0 -69
- package/scripts/bench/tasks/03-bug-fix/setup.sh +0 -82
- package/scripts/bench/tasks/03-bug-fix/task.md +0 -30
- package/scripts/bench/tasks/03-bug-fix/validator.sh +0 -29
- package/scripts/test-agents-migrate.ts +0 -149
- package/scripts/test-mantis.ts +0 -223
- package/scripts/test-memory-local.ts +0 -139
- package/scripts/test-memory-upsert.ts +0 -106
- package/scripts/verify-usage.ts +0 -178
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.18
|
|
2
2
|
|
|
3
3
|
Released: 2026-05-30
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.17
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
8
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.17...v0.10.18
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/public-info/<resource>?refresh=1
|
|
3
|
+
*
|
|
4
|
+
* Server-side proxy + cache for files under the public-info repo. UI
|
|
5
|
+
* never hits GitHub directly — avoids CORS / rate-limit headaches and
|
|
6
|
+
* gives us a single cache shared across all browser tabs.
|
|
7
|
+
*
|
|
8
|
+
* Supported resources (extend by adding to RESOURCE_MAP):
|
|
9
|
+
* - `models` → `models/registry.json`
|
|
10
|
+
*
|
|
11
|
+
* The UI passes the short name (`models`), this route translates to the
|
|
12
|
+
* actual path under the repo and pairs it with the right fallback.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
16
|
+
import { fetchPublicInfo } from '@/lib/public-info/fetch';
|
|
17
|
+
import { KNOWN_MODELS_FALLBACK } from '@/lib/agents/known-models';
|
|
18
|
+
|
|
19
|
+
interface ResourceSpec {
|
|
20
|
+
path: string;
|
|
21
|
+
fallback: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const RESOURCE_MAP: Record<string, ResourceSpec> = {
|
|
25
|
+
models: { path: 'models/registry.json', fallback: KNOWN_MODELS_FALLBACK },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function GET(
|
|
29
|
+
req: NextRequest,
|
|
30
|
+
{ params }: { params: Promise<{ resource: string }> },
|
|
31
|
+
): Promise<NextResponse> {
|
|
32
|
+
const { resource } = await params;
|
|
33
|
+
const spec = RESOURCE_MAP[resource];
|
|
34
|
+
if (!spec) {
|
|
35
|
+
return NextResponse.json({ error: `unknown resource: ${resource}` }, { status: 404 });
|
|
36
|
+
}
|
|
37
|
+
const forceRefresh = req.nextUrl.searchParams.get('refresh') === '1';
|
|
38
|
+
const data = await fetchPublicInfo(spec.path, spec.fallback, { forceRefresh });
|
|
39
|
+
return NextResponse.json(data);
|
|
40
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
4
|
import { FolderPicker } from './FolderPicker';
|
|
5
|
+
import { useModelsRegistry, pickerOptions } from '@/lib/public-info/use-models-registry';
|
|
5
6
|
|
|
6
7
|
function SecretInput({ value, onChange, placeholder, className }: {
|
|
7
8
|
value: string;
|
|
@@ -917,6 +918,7 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete, isApi: isApiProp
|
|
|
917
918
|
onUpdate: (cfg: any) => void; onDelete: () => void;
|
|
918
919
|
isApi?: boolean;
|
|
919
920
|
}) {
|
|
921
|
+
const { registry: modelsRegistry } = useModelsRegistry();
|
|
920
922
|
const [expanded, setExpanded] = useState(false);
|
|
921
923
|
const [testing, setTesting] = useState(false);
|
|
922
924
|
const [testResult, setTestResult] = useState<{ ok: boolean; message?: string; error?: string; duration_ms?: number } | null>(null);
|
|
@@ -975,20 +977,23 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete, isApi: isApiProp
|
|
|
975
977
|
<label className="text-[8px] text-[var(--text-secondary)]">Model</label>
|
|
976
978
|
<input value={cfg.model || ''} onChange={e => onUpdate({ ...cfg, model: e.target.value })}
|
|
977
979
|
list={`profile-model-${id}`} className={inputClass} />
|
|
978
|
-
{
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
980
|
+
{/* Datalist sourced from forge-public-info registry — adding a new
|
|
981
|
+
model id over there shows up here within 24h, no forge release.
|
|
982
|
+
API profiles look up by provider; CLI profiles look up by cliType. */}
|
|
983
|
+
<datalist id={`profile-model-${id}`}>
|
|
984
|
+
{(() => {
|
|
985
|
+
const models = isApi
|
|
986
|
+
? modelsRegistry.providers?.[cfg.provider || 'anthropic']?.models
|
|
987
|
+
: modelsRegistry.agents[
|
|
988
|
+
cfg.cliType === 'codex' || cfg.base === 'codex' ? 'codex'
|
|
989
|
+
: cfg.cliType === 'aider' || cfg.base === 'aider' ? 'aider'
|
|
990
|
+
: 'claude-code'
|
|
991
|
+
]?.models;
|
|
992
|
+
return (models || []).map(m => (
|
|
993
|
+
<option key={m.id} value={m.id} />
|
|
994
|
+
));
|
|
995
|
+
})()}
|
|
996
|
+
</datalist>
|
|
992
997
|
</div>
|
|
993
998
|
</div>
|
|
994
999
|
{isApi ? (
|
|
@@ -1127,6 +1132,7 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1127
1132
|
baseAgents: AgentEntry[];
|
|
1128
1133
|
onAdd: (id: string, cfg: any) => void;
|
|
1129
1134
|
}) {
|
|
1135
|
+
const { registry: modelsRegistry } = useModelsRegistry();
|
|
1130
1136
|
const [open, setOpen] = useState(false);
|
|
1131
1137
|
const [id, setId] = useState('');
|
|
1132
1138
|
const [name, setName] = useState('');
|
|
@@ -1228,22 +1234,17 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1228
1234
|
<div className="flex-1">
|
|
1229
1235
|
<label className="text-[8px] text-[var(--text-secondary)]">Model</label>
|
|
1230
1236
|
<input value={model} onChange={e => setModel(e.target.value)}
|
|
1231
|
-
placeholder={
|
|
1237
|
+
placeholder={
|
|
1238
|
+
base === 'claude' ? modelsRegistry.agents['claude-code']?.default || ''
|
|
1239
|
+
: base === 'codex' ? modelsRegistry.agents['codex']?.default || ''
|
|
1240
|
+
: ''
|
|
1241
|
+
}
|
|
1232
1242
|
list={`model-list-${base}`} className={inputClass} />
|
|
1233
|
-
{base
|
|
1234
|
-
|
|
1235
|
-
<option value=
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
</datalist>
|
|
1239
|
-
)}
|
|
1240
|
-
{base === 'codex' && (
|
|
1241
|
-
<datalist id="model-list-codex">
|
|
1242
|
-
<option value="codex-mini" />
|
|
1243
|
-
<option value="o4-mini" />
|
|
1244
|
-
<option value="gpt-4o" />
|
|
1245
|
-
</datalist>
|
|
1246
|
-
)}
|
|
1243
|
+
<datalist id={`model-list-${base}`}>
|
|
1244
|
+
{(modelsRegistry.agents[base === 'claude' ? 'claude-code' : base]?.models || []).map(m => (
|
|
1245
|
+
<option key={m.id} value={m.id} />
|
|
1246
|
+
))}
|
|
1247
|
+
</datalist>
|
|
1247
1248
|
</div>
|
|
1248
1249
|
</div>
|
|
1249
1250
|
<div>
|
|
@@ -1275,7 +1276,14 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1275
1276
|
</div>
|
|
1276
1277
|
<div className="flex-1">
|
|
1277
1278
|
<label className="text-[8px] text-[var(--text-secondary)]">Model</label>
|
|
1278
|
-
<input value={model} onChange={e => setModel(e.target.value)}
|
|
1279
|
+
<input value={model} onChange={e => setModel(e.target.value)}
|
|
1280
|
+
placeholder={modelsRegistry.providers?.[provider]?.default || ''}
|
|
1281
|
+
list={`api-model-list-${provider}`} className={inputClass} />
|
|
1282
|
+
<datalist id={`api-model-list-${provider}`}>
|
|
1283
|
+
{(modelsRegistry.providers?.[provider]?.models || []).map(m => (
|
|
1284
|
+
<option key={m.id} value={m.id} />
|
|
1285
|
+
))}
|
|
1286
|
+
</datalist>
|
|
1279
1287
|
</div>
|
|
1280
1288
|
</div>
|
|
1281
1289
|
<div>
|
|
@@ -1314,6 +1322,7 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1314
1322
|
}
|
|
1315
1323
|
|
|
1316
1324
|
function AgentsSection({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
|
|
1325
|
+
const { registry: modelsRegistry } = useModelsRegistry();
|
|
1317
1326
|
const [agents, setAgents] = useState<AgentEntry[]>([]);
|
|
1318
1327
|
const [loading, setLoading] = useState(true);
|
|
1319
1328
|
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
|
@@ -1649,9 +1658,9 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1649
1658
|
<span className="text-[8px] text-[var(--text-secondary)]">Presets:</span>
|
|
1650
1659
|
{((() => {
|
|
1651
1660
|
const ct = (settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic');
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
return
|
|
1661
|
+
// Models pulled from forge-public-info repo (lib/public-info/fetch.ts).
|
|
1662
|
+
// Add new IDs there and they show up here within 24h with no forge release.
|
|
1663
|
+
return pickerOptions(modelsRegistry, ct);
|
|
1655
1664
|
})()).map(preset => (
|
|
1656
1665
|
<button
|
|
1657
1666
|
key={preset}
|
|
@@ -398,9 +398,11 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
398
398
|
agentSkipFlag = agentConfig?.skipPermissionsFlag || '';
|
|
399
399
|
} catch {}
|
|
400
400
|
|
|
401
|
-
// Resume flag: explicit sessionId > fixedSession > -c (only for session-capable agents)
|
|
401
|
+
// Resume flag: explicit sessionId > fixedSession > -c (only for session-capable agents).
|
|
402
|
+
// resumeMode === false means the user explicitly chose "New Session" — never inject
|
|
403
|
+
// a fixedSession then, or "new" silently resumes the current one.
|
|
402
404
|
let resumeFlag = '';
|
|
403
|
-
if (supportsSession) {
|
|
405
|
+
if (supportsSession && resumeMode !== false) {
|
|
404
406
|
if (sessionId) resumeFlag = ` --resume ${sessionId}`;
|
|
405
407
|
else if (resumeMode) resumeFlag = ' -c';
|
|
406
408
|
// Override with fixedSession if no explicit sessionId
|
|
@@ -987,9 +989,12 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
987
989
|
currentSessionId,
|
|
988
990
|
});
|
|
989
991
|
} catch {
|
|
990
|
-
// Fallback: open directly without picker
|
|
992
|
+
// Fallback: open directly without picker.
|
|
993
|
+
// resumeMode:false so this fresh-open path skips the
|
|
994
|
+
// fixedSession resume branch downstream (else "open"
|
|
995
|
+
// silently resumes the current session).
|
|
991
996
|
window.dispatchEvent(new CustomEvent('forge:open-terminal', {
|
|
992
|
-
detail: { projectPath: p.path, projectName: p.name, agentId: a.id },
|
|
997
|
+
detail: { projectPath: p.path, projectName: p.name, agentId: a.id, resumeMode: false },
|
|
993
998
|
}));
|
|
994
999
|
}
|
|
995
1000
|
}}
|
|
@@ -1068,7 +1073,10 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
1068
1073
|
onSelect={(sel) => {
|
|
1069
1074
|
const info = vibePickerInfo;
|
|
1070
1075
|
setVibePickerInfo(null);
|
|
1071
|
-
|
|
1076
|
+
// resumeMode must be LITERAL false for "new" — the downstream guard
|
|
1077
|
+
// is `resumeMode !== false`, so leaving it undefined lets fixedSession
|
|
1078
|
+
// get injected and "New session" silently resumes the current one.
|
|
1079
|
+
const detail: any = { projectPath: info.projectPath, projectName: info.projectName, agentId: info.agentId, profileEnv: info.profileEnv, resumeMode: false };
|
|
1072
1080
|
if (sel.mode !== 'new') { detail.resumeMode = true; detail.sessionId = sel.sessionId; }
|
|
1073
1081
|
window.dispatchEvent(new CustomEvent('forge:open-terminal', { detail }));
|
|
1074
1082
|
}}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useMemo, useRef, forwardRef, useImperativeHandle, lazy, Suspense } from 'react';
|
|
4
4
|
import { TerminalSessionPickerLazy, fetchAgentSessions, type PickerSelection } from './TerminalLauncher';
|
|
5
|
+
import { useModelsRegistry } from '@/lib/public-info/use-models-registry';
|
|
5
6
|
import {
|
|
6
7
|
ReactFlow, Background, Controls, Handle, Position, useReactFlow, ReactFlowProvider,
|
|
7
8
|
type Node, type NodeProps, MarkerType, type NodeChange,
|
|
@@ -715,6 +716,7 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
715
716
|
onConfirm: (cfg: Omit<AgentConfig, 'id'>) => void;
|
|
716
717
|
onCancel: () => void;
|
|
717
718
|
}) {
|
|
719
|
+
const { registry: modelsRegistry } = useModelsRegistry();
|
|
718
720
|
const [label, setLabel] = useState(initial.label || '');
|
|
719
721
|
const [icon, setIcon] = useState(initial.icon || '🤖');
|
|
720
722
|
const [role, setRole] = useState(initial.role || '');
|
|
@@ -1160,9 +1162,9 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
1160
1162
|
list="workspace-model-list"
|
|
1161
1163
|
className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] font-mono" />
|
|
1162
1164
|
<datalist id="workspace-model-list">
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1165
|
+
{(modelsRegistry.agents['claude-code']?.models || []).map(m => (
|
|
1166
|
+
<option key={m.id} value={m.id} />
|
|
1167
|
+
))}
|
|
1166
1168
|
</datalist>
|
|
1167
1169
|
</div>
|
|
1168
1170
|
);
|
package/lib/agents/index.ts
CHANGED
|
@@ -280,7 +280,12 @@ export function resolveTerminalLaunch(agentId?: string): TerminalLaunchInfo {
|
|
|
280
280
|
}
|
|
281
281
|
|
|
282
282
|
return {
|
|
283
|
-
|
|
283
|
+
// Prefer the user-configured absolute binary path over the bare command
|
|
284
|
+
// name. A bare `claude` resolves via the tmux pane's PATH — non-login shell
|
|
285
|
+
// + forge-server's inherited PATH can pick a different/older install than
|
|
286
|
+
// the user's interactive shell, and an old claude crashes resuming a
|
|
287
|
+
// session a newer claude wrote. An absolute path bypasses PATH entirely.
|
|
288
|
+
cliCmd: agentCfg.path || cli.cmd,
|
|
284
289
|
cliType,
|
|
285
290
|
supportsSession: cli.session,
|
|
286
291
|
resumeFlag: agentCfg.resumeFlag || cli.resume,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in fallback model list. Forge prefers the public-info repo's
|
|
3
|
+
* registry (see `lib/public-info/fetch.ts`), but falls back to this
|
|
4
|
+
* when the network is unreachable, the file is malformed, or the
|
|
5
|
+
* user is on first-run before the cache is populated.
|
|
6
|
+
*
|
|
7
|
+
* Keep this list reasonably current — if the registry is permanently
|
|
8
|
+
* unreachable, this is what users see. New models in the wild should
|
|
9
|
+
* land in the public-info repo first (no code change required), and
|
|
10
|
+
* trickle into this fallback whenever the next forge release happens.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ModelsRegistry } from '../public-info/types';
|
|
14
|
+
|
|
15
|
+
export const KNOWN_MODELS_FALLBACK: ModelsRegistry = {
|
|
16
|
+
version: 1,
|
|
17
|
+
updatedAt: '2026-05-30',
|
|
18
|
+
note: 'Bundled fallback — actual current list lives in forge-public-info/models/registry.json',
|
|
19
|
+
agents: {
|
|
20
|
+
'claude-code': {
|
|
21
|
+
displayName: 'Claude Code',
|
|
22
|
+
default: 'claude-sonnet-4-6',
|
|
23
|
+
aliases: [
|
|
24
|
+
{ id: 'default', label: 'default (CLI decides)' },
|
|
25
|
+
{ id: 'sonnet', label: 'sonnet (alias)' },
|
|
26
|
+
{ id: 'opus', label: 'opus (alias)' },
|
|
27
|
+
{ id: 'haiku', label: 'haiku (alias)' },
|
|
28
|
+
],
|
|
29
|
+
models: [
|
|
30
|
+
{ id: 'claude-opus-4-8', label: 'Opus 4.8', tier: 'premium' },
|
|
31
|
+
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', tier: 'standard', default: true },
|
|
32
|
+
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', tier: 'fast' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
codex: {
|
|
36
|
+
displayName: 'OpenAI Codex',
|
|
37
|
+
default: 'o4-mini',
|
|
38
|
+
aliases: [{ id: 'default', label: 'default (CLI decides)' }],
|
|
39
|
+
models: [
|
|
40
|
+
{ id: 'o4-mini', label: 'o4-mini', tier: 'fast', default: true },
|
|
41
|
+
{ id: 'o3-mini', label: 'o3-mini', tier: 'fast' },
|
|
42
|
+
{ id: 'gpt-4.1', label: 'GPT-4.1', tier: 'standard' },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
aider: {
|
|
46
|
+
displayName: 'Aider',
|
|
47
|
+
default: 'default',
|
|
48
|
+
aliases: [{ id: 'default', label: 'default (CLI decides)' }],
|
|
49
|
+
models: [],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
providers: {
|
|
53
|
+
anthropic: {
|
|
54
|
+
displayName: 'Anthropic API',
|
|
55
|
+
default: 'claude-sonnet-4-6',
|
|
56
|
+
aliases: [],
|
|
57
|
+
models: [
|
|
58
|
+
{ id: 'claude-opus-4-8', label: 'Opus 4.8', tier: 'premium' },
|
|
59
|
+
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', tier: 'standard', default: true },
|
|
60
|
+
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', tier: 'fast' },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
openai: {
|
|
64
|
+
displayName: 'OpenAI API',
|
|
65
|
+
default: 'gpt-4.1',
|
|
66
|
+
aliases: [],
|
|
67
|
+
models: [
|
|
68
|
+
{ id: 'gpt-4.1', label: 'GPT-4.1', tier: 'premium', default: true },
|
|
69
|
+
{ id: 'gpt-4o', label: 'GPT-4o', tier: 'standard' },
|
|
70
|
+
{ id: 'o4-mini', label: 'o4-mini', tier: 'fast' },
|
|
71
|
+
{ id: 'o3-mini', label: 'o3-mini', tier: 'fast' },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -189,6 +189,25 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
189
189
|
return line;
|
|
190
190
|
},
|
|
191
191
|
|
|
192
|
+
// Query a pipeline run's status + per-node results by id (pairs with
|
|
193
|
+
// trigger_pipeline's returned id). Mirrors the MCP get_pipeline_status tool.
|
|
194
|
+
get_pipeline_status: async (input) => {
|
|
195
|
+
const params = (input as { pipeline_id?: string } | undefined) || {};
|
|
196
|
+
if (!params.pipeline_id) return 'get_pipeline_status failed: pipeline_id is required (returned by trigger_pipeline).';
|
|
197
|
+
const { getPipeline } = await import('../pipeline');
|
|
198
|
+
const pipeline = getPipeline(params.pipeline_id);
|
|
199
|
+
if (!pipeline) return `Pipeline "${params.pipeline_id}" not found.`;
|
|
200
|
+
const nodes = Object.entries(pipeline.nodes || {}).map(([id, n]) => {
|
|
201
|
+
let line = ` ${id}: ${n.status}`;
|
|
202
|
+
if (n.error) line += ` — ${n.error}`;
|
|
203
|
+
for (const [k, v] of Object.entries(n.outputs || {})) {
|
|
204
|
+
line += `\n ${k}: ${String(v).slice(0, 200)}`;
|
|
205
|
+
}
|
|
206
|
+
return line;
|
|
207
|
+
}).join('\n');
|
|
208
|
+
return `Pipeline ${pipeline.id} [${pipeline.status}] (${pipeline.workflowName})\n${nodes}`;
|
|
209
|
+
},
|
|
210
|
+
|
|
192
211
|
// Surface Forge's local context (projects + agents + skills) so the chat
|
|
193
212
|
// agent can pick valid values for inputs like trigger_pipeline.input.project
|
|
194
213
|
// without guessing. Cheap call — read-only directory + DB lookups.
|
|
@@ -291,6 +310,20 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
291
310
|
},
|
|
292
311
|
},
|
|
293
312
|
},
|
|
313
|
+
{
|
|
314
|
+
name: 'get_pipeline_status',
|
|
315
|
+
description: "Check a Forge pipeline run's live status + per-node results by id. Pass pipeline_id (returned by trigger_pipeline) to get the run's overall status + each node's status / error / outputs. Use whenever the user asks how a running or finished pipeline is doing.",
|
|
316
|
+
input_schema: {
|
|
317
|
+
type: 'object',
|
|
318
|
+
properties: {
|
|
319
|
+
pipeline_id: {
|
|
320
|
+
type: 'string',
|
|
321
|
+
description: 'Pipeline run id (returned by trigger_pipeline).',
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
required: ['pipeline_id'],
|
|
325
|
+
},
|
|
326
|
+
},
|
|
294
327
|
{
|
|
295
328
|
name: 'list_forge_context',
|
|
296
329
|
description: "Return the current Forge instance's local context: project names (use these as input.project for pipelines / dispatch_task), agent profile ids, and installed skills. Call this whenever the user references a project / agent / skill by name and you need to validate the name OR when picking defaults for trigger_pipeline / dispatch_task. No arguments.",
|
|
@@ -639,6 +639,15 @@ stop_condition: "all agents say DONE"
|
|
|
639
639
|
initial_prompt: "Review PR #{{input.pr_number}} in project {{input.project}}. Reviewer: analyze the diff. Author: be ready to address feedback."
|
|
640
640
|
```
|
|
641
641
|
|
|
642
|
+
## Controlling pipelines from chat
|
|
643
|
+
|
|
644
|
+
The chat assistant has builtin tools for pipelines, so you can run and check them in plain language:
|
|
645
|
+
|
|
646
|
+
- **`trigger_pipeline`** — start a workflow. Ask the assistant to "run the `<name>` pipeline" and it dispatches the run, returning a pipeline run id. Call it with no arguments first and it lists every workflow plus its input schema.
|
|
647
|
+
- **`get_pipeline_status`** — check a run by id. Ask "what's the status of pipeline `<id>`?" and it returns the run's overall status plus each node's status, error, and outputs.
|
|
648
|
+
|
|
649
|
+
Typical flow: trigger a pipeline, note the returned id, then ask the assistant for its status whenever you want an update — no need to open the Pipelines view. The same two tools are exposed to Claude Code over MCP (`trigger_pipeline` / `get_pipeline_status`).
|
|
650
|
+
|
|
642
651
|
## Pipeline Model
|
|
643
652
|
|
|
644
653
|
In **Settings → Pipeline Model**, you can select which Claude model runs pipeline tasks. Set to `default` to use the same model as regular tasks.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic fetch+cache helper for `forge-public-info` resources.
|
|
3
|
+
*
|
|
4
|
+
* Pattern: GitHub-hosted JSON files live under
|
|
5
|
+
* `<publicInfoRepoUrl>/<resourceDir>/<file>.json`
|
|
6
|
+
*
|
|
7
|
+
* On read:
|
|
8
|
+
* 1. If a fresh cached copy exists (< TTL), return it.
|
|
9
|
+
* 2. Otherwise fetch from the configured `publicInfoRepoUrl`.
|
|
10
|
+
* 3. On any error (offline, 4xx, malformed JSON), return the bundled
|
|
11
|
+
* fallback so the UI never breaks.
|
|
12
|
+
*
|
|
13
|
+
* Cache lives at `<dataDir>/public-info-cache/<resource>.json`. Each
|
|
14
|
+
* file has a sidecar `<resource>.meta.json` carrying the fetch
|
|
15
|
+
* timestamp; we don't trust HTTP `Last-Modified` because GitHub raw
|
|
16
|
+
* serves through a CDN that strips it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { getDataDir } from '../dirs';
|
|
22
|
+
import { loadSettings } from '../settings';
|
|
23
|
+
|
|
24
|
+
const DEFAULT_REPO_URL = 'https://raw.githubusercontent.com/aiwatching/forge-public-info/main';
|
|
25
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
27
|
+
|
|
28
|
+
interface CacheMeta { fetchedAt: number; etag?: string }
|
|
29
|
+
|
|
30
|
+
function cacheDir(): string {
|
|
31
|
+
const dir = join(getDataDir(), 'public-info-cache');
|
|
32
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function repoUrl(): string {
|
|
37
|
+
return (loadSettings() as any).publicInfoRepoUrl || DEFAULT_REPO_URL;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cacheKey(resourcePath: string): string {
|
|
41
|
+
// public/info/models/registry.json → models-registry
|
|
42
|
+
return resourcePath.replace(/\.json$/, '').replace(/[^a-zA-Z0-9]/g, '-').replace(/^-+|-+$/g, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readCache(resourcePath: string): { data: unknown; meta: CacheMeta } | null {
|
|
46
|
+
const key = cacheKey(resourcePath);
|
|
47
|
+
const dataPath = join(cacheDir(), `${key}.json`);
|
|
48
|
+
const metaPath = join(cacheDir(), `${key}.meta.json`);
|
|
49
|
+
if (!existsSync(dataPath) || !existsSync(metaPath)) return null;
|
|
50
|
+
try {
|
|
51
|
+
return {
|
|
52
|
+
data: JSON.parse(readFileSync(dataPath, 'utf-8')),
|
|
53
|
+
meta: JSON.parse(readFileSync(metaPath, 'utf-8')),
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeCache(resourcePath: string, data: unknown): void {
|
|
61
|
+
const key = cacheKey(resourcePath);
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(join(cacheDir(), `${key}.json`), JSON.stringify(data, null, 2), 'utf-8');
|
|
64
|
+
writeFileSync(
|
|
65
|
+
join(cacheDir(), `${key}.meta.json`),
|
|
66
|
+
JSON.stringify({ fetchedAt: Date.now() } satisfies CacheMeta, null, 2),
|
|
67
|
+
'utf-8',
|
|
68
|
+
);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.warn('[public-info] cache write failed:', err instanceof Error ? err.message : err);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fetch a public-info resource with built-in cache + fallback.
|
|
76
|
+
*
|
|
77
|
+
* @param resourcePath Path under the repo, e.g. `"models/registry.json"`
|
|
78
|
+
* @param fallback Used when network + cache both fail
|
|
79
|
+
* @param opts.forceRefresh Skip cache TTL check (e.g., user-triggered refresh)
|
|
80
|
+
*/
|
|
81
|
+
export async function fetchPublicInfo<T>(
|
|
82
|
+
resourcePath: string,
|
|
83
|
+
fallback: T,
|
|
84
|
+
opts: { forceRefresh?: boolean } = {},
|
|
85
|
+
): Promise<T> {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
|
|
88
|
+
// 1. Cache hit (fresh)
|
|
89
|
+
if (!opts.forceRefresh) {
|
|
90
|
+
const cached = readCache(resourcePath);
|
|
91
|
+
if (cached && now - cached.meta.fetchedAt < CACHE_TTL_MS) {
|
|
92
|
+
return cached.data as T;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Network fetch
|
|
97
|
+
const url = `${repoUrl()}/${resourcePath}`;
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
100
|
+
if (res.ok) {
|
|
101
|
+
const data = await res.json();
|
|
102
|
+
writeCache(resourcePath, data);
|
|
103
|
+
return data as T;
|
|
104
|
+
}
|
|
105
|
+
console.warn(`[public-info] ${url} returned HTTP ${res.status}, using fallback`);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn(`[public-info] ${url} fetch failed:`, err instanceof Error ? err.message : err);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 3. Stale cache (better than fallback)
|
|
111
|
+
const cached = readCache(resourcePath);
|
|
112
|
+
if (cached) return cached.data as T;
|
|
113
|
+
|
|
114
|
+
// 4. Bundled fallback
|
|
115
|
+
return fallback;
|
|
116
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shapes of JSON resources served from the public-info repo
|
|
3
|
+
* (https://github.com/aiwatching/forge-public-info, by default).
|
|
4
|
+
*
|
|
5
|
+
* Each resource lives under `<repoUrl>/<resource-dir>/<file>.json` so new
|
|
6
|
+
* resource types can be added without touching settings. Forge fetches
|
|
7
|
+
* them via `lib/public-info/fetch.ts` (24h cache + built-in fallback).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ModelInfo {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
/** Rough quality/cost band — used by UI to sort/group. */
|
|
14
|
+
tier?: 'premium' | 'standard' | 'fast';
|
|
15
|
+
default?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ModelAlias {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AgentModels {
|
|
24
|
+
displayName: string;
|
|
25
|
+
default: string;
|
|
26
|
+
aliases: ModelAlias[];
|
|
27
|
+
models: ModelInfo[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ModelsRegistry {
|
|
31
|
+
version: number;
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
note?: string;
|
|
34
|
+
/** Keyed by CLI type (e.g. `claude-code`, `codex`, `aider`). */
|
|
35
|
+
agents: Record<string, AgentModels>;
|
|
36
|
+
/** Keyed by API provider id (e.g. `anthropic`, `openai`, `grok`, `google`, `deepseek`, `litellm`). */
|
|
37
|
+
providers?: Record<string, AgentModels>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side hook for the model registry. Lazy-fetches from
|
|
3
|
+
* `GET /api/public-info/models`, caches in module-level state so
|
|
4
|
+
* multiple components sharing the registry hit the network once per
|
|
5
|
+
* page load. Returns the bundled fallback shape until the network
|
|
6
|
+
* round-trip completes so callers never see `undefined`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use client';
|
|
10
|
+
|
|
11
|
+
import { useEffect, useState } from 'react';
|
|
12
|
+
import { KNOWN_MODELS_FALLBACK } from '../agents/known-models';
|
|
13
|
+
import type { ModelsRegistry, AgentModels } from './types';
|
|
14
|
+
|
|
15
|
+
let cached: ModelsRegistry | null = null;
|
|
16
|
+
let inflight: Promise<ModelsRegistry> | null = null;
|
|
17
|
+
|
|
18
|
+
async function load(refresh = false): Promise<ModelsRegistry> {
|
|
19
|
+
if (cached && !refresh) return cached;
|
|
20
|
+
if (inflight && !refresh) return inflight;
|
|
21
|
+
inflight = (async () => {
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(`/api/public-info/models${refresh ? '?refresh=1' : ''}`);
|
|
24
|
+
if (res.ok) {
|
|
25
|
+
const data = await res.json();
|
|
26
|
+
cached = data as ModelsRegistry;
|
|
27
|
+
return cached;
|
|
28
|
+
}
|
|
29
|
+
} catch {}
|
|
30
|
+
cached = KNOWN_MODELS_FALLBACK;
|
|
31
|
+
return cached;
|
|
32
|
+
})();
|
|
33
|
+
try { return await inflight; } finally { inflight = null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useModelsRegistry(): {
|
|
37
|
+
registry: ModelsRegistry;
|
|
38
|
+
refresh: () => Promise<void>;
|
|
39
|
+
loading: boolean;
|
|
40
|
+
} {
|
|
41
|
+
const [registry, setRegistry] = useState<ModelsRegistry>(cached ?? KNOWN_MODELS_FALLBACK);
|
|
42
|
+
const [loading, setLoading] = useState(!cached);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (cached) return;
|
|
46
|
+
setLoading(true);
|
|
47
|
+
load().then(r => { setRegistry(r); setLoading(false); });
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
async function refresh() {
|
|
51
|
+
setLoading(true);
|
|
52
|
+
const r = await load(true);
|
|
53
|
+
setRegistry(r);
|
|
54
|
+
setLoading(false);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { registry, refresh, loading };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Get the model option list for a specific agent CLI type. Returns the
|
|
61
|
+
* flat list of `[...aliases, ...models]` as the picker expects them. */
|
|
62
|
+
export function pickerOptions(registry: ModelsRegistry, cliType: string): string[] {
|
|
63
|
+
const agent: AgentModels | undefined = registry.agents[cliType];
|
|
64
|
+
if (!agent) return ['default'];
|
|
65
|
+
return [...agent.aliases.map(a => a.id), ...agent.models.map(m => m.id)];
|
|
66
|
+
}
|