@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.
@@ -13,8 +13,7 @@ export interface WebTerminalHandle {
13
13
 
14
14
  export interface WebTerminalProps {
15
15
  onActiveSession?: (sessionName: string | null) => void;
16
- codeOpen?: boolean;
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, codeOpen, onToggleCode }, ref) {
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 (!onActiveSession || !activeTab) return;
220
- const sessions = collectSessionNames(activeTab.tree);
221
- onActiveSession(sessions[0] || null);
222
- }, [activeTabId, activeTab, onActiveSession]);
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 newTab: TabState = { id: nextId++, label: `Terminal ${tabNum}`, tree, ratios: {}, activeId: firstTerminalId(tree) };
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={addTab}
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 terminal tab"
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
- {onToggleCode && (
542
+ {onCodeOpenChange && activeTab && (
523
543
  <button
524
- onClick={onToggleCode}
525
- className={`text-[11px] px-3 py-1 rounded font-bold ${codeOpen ? 'text-white bg-red-500 hover:bg-red-400' : 'text-red-400 border border-red-500 hover:bg-red-500 hover:text-white'}`}
526
- title={codeOpen ? 'Hide code panel' : 'Show code panel'}
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;
@@ -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
- let runner: ReturnType<typeof setInterval> | null = null;
16
- let currentTaskId: string | null = null;
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
  /**
@@ -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\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' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {