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

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,63 @@ export interface PendingEventEmitHint {
52
52
  /** coordinatorRunId to fold into the derived identity when the event lacks one. */
53
53
  coordinatorRunId?: string;
54
54
  }
55
+ /**
56
+ * T6 (B3c) enforce switch. When ON, the drain path stops passing an unversioned
57
+ * (v1) or a validation-failing v2 event through to the coordinator — it QUARANTINES
58
+ * it instead (excluded from the delivered batch + WARN + counter), and unicast
59
+ * routing is the only delivery path (there is no v1 broadcast fallback). Off (the
60
+ * default) preserves the accept-and-warn rollout behaviour exactly.
61
+ *
62
+ * Per the rollout plan (§1 decision 4): enforce is `MESH_PROTOCOL_V2_ENFORCE` (env)
63
+ * — its activation is a deliberate operational step taken ONLY after daemonBuilds
64
+ * confirms every node emits v2 (§배포 게이트 1 / risk §4). So the code default is
65
+ * OFF; flipping the env back to accept mode is a pure-env rollback (no code change,
66
+ * no data migration — the schema is additive). Read at call time so a test /
67
+ * operator can toggle it without a restart.
68
+ *
69
+ * Quarantine (not drop) keeps the loss-free invariant. The DESTRUCTIVE drain has
70
+ * already consumed the event from its store by the time routing runs, so "held
71
+ * back" here means: excluded from the delivered batch AND mirrored into the mesh
72
+ * ledger as a recoverable `event_held` entry (the same recovery channel the
73
+ * pending-trim path uses). It is observable via the counters + the ledger, so an
74
+ * operator can requeue it after fixing the producer. The non-destructive PEEK path
75
+ * (countMetrics=false) merely omits the event from the returned list — it never
76
+ * consumed it and must not ledger-record on every status poll.
77
+ */
78
+ export declare function isMeshProtocolV2EnforceEnabled(): boolean;
79
+ /** Observability counters for the v2 drain path. Read by tests and surfaced in
80
+ * mesh_status (B4/T6). Process-lifetime totals — never reset in production. */
81
+ declare const meshV2DrainCounters: {
82
+ /** v2 events that passed validation and unicast/broadcast routing → delivered. */
83
+ v2Delivered: number;
84
+ /** v2 unicast events skipped because intendedFor addressed another coordinator. */
85
+ v2RoutedAway: number;
86
+ /** v2 events skipped because their eventId was already drained (idempotency). */
87
+ v2DedupSkipped: number;
88
+ /** v2 events that failed assertPendingMeshCoordinatorEventV2 but were PASSED
89
+ * THROUGH (accept mode). Non-zero here is the rollout signal that a producer
90
+ * emits a malformed envelope. */
91
+ v2ValidationFailedAccepted: number;
92
+ /** unicast events re-attributed to the drainer via daemon-core match (a
93
+ * coordinatorRunId change orphaned them). */
94
+ v2ReattributedToDrainer: number;
95
+ /** v1 (unversioned) events passed through as broadcast (rollout baseline). */
96
+ v1BroadcastAccepted: number;
97
+ /** T6 enforce: v2 events that FAILED validation and were QUARANTINED (held back
98
+ * from delivery, not dropped). Non-zero here means a producer is still emitting a
99
+ * malformed envelope after enforce was turned on. */
100
+ v2ValidationFailedQuarantined: number;
101
+ /** T6 enforce: v1 (unversioned) events QUARANTINED because no v2 envelope could be
102
+ * derived at emit time. Non-zero here means a producer path still emits v1 after
103
+ * enforce — it should reach 0 once every node is on a v2-stamping build. */
104
+ v1UnversionedQuarantined: number;
105
+ };
106
+ /** Test/observability accessor for the v2 drain counters (snapshot copy). */
107
+ export declare function getMeshV2DrainCounters(): Readonly<typeof meshV2DrainCounters>;
108
+ /** Test helper: zero the v2 drain counters so a test starts from a clean slate. */
109
+ export declare function __resetMeshV2DrainCountersForTests(): void;
110
+ /** Test helper: clear the one-shot WARN dedup set. */
111
+ export declare function __resetMeshV2WarnDedupForTests(): void;
55
112
  export declare function readRefineJobId(event: {
56
113
  metadataEvent?: Record<string, unknown>;
57
114
  } | Record<string, unknown>): string;
