@aion0/forge 0.10.53 → 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.
@@ -28,6 +28,7 @@ import { buildMemoryContext } from './build-memory-context';
28
28
  import { buildReferencePromptSection } from './reference-prompt';
29
29
  import { buildMemoryTools } from './memory-tools';
30
30
  import { buildStartWatchTool } from '../watch/start-watch-tool';
31
+ import { beginTurn, endTurn, isAborted, consumeNotes } from './turn-control';
31
32
  import { estimateTokens } from '../memory/token-estimate';
32
33
  import {
33
34
  listInstalledConnectors,
@@ -419,9 +420,16 @@ function buildSystemPrompt(
419
420
  }
420
421
  }
421
422
  if (builtinDefs.length > 0) {
423
+ // Builtins are always-active (no connector_open gate) — list their
424
+ // FULL description so per-tool rules like "save_tmp_file: output
425
+ // user_message verbatim" / "dispatch_task: ASK before classifying
426
+ // a save as a task" reach the LLM here, not just buried in the
427
+ // tools schema. The connector catalog above can be terse because
428
+ // those tools only become live after connector_open; builtins
429
+ // never go through that gate, so terseness here loses real guidance.
422
430
  lines.push('', 'Builtin tools (always available):');
423
431
  for (const t of builtinDefs) {
424
- lines.push(`- ${t.name}: ${t.description.slice(0, 100)}`);
432
+ lines.push(`- ${t.name}: ${t.description}`);
425
433
  }
426
434
  }
427
435
 
@@ -754,10 +762,52 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
754
762
  let lastStop = '';
755
763
  let assistantBlocksAccum: ContentBlock[] = [];
756
764
 
765
+ // Mark this turn live so the user can abort it / inject notes mid-flight
766
+ // (see turn-control.ts). Cleared in the finally below.
767
+ beginTurn(args.sessionId);
768
+
757
769
  try {
758
770
  while (iter < MAX_ITERATIONS) {
759
771
  iter += 1;
760
772
 
773
+ // ── User intervention (turn-control) ────────────────────────
774
+ // Abort: stop the loop cleanly at this boundary with a sentinel so
775
+ // the turn doesn't just vanish. (The current in-flight LLM call /
776
+ // tool batch from the previous iteration has already settled here.)
777
+ if (isAborted(args.sessionId)) {
778
+ const stopMsg = appendMessage({
779
+ session_id: args.sessionId,
780
+ role: 'assistant',
781
+ blocks: [{ type: 'text', text: '⏹ Stopped by user.' } as TextBlock],
782
+ });
783
+ cb({ type: 'message_saved', message_id: stopMsg.id, data: stopMsg });
784
+ lastStop = 'aborted';
785
+ break;
786
+ }
787
+ // Supplementary notes: splice any queued user notes in as a user
788
+ // message so THIS iteration's LLM call sees them. Safe after a
789
+ // tool_result message — the adapter emits that as a `tool` message,
790
+ // so a following `user` message doesn't collide on role.
791
+ // Drain notes — each becomes its own user message so the thread
792
+ // shows them in arrival order (matches the optimistic messages the
793
+ // client already rendered when the user hit Send). The first note
794
+ // carries a flag for the model so it knows this is a mid-task
795
+ // redirect, not ambient chat; subsequent notes go raw to avoid
796
+ // cluttering the visible thread.
797
+ const notes = consumeNotes(args.sessionId);
798
+ if (notes.length > 0) {
799
+ const FLAG = '[mid-task interjection — sent WHILE you were running tools. Treat as an authoritative redirect that overrides any plan you announced earlier (count, target, scope). Adjust on the very next step.]';
800
+ for (let i = 0; i < notes.length; i++) {
801
+ const text = i === 0 ? `${FLAG}\n\n${notes[i]}` : notes[i]!;
802
+ const noteMsg = appendMessage({
803
+ session_id: args.sessionId,
804
+ role: 'user',
805
+ blocks: [{ type: 'text', text } as TextBlock],
806
+ });
807
+ cb({ type: 'message_saved', message_id: noteMsg.id, data: noteMsg });
808
+ }
809
+ }
810
+
761
811
  // ── Recompute open set every iteration ──────────────────────
762
812
  // Scan history (since last user text msg) + this turn's accumulated
763
813
  // blocks → which connectors are open right now. Then filter tools.
@@ -885,10 +935,28 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
885
935
 
886
936
  if (result.stopReason !== 'tool_use') break;
887
937
 
888
- // Execute tool calls
938
+ // Execute tool calls. The LLM can emit several tool_use blocks per
939
+ // iteration (parallel batch). Without an in-batch abort check, a
940
+ // user who clicks Stop after the batch starts has to wait for ALL
941
+ // tools to finish before the loop top-check fires next iter — feels
942
+ // like Stop did nothing. So: between tools, if abort was requested,
943
+ // skip the remaining ones with synthetic "aborted" tool_results
944
+ // (the tool_use/tool_result pairing invariant must hold for the
945
+ // Anthropic API; an orphan tool_use rejects the next call).
889
946
  const toolUses = result.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
890
947
  const toolResults: ToolResultBlock[] = [];
891
948
  for (const t of toolUses) {
949
+ if (isAborted(args.sessionId)) {
950
+ const block: ToolResultBlock = {
951
+ type: 'tool_result',
952
+ tool_use_id: t.id,
953
+ content: '⏹ Skipped — user requested stop.',
954
+ is_error: true,
955
+ };
956
+ toolResults.push(block);
957
+ cb({ type: 'tool_result', data: { tool_use_id: t.id, name: t.name, result: { content: block.content, is_error: true } } });
958
+ continue;
959
+ }
892
960
  const r = await dispatchTool({ id: t.id, name: t.name, input: t.input }, { extraBuiltins: memHandlers, sessionId: args.sessionId });
893
961
  const block: ToolResultBlock = {
894
962
  type: 'tool_result',
@@ -960,5 +1028,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
960
1028
  });
961
1029
  cb({ type: 'error', data: { error: msg } });
962
1030
  return { ok: false, error: msg };
1031
+ } finally {
1032
+ endTurn(args.sessionId);
963
1033
  }
964
1034
  }
@@ -81,17 +81,40 @@ export const LINK_PATTERNS: LinkPattern[] = [
81
81
  url: 'https://nvd.nist.gov/vuln/detail/{1}',
82
82
  label: '{1}',
83
83
  },
84
- // Forge scratch-dir files. LLMs frequently emit paths like
85
- // `scratch/foo.md` when they write reports during chat-launched tasks.
86
- // Link to the in-browser viewer at /scratch/<path> (page renders .md
87
- // through the chat markdown component + download button); the viewer
88
- // itself fetches /api/scratch/<path> for raw bytes.
84
+ // Forge-managed files inside <dataDir>/. LLMs emit:
85
+ // `scratch/foo.md` legacy task-workspace writes (kept under
86
+ // <dataDir>/scratch/, served by /scratch viewer)
87
+ // `tmp/foo.md` — chat's save_tmp_file output (<dataDir>/tmp/)
88
+ // `flows/x.yaml` — pipeline workflow definitions
89
+ // • `prompts/y.yaml` — schedule prompt bodies
90
+ // All four resolve through the /files/<dataDir-path> viewer (rendered
91
+ // markdown + download button), which fetches /api/files for raw bytes.
92
+ // Sensitive top-level items (encrypt key, sqlite DBs, log, token
93
+ // caches) are blocked at the API layer, so safe to be liberal here.
89
94
  {
90
95
  id: 'scratch-file',
91
96
  regex: /\bscratch\/([\w\-./]+?\.(?:md|txt|json|yaml|yml|csv|log|html|pdf|png|jpg|jpeg|gif|svg))\b/gi,
92
97
  url: '/scratch/{1}',
93
98
  label: 'scratch/{1}',
94
99
  },
100
+ {
101
+ id: 'tmp-file',
102
+ regex: /\btmp\/([\w\-./]+?\.(?:md|txt|json|yaml|yml|csv|log|html|pdf|png|jpg|jpeg|gif|svg))\b/gi,
103
+ url: '/files/tmp/{1}',
104
+ label: 'tmp/{1}',
105
+ },
106
+ {
107
+ id: 'flows-file',
108
+ regex: /\bflows\/([\w\-./]+?\.(?:yaml|yml|json))\b/gi,
109
+ url: '/files/flows/{1}',
110
+ label: 'flows/{1}',
111
+ },
112
+ {
113
+ id: 'prompts-file',
114
+ regex: /\bprompts\/([\w\-./]+?\.(?:yaml|yml|md|txt))\b/gi,
115
+ url: '/files/prompts/{1}',
116
+ label: 'prompts/{1}',
117
+ },
95
118
  ];
96
119
 
97
120
  export interface CompiledPattern {
@@ -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
+ }