@aion0/forge 0.5.5 → 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 +5 -12
- package/components/SkillsPanel.tsx +1 -0
- package/components/WorkspaceView.tsx +159 -17
- package/lib/workspace/orchestrator.ts +31 -0
- package/lib/workspace/types.ts +8 -5
- package/lib/workspace/watch-manager.ts +241 -2
- package/middleware.ts +6 -0
- package/next.config.ts +8 -0
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
|
-
# Forge v0.5.
|
|
1
|
+
# Forge v0.5.7
|
|
2
2
|
|
|
3
|
-
Released: 2026-03-
|
|
3
|
+
Released: 2026-03-29
|
|
4
4
|
|
|
5
|
-
## Changes since v0.5.
|
|
6
|
-
|
|
7
|
-
### Features
|
|
8
|
-
- feat: API token auth for Help AI — password-based, 24h validity
|
|
9
|
-
- feat: enhanced agent role presets with detailed prompts + UI Designer
|
|
10
|
-
|
|
11
|
-
### Bug Fixes
|
|
12
|
-
- fix: whitelist all /api/workspace routes in auth middleware
|
|
5
|
+
## Changes since v0.5.6
|
|
13
6
|
|
|
14
7
|
### Other
|
|
15
|
-
-
|
|
8
|
+
- v0.5.6: watch enhancements, skills UI, dev mode fixes
|
|
16
9
|
|
|
17
10
|
|
|
18
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.
|
|
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 [
|
|
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
|
|
571
|
-
<label className="text-[8px] text-gray-600">Interval (
|
|
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-
|
|
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">
|
|
@@ -730,21 +840,47 @@ function RunPromptDialog({ agentLabel, onRun, onCancel }: {
|
|
|
730
840
|
|
|
731
841
|
// ─── Log Panel (overlay) ─────────────────────────────────
|
|
732
842
|
|
|
733
|
-
/** Format log content:
|
|
734
|
-
function LogContent({ content }: { content: string }) {
|
|
843
|
+
/** Format log content: extract readable text from JSON, format nicely */
|
|
844
|
+
function LogContent({ content, subtype }: { content: string; subtype?: string }) {
|
|
735
845
|
if (!content) return null;
|
|
736
|
-
const MAX_LINES =
|
|
737
|
-
const MAX_CHARS =
|
|
846
|
+
const MAX_LINES = 40;
|
|
847
|
+
const MAX_CHARS = 4000;
|
|
738
848
|
|
|
739
849
|
let text = content;
|
|
740
850
|
|
|
741
|
-
// Try to parse JSON and extract readable content
|
|
851
|
+
// Try to parse JSON and extract human-readable content
|
|
742
852
|
if (text.startsWith('{') || text.startsWith('[')) {
|
|
743
853
|
try {
|
|
744
854
|
const parsed = JSON.parse(text);
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
else
|
|
855
|
+
if (typeof parsed === 'string') {
|
|
856
|
+
text = parsed;
|
|
857
|
+
} else if (parsed.content) {
|
|
858
|
+
text = String(parsed.content);
|
|
859
|
+
} else if (parsed.text) {
|
|
860
|
+
text = String(parsed.text);
|
|
861
|
+
} else if (parsed.result) {
|
|
862
|
+
text = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result, null, 2);
|
|
863
|
+
} else if (parsed.message?.content) {
|
|
864
|
+
// Claude stream-json format
|
|
865
|
+
const blocks = Array.isArray(parsed.message.content) ? parsed.message.content : [parsed.message.content];
|
|
866
|
+
text = blocks.map((b: any) => {
|
|
867
|
+
if (typeof b === 'string') return b;
|
|
868
|
+
if (b.type === 'text') return b.text;
|
|
869
|
+
if (b.type === 'tool_use') return `🔧 ${b.name}(${typeof b.input === 'string' ? b.input : JSON.stringify(b.input).slice(0, 100)})`;
|
|
870
|
+
if (b.type === 'tool_result') return `→ ${typeof b.content === 'string' ? b.content.slice(0, 200) : JSON.stringify(b.content).slice(0, 200)}`;
|
|
871
|
+
return JSON.stringify(b).slice(0, 100);
|
|
872
|
+
}).join('\n');
|
|
873
|
+
} else if (Array.isArray(parsed)) {
|
|
874
|
+
text = parsed.map((item: any) => typeof item === 'string' ? item : JSON.stringify(item)).join('\n');
|
|
875
|
+
} else {
|
|
876
|
+
// Generic object — show key fields only
|
|
877
|
+
const keys = Object.keys(parsed);
|
|
878
|
+
if (keys.length <= 5) {
|
|
879
|
+
text = keys.map(k => `${k}: ${typeof parsed[k] === 'string' ? parsed[k] : JSON.stringify(parsed[k]).slice(0, 80)}`).join('\n');
|
|
880
|
+
} else {
|
|
881
|
+
text = JSON.stringify(parsed, null, 2);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
748
884
|
} catch {
|
|
749
885
|
// Not valid JSON, keep as-is
|
|
750
886
|
}
|
|
@@ -843,8 +979,14 @@ function LogPanel({ agentId, agentLabel, workspaceId, onClose }: {
|
|
|
843
979
|
) : (
|
|
844
980
|
<>
|
|
845
981
|
<span className="text-[8px] text-gray-600 shrink-0 w-16">{entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : ''}</span>
|
|
846
|
-
{entry.
|
|
847
|
-
<
|
|
982
|
+
{entry.subtype === 'tool_use' && <span className="text-yellow-500 shrink-0">🔧 {entry.tool || 'tool'}</span>}
|
|
983
|
+
{entry.subtype === 'tool_result' && <span className="text-cyan-500 shrink-0">→</span>}
|
|
984
|
+
{entry.subtype === 'init' && <span className="text-blue-400 shrink-0">⚡</span>}
|
|
985
|
+
{entry.subtype === 'daemon' && <span className="text-purple-400 shrink-0">👁</span>}
|
|
986
|
+
{entry.subtype === 'watch_detected' && <span className="text-orange-400 shrink-0">🔍</span>}
|
|
987
|
+
{entry.subtype === 'error' && <span className="text-red-400 shrink-0">❌</span>}
|
|
988
|
+
{!entry.tool && entry.subtype === 'text' && <span className="text-gray-500 shrink-0">💬</span>}
|
|
989
|
+
<LogContent content={entry.content} subtype={entry.subtype} />
|
|
848
990
|
</>
|
|
849
991
|
)}
|
|
850
992
|
</div>
|
|
@@ -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 */
|
package/lib/workspace/types.ts
CHANGED
|
@@ -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
|
{
|