@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.
- package/dist/chat/source-machine.d.ts +166 -0
- package/dist/chat/source-resolver.d.ts +104 -0
- package/dist/cli-adapters/cli-state-engine.d.ts +15 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +0 -1
- package/dist/cli-adapters/provider-cli-parse.d.ts +1 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +1 -0
- package/dist/index.js +922 -328
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +922 -328
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/contracts.d.ts +164 -0
- package/dist/providers/contracts.d.ts +19 -0
- package/dist/providers/read-chat-contract.d.ts +29 -0
- package/dist/providers/transcript-v2.d.ts +176 -0
- package/dist/shared-types.d.ts +7 -0
- package/dist/status/snapshot.d.ts +1 -0
- package/dist/types.d.ts +5 -0
- package/package.json +1 -1
- package/src/chat/source-machine.ts +534 -0
- package/src/chat/source-resolver.ts +0 -0
- package/src/chat/subscription-updates.ts +9 -0
- package/src/cli-adapters/cli-state-engine.ts +103 -6
- package/src/cli-adapters/provider-cli-adapter.ts +51 -5
- package/src/cli-adapters/provider-cli-parse.ts +3 -0
- package/src/cli-adapters/provider-cli-shared.ts +13 -1
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +17 -1
- package/src/cli-adapters/terminal-backends/xterm-backend.ts +8 -1
- package/src/commands/chat-commands.ts +712 -381
- package/src/commands/router.ts +14 -2
- package/src/config/chat-history.ts +36 -13
- package/src/mesh/contracts.ts +329 -0
- package/src/providers/contracts.ts +19 -0
- package/src/providers/provider-loader.ts +21 -7
- package/src/providers/provider-schema.ts +10 -0
- package/src/providers/read-chat-contract.ts +74 -14
- package/src/providers/transcript-v2.ts +567 -0
- package/src/shared-types.ts +7 -0
- package/src/status/snapshot.ts +35 -11
- package/src/types.ts +5 -0
package/src/commands/router.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) =>
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
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
|
|
79
|
-
if (typeof
|
|
80
|
-
if (typeof
|
|
81
|
-
if (typeof
|
|
82
|
-
if (typeof
|
|
83
|
-
if (typeof
|
|
84
|
-
if (typeof
|
|
85
|
-
if (typeof
|
|
86
|
-
if (typeof
|
|
87
|
-
if (typeof
|
|
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`)
|