@@ -73,6 +130,19 @@ export declare function hasPendingCoordinatorEventDuplicate(event: PendingMeshCo
73
130
  * key is stable across re-delivery. An already-stamped event is returned as-is.
74
131
  */
75
132
  export declare function stampPendingEventV2(event: PendingMeshCoordinatorEvent, hint?: PendingEventEmitHint): PendingMeshCoordinatorEvent;
133
+ /**
134
+ * Copy the v2 envelope fields that are present on `event` onto a flat wire
135
+ * payload. Only sets a field when it is present, so a v1 event contributes
136
+ * nothing (the payload stays v1-shaped and version-skew safe).
137
+ */
138
+ export declare function serializeV2EnvelopeToWire(event: PendingMeshCoordinatorEvent): Record<string, unknown>;
139
+ /**
140
+ * Restore the v2 envelope fields from a flat wire payload for a re-queue. Only
141
+ * returns fields that survive validation; a payload missing/malforming a field
142
+ * yields a partial (or empty) object so the re-queue path stays v1-safe. The
143
+ * eventId is returned verbatim — its preservation is the idempotency guarantee.
144
+ */
145
+ export declare function readV2EnvelopeFromWire(payload: Record<string, unknown>): Partial<Pick<PendingMeshCoordinatorEvent, 'protocolVersion' | 'eventId' | 'scope' | 'dispatchedBy' | 'intendedFor'>>;
76
146
  export declare function queuePendingMeshCoordinatorEvent(rawEvent: PendingMeshCoordinatorEvent, hint?: PendingEventEmitHint): boolean;
77
147
  /**
78
148
  * Drain and return pending coordinator events for meshId, removing the drained
@@ -87,6 +157,7 @@ export declare function queuePendingMeshCoordinatorEvent(rawEvent: PendingMeshCo
87
157
  */
88
158
  export declare function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string | ReadonlyArray<string>, opts?: {
89
159
  onlyEvents?: ReadonlySet<string>;
160
+ drainerIdentity?: CoordinatorIdentity;
90
161
  }): PendingMeshCoordinatorEvent[];
91
162
  /**
92
163
  * FALSE-BLOCKER-CLONE-QUEUE: retract any still-UNDELIVERED `mesh:dispatch_blocked`
@@ -103,7 +174,9 @@ export declare function drainPendingMeshCoordinatorEvents(meshId?: string, coord
103
174
  */
104
175
  export declare function retractPendingDispatchBlockedEvent(meshId: string | undefined, taskId: string | undefined, coordinatorDaemonId?: string): number;
105
176
  /** Peek at pending coordinator events without draining (non-destructive). */
106
- export declare function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string | ReadonlyArray<string>): readonly PendingMeshCoordinatorEvent[];
177
+ export declare function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string | ReadonlyArray<string>, opts?: {
178
+ drainerIdentity?: CoordinatorIdentity;
179
+ }): readonly PendingMeshCoordinatorEvent[];
107
180
  /**
108
181
  * Test helper: purge all pending-event state for a mesh — SQLite rows
109
182
  * (including drained fingerprint history) and JSONL files.
@@ -111,3 +184,4 @@ export declare function getPendingMeshCoordinatorEvents(meshId?: string, coordin
111
184
  export declare function __clearMeshPendingEventsForTests(meshId: string): void;
112
185
  /** Explicitly clear all pending coordinator events for a mesh (and coordinator if scoped). */
113
186
  export declare function clearPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): void;
187
+ export {};
@@ -1,6 +1,6 @@
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, getMeshV2DrainCounters, isMeshProtocolV2EnforceEnabled, } from './mesh-events-pending.js';
3
3
  export { reconcileDirectDispatchCompletionFromTranscript, } from './mesh-events-stale.js';
4
- export { setupMeshReconcileLoop, runMeshReconcileTick, resolveCoordinatorDrainDeliverability, shouldHoldPendingDrainForBusyLocalCoordinator, } from './mesh-reconcile-loop.js';
4
+ export { setupMeshReconcileLoop, runMeshReconcileTick, resolveCoordinatorDrainDeliverability, shouldHoldPendingDrainForBusyLocalCoordinator, getMeshV2BackstopCounters, } from './mesh-reconcile-loop.js';
5
5
  export type { MeshQueueTriggerResult } from './mesh-events-coordinator.js';
6
6
  export { tryAssignQueueTask, isSessionActivelyGenerating, triggerMeshQueue, handleMeshForwardEvent, setupMeshEventForwarding, isMeshCoordinatorEvent, __resetIdleAutoFastForwardForTests, __resetMeshWorkspaceCacheForTests, } 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;
