@adhdev/daemon-core 1.0.18-rc.15 → 1.0.18-rc.17

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.
@@ -0,0 +1,97 @@
1
+ import type {
2
+ AutoApproveMode,
3
+ AutoApproveModeRisk,
4
+ AutoApproveModeStrategy,
5
+ ProviderModule,
6
+ } from './contracts.js';
7
+
8
+ const KNOWN_DANGEROUS_LAUNCH_ARGS = new Set([
9
+ '--dangerously-skip-permissions',
10
+ '--dangerously-bypass-approvals-and-sandbox',
11
+ 'bypassPermissions',
12
+ 'sandbox_mode=danger-full-access',
13
+ 'approval_policy=never',
14
+ ]);
15
+
16
+ export interface ResolvedAutoApproveMode {
17
+ active: boolean;
18
+ strategy: AutoApproveModeStrategy;
19
+ modeId: string;
20
+ }
21
+
22
+ function isKnownDangerousLaunchArg(arg: string): boolean {
23
+ const normalized = arg.trim();
24
+ if (KNOWN_DANGEROUS_LAUNCH_ARGS.has(normalized)) return true;
25
+ return normalized.startsWith('--dangerously-skip-permissions=')
26
+ || normalized.startsWith('--dangerously-bypass-approvals-and-sandbox=');
27
+ }
28
+
29
+ /** Runtime defense-in-depth for provider definitions that bypass schema validation. */
30
+ export function deriveAutoApproveModeRisk(mode: Pick<AutoApproveMode, 'risk' | 'launchArgs'>): AutoApproveModeRisk {
31
+ return Array.isArray(mode.launchArgs) && mode.launchArgs.some(isKnownDangerousLaunchArg)
32
+ ? 'dangerous'
33
+ : mode.risk;
34
+ }
35
+
36
+ function inactiveMode(modeId = ''): ResolvedAutoApproveMode {
37
+ return { active: false, strategy: 'pty-parse-default', modeId };
38
+ }
39
+
40
+ function resolveConfiguredMode(
41
+ provider: ProviderModule,
42
+ mode: AutoApproveMode,
43
+ settings: Record<string, unknown> | undefined,
44
+ ): ResolvedAutoApproveMode {
45
+ if (mode.strategy === 'post-boot-command') return inactiveMode(mode.id);
46
+ if (settings?.launchedByCoordinator === true
47
+ && settings.delegatedWorkerDangerousModeAllow !== true
48
+ && deriveAutoApproveModeRisk(mode) === 'dangerous') {
49
+ const fallback = provider.autoApproveModes?.modes.find((candidate) =>
50
+ candidate.strategy === 'pty-parse-default'
51
+ && deriveAutoApproveModeRisk(candidate) !== 'dangerous');
52
+ return fallback
53
+ ? { active: true, strategy: fallback.strategy, modeId: fallback.id }
54
+ : inactiveMode(mode.id);
55
+ }
56
+ return { active: true, strategy: mode.strategy, modeId: mode.id };
57
+ }
58
+
59
+ /**
60
+ * Resolve new mode settings before the legacy boolean. A stale/unknown explicit
61
+ * mode id fails closed instead of falling through to an enabled legacy setting.
62
+ */
63
+ export function resolveProviderAutoApproveMode(
64
+ provider: ProviderModule,
65
+ settings: Record<string, unknown> | undefined,
66
+ ): ResolvedAutoApproveMode {
67
+ const config = provider.autoApproveModes;
68
+ const explicitModeId = settings?.autoApproveMode;
69
+ if (typeof explicitModeId === 'string') {
70
+ const mode = config?.modes.find((candidate) => candidate.id === explicitModeId);
71
+ if (!mode) return inactiveMode(explicitModeId);
72
+ return resolveConfiguredMode(provider, mode, settings);
73
+ }
74
+
75
+ const explicitLegacy = settings?.autoApprove;
76
+ const providerLegacyDefault = provider.settings?.autoApprove?.default;
77
+ const legacyActive = typeof explicitLegacy === 'boolean'
78
+ ? explicitLegacy
79
+ : typeof providerLegacyDefault === 'boolean'
80
+ ? providerLegacyDefault
81
+ : false;
82
+ if (!legacyActive) return inactiveMode();
83
+
84
+ if (!config) {
85
+ return { active: true, strategy: 'pty-parse-default', modeId: 'legacy' };
86
+ }
87
+ const defaultMode = config.modes.find((mode) => mode.id === config.default);
88
+ if (!defaultMode) return inactiveMode(config.default);
89
+ return resolveConfiguredMode(provider, defaultMode, settings);
90
+ }
91
+
92
+ export function findProviderAutoApproveMode(
93
+ provider: ProviderModule | undefined,
94
+ modeId: string,
95
+ ): AutoApproveMode | undefined {
96
+ return provider?.autoApproveModes?.modes.find((mode) => mode.id === modeId);
97
+ }
@@ -156,6 +156,59 @@ export function selectFinalAssistantTurnEndMessage(
156
156
  return null;
157
157
  }
158
158
 
159
+ /**
160
+ * EARLY-IDLE-COMPLETION-FALSE-POSITIVE — trailing tool/terminal activity detector.
161
+ *
162
+ * True when the transcript's LAST non-empty user-facing assistant bubble is followed
163
+ * by one or more TOOL / TERMINAL activity bubbles (a tool_use/command the assistant
164
+ * fired AFTER its text) — i.e. the assistant emitted a preamble ("Let me explore…"),
165
+ * then started running Read/Grep, so the turn is still executing and its answer has
166
+ * not landed. Callers that would otherwise promote that preamble to a turn-end summary
167
+ * off a momentary (startup-grace / inter-tool) idle read use this as a veto.
168
+ *
169
+ * Deliberately NARROW so the pure-PTY completion rescue is preserved:
170
+ * - Only TOOL/TERMINAL activity trailing the assistant vetoes. A trailing THOUGHT or
171
+ * status bubble does NOT (a finished turn can end on an internal thought), matching
172
+ * selectFinalAssistantTurnEndMessage's skip set minus the "still-working" signals.
173
+ * - A genuinely FINISHED worker (final assistant last, no trailing tool activity —
174
+ * the kimi pure-PTY continuous-idle shape) returns false, so its early completion
175
+ * is untouched.
176
+ *
177
+ * Returns false when there is no final assistant bubble at all (the caller's
178
+ * selectFinalAssistantTurnEndMessage already handles that as "not a turn end").
179
+ */
180
+ export function hasTrailingToolActivityAfterFinalAssistant(
181
+ messages: ChatMessage[] | null | undefined,
182
+ ): boolean {
183
+ if (!Array.isArray(messages) || messages.length === 0) return false;
184
+ let sawTrailingToolActivity = false;
185
+ for (let i = messages.length - 1; i >= 0; i--) {
186
+ const msg = messages[i];
187
+ if (!msg) continue;
188
+ const classification = classifyChatMessageVisibility(msg);
189
+ // A trailing user-facing assistant/model bubble with real text ends the scan:
190
+ // whether we saw a tool AFTER it is the verdict.
191
+ if (classification.isUserFacing && (msg.role === 'assistant' || msg.role === 'model')) {
192
+ if (flattenContent(msg.content).trim()) return sawTrailingToolActivity;
193
+ // Empty streaming assistant bubble — keep scanning back past it, same as
194
+ // selectFinalAssistantTurnEndMessage (which returns null here); an empty tail
195
+ // isn't a turn end, so there is nothing to veto.
196
+ return false;
197
+ }
198
+ if (classification.isUserFacing) {
199
+ // A trailing user bubble (freshly dispatched task, no reply) → no assistant
200
+ // turn end below it to veto.
201
+ return false;
202
+ }
203
+ // Non-user-facing activity/internal bubble sitting AFTER the (not-yet-seen)
204
+ // assistant bubble. Only tool/terminal activity signals an in-flight turn.
205
+ if (classification.kind === 'tool' || classification.kind === 'terminal') {
206
+ sawTrailingToolActivity = true;
207
+ }
208
+ }
209
+ return false;
210
+ }
211
+
159
212
  export const BUILTIN_CHAT_MESSAGE_KINDS = ['standard', 'thought', 'tool', 'terminal', 'system'] as const;
160
213
 
161
214
  export type BuiltinChatMessageKind = typeof BUILTIN_CHAT_MESSAGE_KINDS[number];
@@ -75,6 +75,7 @@ import type {
75
75
  } from './cli-provider-instance-types.js';
76
76
  import { mergeConversationMessages, buildExternalTranscriptProbe } from './cli-provider-transcript-merge.js';
77
77
  import { getEffectDedupKey, formatApprovalRequestMessage, formatMarkerTimestamp } from './cli-provider-effect-format.js';
78
+ import { resolveProviderAutoApproveMode, type ResolvedAutoApproveMode } from './auto-approve-modes.js';
78
79
 
79
80
  // Re-export moved public symbols so existing importers (index.ts, tests) keep
80
81
  // their `./cli-provider-instance.js` path. Pure move — no behavior change.
@@ -2780,7 +2781,7 @@ export class CliProviderInstance implements ProviderInstance {
2780
2781
  * resolveModal, so a plain turn that never saw an approval always returns false.
2781
2782
  */
2782
2783
  private inApprovalResumeGrace(now = Date.now()): boolean {
2783
- if (!this.isAutonomousMeshSession() || !this.shouldAutoApprove()) return false;
2784
+ if (!this.isAutonomousMeshSession() || !this.shouldUsePtyAutoApprove()) return false;
2784
2785
  const resolvedAt = typeof (this.adapter as any)?.lastApprovalResolvedAt === 'number'
2785
2786
  ? (this.adapter as any).lastApprovalResolvedAt as number
2786
2787
  : 0;
@@ -2897,7 +2898,7 @@ export class CliProviderInstance implements ProviderInstance {
2897
2898
  }
2898
2899
 
2899
2900
  const latestStatus = this.adapter.getStatus({ allowParse: false });
2900
- const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
2901
+ const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldUsePtyAutoApprove();
2901
2902
  const latestVisibleStatus = latestAutoApproveActive || this.autoApproveBusy ? 'generating' : latestStatus.status;
2902
2903
  LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
2903
2904
  if (latestVisibleStatus !== 'idle') {
@@ -3300,7 +3301,7 @@ export class CliProviderInstance implements ProviderInstance {
3300
3301
  private stabilizeFlappingApprovalStatus(adapterStatus: any, now = Date.now()): any {
3301
3302
  // Only autonomous auto-approving mesh sessions are subject to the delegated flap;
3302
3303
  // never overlay for attended/foreground/non-mesh sessions.
3303
- if (!this.isAutonomousMeshSession() || !this.shouldAutoApprove()) return adapterStatus;
3304
+ if (!this.isAutonomousMeshSession() || !this.shouldUsePtyAutoApprove()) return adapterStatus;
3304
3305
 
3305
3306
  const rawStatus = adapterStatus?.status;
3306
3307
  const resolvedAt = typeof (this.adapter as any)?.lastApprovalResolvedAt === 'number'
@@ -3347,6 +3348,13 @@ export class CliProviderInstance implements ProviderInstance {
3347
3348
  }
3348
3349
 
3349
3350
  private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
3351
+ // launch-args modes grant approval in the CLI process itself and therefore
3352
+ // must never enter the PTY modal parser/fire/settle/mask/nudge subsystem.
3353
+ // post-boot-command is reserved and resolves inactive in v1.
3354
+ if (!this.shouldUsePtyAutoApprove()) {
3355
+ this.resetPtyAutoApproveState();
3356
+ return false;
3357
+ }
3350
3358
  // Manual-attendance suppression (provider-common): when a human is
3351
3359
  // Manual-attendance suppression (provider-common): when a human is
3352
3360
  // actively driving this session from the dashboard, hold auto-approve so
@@ -3358,7 +3366,7 @@ export class CliProviderInstance implements ProviderInstance {
3358
3366
  // would otherwise never re-drive this decision. Background mesh workers
3359
3367
  // are never attended, so their delegated auto-approve is untouched.
3360
3368
  if (adapterStatus?.status === 'waiting_approval'
3361
- && this.shouldAutoApprove()
3369
+ && this.shouldUsePtyAutoApprove()
3362
3370
  && this.manualAttendance.isAttended(now)) {
3363
3371
  this.lastAutoApprovalSignature = '';
3364
3372
  this.pendingAutoApprovalSignature = '';
@@ -3376,7 +3384,7 @@ export class CliProviderInstance implements ProviderInstance {
3376
3384
  }, this.manualAttendance.remainingMs(now) + 20);
3377
3385
  return false;
3378
3386
  }
3379
- const autoApproveActive = adapterStatus?.status === 'waiting_approval' && this.shouldAutoApprove();
3387
+ const autoApproveActive = adapterStatus?.status === 'waiting_approval' && this.shouldUsePtyAutoApprove();
3380
3388
  // Guard re-entry: onStatusChange/getState can observe the same modal multiple
3381
3389
  // times while the PTY absorbs the approval key. Without this flag, repeated
3382
3390
  // snapshots would write stray keys into the input once the modal dismisses.
@@ -4652,15 +4660,37 @@ export class CliProviderInstance implements ProviderInstance {
4652
4660
  get cliType(): string { return this.type; }
4653
4661
  get cliName(): string { return this.provider.name; }
4654
4662
 
4663
+ private resolveAutoApproveMode(): ResolvedAutoApproveMode {
4664
+ return resolveProviderAutoApproveMode(this.provider, this.settings);
4665
+ }
4666
+
4667
+ /** Legacy boolean view retained for internal/test compatibility. */
4655
4668
  private shouldAutoApprove(): boolean {
4656
- if (typeof this.settings.autoApprove === 'boolean') {
4657
- return this.settings.autoApprove;
4669
+ return this.resolveAutoApproveMode().active;
4670
+ }
4671
+
4672
+ private shouldUsePtyAutoApprove(): boolean {
4673
+ const resolved = this.resolveAutoApproveMode();
4674
+ return this.shouldAutoApprove() && resolved.strategy === 'pty-parse-default';
4675
+ }
4676
+
4677
+ private resetPtyAutoApproveState(): void {
4678
+ this.lastAutoApprovalSignature = '';
4679
+ this.pendingAutoApprovalSignature = '';
4680
+ this.pendingAutoApprovalSince = 0;
4681
+ this.autoApproveInactiveSince = 0;
4682
+ this.autoApproveMaskSince = 0;
4683
+ this.stalledApprovalNudgeEpisode = 0;
4684
+ this.autoApproveLastModalSeenAt = 0;
4685
+ this.autoApproveBusy = false;
4686
+ if (this.autoApproveSettleTimer) {
4687
+ clearTimeout(this.autoApproveSettleTimer);
4688
+ this.autoApproveSettleTimer = null;
4658
4689
  }
4659
- const providerDefault = this.provider.settings?.autoApprove?.default;
4660
- if (typeof providerDefault === 'boolean') {
4661
- return providerDefault;
4690
+ if (this.autoApproveBusyTimer) {
4691
+ clearTimeout(this.autoApproveBusyTimer);
4692
+ this.autoApproveBusyTimer = null;
4662
4693
  }
4663
- return false;
4664
4694
  }
4665
4695
 
4666
4696
  /** @see ProviderInstance.noteManualInteraction */
@@ -4687,7 +4717,7 @@ export class CliProviderInstance implements ProviderInstance {
4687
4717
  */
4688
4718
  private autoApproveEffectivelyActive(status: string | undefined, now = Date.now()): boolean {
4689
4719
  return status === 'waiting_approval'
4690
- && this.shouldAutoApprove()
4720
+ && this.shouldUsePtyAutoApprove()
4691
4721
  && !this.manualAttendance.isAttended(now);
4692
4722
  }
4693
4723
 
@@ -4699,7 +4729,8 @@ export class CliProviderInstance implements ProviderInstance {
4699
4729
  // maybeAutoApproveStatus (driven by getState + the recheck timer during a waiting episode);
4700
4730
  // this read is side-effect-free so getStatusMetadata can consult it too.
4701
4731
  private autoApproveMaskStalled(now = Date.now()): boolean {
4702
- return this.autoApproveMaskSince > 0
4732
+ return this.shouldUsePtyAutoApprove()
4733
+ && this.autoApproveMaskSince > 0
4703
4734
  && now - this.autoApproveMaskSince > CliProviderInstance.AUTO_APPROVE_MASK_STALL_MS;
4704
4735
  }
4705
4736
 
@@ -4725,6 +4756,7 @@ export class CliProviderInstance implements ProviderInstance {
4725
4756
  * mask-clock value) so a modal that flaps between parsed/unparsed states is announced once.
4726
4757
  */
4727
4758
  private maybeEmitStalledApprovalNudge(adapterStatus: any, now: number): void {
4759
+ if (!this.shouldUsePtyAutoApprove()) return;
4728
4760
  if (!this.isMeshWorkerSession()) return;
4729
4761
  if (adapterStatus?.status !== 'waiting_approval') return;
4730
4762
  if (!this.autoApproveMaskStalled(now)) return;
@@ -488,6 +488,28 @@ export interface ProviderCompatibilityEntry {
488
488
  scriptDir: string;
489
489
  }
490
490
 
491
+ export type AutoApproveModeStrategy =
492
+ | 'pty-parse-default'
493
+ | 'launch-args'
494
+ | 'post-boot-command';
495
+
496
+ export type AutoApproveModeRisk = 'safe' | 'caution' | 'dangerous';
497
+
498
+ export interface AutoApproveMode {
499
+ id: string;
500
+ label: string;
501
+ strategy: AutoApproveModeStrategy;
502
+ risk: AutoApproveModeRisk;
503
+ warning?: string;
504
+ launchArgs?: string[];
505
+ removeArgs?: string[];
506
+ }
507
+
508
+ export interface AutoApproveModesConfig {
509
+ default: string;
510
+ modes: AutoApproveMode[];
511
+ }
512
+
491
513
  export interface ProviderModule {
492
514
  /** Unique identifier (e.g. 'cline', 'cursor', 'gemini-cli') */
493
515
  type: string;
@@ -519,7 +541,9 @@ export interface ProviderModule {
519
541
  status?: string;
520
542
  /** Inventory/support detail string maintained in adhdev-providers */
521
543
  details?: string;
522
- /** Install instructions (shown when command is missing) */
544
+ /** Provider-specific auto-approve choices and their launch/runtime strategy. */
545
+ autoApproveModes?: AutoApproveModesConfig;
546
+ /** Install instructions (shown when command is missing) */
523
547
  install?: string;
524
548
  /** Custom version detection command (e.g. 'cursor --version', 'claude -v') */
525
549
  versionCommand?: ProviderVersionCommand;
@@ -1,4 +1,5 @@
1
1
  import type { ProviderControlDef, ProviderControlType, ProviderModule } from './contracts.js'
2
+ import { deriveAutoApproveModeRisk } from './auto-approve-modes.js'
2
3
  import { providerHasOpenPanelSupport } from './open-panel-support.js'
3
4
 
4
5
  const VALID_CAPABILITY_MEDIA_TYPES = new Set(['text', 'image', 'audio', 'video', 'resource'])
@@ -66,6 +67,7 @@ const KNOWN_PROVIDER_FIELDS = new Set<string>([
66
67
  'providerVersion',
67
68
  'status',
68
69
  'details',
70
+ 'autoApproveModes',
69
71
  'modelLaunchArgs',
70
72
  'modelOptions',
71
73
  'thinkingLaunchArgs',
@@ -144,6 +146,7 @@ export function validateProviderDefinition(raw: unknown): ProviderValidationResu
144
146
  validateCapabilities(provider as unknown as ProviderModule, controls, errors)
145
147
  validateNativeHistory(provider.nativeHistory, errors)
146
148
  validateMeshCoordinator(provider.meshCoordinator, errors)
149
+ validateAutoApproveModes(provider.autoApproveModes, errors)
147
150
 
148
151
  for (const control of controls) {
149
152
  validateControl(control as ProviderControlDef, errors)
@@ -160,6 +163,86 @@ export function validateProviderDefinition(raw: unknown): ProviderValidationResu
160
163
  return { errors, warnings }
161
164
  }
162
165
 
166
+ export function validateAutoApproveModes(raw: unknown, errors: string[]): void {
167
+ if (raw === undefined) return
168
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
169
+ errors.push('autoApproveModes must be an object')
170
+ return
171
+ }
172
+
173
+ const config = raw as Record<string, unknown>
174
+ const defaultModeId = typeof config.default === 'string' ? config.default.trim() : ''
175
+ if (!defaultModeId) {
176
+ errors.push('autoApproveModes.default must be a non-empty string')
177
+ } else {
178
+ config.default = defaultModeId
179
+ }
180
+ if (!Array.isArray(config.modes) || config.modes.length === 0) {
181
+ errors.push('autoApproveModes.modes must be a non-empty array')
182
+ return
183
+ }
184
+
185
+ const ids = new Set<string>()
186
+ for (const [index, rawMode] of config.modes.entries()) {
187
+ const prefix = `autoApproveModes.modes[${index}]`
188
+ if (!rawMode || typeof rawMode !== 'object' || Array.isArray(rawMode)) {
189
+ errors.push(`${prefix} must be an object`)
190
+ continue
191
+ }
192
+ const mode = rawMode as Record<string, unknown>
193
+ const id = typeof mode.id === 'string' ? mode.id.trim() : ''
194
+ if (!id) {
195
+ errors.push(`${prefix}.id must be a non-empty string`)
196
+ } else if (ids.has(id)) {
197
+ errors.push(`${prefix}.id must be unique (duplicate: ${id})`)
198
+ } else {
199
+ ids.add(id)
200
+ mode.id = id
201
+ }
202
+ if (typeof mode.label !== 'string' || !mode.label.trim()) {
203
+ errors.push(`${prefix}.label must be a non-empty string`)
204
+ }
205
+
206
+ const strategy = mode.strategy
207
+ if (!['pty-parse-default', 'launch-args', 'post-boot-command'].includes(String(strategy))) {
208
+ errors.push(`${prefix}.strategy must be one of: pty-parse-default, launch-args, post-boot-command`)
209
+ } else if (strategy === 'post-boot-command') {
210
+ errors.push(`${prefix}.strategy post-boot-command is reserved and unsupported in v1`)
211
+ }
212
+
213
+ const risk = mode.risk
214
+ if (!['safe', 'caution', 'dangerous'].includes(String(risk))) {
215
+ errors.push(`${prefix}.risk must be one of: safe, caution, dangerous`)
216
+ }
217
+
218
+ for (const field of ['launchArgs', 'removeArgs'] as const) {
219
+ const value = mode[field]
220
+ if (value !== undefined && (!Array.isArray(value) || value.some((arg) => typeof arg !== 'string' || !arg.trim()))) {
221
+ errors.push(`${prefix}.${field} must be an array of non-empty strings when provided`)
222
+ }
223
+ }
224
+ if (strategy === 'launch-args' && (!Array.isArray(mode.launchArgs) || mode.launchArgs.length === 0)) {
225
+ errors.push(`${prefix}.launchArgs must be a non-empty array for launch-args strategy`)
226
+ }
227
+
228
+ // Mutate the validated provider object so downstream runtime/UI consumers see
229
+ // the effective risk. The runtime resolver repeats this derivation as defense-in-depth.
230
+ if (['safe', 'caution', 'dangerous'].includes(String(risk))
231
+ && deriveAutoApproveModeRisk(mode as any) === 'dangerous') {
232
+ mode.risk = 'dangerous'
233
+ }
234
+ if (mode.risk === 'dangerous' && (typeof mode.warning !== 'string' || !mode.warning.trim())) {
235
+ errors.push(`${prefix}.warning is required for dangerous modes`)
236
+ } else if (mode.warning !== undefined && (typeof mode.warning !== 'string' || !mode.warning.trim())) {
237
+ errors.push(`${prefix}.warning must be a non-empty string when provided`)
238
+ }
239
+ }
240
+
241
+ if (defaultModeId && !ids.has(defaultModeId)) {
242
+ errors.push(`autoApproveModes.default must reference an existing mode id: ${defaultModeId}`)
243
+ }
244
+ }
245
+
163
246
  function validateCapabilities(provider: ProviderModule, controls: ProviderControlDef[], errors: string[]): void {
164
247
  const capabilities = provider.capabilities
165
248
  if (provider.contractVersion === 2) {
@@ -119,6 +119,20 @@
119
119
  }
120
120
  }
121
121
  },
122
+ "autoApproveModes": {
123
+ "type": "object",
124
+ "description": "Provider-specific auto-approve choices and their launch/runtime strategy.",
125
+ "required": ["default", "modes"],
126
+ "additionalProperties": false,
127
+ "properties": {
128
+ "default": { "type": "string", "minLength": 1 },
129
+ "modes": {
130
+ "type": "array",
131
+ "minItems": 1,
132
+ "items": { "$ref": "#/$defs/autoApproveMode" }
133
+ }
134
+ }
135
+ },
122
136
  "sendDelayMs": {
123
137
  "type": "number",
124
138
  "minimum": 0,
@@ -476,6 +490,36 @@
476
490
  }
477
491
  },
478
492
  "$defs": {
493
+ "autoApproveMode": {
494
+ "type": "object",
495
+ "required": ["id", "label", "strategy", "risk"],
496
+ "additionalProperties": false,
497
+ "properties": {
498
+ "id": { "type": "string", "minLength": 1 },
499
+ "label": { "type": "string", "minLength": 1 },
500
+ "strategy": { "enum": ["pty-parse-default", "launch-args", "post-boot-command"] },
501
+ "risk": { "enum": ["safe", "caution", "dangerous"] },
502
+ "warning": { "type": "string", "minLength": 1 },
503
+ "launchArgs": {
504
+ "type": "array",
505
+ "items": { "type": "string", "minLength": 1 }
506
+ },
507
+ "removeArgs": {
508
+ "type": "array",
509
+ "items": { "type": "string", "minLength": 1 }
510
+ }
511
+ },
512
+ "allOf": [
513
+ {
514
+ "if": { "properties": { "strategy": { "const": "launch-args" } }, "required": ["strategy"] },
515
+ "then": { "required": ["launchArgs"], "properties": { "launchArgs": { "minItems": 1 } } }
516
+ },
517
+ {
518
+ "if": { "properties": { "risk": { "const": "dangerous" } }, "required": ["risk"] },
519
+ "then": { "required": ["warning"] }
520
+ }
521
+ ]
522
+ },
479
523
  "patternList": {
480
524
  "type": "array",
481
525
  "items": {
@@ -15,6 +15,8 @@ import type { GitRepoStatus, GitCompactSummary } from './git/git-types.js';
15
15
  import type { MeshMissionSummary, MeshMissionSlimSummary } from './mesh/mesh-missions.js';
16
16
  import type { MeshMagiActivitySummary } from './mesh/mesh-magi-status.js';
17
17
  import type { MagiKindPanelMap, DifficultyBrainMap, NodeCapabilitySlot } from '@adhdev/mesh-shared';
18
+ import type { ProviderModule } from './providers/contracts.js';
19
+ import { deriveAutoApproveModeRisk } from './providers/auto-approve-modes.js';
18
20
 
19
21
  // ─── Core Mesh Types ────────────────────────────
20
22
 
@@ -350,6 +352,8 @@ export interface RepoMeshPolicy {
350
352
  * A node policy may override this per-node (RepoMeshNodePolicy.delegatedWorkerAutoApprove).
351
353
  */
352
354
  delegatedWorkerAutoApprove?: boolean;
355
+ /** Explicit opt-in required before delegated workers may use a dangerous provider mode. */
356
+ delegatedWorkerDangerousModeAllow?: boolean;
353
357
  /**
354
358
  * MESH-SEND-KEYS (feature 3): opt-in to allow the coordinator to inject
355
359
  * DESTRUCTIVE keys (CTRL_C / ESC) into a worker PTY via mesh_send_keys. These
@@ -505,6 +509,8 @@ export interface RepoMeshNodePolicy {
505
509
  * precedence over the mesh-level policy for worker sessions launched onto this node.
506
510
  */
507
511
  delegatedWorkerAutoApprove?: boolean;
512
+ /** Per-node override for dangerous delegated worker mode authorization. */
513
+ delegatedWorkerDangerousModeAllow?: boolean;
508
514
  /**
509
515
  * MESH-SEND-KEYS (feature 3): per-node override for
510
516
  * RepoMeshPolicy.allowSendKeysDestructive.
@@ -550,6 +556,7 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
550
556
  // any specific session manually; that override is preserved per-device.
551
557
  spawnedSessionVisibility: 'hidden',
552
558
  delegatedWorkerAutoApprove: true,
559
+ delegatedWorkerDangerousModeAllow: false,
553
560
  sessionCleanupOnNodeRemove: 'preserve',
554
561
  // MAGI auto-launches a worker session per pinned replica target with no idle
555
562
  // session; those stay idle-LIVE after their turn. Default ON (stop_and_delete)
@@ -776,6 +783,13 @@ export function mergeAndNormalizePolicy(
776
783
  } else {
777
784
  delete policy.autoConvergeCodeChange;
778
785
  }
786
+ // Dangerous delegated-worker provider modes are fail-closed and only persist
787
+ // when the mesh owner has explicitly opted in.
788
+ if (policy.delegatedWorkerDangerousModeAllow === true) {
789
+ policy.delegatedWorkerDangerousModeAllow = true;
790
+ } else {
791
+ delete policy.delegatedWorkerDangerousModeAllow;
792
+ }
779
793
  // Coordinator idle-push policy: strict opt-in. Only persist the explicit
780
794
  // 'auto_silent_on_dispatch' value; any other/invalid value normalizes to the
781
795
  // 'always' default and is dropped so existing meshes.json stays byte-for-byte
@@ -789,23 +803,62 @@ export function mergeAndNormalizePolicy(
789
803
  }
790
804
 
791
805
  /**
792
- * Resolve whether a delegated worker session launched onto `nodePolicy` (within a mesh
793
- * governed by `meshPolicy`) should auto-approve. Precedence: node override mesh policy
794
- * default true. The result is stamped into the worker launch settings envelope as
795
- * `autoApprove`; it wins over the global per-provider-type autoApprove config because the
796
- * launch path merges the envelope as a settingsOverride on top of the provider defaults.
806
+ * Resolve delegated worker auto-approve. Legacy providers return a boolean. Providers
807
+ * with modes return the default mode id, except a dangerous default is downgraded to a
808
+ * non-dangerous PTY mode unless mesh/node policy explicitly opts in.
797
809
  */
798
810
  export function resolveDelegatedWorkerAutoApprove(
799
- meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove'> | null,
800
- nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove'> | null,
801
- ): boolean {
811
+ meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null,
812
+ nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null,
813
+ provider?: Pick<ProviderModule, 'autoApproveModes'> | null,
814
+ ): boolean | string {
815
+ let enabled = true;
802
816
  if (typeof nodePolicy?.delegatedWorkerAutoApprove === 'boolean') {
803
- return nodePolicy.delegatedWorkerAutoApprove;
817
+ enabled = nodePolicy.delegatedWorkerAutoApprove;
818
+ } else if (typeof meshPolicy?.delegatedWorkerAutoApprove === 'boolean') {
819
+ enabled = meshPolicy.delegatedWorkerAutoApprove;
804
820
  }
805
- if (typeof meshPolicy?.delegatedWorkerAutoApprove === 'boolean') {
806
- return meshPolicy.delegatedWorkerAutoApprove;
821
+ if (!enabled) return false;
822
+
823
+ const modes = provider?.autoApproveModes;
824
+ if (!modes) return true;
825
+ const defaultMode = modes.modes.find((mode) => mode.id === modes.default);
826
+ if (!defaultMode || defaultMode.strategy === 'post-boot-command') return false;
827
+
828
+ const dangerousAllowed = resolveDelegatedWorkerDangerousModeAllow(meshPolicy, nodePolicy);
829
+ if (deriveAutoApproveModeRisk(defaultMode) === 'dangerous' && !dangerousAllowed) {
830
+ const ptyFallback = modes!.modes.find((mode) =>
831
+ mode.strategy === 'pty-parse-default' && deriveAutoApproveModeRisk(mode) !== 'dangerous');
832
+ return ptyFallback?.id || false;
807
833
  }
808
- return true;
834
+ return defaultMode.id;
835
+ }
836
+
837
+ export function resolveDelegatedWorkerDangerousModeAllow(
838
+ meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerDangerousModeAllow'> | null,
839
+ nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerDangerousModeAllow'> | null,
840
+ ): boolean {
841
+ if (typeof nodePolicy?.delegatedWorkerDangerousModeAllow === 'boolean') {
842
+ return nodePolicy.delegatedWorkerDangerousModeAllow;
843
+ }
844
+ return meshPolicy?.delegatedWorkerDangerousModeAllow === true;
845
+ }
846
+
847
+ /** Shape a boolean-or-mode resolution for the settings precedence contract. */
848
+ export function delegatedWorkerAutoApproveSettings(
849
+ meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null,
850
+ nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null,
851
+ provider?: Pick<ProviderModule, 'autoApproveModes'> | null,
852
+ ): {
853
+ autoApprove: boolean | undefined;
854
+ autoApproveMode: string | undefined;
855
+ delegatedWorkerDangerousModeAllow: boolean;
856
+ } {
857
+ const resolved = resolveDelegatedWorkerAutoApprove(meshPolicy, nodePolicy, provider);
858
+ const delegatedWorkerDangerousModeAllow = resolveDelegatedWorkerDangerousModeAllow(meshPolicy, nodePolicy);
859
+ return typeof resolved === 'string'
860
+ ? { autoApprove: undefined, autoApproveMode: resolved, delegatedWorkerDangerousModeAllow }
861
+ : { autoApprove: resolved, autoApproveMode: undefined, delegatedWorkerDangerousModeAllow };
809
862
  }
810
863
 
811
864
  /**
@@ -52,7 +52,7 @@ export type { ProviderErrorReason } from './providers/provider-instance.js';
52
52
  // Local import for use in Managed*Entry types below
53
53
  import type { ActiveChatData as _ActiveChatData, ProviderErrorReason as _ProviderErrorReason } from './providers/provider-instance.js';
54
54
  import type { WorkspaceEntry } from './config/workspaces.js';
55
- import type { ProviderMeshCoordinatorConfig, ProviderResumeCapability } from './providers/contracts.js';
55
+ import type { AutoApproveModesConfig, ProviderMeshCoordinatorConfig, ProviderResumeCapability } from './providers/contracts.js';
56
56
  import type {
57
57
  GitCompactSummary,
58
58
  GitDiffSummary,
@@ -563,6 +563,8 @@ export interface AvailableProviderInfo {
563
563
  lastVerification?: MachineProviderCheckResult;
564
564
  /** Provider-declared Repo Mesh coordinator/MCP behavior. */
565
565
  meshCoordinator?: ProviderMeshCoordinatorConfig;
566
+ /** Provider-declared auto-approve choices shown by session launch UIs. */
567
+ autoApproveModes?: AutoApproveModesConfig;
566
568
  /** BRAIN-ROUTING: suggested model values for the new-session model dropdown. */
567
569
  modelOptions?: string[];
568
570
  /** BRAIN-ROUTING: reasoning-effort values for the new-session thinking dropdown. */
@@ -145,6 +145,7 @@ export function buildAvailableProviders(
145
145
  lastDetection?: AvailableProviderInfo['lastDetection'];
146
146
  lastVerification?: AvailableProviderInfo['lastVerification'];
147
147
  meshCoordinator?: AvailableProviderInfo['meshCoordinator'];
148
+ autoApproveModes?: AvailableProviderInfo['autoApproveModes'];
148
149
  _sourceTrust?: AvailableProviderInfo['trust'];
149
150
  _sourceLayer?: AvailableProviderInfo['sourceLayer'];
150
151
  _sourceName?: string | null;
@@ -183,6 +184,7 @@ export function buildAvailableProviders(
183
184
  ...(provider.lastDetection !== undefined ? { lastDetection: provider.lastDetection } : {}),
184
185
  ...(provider.lastVerification !== undefined ? { lastVerification: provider.lastVerification } : {}),
185
186
  ...(provider.meshCoordinator !== undefined ? { meshCoordinator: provider.meshCoordinator } : {}),
187
+ ...(provider.autoApproveModes !== undefined ? { autoApproveModes: provider.autoApproveModes } : {}),
186
188
  ...(trust ? {
187
189
  trust,
188
190
  trustDescription: describeTrust(trust),