@agentdeck/bridge 1.0.18 → 1.0.20

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 (60) hide show
  1. package/dist/adapters/claude-code.d.ts +4 -2
  2. package/dist/adapters/claude-code.d.ts.map +1 -1
  3. package/dist/adapters/claude-code.js +9 -10
  4. package/dist/adapters/claude-code.js.map +1 -1
  5. package/dist/adapters/codex-cli.d.ts +3 -4
  6. package/dist/adapters/codex-cli.d.ts.map +1 -1
  7. package/dist/adapters/codex-cli.js +8 -12
  8. package/dist/adapters/codex-cli.js.map +1 -1
  9. package/dist/adapters/pty-adapter.d.ts +2 -2
  10. package/dist/adapters/pty-adapter.js +2 -2
  11. package/dist/apme/adapters/codex-turn-manager.d.ts +13 -77
  12. package/dist/apme/adapters/codex-turn-manager.d.ts.map +1 -1
  13. package/dist/apme/adapters/codex-turn-manager.js +102 -403
  14. package/dist/apme/adapters/codex-turn-manager.js.map +1 -1
  15. package/dist/apme/claude-transcript-reader.d.ts +22 -0
  16. package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
  17. package/dist/apme/claude-transcript-reader.js +75 -1
  18. package/dist/apme/claude-transcript-reader.js.map +1 -1
  19. package/dist/bridge-core.d.ts.map +1 -1
  20. package/dist/bridge-core.js +6 -1
  21. package/dist/bridge-core.js.map +1 -1
  22. package/dist/check-deps.d.ts.map +1 -1
  23. package/dist/check-deps.js +3 -1
  24. package/dist/check-deps.js.map +1 -1
  25. package/dist/claude-turn-watchdog.d.ts +70 -0
  26. package/dist/claude-turn-watchdog.d.ts.map +1 -0
  27. package/dist/claude-turn-watchdog.js +147 -0
  28. package/dist/claude-turn-watchdog.js.map +1 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +20 -3
  31. package/dist/cli.js.map +1 -1
  32. package/dist/codex-hook-silence.d.ts +52 -0
  33. package/dist/codex-hook-silence.d.ts.map +1 -0
  34. package/dist/codex-hook-silence.js +80 -0
  35. package/dist/codex-hook-silence.js.map +1 -0
  36. package/dist/codex-rate-limits.d.ts +1 -1
  37. package/dist/codex-rate-limits.d.ts.map +1 -1
  38. package/dist/codex-rate-limits.js +27 -17
  39. package/dist/codex-rate-limits.js.map +1 -1
  40. package/dist/daemon.js +1 -1
  41. package/dist/index.d.ts +3 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +86 -192
  44. package/dist/index.js.map +1 -1
  45. package/dist/state-machine.d.ts +43 -1
  46. package/dist/state-machine.d.ts.map +1 -1
  47. package/dist/state-machine.js +146 -12
  48. package/dist/state-machine.js.map +1 -1
  49. package/dist/types.d.ts +11 -1
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/types.js.map +1 -1
  52. package/dist/version-check.d.ts +8 -0
  53. package/dist/version-check.d.ts.map +1 -1
  54. package/dist/version-check.js +27 -0
  55. package/dist/version-check.js.map +1 -1
  56. package/package.json +4 -3
  57. package/dist/apme/adapters/claude-pty.d.ts +0 -28
  58. package/dist/apme/adapters/claude-pty.d.ts.map +0 -1
  59. package/dist/apme/adapters/claude-pty.js +0 -88
  60. package/dist/apme/adapters/claude-pty.js.map +0 -1
package/dist/index.js CHANGED
@@ -14,7 +14,6 @@ import { readLastTurn as readClaudeTranscriptLastTurn } from './apme/claude-tran
14
14
  import { claudeHookToSpans } from './apme/adapters/claude-hook.js';
15
15
  import { codexHookToSpans } from './apme/adapters/codex-hook.js';
16
16
  import { CodexTurnManager } from './apme/adapters/codex-turn-manager.js';
17
- import { claudePtyParserEventToSpans, claudePtyResponseToSpan } from './apme/adapters/claude-pty.js';
18
17
  import { timelineEntryToSpans } from './apme/adapters/timeline.js';
19
18
  import { classifyAndEnqueueTurn } from './apme/classify-turn.js';
20
19
  import { VoiceManager } from './voice.js';
@@ -23,6 +22,8 @@ import { enableDebugLog, log, debug, setPtyMode } from './logger.js';
23
22
  import { EventJournal } from './event-journal.js';
24
23
  import { PtyRingBuffer } from './pty-ringbuffer.js';
25
24
  import { isSubagentOnlyHook, SubagentTimelineTracker } from './subagent-timeline.js';
25
+ import { ClaudeTurnWatchdog } from './claude-turn-watchdog.js';
26
+ import { CodexHookSilenceWarning } from './codex-hook-silence.js';
26
27
  import { createDiagDump } from './diag-analyzer.js';
27
28
  import { createAdapter, ClaudeCodeAdapter, CodexCliAdapter, OpenCodeAdapter } from './adapters/index.js';
28
29
  import { MonitorAdapter } from './adapters/monitor.js';
@@ -106,6 +107,14 @@ export async function startSession(opts) {
106
107
  process.exit(1);
107
108
  for (const w of deps.warnings)
108
109
  log(`WARNING: ${w}`);
110
+ if (agentType === 'codex-cli' && deps.agentVersion) {
111
+ const { satisfiesRange, getCompatibleCodexRange } = await import('./version-check.js');
112
+ const codexRange = getCompatibleCodexRange() ?? '>=0.141.0';
113
+ if (!satisfiesRange(deps.agentVersion, codexRange)) {
114
+ log(`WARNING: Codex ${deps.agentVersion} predates the lifecycle-hook baseline (${codexRange}). ` +
115
+ 'The terminal can start, but AgentDeck lifecycle monitoring requires a newer Codex CLI.');
116
+ }
117
+ }
109
118
  // Version compatibility check (Claude Code only — uses npm registry + compatibleClaudeCode range)
110
119
  if (agentType === 'claude-code' && !opts.noUpdateCheck) {
111
120
  const { checkVersionCompatibility } = await import('./version-check.js');
@@ -157,6 +166,15 @@ export async function startSession(opts) {
157
166
  if (adapter instanceof ClaudeCodeAdapter) {
158
167
  adapter.seedProjectName(projectName);
159
168
  }
169
+ // Codex fires codex_session_start within seconds of the PTY spawning —
170
+ // before the main wiring below registers its listeners — so capture the
171
+ // fact early or the hook-silence warning would misread a healthy install
172
+ // as lifecycle-blind.
173
+ let codexHookSeenEarly = false;
174
+ adapter.on('event', (evt) => {
175
+ if (evt.source === 'hook' && evt.event.startsWith('codex_'))
176
+ codexHookSeenEarly = true;
177
+ });
160
178
  // ===== Start adapter (creates HTTP server, spawns process) =====
161
179
  try {
162
180
  await adapter.start({ port, command: opts.command, gatewayUrl: opts.gatewayUrl });
@@ -443,20 +461,60 @@ export async function startSession(opts) {
443
461
  });
444
462
  // Claude Code hook events → timeline entries
445
463
  if (agentType === 'claude-code') {
446
- wireClaudeCodeTimeline(adapter, core, journal, ptyRingBuffer);
464
+ wireClaudeCodeTimeline(adapter, core);
465
+ // Missed-Stop recovery: when the hook channel goes quiet mid-turn, the
466
+ // transcript JSONL tail (the Stop branch's own authority) is probed and a
467
+ // proven-finished turn is closed by injecting a synthetic Stop through
468
+ // this same event pipe — state machine, timeline, and APME all close via
469
+ // their existing Stop paths, and a late real Stop dedups downstream.
470
+ const turnWatchdog = new ClaudeTurnWatchdog({
471
+ onMissedStop: ({ transcript_path }) => {
472
+ adapter.emit('event', {
473
+ source: 'hook',
474
+ event: 'Stop',
475
+ data: { transcript_path, synthetic_stop: true },
476
+ });
477
+ },
478
+ });
479
+ adapter.on('event', (evt) => {
480
+ if (evt.source === 'hook')
481
+ turnWatchdog.noteHookEvent(evt.event, evt.data ?? {});
482
+ });
483
+ core.onShutdown(() => turnWatchdog.stop());
484
+ }
485
+ // Codex lifecycle-channel dead-on-arrival warning: hooks and notify share
486
+ // the same curl/port mechanism, so when both are silent the session is
487
+ // lifecycle-blind with no signal at all. Warn once if the PTY is clearly in
488
+ // use but no codex_* event ever arrived. `--no-codex-hooks` opted out.
489
+ if (agentType === 'codex-cli' && opts.codexHooksExpected !== false) {
490
+ const hookSilence = new CodexHookSilenceWarning({
491
+ onSilent: () => {
492
+ log('WARNING: no Codex lifecycle event has reached this bridge — state/timeline/eval are blind for this session.');
493
+ log('Repair: rerun `agentdeck codex` (reinstalls hooks) or `agentdeck daemon install`, and check [hooks]/notify in ~/.codex/config.toml.');
494
+ core.bridgeTimeline.addEntry({
495
+ ts: Date.now(),
496
+ type: 'error',
497
+ raw: 'Codex lifecycle hooks silent — session is lifecycle-blind',
498
+ detail: 'The terminal is active but no codex_* hook or notify event arrived. Hooks and the notify fallback share one curl/port path in ~/.codex/config.toml, so a stale port or config-layer conflict kills both. Rerun `agentdeck codex` or `agentdeck daemon install` to repair.',
499
+ agentType: 'codex-cli',
500
+ });
501
+ },
502
+ });
503
+ if (codexHookSeenEarly)
504
+ hookSilence.noteHookEvent();
505
+ adapter.on('event', (evt) => {
506
+ if (evt.source === 'hook' && evt.event.startsWith('codex_'))
507
+ hookSilence.noteHookEvent();
508
+ else if (evt.source === 'activity')
509
+ hookSilence.noteActivity();
510
+ });
511
+ core.onShutdown(() => hookSilence.stop());
447
512
  }
448
513
  // APME wiring for non-Claude-Code agents (OpenCode, Codex, OpenClaw)
449
514
  if (apme && agentType !== 'claude-code' && agentType !== 'monitor') {
450
- wireAgentApme(adapter, agentType, apme, core, ptyRingBuffer);
515
+ wireAgentApme(adapter, agentType, apme, core);
451
516
  }
452
517
  // ===== Wire adapter events → StateMachine + journal =====
453
- // APME: PTY response fallback — captures response from terminal output when
454
- // the Stop hook doesn't fire (unreliable in Claude Code v2.1+).
455
- // Three capture paths handle the hook/PTY race condition:
456
- // Path A: idle fires, turn exists → apply directly
457
- // Path B: idle fires before hook (fast responses) → buffer, apply on hook arrival
458
- // Path C: UserPromptSubmit closes prev turn → apply PTY text to closed turn
459
- let pendingPtyResponse = null;
460
518
  adapter.on('event', (evt) => {
461
519
  switch (evt.source) {
462
520
  case 'hook':
@@ -479,28 +537,6 @@ export async function startSession(opts) {
479
537
  apme.collector.ingestSpan(core.sessionId, span);
480
538
  }
481
539
  }
482
- if (evt.event === 'UserPromptSubmit' && apme) {
483
- // Path B: apply buffered response to the NEW turn (idle fired before hook)
484
- if (pendingPtyResponse) {
485
- debug('APME', `hook:UPS Path B: applying pending (${pendingPtyResponse.length} chars)`);
486
- const ctx = makeApmeAdapterCtx(apme, core.sessionId, agentType);
487
- const span = claudePtyResponseToSpan(ctx, pendingPtyResponse);
488
- if (span)
489
- apme.collector.ingestSpan(core.sessionId, span);
490
- pendingPtyResponse = null;
491
- }
492
- // Path C: prev turn was just closed by ingestHook — apply pending response
493
- if (pendingPtyResponse) {
494
- debug('APME', `hook:UPS Path C: applying pending to closed turn (${pendingPtyResponse.length} chars)`);
495
- const ctx = makeApmeAdapterCtx(apme, core.sessionId, agentType);
496
- const span = claudePtyResponseToSpan(ctx, pendingPtyResponse, { fallbackToLastClosed: true });
497
- if (span)
498
- apme.collector.ingestSpan(core.sessionId, span);
499
- pendingPtyResponse = null;
500
- }
501
- }
502
- if (evt.event === 'Stop')
503
- pendingPtyResponse = null; // Stop has cleaner response
504
540
  // APME-off fallback: task rows from boundary hooks alone.
505
541
  if (fallbackTasks)
506
542
  fallbackTasks.ingestHook(core.sessionId, evt.event, evt.data ?? {});
@@ -511,67 +547,14 @@ export async function startSession(opts) {
511
547
  core.stateMachine.handleHookEvent(evt.event, evt.data);
512
548
  break;
513
549
  case 'parser':
514
- journal.write('parser_emit', 'pty', { event: evt.event, ...evt.data });
550
+ // Structured adapters (OpenCode SSE / OpenClaw Gateway) normalize
551
+ // their native event streams into the legacy state vocabulary.
552
+ journal.write('adapter_signal', agentType, { event: evt.event, ...evt.data });
515
553
  core.stateMachine.handleParserEvent(evt.event, evt.data);
516
- // APME ingestion fallback: PTY parser sees tool use + state changes
517
- // even when Claude Code hooks are flaky (memory: feedback_apme_stop_hook).
518
- if (apme && evt.event) {
519
- const ctx = makeApmeAdapterCtx(apme, core.sessionId, agentType);
520
- for (const span of claudePtyParserEventToSpans(ctx, evt.event, evt.data ?? {})) {
521
- apme.collector.ingestSpan(core.sessionId, span);
522
- }
523
- }
524
- // APME: Path A — spinner_stop fires when Claude finishes responding.
525
- // Delay 500ms to let response render in PTY, then extract text after ⏺ marker.
526
- if (apme && evt.event === 'spinner_stop') {
527
- const sid = core.sessionId;
528
- setTimeout(async () => {
529
- if (!apme)
530
- return;
531
- const tail = ptyRingBuffer.getTail(5000);
532
- // Claude's response starts with ⏺ — extract content after last ⏺ marker.
533
- // Take only meaningful lines (stop at spinner chars ✢✻⏸, prompt ❯, or separator ──).
534
- const marker = tail.lastIndexOf('⏺');
535
- let response = '';
536
- if (marker >= 0) {
537
- const afterMarker = tail.slice(marker + 1).trim();
538
- const lines = afterMarker.split('\n');
539
- const clean = [];
540
- for (const line of lines) {
541
- const trimmed = line.trim();
542
- if (!trimmed)
543
- continue;
544
- // Stop at UI artifacts: spinner chars (✢✳✶✻✽), plan mode (⏸), prompt (❯), separator (─)
545
- if (/^[✢✳✶✻✽⏸⏵❯─>]/.test(trimmed))
546
- break;
547
- if (/planmode|plan\s*mode|shift\+tab|accept\s*edits/i.test(trimmed))
548
- break;
549
- // Status text: "Whirring…", "Finagling…", token count, shortcuts hint
550
- if (/^\S+…(\s|$)/.test(trimmed) && /tokens|shortcuts|\d+[ms]\s/i.test(trimmed))
551
- break;
552
- if (/\?\s*for\s*shortcuts/.test(trimmed))
553
- break;
554
- clean.push(trimmed);
555
- }
556
- response = clean.join('\n').trim();
557
- }
558
- debug('APME', `spinner_stop+500ms: tailLen=${tail.length} marker=${marker} respLen=${response.length}`);
559
- if (response.length > 2) {
560
- const turnId = apme.collector.getActiveTurnId(sid);
561
- if (turnId) {
562
- const ctx = makeApmeAdapterCtx(apme, sid, agentType);
563
- const span = claudePtyResponseToSpan(ctx, response);
564
- if (span)
565
- apme.collector.ingestSpan(sid, span);
566
- pendingPtyResponse = null;
567
- await classifyAndEnqueueTurn(apme, sid);
568
- }
569
- else {
570
- pendingPtyResponse = response;
571
- }
572
- }
573
- }, 500);
574
- }
554
+ break;
555
+ case 'terminal_ui':
556
+ journal.write('terminal_ui', 'pty', { event: evt.event, ...evt.data });
557
+ core.stateMachine.handleTerminalUiEvent(evt.event, evt.data);
575
558
  break;
576
559
  case 'metadata':
577
560
  switch (evt.event) {
@@ -587,11 +570,6 @@ export async function startSession(opts) {
587
570
  const text = evt.data?.text;
588
571
  if (text)
589
572
  core.broadcast({ type: 'user_prompt', text });
590
- // APME: user_prompt is the PTY-detected prompt — use it to open a turn
591
- // and capture the prompt text (fallback for when hooks don't fire).
592
- if (apme && text) {
593
- apme.collector.ingestHook(core.sessionId, 'UserPromptSubmit', { message: { content: text } });
594
- }
595
573
  break;
596
574
  }
597
575
  case 'model_catalog': {
@@ -1266,14 +1244,12 @@ export async function startSession(opts) {
1266
1244
  // ===== Non-Claude agent APME wiring =====
1267
1245
  // For OpenCode, Codex, OpenClaw: intercepts adapter timeline events to create
1268
1246
  // APME turns with prompts and responses. Claude Code uses hooks instead.
1269
- function wireAgentApme(adapter, agentType, apme, core, ptyRingBuffer) {
1247
+ function wireAgentApme(adapter, agentType, apme, core) {
1270
1248
  const sid = core.sessionId;
1271
- // Codex turn boundaries (hook-primary, PTY-fallback) live in their own
1272
- // module. The class encapsulates the seven-state-variable PTY-only
1273
- // machine plus the hook-fresh shortcut so non-codex sessions allocate
1274
- // nothing here.
1249
+ // Codex turn boundaries and rollout response capture live in their own
1250
+ // hook-owned module so non-Codex sessions allocate nothing here.
1275
1251
  const codexManager = agentType === 'codex-cli'
1276
- ? new CodexTurnManager(core, apme, ptyRingBuffer, sid, agentType)
1252
+ ? new CodexTurnManager(core, apme, sid, agentType)
1277
1253
  : null;
1278
1254
  adapter.on('event', (evt) => {
1279
1255
  if (evt.source === 'hook' && isSubagentOnlyHook(evt.event, evt.data ?? {})) {
@@ -1309,12 +1285,10 @@ function wireAgentApme(adapter, agentType, apme, core, ptyRingBuffer) {
1309
1285
  void classifyAndEnqueueTurn(apme, sid);
1310
1286
  }
1311
1287
  }
1312
- // ── Codex: hook (primary) + PTY-parser fallback ──
1288
+ // ── Codex: lifecycle hooks + notify turn-complete fallback ──
1313
1289
  if (codexManager) {
1314
1290
  if (evt.source === 'hook')
1315
1291
  codexManager.onHookEvent(evt);
1316
- else if (evt.source === 'parser')
1317
- codexManager.onParserEvent(evt);
1318
1292
  }
1319
1293
  });
1320
1294
  if (codexManager) {
@@ -1338,14 +1312,12 @@ function isClaudeTaskNotificationPrompt(text) {
1338
1312
  return text.trim().toLowerCase().startsWith('<task-notification>');
1339
1313
  }
1340
1314
  // ===== Claude Code timeline wiring =====
1341
- function wireClaudeCodeTimeline(adapter, core, journal, ptyRingBuffer) {
1315
+ function wireClaudeCodeTimeline(adapter, core) {
1342
1316
  let ccChatStart = null;
1343
1317
  let ccPendingChatStart = false;
1344
1318
  let ccPendingChatStartTimer = null;
1345
1319
  let ccLastPromptText = null;
1346
- let ccLastHookToolTs = 0; // Change 1: track last hook-based tool_request
1347
- let ccPendingCompletion = false; // Change 2: track pending chat_end for PTY fallback
1348
- let ccPendingCompletionTimer = null;
1320
+ let ccPendingCompletion = false;
1349
1321
  const emitChatStart = (text) => {
1350
1322
  if (ccPendingChatStartTimer) {
1351
1323
  clearTimeout(ccPendingChatStartTimer);
@@ -1376,33 +1348,8 @@ function wireClaudeCodeTimeline(adapter, core, journal, ptyRingBuffer) {
1376
1348
  startedAt: ccChatStart ?? Date.now(),
1377
1349
  });
1378
1350
  };
1379
- /** Extract response text from PTY ringbuffer after ⏺ marker (same pattern as APME) */
1380
- const extractPtyResponse = () => {
1381
- const tail = ptyRingBuffer.getTail(5000);
1382
- const marker = tail.lastIndexOf('⏺');
1383
- if (marker < 0)
1384
- return '';
1385
- const afterMarker = tail.slice(marker + 1).trim();
1386
- const lines = afterMarker.split('\n');
1387
- const clean = [];
1388
- for (const line of lines) {
1389
- const trimmed = line.trim();
1390
- if (!trimmed)
1391
- continue;
1392
- if (/^[✢✳✶✻✽⏸⏵❯─>]/.test(trimmed))
1393
- break;
1394
- if (/planmode|plan\s*mode|shift\+tab|accept\s*edits/i.test(trimmed))
1395
- break;
1396
- if (/^\S+…(\s|$)/.test(trimmed) && /tokens|shortcuts|\d+[ms]\s/i.test(trimmed))
1397
- break;
1398
- if (/\?\s*for\s*shortcuts/.test(trimmed))
1399
- break;
1400
- clean.push(trimmed);
1401
- }
1402
- return clean.join('\n').trim();
1403
- };
1404
1351
  /**
1405
- * Emit a single Claude turn-close row (shared by Stop hook and PTY fallback):
1352
+ * Emit a single Claude turn-close row from the authoritative Stop hook:
1406
1353
  * a `chat_response` when there is meaningful response text, otherwise a
1407
1354
  * `chat_end`. The two are mutually exclusive so a turn never produces both —
1408
1355
  * the paired dimmed `chat_end` used to fragment every turn into three rows on
@@ -1501,48 +1448,6 @@ function wireClaudeCodeTimeline(adapter, core, journal, ptyRingBuffer) {
1501
1448
  }
1502
1449
  }
1503
1450
  });
1504
- // Change 1: PTY tool_action → tool_exec timeline entry (fills gap when hooks don't fire)
1505
- adapter.on('event', (evt) => {
1506
- if (evt.source !== 'parser' || evt.event !== 'tool_action')
1507
- return;
1508
- const now = Date.now();
1509
- // Skip if hook already emitted tool_request within 2s (avoid duplicates)
1510
- if (now - ccLastHookToolTs < 2000)
1511
- return;
1512
- const toolName = evt.data?.toolName || 'tool';
1513
- const toolArgs = evt.data?.toolArgs || '';
1514
- const raw = toolArgs ? `${toolName} ${toolArgs}` : toolName;
1515
- core.bridgeTimeline.addEntry({
1516
- ts: now, type: 'tool_exec',
1517
- raw: raw.length > 500 ? raw.slice(0, 497) + '...' : raw,
1518
- agentType: 'claude-code',
1519
- });
1520
- });
1521
- // Change 2: PTY fallback chat_end from spinner_stop/idle (when Stop hook doesn't fire)
1522
- adapter.on('event', (evt) => {
1523
- if (evt.source !== 'parser')
1524
- return;
1525
- if ((evt.event === 'spinner_stop' || evt.event === 'idle') && ccPendingCompletion) {
1526
- // Wait 1.5s — Stop hook may still arrive
1527
- if (ccPendingCompletionTimer)
1528
- clearTimeout(ccPendingCompletionTimer);
1529
- ccPendingCompletionTimer = setTimeout(() => {
1530
- ccPendingCompletionTimer = null;
1531
- if (!ccPendingCompletion)
1532
- return; // Stop hook already handled it
1533
- ccPendingCompletion = false;
1534
- if (ccPendingChatStart)
1535
- emitChatStart('');
1536
- const now = Date.now();
1537
- const duration = ccChatStart ? Math.round((now - ccChatStart) / 1000) : null;
1538
- const toolSummary = core.usageTracker.getToolSummary();
1539
- // Extract response from PTY ringbuffer
1540
- const responseText = extractPtyResponse();
1541
- debug('timeline', `CC PTY fallback chat_end: respLen=${responseText.length} duration=${duration}`);
1542
- emitCompletion(responseText, duration, toolSummary);
1543
- }, 1500);
1544
- }
1545
- });
1546
1451
  adapter.on('event', (evt) => {
1547
1452
  if (evt.source !== 'hook')
1548
1453
  return;
@@ -1554,7 +1459,7 @@ function wireClaudeCodeTimeline(adapter, core, journal, ptyRingBuffer) {
1554
1459
  ccChatStart = now;
1555
1460
  core.usageTracker.resetToolCounts();
1556
1461
  ccPendingChatStart = true;
1557
- ccPendingCompletion = true; // Change 2: arm PTY fallback
1462
+ ccPendingCompletion = true;
1558
1463
  // Claude Code v2.1+ sends { message: { content: "..." } }, not { prompt: "..." }
1559
1464
  const msg = evt.data?.message;
1560
1465
  const hookText = evt.data?.prompt
@@ -1569,7 +1474,6 @@ function wireClaudeCodeTimeline(adapter, core, journal, ptyRingBuffer) {
1569
1474
  break;
1570
1475
  }
1571
1476
  case 'PreToolUse': {
1572
- ccLastHookToolTs = now; // Change 1: mark hook tool time for dedup
1573
1477
  const toolName = evt.data?.tool_name || 'tool';
1574
1478
  const formatted = formatToolInputForTimeline(toolName, evt.data?.tool_input);
1575
1479
  core.bridgeTimeline.addEntry({
@@ -1586,18 +1490,10 @@ function wireClaudeCodeTimeline(adapter, core, journal, ptyRingBuffer) {
1586
1490
  break;
1587
1491
  }
1588
1492
  case 'Stop': {
1589
- // Race guard: if ccPendingCompletion is already false, the PTY fallback
1590
- // timer fired first (Stop hook arrived >1.5s after spinner_stop) and
1591
- // already emitted chat_response/chat_end. Skipping here prevents the
1592
- // duplicate-line bug visible on the dashboard timeline.
1593
1493
  const wasPending = ccPendingCompletion;
1594
- ccPendingCompletion = false; // Change 2: disarm PTY fallback — hook handled it
1595
- if (ccPendingCompletionTimer) {
1596
- clearTimeout(ccPendingCompletionTimer);
1597
- ccPendingCompletionTimer = null;
1598
- }
1494
+ ccPendingCompletion = false;
1599
1495
  if (!wasPending) {
1600
- debug('timeline', 'CC Stop hook arrived after PTY fallback emit — skipping duplicate');
1496
+ debug('timeline', 'duplicate Claude Stop hook ignored');
1601
1497
  break;
1602
1498
  }
1603
1499
  if (ccPendingChatStart)
@@ -1612,8 +1508,6 @@ function wireClaudeCodeTimeline(adapter, core, journal, ptyRingBuffer) {
1612
1508
  // payload points at it.
1613
1509
  // 2. last_assistant_message — ~18% reliable field on the hook
1614
1510
  // payload; honoured when the transcript isn't reachable.
1615
- // 3. PTY ringbuffer — last-resort fallback already handled by
1616
- // the existing spinner_stop pathway.
1617
1511
  const transcriptPath = evt.data?.transcript_path || '';
1618
1512
  let lastAssistantMsg = evt.data?.last_assistant_message || '';
1619
1513
  if (transcriptPath) {