@adhdev/daemon-core 0.9.82-rc.327 → 0.9.82-rc.329

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/cli-adapters/provider-cli-shared.d.ts +1 -1
  2. package/dist/git/change-impact-config.d.ts +159 -0
  3. package/dist/git/git-status.d.ts +14 -0
  4. package/dist/git/index.d.ts +2 -0
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +1140 -607
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +1090 -564
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/mesh/mesh-active-work.d.ts +66 -0
  11. package/dist/mesh/mesh-events-stale.d.ts +1 -1
  12. package/dist/providers/status-monitor.d.ts +7 -7
  13. package/dist/shared-types.d.ts +1 -1
  14. package/package.json +2 -2
  15. package/src/agent-stream/provider-adapter.ts +1 -1
  16. package/src/cli-adapters/provider-cli-adapter.ts +2 -2
  17. package/src/cli-adapters/provider-cli-shared.ts +1 -1
  18. package/src/commands/chat-commands.ts +1 -1
  19. package/src/commands/cli-manager.ts +1 -1
  20. package/src/commands/router.ts +46 -0
  21. package/src/git/change-impact-config.ts +354 -0
  22. package/src/git/git-status.ts +182 -16
  23. package/src/git/index.ts +16 -0
  24. package/src/index.ts +2 -2
  25. package/src/mesh/mesh-active-work.ts +154 -0
  26. package/src/mesh/mesh-events-coordinator.ts +13 -12
  27. package/src/mesh/mesh-events-stale.ts +4 -4
  28. package/src/mesh/mesh-events-utils.ts +3 -3
  29. package/src/mesh/mesh-reconcile-loop.ts +144 -1
  30. package/src/providers/acp-provider-instance.ts +6 -5
  31. package/src/providers/cli-provider-instance.ts +14 -10
  32. package/src/providers/extension-provider-instance.ts +7 -6
  33. package/src/providers/ide-provider-instance.ts +10 -6
  34. package/src/providers/read-chat-contract.ts +1 -1
  35. package/src/providers/status-monitor.d.ts +7 -7
  36. package/src/providers/status-monitor.ts +37 -22
  37. package/src/shared-types.ts +3 -0
  38. package/src/status/reporter.ts +1 -1
@@ -55,7 +55,9 @@ import {
55
55
  expireStaleUnresolvedDelegateForwards,
56
56
  } from './mesh-unresolved-forward-outbox.js';
57
57
  import { readNonEmptyString } from './mesh-events-utils.js';
58
- import { getActiveDirectDispatches } from './mesh-work-queue.js';
58
+ import { getActiveDirectDispatches, getQueue } from './mesh-work-queue.js';
59
+ import { readLedgerEntries } from './mesh-ledger.js';
60
+ import { pruneStaleDirectDispatches } from './mesh-active-work.js';
59
61
  import { reconcileDirectDispatchCompletionFromTranscript } from './mesh-events-stale.js';
60
62
  import { extractFinalAssistantSummaryEvidence } from '../providers/chat-message-normalization.js';
61
63
  import type { ChatMessage } from '../types.js';
@@ -64,6 +66,25 @@ import type { ChatMessage } from '../types.js';
64
66
  // coordinator land within at most one interval. Overridable via env for tuning.
65
67
  const DEFAULT_RECONCILE_INTERVAL_MS = 4_000;
66
68
 
69
+ // PHASE 5 (auto-prune) conservative age gate. A direct dispatch whose node/session is
70
+ // orphaned (no longer in the live mesh) is only auto-pruned once it is at least this old,
71
+ // measured from its dispatch time. This protects against a node/session that is only
72
+ // *transiently* invisible (a momentary probe failure, a daemon restart) being pruned the
73
+ // instant it disappears. The MANUAL prune (mesh_prune_stale_direct) has no age gate — an
74
+ // operator pruning explicitly wants the orphan gone now. Overridable via env for tuning.
75
+ const DEFAULT_AUTO_PRUNE_MIN_AGE_MS = 24 * 60 * 60_000; // 24h
76
+
77
+ function resolveAutoPruneMinAgeMs(): number {
78
+ const raw = readNonEmptyString(process.env.MESH_AUTO_PRUNE_MIN_AGE_MS);
79
+ if (raw) {
80
+ const parsed = Number.parseInt(raw, 10);
81
+ // Clamp to [1h, 30d] so a mis-set env can't make the gate pathologically aggressive
82
+ // (prune the moment something blinks) or effectively disable it forever.
83
+ if (Number.isFinite(parsed) && parsed >= 60 * 60_000 && parsed <= 30 * 24 * 60 * 60_000) return parsed;
84
+ }
85
+ return DEFAULT_AUTO_PRUNE_MIN_AGE_MS;
86
+ }
87
+
67
88
  function resolveReconcileIntervalMs(): number {
68
89
  const raw = readNonEmptyString(process.env.MESH_RECONCILE_INTERVAL_MS);
69
90
  if (raw) {
@@ -310,6 +331,36 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
310
331
  }
311
332
  }
312
333
 
334
+ // ── PHASE 5: auto-prune orphaned direct dispatch records ───────────────────
335
+ // staleDirectWork (orphaned direct-dispatch rows whose node/session is no longer in the
336
+ // live mesh) otherwise accumulates indefinitely: a removed worktree node or a cleanly
337
+ // terminated session leaves its direct-dispatch row behind, stuck in a non-terminal status
338
+ // (e.g. generating) for days. This is NOT a false-idle bug — it is the separate problem of
339
+ // orphaned records that the only existing cleanup path (manual MCP mesh_prune_stale_direct)
340
+ // never reaches unless an operator runs it by hand.
341
+ //
342
+ // This phase runs the SAME prune core the manual tool calls (pruneStaleDirectDispatches),
343
+ // in execute mode, on the daemon timer. The only difference from the manual path is a
344
+ // conservative age gate (DEFAULT_AUTO_PRUNE_MIN_AGE_MS): a freshly-orphaned record is held
345
+ // back until it is provably stale, so a transient probe miss never auto-prunes live work.
346
+ // Every other safety rule is inherited unchanged from the core — active/pending/generating
347
+ // work and fresh unacknowledged dispatch failures are never pruned, ledger-only audit entries
348
+ // are preserved, and the prune itself is recorded with a direct_dispatch_pruned ledger entry.
349
+ // Idempotent: a pruned row is gone from getActiveDirectDispatches, so the next tick finds
350
+ // nothing to re-prune. Isolated in its own try/catch per mesh so it can never kill the tick.
351
+ {
352
+ const minAgeMs = resolveAutoPruneMinAgeMs();
353
+ for (const mesh of listMeshes()) {
354
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
355
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
356
+ try {
357
+ await autoPruneStaleDirectDispatches(components, mesh, selfIds, localDaemonId, minAgeMs);
358
+ } catch (e: any) {
359
+ LOG.warn('MeshReconcile', `Auto-prune stale direct failed for mesh ${mesh.id}: ${e?.message || e}`);
360
+ }
361
+ }
362
+ }
363
+
313
364
  // ── PHASE 2: inject into live CLI coordinators on this daemon ──────────────
314
365
  const coordinators = findLiveCoordinators(components);
315
366
  if (coordinators.length === 0) {
@@ -592,6 +643,98 @@ async function reconcileUnterminatedDirectDispatches(
592
643
  }
593
644
  }
594
645
 
