@adhdev/daemon-core 0.9.82-rc.195 → 0.9.82-rc.196

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 (37) hide show
  1. package/dist/cli-adapter-types.d.ts +1 -0
  2. package/dist/index.js +202 -70
  3. package/dist/index.js.map +1 -1
  4. package/dist/index.mjs +205 -73
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/mesh/contracts.d.ts +1 -1
  7. package/dist/mesh/mesh-active-work.d.ts +1 -1
  8. package/dist/mesh/{beads-db.d.ts → mesh-runtime-store.d.ts} +2 -2
  9. package/dist/mesh/mesh-work-queue.d.ts +3 -3
  10. package/dist/providers/provider-instance.d.ts +1 -1
  11. package/dist/providers/spec/driver.d.ts +4 -1
  12. package/dist/providers/spec/schema.gen.d.ts +46 -0
  13. package/dist/providers/spec/types.d.ts +39 -0
  14. package/dist/shared-types-extra.d.ts +1 -1
  15. package/dist/status/normalize.d.ts +1 -1
  16. package/dist/status/normalize.js +1 -0
  17. package/dist/status/normalize.js.map +1 -1
  18. package/dist/status/normalize.mjs +1 -0
  19. package/dist/status/normalize.mjs.map +1 -1
  20. package/package.json +1 -1
  21. package/src/cli-adapter-types.ts +1 -0
  22. package/src/cli-adapters/cli-state-engine.ts +44 -2
  23. package/src/mesh/contracts.ts +1 -1
  24. package/src/mesh/mesh-active-work.ts +8 -8
  25. package/src/mesh/mesh-events.ts +12 -12
  26. package/src/mesh/{beads-db.ts → mesh-runtime-store.ts} +30 -7
  27. package/src/mesh/mesh-work-queue.ts +33 -33
  28. package/src/providers/cli-provider-instance.ts +31 -8
  29. package/src/providers/provider-instance.ts +1 -1
  30. package/src/providers/spec/driver.ts +34 -3
  31. package/src/providers/spec/evaluator.ts +32 -3
  32. package/src/providers/spec/schema.gen.ts +22 -2
  33. package/src/providers/spec/schema.json +1 -0
  34. package/src/providers/spec/types.ts +39 -0
  35. package/src/providers/types/interactive-prompt.ts +21 -7
  36. package/src/shared-types-extra.ts +1 -1
  37. package/src/status/normalize.ts +2 -0
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, statSync } from 'fs';
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'fs';
2
2
  import { dirname, join } from 'path';
3
3
  import { createRequire } from 'module';
4
4
  import { getLedgerDir } from './mesh-ledger.js';
@@ -26,8 +26,31 @@ function legacyQueuePath(meshId: string): string {
26
26
  return join(getLedgerDir(), `${safeMeshId(meshId)}.queue.json`);
27
27
  }
28
28
 
29
- export class BeadsDB {
30
- private static instance: BeadsDB | undefined;
29
+ function meshRuntimeStorePath(): string {
30
+ const dir = getLedgerDir();
31
+ const nextPath = join(dir, 'mesh-runtime.db');
32
+ if (existsSync(nextPath)) return nextPath;
33
+
34
+ const legacyPath = join(dir, 'beads.db');
35
+ if (!existsSync(legacyPath)) return nextPath;
36
+
37
+ try {
38
+ renameSync(legacyPath, nextPath);
39
+ for (const suffix of ['-wal', '-shm']) {
40
+ const legacyCompanion = `${legacyPath}${suffix}`;
41
+ if (existsSync(legacyCompanion)) {
42
+ renameSync(legacyCompanion, `${nextPath}${suffix}`);
43
+ }
44
+ }
45
+ } catch {
46
+ // Best-effort compatibility for existing installs. If migration fails,
47
+ // opening the new store will create a clean DB instead of blocking boot.
48
+ }
49
+ return nextPath;
50
+ }
51
+
52
+ export class MeshRuntimeStore {
53
+ private static instance: MeshRuntimeStore | undefined;
31
54
  private readonly db: DatabaseHandle;
32
55
  private readonly dbPath: string;
33
56
  private readonly migratedMeshIds = new Set<string>();
@@ -49,9 +72,9 @@ export class BeadsDB {
49
72
  this.migrate();
50
73
  }
51
74
 
52
- static getInstance(): BeadsDB {
75
+ static getInstance(): MeshRuntimeStore {
53
76
  if (!this.instance) {
54
- this.instance = new BeadsDB(join(getLedgerDir(), 'beads.db'));
77
+ this.instance = new MeshRuntimeStore(meshRuntimeStorePath());
55
78
  }
56
79
  return this.instance;
57
80
  }
@@ -149,13 +172,13 @@ export class BeadsDB {
149
172
  }
150
173
 
151
174
  private maybeCheckpointWal(): void {
152
- if (++this.walWriteCounter < BeadsDB.WAL_CHECK_INTERVAL) return;
175
+ if (++this.walWriteCounter < MeshRuntimeStore.WAL_CHECK_INTERVAL) return;
153
176
  this.walWriteCounter = 0;
154
177
  try {
155
178
  const walPath = `${this.dbPath}-wal`;
156
179
  if (!existsSync(walPath)) return;
157
180
  const size = statSync(walPath).size;
158
- if (size < BeadsDB.WAL_MAX_BYTES) return;
181
+ if (size < MeshRuntimeStore.WAL_MAX_BYTES) return;
159
182
  process.stderr.write(
160
183
  `[adhdev-mesh] WAL file ${Math.round(size / 1024 / 1024)}MB exceeds threshold; forcing checkpoint\n`,
161
184
  );
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
3
3
  import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
4
- import { BeadsDB } from './beads-db.js';
4
+ import { MeshRuntimeStore } from './mesh-runtime-store.js';
5
5
 
6
6
  export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
7
7
  export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
@@ -145,15 +145,15 @@ export function nodeSatisfiesRequiredTags(requiredTags: unknown, capabilityTags:
145
145
  }
146
146
 
147
147
  function withQueueLock<T>(_meshId: string, fn: () => T): T {
148
- return BeadsDB.getInstance().transaction(fn);
148
+ return MeshRuntimeStore.getInstance().transaction(fn);
149
149
  }
150
150
 
151
151
  function readQueue(meshId: string): MeshWorkQueueEntry[] {
152
- return BeadsDB.getInstance().getQueueEntries(meshId);
152
+ return MeshRuntimeStore.getInstance().getQueueEntries(meshId);
153
153
  }
154
154
 
155
155
  function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
156
- BeadsDB.getInstance().replaceQueue(meshId, queue);
156
+ MeshRuntimeStore.getInstance().replaceQueue(meshId, queue);
157
157
  }
158
158
 
159
159
  /**
@@ -181,7 +181,7 @@ export function enqueueTask(
181
181
  createdAt: new Date().toISOString(),
182
182
  updatedAt: new Date().toISOString(),
183
183
  };
184
- BeadsDB.getInstance().insertQueueEntry(entry);
184
+ MeshRuntimeStore.getInstance().insertQueueEntry(entry);
185
185
  return entry;
186
186
  }
187
187
 
@@ -189,18 +189,18 @@ export function enqueueTask(
189
189
  * Get all tasks in the queue, optionally filtered by status.
190
190
  */
191
191
  export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }): MeshWorkQueueEntry[] {
192
- return BeadsDB.getInstance().getQueueEntries(meshId, opts?.status?.length ? opts.status : undefined);
192
+ return MeshRuntimeStore.getInstance().getQueueEntries(meshId, opts?.status?.length ? opts.status : undefined);
193
193
  }
194
194
 
195
195
  export function getMeshQueueRevision(meshId: string): string {
196
- return BeadsDB.getInstance().getQueueRevision(meshId);
196
+ return MeshRuntimeStore.getInstance().getQueueRevision(meshId);
197
197
  }
198
198
 
199
199
  /**
200
200
  * Find the next pending task that this node is allowed to claim, and mark it as assigned.
201
201
  */
202
202
  export function claimNextTask(meshId: string, nodeId: string, sessionId: string, capabilityTags?: string[]): MeshWorkQueueEntry | null {
203
- return BeadsDB.getInstance().claimNextQueueTask(meshId, nodeId, sessionId, capabilityTags);
203
+ return MeshRuntimeStore.getInstance().claimNextQueueTask(meshId, nodeId, sessionId, capabilityTags);
204
204
  }
205
205
 
206
206
  /**
@@ -215,10 +215,10 @@ export function updateTaskStatus(
215
215
  ): MeshWorkQueueEntry | null {
216
216
  requireMeshHostQueueOwner(opts);
217
217
  return withQueueLock(meshId, () => {
218
- const entry = BeadsDB.getInstance().findQueueEntryById(meshId, taskId);
218
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
219
219
  if (!entry) return null;
220
220
  entry.status = status;
221
- BeadsDB.getInstance().updateQueueEntry(entry);
221
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
222
222
  return entry;
223
223
  });
224
224
  }
@@ -229,11 +229,11 @@ export function recordTaskAutoLaunch(
229
229
  autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>,
230
230
  ): MeshWorkQueueEntry | null {
231
231
  return withQueueLock(meshId, () => {
232
- const entry = BeadsDB.getInstance().findQueueEntryById(meshId, taskId);
232
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
233
233
  if (!entry) return null;
234
234
  const now = new Date().toISOString();
235
235
  entry.autoLaunch = { ...autoLaunch, updatedAt: now };
236
- BeadsDB.getInstance().updateQueueEntry(entry);
236
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
237
237
  return entry;
238
238
  });
239
239
  }
@@ -248,13 +248,13 @@ export function cancelTask(
248
248
  ): MeshWorkQueueEntry | null {
249
249
  requireMeshHostQueueOwner(opts);
250
250
  return withQueueLock(meshId, () => {
251
- const entry = BeadsDB.getInstance().findQueueEntryById(meshId, taskId);
251
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
252
252
  if (!entry) return null;
253
253
  const now = new Date().toISOString();
254
254
  entry.status = 'cancelled';
255
255
  entry.cancelledAt = now;
256
256
  if (opts?.reason) entry.cancelReason = opts.reason;
257
- BeadsDB.getInstance().updateQueueEntry(entry);
257
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
258
258
  return entry;
259
259
  });
260
260
  }
@@ -276,7 +276,7 @@ export function requeueTask(
276
276
  ): MeshWorkQueueEntry | null {
277
277
  requireMeshHostQueueOwner(opts);
278
278
  return withQueueLock(meshId, () => {
279
- const entry = BeadsDB.getInstance().findQueueEntryById(meshId, taskId);
279
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
280
280
  if (!entry) return null;
281
281
  entry.status = 'pending';
282
282
  delete entry.assignedNodeId;
@@ -290,7 +290,7 @@ export function requeueTask(
290
290
  entry.requeuedAt = new Date().toISOString();
291
291
  entry.requeueCount = (entry.requeueCount || 0) + 1;
292
292
  if (opts?.reason) entry.requeueReason = opts.reason;
293
- BeadsDB.getInstance().updateQueueEntry(entry);
293
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
294
294
  return entry;
295
295
  });
296
296
  }
@@ -306,10 +306,10 @@ export function updateSessionTaskStatus(
306
306
  ): MeshWorkQueueEntry | null {
307
307
  return withQueueLock(meshId, () => {
308
308
  const occurredAtIso = opts?.occurredAt ? new Date(opts.occurredAt).toISOString() : undefined;
309
- const entry = BeadsDB.getInstance().findAssignedBySession(meshId, sessionId, occurredAtIso);
309
+ const entry = MeshRuntimeStore.getInstance().findAssignedBySession(meshId, sessionId, occurredAtIso);
310
310
  if (!entry) return null;
311
311
  entry.status = status;
312
- BeadsDB.getInstance().updateQueueEntry(entry);
312
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
313
313
  return entry;
314
314
  });
315
315
  }
@@ -339,7 +339,7 @@ export interface MeshWorkQueueStats {
339
339
  * Return aggregate queue statistics for the given mesh.
340
340
  */
341
341
  export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
342
- const rows = BeadsDB.getInstance().getQueueStatsByStatus(meshId);
342
+ const rows = MeshRuntimeStore.getInstance().getQueueStatsByStatus(meshId);
343
343
  const counts: Record<string, number> = {};
344
344
  for (const r of rows) counts[r.status] = r.count;
345
345
  const pending = counts['pending'] ?? 0;
@@ -358,33 +358,33 @@ export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
358
358
  cancelled,
359
359
  activeCounts: { pending, assigned },
360
360
  historicalCounts: { completed, failed, cancelled },
361
- activeAssignments: BeadsDB.getInstance().getActiveAssignmentDetails(meshId),
361
+ activeAssignments: MeshRuntimeStore.getInstance().getActiveAssignmentDetails(meshId),
362
362
  };
363
363
  }
364
364
 
365
365
  export function __replaceMeshQueueForTests(meshId: string, queue: MeshWorkQueueEntry[]): void {
366
- BeadsDB.getInstance().transaction(() => {
367
- BeadsDB.getInstance().replaceQueue(meshId, queue);
366
+ MeshRuntimeStore.getInstance().transaction(() => {
367
+ MeshRuntimeStore.getInstance().replaceQueue(meshId, queue);
368
368
  });
369
369
  }
370
370
 
371
371
  export function __clearMeshQueueForTests(meshId: string): void {
372
- BeadsDB.getInstance().deleteQueue(meshId);
372
+ MeshRuntimeStore.getInstance().deleteQueue(meshId);
373
373
  }
374
374
 
375
375
  export function __clearDirectDispatchesForTests(meshId: string): void {
376
- BeadsDB.getInstance().deleteDirectDispatches(meshId);
376
+ MeshRuntimeStore.getInstance().deleteDirectDispatches(meshId);
377
377
  }
378
378
 
379
- export function __resetBeadsDBForTests(): void {
380
- BeadsDB.resetForTests();
379
+ export function __resetMeshRuntimeStoreForTests(): void {
380
+ MeshRuntimeStore.resetForTests();
381
381
  }
382
382
 
383
383
  // ── Direct Dispatch Tracking ─────────────────────────────────────────────────
384
384
  // Persists direct (non-queue) task dispatches so buildMeshActiveWork can read
385
- // active work from BeadsDB instead of scanning ledger JSONL entries.
385
+ // active work from MeshRuntimeStore instead of scanning ledger JSONL entries.
386
386
 
387
- export type DirectDispatchRecord = ReturnType<BeadsDB['getActiveDirectDispatches']>[number];
387
+ export type DirectDispatchRecord = ReturnType<MeshRuntimeStore['getActiveDirectDispatches']>[number];
388
388
 
389
389
  export function insertDirectDispatch(
390
390
  meshId: string,
@@ -401,7 +401,7 @@ export function insertDirectDispatch(
401
401
  },
402
402
  ): void {
403
403
  try {
404
- BeadsDB.getInstance().insertDirectDispatch({ ...data, meshId });
404
+ MeshRuntimeStore.getInstance().insertDirectDispatch({ ...data, meshId });
405
405
  } catch (e: any) {
406
406
  process.stderr.write(`[adhdev-mesh] insertDirectDispatch failed for task ${data.taskId}: ${e?.message || e}\n`);
407
407
  }
@@ -409,7 +409,7 @@ export function insertDirectDispatch(
409
409
 
410
410
  export function getActiveDirectDispatches(meshId: string): DirectDispatchRecord[] {
411
411
  try {
412
- return BeadsDB.getInstance().getActiveDirectDispatches(meshId);
412
+ return MeshRuntimeStore.getInstance().getActiveDirectDispatches(meshId);
413
413
  } catch {
414
414
  return [];
415
415
  }
@@ -421,18 +421,18 @@ export function updateDirectDispatchStatus(
421
421
  status: 'acked' | 'completed' | 'failed' | 'stale',
422
422
  ): void {
423
423
  try {
424
- BeadsDB.getInstance().updateDirectDispatchStatus(meshId, sessionId, status);
424
+ MeshRuntimeStore.getInstance().updateDirectDispatchStatus(meshId, sessionId, status);
425
425
  } catch { /* best-effort */ }
426
426
  }
427
427
 
428
428
  export function cleanupTerminalDirectDispatches(olderThanMs = 7 * 24 * 60 * 60_000): void {
429
429
  try {
430
- BeadsDB.getInstance().cleanupTerminalDirectDispatches(olderThanMs);
430
+ MeshRuntimeStore.getInstance().cleanupTerminalDirectDispatches(olderThanMs);
431
431
  } catch { /* best-effort */ }
432
432
  }
433
433
 
434
434
  export function markStaleDirectDispatches(meshId: string, olderThanMs = 60 * 60_000): void {
435
435
  try {
436
- BeadsDB.getInstance().markStaleDirectDispatches(meshId, olderThanMs);
436
+ MeshRuntimeStore.getInstance().markStaleDirectDispatches(meshId, olderThanMs);
437
437
  } catch { /* best-effort */ }
438
438
  }
@@ -724,16 +724,24 @@ export class CliProviderInstance implements ProviderInstance {
724
724
  ? visibleStatus
725
725
  : (suppressStaleParsedBusyStatus ? visibleStatus : (parsedChatStatus || visibleStatus)));
726
726
 
727
+ // If an AskUserQuestion prompt is awaiting user input, overlay status as
728
+ // waiting_choice. This is distinct from waiting_approval (tool-use consent)
729
+ // — the engine's isWaitingForResponse state is unchanged, so completion
730
+ // tracking continues normally once the user responds.
731
+ const hasInteractivePrompt = !!this.activeInteractivePrompt;
732
+ const finalStatus = hasInteractivePrompt ? 'waiting_choice' : visibleStatus;
733
+ const finalChatStatus = hasInteractivePrompt ? 'waiting_choice' : activeChatStatus;
734
+
727
735
  return {
728
736
  type: this.type,
729
737
  name: this.provider.name,
730
738
  category: 'cli',
731
- status: visibleStatus,
739
+ status: finalStatus,
732
740
  mode: this.presentationMode,
733
741
  activeChat: {
734
742
  id: activeChatId,
735
743
  title: parsedStatus?.title || dirName,
736
- status: activeChatStatus,
744
+ status: finalChatStatus,
737
745
  messages: statusMessages,
738
746
  activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
739
747
  activeInteractivePrompt: this.activeInteractivePrompt,
@@ -1265,6 +1273,7 @@ export class CliProviderInstance implements ProviderInstance {
1265
1273
  const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1266
1274
  const finalAssistantEvidence = this.completionFinalAssistantEvidence(parsed?.messages);
1267
1275
  const allowMissingAssistantTimeout = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1276
+ LOG.debug('CLI', `[${this.type}] finalAssistantEvidence: present=${finalAssistantEvidence.present} source=${finalAssistantEvidence.source} adapterOwnsMessagesElsewhere=${adapterOwnsMessagesElsewhere} parsedStatus=${parsedStatus}`);
1268
1277
  if (!finalAssistantEvidence.present) {
1269
1278
  if (adapterOwnsMessagesElsewhere) {
1270
1279
  if (finalAssistantEvidence.source === 'external-native') {
@@ -1273,19 +1282,20 @@ export class CliProviderInstance implements ProviderInstance {
1273
1282
  LOG.info('CLI', `[${this.type}] external transcript probe: msgCount=${probe.msgCount} lastRole=${probe.lastRole || 'none'} lastKind=${probe.lastKind || 'none'} contentLen=${probe.contentLen} sourceMtime=${probe.sourceMtimeMs ?? 'unknown'} mtimeAge=${probe.mtimeAgeMs ?? 'unknown'}ms`);
1274
1283
  pending.loggedTranscriptProbe = true;
1275
1284
  }
1285
+ LOG.debug('CLI', `[${this.type}] external-native probe result: lastRole=${probe?.lastRole} contentLen=${probe?.contentLen}`);
1286
+ if (probe?.lastRole === 'assistant' && (probe.contentLen ?? 0) > 0) {
1287
+ return null;
1288
+ }
1276
1289
  if (this.type === 'antigravity-cli') {
1277
1290
  return null;
1278
1291
  }
1279
1292
  return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1280
1293
  }
1281
- // SpecCliAdapter never populates parsed.messages — chat history flows
1282
- // through the daemon's native-history pipeline, not the status hook.
1283
- // If that pipeline is unavailable, keep the old skip behavior for
1284
- // providers that have not opted into strict final-assistant evidence.
1285
1294
  if ((this.provider as any).requiresFinalAssistantBeforeIdle === true) {
1286
1295
  return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1287
1296
  }
1288
1297
  } else {
1298
+ LOG.debug('CLI', `[${this.type}] missing_final_assistant (not ownsExternal) requiresFinalAssistant=${!!(this.provider as any).requiresFinalAssistantBeforeIdle}`);
1289
1299
  return {
1290
1300
  reason: 'missing_final_assistant',
1291
1301
  terminal: (this.provider as any).requiresFinalAssistantBeforeIdle === true,
@@ -1331,6 +1341,7 @@ export class CliProviderInstance implements ProviderInstance {
1331
1341
  const latestVisibleStatus = externalNativeFinal && isCliGeneratingLikeStatus(latestStatus.status)
1332
1342
  ? 'idle'
1333
1343
  : (latestAutoApproveActive ? 'generating' : latestStatus.status);
1344
+ LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} externalNativeFinal=${!!externalNativeFinal} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
1334
1345
  if (latestVisibleStatus !== 'idle') {
1335
1346
  LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
1336
1347
  this.completedDebouncePending = null;
@@ -1342,6 +1353,7 @@ export class CliProviderInstance implements ProviderInstance {
1342
1353
  if (block) {
1343
1354
  const blockReason = block.reason;
1344
1355
  const waitedMs = Date.now() - pending.firstObservedAt;
1356
+ LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
1345
1357
  if ((block.terminal && !block.allowTimeout) || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
1346
1358
  if (pending.loggedBlockReason !== blockReason) {
1347
1359
  LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
@@ -1485,10 +1497,18 @@ export class CliProviderInstance implements ProviderInstance {
1485
1497
  if (newStatus !== this.lastStatus) {
1486
1498
  LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
1487
1499
  if (this.lastStatus === 'idle' && newStatus === 'generating') {
1500
+ // If a completion event is already pending and the turn has ended
1501
+ // (generatingStartedAt===0), the PTY is painting its prompt area
1502
+ // after completing. Ignore this blip — do not cancel the pending
1503
+ // completion and do not advance lastStatus to generating.
1504
+ if (this.completedDebouncePending && this.generatingStartedAt === 0) {
1505
+ LOG.debug('CLI', `[${this.type}] ignoring post-completion PTY generating blip (generatingStartedAt=0)`);
1506
+ return;
1507
+ }
1488
1508
  this.suppressIdleHistoryReplay = false;
1489
1509
  // Cancel any pending completed event (multi-step: idle→generating resume)
1490
1510
  if (this.completedDebouncePending) {
1491
- LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating)`);
1511
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating) generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse}`);
1492
1512
  if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
1493
1513
  this.completedDebouncePending = null;
1494
1514
  }
@@ -1586,7 +1606,10 @@ export class CliProviderInstance implements ProviderInstance {
1586
1606
  firstObservedAt: now,
1587
1607
  previousStatus: this.lastStatus,
1588
1608
  };
1589
- this.scheduleCompletedDebounceFlush(3000);
1609
+ const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
1610
+ const flushDelay = ownsExternalHistory ? 0 : 3000;
1611
+ LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
1612
+ this.scheduleCompletedDebounceFlush(flushDelay);
1590
1613
  }
1591
1614
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {
1592
1615
  this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
@@ -16,7 +16,7 @@ import type { InteractivePrompt } from './types/interactive-prompt.js';
16
16
 
17
17
  // ─── ProviderState — Discriminated union by category ─────────────
18
18
 
19
- export type ProviderStatus = 'idle' | 'generating' | 'waiting_approval' | 'error' | 'stopped' | 'starting';
19
+ export type ProviderStatus = 'idle' | 'generating' | 'waiting_approval' | 'waiting_choice' | 'error' | 'stopped' | 'starting';
20
20
 
21
21
  export interface ProviderRuntimeWriteOwner {
22
22
  clientId: string;
@@ -41,6 +41,7 @@ import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
41
41
  import { evaluate, type SpecEvaluation, type TraceEntry } from './evaluator.js';
42
42
  import { loadSpec } from './loader.js';
43
43
  import type { CliSpec, Control, DelegateTrigger } from './types.js';
44
+ import { LOG } from '../../logging/logger.js';
44
45
 
45
46
  export type DashboardEvent =
46
47
  | { kind: 'pty_data'; chunk: string }
@@ -148,10 +149,32 @@ export function matchesCompletionIdleRule(spec: CliSpec, ev: SpecEvaluation, scr
148
149
  }
149
150
  }
150
151
 
151
- export function matchesCompletionIdleTargetState(spec: CliSpec, ev: SpecEvaluation, screen: string): boolean {
152
+ export function matchesCompletionIdleTargetState(
153
+ spec: CliSpec,
154
+ ev: SpecEvaluation,
155
+ screen: string,
156
+ cursor?: { row: number; col: number },
157
+ ): boolean {
152
158
  const target = spec.states.find(state => state.id === spec.default_state)
153
159
  ?? spec.states.find(state => state.id === 'idle');
154
- if (!target?.when?.regex) return false;
160
+ if (!target?.when) return false;
161
+
162
+ // Cursor-only idle: if the target state has cursor guards but no regex,
163
+ // treat a cursor match alone as sufficient.
164
+ const hasCursorGuard = target.when.cursor_row_min !== undefined
165
+ || target.when.cursor_row_max !== undefined
166
+ || target.when.cursor_col_min !== undefined
167
+ || target.when.cursor_col_max !== undefined;
168
+ if (hasCursorGuard && cursor !== undefined) {
169
+ const { cursor_row_min, cursor_row_max, cursor_col_min, cursor_col_max } = target.when;
170
+ const cursorOk = (cursor_row_min === undefined || cursor.row >= cursor_row_min)
171
+ && (cursor_row_max === undefined || cursor.row <= cursor_row_max)
172
+ && (cursor_col_min === undefined || cursor.col >= cursor_col_min)
173
+ && (cursor_col_max === undefined || cursor.col <= cursor_col_max);
174
+ if (cursorOk) return true;
175
+ }
176
+
177
+ if (!target.when.regex) return false;
155
178
  const haystack = target.when.section
156
179
  ? ev.sections.find(section => section.id === target.when.section)?.text ?? ''
157
180
  : screen;
@@ -314,6 +337,7 @@ export class SpecDriver {
314
337
  if (this.busyExpiryTimer) clearTimeout(this.busyExpiryTimer);
315
338
  this.busyExpiryTimer = setTimeout(() => {
316
339
  this.busyExpiryTimer = null;
340
+ LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] busyExpiry fired holdMs=${holdMs}`);
317
341
  this.reevaluate();
318
342
  }, Math.max(holdMs + 50, 100));
319
343
  }
@@ -352,11 +376,18 @@ export class SpecDriver {
352
376
  if (completionKey !== this.completionIdleKey) {
353
377
  this.completionIdleKey = completionKey;
354
378
  this.completionIdleFirstSeenAt = now;
379
+ LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] completion_idle_after matched: key="${completionKey}"`);
355
380
  }
356
381
  const holdMs = Math.max(0, completionIdleRule.hold_ms || 0);
382
+ const forceAfterMs = typeof completionIdleRule.force_after_ms === 'number'
383
+ ? completionIdleRule.force_after_ms
384
+ : null;
357
385
  const ageMs = now - this.completionIdleFirstSeenAt;
358
386
  if (ageMs >= holdMs) {
359
- if (matchesCompletionIdleTargetState(this.spec, ev, screen)) {
387
+ const targetMatches = matchesCompletionIdleTargetState(this.spec, ev, screen, cursor);
388
+ const forced = !targetMatches && forceAfterMs !== null && ageMs >= holdMs + forceAfterMs;
389
+ LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] completion_idle_after hold expired ageMs=${ageMs} targetState=${targetMatches} forced=${forced} screenTail="${screen.split(/\r?\n/).slice(-3).join('\\n').slice(-200)}"`);
390
+ if (targetMatches || forced) {
360
391
  const idle = this.spec.states.find(state => state.id === this.spec.default_state)
361
392
  ?? this.spec.states.find(state => state.id === 'idle');
362
393
  evState = idle
@@ -84,10 +84,39 @@ function resolveSections(spec: CliSpec, lines: string[]): ResolvedSection[] {
84
84
  for (const sec of spec.layout.sections) {
85
85
  let from = 0;
86
86
  let to = total;
87
- if (sec.from_top !== undefined) {
87
+ if (sec.anchor_regex !== undefined) {
88
+ try {
89
+ const re = new RegExp(sec.anchor_regex, sec.anchor_flags ?? '');
90
+ const prevRe = sec.anchor_context?.prev !== undefined
91
+ ? new RegExp(sec.anchor_context.prev, sec.anchor_context.prev_flags ?? '') : null;
92
+ const nextRe = sec.anchor_context?.next !== undefined
93
+ ? new RegExp(sec.anchor_context.next, sec.anchor_context.next_flags ?? '') : null;
94
+ const matches = (i: number) => re.test(lines[i])
95
+ && (prevRe === null || (i > 0 && prevRe.test(lines[i - 1])))
96
+ && (nextRe === null || (i < total - 1 && nextRe.test(lines[i + 1])));
97
+ let idx = -1;
98
+ if (sec.anchor_last) {
99
+ for (let i = total - 1; i >= 0; i--) { if (matches(i)) { idx = i; break; } }
100
+ } else {
101
+ for (let i = 0; i < total; i++) { if (matches(i)) { idx = i; break; } }
102
+ }
103
+ if (idx !== -1) {
104
+ from = idx;
105
+ to = total;
106
+ if (sec.until_regex !== undefined) {
107
+ try {
108
+ const ure = new RegExp(sec.until_regex, sec.until_regex_flags ?? '');
109
+ const end = lines.findIndex((l, i) => i > idx && ure.test(l));
110
+ if (end !== -1) to = end;
111
+ } catch { /* bad until_regex — extend to end */ }
112
+ } else if (sec.lines !== undefined) {
113
+ to = Math.min(total, from + sec.lines);
114
+ }
115
+ }
116
+ } catch { /* bad anchor_regex — fall through to defaults */ }
117
+ } else if (sec.from_top !== undefined) {
88
118
  from = resolveSize(sec.from_top, total);
89
- }
90
- if (sec.from_bottom !== undefined) {
119
+ } else if (sec.from_bottom !== undefined) {
91
120
  const sz = resolveSize(sec.from_bottom, total);
92
121
  from = total - sz;
93
122
  to = total;
@@ -125,6 +125,9 @@ export const SCHEMA = {
125
125
  "type": "string",
126
126
  "minLength": 1
127
127
  },
128
+ "requiresFinalAssistantBeforeIdle": {
129
+ "type": "boolean"
130
+ },
128
131
  "debounce": {
129
132
  "type": "object",
130
133
  "additionalProperties": false,
@@ -139,7 +142,8 @@ export const SCHEMA = {
139
142
  "section": { "type": "string", "minLength": 1 },
140
143
  "regex": { "type": "string", "minLength": 1 },
141
144
  "flags": { "type": "string" },
142
- "hold_ms": { "type": "integer", "minimum": 0 }
145
+ "hold_ms": { "type": "integer", "minimum": 0 },
146
+ "force_after_ms": { "type": "integer", "minimum": 0 }
143
147
  }
144
148
  }
145
149
  }
@@ -187,7 +191,23 @@ export const SCHEMA = {
187
191
  "type": "string"
188
192
  }
189
193
  }
190
- }
194
+ },
195
+ "anchor_regex": { "type": "string", "minLength": 1 },
196
+ "anchor_flags": { "type": "string" },
197
+ "anchor_last": { "type": "boolean" },
198
+ "anchor_context": {
199
+ "type": "object",
200
+ "additionalProperties": false,
201
+ "properties": {
202
+ "prev": { "type": "string" },
203
+ "prev_flags": { "type": "string" },
204
+ "next": { "type": "string" },
205
+ "next_flags": { "type": "string" }
206
+ }
207
+ },
208
+ "lines": { "type": "integer", "minimum": 1 },
209
+ "until_regex": { "type": "string", "minLength": 1 },
210
+ "until_regex_flags": { "type": "string" }
191
211
  }
192
212
  },
193
213
  "sectionRegex": {
@@ -61,6 +61,7 @@
61
61
  },
62
62
  "native_history": { "$ref": "#/definitions/nativeHistory" },
63
63
  "cli_version_range": { "type": "string", "minLength": 1 },
64
+ "requiresFinalAssistantBeforeIdle": { "type": "boolean" },
64
65
  "debounce": {
65
66
  "type": "object",
66
67
  "additionalProperties": false,
@@ -13,6 +13,33 @@ export interface Section {
13
13
  from_top?: Size;
14
14
  from_bottom?: Size;
15
15
  until?: { section: string };
16
+ /** Scan the screen for the first (or last, if anchor_last=true) line
17
+ * matching this regex and use that line as the section start.
18
+ * Combine with `lines`, `until_regex`, or leave alone (defaults to
19
+ * end-of-screen) to control how far it extends. */
20
+ anchor_regex?: string;
21
+ anchor_flags?: string;
22
+ /** If true, use the LAST matching line instead of the first. Useful
23
+ * when the anchor pattern appears multiple times (e.g. separator
24
+ * lines) and you want the one closest to the bottom. */
25
+ anchor_last?: boolean;
26
+ /** Optional neighbouring-line guards that must also match for the
27
+ * anchor to be accepted. Reduces false positives when the anchor
28
+ * pattern could appear elsewhere on screen (e.g. `>` in body text).
29
+ * `prev` checks the line immediately above; `next` the line below. */
30
+ anchor_context?: {
31
+ prev?: string;
32
+ prev_flags?: string;
33
+ next?: string;
34
+ next_flags?: string;
35
+ };
36
+ /** When used with anchor_regex: take at most this many lines from the
37
+ * anchor point. */
38
+ lines?: number;
39
+ /** When used with anchor_regex: extend until the first line matching
40
+ * this regex (exclusive). Takes precedence over `lines`. */
41
+ until_regex?: string;
42
+ until_regex_flags?: string;
16
43
  }
17
44
 
18
45
  export interface SectionRegex {
@@ -224,6 +251,13 @@ export interface CliSpec {
224
251
  notifications?: NotificationRule[];
225
252
  delegate?: DelegateTrigger[];
226
253
  native_history?: NativeHistoryConfig;
254
+ /**
255
+ * When true, the daemon's completed-finalization gate will not emit
256
+ * `generating_completed` until native history confirms the last message
257
+ * role is `assistant`. Prevents PTY quiet-period from triggering idle
258
+ * before the CLI has finished writing its response to disk.
259
+ */
260
+ requiresFinalAssistantBeforeIdle?: boolean;
227
261
  /**
228
262
  * Per-spec debounce knobs. Defaults are conservative (busy_hold_ms
229
263
  * 6000) and live in SpecDriver. Spec authors override here when a
@@ -251,6 +285,11 @@ export interface CliSpec {
251
285
  regex: string;
252
286
  flags?: string;
253
287
  hold_ms: number;
288
+ /** If the target-state check still fails this many ms after hold_ms
289
+ * expired, force an idle transition anyway. Guards against TUIs that
290
+ * show transient UI (pickers, option lists) after the completion
291
+ * marker appears, keeping the target-state regex from matching. */
292
+ force_after_ms?: number;
254
293
  };
255
294
  };
256
295
  }