sidekick-shared 0.29.1 → 0.30.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 (109) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +171 -60
  3. package/dist/aggregation/EventAggregator.d.ts +79 -4
  4. package/dist/aggregation/EventAggregator.js +391 -37
  5. package/dist/aggregation/types.d.ts +27 -3
  6. package/dist/browser.d.ts +7 -3
  7. package/dist/browser.js +23 -3
  8. package/dist/context/sessionContext.js +5 -1
  9. package/dist/esm/aggregation/EventAggregator.js +392 -38
  10. package/dist/esm/browser.js +3 -1
  11. package/dist/esm/context/sessionContext.js +6 -2
  12. package/dist/esm/fileContinuity.js +149 -0
  13. package/dist/esm/index.js +5 -2
  14. package/dist/esm/node.js +2 -0
  15. package/dist/esm/parsers/codexParser.js +192 -3
  16. package/dist/esm/parsers/subagentEndState.js +61 -0
  17. package/dist/esm/parsers/subagentScanner.js +4 -0
  18. package/dist/esm/parsers/subagentTraceParser.js +6 -0
  19. package/dist/esm/promptHistory.js +22 -11
  20. package/dist/esm/providers/claudeCode.js +50 -27
  21. package/dist/esm/providers/codex.js +153 -163
  22. package/dist/esm/providers/codexCompressedReader.js +184 -0
  23. package/dist/esm/providers/codexDatabase.js +47 -2
  24. package/dist/esm/providers/codexRollouts.js +20 -2
  25. package/dist/esm/providers/codexSubagents.js +456 -0
  26. package/dist/esm/providers/compressedRollouts.js +330 -0
  27. package/dist/esm/providers/factory.js +4 -1
  28. package/dist/esm/providers/rolloutWalker.js +32 -4
  29. package/dist/esm/schemas/sessionEvent.js +32 -4
  30. package/dist/esm/sessionMonitor.js +7 -1
  31. package/dist/esm/sessionPreviews.js +9 -0
  32. package/dist/esm/sessionPromptHistory.js +5 -3
  33. package/dist/esm/subagentActivity.js +125 -0
  34. package/dist/esm/subagentDisplayState.js +81 -0
  35. package/dist/esm/subagentSpawn.js +206 -6
  36. package/dist/esm/testing/codexSubagentFixtures.js +336 -0
  37. package/dist/esm/toolCallOrigins.js +17 -10
  38. package/dist/esm/toolCategory.js +6 -2
  39. package/dist/esm/transcript.js +6 -2
  40. package/dist/esm/turns/assistantTurn.js +6 -3
  41. package/dist/esm/usage/usageEvents.js +4 -1
  42. package/dist/esm/watchers/eventBridge.js +31 -1
  43. package/dist/esm/watchers/jsonlTail.js +11 -42
  44. package/dist/esm/watchers/jsonlWatcher.js +19 -9
  45. package/dist/esm/watchers/providerReaderWatcher.js +5 -4
  46. package/dist/fileContinuity.d.ts +95 -0
  47. package/dist/fileContinuity.js +187 -0
  48. package/dist/index.d.ts +14 -7
  49. package/dist/index.js +39 -10
  50. package/dist/node.d.ts +4 -0
  51. package/dist/node.js +16 -2
  52. package/dist/parsers/codexParser.d.ts +44 -0
  53. package/dist/parsers/codexParser.js +192 -3
  54. package/dist/parsers/subagentEndState.d.ts +20 -0
  55. package/dist/parsers/subagentEndState.js +65 -0
  56. package/dist/parsers/subagentScanner.js +4 -0
  57. package/dist/parsers/subagentTraceParser.d.ts +8 -0
  58. package/dist/parsers/subagentTraceParser.js +6 -0
  59. package/dist/promptHistory.d.ts +13 -3
  60. package/dist/promptHistory.js +22 -11
  61. package/dist/providers/claudeCode.js +50 -27
  62. package/dist/providers/codex.d.ts +35 -5
  63. package/dist/providers/codex.js +150 -160
  64. package/dist/providers/codexCompressedReader.d.ts +58 -0
  65. package/dist/providers/codexCompressedReader.js +221 -0
  66. package/dist/providers/codexDatabase.d.ts +25 -0
  67. package/dist/providers/codexDatabase.js +48 -2
  68. package/dist/providers/codexRollouts.d.ts +16 -2
  69. package/dist/providers/codexRollouts.js +20 -2
  70. package/dist/providers/codexSubagents.d.ts +66 -0
  71. package/dist/providers/codexSubagents.js +495 -0
  72. package/dist/providers/compressedRollouts.d.ts +114 -0
  73. package/dist/providers/compressedRollouts.js +380 -0
  74. package/dist/providers/factory.d.ts +5 -0
  75. package/dist/providers/factory.js +4 -1
  76. package/dist/providers/rolloutWalker.d.ts +15 -2
  77. package/dist/providers/rolloutWalker.js +33 -4
  78. package/dist/providers/types.d.ts +22 -1
  79. package/dist/schemas/sessionEvent.d.ts +26 -2
  80. package/dist/schemas/sessionEvent.js +31 -3
  81. package/dist/sessionMonitor.d.ts +6 -0
  82. package/dist/sessionMonitor.js +7 -1
  83. package/dist/sessionPreviews.js +9 -0
  84. package/dist/sessionPromptHistory.d.ts +5 -0
  85. package/dist/sessionPromptHistory.js +5 -36
  86. package/dist/subagentActivity.d.ts +91 -0
  87. package/dist/subagentActivity.js +132 -0
  88. package/dist/subagentDisplayState.d.ts +81 -0
  89. package/dist/subagentDisplayState.js +86 -0
  90. package/dist/subagentSpawn.d.ts +100 -5
  91. package/dist/subagentSpawn.js +218 -7
  92. package/dist/testing/codexSubagentFixtures.d.ts +48 -0
  93. package/dist/testing/codexSubagentFixtures.js +344 -0
  94. package/dist/toolCallOrigins.d.ts +5 -0
  95. package/dist/toolCallOrigins.js +17 -10
  96. package/dist/toolCategory.js +6 -2
  97. package/dist/transcript.js +5 -1
  98. package/dist/turns/assistantTurn.d.ts +4 -1
  99. package/dist/turns/assistantTurn.js +5 -2
  100. package/dist/types/codex.d.ts +71 -2
  101. package/dist/types/sessionEvent.d.ts +38 -3
  102. package/dist/usage/usageEvents.d.ts +4 -1
  103. package/dist/usage/usageEvents.js +4 -1
  104. package/dist/watchers/eventBridge.js +30 -0
  105. package/dist/watchers/jsonlTail.js +11 -42
  106. package/dist/watchers/jsonlWatcher.d.ts +5 -1
  107. package/dist/watchers/jsonlWatcher.js +18 -8
  108. package/dist/watchers/providerReaderWatcher.js +5 -4
  109. package/package.json +1 -1
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Session reader for a compressed Codex rollout (`rollout-…jsonl.zst`).
3
+ *
4
+ * Codex compresses only rollouts it no longer writes, so the content is
5
+ * decompressed and parsed whole on the first read; later reads cost one
6
+ * stat and return nothing until the file changes (then the rollout is read
7
+ * again from the start, reported as `replaced`). Positions are offsets in
8
+ * the decompressed content. Live tailing stays with the `.jsonl` reader.
9
+ *
10
+ * Node-only: reads files.
11
+ *
12
+ * @module providers/codexCompressedReader
13
+ */
14
+ import * as fs from 'fs';
15
+ import { CodexRolloutParser } from '../parsers/codexParser';
16
+ import { normalizeProviderUsage } from '../usageNormalization';
17
+ import { readRolloutBuffer } from './compressedRollouts';
18
+ /** Reads a `.jsonl.zst` rollout through a {@link CodexRolloutParser}. */
19
+ export class CompressedCodexRolloutReader {
20
+ rolloutPath;
21
+ callbacks;
22
+ parser = new CodexRolloutParser({ includeSubagentActivity: true });
23
+ /** Offset after the last line parsed (the content is complete, so its end). */
24
+ position = 0;
25
+ /** Size and mtime of the compressed file the position refers to. */
26
+ source = null;
27
+ reset_ = null;
28
+ /** Position from `seekTo()` whose prefix still has to be parsed for context. */
29
+ pendingSeek = null;
30
+ constructor(rolloutPath, callbacks = {}) {
31
+ this.rolloutPath = rolloutPath;
32
+ this.callbacks = callbacks;
33
+ }
34
+ getSessionMeta() {
35
+ this.settleSeek();
36
+ return this.parser.getSessionMeta();
37
+ }
38
+ readNew() {
39
+ this.settleSeek();
40
+ this.reset_ = null;
41
+ return this.readFrom(true);
42
+ }
43
+ readAll() {
44
+ this.reset();
45
+ return this.readNew();
46
+ }
47
+ reset() {
48
+ this.parser.reset();
49
+ this.position = 0;
50
+ this.source = null;
51
+ this.reset_ = null;
52
+ this.pendingSeek = null;
53
+ }
54
+ exists() {
55
+ return fs.existsSync(this.rolloutPath);
56
+ }
57
+ flush() {
58
+ // Nothing is buffered: each read parses through the end of the content.
59
+ }
60
+ getPosition() {
61
+ return this.position;
62
+ }
63
+ seekTo(position) {
64
+ this.reset();
65
+ this.position = Math.max(0, position);
66
+ if (this.position > 0)
67
+ this.pendingSeek = this.position;
68
+ }
69
+ wasTruncated() {
70
+ return this.reset_ !== null;
71
+ }
72
+ lastReset() {
73
+ return this.reset_ ? { ...this.reset_ } : null;
74
+ }
75
+ getResumeState() {
76
+ this.settleSeek();
77
+ return this.parser.exportResumeState();
78
+ }
79
+ restoreResumeState(state) {
80
+ if (!this.parser.importResumeState(state))
81
+ return false;
82
+ this.pendingSeek = null;
83
+ this.propagateParserState();
84
+ return true;
85
+ }
86
+ /** Parses the content past `position`; `emit` false only rebuilds parser state. */
87
+ readFrom(emit, endPosition) {
88
+ let stat;
89
+ try {
90
+ stat = fs.statSync(this.rolloutPath);
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ const current = { size: stat.size, mtimeMs: stat.mtimeMs };
96
+ if (this.source &&
97
+ (this.source.size !== current.size || this.source.mtimeMs !== current.mtimeMs)) {
98
+ // Another file has the path: what was read no longer describes it.
99
+ this.reset_ = { reason: 'replaced', previousPosition: this.position };
100
+ this.parser.reset();
101
+ this.position = 0;
102
+ }
103
+ else if (this.source) {
104
+ return [];
105
+ }
106
+ const content = readRolloutBuffer(this.rolloutPath, {
107
+ onDiagnostic: this.callbacks.onDiagnostic,
108
+ });
109
+ if (!content)
110
+ return [];
111
+ this.source = current;
112
+ const end = Math.min(content.length, endPosition ?? content.length);
113
+ if (this.position > end) {
114
+ // The position sought lies past this content: start over.
115
+ this.reset_ = { reason: 'truncated', previousPosition: this.position };
116
+ this.parser.reset();
117
+ this.position = 0;
118
+ }
119
+ const events = [];
120
+ const text = content.toString('utf8', this.position, end);
121
+ for (const line of text.split('\n')) {
122
+ const trimmed = line.trim();
123
+ if (!trimmed)
124
+ continue;
125
+ try {
126
+ const converted = this.parser.convertLine(JSON.parse(trimmed));
127
+ if (emit)
128
+ events.push(...converted);
129
+ }
130
+ catch {
131
+ // Skip malformed lines.
132
+ }
133
+ }
134
+ this.position = end;
135
+ if (end < content.length)
136
+ this.source = null;
137
+ this.propagateParserState();
138
+ return events;
139
+ }
140
+ /** Rebuilds parser context for a pending `seekTo()` position. */
141
+ settleSeek() {
142
+ const position = this.pendingSeek;
143
+ if (position === null)
144
+ return;
145
+ this.pendingSeek = null;
146
+ this.parser.reset();
147
+ this.position = 0;
148
+ this.source = null;
149
+ this.readFrom(false, position);
150
+ }
151
+ /** Hands the parser's context window, rate limits, and last usage to the provider. */
152
+ propagateParserState() {
153
+ const { onContextWindowLimit, onRateLimits, onTokenUsage } = this.callbacks;
154
+ const window = this.parser.getModelContextWindow();
155
+ if (window && onContextWindowLimit) {
156
+ onContextWindowLimit(window, this.parser.getCurrentModel() ?? undefined);
157
+ }
158
+ const rateLimits = this.parser.getLastRateLimits();
159
+ if (rateLimits && onRateLimits)
160
+ onRateLimits(rateLimits);
161
+ const lastUsage = this.parser.getLastTokenUsage();
162
+ if (lastUsage && onTokenUsage) {
163
+ const normalized = normalizeProviderUsage({
164
+ semantics: 'openai',
165
+ provider: 'openai',
166
+ source: 'codex-reader-snapshot',
167
+ model: this.parser.getCurrentModel() || undefined,
168
+ inputTokens: lastUsage.input_tokens,
169
+ outputTokens: lastUsage.output_tokens,
170
+ cacheReadTokens: lastUsage.cached_input_tokens,
171
+ reasoningTokens: lastUsage.reasoning_output_tokens,
172
+ });
173
+ onTokenUsage({
174
+ inputTokens: normalized.uncachedInputTokens,
175
+ outputTokens: normalized.outputTokens,
176
+ cacheWriteTokens: normalized.cacheWriteTokens,
177
+ cacheReadTokens: normalized.cacheReadTokens,
178
+ reasoningTokens: normalized.reasoningTokens,
179
+ model: this.parser.getCurrentModel() || 'unknown',
180
+ timestamp: new Date(),
181
+ });
182
+ }
183
+ }
184
+ }
@@ -5,7 +5,7 @@
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import { execFile, execFileSync } from 'child_process';
8
- import { codexSpawnParentFromSource as spawnParentFromSource } from '../parsers/codexRolloutOrigin';
8
+ import { codexRolloutOrigin, codexSpawnParentFromSource as spawnParentFromSource, } from '../parsers/codexRolloutOrigin';
9
9
  /**
10
10
  * Ids per inlined `IN (…)` query. Values are passed inside a single argv
11
11
  * element to sqlite3, so an unbounded list would hit E2BIG on large stores.
@@ -13,6 +13,17 @@ import { codexSpawnParentFromSource as spawnParentFromSource } from '../parsers/
13
13
  const SQL_ID_CHUNK_SIZE = 400;
14
14
  /** Parent thread id of a spawned subagent's `source`; one rule shared with rollout parsing. */
15
15
  export { spawnParentFromSource };
16
+ /** Default levels {@link CodexDatabase.getSpawnedDescendantThreads} follows. */
17
+ export const DEFAULT_SUBAGENT_TREE_MAX_DEPTH = 8;
18
+ /**
19
+ * Whether a thread row is a subagent's rather than a session a person
20
+ * started: `thread_source: 'subagent'`, or a `{ subagent: … }` source.
21
+ */
22
+ export function isCodexSubagentThread(thread) {
23
+ if (thread.thread_source === 'subagent')
24
+ return true;
25
+ return codexRolloutOrigin({ source: thread.source }).isSubagent;
26
+ }
16
27
  export class CodexDatabase {
17
28
  dbPath;
18
29
  sqlite3Available = null;
@@ -141,8 +152,12 @@ export class CodexDatabase {
141
152
  threadCwd.startsWith(normalized + path.sep));
142
153
  });
143
154
  }
155
+ /**
156
+ * The workspace's most recently updated thread that a person started:
157
+ * subagent threads (which share their parent's cwd) are skipped.
158
+ */
144
159
  getMostRecentThread(cwd) {
145
- return this.getThreadsByCwd(cwd)[0] ?? null;
160
+ return this.getThreadsByCwd(cwd).find((thread) => !isCodexSubagentThread(thread)) ?? null;
146
161
  }
147
162
  getAllDistinctCwds() {
148
163
  return this.query('SELECT cwd, COUNT(*) as count, MAX(updated_at) as lastUpdated FROM threads GROUP BY cwd ORDER BY lastUpdated DESC');
@@ -195,6 +210,36 @@ export class CodexDatabase {
195
210
  const rows = this.query('SELECT * FROM threads WHERE source LIKE ? ORDER BY created_at ASC', [`%${parentId}%`]);
196
211
  return rows.filter((row) => spawnParentFromSource(row.source) === parentId);
197
212
  }
213
+ /**
214
+ * Every subagent thread below a session, level by level (one query per
215
+ * level, in chunks of ids), oldest first within a level: its children,
216
+ * their children, and so on up to `maxDepth` levels.
217
+ */
218
+ getSpawnedDescendantThreads(rootId, maxDepth = DEFAULT_SUBAGENT_TREE_MAX_DEPTH) {
219
+ if (!this.getThreadColumns().has('source'))
220
+ return [];
221
+ const found = [];
222
+ const seen = new Set([rootId]);
223
+ let level = [rootId];
224
+ for (let depth = 1; depth <= maxDepth && level.length > 0; depth++) {
225
+ const parents = new Set(level);
226
+ const next = [];
227
+ for (let i = 0; i < level.length; i += SQL_ID_CHUNK_SIZE) {
228
+ const chunk = level.slice(i, i + SQL_ID_CHUNK_SIZE);
229
+ const rows = this.query(`SELECT * FROM threads WHERE ${chunk.map(() => 'source LIKE ?').join(' OR ')} ORDER BY created_at ASC`, chunk.map((id) => `%${id}%`));
230
+ for (const row of rows) {
231
+ const parentId = spawnParentFromSource(row.source);
232
+ if (!parentId || !parents.has(parentId) || seen.has(row.id))
233
+ continue;
234
+ seen.add(row.id);
235
+ found.push({ thread: row, parentId, depth });
236
+ next.push(row.id);
237
+ }
238
+ }
239
+ level = next;
240
+ }
241
+ return found;
242
+ }
198
243
  /** Get the database file's mtime (ms epoch). Returns 0 if unavailable. */
199
244
  getDbMtime() {
200
245
  try {
@@ -9,15 +9,19 @@ import * as path from 'path';
9
9
  import { getCodexSessionsDirs } from '../codexPaths';
10
10
  import { DirectoryListingCache } from './directoryListingCache';
11
11
  import { walkRolloutFiles, walkRolloutFilesAsync } from './rolloutWalker';
12
+ import { isCompressedRolloutPath, readRolloutFirstLines, readRolloutHead, } from './compressedRollouts';
12
13
  /** Every directory across the monitored homes that may hold rollouts, archived ones included. */
13
14
  export function getSessionsDirs() {
14
15
  return getCodexSessionsDirs();
15
16
  }
16
17
  /**
17
18
  * Read the first N complete lines from a file using chunked reads.
18
- * Handles arbitrarily long lines (Codex session_meta can be 10KB+).
19
+ * Handles arbitrarily long lines (Codex session_meta can be 10KB+), and
20
+ * compressed rollouts (`.jsonl.zst`, decompressed when the runtime can).
19
21
  */
20
22
  export function readFirstLines(filePath, maxLines, maxBytes = 256 * 1024) {
23
+ if (isCompressedRolloutPath(filePath))
24
+ return readRolloutFirstLines(filePath, maxLines, maxBytes);
21
25
  const lines = [];
22
26
  let fd;
23
27
  try {
@@ -81,6 +85,14 @@ export async function readFirstLineAsync(rolloutPath) {
81
85
  // `instructions` and can far exceed a single chunk.
82
86
  const maxBytes = 256 * 1024;
83
87
  const chunkSize = 64 * 1024;
88
+ if (isCompressedRolloutPath(rolloutPath)) {
89
+ // Compressed rollouts are complete: their first line is final.
90
+ const head = readRolloutHead(rolloutPath, maxBytes);
91
+ if (head.length === 0)
92
+ return { line: null, complete: false };
93
+ const firstLine = head.toString('utf8').split('\n', 1)[0]?.trim();
94
+ return { line: firstLine || null, complete: true };
95
+ }
84
96
  let handle = null;
85
97
  try {
86
98
  handle = await fs.promises.open(rolloutPath, 'r');
@@ -198,6 +210,7 @@ export async function listCodexSessionFilesAsync(workspacePath, options = {}, ca
198
210
  cache,
199
211
  revalidateRecent: options.revalidateRecent,
200
212
  stats: options.stats,
213
+ includeCompressed: options.includeCompressed,
201
214
  });
202
215
  for (const file of files) {
203
216
  if (options.limit !== undefined && results.length >= options.limit)
@@ -234,5 +247,10 @@ export function findCodexRolloutFile(sessionId, options = {}) {
234
247
  ? getCodexSessionsDirs([options.codexHome])
235
248
  : getSessionsDirs();
236
249
  // Name-only filter, one walk across the homes, newest modification first.
237
- return walkRolloutFiles(sessionsDirs, { sessionId: normalizedId })[0]?.path ?? null;
250
+ const matches = walkRolloutFiles(sessionsDirs, {
251
+ sessionId: normalizedId,
252
+ includeCompressed: options.includeCompressed,
253
+ });
254
+ // A live `.jsonl` wins over a compressed copy found in another home.
255
+ return (matches.find((file) => !isCompressedRolloutPath(file.path)) ?? matches[0])?.path ?? null;
238
256
  }