@adhdev/daemon-core 0.9.82-rc.136 → 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 (63) 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-script-runner.d.ts +45 -0
  4. package/dist/cli-adapters/cli-state-engine.d.ts +169 -0
  5. package/dist/cli-adapters/provider-cli-adapter.d.ts +72 -74
  6. package/dist/cli-adapters/provider-cli-parse.d.ts +1 -0
  7. package/dist/cli-adapters/provider-cli-shared.d.ts +5 -0
  8. package/dist/config/chat-history.d.ts +1 -0
  9. package/dist/index.d.ts +3 -3
  10. package/dist/index.js +3507 -2288
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +3515 -2301
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/mesh/beads-db.d.ts +54 -0
  15. package/dist/mesh/contracts.d.ts +164 -0
  16. package/dist/mesh/mesh-active-work.d.ts +7 -1
  17. package/dist/mesh/mesh-events.d.ts +10 -4
  18. package/dist/mesh/mesh-ledger.d.ts +21 -1
  19. package/dist/mesh/mesh-refine-status.d.ts +2 -3
  20. package/dist/mesh/mesh-work-queue.d.ts +17 -0
  21. package/dist/mesh/worktree-bootstrap-config.d.ts +2 -4
  22. package/dist/providers/contracts.d.ts +19 -0
  23. package/dist/providers/read-chat-contract.d.ts +29 -0
  24. package/dist/providers/transcript-v2.d.ts +176 -0
  25. package/dist/repo-mesh-types.d.ts +5 -0
  26. package/dist/shared-types.d.ts +7 -0
  27. package/dist/status/snapshot.d.ts +1 -0
  28. package/dist/types.d.ts +5 -0
  29. package/package.json +1 -1
  30. package/src/chat/source-machine.ts +534 -0
  31. package/src/chat/source-resolver.ts +0 -0
  32. package/src/chat/subscription-updates.ts +9 -0
  33. package/src/cli-adapters/cli-script-runner.ts +145 -0
  34. package/src/cli-adapters/cli-state-engine.ts +1054 -0
  35. package/src/cli-adapters/provider-cli-adapter.d.ts +0 -1
  36. package/src/cli-adapters/provider-cli-adapter.ts +413 -1399
  37. package/src/cli-adapters/provider-cli-parse.ts +3 -0
  38. package/src/cli-adapters/provider-cli-shared.ts +17 -1
  39. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +17 -1
  40. package/src/cli-adapters/terminal-backends/xterm-backend.ts +8 -1
  41. package/src/commands/chat-commands.ts +715 -368
  42. package/src/commands/router.ts +22 -2
  43. package/src/config/chat-history.ts +43 -16
  44. package/src/git/git-worktree.ts +8 -1
  45. package/src/index.ts +3 -2
  46. package/src/mesh/beads-db.ts +305 -2
  47. package/src/mesh/contracts.ts +329 -0
  48. package/src/mesh/coordinator-prompt.ts +12 -17
  49. package/src/mesh/mesh-active-work.ts +162 -59
  50. package/src/mesh/mesh-events.ts +198 -53
  51. package/src/mesh/mesh-ledger.ts +321 -105
  52. package/src/mesh/mesh-refine-status.ts +2 -3
  53. package/src/mesh/mesh-work-queue.ts +116 -120
  54. package/src/mesh/worktree-bootstrap-config.ts +17 -4
  55. package/src/providers/contracts.ts +19 -0
  56. package/src/providers/provider-loader.ts +21 -7
  57. package/src/providers/provider-schema.ts +12 -0
  58. package/src/providers/read-chat-contract.ts +74 -14
  59. package/src/providers/transcript-v2.ts +567 -0
  60. package/src/repo-mesh-types.ts +10 -0
  61. package/src/shared-types.ts +7 -0
  62. package/src/status/snapshot.ts +35 -11
  63. package/src/types.ts +5 -0
@@ -21,10 +21,12 @@ export interface MeshTaskModeValidationResult {
21
21
 
22
22
  const LIVE_DEBUG_READONLY_FORBIDDEN: Array<{ label: string; pattern: RegExp }> = [
23
23
  { label: 'source_edit', pattern: /\b(edit|modify|patch|apply\s+patch|write\s+(?:to\s+)?(?:file|source)|overwrite|delete\s+file|remove\s+file|create\s+file|touch\s+file)\b/i },
24
- { label: 'git_mutation', pattern: /\b(?:git\s+(?:add|commit|push|reset|rebase|clean|checkout|switch|merge|tag|restore|rm|mv)|push\b)/i },
24
+ { label: 'git_mutation', pattern: /\b(?:git\s+(?:add|commit|push|reset|rebase|clean|checkout|switch|merge|tag|restore|rm|mv|stash|worktree\s+(?:add|remove|move))|push\b)/i },
25
25
  { label: 'checkpoint', pattern: /\b(checkpoint|mesh_checkpoint)\b/i },
26
- { label: 'deploy_or_version_bump', pattern: /\b(deploy|wrangler\s+deploy|version[-\s]?bump|npm\s+version|release)\b/i },
27
- { label: 'destructive_shell', pattern: /\b(rm\s+-rf|mv\s+\S+\s+\S+|truncate\s|tee\s+\S+|sed\s+-i)\b/i },
26
+ { label: 'deploy_or_version_bump', pattern: /\b(deploy|wrangler\s+deploy|version[-\s]?bump|npm\s+version|release|npm\s+publish|yarn\s+publish|pnpm\s+publish)\b/i },
27
+ { label: 'destructive_shell', pattern: /\b(rm\s+-rf|mv\s+\S+\s+\S+|truncate\s|tee\s+\S+|sed\s+-i|shred\b)\b/i },
28
+ { label: 'package_install', pattern: /\b(npm\s+(?:install|i|add|link|uninstall|remove)|yarn\s+(?:add|remove|link)|pnpm\s+(?:add|remove|link)|pip\s+install|brew\s+install|apt\s+install|cargo\s+install)\b/i },
29
+ { label: 'container_mutation', pattern: /\b(docker\s+(?:build|run|exec|push|tag|rmi|rm|create|start|stop|kill)|kubectl\s+(?:apply|delete|patch|replace|create|scale))\b/i },
28
30
  ];
29
31
 
30
32
  export function normalizeMeshTaskMode(value: unknown): MeshTaskMode | undefined {
@@ -122,35 +124,26 @@ export function enqueueTask(
122
124
  if (!modeValidation.valid) {
123
125
  throw new Error(`live_debug_readonly_guardrail_violation: forbidden operations (${modeValidation.violations.join(', ')})`);
124
126
  }
125
- return withQueueLock(meshId, () => {
126
- const queue = readQueue(meshId);
127
- const entry: MeshWorkQueueEntry = {
128
- id: randomUUID(),
129
- meshId,
130
- message,
131
- status: 'pending',
132
- taskMode: modeValidation.taskMode,
133
- targetNodeId: opts?.targetNodeId,
134
- targetSessionId: opts?.targetSessionId,
135
- createdAt: new Date().toISOString(),
136
- updatedAt: new Date().toISOString(),
137
- };
138
- queue.push(entry);
139
- writeQueue(meshId, queue);
140
- return entry;
141
- });
127
+ const entry: MeshWorkQueueEntry = {
128
+ id: randomUUID(),
129
+ meshId,
130
+ message,
131
+ status: 'pending',
132
+ taskMode: modeValidation.taskMode,
133
+ targetNodeId: opts?.targetNodeId,
134
+ targetSessionId: opts?.targetSessionId,
135
+ createdAt: new Date().toISOString(),
136
+ updatedAt: new Date().toISOString(),
137
+ };
138
+ BeadsDB.getInstance().insertQueueEntry(entry);
139
+ return entry;
142
140
  }
143
141
 
144
142
  /**
145
143
  * Get all tasks in the queue, optionally filtered by status.
146
144
  */
147
145
  export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }): MeshWorkQueueEntry[] {
148
- let queue = readQueue(meshId);
149
- if (opts?.status?.length) {
150
- const statuses = new Set(opts.status);
151
- queue = queue.filter(q => statuses.has(q.status));
152
- }
153
- return queue;
146
+ return BeadsDB.getInstance().getQueueEntries(meshId, opts?.status?.length ? opts.status : undefined);
154
147
  }
155
148
 
156
149
  export function getMeshQueueRevision(meshId: string): string {
@@ -161,29 +154,7 @@ export function getMeshQueueRevision(meshId: string): string {
161
154
  * Find the next pending task that this node is allowed to claim, and mark it as assigned.
162
155
  */
163
156
  export function claimNextTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
164
- return withQueueLock(meshId, () => {
165
- const queue = readQueue(meshId);
166
- const hasActiveAssignment = queue.some(q => q.status === 'assigned' && (
167
- q.assignedSessionId === sessionId || q.assignedNodeId === nodeId
168
- ));
169
- if (hasActiveAssignment) return null;
170
- let targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetSessionId === sessionId);
171
- if (targetIdx === -1) {
172
- targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetNodeId === nodeId && !q.targetSessionId);
173
- }
174
- if (targetIdx === -1) {
175
- targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId && !q.targetSessionId);
176
- }
177
- if (targetIdx === -1) return null;
178
- const entry = queue[targetIdx];
179
- entry.status = 'assigned';
180
- entry.assignedNodeId = nodeId;
181
- entry.assignedSessionId = sessionId;
182
- entry.dispatchTimestamp = new Date().toISOString();
183
- entry.updatedAt = new Date().toISOString();
184
- writeQueue(meshId, queue);
185
- return entry;
186
- });
157
+ return BeadsDB.getInstance().claimNextQueueTask(meshId, nodeId, sessionId);
187
158
  }
188
159
 
189
160
  /**
@@ -198,13 +169,11 @@ export function updateTaskStatus(
198
169
  ): MeshWorkQueueEntry | null {
199
170
  requireMeshHostQueueOwner(opts);
200
171
  return withQueueLock(meshId, () => {
201
- const queue = readQueue(meshId);
202
- const idx = queue.findIndex(q => q.id === taskId);
203
- if (idx === -1) return null;
204
- queue[idx].status = status;
205
- queue[idx].updatedAt = new Date().toISOString();
206
- writeQueue(meshId, queue);
207
- return queue[idx];
172
+ const entry = BeadsDB.getInstance().findQueueEntryById(meshId, taskId);
173
+ if (!entry) return null;
174
+ entry.status = status;
175
+ BeadsDB.getInstance().updateQueueEntry(entry);
176
+ return entry;
208
177
  });
209
178
  }
210
179
 
@@ -214,14 +183,12 @@ export function recordTaskAutoLaunch(
214
183
  autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>,
215
184
  ): MeshWorkQueueEntry | null {
216
185
  return withQueueLock(meshId, () => {
217
- const queue = readQueue(meshId);
218
- const idx = queue.findIndex(q => q.id === taskId);
219
- if (idx === -1) return null;
186
+ const entry = BeadsDB.getInstance().findQueueEntryById(meshId, taskId);
187
+ if (!entry) return null;
220
188
  const now = new Date().toISOString();
221
- queue[idx].autoLaunch = { ...autoLaunch, updatedAt: now };
222
- queue[idx].updatedAt = now;
223
- writeQueue(meshId, queue);
224
- return queue[idx];
189
+ entry.autoLaunch = { ...autoLaunch, updatedAt: now };
190
+ BeadsDB.getInstance().updateQueueEntry(entry);
191
+ return entry;
225
192
  });
226
193
  }
227
194
 
@@ -235,16 +202,14 @@ export function cancelTask(
235
202
  ): MeshWorkQueueEntry | null {
236
203
  requireMeshHostQueueOwner(opts);
237
204
  return withQueueLock(meshId, () => {
238
- const queue = readQueue(meshId);
239
- const idx = queue.findIndex(q => q.id === taskId);
240
- if (idx === -1) return null;
205
+ const entry = BeadsDB.getInstance().findQueueEntryById(meshId, taskId);
206
+ if (!entry) return null;
241
207
  const now = new Date().toISOString();
242
- queue[idx].status = 'cancelled';
243
- queue[idx].updatedAt = now;
244
- queue[idx].cancelledAt = now;
245
- if (opts?.reason) queue[idx].cancelReason = opts.reason;
246
- writeQueue(meshId, queue);
247
- return queue[idx];
208
+ entry.status = 'cancelled';
209
+ entry.cancelledAt = now;
210
+ if (opts?.reason) entry.cancelReason = opts.reason;
211
+ BeadsDB.getInstance().updateQueueEntry(entry);
212
+ return entry;
248
213
  });
249
214
  }
250
215
 
@@ -265,11 +230,8 @@ export function requeueTask(
265
230
  ): MeshWorkQueueEntry | null {
266
231
  requireMeshHostQueueOwner(opts);
267
232
  return withQueueLock(meshId, () => {
268
- const queue = readQueue(meshId);
269
- const idx = queue.findIndex(q => q.id === taskId);
270
- if (idx === -1) return null;
271
- const entry = queue[idx];
272
- const now = new Date().toISOString();
233
+ const entry = BeadsDB.getInstance().findQueueEntryById(meshId, taskId);
234
+ if (!entry) return null;
273
235
  entry.status = 'pending';
274
236
  delete entry.assignedNodeId;
275
237
  delete entry.assignedSessionId;
@@ -279,11 +241,10 @@ export function requeueTask(
279
241
  if (typeof opts?.targetNodeId === 'string') entry.targetNodeId = opts.targetNodeId;
280
242
  if (opts?.clearTargetSession !== false) delete entry.targetSessionId;
281
243
  if (typeof opts?.targetSessionId === 'string') entry.targetSessionId = opts.targetSessionId;
282
- entry.updatedAt = now;
283
- entry.requeuedAt = now;
244
+ entry.requeuedAt = new Date().toISOString();
284
245
  entry.requeueCount = (entry.requeueCount || 0) + 1;
285
246
  if (opts?.reason) entry.requeueReason = opts.reason;
286
- writeQueue(meshId, queue);
247
+ BeadsDB.getInstance().updateQueueEntry(entry);
287
248
  return entry;
288
249
  });
289
250
  }
@@ -298,22 +259,12 @@ export function updateSessionTaskStatus(
298
259
  opts?: { occurredAt?: string },
299
260
  ): MeshWorkQueueEntry | null {
300
261
  return withQueueLock(meshId, () => {
301
- const queue = readQueue(meshId);
302
- const occurredAtTime = opts?.occurredAt ? new Date(opts.occurredAt).getTime() : Number.NaN;
303
- const hasOccurredAt = Number.isFinite(occurredAtTime);
304
- let bestIdx = -1;
305
- let bestTime = 0;
306
- for (let i = queue.length - 1; i >= 0; i--) {
307
- if (queue[i].assignedSessionId !== sessionId || queue[i].status !== 'assigned') continue;
308
- const time = new Date(queue[i].dispatchTimestamp || queue[i].updatedAt).getTime();
309
- if (hasOccurredAt && Number.isFinite(time) && time > occurredAtTime) continue;
310
- if (time > bestTime) { bestTime = time; bestIdx = i; }
311
- }
312
- if (bestIdx === -1) return null;
313
- queue[bestIdx].status = status;
314
- queue[bestIdx].updatedAt = new Date().toISOString();
315
- writeQueue(meshId, queue);
316
- return queue[bestIdx];
262
+ const occurredAtIso = opts?.occurredAt ? new Date(opts.occurredAt).toISOString() : undefined;
263
+ const entry = BeadsDB.getInstance().findAssignedBySession(meshId, sessionId, occurredAtIso);
264
+ if (!entry) return null;
265
+ entry.status = status;
266
+ BeadsDB.getInstance().updateQueueEntry(entry);
267
+ return entry;
317
268
  });
318
269
  }
319
270
 
@@ -342,14 +293,16 @@ export interface MeshWorkQueueStats {
342
293
  * Return aggregate queue statistics for the given mesh.
343
294
  */
344
295
  export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
345
- const queue = readQueue(meshId);
346
- const pending = queue.filter(q => q.status === 'pending').length;
347
- const assigned = queue.filter(q => q.status === 'assigned').length;
348
- const completed = queue.filter(q => q.status === 'completed').length;
349
- const failed = queue.filter(q => q.status === 'failed').length;
350
- const cancelled = queue.filter(q => q.status === 'cancelled').length;
296
+ const rows = BeadsDB.getInstance().getQueueStatsByStatus(meshId);
297
+ const counts: Record<string, number> = {};
298
+ for (const r of rows) counts[r.status] = r.count;
299
+ const pending = counts['pending'] ?? 0;
300
+ const assigned = counts['assigned'] ?? 0;
301
+ const completed = counts['completed'] ?? 0;
302
+ const failed = counts['failed'] ?? 0;
303
+ const cancelled = counts['cancelled'] ?? 0;
351
304
  return {
352
- total: queue.length,
305
+ total: pending + assigned + completed + failed + cancelled,
353
306
  active: pending + assigned,
354
307
  historical: completed + failed + cancelled,
355
308
  pending,
@@ -357,23 +310,9 @@ export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
357
310
  completed,
358
311
  failed,
359
312
  cancelled,
360
- activeCounts: {
361
- pending,
362
- assigned,
363
- },
364
- historicalCounts: {
365
- completed,
366
- failed,
367
- cancelled,
368
- },
369
- activeAssignments: queue
370
- .filter(q => q.status === 'assigned')
371
- .map(q => ({
372
- id: q.id,
373
- nodeId: q.assignedNodeId,
374
- sessionId: q.assignedSessionId,
375
- message: q.message,
376
- })),
313
+ activeCounts: { pending, assigned },
314
+ historicalCounts: { completed, failed, cancelled },
315
+ activeAssignments: BeadsDB.getInstance().getActiveAssignmentDetails(meshId),
377
316
  };
378
317
  }
379
318
 
@@ -390,3 +329,60 @@ export function __clearMeshQueueForTests(meshId: string): void {
390
329
  export function __resetBeadsDBForTests(): void {
391
330
  BeadsDB.resetForTests();
392
331
  }
332
+
333
+ // ── Direct Dispatch Tracking ─────────────────────────────────────────────────
334
+ // Persists direct (non-queue) task dispatches so buildMeshActiveWork can read
335
+ // active work from BeadsDB instead of scanning ledger JSONL entries.
336
+
337
+ export type DirectDispatchRecord = ReturnType<BeadsDB['getActiveDirectDispatches']>[number];
338
+
339
+ export function insertDirectDispatch(
340
+ meshId: string,
341
+ data: {
342
+ taskId: string;
343
+ nodeId?: string;
344
+ sessionId?: string;
345
+ providerType?: string;
346
+ message: string;
347
+ taskMode?: string;
348
+ via: string;
349
+ dispatchedToIdleSession?: boolean;
350
+ dispatchedAt: string;
351
+ },
352
+ ): void {
353
+ try {
354
+ BeadsDB.getInstance().insertDirectDispatch({ ...data, meshId });
355
+ } catch (e: any) {
356
+ process.stderr.write(`[adhdev-mesh] insertDirectDispatch failed for task ${data.taskId}: ${e?.message || e}\n`);
357
+ }
358
+ }
359
+
360
+ export function getActiveDirectDispatches(meshId: string): DirectDispatchRecord[] {
361
+ try {
362
+ return BeadsDB.getInstance().getActiveDirectDispatches(meshId);
363
+ } catch {
364
+ return [];
365
+ }
366
+ }
367
+
368
+ export function updateDirectDispatchStatus(
369
+ meshId: string,
370
+ sessionId: string,
371
+ status: 'acked' | 'completed' | 'failed' | 'stale',
372
+ ): void {
373
+ try {
374
+ BeadsDB.getInstance().updateDirectDispatchStatus(meshId, sessionId, status);
375
+ } catch { /* best-effort */ }
376
+ }
377
+
378
+ export function cleanupTerminalDirectDispatches(olderThanMs = 7 * 24 * 60 * 60_000): void {
379
+ try {
380
+ BeadsDB.getInstance().cleanupTerminalDirectDispatches(olderThanMs);
381
+ } catch { /* best-effort */ }
382
+ }
383
+
384
+ export function markStaleDirectDispatches(meshId: string, olderThanMs = 60 * 60_000): void {
385
+ try {
386
+ BeadsDB.getInstance().markStaleDirectDispatches(meshId, olderThanMs);
387
+ } catch { /* best-effort */ }
388
+ }
@@ -9,6 +9,7 @@ import {
9
9
  type MeshRefineValidationCommandPlan,
10
10
  type RepoMeshRefineValidationCommandConfig,
11
11
  } from './refine-config.js';
12
+ import type { MeshAsyncJobLifecycle } from '../repo-mesh-types.js';
12
13
 
13
14
  export type WorktreeBootstrapStatus = 'ready' | 'running' | 'failed' | 'not_configured' | 'disabled' | 'stale';
14
15
 
@@ -21,16 +22,13 @@ export interface RepoMeshWorktreeBootstrapConfig {
21
22
  staleInputs?: string[];
22
23
  }
23
24
 
24
- export interface WorktreeBootstrapState {
25
+ export interface WorktreeBootstrapState extends MeshAsyncJobLifecycle {
25
26
  status: WorktreeBootstrapStatus;
26
27
  required: boolean;
27
28
  configSource?: string;
28
29
  configSourceType?: 'repo_file' | 'mesh_policy' | 'unavailable' | 'invalid';
29
- startedAt?: string;
30
- completedAt?: string;
31
30
  lastCommand?: string;
32
31
  exitCode?: number | null;
33
- error?: string;
34
32
  commandsRun?: Array<Record<string, unknown>>;
35
33
  staleInputs?: string[];
36
34
  }
@@ -177,7 +175,22 @@ export async function runMeshWorktreeBootstrap(mesh: any, workspace: string): Pr
177
175
  commandsRun: [],
178
176
  staleInputs: loaded.config.staleInputs,
179
177
  };
178
+ const staleInputPaths = loaded.config.staleInputs ?? [];
179
+ // Snapshot which staleInputs files are absent at bootstrap start.
180
+ // Files already present before we start are not interference signals.
181
+ const initiallyAbsent = staleInputPaths.filter(p => !existsSync(join(workspace, p)));
180
182
  for (const command of validation.commands) {
183
+ // Check if any initially-absent staleInputs files appeared since bootstrap started
184
+ // (indicates another process modified the environment mid-run).
185
+ if (initiallyAbsent.length > 0) {
186
+ const appearedNow = initiallyAbsent.filter(p => existsSync(join(workspace, p)));
187
+ if (appearedNow.length > 0) {
188
+ state.status = 'stale';
189
+ state.completedAt = new Date().toISOString();
190
+ state.error = `Bootstrap interrupted: staleInputs files appeared during run: ${appearedNow.join(', ')}`;
191
+ return state;
192
+ }
193
+ }
181
194
  const cwd = command.cwd ? pathResolve(workspace, command.cwd) : workspace;
182
195
  const startedAt = Date.now();
183
196
  state.lastCommand = command.displayCommand;
@@ -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.',
@@ -64,6 +64,8 @@ const KNOWN_PROVIDER_FIELDS = new Set<string>([
64
64
  'sendKey',
65
65
  'submitStrategy',
66
66
  'requirePromptEchoBeforeSubmit',
67
+ 'requiresFinalAssistantBeforeIdle',
68
+ 'augmentStaleSnapshot',
67
69
  'timeouts',
68
70
  'disableUpstream',
69
71
  ])
@@ -251,6 +253,16 @@ function validateCanonicalHistory(raw: unknown, errors: string[]): void {
251
253
  errors.push('canonicalHistory.mode must be one of: native-source, materialized-mirror, disabled')
252
254
  }
253
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
+
254
266
  const scripts = canonicalHistory.scripts
255
267
  if (scripts === undefined) return
256
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`)