@aion0/forge 0.5.6 → 0.5.8
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 +8 -0
- package/RELEASE_NOTES.md +130 -4
- package/app/api/monitor/route.ts +12 -0
- package/app/api/project-sessions/route.ts +61 -0
- package/app/api/workspace/route.ts +1 -1
- package/check-forge-status.sh +9 -0
- package/components/MonitorPanel.tsx +15 -0
- package/components/ProjectDetail.tsx +99 -5
- package/components/SessionView.tsx +67 -19
- package/components/SkillsPanel.tsx +1 -0
- package/components/WebTerminal.tsx +40 -25
- package/components/WorkspaceView.tsx +649 -97
- package/lib/claude-sessions.ts +26 -28
- package/lib/forge-mcp-server.ts +389 -0
- package/lib/forge-skills/forge-inbox.md +13 -12
- package/lib/forge-skills/forge-send.md +13 -6
- package/lib/forge-skills/forge-status.md +12 -12
- package/lib/project-sessions.ts +48 -0
- package/lib/session-utils.ts +49 -0
- package/lib/workspace/__tests__/state-machine.test.ts +2 -2
- package/lib/workspace/agent-worker.ts +2 -5
- package/lib/workspace/backends/cli-backend.ts +3 -0
- package/lib/workspace/orchestrator.ts +763 -80
- package/lib/workspace/persistence.ts +0 -1
- package/lib/workspace/types.ts +18 -11
- package/lib/workspace/watch-manager.ts +251 -2
- package/lib/workspace-standalone.ts +83 -27
- package/middleware.ts +6 -0
- package/next.config.ts +8 -0
- package/package.json +4 -2
|
@@ -147,7 +147,6 @@ export function loadWorkspace(workspaceId: string): WorkspaceState | null {
|
|
|
147
147
|
if ('status' in agentState && !('smithStatus' in agentState)) {
|
|
148
148
|
const oldStatus = (agentState as any).status;
|
|
149
149
|
(agentState as any).smithStatus = 'down';
|
|
150
|
-
(agentState as any).mode = (agentState as any).runMode || 'auto';
|
|
151
150
|
(agentState as any).taskStatus = (oldStatus === 'running' || oldStatus === 'listening') ? 'idle' :
|
|
152
151
|
(oldStatus === 'interrupted') ? 'idle' :
|
|
153
152
|
(oldStatus === 'waiting_approval') ? 'idle' :
|
package/lib/workspace/types.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface WorkspaceAgentConfig {
|
|
|
12
12
|
icon: string;
|
|
13
13
|
// Node type: 'agent' (default) or 'input' (user-provided requirements)
|
|
14
14
|
type?: 'agent' | 'input';
|
|
15
|
+
// Primary agent: one per workspace, terminal-only, root dir, fixed session
|
|
16
|
+
primary?: boolean;
|
|
15
17
|
// Input node: append-only entries (latest is active, older are history)
|
|
16
18
|
content?: string; // legacy single content (migrated to entries)
|
|
17
19
|
entries?: InputEntry[]; // incremental input history
|
|
@@ -32,6 +34,12 @@ export interface WorkspaceAgentConfig {
|
|
|
32
34
|
steps: AgentStep[];
|
|
33
35
|
// Approval gate
|
|
34
36
|
requiresApproval?: boolean;
|
|
37
|
+
// Persistent terminal: keep a tmux+claude session alive, inject messages directly
|
|
38
|
+
persistentSession?: boolean;
|
|
39
|
+
// Bound CLI session ID for this agent (like fixedSessionId but per-agent)
|
|
40
|
+
boundSessionId?: string;
|
|
41
|
+
// Skip dangerous permissions check (default true when persistentSession is enabled)
|
|
42
|
+
skipPermissions?: boolean;
|
|
35
43
|
// Watch: autonomous periodic monitoring
|
|
36
44
|
watch?: WatchConfig;
|
|
37
45
|
}
|
|
@@ -39,20 +47,23 @@ export interface WorkspaceAgentConfig {
|
|
|
39
47
|
// ─── Watch Config ─────────────────────────────────────────
|
|
40
48
|
|
|
41
49
|
export interface WatchTarget {
|
|
42
|
-
type: 'directory' | 'git' | 'agent_output' | 'command';
|
|
43
|
-
path?: string; // directory: relative path; agent_output: agent ID
|
|
44
|
-
pattern?: string; // glob for directory, stdout pattern for command
|
|
50
|
+
type: 'directory' | 'git' | 'agent_output' | 'agent_log' | 'session' | 'command';
|
|
51
|
+
path?: string; // directory: relative path; agent_output/agent_log: agent ID
|
|
52
|
+
pattern?: string; // glob for directory, regex/keyword for agent_log, stdout pattern for command
|
|
45
53
|
cmd?: string; // shell command (type='command' only)
|
|
54
|
+
contextChars?: number; // agent_log/session: chars to capture around match (default 500)
|
|
55
|
+
debounce?: number; // seconds to wait after match before triggering (default 10)
|
|
46
56
|
}
|
|
47
57
|
|
|
48
|
-
export type WatchAction = 'log' | 'analyze' | 'approve';
|
|
58
|
+
export type WatchAction = 'log' | 'analyze' | 'approve' | 'send_message';
|
|
49
59
|
|
|
50
60
|
export interface WatchConfig {
|
|
51
61
|
enabled: boolean;
|
|
52
62
|
interval: number; // check interval in seconds (default 60)
|
|
53
63
|
targets: WatchTarget[];
|
|
54
|
-
action: WatchAction; // log=report only, analyze=auto-execute, approve=pending user approval
|
|
64
|
+
action: WatchAction; // log=report only, analyze=auto-execute, approve=pending user approval, send_message=send to target agent
|
|
55
65
|
prompt?: string; // custom prompt for analyze action (default: "Analyze the following changes...")
|
|
66
|
+
sendTo?: string; // send_message: target agent ID
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
export type AgentBackendType = 'api' | 'cli';
|
|
@@ -76,16 +87,12 @@ export type SmithStatus = 'down' | 'active';
|
|
|
76
87
|
/** Task layer: current work execution */
|
|
77
88
|
export type TaskStatus = 'idle' | 'running' | 'done' | 'failed';
|
|
78
89
|
|
|
79
|
-
/** Agent execution mode */
|
|
80
|
-
export type AgentMode = 'auto' | 'manual';
|
|
81
|
-
|
|
82
90
|
/** @deprecated Use SmithStatus + TaskStatus instead */
|
|
83
91
|
export type AgentStatus = SmithStatus | TaskStatus | 'paused' | 'waiting_approval' | 'listening' | 'interrupted';
|
|
84
92
|
|
|
85
93
|
export interface AgentState {
|
|
86
94
|
// ─── Smith layer (daemon lifecycle) ─────
|
|
87
95
|
smithStatus: SmithStatus; // down=not started, active=listening on bus
|
|
88
|
-
mode: AgentMode; // auto=respond to messages, manual=user in terminal
|
|
89
96
|
|
|
90
97
|
// ─── Task layer (current work) ──────────
|
|
91
98
|
taskStatus: TaskStatus; // idle/running/done/failed
|
|
@@ -98,7 +105,7 @@ export interface AgentState {
|
|
|
98
105
|
lastCheckpoint?: number;
|
|
99
106
|
cliSessionId?: string;
|
|
100
107
|
currentMessageId?: string; // bus message that triggered current/last task execution
|
|
101
|
-
tmuxSession?: string; // tmux session name
|
|
108
|
+
tmuxSession?: string; // tmux session name (persistent or user-opened terminal)
|
|
102
109
|
startedAt?: number;
|
|
103
110
|
completedAt?: number;
|
|
104
111
|
error?: string;
|
|
@@ -221,7 +228,7 @@ export interface AgentBackend {
|
|
|
221
228
|
// ─── Worker Events ───────────────────────────────────────
|
|
222
229
|
|
|
223
230
|
export type WorkerEvent =
|
|
224
|
-
| { type: 'smith_status'; agentId: string; smithStatus: SmithStatus
|
|
231
|
+
| { type: 'smith_status'; agentId: string; smithStatus: SmithStatus }
|
|
225
232
|
| { type: 'task_status'; agentId: string; taskStatus: TaskStatus; error?: string }
|
|
226
233
|
| { type: 'log'; agentId: string; entry: TaskLogEntry }
|
|
227
234
|
| { type: 'step'; agentId: string; stepIndex: number; stepLabel: string }
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { EventEmitter } from 'node:events';
|
|
10
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
10
|
+
import { existsSync, readdirSync, statSync, readFileSync, openSync, readSync, fstatSync, closeSync } from 'node:fs';
|
|
11
11
|
import { join, relative } from 'node:path';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
12
13
|
import { execSync } from 'node:child_process';
|
|
13
14
|
import type { WorkspaceAgentConfig, WatchTarget, WatchConfig } from './types';
|
|
14
15
|
import { appendAgentLog } from './persistence';
|
|
@@ -19,6 +20,8 @@ interface WatchSnapshot {
|
|
|
19
20
|
lastCheckTime: number; // timestamp ms — only files modified after this are "changed"
|
|
20
21
|
gitHash?: string;
|
|
21
22
|
commandOutput?: string;
|
|
23
|
+
logLineCount?: number; // last known line count in agent's logs.jsonl
|
|
24
|
+
sessionFileSize?: number; // last known file size of session JSONL (bytes)
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
interface WatchChange {
|
|
@@ -132,11 +135,196 @@ function detectCommandChanges(projectPath: string, target: WatchTarget, prevOutp
|
|
|
132
135
|
}
|
|
133
136
|
}
|
|
134
137
|
|
|
138
|
+
function detectAgentLogChanges(workspaceId: string, targetAgentId: string, pattern: string | undefined, prevLineCount: number, contextChars = 500): { changes: WatchChange | null; lineCount: number } {
|
|
139
|
+
const logFile = join(homedir(), '.forge', 'workspaces', workspaceId, 'agents', targetAgentId, 'logs.jsonl');
|
|
140
|
+
if (!existsSync(logFile)) return { changes: null, lineCount: 0 };
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const lines = readFileSync(logFile, 'utf-8').split('\n').filter(Boolean);
|
|
144
|
+
const currentCount = lines.length;
|
|
145
|
+
if (currentCount <= prevLineCount) return { changes: null, lineCount: currentCount };
|
|
146
|
+
|
|
147
|
+
// Get new lines since last check
|
|
148
|
+
const newLines = lines.slice(prevLineCount);
|
|
149
|
+
const newEntries: string[] = [];
|
|
150
|
+
|
|
151
|
+
// Build matcher: try regex first, fallback to case-insensitive includes
|
|
152
|
+
let matcher: ((text: string) => boolean) | null = null;
|
|
153
|
+
if (pattern) {
|
|
154
|
+
try {
|
|
155
|
+
const re = new RegExp(pattern, 'i');
|
|
156
|
+
matcher = (text: string) => re.test(text);
|
|
157
|
+
} catch {
|
|
158
|
+
const lower = pattern.toLowerCase();
|
|
159
|
+
matcher = (text: string) => text.toLowerCase().includes(lower);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Extract content around match (contextChars before + after match point)
|
|
164
|
+
const extractContext = (text: string): string => {
|
|
165
|
+
if (!pattern || text.length <= contextChars) return text.slice(0, contextChars);
|
|
166
|
+
// Find match position for context window
|
|
167
|
+
let matchIdx = 0;
|
|
168
|
+
try {
|
|
169
|
+
const re = new RegExp(pattern, 'i');
|
|
170
|
+
const m = re.exec(text);
|
|
171
|
+
if (m) matchIdx = m.index;
|
|
172
|
+
} catch {
|
|
173
|
+
matchIdx = text.toLowerCase().indexOf(pattern.toLowerCase());
|
|
174
|
+
if (matchIdx === -1) matchIdx = 0;
|
|
175
|
+
}
|
|
176
|
+
const half = Math.floor(contextChars / 2);
|
|
177
|
+
const start = Math.max(0, matchIdx - half);
|
|
178
|
+
const end = Math.min(text.length, start + contextChars);
|
|
179
|
+
return (start > 0 ? '...' : '') + text.slice(start, end) + (end < text.length ? '...' : '');
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
for (const line of newLines) {
|
|
183
|
+
try {
|
|
184
|
+
const entry = JSON.parse(line);
|
|
185
|
+
const content = entry.content || '';
|
|
186
|
+
if (matcher && !matcher(content)) continue;
|
|
187
|
+
newEntries.push(extractContext(content));
|
|
188
|
+
} catch {
|
|
189
|
+
if (matcher && !matcher(line)) continue;
|
|
190
|
+
newEntries.push(extractContext(line));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (newEntries.length === 0) return { changes: null, lineCount: currentCount };
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
changes: {
|
|
198
|
+
targetType: 'agent_log',
|
|
199
|
+
description: `${newEntries.length} new log entries${pattern ? ` matching "${pattern}"` : ''}`,
|
|
200
|
+
files: newEntries.slice(0, 10),
|
|
201
|
+
},
|
|
202
|
+
lineCount: currentCount,
|
|
203
|
+
};
|
|
204
|
+
} catch {
|
|
205
|
+
return { changes: null, lineCount: prevLineCount };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Track which session file was used last (to detect file switch)
|
|
210
|
+
const lastSessionFile = new Map<string, string>();
|
|
211
|
+
|
|
212
|
+
function detectSessionChanges(projectPath: string, pattern: string | undefined, prevLineCount: number, contextChars = 500, sessionId?: string): { changes: WatchChange | null; lineCount: number } {
|
|
213
|
+
const claudeHome = join(homedir(), '.claude', 'projects');
|
|
214
|
+
const encoded = projectPath.replace(/\//g, '-');
|
|
215
|
+
const sessionDir = join(claudeHome, encoded);
|
|
216
|
+
if (!existsSync(sessionDir)) return { changes: null, lineCount: prevLineCount };
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
let latestFile: string;
|
|
220
|
+
|
|
221
|
+
if (sessionId) {
|
|
222
|
+
latestFile = join(sessionDir, `${sessionId}.jsonl`);
|
|
223
|
+
if (!existsSync(latestFile)) return { changes: null, lineCount: prevLineCount };
|
|
224
|
+
} else {
|
|
225
|
+
const files = readdirSync(sessionDir)
|
|
226
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
227
|
+
.map(f => ({ name: f, mtime: statSync(join(sessionDir, f)).mtimeMs }))
|
|
228
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
229
|
+
if (files.length === 0) return { changes: null, lineCount: prevLineCount };
|
|
230
|
+
latestFile = join(sessionDir, files[0].name);
|
|
231
|
+
}
|
|
232
|
+
// Detect if session file changed (user started new session) → reset tracking
|
|
233
|
+
const cacheKey = `${projectPath}:${sessionId || 'latest'}`;
|
|
234
|
+
const prevFile = lastSessionFile.get(cacheKey);
|
|
235
|
+
if (prevFile && prevFile !== latestFile) {
|
|
236
|
+
// Session file switched — reset prevLineCount to read from start of new file
|
|
237
|
+
prevLineCount = 0;
|
|
238
|
+
console.log(`[watch] Session file switched: ${prevFile.split('/').pop()} → ${latestFile.split('/').pop()}`);
|
|
239
|
+
}
|
|
240
|
+
lastSessionFile.set(cacheKey, latestFile);
|
|
241
|
+
|
|
242
|
+
// Only read new bytes since last check (efficient for large files)
|
|
243
|
+
const fd = openSync(latestFile, 'r');
|
|
244
|
+
const fileSize = fstatSync(fd).size;
|
|
245
|
+
if (fileSize <= prevLineCount) { closeSync(fd); return { changes: null, lineCount: fileSize }; }
|
|
246
|
+
|
|
247
|
+
const readFrom = Math.max(0, prevLineCount > 0 ? prevLineCount - 1 : 0);
|
|
248
|
+
const readSize = Math.min(fileSize - readFrom, 500_000); // max 500KB per check
|
|
249
|
+
const buf = Buffer.alloc(readSize);
|
|
250
|
+
readSync(fd, buf, 0, readSize, readFrom);
|
|
251
|
+
closeSync(fd);
|
|
252
|
+
|
|
253
|
+
const tail = buf.toString('utf-8');
|
|
254
|
+
const newLines = tail.split('\n').filter(Boolean);
|
|
255
|
+
if (prevLineCount > 0 && newLines.length > 0) newLines.shift(); // skip partial first line
|
|
256
|
+
|
|
257
|
+
// Build matcher
|
|
258
|
+
let matcher: ((text: string) => boolean) | null = null;
|
|
259
|
+
if (pattern) {
|
|
260
|
+
try {
|
|
261
|
+
const re = new RegExp(pattern, 'i');
|
|
262
|
+
matcher = (text: string) => re.test(text);
|
|
263
|
+
} catch {
|
|
264
|
+
const lower = pattern.toLowerCase();
|
|
265
|
+
matcher = (text: string) => text.toLowerCase().includes(lower);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Only extract the LAST assistant/result text (not all entries)
|
|
270
|
+
const entries: string[] = [];
|
|
271
|
+
for (const line of [...newLines].reverse()) {
|
|
272
|
+
try {
|
|
273
|
+
const parsed = JSON.parse(line);
|
|
274
|
+
let text = '';
|
|
275
|
+
if (parsed.type === 'assistant' && parsed.message?.content) {
|
|
276
|
+
for (const block of (Array.isArray(parsed.message.content) ? parsed.message.content : [parsed.message.content])) {
|
|
277
|
+
if (typeof block === 'string') text += block;
|
|
278
|
+
else if (block.type === 'text' && block.text) text += block.text;
|
|
279
|
+
}
|
|
280
|
+
} else if (parsed.type === 'result' && parsed.result) {
|
|
281
|
+
text = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
|
|
282
|
+
} else if (parsed.type === 'human' || parsed.type === 'user') {
|
|
283
|
+
const content = parsed.content || parsed.message?.content;
|
|
284
|
+
text = typeof content === 'string' ? content : '';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!text) continue;
|
|
288
|
+
if (matcher && !matcher(text)) continue;
|
|
289
|
+
|
|
290
|
+
// Context extraction around match
|
|
291
|
+
if (text.length > contextChars && pattern) {
|
|
292
|
+
let matchIdx = 0;
|
|
293
|
+
try { matchIdx = new RegExp(pattern, 'i').exec(text)?.index || 0; } catch {}
|
|
294
|
+
const half = Math.floor(contextChars / 2);
|
|
295
|
+
const start = Math.max(0, matchIdx - half);
|
|
296
|
+
const end = Math.min(text.length, start + contextChars);
|
|
297
|
+
text = (start > 0 ? '...' : '') + text.slice(start, end) + (end < text.length ? '...' : '');
|
|
298
|
+
} else {
|
|
299
|
+
text = text.slice(0, contextChars);
|
|
300
|
+
}
|
|
301
|
+
entries.push(text);
|
|
302
|
+
break; // only take the last matching entry (we're scanning in reverse)
|
|
303
|
+
} catch {}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (entries.length === 0) return { changes: null, lineCount: fileSize };
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
changes: {
|
|
310
|
+
targetType: 'session',
|
|
311
|
+
description: `${entries.length} new session entries${pattern ? ` matching "${pattern}"` : ''}`,
|
|
312
|
+
files: entries.slice(0, 10),
|
|
313
|
+
},
|
|
314
|
+
lineCount: fileSize, // actually bytes, reusing field name
|
|
315
|
+
};
|
|
316
|
+
} catch {
|
|
317
|
+
return { changes: null, lineCount: prevLineCount };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
135
321
|
// ─── WatchManager class ──────────────────────────────────
|
|
136
322
|
|
|
137
323
|
export class WatchManager extends EventEmitter {
|
|
138
324
|
private timers = new Map<string, NodeJS.Timeout>();
|
|
139
325
|
private snapshots = new Map<string, WatchSnapshot>();
|
|
326
|
+
private pendingAlert = new Map<string, { changes: WatchChange[]; summary: string; timestamp: number }>();
|
|
327
|
+
private debounceTimers = new Map<string, NodeJS.Timeout>();
|
|
140
328
|
|
|
141
329
|
constructor(
|
|
142
330
|
private workspaceId: string,
|
|
@@ -229,6 +417,37 @@ export class WatchManager extends EventEmitter {
|
|
|
229
417
|
}
|
|
230
418
|
break;
|
|
231
419
|
}
|
|
420
|
+
case 'agent_log': {
|
|
421
|
+
if (target.path) {
|
|
422
|
+
const agentLabel = this.getAgents().get(target.path)?.config.label || target.path;
|
|
423
|
+
const { changes, lineCount } = detectAgentLogChanges(this.workspaceId, target.path, target.pattern, prev.logLineCount || 0, target.contextChars || 500);
|
|
424
|
+
newSnapshot.logLineCount = lineCount;
|
|
425
|
+
if (changes) allChanges.push({ ...changes, description: `${agentLabel} log: ${changes.description}` });
|
|
426
|
+
}
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
case 'session': {
|
|
430
|
+
// Resolve session ID: explicit (cmd field) > agent's cliSessionId > latest file
|
|
431
|
+
let sessionId: string | undefined;
|
|
432
|
+
if (target.cmd) {
|
|
433
|
+
// Explicit session ID selected by user
|
|
434
|
+
sessionId = target.cmd;
|
|
435
|
+
} else if (target.path) {
|
|
436
|
+
// Agent selected — use its current cliSessionId
|
|
437
|
+
const agents = this.getAgents();
|
|
438
|
+
const targetAgent = agents.get(target.path);
|
|
439
|
+
if (targetAgent) {
|
|
440
|
+
sessionId = (targetAgent.state as any).cliSessionId;
|
|
441
|
+
if (!sessionId) {
|
|
442
|
+
console.log(`[watch] Agent ${targetAgent.config.label} has no cliSessionId — falling back to latest session file`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const { changes, lineCount: newFileSize } = detectSessionChanges(this.projectPath, target.pattern, prev.sessionFileSize || 0, target.contextChars || 500, sessionId);
|
|
447
|
+
newSnapshot.sessionFileSize = newFileSize;
|
|
448
|
+
if (changes) allChanges.push(changes);
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
232
451
|
case 'command': {
|
|
233
452
|
const { changes, commandOutput } = detectCommandChanges(this.projectPath, target, prev.commandOutput);
|
|
234
453
|
newSnapshot.commandOutput = commandOutput;
|
|
@@ -259,10 +478,40 @@ export class WatchManager extends EventEmitter {
|
|
|
259
478
|
|
|
260
479
|
console.log(`[watch] ${config.label}: detected ${allChanges.length} change(s)`);
|
|
261
480
|
|
|
481
|
+
// Get debounce from first target that has it, or default 10s
|
|
482
|
+
const debounceMs = (config.watch!.targets.find(t => t.debounce !== undefined)?.debounce ?? 10) * 1000;
|
|
483
|
+
|
|
484
|
+
if (debounceMs > 0) {
|
|
485
|
+
// Accumulate changes, reset timer each time
|
|
486
|
+
const existing = this.pendingAlert.get(agentId);
|
|
487
|
+
const merged = existing ? [...existing.changes, ...allChanges] : allChanges;
|
|
488
|
+
const mergedSummary = merged.map(c =>
|
|
489
|
+
`[${c.targetType}] ${c.description}${c.files.length ? '\n ' + c.files.join('\n ') : ''}`
|
|
490
|
+
).join('\n');
|
|
491
|
+
this.pendingAlert.set(agentId, { changes: merged, summary: mergedSummary, timestamp: Date.now() });
|
|
492
|
+
|
|
493
|
+
// Clear previous debounce timer, set new one
|
|
494
|
+
const prevTimer = this.debounceTimers.get(agentId);
|
|
495
|
+
if (prevTimer) clearTimeout(prevTimer);
|
|
496
|
+
|
|
497
|
+
this.debounceTimers.set(agentId, setTimeout(() => {
|
|
498
|
+
const pending = this.pendingAlert.get(agentId);
|
|
499
|
+
if (!pending) return;
|
|
500
|
+
this.pendingAlert.delete(agentId);
|
|
501
|
+
this.debounceTimers.delete(agentId);
|
|
502
|
+
this.emitAlert(agentId, config, pending.changes, pending.summary);
|
|
503
|
+
}, debounceMs));
|
|
504
|
+
|
|
505
|
+
console.log(`[watch] ${config.label}: debouncing ${debounceMs / 1000}s...`);
|
|
506
|
+
} else {
|
|
507
|
+
this.emitAlert(agentId, config, allChanges, summary);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private emitAlert(agentId: string, config: WorkspaceAgentConfig, allChanges: WatchChange[], summary: string): void {
|
|
262
512
|
const entry = { type: 'system' as const, subtype: 'watch_detected', content: `🔍 Watch detected changes:\n${summary}`, timestamp: new Date().toISOString() };
|
|
263
513
|
appendAgentLog(this.workspaceId, agentId, entry).catch(() => {});
|
|
264
514
|
|
|
265
|
-
// Emit SSE event for UI
|
|
266
515
|
this.emit('watch_alert', {
|
|
267
516
|
type: 'watch_alert',
|
|
268
517
|
agentId,
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
16
16
|
import { readdirSync, statSync } from 'node:fs';
|
|
17
|
-
import { join } from 'node:path';
|
|
17
|
+
import { join, resolve } from 'node:path';
|
|
18
18
|
import { homedir } from 'node:os';
|
|
19
19
|
import { WorkspaceOrchestrator, type OrchestratorEvent } from './workspace/orchestrator';
|
|
20
|
-
import { loadWorkspace, saveWorkspace } from './workspace/persistence';
|
|
20
|
+
import { loadWorkspace, saveWorkspace, findWorkspaceByProject } from './workspace/persistence';
|
|
21
21
|
import { installForgeSkills, applyProfileToProject } from './workspace/skill-installer';
|
|
22
22
|
import {
|
|
23
23
|
loadMemory, formatMemoryForDisplay, getMemoryStats,
|
|
@@ -30,7 +30,7 @@ import { execSync } from 'node:child_process';
|
|
|
30
30
|
|
|
31
31
|
const PORT = Number(process.env.WORKSPACE_PORT) || 8405;
|
|
32
32
|
const FORGE_PORT = Number(process.env.PORT) || 8403;
|
|
33
|
-
const MAX_ACTIVE =
|
|
33
|
+
const MAX_ACTIVE = 5;
|
|
34
34
|
|
|
35
35
|
// ─── State ───────────────────────────────────────────────
|
|
36
36
|
|
|
@@ -48,14 +48,6 @@ function loadOrchestrator(id: string): WorkspaceOrchestrator {
|
|
|
48
48
|
const existing = orchestrators.get(id);
|
|
49
49
|
if (existing) return existing;
|
|
50
50
|
|
|
51
|
-
// Enforce max active limit
|
|
52
|
-
if (orchestrators.size >= MAX_ACTIVE) {
|
|
53
|
-
const evicted = evictIdleWorkspace();
|
|
54
|
-
if (!evicted) {
|
|
55
|
-
throw new Error(`Maximum ${MAX_ACTIVE} active workspaces. Stop agents in another workspace first.`);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
51
|
const state = loadWorkspace(id);
|
|
60
52
|
if (!state) throw new Error('Workspace not found');
|
|
61
53
|
|
|
@@ -285,12 +277,13 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
|
|
|
285
277
|
return json(res, { ok: true, ...launchInfo });
|
|
286
278
|
}
|
|
287
279
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
280
|
+
// Primary agent: always return its fixed session, no selection
|
|
281
|
+
if (agentConfig.primary && agentState.tmuxSession) {
|
|
282
|
+
return json(res, { ok: true, primary: true, tmuxSession: agentState.tmuxSession, fixedSession: true, ...launchInfo });
|
|
283
|
+
}
|
|
291
284
|
|
|
292
|
-
if (agentState.
|
|
293
|
-
return json(res, { ok: true,
|
|
285
|
+
if (agentState.tmuxSession) {
|
|
286
|
+
return json(res, { ok: true, alreadyOpen: true, tmuxSession: agentState.tmuxSession, ...launchInfo });
|
|
294
287
|
}
|
|
295
288
|
|
|
296
289
|
orch.setManualMode(agentId);
|
|
@@ -299,7 +292,8 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
|
|
|
299
292
|
|
|
300
293
|
return json(res, {
|
|
301
294
|
ok: true,
|
|
302
|
-
|
|
295
|
+
primary: agentConfig.primary || undefined,
|
|
296
|
+
fixedSession: agentConfig.primary || undefined,
|
|
303
297
|
skillsInstalled: result.installed,
|
|
304
298
|
agentId,
|
|
305
299
|
label: agentConfig.label,
|
|
@@ -308,7 +302,12 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
|
|
|
308
302
|
}
|
|
309
303
|
case 'close_terminal': {
|
|
310
304
|
if (!agentId) return jsonError(res, 'agentId required');
|
|
311
|
-
|
|
305
|
+
if (body.kill) {
|
|
306
|
+
// Kill: clear tmuxSession → message loop falls back to headless (claude -p)
|
|
307
|
+
orch.clearTmuxSession(agentId);
|
|
308
|
+
console.log(`[workspace] ${agentId}: terminal killed, falling back to headless`);
|
|
309
|
+
}
|
|
310
|
+
// Suspend: tmuxSession stays, agent can reattach later
|
|
312
311
|
return json(res, { ok: true });
|
|
313
312
|
}
|
|
314
313
|
case 'create_ticket': {
|
|
@@ -380,12 +379,20 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
|
|
|
380
379
|
return json(res, { ok: true });
|
|
381
380
|
}
|
|
382
381
|
case 'delete_message': {
|
|
383
|
-
const { messageId } = body;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
382
|
+
const { messageId, messageIds } = body;
|
|
383
|
+
const ids: string[] = messageIds || (messageId ? [messageId] : []);
|
|
384
|
+
if (ids.length === 0) return jsonError(res, 'messageId or messageIds required');
|
|
385
|
+
for (const id of ids) orch.getBus().deleteMessage(id);
|
|
386
|
+
// Push updated bus log to frontend
|
|
387
|
+
orch.emit('event', { type: 'bus_log_updated', log: orch.getBus().getLog() } as any);
|
|
388
|
+
return json(res, { ok: true, deleted: ids.length });
|
|
387
389
|
}
|
|
388
390
|
case 'start_daemon': {
|
|
391
|
+
// Check active daemon count before starting
|
|
392
|
+
const activeCount = Array.from(orchestrators.values()).filter(o => o.isDaemonActive()).length;
|
|
393
|
+
if (activeCount >= MAX_ACTIVE && !orch.isDaemonActive()) {
|
|
394
|
+
return jsonError(res, `Maximum ${MAX_ACTIVE} active daemons. Stop agents in another workspace first.`);
|
|
395
|
+
}
|
|
389
396
|
orch.startDaemon().catch(err => {
|
|
390
397
|
console.error('[workspace] startDaemon error:', err.message);
|
|
391
398
|
});
|
|
@@ -637,9 +644,12 @@ async function handleSmith(id: string, body: any, res: ServerResponse): Promise<
|
|
|
637
644
|
|
|
638
645
|
case 'sessions': {
|
|
639
646
|
// List recent claude sessions for resume picker
|
|
640
|
-
// Uses the
|
|
647
|
+
// Uses the agent's workDir (or project root) to find sessions
|
|
641
648
|
try {
|
|
642
|
-
const
|
|
649
|
+
const agentConfig = agentId ? orch.getSnapshot().agents.find(a => a.id === agentId) : null;
|
|
650
|
+
const agentWorkDir = agentConfig?.workDir && agentConfig.workDir !== './' && agentConfig.workDir !== '.'
|
|
651
|
+
? join(orch.projectPath, agentConfig.workDir) : orch.projectPath;
|
|
652
|
+
const encoded = resolve(agentWorkDir).replace(/\//g, '-');
|
|
643
653
|
const sessDir = join(homedir(), '.claude', 'projects', encoded);
|
|
644
654
|
const entries = readdirSync(sessDir);
|
|
645
655
|
const files = entries
|
|
@@ -661,15 +671,33 @@ async function handleSmith(id: string, body: any, res: ServerResponse): Promise<
|
|
|
661
671
|
const snapshot = orch.getSnapshot();
|
|
662
672
|
const states = orch.getAllAgentStates();
|
|
663
673
|
const agents = snapshot.agents.map(a => ({
|
|
664
|
-
id: a.id, label: a.label, icon: a.icon, type: a.type,
|
|
674
|
+
id: a.id, label: a.label, icon: a.icon, type: a.type, primary: a.primary || undefined,
|
|
665
675
|
smithStatus: states[a.id]?.smithStatus || 'down',
|
|
666
|
-
mode: states[a.id]?.mode || 'auto',
|
|
667
676
|
taskStatus: states[a.id]?.taskStatus || 'idle',
|
|
677
|
+
hasTmux: !!states[a.id]?.tmuxSession,
|
|
668
678
|
currentStep: states[a.id]?.currentStep,
|
|
669
679
|
}));
|
|
670
680
|
return json(res, { agents });
|
|
671
681
|
}
|
|
672
682
|
|
|
683
|
+
case 'primary_session': {
|
|
684
|
+
// Get the primary agent's tmux session + project-level fixed session
|
|
685
|
+
const primary = orch.getPrimaryAgent();
|
|
686
|
+
if (!primary) return json(res, { ok: false, error: 'No primary agent configured' });
|
|
687
|
+
let fixedSessionId: string | null = null;
|
|
688
|
+
try {
|
|
689
|
+
const { getFixedSession } = await import('./project-sessions.js');
|
|
690
|
+
fixedSessionId = getFixedSession(orch.projectPath) || null;
|
|
691
|
+
} catch {}
|
|
692
|
+
return json(res, {
|
|
693
|
+
ok: true,
|
|
694
|
+
agentId: primary.config.id,
|
|
695
|
+
label: primary.config.label,
|
|
696
|
+
tmuxSession: primary.state.tmuxSession || null,
|
|
697
|
+
fixedSessionId,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
673
701
|
default:
|
|
674
702
|
return jsonError(res, `Unknown action: ${action}`);
|
|
675
703
|
}
|
|
@@ -716,6 +744,28 @@ const server = createServer(async (req, res) => {
|
|
|
716
744
|
});
|
|
717
745
|
}
|
|
718
746
|
|
|
747
|
+
// Resolve projectPath → workspaceId + agentId (walks up directories)
|
|
748
|
+
if (path === '/resolve' && method === 'GET') {
|
|
749
|
+
const projectPath = query.get('projectPath') || '';
|
|
750
|
+
if (!projectPath) return jsonError(res, 'projectPath required');
|
|
751
|
+
// Walk up directories to find workspace
|
|
752
|
+
let dir = projectPath;
|
|
753
|
+
while (dir && dir !== '/') {
|
|
754
|
+
const ws = findWorkspaceByProject(dir);
|
|
755
|
+
if (ws) {
|
|
756
|
+
const primary = ws.agents?.find((a: any) => a.primary);
|
|
757
|
+
return json(res, {
|
|
758
|
+
workspaceId: ws.id,
|
|
759
|
+
projectPath: ws.projectPath,
|
|
760
|
+
projectName: ws.projectName,
|
|
761
|
+
primaryAgentId: primary?.id || null,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
dir = dir.replace(/\/[^/]+$/, '') || '/';
|
|
765
|
+
}
|
|
766
|
+
return json(res, { workspaceId: null });
|
|
767
|
+
}
|
|
768
|
+
|
|
719
769
|
// Active workspaces
|
|
720
770
|
if (path === '/workspaces/active' && method === 'GET') {
|
|
721
771
|
return json(res, {
|
|
@@ -809,6 +859,12 @@ process.on('unhandledRejection', (err) => {
|
|
|
809
859
|
|
|
810
860
|
// ─── Start ───────────────────────────────────────────────
|
|
811
861
|
|
|
862
|
+
// Start MCP Server alongside workspace daemon
|
|
863
|
+
import { startMcpServer, setOrchestratorResolver, getMcpPort } from './forge-mcp-server.js';
|
|
864
|
+
setOrchestratorResolver((id: string) => loadOrchestrator(id));
|
|
865
|
+
const MCP_PORT = getMcpPort();
|
|
866
|
+
startMcpServer(MCP_PORT).catch(err => console.error('[forge-mcp] Failed to start:', err));
|
|
867
|
+
|
|
812
868
|
server.listen(PORT, () => {
|
|
813
|
-
console.log(`[workspace] Daemon started on http://0.0.0.0:${PORT} (max ${MAX_ACTIVE} workspaces)`);
|
|
869
|
+
console.log(`[workspace] Daemon started on http://0.0.0.0:${PORT} (max ${MAX_ACTIVE} workspaces, MCP on ${MCP_PORT})`);
|
|
814
870
|
});
|
package/middleware.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
2
|
|
|
3
3
|
export function middleware(req: NextRequest) {
|
|
4
|
+
// Skip auth entirely in dev mode
|
|
5
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
6
|
+
if (isDev) {
|
|
7
|
+
return NextResponse.next();
|
|
8
|
+
}
|
|
9
|
+
|
|
4
10
|
const { pathname } = req.nextUrl;
|
|
5
11
|
|
|
6
12
|
// Allow auth endpoints and static assets without login
|
package/next.config.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import type { NextConfig } from 'next';
|
|
2
|
+
import { networkInterfaces } from 'node:os';
|
|
2
3
|
|
|
3
4
|
const terminalPort = parseInt(process.env.TERMINAL_PORT || '') || 3001;
|
|
4
5
|
|
|
6
|
+
// Auto-detect local IPs for dev mode cross-origin access
|
|
7
|
+
const localIPs = Object.values(networkInterfaces())
|
|
8
|
+
.flat()
|
|
9
|
+
.filter(i => i && !i.internal && i.family === 'IPv4')
|
|
10
|
+
.map(i => i!.address);
|
|
11
|
+
|
|
5
12
|
const nextConfig: NextConfig = {
|
|
6
13
|
serverExternalPackages: ['better-sqlite3'],
|
|
14
|
+
allowedDevOrigins: localIPs,
|
|
7
15
|
async rewrites() {
|
|
8
16
|
return [
|
|
9
17
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aion0/forge",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.8",
|
|
4
4
|
"description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"@ai-sdk/google": "^3.0.43",
|
|
33
33
|
"@ai-sdk/openai": "^3.0.41",
|
|
34
34
|
"@auth/core": "^0.34.3",
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
35
36
|
"@xterm/addon-fit": "^0.11.0",
|
|
36
37
|
"@xterm/addon-search": "^0.16.0",
|
|
37
38
|
"@xterm/addon-unicode11": "^0.9.0",
|
|
@@ -48,7 +49,8 @@
|
|
|
48
49
|
"react-markdown": "^10.1.0",
|
|
49
50
|
"remark-gfm": "^4.0.1",
|
|
50
51
|
"ws": "^8.19.0",
|
|
51
|
-
"yaml": "^2.8.2"
|
|
52
|
+
"yaml": "^2.8.2",
|
|
53
|
+
"zod": "^4.3.6"
|
|
52
54
|
},
|
|
53
55
|
"pnpm": {
|
|
54
56
|
"onlyBuiltDependencies": [
|