@adhdev/daemon-core 0.9.82-rc.455 → 0.9.82-rc.457

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 (32) hide show
  1. package/dist/commands/med-family/mesh-crud.d.ts +19 -0
  2. package/dist/config/config.d.ts +14 -0
  3. package/dist/config/registry-resolver.d.ts +54 -0
  4. package/dist/index.js +369 -67
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +369 -67
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/mesh/preview-freshness.d.ts +11 -1
  9. package/dist/mesh/worktree-bootstrap-config.d.ts +9 -0
  10. package/dist/providers/approval-utils.d.ts +25 -0
  11. package/dist/providers/cli-provider-instance.d.ts +30 -1
  12. package/dist/providers/manual-attendance.d.ts +16 -0
  13. package/dist/providers/provider-instance.d.ts +8 -1
  14. package/dist/providers/provider-loader.d.ts +17 -2
  15. package/dist/providers/spec/fsm-driver.d.ts +5 -0
  16. package/package.json +3 -3
  17. package/src/boot/daemon-lifecycle.ts +2 -0
  18. package/src/commands/handler.ts +9 -5
  19. package/src/commands/low-family/daemon-lifecycle.ts +14 -1
  20. package/src/commands/med-family/mesh-crud.ts +83 -49
  21. package/src/config/config.ts +18 -0
  22. package/src/config/registry-resolver.ts +100 -0
  23. package/src/mesh/preview-freshness.ts +46 -1
  24. package/src/mesh/worktree-bootstrap-config.ts +1 -1
  25. package/src/providers/approval-utils.ts +42 -0
  26. package/src/providers/cli-provider-instance.ts +246 -13
  27. package/src/providers/manual-attendance.ts +20 -0
  28. package/src/providers/provider-instance.ts +6 -1
  29. package/src/providers/provider-loader.ts +36 -9
  30. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +13 -2
  31. package/src/providers/spec/fsm-driver.ts +49 -2
  32. package/src/commands/WINDOWS-UPGRADE-LOCK-FAILURE.md +0 -198
@@ -23,6 +23,46 @@ export interface PreviewFreshness {
23
23
 
24
24
  const PREVIEW_DEPLOY_RECORD = '.adhdev/preview-deploy.json';
25
25
 
26
+ // Repo-relative driver scripts that indicate this repository actually ships the
27
+ // preview-deploy pipeline. Presence of any one of these (or the deploy record,
28
+ // or a `deploy:preview` npm script) means the private release-pipeline guidance
29
+ // carried by buildPreviewFreshness is relevant here. In every other repo the
30
+ // pipeline is not configured and the guidance must NOT leak (F15).
31
+ const PREVIEW_PIPELINE_SCRIPTS = [
32
+ 'scripts/preview-freshness.mjs',
33
+ 'scripts/smoke-preview-web.mjs',
34
+ 'scripts/deploy-preview-local.mjs',
35
+ ] as const;
36
+
37
+ function hasDeployPreviewNpmScript(repoRoot: string): boolean {
38
+ const pkgPath = resolve(repoRoot, 'package.json');
39
+ if (!existsSync(pkgPath)) return false;
40
+ try {
41
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, unknown> };
42
+ return typeof pkg?.scripts?.['deploy:preview'] === 'string';
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Gate: does this repository actually configure the preview-deploy pipeline?
50
+ *
51
+ * The preview-freshness surface embeds this project's private release-pipeline
52
+ * instructions (`npm run deploy:preview`, smoke preview, …). Those are only
53
+ * meaningful in a repo that ships the pipeline. An external repo joined to a
54
+ * mesh must not have that guidance leak into its coordinator prompt, so this
55
+ * gate keeps the surface off unless a concrete pipeline artifact is present.
56
+ */
57
+ export function isPreviewPipelineConfigured(repoRoot: string): boolean {
58
+ // Strongest signal: the repo has produced a preview-deploy record before.
59
+ if (existsSync(resolve(repoRoot, PREVIEW_DEPLOY_RECORD))) return true;
60
+ // Otherwise the pipeline's own driver scripts are enough.
61
+ if (PREVIEW_PIPELINE_SCRIPTS.some((rel) => existsSync(resolve(repoRoot, rel)))) return true;
62
+ // Or the `deploy:preview` npm script that fronts the pipeline.
63
+ return hasDeployPreviewNpmScript(repoRoot);
64
+ }
65
+
26
66
  function runGit(repoRoot: string, args: readonly string[]): string {
27
67
  try {
28
68
  return execFileSync('git', args, {
@@ -86,7 +126,12 @@ function readCurrentMainCommit(repoRoot: string): Pick<PreviewFreshness, 'curren
86
126
  return { currentMainCommit: null, currentMainCommitSource: 'unknown' };
87
127
  }
88
128
 
89
- export function buildPreviewFreshness(repoRoot: string): PreviewFreshness {
129
+ export function buildPreviewFreshness(repoRoot: string): PreviewFreshness | null {
130
+ // F15 gate: only surface preview-freshness (and its private pipeline
131
+ // guidance) in repos that actually configure the preview-deploy pipeline.
132
+ // Unconfigured repos return null so the caller omits the field entirely.
133
+ if (!isPreviewPipelineConfigured(repoRoot)) return null;
134
+
90
135
  const current = readCurrentMainCommit(repoRoot);
91
136
  const record = readRecord(repoRoot);
92
137
  const lastPreviewCommit = normalizeCommit(record?.lastPreviewCommit);
@@ -69,7 +69,7 @@ export const WORKTREE_BOOTSTRAP_STALE_RUNNING_MS = 10 * 60 * 1000;
69
69
  * trailing slash stripped. Returns an empty set when there are no submodules or the
70
70
  * lookup fails — callers must then treat any change as dirty (conservative).
71
71
  */
72
- function getRegisteredSubmodulePaths(workspace: string): Set<string> {
72
+ export function getRegisteredSubmodulePaths(workspace: string): Set<string> {
73
73
  const paths = new Set<string>();
74
74
  try {
75
75
  const out = execFileSync(
@@ -41,6 +41,48 @@ export function hasNegativeApprovalOption(buttons: string[] | null | undefined):
41
41
  return (buttons || []).some((button) => isNegativeApprovalLabel(String(button || '')));
42
42
  }
43
43
 
44
+ /**
45
+ * True when a button reliably identifies a tool-CONSENT modal on its own — a
46
+ * scoped permission-grant affirmative such as:
47
+ * - "Yes, allow all edits in tmp/ during this session"
48
+ * - "Yes, and don't ask again for example.com"
49
+ * - "Yes, allow reading from etc/ from this project"
50
+ * - "Always allow"
51
+ *
52
+ * These options only ever appear in a genuine approval/permission prompt; a
53
+ * /model or /mode picker ("1. Default 2. Opus 3. Sonnet") never offers a
54
+ * "grant this scope" choice. They therefore serve as a SECOND reliable
55
+ * structural anchor alongside {@link hasNegativeApprovalOption}.
56
+ *
57
+ * Why this exists (tall-diff fallback, #137): when a Write/Edit diff is tall,
58
+ * the trailing decline option ("3. No") can scroll off the bottom of the
59
+ * captured PTY frame, leaving only "1. Yes" + "2. Yes, allow … this session".
60
+ * hasNegativeApprovalOption then reads false and the auto-approve gate bails —
61
+ * a delegated worker sits forever on a modal it could safely have approved. The
62
+ * grant-scope affirmative lets the gate recognize the consent modal WITHOUT
63
+ * seeing the off-frame decline. The gate still selects the plain "Yes"
64
+ * (allow-once) via pickApprovalButton, never the broader grant, and the settle
65
+ * gate still requires a stable modal — so a half-rendered frame never fires.
66
+ * Kept deliberately narrow so no picker/confirm modal can trip it.
67
+ */
68
+ export function hasReliableApprovalAffirmative(buttons: string[] | null | undefined): boolean {
69
+ return (buttons || []).some((button) => {
70
+ const label = normalizeApprovalLabel(String(button || ''));
71
+ if (!label) return false;
72
+ // "always allow …" as a standalone grant option.
73
+ if (/^always allow\b/.test(label)) return true;
74
+ // "yes, …" scoped grants: allow / always allow / don't ask again / etc.
75
+ // (normalizeApprovalLabel strips the apostrophe, so "don't" → "don t").
76
+ if (/^yes\b/.test(label)) {
77
+ if (/\ballow\b/.test(label)) return true;
78
+ if (/\bask again\b/.test(label)) return true;
79
+ if (/\bduring this session\b/.test(label)) return true;
80
+ if (/\bfrom this project\b/.test(label)) return true;
81
+ }
82
+ return false;
83
+ });
84
+ }
85
+
44
86
  export function getApprovalPositiveHints(provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): string[] {
45
87
  const customHints = Array.isArray(provider?.approvalPositiveHints)
46
88
  ? provider.approvalPositiveHints
@@ -22,10 +22,12 @@ import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pt
22
22
  import { StatusMonitor } from './status-monitor.js';
23
23
  import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
24
24
  import { LOG } from '../logging/logger.js';
25
+ import { recordDebugTrace } from '../logging/debug-trace.js';
26
+ import { shouldCollectTraceCategory } from '../logging/debug-config.js';
25
27
  import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
26
28
  import type { ChatMessage } from '../types.js';
27
29
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
28
- import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, looksLikeActiveApprovalPromptText } from './approval-utils.js';
30
+ import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, hasReliableApprovalAffirmative, looksLikeActiveApprovalPromptText } from './approval-utils.js';
29
31
  import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
30
32
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
31
33
  import { normalizeProviderSessionId } from './provider-session-id.js';
@@ -572,6 +574,13 @@ export class CliProviderInstance implements ProviderInstance {
572
574
  // mask is dropped so the real waiting_approval surfaces. Cleared when the episode ends
573
575
  // (modal genuinely gone, manual attendance takes over, or auto-approve fires).
574
576
  private autoApproveMaskSince = 0;
577
+ // NOTIF-APPROVAL-MASKED (Q1b): the autoApproveMaskSince episode value for which a
578
+ // stalled-approval coordinator nudge has already been emitted, so the nudge fires
579
+ // exactly once per stalled auto-approve episode (0 = none emitted). Reusing the
580
+ // per-episode mask-clock value as the key makes it provider-agnostic (no reliance on
581
+ // approvalEntrySeq) and self-resetting: each new episode gets a fresh
582
+ // autoApproveMaskSince timestamp, and the episode-end reset zeroes it.
583
+ private stalledApprovalNudgeEpisode = 0;
575
584
  // Provider-common manual-attendance signal: while a human is actively driving
576
585
  // this session from the dashboard, auto-approve holds so they can take manual
577
586
  // control. Background mesh workers are never attended → delegated auto-approve
@@ -1519,9 +1528,23 @@ export class CliProviderInstance implements ProviderInstance {
1519
1528
  }
1520
1529
 
1521
1530
  private completionFinalAssistantEvidence(parsedMessages: unknown, turnStartedAt?: number): CompletionFinalAssistantEvidence {
1531
+ // (FALSEIDLE FixB) UPPER-BOUND turn-end evidence. completionHasFinalAssistantMessage is a
1532
+ // pure message-content check ("does the last visible bubble read as a finalized assistant
1533
+ // reply, post-dating the turn start?"). That LOWER bound alone treated the FIRST assistant
1534
+ // bubble of a turn that is STILL running — a tool call in flight between two assistant
1535
+ // bubbles — as proof the turn ended (RCA cases a & b). Require in ADDITION that the turn is
1536
+ // genuinely OVER: hasAdapterPendingResponse() folds the three upper-bound discriminators
1537
+ // into one — currentTurnScope closed, no in-flight tool (isProcessing false), and no partial
1538
+ // response buffer. So a mid-turn point-sample (short-gen / fast-collapse inline paths that
1539
+ // do NOT route through getCompletedFinalizationBlock) yields present=false and is held/settled
1540
+ // rather than fired. A genuinely-finished turn (adapter idle, no pending) is unaffected — the
1541
+ // gate stays open and the completion fires exactly as before. This mirrors the established
1542
+ // `completionHasFinalAssistantMessage(...) && !hasAdapterPendingResponse()` pairing already
1543
+ // used by the no-progress monitor reconcile path.
1544
+ const turnClosed = !this.hasAdapterPendingResponse();
1522
1545
  if (this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)) {
1523
1546
  return {
1524
- present: true,
1547
+ present: turnClosed,
1525
1548
  messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1526
1549
  source: 'parsed',
1527
1550
  };
@@ -1530,7 +1553,7 @@ export class CliProviderInstance implements ProviderInstance {
1530
1553
  const externalMessages = this.readExternalCompletionMessages();
1531
1554
  if (externalMessages) {
1532
1555
  return {
1533
- present: this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt),
1556
+ present: turnClosed && this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt),
1534
1557
  messages: externalMessages,
1535
1558
  source: 'external-native',
1536
1559
  };
@@ -1683,11 +1706,21 @@ export class CliProviderInstance implements ProviderInstance {
1683
1706
 
1684
1707
  const adapterAny = this.adapter as any;
1685
1708
  const approvalResolvedIdle = pending.previousStatus === 'waiting_approval';
1686
- if (!approvalResolvedIdle) {
1687
- if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: true };
1688
- if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: true };
1689
- if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: true };
1690
- }
1709
+ // (FALSEIDLE-a FixA) The adapter pending-response checks run UNCONDITIONALLY.
1710
+ // Previously they were SKIPPED when approvalResolvedIdle, on the assumption that
1711
+ // a waiting_approval→idle transition proved the approval's turn was over. But
1712
+ // auto-approve RESOLVES the modal and the agent RESUMES the same turn — currentTurnScope
1713
+ // / isWaitingForResponse stay set, or a tool runs — so skipping the guard let the FIRST
1714
+ // assistant bubble of the still-running turn be mistaken for the last and fired an early
1715
+ // completion the coordinator could never correct (RCA case a). Keep the guard live for the
1716
+ // approval path too: when the resumed turn genuinely ends these clear and the completion
1717
+ // fires. Approval-resolved holds are NON-terminal (bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS)
1718
+ // so a provider that never closes its turn-scope still force-fires a weak completion rather
1719
+ // than wedging; the non-approval path keeps its terminal hold (a genuinely-busy adapter must
1720
+ // never force a completion out).
1721
+ if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: !approvalResolvedIdle };
1722
+ if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: !approvalResolvedIdle };
1723
+ if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: !approvalResolvedIdle };
1691
1724
 
1692
1725
  const partial = typeof this.adapter.getPartialResponse === 'function'
1693
1726
  ? this.adapter.getPartialResponse()
@@ -1884,6 +1917,40 @@ export class CliProviderInstance implements ProviderInstance {
1884
1917
  };
1885
1918
  }
1886
1919
 
1920
+ // COMPLETION-EARLYNOTIFY instrumentation. A session-keyed FSM-transition +
1921
+ // completion-gate snapshot recorded into the shared debug-trace ring buffer
1922
+ // (secret-safe, length/role/pattern-name only — never screen or bubble text).
1923
+ // Retrieved via getRecentDebugTrace (chat_debug_bundle). Both categories are a
1924
+ // no-op unless collectDebugTrace is on AND the category is selected, so the
1925
+ // hot-path guards below (completionTraceOn / fsmTraceOn) keep production cost
1926
+ // at a single boolean check.
1927
+ private completionTraceOn(): boolean {
1928
+ return shouldCollectTraceCategory('completion-gate');
1929
+ }
1930
+ private fsmTraceOn(): boolean {
1931
+ return shouldCollectTraceCategory('fsm-transition');
1932
+ }
1933
+ private recordCompletionGateTrace(stage: string, payload: Record<string, unknown>): void {
1934
+ recordDebugTrace({
1935
+ category: 'completion-gate',
1936
+ stage,
1937
+ level: 'debug',
1938
+ sessionId: this.instanceId,
1939
+ providerType: this.type,
1940
+ payload,
1941
+ });
1942
+ }
1943
+ private recordFsmTransitionTrace(payload: Record<string, unknown>): void {
1944
+ recordDebugTrace({
1945
+ category: 'fsm-transition',
1946
+ stage: 'transition',
1947
+ level: 'debug',
1948
+ sessionId: this.instanceId,
1949
+ providerType: this.type,
1950
+ payload,
1951
+ });
1952
+ }
1953
+
1887
1954
  private flushCompletedDebounceIfFinalized(): void {
1888
1955
  const pending = this.completedDebouncePending;
1889
1956
  if (!pending) {
@@ -1897,6 +1964,13 @@ export class CliProviderInstance implements ProviderInstance {
1897
1964
  LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
1898
1965
  if (latestVisibleStatus !== 'idle') {
1899
1966
  LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
1967
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
1968
+ blockReason: 'resumed_status',
1969
+ latestVisibleStatus,
1970
+ previousStatus: pending.previousStatus,
1971
+ busyEpochAtArm: pending.busyEpochAtArm,
1972
+ busyEpoch: this.busyEpoch,
1973
+ });
1900
1974
  this.completedDebouncePending = null;
1901
1975
  this.completedDebounceTimer = null;
1902
1976
  return;
@@ -1914,6 +1988,14 @@ export class CliProviderInstance implements ProviderInstance {
1914
1988
  // so shared behaviour for claude/codex/antigravity is strictly stricter, never looser.
1915
1989
  if (typeof pending.busyEpochAtArm === 'number' && this.busyEpoch !== pending.busyEpochAtArm) {
1916
1990
  LOG.info('CLI', `[${this.type}] cancelled pending completed (busy re-entry during settle: epoch ${pending.busyEpochAtArm}→${this.busyEpoch})`);
1991
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
1992
+ blockReason: 'busy_reentry',
1993
+ latestVisibleStatus,
1994
+ previousStatus: pending.previousStatus,
1995
+ busyEpochAtArm: pending.busyEpochAtArm,
1996
+ busyEpoch: this.busyEpoch,
1997
+ busyEpochDelta: this.busyEpoch - pending.busyEpochAtArm,
1998
+ });
1917
1999
  this.completedDebouncePending = null;
1918
2000
  this.completedDebounceTimer = null;
1919
2001
  return;
@@ -1923,6 +2005,14 @@ export class CliProviderInstance implements ProviderInstance {
1923
2005
  && typeof latestOutputAt === 'number'
1924
2006
  && latestOutputAt > pending.lastOutputAtArm) {
1925
2007
  LOG.info('CLI', `[${this.type}] cancelled pending completed (new PTY output during settle: ${pending.lastOutputAtArm}→${latestOutputAt})`);
2008
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
2009
+ blockReason: 'new_pty_output',
2010
+ latestVisibleStatus,
2011
+ previousStatus: pending.previousStatus,
2012
+ lastOutputAtArm: pending.lastOutputAtArm,
2013
+ lastOutputAt: latestOutputAt,
2014
+ lastOutputAtDelta: latestOutputAt - pending.lastOutputAtArm,
2015
+ });
1926
2016
  this.completedDebouncePending = null;
1927
2017
  this.completedDebounceTimer = null;
1928
2018
  return;
@@ -1968,6 +2058,17 @@ export class CliProviderInstance implements ProviderInstance {
1968
2058
  if (this.isMeshWorkerSession()) {
1969
2059
  traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `${blockReason} waited=${waitedMs}ms`);
1970
2060
  }
2061
+ // COMPLETION-EARLYNOTIFY: a hold is the CORRECT outcome when the turn is not
2062
+ // yet proven done (the FixA/FixB gates route here); trace it so an early-notify
2063
+ // investigation can see the gate holding rather than firing.
2064
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('hold', {
2065
+ blockReason,
2066
+ latestVisibleStatus,
2067
+ terminal: block.terminal === true,
2068
+ holdForTranscript: block.holdForTranscript === true,
2069
+ approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
2070
+ waitedMs,
2071
+ });
1971
2072
  pending.loggedBlockReason = blockReason;
1972
2073
  }
1973
2074
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
@@ -1990,6 +2091,19 @@ export class CliProviderInstance implements ProviderInstance {
1990
2091
  if (this.isMeshWorkerSession()) {
1991
2092
  traceMeshEventStage('fired', this.meshTraceCtx(), `forced after ${waitedMs}ms (${blockReason})`);
1992
2093
  }
2094
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('fire', {
2095
+ path: isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout ? 'canon_c_decoupled' : 'forced_timeout',
2096
+ blockReason,
2097
+ latestVisibleStatus,
2098
+ approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
2099
+ finalAssistantPresent: (completionDiagnostic as any).finalAssistantPresent === true,
2100
+ evidenceSource: (completionDiagnostic as any).finalAssistantEvidenceSource ?? null,
2101
+ lastVisibleRole: (completionDiagnostic as any).lastVisibleRole ?? null,
2102
+ lastVisibleContentLen: (completionDiagnostic as any).lastVisibleContentLength ?? null,
2103
+ emittedAfterFinalizationTimeout,
2104
+ waitedMs,
2105
+ busyEpoch: this.busyEpoch,
2106
+ });
1993
2107
  this.pushEvent({
1994
2108
  event: 'agent:generating_completed',
1995
2109
  chatTitle: pending.chatTitle,
@@ -2021,6 +2135,14 @@ export class CliProviderInstance implements ProviderInstance {
2021
2135
  if (this.isMeshWorkerSession()) {
2022
2136
  traceMeshEventStage('fired', this.meshTraceCtx(), `duration=${pending.duration}s`);
2023
2137
  }
2138
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('fire', {
2139
+ path: 'clean',
2140
+ latestVisibleStatus,
2141
+ approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
2142
+ finalAssistantPresent: true,
2143
+ duration: pending.duration,
2144
+ busyEpoch: this.busyEpoch,
2145
+ });
2024
2146
  this.pushEvent({
2025
2147
  event: 'agent:generating_completed',
2026
2148
  chatTitle: pending.chatTitle,
@@ -2056,6 +2178,7 @@ export class CliProviderInstance implements ProviderInstance {
2056
2178
  // Manual attendance takes over — the modal stays surfaced (maybeAutoApproveStatus
2057
2179
  // returns false), so end the mask episode.
2058
2180
  this.autoApproveMaskSince = 0;
2181
+ this.stalledApprovalNudgeEpisode = 0;
2059
2182
  if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
2060
2183
  this.autoApproveSettleTimer = setTimeout(() => {
2061
2184
  this.autoApproveSettleTimer = null;
@@ -2100,6 +2223,7 @@ export class CliProviderInstance implements ProviderInstance {
2100
2223
  // Modal has genuinely been gone past the hysteresis window → the episode ended;
2101
2224
  // end the mask episode too (a later approval starts a fresh stall clock).
2102
2225
  this.autoApproveMaskSince = 0;
2226
+ this.stalledApprovalNudgeEpisode = 0;
2103
2227
  if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
2104
2228
  return autoApproveActive;
2105
2229
  }
@@ -2110,6 +2234,12 @@ export class CliProviderInstance implements ProviderInstance {
2110
2234
  // when zero so it survives modal-signature changes and hysteresis blips — it measures
2111
2235
  // the true age of the unresolved auto-approve, not the per-signature settle window.
2112
2236
  if (!this.autoApproveMaskSince) this.autoApproveMaskSince = now;
2237
+ // NOTIF-APPROVAL-MASKED (Q1b): once this episode has stalled past the mask threshold,
2238
+ // surface the raw waiting_approval to the mesh coordinator (decoupled from the dashboard
2239
+ // mask). Placed on the active-approval path here — the single choke point that owns the
2240
+ // mask-stall clock and is re-driven throughout a silent stall (getState heartbeat,
2241
+ // detectStatusTransition, recheckAutoApproveSettled). No-op until the stall threshold trips.
2242
+ this.maybeEmitStalledApprovalNudge(adapterStatus, now);
2113
2243
  const modal = adapterStatus.activeModal;
2114
2244
  // (fix) Do not auto-approve when no concrete modal/buttons are present.
2115
2245
  // Claude TUI flaps between paints; without this guard adapterStatus
@@ -2147,10 +2277,22 @@ export class CliProviderInstance implements ProviderInstance {
2147
2277
  return autoApproveActive;
2148
2278
  }
2149
2279
  const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(buttons, this.provider);
2150
- if (buttonIndex < 0 || !hasNegativeApprovalOption(buttons)) {
2151
- // No affirmative matched, or no decline option present (→ not a real
2152
- // consent prompt, e.g. a picker that slipped past the kind gate).
2153
- // Surface the modal so the user decides; never pick blindly.
2280
+ // Structural decline anchor. A real approval offers BOTH an affirmative
2281
+ // and a decline but on a TALL Write/Edit diff the trailing "3. No"
2282
+ // scrolls off the captured frame, leaving only "1. Yes" + "2. Yes, allow
2283
+ // this session" so hasNegativeApprovalOption reads false (#137). A
2284
+ // scoped grant-affirmative ("Yes, allow … during this session" / "…don't
2285
+ // ask again") ONLY appears in a genuine consent modal (never a picker),
2286
+ // so it stands in for the off-frame decline as a reliable second anchor.
2287
+ // Conservative by construction: a picker without a grant-scope option
2288
+ // still bails here, and the fire below still picks the plain allow-once
2289
+ // "Yes" via pickApprovalButton, not the broader grant.
2290
+ const hasReliableConsentAnchor = hasNegativeApprovalOption(buttons)
2291
+ || hasReliableApprovalAffirmative(buttons);
2292
+ if (buttonIndex < 0 || !hasReliableConsentAnchor) {
2293
+ // No affirmative matched, or no decline / reliable grant option present
2294
+ // (→ not a real consent prompt, e.g. a picker that slipped past the
2295
+ // kind gate). Surface the modal so the user decides; never pick blindly.
2154
2296
  return autoApproveActive;
2155
2297
  }
2156
2298
  // Modal *identity* signature — the question/button set only, NO volatile
@@ -2215,6 +2357,7 @@ export class CliProviderInstance implements ProviderInstance {
2215
2357
  this.autoApproveInactiveSince = 0;
2216
2358
  // Fired (resolveModal in flight) — the episode resolved; end the mask-stall clock.
2217
2359
  this.autoApproveMaskSince = 0;
2360
+ this.stalledApprovalNudgeEpisode = 0;
2218
2361
  if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
2219
2362
  this.autoApproveBusyTimer = setTimeout(() => {
2220
2363
  this.autoApproveBusy = false;
@@ -2396,6 +2539,22 @@ export class CliProviderInstance implements ProviderInstance {
2396
2539
  const previousStatus = this.lastStatus;
2397
2540
  if (newStatus !== this.lastStatus) {
2398
2541
  LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
2542
+ // COMPLETION-EARLYNOTIFY: snapshot every FSM status transition (the arm/fire/cancel
2543
+ // decisions downstream all hang off these edges). Guarded so production pays only a
2544
+ // boolean check; payload carries the visibility/auto-approve flags and the continuity
2545
+ // clocks (busyEpoch / lastOutputAt / lastScreenChangeAt) that the completion gate reads.
2546
+ if (this.fsmTraceOn()) this.recordFsmTransitionTrace({
2547
+ from: this.lastStatus,
2548
+ to: newStatus,
2549
+ rawStatus,
2550
+ autoApproveActive,
2551
+ autoApproveHoldIdle,
2552
+ autoApproveBusy: this.autoApproveBusy,
2553
+ hasPending: this.hasAdapterPendingResponse(),
2554
+ busyEpoch: this.busyEpoch,
2555
+ lastOutputAt: typeof adapterStatus?.lastOutputAt === 'number' ? adapterStatus.lastOutputAt : null,
2556
+ lastScreenChangeAt: typeof adapterStatus?.lastScreenChangeAt === 'number' ? adapterStatus.lastScreenChangeAt : null,
2557
+ });
2399
2558
  // GENERATING-MISSING (win32 fresh-worktree first-turn): a freshly-launched session
2400
2559
  // is in 'starting' until its startup-grace settles to idle. When the FIRST inject
2401
2560
  // lands inside that grace window, the adapter can report status DIRECTLY
@@ -2633,6 +2792,17 @@ export class CliProviderInstance implements ProviderInstance {
2633
2792
  if (this.isMeshWorkerSession()) {
2634
2793
  traceMeshEventStage('arm', this.meshTraceCtx(), `short-generating settle-arm (source=${shortEvidenceSource}, missingEvidence=${missingEvidence})`);
2635
2794
  }
2795
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('arm', {
2796
+ branch: 'short_generating',
2797
+ previousStatus: this.lastStatus,
2798
+ turnStartedAt: shortTurnStartedAt || null,
2799
+ busyEpochAtArm: this.busyEpoch,
2800
+ lastOutputAtArm: typeof adapterStatus?.lastOutputAt === 'number' ? adapterStatus.lastOutputAt : null,
2801
+ flushDelay: NATIVE_HISTORY_MESH_IDLE_SETTLE_MS,
2802
+ evidenceSource: shortEvidenceSource,
2803
+ missingEvidence,
2804
+ hasFinalSummary: !!shortFinalSummary,
2805
+ });
2636
2806
  this.scheduleCompletedDebounceFlush(NATIVE_HISTORY_MESH_IDLE_SETTLE_MS);
2637
2807
  } else if (missingEvidence) {
2638
2808
  // NON-MESH, missing evidence: suppress the completion event entirely (the
@@ -2721,6 +2891,16 @@ export class CliProviderInstance implements ProviderInstance {
2721
2891
  ? (meshSettleSession ? NATIVE_HISTORY_MESH_IDLE_SETTLE_MS : 0)
2722
2892
  : 3000;
2723
2893
  LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} meshSettle=${meshSettleSession} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
2894
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('arm', {
2895
+ branch: 'normal',
2896
+ previousStatus: this.completedDebouncePending.previousStatus,
2897
+ turnStartedAt: this.completedDebouncePending.turnStartedAt ?? null,
2898
+ busyEpochAtArm: this.completedDebouncePending.busyEpochAtArm ?? null,
2899
+ lastOutputAtArm: this.completedDebouncePending.lastOutputAtArm ?? null,
2900
+ flushDelay,
2901
+ ownsExternalHistory,
2902
+ meshSettle: meshSettleSession,
2903
+ });
2724
2904
  this.scheduleCompletedDebounceFlush(flushDelay);
2725
2905
  }
2726
2906
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {
@@ -3124,7 +3304,16 @@ export class CliProviderInstance implements ProviderInstance {
3124
3304
  }
3125
3305
 
3126
3306
  /** @see ProviderInstance.noteManualInteraction */
3127
- noteManualInteraction(now = Date.now()): void {
3307
+ noteManualInteraction(now = Date.now(), opts?: { passive?: boolean }): void {
3308
+ // P1b (#137 secondary): a DELEGATED worker session must not treat a
3309
+ // passive dashboard view (foreground tab selection / panel open) as
3310
+ // manual attendance. A coordinator merely peeking at a worker's panel
3311
+ // would otherwise suppress that worker's delegated auto-approve for the
3312
+ // whole 60s window. Only explicit input/intervention (controlbar,
3313
+ // resolve_action, pty_input) attends a worker. Non-worker (foreground)
3314
+ // sessions keep noting on passive views so a user foregrounding their own
3315
+ // session still holds auto-approve to act on the modal themselves.
3316
+ if (opts?.passive && this.isMeshWorkerSession()) return;
3128
3317
  this.manualAttendance.note(now);
3129
3318
  }
3130
3319
 
@@ -3154,6 +3343,50 @@ export class CliProviderInstance implements ProviderInstance {
3154
3343
  && now - this.autoApproveMaskSince > CliProviderInstance.AUTO_APPROVE_MASK_STALL_MS;
3155
3344
  }
3156
3345
 
3346
+ /**
3347
+ * NOTIF-APPROVAL-MASKED (Q1b): surface a delegated worker's STALLED auto-approve modal
3348
+ * to the mesh COORDINATOR, decoupled from the dashboard visible-status mask.
3349
+ *
3350
+ * When auto-approve is configured but the episode never settles (modal parse miss / the
3351
+ * settle gate never satisfied), getState()/detectStatusTransition() fold the raw
3352
+ * `waiting_approval` into `generating` to suppress dashboard flicker — so
3353
+ * detectStatusTransition()'s `waiting_approval` arm never runs and NO agent:waiting_approval
3354
+ * event is emitted. The coordinator's real-time approval-nudge delivery then has no input and
3355
+ * the worker's stuck modal is never surfaced (the live ~25s stall). The dashboard mask is
3356
+ * intentional and stays; this emits the coordinator nudge exactly ONCE, gated on the SAME
3357
+ * raw-waiting_approval + mask-stalled signal resolveModalParkStatus() distinguishes, the
3358
+ * instant the mask-stall threshold trips (the same moment getState un-folds the mask).
3359
+ *
3360
+ * Only delegated worker sessions qualify: a foreground session has no coordinator to notify,
3361
+ * and its own dashboard mask already reveals the modal on stall. A normally-resolving
3362
+ * auto-approve never reaches AUTO_APPROVE_MASK_STALL_MS, so it emits nothing here; and if a
3363
+ * masked approval clears just as this fires, rc.455's isApprovalNudgeResolved stale-drop
3364
+ * discards the nudge coordinator-side without noise. Dedup is per-episode (keyed on the
3365
+ * mask-clock value) so a modal that flaps between parsed/unparsed states is announced once.
3366
+ */
3367
+ private maybeEmitStalledApprovalNudge(adapterStatus: any, now: number): void {
3368
+ if (!this.isMeshWorkerSession()) return;
3369
+ if (adapterStatus?.status !== 'waiting_approval') return;
3370
+ if (!this.autoApproveMaskStalled(now)) return;
3371
+ // Exactly once per stalled episode (autoApproveMaskSince uniquely identifies it).
3372
+ if (this.stalledApprovalNudgeEpisode === this.autoApproveMaskSince) return;
3373
+ this.stalledApprovalNudgeEpisode = this.autoApproveMaskSince;
3374
+ const modal = adapterStatus.activeModal;
3375
+ const dirName = workingDirBasename(this.workingDir);
3376
+ const chatTitle = `${this.provider.name} · ${dirName}`;
3377
+ this.appendRuntimeSystemMessage(
3378
+ this.formatApprovalRequestMessage(modal?.message, modal?.buttons),
3379
+ `approval_request:${now}`,
3380
+ now,
3381
+ );
3382
+ this.pushEvent({
3383
+ event: 'agent:waiting_approval', chatTitle, timestamp: now,
3384
+ modalMessage: modal?.message,
3385
+ modalButtons: modal?.buttons,
3386
+ });
3387
+ LOG.info('CLI', `[${this.type}] stalled auto-approve nudge → coordinator (masked ${Math.round((now - this.autoApproveMaskSince) / 1000)}s)`);
3388
+ }
3389
+
3157
3390
  private recordAutoApproval(modalMessage?: string, buttonLabel?: string, now = Date.now()): void {
3158
3391
  this.appendRuntimeSystemMessage(
3159
3392
  formatAutoApprovalMessage(modalMessage, buttonLabel),
@@ -83,3 +83,23 @@ export const MANUAL_ATTENDANCE_COMMANDS: ReadonlySet<string> = new Set([
83
83
  'resolve_action',
84
84
  'pty_input',
85
85
  ]);
86
+
87
+ /**
88
+ * The subset of {@link MANUAL_ATTENDANCE_COMMANDS} that are PASSIVE view-only
89
+ * actions — foregrounding a session's tab / opening its panel. They convey "I am
90
+ * looking at this session", not "I am driving it", and carry no user input.
91
+ *
92
+ * For a foreground (base-node) session these still attend: a user who
93
+ * foregrounds their own session should get the quiet window so an incoming
94
+ * approval stays visible for them to act on. But for a DELEGATED worker session
95
+ * a passive peek must NOT attend — a coordinator merely opening a worker's panel
96
+ * to watch progress would otherwise suppress that worker's delegated
97
+ * auto-approve for the whole window (secondary cause, #137). The per-instance
98
+ * hook decides: it drops a passive stamp only when the session is a delegated
99
+ * worker, so explicit input (controlbar / resolve_action / pty_input) still
100
+ * attends a worker and a foreground session is unaffected.
101
+ */
102
+ export const MANUAL_ATTENDANCE_PASSIVE_VIEW_COMMANDS: ReadonlySet<string> = new Set([
103
+ 'select_session',
104
+ 'open_panel',
105
+ ]);
@@ -232,8 +232,13 @@ export interface ProviderInstance {
232
232
  * input). Provider-common signal that suppresses auto-approve for a short
233
233
  * window so the user can drive the session manually; background mesh worker
234
234
  * sessions never receive it, so their delegated auto-approve is unaffected.
235
+ *
236
+ * `opts.passive` marks a view-only action (select_session / open_panel). A
237
+ * delegated worker session ignores passive stamps so a coordinator merely
238
+ * watching its panel does not suppress its delegated auto-approve; explicit
239
+ * input still attends. Foreground sessions attend on passive views too.
235
240
  */
236
- noteManualInteraction?(now?: number): void;
241
+ noteManualInteraction?(now?: number, opts?: { passive?: boolean }): void;
237
242
 
238
243
  /** cleanup */
239
244
  dispose(): void;