@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.
@@ -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' :
@@ -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 for manual terminal reattach
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; mode: AgentMode }
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 = 2;
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
- if (agentState.taskStatus === 'running') return jsonError(res, 'Cannot open terminal while agent is running. Wait for it to finish.');
289
- const hasPending = orch.getBus().getPendingMessagesFor(agentId).length > 0;
290
- if (hasPending) return jsonError(res, 'Agent has pending messages being processed. Wait for execution to complete.');
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.mode === 'manual') {
293
- return json(res, { ok: true, mode: 'manual', alreadyManual: true, ...launchInfo });
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
- mode: 'manual',
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
- orch.restartAgentDaemon(agentId);
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
- if (!messageId) return jsonError(res, 'messageId required');
385
- orch.getBus().deleteMessage(messageId);
386
- return json(res, { ok: true });
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 workspace's projectPath to find sessions in ~/.claude/projects/
647
+ // Uses the agent's workDir (or project root) to find sessions
641
648
  try {
642
- const encoded = orch.projectPath.replace(/\//g, '-');
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.6",
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": [