ocwatch 0.4.0 → 0.6.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 (41) hide show
  1. package/README.md +22 -3
  2. package/package.json +4 -4
  3. package/src/client/dist/assets/GraphView-BZV40eAE.css +1 -0
  4. package/src/client/dist/assets/GraphView-KWCCGYb2.js +9 -0
  5. package/src/client/dist/assets/graph-Cw_XSlvx.js +7 -0
  6. package/src/client/dist/assets/index-CbgYG3pJ.js +23 -0
  7. package/src/client/dist/assets/index-CgDCc8Mm.css +1 -0
  8. package/src/client/dist/assets/motion-CGUGF2CN.js +9 -0
  9. package/src/client/dist/index.html +4 -2
  10. package/src/server/__tests__/helpers/testDb.ts +220 -0
  11. package/src/server/index.ts +27 -27
  12. package/src/server/logic/activityLogic.ts +260 -0
  13. package/src/server/logic/index.ts +2 -0
  14. package/src/server/logic/sessionLogic.ts +107 -0
  15. package/src/server/routes/parts.ts +9 -7
  16. package/src/server/routes/poll.ts +32 -46
  17. package/src/server/routes/projects.ts +10 -27
  18. package/src/server/routes/sessions.ts +159 -68
  19. package/src/server/routes/sse.ts +10 -4
  20. package/src/server/services/parsing.ts +211 -0
  21. package/src/server/services/pollService.ts +400 -116
  22. package/src/server/services/recentSessions.ts +14 -0
  23. package/src/server/services/sessionContext.ts +97 -0
  24. package/src/server/services/sessionService.ts +97 -193
  25. package/src/server/services/sessionTree.ts +92 -0
  26. package/src/server/storage/db.ts +63 -0
  27. package/src/server/storage/index.ts +28 -0
  28. package/src/server/storage/queries.ts +528 -0
  29. package/src/server/utils/projectResolver.ts +9 -3
  30. package/src/server/utils/sessionStatus.ts +5 -89
  31. package/src/server/validation.ts +2 -4
  32. package/src/server/watcher.ts +225 -82
  33. package/src/shared/constants.ts +8 -3
  34. package/src/shared/index.ts +3 -0
  35. package/src/shared/types/index.ts +48 -53
  36. package/src/shared/utils/activityUtils.ts +3 -2
  37. package/src/client/dist/assets/index-BIu7r5_5.css +0 -1
  38. package/src/client/dist/assets/index-BYMVif3u.js +0 -50
  39. package/src/server/storage/messageParser.ts +0 -169
  40. package/src/server/storage/partParser.ts +0 -532
  41. package/src/server/storage/sessionParser.ts +0 -180
