@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.
Files changed (145) hide show
  1. package/.forge/mcp.json +8 -0
  2. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
  3. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
  4. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
  5. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
  6. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
  7. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
  8. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
  9. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
  10. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
  11. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
  12. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
  13. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
  14. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
  15. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
  16. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
  17. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
  18. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
  19. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
  20. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
  21. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
  22. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
  23. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
  24. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
  25. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
  26. package/CLAUDE.md +2 -2
  27. package/RELEASE_NOTES.md +101 -5
  28. package/app/api/auth/check/route.ts +18 -0
  29. package/app/api/browser-bridge/route.ts +70 -0
  30. package/app/api/chat/sessions/[id]/events/route.ts +17 -0
  31. package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
  32. package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
  33. package/app/api/chat/sessions/[id]/route.ts +23 -0
  34. package/app/api/chat/sessions/route.ts +12 -0
  35. package/app/api/chat/temper-ping/route.ts +18 -0
  36. package/app/api/chat-proxy/[...path]/route.ts +83 -0
  37. package/app/api/connector-tool/route.ts +38 -0
  38. package/app/api/connectors/[id]/settings/route.ts +112 -0
  39. package/app/api/connectors/route.ts +108 -0
  40. package/app/api/health/tools/route.ts +14 -0
  41. package/app/api/issue-scanner-gitlab/route.ts +95 -0
  42. package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
  43. package/app/api/jobs/[id]/route.ts +31 -0
  44. package/app/api/jobs/[id]/run/route.ts +44 -0
  45. package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
  46. package/app/api/jobs/[id]/runs/route.ts +15 -0
  47. package/app/api/jobs/preview/route.ts +193 -0
  48. package/app/api/jobs/route.ts +36 -0
  49. package/app/api/notify/test/route.ts +39 -7
  50. package/app/api/pipelines/[id]/route.ts +10 -1
  51. package/app/api/pipelines/route.ts +16 -2
  52. package/app/api/plugins/route.ts +40 -8
  53. package/app/api/project-sessions/route.ts +50 -10
  54. package/app/api/settings/route.ts +13 -0
  55. package/app/chat/page.tsx +531 -0
  56. package/bin/forge-server.mjs +3 -1
  57. package/cli/chat.ts +283 -0
  58. package/cli/jobs.ts +176 -0
  59. package/cli/mw.ts +28 -1
  60. package/cli/worktree.ts +245 -0
  61. package/components/ConnectorsPanel.tsx +275 -0
  62. package/components/Dashboard.tsx +90 -37
  63. package/components/JobsView.tsx +361 -0
  64. package/components/LogViewer.tsx +12 -2
  65. package/components/PipelineView.tsx +275 -56
  66. package/components/PluginsPanel.tsx +3 -1
  67. package/components/SettingsModal.tsx +229 -40
  68. package/components/SkillsPanel.tsx +12 -4
  69. package/components/TerminalLauncher.tsx +3 -1
  70. package/components/WebTerminal.tsx +32 -9
  71. package/components/WorkspaceView.tsx +18 -10
  72. package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
  73. package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
  74. package/docs/Implementation-Plan-Browser-Agent.md +487 -0
  75. package/docs/Jobs-Design.md +240 -0
  76. package/docs/LOCAL-DEPLOY.md +3 -3
  77. package/docs/RFC-Browser-Connectors.md +509 -0
  78. package/lib/agents/index.ts +44 -6
  79. package/lib/agents/types.ts +1 -1
  80. package/lib/browser-bridge-standalone.ts +317 -0
  81. package/lib/builtin-plugins/github-api.yaml +93 -0
  82. package/lib/builtin-plugins/gitlab.yaml +860 -0
  83. package/lib/builtin-plugins/mantis.probe.js +176 -0
  84. package/lib/builtin-plugins/mantis.yaml +964 -0
  85. package/lib/builtin-plugins/pmdb.yaml +178 -0
  86. package/lib/builtin-plugins/teams.yaml +913 -0
  87. package/lib/chat/__test__/smoke.ts +30 -0
  88. package/lib/chat/agent-loop.ts +523 -0
  89. package/lib/chat/bridge-client.ts +59 -0
  90. package/lib/chat/llm/anthropic.ts +99 -0
  91. package/lib/chat/llm/index.ts +20 -0
  92. package/lib/chat/llm/openai.ts +215 -0
  93. package/lib/chat/llm/types.ts +42 -0
  94. package/lib/chat/local-memory.ts +300 -0
  95. package/lib/chat/memory-store.ts +87 -0
  96. package/lib/chat/memory-tools.ts +157 -0
  97. package/lib/chat/protocols/http.ts +118 -0
  98. package/lib/chat/protocols/shell.ts +101 -0
  99. package/lib/chat/proxy.ts +51 -0
  100. package/lib/chat/session-store.ts +272 -0
  101. package/lib/chat/telegram-bridge.ts +276 -0
  102. package/lib/chat/temper.ts +281 -0
  103. package/lib/chat/tool-dispatcher.ts +190 -0
  104. package/lib/chat/types.ts +50 -0
  105. package/lib/chat-standalone.ts +286 -0
  106. package/lib/crypto.ts +1 -1
  107. package/lib/health.ts +131 -0
  108. package/lib/help-docs/00-overview.md +2 -1
  109. package/lib/help-docs/01-settings.md +46 -25
  110. package/lib/help-docs/07-projects.md +1 -1
  111. package/lib/help-docs/10-troubleshooting.md +10 -2
  112. package/lib/help-docs/16-gitlab-autofix.md +114 -0
  113. package/lib/help-docs/17-connectors.md +322 -0
  114. package/lib/help-docs/18-chrome-mcp.md +134 -0
  115. package/lib/help-docs/19-jobs.md +140 -0
  116. package/lib/help-docs/20-mantis-bug-fix.md +115 -0
  117. package/lib/help-docs/CLAUDE.md +10 -0
  118. package/lib/init.ts +137 -50
  119. package/lib/iso-time.ts +30 -0
  120. package/lib/issue-scanner-gitlab.ts +281 -0
  121. package/lib/jobs/dispatcher.ts +217 -0
  122. package/lib/jobs/scheduler.ts +334 -0
  123. package/lib/jobs/store.ts +319 -0
  124. package/lib/jobs/types.ts +117 -0
  125. package/lib/pipeline-scheduler.ts +1 -6
  126. package/lib/pipeline.ts +790 -10
  127. package/lib/plugins/registry.ts +133 -8
  128. package/lib/plugins/templates.ts +83 -0
  129. package/lib/plugins/types.ts +140 -1
  130. package/lib/session-watcher.ts +36 -10
  131. package/lib/settings.ts +65 -33
  132. package/lib/skills.ts +3 -1
  133. package/lib/task-manager.ts +50 -22
  134. package/lib/telegram-bot.ts +71 -0
  135. package/lib/terminal-standalone.ts +58 -36
  136. package/lib/workspace/orchestrator.ts +1 -0
  137. package/middleware.ts +10 -0
  138. package/package.json +3 -2
  139. package/scripts/bench/README.md +1 -1
  140. package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
  141. package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
  142. package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
  143. package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
  144. package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
  145. package/src/core/db/database.ts +21 -12
