@aion0/forge 0.5.14 → 0.5.16

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.
@@ -0,0 +1,6 @@
1
+ {
2
+ "workspaceId": "656c9e65-9d73-4cb6-a065-60d966e1fc78",
3
+ "agentId": "engineer-1774920478256",
4
+ "agentLabel": "Engineer",
5
+ "forgePort": 8403
6
+ }
package/RELEASE_NOTES.md CHANGED
@@ -1,14 +1,12 @@
1
- # Forge v0.5.14
1
+ # Forge v0.5.16
2
2
 
3
3
  Released: 2026-03-31
4
4
 
5
- ## Changes since v0.5.13
5
+ ## Changes since v0.5.15
6
6
 
7
- ### Bug Fixes
8
- - fix: dedup forge failed notifications by sender→target pair
9
- - fix: TerminalLaunchDialog also uses daemon to create session
10
- - fix: FloatingTerminal only attaches, daemon creates all sessions
11
- - fix: write launch script to file to avoid tmux send-keys truncation
7
+ ### Features
8
+ - feat: code search in project sidebar
9
+ - feat: git enhancements branch switch, resizable changes, more log
12
10
 
13
11
 
14
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.13...v0.5.14
12
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.15...v0.5.16
@@ -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 */}
@@ -2212,6 +2212,9 @@ interface AgentNodeData {
2212
2212
  onShowInbox: () => void;
2213
2213
  onOpenTerminal: () => void;
2214
2214
  onSwitchSession: () => void;
2215
+ onMarkIdle?: () => void;
2216
+ onMarkDone?: (notify: boolean) => void;
2217
+ onMarkFailed?: (notify: boolean) => void;
2215
2218
  inboxPending?: number;
2216
2219
  inboxFailed?: number;
2217
2220
  [key: string]: unknown;
@@ -2325,8 +2328,14 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
2325
2328
  {/* Actions */}
2326
2329
  <div className="flex items-center gap-1 px-2 py-1.5" style={{ borderTop: `1px solid ${c.border}15` }}>
2327
2330
  {taskStatus === 'running' && (
2328
- <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onStop(); }}
2329
- className="text-[9px] px-1.5 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30">■ Stop</button>
2331
+ <>
2332
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); data.onMarkIdle?.(); }}
2333
+ className="text-[9px] px-1 py-0.5 rounded bg-gray-600/20 text-gray-400 hover:bg-gray-600/30" title="Silent stop — no notifications">■</button>
2334
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); data.onMarkDone?.(true); }}
2335
+ className="text-[9px] px-1 py-0.5 rounded bg-green-600/20 text-green-400 hover:bg-green-600/30" title="Mark done + notify">✓</button>
2336
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); data.onMarkFailed?.(true); }}
2337
+ className="text-[9px] px-1 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30" title="Mark failed + notify">✕</button>
2338
+ </>
2330
2339
  )}
2331
2340
  {/* Message button — send instructions to agent */}
2332
2341
  {smithStatus === 'active' && taskStatus !== 'running' && (
@@ -2469,6 +2478,9 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2469
2478
  },
2470
2479
  onPause: () => wsApi(workspaceId!, 'pause', { agentId: agent.id }),
2471
2480
  onStop: () => wsApi(workspaceId!, 'stop', { agentId: agent.id }),
2481
+ onMarkIdle: () => wsApi(workspaceId!, 'mark_done', { agentId: agent.id, notify: false }),
2482
+ onMarkDone: (notify: boolean) => wsApi(workspaceId!, 'mark_done', { agentId: agent.id, notify }),
2483
+ onMarkFailed: (notify: boolean) => wsApi(workspaceId!, 'mark_failed', { agentId: agent.id, notify }),
2472
2484
  onRetry: () => wsApi(workspaceId!, 'retry', { agentId: agent.id }),
2473
2485
  onEdit: () => setModal({ mode: 'edit', initial: agent, editId: agent.id }),
2474
2486
  onRemove: () => {
@@ -1103,6 +1103,31 @@ export class WorkspaceOrchestrator extends EventEmitter {
1103
1103
  console.log('[workspace] Daemon stopped');
1104
1104
  }
1105
1105
 
1106
+ // ─── Hook-based completion ─────────────────────────────
1107
+
1108
+ /** Called by Claude Code Stop hook via HTTP — agent finished a turn */
1109
+ handleHookDone(agentId: string): void {
1110
+ const entry = this.agents.get(agentId);
1111
+ if (!entry) return;
1112
+ if (!this.daemonActive) return;
1113
+
1114
+ // Only transition running → done (ignore if already idle/done)
1115
+ if (entry.state.taskStatus !== 'running') {
1116
+ console.log(`[hook] ${entry.config.label}: Stop hook fired but task=${entry.state.taskStatus}, ignoring`);
1117
+ return;
1118
+ }
1119
+
1120
+ console.log(`[hook] ${entry.config.label}: Stop hook → done`);
1121
+ entry.state.taskStatus = 'done';
1122
+ entry.state.completedAt = Date.now();
1123
+ this.emit('event', { type: 'task_status', agentId, taskStatus: 'done' } as any);
1124
+ this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'hook_done', content: 'Claude Code Stop hook: turn completed', timestamp: new Date().toISOString() } } as any);
1125
+ this.handleAgentDone(agentId, entry, 'Stop hook');
1126
+ this.sessionMonitor?.resetState(agentId);
1127
+ this.saveNow();
1128
+ this.emitAgentsChanged();
1129
+ }
1130
+
1106
1131
  // ─── Session File Monitor ──────────────────────────────
1107
1132
 
1108
1133
  private async startSessionMonitors(): Promise<void> {
@@ -1502,9 +1527,63 @@ export class WorkspaceOrchestrator extends EventEmitter {
1502
1527
  }
1503
1528
 
1504
1529
  /** Stop a running agent */
1530
+ /** Mark agent task as done (user manually confirms completion) */
1531
+ markAgentDone(agentId: string, notify: boolean): void {
1532
+ const entry = this.agents.get(agentId);
1533
+ if (!entry) return;
1534
+
1535
+ // Mark running inbox messages as done
1536
+ const runningMsgs = this.bus.getLog().filter(m => m.to === agentId && m.status === 'running' && m.type !== 'ack');
1537
+ for (const m of runningMsgs) {
1538
+ m.status = 'done' as any;
1539
+ this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'done' } as any);
1540
+ }
1541
+
1542
+ entry.state.taskStatus = notify ? 'done' : 'idle';
1543
+ entry.state.completedAt = Date.now();
1544
+ this.emit('event', { type: 'task_status', agentId, taskStatus: entry.state.taskStatus } as any);
1545
+ if (notify) {
1546
+ this.handleAgentDone(agentId, entry, 'Manually marked done');
1547
+ }
1548
+ this.sessionMonitor?.resetState(agentId);
1549
+ this.saveNow();
1550
+ this.emitAgentsChanged();
1551
+ console.log(`[workspace] ${entry.config.label}: manually marked ${notify ? 'done' : 'idle'} (${runningMsgs.length} messages completed)`);
1552
+ }
1553
+
1554
+ /** Mark agent task as failed (user manually marks failure) */
1555
+ markAgentFailed(agentId: string, notify: boolean): void {
1556
+ const entry = this.agents.get(agentId);
1557
+ if (!entry) return;
1558
+
1559
+ // Mark running inbox messages as failed
1560
+ const runningMsgs = this.bus.getLog().filter(m => m.to === agentId && m.status === 'running' && m.type !== 'ack');
1561
+ for (const m of runningMsgs) {
1562
+ m.status = 'failed' as any;
1563
+ this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'failed' } as any);
1564
+ }
1565
+
1566
+ entry.state.taskStatus = 'failed';
1567
+ entry.state.error = 'Manually marked as failed';
1568
+ this.emit('event', { type: 'task_status', agentId, taskStatus: 'failed' } as any);
1569
+ if (notify) {
1570
+ this.bus.notifyTaskComplete(agentId, [], 'Task failed');
1571
+ }
1572
+ this.sessionMonitor?.resetState(agentId);
1573
+ this.saveNow();
1574
+ this.emitAgentsChanged();
1575
+ console.log(`[workspace] ${entry.config.label}: manually marked failed (${runningMsgs.length} messages failed)`);
1576
+ }
1577
+
1578
+ /** Legacy stop — for headless mode */
1505
1579
  stopAgent(agentId: string): void {
1506
1580
  const entry = this.agents.get(agentId);
1507
- entry?.worker?.stop();
1581
+ if (!entry) return;
1582
+ if (entry.config.persistentSession) {
1583
+ this.markAgentDone(agentId, false);
1584
+ } else {
1585
+ entry.worker?.stop();
1586
+ }
1508
1587
  }
1509
1588
 
1510
1589
  /** Retry a failed agent from its last checkpoint */
@@ -1967,12 +2046,38 @@ export class WorkspaceOrchestrator extends EventEmitter {
1967
2046
  }
1968
2047
  }
1969
2048
 
2049
+ // Write agent context file for hooks to read (workDir/.forge/agent-context.json)
2050
+ try {
2051
+ const forgeDir = join(workDir, '.forge');
2052
+ const { mkdirSync: mkdirS } = require('node:fs');
2053
+ mkdirS(forgeDir, { recursive: true });
2054
+ const ctxPath = join(forgeDir, 'agent-context.json');
2055
+ writeFileSync(ctxPath, JSON.stringify({
2056
+ workspaceId: this.workspaceId,
2057
+ agentId: config.id,
2058
+ agentLabel: config.label,
2059
+ forgePort: Number(process.env.PORT) || 8403,
2060
+ }, null, 2));
2061
+ console.log(`[daemon] ${config.label}: wrote agent-context.json to ${ctxPath}`);
2062
+ } catch (err: any) {
2063
+ console.error(`[daemon] ${config.label}: failed to write agent-context.json: ${err.message}`);
2064
+ }
2065
+
1970
2066
  // Check if tmux session already exists
1971
2067
  let sessionAlreadyExists = false;
1972
2068
  try {
1973
2069
  execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 });
1974
2070
  sessionAlreadyExists = true;
1975
2071
  console.log(`[daemon] ${config.label}: persistent session already exists (${sessionName})`);
