@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.
@@ -34,6 +34,18 @@ export interface PendingMeshCoordinatorEvent {
34
34
  dispatchedBy?: CoordinatorIdentity;
35
35
  /** Present only for unicast scope: the coordinator this event is addressed to. */
36
36
  intendedFor?: CoordinatorIdentity;
37
+ /**
38
+ * True when this event was stamped as a broadcast SOLELY because no owning
39
+ * coordinator identity was resolvable at emit time (self-fallback: dispatchedBy
40
+ * is THIS daemon's own machineId, not a real coordinator). Such a broadcast has
41
+ * no owner, so the MAGI-REPLICA-COMPLETION-EVENT-LEAK guard — which only exists
42
+ * to stop a NON-owner coordinator from consuming an OWNED terminal event — must
43
+ * not apply: an ownerless terminal broadcast is a genuine "deliver to any
44
+ * coordinator that drains on this machine" event and identity-matching its
45
+ * self-id dispatchedBy against the drainer would wrongly route it away.
46
+ * Absent (undefined/false) on a normally-owned event → the leak guard applies.
47
+ */
48
+ dispatchedBySelfFallback?: boolean;
37
49
  }
38
50
  /**
39
51
  * Optional emit-time hint passed to queuePendingMeshCoordinatorEvent so a call
@@ -239,6 +239,20 @@ export declare class MeshRuntimeStore {
239
239
  * watchdog's). Indexed by (mesh_id, task_id).
240
240
  */
241
241
  taskHasConfirmedDelivery(meshId: string, taskId: string): boolean;
242
+ /**
243
+ * DELIVERED-NOT-CONSUMED re-drive support: true when at least one delivery record for
244
+ * the task has reached a CONSUMED status ('acked' / 'completed'). Distinct from
245
+ * {@link taskHasConfirmedDelivery} ('delivered' | 'acked' | 'completed'): a delivery is
246
+ * flipped to 'delivered' the instant the transport hands the dispatch off, but only
247
+ * flipped to 'acked' when the worker's agent:generating_started event arrives (see the
248
+ * generating_started handler in mesh-event-forwarding) — i.e. when the session has
249
+ * actually begun the turn. That distinction is the cross-daemon consumption signal the
250
+ * short-grace re-drive uses: a row whose delivery is 'delivered' but never 'acked' was
251
+ * handed to a REMOTE worker that never started generating — the remote autoLaunch
252
+ * delivered≠consumed gap — even when the session's busy verdict is UNKNOWN (not locally
253
+ * observable). Indexed by (mesh_id, task_id).
254
+ */
255
+ taskDeliveryConsumed(meshId: string, taskId: string): boolean;
242
256
  expireStaleSessionDeliveries(meshId: string): void;
243
257
  deleteSessionDeliveries(meshId: string): void;
244
258
  /**
@@ -119,6 +119,13 @@ export interface MeshWorkQueueEntry {
119
119
  * that cannot honor the model still runs the task (never a fatal launch error).
120
120
  */
121
121
  model?: string;
122
+ /**
123
+ * BRAIN-ROUTING (thinking axis): standard reasoning level ('low'|'medium'|'high')
124
+ * for the session that executes this task. When the task auto-launches, this is
125
+ * passed to launch_cli as `initialThinkingLevel` (CLI → thinkingLaunchArgs; ACP →
126
+ * setConfigOption('thought_level')). Rides in payload JSON. Best-effort like model.
127
+ */
128
+ thinkingLevel?: string;
122
129
  /**
123
130
  * M1: why this task is held back (e.g. "dependency_failed:<taskId>").
124
131
  * Only set by the system on dependency failure under the 'block' policy;
@@ -241,6 +248,16 @@ export declare function enqueueTask(meshId: string, message: string, opts?: {
241
248
  consensusGroupId?: string;
242
249
  /** MAGI-KIND-PANEL: model override forwarded to the executing session's launch (initialModel). */
243
250
  model?: string;
251
+ /** BRAIN-ROUTING: standard thinking level forwarded to launch (initialThinkingLevel). */
252
+ thinkingLevel?: string;
253
+ /**
254
+ * BRAIN-ROUTING: task execution difficulty ('easy'|'medium'|'difficult'|
255
+ * 'freeform'). When set, the mesh's difficulty→brain preset fills in model /
256
+ * thinkingLevel that were not passed explicitly (an explicit model/thinkingLevel
257
+ * wins). Purely a convenience resolver — the stored task still carries the
258
+ * resolved model/thinkingLevel, so downstream launch is unchanged.
259
+ */
260
+ difficulty?: string;
244
261
  /** Explicit task id for batch/template flows (M5). Random UUID when omitted. */
245
262
  id?: string;
246
263
  /** (3) Originating coordinator session id (for session-anchored completion routing). */
