@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
package/lib/task-manager.ts
CHANGED
|
@@ -12,12 +12,7 @@ import { loadSettings } from './settings';
|
|
|
12
12
|
import { notifyTaskComplete, notifyTaskFailed } from './notify';
|
|
13
13
|
import type { Task, TaskLogEntry, TaskStatus, TaskMode, WatchConfig } from '@/src/types';
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
function toIsoUTC(s: string | null | undefined): string | null {
|
|
17
|
-
if (!s) return null;
|
|
18
|
-
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s)) return s.replace(' ', 'T') + 'Z';
|
|
19
|
-
return s;
|
|
20
|
-
}
|
|
15
|
+
import { toIsoUTC } from './iso-time';
|
|
21
16
|
|
|
22
17
|
const runnerKey = Symbol.for('mw-task-runner');
|
|
23
18
|
const gRunner = globalThis as any;
|
|
@@ -362,33 +357,55 @@ function executeShellTask(task: Task): Promise<void> {
|
|
|
362
357
|
db().prepare('UPDATE tasks SET started_at = datetime(\'now\') WHERE id = ?').run(task.id);
|
|
363
358
|
console.log(`[task:shell] ${task.projectName}: "${task.prompt.slice(0, 80)}"`);
|
|
364
359
|
|
|
365
|
-
|
|
360
|
+
// Use an absolute path: when the parent's PATH is stripped (which has
|
|
361
|
+
// happened under certain supervisor relaunches), bare 'bash' becomes
|
|
362
|
+
// ENOENT, the 'error' event fires, and without the listener below it
|
|
363
|
+
// bubbles up to the process as an uncaughtException — crashing the
|
|
364
|
+
// entire Next.js worker and triggering a supervisor restart loop.
|
|
365
|
+
const shell = process.env.SHELL && process.env.SHELL.endsWith('bash') ? process.env.SHELL : '/bin/bash';
|
|
366
|
+
const child = spawn(shell, ['-c', task.prompt], {
|
|
366
367
|
cwd: task.projectPath,
|
|
367
|
-
env: { ...process.env },
|
|
368
|
+
env: { ...process.env, PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' },
|
|
368
369
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
369
370
|
});
|
|
370
371
|
|
|
371
372
|
let stdout = '';
|
|
372
373
|
let stderr = '';
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
379
|
-
|
|
380
|
-
child.on('exit', (code) => {
|
|
381
|
-
if (code === 0) {
|
|
374
|
+
let resolved = false;
|
|
375
|
+
const finish = (status: 'done' | 'failed', summary: string, error?: string) => {
|
|
376
|
+
if (resolved) return;
|
|
377
|
+
resolved = true;
|
|
378
|
+
if (status === 'done') {
|
|
382
379
|
db().prepare('UPDATE tasks SET status = ?, result_summary = ?, completed_at = datetime(\'now\') WHERE id = ?')
|
|
383
|
-
.run('done',
|
|
380
|
+
.run('done', summary, task.id);
|
|
384
381
|
emit(task.id, 'status', 'done');
|
|
385
382
|
} else {
|
|
386
|
-
const errMsg = stderr.trim() || `Exit code ${code}`;
|
|
387
383
|
db().prepare('UPDATE tasks SET status = ?, error = ?, completed_at = datetime(\'now\') WHERE id = ?')
|
|
388
|
-
.run('failed',
|
|
384
|
+
.run('failed', error || summary || 'unknown error', task.id);
|
|
389
385
|
emit(task.id, 'status', 'failed');
|
|
390
386
|
}
|
|
391
387
|
resolve();
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// CRITICAL: without this listener, spawn errors (ENOENT, EACCES, …)
|
|
391
|
+
// become uncaughtException and crash the worker.
|
|
392
|
+
child.on('error', (err: NodeJS.ErrnoException) => {
|
|
393
|
+
const msg = `spawn failed: ${err.code || ''} ${err.message}`.trim();
|
|
394
|
+
console.error(`[task:shell] ${task.id} ${msg}`);
|
|
395
|
+
appendLog(task.id, { type: 'system', subtype: 'text', content: msg + '\n', timestamp: new Date().toISOString() });
|
|
396
|
+
finish('failed', '', msg);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
400
|
+
const text = chunk.toString();
|
|
401
|
+
stdout += text;
|
|
402
|
+
appendLog(task.id, { type: 'system', subtype: 'text', content: text, timestamp: new Date().toISOString() });
|
|
403
|
+
});
|
|
404
|
+
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
405
|
+
|
|
406
|
+
child.on('exit', (code) => {
|
|
407
|
+
if (code === 0) finish('done', stdout.trim());
|
|
408
|
+
else finish('failed', '', stderr.trim() || `Exit code ${code}`);
|
|
392
409
|
});
|
|
393
410
|
});
|
|
394
411
|
}
|
|
@@ -591,10 +608,21 @@ function executeTask(task: Task): Promise<void> {
|
|
|
591
608
|
db().prepare('UPDATE tasks SET conversation_id = ? WHERE id = ?').run(sessionId, task.id);
|
|
592
609
|
}
|
|
593
610
|
|
|
594
|
-
// Capture git diff
|
|
611
|
+
// Capture git diff. Three-way fallback so pipeline nodes (which commit
|
|
612
|
+
// their own work in a worktree) don't end up with an empty diff:
|
|
613
|
+
// 1. `git diff HEAD` — uncommitted changes (interactive tasks)
|
|
614
|
+
// 2. `git diff @{u}..HEAD` — commits ahead of upstream (worktree pushed nothing yet)
|
|
615
|
+
// 3. `git show HEAD` — last commit's diff (fallback when no upstream tracking)
|
|
595
616
|
try {
|
|
596
617
|
const { execSync } = require('node:child_process');
|
|
597
|
-
const
|
|
618
|
+
const tryDiff = (cmd: string): string => {
|
|
619
|
+
try {
|
|
620
|
+
return execSync(cmd, { cwd: task.projectPath, timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
621
|
+
} catch { return ''; }
|
|
622
|
+
};
|
|
623
|
+
let diff = tryDiff('git diff HEAD');
|
|
624
|
+
if (!diff.trim()) diff = tryDiff('git diff @{upstream}..HEAD --no-color');
|
|
625
|
+
if (!diff.trim()) diff = tryDiff('git show HEAD --no-color');
|
|
598
626
|
if (diff.trim()) {
|
|
599
627
|
db().prepare('UPDATE tasks SET git_diff = ? WHERE id = ?').run(diff, task.id);
|
|
600
628
|
}
|
package/lib/telegram-bot.ts
CHANGED
|
@@ -296,6 +296,20 @@ async function handleMessage(msg: any) {
|
|
|
296
296
|
await send(chatId, 'Inject target cleared.');
|
|
297
297
|
break;
|
|
298
298
|
}
|
|
299
|
+
case '/chat':
|
|
300
|
+
case '/c':
|
|
301
|
+
if (args.length === 0) {
|
|
302
|
+
await send(chatId, 'Usage: /chat <message>\n/chat_new to start a fresh session\n/chat_session to show the active session id');
|
|
303
|
+
} else {
|
|
304
|
+
await handleChat(chatId, args.join(' '));
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
case '/chat_new':
|
|
308
|
+
await handleChatReset(chatId);
|
|
309
|
+
break;
|
|
310
|
+
case '/chat_session':
|
|
311
|
+
await handleChatStatus(chatId);
|
|
312
|
+
break;
|
|
299
313
|
case '/tunnel':
|
|
300
314
|
await handleTunnelStatus(chatId);
|
|
301
315
|
break;
|
|
@@ -354,6 +368,9 @@ async function sendHelp(chatId: number) {
|
|
|
354
368
|
`🔧 /cancel <id> /retry <id>\n` +
|
|
355
369
|
`/projects — list projects\n` +
|
|
356
370
|
`🤖 /agents — list available agents\n\n` +
|
|
371
|
+
`💬 /chat <msg> — chat with Forge agent\n` +
|
|
372
|
+
`/chat_new — start a fresh chat session\n` +
|
|
373
|
+
`/chat_session — show active session id\n\n` +
|
|
357
374
|
`🌐 /tunnel — status\n` +
|
|
358
375
|
`/tunnel_start / /tunnel_stop\n` +
|
|
359
376
|
`/tunnel_code <admin_pw> — get session code\n\n` +
|
|
@@ -361,6 +378,40 @@ async function sendHelp(chatId: number) {
|
|
|
361
378
|
);
|
|
362
379
|
}
|
|
363
380
|
|
|
381
|
+
async function handleChat(chatId: number, userText: string) {
|
|
382
|
+
try {
|
|
383
|
+
const { runTelegramTurn } = require('./chat/telegram-bridge') as typeof import('./chat/telegram-bridge');
|
|
384
|
+
await runTelegramTurn({
|
|
385
|
+
telegramChatId: chatId,
|
|
386
|
+
userText,
|
|
387
|
+
cb: {
|
|
388
|
+
sendPlaceholder: (text: string) => send(chatId, text),
|
|
389
|
+
editPlaceholder: (mid: number, text: string) => editMessage(chatId, mid, text),
|
|
390
|
+
sendNew: (text: string) => send(chatId, text),
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
} catch (err) {
|
|
394
|
+
console.error('[telegram] /chat failed', err);
|
|
395
|
+
await send(chatId, `✗ chat error: ${(err as Error).message}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function handleChatReset(chatId: number) {
|
|
400
|
+
const { clearTelegramSession } = require('./chat/telegram-bridge') as typeof import('./chat/telegram-bridge');
|
|
401
|
+
clearTelegramSession(chatId);
|
|
402
|
+
await send(chatId, '✓ Chat session cleared. The next /chat starts fresh.');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function handleChatStatus(chatId: number) {
|
|
406
|
+
const { getTelegramSession } = require('./chat/telegram-bridge') as typeof import('./chat/telegram-bridge');
|
|
407
|
+
const id = getTelegramSession(chatId);
|
|
408
|
+
if (!id) {
|
|
409
|
+
await send(chatId, 'No active chat session. /chat <message> creates one.');
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
await send(chatId, `Active session: ${id}\n/chat_new to start fresh`);
|
|
413
|
+
}
|
|
414
|
+
|
|
364
415
|
async function sendAgentList(chatId: number) {
|
|
365
416
|
try {
|
|
366
417
|
const { listAgents, getDefaultAgentId } = require('./agents');
|
|
@@ -1668,6 +1719,24 @@ async function send(chatId: number, text: string): Promise<number | null> {
|
|
|
1668
1719
|
}
|
|
1669
1720
|
}
|
|
1670
1721
|
|
|
1722
|
+
/** Edit a previously-sent message. Failures (rate-limit, message-not-modified) are swallowed. */
|
|
1723
|
+
async function editMessage(chatId: number, messageId: number, text: string): Promise<void> {
|
|
1724
|
+
const settings = loadSettings();
|
|
1725
|
+
if (!settings.telegramBotToken) return;
|
|
1726
|
+
try {
|
|
1727
|
+
await fetch(`https://api.telegram.org/bot${settings.telegramBotToken}/editMessageText`, {
|
|
1728
|
+
method: 'POST',
|
|
1729
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1730
|
+
body: JSON.stringify({
|
|
1731
|
+
chat_id: chatId,
|
|
1732
|
+
message_id: messageId,
|
|
1733
|
+
text,
|
|
1734
|
+
disable_web_page_preview: true,
|
|
1735
|
+
}),
|
|
1736
|
+
});
|
|
1737
|
+
} catch {}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1671
1740
|
/** Delete a message after a delay (seconds) */
|
|
1672
1741
|
function deleteMessageLater(chatId: number, messageId: number, delaySec: number = 30) {
|
|
1673
1742
|
setTimeout(async () => {
|
|
@@ -1691,6 +1760,8 @@ async function setBotCommands(token: string) {
|
|
|
1691
1760
|
headers: { 'Content-Type': 'application/json' },
|
|
1692
1761
|
body: JSON.stringify({
|
|
1693
1762
|
commands: [
|
|
1763
|
+
{ command: 'chat', description: '💬 Chat with Forge agent' },
|
|
1764
|
+
{ command: 'chat_new', description: 'Start a fresh chat session' },
|
|
1694
1765
|
{ command: 'i', description: '🎯 Inject text into a terminal' },
|
|
1695
1766
|
{ command: 'iclear', description: 'Clear inject target' },
|
|
1696
1767
|
{ command: 'sessions', description: 'Session summary (AI)' },
|
|
@@ -116,12 +116,63 @@ function getDefaultCwd(): string {
|
|
|
116
116
|
return homedir();
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/** Recognize the "system out of pty / process slots" family of errors. */
|
|
120
|
+
function isPtyExhaustedError(e: any): boolean {
|
|
121
|
+
const msg = e?.stderr?.toString() || e?.message || '';
|
|
122
|
+
return /posix_spawn|fork failed|EAGAIN|EMFILE|ENFILE|No such file/.test(msg);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Aggressive last-resort cleanup when posix_spawn fails: kill every session
|
|
126
|
+
* with no live WebSocket client, regardless of whether it's "attached" from
|
|
127
|
+
* tmux's POV or appears in saved layout. Saved layout will rebuild lost
|
|
128
|
+
* sessions on the next user reconnect. Workspace agent sessions
|
|
129
|
+
* (`mw-forge-*`) are still preserved — orchestrator owns those. */
|
|
130
|
+
function reapIdleForPtyRecovery(): number {
|
|
131
|
+
const all = listTmuxSessions();
|
|
132
|
+
let killed = 0;
|
|
133
|
+
for (const s of all) {
|
|
134
|
+
if (s.name.startsWith(`${SESSION_PREFIX}forge-`)) continue;
|
|
135
|
+
if ((sessionClients.get(s.name)?.size ?? 0) > 0) continue;
|
|
136
|
+
if (killTmuxSession(s.name)) {
|
|
137
|
+
killed++;
|
|
138
|
+
console.log(`[terminal] PTY-recovery cleanup: killed "${s.name}"`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return killed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Run `tmux new-session` with auto-recovery on PTY exhaustion. Used by both
|
|
145
|
+
* the random-name and fixed-name create paths so neither leaves the user
|
|
146
|
+
* staring at a raw `posix_spawnp failed` error. */
|
|
147
|
+
function tmuxNewSession(name: string, cols: number, rows: number, cwd: string): void {
|
|
148
|
+
const args = `${TMUX} new-session -d -s ${name} -x ${cols} -y ${rows}`;
|
|
149
|
+
const env = { ...process.env, TERM: 'xterm-256color' };
|
|
150
|
+
try {
|
|
151
|
+
execSync(args, { cwd, env });
|
|
152
|
+
} catch (e: any) {
|
|
153
|
+
if (!isPtyExhaustedError(e)) throw e;
|
|
154
|
+
console.error(`[terminal] PTY exhausted, running recovery cleanup…`);
|
|
155
|
+
const killed = reapIdleForPtyRecovery();
|
|
156
|
+
if (killed === 0) {
|
|
157
|
+
throw new Error('Failed to create terminal session: PTY devices exhausted and no idle sessions to reclaim. Try: sudo sysctl kern.tty.ptmx_max=2048');
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
execSync(args, { cwd, env });
|
|
161
|
+
} catch (retryErr: any) {
|
|
162
|
+
if (isPtyExhaustedError(retryErr)) {
|
|
163
|
+
throw new Error('Failed to create terminal session after cleanup. PTY pool still exhausted — try: sudo sysctl kern.tty.ptmx_max=2048');
|
|
164
|
+
}
|
|
165
|
+
throw retryErr;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
119
170
|
function createTmuxSession(cols: number, rows: number): string {
|
|
120
|
-
//
|
|
171
|
+
// Preemptive: if too many sessions, kill the oldest idle ones to stay
|
|
172
|
+
// under the soft cap (separate from PTY-exhaustion recovery).
|
|
121
173
|
const existing = listTmuxSessions();
|
|
122
174
|
if (existing.length >= MAX_SESSIONS) {
|
|
123
175
|
const idle = existing.filter(s => !s.attached);
|
|
124
|
-
// Kill oldest idle sessions to make room
|
|
125
176
|
const toKill = idle.slice(0, Math.max(1, idle.length - Math.floor(MAX_SESSIONS / 2)));
|
|
126
177
|
for (const s of toKill) {
|
|
127
178
|
console.log(`[terminal] Auto-cleanup: killing idle session "${s.name}"`);
|
|
@@ -131,36 +182,7 @@ function createTmuxSession(cols: number, rows: number): string {
|
|
|
131
182
|
|
|
132
183
|
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
133
184
|
const name = `${SESSION_PREFIX}${id}`;
|
|
134
|
-
|
|
135
|
-
execSync(`${TMUX} new-session -d -s ${name} -x ${cols} -y ${rows}`, {
|
|
136
|
-
cwd: getDefaultCwd(),
|
|
137
|
-
env: { ...process.env, TERM: 'xterm-256color' },
|
|
138
|
-
});
|
|
139
|
-
} catch (e: any) {
|
|
140
|
-
const msg = e.stderr?.toString() || e.message || '';
|
|
141
|
-
if (msg.includes('posix_spawn') || msg.includes('fork failed') || msg.includes('No such file')) {
|
|
142
|
-
// PTY exhausted — aggressive cleanup: kill ALL idle sessions
|
|
143
|
-
console.error(`[terminal] PTY exhausted, cleaning up all idle sessions...`);
|
|
144
|
-
const all = listTmuxSessions();
|
|
145
|
-
for (const s of all) {
|
|
146
|
-
if (!s.attached) {
|
|
147
|
-
killTmuxSession(s.name);
|
|
148
|
-
console.log(`[terminal] Killed idle session: ${s.name}`);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
// Retry once
|
|
152
|
-
try {
|
|
153
|
-
execSync(`${TMUX} new-session -d -s ${name} -x ${cols} -y ${rows}`, {
|
|
154
|
-
cwd: getDefaultCwd(),
|
|
155
|
-
env: { ...process.env, TERM: 'xterm-256color' },
|
|
156
|
-
});
|
|
157
|
-
} catch {
|
|
158
|
-
throw new Error('Failed to create terminal session. PTY devices exhausted. Run: sudo sysctl kern.tty.ptmx_max=2048');
|
|
159
|
-
}
|
|
160
|
-
} else {
|
|
161
|
-
throw e;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
185
|
+
tmuxNewSession(name, cols, rows, getDefaultCwd());
|
|
164
186
|
// Mouse and scrollback are set in attachToTmux (always called after create)
|
|
165
187
|
return name;
|
|
166
188
|
}
|
|
@@ -337,10 +359,10 @@ wss.on('connection', (ws: WebSocket) => {
|
|
|
337
359
|
break;
|
|
338
360
|
}
|
|
339
361
|
name = parsed.sessionName;
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
362
|
+
// Goes through the same PTY-exhaustion recovery path as random-name creates,
|
|
363
|
+
// so workspace-agent reconnects and named-tab creates don't fail with raw
|
|
364
|
+
// `posix_spawnp failed` after the server has been running for a long time.
|
|
365
|
+
tmuxNewSession(name, cols, rows, homedir());
|
|
344
366
|
// Mouse and scrollback are set in attachToTmux (always called after create)
|
|
345
367
|
} else {
|
|
346
368
|
name = createTmuxSession(cols, rows);
|
|
@@ -2577,6 +2577,7 @@ Silently ingest this context. Do NOT respond — await an actual task.`;
|
|
|
2577
2577
|
cliCmd = info.cliCmd || 'claude';
|
|
2578
2578
|
cliType = info.cliType || 'claude-code';
|
|
2579
2579
|
supportsSession = info.supportsSession ?? true;
|
|
2580
|
+
if (info.skipPermissionsFlag) skipPermissionsFlag = info.skipPermissionsFlag;
|
|
2580
2581
|
const agents = listAgents();
|
|
2581
2582
|
const agentDef = agents.find((a: any) => a.id === config.agentId);
|
|
2582
2583
|
if (agentDef?.skipPermissionsFlag) skipPermissionsFlag = agentDef.skipPermissionsFlag;
|
package/middleware.ts
CHANGED
|
@@ -24,6 +24,16 @@ export function middleware(req: NextRequest) {
|
|
|
24
24
|
return NextResponse.next();
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// /api/connector-tool — loopback-only, no auth (used by Forge-internal
|
|
28
|
+
// callers: pipelines via curl, jobs scheduler, CLI). Non-loopback hosts
|
|
29
|
+
// fall through to the normal token check.
|
|
30
|
+
if (pathname === '/api/connector-tool') {
|
|
31
|
+
const host = req.headers.get('host') || '';
|
|
32
|
+
if (host.startsWith('127.0.0.1:') || host.startsWith('localhost:')) {
|
|
33
|
+
return NextResponse.next();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
// Check for NextAuth session cookie (browser login)
|
|
28
38
|
const hasSession =
|
|
29
39
|
req.cookies.has('authjs.session-token') ||
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aion0/forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -24,13 +24,14 @@
|
|
|
24
24
|
"multi-model",
|
|
25
25
|
"telegram"
|
|
26
26
|
],
|
|
27
|
-
"author": "
|
|
27
|
+
"author": "",
|
|
28
28
|
"license": "MIT",
|
|
29
29
|
"packageManager": "pnpm@10.32.1",
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@ai-sdk/anthropic": "^3.0.58",
|
|
32
32
|
"@ai-sdk/google": "^3.0.43",
|
|
33
33
|
"@ai-sdk/openai": "^3.0.41",
|
|
34
|
+
"@anthropic-ai/sdk": "^0.96.0",
|
|
34
35
|
"@auth/core": "^0.34.3",
|
|
35
36
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
36
37
|
"@xterm/addon-fit": "^0.11.0",
|
package/scripts/bench/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Compares a single Claude Code run against a Forge multi-smith workspace on the s
|
|
|
13
13
|
|
|
14
14
|
1. **Forge running**: `forge server start` (listening on port 8403)
|
|
15
15
|
2. **Claude Code installed** and authenticated (`claude --version` works)
|
|
16
|
-
3. **harness_test project** exists at
|
|
16
|
+
3. **harness_test project** exists at `~/Projects/sandbox`
|
|
17
17
|
|
|
18
18
|
## Run
|
|
19
19
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# Runs in harness_test project root. Exits 0 = pass, non-zero = fail.
|
|
4
4
|
set -e
|
|
5
5
|
|
|
6
|
-
PROJECT_ROOT="${1
|
|
6
|
+
PROJECT_ROOT="${1:-~/Projects/sandbox}"
|
|
7
7
|
cd "$PROJECT_ROOT/src" || { echo "FAIL: src/ directory not found"; exit 1; }
|
|
8
8
|
|
|
9
9
|
# 1. Check files exist
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Create a date range calculator with 2 bugs.
|
|
3
3
|
set -e
|
|
4
|
-
PROJECT="${1
|
|
4
|
+
PROJECT="${1:-~/Projects/sandbox}"
|
|
5
5
|
mkdir -p "$PROJECT/src/lib" "$PROJECT/src/lib/__tests__"
|
|
6
6
|
|
|
7
7
|
cat > "$PROJECT/src/lib/dateRange.js" <<'EOF'
|
package/src/core/db/database.ts
CHANGED
|
@@ -41,20 +41,29 @@ function initSchema(db: Database.Database) {
|
|
|
41
41
|
try { db.exec("SELECT day FROM token_usage LIMIT 1"); } catch { try { db.exec("DROP TABLE IF EXISTS token_usage"); db.exec("DROP TABLE IF EXISTS usage_scan_state"); } catch {} }
|
|
42
42
|
// Unique index for dedup (only applies when dedup_key is NOT NULL)
|
|
43
43
|
try { db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_pipeline_runs_dedup ON pipeline_runs(project_path, workflow_name, dedup_key)'); } catch {}
|
|
44
|
-
//
|
|
44
|
+
// One-shot migration of old issue_autofix_processed → pipeline_runs.
|
|
45
|
+
// Previously this ran every startup (reading + re-inserting all rows on
|
|
46
|
+
// every cold worker boot — visible as `[db] Migrated N records …`).
|
|
47
|
+
// Now: skip entirely if the source table has zero rows OR if any rows
|
|
48
|
+
// have already been migrated (presence of issue: dedup keys is the
|
|
49
|
+
// tell — INSERT OR IGNORE already protects against dups, the loop
|
|
50
|
+
// itself was just wasted work).
|
|
45
51
|
try {
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
ins.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
const alreadyMigrated = db.prepare("SELECT 1 FROM pipeline_runs WHERE dedup_key LIKE 'issue:%' LIMIT 1").get();
|
|
53
|
+
if (!alreadyMigrated) {
|
|
54
|
+
const old = db.prepare('SELECT * FROM issue_autofix_processed').all() as any[];
|
|
55
|
+
if (old.length > 0) {
|
|
56
|
+
const ins = db.prepare('INSERT OR IGNORE INTO pipeline_runs (id, project_path, workflow_name, pipeline_id, status, dedup_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
|
57
|
+
for (const r of old) {
|
|
58
|
+
ins.run(
|
|
59
|
+
r.pipeline_id?.slice(0, 8) || ('mig-' + r.issue_number),
|
|
60
|
+
r.project_path, 'issue-fix-and-review', r.pipeline_id || '',
|
|
61
|
+
r.status === 'processing' ? 'running' : (r.status || 'done'),
|
|
62
|
+
`issue:${r.issue_number}`, r.created_at || new Date().toISOString()
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
console.log(`[db] Migrated ${old.length} issue_autofix_processed records to pipeline_runs (one-shot)`);
|
|
56
66
|
}
|
|
57
|
-
console.log(`[db] Migrated ${old.length} issue_autofix_processed records to pipeline_runs`);
|
|
58
67
|
}
|
|
59
68
|
} catch {}
|
|
60
69
|
|