@adhdev/daemon-core 0.9.82-rc.483 → 0.9.82-rc.484

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.
@@ -679,6 +679,19 @@ const ASSIGNED_STRANDED_DEADLINE_MS = 5 * 60_000;
679
679
  // reclaimed out from under itself.
680
680
  const DELIVERED_NO_TURN_DEADLINE_MS = 15 * 60_000;
681
681
 
682
+ // DELIVERED-NOT-CONSUMED (remote autoLaunch delivered≠consumed gap): how long a row may sit
683
+ // 'assigned' with a CONFIRMED delivery ('delivered') that was never CONSUMED ('acked' — the
684
+ // worker's agent:generating_started never arrived) before the watchdog re-drives it. Far shorter
685
+ // than DELIVERED_NO_TURN_DEADLINE_MS (15min): a remote autoLaunch marks markAutoLaunch(completed)
686
+ // and returns immediately, relying on agent:ready/reconcile to inject; if the launch→ready→claim
687
+ // window (widened on win32 by the 3–4s git spawn latency) drops the inject, the row sits 'assigned'
688
+ // but the delivery never flips past 'delivered' to 'acked'. The delivered-not-acked state is the
689
+ // cross-daemon consumption signal — positive evidence the worker never started the turn — so we can
690
+ // safely re-open the task after a SHORT grace (well above a normal generating_started round-trip so
691
+ // a merely-slow start is never torn off) instead of waiting the full 15min turn budget. Floored
692
+ // comfortably above the auto-launch cooldown so a legitimate late inject still has room to land.
693
+ const ASSIGNED_DELIVERED_UNCONSUMED_REDRIVE_MS = 25_000;
694
+
682
695
  // RECLAIM-FALSEPOS: how many CONSECUTIVE UNKNOWN busy-verdict ticks (past the delivered-no-turn
683
696
  // deadline) must accumulate before a delivered row whose worker session cannot be positively
684
697
  // observed is reclaimed. An UNKNOWN verdict means the assigned session is not present in THIS
@@ -728,7 +741,62 @@ function recoverStrandedAssignedDispatches(components: DaemonComponents, meshId:
728
741
  for (const row of assigned) {
729
742
  const dispatchedAtMs = Date.parse(row.dispatchTimestamp ?? '');
730
743
  if (!Number.isFinite(dispatchedAtMs)) continue; // no dispatch ts → can't age it
731
- if (nowMs - dispatchedAtMs < ASSIGNED_STRANDED_DEADLINE_MS) continue; // still in confirm window
744
+ const ageMs = nowMs - dispatchedAtMs;
745
+ // DELIVERED-NOT-CONSUMED short-grace re-drive (remote autoLaunch delivered≠consumed gap).
746
+ // Runs BEFORE the ASSIGNED_STRANDED_DEADLINE_MS confirm-window gate below because its whole
747
+ // point is to recover a delivered-but-unconsumed row well inside that window. A remote
748
+ // autoLaunch marks the dispatch delivered (transport acked) but the worker may never emit
749
+ // agent:generating_started — the delivery then sits 'delivered' and never flips to 'acked',
750
+ // so the task is stranded 'assigned' with no live turn. This branch re-opens exactly that
751
+ // row after a short grace:
752
+ // - the delivery IS confirmed handed off (taskHasConfirmedDelivery) but was NEVER consumed
753
+ // (!taskDeliveryConsumed → no 'acked'/'completed' delivery) — the cross-daemon "worker
754
+ // never started the turn" signal, valid even for a REMOTE session whose local busy
755
+ // verdict is UNKNOWN;
756
+ // - AND the busy verdict is NOT GENERATING — a locally-present generating session IS
757
+ // consuming (ack lost/late), so never touch it (regression guard against tearing a live
758
+ // worker off its turn);
759
+ // - AND no terminal ledger evidence exists (the completion already landed → leave it).
760
+ // reclaimStrandedAssignedTask returns the row to 'pending' (bounded by MAX_STRANDED_RECLAIMS)
761
+ // so PHASE 3 re-dispatches it this same tick onto a fresh idle session — idempotent: it only
762
+ // mutates a still-'assigned' row, so a completion/ack that raced in already moved the row off
763
+ // 'assigned' and this is a no-op.
764
+ if (
765
+ ageMs >= ASSIGNED_DELIVERED_UNCONSUMED_REDRIVE_MS
766
+ && ageMs < ASSIGNED_STRANDED_DEADLINE_MS
767
+ && store.taskHasConfirmedDelivery(meshId, row.id)
768
+ && !store.taskDeliveryConsumed(meshId, row.id)
769
+ ) {
770
+ const terminal = findTerminalLedgerEvidenceForTask({ meshId, taskId: row.id });
771
+ if (terminal) {
772
+ const status = terminal.kind === 'task_completed' ? 'completed' : 'failed';
773
+ updateTaskStatus(meshId, row.id, status);
774
+ continue;
775
+ }
776
+ const verdict = row.assignedSessionId
777
+ ? resolveSessionBusyVerdict(components, row.assignedSessionId)
778
+ : 'IDLE_CONFIRMED'; // no session bound → nothing live generating to protect
779
+ if (verdict !== 'GENERATING') {
780
+ const redriven = reclaimStrandedAssignedTask(meshId, row.id, {
781
+ reason: 'delivered_not_consumed_redrive',
782
+ ageMs,
783
+ });
784
+ if (redriven) {
785
+ LOG.warn('MeshReconcile', `Re-drove delivered-but-unconsumed task ${row.id} on mesh ${meshId} `
786
+ + `(node=${row.assignedNodeId ?? '?'} session=${row.assignedSessionId ?? '?'}, delivered but no `
787
+ + `generating_started in ${Math.round(ageMs / 1000)}s, verdict ${verdict} → ${redriven.status})`);
788
+ traceMeshEventDrop('assigned_delivered_not_consumed_redrive', {
789
+ taskId: row.id,
790
+ sessionId: row.assignedSessionId,
791
+ nodeId: row.assignedNodeId,
792
+ meshId,
793
+ event: 'agent:generating_started',
794
+ }, `delivered_not_consumed ${Math.round(ageMs / 1000)}s → ${redriven.status}`);
795
+ continue;
796
+ }
797
+ }
798
+ }
799
+ if (ageMs < ASSIGNED_STRANDED_DEADLINE_MS) continue; // still in confirm window
732
800
  const terminal = findTerminalLedgerEvidenceForTask({
733
801
  meshId,
734
802
  taskId: row.id,
@@ -1488,6 +1488,28 @@ export class MeshRuntimeStore {
1488
1488
  return !!row;
1489
1489
  }
1490
1490
 
1491
+ /**
1492
+ * DELIVERED-NOT-CONSUMED re-drive support: true when at least one delivery record for
1493
+ * the task has reached a CONSUMED status ('acked' / 'completed'). Distinct from
1494
+ * {@link taskHasConfirmedDelivery} ('delivered' | 'acked' | 'completed'): a delivery is
1495
+ * flipped to 'delivered' the instant the transport hands the dispatch off, but only
1496
+ * flipped to 'acked' when the worker's agent:generating_started event arrives (see the
1497
+ * generating_started handler in mesh-event-forwarding) — i.e. when the session has
1498
+ * actually begun the turn. That distinction is the cross-daemon consumption signal the
1499
+ * short-grace re-drive uses: a row whose delivery is 'delivered' but never 'acked' was
1500
+ * handed to a REMOTE worker that never started generating — the remote autoLaunch
1501
+ * delivered≠consumed gap — even when the session's busy verdict is UNKNOWN (not locally
1502
+ * observable). Indexed by (mesh_id, task_id).
1503
+ */
1504
+ taskDeliveryConsumed(meshId: string, taskId: string): boolean {
1505
+ const row = this.db.prepare(`
1506
+ SELECT 1 FROM mesh_session_delivery
1507
+ WHERE mesh_id = ? AND task_id = ? AND status IN ('acked','completed')
1508
+ LIMIT 1
1509
+ `).get(meshId, taskId) as { 1: number } | undefined;
1510
+ return !!row;
1511
+ }
1512
+
1491
1513
  expireStaleSessionDeliveries(meshId: string): void {
1492
1514
  const now = new Date().toISOString();
1493
1515
  this.db.prepare(`
@@ -3,13 +3,13 @@ import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
3
3
  import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
4
4
  import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange } from '../repo-mesh-types.js';
5
5
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
6
- import { getMesh } from '../config/mesh-config.js';
6
+ import { getMesh, getDifficultyBrains } from '../config/mesh-config.js';
7
7
  import { LOG } from '../logging/logger.js';
8
8
  import { appendLedgerEntry } from './mesh-ledger.js';
9
9
  import type { MeshLedgerKind } from './mesh-ledger.js';
10
10
  import { createSessionDelivery } from './mesh-delivery-policy.js';
11
11
  import { isTaskDispatchInFlight, endTaskDispatchInFlight } from './mesh-task-inflight.js';
12
- import { sessionIdsEquivalent } from '@adhdev/mesh-shared';
12
+ import { sessionIdsEquivalent, isMeshTaskDifficulty, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
13
13
 
14
14
  export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
15
15
  export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
@@ -584,6 +584,13 @@ export interface MeshWorkQueueEntry {
584
584
  * that cannot honor the model still runs the task (never a fatal launch error).
585
585
  */
586
586
  model?: string;
587
+ /**
588
+ * BRAIN-ROUTING (thinking axis): standard reasoning level ('low'|'medium'|'high')
589
+ * for the session that executes this task. When the task auto-launches, this is
590
+ * passed to launch_cli as `initialThinkingLevel` (CLI → thinkingLaunchArgs; ACP →
591
+ * setConfigOption('thought_level')). Rides in payload JSON. Best-effort like model.
592
+ */
593
+ thinkingLevel?: string;
587
594
  /**
588
595
  * M1: why this task is held back (e.g. "dependency_failed:<taskId>").
589
596
  * Only set by the system on dependency failure under the 'block' policy;
@@ -862,6 +869,16 @@ export function enqueueTask(
862
869
  consensusGroupId?: string;
863
870
  /** MAGI-KIND-PANEL: model override forwarded to the executing session's launch (initialModel). */
864
871
  model?: string;
872
+ /** BRAIN-ROUTING: standard thinking level forwarded to launch (initialThinkingLevel). */
873
+ thinkingLevel?: string;
874
+ /**
875
+ * BRAIN-ROUTING: task execution difficulty ('easy'|'medium'|'difficult'|
876
+ * 'freeform'). When set, the mesh's difficulty→brain preset fills in model /
877
+ * thinkingLevel that were not passed explicitly (an explicit model/thinkingLevel
878
+ * wins). Purely a convenience resolver — the stored task still carries the
879
+ * resolved model/thinkingLevel, so downstream launch is unchanged.
880
+ */
881
+ difficulty?: string;
865
882
  /** Explicit task id for batch/template flows (M5). Random UUID when omitted. */
866
883
  id?: string;
867
884
  /** (3) Originating coordinator session id (for session-anchored completion routing). */
@@ -881,6 +898,21 @@ export function enqueueTask(
881
898
  const maxRetries = typeof opts?.maxRetries === 'number' && Number.isFinite(opts.maxRetries) && opts.maxRetries >= 0
882
899
  ? Math.floor(opts.maxRetries)
883
900
  : undefined;
901
+ // BRAIN-ROUTING: resolve the difficulty preset into effective model / thinking
902
+ // level. An explicit opts.model / opts.thinkingLevel always wins; the preset only
903
+ // fills what the caller left blank. Best-effort — a missing/invalid difficulty or
904
+ // an unconfigured preset just leaves the explicit values (or none) in place.
905
+ let effectiveModel = typeof opts?.model === 'string' && opts.model.trim() ? opts.model.trim() : undefined;
906
+ let effectiveThinkingLevel = typeof opts?.thinkingLevel === 'string' && opts.thinkingLevel.trim() ? opts.thinkingLevel.trim() : undefined;
907
+ if (isMeshTaskDifficulty(opts?.difficulty)) {
908
+ try {
909
+ const preset = getDifficultyBrains()[opts!.difficulty as MeshTaskDifficulty];
910
+ if (preset) {
911
+ if (!effectiveModel && preset.model) effectiveModel = preset.model;
912
+ if (!effectiveThinkingLevel && preset.thinkingLevel) effectiveThinkingLevel = preset.thinkingLevel;
913
+ }
914
+ } catch { /* preset read is best-effort — never block enqueue */ }
915
+ }
884
916
  const result = withQueueLock(meshId, () => {
885
917
  if (MeshRuntimeStore.getInstance().findQueueEntryById(meshId, id)) {
886
918
  throw new Error(`duplicate_task_id: task '${id}' already exists in mesh '${meshId}'`);
@@ -917,7 +949,8 @@ export function enqueueTask(
917
949
  ...(maxRetries !== undefined ? { maxRetries } : {}),
918
950
  ...(typeof opts?.missionId === 'string' && opts.missionId.trim() ? { missionId: opts.missionId.trim() } : {}),
919
951
  ...(typeof opts?.consensusGroupId === 'string' && opts.consensusGroupId.trim() ? { consensusGroupId: opts.consensusGroupId.trim() } : {}),
920
- ...(typeof opts?.model === 'string' && opts.model.trim() ? { model: opts.model.trim() } : {}),
952
+ ...(effectiveModel ? { model: effectiveModel } : {}),
953
+ ...(effectiveThinkingLevel ? { thinkingLevel: effectiveThinkingLevel } : {}),
921
954
  ...(typeof opts?.sourceCoordinatorSessionId === 'string' && opts.sourceCoordinatorSessionId.trim()
922
955
  ? { sourceCoordinatorSessionId: opts.sourceCoordinatorSessionId.trim() }
923
956
  : {}),
@@ -312,6 +312,7 @@ export class CliProviderInstance implements ProviderInstance {
312
312
  private presentationMode: 'terminal' | 'chat';
313
313
  private providerSessionId?: string;
314
314
  private launchMode: 'new' | 'resume' | 'manual';
315
+ private initialThinkingLevel?: string;
315
316
  private readonly startedAt = Date.now();
316
317
  private onProviderSessionResolved?: (info: {
317
318
  instanceId: string;
@@ -332,6 +333,10 @@ export class CliProviderInstance implements ProviderInstance {
332
333
  providerSessionId?: string;
333
334
  launchMode?: 'new' | 'resume' | 'manual';
334
335
  extraEnv?: Record<string, string>;
336
+ /** BRAIN-ROUTING: standard thinking level to apply post-launch via the
337
+ * provider's thinkingControlId (runtime-control providers like hermes).
338
+ * Providers using thinkingLaunchArgs get it at spawn instead and ignore this. */
339
+ initialThinkingLevel?: string;
335
340
  onProviderSessionResolved?: (info: {
336
341
  instanceId: string;
337
342
  providerType: string;
@@ -347,6 +352,7 @@ export class CliProviderInstance implements ProviderInstance {
347
352
  this.presentationMode = 'chat';
348
353
  this.providerSessionId = options?.providerSessionId;
349
354
  this.launchMode = options?.launchMode || 'new';
355
+ this.initialThinkingLevel = options?.initialThinkingLevel;
350
356
  this.onProviderSessionResolved = options?.onProviderSessionResolved;
351
357
  this.adapter = createCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory) as ProviderCliAdapter;
352
358
  if (this.providerSessionId) {
@@ -392,6 +398,7 @@ export class CliProviderInstance implements ProviderInstance {
392
398
  // PTY spawn
393
399
  await this.adapter.spawn();
394
400
  await this.enforceFreshSessionLaunchIfNeeded();
401
+ await this.applyInitialThinkingLevelViaControl();
395
402
  this.maybeAppendRuntimeRecoveryMessage(this.adapter.getRuntimeMetadata());
396
403
  if (this.providerSessionId && this.shouldHydrateExistingProviderHistory()) {
397
404
  this.restorePersistedHistoryFromCurrentSession();
@@ -1209,6 +1216,45 @@ export class CliProviderInstance implements ProviderInstance {
1209
1216
  this.applyProviderResponse(parsed.payload, { phase: 'immediate' });
1210
1217
  }
1211
1218
 
1219
+ /**
1220
+ * BRAIN-ROUTING (runtime-control thinking axis): for a provider that selects
1221
+ * reasoning effort via a runtime control instead of a launch arg (e.g. hermes
1222
+ * `reasoning`), apply the requested initialThinkingLevel after spawn by invoking
1223
+ * that control's setScript. The provider names the control via thinkingControlId.
1224
+ * The standard level is mapped through thinkingLevelMap first (same as the
1225
+ * launch-arg path). Best-effort: any failure logs and never blocks launch.
1226
+ */
1227
+ private async applyInitialThinkingLevelViaControl(): Promise<void> {
1228
+ const level = typeof this.initialThinkingLevel === 'string' ? this.initialThinkingLevel.trim() : '';
1229
+ if (!level) return;
1230
+ const controlId = (this.provider as any).thinkingControlId;
1231
+ if (!controlId) return; // provider uses thinkingLaunchArgs (or has no support)
1232
+ const controls: any[] = Array.isArray((this.provider as any).controls) ? (this.provider as any).controls : [];
1233
+ const control = controls.find(c => c && c.id === controlId);
1234
+ if (!control || !control.setScript) return;
1235
+ // Map the standard level to the provider's own vocabulary (unchanged if absent).
1236
+ const map = (this.provider as any).thinkingLevelMap as Record<string, string> | undefined;
1237
+ const mapped = (map && typeof map[level] === 'string' && map[level].trim()) ? map[level].trim() : level;
1238
+ try {
1239
+ await waitForCliAdapterReady(this.adapter);
1240
+ const raw = await this.adapter.invokeScript(control.setScript, { value: mapped });
1241
+ const parsed = parseCliScriptResult(raw);
1242
+ if (!parsed.success) {
1243
+ LOG.warn('CLI', `[${this.type}] thinking control '${controlId}' set to '${mapped}' failed: ${parsed.payload?.error || 'unknown'}`);
1244
+ return;
1245
+ }
1246
+ const cliCommand = getCliScriptCommand(parsed.payload);
1247
+ if (cliCommand?.type === 'send_message' && cliCommand.text) {
1248
+ await this.adapter.sendMessage(cliCommand.text);
1249
+ } else if (cliCommand?.type === 'pty_write' && cliCommand.text) {
1250
+ await this.adapter.writeRaw(cliCommand.text + '\r');
1251
+ }
1252
+ LOG.info('CLI', `[${this.type}] applied thinking level '${mapped}' via control '${controlId}'`);
1253
+ } catch (e: any) {
1254
+ LOG.warn('CLI', `[${this.type}] thinking control apply threw: ${e?.message || e}`);
1255
+ }
1256
+ }
1257
+
1212
1258
  private completionHasFinalAssistantMessage(messages: unknown, turnStartedAt?: number): boolean {
1213
1259
  const visibleMessages = (Array.isArray(messages) ? messages : [])
1214
1260
  .filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
@@ -604,6 +604,53 @@ export interface ProviderModule {
604
604
  * request never fails a launch). Absent → no launch-time model selection for CLI.
605
605
  */
606
606
  modelLaunchArgs?: string[];
607
+ /**
608
+ * BRAIN-ROUTING (model axis): suggested model values for this provider, surfaced
609
+ * as dropdown options in the new-session dialog (e.g. claude ['opus','sonnet',
610
+ * 'haiku']; codex ['gpt-5.5','gpt-5-codex']). Advisory only — the UI allows free
611
+ * text too, so the list going stale never blocks a model the provider accepts.
612
+ */
613
+ modelOptions?: string[];
614
+ /**
615
+ * BRAIN-ROUTING (thinking axis): template for expanding an `initialThinkingLevel`
616
+ * selection into launch args for a CLI provider, parallel to modelLaunchArgs.
617
+ * `{{level}}` is substituted with the provider-appropriate reasoning-effort value
618
+ * (already mapped from the standard low|medium|high level, see thinkingLevelMap).
619
+ * Examples: claude-cli `['--effort', '{{level}}']` → `--effort high`; codex-cli
620
+ * `['-c', 'model_reasoning_effort={{level}}']`. Applied at session launch when
621
+ * `initialThinkingLevel` is passed AND this provider is a plain CLI. A CLI provider
622
+ * with no template silently ignores the thinking level (best-effort; never fails a
623
+ * launch). ACP providers instead route thinking through setConfigOption('thought_level').
624
+ */
625
+ thinkingLaunchArgs?: string[];
626
+ /**
627
+ * BRAIN-ROUTING (thinking axis): optional per-provider mapping from the standard
628
+ * thinking levels (`low`|`medium`|`high`) to this provider's own reasoning-effort
629
+ * vocabulary, used to fill `{{level}}` in thinkingLaunchArgs. e.g. claude-cli might
630
+ * map `{ high: 'max' }`; codex-cli `{ high: 'xhigh' }`. A level absent from the map
631
+ * passes through unchanged (so `medium` → `medium` by default).
632
+ */
633
+ thinkingLevelMap?: Partial<Record<'low' | 'medium' | 'high', string>>;
634
+ /**
635
+ * BRAIN-ROUTING (thinking axis): the reasoning-effort values this provider actually
636
+ * accepts, surfaced as the thinking-level dropdown options in the new-session
637
+ * dialog (e.g. claude ['low','medium','high','max']; codex ['minimal','low',
638
+ * 'medium','high','xhigh']). Absent → the UI falls back to the standard
639
+ * low/medium/high. These are the provider's OWN vocabulary and are passed through
640
+ * verbatim as initialThinkingLevel (not remapped by thinkingLevelMap, which only
641
+ * translates the mesh's standard low/medium/high presets).
642
+ */
643
+ thinkingLevelOptions?: string[];
644
+ /**
645
+ * BRAIN-ROUTING (thinking axis, runtime-control providers): the `controls[].id`
646
+ * of a runtime reasoning-effort control to drive for the thinking level when the
647
+ * provider has no `thinkingLaunchArgs` (e.g. hermes-cli's `reasoning` select,
648
+ * which types `/reasoning <level>` into the PTY via its setScript). At launch,
649
+ * initialThinkingLevel (after thinkingLevelMap) is applied by invoking that
650
+ * control's setScript with `{ value: <level> }`. Ignored if the id doesn't match a
651
+ * control. Providers that use thinkingLaunchArgs don't need this.
652
+ */
653
+ thinkingControlId?: string;
607
654
  /** Delay before submitting typed CLI input (provider-specific TUI tuning) */
608
655
  sendDelayMs?: number;
609
656
  /** Submit key used after typing into CLI PTY (default: carriage return) */
@@ -66,6 +66,12 @@ const KNOWN_PROVIDER_FIELDS = new Set<string>([
66
66
  'providerVersion',
67
67
  'status',
68
68
  'details',
69
+ 'modelLaunchArgs',
70
+ 'modelOptions',
71
+ 'thinkingLaunchArgs',
72
+ 'thinkingLevelMap',
73
+ 'thinkingLevelOptions',
74
+ 'thinkingControlId',
69
75
  'sendDelayMs',
70
76
  'sendKey',
71
77
  'submitStrategy',
@@ -129,6 +129,35 @@
129
129
  "items": { "type": "string" },
130
130
  "description": "Template for expanding an initialModel selection into launch args. '{{model}}' is substituted with the model string (e.g. ['--model', '{{model}}'] → --model opus). Applied at launch when a model is requested for this CLI provider (MAGI kind-panel model axis). Absent → no launch-time model selection."
131
131
  },
132
+ "modelOptions": {
133
+ "type": "array",
134
+ "items": { "type": "string" },
135
+ "description": "Suggested model values shown as dropdown options in the new-session dialog (brain-routing model axis), e.g. ['opus','sonnet','haiku']. Advisory — the UI still accepts free text, so a stale list never blocks an accepted model."
136
+ },
137
+ "thinkingLaunchArgs": {
138
+ "type": "array",
139
+ "items": { "type": "string" },
140
+ "description": "Template for expanding an initialThinkingLevel selection into launch args (brain-routing thinking axis, parallel to modelLaunchArgs). '{{level}}' is substituted with the provider-mapped reasoning-effort value (e.g. ['--effort', '{{level}}'] → --effort high; ['-c', 'model_reasoning_effort={{level}}']). Applied at launch when a thinking level is requested. Absent → no launch-time thinking selection."
141
+ },
142
+ "thinkingLevelMap": {
143
+ "type": "object",
144
+ "properties": {
145
+ "low": { "type": "string" },
146
+ "medium": { "type": "string" },
147
+ "high": { "type": "string" }
148
+ },
149
+ "additionalProperties": false,
150
+ "description": "Optional map from the standard thinking levels (low/medium/high) to this provider's own reasoning-effort vocabulary, used to fill {{level}} in thinkingLaunchArgs. A level absent from the map passes through unchanged."
151
+ },
152
+ "thinkingLevelOptions": {
153
+ "type": "array",
154
+ "items": { "type": "string" },
155
+ "description": "Reasoning-effort values this provider accepts, shown as the thinking-level dropdown in the new-session dialog (e.g. ['low','medium','high','max']). Absent → the UI falls back to standard low/medium/high. Provider's own vocabulary, passed through verbatim."
156
+ },
157
+ "thinkingControlId": {
158
+ "type": "string",
159
+ "description": "For a provider with no thinkingLaunchArgs but a runtime reasoning-effort control (e.g. hermes 'reasoning'), the controls[].id to drive at launch for the thinking level. The control's setScript is invoked with { value: <mapped level> }."
160
+ },
132
161
  "scriptCallBudgetMs": {
133
162
  "type": "integer",
134
163
  "minimum": 1,
@@ -14,7 +14,7 @@
14
14
  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
- import type { MagiKindPanelMap } from '@adhdev/mesh-shared';
17
+ import type { MagiKindPanelMap, DifficultyBrainMap } from '@adhdev/mesh-shared';
18
18
 
19
19
  // ─── Core Mesh Types ────────────────────────────
20
20
 
@@ -822,6 +822,15 @@ export interface LocalMeshConfig {
822
822
  * Optional; absent on pre-feature configs.
823
823
  */
824
824
  magiKindPanels?: MagiKindPanelMap;
825
+ /**
826
+ * BRAIN-ROUTING: per-task-difficulty brain presets (machine-local), sibling of
827
+ * magiKindPanels. Keyed by difficulty (easy / medium / difficult / freeform);
828
+ * each maps to a BrainSlot (provider? / model? / thinkingLevel?). The coordinator
829
+ * classifies a task's difficulty at enqueue; the matching preset fills in the
830
+ * task's model / thinking level (an explicit task value wins). Optional; a mesh
831
+ * with none seeded uses DEFAULT_DIFFICULTY_BRAINS on first read.
832
+ */
833
+ difficultyBrains?: DifficultyBrainMap;
825
834
  }
826
835
 
827
836
  export interface LocalMeshEntry {
@@ -555,6 +555,10 @@ export interface AvailableProviderInfo {
555
555
  lastVerification?: MachineProviderCheckResult;
556
556
  /** Provider-declared Repo Mesh coordinator/MCP behavior. */
557
557
  meshCoordinator?: ProviderMeshCoordinatorConfig;
558
+ /** BRAIN-ROUTING: suggested model values for the new-session model dropdown. */
559
+ modelOptions?: string[];
560
+ /** BRAIN-ROUTING: reasoning-effort values for the new-session thinking dropdown. */
561
+ thinkingLevelOptions?: string[];
558
562
  /**
559
563
  * Provider trust classification — derived from the on-disk layer the
560
564
  * manifest came from and the shape of the manifest. Dashboards use
@@ -153,6 +153,8 @@ function buildAvailableProviders(
153
153
  status?: string;
154
154
  details?: string;
155
155
  links?: Record<string, string>;
156
+ modelOptions?: string[];
157
+ thinkingLevelOptions?: string[];
156
158
  }> = providerLoader.getAvailableProviderInfos?.() || providerLoader.getAll();
157
159
  // Trust helpers come from daemon-core; resolve them lazily so the
158
160
  // status snapshot path stays loadable in older bundles that don't
@@ -189,6 +191,8 @@ function buildAvailableProviders(
189
191
  ...(sourceLayer ? { sourceLayer } : {}),
190
192
  ...(sourceName ? { sourceName } : {}),
191
193
  ...(provider.providerVersion ? { providerVersion: provider.providerVersion } : {}),
194
+ ...(Array.isArray(provider.modelOptions) && provider.modelOptions.length ? { modelOptions: provider.modelOptions } : {}),
195
+ ...(Array.isArray(provider.thinkingLevelOptions) && provider.thinkingLevelOptions.length ? { thinkingLevelOptions: provider.thinkingLevelOptions } : {}),
192
196
  ...(provider.binary ? { binary: provider.binary } : {}),
193
197
  ...(provider.status ? { status: provider.status } : {}),
194
198
  ...(provider.details ? { details: provider.details } : {}),