@@ -1,4 +1,16 @@
1
1
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
2
+ declare const meshV2BackstopCounters: {
3
+ /** PHASE-4 transcript synthesis actually reconciled a missing completion. */
4
+ phase4SynthesisFired: number;
5
+ /** Acked-hold transcript fast-track promoted a synth ahead of the death deadline. */
6
+ ackedHoldFastTrackFired: number;
7
+ /** Acked-hold death-deadline backstop released a held synth (the 8-min net). */
8
+ ackedHoldDeathDeadlineFired: number;
9
+ };
10
+ /** Test/observability accessor for the v2 last-resort backstop counters (snapshot). */
11
+ export declare function getMeshV2BackstopCounters(): Readonly<typeof meshV2BackstopCounters>;
12
+ /** Test helper: zero the backstop counters so a case starts from a clean slate. */
13
+ export declare function __resetMeshV2BackstopCountersForTests(): void;
2
14
  export declare function __resetReconcileInFlightSynthDebounceForTests(): void;
3
15
  /**
4
16
  * DRAIN-WITHOUT-INJECT guard. Classify, for a mesh on THIS daemon, whether a
@@ -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;
@@ -1,4 +1,5 @@
1
1
  import type { ProviderModule } from './contracts.js';
2
+ export declare function normalizeApprovalLabel(value: string): string;
2
3
  /**
3
4
  * True when any of the given button labels reads as a decline/negative option
4
5
  * (No / Deny / Cancel / Skip / …). Used as the second half of an approval-modal
@@ -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,76 @@ 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
+ * T6 (B3c): live process-lifetime mesh-protocol-v2 enforce counters from THIS
748
+ * daemon — the enforce flag state, drain-routing tallies (deliver / route-away /
749
+ * dedup / quarantine), and the last-resort backstop fire counts (PHASE-4 synth,
750
+ * acked-hold fast-track / death-deadline). Diagnostic-only and never cached (a
751
+ * live snapshot). Under enforce, non-zero quarantine or backstop counts are the
752
+ * rollout-health signal (target 0). Omitted when unavailable.
753
+ */
754
+ meshProtocolV2Counters?: MeshProtocolV2Counters;
755
+ }
756
+ /** T6 (B3c) live v2 enforce/observability counters (see RepoMeshStatus.meshProtocolV2Counters). */
757
+ export interface MeshProtocolV2Counters {
758
+ /** True when MESH_PROTOCOL_V2_ENFORCE is active on this daemon. */
759
+ enforce: boolean;
760
+ /** Drain-path routing tallies (accept + enforce). Process-lifetime totals. */
761
+ drain: {
762
+ v2Delivered: number;
763
+ v2RoutedAway: number;
764
+ v2DedupSkipped: number;
765
+ v2ValidationFailedAccepted: number;
766
+ v2ReattributedToDrainer: number;
767
+ v1BroadcastAccepted: number;
768
+ v2ValidationFailedQuarantined: number;
769
+ v1UnversionedQuarantined: number;
770
+ };
771
+ /** Last-resort backstop fire counts. Target 0 under a healthy v2 contract. */
772
+ backstop: {
773
+ phase4SynthesisFired: number;
774
+ ackedHoldFastTrackFired: number;
775
+ ackedHoldDeathDeadlineFired: number;
776
+ };
777
+ }
778
+ /** One provider's version skew across mesh nodes (see RepoMeshStatus.providerVersionSkew). */
779
+ export interface MeshProviderVersionSkew {
780
+ /** Provider id (e.g. 'claude-cli'). */
781
+ provider: string;
782
+ /** Each distinct detected version and the node ids running it. */
783
+ versions: Array<{
784
+ version: string;
785
+ nodeIds: string[];
786
+ }>;
787
+ }
788
+ /** Mesh-protocol-v2 adoption snapshot over one drain (see RepoMeshStatus.meshProtocolMetrics). */
789
+ export interface MeshProtocolMetrics {
790
+ /** Total pending events surfaced in the drain. */
791
+ total: number;
792
+ /** Count carrying a v2 envelope (protocolVersion '2.0'). */
793
+ v2: number;
794
+ /** Count still on v1 (unstamped). */
795
+ v1: number;
796
+ /** v2/total, rounded to 2 decimals (0 when total is 0). */
797
+ v2Ratio: number;
798
+ /** Scope breakdown of the v2 events (unicast/broadcast/system/unspecified → count). */
799
+ scopes: Record<string, number>;
700
800
  }
701
801
  export type { RepoMeshSessionStatus } from '@adhdev/mesh-shared';
702
802
  import type { RepoMeshSessionStatus } from '@adhdev/mesh-shared';
@@ -741,6 +841,17 @@ export interface RepoMeshNodeStatus {
741
841
  */
742
842
  gitProbePending?: boolean;
743
843
  providers: string[];
844
+ /**
845
+ * Detected provider CLI/ACP versions on this node, keyed by provider id. Mirrors
846
+ * RepoMeshNodeCapabilities.providerVersions onto the status snapshot so the mesh
847
+ * UI / coordinator prompt can render per-provider versions and flag a version
848
+ * skew across nodes. Optional — omitted by daemons predating the exposure or when
849
+ * detection has not run. Additive; existing consumers ignore it. */
850
+ providerVersions?: Record<string, string>;
851
+ /** Human-readable daemon build version (getDaemonBuildInfo().version) of the
852
+ * daemon that owns this node. Complements the per-daemon commit stamp
853
+ * (daemonBuilds) for node-card display. Omitted when unknown. */
854
+ daemonBuildVersion?: string;
744
855
  activeSessions: string[];
745
856
  activeSessionDetails?: RepoMeshSessionStatus[];
746
857
  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.462",
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.462",
51
+ "@adhdev/session-host-core": "0.9.82-rc.462",
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?.();
@@ -12,6 +12,9 @@ import {
12
12
  drainPendingMeshCoordinatorEvents,
13
13
  shouldHoldPendingDrainForBusyLocalCoordinator,
14
14
  resolveCoordinatorDrainDeliverability,
15
+ getMeshV2DrainCounters,
16
+ getMeshV2BackstopCounters,
17
+ isMeshProtocolV2EnforceEnabled,
15
18
  } from '../../mesh/mesh-events.js';
16
19
  import { normalizeInteractivePromptResponse } from '../../providers/types/interactive-prompt.js';
17
20
  import type { HighFamilyContext, HighFamilyHandler } from './types.js';
@@ -66,11 +69,21 @@ export const meshEventsHandlers: Record<string, HighFamilyHandler> = {
66
69
  return { success: true, events: [], heldForBusyLocalCoordinator: true, hasLiveCliCoordinator };
67
70
  }
68
71
  const events = drainPendingMeshCoordinatorEvents(meshId || undefined, coordinatorDaemonId);
72
+ // T6 (B3c): ride the live v2 enforce/backstop counters on the drain response so a
73
+ // pure stdio MCP coordinator (which reads its inbox via this IPC call, not the
74
+ // daemon-core mesh_status command) sees the same enforce state + quarantine /
75
+ // last-resort-backstop tallies. Process-lifetime snapshot; the counters were just
76
+ // updated by the drain above. Additive — omitting it keeps version-skewed pullers safe.
77
+ const meshProtocolV2Counters = {
78
+ enforce: isMeshProtocolV2EnforceEnabled(),
79
+ drain: { ...getMeshV2DrainCounters() },
80
+ backstop: { ...getMeshV2BackstopCounters() },
81
+ };
69
82
  // SELF-COORDINATOR INBOX LEVEL-DRAIN: when the busy local coordinator drained its OWN
70
83
  // inbox (selfCoordinatorInboxRead), tell the puller these events were surfaced through
71
84
  // the caller's tool result — it must NOT re-forward them into the (busy) PTY (that is the
72
85
  // lossy path). Absent the flag, delivery is unchanged (reconcile-owned PTY / remote pull).
73
- return { success: true, events, hasLiveCliCoordinator, ...(selfCoordinatorInboxRead ? { surfacedForSelfCoordinator: true } : {}) };
86
+ return { success: true, events, hasLiveCliCoordinator, meshProtocolV2Counters, ...(selfCoordinatorInboxRead ? { surfacedForSelfCoordinator: true } : {}) };
74
87
  },
75
88
 