2072
+ // Ensure FORGE env vars are set in the tmux session environment
2073
+ // (for hooks that read them — set-environment makes them available to new processes in this session)
2074
+ try {
2075
+ execSync(`tmux set-environment -t "${sessionName}" FORGE_WORKSPACE_ID "${this.workspaceId}"`, { timeout: 3000 });
2076
+ execSync(`tmux set-environment -t "${sessionName}" FORGE_AGENT_ID "${config.id}"`, { timeout: 3000 });
2077
+ execSync(`tmux set-environment -t "${sessionName}" FORGE_PORT "${Number(process.env.PORT) || 8403}"`, { timeout: 3000 });
2078
+ } catch {}
2079
+ // Note: set-environment affects new processes in this tmux session.
2080
+ // Claude Code hooks run as child processes of the shell, which inherits tmux environment.
1976
2081
  } catch {
1977
2082
  // Create new tmux session and start the CLI agent
1978
2083
  try {
@@ -27,9 +27,9 @@ export interface SessionMonitorEvent {
27
27
  detail?: string; // e.g., result summary
28
28
  }
29
29
 
30
- const POLL_INTERVAL = 1000; // check every 1s (need to catch short executions)
31
- const IDLE_THRESHOLD = 10000; // 10s of no file change → check for done
32
- const STABLE_THRESHOLD = 20000; // 20s of no change → force done
30
+ const POLL_INTERVAL = 3000; // check every 3s
31
+ const IDLE_THRESHOLD = 3540000; // 59min of no file change → check for result entry
32
+ const STABLE_THRESHOLD = 3600000; // 60min of no change → force done (fallback if hook missed)
33
33
 
34
34
  export class SessionFileMonitor extends EventEmitter {
35
35
  private timers = new Map<string, NodeJS.Timeout>();
@@ -86,6 +86,20 @@ export class SessionFileMonitor extends EventEmitter {
86
86
  return this.currentState.get(agentId) || 'idle';
87
87
  }
88
88
 
89
+ /**
90
+ * Reset monitor state to idle and pause detection briefly.
91
+ * Call when orchestrator manually changes taskStatus (button/hook).
92
+ * Suppresses detection for 10s to avoid immediately flipping back.
93
+ */
94
+ private suppressUntil = new Map<string, number>();
95
+
96
+ resetState(agentId: string): void {
97
+ this.currentState.set(agentId, 'idle');
98
+ this.lastStableTime.set(agentId, Date.now());
99
+ // Suppress state changes for 10s after manual reset
100
+ this.suppressUntil.set(agentId, Date.now() + 10_000);
101
+ }
102
+
89
103
  /**
90
104
  * Resolve session file path for a project + session ID.
91
105
  */
@@ -202,6 +216,12 @@ export class SessionFileMonitor extends EventEmitter {
202
216
  const prev = this.currentState.get(agentId);
203
217
  if (prev === state) return;
204
218
 
219
+ // Suppress state changes if recently reset by orchestrator
220
+ const suppressed = this.suppressUntil.get(agentId);
221
+ if (suppressed && Date.now() < suppressed) {
222
+ return;
223
+ }
224
+
205
225
  this.currentState.set(agentId, state);
206
226
  const event: SessionMonitorEvent = { agentId, state, sessionFile: filePath, detail };
207
227
  this.emit('stateChange', event);
@@ -63,6 +63,9 @@ export function installForgeSkills(
63
63
  ensureForgePermissions(projectClaudeDir);
64
64
  }
65
65
 
66
+ // Install Stop hook in user-level settings (for agent completion detection)
67
+ installForgeStopHook(forgePort);
68
+
66
69
  return { installed };
67
70
  }
68
71
 
@@ -162,6 +165,90 @@ export function applyProfileToProject(
162
165
  }
163
166
  }
164
167
 
168
+ const FORGE_HOOK_MARKER = '# forge-stop-hook';
169
+
170
+ /**
171
+ * Install a Stop hook in user-level ~/.claude/settings.json.
172
+ * When Claude Code finishes a turn, the hook notifies Forge via HTTP.
173
+ * Preserves existing user hooks. Creates backup before modifying.
174
+ */
175
+ function installForgeStopHook(forgePort: number): void {
176
+ const settingsFile = join(homedir(), '.claude', 'settings.json');
177
+ const now = new Date();
178
+ const dateStr = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}-${String(now.getHours()).padStart(2,'0')}${String(now.getMinutes()).padStart(2,'0')}`;
179
+ const backupFile = join(homedir(), '.claude', `settings.json.forge-backup-${dateStr}`);
180
+ const daemonPort = forgePort + 2; // 8403 → 8405
181
+
182
+ // Hook reads agent context from .forge/agent-context.json in the project dir.
183
+ // This file is written by ensurePersistentSession for each agent's workDir.
184
+ // Falls back to env vars if file doesn't exist.
185
+ const hookCommand = `${FORGE_HOOK_MARKER}\nCTX_FILE="$(pwd)/.forge/agent-context.json"; if [ -f "$CTX_FILE" ]; then WS_ID=$(python3 -c "import json;print(json.load(open('$CTX_FILE')).get('workspaceId',''))" 2>/dev/null); AG_ID=$(python3 -c "import json;print(json.load(open('$CTX_FILE')).get('agentId',''))" 2>/dev/null); elif [ -n "$FORGE_WORKSPACE_ID" ]; then WS_ID="$FORGE_WORKSPACE_ID"; AG_ID="$FORGE_AGENT_ID"; fi; if [ -n "$WS_ID" ] && [ -n "$AG_ID" ]; then curl -s -X POST "http://localhost:${daemonPort}/workspace/$WS_ID/agents" -H "Content-Type: application/json" -d "{\\"action\\":\\"agent_done\\",\\"agentId\\":\\"$AG_ID\\"}" > /dev/null 2>&1 & fi`;
186
+
187
+ try {
188
+ let settings: any = {};
189
+ if (existsSync(settingsFile)) {
190
+ const raw = readFileSync(settingsFile, 'utf-8');
191
+ settings = JSON.parse(raw);
192
+
193
+ // Check if hook already installed
194
+ // Remove old forge hook if present (will re-add with latest version)
195
+ if (settings.hooks?.Stop) {
196
+ settings.hooks.Stop = settings.hooks.Stop.filter((h: any) => {
197
+ if (h.command?.includes(FORGE_HOOK_MARKER) || h.command?.includes('agent_done')) return false;
198
+ if (h.hooks?.some((sub: any) => sub.command?.includes(FORGE_HOOK_MARKER) || sub.command?.includes('agent_done'))) return false;
199
+ return true;
200
+ });
201
+ }
202
+
203
+ // Backup before modifying
204
+ writeFileSync(backupFile, raw, 'utf-8');
205
+ }
206
+
207
+ if (!settings.hooks) settings.hooks = {};
208
+ if (!settings.hooks.Stop) settings.hooks.Stop = [];
209
+
210
+ // Add forge hook (Claude Code hooks schema: matcher + hooks array)
211
+ settings.hooks.Stop.push({
212
+ matcher: '',
213
+ hooks: [{
214
+ type: 'command',
215
+ command: hookCommand,
216
+ timeout: 5000,
217
+ }],
218
+ });
219
+
220
+ mkdirSync(join(homedir(), '.claude'), { recursive: true });
221
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
222
+ console.log('[skills] Installed Forge Stop hook in ~/.claude/settings.json');
223
+ } catch (err: any) {
224
+ console.error('[skills] Failed to install Stop hook:', err.message);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Remove Forge Stop hook from user-level settings (cleanup).
230
+ */
231
+ export function removeForgeStopHook(): void {
232
+ const settingsFile = join(homedir(), '.claude', 'settings.json');
233
+ try {
234
+ if (!existsSync(settingsFile)) return;
235
+ const settings = JSON.parse(readFileSync(settingsFile, 'utf-8'));
236
+ if (!settings.hooks?.Stop) return;
237
+
238
+ settings.hooks.Stop = settings.hooks.Stop.filter((h: any) => {
239
+ // Remove entries matching either old flat format or new nested format
240
+ if (h.command?.includes(FORGE_HOOK_MARKER) || h.command?.includes('agent_done')) return false;
241
+ if (h.hooks?.some((sub: any) => sub.command?.includes(FORGE_HOOK_MARKER) || sub.command?.includes('agent_done'))) return false;
242
+ return true;
243
+ });
244
+ if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
245
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
246
+
247
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
248
+ console.log('[skills] Removed Forge Stop hook from ~/.claude/settings.json');
249
+ } catch {}
250
+ }
251
+
165
252
  /**
166
253
  * Check if forge skills are already installed for this agent.
167
254
  */
@@ -190,6 +190,12 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
190
190
  return jsonError(res, err.message);
191
191
  }
192
192
  }
193
+ case 'agent_done': {
194
+ // Called by Claude Code Stop hook — agent finished a turn
195
+ if (!agentId) return jsonError(res, 'agentId required');
196
+ orch.handleHookDone(agentId);
197
+ return json(res, { ok: true });
198
+ }
193
199
  case 'run': {
194
200
  if (!agentId) return jsonError(res, 'agentId required');
195
201
  if (!orch.isDaemonActive()) return jsonError(res, 'Start daemon first before running agents');
@@ -226,6 +232,16 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
226
232
  orch.stopAgent(agentId);
227
233
  return json(res, { ok: true });
228
234
  }
235
+ case 'mark_done': {
236
+ if (!agentId) return jsonError(res, 'agentId required');
237
+ orch.markAgentDone(agentId, body.notify !== false);
238
+ return json(res, { ok: true });
239
+ }
240
+ case 'mark_failed': {
241
+ if (!agentId) return jsonError(res, 'agentId required');
242
+ orch.markAgentFailed(agentId, body.notify !== false);
243
+ return json(res, { ok: true });
244
+ }
229
245
  case 'retry': {
230
246
  if (!agentId) return jsonError(res, 'agentId required');
231
247
  if (!orch.isDaemonActive()) return jsonError(res, 'Start daemon first before retrying agents');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.5.14",
3
+ "version": "0.5.16",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {