imcodes 2026.4.1874-dev.1860 → 2026.4.1881-dev.1865

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 (28) hide show
  1. package/dist/shared/effort-levels.d.ts +4 -2
  2. package/dist/shared/effort-levels.d.ts.map +1 -1
  3. package/dist/shared/effort-levels.js +16 -2
  4. package/dist/shared/effort-levels.js.map +1 -1
  5. package/dist/src/context/materialization-coordinator.d.ts.map +1 -1
  6. package/dist/src/context/materialization-coordinator.js +94 -51
  7. package/dist/src/context/materialization-coordinator.js.map +1 -1
  8. package/dist/src/daemon/jsonl-parse-core.d.ts +79 -0
  9. package/dist/src/daemon/jsonl-parse-core.d.ts.map +1 -0
  10. package/dist/src/daemon/jsonl-parse-core.js +400 -0
  11. package/dist/src/daemon/jsonl-parse-core.js.map +1 -0
  12. package/dist/src/daemon/jsonl-parse-pool.d.ts +52 -0
  13. package/dist/src/daemon/jsonl-parse-pool.d.ts.map +1 -0
  14. package/dist/src/daemon/jsonl-parse-pool.js +173 -0
  15. package/dist/src/daemon/jsonl-parse-pool.js.map +1 -0
  16. package/dist/src/daemon/jsonl-parse-worker-bootstrap.mjs +30 -0
  17. package/dist/src/daemon/jsonl-parse-worker-types.d.ts +36 -0
  18. package/dist/src/daemon/jsonl-parse-worker-types.d.ts.map +1 -0
  19. package/dist/src/daemon/jsonl-parse-worker-types.js +2 -0
  20. package/dist/src/daemon/jsonl-parse-worker-types.js.map +1 -0
  21. package/dist/src/daemon/jsonl-parse-worker.d.ts +12 -0
  22. package/dist/src/daemon/jsonl-parse-worker.d.ts.map +1 -0
  23. package/dist/src/daemon/jsonl-parse-worker.js +47 -0
  24. package/dist/src/daemon/jsonl-parse-worker.js.map +1 -0
  25. package/dist/src/daemon/jsonl-watcher.d.ts.map +1 -1
  26. package/dist/src/daemon/jsonl-watcher.js +72 -412
  27. package/dist/src/daemon/jsonl-watcher.js.map +1 -1
  28. package/package.json +2 -2
@@ -21,8 +21,8 @@ import logger from '../util/logger.js';
21
21
  import { resolveContextWindow } from '../util/model-context.js';
22
22
  import { getSessionContextWindow } from './cc-presets.js';
23
23
  import { registerWatcherControl, unregisterWatcherControl } from './watcher-controls.js';
24
- import { TIMELINE_EVENT_FILE_CHANGE } from '../../shared/file-change.js';
25
- import { normalizeClaudeFileChange } from './file-change-normalizer.js';
24
+ import { createParseContext, forgetSession as forgetSessionInCtx, parseLines as parseLinesInCtx, } from './jsonl-parse-core.js';
25
+ import { jsonlParsePool, isJsonlWorkerEnabled } from './jsonl-parse-pool.js';
26
26
  // ── Path helpers ──────────────────────────────────────────────────────────────
27
27
  /** Compute a filesystem-safe project key from an absolute path.
28
28
  * Replaces / \ and : with '-'. Used by Claude Code and Codex watchers.
@@ -134,364 +134,26 @@ async function findLatestJsonl(dir) {
134
134
  withStats.sort((a, b) => b.mtime - a.mtime);
135
135
  return join(dir, withStats[0].f);
136
136
  }
137
- function asRecord(value) {
138
- return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
139
- }
140
- function extractToolInput(name, input) {
141
- if (!input)
142
- return '';
143
- if (name === 'Grep') {
144
- const pattern = input.pattern ?? input.query ?? input.text;
145
- const path = input.path ?? input.file_path ?? input.filePath;
146
- if (pattern && path)
147
- return `${String(pattern).split('\n')[0]} in ${String(path).split('\n')[0]}`;
148
- }
149
- const val = input.command
150
- ?? input.path
151
- ?? input.file_path
152
- ?? input.pattern
153
- ?? input.description
154
- ?? input.query
155
- ?? input.objective
156
- ?? input.text
157
- ?? '';
158
- const text = String(val);
159
- return text.split('\n')[0] ?? '';
160
- }
161
- const pendingClaudeToolCalls = new Map();
162
- function getPendingClaudeTools(sessionName) {
163
- let pending = pendingClaudeToolCalls.get(sessionName);
164
- if (!pending) {
165
- pending = new Map();
166
- pendingClaudeToolCalls.set(sessionName, pending);
167
- }
168
- return pending;
169
- }
170
- function rememberClaudeToolCall(sessionName, pending) {
171
- getPendingClaudeTools(sessionName).set(pending.id, pending);
172
- }
173
- function takeClaudeToolCall(sessionName, toolUseId) {
174
- if (!toolUseId)
175
- return undefined;
176
- const pending = pendingClaudeToolCalls.get(sessionName);
177
- if (!pending)
178
- return undefined;
179
- const tool = pending.get(toolUseId);
180
- if (tool)
181
- pending.delete(toolUseId);
182
- if (pending.size === 0)
183
- pendingClaudeToolCalls.delete(sessionName);
184
- return tool;
185
- }
186
- function emitClaudeFileChange(sessionName, batch, eventId, ts) {
187
- timelineEmitter.emit(sessionName, TIMELINE_EVENT_FILE_CHANGE, { batch }, {
188
- source: 'daemon',
189
- confidence: 'high',
190
- eventId,
191
- ...(ts ? { ts } : {}),
192
- });
193
- }
194
- function buildClaudeToolEventId(sessionName, toolUseId, phase) {
195
- return `cc-tool:${sessionName}:${toolUseId}:${phase}`;
196
- }
197
- function isClaudeFileChangeTool(name) {
198
- return name === 'Edit' || name === 'MultiEdit' || name === 'Write' || name === 'NotebookEdit';
199
- }
200
- /** Patterns for system-injected messages that should not display as user messages. */
201
- const SYSTEM_INJECT_RE = /<task-notification|<system-reminder|<command-name>|<command-message>|<local-command-|<bash-input>|<bash-stdout>|<bash-stderr>/;
202
- function emitUserStringContent(sessionName, text, stableId, ts) {
203
- if (!text.trim())
204
- return;
205
- // System-injected messages: don't show as user message, emit working signal instead
206
- if (SYSTEM_INJECT_RE.test(text)) {
207
- timelineEmitter.emit(sessionName, 'agent.status', {
208
- status: 'processing',
209
- label: 'Processing system event...',
210
- }, { source: 'daemon', confidence: 'high' });
211
- return;
212
- }
213
- timelineEmitter.emit(sessionName, 'user.message', {
214
- text,
215
- }, { source: 'daemon', confidence: 'high', ...(stableId ? { eventId: stableId('um') } : {}), ...(ts ? { ts } : {}) });
216
- }
217
- function emitAssistantStringContent(sessionName, text, stableId, ts) {
218
- if (!text.trim())
219
- return;
220
- timelineEmitter.emit(sessionName, 'assistant.text', {
221
- text,
222
- streaming: false,
223
- }, { source: 'daemon', confidence: 'high', ...(stableId ? { eventId: stableId('at') } : {}), ...(ts ? { ts } : {}) });
224
- }
225
- function emitClaudeToolCallBlock(sessionName, block, stableId, ts) {
226
- if (!block.name)
227
- return;
228
- if (block.name === 'AskUserQuestion') {
229
- const inp = block.input;
230
- timelineEmitter.emit(sessionName, 'ask.question', {
231
- toolUseId: block.id,
232
- questions: inp?.['questions'] ?? [],
233
- }, { source: 'daemon', confidence: 'high', ...(stableId ? { eventId: stableId('aq') } : {}), ...(ts ? { ts } : {}) });
234
- return;
235
- }
236
- const input = block.input;
237
- const toolUseId = block.id;
238
- const isDeferredFileTool = isClaudeFileChangeTool(block.name) && !!toolUseId;
239
- if (toolUseId) {
240
- rememberClaudeToolCall(sessionName, {
241
- id: toolUseId,
242
- name: block.name,
243
- input,
244
- ...(ts ? { ts } : {}),
245
- });
246
- }
247
- if (isDeferredFileTool)
248
- return;
249
- const callEventId = toolUseId ? buildClaudeToolEventId(sessionName, toolUseId, 'call') : (stableId ? stableId('tc') : undefined);
250
- const summaryInput = extractToolInput(block.name, input);
251
- timelineEmitter.emit(sessionName, 'tool.call', {
252
- tool: block.name,
253
- ...(summaryInput ? { input: summaryInput } : (input ? { input } : {})),
254
- }, {
255
- source: 'daemon',
256
- confidence: 'high',
257
- ...(callEventId ? { eventId: callEventId } : {}),
258
- ...(ts ? { ts } : {}),
259
- });
260
- }
261
- function emitClaudeToolResultBlock(sessionName, block, stableId, ts) {
262
- const toolUseId = block.tool_use_id;
263
- const pending = takeClaudeToolCall(sessionName, toolUseId);
264
- const toolUseResult = asRecord(block.toolUseResult);
265
- const contentResult = asRecord(block.content);
266
- const normalized = pending
267
- && !block.is_error
268
- ? normalizeClaudeFileChange({
269
- toolName: pending.name,
270
- toolCallId: pending.id,
271
- input: pending.input,
272
- toolResult: toolUseResult ?? contentResult ?? undefined,
273
- })
274
- : null;
275
- if (pending && isClaudeFileChangeTool(pending.name)) {
276
- const summaryInput = extractToolInput(pending.name, pending.input);
277
- timelineEmitter.emit(sessionName, 'tool.call', {
278
- tool: pending.name,
279
- ...(summaryInput ? { input: summaryInput } : (pending.input ? { input: pending.input } : {})),
280
- }, {
281
- source: 'daemon',
282
- confidence: 'high',
283
- eventId: buildClaudeToolEventId(sessionName, pending.id, 'call'),
284
- ...(pending.ts ? { ts: pending.ts } : {}),
285
- ...(normalized ? { hidden: true } : {}),
286
- });
287
- }
288
- if (normalized && pending) {
289
- timelineEmitter.emit(sessionName, 'tool.result', {
290
- ...(block.is_error ? { error: String(block.content ?? 'error') } : {}),
291
- ...(toolUseResult?.content ? { output: toolUseResult.content } : {}),
292
- }, {
293
- source: 'daemon',
294
- confidence: 'high',
295
- eventId: buildClaudeToolEventId(sessionName, pending.id, 'result'),
296
- ...(ts ? { ts } : {}),
297
- hidden: true,
298
- });
299
- emitClaudeFileChange(sessionName, normalized, `cc-file-change:${sessionName}:${pending.id}`, ts);
300
- return;
301
- }
302
- const error = block.is_error ? String(block.content ?? 'error') : undefined;
303
- const output = !error ? extractToolResultOutput(block) : undefined;
304
- timelineEmitter.emit(sessionName, 'tool.result', {
305
- ...(error ? { error } : {}),
306
- ...(output ? { output } : {}),
307
- }, {
308
- source: 'daemon',
309
- confidence: 'high',
310
- ...(toolUseId ? { eventId: buildClaudeToolEventId(sessionName, toolUseId, 'result') } : stableId ? { eventId: stableId('tr') } : {}),
311
- ...(ts ? { ts } : {}),
312
- });
313
- }
314
- /**
315
- * Parse one JSONL line and emit timeline events.
316
- * - assistant: emit assistant.text, assistant.thinking, tool.call
317
- * - user: emit user.message, tool.result
318
- *
319
- * lineByteOffset: byte offset of this line in the file — used to generate stable
320
- * eventIds so the same line always produces the same ID regardless of whether it
321
- * arrives via real-time streaming or history replay.
322
- */
323
- function parseLine(sessionName, line, lineByteOffset) {
324
- if (!line.trim())
325
- return;
326
- let raw;
327
- try {
328
- raw = JSON.parse(line);
329
- }
330
- catch {
331
- return;
332
- }
333
- // Extract original event timestamp from JSONL (CC writes ISO timestamp on each entry).
334
- const lineTs = raw['timestamp'] ? new Date(raw['timestamp']).getTime() : undefined;
335
- const ts = lineTs && isFinite(lineTs) ? lineTs : undefined;
336
- // Stable ID generator: same line always gets same eventId across restarts.
337
- let blockIdx = 0;
338
- const stableId = lineByteOffset !== undefined
339
- ? (suffix) => `cc:${sessionName}:${lineByteOffset}:${suffix}:${blockIdx++}`
340
- : undefined;
341
- // Progress events — transient status for status bar display (no message.content)
342
- if (raw['type'] === 'progress') {
343
- const data = raw['data'];
344
- if (!data)
345
- return;
346
- const progressType = String(data['type'] ?? '');
347
- switch (progressType) {
348
- case 'bash_progress': {
349
- const elapsed = data['elapsedTimeSeconds'];
350
- timelineEmitter.emit(sessionName, 'agent.status', {
351
- status: 'bash_running',
352
- label: `Bash running${elapsed ? ` (${Math.round(elapsed)}s)` : ''}...`,
353
- }, { source: 'daemon', confidence: 'high' });
354
- break;
355
- }
356
- case 'agent_progress': {
357
- const raw = data['message'];
358
- let msg = 'working';
359
- if (typeof raw === 'string') {
360
- msg = raw;
361
- }
362
- else if (raw && typeof raw === 'object') {
363
- const role = raw.type ?? raw.role ?? '';
364
- msg = String(role) || 'working';
365
- }
366
- timelineEmitter.emit(sessionName, 'agent.status', {
367
- status: 'agent_working',
368
- label: `Sub-agent: ${msg}`,
369
- }, { source: 'daemon', confidence: 'high' });
370
- break;
371
- }
372
- case 'mcp_progress': {
373
- const toolName = String(data['toolName'] ?? 'tool');
374
- const server = String(data['serverName'] ?? '');
375
- const mStatus = String(data['status'] ?? 'started');
376
- if (mStatus === 'started') {
377
- timelineEmitter.emit(sessionName, 'agent.status', {
378
- status: 'mcp_running',
379
- label: `MCP: ${server ? server + '/' : ''}${toolName}...`,
380
- }, { source: 'daemon', confidence: 'high' });
381
- }
382
- break;
383
- }
384
- case 'waiting_for_task': {
385
- const desc = String(data['taskDescription'] ?? 'task');
386
- timelineEmitter.emit(sessionName, 'agent.status', {
387
- status: 'waiting',
388
- label: `Waiting: ${desc}`,
389
- }, { source: 'daemon', confidence: 'high' });
390
- break;
391
- }
392
- }
393
- return;
394
- }
395
- // Result events — end-of-turn summary with total cost
396
- if (raw['type'] === 'result') {
397
- const costUsd = raw['total_cost_usd'];
398
- if (typeof costUsd === 'number' && costUsd > 0) {
399
- timelineEmitter.emit(sessionName, 'usage.update', { costUsd }, { source: 'daemon', confidence: 'high' });
400
- }
401
- return;
402
- }
403
- // System events — compact_boundary etc. (no message.content)
404
- if (raw['type'] === 'system') {
405
- const subtype = String(raw['subtype'] ?? '');
406
- if (subtype === 'compact_boundary') {
407
- timelineEmitter.emit(sessionName, 'agent.status', {
408
- status: 'compacting',
409
- label: 'Compacting conversation...',
410
- }, { source: 'daemon', confidence: 'high' });
411
- }
412
- return;
413
- }
414
- const msg = raw['message'];
415
- if (!msg)
416
- return;
417
- const content = msg['content'];
418
- if (raw['type'] === 'assistant') {
419
- if (typeof content === 'string') {
420
- emitAssistantStringContent(sessionName, content, stableId, ts);
421
- return;
422
- }
423
- if (!Array.isArray(content))
424
- return;
425
- for (const block of content) {
426
- if (block.type === 'text' && block.text) {
427
- timelineEmitter.emit(sessionName, 'assistant.text', {
428
- text: block.text,
429
- streaming: false,
430
- }, { source: 'daemon', confidence: 'high', ...(stableId ? { eventId: stableId('at') } : {}), ...(ts ? { ts } : {}) });
431
- }
432
- else if (block.type === 'thinking') {
433
- timelineEmitter.emit(sessionName, 'assistant.thinking', {
434
- text: block.thinking,
435
- }, { source: 'daemon', confidence: 'high', ...(stableId ? { eventId: stableId('th') } : {}), ...(ts ? { ts } : {}) });
436
- }
437
- else if (block.type === 'tool_use' && block.name) {
438
- emitClaudeToolCallBlock(sessionName, block, stableId, ts);
439
- }
440
- else if (block.type === 'tool_result') {
441
- emitClaudeToolResultBlock(sessionName, block, stableId, ts);
442
- }
443
- }
444
- // Emit token usage + model for context bar (transient, no stable ID needed)
445
- const usage = msg['usage'];
446
- const model = msg['model'];
447
- if (usage && typeof usage.input_tokens === 'number') {
448
- const presetCtx = watchers.get(sessionName)?.ccSessionId ? getSessionContextWindow(watchers.get(sessionName).ccSessionId) : undefined;
449
- timelineEmitter.emit(sessionName, 'usage.update', {
450
- inputTokens: usage.input_tokens + (usage.cache_creation_input_tokens ?? 0),
451
- cacheTokens: usage.cache_read_input_tokens ?? 0,
452
- contextWindow: resolveContextWindow(presetCtx, model),
453
- ...(model ? { model } : {}),
454
- }, { source: 'daemon', confidence: 'high' });
455
- }
456
- return;
457
- }
458
- if (raw['type'] === 'user') {
459
- if (typeof content === 'string') {
460
- emitUserStringContent(sessionName, content, stableId, ts);
461
- return;
462
- }
463
- if (!Array.isArray(content))
464
- return;
465
- for (const block of content) {
466
- if (block.type === 'text' && block.text?.trim()) {
467
- emitUserStringContent(sessionName, block.text, stableId, ts);
468
- }
469
- else if (block.type === 'tool_result') {
470
- emitClaudeToolResultBlock(sessionName, block, stableId, ts);
471
- }
472
- }
473
- }
474
- }
475
- /** Extract a truncated summary of tool result content for display. */
476
- function extractToolResultOutput(block) {
477
- const raw = block.content;
478
- if (!raw)
479
- return undefined;
480
- let text;
481
- if (typeof raw === 'string') {
482
- text = raw;
483
- }
484
- else if (Array.isArray(raw)) {
485
- text = raw.map((b) => b.text ?? '').join('\n');
486
- }
487
- else {
488
- return undefined;
489
- }
490
- text = text.trim();
491
- if (!text)
492
- return undefined;
493
- return text.length > 200 ? text.slice(0, 197) + '...' : text;
494
- }
137
+ // ── JSONL parsing ─────────────────────────────────────────────────────────────
138
+ //
139
+ // The heavy parsing (JSON.parse + regex + block interpretation) lives in the
140
+ // pure `jsonl-parse-core` module so it can run either on the main thread or
141
+ // in the `jsonl-parse-worker` thread without diverging. The watcher itself
142
+ // only owns I/O and event dispatch.
143
+ //
144
+ // Strategy:
145
+ // - Default: drained batches are parsed synchronously on main using
146
+ // `mainParseCtx`. Code path identical to pre-worker behaviour.
147
+ // - Opt-in `IM4CODES_JSONL_WORKER=1`: drained batches ship to the worker
148
+ // pool; crash / timeout automatically falls back to the main-thread
149
+ // context (same pure parser). Intended for deployments that observe
150
+ // main-loop pressure from heavy Claude JSONL streams.
151
+ //
152
+ // `emitRecentHistory` (one-shot at session start) always runs on main using a
153
+ // throwaway context, because (a) it's infrequent and (b) we want to filter
154
+ // `usage.update` events and emit the snapshot at the end instead.
155
+ /** Main-thread fallback state (only used when worker disabled or failed). */
156
+ const mainParseCtx = createParseContext();
495
157
  const watchers = new Map();
496
158
  function watcherControl(sessionName) {
497
159
  return {
@@ -630,58 +292,27 @@ export async function emitRecentHistory(sessionName, filePath) {
630
292
  const content = msg?.['content'];
631
293
  if (!(Array.isArray(content) || typeof content === 'string'))
632
294
  continue;
633
- allEntries.push({ lineBytePos, raw });
295
+ allEntries.push({ lineBytePos, rawLine: line });
634
296
  }
635
- // Take last HISTORY_LINES entries
297
+ // Take last HISTORY_LINES entries and replay them through the shared parse
298
+ // core. A scratch ParseContext keeps history's tool-call correlation state
299
+ // isolated from the live watcher — no leakage into subsequent drains.
636
300
  const recentEntries = allEntries.slice(-HISTORY_LINES);
637
- for (const { lineBytePos, raw } of recentEntries) {
638
- let blockIdx = 0;
639
- const stableId = (suffix) => `cc:${sessionName}:${lineBytePos}:${suffix}:${blockIdx++}`;
640
- // Extract original timestamp from JSONL entry (same as parseLine)
641
- const lineTs = raw['timestamp'] ? new Date(raw['timestamp']).getTime() : undefined;
642
- const ts = lineTs && isFinite(lineTs) ? lineTs : undefined;
643
- if (raw['type'] === 'assistant') {
644
- const msg = raw['message'];
645
- const content = msg?.['content'];
646
- if (typeof content === 'string') {
647
- emitAssistantStringContent(sessionName, content, stableId, ts);
648
- continue;
649
- }
650
- for (const block of content) {
651
- if (block.type === 'text' && block.text) {
652
- timelineEmitter.emit(sessionName, 'assistant.text', {
653
- text: block.text, streaming: false,
654
- }, { source: 'daemon', confidence: 'high', eventId: stableId('at'), ...(ts ? { ts } : {}) });
655
- }
656
- else if (block.type === 'thinking') {
657
- timelineEmitter.emit(sessionName, 'assistant.thinking', {
658
- text: block.thinking,
659
- }, { source: 'daemon', confidence: 'high', eventId: stableId('th'), ...(ts ? { ts } : {}) });
660
- }
661
- else if (block.type === 'tool_use' && block.name) {
662
- emitClaudeToolCallBlock(sessionName, block, stableId, ts);
663
- }
664
- else if (block.type === 'tool_result') {
665
- emitClaudeToolResultBlock(sessionName, block, stableId, ts);
666
- }
667
- }
668
- }
669
- else if (raw['type'] === 'user') {
670
- const msg = raw['message'];
671
- const content = msg?.['content'];
672
- if (typeof content === 'string') {
673
- emitUserStringContent(sessionName, content, stableId, ts);
674
- continue;
675
- }
676
- for (const block of content) {
677
- if (block.type === 'text' && block.text?.trim()) {
678
- emitUserStringContent(sessionName, block.text, stableId, ts);
679
- }
680
- else if (block.type === 'tool_result') {
681
- emitClaudeToolResultBlock(sessionName, block, stableId, ts);
682
- }
683
- }
684
- }
301
+ const historyCtx = createParseContext();
302
+ const presetContextWindow = watchers.get(sessionName)?.ccSessionId
303
+ ? getSessionContextWindow(watchers.get(sessionName).ccSessionId)
304
+ : undefined;
305
+ const { emits } = parseLinesInCtx(historyCtx, {
306
+ sessionName,
307
+ items: recentEntries.map((e) => ({ line: e.rawLine, lineByteOffset: e.lineBytePos })),
308
+ ...(presetContextWindow !== undefined ? { presetContextWindow } : {}),
309
+ });
310
+ // History handles the usage snapshot separately below — drop per-line
311
+ // usage events so we don't spam the context bar during replay.
312
+ for (const em of emits) {
313
+ if (em.type === 'usage.update')
314
+ continue;
315
+ timelineEmitter.emit(em.sessionName, em.type, em.payload, em.metadata);
685
316
  }
686
317
  // Emit the most recent usage snapshot so the context bar populates on load
687
318
  if (lastUsagePayload) {
@@ -779,7 +410,10 @@ export function stopWatching(sessionName) {
779
410
  unregisterWatcherControl(sessionName);
780
411
  releaseFiles(sessionName);
781
412
  releaseOwnership(sessionName);
782
- pendingClaudeToolCalls.delete(sessionName);
413
+ // Drop per-session pending tool-call state in both the main-thread fallback
414
+ // context and the worker (best-effort; worker call is async and unawaited).
415
+ forgetSessionInCtx(mainParseCtx, sessionName);
416
+ void jsonlParsePool.forgetSession(sessionName);
783
417
  }
784
418
  /**
785
419
  * Start watching a specific JSONL file (CC sub-sessions with known --session-id path).
@@ -1102,12 +736,38 @@ async function drainNewLines(sessionName, state) {
1102
736
  // pendingPartialLine bytes were already read in a prior drain, so we subtract them.
1103
737
  const prevPendingByteLen = Buffer.byteLength(fullChunk.slice(0, fullChunk.length - chunk.length), 'utf8');
1104
738
  let lineByteOffset = chunkStartOffset - prevPendingByteLen;
739
+ // Batch complete lines and route to worker (if enabled) or parse on main.
740
+ const items = [];
1105
741
  for (const line of lines) {
1106
742
  if (state.stopped)
1107
743
  break;
1108
- parseLine(sessionName, line, lineByteOffset);
744
+ items.push({ line, lineByteOffset });
1109
745
  lineByteOffset += Buffer.byteLength(line, 'utf8') + 1; // +1 for \n
1110
746
  }
747
+ if (items.length === 0)
748
+ return;
749
+ const presetContextWindow = state.ccSessionId
750
+ ? getSessionContextWindow(state.ccSessionId)
751
+ : undefined;
752
+ const request = {
753
+ sessionName,
754
+ items,
755
+ ...(state.ccSessionId ? { ccSessionId: state.ccSessionId } : {}),
756
+ ...(presetContextWindow !== undefined ? { presetContextWindow } : {}),
757
+ };
758
+ let emits = null;
759
+ if (isJsonlWorkerEnabled() && jsonlParsePool.isAvailable()) {
760
+ const result = await jsonlParsePool.parseLines(request);
761
+ if (result)
762
+ emits = result.emits;
763
+ }
764
+ if (!emits) {
765
+ // Main-thread fallback: either worker disabled, unavailable, or returned null.
766
+ emits = parseLinesInCtx(mainParseCtx, request).emits;
767
+ }
768
+ for (const em of emits) {
769
+ timelineEmitter.emit(em.sessionName, em.type, em.payload, em.metadata);
770
+ }
1111
771
  }
1112
772
  catch (err) {
1113
773
  if (!state.stopped) {