@aion0/forge 0.5.7 → 0.5.9

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
  }
@@ -79,16 +87,12 @@ export type SmithStatus = 'down' | 'active';
79
87
  /** Task layer: current work execution */
80
88
  export type TaskStatus = 'idle' | 'running' | 'done' | 'failed';
81
89
 
82
- /** Agent execution mode */
83
- export type AgentMode = 'auto' | 'manual';
84
-
85
90
  /** @deprecated Use SmithStatus + TaskStatus instead */
86
91
  export type AgentStatus = SmithStatus | TaskStatus | 'paused' | 'waiting_approval' | 'listening' | 'interrupted';
87
92
 
88
93
  export interface AgentState {
89
94
  // ─── Smith layer (daemon lifecycle) ─────
90
95
  smithStatus: SmithStatus; // down=not started, active=listening on bus
91
- mode: AgentMode; // auto=respond to messages, manual=user in terminal
92
96
 
93
97
  // ─── Task layer (current work) ──────────
94
98
  taskStatus: TaskStatus; // idle/running/done/failed
@@ -101,7 +105,7 @@ export interface AgentState {
101
105
  lastCheckpoint?: number;
102
106
  cliSessionId?: string;
103
107
  currentMessageId?: string; // bus message that triggered current/last task execution
104
- tmuxSession?: string; // tmux session name for manual terminal reattach
108
+ tmuxSession?: string; // tmux session name (persistent or user-opened terminal)
105
109
  startedAt?: number;
106
110
  completedAt?: number;
107
111
  error?: string;
@@ -224,7 +228,7 @@ export interface AgentBackend {
224
228
  // ─── Worker Events ───────────────────────────────────────
225
229
 
226
230
  export type WorkerEvent =
227
- | { type: 'smith_status'; agentId: string; smithStatus: SmithStatus; mode: AgentMode }
231
+ | { type: 'smith_status'; agentId: string; smithStatus: SmithStatus }
228
232
  | { type: 'task_status'; agentId: string; taskStatus: TaskStatus; error?: string }
229
233
  | { type: 'log'; agentId: string; entry: TaskLogEntry }
230
234
  | { type: 'step'; agentId: string; stepIndex: number; stepLabel: string }
@@ -206,8 +206,10 @@ function detectAgentLogChanges(workspaceId: string, targetAgentId: string, patte
206
206
  }
207
207
  }
208
208
 
209
+ // Track which session file was used last (to detect file switch)
210
+ const lastSessionFile = new Map<string, string>();
211
+
209
212
  function detectSessionChanges(projectPath: string, pattern: string | undefined, prevLineCount: number, contextChars = 500, sessionId?: string): { changes: WatchChange | null; lineCount: number } {
210
- // Find session file for this project
211
213
  const claudeHome = join(homedir(), '.claude', 'projects');
212
214
  const encoded = projectPath.replace(/\//g, '-');
213
215
  const sessionDir = join(claudeHome, encoded);
@@ -217,11 +219,9 @@ function detectSessionChanges(projectPath: string, pattern: string | undefined,
217
219
  let latestFile: string;
218
220
 
219
221
  if (sessionId) {
220
- // Use specific session ID
221
222
  latestFile = join(sessionDir, `${sessionId}.jsonl`);
222
223
  if (!existsSync(latestFile)) return { changes: null, lineCount: prevLineCount };
223
224
  } else {
224
- // Find most recently modified .jsonl file
225
225
  const files = readdirSync(sessionDir)
226
226
  .filter(f => f.endsWith('.jsonl'))
227
227
  .map(f => ({ name: f, mtime: statSync(join(sessionDir, f)).mtimeMs }))
@@ -229,7 +229,17 @@ function detectSessionChanges(projectPath: string, pattern: string | undefined,
229
229
  if (files.length === 0) return { changes: null, lineCount: prevLineCount };
230
230
  latestFile = join(sessionDir, files[0].name);
231
231
  }
232
- // Only read new bytes since last check (efficient for large 70MB+ files)
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)
233
243
  const fd = openSync(latestFile, 'r');
234
244
  const fileSize = fstatSync(fd).size;
235
245
  if (fileSize <= prevLineCount) { closeSync(fd); return { changes: null, lineCount: fileSize }; }
@@ -256,17 +266,16 @@ function detectSessionChanges(projectPath: string, pattern: string | undefined,
256
266
  }
257
267
  }
258
268
 
269
+ // Only extract the LAST assistant/result text (not all entries)
259
270
  const entries: string[] = [];
260
- for (const line of newLines) {
271
+ for (const line of [...newLines].reverse()) {
261
272
  try {
262
273
  const parsed = JSON.parse(line);
263
- // Extract text content from various session JSONL formats
264
274
  let text = '';
265
275
  if (parsed.type === 'assistant' && parsed.message?.content) {
266
276
  for (const block of (Array.isArray(parsed.message.content) ? parsed.message.content : [parsed.message.content])) {
267
277
  if (typeof block === 'string') text += block;
268
278
  else if (block.type === 'text' && block.text) text += block.text;
269
- else if (block.type === 'tool_use') text += `[tool: ${block.name}] `;
270
279
  }
271
280
  } else if (parsed.type === 'result' && parsed.result) {
272
281
  text = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
@@ -290,6 +299,7 @@ function detectSessionChanges(projectPath: string, pattern: string | undefined,
290
299
  text = text.slice(0, contextChars);
291
300
  }
292
301
  entries.push(text);
302
+ break; // only take the last matching entry (we're scanning in reverse)
293
303
  } catch {}
294
304
  }
295
305
 
@@ -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/next-env.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/dev/types/routes.d.ts";
3
+ import "./.next/types/routes.d.ts";
4
4
 
5
5
  // NOTE: This file should not be edited
6
6
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
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": [
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "forge": {
4
+ "type": "sse",
5
+ "url": "http://localhost:8406/sse?workspaceId=656c9e65-9d73-4cb6-a065-60d966e1fc78&agentId=qa-1774920510930"
6
+ }
7
+ }
8
+ }