@aion0/forge 0.10.53 → 0.10.56
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 +14 -3
- package/app/api/activity/summary/route.ts +30 -0
- package/app/api/cache/route.ts +125 -41
- package/app/api/chat/sessions/[id]/abort/route.ts +14 -0
- package/app/api/chat/sessions/[id]/note/route.ts +16 -0
- package/app/api/files/[...path]/route.ts +94 -0
- package/app/api/scratch/[...path]/route.ts +5 -0
- package/app/chat/page.tsx +237 -36
- package/app/files/[...path]/page.tsx +22 -0
- package/components/Dashboard.tsx +82 -26
- package/components/PipelineView.tsx +40 -7
- package/components/ScratchViewer.tsx +14 -3
- package/lib/chat/agent-loop.ts +95 -2
- package/lib/chat/input-queue.ts +159 -0
- package/lib/chat/link-patterns.ts +28 -5
- package/lib/chat/tool-dispatcher.ts +270 -17
- package/lib/chat/turn-control.ts +109 -0
- package/lib/chat-standalone.ts +75 -21
- package/lib/help-docs/10-troubleshooting.md +16 -0
- package/lib/help-docs/17-connectors.md +19 -0
- package/lib/help-docs/25-chat-tools.md +125 -0
- package/lib/help-docs/CLAUDE.md +2 -0
- package/lib/init.ts +14 -0
- package/lib/pipeline.ts +11 -0
- package/lib/scratch-cleanup.ts +25 -16
- package/lib/task-manager.ts +30 -0
- package/package.json +1 -1
|
@@ -37,6 +37,85 @@ export interface ToolResult {
|
|
|
37
37
|
|
|
38
38
|
export type BuiltinHandler = (input: unknown) => Promise<string>;
|
|
39
39
|
|
|
40
|
+
// ─── Data-dir file helpers ────────────────────────────────
|
|
41
|
+
// resolveTmpPath: scoped to <dataDir>/tmp/ — where save_tmp_file drops
|
|
42
|
+
// its files. Kept separate from scratch/ (the synthetic "scratch
|
|
43
|
+
// project" task workspace) so the cache janitor can wipe tmp/ without
|
|
44
|
+
// disturbing in-progress task workdirs.
|
|
45
|
+
async function resolveTmpPath(rel: string): Promise<string> {
|
|
46
|
+
const { getDataDir } = await import('../dirs');
|
|
47
|
+
const { join, resolve } = await import('node:path');
|
|
48
|
+
const clean = rel.replace(/^\/+/, '');
|
|
49
|
+
if (!clean || clean.includes('..') || clean.includes('\0')) throw new Error('invalid tmp path');
|
|
50
|
+
const tmpRoot = join(getDataDir(), 'tmp');
|
|
51
|
+
const target = resolve(tmpRoot, clean);
|
|
52
|
+
if (!target.startsWith(tmpRoot + '/') && target !== tmpRoot) {
|
|
53
|
+
throw new Error('resolved path escapes tmp dir');
|
|
54
|
+
}
|
|
55
|
+
return target;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// resolveDataPath: scoped to <dataDir>/ — the read/list tools' broader
|
|
59
|
+
// surface. Blocks known sensitive files (encrypt key, enterprise keys,
|
|
60
|
+
// sqlite DBs, server log, anything starting with a dot at the dataDir
|
|
61
|
+
// root). Subpaths under known-safe subdirs (scratch/, flows/, prompts/,
|
|
62
|
+
// connectors/, etc.) are accepted as-is. The agent can use this to
|
|
63
|
+
// inspect its OWN config — but secrets stay invisible.
|
|
64
|
+
function isSensitiveDataPath(target: string, dataRoot: string): string | null {
|
|
65
|
+
if (target === dataRoot) return null;
|
|
66
|
+
const rel = target.slice(dataRoot.length + 1); // drop "<root>/"
|
|
67
|
+
const top = rel.split('/')[0] || '';
|
|
68
|
+
// Top-level dotfiles (.encrypt-key, .enterprise-keys.json, …) — secrets.
|
|
69
|
+
if (top.startsWith('.')) return `top-level dotfile "${top}" is sensitive (encryption keys, session state)`;
|
|
70
|
+
// SQLite databases are binary, large, and contain raw chat / task history.
|
|
71
|
+
if (/\.(db|db-wal|db-shm)$/i.test(top)) return `sqlite database "${top}" — read via Forge APIs instead`;
|
|
72
|
+
// forge.log / forge.log.old — large + may contain tokens that leaked into errors.
|
|
73
|
+
if (/^forge\.log/i.test(top)) return 'forge.log is server diagnostics — ask the user to grep it directly';
|
|
74
|
+
// Token caches at root.
|
|
75
|
+
if (/-tokens\.json$/i.test(top)) return `"${top}" stores auth tokens — sensitive`;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
async function resolveDataPath(rel: string): Promise<string> {
|
|
79
|
+
const { getDataDir } = await import('../dirs');
|
|
80
|
+
const { resolve } = await import('node:path');
|
|
81
|
+
const clean = String(rel || '').replace(/^\/+/, '');
|
|
82
|
+
if (clean.includes('..') || clean.includes('\0')) throw new Error('invalid path');
|
|
83
|
+
const dataRoot = getDataDir();
|
|
84
|
+
const target = resolve(dataRoot, clean);
|
|
85
|
+
if (!target.startsWith(dataRoot + '/') && target !== dataRoot) {
|
|
86
|
+
throw new Error('resolved path escapes data dir');
|
|
87
|
+
}
|
|
88
|
+
const blocked = isSensitiveDataPath(target, dataRoot);
|
|
89
|
+
if (blocked) throw new Error(`refused: ${blocked}`);
|
|
90
|
+
return target;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A connector arg whose WHOLE value is one of these forms is a reference
|
|
94
|
+
// to a Forge-managed file. Path inside the prefix is dataDir-relative —
|
|
95
|
+
// e.g. "scratch://tmp/foo.md", "scratch://flows/x.yaml". The "scratch:"
|
|
96
|
+
// prefix is historical (originally only pointed at <dataDir>/scratch/);
|
|
97
|
+
// kept for compatibility.
|
|
98
|
+
const SCRATCH_REF_RE = /^(?:scratch:\/\/|scratch:|\/api\/scratch\/)(.+)$/;
|
|
99
|
+
|
|
100
|
+
// Replace any `scratch://<file>` arg with the file's base64 content,
|
|
101
|
+
// server-side. This is the whole point: the LLM can hand a connector a
|
|
102
|
+
// saved file (e.g. onedrive.upload_file content_base64) without ever
|
|
103
|
+
// hand-encoding it — hand-encoding large/non-ASCII content is unreliable
|
|
104
|
+
// and is exactly what wedged the OneDrive upload in an endless retry loop.
|
|
105
|
+
async function resolveScratchRefsInArgs(args: Record<string, unknown>): Promise<void> {
|
|
106
|
+
const { readFile } = await import('node:fs/promises');
|
|
107
|
+
for (const [k, v] of Object.entries(args)) {
|
|
108
|
+
if (typeof v !== 'string') continue;
|
|
109
|
+
const m = v.match(SCRATCH_REF_RE);
|
|
110
|
+
if (!m) continue;
|
|
111
|
+
// Path inside the ref is dataDir-relative. Goes through the same
|
|
112
|
+
// sensitive-file guard as read_forge_file (no leaking encrypt keys
|
|
113
|
+
// / DBs into a connector upload).
|
|
114
|
+
const buf = await readFile(await resolveDataPath(m[1])); // ENOENT → tool error
|
|
115
|
+
args[k] = buf.toString('base64');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
40
119
|
const BUILTINS: Record<string, BuiltinHandler> = {
|
|
41
120
|
get_current_time: async () => new Date().toISOString(),
|
|
42
121
|
|
|
@@ -310,31 +389,144 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
310
389
|
// gets a download link in the response. Prefer this over dispatch_task for
|
|
311
390
|
// "save / create / export a file" asks — the LLM already has the content
|
|
312
391
|
// in context, no need to spawn a CLI task that may write elsewhere.
|
|
313
|
-
|
|
392
|
+
save_tmp_file: async (input) => {
|
|
314
393
|
const params = (input as { filename?: string; content?: string } | undefined) || {};
|
|
315
394
|
const filename = (params.filename || '').trim();
|
|
316
395
|
const content = params.content == null ? '' : String(params.content);
|
|
317
396
|
if (!filename) return JSON.stringify({ ok: false, error: 'filename is required' });
|
|
318
|
-
|
|
319
|
-
|
|
397
|
+
// Bare filename only — no slashes anywhere. The cache janitor
|
|
398
|
+
// (lib/scratch-cleanup.ts) only sweeps top-level files in tmp/;
|
|
399
|
+
// allowing nested writes would create files that never expire.
|
|
400
|
+
// Also keeps the path returned in `path: "tmp/<filename>"` simple.
|
|
401
|
+
if (filename.includes('..') || filename.includes('/') || filename.includes('\\') || filename.includes('\0')) {
|
|
402
|
+
return JSON.stringify({ ok: false, error: 'filename must be a bare relative name (no slashes, no ".." — use date-stamped flat names like "report-2026-06-09.md")' });
|
|
320
403
|
}
|
|
321
|
-
|
|
322
|
-
|
|
404
|
+
// Land under <dataDir>/tmp/ — cache-managed location the user can
|
|
405
|
+
// sweep via "forge clean cache" / the user-menu Cache panel. Keeps
|
|
406
|
+
// <dataDir>/scratch/ for the synthetic "scratch project" workspace
|
|
407
|
+
// (where dispatched tasks live + edit their own files).
|
|
408
|
+
let target: string;
|
|
409
|
+
try { target = await resolveTmpPath(filename); }
|
|
410
|
+
catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
|
|
323
411
|
const { mkdir, writeFile } = await import('node:fs/promises');
|
|
324
|
-
const scratchRoot = join(getDataDir(), 'scratch');
|
|
325
|
-
const target = resolve(scratchRoot, filename);
|
|
326
|
-
if (!target.startsWith(scratchRoot + '/') && target !== scratchRoot) {
|
|
327
|
-
return JSON.stringify({ ok: false, error: 'resolved path escapes scratch dir' });
|
|
328
|
-
}
|
|
329
412
|
const { dirname } = await import('node:path');
|
|
413
|
+
// Bare filename → dirname is always <dataDir>/tmp/. mkdir -p idempotent.
|
|
330
414
|
await mkdir(dirname(target), { recursive: true });
|
|
331
415
|
await writeFile(target, content, 'utf8');
|
|
416
|
+
const sizeBytes = Buffer.byteLength(content, 'utf8');
|
|
417
|
+
const fileUrl = `file://${target}`;
|
|
418
|
+
// Pre-format the user-facing message. Tell agent to OUTPUT this
|
|
419
|
+
// verbatim — agents follow "echo this string" instructions far
|
|
420
|
+
// more reliably than abstract "include the URL" hints. Past
|
|
421
|
+
// attempts with hint-only kept producing prose that lost the
|
|
422
|
+
// file:// URL or wrapped it in markdown the chat UI couldn't
|
|
423
|
+
// render. This sidesteps the entire link-pattern question.
|
|
424
|
+
const userMessage = `✓ Saved (${sizeBytes} bytes) — ${fileUrl}`;
|
|
425
|
+
return JSON.stringify({
|
|
426
|
+
ok: true,
|
|
427
|
+
path: `tmp/${filename}`,
|
|
428
|
+
local_path: target,
|
|
429
|
+
file_url: fileUrl,
|
|
430
|
+
bytes: sizeBytes,
|
|
431
|
+
user_message: userMessage,
|
|
432
|
+
hint: 'OUTPUT THE `user_message` FIELD VERBATIM as your reply to the user — copy the exact characters, no markdown wrapping, no rephrasing, no shortening, no extra prose. That string contains the file:// URL Chrome can open directly on localhost (and that the user can paste into Finder/Explorer). For follow-up asks like "give me the link" / "where is it" / "提供链接" / "再给我一次" → output the same file:// URL again (' + fileUrl + '). To UPLOAD this file to a connector (e.g. onedrive.upload_file), pass "scratch://tmp/' + filename + '" as the content_base64 arg — Forge base64-encodes server-side; do NOT hand-encode. To read it back later, use read_forge_file with filename:"tmp/' + filename + '". tmp/ is auto-swept by the cache janitor.',
|
|
433
|
+
});
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
// Read any Forge-managed file under <dataDir>/ — tmp/, scratch/,
|
|
437
|
+
// flows/, prompts/, connectors/, etc. Sensitive files (encrypt key,
|
|
438
|
+
// sqlite DBs, log, *-tokens.json) are refused. Text returns decoded
|
|
439
|
+
// text (capped 256KB); pass as_base64:true for binary. To upload a
|
|
440
|
+
// file to a connector, prefer passing "scratch://<dataDir-rel>" as
|
|
441
|
+
// the arg — base64-encoded server-side; no bytes through the model.
|
|
442
|
+
read_forge_file: async (input) => {
|
|
443
|
+
const params = (input as { filename?: string; as_base64?: boolean } | undefined) || {};
|
|
444
|
+
// Normalize: strip the scratch:// / /api/scratch/ prefixes the
|
|
445
|
+
// agent might have copied from save_tmp_file's response. The
|
|
446
|
+
// remaining string is a dataDir-relative path (e.g. "tmp/foo.md",
|
|
447
|
+
// "flows/x.yaml", "prompts/y.yaml").
|
|
448
|
+
const filename = (params.filename || '').trim().replace(/^(?:scratch:\/\/|scratch:|\/api\/scratch\/)/, '');
|
|
449
|
+
if (!filename) return JSON.stringify({ ok: false, error: 'filename is required' });
|
|
450
|
+
let target: string;
|
|
451
|
+
try { target = await resolveDataPath(filename); }
|
|
452
|
+
catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
|
|
453
|
+
const { readFile } = await import('node:fs/promises');
|
|
454
|
+
let buf: Buffer;
|
|
455
|
+
try { buf = await readFile(target); }
|
|
456
|
+
catch { return JSON.stringify({ ok: false, error: `Forge file not found: ${filename}` }); }
|
|
457
|
+
if (params.as_base64) {
|
|
458
|
+
return JSON.stringify({
|
|
459
|
+
ok: true, path: filename,
|
|
460
|
+
local_path: target, file_url: `file://${target}`,
|
|
461
|
+
encoding: 'base64', size_bytes: buf.length, content: buf.toString('base64'),
|
|
462
|
+
// "scratch://" is the connector-ref protocol name (historical —
|
|
463
|
+
// not the scratch/ dir). Path after it is dataDir-relative.
|
|
464
|
+
note: `To upload to a connector, prefer passing "scratch://${filename}" directly as the connector arg — Forge resolves + base64-encodes it server-side, no need to round-trip these bytes through the model.`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
const MAX = 256 * 1024;
|
|
468
|
+
const truncated = buf.length > MAX;
|
|
469
|
+
return JSON.stringify({
|
|
470
|
+
ok: true, path: filename,
|
|
471
|
+
local_path: target, file_url: `file://${target}`,
|
|
472
|
+
encoding: 'utf-8', size_bytes: buf.length, truncated,
|
|
473
|
+
content: buf.subarray(0, MAX).toString('utf8'),
|
|
474
|
+
...(truncated ? { note: `content truncated to ${MAX} bytes — use as_base64 for the full file` } : {}),
|
|
475
|
+
});
|
|
476
|
+
},
|
|
477
|
+
|
|
478
|
+
// List files anywhere under <dataDir>/ (Forge's own data dir).
|
|
479
|
+
// Replaces the "dispatch a task just to `ls`" workaround. The `dir`
|
|
480
|
+
// param is dataDir-relative — pass "tmp" / "scratch" / "flows" /
|
|
481
|
+
// "connectors" / "prompts" / etc. No dir → lists dataDir root.
|
|
482
|
+
// Sensitive items (encrypt key, sqlite DBs, log, *-tokens.json at
|
|
483
|
+
// root) are skipped — see resolveDataPath.
|
|
484
|
+
list_forge_files: async (input) => {
|
|
485
|
+
const params = (input as { dir?: string; ext?: string; limit?: number } | undefined) || {};
|
|
486
|
+
const sub = String(params.dir || '').trim().replace(/^\/+|\/+$/g, '');
|
|
487
|
+
const ext = String(params.ext || '').trim().toLowerCase();
|
|
488
|
+
const limit = Math.max(1, Math.min(500, Number(params.limit) || 200));
|
|
489
|
+
const { getDataDir } = await import('../dirs');
|
|
490
|
+
const { join, relative } = await import('node:path');
|
|
491
|
+
const { readdir, stat } = await import('node:fs/promises');
|
|
492
|
+
const dataRoot = getDataDir();
|
|
493
|
+
let target: string;
|
|
494
|
+
try { target = sub ? await resolveDataPath(sub) : dataRoot; }
|
|
495
|
+
catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
|
|
496
|
+
let dirents;
|
|
497
|
+
try { dirents = await readdir(target, { withFileTypes: true }); }
|
|
498
|
+
catch (e) { return JSON.stringify({ ok: false, error: `cannot list ${sub || 'data/'}: ${(e as Error).message}` }); }
|
|
499
|
+
const entries: Array<{ path: string; kind: 'file' | 'dir'; size?: number; mtime?: string; file_url?: string }> = [];
|
|
500
|
+
for (const d of dirents) {
|
|
501
|
+
const full = join(target, d.name);
|
|
502
|
+
// Apply the same sensitive-file filter to entries we list — so the
|
|
503
|
+
// agent doesn't even see .encrypt-key / *.db etc.
|
|
504
|
+
if (isSensitiveDataPath(full, dataRoot)) continue;
|
|
505
|
+
if (ext && d.isFile() && !d.name.toLowerCase().endsWith(ext)) continue;
|
|
506
|
+
let size: number | undefined;
|
|
507
|
+
let mtime: string | undefined;
|
|
508
|
+
if (d.isFile()) {
|
|
509
|
+
try { const s = await stat(full); size = s.size; mtime = s.mtime.toISOString(); } catch {}
|
|
510
|
+
}
|
|
511
|
+
entries.push({
|
|
512
|
+
path: relative(dataRoot, full),
|
|
513
|
+
kind: d.isDirectory() ? 'dir' : 'file',
|
|
514
|
+
...(size !== undefined ? { size } : {}),
|
|
515
|
+
...(mtime ? { mtime } : {}),
|
|
516
|
+
// file:// URL the user can paste into Chrome (works on localhost).
|
|
517
|
+
// Provide on dirs too — opening a dir in Chrome shows the listing.
|
|
518
|
+
file_url: `file://${full}`,
|
|
519
|
+
});
|
|
520
|
+
if (entries.length >= limit) break;
|
|
521
|
+
}
|
|
522
|
+
entries.sort((a, b) => (b.mtime || '').localeCompare(a.mtime || '') || a.path.localeCompare(b.path));
|
|
332
523
|
return JSON.stringify({
|
|
333
524
|
ok: true,
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
525
|
+
root: relative(dataRoot, target) || '.',
|
|
526
|
+
local_root: target,
|
|
527
|
+
count: entries.length,
|
|
528
|
+
entries,
|
|
529
|
+
hint: 'When the user asks to show / list files, surface each entry\'s `path` (dataDir-relative, e.g. "tmp/foo.md") and `file_url` (file://… — opens in Chrome on localhost). Use read_forge_file with the same `path` to inspect content. PREFER this over dispatch_task with `ls`.',
|
|
338
530
|
});
|
|
339
531
|
},
|
|
340
532
|
|
|
@@ -365,6 +557,23 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
365
557
|
});
|
|
366
558
|
},
|
|
367
559
|
|
|
560
|
+
// Stop a still-running dispatched task. Use when the user says "停止
|
|
561
|
+
// / cancel / kill the task" — much better than telling them to open
|
|
562
|
+
// the Tasks UI. No-op (returns terminal:true) if the task already
|
|
563
|
+
// finished, so it's safe to call without checking status first.
|
|
564
|
+
cancel_task: async (input) => {
|
|
565
|
+
const params = (input as { task_id?: string } | undefined) || {};
|
|
566
|
+
if (!params.task_id) return JSON.stringify({ ok: false, error: 'task_id is required' });
|
|
567
|
+
const { getTask, cancelTask } = await import('../task-manager');
|
|
568
|
+
const task = getTask(params.task_id);
|
|
569
|
+
if (!task) return JSON.stringify({ ok: false, error: `Task "${params.task_id}" not found` });
|
|
570
|
+
if (task.status !== 'running' && task.status !== 'queued') {
|
|
571
|
+
return JSON.stringify({ ok: true, task_id: task.id, status: task.status, terminal: true, note: 'already terminal — no cancel needed' });
|
|
572
|
+
}
|
|
573
|
+
const cancelled = cancelTask(params.task_id);
|
|
574
|
+
return JSON.stringify({ ok: cancelled, task_id: task.id, status: cancelled ? 'cancelled' : task.status, terminal: cancelled });
|
|
575
|
+
},
|
|
576
|
+
|
|
368
577
|
// Companion to dispatch_task — read a task's status + result. Returns JSON
|
|
369
578
|
// so start_watch can poll via done_path="terminal" or done_match
|
|
370
579
|
// {path:"status", equals:"done"}.
|
|
@@ -630,7 +839,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
630
839
|
},
|
|
631
840
|
{
|
|
632
841
|
name: 'dispatch_task',
|
|
633
|
-
description: 'Dispatch a one-shot background Claude task in a Forge project.
|
|
842
|
+
description: 'Dispatch a one-shot background Claude CLI task in a Forge project. EXPENSIVE — spawns a fresh Claude subprocess that reads/edits code in the target project. Use ONLY for genuine codebase work: "analyze X repo and write findings", "run the test suite and summarize", "fix bug in <project>", "refactor module Y". \n\nDO NOT use for: \n • Saving a file with content YOU already have → use save_tmp_file. \n • Reading a Forge-owned file (tmp/scratch/flows/prompts/...) → use read_forge_file. \n • Listing files in <dataDir>/ → use list_forge_files. \n • Running a pipeline → use trigger_pipeline. \n • Inspecting a saved task → use get_task_status. \n\nFor "create / write / save a file with this content" the right tool is ALWAYS save_tmp_file — the LLM has the content, no CLI subprocess needed. \n\nIf the user\'s ask is ambiguous (might be a quick save vs a real codebase task), STOP and ask before dispatching — a user reporting "I just wanted a file" after seeing a task spawn is a clear signal you misclassified. \n\nReturns JSON: {ok, task_id, project, status, hint}. The task runs in the background; if the user wants completion notification, follow the hint — call start_watch on get_task_status and STOP polling in this conversation.',
|
|
634
843
|
input_schema: {
|
|
635
844
|
type: 'object',
|
|
636
845
|
properties: {
|
|
@@ -651,8 +860,8 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
651
860
|
},
|
|
652
861
|
},
|
|
653
862
|
{
|
|
654
|
-
name: '
|
|
655
|
-
description: 'Save text content to
|
|
863
|
+
name: 'save_tmp_file',
|
|
864
|
+
description: 'Save text content to <dataDir>/tmp/<filename>. USE THIS — never dispatch_task — when the user says "save this to a file", "create a file with X", "export the results", "give me a downloadable copy". The LLM already has the content; no CLI task needed. Returns `file_url` (file://… that opens directly in Chrome on localhost) plus `path` ("tmp/<filename>") + `local_path`. tmp/ is cache-managed and gets swept by the cache janitor / "forge clean cache" — tell the user it\'s ephemeral. To UPLOAD this file to a connector (e.g. onedrive.upload_file content_base64), pass "scratch://tmp/<filename>" as the arg — Forge base64-encodes server-side; do NOT hand-encode. To read it back: read_forge_file with filename:"tmp/<filename>". Filename must be a bare relative name (no .. or /).',
|
|
656
865
|
input_schema: {
|
|
657
866
|
type: 'object',
|
|
658
867
|
properties: {
|
|
@@ -668,6 +877,41 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
668
877
|
required: ['filename', 'content'],
|
|
669
878
|
},
|
|
670
879
|
},
|
|
880
|
+
{
|
|
881
|
+
name: 'read_forge_file',
|
|
882
|
+
description: 'Read any file Forge manages under <dataDir>/ — tmp/ (chat-saved), scratch/ (task workspaces), flows/, prompts/, connectors/, etc. PREFER THIS over dispatch_task when the user wants to inspect a Forge-owned file. filename is a dataDir-relative path: "tmp/report.md", "flows/x.yaml", "connectors/onedrive/manifest.yaml". Sensitive files (encrypt key, sqlite DBs, server log, *-tokens.json at root) are refused. Text returns decoded UTF-8 (capped 256KB); pass as_base64:true for binary (pdf, images, zip). To UPLOAD to a connector, prefer "scratch://<dataDir-rel>" arg — bytes never traverse the model.',
|
|
883
|
+
input_schema: {
|
|
884
|
+
type: 'object',
|
|
885
|
+
properties: {
|
|
886
|
+
filename: { type: 'string', description: 'dataDir-relative path, e.g. "tmp/report.md", "flows/mantis-bug-fix.yaml", "connectors/mantis/manifest.yaml".' },
|
|
887
|
+
as_base64: { type: 'boolean', description: 'Return raw bytes base64-encoded instead of decoded UTF-8 text. Use for binary files.' },
|
|
888
|
+
},
|
|
889
|
+
required: ['filename'],
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
name: 'list_forge_files',
|
|
894
|
+
description: 'List files / subdirs anywhere under <dataDir>/ (Forge\'s own data dir). PREFER THIS over `dispatch_task` with `ls` — it\'s sync, in-process, no LLM detour. `dir` is dataDir-relative: pass "tmp" for chat-saved temp files, "scratch" for task workspaces, "flows" / "prompts" / "connectors" for configs. Omit `dir` to see the dataDir root. Each entry has `path` (dataDir-relative), `kind` (file/dir), and `file_url` (file://… opens in Chrome on localhost). Sensitive items (encrypt key, sqlite DBs, server log, *-tokens.json) are filtered out automatically.',
|
|
895
|
+
input_schema: {
|
|
896
|
+
type: 'object',
|
|
897
|
+
properties: {
|
|
898
|
+
dir: { type: 'string', description: 'dataDir-relative directory. Examples: "tmp", "scratch", "flows", "prompts", "connectors/mantis". Omit to list dataDir root.' },
|
|
899
|
+
ext: { type: 'string', description: 'Optional file-extension filter (e.g. ".md", ".yaml"). Lowercase match on suffix.' },
|
|
900
|
+
limit: { type: 'number', description: 'Max entries (default 200, capped at 500).' },
|
|
901
|
+
},
|
|
902
|
+
},
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
name: 'cancel_task',
|
|
906
|
+
description: 'Cancel a still-running dispatched task by id. Use when the user says "停止 / cancel / kill the task" — much cleaner than telling them to open the Tasks UI. Safe to call on an already-terminal task (returns ok with note). task_id comes from dispatch_task\'s response.',
|
|
907
|
+
input_schema: {
|
|
908
|
+
type: 'object',
|
|
909
|
+
properties: {
|
|
910
|
+
task_id: { type: 'string', description: 'Task id from dispatch_task.' },
|
|
911
|
+
},
|
|
912
|
+
required: ['task_id'],
|
|
913
|
+
},
|
|
914
|
+
},
|
|
671
915
|
{
|
|
672
916
|
name: 'get_task_status',
|
|
673
917
|
description: "Check a dispatched Forge task's status + result by id. Pass task_id (returned by dispatch_task). Returns JSON: {id, status: 'queued'|'running'|'done'|'failed'|'cancelled', terminal: bool, project, result_summary?, error?, completed_at?}. For start_watch, use done_path=\"terminal\" (fires on done/failed/cancelled) or done_match={path:\"status\",equals:\"done\"}.",
|
|
@@ -1090,6 +1334,15 @@ export async function dispatchTool(
|
|
|
1090
1334
|
const cfgGap = preflightConnectorSettings(def, effectiveSettings);
|
|
1091
1335
|
if (cfgGap) return { content: cfgGap, is_error: true };
|
|
1092
1336
|
|
|
1337
|
+
// Resolve any `scratch://<file>` arg into the file's base64 content,
|
|
1338
|
+
// server-side — so the LLM can hand a saved file to a connector
|
|
1339
|
+
// (e.g. onedrive.upload_file content_base64) without hand-encoding it.
|
|
1340
|
+
try {
|
|
1341
|
+
await resolveScratchRefsInArgs(argInput);
|
|
1342
|
+
} catch (e) {
|
|
1343
|
+
return { content: `scratch reference failed: ${(e as Error).message}`, is_error: true };
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1093
1346
|
try {
|
|
1094
1347
|
let result: ToolResult;
|
|
1095
1348
|
switch (protocol) {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session control channel for an in-flight agent turn.
|
|
3
|
+
*
|
|
4
|
+
* A tool-call loop (agent-loop.ts) can otherwise run unattended for up to
|
|
5
|
+
* MAX_ITERATIONS with no way for the user to intervene — which is exactly
|
|
6
|
+
* how a doomed task melts an instance. This gives two levers, both scoped
|
|
7
|
+
* to a CURRENTLY RUNNING turn:
|
|
8
|
+
* • abort — break the loop cleanly at the next iteration boundary
|
|
9
|
+
* • notes — supplementary text the agent picks up on its next iteration
|
|
10
|
+
*
|
|
11
|
+
* State is in-process (chat-standalone owns the loop) and ephemeral: it is
|
|
12
|
+
* reset when the turn begins and cleared when it ends. Both levers no-op
|
|
13
|
+
* when no turn is running, so the caller can tell the user to just send a
|
|
14
|
+
* normal message instead.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface TurnControl {
|
|
18
|
+
running: boolean;
|
|
19
|
+
aborted: boolean;
|
|
20
|
+
notes: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const controls = new Map<string, TurnControl>();
|
|
24
|
+
|
|
25
|
+
function get(sessionId: string): TurnControl {
|
|
26
|
+
let c = controls.get(sessionId);
|
|
27
|
+
if (!c) {
|
|
28
|
+
c = { running: false, aborted: false, notes: [] };
|
|
29
|
+
controls.set(sessionId, c);
|
|
30
|
+
}
|
|
31
|
+
return c;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Mark a turn as live. Called by agent-loop at the top of runTurn.
|
|
35
|
+
* Sets running:true + aborted:false. Does NOT clear notes — endTurn
|
|
36
|
+
* is the one that clears them at completion. Important: if a turn was
|
|
37
|
+
* claimed via tryBeginTurn in enqueueChatInput and a note arrived in
|
|
38
|
+
* the async window before runTurn reached this point, the note must
|
|
39
|
+
* survive to be picked up at the first iteration boundary. Wiping
|
|
40
|
+
* here would silently drop user input. */
|
|
41
|
+
export function beginTurn(sessionId: string): void {
|
|
42
|
+
const c = get(sessionId);
|
|
43
|
+
c.running = true;
|
|
44
|
+
c.aborted = false;
|
|
45
|
+
// notes intentionally preserved — see comment above.
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Atomic claim — if no turn is running, mark running and return true.
|
|
49
|
+
* If a turn is already running, return false (caller should merge as note).
|
|
50
|
+
* Synchronous — closes the race window between "check isTurnRunning" and
|
|
51
|
+
* runTurn actually entering its loop and calling beginTurn. Without this,
|
|
52
|
+
* two enqueueChatInput calls arriving while runTurn is still in its async
|
|
53
|
+
* setup phase BOTH see isTurnRunning=false and BOTH fork a turn — exactly
|
|
54
|
+
* the duplicate-turn bug observed on 2026-06-10 (watch fired, user typed
|
|
55
|
+
* immediately after, both got separate runTurns and produced doubled
|
|
56
|
+
* tool calls). */
|
|
57
|
+
export function tryBeginTurn(sessionId: string): boolean {
|
|
58
|
+
const c = get(sessionId);
|
|
59
|
+
if (c.running) return false;
|
|
60
|
+
c.running = true;
|
|
61
|
+
c.aborted = false;
|
|
62
|
+
// notes intentionally NOT cleared here — any addNote that landed in
|
|
63
|
+
// the race window before claim is valid input for THIS new turn.
|
|
64
|
+
// beginTurn (called by agent-loop on entry) will set notes=[] only if
|
|
65
|
+
// this is the very first turn; otherwise the prior endTurn already
|
|
66
|
+
// cleared them.
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Turn finished (normally or via abort) — clear all transient state. */
|
|
71
|
+
export function endTurn(sessionId: string): void {
|
|
72
|
+
const c = get(sessionId);
|
|
73
|
+
c.running = false;
|
|
74
|
+
c.aborted = false;
|
|
75
|
+
c.notes = [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** User asked to stop. Returns false if no turn is running (nothing to abort). */
|
|
79
|
+
export function requestAbort(sessionId: string): boolean {
|
|
80
|
+
const c = get(sessionId);
|
|
81
|
+
if (!c.running) return false;
|
|
82
|
+
c.aborted = true;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isAborted(sessionId: string): boolean {
|
|
87
|
+
return get(sessionId).aborted;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isTurnRunning(sessionId: string): boolean {
|
|
91
|
+
return get(sessionId).running;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Queue supplementary info for the running turn. Returns false if idle. */
|
|
95
|
+
export function addNote(sessionId: string, text: string): boolean {
|
|
96
|
+
const c = get(sessionId);
|
|
97
|
+
if (!c.running) return false;
|
|
98
|
+
c.notes.push(text);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Take + clear the queued notes (called by the loop each iteration). */
|
|
103
|
+
export function consumeNotes(sessionId: string): string[] {
|
|
104
|
+
const c = get(sessionId);
|
|
105
|
+
if (c.notes.length === 0) return [];
|
|
106
|
+
const out = c.notes;
|
|
107
|
+
c.notes = [];
|
|
108
|
+
return out;
|
|
109
|
+
}
|
package/lib/chat-standalone.ts
CHANGED
|
@@ -33,9 +33,11 @@
|
|
|
33
33
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
34
34
|
import {
|
|
35
35
|
createSession, getSession, listSessions, updateSession, deleteSession, listMessages,
|
|
36
|
-
clearSessionMessages, ensureMainSession, forkSession,
|
|
36
|
+
clearSessionMessages, ensureMainSession, forkSession,
|
|
37
37
|
} from './chat/session-store';
|
|
38
|
-
import {
|
|
38
|
+
import { type AgentEvent } from './chat/agent-loop';
|
|
39
|
+
import { requestAbort, addNote, isTurnRunning } from './chat/turn-control';
|
|
40
|
+
import { enqueueChatInput } from './chat/input-queue';
|
|
39
41
|
import { bridgePush } from './chat/bridge-client';
|
|
40
42
|
import { startWatchRunner } from './watch/watch-runner';
|
|
41
43
|
|
|
@@ -173,20 +175,21 @@ async function handleMessagePost(req: IncomingMessage, res: ServerResponse, id:
|
|
|
173
175
|
const text = String(body?.text || '').trim();
|
|
174
176
|
if (!text) return sendJson(res, 400, { error: 'text is required' });
|
|
175
177
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
+
// Route through the single input queue — handles isTurnRunning merge
|
|
179
|
+
// (so extension + webchat sending on same session collapse into one
|
|
180
|
+
// turn) without this handler caring how. See lib/chat/input-queue.ts.
|
|
181
|
+
const r = enqueueChatInput({
|
|
178
182
|
sessionId: id,
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
183
|
+
text,
|
|
184
|
+
mode: 'turn',
|
|
185
|
+
source: 'user',
|
|
186
|
+
onEvent: (e) => fanoutEvent(id, e),
|
|
187
|
+
});
|
|
188
|
+
sendJson(res, 202, {
|
|
189
|
+
accepted: true,
|
|
190
|
+
merged: r.status === 'merged',
|
|
191
|
+
topic: `chat:${id}`,
|
|
187
192
|
});
|
|
188
|
-
|
|
189
|
-
sendJson(res, 202, { accepted: true, topic: `chat:${id}` });
|
|
190
193
|
}
|
|
191
194
|
|
|
192
195
|
/**
|
|
@@ -204,11 +207,46 @@ async function handleInjectMessage(req: IncomingMessage, res: ServerResponse, id
|
|
|
204
207
|
const role: 'user' | 'assistant' = body?.role === 'user' ? 'user' : 'assistant';
|
|
205
208
|
const blocks = Array.isArray(body?.blocks) ? body.blocks : null;
|
|
206
209
|
if (!blocks || blocks.length === 0) return sendJson(res, 400, { error: 'blocks[] required' });
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
// Announce mode — persist + SSE push, no agent invocation. Goes
|
|
211
|
+
// through the same input-queue for consistency (one entry point).
|
|
212
|
+
const r = enqueueChatInput({
|
|
213
|
+
sessionId: id,
|
|
214
|
+
blocks,
|
|
215
|
+
role,
|
|
216
|
+
mode: 'announce',
|
|
217
|
+
source: 'schedule',
|
|
218
|
+
onEvent: (e) => fanoutEvent(id, e),
|
|
219
|
+
});
|
|
220
|
+
if (!r.ok) return sendJson(res, 400, { error: r.reason || 'enqueue failed' });
|
|
221
|
+
sendJson(res, 200, { ok: true, message_id: r.messageId });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Abort the in-flight tool-call loop for a session. The loop breaks at its
|
|
225
|
+
// next iteration boundary and persists a "⏹ Stopped" sentinel. No-op (409)
|
|
226
|
+
// when no turn is running.
|
|
227
|
+
async function handleAbortPost(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
|
|
228
|
+
const session = getSession(id);
|
|
229
|
+
if (!session) return sendJson(res, 404, { error: 'session not found' });
|
|
230
|
+
// Loud log: every abort goes through here. If "Stop" fires without a
|
|
231
|
+
// visible UI click, this print pins the culprit (user-agent / referer).
|
|
232
|
+
const accepted = requestAbort(id);
|
|
233
|
+
if (!accepted) return sendJson(res, 409, { ok: false, running: false, error: 'no active turn to stop' });
|
|
234
|
+
return sendJson(res, 200, { ok: true, running: true });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Queue supplementary info for the running turn — the loop splices it in as
|
|
238
|
+
// a user message the agent sees on its next iteration. 409 when idle (the
|
|
239
|
+
// client should just send a normal message instead). The note appears in
|
|
240
|
+
// the transcript when the loop consumes it (single source of truth).
|
|
241
|
+
async function handleNotePost(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
|
|
242
|
+
const session = getSession(id);
|
|
243
|
+
if (!session) return sendJson(res, 404, { error: 'session not found' });
|
|
244
|
+
const body = await readJson(req);
|
|
245
|
+
const text = String(body?.text || '').trim();
|
|
246
|
+
if (!text) return sendJson(res, 400, { error: 'text is required' });
|
|
247
|
+
const queued = addNote(id, text);
|
|
248
|
+
if (!queued) return sendJson(res, 409, { ok: false, running: false, error: 'no active turn — send it as a normal message instead' });
|
|
249
|
+
return sendJson(res, 200, { ok: true, running: true });
|
|
212
250
|
}
|
|
213
251
|
|
|
214
252
|
function handleEventsSse(_req: IncomingMessage, res: ServerResponse, id: string): void {
|
|
@@ -276,6 +314,12 @@ async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
|
276
314
|
const inject = /^\/api\/sessions\/([^/]+)\/inject$/.exec(url.pathname);
|
|
277
315
|
if (inject && m === 'POST') return handleInjectMessage(req, res, inject[1]!);
|
|
278
316
|
|
|
317
|
+
const abort = /^\/api\/sessions\/([^/]+)\/abort$/.exec(url.pathname);
|
|
318
|
+
if (abort && m === 'POST') return handleAbortPost(req, res, abort[1]!);
|
|
319
|
+
|
|
320
|
+
const note = /^\/api\/sessions\/([^/]+)\/note$/.exec(url.pathname);
|
|
321
|
+
if (note && m === 'POST') return handleNotePost(req, res, note[1]!);
|
|
322
|
+
|
|
279
323
|
const fork = /^\/api\/sessions\/([^/]+)\/fork$/.exec(url.pathname);
|
|
280
324
|
if (fork && m === 'POST') return handleSessionFork(req, res, fork[1]!);
|
|
281
325
|
|
|
@@ -310,8 +354,18 @@ httpServer.listen(PORT, '127.0.0.1', () => {
|
|
|
310
354
|
startWatchRunner({
|
|
311
355
|
onProgress: (sessionId, payload) => fanoutEvent(sessionId, { type: 'watch_status', data: payload }),
|
|
312
356
|
runChat: (sessionId, text) => {
|
|
313
|
-
|
|
314
|
-
|
|
357
|
+
// Watch-triggered chat input. Routes through the single input
|
|
358
|
+
// queue — automatically merges into a running turn (as a note)
|
|
359
|
+
// or starts a fresh one. Without going through enqueueChatInput
|
|
360
|
+
// a watch firing mid-turn would spawn a concurrent runTurn and
|
|
361
|
+
// produce duplicate tool calls (regression seen on 2026-06-09).
|
|
362
|
+
enqueueChatInput({
|
|
363
|
+
sessionId,
|
|
364
|
+
text,
|
|
365
|
+
mode: 'turn',
|
|
366
|
+
source: 'watch',
|
|
367
|
+
onEvent: (e) => fanoutEvent(sessionId, e),
|
|
368
|
+
});
|
|
315
369
|
},
|
|
316
370
|
});
|
|
317
371
|
});
|
|
@@ -88,6 +88,22 @@ rm -rf $(npm root -g)/@aion0/forge $(npm root -g)/@aion0/.forge-*
|
|
|
88
88
|
npm install -g @aion0/forge
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
+
### Chat keeps looping / "thinking…" never finishes
|
|
92
|
+
The agent got stuck retrying a tool that can't succeed (e.g. a truncated
|
|
93
|
+
result it keeps re-fetching). While a turn is running the `/chat` composer
|
|
94
|
+
stays usable:
|
|
95
|
+
- **■ Stop** — aborts the tool-call loop at the next step and posts a
|
|
96
|
+
"⏹ Stopped by user" message. Use this to break a runaway turn.
|
|
97
|
+
- **Send** — type a correction/extra instruction and send it; while a turn
|
|
98
|
+
is running it's spliced into THAT turn (the agent reads it on its next
|
|
99
|
+
step), so you can redirect without cancelling. A normal `POST /messages`
|
|
100
|
+
is deliberately NOT used here — it would spawn a second concurrent loop on
|
|
101
|
+
the same session.
|
|
102
|
+
|
|
103
|
+
These hit `POST /api/sessions/:id/abort` and `/note` on the chat backend, so
|
|
104
|
+
any client (web `/chat`, extension) can drive them. `/note` no-ops (409) when
|
|
105
|
+
no turn is running — the page then sends it as a normal new message instead.
|
|
106
|
+
|
|
91
107
|
## Logs
|
|
92
108
|
|
|
93
109
|
- Background server: `~/.forge/data/forge.log`
|
|
@@ -165,6 +165,25 @@ tools:
|
|
|
165
165
|
- `{base_url}` / `{settings.<name>}` → expanded server-side from saved settings
|
|
166
166
|
- `{args.<name>}` → expanded by the runtime from the LLM's tool input
|
|
167
167
|
|
|
168
|
+
**Forge-file args (`scratch://` connector-ref protocol)**: any tool arg
|
|
169
|
+
whose whole value is a `scratch://<path>` reference is read from
|
|
170
|
+
`<dataDir>/<path>` and replaced with the file's **base64** content,
|
|
171
|
+
server-side, before the tool runs. The `scratch://` prefix is the
|
|
172
|
+
ref protocol name (historical — not tied to the `scratch/` subdir); the
|
|
173
|
+
path after it is **dataDir-relative**, so `scratch://tmp/foo.md`,
|
|
174
|
+
`scratch://scratch/report.md`, `scratch://flows/x.yaml` all work.
|
|
175
|
+
Sensitive items (encryption keys, sqlite DBs, server log, `*-tokens.json`
|
|
176
|
+
at root) are refused. Bare-form references (`scratch/<file>` and
|
|
177
|
+
`/api/scratch/<file>`) are accepted for back-compat — same dataDir-rooted
|
|
178
|
+
resolution. This lets the chat agent hand a saved file straight to a
|
|
179
|
+
connector (e.g. `onedrive.upload_file` `content_base64:
|
|
180
|
+
"scratch://tmp/report.md"`) without hand-encoding it — hand-encoding
|
|
181
|
+
large or non-ASCII content is unreliable. A missing file fails the call
|
|
182
|
+
with a clear error (no retry). The companion builtin `read_forge_file`
|
|
183
|
+
pulls Forge-managed file content back into chat context (same dataDir-
|
|
184
|
+
relative paths, same sensitive blacklist). For *uploading*, prefer the
|
|
185
|
+
`scratch://` ref so the bytes never round-trip through the model.
|
|
186
|
+
|
|
168
187
|
**`script` contract**:
|
|
169
188
|
- Receives `args` (the LLM's parameters)
|
|
170
189
|
- Returns a JSON-serializable value (no DOM nodes, no functions)
|