sidekick-shared 0.26.1 → 0.26.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to sidekick-shared will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.26.2] - 2026-09-06
9
+
10
+ ### Added
11
+
12
+ - `DoctorOptions.provider` selects the session provider to diagnose; `HealthReport.sessionProvider` records it.
13
+ - Optional watcher `onBatchComplete` callback for persisting state after all events in a read have been delivered.
14
+ - `SessionSearchOptions.projectPath` and `signal` for provider-aware workspace filtering and cancellation; legacy `projectSlug` remains supported.
15
+
16
+ ### Fixed
17
+
18
+ - JSONL watcher and provider reader checkpoints end at complete lines, retaining partial UTF-8 characters across reads and restarts. Codex restores parser context when seeking.
19
+ - Watcher failures retain polling fallback; OpenCode watchers share the canonical data-directory resolver.
20
+ - Session search enumerates through providers, includes database-only sessions, and deduplicates paths.
21
+ - Doctor checks the selected session provider, treats unrelated integrations as informational, and reads account status without changing account selection.
22
+
8
23
  ## [0.26.1] - 2026-09-05
9
24
 
10
25
  ### Fixed
package/README.md CHANGED
@@ -9,6 +9,7 @@ Types, parsers, providers, readers, formatters, aggregation, search, reporting,
9
9
 
10
10
  ## Recent additions
11
11
 
12
+ - **0.26.2: reliable monitoring and search** — complete-line JSONL cursors preserve Unicode and Codex parser context; watcher `onBatchComplete` signals a delivered batch. `searchSessions()` adds workspace-path filtering and cancellation, and Doctor accepts an explicit session provider.
12
13
  - **One token vocabulary and cost provenance (0.26.0)** — `summarizeTokens()` gives every surface the same `total` (input + output + cache writes + cache reads) and `context` (input + cache) figures; `AggregatedTokens.costUsd` carries `costProvenance` so reports can say whether a cost was provider-reported or estimated; `parseClaudeStatuslinePayload()` / `quotaFromStatuslinePayload()` turn Claude Code's status-line JSON into an official `statusline` quota source; `readQuotaSnapshot()` reports `ageMs` / `freshness` and keeps the sample's origin as `capturedSource`; `resolveQuota()` shares quota resolution across CLI, MCP, and dashboard startup, with default local-sample preferences and an API-first option used by one-shot Codex queries; `readSessionFileStats()` / `computeSessionFileStats()` give every provider one `readSessionStats()` over the shared aggregator, with `availability`, `costProvenance`, and `toolFailures` on `SessionFileStats`; `collectUsageEvents()` reads priced usage events from session logs with a per-session cache, `computeBillingBlocks()` groups them into five-hour billing blocks, and `bucketUsage()` turns them into day, week, month, or session rows; `applySessionSummary()` and `importSessionHistory()` let any host fold finished sessions into `historical-data.json` the way the extension does; observed sessions are read once per discovery, re-read only when their fingerprint changes, and classified from events by `classifySessionActivity()`; `readSessionReportInputs()` derives report metrics and transcript from one read; `writeStateFile()` / `readStateFile()` (also on the `statusline` subpath) maintain the public, versioned `state.json` for external tools, validated by `sidekickStateFileSchema`; `getScheduledPeakHoursState()` and `evaluateQuotaThresholds()` are shared by the CLI and extension dashboards.
13
14
  - **Host-safe APIs (0.25.0)** — `listSessionPreviewsAsync()` / `readSessionPreviewAsync()` read previews with bounded concurrency and cooperative yielding; `ObservedSessionCollector.subscribe()` and `SessionMonitor.subscribe()` replace host polling loops with debounced change batches; `createSessionProviders({ onDiagnostic })` constructs every usable provider with structured diagnostics instead of throwing; `findSessionById()` resolves one session without scanning; `exportResolvedModelCatalog()` / `importResolvedModelCatalog()` and `registerModelAlias()` transfer context/pricing resolutions across realms; `onAccountsChanged()` reports login/logout/switch events; provider constructors perform no environment I/O, and quota pollers stay dormant without an account.
14
15
  - **Native session-file helpers** — `encodeClaudeWorkspacePath()` / `getClaudeSessionDirectory()` locate Claude Code's `~/.claude/projects/` directories with the real on-disk encoding, `findCodexRolloutFile()` resolves a Codex session id to its rollout path, `readCodexHistory()` tail-reads `~/.codex/history.jsonl`, `listSessionPreviews()` / `readSessionPreview()` build a cheap stat-first recent-sessions index across providers, and `parseMcpToolName()` splits `mcp__<server>__<tool>` identifiers (browser-safe).
@@ -104,7 +105,7 @@ returns the platform default that both overrides bypass.
104
105
  | **Credentials** | Claude Max OAuth credential reading from `~/.claude/.credentials.json` |
105
106
  | **Quota** | Claude Max subscription quota fetching (5-hour and 7-day windows), Codex rate-limit extraction from event streams (plus Codex reset credits via `fetchCodexResetCreditsFromApi()`, attached to quota state by `fetchCodexQuotaFromApi()`), and authoritative z.ai Coding Plan quota fetching via `resolveZaiQuota()` / `fetchZaiQuotaFromApi()` (reads z.ai's `api/monitor/usage/quota/limit` endpoint with cached-snapshot fallback). The observed-traffic z.ai estimator was removed from the package root exports in 0.23.0 and every remaining export of it is `@deprecated` — `resolveZaiQuota()` is the single supported z.ai quota API |
106
107
  | **Provider Status** | API health checking via status.claude.com and status.openai.com (indicator, components, incidents) |
107
- | **Doctor** | Typed cross-provider health diagnostics — `runDoctor()` checks project identity, sessions, accounts, providers, and dependencies into a `HealthReport`; `formatHealthReport()` renders it and `getSessionDiagnostics()` exposes the per-provider session probe |
108
+ | **Doctor** | Typed cross-provider health diagnostics — `runDoctor()` checks project identity, sessions, accounts, providers, and dependencies into a `HealthReport`; `formatHealthReport()` renders it and `getSessionDiagnostics()` retains the legacy Claude Code path probe; `runDoctor({ provider })` diagnoses the selected session provider |
108
109
  | **Schemas** | Zod schemas for runtime validation of data crossing process/IPC boundaries — JSONL session events (`sessionEventSchema`, `messageUsageSchema`, `sessionMessageSchema`), assistant turns, observed-session V1 records, quota, account status, account management, quota history, and the public `state.json` contract (`sidekickStateFileSchema`) — plus `extractSessionEvents()` to unwrap `progress`-wrapped events. Account and quota APIs guarantee results valid against the same-release schemas. Also published fs-free via the [`sidekick-shared/schemas`](#supported-import-paths) subpath |
109
110
  | **Extractors** | Pure functions for single-event processing: `extractTokenUsage()`, `extractToolCall()` (top-level `tool_use`), `extractToolCalls()` (assistant content blocks), plus Node-only session asset extraction (`gatherAssetsForCwd()`, `readClaudeAssets()`, `readCodexAssets()`) for URLs, file paths, commands, and plans |
110
111
  | **Model Info & Pricing** | Model family parsing (Anthropic / OpenAI / Google, including legacy `claude-3-opus-…` and `claude-3-5-sonnet-…` IDs), context-window lookup (including Fable 5 / Opus 4.8 / Opus 4.7 / Sonnet 4.7 1M and GPT-5.x variants), pricing tables with optional LiteLLM hydration, normalized non-double-counting cost (`calculateNormalizedUsageCost()`, fed by `normalizeProviderUsage()` / `extractNormalizedUsage()`), legacy cost helpers retained but deprecated (`calculateCost()`, `calculateCostWithProvenance()`), `mergeCostSources()`, display helpers (`shortModelName()`, `getModelDisplayInfo()`, `compareModelIds()`, `sortModelIds()`, `formatCost()`), and cross-realm catalog transfer — `exportResolvedModelCatalog()` / `importResolvedModelCatalog()` move resolved context windows and pricing (with provenance, prefix inheritance explicit) between Node and browser realms, and `registerModelAlias()` resolves short or host-specific names to canonical ids |
@@ -670,6 +671,26 @@ const tail = createJsonlTail({
670
671
  tail.start();
671
672
  ```
672
673
 
674
+ `SessionWatcherCallbacks.onBatchComplete` is also available through `createWatcher()`. Save aggregation state only after a complete event batch and only when the state includes the full history (a completed replay or a compatible restored checkpoint). Live-only state is incomplete and must not replace a full-history checkpoint. JSONL positions end at complete lines; Codex readers reconstruct parser context when seeking. OpenCode's SQLite timestamp cursor does not persist row-version deduplication, so the dashboards replay OpenCode history instead of restoring a snapshot.
675
+
676
+ ### Search a workspace and diagnose its provider
677
+
678
+ ```typescript
679
+ import { CodexProvider, searchSessions, runDoctor } from 'sidekick-shared';
680
+
681
+ const provider = new CodexProvider();
682
+ const controller = new AbortController();
683
+ const matches = await searchSessions(provider, 'authentication', {
684
+ projectPath: '/path/to/project',
685
+ signal: controller.signal,
686
+ maxResults: 20,
687
+ });
688
+ const report = await runDoctor({ cwd: '/path/to/project', provider: 'codex' });
689
+ console.log(matches, report.sessionProvider, report.checks);
690
+ ```
691
+
692
+ `projectPath` resolves the workspace canonically and takes precedence over the legacy `projectSlug`. Cancellation is checked between session reads; it does not interrupt an in-progress synchronous provider read. `HealthReport.sessionProvider` is optional for compatibility. `getSessionDiagnostics()` remains a Claude Code path diagnostic; use `runDoctor()` for provider-aware checks.
693
+
673
694
  ### Poll quota with backoff
674
695
 
675
696
  ```typescript
package/dist/doctor.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type ActiveAccountStatus } from './accountStatus';
2
2
  import { type ProjectIdentity } from './paths';
3
3
  import { type ProviderStatusState } from './providerStatus';
4
+ import type { ProviderId } from './providers/types';
4
5
  import { type OpenCodeDbRuntimeStatus } from './providers/openCodeDatabase';
5
6
  export type HealthCheckStatus = 'ok' | 'warning' | 'error' | 'info';
6
7
  export interface HealthCheck {
@@ -38,10 +39,14 @@ export interface HealthReport {
38
39
  claude: ProviderStatusState;
39
40
  openai: ProviderStatusState;
40
41
  };
42
+ /** Legacy Claude Code path diagnostics, retained for compatibility. */
41
43
  sessions: SessionDiagnostics;
44
+ sessionProvider?: ProviderId;
42
45
  }
43
46
  export interface DoctorOptions {
44
47
  cwd?: string;
48
+ /** Session provider to diagnose; defaults to auto-detection. */
49
+ provider?: ProviderId | 'auto';
45
50
  deprecatedSettings?: DeprecatedSetting[];
46
51
  openCodeStatus?: OpenCodeDbRuntimeStatus;
47
52
  fetchStatuses?: () => Promise<{
package/dist/doctor.js CHANGED
@@ -44,6 +44,8 @@ const paths_1 = require("./paths");
44
44
  const sessionPathResolver_1 = require("./parsers/sessionPathResolver");
45
45
  const providerStatus_1 = require("./providerStatus");
46
46
  const openCode_1 = require("./providers/openCode");
47
+ const factory_1 = require("./providers/factory");
48
+ const detect_1 = require("./providers/detect");
47
49
  const openCodeDatabase_1 = require("./providers/openCodeDatabase");
48
50
  /** Collects the Claude Code path-resolution detail used by both host surfaces. */
49
51
  function getSessionDiagnostics(workspacePath) {
@@ -100,15 +102,66 @@ function statusUnavailable() {
100
102
  updatedAt: new Date().toISOString(),
101
103
  };
102
104
  }
105
+ async function checkSessionProvider(providerId, workspacePath) {
106
+ const { providers } = (0, factory_1.createSessionProviders)({ providerIds: [providerId] });
107
+ const provider = providers[0];
108
+ const title = `Session discovery (${provider?.displayName ?? providerId})`;
109
+ try {
110
+ if (!provider)
111
+ throw new Error(`${providerId} could not be initialized.`);
112
+ const sessions = provider.listSessionFilesAsync
113
+ ? await provider.listSessionFilesAsync(workspacePath)
114
+ : provider.findAllSessions(workspacePath);
115
+ if (sessions.length > 0) {
116
+ return {
117
+ id: 'session_discovery',
118
+ status: 'ok',
119
+ title,
120
+ message: `Found ${sessions.length} ${provider.displayName} session${sessions.length === 1 ? '' : 's'} for ${workspacePath}.`,
121
+ };
122
+ }
123
+ const operation = provider.getLastOperationStatus?.();
124
+ const runtime = provider.getRuntimeStatus?.();
125
+ const issue = operation && !operation.usable
126
+ ? operation.runtimeStatus
127
+ : runtime && !runtime.available
128
+ ? runtime
129
+ : undefined;
130
+ return {
131
+ id: 'session_discovery',
132
+ status: 'warning',
133
+ title,
134
+ message: issue?.message ?? `No ${provider.displayName} sessions found for ${workspacePath}.`,
135
+ repair: issue?.kind === 'sqlite_missing'
136
+ ? 'Install sqlite3 and ensure it is on PATH, then rerun sidekick doctor.'
137
+ : `Start ${provider.displayName} in this project, or inspect ${provider.getSessionDirectory(workspacePath)}.`,
138
+ };
139
+ }
140
+ catch (error) {
141
+ return {
142
+ id: 'session_discovery',
143
+ status: 'warning',
144
+ title,
145
+ message: error instanceof Error ? error.message : String(error),
146
+ repair: `Check ${providerId} session storage permissions and rerun sidekick doctor.`,
147
+ };
148
+ }
149
+ finally {
150
+ for (const candidate of providers)
151
+ candidate.dispose();
152
+ }
153
+ }
103
154
  async function runDoctor(options = {}) {
104
155
  const project = (0, paths_1.resolveProjectIdentity)(options.cwd);
105
156
  const sessions = getSessionDiagnostics(project.cwd);
106
- const accounts = (0, accountStatus_1.getActiveAccountStatus)();
157
+ const accounts = (0, accountStatus_1.getActiveAccountStatus)(undefined, { selfHeal: false });
158
+ const sessionProvider = options.provider && options.provider !== 'auto' ? options.provider : (0, detect_1.detectProvider)();
107
159
  let openCode = options.openCodeStatus;
108
160
  if (!openCode) {
109
161
  const database = new openCodeDatabase_1.OpenCodeDatabase((0, openCode_1.getOpenCodeDataDir)());
110
162
  database.open();
111
163
  openCode = database.getRuntimeStatus();
164
+ database.close();
112
165
  }
113
166
  const providerStatus = await (options.fetchStatuses ??
114
167
  (async () => {
@@ -130,20 +183,7 @@ async function runDoctor(options = {}) {
130
183
  title: 'Project identity',
131
184
  message: `Canonical slug: ${project.canonicalSlug}`,
132
185
  });
133
- checks.push(sessions.discoveredSessionDir
134
- ? {
135
- id: 'session_discovery',
136
- status: 'ok',
137
- title: 'Session discovery',
138
- message: `Found Claude Code sessions at ${sessions.discoveredSessionDir}.`,
139
- }
140
- : {
141
- id: 'session_discovery',
142
- status: 'warning',
143
- title: 'Session discovery',
144
- message: `No Claude Code session directory found for ${project.cwd}.`,
145
- repair: `Start Claude Code in this project, or inspect ${sessions.expectedSessionDir}.`,
146
- });
186
+ checks.push(await checkSessionProvider(sessionProvider, project.resolvedCwd));
147
187
  const openCodeCheck = openCode.available
148
188
  ? {
149
189
  id: 'opencode_sqlite',
@@ -167,16 +207,22 @@ async function runDoctor(options = {}) {
167
207
  ? 'Install sqlite3 and ensure the sqlite3 executable is on PATH, then rerun sidekick doctor.'
168
208
  : 'Make sqlite3 executable and verify the OpenCode database is readable.',
169
209
  };
210
+ if (sessionProvider !== 'opencode' && openCodeCheck.status === 'warning') {
211
+ openCodeCheck.status = 'info';
212
+ openCodeCheck.message += ' This does not affect the selected session provider.';
213
+ }
170
214
  checks.push(openCodeCheck);
171
215
  checks.push({
172
216
  id: 'accounts',
173
- status: accounts.ok ? 'ok' : 'warning',
217
+ status: accounts.ok ? 'ok' : 'info',
174
218
  title: 'Accounts and credentials',
175
219
  message: accounts.ok
176
220
  ? `Detected ${[accounts.claude.present && 'Claude', accounts.codex.present && 'Codex'].filter(Boolean).join(' and ')} credentials.`
177
221
  : (accounts.error ?? 'No Claude or Codex credentials were detected.'),
178
222
  ...(!accounts.ok
179
- ? { repair: 'Sign in with claude or codex, then rerun sidekick doctor.' }
223
+ ? {
224
+ repair: 'Sign in with claude or codex to refresh account quota. Existing sessions can be monitored without saved credentials.',
225
+ }
180
226
  : {}),
181
227
  });
182
228
  const unavailableProviders = Object.entries(providerStatus)
@@ -187,7 +233,11 @@ async function runDoctor(options = {}) {
187
233
  .map(([name]) => name);
188
234
  checks.push({
189
235
  id: 'provider_api',
190
- status: degradedProviders.length > 0 ? 'warning' : unavailableProviders.length > 0 ? 'info' : 'ok',
236
+ status: degradedProviders.includes(sessionProvider === 'codex' ? 'openai' : sessionProvider === 'claude-code' ? 'claude' : '')
237
+ ? 'warning'
238
+ : degradedProviders.length > 0 || unavailableProviders.length > 0
239
+ ? 'info'
240
+ : 'ok',
191
241
  title: 'Provider API status',
192
242
  message: degradedProviders.length > 0
193
243
  ? `Provider incidents reported for: ${degradedProviders.join(', ')}.`
@@ -226,6 +276,7 @@ async function runDoctor(options = {}) {
226
276
  openCode,
227
277
  providerStatus,
228
278
  sessions,
279
+ sessionProvider,
229
280
  };
230
281
  }
231
282
  function formatHealthReport(report) {
package/dist/index.d.ts CHANGED
@@ -97,7 +97,7 @@ export type { DebugLogEntry, DebugLogFile, DebugLogLevel } from './parsers/debug
97
97
  export { OpenCodeDatabase } from './providers/openCodeDatabase';
98
98
  export { CodexDatabase } from './providers/codexDatabase';
99
99
  export { searchSessions } from './search/sessionSearch';
100
- export type { SearchResult } from './search/sessionSearch';
100
+ export type { SearchResult, SessionSearchOptions } from './search/sessionSearch';
101
101
  export { FilterEngine } from './search/advancedFilter';
102
102
  export type { FilterMode, FilterState, HighlightFormat as FilterHighlightFormat, } from './search/advancedFilter';
103
103
  export { composeContext } from './context/composer';
@@ -136,6 +136,8 @@ class ClaudeCodeReader {
136
136
  sessionPath;
137
137
  parser;
138
138
  filePosition = 0;
139
+ /** Cursor after the last complete line, safe to persist across restarts. */
140
+ committedPosition = 0;
139
141
  events = [];
140
142
  _wasTruncated = false;
141
143
  decoder = new string_decoder_1.StringDecoder('utf8');
@@ -165,6 +167,7 @@ class ClaudeCodeReader {
165
167
  if (currentSize < this.filePosition) {
166
168
  this._wasTruncated = true;
167
169
  this.filePosition = 0;
170
+ this.committedPosition = 0;
168
171
  this.parser.reset();
169
172
  this.decoder = new string_decoder_1.StringDecoder('utf8');
170
173
  }
@@ -183,7 +186,11 @@ class ClaudeCodeReader {
183
186
  finally {
184
187
  fs.closeSync(fd);
185
188
  }
186
- const chunk = this.decoder.write(buffer.subarray(0, bytesRead));
189
+ const bytes = buffer.subarray(0, bytesRead);
190
+ const newline = bytes.lastIndexOf(0x0a);
191
+ if (newline >= 0)
192
+ this.committedPosition = this.filePosition + newline + 1;
193
+ const chunk = this.decoder.write(bytes);
187
194
  this.parser.processChunk(chunk);
188
195
  this.filePosition += bytesRead;
189
196
  }
@@ -198,6 +205,7 @@ class ClaudeCodeReader {
198
205
  }
199
206
  reset() {
200
207
  this.filePosition = 0;
208
+ this.committedPosition = 0;
201
209
  this.parser.reset();
202
210
  this.decoder = new string_decoder_1.StringDecoder('utf8');
203
211
  this._wasTruncated = false;
@@ -213,10 +221,11 @@ class ClaudeCodeReader {
213
221
  this.decoder = new string_decoder_1.StringDecoder('utf8');
214
222
  }
215
223
  getPosition() {
216
- return this.filePosition;
224
+ return this.committedPosition;
217
225
  }
218
226
  seekTo(position) {
219
227
  this.filePosition = position;
228
+ this.committedPosition = position;
220
229
  this.parser.reset();
221
230
  this.decoder = new string_decoder_1.StringDecoder('utf8');
222
231
  }
@@ -473,6 +473,8 @@ class CodexReader {
473
473
  onTokenUsage;
474
474
  parser;
475
475
  filePosition = 0;
476
+ /** Cursor after the last complete line, safe to persist across restarts. */
477
+ committedPosition = 0;
476
478
  lineBuffer = '';
477
479
  _wasTruncated = false;
478
480
  decoder = new string_decoder_1.StringDecoder('utf8');
@@ -487,18 +489,19 @@ class CodexReader {
487
489
  getSessionMeta() {
488
490
  return this.parser.getSessionMeta();
489
491
  }
490
- readNew() {
492
+ readNew(endPosition) {
491
493
  this._wasTruncated = false;
492
494
  const events = [];
493
495
  try {
494
496
  if (!fs.existsSync(this.rolloutPath))
495
497
  return [];
496
498
  const stats = fs.statSync(this.rolloutPath);
497
- const currentSize = stats.size;
499
+ const currentSize = Math.min(stats.size, endPosition ?? stats.size);
498
500
  // Handle truncation
499
501
  if (currentSize < this.filePosition) {
500
502
  this._wasTruncated = true;
501
503
  this.filePosition = 0;
504
+ this.committedPosition = 0;
502
505
  this.lineBuffer = '';
503
506
  this.parser.reset();
504
507
  this.decoder = new string_decoder_1.StringDecoder('utf8');
@@ -517,7 +520,11 @@ class CodexReader {
517
520
  finally {
518
521
  fs.closeSync(fd);
519
522
  }
520
- const chunk = this.decoder.write(buffer.subarray(0, bytesRead));
523
+ const bytes = buffer.subarray(0, bytesRead);
524
+ const newline = bytes.lastIndexOf(0x0a);
525
+ if (newline >= 0)
526
+ this.committedPosition = this.filePosition + newline + 1;
527
+ const chunk = this.decoder.write(bytes);
521
528
  this.filePosition += bytesRead;
522
529
  // Process lines
523
530
  const text = this.lineBuffer + chunk;
@@ -583,6 +590,7 @@ class CodexReader {
583
590
  }
584
591
  reset() {
585
592
  this.filePosition = 0;
593
+ this.committedPosition = 0;
586
594
  this.lineBuffer = '';
587
595
  this.parser.reset();
588
596
  this.decoder = new string_decoder_1.StringDecoder('utf8');
@@ -606,12 +614,14 @@ class CodexReader {
606
614
  this.decoder = new string_decoder_1.StringDecoder('utf8');
607
615
  }
608
616
  getPosition() {
609
- return this.filePosition;
617
+ return this.committedPosition;
610
618
  }
611
619
  seekTo(position) {
612
- this.filePosition = position;
613
- this.lineBuffer = '';
614
- this.decoder = new string_decoder_1.StringDecoder('utf8');
620
+ // Rebuild model, turn, and cumulative-token context before resuming.
621
+ // The prefix is parsed only; its events are already in the consumer snapshot.
622
+ this.reset();
623
+ if (position > 0)
624
+ this.readNew(position);
615
625
  }
616
626
  wasTruncated() {
617
627
  return this._wasTruncated;
@@ -1,6 +1,4 @@
1
- /**
2
- * Provider-aware cross-session search.
3
- */
1
+ /** Provider-aware cross-session search. */
4
2
  import type { SessionProviderBase, ProviderId } from '../providers/types';
5
3
  export interface SearchResult {
6
4
  providerId: ProviderId;
@@ -10,7 +8,13 @@ export interface SearchResult {
10
8
  eventType: string;
11
9
  timestamp: string;
12
10
  }
13
- export declare function searchSessions(provider: SessionProviderBase, query: string, opts?: {
11
+ export interface SessionSearchOptions {
12
+ /** Workspace path, resolved canonically. Takes precedence over projectSlug. */
13
+ projectPath?: string;
14
+ /** Legacy provider-encoded project identifier. */
14
15
  projectSlug?: string;
15
16
  maxResults?: number;
16
- }): Promise<SearchResult[]>;
17
+ /** Stop obsolete interactive searches between session reads. */
18
+ signal?: AbortSignal;
19
+ }
20
+ export declare function searchSessions(provider: SessionProviderBase, query: string, options?: SessionSearchOptions): Promise<SearchResult[]>;
@@ -1,98 +1,69 @@
1
1
  "use strict";
2
- /**
3
- * Provider-aware cross-session search.
4
- */
5
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
- if (k2 === undefined) k2 = k;
7
- var desc = Object.getOwnPropertyDescriptor(m, k);
8
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
- desc = { enumerable: true, get: function() { return m[k]; } };
10
- }
11
- Object.defineProperty(o, k2, desc);
12
- }) : (function(o, m, k, k2) {
13
- if (k2 === undefined) k2 = k;
14
- o[k2] = m[k];
15
- }));
16
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
17
- Object.defineProperty(o, "default", { enumerable: true, value: v });
18
- }) : function(o, v) {
19
- o["default"] = v;
20
- });
21
- var __importStar = (this && this.__importStar) || (function () {
22
- var ownKeys = function(o) {
23
- ownKeys = Object.getOwnPropertyNames || function (o) {
24
- var ar = [];
25
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
26
- return ar;
27
- };
28
- return ownKeys(o);
29
- };
30
- return function (mod) {
31
- if (mod && mod.__esModule) return mod;
32
- var result = {};
33
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
34
- __setModuleDefault(result, mod);
35
- return result;
36
- };
37
- })();
2
+ /** Provider-aware cross-session search. */
38
3
  Object.defineProperty(exports, "__esModule", { value: true });
39
4
  exports.searchSessions = searchSessions;
40
- const fs = __importStar(require("fs"));
41
- const path = __importStar(require("path"));
42
- async function searchSessions(provider, query, opts) {
43
- const maxResults = opts?.maxResults ?? 50;
5
+ const paths_1 = require("../paths");
6
+ async function enumerateSessions(provider, options) {
7
+ const workspace = options.projectPath
8
+ ? (0, paths_1.resolveProjectIdentity)(options.projectPath).resolvedCwd
9
+ : undefined;
10
+ if (!options.projectSlug || workspace) {
11
+ if (provider.listSessionFilesAsync)
12
+ return provider.listSessionFilesAsync(workspace);
13
+ if (workspace) {
14
+ return provider
15
+ .findAllSessions(workspace)
16
+ .map((path) => ({ path, mtime: new Date(0), workspacePath: workspace }));
17
+ }
18
+ if (provider.listAllSessionFiles)
19
+ return provider.listAllSessionFiles();
20
+ }
21
+ // Older/custom providers can expose virtual project directories. Let the
22
+ // provider enumerate them instead of assuming a particular on-disk layout.
23
+ const files = [];
24
+ for (const folder of provider.getAllProjectFolders()) {
25
+ if (options.signal?.aborted)
26
+ break;
27
+ if (options.projectSlug && folder.encodedName !== options.projectSlug)
28
+ continue;
29
+ const paths = provider.id === 'codex'
30
+ ? provider.findAllSessions(folder.name)
31
+ : provider.findSessionsInDirectory(folder.dir);
32
+ files.push(...paths.map((path) => ({ path, mtime: folder.lastModified, workspacePath: folder.name })));
33
+ }
34
+ return files;
35
+ }
36
+ async function searchSessions(provider, query, options = {}) {
37
+ const maxResults = options.maxResults ?? 50;
44
38
  const results = [];
45
- const baseDir = provider.getProjectsBaseDir();
46
- try {
47
- if (!fs.existsSync(baseDir))
48
- return results;
49
- // Get all project folders
50
- const folders = provider.getAllProjectFolders();
51
- for (const folder of folders) {
52
- if (results.length >= maxResults)
53
- break;
54
- // If projectSlug specified, filter by encoded name
55
- if (opts?.projectSlug && folder.encodedName !== opts.projectSlug)
56
- continue;
57
- // Get session files in this folder
58
- let sessionFiles = [];
59
- try {
60
- const dir = folder.dir;
61
- if (fs.existsSync(dir)) {
62
- const entries = fs
63
- .readdirSync(dir)
64
- .filter((f) => f.endsWith('.jsonl') || f.endsWith('.json'));
65
- sessionFiles = entries.map((f) => path.join(dir, f));
66
- }
67
- }
68
- catch {
69
- /* skip */
70
- }
71
- // If no files found from dir scan, try findAllSessions with the folder name
72
- if (sessionFiles.length === 0) {
73
- // Let canonical directory discovery handle synthetic DB-backed paths.
74
- sessionFiles = provider.findSessionsInDirectory(folder.dir);
75
- }
76
- for (const sessionPath of sessionFiles) {
77
- if (results.length >= maxResults)
78
- break;
79
- const remaining = maxResults - results.length;
80
- const hits = provider.searchInSession(sessionPath, query, remaining);
81
- for (const hit of hits) {
82
- results.push({
83
- providerId: provider.id,
84
- projectPath: hit.projectPath || folder.name,
85
- sessionPath: hit.sessionPath,
86
- snippet: hit.line,
87
- eventType: hit.eventType,
88
- timestamp: hit.timestamp,
89
- });
90
- }
39
+ if (maxResults <= 0 || !query.trim() || options.signal?.aborted)
40
+ return results;
41
+ const files = await enumerateSessions(provider, options);
42
+ const seen = new Set();
43
+ files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
44
+ for (const file of files) {
45
+ if (results.length >= maxResults || options.signal?.aborted)
46
+ break;
47
+ if (seen.has(file.path))
48
+ continue;
49
+ seen.add(file.path);
50
+ try {
51
+ const hits = provider.searchInSession(file.path, query, maxResults - results.length);
52
+ for (const hit of hits.slice(0, maxResults - results.length)) {
53
+ results.push({
54
+ providerId: provider.id,
55
+ projectPath: hit.projectPath || file.workspacePath || '',
56
+ sessionPath: hit.sessionPath,
57
+ snippet: hit.line,
58
+ eventType: hit.eventType,
59
+ timestamp: hit.timestamp,
60
+ });
91
61
  }
92
62
  }
63
+ catch {
64
+ // An unreadable session must not hide matches in the remaining sessions.
65
+ }
66
+ await new Promise((resolve) => setImmediate(resolve));
93
67
  }
94
- catch {
95
- /* skip */
96
- }
97
- return results.slice(0, maxResults);
68
+ return results;
98
69
  }
@@ -38,17 +38,11 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.resolveSessionPath = resolveSessionPath;
40
40
  exports.createWatcher = createWatcher;
41
- const os = __importStar(require("os"));
42
41
  const path = __importStar(require("path"));
42
+ const openCode_1 = require("../providers/openCode");
43
43
  const jsonlWatcher_1 = require("./jsonlWatcher");
44
44
  const providerReaderWatcher_1 = require("./providerReaderWatcher");
45
45
  const sqliteWatcher_1 = require("./sqliteWatcher");
46
- function getOpenCodeDataDir() {
47
- const xdg = process.env.XDG_DATA_HOME;
48
- if (xdg)
49
- return path.join(xdg, 'opencode');
50
- return path.join(os.homedir(), '.local', 'share', 'opencode');
51
- }
52
46
  /**
53
47
  * Resolve the session a command should act on: an exact or unique-prefix
54
48
  * match of `sessionId` among the workspace's sessions, or the most recent
@@ -94,7 +88,7 @@ function createWatcherForProvider(provider, sessionPath, callbacks) {
94
88
  case 'codex':
95
89
  return new providerReaderWatcher_1.ProviderReaderSessionWatcher(provider, sessionPath, callbacks);
96
90
  case 'opencode': {
97
- const dataDir = getOpenCodeDataDir();
91
+ const dataDir = (0, openCode_1.getOpenCodeDataDir)();
98
92
  const dbPath = path.join(dataDir, 'opencode.db');
99
93
  // Session ID is the basename without extension
100
94
  const sid = path.basename(sessionPath, '.json');
@@ -42,6 +42,26 @@ const fs = __importStar(require("fs"));
42
42
  const jsonl_1 = require("../parsers/jsonl");
43
43
  const DEFAULT_DEBOUNCE_MS = 100;
44
44
  const DEFAULT_CATCHUP_INTERVAL_MS = 30_000;
45
+ /** Skip old complete events while retaining an in-progress final line. */
46
+ function lastCompleteLineOffset(filePath) {
47
+ const fd = fs.openSync(filePath, 'r');
48
+ try {
49
+ let end = fs.fstatSync(fd).size;
50
+ const buffer = Buffer.alloc(64 * 1024);
51
+ while (end > 0) {
52
+ const start = Math.max(0, end - buffer.length);
53
+ const count = fs.readSync(fd, buffer, 0, end - start, start);
54
+ const newline = buffer.subarray(0, count).lastIndexOf(0x0a);
55
+ if (newline >= 0)
56
+ return start + newline + 1;
57
+ end = start;
58
+ }
59
+ return 0;
60
+ }
61
+ finally {
62
+ fs.closeSync(fd);
63
+ }
64
+ }
45
65
  function createJsonlTail(options) {
46
66
  return new JsonlTailReader(options);
47
67
  }
@@ -79,7 +99,7 @@ class JsonlTailReader {
79
99
  this.active = true;
80
100
  if (this.options.startAtEnd && this.options.startOffset === undefined) {
81
101
  try {
82
- this.readOffset = fs.statSync(this.options.path).size;
102
+ this.readOffset = lastCompleteLineOffset(this.options.path);
83
103
  }
84
104
  catch {
85
105
  this.readOffset = 0;
@@ -179,7 +199,11 @@ class JsonlTailReader {
179
199
  this.fsWatcher = fs.watch(this.options.path, { persistent: false }, () => {
180
200
  this.debouncedRead();
181
201
  });
182
- this.fsWatcher.on('error', (error) => this.options.onError?.(error));
202
+ this.fsWatcher.on('error', (error) => {
203
+ this.fsWatcher?.close();
204
+ this.fsWatcher = null;
205
+ this.options.onError?.(error);
206
+ });
183
207
  }
184
208
  catch {
185
209
  // Polling still covers filesystems where fs.watch is unavailable.
@@ -5,26 +5,20 @@
5
5
  import type { ProviderId } from '../providers/types';
6
6
  import type { SessionWatcher, SessionWatcherCallbacks } from './types';
7
7
  export declare class JsonlSessionWatcher implements SessionWatcher {
8
- private _isActive;
9
- private filePosition;
10
- private fsWatcher;
11
- private catchupTimer;
12
- private debounceTimer;
13
- private codexInPlanMode;
14
- private readonly parser;
15
8
  private readonly providerId;
16
9
  private readonly sessionPath;
17
10
  private readonly callbacks;
11
+ private tail;
12
+ private startOffset;
13
+ private codexInPlanMode;
18
14
  constructor(providerId: ProviderId, sessionPath: string, callbacks: SessionWatcherCallbacks);
19
15
  get isActive(): boolean;
20
- /** Seek to a byte offset. Must be called before start(). */
16
+ /** Seek to a committed byte offset before starting replay. */
21
17
  seekTo(position: number): void;
22
- /** Get the current byte position. */
18
+ /** Position after the last complete JSONL line. */
23
19
  getPosition(): number;
24
20
  start(replay: boolean): void;
25
21
  stop(): void;
26
- private debouncedRead;
27
- private readNewBytes;
28
22
  private handleRawEvent;
29
23
  private normalizeCodexEvent;
30
24
  }
@@ -3,47 +3,11 @@
3
3
  * JSONL session watcher for Claude Code and Codex.
4
4
  * Uses byte-offset tracking + fs.watch with debounce + catch-up polling.
5
5
  */
6
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
- if (k2 === undefined) k2 = k;
8
- var desc = Object.getOwnPropertyDescriptor(m, k);
9
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
- desc = { enumerable: true, get: function() { return m[k]; } };
11
- }
12
- Object.defineProperty(o, k2, desc);
13
- }) : (function(o, m, k, k2) {
14
- if (k2 === undefined) k2 = k;
15
- o[k2] = m[k];
16
- }));
17
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
- Object.defineProperty(o, "default", { enumerable: true, value: v });
19
- }) : function(o, v) {
20
- o["default"] = v;
21
- });
22
- var __importStar = (this && this.__importStar) || (function () {
23
- var ownKeys = function(o) {
24
- ownKeys = Object.getOwnPropertyNames || function (o) {
25
- var ar = [];
26
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
- return ar;
28
- };
29
- return ownKeys(o);
30
- };
31
- return function (mod) {
32
- if (mod && mod.__esModule) return mod;
33
- var result = {};
34
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
- __setModuleDefault(result, mod);
36
- return result;
37
- };
38
- })();
39
6
  Object.defineProperty(exports, "__esModule", { value: true });
40
7
  exports.JsonlSessionWatcher = void 0;
41
- const fs = __importStar(require("fs"));
42
- const jsonl_1 = require("../parsers/jsonl");
8
+ const jsonlTail_1 = require("./jsonlTail");
43
9
  const codexParser_1 = require("../parsers/codexParser");
44
10
  const toolSummary_1 = require("../formatters/toolSummary");
45
- const DEBOUNCE_MS = 100;
46
- const CATCHUP_INTERVAL_MS = 30_000;
47
11
  // ── Normalizers ──
48
12
  function normalizeClaudeCodeEvent(raw) {
49
13
  const events = [];
@@ -220,139 +184,46 @@ function truncate(text, maxLen) {
220
184
  }
221
185
  // ── Watcher ──
222
186
  class JsonlSessionWatcher {
223
- _isActive = false;
224
- filePosition = 0;
225
- fsWatcher = null;
226
- catchupTimer = null;
227
- debounceTimer = null;
228
- codexInPlanMode = false;
229
- parser;
230
187
  providerId;
231
188
  sessionPath;
232
189
  callbacks;
190
+ tail;
191
+ startOffset;
192
+ codexInPlanMode = false;
233
193
  constructor(providerId, sessionPath, callbacks) {
234
194
  this.providerId = providerId;
235
195
  this.sessionPath = sessionPath;
236
196
  this.callbacks = callbacks;
237
- this.parser = new jsonl_1.JsonlParser({
238
- onEvent: (event) => this.handleRawEvent(event),
239
- onError: (err) => callbacks.onError?.(err),
240
- });
241
197
  }
242
198
  get isActive() {
243
- return this._isActive;
199
+ return this.tail?.isActive ?? false;
244
200
  }
245
- /** Seek to a byte offset. Must be called before start(). */
201
+ /** Seek to a committed byte offset before starting replay. */
246
202
  seekTo(position) {
247
- this.filePosition = position;
248
- this.parser.reset();
203
+ this.startOffset = Math.max(0, position);
204
+ this.tail?.seekTo(this.startOffset);
249
205
  }
250
- /** Get the current byte position. */
206
+ /** Position after the last complete JSONL line. */
251
207
  getPosition() {
252
- return this.filePosition;
208
+ return this.tail?.getOffset() ?? this.startOffset ?? 0;
253
209
  }
254
210
  start(replay) {
255
- if (this._isActive)
211
+ if (this.isActive)
256
212
  return;
257
- this._isActive = true;
258
- if (replay) {
259
- // Read entire file from offset 0
260
- this.readNewBytes();
261
- }
262
- else {
263
- // Skip to end of file
264
- try {
265
- const stat = fs.statSync(this.sessionPath);
266
- this.filePosition = stat.size;
267
- }
268
- catch {
269
- this.filePosition = 0;
270
- }
271
- }
272
- // Watch for changes
273
- try {
274
- this.fsWatcher = fs.watch(this.sessionPath, { persistent: false }, () => {
275
- this.debouncedRead();
276
- });
277
- this.fsWatcher.on('error', (error) => {
278
- // Degrade to catch-up polling without killing the live session.
279
- this.fsWatcher?.close();
280
- this.fsWatcher = null;
281
- this.callbacks.onError?.(error);
282
- });
283
- }
284
- catch {
285
- // fs.watch not available, rely on polling only
286
- }
287
- // Catch-up polling
288
- this.catchupTimer = setInterval(() => {
289
- if (this._isActive)
290
- this.readNewBytes();
291
- }, CATCHUP_INTERVAL_MS);
213
+ this.tail = (0, jsonlTail_1.createJsonlTail)({
214
+ path: this.sessionPath,
215
+ startOffset: replay ? this.getPosition() : undefined,
216
+ startAtEnd: !replay,
217
+ onEvent: (event) => this.handleRawEvent(event),
218
+ onBatchComplete: () => this.callbacks.onBatchComplete?.(),
219
+ onError: (error) => this.callbacks.onError?.(error),
220
+ });
221
+ this.tail.start();
292
222
  }
293
223
  stop() {
294
- if (!this._isActive)
295
- return;
296
- this._isActive = false;
297
- if (this.debounceTimer) {
298
- clearTimeout(this.debounceTimer);
299
- this.debounceTimer = null;
300
- }
301
- if (this.fsWatcher) {
302
- this.fsWatcher.close();
303
- this.fsWatcher = null;
304
- }
305
- if (this.catchupTimer) {
306
- clearInterval(this.catchupTimer);
307
- this.catchupTimer = null;
308
- }
309
- this.parser.flush();
224
+ this.tail?.stop();
310
225
  this.codexInPlanMode = false;
311
226
  }
312
- debouncedRead() {
313
- if (this.debounceTimer)
314
- clearTimeout(this.debounceTimer);
315
- this.debounceTimer = setTimeout(() => {
316
- this.readNewBytes();
317
- }, DEBOUNCE_MS);
318
- }
319
- readNewBytes() {
320
- if (!this._isActive)
321
- return;
322
- let fd = null;
323
- try {
324
- const stat = fs.statSync(this.sessionPath);
325
- // Handle file truncation (shouldn't happen with append-only, but be safe)
326
- if (stat.size < this.filePosition) {
327
- this.filePosition = 0;
328
- this.parser.reset();
329
- }
330
- if (stat.size <= this.filePosition)
331
- return;
332
- fd = fs.openSync(this.sessionPath, 'r');
333
- const bytesToRead = stat.size - this.filePosition;
334
- const buffer = Buffer.alloc(bytesToRead);
335
- const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, this.filePosition);
336
- fs.closeSync(fd);
337
- fd = null;
338
- if (bytesRead > 0) {
339
- this.filePosition += bytesRead;
340
- const chunk = buffer.toString('utf-8', 0, bytesRead);
341
- this.parser.processChunk(chunk);
342
- }
343
- }
344
- catch (err) {
345
- if (fd !== null) {
346
- try {
347
- fs.closeSync(fd);
348
- }
349
- catch {
350
- /* ignore */
351
- }
352
- }
353
- this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err)));
354
- }
355
- }
356
227
  handleRawEvent(event) {
357
228
  // For codex, raw events don't match RawSessionEvent shape exactly, but
358
229
  // JsonlParser parses any JSON object. Cast to Record for codex normalizer.
@@ -112,12 +112,13 @@ class ProviderReaderSessionWatcher {
112
112
  clearInterval(this.catchupTimer);
113
113
  this.catchupTimer = null;
114
114
  }
115
- this.reader.flush();
115
+ // Keep unfinished input buffered so a later start can resume losslessly.
116
116
  }
117
117
  seekToEnd() {
118
118
  try {
119
- const stat = fs.statSync(this.sessionPath);
120
- this.reader.seekTo(stat.size);
119
+ // Consume existing complete records to retain parser context and any
120
+ // unfinished line, without emitting historical events.
121
+ this.reader.readNew();
121
122
  }
122
123
  catch {
123
124
  this.reader.reset();
@@ -143,6 +144,7 @@ class ProviderReaderSessionWatcher {
143
144
  this.callbacks.onEvent(followEvent);
144
145
  }
145
146
  }
147
+ this.callbacks.onBatchComplete?.();
146
148
  }
147
149
  catch (err) {
148
150
  this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err)));
@@ -312,6 +312,7 @@ class SqliteSessionWatcher {
312
312
  if (part.time_updated > this.lastPartTime)
313
313
  this.lastPartTime = part.time_updated;
314
314
  }
315
+ this.callbacks.onBatchComplete?.();
315
316
  }
316
317
  catch (err) {
317
318
  this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err)));
@@ -46,6 +46,8 @@ export interface FollowEvent {
46
46
  }
47
47
  export interface SessionWatcherCallbacks {
48
48
  onEvent: (event: FollowEvent) => void;
49
+ /** All events in this read have been delivered; the cursor can now be checkpointed. */
50
+ onBatchComplete?: () => void;
49
51
  onError?: (error: Error) => void;
50
52
  }
51
53
  export interface SessionWatcher {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidekick-shared",
3
- "version": "0.26.1",
3
+ "version": "0.26.2",
4
4
  "description": "Shared data access layer for Sidekick — readers, types, providers, credentials, quota",
5
5
  "keywords": [
6
6
  "claude-code",