@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.
- package/dist/commands/handler.d.ts +15 -0
- package/dist/index.js +136 -22
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +136 -22
- package/dist/index.mjs.map +1 -1
- package/dist/providers/acp-provider-instance.d.ts +3 -0
- package/dist/providers/cli-provider-instance.d.ts +12 -0
- package/dist/providers/manual-attendance.d.ts +63 -0
- package/dist/providers/provider-instance.d.ts +8 -0
- package/package.json +2 -2
- package/src/commands/cli-manager.ts +20 -2
- package/src/commands/handler.ts +32 -0
- package/src/git/git-diff.ts +31 -14
- package/src/providers/acp-provider-instance.ts +18 -1
- package/src/providers/cli-provider-instance.ts +54 -3
- package/src/providers/manual-attendance.ts +85 -0
- package/src/providers/provider-instance.ts +9 -0
|
@@ -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.
|
|
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.
|
|
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,
|
package/src/commands/handler.ts
CHANGED
|
@@ -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)) {
|
package/src/git/git-diff.ts
CHANGED
|
@@ -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,
|
|
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, '--'], { ...
|
|
66
|
-
runGit(repo, ['diff', '--no-ext-diff', '--numstat', range, '--'], { ...
|
|
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'], { ...
|
|
87
|
-
runGit(repo, ['diff', '--no-ext-diff', '--numstat'], { ...
|
|
88
|
-
runGit(repo, ['diff', '--cached', '--no-ext-diff', '--name-status'], { ...
|
|
89
|
-
runGit(repo, ['diff', '--cached', '--no-ext-diff', '--numstat'], { ...
|
|
90
|
-
runGit(repo, ['ls-files', '--others', '--exclude-standard'], { ...
|
|
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
|
|
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], { ...
|
|
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], { ...
|
|
164
|
-
runGit(repo, ['diff', '--cached', '--no-ext-diff', '--', selected.relativePath], { ...
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|