@adhdev/daemon-core 0.9.82-rc.137 → 0.9.82-rc.138

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.
Files changed (39) hide show
  1. package/dist/chat/source-machine.d.ts +166 -0
  2. package/dist/chat/source-resolver.d.ts +104 -0
  3. package/dist/cli-adapters/cli-state-engine.d.ts +15 -0
  4. package/dist/cli-adapters/provider-cli-adapter.d.ts +0 -1
  5. package/dist/cli-adapters/provider-cli-parse.d.ts +1 -0
  6. package/dist/cli-adapters/provider-cli-shared.d.ts +1 -0
  7. package/dist/index.js +922 -328
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +922 -328
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/mesh/contracts.d.ts +164 -0
  12. package/dist/providers/contracts.d.ts +19 -0
  13. package/dist/providers/read-chat-contract.d.ts +29 -0
  14. package/dist/providers/transcript-v2.d.ts +176 -0
  15. package/dist/shared-types.d.ts +7 -0
  16. package/dist/status/snapshot.d.ts +1 -0
  17. package/dist/types.d.ts +5 -0
  18. package/package.json +1 -1
  19. package/src/chat/source-machine.ts +534 -0
  20. package/src/chat/source-resolver.ts +0 -0
  21. package/src/chat/subscription-updates.ts +9 -0
  22. package/src/cli-adapters/cli-state-engine.ts +103 -6
  23. package/src/cli-adapters/provider-cli-adapter.ts +51 -5
  24. package/src/cli-adapters/provider-cli-parse.ts +3 -0
  25. package/src/cli-adapters/provider-cli-shared.ts +13 -1
  26. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +17 -1
  27. package/src/cli-adapters/terminal-backends/xterm-backend.ts +8 -1
  28. package/src/commands/chat-commands.ts +712 -381
  29. package/src/commands/router.ts +14 -2
  30. package/src/config/chat-history.ts +36 -13
  31. package/src/mesh/contracts.ts +329 -0
  32. package/src/providers/contracts.ts +19 -0
  33. package/src/providers/provider-loader.ts +21 -7
  34. package/src/providers/provider-schema.ts +10 -0
  35. package/src/providers/read-chat-contract.ts +74 -14
  36. package/src/providers/transcript-v2.ts +567 -0
  37. package/src/shared-types.ts +7 -0
  38. package/src/status/snapshot.ts +35 -11
  39. package/src/types.ts +5 -0
@@ -3645,7 +3645,13 @@ export class DaemonCommandRouter {
3645
3645
 
3646
3646
  case 'get_pending_mesh_events': {
3647
3647
  const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
3648
- const events = drainPendingMeshCoordinatorEvents(meshId || undefined);
3648
+ // (B3) Respect coordinatorDaemonId when the caller declares it
3649
+ // so unicast events route to the right coordinator instead of
3650
+ // being silently consumed by the first drainer.
3651
+ const coordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
3652
+ ? args.coordinatorDaemonId.trim()
3653
+ : undefined;
3654
+ const events = drainPendingMeshCoordinatorEvents(meshId || undefined, coordinatorDaemonId);
3649
3655
  return { success: true, events };
3650
3656
  }
3651
3657
 
@@ -5887,7 +5893,13 @@ export class DaemonCommandRouter {
5887
5893
  nodeStatuses.push(status);
5888
5894
  }
5889
5895
 
5890
- const pendingCoordinatorEvents = drainPendingMeshCoordinatorEvents(meshId);
5896
+ // (B3) Pass coordinatorDaemonId when the caller declares
5897
+ // it so v1.5 unicast routing (targetCoordinatorDaemonId)
5898
+ // delivers events to the right coordinator.
5899
+ const callerCoordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
5900
+ ? args.coordinatorDaemonId.trim()
5901
+ : undefined;
5902
+ const pendingCoordinatorEvents = drainPendingMeshCoordinatorEvents(meshId, callerCoordinatorDaemonId);
5891
5903
  const previewFreshness = (() => {
5892
5904
  const localRepoRoot = nodeStatuses
5893
5905
  .map((node: any) => readStringValue(node?.git?.repoRoot, node?.repoRoot, node?.workspace))
@@ -1375,19 +1375,42 @@ function normalizeProviderNativeHistoryRecords(agentType: string, historySession
1375
1375
  if (!Array.isArray(records)) return [];
1376
1376
  const normalizedSessionId = normalizeSavedHistorySessionId(historySessionId);
1377
1377
  return records
1378
- .map((record: any) => sanitizeHistoryMessage(agentType, {
1379
- ts: typeof record?.ts === 'string' ? record.ts : new Date(Number(record?.receivedAt) || Date.now()).toISOString(),
1380
- receivedAt: Number(record?.receivedAt) || Date.parse(record?.ts || '') || Date.now(),
1381
- role: record?.role,
1382
- content: String(record?.content || ''),
1383
- kind: record?.kind || (record?.role === 'system' ? 'session_start' : 'standard'),
1384
- senderName: record?.senderName,
1385
- agent: agentType,
1386
- instanceId: record?.instanceId,
1387
- historySessionId: normalizeSavedHistorySessionId(record?.historySessionId || normalizedSessionId),
1388
- sessionTitle: record?.sessionTitle,
1389
- workspace: record?.workspace,
1390
- } as HistoryMessage))
1378
+ .map((record: any) => {
1379
+ const base: HistoryMessage = {
1380
+ ts: typeof record?.ts === 'string' ? record.ts : new Date(Number(record?.receivedAt) || Date.now()).toISOString(),
1381
+ receivedAt: Number(record?.receivedAt) || Date.parse(record?.ts || '') || Date.now(),
1382
+ role: record?.role,
1383
+ content: String(record?.content || ''),
1384
+ kind: record?.kind || (record?.role === 'system' ? 'session_start' : 'standard'),
1385
+ senderName: record?.senderName,
1386
+ agent: agentType,
1387
+ instanceId: record?.instanceId,
1388
+ historySessionId: normalizeSavedHistorySessionId(record?.historySessionId || normalizedSessionId),
1389
+ sessionTitle: record?.sessionTitle,
1390
+ workspace: record?.workspace,
1391
+ } as HistoryMessage;
1392
+ // (A2.3 v2 identity passthrough) — if the producer (native_history.js)
1393
+ // emitted v2 stable identity, keep it across the sanitize layer so
1394
+ // downstream (chat-commands.ts normalizeNativeHistoryMessages) sees
1395
+ // the producer's contract output instead of recomputing from index
1396
+ // and content hash. v1 producers without these fields are unaffected.
1397
+ if (typeof record?.providerUnitKey === 'string' && record.providerUnitKey) {
1398
+ (base as any).providerUnitKey = record.providerUnitKey;
1399
+ }
1400
+ if (typeof record?.bubbleId === 'string' && record.bubbleId) {
1401
+ (base as any).bubbleId = record.bubbleId;
1402
+ }
1403
+ if (typeof record?.sequence === 'number' && Number.isFinite(record.sequence)) {
1404
+ (base as any).sequence = record.sequence;
1405
+ }
1406
+ if (typeof record?._turnKey === 'string' && record._turnKey) {
1407
+ (base as any)._turnKey = record._turnKey;
1408
+ }
1409
+ if (typeof record?.bubbleState === 'string' && record.bubbleState) {
1410
+ (base as any).bubbleState = record.bubbleState;
1411
+ }
1412
+ return sanitizeHistoryMessage(agentType, base);
1413
+ })
1391
1414
  .filter(Boolean) as HistoryMessage[];
1392
1415
  }
1393
1416
 
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Mesh contract v2 — first-class identity, scopes, and protocol version.
3
+ *
4
+ * Replaces the implicit conventions that grew across mesh-events.ts,
5
+ * mesh-work-queue.ts, mesh-ledger.ts, and mesh-tools.ts where the same
6
+ * concept (a coordinator, a session, a task status) appeared in different
7
+ * shapes per file. The audit found:
8
+ *
9
+ * - PendingMeshCoordinatorEvent had no targetCoordinatorDaemonId. All
10
+ * events were broadcast to every coordinator that drained them. Two
11
+ * coordinators sharing a mesh would each receive every event,
12
+ * leading to duplicate completion handling and missed targeting.
13
+ * - drainPendingMeshCoordinatorEvents accepted only meshId. The caller's
14
+ * coordinator identity was never available, so per-coordinator
15
+ * routing was impossible by construction.
16
+ * - Session identifier keys diverged across stores: targetSessionId /
17
+ * assignedSessionId / instanceId / runtimeSessionId / providerSessionId.
18
+ * resolveEventSessionId() tried four fallbacks per call.
19
+ * - No protocol version on the JSONL ledger or BeadsDB. Schema
20
+ * evolutions had no guard rail.
21
+ * - mesh_reconcile_ledger existed as a routine recovery tool, not as
22
+ * an incident-response escape hatch. That itself signals the routing
23
+ * layer cannot be trusted.
24
+ *
25
+ * This module introduces the types; the actual wiring lands in B2 (data
26
+ * model + 3-way transactional store), B3 (MCP layer enforcement), and B4
27
+ * (frontend consumption). B1 is intentionally non-breaking — it adds the
28
+ * types and leaves runtime behaviour unchanged.
29
+ */
30
+
31
+ /** Provider type identifier (e.g. 'claude-cli', 'codex-cli', 'roo-code').
32
+ * Free-form string; no shared enum exists in daemon-core yet. */
33
+ type ProviderType = string;
34
+
35
+ // ─── Protocol version ────────────────────────────────────────────────────
36
+
37
+ export const MESH_PROTOCOL_VERSION_V1 = '1.0' as const;
38
+ export const MESH_PROTOCOL_VERSION_V2 = '2.0' as const;
39
+
40
+ export type MeshProtocolVersion =
41
+ | typeof MESH_PROTOCOL_VERSION_V1
42
+ | typeof MESH_PROTOCOL_VERSION_V2;
43
+
44
+ export const SUPPORTED_MESH_PROTOCOL_VERSIONS: readonly MeshProtocolVersion[] = [
45
+ MESH_PROTOCOL_VERSION_V1,
46
+ MESH_PROTOCOL_VERSION_V2,
47
+ ] as const;
48
+
49
+ export function isSupportedMeshProtocolVersion(value: unknown): value is MeshProtocolVersion {
50
+ return typeof value === 'string'
51
+ && (SUPPORTED_MESH_PROTOCOL_VERSIONS as readonly string[]).includes(value);
52
+ }
53
+
54
+ // ─── Coordinator identity ────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Identity of a mesh coordinator. Required (non-optional) on every dispatch
58
+ * and drain operation in v2 — opaque/missing identity is the audit's
59
+ * smoking gun for routing failures and is structurally banned by the v2
60
+ * types.
61
+ *
62
+ * - daemonId: the machineId of the daemon hosting the coordinator. Stable
63
+ * across coordinator restarts on the same machine.
64
+ * - coordinatorRunId: a UUID generated when the coordinator process starts.
65
+ * Stable for the coordinator's lifetime, fresh on restart. Required so
66
+ * two coordinators on the same daemon (one CLI, one MCP) can be told
67
+ * apart without conflating their drains.
68
+ * - sessionId: optional CLI coordinator session identifier (instanceId).
69
+ * Present when the coordinator is itself a CLI session, absent for
70
+ * pure MCP coordinators.
71
+ */
72
+ export interface CoordinatorIdentity {
73
+ readonly daemonId: string;
74
+ readonly coordinatorRunId: string;
75
+ readonly sessionId?: string;
76
+ }
77
+
78
+ export function coordinatorIdentityEquals(a: CoordinatorIdentity, b: CoordinatorIdentity): boolean {
79
+ return a.daemonId === b.daemonId
80
+ && a.coordinatorRunId === b.coordinatorRunId
81
+ && (a.sessionId ?? '') === (b.sessionId ?? '');
82
+ }
83
+
84
+ export function coordinatorIdentityKey(identity: CoordinatorIdentity): string {
85
+ // Stable string form for map keys and ledger payloads. Avoids the
86
+ // {a:b, c:d} JSON.stringify ordering trap by enforcing field order.
87
+ return `${identity.daemonId}|${identity.coordinatorRunId}|${identity.sessionId ?? ''}`;
88
+ }
89
+
90
+ // ─── Session handle ──────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Unified mesh session identifier. v1 referred to "the session" with five
94
+ * different field names depending on which store you were reading; v2
95
+ * collapses them into one explicit handle that carries every disambiguator
96
+ * a consumer might need.
97
+ *
98
+ * - nodeId: the mesh node this session belongs to (FK into mesh node table).
99
+ * - sessionId: provider-instance identifier (the runtime session id).
100
+ * - providerType: which provider category/type the session runs (e.g.
101
+ * 'claude-cli', 'codex-cli', 'roo-code').
102
+ * - coordinatorDaemonId: which coordinator dispatched the work that
103
+ * spawned this session. Used for completion event routing.
104
+ * - assignedAt: ms epoch when the session was bound to its current task.
105
+ */
106
+ export interface MeshSessionHandle {
107
+ readonly nodeId: string;
108
+ readonly sessionId: string;
109
+ readonly providerType: ProviderType;
110
+ readonly coordinatorDaemonId: string;
111
+ readonly assignedAt: number;
112
+ }
113
+
114
+ export function meshSessionHandleKey(handle: MeshSessionHandle): string {
115
+ return `${handle.nodeId}|${handle.sessionId}`;
116
+ }
117
+
118
+ // ─── Task status (canonical enum) ────────────────────────────────────────
119
+
120
+ /**
121
+ * Single canonical task status enum. v1 had at least three overlapping
122
+ * sets (queue / ledger / direct-dispatch). v2 consumers import from here.
123
+ */
124
+ export const MESH_TASK_STATUSES = [
125
+ 'pending',
126
+ 'assigned',
127
+ 'in_progress',
128
+ 'completed',
129
+ 'failed',
130
+ 'cancelled',
131
+ ] as const;
132
+ export type MeshTaskStatus = typeof MESH_TASK_STATUSES[number];
133
+
134
+ export function isMeshTaskStatus(value: unknown): value is MeshTaskStatus {
135
+ return typeof value === 'string'
136
+ && (MESH_TASK_STATUSES as readonly string[]).includes(value);
137
+ }
138
+
139
+ // ─── Event scope ─────────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * Explicit scope for pending coordinator events. v1 had no scope: every
143
+ * event was broadcast to every drainer. v2 forces producers to declare
144
+ * intent.
145
+ *
146
+ * - 'unicast': delivered to exactly one coordinator (intendedFor). Other
147
+ * coordinators' drains skip it.
148
+ * - 'broadcast': delivered to every coordinator on the mesh. Used for
149
+ * system-wide signals (e.g. mesh-wide policy changes). The v1 default
150
+ * becomes explicit here so audit tools can flag unintended broadcasts.
151
+ * - 'system': delivered to the daemon-level handler, not coordinators.
152
+ * Reserved for infrastructure events that no coordinator should see
153
+ * (e.g. ledger reconciliation outcomes).
154
+ */
155
+ export const MESH_EVENT_SCOPES = ['unicast', 'broadcast', 'system'] as const;
156
+ export type MeshEventScope = typeof MESH_EVENT_SCOPES[number];
157
+
158
+ export function isMeshEventScope(value: unknown): value is MeshEventScope {
159
+ return typeof value === 'string'
160
+ && (MESH_EVENT_SCOPES as readonly string[]).includes(value);
161
+ }
162
+
163
+ // ─── Pending coordinator event v2 ────────────────────────────────────────
164
+
165
+ /**
166
+ * v2 shape of a pending coordinator event. Strict supersets of the v1
167
+ * shape — every v1 field is preserved so existing readers do not break;
168
+ * v2 fields (scope, dispatchedBy, intendedFor, protocolVersion) are
169
+ * additive and consulted by v2-aware drainers only.
170
+ *
171
+ * Mixed v1/v2 events coexist during the rollout window. B2 fully cuts
172
+ * over once every coordinator drain is v2-aware.
173
+ */
174
+ export interface PendingMeshCoordinatorEventV2 {
175
+ // v1 fields (preserved exactly)
176
+ readonly event: string;
177
+ readonly meshId: string;
178
+ readonly nodeLabel: string;
179
+ readonly nodeId?: string;
180
+ readonly workspace?: string;
181
+ readonly metadataEvent: Record<string, unknown>;
182
+ readonly coordinatorMessage?: string;
183
+ readonly queuedAt: number;
184
+
185
+ // v2 additions
186
+ readonly protocolVersion: MeshProtocolVersion;
187
+ readonly scope: MeshEventScope;
188
+ readonly dispatchedBy: CoordinatorIdentity;
189
+ /**
190
+ * Required when scope === 'unicast', forbidden otherwise. Drainers MUST
191
+ * skip unicast events whose intendedFor does not equal their own identity.
192
+ */
193
+ readonly intendedFor?: CoordinatorIdentity;
194
+ }
195
+
196
+ // ─── Ledger entry v2 ─────────────────────────────────────────────────────
197
+
198
+ /**
199
+ * v2 ledger entry additions. The originatingCoordinator field is the
200
+ * source of truth that lets completion events route back to the
201
+ * coordinator that dispatched the task. v1's "worker settings carried it
202
+ * if it happened to be set" approach is replaced.
203
+ */
204
+ export interface MeshLedgerOriginatingCoordinatorV2 {
205
+ readonly originatingCoordinator: CoordinatorIdentity;
206
+ readonly protocolVersion: MeshProtocolVersion;
207
+ }
208
+
209
+ // ─── Validation primitives ───────────────────────────────────────────────
210
+
211
+ export class MeshContractViolationError extends Error {
212
+ readonly violationPath: string;
213
+ readonly protocolVersion: MeshProtocolVersion;
214
+ constructor(
215
+ protocolVersion: MeshProtocolVersion,
216
+ violationPath: string,
217
+ detail: string,
218
+ ) {
219
+ super(`mesh contract ${protocolVersion} violation at ${violationPath}: ${detail}`);
220
+ this.name = 'MeshContractViolationError';
221
+ this.violationPath = violationPath;
222
+ this.protocolVersion = protocolVersion;
223
+ }
224
+ }
225
+
226
+ function isNonEmptyString(value: unknown): value is string {
227
+ return typeof value === 'string' && value.length > 0;
228
+ }
229
+
230
+ export function assertCoordinatorIdentity(raw: unknown, path: string): CoordinatorIdentity {
231
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
232
+ throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, path, 'must be an object');
233
+ }
234
+ const obj = raw as Record<string, unknown>;
235
+ if (!isNonEmptyString(obj.daemonId)) {
236
+ throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, `${path}.daemonId`, 'must be a non-empty string');
237
+ }
238
+ if (!isNonEmptyString(obj.coordinatorRunId)) {
239
+ throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, `${path}.coordinatorRunId`, 'must be a non-empty string');
240
+ }
241
+ const sessionId = obj.sessionId;
242
+ if (sessionId !== undefined && !isNonEmptyString(sessionId)) {
243
+ throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, `${path}.sessionId`, 'must be a non-empty string when provided');
244
+ }
245
+ return sessionId !== undefined
246
+ ? { daemonId: obj.daemonId, coordinatorRunId: obj.coordinatorRunId, sessionId }
247
+ : { daemonId: obj.daemonId, coordinatorRunId: obj.coordinatorRunId };
248
+ }
249
+
250
+ export function assertPendingMeshCoordinatorEventV2(raw: unknown, path = '$'): PendingMeshCoordinatorEventV2 {
251
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
252
+ throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, path, 'must be an object');
253
+ }
254
+ const obj = raw as Record<string, unknown>;
255
+ if (!isSupportedMeshProtocolVersion(obj.protocolVersion)) {
256
+ throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, `${path}.protocolVersion`, `must be one of ${SUPPORTED_MESH_PROTOCOL_VERSIONS.join(', ')}`);
257
+ }
258
+ if (!isMeshEventScope(obj.scope)) {
259
+ throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, `${path}.scope`, `must be one of ${MESH_EVENT_SCOPES.join(', ')}`);
260
+ }
261
+ if (!isNonEmptyString(obj.event)) {
262
+ throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, `${path}.event`, 'must be a non-empty string');
263
+ }
264
+ if (!isNonEmptyString(obj.meshId)) {
265
+ throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, `${path}.meshId`, 'must be a non-empty string');
266
+ }
267
+ const dispatchedBy = assertCoordinatorIdentity(obj.dispatchedBy, `${path}.dispatchedBy`);
268
+
269
+ if (obj.scope === 'unicast') {
270
+ if (!obj.intendedFor) {
271
+ throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, `${path}.intendedFor`, 'unicast scope requires intendedFor');
272
+ }
273
+ } else if (obj.intendedFor !== undefined) {
274
+ throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, `${path}.intendedFor`, 'only unicast scope may set intendedFor');
275
+ }
276
+ const intendedFor = obj.intendedFor
277
+ ? assertCoordinatorIdentity(obj.intendedFor, `${path}.intendedFor`)
278
+ : undefined;
279
+
280
+ const metadata = obj.metadataEvent && typeof obj.metadataEvent === 'object' && !Array.isArray(obj.metadataEvent)
281
+ ? (obj.metadataEvent as Record<string, unknown>)
282
+ : null;
283
+ if (!metadata) {
284
+ throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, `${path}.metadataEvent`, 'must be an object');
285
+ }
286
+ const queuedAt = typeof obj.queuedAt === 'number' && Number.isFinite(obj.queuedAt) ? obj.queuedAt : null;
287
+ if (queuedAt === null) {
288
+ throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, `${path}.queuedAt`, 'must be a finite number');
289
+ }
290
+
291
+ return {
292
+ event: obj.event,
293
+ meshId: obj.meshId,
294
+ nodeLabel: isNonEmptyString(obj.nodeLabel) ? obj.nodeLabel : '',
295
+ nodeId: typeof obj.nodeId === 'string' ? obj.nodeId : undefined,
296
+ workspace: typeof obj.workspace === 'string' ? obj.workspace : undefined,
297
+ metadataEvent: metadata,
298
+ coordinatorMessage: typeof obj.coordinatorMessage === 'string' ? obj.coordinatorMessage : undefined,
299
+ queuedAt,
300
+ protocolVersion: obj.protocolVersion,
301
+ scope: obj.scope,
302
+ dispatchedBy,
303
+ ...(intendedFor ? { intendedFor } : {}),
304
+ };
305
+ }
306
+
307
+ // ─── Routing helper ──────────────────────────────────────────────────────
308
+
309
+ /**
310
+ * Decide whether a v2 pending event should be delivered to the given drainer.
311
+ * Centralised so every drain implementation uses the same rule.
312
+ *
313
+ * - 'broadcast': always delivered.
314
+ * - 'system': never delivered to coordinators (system handler only).
315
+ * - 'unicast': delivered iff intendedFor matches drainer identity.
316
+ *
317
+ * v1 events (no scope / no protocolVersion) are treated as broadcast for
318
+ * backward compatibility during rollout; B3 tightens this so v1 events
319
+ * are quarantined to a dedicated drain endpoint instead.
320
+ */
321
+ export function shouldDeliverPendingEventToCoordinator(
322
+ event: PendingMeshCoordinatorEventV2,
323
+ drainer: CoordinatorIdentity,
324
+ ): boolean {
325
+ if (event.scope === 'system') return false;
326
+ if (event.scope === 'broadcast') return true;
327
+ if (!event.intendedFor) return false;
328
+ return coordinatorIdentityEquals(event.intendedFor, drainer);
329
+ }
@@ -15,6 +15,14 @@ import type { ChatMessageKind } from './chat-message-normalization.js';
15
15
  export type ReadChatTurnStatus = 'open' | 'waiting_approval' | 'complete' | 'error';
