@aion0/forge 0.10.79 → 0.10.80

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/RELEASE_NOTES.md CHANGED
@@ -1,12 +1,15 @@
1
- # Forge v0.10.79
1
+ # Forge v0.10.80
2
2
 
3
3
  Released: 2026-06-14
4
4
 
5
- ## Changes since v0.10.78
5
+ ## Changes since v0.10.79
6
6
 
7
7
  ### Other
8
- - feat(terminal): hide API key env vars from pane echo + container-aware claude auth
9
- - Move file-tree computation server-side (fix browser stack overflow on large repos) (#35)
8
+ - feat(chat): read_project_file + full task output + anti-fabrication guidance
9
+ - feat(pipeline): per-run tmux/headless backend selection
10
+ - feat(tasks): tmux backend — reliable completion detection + interactive session view
11
+ - feat(tasks): show backend + agent badges in TaskDetail header
12
+ - feat(tasks): tmux backend — interactive claude via Stop hook completion detection
10
13
 
11
14
 
12
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.78...v0.10.79
15
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.79...v0.10.80
@@ -0,0 +1,15 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { fireTmuxHook, completeStaleTmuxTask } from '@/lib/task-tmux-backend';
3
+
4
+ // Called by the Claude Code Stop hook when a tmux-backend task turn completes.
5
+ // The hook script (installed in ~/.claude/settings.json) reads task-context.json
6
+ // from the project dir and POSTs here. We resolve the awaited promise in executeTmuxTask.
7
+ // If no waiter exists (server restart mid-task), fall back to directly completing the task.
8
+ export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
9
+ const { id } = await params;
10
+ const handled = fireTmuxHook(id);
11
+ if (!handled) {
12
+ completeStaleTmuxTask(id);
13
+ }
14
+ return NextResponse.json({ ok: true });
15
+ }
@@ -14,7 +14,7 @@ export async function GET(req: Request) {
14
14
 
15
15
  // Create a new task
16
16
  export async function POST(req: Request) {
17
- const { projectName, prompt, priority, newSession, conversationId, scheduledAt, mode, watchConfig, agent } = await req.json();
17
+ const { projectName, prompt, priority, newSession, conversationId, scheduledAt, mode, watchConfig, agent, backend } = await req.json();
18
18
 
19
19
  if (!projectName || !prompt) {
20
20
  return NextResponse.json({ error: 'projectName and prompt are required' }, { status: 400 });
@@ -38,6 +38,7 @@ export async function POST(req: Request) {
38
38
  mode: mode || 'prompt',
39
39
  watchConfig: watchConfig || undefined,
40
40
  agent: agent || undefined,
41
+ backend: backend === 'tmux' ? 'tmux' : undefined,
41
42
  });
42
43
 
43
44
  return NextResponse.json(task);
package/cli/mw.mjs CHANGED
@@ -1352,7 +1352,7 @@ var init_clean = __esm({
1352
1352
 
1353
1353
  // cli/mw.ts
1354
1354
  var _cliPort = process.argv.find((a, i) => i > 0 && process.argv[i - 1] === "--port");
1355
- var BASE2 = process.env.MW_URL || `http://localhost:${_cliPort || "3000"}`;
1355
+ var BASE2 = process.env.MW_URL || `http://localhost:${_cliPort || "8403"}`;
1356
1356
  var [, , cmd, ...args] = process.argv;
1357
1357
  async function checkForUpdate() {
1358
1358
  try {
@@ -1463,19 +1463,21 @@ async function main() {
1463
1463
  case "task":
1464
1464
  case "t": {
1465
1465
  const newSession = args.includes("--new");
1466
- const filtered = args.filter((a) => a !== "--new");
1466
+ const useTmux = args.includes("--tmux");
1467
+ const filtered = args.filter((a) => a !== "--new" && a !== "--tmux");
1467
1468
  const project = filtered[0];
1468
1469
  const prompt = filtered.slice(1).join(" ");
1469
1470
  if (!project || !prompt) {
1470
- console.log("Usage: mw task <project> <prompt> [--new]");
1471
- console.log(" --new Start a fresh session (ignore previous context)");
1471
+ console.log("Usage: mw task <project> <prompt> [--new] [--tmux]");
1472
+ console.log(" --new Start a fresh session (ignore previous context)");
1473
+ console.log(" --tmux Run via tmux backend (interactive mode, subscription billing)");
1472
1474
  console.log('Example: mw task my-app "Fix the login bug"');
1473
1475
  process.exit(1);
1474
1476
  }
1475
1477
  const task = await api3("/api/tasks", {
1476
1478
  method: "POST",
1477
1479
  headers: { "Content-Type": "application/json" },
1478
- body: JSON.stringify({ projectName: project, prompt, newSession })
1480
+ body: JSON.stringify({ projectName: project, prompt, newSession, ...useTmux ? { backend: "tmux" } : {} })
1479
1481
  });
1480
1482
  const session = task.conversationId ? "(continuing session)" : "(new session)";
1481
1483
  console.log(`\u2713 Task ${task.id} created ${session}`);
package/cli/mw.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  const _cliPort = process.argv.find((a, i) => i > 0 && process.argv[i - 1] === '--port');
19
- const BASE = process.env.MW_URL || `http://localhost:${_cliPort || '3000'}`;
19
+ const BASE = process.env.MW_URL || `http://localhost:${_cliPort || '8403'}`;
20
20
 
21
21
  const [, , cmd, ...args] = process.argv;
22
22
 
@@ -149,21 +149,23 @@ async function main() {
149
149
 
150
150
  case 'task':
151
151
  case 't': {
152
- // Parse --new flag to force a fresh session
152
+ // Parse --new and --tmux flags
153
153
  const newSession = args.includes('--new');
154
- const filtered = args.filter(a => a !== '--new');
154
+ const useTmux = args.includes('--tmux');
155
+ const filtered = args.filter(a => a !== '--new' && a !== '--tmux');
155
156
  const project = filtered[0];
156
157
  const prompt = filtered.slice(1).join(' ');
157
158
  if (!project || !prompt) {
158
- console.log('Usage: mw task <project> <prompt> [--new]');
159
- console.log(' --new Start a fresh session (ignore previous context)');
159
+ console.log('Usage: mw task <project> <prompt> [--new] [--tmux]');
160
+ console.log(' --new Start a fresh session (ignore previous context)');
161
+ console.log(' --tmux Run via tmux backend (interactive mode, subscription billing)');
160
162
  console.log('Example: mw task my-app "Fix the login bug"');
161
163
  process.exit(1);
162
164
  }
163
165
  const task = await api('/api/tasks', {
164
166
  method: 'POST',
165
167
  headers: { 'Content-Type': 'application/json' },
166
- body: JSON.stringify({ projectName: project, prompt, newSession }),
168
+ body: JSON.stringify({ projectName: project, prompt, newSession, ...(useTmux ? { backend: 'tmux' } : {}) }),
167
169
  });
168
170
  const session = task.conversationId ? '(continuing session)' : '(new session)';
169
171
  console.log(`✓ Task ${task.id} created ${session}`);
@@ -282,10 +282,14 @@ export default function Dashboard({ user }: { user: any }) {
282
282
  // Listen for open-terminal events from ProjectManager
283
283
  useEffect(() => {
284
284
  const handler = (e: Event) => {
285
- const { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv } = (e as CustomEvent).detail;
285
+ const { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv, tmuxSession, tmuxLabel } = (e as CustomEvent).detail;
286
286
  setViewMode('terminal');
287
287
  setTimeout(() => {
288
- terminalRef.current?.openProjectTerminal?.(projectPath, projectName, agentId, resumeMode, sessionId, profileEnv);
288
+ if (tmuxSession) {
289
+ terminalRef.current?.openExistingSession?.(tmuxSession, tmuxLabel || tmuxSession);
290
+ } else {
291
+ terminalRef.current?.openProjectTerminal?.(projectPath, projectName, agentId, resumeMode, sessionId, profileEnv);
292
+ }
289
293
  }, 300);
290
294
  };
291
295
  window.addEventListener('forge:open-terminal', handler);
@@ -1,10 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useRef, memo, useCallback, useMemo } from 'react';
3
+ import { useState, useEffect, useRef, memo, useCallback, useMemo, lazy, Suspense } from 'react';
4
4
  import MarkdownContent from './MarkdownContent';
5
5
  import NewTaskModal from './NewTaskModal';
6
6
  import type { Task, TaskLogEntry } from '@/src/types';
7
7
 
8
+ const TmuxTaskTerminal = lazy(() => import('./TmuxTaskTerminal'));
9
+
8
10
  // Bound the rendered log/diff to keep React from choking on huge sessions.
9
11
  // Each LogEntry can include MarkdownContent and tool_use payloads (often
10
12
  // kilobytes per entry); rendering even ~200 fat entries can take a beat.
@@ -29,6 +31,7 @@ export default function TaskDetail({
29
31
  const [detailLoading, setDetailLoading] = useState(false);
30
32
  const [loadingMore, setLoadingMore] = useState(false);
31
33
  const [tab, setTab] = useState<'log' | 'diff' | 'result'>('log');
34
+ const [showSession, setShowSession] = useState(false);
32
35
  const [expandedTools, setExpandedTools] = useState<Set<number>>(new Set());
33
36
  const [followUpText, setFollowUpText] = useState('');
34
37
  const [editing, setEditing] = useState(false);
@@ -166,6 +169,15 @@ export default function TaskDetail({
166
169
  <span className="text-[10px] text-[var(--text-secondary)] font-mono">{task.id}</span>
167
170
  </div>
168
171
  <div className="flex items-center gap-2">
172
+ {task.backend === 'tmux' && (
173
+ <button
174
+ onClick={() => setShowSession(s => !s)}
175
+ className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${showSession ? 'text-white bg-violet-500 border-violet-500' : 'text-violet-400 border-violet-400/30 hover:bg-violet-400 hover:text-white'}`}
176
+ title="Toggle tmux session panel"
177
+ >
178
+ ⌨ Session
179
+ </button>
180
+ )}
169
181
  <button onClick={() => setEditing(true)} className="text-[10px] px-2 py-0.5 text-[var(--accent)] border border-[var(--accent)]/30 rounded hover:bg-[var(--accent)] hover:text-white">
170
182
  Edit
171
183
  </button>
@@ -191,6 +203,12 @@ export default function TaskDetail({
191
203
  {task.startedAt && <span>Started: {new Date(task.startedAt).toLocaleString()}</span>}
192
204
  {task.completedAt && <span>Completed: {new Date(task.completedAt).toLocaleString()}</span>}
193
205
  {task.costUSD != null && <span>Cost: ${task.costUSD.toFixed(4)}</span>}
206
+ {task.backend === 'tmux' && (
207
+ <span className="px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400 font-medium">tmux</span>
208
+ )}
209
+ {task.agent && task.agent !== 'claude' && (
210
+ <span className="font-mono text-[var(--text-secondary)]">{task.agent}</span>
211
+ )}
194
212
  </div>
195
213
  </div>
196
214
 
@@ -318,6 +336,15 @@ export default function TaskDetail({
318
336
  </div>
319
337
  )}
320
338
 
339
+ {/* Tmux session panel — shown at the bottom when ⌨ Session is toggled */}
340
+ {showSession && task.backend === 'tmux' && (
341
+ <div className="border-t border-violet-500/30 shrink-0" style={{ height: 320 }}>
342
+ <Suspense fallback={<div className="p-3 text-[var(--text-secondary)] text-xs">Loading terminal…</div>}>
343
+ <TmuxTaskTerminal taskId={task.id} />
344
+ </Suspense>
345
+ </div>
346
+ )}
347
+
321
348
  {editing && (
322
349
  <NewTaskModal
323
350
  editTask={{ id: task.id, projectName: task.projectName, prompt: task.prompt, priority: task.priority, mode: task.mode, scheduledAt: task.scheduledAt }}
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+
5
+ function getWsUrl(): string {
6
+ if (typeof window === 'undefined') return 'ws://localhost:8404';
7
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
8
+ const webPort = parseInt(window.location.port) || 8403;
9
+ return `${proto}//${window.location.hostname}:${webPort + 1}`;
10
+ }
11
+
12
+ export default function TmuxTaskTerminal({ taskId }: { taskId: string }) {
13
+ const containerRef = useRef<HTMLDivElement>(null);
14
+
15
+ useEffect(() => {
16
+ const el = containerRef.current;
17
+ if (!el) return;
18
+
19
+ let disposed = false;
20
+ let termRef: import('@xterm/xterm').Terminal | null = null;
21
+ let wsRef: WebSocket | null = null;
22
+ let roRef: ResizeObserver | null = null;
23
+
24
+ Promise.all([
25
+ import('@xterm/xterm'),
26
+ import('@xterm/addon-fit'),
27
+ ]).then(([{ Terminal }, { FitAddon }]) => {
28
+ if (disposed) return;
29
+
30
+ const cs = getComputedStyle(document.documentElement);
31
+ const tv = (n: string) => cs.getPropertyValue(n).trim();
32
+
33
+ const term = new Terminal({
34
+ cursorBlink: true,
35
+ fontSize: 13,
36
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
37
+ scrollback: 10000,
38
+ theme: {
39
+ background: tv('--term-bg') || '#0d1117',
40
+ foreground: tv('--term-fg') || '#c9d1d9',
41
+ cursor: tv('--term-cursor') || '#7c5bf0',
42
+ },
43
+ });
44
+ termRef = term;
45
+
46
+ const fitAddon = new FitAddon();
47
+ term.loadAddon(fitAddon);
48
+ term.open(el);
49
+ setTimeout(() => { try { fitAddon.fit(); } catch {} }, 50);
50
+
51
+ const ro = new ResizeObserver(() => {
52
+ try { fitAddon.fit(); } catch {}
53
+ if (wsRef?.readyState === WebSocket.OPEN) {
54
+ wsRef.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
55
+ }
56
+ });
57
+ roRef = ro;
58
+ ro.observe(el);
59
+
60
+ const sessionName = `fgt-${taskId}`;
61
+ const ws = new WebSocket(getWsUrl());
62
+ wsRef = ws;
63
+
64
+ ws.onopen = () => {
65
+ if (!disposed) {
66
+ ws.send(JSON.stringify({ type: 'attach', sessionName, cols: term.cols, rows: term.rows }));
67
+ }
68
+ };
69
+ ws.onmessage = (e) => {
70
+ try {
71
+ const msg = JSON.parse(e.data);
72
+ if (msg.type === 'output') term.write(msg.data);
73
+ } catch {}
74
+ };
75
+ ws.onclose = () => {
76
+ if (!disposed) term.write('\r\n\x1b[90m[disconnected — session may have ended]\x1b[0m\r\n');
77
+ };
78
+ ws.onerror = () => {
79
+ if (!disposed) term.write('\r\n\x1b[91m[connection error]\x1b[0m\r\n');
80
+ };
81
+
82
+ term.onData(data => {
83
+ if (wsRef?.readyState === WebSocket.OPEN) {
84
+ wsRef.send(JSON.stringify({ type: 'input', data }));
85
+ }
86
+ });
87
+ });
88
+
89
+ return () => {
90
+ disposed = true;
91
+ roRef?.disconnect();
92
+ wsRef?.close();
93
+ termRef?.dispose();
94
+ };
95
+ }, [taskId]);
96
+
97
+ return (
98
+ <div className="flex flex-col h-full bg-[#0d1117]">
99
+ <div className="text-[10px] text-[var(--text-secondary)] px-3 py-1 border-b border-[var(--border)] shrink-0 font-mono">
100
+ fgt-{taskId}
101
+ </div>
102
+ <div ref={containerRef} className="flex-1 overflow-hidden p-1" />
103
+ </div>
104
+ );
105
+ }
@@ -14,6 +14,7 @@ import '@xterm/xterm/css/xterm.css';
14
14
  export interface WebTerminalHandle {
15
15
  openSessionInTerminal: (sessionId: string, projectPath: string) => void;
16
16
  openProjectTerminal: (projectPath: string, projectName: string, agentId?: string, resumeMode?: boolean, sessionId?: string, profileEnv?: Record<string, string>) => void;
17
+ openExistingSession: (sessionName: string, label: string) => void;
17
18
  }
18
19
 
19
20
  export interface WebTerminalProps {
@@ -501,6 +502,12 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
501
502
  if (targetTabId !== null) setActiveTabId(targetTabId);
502
503
  }, 0);
503
504
  },
505
+ openExistingSession(sessionName: string, label: string) {
506
+ const tree = makeTerminal(sessionName);
507
+ const newTab: TabState = { id: nextId++, label, tree, ratios: {}, activeId: firstTerminalId(tree) };
508
+ setTabs(prev => [...prev, newTab]);
509
+ setTimeout(() => setActiveTabId(newTab.id), 0);
510
+ },
504
511
  }), [skipPermissions]);
505
512
 
506
513
  // ─── Tab operations ───────────────────────────────────