@aion0/forge 0.2.0 → 0.2.2
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/app/api/code/route.ts +83 -22
- package/app/api/docs/route.ts +48 -3
- package/app/api/git/route.ts +131 -0
- package/app/api/online/route.ts +40 -0
- package/app/api/preview/[...path]/route.ts +64 -0
- package/app/api/preview/route.ts +135 -0
- package/app/api/tasks/[id]/route.ts +8 -2
- package/components/CodeViewer.tsx +274 -37
- package/components/Dashboard.tsx +68 -3
- package/components/DocsViewer.tsx +54 -5
- package/components/NewTaskModal.tsx +7 -7
- package/components/PreviewPanel.tsx +154 -0
- package/components/ProjectManager.tsx +410 -0
- package/components/SettingsModal.tsx +4 -1
- package/components/TaskDetail.tsx +1 -1
- package/components/WebTerminal.tsx +131 -21
- package/lib/task-manager.ts +70 -12
- package/lib/telegram-bot.ts +99 -1
- package/package.json +1 -1
|
@@ -13,8 +13,7 @@ export interface WebTerminalHandle {
|
|
|
13
13
|
|
|
14
14
|
export interface WebTerminalProps {
|
|
15
15
|
onActiveSession?: (sessionName: string | null) => void;
|
|
16
|
-
|
|
17
|
-
onToggleCode?: () => void;
|
|
16
|
+
onCodeOpenChange?: (open: boolean) => void;
|
|
18
17
|
}
|
|
19
18
|
|
|
20
19
|
// ─── Types ───────────────────────────────────────────────────
|
|
@@ -27,7 +26,7 @@ interface TmuxSession {
|
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
type SplitNode =
|
|
30
|
-
| { type: 'terminal'; id: number; sessionName?: string }
|
|
29
|
+
| { type: 'terminal'; id: number; sessionName?: string; projectPath?: string }
|
|
31
30
|
| { type: 'split'; id: number; direction: 'horizontal' | 'vertical'; ratio: number; first: SplitNode; second: SplitNode };
|
|
32
31
|
|
|
33
32
|
interface TabState {
|
|
@@ -36,6 +35,7 @@ interface TabState {
|
|
|
36
35
|
tree: SplitNode;
|
|
37
36
|
ratios: Record<number, number>;
|
|
38
37
|
activeId: number;
|
|
38
|
+
projectPath?: string; // If set, auto-run claude --resume in this dir on session create
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// ─── Layout persistence ──────────────────────────────────────
|
|
@@ -100,8 +100,8 @@ function initNextIdFromTabs(tabs: TabState[]) {
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
function makeTerminal(sessionName?: string): SplitNode {
|
|
104
|
-
return { type: 'terminal', id: nextId++, sessionName };
|
|
103
|
+
function makeTerminal(sessionName?: string, projectPath?: string): SplitNode {
|
|
104
|
+
return { type: 'terminal', id: nextId++, sessionName, projectPath };
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
function makeSplit(direction: 'horizontal' | 'vertical', first: SplitNode, second: SplitNode): SplitNode {
|
|
@@ -162,7 +162,7 @@ let globalDragging = false;
|
|
|
162
162
|
|
|
163
163
|
// ─── Main component ─────────────────────────────────────────
|
|
164
164
|
|
|
165
|
-
const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession,
|
|
165
|
+
const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, onCodeOpenChange }, ref) {
|
|
166
166
|
const [tabs, setTabs] = useState<TabState[]>(() => {
|
|
167
167
|
const tree = makeTerminal();
|
|
168
168
|
return [{ id: nextId++, label: 'Terminal 1', tree, ratios: {}, activeId: firstTerminalId(tree) }];
|
|
@@ -178,6 +178,11 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
178
178
|
const sessionLabelsRef = useRef<Record<string, string>>({});
|
|
179
179
|
const dragTabRef = useRef<number | null>(null);
|
|
180
180
|
const [refreshKeys, setRefreshKeys] = useState<Record<number, number>>({});
|
|
181
|
+
const [tabCodeOpen, setTabCodeOpen] = useState<Record<number, boolean>>({});
|
|
182
|
+
const [showNewTabModal, setShowNewTabModal] = useState(false);
|
|
183
|
+
const [projectRoots, setProjectRoots] = useState<string[]>([]);
|
|
184
|
+
const [allProjects, setAllProjects] = useState<{ name: string; path: string; root: string }[]>([]);
|
|
185
|
+
const [expandedRoot, setExpandedRoot] = useState<string | null>(null);
|
|
181
186
|
|
|
182
187
|
// Restore shared state from server after mount
|
|
183
188
|
useEffect(() => {
|
|
@@ -191,6 +196,15 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
191
196
|
}
|
|
192
197
|
setHydrated(true);
|
|
193
198
|
});
|
|
199
|
+
// Fetch projects and derive roots
|
|
200
|
+
fetch('/api/projects').then(r => r.json())
|
|
201
|
+
.then((p: { name: string; path: string; root: string }[]) => {
|
|
202
|
+
if (!Array.isArray(p)) return;
|
|
203
|
+
setAllProjects(p);
|
|
204
|
+
const roots = [...new Set(p.map(proj => proj.root))];
|
|
205
|
+
setProjectRoots(roots);
|
|
206
|
+
})
|
|
207
|
+
.catch(() => {});
|
|
194
208
|
}, []);
|
|
195
209
|
|
|
196
210
|
// Persist to server on changes (debounced, only after hydration)
|
|
@@ -214,12 +228,17 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
214
228
|
|
|
215
229
|
const activeTab = tabs.find(t => t.id === activeTabId) || tabs[0];
|
|
216
230
|
|
|
217
|
-
// Notify parent when active terminal session changes
|
|
231
|
+
// Notify parent when active terminal session or code state changes
|
|
218
232
|
useEffect(() => {
|
|
219
|
-
if (!
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
233
|
+
if (!activeTab) return;
|
|
234
|
+
if (onActiveSession) {
|
|
235
|
+
const sessions = collectSessionNames(activeTab.tree);
|
|
236
|
+
onActiveSession(sessions[0] || null);
|
|
237
|
+
}
|
|
238
|
+
if (onCodeOpenChange) {
|
|
239
|
+
onCodeOpenChange(tabCodeOpen[activeTab.id] ?? false);
|
|
240
|
+
}
|
|
241
|
+
}, [activeTabId, activeTab, onActiveSession, onCodeOpenChange, tabCodeOpen]);
|
|
223
242
|
|
|
224
243
|
// ─── Imperative handle for parent ─────────────────────
|
|
225
244
|
|
|
@@ -243,10 +262,11 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
243
262
|
|
|
244
263
|
// ─── Tab operations ───────────────────────────────────
|
|
245
264
|
|
|
246
|
-
const addTab = useCallback(() => {
|
|
247
|
-
const tree = makeTerminal();
|
|
265
|
+
const addTab = useCallback((projectPath?: string) => {
|
|
266
|
+
const tree = makeTerminal(undefined, projectPath);
|
|
248
267
|
const tabNum = tabs.length + 1;
|
|
249
|
-
const
|
|
268
|
+
const label = projectPath ? projectPath.split('/').pop() || `Terminal ${tabNum}` : `Terminal ${tabNum}`;
|
|
269
|
+
const newTab: TabState = { id: nextId++, label, tree, ratios: {}, activeId: firstTerminalId(tree), projectPath };
|
|
250
270
|
setTabs(prev => [...prev, newTab]);
|
|
251
271
|
setActiveTabId(newTab.id);
|
|
252
272
|
}, [tabs.length]);
|
|
@@ -481,9 +501,9 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
481
501
|
</div>
|
|
482
502
|
))}
|
|
483
503
|
<button
|
|
484
|
-
onClick={
|
|
504
|
+
onClick={() => setShowNewTabModal(true)}
|
|
485
505
|
className="px-2 py-1 text-[11px] text-gray-500 hover:text-white hover:bg-[#2a2a4a]"
|
|
486
|
-
title="New
|
|
506
|
+
title="New tab"
|
|
487
507
|
>
|
|
488
508
|
+
|
|
489
509
|
</button>
|
|
@@ -519,11 +539,16 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
519
539
|
>
|
|
520
540
|
Refresh
|
|
521
541
|
</button>
|
|
522
|
-
{
|
|
542
|
+
{onCodeOpenChange && activeTab && (
|
|
523
543
|
<button
|
|
524
|
-
onClick={
|
|
525
|
-
|
|
526
|
-
|
|
544
|
+
onClick={() => {
|
|
545
|
+
const current = tabCodeOpen[activeTab.id] ?? false;
|
|
546
|
+
const next = !current;
|
|
547
|
+
setTabCodeOpen(prev => ({ ...prev, [activeTab.id]: next }));
|
|
548
|
+
onCodeOpenChange(next);
|
|
549
|
+
}}
|
|
550
|
+
className={`text-[11px] px-3 py-1 rounded font-bold ${(tabCodeOpen[activeTab.id] ?? false) ? 'text-white bg-red-500 hover:bg-red-400' : 'text-red-400 border border-red-500 hover:bg-red-500 hover:text-white'}`}
|
|
551
|
+
title={(tabCodeOpen[activeTab.id] ?? false) ? 'Hide code panel' : 'Show code panel'}
|
|
527
552
|
>
|
|
528
553
|
Code
|
|
529
554
|
</button>
|
|
@@ -621,6 +646,75 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
621
646
|
</div>
|
|
622
647
|
)}
|
|
623
648
|
|
|
649
|
+
{/* New tab modal */}
|
|
650
|
+
{showNewTabModal && (
|
|
651
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { setShowNewTabModal(false); setExpandedRoot(null); }}>
|
|
652
|
+
<div className="bg-[#1a1a2e] border border-[#2a2a4a] rounded-lg shadow-xl w-[350px] max-h-[70vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
653
|
+
<div className="px-4 py-3 border-b border-[#2a2a4a]">
|
|
654
|
+
<h3 className="text-sm font-semibold text-white">New Tab</h3>
|
|
655
|
+
</div>
|
|
656
|
+
<div className="flex-1 overflow-y-auto p-2">
|
|
657
|
+
{/* Plain terminal */}
|
|
658
|
+
<button
|
|
659
|
+
onClick={() => { addTab(); setShowNewTabModal(false); setExpandedRoot(null); }}
|
|
660
|
+
className="w-full text-left px-3 py-2 rounded hover:bg-[#2a2a4a] text-[12px] text-gray-300 flex items-center gap-2"
|
|
661
|
+
>
|
|
662
|
+
<span className="text-gray-500">▸</span> Terminal
|
|
663
|
+
</button>
|
|
664
|
+
|
|
665
|
+
{/* Project roots */}
|
|
666
|
+
{projectRoots.length > 0 && (
|
|
667
|
+
<div className="mt-2 pt-2 border-t border-[#2a2a4a]">
|
|
668
|
+
<div className="px-3 py-1 text-[9px] text-gray-500 uppercase">Claude in Project</div>
|
|
669
|
+
{projectRoots.map(root => {
|
|
670
|
+
const rootName = root.split('/').pop() || root;
|
|
671
|
+
const isExpanded = expandedRoot === root;
|
|
672
|
+
const rootProjects = allProjects.filter(p => p.root === root);
|
|
673
|
+
return (
|
|
674
|
+
<div key={root}>
|
|
675
|
+
<button
|
|
676
|
+
onClick={() => setExpandedRoot(isExpanded ? null : root)}
|
|
677
|
+
className="w-full text-left px-3 py-2 rounded hover:bg-[#2a2a4a] text-[12px] text-gray-300 flex items-center gap-2"
|
|
678
|
+
>
|
|
679
|
+
<span className="text-gray-500 text-[10px] w-3">{isExpanded ? '▾' : '▸'}</span>
|
|
680
|
+
<span>{rootName}</span>
|
|
681
|
+
<span className="text-[9px] text-gray-600 ml-auto">{rootProjects.length}</span>
|
|
682
|
+
</button>
|
|
683
|
+
{isExpanded && (
|
|
684
|
+
<div className="ml-4">
|
|
685
|
+
{rootProjects.map(p => (
|
|
686
|
+
<button
|
|
687
|
+
key={p.path}
|
|
688
|
+
onClick={() => { addTab(p.path); setShowNewTabModal(false); setExpandedRoot(null); }}
|
|
689
|
+
className="w-full text-left px-3 py-1.5 rounded hover:bg-[#2a2a4a] text-[11px] text-gray-300 flex items-center gap-2 truncate"
|
|
690
|
+
title={p.path}
|
|
691
|
+
>
|
|
692
|
+
<span className="text-gray-600 text-[10px]">↳</span> {p.name}
|
|
693
|
+
</button>
|
|
694
|
+
))}
|
|
695
|
+
{rootProjects.length === 0 && (
|
|
696
|
+
<div className="px-3 py-1.5 text-[10px] text-gray-600">No projects</div>
|
|
697
|
+
)}
|
|
698
|
+
</div>
|
|
699
|
+
)}
|
|
700
|
+
</div>
|
|
701
|
+
);
|
|
702
|
+
})}
|
|
703
|
+
</div>
|
|
704
|
+
)}
|
|
705
|
+
</div>
|
|
706
|
+
<div className="px-4 py-2 border-t border-[#2a2a4a]">
|
|
707
|
+
<button
|
|
708
|
+
onClick={() => { setShowNewTabModal(false); setExpandedRoot(null); }}
|
|
709
|
+
className="w-full text-center text-[11px] text-gray-500 hover:text-gray-300 py-1"
|
|
710
|
+
>
|
|
711
|
+
Cancel
|
|
712
|
+
</button>
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
)}
|
|
717
|
+
|
|
624
718
|
{/* Close confirmation dialog */}
|
|
625
719
|
{closeConfirm && (
|
|
626
720
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setCloseConfirm(null)}>
|
|
@@ -696,7 +790,7 @@ function PaneRenderer({
|
|
|
696
790
|
if (node.type === 'terminal') {
|
|
697
791
|
return (
|
|
698
792
|
<div className={`h-full w-full ${activeId === node.id ? 'ring-1 ring-[#7c5bf0]/50 ring-inset' : ''}`} onMouseDown={() => onFocus(node.id)}>
|
|
699
|
-
<MemoTerminalPane key={`${node.id}-${refreshKeys[node.id] || 0}`} id={node.id} sessionName={node.sessionName} onSessionConnected={onSessionConnected} />
|
|
793
|
+
<MemoTerminalPane key={`${node.id}-${refreshKeys[node.id] || 0}`} id={node.id} sessionName={node.sessionName} projectPath={node.projectPath} onSessionConnected={onSessionConnected} />
|
|
700
794
|
</div>
|
|
701
795
|
);
|
|
702
796
|
}
|
|
@@ -826,15 +920,19 @@ function DraggableSplit({
|
|
|
826
920
|
const MemoTerminalPane = memo(function TerminalPane({
|
|
827
921
|
id,
|
|
828
922
|
sessionName,
|
|
923
|
+
projectPath,
|
|
829
924
|
onSessionConnected,
|
|
830
925
|
}: {
|
|
831
926
|
id: number;
|
|
832
927
|
sessionName?: string;
|
|
928
|
+
projectPath?: string;
|
|
833
929
|
onSessionConnected: (paneId: number, sessionName: string) => void;
|
|
834
930
|
}) {
|
|
835
931
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
836
932
|
const sessionNameRef = useRef(sessionName);
|
|
837
933
|
sessionNameRef.current = sessionName;
|
|
934
|
+
const projectPathRef = useRef(projectPath);
|
|
935
|
+
projectPathRef.current = projectPath;
|
|
838
936
|
|
|
839
937
|
useEffect(() => {
|
|
840
938
|
if (!containerRef.current) return;
|
|
@@ -911,6 +1009,7 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
911
1009
|
let createRetries = 0;
|
|
912
1010
|
const MAX_CREATE_RETRIES = 2;
|
|
913
1011
|
let reconnectAttempts = 0;
|
|
1012
|
+
let isNewlyCreated = false;
|
|
914
1013
|
|
|
915
1014
|
function connect() {
|
|
916
1015
|
if (disposed) return;
|
|
@@ -932,6 +1031,7 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
932
1031
|
socket.send(JSON.stringify({ type: 'attach', sessionName: sn, cols, rows }));
|
|
933
1032
|
} else if (createRetries < MAX_CREATE_RETRIES) {
|
|
934
1033
|
createRetries++;
|
|
1034
|
+
isNewlyCreated = true;
|
|
935
1035
|
socket.send(JSON.stringify({ type: 'create', cols, rows }));
|
|
936
1036
|
} else {
|
|
937
1037
|
term.write('\r\n\x1b[91m[failed to create session — check server logs]\x1b[0m\r\n');
|
|
@@ -950,6 +1050,16 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
950
1050
|
createRetries = 0;
|
|
951
1051
|
reconnectAttempts = 0;
|
|
952
1052
|
onSessionConnected(id, msg.sessionName);
|
|
1053
|
+
// Auto-run claude --resume for project tabs on new session
|
|
1054
|
+
if (isNewlyCreated && projectPathRef.current) {
|
|
1055
|
+
isNewlyCreated = false;
|
|
1056
|
+
setTimeout(() => {
|
|
1057
|
+
if (!disposed && ws?.readyState === WebSocket.OPEN) {
|
|
1058
|
+
ws.send(JSON.stringify({ type: 'input', data: `cd "${projectPathRef.current}" && claude --resume\n` }));
|
|
1059
|
+
}
|
|
1060
|
+
}, 300);
|
|
1061
|
+
}
|
|
1062
|
+
isNewlyCreated = false;
|
|
953
1063
|
// Force tmux to redraw by toggling size, then send reset
|
|
954
1064
|
setTimeout(() => {
|
|
955
1065
|
if (disposed || ws?.readyState !== WebSocket.OPEN) return;
|
package/lib/task-manager.ts
CHANGED
|
@@ -12,8 +12,10 @@ import { loadSettings } from './settings';
|
|
|
12
12
|
import { notifyTaskComplete, notifyTaskFailed } from './notify';
|
|
13
13
|
import type { Task, TaskLogEntry, TaskStatus, TaskMode, WatchConfig } from '@/src/types';
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const runnerKey = Symbol.for('mw-task-runner');
|
|
16
|
+
const gRunner = globalThis as any;
|
|
17
|
+
if (!gRunner[runnerKey]) gRunner[runnerKey] = { runner: null, currentTaskId: null };
|
|
18
|
+
const runnerState: { runner: ReturnType<typeof setInterval> | null; currentTaskId: string | null } = gRunner[runnerKey];
|
|
17
19
|
|
|
18
20
|
// Per-project concurrency: track which projects have a running prompt task
|
|
19
21
|
const runningProjects = new Set<string>();
|
|
@@ -133,7 +135,7 @@ export function deleteTask(id: string): boolean {
|
|
|
133
135
|
return true;
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
export function updateTask(id: string, updates: { prompt?: string; projectName?: string; projectPath?: string; priority?: number; restart?: boolean }): Task | null {
|
|
138
|
+
export function updateTask(id: string, updates: { prompt?: string; projectName?: string; projectPath?: string; priority?: number; scheduledAt?: string; restart?: boolean }): Task | null {
|
|
137
139
|
const task = getTask(id);
|
|
138
140
|
if (!task) return null;
|
|
139
141
|
|
|
@@ -146,6 +148,7 @@ export function updateTask(id: string, updates: { prompt?: string; projectName?:
|
|
|
146
148
|
if (updates.projectName !== undefined) { fields.push('project_name = ?'); values.push(updates.projectName); }
|
|
147
149
|
if (updates.projectPath !== undefined) { fields.push('project_path = ?'); values.push(updates.projectPath); }
|
|
148
150
|
if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); }
|
|
151
|
+
if (updates.scheduledAt !== undefined) { fields.push('scheduled_at = ?'); values.push(updates.scheduledAt || null); }
|
|
149
152
|
|
|
150
153
|
// Reset to queued so it runs again
|
|
151
154
|
if (updates.restart) {
|
|
@@ -179,16 +182,16 @@ export function retryTask(id: string): Task | null {
|
|
|
179
182
|
// ─── Background Runner ───────────────────────────────────────
|
|
180
183
|
|
|
181
184
|
export function ensureRunnerStarted() {
|
|
182
|
-
if (runner) return;
|
|
183
|
-
runner = setInterval(processNextTask, 3000);
|
|
185
|
+
if (runnerState.runner) return;
|
|
186
|
+
runnerState.runner = setInterval(processNextTask, 3000);
|
|
184
187
|
// Also try immediately
|
|
185
188
|
processNextTask();
|
|
186
189
|
}
|
|
187
190
|
|
|
188
191
|
export function stopRunner() {
|
|
189
|
-
if (runner) {
|
|
190
|
-
clearInterval(runner);
|
|
191
|
-
runner = null;
|
|
192
|
+
if (runnerState.runner) {
|
|
193
|
+
clearInterval(runnerState.runner);
|
|
194
|
+
runnerState.runner = null;
|
|
192
195
|
}
|
|
193
196
|
}
|
|
194
197
|
|
|
@@ -196,7 +199,7 @@ async function processNextTask() {
|
|
|
196
199
|
// Find all queued tasks ready to run
|
|
197
200
|
const queued = db().prepare(`
|
|
198
201
|
SELECT * FROM tasks WHERE status = 'queued'
|
|
199
|
-
AND (scheduled_at IS NULL OR scheduled_at <= datetime('now'))
|
|
202
|
+
AND (scheduled_at IS NULL OR replace(replace(scheduled_at, 'T', ' '), 'Z', '') <= datetime('now'))
|
|
200
203
|
ORDER BY priority DESC, created_at ASC
|
|
201
204
|
`).all() as any[];
|
|
202
205
|
|
|
@@ -214,7 +217,7 @@ async function processNextTask() {
|
|
|
214
217
|
|
|
215
218
|
// Run this task
|
|
216
219
|
runningProjects.add(task.projectName);
|
|
217
|
-
currentTaskId = task.id;
|
|
220
|
+
runnerState.currentTaskId = task.id;
|
|
218
221
|
|
|
219
222
|
// Execute async — don't await so we can process tasks for other projects in parallel
|
|
220
223
|
executeTask(task)
|
|
@@ -224,7 +227,7 @@ async function processNextTask() {
|
|
|
224
227
|
})
|
|
225
228
|
.finally(() => {
|
|
226
229
|
runningProjects.delete(task.projectName);
|
|
227
|
-
if (currentTaskId === task.id) currentTaskId = null;
|
|
230
|
+
if (runnerState.currentTaskId === task.id) runnerState.currentTaskId = null;
|
|
228
231
|
});
|
|
229
232
|
}
|
|
230
233
|
}
|
|
@@ -234,7 +237,7 @@ function executeTask(task: Task): Promise<void> {
|
|
|
234
237
|
const settings = loadSettings();
|
|
235
238
|
const claudePath = settings.claudePath || process.env.CLAUDE_PATH || 'claude';
|
|
236
239
|
|
|
237
|
-
const args = ['-p', '--verbose', '--output-format', 'stream-json'];
|
|
240
|
+
const args = ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions'];
|
|
238
241
|
|
|
239
242
|
// Resume specific session to continue the conversation
|
|
240
243
|
if (task.conversationId) {
|
|
@@ -352,12 +355,14 @@ function executeTask(task: Task): Promise<void> {
|
|
|
352
355
|
emit(task.id, 'status', 'done');
|
|
353
356
|
const doneTask = getTask(task.id);
|
|
354
357
|
if (doneTask) notifyTaskComplete(doneTask).catch(() => {});
|
|
358
|
+
notifyTerminalSession(task, 'done', sessionId);
|
|
355
359
|
resolve();
|
|
356
360
|
} else {
|
|
357
361
|
const errMsg = `Process exited with code ${code}`;
|
|
358
362
|
updateTaskStatus(task.id, 'failed', errMsg);
|
|
359
363
|
const failedTask = getTask(task.id);
|
|
360
364
|
if (failedTask) notifyTaskFailed(failedTask).catch(() => {});
|
|
365
|
+
notifyTerminalSession(task, 'failed', sessionId);
|
|
361
366
|
reject(new Error(errMsg));
|
|
362
367
|
}
|
|
363
368
|
});
|
|
@@ -369,6 +374,59 @@ function executeTask(task: Task): Promise<void> {
|
|
|
369
374
|
});
|
|
370
375
|
}
|
|
371
376
|
|
|
377
|
+
// ─── Terminal notification ────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Notify tmux terminal sessions in the same project directory that a task completed.
|
|
381
|
+
* Sends a visible bell character so the user knows to resume.
|
|
382
|
+
*/
|
|
383
|
+
function notifyTerminalSession(task: Task, status: 'done' | 'failed', sessionId?: string) {
|
|
384
|
+
try {
|
|
385
|
+
const out = execSync(
|
|
386
|
+
`tmux list-sessions -F "#{session_name}" 2>/dev/null`,
|
|
387
|
+
{ encoding: 'utf-8', timeout: 3000 }
|
|
388
|
+
).trim();
|
|
389
|
+
if (!out) return;
|
|
390
|
+
|
|
391
|
+
for (const name of out.split('\n')) {
|
|
392
|
+
if (!name.startsWith('mw-')) continue;
|
|
393
|
+
try {
|
|
394
|
+
const cwd = execSync(
|
|
395
|
+
`tmux display-message -p -t ${name} '#{pane_current_path}'`,
|
|
396
|
+
{ encoding: 'utf-8', timeout: 2000 }
|
|
397
|
+
).trim();
|
|
398
|
+
|
|
399
|
+
// Match: same dir, parent dir, or child dir
|
|
400
|
+
const match = cwd && (
|
|
401
|
+
cwd === task.projectPath ||
|
|
402
|
+
cwd.startsWith(task.projectPath + '/') ||
|
|
403
|
+
task.projectPath.startsWith(cwd + '/')
|
|
404
|
+
);
|
|
405
|
+
if (!match) continue;
|
|
406
|
+
|
|
407
|
+
const paneCmd = execSync(
|
|
408
|
+
`tmux display-message -p -t ${name} '#{pane_current_command}'`,
|
|
409
|
+
{ encoding: 'utf-8', timeout: 2000 }
|
|
410
|
+
).trim();
|
|
411
|
+
|
|
412
|
+
if (status === 'done') {
|
|
413
|
+
const summary = task.prompt.slice(0, 80).replace(/"/g, "'");
|
|
414
|
+
const msg = `A background task just completed. Task: "${summary}". Please check git diff and continue.`;
|
|
415
|
+
|
|
416
|
+
// If a process is running (claude/node), send as input
|
|
417
|
+
if (paneCmd !== 'zsh' && paneCmd !== 'bash' && paneCmd !== 'fish') {
|
|
418
|
+
execSync(`tmux send-keys -t ${name} -- "${msg.replace(/"/g, '\\"')}" Enter`, { timeout: 2000 });
|
|
419
|
+
} else {
|
|
420
|
+
execSync(`tmux display-message -t ${name} "✅ Task ${task.id} done — changes ready"`, { timeout: 2000 });
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
execSync(`tmux display-message -t ${name} "❌ Task ${task.id} failed"`, { timeout: 2000 });
|
|
424
|
+
}
|
|
425
|
+
} catch {}
|
|
426
|
+
}
|
|
427
|
+
} catch {}
|
|
428
|
+
}
|
|
429
|
+
|
|
372
430
|
// ─── Helpers ─────────────────────────────────────────────────
|
|
373
431
|
|
|
374
432
|
/**
|
package/lib/telegram-bot.ts
CHANGED
|
@@ -39,6 +39,9 @@ const chatListMode = new Map<number, 'tasks' | 'projects' | 'sessions' | 'task-c
|
|
|
39
39
|
// Pending task creation: waiting for prompt text
|
|
40
40
|
const pendingTaskProject = new Map<number, { name: string; path: string }>(); // chatId → project
|
|
41
41
|
|
|
42
|
+
// Pending note: waiting for content
|
|
43
|
+
const pendingNote = new Set<number>(); // chatIds waiting for note content
|
|
44
|
+
|
|
42
45
|
// Buffer for streaming logs
|
|
43
46
|
const logBuffers = new Map<string, { entries: string[]; timer: ReturnType<typeof setTimeout> | null }>();
|
|
44
47
|
|
|
@@ -112,10 +115,25 @@ async function poll() {
|
|
|
112
115
|
|
|
113
116
|
async function handleMessage(msg: any) {
|
|
114
117
|
const chatId = msg.chat.id;
|
|
118
|
+
|
|
119
|
+
// Whitelist check — only allow configured chat IDs, block all if not configured
|
|
120
|
+
const settings = loadSettings();
|
|
121
|
+
const allowedIds = settings.telegramChatId.split(',').map((s: string) => s.trim()).filter(Boolean);
|
|
122
|
+
if (allowedIds.length === 0 || !allowedIds.includes(String(chatId))) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
115
126
|
// Message received (logged silently)
|
|
116
127
|
const text: string = msg.text.trim();
|
|
117
128
|
const replyTo = msg.reply_to_message?.message_id;
|
|
118
129
|
|
|
130
|
+
// Check if waiting for note content
|
|
131
|
+
if (pendingNote.has(chatId) && !text.startsWith('/')) {
|
|
132
|
+
pendingNote.delete(chatId);
|
|
133
|
+
await sendNoteToDocsClaude(chatId, text);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
119
137
|
// Check if waiting for task prompt
|
|
120
138
|
const pending = pendingTaskProject.get(chatId);
|
|
121
139
|
if (pending && !text.startsWith('/')) {
|
|
@@ -186,6 +204,7 @@ async function handleMessage(msg: any) {
|
|
|
186
204
|
if (text.startsWith('/')) {
|
|
187
205
|
// Any new command cancels pending states
|
|
188
206
|
pendingTaskProject.delete(chatId);
|
|
207
|
+
pendingNote.delete(chatId);
|
|
189
208
|
|
|
190
209
|
const [cmd, ...args] = text.split(/\s+/);
|
|
191
210
|
switch (cmd) {
|
|
@@ -238,6 +257,10 @@ async function handleMessage(msg: any) {
|
|
|
238
257
|
case '/doc':
|
|
239
258
|
await handleDocs(chatId, args.join(' '));
|
|
240
259
|
break;
|
|
260
|
+
case '/note':
|
|
261
|
+
case '/docs_write':
|
|
262
|
+
await handleDocsWrite(chatId, args.join(' '));
|
|
263
|
+
break;
|
|
241
264
|
case '/cancel':
|
|
242
265
|
await handleCancel(chatId, args[0]);
|
|
243
266
|
break;
|
|
@@ -293,7 +316,8 @@ async function sendHelp(chatId: number) {
|
|
|
293
316
|
`📝 Submit task:\nproject-name: your instructions\n\n` +
|
|
294
317
|
`👀 /peek [project] [sessionId] — session summary\n` +
|
|
295
318
|
`📖 /docs — docs session summary\n` +
|
|
296
|
-
`/docs <filename> — view doc file\n
|
|
319
|
+
`/docs <filename> — view doc file\n` +
|
|
320
|
+
`📝 /note — quick note to docs claude\n\n` +
|
|
297
321
|
`🔧 /cancel <id> /retry <id>\n` +
|
|
298
322
|
`/projects — list projects\n\n` +
|
|
299
323
|
`🌐 /tunnel — tunnel status\n` +
|
|
@@ -1151,6 +1175,79 @@ async function handleDocs(chatId: number, input: string) {
|
|
|
1151
1175
|
}
|
|
1152
1176
|
}
|
|
1153
1177
|
|
|
1178
|
+
// ─── Docs Write (Quick Notes) ────────────────────────────────
|
|
1179
|
+
|
|
1180
|
+
async function handleDocsWrite(chatId: number, content: string) {
|
|
1181
|
+
const settings = loadSettings();
|
|
1182
|
+
if (String(chatId) !== settings.telegramChatId) { await send(chatId, '⛔ Unauthorized'); return; }
|
|
1183
|
+
|
|
1184
|
+
if (!content) {
|
|
1185
|
+
pendingNote.add(chatId);
|
|
1186
|
+
await send(chatId, '📝 Send your note content:');
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
await sendNoteToDocsClaude(chatId, content);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
async function sendNoteToDocsClaude(chatId: number, content: string) {
|
|
1194
|
+
const settings = loadSettings();
|
|
1195
|
+
const docRoots = (settings.docRoots || []).map((r: string) => r.replace(/^~/, require('os').homedir()));
|
|
1196
|
+
|
|
1197
|
+
if (docRoots.length === 0) {
|
|
1198
|
+
await send(chatId, '⚠️ No document directories configured.');
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const { execSync, spawnSync } = require('child_process');
|
|
1203
|
+
const { writeFileSync, unlinkSync } = require('fs');
|
|
1204
|
+
const { join } = require('path');
|
|
1205
|
+
const { homedir } = require('os');
|
|
1206
|
+
const SESSION_NAME = 'mw-docs-claude';
|
|
1207
|
+
|
|
1208
|
+
// Check if the docs tmux session exists
|
|
1209
|
+
let sessionExists = false;
|
|
1210
|
+
try {
|
|
1211
|
+
execSync(`tmux has-session -t ${SESSION_NAME} 2>/dev/null`);
|
|
1212
|
+
sessionExists = true;
|
|
1213
|
+
} catch {}
|
|
1214
|
+
|
|
1215
|
+
if (!sessionExists) {
|
|
1216
|
+
await send(chatId, '⚠️ Docs Claude session not running. Open the Docs tab first to start it.');
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// Check if Claude is the active process (not shell)
|
|
1221
|
+
let paneCmd = '';
|
|
1222
|
+
try {
|
|
1223
|
+
paneCmd = execSync(`tmux display-message -p -t ${SESSION_NAME} '#{pane_current_command}'`, { encoding: 'utf-8', timeout: 2000 }).trim();
|
|
1224
|
+
} catch {}
|
|
1225
|
+
|
|
1226
|
+
if (paneCmd === 'zsh' || paneCmd === 'bash' || paneCmd === 'fish' || !paneCmd) {
|
|
1227
|
+
await send(chatId, '⚠️ Claude is not running in the Docs session. Open the Docs tab and start Claude first.');
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// Write content to a temp file, then use tmux to send a prompt referencing it
|
|
1232
|
+
const tmpFile = join(homedir(), '.forge', '.note-tmp.txt');
|
|
1233
|
+
try {
|
|
1234
|
+
writeFileSync(tmpFile, content, 'utf-8');
|
|
1235
|
+
|
|
1236
|
+
// Send a single-line prompt to Claude via tmux send-keys using the temp file
|
|
1237
|
+
const prompt = `Please read the file ${tmpFile} and save its content as a note in the appropriate location in my docs. Analyze the content to determine the best file and location. After saving, delete the temp file.`;
|
|
1238
|
+
|
|
1239
|
+
// Use tmux send-keys with literal flag to avoid interpretation issues
|
|
1240
|
+
spawnSync('tmux', ['send-keys', '-t', SESSION_NAME, '-l', prompt], { timeout: 5000 });
|
|
1241
|
+
// Send Enter separately
|
|
1242
|
+
spawnSync('tmux', ['send-keys', '-t', SESSION_NAME, 'Enter'], { timeout: 2000 });
|
|
1243
|
+
|
|
1244
|
+
await send(chatId, `📝 Note sent to Docs Claude:\n\n${content.slice(0, 200)}${content.length > 200 ? '...' : ''}`);
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
try { unlinkSync(tmpFile); } catch {}
|
|
1247
|
+
await send(chatId, '❌ Failed to send note to Claude session');
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1154
1251
|
// ─── Real-time Streaming ─────────────────────────────────────
|
|
1155
1252
|
|
|
1156
1253
|
function bufferLogEntry(taskId: string, chatId: number, entry: TaskLogEntry) {
|
|
@@ -1283,6 +1380,7 @@ async function setBotCommands(token: string) {
|
|
|
1283
1380
|
{ command: 'tunnel_password', description: 'Get login password' },
|
|
1284
1381
|
{ command: 'peek', description: 'Session summary (AI + recent)' },
|
|
1285
1382
|
{ command: 'docs', description: 'Docs session summary / view file' },
|
|
1383
|
+
{ command: 'note', description: 'Quick note to docs Claude' },
|
|
1286
1384
|
{ command: 'watch', description: 'Monitor session' },
|
|
1287
1385
|
{ command: 'watchers', description: 'List watchers' },
|
|
1288
1386
|
{ command: 'help', description: 'Show help' },
|