@aion0/forge 0.10.50 → 0.10.53

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 CHANGED
@@ -1,8 +1,8 @@
1
- # Forge v0.10.50
1
+ # Forge v0.10.53
2
2
 
3
3
  Released: 2026-06-09
4
4
 
5
- ## Changes since v0.10.49
5
+ ## Changes since v0.10.52
6
6
 
7
7
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.49...v0.10.50
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.52...v0.10.53
@@ -0,0 +1,83 @@
1
+ /**
2
+ * GET /api/cache → list cached cloned-project dirs + total size.
3
+ * DELETE /api/cache → wipe the whole cloned-projects cache.
4
+ * DELETE /api/cache?name=<entry> → wipe a single entry.
5
+ *
6
+ * Backs the "Cache" item in the user dropdown. Same data the CLI's
7
+ * `forge clean cache` shows, surfaced in the UI for users who don't
8
+ * touch the terminal.
9
+ */
10
+
11
+ import { NextResponse } from 'next/server';
12
+ import { existsSync, readdirSync, rmSync, statSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { getDataDir } from '@/lib/dirs';
15
+
16
+ function dirSizeBytes(p: string): number {
17
+ let total = 0;
18
+ try {
19
+ for (const ent of readdirSync(p, { withFileTypes: true })) {
20
+ const child = join(p, ent.name);
21
+ try {
22
+ if (ent.isDirectory()) total += dirSizeBytes(child);
23
+ else total += statSync(child).size;
24
+ } catch { /* skip unreadable */ }
25
+ }
26
+ } catch { /* skip unreadable */ }
27
+ return total;
28
+ }
29
+
30
+ function listCache(): { entries: { name: string; bytes: number }[]; total_bytes: number; root: string } {
31
+ const root = join(getDataDir(), 'cloned-projects');
32
+ if (!existsSync(root)) return { entries: [], total_bytes: 0, root };
33
+ const entries: { name: string; bytes: number }[] = [];
34
+ for (const ent of readdirSync(root, { withFileTypes: true })) {
35
+ if (!ent.isDirectory()) continue;
36
+ entries.push({ name: ent.name, bytes: dirSizeBytes(join(root, ent.name)) });
37
+ }
38
+ entries.sort((a, b) => b.bytes - a.bytes);
39
+ return { entries, total_bytes: entries.reduce((s, e) => s + e.bytes, 0), root };
40
+ }
41
+
42
+ export async function GET() {
43
+ return NextResponse.json(listCache());
44
+ }
45
+
46
+ export async function DELETE(req: Request) {
47
+ const url = new URL(req.url);
48
+ const name = url.searchParams.get('name');
49
+ const root = join(getDataDir(), 'cloned-projects');
50
+ if (!existsSync(root)) {
51
+ return NextResponse.json({ ok: true, deleted: 0, freed_bytes: 0 });
52
+ }
53
+ // Reject anything that escapes the cache root or contains separators —
54
+ // we accept ONLY a direct subdirectory name (e.g. "fortinet-fortinac-dev").
55
+ if (name && (name.includes('/') || name.includes('\\') || name.includes('..'))) {
56
+ return NextResponse.json({ ok: false, error: 'invalid name' }, { status: 400 });
57
+ }
58
+
59
+ const before = listCache();
60
+ if (name) {
61
+ const target = join(root, name);
62
+ if (!existsSync(target)) {
63
+ return NextResponse.json({ ok: false, error: 'not found' }, { status: 404 });
64
+ }
65
+ const hit = before.entries.find((e) => e.name === name);
66
+ try {
67
+ rmSync(target, { recursive: true, force: true });
68
+ } catch (e) {
69
+ return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
70
+ }
71
+ return NextResponse.json({ ok: true, deleted: 1, freed_bytes: hit?.bytes || 0 });
72
+ }
73
+
74
+ // Wipe all
75
+ let deleted = 0;
76
+ for (const e of before.entries) {
77
+ try {
78
+ rmSync(join(root, e.name), { recursive: true, force: true });
79
+ deleted++;
80
+ } catch { /* skip individual failures, report partial */ }
81
+ }
82
+ return NextResponse.json({ ok: true, deleted, freed_bytes: before.total_bytes });
83
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * /scratch/<path...> — in-browser viewer for files under <dataDir>/scratch/.
3
+ *
4
+ * Why this page exists: the raw `/api/scratch/<path>` route returns the
5
+ * file with its proper MIME, which works for images / pdf but is poor UX
6
+ * for markdown — browsers either show raw text or trigger a download
7
+ * (depending on the browser's text/markdown handling).
8
+ *
9
+ * This wrapper renders .md through MarkdownContent (same renderer as
10
+ * chat), and falls back to a plain <pre> for other text formats. It also
11
+ * exposes a one-click download link for any file type.
12
+ */
13
+
14
+ import ScratchViewer from '@/components/ScratchViewer';
15
+
16
+ export default async function ScratchPage({
17
+ params,
18
+ }: {
19
+ params: Promise<{ path: string[] }>;
20
+ }) {
21
+ const { path } = await params;
22
+ const joined = (path || []).map(encodeURIComponent).join('/');
23
+ return <ScratchViewer path={joined} />;
24
+ }
package/cli/clean.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `forge clean` — wipe Forge's on-disk caches.
3
+ *
4
+ * Today: just `forge clean cache` which removes auto-cloned gitlab
5
+ * repos under `<dataDir>/cloned-projects/`. Pipelines that need them
6
+ * re-clone on the next dispatch (one-shot slowdown, no broken state).
7
+ *
8
+ * Add more cache targets here as they appear — keep one obvious entry
9
+ * point so users don't have to learn N subcommands.
10
+ */
11
+
12
+ import { existsSync, readdirSync, rmSync, statSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+
16
+ function dataDir(): string {
17
+ return process.env.FORGE_DATA_DIR || join(homedir(), '.forge', 'data');
18
+ }
19
+
20
+ function dirSizeBytes(p: string): number {
21
+ let total = 0;
22
+ try {
23
+ for (const ent of readdirSync(p, { withFileTypes: true })) {
24
+ const child = join(p, ent.name);
25
+ try {
26
+ if (ent.isDirectory()) total += dirSizeBytes(child);
27
+ else total += statSync(child).size;
28
+ } catch { /* skip unreadable */ }
29
+ }
30
+ } catch { /* skip unreadable */ }
31
+ return total;
32
+ }
33
+
34
+ function humanBytes(n: number): string {
35
+ if (n < 1024) return `${n} B`;
36
+ if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`;
37
+ if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
38
+ return `${(n / 1024 ** 3).toFixed(1)} GB`;
39
+ }
40
+
41
+ function printHelp(): void {
42
+ console.log(`
43
+ forge clean — wipe Forge caches.
44
+
45
+ Subcommands:
46
+ forge clean cache List cached items + sizes (dry-run).
47
+ forge clean cache --yes Actually delete everything.
48
+ forge clean cache <name> --yes Delete one entry (e.g. fortinet-fortinac-dev).
49
+
50
+ Targets cleaned:
51
+ • <dataDir>/cloned-projects/ Auto-cloned gitlab repos (gitlab fallback).
52
+ `);
53
+ }
54
+
55
+ async function cmdCache(args: string[]): Promise<void> {
56
+ const root = join(dataDir(), 'cloned-projects');
57
+ const yes = args.includes('--yes') || args.includes('-y');
58
+ const target = args.find((a) => !a.startsWith('-'));
59
+
60
+ if (!existsSync(root)) {
61
+ console.log(`Nothing to clean. ${root} does not exist.`);
62
+ return;
63
+ }
64
+
65
+ const entries: { name: string; path: string; size: number }[] = [];
66
+ for (const ent of readdirSync(root, { withFileTypes: true })) {
67
+ if (!ent.isDirectory()) continue;
68
+ const p = join(root, ent.name);
69
+ entries.push({ name: ent.name, path: p, size: dirSizeBytes(p) });
70
+ }
71
+ entries.sort((a, b) => b.size - a.size);
72
+
73
+ if (entries.length === 0) {
74
+ console.log(`Nothing to clean. ${root} is empty.`);
75
+ return;
76
+ }
77
+
78
+ const totalBytes = entries.reduce((s, e) => s + e.size, 0);
79
+
80
+ // Single-target mode
81
+ if (target) {
82
+ const hit = entries.find((e) => e.name === target);
83
+ if (!hit) {
84
+ console.error(`Not found: ${target}`);
85
+ console.error(`Available: ${entries.map(e => e.name).join(', ')}`);
86
+ process.exit(1);
87
+ }
88
+ console.log(`${hit.name} ${humanBytes(hit.size)} ${hit.path}`);
89
+ if (!yes) {
90
+ console.log('\nDry-run. Add --yes to actually delete.');
91
+ return;
92
+ }
93
+ try {
94
+ rmSync(hit.path, { recursive: true, force: true });
95
+ console.log(`✓ Deleted ${hit.name}`);
96
+ } catch (e) {
97
+ console.error(`Failed: ${(e as Error).message}`);
98
+ process.exit(1);
99
+ }
100
+ return;
101
+ }
102
+
103
+ // List / wipe-all mode
104
+ console.log(`Cached repos under ${root}:\n`);
105
+ for (const e of entries) {
106
+ console.log(` ${e.name.padEnd(40)} ${humanBytes(e.size).padStart(10)}`);
107
+ }
108
+ console.log(`\nTotal: ${entries.length} entries, ${humanBytes(totalBytes)}`);
109
+
110
+ if (!yes) {
111
+ console.log('\nDry-run. Add --yes to delete everything.');
112
+ return;
113
+ }
114
+
115
+ let deleted = 0;
116
+ for (const e of entries) {
117
+ try {
118
+ rmSync(e.path, { recursive: true, force: true });
119
+ deleted++;
120
+ } catch (err) {
121
+ console.error(`Failed ${e.name}: ${(err as Error).message}`);
122
+ }
123
+ }
124
+ console.log(`\n✓ Deleted ${deleted} entries (${humanBytes(totalBytes)} freed).`);
125
+ }
126
+
127
+ export async function cleanCommand(args: string[]): Promise<void> {
128
+ const sub = args[0] || '';
129
+ switch (sub) {
130
+ case 'cache':
131
+ await cmdCache(args.slice(1));
132
+ break;
133
+ case '--help':
134
+ case '-h':
135
+ case '':
136
+ default:
137
+ printHelp();
138
+ break;
139
+ }
140
+ }
package/cli/mw.mjs CHANGED
@@ -758,15 +758,15 @@ function migrateDataDir() {
758
758
  migrated = true;
759
759
  if (process.env.FORGE_DATA_DIR) return;
760
760
  const configDir = getConfigDir();
761
- const dataDir2 = join3(configDir, "data");
761
+ const dataDir3 = join3(configDir, "data");
762
762
  const oldSettings = join3(configDir, "settings.yaml");
763
- const newSettings = join3(dataDir2, "settings.yaml");
763
+ const newSettings = join3(dataDir3, "settings.yaml");
764
764
  if (!existsSync3(oldSettings) || existsSync3(newSettings)) return;
765
765
  console.log("[forge] Migrating data from ~/.forge/ to ~/.forge/data/...");
766
- if (!existsSync3(dataDir2)) mkdirSync2(dataDir2, { recursive: true });
766
+ if (!existsSync3(dataDir3)) mkdirSync2(dataDir3, { recursive: true });
767
767
  for (const file2 of MIGRATE_FILES) {
768
768
  const src = join3(configDir, file2);
769
- const dest = join3(dataDir2, file2);
769
+ const dest = join3(dataDir3, file2);
770
770
  if (existsSync3(src) && !existsSync3(dest)) {
771
771
  try {
772
772
  copyFileSync(src, dest);
@@ -776,7 +776,7 @@ function migrateDataDir() {
776
776
  }
777
777
  }
778
778
  const oldDb = join3(configDir, "data.db");
779
- const newDb = join3(dataDir2, "workflow.db");
779
+ const newDb = join3(dataDir3, "workflow.db");
780
780
  if (existsSync3(oldDb) && !existsSync3(newDb)) {
781
781
  try {
782
782
  copyFileSync(oldDb, newDb);
@@ -786,7 +786,7 @@ function migrateDataDir() {
786
786
  }
787
787
  for (const dir of MIGRATE_DIRS) {
788
788
  const src = join3(configDir, dir);
789
- const dest = join3(dataDir2, dir);
789
+ const dest = join3(dataDir3, dir);
790
790
  if (existsSync3(src) && !existsSync3(dest)) {
791
791
  try {
792
792
  renameSync(src, dest);
@@ -1221,6 +1221,135 @@ var init_key = __esm({
1221
1221
  }
1222
1222
  });
1223
1223
 
1224
+ // cli/clean.ts
1225
+ var clean_exports = {};
1226
+ __export(clean_exports, {
1227
+ cleanCommand: () => cleanCommand
1228
+ });
1229
+ import { existsSync as existsSync7, readdirSync as readdirSync2, rmSync as rmSync3, statSync as statSync3 } from "node:fs";
1230
+ import { join as join8 } from "node:path";
1231
+ import { homedir as homedir6 } from "node:os";
1232
+ function dataDir2() {
1233
+ return process.env.FORGE_DATA_DIR || join8(homedir6(), ".forge", "data");
1234
+ }
1235
+ function dirSizeBytes2(p) {
1236
+ let total = 0;
1237
+ try {
1238
+ for (const ent of readdirSync2(p, { withFileTypes: true })) {
1239
+ const child = join8(p, ent.name);
1240
+ try {
1241
+ if (ent.isDirectory()) total += dirSizeBytes2(child);
1242
+ else total += statSync3(child).size;
1243
+ } catch {
1244
+ }
1245
+ }
1246
+ } catch {
1247
+ }
1248
+ return total;
1249
+ }
1250
+ function humanBytes(n) {
1251
+ if (n < 1024) return `${n} B`;
1252
+ if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`;
1253
+ if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
1254
+ return `${(n / 1024 ** 3).toFixed(1)} GB`;
1255
+ }
1256
+ function printHelp4() {
1257
+ console.log(`
1258
+ forge clean \u2014 wipe Forge caches.
1259
+
1260
+ Subcommands:
1261
+ forge clean cache List cached items + sizes (dry-run).
1262
+ forge clean cache --yes Actually delete everything.
1263
+ forge clean cache <name> --yes Delete one entry (e.g. fortinet-fortinac-dev).
1264
+
1265
+ Targets cleaned:
1266
+ \u2022 <dataDir>/cloned-projects/ Auto-cloned gitlab repos (gitlab fallback).
1267
+ `);
1268
+ }
1269
+ async function cmdCache(args2) {
1270
+ const root = join8(dataDir2(), "cloned-projects");
1271
+ const yes = args2.includes("--yes") || args2.includes("-y");
1272
+ const target = args2.find((a) => !a.startsWith("-"));
1273
+ if (!existsSync7(root)) {
1274
+ console.log(`Nothing to clean. ${root} does not exist.`);
1275
+ return;
1276
+ }
1277
+ const entries = [];
1278
+ for (const ent of readdirSync2(root, { withFileTypes: true })) {
1279
+ if (!ent.isDirectory()) continue;
1280
+ const p = join8(root, ent.name);
1281
+ entries.push({ name: ent.name, path: p, size: dirSizeBytes2(p) });
1282
+ }
1283
+ entries.sort((a, b) => b.size - a.size);
1284
+ if (entries.length === 0) {
1285
+ console.log(`Nothing to clean. ${root} is empty.`);
1286
+ return;
1287
+ }
1288
+ const totalBytes = entries.reduce((s, e) => s + e.size, 0);
1289
+ if (target) {
1290
+ const hit = entries.find((e) => e.name === target);
1291
+ if (!hit) {
1292
+ console.error(`Not found: ${target}`);
1293
+ console.error(`Available: ${entries.map((e) => e.name).join(", ")}`);
1294
+ process.exit(1);
1295
+ }
1296
+ console.log(`${hit.name} ${humanBytes(hit.size)} ${hit.path}`);
1297
+ if (!yes) {
1298
+ console.log("\nDry-run. Add --yes to actually delete.");
1299
+ return;
1300
+ }
1301
+ try {
1302
+ rmSync3(hit.path, { recursive: true, force: true });
1303
+ console.log(`\u2713 Deleted ${hit.name}`);
1304
+ } catch (e) {
1305
+ console.error(`Failed: ${e.message}`);
1306
+ process.exit(1);
1307
+ }
1308
+ return;
1309
+ }
1310
+ console.log(`Cached repos under ${root}:
1311
+ `);
1312
+ for (const e of entries) {
1313
+ console.log(` ${e.name.padEnd(40)} ${humanBytes(e.size).padStart(10)}`);
1314
+ }
1315
+ console.log(`
1316
+ Total: ${entries.length} entries, ${humanBytes(totalBytes)}`);
1317
+ if (!yes) {
1318
+ console.log("\nDry-run. Add --yes to delete everything.");
1319
+ return;
1320
+ }
1321
+ let deleted = 0;
1322
+ for (const e of entries) {
1323
+ try {
1324
+ rmSync3(e.path, { recursive: true, force: true });
1325
+ deleted++;
1326
+ } catch (err) {
1327
+ console.error(`Failed ${e.name}: ${err.message}`);
1328
+ }
1329
+ }
1330
+ console.log(`
1331
+ \u2713 Deleted ${deleted} entries (${humanBytes(totalBytes)} freed).`);
1332
+ }
1333
+ async function cleanCommand(args2) {
1334
+ const sub = args2[0] || "";
1335
+ switch (sub) {
1336
+ case "cache":
1337
+ await cmdCache(args2.slice(1));
1338
+ break;
1339
+ case "--help":
1340
+ case "-h":
1341
+ case "":
1342
+ default:
1343
+ printHelp4();
1344
+ break;
1345
+ }
1346
+ }
1347
+ var init_clean = __esm({
1348
+ "cli/clean.ts"() {
1349
+ "use strict";
1350
+ }
1351
+ });
1352
+
1224
1353
  // cli/mw.ts
1225
1354
  var _cliPort = process.argv.find((a, i) => i > 0 && process.argv[i - 1] === "--port");
1226
1355
  var BASE2 = process.env.MW_URL || `http://localhost:${_cliPort || "3000"}`;
@@ -1228,9 +1357,9 @@ var [, , cmd, ...args] = process.argv;
1228
1357
  async function checkForUpdate() {
1229
1358
  try {
1230
1359
  const { readFileSync: readFileSync5 } = await import("node:fs");
1231
- const { join: join8, dirname } = await import("node:path");
1360
+ const { join: join9, dirname } = await import("node:path");
1232
1361
  const { fileURLToPath } = await import("node:url");
1233
- const pkg = JSON.parse(readFileSync5(join8(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
1362
+ const pkg = JSON.parse(readFileSync5(join9(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
1234
1363
  const current = pkg.version;
1235
1364
  const controller = new AbortController();
1236
1365
  const timeout = setTimeout(() => controller.abort(), 3e3);
@@ -1265,10 +1394,10 @@ async function api3(path, opts) {
1265
1394
  async function main() {
1266
1395
  if (cmd === "--version" || cmd === "-v") {
1267
1396
  const { readFileSync: readFileSync5 } = await import("node:fs");
1268
- const { join: join8, dirname } = await import("node:path");
1397
+ const { join: join9, dirname } = await import("node:path");
1269
1398
  const { fileURLToPath } = await import("node:url");
1270
1399
  try {
1271
- const pkg = JSON.parse(readFileSync5(join8(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
1400
+ const pkg = JSON.parse(readFileSync5(join9(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
1272
1401
  console.log(`@aion0/forge v${pkg.version}`);
1273
1402
  } catch {
1274
1403
  console.log("forge (version unknown)");
@@ -1277,18 +1406,18 @@ async function main() {
1277
1406
  }
1278
1407
  if (cmd === "--reset-password") {
1279
1408
  const { spawnSync: spawnSync3 } = await import("node:child_process");
1280
- const { join: join8, dirname } = await import("node:path");
1409
+ const { join: join9, dirname } = await import("node:path");
1281
1410
  const { fileURLToPath } = await import("node:url");
1282
- const serverScript = join8(dirname(fileURLToPath(import.meta.url)), "..", "bin", "forge-server.mjs");
1411
+ const serverScript = join9(dirname(fileURLToPath(import.meta.url)), "..", "bin", "forge-server.mjs");
1283
1412
  const passthru = process.argv.slice(3);
1284
1413
  spawnSync3("node", [serverScript, "--reset-password", ...passthru], { stdio: "inherit" });
1285
1414
  process.exit(0);
1286
1415
  }
1287
1416
  if (cmd === "--add-enterprise-key") {
1288
1417
  const { spawnSync: spawnSync3 } = await import("node:child_process");
1289
- const { join: join8, dirname } = await import("node:path");
1418
+ const { join: join9, dirname } = await import("node:path");
1290
1419
  const { fileURLToPath } = await import("node:url");
1291
- const serverScript = join8(dirname(fileURLToPath(import.meta.url)), "..", "bin", "forge-server.mjs");
1420
+ const serverScript = join9(dirname(fileURLToPath(import.meta.url)), "..", "bin", "forge-server.mjs");
1292
1421
  const r = spawnSync3("node", [serverScript, "--add-enterprise-key", ...args], { stdio: "inherit" });
1293
1422
  process.exit(r.status ?? 1);
1294
1423
  }
@@ -1326,6 +1455,11 @@ async function main() {
1326
1455
  await keyCommand2(args);
1327
1456
  break;
1328
1457
  }
1458
+ case "clean": {
1459
+ const { cleanCommand: cleanCommand2 } = await Promise.resolve().then(() => (init_clean(), clean_exports));
1460
+ await cleanCommand2(args);
1461
+ break;
1462
+ }
1329
1463
  case "task":
1330
1464
  case "t": {
1331
1465
  const newSession = args.includes("--new");
@@ -1606,13 +1740,13 @@ Resume in CLI:`);
1606
1740
  }
1607
1741
  case "tunnel_code":
1608
1742
  case "tcode": {
1609
- const { readFileSync: readFileSync5, existsSync: existsSync7 } = await import("node:fs");
1610
- const { join: join8 } = await import("node:path");
1743
+ const { readFileSync: readFileSync5, existsSync: existsSync8 } = await import("node:fs");
1744
+ const { join: join9 } = await import("node:path");
1611
1745
  const { getDataDir: _gdd } = await Promise.resolve().then(() => (init_dirs(), dirs_exports));
1612
- const dataDir2 = _gdd();
1613
- const codeFile = join8(dataDir2, "session-code.json");
1746
+ const dataDir3 = _gdd();
1747
+ const codeFile = join9(dataDir3, "session-code.json");
1614
1748
  try {
1615
- if (existsSync7(codeFile)) {
1749
+ if (existsSync8(codeFile)) {
1616
1750
  const data = JSON.parse(readFileSync5(codeFile, "utf-8"));
1617
1751
  if (data.code) {
1618
1752
  console.log(`Session code: ${data.code}`);
@@ -1625,7 +1759,7 @@ Resume in CLI:`);
1625
1759
  } catch {
1626
1760
  }
1627
1761
  try {
1628
- const tunnelState = JSON.parse(readFileSync5(join8(dataDir2, "tunnel-state.json"), "utf-8"));
1762
+ const tunnelState = JSON.parse(readFileSync5(join9(dataDir3, "tunnel-state.json"), "utf-8"));
1629
1763
  if (tunnelState.url) console.log(`Tunnel URL: ${tunnelState.url}`);
1630
1764
  } catch {
1631
1765
  }
@@ -1672,9 +1806,9 @@ ${projects.length} projects`);
1672
1806
  }
1673
1807
  case "server": {
1674
1808
  const { execSync: execSync2 } = await import("node:child_process");
1675
- const { join: join8, dirname } = await import("node:path");
1809
+ const { join: join9, dirname } = await import("node:path");
1676
1810
  const { fileURLToPath } = await import("node:url");
1677
- const serverScript = join8(dirname(fileURLToPath(import.meta.url)), "..", "bin", "forge-server.mjs");
1811
+ const serverScript = join9(dirname(fileURLToPath(import.meta.url)), "..", "bin", "forge-server.mjs");
1678
1812
  const sub = args[0] || "start";
1679
1813
  const serverArgs = args.slice(1);
1680
1814
  const flagMap = {
@@ -1747,31 +1881,31 @@ ${task.gitDiff.slice(0, 2e3)}`);
1747
1881
  case "upgrade": {
1748
1882
  const { execSync: execSync2 } = await import("node:child_process");
1749
1883
  const { lstatSync } = await import("node:fs");
1750
- const { join: join8, dirname } = await import("node:path");
1884
+ const { join: join9, dirname } = await import("node:path");
1751
1885
  const { fileURLToPath } = await import("node:url");
1752
1886
  const cliDir = dirname(fileURLToPath(import.meta.url));
1753
1887
  let isLinked = false;
1754
1888
  try {
1755
- isLinked = lstatSync(join8(cliDir, "..")).isSymbolicLink();
1889
+ isLinked = lstatSync(join9(cliDir, "..")).isSymbolicLink();
1756
1890
  } catch {
1757
1891
  }
1758
1892
  if (isLinked) {
1759
1893
  console.log("[forge] Installed via npm link (local source)");
1760
1894
  console.log("[forge] Pull latest and rebuild:");
1761
- console.log(" cd " + join8(cliDir, ".."));
1895
+ console.log(" cd " + join9(cliDir, ".."));
1762
1896
  console.log(" git pull && pnpm install && pnpm build");
1763
1897
  } else {
1764
1898
  console.log("[forge] Upgrading from npm...");
1765
1899
  try {
1766
- const { homedir: homedir6 } = await import("node:os");
1900
+ const { homedir: homedir7 } = await import("node:os");
1767
1901
  execSync2("npm install -g @aion0/forge@latest --prefer-online", {
1768
1902
  stdio: "inherit",
1769
- cwd: homedir6()
1903
+ cwd: homedir7()
1770
1904
  });
1771
1905
  try {
1772
1906
  const { readFileSync: readFileSync5 } = await import("node:fs");
1773
- const globalRoot = execSync2("npm root -g", { encoding: "utf-8", cwd: homedir6() }).trim();
1774
- const pkg = JSON.parse(readFileSync5(join8(globalRoot, "@aion0", "forge", "package.json"), "utf-8"));
1907
+ const globalRoot = execSync2("npm root -g", { encoding: "utf-8", cwd: homedir7() }).trim();
1908
+ const pkg = JSON.parse(readFileSync5(join9(globalRoot, "@aion0", "forge", "package.json"), "utf-8"));
1775
1909
  console.log(`[forge] Upgraded to v${pkg.version}. Run: forge server restart`);
1776
1910
  } catch {
1777
1911
  console.log("[forge] Upgraded. Run: forge server restart");
package/cli/mw.ts CHANGED
@@ -140,6 +140,13 @@ async function main() {
140
140
  break;
141
141
  }
142
142
 
143
+ case 'clean': {
144
+ // Wipe Forge on-disk caches (cloned gitlab repos, etc).
145
+ const { cleanCommand } = await import('./clean');
146
+ await cleanCommand(args);
147
+ break;
148
+ }
149
+
143
150
  case 'task':
144
151
  case 't': {
145
152
  // Parse --new flag to force a fresh session
@@ -176,10 +176,52 @@ export default function Dashboard({ user }: { user: any }) {
176
176
  const [expandedNotif, setExpandedNotif] = useState<string | null>(null);
177
177
  const [showUserMenu, setShowUserMenu] = useState(false);
178
178
  const [theme, setTheme] = useState<'dark' | 'light'>('dark');
179
+ const [cacheInfo, setCacheInfo] = useState<{ entries: { name: string; bytes: number }[]; total_bytes: number } | null>(null);
180
+ const [showCacheMenu, setShowCacheMenu] = useState(false);
181
+ const [cacheBusy, setCacheBusy] = useState(false);
179
182
  const [displayName, setDisplayName] = useState(user?.name || 'Forge');
180
183
  const [profileDept, setProfileDept] = useState('');
181
184
  const terminalRef = useRef<WebTerminalHandle>(null);
182
185
 
186
+ // Cache (forge-managed cloned-projects) load + clear helpers. Used by the
187
+ // user-menu "Cache" entry to show on-disk size and let the user wipe it
188
+ // without dropping to the terminal. Lazy — only fetched on menu open.
189
+ const loadCacheInfo = async () => {
190
+ try {
191
+ const r = await fetch('/api/cache');
192
+ if (!r.ok) return;
193
+ const data = await r.json();
194
+ setCacheInfo({ entries: data.entries || [], total_bytes: data.total_bytes || 0 });
195
+ } catch { /* leave previous */ }
196
+ };
197
+ const humanBytes = (n: number) => {
198
+ if (!n) return '0 B';
199
+ if (n < 1024) return `${n} B`;
200
+ if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`;
201
+ if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
202
+ return `${(n / 1024 ** 3).toFixed(2)} GB`;
203
+ };
204
+ const clearCache = async (name?: string) => {
205
+ if (cacheBusy) return;
206
+ const ok = confirm(name ? `Delete cached repo "${name}"?` : 'Delete ALL cached repos? They will be re-cloned on next pipeline run.');
207
+ if (!ok) return;
208
+ setCacheBusy(true);
209
+ try {
210
+ const q = name ? `?name=${encodeURIComponent(name)}` : '';
211
+ const r = await fetch(`/api/cache${q}`, { method: 'DELETE' });
212
+ const data = await r.json();
213
+ if (data?.ok) {
214
+ await loadCacheInfo();
215
+ } else {
216
+ alert(`Cache clear failed: ${data?.error || `HTTP ${r.status}`}`);
217
+ }
218
+ } catch (e) {
219
+ alert(`Cache clear failed: ${(e as Error).message}`);
220
+ } finally {
221
+ setCacheBusy(false);
222
+ }
223
+ };
224
+
183
225
  // Theme: load from localStorage + apply
184
226
  useEffect(() => {
185
227
  const saved = localStorage.getItem('forge-theme') as 'dark' | 'light' | null;
@@ -621,7 +663,14 @@ export default function Dashboard({ user }: { user: any }) {
621
663
  {/* User menu */}
622
664
  <div className="relative">
623
665
  <button
624
- onClick={() => { setShowUserMenu(v => !v); setShowNotifications(false); }}
666
+ onClick={() => {
667
+ const next = !showUserMenu;
668
+ setShowUserMenu(next);
669
+ setShowNotifications(false);
670
+ // Lazy-load cache size whenever the menu opens — keeps
671
+ // the cache row's "· N MB" current without polling.
672
+ if (next) loadCacheInfo();
673
+ }}
625
674
  className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] flex items-center gap-1 px-1"
626
675
  >
627
676
  <span className="flex flex-col items-end leading-tight">
@@ -734,6 +783,52 @@ export default function Dashboard({ user }: { user: any }) {
734
783
  )}
735
784
  </div>
736
785
  )}
786
+ {/* Cache — show on-disk size of auto-cloned gitlab repos
787
+ under <dataDir>/cloned-projects/, with a click-to-clear
788
+ sub-menu listing each entry. Same backend as
789
+ `forge clean cache` CLI. */}
790
+ <button
791
+ onClick={() => setShowCacheMenu(v => !v)}
792
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
793
+ >
794
+ <span className="w-3 text-center">💾</span>
795
+ <span className="flex-1">Cache</span>
796
+ <span className="text-[9px] text-[var(--text-secondary)]">
797
+ {cacheInfo ? humanBytes(cacheInfo.total_bytes) : '…'}
798
+ </span>
799
+ <span className="text-[9px] text-[var(--text-secondary)]">{showCacheMenu ? '▾' : '▸'}</span>
800
+ </button>
801
+ {showCacheMenu && (
802
+ <div className="pl-6 pr-1 py-0.5 border-l border-[var(--border)] ml-3 mb-1 max-h-48 overflow-y-auto">
803
+ {!cacheInfo && (
804
+ <div className="text-[10px] text-[var(--text-secondary)] py-1 px-2">Loading…</div>
805
+ )}
806
+ {cacheInfo && cacheInfo.entries.length === 0 && (
807
+ <div className="text-[10px] text-[var(--text-secondary)] py-1 px-2">Empty.</div>
808
+ )}
809
+ {cacheInfo?.entries.map((e) => (
810
+ <button
811
+ key={e.name}
812
+ disabled={cacheBusy}
813
+ onClick={() => clearCache(e.name)}
814
+ className="w-full text-left px-2 py-1 text-[10px] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2 disabled:opacity-50"
815
+ title={`Click to delete ${e.name}`}
816
+ >
817
+ <span className="flex-1 truncate">{e.name}</span>
818
+ <span className="text-[9px] text-[var(--text-secondary)] flex-shrink-0">{humanBytes(e.bytes)}</span>
819
+ </button>
820
+ ))}
821
+ {cacheInfo && cacheInfo.entries.length > 0 && (
822
+ <button
823
+ disabled={cacheBusy}
824
+ onClick={() => clearCache()}
825
+ className="w-full text-left px-2 py-1 mt-1 text-[10px] text-red-400 hover:bg-[var(--bg-tertiary)] disabled:opacity-50 border-t border-[var(--border)]"
826
+ >
827
+ Clear All ({humanBytes(cacheInfo.total_bytes)})
828
+ </button>
829
+ )}
830
+ </div>
831
+ )}
737
832
  <div className="border-t border-[var(--border)] my-1" />
738
833
  <button
739
834
  onClick={() => { setShowHelp(v => !v); setShowUserMenu(false); }}
@@ -0,0 +1,141 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import MarkdownContent from './MarkdownContent';
5
+
6
+ const TEXT_LIKE = new Set(['md', 'txt', 'log', 'json', 'yaml', 'yml', 'csv', 'html']);
7
+ const IMAGE_LIKE = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg']);
8
+ const EMBED_LIKE = new Set(['pdf']);
9
+
10
+ /** Cap inline text rendering. Anything larger falls back to a download
11
+ * prompt — react-markdown on multi-MB strings locks the tab. */
12
+ const MAX_INLINE_BYTES = 2 * 1024 * 1024;
13
+
14
+ function extOf(p: string): string {
15
+ const m = p.match(/\.([^./]+)$/);
16
+ return m ? m[1].toLowerCase() : '';
17
+ }
18
+
19
+ export default function ScratchViewer({ path }: { path: string }) {
20
+ const decoded = (() => {
21
+ try {
22
+ return decodeURIComponent(path);
23
+ } catch {
24
+ return path;
25
+ }
26
+ })();
27
+ const ext = extOf(decoded);
28
+ const rawUrl = `/api/scratch/${path}`;
29
+ const downloadUrl = `${rawUrl}?download=1`;
30
+
31
+ const [text, setText] = useState<string | null>(null);
32
+ const [err, setErr] = useState<string>('');
33
+ const [tooLarge, setTooLarge] = useState(false);
34
+
35
+ useEffect(() => {
36
+ if (!TEXT_LIKE.has(ext)) return;
37
+ let cancelled = false;
38
+ (async () => {
39
+ try {
40
+ const r = await fetch(rawUrl);
41
+ if (!r.ok) {
42
+ if (!cancelled) setErr(`${r.status} ${r.statusText}`);
43
+ return;
44
+ }
45
+ const len = Number(r.headers.get('content-length') || '0');
46
+ if (len > MAX_INLINE_BYTES) {
47
+ if (!cancelled) setTooLarge(true);
48
+ return;
49
+ }
50
+ const body = await r.text();
51
+ if (cancelled) return;
52
+ if (body.length > MAX_INLINE_BYTES) {
53
+ setTooLarge(true);
54
+ return;
55
+ }
56
+ setText(body);
57
+ } catch (e) {
58
+ if (!cancelled) setErr((e as Error).message);
59
+ }
60
+ })();
61
+ return () => {
62
+ cancelled = true;
63
+ };
64
+ }, [rawUrl, ext]);
65
+
66
+ return (
67
+ <div className="min-h-screen bg-[var(--bg-primary)] text-[var(--text-primary)]">
68
+ <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
+ <div className="flex items-center gap-2 min-w-0">
70
+ <span className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">scratch</span>
71
+ <span className="text-xs font-mono truncate" title={decoded}>
72
+ {decoded}
73
+ </span>
74
+ </div>
75
+ <div className="flex items-center gap-2 flex-shrink-0">
76
+ <a
77
+ href={rawUrl}
78
+ className="text-xs px-2 py-1 rounded border border-[var(--border)] hover:bg-[var(--bg-tertiary)]"
79
+ target="_blank"
80
+ rel="noopener"
81
+ >
82
+ Open raw
83
+ </a>
84
+ <a
85
+ href={downloadUrl}
86
+ className="text-xs px-2 py-1 rounded bg-[var(--accent)] text-white hover:opacity-90"
87
+ >
88
+ Download
89
+ </a>
90
+ </div>
91
+ </header>
92
+
93
+ <main className="px-4 py-3 max-w-4xl mx-auto">
94
+ {err && (
95
+ <div className="text-xs text-red-400 border border-red-400/40 rounded p-2 bg-red-400/5">
96
+ Failed to load: {err}
97
+ </div>
98
+ )}
99
+
100
+ {tooLarge && (
101
+ <div className="text-xs text-[var(--text-secondary)] border border-[var(--border)] rounded p-3">
102
+ File is larger than {Math.round(MAX_INLINE_BYTES / 1024 / 1024)} MB — inline preview skipped. Use Download or Open raw.
103
+ </div>
104
+ )}
105
+
106
+ {!err && !tooLarge && IMAGE_LIKE.has(ext) && (
107
+ <img src={rawUrl} alt={decoded} className="max-w-full rounded border border-[var(--border)]" />
108
+ )}
109
+
110
+ {!err && !tooLarge && EMBED_LIKE.has(ext) && (
111
+ <embed src={rawUrl} type="application/pdf" className="w-full h-[calc(100vh-60px)]" />
112
+ )}
113
+
114
+ {!err && !tooLarge && TEXT_LIKE.has(ext) && text != null && (
115
+ ext === 'md' ? (
116
+ <MarkdownContent content={text} />
117
+ ) : ext === 'html' ? (
118
+ // Wrap in iframe srcdoc so any embedded scripts can't reach Forge's
119
+ // origin or steal session cookies. sandbox blocks everything.
120
+ <iframe
121
+ srcDoc={text}
122
+ sandbox=""
123
+ className="w-full h-[calc(100vh-60px)] bg-white rounded border border-[var(--border)]"
124
+ title={decoded}
125
+ />
126
+ ) : (
127
+ <pre className="text-[12px] font-mono text-[var(--text-primary)] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded p-3 overflow-auto whitespace-pre-wrap break-words" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace' }}>
128
+ {text}
129
+ </pre>
130
+ )
131
+ )}
132
+
133
+ {!err && !tooLarge && !TEXT_LIKE.has(ext) && !IMAGE_LIKE.has(ext) && !EMBED_LIKE.has(ext) && (
134
+ <div className="text-xs text-[var(--text-secondary)] border border-[var(--border)] rounded p-3">
135
+ No inline preview for <code className="font-mono">.{ext || '?'}</code> files. Use Download.
136
+ </div>
137
+ )}
138
+ </main>
139
+ </div>
140
+ );
141
+ }
@@ -83,13 +83,13 @@ export const LINK_PATTERNS: LinkPattern[] = [
83
83
  },
84
84
  // Forge scratch-dir files. LLMs frequently emit paths like
85
85
  // `scratch/foo.md` when they write reports during chat-launched tasks.
86
- // Turn them into clickable links served by /api/scratch/<path>.
87
- // Match path segments + filename with extension; bound to a known set
88
- // of extensions to avoid linkifying noise like `scratch/notes`.
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.
89
89
  {
90
90
  id: 'scratch-file',
91
91
  regex: /\bscratch\/([\w\-./]+?\.(?:md|txt|json|yaml|yml|csv|log|html|pdf|png|jpg|jpeg|gif|svg))\b/gi,
92
- url: '/api/scratch/{1}',
92
+ url: '/scratch/{1}',
93
93
  label: 'scratch/{1}',
94
94
  },
95
95
  ];
package/lib/pipeline.ts CHANGED
@@ -1792,7 +1792,13 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
1792
1792
  // declared, skip the auto-worktree creation entirely — otherwise we
1793
1793
  // produce dead `.forge/worktrees/pipeline-<id>` directories that
1794
1794
  // nothing ever uses but pile up on disk per pipeline run.
1795
- const useWorktree = nodeDef.worktree !== false && !nodeDef.workdir;
1795
+ //
1796
+ // Also skip on non-git projects (scratch, or any user project where
1797
+ // `.git` is missing). Pipelines without a project bound default to
1798
+ // the scratch project, which has hasGit:false — without this guard
1799
+ // the worktree branch fired `git branch` / `git worktree add` on a
1800
+ // non-repo and surfaced "fatal: not a git repository" in the run log.
1801
+ const useWorktree = nodeDef.worktree !== false && !nodeDef.workdir && projectInfo.hasGit;
1796
1802
  const branchName = nodeDef.branch ? resolveTemplate(nodeDef.branch, ctx) : `pipeline/${pipeline.id.slice(0, 8)}`;
1797
1803
  if (useWorktree) try {
1798
1804
  const wtRoot = getProjectWorktreeRoot(projectInfo);
package/lib/projects.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { execSync } from 'node:child_process';
2
3
  import { join, resolve, isAbsolute, parse } from 'node:path';
3
4
  import { homedir } from 'node:os';
4
5
  import { loadSettings, saveSettings } from './settings';
@@ -178,7 +179,7 @@ export function getProjectInfo(name: string): LocalProject | null {
178
179
  */
179
180
  export interface ResolveResult {
180
181
  project: LocalProject;
181
- source: 'existing' | 'cloned' | 'scratch';
182
+ source: 'existing' | 'cloned' | 'gitlab-cloned' | 'scratch';
182
183
  clone_url?: string;
183
184
  }
184
185
 
@@ -206,6 +207,95 @@ export function getDefaultCloneRoot(): string {
206
207
  return join(getDataDir(), 'scratch');
207
208
  }
208
209
 
210
+ /** Read the gitlab connector config without pulling in the connectors
211
+ * module statically — projects.ts is imported very early by init/dirs;
212
+ * going through a dynamic require keeps the dependency graph one-way.
213
+ * Returns null when the connector isn't installed/configured. */
214
+ function readGitlabConnector(): { base_url: string; token: string; default_project_path?: string } | null {
215
+ try {
216
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
217
+ const { getInstalledConnector } = require('./connectors/registry') as typeof import('./connectors/registry');
218
+ const inst = getInstalledConnector('gitlab');
219
+ if (!inst) return null;
220
+ const cfg = (inst.config || {}) as { base_url?: string; token?: string; default_project_path?: string };
221
+ if (!cfg.base_url || !cfg.token) return null;
222
+ return {
223
+ base_url: String(cfg.base_url).replace(/\/+$/, ''),
224
+ token: String(cfg.token),
225
+ default_project_path: cfg.default_project_path,
226
+ };
227
+ } catch { return null; }
228
+ }
229
+
230
+ /** Per-process freshness cache for cloned repos. Within this window the
231
+ * same repo path skips the `git fetch` step and reuses the on-disk
232
+ * state. Pipelines call resolveOrCloneProject once per node per
233
+ * for_each iteration — without this, a 5-node × 10-item run does 50
234
+ * fetches (~50s of network) before any real work starts. */
235
+ const CLONE_FRESH_WINDOW_MS = 5 * 60 * 1000;
236
+ const _cloneFreshAt = new Map<string, number>();
237
+
238
+ /** Try to clone (or fast-update) a gitlab project into a Forge-managed
239
+ * cache dir. Returns the cached LocalProject on success, null when the
240
+ * connector isn't configured or git fails. Used by resolveOrCloneProject
241
+ * when no real local project matches the requested name.
242
+ *
243
+ * Cache layout: <dataDir>/cloned-projects/<owner>-<repo>/
244
+ * Within the per-process freshness window: skip network entirely.
245
+ * Past it (or on first call): `git fetch` to refresh refs. */
246
+ function tryGitlabClone(repoPath: string): LocalProject | null {
247
+ const gl = readGitlabConnector();
248
+ if (!gl) return null;
249
+ const path = repoPath.replace(/^\/+|\/+$/g, '');
250
+ if (!path || !path.includes('/')) return null; // not a repo path shape
251
+ const safe = path.replace(/[^A-Za-z0-9._-]+/g, '-');
252
+ const cacheRoot = join(getDataDir(), 'cloned-projects');
253
+ const target = join(cacheRoot, safe);
254
+ try { mkdirSync(cacheRoot, { recursive: true }); } catch {}
255
+
256
+ // Inject PAT into URL. gitlab accepts `oauth2:<token>` basic auth on
257
+ // HTTPS. URL-encode the token in case it contains '@' or '/'.
258
+ const cloneUrl = `${gl.base_url}/${path}.git`;
259
+ const authedUrl = cloneUrl.replace(/^(https?:\/\/)/, `$1oauth2:${encodeURIComponent(gl.token)}@`);
260
+
261
+ const alreadyCloned = existsSync(join(target, '.git'));
262
+ const lastFreshAt = _cloneFreshAt.get(target) || 0;
263
+ const stillFresh = alreadyCloned && (Date.now() - lastFreshAt) < CLONE_FRESH_WINDOW_MS;
264
+
265
+ try {
266
+ if (!alreadyCloned) {
267
+ execSync(`git clone --quiet "${authedUrl}" "${target}"`, { stdio: 'pipe', timeout: 120000 });
268
+ _cloneFreshAt.set(target, Date.now());
269
+ } else if (!stillFresh) {
270
+ // Best-effort refresh; failures (offline, network, auth drift) are
271
+ // non-fatal — fall back to whatever's cached. Pipelines see stale
272
+ // code, not "fatal: not a git repo".
273
+ try {
274
+ execSync(`git -C "${target}" remote set-url origin "${authedUrl}"`, { stdio: 'pipe', timeout: 5000 });
275
+ execSync(`git -C "${target}" fetch --quiet origin`, { stdio: 'pipe', timeout: 30000 });
276
+ } catch { /* keep cached state */ }
277
+ _cloneFreshAt.set(target, Date.now());
278
+ }
279
+ // else: within freshness window — skip network entirely
280
+ } catch (e) {
281
+ console.warn(`[projects] gitlab clone of ${path} failed:`, (e as Error).message);
282
+ return null;
283
+ }
284
+
285
+ let mtime: string;
286
+ try { mtime = statSync(target).mtime.toISOString(); }
287
+ catch { mtime = new Date().toISOString(); }
288
+ return {
289
+ name: path,
290
+ path: target,
291
+ root: cacheRoot,
292
+ hasGit: existsSync(join(target, '.git')),
293
+ hasClaudeMd: existsSync(join(target, 'CLAUDE.md')),
294
+ language: null,
295
+ lastModified: mtime,
296
+ };
297
+ }
298
+
209
299
  export function resolveOrCloneProject(name: string | undefined): ResolveResult {
210
300
  const trimmed = (name || '').trim();
211
301
  if (trimmed) {
@@ -213,14 +303,33 @@ export function resolveOrCloneProject(name: string | undefined): ResolveResult {
213
303
  if (hit) return { project: hit, source: 'existing' };
214
304
  }
215
305
 
216
- // Default to scratch when the name doesn't match a real project root. The
217
- // earlier behavior was to auto-clone from gitlab.default_project_path — but
218
- // that ran a `git clone` in-band (slow, surprising, leaves dirs scattered
219
- // under ~/IdeaProjects). Pipelines now always run inside Forge-managed
220
- // `<dataDir>/scratch/worktrees/pipeline-<id>/` unless the user has
221
- // explicitly added the named project under Settings → Project roots.
222
- // Explicit clone is still available via `POST /api/projects/clone` for
223
- // users who want it.
306
+ // Fallback to gitlab connector when no local match. Two cases worth
307
+ // honouring:
308
+ // 1. `name` itself looks like a gitlab repo path (e.g. "team/repo")
309
+ // caller meant that path literally.
310
+ // 2. `name` is empty / unknown — pull the gitlab connector's
311
+ // `default_project_path` (e.g. "fortinet/fortinac-dev") and use
312
+ // it as the fallback repo.
313
+ // Either way we clone into <dataDir>/cloned-projects/ and reuse on
314
+ // subsequent runs. The previous behaviour silently dropped to scratch
315
+ // and code-fix pipelines exploded with "fatal: not a git repository".
316
+ const gl = readGitlabConnector();
317
+ if (gl) {
318
+ const targetPath = trimmed && trimmed.includes('/')
319
+ ? trimmed
320
+ : (gl.default_project_path || '').trim();
321
+ if (targetPath) {
322
+ const cloned = tryGitlabClone(targetPath);
323
+ if (cloned) return { project: cloned, source: 'gitlab-cloned', clone_url: `${gl.base_url}/${targetPath.replace(/^\/+|\/+$/g, '')}.git` };
324
+ }
325
+ }
326
+
327
+ // Last resort: scratch project. Most non-code pipelines (chat
328
+ // dispatch, mantis-only, notifications) are fine here. Code-fix
329
+ // pipelines that try to run git will surface their own errors —
330
+ // pipeline.ts:1795 already skips framework auto-worktree, so the
331
+ // noise is limited to the pipeline's own shell nodes (which need
332
+ // a real project to make sense).
224
333
  return { project: scratchProject(), source: 'scratch' };
225
334
  }
226
335
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.50",
3
+ "version": "0.10.53",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {