@aion0/forge 0.6.1 → 0.8.0
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/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
- package/CLAUDE.md +2 -2
- package/RELEASE_NOTES.md +101 -5
- package/app/api/auth/check/route.ts +18 -0
- package/app/api/browser-bridge/route.ts +70 -0
- package/app/api/chat/sessions/[id]/events/route.ts +17 -0
- package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
- package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
- package/app/api/chat/sessions/[id]/route.ts +23 -0
- package/app/api/chat/sessions/route.ts +12 -0
- package/app/api/chat/temper-ping/route.ts +18 -0
- package/app/api/chat-proxy/[...path]/route.ts +83 -0
- package/app/api/connector-tool/route.ts +38 -0
- package/app/api/connectors/[id]/settings/route.ts +112 -0
- package/app/api/connectors/route.ts +108 -0
- package/app/api/health/tools/route.ts +14 -0
- package/app/api/issue-scanner-gitlab/route.ts +95 -0
- package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
- package/app/api/jobs/[id]/route.ts +31 -0
- package/app/api/jobs/[id]/run/route.ts +44 -0
- package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
- package/app/api/jobs/[id]/runs/route.ts +15 -0
- package/app/api/jobs/preview/route.ts +193 -0
- package/app/api/jobs/route.ts +36 -0
- package/app/api/notify/test/route.ts +39 -7
- package/app/api/pipelines/[id]/route.ts +10 -1
- package/app/api/pipelines/route.ts +16 -2
- package/app/api/plugins/route.ts +40 -8
- package/app/api/project-sessions/route.ts +50 -10
- package/app/api/settings/route.ts +13 -0
- package/app/chat/page.tsx +531 -0
- package/bin/forge-server.mjs +3 -1
- package/cli/chat.ts +283 -0
- package/cli/jobs.ts +176 -0
- package/cli/mw.ts +28 -1
- package/cli/worktree.ts +245 -0
- package/components/ConnectorsPanel.tsx +275 -0
- package/components/Dashboard.tsx +90 -37
- package/components/JobsView.tsx +361 -0
- package/components/LogViewer.tsx +12 -2
- package/components/PipelineView.tsx +275 -56
- package/components/PluginsPanel.tsx +3 -1
- package/components/SettingsModal.tsx +229 -40
- package/components/SkillsPanel.tsx +12 -4
- package/components/TerminalLauncher.tsx +3 -1
- package/components/WebTerminal.tsx +32 -9
- package/components/WorkspaceView.tsx +18 -10
- package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
- package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
- package/docs/Implementation-Plan-Browser-Agent.md +487 -0
- package/docs/Jobs-Design.md +240 -0
- package/docs/LOCAL-DEPLOY.md +3 -3
- package/docs/RFC-Browser-Connectors.md +509 -0
- package/lib/agents/index.ts +44 -6
- package/lib/agents/types.ts +1 -1
- package/lib/browser-bridge-standalone.ts +317 -0
- package/lib/builtin-plugins/github-api.yaml +93 -0
- package/lib/builtin-plugins/gitlab.yaml +860 -0
- package/lib/builtin-plugins/mantis.probe.js +176 -0
- package/lib/builtin-plugins/mantis.yaml +964 -0
- package/lib/builtin-plugins/pmdb.yaml +178 -0
- package/lib/builtin-plugins/teams.yaml +913 -0
- package/lib/chat/__test__/smoke.ts +30 -0
- package/lib/chat/agent-loop.ts +523 -0
- package/lib/chat/bridge-client.ts +59 -0
- package/lib/chat/llm/anthropic.ts +99 -0
- package/lib/chat/llm/index.ts +20 -0
- package/lib/chat/llm/openai.ts +215 -0
- package/lib/chat/llm/types.ts +42 -0
- package/lib/chat/local-memory.ts +300 -0
- package/lib/chat/memory-store.ts +87 -0
- package/lib/chat/memory-tools.ts +157 -0
- package/lib/chat/protocols/http.ts +118 -0
- package/lib/chat/protocols/shell.ts +101 -0
- package/lib/chat/proxy.ts +51 -0
- package/lib/chat/session-store.ts +272 -0
- package/lib/chat/telegram-bridge.ts +276 -0
- package/lib/chat/temper.ts +281 -0
- package/lib/chat/tool-dispatcher.ts +190 -0
- package/lib/chat/types.ts +50 -0
- package/lib/chat-standalone.ts +286 -0
- package/lib/crypto.ts +1 -1
- package/lib/health.ts +131 -0
- package/lib/help-docs/00-overview.md +2 -1
- package/lib/help-docs/01-settings.md +46 -25
- package/lib/help-docs/07-projects.md +1 -1
- package/lib/help-docs/10-troubleshooting.md +10 -2
- package/lib/help-docs/16-gitlab-autofix.md +114 -0
- package/lib/help-docs/17-connectors.md +322 -0
- package/lib/help-docs/18-chrome-mcp.md +134 -0
- package/lib/help-docs/19-jobs.md +140 -0
- package/lib/help-docs/20-mantis-bug-fix.md +115 -0
- package/lib/help-docs/CLAUDE.md +10 -0
- package/lib/init.ts +137 -50
- package/lib/iso-time.ts +30 -0
- package/lib/issue-scanner-gitlab.ts +281 -0
- package/lib/jobs/dispatcher.ts +217 -0
- package/lib/jobs/scheduler.ts +334 -0
- package/lib/jobs/store.ts +319 -0
- package/lib/jobs/types.ts +117 -0
- package/lib/pipeline-scheduler.ts +1 -6
- package/lib/pipeline.ts +790 -10
- package/lib/plugins/registry.ts +133 -8
- package/lib/plugins/templates.ts +83 -0
- package/lib/plugins/types.ts +140 -1
- package/lib/session-watcher.ts +36 -10
- package/lib/settings.ts +65 -33
- package/lib/skills.ts +3 -1
- package/lib/task-manager.ts +50 -22
- package/lib/telegram-bot.ts +71 -0
- package/lib/terminal-standalone.ts +58 -36
- package/lib/workspace/orchestrator.ts +1 -0
- package/middleware.ts +10 -0
- package/package.json +3 -2
- package/scripts/bench/README.md +1 -1
- package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
- package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
- package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
- package/src/core/db/database.ts +21 -12
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env -S npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Chat backend standalone — HTTP server on $CHAT_PORT (default 8408).
|
|
4
|
+
*
|
|
5
|
+
* Why a standalone:
|
|
6
|
+
* - Exclusive writer to chat_sessions / chat_messages tables, mirroring
|
|
7
|
+
* the workspace daemon pattern (avoids multi-writer races across
|
|
8
|
+
* Next.js workers).
|
|
9
|
+
* - One canonical endpoint for every surface: web `/chat` tab (via Next
|
|
10
|
+
* proxy), `forge chat` CLI, Telegram bot, extension side panel.
|
|
11
|
+
* - LLM streams can run for tens of seconds without occupying a Next
|
|
12
|
+
* worker for the whole turn.
|
|
13
|
+
*
|
|
14
|
+
* Loopback-only — Next.js routes proxy requests in; CLI/Telegram talk
|
|
15
|
+
* directly. Auth happens at the Next.js / Telegram layer.
|
|
16
|
+
*
|
|
17
|
+
* Routes:
|
|
18
|
+
* GET /api/status
|
|
19
|
+
* GET /api/sessions
|
|
20
|
+
* POST /api/sessions { title?, model?, provider?, system_prompt?, meta? }
|
|
21
|
+
* GET /api/sessions/:id?after_ts=&limit=
|
|
22
|
+
* PATCH /api/sessions/:id
|
|
23
|
+
* DELETE /api/sessions/:id
|
|
24
|
+
* POST /api/sessions/:id/messages { text } → 202 { topic }
|
|
25
|
+
* GET /api/sessions/:id/events SSE stream of agent events
|
|
26
|
+
*
|
|
27
|
+
* Fanout:
|
|
28
|
+
* Each agent event is delivered to (a) local SSE subscribers and
|
|
29
|
+
* (b) the browser bridge as `chat:<session_id>` push. Browser surfaces
|
|
30
|
+
* keep their existing WS path; HTTP-only surfaces use SSE.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
34
|
+
import {
|
|
35
|
+
createSession, getSession, listSessions, updateSession, deleteSession, listMessages,
|
|
36
|
+
clearSessionMessages, ensureMainSession, forkSession,
|
|
37
|
+
} from './chat/session-store';
|
|
38
|
+
import { runTurn, type AgentEvent } from './chat/agent-loop';
|
|
39
|
+
import { bridgePush } from './chat/bridge-client';
|
|
40
|
+
|
|
41
|
+
const PORT = Number(process.env.CHAT_PORT) || 8408;
|
|
42
|
+
const startTime = Date.now();
|
|
43
|
+
|
|
44
|
+
// ─── SSE subscriber registry ──────────────────────────────
|
|
45
|
+
|
|
46
|
+
const sseSubs = new Map<string, Set<ServerResponse>>(); // session_id → subscribers
|
|
47
|
+
|
|
48
|
+
function addSseSub(sessionId: string, res: ServerResponse): void {
|
|
49
|
+
let set = sseSubs.get(sessionId);
|
|
50
|
+
if (!set) { set = new Set(); sseSubs.set(sessionId, set); }
|
|
51
|
+
set.add(res);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function removeSseSub(sessionId: string, res: ServerResponse): void {
|
|
55
|
+
const set = sseSubs.get(sessionId);
|
|
56
|
+
if (!set) return;
|
|
57
|
+
set.delete(res);
|
|
58
|
+
if (set.size === 0) sseSubs.delete(sessionId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fanoutEvent(sessionId: string, event: AgentEvent): void {
|
|
62
|
+
// (a) browser bridge push — extension side panels listen on this topic.
|
|
63
|
+
void bridgePush(`chat:${sessionId}`, event);
|
|
64
|
+
// (b) local SSE subscribers — CLI, web tab, future surfaces.
|
|
65
|
+
const set = sseSubs.get(sessionId);
|
|
66
|
+
if (!set || set.size === 0) return;
|
|
67
|
+
const frame = `data: ${JSON.stringify(event)}\n\n`;
|
|
68
|
+
for (const res of set) {
|
|
69
|
+
try { res.write(frame); } catch {}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── HTTP helpers ─────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
async function readJson(req: IncomingMessage): Promise<any> {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const chunks: Buffer[] = [];
|
|
78
|
+
req.on('data', (c) => chunks.push(c));
|
|
79
|
+
req.on('end', () => {
|
|
80
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
81
|
+
if (!raw) return resolve({});
|
|
82
|
+
try { resolve(JSON.parse(raw)); } catch (e) { reject(e); }
|
|
83
|
+
});
|
|
84
|
+
req.on('error', reject);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
89
|
+
res.statusCode = status;
|
|
90
|
+
res.setHeader('content-type', 'application/json');
|
|
91
|
+
res.end(JSON.stringify(body));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Route handlers ───────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
async function handleSessionsList(_req: IncomingMessage, res: ServerResponse, url: URL): Promise<void> {
|
|
97
|
+
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit') || 50)));
|
|
98
|
+
sendJson(res, 200, { sessions: listSessions(limit) });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function handleSessionCreate(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
102
|
+
const body = await readJson(req);
|
|
103
|
+
const session = createSession({
|
|
104
|
+
title: body.title,
|
|
105
|
+
model: body.model,
|
|
106
|
+
provider: body.provider,
|
|
107
|
+
system_prompt: body.system_prompt,
|
|
108
|
+
meta: body.meta,
|
|
109
|
+
});
|
|
110
|
+
sendJson(res, 200, { session });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function handleSessionGet(_req: IncomingMessage, res: ServerResponse, id: string, url: URL): Promise<void> {
|
|
114
|
+
const session = getSession(id);
|
|
115
|
+
if (!session) return sendJson(res, 404, { error: 'session not found' });
|
|
116
|
+
const after_ts = Number(url.searchParams.get('after_ts') || 0);
|
|
117
|
+
const limit = Math.min(2000, Math.max(1, Number(url.searchParams.get('limit') || 500)));
|
|
118
|
+
sendJson(res, 200, { session, messages: listMessages(id, { after_ts, limit }) });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function handleSessionPatch(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
|
|
122
|
+
const session = getSession(id);
|
|
123
|
+
if (!session) return sendJson(res, 404, { error: 'session not found' });
|
|
124
|
+
const body = await readJson(req);
|
|
125
|
+
const ok = updateSession(id, {
|
|
126
|
+
title: body.title,
|
|
127
|
+
model: body.model,
|
|
128
|
+
provider: body.provider,
|
|
129
|
+
system_prompt: body.system_prompt,
|
|
130
|
+
meta: body.meta,
|
|
131
|
+
});
|
|
132
|
+
sendJson(res, 200, { ok, session: getSession(id) });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function handleSessionDelete(_req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
|
|
136
|
+
// Refuse to delete the persistent main session — the UI is allowed to
|
|
137
|
+
// "clear" it (drop messages, keep the session) via DELETE /messages.
|
|
138
|
+
const session = getSession(id);
|
|
139
|
+
if (session?.meta && (session.meta as any).kind === 'main') {
|
|
140
|
+
return sendJson(res, 400, { error: 'cannot delete the main session — use DELETE /api/sessions/:id/messages to clear it' });
|
|
141
|
+
}
|
|
142
|
+
const ok = deleteSession(id);
|
|
143
|
+
sendJson(res, 200, { ok });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function handleSessionClearMessages(_req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
|
|
147
|
+
const session = getSession(id);
|
|
148
|
+
if (!session) return sendJson(res, 404, { error: 'session not found' });
|
|
149
|
+
const removed = clearSessionMessages(id);
|
|
150
|
+
sendJson(res, 200, { ok: true, removed });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function handleSessionFork(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
|
|
154
|
+
const body = await readJson(req).catch(() => ({}));
|
|
155
|
+
const title = body?.title ? String(body.title) : undefined;
|
|
156
|
+
const upToTs = typeof body?.up_to_ts === 'number' ? body.up_to_ts : undefined;
|
|
157
|
+
const fork = forkSession(id, { title, upToTs });
|
|
158
|
+
if (!fork) return sendJson(res, 404, { error: 'source session not found' });
|
|
159
|
+
sendJson(res, 200, { ok: true, session: fork });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function handleMessagePost(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
|
|
163
|
+
const session = getSession(id);
|
|
164
|
+
if (!session) return sendJson(res, 404, { error: 'session not found' });
|
|
165
|
+
const body = await readJson(req);
|
|
166
|
+
const text = String(body?.text || '').trim();
|
|
167
|
+
if (!text) return sendJson(res, 400, { error: 'text is required' });
|
|
168
|
+
|
|
169
|
+
const startedAt = Date.now();
|
|
170
|
+
void runTurn({
|
|
171
|
+
sessionId: id,
|
|
172
|
+
userText: text,
|
|
173
|
+
callbacks: { onEvent: (e: AgentEvent) => fanoutEvent(id, e) },
|
|
174
|
+
}).then((r) => {
|
|
175
|
+
if (!r.ok) fanoutEvent(id, { type: 'error', data: { error: r.error || 'unknown' } });
|
|
176
|
+
console.log(`[chat] turn ${id.slice(0, 8)} done in ${Date.now() - startedAt}ms ok=${r.ok}`);
|
|
177
|
+
}).catch((err) => {
|
|
178
|
+
fanoutEvent(id, { type: 'error', data: { error: (err as Error).message } });
|
|
179
|
+
console.error('[chat] turn error', err);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
sendJson(res, 202, { accepted: true, topic: `chat:${id}` });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function handleEventsSse(_req: IncomingMessage, res: ServerResponse, id: string): void {
|
|
186
|
+
// Verify the session exists before opening the stream.
|
|
187
|
+
const session = getSession(id);
|
|
188
|
+
if (!session) return sendJson(res, 404, { error: 'session not found' });
|
|
189
|
+
|
|
190
|
+
res.statusCode = 200;
|
|
191
|
+
res.setHeader('content-type', 'text/event-stream');
|
|
192
|
+
res.setHeader('cache-control', 'no-cache, no-transform');
|
|
193
|
+
res.setHeader('connection', 'keep-alive');
|
|
194
|
+
res.setHeader('x-accel-buffering', 'no'); // disable proxy buffering
|
|
195
|
+
res.write(`: subscribed to chat:${id}\n\n`);
|
|
196
|
+
|
|
197
|
+
addSseSub(id, res);
|
|
198
|
+
|
|
199
|
+
// Heartbeat every 25s keeps proxies from idling out.
|
|
200
|
+
const hb = setInterval(() => {
|
|
201
|
+
try { res.write(`: ping ${Date.now()}\n\n`); } catch {}
|
|
202
|
+
}, 25_000);
|
|
203
|
+
hb.unref?.();
|
|
204
|
+
|
|
205
|
+
const cleanup = () => {
|
|
206
|
+
clearInterval(hb);
|
|
207
|
+
removeSseSub(id, res);
|
|
208
|
+
try { res.end(); } catch {}
|
|
209
|
+
};
|
|
210
|
+
res.on('close', cleanup);
|
|
211
|
+
res.on('error', cleanup);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── Router ───────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
217
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
218
|
+
const m = req.method || 'GET';
|
|
219
|
+
|
|
220
|
+
if (m === 'GET' && url.pathname === '/api/status') {
|
|
221
|
+
return sendJson(res, 200, {
|
|
222
|
+
port: PORT,
|
|
223
|
+
uptime_seconds: Math.floor((Date.now() - startTime) / 1000),
|
|
224
|
+
active_sse_sessions: sseSubs.size,
|
|
225
|
+
sse_subscribers: [...sseSubs.values()].reduce((n, s) => n + s.size, 0),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (m === 'GET' && url.pathname === '/api/sessions') return handleSessionsList(req, res, url);
|
|
230
|
+
if (m === 'POST' && url.pathname === '/api/sessions') return handleSessionCreate(req, res);
|
|
231
|
+
if (m === 'GET' && url.pathname === '/api/sessions/main') {
|
|
232
|
+
return sendJson(res, 200, { session: ensureMainSession() });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const detail = /^\/api\/sessions\/([^/]+)$/.exec(url.pathname);
|
|
236
|
+
if (detail) {
|
|
237
|
+
const id = detail[1]!;
|
|
238
|
+
if (m === 'GET') return handleSessionGet(req, res, id, url);
|
|
239
|
+
if (m === 'PATCH') return handleSessionPatch(req, res, id);
|
|
240
|
+
if (m === 'DELETE') return handleSessionDelete(req, res, id);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const messages = /^\/api\/sessions\/([^/]+)\/messages$/.exec(url.pathname);
|
|
244
|
+
if (messages && m === 'POST') return handleMessagePost(req, res, messages[1]!);
|
|
245
|
+
if (messages && m === 'DELETE') return handleSessionClearMessages(req, res, messages[1]!);
|
|
246
|
+
|
|
247
|
+
const fork = /^\/api\/sessions\/([^/]+)\/fork$/.exec(url.pathname);
|
|
248
|
+
if (fork && m === 'POST') return handleSessionFork(req, res, fork[1]!);
|
|
249
|
+
|
|
250
|
+
const events = /^\/api\/sessions\/([^/]+)\/events$/.exec(url.pathname);
|
|
251
|
+
if (events && m === 'GET') return handleEventsSse(req, res, events[1]!);
|
|
252
|
+
|
|
253
|
+
sendJson(res, 404, { error: 'not found' });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── Boot ─────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
const httpServer = createServer((req, res) => {
|
|
259
|
+
route(req, res).catch((err) => {
|
|
260
|
+
console.error('[chat] http error:', err);
|
|
261
|
+
try { sendJson(res, 500, { error: 'internal' }); } catch {}
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
httpServer.listen(PORT, '127.0.0.1', () => {
|
|
266
|
+
console.log(`[chat] Chat standalone listening on http://127.0.0.1:${PORT}`);
|
|
267
|
+
// Make sure exactly one persistent main session exists. The UI uses
|
|
268
|
+
// meta.kind === 'main' to pin it, refuse delete, and route the
|
|
269
|
+
// top-level "clear" button to DELETE /messages instead.
|
|
270
|
+
try {
|
|
271
|
+
const main = ensureMainSession();
|
|
272
|
+
console.log(`[chat] Main session: ${main.id.slice(0, 8)} "${main.title}"`);
|
|
273
|
+
} catch (e) { console.warn('[chat] ensureMainSession failed:', (e as Error).message); }
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
function shutdown(): void {
|
|
277
|
+
console.log('[chat] shutting down');
|
|
278
|
+
for (const set of sseSubs.values()) {
|
|
279
|
+
for (const res of set) { try { res.end(); } catch {} }
|
|
280
|
+
}
|
|
281
|
+
sseSubs.clear();
|
|
282
|
+
httpServer.close(() => process.exit(0));
|
|
283
|
+
setTimeout(() => process.exit(0), 2000).unref();
|
|
284
|
+
}
|
|
285
|
+
process.on('SIGTERM', shutdown);
|
|
286
|
+
process.on('SIGINT', shutdown);
|
package/lib/crypto.ts
CHANGED
|
@@ -63,5 +63,5 @@ export function hashSecret(value: string): string {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** Secret field names in settings */
|
|
66
|
-
export const SECRET_FIELDS = ['telegramBotToken', 'telegramTunnelPassword'] as const;
|
|
66
|
+
export const SECRET_FIELDS = ['telegramBotToken', 'telegramTunnelPassword', 'temperKey'] as const;
|
|
67
67
|
export type SecretField = typeof SECRET_FIELDS[number];
|
package/lib/health.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External-tool health checks. Surfaced two ways:
|
|
3
|
+
* - On startup (lib/init.ts) — one log line per tool with install hint
|
|
4
|
+
* if missing, so server logs make the problem obvious
|
|
5
|
+
* - Via `GET /api/health/tools` — the Pipelines view uses this to show
|
|
6
|
+
* a banner when a tool needed by a built-in workflow isn't installed
|
|
7
|
+
*
|
|
8
|
+
* Add new tools by extending CHECKS. Tools are marked "optional" when a
|
|
9
|
+
* subset of Forge features needs them — the absence isn't an error, just
|
|
10
|
+
* a heads-up that those features won't work.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execSync } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
export interface ToolStatus {
|
|
16
|
+
/** binary name as invoked from PATH */
|
|
17
|
+
name: string;
|
|
18
|
+
/** human-readable label for UI */
|
|
19
|
+
label: string;
|
|
20
|
+
/** what part of Forge needs this — shown in the warning */
|
|
21
|
+
needed_for: string;
|
|
22
|
+
/** absolute path if installed, '' otherwise */
|
|
23
|
+
path: string;
|
|
24
|
+
/** parsed version string if we could pull one */
|
|
25
|
+
version: string;
|
|
26
|
+
/** true iff `which <name>` found it */
|
|
27
|
+
installed: boolean;
|
|
28
|
+
/** copy-pasteable install / auth instructions when missing */
|
|
29
|
+
install_hint: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Check {
|
|
33
|
+
name: string;
|
|
34
|
+
label: string;
|
|
35
|
+
needed_for: string;
|
|
36
|
+
versionFlag?: string; // arg to invoke for version probe (default --version)
|
|
37
|
+
install_hint: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const CHECKS: Check[] = [
|
|
41
|
+
{
|
|
42
|
+
name: 'glab',
|
|
43
|
+
label: 'GitLab CLI',
|
|
44
|
+
needed_for: 'gitlab-issue-fix-and-review + mantis-bug-fix-and-mr pipelines (push branches + open MRs)',
|
|
45
|
+
install_hint: [
|
|
46
|
+
'macOS: brew install glab',
|
|
47
|
+
'Linux: https://gitlab.com/gitlab-org/cli/-/releases (deb/rpm/tar)',
|
|
48
|
+
'Windows: winget install GitLab.cli',
|
|
49
|
+
'Then auth once per host: glab auth login --hostname <your-gitlab.example.com>',
|
|
50
|
+
].join('\n'),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'gh',
|
|
54
|
+
label: 'GitHub CLI',
|
|
55
|
+
needed_for: 'issue-fix-and-review pipeline + GitHub issue scanner',
|
|
56
|
+
install_hint: [
|
|
57
|
+
'macOS: brew install gh',
|
|
58
|
+
'Linux: https://cli.github.com/manual/installation',
|
|
59
|
+
'Then auth once: gh auth login',
|
|
60
|
+
].join('\n'),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'git',
|
|
64
|
+
label: 'git',
|
|
65
|
+
needed_for: 'every pipeline (worktree, fetch, push)',
|
|
66
|
+
install_hint: [
|
|
67
|
+
'macOS: xcode-select --install (or brew install git)',
|
|
68
|
+
'Linux: apt install git / dnf install git',
|
|
69
|
+
'Windows: https://git-scm.com/download/win',
|
|
70
|
+
].join('\n'),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'jq',
|
|
74
|
+
label: 'jq',
|
|
75
|
+
needed_for: 'gitlab-issue-fix-and-review (parses glab JSON)',
|
|
76
|
+
install_hint: [
|
|
77
|
+
'macOS: brew install jq',
|
|
78
|
+
'Linux: apt install jq / dnf install jq',
|
|
79
|
+
].join('\n'),
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
function which(bin: string): string {
|
|
84
|
+
try {
|
|
85
|
+
return execSync(`which ${bin}`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
86
|
+
} catch {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function version(bin: string, flag = '--version'): string {
|
|
92
|
+
try {
|
|
93
|
+
const out = execSync(`${bin} ${flag}`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
94
|
+
// First line, usually like "git version 2.42.0" or "glab version 1.40.0 (...)"
|
|
95
|
+
return out.split('\n')[0] || '';
|
|
96
|
+
} catch {
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function checkTools(): ToolStatus[] {
|
|
102
|
+
return CHECKS.map((c) => {
|
|
103
|
+
const path = which(c.name);
|
|
104
|
+
return {
|
|
105
|
+
name: c.name,
|
|
106
|
+
label: c.label,
|
|
107
|
+
needed_for: c.needed_for,
|
|
108
|
+
path,
|
|
109
|
+
version: path ? version(c.name, c.versionFlag) : '',
|
|
110
|
+
installed: !!path,
|
|
111
|
+
install_hint: c.install_hint,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Log a one-line summary per tool to the startup console. For missing
|
|
118
|
+
* ones, also log the install hint so users see it without poking at the
|
|
119
|
+
* UI.
|
|
120
|
+
*/
|
|
121
|
+
export function logToolStatus(): void {
|
|
122
|
+
const results = checkTools();
|
|
123
|
+
for (const r of results) {
|
|
124
|
+
if (r.installed) {
|
|
125
|
+
console.log(`[tools] ✓ ${r.name.padEnd(6)} ${r.version || r.path}`);
|
|
126
|
+
} else {
|
|
127
|
+
console.warn(`[tools] ✗ ${r.name.padEnd(6)} NOT FOUND — ${r.needed_for}`);
|
|
128
|
+
for (const line of r.install_hint.split('\n')) console.warn(` ${line}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -16,7 +16,8 @@ Forge is a self-hosted Vibe Coding platform for Claude Code. It provides a brows
|
|
|
16
16
|
| **Telegram Bot** | Mobile control — submit tasks, receive notifications |
|
|
17
17
|
| **Remote Access** | One-click Cloudflare tunnel for remote browsing |
|
|
18
18
|
| **GitHub Issue Auto-fix** | Scan issues, auto-fix, create PRs |
|
|
19
|
-
| **Memory
|
|
19
|
+
| **Memory** | Long-term chat memory. Configure `@aion0/temper` for semantic + graph search, or leave URL/key blank to use the built-in local SQLite store (same block/episode API, keyword search). |
|
|
20
|
+
| **Web Chat (simplified)** | `/chat` — minimal in-browser chat UI for use without the extension. Sessions, SSE streaming, memory badge. Full connector UX still lives in the extension. |
|
|
20
21
|
| **IDE Plugins** | First-party VSCode extension and IntelliJ plugin — drive workspaces, agents, terminals, pipelines and docs from inside the editor (see `13-ide-plugins.md`) |
|
|
21
22
|
|
|
22
23
|
## Quick Start
|
|
@@ -61,7 +61,7 @@ The `cliType` field determines how Forge interacts with the agent:
|
|
|
61
61
|
| CLI Type | Session Support | Resume | Skip Permissions |
|
|
62
62
|
|----------|----------------|--------|------------------|
|
|
63
63
|
| `claude-code` | Yes (session files) | `-c` / `--resume <id>` | `--dangerously-skip-permissions` |
|
|
64
|
-
| `codex` | No | — | `--
|
|
64
|
+
| `codex` | No | — | `--dangerously-bypass-approvals-and-sandbox` |
|
|
65
65
|
| `aider` | No | — | `--yes` |
|
|
66
66
|
| `generic` | No | — | — |
|
|
67
67
|
|
|
@@ -81,7 +81,7 @@ agents:
|
|
|
81
81
|
enabled: true
|
|
82
82
|
cliType: codex
|
|
83
83
|
requiresTTY: true
|
|
84
|
-
skipPermissionsFlag: --
|
|
84
|
+
skipPermissionsFlag: --dangerously-bypass-approvals-and-sandbox
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
## Agent Profiles
|
|
@@ -103,14 +103,14 @@ Profiles are reusable configurations that extend a base agent with custom enviro
|
|
|
103
103
|
|
|
104
104
|
```yaml
|
|
105
105
|
agents:
|
|
106
|
-
|
|
106
|
+
forge-k2:
|
|
107
107
|
base: claude
|
|
108
|
-
name:
|
|
109
|
-
model:
|
|
108
|
+
name: Forge K2
|
|
109
|
+
model: forge-k2
|
|
110
110
|
env:
|
|
111
111
|
ANTHROPIC_AUTH_TOKEN: sk-xxx
|
|
112
112
|
ANTHROPIC_BASE_URL: http://my-server:7001/
|
|
113
|
-
ANTHROPIC_SMALL_FAST_MODEL:
|
|
113
|
+
ANTHROPIC_SMALL_FAST_MODEL: forge-k2
|
|
114
114
|
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "true"
|
|
115
115
|
DISABLE_TELEMETRY: "true"
|
|
116
116
|
```
|
|
@@ -143,31 +143,51 @@ In the agent configuration, set the `profile` field to link a profile:
|
|
|
143
143
|
```yaml
|
|
144
144
|
agents:
|
|
145
145
|
claude:
|
|
146
|
-
profile:
|
|
146
|
+
profile: forge-k2 # Claude will use forge-k2's env/model when launched
|
|
147
147
|
```
|
|
148
148
|
|
|
149
149
|
This applies the profile's environment variables and model override whenever that agent is launched in a terminal.
|
|
150
150
|
|
|
151
|
-
## API
|
|
151
|
+
## API agent profiles
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
API access (used by API-backend smiths and the chat backend) is configured
|
|
154
|
+
as agent profiles with `type: api`. The same profile is reused everywhere
|
|
155
|
+
— no separate provider/key duplication.
|
|
154
156
|
|
|
155
157
|
```yaml
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
apiKey:
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
158
|
+
agents:
|
|
159
|
+
my-litellm:
|
|
160
|
+
type: api
|
|
161
|
+
name: My LiteLLM
|
|
162
|
+
provider: litellm # display label
|
|
163
|
+
model: claude-sonnet-4-6
|
|
164
|
+
apiKey: sk-... # encrypted on save
|
|
165
|
+
baseUrl: http://127.0.0.1:4000/v1 # LiteLLM / Azure / self-hosted proxy
|
|
166
|
+
|
|
167
|
+
direct-anthropic:
|
|
168
|
+
type: api
|
|
169
|
+
name: Anthropic Direct
|
|
170
|
+
provider: anthropic
|
|
171
|
+
model: claude-haiku-4-5-20251001
|
|
172
|
+
apiKey: sk-ant-...
|
|
173
|
+
|
|
174
|
+
chatAgent: my-litellm # which profile Forge's chat backend uses
|
|
168
175
|
```
|
|
169
176
|
|
|
170
|
-
|
|
177
|
+
The chat backend (extension chat, `forge chat`, Telegram `/chat`) picks
|
|
178
|
+
`settings.chatAgent` by default; a session can override via
|
|
179
|
+
`Session.provider = <profile-id>`.
|
|
180
|
+
|
|
181
|
+
Provider → adapter mapping for chat:
|
|
182
|
+
- `provider: anthropic` (or `claude`) → Anthropic Messages API protocol
|
|
183
|
+
- everything else → OpenAI-compatible protocol (LiteLLM, Grok, Google via
|
|
184
|
+
LiteLLM, OpenAI itself, Azure)
|
|
185
|
+
|
|
186
|
+
`baseUrl` falls back to `env.ANTHROPIC_BASE_URL` / `env.OPENAI_BASE_URL` /
|
|
187
|
+
`env.OPENAI_API_BASE` if not set directly. `apiKey` falls back to
|
|
188
|
+
`env.ANTHROPIC_API_KEY` / `env.ANTHROPIC_AUTH_TOKEN` /
|
|
189
|
+
`env.OPENAI_API_KEY`. This makes a single profile usable both by a CLI
|
|
190
|
+
(reads `env`) and by chat (reads the top-level field).
|
|
171
191
|
|
|
172
192
|
## Admin Password
|
|
173
193
|
|
|
@@ -177,7 +197,7 @@ Provider API keys are encrypted with AES-256-GCM. The UI shows masked values (
|
|
|
177
197
|
|
|
178
198
|
## Encrypted Fields
|
|
179
199
|
|
|
180
|
-
`telegramBotToken` and `telegramTunnelPassword` are encrypted with AES-256-GCM. Agent
|
|
200
|
+
`telegramBotToken` and `telegramTunnelPassword` are encrypted with AES-256-GCM. Agent profile `apiKey` fields are also encrypted. The encryption key is stored at `~/.forge/data/.encrypt-key`.
|
|
181
201
|
|
|
182
202
|
## Settings UI
|
|
183
203
|
|
|
@@ -188,8 +208,9 @@ The Settings modal has these sections:
|
|
|
188
208
|
| **Project Roots** | Directories to scan for projects |
|
|
189
209
|
| **Document Roots** | Markdown/Obsidian vault paths |
|
|
190
210
|
| **Agents** | Detected CLI agents + configuration |
|
|
191
|
-
| **Profiles** | Agent profiles
|
|
192
|
-
| **
|
|
211
|
+
| **Profiles** | Agent profiles (CLI + API) — API profiles also power Forge chat |
|
|
212
|
+
| **Memory (Temper)** | Long-term memory backend for the chat agent. When Temper URL+key are blank, chat falls back to a local SQLite store with the same block/episode tools (keyword search only — no semantic/graph search). |
|
|
213
|
+
| **Chat backend** | Pick the default API profile for chat (extension / CLI / Telegram) |
|
|
193
214
|
| **Telegram** | Bot token, chat ID, notification toggles |
|
|
194
215
|
| **Display** | Name, email |
|
|
195
216
|
| **Other** | Skip permissions, skills repo URL, notification retention |
|
|
@@ -81,7 +81,7 @@ There are two ways to open a terminal for a project:
|
|
|
81
81
|
|
|
82
82
|
When selecting an agent or profile for a terminal:
|
|
83
83
|
- **Base agents** (Claude, Codex, Aider): Use their default configuration
|
|
84
|
-
- **Profiles** (e.g.,
|
|
84
|
+
- **Profiles** (e.g., Forge K2, Claude Opus): Apply the profile's environment variables and model override
|
|
85
85
|
- Environment variables are exported in the terminal before launching the CLI
|
|
86
86
|
- Model is passed via `--model` flag (for claude-code agents)
|
|
87
87
|
|
|
@@ -2,12 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## Common Issues
|
|
4
4
|
|
|
5
|
-
### "fork failed: Device not configured" (macOS)
|
|
6
|
-
|
|
5
|
+
### "fork failed: Device not configured" / "posix_spawnp failed" (macOS)
|
|
6
|
+
Symptom: after the server has been running for a long time, opening a new terminal fails with `Failed to create tmux session: posix_spawnp failed`.
|
|
7
|
+
|
|
8
|
+
Forge ≥ 0.5.46 auto-recovers by killing idle sessions on PTY exhaustion (both random-name and fixed-name `create` paths go through the same recovery). On older versions, or if the macOS PTY pool is genuinely too small, raise the limit:
|
|
7
9
|
```bash
|
|
8
10
|
sudo sysctl kern.tty.ptmx_max=2048
|
|
9
11
|
echo 'kern.tty.ptmx_max=2048' | sudo tee -a /etc/sysctl.conf
|
|
10
12
|
```
|
|
13
|
+
Manual cleanup if needed:
|
|
14
|
+
```bash
|
|
15
|
+
tmux list-sessions
|
|
16
|
+
tmux kill-session -t <name> # kill specific
|
|
17
|
+
tmux kill-server # nuclear option — kills ALL tmux sessions
|
|
18
|
+
```
|
|
11
19
|
|
|
12
20
|
### Session cookie invalid after restart
|
|
13
21
|
Fix AUTH_SECRET so it persists:
|