@aion0/forge 0.1.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 (80) hide show
  1. package/CLAUDE.md +4 -0
  2. package/README.md +264 -0
  3. package/app/api/auth/[...nextauth]/route.ts +3 -0
  4. package/app/api/claude/[id]/route.ts +31 -0
  5. package/app/api/claude/[id]/stream/route.ts +63 -0
  6. package/app/api/claude/route.ts +28 -0
  7. package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
  8. package/app/api/claude-sessions/[projectName]/route.ts +37 -0
  9. package/app/api/claude-sessions/sync/route.ts +17 -0
  10. package/app/api/flows/route.ts +6 -0
  11. package/app/api/flows/run/route.ts +19 -0
  12. package/app/api/notify/test/route.ts +33 -0
  13. package/app/api/projects/route.ts +7 -0
  14. package/app/api/sessions/[id]/chat/route.ts +64 -0
  15. package/app/api/sessions/[id]/messages/route.ts +9 -0
  16. package/app/api/sessions/[id]/route.ts +17 -0
  17. package/app/api/sessions/route.ts +20 -0
  18. package/app/api/settings/route.ts +15 -0
  19. package/app/api/status/route.ts +12 -0
  20. package/app/api/tasks/[id]/route.ts +36 -0
  21. package/app/api/tasks/[id]/stream/route.ts +77 -0
  22. package/app/api/tasks/link/route.ts +37 -0
  23. package/app/api/tasks/route.ts +43 -0
  24. package/app/api/tasks/session/route.ts +14 -0
  25. package/app/api/templates/route.ts +6 -0
  26. package/app/api/tunnel/route.ts +20 -0
  27. package/app/api/watchers/route.ts +33 -0
  28. package/app/globals.css +26 -0
  29. package/app/icon.svg +26 -0
  30. package/app/layout.tsx +17 -0
  31. package/app/login/page.tsx +61 -0
  32. package/app/page.tsx +9 -0
  33. package/cli/mw.ts +377 -0
  34. package/components/ChatPanel.tsx +191 -0
  35. package/components/ClaudeTerminal.tsx +267 -0
  36. package/components/Dashboard.tsx +270 -0
  37. package/components/MarkdownContent.tsx +57 -0
  38. package/components/NewSessionModal.tsx +93 -0
  39. package/components/NewTaskModal.tsx +456 -0
  40. package/components/ProjectList.tsx +108 -0
  41. package/components/SessionList.tsx +74 -0
  42. package/components/SessionView.tsx +655 -0
  43. package/components/SettingsModal.tsx +366 -0
  44. package/components/StatusBar.tsx +99 -0
  45. package/components/TaskBoard.tsx +110 -0
  46. package/components/TaskDetail.tsx +351 -0
  47. package/components/TunnelToggle.tsx +163 -0
  48. package/components/WebTerminal.tsx +1069 -0
  49. package/docs/LOCAL-DEPLOY.md +144 -0
  50. package/docs/roadmap-multi-agent-workflow.md +330 -0
  51. package/instrumentation.ts +14 -0
  52. package/lib/auth.ts +47 -0
  53. package/lib/claude-process.ts +352 -0
  54. package/lib/claude-sessions.ts +267 -0
  55. package/lib/cloudflared.ts +218 -0
  56. package/lib/flows.ts +86 -0
  57. package/lib/init.ts +82 -0
  58. package/lib/notify.ts +75 -0
  59. package/lib/password.ts +77 -0
  60. package/lib/projects.ts +86 -0
  61. package/lib/session-manager.ts +156 -0
  62. package/lib/session-watcher.ts +345 -0
  63. package/lib/settings.ts +44 -0
  64. package/lib/task-manager.ts +668 -0
  65. package/lib/telegram-bot.ts +912 -0
  66. package/lib/terminal-server.ts +70 -0
  67. package/lib/terminal-standalone.ts +363 -0
  68. package/middleware.ts +33 -0
  69. package/next-env.d.ts +6 -0
  70. package/next.config.ts +16 -0
  71. package/package.json +66 -0
  72. package/postcss.config.mjs +7 -0
  73. package/src/config/index.ts +119 -0
  74. package/src/core/db/database.ts +133 -0
  75. package/src/core/memory/strategy.ts +32 -0
  76. package/src/core/providers/chat.ts +65 -0
  77. package/src/core/providers/registry.ts +60 -0
  78. package/src/core/session/manager.ts +190 -0
  79. package/src/types/index.ts +128 -0
  80. package/tsconfig.json +41 -0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Terminal Server — standalone WebSocket PTY server.
3
+ * Runs on port 3001 alongside the Next.js dev server on 3000.
4
+ */
5
+
6
+ import { WebSocketServer, WebSocket } from 'ws';
7
+ import * as pty from 'node-pty';
8
+ import { homedir } from 'node:os';
9
+
10
+ let wss: WebSocketServer | null = null;
11
+
12
+ export function startTerminalServer(port = 3001) {
13
+ if (wss) return;
14
+
15
+ wss = new WebSocketServer({ port });
16
+ console.log(`[terminal] WebSocket server on ws://localhost:${port}`);
17
+
18
+ wss.on('connection', (ws: WebSocket) => {
19
+ const shell = process.env.SHELL || '/bin/zsh';
20
+ const term = pty.spawn(shell, [], {
21
+ name: 'xterm-256color',
22
+ cols: 120,
23
+ rows: 30,
24
+ cwd: homedir(),
25
+ env: {
26
+ ...process.env,
27
+ TERM: 'xterm-256color',
28
+ COLORTERM: 'truecolor',
29
+ } as Record<string, string>,
30
+ });
31
+
32
+ console.log(`[terminal] New session (pid: ${term.pid})`);
33
+
34
+ term.onData((data: string) => {
35
+ if (ws.readyState === WebSocket.OPEN) {
36
+ ws.send(JSON.stringify({ type: 'output', data }));
37
+ }
38
+ });
39
+
40
+ term.onExit(({ exitCode }) => {
41
+ if (ws.readyState === WebSocket.OPEN) {
42
+ ws.send(JSON.stringify({ type: 'exit', code: exitCode }));
43
+ ws.close();
44
+ }
45
+ });
46
+
47
+ ws.on('message', (msg: Buffer) => {
48
+ try {
49
+ const parsed = JSON.parse(msg.toString());
50
+ if (parsed.type === 'input') {
51
+ term.write(parsed.data);
52
+ } else if (parsed.type === 'resize') {
53
+ term.resize(parsed.cols, parsed.rows);
54
+ }
55
+ } catch {}
56
+ });
57
+
58
+ ws.on('close', () => {
59
+ term.kill();
60
+ console.log(`[terminal] Session closed (pid: ${term.pid})`);
61
+ });
62
+ });
63
+ }
64
+
65
+ export function stopTerminalServer() {
66
+ if (wss) {
67
+ wss.close();
68
+ wss = null;
69
+ }
70
+ }
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Standalone terminal WebSocket server with tmux-backed persistent sessions.
4
+ * Sessions survive browser close and app restart.
5
+ *
6
+ * Protocol:
7
+ * Client → Server:
8
+ * { type: 'create', cols, rows } — create new tmux session
9
+ * { type: 'attach', sessionName, cols, rows } — attach to existing
10
+ * { type: 'list' } — list all mw-* sessions
11
+ * { type: 'input', data } — stdin
12
+ * { type: 'resize', cols, rows } — resize
13
+ * { type: 'kill', sessionName } — kill a session
14
+ * { type: 'load-state' } — load shared terminal state
15
+ * { type: 'save-state', data } — save shared terminal state
16
+ *
17
+ * Server → Client:
18
+ * { type: 'sessions', sessions: [{name, created, attached, windows}] }
19
+ * { type: 'connected', sessionName }
20
+ * { type: 'output', data }
21
+ * { type: 'exit', code }
22
+ * { type: 'terminal-state', data } — loaded state (or null)
23
+ *
24
+ * Usage: npx tsx lib/terminal-standalone.ts
25
+ */
26
+
27
+ import { WebSocketServer, WebSocket } from 'ws';
28
+ import * as pty from 'node-pty';
29
+ import { execSync } from 'node:child_process';
30
+ import { homedir } from 'node:os';
31
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
32
+ import { join } from 'node:path';
33
+
34
+ const PORT = Number(process.env.TERMINAL_PORT) || 3001;
35
+ const SESSION_PREFIX = 'mw-';
36
+
37
+ // Remove CLAUDECODE env so Claude Code can run inside terminal sessions
38
+ delete process.env.CLAUDECODE;
39
+
40
+ // ─── Shared state persistence ─────────────────────────────────
41
+
42
+ const STATE_DIR = join(homedir(), '.my-workflow');
43
+ const STATE_FILE = join(STATE_DIR, 'terminal-state.json');
44
+
45
+ function loadTerminalState(): unknown {
46
+ try {
47
+ return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function saveTerminalState(data: unknown): void {
54
+ try {
55
+ mkdirSync(STATE_DIR, { recursive: true });
56
+ writeFileSync(STATE_FILE, JSON.stringify(data, null, 2));
57
+ } catch (e) {
58
+ console.error('[terminal] Failed to save state:', e);
59
+ }
60
+ }
61
+
62
+ /** Get session names that have custom labels (user-renamed) */
63
+ function getRenamedSessions(): Set<string> {
64
+ try {
65
+ const state = loadTerminalState() as any;
66
+ if (!state?.sessionLabels) return new Set();
67
+ // sessionLabels: { "mw-xxx": "My Custom Name", ... }
68
+ // A session is "renamed" if its label differs from default patterns
69
+ const renamed = new Set<string>();
70
+ for (const [sessionName, label] of Object.entries(state.sessionLabels)) {
71
+ if (label && typeof label === 'string') {
72
+ renamed.add(sessionName);
73
+ }
74
+ }
75
+ return renamed;
76
+ } catch {
77
+ return new Set();
78
+ }
79
+ }
80
+
81
+ // ─── tmux helpers ──────────────────────────────────────────────
82
+
83
+ function tmuxBin(): string {
84
+ try {
85
+ return execSync('which tmux', { encoding: 'utf-8' }).trim();
86
+ } catch {
87
+ return 'tmux';
88
+ }
89
+ }
90
+
91
+ const TMUX = tmuxBin();
92
+
93
+ function listTmuxSessions(): { name: string; created: string; attached: boolean; windows: number }[] {
94
+ try {
95
+ const out = execSync(
96
+ `${TMUX} list-sessions -F "#{session_name}||#{session_created}||#{session_attached}||#{session_windows}" 2>/dev/null`,
97
+ { encoding: 'utf-8' }
98
+ );
99
+ return out
100
+ .trim()
101
+ .split('\n')
102
+ .filter(line => line.startsWith(SESSION_PREFIX))
103
+ .map(line => {
104
+ const [name, created, attached, windows] = line.split('||');
105
+ return {
106
+ name,
107
+ created: new Date(Number(created) * 1000).toISOString(),
108
+ attached: attached !== '0',
109
+ windows: Number(windows) || 1,
110
+ };
111
+ });
112
+ } catch {
113
+ return [];
114
+ }
115
+ }
116
+
117
+ const MAX_SESSIONS = 10;
118
+
119
+ function createTmuxSession(cols: number, rows: number): string {
120
+ // Auto-cleanup: if too many sessions, kill the oldest idle ones
121
+ const existing = listTmuxSessions();
122
+ if (existing.length >= MAX_SESSIONS) {
123
+ const idle = existing.filter(s => !s.attached);
124
+ // Kill oldest idle sessions to make room
125
+ const toKill = idle.slice(0, Math.max(1, idle.length - Math.floor(MAX_SESSIONS / 2)));
126
+ for (const s of toKill) {
127
+ console.log(`[terminal] Auto-cleanup: killing idle session "${s.name}"`);
128
+ killTmuxSession(s.name);
129
+ }
130
+ }
131
+
132
+ const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
133
+ const name = `${SESSION_PREFIX}${id}`;
134
+ execSync(`${TMUX} new-session -d -s ${name} -x ${cols} -y ${rows}`, {
135
+ cwd: homedir(),
136
+ env: { ...process.env, TERM: 'xterm-256color' },
137
+ });
138
+ // Enable mouse scrolling and set large scrollback buffer
139
+ try {
140
+ execSync(`${TMUX} set-option -t ${name} mouse on 2>/dev/null`);
141
+ execSync(`${TMUX} set-option -t ${name} history-limit 50000 2>/dev/null`);
142
+ } catch {}
143
+ return name;
144
+ }
145
+
146
+ function killTmuxSession(name: string): boolean {
147
+ if (!name.startsWith(SESSION_PREFIX)) return false;
148
+ try {
149
+ execSync(`${TMUX} kill-session -t ${name} 2>/dev/null`);
150
+ return true;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ function tmuxSessionExists(name: string): boolean {
157
+ try {
158
+ execSync(`${TMUX} has-session -t ${name} 2>/dev/null`);
159
+ return true;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ // ─── Connection tracking (for orphan cleanup) ──────────────────
166
+
167
+ /** Map from tmux session name → Set of WebSocket clients attached to it */
168
+ const sessionClients = new Map<string, Set<WebSocket>>();
169
+
170
+ /** Map from WebSocket → timestamp when the session was *created* (not attached) by this client */
171
+ const createdAt = new Map<WebSocket, { session: string; time: number }>();
172
+
173
+ function trackAttach(ws: WebSocket, sessionName: string) {
174
+ if (!sessionClients.has(sessionName)) sessionClients.set(sessionName, new Set());
175
+ sessionClients.get(sessionName)!.add(ws);
176
+ }
177
+
178
+ function trackDetach(ws: WebSocket, sessionName: string) {
179
+ sessionClients.get(sessionName)?.delete(ws);
180
+ if (sessionClients.get(sessionName)?.size === 0) sessionClients.delete(sessionName);
181
+ }
182
+
183
+ // ─── Periodic orphan cleanup ─────────────────────────────────
184
+
185
+ /** Clean up detached tmux sessions that no WS client is connected to (skip renamed ones) */
186
+ function cleanupOrphanedSessions() {
187
+ const renamed = getRenamedSessions();
188
+ const sessions = listTmuxSessions();
189
+ for (const s of sessions) {
190
+ if (s.attached) continue;
191
+ if (renamed.has(s.name)) continue; // user renamed — preserve
192
+ const clients = sessionClients.get(s.name)?.size ?? 0;
193
+ if (clients === 0) {
194
+ console.log(`[terminal] Cleanup: killing orphaned session "${s.name}" (no clients, not renamed)`);
195
+ killTmuxSession(s.name);
196
+ }
197
+ }
198
+ }
199
+
200
+ // Run cleanup every 30 seconds
201
+ setInterval(cleanupOrphanedSessions, 30_000);
202
+
203
+ // ─── WebSocket server ──────────────────────────────────────────
204
+
205
+ const wss = new WebSocketServer({ port: PORT });
206
+ console.log(`[terminal] WebSocket server on ws://0.0.0.0:${PORT} (tmux-backed)`);
207
+
208
+ wss.on('connection', (ws: WebSocket) => {
209
+ let term: pty.IPty | null = null;
210
+ let sessionName: string | null = null;
211
+
212
+ function attachToTmux(name: string, cols: number, rows: number) {
213
+ if (!tmuxSessionExists(name)) {
214
+ ws.send(JSON.stringify({ type: 'error', message: `session "${name}" no longer exists` }));
215
+ return;
216
+ }
217
+
218
+ // Ensure mouse and scrollback are enabled (for old sessions too)
219
+ try {
220
+ execSync(`${TMUX} set-option -t ${name} mouse on 2>/dev/null`);
221
+ execSync(`${TMUX} set-option -t ${name} history-limit 50000 2>/dev/null`);
222
+ } catch {}
223
+
224
+ // Detach from previous session if switching
225
+ if (sessionName) trackDetach(ws, sessionName);
226
+ sessionName = name;
227
+ trackAttach(ws, name);
228
+
229
+ // Attach to tmux session via pty
230
+ term = pty.spawn(TMUX, ['attach-session', '-t', name], {
231
+ name: 'xterm-256color',
232
+ cols,
233
+ rows,
234
+ cwd: homedir(),
235
+ env: {
236
+ ...process.env,
237
+ TERM: 'xterm-256color',
238
+ COLORTERM: 'truecolor',
239
+ } as Record<string, string>,
240
+ });
241
+
242
+ console.log(`[terminal] Attached to tmux session "${name}" (pid: ${term.pid})`);
243
+ ws.send(JSON.stringify({ type: 'connected', sessionName: name }));
244
+
245
+ term.onData((data: string) => {
246
+ if (ws.readyState === WebSocket.OPEN) {
247
+ ws.send(JSON.stringify({ type: 'output', data }));
248
+ }
249
+ });
250
+
251
+ term.onExit(({ exitCode }) => {
252
+ if (ws.readyState === WebSocket.OPEN) {
253
+ ws.send(JSON.stringify({ type: 'exit', code: exitCode }));
254
+ }
255
+ term = null;
256
+ });
257
+ }
258
+
259
+ ws.on('message', (msg: Buffer) => {
260
+ try {
261
+ const parsed = JSON.parse(msg.toString());
262
+
263
+ switch (parsed.type) {
264
+ case 'list': {
265
+ const sessions = listTmuxSessions();
266
+ ws.send(JSON.stringify({ type: 'sessions', sessions }));
267
+ break;
268
+ }
269
+
270
+ case 'create': {
271
+ const cols = parsed.cols || 120;
272
+ const rows = parsed.rows || 30;
273
+ try {
274
+ const name = createTmuxSession(cols, rows);
275
+ createdAt.set(ws, { session: name, time: Date.now() });
276
+ attachToTmux(name, cols, rows);
277
+ } catch (e: unknown) {
278
+ const errMsg = e instanceof Error ? e.message : 'unknown error';
279
+ console.error(`[terminal] Failed to create tmux session:`, errMsg);
280
+ ws.send(JSON.stringify({ type: 'error', message: `failed to create session: ${errMsg}` }));
281
+ }
282
+ break;
283
+ }
284
+
285
+ case 'attach': {
286
+ const cols = parsed.cols || 120;
287
+ const rows = parsed.rows || 30;
288
+ try {
289
+ attachToTmux(parsed.sessionName, cols, rows);
290
+ } catch (e: unknown) {
291
+ const errMsg = e instanceof Error ? e.message : 'unknown error';
292
+ console.error(`[terminal] Failed to attach to session:`, errMsg);
293
+ ws.send(JSON.stringify({ type: 'error', message: `failed to attach: ${errMsg}` }));
294
+ }
295
+ break;
296
+ }
297
+
298
+ case 'input': {
299
+ if (term) term.write(parsed.data);
300
+ break;
301
+ }
302
+
303
+ case 'resize': {
304
+ if (term) term.resize(parsed.cols, parsed.rows);
305
+ break;
306
+ }
307
+
308
+ case 'kill': {
309
+ if (parsed.sessionName) {
310
+ killTmuxSession(parsed.sessionName);
311
+ ws.send(JSON.stringify({ type: 'sessions', sessions: listTmuxSessions() }));
312
+ }
313
+ break;
314
+ }
315
+
316
+ case 'load-state': {
317
+ const state = loadTerminalState();
318
+ ws.send(JSON.stringify({ type: 'terminal-state', data: state }));
319
+ break;
320
+ }
321
+
322
+ case 'save-state': {
323
+ if (parsed.data) {
324
+ saveTerminalState(parsed.data);
325
+ }
326
+ break;
327
+ }
328
+ }
329
+ } catch (e) {
330
+ console.error('[terminal] Error handling message:', e);
331
+ try {
332
+ ws.send(JSON.stringify({ type: 'error', message: 'internal server error' }));
333
+ } catch {}
334
+ }
335
+ });
336
+
337
+ ws.on('close', () => {
338
+ // Only kill the pty attach process, NOT the tmux session — it persists
339
+ if (term) {
340
+ term.kill();
341
+ console.log(`[terminal] Detached from tmux session "${sessionName}"`);
342
+ }
343
+
344
+ // Untrack this client
345
+ const disconnectedSession = sessionName;
346
+ if (sessionName) trackDetach(ws, sessionName);
347
+ createdAt.delete(ws);
348
+
349
+ // Grace period cleanup: if no client reconnects within 10s, kill the session (unless renamed)
350
+ if (disconnectedSession) {
351
+ setTimeout(() => {
352
+ const clients = sessionClients.get(disconnectedSession)?.size ?? 0;
353
+ if (clients === 0 && tmuxSessionExists(disconnectedSession)) {
354
+ const renamed = getRenamedSessions();
355
+ if (!renamed.has(disconnectedSession)) {
356
+ console.log(`[terminal] Auto-killing orphaned session "${disconnectedSession}" (no clients after 10s, not renamed)`);
357
+ killTmuxSession(disconnectedSession);
358
+ }
359
+ }
360
+ }, 10_000);
361
+ }
362
+ });
363
+ });
package/middleware.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { NextResponse, type NextRequest } from 'next/server';
2
+
3
+ export function middleware(req: NextRequest) {
4
+ const { pathname } = req.nextUrl;
5
+
6
+ // Allow auth endpoints and static assets without login
7
+ if (
8
+ pathname.startsWith('/login') ||
9
+ pathname.startsWith('/api/auth') ||
10
+ pathname.startsWith('/_next') ||
11
+ pathname === '/favicon.ico'
12
+ ) {
13
+ return NextResponse.next();
14
+ }
15
+
16
+ // Check for NextAuth session cookie (works in Edge Runtime, no Node.js imports)
17
+ const hasSession =
18
+ req.cookies.has('authjs.session-token') ||
19
+ req.cookies.has('__Secure-authjs.session-token');
20
+
21
+ if (!hasSession) {
22
+ if (pathname.startsWith('/api/')) {
23
+ return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
24
+ }
25
+ return NextResponse.redirect(new URL('/login', req.url));
26
+ }
27
+
28
+ return NextResponse.next();
29
+ }
30
+
31
+ export const config = {
32
+ matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
33
+ };
package/next-env.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ import "./.next/dev/types/routes.d.ts";
4
+
5
+ // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
package/next.config.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { NextConfig } from 'next';
2
+
3
+ const nextConfig: NextConfig = {
4
+ serverExternalPackages: ['better-sqlite3'],
5
+ async rewrites() {
6
+ return [
7
+ {
8
+ // Proxy terminal WebSocket through Next.js so it works via Cloudflare Tunnel
9
+ source: '/terminal-ws',
10
+ destination: 'http://localhost:3001',
11
+ },
12
+ ];
13
+ },
14
+ };
15
+
16
+ export default nextConfig;
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@aion0/forge",
3
+ "version": "0.1.0",
4
+ "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "next dev --turbopack",
8
+ "build": "next build",
9
+ "start": "next start",
10
+ "forge": "npx tsx cli/mw.ts"
11
+ },
12
+ "bin": {
13
+ "forge": "./cli/mw.ts",
14
+ "mw": "./cli/mw.ts"
15
+ },
16
+ "keywords": [
17
+ "ai",
18
+ "workflow",
19
+ "claude",
20
+ "task-orchestration",
21
+ "terminal",
22
+ "tmux",
23
+ "multi-model",
24
+ "telegram"
25
+ ],
26
+ "author": "zliu",
27
+ "license": "MIT",
28
+ "packageManager": "pnpm@10.32.1",
29
+ "dependencies": {
30
+ "@ai-sdk/anthropic": "^3.0.58",
31
+ "@ai-sdk/google": "^3.0.43",
32
+ "@ai-sdk/openai": "^3.0.41",
33
+ "@auth/core": "^0.34.3",
34
+ "@xterm/addon-fit": "^0.11.0",
35
+ "@xterm/xterm": "^6.0.0",
36
+ "ai": "^6.0.116",
37
+ "better-sqlite3": "^12.6.2",
38
+ "next": "^16.1.6",
39
+ "next-auth": "5.0.0-beta.30",
40
+ "node-pty": "1.0.0",
41
+ "react": "^19.2.4",
42
+ "react-dom": "^19.2.4",
43
+ "react-markdown": "^10.1.0",
44
+ "ws": "^8.19.0",
45
+ "yaml": "^2.8.2"
46
+ },
47
+ "pnpm": {
48
+ "onlyBuiltDependencies": [
49
+ "better-sqlite3",
50
+ "esbuild",
51
+ "node-pty"
52
+ ]
53
+ },
54
+ "devDependencies": {
55
+ "@tailwindcss/postcss": "^4.2.1",
56
+ "@types/better-sqlite3": "^7.6.13",
57
+ "@types/node": "^25.4.0",
58
+ "@types/react": "^19.2.14",
59
+ "@types/react-dom": "^19.2.3",
60
+ "@types/ws": "^8.18.1",
61
+ "postcss": "^8.5.8",
62
+ "tailwindcss": "^4.2.1",
63
+ "tsx": "^4.21.0",
64
+ "typescript": "^5.9.3"
65
+ }
66
+ }
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
@@ -0,0 +1,119 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import YAML from 'yaml';
5
+ import type { AppConfig, ProviderName, SessionTemplate } from '@/src/types';
6
+
7
+ const CONFIG_DIR = join(homedir(), '.my-workflow');
8
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.yaml');
9
+ const TEMPLATES_DIR = join(CONFIG_DIR, 'templates');
10
+
11
+ export function getConfigDir(): string {
12
+ return CONFIG_DIR;
13
+ }
14
+
15
+ export function getDataDir(): string {
16
+ return join(CONFIG_DIR, 'data');
17
+ }
18
+
19
+ export function getDbPath(): string {
20
+ return join(getDataDir(), 'workflow.db');
21
+ }
22
+
23
+ export function ensureDirs() {
24
+ for (const dir of [CONFIG_DIR, TEMPLATES_DIR, getDataDir()]) {
25
+ if (!existsSync(dir)) {
26
+ mkdirSync(dir, { recursive: true });
27
+ }
28
+ }
29
+ }
30
+
31
+ export function loadConfig(): AppConfig {
32
+ ensureDirs();
33
+
34
+ if (!existsSync(CONFIG_FILE)) {
35
+ const defaults = getDefaultConfig();
36
+ writeFileSync(CONFIG_FILE, YAML.stringify(defaults), 'utf-8');
37
+ return defaults;
38
+ }
39
+
40
+ const raw = readFileSync(CONFIG_FILE, 'utf-8');
41
+ return YAML.parse(raw) as AppConfig;
42
+ }
43
+
44
+ export function saveConfig(config: AppConfig) {
45
+ ensureDirs();
46
+ writeFileSync(CONFIG_FILE, YAML.stringify(config), 'utf-8');
47
+ }
48
+
49
+ export function loadTemplate(templateId: string): SessionTemplate | null {
50
+ const filePath = join(TEMPLATES_DIR, `${templateId}.yaml`);
51
+ if (!existsSync(filePath)) return null;
52
+ const raw = readFileSync(filePath, 'utf-8');
53
+ return YAML.parse(raw) as SessionTemplate;
54
+ }
55
+
56
+ export function loadAllTemplates(): SessionTemplate[] {
57
+ ensureDirs();
58
+ const files = readdirSync(TEMPLATES_DIR).filter(f => f.endsWith('.yaml'));
59
+ return files.map(f => {
60
+ const raw = readFileSync(join(TEMPLATES_DIR, f), 'utf-8');
61
+ return YAML.parse(raw) as SessionTemplate;
62
+ });
63
+ }
64
+
65
+ export function saveTemplate(template: SessionTemplate) {
66
+ ensureDirs();
67
+ const filePath = join(TEMPLATES_DIR, `${template.id}.yaml`);
68
+ writeFileSync(filePath, YAML.stringify(template), 'utf-8');
69
+ }
70
+
71
+ function getDefaultConfig(): AppConfig {
72
+ return {
73
+ dataDir: getDataDir(),
74
+ providers: {
75
+ anthropic: {
76
+ name: 'anthropic',
77
+ displayName: 'Claude',
78
+ defaultModel: 'claude-sonnet-4-6',
79
+ models: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001'],
80
+ enabled: true,
81
+ },
82
+ google: {
83
+ name: 'google',
84
+ displayName: 'Gemini',
85
+ defaultModel: 'gemini-2.0-flash',
86
+ models: ['gemini-2.5-pro', 'gemini-2.0-flash', 'gemini-2.0-flash-lite'],
87
+ enabled: true,
88
+ },
89
+ openai: {
90
+ name: 'openai',
91
+ displayName: 'OpenAI',
92
+ defaultModel: 'gpt-4o-mini',
93
+ models: ['gpt-4o', 'gpt-4o-mini', 'o3-mini'],
94
+ enabled: false,
95
+ },
96
+ grok: {
97
+ name: 'grok',
98
+ displayName: 'Grok',
99
+ defaultModel: 'grok-3-mini-fast',
100
+ models: ['grok-3', 'grok-3-mini-fast'],
101
+ enabled: false,
102
+ },
103
+ },
104
+ server: {
105
+ host: '0.0.0.0',
106
+ port: 3000,
107
+ },
108
+ };
109
+ }
110
+
111
+ export function getProviderApiKey(provider: ProviderName): string | undefined {
112
+ const envMap: Record<ProviderName, string> = {
113
+ anthropic: 'ANTHROPIC_API_KEY',
114
+ google: 'GOOGLE_GENERATIVE_AI_API_KEY',
115
+ openai: 'OPENAI_API_KEY',
116
+ grok: 'XAI_API_KEY',
117
+ };
118
+ return process.env[envMap[provider]];
119
+ }