@adhdev/daemon-core 0.9.82-rc.310 → 0.9.82-rc.312
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 +19 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1070 -263
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1069 -270
- package/dist/index.mjs.map +1 -1
- package/dist/logging/log-redactor.d.ts +24 -0
- package/dist/logging/log-tail-reader.d.ts +46 -0
- 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 +103 -9
- package/package.json +2 -2
- package/src/commands/chat-commands.ts +10 -2
- package/src/commands/router.ts +323 -6
- 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 +16 -2
- package/src/logging/log-redactor.ts +100 -0
- package/src/logging/log-tail-reader.ts +220 -0
- package/src/mesh/coordinator-prompt.ts +1 -0
- 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 +134 -9
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
2
|
import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
|
|
3
3
|
import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
|
|
4
|
+
import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange } from '../repo-mesh-types.js';
|
|
4
5
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
5
6
|
import { getMesh } from '../config/mesh-config.js';
|
|
6
7
|
|
|
@@ -203,6 +204,45 @@ function firstProviderPriority(policy: unknown): string | undefined {
|
|
|
203
204
|
return raw.find(type => typeof type === 'string' && type.trim())?.trim();
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Synthetic `role=<x>` capability tags advertised by a node's policy.providerRoles.
|
|
209
|
+
*
|
|
210
|
+
* When a specific `providerType` is being evaluated, only that provider's declared
|
|
211
|
+
* role is emitted — so role-based routing (requiredTags: ["role=validation"]) gates
|
|
212
|
+
* the *selected* provider through the ordinary capability-tag filter. When no
|
|
213
|
+
* provider is selected (node-level eligibility scan), every declared role is emitted
|
|
214
|
+
* so the node passes the filter if ANY of its providers could satisfy the role; the
|
|
215
|
+
* per-provider tag set then narrows it during provider selection.
|
|
216
|
+
*
|
|
217
|
+
* Roles are lowercased and deduped. Missing/empty providerRoles emits nothing, so a
|
|
218
|
+
* node that never declares roles advertises no `role=` tags and is therefore only
|
|
219
|
+
* matched by role-unconstrained tasks (full backward compatibility).
|
|
220
|
+
*/
|
|
221
|
+
function roleCapabilityTags(policy: unknown, providerType: string | undefined): string[] {
|
|
222
|
+
const roles = policy && typeof policy === 'object' && !Array.isArray(policy)
|
|
223
|
+
? (policy as Record<string, unknown>).providerRoles
|
|
224
|
+
: undefined;
|
|
225
|
+
if (!Array.isArray(roles)) return [];
|
|
226
|
+
const wantedProvider = typeof providerType === 'string' && providerType.trim()
|
|
227
|
+
? providerType.trim().toLowerCase()
|
|
228
|
+
: '';
|
|
229
|
+
const out: string[] = [];
|
|
230
|
+
for (const entry of roles) {
|
|
231
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
232
|
+
const type = typeof (entry as any).providerType === 'string'
|
|
233
|
+
? (entry as any).providerType.trim().toLowerCase()
|
|
234
|
+
: '';
|
|
235
|
+
const role = typeof (entry as any).role === 'string'
|
|
236
|
+
? (entry as any).role.trim().toLowerCase()
|
|
237
|
+
: '';
|
|
238
|
+
if (!role) continue;
|
|
239
|
+
// When narrowing to a selected provider, only emit that provider's role.
|
|
240
|
+
if (wantedProvider && type && type !== wantedProvider) continue;
|
|
241
|
+
out.push(`role=${role}`);
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
206
246
|
export function buildMeshNodeCapabilityTags(
|
|
207
247
|
node: { capabilities?: unknown; policy?: unknown; isLocalWorktree?: unknown; worktreeBranch?: unknown } | undefined,
|
|
208
248
|
providerType?: string,
|
|
@@ -222,6 +262,24 @@ export function buildMeshNodeCapabilityTags(
|
|
|
222
262
|
// mesh_enqueue_task with required_tags: ["worktree=<branch>"] routes
|
|
223
263
|
// only to the matching worktree node.
|
|
224
264
|
...(node?.isLocalWorktree === true && worktreeBranch ? [`worktree=${worktreeBranch}`] : []),
|
|
265
|
+
// Convergence routing: advertise how this node can land its work onto base.
|
|
266
|
+
// - converge=refine: local worktree nodes (on ANY machine — refine_mesh_node
|
|
267
|
+
// now forwards to the owning daemon) can run the Refinery merge → push →
|
|
268
|
+
// cleanup against their own checkout, so they accept code_change tasks.
|
|
269
|
+
// - converge=fast_forward: non-worktree nodes (the machine itself) can only
|
|
270
|
+
// ff/push an already-converged branch; they are NOT a destination for
|
|
271
|
+
// code_change work (a worktree is created first, and that worktree node
|
|
272
|
+
// receives the task instead). Reuses the ordinary required-tags filter —
|
|
273
|
+
// the load-balancing scheduler auto-injects converge=refine for code_change
|
|
274
|
+
// so such work is hard-filtered onto refine-capable nodes.
|
|
275
|
+
...(node?.isLocalWorktree === true ? ['converge=refine'] : ['converge=fast_forward']),
|
|
276
|
+
// Role-based routing: advertise role=<x> for each (node, provider) role
|
|
277
|
+
// declared in policy.providerRoles. Narrowed to the selected provider when
|
|
278
|
+
// one is given so the chosen provider must match a task's required role;
|
|
279
|
+
// when no provider is selected, all declared roles are advertised for the
|
|
280
|
+
// node-level eligibility scan. Reuses the ordinary required-tags filter —
|
|
281
|
+
// no separate role field/gate.
|
|
282
|
+
...roleCapabilityTags(node?.policy, providerType),
|
|
225
283
|
]);
|
|
226
284
|
}
|
|
227
285
|
|
|
@@ -232,6 +290,44 @@ export function nodeSatisfiesRequiredTags(requiredTags: unknown, capabilityTags:
|
|
|
232
290
|
return required.every(tag => available.has(tag));
|
|
233
291
|
}
|
|
234
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Convergence-aware required-tags resolution (load-balancing scheduler, opt-in).
|
|
295
|
+
*
|
|
296
|
+
* When the mesh enables policy.autoConvergeCodeChange, a `converge=refine` required
|
|
297
|
+
* tag is merged into a code_change task's required tags at enqueue time, so the
|
|
298
|
+
* scheduler hard-filters the task onto refine-capable worktree nodes only (on any
|
|
299
|
+
* machine — refine_mesh_node forwards to the owning daemon). Because the tag is
|
|
300
|
+
* persisted on the queue entry, BOTH the eligibility scan (maybeAutoLaunchOneQueueSession)
|
|
301
|
+
* and the claim transaction (claimNextQueueTask → nodeSatisfiesRequiredTags) enforce
|
|
302
|
+
* it consistently.
|
|
303
|
+
*
|
|
304
|
+
* Strict backward compatibility — the injection is skipped (returns the explicit tags
|
|
305
|
+
* unchanged) when ANY of:
|
|
306
|
+
* - the mesh does not opt in (autoConvergeCodeChange !== true), or
|
|
307
|
+
* - the task is not code_change (validation / live_debug_readonly / launch_app /
|
|
308
|
+
* convergence carry no merge cost and may run anywhere), or
|
|
309
|
+
* - the task is explicitly targeted (targetNodeId): the operator chose the node, so
|
|
310
|
+
* we do not second-guess it by filtering on convergence capability.
|
|
311
|
+
* Idempotent: normalizeMeshCapabilityTags dedupes, so re-injection is a no-op.
|
|
312
|
+
*/
|
|
313
|
+
export function resolveConvergeRequiredTags(
|
|
314
|
+
meshId: string,
|
|
315
|
+
taskMode: MeshTaskMode | undefined,
|
|
316
|
+
explicitRequiredTags: string[],
|
|
317
|
+
opts?: { targetNodeId?: string },
|
|
318
|
+
): string[] {
|
|
319
|
+
if (taskMode !== 'code_change') return explicitRequiredTags;
|
|
320
|
+
if (typeof opts?.targetNodeId === 'string' && opts.targetNodeId.trim()) return explicitRequiredTags;
|
|
321
|
+
let optedIn = false;
|
|
322
|
+
try {
|
|
323
|
+
optedIn = resolveAutoConvergeCodeChange(getMesh(meshId)?.policy as any);
|
|
324
|
+
} catch {
|
|
325
|
+
optedIn = false;
|
|
326
|
+
}
|
|
327
|
+
if (!optedIn) return explicitRequiredTags;
|
|
328
|
+
return normalizeMeshCapabilityTags([...explicitRequiredTags, MESH_CONVERGE_REFINE_TAG]);
|
|
329
|
+
}
|
|
330
|
+
|
|
235
331
|
function withQueueLock<T>(_meshId: string, fn: () => T): T {
|
|
236
332
|
return MeshRuntimeStore.getInstance().transaction(fn);
|
|
237
333
|
}
|
|
@@ -325,7 +421,15 @@ export function enqueueTask(
|
|
|
325
421
|
taskMode: modeValidation.taskMode,
|
|
326
422
|
targetNodeId: opts?.targetNodeId,
|
|
327
423
|
targetSessionId: opts?.targetSessionId,
|
|
328
|
-
|
|
424
|
+
// Convergence routing (opt-in): auto-inject converge=refine for code_change
|
|
425
|
+
// tasks so they hard-filter onto refine-capable worktree nodes. No-op unless
|
|
426
|
+
// the mesh opts in; explicit target_node_id / required_tags are preserved.
|
|
427
|
+
requiredTags: resolveConvergeRequiredTags(
|
|
428
|
+
meshId,
|
|
429
|
+
modeValidation.taskMode,
|
|
430
|
+
normalizeMeshCapabilityTags(opts?.requiredTags),
|
|
431
|
+
{ targetNodeId: opts?.targetNodeId },
|
|
432
|
+
),
|
|
329
433
|
...(dependsOn.length > 0 ? { dependsOn } : {}),
|
|
330
434
|
...(typeof opts?.missionId === 'string' && opts.missionId.trim() ? { missionId: opts.missionId.trim() } : {}),
|
|
331
435
|
createdAt: new Date().toISOString(),
|
|
@@ -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
|
// ─────────────────────────────────────────────────────────────────────────────
|