@adhdev/daemon-core 0.9.82-rc.353 → 0.9.82-rc.355

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 (38) hide show
  1. package/dist/commands/handler.d.ts +15 -0
  2. package/dist/index.js +703 -220
  3. package/dist/index.js.map +1 -1
  4. package/dist/index.mjs +703 -220
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  7. package/dist/mesh/mesh-runtime-store.d.ts +1 -1
  8. package/dist/mesh/mesh-work-queue.d.ts +1 -1
  9. package/dist/providers/acp-provider-instance.d.ts +3 -0
  10. package/dist/providers/cli-provider-instance.d.ts +14 -0
  11. package/dist/providers/manual-attendance.d.ts +63 -0
  12. package/dist/providers/provider-instance.d.ts +8 -0
  13. package/dist/providers/spec/adapter.d.ts +22 -0
  14. package/dist/providers/spec/fsm-driver.d.ts +49 -7
  15. package/dist/providers/spec/fsm-evaluator.d.ts +4 -0
  16. package/dist/providers/spec/types.d.ts +9 -5
  17. package/package.json +2 -2
  18. package/src/commands/cli-manager.ts +20 -2
  19. package/src/commands/handler.ts +32 -0
  20. package/src/commands/router.ts +19 -6
  21. package/src/git/git-diff.ts +31 -14
  22. package/src/mesh/mesh-event-trace.ts +67 -0
  23. package/src/mesh/mesh-events-coordinator.ts +117 -12
  24. package/src/mesh/mesh-events-pending.ts +33 -0
  25. package/src/mesh/mesh-events-stale.ts +3 -1
  26. package/src/mesh/mesh-reconcile-loop.ts +47 -0
  27. package/src/mesh/mesh-runtime-store.ts +18 -2
  28. package/src/mesh/mesh-work-queue.ts +8 -1
  29. package/src/providers/acp-provider-instance.ts +18 -1
  30. package/src/providers/cli-provider-instance.ts +123 -7
  31. package/src/providers/manual-attendance.ts +85 -0
  32. package/src/providers/provider-instance.ts +9 -0
  33. package/src/providers/spec/adapter.ts +67 -0
  34. package/src/providers/spec/cli-adapter.ts +6 -0
  35. package/src/providers/spec/evaluator.ts +24 -9
  36. package/src/providers/spec/fsm-driver.ts +135 -13
  37. package/src/providers/spec/fsm-evaluator.ts +19 -2
  38. package/src/providers/spec/types.ts +9 -5
@@ -21,6 +21,7 @@ import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pt
21
21
  import { StatusMonitor } from './status-monitor.js';
22
22
  import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
23
23
  import { LOG } from '../logging/logger.js';
24
+ import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
24
25
  import type { ChatMessage } from '../types.js';
25
26
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
26
27
  import { formatAutoApprovalMessage, pickApprovalButton, pickAutoApprovalButton, looksLikeActiveApprovalPromptText } from './approval-utils.js';
@@ -29,6 +30,7 @@ import { mergeProviderPatchState, resolveProviderStateSurface } from './provider
29
30
  import { normalizeProviderSessionId } from './provider-session-id.js';
30
31
  import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages } from './chat-message-normalization.js';
31
32
  import { workingDirBasename } from './working-dir.js';
33
+ import { ManualAttendanceTracker } from './manual-attendance.js';
32
34
 
33
35
  type PersistableCliHistoryMessage = {
34
36
  role: string;
@@ -416,6 +418,11 @@ export class CliProviderInstance implements ProviderInstance {
416
418
  // a settle gate was in progress. Drives AUTO_APPROVE_GATE_HYSTERESIS_MS so a
417
419
  // brief generating flip does not immediately wipe the settle clock.
418
420
  private autoApproveInactiveSince = 0;
421
+ // Provider-common manual-attendance signal: while a human is actively driving
422
+ // this session from the dashboard, auto-approve holds so they can take manual
423
+ // control. Background mesh workers are never attended → delegated auto-approve
424
+ // is unaffected.
425
+ private readonly manualAttendance = new ManualAttendanceTracker();
419
426
  private controlValues: Record<string, string | number | boolean> = {};
420
427
  private summaryMetadata: unknown = undefined;
421
428
  private appliedEffectKeys = new Set<string>();
@@ -828,7 +835,7 @@ export class CliProviderInstance implements ProviderInstance {
828
835
 
829
836
  getHotChatSessionState(): HotChatSessionState {
830
837
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
831
- const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
838
+ const autoApproveActive = this.autoApproveEffectivelyActive(adapterStatus.status);
832
839
  const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
833
840
  const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
834
841
  const runtime = this.adapter.getRuntimeMetadata();
@@ -844,7 +851,7 @@ export class CliProviderInstance implements ProviderInstance {
844
851
 
845
852
  getSessionModalState(sessionId?: string): SessionModalState {
846
853
  const adapterStatus = this.adapter.getStatus({ allowParse: true });
847
- const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
854
+ const autoApproveActive = this.autoApproveEffectivelyActive(adapterStatus.status);
848
855
  const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
849
856
  const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
850
857
  const dirName = workingDirBasename(this.workingDir);
@@ -947,7 +954,10 @@ export class CliProviderInstance implements ProviderInstance {
947
954
  } catch {
948
955
  return null;
949
956
  }
950
- if (adapterStatus.status === 'waiting_approval' && !this.shouldAutoApprove()) {
957
+ // A session whose auto-approve is held by manual attendance IS parked on a
958
+ // modal awaiting the human — autoApproveEffectivelyActive folds that in, so
959
+ // the mesh force-inject guard correctly treats it as modal-parked.
960
+ if (adapterStatus.status === 'waiting_approval' && !this.autoApproveEffectivelyActive(adapterStatus.status)) {
951
961
  return 'waiting_approval';
952
962
  }
953
963
  return null;
@@ -1402,6 +1412,26 @@ export class CliProviderInstance implements ProviderInstance {
1402
1412
  this.completedDebounceTimer = setTimeout(() => this.flushCompletedDebounceIfFinalized(), delayMs);
1403
1413
  }
1404
1414
 
1415
+ // EVTTRACE (observation-only): is this a mesh worker session whose completion
1416
+ // events must route to a coordinator? Used purely to gate trace logging so a
1417
+ // non-mesh CLI session's completions don't add EvtTrace noise. No decision logic.
1418
+ private isMeshWorkerSession(): boolean {
1419
+ return !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId
1420
+ || this.settings.meshNodeId || this.settings.launchedByCoordinator);
1421
+ }
1422
+
1423
+ // EVTTRACE correlation context for this session's completion lifecycle. taskId is
1424
+ // the primary grep anchor; instanceId is the session fallback.
1425
+ private meshTraceCtx(event = 'agent:generating_completed'): Record<string, unknown> {
1426
+ return {
1427
+ taskId: this.settings.meshActiveTaskId,
1428
+ sessionId: this.instanceId,
1429
+ nodeId: this.settings.meshNodeId,
1430
+ meshId: this.settings.meshNodeFor,
1431
+ event,
1432
+ };
1433
+ }
1434
+
1405
1435
  private flushCompletedDebounceIfFinalized(): void {
1406
1436
  const pending = this.completedDebouncePending;
1407
1437
  if (!pending) {
@@ -1424,24 +1454,55 @@ export class CliProviderInstance implements ProviderInstance {
1424
1454
  if (block) {
1425
1455
  const blockReason = block.reason;
1426
1456
  const waitedMs = Date.now() - pending.firstObservedAt;
1427
- LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
1428
- if ((block.terminal && !block.allowTimeout) || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
1457
+ // CANON-C (completion-gate decouple): a block carrying `allowTimeout` is the
1458
+ // transcript-evidence gate the worker FSM has ALREADY reached idle and the only
1459
+ // thing missing is the append-only transcript's final assistant turn (a native-source
1460
+ // race: claude-cli owns its history externally and the file write trails the idle
1461
+ // transition). `allowTimeout` is set ONLY on the missing_final_assistant block, and
1462
+ // ONLY for mesh worker sessions (meshNodeFor / meshActiveTaskId / launchedByCoordinator).
1463
+ // The coordinator's sole path to learn this session is idle is agent:generating_completed,
1464
+ // so holding it up to COMPLETED_FINALIZATION_MAX_WAIT_MS (30s) leaves the coordinator
1465
+ // false-generating while the worker is done. Decouple the idle NOTIFICATION from the
1466
+ // transcript evidence: emit the completion immediately, marked weak
1467
+ // (completionDiagnostic.blockReason=missing_final_assistant, finalAssistantPresent=false).
1468
+ // The finalSummary is enriched on a SEPARATE path — the mesh reconcile loop reads the
1469
+ // transcript once written and re-emits a GENUINE completion (CANON-B weak→genuine
1470
+ // upgrade; buildPendingEventFingerprint keeps weak and genuine distinct so the enriched
1471
+ // one still surfaces, and isFalseIdleCompletion keeps the direct dispatch active until
1472
+ // then). All OTHER blocks (genuinely-busy adapter/partial/parsed states, transient
1473
+ // parse_error) keep the existing terminal-hold / 30s-retry behavior unchanged.
1474
+ const isTranscriptEvidenceGate = block.allowTimeout === true;
1475
+ LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} allowTimeout=${isTranscriptEvidenceGate} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
1476
+ if (!isTranscriptEvidenceGate && (block.terminal || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS)) {
1429
1477
  if (pending.loggedBlockReason !== blockReason) {
1430
1478
  LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
1479
+ // EVTTRACE: completion held by the finalization gate (CANON-C). Observation
1480
+ // only — does not change the hold decision above.
1481
+ if (this.isMeshWorkerSession()) {
1482
+ traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `${blockReason} waited=${waitedMs}ms`);
1483
+ }
1431
1484
  pending.loggedBlockReason = blockReason;
1432
1485
  }
1433
1486
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
1434
1487
  return;
1435
1488
  }
1489
+ const emittedAfterFinalizationTimeout = waitedMs >= COMPLETED_FINALIZATION_MAX_WAIT_MS;
1436
1490
  const completionDiagnostic = this.buildCompletedFinalizationDiagnostic({
1437
1491
  blockReason,
1438
1492
  latestStatus,
1439
1493
  latestVisibleStatus,
1440
1494
  waitedMs,
1441
1495
  pending,
1442
- emittedAfterFinalizationTimeout: true,
1496
+ emittedAfterFinalizationTimeout,
1443
1497
  });
1444
- LOG.warn('CLI', `[${this.type}] emitting completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
1498
+ // Surface the CANON-C immediate-emit path distinctly so a delegated worker's idle
1499
+ // notification (transcript still pending) is not mistaken for a 30s-timeout fallback.
1500
+ (completionDiagnostic as Record<string, unknown>).decoupledImmediateEmit = isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout;
1501
+ LOG.warn('CLI', `[${this.type}] emitting completed event (${isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout ? 'CANON-C decoupled-immediate, transcript pending' : `after ${waitedMs}ms`}) without finalized assistant turn (${blockReason})`);
1502
+ // EVTTRACE: completion fired (forced past the finalization timeout / CANON-C decoupled-immediate).
1503
+ if (this.isMeshWorkerSession()) {
1504
+ traceMeshEventStage('fired', this.meshTraceCtx(), `forced after ${waitedMs}ms (${blockReason})`);
1505
+ }
1445
1506
  this.pushEvent({
1446
1507
  event: 'agent:generating_completed',
1447
1508
  chatTitle: pending.chatTitle,
@@ -1467,6 +1528,10 @@ export class CliProviderInstance implements ProviderInstance {
1467
1528
  }
1468
1529
 
1469
1530
  LOG.info('CLI', `[${this.type}] completed in ${pending.duration}s`);
1531
+ // EVTTRACE: completion fired (transcript finalized cleanly).
1532
+ if (this.isMeshWorkerSession()) {
1533
+ traceMeshEventStage('fired', this.meshTraceCtx(), `duration=${pending.duration}s`);
1534
+ }
1470
1535
  this.pushEvent({
1471
1536
  event: 'agent:generating_completed',
1472
1537
  chatTitle: pending.chatTitle,
@@ -1481,6 +1546,29 @@ export class CliProviderInstance implements ProviderInstance {
1481
1546
  }
1482
1547
 
1483
1548
  private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
1549
+ // Manual-attendance suppression (provider-common): when a human is
1550
+ // actively driving this session from the dashboard, hold auto-approve so
1551
+ // the modal stays visible and they can pick a button / use the controlbar
1552
+ // themselves. Return false (NOT auto-approving) so getState keeps the
1553
+ // modal surfaced. Clear any in-progress settle gate — a genuine fire
1554
+ // after the window lapses must re-settle from scratch — and arm a
1555
+ // re-check for the lapse moment, because the PTY may have gone silent and
1556
+ // would otherwise never re-drive this decision. Background mesh workers
1557
+ // are never attended, so their delegated auto-approve is untouched.
1558
+ if (adapterStatus?.status === 'waiting_approval'
1559
+ && this.shouldAutoApprove()
1560
+ && this.manualAttendance.isAttended(now)) {
1561
+ this.lastAutoApprovalSignature = '';
1562
+ this.pendingAutoApprovalSignature = '';
1563
+ this.pendingAutoApprovalSince = 0;
1564
+ this.autoApproveInactiveSince = 0;
1565
+ if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
1566
+ this.autoApproveSettleTimer = setTimeout(() => {
1567
+ this.autoApproveSettleTimer = null;
1568
+ this.recheckAutoApproveSettled();
1569
+ }, this.manualAttendance.remainingMs(now) + 20);
1570
+ return false;
1571
+ }
1484
1572
  const autoApproveActive = adapterStatus?.status === 'waiting_approval' && this.shouldAutoApprove();
1485
1573
  // Guard re-entry: onStatusChange/getState can observe the same modal multiple
1486
1574
  // times while the PTY absorbs the approval key. Without this flag, repeated
@@ -1805,7 +1893,12 @@ export class CliProviderInstance implements ProviderInstance {
1805
1893
  LOG.info('CLI', `[${this.type}] short completion suppressed: missing final assistant evidence, no mesh context (source=${shortEvidenceSource})`);
1806
1894
  // completedDebouncePending intentionally left null — the session is now idle
1807
1895
  // with no confirmed turn, matching the startup-blip suppression semantics.
1896
+ // (No EvtTrace: not a mesh session, so nothing routes to a coordinator.)
1808
1897
  } else {
1898
+ // EVTTRACE: completion fired (short-generating idle path).
1899
+ if (this.isMeshWorkerSession()) {
1900
+ traceMeshEventStage('fired', this.meshTraceCtx(), `short-generating idle (source=${shortEvidenceSource})`);
1901
+ }
1809
1902
  this.pushEvent({
1810
1903
  event: 'agent:generating_completed',
1811
1904
  chatTitle,
@@ -1886,6 +1979,10 @@ export class CliProviderInstance implements ProviderInstance {
1886
1979
  && !this.hasAdapterPendingResponse()
1887
1980
  && !hasNonEmptyCliModalButtons(monitorParsedStatus?.activeModal ?? monitorParsedStatus?.modal)
1888
1981
  ) {
1982
+ // EVTTRACE: completion fired (no-progress monitor reconciled to completion).
1983
+ if (this.isMeshWorkerSession()) {
1984
+ traceMeshEventStage('fired', this.meshTraceCtx(), 'no_progress_monitor_final_summary');
1985
+ }
1889
1986
  this.pushEvent({
1890
1987
  event: 'agent:generating_completed',
1891
1988
  chatTitle,
@@ -2096,6 +2193,25 @@ export class CliProviderInstance implements ProviderInstance {
2096
2193
  return false;
2097
2194
  }
2098
2195
 
2196
+ /** @see ProviderInstance.noteManualInteraction */
2197
+ noteManualInteraction(now = Date.now()): void {
2198
+ this.manualAttendance.note(now);
2199
+ }
2200
+
2201
+ /**
2202
+ * Whether auto-approve should be treated as active *right now* for display
2203
+ * and firing decisions: the configured intent AND the user is not currently
2204
+ * attending this session by hand. When a human is attending, auto-approve is
2205
+ * held so the modal stays visible and they can drive it via the controlbar.
2206
+ * Provider-agnostic — the attendance signal is the command set, never any
2207
+ * CLI-specific modal text.
2208
+ */
2209
+ private autoApproveEffectivelyActive(status: string | undefined, now = Date.now()): boolean {
2210
+ return status === 'waiting_approval'
2211
+ && this.shouldAutoApprove()
2212
+ && !this.manualAttendance.isAttended(now);
2213
+ }
2214
+
2099
2215
  private recordAutoApproval(modalMessage?: string, buttonLabel?: string, now = Date.now()): void {
2100
2216
  this.appendRuntimeSystemMessage(
2101
2217
  formatAutoApprovalMessage(modalMessage, buttonLabel),
@@ -0,0 +1,85 @@
1
+ /**
2
+ * ManualAttendanceTracker — provider-agnostic "is a human driving this session
3
+ * right now" signal, used to suppress auto-approve while the user is taking
4
+ * manual control of a session from the dashboard.
5
+ *
6
+ * Why this exists
7
+ * ---------------
8
+ * When `autoApprove` is on, an approval modal is auto-dismissed within a few
9
+ * hundred ms of appearing. For a background mesh worker that is exactly the
10
+ * desired delegated behavior. But for a session the user is actively watching
11
+ * and operating (a base-node / foreground session), the auto-fire closes the
12
+ * modal before the human can pick a button — and likewise fights their use of
13
+ * the controlbar. The fix is to give the human a short quiet window: while they
14
+ * are attending the session by hand, auto-approve holds; once they go idle it
15
+ * resumes.
16
+ *
17
+ * The tracker holds only a timestamp. The *signal* — which commands count as
18
+ * "a human attending" — is decided by the caller (the command handler), and is
19
+ * the same set for every provider: foreground tab selection (select_session /
20
+ * open_panel), controlbar use (invoke_provider_script / set_mode / change_model
21
+ * / set_thought_level), manual approval (resolve_action) and manual terminal
22
+ * input (pty_input). Notably NOT send_chat, which is also how a coordinator
23
+ * delegates a task to a worker — counting it would wrongly suppress the
24
+ * worker's delegated auto-approve.
25
+ *
26
+ * Because a background worker never receives any of those attending commands,
27
+ * it is never "attended", so its delegated auto-approve is unaffected. The
28
+ * mechanism is therefore provider-common AND preserves worker auto-approve
29
+ * without any per-provider branching.
30
+ */
31
+
32
+ /**
33
+ * How long after the last manual interaction auto-approve stays suppressed.
34
+ *
35
+ * Trade-off: long enough that after foregrounding a session's tab the user has
36
+ * a realistic chance to act on an incoming approval (the auto-approve settle
37
+ * window is only ~600ms, so a human cannot out-race it), yet short enough that
38
+ * a session left unattended — e.g. a worker tab the user briefly peeked at —
39
+ * resumes auto-approving within about a minute. Re-armed on every attending
40
+ * command, so a user who keeps interacting keeps the window fresh.
41
+ */
42
+ export const AUTO_APPROVE_MANUAL_ATTENDANCE_SUPPRESS_MS = 60_000;
43
+
44
+ export class ManualAttendanceTracker {
45
+ private lastInteractionAt = 0;
46
+
47
+ constructor(private readonly suppressMs: number = AUTO_APPROVE_MANUAL_ATTENDANCE_SUPPRESS_MS) {}
48
+
49
+ /** Record that a human just drove this session by hand. */
50
+ note(now = Date.now()): void {
51
+ this.lastInteractionAt = now;
52
+ }
53
+
54
+ /** True while a manual interaction is recent enough to suppress auto-approve. */
55
+ isAttended(now = Date.now()): boolean {
56
+ return this.lastInteractionAt > 0 && (now - this.lastInteractionAt) < this.suppressMs;
57
+ }
58
+
59
+ /**
60
+ * Milliseconds remaining in the current suppression window, or 0 when not
61
+ * attended. Used to re-arm a re-check timer so auto-approve fires the moment
62
+ * the window lapses even if the PTY/agent has since gone silent.
63
+ */
64
+ remainingMs(now = Date.now()): number {
65
+ if (this.lastInteractionAt <= 0) return 0;
66
+ return Math.max(0, this.suppressMs - (now - this.lastInteractionAt));
67
+ }
68
+ }
69
+
70
+ /**
71
+ * The session-scoped commands that count as "a human is attending this session
72
+ * by hand". Shared so the command handler and any forward path agree on one
73
+ * definition. Deliberately excludes send_chat (coordinator task delegation) and
74
+ * pure read commands (read_chat / list_chats — passive polling, not driving).
75
+ */
76
+ export const MANUAL_ATTENDANCE_COMMANDS: ReadonlySet<string> = new Set([
77
+ 'select_session',
78
+ 'open_panel',
79
+ 'invoke_provider_script',
80
+ 'set_mode',
81
+ 'change_model',
82
+ 'set_thought_level',
83
+ 'resolve_action',
84
+ 'pty_input',
85
+ ]);
@@ -226,6 +226,15 @@ export interface ProviderInstance {
226
226
  /** Refresh static provider definition/scripts without restarting the live runtime. */
227
227
  refreshProviderDefinition?(provider: ProviderModule): void;
228
228
 
229
+ /**
230
+ * Record that a human is actively attending this session by hand right now
231
+ * (foreground tab selection, controlbar use, manual approval, terminal
232
+ * input). Provider-common signal that suppresses auto-approve for a short
233
+ * window so the user can drive the session manually; background mesh worker
234
+ * sessions never receive it, so their delegated auto-approve is unaffected.
235
+ */
236
+ noteManualInteraction?(now?: number): void;
237
+
229
238
  /** cleanup */
230
239
  dispose(): void;
231
240
  }
@@ -51,6 +51,43 @@ export interface TerminalAdapterHandlers {
51
51
  tick?(): void;
52
52
  }
53
53
 
54
+ /**
55
+ * One entry in the PTY input/output event timeline (debug-only). Captured at
56
+ * the single common point every spec@4 provider funnels through — this adapter
57
+ * — so the Spec Debug Snapshot can answer "what input / output preceded a status
58
+ * transition?". Observation only; nothing here feeds the FSM decision.
59
+ */
60
+ export interface SpecPtyEvent {
61
+ /** Wall-clock ms. */
62
+ ts: number;
63
+ kind: 'spawn' | 'input' | 'output' | 'resize' | 'cursor' | 'exit';
64
+ /** Human-readable, control-char-escaped, length-capped preview. */
65
+ content: string;
66
+ /** Raw byte length before truncation (output/input only). */
67
+ bytes?: number;
68
+ }
69
+
70
+ const MAX_PTY_EVENTS = 300;
71
+ const EVENT_CONTENT_CAP = 240;
72
+
73
+ /** Escape control characters into a visible form so the timeline is readable
74
+ * (CR/LF/ESC/tab become \r \n \x1b \t; other C0 controls become \xNN). */
75
+ function escapeControl(text: string): string {
76
+ // eslint-disable-next-line no-control-regex
77
+ return String(text).replace(/[\x00-\x1f\x7f]/g, (ch) => {
78
+ const code = ch.charCodeAt(0);
79
+ if (ch === '\r') return '\\r';
80
+ if (ch === '\n') return '\\n';
81
+ if (ch === '\t') return '\\t';
82
+ if (code === 0x1b) return '\\x1b';
83
+ return '\\x' + code.toString(16).padStart(2, '0');
84
+ });
85
+ }
86
+
87
+ function capPreview(text: string): string {
88
+ return text.length > EVENT_CONTENT_CAP ? text.slice(0, EVENT_CONTENT_CAP) + `…(+${text.length - EVENT_CONTENT_CAP})` : text;
89
+ }
90
+
54
91
  export class TerminalAdapter {
55
92
  private rows: number;
56
93
  private cols: number;
@@ -62,6 +99,9 @@ export class TerminalAdapter {
62
99
  private screenTimer: ReturnType<typeof setTimeout> | null = null;
63
100
  private tickTimer: ReturnType<typeof setInterval> | null = null;
64
101
  private lastScreen = '';
102
+ /** Debug-only ring buffer of PTY input/output/resize/cursor events. */
103
+ private events: SpecPtyEvent[] = [];
104
+ private lastCursorKey = '';
65
105
 
66
106
  constructor(
67
107
  private readonly opts: TerminalAdapterOpts,
@@ -95,10 +135,12 @@ export class TerminalAdapter {
95
135
  cols: this.cols,
96
136
  rows: this.rows,
97
137
  });
138
+ this.recordEvent('spawn', `${this.opts.binary} (${this.cols}x${this.rows})`);
98
139
  this.handlers.init?.({ pid: this.pty.pid });
99
140
  this.pty.onData((chunk) => this.onChunk(chunk));
100
141
  this.pty.onExit((info) => {
101
142
  this.stopTimers();
143
+ this.recordEvent('exit', `exitCode=${typeof info.exitCode === 'number' ? info.exitCode : 0}`);
102
144
  this.handlers.on_exit?.({ exitCode: typeof info.exitCode === 'number' ? info.exitCode : 0 });
103
145
  this.pty = null;
104
146
  });
@@ -109,6 +151,7 @@ export class TerminalAdapter {
109
151
 
110
152
  resize(cols: number, rows: number): void {
111
153
  this.cols = cols; this.rows = rows;
154
+ this.recordEvent('resize', `${cols}x${rows}`);
112
155
  this.pty?.resize(cols, rows);
113
156
  this.screen.resize(rows, cols);
114
157
  }
@@ -133,9 +176,24 @@ export class TerminalAdapter {
133
176
  }
134
177
 
135
178
  send_keys(text: string): void {
179
+ this.recordEvent('input', capPreview(escapeControl(text)), text.length);
136
180
  this.pty?.write(text);
137
181
  }
138
182
 
183
+ /** Debug-only: most-recent PTY input/output/resize/cursor events, oldest
184
+ * first. Pure observation — never consulted by the FSM. */
185
+ getEventTimeline(limit = MAX_PTY_EVENTS): SpecPtyEvent[] {
186
+ const n = Math.max(0, Math.min(limit, this.events.length));
187
+ return this.events.slice(this.events.length - n);
188
+ }
189
+
190
+ private recordEvent(kind: SpecPtyEvent['kind'], content: string, bytes?: number): void {
191
+ const ev: SpecPtyEvent = { ts: Date.now(), kind, content };
192
+ if (typeof bytes === 'number') ev.bytes = bytes;
193
+ this.events.push(ev);
194
+ if (this.events.length > MAX_PTY_EVENTS) this.events.splice(0, this.events.length - MAX_PTY_EVENTS);
195
+ }
196
+
139
197
  kill(): void {
140
198
  this.stopTimers();
141
199
  try { this.pty?.kill(); } catch { /* ignore */ }
@@ -144,6 +202,7 @@ export class TerminalAdapter {
144
202
  }
145
203
 
146
204
  private onChunk(chunk: string): void {
205
+ this.recordEvent('output', capPreview(escapeControl(chunk)), chunk.length);
147
206
  try { this.handlers.on_pty_data?.(chunk); } catch { /* user side */ }
148
207
  this.screen.write(chunk);
149
208
  // Coalesce snapshot emission — rapid bursts shouldn't fire 200x.
@@ -151,6 +210,14 @@ export class TerminalAdapter {
151
210
  this.screenTimer = setTimeout(() => {
152
211
  this.screenTimer = null;
153
212
  const snap = this.computeScreen();
213
+ // Record cursor movement at the (debounced) screen-change boundary
214
+ // rather than per output chunk, so the timeline isn't flooded.
215
+ const cur = this.screen.getCursorPosition();
216
+ const curKey = `${cur.row},${cur.col}`;
217
+ if (curKey !== this.lastCursorKey) {
218
+ this.lastCursorKey = curKey;
219
+ this.recordEvent('cursor', `(${cur.row},${cur.col})`);
220
+ }
154
221
  if (snap === this.lastScreen) return;
155
222
  this.lastScreen = snap;
156
223
  try { this.handlers.on_screen_changed?.(snap); } catch { /* user side */ }
@@ -552,6 +552,10 @@ export class SpecCliAdapter implements CliAdapter {
552
552
  // answers "why did this rule fire" after the fact, unlike the live
553
553
  // `fsm` field which only reflects the current instant.
554
554
  fsmHistory: this.driver.getFsmSnapshotHistory?.() ?? null,
555
+ // PTY input/output/resize/cursor event timeline (debug-only) so the
556
+ // snapshot shows what we typed / what the PTY printed around each
557
+ // status transition. Null for drivers without the timeline.
558
+ eventTimeline: this.driver.getEventTimeline?.() ?? null,
555
559
  // Extended fields
556
560
  name: this.cliName,
557
561
  status: this.getStatus().status,
@@ -962,6 +966,8 @@ export class SpecCliAdapter implements CliAdapter {
962
966
  // v4 FSM transition snapshot history — the captured pre-transition
963
967
  // evaluation table at each transition (null for v3 specs).
964
968
  fsmHistory: this.driver.getFsmSnapshotHistory?.() ?? null,
969
+ // PTY input/output/resize/cursor event timeline (debug-only).
970
+ eventTimeline: this.driver.getEventTimeline?.() ?? null,
965
971
  messages,
966
972
  committedMessages: messages,
967
973
  };
@@ -55,12 +55,24 @@ export function resolveSections(
55
55
  // Normalize anchor + context into parallel candidate lists. A
56
56
  // scalar anchor becomes a single-entry array; a single context
57
57
  // object applies to every entry; an array context is positional.
58
- // Candidates are tried IN ORDER as independent passes: the first
59
- // pattern that finds any line wins. This keeps a scalar anchor's
60
- // behavior identical, and lets an array express a preferred shape
61
- // (e.g. a box divider) with later entries as fallbacks (e.g. a
62
- // divider-less modal anchored on its question line) — the
63
- // fallback only takes over when the preferred pattern is absent.
58
+ // Each candidate resolves its own anchor line independently
59
+ // (anchor_last that candidate's LAST matching line, else its
60
+ // FIRST). Across candidates we then pick the TOPMOST resolved
61
+ // line, because a section's anchor marks the TOP of the block:
62
+ // among several recognized landmark shapes, the highest one
63
+ // bounds the whole block. This keeps a scalar anchor identical
64
+ // (one candidate), preserves a genuine box-top divider (it sits
65
+ // ABOVE the question line, so it still wins), and — crucially —
66
+ // stops a stray lower landmark from clipping the block: e.g. a
67
+ // claude approval whose numbered choices sit ABOVE the input-box
68
+ // `────` rule. anchor_last on the bare-divider pattern alone
69
+ // would latch that LOWER chrome rule and strand the buttons
70
+ // above it (deriveModal sees < min_count → auto-approve never
71
+ // fires); preferring the topmost landmark (here the question
72
+ // line just above the choices, matched by the fallback context)
73
+ // captures the whole modal. The fallback still only contributes
74
+ // when its own pattern matches, so non-modal screens are
75
+ // unaffected.
64
76
  const anchorPatterns = Array.isArray(sec.anchor) ? sec.anchor : [sec.anchor];
65
77
  const sharedCtx: AnchorContext | null = Array.isArray(sec.anchor_context)
66
78
  ? null
@@ -84,12 +96,15 @@ export function resolveSections(
84
96
  && (c.nextRe === null || (i < total - 1 && c.nextRe.test(lines[i + 1])));
85
97
  let idx = -1;
86
98
  for (const c of candidates) {
99
+ let candIdx = -1;
87
100
  if (sec.anchor_last) {
88
- for (let i = total - 1; i >= 0; i--) { if (matchesCandidate(c, i)) { idx = i; break; } }
101
+ for (let i = total - 1; i >= 0; i--) { if (matchesCandidate(c, i)) { candIdx = i; break; } }
89
102
  } else {
90
- for (let i = 0; i < total; i++) { if (matchesCandidate(c, i)) { idx = i; break; } }
103
+ for (let i = 0; i < total; i++) { if (matchesCandidate(c, i)) { candIdx = i; break; } }
91
104
  }
92
- if (idx !== -1) break;
105
+ // Topmost resolved anchor across candidates wins (see note
106
+ // above): keep the smallest matching line index.
107
+ if (candIdx !== -1 && (idx === -1 || candIdx < idx)) idx = candIdx;
93
108
  }
94
109
  if (idx !== -1) {
95
110
  from = idx;