@adhdev/daemon-core 0.9.82-rc.353 → 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.
@@ -88,6 +88,9 @@ export declare class AcpProviderInstance implements ProviderInstance {
88
88
  sendPrompt(text: string, contentBlocks?: ContentBlock[]): Promise<void>;
89
89
  private cancelSession;
90
90
  private permissionResolvers;
91
+ private readonly manualAttendance;
92
+ /** @see ProviderInstance.noteManualInteraction */
93
+ noteManualInteraction(now?: number): void;
91
94
  resolvePermission(approved: boolean): Promise<void>;
92
95
  private handleSessionUpdate;
93
96
  /** Handle legacy session/update formats (pre-standardization compat) */
@@ -79,6 +79,7 @@ export declare class CliProviderInstance implements ProviderInstance {
79
79
  private pendingAutoApprovalSince;
80
80
  private autoApproveSettleTimer;
81
81
  private autoApproveInactiveSince;
82
+ private readonly manualAttendance;
82
83
  private controlValues;
83
84
  private summaryMetadata;
84
85
  private appliedEffectKeys;
@@ -204,6 +205,17 @@ export declare class CliProviderInstance implements ProviderInstance {
204
205
  get cliType(): string;
205
206
  get cliName(): string;
206
207
  private shouldAutoApprove;
208
+ /** @see ProviderInstance.noteManualInteraction */
209
+ noteManualInteraction(now?: number): void;
210
+ /**
211
+ * Whether auto-approve should be treated as active *right now* for display
212
+ * and firing decisions: the configured intent AND the user is not currently
213
+ * attending this session by hand. When a human is attending, auto-approve is
214
+ * held so the modal stays visible and they can drive it via the controlbar.
215
+ * Provider-agnostic — the attendance signal is the command set, never any
216
+ * CLI-specific modal text.
217
+ */
218
+ private autoApproveEffectivelyActive;
207
219
  private recordAutoApproval;
208
220
  recordApprovalSelection(buttonText: string): void;
209
221
  private formatMarkerTimestamp;
@@ -0,0 +1,63 @@
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
+ * How long after the last manual interaction auto-approve stays suppressed.
33
+ *
34
+ * Trade-off: long enough that after foregrounding a session's tab the user has
35
+ * a realistic chance to act on an incoming approval (the auto-approve settle
36
+ * window is only ~600ms, so a human cannot out-race it), yet short enough that
37
+ * a session left unattended — e.g. a worker tab the user briefly peeked at —
38
+ * resumes auto-approving within about a minute. Re-armed on every attending
39
+ * command, so a user who keeps interacting keeps the window fresh.
40
+ */
41
+ export declare const AUTO_APPROVE_MANUAL_ATTENDANCE_SUPPRESS_MS = 60000;
42
+ export declare class ManualAttendanceTracker {
43
+ private readonly suppressMs;
44
+ private lastInteractionAt;
45
+ constructor(suppressMs?: number);
46
+ /** Record that a human just drove this session by hand. */
47
+ note(now?: number): void;
48
+ /** True while a manual interaction is recent enough to suppress auto-approve. */
49
+ isAttended(now?: number): boolean;
50
+ /**
51
+ * Milliseconds remaining in the current suppression window, or 0 when not
52
+ * attended. Used to re-arm a re-check timer so auto-approve fires the moment
53
+ * the window lapses even if the PTY/agent has since gone silent.
54
+ */
55
+ remainingMs(now?: number): number;
56
+ }
57
+ /**
58
+ * The session-scoped commands that count as "a human is attending this session
59
+ * by hand". Shared so the command handler and any forward path agree on one
60
+ * definition. Deliberately excludes send_chat (coordinator task delegation) and
61
+ * pure read commands (read_chat / list_chats — passive polling, not driving).
62
+ */
63
+ export declare const MANUAL_ATTENDANCE_COMMANDS: ReadonlySet<string>;
@@ -194,6 +194,14 @@ export interface ProviderInstance {
194
194
  detachMeshAssignment?(): void;
195
195
  /** Refresh static provider definition/scripts without restarting the live runtime. */
196
196
  refreshProviderDefinition?(provider: ProviderModule): void;
197
+ /**
198
+ * Record that a human is actively attending this session by hand right now
199
+ * (foreground tab selection, controlbar use, manual approval, terminal
200
+ * input). Provider-common signal that suppresses auto-approve for a short
201
+ * window so the user can drive the session manually; background mesh worker
202
+ * sessions never receive it, so their delegated auto-approve is unaffected.
203
+ */
204
+ noteManualInteraction?(now?: number): void;
197
205
  /** cleanup */
198
206
  dispose(): void;
199
207
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.353",
3
+ "version": "0.9.82-rc.354",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.353",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.354",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -18,7 +18,7 @@ import { loadConfig } from '../config/config.js';
18
18
  import { loadState, saveState } from '../config/state-store.js';
19
19
  import { getWorkspaceState, resolveLaunchDirectory } from '../config/workspaces.js';
20
20
  import { appendRecentActivity } from '../config/recent-activity.js';
21
- import { unregisterMeshCoordinator } from '../mesh/coordinator-registry.js';
21
+ import { unregisterMeshCoordinator, getCoordinatorForSession } from '../mesh/coordinator-registry.js';
22
22
  import { upsertSavedProviderSession } from '../config/saved-sessions.js';
23
23
  import { buildLegacyModelModeSummaryMetadata, normalizeProviderSummaryMetadata } from '../providers/summary-metadata.js';
24
24
  import { CliProviderInstance } from '../providers/cli-provider-instance.js';
@@ -1042,6 +1042,24 @@ export class DaemonCliManager {
1042
1042
  );
1043
1043
  continue;
1044
1044
  }
1045
+ // Re-establish the launch-time settings a fresh launch applies. startSession
1046
+ // seeds every new instance with { ...providerLoader.getSettings(type), ...override };
1047
+ // passing a bare {} here on restart silently dropped TWO launch settings, so a
1048
+ // restored session diverged from a freshly-launched one:
1049
+ // - autoApprove (a provider/machine setting from getSettings) → a restored
1050
+ // coordinator self-session lost auto-approve and re-prompted on every tool call.
1051
+ // - meshCoordinatorFor (the coordinator launch's settingsOverride) → the restored
1052
+ // session was no longer recognized as this daemon's live CLI coordinator by
1053
+ // findLiveCoordinators (so pending mesh events stopped draining into its PTY) nor
1054
+ // surfaced with the coordinator badge via settings. The persisted coordinator
1055
+ // registry (loaded on boot) is the source of truth to rebuild that mark.
1056
+ // Both restores are provider-agnostic — getSettings is keyed by provider type and the
1057
+ // registry mark is type-independent.
1058
+ const restoredSettings: Record<string, any> = { ...this.providerLoader.getSettings(normalizedType) };
1059
+ const coordinatorEntry = getCoordinatorForSession(record.runtimeId);
1060
+ if (coordinatorEntry?.meshId) {
1061
+ restoredSettings.meshCoordinatorFor = coordinatorEntry.meshId;
1062
+ }
1045
1063
  try {
1046
1064
  await this.registerCliInstance(
1047
1065
  record.runtimeId,
@@ -1050,7 +1068,7 @@ export class DaemonCliManager {
1050
1068
  record.workspace,
1051
1069
  record.cliArgs,
1052
1070
  resolvedProvider,
1053
- {},
1071
+ restoredSettings,
1054
1072
  true,
1055
1073
  {
1056
1074
  providerSessionId: sessionBinding.providerSessionId,
@@ -25,6 +25,7 @@ import type { SessionRegistry, SessionRuntimeTarget } from '../sessions/registry
25
25
  import { reconcileIdeRuntimeSessions } from '../sessions/reconcile.js';
26
26
  import { LOG } from '../logging/logger.js';
27
27
  import { resolveLegacyProviderScript, type LegacyStringScript } from './provider-script-resolver.js';
28
+ import { MANUAL_ATTENDANCE_COMMANDS } from '../providers/manual-attendance.js';
28
29
 
29
30
  // Sub-module imports
30
31
  import * as Chat from './chat-commands.js';
@@ -375,6 +376,36 @@ export class DaemonCommandHandler implements CommandHelpers {
375
376
  this._agentStream = manager;
376
377
  }
377
378
 
379
+ /**
380
+ * When a command in the manual-attendance set arrives for a session this
381
+ * daemon hosts, stamp the live instance so auto-approve holds while the user
382
+ * drives the session by hand. Provider-common: the signal is the command
383
+ * (foreground select_session / open_panel, controlbar invoke_provider_script
384
+ * / set_mode / change_model / set_thought_level, manual resolve_action,
385
+ * pty_input), never any CLI-specific modal text — so it works identically for
386
+ * every CLI/ACP provider. send_chat is deliberately excluded because a
387
+ * coordinator delegating a task to a worker also uses send_chat; counting it
388
+ * would wrongly suppress the worker's delegated auto-approve. For a remote
389
+ * mesh worker session the controlbar commands are forwarded to the owning
390
+ * worker daemon, which runs this same hook there, so attendance is recorded
391
+ * on the daemon that actually hosts the instance.
392
+ */
393
+ private noteManualAttendanceIfApplicable(cmd: string, args: any): void {
394
+ if (!MANUAL_ATTENDANCE_COMMANDS.has(cmd)) return;
395
+ const sessionId = this._currentRoute.session?.sessionId
396
+ || (typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '');
397
+ if (!sessionId) return;
398
+ const session = this._ctx.sessionRegistry?.get(sessionId);
399
+ const instanceKey = session?.adapterKey || session?.instanceKey || sessionId;
400
+ const instance = this._ctx.instanceManager?.getInstance(instanceKey) as
401
+ { noteManualInteraction?: (now?: number) => void } | undefined;
402
+ try {
403
+ instance?.noteManualInteraction?.();
404
+ } catch {
405
+ // attendance is best-effort — never block command dispatch
406
+ }
407
+ }
408
+
378
409
  // ─── Command Dispatcher ──────────────────────────
379
410
 
380
411
  async handle(cmd: string, args: any): Promise<CommandResult> {
@@ -382,6 +413,7 @@ export class DaemonCommandHandler implements CommandHelpers {
382
413
  this._currentRoute = this.resolveRoute(args);
383
414
  const startedAt = Date.now();
384
415
  this.logCommandStart(cmd, args);
416
+ this.noteManualAttendanceIfApplicable(cmd, args);
385
417
  let result: CommandResult;
386
418
 
387
419
  if (isGitCommandName(cmd)) {
@@ -1,7 +1,7 @@
1
1
  import { readFile, realpath } from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import type { GitDiffSummary, GitFileChange, GitFileChangeStatus } from './git-types.js';
4
- import { GitCommandError, isPathInside, resolveGitRepository, runGit } from './git-executor.js';
4
+ import { GIT_STATUS_TIMEOUT_MS, GitCommandError, isPathInside, resolveGitRepository, runGit } from './git-executor.js';
5
5
 
6
6
  const DEFAULT_MAX_FILES = 200;
7
7
  const DEFAULT_MAX_BYTES = 200_000;
@@ -18,6 +18,21 @@ export interface GitDiffOptions {
18
18
  baseRef?: string;
19
19
  }
20
20
 
21
+ /**
22
+ * Diff collection fans out an even larger parallel git burst than status (up to five
23
+ * concurrent spawns — name-status/numstat × unstaged/staged + ls-files — plus its own
24
+ * `rev-parse --show-toplevel`) and runs concurrently with the status path. On Windows the
25
+ * per-spawn cost alone can exceed the 5s `execGitRaw` default, so — exactly like
26
+ * getGitRepoStatus — give the collection path the larger status budget unless the caller
27
+ * pinned a timeout. Without this, a slow-but-healthy Windows worktree times out on the diff
28
+ * burst and the catch below flattens it to `repoRoot:null, isGitRepo:false`, dropping the
29
+ * diff while the (already-hardened) status block reports isGitRepo:true — an asymmetric
30
+ * failure that reads as "not a git repo" for diff only.
31
+ */
32
+ function withCollectionTimeout(options: GitDiffOptions): GitDiffOptions {
33
+ return options.timeoutMs === undefined ? { ...options, timeoutMs: GIT_STATUS_TIMEOUT_MS } : options;
34
+ }
35
+
21
36
  function validateBaseRef(ref: string): string {
22
37
  const trimmed = ref.trim();
23
38
  if (!trimmed || trimmed.startsWith('-') || trimmed.includes('..') || !/^[A-Za-z0-9][A-Za-z0-9._/@-]*$/.test(trimmed)) {
@@ -54,16 +69,17 @@ export async function getGitDiffSummary(
54
69
  options: GitDiffOptions = {},
55
70
  ): Promise<GitDiffSummary> {
56
71
  const lastCheckedAt = Date.now();
72
+ const effectiveOptions = withCollectionTimeout(options);
57
73
 
58
74
  try {
59
- const repo = await resolveGitRepository(workspace, options);
75
+ const repo = await resolveGitRepository(workspace, effectiveOptions);
60
76
  const repoRoot = repo.repoRoot!;
61
77
 
62
78
  if (options.baseRef) {
63
79
  const range = `${validateBaseRef(options.baseRef)}...HEAD`;
64
80
  const [nameStatus, numstat] = await Promise.all([
65
- runGit(repo, ['diff', '--no-ext-diff', '--name-status', range, '--'], { ...options, cwd: repoRoot }),
66
- runGit(repo, ['diff', '--no-ext-diff', '--numstat', range, '--'], { ...options, cwd: repoRoot }),
81
+ runGit(repo, ['diff', '--no-ext-diff', '--name-status', range, '--'], { ...effectiveOptions, cwd: repoRoot }),
82
+ runGit(repo, ['diff', '--no-ext-diff', '--numstat', range, '--'], { ...effectiveOptions, cwd: repoRoot }),
67
83
  ]);
68
84
  const outputBytes = byteLength(nameStatus.stdout + numstat.stdout);
69
85
  const changes = combineDiffEntries(nameStatus.stdout, numstat.stdout, false);
@@ -83,11 +99,11 @@ export async function getGitDiffSummary(
83
99
  }
84
100
 
85
101
  const [unstagedNameStatus, unstagedNumstat, stagedNameStatus, stagedNumstat, untracked] = await Promise.all([
86
- runGit(repo, ['diff', '--no-ext-diff', '--name-status'], { ...options, cwd: repoRoot }),
87
- runGit(repo, ['diff', '--no-ext-diff', '--numstat'], { ...options, cwd: repoRoot }),
88
- runGit(repo, ['diff', '--cached', '--no-ext-diff', '--name-status'], { ...options, cwd: repoRoot }),
89
- runGit(repo, ['diff', '--cached', '--no-ext-diff', '--numstat'], { ...options, cwd: repoRoot }),
90
- runGit(repo, ['ls-files', '--others', '--exclude-standard'], { ...options, cwd: repoRoot }),
102
+ runGit(repo, ['diff', '--no-ext-diff', '--name-status'], { ...effectiveOptions, cwd: repoRoot }),
103
+ runGit(repo, ['diff', '--no-ext-diff', '--numstat'], { ...effectiveOptions, cwd: repoRoot }),
104
+ runGit(repo, ['diff', '--cached', '--no-ext-diff', '--name-status'], { ...effectiveOptions, cwd: repoRoot }),
105
+ runGit(repo, ['diff', '--cached', '--no-ext-diff', '--numstat'], { ...effectiveOptions, cwd: repoRoot }),
106
+ runGit(repo, ['ls-files', '--others', '--exclude-standard'], { ...effectiveOptions, cwd: repoRoot }),
91
107
  ]);
92
108
 
93
109
  const outputBytes = byteLength(
@@ -139,14 +155,15 @@ export async function getGitFileDiff(
139
155
  options: GitDiffOptions = {},
140
156
  ): Promise<GitFileDiffResult> {
141
157
  const lastCheckedAt = Date.now();
142
- const repo = await resolveGitRepository(workspace, options);
158
+ const effectiveOptions = withCollectionTimeout(options);
159
+ const repo = await resolveGitRepository(workspace, effectiveOptions);
143
160
  const repoRoot = repo.repoRoot!;
144
161
  const selected = await resolveRepoFilePath(repoRoot, filePath);
145
162
  const maxBytes = normalizePositiveInteger(options.maxBytes, DEFAULT_MAX_BYTES);
146
163
 
147
164
  if (options.baseRef) {
148
165
  const range = `${validateBaseRef(options.baseRef)}...HEAD`;
149
- const result = await runGit(repo, ['diff', '--no-ext-diff', range, '--', selected.relativePath], { ...options, cwd: repoRoot });
166
+ const result = await runGit(repo, ['diff', '--no-ext-diff', range, '--', selected.relativePath], { ...effectiveOptions, cwd: repoRoot });
150
167
  const bounded = truncateText(result.stdout, maxBytes);
151
168
  return {
152
169
  workspace: repo.workspace,
@@ -160,15 +177,15 @@ export async function getGitFileDiff(
160
177
  }
161
178
 
162
179
  const [unstaged, staged] = await Promise.all([
163
- runGit(repo, ['diff', '--no-ext-diff', '--', selected.relativePath], { ...options, cwd: repoRoot }),
164
- runGit(repo, ['diff', '--cached', '--no-ext-diff', '--', selected.relativePath], { ...options, cwd: repoRoot }),
180
+ runGit(repo, ['diff', '--no-ext-diff', '--', selected.relativePath], { ...effectiveOptions, cwd: repoRoot }),
181
+ runGit(repo, ['diff', '--cached', '--no-ext-diff', '--', selected.relativePath], { ...effectiveOptions, cwd: repoRoot }),
165
182
  ]);
166
183
 
167
184
  let diff = [unstaged.stdout, staged.stdout].filter((part) => part.length > 0).join('\n');
168
185
 
169
186
  if (!diff) {
170
187
  const untracked = await runGit(repo, ['ls-files', '--others', '--exclude-standard', '--', selected.relativePath], {
171
- ...options,
188
+ ...effectiveOptions,
172
189
  cwd: repoRoot,
173
190
  });
174
191
  const untrackedFiles = untracked.stdout.split('\n').filter(Boolean);
@@ -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
  }