@aion0/forge 0.5.49 → 0.5.50
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 +48 -7
- package/app/api/craft-system/build/route.ts +78 -0
- package/app/api/craft-system/delete/route.ts +28 -0
- package/app/api/craft-system/helpers/file/route.ts +20 -0
- package/app/api/craft-system/helpers/openapi/route.ts +27 -0
- package/app/api/craft-system/helpers/shell/route.ts +26 -0
- package/app/api/craft-system/inject/route.ts +41 -0
- package/app/api/craft-system/kill-session/route.ts +19 -0
- package/app/api/craft-system/manifest/route.ts +71 -0
- package/app/api/craft-system/marketplace/install/route.ts +11 -0
- package/app/api/craft-system/marketplace/route.ts +18 -0
- package/app/api/craft-system/marketplace/uninstall/route.ts +11 -0
- package/app/api/craft-system/marketplace/update/route.ts +10 -0
- package/app/api/craft-system/marketplace/updates/route.ts +17 -0
- package/app/api/craft-system/publish/auto/route.ts +173 -0
- package/app/api/craft-system/publish/route.ts +50 -0
- package/app/api/craft-system/registry/route.ts +16 -0
- package/app/api/craft-system/runtime/react/route.ts +26 -0
- package/app/api/craft-system/runtime/react-jsx/route.ts +11 -0
- package/app/api/craft-system/runtime/sdk/route.ts +18 -0
- package/app/api/craft-system/scaffold/route.ts +164 -0
- package/app/api/craft-system/sessions/route.ts +45 -0
- package/app/api/craft-system/storage/route.ts +44 -0
- package/app/api/craft-system/tmux-sessions/route.ts +62 -0
- package/app/api/craft-system/ui/route.ts +30 -0
- package/app/api/crafts/[name]/[...route]/route.ts +48 -0
- package/app/api/crafts/route.ts +29 -0
- package/components/CraftBuilder.tsx +241 -0
- package/components/CraftManifestEditor.tsx +258 -0
- package/components/CraftMarketplaceModal.tsx +207 -0
- package/components/CraftPublishModal.tsx +285 -0
- package/components/CraftTabs.tsx +279 -0
- package/components/CraftTerminal.tsx +305 -0
- package/components/CraftTerminalPicker.tsx +179 -0
- package/components/CraftsDropdown.tsx +186 -0
- package/components/CraftsMarketplacePanel.tsx +194 -0
- package/components/ProjectDetail.tsx +105 -1
- package/components/SkillsPanel.tsx +12 -4
- package/components/TaskDetail.tsx +49 -1
- package/lib/craft-sdk/client.tsx +260 -0
- package/lib/craft-sdk/server.ts +14 -0
- package/lib/crafts/loader.ts +117 -0
- package/lib/crafts/registry.ts +272 -0
- package/lib/crafts/runtime.ts +208 -0
- package/lib/crafts/types.ts +92 -0
- package/lib/forge-skills/craft-builder.md +231 -0
- package/lib/help-docs/15-crafts.md +127 -0
- package/lib/help-docs/CLAUDE.md +2 -0
- package/lib/terminal-standalone.ts +1 -0
- package/next.config.ts +1 -1
- package/package.json +2 -1
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Renders a single craft as a tab. Lazy-imports the craft's transpiled UI module.
|
|
4
|
+
|
|
5
|
+
import React, { useEffect, useState, useRef, lazy, Suspense, useCallback } from 'react';
|
|
6
|
+
import * as ReactJsxRuntime from 'react/jsx-runtime';
|
|
7
|
+
import { CraftSDKProvider, getSDK, setGlobalToast } from '@/lib/craft-sdk/client';
|
|
8
|
+
|
|
9
|
+
const CraftTerminalLazy = lazy(() => import('./CraftTerminal'));
|
|
10
|
+
const CraftTerminalPickerLazy = lazy(() => import('./CraftTerminalPicker'));
|
|
11
|
+
|
|
12
|
+
interface CraftTermChoice {
|
|
13
|
+
agentId: string;
|
|
14
|
+
resumeSessionId?: string;
|
|
15
|
+
}
|
|
16
|
+
function termChoiceKey(projectPath: string, craftName: string): string {
|
|
17
|
+
return `forge.craft.term.${projectPath}::${craftName}`;
|
|
18
|
+
}
|
|
19
|
+
function termOpenKey(projectPath: string, craftName: string): string {
|
|
20
|
+
return `forge.craft.term-open.${projectPath}::${craftName}`;
|
|
21
|
+
}
|
|
22
|
+
function loadTermChoice(projectPath: string, craftName: string): CraftTermChoice | null {
|
|
23
|
+
if (typeof window === 'undefined') return null;
|
|
24
|
+
try {
|
|
25
|
+
const raw = localStorage.getItem(termChoiceKey(projectPath, craftName));
|
|
26
|
+
return raw ? JSON.parse(raw) : null;
|
|
27
|
+
} catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
function saveTermChoice(projectPath: string, craftName: string, c: CraftTermChoice) {
|
|
30
|
+
try { localStorage.setItem(termChoiceKey(projectPath, craftName), JSON.stringify(c)); } catch {}
|
|
31
|
+
}
|
|
32
|
+
function loadTermOpen(projectPath: string, craftName: string): boolean {
|
|
33
|
+
if (typeof window === 'undefined') return false;
|
|
34
|
+
return localStorage.getItem(termOpenKey(projectPath, craftName)) === '1';
|
|
35
|
+
}
|
|
36
|
+
function saveTermOpen(projectPath: string, craftName: string, open: boolean) {
|
|
37
|
+
try {
|
|
38
|
+
if (open) localStorage.setItem(termOpenKey(projectPath, craftName), '1');
|
|
39
|
+
else localStorage.removeItem(termOpenKey(projectPath, craftName));
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CraftSummary {
|
|
44
|
+
name: string;
|
|
45
|
+
displayName: string;
|
|
46
|
+
icon?: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
scope: 'builtin' | 'project';
|
|
49
|
+
hasUi: boolean;
|
|
50
|
+
hasServer: boolean;
|
|
51
|
+
dir?: string;
|
|
52
|
+
preferredSessionName?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Install the host's React + JSX runtime on window so craft modules can grab them.
|
|
56
|
+
let runtimeInstalled = false;
|
|
57
|
+
function installRuntime() {
|
|
58
|
+
if (runtimeInstalled || typeof window === 'undefined') return;
|
|
59
|
+
(window as any).__forge_react = React;
|
|
60
|
+
(window as any).__forge_jsx = ReactJsxRuntime;
|
|
61
|
+
(window as any).__forge_sdk = getSDK();
|
|
62
|
+
// Minimal toast — DOM injected so crafts get something instead of console.log.
|
|
63
|
+
setGlobalToast((msg, kind = 'info') => {
|
|
64
|
+
const el = document.createElement('div');
|
|
65
|
+
const colors = kind === 'error' ? 'background:#7f1d1d;color:#fecaca' : kind === 'success' ? 'background:#064e3b;color:#a7f3d0' : 'background:#1f2937;color:#e5e7eb';
|
|
66
|
+
el.setAttribute('style', `position:fixed;top:1rem;left:50%;transform:translateX(-50%);${colors};padding:6px 14px;border-radius:6px;font-size:12px;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,.4);`);
|
|
67
|
+
el.textContent = msg;
|
|
68
|
+
document.body.appendChild(el);
|
|
69
|
+
setTimeout(() => el.remove(), 2500);
|
|
70
|
+
});
|
|
71
|
+
runtimeInstalled = true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface Props {
|
|
75
|
+
craft: CraftSummary;
|
|
76
|
+
projectPath: string;
|
|
77
|
+
projectName: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Persisted split ratio (UI height fraction; rest is terminal)
|
|
81
|
+
const SPLIT_KEY = 'forge.craft.split';
|
|
82
|
+
const DEFAULT_SPLIT = 0.6;
|
|
83
|
+
|
|
84
|
+
export function CraftTab({ craft, projectPath, projectName }: Props) {
|
|
85
|
+
const [Comp, setComp] = useState<React.ComponentType | null>(null);
|
|
86
|
+
const [error, setError] = useState<string | null>(null);
|
|
87
|
+
const [reloadTick, setReloadTick] = useState(0);
|
|
88
|
+
const [split, setSplit] = useState<number>(() => {
|
|
89
|
+
if (typeof window === 'undefined') return DEFAULT_SPLIT;
|
|
90
|
+
const v = parseFloat(localStorage.getItem(SPLIT_KEY) || '');
|
|
91
|
+
return isFinite(v) && v > 0.15 && v < 0.95 ? v : DEFAULT_SPLIT;
|
|
92
|
+
});
|
|
93
|
+
// Terminal panel state — persisted per craft so toggling tabs/closing browser
|
|
94
|
+
// doesn't lose what the user had open.
|
|
95
|
+
const [termChoice, setTermChoice] = useState<CraftTermChoice | null>(() => loadTermChoice(projectPath, craft.name));
|
|
96
|
+
const [showTerm, setShowTermState] = useState<boolean>(() => {
|
|
97
|
+
// If we have a saved choice + previously was open, restore. Otherwise hidden.
|
|
98
|
+
return loadTermChoice(projectPath, craft.name) != null && loadTermOpen(projectPath, craft.name);
|
|
99
|
+
});
|
|
100
|
+
const setShowTerm = useCallback((v: boolean) => {
|
|
101
|
+
setShowTermState(v);
|
|
102
|
+
saveTermOpen(projectPath, craft.name, v);
|
|
103
|
+
}, [projectPath, craft.name]);
|
|
104
|
+
const [pickerOpen, setPickerOpen] = useState<boolean>(false);
|
|
105
|
+
const mountedRef = useRef(true);
|
|
106
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
107
|
+
const draggingRef = useRef(false);
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
mountedRef.current = true;
|
|
111
|
+
installRuntime();
|
|
112
|
+
let cancelled = false;
|
|
113
|
+
(async () => {
|
|
114
|
+
try {
|
|
115
|
+
const url = `/api/craft-system/ui?projectPath=${encodeURIComponent(projectPath)}&name=${encodeURIComponent(craft.name)}&t=${Date.now()}`;
|
|
116
|
+
// eslint-disable-next-line @next/next/no-assign-module-variable
|
|
117
|
+
const mod = await import(/* webpackIgnore: true */ url);
|
|
118
|
+
if (cancelled) return;
|
|
119
|
+
const C = mod.default || mod.Tab;
|
|
120
|
+
if (!C) throw new Error(`Craft ${craft.name} did not export a default component`);
|
|
121
|
+
setComp(() => C);
|
|
122
|
+
setError(null);
|
|
123
|
+
} catch (e: any) {
|
|
124
|
+
if (cancelled) return;
|
|
125
|
+
setError(e?.message || String(e));
|
|
126
|
+
}
|
|
127
|
+
})();
|
|
128
|
+
return () => { cancelled = true; mountedRef.current = false; };
|
|
129
|
+
}, [craft.name, projectPath, reloadTick]);
|
|
130
|
+
|
|
131
|
+
const onDragStart = useCallback((e: React.MouseEvent) => {
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
draggingRef.current = true;
|
|
134
|
+
const onMove = (ev: MouseEvent) => {
|
|
135
|
+
if (!draggingRef.current || !containerRef.current) return;
|
|
136
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
137
|
+
const ratio = (ev.clientY - rect.top) / rect.height;
|
|
138
|
+
const clamped = Math.max(0.15, Math.min(0.9, ratio));
|
|
139
|
+
setSplit(clamped);
|
|
140
|
+
};
|
|
141
|
+
const onUp = () => {
|
|
142
|
+
draggingRef.current = false;
|
|
143
|
+
document.removeEventListener('mousemove', onMove);
|
|
144
|
+
document.removeEventListener('mouseup', onUp);
|
|
145
|
+
try { localStorage.setItem(SPLIT_KEY, String(split)); } catch {}
|
|
146
|
+
};
|
|
147
|
+
document.addEventListener('mousemove', onMove);
|
|
148
|
+
document.addEventListener('mouseup', onUp);
|
|
149
|
+
}, [split]);
|
|
150
|
+
|
|
151
|
+
// Persist on change
|
|
152
|
+
useEffect(() => { try { localStorage.setItem(SPLIT_KEY, String(split)); } catch {} }, [split]);
|
|
153
|
+
|
|
154
|
+
const uiPanel = (
|
|
155
|
+
error ? (
|
|
156
|
+
<div className="p-4 text-xs text-red-300 font-mono whitespace-pre-wrap h-full overflow-auto">
|
|
157
|
+
Failed to load craft: {error}
|
|
158
|
+
<div className="mt-2"><button onClick={() => setReloadTick(t => t + 1)} className="text-[10px] px-2 py-0.5 rounded bg-[var(--accent)]/20 text-[var(--accent)]">↻ Retry</button></div>
|
|
159
|
+
</div>
|
|
160
|
+
) : !Comp ? (
|
|
161
|
+
<div className="p-4 text-xs text-[var(--text-secondary)]">Loading craft…</div>
|
|
162
|
+
) : (
|
|
163
|
+
<CraftSDKProvider projectPath={projectPath} projectName={projectName} craftName={craft.name}>
|
|
164
|
+
<div className="h-full flex flex-col min-h-0 overflow-hidden">
|
|
165
|
+
<Comp />
|
|
166
|
+
</div>
|
|
167
|
+
</CraftSDKProvider>
|
|
168
|
+
)
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div ref={containerRef} className="flex-1 flex flex-col min-h-0 overflow-hidden relative">
|
|
173
|
+
{/* Header strip with hot-reload + show/hide terminal */}
|
|
174
|
+
<div className="px-3 py-1 border-b border-[var(--border)] flex items-center gap-2 text-[10px] bg-[var(--bg-secondary)]/30 shrink-0">
|
|
175
|
+
<span className="text-[var(--text-secondary)]">{craft.dir || `<project>/.forge/crafts/${craft.name}`}</span>
|
|
176
|
+
<div className="flex-1" />
|
|
177
|
+
<button onClick={() => setReloadTick(t => t + 1)}
|
|
178
|
+
className="px-1.5 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--accent)] hover:bg-[var(--bg-tertiary)]"
|
|
179
|
+
title="Re-fetch ui.tsx (after agent edits)">↻ reload</button>
|
|
180
|
+
{!showTerm && (
|
|
181
|
+
<button onClick={() => setPickerOpen(true)}
|
|
182
|
+
className="px-1.5 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--accent)] hover:bg-[var(--bg-tertiary)]"
|
|
183
|
+
title="Open terminal — pick agent + session">
|
|
184
|
+
⊞ open terminal
|
|
185
|
+
</button>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{/* Top: UI */}
|
|
190
|
+
<div className="overflow-hidden" style={{ height: showTerm ? `${split * 100}%` : '100%' }}>
|
|
191
|
+
{uiPanel}
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{/* Drag handle + bottom: terminal */}
|
|
195
|
+
{showTerm && termChoice && (
|
|
196
|
+
<>
|
|
197
|
+
<div onMouseDown={onDragStart}
|
|
198
|
+
className="h-1 cursor-row-resize bg-[var(--border)] hover:bg-[var(--accent)] transition-colors shrink-0" />
|
|
199
|
+
<div className="overflow-hidden" style={{ height: `${(1 - split) * 100}%` }}>
|
|
200
|
+
<Suspense fallback={<div className="p-2 text-[10px] text-[var(--text-secondary)]">Loading terminal…</div>}>
|
|
201
|
+
<CraftTerminalLazy
|
|
202
|
+
projectPath={projectPath}
|
|
203
|
+
craftName={craft.name}
|
|
204
|
+
craftDisplayName={craft.displayName}
|
|
205
|
+
preferredSessionName={craft.preferredSessionName || `mw-craft-${craft.name}`}
|
|
206
|
+
craftDir={craft.dir || `${projectPath}/.forge/crafts/${craft.name}`}
|
|
207
|
+
initialAgentId={termChoice.agentId}
|
|
208
|
+
initialResumeSessionId={termChoice.resumeSessionId}
|
|
209
|
+
onPickAgain={() => setPickerOpen(true)}
|
|
210
|
+
onHide={() => setShowTerm(false)}
|
|
211
|
+
onClose={async () => {
|
|
212
|
+
const sn = craft.preferredSessionName || `mw-craft-${craft.name}`;
|
|
213
|
+
if (!confirm(`Close terminal for "${craft.displayName}"?\n\nThis kills the tmux session ${sn} and stops any agent running there. The craft files stay untouched.`)) return;
|
|
214
|
+
try {
|
|
215
|
+
await fetch('/api/craft-system/kill-session', {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: { 'Content-Type': 'application/json' },
|
|
218
|
+
body: JSON.stringify({ sessionName: sn }),
|
|
219
|
+
});
|
|
220
|
+
} catch {}
|
|
221
|
+
try { localStorage.removeItem(termChoiceKey(projectPath, craft.name)); } catch {}
|
|
222
|
+
setTermChoice(null);
|
|
223
|
+
setShowTerm(false);
|
|
224
|
+
}}
|
|
225
|
+
/>
|
|
226
|
+
</Suspense>
|
|
227
|
+
</div>
|
|
228
|
+
</>
|
|
229
|
+
)}
|
|
230
|
+
|
|
231
|
+
{/* Picker overlay */}
|
|
232
|
+
{pickerOpen && (
|
|
233
|
+
<Suspense fallback={null}>
|
|
234
|
+
<CraftTerminalPickerLazy
|
|
235
|
+
projectName={projectName}
|
|
236
|
+
craftDir={craft.dir || `${projectPath}/.forge/crafts/${craft.name}`}
|
|
237
|
+
defaultAgentId={termChoice?.agentId}
|
|
238
|
+
onPick={async (c) => {
|
|
239
|
+
const next: CraftTermChoice = {
|
|
240
|
+
agentId: c.agentId,
|
|
241
|
+
resumeSessionId: c.sessionMode === 'new' ? undefined : c.sessionId,
|
|
242
|
+
};
|
|
243
|
+
const sessionName = craft.preferredSessionName || `mw-craft-${craft.name}`;
|
|
244
|
+
const wasShowing = showTerm;
|
|
245
|
+
const choiceChanged = !termChoice
|
|
246
|
+
|| termChoice.agentId !== next.agentId
|
|
247
|
+
|| termChoice.resumeSessionId !== next.resumeSessionId;
|
|
248
|
+
|
|
249
|
+
// Tear down existing tmux session so CraftTerminal can recreate
|
|
250
|
+
// it with the chosen agent + --resume flag. The cleanupOrphans
|
|
251
|
+
// exemption keeps craft sessions alive otherwise.
|
|
252
|
+
if (choiceChanged) {
|
|
253
|
+
try {
|
|
254
|
+
await fetch('/api/craft-system/kill-session', {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: { 'Content-Type': 'application/json' },
|
|
257
|
+
body: JSON.stringify({ sessionName }),
|
|
258
|
+
});
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
saveTermChoice(projectPath, craft.name, next);
|
|
263
|
+
setPickerOpen(false);
|
|
264
|
+
if (choiceChanged && wasShowing) {
|
|
265
|
+
// Force-remount CraftTerminal with the new choice
|
|
266
|
+
setShowTerm(false);
|
|
267
|
+
setTimeout(() => { setTermChoice(next); setShowTerm(true); }, 50);
|
|
268
|
+
} else {
|
|
269
|
+
setTermChoice(next);
|
|
270
|
+
setShowTerm(true);
|
|
271
|
+
}
|
|
272
|
+
}}
|
|
273
|
+
onCancel={() => setPickerOpen(false)}
|
|
274
|
+
/>
|
|
275
|
+
</Suspense>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Bottom-panel tmux terminal pinned to one craft. Attaches to the craft's
|
|
4
|
+
// session if it exists; otherwise creates it on demand with the chosen agent.
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
7
|
+
import { Terminal } from '@xterm/xterm';
|
|
8
|
+
import { FitAddon } from '@xterm/addon-fit';
|
|
9
|
+
import '@xterm/xterm/css/xterm.css';
|
|
10
|
+
|
|
11
|
+
function getWsUrl() {
|
|
12
|
+
if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '8404')}`;
|
|
13
|
+
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
14
|
+
const host = window.location.hostname;
|
|
15
|
+
if (host !== 'localhost' && host !== '127.0.0.1') {
|
|
16
|
+
return `${proto}//${window.location.host}/terminal-ws`;
|
|
17
|
+
}
|
|
18
|
+
const webPort = parseInt(window.location.port) || 8403;
|
|
19
|
+
return `${proto}//${host}:${webPort + 1}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface AgentSummary { id: string; name?: string; path?: string; }
|
|
23
|
+
|
|
24
|
+
export interface CraftTerminalProps {
|
|
25
|
+
projectPath: string;
|
|
26
|
+
craftName: string;
|
|
27
|
+
craftDisplayName?: string;
|
|
28
|
+
preferredSessionName: string; // e.g. mw-craft-<hash>-<name>; component will create if missing
|
|
29
|
+
craftDir: string; // <project>/.forge/crafts/<name>/
|
|
30
|
+
// Initial agent + session choice from the picker. Used when CraftTerminal
|
|
31
|
+
// has to create a fresh tmux session and start the agent CLI.
|
|
32
|
+
initialAgentId?: string;
|
|
33
|
+
initialResumeSessionId?: string;
|
|
34
|
+
onPickAgain?: () => void; // toolbar handler — re-open the picker
|
|
35
|
+
onHide?: () => void; // hide panel (preserve tmux + agent)
|
|
36
|
+
onClose?: () => void; // close = kill tmux + clear choice
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function CraftTerminal({
|
|
40
|
+
projectPath, craftName, craftDisplayName, preferredSessionName, craftDir,
|
|
41
|
+
initialAgentId, initialResumeSessionId, onPickAgain, onHide, onClose,
|
|
42
|
+
}: CraftTerminalProps) {
|
|
43
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
44
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
45
|
+
const termRef = useRef<Terminal | null>(null);
|
|
46
|
+
const [connected, setConnected] = useState(false);
|
|
47
|
+
const [activeSession, setActiveSession] = useState(preferredSessionName);
|
|
48
|
+
const activeSessionRef = useRef(activeSession);
|
|
49
|
+
const [sessions, setSessions] = useState<{ name: string; cwd?: string; attached: boolean }[]>([]);
|
|
50
|
+
const [agents, setAgents] = useState<AgentSummary[]>([]);
|
|
51
|
+
const [agentId, setAgentId] = useState<string>('');
|
|
52
|
+
const skipPermRef = useRef(true);
|
|
53
|
+
const [showPicker, setShowPicker] = useState(false);
|
|
54
|
+
|
|
55
|
+
// Refs that the WS message handler can read at fire time (avoids closure
|
|
56
|
+
// staleness — agents/agentId load async, but the handler runs right after
|
|
57
|
+
// the tmux session is created which is before the fetch settles).
|
|
58
|
+
const agentsRef = useRef<AgentSummary[]>([]);
|
|
59
|
+
const agentIdRef = useRef<string>('');
|
|
60
|
+
const resumeIdRef = useRef<string | undefined>(initialResumeSessionId);
|
|
61
|
+
|
|
62
|
+
activeSessionRef.current = activeSession;
|
|
63
|
+
agentsRef.current = agents;
|
|
64
|
+
agentIdRef.current = agentId;
|
|
65
|
+
resumeIdRef.current = initialResumeSessionId;
|
|
66
|
+
|
|
67
|
+
// Load sessions + agents
|
|
68
|
+
const refreshSessions = useCallback(async () => {
|
|
69
|
+
try {
|
|
70
|
+
const r = await fetch(`/api/craft-system/tmux-sessions?projectPath=${encodeURIComponent(projectPath)}`);
|
|
71
|
+
if (!r.ok) return;
|
|
72
|
+
const j = await r.json();
|
|
73
|
+
setSessions([...(j.matches || []), ...(j.others || [])]);
|
|
74
|
+
} catch {}
|
|
75
|
+
}, [projectPath]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => { refreshSessions(); }, [refreshSessions]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
fetch('/api/agents')
|
|
81
|
+
.then(r => r.ok ? r.json() : { agents: [] })
|
|
82
|
+
.then((res: any) => {
|
|
83
|
+
const list = (res.agents || []).filter((a: any) => a.enabled !== false);
|
|
84
|
+
setAgents(list);
|
|
85
|
+
const wanted = initialAgentId || res.defaultAgent;
|
|
86
|
+
const def = wanted && list.find((a: any) => a.id === wanted) ? wanted : list[0]?.id || '';
|
|
87
|
+
setAgentId(def);
|
|
88
|
+
});
|
|
89
|
+
fetch('/api/settings').then(r => r.ok ? r.json() : null).then((s: any) => { if (s?.skipPermissions === false) skipPermRef.current = false; });
|
|
90
|
+
}, [initialAgentId]);
|
|
91
|
+
|
|
92
|
+
// Mount xterm + connect
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!containerRef.current) return;
|
|
95
|
+
let disposed = false;
|
|
96
|
+
let reconnectTimer = 0;
|
|
97
|
+
|
|
98
|
+
const cs = getComputedStyle(document.documentElement);
|
|
99
|
+
const tv = (n: string) => cs.getPropertyValue(n).trim();
|
|
100
|
+
const term = new Terminal({
|
|
101
|
+
cursorBlink: true,
|
|
102
|
+
fontSize: 12,
|
|
103
|
+
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
104
|
+
scrollback: 5000,
|
|
105
|
+
logger: { trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
|
106
|
+
theme: {
|
|
107
|
+
background: tv('--term-bg') || '#0d1117',
|
|
108
|
+
foreground: tv('--term-fg') || '#e0e0e0',
|
|
109
|
+
cursor: tv('--term-cursor') || '#7c5bf0',
|
|
110
|
+
selectionBackground: (tv('--term-cursor') || '#7c5bf0') + '44',
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
termRef.current = term;
|
|
114
|
+
const fit = new FitAddon();
|
|
115
|
+
term.loadAddon(fit);
|
|
116
|
+
term.open(containerRef.current);
|
|
117
|
+
try { fit.fit(); } catch {}
|
|
118
|
+
|
|
119
|
+
function connect() {
|
|
120
|
+
if (disposed) return;
|
|
121
|
+
const ws = new WebSocket(getWsUrl());
|
|
122
|
+
wsRef.current = ws;
|
|
123
|
+
let pendingCreate = false;
|
|
124
|
+
|
|
125
|
+
ws.onopen = () => {
|
|
126
|
+
if (disposed) { ws.close(); return; }
|
|
127
|
+
ws.send(JSON.stringify({ type: 'attach', sessionName: activeSessionRef.current, cols: term.cols, rows: term.rows }));
|
|
128
|
+
};
|
|
129
|
+
ws.onmessage = ev => {
|
|
130
|
+
if (disposed) return;
|
|
131
|
+
try {
|
|
132
|
+
const msg = JSON.parse(ev.data);
|
|
133
|
+
if (msg.type === 'output') { try { term.write(msg.data); } catch {} }
|
|
134
|
+
else if (msg.type === 'connected') setConnected(true);
|
|
135
|
+
else if (msg.type === 'error') {
|
|
136
|
+
// Session doesn't exist — create it on demand
|
|
137
|
+
if (!pendingCreate) {
|
|
138
|
+
pendingCreate = true;
|
|
139
|
+
ws.send(JSON.stringify({ type: 'create', sessionName: activeSessionRef.current, cols: term.cols, rows: term.rows }));
|
|
140
|
+
// After creation, cd into craft dir and start the chosen agent
|
|
141
|
+
// Wait until agents fetch settles so we can pick the right CLI + resume flag
|
|
142
|
+
const tryLaunch = (attempt = 0) => {
|
|
143
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
144
|
+
const list = agentsRef.current;
|
|
145
|
+
const id = agentIdRef.current;
|
|
146
|
+
if (list.length === 0 && attempt < 10) {
|
|
147
|
+
setTimeout(() => tryLaunch(attempt + 1), 200);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const a = list.find(x => x.id === id) || list[0];
|
|
151
|
+
const cli = a?.path || a?.id || 'claude';
|
|
152
|
+
const isClaude = (a?.id === 'claude') || (a as any)?.cliType === 'claude-code';
|
|
153
|
+
const sf = (isClaude && skipPermRef.current) ? ' --dangerously-skip-permissions' : '';
|
|
154
|
+
const resume = resumeIdRef.current && isClaude ? ` --resume ${resumeIdRef.current}` : '';
|
|
155
|
+
ws.send(JSON.stringify({ type: 'input', data: `cd "${craftDir}" && ${cli}${sf}${resume}\n` }));
|
|
156
|
+
};
|
|
157
|
+
setTimeout(() => tryLaunch(), 500);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch {}
|
|
161
|
+
};
|
|
162
|
+
ws.onclose = () => {
|
|
163
|
+
if (disposed) return;
|
|
164
|
+
setConnected(false);
|
|
165
|
+
reconnectTimer = window.setTimeout(connect, 2000);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
connect();
|
|
169
|
+
|
|
170
|
+
term.onData(d => {
|
|
171
|
+
const ws = wsRef.current;
|
|
172
|
+
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: d }));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const ro = new ResizeObserver(() => {
|
|
176
|
+
const el = containerRef.current;
|
|
177
|
+
if (!el || el.offsetWidth < 100 || el.offsetHeight < 50) return;
|
|
178
|
+
try {
|
|
179
|
+
fit.fit();
|
|
180
|
+
if (term.cols < 2 || term.rows < 2) return;
|
|
181
|
+
const ws = wsRef.current;
|
|
182
|
+
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
183
|
+
} catch {}
|
|
184
|
+
});
|
|
185
|
+
ro.observe(containerRef.current);
|
|
186
|
+
|
|
187
|
+
return () => {
|
|
188
|
+
disposed = true;
|
|
189
|
+
clearTimeout(reconnectTimer);
|
|
190
|
+
ro.disconnect();
|
|
191
|
+
const ws = wsRef.current;
|
|
192
|
+
if (ws) { ws.onclose = null; ws.close(); }
|
|
193
|
+
term.dispose();
|
|
194
|
+
termRef.current = null;
|
|
195
|
+
wsRef.current = null;
|
|
196
|
+
};
|
|
197
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
198
|
+
}, []);
|
|
199
|
+
|
|
200
|
+
// Switch session — close socket and reconnect with new name
|
|
201
|
+
const switchSession = useCallback((name: string) => {
|
|
202
|
+
setActiveSession(name);
|
|
203
|
+
activeSessionRef.current = name;
|
|
204
|
+
setShowPicker(false);
|
|
205
|
+
const ws = wsRef.current;
|
|
206
|
+
if (ws) ws.close();
|
|
207
|
+
// Clear xterm output
|
|
208
|
+
termRef.current?.clear();
|
|
209
|
+
}, []);
|
|
210
|
+
|
|
211
|
+
const startNewSession = useCallback(async () => {
|
|
212
|
+
// Create a fresh session pinned to this craft + chosen agent
|
|
213
|
+
const sessionName = `${preferredSessionName}-${Date.now().toString(36).slice(-4)}`;
|
|
214
|
+
switchSession(sessionName);
|
|
215
|
+
}, [preferredSessionName, switchSession]);
|
|
216
|
+
|
|
217
|
+
const sendInjectPrompt = useCallback(() => {
|
|
218
|
+
// Re-paste the original prompt.md into the running session
|
|
219
|
+
const ws = wsRef.current;
|
|
220
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
221
|
+
fetch(`/api/craft-system/helpers/file?projectPath=${encodeURIComponent(projectPath)}&path=${encodeURIComponent(`.forge/crafts/${craftName}/prompt.md`)}`)
|
|
222
|
+
.then(r => r.ok ? r.text() : '')
|
|
223
|
+
.then(text => {
|
|
224
|
+
if (!text) return;
|
|
225
|
+
ws.send(JSON.stringify({ type: 'input', data: text + '\n' }));
|
|
226
|
+
});
|
|
227
|
+
}, [projectPath, craftName]);
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div className="h-full flex flex-col bg-[var(--bg-primary)] border-t border-[var(--border)]">
|
|
231
|
+
{/* Toolbar */}
|
|
232
|
+
<div className="flex items-center gap-2 px-2 py-1 border-b border-[var(--border)] shrink-0 bg-[var(--bg-secondary)]/40">
|
|
233
|
+
<span className="text-[10px] text-[var(--text-secondary)]">🖥</span>
|
|
234
|
+
<button onClick={() => { setShowPicker(v => !v); refreshSessions(); }}
|
|
235
|
+
className="text-[10px] px-1.5 py-0.5 rounded font-mono text-[var(--accent)] hover:bg-[var(--accent)]/10"
|
|
236
|
+
title="Switch session">
|
|
237
|
+
{activeSession.replace(/^mw[a-z0-9]*-/, '')} ▾
|
|
238
|
+
</button>
|
|
239
|
+
<span className={`text-[9px] ${connected ? 'text-emerald-400' : 'text-[var(--text-secondary)]'}`}>
|
|
240
|
+
{connected ? '● connected' : '○ disconnected'}
|
|
241
|
+
</span>
|
|
242
|
+
<select value={agentId} onChange={e => setAgentId(e.target.value)}
|
|
243
|
+
className="text-[10px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-1.5 py-0.5"
|
|
244
|
+
title="Agent used when creating a fresh session">
|
|
245
|
+
{agents.length === 0 && <option value="">no agents</option>}
|
|
246
|
+
{agents.map(a => <option key={a.id} value={a.id}>{a.name || a.id}</option>)}
|
|
247
|
+
</select>
|
|
248
|
+
<div className="flex-1" />
|
|
249
|
+
<button onClick={sendInjectPrompt}
|
|
250
|
+
className="text-[10px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--accent)]"
|
|
251
|
+
title="Re-paste the craft's prompt.md into the session">
|
|
252
|
+
📋 prompt
|
|
253
|
+
</button>
|
|
254
|
+
<button onClick={startNewSession}
|
|
255
|
+
className="text-[10px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--accent)]"
|
|
256
|
+
title="Start a fresh tmux session for this craft">
|
|
257
|
+
+ Fresh
|
|
258
|
+
</button>
|
|
259
|
+
{onPickAgain && (
|
|
260
|
+
<button onClick={onPickAgain}
|
|
261
|
+
className="text-[10px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--accent)]"
|
|
262
|
+
title="Re-pick agent + session">
|
|
263
|
+
⚙ session
|
|
264
|
+
</button>
|
|
265
|
+
)}
|
|
266
|
+
{onHide && (
|
|
267
|
+
<button onClick={onHide}
|
|
268
|
+
className="text-[10px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--accent)]"
|
|
269
|
+
title="Hide this panel — tmux session keeps running so you can re-open right back where you left off">
|
|
270
|
+
⊟ hide
|
|
271
|
+
</button>
|
|
272
|
+
)}
|
|
273
|
+
{onClose && (
|
|
274
|
+
<button onClick={onClose}
|
|
275
|
+
className="text-[10px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:text-red-300 hover:bg-red-500/10"
|
|
276
|
+
title="Close — kills the tmux session and forgets the agent/session choice">
|
|
277
|
+
✕ close
|
|
278
|
+
</button>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
{/* Session picker dropdown */}
|
|
283
|
+
{showPicker && (
|
|
284
|
+
<div className="absolute top-12 left-2 z-30 bg-[var(--bg-primary)] border border-[var(--border)] rounded shadow-xl text-[10px] min-w-[260px] max-h-64 overflow-auto">
|
|
285
|
+
<div className="px-2 py-1 text-[var(--text-secondary)] border-b border-[var(--border)]">All project sessions</div>
|
|
286
|
+
{sessions.length === 0 && <div className="px-2 py-2 text-[var(--text-secondary)]">no sessions</div>}
|
|
287
|
+
{sessions.map(s => (
|
|
288
|
+
<button key={s.name} onClick={() => switchSession(s.name)}
|
|
289
|
+
className={`w-full text-left px-2 py-1 hover:bg-[var(--bg-tertiary)] ${s.name === activeSession ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : ''}`}>
|
|
290
|
+
<div className="font-mono">{s.name.replace(/^mw[a-z0-9]*-/, '')}{s.attached ? ' ●' : ''}</div>
|
|
291
|
+
{s.cwd && <div className="text-[9px] text-[var(--text-secondary)] truncate">{s.cwd}</div>}
|
|
292
|
+
</button>
|
|
293
|
+
))}
|
|
294
|
+
<button onClick={() => { setShowPicker(false); switchSession(preferredSessionName); }}
|
|
295
|
+
className="w-full text-left px-2 py-1 border-t border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]">
|
|
296
|
+
↩ back to craft default ({preferredSessionName.replace(/^mw[a-z0-9]*-/, '')})
|
|
297
|
+
</button>
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
{/* Terminal */}
|
|
302
|
+
<div ref={containerRef} className="flex-1 min-h-0" />
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|