@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.
- package/.forge/mcp.json +8 -0
- package/RELEASE_NOTES.md +10 -6
- 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/WebTerminal.tsx +40 -25
- package/components/WorkspaceView.tsx +599 -109
- 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 +774 -90
- package/lib/workspace/persistence.ts +0 -1
- package/lib/workspace/types.ts +10 -6
- package/lib/workspace/watch-manager.ts +17 -7
- package/lib/workspace-standalone.ts +83 -27
- package/next-env.d.ts +1 -1
- package/package.json +4 -2
- package/qa/.forge/mcp.json +8 -0
package/lib/claude-sessions.ts
CHANGED
|
@@ -53,36 +53,11 @@ export function listClaudeSessions(projectName: string): ClaudeSessionInfo[] {
|
|
|
53
53
|
const dir = getClaudeDirForProject(projectName);
|
|
54
54
|
if (!dir) return [];
|
|
55
55
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
if (existsSync(indexPath)) {
|
|
59
|
-
try {
|
|
60
|
-
const index = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
61
|
-
const sessions: ClaudeSessionInfo[] = (index.entries || []).map((e: any) => ({
|
|
62
|
-
sessionId: e.sessionId,
|
|
63
|
-
summary: e.summary,
|
|
64
|
-
firstPrompt: e.firstPrompt,
|
|
65
|
-
messageCount: e.messageCount,
|
|
66
|
-
created: e.created,
|
|
67
|
-
modified: e.modified,
|
|
68
|
-
gitBranch: e.gitBranch,
|
|
69
|
-
fileSize: 0,
|
|
70
|
-
}));
|
|
71
|
-
|
|
72
|
-
// Enrich with file size
|
|
73
|
-
for (const s of sessions) {
|
|
74
|
-
const fp = join(dir, `${s.sessionId}.jsonl`);
|
|
75
|
-
try { s.fileSize = statSync(fp).size; } catch {}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return sessions.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''));
|
|
79
|
-
} catch {}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Fallback: scan for .jsonl files
|
|
56
|
+
// Scan .jsonl files on disk (authoritative source)
|
|
57
|
+
let diskSessions: ClaudeSessionInfo[] = [];
|
|
83
58
|
try {
|
|
84
59
|
const files = readdirSync(dir).filter(f => f.endsWith('.jsonl'));
|
|
85
|
-
|
|
60
|
+
diskSessions = files.map(f => {
|
|
86
61
|
const sessionId = f.replace('.jsonl', '');
|
|
87
62
|
const fp = join(dir, f);
|
|
88
63
|
const stat = statSync(fp);
|
|
@@ -96,6 +71,29 @@ export function listClaudeSessions(projectName: string): ClaudeSessionInfo[] {
|
|
|
96
71
|
} catch {
|
|
97
72
|
return [];
|
|
98
73
|
}
|
|
74
|
+
|
|
75
|
+
// Enrich with metadata from sessions-index.json if available
|
|
76
|
+
const indexPath = join(dir, 'sessions-index.json');
|
|
77
|
+
if (existsSync(indexPath)) {
|
|
78
|
+
try {
|
|
79
|
+
const index = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
80
|
+
const indexMap = new Map<string, any>();
|
|
81
|
+
for (const e of (index.entries || [])) {
|
|
82
|
+
if (e.sessionId) indexMap.set(e.sessionId, e);
|
|
83
|
+
}
|
|
84
|
+
for (const s of diskSessions) {
|
|
85
|
+
const meta = indexMap.get(s.sessionId);
|
|
86
|
+
if (meta) {
|
|
87
|
+
s.summary = meta.summary;
|
|
88
|
+
s.firstPrompt = meta.firstPrompt;
|
|
89
|
+
s.messageCount = meta.messageCount;
|
|
90
|
+
s.gitBranch = meta.gitBranch;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return diskSessions;
|
|
99
97
|
}
|
|
100
98
|
|
|
101
99
|
/**
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge MCP Server — agent communication bus via Model Context Protocol.
|
|
3
|
+
*
|
|
4
|
+
* Each Claude Code session connects with context baked into the SSE URL:
|
|
5
|
+
* http://localhost:8406/sse?workspaceId=xxx&agentId=yyy
|
|
6
|
+
*
|
|
7
|
+
* The agent doesn't need to know IDs. It just calls:
|
|
8
|
+
* send_message(to: "Reviewer", content: "fixed the bug")
|
|
9
|
+
* get_inbox()
|
|
10
|
+
* get_status()
|
|
11
|
+
*
|
|
12
|
+
* Forge resolves everything from the connection context.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
16
|
+
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
19
|
+
|
|
20
|
+
// Lazy imports to avoid circular deps (workspace modules)
|
|
21
|
+
let _getOrchestrator: ((workspaceId: string) => any) | null = null;
|
|
22
|
+
|
|
23
|
+
export function setOrchestratorResolver(fn: (id: string) => any): void {
|
|
24
|
+
_getOrchestrator = fn;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getOrch(workspaceId: string): any {
|
|
28
|
+
if (!_getOrchestrator) throw new Error('Orchestrator resolver not set');
|
|
29
|
+
return _getOrchestrator(workspaceId);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Per-session context (resolved from SSE URL + orchestrator topo)
|
|
33
|
+
interface SessionContext {
|
|
34
|
+
workspaceId: string;
|
|
35
|
+
agentId: string; // resolved dynamically, may be empty for non-agent terminals
|
|
36
|
+
}
|
|
37
|
+
const sessionContexts = new Map<string, SessionContext>();
|
|
38
|
+
|
|
39
|
+
/** Resolve agentId from orchestrator's agent-tmux mapping */
|
|
40
|
+
function resolveAgentFromOrch(workspaceId: string): string {
|
|
41
|
+
// For now, default to primary agent. Future: resolve from tmux session → agent map
|
|
42
|
+
try {
|
|
43
|
+
const orch = getOrch(workspaceId);
|
|
44
|
+
const primary = orch.getPrimaryAgent();
|
|
45
|
+
return primary?.config?.id || '';
|
|
46
|
+
} catch { return ''; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── MCP Server Definition ──────────────────────────────
|
|
50
|
+
|
|
51
|
+
function createForgeMcpServer(sessionId: string): McpServer {
|
|
52
|
+
const server = new McpServer({
|
|
53
|
+
name: 'forge',
|
|
54
|
+
version: '1.0.0',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Helper: get context for this session
|
|
58
|
+
const ctx = () => sessionContexts.get(sessionId) || { workspaceId: '', agentId: '' };
|
|
59
|
+
|
|
60
|
+
// ── send_message ──────────────────────────
|
|
61
|
+
server.tool(
|
|
62
|
+
'send_message',
|
|
63
|
+
'Send a message to another agent in the workspace',
|
|
64
|
+
{
|
|
65
|
+
to: z.string().describe('Target agent — name like "Reviewer", or description like "the one who does testing"'),
|
|
66
|
+
content: z.string().describe('Message content'),
|
|
67
|
+
action: z.string().optional().describe('Message type: fix_request, update_notify, question, review, info_request'),
|
|
68
|
+
},
|
|
69
|
+
async (params) => {
|
|
70
|
+
const { to, content, action = 'update_notify' } = params;
|
|
71
|
+
const { workspaceId, agentId } = ctx();
|
|
72
|
+
if (!workspaceId) return { content: [{ type: 'text', text: 'Error: No workspace context.' }] };
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const orch = getOrch(workspaceId);
|
|
76
|
+
const snapshot = orch.getSnapshot();
|
|
77
|
+
const candidates = snapshot.agents.filter((a: any) => a.type !== 'input' && a.id !== agentId);
|
|
78
|
+
|
|
79
|
+
// Match: exact label > label contains > role contains
|
|
80
|
+
const toLower = to.toLowerCase();
|
|
81
|
+
let target = candidates.find((a: any) => a.label.toLowerCase() === toLower);
|
|
82
|
+
if (!target) target = candidates.find((a: any) => a.label.toLowerCase().includes(toLower));
|
|
83
|
+
if (!target) target = candidates.find((a: any) => (a.role || '').toLowerCase().includes(toLower));
|
|
84
|
+
|
|
85
|
+
if (!target) {
|
|
86
|
+
const available = candidates.map((a: any) => `${a.label} (${(a.role || '').slice(0, 50)})`).join(', ');
|
|
87
|
+
return { content: [{ type: 'text', text: `No agent matches "${to}". Available: ${available}` }] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
orch.getBus().send(agentId, target.id, 'notify', { action, content });
|
|
91
|
+
return { content: [{ type: 'text', text: `Message sent to ${target.label}` }] };
|
|
92
|
+
} catch (err: any) {
|
|
93
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// ── get_inbox ─────────────────────────────
|
|
99
|
+
server.tool(
|
|
100
|
+
'get_inbox',
|
|
101
|
+
'Check inbox messages from other agents',
|
|
102
|
+
{},
|
|
103
|
+
async () => {
|
|
104
|
+
const { workspaceId, agentId } = ctx();
|
|
105
|
+
if (!workspaceId) return { content: [{ type: 'text', text: 'Error: No workspace context' }] };
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const orch = getOrch(workspaceId);
|
|
109
|
+
const messages = orch.getBus().getLog()
|
|
110
|
+
.filter((m: any) => m.to === agentId && m.type !== 'ack')
|
|
111
|
+
.slice(-20);
|
|
112
|
+
|
|
113
|
+
if (messages.length === 0) {
|
|
114
|
+
return { content: [{ type: 'text', text: 'No messages in inbox.' }] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const snapshot = orch.getSnapshot();
|
|
118
|
+
const getLabel = (id: string) => snapshot.agents.find((a: any) => a.id === id)?.label || id;
|
|
119
|
+
|
|
120
|
+
const formatted = messages.map((m: any) =>
|
|
121
|
+
`[${m.status}] From ${getLabel(m.from)}: ${m.payload?.content || m.payload?.action || '(no content)'} (${m.id.slice(0, 8)})`
|
|
122
|
+
).join('\n');
|
|
123
|
+
|
|
124
|
+
return { content: [{ type: 'text', text: formatted }] };
|
|
125
|
+
} catch (err: any) {
|
|
126
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// ── mark_message_done ─────────────────────
|
|
132
|
+
server.tool(
|
|
133
|
+
'mark_message_done',
|
|
134
|
+
'Mark an inbox message as done after handling it',
|
|
135
|
+
{
|
|
136
|
+
message_id: z.string().describe('Message ID (first 8 chars or full UUID)'),
|
|
137
|
+
},
|
|
138
|
+
async (params) => {
|
|
139
|
+
const { workspaceId, agentId } = ctx();
|
|
140
|
+
if (!workspaceId) return { content: [{ type: 'text', text: 'Error: No workspace context' }] };
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const orch = getOrch(workspaceId);
|
|
144
|
+
const msg = orch.getBus().getLog().find((m: any) =>
|
|
145
|
+
(m.id === params.message_id || m.id.startsWith(params.message_id)) && m.to === agentId
|
|
146
|
+
);
|
|
147
|
+
if (!msg) return { content: [{ type: 'text', text: 'Message not found' }] };
|
|
148
|
+
|
|
149
|
+
msg.status = 'done';
|
|
150
|
+
return { content: [{ type: 'text', text: `Message ${params.message_id} marked as done` }] };
|
|
151
|
+
} catch (err: any) {
|
|
152
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// ── get_status ────────────────────────────
|
|
158
|
+
server.tool(
|
|
159
|
+
'get_status',
|
|
160
|
+
'Get status of all agents in the workspace',
|
|
161
|
+
{},
|
|
162
|
+
async () => {
|
|
163
|
+
const { workspaceId } = ctx();
|
|
164
|
+
if (!workspaceId) return { content: [{ type: 'text', text: 'Error: No workspace context' }] };
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const orch = getOrch(workspaceId);
|
|
168
|
+
const snapshot = orch.getSnapshot();
|
|
169
|
+
const states = orch.getAllAgentStates();
|
|
170
|
+
|
|
171
|
+
const lines = snapshot.agents
|
|
172
|
+
.filter((a: any) => a.type !== 'input')
|
|
173
|
+
.map((a: any) => {
|
|
174
|
+
const s = states[a.id];
|
|
175
|
+
const smith = s?.smithStatus || 'down';
|
|
176
|
+
const task = s?.taskStatus || 'idle';
|
|
177
|
+
const icon = smith === 'active' ? (task === 'running' ? '🔵' : task === 'done' ? '✅' : task === 'failed' ? '🔴' : '🟢') : '⬚';
|
|
178
|
+
return `${icon} ${a.label}: smith=${smith} task=${task}${s?.error ? ` error=${s.error}` : ''}`;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return { content: [{ type: 'text', text: lines.join('\n') || 'No agents configured.' }] };
|
|
182
|
+
} catch (err: any) {
|
|
183
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// ── get_agents ────────────────────────────
|
|
189
|
+
server.tool(
|
|
190
|
+
'get_agents',
|
|
191
|
+
'Get all agents in the workspace with their roles and relationships. Use this to understand who does what before sending messages.',
|
|
192
|
+
{},
|
|
193
|
+
async () => {
|
|
194
|
+
const { workspaceId, agentId } = ctx();
|
|
195
|
+
if (!workspaceId) return { content: [{ type: 'text', text: 'Error: No workspace context' }] };
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const orch = getOrch(workspaceId);
|
|
199
|
+
const snapshot = orch.getSnapshot();
|
|
200
|
+
|
|
201
|
+
const agents = snapshot.agents
|
|
202
|
+
.filter((a: any) => a.type !== 'input')
|
|
203
|
+
.map((a: any) => {
|
|
204
|
+
const deps = a.dependsOn
|
|
205
|
+
.map((depId: string) => snapshot.agents.find((d: any) => d.id === depId)?.label || depId)
|
|
206
|
+
.join(', ');
|
|
207
|
+
const isMe = a.id === agentId;
|
|
208
|
+
return [
|
|
209
|
+
`${a.icon} ${a.label}${isMe ? ' (you)' : ''}${a.primary ? ' [PRIMARY]' : ''}`,
|
|
210
|
+
` Role: ${a.role || '(no role defined)'}`,
|
|
211
|
+
deps ? ` Depends on: ${deps}` : null,
|
|
212
|
+
a.workDir && a.workDir !== './' ? ` Work dir: ${a.workDir}` : null,
|
|
213
|
+
].filter(Boolean).join('\n');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return { content: [{ type: 'text', text: agents.join('\n\n') || 'No agents configured.' }] };
|
|
217
|
+
} catch (err: any) {
|
|
218
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// ── sync_progress ─────────────────────────
|
|
224
|
+
server.tool(
|
|
225
|
+
'sync_progress',
|
|
226
|
+
'Report your work progress to the workspace (what you did, files changed)',
|
|
227
|
+
{
|
|
228
|
+
summary: z.string().describe('Brief summary of what you accomplished'),
|
|
229
|
+
files: z.array(z.string()).optional().describe('List of files changed'),
|
|
230
|
+
},
|
|
231
|
+
async (params) => {
|
|
232
|
+
const { workspaceId, agentId } = ctx();
|
|
233
|
+
if (!workspaceId) return { content: [{ type: 'text', text: 'Error: No workspace context' }] };
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const orch = getOrch(workspaceId);
|
|
237
|
+
const entry = orch.getSnapshot().agents.find((a: any) => a.id === agentId);
|
|
238
|
+
if (!entry) return { content: [{ type: 'text', text: 'Agent not found in workspace' }] };
|
|
239
|
+
|
|
240
|
+
orch.completeManualAgent(agentId, params.files || []);
|
|
241
|
+
|
|
242
|
+
return { content: [{ type: 'text', text: `Progress synced: ${params.summary}` }] };
|
|
243
|
+
} catch (err: any) {
|
|
244
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// ── check_outbox ──────────────────────────
|
|
250
|
+
server.tool(
|
|
251
|
+
'check_outbox',
|
|
252
|
+
'Check status of messages you sent to other agents. See if they replied or completed.',
|
|
253
|
+
{},
|
|
254
|
+
async () => {
|
|
255
|
+
const { workspaceId, agentId } = ctx();
|
|
256
|
+
if (!workspaceId) return { content: [{ type: 'text', text: 'Error: No workspace context' }] };
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const orch = getOrch(workspaceId);
|
|
260
|
+
const snapshot = orch.getSnapshot();
|
|
261
|
+
const getLabel = (id: string) => snapshot.agents.find((a: any) => a.id === id)?.label || id;
|
|
262
|
+
|
|
263
|
+
// Messages sent BY this agent
|
|
264
|
+
const sent = orch.getBus().getLog()
|
|
265
|
+
.filter((m: any) => m.from === agentId && m.type !== 'ack')
|
|
266
|
+
.slice(-20);
|
|
267
|
+
|
|
268
|
+
if (sent.length === 0) {
|
|
269
|
+
return { content: [{ type: 'text', text: 'No messages sent.' }] };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Check for replies
|
|
273
|
+
const formatted = sent.map((m: any) => {
|
|
274
|
+
const targetLabel = getLabel(m.to);
|
|
275
|
+
const replies = orch.getBus().getLog().filter((r: any) =>
|
|
276
|
+
r.from === m.to && r.to === agentId && r.timestamp > m.timestamp && r.type !== 'ack'
|
|
277
|
+
);
|
|
278
|
+
const replyInfo = replies.length > 0
|
|
279
|
+
? `replied: ${replies[replies.length - 1].payload?.content?.slice(0, 100) || '(no content)'}`
|
|
280
|
+
: 'no reply yet';
|
|
281
|
+
return `→ ${targetLabel}: [${m.status}] ${(m.payload?.content || '').slice(0, 60)} | ${replyInfo}`;
|
|
282
|
+
}).join('\n');
|
|
283
|
+
|
|
284
|
+
return { content: [{ type: 'text', text: formatted }] };
|
|
285
|
+
} catch (err: any) {
|
|
286
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
return server;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── HTTP Server with SSE Transport ─────────────────────
|
|
295
|
+
|
|
296
|
+
let mcpHttpServer: ReturnType<typeof createServer> | null = null;
|
|
297
|
+
const transports = new Map<string, SSEServerTransport>();
|
|
298
|
+
|
|
299
|
+
export async function startMcpServer(port: number): Promise<void> {
|
|
300
|
+
mcpHttpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
301
|
+
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
302
|
+
|
|
303
|
+
// CORS
|
|
304
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
305
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
306
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
307
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
308
|
+
|
|
309
|
+
// SSE endpoint — each connection gets its own MCP server instance
|
|
310
|
+
if (url.pathname === '/sse' && req.method === 'GET') {
|
|
311
|
+
const transport = new SSEServerTransport('/message', res);
|
|
312
|
+
const sessionId = transport.sessionId;
|
|
313
|
+
transports.set(sessionId, transport);
|
|
314
|
+
|
|
315
|
+
// Extract workspace context from URL params
|
|
316
|
+
const workspaceId = url.searchParams.get('workspaceId') || '';
|
|
317
|
+
const agentId = url.searchParams.get('agentId') || (workspaceId ? resolveAgentFromOrch(workspaceId) : '');
|
|
318
|
+
sessionContexts.set(sessionId, { workspaceId, agentId });
|
|
319
|
+
|
|
320
|
+
transport.onclose = () => {
|
|
321
|
+
transports.delete(sessionId);
|
|
322
|
+
sessionContexts.delete(sessionId);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Each session gets its own MCP server with context
|
|
326
|
+
const server = createForgeMcpServer(sessionId);
|
|
327
|
+
await server.connect(transport);
|
|
328
|
+
const agentLabel = workspaceId ? (getOrch(workspaceId)?.getSnapshot()?.agents?.find((a: any) => a.id === agentId)?.label || agentId) : 'unknown';
|
|
329
|
+
console.log(`[forge-mcp] Client connected: ${agentLabel} (ws=${workspaceId.slice(0, 8)}, session=${sessionId})`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Message endpoint — route by sessionId query param
|
|
334
|
+
if (url.pathname === '/message' && req.method === 'POST') {
|
|
335
|
+
const sessionId = url.searchParams.get('sessionId');
|
|
336
|
+
if (!sessionId) {
|
|
337
|
+
res.writeHead(400);
|
|
338
|
+
res.end('Missing sessionId parameter');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const transport = transports.get(sessionId);
|
|
343
|
+
if (!transport) {
|
|
344
|
+
res.writeHead(404);
|
|
345
|
+
res.end('Session not found');
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Read body and pass to transport
|
|
350
|
+
const body: Buffer[] = [];
|
|
351
|
+
req.on('data', (chunk: Buffer) => body.push(chunk));
|
|
352
|
+
req.on('end', async () => {
|
|
353
|
+
try {
|
|
354
|
+
const parsed = JSON.parse(Buffer.concat(body).toString());
|
|
355
|
+
await transport.handlePostMessage(req, res, parsed);
|
|
356
|
+
} catch (err: any) {
|
|
357
|
+
if (!res.headersSent) { res.writeHead(400); res.end('Invalid JSON'); }
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Health check
|
|
364
|
+
if (url.pathname === '/health') {
|
|
365
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
366
|
+
res.end(JSON.stringify({ ok: true, sessions: transports.size }));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
res.writeHead(404);
|
|
371
|
+
res.end('Not found');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
mcpHttpServer.listen(port, () => {
|
|
375
|
+
console.log(`[forge-mcp] MCP Server running on http://localhost:${port}`);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function stopMcpServer(): void {
|
|
380
|
+
if (mcpHttpServer) {
|
|
381
|
+
mcpHttpServer.close();
|
|
382
|
+
mcpHttpServer = null;
|
|
383
|
+
transports.clear();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function getMcpPort(): number {
|
|
388
|
+
return Number(process.env.MCP_PORT) || 8406;
|
|
389
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: "[FALLBACK — only use if MCP get_inbox tool is NOT available] Check messages from other Forge Workspace agents via HTTP API"
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# Forge Inbox
|
|
@@ -12,26 +12,27 @@ Check for messages from other agents and manage their status.
|
|
|
12
12
|
|
|
13
13
|
## How to use
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
### Option 1: MCP Tools (preferred)
|
|
16
|
+
If MCP tools are available, use them directly:
|
|
17
|
+
- `get_inbox()` — check messages
|
|
18
|
+
- `mark_message_done(message_id: "ID")` — mark as done
|
|
19
|
+
- `check_outbox()` — check messages you sent
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
curl -s "http://localhost:8403/api/workspace?projectPath=$(pwd)" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))"
|
|
20
|
-
```
|
|
21
|
+
### Option 2: HTTP API (fallback)
|
|
21
22
|
|
|
22
|
-
Step
|
|
23
|
+
Step 1 — Get workspace ID (env var first, then API fallback):
|
|
23
24
|
```bash
|
|
24
|
-
curl -s
|
|
25
|
+
WS_ID="${FORGE_WORKSPACE_ID:-$(curl -s "http://localhost:8405/resolve?projectPath=$(pwd)" | python3 -c "import sys,json; print(json.load(sys.stdin).get('workspaceId',''))" 2>/dev/null)}"
|
|
25
26
|
```
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
Step 2 — Check inbox:
|
|
28
29
|
```bash
|
|
29
|
-
curl -s -X POST "http://localhost:8403/api/workspace/
|
|
30
|
+
curl -s -X POST "http://localhost:8403/api/workspace/$WS_ID/smith" -H "Content-Type: application/json" -d '{"action":"inbox","agentId":"'"$FORGE_AGENT_ID"'"}'
|
|
30
31
|
```
|
|
31
32
|
|
|
32
|
-
## Mark message as
|
|
33
|
+
## Mark message as done
|
|
33
34
|
```bash
|
|
34
|
-
curl -s -X POST "http://localhost:8403/api/workspace/
|
|
35
|
+
curl -s -X POST "http://localhost:8403/api/workspace/$WS_ID/smith" -H "Content-Type: application/json" -d '{"action":"message_done","agentId":"'"$FORGE_AGENT_ID"'","messageId":"MESSAGE_ID"}'
|
|
35
36
|
```
|
|
36
37
|
|
|
37
38
|
After handling a message, always mark it as done or failed.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: "[FALLBACK — only use if MCP send_message tool is NOT available] Send a message to another Forge Workspace agent via HTTP API"
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# Forge Send
|
|
@@ -17,16 +17,23 @@ Send a message to another agent in the Forge Workspace.
|
|
|
17
17
|
|
|
18
18
|
## How to send
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
### Option 1: MCP Tool (preferred)
|
|
21
|
+
If the `send_message` tool is available, use it directly:
|
|
22
|
+
```
|
|
23
|
+
send_message(to: "TARGET_LABEL", content: "YOUR MESSAGE", action: "ACTION")
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Option 2: HTTP API (fallback if MCP not available)
|
|
21
27
|
|
|
22
|
-
Step 1 — Get workspace ID:
|
|
28
|
+
Step 1 — Get workspace ID (env var first, then API fallback):
|
|
23
29
|
```bash
|
|
24
|
-
curl -s "http://localhost:
|
|
30
|
+
WS_ID="${FORGE_WORKSPACE_ID:-$(curl -s "http://localhost:8405/resolve?projectPath=$(pwd)" | python3 -c "import sys,json; print(json.load(sys.stdin).get('workspaceId',''))" 2>/dev/null)}"
|
|
31
|
+
echo "$WS_ID"
|
|
25
32
|
```
|
|
26
33
|
|
|
27
|
-
Step 2 — Send message
|
|
34
|
+
Step 2 — Send message:
|
|
28
35
|
```bash
|
|
29
|
-
curl -s -X POST "http://localhost:8403/api/workspace/
|
|
36
|
+
curl -s -X POST "http://localhost:8403/api/workspace/$WS_ID/smith" -H "Content-Type: application/json" -d '{"action":"send","agentId":"'"$FORGE_AGENT_ID"'","to":"TARGET_LABEL","msgAction":"ACTION","content":"YOUR MESSAGE"}'
|
|
30
37
|
```
|
|
31
38
|
|
|
32
39
|
Replace:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: "[FALLBACK — only use if MCP get_status tool is NOT available] Check agent statuses via HTTP API"
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# Forge Status
|
|
@@ -12,21 +12,21 @@ Check the current status of all agents in the Forge Workspace.
|
|
|
12
12
|
|
|
13
13
|
## How to check
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
### Option 1: MCP Tools (preferred)
|
|
16
|
+
If MCP tools are available:
|
|
17
|
+
- `get_status()` — all agent statuses
|
|
18
|
+
- `get_agents()` — agent details (roles, dependencies)
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
### Option 2: HTTP API (fallback)
|
|
21
|
+
|
|
22
|
+
Step 1 — Get workspace ID (env var first, then API fallback):
|
|
18
23
|
```bash
|
|
19
|
-
curl -s "http://localhost:
|
|
24
|
+
WS_ID="${FORGE_WORKSPACE_ID:-$(curl -s "http://localhost:8405/resolve?projectPath=$(pwd)" | python3 -c "import sys,json; print(json.load(sys.stdin).get('workspaceId',''))" 2>/dev/null)}"
|
|
20
25
|
```
|
|
21
26
|
|
|
22
|
-
Step 2 — Check status
|
|
27
|
+
Step 2 — Check status:
|
|
23
28
|
```bash
|
|
24
|
-
curl -s -X POST "http://localhost:8403/api/workspace/
|
|
29
|
+
curl -s -X POST "http://localhost:8403/api/workspace/$WS_ID/smith" -H "Content-Type: application/json" -d '{"action":"status","agentId":"'"$FORGE_AGENT_ID"'"}'
|
|
25
30
|
```
|
|
26
31
|
|
|
27
|
-
Present the results as a clear status overview
|
|
28
|
-
- 🟢 active — smith is online and listening
|
|
29
|
-
- 🔵 running — agent is currently executing a task
|
|
30
|
-
- ✅ done — agent completed its work
|
|
31
|
-
- 🔴 failed — agent encountered an error
|
|
32
|
-
- ⬚ down — smith is not started
|
|
32
|
+
Present the results as a clear status overview.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-level fixed session binding.
|
|
3
|
+
* Stores { projectPath: sessionId } in ~/.forge/data/project-sessions.json
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { getDataDir } from '@/src/config';
|
|
9
|
+
|
|
10
|
+
function getFilePath(): string {
|
|
11
|
+
const dir = getDataDir();
|
|
12
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
13
|
+
return join(dir, 'project-sessions.json');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function loadAll(): Record<string, string> {
|
|
17
|
+
const fp = getFilePath();
|
|
18
|
+
if (!existsSync(fp)) return {};
|
|
19
|
+
try { return JSON.parse(readFileSync(fp, 'utf-8')); } catch { return {}; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function saveAll(data: Record<string, string>): void {
|
|
23
|
+
writeFileSync(getFilePath(), JSON.stringify(data, null, 2));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Get the fixed session ID for a project */
|
|
27
|
+
export function getFixedSession(projectPath: string): string | undefined {
|
|
28
|
+
return loadAll()[projectPath] || undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Set the fixed session ID for a project */
|
|
32
|
+
export function setFixedSession(projectPath: string, sessionId: string): void {
|
|
33
|
+
const data = loadAll();
|
|
34
|
+
data[projectPath] = sessionId;
|
|
35
|
+
saveAll(data);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Clear the fixed session for a project */
|
|
39
|
+
export function clearFixedSession(projectPath: string): void {
|
|
40
|
+
const data = loadAll();
|
|
41
|
+
delete data[projectPath];
|
|
42
|
+
saveAll(data);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Get all bindings */
|
|
46
|
+
export function getAllFixedSessions(): Record<string, string> {
|
|
47
|
+
return loadAll();
|
|
48
|
+
}
|