16
16
 
17
17
  export interface ReadChatResult {
18
+ /**
19
+ * Declared chat contract version. Absent or `'1.0'` → legacy v1 payload
20
+ * (current shape). `'2.0'` → v2 payload conforming to transcript-v2.ts
21
+ * (ReadChatResultV2). Validators in read-chat-contract.ts route on this
22
+ * field. A1 only adds the field; A2 will make v2 the daemon-internal
23
+ * canonical form and reject unrecognised versions at provider load time.
24
+ */
25
+ contractVersion?: import('./transcript-v2.js').ChatContractVersion;
18
26
  messages: ChatMessage[];
19
27
  status: AgentStatus;
20
28
  activeModal?: ModalInfo | null;
@@ -702,6 +710,17 @@ export interface ProviderCanonicalHistoryConfig {
702
710
  * Omitted mode defaults to 'native-source'.
703
711
  */
704
712
  mode?: 'native-source' | 'materialized-mirror' | 'disabled';
713
+ /**
714
+ * Chat transcript contract version this provider's read_chat output
715
+ * conforms to. See transcript-v2.ts for the v2 invariants. Absent or `'1.0'`
716
+ * → legacy v1 payload (current behaviour). `'2.0'` → strict v2 payload
717
+ * (stable providerUnitKey/bubbleId/sequence, strict enums, honest coverage).
718
+ *
719
+ * A1 only surfaces this field; validators in read-chat-contract.ts route
720
+ * on it. A2 makes v2 the daemon-internal canonical form and rejects
721
+ * unrecognised values at provider load time.
722
+ */
723
+ contractVersion?: import('./transcript-v2.js').ChatContractVersion;
705
724
  }
706
725
 
707
726
  /**
@@ -1378,11 +1378,15 @@ export class ProviderLoader {
1378
1378
  return args ? args.map((arg) => /\s/.test(arg) ? JSON.stringify(arg) : arg).join(' ') : '';
1379
1379
  }
1380
1380
  const schemaDef = this.getSettingsSchema(providerType)[key];
1381
- const defaultVal = schemaDef
1382
- ? (key === 'autoApprove' && schemaDef.type === 'boolean'
1383
- ? true
1384
- : schemaDef.default)
1385
- : undefined;
1381
+ // (fix) Previously this hard-coded `autoApprove` boolean default to `true`,
1382
+ // overriding whatever schemaDef.default the provider.json declared. That
1383
+ // surfaced as soon as a provider added an `autoApprove` schema entry with
1384
+ // default=false: the user had never opted in but the daemon treated the
1385
+ // session as auto-approve, which then triggered recordAutoApproval every
1386
+ // time the CLI showed an approval modal — producing a flood of system
1387
+ // "Auto-approved: ..." messages and keeping the session pinned to
1388
+ // generating while modals cycled in and out. Trust the schemaDef.default.
1389
+ const defaultVal = schemaDef ? schemaDef.default : undefined;
1386
1390
 
1387
1391
  const config = this.readConfig();
1388
1392
  const userVal = config?.providerSettings?.[providerType]?.[key];
@@ -1496,10 +1500,17 @@ export class ProviderLoader {
1496
1500
  ...this.getSyntheticSettings(type, provider),
1497
1501
  ...(provider.settings || {}),
1498
1502
  };
1503
+ // (fix) Previously this clause forced `autoApprove.default = true` for any
1504
+ // boolean autoApprove schema, even when the provider.json explicitly set
1505
+ // `default: false`. Combined with the synthetic-settings fallback at
1506
+ // getSyntheticSettings (which also defaults autoApprove to true when the
1507
+ // provider doesn't supply one), that meant CLI providers silently turned on
1508
+ // auto-approval, producing a flood of "Auto-approved: ..." system messages
1509
+ // every time an approval modal appeared and pinning the session to
1510
+ // generating while modals cycled. Trust the provider's declared default.
1499
1511
  if (result.autoApprove?.type === 'boolean') {
1500
1512
  result.autoApprove = {
1501
1513
  ...result.autoApprove,
1502
- default: true,
1503
1514
  public: true,
1504
1515
  label: result.autoApprove.label || 'Auto Approve',
1505
1516
  description: result.autoApprove.description || 'Automatically approve actionable prompts without sending approval alerts.',
@@ -1524,7 +1535,10 @@ export class ProviderLoader {
1524
1535
  if (!provider.settings?.autoApprove) {
1525
1536
  result.autoApprove = {
1526
1537
  type: 'boolean',
1527
- default: true,
1538
+ // (fix) Safe default is *off*. Auto-approving every modal without the
1539
+ // user opting in produced silent-bash-execution surprises and the
1540
+ // "Auto-approved: ..." system-message flood seen on AGY/Codex.
1541
+ default: false,
1528
1542
  public: true,
1529
1543
  label: 'Auto Approve',
1530
1544
  description: 'Automatically approve actionable prompts without sending approval alerts.',
@@ -253,6 +253,16 @@ function validateCanonicalHistory(raw: unknown, errors: string[]): void {
253
253
  errors.push('canonicalHistory.mode must be one of: native-source, materialized-mirror, disabled')
254
254
  }
255
255
 
256
+ // Chat transcript contract version (transcript-v2.ts). Absent → treated as
257
+ // v1 by readDeclaredChatContractVersion(). A2 will reject unrecognised
258
+ // values at load time; A1 only validates the field shape.
259
+ const chatContractVersion = canonicalHistory.contractVersion
260
+ if (chatContractVersion !== undefined
261
+ && chatContractVersion !== '1.0'
262
+ && chatContractVersion !== '2.0') {
263
+ errors.push(`canonicalHistory.contractVersion must be '1.0' or '2.0' when provided (got ${JSON.stringify(chatContractVersion)})`)
264
+ }
265
+
256
266
  const scripts = canonicalHistory.scripts
257
267
  if (scripts === undefined) return
258
268
  if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) {
@@ -1,6 +1,14 @@
1
1
  import type { MessagePart, ModalInfo, ReadChatResult } from './contracts.js'
2
2
  import { normalizeMessageParts } from './contracts.js'
3
3
  import type { ChatBubbleState, ChatMessage } from '../types.js'
4
+ import {
5
+ CHAT_CONTRACT_VERSION_V1,
6
+ CHAT_CONTRACT_VERSION_V2,
7
+ assertReadChatResultV2Payload,
8
+ isSupportedChatContractVersion,
9
+ type ChatContractVersion,
10
+ type ReadChatResultV2,
11
+ } from './transcript-v2.js'
4
12
 
5
13
  const VALID_STATUSES = ['idle', 'generating', 'waiting_approval', 'error', 'panel_hidden', 'starting', 'streaming', 'long_generating'] as const
6
14
  const VALID_ROLES = ['user', 'assistant', 'system', 'human'] as const
@@ -63,7 +71,7 @@ function validateMessage(message: unknown, source: string, index: number): ChatM
63
71
  content: validateMessageContent(message.content, source, index),
64
72
  }
65
73
 
66
- if (typeof message.kind === 'string') normalized.kind = message.kind as any
74
+ if (typeof message.kind === 'string') normalized.kind = message.kind as ChatMessage['kind']
67
75
  if (typeof message.id === 'string') normalized.id = message.id
68
76
  if (typeof message.bubbleId === 'string') normalized.bubbleId = message.bubbleId
69
77
  if (typeof message.providerUnitKey === 'string') normalized.providerUnitKey = message.providerUnitKey
@@ -71,20 +79,23 @@ function validateMessage(message: unknown, source: string, index: number): ChatM
71
79
  if (isFiniteNumber(message.index)) normalized.index = message.index
72
80
  if (isFiniteNumber(message.timestamp)) normalized.timestamp = message.timestamp
73
81
  if (isFiniteNumber(message.receivedAt)) normalized.receivedAt = message.receivedAt
74
- if (typeof (message as any)._turnKey === 'string') normalized._turnKey = (message as any)._turnKey
75
- if (Array.isArray(message.toolCalls)) normalized.toolCalls = message.toolCalls as any
76
- if (isPlainObject(message.meta)) normalized.meta = message.meta as any
82
+ // (A2.3) sequence is the monotonic ordering key consumed by ChatSourceMachine.
83
+ // v1 producers omit it; the daemon derives it in normalizeNativeHistoryMessages.
84
+ if (isFiniteNumber(message.sequence)) normalized.sequence = message.sequence
85
+ if (typeof message._turnKey === 'string') normalized._turnKey = message._turnKey
86
+ if (Array.isArray(message.toolCalls)) normalized.toolCalls = message.toolCalls as ChatMessage['toolCalls']
87
+ if (isPlainObject(message.meta)) normalized.meta = message.meta as ChatMessage['meta']
77
88
  if (typeof message.senderName === 'string') normalized.senderName = message.senderName
78
- if (typeof (message as any)._type === 'string') normalized._type = (message as any)._type
79
- if (typeof (message as any)._sub === 'string') normalized._sub = (message as any)._sub
80
- if (typeof (message as any).visibility === 'string') normalized.visibility = (message as any).visibility
81
- if (typeof (message as any).transcriptVisibility === 'string') normalized.transcriptVisibility = (message as any).transcriptVisibility
82
- if (typeof (message as any).audience === 'string') normalized.audience = (message as any).audience
83
- if (typeof (message as any).source === 'string') normalized.source = (message as any).source
84
- if (typeof (message as any).userFacing === 'boolean') normalized.userFacing = (message as any).userFacing
85
- if (typeof (message as any).internal === 'boolean') normalized.internal = (message as any).internal
86
- if (typeof (message as any).isInternal === 'boolean') normalized.isInternal = (message as any).isInternal
87
- if (typeof (message as any).debug === 'boolean') normalized.debug = (message as any).debug
89
+ if (typeof message._type === 'string') normalized._type = message._type
90
+ if (typeof message._sub === 'string') normalized._sub = message._sub
91
+ if (typeof message.visibility === 'string') normalized.visibility = message.visibility
92
+ if (typeof message.transcriptVisibility === 'string') normalized.transcriptVisibility = message.transcriptVisibility
93
+ if (typeof message.audience === 'string') normalized.audience = message.audience
94
+ if (typeof message.source === 'string') normalized.source = message.source
95
+ if (typeof message.userFacing === 'boolean') normalized.userFacing = message.userFacing
96
+ if (typeof message.internal === 'boolean') normalized.internal = message.internal
97
+ if (typeof message.isInternal === 'boolean') normalized.isInternal = message.isInternal
98
+ if (typeof message.debug === 'boolean') normalized.debug = message.debug
88
99
 
89
100
  return normalized
90
101
  }
@@ -129,6 +140,55 @@ function validateControlValues(controlValues: unknown, source: string): Record<s
129
140
  return normalized
130
141
  }
131
142
 
143
+ /**
144
+ * Read the producer-declared contract version from a raw read_chat payload.
145
+ * Returns v1 when absent or unrecognised, so legacy producers keep working
146
+ * through A1. A2 will tighten this to throw when an unsupported version is
147
+ * declared.
148
+ */
149
+ export function readPayloadContractVersion(raw: unknown): ChatContractVersion {
150
+ if (!isPlainObject(raw)) return CHAT_CONTRACT_VERSION_V1
151
+ const declared = (raw as Record<string, unknown>).contractVersion
152
+ if (isSupportedChatContractVersion(declared)) return declared
153
+ return CHAT_CONTRACT_VERSION_V1
154
+ }
155
+
156
+ /**
157
+ * Validate a v2 payload. Thin wrapper around assertReadChatResultV2Payload
158
+ * that prefixes the contract violation with the caller's source label.
159
+ */
160
+ export function validateReadChatResultV2Payload(raw: unknown, source = 'read_chat'): ReadChatResultV2 {
161
+ try {
162
+ return assertReadChatResultV2Payload(raw)
163
+ } catch (err) {
164
+ if (err instanceof Error) {
165
+ err.message = `${source}: ${err.message}`
166
+ }
167
+ throw err
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Versioned entry point. Routes on the producer-declared contractVersion:
173
+ * - v2 → strict v2 validation (transcript-v2.ts invariants)
174
+ * - v1 (or absent) → legacy permissive validation
175
+ *
176
+ * Callers that have not yet been audited to handle v2 outputs should use
177
+ * validateReadChatResultPayload directly; that path stays bound to v1 shape
178
+ * during the A1 transition.
179
+ */
180
+ export function validateReadChatResultPayloadVersioned(
181
+ raw: unknown,
182
+ source = 'read_chat',
183
+ ): { version: typeof CHAT_CONTRACT_VERSION_V1; payload: ReadChatResult & Record<string, unknown> }
184
+ | { version: typeof CHAT_CONTRACT_VERSION_V2; payload: ReadChatResultV2 } {
185
+ const version = readPayloadContractVersion(raw)
186
+ if (version === CHAT_CONTRACT_VERSION_V2) {
187
+ return { version, payload: validateReadChatResultV2Payload(raw, source) }
188
+ }
189
+ return { version: CHAT_CONTRACT_VERSION_V1, payload: validateReadChatResultPayload(raw, source) }
190
+ }
191
+
132
192
  export function validateReadChatResultPayload(raw: unknown, source = 'read_chat'): ReadChatResult & Record<string, unknown> {
133
193
  if (!isPlainObject(raw)) {
134
194
  throw new Error(`${source}: payload must be an object`)