@aion0/forge 0.4.9 → 0.4.11

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.
@@ -15,8 +15,6 @@ export interface WebTerminalHandle {
15
15
  export interface WebTerminalProps {
16
16
  onActiveSession?: (sessionName: string | null) => void;
17
17
  onCodeOpenChange?: (open: boolean) => void;
18
- browserOpen?: boolean;
19
- onBrowserToggle?: () => void;
20
18
  }
21
19
 
22
20
  // ─── Types ───────────────────────────────────────────────────
@@ -166,7 +164,7 @@ let globalDragging = false;
166
164
 
167
165
  // ─── Main component ─────────────────────────────────────────
168
166
 
169
- const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, onCodeOpenChange, browserOpen, onBrowserToggle }, ref) {
167
+ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, onCodeOpenChange }, ref) {
170
168
  const [tabs, setTabs] = useState<TabState[]>(() => {
171
169
  const tree = makeTerminal();
172
170
  return [{ id: nextId++, label: 'Terminal 1', tree, ratios: {}, activeId: firstTerminalId(tree) }];
@@ -478,6 +476,15 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
478
476
  });
479
477
  }, [activeTab, updateActiveTab]);
480
478
 
479
+ const closePaneById = useCallback((id: number) => {
480
+ updateActiveTab(t => {
481
+ if (countTerminals(t.tree) <= 1) return t;
482
+ const newTree = removeNodeById(t.tree, id) || t.tree;
483
+ const newActiveId = t.activeId === id ? firstTerminalId(newTree) : t.activeId;
484
+ return { ...t, tree: newTree, activeId: newActiveId };
485
+ });
486
+ }, [updateActiveTab]);
487
+
481
488
  const setActiveId = useCallback((id: number) => {
482
489
  updateActiveTab(t => ({ ...t, activeId: id }));
483
490
  }, [updateActiveTab]);
@@ -636,20 +643,6 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
636
643
  Code
637
644
  </button>
638
645
  )}
639
- {onBrowserToggle && (
640
- <button
641
- onClick={onBrowserToggle}
642
- className={`text-[11px] px-3 py-1 rounded font-bold ${browserOpen ? 'text-white bg-blue-500 hover:bg-blue-400' : 'text-blue-400 border border-blue-500 hover:bg-blue-500 hover:text-white'}`}
643
- title={browserOpen ? 'Close browser' : 'Open browser'}
644
- >
645
- Browser
646
- </button>
647
- )}
648
- {activeTab && countTerminals(activeTab.tree) > 1 && (
649
- <button onClick={onClosePane} className="text-[10px] px-2 py-0.5 text-[var(--accent)] hover:text-red-400 hover:bg-[var(--term-border)] rounded font-medium">
650
- Close Pane
651
- </button>
652
- )}
653
646
  </div>
654
647
  </div>
655
648
 
@@ -876,6 +869,8 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
876
869
  onSessionConnected={onSessionConnected}
877
870
  refreshKeys={refreshKeys}
878
871
  skipPermissions={skipPermissions}
872
+ canClose={countTerminals(tab.tree) > 1}
873
+ onClosePane={tab.id === activeTabId ? closePaneById : undefined}
879
874
  />
880
875
  </div>
881
876
  ))}
@@ -888,7 +883,7 @@ export default WebTerminal;
888
883
  // ─── Pane renderer ───────────────────────────────────────────
889
884
 