@@ -155,12 +155,17 @@ export declare class CliProviderInstance implements ProviderInstance {
155
155
  private presentationMode;
156
156
  private providerSessionId?;
157
157
  private launchMode;
158
+ private initialThinkingLevel?;
158
159
  private readonly startedAt;
159
160
  private onProviderSessionResolved?;
160
161
  constructor(provider: ProviderModule, workingDir: string, cliArgs?: string[], instanceId?: string, transportFactory?: PtyTransportFactory, options?: {
161
162
  providerSessionId?: string;
162
163
  launchMode?: 'new' | 'resume' | 'manual';
163
164
  extraEnv?: Record<string, string>;
165
+ /** BRAIN-ROUTING: standard thinking level to apply post-launch via the
166
+ * provider's thinkingControlId (runtime-control providers like hermes).
167
+ * Providers using thinkingLaunchArgs get it at spawn instead and ignore this. */
168
+ initialThinkingLevel?: string;
164
169
  onProviderSessionResolved?: (info: {
165
170
  instanceId: string;
166
171
  providerType: string;
@@ -321,6 +326,15 @@ export declare class CliProviderInstance implements ProviderInstance {
321
326
  */
322
327
  private lastCompletionSummary;
323
328
  private enforceFreshSessionLaunchIfNeeded;
329
+ /**
330
+ * BRAIN-ROUTING (runtime-control thinking axis): for a provider that selects
331
+ * reasoning effort via a runtime control instead of a launch arg (e.g. hermes
332
+ * `reasoning`), apply the requested initialThinkingLevel after spawn by invoking
333
+ * that control's setScript. The provider names the control via thinkingControlId.
334
+ * The standard level is mapped through thinkingLevelMap first (same as the
335
+ * launch-arg path). Best-effort: any failure logs and never blocks launch.
336
+ */
337
+ private applyInitialThinkingLevelViaControl;
324
338
  private completionHasFinalAssistantMessage;
325
339
  private recordPendingTranscriptProbe;
326
340
  /**
@@ -499,6 +499,53 @@ export interface ProviderModule {
499
499
  * request never fails a launch). Absent → no launch-time model selection for CLI.
500
500
  */
501
501
  modelLaunchArgs?: string[];
502
+ /**
503
+ * BRAIN-ROUTING (model axis): suggested model values for this provider, surfaced
504
+ * as dropdown options in the new-session dialog (e.g. claude ['opus','sonnet',
505
+ * 'haiku']; codex ['gpt-5.5','gpt-5-codex']). Advisory only — the UI allows free
506
+ * text too, so the list going stale never blocks a model the provider accepts.
507
+ */
508
+ modelOptions?: string[];
509
+ /**
510
+ * BRAIN-ROUTING (thinking axis): template for expanding an `initialThinkingLevel`
511
+ * selection into launch args for a CLI provider, parallel to modelLaunchArgs.
512
+ * `{{level}}` is substituted with the provider-appropriate reasoning-effort value
513
+ * (already mapped from the standard low|medium|high level, see thinkingLevelMap).
514
+ * Examples: claude-cli `['--effort', '{{level}}']` → `--effort high`; codex-cli
515
+ * `['-c', 'model_reasoning_effort={{level}}']`. Applied at session launch when
516
+ * `initialThinkingLevel` is passed AND this provider is a plain CLI. A CLI provider
517
+ * with no template silently ignores the thinking level (best-effort; never fails a
518
+ * launch). ACP providers instead route thinking through setConfigOption('thought_level').
519
+ */
520
+ thinkingLaunchArgs?: string[];
521
+ /**
522
+ * BRAIN-ROUTING (thinking axis): optional per-provider mapping from the standard
523
+ * thinking levels (`low`|`medium`|`high`) to this provider's own reasoning-effort
524
+ * vocabulary, used to fill `{{level}}` in thinkingLaunchArgs. e.g. claude-cli might
525
+ * map `{ high: 'max' }`; codex-cli `{ high: 'xhigh' }`. A level absent from the map
526
+ * passes through unchanged (so `medium` → `medium` by default).
527
+ */
528
+ thinkingLevelMap?: Partial<Record<'low' | 'medium' | 'high', string>>;
529
+ /**
530
+ * BRAIN-ROUTING (thinking axis): the reasoning-effort values this provider actually
531
+ * accepts, surfaced as the thinking-level dropdown options in the new-session
532
+ * dialog (e.g. claude ['low','medium','high','max']; codex ['minimal','low',
533
+ * 'medium','high','xhigh']). Absent → the UI falls back to the standard
534
+ * low/medium/high. These are the provider's OWN vocabulary and are passed through
535
+ * verbatim as initialThinkingLevel (not remapped by thinkingLevelMap, which only
536
+ * translates the mesh's standard low/medium/high presets).
537
+ */
538
+ thinkingLevelOptions?: string[];
539
+ /**
540
+ * BRAIN-ROUTING (thinking axis, runtime-control providers): the `controls[].id`
541
+ * of a runtime reasoning-effort control to drive for the thinking level when the
542
+ * provider has no `thinkingLaunchArgs` (e.g. hermes-cli's `reasoning` select,
543
+ * which types `/reasoning <level>` into the PTY via its setScript). At launch,
544
+ * initialThinkingLevel (after thinkingLevelMap) is applied by invoking that
545
+ * control's setScript with `{ value: <level> }`. Ignored if the id doesn't match a
546
+ * control. Providers that use thinkingLaunchArgs don't need this.
547
+ */
548
+ thinkingControlId?: string;
502
549
  /** Delay before submitting typed CLI input (provider-specific TUI tuning) */
503
550
  sendDelayMs?: number;
504
551
  /** Submit key used after typing into CLI PTY (default: carriage return) */
@@ -13,7 +13,7 @@
13
13
  import type { GitRepoStatus, GitCompactSummary } from './git/git-types.js';
14
14
  import type { MeshMissionSummary, MeshMissionSlimSummary } from './mesh/mesh-missions.js';
15
15
  import type { MeshMagiActivitySummary } from './mesh/mesh-magi-status.js';
16
- import type { MagiKindPanelMap } from '@adhdev/mesh-shared';
16
+ import type { MagiKindPanelMap, DifficultyBrainMap } from '@adhdev/mesh-shared';
17
17
  export interface RepoMesh {
18
18
  id: string;
19
19
  name: string;
@@ -555,6 +555,15 @@ export interface LocalMeshConfig {
555
555
  * Optional; absent on pre-feature configs.
556
556
  */
557
557
  magiKindPanels?: MagiKindPanelMap;
558
+ /**
559
+ * BRAIN-ROUTING: per-task-difficulty brain presets (machine-local), sibling of
560
+ * magiKindPanels. Keyed by difficulty (easy / medium / difficult / freeform);
561
+ * each maps to a BrainSlot (provider? / model? / thinkingLevel?). The coordinator
562
+ * classifies a task's difficulty at enqueue; the matching preset fills in the
563
+ * task's model / thinking level (an explicit task value wins). Optional; a mesh
564
+ * with none seeded uses DEFAULT_DIFFICULTY_BRAINS on first read.
565
+ */
566
+ difficultyBrains?: DifficultyBrainMap;
558
567
  }
559
568
  export interface LocalMeshEntry {
560
569
  id: string;
@@ -420,6 +420,10 @@ export interface AvailableProviderInfo {
420
420
  lastVerification?: MachineProviderCheckResult;
421
421
  /** Provider-declared Repo Mesh coordinator/MCP behavior. */
422
422
  meshCoordinator?: ProviderMeshCoordinatorConfig;
423
+ /** BRAIN-ROUTING: suggested model values for the new-session model dropdown. */
424
+ modelOptions?: string[];
425
+ /** BRAIN-ROUTING: reasoning-effort values for the new-session thinking dropdown. */
426
+ thinkingLevelOptions?: string[];
423
427
  /**
424
428
  * Provider trust classification — derived from the on-disk layer the
425
429
  * manifest came from and the shape of the manifest. Dashboards use
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.483",
3
+ "version": "0.9.82-rc.485",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "0.9.82-rc.483",
51
- "@adhdev/session-host-core": "0.9.82-rc.483",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.485",
51
+ "@adhdev/session-host-core": "0.9.82-rc.485",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -208,6 +208,13 @@ export class ProviderCliAdapter implements CliAdapter {
208
208
  private lastScreenSnapshot = '';
209
209
  private lastScreenText = '';
210
210
  private lastScreenSnapshotReadAt = Number.NEGATIVE_INFINITY;
211
+ // (FALSEIDLE Path-C) Count of CONSECUTIVE getStatus polls that observed a
212
+ // gate-eligible static-idle screen (detect=idle, no modal, quiet, empty
213
+ // partial buffer). For a mesh/autonomous worker we require several such
214
+ // polls in a row before confirming static-idle (see getStatus), so a
215
+ // single momentarily-silent point-sample of a still-live turn cannot flip
216
+ // it. Reset to 0 the instant any poll is ineligible.
217
+ private staticIdlePollStreak = 0;
211
218
 
212
219
  // Server log forwarding
213
220
  private serverConn: any = null;
@@ -287,6 +294,13 @@ export class ProviderCliAdapter implements CliAdapter {
287
294
  result: any;
288
295
  } | null = null;
289
296
  private static readonly SCREEN_SNAPSHOT_MIN_INTERVAL_MS = 250;
297
+ // (FALSEIDLE Path-C) Consecutive gate-eligible getStatus polls a mesh/autonomous
298
+ // session must show before the poll-static-idle confirm fires. 2 = one extra
299
+ // status tick of hysteresis: enough to reject a single momentary-silence
300
+ // point-sample of a still-live turn, cheap enough not to materially delay a
301
+ // genuine boot-wedge release (the wedge screen is stably static, so it clears
302
+ // every consecutive poll and confirms on the 2nd).
303
+ private static readonly STATIC_IDLE_POLL_CONFIRM_COUNT = 2;
290
304
 
291
305
  private readonly providerResolutionMeta: ProviderResolutionMeta;
292
306
 
@@ -394,6 +408,20 @@ export class ProviderCliAdapter implements CliAdapter {
394
408
  return this.timeouts.statusActivityHold;
395
409
  }
396
410
 
411
+ // (FALSEIDLE Path-C) Whether this session is a mesh worker or coordinator's
412
+ // own autonomous session. Mirrors CliProviderInstance.isAutonomousMeshSession
413
+ // over the runtimeSettings the instance mirrors down via updateRuntimeSettings
414
+ // (meshNodeFor / meshActiveTaskId / meshNodeId / launchedByCoordinator =
415
+ // isMeshWorkerSession, plus meshCoordinatorFor for the coordinator's own turn).
416
+ // Such a session has no human at the keyboard to correct a premature idle, so
417
+ // the poll-static-idle confirm is debounced for it (multiple consecutive idle
418
+ // polls) rather than fired on a single point-sample.
419
+ private isAutonomousMeshSession(): boolean {
420
+ const s = this.runtimeSettings;
421
+ return !!(s?.meshNodeFor || s?.meshActiveTaskId || s?.meshNodeId
422
+ || s?.launchedByCoordinator || s?.meshCoordinatorFor);
423
+ }
424
+
397
425
  // Resolved timeouts
398
426
  private readonly timeouts: Required<NonNullable<CliProviderModule['timeouts']>>;
399
427
 
@@ -956,15 +984,56 @@ export class ProviderCliAdapter implements CliAdapter {
956
984
  const quietForMs = this.lastNonEmptyOutputAt
957
985
  ? (now - this.lastNonEmptyOutputAt)
958
986
  : Number.MAX_SAFE_INTEGER;
987
+ let eligible = false;
959
988
  if (quietForMs >= this.getStatusActivityHoldMs()) {
960
989
  const screenText = this.terminalScreen.getText();
961
990
  const pollDetect = this.runDetectStatus(screenText || this.recentOutputBuffer);
962
991
  const pollModal = this.runParseApproval(screenText)
963
992
  || this.runParseApproval(this.recentOutputBuffer);
964
- if (pollDetect === 'idle' && !pollModal) {
993
+ // (FALSEIDLE Path-C) Final-assistant / pending-response discriminator.
994
+ // Paths A and B refuse to finalize a turn whose partial-response buffer
995
+ // is still non-empty (getCompletedFinalizationBlock 'partial_response_pending'
996
+ // / completionFinalAssistantEvidence turnClosed at cli-provider-instance.ts).
997
+ // Path C (this poll) previously OMITTED it, so a genuinely-live but
998
+ // momentarily-silent turn — silent thinking, a backgrounded/long tool child,
999
+ // the gap between two assistant bubbles — whose currentTurnScope anchor was
1000
+ // lost still satisfied the weaker gate and flipped to idle prematurely.
1001
+ // Require an EMPTY partial buffer here too. getPartialResponse() returns the
1002
+ // accumulated assistant stream while isWaitingForResponse (which
1003
+ // applyGenerating leaves set on the boot-banner wedge too), so this does NOT
1004
+ // reintroduce the D4b wedge: the attach/boot-banner seeds only the static
1005
+ // ready screen — no assistant turn ever streamed — so its partial buffer is
1006
+ // empty and the gate still releases it. A mid-turn quiet gap holds a
1007
+ // non-empty buffer and is deferred.
1008
+ const partial = this.getPartialResponse();
1009
+ const partialPending = typeof partial === 'string' && partial.trim().length > 0;
1010
+ eligible = pollDetect === 'idle' && !pollModal && !partialPending;
1011
+ }
1012
+ if (eligible) {
1013
+ // (FALSEIDLE Path-C) Debounce for autonomous mesh sessions. A worker /
1014
+ // coordinator has no human to correct a premature idle, and a single
1015
+ // runDetectStatus point-sample can land in a live turn's momentary silence.
1016
+ // Require STATIC_IDLE_POLL_CONFIRM_COUNT consecutive eligible polls before
1017
+ // confirming, so the FSM must observe a sustained static-idle screen — a
1018
+ // turn that resumes (fresh output, or a re-armed turn scope) resets the
1019
+ // streak. The status poll runs on the 30s-idle / 5s-generating heartbeat and
1020
+ // this getStatus gate is re-hit each dashboard status tick, so 2 confirms is
1021
+ // ~one extra tick of hysteresis — enough to reject a one-sample silence gap
1022
+ // without materially delaying a genuine boot-wedge release. Foreground /
1023
+ // attended sessions keep the single-poll confirm (a human is watching Send).
1024
+ const requiredStreak = this.isAutonomousMeshSession()
1025
+ ? ProviderCliAdapter.STATIC_IDLE_POLL_CONFIRM_COUNT
1026
+ : 1;
1027
+ this.staticIdlePollStreak += 1;
1028
+ if (this.staticIdlePollStreak >= requiredStreak) {
965
1029
  this.engine.confirmPollStaticIdle('poll_static_idle');
1030
+ this.staticIdlePollStreak = 0;
966
1031
  }
1032
+ } else {
1033
+ this.staticIdlePollStreak = 0;
967
1034
  }
1035
+ } else {
1036
+ this.staticIdlePollStreak = 0;
968
1037
  }
969
1038
  let effectiveStatus = this.projectEffectiveStatus(startupModal);
970
1039
  let effectiveModal = startupModal || this.engine.activeModal;
@@ -276,6 +276,10 @@ type CliStartOptions = {
276
276
  resumeSessionId?: string;
277
277
  settingsOverride?: Record<string, any>;
278
278
  extraEnv?: Record<string, string>;
279
+ /** BRAIN-ROUTING thinking axis: standard level ('low'|'medium'|'high') applied
280
+ * at launch via the provider's thinkingLaunchArgs (CLI) or setConfigOption
281
+ * ('thought_level', ACP). Best-effort — ignored by providers with no support. */
282
+ initialThinkingLevel?: string;
279
283
  };
280
284
 
281
285
  const DEFAULT_COORDINATOR_DELEGATED_ENV_UNSETS = [
@@ -420,10 +424,32 @@ function expandResumeArgs(template: string[] | undefined, sessionId: string): st
420
424
  * there is no template or no model (a model request without a template is a no-op —
421
425
  * see startSession, where the caller logs the skip). MAGI kind-panel model axis.
422
426
  */
423
- function expandModelLaunchArgs(template: string[] | undefined, model: string | undefined): string[] | undefined {
427
+ export function expandModelLaunchArgs(template: string[] | undefined, model: string | undefined): string[] | undefined {
424
428
  const m = typeof model === 'string' ? model.trim() : '';
425
429
  if (!m || !Array.isArray(template) || template.length === 0) return undefined;
426
- return template.map((part) => part === '{{model}}' ? m : part);
430
+ // Substitute {{model}} anywhere in a token, not only when it is the whole token,
431
+ // so templates like ['-c', 'model={{model}}'] (codex) expand as well as the
432
+ // standalone ['--model', '{{model}}'] (claude) form.
433
+ return template.map((part) => part.includes('{{model}}') ? part.split('{{model}}').join(m) : part);
434
+ }
435
+
436
+ /**
437
+ * Expand a provider's `thinkingLaunchArgs` template with the requested thinking
438
+ * level, parallel to expandModelLaunchArgs. The standard level ('low'|'medium'|
439
+ * 'high') is first mapped through the provider's `thinkingLevelMap` (a level absent
440
+ * from the map passes through unchanged), then substituted into every `{{level}}`
441
+ * token. Returns undefined when there is no template or no level (best-effort; a
442
+ * thinking request without a template is a no-op). BRAIN-ROUTING thinking axis.
443
+ */
444
+ export function expandThinkingLaunchArgs(
445
+ template: string[] | undefined,
446
+ level: string | undefined,
447
+ levelMap: Partial<Record<string, string>> | undefined,
448
+ ): string[] | undefined {
449
+ const raw = typeof level === 'string' ? level.trim() : '';
450
+ if (!raw || !Array.isArray(template) || template.length === 0) return undefined;
451
+ const mapped = (levelMap && typeof levelMap[raw] === 'string' && levelMap[raw]!.trim()) ? levelMap[raw]!.trim() : raw;
452
+ return template.map((part) => part.includes('{{level}}') ? part.replace('{{level}}', mapped) : part);
427
453
  }
428
454
 
429
455
  function readSubcommandSessionId(args: string[], subcommands: string[]): string | undefined {
@@ -708,6 +734,9 @@ export class DaemonCliManager {
708
734
  providerSessionId?: string;
709
735
  launchMode?: CliLaunchMode;
710
736
  extraEnv?: Record<string, string>;
737
+ /** BRAIN-ROUTING: post-launch thinking level for runtime-control providers
738
+ * (e.g. hermes reasoning). Passed through to the instance. */
739
+ initialThinkingLevel?: string;
711
740
  /**
712
741
  * On an attach (attachExisting=true), the real spawn time (ms epoch) of the
713
742
  * session-host runtime being restored — a PAST timestamp. Used to restore the
@@ -956,6 +985,19 @@ export class DaemonCliManager {
956
985
  }
957
986
  }
958
987
 
988
+ // Brain routing thinking axis for ACP: route the standard level through the
989
+ // agent's thought_level config option. Best-effort — throws if the agent declares
990
+ // no thought_level category (see setConfigOption), so we swallow and warn.
991
+ if (options?.initialThinkingLevel) {
992
+ const lvl = options.initialThinkingLevel;
993
+ try {
994
+ await acpInstance.setConfigOption('thought_level', lvl);
995
+ console.log(colorize('green', ` 🧠 Initial thinking level set: ${lvl}`));
996
+ } catch (e: any) {
997
+ LOG.warn('CLI', `[ACP] Initial thinking level set failed (provider may not support thought_level): ${e?.message}`);
998
+ }
999
+ }
1000
+
959
1001
  this.persistRecentActivity({
960
1002
  kind: 'acp',
961
1003
  providerType: normalizedType,
@@ -1003,8 +1045,22 @@ export class DaemonCliManager {
1003
1045
  LOG.warn('CLI', `[${normalizedType}] initialModel='${initialModel}' requested but provider declares no modelLaunchArgs template — launching without model selection.`);
1004
1046
  }
1005
1047
 
1048
+ // ─── Thinking axis (brain routing): expand initialThinkingLevel → launch args ───
1049
+ // Parallel to the model axis: a plain CLI provider selects reasoning effort at spawn
1050
+ // via the manifest's thinkingLaunchArgs template ('{{level}}' → the mapped level).
1051
+ // Best-effort; a provider with no template (or no requested level) is a no-op. ACP
1052
+ // providers route thinking through setConfigOption('thought_level') above.
1053
+ const initialThinkingLevel = options?.initialThinkingLevel;
1054
+ const thinkingLaunchArgs = expandThinkingLaunchArgs(provider?.thinkingLaunchArgs, initialThinkingLevel, provider?.thinkingLevelMap);
1055
+ const cliArgsWithBrain = thinkingLaunchArgs
1056
+ ? [...thinkingLaunchArgs, ...(cliArgsWithModel || [])]
1057
+ : cliArgsWithModel;
1058
+ if (initialThinkingLevel && !thinkingLaunchArgs) {
1059
+ LOG.warn('CLI', `[${normalizedType}] initialThinkingLevel='${initialThinkingLevel}' requested but provider declares no thinkingLaunchArgs template — launching without thinking-level selection.`);
1060
+ }
1061
+
1006
1062
  // ─── Resolve launch options → provider session binding ───
1007
- const sessionBinding = resolveCliSessionBinding(provider, normalizedType, cliArgsWithModel, options?.resumeSessionId);
1063
+ const sessionBinding = resolveCliSessionBinding(provider, normalizedType, cliArgsWithBrain, options?.resumeSessionId);
1008
1064
  const resolvedCliArgs = sessionBinding.cliArgs;
1009
1065
 
1010
1066
  // If InstanceManager exists, manage as CliProviderInstance unified
@@ -1024,6 +1080,10 @@ export class DaemonCliManager {
1024
1080
  providerSessionId: sessionBinding.providerSessionId,
1025
1081
  launchMode: sessionBinding.launchMode,
1026
1082
  extraEnv: options?.extraEnv,
1083
+ // BRAIN-ROUTING: for a provider with no thinkingLaunchArgs but a
1084
+ // runtime reasoning control (hermes), apply the level post-launch.
1085
+ // The launch-arg providers (claude/codex) already consumed it at spawn.
1086
+ ...(options?.initialThinkingLevel && !provider?.thinkingLaunchArgs ? { initialThinkingLevel: options.initialThinkingLevel } : {}),
1027
1087
  onProviderSessionResolved: ({ providerSessionId, providerName, providerType, workspace }) => {
1028
1088
  this.persistRecentActivity({
1029
1089
  kind: 'cli',
@@ -1486,6 +1546,7 @@ export class DaemonCliManager {
1486
1546
  resumeSessionId: args?.resumeSessionId,
1487
1547
  settingsOverride,
1488
1548
  extraEnv: delegatedLaunch ? delegatedLaunch.env : args?.env,
1549
+ ...(typeof args?.initialThinkingLevel === 'string' && args.initialThinkingLevel.trim() ? { initialThinkingLevel: args.initialThinkingLevel.trim() } : {}),
1489
1550
  },
1490
1551
  );
1491
1552
 
@@ -457,6 +457,30 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
457
457
  }
458
458
  },
459
459
 
460
+ // ─── Brain routing: per-difficulty brain presets (machine-local) ───
461
+ // getDifficultyBrains returns the seeded defaults when nothing is configured,
462
+ // so the editor always shows a usable mapping. set replaces the whole map.
463
+ difficulty_brains_get: async (_ctx: MedFamilyContext, _args: any) => {
464
+ try {
465
+ const { getDifficultyBrains } = await import('../../config/mesh-config.js');
466
+ return { success: true, difficultyBrains: getDifficultyBrains() };
467
+ } catch (e: any) {
468
+ return { success: false, error: e.message };
469
+ }
470
+ },
471
+
472
+ difficulty_brains_set: async (_ctx: MedFamilyContext, args: any) => {
473
+ try {
474
+ const { setDifficultyBrains } = await import('../../config/mesh-config.js');
475
+ // normalizeDifficultyBrainMap (inside setDifficultyBrains) drops unknown
476
+ // keys and empty slots. An empty result clears the override → defaults.
477
+ const difficultyBrains = setDifficultyBrains(args?.difficultyBrains);
478
+ return { success: true, difficultyBrains };
479
+ } catch (e: any) {
480
+ return { success: false, error: e.message };
481
+ }
482
+ },
483
+
460
484
  add_mesh_node: async (ctx: MedFamilyContext, args: any) => {
461
485
  const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
462
486
  const workspace = typeof args?.workspace === 'string' ? args.workspace.trim() : '';
@@ -22,7 +22,8 @@ import type {
22
22
  RepoMeshHostMetadata,
23
23
  RepoMeshDaemonRole,
24
24
  } from '../repo-mesh-types.js';
25
- import type { MagiKindPanelMap, MagiSlot, MagiTaskKind } from '@adhdev/mesh-shared';
25
+ import type { MagiKindPanelMap, MagiSlot, MagiTaskKind, DifficultyBrainMap } from '@adhdev/mesh-shared';
26
+ import { normalizeDifficultyBrainMap, DEFAULT_DIFFICULTY_BRAINS } from '@adhdev/mesh-shared';
26
27
  import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
27
28
  import { createDefaultMeshHostMetadata } from '../mesh/mesh-host-ownership.js';
28
29
 
@@ -743,3 +744,31 @@ export function removeMagiKindPanel(kind: string): boolean {
743
744
  saveMeshConfig(stored);
744
745
  return true;
745
746
  }
747
+
748
+ // ─── Brain routing: per-difficulty brain presets (machine-local) ───
749
+
750
+ /**
751
+ * The difficulty→brain presets, machine-local. When nothing is configured yet,
752
+ * returns the sensible DEFAULT_DIFFICULTY_BRAINS so the coordinator always has a
753
+ * usable mapping (the operator can override via setDifficultyBrains). Returns a
754
+ * normalized copy — never the stored reference.
755
+ */
756
+ export function getDifficultyBrains(): DifficultyBrainMap {
757
+ const stored = loadMeshConfig().difficultyBrains;
758
+ const normalized = normalizeDifficultyBrainMap(stored);
759
+ return Object.keys(normalized).length > 0 ? normalized : { ...DEFAULT_DIFFICULTY_BRAINS };
760
+ }
761
+
762
+ /**
763
+ * Replace the difficulty→brain presets wholesale (the editor pushes the full map).
764
+ * Passing an empty/normalized-empty map clears the override, so getDifficultyBrains
765
+ * falls back to the defaults again. Returns the normalized, persisted map.
766
+ */
767
+ export function setDifficultyBrains(map: unknown): DifficultyBrainMap {
768
+ const normalized = normalizeDifficultyBrainMap(map);
769
+ const stored = loadMeshConfig();
770
+ if (Object.keys(normalized).length > 0) stored.difficultyBrains = normalized;
771
+ else delete stored.difficultyBrains;
772
+ saveMeshConfig(stored);
773
+ return normalized;
774
+ }
@@ -32,6 +32,8 @@ import type {
32
32
  RepoMeshNodeStatus,
33
33
  } from '../repo-mesh-types.js';
34
34
  import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
35
+ import { getDifficultyBrains } from '../config/mesh-config.js';
36
+ import { MESH_TASK_DIFFICULTIES } from '@adhdev/mesh-shared';
35
37
 
36
38
  /**
37
39
  * Cheap, locally-derived "what just happened" snapshot for the coordinator
@@ -279,6 +281,9 @@ Repository: \`${mesh.repoIdentity}\`${mesh.defaultBranch ? `\nDefault branch: \`
279
281
  // ── Policy ──
280
282
  sections.push(buildPolicySection(mergeAndNormalizePolicy(undefined, mesh.policy)));
281
283
 
284
+ // ── Brain presets (difficulty → model/thinking) ──
285
+ sections.push(buildBrainPresetsSection());
286
+
282
287
  // ── Tools ──
283
288
  sections.push(TOOLS_SECTION);
284
289
 
@@ -599,6 +604,36 @@ function truncateNote(text: string): string {
599
604
  return `${text.slice(0, OPERATING_NOTE_MAX_CHARS).trimEnd()}… [truncated]`;
600
605
  }
601
606
 
607
+ /**
608
+ * Render the difficulty→brain presets so the coordinator knows what each
609
+ * `difficulty` value resolves to (which model / thinking level). Machine-local,
610
+ * read live at prompt-build time — seeded defaults when nothing is configured.
611
+ */
612
+ function buildBrainPresetsSection(): string {
613
+ let brains;
614
+ try { brains = getDifficultyBrains(); } catch { brains = {}; }
615
+ const lines = [
616
+ '## Brain presets',
617
+ '',
618
+ 'When you pass `difficulty` on `mesh_enqueue_task`, it resolves to this model / thinking level (an explicit model/thinkingLevel on the task overrides it). Pick easy for trivial work to save tokens, difficult for hard reasoning.',
619
+ '',
620
+ ];
621
+ for (const key of MESH_TASK_DIFFICULTIES) {
622
+ const slot = (brains as Record<string, { provider?: string; model?: string; thinkingLevel?: string } | undefined>)[key];
623
+ if (!slot || (!slot.provider && !slot.model && !slot.thinkingLevel)) {
624
+ lines.push(`- **${key}**: (no preset — ordinary routing)`);
625
+ continue;
626
+ }
627
+ const parts = [
628
+ slot.provider ? `provider: \`${slot.provider}\`` : '',
629
+ slot.model ? `model: \`${slot.model}\`` : '',
630
+ slot.thinkingLevel ? `thinking: \`${slot.thinkingLevel}\`` : '',
631
+ ].filter(Boolean).join(' | ');
632
+ lines.push(`- **${key}**: ${parts}`);
633
+ }
634
+ return lines.join('\n');
635
+ }
636
+
602
637
  function buildPolicySection(policy: RepoMeshPolicy): string {
603
638
  const rules: string[] = [];
604
639
  if (policy.requirePreTaskCheckpoint) rules.push('- Create a git checkpoint **before** starting each task');
@@ -737,6 +772,7 @@ function buildRulesSection(coordinatorCliType?: string): string {
737
772
  - **Front-load task messages.** Include everything the agent needs (files, problem, expected fix) in \`mesh_enqueue_task\` / \`mesh_send_task\`. Append a structured result request at the end: ask the worker to conclude with a JSON block containing \`status\`, \`changedFiles\`, \`gitStatus\`, \`validationResults\`, \`errors\`, \`nextAction\`. The daemon parses this automatically; you can read it from \`mesh_task_history\`.
738
773
  - **Reuse idle sessions.** For follow-up, retry, commit/push, or cleanup on the same issue, send only the delta to the existing idle session. Start fresh only for independent work, provider mismatch, transcript contamination, or required worktree isolation.
739
774
  - **Worktree affinity.** A worktree is a durable per-branch workspace; keep all of a branch's code_change/fix/review work on its worktree node by targeting \`required_tags: ["worktree=<branch>"]\` or \`target_node_id\`. Get the id/branch from the \`mesh_clone_node\` result or a live \`mesh_status\` — the Configured Nodes snapshot won't list a worktree cloned after launch. Untargeted same-branch follow-ups drift to the base node. Only \`convergence\` (merge/push) runs on the base, never pinned to the worktree.
775
+ - **Classify task difficulty to save tokens.** For each task you enqueue, judge its execution difficulty and pass \`difficulty\`: \`easy\` (extraction, renames, doc tweaks, trivial fixes), \`medium\` (ordinary feature/bugfix work), \`difficult\` (architecture, tricky debugging, multi-file refactors, subtle reasoning), or \`freeform\`. The mesh's per-difficulty brain preset then runs easy tasks on a cheaper model at low reasoning effort and hard tasks on a stronger model at high effort — real token savings on simple work. The current presets are shown in the "Brain presets" section below. You may still pass an explicit \`model\`/\`thinkingLevel\` to override the preset for one task.
740
776
  - **Respect explicit provider requests.** Map: Hermes → \`hermes-cli\`, Claude/Claude Code → \`claude-cli\`, Codex → \`codex-cli\`, Gemini → \`gemini-cli\`, Antigravity → \`antigravity-cli\`. Never substitute the coordinator's own runtime.
741
777
  - **Verify via git, not source.** Use \`mesh_git_status\` to confirm side effects. Treat agent summaries as self-reports, not verification.
742
778
  - **Limit parallelism.** Start with 1–2 tasks; scale only on success. Never duplicate a session because \`mesh_read_chat\` shows no final message while tool/terminal activity is ongoing. This caps *concurrent* load — it does not mean serialize independent work: when a new, independent request arrives and there is headroom under \`maxParallelTasks\`, dispatch it right away rather than waiting for an in-flight task or a user nudge (read-only diagnosis especially, since it has no merge cost).