@adhdev/daemon-core 1.0.18-rc.9 → 1.0.19

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.
Files changed (67) hide show
  1. package/dist/cli-adapters/provider-cli-shared.d.ts +33 -0
  2. package/dist/commands/cli-manager.d.ts +9 -0
  3. package/dist/commands/upgrade-helper.d.ts +1 -0
  4. package/dist/commands/windows-atomic-upgrade.d.ts +1 -0
  5. package/dist/config/mesh-json-config.d.ts +62 -0
  6. package/dist/index.d.ts +4 -2
  7. package/dist/index.js +2205 -657
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2173 -634
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/mesh/mesh-active-work.d.ts +1 -1
  12. package/dist/mesh/mesh-ledger.d.ts +1 -1
  13. package/dist/mesh/mesh-node-identity.d.ts +23 -0
  14. package/dist/mesh/mesh-refine-gates.d.ts +89 -0
  15. package/dist/mesh/mesh-remote-event-pull.d.ts +3 -0
  16. package/dist/mesh/mesh-work-queue.d.ts +24 -1
  17. package/dist/providers/auto-approve-modes.d.ts +14 -0
  18. package/dist/providers/chat-message-normalization.d.ts +22 -0
  19. package/dist/providers/cli-provider-instance-types.d.ts +2 -0
  20. package/dist/providers/cli-provider-instance.d.ts +76 -0
  21. package/dist/providers/contracts.d.ts +17 -0
  22. package/dist/providers/provider-schema.d.ts +1 -0
  23. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
  24. package/dist/providers/types/interactive-prompt.d.ts +18 -0
  25. package/dist/repo-mesh-types.d.ts +38 -6
  26. package/dist/shared-types.d.ts +4 -2
  27. package/package.json +3 -3
  28. package/src/cli-adapters/cli-state-engine.ts +17 -1
  29. package/src/cli-adapters/provider-cli-adapter.ts +2 -6
  30. package/src/cli-adapters/provider-cli-shared.ts +48 -0
  31. package/src/commands/cli-manager.ts +72 -8
  32. package/src/commands/high-family/mesh-events.ts +13 -2
  33. package/src/commands/med-family/mesh-crud.ts +155 -0
  34. package/src/commands/med-family/mesh-queue.ts +74 -1
  35. package/src/commands/router-refine.ts +71 -4
  36. package/src/commands/router.ts +8 -0
  37. package/src/commands/upgrade-helper.ts +50 -1
  38. package/src/commands/windows-atomic-upgrade.ts +88 -23
  39. package/src/config/mesh-json-config.ts +103 -0
  40. package/src/index.ts +21 -1
  41. package/src/mesh/coordinator-prompt.ts +2 -1
  42. package/src/mesh/mesh-active-work.ts +11 -1
  43. package/src/mesh/mesh-completion-synthesis.ts +12 -1
  44. package/src/mesh/mesh-event-classify.ts +19 -0
  45. package/src/mesh/mesh-event-forwarding.ts +64 -6
  46. package/src/mesh/mesh-events-utils.ts +42 -0
  47. package/src/mesh/mesh-ledger.ts +5 -0
  48. package/src/mesh/mesh-node-identity.ts +49 -0
  49. package/src/mesh/mesh-queue-assignment.ts +84 -10
  50. package/src/mesh/mesh-reconcile-loop.ts +257 -3
  51. package/src/mesh/mesh-refine-gates.ts +300 -0
  52. package/src/mesh/mesh-remote-event-pull.ts +68 -47
  53. package/src/mesh/mesh-work-queue.ts +85 -2
  54. package/src/providers/auto-approve-modes.ts +97 -0
  55. package/src/providers/chat-message-normalization.ts +53 -0
  56. package/src/providers/cli-provider-instance-types.ts +28 -0
  57. package/src/providers/cli-provider-instance.ts +394 -28
  58. package/src/providers/contracts.ts +25 -1
  59. package/src/providers/provider-schema.ts +83 -0
  60. package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
  61. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
  62. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +44 -0
  63. package/src/providers/types/interactive-prompt.ts +77 -0
  64. package/src/repo-mesh-types.ts +112 -12
  65. package/src/shared-types.ts +9 -1
  66. package/src/status/reporter.ts +1 -0
  67. package/src/status/snapshot.ts +2 -0
@@ -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) {
@@ -210,10 +210,33 @@ function buttonBlockApprovalCue(spec: ModalSpec, text: string): boolean {
210
210
  return hasNegativeApprovalOption(labels);
211
211
  }
212
212
 
213
+ /**
214
+ * APPROVAL-PICKER-MISROUTE (mission f1d25e11) defense-in-depth: an
215
+ * AskUserQuestion multi-choice picker is NOT an approval modal. Its option rows
216
+ * ("❯ 1. label") can otherwise satisfy the approval button cue and get
217
+ * mis-classified as `waiting_approval`, so the worker's question is surfaced to
218
+ * the coordinator as a task_approval_needed (→ mesh_approve, which cannot answer
219
+ * it). The picker carries a distinctive signature the approval FSM never does:
220
+ * the claude TUI select footer ("Enter to select … Esc to cancel") together with
221
+ * the freeform escape hatch ("Type something" / "Chat about this"). When both are
222
+ * present the screen is a question picker — surfaced separately as
223
+ * waiting_choice — so the approval matchers must yield. Mirrors the legacy
224
+ * looksLikeSelectionPicker guard (cli-provider-instance.ts) ported to SDK-v1.
225
+ */
226
+ export function isAskUserQuestionPickerSignature(text: string): boolean {
227
+ if (!text) return false;
228
+ const hasSelectFooter = /Enter to select/i.test(text) && /Esc to cancel/i.test(text);
229
+ if (!hasSelectFooter) return false;
230
+ return /Type something\.?|Chat about this/i.test(text);
231
+ }
232
+
213
233
  function modalMatches(spec: ModalSpec, input: CliStatusInput): boolean {
214
234
  // Status-level modal detection is cue-only — does the question appear at all?
215
235
  // Button extraction lives in buildParseApprovalFromTui.
216
236
  const text = input.screenText ?? '';
237
+ // A question picker (AskUserQuestion) is never an approval — bail before any
238
+ // approval cue can match its numbered option rows (mission f1d25e11).
239
+ if (isAskUserQuestionPickerSignature(text)) return false;
217
240
  const question = compile(spec.questionPattern, spec.questionFlags ?? 'i');
218
241
  if (question.test(text)) return true;
219
242
  for (const variant of spec.questionVariants ?? []) {
@@ -78,6 +78,22 @@ function compile(re: string, flags?: string): RegExp {
78
78
  }
79
79
  }
80
80
 
81
+ /**
82
+ * APPROVAL-PICKER-MISROUTE (mission f1d25e11) defense-in-depth: mirror
83
+ * detect-status.isAskUserQuestionPickerSignature at the button-parse layer. A
84
+ * multi-choice AskUserQuestion picker is not an approval modal; its numbered
85
+ * option rows can otherwise be extracted as approval buttons and produce a
86
+ * spurious approval modal. When the picker signature (claude TUI select footer +
87
+ * "Type something"/"Chat about this" freeform escape hatch) is present, return
88
+ * null early so the screen is surfaced as waiting_choice, never approval.
89
+ */
90
+ function isAskUserQuestionPickerSignature(text: string): boolean {
91
+ if (!text) return false;
92
+ const hasSelectFooter = /Enter to select/i.test(text) && /Esc to cancel/i.test(text);
93
+ if (!hasSelectFooter) return false;
94
+ return /Type something\.?|Chat about this/i.test(text);
95
+ }
96
+
81
97
  function findQuestionLineIndex(
82
98
  spec: ModalTuiSpec,
83
99
  lines: string[],
@@ -268,6 +284,10 @@ export function buildParseApprovalFromTui(
268
284
  return function parseApproval(input: CliApprovalInput): CliApprovalModal | null {
269
285
  const rawText = input.screenText ?? input.buffer ?? '';
270
286
  if (!rawText) return null;
287
+ // An AskUserQuestion picker is not an approval — never extract its option
288
+ // rows as approval buttons (mission f1d25e11). Check the raw text before any
289
+ // visible-region scoping trims the footer that identifies the picker.
290
+ if (isAskUserQuestionPickerSignature(rawText)) return null;
271
291
  const text = visibleRegion ? applyVisibleRegion(visibleRegion, rawText) : rawText;
272
292
  const lines = text.split('\n');
273
293
  const question = findQuestionLineIndex(spec, lines);
@@ -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": {
@@ -117,6 +117,83 @@ export function normalizeInteractivePromptResponse(raw: unknown): InteractivePro
117
117
  return { promptId, answers };
118
118
  }
119
119
 
120
+ /**
121
+ * Resolve a coordinator-friendly answer form into the strict, questionId-keyed
122
+ * InteractivePromptResponse the TUI/answer machinery consumes (mission f1d25e11).
123
+ *
124
+ * mesh_answer_question lets the coordinator answer against the option LABELS or
125
+ * 1-based INDEXES it saw in the agent:waiting_choice event, without having to
126
+ * reconstruct the exact questionId → selectedLabels map. This resolves that
127
+ * ergonomic form against the AUTHORITATIVE active prompt (the daemon holds it),
128
+ * so index/label resolution and question ordering are correct by construction.
129
+ *
130
+ * Accepted `raw` shapes:
131
+ * - The strict form ({ promptId, answers: { [questionId]: { selectedLabels } } })
132
+ * — passed straight to normalizeInteractivePromptResponse (back-compat).
133
+ * - The friendly form ({ promptId, answers: [ { questionId?, select?, freeform? } ] })
134
+ * — entries map to questions by questionId, else by array position. `select`
135
+ * is a label (string) / 1-based index (number) / array of either.
136
+ */
137
+ export function resolveInteractivePromptResponse(
138
+ prompt: InteractivePrompt,
139
+ raw: unknown,
140
+ ): InteractivePromptResponse {
141
+ if (!raw || typeof raw !== 'object') throw new Error('Interactive prompt response must be an object');
142
+ const record = raw as Record<string, unknown>;
143
+ const promptId = readString(record.promptId);
144
+ if (!promptId) throw new Error('promptId must be a non-empty string');
145
+ if (promptId !== prompt.promptId) throw new Error('Interactive prompt response does not match active prompt');
146
+ // Strict (keyed-object) form → existing normalizer.
147
+ if (record.answers && typeof record.answers === 'object' && !Array.isArray(record.answers)) {
148
+ return normalizeInteractivePromptResponse(record);
149
+ }
150
+ if (!Array.isArray(record.answers)) throw new Error('answers must be an array or a questionId-keyed object');
151
+
152
+ const resolveOneLabel = (question: InteractiveQuestion, sel: unknown): string => {
153
+ if (typeof sel === 'number' && Number.isFinite(sel)) {
154
+ const idx = Math.trunc(sel) - 1; // 1-based
155
+ const option = question.options[idx];
156
+ if (!option) throw new Error(`Option index ${sel} out of range for ${question.questionId}`);
157
+ return option.label;
158
+ }
159
+ const label = readString(sel);
160
+ if (!label) throw new Error(`Empty selection for ${question.questionId}`);
161
+ // Exact label match first; fall back to a numeric string index.
162
+ const exact = question.options.find((o) => o.label === label);
163
+ if (exact) return exact.label;
164
+ const asIndex = Number(label);
165
+ if (Number.isInteger(asIndex)) {
166
+ const option = question.options[asIndex - 1];
167
+ if (option) return option.label;
168
+ }
169
+ throw new Error(`Unknown option for ${question.questionId}: ${label}`);
170
+ };
171
+
172
+ const answers: Record<string, InteractiveAnswer> = {};
173
+ const entries = record.answers as unknown[];
174
+ entries.forEach((entryRaw, index) => {
175
+ if (!entryRaw || typeof entryRaw !== 'object' || Array.isArray(entryRaw)) return;
176
+ const entry = entryRaw as Record<string, unknown>;
177
+ const questionId = readString(entry.questionId);
178
+ const question = (questionId ? prompt.questions.find((q) => q.questionId === questionId) : undefined)
179
+ ?? prompt.questions[index];
180
+ if (!question) throw new Error(`No matching question for answer entry ${index}`);
181
+ const freeformText = readString(entry.freeform) ?? readString(entry.freeformText);
182
+ const selectedLabels: string[] = [];
183
+ const select = entry.select;
184
+ if (Array.isArray(select)) {
185
+ for (const sel of select) selectedLabels.push(resolveOneLabel(question, sel));
186
+ } else if (select !== undefined && select !== null) {
187
+ selectedLabels.push(resolveOneLabel(question, select));
188
+ }
189
+ answers[question.questionId] = {
190
+ selectedLabels,
191
+ ...(freeformText ? { freeformText } : {}),
192
+ };
193
+ });
194
+ return { promptId, answers };
195
+ }
196
+
120
197
  export function buildClaudeInteractiveToolResult(response: InteractivePromptResponse): string {
121
198
  return JSON.stringify({
122
199
  type: 'user',
@@ -15,6 +15,11 @@ 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';
20
+ // Type-only import (no runtime cycle) — mesh-json-config imports types from this
21
+ // module, and this direction is `import type` so tsc erases it at emit.
22
+ import type { RepoMeshDeclarativeConfig } from './config/mesh-json-config.js';
18
23
 
19
24
  // ─── Core Mesh Types ────────────────────────────
20
25
 
@@ -350,6 +355,8 @@ export interface RepoMeshPolicy {
350
355
  * A node policy may override this per-node (RepoMeshNodePolicy.delegatedWorkerAutoApprove).
351
356
  */
352
357
  delegatedWorkerAutoApprove?: boolean;
358
+ /** Explicit opt-in required before delegated workers may use a dangerous provider mode. */
359
+ delegatedWorkerDangerousModeAllow?: boolean;
353
360
  /**
354
361
  * MESH-SEND-KEYS (feature 3): opt-in to allow the coordinator to inject
355
362
  * DESTRUCTIVE keys (CTRL_C / ESC) into a worker PTY via mesh_send_keys. These
@@ -505,6 +512,8 @@ export interface RepoMeshNodePolicy {
505
512
  * precedence over the mesh-level policy for worker sessions launched onto this node.
506
513
  */
507
514
  delegatedWorkerAutoApprove?: boolean;
515
+ /** Per-node override for dangerous delegated worker mode authorization. */
516
+ delegatedWorkerDangerousModeAllow?: boolean;
508
517
  /**
509
518
  * MESH-SEND-KEYS (feature 3): per-node override for
510
519
  * RepoMeshPolicy.allowSendKeysDestructive.
@@ -550,6 +559,7 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
550
559
  // any specific session manually; that override is preserved per-device.
551
560
  spawnedSessionVisibility: 'hidden',
552
561
  delegatedWorkerAutoApprove: true,
562
+ delegatedWorkerDangerousModeAllow: false,
553
563
  sessionCleanupOnNodeRemove: 'preserve',
554
564
  // MAGI auto-launches a worker session per pinned replica target with no idle
555
565
  // session; those stay idle-LIVE after their turn. Default ON (stop_and_delete)
@@ -776,6 +786,13 @@ export function mergeAndNormalizePolicy(
776
786
  } else {
777
787
  delete policy.autoConvergeCodeChange;
778
788
  }
789
+ // Dangerous delegated-worker provider modes are fail-closed and only persist
790
+ // when the mesh owner has explicitly opted in.
791
+ if (policy.delegatedWorkerDangerousModeAllow === true) {
792
+ policy.delegatedWorkerDangerousModeAllow = true;
793
+ } else {
794
+ delete policy.delegatedWorkerDangerousModeAllow;
795
+ }
779
796
  // Coordinator idle-push policy: strict opt-in. Only persist the explicit
780
797
  // 'auto_silent_on_dispatch' value; any other/invalid value normalizes to the
781
798
  // 'always' default and is dropped so existing meshes.json stays byte-for-byte
@@ -789,23 +806,106 @@ export function mergeAndNormalizePolicy(
789
806
  }
790
807
 
791
808
  /**
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.
809
+ * Resolve delegated worker auto-approve. Legacy providers return a boolean. Providers
810
+ * with modes return a mode id, except a dangerous mode is downgraded to a
811
+ * non-dangerous PTY mode unless mesh/node policy explicitly opts in.
812
+ *
813
+ * THREE EXPLICIT STAGES do not collapse them; the ordering is a hard MAGI
814
+ * invariant:
815
+ *
816
+ * ① ENABLE gate (machine-local policy only): node boolean > mesh boolean.
817
+ * `enabled=false` returns `false` IMMEDIATELY, BEFORE any mode selection.
818
+ * The repo `mesh.json` providerDefaults has ZERO influence here — a
819
+ * node/mesh opt-out is never overridden by a repo-declared requested mode.
820
+ *
821
+ * ② MODE selection (only when enabled === true):
822
+ * task override [FUTURE — param reserved below, not yet wired] >
823
+ * repo mesh.json providerDefaults.autoApproveModes[providerType] >
824
+ * provider spec autoApproveModes.default.
825
+ * A repo-requested mode ID is adopted ONLY when it exists in the provider's
826
+ * own `autoApproveModes.modes`; an unknown/stale/typo'd ID is IGNORED and we
827
+ * fall back to the provider default (fail-closed: never coerce into a
828
+ * dangerous mode via a bad ID).
829
+ *
830
+ * ③ DANGEROUS gate: whichever mode stage ② picked, if it is dangerous and the
831
+ * machine-local delegatedWorkerDangerousModeAllow is not set, downgrade to a
832
+ * non-dangerous PTY-parse mode (or `false` if none exists).
797
833
  */
798
834
  export function resolveDelegatedWorkerAutoApprove(
799
- meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove'> | null,
800
- nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove'> | null,
801
- ): boolean {
835
+ meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null,
836
+ nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null,
837
+ provider?: Pick<ProviderModule, 'autoApproveModes'> | null,
838
+ repoConfig?: RepoMeshDeclarativeConfig | null,
839
+ // providerType is needed to look up the repo-declared requested mode; it is
840
+ // separate from `provider` because the caller resolves the spec independently.
841
+ providerType?: string | null,
842
+ ): boolean | string {
843
+ // ── ① ENABLE gate — machine-local only. false short-circuits before mode. ──
844
+ let enabled = true;
802
845
  if (typeof nodePolicy?.delegatedWorkerAutoApprove === 'boolean') {
803
- return nodePolicy.delegatedWorkerAutoApprove;
846
+ enabled = nodePolicy.delegatedWorkerAutoApprove;
847
+ } else if (typeof meshPolicy?.delegatedWorkerAutoApprove === 'boolean') {
848
+ enabled = meshPolicy.delegatedWorkerAutoApprove;
804
849
  }
805
- if (typeof meshPolicy?.delegatedWorkerAutoApprove === 'boolean') {
806
- return meshPolicy.delegatedWorkerAutoApprove;
850
+ if (!enabled) return false;
851
+
852
+ const modes = provider?.autoApproveModes;
853
+ if (!modes) return true;
854
+
855
+ // ── ② MODE selection (enabled only). Repo providerDefaults MAY override the
856
+ // provider spec default here — but only with a mode ID the spec knows. ──
857
+ // Future: a per-task override would slot in ahead of the repo default; the
858
+ // signature reserves that precedence but no task override is wired yet.
859
+ const requestedModeRaw = typeof providerType === 'string'
860
+ ? repoConfig?.providerDefaults?.autoApproveModes?.[providerType.trim()]
861
+ : undefined;
862
+ const requestedModeId = typeof requestedModeRaw === 'string' && requestedModeRaw.trim()
863
+ ? requestedModeRaw.trim()
864
+ : '';
865
+ const requestedMode = requestedModeId
866
+ ? modes.modes.find((mode) => mode.id === requestedModeId)
867
+ : undefined;
868
+ const selectedMode = requestedMode
869
+ ?? modes.modes.find((mode) => mode.id === modes.default);
870
+ if (!selectedMode || selectedMode.strategy === 'post-boot-command') return false;
871
+
872
+ // ── ③ DANGEROUS gate — downgrade a dangerous selection without machine opt-in. ──
873
+ const dangerousAllowed = resolveDelegatedWorkerDangerousModeAllow(meshPolicy, nodePolicy);
874
+ if (deriveAutoApproveModeRisk(selectedMode) === 'dangerous' && !dangerousAllowed) {
875
+ const ptyFallback = modes.modes.find((mode) =>
876
+ mode.strategy === 'pty-parse-default' && deriveAutoApproveModeRisk(mode) !== 'dangerous');
877
+ return ptyFallback?.id || false;
807
878
  }
808
- return true;
879
+ return selectedMode.id;
880
+ }
881
+
882
+ export function resolveDelegatedWorkerDangerousModeAllow(
883
+ meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerDangerousModeAllow'> | null,
884
+ nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerDangerousModeAllow'> | null,
885
+ ): boolean {
886
+ if (typeof nodePolicy?.delegatedWorkerDangerousModeAllow === 'boolean') {
887
+ return nodePolicy.delegatedWorkerDangerousModeAllow;
888
+ }
889
+ return meshPolicy?.delegatedWorkerDangerousModeAllow === true;
890
+ }
891
+
892
+ /** Shape a boolean-or-mode resolution for the settings precedence contract. */
893
+ export function delegatedWorkerAutoApproveSettings(
894
+ meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null,
895
+ nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null,
896
+ provider?: Pick<ProviderModule, 'autoApproveModes'> | null,
897
+ repoConfig?: RepoMeshDeclarativeConfig | null,
898
+ providerType?: string | null,
899
+ ): {
900
+ autoApprove: boolean | undefined;
901
+ autoApproveMode: string | undefined;
902
+ delegatedWorkerDangerousModeAllow: boolean;
903
+ } {
904
+ const resolved = resolveDelegatedWorkerAutoApprove(meshPolicy, nodePolicy, provider, repoConfig, providerType);
905
+ const delegatedWorkerDangerousModeAllow = resolveDelegatedWorkerDangerousModeAllow(meshPolicy, nodePolicy);
906
+ return typeof resolved === 'string'
907
+ ? { autoApprove: undefined, autoApproveMode: resolved, delegatedWorkerDangerousModeAllow }
908
+ : { autoApprove: resolved, autoApproveMode: undefined, delegatedWorkerDangerousModeAllow };
809
909
  }
810
910
 
811
911
  /**
@@ -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. */
@@ -775,6 +777,12 @@ export interface DashboardBootstrapDaemonEntry extends Partial<CloudDaemonSummar
775
777
  export type DaemonStatusEventName =
776
778
  | 'agent:generating_started'
777
779
  | 'agent:waiting_approval'
780
+ // A question picker (AskUserQuestion / InteractivePrompt) parks the agent
781
+ // awaiting a human decision — distinct from an approval modal, but equally a
782
+ // state the user must answer before work continues. Relayed to the server so
783
+ // push notifications fire (owner requirement: coordinator sessions must be
784
+ // pinged for pending questions).
785
+ | 'agent:waiting_choice'
778
786
  | 'agent:generating_completed'
779
787
  | 'agent:stopped'
780
788
  | 'monitor:no_progress'
@@ -118,6 +118,7 @@ export class DaemonStatusReporter {
118
118
  switch (value) {
119
119
  case 'agent:generating_started':
120
120
  case 'agent:waiting_approval':
121
+ case 'agent:waiting_choice':
121
122
  case 'agent:generating_completed':
122
123
  case 'agent:stopped':
123
124
  case 'monitor:no_progress':
@@ -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),