@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.
@@ -16,7 +16,18 @@ function extOf(p: string): string {
16
16
  return m ? m[1].toLowerCase() : '';
17
17
  }
18
18
 
19
- export default function ScratchViewer({ path }: { path: string }) {
19
+ export default function ScratchViewer({
20
+ path,
21
+ apiBase = '/api/scratch',
22
+ topLabel = 'scratch',
23
+ }: {
24
+ path: string;
25
+ /** API route prefix. /api/scratch resolves under <dataDir>/scratch/;
26
+ * /api/files resolves dataDir-wide. Default scratch for back-compat. */
27
+ apiBase?: string;
28
+ /** Tiny header tag shown next to the file path (e.g. "scratch", "file"). */
29
+ topLabel?: string;
30
+ }) {
20
31
  const decoded = (() => {
21
32
  try {
22
33
  return decodeURIComponent(path);
@@ -25,7 +36,7 @@ export default function ScratchViewer({ path }: { path: string }) {
25
36
  }
26
37
  })();
27
38
  const ext = extOf(decoded);
28
- const rawUrl = `/api/scratch/${path}`;
39
+ const rawUrl = `${apiBase}/${path}`;
29
40
  const downloadUrl = `${rawUrl}?download=1`;
30
41
 
31
42
  const [text, setText] = useState<string | null>(null);
@@ -67,7 +78,7 @@ export default function ScratchViewer({ path }: { path: string }) {
67
78
  <div className="min-h-screen bg-[var(--bg-primary)] text-[var(--text-primary)]">
68
79
  <header className="sticky top-0 z-10 flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--border)] bg-[var(--bg-secondary)]">
69
80
  <div className="flex items-center gap-2 min-w-0">
70
- <span className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">scratch</span>
81
+ <span className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">{topLabel}</span>
71
82
  <span className="text-xs font-mono truncate" title={decoded}>
72
83
  {decoded}
73
84
  </span>
@@ -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
 
@@ -559,6 +567,19 @@ export interface RunTurnArgs {
559
567
 
560
568
  export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?: string }> {
561
569
  const cb = args.callbacks?.onEvent ?? (() => {});
570
+
571
+ // Claim the turn-control flag FIRST — before any early-return path
572
+ // (session-not-found, provider-error). The outer try/finally below
573
+ // guarantees endTurn runs in EVERY exit path, including early returns
574
+ // and uncaught throws, so the running flag never gets stuck true.
575
+ //
576
+ // The caller (enqueueChatInput) may have already claimed via
577
+ // tryBeginTurn — beginTurn is idempotent on running:true and doesn't
578
+ // wipe notes that may have arrived in the claim→here window.
579
+ beginTurn(args.sessionId);
580
+
581
+ try {
582
+
562
583
  const session = getSession(args.sessionId);
563
584
  if (!session) {
564
585
  cb({ type: 'error', data: { error: `session not found: ${args.sessionId}` } });
@@ -754,10 +775,53 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
754
775
  let lastStop = '';
755
776
  let assistantBlocksAccum: ContentBlock[] = [];
756
777
 
778
+ // beginTurn already ran at function entry (see top of runTurn). The
779
+ // try/finally below is for the LLM-streaming error path — keep it
780
+ // so fetch errors still get fanned out as 'error' events. endTurn is
781
+ // handled by the OUTER try/finally so removing it from this inner
782
+ // finally is intentional (avoids redundant calls).
757
783
  try {
758
784
  while (iter < MAX_ITERATIONS) {
759
785
  iter += 1;
760
786
 
787
+ // ── User intervention (turn-control) ────────────────────────
788
+ // Abort: stop the loop cleanly at this boundary with a sentinel so
789
+ // the turn doesn't just vanish. (The current in-flight LLM call /
790
+ // tool batch from the previous iteration has already settled here.)
791
+ if (isAborted(args.sessionId)) {
792
+ const stopMsg = appendMessage({
793
+ session_id: args.sessionId,
794
+ role: 'assistant',
795
+ blocks: [{ type: 'text', text: '⏹ Stopped by user.' } as TextBlock],
796
+ });
797
+ cb({ type: 'message_saved', message_id: stopMsg.id, data: stopMsg });
798
+ lastStop = 'aborted';
799
+ break;
800
+ }
801
+ // Supplementary notes: splice any queued user notes in as a user
802
+ // message so THIS iteration's LLM call sees them. Safe after a
803
+ // tool_result message — the adapter emits that as a `tool` message,
804
+ // so a following `user` message doesn't collide on role.
805
+ // Drain notes — each becomes its own user message so the thread
806
+ // shows them in arrival order (matches the optimistic messages the
807
+ // client already rendered when the user hit Send). The first note
808
+ // carries a flag for the model so it knows this is a mid-task
809
+ // redirect, not ambient chat; subsequent notes go raw to avoid
810
+ // cluttering the visible thread.
811
+ const notes = consumeNotes(args.sessionId);
812
+ if (notes.length > 0) {
813
+ 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.]';
814
+ for (let i = 0; i < notes.length; i++) {
815
+ const text = i === 0 ? `${FLAG}\n\n${notes[i]}` : notes[i]!;
816
+ const noteMsg = appendMessage({
817
+ session_id: args.sessionId,
818
+ role: 'user',
819
+ blocks: [{ type: 'text', text } as TextBlock],
820
+ });
821
+ cb({ type: 'message_saved', message_id: noteMsg.id, data: noteMsg });
822
+ }
823
+ }
824
+
761
825
  // ── Recompute open set every iteration ──────────────────────
762
826
  // Scan history (since last user text msg) + this turn's accumulated
763
827
  // blocks → which connectors are open right now. Then filter tools.
@@ -885,10 +949,28 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
885
949
 
886
950
  if (result.stopReason !== 'tool_use') break;
887
951
 
888
- // Execute tool calls
952
+ // Execute tool calls. The LLM can emit several tool_use blocks per
953
+ // iteration (parallel batch). Without an in-batch abort check, a
954
+ // user who clicks Stop after the batch starts has to wait for ALL
955
+ // tools to finish before the loop top-check fires next iter — feels
956
+ // like Stop did nothing. So: between tools, if abort was requested,
957
+ // skip the remaining ones with synthetic "aborted" tool_results
958
+ // (the tool_use/tool_result pairing invariant must hold for the
959
+ // Anthropic API; an orphan tool_use rejects the next call).
889
960
  const toolUses = result.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
890
961
  const toolResults: ToolResultBlock[] = [];
891
962
  for (const t of toolUses) {
963
+ if (isAborted(args.sessionId)) {
964
+ const block: ToolResultBlock = {
965
+ type: 'tool_result',
966
+ tool_use_id: t.id,
967
+ content: '⏹ Skipped — user requested stop.',
968
+ is_error: true,
969
+ };
970
+ toolResults.push(block);
971
+ cb({ type: 'tool_result', data: { tool_use_id: t.id, name: t.name, result: { content: block.content, is_error: true } } });
972
+ continue;
973
+ }
892
974
  const r = await dispatchTool({ id: t.id, name: t.name, input: t.input }, { extraBuiltins: memHandlers, sessionId: args.sessionId });
893
975
  const block: ToolResultBlock = {
894
976
  type: 'tool_result',
@@ -961,4 +1043,15 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
961
1043
  cb({ type: 'error', data: { error: msg } });
962
1044
  return { ok: false, error: msg };
963
1045
  }
1046
+ // Inner try (LLM streaming) ends here — no finally; the OUTER
1047
+ // finally below owns endTurn so it runs even when an early return
1048
+ // above (session-not-found / provider-error) skips the inner loop.
1049
+
1050
+ } finally {
1051
+ // Outer finally — runs on ALL exit paths, including the early
1052
+ // returns at session-not-found and provider-error, and any throw
1053
+ // anywhere in the function. Guarantees the turn-control running
1054
+ // flag is always cleared so future inputs can start fresh turns.
1055
+ endTurn(args.sessionId);
1056
+ }
964
1057
  }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Chat input queue — the single routing/merge layer for all external
3
+ * inputs to a chat session.
4
+ *
5
+ * Why this file exists. Before, every input source had to re-implement
6
+ * the "if a turn is already running, merge into it instead of forking
7
+ * a new turn" gate (see handleMessagePost vs the watch runChat callback).
8
+ * Easy to forget — and forgetting it caused the 2026-06-09 bug where a
9
+ * watch firing mid-turn produced duplicate trigger_pipeline calls (commit
10
+ * 0d48569 patched that one path). User: "你应该把所有的消息输入别是 chat
11
+ * 放在一个队列通道中,然后合并处理".
12
+ *
13
+ * Now there's exactly ONE entry point. All callers — user POST /messages,
14
+ * watch on_done/on_fail injects, schedule action announcements, future
15
+ * Slack/IDE/cron sources — call enqueueChatInput. The function decides
16
+ * whether to (a) merge into the running turn as a note, (b) start a new
17
+ * turn, or (c) just persist+broadcast an announcement without invoking
18
+ * the LLM.
19
+ *
20
+ * Per-session queueing already lives in turn-control.ts (notes Map keyed
21
+ * by sessionId). When multi-conversation features land later, they just
22
+ * spawn more sessions — the same merge semantics apply per-session
23
+ * without code changes here.
24
+ */
25
+
26
+ import { appendMessage } from './session-store';
27
+ import { runTurn, type AgentEvent } from './agent-loop';
28
+ import type { ContentBlock } from './types';
29
+ import { isTurnRunning, addNote, tryBeginTurn } from './turn-control';
30
+
31
+ /** Where the input came from. Pure metadata — used for logs + decisions
32
+ * about default behavior (e.g. watch text might want a tag prefix). */
33
+ export type InputSource = 'user' | 'watch' | 'schedule' | 'bridge' | 'mcp' | 'unknown';
34
+
35
+ export interface EnqueueOpts {
36
+ sessionId: string;
37
+ /** For 'turn' mode: the raw user-side text the agent should see.
38
+ * For 'announce' mode: optional shortcut — wrapped in [{type:'text'}]. */
39
+ text?: string;
40
+ /** For 'announce' mode: pre-formed message blocks. Wins over `text`. */
41
+ blocks?: ContentBlock[];
42
+ /** Announce role. Default 'assistant' — a system-emitted notice. */
43
+ role?: 'user' | 'assistant';
44
+ /**
45
+ * 'turn' — drive the agent. Either start a new turn or merge into
46
+ * an already-running one as a note (LLM sees on next
47
+ * iteration boundary). MOST CALLERS USE THIS.
48
+ * 'announce' — push a pre-formed message into the thread, no agent
49
+ * invocation. Used by schedule chat-action so its
50
+ * completion notice shows up without triggering a reply.
51
+ */
52
+ mode: 'turn' | 'announce';
53
+ source: InputSource;
54
+ /** Sink for SSE events fanned out to subscribers. Required for 'turn'
55
+ * mode (or the UI sees no streaming). 'announce' uses it to push the
56
+ * message_saved event so open tabs render the new message live. */
57
+ onEvent?: (e: AgentEvent) => void;
58
+ }
59
+
60
+ export interface EnqueueResult {
61
+ ok: boolean;
62
+ /** 'started' — fresh runTurn kicked off
63
+ * 'merged' — text queued as a note for the in-flight turn
64
+ * 'announced' — message persisted + broadcast, no turn
65
+ * 'rejected' — input invalid (empty text in turn mode, etc.) */
66
+ status: 'started' | 'merged' | 'announced' | 'rejected';
67
+ /** Populated by 'announced' — the persisted message's id, so the
68
+ * /inject HTTP response can return it (existing API contract). */
69
+ messageId?: string;
70
+ reason?: string;
71
+ }
72
+
73
+ /**
74
+ * The single entry point. Routes by mode + isTurnRunning state.
75
+ *
76
+ * Sync (returns immediately). For 'turn' mode it kicks off runTurn in
77
+ * the background — caller doesn't await; events flow through onEvent.
78
+ */
79
+ export function enqueueChatInput(opts: EnqueueOpts): EnqueueResult {
80
+ if (opts.mode === 'announce') {
81
+ // Persist the message without invoking the LLM. role defaults to
82
+ // 'assistant' (system-emitted notice). Caller supplies blocks
83
+ // directly or a plain text shortcut.
84
+ const blocks: ContentBlock[] = opts.blocks
85
+ ?? [{ type: 'text', text: (opts.text ?? '') } as ContentBlock];
86
+ if (blocks.length === 0) {
87
+ return { ok: false, status: 'rejected', reason: 'announce requires blocks or text' };
88
+ }
89
+ const saved = appendMessage({
90
+ session_id: opts.sessionId,
91
+ role: opts.role || 'assistant',
92
+ blocks,
93
+ });
94
+ opts.onEvent?.({ type: 'message_saved', message_id: saved.id, data: saved });
95
+ return { ok: true, status: 'announced', messageId: saved.id };
96
+ }
97
+
98
+ // 'turn' mode — agent must drive a response.
99
+ const text = (opts.text || '').trim();
100
+ if (!text) {
101
+ return { ok: false, status: 'rejected', reason: 'turn mode requires non-empty text' };
102
+ }
103
+
104
+ // If a turn is already in flight on this session, queue the text as
105
+ // a note for that running turn. The loop splices each note in at the
106
+ // next iteration boundary (see agent-loop.ts:790). This is the merge
107
+ // semantic — replaces every caller having to gate isTurnRunning itself.
108
+ if (isTurnRunning(opts.sessionId)) {
109
+ const queued = addNote(opts.sessionId, text);
110
+ if (queued) return { ok: true, status: 'merged' };
111
+ // Tiny race: isTurnRunning was true but endTurn fired between the
112
+ // check and addNote. Fall through to start a fresh turn.
113
+ }
114
+
115
+ // Atomic claim — closes the gap between this check and the in-loop
116
+ // beginTurn() call inside runTurn. runTurn does ~100ms of async setup
117
+ // (appendMessage, listMessagesCapped, provider resolve, SSE pushes)
118
+ // BEFORE its beginTurn runs; without claiming here, a second input
119
+ // arriving during that window would also see isTurnRunning=false and
120
+ // fork another runTurn. Both then race on the same chat history and
121
+ // produce duplicate tool calls (observed 2026-06-10: watch + user
122
+ // input → two save_tmp_file calls, two LLM replies).
123
+ //
124
+ // If tryBeginTurn returns false, another caller claimed first within
125
+ // the same JS tick — merge as a note into THAT turn instead. Cheap +
126
+ // correct: addNote on a now-running session always succeeds.
127
+ const claimed = tryBeginTurn(opts.sessionId);
128
+ if (!claimed) {
129
+ addNote(opts.sessionId, text);
130
+ return { ok: true, status: 'merged' };
131
+ }
132
+
133
+ // Claimed — start a fresh one. Fire-
134
+ // and-forget; errors land on the onEvent stream as 'error' events.
135
+ // Two failure modes:
136
+ // (a) runTurn throws (rejected Promise) — caught by .catch.
137
+ // (b) runTurn resolves with {ok:false, error:'...'} — handled in
138
+ // the .then; this case happens in agent-loop's known-error
139
+ // paths (context budget exhausted / orphan tool-use trim /
140
+ // fetch failure with adapter detail). Missing this branch
141
+ // would leave the UI hanging with no error indicator.
142
+ const startedAt = Date.now();
143
+ const onEvent = opts.onEvent;
144
+ void runTurn({
145
+ sessionId: opts.sessionId,
146
+ userText: text,
147
+ callbacks: { onEvent: onEvent || (() => {}) },
148
+ }).then((r) => {
149
+ if (!r.ok) {
150
+ onEvent?.({ type: 'error', data: { error: r.error || 'turn failed' } });
151
+ }
152
+ const ms = Date.now() - startedAt;
153
+ console.log(`[chat] turn ${opts.sessionId.slice(0, 8)} (${opts.source}) done in ${ms}ms ok=${r.ok}`);
154
+ }).catch((err) => {
155
+ console.error(`[input-queue] runTurn threw (source=${opts.source}):`, (err as Error).message);
156
+ onEvent?.({ type: 'error', data: { error: (err as Error).message } });
157
+ });
158
+ return { ok: true, status: 'started' };
159
+ }
@@ -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 {