@aion0/forge 0.5.6 → 0.5.7

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 CHANGED
@@ -1,8 +1,11 @@
1
- # Forge v0.5.6
1
+ # Forge v0.5.7
2
2
 
3
- Released: 2026-03-28
3
+ Released: 2026-03-29
4
4
 
5
- ## Changes since v0.5.5
5
+ ## Changes since v0.5.6
6
6
 
7
+ ### Other
8
+ - v0.5.6: watch enhancements, skills UI, dev mode fixes
7
9
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.5...v0.5.6
10
+
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.6...v0.5.7
@@ -378,6 +378,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
378
378
  ))}
379
379
  </div>
380
380
  </div>
381
+ <span className="text-[8px] px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400">Claude Code</span>
381
382
  <button
382
383
  onClick={sync}
383
384
  disabled={syncing}
@@ -22,7 +22,7 @@ interface AgentConfig {
22
22
  outputs: string[];
23
23
  steps: { id: string; label: string; prompt: string }[];
24
24
  requiresApproval?: boolean;
25
- watch?: { enabled: boolean; interval: number; targets: any[]; action?: 'log' | 'analyze' | 'approve'; prompt?: string };
25
+ watch?: { enabled: boolean; interval: number; targets: any[]; action?: 'log' | 'analyze' | 'approve' | 'send_message'; prompt?: string; sendTo?: string };
26
26
  }
27
27
 
