@aion0/forge 0.10.17 → 0.10.20
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 -7
- package/app/api/agents/route.ts +4 -2
- package/components/HelpTerminal.tsx +23 -6
- package/components/ProjectDetail.tsx +1 -1
- package/components/SettingsModal.tsx +3 -11
- package/components/WebTerminal.tsx +13 -5
- package/lib/agents/index.ts +11 -5
- package/lib/agents/migrate.ts +8 -0
- package/lib/chat/tool-dispatcher.ts +76 -0
- package/lib/help-content.ts +51 -0
- package/lib/help-docs/01-settings.md +0 -1
- package/lib/help-docs/05-pipelines.md +9 -0
- package/lib/settings.ts +1 -1
- package/lib/telegram-bot.ts +5 -5
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.20
|
|
2
2
|
|
|
3
|
-
Released: 2026-05-
|
|
3
|
+
Released: 2026-05-31
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.19
|
|
6
6
|
|
|
7
|
-
### Features
|
|
8
|
-
- feat: API profile model picker uses registry providers
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.16...v0.10.17
|
|
8
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.19...v0.10.20
|
package/app/api/agents/route.ts
CHANGED
|
@@ -6,9 +6,11 @@ export async function GET(req: Request) {
|
|
|
6
6
|
const url = new URL(req.url);
|
|
7
7
|
const resolve = url.searchParams.get('resolve');
|
|
8
8
|
|
|
9
|
-
// GET /api/agents?resolve=claude → resolve
|
|
9
|
+
// GET /api/agents?resolve=claude[&scene=help] → resolve launch info for an
|
|
10
|
+
// agent in a given scene (terminal default; help/task/etc. pick models[scene]).
|
|
10
11
|
if (resolve) {
|
|
11
|
-
const
|
|
12
|
+
const scene = url.searchParams.get('scene') as 'terminal' | 'task' | 'telegram' | 'help' | 'mobile' | null;
|
|
13
|
+
const info = resolveTerminalLaunch(resolve, scene || undefined);
|
|
12
14
|
return NextResponse.json(info);
|
|
13
15
|
}
|
|
14
16
|
|
|
@@ -27,7 +27,10 @@ export default function HelpTerminal() {
|
|
|
27
27
|
|
|
28
28
|
let disposed = false;
|
|
29
29
|
let dataDir = '~/.forge/data';
|
|
30
|
-
|
|
30
|
+
// Full launch command for the Help AI: resolved from the DEFAULT agent's
|
|
31
|
+
// `help` scene (binary path + --model models.help + env exports). Falls back
|
|
32
|
+
// to bare `claude` if resolution fails.
|
|
33
|
+
let launchCmd = 'claude';
|
|
31
34
|
|
|
32
35
|
const cs = getComputedStyle(document.documentElement);
|
|
33
36
|
const tv = (name: string) => cs.getPropertyValue(name).trim();
|
|
@@ -76,7 +79,7 @@ export default function HelpTerminal() {
|
|
|
76
79
|
isNewSession = false;
|
|
77
80
|
setTimeout(() => {
|
|
78
81
|
if (socket.readyState === WebSocket.OPEN) {
|
|
79
|
-
socket.send(JSON.stringify({ type: 'input', data: `cd "${dataDir}" 2>/dev/null && ${
|
|
82
|
+
socket.send(JSON.stringify({ type: 'input', data: `cd "${dataDir}" 2>/dev/null && ${launchCmd}\n` }));
|
|
80
83
|
}
|
|
81
84
|
}, 300);
|
|
82
85
|
}
|
|
@@ -97,13 +100,27 @@ export default function HelpTerminal() {
|
|
|
97
100
|
socket.onerror = () => {};
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
// Fetch data dir + default agent then
|
|
103
|
+
// Fetch data dir + default agent, then resolve that agent's `help`-scene
|
|
104
|
+
// launch info (path + model + env) so Help runs the configured binary/model.
|
|
101
105
|
Promise.all([
|
|
102
106
|
fetch('/api/help?action=status').then(r => r.json()).then(data => { if (data.dataDir) dataDir = data.dataDir; }).catch(() => {}),
|
|
103
|
-
fetch('/api/agents').then(r => r.json()).then(data => {
|
|
107
|
+
fetch('/api/agents').then(r => r.json()).then(async (data) => {
|
|
104
108
|
const defaultId = data.defaultAgent || 'claude';
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
try {
|
|
110
|
+
const info = await fetch(`/api/agents?resolve=${encodeURIComponent(defaultId)}&scene=help`).then(r => r.json());
|
|
111
|
+
const bin = info?.cliCmd || 'claude';
|
|
112
|
+
const modelFlag = info?.model ? ` --model ${info.model}` : '';
|
|
113
|
+
const envPrefix = info?.env
|
|
114
|
+
? Object.entries(info.env as Record<string, string>)
|
|
115
|
+
.map(([k, v]) => `export ${k}=${JSON.stringify(v)}`)
|
|
116
|
+
.join(' && ') + ' && '
|
|
117
|
+
: '';
|
|
118
|
+
launchCmd = `${envPrefix}${bin}${modelFlag}`;
|
|
119
|
+
} catch {
|
|
120
|
+
// Fallback to bare path from the agent list if resolve fails.
|
|
121
|
+
const agent = (data.agents || []).find((a: any) => a.id === defaultId);
|
|
122
|
+
if (agent?.path) launchCmd = agent.path;
|
|
123
|
+
}
|
|
107
124
|
}).catch(() => {}),
|
|
108
125
|
]).finally(() => { if (!disposed) connect(); });
|
|
109
126
|
|
|
@@ -901,7 +901,6 @@ interface AgentEntry {
|
|
|
901
901
|
enabled: boolean;
|
|
902
902
|
type: string;
|
|
903
903
|
taskFlags: string;
|
|
904
|
-
interactiveCmd: string;
|
|
905
904
|
resumeFlag: string;
|
|
906
905
|
outputFormat: string;
|
|
907
906
|
models: { terminal: string; task: string; telegram: string; help: string; mobile: string };
|
|
@@ -1334,7 +1333,7 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1334
1333
|
'generic': { taskFlags: '', resumeFlag: '', outputFormat: 'text', skipPermissionsFlag: '' },
|
|
1335
1334
|
};
|
|
1336
1335
|
const makeNewAgent = (cliType = 'claude-code') => ({
|
|
1337
|
-
id: '', name: '', path: '',
|
|
1336
|
+
id: '', name: '', path: '',
|
|
1338
1337
|
models: { terminal: 'default', task: 'default', telegram: 'default', help: 'default', mobile: 'default' },
|
|
1339
1338
|
requiresTTY: false, cliType,
|
|
1340
1339
|
...cliDefaults[cliType],
|
|
@@ -1369,7 +1368,6 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1369
1368
|
enabled: cfg.enabled !== false,
|
|
1370
1369
|
type: a.type || 'generic',
|
|
1371
1370
|
taskFlags: cfg.taskFlags ?? (a.id === 'claude' ? '-p --verbose --output-format stream-json --dangerously-skip-permissions' : cfg.flags?.join(' ') ?? ''),
|
|
1372
|
-
interactiveCmd: cfg.interactiveCmd ?? a.path,
|
|
1373
1371
|
resumeFlag: cfg.resumeFlag ?? (a.capabilities?.supportsResume ? '-c' : ''),
|
|
1374
1372
|
outputFormat: cfg.outputFormat ?? (a.capabilities?.supportsStreamJson ? 'stream-json' : 'text'),
|
|
1375
1373
|
models: cfg.models ?? { terminal: "default", task: "default", telegram: "default", help: "default", mobile: "default" },
|
|
@@ -1395,7 +1393,6 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1395
1393
|
enabled: cfg.enabled !== false,
|
|
1396
1394
|
type: 'generic',
|
|
1397
1395
|
taskFlags: cfg.taskFlags ?? cfg.flags?.join(' ') ?? '',
|
|
1398
|
-
interactiveCmd: cfg.interactiveCmd ?? cfg.path ?? '',
|
|
1399
1396
|
resumeFlag: cfg.resumeFlag ?? '',
|
|
1400
1397
|
outputFormat: cfg.outputFormat ?? 'text',
|
|
1401
1398
|
models: cfg.models ?? { terminal: "default", task: "default", telegram: "default", help: "default", mobile: "default" },
|
|
@@ -1434,7 +1431,6 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1434
1431
|
path: a.path,
|
|
1435
1432
|
enabled: a.enabled,
|
|
1436
1433
|
taskFlags: a.taskFlags,
|
|
1437
|
-
interactiveCmd: a.interactiveCmd,
|
|
1438
1434
|
resumeFlag: a.resumeFlag,
|
|
1439
1435
|
outputFormat: a.outputFormat,
|
|
1440
1436
|
models: a.models,
|
|
@@ -1527,7 +1523,7 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1527
1523
|
onClick={() => {
|
|
1528
1524
|
// Auto-fill path from detected claude agent when opening Add form
|
|
1529
1525
|
const claude = agents.find(a => a.id === 'claude');
|
|
1530
|
-
if (claude?.path && !newAgent.path) setNewAgent((prev: any) => ({ ...prev, path: claude.path
|
|
1526
|
+
if (claude?.path && !newAgent.path) setNewAgent((prev: any) => ({ ...prev, path: claude.path }));
|
|
1531
1527
|
setShowAdd(v => !v);
|
|
1532
1528
|
}}
|
|
1533
1529
|
className="text-[9px] px-2 py-0.5 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)]"
|
|
@@ -1614,10 +1610,6 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1614
1610
|
<label className="text-[9px] text-[var(--text-secondary)]">Task Flags <span className="text-[8px]">(non-interactive mode, e.g. -p --output-format json)</span></label>
|
|
1615
1611
|
<input value={a.taskFlags} onChange={e => updateAgent(a.id, 'taskFlags', e.target.value)} placeholder="-p --verbose" className={inputClass} />
|
|
1616
1612
|
</div>
|
|
1617
|
-
<div>
|
|
1618
|
-
<label className="text-[9px] text-[var(--text-secondary)]">Interactive Command <span className="text-[8px]">(terminal startup)</span></label>
|
|
1619
|
-
<input value={a.interactiveCmd} onChange={e => updateAgent(a.id, 'interactiveCmd', e.target.value)} placeholder="claude" className={inputClass} />
|
|
1620
|
-
</div>
|
|
1621
1613
|
<div className="flex gap-3">
|
|
1622
1614
|
<div className="flex-1">
|
|
1623
1615
|
<label className="text-[9px] text-[var(--text-secondary)]">Resume Flag <span className="text-[8px]">(empty = no resume)</span></label>
|
|
@@ -1770,7 +1762,7 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1770
1762
|
// Auto-fill path from detected agent if available
|
|
1771
1763
|
const baseId = ct === 'claude-code' ? 'claude' : ct;
|
|
1772
1764
|
const detected = agents.find(a => a.id === baseId);
|
|
1773
|
-
setNewAgent({ ...newAgent, cliType: ct, ...(cliDefaults[ct] || {}), path: detected?.path || newAgent.path
|
|
1765
|
+
setNewAgent({ ...newAgent, cliType: ct, ...(cliDefaults[ct] || {}), path: detected?.path || newAgent.path });
|
|
1774
1766
|
}} className={inputClass}>
|
|
1775
1767
|
<option value="claude-code">Claude Code</option>
|
|
1776
1768
|
<option value="codex">Codex</option>
|
|
@@ -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
|
}}
|
package/lib/agents/index.ts
CHANGED
|
@@ -240,7 +240,7 @@ export interface TerminalLaunchInfo {
|
|
|
240
240
|
model?: string; // profile model override (--model flag)
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
export function resolveTerminalLaunch(agentId?: string): TerminalLaunchInfo {
|
|
243
|
+
export function resolveTerminalLaunch(agentId?: string, scene: 'terminal' | 'task' | 'telegram' | 'help' | 'mobile' = 'terminal'): TerminalLaunchInfo {
|
|
244
244
|
const settings = loadSettings();
|
|
245
245
|
const agentCfg = settings.agents?.[agentId || 'claude'] || {};
|
|
246
246
|
// Resolve cliType: own cliType → base agent's cliType → base agent name guessing → agentId name guessing
|
|
@@ -263,24 +263,30 @@ export function resolveTerminalLaunch(agentId?: string): TerminalLaunchInfo {
|
|
|
263
263
|
|| undefined;
|
|
264
264
|
|
|
265
265
|
// Resolve env/model from this agent's per-use models sub-table.
|
|
266
|
-
// 顶层 model 字段已在 0.10 起被 migrate.ts
|
|
266
|
+
// 顶层 model 字段已在 0.10 起被 migrate.ts 清理,这里按 scene 读 models[scene]
|
|
267
|
+
// (默认 terminal;Help 传 'help' 等)。models[scene] === 'default' → 不加 --model。
|
|
267
268
|
let env: Record<string, string> | undefined;
|
|
268
269
|
let model: string | undefined;
|
|
269
270
|
if (agentCfg.base || agentCfg.env || agentCfg.models) {
|
|
270
271
|
if (agentCfg.env) env = { ...agentCfg.env };
|
|
271
|
-
model = agentCfg.models?.
|
|
272
|
+
model = agentCfg.models?.[scene];
|
|
272
273
|
if (model === 'default') model = undefined; // 'default' = 不加 --model
|
|
273
274
|
} else if (agentCfg.profile) {
|
|
274
275
|
const profileCfg = settings.agents?.[agentCfg.profile];
|
|
275
276
|
if (profileCfg) {
|
|
276
277
|
if (profileCfg.env) env = { ...profileCfg.env };
|
|
277
|
-
model = profileCfg.models?.
|
|
278
|
+
model = profileCfg.models?.[scene];
|
|
278
279
|
if (model === 'default') model = undefined;
|
|
279
280
|
}
|
|
280
281
|
}
|
|
281
282
|
|
|
282
283
|
return {
|
|
283
|
-
|
|
284
|
+
// Prefer the user-configured absolute binary path over the bare command
|
|
285
|
+
// name. A bare `claude` resolves via the tmux pane's PATH — non-login shell
|
|
286
|
+
// + forge-server's inherited PATH can pick a different/older install than
|
|
287
|
+
// the user's interactive shell, and an old claude crashes resuming a
|
|
288
|
+
// session a newer claude wrote. An absolute path bypasses PATH entirely.
|
|
289
|
+
cliCmd: agentCfg.path || cli.cmd,
|
|
284
290
|
cliType,
|
|
285
291
|
supportsSession: cli.session,
|
|
286
292
|
resumeFlag: agentCfg.resumeFlag || cli.resume,
|
package/lib/agents/migrate.ts
CHANGED
|
@@ -89,6 +89,14 @@ export function migrateAgentsFlatten(settings: Settings): boolean {
|
|
|
89
89
|
delete raw.model;
|
|
90
90
|
mutated = true;
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
// ── interactiveCmd 字段清理 ──────────────────────────
|
|
94
|
+
// 终端启动统一走 resolveTerminalLaunch(cliCmd = agentCfg.path),
|
|
95
|
+
// interactiveCmd 从未被后端读取,已从 UI / 类型移除。清掉残留死数据。
|
|
96
|
+
if (raw.interactiveCmd !== undefined) {
|
|
97
|
+
delete raw.interactiveCmd;
|
|
98
|
+
mutated = true;
|
|
99
|
+
}
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
// === 兜底校验 defaultAgent / chatAgent ===
|
|
@@ -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.
|
|
@@ -255,6 +274,30 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
255
274
|
});
|
|
256
275
|
return `Task dispatched: ${task.id} (project: ${project.name}, status: ${task.status}). Watch in the Tasks view.`;
|
|
257
276
|
},
|
|
277
|
+
|
|
278
|
+
// List Forge's own help/documentation files so the chat agent can answer
|
|
279
|
+
// questions about Forge itself (features, config, troubleshooting) without
|
|
280
|
+
// the user opening the separate Help AI. Pair with read_help_doc.
|
|
281
|
+
list_help_docs: async () => {
|
|
282
|
+
const { listHelpDocs } = await import('../help-content');
|
|
283
|
+
const docs = listHelpDocs();
|
|
284
|
+
if (docs.length === 0) return 'No Forge help docs found.';
|
|
285
|
+
return 'Forge help docs (read one with read_help_doc):\n' + docs.map((d) => `- ${d}`).join('\n');
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
// Read one Forge help doc in full. Use to answer questions about Forge's
|
|
289
|
+
// own features / settings / troubleshooting.
|
|
290
|
+
read_help_doc: async (input) => {
|
|
291
|
+
const { doc } = (input as { doc?: string } | undefined) || {};
|
|
292
|
+
if (!doc) return 'read_help_doc failed: doc is required (a filename from list_help_docs, e.g. "05-pipelines.md").';
|
|
293
|
+
const { readHelpDoc } = await import('../help-content');
|
|
294
|
+
const content = readHelpDoc(doc);
|
|
295
|
+
if (content == null) return `Help doc "${doc}" not found. Call list_help_docs to see available filenames.`;
|
|
296
|
+
const MAX = 30000;
|
|
297
|
+
return content.length > MAX
|
|
298
|
+
? content.slice(0, MAX) + `\n\n…[truncated — doc is ${content.length} chars]`
|
|
299
|
+
: content;
|
|
300
|
+
},
|
|
258
301
|
};
|
|
259
302
|
|
|
260
303
|
export interface BuiltinToolDef {
|
|
@@ -291,6 +334,20 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
291
334
|
},
|
|
292
335
|
},
|
|
293
336
|
},
|
|
337
|
+
{
|
|
338
|
+
name: 'get_pipeline_status',
|
|
339
|
+
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.",
|
|
340
|
+
input_schema: {
|
|
341
|
+
type: 'object',
|
|
342
|
+
properties: {
|
|
343
|
+
pipeline_id: {
|
|
344
|
+
type: 'string',
|
|
345
|
+
description: 'Pipeline run id (returned by trigger_pipeline).',
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
required: ['pipeline_id'],
|
|
349
|
+
},
|
|
350
|
+
},
|
|
294
351
|
{
|
|
295
352
|
name: 'list_forge_context',
|
|
296
353
|
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.",
|
|
@@ -318,6 +375,25 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
318
375
|
required: ['prompt'],
|
|
319
376
|
},
|
|
320
377
|
},
|
|
378
|
+
{
|
|
379
|
+
name: 'list_help_docs',
|
|
380
|
+
description: "List Forge's own documentation files. Call this FIRST whenever the user asks how Forge itself works — its features, settings/config, setup, or troubleshooting (e.g. pipelines, schedules, connectors, telegram, tunnel, workspace/smiths, skills, crafts, usage/cost, agents/models). Returns doc filenames; then read the relevant one(s) with read_help_doc and answer from their content. No arguments.",
|
|
381
|
+
input_schema: { type: 'object', properties: {} },
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
name: 'read_help_doc',
|
|
385
|
+
description: 'Read one Forge help doc in full (filename from list_help_docs, e.g. "05-pipelines.md"). Use its content to answer the user\'s question about Forge. Read more than one doc if the question spans topics.',
|
|
386
|
+
input_schema: {
|
|
387
|
+
type: 'object',
|
|
388
|
+
properties: {
|
|
389
|
+
doc: {
|
|
390
|
+
type: 'string',
|
|
391
|
+
description: 'Help doc filename, e.g. "05-pipelines.md" or "10-troubleshooting.md". Get exact names from list_help_docs.',
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
required: ['doc'],
|
|
395
|
+
},
|
|
396
|
+
},
|
|
321
397
|
];
|
|
322
398
|
|
|
323
399
|
// ─── Connector dispatch ──────────────────────────────────
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Help-doc reader for the chat agent's read_help_doc / list_help_docs tools.
|
|
3
|
+
*
|
|
4
|
+
* Reads the markdown docs that ship under lib/help-docs/ (same set the Help AI
|
|
5
|
+
* terminal uses). Resolved relative to this module so it works regardless of
|
|
6
|
+
* whether the on-disk sync (ensureHelpDocs → <config>/help) has run yet, then
|
|
7
|
+
* falls back to the synced copy if the source tree isn't present (installed
|
|
8
|
+
* tarball layouts).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { getConfigDir } from './dirs';
|
|
15
|
+
|
|
16
|
+
// Candidate dirs, in priority order: source tree next to this module, then the
|
|
17
|
+
// synced runtime copy under ~/.forge/help.
|
|
18
|
+
function helpDirs(): string[] {
|
|
19
|
+
const dirs: string[] = [];
|
|
20
|
+
try {
|
|
21
|
+
const here = fileURLToPath(new URL('./help-docs', import.meta.url));
|
|
22
|
+
dirs.push(here);
|
|
23
|
+
} catch { /* import.meta.url unavailable (CJS bundle) — skip */ }
|
|
24
|
+
dirs.push(join(getConfigDir(), 'help'));
|
|
25
|
+
return dirs;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** List available help-doc filenames (e.g. ["00-overview.md", ...]), sorted. */
|
|
29
|
+
export function listHelpDocs(): string[] {
|
|
30
|
+
for (const dir of helpDirs()) {
|
|
31
|
+
try {
|
|
32
|
+
if (!existsSync(dir)) continue;
|
|
33
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
|
|
34
|
+
if (files.length) return files;
|
|
35
|
+
} catch { /* try next dir */ }
|
|
36
|
+
}
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read one help doc by filename. Returns null if not found. Path-traversal safe. */
|
|
41
|
+
export function readHelpDoc(name: string): string | null {
|
|
42
|
+
const base = (name || '').split(/[\\/]/).pop() || '';
|
|
43
|
+
if (!base.endsWith('.md')) return null;
|
|
44
|
+
for (const dir of helpDirs()) {
|
|
45
|
+
try {
|
|
46
|
+
const file = join(dir, base);
|
|
47
|
+
if (existsSync(file)) return readFileSync(file, 'utf-8');
|
|
48
|
+
} catch { /* try next dir */ }
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
@@ -46,7 +46,6 @@ Each agent entry in `settings.agents` supports:
|
|
|
46
46
|
| `enabled` | boolean | Whether this agent is available |
|
|
47
47
|
| `cliType` | string | CLI tool type: `claude-code`, `codex`, `aider`, `generic` |
|
|
48
48
|
| `taskFlags` | string | Flags for headless task execution (e.g. `-p --verbose`) |
|
|
49
|
-
| `interactiveCmd` | string | Command for interactive terminal sessions |
|
|
50
49
|
| `resumeFlag` | string | Flag to resume sessions (e.g. `-c` for claude) |
|
|
51
50
|
| `outputFormat` | string | Output format: `stream-json`, `text` |
|
|
52
51
|
| `skipPermissionsFlag` | string | Flag to skip permissions (e.g. `--dangerously-skip-permissions`) |
|
|
@@ -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.
|
package/lib/settings.ts
CHANGED
|
@@ -23,7 +23,7 @@ const SETTINGS_FILE = join(DATA_DIR, 'settings.yaml');
|
|
|
23
23
|
export interface AgentEntry {
|
|
24
24
|
tool?: 'claude' | 'codex' | 'aider' | 'opencode'; // which CLI binary
|
|
25
25
|
path?: string; name?: string; enabled?: boolean;
|
|
26
|
-
flags?: string[]; taskFlags?: string;
|
|
26
|
+
flags?: string[]; taskFlags?: string; resumeFlag?: string; outputFormat?: string;
|
|
27
27
|
models?: { terminal?: string; task?: string; telegram?: string; help?: string; mobile?: string };
|
|
28
28
|
skipPermissionsFlag?: string;
|
|
29
29
|
requiresTTY?: boolean;
|
package/lib/telegram-bot.ts
CHANGED
|
@@ -1086,7 +1086,7 @@ function getSessionPreview(sessionName: string, maxLines: number = 1): string {
|
|
|
1086
1086
|
try {
|
|
1087
1087
|
const { execSync } = require('node:child_process');
|
|
1088
1088
|
// Capture last screen, strip ANSI escapes, find last non-empty lines
|
|
1089
|
-
const out = execSync(`tmux capture-pane -t "${sessionName}" -p -S -
|
|
1089
|
+
const out = execSync(`tmux capture-pane -t "${sessionName}" -p -S -100 2>/dev/null`, {
|
|
1090
1090
|
encoding: 'utf-8',
|
|
1091
1091
|
timeout: 2000,
|
|
1092
1092
|
}) as string;
|
|
@@ -1101,8 +1101,8 @@ function getSessionPreview(sessionName: string, maxLines: number = 1): string {
|
|
|
1101
1101
|
const last = tail[0];
|
|
1102
1102
|
return last.length > 30 ? last.slice(0, 30) + '…' : last;
|
|
1103
1103
|
}
|
|
1104
|
-
// Multi-line: truncate each line to
|
|
1105
|
-
return tail.map(l => l.length >
|
|
1104
|
+
// Multi-line: truncate each line to 160 chars
|
|
1105
|
+
return tail.map(l => l.length > 160 ? l.slice(0, 160) + '…' : l).join('\n');
|
|
1106
1106
|
} catch {
|
|
1107
1107
|
return '';
|
|
1108
1108
|
}
|
|
@@ -1185,8 +1185,8 @@ async function pickInjectTarget(chatId: number, numStr: string) {
|
|
|
1185
1185
|
scheduleInjectAutoClear(chatId);
|
|
1186
1186
|
const labelMap = getSessionLabels();
|
|
1187
1187
|
const display = labelMap[sessionName] || sessionName.replace(/^mw-?/, '');
|
|
1188
|
-
// Show last
|
|
1189
|
-
const context = getSessionPreview(sessionName,
|
|
1188
|
+
// Show last 16 lines of context so user knows what's in the terminal
|
|
1189
|
+
const context = getSessionPreview(sessionName, 16);
|
|
1190
1190
|
const contextBlock = context ? `\n\n📺 Last output:\n\`\`\`\n${context}\n\`\`\`` : '';
|
|
1191
1191
|
await send(chatId,
|
|
1192
1192
|
`🎯 Target: ${display}${contextBlock}\n\n` +
|
package/package.json
CHANGED