@aion0/forge 0.10.51 → 0.10.55

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.
@@ -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
- save_scratch_file: async (input) => {
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
- if (filename.includes('..') || filename.startsWith('/') || filename.includes('\0')) {
319
- return JSON.stringify({ ok: false, error: 'filename must be a bare relative name (no .. / leading /)' });
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
- const { getDataDir } = await import('../dirs');
322
- const { join, resolve } = await import('node:path');
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
- path: `scratch/${filename}`,
335
- url: `/api/scratch/${filename}`,
336
- bytes: Buffer.byteLength(content, 'utf8'),
337
- hint: 'Tell the user the file is ready and reference it inline as `scratch/' + filename + '` — chat will render a download link automatically.',
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. Use for longer-running asks the user wants to fire-and-forget ("analyze X codebase and write findings to a file", "run the test suite and summarize failures"). Returns JSON: {ok, task_id, project, status, hint}. The task runs in the background; if the user wants to be notified on completion, follow the hint — call start_watch on get_task_status and STOP polling in this conversation.',
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: 'save_scratch_file',
655
- description: 'Save text content to a file under <dataDir>/scratch/ and return a clickable download URL. USE THIS — not 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 provides the content directly (you already have it in context); no CLI task needed. Chat will auto-link any `scratch/<filename>` mention in your reply as a download link, so just mention the returned path in prose. Scratch is EPHEMERAL files are auto-deleted after settings.scratchRetentionDays (default 7 days); tell the user this when handing over the link. Filename must be a bare relative name (no .. or /). Allowed extensions for the auto-link: .md .txt .json .yaml .yml .csv .log .html .pdf .png .jpg .jpeg .gif .svg.',
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,81 @@
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. Resets abort + drains stale notes from any prior turn. */
35
+ export function beginTurn(sessionId: string): void {
36
+ const c = get(sessionId);
37
+ c.running = true;
38
+ c.aborted = false;
39
+ c.notes = [];
40
+ }
41
+
42
+ /** Turn finished (normally or via abort) — clear all transient state. */
43
+ export function endTurn(sessionId: string): void {
44
+ const c = get(sessionId);
45
+ c.running = false;
46
+ c.aborted = false;
47
+ c.notes = [];
48
+ }
49
+
50
+ /** User asked to stop. Returns false if no turn is running (nothing to abort). */
51
+ export function requestAbort(sessionId: string): boolean {
52
+ const c = get(sessionId);
53
+ if (!c.running) return false;
54
+ c.aborted = true;
55
+ return true;
56
+ }
57
+
58
+ export function isAborted(sessionId: string): boolean {
59
+ return get(sessionId).aborted;
60
+ }
61
+
62
+ export function isTurnRunning(sessionId: string): boolean {
63
+ return get(sessionId).running;
64
+ }
65
+
66
+ /** Queue supplementary info for the running turn. Returns false if idle. */
67
+ export function addNote(sessionId: string, text: string): boolean {
68
+ const c = get(sessionId);
69
+ if (!c.running) return false;
70
+ c.notes.push(text);
71
+ return true;
72
+ }
73
+
74
+ /** Take + clear the queued notes (called by the loop each iteration). */
75
+ export function consumeNotes(sessionId: string): string[] {
76
+ const c = get(sessionId);
77
+ if (c.notes.length === 0) return [];
78
+ const out = c.notes;
79
+ c.notes = [];
80
+ return out;
81
+ }
@@ -36,6 +36,7 @@ import {
36
36
  clearSessionMessages, ensureMainSession, forkSession, appendMessage,
37
37
  } from './chat/session-store';
38
38
  import { runTurn, type AgentEvent } from './chat/agent-loop';
39
+ import { requestAbort, addNote, isTurnRunning } from './chat/turn-control';
39
40
  import { bridgePush } from './chat/bridge-client';
40
41
  import { startWatchRunner } from './watch/watch-runner';
41
42
 
@@ -173,6 +174,22 @@ async function handleMessagePost(req: IncomingMessage, res: ServerResponse, id:
173
174
  const text = String(body?.text || '').trim();
174
175
  if (!text) return sendJson(res, 400, { error: 'text is required' });
175
176
 
177
+ // Merge instead of forking: if a turn is already running on this session,
178
+ // queue the text as a note for the running turn instead of starting a
179
+ // second concurrent runTurn. Two parallel loops on the same session
180
+ // (e.g. extension + webchat both sending) would otherwise interleave
181
+ // tool calls and share turn-control state — one abort would stop both,
182
+ // and the user sees the same task twice. This makes the merge happen
183
+ // server-side so it doesn't depend on which client is sending.
184
+ if (isTurnRunning(id)) {
185
+ const queued = addNote(id, text);
186
+ if (queued) {
187
+ return sendJson(res, 202, { accepted: true, merged: true, topic: `chat:${id}` });
188
+ }
189
+ // Tiny race window: isTurnRunning was true but turn ended before we
190
+ // queued. Fall through to start a fresh turn with this text.
191
+ }
192
+
176
193
  const startedAt = Date.now();
177
194
  void runTurn({
178
195
  sessionId: id,
@@ -211,6 +228,34 @@ async function handleInjectMessage(req: IncomingMessage, res: ServerResponse, id
211
228
  sendJson(res, 200, { ok: true, message_id: saved.id });
212
229
  }
213
230
 
231
+ // Abort the in-flight tool-call loop for a session. The loop breaks at its
232
+ // next iteration boundary and persists a "⏹ Stopped" sentinel. No-op (409)
233
+ // when no turn is running.
234
+ async function handleAbortPost(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
235
+ const session = getSession(id);
236
+ if (!session) return sendJson(res, 404, { error: 'session not found' });
237
+ // Loud log: every abort goes through here. If "Stop" fires without a
238
+ // visible UI click, this print pins the culprit (user-agent / referer).
239
+ const accepted = requestAbort(id);
240
+ if (!accepted) return sendJson(res, 409, { ok: false, running: false, error: 'no active turn to stop' });
241
+ return sendJson(res, 200, { ok: true, running: true });
242
+ }
243
+
244
+ // Queue supplementary info for the running turn — the loop splices it in as
245
+ // a user message the agent sees on its next iteration. 409 when idle (the
246
+ // client should just send a normal message instead). The note appears in
247
+ // the transcript when the loop consumes it (single source of truth).
248
+ async function handleNotePost(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
249
+ const session = getSession(id);
250
+ if (!session) return sendJson(res, 404, { error: 'session not found' });
251
+ const body = await readJson(req);
252
+ const text = String(body?.text || '').trim();
253
+ if (!text) return sendJson(res, 400, { error: 'text is required' });
254
+ const queued = addNote(id, text);
255
+ if (!queued) return sendJson(res, 409, { ok: false, running: false, error: 'no active turn — send it as a normal message instead' });
256
+ return sendJson(res, 200, { ok: true, running: true });
257
+ }
258
+
214
259
  function handleEventsSse(_req: IncomingMessage, res: ServerResponse, id: string): void {
215
260
  // Verify the session exists before opening the stream.
216
261
  const session = getSession(id);
@@ -276,6 +321,12 @@ async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
276
321
  const inject = /^\/api\/sessions\/([^/]+)\/inject$/.exec(url.pathname);
277
322
  if (inject && m === 'POST') return handleInjectMessage(req, res, inject[1]!);
278
323
 
324
+ const abort = /^\/api\/sessions\/([^/]+)\/abort$/.exec(url.pathname);
325
+ if (abort && m === 'POST') return handleAbortPost(req, res, abort[1]!);
326
+
327
+ const note = /^\/api\/sessions\/([^/]+)\/note$/.exec(url.pathname);
328
+ if (note && m === 'POST') return handleNotePost(req, res, note[1]!);
329
+
279
330
  const fork = /^\/api\/sessions\/([^/]+)\/fork$/.exec(url.pathname);
280
331
  if (fork && m === 'POST') return handleSessionFork(req, res, fork[1]!);
281
332
 
@@ -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)