@adhdev/daemon-core 0.9.82-rc.460 → 0.9.82-rc.461

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.
@@ -52,6 +52,31 @@ export interface PendingEventEmitHint {
52
52
  /** coordinatorRunId to fold into the derived identity when the event lacks one. */
53
53
  coordinatorRunId?: string;
54
54
  }
55
+ /** Observability counters for the accept-and-warn rollout. Read by tests and (later,
56
+ * B4) surfaced in mesh_status. Process-lifetime totals — never reset in production. */
57
+ declare const meshV2DrainCounters: {
58
+ /** v2 events that passed validation and unicast/broadcast routing → delivered. */
59
+ v2Delivered: number;
60
+ /** v2 unicast events skipped because intendedFor addressed another coordinator. */
61
+ v2RoutedAway: number;
62
+ /** v2 events skipped because their eventId was already drained (idempotency). */
63
+ v2DedupSkipped: number;
64
+ /** v2 events that failed assertPendingMeshCoordinatorEventV2 but were PASSED
65
+ * THROUGH (accept mode). Non-zero here is the rollout signal that a producer
66
+ * emits a malformed envelope. */
67
+ v2ValidationFailedAccepted: number;
68
+ /** unicast events re-attributed to the drainer via daemon-core match (a
69
+ * coordinatorRunId change orphaned them). */
70
+ v2ReattributedToDrainer: number;
71
+ /** v1 (unversioned) events passed through as broadcast (rollout baseline). */
72
+ v1BroadcastAccepted: number;
73
+ };
74
+ /** Test/observability accessor for the v2 drain counters (snapshot copy). */
75
+ export declare function getMeshV2DrainCounters(): Readonly<typeof meshV2DrainCounters>;
76
+ /** Test helper: zero the v2 drain counters so a test starts from a clean slate. */
77
+ export declare function __resetMeshV2DrainCountersForTests(): void;
78
+ /** Test helper: clear the one-shot WARN dedup set. */
79
+ export declare function __resetMeshV2WarnDedupForTests(): void;
55
80
  export declare function readRefineJobId(event: {
56
81
  metadataEvent?: Record<string, unknown>;
57
82
  } | Record<string, unknown>): string;
@@ -73,6 +98,19 @@ export declare function hasPendingCoordinatorEventDuplicate(event: PendingMeshCo
73
98
  * key is stable across re-delivery. An already-stamped event is returned as-is.
74
99
  */
75
100
  export declare function stampPendingEventV2(event: PendingMeshCoordinatorEvent, hint?: PendingEventEmitHint): PendingMeshCoordinatorEvent;
101
+ /**
102
+ * Copy the v2 envelope fields that are present on `event` onto a flat wire
103
+ * payload. Only sets a field when it is present, so a v1 event contributes
104
+ * nothing (the payload stays v1-shaped and version-skew safe).
105
+ */
106
+ export declare function serializeV2EnvelopeToWire(event: PendingMeshCoordinatorEvent): Record<string, unknown>;
107
+ /**
108
+ * Restore the v2 envelope fields from a flat wire payload for a re-queue. Only
109
+ * returns fields that survive validation; a payload missing/malforming a field
110
+ * yields a partial (or empty) object so the re-queue path stays v1-safe. The
111
+ * eventId is returned verbatim — its preservation is the idempotency guarantee.
112
+ */
113
+ export declare function readV2EnvelopeFromWire(payload: Record<string, unknown>): Partial<Pick<PendingMeshCoordinatorEvent, 'protocolVersion' | 'eventId' | 'scope' | 'dispatchedBy' | 'intendedFor'>>;
76
114
  export declare function queuePendingMeshCoordinatorEvent(rawEvent: PendingMeshCoordinatorEvent, hint?: PendingEventEmitHint): boolean;
77
115
  /**
78
116
  * Drain and return pending coordinator events for meshId, removing the drained
@@ -87,6 +125,7 @@ export declare function queuePendingMeshCoordinatorEvent(rawEvent: PendingMeshCo
87
125
  */
88
126
  export declare function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string | ReadonlyArray<string>, opts?: {
89
127
  onlyEvents?: ReadonlySet<string>;
128
+ drainerIdentity?: CoordinatorIdentity;
90
129
  }): PendingMeshCoordinatorEvent[];
91
130
  /**
92
131
  * FALSE-BLOCKER-CLONE-QUEUE: retract any still-UNDELIVERED `mesh:dispatch_blocked`
@@ -103,7 +142,9 @@ export declare function drainPendingMeshCoordinatorEvents(meshId?: string, coord
103
142
  */
