@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.
- package/README.md +1 -1
- package/RELEASE_NOTES.md +170 -14
- package/app/api/agents/route.ts +17 -0
- package/app/api/delivery/[id]/route.ts +62 -0
- package/app/api/delivery/route.ts +40 -0
- package/app/api/mobile-chat/route.ts +13 -7
- package/app/api/monitor/route.ts +10 -6
- package/app/api/pipelines/[id]/route.ts +16 -3
- package/app/api/tasks/route.ts +2 -1
- package/app/api/workspace/[id]/agents/route.ts +35 -0
- package/app/api/workspace/[id]/memory/route.ts +23 -0
- package/app/api/workspace/[id]/smith/route.ts +22 -0
- package/app/api/workspace/[id]/stream/route.ts +28 -0
- package/app/api/workspace/route.ts +100 -0
- package/app/global-error.tsx +10 -4
- package/app/icon.ico +0 -0
- package/app/layout.tsx +2 -2
- package/app/login/LoginForm.tsx +96 -0
- package/app/login/page.tsx +7 -98
- package/app/page.tsx +2 -2
- package/bin/forge-server.mjs +13 -1
- package/check-forge-status.sh +9 -0
- package/components/ConversationEditor.tsx +411 -0
- package/components/ConversationGraphView.tsx +347 -0
- package/components/ConversationTerminalView.tsx +303 -0
- package/components/Dashboard.tsx +36 -39
- package/components/DashboardWrapper.tsx +9 -0
- package/components/DeliveryFlowEditor.tsx +491 -0
- package/components/DeliveryList.tsx +230 -0
- package/components/DeliveryWorkspace.tsx +589 -0
- package/components/DocTerminal.tsx +10 -2
- package/components/DocsViewer.tsx +10 -2
- package/components/HelpTerminal.tsx +11 -6
- package/components/InlinePipelineView.tsx +111 -0
- package/components/MobileView.tsx +20 -0
- package/components/MonitorPanel.tsx +9 -4
- package/components/NewTaskModal.tsx +32 -0
- package/components/PipelineEditor.tsx +49 -6
- package/components/PipelineView.tsx +482 -64
- package/components/ProjectDetail.tsx +314 -56
- package/components/ProjectManager.tsx +49 -4
- package/components/SessionView.tsx +27 -13
- package/components/SettingsModal.tsx +790 -124
- package/components/SkillsPanel.tsx +31 -8
- package/components/TaskBoard.tsx +3 -0
- package/components/WebTerminal.tsx +257 -43
- package/components/WorkspaceTree.tsx +221 -0
- package/components/WorkspaceView.tsx +2224 -0
- package/install.sh +2 -2
- package/lib/agents/claude-adapter.ts +104 -0
- package/lib/agents/generic-adapter.ts +64 -0
- package/lib/agents/index.ts +242 -0
- package/lib/agents/types.ts +70 -0
- package/lib/artifacts.ts +106 -0
- package/lib/delivery.ts +787 -0
- package/lib/forge-skills/forge-inbox.md +37 -0
- package/lib/forge-skills/forge-send.md +40 -0
- package/lib/forge-skills/forge-status.md +32 -0
- package/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/lib/help-docs/00-overview.md +7 -1
- package/lib/help-docs/01-settings.md +159 -2
- package/lib/help-docs/05-pipelines.md +89 -0
- package/lib/help-docs/07-projects.md +35 -1
- package/lib/help-docs/11-workspace.md +204 -0
- package/lib/help-docs/CLAUDE.md +5 -2
- package/lib/init.ts +60 -10
- package/lib/pipeline.ts +537 -1
- package/lib/settings.ts +115 -22
- package/lib/skills.ts +249 -372
- package/lib/task-manager.ts +113 -33
- package/lib/telegram-bot.ts +33 -1
- package/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/lib/workspace/agent-bus.ts +416 -0
- package/lib/workspace/agent-worker.ts +667 -0
- package/lib/workspace/backends/api-backend.ts +262 -0
- package/lib/workspace/backends/cli-backend.ts +479 -0
- package/lib/workspace/index.ts +82 -0
- package/lib/workspace/manager.ts +136 -0
- package/lib/workspace/orchestrator.ts +1804 -0
- package/lib/workspace/persistence.ts +310 -0
- package/lib/workspace/presets.ts +170 -0
- package/lib/workspace/skill-installer.ts +188 -0
- package/lib/workspace/smith-memory.ts +498 -0
- package/lib/workspace/types.ts +231 -0
- package/lib/workspace/watch-manager.ts +288 -0
- package/lib/workspace-standalone.ts +790 -0
- package/middleware.ts +1 -0
- package/package.json +4 -1
- package/src/config/index.ts +12 -1
- package/src/core/db/database.ts +1 -0
- 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
|
+
}
|