@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.
@@ -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)
@@ -0,0 +1,125 @@
1
+ # Chat tools — what the chat agent can do directly
2
+
3
+ The chat agent (Forge's `/chat` page + the IDE plugin + Telegram bot) talks
4
+ to LLMs that can call **builtin tools** without round-tripping through a
5
+ background task. These cover the everyday "Forge knows / Forge owns" cases
6
+ so the agent doesn't have to dispatch a Claude CLI task just to read a
7
+ file or fire a pipeline.
8
+
9
+ This doc lists the tools the agent has by default and the workflows they
10
+ unlock. (Connector tools — `mantis.search_bugs`, `gitlab.list_my_todos`,
11
+ etc. — are documented in `17-connectors.md`.)
12
+
13
+ ## File access — `<dataDir>/`
14
+
15
+ Forge's data dir (`~/.forge/data/` by default, override with `FORGE_DATA_DIR`)
16
+ holds everything Forge owns: pipelines, schedules, prompts, connectors,
17
+ the sqlite DB, the encryption key, and two cache pools — `cloned-projects/`
18
+ and `tmp/`. The chat agent can read most of it and write to `tmp/`.
19
+
20
+ ### `save_tmp_file` — write to `<dataDir>/tmp/`
21
+ Use case: the user says *"save this report to a file"*, *"give me a
22
+ downloadable copy"*, *"export the results"*. The agent already has the
23
+ content in context — no CLI task needed.
24
+
25
+ The file lands in `<dataDir>/tmp/<filename>`. The response includes:
26
+ - `path`: `tmp/<filename>` — chat auto-links this string to the in-browser
27
+ viewer at `/files/tmp/<filename>` (renders markdown, download button)
28
+ - `file_url`: `file://<abs-path>` — opens directly in Chrome on localhost,
29
+ or the user can right-click → Show in Finder/Explorer
30
+ - `local_path`: absolute filesystem path
31
+
32
+ `tmp/` is **cache-managed** — counts in the user-menu Cache panel and gets
33
+ swept by the janitor after `scratchRetentionDays` (default 7 days). Tell
34
+ users their saved file is ephemeral; if they want it permanent, ask them
35
+ to copy it out.
36
+
37
+ ### `read_forge_file` — read anywhere under `<dataDir>/`
38
+ Path is dataDir-relative: `tmp/foo.md`, `scratch/report.md`, `flows/x.yaml`,
39
+ `prompts/y.yaml`, `connectors/mantis/manifest.yaml`, etc. Use this instead
40
+ of dispatching a `cat` task when the user wants to inspect a Forge-owned
41
+ file.
42
+
43
+ Sensitive items at the dataDir root are refused: `.encrypt-key`,
44
+ `.enterprise-keys.json`, any `*.db*`, `forge.log*`, `*-tokens.json`. The
45
+ chat agent can't accidentally leak these into chat context.
46
+
47
+ Pass `as_base64: true` for binary files (pdf, images, zip).
48
+
49
+ ### `list_forge_files` — list files anywhere under `<dataDir>/`
50
+ Pass `dir` as a dataDir-relative subdir (`tmp`, `scratch`, `flows`,
51
+ `connectors/mantis`). Each entry returns `path`, `kind` (file/dir),
52
+ `size`, `mtime`, and a `file_url` (file:// link).
53
+
54
+ Use this for *"what files are in tmp?"* / *"list my flows"* — never
55
+ dispatch a `ls` task for these.
56
+
57
+ ### `scratch://<path>` — connector-arg ref protocol
58
+ Use case: the agent wants to **upload** a Forge-owned file to a connector
59
+ (e.g. `onedrive.upload_file`, `teams.send_message` with an attachment).
60
+ Instead of reading the file into context and base64-encoding it via the
61
+ model (unreliable for non-ASCII, expensive for large files), pass
62
+ `scratch://tmp/report.md` as the connector arg's value. Forge resolves
63
+ the path under `<dataDir>/`, base64-encodes server-side, then dispatches.
64
+
65
+ `scratch://` is the historical name for the **ref protocol**, not the
66
+ `scratch/` subdir — the path after it is dataDir-relative. Bare forms
67
+ (`scratch/<file>`, `/api/scratch/<file>`) are also accepted for
68
+ back-compat. Same sensitive-file blacklist as `read_forge_file`.
69
+
70
+ ## Background tasks
71
+
72
+ ### `dispatch_task` — fire-and-forget a Claude CLI task
73
+ For longer-running asks the user wants in the background ("analyze this
74
+ codebase and write findings", "run the test suite and summarize"). Returns
75
+ a `task_id`. Pair with `get_task_status` (or `start_watch`) to follow up.
76
+
77
+ ### `cancel_task` — stop a running task by id
78
+ Use when the user says *"停止 / cancel / kill that task"*. Safe to call
79
+ on already-terminal tasks (returns ok + a note). Much cleaner than
80
+ telling the user to open the Tasks UI.
81
+
82
+ ### `get_task_status` — check a dispatched task
83
+ Returns `status`, `terminal`, `result_summary` (truncated to 1KB),
84
+ `error`, `completed_at`. For long-running polls, prefer `start_watch`
85
+ over manual polling — see `24-watch.md`.
86
+
87
+ ## Pipelines + schedules
88
+
89
+ The chat agent owns the full schedule CRUD surface and pipeline triggers:
90
+ `create_schedule`, `list_schedules`, `update_schedule`, `delete_schedule`,
91
+ `run_schedule_now`, `trigger_pipeline`, `get_pipeline_status`. See
92
+ `05-pipelines.md` and `13-schedules.md` for details on what the
93
+ underlying objects look like.
94
+
95
+ ## Stop / mid-task interjection
96
+
97
+ The user can interrupt a runaway tool-call loop at any time:
98
+ - **Stop button** — aborts the loop at the next iteration boundary, OR
99
+ between parallel tool calls (so a 5-way parallel batch can be stopped
100
+ mid-way). Persists a "⏹ Stopped by user." message in the thread.
101
+ - **Send during streaming** — typed text is queued as a **note** for the
102
+ running turn; the loop splices it in as a user message at the start of
103
+ the next iteration. The first such note carries a `[mid-task
104
+ interjection — …]` prefix so the model treats it as an authoritative
105
+ redirect, not ambient chat.
106
+
107
+ Both work on the web `/chat` page and the extension. If the user opens the
108
+ same chat session from two clients (e.g. extension + webchat) and posts a
109
+ follow-up while a turn is running, the server merges it into the running
110
+ loop as a note — no parallel turns on one session.
111
+
112
+ See `10-troubleshooting.md` § "Chat keeps looping" for recovery when a
113
+ runaway loop pre-dates Stop being available.
114
+
115
+ ## Help + introspection
116
+
117
+ `list_help_docs` + `read_help_doc` give the agent access to these same
118
+ docs so it can answer Forge questions directly. `list_forge_context`
119
+ returns the current instance's projects / agent profiles / installed
120
+ skills — call it before passing project / agent / skill names to
121
+ `dispatch_task` or `trigger_pipeline` to validate them.
122
+
123
+ `add_enterprise_key` registers a pasted enterprise marketplace key
124
+ (`fortinet:github_pat_…`) and triggers an immediate sync — see
125
+ `01-settings.md` § Marketplace Providers.
@@ -51,6 +51,7 @@ The token is valid for 24 hours. Store it in a variable and reuse for all API ca
51
51
  | `18-chrome-mcp.md` | Connect Forge Claude Code sessions to a real Chrome via chrome-devtools-mcp — dev-time browser access for connector authoring |
52
52
  | `23-automation-states.md` | Fortinet pipeline automation: GitLab MR stage labels, Mantis status flow, Teams notify policy |
53
53
  | `24-watch.md` | Background watches — async polling of long jobs (device upgrade, Jenkins build, test run) that report back in chat. Two triggers: connectors' declarative `async` block, and the `start_watch` builtin the assistant calls on the fly. Where to see/cancel them. |
54
+ | `25-chat-tools.md` | Builtin tools the chat agent has by default — file access (`save_tmp_file` / `read_forge_file` / `list_forge_files`), `scratch://` connector-ref protocol, `dispatch_task` / `cancel_task`, schedules CRUD, pipelines, Stop + mid-task notes. |
54
55
 
55
56
  ## Matching questions to docs
56
57
 
@@ -86,3 +87,4 @@ The token is valid for 24 hours. Store it in a variable and reuse for all API ca
86
87
  - Recipe / "From recipe" form / parameterized job → tell user: **recipes deprecated** along with Jobs; fire pipelines manually or via Schedules.
87
88
  - Mantis bug fix / fortinet-mantis-bug-fix / open MR for Mantis bug / fortinet-mr-review / pre-review / GitLab stage labels → `23-automation-states.md` (kept Fortinet pipelines)
88
89
  - Background watch / "watch this build" / "tell me when the upgrade is done" / progress chip / start_watch / async long job / why is the assistant polling → `24-watch.md`
90
+ - save_tmp_file / read_forge_file / list_forge_files / cancel_task / scratch:// ref / `<dataDir>/tmp/` / Stop / mid-task note / "what tools does the chat agent have" → `25-chat-tools.md`
@@ -1,14 +1,14 @@
1
1
  /**
2
- * Scratch directory janitor — deletes stale files under <dataDir>/scratch/.
2
+ * Cache-dir janitor — deletes stale files under:
3
+ * <dataDir>/tmp/ — chat-saved files (save_tmp_file)
4
+ * <dataDir>/scratch/ — legacy chat-saved location (kept for back-compat)
3
5
  *
4
- * The save_scratch_file chat tool drops generated reports here so the user
5
- * can download them; without periodic sweeping the directory grows forever.
6
- * Retention is settings.scratchRetentionDays (default 7).
6
+ * Without periodic sweeping the dirs grow forever. Retention is
7
+ * settings.scratchRetentionDays (default 7).
7
8
  *
8
- * Conservative scope: only sweeps PLAIN FILES at the top level of scratch/.
9
- * - Skips dotfiles (.mcp.json, .claude/, .forge/) Forge/Claude project state
10
- * - Skips CLAUDE.md scratch project marker
11
- * - Skips subdirectories — could be user-created project trees we don't own
9
+ * Conservative scope: only top-level files. Skips dotfiles, the
10
+ * scratch-project marker CLAUDE.md, and subdirs (could be user-created
11
+ * project trees we don't own).
12
12
  */
13
13
 
14
14
  import { readdirSync, statSync, unlinkSync } from 'node:fs';
@@ -29,16 +29,13 @@ function retentionMs(): number {
29
29
  return days * ONE_DAY_MS;
30
30
  }
31
31
 
32
- export function cleanupScratch(): { scanned: number; deleted: number; freedBytes: number } {
33
- const root = join(getDataDir(), 'scratch');
32
+ function sweepDir(root: string, cutoff: number, skipNames: Set<string>): { scanned: number; deleted: number; freedBytes: number } {
34
33
  let scanned = 0, deleted = 0, freedBytes = 0;
35
- const maxAge = retentionMs();
36
- const cutoff = Date.now() - maxAge;
37
34
  let entries: string[] = [];
38
35
  try { entries = readdirSync(root); } catch { return { scanned, deleted, freedBytes }; }
39
36
  for (const name of entries) {
40
37
  if (name.startsWith('.')) continue;
41
- if (name === 'CLAUDE.md') continue;
38
+ if (skipNames.has(name)) continue;
42
39
  const p = join(root, name);
43
40
  let st;
44
41
  try { st = statSync(p); } catch { continue }
@@ -48,12 +45,24 @@ export function cleanupScratch(): { scanned: number; deleted: number; freedBytes
48
45
  try { unlinkSync(p); deleted++; freedBytes += st.size; }
49
46
  catch (e) { console.warn('[scratch-cleanup] unlink failed:', p, (e as Error).message); }
50
47
  }
51
- if (deleted > 0) {
52
- console.log(`[scratch-cleanup] deleted ${deleted}/${scanned} files, freed ${freedBytes} bytes`);
53
- }
54
48
  return { scanned, deleted, freedBytes };
55
49
  }
56
50
 
51
+ export function cleanupScratch(): { scanned: number; deleted: number; freedBytes: number } {
52
+ const cutoff = Date.now() - retentionMs();
53
+ const tmp = sweepDir(join(getDataDir(), 'tmp'), cutoff, new Set());
54
+ const scratch = sweepDir(join(getDataDir(), 'scratch'), cutoff, new Set(['CLAUDE.md']));
55
+ const total = {
56
+ scanned: tmp.scanned + scratch.scanned,
57
+ deleted: tmp.deleted + scratch.deleted,
58
+ freedBytes: tmp.freedBytes + scratch.freedBytes,
59
+ };
60
+ if (total.deleted > 0) {
61
+ console.log(`[scratch-cleanup] deleted ${total.deleted}/${total.scanned} files (tmp=${tmp.deleted}, scratch=${scratch.deleted}), freed ${total.freedBytes} bytes`);
62
+ }
63
+ return total;
64
+ }
65
+
57
66
  let started = false;
58
67
  export function startScratchCleanup(): void {
59
68
  if (started) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.53",
3
+ "version": "0.10.55",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {