@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.
- package/RELEASE_NOTES.md +3 -3
- package/app/api/cache/route.ts +125 -41
- package/app/api/chat/sessions/[id]/abort/route.ts +14 -0
- package/app/api/chat/sessions/[id]/note/route.ts +16 -0
- package/app/api/files/[...path]/route.ts +94 -0
- package/app/api/scratch/[...path]/route.ts +5 -0
- package/app/chat/page.tsx +237 -36
- package/app/files/[...path]/page.tsx +22 -0
- package/components/Dashboard.tsx +76 -24
- package/components/ScratchViewer.tsx +14 -3
- package/lib/chat/agent-loop.ts +72 -2
- package/lib/chat/link-patterns.ts +28 -5
- package/lib/chat/tool-dispatcher.ts +270 -17
- package/lib/chat/turn-control.ts +81 -0
- package/lib/chat-standalone.ts +51 -0
- package/lib/help-docs/10-troubleshooting.md +16 -0
- package/lib/help-docs/17-connectors.md +19 -0
- package/lib/help-docs/25-chat-tools.md +125 -0
- package/lib/help-docs/CLAUDE.md +2 -0
- package/lib/scratch-cleanup.ts +25 -16
- package/package.json +1 -1
package/lib/chat-standalone.ts
CHANGED
|
@@ -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.
|
package/lib/help-docs/CLAUDE.md
CHANGED
|
@@ -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`
|
package/lib/scratch-cleanup.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
|
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