claude-code-session-manager 0.21.1 → 0.21.3

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 (54) hide show
  1. package/bin/cli.cjs +5 -0
  2. package/dist/assets/{TiptapBody-C46DacIO.js → TiptapBody-PdmsfUCQ.js} +2 -2
  3. package/dist/assets/cssMode-DfqZGMQs.js +1 -0
  4. package/dist/assets/{freemarker2-BxIPNQn-.js → freemarker2-XTPYh37h.js} +1 -1
  5. package/dist/assets/handlebars-DKUF5VyH.js +1 -0
  6. package/dist/assets/html-uqoqsIeI.js +1 -0
  7. package/dist/assets/htmlMode-aMTQs1su.js +1 -0
  8. package/dist/assets/index-DO3ROR11.js +3525 -0
  9. package/dist/assets/index-DeQI4oVI.css +32 -0
  10. package/dist/assets/javascript-BVxRZMds.js +1 -0
  11. package/dist/assets/{jsonMode-1FAJaHiX.js → jsonMode-D04xP2s5.js} +4 -4
  12. package/dist/assets/liquid-BkQHTH2P.js +1 -0
  13. package/dist/assets/lspLanguageFeatures-By9uLznH.js +4 -0
  14. package/dist/assets/mdx-Du1IlbjV.js +1 -0
  15. package/dist/assets/{index-oGyPFfYZ.css → monaco-editor-BTnBOi8r.css} +1 -32
  16. package/dist/assets/monaco-editor-BW5C4Iv1.js +908 -0
  17. package/dist/assets/python-DSlImqXd.js +1 -0
  18. package/dist/assets/razor-BmUVyvSK.js +1 -0
  19. package/dist/assets/{tsMode-CLQIVays.js → tsMode-Btj0TTH7.js} +1 -1
  20. package/dist/assets/typescript-Bzelq9vO.js +1 -0
  21. package/dist/assets/xml-Whd9EaSd.js +1 -0
  22. package/dist/assets/yaml-QYf0-IN8.js +1 -0
  23. package/dist/index.html +4 -2
  24. package/package.json +1 -1
  25. package/src/main/__tests__/runVerify.test.cjs +101 -0
  26. package/src/main/config.cjs +36 -4
  27. package/src/main/historyAggregator.cjs +400 -149
  28. package/src/main/index.cjs +8 -0
  29. package/src/main/ipcSchemas.cjs +42 -13
  30. package/src/main/kg.cjs +87 -30
  31. package/src/main/lib/credentials.cjs +7 -0
  32. package/src/main/lib/e2eStateMachine.cjs +39 -0
  33. package/src/main/runVerify.cjs +28 -5
  34. package/src/main/scheduler/prdParser.cjs +16 -1
  35. package/src/main/scheduler.cjs +97 -13
  36. package/src/main/transcripts.cjs +141 -19
  37. package/src/main/usageMatrix.cjs +7 -3
  38. package/src/main/webRemote.cjs +190 -29
  39. package/src/preload/api.d.ts +40 -0
  40. package/src/preload/index.cjs +7 -0
  41. package/dist/assets/cssMode-CauFS5Bp.js +0 -1
  42. package/dist/assets/handlebars-DnEVFUsu.js +0 -1
  43. package/dist/assets/html-S8NXUTqc.js +0 -1
  44. package/dist/assets/htmlMode-rSEyII9x.js +0 -1
  45. package/dist/assets/index-DMobTczM.js +0 -4431
  46. package/dist/assets/javascript-BiWR68QP.js +0 -1
  47. package/dist/assets/liquid-CEtOkbwI.js +0 -1
  48. package/dist/assets/lspLanguageFeatures-CRF3U0x3.js +0 -4
  49. package/dist/assets/mdx-C7C95Bzt.js +0 -1
  50. package/dist/assets/python-CXvKcjLk.js +0 -1
  51. package/dist/assets/razor-tzZHfRy2.js +0 -1
  52. package/dist/assets/typescript-LxhyM9W2.js +0 -1
  53. package/dist/assets/xml-VS_m20VE.js +0 -1
  54. package/dist/assets/yaml-BsjggdVD.js +0 -1
@@ -43,6 +43,44 @@ function transcriptPath(cwd, sessionUuid) {
43
43
  return path.join(os.homedir(), '.claude', 'projects', encodeCwd(cwd), `${sessionUuid}.jsonl`);
44
44
  }
45
45
 
