@aion0/forge 0.10.67 → 0.10.68

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.67
1
+ # Forge v0.10.68
2
2
 
3
3
  Released: 2026-06-10
4
4
 
5
- ## Changes since v0.10.66
5
+ ## Changes since v0.10.67
6
6
 
7
7
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.66...v0.10.67
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.67...v0.10.68
@@ -148,6 +148,9 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
148
148
  ? String(existing.action_config?.body_template ?? '{body_output}')
149
149
  : '{body_output}'
150
150
  );
151
+ const [emailTransport, setEmailTransport] = useState<'smtp' | 'owa'>(
152
+ existing?.action_kind === 'email' && existing.action_config?.transport === 'owa' ? 'owa' : 'smtp'
153
+ );
151
154
  const [telegramChatId, setTelegramChatId] = useState(
152
155
  existing?.action_kind === 'telegram' ? String(existing.action_config?.chat_id ?? '') : ''
153
156
  );
@@ -340,6 +343,7 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
340
343
  action_config.to = to.length > 1 ? to : (to[0] || '');
341
344
  if (emailSubjectTpl) action_config.subject_template = emailSubjectTpl;
342
345
  if (emailBodyTpl) action_config.body_template = emailBodyTpl;
346
+ action_config.transport = emailTransport;
343
347
  } else if (actionKind === 'telegram') {
344
348
  if (telegramChatId.trim()) action_config.chat_id = telegramChatId.trim();
345
349
  if (telegramPrefix) action_config.prefix = telegramPrefix;
@@ -437,6 +441,7 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
437
441
  emailTo={emailTo} onEmailTo={setEmailTo}
438
442
  emailSubjectTpl={emailSubjectTpl} onEmailSubjectTpl={setEmailSubjectTpl}
439
443
  emailBodyTpl={emailBodyTpl} onEmailBodyTpl={setEmailBodyTpl}
444
+ emailTransport={emailTransport} onEmailTransport={setEmailTransport}
440
445
  telegramChatId={telegramChatId} onTelegramChatId={setTelegramChatId}
441
446
  telegramPrefix={telegramPrefix} onTelegramPrefix={setTelegramPrefix}
442
447
  />
@@ -855,6 +860,7 @@ function Step3({
855
860
  emailTo, onEmailTo,
856
861
  emailSubjectTpl, onEmailSubjectTpl,
857
862
  emailBodyTpl, onEmailBodyTpl,
863
+ emailTransport, onEmailTransport,
858
864
  telegramChatId, onTelegramChatId,
859
865
  telegramPrefix, onTelegramPrefix,
860
866
  }: {
@@ -871,6 +877,7 @@ function Step3({
871
877
  emailTo: string; onEmailTo: (v: string) => void;
872
878
  emailSubjectTpl: string; onEmailSubjectTpl: (v: string) => void;
873
879
  emailBodyTpl: string; onEmailBodyTpl: (v: string) => void;
880
+ emailTransport: 'smtp' | 'owa'; onEmailTransport: (v: 'smtp' | 'owa') => void;
874
881
  telegramChatId: string; onTelegramChatId: (v: string) => void;
875
882
  telegramPrefix: string; onTelegramPrefix: (v: string) => void;
876
883
  }) {
@@ -962,7 +969,7 @@ function Step3({
962
969
  )}
963
970
  <label className="flex items-center gap-2 cursor-pointer">
964
971
  <input type="radio" name="action" checked={actionKind === 'email'} onChange={() => onActionKind('email')} />
965
- <span className="text-[11px] w-44">Send Email (SMTP)</span>
972
+ <span className="text-[11px] w-44">Send Email</span>
966
973
  <input
967
974
  type="text"
968
975
  value={emailTo}
@@ -974,6 +981,20 @@ function Step3({
974
981
  </label>
975
982
  {actionKind === 'email' && (
976
983
  <>
984
+ <label className="flex items-center gap-2">
985
+ <span className="text-[11px] w-44 ml-6">transport</span>
986
+ <select
987
+ value={emailTransport}
988
+ onChange={(e) => onEmailTransport(e.target.value as 'smtp' | 'owa')}
989
+ className="text-[11px] px-2 py-0.5 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
990
+ >
991
+ <option value="smtp">SMTP (Settings → SMTP)</option>
992
+ <option value="owa">OWA connector (your mailbox, no SMTP)</option>
993
+ </select>
994
+ {emailTransport === 'owa' && (
995
+ <span className="text-[10px] text-[var(--text-secondary)]">needs the browser extension connected</span>
996
+ )}
997
+ </label>
977
998
  <label className="flex items-center gap-2">
978
999
  <span className="text-[11px] w-44 ml-6">subject template</span>
979
1000
  <input
@@ -4,6 +4,37 @@
4
4
 
5
5
  Add project directories in Settings → **Project Roots** (e.g. `~/Projects`). Forge scans subdirectories automatically.
6
6
 
7
+ ## How a project is resolved (pipelines / MR review / bug fix)
8
+
9
+ When a pipeline (or chat-dispatched MR review / bug fix) needs a project, Forge
10
+ resolves the given name in this order — first hit wins:
11
+
12
+ 1. **Exact project name** — a scanned project whose directory name equals the
13
+ value passed in.
14
+ 2. **Git repo match** — when the value looks like an `owner/repo` path (e.g.
15
+ derived from a GitLab MR URL), Forge compares it against each project's
16
+ **git `origin` remote**, which it auto-detects from `.git/config` during the
17
+ scan (no subprocess). Full path first, then the repo basename. This is how
18
+ "review this MR" finds the right local checkout even when the directory is
19
+ named differently — you don't need to type the project. Only an
20
+ **unambiguous single match** is accepted; if two local clones share the same
21
+ origin, Forge skips this step rather than guess.
22
+ 3. **Fuzzy name** — basename / `owner-repo` variants of the value.
23
+ 4. **Auto-clone** — if a GitLab connector is configured, clone the repo into
24
+ `<dataDir>/cloned-projects/<owner>-<repo>/` and reuse it next time.
25
+ 5. **Scratch** — the always-writable `<dataDir>/scratch` fallback for flows
26
+ that don't need a real checkout.
27
+
28
+ Notes:
29
+ - The detected repo is internal (not shown in Settings) — it's derived live
30
+ from each project's git origin on every scan, so existing projects work with
31
+ no migration.
32
+ - Override detection (rare: wrong/missing origin) via
33
+ `settings.projectRepos: { "<projectPath>": "owner/repo" }`.
34
+ - Auto-cloned repos under `cloned-projects/` are swept after
35
+ `clonedProjectKeepDays` (default 30) of disuse, unless a running pipeline
36
+ still has a worktree there.
37
+
7
38
  ## Features
8
39
 
9
40
  ### Code Tab
package/lib/init.ts CHANGED
@@ -220,7 +220,7 @@ export function ensureInitialized() {
220
220
  // expired (failed/cancelled past retention). Interval from settings,
221
221
  // clamped to >= 1h to avoid runaway IO. Runs in background only.
222
222
  try {
223
- const { gcPipelineTmp } = require('./pipeline-gc');
223
+ const { gcPipelineTmp, gcClonedProjects } = require('./pipeline-gc');
224
224
  const { loadSettings: ls } = require('./settings');
225
225
  const hours = Math.max(1, Number(ls().pipelineTmpGcIntervalHours) || 6);
226
226
  setInterval(() => {
@@ -228,6 +228,11 @@ export function ensureInitialized() {
228
228
  const r = gcPipelineTmp();
229
229
  if (r.removed.length) console.log(`[pipeline-gc] swept ${r.removed.length}/${r.scanned} dir(s)`);
230
230
  } catch (e) { console.warn('[pipeline-gc] sweep failed:', (e as Error).message); }
231
+ // Also sweep the auto-clone cache (long retention; conservative).
232
+ try {
233
+ const c = gcClonedProjects();
234
+ if (c.removed.length) console.log(`[pipeline-gc] swept ${c.removed.length}/${c.scanned} cloned repo(s)`);
235
+ } catch (e) { console.warn('[pipeline-gc] cloned-projects sweep failed:', (e as Error).message); }
231
236
  }, hours * 60 * 60 * 1000);
232
237
  } catch (e) { console.warn('[pipeline-gc] setup failed:', (e as Error).message); }
233
238
 
@@ -24,6 +24,7 @@ import { scanProjects, getProjectWorktreeRoot } from './projects';
24
24
  import { join } from 'node:path';
25
25
  import { getPipeline } from './pipeline';
26
26
  import { loadSettings } from './settings';
27
+ import { getDataDir } from './dirs';
27
28
 
28
29
  export interface GcResult {
29
30
  scanned: number;
@@ -106,3 +107,64 @@ export function gcPipelineTmp(opts: { dryRun?: boolean } = {}): GcResult {
106
107
 
107
108
  return { scanned, removed, kept };
108
109
  }
110
+
111
+ /**
112
+ * GC the auto-cloned repo cache at <dataDir>/cloned-projects/<owner>-<repo>/.
113
+ * These are full git clones Forge makes when a pipeline runs without a local
114
+ * project (resolveOrCloneProject). They're reused across runs, so we keep
115
+ * them a long time and only sweep ones untouched for clonedProjectKeepDays
116
+ * (default 30). Conservative on purpose:
117
+ * - default 30d, and 0/negative disables the sweep entirely;
118
+ * - never deletes a clone that still has a worktree for a non-terminal
119
+ * (running/started) pipeline under it.
120
+ * Uses the newest mtime across the repo dir + its .forge/worktrees so an
121
+ * actively-used clone (recent worktree) is never considered stale.
122
+ */
123
+ export function gcClonedProjects(opts: { dryRun?: boolean } = {}): GcResult {
124
+ const settings = loadSettings();
125
+ const keepDays = (settings as { clonedProjectKeepDays?: number }).clonedProjectKeepDays ?? 30;
126
+ const removed: GcResult['removed'] = [];
127
+ const kept: GcResult['kept'] = [];
128
+ let scanned = 0;
129
+ if (!keepDays || keepDays <= 0) return { scanned, removed, kept }; // disabled
130
+
131
+ const keepMs = keepDays * 86400_000;
132
+ const now = Date.now();
133
+ const root = join(getDataDir(), 'cloned-projects');
134
+ let entries: string[];
135
+ try { entries = readdirSync(root); } catch { return { scanned, removed, kept }; }
136
+
137
+ for (const entry of entries) {
138
+ const repoDir = join(root, entry);
139
+ let st: ReturnType<typeof statSync>;
140
+ try { st = statSync(repoDir); } catch { continue; }
141
+ if (!st.isDirectory()) continue;
142
+ scanned++;
143
+
144
+ // Newest signal across the repo dir + its worktree root.
145
+ let newest = st.mtimeMs;
146
+ const wtRoot = join(repoDir, '.forge', 'worktrees');
147
+ let liveWorktree = false;
148
+ try {
149
+ for (const wt of readdirSync(wtRoot)) {
150
+ const wtPath = join(wtRoot, wt);
151
+ try { newest = Math.max(newest, statSync(wtPath).mtimeMs); } catch {}
152
+ if (wt.startsWith('pipeline-')) {
153
+ const p = getPipeline(wt.slice('pipeline-'.length));
154
+ if (p && p.status !== 'done' && p.status !== 'failed' && p.status !== 'cancelled') {
155
+ liveWorktree = true;
156
+ }
157
+ }
158
+ }
159
+ } catch { /* no worktrees dir */ }
160
+
161
+ if (liveWorktree) { kept.push({ path: repoDir, reason: 'in-use (running pipeline worktree)' }); continue; }
162
+ if (now - newest > keepMs) {
163
+ if (!opts.dryRun) { try { rmSync(repoDir, { recursive: true, force: true }); } catch {} }
164
+ removed.push({ path: repoDir, reason: `clone unused >${keepDays}d` });
165
+ } else {
166
+ kept.push({ path: repoDir, reason: 'fresh' });
167
+ }
168
+ }
169
+ return { scanned, removed, kept };
170
+ }
package/lib/pipeline.ts CHANGED
@@ -11,7 +11,7 @@ import { execSync } from 'node:child_process';
11
11
  import { join } from 'node:path';
12
12
  import YAML from 'yaml';
13
13
  import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides, cancelTask } from './task-manager';
14
- import { getProjectInfo, resolveOrCloneProject, getProjectWorktreeRoot } from './projects';
14
+ import { getProjectInfo, resolveOrCloneProject, getProjectWorktreeRoot, ensureScratchProject } from './projects';
15
15
  import { loadSettings } from './settings';
16
16
  import { getAgent, listAgents } from './agents';
17
17
  import type { Task } from '../src/types';
@@ -747,16 +747,22 @@ export function resolveTemplate(template: string, ctx: {
747
747
  // ─── Per-run scratch dir (`{{run.tmp_dir}}`) ────────────────
748
748
 
749
749
  /**
750
- * Compute the absolute scratch-dir path for a pipeline run. Returns
751
- * empty string if input.project doesn't resolve. Does NOT mkdir.
750
+ * Compute the absolute scratch-dir path for a pipeline run. Falls back to
751
+ * the scratch project's worktree root when input.project is missing or
752
+ * unresolvable — NEVER returns '' (an empty {{run.tmp_dir}} made YAML like
753
+ * `mkdir -p {{run.tmp_dir}}/mr-${IID}` resolve to `/mr-NNN`, i.e. the
754
+ * read-only filesystem root — that was the "mkdir: /mr-14949: Read-only
755
+ * file system" crash). Does NOT mkdir.
752
756
  */
753
757
  function computePipelineTmpDir(pipeline: Pipeline): string {
754
758
  const name = pipeline.input?.project;
755
- if (!name) return '';
756
- const proj = getProjectInfo(name);
757
- if (!proj) return '';
758
- // Worktree root differs for scratch vs real projects — see getProjectWorktreeRoot.
759
- return join(getProjectWorktreeRoot(proj), `pipeline-${pipeline.id}`);
759
+ const proj = name ? getProjectInfo(name) : null;
760
+ if (proj) {
761
+ // Worktree root differs for scratch vs real projects — see getProjectWorktreeRoot.
762
+ return join(getProjectWorktreeRoot(proj), `pipeline-${pipeline.id}`);
763
+ }
764
+ // No / unresolved project → writable scratch base under <dataDir>/scratch.
765
+ return join(ensureScratchProject(), 'worktrees', `pipeline-${pipeline.id}`);
760
766
  }
761
767
 
762
768
  /**
@@ -764,6 +770,21 @@ function computePipelineTmpDir(pipeline: Pipeline): string {
764
770
  * non-fatal — `tmpDir` stays unset and `{{run.tmp_dir}}` renders empty.
765
771
  */
766
772
  function ensurePipelineTmpDir(pipeline: Pipeline): void {
773
+ // Align run.tmp_dir with the project the nodes will actually use. When no
774
+ // project was given, resolve it through the same three-tier path the nodes
775
+ // use (existing → gitlab clone → scratch) and write the concrete name back
776
+ // to input.project, so tmp_dir lands inside that (possibly cloned) repo and
777
+ // downstream resolveOrCloneProject finds it as 'existing' (no double clone).
778
+ // Every node already calls resolveOrCloneProject, so this adds no NEW clone
779
+ // — it just happens once up front. Never throws (falls back to scratch).
780
+ if (!pipeline.input?.project) {
781
+ try {
782
+ const r = resolveOrCloneProject('');
783
+ if (r?.project?.name) {
784
+ pipeline.input = { ...(pipeline.input || {}), project: r.project.name };
785
+ }
786
+ } catch { /* leave empty → computePipelineTmpDir's scratch fallback */ }
787
+ }
767
788
  const dir = computePipelineTmpDir(pipeline);
768
789
  if (!dir) return;
769
790
  try {
package/lib/projects.ts CHANGED
@@ -13,6 +13,46 @@ export interface LocalProject {
13
13
  hasClaudeMd: boolean;
14
14
  language: string | null;
15
15
  lastModified: string;
16
+ /** Normalized "owner/repo" derived from the git origin remote (or a
17
+ * settings.projectRepos override). Lets MR/bug pipelines map a repo
18
+ * path back to this local checkout. Undefined when not a git repo /
19
+ * no origin. */
20
+ repo?: string;
21
+ }
22
+
23
+ /** Normalize a git remote URL to an "owner/repo" path (lowercased), so it
24
+ * can be matched against a gitlab MR path. Handles ssh + https forms:
25
+ * git@host:owner/repo.git → owner/repo
26
+ * https://host/owner/repo.git → owner/repo
27
+ * https://host/group/sub/repo → group/sub/repo
28
+ * Returns '' if it can't parse one. */
29
+ function normalizeRepoPath(url: string): string {
30
+ let s = (url || '').trim();
31
+ if (!s) return '';
32
+ s = s.replace(/\.git$/i, '');
33
+ // scp-style: git@host:owner/repo
34
+ const scp = s.match(/^[^@]+@[^:]+:(.+)$/);
35
+ if (scp) return scp[1].replace(/^\/+|\/+$/g, '').toLowerCase();
36
+ // url-style: scheme://[user@]host/owner/repo
37
+ const m = s.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]+\/(.+)$/i);
38
+ if (m) return m[1].replace(/^\/+|\/+$/g, '').toLowerCase();
39
+ return '';
40
+ }
41
+
42
+ /** Read a project's origin remote from .git/config WITHOUT spawning git
43
+ * (scanProjects runs often; a subprocess per project would be costly).
44
+ * Returns the normalized "owner/repo" or '' . */
45
+ function readGitOriginRepo(projectPath: string): string {
46
+ try {
47
+ const cfg = readFileSync(join(projectPath, '.git', 'config'), 'utf-8');
48
+ // Find the [remote "origin"] section then its `url = ...` line.
49
+ const sec = cfg.match(/\[remote "origin"\]([\s\S]*?)(?:\n\[|$)/);
50
+ if (!sec) return '';
51
+ const u = sec[1].match(/^\s*url\s*=\s*(.+)\s*$/m);
52
+ return u ? normalizeRepoPath(u[1]) : '';
53
+ } catch {
54
+ return '';
55
+ }
16
56
  }
17
57
 
18
58
  /** Reserved name for the synthetic "scratch" project that lives under
@@ -112,6 +152,13 @@ export function scanProjects(): LocalProject[] {
112
152
  const language = detectLanguage(projectPath);
113
153
  const stat = statSync(projectPath);
114
154
 
155
+ // repo: manual settings.projectRepos override wins; else auto-detect
156
+ // from the git origin remote. Cheap (file read, no subprocess).
157
+ const repoOverride = (settings.projectRepos || {})[projectPath];
158
+ const repo = (typeof repoOverride === 'string' && repoOverride.trim())
159
+ ? normalizeRepoPath(repoOverride) || repoOverride.trim().toLowerCase()
160
+ : (hasGit ? readGitOriginRepo(projectPath) : '');
161
+
115
162
  projects.push({
116
163
  name: entry.name,
117
164
  path: projectPath,
@@ -120,6 +167,7 @@ export function scanProjects(): LocalProject[] {
120
167
  hasClaudeMd,
121
168
  language,
122
169
  lastModified: stat.mtime.toISOString(),
170
+ repo: repo || undefined,
123
171
  });
124
172
  } catch {
125
173
  // Skip inaccessible directories
@@ -301,6 +349,34 @@ export function resolveOrCloneProject(name: string | undefined): ResolveResult {
301
349
  if (trimmed) {
302
350
  const hit = getProjectInfo(trimmed);
303
351
  if (hit) return { project: hit, source: 'existing' };
352
+
353
+ const projects = scanProjects();
354
+
355
+ // Repo match (best signal): when the caller passes an "owner/repo" path
356
+ // (e.g. derived from an MR URL), match it against each project's git
357
+ // origin remote (LocalProject.repo, auto-detected in scanProjects).
358
+ // This is how "review this MR" finds the right local checkout without
359
+ // the user naming the project. Compares full path then basename; only
360
+ // an UNAMBIGUOUS single match wins.
361
+ if (trimmed.includes('/')) {
362
+ const want = normalizeRepoPath(trimmed) || trimmed.toLowerCase().replace(/^\/+|\/+$/g, '');
363
+ const wantBase = want.split('/').pop()!;
364
+ const byRepo = projects.filter((p) => p.repo && p.repo === want);
365
+ if (byRepo.length === 1) return { project: byRepo[0], source: 'existing' };
366
+ const byRepoBase = projects.filter((p) => p.repo && p.repo.split('/').pop() === wantBase);
367
+ if (byRepoBase.length === 1) return { project: byRepoBase[0], source: 'existing' };
368
+ }
369
+
370
+ // Fuzzy name match across the configured project roots before cloning:
371
+ // the caller may pass a gitlab path ("owner/repo") or the cloned-projects
372
+ // dir name ("owner-repo") while the local checkout is just "repo". Try
373
+ // the basename and the owner-repo form; only accept an UNAMBIGUOUS
374
+ // single match so we never silently pick the wrong repo.
375
+ const base = trimmed.split('/').pop()!.trim();
376
+ const ownerRepo = trimmed.replace(/\//g, '-');
377
+ const cands = projects.filter((p) =>
378
+ p.name === base || p.name === ownerRepo || p.name.toLowerCase() === base.toLowerCase());
379
+ if (cands.length === 1) return { project: cands[0], source: 'existing' };
304
380
  }
305
381
 
306
382
  // Fallback to gitlab connector when no local match. Two cases worth
@@ -155,9 +155,6 @@ async function runChatAction(schedule: Schedule, run: ScheduleRun, output: strin
155
155
  */
156
156
  async function runEmailAction(schedule: Schedule, _run: ScheduleRun, output: string): Promise<void> {
157
157
  const settings = loadSettings();
158
- if (!settings.smtpHost) {
159
- throw new Error('SMTP not configured (Settings → SMTP)');
160
- }
161
158
  const cfg = schedule.action_config || {};
162
159
  const toRaw = cfg.to;
163
160
  const to = Array.isArray(toRaw)
@@ -178,6 +175,30 @@ async function runEmailAction(schedule: Schedule, _run: ScheduleRun, output: str
178
175
  const bodyText = bodyTpl.replace('{date}', today).replace('{body_output}', output);
179
176
  const asHtml = !!cfg.html;
180
177
 
178
+ // Transport: 'smtp' (default, nodemailer) or 'owa' (send via the OWA
179
+ // browser connector through the extension bridge — no SMTP server needed,
180
+ // rides the user's logged-in mailbox; requires the extension connected,
181
+ // same as any browser connector in a scheduled run).
182
+ const emailTransport = String(cfg.transport || 'smtp').toLowerCase();
183
+ if (emailTransport === 'owa') {
184
+ const { dispatchTool } = await import('../chat/tool-dispatcher');
185
+ const r = await dispatchTool({
186
+ id: `sched-email-${Date.now()}`,
187
+ name: 'owa.send_mail',
188
+ input: { to: to.join(', '), subject, body: bodyText, body_html: asHtml },
189
+ });
190
+ let parsed: any = r.content;
191
+ try { parsed = JSON.parse(r.content); } catch { /* keep string */ }
192
+ if (r.is_error || (parsed && parsed.error)) {
193
+ throw new Error(`OWA send failed: ${(parsed && parsed.error) || r.content}`);
194
+ }
195
+ return;
196
+ }
197
+
198
+ if (!settings.smtpHost) {
199
+ throw new Error("SMTP not configured (Settings → SMTP). For mailbox send without SMTP, set action_config.transport='owa'.");
200
+ }
201
+
181
202
  // Lazy import keeps the SMTP client out of the main bundle until the
182
203
  // first email action actually fires.
183
204
  const nodemailer = (await import('nodemailer')).default;
package/lib/settings.ts CHANGED
@@ -93,6 +93,11 @@ export interface McpServerConfig {
93
93
 
94
94
  export interface Settings {
95
95
  projectRoots: string[];
96
+ /** Optional per-project repo override: { <projectPath>: "owner/repo" }.
97
+ * Normally the repo is auto-detected from the project's git origin remote
98
+ * (see lib/projects.ts); this only overrides when detection is wrong or
99
+ * the checkout has no origin. */
100
+ projectRepos?: Record<string, string>;
96
101
  docRoots: string[];
97
102
  claudePath: string;
98
103
  // claudeHome removed in 0.10.10 — was a never-UI-exposed override that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.67",
3
+ "version": "0.10.68",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {