@aion0/forge 0.4.14 → 0.4.16

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/CLAUDE.md CHANGED
@@ -9,7 +9,7 @@ forge server start # production via npm link/install
9
9
  forge server start --dev # dev mode
10
10
  forge server start # background by default, logs to ~/.forge/forge.log
11
11
  forge server start --foreground # foreground mode
12
- forge server stop # stop default instance (port 3000)
12
+ forge server stop # stop default instance (port 8403)
13
13
  forge server stop --port 4000 --dir ~/.forge-staging # stop specific instance
14
14
  forge server restart # stop + start (safe for remote)
15
15
  forge server rebuild # force rebuild
package/README.md CHANGED
@@ -32,7 +32,7 @@ npm install -g @aion0/forge
32
32
  forge server start
33
33
  ```
34
34
 
35
- Open `http://localhost:3000`. First launch prompts you to set an admin password.
35
+ Open `http://localhost:8403`. First launch prompts you to set an admin password.
36
36
 
37
37
  **Requirements:** Node.js ≥ 20, tmux, [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)
38
38
 
package/RELEASE_NOTES.md CHANGED
@@ -1,18 +1,26 @@
1
- # Forge v0.4.14
1
+ # Forge v0.4.16
2
2
 
3
3
  Released: 2026-03-23
4
4
 
5
- ## Changes since v0.4.13
5
+ ## Changes since v0.4.15
6
+
7
+ ### Features
8
+ - feat: change default port from 3000/3001 to 8403/8404
9
+ - feat: prompt to restart Claude after skill/command installation
10
+ - Revert "feat: mobile real-time streaming with tool activity display"
11
+ - feat: mobile real-time streaming with tool activity display
6
12
 
7
13
  ### Bug Fixes
8
- - fix: bell idle timer 10s + 90s fallback
9
- - fix: bell uses idle detection instead of output pattern matching
10
- - fix: bell resets only on Enter key, not every keystroke
11
- - fix: bell cooldown 2min per tab label, prevents duplicate notifications
12
- - fix: bell requires 2000+ bytes of new output before checking markers
13
- - fix: notification timestamps display in correct timezone
14
- - fix: bell fires once per claude task, suppressed on attach/redraw
15
- - fix: bell detects claude completion markers (Cogitated, tokens, prompt)
14
+ - fix: only show port change warning when using default port
15
+ - fix: upgrade Next.js 16.1.6 16.2.1 to fix Turbopack panic
16
+ - fix: start.sh exports PORT=8403 and TERMINAL_PORT=8404
17
+ - fix: default config port 3000 8403
18
+
19
+ ### Documentation
20
+ - docs: update README port 3000 8403
21
+
22
+ ### Other
23
+ - Revert "feat: mobile real-time streaming with tool activity display"
16
24
 
17
25
 
18
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.13...v0.4.14
26
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.15...v0.4.16
@@ -19,6 +19,7 @@ export async function POST(req: Request) {
19
19
 
20
20
  const settings = loadSettings();
21
21
  const claudePath = settings.claudePath || 'claude';
22
+ const projectName = projectPath.split('/').pop() || projectPath;
22
23
 
23
24
  const args = ['-p', '--dangerously-skip-permissions', '--output-format', 'json'];
24
25
  if (resume) args.push('-c');
@@ -34,13 +35,16 @@ export async function POST(req: Request) {
34
35
 
35
36
  const encoder = new TextEncoder();
36
37
  let closed = false;
38
+ let fullOutput = '';
37
39
 
38
40
  const stream = new ReadableStream({
39
41
  start(controller) {
40
42
  child.stdout.on('data', (chunk: Buffer) => {
41
43
  if (closed) return;
44
+ const text = chunk.toString();
45
+ fullOutput += text;
42
46
  try {
43
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'chunk', text: chunk.toString() })}\n\n`));
47
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'chunk', text })}\n\n`));
44
48
  } catch {}
45
49
  });
46
50
 
@@ -56,6 +60,24 @@ export async function POST(req: Request) {
56
60
  child.on('exit', (code) => {
57
61
  if (closed) return;
58
62
  closed = true;
63
+
64
+ // Record usage from the JSON output
65
+ try {
66
+ const parsed = JSON.parse(fullOutput);
67
+ if (parsed.session_id) {
68
+ const { recordUsage } = require('@/lib/usage-scanner');
69
+ recordUsage({
70
+ sessionId: parsed.session_id,
71
+ source: 'mobile',
72
+ projectPath,
73
+ projectName,
74
+ model: parsed.model || 'unknown',
75
+ inputTokens: parsed.usage?.input_tokens || parsed.total_input_tokens || 0,
76
+ outputTokens: parsed.usage?.output_tokens || parsed.total_output_tokens || 0,
77
+ });
78
+ }
79
+ } catch {}
80
+
59
81
  try {
60
82
  controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', code })}\n\n`));
61
83
  controller.close();
@@ -0,0 +1,20 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { scanUsage, queryUsage } from '@/lib/usage-scanner';
3
+
4
+ // GET /api/usage?days=7&project=forge&source=task&model=claude-opus-4
5
+ export async function GET(req: Request) {
6
+ const { searchParams } = new URL(req.url);
7
+ const days = searchParams.get('days') ? parseInt(searchParams.get('days')!) : undefined;
8
+ const projectName = searchParams.get('project') || undefined;
9
+ const source = searchParams.get('source') || undefined;
10
+ const model = searchParams.get('model') || undefined;
11
+
12
+ const data = queryUsage({ days, projectName, source, model });
13
+ return NextResponse.json(data);
14
+ }
15
+
16
+ // POST /api/usage — trigger scan
17
+ export async function POST() {
18
+ const result = scanUsage();
19
+ return NextResponse.json(result);
20
+ }
@@ -9,8 +9,8 @@
9
9
  * forge-server --stop Stop background server
10
10
  * forge-server --restart Stop + start (safe for remote)
11
11
  * forge-server --rebuild Force rebuild
12
- * forge-server --port 4000 Custom web port (default: 3000)
13
- * forge-server --terminal-port 4001 Custom terminal port (default: 3001)
12
+ * forge-server --port 4000 Custom web port (default: 8403)
13
+ * forge-server --terminal-port 4001 Custom terminal port (default: 8404)
14
14
  * forge-server --dir ~/.forge-test Custom data directory (default: ~/.forge)
15
15
  * forge-server --reset-terminal Kill terminal server before start (loses tmux sessions)
16
16
  *
@@ -62,7 +62,7 @@ const isRebuild = process.argv.includes('--rebuild');
62
62
  const resetTerminal = process.argv.includes('--reset-terminal');
63
63
  const resetPassword = process.argv.includes('--reset-password');
64
64
 
65
- const webPort = parseInt(getArg('--port')) || 3000;
65
+ const webPort = parseInt(getArg('--port')) || 8403;
66
66
  const terminalPort = parseInt(getArg('--terminal-port')) || (webPort + 1);
67
67
  const DATA_DIR = getArg('--dir')?.replace(/^~/, homedir()) || join(homedir(), '.forge', 'data');
68
68
 
@@ -379,6 +379,13 @@ function startBackground() {
379
379
  startServices();
380
380
 
381
381
  console.log(`[forge] Started in background (pid ${child.pid})`);
382
+ if (!getArg('--port')) {
383
+ console.log(`[forge] ⚠️ Default port changed: 3000 → ${webPort}`);
384
+ console.log(`[forge] ⚠️ Default port changed: 3000 → ${webPort}`);
385
+ console.log(`[forge] ⚠️ Default port changed: 3000 → ${webPort}`);
386
+ console.log(`[forge] ⚠️ Default port changed: 3000 → ${webPort}`);
387
+ console.log(`[forge] ⚠️ Default port changed: 3000 → ${webPort}`);
388
+ }
382
389
  console.log(`[forge] Web: http://localhost:${webPort}`);
383
390
  console.log(`[forge] Terminal: ws://localhost:${terminalPort}`);
384
391
  console.log(`[forge] Data: ${DATA_DIR}`);
package/cli/mw.ts CHANGED
@@ -562,8 +562,8 @@ Usage:
562
562
  forge uninstall Remove forge
563
563
 
564
564
  Options for 'forge server start':
565
- --port 4000 Custom web port (default: 3000)
566
- --terminal-port 4001 Custom terminal port (default: 3001)
565
+ --port 4000 Custom web port (default: 8403)
566
+ --terminal-port 4001 Custom terminal port (default: 8404)
567
567
  --dir ~/.forge-staging Custom data directory
568
568
  --background Run in background
569
569
  --reset-terminal Kill terminal server on start
@@ -21,6 +21,7 @@ const PipelineView = lazy(() => import('./PipelineView'));
21
21
  const HelpDialog = lazy(() => import('./HelpDialog'));
22
22
  const LogViewer = lazy(() => import('./LogViewer'));
23
23
  const SkillsPanel = lazy(() => import('./SkillsPanel'));
24
+ const UsagePanel = lazy(() => import('./UsagePanel'));
24
25
 
25
26
  interface UsageSummary {
26
27
  provider: string;
@@ -94,7 +95,7 @@ function FloatingBrowser({ onClose }: { onClose: () => void }) {
94
95
  }
95
96
 
96
97
  export default function Dashboard({ user }: { user: any }) {
97
- const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'skills' | 'logs'>('terminal');
98
+ const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'skills' | 'logs' | 'usage'>('terminal');
98
99
  const [browserMode, setBrowserMode] = useState<'none' | 'float' | 'right' | 'left'>('none');
99
100
  const [showBrowserMenu, setShowBrowserMenu] = useState(false);
100
101
  const [browserWidth, setBrowserWidth] = useState(600);
@@ -409,6 +410,14 @@ export default function Dashboard({ user }: { user: any }) {
409
410
  </>
410
411
  )}
411
412
  </div>
413
+ <button
414
+ onClick={() => setViewMode('usage')}
415
+ className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
416
+ viewMode === 'usage'
417
+ ? 'border-[var(--accent)] text-[var(--accent)]'
418
+ : 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
419
+ }`}
420
+ >Usage</button>
412
421
  <TunnelToggle />
413
422
  {onlineCount.total > 0 && (
414
423
  <span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1" title={`${onlineCount.total} online${onlineCount.remote > 0 ? `, ${onlineCount.remote} remote` : ''}`}>
@@ -708,6 +717,13 @@ export default function Dashboard({ user }: { user: any }) {
708
717
  </Suspense>
709
718
  )}
710
719
 
720
+ {/* Usage */}
721
+ {viewMode === 'usage' && (
722
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
723
+ <UsagePanel />
724
+ </Suspense>
725
+ )}
726
+
711
727
  {/* Logs */}
712
728
  {viewMode === 'logs' && (
713
729
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
@@ -8,13 +8,13 @@ import '@xterm/xterm/css/xterm.css';
8
8
  const SESSION_NAME = 'mw-docs-claude';
9
9
 
10
10
  function getWsUrl() {
11
- if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '3001')}`;
11
+ if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '8404')}`;
12
12
  const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
13
13
  const wsHost = window.location.hostname;
14
14
  if (wsHost !== 'localhost' && wsHost !== '127.0.0.1') {
15
15
  return `${wsProtocol}//${window.location.host}/terminal-ws`;
16
16
  }
17
- const webPort = parseInt(window.location.port) || 3000;
17
+ const webPort = parseInt(window.location.port) || 8403;
18
18
  return `${wsProtocol}//${wsHost}:${webPort + 1}`;
19
19
  }
20
20
 
@@ -8,13 +8,13 @@ import '@xterm/xterm/css/xterm.css';
8
8
  const SESSION_NAME = 'mw-forge-help';
9
9
 
10
10
  function getWsUrl() {
11
- if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '3001')}`;
11
+ if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '8404')}`;
12
12
  const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
13
13
  const wsHost = window.location.hostname;
14
14
  if (wsHost !== 'localhost' && wsHost !== '127.0.0.1') {
15
15
  return `${wsProtocol}//${window.location.host}/terminal-ws`;
16
16
  }
17
- const webPort = parseInt(window.location.port) || 3000;
17
+ const webPort = parseInt(window.location.port) || 8403;
18
18
  return `${wsProtocol}//${wsHost}:${webPort + 1}`;
19
19
  }
20
20
 
@@ -149,6 +149,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
149
149
  });
150
150
  setInstallTarget({ skill: '', show: false });
151
151
  fetchSkills();
152
+ alert(`"${name}" installed. Restart Claude in terminal to apply.`);
152
153
  };
153
154
 
154
155
  const toggleDetail = async (name: string) => {
@@ -497,6 +498,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
497
498
  body: JSON.stringify({ action: 'install-local', name: itemName, type: localItem.type, sourceProject: localItem.projectPath, target: 'global', force: true }) });
498
499
  const data = await res.json();
499
500
  if (!data.ok) alert(data.error);
501
+ else alert(`"${itemName}" installed globally. Restart Claude to apply.`);
500
502
  setInstallTarget({ skill: '', show: false });
501
503
  fetchSkills();
502
504
  }}
@@ -511,6 +513,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
511
513
  body: JSON.stringify({ action: 'install-local', name: itemName, type: localItem.type, sourceProject: localItem.projectPath, target: p.path, force: true }) });
512
514
  const data = await res.json();
513
515
  if (!data.ok) alert(data.error);
516
+ else alert(`"${itemName}" installed to ${p.name}. Restart Claude to apply.`);
514
517
  setInstallTarget({ skill: '', show: false });
515
518
  fetchSkills();
516
519
  }}
@@ -579,6 +582,12 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
579
582
  </div>}
580
583
  </div>
581
584
  <p className="text-[10px] text-[var(--text-secondary)] mt-0.5">{skill?.description || ''}</p>
585
+ {skill?.author && (
586
+ <div className="text-[9px] text-[var(--text-secondary)] mt-1">By {skill.author}</div>
587
+ )}
588
+ {skill?.sourceUrl && (
589
+ <a href={skill.sourceUrl} target="_blank" rel="noopener noreferrer" className="text-[9px] text-[var(--accent)] hover:underline mt-0.5 block truncate">{skill.sourceUrl.replace(/^https?:\/\//, '').slice(0, 60)}</a>
590
+ )}
582
591
  {/* Installed indicators */}
583
592
  {skill && isInstalled && (
584
593
  <div className="flex items-center gap-2 mt-1">
@@ -0,0 +1,207 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+
5
+ interface UsageData {
6
+ total: { input: number; output: number; cost: number; sessions: number; messages: number };
7
+ byProject: { name: string; input: number; output: number; cost: number; sessions: number }[];
8
+ byModel: { model: string; input: number; output: number; cost: number; messages: number }[];
9
+ byDay: { date: string; input: number; output: number; cost: number }[];
10
+ bySource: { source: string; input: number; output: number; cost: number; messages: number }[];
11
+ }
12
+
13
+ function formatTokens(n: number): string {
14
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
15
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
16
+ return String(n);
17
+ }
18
+
19
+ function formatCost(n: number): string {
20
+ return `$${n.toFixed(2)}`;
21
+ }
22
+
23
+ // Simple bar component
24
+ function Bar({ value, max, color }: { value: number; max: number; color: string }) {
25
+ const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
26
+ return (
27
+ <div className="h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden flex-1">
28
+ <div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
29
+ </div>
30
+ );
31
+ }
32
+
33
+ export default function UsagePanel() {
34
+ const [data, setData] = useState<UsageData | null>(null);
35
+ const [days, setDays] = useState(7);
36
+ const [scanning, setScanning] = useState(false);
37
+ const [loading, setLoading] = useState(true);
38
+
39
+ const fetchData = useCallback(async () => {
40
+ setLoading(true);
41
+ try {
42
+ const res = await fetch(`/api/usage${days ? `?days=${days}` : ''}`);
43
+ const d = await res.json();
44
+ setData(d);
45
+ } catch {}
46
+ setLoading(false);
47
+ }, [days]);
48
+
49
+ useEffect(() => { fetchData(); }, [fetchData]);
50
+
51
+ const triggerScan = async () => {
52
+ setScanning(true);
53
+ try {
54
+ await fetch('/api/usage', { method: 'POST' });
55
+ await fetchData();
56
+ } catch {}
57
+ setScanning(false);
58
+ };
59
+
60
+ if (loading && !data) {
61
+ return <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading usage data...</div>;
62
+ }
63
+
64
+ if (!data) {
65
+ return <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)] text-xs">Failed to load usage data</div>;
66
+ }
67
+
68
+ const maxProjectCost = data.byProject.length > 0 ? data.byProject[0].cost : 1;
69
+ const maxDayCost = data.byDay.length > 0 ? Math.max(...data.byDay.map(d => d.cost)) : 1;
70
+
71
+ return (
72
+ <div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
73
+ {/* Header */}
74
+ <div className="px-4 py-3 border-b border-[var(--border)] shrink-0 flex items-center gap-3">
75
+ <h2 className="text-sm font-semibold text-[var(--text-primary)]">Token Usage</h2>
76
+ <div className="flex items-center gap-1 ml-auto">
77
+ {[7, 30, 90, 0].map(d => (
78
+ <button
79
+ key={d}
80
+ onClick={() => setDays(d)}
81
+ className={`text-[10px] px-2 py-0.5 rounded ${days === d ? 'bg-[var(--accent)] text-white' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
82
+ >
83
+ {d === 0 ? 'All' : `${d}d`}
84
+ </button>
85
+ ))}
86
+ </div>
87
+ <button
88
+ onClick={triggerScan}
89
+ disabled={scanning}
90
+ className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
91
+ >
92
+ {scanning ? 'Scanning...' : 'Scan Now'}
93
+ </button>
94
+ </div>
95
+
96
+ <div className="p-4 space-y-6">
97
+ {/* Summary cards */}
98
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
99
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
100
+ <div className="text-[9px] text-[var(--text-secondary)] uppercase">Total Cost</div>
101
+ <div className="text-lg font-bold text-[var(--text-primary)]">{formatCost(data.total.cost)}</div>
102
+ </div>
103
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
104
+ <div className="text-[9px] text-[var(--text-secondary)] uppercase">Input Tokens</div>
105
+ <div className="text-lg font-bold text-[var(--text-primary)]">{formatTokens(data.total.input)}</div>
106
+ </div>
107
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
108
+ <div className="text-[9px] text-[var(--text-secondary)] uppercase">Output Tokens</div>
109
+ <div className="text-lg font-bold text-[var(--text-primary)]">{formatTokens(data.total.output)}</div>
110
+ </div>
111
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
112
+ <div className="text-[9px] text-[var(--text-secondary)] uppercase">Sessions</div>
113
+ <div className="text-lg font-bold text-[var(--text-primary)]">{data.total.sessions}</div>
114
+ <div className="text-[9px] text-[var(--text-secondary)]">{data.total.messages} messages</div>
115
+ </div>
116
+ </div>
117
+
118
+ {/* By Day — bar chart */}
119
+ {data.byDay.length > 0 && (
120
+ <div>
121
+ <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Daily Cost</h3>
122
+ <div className="space-y-1">
123
+ {data.byDay.slice(0, 14).reverse().map(d => (
124
+ <div key={d.date} className="flex items-center gap-2 text-[10px]">
125
+ <span className="text-[var(--text-secondary)] w-16 shrink-0">{d.date.slice(5)}</span>
126
+ <Bar value={d.cost} max={maxDayCost} color="bg-[var(--accent)]" />
127
+ <span className="text-[var(--text-primary)] w-16 text-right shrink-0">{formatCost(d.cost)}</span>
128
+ </div>
129
+ ))}
130
+ </div>
131
+ </div>
132
+ )}
133
+
134
+ {/* By Project */}
135
+ {data.byProject.length > 0 && (
136
+ <div>
137
+ <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Project</h3>
138
+ <div className="space-y-1.5">
139
+ {data.byProject.map(p => (
140
+ <div key={p.name} className="flex items-center gap-2 text-[10px]">
141
+ <span className="text-[var(--text-primary)] w-28 truncate shrink-0" title={p.name}>{p.name}</span>
142
+ <Bar value={p.cost} max={maxProjectCost} color="bg-blue-500" />
143
+ <span className="text-[var(--text-primary)] w-16 text-right shrink-0">{formatCost(p.cost)}</span>
144
+ <span className="text-[var(--text-secondary)] w-12 text-right shrink-0">{p.sessions}s</span>
145
+ </div>
146
+ ))}
147
+ </div>
148
+ </div>
149
+ )}
150
+
151
+ {/* By Model */}
152
+ {data.byModel.length > 0 && (
153
+ <div>
154
+ <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Model</h3>
155
+ <div className="border border-[var(--border)] rounded overflow-hidden">
156
+ <table className="w-full text-[10px]">
157
+ <thead>
158
+ <tr className="bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
159
+ <th className="text-left px-3 py-1.5">Model</th>
160
+ <th className="text-right px-3 py-1.5">Input</th>
161
+ <th className="text-right px-3 py-1.5">Output</th>
162
+ <th className="text-right px-3 py-1.5">Cost</th>
163
+ <th className="text-right px-3 py-1.5">Msgs</th>
164
+ </tr>
165
+ </thead>
166
+ <tbody>
167
+ {data.byModel.map(m => (
168
+ <tr key={m.model} className="border-t border-[var(--border)]/30">
169
+ <td className="px-3 py-1.5 text-[var(--text-primary)]">{m.model}</td>
170
+ <td className="px-3 py-1.5 text-right text-[var(--text-secondary)]">{formatTokens(m.input)}</td>
171
+ <td className="px-3 py-1.5 text-right text-[var(--text-secondary)]">{formatTokens(m.output)}</td>
172
+ <td className="px-3 py-1.5 text-right text-[var(--text-primary)] font-medium">{formatCost(m.cost)}</td>
173
+ <td className="px-3 py-1.5 text-right text-[var(--text-secondary)]">{m.messages}</td>
174
+ </tr>
175
+ ))}
176
+ </tbody>
177
+ </table>
178
+ </div>
179
+ </div>
180
+ )}
181
+
182
+ {/* By Source */}
183
+ {data.bySource.length > 0 && (
184
+ <div>
185
+ <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Source</h3>
186
+ <div className="flex gap-3 flex-wrap">
187
+ {data.bySource.map(s => (
188
+ <div key={s.source} className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-3 py-2 min-w-[120px]">
189
+ <div className="text-[9px] text-[var(--text-secondary)] uppercase">{s.source}</div>
190
+ <div className="text-sm font-bold text-[var(--text-primary)]">{formatCost(s.cost)}</div>
191
+ <div className="text-[9px] text-[var(--text-secondary)]">{s.messages} msgs</div>
192
+ </div>
193
+ ))}
194
+ </div>
195
+ </div>
196
+ )}
197
+
198
+ {/* Note */}
199
+ <div className="text-[9px] text-[var(--text-secondary)] border-t border-[var(--border)] pt-3">
200
+ Cost estimates based on API pricing (Opus: $15/$75 per M tokens, Sonnet: $3/$15).
201
+ Actual cost may differ with Claude Max/Pro subscription.
202
+ Daily breakdown groups by session completion date.
203
+ </div>
204
+ </div>
205
+ </div>
206
+ );
207
+ }
@@ -43,7 +43,7 @@ interface TabState {
43
43
  // ─── Layout persistence ──────────────────────────────────────
44
44
 
45
45
  function getWsUrl() {
46
- if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '3001')}`;
46
+ if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '8404')}`;
47
47
  const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
48
48
  const wsHost = window.location.hostname;
49
49
  // When accessed via tunnel or non-localhost, use the Next.js proxy path
@@ -51,7 +51,7 @@ function getWsUrl() {
51
51
  return `${wsProtocol}//${window.location.host}/terminal-ws`;
52
52
  }
53
53
  // Terminal port = web port + 1
54
- const webPort = parseInt(window.location.port) || 3000;
54
+ const webPort = parseInt(window.location.port) || 8403;
55
55
  return `${wsProtocol}//${wsHost}:${webPort + 1}`;
56
56
  }
57
57
 
@@ -8,13 +8,13 @@
8
8
 
9
9
  ### 方案对比
10
10
 
11
- | 方案 | 原理 | 优点 | 缺点 |
12
- |------|------|------|------|
13
- | **同一 WiFi 局域网** | Mac 开 Web 服务,手机直接访问 `192.168.x.x:3000` | 零配置、零成本 | 只能在家用 |
14
- | **Tailscale** | 虚拟局域网,任何网络下设备互通 | 免费、安全、无需公网 IP | 需要装客户端 |
15
- | **Cloudflare Tunnel** | 免费内网穿透,给你一个公网域名 | 免费、HTTPS、不开端口 | 依赖 Cloudflare |
16
- | **ngrok** | 临时隧道 | 一行命令搞定 | 免费版地址每次变 |
17
- | **frp** | 自建内网穿透 | 完全自控 | 需要一台有公网 IP 的服务器 |
11
+ | 方案 | 原理 | 优点 | 缺点 |
12
+ |------|----------------------------------------|------|------|
13
+ | **同一 WiFi 局域网** | Mac 开 Web 服务,手机直接访问 `192.168.x.x:8403` | 零配置、零成本 | 只能在家用 |
14
+ | **Tailscale** | 虚拟局域网,任何网络下设备互通 | 免费、安全、无需公网 IP | 需要装客户端 |
15
+ | **Cloudflare Tunnel** | 免费内网穿透,给你一个公网域名 | 免费、HTTPS、不开端口 | 依赖 Cloudflare |
16
+ | **ngrok** | 临时隧道 | 一行命令搞定 | 免费版地址每次变 |
17
+ | **frp** | 自建内网穿透 | 完全自控 | 需要一台有公网 IP 的服务器 |
18
18
 
19
19
  ### 推荐组合:Tailscale(推荐) + 局域网(备选)
20
20
 
@@ -23,9 +23,9 @@
23
23
  │ 你的 Mac │
24
24
  │ │
25
25
  │ my-workflow server │
26
- │ ├── REST API :3000
27
- │ ├── WebSocket :3000/ws │
28
- │ └── Dashboard :3000
26
+ │ ├── REST API :8403
27
+ │ ├── WebSocket :8403/ws │
28
+ │ └── Dashboard :8403
29
29
  │ │
30
30
  │ Tailscale IP: 100.x.x.x │
31
31
  │ 局域网 IP: 192.168.x.x │
@@ -36,8 +36,8 @@
36
36
  ┌──────────┴──────────────────────┐
37
37
  │ 你的手机 │
38
38
  │ │
39
- │ 浏览器 → 100.x.x.x:3000 │ ← 任何网络下都能访问
40
- │ 或 Safari → 192.168.x.x:3000 │ ← 同一 WiFi 下
39
+ │ 浏览器 → 100.x.x.x:8403 │ ← 任何网络下都能访问
40
+ │ 或 Safari → 192.168.x.x:8403 │ ← 同一 WiFi 下
41
41
  │ │
42
42
  └─────────────────────────────────┘
43
43
  ```
@@ -67,7 +67,7 @@ tailscale ip -4
67
67
 
68
68
  # 5. 启动 my-workflow
69
69
  mw server start
70
- # → Dashboard: http://100.64.x.x:3000
70
+ # → Dashboard: http://100.64.x.x:8403
71
71
 
72
72
  # 手机浏览器打开这个地址即可
73
73
  ```
@@ -133,10 +133,10 @@ launchctl list | grep my-workflow
133
133
 
134
134
  ```
135
135
  本地开发阶段:
136
- 手机 → Tailscale → Mac:3000
136
+ 手机 → Tailscale → Mac:8403
137
137
 
138
138
  迁移到云端:
139
- 手机 → Tailscale → VPS:3000 (只是 IP 变了)
139
+ 手机 → Tailscale → VPS:8403 (只是 IP 变了)
140
140
 
141
141
  手机 → https://workflow.yourdomain.com (Cloudflare Tunnel)
142
142
  ```
@@ -169,7 +169,7 @@ function pushLog(line: string) {
169
169
  if (state.log.length > MAX_LOG_LINES) state.log.shift();
170
170
  }
171
171
 
172
- export async function startTunnel(localPort: number = parseInt(process.env.PORT || '3000')): Promise<{ url?: string; error?: string }> {
172
+ export async function startTunnel(localPort: number = parseInt(process.env.PORT || '8403')): Promise<{ url?: string; error?: string }> {
173
173
  console.log(`[tunnel] Starting tunnel on port ${localPort}...`);
174
174
  // Prevent concurrent starts: state.process is already spawned, or another call is
175
175
  // mid-flight between the guard and spawn (the async download window).
@@ -9,7 +9,7 @@ npm install -g @aion0/forge
9
9
  forge server start
10
10
  ```
11
11
 
12
- Open `http://localhost:3000`. First launch prompts you to set an admin password.
12
+ Open `http://localhost:8403`. First launch prompts you to set an admin password.
13
13
 
14
14
  ## Requirements
15
15
  - Node.js >= 20
@@ -260,25 +260,25 @@ Schedule options: Manual only, 15min, 30min, 1h, 2h, 6h, 12h, 24h.
260
260
 
261
261
  ```bash
262
262
  # List bindings + runs + workflows for a project
263
- curl "http://localhost:3000/api/project-pipelines?project=/path/to/project"
263
+ curl "http://localhost:8403/api/project-pipelines?project=/path/to/project"
264
264
 
265
265
  # Add binding
266
- curl -X POST http://localhost:3000/api/project-pipelines \
266
+ curl -X POST http://localhost:8403/api/project-pipelines \
267
267
  -H 'Content-Type: application/json' \
268
268
  -d '{"action":"add","projectPath":"/path","projectName":"my-app","workflowName":"issue-fix-and-review"}'
269
269
 
270
270
  # Update binding (enable/disable, change config/schedule)
271
- curl -X POST http://localhost:3000/api/project-pipelines \
271
+ curl -X POST http://localhost:8403/api/project-pipelines \
272
272
  -H 'Content-Type: application/json' \
273
273
  -d '{"action":"update","projectPath":"/path","workflowName":"issue-fix-and-review","config":{"interval":30}}'
274
274
 
275
275
  # Trigger pipeline manually
276
- curl -X POST http://localhost:3000/api/project-pipelines \
276
+ curl -X POST http://localhost:8403/api/project-pipelines \
277
277
  -H 'Content-Type: application/json' \
278
278
  -d '{"action":"trigger","projectPath":"/path","projectName":"my-app","workflowName":"issue-fix-and-review","input":{"issue_id":"42"}}'
279
279
 
280
280
  # Remove binding
281
- curl -X POST http://localhost:3000/api/project-pipelines \
281
+ curl -X POST http://localhost:8403/api/project-pipelines \
282
282
  -H 'Content-Type: application/json' \
283
283
  -d '{"action":"remove","projectPath":"/path","workflowName":"issue-fix-and-review"}'
284
284
  ```
@@ -303,7 +303,7 @@ To create a workflow via Help AI: ask "Create a pipeline that does X" — the AI
303
303
  ## Creating Workflows via API
304
304
 
305
305
  ```bash
306
- curl -X POST http://localhost:3000/api/pipelines \
306
+ curl -X POST http://localhost:8403/api/pipelines \
307
307
  -H 'Content-Type: application/json' \
308
308
  -d '{"action": "save-workflow", "yaml": "<yaml content>"}'
309
309
  ```