46
+ const MAX_RAW_STR = 4096;
47
+
48
+ // Block types whose text/content fields are parsed structurally by
49
+ // orchestrator.ts / race.ts — truncating them produces mid-token "…" and
50
+ // unparseable JSON, so they are exempt from the size cap.
51
+ const EXEMPT_TYPES = new Set(['tool_result', 'tool_use']);
52
+
53
+ /**
54
+ * Cap string fields in a content block array so arbitrary tool output doesn't
55
+ * bloat the ring buffer. Blocks whose type is in EXEMPT_TYPES are passed
56
+ * through intact so that structured result payloads survive to the digest
57
+ * parsers in race.ts / orchestrator.ts.
58
+ */
59
+ function trimContentArray(content) {
60
+ if (!Array.isArray(content)) return content;
61
+ return content.map((block) => {
62
+ if (!block || typeof block !== 'object') return block;
63
+ if (EXEMPT_TYPES.has(block.type)) return block;
64
+ const b = { ...block };
65
+ if (typeof b.text === 'string' && b.text.length > MAX_RAW_STR) {
66
+ b.text = b.text.slice(0, MAX_RAW_STR) + '…';
67
+ }
68
+ if (typeof b.content === 'string' && b.content.length > MAX_RAW_STR) {
69
+ b.content = b.content.slice(0, MAX_RAW_STR) + '…';
70
+ }
71
+ if (Array.isArray(b.content)) {
72
+ b.content = trimContentArray(b.content);
73
+ }
74
+ return b;
75
+ });
76
+ }
77
+
78
+ /** Build the slim raw projection used by race.ts and orchestrator.ts. */
79
+ function makeRaw(obj) {
80
+ const msgContent = obj?.message?.content;
81
+ return { message: { content: trimContentArray(msgContent) } };
82
+ }
83
+
46
84
  /**
47
85
  * Parse one JSONL line defensively. Real schema drifts, so we pass through
48
86
  * anything that parses and tag a coarse `kind`.
@@ -56,7 +94,7 @@ function classifyLine(obj) {
56
94
 
57
95
  // Usage rollups arrive as summary events.
58
96
  if (obj.usage || msg?.usage) {
59
- return { kind: 'usage', data: obj.usage || msg.usage, raw: obj };
97
+ return { kind: 'usage', data: obj.usage || msg.usage, raw: makeRaw(obj) };
60
98
  }
61
99
 
62
100
  // Tool uses: scan content array for tool_use blocks.
@@ -64,31 +102,31 @@ function classifyLine(obj) {
64
102
  for (const block of content) {
65
103
  if (block?.type === 'tool_use') {
66
104
  if (block.name === 'TodoWrite') {
67
- return { kind: 'todo_write', data: block.input?.todos || block.input || [], raw: obj };
105
+ return { kind: 'todo_write', data: block.input?.todos || block.input || [], raw: makeRaw(obj) };
68
106
  }
69
107
  if (block.name === 'ExitPlanMode' || block.name === 'EnterPlanMode') {
70
- return { kind: 'plan', data: block.input, raw: obj };
108
+ return { kind: 'plan', data: block.input, raw: makeRaw(obj) };
71
109
  }
72
110
  if (block.name === 'Agent' || block.name === 'Task') {
73
111
  // Include block.id as toolUseId so the live store can match the
74
112
  // corresponding tool_result and update per-agent lastActivityAt.
75
- return { kind: 'agent_spawn', data: { ...block.input, toolUseId: block.id }, raw: obj };
113
+ return { kind: 'agent_spawn', data: { ...block.input, toolUseId: block.id }, raw: makeRaw(obj) };
76
114
  }
77
115
  return {
78
116
  kind: 'tool_use',
79
117
  data: { name: block.name, input: block.input, id: block.id },
80
- raw: obj,
118
+ raw: makeRaw(obj),
81
119
  };
82
120
  }
83
121
  // tool_result carries the tool_use_id of the completed Task/Agent call.
84
122
  // The live store uses this to update the agent's lastActivityAt bookend.
85
123
  if (block?.type === 'tool_result' && block.tool_use_id) {
86
- return { kind: 'tool_result', data: { toolUseId: block.tool_use_id }, raw: obj };
124
+ return { kind: 'tool_result', data: { toolUseId: block.tool_use_id }, raw: makeRaw(obj) };
87
125
  }
88
126
  }
89
127
  }
90
128
 
91
- return { kind: type || 'message', data: obj, raw: obj };
129
+ return { kind: type || 'message', data: obj, raw: makeRaw(obj) };
92
130
  }
93
131
 
94
132
  /**
@@ -129,7 +167,7 @@ async function readDelta(sub) {
129
167
  }
130
168
  }
131
169
 
132
- async function flush(sub, { emit = true } = {}) {
170
+ async function doFlush(sub, { emit = true, replay = false } = {}) {
133
171
  const lines = await readDelta(sub);
134
172
  for (const line of lines) {
135
173
  let obj;
@@ -150,6 +188,7 @@ async function flush(sub, { emit = true } = {}) {
150
188
  cwd: sub.cwd,
151
189
  sessionUuid: sub.sessionUuid,
152
190
  ev,
191
+ replay,
153
192
  });
154
193
  if (emit) sendIfAlive(window, `transcript:event:${sub.tabId}`, ev);
155
194
  // Mirror to OTEL — no-op when disabled. We emit on the initial drain too
@@ -164,10 +203,86 @@ async function flush(sub, { emit = true } = {}) {
164
203
  }
165
204
  }
166
205
 
206
+ // Serialised flush scheduler — at most one readDelta per sub in flight at a
207
+ // time. Uses a dirty flag for trailing-edge re-run: if a chokidar event fires
208
+ // while a flush is in progress, dirty stays true and the loop runs one more
209
+ // time after the current read completes, guaranteeing no event is dropped.
210
+ function scheduleFlush(sub) {
211
+ sub.dirty = true;
212
+ if (sub.flushing) return sub.flushing;
213
+ sub.flushing = (async () => {
214
+ while (sub.dirty) {
215
+ sub.dirty = false;
216
+ await doFlush(sub);
217
+ }
218
+ })()
219
+ .catch((e) => {
220
+ logs.writeLine({ level: 'warn', scope: 'transcripts', message: 'flush error', meta: { error: e?.message } });
221
+ })
222
+ .finally(() => {
223
+ sub.flushing = null;
224
+ });
225
+ return sub.flushing;
226
+ }
227
+
167
228
  const MAX_TRANSCRIPT_SUBS = 20;
168
229
 
230
+ /**
231
+ * LRU pool of released-but-cached subscriptions. When a renderer consumer
232
+ * calls release(), the sub stays alive (offset + buffer preserved) so a
233
+ * subsequent tab-switch back resumes from the current offset instead of
234
+ * re-reading the entire transcript from byte 0. Oldest entries are evicted
235
+ * once the pool exceeds LRU_CAP.
236
+ */
237
+ const LRU_CAP = 6;
238
+ const lruReleased = []; // tabIds with no active consumer, ordered oldest→newest
239
+
240
+ function _closeSub(tabId) {
241
+ const sub = subs.get(tabId);
242
+ if (!sub) return;
243
+ sub.watcher?.close().catch(() => {});
244
+ subs.delete(tabId);
245
+ usageMatrix.removeTab(tabId);
246
+ const i = lruReleased.indexOf(tabId);
247
+ if (i !== -1) lruReleased.splice(i, 1);
248
+ }
249
+
250
+ /**
251
+ * release(tabId) — called when the renderer's last consumer unmounts (view
252
+ * switch). Keeps the sub alive in the LRU cache so a quick revisit resumes
253
+ * from the persisted offset. Evicts the oldest cached sub if over LRU_CAP.
254
+ */
255
+ function release(tabId) {
256
+ if (!subs.has(tabId)) return;
257
+ if (!lruReleased.includes(tabId)) {
258
+ lruReleased.push(tabId);
259
+ }
260
+ while (lruReleased.length > LRU_CAP) {
261
+ const oldest = lruReleased.shift();
262
+ _closeSub(oldest);
263
+ }
264
+ }
265
+
266
+ /** closeTab(tabId) — genuine tab close; always destroys the sub immediately. */
267
+ function closeTab(tabId) {
268
+ _closeSub(tabId);
269
+ }
270
+
169
271
  async function subscribe({ tabId, cwd, sessionUuid }) {
170
- if (subs.has(tabId)) return { ok: true, path: subs.get(tabId).filePath };
272
+ if (subs.has(tabId)) {
273
+ // Tab is in the LRU cache — promote it back to active.
274
+ const i = lruReleased.indexOf(tabId);
275
+ if (i !== -1) lruReleased.splice(i, 1);
276
+ return { ok: true, path: subs.get(tabId).filePath };
277
+ }
278
+ if (subs.size >= MAX_TRANSCRIPT_SUBS) {
279
+ // Before rejecting a genuinely new subscription, evict an idle LRU-cached
280
+ // entry — it occupies a slot but has no active consumer. Only reject if no
281
+ // idle entries are available to free.
282
+ if (lruReleased.length > 0) {
283
+ _closeSub(lruReleased[0]);
284
+ }
285
+ }
171
286
  if (subs.size >= MAX_TRANSCRIPT_SUBS) {
172
287
  logs.writeLine({
173
288
  level: 'warn',
@@ -189,34 +304,33 @@ async function subscribe({ tabId, cwd, sessionUuid }) {
189
304
  pending: '',
190
305
  buffer: [],
191
306
  watcher: null,
307
+ flushing: null,
308
+ dirty: false,
192
309
  };
193
310
  // If the file already exists, read current content as replay. Do not emit
194
311
  // during this initial drain — the renderer drains sub.buffer via
195
312
  // `transcript:buffer` after `transcript:subscribe` resolves. Emitting here
196
313
  // would race the renderer's onEvent listener registration and drop events.
314
+ // replay:true prevents historical usage events from entering the 5-min window.
197
315
  if (fs.existsSync(filePath)) {
198
- await flush(sub, { emit: false });
316
+ await doFlush(sub, { emit: false, replay: true });
199
317
  }
200
318
  const watcher = chokidar.watch(filePath, {
201
319
  ignoreInitial: false,
202
320
  persistent: true,
203
321
  awaitWriteFinish: { stabilityThreshold: 30, pollInterval: 20 },
204
322
  });
205
- watcher.on('add', () => flush(sub).catch(() => {}));
206
- watcher.on('change', () => flush(sub).catch(() => {}));
323
+ watcher.on('add', () => scheduleFlush(sub));
324
+ watcher.on('change', () => scheduleFlush(sub));
207
325
  watcher.on('error', (err) => logs.writeLine({ level: 'warn', scope: 'transcripts', message: 'chokidar watcher error', meta: { error: err?.message } }));
208
326
  sub.watcher = watcher;
209
327
  subs.set(tabId, sub);
210
328
  return { ok: true, path: filePath };
211
329
  }
212
330
 
331
+ /** @deprecated Use release() for view-switch, closeTab() for genuine close. */
213
332
  function unsubscribe(tabId) {
214
- const sub = subs.get(tabId);
215
- if (!sub) return;
216
- sub.watcher?.close().catch(() => {});
217
- subs.delete(tabId);
218
- // Drop the tab from the AgOps matrix — "active sessions" only.
219
- usageMatrix.removeTab(tabId);
333
+ release(tabId);
220
334
  }
221
335
 
222
336
  function getBuffer(tabId) {
@@ -233,8 +347,14 @@ function closeAll() {
233
347
  function registerTranscriptHandlers() {
234
348
  const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
235
349
  ipcMain.handle('transcript:subscribe', v(s.transcriptSubscribe, (payload) => subscribe(payload)));
350
+ // transcript:unsubscribe is now an alias for release (view-switch, not close).
236
351
  ipcMain.handle('transcript:unsubscribe', v(s.transcriptTabId, ({ tabId }) => {
237
- unsubscribe(tabId);
352
+ release(tabId);
353
+ return { ok: true };
354
+ }));
355
+ // transcript:close is the genuine close used when a tab is removed.
356
+ ipcMain.handle('transcript:close', v(s.transcriptTabId, ({ tabId }) => {
357
+ closeTab(tabId);
238
358
  return { ok: true };
239
359
  }));
240
360
  ipcMain.handle('transcript:buffer', v(s.transcriptTabId, ({ tabId }) => getBuffer(tabId)));
@@ -245,6 +365,8 @@ module.exports = {
245
365
  attachWindow,
246
366
  registerTranscriptHandlers,
247
367
  closeAll,
368
+ release,
369
+ closeTab,
248
370
  encodeCwd,
249
371
  transcriptPath,
250
372
  classifyLine,
@@ -85,7 +85,7 @@ function ensureTab(tabId, cwd, sessionUuid) {
85
85
  * Feed one classified transcript event into the per-tab aggregator. Called
86
86
  * from transcripts.cjs for every event, both during replay and live.
87
87
  */
88
- function recordEvent({ tabId, cwd, sessionUuid, ev }) {
88
+ function recordEvent({ tabId, cwd, sessionUuid, ev, replay = false }) {
89
89
  if (!tabId || !ev) return;
90
90
  const t = ensureTab(tabId, cwd, sessionUuid);
91
91
  const now = Date.now();
@@ -109,8 +109,12 @@ function recordEvent({ tabId, cwd, sessionUuid, ev }) {
109
109
  t.perTurnInputTokens.push(inTok);
110
110
  if (t.perTurnInputTokens.length > TURN_RING) t.perTurnInputTokens.shift();
111
111
 
112
- t.tokenWindow.push({ ts: now, tokens: inTok + outTok });
113
- pruneWindow(t.tokenWindow, now);
112
+ // Historical events must not enter the 5-min sliding window — doing so
113
+ // would make tokensPerMin spike to "critical" on every tab switch.
114
+ if (!replay) {
115
+ t.tokenWindow.push({ ts: now, tokens: inTok + outTok });
116
+ pruneWindow(t.tokenWindow, now);
117
+ }
114
118
  dirty = true;
115
119
  break;
116
120
  }