@@ -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
- /** Normalize SQLite datetime('now') → ISO 8601 UTC string. */
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
- const child = spawn('bash', ['-c', task.prompt], {
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
- child.stdout.on('data', (chunk: Buffer) => {
374
- const text = chunk.toString();
375
- stdout += text;
376
- appendLog(task.id, { type: 'system', subtype: 'text', content: text, timestamp: new Date().toISOString() });
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', stdout.trim(), task.id);
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', errMsg, task.id);
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 diff = execSync('git diff HEAD', { cwd: task.projectPath, timeout: 5000 }).toString();
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
  }
@@ -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
- // Auto-cleanup: if too many sessions, kill the oldest idle ones
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
- try {
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
- execSync(`${TMUX} new-session -d -s ${name} -x ${cols} -y ${rows}`, {
341
- cwd: homedir(),
342
- env: { ...process.env, TERM: 'xterm-256color' },
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.6.1",
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": "zliu",
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",
@@ -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 `/Users/zliu/IdeaProjects/harness_test`
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:-/Users/zliu/IdeaProjects/harness_test}"
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 basic user list module without pagination.
3
3
  set -e
4
- PROJECT="${1:-/Users/zliu/IdeaProjects/harness_test}"
4
+ PROJECT="${1:-~/Projects/sandbox}"
5
5
  mkdir -p "$PROJECT/src/api"
6
6
 
7
7
  cat > "$PROJECT/src/api/users.js" <<'EOF'
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  set -e
3
- PROJECT="${1:-/Users/zliu/IdeaProjects/harness_test}"
3
+ PROJECT="${1:-~/Projects/sandbox}"
4
4
  cd "$PROJECT/src"
5
5
 
6
6
  [ -f api/users.js ] || { echo "FAIL: api/users.js missing"; exit 1; }
@@ -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:-/Users/zliu/IdeaProjects/harness_test}"
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'
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  set -e
3
- PROJECT="${1:-/Users/zliu/IdeaProjects/harness_test}"
3
+ PROJECT="${1:-~/Projects/sandbox}"
4
4
  cd "$PROJECT/src"
5
5
 
6
6
  [ -f lib/dateRange.js ] || { echo "FAIL: lib/dateRange.js missing (agent deleted it?)"; exit 1; }
@@ -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
- // Migrate old issue_autofix_processed → pipeline_runs
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 old = db.prepare('SELECT * FROM issue_autofix_processed').all() as any[];
47
- if (old.length > 0) {
48
- const ins = db.prepare('INSERT OR IGNORE INTO pipeline_runs (id, project_path, workflow_name, pipeline_id, status, dedup_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
49
- for (const r of old) {
50
- ins.run(
51
- r.pipeline_id?.slice(0, 8) || ('mig-' + r.issue_number),
52
- r.project_path, 'issue-fix-and-review', r.pipeline_id || '',
53
- r.status === 'processing' ? 'running' : (r.status || 'done'),
54
- `issue:${r.issue_number}`, r.created_at || new Date().toISOString()
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