@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.
Files changed (40) hide show
  1. package/RELEASE_NOTES.md +3 -3
  2. package/app/api/public-info/[resource]/route.ts +40 -0
  3. package/components/ProjectDetail.tsx +1 -1
  4. package/components/SettingsModal.tsx +42 -33
  5. package/components/WebTerminal.tsx +13 -5
  6. package/components/WorkspaceView.tsx +5 -3
  7. package/lib/agents/index.ts +6 -1
  8. package/lib/agents/known-models.ts +75 -0
  9. package/lib/chat/tool-dispatcher.ts +33 -0
  10. package/lib/help-docs/05-pipelines.md +9 -0
  11. package/lib/public-info/fetch.ts +116 -0
  12. package/lib/public-info/types.ts +38 -0
  13. package/lib/public-info/use-models-registry.ts +66 -0
  14. package/lib/settings.ts +9 -0
  15. package/next-env.d.ts +1 -1
  16. package/package.json +1 -1
  17. package/lib/__tests__/foreach-batch-yaml.test.ts +0 -33
  18. package/lib/__tests__/foreach-before.test.ts +0 -201
  19. package/lib/__tests__/foreach-parse.test.ts +0 -114
  20. package/lib/__tests__/foreach-snapshot.test.ts +0 -112
  21. package/lib/__tests__/foreach-source.test.ts +0 -105
  22. package/lib/__tests__/foreach-template.test.ts +0 -112
  23. package/lib/workspace/__tests__/state-machine.test.ts +0 -388
  24. package/lib/workspace/__tests__/workspace.test.ts +0 -311
  25. package/scripts/bench/README.md +0 -66
  26. package/scripts/bench/results/.gitignore +0 -2
  27. package/scripts/bench/run.ts +0 -635
  28. package/scripts/bench/tasks/01-text-utils/task.md +0 -26
  29. package/scripts/bench/tasks/01-text-utils/validator.sh +0 -46
  30. package/scripts/bench/tasks/02-pagination/setup.sh +0 -19
  31. package/scripts/bench/tasks/02-pagination/task.md +0 -48
  32. package/scripts/bench/tasks/02-pagination/validator.sh +0 -69
  33. package/scripts/bench/tasks/03-bug-fix/setup.sh +0 -82
  34. package/scripts/bench/tasks/03-bug-fix/task.md +0 -30
  35. package/scripts/bench/tasks/03-bug-fix/validator.sh +0 -29
  36. package/scripts/test-agents-migrate.ts +0 -149
  37. package/scripts/test-mantis.ts +0 -223
  38. package/scripts/test-memory-local.ts +0 -139
  39. package/scripts/test-memory-upsert.ts +0 -106
  40. package/scripts/verify-usage.ts +0 -178
package/RELEASE_NOTES.md CHANGED
@@ -1,8 +1,8 @@
1
- # Forge v0.10.12
1
+ # Forge v0.10.18
2
2
 
3
3
  Released: 2026-05-30
4
4
 
5
- ## Changes since v0.10.11
5
+ ## Changes since v0.10.17
6
6
 
7
7
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.11...v0.10.12
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
+ }
@@ -1678,7 +1678,7 @@ function AgentTerminalButton({ projectPath, projectName }: { projectPath: string
1678
1678
  currentSessionId,
1679
1679
  });
1680
1680
  } catch {
1681
- openTerminal(a.id);
1681
+ openTerminal(a.id, false);
1682
1682
  }
1683
1683
  };
1684
1684
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import { FolderPicker } from './FolderPicker';
5
+ import { useModelsRegistry, pickerOptions } from '@/lib/public-info/use-models-registry';
5
6
 
6
7
  function SecretInput({ value, onChange, placeholder, className }: {
7
8
  value: string;
@@ -917,6 +918,7 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete, isApi: isApiProp
917
918
  onUpdate: (cfg: any) => void; onDelete: () => void;
918
919
  isApi?: boolean;
919
920
  }) {
921
+ const { registry: modelsRegistry } = useModelsRegistry();
920
922
  const [expanded, setExpanded] = useState(false);
921
923
  const [testing, setTesting] = useState(false);
922
924
  const [testResult, setTestResult] = useState<{ ok: boolean; message?: string; error?: string; duration_ms?: number } | null>(null);
@@ -975,20 +977,23 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete, isApi: isApiProp
975
977
  <label className="text-[8px] text-[var(--text-secondary)]">Model</label>
976
978
  <input value={cfg.model || ''} onChange={e => onUpdate({ ...cfg, model: e.target.value })}
977
979
  list={`profile-model-${id}`} className={inputClass} />
978
- {(cfg.cliType === 'claude-code' || (!cfg.cliType && !cfg.base && !isApi)) && (
979
- <datalist id={`profile-model-${id}`}>
980
- <option value="claude-opus-4-6" />
981
- <option value="claude-sonnet-4-6" />
982
- <option value="claude-haiku-4-5-20251001" />
983
- </datalist>
984
- )}
985
- {(cfg.cliType === 'codex' || cfg.base === 'codex') && (
986
- <datalist id={`profile-model-${id}`}>
987
- <option value="codex-mini" />
988
- <option value="o4-mini" />
989
- <option value="gpt-4o" />
990
- </datalist>
991
- )}
980
+ {/* Datalist sourced from forge-public-info registry adding a new
981
+ model id over there shows up here within 24h, no forge release.
982
+ API profiles look up by provider; CLI profiles look up by cliType. */}
983
+ <datalist id={`profile-model-${id}`}>
984
+ {(() => {
985
+ const models = isApi
986
+ ? modelsRegistry.providers?.[cfg.provider || 'anthropic']?.models
987
+ : modelsRegistry.agents[
988
+ cfg.cliType === 'codex' || cfg.base === 'codex' ? 'codex'
989
+ : cfg.cliType === 'aider' || cfg.base === 'aider' ? 'aider'
990
+ : 'claude-code'
991
+ ]?.models;
992
+ return (models || []).map(m => (
993
+ <option key={m.id} value={m.id} />
994
+ ));
995
+ })()}
996
+ </datalist>
992
997
  </div>
993
998
  </div>
994
999
  {isApi ? (
@@ -1127,6 +1132,7 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
1127
1132
  baseAgents: AgentEntry[];
1128
1133
  onAdd: (id: string, cfg: any) => void;
1129
1134
  }) {
1135
+ const { registry: modelsRegistry } = useModelsRegistry();
1130
1136
  const [open, setOpen] = useState(false);
1131
1137
  const [id, setId] = useState('');
1132
1138
  const [name, setName] = useState('');
@@ -1228,22 +1234,17 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
1228
1234
  <div className="flex-1">
1229
1235
  <label className="text-[8px] text-[var(--text-secondary)]">Model</label>
1230
1236
  <input value={model} onChange={e => setModel(e.target.value)}
1231
- placeholder={base === 'claude' ? 'claude-sonnet-4-6' : base === 'codex' ? 'codex-mini' : ''}
1237
+ placeholder={
1238
+ base === 'claude' ? modelsRegistry.agents['claude-code']?.default || ''
1239
+ : base === 'codex' ? modelsRegistry.agents['codex']?.default || ''
1240
+ : ''
1241
+ }
1232
1242
  list={`model-list-${base}`} className={inputClass} />
1233
- {base === 'claude' && (
1234
- <datalist id="model-list-claude">
1235
- <option value="claude-opus-4-6" />
1236
- <option value="claude-sonnet-4-6" />
1237
- <option value="claude-haiku-4-5-20251001" />
1238
- </datalist>
1239
- )}
1240
- {base === 'codex' && (
1241
- <datalist id="model-list-codex">
1242
- <option value="codex-mini" />
1243
- <option value="o4-mini" />
1244
- <option value="gpt-4o" />
1245
- </datalist>
1246
- )}
1243
+ <datalist id={`model-list-${base}`}>
1244
+ {(modelsRegistry.agents[base === 'claude' ? 'claude-code' : base]?.models || []).map(m => (
1245
+ <option key={m.id} value={m.id} />
1246
+ ))}
1247
+ </datalist>
1247
1248
  </div>
1248
1249
  </div>
1249
1250
  <div>
@@ -1275,7 +1276,14 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
1275
1276
  </div>
1276
1277
  <div className="flex-1">
1277
1278
  <label className="text-[8px] text-[var(--text-secondary)]">Model</label>
1278
- <input value={model} onChange={e => setModel(e.target.value)} placeholder="claude-sonnet-4-6" className={inputClass} />
1279
+ <input value={model} onChange={e => setModel(e.target.value)}
1280
+ placeholder={modelsRegistry.providers?.[provider]?.default || ''}
1281
+ list={`api-model-list-${provider}`} className={inputClass} />
1282
+ <datalist id={`api-model-list-${provider}`}>
1283
+ {(modelsRegistry.providers?.[provider]?.models || []).map(m => (
1284
+ <option key={m.id} value={m.id} />
1285
+ ))}
1286
+ </datalist>
1279
1287
  </div>
1280
1288
  </div>
1281
1289
  <div>
@@ -1314,6 +1322,7 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
1314
1322
  }
1315
1323
 
1316
1324
  function AgentsSection({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
1325
+ const { registry: modelsRegistry } = useModelsRegistry();
1317
1326
  const [agents, setAgents] = useState<AgentEntry[]>([]);
1318
1327
  const [loading, setLoading] = useState(true);
1319
1328
  const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
@@ -1649,9 +1658,9 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1649
1658
  <span className="text-[8px] text-[var(--text-secondary)]">Presets:</span>
1650
1659
  {((() => {
1651
1660
  const ct = (settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic');
1652
- if (ct === 'claude-code') return ['default', 'sonnet', 'opus', 'haiku', 'claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5-20251001'];
1653
- if (ct === 'codex') return ['default', 'o3-mini', 'o4-mini', 'gpt-4.1'];
1654
- return ['default'];
1661
+ // Models pulled from forge-public-info repo (lib/public-info/fetch.ts).
1662
+ // Add new IDs there and they show up here within 24h with no forge release.
1663
+ return pickerOptions(modelsRegistry, ct);
1655
1664
  })()).map(preset => (
1656
1665
  <button
1657
1666
  key={preset}
@@ -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
- const detail: any = { projectPath: info.projectPath, projectName: info.projectName, agentId: info.agentId, profileEnv: info.profileEnv };
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
- <option value="claude-sonnet-4-6" />
1164
- <option value="claude-opus-4-6" />
1165
- <option value="claude-haiku-4-5-20251001" />
1165
+ {(modelsRegistry.agents['claude-code']?.models || []).map(m => (
1166
+ <option key={m.id} value={m.id} />
1167
+ ))}
1166
1168
  </datalist>
1167
1169
  </div>
1168
1170
  );
@@ -280,7 +280,12 @@ export function resolveTerminalLaunch(agentId?: string): TerminalLaunchInfo {
280
280
  }
281
281
 
282
282
  return {
283
- cliCmd: cli.cmd,
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
+ }