@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.
- package/dist/commands/router.d.ts +4 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +525 -54
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +517 -54
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-coordinator.d.ts +31 -0
- package/dist/mesh/mesh-runtime-store.d.ts +18 -0
- package/dist/mesh/mesh-work-queue.d.ts +23 -0
- package/dist/providers/spec/cli-adapter.d.ts +34 -3
- package/dist/providers/spec/types.d.ts +36 -0
- package/dist/repo-mesh-types.d.ts +97 -9
- package/package.json +2 -2
- package/src/commands/chat-commands.ts +10 -2
- package/src/commands/router.ts +139 -3
- package/src/commands/stream-commands.ts +8 -0
- package/src/config/chat-history.ts +9 -0
- package/src/config/mesh-config.ts +17 -1
- package/src/index.ts +13 -2
- package/src/mesh/mesh-events-coordinator.ts +165 -9
- package/src/mesh/mesh-runtime-store.ts +52 -0
- package/src/mesh/mesh-work-queue.ts +105 -1
- package/src/providers/spec/cli-adapter.ts +155 -13
- package/src/providers/spec/fsm-driver.ts +14 -1
- package/src/providers/spec/native-history-executor.ts +114 -22
- package/src/providers/spec/types.ts +37 -0
- package/src/repo-mesh-types.ts +128 -9
|
@@ -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 →
|
|
315
|
-
*
|
|
316
|
-
*
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
773
|
-
|
|
837
|
+
return content ? content.trim() : '';
|
|
838
|
+
}
|
|
774
839
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
// ─────────────────────────────────────────────────────────────────────────────
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -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*.
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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.
|
|
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
|
|
201
|
-
*
|
|
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
|
/**
|