ocwatch 0.1.1

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 (35) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/package.json +57 -0
  4. package/src/client/dist/assets/index-BN8enf1I.css +1 -0
  5. package/src/client/dist/assets/index-qN9nCkAq.js +41 -0
  6. package/src/client/dist/index.html +14 -0
  7. package/src/client/dist/vite.svg +1 -0
  8. package/src/server/cli.ts +84 -0
  9. package/src/server/index.ts +78 -0
  10. package/src/server/middleware/error.ts +36 -0
  11. package/src/server/routes/health.ts +7 -0
  12. package/src/server/routes/index.ts +18 -0
  13. package/src/server/routes/parts.ts +19 -0
  14. package/src/server/routes/plan.ts +15 -0
  15. package/src/server/routes/poll.ts +77 -0
  16. package/src/server/routes/projects.ts +34 -0
  17. package/src/server/routes/sessions.ts +118 -0
  18. package/src/server/routes/sse.ts +91 -0
  19. package/src/server/services/pollService.ts +220 -0
  20. package/src/server/services/sessionService.ts +476 -0
  21. package/src/server/services/statsService.ts +53 -0
  22. package/src/server/storage/boulderParser.ts +113 -0
  23. package/src/server/storage/messageParser.ts +169 -0
  24. package/src/server/storage/partParser.ts +519 -0
  25. package/src/server/storage/sessionParser.ts +180 -0
  26. package/src/server/utils/sessionStatus.ts +123 -0
  27. package/src/server/validation.ts +34 -0
  28. package/src/server/watcher.ts +160 -0
  29. package/src/shared/constants.ts +22 -0
  30. package/src/shared/index.ts +2 -0
  31. package/src/shared/types/index.ts +326 -0
  32. package/src/shared/utils/RingBuffer.ts +79 -0
  33. package/src/shared/utils/activityUtils.ts +66 -0
  34. package/src/shared/utils/burstGrouping.ts +99 -0
  35. package/src/shared/utils/formatTime.ts +27 -0
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>client</title>
8
+ <script type="module" crossorigin src="/assets/index-qN9nCkAq.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BN8enf1I.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
@@ -0,0 +1,84 @@
1
+ import { DEFAULT_PORT } from "../shared/constants";
2
+
3
+ export interface CLIFlags {
4
+ port: number;
5
+ noBrowser: boolean;
6
+ projectPath: string | null;
7
+ showHelp: boolean;
8
+ }
9
+
10
+ export function parseArgs(): CLIFlags {
11
+ const args = process.argv.slice(2);
12
+ const flags: CLIFlags = {
13
+ port: DEFAULT_PORT,
14
+ noBrowser: false,
15
+ projectPath: null,
16
+ showHelp: false,
17
+ };
18
+
19
+ for (let i = 0; i < args.length; i++) {
20
+ const arg = args[i];
21
+
22
+ if (arg === "--help" || arg === "-h") {
23
+ flags.showHelp = true;
24
+ } else if (arg === "--no-browser") {
25
+ flags.noBrowser = true;
26
+ } else if (arg === "--port") {
27
+ const portValue = args[i + 1];
28
+ if (portValue && !isNaN(parseInt(portValue))) {
29
+ flags.port = parseInt(portValue);
30
+ i++;
31
+ }
32
+ } else if (arg === "--project") {
33
+ const projectPath = args[i + 1];
34
+ if (projectPath) {
35
+ flags.projectPath = projectPath;
36
+ i++;
37
+ }
38
+ }
39
+ }
40
+
41
+ return flags;
42
+ }
43
+
44
+ export function printHelp(): void {
45
+ console.log(`
46
+ OCWatch - Real-time OpenCode Activity Monitor
47
+
48
+ Usage: ocwatch [options]
49
+
50
+ Options:
51
+ --port <number> Server port (default: 50234)
52
+ --no-browser Skip auto-opening browser
53
+ --project <path> Set default project filter
54
+ --help, -h Show this help message
55
+
56
+ Examples:
57
+ ocwatch
58
+ ocwatch --port 50999
59
+ ocwatch --no-browser
60
+ ocwatch --project /path/to/project
61
+ `);
62
+ }
63
+
64
+ export async function openBrowser(url: string): Promise<void> {
65
+ try {
66
+ const isHeadless =
67
+ process.env.CI === "true" ||
68
+ (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY);
69
+
70
+ if (isHeadless) {
71
+ console.log(`📱 Open browser: ${url}`);
72
+ return;
73
+ }
74
+
75
+ const proc = Bun.spawn(["open", url], {
76
+ stdio: ["ignore", "ignore", "ignore"],
77
+ });
78
+
79
+ proc.exited.catch(() => {
80
+ });
81
+ } catch (error) {
82
+ console.log(`📱 Open browser: ${url}`);
83
+ }
84
+ }
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bun
2
+ import { Hono } from "hono";
3
+ import { cors } from "hono/cors";
4
+ import { compress } from "hono/compress";
5
+ import { serveStatic } from "hono/bun";
6
+ import { join } from "path";
7
+ import { errorHandler, notFoundHandler } from "./middleware/error";
8
+ import { registerRoutes } from "./routes";
9
+ import { parseArgs, printHelp, openBrowser } from "./cli";
10
+ import { getGlobalWatcher, closeAllSSEConnections } from "./routes/sse";
11
+
12
+ const clientDistPath = join(import.meta.dir, "..", "client", "dist");
13
+
14
+ const app = new Hono();
15
+
16
+ app.use("*", compress());
17
+ app.use("*", errorHandler);
18
+
19
+ app.use(
20
+ "/api/*",
21
+ cors({
22
+ origin: ["http://localhost:3000", "http://localhost:50234"],
23
+ credentials: true,
24
+ })
25
+ );
26
+
27
+ registerRoutes(app);
28
+
29
+ app.use("/*", serveStatic({ root: clientDistPath }));
30
+
31
+ app.notFound(async (c) => {
32
+ if (c.req.path.startsWith("/api/")) {
33
+ return notFoundHandler(c);
34
+ }
35
+ const indexPath = join(clientDistPath, "index.html");
36
+ const file = Bun.file(indexPath);
37
+ if (await file.exists()) {
38
+ return new Response(file, {
39
+ headers: { "Content-Type": "text/html; charset=utf-8" },
40
+ });
41
+ }
42
+ return notFoundHandler(c);
43
+ });
44
+
45
+ export { app };
46
+
47
+ const flags = parseArgs();
48
+
49
+ if (flags.showHelp) {
50
+ printHelp();
51
+ process.exit(0);
52
+ }
53
+
54
+ const port = flags.port;
55
+ const url = `http://localhost:${port}`;
56
+
57
+ export default {
58
+ port,
59
+ fetch: app.fetch,
60
+ };
61
+
62
+ function shutdown() {
63
+ console.log("\n🛑 Shutting down gracefully...");
64
+ try { getGlobalWatcher().stop(); } catch {}
65
+ try { closeAllSSEConnections(); } catch {}
66
+ process.exit(0);
67
+ }
68
+
69
+ process.on("SIGINT", shutdown);
70
+ process.on("SIGTERM", shutdown);
71
+
72
+ console.log(`🚀 OCWatch API server running on ${url}`);
73
+ if (flags.noBrowser) {
74
+ console.log(`📡 API ready for Vite dev server`);
75
+ } else {
76
+ console.log(`📋 Press Ctrl+C to stop`);
77
+ openBrowser(url).catch(() => {});
78
+ }
@@ -0,0 +1,36 @@
1
+ import type { Context, Next } from 'hono';
2
+
3
+ export interface ErrorResponse {
4
+ error: string;
5
+ message: string;
6
+ status: number;
7
+ }
8
+
9
+ export async function errorHandler(c: Context, next: Next): Promise<Response | void> {
10
+ try {
11
+ await next();
12
+ } catch (err) {
13
+ console.error('Server error:', err);
14
+
15
+ const error = err instanceof Error ? err : new Error('Unknown error');
16
+ const status = 500;
17
+
18
+ const response: ErrorResponse = {
19
+ error: error.name,
20
+ message: error.message,
21
+ status,
22
+ };
23
+
24
+ return c.json(response, status);
25
+ }
26
+ }
27
+
28
+ export function notFoundHandler(c: Context): Response {
29
+ const response: ErrorResponse = {
30
+ error: 'Not Found',
31
+ message: `Route ${c.req.path} not found`,
32
+ status: 404,
33
+ };
34
+
35
+ return c.json(response, 404);
36
+ }
@@ -0,0 +1,7 @@
1
+ import type { Hono } from "hono";
2
+
3
+ export function registerHealthRoutes(app: Hono) {
4
+ app.get("/api/health", (c) => {
5
+ return c.json({ status: "ok" });
6
+ });
7
+ }
@@ -0,0 +1,18 @@
1
+ import type { Hono } from "hono";
2
+ import { registerHealthRoutes } from "./health";
3
+ import { registerSessionRoutes } from "./sessions";
4
+ import { registerPartRoutes } from "./parts";
5
+ import { registerProjectRoutes } from "./projects";
6
+ import { registerPlanRoute } from "./plan";
7
+ import { registerPollRoute } from "./poll";
8
+ import { registerSSERoute } from "./sse";
9
+
10
+ export function registerRoutes(app: Hono) {
11
+ registerHealthRoutes(app);
12
+ registerSessionRoutes(app);
13
+ registerPartRoutes(app);
14
+ registerProjectRoutes(app);
15
+ registerPlanRoute(app);
16
+ registerPollRoute(app);
17
+ registerSSERoute(app);
18
+ }
@@ -0,0 +1,19 @@
1
+ import type { Hono } from "hono";
2
+ import { getPart } from "../storage/partParser";
3
+ import { partIdSchema, validateWithResponse } from "../validation";
4
+
5
+ export function registerPartRoutes(app: Hono) {
6
+ app.get("/api/parts/:id", async (c) => {
7
+ const validation = validateWithResponse(partIdSchema, c.req.param("id"), c);
8
+ if (!validation.success) return validation.response;
9
+ const partID = validation.value;
10
+
11
+ const part = await getPart(partID);
12
+
13
+ if (!part) {
14
+ return c.json({ error: "PART_NOT_FOUND", message: `Part '${partID}' not found`, status: 404 }, 404);
15
+ }
16
+
17
+ return c.json(part);
18
+ });
19
+ }
@@ -0,0 +1,15 @@
1
+ import type { Hono } from "hono";
2
+ import { parseBoulder, calculatePlanProgress } from "../storage/boulderParser";
3
+
4
+ export function registerPlanRoute(app: Hono) {
5
+ app.get("/api/plan", async (c) => {
6
+ const boulder = await parseBoulder(process.cwd());
7
+
8
+ if (!boulder || !boulder.activePlan) {
9
+ return c.json(null);
10
+ }
11
+
12
+ const planProgress = await calculatePlanProgress(boulder.activePlan);
13
+ return c.json(planProgress);
14
+ });
15
+ }
@@ -0,0 +1,77 @@
1
+ import type { Hono } from "hono";
2
+ import {
3
+ generateETag,
4
+ fetchPollData,
5
+ getPollCache,
6
+ setPollCache,
7
+ getPollCacheEpoch,
8
+ getPollInProgress,
9
+ setPollInProgress,
10
+ getPollCacheTTL
11
+ } from "../services/pollService";
12
+ import { sessionIdSchema, validateWithResponse } from "../validation";
13
+
14
+ export function registerPollRoute(app: Hono) {
15
+ app.get("/api/poll", async (c) => {
16
+ const clientETag = c.req.header("If-None-Match");
17
+ const rawSessionId = c.req.query('sessionId');
18
+
19
+ let sessionId: string | undefined;
20
+ if (rawSessionId) {
21
+ const validation = validateWithResponse(sessionIdSchema, rawSessionId, c);
22
+ if (!validation.success) return validation.response;
23
+ sessionId = validation.value;
24
+ }
25
+
26
+ const pollCache = getPollCache();
27
+ const POLL_CACHE_TTL = getPollCacheTTL();
28
+
29
+ if (!sessionId && pollCache && Date.now() - pollCache.timestamp < POLL_CACHE_TTL) {
30
+ if (clientETag === pollCache.etag) {
31
+ return new Response(null, { status: 304, headers: { ETag: pollCache.etag } });
32
+ }
33
+ c.header("ETag", pollCache.etag);
34
+ return c.json(pollCache.data);
35
+ }
36
+
37
+ const pollInProgress = getPollInProgress();
38
+ if (!sessionId && pollInProgress) {
39
+ try {
40
+ const data = await pollInProgress;
41
+ const etag = generateETag(data);
42
+ if (clientETag === etag) {
43
+ return new Response(null, { status: 304, headers: { ETag: etag } });
44
+ }
45
+ c.header("ETag", etag);
46
+ return c.json(data);
47
+ } catch {
48
+ setPollInProgress(null);
49
+ }
50
+ }
51
+
52
+ let pollData: Awaited<ReturnType<typeof fetchPollData>>;
53
+ if (!sessionId) {
54
+ const cacheEpochAtStart = getPollCacheEpoch();
55
+ const promise = fetchPollData();
56
+ setPollInProgress(promise);
57
+ try {
58
+ pollData = await promise;
59
+ const etag = generateETag(pollData);
60
+ if (cacheEpochAtStart === getPollCacheEpoch()) {
61
+ setPollCache({ data: pollData, etag, timestamp: Date.now() });
62
+ }
63
+ } finally {
64
+ setPollInProgress(null);
65
+ }
66
+ } else {
67
+ pollData = await fetchPollData(sessionId);
68
+ }
69
+
70
+ const etag = generateETag(pollData);
71
+ if (clientETag === etag) {
72
+ return new Response(null, { status: 304, headers: { ETag: etag } });
73
+ }
74
+ c.header("ETag", etag);
75
+ return c.json(pollData);
76
+ });
77
+ }
@@ -0,0 +1,34 @@
1
+ import type { Hono } from "hono";
2
+ import { listProjects, listAllSessions } from "../storage/sessionParser";
3
+
4
+ export function registerProjectRoutes(app: Hono) {
5
+ app.get("/api/projects", async (c) => {
6
+ const projectIDs = await listProjects();
7
+ const allSessions = await listAllSessions();
8
+
9
+ const projectsWithDetails = projectIDs.map((projectID) => {
10
+ const projectSessions = allSessions.filter((s) => s.projectID === projectID);
11
+ const directory = projectSessions[0]?.directory || "";
12
+
13
+ const lastActivityAt =
14
+ projectSessions.length > 0
15
+ ? new Date(
16
+ Math.max(...projectSessions.map((s) => s.updatedAt.getTime()))
17
+ )
18
+ : new Date(0);
19
+
20
+ return {
21
+ id: projectID,
22
+ directory,
23
+ sessionCount: projectSessions.length,
24
+ lastActivityAt,
25
+ };
26
+ });
27
+
28
+ projectsWithDetails.sort(
29
+ (a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
30
+ );
31
+
32
+ return c.json(projectsWithDetails);
33
+ });
34
+ }
@@ -0,0 +1,118 @@
1
+ import type { Hono } from "hono";
2
+ import { listAllSessions, checkStorageExists } from "../storage/sessionParser";
3
+ import { listMessages } from "../storage/messageParser";
4
+ import { isAssistantFinished, buildAgentHierarchy, buildSessionTree } from "../services/sessionService";
5
+ import { getSessionStatus } from "../utils/sessionStatus";
6
+ import { sessionIdSchema, validateWithResponse } from "../validation";
7
+ import { MAX_SESSIONS_LIMIT, MAX_MESSAGES_LIMIT, TWENTY_FOUR_HOURS_MS } from "../../shared/constants";
8
+
9
+ export function registerSessionRoutes(app: Hono) {
10
+ app.get("/api/sessions", async (c) => {
11
+ const storageExists = await checkStorageExists();
12
+ if (!storageExists) {
13
+ return c.json({
14
+ error: "OpenCode storage not found",
15
+ message: "OpenCode storage directory does not exist. Please ensure OpenCode is installed.",
16
+ sessions: []
17
+ }, 200);
18
+ }
19
+
20
+ const allSessions = await listAllSessions();
21
+
22
+ const now = Date.now();
23
+ const twentyFourHoursAgo = now - TWENTY_FOUR_HOURS_MS;
24
+
25
+ const recentSessions = allSessions.filter(
26
+ (s) => s.updatedAt.getTime() >= twentyFourHoursAgo
27
+ );
28
+
29
+ const sortedSessions = recentSessions.sort(
30
+ (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
31
+ );
32
+
33
+ const limitedSessions = sortedSessions.slice(0, MAX_SESSIONS_LIMIT);
34
+ const rootSessions = limitedSessions.filter(s => !s.parentID);
35
+
36
+ const sessionsWithActivity = await Promise.all(
37
+ rootSessions.map(async (session) => {
38
+ const messages = await listMessages(session.id);
39
+ const lastAssistantFinished = isAssistantFinished(messages);
40
+ const status = getSessionStatus(messages, false, undefined, undefined, lastAssistantFinished);
41
+
42
+ return {
43
+ id: session.id,
44
+ title: session.title,
45
+ projectID: session.projectID,
46
+ parentID: session.parentID,
47
+ createdAt: session.createdAt,
48
+ updatedAt: session.updatedAt,
49
+ status,
50
+ isActive: status === "working" || status === "idle",
51
+ };
52
+ })
53
+ );
54
+
55
+ return c.json(sessionsWithActivity);
56
+ });
57
+
58
+ app.get("/api/sessions/:id", async (c) => {
59
+ const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
60
+ if (!validation.success) return validation.response;
61
+ const sessionID = validation.value;
62
+
63
+ const allSessions = await listAllSessions();
64
+ const session = allSessions.find((s) => s.id === sessionID);
65
+
66
+ if (!session) {
67
+ return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
68
+ }
69
+
70
+ const messages = await listMessages(sessionID);
71
+ const agentHierarchy = buildAgentHierarchy(messages);
72
+
73
+ return c.json({
74
+ ...session,
75
+ agentHierarchy,
76
+ });
77
+ });
78
+
79
+ app.get("/api/sessions/:id/messages", async (c) => {
80
+ const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
81
+ if (!validation.success) return validation.response;
82
+ const sessionID = validation.value;
83
+
84
+ const allSessions = await listAllSessions();
85
+ const session = allSessions.find((s) => s.id === sessionID);
86
+
87
+ if (!session) {
88
+ return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
89
+ }
90
+
91
+ const messages = await listMessages(sessionID);
92
+
93
+ const sortedMessages = messages.sort(
94
+ (a, b) => b.createdAt.getTime() - a.createdAt.getTime()
95
+ );
96
+
97
+ const limitedMessages = sortedMessages.slice(0, MAX_MESSAGES_LIMIT);
98
+
99
+ return c.json(limitedMessages);
100
+ });
101
+
102
+ app.get("/api/sessions/:id/tree", async (c) => {
103
+ const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
104
+ if (!validation.success) return validation.response;
105
+ const sessionID = validation.value;
106
+
107
+ const allSessions = await listAllSessions();
108
+ const session = allSessions.find((s) => s.id === sessionID);
109
+
110
+ if (!session) {
111
+ return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
112
+ }
113
+
114
+ const tree = await buildSessionTree(sessionID, allSessions);
115
+
116
+ return c.json(tree);
117
+ });
118
+ }
@@ -0,0 +1,91 @@
1
+ import type { Hono } from "hono";
2
+ import { streamSSE } from "hono/streaming";
3
+ import { Watcher } from "../watcher";
4
+ import { invalidatePollCache } from "../services/pollService";
5
+
6
+ let globalWatcher: Watcher | null = null;
7
+ const activeAbortControllers = new Set<AbortController>();
8
+
9
+ export function getGlobalWatcher(): Watcher {
10
+ if (!globalWatcher) {
11
+ globalWatcher = new Watcher();
12
+ globalWatcher.start();
13
+ }
14
+ return globalWatcher;
15
+ }
16
+
17
+ export function closeAllSSEConnections(): void {
18
+ for (const controller of activeAbortControllers) {
19
+ controller.abort();
20
+ }
21
+ activeAbortControllers.clear();
22
+ }
23
+
24
+ export function getActiveConnectionCount(): number {
25
+ return activeAbortControllers.size;
26
+ }
27
+
28
+ export function registerSSERoute(app: Hono) {
29
+ app.get("/api/sse", async (c) => {
30
+ return streamSSE(c, async (stream) => {
31
+ const watcher = getGlobalWatcher();
32
+ const abortController = new AbortController();
33
+ activeAbortControllers.add(abortController);
34
+
35
+ abortController.signal.addEventListener("abort", () => {
36
+ stream.abort();
37
+ });
38
+
39
+ await stream.writeSSE({
40
+ data: JSON.stringify({ connected: true, timestamp: Date.now() }),
41
+ event: "connected",
42
+ });
43
+
44
+ const heartbeatInterval = setInterval(async () => {
45
+ try {
46
+ await stream.writeSSE({
47
+ data: JSON.stringify({ timestamp: Date.now() }),
48
+ event: "heartbeat",
49
+ });
50
+ } catch {
51
+ // Ignore errors when stream is closed
52
+ }
53
+ }, 30000);
54
+
55
+ const handleChange = async (data: { eventType: string; filename: string }) => {
56
+ try {
57
+ let eventType = "session-update";
58
+ if (data.filename.includes("message")) {
59
+ eventType = "message-update";
60
+ } else if (data.filename.includes("part")) {
61
+ eventType = "part-update";
62
+ } else if (data.filename.includes("boulder")) {
63
+ eventType = "plan-update";
64
+ }
65
+
66
+ invalidatePollCache();
67
+ await stream.writeSSE({
68
+ data: JSON.stringify({
69
+ filename: data.filename,
70
+ eventType: data.eventType,
71
+ timestamp: Date.now(),
72
+ }),
73
+ event: eventType,
74
+ });
75
+ } catch {
76
+ // Ignore errors when stream is closed
77
+ }
78
+ };
79
+
80
+ watcher.on("change", handleChange);
81
+
82
+ stream.onAbort(() => {
83
+ clearInterval(heartbeatInterval);
84
+ watcher.removeListener("change", handleChange);
85
+ activeAbortControllers.delete(abortController);
86
+ });
87
+
88
+ await new Promise(() => {});
89
+ });
90
+ });
91
+ }