sidekick-shared 0.27.6 → 0.28.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 (269) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +58 -18
  3. package/dist/browser.d.ts +13 -2
  4. package/dist/browser.js +30 -2
  5. package/dist/claudePaths.d.ts +11 -0
  6. package/dist/claudePaths.js +61 -0
  7. package/dist/claudeProfiles.d.ts +2 -9
  8. package/dist/claudeProfiles.js +10 -25
  9. package/dist/codexPaths.d.ts +22 -0
  10. package/dist/codexPaths.js +67 -0
  11. package/dist/codexProfiles.d.ts +1 -4
  12. package/dist/codexProfiles.js +5 -43
  13. package/dist/doctor.js +2 -2
  14. package/dist/esm/accountChangeSignal.js +15 -0
  15. package/dist/esm/accountChanges.js +152 -0
  16. package/dist/esm/accountHealth.js +288 -0
  17. package/dist/esm/accountLaunch.js +145 -0
  18. package/dist/esm/accountLease.js +60 -0
  19. package/dist/esm/accountManager.js +606 -0
  20. package/dist/esm/accountRegistry.js +214 -0
  21. package/dist/esm/accountStatus.js +51 -0
  22. package/dist/esm/accountSwitch.js +74 -0
  23. package/dist/esm/accountSync.js +156 -0
  24. package/dist/esm/accountSyncTypes.js +4 -0
  25. package/dist/esm/accounts.js +1291 -0
  26. package/dist/esm/aggregation/EventAggregator.js +2034 -0
  27. package/dist/esm/aggregation/FrequencyTracker.js +69 -0
  28. package/dist/esm/aggregation/HeatmapTracker.js +89 -0
  29. package/dist/esm/aggregation/PatternExtractor.js +168 -0
  30. package/dist/esm/aggregation/costProvenance.js +31 -0
  31. package/dist/esm/aggregation/snapshot.js +161 -0
  32. package/dist/esm/aggregation/types.js +5 -0
  33. package/dist/esm/analytics/codeImpact.js +18 -0
  34. package/dist/esm/analytics/compactionLedger.js +37 -0
  35. package/dist/esm/analytics/qualityScore.js +112 -0
  36. package/dist/esm/autoSwitch.js +185 -0
  37. package/dist/esm/browser.js +34 -0
  38. package/dist/esm/claudeCredentialDoctor.js +239 -0
  39. package/dist/esm/claudeCredentialLedger.js +91 -0
  40. package/dist/esm/claudeCredentials.js +111 -0
  41. package/dist/esm/claudeLiveGuard.js +43 -0
  42. package/dist/esm/claudePaths.js +22 -0
  43. package/dist/esm/claudeProfiles.js +142 -0
  44. package/dist/esm/claudeRefreshLock.js +106 -0
  45. package/dist/esm/codexAuth.js +101 -0
  46. package/dist/esm/codexCredentialDoctor.js +265 -0
  47. package/dist/esm/codexCredentialStore.js +164 -0
  48. package/dist/esm/codexKeepAlive.js +112 -0
  49. package/dist/esm/codexPaths.js +172 -0
  50. package/dist/esm/codexProfiles.js +1251 -0
  51. package/dist/esm/codexQuota.js +564 -0
  52. package/dist/esm/codexQuotaWatcher.js +363 -0
  53. package/dist/esm/context/composer.js +71 -0
  54. package/dist/esm/context/sessionContext.js +546 -0
  55. package/dist/esm/credentialIO.js +283 -0
  56. package/dist/esm/credentials.js +49 -0
  57. package/dist/esm/doctor.js +305 -0
  58. package/dist/esm/ensureDefaultAccounts.js +62 -0
  59. package/dist/esm/errorHistory.js +56 -0
  60. package/dist/esm/extractors/errorTaxonomy.js +43 -0
  61. package/dist/esm/extractors/gatherAssets.js +32 -0
  62. package/dist/esm/extractors/sessionAssets.js +176 -0
  63. package/dist/esm/extractors/sources/claudeAssets.js +158 -0
  64. package/dist/esm/extractors/sources/codexAssets.js +234 -0
  65. package/dist/esm/extractors/tokenUsage.js +41 -0
  66. package/dist/esm/extractors/toolCall.js +142 -0
  67. package/dist/esm/failingTools.js +65 -0
  68. package/dist/esm/fileHead.js +27 -0
  69. package/dist/esm/formatters/eventHighlighter.js +332 -0
  70. package/dist/esm/formatters/noiseClassifier.js +209 -0
  71. package/dist/esm/formatters/sessionDump.js +328 -0
  72. package/dist/esm/formatters/toolSummary.js +226 -0
  73. package/dist/esm/formatting.js +82 -0
  74. package/dist/esm/handoffUrl.js +23 -0
  75. package/dist/esm/historicalStore.js +305 -0
  76. package/dist/esm/humanPrompt.js +88 -0
  77. package/dist/esm/index.js +213 -0
  78. package/dist/esm/keepAlive.js +189 -0
  79. package/dist/esm/liveCredentialGuard.js +33 -0
  80. package/dist/esm/modelAliases.js +51 -0
  81. package/dist/esm/modelCatalog.js +139 -0
  82. package/dist/esm/modelContext.js +280 -0
  83. package/dist/esm/modelInfo.js +832 -0
  84. package/dist/esm/multiProviderQuotaService.js +450 -0
  85. package/dist/esm/node.js +46 -0
  86. package/dist/esm/observedContextWindows.js +165 -0
  87. package/dist/esm/observedSessionCollector.js +939 -0
  88. package/dist/esm/parsers/changelogParser.js +71 -0
  89. package/dist/esm/parsers/codexHistory.js +89 -0
  90. package/dist/esm/parsers/codexParser.js +906 -0
  91. package/dist/esm/parsers/debugLogParser.js +126 -0
  92. package/dist/esm/parsers/jsonl.js +66 -0
  93. package/dist/esm/parsers/mcpToolName.js +28 -0
  94. package/dist/esm/parsers/openCodeParser.js +625 -0
  95. package/dist/esm/parsers/planExtractor.js +335 -0
  96. package/dist/esm/parsers/sessionActivityDetector.js +246 -0
  97. package/dist/esm/parsers/sessionPathResolver.js +713 -0
  98. package/dist/esm/parsers/subagentScanner.js +360 -0
  99. package/dist/esm/parsers/subagentStatsCache.js +45 -0
  100. package/dist/esm/parsers/subagentTraceParser.js +325 -0
  101. package/dist/esm/paths.js +134 -0
  102. package/dist/esm/peakHours.js +194 -0
  103. package/dist/esm/phrases.js +1330 -0
  104. package/dist/esm/pricingCatalog.js +247 -0
  105. package/dist/esm/processDetection.js +200 -0
  106. package/dist/esm/projectMigration.js +59 -0
  107. package/dist/esm/promptHistory.js +1221 -0
  108. package/dist/esm/providerFailure.js +362 -0
  109. package/dist/esm/providerIds.js +6 -0
  110. package/dist/esm/providerQuota.js +1 -0
  111. package/dist/esm/providerServiceStatus.js +145 -0
  112. package/dist/esm/providerServiceStatusTypes.js +1 -0
  113. package/dist/esm/providerStatus.js +117 -0
  114. package/dist/esm/providers/claudeCode.js +575 -0
  115. package/dist/esm/providers/claudeListing.js +121 -0
  116. package/dist/esm/providers/codex.js +1034 -0
  117. package/dist/esm/providers/codexDatabase.js +297 -0
  118. package/dist/esm/providers/codexRollouts.js +215 -0
  119. package/dist/esm/providers/detect.js +185 -0
  120. package/dist/esm/providers/diagnostics.js +83 -0
  121. package/dist/esm/providers/directoryListingCache.js +206 -0
  122. package/dist/esm/providers/factory.js +54 -0
  123. package/dist/esm/providers/openCode.js +2008 -0
  124. package/dist/esm/providers/openCodeDatabase.js +419 -0
  125. package/dist/esm/providers/rolloutWalker.js +252 -0
  126. package/dist/esm/providers/types.js +12 -0
  127. package/dist/esm/quota.js +182 -0
  128. package/dist/esm/quotaHistory.js +364 -0
  129. package/dist/esm/quotaPoller.js +230 -0
  130. package/dist/esm/quotaPresentation.js +97 -0
  131. package/dist/esm/quotaResolver.js +231 -0
  132. package/dist/esm/quotaSnapshots.js +130 -0
  133. package/dist/esm/quotaThresholds.js +85 -0
  134. package/dist/esm/readers/decisions.js +24 -0
  135. package/dist/esm/readers/handoff.js +20 -0
  136. package/dist/esm/readers/helpers.js +27 -0
  137. package/dist/esm/readers/history.js +9 -0
  138. package/dist/esm/readers/notes.js +48 -0
  139. package/dist/esm/readers/plans.js +225 -0
  140. package/dist/esm/readers/tasks.js +19 -0
  141. package/dist/esm/report/htmlHelpers.js +280 -0
  142. package/dist/esm/report/htmlReportGenerator.js +704 -0
  143. package/dist/esm/report/index.js +7 -0
  144. package/dist/esm/report/logo.js +2 -0
  145. package/dist/esm/report/openBrowser.js +19 -0
  146. package/dist/esm/report/sessionReportInputs.js +33 -0
  147. package/dist/esm/report/transcriptParser.js +83 -0
  148. package/dist/esm/report/types.js +4 -0
  149. package/dist/esm/schemas/accountManager.js +204 -0
  150. package/dist/esm/schemas/accountStatus.js +25 -0
  151. package/dist/esm/schemas/assistantTurn.js +74 -0
  152. package/dist/esm/schemas/index.js +25 -0
  153. package/dist/esm/schemas/observedSessionV1.js +77 -0
  154. package/dist/esm/schemas/providerFailure.js +37 -0
  155. package/dist/esm/schemas/quota.js +132 -0
  156. package/dist/esm/schemas/quotaHistory.js +40 -0
  157. package/dist/esm/schemas/sessionEvent.js +216 -0
  158. package/dist/esm/schemas/stateFile.js +75 -0
  159. package/dist/esm/schemas/tokenEstimation.js +7 -0
  160. package/dist/esm/schemas/transcript.js +86 -0
  161. package/dist/esm/schemas/usageNormalization.js +38 -0
  162. package/dist/esm/search/advancedFilter.js +197 -0
  163. package/dist/esm/search/sessionSearch.js +66 -0
  164. package/dist/esm/sessionFingerprint.js +40 -0
  165. package/dist/esm/sessionMonitor.js +198 -0
  166. package/dist/esm/sessionPreviews.js +522 -0
  167. package/dist/esm/sessionPromptHistory.js +254 -0
  168. package/dist/esm/sessionPromptSignals.js +364 -0
  169. package/dist/esm/sessionStats.js +160 -0
  170. package/dist/esm/sessionTokenTotals.js +98 -0
  171. package/dist/esm/sessionTranscripts.js +49 -0
  172. package/dist/esm/stateFile.js +119 -0
  173. package/dist/esm/statusline/BurnRateCalculator.js +40 -0
  174. package/dist/esm/statusline/claudeStatuslinePayload.js +148 -0
  175. package/dist/esm/statusline/formatter.js +122 -0
  176. package/dist/esm/statusline/index.js +8 -0
  177. package/dist/esm/terminalSync.js +95 -0
  178. package/dist/esm/testing/credentialGuard.js +197 -0
  179. package/dist/esm/testing/index.js +12 -0
  180. package/dist/esm/testing/sessionLogFixtures.js +128 -0
  181. package/dist/esm/tokenEstimation.js +67 -0
  182. package/dist/esm/tokenSummary.js +113 -0
  183. package/dist/esm/toolCategory.js +82 -0
  184. package/dist/esm/transcript.js +257 -0
  185. package/dist/esm/turns/assistantTurn.js +373 -0
  186. package/dist/esm/types/codex.js +23 -0
  187. package/dist/esm/types/decisionLog.js +5 -0
  188. package/dist/esm/types/historicalData.js +47 -0
  189. package/dist/esm/types/knowledgeNote.js +15 -0
  190. package/dist/esm/types/observedSessionV1.js +257 -0
  191. package/dist/esm/types/opencode.js +12 -0
  192. package/dist/esm/types/plan.js +7 -0
  193. package/dist/esm/types/sessionEvent.js +10 -0
  194. package/dist/esm/types/taskPersistence.js +12 -0
  195. package/dist/esm/usage/billingBlocks.js +148 -0
  196. package/dist/esm/usage/claudeUsageDedupe.js +189 -0
  197. package/dist/esm/usage/importSessionHistory.js +84 -0
  198. package/dist/esm/usage/subagentUsage.js +18 -0
  199. package/dist/esm/usage/usageEvents.js +402 -0
  200. package/dist/esm/usage/usageReports.js +202 -0
  201. package/dist/esm/usageNormalization.js +95 -0
  202. package/dist/esm/watchers/eventBridge.js +219 -0
  203. package/dist/esm/watchers/factory.js +63 -0
  204. package/dist/esm/watchers/jsonlTail.js +289 -0
  205. package/dist/esm/watchers/jsonlWatcher.js +435 -0
  206. package/dist/esm/watchers/providerReaderWatcher.js +118 -0
  207. package/dist/esm/watchers/sqliteWatcher.js +285 -0
  208. package/dist/esm/watchers/types.js +4 -0
  209. package/dist/esm/workspaceRoots.js +56 -0
  210. package/dist/esm/writers/atomic.js +375 -0
  211. package/dist/esm/writers/decisions.js +51 -0
  212. package/dist/esm/writers/notes.js +46 -0
  213. package/dist/esm/writers/tasks.js +63 -0
  214. package/dist/esm/zaiQuota.js +323 -0
  215. package/dist/esm/zaiQuotaApi.js +271 -0
  216. package/dist/esm/zaiQuotaWatcher.js +265 -0
  217. package/dist/fileHead.d.ts +22 -0
  218. package/dist/fileHead.js +33 -0
  219. package/dist/index.d.ts +16 -7
  220. package/dist/index.js +49 -15
  221. package/dist/node.d.ts +13 -1
  222. package/dist/node.js +43 -2
  223. package/dist/parsers/codexHistory.js +2 -2
  224. package/dist/parsers/codexParser.d.ts +12 -0
  225. package/dist/parsers/codexParser.js +34 -12
  226. package/dist/parsers/debugLogParser.js +2 -2
  227. package/dist/parsers/sessionPathResolver.js +5 -4
  228. package/dist/promptHistory.d.ts +32 -0
  229. package/dist/promptHistory.js +115 -59
  230. package/dist/providerFailure.d.ts +12 -6
  231. package/dist/providerFailure.js +45 -0
  232. package/dist/providers/claudeCode.js +7 -99
  233. package/dist/providers/claudeListing.d.ts +22 -0
  234. package/dist/providers/claudeListing.js +158 -0
  235. package/dist/providers/codex.d.ts +3 -1
  236. package/dist/providers/codex.js +26 -227
  237. package/dist/providers/codexRollouts.d.ts +44 -0
  238. package/dist/providers/codexRollouts.js +261 -0
  239. package/dist/providers/detect.js +4 -4
  240. package/dist/readers/plans.js +3 -3
  241. package/dist/schemas/index.d.ts +2 -0
  242. package/dist/schemas/index.js +7 -2
  243. package/dist/schemas/providerFailure.d.ts +154 -0
  244. package/dist/schemas/providerFailure.js +40 -0
  245. package/dist/sessionPromptHistory.d.ts +12 -7
  246. package/dist/sessionPromptHistory.js +15 -3
  247. package/dist/sessionPromptSignals.d.ts +9 -0
  248. package/dist/sessionPromptSignals.js +13 -0
  249. package/dist/testing/credentialGuard.d.ts +23 -0
  250. package/dist/testing/credentialGuard.js +233 -0
  251. package/dist/testing/index.d.ts +14 -0
  252. package/dist/testing/index.js +22 -0
  253. package/dist/testing/sessionLogFixtures.d.ts +28 -0
  254. package/dist/testing/sessionLogFixtures.js +169 -0
  255. package/dist/tokenEstimation.d.ts +2 -0
  256. package/dist/tokenEstimation.js +21 -11
  257. package/dist/toolCategory.d.ts +18 -0
  258. package/dist/toolCategory.js +86 -0
  259. package/dist/types/codex.d.ts +4 -0
  260. package/dist/usage/usageEvents.d.ts +16 -0
  261. package/dist/usage/usageEvents.js +53 -9
  262. package/dist/watchers/jsonlTail.d.ts +26 -1
  263. package/dist/watchers/jsonlTail.js +136 -28
  264. package/dist/watchers/jsonlWatcher.js +7 -0
  265. package/dist/watchers/providerReaderWatcher.js +1 -0
  266. package/dist/watchers/types.d.ts +6 -0
  267. package/dist/workspaceRoots.d.ts +23 -0
  268. package/dist/workspaceRoots.js +93 -0
  269. package/package.json +22 -2
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Shared plan extraction from session events.
3
+ *
4
+ * Handles all three providers:
5
+ * - Claude Code: EnterPlanMode/ExitPlanMode tool calls with accumulated markdown
6
+ * - OpenCode: <proposed_plan> XML blocks in assistant messages
7
+ * - Codex: UpdatePlan tool calls with structured approach arrays
8
+ */
9
+ /** Checkbox pattern: `- [ ] text` or `- [x] text` */
10
+ const CHECKBOX_PATTERN = /^[-*]\s+\[([ xX])\]\s+(.+)/;
11
+ /** Numbered list pattern: `1. text` or `1) text` */
12
+ const NUMBERED_PATTERN = /^\d+[.)]\s+(.+)/;
13
+ /** Bullet point pattern: `- Step` or `* Step` (excluding checkboxes) */
14
+ const BULLET_PATTERN = /^[-*]\s+(?!\[[ xX]\])(.+)/;
15
+ /** Phase header pattern: `## Phase 1: Setup` */
16
+ const PHASE_HEADER_PATTERN = /^#{2,4}\s+(?:Phase|Step|Stage)\s*\d*[:.]\s*(.+)/i;
17
+ /** Generic H1/H2 header for title extraction */
18
+ const TITLE_HEADER_PATTERN = /^#{1,2}\s+(.+)/;
19
+ /** Complexity keywords */
20
+ const EXPLICIT_COMPLEXITY_PATTERN = /\[(high|medium|low)\]|\((complex|simple)\)/i;
21
+ const HIGH_COMPLEXITY_KEYWORDS = /\b(refactor|migrate|rewrite|redesign|overhaul|rearchitect)\b/i;
22
+ const LOW_COMPLEXITY_KEYWORDS = /\b(update|fix|tweak|rename|adjust|bump|typo)\b/i;
23
+ function inferComplexity(description) {
24
+ const explicitMatch = description.match(EXPLICIT_COMPLEXITY_PATTERN);
25
+ if (explicitMatch) {
26
+ const marker = (explicitMatch[1] || explicitMatch[2]).toLowerCase();
27
+ if (marker === 'high' || marker === 'complex')
28
+ return 'high';
29
+ if (marker === 'low' || marker === 'simple')
30
+ return 'low';
31
+ return 'medium';
32
+ }
33
+ if (HIGH_COMPLEXITY_KEYWORDS.test(description))
34
+ return 'high';
35
+ if (LOW_COMPLEXITY_KEYWORDS.test(description))
36
+ return 'low';
37
+ return undefined;
38
+ }
39
+ /**
40
+ * Parses plan markdown into structured plan steps.
41
+ */
42
+ export function parsePlanMarkdown(markdown) {
43
+ if (!markdown || !markdown.trim())
44
+ return { steps: [] };
45
+ const lines = markdown.split('\n');
46
+ const steps = [];
47
+ let title;
48
+ let currentPhase;
49
+ let stepIndex = 0;
50
+ for (const rawLine of lines) {
51
+ const line = rawLine.trim();
52
+ if (!line)
53
+ continue;
54
+ if (!title) {
55
+ const titleMatch = line.match(TITLE_HEADER_PATTERN);
56
+ if (titleMatch)
57
+ title = titleMatch[1].trim();
58
+ }
59
+ const phaseMatch = line.match(PHASE_HEADER_PATTERN);
60
+ if (phaseMatch) {
61
+ currentPhase = phaseMatch[1].trim();
62
+ continue;
63
+ }
64
+ const checkboxMatch = line.match(CHECKBOX_PATTERN);
65
+ if (checkboxMatch) {
66
+ const checked = checkboxMatch[1].toLowerCase() === 'x';
67
+ const description = checkboxMatch[2].trim();
68
+ steps.push({
69
+ id: `step-${stepIndex}`,
70
+ description,
71
+ status: checked ? 'completed' : 'pending',
72
+ phase: currentPhase,
73
+ complexity: inferComplexity(description),
74
+ });
75
+ stepIndex++;
76
+ continue;
77
+ }
78
+ const numberedMatch = line.match(NUMBERED_PATTERN);
79
+ if (numberedMatch) {
80
+ const description = numberedMatch[1].trim();
81
+ steps.push({
82
+ id: `step-${stepIndex}`,
83
+ description,
84
+ status: 'pending',
85
+ phase: currentPhase,
86
+ complexity: inferComplexity(description),
87
+ });
88
+ stepIndex++;
89
+ continue;
90
+ }
91
+ const bulletMatch = line.match(BULLET_PATTERN);
92
+ if (bulletMatch) {
93
+ const raw = bulletMatch[1].trim();
94
+ if (raw.length <= 3)
95
+ continue; // skip trivially short lines
96
+ // Normalize bold-colon patterns: **Setup**: desc → Setup: desc
97
+ const description = raw.replace(/\*\*(.+?)\*\*:\s*/, '$1: ');
98
+ steps.push({
99
+ id: `step-${stepIndex}`,
100
+ description,
101
+ status: 'pending',
102
+ phase: currentPhase,
103
+ complexity: inferComplexity(description),
104
+ });
105
+ stepIndex++;
106
+ continue;
107
+ }
108
+ }
109
+ return { title, steps };
110
+ }
111
+ /**
112
+ * Extracts <proposed_plan> content from text.
113
+ */
114
+ export function extractProposedPlan(text) {
115
+ const match = text.match(/<proposed_plan>([\s\S]*?)<\/proposed_plan>/);
116
+ return match ? match[1].trim() : null;
117
+ }
118
+ /**
119
+ * Extracts full untruncated text from event.raw message content blocks.
120
+ * Falls back to event.summary if raw is unavailable.
121
+ */
122
+ function extractFullTextFromRaw(event) {
123
+ const raw = event.raw;
124
+ const message = raw?.message;
125
+ const content = message?.content;
126
+ if (Array.isArray(content)) {
127
+ const texts = [];
128
+ for (const block of content) {
129
+ const b = block;
130
+ if (b.type === 'text' && typeof b.text === 'string') {
131
+ texts.push(b.text);
132
+ }
133
+ }
134
+ if (texts.length > 0)
135
+ return texts.join('\n');
136
+ }
137
+ // Fall back to summary (may be truncated, but better than nothing)
138
+ return event.summary || null;
139
+ }
140
+ /**
141
+ * Stateful plan extractor for use with FollowEvent streams.
142
+ *
143
+ * Handles all three providers:
144
+ * - Claude Code: EnterPlanMode/ExitPlanMode tool calls
145
+ * - OpenCode: <proposed_plan> blocks in assistant text
146
+ * - Codex: UpdatePlan tool calls
147
+ */
148
+ export class PlanExtractor {
149
+ _plan = null;
150
+ _planModeActive = false;
151
+ _planTexts = [];
152
+ _planFileContent = null;
153
+ _planFilePath = null;
154
+ _readFile;
155
+ constructor(readFile) {
156
+ this._readFile = readFile ?? null;
157
+ }
158
+ get plan() {
159
+ return this._plan;
160
+ }
161
+ reset() {
162
+ this._plan = null;
163
+ this._planModeActive = false;
164
+ this._planTexts = [];
165
+ this._planFileContent = null;
166
+ this._planFilePath = null;
167
+ }
168
+ /**
169
+ * Process a FollowEvent and extract plan data if present.
170
+ * Returns true if the plan was updated.
171
+ */
172
+ processEvent(event) {
173
+ // Codex: UpdatePlan tool calls
174
+ if (event.type === 'tool_use' && event.toolName === 'UpdatePlan') {
175
+ return this.extractCodexPlan(event);
176
+ }
177
+ // Claude Code: EnterPlanMode
178
+ if (event.type === 'tool_use' && event.toolName === 'EnterPlanMode') {
179
+ this._planModeActive = true;
180
+ this._planTexts = [];
181
+ this._planFileContent = null;
182
+ this._planFilePath = null;
183
+ return false;
184
+ }
185
+ // Claude Code: ExitPlanMode
186
+ if (event.type === 'tool_use' && event.toolName === 'ExitPlanMode') {
187
+ return this.finalizePlanMode();
188
+ }
189
+ // Capture Write/Edit tool calls to plan files during plan mode
190
+ if (this._planModeActive && event.type === 'tool_use') {
191
+ const raw = event.raw;
192
+ const input = extractToolInput(raw);
193
+ const filePath = input?.file_path;
194
+ if (event.toolName === 'Write') {
195
+ const content = input?.content;
196
+ if (filePath && content && filePath.includes('.claude/plans/')) {
197
+ this._planFileContent = content;
198
+ this._planFilePath = filePath;
199
+ }
200
+ }
201
+ else if (event.toolName === 'Edit') {
202
+ // Edit contains a diff, not full content — capture path for disk-read fallback
203
+ if (filePath && filePath.includes('.claude/plans/')) {
204
+ this._planFilePath = filePath;
205
+ }
206
+ }
207
+ }
208
+ // Accumulate text during plan mode (Claude Code)
209
+ // Extract full text from event.raw to avoid truncated summaries
210
+ if (this._planModeActive && event.type === 'assistant') {
211
+ const fullText = extractFullTextFromRaw(event);
212
+ if (fullText) {
213
+ this._planTexts.push(fullText);
214
+ }
215
+ }
216
+ // OpenCode / Codex: <proposed_plan> blocks
217
+ // Check raw message content first (untruncated), then fall back to summary
218
+ if (event.type === 'assistant') {
219
+ const raw = event.raw;
220
+ const message = raw?.message;
221
+ const content = message?.content;
222
+ if (Array.isArray(content)) {
223
+ for (const block of content) {
224
+ const b = block;
225
+ if (b.type === 'text' && typeof b.text === 'string') {
226
+ const planText = extractProposedPlan(b.text);
227
+ if (planText) {
228
+ return this.extractFromMarkdown(planText, this.getSource(event));
229
+ }
230
+ }
231
+ }
232
+ }
233
+ // Fall back to summary (may work for short plans)
234
+ if (event.summary) {
235
+ const proposed = extractProposedPlan(event.summary);
236
+ if (proposed) {
237
+ return this.extractFromMarkdown(proposed, this.getSource(event));
238
+ }
239
+ }
240
+ }
241
+ return false;
242
+ }
243
+ getSource(event) {
244
+ if (event.providerId === 'opencode')
245
+ return 'opencode';
246
+ if (event.providerId === 'codex')
247
+ return 'codex';
248
+ return 'claude-code';
249
+ }
250
+ extractCodexPlan(event) {
251
+ const raw = event.raw;
252
+ const input = extractToolInput(raw);
253
+ if (!input)
254
+ return false;
255
+ // Codex UpdatePlan can use either approach[] or plan[] format
256
+ const approach = (input.approach ?? input.plan);
257
+ if (!approach || !Array.isArray(approach))
258
+ return false;
259
+ const steps = [];
260
+ for (let i = 0; i < approach.length; i++) {
261
+ const entry = approach[i];
262
+ if (typeof entry === 'string') {
263
+ steps.push({
264
+ id: `step-${i}`,
265
+ description: entry,
266
+ status: 'pending',
267
+ complexity: inferComplexity(entry),
268
+ });
269
+ }
270
+ else if (typeof entry === 'object' && entry !== null) {
271
+ const obj = entry;
272
+ const desc = String(obj.step || obj.description || '').trim();
273
+ if (!desc)
274
+ continue;
275
+ const rawStatus = String(obj.status || 'pending').toLowerCase();
276
+ let status;
277
+ if (rawStatus === 'completed')
278
+ status = 'completed';
279
+ else if (rawStatus === 'in_progress' || rawStatus === 'in-progress')
280
+ status = 'in_progress';
281
+ else
282
+ status = 'pending';
283
+ steps.push({
284
+ id: `step-${i}`,
285
+ description: desc,
286
+ status,
287
+ complexity: inferComplexity(desc),
288
+ });
289
+ }
290
+ }
291
+ if (steps.length > 0) {
292
+ this._plan = {
293
+ title: input.title || 'Plan',
294
+ steps,
295
+ source: 'codex',
296
+ };
297
+ return true;
298
+ }
299
+ return false;
300
+ }
301
+ finalizePlanMode() {
302
+ this._planModeActive = false;
303
+ // Prefer captured Write content, then the final file after Edit, then prose fallback.
304
+ // Edit only contains a diff, so reading the file must happen before accumulated text.
305
+ const markdown = this._planFileContent ||
306
+ (this._planFilePath && this._readFile ? this._readFile(this._planFilePath) : null) ||
307
+ (this._planTexts.length > 0 ? this._planTexts.join('\n') : null);
308
+ this._planFileContent = null;
309
+ this._planFilePath = null;
310
+ this._planTexts = [];
311
+ if (!markdown)
312
+ return false;
313
+ return this.extractFromMarkdown(markdown, 'claude-code');
314
+ }
315
+ extractFromMarkdown(markdown, source) {
316
+ const parsed = parsePlanMarkdown(markdown);
317
+ this._plan = {
318
+ title: parsed.title || 'Plan',
319
+ steps: parsed.steps,
320
+ source,
321
+ rawMarkdown: markdown,
322
+ };
323
+ return true;
324
+ }
325
+ }
326
+ function extractToolInput(raw) {
327
+ if (!raw)
328
+ return null;
329
+ if (raw.input && typeof raw.input === 'object')
330
+ return raw.input;
331
+ const tool = raw.tool;
332
+ if (tool?.input && typeof tool.input === 'object')
333
+ return tool.input;
334
+ return null;
335
+ }
@@ -0,0 +1,246 @@
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
+ import * as fs from 'fs';
13
+ // ── Constants ──
14
+ /** How many bytes to read from the end of the file for analysis */
15
+ const TAIL_BYTES = 32 * 1024;
16
+ /** Grace period to prevent spinner flicker (ms) */
17
+ export const SESSION_ACTIVITY_GRACE_PERIOD_MS = 5_000;
18
+ const GRACE_PERIOD_MS = SESSION_ACTIVITY_GRACE_PERIOD_MS;
19
+ /** If mtime is older than this, session is stale regardless of content */
20
+ export const SESSION_ACTIVITY_STALENESS_MS = 5 * 60 * 1000;
21
+ const STALENESS_THRESHOLD_MS = SESSION_ACTIVITY_STALENESS_MS;
22
+ /** How many trailing events the in-memory classifier inspects (about a 32 KiB tail). */
23
+ const TAIL_EVENTS = 200;
24
+ // ── Event Classification Patterns ──
25
+ /** Patterns indicating the AI is actively working */
26
+ const AI_ACTIVITY_PATTERNS = [
27
+ '"type":"assistant"',
28
+ '"type":"tool_use"',
29
+ '"type":"tool_result"',
30
+ '"stop_reason":"tool_use"',
31
+ ];
32
+ /** Patterns indicating the session has ended (final output delivered) */
33
+ const ENDING_PATTERNS = [
34
+ '"stop_reason":"end_turn"',
35
+ '"type":"result"',
36
+ '"type":"user"', // User typing means AI is done with its turn
37
+ ];
38
+ /** Patterns that definitely mean the session is done */
39
+ const TERMINAL_PATTERNS = ['"type":"result"'];
40
+ // ── Public API ──
41
+ /**
42
+ * Determines whether a session is ongoing, ended, or stale.
43
+ *
44
+ * Uses a multi-signal approach:
45
+ * 1. File mtime staleness check (>5min → stale)
46
+ * 2. Content-based: reads last ~32KB and classifies the ending pattern
47
+ * 3. Grace period: prevents flicker by requiring 5s of inactivity before "ended"
48
+ *
49
+ * @param sessionPath - Path to the JSONL session file
50
+ * @returns Activity state with classification reason
51
+ */
52
+ export function detectSessionActivity(sessionPath) {
53
+ let stat;
54
+ try {
55
+ stat = fs.statSync(sessionPath);
56
+ }
57
+ catch {
58
+ return { state: 'ended', lastActivityTime: null, reason: 'file-not-found' };
59
+ }
60
+ const now = Date.now();
61
+ const mtimeAge = now - stat.mtimeMs;
62
+ // Staleness check: if file hasn't been touched in >5 minutes, it's stale
63
+ if (mtimeAge > STALENESS_THRESHOLD_MS) {
64
+ return { state: 'stale', lastActivityTime: new Date(stat.mtimeMs), reason: 'mtime-stale' };
65
+ }
66
+ // Read the tail of the file for content analysis
67
+ const tail = readTail(sessionPath, stat.size);
68
+ if (!tail) {
69
+ return { state: 'ended', lastActivityTime: new Date(stat.mtimeMs), reason: 'empty-file' };
70
+ }
71
+ // Parse the last few JSONL lines
72
+ const lines = tail.split('\n').filter((l) => l.trim().length > 0);
73
+ if (lines.length === 0) {
74
+ return { state: 'ended', lastActivityTime: new Date(stat.mtimeMs), reason: 'no-events' };
75
+ }
76
+ return classifyFromSignals(signalsFromLines(lines), stat.mtimeMs, mtimeAge);
77
+ }
78
+ function signalsFromLines(lines) {
79
+ let lastAiActivityIndex = -1;
80
+ let lastEndingIndex = -1;
81
+ let hasTerminal = false;
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i];
84
+ for (const pattern of AI_ACTIVITY_PATTERNS) {
85
+ if (line.includes(pattern)) {
86
+ lastAiActivityIndex = i;
87
+ break;
88
+ }
89
+ }
90
+ for (const pattern of TERMINAL_PATTERNS) {
91
+ if (line.includes(pattern)) {
92
+ hasTerminal = true;
93
+ lastEndingIndex = i;
94
+ break;
95
+ }
96
+ }
97
+ if (!hasTerminal) {
98
+ for (const pattern of ENDING_PATTERNS) {
99
+ if (pattern === '"type":"user"' && line.includes('"type":"tool_result"'))
100
+ continue;
101
+ if (line.includes(pattern)) {
102
+ lastEndingIndex = i;
103
+ break;
104
+ }
105
+ }
106
+ }
107
+ }
108
+ return { lastAiActivityIndex, lastEndingIndex, hasTerminal };
109
+ }
110
+ function hasToolResultBlock(event) {
111
+ const content = event.message?.content;
112
+ return (Array.isArray(content) &&
113
+ content.some((block) => typeof block === 'object' &&
114
+ block !== null &&
115
+ block.type === 'tool_result'));
116
+ }
117
+ /**
118
+ * The same signals the tail scanner derives from raw JSONL lines, read from
119
+ * canonical events instead: assistant, tool-use, and tool-result events (and
120
+ * user events carrying tool results) are AI activity; an assistant turn that
121
+ * stopped with `end_turn` or a user prompt is an ending event.
122
+ */
123
+ function signalsFromEvents(events) {
124
+ let lastAiActivityIndex = -1;
125
+ let lastEndingIndex = -1;
126
+ for (let i = 0; i < events.length; i++) {
127
+ const event = events[i];
128
+ const carriesToolResult = event.type === 'user' && hasToolResultBlock(event);
129
+ if (event.type === 'assistant' ||
130
+ event.type === 'tool_use' ||
131
+ event.type === 'tool_result' ||
132
+ carriesToolResult) {
133
+ lastAiActivityIndex = i;
134
+ }
135
+ if ((event.type === 'assistant' && event.message?.stop_reason === 'end_turn') ||
136
+ (event.type === 'user' && !carriesToolResult)) {
137
+ lastEndingIndex = i;
138
+ }
139
+ }
140
+ return { lastAiActivityIndex, lastEndingIndex, hasTerminal: false };
141
+ }
142
+ function classifyFromSignals(signals, mtimeMs, mtimeAge) {
143
+ const lastActivityTime = new Date(mtimeMs);
144
+ const { lastAiActivityIndex, lastEndingIndex, hasTerminal } = signals;
145
+ // Terminal patterns mean the session is definitely done
146
+ if (hasTerminal) {
147
+ return { state: 'ended', lastActivityTime, reason: 'terminal-event' };
148
+ }
149
+ // AI activity after the last ending event -> ongoing
150
+ if (lastAiActivityIndex > lastEndingIndex) {
151
+ return { state: 'ongoing', lastActivityTime, reason: 'ai-activity-after-ending' };
152
+ }
153
+ // Ending event with no subsequent AI activity
154
+ if (lastEndingIndex >= 0) {
155
+ // Apply grace period to prevent flicker
156
+ if (mtimeAge < GRACE_PERIOD_MS) {
157
+ return { state: 'ongoing', lastActivityTime, reason: 'grace-period' };
158
+ }
159
+ return { state: 'ended', lastActivityTime, reason: 'ending-event' };
160
+ }
161
+ // No clear signal: if recently modified, assume ongoing
162
+ if (mtimeAge < GRACE_PERIOD_MS) {
163
+ return { state: 'ongoing', lastActivityTime, reason: 'recent-mtime' };
164
+ }
165
+ return { state: 'ended', lastActivityTime, reason: 'no-activity-signal' };
166
+ }
167
+ /**
168
+ * Classify a session from events already in memory, with the same states,
169
+ * reasons, grace period, and staleness rule as {@link detectSessionActivity},
170
+ * but without opening the file again. Works for every provider (the raw
171
+ * tail patterns only ever matched Claude Code JSONL) and for database-backed
172
+ * sessions that have no file to tail.
173
+ */
174
+ export function classifySessionActivity(options) {
175
+ const now = options.now ?? Date.now();
176
+ const events = options.events;
177
+ const lastEventMs = events.length > 0 ? Date.parse(events[events.length - 1].timestamp) : NaN;
178
+ const mtimeMs = typeof options.mtimeMs === 'number' && Number.isFinite(options.mtimeMs)
179
+ ? options.mtimeMs
180
+ : Number.isFinite(lastEventMs)
181
+ ? lastEventMs
182
+ : null;
183
+ if (mtimeMs === null) {
184
+ return { state: 'ended', lastActivityTime: null, reason: 'no-events' };
185
+ }
186
+ const mtimeAge = now - mtimeMs;
187
+ if (mtimeAge > STALENESS_THRESHOLD_MS) {
188
+ return { state: 'stale', lastActivityTime: new Date(mtimeMs), reason: 'mtime-stale' };
189
+ }
190
+ if (events.length === 0) {
191
+ return { state: 'ended', lastActivityTime: new Date(mtimeMs), reason: 'no-events' };
192
+ }
193
+ const tail = events.length > TAIL_EVENTS ? events.slice(-TAIL_EVENTS) : events;
194
+ return classifyFromSignals(signalsFromEvents(tail), mtimeMs, mtimeAge);
195
+ }
196
+ /**
197
+ * Refresh a cached classification from its unchanged fingerprint without
198
+ * re-reading the transcript. An active file remains active until it crosses
199
+ * the same five-minute staleness boundary used by detectSessionActivity,
200
+ * unless it was active only by grace period (`previousReason`).
201
+ */
202
+ export function refreshSessionActivityState(previous, mtimeMs, nowMs = Date.now(), previousReason) {
203
+ if (!Number.isFinite(mtimeMs))
204
+ return previous;
205
+ if (nowMs - mtimeMs > STALENESS_THRESHOLD_MS)
206
+ return 'idle';
207
+ if (previous === 'ended')
208
+ return 'ended';
209
+ if (previous === 'idle')
210
+ return 'idle';
211
+ // A session that was only active thanks to the grace period ends once the
212
+ // grace period lapses, exactly as a fresh classification would say.
213
+ if (previous === 'active' &&
214
+ (previousReason === 'grace-period' || previousReason === 'recent-mtime') &&
215
+ nowMs - mtimeMs >= GRACE_PERIOD_MS) {
216
+ return 'ended';
217
+ }
218
+ return previous === 'unknown' ? 'unknown' : 'active';
219
+ }
220
+ // ── Helpers ──
221
+ function readTail(filePath, fileSize) {
222
+ const bytesToRead = Math.min(TAIL_BYTES, fileSize);
223
+ if (bytesToRead <= 0)
224
+ return null;
225
+ let fd = null;
226
+ try {
227
+ fd = fs.openSync(filePath, 'r');
228
+ const buffer = Buffer.alloc(bytesToRead);
229
+ const offset = Math.max(0, fileSize - bytesToRead);
230
+ const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, offset);
231
+ fs.closeSync(fd);
232
+ fd = null;
233
+ return buffer.toString('utf-8', 0, bytesRead);
234
+ }
235
+ catch {
236
+ if (fd !== null) {
237
+ try {
238
+ fs.closeSync(fd);
239
+ }
240
+ catch {
241
+ /* ignore */
242
+ }
243
+ }
244
+ return null;
245
+ }
246
+ }