646
+ // PHASE 5 helper. Build the live-node view (mesh.nodes decorated with each node's live
647
+ // session list) and run the shared prune core in execute mode with the conservative age gate.
648
+ //
649
+ // Orphan detection needs the SAME live-session evidence the manual MCP prune uses: a node still
650
+ // in mesh.nodes whose session list no longer contains the dispatched sessionId is "session not
651
+ // present" (prunable); a node missing from mesh.nodes entirely is "node no longer in live mesh"
652
+ // (prunable). We obtain live sessions per node via get_status_metadata — local nodes through the
653
+ // local commandHandler, remote nodes over P2P (dispatchMeshCommand) — exactly the transports
654
+ // PHASE 4 already uses. A node we cannot probe (offline) keeps an empty session list; combined
655
+ // with the age gate that only matters once the orphan is genuinely old.
656
+ //
657
+ // O(1) fast exit: when there are no active direct dispatches at all there is nothing to prune,
658
+ // so we skip the (per-node) status probes entirely — an idle mesh costs one indexed query.
659
+ async function autoPruneStaleDirectDispatches(
660
+ components: DaemonComponents,
661
+ mesh: LocalMeshEntry,
662
+ selfIds: string[],
663
+ localDaemonId: string | undefined,
664
+ minAgeMs: number,
665
+ ): Promise<void> {
666
+ const directDispatches = getActiveDirectDispatches(mesh.id);
667
+ if (directDispatches.length === 0) return; // nothing dispatched → nothing to prune
668
+
669
+ const liveNodes = await collectLiveNodesWithSessions(components, mesh, selfIds, localDaemonId);
670
+
671
+ const result = pruneStaleDirectDispatches({
672
+ meshId: mesh.id,
673
+ queue: getQueue(mesh.id),
674
+ ledgerEntries: readLedgerEntries(mesh.id, { tail: 500 }),
675
+ directDispatches,
676
+ nodes: liveNodes,
677
+ execute: true,
678
+ minAgeMs,
679
+ source: 'daemon_reconcile_auto_prune',
680
+ });
681
+
682
+ // Log only when something was actually pruned — silence on the common no-op tick.
683
+ if (result.prunedCount > 0) {
684
+ LOG.info('MeshReconcile', `Auto-pruned ${result.prunedCount} orphaned direct dispatch record(s) for mesh ${mesh.id}`);
685
+ }
686
+ }
687
+
688
+ // Probe each node for its live session list (get_status_metadata) and return mesh.nodes
689
+ // decorated with a `sessions` array — the shape buildMeshActiveWork / sessionStatusFromNodes
690
+ // consume to decide whether a dispatched session is still present. Best-effort: an unreachable
691
+ // node yields an empty session list rather than throwing.
692
+ async function collectLiveNodesWithSessions(
693
+ components: DaemonComponents,
694
+ mesh: LocalMeshEntry,
695
+ selfIds: string[],
696
+ localDaemonId: string | undefined,
697
+ ): Promise<any[]> {
698
+ const dispatchMeshCommand = components.dispatchMeshCommand;
699
+ return Promise.all(mesh.nodes.map(async (node) => {
700
+ const nodeDaemonId = readNonEmptyString(node.daemonId);
701
+ const isLocalNode = !nodeDaemonId
702
+ || selfIds.includes(nodeDaemonId)
703
+ || (localDaemonId !== undefined && nodeDaemonId === localDaemonId);
704
+ let statusResult: unknown;
705
+ try {
706
+ if (isLocalNode) {
707
+ statusResult = await components.commandHandler.handle('get_status_metadata', {});
708
+ } else if (dispatchMeshCommand) {
709
+ statusResult = await dispatchMeshCommand(nodeDaemonId, 'get_status_metadata', {});
710
+ } else {
711
+ return node; // remote node, no P2P transport — leave undecorated
712
+ }
713
+ } catch {
714
+ return node; // unreachable — leave undecorated (empty session list)
715
+ }
716
+ const sessions = extractStatusMetadataSessions(statusResult);
717
+ return sessions.length > 0 ? { ...node, sessions } : node;
718
+ }));
719
+ }
720
+
721
+ // Pull the live session list out of a get_status_metadata result, tolerating the same
722
+ // envelope shapes unwrapReadChatPayload handles (direct CommandResult or { payload }/{ result }).
723
+ function extractStatusMetadataSessions(raw: unknown): any[] {
724
+ let cursor: unknown = raw;
725
+ for (let depth = 0; depth < 4 && cursor && typeof cursor === 'object'; depth++) {
726
+ const record = cursor as Record<string, unknown>;
727
+ const status = record.status && typeof record.status === 'object' ? record.status as Record<string, unknown> : undefined;
728
+ if (status && Array.isArray(status.sessions)) return status.sessions;
729
+ if (Array.isArray(record.sessions)) return record.sessions;
730
+ if (record.payload && typeof record.payload === 'object') { cursor = record.payload; continue; }
731
+ if (record.result && typeof record.result === 'object') { cursor = record.result; continue; }
732
+ if (record.data && typeof record.data === 'object') { cursor = record.data; continue; }
733
+ break;
734
+ }
735
+ return [];
736
+ }
737
+
595
738
  function extractPendingEvents(raw: unknown): any[] {
596
739
  if (Array.isArray(raw)) return raw;
597
740
  if (raw && typeof raw === 'object') {
@@ -329,8 +329,8 @@ export class AcpProviderInstance implements ProviderInstance {
329
329
  this.settings = context.settings || {};
330
330
  this.monitor.updateConfig({
331
331
  approvalAlert: this.settings.approvalAlert !== false,
332
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
333
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
332
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
333
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
334
334
  });
335
335
 
336
336
  await this.spawnAgent();
@@ -674,8 +674,8 @@ export class AcpProviderInstance implements ProviderInstance {
674
674
  this.settings = { ...this.settings, ...newSettings };
675
675
  this.monitor.updateConfig({
676
676
  approvalAlert: this.settings.approvalAlert !== false,
677
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
678
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
677
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
678
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
679
679
  });
680
680
  this.log.info(`[${this.type}] Settings updated: ${Object.keys(newSettings).join(', ')}`);
681
681
  }
@@ -1533,7 +1533,8 @@ export class AcpProviderInstance implements ProviderInstance {
1533
1533
 
1534
1534
  // Monitor check
1535
1535
  const agentKey = `${this.type}:acp`;
1536
- const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint);
1536
+ const approvalPending = newStatus === 'waiting_approval';
1537
+ const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint, approvalPending);
1537
1538
  for (const me of monitorEvents) {
1538
1539
  this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
1539
1540
  }
@@ -183,7 +183,7 @@ function hasNonEmptyCliModalButtons(activeModal: unknown): boolean {
183
183
  }
184
184
 
185
185
  function isCliGeneratingLikeStatus(status: unknown): boolean {
186
- return status === 'generating' || status === 'streaming' || status === 'long_generating' || status === 'starting';
186
+ return status === 'generating' || status === 'streaming' || status === 'no_progress' || status === 'long_generating' || status === 'starting';
187
187
  }
188
188
 
189
189
  export function buildCliStructuredInputPrompt(
@@ -479,8 +479,8 @@ export class CliProviderInstance implements ProviderInstance {
479
479
  this.adapter.updateRuntimeSettings?.(this.settings);
480
480
  this.monitor.updateConfig({
481
481
  approvalAlert: this.settings.approvalAlert !== false,
482
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
483
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
482
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
483
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
484
484
  });
485
485
 
486
486
  // Server connection
@@ -698,7 +698,7 @@ export class CliProviderInstance implements ProviderInstance {
698
698
  && adapterStatus.status === 'idle'
699
699
  && parsedStatus?.status === 'idle';
700
700
  let messagesToSave = parsedMessages;
701
- if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'long_generating')) {
701
+ if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'no_progress' || parsedChatStatus === 'long_generating')) {
702
702
  const lastIdx = messagesToSave.length - 1;
703
703
  if (lastIdx >= 0 && messagesToSave[lastIdx]?.role === 'assistant') {
704
704
  messagesToSave = messagesToSave.slice(0, lastIdx);
@@ -866,8 +866,8 @@ export class CliProviderInstance implements ProviderInstance {
866
866
  this.adapter.updateRuntimeSettings?.(this.settings);
867
867
  this.monitor.updateConfig({
868
868
  approvalAlert: this.settings.approvalAlert !== false,
869
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
870
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
869
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
870
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
871
871
  });
872
872
  }
873
873
 
@@ -1545,7 +1545,7 @@ export class CliProviderInstance implements ProviderInstance {
1545
1545
  const dirName = workingDirBasename(this.workingDir);
1546
1546
  const chatTitle = `${this.provider.name} · ${dirName}`;
1547
1547
  const partial = this.adapter.getPartialResponse();
1548
- // Liveness fingerprint for the long-generating watchdog. The parsed
1548
+ // Liveness fingerprint for the no-progress watchdog. The parsed
1549
1549
  // assistant buffer (`partial`) alone goes static while a tool/build runs
1550
1550
  // — the assistant emits no tokens even though the PTY is actively
1551
1551
  // printing tool output — which made the watchdog false-fire a "stuck"
@@ -1764,11 +1764,15 @@ export class CliProviderInstance implements ProviderInstance {
1764
1764
 
1765
1765
  // Monitor check (cooldown based notification, IDE/CLI common)
1766
1766
  const agentKey = `${this.type}:cli`;
1767
- const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint);
1767
+ // Approval pending is detected from the raw adapter status, not `newStatus`:
1768
+ // auto-approve synthesizes `waiting_approval` → 'generating', which would
1769
+ // otherwise let the no-progress watchdog accumulate the approval wait.
1770
+ const approvalPending = rawStatus === 'waiting_approval';
1771
+ const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint, approvalPending);
1768
1772
  const monitorParsedStatus: any = parsedStatus;
1769
1773
  for (const me of monitorEvents) {
1770
1774
  if (
1771
- me.type === 'monitor:long_generating'
1775
+ me.type === 'monitor:no_progress'
1772
1776
  && this.completionHasFinalAssistantMessage(monitorParsedStatus?.messages)
1773
1777
  && !this.hasAdapterPendingResponse()
1774
1778
  && !hasNonEmptyCliModalButtons(monitorParsedStatus?.activeModal ?? monitorParsedStatus?.modal)
@@ -1783,7 +1787,7 @@ export class CliProviderInstance implements ProviderInstance {
1783
1787
  providerType: this.type,
1784
1788
  sessionId: this.instanceId,
1785
1789
  providerSessionId: this.providerSessionId || null,
1786
- reconciliationReason: 'long_generating_monitor_final_summary',
1790
+ reconciliationReason: 'no_progress_monitor_final_summary',
1787
1791
  finalAssistantPresent: true,
1788
1792
  },
1789
1793
  });
@@ -63,8 +63,8 @@ export class ExtensionProviderInstance implements ProviderInstance {
63
63
  this.settings = context.settings || {};
64
64
  this.monitor.updateConfig({
65
65
  approvalAlert: this.settings.approvalAlert !== false,
66
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
67
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
66
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
67
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
68
68
  });
69
69
  }
70
70
 
@@ -178,8 +178,8 @@ export class ExtensionProviderInstance implements ProviderInstance {
178
178
  this.settings = { ...this.settings, ...newSettings };
179
179
  this.monitor.updateConfig({
180
180
  approvalAlert: this.settings.approvalAlert !== false,
181
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
182
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
181
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
182
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
183
183
  });
184
184
  }
185
185
 
@@ -251,9 +251,10 @@ export class ExtensionProviderInstance implements ProviderInstance {
251
251
  : 'immediate',
252
252
  });
253
253
 
254
- // Monitor check (cooldown based notification) — keep monitor events (long_generating etc)
254
+ // Monitor check (cooldown based notification) — keep monitor events (no_progress etc)
255
255
  const agentKey = `${this.type}:ext`;
256
- const monitorEvents = this.monitor.check(agentKey, agentStatus, now, progressFingerprint);
256
+ const approvalPending = agentStatus === 'waiting_approval';
257
+ const monitorEvents = this.monitor.check(agentKey, agentStatus, now, progressFingerprint, approvalPending);
257
258
  for (const me of monitorEvents) {
258
259
  this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
259
260
  }
@@ -105,8 +105,8 @@ export class IdeProviderInstance implements ProviderInstance {
105
105
  // Sync Monitor config
106
106
  this.monitor.updateConfig({
107
107
  approvalAlert: this.settings.approvalAlert !== false,
108
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
109
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
108
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
109
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
110
110
  });
111
111
  }
112
112
 
@@ -267,8 +267,8 @@ export class IdeProviderInstance implements ProviderInstance {
267
267
  this.settings = { ...this.settings, ...newSettings };
268
268
  this.monitor.updateConfig({
269
269
  approvalAlert: this.settings.approvalAlert !== false,
270
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
271
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
270
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
271
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
272
272
  });
273
273
  }
274
274
 
@@ -417,7 +417,7 @@ export class IdeProviderInstance implements ProviderInstance {
417
417
  const persistedMessages = chat.messages || messages;
418
418
  if (persistedMessages.length > 0) {
419
419
  let toSave = persistedMessages;
420
- if (chat.status === 'generating' || chat.status === 'long_generating') {
420
+ if (chat.status === 'generating' || chat.status === 'no_progress' || chat.status === 'long_generating') {
421
421
  // Find and exclude last assistant message
422
422
  const lastIdx = toSave.length - 1;
423
423
  if (lastIdx >= 0 && toSave[lastIdx].role === 'assistant') {
@@ -508,7 +508,11 @@ export class IdeProviderInstance implements ProviderInstance {
508
508
  }
509
509
 
510
510
  // Monitor check (cooldown based notification)
511
- const monitorEvents = this.monitor.check(agentKey, agentStatus, now, progressFingerprint);
511
+ // Approval pending is detected from the raw status: auto-approve synthesizes
512
+ // `waiting_approval` → 'generating', so the no-progress watchdog must be told
513
+ // to hold its timer during the wait rather than count it as a stall.
514
+ const approvalPending = rawAgentStatus === 'waiting_approval';
515
+ const monitorEvents = this.monitor.check(agentKey, agentStatus, now, progressFingerprint, approvalPending);
512
516
  for (const me of monitorEvents) {
513
517
  this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
514
518
  }
@@ -10,7 +10,7 @@ import {
10
10
  type ReadChatResultV2,
11
11
  } from './transcript-v2.js'
12
12
 
13
- const VALID_STATUSES = ['idle', 'generating', 'waiting_approval', 'error', 'panel_hidden', 'starting', 'streaming', 'long_generating'] as const
13
+ const VALID_STATUSES = ['idle', 'generating', 'waiting_approval', 'error', 'panel_hidden', 'starting', 'streaming', 'no_progress', 'long_generating'] as const
14
14
  const VALID_ROLES = ['user', 'assistant', 'system', 'human'] as const
15
15
  const VALID_BUBBLE_STATES = ['draft', 'streaming', 'final', 'removed'] as const
16
16
  const VALID_TURN_STATUSES = ['open', 'waiting_approval', 'complete', 'error'] as const
@@ -3,16 +3,16 @@
3
3
  *
4
4
  * Common across all Provider categories (IDE/Extension/CLI/ACP).
5
5
  * - Approval waiting (waiting_approval) notification
6
- * - Notification when generating persists for extended duration
6
+ * - No-progress watchdog: alert when an active turn makes no progress for a while
7
7
  * - All config toggleable via Provider Settings
8
8
  */
9
9
  export interface MonitorConfig {
10
10
  /** Enable awaiting-approval notification */
11
11
  approvalAlert: boolean;
12
- /** Prolonged generating notification enabled */
13
- longGeneratingAlert: boolean;
14
- /** Prolonged threshold (seconds) */
15
- longGeneratingThresholdSec: number;
12
+ /** No-progress watchdog notification enabled */
13
+ noProgressAlert: boolean;
14
+ /** No-progress threshold (seconds) */
15
+ noProgressThresholdSec: number;
16
16
  /** Repeat notification cooldown (seconds) */
17
17
  alertCooldownSec: number;
18
18
  }
@@ -28,7 +28,7 @@ export declare class StatusMonitor {
28
28
  private config;
29
29
  private lastAlertTime;
30
30
  private generatingStartTimes;
31
- private longGeneratingAlerted;
31
+ private noProgressAlerted;
32
32
  private lastProgressFingerprint;
33
33
  private lastProgressChangeAt;
34
34
  constructor(config?: Partial<MonitorConfig>);
@@ -40,7 +40,7 @@ export declare class StatusMonitor {
40
40
  * Check status transition → return notification event array.
41
41
  * Called from each onTick() or detectStatusTransition().
42
42
  */
43
- check(agentKey: string, status: string, now: number, progressFingerprint?: string): MonitorEvent[];
43
+ check(agentKey: string, status: string, now: number, progressFingerprint?: string, approvalPending?: boolean): MonitorEvent[];
44
44
  /** Cooldown check — prevent sending the same notification too frequently */
45
45
  private shouldAlert;
46
46
  /** Reset (on agent terminate/restart) */
@@ -3,26 +3,26 @@
3
3
  *
4
4
  * Common across all Provider categories (IDE/Extension/CLI/ACP).
5
5
  * - Approval waiting (waiting_approval) notification
6
- * - Notification when generating persists for extended duration
6
+ * - No-progress watchdog: alert when an active turn makes no progress for a while
7
7
  * - All config toggleable via Provider Settings
8
8
  */
9
9
 
10
10
  export interface MonitorConfig {
11
11
  /** Enable awaiting-approval notification */
12
12
  approvalAlert: boolean;
13
- /** Prolonged generating notification enabled */
14
- longGeneratingAlert: boolean;
15
- /** Prolonged threshold (seconds) */
16
- longGeneratingThresholdSec: number;
13
+ /** No-progress watchdog notification enabled */
14
+ noProgressAlert: boolean;
15
+ /** No-progress threshold (seconds) */
16
+ noProgressThresholdSec: number;
17
17
  /** Repeat notification cooldown (seconds) */
18
18
  alertCooldownSec: number;
19
19
  }
20
20
 
21
21
  export const DEFAULT_MONITOR_CONFIG: MonitorConfig = {
22
22
  approvalAlert: true,
23
- longGeneratingAlert: true,
24
- longGeneratingThresholdSec: 180, // 3 minutes
25
- alertCooldownSec: 60, // 1 minute cooldown
23
+ noProgressAlert: true,
24
+ noProgressThresholdSec: 180, // 3 minutes
25
+ alertCooldownSec: 60, // 1 minute cooldown
26
26
  };
27
27
 
28
28
  export interface MonitorEvent {
@@ -37,7 +37,7 @@ export class StatusMonitor {
37
37
  private config: MonitorConfig;
38
38
  private lastAlertTime = new Map<string, number>();
39
39
  private generatingStartTimes = new Map<string, number>();
40
- private longGeneratingAlerted = new Map<string, boolean>();
40
+ private noProgressAlerted = new Map<string, boolean>();
41
41
  private lastProgressFingerprint = new Map<string, string>();
42
42
  private lastProgressChangeAt = new Map<string, number>();
43
43
 
@@ -59,7 +59,7 @@ export class StatusMonitor {
59
59
  * Check status transition → return notification event array.
60
60
  * Called from each onTick() or detectStatusTransition().
61
61
  */
62
- check(agentKey: string, status: string, now: number, progressFingerprint?: string): MonitorEvent[] {
62
+ check(agentKey: string, status: string, now: number, progressFingerprint?: string, approvalPending?: boolean): MonitorEvent[] {
63
63
  const events: MonitorEvent[] = [];
64
64
 
65
65
  // 1. Approval waiting notification
@@ -74,11 +74,26 @@ export class StatusMonitor {
74
74
  }
75
75
  }
76
76
 
77
- // 2. Detect prolonged generating (identical for IDE/Extension/CLI/ACP)
77
+ // 2. No-progress watchdog (identical for IDE/Extension/CLI/ACP)
78
78
  if (status === 'generating' || status === 'streaming') {
79
+ // While an approval modal is pending we must NOT count the wait as
80
+ // no-progress. The assistant emits no tokens and the screen is frozen
81
+ // on the modal, so the progress fingerprint goes static — but this is
82
+ // a user action wait, not a freeze. Some callers (CLI/IDE auto-approve)
83
+ // also synthesize the reported status to 'generating' during the wait,
84
+ // so the plain `waiting_approval` branch below cannot catch it. Hold the
85
+ // progress anchor at `now` so the elapsed timer restarts the moment the
86
+ // approval clears and real generating resumes.
87
+ if (approvalPending) {
88
+ this.generatingStartTimes.set(agentKey, now);
89
+ this.lastProgressFingerprint.set(agentKey, progressFingerprint ?? '');
90
+ this.lastProgressChangeAt.set(agentKey, now);
91
+ this.noProgressAlerted.set(agentKey, false);
92
+ return events;
93
+ }
79
94
  if (!this.generatingStartTimes.has(agentKey)) {
80
95
  this.generatingStartTimes.set(agentKey, now);
81
- this.longGeneratingAlerted.set(agentKey, false);
96
+ this.noProgressAlerted.set(agentKey, false);
82
97
  const initialFingerprint = progressFingerprint ?? '';
83
98
  this.lastProgressFingerprint.set(agentKey, initialFingerprint);
84
99
  this.lastProgressChangeAt.set(agentKey, now);
@@ -88,17 +103,17 @@ export class StatusMonitor {
88
103
  if (previousFingerprint !== currentFingerprint) {
89
104
  this.lastProgressFingerprint.set(agentKey, currentFingerprint);
90
105
  this.lastProgressChangeAt.set(agentKey, now);
91
- this.longGeneratingAlerted.set(agentKey, false);
106
+ this.noProgressAlerted.set(agentKey, false);
92
107
  }
93
- if (this.config.longGeneratingAlert) {
108
+ if (this.config.noProgressAlert) {
94
109
  const progressChangedAt = this.lastProgressChangeAt.get(agentKey) || this.generatingStartTimes.get(agentKey)!;
95
110
  const elapsedSec = Math.round((now - progressChangedAt) / 1000);
96
- const alreadyAlerted = this.longGeneratingAlerted.get(agentKey) === true;
97
- if (elapsedSec > this.config.longGeneratingThresholdSec && !alreadyAlerted) {
98
- if (this.shouldAlert(agentKey + ':long_gen', now)) {
99
- this.longGeneratingAlerted.set(agentKey, true);
111
+ const alreadyAlerted = this.noProgressAlerted.get(agentKey) === true;
112
+ if (elapsedSec > this.config.noProgressThresholdSec && !alreadyAlerted) {
113
+ if (this.shouldAlert(agentKey + ':no_progress', now)) {
114
+ this.noProgressAlerted.set(agentKey, true);
100
115
  events.push({
101
- type: 'monitor:long_generating',
116
+ type: 'monitor:no_progress',
102
117
  agentKey,
103
118
  elapsedSec,
104
119
  timestamp: now,
@@ -110,7 +125,7 @@ export class StatusMonitor {
110
125
  } else {
111
126
  // Reset timer when switching to non-generating status
112
127
  this.generatingStartTimes.delete(agentKey);
113
- this.longGeneratingAlerted.delete(agentKey);
128
+ this.noProgressAlerted.delete(agentKey);
114
129
  this.lastProgressFingerprint.delete(agentKey);
115
130
  this.lastProgressChangeAt.delete(agentKey);
116
131
  }
@@ -132,7 +147,7 @@ export class StatusMonitor {
132
147
  reset(agentKey?: string): void {
133
148
  if (agentKey) {
134
149
  this.generatingStartTimes.delete(agentKey);
135
- this.longGeneratingAlerted.delete(agentKey);
150
+ this.noProgressAlerted.delete(agentKey);
136
151
  this.lastProgressFingerprint.delete(agentKey);
137
152
  this.lastProgressChangeAt.delete(agentKey);
138
153
  // Delete all cooldowns for this key
@@ -141,7 +156,7 @@ export class StatusMonitor {
141
156
  }
142
157
  } else {
143
158
  this.generatingStartTimes.clear();
144
- this.longGeneratingAlerted.clear();
159
+ this.noProgressAlerted.clear();
145
160
  this.lastProgressFingerprint.clear();
146
161
  this.lastProgressChangeAt.clear();
147
162
  this.lastAlertTime.clear();
@@ -740,6 +740,9 @@ export type DaemonStatusEventName =
740
740
  | 'agent:waiting_approval'
741
741
  | 'agent:generating_completed'
742
742
  | 'agent:stopped'
743
+ | 'monitor:no_progress'
744
+ // Legacy alias for 'monitor:no_progress' — kept so older daemons that still
745
+ // emit it remain type-compatible with consumers during rollout.
743
746
  | 'monitor:long_generating';
744
747
 
745
748
  /** Minimal daemon-originated event payload relayed through the server. */
@@ -120,7 +120,7 @@ export class DaemonStatusReporter {
120
120
  case 'agent:waiting_approval':
121
121
  case 'agent:generating_completed':
122
122
  case 'agent:stopped':
123
- case 'monitor:long_generating':
123
+ case 'monitor:no_progress':
124
124
  return value;
125
125
  default:
126
126
  return null;