@adhdev/daemon-core 0.9.82-rc.328 → 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.
@@ -1,6 +1,13 @@
1
1
  import type { DaemonBuildBehind, GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
2
2
  import { GIT_STATUS_TIMEOUT_MS, GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
3
3
  import { getDaemonBuildInfo, type DaemonBuildInfo } from '../build-info.js';
4
+ import {
5
+ type ChangeImpactConfig,
6
+ type ChangeImpactKind,
7
+ type ChangeImpactTarget,
8
+ globToRegExp,
9
+ loadChangeImpactConfig,
10
+ } from './change-impact-config.js';
4
11
 
5
12
  type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo: boolean };
6
13
 
@@ -12,9 +19,42 @@ type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo:
12
19
  */
13
20
  const lastKnownGoodStatus = new Map<string, GitRepoStatus>();
14
21
 
22
+ /**
23
+ * Memoized Change Impact evaluation, keyed by the inputs that can change the
24
+ * verdict: the scope repo path, the buildCommit..HEAD pair, and the resolved
25
+ * config source. The daemonBuildBehind probe runs on every mesh_status /
26
+ * mesh_git_status / fast_forward / git-monitor hit; without this, each hit
27
+ * re-shells `git diff` for the identical commit range. The cache stays correct
28
+ * because HEAD or a config edit perturbs the key, forcing re-evaluation.
29
+ */
30
+ interface ChangeImpactEvalEntry {
31
+ isDaemonAffecting: boolean;
32
+ affectedPackages: string[];
33
+ }
34
+ const changeImpactEvalCache = new Map<string, ChangeImpactEvalEntry>();
35
+
36
+ /**
37
+ * Best-effort cache of the loaded Change Impact config per repo root, keyed by the
38
+ * config sourceKey (path+mtime). A new sourceKey (config edited/added/removed)
39
+ * supersedes the entry. Avoids re-reading + re-parsing the config file on every
40
+ * status probe while still honoring on-disk edits.
41
+ */
42
+ interface ChangeImpactConfigCacheEntry {
43
+ sourceKey: string;
44
+ config: ChangeImpactConfig | null;
45
+ }
46
+ const changeImpactConfigCache = new Map<string, ChangeImpactConfigCacheEntry>();
47
+
15
48
  /** Test seam: clear the last-known-good status cache between cases. */
16
49
  export function __resetGitStatusCacheForTests(): void {
17
50
  lastKnownGoodStatus.clear();
51
+ changeImpactEvalCache.clear();
52
+ changeImpactConfigCache.clear();
53
+ }
54
+
55
+ /** Test-only introspection: number of memoized Change Impact evaluations. */
56
+ export function __changeImpactEvalCacheSizeForTests(): number {
57
+ return changeImpactEvalCache.size;
18
58
  }
19
59
 
20
60
  /**
@@ -43,6 +83,17 @@ export interface GitStatusOptions {
43
83
  * (getDaemonBuildInfo) is used.
44
84
  */
45
85
  daemonBuildInfo?: DaemonBuildInfo;
86
+ /**
87
+ * Change Impact policy override. When provided, the daemonBuildBehind
88
+ * classifier uses this declarative config instead of auto-loading the repo's
89
+ * `.adhdev/change-impact.*` file. When omitted, getGitRepoStatus auto-loads the
90
+ * repo config (cached); when neither is present the built-in ADHDev default
91
+ * policy applies, preserving the legacy behavior exactly.
92
+ *
93
+ * Pass `null` to force the built-in default policy and skip auto-loading (used
94
+ * to assert legacy parity in tests).
95
+ */
96
+ changeImpactConfig?: ChangeImpactConfig | null;
46
97
  }
47
98
 
48
99
  interface GitUpstreamProbe {
@@ -177,7 +228,7 @@ async function collectGitRepoStatus(
177
228
  * same process surface as the daemon tooling, so it is classified as
178
229
  * daemon-affecting (conservative). Unknown package → daemon-affecting.
179
230
  */
180
- const DAEMON_RUNTIME_PACKAGES = new Set([
231
+ const DEFAULT_DAEMON_RUNTIME_PACKAGES = [
181
232
  'daemon-core',
182
233
  'daemon-standalone',
183
234
  'session-host-core',
@@ -187,14 +238,66 @@ const DAEMON_RUNTIME_PACKAGES = new Set([
187
238
  'terminal-mux-cli',
188
239
  'ghostty-vt-node',
189
240
  'mcp-server',
190
- ]);
241
+ ];
191
242
 
192
- const WEB_ONLY_PACKAGES = new Set([
243
+ const DEFAULT_WEB_ONLY_PACKAGES = [
193
244
  'web-core',
194
245
  'web-standalone',
195
246
  'web-devconsole',
196
247
  'terminal-render-web',
197
- ]);
248
+ ];
249
+
250
+ /**
251
+ * Built-in recommended action/command per impact classification. A change-impact
252
+ * config may override any of these via `impactTargets`; missing keys fall back here.
253
+ */
254
+ const DEFAULT_IMPACT_TARGETS: Record<ChangeImpactKind, ChangeImpactTarget> = {
255
+ daemon: {
256
+ recommendedCommand: 'Redeploy + restart the daemon (a local dist rebuild alone does not update a cloud daemon).',
257
+ },
258
+ web: {
259
+ recommendedCommand: 'Redeploy the web app (no daemon restart required).',
260
+ },
261
+ none: {
262
+ recommendedCommand: 'No action required.',
263
+ },
264
+ };
265
+
266
+ /**
267
+ * The resolved Change Impact policy: the built-in ADHDev defaults merged with any
268
+ * config override. This is what the classifier consults — git-status only knows the
269
+ * facts (changed files/packages), policy is data.
270
+ */
271
+ interface ResolvedChangeImpactPolicy {
272
+ daemonRuntimePackages: Set<string>;
273
+ webOnlyPackages: Set<string>;
274
+ /** Compiled config globs for additional non-runtime root files (on top of built-ins). */
275
+ nonRuntimeRootFilePatterns: RegExp[];
276
+ impactTargets: Record<ChangeImpactKind, ChangeImpactTarget>;
277
+ }
278
+
279
+ function resolveChangeImpactPolicy(config: ChangeImpactConfig | null | undefined): ResolvedChangeImpactPolicy {
280
+ // A field provided in config REPLACES the built-in default for that field; an
281
+ // omitted field falls back to the ADHDev default, so a repo with no config (or a
282
+ // partial config) behaves exactly as before.
283
+ const daemonRuntimePackages = new Set(
284
+ config?.daemonRuntimePackages && config.daemonRuntimePackages.length
285
+ ? config.daemonRuntimePackages
286
+ : DEFAULT_DAEMON_RUNTIME_PACKAGES,
287
+ );
288
+ const webOnlyPackages = new Set(
289
+ config?.webOnlyPackages && config.webOnlyPackages.length
290
+ ? config.webOnlyPackages
291
+ : DEFAULT_WEB_ONLY_PACKAGES,
292
+ );
293
+ const nonRuntimeRootFilePatterns = (config?.nonRuntimeRootFilePatterns || []).map(globToRegExp);
294
+ const impactTargets: Record<ChangeImpactKind, ChangeImpactTarget> = {
295
+ daemon: config?.impactTargets?.daemon ?? DEFAULT_IMPACT_TARGETS.daemon,
296
+ web: config?.impactTargets?.web ?? DEFAULT_IMPACT_TARGETS.web,
297
+ none: config?.impactTargets?.none ?? DEFAULT_IMPACT_TARGETS.none,
298
+ };
299
+ return { daemonRuntimePackages, webOnlyPackages, nonRuntimeRootFilePatterns, impactTargets };
300
+ }
198
301
 
199
302
  /**
200
303
  * Root-level (non-package) files that demonstrably cannot change what the daemon
@@ -210,8 +313,11 @@ const WEB_ONLY_PACKAGES = new Set([
210
313
  * real-world case (e.g. `.verify-patch-equiv-rc292`), so they are matched broadly;
211
314
  * docs are matched by the `docs/` prefix or a markdown/text-doc extension at a
212
315
  * filename we recognize as documentation (README/CHANGELOG/LICENSE/NOTICE).
316
+ *
317
+ * Config-supplied `nonRuntimeRootFilePatterns` are matched in ADDITION to these
318
+ * built-ins, so a repo can extend (never narrow) the benign set declaratively.
213
319
  */
214
- function isNonRuntimeRootFile(file: string): boolean {
320
+ function isNonRuntimeRootFile(file: string, policy: ResolvedChangeImpactPolicy): boolean {
215
321
  const base = file.slice(file.lastIndexOf('/') + 1);
216
322
  // Verify/convergence markers: dotfiles whose name signals a transient marker.
217
323
  if (/^\.(?:verify|marker|converge|ff-verify|patch-equiv|live-verify)\b/i.test(base)) return true;
@@ -221,19 +327,24 @@ function isNonRuntimeRootFile(file: string): boolean {
221
327
  if (/^(?:README|CHANGELOG|LICENSE|NOTICE|AUTHORS|CONTRIBUTING|CODEOWNERS)(?:\.[A-Za-z0-9]+)?$/i.test(base)) {
222
328
  return true;
223
329
  }
330
+ // Config-declared additional non-runtime root globs.
331
+ for (const re of policy.nonRuntimeRootFilePatterns) {
332
+ if (re.test(file)) return true;
333
+ }
224
334
  return false;
225
335
  }
226
336
 
227
337
  /**
228
338
  * Determine whether the changes between buildCommit..HEAD touch any daemon-runtime
229
- * package. Returns isDaemonAffecting:true conservatively when the changed-file set
230
- * can't be obtained or any changed path is outside the known web-only package set
231
- * AND is not a recognized non-runtime root file (marker/doc/license).
339
+ * package, per the resolved policy. Returns isDaemonAffecting:true conservatively
340
+ * when the changed-file set can't be obtained or any changed path is outside the
341
+ * known web-only package set AND is not a recognized non-runtime root file.
232
342
  */
233
343
  async function classifyDaemonBuildChange(
234
344
  repoPath: string,
235
345
  buildCommit: string,
236
346
  options: GitStatusOptions,
347
+ policy: ResolvedChangeImpactPolicy,
237
348
  ): Promise<{ isDaemonAffecting: boolean; affectedPackages: string[] }> {
238
349
  try {
239
350
  const diff = await runGit(repoPath, ['diff', '--name-only', `${buildCommit}..HEAD`], options);
@@ -254,7 +365,7 @@ async function classifyDaemonBuildChange(
254
365
  for (const file of files) {
255
366
  const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
256
367
  if (!match) {
257
- if (!isNonRuntimeRootFile(file)) sawRuntimeAmbiguousNonPackage = true;
368
+ if (!isNonRuntimeRootFile(file, policy)) sawRuntimeAmbiguousNonPackage = true;
258
369
  continue;
259
370
  }
260
371
  pkgs.add(match[1]);
@@ -267,7 +378,7 @@ async function classifyDaemonBuildChange(
267
378
  // file changed) — i.e. nothing runtime-ambiguous remains.
268
379
  const allBenign =
269
380
  !sawRuntimeAmbiguousNonPackage &&
270
- affectedPackages.every((p) => WEB_ONLY_PACKAGES.has(p) && !DAEMON_RUNTIME_PACKAGES.has(p));
381
+ affectedPackages.every((p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p));
271
382
  return { isDaemonAffecting: !allBenign, affectedPackages };
272
383
  } catch {
273
384
  // diff probe failed → can't prove web-only; stay conservative.
@@ -275,6 +386,46 @@ async function classifyDaemonBuildChange(
275
386
  }
276
387
  }
277
388
 
389
+ /**
390
+ * Resolve the Change Impact config to apply for this status read. Priority:
391
+ * - options.changeImpactConfig === null → force built-in default policy (no load).
392
+ * - options.changeImpactConfig provided → use it verbatim (override seam).
393
+ * - otherwise → auto-load the repo's `.adhdev/change-impact.*` (cached by sourceKey).
394
+ * Returns the (possibly null) config plus the sourceKey used for cache invalidation.
395
+ */
396
+ function resolveChangeImpactConfigForRepo(
397
+ repoRoot: string | null,
398
+ options: GitStatusOptions,
399
+ ): { config: ChangeImpactConfig | null; sourceKey: string } {
400
+ if (options.changeImpactConfig === null) {
401
+ return { config: null, sourceKey: 'forced-default' };
402
+ }
403
+ if (options.changeImpactConfig !== undefined) {
404
+ // Injected override — key it to its content so a different injected config
405
+ // re-evaluates rather than reusing a prior verdict.
406
+ let key = 'injected';
407
+ try {
408
+ key = `injected:${JSON.stringify(options.changeImpactConfig)}`;
409
+ } catch {
410
+ // Non-serializable override (shouldn't happen) — fall back to a constant key.
411
+ }
412
+ return { config: options.changeImpactConfig, sourceKey: key };
413
+ }
414
+ if (!repoRoot) {
415
+ return { config: null, sourceKey: 'no-repo-root' };
416
+ }
417
+ const loaded = loadChangeImpactConfig(repoRoot);
418
+ const cached = changeImpactConfigCache.get(repoRoot);
419
+ if (cached && cached.sourceKey === loaded.sourceKey) {
420
+ return { config: cached.config, sourceKey: loaded.sourceKey };
421
+ }
422
+ // An invalid config is conservatively ignored (built-in default policy applies),
423
+ // mirroring the "fail safe" rule — never let a malformed config silence warnings.
424
+ const config = loaded.sourceType === 'repo_file' ? loaded.config ?? null : null;
425
+ changeImpactConfigCache.set(repoRoot, { sourceKey: loaded.sourceKey, config });
426
+ return { config, sourceKey: loaded.sourceKey };
427
+ }
428
+
278
429
  async function detectDaemonBuildBehind(
279
430
  repo: ResolvedGitRepo,
280
431
  submodules: GitSubmoduleStatus[] | undefined,
@@ -283,6 +434,9 @@ async function detectDaemonBuildBehind(
283
434
  const build = options.daemonBuildInfo ?? getDaemonBuildInfo();
284
435
  if (!build.commit || build.commit === 'unknown') return undefined;
285
436
 
437
+ const { config, sourceKey: configKey } = resolveChangeImpactConfigForRepo(repo.repoRoot, options);
438
+ const policy = resolveChangeImpactPolicy(config);
439
+
286
440
  // Check the root repo first, then each submodule. The daemon build commit is
287
441
  // baked from the daemon-core (oss submodule) HEAD, so on an adhdev
288
442
  // superproject worktree the match is expected on the `oss` submodule, not the
@@ -307,12 +461,22 @@ async function detectDaemonBuildBehind(
307
461
  // Inspect WHICH packages changed in buildCommit..HEAD. A daemon rebuild/restart
308
462
  // is only actually required when a daemon-runtime package changed; if only web /
309
463
  // render packages changed, the daemon is unaffected and just the web deploy is
310
- // pending. Conservative: any probe failure → treat as daemon-affecting.
311
- const { isDaemonAffecting, affectedPackages } = await classifyDaemonBuildChange(
312
- repoPath,
313
- build.commit,
314
- options,
315
- );
464
+ // pending. Conservative: any probe failure → treat as daemon-affecting. The
465
+ // verdict is memoized on (repoPath, buildCommit, head, config) to suppress
466
+ // re-evaluation on the hot status path.
467
+ const evalKey = `${repoPath}${build.commit}${head}${configKey}`;
468
+ let evaluated = changeImpactEvalCache.get(evalKey);
469
+ if (!evaluated) {
470
+ evaluated = await classifyDaemonBuildChange(repoPath, build.commit, options, policy);
471
+ changeImpactEvalCache.set(evalKey, evaluated);
472
+ }
473
+ const { isDaemonAffecting, affectedPackages } = evaluated;
474
+ const kind: ChangeImpactKind = isDaemonAffecting
475
+ ? 'daemon'
476
+ : affectedPackages.length > 0
477
+ ? 'web'
478
+ : 'none';
479
+ const target = policy.impactTargets[kind];
316
480
  const scopeLabel = scope === 'root' ? 'workspace' : scope;
317
481
  const benignDetail = affectedPackages.length > 0
318
482
  ? `only web packages changed (${affectedPackages.join(', ')})`
@@ -330,6 +494,8 @@ async function detectDaemonBuildBehind(
330
494
  scope,
331
495
  isDaemonAffecting,
332
496
  ...(affectedPackages && affectedPackages.length > 0 ? { affectedPackages } : {}),
497
+ recommendedAction: kind,
498
+ recommendedCommand: target.recommendedCommand,
333
499
  warning,
334
500
  };
335
501
  } catch {
package/src/git/index.ts CHANGED
@@ -26,6 +26,22 @@ export type { GitCommandResult as GitExecutorCommandResult, GitExecutorOptions,
26
26
  export { getGitRepoStatus, parsePorcelainV2Status } from './git-status.js';
27
27
  export type { GitStatusOptions } from './git-status.js';
28
28
 
29
+ export {
30
+ CHANGE_IMPACT_CONFIG_LOCATIONS,
31
+ CHANGE_IMPACT_CONFIG_SCHEMA,
32
+ globToRegExp,
33
+ loadChangeImpactConfig,
34
+ suggestChangeImpactConfig,
35
+ validateChangeImpactConfig,
36
+ } from './change-impact-config.js';
37
+ export type {
38
+ ChangeImpactConfig,
39
+ ChangeImpactConfigLoadResult,
40
+ ChangeImpactConfigSuggestion,
41
+ ChangeImpactKind,
42
+ ChangeImpactTarget,
43
+ } from './change-impact-config.js';
44
+
29
45
  export { getGitDiffSummary, getGitFileDiff } from './git-diff.js';
30
46
  export type { GitDiffOptions, GitFileDiffResult } from './git-diff.js';
31
47
 
@@ -21,7 +21,7 @@ import {
21
21
  findRecentTerminalLedgerEvidence,
22
22
  hasDispatchAfterTerminal,
23
23
  hasUnterminalDirectDispatchLedgerEntry,
24
- buildLongGeneratingCompletionReconciliation,
24
+ buildNoProgressCompletionReconciliation,
25
25
  } from './mesh-events-stale.js';
26
26
  import {
27
27
  buildMeshSystemMessage,
@@ -125,7 +125,7 @@ function shouldSuppressIntentionalCleanupStop(args: {
125
125
  sessionId?: string;
126
126
  nodeId?: string;
127
127
  }): boolean {
128
- if (args.event !== 'agent:stopped' && args.event !== 'monitor:long_generating') return false;
128
+ if (args.event !== 'agent:stopped' && args.event !== 'monitor:no_progress') return false;
129
129
  if (isIntentionalCleanupStopMetadata(args.metadataEvent)) return true;
130
130
  return hasRecentIntentionalCleanupStop(args.meshId, args.sessionId, args.nodeId);
131
131
  }
@@ -453,7 +453,8 @@ function resolveAutoFastForwardPolicy(mesh: any): { enabled: boolean; maxBehind?
453
453
  function sessionStateLooksActive(state: any): boolean {
454
454
  const status = readNonEmptyString(state?.status).toLowerCase();
455
455
  const chatStatus = readNonEmptyString(state?.activeChat?.status).toLowerCase();
456
- const active = new Set(['generating', 'streaming', 'long_generating', 'working', 'starting', 'waiting_approval']);
456
+ // 'long_generating' is retained as a legacy alias for the renamed 'no_progress' busy status.
457
+ const active = new Set(['generating', 'streaming', 'no_progress', 'long_generating', 'working', 'starting', 'waiting_approval']);
457
458
  return active.has(status) || active.has(chatStatus);
458
459
  }
459
460
 
@@ -1329,7 +1330,7 @@ const MESH_COORDINATOR_EVENTS = new Set([
1329
1330
  'agent:waiting_approval',
1330
1331
  'agent:stopped',
1331
1332
  'agent:ready',
1332
- 'monitor:long_generating',
1333
+ 'monitor:no_progress',
1333
1334
  'refine:accepted',
1334
1335
  'refine:completed',
1335
1336
  'refine:failed',
@@ -1341,7 +1342,7 @@ const EVENT_TO_LEDGER_KIND: Record<string, MeshLedgerKind> = {
1341
1342
  'agent:generating_completed': 'task_completed',
1342
1343
  'agent:waiting_approval': 'task_approval_needed',
1343
1344
  'agent:stopped': 'task_failed',
1344
- 'monitor:long_generating': 'task_stalled',
1345
+ 'monitor:no_progress': 'task_stalled',
1345
1346
  };
1346
1347
 
1347
1348
  export function isMeshCoordinatorEvent(eventName: unknown): eventName is string {
@@ -1419,24 +1420,24 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1419
1420
  return { success: true, forwarded: 0, suppressed: true, intentionalCleanupStop: true };
1420
1421
  }
1421
1422
 
1422
- if (args.event === 'monitor:long_generating') {
1423
- const reconciledCompletion = buildLongGeneratingCompletionReconciliation({
1423
+ if (args.event === 'monitor:no_progress') {
1424
+ const reconciledCompletion = buildNoProgressCompletionReconciliation({
1424
1425
  meshId: args.meshId,
1425
1426
  nodeId: args.nodeId,
1426
1427
  nodeLabel: args.nodeLabel,
1427
1428
  metadataEvent: args.metadataEvent,
1428
1429
  sourceInstanceId: args.sourceInstanceId,
1429
1430
  });
1430
- if (reconciledCompletion?.source === 'long_generating_reconciliation') {
1431
- LOG.info('MeshEvents', `Reconciled long-generating monitor to completion for session ${eventSessionId || '(unknown session)'}`);
1431
+ if (reconciledCompletion?.source === 'no_progress_reconciliation') {
1432
+ LOG.info('MeshEvents', `Reconciled no-progress monitor to completion for session ${eventSessionId || '(unknown session)'}`);
1432
1433
  return injectMeshSystemMessage(components, {
1433
1434
  ...args,
1434
1435
  event: 'agent:generating_completed',
1435
1436
  metadataEvent: reconciledCompletion,
1436
1437
  });
1437
1438
  }
1438
- if (reconciledCompletion?.source === 'long_generating_terminal_ledger_suppression') {
1439
- LOG.info('MeshEvents', `Suppressed long-generating monitor because terminal ledger evidence already exists for session ${eventSessionId || '(unknown session)'}`);
1439
+ if (reconciledCompletion?.source === 'no_progress_terminal_ledger_suppression') {
1440
+ LOG.info('MeshEvents', `Suppressed no-progress monitor because terminal ledger evidence already exists for session ${eventSessionId || '(unknown session)'}`);
1440
1441
  return {
1441
1442
  success: true,
1442
1443
  forwarded: 0,
@@ -1483,7 +1484,7 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1483
1484
  if (
1484
1485
  (terminalProviderSessionId && terminalProviderSessionId === eventProviderSessionId)
1485
1486
  || (terminalFinalSummary && terminalFinalSummary === eventFinalSummary)
1486
- || args.metadataEvent.source === 'long_generating_reconciliation'
1487
+ || args.metadataEvent.source === 'no_progress_reconciliation'
1487
1488
  ) {
1488
1489
  LOG.info('MeshEvents', `Suppressed duplicate completion with existing terminal ledger evidence for mesh ${args.meshId} session ${eventSessionId}`);
1489
1490
  return { success: true, forwarded: 0, suppressed: true, duplicateCompletion: true, terminalLedgerEvidence: true };
@@ -235,7 +235,7 @@ export function reconcileDirectDispatchCompletionFromTranscript(args: {
235
235
  return { reconciled: true, kind, workerResult, ledgerEntryId: entry.id };
236
236
  }
237
237
 
238
- export function buildLongGeneratingCompletionReconciliation(args: {
238
+ export function buildNoProgressCompletionReconciliation(args: {
239
239
  meshId: string;
240
240
  nodeId?: string;
241
241
  nodeLabel: string;
@@ -265,8 +265,8 @@ export function buildLongGeneratingCompletionReconciliation(args: {
265
265
  providerType,
266
266
  providerSessionId,
267
267
  finalSummary,
268
- source: 'long_generating_reconciliation',
269
- reconciledFromEvent: 'monitor:long_generating',
268
+ source: 'no_progress_reconciliation',
269
+ reconciledFromEvent: 'monitor:no_progress',
270
270
  timestamp: args.metadataEvent.timestamp ?? Date.now(),
271
271
  completionDiagnostic: {
272
272
  ...(completionDiagnostic || {}),
@@ -283,7 +283,7 @@ export function buildLongGeneratingCompletionReconciliation(args: {
283
283
  if (!terminal) return null;
284
284
  return {
285
285
  ...args.metadataEvent,
286
- source: 'long_generating_terminal_ledger_suppression',
286
+ source: 'no_progress_terminal_ledger_suppression',
287
287
  terminalLedgerKind: terminal.kind,
288
288
  terminalLedgerAt: terminal.timestamp,
289
289
  };
@@ -137,8 +137,8 @@ export function buildMeshSystemMessage(args: {
137
137
  }): string {
138
138
  const metadata = formatCompletionMetadata(args.metadataEvent);
139
139
  if (args.event === 'agent:generating_completed') {
140
- if (args.metadataEvent.source === 'long_generating_reconciliation') {
141
- return `[System] ${args.nodeLabel} already has completion evidence${metadata}. The long-generating monitor reconciled the terminal handoff and marked the session complete; wait for the queued completion event/status refresh before doing any manual transcript check.`;
140
+ if (args.metadataEvent.source === 'no_progress_reconciliation') {
141
+ return `[System] ${args.nodeLabel} already has completion evidence${metadata}. The no-progress monitor reconciled the terminal handoff and marked the session complete; wait for the queued completion event/status refresh before doing any manual transcript check.`;
142
142
  }
143
143
  const reviewNote = args.metadataEvent.reviewRecommended === true
144
144
  ? ' Completion evidence is insufficient — verify via git status or provider_session_id before assuming the task is done. Use mesh_read_chat once if needed, but do not poll repeatedly.'
@@ -173,7 +173,7 @@ export function buildMeshSystemMessage(args: {
173
173
  }
174
174
  return `[System] ${args.nodeLabel} has stopped${metadata}. Use mesh_read_chat once if you need to inspect its last output.`;
175
175
  }
176
- if (args.event === 'monitor:long_generating') {
176
+ if (args.event === 'monitor:no_progress') {
177
177
  return `[System] ${args.nodeLabel} is still reported as generating after a long interval${metadata}. Wait for pendingCoordinatorEvents or a completion/status event; if the user explicitly asks for status, make one bounded status check and then wait again.`;
178
178
  }
179
179
  if (args.event === 'worktree_bootstrap_complete') {
@@ -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
  }