loki-mode 5.7.2 → 5.7.3

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.
@@ -0,0 +1,517 @@
1
+ /**
2
+ * State Watcher Service
3
+ *
4
+ * Watches .loki/ directory for state changes and emits events.
5
+ * Uses Deno's built-in file watcher with debouncing.
6
+ */
7
+
8
+ import {
9
+ eventBus,
10
+ emitSessionEvent,
11
+ emitPhaseEvent,
12
+ emitTaskEvent,
13
+ emitLogEvent,
14
+ emitHeartbeat,
15
+ } from "./event-bus.ts";
16
+ import type { Session, Task } from "../types/api.ts";
17
+
18
+ interface WatchedState {
19
+ sessions: Map<string, Session>;
20
+ tasks: Map<string, Task[]>;
21
+ lastModified: Map<string, number>;
22
+ }
23
+
24
+ class StateWatcher {
25
+ private lokiDir: string;
26
+ private watchDir: string;
27
+ private watcher: Deno.FsWatcher | null = null;
28
+ private state: WatchedState;
29
+ private debounceTimers: Map<string, number> = new Map();
30
+ private debounceDelay = 100; // ms
31
+ private heartbeatInterval: number | null = null;
32
+ private startTime: Date;
33
+
34
+ constructor() {
35
+ this.lokiDir = Deno.env.get("LOKI_DIR") ||
36
+ new URL("../../", import.meta.url).pathname.replace(/\/$/, "");
37
+ this.watchDir = `${this.lokiDir}/.loki`;
38
+ this.state = {
39
+ sessions: new Map(),
40
+ tasks: new Map(),
41
+ lastModified: new Map(),
42
+ };
43
+ this.startTime = new Date();
44
+ }
45
+
46
+ /**
47
+ * Start watching the .loki directory
48
+ */
49
+ async start(): Promise<void> {
50
+ // Ensure .loki directory exists
51
+ try {
52
+ await Deno.mkdir(this.watchDir, { recursive: true });
53
+ } catch {
54
+ // Directory may already exist
55
+ }
56
+
57
+ // Initial state load
58
+ await this.loadInitialState();
59
+
60
+ // Start file watcher
61
+ this.watcher = Deno.watchFs(this.watchDir, { recursive: true });
62
+
63
+ // Start heartbeat
64
+ this.heartbeatInterval = setInterval(() => {
65
+ this.emitHeartbeat();
66
+ }, 10000); // Every 10 seconds
67
+
68
+ // Process watch events
69
+ this.processWatchEvents();
70
+
71
+ console.log(`State watcher started, monitoring: ${this.watchDir}`);
72
+ }
73
+
74
+ /**
75
+ * Stop watching
76
+ */
77
+ stop(): void {
78
+ if (this.watcher) {
79
+ this.watcher.close();
80
+ this.watcher = null;
81
+ }
82
+
83
+ if (this.heartbeatInterval !== null) {
84
+ clearInterval(this.heartbeatInterval);
85
+ this.heartbeatInterval = null;
86
+ }
87
+
88
+ // Clear debounce timers
89
+ for (const timer of this.debounceTimers.values()) {
90
+ clearTimeout(timer);
91
+ }
92
+ this.debounceTimers.clear();
93
+
94
+ console.log("State watcher stopped");
95
+ }
96
+
97
+ /**
98
+ * Get current state snapshot
99
+ */
100
+ getState(): WatchedState {
101
+ return this.state;
102
+ }
103
+
104
+ /**
105
+ * Load initial state from .loki directory
106
+ */
107
+ private async loadInitialState(): Promise<void> {
108
+ // Load sessions
109
+ try {
110
+ const sessionsDir = `${this.watchDir}/sessions`;
111
+ for await (const entry of Deno.readDir(sessionsDir)) {
112
+ if (entry.isDirectory) {
113
+ await this.loadSession(entry.name);
114
+ }
115
+ }
116
+ } catch {
117
+ // Sessions directory may not exist
118
+ }
119
+
120
+ // Load current state file
121
+ try {
122
+ const stateFile = `${this.watchDir}/state.json`;
123
+ const content = await Deno.readTextFile(stateFile);
124
+ const state = JSON.parse(content);
125
+
126
+ if (state.currentSession) {
127
+ // Emit initial state event
128
+ emitLogEvent(
129
+ "info",
130
+ state.currentSession,
131
+ `State watcher loaded session: ${state.currentSession}`
132
+ );
133
+ }
134
+ } catch {
135
+ // State file may not exist
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Load a specific session
141
+ */
142
+ private async loadSession(sessionId: string): Promise<void> {
143
+ try {
144
+ const sessionFile = `${this.watchDir}/sessions/${sessionId}/session.json`;
145
+ const content = await Deno.readTextFile(sessionFile);
146
+ const session = JSON.parse(content) as Session;
147
+ this.state.sessions.set(sessionId, session);
148
+
149
+ // Load tasks
150
+ await this.loadTasks(sessionId);
151
+ } catch {
152
+ // Session may not have a valid state file
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Load tasks for a session
158
+ */
159
+ private async loadTasks(sessionId: string): Promise<void> {
160
+ try {
161
+ const tasksFile = `${this.watchDir}/sessions/${sessionId}/tasks.json`;
162
+ const content = await Deno.readTextFile(tasksFile);
163
+ const data = JSON.parse(content);
164
+ this.state.tasks.set(sessionId, data.tasks || []);
165
+ } catch {
166
+ // Tasks file may not exist
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Process file system watch events
172
+ */
173
+ private async processWatchEvents(): Promise<void> {
174
+ if (!this.watcher) return;
175
+
176
+ for await (const event of this.watcher) {
177
+ for (const path of event.paths) {
178
+ this.handleFileChange(path, event.kind);
179
+ }
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Handle a file change with debouncing
185
+ */
186
+ private handleFileChange(
187
+ path: string,
188
+ kind: Deno.FsEvent["kind"]
189
+ ): void {
190
+ // Debounce rapid changes to the same file
191
+ const existingTimer = this.debounceTimers.get(path);
192
+ if (existingTimer) {
193
+ clearTimeout(existingTimer);
194
+ }
195
+
196
+ const timer = setTimeout(() => {
197
+ this.debounceTimers.delete(path);
198
+ this.processFileChange(path, kind);
199
+ }, this.debounceDelay);
200
+
201
+ this.debounceTimers.set(path, timer);
202
+ }
203
+
204
+ /**
205
+ * Process a debounced file change
206
+ */
207
+ private async processFileChange(
208
+ path: string,
209
+ kind: Deno.FsEvent["kind"]
210
+ ): Promise<void> {
211
+ const relativePath = path.replace(this.watchDir + "/", "");
212
+
213
+ // Skip non-relevant files
214
+ if (!relativePath.endsWith(".json") && !relativePath.endsWith(".log")) {
215
+ return;
216
+ }
217
+
218
+ // Parse path to determine what changed
219
+ const parts = relativePath.split("/");
220
+
221
+ // Handle session state changes
222
+ if (parts[0] === "sessions" && parts.length >= 3) {
223
+ const sessionId = parts[1];
224
+ const fileName = parts[2];
225
+
226
+ switch (fileName) {
227
+ case "session.json":
228
+ await this.handleSessionChange(sessionId, kind);
229
+ break;
230
+ case "tasks.json":
231
+ await this.handleTasksChange(sessionId, kind);
232
+ break;
233
+ case "phase.json":
234
+ await this.handlePhaseChange(sessionId);
235
+ break;
236
+ case "agents.json":
237
+ await this.handleAgentsChange(sessionId);
238
+ break;
239
+ }
240
+ }
241
+
242
+ // Handle global state changes
243
+ if (relativePath === "state.json") {
244
+ await this.handleGlobalStateChange();
245
+ }
246
+
247
+ // Handle log file changes
248
+ if (relativePath.endsWith(".log")) {
249
+ await this.handleLogChange(path);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Handle session state changes
255
+ */
256
+ private async handleSessionChange(
257
+ sessionId: string,
258
+ kind: Deno.FsEvent["kind"]
259
+ ): Promise<void> {
260
+ if (kind === "remove") {
261
+ const oldSession = this.state.sessions.get(sessionId);
262
+ this.state.sessions.delete(sessionId);
263
+
264
+ if (oldSession) {
265
+ emitSessionEvent("session:stopped", sessionId, {
266
+ status: "stopped",
267
+ message: "Session removed",
268
+ });
269
+ }
270
+ return;
271
+ }
272
+
273
+ try {
274
+ const sessionFile = `${this.watchDir}/sessions/${sessionId}/session.json`;
275
+ const content = await Deno.readTextFile(sessionFile);
276
+ const newSession = JSON.parse(content) as Session;
277
+ const oldSession = this.state.sessions.get(sessionId);
278
+
279
+ this.state.sessions.set(sessionId, newSession);
280
+
281
+ // Detect status changes
282
+ if (!oldSession) {
283
+ emitSessionEvent("session:started", sessionId, {
284
+ status: newSession.status,
285
+ message: "Session created",
286
+ });
287
+ } else if (oldSession.status !== newSession.status) {
288
+ const eventType = this.getSessionEventType(newSession.status);
289
+ emitSessionEvent(eventType, sessionId, {
290
+ status: newSession.status,
291
+ message: `Status changed from ${oldSession.status} to ${newSession.status}`,
292
+ });
293
+ }
294
+
295
+ // Detect phase changes
296
+ if (oldSession && oldSession.currentPhase !== newSession.currentPhase) {
297
+ if (newSession.currentPhase) {
298
+ emitPhaseEvent("phase:started", sessionId, {
299
+ phase: newSession.currentPhase,
300
+ previousPhase: oldSession.currentPhase || undefined,
301
+ });
302
+ }
303
+ }
304
+ } catch (err) {
305
+ console.error(`Error loading session ${sessionId}:`, err);
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Handle task changes
311
+ */
312
+ private async handleTasksChange(sessionId: string): Promise<void> {
313
+ try {
314
+ const tasksFile = `${this.watchDir}/sessions/${sessionId}/tasks.json`;
315
+ const content = await Deno.readTextFile(tasksFile);
316
+ const data = JSON.parse(content);
317
+ const newTasks = data.tasks || [];
318
+ const oldTasks = this.state.tasks.get(sessionId) || [];
319
+
320
+ this.state.tasks.set(sessionId, newTasks);
321
+
322
+ // Detect new tasks
323
+ const oldTaskIds = new Set(oldTasks.map((t: Task) => t.id));
324
+ for (const task of newTasks) {
325
+ if (!oldTaskIds.has(task.id)) {
326
+ emitTaskEvent("task:created", sessionId, {
327
+ taskId: task.id,
328
+ title: task.title || task.subject || "Untitled",
329
+ status: task.status || "pending",
330
+ });
331
+ }
332
+ }
333
+
334
+ // Detect task status changes
335
+ const oldTaskMap = new Map(oldTasks.map((t: Task) => [t.id, t]));
336
+ for (const task of newTasks) {
337
+ const oldTask = oldTaskMap.get(task.id);
338
+ if (oldTask && oldTask.status !== task.status) {
339
+ const eventType = this.getTaskEventType(task.status);
340
+ emitTaskEvent(eventType, sessionId, {
341
+ taskId: task.id,
342
+ title: task.title || task.subject || "Untitled",
343
+ status: task.status,
344
+ output: task.output,
345
+ error: task.error,
346
+ });
347
+ }
348
+ }
349
+ } catch (err) {
350
+ console.error(`Error loading tasks for ${sessionId}:`, err);
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Handle phase changes
356
+ */
357
+ private async handlePhaseChange(sessionId: string): Promise<void> {
358
+ try {
359
+ const phaseFile = `${this.watchDir}/sessions/${sessionId}/phase.json`;
360
+ const content = await Deno.readTextFile(phaseFile);
361
+ const data = JSON.parse(content);
362
+
363
+ emitPhaseEvent("phase:started", sessionId, {
364
+ phase: data.current,
365
+ previousPhase: data.previous,
366
+ progress: data.progress,
367
+ });
368
+ } catch (err) {
369
+ console.error(`Error loading phase for ${sessionId}:`, err);
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Handle agent changes
375
+ */
376
+ private async handleAgentsChange(sessionId: string): Promise<void> {
377
+ try {
378
+ const agentsFile = `${this.watchDir}/sessions/${sessionId}/agents.json`;
379
+ const content = await Deno.readTextFile(agentsFile);
380
+ const data = JSON.parse(content);
381
+
382
+ // Emit events for active agents
383
+ for (const agent of data.active || []) {
384
+ eventBus.publish("agent:spawned", sessionId, {
385
+ agentId: agent.id,
386
+ type: agent.type,
387
+ model: agent.model,
388
+ task: agent.task,
389
+ });
390
+ }
391
+ } catch (err) {
392
+ console.error(`Error loading agents for ${sessionId}:`, err);
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Handle global state changes
398
+ */
399
+ private async handleGlobalStateChange(): Promise<void> {
400
+ try {
401
+ const stateFile = `${this.watchDir}/state.json`;
402
+ const content = await Deno.readTextFile(stateFile);
403
+ const state = JSON.parse(content);
404
+
405
+ emitLogEvent(
406
+ "info",
407
+ state.currentSession || "global",
408
+ `Global state updated: ${JSON.stringify(state).slice(0, 100)}...`
409
+ );
410
+ } catch (err) {
411
+ console.error("Error loading global state:", err);
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Handle log file changes (tail new entries)
417
+ */
418
+ private async handleLogChange(logPath: string): Promise<void> {
419
+ const lastPos = this.state.lastModified.get(logPath) || 0;
420
+
421
+ try {
422
+ const stat = await Deno.stat(logPath);
423
+ const newPos = stat.size;
424
+
425
+ if (newPos > lastPos) {
426
+ // Read new content
427
+ const file = await Deno.open(logPath, { read: true });
428
+ await file.seek(lastPos, Deno.SeekMode.Start);
429
+
430
+ const buffer = new Uint8Array(newPos - lastPos);
431
+ await file.read(buffer);
432
+ file.close();
433
+
434
+ const newContent = new TextDecoder().decode(buffer);
435
+ const lines = newContent.split("\n").filter((l) => l.trim());
436
+
437
+ // Extract session ID from path
438
+ const match = logPath.match(/sessions\/([^/]+)/);
439
+ const sessionId = match ? match[1] : "global";
440
+
441
+ for (const line of lines) {
442
+ emitLogEvent("info", sessionId, line);
443
+ }
444
+
445
+ this.state.lastModified.set(logPath, newPos);
446
+ }
447
+ } catch {
448
+ // Log file may have been deleted
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Emit heartbeat with current stats
454
+ */
455
+ private emitHeartbeat(): void {
456
+ const uptime = Date.now() - this.startTime.getTime();
457
+ let activeAgents = 0;
458
+ let queuedTasks = 0;
459
+
460
+ for (const session of this.state.sessions.values()) {
461
+ if (session.status === "running") {
462
+ const tasks = this.state.tasks.get(session.id) || [];
463
+ queuedTasks += tasks.filter(
464
+ (t) => t.status === "pending" || t.status === "queued"
465
+ ).length;
466
+ }
467
+ }
468
+
469
+ // Get active session for heartbeat
470
+ const activeSessions = Array.from(this.state.sessions.values()).filter(
471
+ (s) => s.status === "running"
472
+ );
473
+
474
+ for (const session of activeSessions) {
475
+ emitHeartbeat(session.id, { uptime, activeAgents, queuedTasks });
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Map session status to event type
481
+ */
482
+ private getSessionEventType(
483
+ status: string
484
+ ): "session:started" | "session:paused" | "session:resumed" | "session:stopped" | "session:completed" | "session:failed" {
485
+ const map: Record<string, "session:started" | "session:paused" | "session:resumed" | "session:stopped" | "session:completed" | "session:failed"> = {
486
+ starting: "session:started",
487
+ running: "session:resumed",
488
+ paused: "session:paused",
489
+ stopping: "session:stopped",
490
+ stopped: "session:stopped",
491
+ completed: "session:completed",
492
+ failed: "session:failed",
493
+ };
494
+ return map[status] || "session:started";
495
+ }
496
+
497
+ /**
498
+ * Map task status to event type
499
+ */
500
+ private getTaskEventType(
501
+ status: string
502
+ ): "task:created" | "task:started" | "task:progress" | "task:completed" | "task:failed" {
503
+ const map: Record<string, "task:created" | "task:started" | "task:progress" | "task:completed" | "task:failed"> = {
504
+ pending: "task:created",
505
+ queued: "task:created",
506
+ running: "task:started",
507
+ "in progress": "task:started",
508
+ completed: "task:completed",
509
+ done: "task:completed",
510
+ failed: "task:failed",
511
+ };
512
+ return map[status.toLowerCase()] || "task:progress";
513
+ }
514
+ }
515
+
516
+ // Singleton instance
517
+ export const stateWatcher = new StateWatcher();