28
28
  interface AgentState {
@@ -322,6 +322,55 @@ function useWorkspaceStream(workspaceId: string | null, onEvent?: (event: any) =
322
322
  return { agents, states, logPreview, busLog, setAgents, daemonActive, setDaemonActive };
323
323
  }
324
324
 
325
+ // ─── Session Target Selector (for Watch) ─────────────────
326
+
327
+ function SessionTargetSelector({ target, agents, projectPath, onChange }: {
328
+ target: { type: string; path?: string; pattern?: string; cmd?: string };
329
+ agents: AgentConfig[];
330
+ projectPath?: string;
331
+ onChange: (updated: typeof target) => void;
332
+ }) {
333
+ const [sessions, setSessions] = useState<{ id: string; modified: string; label: string }[]>([]);
334
+
335
+ // Load sessions when agent changes
336
+ useEffect(() => {
337
+ if (!projectPath) return;
338
+ const pName = (projectPath || '').replace(/\/+$/, '').split('/').pop() || '';
339
+ fetch(`/api/claude-sessions/${encodeURIComponent(pName)}`)
340
+ .then(r => r.json())
341
+ .then(data => {
342
+ if (Array.isArray(data)) {
343
+ setSessions(data.map((s: any, i: number) => ({
344
+ id: s.sessionId || s.id || '',
345
+ modified: s.modified || '',
346
+ label: i === 0 ? `${(s.sessionId || '').slice(0, 8)} (latest)` : (s.sessionId || '').slice(0, 8),
347
+ })));
348
+ }
349
+ })
350
+ .catch(() => {});
351
+ }, [projectPath]);
352
+
353
+ return (
354
+ <>
355
+ <select value={target.path || ''} onChange={e => onChange({ ...target, path: e.target.value, cmd: '' })}
356
+ className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-24">
357
+ <option value="">Any agent</option>
358
+ {agents.map(a => <option key={a.id} value={a.id}>{a.icon} {a.label}</option>)}
359
+ </select>
360
+ <select value={target.cmd || ''} onChange={e => onChange({ ...target, cmd: e.target.value })}
361
+ className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-28">
362
+ <option value="">Latest session</option>
363
+ {sessions.map(s => (
364
+ <option key={s.id} value={s.id}>{s.label}{s.modified ? ` · ${new Date(s.modified).toLocaleDateString()}` : ''}</option>
365
+ ))}
366
+ </select>
367
+ <input value={target.pattern || ''} onChange={e => onChange({ ...target, pattern: e.target.value })}
368
+ placeholder="regex (optional)"
369
+ className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-24" />
370
+ </>
371
+ );
372
+ }
373
+
325
374
  // ─── Agent Config Modal ──────────────────────────────────
326
375
 
327
376
  function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfirm, onCancel }: {
@@ -358,9 +407,11 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
358
407
  const [requiresApproval, setRequiresApproval] = useState(initial.requiresApproval || false);
359
408
  const [watchEnabled, setWatchEnabled] = useState(initial.watch?.enabled || false);
360
409
  const [watchInterval, setWatchInterval] = useState(String(initial.watch?.interval || 60));
361
- const [watchAction, setWatchAction] = useState<'log' | 'analyze' | 'approve'>(initial.watch?.action || 'log');
410
+ const [watchAction, setWatchAction] = useState<'log' | 'analyze' | 'approve' | 'send_message'>(initial.watch?.action || 'log');
362
411
  const [watchPrompt, setWatchPrompt] = useState(initial.watch?.prompt || '');
363
- const [watchTargets, setWatchTargets] = useState<{ type: string; path?: string; cmd?: string }[]>(
412
+ const [watchSendTo, setWatchSendTo] = useState(initial.watch?.sendTo || '');
413
+ const [watchDebounce, setWatchDebounce] = useState(String(initial.watch?.targets?.[0]?.debounce ?? 10));
414
+ const [watchTargets, setWatchTargets] = useState<{ type: string; path?: string; cmd?: string; pattern?: string }[]>(
364
415
  initial.watch?.targets || []
365
416
  );
366
417
  const [projectDirs, setProjectDirs] = useState<string[]>([]);
@@ -567,10 +618,15 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
567
618
  </div>
568
619
  {watchEnabled && (<>
569
620
  <div className="flex gap-2">
570
- <div className="flex flex-col gap-0.5 flex-1">
571
- <label className="text-[8px] text-gray-600">Interval (seconds)</label>
621
+ <div className="flex flex-col gap-0.5">
622
+ <label className="text-[8px] text-gray-600">Interval (s)</label>
572
623
  <input value={watchInterval} onChange={e => setWatchInterval(e.target.value)} type="number" min="10"
573
- className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] w-20" />
624
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] w-16" />
625
+ </div>
626
+ <div className="flex flex-col gap-0.5">
627
+ <label className="text-[8px] text-gray-600">Debounce (s)</label>
628
+ <input value={watchDebounce} onChange={e => setWatchDebounce(e.target.value)} type="number" min="0"
629
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] w-16" />
574
630
  </div>
575
631
  <div className="flex flex-col gap-0.5 flex-1">
576
632
  <label className="text-[8px] text-gray-600">On Change</label>
@@ -579,8 +635,21 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
579
635
  <option value="log">Log only</option>
580
636
  <option value="analyze">Auto analyze</option>
581
637
  <option value="approve">Require approval</option>
638
+ <option value="send_message">Send to agent</option>
582
639
  </select>
583
640
  </div>
641
+ {watchAction === 'send_message' && (
642
+ <div className="flex flex-col gap-0.5 flex-1">
643
+ <label className="text-[8px] text-gray-600">Send to</label>
644
+ <select value={watchSendTo} onChange={e => setWatchSendTo(e.target.value)}
645
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]">
646
+ <option value="">Select agent...</option>
647
+ {existingAgents.filter(a => a.id !== initial.id).map(a =>
648
+ <option key={a.id} value={a.id}>{a.icon} {a.label}</option>
649
+ )}
650
+ </select>
651
+ </div>
652
+ )}
584
653
  </div>
585
654
  <div className="flex flex-col gap-1">
586
655
  <label className="text-[8px] text-gray-600">Targets</label>
@@ -594,6 +663,8 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
594
663
  <option value="directory">Directory</option>
595
664
  <option value="git">Git</option>
596
665
  <option value="agent_output">Agent Output</option>
666
+ <option value="agent_log">Agent Log</option>
667
+ <option value="session">Session Output</option>
597
668
  <option value="command">Command</option>
598
669
  </select>
599
670
  {t.type === 'directory' && (
@@ -618,6 +689,36 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
618
689
  )}
619
690
  </select>
620
691
  )}
692
+ {t.type === 'agent_log' && (<>
693
+ <select value={t.path || ''} onChange={e => {
694
+ const next = [...watchTargets];
695
+ next[i] = { ...t, path: e.target.value };
696
+ setWatchTargets(next);
697
+ }} className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white flex-1">
698
+ <option value="">Select agent...</option>
699
+ {existingAgents.filter(a => a.id !== initial.id).map(a =>
700
+ <option key={a.id} value={a.id}>{a.icon} {a.label}</option>
701
+ )}
702
+ </select>
703
+ <input value={t.pattern || ''} onChange={e => {
704
+ const next = [...watchTargets];
705
+ next[i] = { ...t, pattern: e.target.value };
706
+ setWatchTargets(next);
707
+ }} placeholder="keyword (optional)"
708
+ className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-24" />
709
+ </>)}
710
+ {t.type === 'session' && (
711
+ <SessionTargetSelector
712
+ target={t}
713
+ agents={existingAgents.filter(a => a.id !== initial.id && (!a.agentId || a.agentId === 'claude'))}
714
+ projectPath={projectPath}
715
+ onChange={(updated) => {
716
+ const next = [...watchTargets];
717
+ next[i] = updated;
718
+ setWatchTargets(next);
719
+ }}
720
+ />
721
+ )}
621
722
  {t.type === 'command' && (
622
723
  <input value={t.cmd || ''} onChange={e => {
623
724
  const next = [...watchTargets];
@@ -641,6 +742,14 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
641
742
  className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
642
743
  </div>
643
744
  )}
745
+ {watchAction === 'send_message' && (
746
+ <div className="flex flex-col gap-0.5">
747
+ <label className="text-[8px] text-gray-600">Message context (sent with detected changes)</label>
748
+ <input value={watchPrompt} onChange={e => setWatchPrompt(e.target.value)}
749
+ placeholder="Review the following changes and report issues..."
750
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
751
+ </div>
752
+ )}
644
753
  </>)}
645
754
  </div>
646
755
  </div>
@@ -658,9 +767,10 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
658
767
  watch: watchEnabled && watchTargets.length > 0 ? {
659
768
  enabled: true,
660
769
  interval: Math.max(10, parseInt(watchInterval) || 60),
661
- targets: watchTargets,
770
+ targets: watchTargets.map(t => ({ ...t, debounce: parseInt(watchDebounce) || 10 })),
662
771
  action: watchAction,
663
772
  prompt: watchPrompt || undefined,
773
+ sendTo: watchSendTo || undefined,
664
774
  } : undefined,
665
775
  } as any);
666
776
  }} className="text-xs px-3 py-1.5 rounded bg-[#238636] text-white hover:bg-[#2ea043] disabled:opacity-40">
@@ -1090,6 +1090,37 @@ export class WorkspaceOrchestrator extends EventEmitter {
1090
1090
  this.emit('event', { type: 'bus_message_status', messageId: msg.id, status: 'pending_approval' } as any);
1091
1091
  console.log(`[watch] ${entry.config.label}: changes detected, awaiting approval`);
1092
1092
  }
1093
+
1094
+ if (action === 'send_message') {
1095
+ const targetId = entry.config.watch?.sendTo;
1096
+ if (!targetId) {
1097
+ console.log(`[watch] ${entry.config.label}: send_message but no sendTo configured`);
1098
+ return;
1099
+ }
1100
+ const targetEntry = this.agents.get(targetId);
1101
+ if (!targetEntry) {
1102
+ console.log(`[watch] ${entry.config.label}: sendTo agent ${targetId} not found`);
1103
+ return;
1104
+ }
1105
+
1106
+ // Skip if target already has a pending/running message from this watch
1107
+ const hasPendingFromWatch = this.bus.getLog().some(m =>
1108
+ m.from === agentId && m.to === targetId &&
1109
+ (m.status === 'pending' || m.status === 'running' || m.status === 'pending_approval') &&
1110
+ m.type !== 'ack'
1111
+ );
1112
+ if (hasPendingFromWatch) {
1113
+ console.log(`[watch] ${entry.config.label}: skipping send — target ${targetEntry.config.label} still processing previous message`);
1114
+ return;
1115
+ }
1116
+
1117
+ const prompt = entry.config.watch?.prompt || 'Watch detected changes, please review:';
1118
+ this.bus.send(agentId, targetId, 'notify', {
1119
+ action: 'watch_alert',
1120
+ content: `${prompt}\n\n${summary}`,
1121
+ });
1122
+ console.log(`[watch] ${entry.config.label} → ${targetEntry.config.label}: sent watch alert`);
1123
+ }
1093
1124
  }
1094
1125
 
1095
1126
  /** Check if daemon mode is active */
@@ -39,20 +39,23 @@ export interface WorkspaceAgentConfig {
39
39
  // ─── Watch Config ─────────────────────────────────────────
40
40
 
41
41
  export interface WatchTarget {
42
- type: 'directory' | 'git' | 'agent_output' | 'command';
43
- path?: string; // directory: relative path; agent_output: agent ID
44
- pattern?: string; // glob for directory, stdout pattern for command
42
+ type: 'directory' | 'git' | 'agent_output' | 'agent_log' | 'session' | 'command';
43
+ path?: string; // directory: relative path; agent_output/agent_log: agent ID
44
+ pattern?: string; // glob for directory, regex/keyword for agent_log, stdout pattern for command
45
45
  cmd?: string; // shell command (type='command' only)
46
+ contextChars?: number; // agent_log/session: chars to capture around match (default 500)
47
+ debounce?: number; // seconds to wait after match before triggering (default 10)
46
48
  }
47
49
 
48
- export type WatchAction = 'log' | 'analyze' | 'approve';
50
+ export type WatchAction = 'log' | 'analyze' | 'approve' | 'send_message';
49
51
 
50
52
  export interface WatchConfig {
51
53
  enabled: boolean;
52
54
  interval: number; // check interval in seconds (default 60)
53
55
  targets: WatchTarget[];
54
- action: WatchAction; // log=report only, analyze=auto-execute, approve=pending user approval
56
+ action: WatchAction; // log=report only, analyze=auto-execute, approve=pending user approval, send_message=send to target agent
55
57
  prompt?: string; // custom prompt for analyze action (default: "Analyze the following changes...")
58
+ sendTo?: string; // send_message: target agent ID
56
59
  }
57
60
 
58
61
  export type AgentBackendType = 'api' | 'cli';
@@ -7,8 +7,9 @@
7
7
  */
8
8
 
9
9
  import { EventEmitter } from 'node:events';
10
- import { existsSync, readdirSync, statSync } from 'node:fs';
10
+ import { existsSync, readdirSync, statSync, readFileSync, openSync, readSync, fstatSync, closeSync } from 'node:fs';
11
11
  import { join, relative } from 'node:path';
12
+ import { homedir } from 'node:os';
12
13
  import { execSync } from 'node:child_process';
13
14
  import type { WorkspaceAgentConfig, WatchTarget, WatchConfig } from './types';
14
15
  import { appendAgentLog } from './persistence';
@@ -19,6 +20,8 @@ interface WatchSnapshot {
19
20
  lastCheckTime: number; // timestamp ms — only files modified after this are "changed"
20
21
  gitHash?: string;
21
22
  commandOutput?: string;
23
+ logLineCount?: number; // last known line count in agent's logs.jsonl
24
+ sessionFileSize?: number; // last known file size of session JSONL (bytes)
22
25
  }
23
26
 
24
27
  interface WatchChange {
@@ -132,11 +135,186 @@ function detectCommandChanges(projectPath: string, target: WatchTarget, prevOutp
132
135
  }
133
136
  }
134
137
 
138
+ function detectAgentLogChanges(workspaceId: string, targetAgentId: string, pattern: string | undefined, prevLineCount: number, contextChars = 500): { changes: WatchChange | null; lineCount: number } {
139
+ const logFile = join(homedir(), '.forge', 'workspaces', workspaceId, 'agents', targetAgentId, 'logs.jsonl');
140
+ if (!existsSync(logFile)) return { changes: null, lineCount: 0 };
141
+
142
+ try {
143
+ const lines = readFileSync(logFile, 'utf-8').split('\n').filter(Boolean);
144
+ const currentCount = lines.length;
145
+ if (currentCount <= prevLineCount) return { changes: null, lineCount: currentCount };
146
+
147
+ // Get new lines since last check
148
+ const newLines = lines.slice(prevLineCount);
149
+ const newEntries: string[] = [];
150
+
151
+ // Build matcher: try regex first, fallback to case-insensitive includes
152
+ let matcher: ((text: string) => boolean) | null = null;
153
+ if (pattern) {
154
+ try {
155
+ const re = new RegExp(pattern, 'i');
156
+ matcher = (text: string) => re.test(text);
157
+ } catch {
158
+ const lower = pattern.toLowerCase();
159
+ matcher = (text: string) => text.toLowerCase().includes(lower);
160
+ }
161
+ }
162
+
163
+ // Extract content around match (contextChars before + after match point)
164
+ const extractContext = (text: string): string => {
165
+ if (!pattern || text.length <= contextChars) return text.slice(0, contextChars);
166
+ // Find match position for context window
167
+ let matchIdx = 0;
168
+ try {
169
+ const re = new RegExp(pattern, 'i');
170
+ const m = re.exec(text);
171
+ if (m) matchIdx = m.index;
172
+ } catch {
173
+ matchIdx = text.toLowerCase().indexOf(pattern.toLowerCase());
174
+ if (matchIdx === -1) matchIdx = 0;
175
+ }
176
+ const half = Math.floor(contextChars / 2);
177
+ const start = Math.max(0, matchIdx - half);
178
+ const end = Math.min(text.length, start + contextChars);
179
+ return (start > 0 ? '...' : '') + text.slice(start, end) + (end < text.length ? '...' : '');
180
+ };
181
+
182
+ for (const line of newLines) {
183
+ try {
184
+ const entry = JSON.parse(line);
185
+ const content = entry.content || '';
186
+ if (matcher && !matcher(content)) continue;
187
+ newEntries.push(extractContext(content));
188
+ } catch {
189
+ if (matcher && !matcher(line)) continue;
190
+ newEntries.push(extractContext(line));
191
+ }
192
+ }
193
+
194
+ if (newEntries.length === 0) return { changes: null, lineCount: currentCount };
195
+
196
+ return {
197
+ changes: {
198
+ targetType: 'agent_log',
199
+ description: `${newEntries.length} new log entries${pattern ? ` matching "${pattern}"` : ''}`,
200
+ files: newEntries.slice(0, 10),
201
+ },
202
+ lineCount: currentCount,
203
+ };
204
+ } catch {
205
+ return { changes: null, lineCount: prevLineCount };
206
+ }
207
+ }
208
+
209
+ function detectSessionChanges(projectPath: string, pattern: string | undefined, prevLineCount: number, contextChars = 500, sessionId?: string): { changes: WatchChange | null; lineCount: number } {
210
+ // Find session file for this project
211
+ const claudeHome = join(homedir(), '.claude', 'projects');
212
+ const encoded = projectPath.replace(/\//g, '-');
213
+ const sessionDir = join(claudeHome, encoded);
214
+ if (!existsSync(sessionDir)) return { changes: null, lineCount: prevLineCount };
215
+
216
+ try {
217
+ let latestFile: string;
218
+
219
+ if (sessionId) {
220
+ // Use specific session ID
221
+ latestFile = join(sessionDir, `${sessionId}.jsonl`);
222
+ if (!existsSync(latestFile)) return { changes: null, lineCount: prevLineCount };
223
+ } else {
224
+ // Find most recently modified .jsonl file
225
+ const files = readdirSync(sessionDir)
226
+ .filter(f => f.endsWith('.jsonl'))
227
+ .map(f => ({ name: f, mtime: statSync(join(sessionDir, f)).mtimeMs }))
228
+ .sort((a, b) => b.mtime - a.mtime);
229
+ if (files.length === 0) return { changes: null, lineCount: prevLineCount };
230
+ latestFile = join(sessionDir, files[0].name);
231
+ }
232
+ // Only read new bytes since last check (efficient for large 70MB+ files)
233
+ const fd = openSync(latestFile, 'r');
234
+ const fileSize = fstatSync(fd).size;
235
+ if (fileSize <= prevLineCount) { closeSync(fd); return { changes: null, lineCount: fileSize }; }
236
+
237
+ const readFrom = Math.max(0, prevLineCount > 0 ? prevLineCount - 1 : 0);
238
+ const readSize = Math.min(fileSize - readFrom, 500_000); // max 500KB per check
239
+ const buf = Buffer.alloc(readSize);
240
+ readSync(fd, buf, 0, readSize, readFrom);
241
+ closeSync(fd);
242
+
243
+ const tail = buf.toString('utf-8');
244
+ const newLines = tail.split('\n').filter(Boolean);
245
+ if (prevLineCount > 0 && newLines.length > 0) newLines.shift(); // skip partial first line
246
+
247
+ // Build matcher
248
+ let matcher: ((text: string) => boolean) | null = null;
249
+ if (pattern) {
250
+ try {
251
+ const re = new RegExp(pattern, 'i');
252
+ matcher = (text: string) => re.test(text);
253
+ } catch {
254
+ const lower = pattern.toLowerCase();
255
+ matcher = (text: string) => text.toLowerCase().includes(lower);
256
+ }
257
+ }
258
+
259
+ const entries: string[] = [];
260
+ for (const line of newLines) {
261
+ try {
262
+ const parsed = JSON.parse(line);
263
+ // Extract text content from various session JSONL formats
264
+ let text = '';
265
+ if (parsed.type === 'assistant' && parsed.message?.content) {
266
+ for (const block of (Array.isArray(parsed.message.content) ? parsed.message.content : [parsed.message.content])) {
267
+ if (typeof block === 'string') text += block;
268
+ else if (block.type === 'text' && block.text) text += block.text;
269
+ else if (block.type === 'tool_use') text += `[tool: ${block.name}] `;
270
+ }
271
+ } else if (parsed.type === 'result' && parsed.result) {
272
+ text = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
273
+ } else if (parsed.type === 'human' || parsed.type === 'user') {
274
+ const content = parsed.content || parsed.message?.content;
275
+ text = typeof content === 'string' ? content : '';
276
+ }
277
+
278
+ if (!text) continue;
279
+ if (matcher && !matcher(text)) continue;
280
+
281
+ // Context extraction around match
282
+ if (text.length > contextChars && pattern) {
283
+ let matchIdx = 0;
284
+ try { matchIdx = new RegExp(pattern, 'i').exec(text)?.index || 0; } catch {}
285
+ const half = Math.floor(contextChars / 2);
286
+ const start = Math.max(0, matchIdx - half);
287
+ const end = Math.min(text.length, start + contextChars);
288
+ text = (start > 0 ? '...' : '') + text.slice(start, end) + (end < text.length ? '...' : '');
289
+ } else {
290
+ text = text.slice(0, contextChars);
291
+ }
292
+ entries.push(text);
293
+ } catch {}
294
+ }
295
+
296
+ if (entries.length === 0) return { changes: null, lineCount: fileSize };
297
+
298
+ return {
299
+ changes: {
300
+ targetType: 'session',
301
+ description: `${entries.length} new session entries${pattern ? ` matching "${pattern}"` : ''}`,
302
+ files: entries.slice(0, 10),
303
+ },
304
+ lineCount: fileSize, // actually bytes, reusing field name
305
+ };
306
+ } catch {
307
+ return { changes: null, lineCount: prevLineCount };
308
+ }
309
+ }
310
+
135
311
  // ─── WatchManager class ──────────────────────────────────
136
312
 
137
313
  export class WatchManager extends EventEmitter {
138
314
  private timers = new Map<string, NodeJS.Timeout>();
139
315
  private snapshots = new Map<string, WatchSnapshot>();
316
+ private pendingAlert = new Map<string, { changes: WatchChange[]; summary: string; timestamp: number }>();
317
+ private debounceTimers = new Map<string, NodeJS.Timeout>();
140
318
 
141
319
  constructor(
142
320
  private workspaceId: string,
@@ -229,6 +407,37 @@ export class WatchManager extends EventEmitter {
229
407
  }
230
408
  break;
231
409
  }
410
+ case 'agent_log': {
411
+ if (target.path) {
412
+ const agentLabel = this.getAgents().get(target.path)?.config.label || target.path;
413
+ const { changes, lineCount } = detectAgentLogChanges(this.workspaceId, target.path, target.pattern, prev.logLineCount || 0, target.contextChars || 500);
414
+ newSnapshot.logLineCount = lineCount;
415
+ if (changes) allChanges.push({ ...changes, description: `${agentLabel} log: ${changes.description}` });
416
+ }
417
+ break;
418
+ }
419
+ case 'session': {
420
+ // Resolve session ID: explicit (cmd field) > agent's cliSessionId > latest file
421
+ let sessionId: string | undefined;
422
+ if (target.cmd) {
423
+ // Explicit session ID selected by user
424
+ sessionId = target.cmd;
425
+ } else if (target.path) {
426
+ // Agent selected — use its current cliSessionId
427
+ const agents = this.getAgents();
428
+ const targetAgent = agents.get(target.path);
429
+ if (targetAgent) {
430
+ sessionId = (targetAgent.state as any).cliSessionId;
431
+ if (!sessionId) {
432
+ console.log(`[watch] Agent ${targetAgent.config.label} has no cliSessionId — falling back to latest session file`);
433
+ }
434
+ }
435
+ }
436
+ const { changes, lineCount: newFileSize } = detectSessionChanges(this.projectPath, target.pattern, prev.sessionFileSize || 0, target.contextChars || 500, sessionId);
437
+ newSnapshot.sessionFileSize = newFileSize;
438
+ if (changes) allChanges.push(changes);
439
+ break;
440
+ }
232
441
  case 'command': {
233
442
  const { changes, commandOutput } = detectCommandChanges(this.projectPath, target, prev.commandOutput);
234
443
  newSnapshot.commandOutput = commandOutput;
@@ -259,10 +468,40 @@ export class WatchManager extends EventEmitter {
259
468
 
260
469
  console.log(`[watch] ${config.label}: detected ${allChanges.length} change(s)`);
261
470
 
471
+ // Get debounce from first target that has it, or default 10s
472
+ const debounceMs = (config.watch!.targets.find(t => t.debounce !== undefined)?.debounce ?? 10) * 1000;
473
+
474
+ if (debounceMs > 0) {
475
+ // Accumulate changes, reset timer each time
476
+ const existing = this.pendingAlert.get(agentId);
477
+ const merged = existing ? [...existing.changes, ...allChanges] : allChanges;
478
+ const mergedSummary = merged.map(c =>
479
+ `[${c.targetType}] ${c.description}${c.files.length ? '\n ' + c.files.join('\n ') : ''}`
480
+ ).join('\n');
481
+ this.pendingAlert.set(agentId, { changes: merged, summary: mergedSummary, timestamp: Date.now() });
482
+
483
+ // Clear previous debounce timer, set new one
484
+ const prevTimer = this.debounceTimers.get(agentId);
485
+ if (prevTimer) clearTimeout(prevTimer);
486
+
487
+ this.debounceTimers.set(agentId, setTimeout(() => {
488
+ const pending = this.pendingAlert.get(agentId);
489
+ if (!pending) return;
490
+ this.pendingAlert.delete(agentId);
491
+ this.debounceTimers.delete(agentId);
492
+ this.emitAlert(agentId, config, pending.changes, pending.summary);
493
+ }, debounceMs));
494
+
495
+ console.log(`[watch] ${config.label}: debouncing ${debounceMs / 1000}s...`);
496
+ } else {
497
+ this.emitAlert(agentId, config, allChanges, summary);
498
+ }
499
+ }
500
+
501
+ private emitAlert(agentId: string, config: WorkspaceAgentConfig, allChanges: WatchChange[], summary: string): void {
262
502
  const entry = { type: 'system' as const, subtype: 'watch_detected', content: `🔍 Watch detected changes:\n${summary}`, timestamp: new Date().toISOString() };
263
503
  appendAgentLog(this.workspaceId, agentId, entry).catch(() => {});
264
504
 
265
- // Emit SSE event for UI
266
505
  this.emit('watch_alert', {
267
506
  type: 'watch_alert',
268
507
  agentId,
package/middleware.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
2
 
3
3
  export function middleware(req: NextRequest) {
4
+ // Skip auth entirely in dev mode
5
+ const isDev = process.env.NODE_ENV !== 'production';
6
+ if (isDev) {
7
+ return NextResponse.next();
8
+ }
9
+
4
10
  const { pathname } = req.nextUrl;
5
11
 
6
12
  // Allow auth endpoints and static assets without login
package/next.config.ts CHANGED
@@ -1,9 +1,17 @@
1
1
  import type { NextConfig } from 'next';
2
+ import { networkInterfaces } from 'node:os';
2
3
 
3
4
  const terminalPort = parseInt(process.env.TERMINAL_PORT || '') || 3001;
4
5
 
6
+ // Auto-detect local IPs for dev mode cross-origin access
7
+ const localIPs = Object.values(networkInterfaces())
8
+ .flat()
9
+ .filter(i => i && !i.internal && i.family === 'IPv4')
10
+ .map(i => i!.address);
11
+
5
12
  const nextConfig: NextConfig = {
6
13
  serverExternalPackages: ['better-sqlite3'],
14
+ allowedDevOrigins: localIPs,
7
15
  async rewrites() {
8
16
  return [
9
17
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {