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

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.
@@ -75,6 +75,18 @@ export interface PendingMeshCoordinatorEvent {
75
75
  dispatchedBy?: CoordinatorIdentity;
76
76
  /** Present only for unicast scope: the coordinator this event is addressed to. */
77
77
  intendedFor?: CoordinatorIdentity;
78
+ /**
79
+ * True when this event was stamped as a broadcast SOLELY because no owning
80
+ * coordinator identity was resolvable at emit time (self-fallback: dispatchedBy
81
+ * is THIS daemon's own machineId, not a real coordinator). Such a broadcast has
82
+ * no owner, so the MAGI-REPLICA-COMPLETION-EVENT-LEAK guard — which only exists
83
+ * to stop a NON-owner coordinator from consuming an OWNED terminal event — must
84
+ * not apply: an ownerless terminal broadcast is a genuine "deliver to any
85
+ * coordinator that drains on this machine" event and identity-matching its
86
+ * self-id dispatchedBy against the drainer would wrongly route it away.
87
+ * Absent (undefined/false) on a normally-owned event → the leak guard applies.
88
+ */
89
+ dispatchedBySelfFallback?: boolean;
78
90
  }
79
91
 
80
92
  /**
@@ -419,7 +431,21 @@ function routeV2EventsForDrainer(
419
431
  // matching semantics as unicast (identityDeliversTo), so the true owner —
420
432
  // possibly addressed under a different daemon-id form — still receives it.
421
433
  if (validated.scope === 'broadcast' && isTerminalTaskEvent(validated.event)) {
422
- if (identityDeliversTo(validated.dispatchedBy, drainer)) {
434
+ // An ownerless self-fallback broadcast (dispatchedBy is this daemon's
435
+ // own machineId because no coordinator identity existed at emit) has no
436
+ // coordinator owner — but it must still stay on ITS machine: a replica
437
+ // completion emitted on machine A must never fan out to a coordinator on
438
+ // machine B (the MAGI-REPLICA leak). So for a self-fallback event, match
439
+ // at the MACHINE (daemonId) level — deliver iff the drainer is on the
440
+ // same machine as the self-dispatcher — instead of the full
441
+ // identityDeliversTo (which also compares runId/session and would route
442
+ // the event away from a same-machine coordinator whose id form differs,
443
+ // the exact symptom for refine:* / agent:generating_completed reaching a
444
+ // stdio MCP coordinator). Non-self-fallback broadcasts keep the strict
445
+ // owner check.
446
+ const deliverSelfFallback = event.dispatchedBySelfFallback
447
+ && daemonIdsEquivalent(validated.dispatchedBy.daemonId, drainer.daemonId);
448
+ if (deliverSelfFallback || identityDeliversTo(validated.dispatchedBy, drainer)) {
423
449
  ctx.batchSeen.add(eventId);
424
450
  bump('v2Delivered');
425
451
  kept.push(event);
@@ -858,6 +884,13 @@ export function stampPendingEventV2(
858
884
  });
859
885
  if (!stamp) return event; // no coordinator identity at all (no self id) → stays a v1 event
860
886
 
887
+ // Mark an ownerless (self-fallback) broadcast so the drain-side leak guard can
888
+ // tell it apart from a genuinely owned broadcast terminal event. selfFallback is
889
+ // true only when no coordinator identity existed and we minted the stamp under
890
+ // this daemon's own machineId; a broadcast that stays broadcast for that reason
891
+ // has no owner to leak from and must reach whatever coordinator drains here.
892
+ const dispatchedBySelfFallback = selfFallback && stamp.scope === 'broadcast';
893
+
861
894
  return {
862
895
  ...event,
863
896
  protocolVersion: stamp.protocolVersion,
@@ -865,6 +898,7 @@ export function stampPendingEventV2(
865
898
  scope: stamp.scope,
866
899
  dispatchedBy: stamp.dispatchedBy,
867
900
  ...(stamp.intendedFor ? { intendedFor: stamp.intendedFor } : {}),
901
+ ...(dispatchedBySelfFallback ? { dispatchedBySelfFallback: true } : {}),
868
902
  };
869
903
  }
870
904
 
@@ -76,17 +76,84 @@ export function getMeshWithCache(components: DaemonComponents, meshId: string):
76
76
  *
77
77
  * Fix: union the local-config nodes with any inline-cache-ONLY nodes, so the claim
78
78
  * view matches the command (send_task) view. Base (non-worktree) nodes present in
79
- * local config stay config-authoritative — their entry is taken verbatim from
80
- * localMesh, so base node claim/matching is byte-for-byte unchanged. Only nodes
79
+ * local config stay config-authoritative — their STATIC fields are taken verbatim
80
+ * from localMesh, so base node claim/matching is byte-for-byte unchanged. Only nodes
81
81
  * that exist solely in the inline cache (the cloned worktree nodes) are appended.
82
82
  * Identity comparison uses the shared 3-form normalizer (id / nodeId / node_id),
83
83
  * identical to every other claim-path consumer — the matching logic is untouched,
84
84
  * only which nodes are visible.
85
+ *
86
+ * BOOTSTRAP-DEFER VIEW-CONSISTENCY (this fix): for a worktree node that IS registered
87
+ * in local config, the union previously took the config node verbatim and discarded the
88
+ * inline-cache entry entirely. But the inline cache holds the FRESHER runtime bootstrap
89
+ * state — markWorktreeBootstrapTerminalState stamps worktreeBootstrap.status='complete'
90
+ * synchronously into the inline cache, while local config lags behind the detached async
91
+ * persist chain (and on the coordinator may never receive it at all). A config-registered
92
+ * worktree node therefore read a permanently stale 'running' here, so
93
+ * shouldDeferDispatchForBootstrap deferred its claim forever. We now MERGE the inline
94
+ * cache's dynamic runtime bootstrap state onto the config node (config keeps its static
95
+ * fields; worktreeBootstrap is preferred from the inline cache) so EVERY consumer of the
96
+ * merged view — not just tryAssignQueueTask's gate — observes the terminal stamp.
97
+ *
98
+ * RESIDUAL-getMeshWithCache-bootstrap-overlay (precedence guard): the overlay is DIRECTIONAL —
99
+ * it prefers the inline entry ONLY when the inline runtime state is actually fresher, never
100
+ * merely because the inline entry carries a status. inlineBootstrapIsFresher() (below) permits
101
+ * the overlay in exactly two cases, mirroring the mission's "terminal OR strictly newer" rule:
102
+ * (1) the inline state is TERMINAL ('complete'/'failed') while the config state is NOT — the
103
+ * markWorktreeBootstrapTerminalState synchronous stamp the async config persist has not
104
+ * yet caught up to; this is the whole point of the overlay (opens the gate).
105
+ * (2) both states are non-terminal but the inline startedAt is STRICTLY newer — a re-driven
106
+ * bootstrap whose fresher 'running' epoch the config has not observed.
107
+ * It REFUSES the overlay when the config state is already terminal and the inline state is a
108
+ * stale/non-terminal 'running' — otherwise a stale inline 'running' would MASK a genuinely
109
+ * complete config node and re-defer its claim forever (the exact anti-case this guard closes).
110
+ * And when both are 'running' with no newer epoch, the config value is kept and the gate still
111
+ * defers — the half-built-worktree → empty-session defense is preserved: only a terminal-confirmed
112
+ * inline state, never an ambiguous read, ever opens the gate.
113
+ */
114
+ const BOOTSTRAP_TERMINAL_STATUSES = new Set(['complete', 'failed']);
115
+
116
+ function bootstrapEpochMs(bootstrap: any): number {
117
+ const raw = readNonEmptyString(bootstrap?.startedAt) || readNonEmptyString(bootstrap?.completedAt);
118
+ if (!raw) return 0;
119
+ const parsed = Date.parse(raw);
120
+ return Number.isFinite(parsed) ? parsed : 0;
121
+ }
122
+
123
+ /**
124
+ * Directional freshness test for the bootstrap overlay: may the inline runtime state
125
+ * REPLACE the config runtime state? True only when the inline state is terminal and the
126
+ * config state is not (the synchronous terminal stamp the async persist lags), or when
127
+ * both are non-terminal but the inline epoch is strictly newer. A terminal config state is
128
+ * never overwritten by a non-terminal inline read (the stale-'running'-masks-complete
129
+ * anti-case), and equal states never trigger a rewrite.
85
130
  */
131
+ function inlineBootstrapIsFresher(inlineBootstrap: any, configBootstrap: any): boolean {
132
+ const inlineStatus = readNonEmptyString(inlineBootstrap?.status);
133
+ if (!inlineStatus) return false;
134
+ const configStatus = readNonEmptyString(configBootstrap?.status);
135
+ const inlineTerminal = BOOTSTRAP_TERMINAL_STATUSES.has(inlineStatus);
136
+ const configTerminal = !!configStatus && BOOTSTRAP_TERMINAL_STATUSES.has(configStatus);
137
+ // Config already terminal: only a DIFFERENT terminal inline state (e.g. config 'complete'
138
+ // vs a later 'failed' re-drive) may supersede it; a non-terminal inline read must never
139
+ // mask a terminal config state.
140
+ if (configTerminal) {
141
+ return inlineTerminal && inlineStatus !== configStatus
142
+ && bootstrapEpochMs(inlineBootstrap) > bootstrapEpochMs(configBootstrap);
143
+ }
144
+ // Config not terminal: an inline terminal state is always fresher (opens the gate).
145
+ if (inlineTerminal) return true;
146
+ // Both non-terminal: prefer inline only when its epoch is strictly newer (a re-driven
147
+ // bootstrap the config has not observed). Equal/older ⇒ keep config, gate still defers.
148
+ return bootstrapEpochMs(inlineBootstrap) > bootstrapEpochMs(configBootstrap);
149
+ }
150
+
86
151
  function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
87
152
  const localNodes = Array.isArray(localMesh?.nodes) ? localMesh.nodes : [];
88
153
  const cachedNodes = Array.isArray(cachedMesh?.nodes) ? cachedMesh.nodes : [];
89
154
  if (!cachedNodes.length) return localMesh;
155
+ // Index inline-cache nodes by identity so we can (a) append cache-only nodes and
156
+ // (b) prefer the inline runtime bootstrap state on config-registered nodes.
90
157
  const cacheOnly = cachedNodes.filter((cachedNode: any) => {
91
158
  const cachedId = readMeshNodeId(cachedNode);
92
159
  // Unidentifiable cache entries can never be a claim/route target — skip them
@@ -94,8 +161,29 @@ function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
94
161
  if (!cachedId) return false;
95
162
  return !localNodes.some((localNode: any) => meshNodeIdMatches(localNode, cachedId));
96
163
  });
97
- if (!cacheOnly.length) return localMesh;
98
- return { ...localMesh, nodes: [...localNodes, ...cacheOnly] };
164
+ // Overlay the inline cache's fresher worktreeBootstrap state onto any config node that
165
+ // also exists in the inline cache. inlineBootstrapIsFresher() gates the overlay to the
166
+ // "terminal OR strictly newer" cases, so a stale inline 'running' can never mask a
167
+ // terminal config state and the gate's deferral is preserved for a genuine 'running'.
168
+ let overlaidLocalNodes: any[] = localNodes;
169
+ let overlaid = false;
170
+ for (let i = 0; i < localNodes.length; i++) {
171
+ const localNode = localNodes[i];
172
+ const localId = readMeshNodeId(localNode);
173
+ if (!localId) continue;
174
+ const inlineMatch = cachedNodes.find((cachedNode: any) => meshNodeIdMatches(cachedNode, localId));
175
+ if (!inlineMatch) continue;
176
+ if (!inlineBootstrapIsFresher(inlineMatch.worktreeBootstrap, localNode.worktreeBootstrap)) continue;
177
+ if (!overlaid) {
178
+ overlaidLocalNodes = [...localNodes];
179
+ overlaid = true;
180
+ }
181
+ // Keep the config node's static fields; overlay only the dynamic worktreeBootstrap
182
+ // runtime substate (fresher terminal stamp / epoch) — config identity is unchanged.
183
+ overlaidLocalNodes[i] = { ...localNode, worktreeBootstrap: inlineMatch.worktreeBootstrap };
184
+ }
185
+ if (!cacheOnly.length && !overlaid) return localMesh;
186
+ return { ...localMesh, nodes: [...overlaidLocalNodes, ...cacheOnly] };
99
187
  }
100
188
 
101
189
  // ---------------------------------------------------------------------------
@@ -1880,6 +1968,8 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1880
1968
  // MAGI-KIND-PANEL model axis: forward the task's model override so the
1881
1969
  // remote worker session launches with it (initialModel). Best-effort.
1882
1970
  ...(typeof task.model === 'string' && task.model.trim() ? { initialModel: task.model.trim() } : {}),
1971
+ // BRAIN-ROUTING thinking axis: forward the task's thinking level (initialThinkingLevel).
1972
+ ...(typeof task.thinkingLevel === 'string' && task.thinkingLevel.trim() ? { initialThinkingLevel: task.thinkingLevel.trim() } : {}),
1883
1973
  });
1884
1974
  } catch (e: any) {
1885
1975
  markAutoLaunch(meshId, task.id, { status: 'failed', reason: `remote_launch_dispatch_failed: ${e?.message || String(e)}`, nodeId, providerType: resolved.providerType });
@@ -1912,6 +2002,8 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1912
2002
  // MAGI-KIND-PANEL model axis: local launch forwards the task's model
1913
2003
  // override as initialModel (CLI → modelLaunchArgs; ACP → setConfigOption).
1914
2004
  ...(typeof task.model === 'string' && task.model.trim() ? { initialModel: task.model.trim() } : {}),
2005
+ // BRAIN-ROUTING thinking axis: forward the task's thinking level (initialThinkingLevel).
2006
+ ...(typeof task.thinkingLevel === 'string' && task.thinkingLevel.trim() ? { initialThinkingLevel: task.thinkingLevel.trim() } : {}),
1915
2007
  });
1916
2008
  if (!launchResult?.success) {
1917
2009
  const reason = launchResult?.error || 'launch_cli_failed';
@@ -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 } : {}),