@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.
@@ -40,6 +40,8 @@ import { logCommand } from '../logging/command-log.js';
40
40
  import type { CommandLogEntry } from '../logging/command-log.js';
41
41
  import * as yaml from 'js-yaml';
42
42
  import { getRecentLogs, LOG_PATH } from '../logging/logger.js';
43
+ import { readDaemonLogTail, MAX_TAIL_BYTES } from '../logging/log-tail-reader.js';
44
+ import { redactLogLines } from '../logging/log-redactor.js';
43
45
  import { createInteractionId, getRecentDebugTrace, recordDebugTrace } from '../logging/debug-trace.js';
44
46
  import { getSessionHostSurfaceKind, partitionSessionHostRecords } from '../session-host/runtime-surface.js';
45
47
  import { createHermesManualMeshCoordinatorSetup, resolveMeshCoordinatorSetup } from './mesh-coordinator.js';
@@ -513,6 +515,21 @@ function readInlineMeshNodeId(node: any): string {
513
515
  return normalizeMeshNodeId(node) ?? '';
514
516
  }
515
517
 
518
+ // A local worktree node whose workspace directory has been deleted from disk.
519
+ // The worktree was removed (or the machine pruned it) but the node still lingers
520
+ // in the inline mesh cache. Such a node has no live truth to confirm and must
521
+ // never be probed or counted toward direct-peer-truth — doing so blocks the
522
+ // graph with a permanent `direct_peer_truth_unavailable`. Deliberately narrow:
523
+ // it only fires for `isLocalWorktree === true` nodes with a recorded workspace
524
+ // that does not exist. Remote nodes and nodes whose workspace is present on disk
525
+ // are never matched, so a slow remote peer is still classified unavailable.
526
+ function isDeadLocalWorktreeNode(node: any): boolean {
527
+ if (node?.isLocalWorktree !== true) return false;
528
+ const workspace = readStringValue(node?.workspace);
529
+ if (!workspace) return false;
530
+ return !fs.existsSync(workspace);
531
+ }
532
+
516
533
  // Boundary normalization: reconcile a node's identity so `id` and `nodeId` both
517
534
  // carry the same canonical value (any incoming form — id / nodeId / node_id — is
518
535
  // absorbed by normalizeMeshNodeId, and the SQLite `node_id` leak is dropped).
@@ -985,6 +1002,73 @@ function readMeshTimeoutEnvMs(name: string, defaultMs: number): number {
985
1002
  // (still under the P2P REQUEST_TIMEOUT of 30s) and made env-overridable.
986
1003
  const MESH_DIRECT_PROBE_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_TIMEOUT_MS', 25_000);
987
1004
  const MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS', 25_000);
1005
+ // How long a successful per-peer git_status probe stays fresh enough to be
1006
+ // reused instead of issuing another blocking `refreshUpstream:true` fan-out.
1007
+ // A slow (TURN-relayed) peer's probe can take 9-23s, and the dashboard's
1008
+ // auto-retry loop re-fires every few seconds; without this gate every retry
1009
+ // would start a brand new probe storm to the same peer. Within this window the
1010
+ // last successful result is reused so a refresh quiesces instead of looping.
1011
+ // Min-clamped to 1s by readMeshTimeoutEnvMs; raise via env for very slow peers.
1012
+ const MESH_DIRECT_PROBE_REUSE_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_REUSE_MS', 12_000);
1013
+
1014
+ /**
1015
+ * De-duplicates and rate-limits per-peer git_status probes so a single mesh
1016
+ * refresh — or a burst of refreshes from the dashboard auto-retry loop — cannot
1017
+ * launch a storm of concurrent/back-to-back `refreshUpstream:true` commands to
1018
+ * the same slow peer.
1019
+ *
1020
+ * Two gates, both keyed by `daemonId::workspace`:
1021
+ * - In-flight dedup: a second probe for a key with a probe already running
1022
+ * shares (awaits) the in-flight promise instead of issuing a second command.
1023
+ * - Recently-probed reuse: a successful probe younger than `reuseMs` is reused
1024
+ * verbatim instead of issuing a fresh probe. Failures are NOT cached (so a
1025
+ * transient timeout doesn't pin a peer to "no truth" for the whole window).
1026
+ *
1027
+ * Lives on the router instance so the gate spans separate mesh_status calls,
1028
+ * which is exactly where the refresh storm happens.
1029
+ */
1030
+ class MeshGitProbeCache {
1031
+ private inflight = new Map<string, Promise<Record<string, unknown> | null>>();
1032
+ private recent = new Map<string, { at: number; value: Record<string, unknown> }>();
1033
+
1034
+ constructor(private readonly reuseMs: number, private readonly now: () => number = Date.now) {}
1035
+
1036
+ private key(daemonId: string, workspace: string): string {
1037
+ return `${daemonId}::${workspace}`;
1038
+ }
1039
+
1040
+ /**
1041
+ * Run `probe` for this peer, but reuse a fresh recent result or an in-flight
1042
+ * probe for the same key when one is available. `probe` is only invoked when
1043
+ * neither gate is satisfied.
1044
+ */
1045
+ async probe(
1046
+ daemonId: string,
1047
+ workspace: string,
1048
+ probe: () => Promise<Record<string, unknown> | null>,
1049
+ ): Promise<Record<string, unknown> | null> {
1050
+ const key = this.key(daemonId, workspace);
1051
+ const cached = this.recent.get(key);
1052
+ if (cached && this.now() - cached.at < this.reuseMs) {
1053
+ return cached.value;
1054
+ }
1055
+ const existing = this.inflight.get(key);
1056
+ if (existing) return existing;
1057
+ const pending = (async () => {
1058
+ const result = await probe();
1059
+ if (result) this.recent.set(key, { at: this.now(), value: result });
1060
+ return result;
1061
+ })();
1062
+ this.inflight.set(key, pending);
1063
+ try {
1064
+ return await pending;
1065
+ } finally {
1066
+ // Only clear the slot if it is still ours — a later overlapping call
1067
+ // would have reused this very promise, so it is safe to delete here.
1068
+ if (this.inflight.get(key) === pending) this.inflight.delete(key);
1069
+ }
1070
+ }
1071
+ }
988
1072
 
989
1073
  async function probeRemoteMeshGitStatus(args: {
990
1074
  dispatchMeshCommand?: (daemonId: string, cmd: string, args: Record<string, unknown>) => Promise<unknown>;
@@ -1074,6 +1158,10 @@ async function hydrateInlineMeshDirectTruth(args: {
1074
1158
  // probe was attempted. Only an explicit refresh (probeRemotePeers=true)
1075
1159
  // performs the fan-out and classifies an unreachable peer as unavailable.
1076
1160
  probeRemotePeers: boolean;
1161
+ // Optional shared probe cache: dedups concurrent probes and reuses a
1162
+ // recently-probed peer's result instead of re-issuing a blocking
1163
+ // refreshUpstream probe within the reuse window.
1164
+ probeCache?: MeshGitProbeCache;
1077
1165
  }): Promise<{
1078
1166
  directEvidenceCount: number;
1079
1167
  localConfirmedCount: number;
@@ -1081,6 +1169,7 @@ async function hydrateInlineMeshDirectTruth(args: {
1081
1169
  peerConfirmedCount: number;
1082
1170
  standingEvidenceCount: number;
1083
1171
  unavailableNodeIds: string[];
1172
+ deadNodeIds: string[];
1084
1173
  }> {
1085
1174
  const nodes = Array.isArray(args.mesh?.nodes) ? args.mesh.nodes : [];
1086
1175
  if (!nodes.length) {
@@ -1091,6 +1180,7 @@ async function hydrateInlineMeshDirectTruth(args: {
1091
1180
  peerConfirmedCount: 0,
1092
1181
  standingEvidenceCount: 0,
1093
1182
  unavailableNodeIds: [],
1183
+ deadNodeIds: [],
1094
1184
  };
1095
1185
  }
1096
1186
 
@@ -1105,6 +1195,7 @@ async function hydrateInlineMeshDirectTruth(args: {
1105
1195
  let peerConfirmedCount = 0;
1106
1196
  let standingEvidenceCount = 0;
1107
1197
  const unavailableNodeIds: string[] = [];
1198
+ const deadNodeIds: string[] = [];
1108
1199
 
1109
1200
  for (const [nodeIndex, node] of nodes.entries()) {
1110
1201
  const nodeId = normalizeMeshNodeId(node) || `node_${nodeIndex}`;
@@ -1116,6 +1207,22 @@ async function hydrateInlineMeshDirectTruth(args: {
1116
1207
  daemonId && (daemonId === args.localMachineId || daemonId === args.statusInstanceId),
1117
1208
  ) || Boolean(args.meshSource !== 'local_config' && nodeIndex === 0);
1118
1209
 
1210
+ // A dead local worktree owned by this coordinator (isLocalWorktree, the
1211
+ // node's daemon is us, workspace path gone) has no live truth and cannot
1212
+ // be probed — the directory it would self-probe no longer exists. Exclude
1213
+ // it entirely from direct-peer-truth accounting: do not probe it, do not
1214
+ // attempt it, do not push it to unavailableNodeIds (which would otherwise
1215
+ // wedge the graph in a permanent direct_peer_truth_unavailable). This is
1216
+ // strictly self + isLocalWorktree + absent-path; remote peers and nodes
1217
+ // whose workspace still exists are unaffected and stay classifiable.
1218
+ const isSelfDaemonNode = Boolean(
1219
+ daemonId && (daemonId === args.localMachineId || daemonId === args.statusInstanceId),
1220
+ );
1221
+ if ((isSelfNode || isSelfDaemonNode) && isDeadLocalWorktreeNode(node)) {
1222
+ deadNodeIds.push(nodeId);
1223
+ continue;
1224
+ }
1225
+
1119
1226
  if (!workspace) {
1120
1227
  if (!isSelfNode && daemonId) unavailableNodeIds.push(nodeId);
1121
1228
  continue;
@@ -1162,8 +1269,10 @@ async function hydrateInlineMeshDirectTruth(args: {
1162
1269
  // Bounded retry, gated on the peer staying `connected`: a slow
1163
1270
  // (TURN-relayed) peer that just exceeds one probe window is recovered
1164
1271
  // instead of being hard-failed. The connection is re-checked before each
1165
- // retry so a peer that actually dropped is abandoned promptly.
1166
- const remoteGit = await probeRemoteMeshGitStatusWithRetry({
1272
+ // retry so a peer that actually dropped is abandoned promptly. Routed
1273
+ // through the shared probe cache so a refresh burst reuses a recent
1274
+ // result / shares an in-flight probe instead of storming the peer.
1275
+ const runProbe = () => probeRemoteMeshGitStatusWithRetry({
1167
1276
  dispatchMeshCommand: args.dispatchMeshCommand,
1168
1277
  daemonId,
1169
1278
  workspace,
@@ -1171,6 +1280,9 @@ async function hydrateInlineMeshDirectTruth(args: {
1171
1280
  retryTimeoutMs: MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS,
1172
1281
  getConnection: args.getMeshPeerConnectionStatus,
1173
1282
  });
1283
+ const remoteGit = args.probeCache
1284
+ ? await args.probeCache.probe(daemonId, workspace, runProbe)
1285
+ : await runProbe();
1174
1286
  if (remoteGit) {
1175
1287
  recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
1176
1288
  peerConfirmedCount += 1;
@@ -1193,6 +1305,7 @@ async function hydrateInlineMeshDirectTruth(args: {
1193
1305
  peerConfirmedCount,
1194
1306
  standingEvidenceCount,
1195
1307
  unavailableNodeIds,
1308
+ deadNodeIds,
1196
1309
  };
1197
1310
  }
1198
1311
 
@@ -3157,8 +3270,21 @@ export class DaemonCommandRouter {
3157
3270
  * Allows the MCP server to query mesh data via get_mesh even when
3158
3271
  * the mesh doesn't exist in the local meshes.json file. */
3159
3272
  private inlineMeshCache = new Map<string, any>();
3273
+ /** Tombstones for inline mesh nodes removed via remove_mesh_node, keyed by
3274
+ * meshId → set of removed nodeIds. The dashboard keeps echoing the removed
3275
+ * node in the inlineMesh it attaches to every command; without a tombstone,
3276
+ * reconcileInlineMeshCache MERGEs it straight back (resurrection). A
3277
+ * tombstoned node is skipped during reconcile only while its workspace is
3278
+ * absent from disk — a genuine re-registration (same nodeId, workspace back
3279
+ * on disk) clears the tombstone and merges normally, preserving clone
3280
+ * worktree visibility and legitimate node re-creation. */
3281
+ private removedInlineMeshNodeIds = new Map<string, Set<string>>();
3160
3282
  /** Coordinator-owned whole-mesh aggregate status snapshots. Browser callers read this by default. */
3161
3283
  private aggregateMeshStatusCache = new Map<string, { builtAt: number; snapshot: any; queueRevision: string }>();
3284
+ /** Shared per-peer git_status probe dedup + recently-probed reuse gate.
3285
+ * Spans separate mesh_status/get_mesh calls so the dashboard auto-retry
3286
+ * loop cannot storm a slow peer with back-to-back refreshUpstream probes. */
3287
+ private meshGitProbeCache = new MeshGitProbeCache(MESH_DIRECT_PROBE_REUSE_MS);
3162
3288
  /** In-memory async Refinery jobs keyed by meshId:nodeId to reject/return duplicate in-flight requests. */
3163
3289
  private runningRefineJobs = new Map<string, MeshRefineJobHandle>();
3164
3290
  /** Terminal async Refinery jobs preserve a clear answer after the worktree node has been removed. */
@@ -3190,10 +3316,33 @@ export class DaemonCommandRouter {
3190
3316
  const unavailableNodeIds = new Set<string>();
3191
3317
  const sourceOfTruth = readObjectRecord(snapshot.sourceOfTruth);
3192
3318
  const directPeerTruth = readObjectRecord(sourceOfTruth.directPeerTruth);
3319
+ // Dead local worktree nodes (isLocalWorktree, workspace deleted from disk)
3320
+ // carry no live truth and must never gate the aggregate as unavailable.
3321
+ // A cached snapshot built before the worktree was removed can still list
3322
+ // such a node in unavailableNodeIds, which would wedge the graph in a
3323
+ // permanent direct_peer_truth_unavailable; drop them here so the held
3324
+ // standing-state truth for the surviving nodes satisfies the aggregate.
3325
+ const deadNodeIds = new Set<string>();
3326
+ for (const node of mesh.nodes) {
3327
+ if (!isDeadLocalWorktreeNode(node)) continue;
3328
+ const deadId = readInlineMeshNodeId(node);
3329
+ if (deadId) deadNodeIds.add(deadId);
3330
+ }
3331
+ let droppedDeadUnavailable = false;
3193
3332
  for (const entry of Array.isArray(directPeerTruth.unavailableNodeIds) ? directPeerTruth.unavailableNodeIds : []) {
3194
3333
  const nodeId = readStringValue(entry);
3195
- if (nodeId) unavailableNodeIds.add(nodeId);
3334
+ if (!nodeId) continue;
3335
+ if (deadNodeIds.has(nodeId)) {
3336
+ droppedDeadUnavailable = true;
3337
+ continue;
3338
+ }
3339
+ unavailableNodeIds.add(nodeId);
3196
3340
  }
3341
+ // Force a rewrite when a dead worktree was filtered out of a previously
3342
+ // built unavailable set, even if no live git was re-hydrated this pass —
3343
+ // otherwise the early-return below would hand back the stale snapshot that
3344
+ // still says direct_peer_truth_unavailable.
3345
+ if (droppedDeadUnavailable) changed = true;
3197
3346
 
3198
3347
  const nodes = snapshot.nodes.map((statusNode: any) => {
3199
3348
  const nodeId = normalizeMeshNodeId(statusNode);
@@ -3327,7 +3476,10 @@ export class DaemonCommandRouter {
3327
3476
  // Save-boundary node-id normalization: reconcile each node's identity so
3328
3477
  // `id` and `nodeId` agree before it enters the cache, so reconcile keys
3329
3478
  // and the round-trip through the status serializer stay form-stable.
3330
- const sanitizedInlineMesh = sanitizeInlineMesh(normalizeInlineMeshNodeIdentity(inlineMesh as any));
3479
+ const sanitizedInlineMesh = this.applyInlineMeshNodeTombstones(
3480
+ meshId,
3481
+ sanitizeInlineMesh(normalizeInlineMeshNodeIdentity(inlineMesh as any)),
3482
+ );
3331
3483
  const cached = this.inlineMeshCache.get(meshId);
3332
3484
  if (cached) {
3333
3485
  const merged = reconcileInlineMeshCache(cached, sanitizedInlineMesh);
@@ -3348,7 +3500,10 @@ export class DaemonCommandRouter {
3348
3500
  const cached = this.getCachedInlineMesh(meshId);
3349
3501
  if (cached) {
3350
3502
  if (inlineMeshCarriesTransientNodeTruth(inlineMesh)) {
3351
- const merged = reconcileInlineMeshCache(cached, inlineMesh as any);
3503
+ const merged = reconcileInlineMeshCache(
3504
+ cached,
3505
+ this.applyInlineMeshNodeTombstones(meshId, inlineMesh as any),
3506
+ );
3352
3507
  this.inlineMeshCache.set(meshId, sanitizeInlineMesh(normalizeInlineMeshNodeIdentity(merged)));
3353
3508
  return { mesh: merged, inline: true, source: 'inline_cache' };
3354
3509
  }
@@ -3408,13 +3563,56 @@ export class DaemonCommandRouter {
3408
3563
  if (!mesh || !Array.isArray(mesh.nodes)) return false;
3409
3564
  const idx = mesh.nodes.findIndex((entry: any) => meshNodeIdMatches(entry, nodeId));
3410
3565
  if (idx === -1) return false;
3566
+ const canonicalNodeId = readInlineMeshNodeId(mesh.nodes[idx]) || nodeId;
3411
3567
  mesh.nodes.splice(idx, 1);
3412
3568
  mesh.updatedAt = new Date().toISOString();
3413
3569
  this.inlineMeshCache.set(meshId, mesh);
3570
+ // Tombstone the removed node so the dashboard's stale inlineMesh echo does
3571
+ // not MERGE it back on the next command (see removedInlineMeshNodeIds).
3572
+ this.tombstoneRemovedInlineMeshNode(meshId, canonicalNodeId);
3573
+ if (canonicalNodeId !== nodeId) this.tombstoneRemovedInlineMeshNode(meshId, nodeId);
3414
3574
  this.invalidateAggregateMeshStatus(meshId);
3415
3575
  return true;
3416
3576
  }
3417
3577
 
3578
+ private tombstoneRemovedInlineMeshNode(meshId: string, nodeId: string): void {
3579
+ if (!nodeId) return;
3580
+ let set = this.removedInlineMeshNodeIds.get(meshId);
3581
+ if (!set) {
3582
+ set = new Set<string>();
3583
+ this.removedInlineMeshNodeIds.set(meshId, set);
3584
+ }
3585
+ set.add(nodeId);
3586
+ }
3587
+
3588
+ /** Filter an incoming inline mesh against this mesh's tombstones before it is
3589
+ * reconciled into the cache. A tombstoned node is dropped only while its
3590
+ * workspace is still absent from disk; if the workspace is back (genuine
3591
+ * re-registration), the tombstone is cleared and the node merges normally. */
3592
+ private applyInlineMeshNodeTombstones(meshId: string, incoming: any): any {
3593
+ const tombstones = this.removedInlineMeshNodeIds.get(meshId);
3594
+ if (!tombstones?.size || !incoming || typeof incoming !== 'object' || !Array.isArray(incoming.nodes)) {
3595
+ return incoming;
3596
+ }
3597
+ let dropped = false;
3598
+ const nodes = incoming.nodes.filter((node: any) => {
3599
+ const nodeId = readInlineMeshNodeId(node);
3600
+ if (!nodeId || !tombstones.has(nodeId)) return true;
3601
+ const workspace = readStringValue(node?.workspace);
3602
+ // Genuine re-registration: same nodeId, workspace back on disk →
3603
+ // clear the tombstone and let the node merge normally.
3604
+ if (workspace && fs.existsSync(workspace)) {
3605
+ tombstones.delete(nodeId);
3606
+ return true;
3607
+ }
3608
+ dropped = true;
3609
+ return false;
3610
+ });
3611
+ if (tombstones.size === 0) this.removedInlineMeshNodeIds.delete(meshId);
3612
+ if (!dropped) return incoming;
3613
+ return { ...incoming, nodes };
3614
+ }
3615
+
3418
3616
  private normalizeMeshSessionCleanupMode(value: unknown): RepoMeshSessionCleanupMode {
3419
3617
  return value === 'stop'
3420
3618
  || value === 'delete_stopped'
@@ -6611,6 +6809,7 @@ export class DaemonCommandRouter {
6611
6809
  statusInstanceId: this.deps.statusInstanceId,
6612
6810
  localMachineId: loadConfig().machineId || '',
6613
6811
  probeRemotePeers,
6812
+ probeCache: this.meshGitProbeCache,
6614
6813
  });
6615
6814
  const directTruthSatisfied = meshRecord.source !== 'inline_bootstrap' || directTruth.directEvidenceCount > 0;
6616
6815
  const sourceOfTruth = {
@@ -7289,10 +7488,115 @@ export class DaemonCommandRouter {
7289
7488
  return result as CommandRouterResult;
7290
7489
  }
7291
7490
 
7491
+ case 'get_mesh_node_logs': {
7492
+ // Coordinator-driven remote log fetch: read a (possibly remote)
7493
+ // daemon's recent log tail over P2P instead of opening a session
7494
+ // and grepping the file by hand. Mirrors fast_forward_mesh_node's
7495
+ // forward pattern — resolve the node, forward to its owning daemon
7496
+ // when remote, otherwise read locally. The reply tail is HARD
7497
+ // byte-bounded and secret-redacted before it leaves the machine.
7498
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
7499
+ const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
7500
+ let nodeDaemonId: string | undefined;
7501
+ if (meshId && nodeId) {
7502
+ const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
7503
+ const node = meshRecord?.mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
7504
+ nodeDaemonId = typeof node?.daemonId === 'string' ? node.daemonId.trim() : undefined;
7505
+ }
7506
+ // _meshDirectDispatch prevents re-forwarding (and P2P self-dial)
7507
+ // once the call lands on the owning daemon — that daemon then reads
7508
+ // its own logs even if the stored daemonId uses a legacy form.
7509
+ const selfDaemonId = this.deps.statusInstanceId;
7510
+ const isRemote = nodeDaemonId && selfDaemonId && nodeDaemonId !== selfDaemonId;
7511
+ if (isRemote && this.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
7512
+ const forwarded = await this.deps.dispatchMeshCommand(nodeDaemonId!, 'get_mesh_node_logs', {
7513
+ ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
7514
+ _meshDirectDispatch: true,
7515
+ });
7516
+ return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
7517
+ }
7518
+
7519
+ // Local read on the owning daemon.
7520
+ const rawTailBytes = Number(args?.tailBytes);
7521
+ const tail = readDaemonLogTail({
7522
+ date: typeof args?.date === 'string' ? args.date : undefined,
7523
+ tailBytes: Number.isFinite(rawTailBytes) ? Math.min(rawTailBytes, MAX_TAIL_BYTES) : undefined,
7524
+ grep: typeof args?.grep === 'string' ? args.grep : undefined,
7525
+ sinceMs: Number.isFinite(Number(args?.sinceMs)) ? Number(args?.sinceMs) : undefined,
7526
+ });
7527
+ if (!tail.success) {
7528
+ return {
7529
+ success: false,
7530
+ error: tail.error || 'failed to read daemon log tail',
7531
+ nodeId,
7532
+ logPath: tail.logPath,
7533
+ platform: tail.platform,
7534
+ } as CommandRouterResult;
7535
+ }
7536
+ // SECURITY: redact secrets from every line before returning over P2P.
7537
+ const redactedLines = redactLogLines(tail.lines);
7538
+ return {
7539
+ success: true,
7540
+ nodeId,
7541
+ daemonId: selfDaemonId,
7542
+ logPath: tail.logPath,
7543
+ platform: tail.platform,
7544
+ lines: redactedLines,
7545
+ lineCount: redactedLines.length,
7546
+ truncated: tail.truncated,
7547
+ filtered: tail.filtered,
7548
+ bytesReturned: tail.bytesReturned,
7549
+ ...(tail.grep ? { grep: tail.grep } : {}),
7550
+ } as CommandRouterResult;
7551
+ }
7552
+
7292
7553
  case 'refine_mesh_node': {
7293
7554
  const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
7294
7555
  const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
7295
7556
  if (!meshId || !nodeId) return { success: false, error: 'meshId and nodeId required' };
7557
+
7558
+ // Remote forward: a worktree node lives on its OWN daemon's machine, so the
7559
+ // refine (cd into node.workspace, merge → push → cleanup) must run on THAT
7560
+ // daemon — not the coordinator, whose filesystem has no such path. The sibling
7561
+ // fast_forward_mesh_node / clone_mesh_node handlers already forward to the
7562
+ // node's daemon; refine_mesh_node was the gap (the coordinator would cd into a
7563
+ // non-existent local path and fail), so remote-machine worktrees could not be
7564
+ // converged at all. Forward both dry-run (plan reads the worktree git state)
7565
+ // and execute (async merge job) so the same machine that owns the worktree
7566
+ // resolves it.
7567
+ //
7568
+ // coordinatorDaemonId: refine is ASYNC — the completed/failed event is queued
7569
+ // on the executing daemon's pending-events queue scoped to a coordinator id and
7570
+ // recovered by the coordinator's reconcile loop (pullRemoteNodeQueues →
7571
+ // get_pending_mesh_events). Without stamping our own status id, the remote
7572
+ // daemon would fall back to ITS OWN statusInstanceId as the coordinator
7573
+ // (startMeshRefineJob), scoping the terminal event to the wrong inbox where the
7574
+ // real coordinator never pulls it. Stamp the canonical status id (which is in
7575
+ // the coordinator's self-identity set used to scope the remote drain) so the
7576
+ // event routes back here. Preserve any caller-supplied coordinatorDaemonId.
7577
+ //
7578
+ // _meshDirectDispatch prevents re-forwarding (and P2P self-dial) once the call
7579
+ // has landed on the owning daemon — that daemon then executes locally even if
7580
+ // the stored daemonId uses a legacy form that doesn't match its own identity.
7581
+ {
7582
+ const meshRecordForForward = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
7583
+ const forwardNode = meshRecordForForward?.mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
7584
+ const nodeDaemonId = typeof forwardNode?.daemonId === 'string' ? forwardNode.daemonId.trim() : undefined;
7585
+ const selfDaemonId = this.deps.statusInstanceId;
7586
+ const isRemote = nodeDaemonId && selfDaemonId && nodeDaemonId !== selfDaemonId;
7587
+ if (isRemote && this.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
7588
+ const callerCoordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
7589
+ ? args.coordinatorDaemonId.trim()
7590
+ : undefined;
7591
+ const forwarded = await this.deps.dispatchMeshCommand(nodeDaemonId!, 'refine_mesh_node', {
7592
+ ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
7593
+ coordinatorDaemonId: callerCoordinatorDaemonId || selfDaemonId,
7594
+ _meshDirectDispatch: true,
7595
+ });
7596
+ return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
7597
+ }
7598
+ }
7599
+
7296
7600
  // Dry-run (plan-only) is the default and stays synchronous: it does no
7297
7601
  // validation/merge/push and returns the plan instantly. Only execute=true
7298
7602
  // (and not dry_run) goes through the async refine job that actually
@@ -8492,6 +8796,11 @@ export class DaemonCommandRouter {
8492
8796
 
8493
8797
  const localMachineId = loadConfig().machineId || '';
8494
8798
  const requireDirectPeerTruth = args?.requireDirectPeerTruth === true;
8799
+ // Shared probe gate for this mesh_status call: the bootstrap
8800
+ // hydrate below and the per-node render loop further down both
8801
+ // probe the same peers — route both through this cache so they
8802
+ // dedup within the call and reuse recent results across calls.
8803
+ const meshGitProbeCache = this.meshGitProbeCache;
8495
8804
  const directTruth = requireDirectPeerTruth
8496
8805
  ? await hydrateInlineMeshDirectTruth({
8497
8806
  mesh,
@@ -8504,6 +8813,7 @@ export class DaemonCommandRouter {
8504
8813
  // out a blocking peer git probe. Default loads return
8505
8814
  // held truth so one slow peer can't block the graph.
8506
8815
  probeRemotePeers: refreshRequested,
8816
+ probeCache: meshGitProbeCache,
8507
8817
  })
8508
8818
  : {
8509
8819
  directEvidenceCount: 0,
@@ -8512,6 +8822,7 @@ export class DaemonCommandRouter {
8512
8822
  peerConfirmedCount: 0,
8513
8823
  standingEvidenceCount: 0,
8514
8824
  unavailableNodeIds: [] as string[],
8825
+ deadNodeIds: [] as string[],
8515
8826
  };
8516
8827
  // Default/cached loads may not attempt a remote peer probe yet; do not surface that as
8517
8828
  // a direct mesh truth failure until an explicit probe attempt actually fails.
@@ -8710,7 +9021,7 @@ export class DaemonCommandRouter {
8710
9021
  // path), gated on the peer staying connected, so a
8711
9022
  // slow TURN-relayed peer is recovered rather than
8712
9023
  // dropped after a single timeout.
8713
- const remoteGit = await probeRemoteMeshGitStatusWithRetry({
9024
+ const runNodeProbe = () => probeRemoteMeshGitStatusWithRetry({
8714
9025
  dispatchMeshCommand: this.deps.dispatchMeshCommand,
8715
9026
  daemonId,
8716
9027
  workspace,
@@ -8719,6 +9030,12 @@ export class DaemonCommandRouter {
8719
9030
  getConnection: this.deps.getMeshPeerConnectionStatus,
8720
9031
  onConnection: connection => { status.connection = connection; },
8721
9032
  });
9033
+ // Same shared cache as the bootstrap hydrate path: within one
9034
+ // mesh_status call this dedups the bootstrap probe against this
9035
+ // per-node probe for the same peer, and across calls it reuses a
9036
+ // recent result so the dashboard auto-retry loop can't restart a
9037
+ // fresh refreshUpstream probe seconds apart.
9038
+ const remoteGit = await meshGitProbeCache.probe(daemonId, workspace, runNodeProbe);
8722
9039
  if (remoteGit) {
8723
9040
  status.git = remoteGit;
8724
9041
  status.health = remoteGit.isGitRepo
@@ -245,6 +245,14 @@ export function normalizeProviderScriptArgs(args: any, scriptName?: string): Rec
245
245
  function buildControlScriptResult(scriptName: string, payload: any): Record<string, unknown> {
246
246
  if (!payload || typeof payload !== 'object') return {};
247
247
 
248
+ // The spec-driven control adapter (open_picker LIST/SELECT) already
249
+ // produced a structured controlResult by parsing the live screen. Honour
250
+ // it verbatim instead of re-deriving one from legacy payload shapes —
251
+ // otherwise the screen-parsed options/currentValue get clobbered.
252
+ if (payload.controlResult && typeof payload.controlResult === 'object') {
253
+ return { controlResult: payload.controlResult };
254
+ }
255
+
248
256
  const legacyListPayload = (() => {
249
257
  if (Array.isArray(payload.options)) return payload;
250
258
  if (/^listmodels$/i.test(scriptName) && Array.isArray(payload.models)) {
@@ -172,6 +172,15 @@ function collapseReplayAssistantTurns(messages: HistoryMessage[], historyBehavio
172
172
  }
173
173
 
174
174
  if (message.role === 'assistant') {
175
+ // Tool / activity bubbles are distinct events, not replayed prose —
176
+ // collapsing them would erase every tool call and result after the
177
+ // turn's first assistant message. Only consecutive *prose* assistant
178
+ // turns are the replay-dedup target this collapse exists for.
179
+ const isActivity = message.kind === 'tool' || message.kind === 'terminal' || message.kind === 'thought';
180
+ if (isActivity) {
181
+ collapsed.push(message);
182
+ continue;
183
+ }
175
184
  if (sawAssistantSinceLastUser) continue;
176
185
  sawAssistantSinceLastUser = true;
177
186
  collapsed.push(message);
@@ -21,7 +21,7 @@ import type {
21
21
  RepoMeshHostMetadata,
22
22
  RepoMeshDaemonRole,
23
23
  } from '../repo-mesh-types.js';
24
- import { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
24
+ import { DEFAULT_MESH_POLICY, normalizeMeshSchedulingStrategy } from '../repo-mesh-types.js';
25
25
  import { createDefaultMeshHostMetadata } from '../mesh/mesh-host-ownership.js';
26
26
 
27
27
  // ─── Persistence ────────────────────────────────
@@ -118,6 +118,22 @@ function mergeMeshPolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMe
118
118
  if (!SPAWNED_SESSION_VISIBILITY_MODES.has(String(policy.spawnedSessionVisibility))) {
119
119
  policy.spawnedSessionVisibility = 'visible';
120
120
  }
121
+ // Load-balancing: normalize the scheduling strategy so an invalid/blank value
122
+ // falls back to 'first_eligible' (strict no-change). Only persist the field when
123
+ // it is explicitly a non-default value to keep existing meshes.json untouched.
124
+ const normalizedStrategy = normalizeMeshSchedulingStrategy(policy.schedulingStrategy);
125
+ if (normalizedStrategy === 'first_eligible') {
126
+ delete policy.schedulingStrategy;
127
+ } else {
128
+ policy.schedulingStrategy = normalizedStrategy;
129
+ }
130
+ // Convergence routing: strict opt-in (default false). Only persist when explicitly
131
+ // enabled so existing meshes.json stays byte-for-byte untouched.
132
+ if (policy.autoConvergeCodeChange === true) {
133
+ policy.autoConvergeCodeChange = true;
134
+ } else {
135
+ delete policy.autoConvergeCodeChange;
136
+ }
121
137
  return policy;
122
138
  }
123
139
 
package/src/index.ts CHANGED
@@ -122,6 +122,9 @@ export type {
122
122
  LocalMeshNodeEntry,
123
123
  RepoMeshStatus,
124
124
  RepoMeshNodeStatus,
125
+ RepoMeshPeerConnectionStatus,
126
+ RepoMeshPeerConnectionState,
127
+ RepoMeshPeerConnectionTransport,
125
128
  RepoMeshSessionStatus,
126
129
  RepoMeshQueueTask,
127
130
  RepoMeshQueueTaskStatus,
@@ -131,8 +134,19 @@ export type {
131
134
  RepoMeshLedgerSummaryStatus,
132
135
  RepoMeshLedgerStatus,
133
136
  MeshAsyncJobLifecycle,
137
+ RepoMeshSchedulingStrategy,
138
+ } from './repo-mesh-types.js';
139
+ export {
140
+ DEFAULT_MESH_POLICY,
141
+ resolveDelegatedWorkerAutoApprove,
142
+ MESH_SCHEDULING_STRATEGIES,
143
+ DEFAULT_MESH_SCHEDULING_STRATEGY,
144
+ normalizeMeshSchedulingStrategy,
145
+ resolveNodeSchedulingPriority,
146
+ MESH_CONVERGE_REFINE_TAG,
147
+ MESH_CONVERGE_FAST_FORWARD_TAG,
148
+ resolveAutoConvergeCodeChange,
134
149
  } from './repo-mesh-types.js';
135
- export { DEFAULT_MESH_POLICY, resolveDelegatedWorkerAutoApprove } from './repo-mesh-types.js';
136
150
 
137
151
  // ── Git Surface ──
138
152
  export * from './git/index.js';
@@ -217,7 +231,7 @@ export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence }
217
231
  export type { AnyLedgerSlice, MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
218
232
 
219
233
  // ── Mesh Work Queue (GUPP) ──
220
- export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
234
+ export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, resolveConvergeRequiredTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
221
235
  export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord, MeshToolCallRateResult } from './mesh/mesh-work-queue.js';
222
236
  export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
223
237
  export type { StaleDirectPruneClassification } from './mesh/mesh-active-work.js';