@@ -0,0 +1,107 @@
1
+ import type { AgentPhase, MessageMeta, SessionStatus } from "../../shared/types";
2
+
3
+ export type WaitingReason = "user" | "children";
4
+
5
+ export interface SessionStatusInfo {
6
+ status: SessionStatus;
7
+ waitingReason?: WaitingReason;
8
+ }
9
+
10
+ const WORKING_THRESHOLD = 30 * 1000;
11
+ const COMPLETED_THRESHOLD = 5 * 60 * 1000;
12
+ const GRACE_PERIOD = 5 * 1000;
13
+
14
+ export function isAssistantFinished(messages: MessageMeta[]): boolean {
15
+ if (messages.length === 0) {
16
+ return false;
17
+ }
18
+
19
+ const lastMessage = messages.reduce((latest, current) =>
20
+ current.createdAt.getTime() > latest.createdAt.getTime() ? current : latest
21
+ );
22
+
23
+ return lastMessage.role === "assistant" && lastMessage.finish === "stop";
24
+ }
25
+
26
+ export function detectAgentPhases(messages: MessageMeta[]): AgentPhase[] {
27
+ const sorted = messages
28
+ .filter(m => m.role === "assistant" && m.agent)
29
+ .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
30
+
31
+ if (sorted.length === 0) return [];
32
+
33
+ const phases: AgentPhase[] = [];
34
+ let currentPhase: AgentPhase | null = null;
35
+
36
+ for (const msg of sorted) {
37
+ if (!currentPhase || currentPhase.agent !== msg.agent) {
38
+ if (currentPhase) phases.push(currentPhase);
39
+ currentPhase = {
40
+ agent: msg.agent!,
41
+ startTime: msg.createdAt,
42
+ endTime: msg.createdAt,
43
+ tokens: msg.tokens || 0,
44
+ messageCount: 1,
45
+ };
46
+ } else {
47
+ currentPhase.endTime = msg.createdAt;
48
+ currentPhase.tokens += msg.tokens || 0;
49
+ currentPhase.messageCount++;
50
+ }
51
+ }
52
+ if (currentPhase) phases.push(currentPhase);
53
+
54
+ return phases;
55
+ }
56
+
57
+ export function getSessionStatusInfo(
58
+ messages: MessageMeta[],
59
+ hasPendingToolCall: boolean = false,
60
+ lastToolCompletedAt?: Date,
61
+ workingChildCount?: number,
62
+ lastAssistantFinished?: boolean,
63
+ isSubagent: boolean = false
64
+ ): SessionStatusInfo {
65
+ if (hasPendingToolCall) {
66
+ return { status: "working" };
67
+ }
68
+
69
+ if (workingChildCount && workingChildCount > 0) {
70
+ return { status: "waiting", waitingReason: "children" };
71
+ }
72
+
73
+ let timeSinceLastMessage = Infinity;
74
+ if (messages && messages.length > 0) {
75
+ const lastMessage = messages.reduce((latest, msg) =>
76
+ msg.createdAt.getTime() > latest.createdAt.getTime() ? msg : latest
77
+ );
78
+ timeSinceLastMessage = Date.now() - lastMessage.createdAt.getTime();
79
+ }
80
+
81
+ if (lastAssistantFinished && timeSinceLastMessage < COMPLETED_THRESHOLD) {
82
+ if (isSubagent) {
83
+ return { status: "completed" };
84
+ }
85
+ return { status: "waiting", waitingReason: "user" };
86
+ }
87
+
88
+ if (lastToolCompletedAt) {
89
+ const now = Date.now();
90
+ const timeSinceToolCompleted = now - lastToolCompletedAt.getTime();
91
+ if (timeSinceToolCompleted < GRACE_PERIOD) {
92
+ return { status: "working" };
93
+ }
94
+ }
95
+
96
+ if (!messages || messages.length === 0) {
97
+ return { status: "completed" };
98
+ }
99
+
100
+ if (timeSinceLastMessage < WORKING_THRESHOLD) {
101
+ return { status: "working" };
102
+ } else if (timeSinceLastMessage < COMPLETED_THRESHOLD) {
103
+ return { status: "idle" };
104
+ } else {
105
+ return { status: "completed" };
106
+ }
107
+ }
@@ -1,19 +1,21 @@
1
1
  import type { Hono } from "hono";
2
- import { getPart } from "../storage/partParser";
2
+ import { queryPart } from "../storage/queries";
3
+ import { toPartMeta } from "../services/parsing";
3
4
  import { partIdSchema, validateWithResponse } from "../validation";
4
5
 
5
6
  export function registerPartRoutes(app: Hono) {
6
- app.get("/api/parts/:id", async (c) => {
7
+ app.get("/api/parts/:id", (c) => {
7
8
  const validation = validateWithResponse(partIdSchema, c.req.param("id"), c);
8
9
  if (!validation.success) return validation.response;
9
10
  const partID = validation.value;
10
-
11
- const part = await getPart(partID);
12
-
13
- if (!part) {
11
+
12
+ const row = queryPart(partID);
13
+
14
+ if (!row) {
14
15
  return c.json({ error: "PART_NOT_FOUND", message: `Part '${partID}' not found`, status: 404 }, 404);
15
16
  }
16
-
17
+
18
+ const part = toPartMeta(row);
17
19
  return c.json(part);
18
20
  });
19
21
  }
@@ -1,54 +1,42 @@
1
1
  import type { Hono } from "hono";
2
- import { z } from "zod";
3
- import {
4
- generateETag,
5
- fetchPollData,
6
- getPollCache,
7
- setPollCache,
2
+ import {
3
+ generateETag,
4
+ fetchPollData,
5
+ getPollCache,
6
+ setPollCache,
8
7
  getPollCacheEpoch,
9
- getPollInProgress,
8
+ getPollInProgress,
10
9
  setPollInProgress,
11
- getPollCacheTTL
10
+ getPollCacheTTL,
12
11
  } from "../services/pollService";
13
- import { sessionIdSchema, validateWithResponse } from "../validation";
14
-
15
- const projectIdSchema = z.string().regex(/^[a-zA-Z0-9_-]+$/, "Invalid project ID format");
12
+ import { projectIdSchema, validateWithResponse } from "../validation";
16
13
 
17
14
  export function registerPollRoute(app: Hono) {
18
15
  app.get("/api/poll", async (c) => {
19
16
  const clientETag = c.req.header("If-None-Match");
20
- const rawSessionId = c.req.query('sessionId');
21
17
  const rawProjectId = c.req.query("projectId");
22
-
23
- let sessionId: string | undefined;
24
- let projectId: string | undefined;
25
18
 
26
- if (rawSessionId) {
27
- const validation = validateWithResponse(sessionIdSchema, rawSessionId, c);
28
- if (!validation.success) return validation.response;
29
- sessionId = validation.value;
30
- }
19
+ let projectId: string | undefined;
31
20
 
32
21
  if (rawProjectId) {
33
22
  const validation = validateWithResponse(projectIdSchema, rawProjectId, c);
34
23
  if (!validation.success) return validation.response;
35
24
  projectId = validation.value;
36
25
  }
37
-
26
+
38
27
  const POLL_CACHE_TTL = getPollCacheTTL();
39
- const scopeProjectId = sessionId ? undefined : projectId;
40
-
41
- const cached = getPollCache(scopeProjectId);
42
- if (!sessionId && cached && Date.now() - cached.timestamp < POLL_CACHE_TTL) {
28
+
29
+ const cached = getPollCache(projectId);
30
+ if (cached && Date.now() - cached.timestamp < POLL_CACHE_TTL) {
43
31
  if (clientETag === cached.etag) {
44
32
  return new Response(null, { status: 304, headers: { ETag: cached.etag } });
45
33
  }
46
34
  c.header("ETag", cached.etag);
47
35
  return c.json(cached.data);
48
36
  }
49
-
50
- const inProgress = getPollInProgress(scopeProjectId);
51
- if (!sessionId && inProgress) {
37
+
38
+ const inProgress = getPollInProgress(projectId);
39
+ if (inProgress) {
52
40
  try {
53
41
  const data = await inProgress;
54
42
  const etag = generateETag(data);
@@ -57,29 +45,27 @@ export function registerPollRoute(app: Hono) {
57
45
  }
58
46
  c.header("ETag", etag);
59
47
  return c.json(data);
60
- } catch {
61
- setPollInProgress(null, scopeProjectId);
48
+ } catch (err) {
49
+ console.warn("Poll request failed, retrying:", err instanceof Error ? err.message : err);
50
+ setPollInProgress(null, projectId);
62
51
  }
63
52
  }
64
-
53
+
54
+ const cacheEpochAtStart = getPollCacheEpoch();
55
+ const promise = fetchPollData(projectId);
56
+ setPollInProgress(promise, projectId);
57
+
65
58
  let pollData: Awaited<ReturnType<typeof fetchPollData>>;
66
- if (!sessionId) {
67
- const cacheEpochAtStart = getPollCacheEpoch();
68
- const promise = fetchPollData(undefined, projectId);
69
- setPollInProgress(promise, scopeProjectId);
70
- try {
71
- pollData = await promise;
72
- const etag = generateETag(pollData);
73
- if (cacheEpochAtStart === getPollCacheEpoch()) {
74
- setPollCache({ data: pollData, etag, timestamp: Date.now() }, scopeProjectId);
75
- }
76
- } finally {
77
- setPollInProgress(null, scopeProjectId);
59
+ try {
60
+ pollData = await promise;
61
+ const etag = generateETag(pollData);
62
+ if (cacheEpochAtStart === getPollCacheEpoch()) {
63
+ setPollCache({ data: pollData, etag, timestamp: Date.now() }, projectId);
78
64
  }
79
- } else {
80
- pollData = await fetchPollData(sessionId, projectId);
65
+ } finally {
66
+ setPollInProgress(null, projectId);
81
67
  }
82
-
68
+
83
69
  const etag = generateETag(pollData);
84
70
  if (clientETag === etag) {
85
71
  return new Response(null, { status: 304, headers: { ETag: etag } });
@@ -1,34 +1,17 @@
1
1
  import type { Hono } from "hono";
2
- import { listProjects, listAllSessions } from "../storage/sessionParser";
2
+ import { queryProjectSummaries } from "../storage";
3
3
 
4
4
  export function registerProjectRoutes(app: Hono) {
5
- app.get("/api/projects", async (c) => {
6
- const projectIDs = await listProjects();
7
- const allSessions = await listAllSessions();
5
+ app.get("/api/projects", (c) => {
6
+ const summaries = queryProjectSummaries();
8
7
 
9
- const projectsWithDetails = projectIDs.map((projectID) => {
10
- const projectSessions = allSessions.filter((s) => s.projectID === projectID);
11
- const directory = projectSessions[0]?.directory || "";
8
+ const projects = summaries.map((row) => ({
9
+ id: row.id,
10
+ directory: row.worktree,
11
+ sessionCount: row.sessionCount,
12
+ lastActivityAt: new Date(row.lastActivityAt),
13
+ }));
12
14
 
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);
15
+ return c.json(projects);
33
16
  });
34
17
  }
@@ -1,118 +1,209 @@
1
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";
2
+ import { checkDbExists } from "../storage";
3
+ import {
4
+ querySessions,
5
+ querySession,
6
+ queryMessages,
7
+ queryTodos,
8
+ } from "../storage/queries";
9
+ import type { DbSessionRow } from "../storage/queries";
10
+ import {
11
+ fetchSessionActivity,
12
+ fetchSessionDetail,
13
+ generateSessionActivityETag,
14
+ getPollCacheEpoch,
15
+ getSessionActivityCache,
16
+ getSessionActivityInProgress,
17
+ setSessionActivityCache,
18
+ setSessionActivityInProgress,
19
+ } from "../services/pollService";
20
+ import { toMessageMeta } from "../services/parsing";
21
+ import { selectRecentRootSessions } from "../services/recentSessions";
22
+ import { buildSessionTree } from "../services/sessionTree";
6
23
  import { sessionIdSchema, validateWithResponse } from "../validation";
7
- import { MAX_SESSIONS_LIMIT, MAX_MESSAGES_LIMIT, TWENTY_FOUR_HOURS_MS } from "../../shared/constants";
24
+ import { MAX_SESSIONS_LIMIT, MAX_MESSAGES_LIMIT, SESSION_SCAN_LIMIT } from "../../shared/constants";
25
+ import type { SessionActivityResponse, SessionMetadata } from "../../shared/types";
26
+
27
+ function dbRowToSessionBase(row: DbSessionRow) {
28
+ return {
29
+ id: row.id,
30
+ projectID: row.projectID,
31
+ title: row.title,
32
+ parentID: row.parentID ?? undefined,
33
+ updatedAt: new Date(row.timeUpdated),
34
+ createdAt: new Date(row.timeCreated),
35
+ };
36
+ }
37
+
38
+ function dbRowToSessionMeta(row: DbSessionRow): SessionMetadata {
39
+ return {
40
+ id: row.id,
41
+ projectID: row.projectID,
42
+ directory: row.directory,
43
+ title: row.title,
44
+ parentID: row.parentID ?? undefined,
45
+ createdAt: new Date(row.timeCreated),
46
+ updatedAt: new Date(row.timeUpdated),
47
+ };
48
+ }
8
49
 
9
50
  export function registerSessionRoutes(app: Hono) {
10
- app.get("/api/sessions", async (c) => {
11
- const storageExists = await checkStorageExists();
12
- if (!storageExists) {
13
- return c.json({
51
+ app.get("/api/sessions", (c) => {
52
+ if (!checkDbExists()) {
53
+ return c.json({
14
54
  error: "OpenCode storage not found",
15
55
  message: "OpenCode storage directory does not exist. Please ensure OpenCode is installed.",
16
- sessions: []
56
+ sessions: [],
17
57
  }, 200);
18
58
  }
19
59
 
20
- const allSessions = await listAllSessions();
60
+ const sessions = selectRecentRootSessions(
61
+ querySessions(undefined, undefined, SESSION_SCAN_LIMIT).map(dbRowToSessionBase),
62
+ MAX_SESSIONS_LIMIT,
63
+ );
21
64
 
22
- const now = Date.now();
23
- const twentyFourHoursAgo = now - TWENTY_FOUR_HOURS_MS;
65
+ return c.json(sessions);
66
+ });
24
67
 
25
- const recentSessions = allSessions.filter(
26
- (s) => s.updatedAt.getTime() >= twentyFourHoursAgo
27
- );
68
+ app.get("/api/sessions/:id", async (c) => {
69
+ const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
70
+ if (!validation.success) return validation.response;
71
+ const sessionID = validation.value;
28
72
 
29
- const sortedSessions = recentSessions.sort(
30
- (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
31
- );
73
+ if (!checkDbExists()) {
74
+ return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
75
+ }
32
76
 
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
- );
77
+ const sessionRow = querySession(sessionID);
78
+ if (!sessionRow) {
79
+ return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
80
+ }
54
81
 
55
- return c.json(sessionsWithActivity);
82
+ const detail = await fetchSessionDetail(sessionID);
83
+ return c.json(detail);
56
84
  });
57
85
 
58
- app.get("/api/sessions/:id", async (c) => {
86
+ app.get("/api/sessions/:id/messages", (c) => {
59
87
  const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
60
88
  if (!validation.success) return validation.response;
61
89
  const sessionID = validation.value;
62
90
 
63
- const allSessions = await listAllSessions();
64
- const session = allSessions.find((s) => s.id === sessionID);
91
+ if (!checkDbExists()) {
92
+ return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
93
+ }
94
+
95
+ const sessionRow = querySession(sessionID);
96
+ if (!sessionRow) {
97
+ return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
98
+ }
99
+
100
+ const messages = queryMessages(sessionID, MAX_MESSAGES_LIMIT).map(toMessageMeta);
101
+ return c.json(messages);
102
+ });
103
+
104
+ app.get("/api/sessions/:id/tree", async (c) => {
105
+ const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
106
+ if (!validation.success) return validation.response;
107
+ const sessionID = validation.value;
65
108
 
66
- if (!session) {
109
+ if (!checkDbExists()) {
67
110
  return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
68
111
  }
69
112
 
70
- const messages = await listMessages(sessionID);
71
- const agentHierarchy = buildAgentHierarchy(messages);
113
+ const sessionRow = querySession(sessionID);
114
+ if (!sessionRow) {
115
+ return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
116
+ }
72
117
 
73
- return c.json({
74
- ...session,
75
- agentHierarchy,
76
- });
118
+ const allSessions = querySessions(undefined, undefined, SESSION_SCAN_LIMIT).map(dbRowToSessionMeta);
119
+ const tree = await buildSessionTree(sessionID, allSessions);
120
+ return c.json(tree);
77
121
  });
78
122
 
79
- app.get("/api/sessions/:id/messages", async (c) => {
123
+ app.get("/api/sessions/:id/activity", async (c) => {
80
124
  const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
81
125
  if (!validation.success) return validation.response;
82
126
  const sessionID = validation.value;
127
+ const clientETag = c.req.header("If-None-Match");
83
128
 
84
- const allSessions = await listAllSessions();
85
- const session = allSessions.find((s) => s.id === sessionID);
129
+ if (!checkDbExists()) {
130
+ return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
131
+ }
86
132
 
87
- if (!session) {
133
+ const sessionRow = querySession(sessionID);
134
+ if (!sessionRow) {
88
135
  return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
89
136
  }
90
137
 
91
- const messages = await listMessages(sessionID);
138
+ const cached = getSessionActivityCache(sessionID);
139
+ if (cached) {
140
+ if (clientETag === cached.etag) {
141
+ return new Response(null, { status: 304, headers: { ETag: cached.etag } });
142
+ }
143
+ c.header("ETag", cached.etag);
144
+ return c.json(cached.data);
145
+ }
92
146
 
93
- const sortedMessages = messages.sort(
94
- (a, b) => b.createdAt.getTime() - a.createdAt.getTime()
95
- );
147
+ const inProgress = getSessionActivityInProgress(sessionID);
148
+ if (inProgress) {
149
+ try {
150
+ const data = await inProgress;
151
+ const etag = generateSessionActivityETag(data);
152
+ if (clientETag === etag) {
153
+ return new Response(null, { status: 304, headers: { ETag: etag } });
154
+ }
155
+ c.header("ETag", etag);
156
+ return c.json(data);
157
+ } catch (err) {
158
+ console.warn("Session activity request failed, retrying:", err instanceof Error ? err.message : err);
159
+ setSessionActivityInProgress(sessionID, null);
160
+ }
161
+ }
96
162
 
97
- const limitedMessages = sortedMessages.slice(0, MAX_MESSAGES_LIMIT);
163
+ const cacheEpochAtStart = getPollCacheEpoch();
164
+ const promise = fetchSessionActivity(sessionID);
165
+ setSessionActivityInProgress(sessionID, promise);
166
+
167
+ let data: SessionActivityResponse;
168
+ try {
169
+ data = await promise;
170
+ const etag = generateSessionActivityETag(data);
171
+ if (cacheEpochAtStart === getPollCacheEpoch()) {
172
+ setSessionActivityCache(sessionID, { data, etag });
173
+ }
174
+ } finally {
175
+ setSessionActivityInProgress(sessionID, null);
176
+ }
177
+
178
+ const etag = generateSessionActivityETag(data);
179
+ if (clientETag === etag) {
180
+ return new Response(null, { status: 304, headers: { ETag: etag } });
181
+ }
98
182
 
99
- return c.json(limitedMessages);
183
+ c.header("ETag", etag);
184
+ return c.json(data);
100
185
  });
101
186
 
102
- app.get("/api/sessions/:id/tree", async (c) => {
187
+ app.get("/api/sessions/:id/todos", (c) => {
103
188
  const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
104
189
  if (!validation.success) return validation.response;
105
190
  const sessionID = validation.value;
106
191
 
107
- const allSessions = await listAllSessions();
108
- const session = allSessions.find((s) => s.id === sessionID);
192
+ if (!checkDbExists()) {
193
+ return c.json([], 200);
194
+ }
109
195
 
110
- if (!session) {
196
+ const sessionRow = querySession(sessionID);
197
+ if (!sessionRow) {
111
198
  return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
112
199
  }
113
200
 
114
- const tree = await buildSessionTree(sessionID, allSessions);
115
-
116
- return c.json(tree);
201
+ const todos = queryTodos(sessionID).map((row) => ({
202
+ content: row.content,
203
+ status: row.status,
204
+ priority: row.priority,
205
+ position: row.position,
206
+ }));
207
+ return c.json(todos);
117
208
  });
118
209
  }
@@ -47,8 +47,11 @@ export function registerSSERoute(app: Hono) {
47
47
  data: JSON.stringify({ timestamp: Date.now() }),
48
48
  event: "heartbeat",
49
49
  });
50
- } catch {
51
- // Ignore errors when stream is closed
50
+ } catch (error) {
51
+ // Stream closed expected during disconnect
52
+ if (error instanceof Error && error.message !== 'The stream has been aborted') {
53
+ console.debug('[sse] Heartbeat write failed:', error.message);
54
+ }
52
55
  }
53
56
  }, 30000);
54
57
 
@@ -72,8 +75,11 @@ export function registerSSERoute(app: Hono) {
72
75
  }),
73
76
  event: eventType,
74
77
  });
75
- } catch {
76
- // Ignore errors when stream is closed
78
+ } catch (error) {
79
+ // Stream closed expected during disconnect
80
+ if (error instanceof Error && error.message !== 'The stream has been aborted') {
81
+ console.debug('[sse] Change event write failed:', error.message);
82
+ }
77
83
  }
78
84
  };
79
85