@adhdev/daemon-core 0.9.82-rc.352 → 0.9.82-rc.354

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.
@@ -51,6 +51,7 @@ import { normalizeContent, flattenContent, normalizeInputEnvelope } from './cont
51
51
  import { assertProviderSupportsDeclaredInput, getEffectiveMessageInputSupport } from './provider-input-support.js';
52
52
  import type { ProviderInstance, ProviderState, AcpProviderState, ProviderErrorReason, ProviderEvent, InstanceContext, SessionModalState } from './provider-instance.js';
53
53
  import { StatusMonitor } from './status-monitor.js';
54
+ import { ManualAttendanceTracker } from './manual-attendance.js';
54
55
  import { buildLegacyModelModeSummaryMetadata } from './summary-metadata.js';
55
56
  import { workingDirBasename } from './working-dir.js';
56
57
  import {
@@ -845,7 +846,12 @@ export class AcpProviderInstance implements ProviderInstance {
845
846
  }
846
847
 
847
848
  // ─── Auto-approve: skip user confirmation ───
848
- if (this.settings.autoApprove !== false) {
849
+ // Held while a human is actively attending this session (manual
850
+ // attendance) so they can decide the permission themselves; falls
851
+ // through to the waiting_approval manual path below. A background
852
+ // worker is never attended, so its delegated auto-approve fires
853
+ // as before.
854
+ if (this.settings.autoApprove !== false && !this.manualAttendance.isAttended()) {
849
855
  const toolTitle = tc.title || tc.toolCallId || 'tool call';
850
856
  this.log.info(`[${this.type}] Auto-approving: ${toolTitle}`);
851
857
  this.appendSystemMessage(`Auto-approved: ${toolTitle}`);
@@ -1128,6 +1134,17 @@ export class AcpProviderInstance implements ProviderInstance {
1128
1134
 
1129
1135
  private permissionResolvers: ((approved: boolean) => void)[] = [];
1130
1136
 
1137
+ // Provider-common manual-attendance signal: while a human is actively driving
1138
+ // this session from the dashboard, auto-approve holds so they can decide on
1139
+ // the permission request themselves. Background workers are never attended →
1140
+ // delegated auto-approve is unaffected.
1141
+ private readonly manualAttendance = new ManualAttendanceTracker();
1142
+
1143
+ /** @see ProviderInstance.noteManualInteraction */
1144
+ noteManualInteraction(now = Date.now()): void {
1145
+ this.manualAttendance.note(now);
1146
+ }
1147
+
1131
1148
  async resolvePermission(approved: boolean): Promise<void> {
1132
1149
  const resolver = this.permissionResolvers.shift();
1133
1150
  if (resolver) {
@@ -29,6 +29,7 @@ import { mergeProviderPatchState, resolveProviderStateSurface } from './provider
29
29
  import { normalizeProviderSessionId } from './provider-session-id.js';
30
30
  import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages } from './chat-message-normalization.js';
31
31
  import { workingDirBasename } from './working-dir.js';
32
+ import { ManualAttendanceTracker } from './manual-attendance.js';
32
33
 
33
34
  type PersistableCliHistoryMessage = {
34
35
  role: string;
@@ -416,6 +417,11 @@ export class CliProviderInstance implements ProviderInstance {
416
417
  // a settle gate was in progress. Drives AUTO_APPROVE_GATE_HYSTERESIS_MS so a
417
418
  // brief generating flip does not immediately wipe the settle clock.
418
419
  private autoApproveInactiveSince = 0;
420
+ // Provider-common manual-attendance signal: while a human is actively driving
421
+ // this session from the dashboard, auto-approve holds so they can take manual
422
+ // control. Background mesh workers are never attended → delegated auto-approve
423
+ // is unaffected.
424
+ private readonly manualAttendance = new ManualAttendanceTracker();
419
425
  private controlValues: Record<string, string | number | boolean> = {};
420
426
  private summaryMetadata: unknown = undefined;
421
427
  private appliedEffectKeys = new Set<string>();
@@ -828,7 +834,7 @@ export class CliProviderInstance implements ProviderInstance {
828
834
 
829
835
  getHotChatSessionState(): HotChatSessionState {
830
836
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
831
- const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
837
+ const autoApproveActive = this.autoApproveEffectivelyActive(adapterStatus.status);
832
838
  const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
833
839
  const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
834
840
  const runtime = this.adapter.getRuntimeMetadata();
@@ -844,7 +850,7 @@ export class CliProviderInstance implements ProviderInstance {
844
850
 
845
851
  getSessionModalState(sessionId?: string): SessionModalState {
846
852
  const adapterStatus = this.adapter.getStatus({ allowParse: true });
847
- const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
853
+ const autoApproveActive = this.autoApproveEffectivelyActive(adapterStatus.status);
848
854
  const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
849
855
  const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
850
856
  const dirName = workingDirBasename(this.workingDir);
@@ -947,7 +953,10 @@ export class CliProviderInstance implements ProviderInstance {
947
953
  } catch {
948
954
  return null;
949
955
  }
950
- if (adapterStatus.status === 'waiting_approval' && !this.shouldAutoApprove()) {
956
+ // A session whose auto-approve is held by manual attendance IS parked on a
957
+ // modal awaiting the human — autoApproveEffectivelyActive folds that in, so
958
+ // the mesh force-inject guard correctly treats it as modal-parked.
959
+ if (adapterStatus.status === 'waiting_approval' && !this.autoApproveEffectivelyActive(adapterStatus.status)) {
951
960
  return 'waiting_approval';
952
961
  }
953
962
  return null;
@@ -1481,6 +1490,29 @@ export class CliProviderInstance implements ProviderInstance {
1481
1490
  }
1482
1491
 
1483
1492
  private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
1493
+ // Manual-attendance suppression (provider-common): when a human is
1494
+ // actively driving this session from the dashboard, hold auto-approve so
1495
+ // the modal stays visible and they can pick a button / use the controlbar
1496
+ // themselves. Return false (NOT auto-approving) so getState keeps the
1497
+ // modal surfaced. Clear any in-progress settle gate — a genuine fire
1498
+ // after the window lapses must re-settle from scratch — and arm a
1499
+ // re-check for the lapse moment, because the PTY may have gone silent and
1500
+ // would otherwise never re-drive this decision. Background mesh workers
1501
+ // are never attended, so their delegated auto-approve is untouched.
1502
+ if (adapterStatus?.status === 'waiting_approval'
1503
+ && this.shouldAutoApprove()
1504
+ && this.manualAttendance.isAttended(now)) {
1505
+ this.lastAutoApprovalSignature = '';
1506
+ this.pendingAutoApprovalSignature = '';
1507
+ this.pendingAutoApprovalSince = 0;
1508
+ this.autoApproveInactiveSince = 0;
1509
+ if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
1510
+ this.autoApproveSettleTimer = setTimeout(() => {
1511
+ this.autoApproveSettleTimer = null;
1512
+ this.recheckAutoApproveSettled();
1513
+ }, this.manualAttendance.remainingMs(now) + 20);
1514
+ return false;
1515
+ }
1484
1516
  const autoApproveActive = adapterStatus?.status === 'waiting_approval' && this.shouldAutoApprove();
1485
1517
  // Guard re-entry: onStatusChange/getState can observe the same modal multiple
1486
1518
  // times while the PTY absorbs the approval key. Without this flag, repeated
@@ -2096,6 +2128,25 @@ export class CliProviderInstance implements ProviderInstance {
2096
2128
  return false;
2097
2129
  }
2098
2130
 
2131
+ /** @see ProviderInstance.noteManualInteraction */
2132
+ noteManualInteraction(now = Date.now()): void {
2133
+ this.manualAttendance.note(now);
2134
+ }
2135
+
2136
+ /**
2137
+ * Whether auto-approve should be treated as active *right now* for display
2138
+ * and firing decisions: the configured intent AND the user is not currently
2139
+ * attending this session by hand. When a human is attending, auto-approve is
2140
+ * held so the modal stays visible and they can drive it via the controlbar.
2141
+ * Provider-agnostic — the attendance signal is the command set, never any
2142
+ * CLI-specific modal text.
2143
+ */
2144
+ private autoApproveEffectivelyActive(status: string | undefined, now = Date.now()): boolean {
2145
+ return status === 'waiting_approval'
2146
+ && this.shouldAutoApprove()
2147
+ && !this.manualAttendance.isAttended(now);
2148
+ }
2149
+
2099
2150
  private recordAutoApproval(modalMessage?: string, buttonLabel?: string, now = Date.now()): void {
2100
2151
  this.appendRuntimeSystemMessage(
2101
2152
  formatAutoApprovalMessage(modalMessage, buttonLabel),
@@ -0,0 +1,85 @@
1
+ /**
2
+ * ManualAttendanceTracker — provider-agnostic "is a human driving this session
3
+ * right now" signal, used to suppress auto-approve while the user is taking
4
+ * manual control of a session from the dashboard.
5
+ *
6
+ * Why this exists
7
+ * ---------------
8
+ * When `autoApprove` is on, an approval modal is auto-dismissed within a few
9
+ * hundred ms of appearing. For a background mesh worker that is exactly the
10
+ * desired delegated behavior. But for a session the user is actively watching
11
+ * and operating (a base-node / foreground session), the auto-fire closes the
12
+ * modal before the human can pick a button — and likewise fights their use of
13
+ * the controlbar. The fix is to give the human a short quiet window: while they
14
+ * are attending the session by hand, auto-approve holds; once they go idle it
15
+ * resumes.
16
+ *
17
+ * The tracker holds only a timestamp. The *signal* — which commands count as
18
+ * "a human attending" — is decided by the caller (the command handler), and is
19
+ * the same set for every provider: foreground tab selection (select_session /
20
+ * open_panel), controlbar use (invoke_provider_script / set_mode / change_model
21
+ * / set_thought_level), manual approval (resolve_action) and manual terminal
22
+ * input (pty_input). Notably NOT send_chat, which is also how a coordinator
23
+ * delegates a task to a worker — counting it would wrongly suppress the
24
+ * worker's delegated auto-approve.
25
+ *
26
+ * Because a background worker never receives any of those attending commands,
27
+ * it is never "attended", so its delegated auto-approve is unaffected. The
28
+ * mechanism is therefore provider-common AND preserves worker auto-approve
29
+ * without any per-provider branching.
30
+ */
31
+
32
+ /**
33
+ * How long after the last manual interaction auto-approve stays suppressed.
34
+ *
35
+ * Trade-off: long enough that after foregrounding a session's tab the user has
36
+ * a realistic chance to act on an incoming approval (the auto-approve settle
37
+ * window is only ~600ms, so a human cannot out-race it), yet short enough that
38
+ * a session left unattended — e.g. a worker tab the user briefly peeked at —
39
+ * resumes auto-approving within about a minute. Re-armed on every attending
40
+ * command, so a user who keeps interacting keeps the window fresh.
41
+ */
42
+ export const AUTO_APPROVE_MANUAL_ATTENDANCE_SUPPRESS_MS = 60_000;
43
+
44
+ export class ManualAttendanceTracker {
45
+ private lastInteractionAt = 0;
46
+
47
+ constructor(private readonly suppressMs: number = AUTO_APPROVE_MANUAL_ATTENDANCE_SUPPRESS_MS) {}
48
+
49
+ /** Record that a human just drove this session by hand. */
50
+ note(now = Date.now()): void {
51
+ this.lastInteractionAt = now;
52
+ }
53
+
54
+ /** True while a manual interaction is recent enough to suppress auto-approve. */
55
+ isAttended(now = Date.now()): boolean {
56
+ return this.lastInteractionAt > 0 && (now - this.lastInteractionAt) < this.suppressMs;
57
+ }
58
+
59
+ /**
60
+ * Milliseconds remaining in the current suppression window, or 0 when not
61
+ * attended. Used to re-arm a re-check timer so auto-approve fires the moment
62
+ * the window lapses even if the PTY/agent has since gone silent.
63
+ */
64
+ remainingMs(now = Date.now()): number {
65
+ if (this.lastInteractionAt <= 0) return 0;
66
+ return Math.max(0, this.suppressMs - (now - this.lastInteractionAt));
67
+ }
68
+ }
69
+
70
+ /**
71
+ * The session-scoped commands that count as "a human is attending this session
72
+ * by hand". Shared so the command handler and any forward path agree on one
73
+ * definition. Deliberately excludes send_chat (coordinator task delegation) and
74
+ * pure read commands (read_chat / list_chats — passive polling, not driving).
75
+ */
76
+ export const MANUAL_ATTENDANCE_COMMANDS: ReadonlySet<string> = new Set([
77
+ 'select_session',
78
+ 'open_panel',
79
+ 'invoke_provider_script',
80
+ 'set_mode',
81
+ 'change_model',
82
+ 'set_thought_level',
83
+ 'resolve_action',
84
+ 'pty_input',
85
+ ]);
@@ -226,6 +226,15 @@ export interface ProviderInstance {
226
226
  /** Refresh static provider definition/scripts without restarting the live runtime. */
227
227
  refreshProviderDefinition?(provider: ProviderModule): void;
228
228
 
229
+ /**
230
+ * Record that a human is actively attending this session by hand right now
231
+ * (foreground tab selection, controlbar use, manual approval, terminal
232
+ * input). Provider-common signal that suppresses auto-approve for a short
233
+ * window so the user can drive the session manually; background mesh worker
234
+ * sessions never receive it, so their delegated auto-approve is unaffected.
235
+ */
236
+ noteManualInteraction?(now?: number): void;
237
+
229
238
  /** cleanup */
230
239
  dispose(): void;
231
240
  }