890
885
  function PaneRenderer({
891
- node, activeId, onFocus, ratios, setRatios, onSessionConnected, refreshKeys, skipPermissions,
886
+ node, activeId, onFocus, ratios, setRatios, onSessionConnected, refreshKeys, skipPermissions, canClose, onClosePane,
892
887
  }: {
893
888
  node: SplitNode;
894
889
  activeId: number;
@@ -898,11 +893,20 @@ function PaneRenderer({
898
893
  onSessionConnected: (paneId: number, sessionName: string) => void;
899
894
  refreshKeys: Record<number, number>;
900
895
  skipPermissions?: boolean;
896
+ canClose?: boolean;
897
+ onClosePane?: (id: number) => void;
901
898
  }) {
902
899
  if (node.type === 'terminal') {
903
900
  return (
904
- <div className={`h-full w-full ${activeId === node.id ? 'ring-1 ring-[#7c5bf0]/50 ring-inset' : ''}`} onMouseDown={() => onFocus(node.id)}>
901
+ <div className={`h-full w-full relative group/pane ${activeId === node.id ? 'ring-1 ring-[#7c5bf0]/50 ring-inset' : ''}`} onMouseDown={() => onFocus(node.id)}>
905
902
  <MemoTerminalPane key={`${node.id}-${refreshKeys[node.id] || 0}`} id={node.id} sessionName={node.sessionName} projectPath={node.projectPath} skipPermissions={skipPermissions} onSessionConnected={onSessionConnected} />
903
+ {canClose && onClosePane && (
904
+ <button
905
+ onClick={(e) => { e.stopPropagation(); if (confirm('Close this pane?')) onClosePane(node.id); }}
906
+ className="absolute top-1.5 right-1.5 z-10 w-6 h-6 flex items-center justify-center rounded bg-red-500/80 text-white hover:bg-red-500 opacity-0 group-hover/pane:opacity-100 transition-opacity text-xs font-bold shadow"
907
+ title="Close this pane"
908
+ >✕</button>
909
+ )}
906
910
  </div>
907
911
  );
908
912
  }
@@ -911,8 +915,8 @@ function PaneRenderer({
911
915
 
912
916
  return (
913
917
  <DraggableSplit splitId={node.id} direction={node.direction} ratio={ratio} setRatios={setRatios}>
914
- <PaneRenderer node={node.first} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} />
915
- <PaneRenderer node={node.second} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} />
918
+ <PaneRenderer node={node.first} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} canClose={canClose} onClosePane={onClosePane} />
919
+ <PaneRenderer node={node.second} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} canClose={canClose} onClosePane={onClosePane} />
916
920
  </DraggableSplit>
917
921
  );
918
922
  }
@@ -1198,12 +1202,29 @@ const MemoTerminalPane = memo(function TerminalPane({
1198
1202
  // Auto-run claude for project tabs (only if no pendingCommand already set)
1199
1203
  if (isNewlyCreated && projectPathRef.current && !pendingCommands.has(id)) {
1200
1204
  isNewlyCreated = false;
1201
- setTimeout(() => {
1202
- if (!disposed && ws?.readyState === WebSocket.OPEN) {
1205
+ // Check if project has existing claude sessions to decide -c flag
1206
+ const pp = projectPathRef.current;
1207
+ const pName = pp.replace(/\/+$/, '').split('/').pop() || '';
1208
+ fetch(`/api/claude-sessions/${encodeURIComponent(pName)}`)
1209
+ .then(r => r.json())
1210
+ .then(sData => {
1211
+ const hasSession = Array.isArray(sData) ? sData.length > 0 : false;
1212
+ const resumeFlag = hasSession ? ' -c' : '';
1203
1213
  const skipFlag = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
1204
- ws.send(JSON.stringify({ type: 'input', data: `cd "${projectPathRef.current}" && claude${skipFlag}\n` }));
1205
- }
1206
- }, 300);
1214
+ setTimeout(() => {
1215
+ if (!disposed && ws?.readyState === WebSocket.OPEN) {
1216
+ ws.send(JSON.stringify({ type: 'input', data: `cd "${pp}" && claude${resumeFlag}${skipFlag}\n` }));
1217
+ }
1218
+ }, 300);
1219
+ })
1220
+ .catch(() => {
1221
+ const skipFlag = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
1222
+ setTimeout(() => {
1223
+ if (!disposed && ws?.readyState === WebSocket.OPEN) {
1224
+ ws.send(JSON.stringify({ type: 'input', data: `cd "${pp}" && claude${skipFlag}\n` }));
1225
+ }
1226
+ }, 300);
1227
+ });
1207
1228
  }
1208
1229
  isNewlyCreated = false;
1209
1230
  // Force tmux to redraw by toggling size, then send reset
@@ -257,8 +257,8 @@ export function tailSessionFile(
257
257
 
258
258
  watcher.on('error', (err) => onError?.(err));
259
259
 
260
- // Poll every 5 seconds as fallback
261
- const pollTimer = setInterval(readNewBytes, 5000);
260
+ // Poll every 1 second as fallback (fs.watch is unreliable on macOS)
261
+ const pollTimer = setInterval(readNewBytes, 1000);
262
262
 
263
263
  return () => {
264
264
  watcher.close();
package/lib/init.ts CHANGED
@@ -82,11 +82,28 @@ export function ensureInitialized() {
82
82
  // Auto-detect claude path if not configured
83
83
  autoDetectClaude();
84
84
 
85
+ // Sync help docs + CLAUDE.md to data dir on startup
86
+ try {
87
+ const { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } = require('node:fs');
88
+ const { join: joinPath } = require('node:path');
89
+ const { getConfigDir, getDataDir } = require('./dirs');
90
+ const helpDir = joinPath(getConfigDir(), 'help');
91
+ const sourceDir = joinPath(process.cwd(), 'lib', 'help-docs');
92
+ if (existsSync(sourceDir)) {
93
+ if (!existsSync(helpDir)) mkdirSync(helpDir, { recursive: true });
94
+ for (const f of readdirSync(sourceDir)) {
95
+ if (f.endsWith('.md')) writeFileSync(joinPath(helpDir, f), readFileSync(joinPath(sourceDir, f)));
96
+ }
97
+ const claudeMd = joinPath(helpDir, 'CLAUDE.md');
98
+ if (existsSync(claudeMd)) writeFileSync(joinPath(getDataDir(), 'CLAUDE.md'), readFileSync(claudeMd));
99
+ }
100
+ } catch {}
101
+
85
102
  // Sync skills registry (async, non-blocking) — on startup + every 30 min
86
103
  try {
87
104
  const { syncSkills } = require('./skills');
88
105
  syncSkills().catch(() => {});
89
- setInterval(() => { syncSkills().catch(() => {}); }, 30 * 60 * 1000);
106
+ setInterval(() => { syncSkills().catch(() => {}); }, 60 * 60 * 1000);
90
107
  } catch {}
91
108
 
92
109
  // Task runner is safe in every worker (DB-level coordination)
@@ -150,9 +150,11 @@ export function deleteRun(id: string): void {
150
150
 
151
151
  function isDuplicate(projectPath: string, workflowName: string, dedupKey: string): boolean {
152
152
  const row = db().prepare(
153
- 'SELECT 1 FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key = ?'
154
- ).get(projectPath, workflowName, dedupKey);
155
- return !!row;
153
+ 'SELECT status FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key = ? ORDER BY created_at DESC LIMIT 1'
154
+ ).get(projectPath, workflowName, dedupKey) as { status: string } | undefined;
155
+ if (!row) return false;
156
+ // Failed runs are not duplicates — allow retry
157
+ return row.status !== 'failed';
156
158
  }
157
159
 
158
160
  export function resetDedup(projectPath: string, workflowName: string, dedupKey: string): void {
@@ -222,7 +224,7 @@ function fetchOpenIssues(projectPath: string, labels: string[]): { number: numbe
222
224
  if (!repo) return [{ number: -1, title: '', error: 'Could not detect GitHub repo. Run: gh auth login' }];
223
225
  try {
224
226
  const labelFilter = labels.length > 0 ? ` --label "${labels.join(',')}"` : '';
225
- const out = execSync(`gh issue list --state open --json number,title${labelFilter} -R ${repo}`, {
227
+ const out = execSync(`gh issue list --state open --limit 30 --json number,title${labelFilter} -R ${repo}`, {
226
228
  cwd: projectPath, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
227
229
  });
228
230
  return JSON.parse(out) || [];
@@ -246,11 +248,18 @@ export function scanAndTriggerIssues(binding: ProjectPipelineBinding): { trigger
246
248
  const recentRuns = getRuns(binding.projectPath, binding.workflowName, 5);
247
249
  const hasRunning = recentRuns.some(r => r.status === 'running');
248
250
 
251
+ // Batch dedup check — one query instead of N
252
+ const processedKeys = new Set(
253
+ (db().prepare(
254
+ 'SELECT dedup_key FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key IS NOT NULL AND status != ?'
255
+ ).all(binding.projectPath, binding.workflowName, 'failed') as { dedup_key: string }[])
256
+ .map(r => r.dedup_key)
257
+ );
258
+
249
259
  const newIssues: { number: number; title: string }[] = [];
250
260
  for (const issue of issues) {
251
261
  if (issue.number < 0) continue;
252
- const dedupKey = `issue:${issue.number}`;
253
- if (!isDuplicate(binding.projectPath, binding.workflowName, dedupKey)) {
262
+ if (!processedKeys.has(`issue:${issue.number}`)) {
254
263
  newIssues.push(issue);
255
264
  }
256
265
  }
@@ -269,6 +278,9 @@ export function scanAndTriggerIssues(binding: ProjectPipelineBinding): { trigger
269
278
 
270
279
  const issue = newIssues[0];
271
280
  const dedupKey = `issue:${issue.number}`;
281
+ // Remove old failed run so new dedup_key insert won't conflict
282
+ db().prepare('DELETE FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key = ? AND status = ?')
283
+ .run(binding.projectPath, binding.workflowName, dedupKey, 'failed');
272
284
  try {
273
285
  triggerPipeline(
274
286
  binding.projectPath, binding.projectName, binding.workflowName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,167 +0,0 @@
1
- 'use client';
2
-
3
- import { useState, useEffect, useCallback } from 'react';
4
-
5
- interface PreviewEntry {
6
- port: number;
7
- url: string | null;
8
- status: string;
9
- label?: string;
10
- }
11
-
12
- export default function PreviewPanel() {
13
- const [previews, setPreviews] = useState<PreviewEntry[]>([]);
14
- const [inputPort, setInputPort] = useState('');
15
- const [inputLabel, setInputLabel] = useState('');
16
- const [starting, setStarting] = useState(false);
17
- const [error, setError] = useState('');
18
- const [activePreview, setActivePreview] = useState<number | null>(null);
19
- const [isRemote, setIsRemote] = useState(false);
20
-
21
- useEffect(() => {
22
- setIsRemote(!['localhost', '127.0.0.1'].includes(window.location.hostname));
23
- }, []);
24
-
25
- const fetchPreviews = useCallback(async () => {
26
- try {
27
- const res = await fetch('/api/preview');
28
- const data = await res.json();
29
- if (Array.isArray(data)) setPreviews(data);
30
- } catch {}
31
- }, []);
32
-
33
- useEffect(() => {
34
- fetchPreviews();
35
- const timer = setInterval(fetchPreviews, 5000);
36
- return () => clearInterval(timer);
37
- }, [fetchPreviews]);
38
-
39
- const handleStart = async () => {
40
- const p = parseInt(inputPort);
41
- if (!p || p < 1 || p > 65535) { setError('Invalid port'); return; }
42
- setError('');
43
- setStarting(true);
44
- try {
45
- const res = await fetch('/api/preview', {
46
- method: 'POST',
47
- headers: { 'Content-Type': 'application/json' },
48
- body: JSON.stringify({ action: 'start', port: p, label: inputLabel || undefined }),
49
- });
50
- const data = await res.json();
51
- if (data.error) setError(data.error);
52
- else {
53
- setInputPort('');
54
- setInputLabel('');
55
- setActivePreview(p);
56
- }
57
- fetchPreviews();
58
- } catch { setError('Failed'); }
59
- setStarting(false);
60
- };
61
-
62
- const handleStop = async (port: number) => {
63
- await fetch('/api/preview', {
64
- method: 'POST',
65
- headers: { 'Content-Type': 'application/json' },
66
- body: JSON.stringify({ action: 'stop', port }),
67
- });
68
- if (activePreview === port) setActivePreview(null);
69
- fetchPreviews();
70
- };
71
-
72
- const active = previews.find(p => p.port === activePreview);
73
- const previewSrc = active
74
- ? (isRemote ? active.url : `http://localhost:${active.port}`)
75
- : null;
76
-
77
- return (
78
- <div className="flex-1 flex flex-col min-h-0">
79
- {/* Top bar */}
80
- <div className="px-4 py-2 border-b border-[var(--border)] shrink-0 space-y-2">
81
- {/* Preview list */}
82
- <div className="flex items-center gap-2 flex-wrap">
83
- <span className="text-[11px] font-semibold text-[var(--text-primary)]">Demo Preview</span>
84
- {previews.map(p => (
85
- <div key={p.port} className="flex items-center gap-1">
86
- <button
87
- onClick={() => setActivePreview(p.port)}
88
- className={`text-[10px] px-2 py-0.5 rounded ${activePreview === p.port ? 'bg-[var(--accent)] text-white' : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
89
- >
90
- <span className={`mr-1 ${p.status === 'running' ? 'text-green-400' : 'text-gray-500'}`}>●</span>
91
- {p.label || `:${p.port}`}
92
- </button>
93
- {p.url && (
94
- <button
95
- onClick={() => navigator.clipboard.writeText(p.url!)}
96
- className="text-[8px] text-green-400 hover:text-green-300 truncate max-w-[150px]"
97
- title={`Copy: ${p.url}`}
98
- >
99
- {p.url.replace('https://', '').slice(0, 20)}...
100
- </button>
101
- )}
102
- <button
103
- onClick={() => handleStop(p.port)}
104
- className="text-[9px] text-red-400 hover:text-red-300"
105
- >
106
- x
107
- </button>
108
- </div>
109
- ))}
110
- </div>
111
-
112
- {/* Add new */}
113
- <div className="flex items-center gap-2">
114
- <input
115
- type="number"
116
- value={inputPort}
117
- onChange={e => setInputPort(e.target.value)}
118
- onKeyDown={e => e.key === 'Enter' && handleStart()}
119
- placeholder="Port"
120
- className="w-20 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] font-mono"
121
- />
122
- <input
123
- value={inputLabel}
124
- onChange={e => setInputLabel(e.target.value)}
125
- onKeyDown={e => e.key === 'Enter' && handleStart()}
126
- placeholder="Label (optional)"
127
- className="w-32 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
128
- />
129
- <button
130
- onClick={handleStart}
131
- disabled={!inputPort || starting}
132
- className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
133
- >
134
- {starting ? 'Starting...' : '+ Add'}
135
- </button>
136
- {active && (
137
- <a
138
- href={previewSrc || '#'}
139
- target="_blank"
140
- rel="noopener"
141
- className="text-[10px] text-[var(--accent)] hover:underline ml-auto"
142
- >
143
- Open ↗
144
- </a>
145
- )}
146
- {error && <span className="text-[10px] text-red-400">{error}</span>}
147
- </div>
148
- </div>
149
-
150
- {/* Preview iframe */}
151
- {previewSrc && active?.status === 'running' ? (
152
- <iframe
153
- src={previewSrc}
154
- className="flex-1 w-full border-0 bg-white"
155
- title="Preview"
156
- />
157
- ) : (
158
- <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
159
- <div className="text-center space-y-3 max-w-md">
160
- <p className="text-sm">{previews.length > 0 ? 'Select a preview to display' : 'Preview local dev servers'}</p>
161
- <p className="text-xs">Enter a port, add a label, and click Add. Each preview gets its own Cloudflare Tunnel URL.</p>
162
- </div>
163
- </div>
164
- )}
165
- </div>
166
- );
167
- }