@aion0/forge 0.5.12 → 0.5.15

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,13 +1,23 @@
1
- # Forge v0.5.12
1
+ # Forge v0.5.15
2
2
 
3
3
  Released: 2026-03-31
4
4
 
5
- ## Changes since v0.5.11
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
6
10
 
7
11
  ### Bug Fixes
8
- - fix: correct bin paths in package.json
9
- - fix: merge pending upstream_complete auto-complete older ones before sending new
10
- - fix: upstream_complete notification tells agent to ignore if busy or duplicate
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
11
21
 
12
22
 
13
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.11...v0.5.12
23
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.14...v0.5.15
@@ -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: () => {
@@ -2517,43 +2529,20 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2517
2529
  },
2518
2530
  };
2519
2531
 
2520
- // If tmux session exists attach (primary or non-primary)
2521
- if (existingTmux) {
2522
- wsApi(workspaceId, 'open_terminal', { agentId: agent.id });
2523
- setFloatingTerminals(prev => [...prev, {
2524
- agentId: agent.id, label: agent.label, icon: agent.icon,
2525
- cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2526
- tmuxSession: existingTmux, sessionName: sessName,
2527
- isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2528
- }]);
2529
- return;
2530
- }
2531
-
2532
- // Primary without session → open directly (no dialog)
2533
- if (agent.primary) {
2534
- const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
2535
- setFloatingTerminals(prev => [...prev, {
2536
- agentId: agent.id, label: agent.label, icon: agent.icon,
2537
- cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2538
- tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
2539
- isPrimary: true, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2540
- }]);
2541
- return;
2542
- }
2543
-
2544
- // Non-primary: has boundSessionId → use it directly; no bound → show dialog
2545
- if (agent.boundSessionId) {
2532
+ // All paths: let daemon create/ensure session, then attach
2533
+ if (existingTmux || agent.primary || agent.persistentSession || agent.boundSessionId) {
2534
+ // Daemon creates session via ensurePersistentSession (launch script, no truncation)
2546
2535
  const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
2536
+ const tmux = existingTmux || res?.tmuxSession || sessName;
2547
2537
  setFloatingTerminals(prev => [...prev, {
2548
2538
  agentId: agent.id, label: agent.label, icon: agent.icon,
2549
- cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2550
- tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
2551
- resumeSessionId: agent.boundSessionId,
2552
- isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2539
+ cliId: agent.agentId || 'claude', workDir,
2540
+ tmuxSession: tmux, sessionName: sessName,
2541
+ isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2553
2542
  }]);
2554
2543
  return;
2555
2544
  }
2556
- // No bound session → show launch dialog (New / Resume / Select)
2545
+ // No persistent session, no bound session → show launch dialog
2557
2546
  setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true, initialPos });
2558
2547
  },
2559
2548
  onSwitchSession: async () => {
@@ -2934,28 +2923,19 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2934
2923
  onLaunch={async (resumeMode, sessionId) => {
2935
2924
  const { agent, sessName, workDir } = termLaunchDialog;
2936
2925
  setTermLaunchDialog(null);
2937
- const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id });
2938
- if (res.ok) {
2939
- // Save selected session as boundSessionId if user chose a specific one
2940
- if (sessionId) {
2941
- wsApi(workspaceId, 'update', { agentId: agent.id, config: { ...agent, boundSessionId: sessionId } }).catch(() => {});
2942
- }
2943
- setFloatingTerminals(prev => [...prev, {
2944
- agentId: agent.id, label: agent.label, icon: agent.icon,
2945
- cliId: agent.agentId || 'claude',
2946
- cliCmd: res.cliCmd || 'claude',
2947
- cliType: res.cliType || 'claude-code',
2948
- workDir,
2949
- sessionName: sessName, resumeMode, resumeSessionId: sessionId, isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: sessionId || agent.boundSessionId, initialPos: termLaunchDialog.initialPos,
2950
- profileEnv: {
2951
- ...(res.env || {}),
2952
- ...(res.model ? { CLAUDE_MODEL: res.model } : {}),
2953
- FORGE_AGENT_ID: agent.id,
2954
- FORGE_WORKSPACE_ID: workspaceId,
2955
- FORGE_PORT: String(window.location.port || 8403),
2956
- },
2957
- }]);
2926
+ // Save selected session as boundSessionId
2927
+ if (sessionId) {
2928
+ await wsApi(workspaceId, 'update', { agentId: agent.id, config: { ...agent, boundSessionId: sessionId } }).catch(() => {});
2958
2929
  }
2930
+ // Daemon creates session (launch script), then attach
2931
+ const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
2932
+ const tmux = res?.tmuxSession || sessName;
2933
+ setFloatingTerminals(prev => [...prev, {
2934
+ agentId: agent.id, label: agent.label, icon: agent.icon,
2935
+ cliId: agent.agentId || 'claude', workDir,
2936
+ tmuxSession: tmux, sessionName: sessName,
2937
+ isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: sessionId || agent.boundSessionId, initialPos: termLaunchDialog.initialPos,
2938
+ }]);
2959
2939
  }}
2960
2940
  onCancel={() => setTermLaunchDialog(null)}
2961
2941
  />
@@ -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> {
@@ -1353,16 +1378,20 @@ export class WorkspaceOrchestrator extends EventEmitter {
1353
1378
  }
1354
1379
  }
1355
1380
 
1356
- // Case 4: Failed → notify sender so they know
1357
- if (msg.status === 'failed' && !this.forgeActedMessages.has(`failed-${msg.id}`)) {
1358
- const senderEntry = this.agents.get(msg.from);
1359
- const targetLabel = this.agents.get(msg.to)?.config.label || msg.to;
1360
- if (senderEntry && msg.from !== '_forge' && msg.from !== '_system') {
1361
- this.bus.send('_forge', msg.from, 'notify', {
1362
- action: 'update_notify',
1363
- content: `Your message to ${targetLabel} has failed. You may want to retry or take a different approach.`,
1364
- });
1365
- console.log(`[forge-agent] Notified ${senderEntry.config.label} that message to ${targetLabel} failed`);
1381
+ // Case 4: Failed → notify sender (once per sender→target pair)
1382
+ if (msg.status === 'failed') {
1383
+ const failKey = `failed-${msg.from}->${msg.to}`;
1384
+ if (!this.forgeActedMessages.has(failKey)) {
1385
+ const senderEntry = this.agents.get(msg.from);
1386
+ const targetLabel = this.agents.get(msg.to)?.config.label || msg.to;
1387
+ if (senderEntry && msg.from !== '_forge' && msg.from !== '_system') {
1388
+ this.bus.send('_forge', msg.from, 'notify', {
1389
+ action: 'update_notify',
1390
+ content: `Your message to ${targetLabel} has failed. You may want to retry or take a different approach.`,
1391
+ });
1392
+ console.log(`[forge-agent] Notified ${senderEntry.config.label} that message to ${targetLabel} failed (once)`);
1393
+ }
1394
+ this.forgeActedMessages.add(failKey);
1366
1395
  }
1367
1396
  this.forgeActedMessages.add(`failed-${msg.id}`);
1368
1397
  }
@@ -1498,9 +1527,63 @@ export class WorkspaceOrchestrator extends EventEmitter {
1498
1527
  }
1499
1528
 
1500
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 */
1501
1579
  stopAgent(agentId: string): void {
1502
1580
  const entry = this.agents.get(agentId);
1503
- entry?.worker?.stop();
1581
+ if (!entry) return;
1582
+ if (entry.config.persistentSession) {
1583
+ this.markAgentDone(agentId, false);
1584
+ } else {
1585
+ entry.worker?.stop();
1586
+ }
1504
1587
  }
1505
1588
 
1506
1589
  /** Retry a failed agent from its last checkpoint */
@@ -1963,12 +2046,38 @@ export class WorkspaceOrchestrator extends EventEmitter {
1963
2046
  }
1964
2047
  }
1965
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
+
1966
2066
  // Check if tmux session already exists
1967
2067
  let sessionAlreadyExists = false;
1968
2068
  try {
1969
2069
  execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 });
1970
2070
  sessionAlreadyExists = true;
1971
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.
1972
2081
  } catch {
1973
2082
  // Create new tmux session and start the CLI agent
1974
2083
  try {
@@ -2023,27 +2132,24 @@ export class WorkspaceOrchestrator extends EventEmitter {
2023
2132
 
2024
2133
  execSync(`tmux new-session -d -s "${sessionName}" -c "${workDir}"`, { timeout: 5000 });
2025
2134
 
2026
- // Reset profile env vars (unset any leftover from previous agent) then set new ones
2027
- const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
2028
- const unsetCmd = profileVarsToReset.map(v => `unset ${v}`).join(' && ');
2029
- execSync(`tmux send-keys -t "${sessionName}" '${unsetCmd}' Enter`, { timeout: 5000 });
2135
+ // Build launch script to avoid tmux send-keys truncation
2136
+ const scriptLines: string[] = ['#!/bin/bash', `cd "${workDir}"`];
2137
+
2138
+ // Unset old profile vars
2139
+ scriptLines.push('unset ANTHROPIC_AUTH_TOKEN ANTHROPIC_BASE_URL ANTHROPIC_SMALL_FAST_MODEL CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC DISABLE_TELEMETRY DISABLE_ERROR_REPORTING DISABLE_AUTOUPDATER DISABLE_NON_ESSENTIAL_MODEL_CALLS CLAUDE_MODEL');
2030
2140
 
2031
- // Set FORGE env vars (short, separate command)
2032
- execSync(`tmux send-keys -t "${sessionName}" 'export FORGE_WORKSPACE_ID="${this.workspaceId}" FORGE_AGENT_ID="${config.id}" FORGE_PORT="${Number(process.env.PORT) || 8403}"' Enter`, { timeout: 5000 });
2141
+ // Set FORGE env vars
2142
+ scriptLines.push(`export FORGE_WORKSPACE_ID="${this.workspaceId}" FORGE_AGENT_ID="${config.id}" FORGE_PORT="${Number(process.env.PORT) || 8403}"`);
2033
2143
 
2034
- // Set profile env vars if any (separate command to avoid truncation)
2144
+ // Set profile env vars
2035
2145
  if (envExports) {
2036
- execSync(`tmux send-keys -t "${sessionName}" '${envExports.replace(/ && $/, '')}' Enter`, { timeout: 5000 });
2146
+ scriptLines.push(envExports.replace(/ && /g, '\n').replace(/\n$/, ''));
2037
2147
  }
2038
2148
 
2039
- // Build CLI start command
2040
- const parts: string[] = [];
2149
+ // Build CLI command
2041
2150
  let cmd = cliCmd;
2042
-
2043
- // Session resume: use bound session ID (primary from project-sessions, others from config)
2044
2151
  if (supportsSession) {
2045
2152
  let sessionId: string | undefined;
2046
-
2047
2153
  if (config.primary) {
2048
2154
  try {
2049
2155
  const { getFixedSession } = await import('../project-sessions') as any;
@@ -2052,7 +2158,6 @@ export class WorkspaceOrchestrator extends EventEmitter {
2052
2158
  } else {
2053
2159
  sessionId = config.boundSessionId;
2054
2160
  }
2055
-
2056
2161
  if (sessionId) {
2057
2162
  const sessionFile = join(this.getCliSessionDir(config.workDir), `${sessionId}.jsonl`);
2058
2163
  if (existsSync(sessionFile)) {
@@ -2061,15 +2166,16 @@ export class WorkspaceOrchestrator extends EventEmitter {
2061
2166
  console.log(`[daemon] ${config.label}: bound session ${sessionId} missing, starting fresh`);
2062
2167
  }
2063
2168
  }
2064
- // No bound session → start fresh (no -c, avoids "No conversation found")
2065
2169
  }
2066
2170
  if (modelFlag) cmd += modelFlag;
2067
2171
  if (config.skipPermissions !== false && skipPermissionsFlag) cmd += ` ${skipPermissionsFlag}`;
2068
2172
  if (mcpConfigFlag) cmd += mcpConfigFlag;
2069
- parts.push(cmd);
2173
+ scriptLines.push(`exec ${cmd}`);
2070
2174
 
2071
- const startCmd = parts.join(' && ');
2072
- execSync(`tmux send-keys -t "${sessionName}" '${startCmd}' Enter`, { timeout: 5000 });
2175
+ // Write script and execute in tmux
2176
+ const scriptPath = `/tmp/forge-launch-${config.id.replace(/[^a-z0-9-]/g, '')}.sh`;
2177
+ writeFileSync(scriptPath, scriptLines.join('\n'), { mode: 0o755 });
2178
+ execSync(`tmux send-keys -t "${sessionName}" 'bash ${scriptPath}' Enter`, { timeout: 5000 });
2073
2179
 
2074
2180
  console.log(`[daemon] ${config.label}: persistent session created (${sessionName}) [${cliType}: ${cliCmd}]`);
2075
2181
 
@@ -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.12",
3
+ "version": "0.5.15",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {