@aion0/forge 0.4.16 → 0.5.0

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.
Files changed (92) hide show
  1. package/README.md +1 -1
  2. package/RELEASE_NOTES.md +170 -14
  3. package/app/api/agents/route.ts +17 -0
  4. package/app/api/delivery/[id]/route.ts +62 -0
  5. package/app/api/delivery/route.ts +40 -0
  6. package/app/api/mobile-chat/route.ts +13 -7
  7. package/app/api/monitor/route.ts +10 -6
  8. package/app/api/pipelines/[id]/route.ts +16 -3
  9. package/app/api/tasks/route.ts +2 -1
  10. package/app/api/workspace/[id]/agents/route.ts +35 -0
  11. package/app/api/workspace/[id]/memory/route.ts +23 -0
  12. package/app/api/workspace/[id]/smith/route.ts +22 -0
  13. package/app/api/workspace/[id]/stream/route.ts +28 -0
  14. package/app/api/workspace/route.ts +100 -0
  15. package/app/global-error.tsx +10 -4
  16. package/app/icon.ico +0 -0
  17. package/app/layout.tsx +2 -2
  18. package/app/login/LoginForm.tsx +96 -0
  19. package/app/login/page.tsx +7 -98
  20. package/app/page.tsx +2 -2
  21. package/bin/forge-server.mjs +13 -1
  22. package/check-forge-status.sh +9 -0
  23. package/components/ConversationEditor.tsx +411 -0
  24. package/components/ConversationGraphView.tsx +347 -0
  25. package/components/ConversationTerminalView.tsx +303 -0
  26. package/components/Dashboard.tsx +36 -39
  27. package/components/DashboardWrapper.tsx +9 -0
  28. package/components/DeliveryFlowEditor.tsx +491 -0
  29. package/components/DeliveryList.tsx +230 -0
  30. package/components/DeliveryWorkspace.tsx +589 -0
  31. package/components/DocTerminal.tsx +10 -2
  32. package/components/DocsViewer.tsx +10 -2
  33. package/components/HelpTerminal.tsx +11 -6
  34. package/components/InlinePipelineView.tsx +111 -0
  35. package/components/MobileView.tsx +20 -0
  36. package/components/MonitorPanel.tsx +9 -4
  37. package/components/NewTaskModal.tsx +32 -0
  38. package/components/PipelineEditor.tsx +49 -6
  39. package/components/PipelineView.tsx +482 -64
  40. package/components/ProjectDetail.tsx +314 -56
  41. package/components/ProjectManager.tsx +49 -4
  42. package/components/SessionView.tsx +27 -13
  43. package/components/SettingsModal.tsx +790 -124
  44. package/components/SkillsPanel.tsx +31 -8
  45. package/components/TaskBoard.tsx +3 -0
  46. package/components/WebTerminal.tsx +257 -43
  47. package/components/WorkspaceTree.tsx +221 -0
  48. package/components/WorkspaceView.tsx +2224 -0
  49. package/install.sh +2 -2
  50. package/lib/agents/claude-adapter.ts +104 -0
  51. package/lib/agents/generic-adapter.ts +64 -0
  52. package/lib/agents/index.ts +242 -0
  53. package/lib/agents/types.ts +70 -0
  54. package/lib/artifacts.ts +106 -0
  55. package/lib/delivery.ts +787 -0
  56. package/lib/forge-skills/forge-inbox.md +37 -0
  57. package/lib/forge-skills/forge-send.md +40 -0
  58. package/lib/forge-skills/forge-status.md +32 -0
  59. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  60. package/lib/help-docs/00-overview.md +7 -1
  61. package/lib/help-docs/01-settings.md +159 -2
  62. package/lib/help-docs/05-pipelines.md +89 -0
  63. package/lib/help-docs/07-projects.md +35 -1
  64. package/lib/help-docs/11-workspace.md +204 -0
  65. package/lib/help-docs/CLAUDE.md +5 -2
  66. package/lib/init.ts +60 -10
  67. package/lib/pipeline.ts +537 -1
  68. package/lib/settings.ts +115 -22
  69. package/lib/skills.ts +249 -372
  70. package/lib/task-manager.ts +113 -33
  71. package/lib/telegram-bot.ts +33 -1
  72. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  73. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  74. package/lib/workspace/agent-bus.ts +416 -0
  75. package/lib/workspace/agent-worker.ts +667 -0
  76. package/lib/workspace/backends/api-backend.ts +262 -0
  77. package/lib/workspace/backends/cli-backend.ts +479 -0
  78. package/lib/workspace/index.ts +82 -0
  79. package/lib/workspace/manager.ts +136 -0
  80. package/lib/workspace/orchestrator.ts +1804 -0
  81. package/lib/workspace/persistence.ts +310 -0
  82. package/lib/workspace/presets.ts +170 -0
  83. package/lib/workspace/skill-installer.ts +188 -0
  84. package/lib/workspace/smith-memory.ts +498 -0
  85. package/lib/workspace/types.ts +231 -0
  86. package/lib/workspace/watch-manager.ts +288 -0
  87. package/lib/workspace-standalone.ts +790 -0
  88. package/middleware.ts +1 -0
  89. package/package.json +4 -1
  90. package/src/config/index.ts +12 -1
  91. package/src/core/db/database.ts +1 -0
  92. package/start.sh +7 -0
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Workspace Agent types — core interfaces for the multi-agent workspace system.
3
+ */
4
+
5
+ import type { ProviderName, TaskLogEntry } from '@/src/types';
6
+
7
+ // ─── Agent Config ────────────────────────────────────────
8
+
9
+ export interface WorkspaceAgentConfig {
10
+ id: string;
11
+ label: string;
12
+ icon: string;
13
+ // Node type: 'agent' (default) or 'input' (user-provided requirements)
14
+ type?: 'agent' | 'input';
15
+ // Input node: append-only entries (latest is active, older are history)
16
+ content?: string; // legacy single content (migrated to entries)
17
+ entries?: InputEntry[]; // incremental input history
18
+ role: string; // system prompt / role description
19
+ backend: AgentBackendType;
20
+ // CLI mode
21
+ agentId?: string; // 'claude' | 'codex' | 'aider'
22
+ // API mode
23
+ provider?: ProviderName;
24
+ model?: string; // e.g. 'claude-sonnet-4-6'
25
+ // Dependencies (replaces inputPaths matching)
26
+ dependsOn: string[]; // upstream agent IDs to wait for
27
+ // Working directory (relative to project root, default './' = root)
28
+ workDir?: string; // e.g. 'docs/prd' — where this agent runs
29
+ // Declared outputs (files or dirs this agent produces)
30
+ outputs: string[]; // e.g. ['docs/prd/v1.0.md']
31
+ // Multi-step execution (user-defined, preset templates provide defaults)
32
+ steps: AgentStep[];
33
+ // Approval gate
34
+ requiresApproval?: boolean;
35
+ // Watch: autonomous periodic monitoring
36
+ watch?: WatchConfig;
37
+ }
38
+
39
+ // ─── Watch Config ─────────────────────────────────────────
40
+
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
45
+ cmd?: string; // shell command (type='command' only)
46
+ }
47
+
48
+ export type WatchAction = 'log' | 'analyze' | 'approve';
49
+
50
+ export interface WatchConfig {
51
+ enabled: boolean;
52
+ interval: number; // check interval in seconds (default 60)
53
+ targets: WatchTarget[];
54
+ action: WatchAction; // log=report only, analyze=auto-execute, approve=pending user approval
55
+ prompt?: string; // custom prompt for analyze action (default: "Analyze the following changes...")
56
+ }
57
+
58
+ export type AgentBackendType = 'api' | 'cli';
59
+
60
+ export interface InputEntry {
61
+ content: string;
62
+ timestamp: number;
63
+ }
64
+
65
+ export interface AgentStep {
66
+ id: string;
67
+ label: string; // e.g. "Analyze requirements"
68
+ prompt: string; // instruction for this step
69
+ }
70
+
71
+ // ─── Agent State (Two-Layer Model) ───────────────────────
72
+
73
+ /** Smith layer: daemon lifecycle */
74
+ export type SmithStatus = 'down' | 'active';
75
+
76
+ /** Task layer: current work execution */
77
+ export type TaskStatus = 'idle' | 'running' | 'done' | 'failed';
78
+
79
+ /** Agent execution mode */
80
+ export type AgentMode = 'auto' | 'manual';
81
+
82
+ /** @deprecated Use SmithStatus + TaskStatus instead */
83
+ export type AgentStatus = SmithStatus | TaskStatus | 'paused' | 'waiting_approval' | 'listening' | 'interrupted';
84
+
85
+ export interface AgentState {
86
+ // ─── Smith layer (daemon lifecycle) ─────
87
+ smithStatus: SmithStatus; // down=not started, active=listening on bus
88
+ mode: AgentMode; // auto=respond to messages, manual=user in terminal
89
+
90
+ // ─── Task layer (current work) ──────────
91
+ taskStatus: TaskStatus; // idle/running/done/failed
92
+
93
+ // ─── Execution details ──────────────────
94
+ currentStep?: number;
95
+ history: TaskLogEntry[];
96
+ artifacts: Artifact[];
97
+ logFile?: string;
98
+ lastCheckpoint?: number;
99
+ cliSessionId?: string;
100
+ currentMessageId?: string; // bus message that triggered current/last task execution
101
+ tmuxSession?: string; // tmux session name for manual terminal reattach
102
+ startedAt?: number;
103
+ completedAt?: number;
104
+ error?: string;
105
+ daemonIteration?: number; // how many times re-executed after initial steps
106
+ }
107
+
108
+ // ─── Daemon Wake Reason ──────────────────────────────────
109
+
110
+ export type DaemonWakeReason =
111
+ | { type: 'abort' }
112
+ | { type: 'bus_message'; messages: TaskLogEntry[] }
113
+ | { type: 'upstream_changed'; agentId: string; files: string[] }
114
+ | { type: 'input_changed'; content: string }
115
+ | { type: 'user_message'; content: string };
116
+
117
+ // ─── Artifact ────────────────────────────────────────────
118
+
119
+ export interface Artifact {
120
+ type: 'file' | 'text';
121
+ path?: string;
122
+ summary?: string;
123
+ }
124
+
125
+ // ─── Bus Message ─────────────────────────────────────────
126
+
127
+ export type MessageCategory = 'notification' | 'ticket';
128
+
129
+ export type TicketStatus = 'open' | 'in_progress' | 'fixed' | 'verified' | 'closed';
130
+
131
+ export interface BusMessage {
132
+ id: string;
133
+ from: string; // source agent ID
134
+ to: string; // target agent ID (no broadcast)
135
+ type: 'notify' | 'request' | 'response' | 'artifact' | 'ack';
136
+ payload: {
137
+ action: string; // 'task_complete' | 'step_complete' | 'question' | 'fix_request' | ...
138
+ content?: string; // natural language message
139
+ files?: string[]; // related file paths
140
+ replyTo?: string; // reply to which message ID
141
+ };
142
+ timestamp: number;
143
+ // Delivery tracking
144
+ status?: 'pending' | 'running' | 'done' | 'failed';
145
+ retries?: number;
146
+ // Message classification
147
+ category?: MessageCategory; // 'notification' (default, follows DAG) | 'ticket' (1-to-1, ignores DAG)
148
+ // Causal chain — which inbox message triggered this outbox message
149
+ causedBy?: {
150
+ messageId: string; // the inbox message being processed
151
+ from: string; // who sent that inbox message
152
+ to: string; // who received it (this agent)
153
+ };
154
+ // Ticket lifecycle (only for category='ticket')
155
+ ticketStatus?: TicketStatus;
156
+ ticketRetries?: number; // how many times this ticket has been retried
157
+ maxRetries?: number; // configurable limit (default 3)
158
+ }
159
+
160
+ // ─── Agent Heartbeat ─────────────────────────────────────
161
+
162
+ export type AgentLiveness = 'alive' | 'busy' | 'down';
163
+
164
+ export interface AgentHeartbeat {
165
+ agentId: string;
166
+ liveness: AgentLiveness;
167
+ lastSeen: number;
168
+ currentStep?: string;
169
+ }
170
+
171
+ // ─── Workspace State (persistence) ───────────────────────
172
+
173
+ export interface WorkspaceState {
174
+ id: string;
175
+ projectPath: string;
176
+ projectName: string;
177
+ agents: WorkspaceAgentConfig[];
178
+ agentStates: Record<string, AgentState>;
179
+ nodePositions: Record<string, { x: number; y: number }>;
180
+ busLog: BusMessage[];
181
+ busOutbox?: Record<string, BusMessage[]>; // agentId → undelivered messages
182
+ createdAt: number;
183
+ updatedAt: number;
184
+ }
185
+
186
+ // ─── Backend Interface ───────────────────────────────────
187
+
188
+ export interface StepExecutionParams {
189
+ config: WorkspaceAgentConfig;
190
+ step: AgentStep;
191
+ stepIndex: number;
192
+ history: TaskLogEntry[]; // accumulated context from prior steps
193
+ projectPath: string;
194
+ upstreamContext?: string; // injected context from upstream agents
195
+ onLog?: (entry: TaskLogEntry) => void;
196
+ abortSignal?: AbortSignal;
197
+ // Bus communication callbacks (injected by orchestrator)
198
+ onBusSend?: (to: string, content: string) => void;
199
+ onBusRequest?: (to: string, question: string) => Promise<string>;
200
+ /** List of other agent IDs in the workspace (for communication tools) */
201
+ peerAgentIds?: string[];
202
+ /** Workspace ID — injected as env var for forge skills */
203
+ workspaceId?: string;
204
+ }
205
+
206
+ export interface StepExecutionResult {
207
+ response: string;
208
+ artifacts: Artifact[];
209
+ sessionId?: string; // CLI: conversation ID for --resume
210
+ inputTokens?: number;
211
+ outputTokens?: number;
212
+ }
213
+
214
+ export interface AgentBackend {
215
+ /** Execute a single step */
216
+ executeStep(params: StepExecutionParams): Promise<StepExecutionResult>;
217
+ /** Abort a running step */
218
+ abort(): void;
219
+ }
220
+
221
+ // ─── Worker Events ───────────────────────────────────────
222
+
223
+ export type WorkerEvent =
224
+ | { type: 'smith_status'; agentId: string; smithStatus: SmithStatus; mode: AgentMode }
225
+ | { type: 'task_status'; agentId: string; taskStatus: TaskStatus; error?: string }
226
+ | { type: 'log'; agentId: string; entry: TaskLogEntry }
227
+ | { type: 'step'; agentId: string; stepIndex: number; stepLabel: string }
228
+ | { type: 'artifact'; agentId: string; artifact: Artifact }
229
+ | { type: 'agents_changed'; agents: WorkspaceAgentConfig[]; agentStates?: Record<string, AgentState> }
230
+ | { type: 'done'; agentId: string; summary: string }
231
+ | { type: 'error'; agentId: string; error: string };
@@ -0,0 +1,288 @@
1
+ /**
2
+ * WatchManager — autonomous periodic monitoring for workspace agents.
3
+ *
4
+ * Fully independent from message bus / worker / state management.
5
+ * Detects file changes, git diffs, agent outputs, or custom commands.
6
+ * Writes results to agent log + emits SSE events. Never sends messages.
7
+ */
8
+
9
+ import { EventEmitter } from 'node:events';
10
+ import { existsSync, readdirSync, statSync } from 'node:fs';
11
+ import { join, relative } from 'node:path';
12
+ import { execSync } from 'node:child_process';
13
+ import type { WorkspaceAgentConfig, WatchTarget, WatchConfig } from './types';
14
+ import { appendAgentLog } from './persistence';
15
+
16
+ // ─── Snapshot types ──────────────────────────────────────
17
+
18
+ interface WatchSnapshot {
19
+ lastCheckTime: number; // timestamp ms — only files modified after this are "changed"
20
+ gitHash?: string;
21
+ commandOutput?: string;
22
+ }
23
+
24
+ interface WatchChange {
25
+ targetType: WatchTarget['type'];
26
+ description: string;
27
+ files: string[];
28
+ }
29
+
30
+ // ─── Detection functions ─────────────────────────────────
31
+
32
+ /** Find files modified after `since` timestamp in a directory */
33
+ function findModifiedFiles(dir: string, since: number, pattern?: string, maxDepth = 3): string[] {
34
+ const modified: string[] = [];
35
+ if (!existsSync(dir)) return modified;
36
+
37
+ const globMatch = pattern
38
+ ? (name: string) => {
39
+ if (pattern.startsWith('*.')) return name.endsWith(pattern.slice(1));
40
+ return name.includes(pattern);
41
+ }
42
+ : () => true;
43
+
44
+ function walk(current: string, depth: number) {
45
+ if (depth > maxDepth) return;
46
+ try {
47
+ for (const entry of readdirSync(current)) {
48
+ if (entry.startsWith('.') || entry === 'node_modules') continue;
49
+ const full = join(current, entry);
50
+ try {
51
+ const st = statSync(full);
52
+ if (st.isDirectory()) {
53
+ walk(full, depth + 1);
54
+ } else if (globMatch(entry) && st.mtimeMs > since) {
55
+ modified.push(relative(dir, full));
56
+ }
57
+ } catch {}
58
+ }
59
+ } catch {}
60
+ }
61
+
62
+ walk(dir, 0);
63
+ return modified;
64
+ }
65
+
66
+ function detectDirectoryChanges(projectPath: string, target: WatchTarget, since: number): { changes: WatchChange | null } {
67
+ const dir = join(projectPath, target.path || '.');
68
+ const files = findModifiedFiles(dir, since, target.pattern);
69
+
70
+ if (files.length === 0) return { changes: null };
71
+
72
+ return {
73
+ changes: {
74
+ targetType: 'directory',
75
+ description: `${target.path || '.'}: ${files.length} file(s) changed`,
76
+ files: files.slice(0, 20),
77
+ },
78
+ };
79
+ }
80
+
81
+ function detectGitChanges(projectPath: string, prevHash?: string): { changes: WatchChange | null; gitHash: string } {
82
+ try {
83
+ const hash = execSync('git rev-parse HEAD', { cwd: projectPath, timeout: 5000 }).toString().trim();
84
+ if (hash === prevHash) return { changes: null, gitHash: hash };
85
+
86
+ // Get diff summary
87
+ let diffStat = '';
88
+ try {
89
+ const cmd = prevHash ? `git diff --stat ${prevHash}..${hash}` : 'git diff --stat HEAD~1';
90
+ diffStat = execSync(cmd, { cwd: projectPath, timeout: 5000 }).toString().trim();
91
+ } catch {}
92
+
93
+ const files = diffStat.split('\n')
94
+ .filter(l => l.includes('|'))
95
+ .map(l => l.trim().split(/\s+\|/)[0].trim())
96
+ .slice(0, 20);
97
+
98
+ return {
99
+ changes: {
100
+ targetType: 'git',
101
+ description: `New commit ${hash.slice(0, 8)}${files.length ? `: ${files.length} files changed` : ''}`,
102
+ files,
103
+ },
104
+ gitHash: hash,
105
+ };
106
+ } catch {
107
+ return { changes: null, gitHash: prevHash || '' };
108
+ }
109
+ }
110
+
111
+ function detectCommandChanges(projectPath: string, target: WatchTarget, prevOutput?: string): { changes: WatchChange | null; commandOutput: string } {
112
+ if (!target.cmd) return { changes: null, commandOutput: prevOutput || '' };
113
+ try {
114
+ const output = execSync(target.cmd, { cwd: projectPath, timeout: 30000 }).toString().trim();
115
+ if (output === prevOutput) return { changes: null, commandOutput: output };
116
+
117
+ // Check pattern match if specified
118
+ if (target.pattern && !output.includes(target.pattern)) {
119
+ return { changes: null, commandOutput: output };
120
+ }
121
+
122
+ return {
123
+ changes: {
124
+ targetType: 'command',
125
+ description: `Command "${target.cmd.slice(0, 40)}": output changed`,
126
+ files: [],
127
+ },
128
+ commandOutput: output,
129
+ };
130
+ } catch {
131
+ return { changes: null, commandOutput: prevOutput || '' };
132
+ }
133
+ }
134
+
135
+ // ─── WatchManager class ──────────────────────────────────
136
+
137
+ export class WatchManager extends EventEmitter {
138
+ private timers = new Map<string, NodeJS.Timeout>();
139
+ private snapshots = new Map<string, WatchSnapshot>();
140
+
141
+ constructor(
142
+ private workspaceId: string,
143
+ private projectPath: string,
144
+ private getAgents: () => Map<string, { config: WorkspaceAgentConfig; state: { smithStatus: string; taskStatus: string; mode: string } }>,
145
+ ) {
146
+ super();
147
+ }
148
+
149
+ /** Start watch loops for all agents with watch config */
150
+ start(): void {
151
+ for (const [id, entry] of this.getAgents()) {
152
+ if (entry.config.watch?.enabled) {
153
+ this.startWatch(id, entry.config);
154
+ }
155
+ }
156
+ }
157
+
158
+ /** Stop all watch loops */
159
+ stop(): void {
160
+ for (const [id, timer] of this.timers) {
161
+ clearInterval(timer);
162
+ }
163
+ this.timers.clear();
164
+ console.log(`[watch] All watch loops stopped`);
165
+ }
166
+
167
+ /** Start/restart watch for a specific agent */
168
+ startWatch(agentId: string, config: WorkspaceAgentConfig): void {
169
+ this.stopWatch(agentId);
170
+ if (!config.watch?.enabled || config.watch.targets.length === 0) return;
171
+
172
+ const interval = Math.max(config.watch.interval || 60, 10) * 1000; // min 10s
173
+ console.log(`[watch] ${config.label}: started (interval=${interval / 1000}s, targets=${config.watch.targets.length})`);
174
+
175
+ // Initialize snapshot on first run (don't alert on existing state)
176
+ this.runCheck(agentId, config, true);
177
+
178
+ const timer = setInterval(() => {
179
+ const agents = this.getAgents();
180
+ const entry = agents.get(agentId);
181
+ if (!entry || entry.state.smithStatus !== 'active') return;
182
+ // Skip if agent is busy
183
+ if (entry.state.taskStatus === 'running') return;
184
+
185
+ this.runCheck(agentId, config, false);
186
+ }, interval);
187
+
188
+ timer.unref();
189
+ this.timers.set(agentId, timer);
190
+ }
191
+
192
+ /** Stop watch for a specific agent */
193
+ stopWatch(agentId: string): void {
194
+ const timer = this.timers.get(agentId);
195
+ if (timer) {
196
+ clearInterval(timer);
197
+ this.timers.delete(agentId);
198
+ }
199
+ }
200
+
201
+ /** Run a single check cycle */
202
+ private runCheck(agentId: string, config: WorkspaceAgentConfig, initialRun: boolean): void {
203
+ const now = Date.now();
204
+ const prev = this.snapshots.get(agentId) || { lastCheckTime: now };
205
+ const allChanges: WatchChange[] = [];
206
+ const newSnapshot: WatchSnapshot = { lastCheckTime: now };
207
+
208
+ for (const target of config.watch!.targets) {
209
+ switch (target.type) {
210
+ case 'directory': {
211
+ const { changes } = detectDirectoryChanges(this.projectPath, target, prev.lastCheckTime);
212
+ if (changes) allChanges.push(changes);
213
+ break;
214
+ }
215
+ case 'git': {
216
+ const { changes, gitHash } = detectGitChanges(this.projectPath, prev.gitHash);
217
+ newSnapshot.gitHash = gitHash;
218
+ if (changes) allChanges.push(changes);
219
+ break;
220
+ }
221
+ case 'agent_output': {
222
+ const agents = this.getAgents();
223
+ const targetAgent = target.path ? agents.get(target.path) : null;
224
+ if (targetAgent) {
225
+ for (const outputPath of targetAgent.config.outputs) {
226
+ const { changes } = detectDirectoryChanges(this.projectPath, { ...target, path: outputPath }, prev.lastCheckTime);
227
+ if (changes) allChanges.push({ ...changes, targetType: 'agent_output', description: `${targetAgent.config.label} output: ${changes.description}` });
228
+ }
229
+ }
230
+ break;
231
+ }
232
+ case 'command': {
233
+ const { changes, commandOutput } = detectCommandChanges(this.projectPath, target, prev.commandOutput);
234
+ newSnapshot.commandOutput = commandOutput;
235
+ if (changes) allChanges.push(changes);
236
+ break;
237
+ }
238
+ }
239
+ }
240
+
241
+ this.snapshots.set(agentId, newSnapshot);
242
+
243
+ if (initialRun) {
244
+ console.log(`[watch] ${config.label}: baseline set (checkTime=${new Date(now).toLocaleTimeString()})`);
245
+ return;
246
+ }
247
+
248
+ if (allChanges.length === 0) {
249
+ console.log(`[watch] ${config.label}: checked — no changes`);
250
+ // Heartbeat: only log to console, don't write to logs.jsonl or agent history
251
+ // (prevents disk/memory bloat from frequent no-change checks)
252
+ return;
253
+ }
254
+
255
+ // Build report
256
+ const summary = allChanges.map(c =>
257
+ `[${c.targetType}] ${c.description}${c.files.length ? '\n ' + c.files.join('\n ') : ''}`
258
+ ).join('\n');
259
+
260
+ console.log(`[watch] ${config.label}: detected ${allChanges.length} change(s)`);
261
+
262
+ const entry = { type: 'system' as const, subtype: 'watch_detected', content: `🔍 Watch detected changes:\n${summary}`, timestamp: new Date().toISOString() };
263
+ appendAgentLog(this.workspaceId, agentId, entry).catch(() => {});
264
+
265
+ // Emit SSE event for UI
266
+ this.emit('watch_alert', {
267
+ type: 'watch_alert',
268
+ agentId,
269
+ entry,
270
+ changes: allChanges,
271
+ summary,
272
+ timestamp: Date.now(),
273
+ });
274
+ }
275
+
276
+ /** Manual trigger: run check now and return results */
277
+ triggerCheck(agentId: string): { changes: WatchChange[] } | null {
278
+ const agents = this.getAgents();
279
+ const entry = agents.get(agentId);
280
+ if (!entry?.config.watch?.enabled) return null;
281
+
282
+ const prev = this.snapshots.get(agentId) || { files: {} };
283
+ const allChanges: WatchChange[] = [];
284
+ // Reuse runCheck logic but capture results
285
+ this.runCheck(agentId, entry.config, false);
286
+ return { changes: allChanges };
287
+ }
288
+ }