@aion0/forge 0.5.15 → 0.5.17

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/.forge/mcp.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "forge": {
4
4
  "type": "sse",
5
- "url": "http://localhost:8406/sse?workspaceId=656c9e65-9d73-4cb6-a065-60d966e1fc78"
5
+ "url": "http://localhost:8406/sse?workspaceId=656c9e65-9d73-4cb6-a065-60d966e1fc78&agentId=engineer-1774920478256"
6
6
  }
7
7
  }
8
8
  }
package/RELEASE_NOTES.md CHANGED
@@ -1,23 +1,18 @@
1
- # Forge v0.5.15
1
+ # Forge v0.5.17
2
2
 
3
3
  Released: 2026-03-31
4
4
 
5
- ## Changes since v0.5.14
6
-
7
- ### Features
8
- - feat: replace Stop button with Done/Failed/Idle for running agents
9
- - feat: Claude Code Stop hook for agent completion detection
5
+ ## Changes since v0.5.16
10
6
 
11
7
  ### Bug Fixes
12
- - fix: suppress session monitor for 10s after manual state change
13
- - fix: reset session monitor state when task status manually changed
14
- - fix: stop button resets task to idle for terminal agents, not smith down
15
- - fix: session monitor fallback timeout to 60min
16
- - fix: session monitor fallback timeout to 10min
17
- - fix: session monitor done threshold to 5min (hook is primary detection)
18
- - fix: add logging to agent-context.json write for debugging
19
- - fix: hook uses correct Claude Code schema + date-stamped backup
20
- - fix: hook reads agent context from file instead of env vars
8
+ - fix: reset session monitor on clearTmuxSession + always restart on ensurePersistentSession
9
+ - fix: reset warmup count on startMonitoring and resetState
10
+ - fix: warmup 7 polls poll 7 sets fresh baseline before real detection
11
+ - fix: warmup 6 polls (~18s) before first running detection
12
+ - fix: poll 3s with 4-poll warmup (12s before first running detection)
13
+ - fix: session monitor poll interval 3s 6s
14
+ - fix: read fixedSessionId directly from file instead of dynamic import
15
+ - fix: add logging for fixedSession resolution in ensurePersistentSession
21
16
 
22
17
 
23
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.14...v0.5.15
18
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.16...v0.5.17
@@ -89,6 +89,28 @@ export async function GET(req: Request) {
89
89
  return NextResponse.json({ error: 'Directory not under any project root' }, { status: 403 });
90
90
  }
91
91
 
92
+ // Code search (grep)
93
+ const searchQuery = searchParams.get('search');
94
+ if (searchQuery) {
95
+ try {
96
+ const { execSync } = require('node:child_process');
97
+ const safeQuery = searchQuery.replace(/['"\\]/g, '\\$&');
98
+ // Use grep -rn with limits to prevent huge output
99
+ const result = execSync(
100
+ `grep -rn --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' --include='*.py' --include='*.java' --include='*.go' --include='*.rs' --include='*.md' --include='*.json' --include='*.yaml' --include='*.yml' --include='*.css' --include='*.html' --include='*.vue' --include='*.svelte' -m 5 '${safeQuery}' . 2>/dev/null | head -100`,
101
+ { cwd: resolvedDir, encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] }
102
+ ).trim();
103
+ const matches = result ? result.split('\n').map((line: string) => {
104
+ const match = line.match(/^\.\/(.+?):(\d+):(.*)$/);
105
+ if (!match) return null;
106
+ return { file: match[1], line: parseInt(match[2]), content: match[3].trim().slice(0, 200) };
107
+ }).filter(Boolean) : [];
108
+ return NextResponse.json({ matches });
109
+ } catch {
110
+ return NextResponse.json({ matches: [] });
111
+ }
112
+ }
113
+
92
114
  // Git diff for a specific file
93
115
  const diffFile = searchParams.get('diff');
94
116
  if (diffFile) {
@@ -34,12 +34,13 @@ export async function GET(req: NextRequest) {
34
34
 
35
35
  try {
36
36
  // Run all git commands in parallel
37
- const [branchOut, statusOut, remoteOut, lastCommitOut, logOut] = await Promise.all([
37
+ const [branchOut, statusOut, remoteOut, lastCommitOut, logOut, branchListOut] = await Promise.all([
38
38
  gitAsync('rev-parse --abbrev-ref HEAD', dir),
39
39
  gitAsync('status --porcelain -u', dir),
40
40
  gitAsync('remote get-url origin', dir),
41
41
  gitAsync('log -1 --format="%h %s"', dir),
42
- gitAsync('log --format="%h||%s||%an||%ar" -10', dir),
42
+ gitAsync('log --format="%h||%s||%an||%ar" -20', dir),
43
+ gitAsync('branch --format="%(refname:short)||%(upstream:short)||%(objectname:short)"', dir),
43
44
  ]);
44
45
 
45
46
  const branch = branchOut;
@@ -59,7 +60,12 @@ export async function GET(req: NextRequest) {
59
60
  return { hash, message, author, date };
60
61
  }) : [];
61
62
 
62
- return NextResponse.json({ branch, changes, remote: remoteOut, ahead, behind, lastCommit: lastCommitOut, log });
63
+ const branches = branchListOut ? branchListOut.split('\n').filter(Boolean).map(line => {
64
+ const [name, upstream, hash] = line.split('||');
65
+ return { name, upstream: upstream || '', hash: hash || '', current: name === branch };
66
+ }) : [];
67
+
68
+ return NextResponse.json({ branch, branches, changes, remote: remoteOut, ahead, behind, lastCommit: lastCommitOut, log });
63
69
  } catch (e: any) {
64
70
  return NextResponse.json({ error: e.message }, { status: 500 });
65
71
  }
@@ -120,6 +126,13 @@ export async function POST(req: NextRequest) {
120
126
  return NextResponse.json({ ok: true, output });
121
127
  }
122
128
 
129
+ if (action === 'checkout') {
130
+ const branch = body.branch;
131
+ if (!branch) return NextResponse.json({ error: 'branch required' }, { status: 400 });
132
+ const out = gitSync(`checkout ${branch}`, dir);
133
+ return NextResponse.json({ ok: true, output: out });
134
+ }
135
+
123
136
  if (action === 'stage') {
124
137
  if (files && files.length > 0) {
125
138
  for (const f of files) gitSync(`add "${f}"`, dir);
@@ -49,6 +49,7 @@ function highlightLine(line: string): React.ReactNode {
49
49
 
50
50
  interface GitInfo {
51
51
  branch: string;
52
+ branches: { name: string; upstream: string; hash: string; current: boolean }[];
52
53
  changes: { status: string; path: string }[];
53
54
  remote: string;
54
55
  ahead: number;
@@ -72,6 +73,12 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
72
73
  const [fileLanguage, setFileLanguage] = useState('');
73
74
  const [fileLoading, setFileLoading] = useState(false);
74
75
  const [showLog, setShowLog] = useState(false);
76
+ const [changesExpanded, setChangesExpanded] = useState(false);
77
+ const [codeSearch, setCodeSearch] = useState('');
78
+ const [codeSearchResults, setCodeSearchResults] = useState<{ file: string; line: number; content: string }[]>([]);
79
+ const [codeSearching, setCodeSearching] = useState(false);
80
+ const [changesHeight, setChangesHeight] = useState(120);
81
+ const changesResizeRef = useRef<{ startY: number; origH: number } | null>(null);
75
82
  const [diffContent, setDiffContent] = useState<string | null>(null);
76
83
  const [diffFile, setDiffFile] = useState<string | null>(null);
77
84
  const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; type: string; scope: string; version: string; installedVersion: string; hasUpdate: boolean; source: 'registry' | 'local' }[]>([]);
@@ -448,7 +455,25 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
448
455
  <div className="flex items-center gap-2">
449
456
  <span className="text-sm font-semibold text-[var(--text-primary)]">{projectName}</span>
450
457
  {gitInfo?.branch && (
451
- <span className="text-[9px] text-[var(--accent)] bg-[var(--accent)]/10 px-1.5 py-0.5 rounded">{gitInfo.branch}</span>
458
+ <div className="relative">
459
+ <select
460
+ value={gitInfo.branch}
461
+ onChange={async (e) => {
462
+ const branch = e.target.value;
463
+ if (branch === gitInfo.branch) return;
464
+ try {
465
+ await fetch('/api/git', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'checkout', dir: projectPath, branch }) });
466
+ fetchGitInfo(); fetchTree();
467
+ } catch {}
468
+ }}
469
+ className="text-[9px] text-[var(--accent)] bg-[var(--accent)]/10 px-1.5 py-0.5 rounded border-none cursor-pointer appearance-none pr-4 focus:outline-none"
470
+ style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'8\' height=\'8\' viewBox=\'0 0 8 8\'%3E%3Cpath d=\'M0 2l4 4 4-4z\' fill=\'%2358a6ff\'/%3E%3C/svg%3E")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 4px center' }}
471
+ >
472
+ {(gitInfo.branches || []).map(b => (
473
+ <option key={b.name} value={b.name}>{b.name}{b.current ? ' ●' : ''}</option>
474
+ ))}
475
+ </select>
476
+ </div>
452
477
  )}
453
478
  {gitInfo?.ahead ? <span className="text-[9px] text-green-400">↑{gitInfo.ahead}</span> : null}
454
479
  {gitInfo?.behind ? <span className="text-[9px] text-yellow-400">↓{gitInfo.behind}</span> : null}
@@ -645,11 +670,51 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
645
670
 
646
671
  {/* Code content area */}
647
672
  {projectTab === 'code' && <div className="flex-1 flex min-h-0 overflow-hidden">
648
- {/* File tree */}
649
- <div style={{ width: sidebarWidth }} className="overflow-y-auto p-1 shrink-0">
650
- {fileTree.map((node: any) => (
651
- <FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
652
- ))}
673
+ {/* File tree + search */}
674
+ <div style={{ width: sidebarWidth }} className="flex flex-col shrink-0">
675
+ {/* Search input */}
676
+ <div className="p-1 border-b border-[var(--border)]">
677
+ <input
678
+ value={codeSearch}
679
+ onChange={e => setCodeSearch(e.target.value)}
680
+ onKeyDown={async e => {
681
+ if (e.key === 'Enter' && codeSearch.trim()) {
682
+ setCodeSearching(true);
683
+ try {
684
+ const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&search=${encodeURIComponent(codeSearch.trim())}`);
685
+ const data = await res.json();
686
+ setCodeSearchResults(data.matches || []);
687
+ } catch { setCodeSearchResults([]); }
688
+ setCodeSearching(false);
689
+ }
690
+ if (e.key === 'Escape') { setCodeSearch(''); setCodeSearchResults([]); }
691
+ }}
692
+ placeholder="Search code... (Enter)"
693
+ className="w-full text-[10px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] placeholder-[var(--text-secondary)]"
694
+ />
695
+ </div>
696
+ {/* Search results */}
697
+ {codeSearchResults.length > 0 && (
698
+ <div className="overflow-y-auto border-b border-[var(--border)] max-h-60">
699
+ <div className="px-2 py-0.5 text-[8px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0 flex items-center justify-between">
700
+ <span>{codeSearchResults.length} results for "{codeSearch}"</span>
701
+ <button onClick={() => { setCodeSearch(''); setCodeSearchResults([]); }} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]">✕</button>
702
+ </div>
703
+ {codeSearchResults.map((r, i) => (
704
+ <div key={i} onClick={() => openFile(r.file)} className="px-2 py-0.5 cursor-pointer hover:bg-[var(--bg-tertiary)] border-b border-[var(--border)]/30">
705
+ <div className="text-[9px] text-[var(--accent)] truncate">{r.file}:{r.line}</div>
706
+ <div className="text-[8px] text-[var(--text-secondary)] font-mono truncate">{r.content}</div>
707
+ </div>
708
+ ))}
709
+ </div>
710
+ )}
711
+ {codeSearching && <div className="px-2 py-1 text-[9px] text-[var(--text-secondary)]">Searching...</div>}
712
+ {/* File tree */}
713
+ <div className="overflow-y-auto flex-1 p-1">
714
+ {fileTree.map((node: any) => (
715
+ <FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
716
+ ))}
717
+ </div>
653
718
  </div>
654
719
 
655
720
  {/* Sidebar resize handle */}
@@ -1299,36 +1364,58 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
1299
1364
  <div className="border-t border-[var(--border)] shrink-0">
1300
1365
  {/* Changes list */}
1301
1366
  {gitInfo.changes.length > 0 && (
1302
- <div className="max-h-32 overflow-y-auto border-b border-[var(--border)]">
1303
- <div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0">
1304
- {gitInfo.changes.length} changes
1305
- </div>
1306
- {gitInfo.changes.map(g => (
1307
- <div key={g.path} className="flex items-center px-3 py-0.5 text-xs hover:bg-[var(--bg-tertiary)] group">
1308
- <span className={`text-[10px] font-mono w-4 shrink-0 ${
1309
- g.status.includes('M') ? 'text-yellow-500' :
1310
- g.status.includes('?') ? 'text-green-500' :
1311
- g.status.includes('D') ? 'text-red-500' : 'text-[var(--text-secondary)]'
1312
- }`}>
1313
- {g.status.includes('?') ? '+' : g.status[0]}
1314
- </span>
1315
- <button
1316
- onClick={() => openDiff(g.path)}
1317
- className={`truncate flex-1 text-left ml-1 ${diffFile === g.path ? 'text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
1318
- title="View diff"
1319
- >
1320
- {g.path}
1321
- </button>
1322
- <button
1323
- onClick={() => openFile(g.path)}
1324
- className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--accent)] opacity-0 group-hover:opacity-100 shrink-0 ml-1"
1325
- title="Open source file"
1326
- >
1327
- src
1328
- </button>
1367
+ <>
1368
+ <div className="overflow-y-auto border-b border-[var(--border)]" style={{ height: changesHeight }}>
1369
+ <div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0 flex items-center gap-1 cursor-pointer z-10" onClick={() => {
1370
+ setChangesExpanded(!changesExpanded);
1371
+ setChangesHeight(changesExpanded ? 120 : Math.min(400, gitInfo.changes.length * 22 + 24));
1372
+ }}>
1373
+ <span>{changesExpanded ? '▼' : '▶'}</span>
1374
+ <span>{gitInfo.changes.length} changes</span>
1375
+ <button onClick={(e) => { e.stopPropagation(); fetchGitInfo(); }} className="ml-auto text-[8px] hover:text-[var(--accent)]" title="Refresh">↻</button>
1329
1376
  </div>
1330
- ))}
1331
- </div>
1377
+ {gitInfo.changes.map(g => (
1378
+ <div key={g.path} className="flex items-center px-3 py-0.5 text-xs hover:bg-[var(--bg-tertiary)] group">
1379
+ <span className={`text-[10px] font-mono w-4 shrink-0 ${
1380
+ g.status.includes('M') ? 'text-yellow-500' :
1381
+ g.status.includes('?') ? 'text-green-500' :
1382
+ g.status.includes('D') ? 'text-red-500' : 'text-[var(--text-secondary)]'
1383
+ }`}>
1384
+ {g.status.includes('?') ? '+' : g.status[0]}
1385
+ </span>
1386
+ <button
1387
+ onClick={() => openDiff(g.path)}
1388
+ className={`truncate flex-1 text-left ml-1 ${diffFile === g.path ? 'text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
1389
+ title="View diff"
1390
+ >
1391
+ {g.path}
1392
+ </button>
1393
+ <button
1394
+ onClick={() => openFile(g.path)}
1395
+ className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--accent)] opacity-0 group-hover:opacity-100 shrink-0 ml-1"
1396
+ title="Open source file"
1397
+ >
1398
+ src
1399
+ </button>
1400
+ </div>
1401
+ ))}
1402
+ </div>
1403
+ {/* Drag handle to resize changes list */}
1404
+ <div
1405
+ className="h-1 cursor-ns-resize hover:bg-[var(--accent)]/30 border-b border-[var(--border)]"
1406
+ onMouseDown={(e) => {
1407
+ e.preventDefault();
1408
+ changesResizeRef.current = { startY: e.clientY, origH: changesHeight };
1409
+ const onMove = (ev: MouseEvent) => {
1410
+ if (!changesResizeRef.current) return;
1411
+ setChangesHeight(Math.max(60, Math.min(600, changesResizeRef.current.origH + ev.clientY - changesResizeRef.current.startY)));
1412
+ };
1413
+ const onUp = () => { changesResizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
1414
+ window.addEventListener('mousemove', onMove);
1415
+ window.addEventListener('mouseup', onUp);
1416
+ }}
1417
+ />
1418
+ </>
1332
1419
  )}
1333
1420
 
1334
1421
  {/* Git actions */}
@@ -1633,6 +1633,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
1633
1633
  const entry = this.agents.get(agentId);
1634
1634
  if (!entry) return;
1635
1635
  entry.state.tmuxSession = undefined;
1636
+ // Reset session monitor warmup so it doesn't immediately trigger running on restart
1637
+ this.sessionMonitor?.resetState(agentId);
1636
1638
  this.saveNow();
1637
1639
  this.emitAgentsChanged();
1638
1640
  }
@@ -2151,10 +2153,17 @@ export class WorkspaceOrchestrator extends EventEmitter {
2151
2153
  if (supportsSession) {
2152
2154
  let sessionId: string | undefined;
2153
2155
  if (config.primary) {
2156
+ // Read fixedSessionId directly from file (avoid dynamic import issues in production build)
2154
2157
  try {
2155
- const { getFixedSession } = await import('../project-sessions') as any;
2156
- sessionId = getFixedSession(this.projectPath);
2157
- } catch {}
2158
+ const psPath = join(homedir(), '.forge', 'data', 'project-sessions.json');
2159
+ if (existsSync(psPath)) {
2160
+ const psData = JSON.parse(readFileSync(psPath, 'utf-8'));
2161
+ sessionId = psData[this.projectPath];
2162
+ }
2163
+ console.log(`[daemon] ${config.label}: fixedSession=${sessionId || 'NONE'} for ${this.projectPath}`);
2164
+ } catch (err: any) {
2165
+ console.error(`[daemon] ${config.label}: failed to read fixedSession: ${err.message}`);
2166
+ }
2158
2167
  } else {
2159
2168
  sessionId = config.boundSessionId;
2160
2169
  }
@@ -2255,6 +2264,9 @@ export class WorkspaceOrchestrator extends EventEmitter {
2255
2264
  this.emitAgentsChanged();
2256
2265
  }
2257
2266
 
2267
+ // Always (re-)start session monitor with fresh warmup
2268
+ this.startAgentSessionMonitor(agentId, config);
2269
+
2258
2270
  // Ensure boundSessionId is set (required for session monitor + --resume)
2259
2271
  if (!config.primary && !config.boundSessionId) {
2260
2272
  const bindDelay = sessionAlreadyExists ? 500 : 5000;
@@ -47,6 +47,7 @@ export class SessionFileMonitor extends EventEmitter {
47
47
  this.stopMonitoring(agentId);
48
48
  this.currentState.set(agentId, 'idle');
49
49
  this.lastStableTime.set(agentId, Date.now());
50
+ this.warmupCount.set(agentId, 0); // reset warmup for fresh start
50
51
 
51
52
  const timer = setInterval(() => {
52
53
  this.checkFile(agentId, sessionFilePath);
@@ -96,6 +97,7 @@ export class SessionFileMonitor extends EventEmitter {
96
97
  resetState(agentId: string): void {
97
98
  this.currentState.set(agentId, 'idle');
98
99
  this.lastStableTime.set(agentId, Date.now());
100
+ this.warmupCount.set(agentId, 0); // re-warmup after reset
99
101
  // Suppress state changes for 10s after manual reset
100
102
  this.suppressUntil.set(agentId, Date.now() + 10_000);
101
103
  }
@@ -110,20 +112,23 @@ export class SessionFileMonitor extends EventEmitter {
110
112
  return join(homedir(), '.claude', 'projects', encoded, `${sessionId}.jsonl`);
111
113
  }
112
114
 
113
- private initialized = new Set<string>();
115
+ private warmupCount = new Map<string, number>();
114
116
  private checkFile(agentId: string, filePath: string): void {
115
117
  try {
116
118
  const stat = statSync(filePath);
117
119
  const mtime = stat.mtimeMs;
118
120
  const size = stat.size;
119
121
 
120
- // First poll: just record baseline, don't trigger state change
121
- if (!this.initialized.has(agentId)) {
122
- this.initialized.add(agentId);
122
+ // Warmup: skip first 6 polls (~18s) to avoid false running during startup
123
+ // On poll 7 (first real check), just set new baseline — don't trigger running
124
+ const count = (this.warmupCount.get(agentId) || 0) + 1;
125
+ this.warmupCount.set(agentId, count);
126
+ if (count <= 7) {
123
127
  this.lastMtime.set(agentId, mtime);
124
128
  this.lastSize.set(agentId, size);
125
129
  this.lastStableTime.set(agentId, Date.now());
126
- console.log(`[session-monitor] ${agentId}: baseline mtime=${mtime} size=${size}`);
130
+ if (count === 1) console.log(`[session-monitor] ${agentId}: warmup started`);
131
+ if (count === 7) console.log(`[session-monitor] ${agentId}: warmup done, monitoring active`);
127
132
  return;
128
133
  }
129
134
 
@@ -163,8 +168,8 @@ export class SessionFileMonitor extends EventEmitter {
163
168
  }
164
169
  }
165
170
  } catch (err: any) {
166
- if (!this.initialized.has(`err-${agentId}`)) {
167
- this.initialized.add(`err-${agentId}`);
171
+ if ((this.warmupCount.get(`err-${agentId}`) || 0) === 0) {
172
+ this.warmupCount.set(`err-${agentId}`, 1);
168
173
  console.log(`[session-monitor] ${agentId}: checkFile error — ${err.message}`);
169
174
  }
170
175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {