@adhdev/daemon-core 0.9.82-rc.310 → 0.9.82-rc.311

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.
@@ -21,7 +21,7 @@
21
21
  import { FsmDriver, type DashboardEvent, type ISpecDriver } from './fsm-driver.js';
22
22
  import { executeNativeHistory } from './native-history-executor.js';
23
23
  import * as fs from 'node:fs';
24
- import type { NativeHistoryConfig, Control } from './types.js';
24
+ import type { NativeHistoryConfig, Control, ControlAction } from './types.js';
25
25
  import type { CliAdapter, CliAdapterStatus } from '../../cli-adapter-types.js';
26
26
  import type { ChatMessage } from '../../types.js';
27
27
  import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
@@ -47,6 +47,10 @@ function stripAnsi(text: string): string {
47
47
  .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
48
48
  }
49
49
 
50
+ function delay(ms: number): Promise<void> {
51
+ return new Promise(resolve => setTimeout(resolve, ms));
52
+ }
53
+
50
54
  export class SpecCliAdapter implements CliAdapter {
51
55
  readonly cliType: string;
52
56
  readonly cliName: string;
@@ -311,9 +315,15 @@ export class SpecCliAdapter implements CliAdapter {
311
315
  * drives the dispatch:
312
316
  *
313
317
  * send_keys → click_control (e.g. stop)
314
- * open_picker → click_control then resolve when extract_choices
315
- * surface; choice index comes from args.choiceIndex
316
- * or args.choice (string label match), defaulting to 0
318
+ * open_picker → two roles, driven by the screen, not a hardcoded list:
319
+ * - LIST (no choice arg): open the picker, wait for it
320
+ * to render, parse the on-screen options via
321
+ * `extract_choices`, and return them as
322
+ * `controlResult.options` (+ `currentValue`). This is
323
+ * how the dashboard's Model/Mode controls learn what is
324
+ * actually selectable in this CLI right now.
325
+ * - SELECT (args.choiceIndex / args.choiceLabel): drive
326
+ * the picker to that option using `submit_key`.
317
327
  * attach_image → attach_image dispatch; expects args.blob (data url
318
328
  * or base64) and args.mime
319
329
  *
@@ -341,16 +351,148 @@ export class SpecCliAdapter implements CliAdapter {
341
351
  this.driver.dispatch({ kind: 'attach_image', blob, mime });
342
352
  return Promise.resolve({ ok: true, effects: [{ type: 'attached_image', controlId: ctl.id }] });
343
353
  }
344
- // send_keys + open_picker both route through click_control. For
345
- // open_picker the dashboard sees the picker modal arrive via a
346
- // state_changed event, then submits the choice using
347
- // resolveModal/click_modal_button. invokeScript's synchronous
348
- // return is just an acknowledgement that the trigger fired.
354
+ if (action.type === 'open_picker') {
355
+ const choiceIndex = typeof flat.choiceIndex === 'number' ? flat.choiceIndex
356
+ : typeof flat.choiceIndex === 'string' && flat.choiceIndex.trim() ? Number(flat.choiceIndex)
357
+ : undefined;
358
+ const choiceLabel = typeof flat.choiceLabel === 'string' ? flat.choiceLabel
359
+ : typeof flat.choice === 'string' ? flat.choice
360
+ : undefined;
361
+ if ((typeof choiceIndex === 'number' && Number.isFinite(choiceIndex)) || (choiceLabel && choiceLabel.trim())) {
362
+ return this.selectPickerChoice(ctl, action, choiceIndex, choiceLabel);
363
+ }
364
+ return this.openPickerAndListChoices(ctl, action);
365
+ }
366
+ // send_keys routes through click_control.
349
367
  this.driver.dispatch({ kind: 'click_control', control_id: ctl.id, payload: flat });
350
- const effects: { type: string; controlId: string }[] = [];
351
- if (action.type === 'open_picker') effects.push({ type: 'opened_picker', controlId: ctl.id });
352
- else if (action.type === 'send_keys') effects.push({ type: 'sent_keys', controlId: ctl.id });
353
- return Promise.resolve({ ok: true, effects });
368
+ return Promise.resolve({ ok: true, effects: [{ type: 'sent_keys', controlId: ctl.id }] });
369
+ }
370
+
371
+ /**
372
+ * Open an `open_picker` control and return the options the CLI is showing,
373
+ * parsed live from the screen via `extract_choices`. Nothing is selected —
374
+ * the picker is left open so a follow-up SELECT invoke can commit a choice.
375
+ */
376
+ private async openPickerAndListChoices(
377
+ ctl: Control,
378
+ action: Extract<ControlAction, { type: 'open_picker' }>,
379
+ ): Promise<unknown> {
380
+ this.driver.dispatch({ kind: 'click_control', control_id: ctl.id });
381
+ const ready = await this.waitForPickerRendered(action);
382
+ const options = this.extractPickerChoices(action);
383
+ const currentValue = options.find(o => o.current)?.label;
384
+ return {
385
+ ok: true,
386
+ effects: [{ type: 'opened_picker', controlId: ctl.id }],
387
+ controlResult: {
388
+ options: options.map(o => ({ value: o.label, label: o.label, current: o.current })),
389
+ ...(currentValue ? { currentValue } : {}),
390
+ source: 'screen-parse',
391
+ ...(ready ? {} : { warning: 'picker_render_timeout' }),
392
+ },
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Drive an already-listable picker to a specific option. The option can be
398
+ * named (choiceLabel — matched against the parsed on-screen labels) or
399
+ * positional (choiceIndex — the on-screen number). The actual keystrokes
400
+ * come from the spec's `submit_key` with `{index}` substituted, so the spec
401
+ * — not this code — decides how a selection is keyed for each CLI.
402
+ */
403
+ private async selectPickerChoice(
404
+ ctl: Control,
405
+ action: Extract<ControlAction, { type: 'open_picker' }>,
406
+ choiceIndex: number | undefined,
407
+ choiceLabel: string | undefined,
408
+ ): Promise<unknown> {
409
+ // Open + wait so the choice list is on screen before we resolve the
410
+ // label → index mapping (a fresh invoke may arrive with the picker
411
+ // closed; re-opening an open picker is a no-op on these CLIs).
412
+ this.driver.dispatch({ kind: 'click_control', control_id: ctl.id });
413
+ await this.waitForPickerRendered(action);
414
+ const options = this.extractPickerChoices(action);
415
+
416
+ let index = choiceIndex;
417
+ if ((index == null || !Number.isFinite(index)) && choiceLabel) {
418
+ const needle = choiceLabel.trim().toLowerCase();
419
+ const match = options.find(o => o.label.toLowerCase().includes(needle));
420
+ if (!match) {
421
+ return { ok: false, error: `choice not found on screen: ${choiceLabel}`, controlResult: { options: options.map(o => ({ value: o.label, label: o.label })) } };
422
+ }
423
+ index = match.index;
424
+ }
425
+ if (index == null || !Number.isFinite(index)) {
426
+ return { ok: false, error: 'choiceIndex or choiceLabel required to select' };
427
+ }
428
+
429
+ const keys = (action.submit_key || '{index}\r').replace(/\{index\}/g, String(index));
430
+ this.driver.dispatch({ kind: 'pty_write', data: keys });
431
+ const selected = options.find(o => o.index === index);
432
+ return {
433
+ ok: true,
434
+ effects: [{ type: 'selected_choice', controlId: ctl.id }],
435
+ controlResult: {
436
+ ok: true,
437
+ ...(selected ? { currentValue: selected.label } : {}),
438
+ selectedIndex: index,
439
+ },
440
+ };
441
+ }
442
+
443
+ /** Poll the live screen until the picker's `wait_for` condition matches,
444
+ * up to a short budget. Returns true if it rendered, false on timeout. */
445
+ private async waitForPickerRendered(action: Extract<ControlAction, { type: 'open_picker' }>): Promise<boolean> {
446
+ const wf = action.wait_for;
447
+ if (!wf?.regex) { await delay(250); return true; }
448
+ const re = new RegExp(wf.regex, wf.flags ?? 'i');
449
+ const deadline = Date.now() + 2500;
450
+ while (Date.now() < deadline) {
451
+ await delay(120);
452
+ const hay = this.readScreenSectionText(wf.section);
453
+ if (re.test(hay)) return true;
454
+ }
455
+ return false;
456
+ }
457
+
458
+ /** Parse the picker's `extract_choices` pattern against the live screen.
459
+ * Each match yields { index, label, current }. `current` is true for the
460
+ * line the CLI marks with its cursor glyph (❯ ›). Purely screen-driven —
461
+ * no model/mode names are baked in. */
462
+ private extractPickerChoices(action: Extract<ControlAction, { type: 'open_picker' }>): Array<{ index: number; label: string; current: boolean }> {
463
+ const ec = action.extract_choices;
464
+ if (!ec?.pattern) return [];
465
+ const text = this.readScreenSectionText(ec.section);
466
+ const out: Array<{ index: number; label: string; current: boolean }> = [];
467
+ const seen = new Set<number>();
468
+ for (const rawLine of text.split('\n')) {
469
+ const line = rawLine.replace(/\r$/, '');
470
+ const m = new RegExp(ec.pattern, ec.flags ?? '').exec(line);
471
+ if (!m) continue;
472
+ const idx = Number(m[1]);
473
+ if (!Number.isFinite(idx) || seen.has(idx)) continue;
474
+ const label = (m[2] ?? '').replace(/\s+/g, ' ').trim();
475
+ if (!label) continue;
476
+ const current = /^\s*[❯›>]/.test(line) || /[✔✓●]\s*$/.test(label);
477
+ seen.add(idx);
478
+ out.push({ index: idx, label, current });
479
+ }
480
+ return out;
481
+ }
482
+
483
+ /** Live text of a named screen section (or the whole screen when no
484
+ * section is named), resolved from the driver's current sections. */
485
+ private readScreenSectionText(sectionId?: string): string {
486
+ try {
487
+ const sections = this.driver.getSections();
488
+ if (sectionId && sections) {
489
+ const hit = sections.find(s => s.id === sectionId);
490
+ if (hit) return hit.text;
491
+ }
492
+ return this.driver.getScreen();
493
+ } catch {
494
+ return '';
495
+ }
354
496
  }
355
497
  getDebugSnapshot(): unknown {
356
498
  let screen = '';
@@ -752,7 +752,20 @@ export class FsmDriver implements ISpecDriver {
752
752
  switch (a.type) {
753
753
  case 'send_keys': this.adapter.send_keys(a.keys); return;
754
754
  case 'open_picker':
755
- this.adapter.send_keys(a.trigger_keys);
755
+ // Some TUIs (e.g. codex) don't register a slash command if its
756
+ // text and the submitting Enter arrive in the same write — the
757
+ // composer needs a beat to recognise the command before the CR.
758
+ // Split a trailing CR/LF off the trigger and send it after a
759
+ // short delay, mirroring send_message's delay_ms_before_submit.
760
+ {
761
+ const m = /^([\s\S]*?)([\r\n]+)$/.exec(a.trigger_keys);
762
+ if (m && m[1]) {
763
+ this.adapter.send_keys(m[1]);
764
+ setTimeout(() => this.adapter.send_keys(m[2]), 200);
765
+ } else {
766
+ this.adapter.send_keys(a.trigger_keys);
767
+ }
768
+ }
756
769
  this.pickerInProgress = { control_id: ctl.id, spec: ctl };
757
770
  return;
758
771
  case 'attach_image': {
@@ -26,6 +26,7 @@ import type {
26
26
  NativeHistoryJsonlSource,
27
27
  NativeHistoryMessageMap,
28
28
  NativeHistorySqliteSource,
29
+ NativeHistoryToolMap,
29
30
  } from './types.js';
30
31
 
31
32
  export interface NativeHistoryInput {
@@ -163,8 +164,7 @@ function executeJsonl(src: NativeHistoryJsonlSource, input: NativeHistoryInput):
163
164
  for (let i = 0; i < lines.length; i += 1) {
164
165
  const rec = lines[i];
165
166
  if (filter && !filter(rec)) continue;
166
- const msg = projectMessage(rec, src.message_map, i, lines.length, mtime);
167
- if (msg) {
167
+ for (const msg of projectMessages(rec, src.message_map, i, lines.length, mtime)) {
168
168
  if (transcriptWorkspace) msg.workspace = transcriptWorkspace;
169
169
  messages.push(msg);
170
170
  }
@@ -256,8 +256,9 @@ function executeSqlite(src: NativeHistorySqliteSource, input: NativeHistoryInput
256
256
  const mtime = safeMtimeMs(resolved);
257
257
  const messages: NativeHistoryMessage[] = [];
258
258
  for (let i = 0; i < messageRows.length; i += 1) {
259
- const msg = projectMessage(messageRows[i], src.message_map, i, messageRows.length, mtime);
260
- if (msg) messages.push(msg);
259
+ for (const msg of projectMessages(messageRows[i], src.message_map, i, messageRows.length, mtime)) {
260
+ messages.push(msg);
261
+ }
261
262
  }
262
263
  if (messages.length === 0) return null;
263
264
 
@@ -749,11 +750,75 @@ function jsonPathGet(record: any, expr: string): unknown {
749
750
  return cur;
750
751
  }
751
752
 
752
- function projectMessage(record: any, map: NativeHistoryMessageMap, index: number, total: number, sourceMtimeMs: number): NativeHistoryMessage | null {
753
+ /**
754
+ * Project one on-disk record into zero or more transcript messages.
755
+ *
756
+ * A record yields at most one text bubble (the prose turn) plus — when the
757
+ * spec declares `message_map.tools` — one `kind:'tool'` bubble per tool-call
758
+ * or tool-result content block. Without `tools`, behaviour is identical to
759
+ * the old single-message projection: text-only, tool blocks dropped.
760
+ */
761
+ function projectMessages(record: any, map: NativeHistoryMessageMap, index: number, total: number, sourceMtimeMs: number): NativeHistoryMessage[] {
753
762
  const roleRaw = jsonPathGet(record, map.role);
754
- const contentRaw = jsonPathGet(record, map.content);
755
763
  const role = normalizeRole(roleRaw);
756
- let content = stringifyContent(contentRaw);
764
+
765
+ // Records are passed in chronological order (oldest → newest), so the
766
+ // last record's receivedAt should be ~sourceMtimeMs (when the file was
767
+ // last touched) and earlier records should walk backwards. Earlier
768
+ // version had this inverted, which made the dashboard render bubbles
769
+ // in reverse order and produce the "chat jumping" effect.
770
+ let receivedAt = sourceMtimeMs - ((total - 1 - index) * 1000);
771
+ if (map.timestamp_ms) {
772
+ const tsRaw = jsonPathGet(record, map.timestamp_ms);
773
+ const parsed = parseTimestamp(tsRaw);
774
+ if (parsed != null) receivedAt = parsed;
775
+ }
776
+ const kindRaw = map.kind ? jsonPathGet(record, map.kind) : undefined;
777
+ const kind = typeof kindRaw === 'string' && kindRaw ? kindRaw : 'standard';
778
+
779
+ const out: NativeHistoryMessage[] = [];
780
+
781
+ // Two transcript shapes carry tool activity:
782
+ // - record-level: the whole record IS a tool call/result (codex stores
783
+ // each function_call / function_call_output as its own jsonl record).
784
+ // - block-nested: tool blocks live inside the message's content array
785
+ // (claude stores tool_use / tool_result as content blocks).
786
+ // When the spec opts into `tools`, try the record itself first; if it's a
787
+ // tool record we emit only that bubble (it has no prose). Otherwise emit
788
+ // the text bubble plus a tool bubble per matching content block.
789
+ if (map.tools) {
790
+ const recordTool = projectToolBlock(record, role, map.tools);
791
+ if (recordTool) {
792
+ out.push({ ...recordTool, receivedAt });
793
+ return out;
794
+ }
795
+ }
796
+
797
+ const contentRaw = jsonPathGet(record, map.content);
798
+ const content = cleanContent(stringifyContent(contentRaw), map);
799
+ if (content) out.push({ role, content, receivedAt, kind });
800
+
801
+ // Block-nested tool bubbles are ordered just after the text bubble of the
802
+ // same record by nudging receivedAt forward a millisecond per bubble, so a
803
+ // turn's prose still renders before its tool activity without colliding
804
+ // with the next record's timestamp.
805
+ if (map.tools && Array.isArray(contentRaw)) {
806
+ let nudge = 1;
807
+ for (const block of contentRaw) {
808
+ const tool = projectToolBlock(block, role, map.tools);
809
+ if (tool) {
810
+ out.push({ ...tool, receivedAt: receivedAt + nudge });
811
+ nudge += 1;
812
+ }
813
+ }
814
+ }
815
+
816
+ return out;
817
+ }
818
+
819
+ /** Apply content_strip / content_unwrap tag surgery and trim. */
820
+ function cleanContent(input: string, map: NativeHistoryMessageMap): string {
821
+ let content = input;
757
822
  if (content && map.content_strip) {
758
823
  for (const tag of map.content_strip) {
759
824
  const safeTag = tag.replace(/[.+^${}()|[\]\\]/g, '\\$&');
@@ -769,23 +834,50 @@ function projectMessage(record: any, map: NativeHistoryMessageMap, index: number
769
834
  content = content.replace(open, '').replace(close, '');
770
835
  }
771
836
  }
772
- if (content) content = content.trim();
773
- if (!content) return null;
837
+ return content ? content.trim() : '';
838
+ }
774
839
 
775
- // Records are passed in chronological order (oldest → newest), so the
776
- // last record's receivedAt should be ~sourceMtimeMs (when the file was
777
- // last touched) and earlier records should walk backwards. Earlier
778
- // version had this inverted, which made the dashboard render bubbles
779
- // in reverse order and produce the "chat jumping" effect.
780
- let receivedAt = sourceMtimeMs - ((total - 1 - index) * 1000);
781
- if (map.timestamp_ms) {
782
- const tsRaw = jsonPathGet(record, map.timestamp_ms);
783
- const parsed = parseTimestamp(tsRaw);
784
- if (parsed != null) receivedAt = parsed;
840
+ const DEFAULT_TOOL_CALL_TYPES = ['tool_use', 'function_call', 'custom_tool_call'];
841
+ const DEFAULT_TOOL_RESULT_TYPES = ['tool_result', 'function_call_output', 'custom_tool_call_output'];
842
+
843
+ /**
844
+ * Turn a single content block into a `kind:'tool'` message, or null if the
845
+ * block is not a tool call/result. Field locations come from the spec's
846
+ * `tools` map with Anthropic-block defaults.
847
+ *
848
+ * Both tool calls and tool results render on the assistant side: a tool call
849
+ * is the agent's action, and a tool result is part of the agent's work, not a
850
+ * user turn (claude/codex persist results under the user / no role, which would
851
+ * otherwise misattribute them). Calls render as `↗ {name}: {one-line args}`,
852
+ * results as `↘ {one-line result}`. The `role` param is accepted for symmetry
853
+ * but tool bubbles are always assistant.
854
+ */
855
+ function projectToolBlock(block: any, role: 'user' | 'assistant' | 'system', tmap: NativeHistoryToolMap): NativeHistoryMessage | null {
856
+ void role;
857
+ if (block == null || typeof block !== 'object') return null;
858
+ const typeVal = String(jsonPathGet(block, tmap.block_type || '$.type') ?? '');
859
+ if (!typeVal) return null;
860
+ const callTypes = tmap.call_types ?? DEFAULT_TOOL_CALL_TYPES;
861
+ const resultTypes = tmap.result_types ?? DEFAULT_TOOL_RESULT_TYPES;
862
+
863
+ if (callTypes.includes(typeVal)) {
864
+ const name = String(jsonPathGet(block, tmap.call_name || '$.name') ?? 'tool').trim() || 'tool';
865
+ const args = oneLine(stringifyContent(jsonPathGet(block, tmap.call_args || '$.input')), 240);
866
+ const content = args ? `↗ ${name}: ${args}` : `↗ ${name}`;
867
+ return { role: 'assistant', content, receivedAt: 0, kind: 'tool' };
785
868
  }
786
- const kindRaw = map.kind ? jsonPathGet(record, map.kind) : undefined;
787
- const kind = typeof kindRaw === 'string' && kindRaw ? kindRaw : 'standard';
788
- return { role, content, receivedAt, kind };
869
+ if (resultTypes.includes(typeVal)) {
870
+ const result = oneLine(stringifyContent(jsonPathGet(block, tmap.result_content || '$.content')), 600);
871
+ if (!result) return null;
872
+ return { role: 'assistant', content: `↘ ${result}`, receivedAt: 0, kind: 'tool' };
873
+ }
874
+ return null;
875
+ }
876
+
877
+ /** Collapse whitespace to single spaces and cap length for a tool summary. */
878
+ function oneLine(s: string, max: number): string {
879
+ const flat = s.replace(/\s+/g, ' ').trim();
880
+ return flat.length > max ? flat.slice(0, max - 1) + '…' : flat;
789
881
  }
790
882
 
791
883
  /**
@@ -98,6 +98,43 @@ export interface NativeHistoryMessageMap {
98
98
  content_unwrap?: string[];
99
99
  timestamp_ms?: string;
100
100
  kind?: string;
101
+ /**
102
+ * Declarative tool-bubble extraction. Without it the executor only emits
103
+ * the text-bearing parts of each record, so a turn that is purely a tool
104
+ * call or tool result (no prose) is dropped — the restored transcript
105
+ * loses every tool interaction. When present, the executor walks each
106
+ * record's content blocks and emits an extra `kind:'tool'` message for
107
+ * any block whose `$.type` matches a tool shape.
108
+ *
109
+ * Defaults target the Anthropic-style content-block shape that claude-cli
110
+ * and codex-cli persist (blocks of `{ type: 'tool_use' | 'tool_result',
111
+ * name, input, content }`); a provider with a different on-disk shape
112
+ * overrides the field paths. Set `tools: {}` to opt in with the defaults.
113
+ */
114
+ tools?: NativeHistoryToolMap;
115
+ }
116
+
117
+ /**
118
+ * How to surface tool-call / tool-result blocks as `kind:'tool'` bubbles.
119
+ * Every field is optional — the defaults read the Anthropic block shape.
120
+ * Paths are jsonpath-lite evaluated against a single content block (the
121
+ * element of `$.message.content[]`), not the whole record.
122
+ */
123
+ export interface NativeHistoryToolMap {
124
+ /** Path to a block's discriminator. Default `$.type`. */
125
+ block_type?: string;
126
+ /** Block-type values that mean "a tool was invoked". Default
127
+ * `['tool_use', 'function_call', 'custom_tool_call']`. */
128
+ call_types?: string[];
129
+ /** Block-type values that mean "a tool returned". Default
130
+ * `['tool_result', 'function_call_output', 'custom_tool_call_output']`. */
131
+ result_types?: string[];
132
+ /** Path to the tool name on a call block. Default `$.name`. */
133
+ call_name?: string;
134
+ /** Path to the tool arguments on a call block. Default `$.input`. */
135
+ call_args?: string;
136
+ /** Path to the result payload on a result block. Default `$.content`. */
137
+ result_content?: string;
101
138
  }
102
139
 
103
140
  // ─────────────────────────────────────────────────────────────────────────────
@@ -91,6 +91,93 @@ export type RepoMeshNodeHealth =
91
91
  export type RepoMeshSessionCleanupMode = 'preserve' | 'stop' | 'delete_stopped' | 'stop_and_delete';
92
92
  export type RepoMeshSpawnedSessionVisibility = 'visible' | 'hidden';
93
93
 
94
+ /**
95
+ * Mesh-wide tie-break strategy for distributing untargeted queue work across
96
+ * eligible nodes. This ONLY governs the final tie-break stage of the scheduler
97
+ * pipeline (TAG hard-filter → MAX-ALLOC capacity gate → PRIORITY soft score →
98
+ * TIE-BREAK); eligibility/capacity/priority are evaluated identically for every
99
+ * strategy.
100
+ *
101
+ * - 'first_eligible' (DEFAULT): preserve today's behavior exactly. Nodes are
102
+ * visited in config/array order and the first that can launch wins. No
103
+ * load-spreading. This is the strict no-change default — a mesh that never
104
+ * sets schedulingStrategy behaves identically to before this feature.
105
+ * - 'least_loaded': prefer the eligible node with the fewest active assignments,
106
+ * so untargeted work spreads instead of piling onto whichever node asks first.
107
+ * - 'round_robin': among nodes tied at the least load, rotate the winner using a
108
+ * per-mesh cursor so distribution stays fair across passes.
109
+ * - 'priority_only': rank purely by schedulingPriority (then config order),
110
+ * ignoring load — always send to the highest-priority eligible node.
111
+ *
112
+ * Distribution is explicit opt-in: a strategy other than 'first_eligible' must be
113
+ * configured for any load-spreading to occur.
114
+ */
115
+ export type RepoMeshSchedulingStrategy =
116
+ | 'first_eligible'
117
+ | 'least_loaded'
118
+ | 'round_robin'
119
+ | 'priority_only';
120
+
121
+ export const MESH_SCHEDULING_STRATEGIES: RepoMeshSchedulingStrategy[] = [
122
+ 'first_eligible',
123
+ 'least_loaded',
124
+ 'round_robin',
125
+ 'priority_only',
126
+ ];
127
+
128
+ export const DEFAULT_MESH_SCHEDULING_STRATEGY: RepoMeshSchedulingStrategy = 'first_eligible';
129
+
130
+ /**
131
+ * Normalize an unknown scheduling-strategy value to a valid strategy, defaulting
132
+ * to 'first_eligible' (strict no-change) for anything missing/blank/unrecognized.
133
+ */
134
+ export function normalizeMeshSchedulingStrategy(value: unknown): RepoMeshSchedulingStrategy {
135
+ if (typeof value !== 'string') return DEFAULT_MESH_SCHEDULING_STRATEGY;
136
+ const trimmed = value.trim() as RepoMeshSchedulingStrategy;
137
+ return (MESH_SCHEDULING_STRATEGIES as string[]).includes(trimmed)
138
+ ? trimmed
139
+ : DEFAULT_MESH_SCHEDULING_STRATEGY;
140
+ }
141
+
142
+ /**
143
+ * Resolve a node's soft scheduling priority — a single scalar used as the PRIORITY
144
+ * stage rank key (higher = preferred). It is NOT an eligibility gate (the MAX-ALLOC
145
+ * capacity gate alone decides whether a node can take work). Missing/blank/NaN
146
+ * resolves to 0 so unconfigured nodes all share the same neutral priority.
147
+ */
148
+ export function resolveNodeSchedulingPriority(
149
+ nodePolicy: Pick<RepoMeshNodePolicy, 'schedulingPriority'> | null | undefined,
150
+ ): number {
151
+ const raw = Number(nodePolicy?.schedulingPriority);
152
+ return Number.isFinite(raw) ? raw : 0;
153
+ }
154
+
155
+ /**
156
+ * Synthetic capability tag advertised by every mesh node describing how it can land
157
+ * its work onto the base branch:
158
+ * - converge=refine: a local worktree node (on any machine — refine_mesh_node
159
+ * forwards to the owning daemon) can run the Refinery merge → push → cleanup.
160
+ * - converge=fast_forward: a non-worktree node (the machine itself) can only
161
+ * fast-forward/push an already-converged branch.
162
+ * Emitted by buildMeshNodeCapabilityTags and matched through the ordinary
163
+ * required-tags filter.
164
+ */
165
+ export const MESH_CONVERGE_REFINE_TAG = 'converge=refine';
166
+ export const MESH_CONVERGE_FAST_FORWARD_TAG = 'converge=fast_forward';
167
+
168
+ /**
169
+ * Resolve whether the load-balancing scheduler should auto-inject a
170
+ * `converge=refine` required tag onto code_change tasks so they hard-filter onto
171
+ * refine-capable (worktree) nodes only. Strict opt-in: defaults to false, so a mesh
172
+ * that does not set it behaves exactly as before (code_change routes to any eligible
173
+ * node, including a non-worktree machine node when no worktree exists).
174
+ */
175
+ export function resolveAutoConvergeCodeChange(
176
+ policy: Pick<RepoMeshPolicy, 'autoConvergeCodeChange'> | null | undefined,
177
+ ): boolean {
178
+ return policy?.autoConvergeCodeChange === true;
179
+ }
180
+
94
181
  export interface RepoMeshAutoFastForwardPolicy {
95
182
  /** Defaults to true. Set false to disable daemon-initiated idle fast-forwards. */
96
183
  enabled: boolean;
@@ -115,6 +202,24 @@ export interface RepoMeshPolicy {
115
202
  dirtyWorkspaceBehavior: 'block' | 'warn' | 'checkpoint_then_continue';
116
203
  maxParallelTasks: number;
117
204
  allowedProviders?: string[];
205
+ /**
206
+ * Mesh-wide tie-break strategy for distributing untargeted queue work across
207
+ * eligible nodes. Defaults to 'first_eligible' (today's exact behavior — no
208
+ * load-spreading). Set to 'least_loaded' / 'round_robin' / 'priority_only' to
209
+ * opt into distribution. Only governs the final tie-break stage; eligibility,
210
+ * capacity, and priority are evaluated identically regardless of strategy.
211
+ */
212
+ schedulingStrategy?: RepoMeshSchedulingStrategy;
213
+ /**
214
+ * Convergence routing opt-in: when true, the scheduler auto-injects a
215
+ * `converge=refine` required tag onto every code_change task at enqueue time, so
216
+ * code_change work hard-filters onto refine-capable worktree nodes (on any
217
+ * machine — refine_mesh_node forwards to the owning daemon) and never lands on a
218
+ * non-worktree machine node. Explicit target_node_id routing and any
219
+ * caller-supplied required_tags are preserved (the tag is merged, not replaced).
220
+ * Defaults to false: code_change routing is unchanged unless opted in.
221
+ */
222
+ autoConvergeCodeChange?: boolean;
118
223
  /**
119
224
  * Whether sessions spawned by mesh/coordinator policy should auto-open as visible
120
225
  * dashboard tabs or start hidden. Defaults to 'visible' to preserve existing
@@ -163,12 +268,17 @@ export interface RepoMeshRelatedRepo {
163
268
  *
164
269
  * `role` is a free-form resource-pool label (recommended values:
165
270
  * 'investigation' | 'coding' | 'orchestration') describing what this
166
- * (node, provider) combination is *for*. It is a label/hint only it is
167
- * surfaced to the coordinator and dashboards but the daemon does NOT route
168
- * tasks by role (task routing stays driven by taskMode + requiredTags +
169
- * targetNode/targetSession). role is intentionally orthogonal to taskMode:
170
- * taskMode classifies the *work* (code_change vs live_debug_readonly), role
171
- * classifies the *resource pool*.
271
+ * (node, provider) combination is *for*. As of the load-balancing scheduler it
272
+ * is ALSO routable: each declared role is advertised as a synthetic `role=<x>`
273
+ * capability tag (see buildMeshNodeCapabilityTags), so a task enqueued with
274
+ * requiredTags: ["role=validation"] is hard-filtered to nodes/providers that
275
+ * declare that role — through the same nodeSatisfiesRequiredTags path as any
276
+ * other tag. There is intentionally no separate "advertisedRoles" field: the
277
+ * label and the routing tag are one mechanism. A task that does not require a
278
+ * `role=` tag ignores roles entirely (opt-in, fully backward compatible).
279
+ *
280
+ * role is intentionally orthogonal to taskMode: taskMode classifies the *work*
281
+ * (code_change vs live_debug_readonly), role classifies the *resource pool*.
172
282
  *
173
283
  * `maxParallel` is the only enforced field: the queue will not assign a task
174
284
  * to this (node, provider) once it already has `maxParallel` active
@@ -190,15 +300,24 @@ export interface RepoMeshNodePolicy {
190
300
  readOnly?: boolean;
191
301
  canPush?: boolean;
192
302
  maxConcurrentSessions?: number;
303
+ /**
304
+ * Soft scheduling priority used as the PRIORITY rank key (higher = preferred)
305
+ * when the mesh schedulingStrategy spreads work across nodes. Defaults to 0.
306
+ * This is NOT an eligibility gate — a node with a high priority that is at its
307
+ * capacity (MAX-ALLOC gate) is still skipped; priority only orders nodes that
308
+ * can actually take work. Ignored entirely under 'first_eligible'.
309
+ */
310
+ schedulingPriority?: number;
193
311
  /** Ordered provider preference used when mesh_launch_session omits an explicit type. */
194
312
  providerPriority?: string[];
195
313
  /**
196
314
  * Per-(node, provider) role + parallelism declarations. Each entry binds a
197
315
  * providerType on THIS node to an optional free-form role label and an
198
- * optional maxParallel cap. Only maxParallel is enforced (as an additional,
316
+ * optional maxParallel cap. maxParallel is enforced (as an additional,
199
317
  * stricter-wins constraint on top of the global maxParallelTasks/taskMode
200
- * caps); role is a label/hint surfaced to the coordinator. Missing/empty:
201
- * the node behaves exactly as before (global caps only).
318
+ * caps); role is advertised as a routable `role=<x>` capability tag so tasks
319
+ * can hard-filter by required role. Missing/empty: the node behaves exactly
320
+ * as before (global caps only, no role tags).
202
321
  */
203
322
  providerRoles?: RepoMeshProviderRole[];
204
323
  /**