76
89
  interactive_prompt_response: async (ctx: HighFamilyContext, args: any) => {
@@ -19,7 +19,13 @@ import {
19
19
  normalizeMeshNodeId,
20
20
  daemonIdsEquivalent,
21
21
  } from '@adhdev/mesh-shared';
22
- import { getPendingMeshCoordinatorEvents } from '../../mesh/mesh-events.js';
22
+ import {
23
+ getPendingMeshCoordinatorEvents,
24
+ getMeshV2DrainCounters,
25
+ getMeshV2BackstopCounters,
26
+ isMeshProtocolV2EnforceEnabled,
27
+ } from '../../mesh/mesh-events.js';
28
+ import type { MeshProtocolV2Counters } from '../../repo-mesh-types.js';
23
29
  import { getRecentUnroutableDeliveries } from '../../mesh/mesh-routing.js';
24
30
  import { normalizeMeshDaemonRole, resolveMeshHostStatus } from '../../mesh/mesh-host-ownership.js';
25
31
  import { buildPreviewFreshness } from '../../mesh/preview-freshness.js';
@@ -340,6 +346,16 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
340
346
  machineStatus: node.machineStatus,
341
347
  health: 'unknown',
342
348
  providers: node.providers || [],
349
+ // T7: surface self-healed provider versions + build version (from the
350
+ // git_status envelope, persisted on the node) so the coordinator/UI can
351
+ // spot a provider-version skew across nodes. Additive; omitted when a
352
+ // node has never reported them.
353
+ ...(node.reportedProviderVersions && typeof node.reportedProviderVersions === 'object'
354
+ ? { providerVersions: node.reportedProviderVersions }
355
+ : {}),
356
+ ...(typeof node.reportedDaemonBuildVersion === 'string' && node.reportedDaemonBuildVersion
357
+ ? { daemonBuildVersion: node.reportedDaemonBuildVersion }
358
+ : {}),
343
359
  providerPriority,
344
360
  activeSessions: [],
345
361
  activeSessionDetails: [],
@@ -582,6 +598,15 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
582
598
  // that a worker completion was lost (envelope present, mesh unresolved) instead
583
599
  // of it vanishing silently. Diagnostic-only — never cached (see omit below).
584
600
  const unroutableDeliveries = getRecentUnroutableDeliveries();
601
+ // T6 (B3c): live enforce/observability counters from this daemon. A
602
+ // process-lifetime snapshot (never cached — like unroutableDeliveries)
603
+ // so an operator/coordinator can read enforce state, quarantine tallies,
604
+ // and last-resort backstop fires straight from the aggregate status.
605
+ const meshProtocolV2Counters: MeshProtocolV2Counters = {
606
+ enforce: isMeshProtocolV2EnforceEnabled(),
607
+ drain: { ...getMeshV2DrainCounters() },
608
+ backstop: { ...getMeshV2BackstopCounters() },
609
+ };
585
610
  const previewFreshness = (() => {
586
611
  const localRepoRoot = nodeStatuses
587
612
  .map((node: any) => readStringValue(node?.git?.repoRoot, node?.repoRoot, node?.workspace))
@@ -697,6 +722,7 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
697
722
  ...(historicalSessions ? { historicalSessions } : {}),
698
723
  ...(pendingCoordinatorEvents.length > 0 ? { pendingCoordinatorEvents } : {}),
699
724
  ...(unroutableDeliveries.length > 0 ? { unroutableDeliveries } : {}),
725
+ meshProtocolV2Counters,
700
726
  activeRefineJobs: Array.from(ctx.runningRefineJobs.values())
701
727
  .filter(job => job.meshId === meshId)
702
728
  .map(job => ({
@@ -708,7 +734,7 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
708
734
  targetCoordinatorDaemonId: job.targetCoordinatorDaemonId,
709
735
  })),
710
736
  };
711
- const { pendingCoordinatorEvents: _pendingCoordinatorEvents, unroutableDeliveries: _unroutableDeliveries, ...cacheableStatusResult } = statusResult as any;
737
+ const { pendingCoordinatorEvents: _pendingCoordinatorEvents, unroutableDeliveries: _unroutableDeliveries, meshProtocolV2Counters: _meshProtocolV2Counters, ...cacheableStatusResult } = statusResult as any;
712
738
  // Verbose carries full mission goals; never store it in the shared
713
739
  // (compact) aggregate cache or a later compact poll would return the
714
740
  // heavy goals from cache. Return it without caching.
@@ -719,6 +745,7 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
719
745
  ...rememberedStatus,
720
746
  ...(pendingCoordinatorEvents.length > 0 ? { pendingCoordinatorEvents } : {}),
721
747
  ...(unroutableDeliveries.length > 0 ? { unroutableDeliveries } : {}),
748
+ meshProtocolV2Counters,
722
749
  };
723
750
  logRepoMeshStatusDebug('return_live', {
724
751
  meshId,
@@ -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
+ }