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

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.
@@ -51,6 +51,10 @@ export declare function __resolveSchedulingStrategyForTests(mesh: any): RepoMesh
51
51
  * scheduling pipeline can be unit-tested without standing up live CLI sessions. */
52
52
  export declare function __orderEligibleNodesForTests(meshId: string, strategy: RepoMeshSchedulingStrategy, nodes: RankableNode[], opts?: {
53
53
  bumpCursor?: boolean;
54
+ task?: {
55
+ difficulty?: string;
56
+ requiredTags?: string[];
57
+ };
54
58
  }): RankableNode[];
55
59
  /** One idle session eligible to claim a queued task, together with the resolved
56
60
  * mesh node record it belongs to. Local candidates come from live CLI instances,
@@ -126,6 +126,15 @@ export interface MeshWorkQueueEntry {
126
126
  * setConfigOption('thought_level')). Rides in payload JSON. Best-effort like model.
127
127
  */
128
128
  thinkingLevel?: string;
129
+ /**
130
+ * SLOT-ROUTING (ORCHESTRATION_NODE_SLOTS.md): the coordinator's difficulty
131
+ * classification for this task ('easy'|'medium'|'difficult'|'freeform'),
132
+ * PERSISTED on the entry so the scheduler can match it against node capability
133
+ * slots at assignment time. Previously an enqueue-only option consumed to
134
+ * resolve model/thinkingLevel and then discarded; keeping it lets task→node
135
+ * fitness matching run. Absent on tasks enqueued without a difficulty.
136
+ */
137
+ difficulty?: string;
129
138
  /**
130
139
  * M1: why this task is held back (e.g. "dependency_failed:<taskId>").
131
140
  * Only set by the system on dependency failure under the 'block' policy;
@@ -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, DifficultyBrainMap } from '@adhdev/mesh-shared';
16
+ import type { MagiKindPanelMap, DifficultyBrainMap, NodeCapabilitySlot } from '@adhdev/mesh-shared';
17
17
  export interface RepoMesh {
18
18
  id: string;
19
19
  name: string;
@@ -114,7 +114,7 @@ export type RepoMeshMagiSessionCleanupMode = 'preserve' | 'stop_and_delete';
114
114
  * Distribution is explicit opt-in: a strategy other than 'first_eligible' must be
115
115
  * configured for any load-spreading to occur.
116
116
  */
117
- export type RepoMeshSchedulingStrategy = 'first_eligible' | 'least_loaded' | 'round_robin' | 'priority_only';
117
+ export type RepoMeshSchedulingStrategy = 'first_eligible' | 'least_loaded' | 'round_robin' | 'priority_only' | 'fitness';
118
118
  export declare const MESH_SCHEDULING_STRATEGIES: RepoMeshSchedulingStrategy[];
119
119
  export declare const DEFAULT_MESH_SCHEDULING_STRATEGY: RepoMeshSchedulingStrategy;
120
120
  /**
@@ -310,8 +310,22 @@ export interface RepoMeshNodePolicy {
310
310
  * enforced as an additional, stricter-wins constraint on top of the global
311
311
  * maxParallelTasks/taskMode caps. Missing/empty: the node behaves exactly as
312
312
  * before (global caps only). Routing is governed solely by required_tags.
313
+ *
314
+ * SUPERSEDED by `slots` (ORCHESTRATION_NODE_SLOTS.md). Kept for back-compat:
315
+ * when `slots` is absent, providerRoles + providerPriority + the machine-global
316
+ * difficultyBrains are auto-derived into slots via deriveSlotsFromLegacy.
313
317
  */
314
318
  providerRoles?: RepoMeshProviderRole[];
319
+ /**
320
+ * Node capability slots (ORCHESTRATION_NODE_SLOTS.md) — the ordered "Preferred
321
+ * AI tools" profile that is the single source of truth for task routing, MAGI
322
+ * fan-out, and orchestrator-proposed edits. Each slot bundles provider + model
323
+ * + thinkingLevel + difficulty range + capability tags + per-slot maxParallel.
324
+ * Order = preference. When absent, the scheduler derives slots from the legacy
325
+ * providerPriority/providerRoles/difficultyBrains (deriveSlotsFromLegacy) so
326
+ * existing nodes keep working without reconfiguration.
327
+ */
328
+ slots?: NodeCapabilitySlot[];
315
329
  /**
316
330
  * Per-node override for RepoMeshPolicy.delegatedWorkerAutoApprove. When set, takes
317
331
  * precedence over the mesh-level policy for worker sessions launched onto this node.
@@ -858,6 +872,8 @@ export interface RepoMeshNodeStatus {
858
872
  activeSessions: string[];
859
873
  activeSessionDetails?: RepoMeshSessionStatus[];
860
874
  providerPriority?: string[];
875
+ /** Explicitly-configured node capability slots (ORCHESTRATION_NODE_SLOTS.md). */
876
+ slots?: NodeCapabilitySlot[];
861
877
  launchReady?: boolean;
862
878
  /** True when the node is clean, ahead=0, behind>0, and safe for fast-forward consideration. */
863
879
  autoFastForwardEligible?: boolean;
@@ -287,6 +287,13 @@ export interface SessionEntry {
287
287
  completionMarker?: string;
288
288
  seenCompletionMarker?: string;
289
289
  surfaceHidden?: boolean;
290
+ /**
291
+ * User (or coordinator-policy) muted: suppress attention side-effects
292
+ * (notifications, toasts, completion audio) for this session WITHOUT removing
293
+ * it from the inbox list. Distinct from surfaceHidden (which collapses it from
294
+ * the list). Daemon-owned, in-memory; rides the status snapshot.
295
+ */
296
+ muted?: boolean;
290
297
  settings?: Record<string, any>;
291
298
  /**
292
299
  * True owning-daemon id for a session a coordinator synthesises into its own
@@ -361,6 +368,7 @@ export interface CompactSessionEntry {
361
368
  completionMarker?: string;
362
369
  seenCompletionMarker?: string;
363
370
  surfaceHidden?: boolean;
371
+ muted?: boolean;
364
372
  controlValues?: Record<string, string | number | boolean>;
365
373
  providerControls?: ProviderControlSchema[];
366
374
  summaryMetadata?: ProviderSummaryMetadata;
@@ -64,6 +64,7 @@ export interface RecentReadDebugSnapshot {
64
64
  messageUpdatedAt: number;
65
65
  }
66
66
  export declare function shouldEmitRecentReadDebugLog(cache: Map<string, string>, snapshot: RecentReadDebugSnapshot): boolean;
67
+ export declare function buildAvailableProviders(providerLoader: StatusSnapshotOptions['providerLoader']): AvailableProviderInfo[];
67
68
  export declare function buildMachineInfo(profile?: 'full' | 'live' | 'metadata'): MachineInfo;
68
69
  /**
69
70
  * Resolve the last user-visible (non-system) message for a session as a preview.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.485",
3
+ "version": "0.9.82-rc.486",
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.485",
51
- "@adhdev/session-host-core": "0.9.82-rc.485",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.486",
51
+ "@adhdev/session-host-core": "0.9.82-rc.486",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -1080,7 +1080,14 @@ export class DaemonCommandHandler implements CommandHelpers {
1080
1080
  if (!fs.existsSync(installRoot)) return { success: true, providers: [] };
1081
1081
 
1082
1082
  const CATEGORIES = ['cli', 'ide', 'extension', 'acp'] as const;
1083
- const items: Array<{ type: string; category: string; version: string; path: string }> = [];
1083
+ const items: Array<{
1084
+ type: string;
1085
+ category: string;
1086
+ version: string;
1087
+ path: string;
1088
+ modelOptions?: string[];
1089
+ thinkingLevelOptions?: string[];
1090
+ }> = [];
1084
1091
 
1085
1092
  for (const category of CATEGORIES) {
1086
1093
  const categoryDir = path.join(installRoot, category);
@@ -1095,11 +1102,24 @@ export class DaemonCommandHandler implements CommandHelpers {
1095
1102
  if (!manifestPath) continue;
1096
1103
  try {
1097
1104
  const m = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
1105
+ // Surface the manifest's advisory model / thinking-level lists so
1106
+ // consumers of this endpoint (standalone New-session dialog, mesh
1107
+ // node slot editor) get the same provider-specific dropdowns the
1108
+ // status-snapshot path already carries — otherwise every provider
1109
+ // (codex included) falls back to a free-text Model field.
1110
+ const modelOptions = Array.isArray(m.modelOptions)
1111
+ ? m.modelOptions.filter((x: unknown): x is string => typeof x === 'string' && !!x.trim())
1112
+ : [];
1113
+ const thinkingLevelOptions = Array.isArray(m.thinkingLevelOptions)
1114
+ ? m.thinkingLevelOptions.filter((x: unknown): x is string => typeof x === 'string' && !!x.trim())
1115
+ : [];
1098
1116
  items.push({
1099
1117
  type,
1100
1118
  category,
1101
1119
  version: typeof m.providerVersion === 'string' ? m.providerVersion : '0.0.0',
1102
1120
  path: manifestPath,
1121
+ ...(modelOptions.length ? { modelOptions } : {}),
1122
+ ...(thinkingLevelOptions.length ? { thinkingLevelOptions } : {}),
1103
1123
  });
1104
1124
  } catch {
1105
1125
  // Corrupt manifest — skip but don't fail the whole listing.
@@ -48,6 +48,18 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
48
48
  const extraSystemPrompt = typeof args?.extraSystemPrompt === 'string'
49
49
  ? args.extraSystemPrompt.trim()
50
50
  : '';
51
+ // Optional per-launch model / thinking-level override for the
52
+ // coordinator session. Passed straight through to launch_cli,
53
+ // which applies them best-effort for providers that support it
54
+ // (claude --effort, codex model_reasoning_effort). Blank / absent
55
+ // => provider default. This is a launch-time override only; it is
56
+ // NOT persisted to the mesh coordinator config.
57
+ const initialModel = typeof args?.initialModel === 'string' && args.initialModel.trim()
58
+ ? args.initialModel.trim()
59
+ : null;
60
+ const initialThinkingLevel = typeof args?.initialThinkingLevel === 'string' && args.initialThinkingLevel.trim()
61
+ ? args.initialThinkingLevel.trim()
62
+ : null;
51
63
  if (!meshId) return { success: false, error: 'meshId required' };
52
64
 
53
65
  try {
@@ -390,6 +402,8 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
390
402
  cliArgs: cliCmdArgs.length > 0 ? cliCmdArgs : undefined,
391
403
  env: Object.keys(cliCmdEnv).length > 0 ? cliCmdEnv : undefined,
392
404
  settings: { meshCoordinatorFor: meshId },
405
+ ...(initialModel ? { initialModel } : {}),
406
+ ...(initialThinkingLevel ? { initialThinkingLevel } : {}),
393
407
  });
394
408
 
395
409
  // R48 inject-then-remove. Spawn was just kicked off above; agy and
@@ -609,7 +623,9 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
609
623
  env: Object.keys(launchEnv).length > 0 ? launchEnv : undefined,
610
624
  settings: {
611
625
  meshCoordinatorFor: meshId
612
- }
626
+ },
627
+ ...(initialModel ? { initialModel } : {}),
628
+ ...(initialThinkingLevel ? { initialThinkingLevel } : {}),
613
629
  });
614
630
 
615
631
  // R48 inject-then-remove. See the cli_command branch for context;
@@ -18,6 +18,7 @@ import { getGitRepoStatus } from '../../git/git-status.js';
18
18
  import {
19
19
  normalizeMeshNodeId,
20
20
  daemonIdsEquivalent,
21
+ normalizeNodeCapabilitySlots,
21
22
  } from '@adhdev/mesh-shared';
22
23
  import {
23
24
  getPendingMeshCoordinatorEvents,
@@ -357,6 +358,13 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
357
358
  ? { daemonBuildVersion: node.reportedDaemonBuildVersion }
358
359
  : {}),
359
360
  providerPriority,
361
+ // ORCHESTRATION_NODE_SLOTS.md: surface the node's capability
362
+ // slots so the dashboard slot editor can read them. Only
363
+ // emitted when explicitly configured (derived-from-legacy
364
+ // slots stay implicit — the editor shows the legacy fields).
365
+ ...(Array.isArray((node.policy as any)?.slots) && (node.policy as any).slots.length
366
+ ? { slots: normalizeNodeCapabilitySlots((node.policy as any).slots) }
367
+ : {}),
360
368
  activeSessions: [],
361
369
  activeSessionDetails: [],
362
370
  launchReady: false,
@@ -64,6 +64,35 @@ export const cliAgentHandlers: Record<string, MedFamilyHandler> = {
64
64
  return ctx.deps.cliManager.handleCliCommand('record_provider_pty', args);
65
65
  },
66
66
 
67
+ // Daemon-owned per-session user Mute/Hide. Replaces the old browser-local
68
+ // localStorage layer: the user's manual hide/mute for a conversation is stored
69
+ // in-memory on the live session's settings (userHidden / userMuted) and rides
70
+ // the SAME status snapshot pipeline as the coordinator-policy surfaceHidden
71
+ // flag, so every client of this daemon sees the same state. In-memory only —
72
+ // resets on daemon restart (coordinator-spawned sessions re-derive their hidden
73
+ // default from mesh policy on relaunch). Passing null/undefined for a field
74
+ // leaves it unchanged; pass an explicit boolean to set, or false to clear an
75
+ // earlier hide/mute (e.g. unmute a coordinator-spawned worker overrides the
76
+ // policy default until restart).
77
+ set_conversation_prefs: async (ctx: MedFamilyContext, args: any) => {
78
+ const sessionId = readStringValue(args?.sessionId, (args as any)?.targetSessionId, (args as any)?.instanceId);
79
+ if (!sessionId) return { success: false, error: 'sessionId required' };
80
+ const inst = ctx.deps.instanceManager.getInstance(sessionId);
81
+ if (!inst || typeof inst.updateSettings !== 'function') {
82
+ return { success: false, error: 'Session not found or does not support preferences' };
83
+ }
84
+ const patch: Record<string, unknown> = {};
85
+ if (typeof args?.hidden === 'boolean') patch.userHidden = args.hidden;
86
+ if (typeof args?.muted === 'boolean') patch.userMuted = args.muted;
87
+ if (!Object.keys(patch).length) return { success: false, error: 'Nothing to update (hidden and/or muted required)' };
88
+ inst.updateSettings(patch);
89
+ // Push a fresh status snapshot so all clients see the updated
90
+ // surfaceHidden/muted immediately (cloud path). The standalone server has a
91
+ // parallel broadcast gate keyed on the command name (see daemon-standalone).
92
+ ctx.deps.onStatusChange?.();
93
+ return { success: true, sessionId, ...patch };
94
+ },
95
+
67
96
  agent_command: async (ctx: MedFamilyContext, args: any) => {
68
97
  // Relay-safety stamp: a dispatch carrying meshContext.coordinatorDaemonId
69
98
  // (mesh_send_task / queue assignment over P2P) is the worker daemon's chance
package/src/index.ts CHANGED
@@ -381,7 +381,7 @@ export type {
381
381
  // ── Status ──
382
382
  export { DaemonStatusReporter } from './status/reporter.js';
383
383
  export { buildSessionEntries, findCdpManager, hasCdpManager, isCdpConnected } from './status/builders.js';
384
- export { buildStatusSnapshot, buildMachineInfo, getLastDisplayMessage } from './status/snapshot.js';
384
+ export { buildStatusSnapshot, buildMachineInfo, buildAvailableProviders, getLastDisplayMessage } from './status/snapshot.js';
385
385
  export { getDaemonBuildInfo } from './build-info.js';
386
386
  export type { DaemonBuildInfo } from './build-info.js';
387
387
  export { normalizeManagedStatus, isManagedStatusWorking, isManagedStatusWaiting, normalizeActiveChatData } from './status/normalize.js';
@@ -702,7 +702,9 @@ const TOOLS_SECTION = `## Available Tools
702
702
  | \`mesh_magi_review\` | Cross-verify a read-only investigation across a standing panel of independent mesh agents (different machines/providers) instead of a single worker |
703
703
  | \`mesh_magi_collect\` | Collect + synthesize a previously dispatched MAGI fan-out by its consensus group id (async companion to mesh_magi_review wait:false) |
704
704
  | \`mesh_magi_kind_panel_set\` | Bind a task_kind → MAGI kind-panel slots (the SOLE MAGI panel-resolution surface; machine-local, wholesale replacement — approve current-vs-new first) |
705
- | \`mesh_magi_kind_panel_list\` | List configured task_kind → MAGI kind-panel slot bindings (machine-local, read-only) |`;
705
+ | \`mesh_magi_kind_panel_list\` | List configured task_kind → MAGI kind-panel slot bindings (machine-local, read-only) |
706
+ | \`mesh_node_slots_list\` | List a node's capability slots (its AI-tool profile: provider/model/thinking + difficulty range + capability tags), read-only |
707
+ | \`mesh_node_slots_set\` | PROPOSE (dry-run) or APPLY a node's capability slots — how you autonomously retune a node's tool profile; WHOLESALE replacement, present current-vs-proposed and get user approval before write=true |`;
706
708
 
707
709
  const TOOL_EXPOSURE_PREFLIGHT_SECTION = `## Tool Exposure Preflight
708
710
 
@@ -773,6 +775,7 @@ function buildRulesSection(coordinatorCliType?: string): string {
773
775
  - **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.
774
776
  - **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
777
  - **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.
778
+ - **Retune node profiles when routing is a poor fit — but only with approval.** A node's capability slots (its provider/model/thinking + difficulty range + capability tags, seen via \`mesh_node_slots_list\`) are what task→node fitness routing matches against. If you notice a persistent mismatch — e.g. every \`difficult\` task lands on a node whose only slot is a cheap model, or a capability a node clearly has isn't declared — you MAY propose a slot change with \`mesh_node_slots_set\` (write=false). That returns current-vs-proposed; present that diff to the user with a one-line reason and apply (write=true) ONLY after they approve. It is a WHOLESALE replacement of the node's slots, so include the slots you want to keep. Never rewrite a node's profile silently or without a clear routing reason.
776
779
  - **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.
777
780
  - **Verify via git, not source.** Use \`mesh_git_status\` to confirm side effects. Treat agent summaries as self-reports, not verification.
778
781
  - **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).
@@ -2,7 +2,7 @@ import { existsSync } from 'fs';
2
2
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
3
3
  import { MESH_CONNECT_TIMEOUT_MS } from '../runtime-defaults.js';
4
4
  import { loadConfig } from '../config/config.js';
5
- import { getMesh } from '../config/mesh-config.js';
5
+ import { getMesh, getDifficultyBrains } from '../config/mesh-config.js';
6
6
  import { detectCLI } from '../detection/cli-detector.js';
7
7
  import { LOG } from '../logging/logger.js';
8
8
  import { appendLedgerEntry } from './mesh-ledger.js';
@@ -15,7 +15,7 @@ import { traceMeshEventDrop } from './mesh-event-trace.js';
15
15
  import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
16
16
  import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks } from '../repo-mesh-types.js';
17
17
  import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
18
- import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, type MeshNodeIdentified } from '@adhdev/mesh-shared';
18
+ import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, deriveSlotsFromLegacy, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
19
19
  import { findTerminalLedgerEvidenceForTask, hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
20
20
  import { readNonEmptyString } from './mesh-events-utils.js';
21
21
  import { readMeshNodeDaemonId } from './mesh-node-identity.js';
@@ -1141,17 +1141,36 @@ function nodeActiveLoad(meshId: string, nodeId: string): number {
1141
1141
  return MeshRuntimeStore.getInstance().nodeActiveAssignmentCount(meshId, nodeId);
1142
1142
  }
1143
1143
 
1144
+ /** True when any node in the mesh has an EXPLICIT capability-slot list configured
1145
+ * (policy.slots). Legacy-derived slots don't count — only an operator (or the
1146
+ * orchestrator, with approval) authoring slots signals intent to route by fitness. */
1147
+ function meshHasExplicitSlots(mesh: any): boolean {
1148
+ const nodes = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
1149
+ return nodes.some((n: any) => normalizeNodeCapabilitySlots(n?.policy?.slots).length > 0);
1150
+ }
1151
+
1144
1152
  /**
1145
1153
  * The mesh-wide scheduling strategy, read from the MACHINE-LOCAL stored mesh
1146
1154
  * policy. Resolution order:
1147
- * 1. the stored mesh policy schedulingStrategy raw 4-union, then
1148
- * 2. 'first_eligible' (strict no-change default).
1149
- * Policy is machine-local only there is no repo-file (`.adhdev/mesh.json`)
1150
- * overlay. Only governs the final tie-break; eligibility, capacity, and priority
1151
- * gates apply identically to every strategy.
1155
+ * 1. an EXPLICITLY stored schedulingStrategy (operator picked a mode) wins, else
1156
+ * 2. 'fitness' AUTO when the mesh has any node with explicit capability slots —
1157
+ * configuring slots is itself the signal to route tasks by task→slot fitness
1158
+ * (difficulty/capability), no separate strategy toggle required, else
1159
+ * 3. 'first_eligible' (strict no-change default for slot-less meshes).
1160
+ *
1161
+ * Persistence economy drops schedulingStrategy from policy when it equals the
1162
+ * first_eligible default (repo-mesh-types normalizeMeshPolicy), so an ABSENT value
1163
+ * means "unset" — safe to auto-upgrade — while a PRESENT value means the operator
1164
+ * chose it and we never override. Policy is machine-local only; this only governs
1165
+ * the final tie-break — eligibility, capacity, and priority gates are unchanged.
1152
1166
  */
1153
1167
  function resolveSchedulingStrategy(mesh: any): RepoMeshSchedulingStrategy {
1154
- return normalizeMeshSchedulingStrategy(mesh?.policy?.schedulingStrategy);
1168
+ const raw = mesh?.policy?.schedulingStrategy;
1169
+ if (typeof raw === 'string' && raw.trim()) {
1170
+ return normalizeMeshSchedulingStrategy(raw);
1171
+ }
1172
+ // Unset → auto-fitness when slots exist, else the historical default.
1173
+ return meshHasExplicitSlots(mesh) ? 'fitness' : normalizeMeshSchedulingStrategy(undefined);
1155
1174
  }
1156
1175
 
1157
1176
  /**
@@ -1193,7 +1212,7 @@ export function __orderEligibleNodesForTests(
1193
1212
  meshId: string,
1194
1213
  strategy: RepoMeshSchedulingStrategy,
1195
1214
  nodes: RankableNode[],
1196
- opts?: { bumpCursor?: boolean },
1215
+ opts?: { bumpCursor?: boolean; task?: { difficulty?: string; requiredTags?: string[] } },
1197
1216
  ): RankableNode[] {
1198
1217
  return orderEligibleNodes(meshId, strategy, nodes, opts);
1199
1218
  }
@@ -1249,16 +1268,107 @@ export function __buildSchedulingPoolForTests(
1249
1268
  return buildSchedulingPool(localCandidates, remoteCandidates);
1250
1269
  }
1251
1270
 
1271
+ // ─────────────────────────────────────────────────────────────────────────────
1272
+ // Node capability slots — task→node/slot fitness (ORCHESTRATION_NODE_SLOTS.md)
1273
+ //
1274
+ // A node's capability slots are the single source of truth for routing. When a
1275
+ // node has explicit `policy.slots` we use them; otherwise we derive slots from the
1276
+ // legacy providerPriority/providerRoles + the machine-global difficultyBrains so
1277
+ // existing nodes keep working (back-compat). The fitness scorer ranks a node for a
1278
+ // specific task by how well its best slot matches the task's difficulty and
1279
+ // required tags — with graceful fallback so a task is never blocked by a missing
1280
+ // exact match.
1281
+ // ─────────────────────────────────────────────────────────────────────────────
1282
+
1283
+ /** The task shape the fitness scorer reads (a subset of MeshWorkQueueEntry). */
1284
+ interface FitnessTask {
1285
+ difficulty?: string;
1286
+ requiredTags?: string[];
1287
+ }
1288
+
1289
+ /** Resolve a node's capability slots: explicit policy.slots, else derived from legacy. */
1290
+ function resolveNodeCapabilitySlots(node: any): NodeCapabilitySlot[] {
1291
+ const explicit = normalizeNodeCapabilitySlots(node?.policy?.slots);
1292
+ if (explicit.length) return explicit;
1293
+ let difficultyBrains: any;
1294
+ try { difficultyBrains = getDifficultyBrains(); } catch { difficultyBrains = undefined; }
1295
+ return deriveSlotsFromLegacy({
1296
+ providerPriority: normalizeProviderPriority(node?.policy),
1297
+ providerRoles: Array.isArray(node?.policy?.providerRoles) ? node.policy.providerRoles : undefined,
1298
+ difficultyBrains,
1299
+ });
1300
+ }
1301
+
1302
+ /**
1303
+ * Score how well one slot fits a task. Higher = better. A slot whose difficulty
1304
+ * range contains the task's difficulty scores highest; a general-purpose slot
1305
+ * (no declared difficulty) is a valid fallback; a slot whose capability tags cover
1306
+ * the task's requiredTags gets a capability bonus. Never negative — the worst a
1307
+ * slot does is score 0 (still selectable as a last-resort fallback).
1308
+ */
1309
+ function scoreSlotForTask(slot: NodeCapabilitySlot, task: FitnessTask): number {
1310
+ let score = 1; // base: any slot can run the task (fallback floor)
1311
+ const diff = isMeshTaskDifficulty(task.difficulty) ? task.difficulty as MeshTaskDifficulty : undefined;
1312
+ if (diff) {
1313
+ if (slot.difficulty?.length) {
1314
+ score += slot.difficulty.includes(diff) ? 100 : 0; // exact difficulty match dominates
1315
+ } else {
1316
+ score += 20; // general-purpose slot: decent fallback for any difficulty
1317
+ }
1318
+ }
1319
+ const req = task.requiredTags?.filter(t => !!t) ?? [];
1320
+ if (req.length) {
1321
+ const cap = new Set(slot.capability ?? []);
1322
+ const covered = req.every(t => cap.has(t));
1323
+ score += covered ? 30 : 0; // capability coverage bonus (hard filter is applied elsewhere)
1324
+ }
1325
+ return score;
1326
+ }
1327
+
1328
+ /** Best (slot, score) for a task on a node, or null when the node has no slots. */
1329
+ function bestSlotForTask(node: any, task: FitnessTask): { slot: NodeCapabilitySlot; score: number } | null {
1330
+ const slots = resolveNodeCapabilitySlots(node);
1331
+ if (!slots.length) return null;
1332
+ let best: { slot: NodeCapabilitySlot; score: number } | null = null;
1333
+ for (const slot of slots) {
1334
+ const score = scoreSlotForTask(slot, task);
1335
+ if (!best || score > best.score) best = { slot, score };
1336
+ }
1337
+ return best;
1338
+ }
1339
+
1340
+ /** Node-level fitness for a task = its best slot's score (0 when the node has no slots). */
1341
+ function nodeFitnessForTask(node: any, task: FitnessTask): number {
1342
+ return bestSlotForTask(node, task)?.score ?? 0;
1343
+ }
1344
+
1252
1345
  function orderEligibleNodes(
1253
1346
  meshId: string,
1254
1347
  strategy: RepoMeshSchedulingStrategy,
1255
1348
  nodes: RankableNode[],
1256
- opts?: { bumpCursor?: boolean },
1349
+ opts?: { bumpCursor?: boolean; task?: FitnessTask },
1257
1350
  ): RankableNode[] {
1258
1351
  if (strategy === 'first_eligible' || nodes.length <= 1) {
1259
1352
  return nodes;
1260
1353
  }
1261
1354
 
1355
+ // Fitness strategy: rank by task→slot fit first (when a task is in scope —
1356
+ // auto-launch drains per-task), then fall through to priority/load/rotation for
1357
+ // ties. Without a task (idle-session drain ranks task-independently) fitness is
1358
+ // inert and this behaves like least_loaded.
1359
+ if (strategy === 'fitness' && opts?.task) {
1360
+ const task = opts.task;
1361
+ return [...nodes].sort((a, b) => {
1362
+ const fitDelta = nodeFitnessForTask(b.node, task) - nodeFitnessForTask(a.node, task);
1363
+ if (fitDelta !== 0) return fitDelta; // higher fitness first
1364
+ const prioDelta = resolveNodeSchedulingPriority(b.node?.policy) - resolveNodeSchedulingPriority(a.node?.policy);
1365
+ if (prioDelta !== 0) return prioDelta;
1366
+ const loadDelta = nodeActiveLoad(meshId, a.nodeId) - nodeActiveLoad(meshId, b.nodeId);
1367
+ if (loadDelta !== 0) return loadDelta;
1368
+ return a.index - b.index;
1369
+ });
1370
+ }
1371
+
1262
1372
  const priorityOf = (n: { node: any }) => resolveNodeSchedulingPriority(n.node?.policy);
1263
1373
 
1264
1374
  // Round-robin rotation offset: rotate the deterministic input order by a
@@ -1542,19 +1652,29 @@ async function resolveUsableProvider(
1542
1652
  nodeId: string,
1543
1653
  node: any,
1544
1654
  requiredTags?: string[],
1545
- ): Promise<{ providerType?: string; reason?: string }> {
1546
- const providerPriority = normalizeProviderPriority(node?.policy);
1547
- if (!providerPriority.length) return { reason: 'missing_provider_priority' };
1655
+ task?: FitnessTask,
1656
+ ): Promise<{ providerType?: string; model?: string; thinkingLevel?: string; reason?: string }> {
1548
1657
  const providerLoader = components.providerLoader;
1549
1658
  if (!providerLoader) return { reason: 'provider_loader_unavailable' };
1550
1659
 
1660
+ // Slot-based order (ORCHESTRATION_NODE_SLOTS.md): rank the node's capability
1661
+ // slots by task→slot fitness (difficulty/requiredTags) so the best-fit slot's
1662
+ // provider is tried first, and its model/thinkingLevel ride along. Falls back
1663
+ // to the legacy providerPriority-derived slots when no explicit slots exist.
1664
+ const slots = resolveNodeCapabilitySlots(node);
1665
+ if (!slots.length) return { reason: 'missing_provider_priority' };
1666
+ const orderedSlots = task
1667
+ ? [...slots].sort((a, b) => scoreSlotForTask(b, task) - scoreSlotForTask(a, task))
1668
+ : slots;
1669
+
1551
1670
  const failed: string[] = [];
1552
- for (const requestedType of providerPriority) {
1671
+ for (const slot of orderedSlots) {
1672
+ const requestedType = slot.provider;
1553
1673
  const normalizedType = typeof providerLoader.resolveAlias === 'function'
1554
1674
  ? providerLoader.resolveAlias(requestedType)
1555
1675
  : requestedType;
1556
1676
  // Skip providers that can't satisfy the task's requiredTags (e.g. provider=hermes-cli
1557
- // means only hermes-cli qualifies, not any other type in providerPriority).
1677
+ // means only hermes-cli qualifies, not any other slot's provider).
1558
1678
  if (requiredTags?.length && !nodeSatisfiesRequiredTags(requiredTags, buildMeshNodeCapabilityTags(node, normalizedType))) {
1559
1679
  failed.push(`${requestedType}: required_tags_mismatch`);
1560
1680
  continue;
@@ -1578,7 +1698,13 @@ async function resolveUsableProvider(
1578
1698
  }], false);
1579
1699
  }
1580
1700
  (components as any).onStatusChange?.();
1581
- if (detected) return { providerType: normalizedType };
1701
+ if (detected) {
1702
+ return {
1703
+ providerType: normalizedType,
1704
+ ...(slot.model ? { model: slot.model } : {}),
1705
+ ...(slot.thinkingLevel ? { thinkingLevel: slot.thinkingLevel } : {}),
1706
+ };
1707
+ }
1582
1708
  failed.push(`${requestedType}: not detected`);
1583
1709
  }
1584
1710
  return { reason: `provider_priority_unusable: ${failed.join('; ') || nodeId}` };
@@ -1850,7 +1976,9 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1850
1976
  candidateNodes
1851
1977
  .map((node: any, index: number) => ({ nodeId: readMeshNodeId(node), node, index }))
1852
1978
  .filter((c: RankableNode) => c.nodeId),
1853
- { bumpCursor: true },
1979
+ // Auto-launch drains one task at a time, so the task IS in scope here —
1980
+ // pass it through for the 'fitness' strategy's task→slot ranking.
1981
+ { bumpCursor: true, task: { difficulty: (task as any).difficulty, requiredTags: task.requiredTags } },
1854
1982
  ).map((c: RankableNode) => c.node);
1855
1983
 
1856
1984
  for (const node of orderedCandidateNodes) {
@@ -1914,11 +2042,16 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1914
2042
 
1915
2043
  autoLaunchInProgress.add(launchKey);
1916
2044
  try {
1917
- const resolved = await resolveUsableProvider(components, nodeId, node, task.requiredTags);
2045
+ const resolved = await resolveUsableProvider(components, nodeId, node, task.requiredTags, { difficulty: (task as any).difficulty, requiredTags: task.requiredTags });
1918
2046
  if (!resolved.providerType) {
1919
2047
  markAutoLaunch(meshId, task.id, { status: 'skipped', reason: resolved.reason || 'provider_unusable', nodeId });
1920
2048
  continue;
1921
2049
  }
2050
+ // Slot-derived model/thinking: an explicit task.model/thinkingLevel
2051
+ // (resolved from the enqueue-time brain) still wins; the matched
2052
+ // slot fills only what the task left blank (ORCHESTRATION_NODE_SLOTS.md).
2053
+ const effectiveModel = (typeof task.model === 'string' && task.model.trim()) ? task.model.trim() : resolved.model;
2054
+ const effectiveThinkingLevel = (typeof task.thinkingLevel === 'string' && task.thinkingLevel.trim()) ? task.thinkingLevel.trim() : resolved.thinkingLevel;
1922
2055
 
1923
2056
  // Don't spawn a session for a (node, provider) already at its declared
1924
2057
  // maxParallel cap — it would launch only to fail the claim. The claim
@@ -1967,9 +2100,10 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1967
2100
  settings: remoteSettings,
1968
2101
  // MAGI-KIND-PANEL model axis: forward the task's model override so the
1969
2102
  // remote worker session launches with it (initialModel). Best-effort.
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() } : {}),
2103
+ // Slot-aware: task override wins, else the matched slot's model.
2104
+ ...(effectiveModel ? { initialModel: effectiveModel } : {}),
2105
+ // BRAIN-ROUTING thinking axis: forward the effective thinking level (initialThinkingLevel).
2106
+ ...(effectiveThinkingLevel ? { initialThinkingLevel: effectiveThinkingLevel } : {}),
1973
2107
  });
1974
2108
  } catch (e: any) {
1975
2109
  markAutoLaunch(meshId, task.id, { status: 'failed', reason: `remote_launch_dispatch_failed: ${e?.message || String(e)}`, nodeId, providerType: resolved.providerType });
@@ -1999,11 +2133,11 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1999
2133
  cliType: resolved.providerType,
2000
2134
  dir: node.workspace,
2001
2135
  settings: launchSettings,
2002
- // MAGI-KIND-PANEL model axis: local launch forwards the task's model
2003
- // override as initialModel (CLI → modelLaunchArgs; ACP → setConfigOption).
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() } : {}),
2136
+ // MAGI-KIND-PANEL model axis: local launch forwards the effective model
2137
+ // (task override, else matched slot) as initialModel (CLI → modelLaunchArgs; ACP → setConfigOption).
2138
+ ...(effectiveModel ? { initialModel: effectiveModel } : {}),
2139
+ // BRAIN-ROUTING thinking axis: forward the effective thinking level (initialThinkingLevel).
2140
+ ...(effectiveThinkingLevel ? { initialThinkingLevel: effectiveThinkingLevel } : {}),
2007
2141
  });
2008
2142
  if (!launchResult?.success) {
2009
2143
  const reason = launchResult?.error || 'launch_cli_failed';
@@ -2211,7 +2345,10 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
2211
2345
  const aPrio = resolveNodeSchedulingPriority(a.node?.policy);
2212
2346
  const bPrio = resolveNodeSchedulingPriority(b.node?.policy);
2213
2347
  if (aPrio !== bPrio) return bPrio - aPrio;
2214
- if (strategy === 'least_loaded' || strategy === 'round_robin') {
2348
+ // The idle-session drain ranks task-independently (a session pulls
2349
+ // whatever task matches), so 'fitness' here reduces to load-aware
2350
+ // ordering — the same tiebreak as least_loaded/round_robin.
2351
+ if (strategy === 'least_loaded' || strategy === 'round_robin' || strategy === 'fitness') {
2215
2352
  const loadDelta = nodeActiveLoad(meshId, a.nodeId) - nodeActiveLoad(meshId, b.nodeId);
2216
2353
  if (loadDelta !== 0) return loadDelta;
2217
2354
  }