@adhdev/daemon-core 0.9.82-rc.455 → 0.9.82-rc.456
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/index.js +85 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +85 -9
- package/dist/index.mjs.map +1 -1
- package/dist/providers/approval-utils.d.ts +25 -0
- package/dist/providers/cli-provider-instance.d.ts +26 -1
- package/dist/providers/manual-attendance.d.ts +16 -0
- package/dist/providers/provider-instance.d.ts +8 -1
- package/package.json +3 -3
- package/src/commands/handler.ts +6 -3
- package/src/providers/approval-utils.ts +42 -0
- package/src/providers/cli-provider-instance.ts +87 -6
- package/src/providers/manual-attendance.ts +20 -0
- package/src/providers/provider-instance.ts +6 -1
- package/src/providers/sdk/v1/builders/cli/parse-approval.ts +13 -2
|
@@ -6,6 +6,31 @@ import type { ProviderModule } from './contracts.js';
|
|
|
6
6
|
* decline, which distinguishes it from a generic numbered menu or prose list.
|
|
7
7
|
*/
|
|
8
8
|
export declare function hasNegativeApprovalOption(buttons: string[] | null | undefined): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* True when a button reliably identifies a tool-CONSENT modal on its own — a
|
|
11
|
+
* scoped permission-grant affirmative such as:
|
|
12
|
+
* - "Yes, allow all edits in tmp/ during this session"
|
|
13
|
+
* - "Yes, and don't ask again for example.com"
|
|
14
|
+
* - "Yes, allow reading from etc/ from this project"
|
|
15
|
+
* - "Always allow"
|
|
16
|
+
*
|
|
17
|
+
* These options only ever appear in a genuine approval/permission prompt; a
|
|
18
|
+
* /model or /mode picker ("1. Default 2. Opus 3. Sonnet") never offers a
|
|
19
|
+
* "grant this scope" choice. They therefore serve as a SECOND reliable
|
|
20
|
+
* structural anchor alongside {@link hasNegativeApprovalOption}.
|
|
21
|
+
*
|
|
22
|
+
* Why this exists (tall-diff fallback, #137): when a Write/Edit diff is tall,
|
|
23
|
+
* the trailing decline option ("3. No") can scroll off the bottom of the
|
|
24
|
+
* captured PTY frame, leaving only "1. Yes" + "2. Yes, allow … this session".
|
|
25
|
+
* hasNegativeApprovalOption then reads false and the auto-approve gate bails —
|
|
26
|
+
* a delegated worker sits forever on a modal it could safely have approved. The
|
|
27
|
+
* grant-scope affirmative lets the gate recognize the consent modal WITHOUT
|
|
28
|
+
* seeing the off-frame decline. The gate still selects the plain "Yes"
|
|
29
|
+
* (allow-once) via pickApprovalButton, never the broader grant, and the settle
|
|
30
|
+
* gate still requires a stable modal — so a half-rendered frame never fires.
|
|
31
|
+
* Kept deliberately narrow so no picker/confirm modal can trip it.
|
|
32
|
+
*/
|
|
33
|
+
export declare function hasReliableApprovalAffirmative(buttons: string[] | null | undefined): boolean;
|
|
9
34
|
export declare function getApprovalPositiveHints(provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): string[];
|
|
10
35
|
export declare function pickApprovalButton(buttons: string[] | null | undefined, provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): {
|
|
11
36
|
index: number;
|
|
@@ -115,6 +115,7 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
115
115
|
private autoApproveSettleTimer;
|
|
116
116
|
private autoApproveInactiveSince;
|
|
117
117
|
private autoApproveMaskSince;
|
|
118
|
+
private stalledApprovalNudgeEpisode;
|
|
118
119
|
private readonly manualAttendance;
|
|
119
120
|
private controlValues;
|
|
120
121
|
private summaryMetadata;
|
|
@@ -355,7 +356,9 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
355
356
|
get cliName(): string;
|
|
356
357
|
private shouldAutoApprove;
|
|
357
358
|
/** @see ProviderInstance.noteManualInteraction */
|
|
358
|
-
noteManualInteraction(now?: number
|
|
359
|
+
noteManualInteraction(now?: number, opts?: {
|
|
360
|
+
passive?: boolean;
|
|
361
|
+
}): void;
|
|
359
362
|
/**
|
|
360
363
|
* Whether auto-approve should be treated as active *right now* for display
|
|
361
364
|
* and firing decisions: the configured intent AND the user is not currently
|
|
@@ -366,6 +369,28 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
366
369
|
*/
|
|
367
370
|
private autoApproveEffectivelyActive;
|
|
368
371
|
private autoApproveMaskStalled;
|
|
372
|
+
/**
|
|
373
|
+
* NOTIF-APPROVAL-MASKED (Q1b): surface a delegated worker's STALLED auto-approve modal
|
|
374
|
+
* to the mesh COORDINATOR, decoupled from the dashboard visible-status mask.
|
|
375
|
+
*
|
|
376
|
+
* When auto-approve is configured but the episode never settles (modal parse miss / the
|
|
377
|
+
* settle gate never satisfied), getState()/detectStatusTransition() fold the raw
|
|
378
|
+
* `waiting_approval` into `generating` to suppress dashboard flicker — so
|
|
379
|
+
* detectStatusTransition()'s `waiting_approval` arm never runs and NO agent:waiting_approval
|
|
380
|
+
* event is emitted. The coordinator's real-time approval-nudge delivery then has no input and
|
|
381
|
+
* the worker's stuck modal is never surfaced (the live ~25s stall). The dashboard mask is
|
|
382
|
+
* intentional and stays; this emits the coordinator nudge exactly ONCE, gated on the SAME
|
|
383
|
+
* raw-waiting_approval + mask-stalled signal resolveModalParkStatus() distinguishes, the
|
|
384
|
+
* instant the mask-stall threshold trips (the same moment getState un-folds the mask).
|
|
385
|
+
*
|
|
386
|
+
* Only delegated worker sessions qualify: a foreground session has no coordinator to notify,
|
|
387
|
+
* and its own dashboard mask already reveals the modal on stall. A normally-resolving
|
|
388
|
+
* auto-approve never reaches AUTO_APPROVE_MASK_STALL_MS, so it emits nothing here; and if a
|
|
389
|
+
* masked approval clears just as this fires, rc.455's isApprovalNudgeResolved stale-drop
|
|
390
|
+
* discards the nudge coordinator-side without noise. Dedup is per-episode (keyed on the
|
|
391
|
+
* mask-clock value) so a modal that flaps between parsed/unparsed states is announced once.
|
|
392
|
+
*/
|
|
393
|
+
private maybeEmitStalledApprovalNudge;
|
|
369
394
|
private recordAutoApproval;
|
|
370
395
|
recordApprovalSelection(buttonText: string): void;
|
|
371
396
|
private formatMarkerTimestamp;
|
|
@@ -61,3 +61,19 @@ export declare class ManualAttendanceTracker {
|
|
|
61
61
|
* pure read commands (read_chat / list_chats — passive polling, not driving).
|
|
62
62
|
*/
|
|
63
63
|
export declare const MANUAL_ATTENDANCE_COMMANDS: ReadonlySet<string>;
|
|
64
|
+
/**
|
|
65
|
+
* The subset of {@link MANUAL_ATTENDANCE_COMMANDS} that are PASSIVE view-only
|
|
66
|
+
* actions — foregrounding a session's tab / opening its panel. They convey "I am
|
|
67
|
+
* looking at this session", not "I am driving it", and carry no user input.
|
|
68
|
+
*
|
|
69
|
+
* For a foreground (base-node) session these still attend: a user who
|
|
70
|
+
* foregrounds their own session should get the quiet window so an incoming
|
|
71
|
+
* approval stays visible for them to act on. But for a DELEGATED worker session
|
|
72
|
+
* a passive peek must NOT attend — a coordinator merely opening a worker's panel
|
|
73
|
+
* to watch progress would otherwise suppress that worker's delegated
|
|
74
|
+
* auto-approve for the whole window (secondary cause, #137). The per-instance
|
|
75
|
+
* hook decides: it drops a passive stamp only when the session is a delegated
|
|
76
|
+
* worker, so explicit input (controlbar / resolve_action / pty_input) still
|
|
77
|
+
* attends a worker and a foreground session is unaffected.
|
|
78
|
+
*/
|
|
79
|
+
export declare const MANUAL_ATTENDANCE_PASSIVE_VIEW_COMMANDS: ReadonlySet<string>;
|
|
@@ -200,8 +200,15 @@ export interface ProviderInstance {
|
|
|
200
200
|
* input). Provider-common signal that suppresses auto-approve for a short
|
|
201
201
|
* window so the user can drive the session manually; background mesh worker
|
|
202
202
|
* sessions never receive it, so their delegated auto-approve is unaffected.
|
|
203
|
+
*
|
|
204
|
+
* `opts.passive` marks a view-only action (select_session / open_panel). A
|
|
205
|
+
* delegated worker session ignores passive stamps so a coordinator merely
|
|
206
|
+
* watching its panel does not suppress its delegated auto-approve; explicit
|
|
207
|
+
* input still attends. Foreground sessions attend on passive views too.
|
|
203
208
|
*/
|
|
204
|
-
noteManualInteraction?(now?: number
|
|
209
|
+
noteManualInteraction?(now?: number, opts?: {
|
|
210
|
+
passive?: boolean;
|
|
211
|
+
}): void;
|
|
205
212
|
/** cleanup */
|
|
206
213
|
dispose(): void;
|
|
207
214
|
}
|
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.456",
|
|
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,8 +46,8 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
50
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.456",
|
|
50
|
+
"@adhdev/session-host-core": "0.9.82-rc.456",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
53
53
|
"ajv-formats": "^3.0.1",
|
package/src/commands/handler.ts
CHANGED
|
@@ -26,7 +26,7 @@ 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
28
|
import { sha256Hex } from '../system/hash.js';
|
|
29
|
-
import { MANUAL_ATTENDANCE_COMMANDS } from '../providers/manual-attendance.js';
|
|
29
|
+
import { MANUAL_ATTENDANCE_COMMANDS, MANUAL_ATTENDANCE_PASSIVE_VIEW_COMMANDS } from '../providers/manual-attendance.js';
|
|
30
30
|
|
|
31
31
|
// Sub-module imports
|
|
32
32
|
import * as Chat from './chat-commands.js';
|
|
@@ -393,15 +393,18 @@ export class DaemonCommandHandler implements CommandHelpers {
|
|
|
393
393
|
*/
|
|
394
394
|
private noteManualAttendanceIfApplicable(cmd: string, args: any): void {
|
|
395
395
|
if (!MANUAL_ATTENDANCE_COMMANDS.has(cmd)) return;
|
|
396
|
+
// Passive view-only actions (select_session / open_panel) attend a
|
|
397
|
+
// foreground session but NOT a delegated worker — the instance decides.
|
|
398
|
+
const passive = MANUAL_ATTENDANCE_PASSIVE_VIEW_COMMANDS.has(cmd);
|
|
396
399
|
const sessionId = this._currentRoute.session?.sessionId
|
|
397
400
|
|| (typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '');
|
|
398
401
|
if (!sessionId) return;
|
|
399
402
|
const session = this._ctx.sessionRegistry?.get(sessionId);
|
|
400
403
|
const instanceKey = session?.adapterKey || session?.instanceKey || sessionId;
|
|
401
404
|
const instance = this._ctx.instanceManager?.getInstance(instanceKey) as
|
|
402
|
-
{ noteManualInteraction?: (now?: number) => void } | undefined;
|
|
405
|
+
{ noteManualInteraction?: (now?: number, opts?: { passive?: boolean }) => void } | undefined;
|
|
403
406
|
try {
|
|
404
|
-
instance?.noteManualInteraction?.();
|
|
407
|
+
instance?.noteManualInteraction?.(undefined, { passive });
|
|
405
408
|
} catch {
|
|
406
409
|
// attendance is best-effort — never block command dispatch
|
|
407
410
|
}
|
|
@@ -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
|
|
@@ -25,7 +25,7 @@ import { LOG } from '../logging/logger.js';
|
|
|
25
25
|
import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
|
|
26
26
|
import type { ChatMessage } from '../types.js';
|
|
27
27
|
import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
|
|
28
|
-
import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, looksLikeActiveApprovalPromptText } from './approval-utils.js';
|
|
28
|
+
import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, hasReliableApprovalAffirmative, looksLikeActiveApprovalPromptText } from './approval-utils.js';
|
|
29
29
|
import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
|
|
30
30
|
import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
|
|
31
31
|
import { normalizeProviderSessionId } from './provider-session-id.js';
|
|
@@ -572,6 +572,13 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
572
572
|
// mask is dropped so the real waiting_approval surfaces. Cleared when the episode ends
|
|
573
573
|
// (modal genuinely gone, manual attendance takes over, or auto-approve fires).
|
|
574
574
|
private autoApproveMaskSince = 0;
|
|
575
|
+
// NOTIF-APPROVAL-MASKED (Q1b): the autoApproveMaskSince episode value for which a
|
|
576
|
+
// stalled-approval coordinator nudge has already been emitted, so the nudge fires
|
|
577
|
+
// exactly once per stalled auto-approve episode (0 = none emitted). Reusing the
|
|
578
|
+
// per-episode mask-clock value as the key makes it provider-agnostic (no reliance on
|
|
579
|
+
// approvalEntrySeq) and self-resetting: each new episode gets a fresh
|
|
580
|
+
// autoApproveMaskSince timestamp, and the episode-end reset zeroes it.
|
|
581
|
+
private stalledApprovalNudgeEpisode = 0;
|
|
575
582
|
// Provider-common manual-attendance signal: while a human is actively driving
|
|
576
583
|
// this session from the dashboard, auto-approve holds so they can take manual
|
|
577
584
|
// control. Background mesh workers are never attended → delegated auto-approve
|
|
@@ -2056,6 +2063,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2056
2063
|
// Manual attendance takes over — the modal stays surfaced (maybeAutoApproveStatus
|
|
2057
2064
|
// returns false), so end the mask episode.
|
|
2058
2065
|
this.autoApproveMaskSince = 0;
|
|
2066
|
+
this.stalledApprovalNudgeEpisode = 0;
|
|
2059
2067
|
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
2060
2068
|
this.autoApproveSettleTimer = setTimeout(() => {
|
|
2061
2069
|
this.autoApproveSettleTimer = null;
|
|
@@ -2100,6 +2108,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2100
2108
|
// Modal has genuinely been gone past the hysteresis window → the episode ended;
|
|
2101
2109
|
// end the mask episode too (a later approval starts a fresh stall clock).
|
|
2102
2110
|
this.autoApproveMaskSince = 0;
|
|
2111
|
+
this.stalledApprovalNudgeEpisode = 0;
|
|
2103
2112
|
if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
|
|
2104
2113
|
return autoApproveActive;
|
|
2105
2114
|
}
|
|
@@ -2110,6 +2119,12 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2110
2119
|
// when zero so it survives modal-signature changes and hysteresis blips — it measures
|
|
2111
2120
|
// the true age of the unresolved auto-approve, not the per-signature settle window.
|
|
2112
2121
|
if (!this.autoApproveMaskSince) this.autoApproveMaskSince = now;
|
|
2122
|
+
// NOTIF-APPROVAL-MASKED (Q1b): once this episode has stalled past the mask threshold,
|
|
2123
|
+
// surface the raw waiting_approval to the mesh coordinator (decoupled from the dashboard
|
|
2124
|
+
// mask). Placed on the active-approval path here — the single choke point that owns the
|
|
2125
|
+
// mask-stall clock and is re-driven throughout a silent stall (getState heartbeat,
|
|
2126
|
+
// detectStatusTransition, recheckAutoApproveSettled). No-op until the stall threshold trips.
|
|
2127
|
+
this.maybeEmitStalledApprovalNudge(adapterStatus, now);
|
|
2113
2128
|
const modal = adapterStatus.activeModal;
|
|
2114
2129
|
// (fix) Do not auto-approve when no concrete modal/buttons are present.
|
|
2115
2130
|
// Claude TUI flaps between paints; without this guard adapterStatus
|
|
@@ -2147,10 +2162,22 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2147
2162
|
return autoApproveActive;
|
|
2148
2163
|
}
|
|
2149
2164
|
const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(buttons, this.provider);
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2165
|
+
// Structural decline anchor. A real approval offers BOTH an affirmative
|
|
2166
|
+
// and a decline — but on a TALL Write/Edit diff the trailing "3. No"
|
|
2167
|
+
// scrolls off the captured frame, leaving only "1. Yes" + "2. Yes, allow
|
|
2168
|
+
// … this session" so hasNegativeApprovalOption reads false (#137). A
|
|
2169
|
+
// scoped grant-affirmative ("Yes, allow … during this session" / "…don't
|
|
2170
|
+
// ask again") ONLY appears in a genuine consent modal (never a picker),
|
|
2171
|
+
// so it stands in for the off-frame decline as a reliable second anchor.
|
|
2172
|
+
// Conservative by construction: a picker without a grant-scope option
|
|
2173
|
+
// still bails here, and the fire below still picks the plain allow-once
|
|
2174
|
+
// "Yes" via pickApprovalButton, not the broader grant.
|
|
2175
|
+
const hasReliableConsentAnchor = hasNegativeApprovalOption(buttons)
|
|
2176
|
+
|| hasReliableApprovalAffirmative(buttons);
|
|
2177
|
+
if (buttonIndex < 0 || !hasReliableConsentAnchor) {
|
|
2178
|
+
// No affirmative matched, or no decline / reliable grant option present
|
|
2179
|
+
// (→ not a real consent prompt, e.g. a picker that slipped past the
|
|
2180
|
+
// kind gate). Surface the modal so the user decides; never pick blindly.
|
|
2154
2181
|
return autoApproveActive;
|
|
2155
2182
|
}
|
|
2156
2183
|
// Modal *identity* signature — the question/button set only, NO volatile
|
|
@@ -2215,6 +2242,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2215
2242
|
this.autoApproveInactiveSince = 0;
|
|
2216
2243
|
// Fired (resolveModal in flight) — the episode resolved; end the mask-stall clock.
|
|
2217
2244
|
this.autoApproveMaskSince = 0;
|
|
2245
|
+
this.stalledApprovalNudgeEpisode = 0;
|
|
2218
2246
|
if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
|
|
2219
2247
|
this.autoApproveBusyTimer = setTimeout(() => {
|
|
2220
2248
|
this.autoApproveBusy = false;
|
|
@@ -3124,7 +3152,16 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
3124
3152
|
}
|
|
3125
3153
|
|
|
3126
3154
|
/** @see ProviderInstance.noteManualInteraction */
|
|
3127
|
-
noteManualInteraction(now = Date.now()): void {
|
|
3155
|
+
noteManualInteraction(now = Date.now(), opts?: { passive?: boolean }): void {
|
|
3156
|
+
// P1b (#137 secondary): a DELEGATED worker session must not treat a
|
|
3157
|
+
// passive dashboard view (foreground tab selection / panel open) as
|
|
3158
|
+
// manual attendance. A coordinator merely peeking at a worker's panel
|
|
3159
|
+
// would otherwise suppress that worker's delegated auto-approve for the
|
|
3160
|
+
// whole 60s window. Only explicit input/intervention (controlbar,
|
|
3161
|
+
// resolve_action, pty_input) attends a worker. Non-worker (foreground)
|
|
3162
|
+
// sessions keep noting on passive views so a user foregrounding their own
|
|
3163
|
+
// session still holds auto-approve to act on the modal themselves.
|
|
3164
|
+
if (opts?.passive && this.isMeshWorkerSession()) return;
|
|
3128
3165
|
this.manualAttendance.note(now);
|
|
3129
3166
|
}
|
|
3130
3167
|
|
|
@@ -3154,6 +3191,50 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
3154
3191
|
&& now - this.autoApproveMaskSince > CliProviderInstance.AUTO_APPROVE_MASK_STALL_MS;
|
|
3155
3192
|
}
|
|
3156
3193
|
|
|
3194
|
+
/**
|
|
3195
|
+
* NOTIF-APPROVAL-MASKED (Q1b): surface a delegated worker's STALLED auto-approve modal
|
|
3196
|
+
* to the mesh COORDINATOR, decoupled from the dashboard visible-status mask.
|
|
3197
|
+
*
|
|
3198
|
+
* When auto-approve is configured but the episode never settles (modal parse miss / the
|
|
3199
|
+
* settle gate never satisfied), getState()/detectStatusTransition() fold the raw
|
|
3200
|
+
* `waiting_approval` into `generating` to suppress dashboard flicker — so
|
|
3201
|
+
* detectStatusTransition()'s `waiting_approval` arm never runs and NO agent:waiting_approval
|
|
3202
|
+
* event is emitted. The coordinator's real-time approval-nudge delivery then has no input and
|
|
3203
|
+
* the worker's stuck modal is never surfaced (the live ~25s stall). The dashboard mask is
|
|
3204
|
+
* intentional and stays; this emits the coordinator nudge exactly ONCE, gated on the SAME
|
|
3205
|
+
* raw-waiting_approval + mask-stalled signal resolveModalParkStatus() distinguishes, the
|
|
3206
|
+
* instant the mask-stall threshold trips (the same moment getState un-folds the mask).
|
|
3207
|
+
*
|
|
3208
|
+
* Only delegated worker sessions qualify: a foreground session has no coordinator to notify,
|
|
3209
|
+
* and its own dashboard mask already reveals the modal on stall. A normally-resolving
|
|
3210
|
+
* auto-approve never reaches AUTO_APPROVE_MASK_STALL_MS, so it emits nothing here; and if a
|
|
3211
|
+
* masked approval clears just as this fires, rc.455's isApprovalNudgeResolved stale-drop
|
|
3212
|
+
* discards the nudge coordinator-side without noise. Dedup is per-episode (keyed on the
|
|
3213
|
+
* mask-clock value) so a modal that flaps between parsed/unparsed states is announced once.
|
|
3214
|
+
*/
|
|
3215
|
+
private maybeEmitStalledApprovalNudge(adapterStatus: any, now: number): void {
|
|
3216
|
+
if (!this.isMeshWorkerSession()) return;
|
|
3217
|
+
if (adapterStatus?.status !== 'waiting_approval') return;
|
|
3218
|
+
if (!this.autoApproveMaskStalled(now)) return;
|
|
3219
|
+
// Exactly once per stalled episode (autoApproveMaskSince uniquely identifies it).
|
|
3220
|
+
if (this.stalledApprovalNudgeEpisode === this.autoApproveMaskSince) return;
|
|
3221
|
+
this.stalledApprovalNudgeEpisode = this.autoApproveMaskSince;
|
|
3222
|
+
const modal = adapterStatus.activeModal;
|
|
3223
|
+
const dirName = workingDirBasename(this.workingDir);
|
|
3224
|
+
const chatTitle = `${this.provider.name} · ${dirName}`;
|
|
3225
|
+
this.appendRuntimeSystemMessage(
|
|
3226
|
+
this.formatApprovalRequestMessage(modal?.message, modal?.buttons),
|
|
3227
|
+
`approval_request:${now}`,
|
|
3228
|
+
now,
|
|
3229
|
+
);
|
|
3230
|
+
this.pushEvent({
|
|
3231
|
+
event: 'agent:waiting_approval', chatTitle, timestamp: now,
|
|
3232
|
+
modalMessage: modal?.message,
|
|
3233
|
+
modalButtons: modal?.buttons,
|
|
3234
|
+
});
|
|
3235
|
+
LOG.info('CLI', `[${this.type}] stalled auto-approve nudge → coordinator (masked ${Math.round((now - this.autoApproveMaskSince) / 1000)}s)`);
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3157
3238
|
private recordAutoApproval(modalMessage?: string, buttonLabel?: string, now = Date.now()): void {
|
|
3158
3239
|
this.appendRuntimeSystemMessage(
|
|
3159
3240
|
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;
|
|
@@ -63,7 +63,12 @@ export interface ModalTuiSpec {
|
|
|
63
63
|
|
|
64
64
|
// ─── Helpers ───────────────────────────────────────────────────────────
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
// A horizontal rule line. Covers solid box-drawing rules (─ ━ ═) AND the dashed
|
|
67
|
+
// variants (╌ ╍ ┄ ┅ ┈ ┉) that Claude Code draws as the INNER separators around a
|
|
68
|
+
// Write/Edit diff body. This matches the coverage of the claude-cli v4 FSM spec
|
|
69
|
+
// anchor `^[─╌]+$` (issue #137) so the SDK-v1 parser recognizes the same modal
|
|
70
|
+
// frames the FSM does — a dashed rule is a separator, not modal content.
|
|
71
|
+
const SEPARATOR_RE = /^[─━═╌╍┄┅┈┉]{10,}\s*$/;
|
|
67
72
|
|
|
68
73
|
function compile(re: string, flags?: string): RegExp {
|
|
69
74
|
try {
|
|
@@ -119,7 +124,13 @@ function scopeLines(
|
|
|
119
124
|
}
|
|
120
125
|
}
|
|
121
126
|
}
|
|
122
|
-
|
|
127
|
+
// Only scope to the separator frame when it actually BRACKETS the question
|
|
128
|
+
// line. Claude Write/Edit modals draw dashed (╌) inner rules around the diff
|
|
129
|
+
// body, so the last two separators can enclose the file diff while the
|
|
130
|
+
// question + button block sit BELOW the lower dashed rule. Scoping to that
|
|
131
|
+
// inner frame would drop every button (→ null → missed auto-approve, #137).
|
|
132
|
+
// When the question is outside the frame, fall through to a window around it.
|
|
133
|
+
if (lastSep >= 0 && prevSep >= 0 && questionIndex >= prevSep && questionIndex < lastSep + 1) {
|
|
123
134
|
return { start: prevSep, end: lastSep + 1 };
|
|
124
135
|
}
|
|
125
136
|
// Fallback: window around question line.
|