sidekick-shared 0.13.2

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 (126) hide show
  1. package/README.md +92 -0
  2. package/dist/aggregation/EventAggregator.d.ts +172 -0
  3. package/dist/aggregation/EventAggregator.js +1443 -0
  4. package/dist/aggregation/FrequencyTracker.d.ts +42 -0
  5. package/dist/aggregation/FrequencyTracker.js +73 -0
  6. package/dist/aggregation/HeatmapTracker.d.ts +40 -0
  7. package/dist/aggregation/HeatmapTracker.js +93 -0
  8. package/dist/aggregation/PatternExtractor.d.ts +51 -0
  9. package/dist/aggregation/PatternExtractor.js +171 -0
  10. package/dist/aggregation/snapshot.d.ts +64 -0
  11. package/dist/aggregation/snapshot.js +151 -0
  12. package/dist/aggregation/types.d.ts +121 -0
  13. package/dist/aggregation/types.js +6 -0
  14. package/dist/context/composer.d.ts +31 -0
  15. package/dist/context/composer.js +72 -0
  16. package/dist/credentials.d.ts +23 -0
  17. package/dist/credentials.js +96 -0
  18. package/dist/formatters/eventHighlighter.d.ts +30 -0
  19. package/dist/formatters/eventHighlighter.js +217 -0
  20. package/dist/formatters/noiseClassifier.d.ts +73 -0
  21. package/dist/formatters/noiseClassifier.js +226 -0
  22. package/dist/formatters/sessionDump.d.ts +38 -0
  23. package/dist/formatters/sessionDump.js +313 -0
  24. package/dist/formatters/toolSummary.d.ts +23 -0
  25. package/dist/formatters/toolSummary.js +230 -0
  26. package/dist/index.d.ts +85 -0
  27. package/dist/index.js +182 -0
  28. package/dist/parsers/changelogParser.d.ts +25 -0
  29. package/dist/parsers/changelogParser.js +74 -0
  30. package/dist/parsers/codexParser.d.ts +76 -0
  31. package/dist/parsers/codexParser.js +653 -0
  32. package/dist/parsers/debugLogParser.d.ts +63 -0
  33. package/dist/parsers/debugLogParser.js +164 -0
  34. package/dist/parsers/jsonl.d.ts +45 -0
  35. package/dist/parsers/jsonl.js +57 -0
  36. package/dist/parsers/openCodeParser.d.ts +64 -0
  37. package/dist/parsers/openCodeParser.js +581 -0
  38. package/dist/parsers/planExtractor.d.ts +63 -0
  39. package/dist/parsers/planExtractor.js +330 -0
  40. package/dist/parsers/sessionActivityDetector.d.ts +31 -0
  41. package/dist/parsers/sessionActivityDetector.js +184 -0
  42. package/dist/parsers/sessionPathResolver.d.ts +230 -0
  43. package/dist/parsers/sessionPathResolver.js +753 -0
  44. package/dist/parsers/subagentScanner.d.ts +43 -0
  45. package/dist/parsers/subagentScanner.js +366 -0
  46. package/dist/parsers/subagentTraceParser.d.ts +58 -0
  47. package/dist/parsers/subagentTraceParser.js +346 -0
  48. package/dist/paths.d.ts +38 -0
  49. package/dist/paths.js +107 -0
  50. package/dist/phrases.d.ts +52 -0
  51. package/dist/phrases.js +1333 -0
  52. package/dist/providers/claudeCode.d.ts +48 -0
  53. package/dist/providers/claudeCode.js +465 -0
  54. package/dist/providers/codex.d.ts +57 -0
  55. package/dist/providers/codex.js +944 -0
  56. package/dist/providers/codexDatabase.d.ts +37 -0
  57. package/dist/providers/codexDatabase.js +148 -0
  58. package/dist/providers/detect.d.ts +16 -0
  59. package/dist/providers/detect.js +162 -0
  60. package/dist/providers/openCode.d.ts +70 -0
  61. package/dist/providers/openCode.js +1524 -0
  62. package/dist/providers/openCodeDatabase.d.ts +87 -0
  63. package/dist/providers/openCodeDatabase.js +232 -0
  64. package/dist/providers/types.d.ts +154 -0
  65. package/dist/providers/types.js +12 -0
  66. package/dist/quota.d.ts +34 -0
  67. package/dist/quota.js +80 -0
  68. package/dist/readers/decisions.d.ts +10 -0
  69. package/dist/readers/decisions.js +27 -0
  70. package/dist/readers/handoff.d.ts +4 -0
  71. package/dist/readers/handoff.js +51 -0
  72. package/dist/readers/helpers.d.ts +7 -0
  73. package/dist/readers/helpers.js +52 -0
  74. package/dist/readers/history.d.ts +5 -0
  75. package/dist/readers/history.js +12 -0
  76. package/dist/readers/notes.d.ts +10 -0
  77. package/dist/readers/notes.js +46 -0
  78. package/dist/readers/plans.d.ts +35 -0
  79. package/dist/readers/plans.js +247 -0
  80. package/dist/readers/tasks.d.ts +8 -0
  81. package/dist/readers/tasks.js +22 -0
  82. package/dist/report/htmlHelpers.d.ts +18 -0
  83. package/dist/report/htmlHelpers.js +166 -0
  84. package/dist/report/htmlReportGenerator.d.ts +11 -0
  85. package/dist/report/htmlReportGenerator.js +650 -0
  86. package/dist/report/index.d.ts +8 -0
  87. package/dist/report/index.js +16 -0
  88. package/dist/report/logo.d.ts +2 -0
  89. package/dist/report/logo.js +5 -0
  90. package/dist/report/openBrowser.d.ts +5 -0
  91. package/dist/report/openBrowser.js +22 -0
  92. package/dist/report/transcriptParser.d.ts +12 -0
  93. package/dist/report/transcriptParser.js +177 -0
  94. package/dist/report/types.d.ts +43 -0
  95. package/dist/report/types.js +5 -0
  96. package/dist/search/advancedFilter.d.ts +62 -0
  97. package/dist/search/advancedFilter.js +201 -0
  98. package/dist/search/sessionSearch.d.ts +16 -0
  99. package/dist/search/sessionSearch.js +93 -0
  100. package/dist/types/codex.d.ts +276 -0
  101. package/dist/types/codex.js +14 -0
  102. package/dist/types/decisionLog.d.ts +23 -0
  103. package/dist/types/decisionLog.js +8 -0
  104. package/dist/types/historicalData.d.ts +74 -0
  105. package/dist/types/historicalData.js +17 -0
  106. package/dist/types/knowledgeNote.d.ts +40 -0
  107. package/dist/types/knowledgeNote.js +18 -0
  108. package/dist/types/opencode.d.ts +268 -0
  109. package/dist/types/opencode.js +13 -0
  110. package/dist/types/plan.d.ts +49 -0
  111. package/dist/types/plan.js +10 -0
  112. package/dist/types/sessionEvent.d.ts +562 -0
  113. package/dist/types/sessionEvent.js +11 -0
  114. package/dist/types/taskPersistence.d.ts +33 -0
  115. package/dist/types/taskPersistence.js +16 -0
  116. package/dist/watchers/eventBridge.d.ts +19 -0
  117. package/dist/watchers/eventBridge.js +162 -0
  118. package/dist/watchers/factory.d.ts +15 -0
  119. package/dist/watchers/factory.js +85 -0
  120. package/dist/watchers/jsonlWatcher.d.ts +30 -0
  121. package/dist/watchers/jsonlWatcher.js +444 -0
  122. package/dist/watchers/sqliteWatcher.d.ts +30 -0
  123. package/dist/watchers/sqliteWatcher.js +278 -0
  124. package/dist/watchers/types.d.ts +60 -0
  125. package/dist/watchers/types.js +5 -0
  126. package/package.json +31 -0
@@ -0,0 +1,330 @@
1
+ "use strict";
2
+ /**
3
+ * Shared plan extraction from session events.
4
+ *
5
+ * Handles all three providers:
6
+ * - Claude Code: EnterPlanMode/ExitPlanMode tool calls with accumulated markdown
7
+ * - OpenCode: <proposed_plan> XML blocks in assistant messages
8
+ * - Codex: UpdatePlan tool calls with structured approach arrays
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.PlanExtractor = void 0;
12
+ exports.parsePlanMarkdown = parsePlanMarkdown;
13
+ exports.extractProposedPlan = extractProposedPlan;
14
+ /** Checkbox pattern: `- [ ] text` or `- [x] text` */
15
+ const CHECKBOX_PATTERN = /^[-*]\s+\[([ xX])\]\s+(.+)/;
16
+ /** Numbered list pattern: `1. text` or `1) text` */
17
+ const NUMBERED_PATTERN = /^\d+[.)]\s+(.+)/;
18
+ /** Bullet point pattern: `- Step` or `* Step` (excluding checkboxes) */
19
+ const BULLET_PATTERN = /^[-*]\s+(?!\[[ xX]\])(.+)/;
20
+ /** Phase header pattern: `## Phase 1: Setup` */
21
+ const PHASE_HEADER_PATTERN = /^#{2,4}\s+(?:Phase|Step|Stage)\s*\d*[:.]\s*(.+)/i;
22
+ /** Generic H1/H2 header for title extraction */
23
+ const TITLE_HEADER_PATTERN = /^#{1,2}\s+(.+)/;
24
+ /** Complexity keywords */
25
+ const EXPLICIT_COMPLEXITY_PATTERN = /\[(high|medium|low)\]|\((complex|simple)\)/i;
26
+ const HIGH_COMPLEXITY_KEYWORDS = /\b(refactor|migrate|rewrite|redesign|overhaul|rearchitect)\b/i;
27
+ const LOW_COMPLEXITY_KEYWORDS = /\b(update|fix|tweak|rename|adjust|bump|typo)\b/i;
28
+ function inferComplexity(description) {
29
+ const explicitMatch = description.match(EXPLICIT_COMPLEXITY_PATTERN);
30
+ if (explicitMatch) {
31
+ const marker = (explicitMatch[1] || explicitMatch[2]).toLowerCase();
32
+ if (marker === 'high' || marker === 'complex')
33
+ return 'high';
34
+ if (marker === 'low' || marker === 'simple')
35
+ return 'low';
36
+ return 'medium';
37
+ }
38
+ if (HIGH_COMPLEXITY_KEYWORDS.test(description))
39
+ return 'high';
40
+ if (LOW_COMPLEXITY_KEYWORDS.test(description))
41
+ return 'low';
42
+ return undefined;
43
+ }
44
+ /**
45
+ * Parses plan markdown into structured plan steps.
46
+ */
47
+ function parsePlanMarkdown(markdown) {
48
+ if (!markdown || !markdown.trim())
49
+ return { steps: [] };
50
+ const lines = markdown.split('\n');
51
+ const steps = [];
52
+ let title;
53
+ let currentPhase;
54
+ let stepIndex = 0;
55
+ for (const rawLine of lines) {
56
+ const line = rawLine.trim();
57
+ if (!line)
58
+ continue;
59
+ if (!title) {
60
+ const titleMatch = line.match(TITLE_HEADER_PATTERN);
61
+ if (titleMatch)
62
+ title = titleMatch[1].trim();
63
+ }
64
+ const phaseMatch = line.match(PHASE_HEADER_PATTERN);
65
+ if (phaseMatch) {
66
+ currentPhase = phaseMatch[1].trim();
67
+ continue;
68
+ }
69
+ const checkboxMatch = line.match(CHECKBOX_PATTERN);
70
+ if (checkboxMatch) {
71
+ const checked = checkboxMatch[1].toLowerCase() === 'x';
72
+ const description = checkboxMatch[2].trim();
73
+ steps.push({
74
+ id: `step-${stepIndex}`,
75
+ description,
76
+ status: checked ? 'completed' : 'pending',
77
+ phase: currentPhase,
78
+ complexity: inferComplexity(description),
79
+ });
80
+ stepIndex++;
81
+ continue;
82
+ }
83
+ const numberedMatch = line.match(NUMBERED_PATTERN);
84
+ if (numberedMatch) {
85
+ const description = numberedMatch[1].trim();
86
+ steps.push({
87
+ id: `step-${stepIndex}`,
88
+ description,
89
+ status: 'pending',
90
+ phase: currentPhase,
91
+ complexity: inferComplexity(description),
92
+ });
93
+ stepIndex++;
94
+ continue;
95
+ }
96
+ const bulletMatch = line.match(BULLET_PATTERN);
97
+ if (bulletMatch) {
98
+ const raw = bulletMatch[1].trim();
99
+ if (raw.length <= 3)
100
+ continue; // skip trivially short lines
101
+ // Normalize bold-colon patterns: **Setup**: desc → Setup: desc
102
+ const description = raw.replace(/\*\*(.+?)\*\*:\s*/, '$1: ');
103
+ steps.push({
104
+ id: `step-${stepIndex}`,
105
+ description,
106
+ status: 'pending',
107
+ phase: currentPhase,
108
+ complexity: inferComplexity(description),
109
+ });
110
+ stepIndex++;
111
+ continue;
112
+ }
113
+ }
114
+ return { title, steps };
115
+ }
116
+ /**
117
+ * Extracts <proposed_plan> content from text.
118
+ */
119
+ function extractProposedPlan(text) {
120
+ const match = text.match(/<proposed_plan>([\s\S]*?)<\/proposed_plan>/);
121
+ return match ? match[1].trim() : null;
122
+ }
123
+ /**
124
+ * Extracts full untruncated text from event.raw message content blocks.
125
+ * Falls back to event.summary if raw is unavailable.
126
+ */
127
+ function extractFullTextFromRaw(event) {
128
+ const raw = event.raw;
129
+ const message = raw?.message;
130
+ const content = message?.content;
131
+ if (Array.isArray(content)) {
132
+ const texts = [];
133
+ for (const block of content) {
134
+ const b = block;
135
+ if (b.type === 'text' && typeof b.text === 'string') {
136
+ texts.push(b.text);
137
+ }
138
+ }
139
+ if (texts.length > 0)
140
+ return texts.join('\n');
141
+ }
142
+ // Fall back to summary (may be truncated, but better than nothing)
143
+ return event.summary || null;
144
+ }
145
+ /**
146
+ * Stateful plan extractor for use with FollowEvent streams.
147
+ *
148
+ * Handles all three providers:
149
+ * - Claude Code: EnterPlanMode/ExitPlanMode tool calls
150
+ * - OpenCode: <proposed_plan> blocks in assistant text
151
+ * - Codex: UpdatePlan tool calls
152
+ */
153
+ class PlanExtractor {
154
+ _plan = null;
155
+ _planModeActive = false;
156
+ _planTexts = [];
157
+ _planFileContent = null;
158
+ _planFilePath = null;
159
+ _readFile;
160
+ constructor(readFile) {
161
+ this._readFile = readFile ?? null;
162
+ }
163
+ get plan() {
164
+ return this._plan;
165
+ }
166
+ reset() {
167
+ this._plan = null;
168
+ this._planModeActive = false;
169
+ this._planTexts = [];
170
+ this._planFileContent = null;
171
+ this._planFilePath = null;
172
+ }
173
+ /**
174
+ * Process a FollowEvent and extract plan data if present.
175
+ * Returns true if the plan was updated.
176
+ */
177
+ processEvent(event) {
178
+ // Codex: UpdatePlan tool calls
179
+ if (event.type === 'tool_use' && event.toolName === 'UpdatePlan') {
180
+ return this.extractCodexPlan(event);
181
+ }
182
+ // Claude Code: EnterPlanMode
183
+ if (event.type === 'tool_use' && event.toolName === 'EnterPlanMode') {
184
+ this._planModeActive = true;
185
+ this._planTexts = [];
186
+ this._planFileContent = null;
187
+ this._planFilePath = null;
188
+ return false;
189
+ }
190
+ // Claude Code: ExitPlanMode
191
+ if (event.type === 'tool_use' && event.toolName === 'ExitPlanMode') {
192
+ return this.finalizePlanMode();
193
+ }
194
+ // Capture Write/Edit tool calls to plan files during plan mode
195
+ if (this._planModeActive && event.type === 'tool_use') {
196
+ const raw = event.raw;
197
+ const input = raw?.input;
198
+ const filePath = input?.file_path;
199
+ if (event.toolName === 'Write') {
200
+ const content = input?.content;
201
+ if (filePath && content && filePath.includes('.claude/plans/')) {
202
+ this._planFileContent = content;
203
+ this._planFilePath = filePath;
204
+ }
205
+ }
206
+ else if (event.toolName === 'Edit') {
207
+ // Edit contains a diff, not full content — capture path for disk-read fallback
208
+ if (filePath && filePath.includes('.claude/plans/')) {
209
+ this._planFilePath = filePath;
210
+ }
211
+ }
212
+ }
213
+ // Accumulate text during plan mode (Claude Code)
214
+ // Extract full text from event.raw to avoid truncated summaries
215
+ if (this._planModeActive && event.type === 'assistant') {
216
+ const fullText = extractFullTextFromRaw(event);
217
+ if (fullText) {
218
+ this._planTexts.push(fullText);
219
+ }
220
+ }
221
+ // OpenCode / Codex: <proposed_plan> blocks
222
+ // Check raw message content first (untruncated), then fall back to summary
223
+ if (event.type === 'assistant') {
224
+ const raw = event.raw;
225
+ const message = raw?.message;
226
+ const content = message?.content;
227
+ if (Array.isArray(content)) {
228
+ for (const block of content) {
229
+ const b = block;
230
+ if (b.type === 'text' && typeof b.text === 'string') {
231
+ const planText = extractProposedPlan(b.text);
232
+ if (planText) {
233
+ return this.extractFromMarkdown(planText, this.getSource(event));
234
+ }
235
+ }
236
+ }
237
+ }
238
+ // Fall back to summary (may work for short plans)
239
+ if (event.summary) {
240
+ const proposed = extractProposedPlan(event.summary);
241
+ if (proposed) {
242
+ return this.extractFromMarkdown(proposed, this.getSource(event));
243
+ }
244
+ }
245
+ }
246
+ return false;
247
+ }
248
+ getSource(event) {
249
+ if (event.providerId === 'opencode')
250
+ return 'opencode';
251
+ if (event.providerId === 'codex')
252
+ return 'codex';
253
+ return 'claude-code';
254
+ }
255
+ extractCodexPlan(event) {
256
+ const raw = event.raw;
257
+ if (!raw?.input)
258
+ return false;
259
+ const input = raw.input;
260
+ // Codex UpdatePlan can use either approach[] or plan[] format
261
+ const approach = (input.approach ?? input.plan);
262
+ if (!approach || !Array.isArray(approach))
263
+ return false;
264
+ const steps = [];
265
+ for (let i = 0; i < approach.length; i++) {
266
+ const entry = approach[i];
267
+ if (typeof entry === 'string') {
268
+ steps.push({
269
+ id: `step-${i}`,
270
+ description: entry,
271
+ status: 'pending',
272
+ complexity: inferComplexity(entry),
273
+ });
274
+ }
275
+ else if (typeof entry === 'object' && entry !== null) {
276
+ const obj = entry;
277
+ const desc = String(obj.step || obj.description || '').trim();
278
+ if (!desc)
279
+ continue;
280
+ const rawStatus = String(obj.status || 'pending').toLowerCase();
281
+ let status;
282
+ if (rawStatus === 'completed')
283
+ status = 'completed';
284
+ else if (rawStatus === 'in_progress' || rawStatus === 'in-progress')
285
+ status = 'in_progress';
286
+ else
287
+ status = 'pending';
288
+ steps.push({
289
+ id: `step-${i}`,
290
+ description: desc,
291
+ status,
292
+ complexity: inferComplexity(desc),
293
+ });
294
+ }
295
+ }
296
+ if (steps.length > 0) {
297
+ this._plan = {
298
+ title: input.title || 'Plan',
299
+ steps,
300
+ source: 'codex',
301
+ };
302
+ return true;
303
+ }
304
+ return false;
305
+ }
306
+ finalizePlanMode() {
307
+ this._planModeActive = false;
308
+ // Prefer plan file content (from Write tool) → accumulated assistant text → disk read fallback
309
+ const markdown = this._planFileContent
310
+ || (this._planTexts.length > 0 ? this._planTexts.join('\n') : null)
311
+ || (this._planFilePath && this._readFile ? this._readFile(this._planFilePath) : null);
312
+ this._planFileContent = null;
313
+ this._planFilePath = null;
314
+ this._planTexts = [];
315
+ if (!markdown)
316
+ return false;
317
+ return this.extractFromMarkdown(markdown, 'claude-code');
318
+ }
319
+ extractFromMarkdown(markdown, source) {
320
+ const parsed = parsePlanMarkdown(markdown);
321
+ this._plan = {
322
+ title: parsed.title || 'Plan',
323
+ steps: parsed.steps,
324
+ source,
325
+ rawMarkdown: markdown,
326
+ };
327
+ return true;
328
+ }
329
+ }
330
+ exports.PlanExtractor = PlanExtractor;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Content-based session activity detection.
3
+ *
4
+ * Uses heuristics on the tail of a JSONL file to determine if a session
5
+ * is genuinely ongoing (AI thinking/calling tools), ended (final output
6
+ * delivered), or stale (no updates for >5 minutes).
7
+ *
8
+ * Inspired by tail-claude's ending-event classification and grace periods.
9
+ *
10
+ * @module parsers/sessionActivityDetector
11
+ */
12
+ export type SessionActivityState = 'ongoing' | 'ended' | 'stale';
13
+ export interface SessionActivityResult {
14
+ state: SessionActivityState;
15
+ /** Timestamp of the last meaningful event */
16
+ lastActivityTime: Date | null;
17
+ /** Reason for the classification */
18
+ reason: string;
19
+ }
20
+ /**
21
+ * Determines whether a session is ongoing, ended, or stale.
22
+ *
23
+ * Uses a multi-signal approach:
24
+ * 1. File mtime staleness check (>5min → stale)
25
+ * 2. Content-based: reads last ~32KB and classifies the ending pattern
26
+ * 3. Grace period: prevents flicker by requiring 5s of inactivity before "ended"
27
+ *
28
+ * @param sessionPath - Path to the JSONL session file
29
+ * @returns Activity state with classification reason
30
+ */
31
+ export declare function detectSessionActivity(sessionPath: string): SessionActivityResult;
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ /**
3
+ * Content-based session activity detection.
4
+ *
5
+ * Uses heuristics on the tail of a JSONL file to determine if a session
6
+ * is genuinely ongoing (AI thinking/calling tools), ended (final output
7
+ * delivered), or stale (no updates for >5 minutes).
8
+ *
9
+ * Inspired by tail-claude's ending-event classification and grace periods.
10
+ *
11
+ * @module parsers/sessionActivityDetector
12
+ */
13
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ var desc = Object.getOwnPropertyDescriptor(m, k);
16
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
+ desc = { enumerable: true, get: function() { return m[k]; } };
18
+ }
19
+ Object.defineProperty(o, k2, desc);
20
+ }) : (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ o[k2] = m[k];
23
+ }));
24
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
26
+ }) : function(o, v) {
27
+ o["default"] = v;
28
+ });
29
+ var __importStar = (this && this.__importStar) || (function () {
30
+ var ownKeys = function(o) {
31
+ ownKeys = Object.getOwnPropertyNames || function (o) {
32
+ var ar = [];
33
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
34
+ return ar;
35
+ };
36
+ return ownKeys(o);
37
+ };
38
+ return function (mod) {
39
+ if (mod && mod.__esModule) return mod;
40
+ var result = {};
41
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
42
+ __setModuleDefault(result, mod);
43
+ return result;
44
+ };
45
+ })();
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.detectSessionActivity = detectSessionActivity;
48
+ const fs = __importStar(require("fs"));
49
+ // ── Constants ──
50
+ /** How many bytes to read from the end of the file for analysis */
51
+ const TAIL_BYTES = 32 * 1024;
52
+ /** Grace period to prevent spinner flicker (ms) */
53
+ const GRACE_PERIOD_MS = 5_000;
54
+ /** If mtime is older than this, session is stale regardless of content */
55
+ const STALENESS_THRESHOLD_MS = 5 * 60 * 1000;
56
+ // ── Event Classification Patterns ──
57
+ /** Patterns indicating the AI is actively working */
58
+ const AI_ACTIVITY_PATTERNS = [
59
+ '"type":"assistant"',
60
+ '"type":"tool_use"',
61
+ '"type":"tool_result"',
62
+ '"stop_reason":"tool_use"',
63
+ ];
64
+ /** Patterns indicating the session has ended (final output delivered) */
65
+ const ENDING_PATTERNS = [
66
+ '"stop_reason":"end_turn"',
67
+ '"type":"result"',
68
+ '"type":"user"', // User typing means AI is done with its turn
69
+ ];
70
+ /** Patterns that definitely mean the session is done */
71
+ const TERMINAL_PATTERNS = [
72
+ '"type":"result"',
73
+ ];
74
+ // ── Public API ──
75
+ /**
76
+ * Determines whether a session is ongoing, ended, or stale.
77
+ *
78
+ * Uses a multi-signal approach:
79
+ * 1. File mtime staleness check (>5min → stale)
80
+ * 2. Content-based: reads last ~32KB and classifies the ending pattern
81
+ * 3. Grace period: prevents flicker by requiring 5s of inactivity before "ended"
82
+ *
83
+ * @param sessionPath - Path to the JSONL session file
84
+ * @returns Activity state with classification reason
85
+ */
86
+ function detectSessionActivity(sessionPath) {
87
+ let stat;
88
+ try {
89
+ stat = fs.statSync(sessionPath);
90
+ }
91
+ catch {
92
+ return { state: 'ended', lastActivityTime: null, reason: 'file-not-found' };
93
+ }
94
+ const now = Date.now();
95
+ const mtimeAge = now - stat.mtimeMs;
96
+ // Staleness check: if file hasn't been touched in >5 minutes, it's stale
97
+ if (mtimeAge > STALENESS_THRESHOLD_MS) {
98
+ return { state: 'stale', lastActivityTime: new Date(stat.mtimeMs), reason: 'mtime-stale' };
99
+ }
100
+ // Read the tail of the file for content analysis
101
+ const tail = readTail(sessionPath, stat.size);
102
+ if (!tail) {
103
+ return { state: 'ended', lastActivityTime: new Date(stat.mtimeMs), reason: 'empty-file' };
104
+ }
105
+ // Parse the last few JSONL lines
106
+ const lines = tail.split('\n').filter(l => l.trim().length > 0);
107
+ if (lines.length === 0) {
108
+ return { state: 'ended', lastActivityTime: new Date(stat.mtimeMs), reason: 'no-events' };
109
+ }
110
+ // Find the last line that matches each category
111
+ let lastAiActivityIndex = -1;
112
+ let lastEndingIndex = -1;
113
+ let hasTerminal = false;
114
+ for (let i = 0; i < lines.length; i++) {
115
+ const line = lines[i];
116
+ for (const pattern of AI_ACTIVITY_PATTERNS) {
117
+ if (line.includes(pattern)) {
118
+ lastAiActivityIndex = i;
119
+ break;
120
+ }
121
+ }
122
+ for (const pattern of TERMINAL_PATTERNS) {
123
+ if (line.includes(pattern)) {
124
+ hasTerminal = true;
125
+ lastEndingIndex = i;
126
+ break;
127
+ }
128
+ }
129
+ if (!hasTerminal) {
130
+ for (const pattern of ENDING_PATTERNS) {
131
+ if (line.includes(pattern)) {
132
+ lastEndingIndex = i;
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ }
138
+ // Terminal patterns mean the session is definitely done
139
+ if (hasTerminal) {
140
+ return { state: 'ended', lastActivityTime: new Date(stat.mtimeMs), reason: 'terminal-event' };
141
+ }
142
+ // AI activity after the last ending event → ongoing
143
+ if (lastAiActivityIndex > lastEndingIndex) {
144
+ return { state: 'ongoing', lastActivityTime: new Date(stat.mtimeMs), reason: 'ai-activity-after-ending' };
145
+ }
146
+ // Ending event with no subsequent AI activity
147
+ if (lastEndingIndex >= 0) {
148
+ // Apply grace period to prevent flicker
149
+ if (mtimeAge < GRACE_PERIOD_MS) {
150
+ return { state: 'ongoing', lastActivityTime: new Date(stat.mtimeMs), reason: 'grace-period' };
151
+ }
152
+ return { state: 'ended', lastActivityTime: new Date(stat.mtimeMs), reason: 'ending-event' };
153
+ }
154
+ // No clear signal — if recently modified, assume ongoing
155
+ if (mtimeAge < GRACE_PERIOD_MS) {
156
+ return { state: 'ongoing', lastActivityTime: new Date(stat.mtimeMs), reason: 'recent-mtime' };
157
+ }
158
+ return { state: 'ended', lastActivityTime: new Date(stat.mtimeMs), reason: 'no-activity-signal' };
159
+ }
160
+ // ── Helpers ──
161
+ function readTail(filePath, fileSize) {
162
+ const bytesToRead = Math.min(TAIL_BYTES, fileSize);
163
+ if (bytesToRead <= 0)
164
+ return null;
165
+ let fd = null;
166
+ try {
167
+ fd = fs.openSync(filePath, 'r');
168
+ const buffer = Buffer.alloc(bytesToRead);
169
+ const offset = Math.max(0, fileSize - bytesToRead);
170
+ const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, offset);
171
+ fs.closeSync(fd);
172
+ fd = null;
173
+ return buffer.toString('utf-8', 0, bytesRead);
174
+ }
175
+ catch {
176
+ if (fd !== null) {
177
+ try {
178
+ fs.closeSync(fd);
179
+ }
180
+ catch { /* ignore */ }
181
+ }
182
+ return null;
183
+ }
184
+ }