@aion0/forge 0.10.18 → 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 CHANGED
@@ -1,8 +1,8 @@
1
- # Forge v0.10.18
1
+ # Forge v0.10.20
2
2
 
3
- Released: 2026-05-30
3
+ Released: 2026-05-31
4
4
 
5
- ## Changes since v0.10.17
5
+ ## Changes since v0.10.19
6
6
 
7
7
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.17...v0.10.18
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.19...v0.10.20
@@ -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 terminal launch info for an agent
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 info = resolveTerminalLaunch(resolve);
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
- let agentCmd = 'claude';
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 && ${agentCmd}\n` }));
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 connect
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
- const agent = (data.agents || []).find((a: any) => a.id === defaultId);
106
- if (agent?.path) agentCmd = agent.path;
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: '', interactiveCmd: '',
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, interactiveCmd: 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, interactiveCmd: detected?.path || newAgent.interactiveCmd });
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>
@@ -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,18 +263,19 @@ 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 清理,这里只读 models.terminal。
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?.terminal;
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?.terminal;
278
+ model = profileCfg.models?.[scene];
278
279
  if (model === 'default') model = undefined;
279
280
  }
280
281
  }
@@ -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 ===
@@ -274,6 +274,30 @@ const BUILTINS: Record<string, BuiltinHandler> = {
274
274
  });
275
275
  return `Task dispatched: ${task.id} (project: ${project.name}, status: ${task.status}). Watch in the Tasks view.`;
276
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
+ },
277
301
  };
278
302
 
279
303
  export interface BuiltinToolDef {
@@ -351,6 +375,25 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
351
375
  required: ['prompt'],
352
376
  },
353
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
+ },
354
397
  ];
355
398
 
356
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`) |
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; interactiveCmd?: string; resumeFlag?: string; outputFormat?: 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;
@@ -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 -50 2>/dev/null`, {
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 80 chars
1105
- return tail.map(l => l.length > 80 ? l.slice(0, 80) + '…' : l).join('\n');
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 8 lines of context so user knows what's in the terminal
1189
- const context = getSessionPreview(sessionName, 8);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.18",
3
+ "version": "0.10.20",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {