@aion0/forge 0.10.41 → 0.10.43

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,89 +1,11 @@
1
- # Forge v0.10.41
1
+ # Forge v0.10.43
2
2
 
3
3
  Released: 2026-06-07
4
4
 
5
- ## Changes since v0.10.40
6
-
7
- ### Features
8
- - fix: agent path resolution + wizard writes self-contained settings
9
- - feat: pipelines auto-install on Reinstall + monitor self-detect
10
-
11
- ### Bug Fixes
12
- - fix: agent path resolution + wizard writes self-contained settings
5
+ ## Changes since v0.10.42
13
6
 
14
7
  ### Other
15
- - chat: guard against silent project guessing for ambiguous MR / bug ids
16
- - feat(marketplace): single unified Sync all — covers every type + every source
17
- - ui: restore Re-sync registry button in Enterprise section
18
- - ui: consolidate sync into Marketplace Connectors → Refresh
19
- - ui(settings): per-source ↻ Sync button on each enterprise row
20
- - ui(settings): re-run wizard moves next to Re-sync, shorter copy
21
- - fix(settings): remove "Reinstall all" button — too many footguns
22
- - fix(login-status,reinstall): badge polls, Reinstall force-overwrites instance fields
23
- - fix(login-status): panel re-probes on mount, drops stale-cache UX
24
- - fix(login-status): Test button writes result to login-status cache
25
- - feat(wizard,pipeline): temper auto-provision + scratch-first project resolution + login-status cache invalidate
26
- - fix(wizard): sequential section numbers + always show Pipelines
27
- - feat(wizard): show template-baked defaults under each connector
28
- - fix(wizard): profile-as-defaults — open with user's company+dept
29
- - revert(wizard): drop settings.company override on source default
30
- - fix(wizard): tenant selector defaults to settings.company
31
- - fix(wizard): revert force-apply complexity, dept default reads settings.dept
32
- - fix(wizard): re-run with different dept actually rotates defaults
33
- - ui(profile): dept edit just updates label, no template re-apply
34
- - feat(profile): company + dept as user-profile fields
35
- - ui(enterprise): dept chip is display-only, picker lives in wizard
36
- - feat(enterprise): dept chip becomes a one-click selector
37
- - feat(wizard): support template-less department entries
38
- - ui(enterprise): show single active dept, not the full list
39
- - feat(enterprise): highlight currently-active dept chip
40
- - feat(enterprise): surface dept list under each tenant
41
- - ui(settings): merge Onboarding into Enterprise section at top
42
- - fix(wizard): default dept picker to last-applied dept
43
- - fix(wizard): restore auto-open + add cold-boot tenant splash
44
- - fix(wizard): hasCache check accepts multi-dept index too
45
- - fix(wizard): priority chain reads multi-dept cache, not just legacy file
46
- - feat(wizard): add-key → wizard handoff + cold-boot auto-open gate (E4)
47
- - feat(wizard): multi-department templates per source (E3)
48
- - feat(wizard): tenant-scoped wizard via ?source_id (E2)
49
- - feat(enterprise): editable keys (E1 of wizard redesign)
50
- - feat(chat): surface connector defaults + stem-match arg fallback
51
- - fix(ui): anchor enterprise popover to left edge
52
- - fix(ui): drop displayName suffix from top-left title
53
- - feat(ui): enterprise badge popover next to Forge title + settings reorder
54
- - ui(settings): split 'Add tenant' from sync/reinstall actions
55
- - feat(marketplace): Reinstall also applies template defaults to connector configs
56
- - feat(dispatcher): auto-fill missing tool args from settings.default_<name>
57
- - fix(http): expand tokens in body_form_inject_from values + keys
58
- - fix(onboarding): fall back to displayName when email is empty for {user.login}
59
- - feat(api): /api/bridge-info — expose browser-bridge port for the extension
60
- - feat(auth): first-time admin password setup — no current-password required
61
- - cli: forge --reset-password forwards trailing args (so --dir picks target instance)
62
- - fix(onboarding): synchronously sync enterprise template on first launch
63
- - fix(db): silence expected 'no such table' on fresh DB init
64
- - onboarding: orphan instance rows drop unconditionally — template is canonical
65
- - fix(connectors): cross-ref regex must also accept single-token refs like {base_url}
66
- - fix(connectors): jenkins template — preserve cross-refs, refill empty, drop orphans
67
- - feat(chat): preflight required connector settings with wizard prompt URL
68
- - template: drop hardcoded default-jenkins from bundled fallback
69
- - fix(onboarding): bake {user.X} tokens into persisted connector config
70
- - marketplace: surface enterprise availability even when versions match
71
- - fix(marketplace): surface update_source_id so Update button shows where it pulls from
72
- - feat(marketplace): Reinstall-all button — force-flip installed connectors to enterprise
73
- - feat(connectors): {user.*} global identity tokens + instances merge by name
74
- - fix(wizard): minimal mode no longer hides required-prompt connector cards
75
- - feat(marketplace): per-source sync status + PAT-scope diagnostic
76
- - feat(onboarding): _wizard.minimal flag — hide non-required steps
77
- - fix(enterprise): dedupe sources, scope monitor, tighten browser probe, default chat agent
78
- - feat(wizard): data-driven UI — collapse hardcoded sections when template fills them
79
- - feat(templates): {connector:id.field} cross-ref + _derive in wizard apply
80
- - refactor(enterprise): unify ftnt → fortinet + dev-test passthrough + secret-scan guard
81
- - feat(enterprise): obfuscated secrets + _agents preset in wizard template
82
- - docs(help): enterprise marketplace + multi-source connector flow
83
- - feat(wizard): per-enterprise onboarding template via resolver chain
84
- - feat(enterprise): Dashboard badge + add_enterprise_key chat tool
85
- - feat(ui): enterprise sources in marketplace + Settings panel
86
- - feat(marketplace): multi-source connector + workflow registries (enterprise keys)
8
+ - wizard: required fields + email pattern + skip means skip + dedup
87
9
 
88
10
 
89
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.40...v0.10.41
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.42...v0.10.43
@@ -449,7 +449,15 @@ function preprocessTemplate(
449
449
  if (changed) saveSettings({ ...fresh, agents: current });
450
450
  }
451
451
 
452
- // _apiProfiles — same shape rules
452
+ // _apiProfiles — same shape rules + dedup-by-backend.
453
+ //
454
+ // Dedup: if the user already has an apiProfile pointing at the same
455
+ // (baseUrl, model) tuple, skip the template entry even when the IDs
456
+ // differ. This handles wizard-template rename scenarios — e.g. the
457
+ // template changed `forti-k2-chat` (provider: anthropic) into
458
+ // `fortinac-qwen-chat` (provider: litellm). Without this check we'd
459
+ // add the renamed entry alongside the user's existing one, leaving
460
+ // two profiles for the same backend.
453
461
  const profilesApplied: string[] = [];
454
462
  const profilesDict = template._apiProfiles as Record<string, any> | undefined;
455
463
  if (profilesDict && typeof profilesDict === 'object') {
@@ -457,12 +465,27 @@ function preprocessTemplate(
457
465
  const current = { ...(fresh.apiProfiles || {}) };
458
466
  let changed = false;
459
467
  let defaultPick: string | undefined;
468
+ // Build (baseUrl|model) → existing id index for dedup lookup.
469
+ const backendKey = (p: any): string =>
470
+ `${String(p?.baseUrl || '').toLowerCase().replace(/\/+$/, '')}|${String(p?.model || '').toLowerCase()}`;
471
+ const existingByBackend = new Map<string, string>();
472
+ for (const [id, p] of Object.entries(current)) {
473
+ const k = backendKey(p);
474
+ if (k !== '|') existingByBackend.set(k, id);
475
+ }
460
476
  for (const [profileId, raw] of Object.entries(profilesDict)) {
461
477
  if (!raw || typeof raw !== 'object') continue;
462
478
  const overwrite = (raw as any)._overwrite === true;
463
479
  if (current[profileId] && !overwrite) continue;
480
+ // Skip if a different-id profile already targets the same (baseUrl, model).
481
+ const tplKey = backendKey(raw);
482
+ if (!overwrite && tplKey !== '|' && existingByBackend.has(tplKey)
483
+ && existingByBackend.get(tplKey) !== profileId) {
484
+ continue;
485
+ }
464
486
  const { _overwrite, _default, ...entry } = raw as Record<string, unknown>;
465
487
  current[profileId] = entry as unknown as ApiProfile;
488
+ existingByBackend.set(tplKey, profileId);
466
489
  profilesApplied.push(profileId);
467
490
  changed = true;
468
491
  // First entry flagged `_default: true` (or the first applied entry
@@ -1248,6 +1271,14 @@ export async function GET(req: Request) {
1248
1271
  // {dept.name} substitution and surfaced in the UI breadcrumb.
1249
1272
  template_department_name:
1250
1273
  typeof template._department_name === 'string' ? template._department_name : undefined,
1274
+ // Per-template identity validation. Currently just an email regex —
1275
+ // e.g. `_identity.email_pattern: "^[^@]+@fortinet(-[a-z]+)?\\.com$"`
1276
+ // lets the fortinac template enforce @fortinet.com / @fortinet-us.com
1277
+ // suffixes. Surfaced raw so the client can show the pattern in error
1278
+ // messages.
1279
+ template_identity: (template._identity && typeof template._identity === 'object')
1280
+ ? template._identity as Record<string, unknown>
1281
+ : undefined,
1251
1282
  });
1252
1283
  }
1253
1284
 
@@ -1265,6 +1296,19 @@ export async function POST(req: Request) {
1265
1296
  return NextResponse.json({ ok: true });
1266
1297
  }
1267
1298
 
1299
+ // 'skip' — user closes the wizard without applying. Just flip the
1300
+ // gate; do NOT run any of the apply phases (identity / template
1301
+ // _agents / _apiProfiles / connectors / pipelines / temper). The old
1302
+ // behaviour reused action=apply with payload={} which still ran the
1303
+ // template-side writes (applyConnectors → applyTemplateAgentsAndProfiles),
1304
+ // so Skip silently committed the template even though the user said no.
1305
+ if (action === 'skip') {
1306
+ const settings = loadSettings();
1307
+ settings.onboardingCompleted = true;
1308
+ saveSettings(settings);
1309
+ return NextResponse.json({ ok: true, skipped: true });
1310
+ }
1311
+
1268
1312
  if (action !== 'apply') {
1269
1313
  return NextResponse.json({ ok: false, error: 'unknown action' }, { status: 400 });
1270
1314
  }
@@ -1272,6 +1316,32 @@ export async function POST(req: Request) {
1272
1316
  const payload = (body?.payload || {}) as OnboardingPayload;
1273
1317
  const phases: Array<{ phase: string; ok: boolean; error?: string; detail?: any }> = [];
1274
1318
 
1319
+ // Server-side enforcement of template's identity rules. The wizard
1320
+ // also blocks at the client, but a malformed POST (curl / older client /
1321
+ // dev-tools edit) can bypass that. Reject before any phase runs — no
1322
+ // partial writes.
1323
+ try {
1324
+ const tpl = resolveTemplate(payload.sourceId, payload.deptId);
1325
+ const idCfg = (tpl && typeof tpl._identity === 'object') ? tpl._identity as any : null;
1326
+ const patternStr = idCfg?.email_pattern as string | undefined;
1327
+ const email = payload.identity?.displayEmail?.trim();
1328
+ if (patternStr && email) {
1329
+ let pat: RegExp | null = null;
1330
+ try { pat = new RegExp(patternStr); } catch { pat = null; }
1331
+ if (pat && !pat.test(email)) {
1332
+ const hint = (idCfg.email_pattern_hint as string | undefined)
1333
+ || `Must match: ${patternStr}`;
1334
+ return NextResponse.json({
1335
+ ok: false,
1336
+ error: `email "${email}" does not match the template's required pattern. ${hint}`,
1337
+ }, { status: 400 });
1338
+ }
1339
+ }
1340
+ } catch {
1341
+ // Template resolution failure → fall through; client validation
1342
+ // already covers the happy path.
1343
+ }
1344
+
1275
1345
  try {
1276
1346
  const e1 = applyIdentity(payload.identity);
1277
1347
  phases.push({ phase: 'identity', ok: !e1, ...(e1 ? { error: e1 } : {}) });
package/app/chat/page.tsx CHANGED
@@ -19,7 +19,7 @@
19
19
 
20
20
  'use client';
21
21
 
22
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
23
  import MarkdownContent from '@/components/MarkdownContent';
24
24
  import WatchesPanel from '@/components/WatchesPanel';
25
25
  import type { ContentBlock, Message, Session } from '@/lib/chat/types';
@@ -497,7 +497,10 @@ function RoleBlock({ role, ts, children }: { role: 'user' | 'assistant'; ts?: nu
497
497
  );
498
498
  }
499
499
 
500
- function MessageView({ m }: { m: Message }) {
500
+ // Memoized keystroke in the composer re-renders the page, and without
501
+ // memo every prior message re-runs its markdown parse → typing in a long
502
+ // chat became visibly laggy.
503
+ const MessageView = memo(function MessageView({ m }: { m: Message }) {
501
504
  return (
502
505
  <RoleBlock role={m.role} ts={m.ts}>
503
506
  {m.blocks.map((b, i) => (
@@ -510,9 +513,9 @@ function MessageView({ m }: { m: Message }) {
510
513
  )}
511
514
  </RoleBlock>
512
515
  );
513
- }
516
+ });
514
517
 
515
- function BlockView({ b, role }: { b: ContentBlock; role?: 'user' | 'assistant' }) {
518
+ const BlockView = memo(function BlockView({ b, role }: { b: ContentBlock; role?: 'user' | 'assistant' }) {
516
519
  if (b.type === 'text') {
517
520
  return <MarkdownContent content={b.text} linkify={role === 'assistant'} />;
518
521
  }
@@ -524,7 +527,7 @@ function BlockView({ b, role }: { b: ContentBlock; role?: 'user' | 'assistant' }
524
527
  return <ToolResultBlockView content={txt} isError={!!b.is_error} />;
525
528
  }
526
529
  return null;
527
- }
530
+ });
528
531
 
529
532
  function ToolUseBlockView({ name, input }: { name: string; input: unknown }) {
530
533
  const [open, setOpen] = useState(false);
@@ -119,6 +119,10 @@ const enterpriseKeysFromArgv = collectEnterpriseKeys();
119
119
  const webPort = parseInt(getArg('--port')) || 8403;
120
120
  const terminalPort = parseInt(getArg('--terminal-port')) || (webPort + 1);
121
121
  const workspacePort = parseInt(getArg('--workspace-port')) || (webPort + 2);
122
+ // MCP server (inside workspace-standalone) was hardcoded to 8406, so a
123
+ // second instance (dev-test on a different webPort) collided with the
124
+ // main instance. Offset from webPort like every other service.
125
+ const mcpPort = parseInt(getArg('--mcp-port')) || (webPort + 3);
122
126
  const DATA_DIR = getArg('--dir')?.replace(/^~/, homedir()) || join(homedir(), '.forge', 'data');
123
127
 
124
128
  const PID_FILE = join(DATA_DIR, 'forge.pid');
@@ -240,6 +244,7 @@ if (existsSync(envFile)) {
240
244
  process.env.PORT = String(webPort);
241
245
  process.env.TERMINAL_PORT = String(terminalPort);
242
246
  process.env.WORKSPACE_PORT = String(workspacePort);
247
+ process.env.MCP_PORT = String(mcpPort);
243
248
  process.env.FORGE_DATA_DIR = DATA_DIR;
244
249
 
245
250
  // ── Password setup (first run or --reset-password) ──
@@ -111,6 +111,13 @@ interface OnboardingState {
111
111
  /** Template's self-declared `_department_name`. Surfaced for the
112
112
  * breadcrumb so the user sees what {dept.name} resolves to. */
113
113
  template_department_name?: string;
114
+ /** Per-template identity validation. Currently supports
115
+ * `email_pattern` (JS-compatible regex) — e.g. fortinet enforces
116
+ * ^[^@]+@fortinet(-[a-z]+)?\.com$. Optional. */
117
+ template_identity?: {
118
+ email_pattern?: string;
119
+ email_pattern_hint?: string;
120
+ };
114
121
  }
115
122
 
116
123
  interface ApplyResultPhase {
@@ -509,6 +516,51 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
509
516
 
510
517
  async function apply() {
511
518
  if (applying) return;
519
+ // Identity is required — display name + email are referenced via
520
+ // {user.name} / {user.email} all over the template (jenkins username,
521
+ // gitlab commit author, etc.). Empty values produce silently-broken
522
+ // configs that surface much later.
523
+ const missing: string[] = [];
524
+ if (!displayName.trim()) missing.push('Display name');
525
+ if (!displayEmail.trim()) missing.push('Email');
526
+ // Template-side email pattern (e.g. fortinet template restricts to
527
+ // @fortinet.com / @fortinet-us.com / @fortinet-*.com). Skip when no
528
+ // pattern is declared. Compile-on-demand — pattern is a string from
529
+ // JSON so an invalid regex falls back to "accept all" rather than
530
+ // crashing the user's Apply flow.
531
+ const emailPatternStr = state?.template_identity?.email_pattern;
532
+ if (displayEmail.trim() && emailPatternStr) {
533
+ let pat: RegExp | null = null;
534
+ try { pat = new RegExp(emailPatternStr); } catch { pat = null; }
535
+ if (pat && !pat.test(displayEmail.trim())) {
536
+ const hint = state?.template_identity?.email_pattern_hint;
537
+ alert(`Email "${displayEmail.trim()}" doesn't match the required pattern.\n\n${hint || `Must match: ${emailPatternStr}`}`);
538
+ return;
539
+ }
540
+ }
541
+ // Required connector prompts (e.g. gitlab_token_name, gitlab_pat).
542
+ // Only check when their owning connector is selected AND the value
543
+ // isn't already saved from a prior wizard run (prompt_values_set).
544
+ if (state) {
545
+ const prompts = state.template_prompts || {};
546
+ const valuesSet = state.prompt_values_set || {};
547
+ const targets = state.prompt_targets || {};
548
+ for (const [key, def] of Object.entries(prompts)) {
549
+ if (!def.required) continue;
550
+ if (valuesSet[key]) continue; // already saved
551
+ const owners = targets[key] || [];
552
+ const owningConnectorSelected = owners.length === 0
553
+ || owners.some(t => selectedConnectors.has(t.connector));
554
+ if (!owningConnectorSelected) continue; // unrelated to selected connectors
555
+ if (!(connectorValues[key] || '').trim()) {
556
+ missing.push(def.label || key);
557
+ }
558
+ }
559
+ }
560
+ if (missing.length > 0) {
561
+ alert(`Required field${missing.length > 1 ? 's' : ''} missing:\n\n• ${missing.join('\n• ')}`);
562
+ return;
563
+ }
512
564
  setApplying(true); setResult(null);
513
565
  try {
514
566
  // Strip values whose target connector got unchecked — keeping them
@@ -826,11 +878,36 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
826
878
 
827
879
  {/* ── Identity ─────────────────────────────────────────── */}
828
880
  <Section title={`${stepN()}. Identity`} hint="Used as your name in pipeline/connector contexts; referenceable later as {user.name} / {user.email}. Forge derives things like jenkins username from the email's local-part.">
829
- <Field label="Display name">
830
- <input className={inputCls} value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="Zhen Liu" />
881
+ <Field label={<><span>Display name</span> <span className="text-red-400 font-bold ml-0.5">*</span></>}>
882
+ <input
883
+ className={inputCls + (displayName.trim() ? '' : ' !border-red-500 !border-2 ring-1 ring-red-500/40 bg-red-500/5')}
884
+ value={displayName}
885
+ onChange={e => setDisplayName(e.target.value)}
886
+ placeholder="Zhen Liu (required)"
887
+ required
888
+ />
831
889
  </Field>
832
- <Field label="Email">
833
- <input className={inputCls} value={displayEmail} onChange={e => setDisplayEmail(e.target.value)} placeholder="zliu@fortinet.com" />
890
+ <Field label={<><span>Email</span> <span className="text-red-400 font-bold ml-0.5">*</span></>}>
891
+ <input
892
+ className={inputCls + (() => {
893
+ if (!displayEmail.trim()) return ' !border-red-500 !border-2 ring-1 ring-red-500/40 bg-red-500/5';
894
+ const pat = state.template_identity?.email_pattern;
895
+ if (pat) {
896
+ try { if (!new RegExp(pat).test(displayEmail.trim())) return ' !border-red-500 !border-2 ring-1 ring-red-500/40 bg-red-500/5'; } catch {}
897
+ }
898
+ return '';
899
+ })()}
900
+ value={displayEmail}
901
+ onChange={e => setDisplayEmail(e.target.value)}
902
+ placeholder="zliu@fortinet.com (required)"
903
+ required
904
+ />
905
+ {state.template_identity?.email_pattern && (
906
+ <p className="text-[9px] text-[var(--text-secondary)] italic">
907
+ {state.template_identity.email_pattern_hint
908
+ || `Must match: ${state.template_identity.email_pattern}`}
909
+ </p>
910
+ )}
834
911
  </Field>
835
912
  {state.template_derive_keys?.length ? (
836
913
  <p className="text-[9px] text-[var(--text-secondary)] italic">
@@ -839,13 +916,91 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
839
916
  ) : null}
840
917
  </Section>
841
918
 
842
- {/* ── Template pre-baked items ─────────────────────────── */}
919
+ {/* ── Project roots moved up so users address it before
920
+ the template-installed sections (which most just glance at). */}
921
+ <Section title={`${stepN()}. Project roots`} hint="Directories Forge can run agents in. Pick from filesystem or type/paste paths (one per line). Optional — pipelines fall back to <dataDir>/scratch when none configured.">
922
+ {state.current.projectRoots.length > 0 ? (
923
+ <p className="text-[10px] text-[var(--text-secondary)]">
924
+ Existing: <span className="font-mono">{state.current.projectRoots.join(', ')}</span> (new entries appended)
925
+ </p>
926
+ ) : (
927
+ <p className="text-[10px] text-amber-400/90">
928
+ No project roots yet. Pipelines first try to auto-clone the GitLab connector's <span className="font-mono">default_project_path</span>; if that's not set or clone fails, they fall back to <span className="font-mono">&lt;dataDir&gt;/scratch</span> so they still run (worktrees land inside scratch).
929
+ </p>
930
+ )}
931
+ <div className="flex items-center gap-2">
932
+ <button
933
+ type="button"
934
+ onClick={() => openPicker()}
935
+ className="text-[10px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
936
+ >
937
+ 📁 Pick folder…
938
+ </button>
939
+ <span className="text-[9px] text-[var(--text-secondary)]">or type below</span>
940
+ </div>
941
+ <textarea
942
+ rows={2}
943
+ className={inputCls + ' font-mono'}
944
+ value={projectInput}
945
+ onChange={e => setProjectInput(e.target.value)}
946
+ placeholder="/Users/you/IdeaProjects"
947
+ />
948
+
949
+ {pickerOpen && (
950
+ <div className="border border-[var(--border)] rounded p-2 bg-[var(--bg-tertiary)] space-y-1">
951
+ <div className="flex items-center gap-1 text-[10px] font-mono text-[var(--text-secondary)] truncate">
952
+ {pickerParent && (
953
+ <button onClick={() => openPicker(pickerParent!)} className="text-[var(--accent)] hover:underline">..</button>
954
+ )}
955
+ <span className="truncate">{pickerPath || '(loading)'}</span>
956
+ <button
957
+ onClick={() => setPickerOpen(false)}
958
+ className="ml-auto text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
959
+ >✕</button>
960
+ </div>
961
+ <div className="max-h-40 overflow-y-auto space-y-0.5">
962
+ {pickerEntries.length === 0 && (
963
+ <p className="text-[10px] text-[var(--text-secondary)] italic">no subdirectories</p>
964
+ )}
965
+ {pickerEntries.map(e => (
966
+ <div key={e.path} className="flex items-center gap-1 text-[10px] font-mono">
967
+ <button
968
+ onClick={() => openPicker(e.path)}
969
+ className="text-[var(--text-primary)] hover:text-[var(--accent)] flex-1 text-left truncate"
970
+ title="Drill into this folder"
971
+ >
972
+ 📁 {e.name}
973
+ </button>
974
+ <button
975
+ onClick={() => addPickedPath(e.path)}
976
+ className="text-[9px] text-[var(--accent)] hover:underline px-1"
977
+ title="Add this folder to project roots"
978
+ >+ pick</button>
979
+ </div>
980
+ ))}
981
+ </div>
982
+ {pickerPath && (
983
+ <button
984
+ onClick={() => addPickedPath(pickerPath)}
985
+ className="text-[10px] px-2 py-0.5 bg-[var(--accent)] text-white rounded w-full"
986
+ >
987
+ Use current folder: <span className="font-mono">{pickerPath}</span>
988
+ </button>
989
+ )}
990
+ </div>
991
+ )}
992
+ </Section>
993
+
994
+ {/* ── Template pre-baked items ─────────────────────────────
995
+ Collapsed by default — once the template's installed there's
996
+ nothing to do here, click to expand to inspect. */}
843
997
  {(state.template_agents_preview?.length || state.template_api_profiles_preview?.length) && (
844
998
  <Section
845
999
  title={`${stepN()}. ${state.template_enterprise_name
846
1000
  ? `🔒 ${state.template_enterprise_name} — auto-installed`
847
1001
  : '🔒 Template — auto-installed'}`}
848
1002
  hint="The enterprise template pre-bakes these on your behalf. Tokens are encrypted in git and decrypted locally. You can edit / disable any of them in Settings after onboarding."
1003
+ defaultCollapsed
849
1004
  >
850
1005
  {state.template_agents_preview?.length ? (
851
1006
  <div>
@@ -881,9 +1036,14 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
881
1036
  </Section>
882
1037
  )}
883
1038
 
884
- {/* ── 2. API Profile (hidden when template provides one) ─ */}
1039
+ {/* ── 2. API Profile (hidden when template provides one) ─
1040
+ Auto-collapse when user already has a chat profile configured. */}
885
1041
  {!state.template_api_profiles_preview?.length && (
886
- <Section title={`${stepN()}. Chat API key`} hint="The LLM Forge's chat agent talks to. DeepSeek / Anthropic / OpenAI / Qwen / LiteLLM-compatible. Key encrypted at rest.">
1042
+ <Section
1043
+ title={`${stepN()}. Chat API key`}
1044
+ hint="The LLM Forge's chat agent talks to. DeepSeek / Anthropic / OpenAI / Qwen / LiteLLM-compatible. Key encrypted at rest."
1045
+ defaultCollapsed={!!state.current.apiProfile || apiKeyExisting}
1046
+ >
887
1047
  <div className="flex gap-1 mb-1 flex-wrap">
888
1048
  {(['deepseek', 'anthropic', 'openai', 'qwen', 'litellm'] as const).map(p => (
889
1049
  <button
@@ -917,9 +1077,14 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
917
1077
  </Section>
918
1078
  )}
919
1079
 
920
- {/* ── 2.5. CLI Agent (hidden when template provides _agents) ─ */}
1080
+ {/* ── 2.5. CLI Agent (hidden when template provides _agents) ─
1081
+ Auto-collapse when user already has CLI agents configured. */}
921
1082
  {!state.template_agents_preview?.length && (
922
- <Section title={`${stepN()}. CLI Agent`} hint="The CLI tool Forge launches for terminal / task sessions (claude-code / codex / aider). Detected on PATH below.">
1083
+ <Section
1084
+ title={`${stepN()}. CLI Agent`}
1085
+ hint="The CLI tool Forge launches for terminal / task sessions (claude-code / codex / aider). Detected on PATH below."
1086
+ defaultCollapsed={state.current.agents.length > 0}
1087
+ >
923
1088
  {state.detected_cli.length === 0 && state.current.agents.length === 0 && (
924
1089
  <p className="text-[10px] text-amber-500">
925
1090
  No CLI agents detected on PATH. Install one (e.g. <code>npm i -g @anthropic-ai/claude-code</code>) and re-run onboarding.
@@ -1090,7 +1255,11 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
1090
1255
  )}
1091
1256
  <input
1092
1257
  type={p.secret ? 'password' : 'text'}
1093
- className={inputCls + ' font-mono'}
1258
+ className={inputCls + ' font-mono' + (
1259
+ p.required && !isSet && !v.trim()
1260
+ ? ' !border-red-500 !border-2 ring-1 ring-red-500/40 bg-red-500/5'
1261
+ : ''
1262
+ )}
1094
1263
  value={v}
1095
1264
  onChange={e => setConnectorValues({ ...connectorValues, [key]: e.target.value })}
1096
1265
  placeholder={isSet ? '•••••••• (leave blank to keep current)' : (p.required ? 'required' : 'leave blank')}
@@ -1165,79 +1334,7 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
1165
1334
  ))}
1166
1335
  </Section>
1167
1336
 
1168
- {/* ── Projects ────────────────────────────────────────── */}
1169
- <Section title={`${stepN()}. Project roots (optional)`} hint="Directories Forge can run agents in. Pick from filesystem or type/paste paths (one per line).">
1170
- {state.current.projectRoots.length > 0 ? (
1171
- <p className="text-[10px] text-[var(--text-secondary)]">
1172
- Existing: <span className="font-mono">{state.current.projectRoots.join(', ')}</span> (new entries appended)
1173
- </p>
1174
- ) : (
1175
- <p className="text-[10px] text-amber-400/90">
1176
- No project roots yet. Pipelines first try to auto-clone the GitLab connector's <span className="font-mono">default_project_path</span>; if that's not set or clone fails, they fall back to <span className="font-mono">&lt;dataDir&gt;/scratch</span> so they still run (worktrees land inside scratch).
1177
- </p>
1178
- )}
1179
- <div className="flex items-center gap-2">
1180
- <button
1181
- type="button"
1182
- onClick={() => openPicker()}
1183
- className="text-[10px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
1184
- >
1185
- 📁 Pick folder…
1186
- </button>
1187
- <span className="text-[9px] text-[var(--text-secondary)]">or type below</span>
1188
- </div>
1189
- <textarea
1190
- rows={2}
1191
- className={inputCls + ' font-mono'}
1192
- value={projectInput}
1193
- onChange={e => setProjectInput(e.target.value)}
1194
- placeholder="/Users/you/IdeaProjects"
1195
- />
1196
-
1197
- {pickerOpen && (
1198
- <div className="border border-[var(--border)] rounded p-2 bg-[var(--bg-tertiary)] space-y-1">
1199
- <div className="flex items-center gap-1 text-[10px] font-mono text-[var(--text-secondary)] truncate">
1200
- {pickerParent && (
1201
- <button onClick={() => openPicker(pickerParent!)} className="text-[var(--accent)] hover:underline">..</button>
1202
- )}
1203
- <span className="truncate">{pickerPath || '(loading)'}</span>
1204
- <button
1205
- onClick={() => setPickerOpen(false)}
1206
- className="ml-auto text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
1207
- >✕</button>
1208
- </div>
1209
- <div className="max-h-40 overflow-y-auto space-y-0.5">
1210
- {pickerEntries.length === 0 && (
1211
- <p className="text-[10px] text-[var(--text-secondary)] italic">no subdirectories</p>
1212
- )}
1213
- {pickerEntries.map(e => (
1214
- <div key={e.path} className="flex items-center gap-1 text-[10px] font-mono">
1215
- <button
1216
- onClick={() => openPicker(e.path)}
1217
- className="text-[var(--text-primary)] hover:text-[var(--accent)] flex-1 text-left truncate"
1218
- title="Drill into this folder"
1219
- >
1220
- 📁 {e.name}
1221
- </button>
1222
- <button
1223
- onClick={() => addPickedPath(e.path)}
1224
- className="text-[9px] text-[var(--accent)] hover:underline px-1"
1225
- title="Add this folder to project roots"
1226
- >+ pick</button>
1227
- </div>
1228
- ))}
1229
- </div>
1230
- {pickerPath && (
1231
- <button
1232
- onClick={() => addPickedPath(pickerPath)}
1233
- className="text-[10px] px-2 py-0.5 bg-[var(--accent)] text-white rounded w-full"
1234
- >
1235
- Use current folder: <span className="font-mono">{pickerPath}</span>
1236
- </button>
1237
- )}
1238
- </div>
1239
- )}
1240
- </Section>
1337
+ {/* Project roots moved up to step 2 (after Identity). */}
1241
1338
 
1242
1339
  {/* ── Memory (info only) ──────────────────────────────── */}
1243
1340
  <Section title={`${stepN()}. Memory`}>
@@ -1284,11 +1381,14 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
1284
1381
  <button
1285
1382
  onClick={async () => {
1286
1383
  if (dirty && !confirm('Skip setup? Your unsaved inputs will be discarded. (Banner will not appear again.)')) return;
1287
- // Mark onboarding completed without applying anything — empty payload.
1384
+ // action:'skip' server only flips onboardingCompleted, does
1385
+ // NOT run any apply phases. Previously this was action:'apply'
1386
+ // with empty payload, which still wrote template _agents /
1387
+ // _apiProfiles via the connectors phase.
1288
1388
  await fetch('/api/onboarding', {
1289
1389
  method: 'POST',
1290
1390
  headers: { 'Content-Type': 'application/json' },
1291
- body: JSON.stringify({ action: 'apply', payload: {} }),
1391
+ body: JSON.stringify({ action: 'skip' }),
1292
1392
  });
1293
1393
  onComplete();
1294
1394
  }}
@@ -1324,12 +1424,29 @@ function DrawerShell({ onClose, children }: { onClose: () => void; children: Rea
1324
1424
  );
1325
1425
  }
1326
1426
 
1327
- function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
1427
+ function Section({ title, hint, children, defaultCollapsed }: { title: string; hint?: string; children: React.ReactNode; defaultCollapsed?: boolean }) {
1428
+ const [open, setOpen] = useState(!defaultCollapsed);
1429
+ const collapsible = defaultCollapsed !== undefined;
1328
1430
  return (
1329
1431
  <div className="space-y-1.5 pb-3 border-b border-[var(--border)]/40">
1330
- <h3 className="text-[12px] font-medium text-[var(--text-primary)]">{title}</h3>
1331
- {hint && <p className="text-[10px] text-[var(--text-secondary)] leading-snug">{hint}</p>}
1332
- {children}
1432
+ <h3 className="text-[12px] font-medium text-[var(--text-primary)] flex items-center gap-1">
1433
+ {collapsible && (
1434
+ <button
1435
+ type="button"
1436
+ onClick={() => setOpen(o => !o)}
1437
+ className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] w-3 inline-flex items-center justify-center"
1438
+ title={open ? 'Collapse' : 'Expand'}
1439
+ >
1440
+ {open ? '▾' : '▸'}
1441
+ </button>
1442
+ )}
1443
+ <span
1444
+ onClick={collapsible ? () => setOpen(o => !o) : undefined}
1445
+ className={collapsible ? 'cursor-pointer flex-1' : 'flex-1'}
1446
+ >{title}</span>
1447
+ </h3>
1448
+ {open && hint && <p className="text-[10px] text-[var(--text-secondary)] leading-snug">{hint}</p>}
1449
+ {open && children}
1333
1450
  </div>
1334
1451
  );
1335
1452
  }
@@ -1379,10 +1496,10 @@ function CheckRow({
1379
1496
  );
1380
1497
  }
1381
1498
 
1382
- function Field({ label, children }: { label: string; children: React.ReactNode }) {
1499
+ function Field({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
1383
1500
  return (
1384
1501
  <div className="space-y-0.5">
1385
- <label className="text-[10px] text-[var(--text-secondary)]">{label}</label>
1502
+ <label className="text-[10px] text-[var(--text-secondary)] inline-flex items-center">{label}</label>
1386
1503
  {children}
1387
1504
  </div>
1388
1505
  );
package/dev-test.sh CHANGED
@@ -30,6 +30,7 @@ fi
30
30
  PORT=4000 \
31
31
  TERMINAL_PORT=4001 \
32
32
  WORKSPACE_PORT=4005 \
33
+ MCP_PORT=4006 \
33
34
  BRIDGE_PORT=4007 \
34
35
  CHAT_PORT=4008 \
35
36
  MEMORY_PORT=4009 \
@@ -167,7 +167,12 @@ export const anthropicAdapter: LlmAdapter = {
167
167
  const content: ContentBlock[] = [];
168
168
  let textBuf = '';
169
169
  for await (const part of result.fullStream) {
170
- if (part.type === 'text-delta') {
170
+ if (part.type === 'text-delta' || part.type === 'reasoning-delta') {
171
+ // Treat reasoning-delta the same as text-delta — Fortinet
172
+ // relay endpoints (forti-k2, forti-coder, etc.) return their
173
+ // entire response as reasoning blocks instead of regular text.
174
+ // Without this branch the model output silently disappears
175
+ // (assistant message saved with blocks=[]).
171
176
  textBuf += part.text;
172
177
  cb.onTextDelta(part.text);
173
178
  } else if (part.type === 'tool-call') {
package/next-env.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/types/routes.d.ts";
3
+ import "./.next/dev/types/routes.d.ts";
4
4
 
5
5
  // NOTE: This file should not be edited
6
6
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.41",
3
+ "version": "0.10.43",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {