@adhdev/daemon-core 0.9.82-rc.196 → 0.9.82-rc.198

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.
@@ -10,6 +10,7 @@ import type { MeshLedgerKind, SessionRecoveryContext } from './mesh-ledger.js';
10
10
  import { buildMeshNodeCapabilityTags, claimNextTask, updateSessionTaskStatus, enqueueTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, getActiveDirectDispatches } from './mesh-work-queue.js';
11
11
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
12
12
  import { fastForwardMeshNode } from './mesh-fast-forward.js';
13
+ import { createSessionDelivery, updateSessionDeliveryStatus, recordCompletionConflict } from './mesh-delivery-policy.js';
13
14
 
14
15
  // ---------------------------------------------------------------------------
15
16
  // Remote Node Idle Session Tracking
@@ -499,10 +500,27 @@ function isDuplicateMeshCompletionEvent(args: {
499
500
  timestamp?: number | null;
500
501
  finalSummary?: string;
501
502
  coordinatorDaemonId?: string;
503
+ taskId?: string;
504
+ nodeId?: string;
502
505
  }): boolean {
503
506
  const fingerprint = buildMeshCompletionFingerprint(args);
504
507
  if (!fingerprint) return false;
505
- if (hasFingerprintSeen(fingerprint)) return true;
508
+ if (hasFingerprintSeen(fingerprint)) {
509
+ // Suppressed duplicate — but if we have a taskId and it differs from what the
510
+ // fingerprint was stamped for, record a conflict diagnostic so it doesn't disappear silently.
511
+ // (We can't recover the original taskId from the fingerprint alone, so we record
512
+ // the conflicting taskId/session as a diagnostic for coordinator inspection.)
513
+ if (args.taskId) {
514
+ recordCompletionConflict({
515
+ meshId: args.meshId,
516
+ fingerprint,
517
+ conflictingTaskId: args.taskId,
518
+ conflictingSessionId: args.sessionId,
519
+ event: args.event,
520
+ });
521
+ }
522
+ return true;
523
+ }
506
524
  recordFingerprintSeen(fingerprint);
507
525
  return false;
508
526
  }
@@ -837,13 +855,27 @@ export function tryAssignQueueTask(
837
855
  if (node?.daemonId && components.dispatchMeshCommand) {
838
856
  const isLocalNode = components.cliManager.adapters.has(sessionId);
839
857
  if (!isLocalNode) {
858
+ // Create delivery record before attempting P2P send
859
+ const delivery = createSessionDelivery({
860
+ meshId,
861
+ nodeId,
862
+ sessionId,
863
+ providerType,
864
+ taskId: task.id,
865
+ kind: 'task',
866
+ message: task.message,
867
+ status: 'delivering',
868
+ });
840
869
  components.dispatchMeshCommand(node.daemonId, 'agent_command', {
841
870
  targetSessionId: sessionId,
842
871
  cliType: providerType,
843
872
  action: 'send_chat',
844
873
  message: task.message,
874
+ }).then(() => {
875
+ updateSessionDeliveryStatus(delivery.id, 'delivered');
845
876
  }).catch((e: any) => {
846
877
  LOG.error('MeshQueue', `Failed to dispatch task via P2P to remote node ${nodeId}: ${e?.message}`);
878
+ updateSessionDeliveryStatus(delivery.id, 'failed', { lastError: e?.message, incrementAttempt: true });
847
879
  // Revert to pending so the task can be retried rather than permanently failing
848
880
  updateTaskStatus(meshId, task.id, 'pending');
849
881
  try {
@@ -851,7 +883,7 @@ export function tryAssignQueueTask(
851
883
  kind: 'dispatch_failed' as any,
852
884
  nodeId,
853
885
  sessionId,
854
- payload: { taskId: task.id, error: e?.message, retryable: true },
886
+ payload: { taskId: task.id, deliveryId: delivery.id, error: e?.message, retryable: true },
855
887
  });
856
888
  } catch { /* ledger write is best-effort */ }
857
889
  });
@@ -859,14 +891,27 @@ export function tryAssignQueueTask(
859
891
  }
860
892
  }
861
893
 
862
- // Local routing
894
+ // Local routing — create delivery record before send_chat
895
+ const delivery = createSessionDelivery({
896
+ meshId,
897
+ nodeId,
898
+ sessionId,
899
+ providerType,
900
+ taskId: task.id,
901
+ kind: 'task',
902
+ message: task.message,
903
+ status: 'delivering',
904
+ });
863
905
  components.cliManager.handleCliCommand('agent_command', {
864
906
  targetSessionId: sessionId,
865
907
  cliType: providerType,
866
908
  action: 'send_chat',
867
909
  message: task.message,
910
+ }).then(() => {
911
+ updateSessionDeliveryStatus(delivery.id, 'delivered');
868
912
  }).catch((e: any) => {
869
913
  LOG.error('MeshQueue', `Failed to dispatch task locally to node ${nodeId}: ${e?.message}`);
914
+ updateSessionDeliveryStatus(delivery.id, 'failed', { lastError: e?.message, incrementAttempt: true });
870
915
  updateTaskStatus(meshId, task.id, 'failed');
871
916
  });
872
917
 
@@ -1625,6 +1670,8 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1625
1670
  // Scope dedup to the coordinator daemon so two coordinators for the same mesh
1626
1671
  // don't suppress each other's completion events via shared fingerprint table.
1627
1672
  coordinatorDaemonId: workerCoordinatorDaemonId || undefined,
1673
+ taskId: readNonEmptyString(args.metadataEvent.taskId) || undefined,
1674
+ nodeId: eventNodeId || undefined,
1628
1675
  });
1629
1676
  if (duplicateCompletion) {
1630
1677
  LOG.info('MeshEvents', `Suppressed duplicate completion for mesh ${args.meshId} session ${eventSessionId}`);
@@ -1641,6 +1688,8 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1641
1688
  timestamp: eventTimestamp,
1642
1689
  finalSummary: readNonEmptyString(args.metadataEvent.finalSummary) || undefined,
1643
1690
  coordinatorDaemonId: workerCoordinatorDaemonId || undefined,
1691
+ taskId: readNonEmptyString(args.metadataEvent.taskId) || undefined,
1692
+ nodeId: eventNodeId || undefined,
1644
1693
  });
1645
1694
  if (duplicateStopped) {
1646
1695
  LOG.info('MeshEvents', `Suppressed duplicate stopped event for mesh ${args.meshId} session ${eventSessionId}`);
@@ -143,6 +143,49 @@ export class MeshRuntimeStore {
143
143
  metadata TEXT,
144
144
  PRIMARY KEY (node_id, session_id)
145
145
  );
146
+
147
+ CREATE TABLE IF NOT EXISTS mesh_session_delivery (
148
+ id TEXT PRIMARY KEY,
149
+ mesh_id TEXT NOT NULL,
150
+ node_id TEXT,
151
+ session_id TEXT,
152
+ provider_type TEXT,
153
+ task_id TEXT,
154
+ kind TEXT NOT NULL,
155
+ priority INTEGER NOT NULL DEFAULT 0,
156
+ message TEXT NOT NULL,
157
+ status TEXT NOT NULL DEFAULT 'queued',
158
+ deliver_after TEXT,
159
+ expires_at TEXT,
160
+ attempt_count INTEGER NOT NULL DEFAULT 0,
161
+ source_coordinator_session_id TEXT,
162
+ source_coordinator_daemon_id TEXT,
163
+ last_error TEXT,
164
+ created_at TEXT NOT NULL,
165
+ updated_at TEXT NOT NULL
166
+ );
167
+
168
+ CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_mesh_status
169
+ ON mesh_session_delivery(mesh_id, status, created_at);
170
+ CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_session
171
+ ON mesh_session_delivery(mesh_id, session_id, status);
172
+ CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_task
173
+ ON mesh_session_delivery(mesh_id, task_id);
174
+
175
+ CREATE TABLE IF NOT EXISTS mesh_completion_conflicts (
176
+ id TEXT PRIMARY KEY,
177
+ mesh_id TEXT NOT NULL,
178
+ fingerprint TEXT NOT NULL,
179
+ conflicting_task_id TEXT,
180
+ conflicting_session_id TEXT,
181
+ original_task_id TEXT,
182
+ original_session_id TEXT,
183
+ event TEXT NOT NULL,
184
+ created_at TEXT NOT NULL
185
+ );
186
+
187
+ CREATE INDEX IF NOT EXISTS idx_mesh_completion_conflicts_mesh
188
+ ON mesh_completion_conflicts(mesh_id, created_at);
146
189
  `);
147
190
  }
148
191
 
@@ -547,4 +590,180 @@ export class MeshRuntimeStore {
547
590
  pruneExpiredRemoteIdleSessions(): void {
548
591
  this.db.prepare('DELETE FROM remote_idle_sessions WHERE expires_at <= ?').run(Date.now());
549
592
  }
593
+
594
+ // ── Session Delivery Queue ───────────────────────────────────────────────
595
+
596
+ insertSessionDelivery(entry: {
597
+ id: string;
598
+ meshId: string;
599
+ nodeId?: string;
600
+ sessionId?: string;
601
+ providerType?: string;
602
+ taskId?: string;
603
+ kind: string;
604
+ priority?: number;
605
+ message: string;
606
+ status: string;
607
+ deliverAfter?: string;
608
+ expiresAt?: string;
609
+ sourceCoordinatorSessionId?: string;
610
+ sourceCoordinatorDaemonId?: string;
611
+ createdAt: string;
612
+ updatedAt: string;
613
+ }): void {
614
+ this.db.prepare(`
615
+ INSERT OR REPLACE INTO mesh_session_delivery (
616
+ id, mesh_id, node_id, session_id, provider_type, task_id, kind, priority,
617
+ message, status, deliver_after, expires_at, attempt_count,
618
+ source_coordinator_session_id, source_coordinator_daemon_id,
619
+ last_error, created_at, updated_at
620
+ ) VALUES (
621
+ @id, @meshId, @nodeId, @sessionId, @providerType, @taskId, @kind, @priority,
622
+ @message, @status, @deliverAfter, @expiresAt, 0,
623
+ @sourceCoordinatorSessionId, @sourceCoordinatorDaemonId,
624
+ NULL, @createdAt, @updatedAt
625
+ )
626
+ `).run({
627
+ id: entry.id,
628
+ meshId: entry.meshId,
629
+ nodeId: entry.nodeId ?? null,
630
+ sessionId: entry.sessionId ?? null,
631
+ providerType: entry.providerType ?? null,
632
+ taskId: entry.taskId ?? null,
633
+ kind: entry.kind,
634
+ priority: entry.priority ?? 0,
635
+ message: entry.message,
636
+ status: entry.status,
637
+ deliverAfter: entry.deliverAfter ?? null,
638
+ expiresAt: entry.expiresAt ?? null,
639
+ sourceCoordinatorSessionId: entry.sourceCoordinatorSessionId ?? null,
640
+ sourceCoordinatorDaemonId: entry.sourceCoordinatorDaemonId ?? null,
641
+ createdAt: entry.createdAt,
642
+ updatedAt: entry.updatedAt,
643
+ });
644
+ this.maybeCheckpointWal();
645
+ }
646
+
647
+ updateSessionDeliveryStatus(id: string, status: string, opts?: { lastError?: string; incrementAttempt?: boolean }): void {
648
+ const now = new Date().toISOString();
649
+ if (opts?.incrementAttempt) {
650
+ this.db.prepare(`
651
+ UPDATE mesh_session_delivery
652
+ SET status = @status, last_error = @lastError, attempt_count = attempt_count + 1, updated_at = @updatedAt
653
+ WHERE id = @id
654
+ `).run({ id, status, lastError: opts?.lastError ?? null, updatedAt: now });
655
+ } else {
656
+ this.db.prepare(`
657
+ UPDATE mesh_session_delivery
658
+ SET status = @status, last_error = @lastError, updated_at = @updatedAt
659
+ WHERE id = @id
660
+ `).run({ id, status, lastError: opts?.lastError ?? null, updatedAt: now });
661
+ }
662
+ }
663
+
664
+ getActiveSessionDeliveries(meshId: string, sessionId?: string): Array<{
665
+ id: string; meshId: string; nodeId: string | null; sessionId: string | null;
666
+ providerType: string | null; taskId: string | null; kind: string; priority: number;
667
+ message: string; status: string; deliverAfter: string | null; expiresAt: string | null;
668
+ attemptCount: number; sourceCoordinatorSessionId: string | null;
669
+ sourceCoordinatorDaemonId: string | null; lastError: string | null;
670
+ createdAt: string; updatedAt: string;
671
+ }> {
672
+ const now = new Date().toISOString();
673
+ const sql = sessionId
674
+ ? `SELECT * FROM mesh_session_delivery WHERE mesh_id = ? AND session_id = ? AND status NOT IN ('delivered','completed','failed','expired','cancelled') AND (expires_at IS NULL OR expires_at > ?) ORDER BY priority DESC, created_at ASC`
675
+ : `SELECT * FROM mesh_session_delivery WHERE mesh_id = ? AND status NOT IN ('delivered','completed','failed','expired','cancelled') AND (expires_at IS NULL OR expires_at > ?) ORDER BY priority DESC, created_at ASC`;
676
+ const rows = sessionId
677
+ ? this.db.prepare(sql).all(meshId, sessionId, now) as Array<Record<string, unknown>>
678
+ : this.db.prepare(sql).all(meshId, now) as Array<Record<string, unknown>>;
679
+ return rows.map(r => ({
680
+ id: r.id as string,
681
+ meshId: r.mesh_id as string,
682
+ nodeId: r.node_id as string | null,
683
+ sessionId: r.session_id as string | null,
684
+ providerType: r.provider_type as string | null,
685
+ taskId: r.task_id as string | null,
686
+ kind: r.kind as string,
687
+ priority: r.priority as number,
688
+ message: r.message as string,
689
+ status: r.status as string,
690
+ deliverAfter: r.deliver_after as string | null,
691
+ expiresAt: r.expires_at as string | null,
692
+ attemptCount: r.attempt_count as number,
693
+ sourceCoordinatorSessionId: r.source_coordinator_session_id as string | null,
694
+ sourceCoordinatorDaemonId: r.source_coordinator_daemon_id as string | null,
695
+ lastError: r.last_error as string | null,
696
+ createdAt: r.created_at as string,
697
+ updatedAt: r.updated_at as string,
698
+ }));
699
+ }
700
+
701
+ expireStaleSessionDeliveries(meshId: string): void {
702
+ const now = new Date().toISOString();
703
+ this.db.prepare(`
704
+ UPDATE mesh_session_delivery
705
+ SET status = 'expired', updated_at = ?
706
+ WHERE mesh_id = ? AND expires_at IS NOT NULL AND expires_at <= ?
707
+ AND status NOT IN ('delivered','completed','failed','expired','cancelled')
708
+ `).run(now, meshId, now);
709
+ }
710
+
711
+ deleteSessionDeliveries(meshId: string): void {
712
+ this.db.prepare('DELETE FROM mesh_session_delivery WHERE mesh_id = ?').run(meshId);
713
+ }
714
+
715
+ // ── Completion Conflict Diagnostics ──────────────────────────────────────
716
+
717
+ recordCompletionConflict(entry: {
718
+ id: string;
719
+ meshId: string;
720
+ fingerprint: string;
721
+ conflictingTaskId?: string;
722
+ conflictingSessionId?: string;
723
+ originalTaskId?: string;
724
+ originalSessionId?: string;
725
+ event: string;
726
+ createdAt: string;
727
+ }): void {
728
+ this.db.prepare(`
729
+ INSERT OR IGNORE INTO mesh_completion_conflicts
730
+ (id, mesh_id, fingerprint, conflicting_task_id, conflicting_session_id,
731
+ original_task_id, original_session_id, event, created_at)
732
+ VALUES (@id, @meshId, @fingerprint, @conflictingTaskId, @conflictingSessionId,
733
+ @originalTaskId, @originalSessionId, @event, @createdAt)
734
+ `).run({
735
+ id: entry.id,
736
+ meshId: entry.meshId,
737
+ fingerprint: entry.fingerprint,
738
+ conflictingTaskId: entry.conflictingTaskId ?? null,
739
+ conflictingSessionId: entry.conflictingSessionId ?? null,
740
+ originalTaskId: entry.originalTaskId ?? null,
741
+ originalSessionId: entry.originalSessionId ?? null,
742
+ event: entry.event,
743
+ createdAt: entry.createdAt,
744
+ });
745
+ this.maybeCheckpointWal();
746
+ }
747
+
748
+ getRecentCompletionConflicts(meshId: string, limitMs: number = 60 * 60 * 1000): Array<{
749
+ id: string; meshId: string; fingerprint: string; conflictingTaskId: string | null;
750
+ conflictingSessionId: string | null; originalTaskId: string | null;
751
+ originalSessionId: string | null; event: string; createdAt: string;
752
+ }> {
753
+ const cutoff = new Date(Date.now() - limitMs).toISOString();
754
+ const rows = this.db.prepare(
755
+ 'SELECT * FROM mesh_completion_conflicts WHERE mesh_id = ? AND created_at >= ? ORDER BY created_at DESC LIMIT 50'
756
+ ).all(meshId, cutoff) as Array<Record<string, unknown>>;
757
+ return rows.map(r => ({
758
+ id: r.id as string,
759
+ meshId: r.mesh_id as string,
760
+ fingerprint: r.fingerprint as string,
761
+ conflictingTaskId: r.conflicting_task_id as string | null,
762
+ conflictingSessionId: r.conflicting_session_id as string | null,
763
+ originalTaskId: r.original_task_id as string | null,
764
+ originalSessionId: r.original_session_id as string | null,
765
+ event: r.event as string,
766
+ createdAt: r.created_at as string,
767
+ }));
768
+ }
550
769
  }
@@ -349,6 +349,10 @@ export class SpecCliAdapter implements CliAdapter {
349
349
  exited: this.exited,
350
350
  screen,
351
351
  sections,
352
+ stateHistory: this.driver.getStateHistory(),
353
+ idleHoldPending: this.driver.hasIdleHoldPending(),
354
+ lastBusyAt: this.driver.getLastBusyAt(),
355
+ specPath: this.driver.getSpecPath(),
352
356
  };
353
357
  }
354
358
  getRuntimeMetadata(): unknown {
@@ -218,7 +218,15 @@ export class SpecDriver {
218
218
  * explicit wake-up there's nothing to trigger the busy → idle
219
219
  * downshift. */
220
220
  private busyExpiryTimer: ReturnType<typeof setTimeout> | null = null;
221
+ /** Pending idle-commit timer. Armed when the evaluator first returns idle;
222
+ * fires after idle_hold_ms if no non-idle reading has cancelled it. */
223
+ private idleHoldTimer: ReturnType<typeof setTimeout> | null = null;
224
+ /** State snapshot captured when the idle hold was armed — emitted on commit. */
225
+ private pendingIdleState: SpecEvaluation['state'] | null = null;
221
226
  private specWatcher: fs.FSWatcher | null = null;
227
+ /** Ring buffer of committed state transitions (max 50). */
228
+ private stateHistory: Array<{ stateId: string; label: string; at: number; durationMs: number }> = [];
229
+ private prevStateAt = 0;
222
230
 
223
231
  constructor(private readonly opts: SpecDriverOpts) {
224
232
  this.loadSpecOrThrow();
@@ -277,10 +285,33 @@ export class SpecDriver {
277
285
  shutdown(): void {
278
286
  for (const t of this.delegateTimers.values()) clearTimeout(t);
279
287
  this.delegateTimers.clear();
288
+ this.cancelIdleHold();
289
+ if (this.busyExpiryTimer) { clearTimeout(this.busyExpiryTimer); this.busyExpiryTimer = null; }
280
290
  this.specWatcher?.close();
281
291
  this.adapter.kill();
282
292
  }
283
293
 
294
+ private cancelIdleHold(): void {
295
+ if (this.idleHoldTimer) { clearTimeout(this.idleHoldTimer); this.idleHoldTimer = null; }
296
+ this.pendingIdleState = null;
297
+ }
298
+
299
+ private pushHistory(stateId: string, label: string): void {
300
+ const now = Date.now();
301
+ const durationMs = this.prevStateAt > 0 ? now - this.prevStateAt : 0;
302
+ this.prevStateAt = now;
303
+ this.stateHistory.push({ stateId, label, at: now, durationMs });
304
+ if (this.stateHistory.length > 50) this.stateHistory.shift();
305
+ }
306
+
307
+ getStateHistory(): ReadonlyArray<{ stateId: string; label: string; at: number; durationMs: number }> {
308
+ return this.stateHistory;
309
+ }
310
+
311
+ getLastBusyAt(): number { return this.lastBusyAt; }
312
+ hasIdleHoldPending(): boolean { return this.idleHoldTimer !== null; }
313
+ getSpecPath(): string { return this.opts.specPath; }
314
+
284
315
  // ────────────────────────────────────────────────────────────────────
285
316
  // Loading & adapter wiring
286
317
  // ────────────────────────────────────────────────────────────────────
@@ -307,9 +338,13 @@ export class SpecDriver {
307
338
 
308
339
  private armSpecWatcher(): void {
309
340
  try {
310
- this.specWatcher = fs.watch(this.opts.specPath, { persistent: false }, () => {
311
- // Re-read; if it parses, replace and re-evaluate. Errors are
312
- // surfaced to the dashboard so the spec author sees them live.
341
+ // Watch the parent directory so we catch atomic replacements
342
+ // (cp, install scripts) that create a new inode — a file-level
343
+ // watch misses those on macOS because the original inode is gone.
344
+ const dir = path.dirname(this.opts.specPath);
345
+ const base = path.basename(this.opts.specPath);
346
+ this.specWatcher = fs.watch(dir, { persistent: false }, (_event, filename) => {
347
+ if (filename && filename !== base) return;
313
348
  const res = loadSpec(this.opts.specPath);
314
349
  if (!res.ok) { this.emit({ kind: 'spec_error', errors: res.errors }); return; }
315
350
  this.spec = res.spec;
@@ -411,12 +446,58 @@ export class SpecDriver {
411
446
  if (evState.id === 'busy') {
412
447
  this.lastBusyAt = Date.now();
413
448
  this.lastBusyState = evState;
449
+ // Cancel any pending idle commit — non-idle reading invalidates it.
450
+ this.cancelIdleHold();
414
451
  // Schedule a re-evaluation when the hold window expires. PTYs
415
452
  // typically stop emitting once the agent stops printing (the
416
453
  // footer settles), so without an explicit timer the driver
417
454
  // never wakes up to downshift to idle and the dashboard sees
418
455
  // status stuck at generating long after the turn ended.
419
456
  this.scheduleBusyExpiry(busyWakeMs);
457
+ } else if (evState.id !== this.currentStateId && evState.id !== 'busy') {
458
+ // Non-busy modal states (approval, picker, signing_in) also cancel
459
+ // any in-flight idle hold — they are higher-priority than idle.
460
+ if (evState.id !== (this.spec.default_state ?? 'idle')) {
461
+ this.cancelIdleHold();
462
+ }
463
+ }
464
+
465
+ // Idle hold: if idle_hold_ms is set, don't commit idle immediately.
466
+ // Arm a timer; if a non-idle reading arrives before it fires, cancel.
467
+ const idleHoldMs = this.spec.debounce?.idle_hold_ms ?? 0;
468
+ const isIdleState = evState.id === (this.spec.default_state ?? 'idle');
469
+ if (isIdleState && idleHoldMs > 0 && this.currentStateId !== evState.id) {
470
+ if (!this.idleHoldTimer) {
471
+ this.pendingIdleState = evState;
472
+ this.idleHoldTimer = setTimeout(() => {
473
+ this.idleHoldTimer = null;
474
+ const committed = this.pendingIdleState;
475
+ this.pendingIdleState = null;
476
+ if (!committed) return;
477
+ LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] idleHold committed after ${idleHoldMs}ms`);
478
+ this.currentStateId = committed.id;
479
+ this.currentEval = ev;
480
+ this.pushHistory(committed.id, committed.label);
481
+ this.emit({
482
+ kind: 'state_changed',
483
+ state: committed,
484
+ modal: null,
485
+ controls: ev.controls.map(c => ({ id: c.id, label: c.label, action_type: c.actionType })),
486
+ });
487
+ this.armOrCancelDelegateTimers(committed.id);
488
+ if (this.opts.emitTrace) this.emit({ kind: 'spec_trace', entries: ev.trace });
489
+ }, idleHoldMs);
490
+ }
491
+ // Don't fall through to the normal changed/emit path for idle.
492
+ this.currentEval = ev;
493
+ const graceMs2 = this.spec.debounce?.startup_grace_ms ?? STARTUP_GRACE_MS;
494
+ if (!this.idleSeenOnce && Date.now() - this.startedAtMs >= graceMs2) {
495
+ this.idleSeenOnce = true;
496
+ const queued = this.pendingSends.splice(0);
497
+ for (const text of queued) setTimeout(() => this.actuallySendMessage(text), 50);
498
+ }
499
+ if (this.pickerInProgress) this.tryAdvancePicker(screen);
500
+ return;
420
501
  }
421
502
 
422
503
  const changed = forceEmit
@@ -449,6 +530,7 @@ export class SpecDriver {
449
530
  }
450
531
  if (changed) {
451
532
  this.currentStateId = evState.id;
533
+ this.pushHistory(evState.id, evState.label);
452
534
  this.emit({
453
535
  kind: 'state_changed',
454
536
  state: evState,
@@ -268,6 +268,12 @@ export interface CliSpec {
268
268
  * Absorbs per-frame flicker in TUIs that stream output through
269
269
  * the same region as the spinner. */
270
270
  busy_hold_ms?: number;
271
+ /** Min time the idle state must remain matched before it is
272
+ * committed. Filters transient idle flickers that appear during
273
+ * approval dismissals, layout reflows, or brief spinner gaps.
274
+ * Any non-idle reading within the window cancels the transition.
275
+ * When omitted the idle transition is immediate (legacy behaviour). */
276
+ idle_hold_ms?: number;
271
277
  /** Min time after start() before a send_message is allowed to
272
278
  * reach the PTY. Banner paints + auth flows + skill listings
273
279
  * can keep the agent unable to accept input for several seconds