104
143
  export declare function retractPendingDispatchBlockedEvent(meshId: string | undefined, taskId: string | undefined, coordinatorDaemonId?: string): number;
105
144
  /** Peek at pending coordinator events without draining (non-destructive). */
106
- export declare function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string | ReadonlyArray<string>): readonly PendingMeshCoordinatorEvent[];
145
+ export declare function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string | ReadonlyArray<string>, opts?: {
146
+ drainerIdentity?: CoordinatorIdentity;
147
+ }): readonly PendingMeshCoordinatorEvent[];
107
148
  /**
108
149
  * Test helper: purge all pending-event state for a mesh — SQLite rows
109
150
  * (including drained fingerprint history) and JSONL files.
@@ -111,3 +152,4 @@ export declare function getPendingMeshCoordinatorEvents(meshId?: string, coordin
111
152
  export declare function __clearMeshPendingEventsForTests(meshId: string): void;
112
153
  /** Explicitly clear all pending coordinator events for a mesh (and coordinator if scoped). */
113
154
  export declare function clearPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): void;
155
+ export {};
@@ -1,5 +1,5 @@
1
1
  export type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
2
- export { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents, } from './mesh-events-pending.js';
2
+ export { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents, serializeV2EnvelopeToWire, readV2EnvelopeFromWire, } from './mesh-events-pending.js';
3
3
  export { reconcileDirectDispatchCompletionFromTranscript, } from './mesh-events-stale.js';
4
4
  export { setupMeshReconcileLoop, runMeshReconcileTick, resolveCoordinatorDrainDeliverability, shouldHoldPendingDrainForBusyLocalCoordinator, } from './mesh-reconcile-loop.js';
5
5
  export type { MeshQueueTriggerResult } from './mesh-events-coordinator.js';
@@ -49,6 +49,8 @@ export declare function recordInlineMeshDirectGitTruth(node: any, git: Record<st
49
49
  reporterPlatform: string | null;
50
50
  reporterArch: string | null;
51
51
  reporterMachineNickname: string | null;
52
+ reporterProviderVersions: Record<string, string> | null;
53
+ reporterDaemonBuildVersion: string | null;
52
54
  };
53
55
  /**
54
56
  * Persist the live self-reported platform/arch onto the local meshes.json node
@@ -65,6 +67,8 @@ export declare function persistNodeReporterPlatform(meshSource: 'inline_cache' |
65
67
  reporterPlatform: string | null;
66
68
  reporterArch: string | null;
67
69
  reporterMachineNickname?: string | null;
70
+ reporterProviderVersions?: Record<string, string> | null;
71
+ reporterDaemonBuildVersion?: string | null;
68
72
  }): void;
69
73
  export declare function inlineMeshCarriesTransientNodeTruth(inlineMesh: any): boolean;
70
74
  export declare function readInlineMeshNodeId(node: any): string;
@@ -425,6 +425,23 @@ export declare class MeshRuntimeStore {
425
425
  payload: unknown;
426
426
  }>;
427
427
  hasPendingEventFingerprint(meshId: string, fingerprint: string): boolean;
428
+ /**
429
+ * B3a — v2 eventId idempotency. Returns true when a row with this event_id has
430
+ * ALREADY been drained (drained = 1) for the mesh. Drained rows are retained
431
+ * (soft-marked, not deleted until mesh deletion), so this is a durable, restart-
432
+ * surviving dedup: a v2 event whose eventId was already consumed is skipped on
433
+ * re-delivery even when its content fingerprint differs. Scoped by mesh_id +
434
+ * the partial event_id index (idx_mesh_pending_events_event_id).
435
+ */
436
+ hasDrainedEventId(meshId: string, eventId: string): boolean;
437
+ /**
438
+ * B3a — snapshot of the v2 event_ids ALREADY drained (drained = 1) for the mesh.
439
+ * Taken BEFORE a drain call marks the current batch drained=1, so the resulting
440
+ * set names only PRIOR drains — the re-delivery dedup baseline. (Reading it after
441
+ * the drain would self-match the batch's own freshly-drained rows.) Non-v2 rows
442
+ * have a NULL event_id and are excluded by the index/WHERE.
443
+ */
444
+ drainedEventIdsForMesh(meshId: string): Set<string>;
428
445
  upsertMission(mission: {
429
446
  id: string;
430
447
  meshId: string;
@@ -447,6 +447,23 @@ export interface RepoMeshNodeCapabilities {
447
447
  canPush?: boolean;
448
448
  readOnly?: boolean;
449
449
  userLabels?: string[];
450
+ /**
451
+ * Detected provider CLI/ACP versions on this node, keyed by provider id
452
+ * (e.g. `{ 'claude-cli': '1.2.3', 'codex-cli': '0.9.0' }`). Populated from the
453
+ * same CLI detection pass that feeds providerPriority (see buildProviderVersions
454
+ * over detectCLIs' CLIInfo[]). Absent/undefined when detection has not run or a
455
+ * daemon predates the exposure — never a hard signal, purely observability so a
456
+ * coordinator can spot a provider-version skew across nodes before dispatch.
457
+ * Additive: existing status consumers ignore it.
458
+ */
459
+ providerVersions?: Record<string, string>;
460
+ /**
461
+ * The daemon build version (package.json version baked into the running bundle,
462
+ * see getDaemonBuildInfo().version) that detected the above providerVersions.
463
+ * Complements the commit-level daemonBuild stamp with a human-readable version
464
+ * for node-card rendering. Absent when the build define was not injected.
465
+ */
466
+ daemonBuildVersion?: string;
450
467
  }
451
468
  export interface DetectedCommand {
452
469
  command: string;
@@ -580,6 +597,19 @@ export interface LocalMeshNodeEntry {
580
597
  */
581
598
  reportedPlatform?: string;
582
599
  reportedArch?: string;
600
+ /**
601
+ * Live, self-healed provider CLI/ACP versions reported by the daemon that owns
602
+ * this node's workspace, carried on the git_status envelope (reporterProviderVersions)
603
+ * and persisted by the coordinator on each direct git probe — mirrors the
604
+ * reportedPlatform/reportedArch self-heal pattern. Auto-detected truth, overwritten
605
+ * by the next report so a stale value never sticks. Absent until the first probe
606
+ * carrying versions succeeds. Surfaced as RepoMeshNodeStatus.providerVersions.
607
+ */
608
+ reportedProviderVersions?: Record<string, string>;
609
+ /** Live, self-healed daemon build version (getDaemonBuildInfo().version) of the
610
+ * owning daemon, carried on the git_status envelope (reporterDaemonBuildVersion)
611
+ * alongside the provider versions. Absent until first reported. */
612
+ reportedDaemonBuildVersion?: string;
583
613
  /**
584
614
  * The operator-set machine nickname (config.machineNickname) of the daemon
585
615
  * that owns this node's workspace. The local coordinator stamps its own
@@ -697,6 +727,45 @@ export interface RepoMeshStatus {
697
727
  * optional. Mirrors the MCP `mesh_status` tool's `magiActivity` field.
698
728
  */
699
729
  magiActivity?: MeshMagiActivitySummary[];
730
+ /**
731
+ * T7 (visibility 7-2b): provider CLI/ACP version skew across nodes. Each entry
732
+ * names a provider running ≥2 distinct versions across the nodes that reported
733
+ * it, with the node ids per version. Observational only — never a dispatch
734
+ * blocker. Omitted when every reported provider is uniform (or none reported).
735
+ * Mirrors the MCP `mesh_status` tool's `providerVersionSkew` field.
736
+ */
737
+ providerVersionSkew?: MeshProviderVersionSkew[];
738
+ /** Human-readable companion warning to providerVersionSkew. Omitted when no skew. */
739
+ providerVersionSkewWarning?: string;
740
+ /**
741
+ * T7 (B4): mesh-protocol-v2 adoption metrics for the batch of pending events
742
+ * surfaced in the drain backing this status. Snapshot, not a durable counter.
743
+ * Omitted when nothing was drained. Mirrors the MCP tool's meshProtocolMetrics.
744
+ */
745
+ meshProtocolMetrics?: MeshProtocolMetrics;
746
+ }
747
+ /** One provider's version skew across mesh nodes (see RepoMeshStatus.providerVersionSkew). */
748
+ export interface MeshProviderVersionSkew {
749
+ /** Provider id (e.g. 'claude-cli'). */
750
+ provider: string;
751
+ /** Each distinct detected version and the node ids running it. */
752
+ versions: Array<{
753
+ version: string;
754
+ nodeIds: string[];
755
+ }>;
756
+ }
757
+ /** Mesh-protocol-v2 adoption snapshot over one drain (see RepoMeshStatus.meshProtocolMetrics). */
758
+ export interface MeshProtocolMetrics {
759
+ /** Total pending events surfaced in the drain. */
760
+ total: number;
761
+ /** Count carrying a v2 envelope (protocolVersion '2.0'). */
762
+ v2: number;
763
+ /** Count still on v1 (unstamped). */
764
+ v1: number;
765
+ /** v2/total, rounded to 2 decimals (0 when total is 0). */
766
+ v2Ratio: number;
767
+ /** Scope breakdown of the v2 events (unicast/broadcast/system/unspecified → count). */
768
+ scopes: Record<string, number>;
700
769
  }
701
770
  export type { RepoMeshSessionStatus } from '@adhdev/mesh-shared';
702
771
  import type { RepoMeshSessionStatus } from '@adhdev/mesh-shared';
@@ -741,6 +810,17 @@ export interface RepoMeshNodeStatus {
741
810
  */
742
811
  gitProbePending?: boolean;
743
812
  providers: string[];
813
+ /**
814
+ * Detected provider CLI/ACP versions on this node, keyed by provider id. Mirrors
815
+ * RepoMeshNodeCapabilities.providerVersions onto the status snapshot so the mesh
816
+ * UI / coordinator prompt can render per-provider versions and flag a version
817
+ * skew across nodes. Optional — omitted by daemons predating the exposure or when
818
+ * detection has not run. Additive; existing consumers ignore it. */
819
+ providerVersions?: Record<string, string>;
820
+ /** Human-readable daemon build version (getDaemonBuildInfo().version) of the
821
+ * daemon that owns this node. Complements the per-daemon commit stamp
822
+ * (daemonBuilds) for node-card display. Omitted when unknown. */
823
+ daemonBuildVersion?: string;
744
824
  activeSessions: string[];
745
825
  activeSessionDetails?: RepoMeshSessionStatus[];
746
826
  providerPriority?: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.460",
3
+ "version": "0.9.82-rc.461",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "0.9.82-rc.460",
51
- "@adhdev/session-host-core": "0.9.82-rc.460",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.461",
51
+ "@adhdev/session-host-core": "0.9.82-rc.461",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -25,7 +25,8 @@ import { VersionArchive, detectAllVersions } from '../providers/version-archive.
25
25
  import { ProviderInstanceManager } from '../providers/provider-instance-manager.js';
26
26
  import { DevServer } from '../daemon/dev-server.js';
27
27
  import { detectIDEs, type IDEInfo } from '../detection/ide-detector.js';
28
- import { detectCLI, detectCLIs } from '../detection/cli-detector.js';
28
+ import { detectCLI, detectCLIs, getCachedProviderVersions } from '../detection/cli-detector.js';
29
+ import { getDaemonBuildInfo } from '../build-info.js';
29
30
  import { SessionRegistry } from '../sessions/registry.js';
30
31
  import { LOG, installGlobalInterceptor } from '../logging/logger.js';
31
32
  import {
@@ -304,7 +305,19 @@ export async function initDaemonComponents(config: DaemonInitConfig): Promise<Da
304
305
  providerLoader,
305
306
  instanceManager,
306
307
  sessionRegistry,
307
- gitCommandServices: createDefaultGitCommandServices(),
308
+ gitCommandServices: createDefaultGitCommandServices({
309
+ // T7: fold this daemon's cached provider versions + build version onto the
310
+ // git_status envelope so the mesh coordinator self-heals each node's
311
+ // providerVersions. Non-blocking: reads a TTL cache, lazily refreshed.
312
+ getReporterProviderVersions: () => {
313
+ const providerVersions = getCachedProviderVersions(providerLoader);
314
+ const daemonBuildVersion = getDaemonBuildInfo().version;
315
+ return {
316
+ ...(Object.keys(providerVersions).length > 0 ? { providerVersions } : {}),
317
+ ...(daemonBuildVersion && daemonBuildVersion !== 'unknown' ? { daemonBuildVersion } : {}),
318
+ };
319
+ },
320
+ }),
308
321
  onProviderSettingChanged: async (providerType) => {
309
322
  await refreshProviderAvailability(providerType);
310
323
  config.onStatusChange?.();
@@ -340,6 +340,16 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
340
340
  machineStatus: node.machineStatus,
341
341
  health: 'unknown',
342
342
  providers: node.providers || [],
343
+ // T7: surface self-healed provider versions + build version (from the
344
+ // git_status envelope, persisted on the node) so the coordinator/UI can
345
+ // spot a provider-version skew across nodes. Additive; omitted when a
346
+ // node has never reported them.
347
+ ...(node.reportedProviderVersions && typeof node.reportedProviderVersions === 'object'
348
+ ? { providerVersions: node.reportedProviderVersions }
349
+ : {}),
350
+ ...(typeof node.reportedDaemonBuildVersion === 'string' && node.reportedDaemonBuildVersion
351
+ ? { daemonBuildVersion: node.reportedDaemonBuildVersion }
352
+ : {}),
343
353
  providerPriority,
344
354
  activeSessions: [],
345
355
  activeSessionDetails: [],
@@ -583,6 +583,13 @@ export function updateNode(
583
583
  * git_status envelope. Persisted so the friendly label survives across
584
584
  * coordinator restarts (mirrors reportedPlatform/reportedArch). */
585
585
  reportedMachineNickname?: string;
586
+ /** Owning daemon's self-reported provider CLI/ACP versions + build version,
587
+ * carried on the git_status envelope. Persisted distinctly from userOverrides
588
+ * (auto-detected observability, not operator intent), mirroring the
589
+ * reportedPlatform/reportedArch self-heal so the value survives restarts and
590
+ * is overwritten by the next report. */
591
+ reportedProviderVersions?: Record<string, string>;
592
+ reportedDaemonBuildVersion?: string;
586
593
  },
587
594
  ): LocalMeshNodeEntry | undefined {
588
595
  const config = loadMeshConfig();
@@ -596,6 +603,12 @@ export function updateNode(
596
603
  if (opts.reportedPlatform && opts.reportedPlatform.trim()) node.reportedPlatform = opts.reportedPlatform.trim();
597
604
  if (opts.reportedArch && opts.reportedArch.trim()) node.reportedArch = opts.reportedArch.trim();
598
605
  if (opts.reportedMachineNickname && opts.reportedMachineNickname.trim()) node.machineNickname = opts.reportedMachineNickname.trim();
606
+ if (opts.reportedProviderVersions && Object.keys(opts.reportedProviderVersions).length > 0) {
607
+ node.reportedProviderVersions = { ...opts.reportedProviderVersions };
608
+ }
609
+ if (opts.reportedDaemonBuildVersion && opts.reportedDaemonBuildVersion.trim()) {
610
+ node.reportedDaemonBuildVersion = opts.reportedDaemonBuildVersion.trim();
611
+ }
599
612
  if (opts.policy) node.policy = { ...node.policy, ...opts.policy };
600
613
  if (opts.worktreeBootstrap) node.worktreeBootstrap = opts.worktreeBootstrap;
601
614
  if (Object.prototype.hasOwnProperty.call(opts, 'systemPrompt')) {
@@ -200,3 +200,69 @@ export async function detectCLI(
200
200
  const all = await detectCLIs(providerLoader, options);
201
201
  return all.find((c) => c.id === resolvedId && c.installed) || null;
202
202
  }
203
+
204
+ /**
205
+ * Fold a detected CLIInfo[] into the `{ providerId: version }` map surfaced as a
206
+ * node's providerVersions (see RepoMeshNodeCapabilities.providerVersions). Only
207
+ * installed providers that produced a parseable version string are included, so a
208
+ * missing entry means "not installed or version unknown" — never a fabricated value.
209
+ * Pure/deterministic: the same detection input always yields the same map, which is
210
+ * what the git_status envelope carries and the mesh_status skew check compares.
211
+ */
212
+ export function buildProviderVersions(detected: CLIInfo[]): Record<string, string> {
213
+ const out: Record<string, string> = {};
214
+ for (const cli of detected) {
215
+ if (!cli.installed) continue;
216
+ const version = typeof cli.version === 'string' ? cli.version.trim() : '';
217
+ if (!version) continue;
218
+ out[cli.id] = version;
219
+ }
220
+ return out;
221
+ }
222
+
223
+ // ─── Cached provider-versions snapshot (mesh visibility, T7) ────────────────
224
+ //
225
+ // A node's providerVersions rides the git_status envelope, which a coordinator
226
+ // probes on every graph refresh. Running the full `--version` exec fan-out on
227
+ // each probe would be far too costly (one spawn per provider per probe), so the
228
+ // snapshot is memoized with a TTL and refreshed lazily in the background: a probe
229
+ // reads the current cache immediately (empty until the first refresh completes)
230
+ // and kicks off a refresh only when the cache is stale, never blocking the git
231
+ // response on version detection. Best-effort observability — a cold/empty map
232
+ // simply omits providerVersions from that probe, and the next probe carries it.
233
+ const PROVIDER_VERSIONS_TTL_MS = 5 * 60 * 1000; // 5 min — provider installs change rarely
234
+ let cachedProviderVersions: Record<string, string> = {};
235
+ let cachedProviderVersionsAt = 0;
236
+ let providerVersionsRefreshInFlight: Promise<void> | null = null;
237
+
238
+ function refreshProviderVersionsSnapshot(providerLoader?: ProviderLoader): Promise<void> {
239
+ if (providerVersionsRefreshInFlight) return providerVersionsRefreshInFlight;
240
+ providerVersionsRefreshInFlight = (async () => {
241
+ try {
242
+ const detected = await detectCLIs(providerLoader, { includeVersion: true });
243
+ cachedProviderVersions = buildProviderVersions(detected);
244
+ cachedProviderVersionsAt = Date.now();
245
+ } catch {
246
+ // Best-effort: keep the last good snapshot on failure.
247
+ } finally {
248
+ providerVersionsRefreshInFlight = null;
249
+ }
250
+ })();
251
+ return providerVersionsRefreshInFlight;
252
+ }
253
+
254
+ /**
255
+ * Return the current cached provider-versions map for this daemon, triggering a
256
+ * lazy background refresh when the snapshot is stale/cold. Never blocks: returns
257
+ * whatever is currently cached (an empty object before the first refresh lands).
258
+ * This is the source the git_status envelope reads to self-report a node's
259
+ * provider versions to the mesh coordinator.
260
+ */
261
+ export function getCachedProviderVersions(providerLoader?: ProviderLoader): Record<string, string> {
262
+ const stale = Date.now() - cachedProviderVersionsAt > PROVIDER_VERSIONS_TTL_MS;
263
+ if (stale) {
264
+ // Fire-and-forget; the current (possibly empty) snapshot is returned now.
265
+ void refreshProviderVersionsSnapshot(providerLoader);
266
+ }
267
+ return { ...cachedProviderVersions };
268
+ }
@@ -102,6 +102,14 @@ export interface GitCommandServices {
102
102
  checkoutFiles?: (params: { workspace: string; paths: string[] }) => Promise<{ checkedOut: string[] }>;
103
103
  getRemoteUrl?: (params: { workspace: string; remote?: string }) => Promise<{ remoteUrl: string; remote: string }>;
104
104
  push?: (params: { workspace: string; remote?: string; branch?: string; setUpstream?: boolean }) => Promise<GitPushResult>;
105
+ /**
106
+ * Best-effort, non-blocking source of this daemon's detected provider CLI/ACP
107
+ * versions (keyed by provider id) + its build version, folded onto the git_status
108
+ * envelope so a mesh coordinator can self-heal each node's providerVersions the
109
+ * same way it does platform/arch. Wired at daemon boot from the cached CLI
110
+ * detection snapshot; omitted (⇒ versions not reported) when unavailable.
111
+ */
112
+ getReporterProviderVersions?: () => { providerVersions?: Record<string, string>; daemonBuildVersion?: string };
105
113
  }
106
114
 
107
115
  type GitCommandFailure = {
@@ -116,7 +124,7 @@ type GitCommandSuccess =
116
124
  // node's userOverrides.platform/arch (the fields capability-tag routing reads).
117
125
  // reporterMachineNickname carries the responding daemon's config.machineNickname
118
126
  // so the coordinator can populate node.machineNickname → the friendly display label.
119
- | { success: true; status: GitRepoStatus; reporterPlatform?: string; reporterArch?: string; reporterMachineNickname?: string }
127
+ | { success: true; status: GitRepoStatus; reporterPlatform?: string; reporterArch?: string; reporterMachineNickname?: string; reporterProviderVersions?: Record<string, string>; reporterDaemonBuildVersion?: string }
120
128
  | { success: true; diffSummary: GitDiffSummary }
121
129
  | { success: true; diff: GitFileDiff }
122
130
  | { success: true; snapshot: GitSnapshot }
@@ -179,7 +187,9 @@ const defaultSnapshotStore = createGitSnapshotStore({
179
187
  getDiffSummary: (workspace) => getGitDiffSummary(workspace),
180
188
  });
181
189
 
182
- export function createDefaultGitCommandServices(): GitCommandServices {
190
+ export function createDefaultGitCommandServices(
191
+ overrides?: Pick<GitCommandServices, 'getReporterProviderVersions'>,
192
+ ): GitCommandServices {
183
193
  return {
184
194
  getStatus: ({ workspace, refreshUpstream }) => getGitRepoStatus(workspace, { refreshUpstream }),
185
195
  getDiffSummary: ({ workspace, base }) => getGitDiffSummary(workspace, base ? { baseRef: base } : {}),
@@ -199,6 +209,9 @@ export function createDefaultGitCommandServices(): GitCommandServices {
199
209
  getRemoteUrl: async ({ workspace, remote = 'origin' }) => gitGetRemoteUrl(workspace, remote),
200
210
  push: async ({ workspace, remote = 'origin', branch, setUpstream = false }) =>
201
211
  gitPush(workspace, remote, branch, setUpstream),
212
+ ...(overrides?.getReporterProviderVersions
213
+ ? { getReporterProviderVersions: overrides.getReporterProviderVersions }
214
+ : {}),
202
215
  };
203
216
  }
204
217
 
@@ -328,12 +341,32 @@ export async function handleGitCommand(
328
341
  return undefined;
329
342
  }
330
343
  })();
344
+ // Provider versions + build version ride the same self-report channel (T7
345
+ // visibility). Best-effort and non-blocking: the service reads a cached
346
+ // snapshot, so a cold cache simply omits the fields on this probe.
347
+ const reporterVersions = (() => {
348
+ try {
349
+ return services.getReporterProviderVersions?.() ?? {};
350
+ } catch {
351
+ return {};
352
+ }
353
+ })();
354
+ const reporterProviderVersions =
355
+ reporterVersions.providerVersions && Object.keys(reporterVersions.providerVersions).length > 0
356
+ ? reporterVersions.providerVersions
357
+ : undefined;
358
+ const reporterDaemonBuildVersion =
359
+ typeof reporterVersions.daemonBuildVersion === 'string' && reporterVersions.daemonBuildVersion.trim()
360
+ ? reporterVersions.daemonBuildVersion.trim()
361
+ : undefined;
331
362
  return {
332
363
  success: true,
333
364
  status,
334
365
  reporterPlatform: process.platform,
335
366
  reporterArch: process.arch,
336
367
  ...(reporterMachineNickname ? { reporterMachineNickname } : {}),
368
+ ...(reporterProviderVersions ? { reporterProviderVersions } : {}),
369
+ ...(reporterDaemonBuildVersion ? { reporterDaemonBuildVersion } : {}),
337
370
  };
338
371
  }
339
372
 
package/src/index.ts CHANGED
@@ -295,7 +295,7 @@ export { buildMeshHostRequiredFailure, createDefaultMeshHostMetadata, isMeshHost
295
295
  // export type { MeshGraph, MeshGraphNode, MeshGraphEdge, MeshGraphNodeType, MeshGraphEdgeType } from './mesh/mesh-visualization.js';
296
296
 
297
297
  // ── Mesh Events ──
298
- export { triggerMeshQueue, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents, queuePendingMeshCoordinatorEvent, reconcileDirectDispatchCompletionFromTranscript } from './mesh/mesh-events.js';
298
+ export { triggerMeshQueue, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents, queuePendingMeshCoordinatorEvent, serializeV2EnvelopeToWire, readV2EnvelopeFromWire, reconcileDirectDispatchCompletionFromTranscript } from './mesh/mesh-events.js';
299
299
  export type { PendingMeshCoordinatorEvent } from './mesh/mesh-events.js';
300
300
  // The coordinator-side preview surfaced from a worker's completion/status event
301
301
  // (finalSummary / workerResult.summary / lastMessagePreview). Same data the mobile
@@ -392,9 +392,27 @@ function buildNodeStatusSection(nodes: RepoMeshNodeStatus[]): string {
392
392
  ? `sessions: ${n.activeSessions.join(', ')}`
393
393
  : 'no active sessions';
394
394
  const branch = n.git?.branch ? `branch: \`${n.git.branch}\`` : '';
395
+ // Render each provider with its detected version when known (T7 visibility)
396
+ // so the coordinator can eyeball a per-node provider-version skew inline —
397
+ // e.g. `claude-cli@1.2.3`. Providers without a reported version render bare.
398
+ const providerVersions = n.providerVersions && typeof n.providerVersions === 'object'
399
+ ? n.providerVersions
400
+ : undefined;
401
+ const providersRendered = n.providers?.length
402
+ ? n.providers
403
+ .map((p) => {
404
+ const version = providerVersions?.[p];
405
+ return version ? `${p}@${version}` : p;
406
+ })
407
+ .join(', ')
408
+ : '';
409
+ const buildVersion = typeof n.daemonBuildVersion === 'string' && n.daemonBuildVersion
410
+ ? `build: ${n.daemonBuildVersion}`
411
+ : '';
395
412
  const context = [
396
413
  n.daemonId ? `daemon: \`${n.daemonId}\`` : '',
397
- n.providers?.length ? `providers: ${n.providers.join(', ')}` : '',
414
+ providersRendered ? `providers: ${providersRendered}` : '',
415
+ buildVersion,
398
416
  ].filter(Boolean).join(' | ');
399
417
  lines.push(`- ${healthIcon} **${n.machineLabel}** (nodeId: \`${n.nodeId}\`)`);
400
418
  lines.push(` workspace: \`${n.workspace}\`${context ? ` | ${context}` : ''} | ${branch} | ${sessions}`);
@@ -7,7 +7,7 @@ import type { SessionRecoveryContext } from './mesh-ledger.js';
7
7
  import { updateSessionTaskStatus, enqueueTask, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, getActiveDirectDispatches, hasPendingDependents, getQueue } from './mesh-work-queue.js';
8
8
  import { markSessionDeliveriesTerminal, updateSessionDeliveryStatus, recordCompletionConflict } from './mesh-delivery-policy.js';
9
9
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
10
- import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, type PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
10
+ import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, readV2EnvelopeFromWire, type PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
11
11
  import type { ProviderInstance } from '../providers/provider-instance.js';
12
12
  import { resolveWorkerDelegateRouting, recordUnroutableDelegateEvent, isUnroutableDelegateRejection } from './mesh-routing.js';
13
13
  import { resolveMeshHostStatus } from './mesh-host-ownership.js';
@@ -681,6 +681,13 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
681
681
  nodeLabel: string;
682
682
  event: string;
683
683
  metadataEvent: Record<string, unknown>;
684
+ // T4 (B3b): v2 envelope restored from a remote (P2P) relay's flat payload. Only the
685
+ // relay path (handleMeshForwardEvent) sets it; the in-process forward path leaves it
686
+ // undefined and the local emit stamp applies as usual. When present with a preserved
687
+ // eventId, it is spread onto the re-queued pending event so stampPendingEventV2's
688
+ // already-stamped short-circuit keeps the ORIGINAL eventId (cross-machine idempotency).
689
+ v2Envelope?: Partial<Pick<PendingMeshCoordinatorEvent,
690
+ 'protocolVersion' | 'eventId' | 'scope' | 'dispatchedBy' | 'intendedFor'>>;
684
691
  }) {
685
692
  const eventSessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
686
693
  const eventNodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
@@ -1363,6 +1370,12 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1363
1370
  // Top-level session anchor for the local PHASE 2 strict-match on the coordinator
1364
1371
  // daemon. Absent → daemon-level broadcast (legacy / single-coordinator path).
1365
1372
  ...(workerCoordinatorSessionId ? { targetCoordinatorSessionId: workerCoordinatorSessionId } : {}),
1373
+ // T4 (B3b): restore the v2 envelope from the remote relay so the re-queue keeps the
1374
+ // ORIGINAL eventId. Spread LAST so its authoritative eventId/scope/identity win over
1375
+ // any default. queuePendingMeshCoordinatorEvent → stampPendingEventV2 then no-ops
1376
+ // (already-stamped short-circuit) instead of minting a fresh eventId. Empty object
1377
+ // for a v1 relay → unchanged v1 emit-stamp path (version-skew safe).
1378
+ ...(args.v2Envelope ?? {}),
1366
1379
  };
1367
1380
  if (queuePendingMeshCoordinatorEvent(pendingEvent)) {
1368
1381
  LOG.info('MeshEvents', `Queued ${args.event} for coordinator (mesh ${args.meshId}${workerCoordinatorDaemonId ? `, coordinator daemon ${workerCoordinatorDaemonId}` : ''}${workerCoordinatorSessionId ? `, coordinator session ${workerCoordinatorSessionId}` : ''})`);
@@ -1518,6 +1531,11 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
1518
1531
  nodeLabel,
1519
1532
  event: eventName,
1520
1533
  metadataEvent: buildRelayMetadataEvent(payload),
1534
+ // T4 (B3b): restore the v2 envelope carried at the top level of the relayed flat
1535
+ // payload (buildForwardPayloadFromPending → serializeV2EnvelopeToWire) so the
1536
+ // re-queue preserves the original eventId (idempotency) and unicast routing rather
1537
+ // than re-stamping a fresh v1/broadcast event. Empty for a v1 relay (version-skew safe).
1538
+ v2Envelope: readV2EnvelopeFromWire(payload),
1521
1539
  });
1522
1540
  }
1523
1541