@aion0/forge 0.2.1 → 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.
@@ -26,7 +26,7 @@ interface TmuxSession {
26
26
  }
27
27
 
28
28
  type SplitNode =
29
- | { type: 'terminal'; id: number; sessionName?: string }
29
+ | { type: 'terminal'; id: number; sessionName?: string; projectPath?: string }
30
30
  | { type: 'split'; id: number; direction: 'horizontal' | 'vertical'; ratio: number; first: SplitNode; second: SplitNode };
31
31
 
32
32
  interface TabState {
@@ -35,6 +35,7 @@ interface TabState {
35
35
  tree: SplitNode;
36
36
  ratios: Record<number, number>;
37
37
  activeId: number;
38
+ projectPath?: string; // If set, auto-run claude --resume in this dir on session create
38
39
  }
39
40
 
40
41
  // ─── Layout persistence ──────────────────────────────────────
@@ -99,8 +100,8 @@ function initNextIdFromTabs(tabs: TabState[]) {
99
100
  }
100
101
  }
101
102
 
102
- function makeTerminal(sessionName?: string): SplitNode {
103
- return { type: 'terminal', id: nextId++, sessionName };
103
+ function makeTerminal(sessionName?: string, projectPath?: string): SplitNode {
104
+ return { type: 'terminal', id: nextId++, sessionName, projectPath };
104
105
  }
105
106
 
106
107
  function makeSplit(direction: 'horizontal' | 'vertical', first: SplitNode, second: SplitNode): SplitNode {
@@ -178,6 +179,10 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
178
179
  const dragTabRef = useRef<number | null>(null);
179
180
  const [refreshKeys, setRefreshKeys] = useState<Record<number, number>>({});
180
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)
@@ -248,10 +262,11 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
248
262
 
249
263
  // ─── Tab operations ───────────────────────────────────
250
264
 
251
- const addTab = useCallback(() => {
252
- const tree = makeTerminal();
265
+ const addTab = useCallback((projectPath?: string) => {
266
+ const tree = makeTerminal(undefined, projectPath);
253
267
  const tabNum = tabs.length + 1;
254
- 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 };
255
270
  setTabs(prev => [...prev, newTab]);
256
271
  setActiveTabId(newTab.id);
257
272
  }, [tabs.length]);
@@ -486,9 +501,9 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
486
501
  </div>
487
502
  ))}
488
503
  <button
489
- onClick={addTab}
504
+ onClick={() => setShowNewTabModal(true)}
490
505
  className="px-2 py-1 text-[11px] text-gray-500 hover:text-white hover:bg-[#2a2a4a]"
491
- title="New terminal tab"
506
+ title="New tab"
492
507
  >
493
508
  +
494
509
  </button>
@@ -631,6 +646,75 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
631
646
  </div>
632
647
  )}
633
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
+
634
718
  {/* Close confirmation dialog */}
635
719
  {closeConfirm && (
636
720
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setCloseConfirm(null)}>
@@ -706,7 +790,7 @@ function PaneRenderer({
706
790
  if (node.type === 'terminal') {
707
791
  return (
708
792
  <div className={`h-full w-full ${activeId === node.id ? 'ring-1 ring-[#7c5bf0]/50 ring-inset' : ''}`} onMouseDown={() => onFocus(node.id)}>
709
- <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} />
710
794
  </div>
711
795
  );
712
796
  }
@@ -836,15 +920,19 @@ function DraggableSplit({
836
920
  const MemoTerminalPane = memo(function TerminalPane({
837
921
  id,
838
922
  sessionName,
923
+ projectPath,
839
924
  onSessionConnected,
840
925
  }: {
841
926
  id: number;
842
927
  sessionName?: string;
928
+ projectPath?: string;
843
929
  onSessionConnected: (paneId: number, sessionName: string) => void;
844
930
  }) {
845
931
  const containerRef = useRef<HTMLDivElement>(null);
846
932
  const sessionNameRef = useRef(sessionName);
847
933
  sessionNameRef.current = sessionName;
934
+ const projectPathRef = useRef(projectPath);
935
+ projectPathRef.current = projectPath;
848
936
 
849
937
  useEffect(() => {
850
938
  if (!containerRef.current) return;
@@ -921,6 +1009,7 @@ const MemoTerminalPane = memo(function TerminalPane({
921
1009
  let createRetries = 0;
922
1010
  const MAX_CREATE_RETRIES = 2;
923
1011
  let reconnectAttempts = 0;
1012
+ let isNewlyCreated = false;
924
1013
 
925
1014
  function connect() {
926
1015
  if (disposed) return;
@@ -942,6 +1031,7 @@ const MemoTerminalPane = memo(function TerminalPane({
942
1031
  socket.send(JSON.stringify({ type: 'attach', sessionName: sn, cols, rows }));
943
1032
  } else if (createRetries < MAX_CREATE_RETRIES) {
944
1033
  createRetries++;
1034
+ isNewlyCreated = true;
945
1035
  socket.send(JSON.stringify({ type: 'create', cols, rows }));
946
1036
  } else {
947
1037
  term.write('\r\n\x1b[91m[failed to create session — check server logs]\x1b[0m\r\n');
@@ -960,6 +1050,16 @@ const MemoTerminalPane = memo(function TerminalPane({
960
1050
  createRetries = 0;
961
1051
  reconnectAttempts = 0;
962
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;
963
1063
  // Force tmux to redraw by toggling size, then send reset
964
1064
  setTimeout(() => {
965
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.1",
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": {