@adhdev/daemon-core 0.9.82-rc.362 → 0.9.82-rc.364

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,6 +52,7 @@ export declare function resolveMeshSurfacedSessionPreview(metadataEvent: Record<
52
52
  role: 'assistant';
53
53
  receivedAt: number;
54
54
  } | undefined;
55
+ export declare function isWeakCompletionMetadata(metadataEvent: Record<string, unknown>): boolean;
55
56
  export declare function buildMeshSystemMessage(args: {
56
57
  event: string;
57
58
  nodeLabel: string;
@@ -183,6 +183,8 @@ export declare class CliProviderInstance implements ProviderInstance {
183
183
  private hasAdapterPendingResponse;
184
184
  private shouldSuppressStaleParsedBusyStatus;
185
185
  private getCompletedFinalizationBlock;
186
+ private hasApprovalResolutionEvidence;
187
+ private approvalResolutionFinalizationBlock;
186
188
  private scheduleCompletedDebounceFlush;
187
189
  private isMeshWorkerSession;
188
190
  private meshTraceCtx;
@@ -198,6 +198,11 @@ export declare class FsmDriver implements ISpecDriver {
198
198
  private lastWin32WriteAt;
199
199
  /** Pending paced chunk-write timer for a large win32 body (see writeWin32Body). */
200
200
  private win32WriteTimer;
201
+ /** Timer driving the win32 verification-based modal-confirm CR resend loop (see
202
+ * scheduleWin32ModalConfirm). A lone CR that confirms an approval/picker choice
203
+ * is absorbed by ConPTY the same way a send_message submit CR is, so the confirm
204
+ * must be resent until the modal actually resolves (status leaves 'approval'). */
205
+ private win32ModalConfirmTimer;
201
206
  private currentEval;
202
207
  private stateHistory;
203
208
  private prevStateAt;
@@ -358,6 +363,28 @@ export declare class FsmDriver implements ISpecDriver {
358
363
  private scheduleWin32Submit;
359
364
  private handleClickControl;
360
365
  private handleClickModalButton;
366
+ /**
367
+ * Submit a modal-confirm key sequence (the choice key + its trailing CR).
368
+ *
369
+ * On win32 the trailing CR is the SAME lone-CR-swallow case as a send_message
370
+ * submit: ConPTY can absorb a single CR as a literal newline instead of a
371
+ * confirm, so the approval/picker modal never resolves and the FSM flaps
372
+ * approval↔busy while auto-approve keeps firing into the void (APPROVESTUCK).
373
+ * So we split any non-CR prefix (e.g. the "1" of "1\r") off, write it once, and
374
+ * resend the CR on a fixed cadence until the modal actually resolves (status
375
+ * leaves 'approval'). Non-win32 keeps the single direct write — its CR submits
376
+ * on the first try.
377
+ */
378
+ private submitModalConfirm;
379
+ /**
380
+ * win32 modal-confirm CR resend loop. Mirrors scheduleWin32Submit's phase-2
381
+ * verified resend, but gated on still being IN a modal (status 'approval')
382
+ * rather than still idle: the first CR fires immediately, then resends every
383
+ * WIN32_SUBMIT_RESEND_GAP_MS while the FSM is still showing the modal, up to
384
+ * WIN32_SUBMIT_MAX_RESENDS. The instant the modal resolves (status flips to
385
+ * generating/idle) we stop, so no stray CR leaks into the next turn's composer.
386
+ */
387
+ private scheduleWin32ModalConfirm;
361
388
  private handleAttachImage;
362
389
  private tryAdvancePicker;
363
390
  private handleExit;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.362",
3
+ "version": "0.9.82-rc.364",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.362",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.364",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -126,7 +126,13 @@ export class CliStateEngine {
126
126
  * seq) is always a real, distinct approval and must be written.
127
127
  */
128
128
  approvalEntrySeq = 0;
129
- private lastResolvedEntrySeq = -1;
129
+ // Records which approval entry the last resolveModal() handled. Set unconditionally on
130
+ // every resolveModal (auto-approve fire, dashboard/mesh_approve, dev-cli-debug), so
131
+ // `lastResolvedEntrySeq >= approvalEntrySeq` is positive, ADHDev-side proof that the
132
+ // current/latest approval entry was actually resolved. Exposed via getStatus so the
133
+ // completion finalization gate (FALSEIDLE-a) can require resolution evidence before
134
+ // confirming a waiting_approval→idle transition. Public (read-only by convention).
135
+ lastResolvedEntrySeq = -1;
130
136
  /**
131
137
  * When the engine previously held a modal but the latest parse failed
132
138
  * to extract one, we record the timestamp here and only drop the modal
@@ -967,6 +967,7 @@ export class ProviderCliAdapter implements CliAdapter {
967
967
  workingDir: this.workingDir,
968
968
  activeModal: effectiveModal,
969
969
  approvalEntrySeq: this.engine.approvalEntrySeq,
970
+ lastResolvedEntrySeq: this.engine.lastResolvedEntrySeq,
970
971
  pendingOutboundCount: this.pendingOutboundQueue.length,
971
972
  pendingOutboundMessages: this.pendingOutboundQueue.map((message) => ({
972
973
  id: message.id,
@@ -38,6 +38,16 @@ export interface CliSessionStatus {
38
38
  * seq is the only reliable discriminator.
39
39
  */
40
40
  approvalEntrySeq?: number;
41
+ /**
42
+ * The approval entry seq that the engine's last resolveModal() handled. When
43
+ * `lastResolvedEntrySeq >= approvalEntrySeq` (and approvalEntrySeq > 0) it is positive
44
+ * evidence that the current/latest approval entry was resolved through ADHDev (auto-approve
45
+ * fire, dashboard / mesh_approve, or dev-cli-debug — all route through resolveModal). The
46
+ * completion finalization gate uses this to avoid confirming a waiting_approval→idle
47
+ * transition for which no resolution actually occurred (a false idle: the spec's text-based
48
+ * approval→idle rule tripped while the modal is still on screen).
49
+ */
50
+ lastResolvedEntrySeq?: number;
41
51
  activeInteractivePrompt?: InteractivePrompt | null;
42
52
  pendingOutboundCount?: number;
43
53
  pendingOutboundMessages?: Array<{
@@ -457,6 +457,17 @@ function deliverTaskToSession(dispatchThunk: () => Promise<unknown>, ctx: Delive
457
457
  });
458
458
  }
459
459
 
460
+ // WTCLAIM: normalize a workspace path for base-vs-worktree comparison. Mirrors
461
+ // cli-manager.ts normalizeDirForCompare (fix-B) — folds separator style, trailing
462
+ // slashes, and case (Windows paths are case-insensitive) so a base node and a worktree
463
+ // clone, whose only structural difference is their distinct workspace roots, are still
464
+ // told apart. Kept local (the cli-manager copy is module-private) so the comparison rule
465
+ // stays identical to the one fix-B already uses on the worker side.
466
+ function normalizeMeshWorkspaceForCompare(dir?: string): string {
467
+ if (typeof dir !== 'string') return '';
468
+ return dir.trim().replace(/[\\/]+/g, '/').replace(/\/+$/, '').toLowerCase();
469
+ }
470
+
460
471
  export function tryAssignQueueTask(
461
472
  components: DaemonComponents,
462
473
  meshId: string,
@@ -466,6 +477,32 @@ export function tryAssignQueueTask(
466
477
  ): boolean {
467
478
  const mesh = getMeshWithCache(components, meshId);
468
479
  const node = mesh?.nodes.find((n: any) => readMeshNodeId(n) === nodeId);
480
+
481
+ // WTCLAIM (fix-B extended to the enqueue→claim path): a base-targeted task must never be
482
+ // claimed by — and dispatched into — a co-located worktree-clone session, nor vice versa.
483
+ // The drain candidate's nodeId is derived from settings.meshNodeId || settings.nodeId
484
+ // (triggerMeshQueue), so a worktree session whose meshNodeId is empty/stale falls back to
485
+ // settings.nodeId = the BASE node id and impersonates the base node here. fix-B's worker-side
486
+ // workspace scope only ran for sessionless dispatch (meshScopeNodeId && !targetSessionId); the
487
+ // claim path ALWAYS carries a targetSessionId, so it never engaged. Apply the same scope here:
488
+ // for a LOCAL claiming session (adapter resolvable on this daemon), require its actual
489
+ // workingDir to match the target node's declared workspace. On a confirmed mismatch, refuse the
490
+ // claim so the task returns to pending for the correctly-scoped session/node to pull. Scoped to
491
+ // local sessions where the workspace is verifiable — a remote session lives on another daemon
492
+ // whose paths we cannot compare here (and remote candidates are already nodeId-matched from
493
+ // getRemoteIdleSessions). Conservative by design: when either workspace is unknown we do NOT
494
+ // skip, so a node with no declared workspace keeps its prior behavior and no legitimate claim
495
+ // is starved.
496
+ const localClaimAdapter = components.cliManager?.adapters?.get(sessionId) as { workingDir?: string } | undefined;
497
+ if (localClaimAdapter) {
498
+ const sessionWorkspace = normalizeMeshWorkspaceForCompare(localClaimAdapter.workingDir);
499
+ const nodeWorkspace = normalizeMeshWorkspaceForCompare(readNonEmptyString(node?.workspace));
500
+ if (sessionWorkspace && nodeWorkspace && sessionWorkspace !== nodeWorkspace) {
501
+ LOG.info('MeshQueue', `WTCLAIM: refusing claim for node ${nodeId} (${sessionId}) — session workspace "${sessionWorkspace}" ≠ node workspace "${nodeWorkspace}" (cross-workspace dispatch blocked)`);
502
+ return false;
503
+ }
504
+ }
505
+
469
506
  const capabilityTags = buildMeshNodeCapabilityTags(node, providerType);
470
507
  // Per-(node, provider) maxParallel cap (RepoMeshNodePolicy.providerRoles) layers
471
508
  // on top of the global/taskMode caps — stricter wins. Resolved here where the
@@ -195,6 +195,23 @@ function readEventTimestampValue(value: unknown): number {
195
195
  return 0;
196
196
  }
197
197
 
198
+ // A completion whose evidence is WEAK: the worker FSM reached idle but the turn's
199
+ // final assistant message was never confirmed (missing_final_assistant / a
200
+ // finalAssistantPresent=false diagnostic), or the event self-declares insufficient/weak
201
+ // evidence. cli-provider-instance emits this on the CANON-C decoupled-immediate path and
202
+ // on a forced finalization timeout, and the new structural approval-resolution gate
203
+ // (FALSEIDLE-a) can also let an unconfirmed approval→idle through. Such a completion is NOT
204
+ // trustworthy terminal evidence — surface a verify hint so the coordinator confirms via
205
+ // mesh_read_chat / git status before declaring the task done. Mirrors
206
+ // isWeakCompletionMetadata in mesh-events-pending.ts.
207
+ export function isWeakCompletionMetadata(metadataEvent: Record<string, unknown>): boolean {
208
+ const evidenceLevel = readNonEmptyString(metadataEvent.evidenceLevel);
209
+ if (evidenceLevel === 'insufficient' || evidenceLevel === 'weak') return true;
210
+ if (metadataEvent.reviewRecommended === true) return true;
211
+ const diag = readRecord(metadataEvent.completionDiagnostic);
212
+ return diag?.finalAssistantPresent === false || diag?.blockReason === 'missing_final_assistant';
213
+ }
214
+
198
215
  function formatCompletionMetadata(event: Record<string, unknown>): string {
199
216
  const completionDiagnostic = event.completionDiagnostic && typeof event.completionDiagnostic === 'object'
200
217
  ? event.completionDiagnostic as Record<string, unknown>
@@ -229,6 +246,13 @@ export function buildMeshSystemMessage(args: {
229
246
  return `[System] ${args.nodeLabel} already has completion evidence${metadata}. The no-progress monitor reconciled the terminal handoff and marked the session complete; wait for the queued completion event/status refresh before doing any manual transcript check.`;
230
247
  }
231
248
  const reviewRecommended = args.metadataEvent.reviewRecommended === true;
249
+ // FALSEIDLE-b: a weak completion (missing final assistant / finalAssistantPresent=false /
250
+ // insufficient evidence) is not trustworthy terminal evidence even when reviewRecommended
251
+ // is not set. Append an explicit verify hint so the coordinator does not declare the task
252
+ // done off a false idle. Only the weak case is affected — a genuine completion (final
253
+ // assistant confirmed, no weak diagnostic) carries no extra note and is unchanged.
254
+ const weakCompletion = isWeakCompletionMetadata(args.metadataEvent);
255
+ const verifyTextNote = ' Completion evidence is weak — verify via mesh_read_chat or git status before declaring the task done; the worker may still be parked on an approval/modal.';
232
256
  // Auto-surface the worker's final summary directly into the coordinator chat so it
233
257
  // does not have to call mesh_read_chat just to see the result. The summary IS the
234
258
  // worker's final assistant message; embedding it here replaces the previous
@@ -244,12 +268,14 @@ export function buildMeshSystemMessage(args: {
244
268
  : completionSummary;
245
269
  const verifyNote = reviewRecommended
246
270
  ? ' Completion evidence is insufficient — verify via git status or provider_session_id before assuming the task is done.'
247
- : '';
271
+ : (weakCompletion ? verifyTextNote : '');
248
272
  return `[System] ${args.nodeLabel} has completed its task and is now idle${metadata}. Its final summary is included below — read it directly and only call mesh_read_chat if you need the full transcript.${verifyNote}\n\n--- ${args.nodeLabel} final summary ---\n${surfaced}`;
249
273
  }
250
274
  const reviewNote = reviewRecommended
251
275
  ? ' Completion evidence is insufficient — verify via git status or provider_session_id before assuming the task is done. Use mesh_read_chat once if needed, but do not poll repeatedly.'
252
- : ' Use mesh_read_chat once to review its final progress, but do not poll repeatedly.';
276
+ : (weakCompletion
277
+ ? `${verifyTextNote} Use mesh_read_chat once if needed, but do not poll repeatedly.`
278
+ : ' Use mesh_read_chat once to review its final progress, but do not poll repeatedly.');
253
279
  return `[System] ${args.nodeLabel} has completed its task and is now idle${metadata}. This completion came from the agent status event path;${reviewNote}`;
254
280
  }
255
281
  if (args.event === 'agent:waiting_approval') {
@@ -1401,6 +1401,12 @@ export class CliProviderInstance implements ProviderInstance {
1401
1401
  }
1402
1402
  }
1403
1403
 
1404
+ // (FALSEIDLE-a) Structural approval-resolution gate. Runs BEFORE the brittle
1405
+ // screen-text heuristic below so it also catches modals whose text does not match
1406
+ // looksLikeActiveApprovalPromptText (e.g. claude-cli's cd / "untrusted hooks" prompt).
1407
+ const approvalResolutionBlock = this.approvalResolutionFinalizationBlock(pending);
1408
+ if (approvalResolutionBlock) return approvalResolutionBlock;
1409
+
1404
1410
  // Guard: if the screen still shows an approval/choice prompt as the last visible text,
1405
1411
  // the turn is not complete even if the parsed status says idle and there is an assistant
1406
1412
  // message. This catches the case where waiting_approval→idle transitions occur before
@@ -1420,6 +1426,46 @@ export class CliProviderInstance implements ProviderInstance {
1420
1426
  return null;
1421
1427
  }
1422
1428
 
1429
+ // (FALSEIDLE-a) Positive, structural proof that the latest approval entry was resolved
1430
+ // through ADHDev. resolveModal() — driven by auto-approve, dashboard/mesh_approve, and
1431
+ // dev-cli-debug alike — advances the engine's lastResolvedEntrySeq to the current
1432
+ // approvalEntrySeq. So `lastResolvedEntrySeq >= approvalEntrySeq` (with a real entry,
1433
+ // approvalEntrySeq > 0) means the modal we last saw was actually answered. Absence of this
1434
+ // evidence after a waiting_approval→idle transition means the idle is suspect: the spec's
1435
+ // text-based approval→idle rule false-tripped while the modal is still unresolved.
1436
+ // Fails OPEN (returns true) when the seq fields are unavailable, so the gate can never wedge
1437
+ // a session on a provider/adapter that does not surface the counters.
1438
+ private hasApprovalResolutionEvidence(): boolean {
1439
+ try {
1440
+ const status = this.adapter.getStatus({ allowParse: false }) as any;
1441
+ const entrySeq = typeof status?.approvalEntrySeq === 'number' ? status.approvalEntrySeq : 0;
1442
+ if (entrySeq <= 0) return true;
1443
+ const resolvedSeq = typeof status?.lastResolvedEntrySeq === 'number' ? status.lastResolvedEntrySeq : undefined;
1444
+ if (resolvedSeq === undefined) return true;
1445
+ return resolvedSeq >= entrySeq;
1446
+ } catch {
1447
+ return true;
1448
+ }
1449
+ }
1450
+
1451
+ // (FALSEIDLE-a) Hold a completion that is the anomalous DIRECT waiting_approval→idle
1452
+ // transition with no positive resolution evidence. A genuinely resolved approval routes
1453
+ // through resolveModal → setStatus('generating'), so its completion's previousStatus is
1454
+ // 'generating' (not 'waiting_approval') and this gate never fires for it. Scoped to
1455
+ // delegated mesh/coordinator sessions — whose only modal-resolution path is auto-approve /
1456
+ // mesh_approve (both advance lastResolvedEntrySeq) — so an interactive local session, where
1457
+ // a human may answer the PTY prompt directly and leave no resolveModal record, is untouched.
1458
+ // Non-terminal: the hold is bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS (30s), giving a
1459
+ // settling auto-approve time to fire and advance the seq, and guaranteeing no permanent wedge
1460
+ // if resolution ever happens via a path that does not record evidence.
1461
+ private approvalResolutionFinalizationBlock(pending: CompletedDebouncePending): CompletedFinalizationBlock | null {
1462
+ if (pending.previousStatus !== 'waiting_approval') return null;
1463
+ const meshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1464
+ if (!meshContext) return null;
1465
+ if (this.hasApprovalResolutionEvidence()) return null;
1466
+ return { reason: 'approval_resolution_unconfirmed', terminal: false };
1467
+ }
1468
+
1423
1469
  private scheduleCompletedDebounceFlush(delayMs: number): void {
1424
1470
  if (this.completedDebounceTimer) clearTimeout(this.completedDebounceTimer);
1425
1471
  this.completedDebounceTimer = setTimeout(() => this.flushCompletedDebounceIfFinalized(), delayMs);
@@ -294,6 +294,11 @@ export class FsmDriver implements ISpecDriver {
294
294
  private lastWin32WriteAt = 0;
295
295
  /** Pending paced chunk-write timer for a large win32 body (see writeWin32Body). */
296
296
  private win32WriteTimer: ReturnType<typeof setTimeout> | null = null;
297
+ /** Timer driving the win32 verification-based modal-confirm CR resend loop (see
298
+ * scheduleWin32ModalConfirm). A lone CR that confirms an approval/picker choice
299
+ * is absorbed by ConPTY the same way a send_message submit CR is, so the confirm
300
+ * must be resent until the modal actually resolves (status leaves 'approval'). */
301
+ private win32ModalConfirmTimer: ReturnType<typeof setTimeout> | null = null;
297
302
 
298
303
  private currentEval: CurrentEval | null = null;
299
304
  private stateHistory: HistoryEntry[] = [];
@@ -413,6 +418,7 @@ export class FsmDriver implements ISpecDriver {
413
418
  if (this.stallTimer) { clearTimeout(this.stallTimer); this.stallTimer = null; }
414
419
  if (this.win32SubmitTimer) { clearTimeout(this.win32SubmitTimer); this.win32SubmitTimer = null; }
415
420
  if (this.win32WriteTimer) { clearTimeout(this.win32WriteTimer); this.win32WriteTimer = null; }
421
+ if (this.win32ModalConfirmTimer) { clearTimeout(this.win32ModalConfirmTimer); this.win32ModalConfirmTimer = null; }
416
422
  this.specWatcher?.close();
417
423
  this.adapter.kill();
418
424
  }
@@ -1140,10 +1146,58 @@ export class FsmDriver implements ISpecDriver {
1140
1146
  // `{index}\r` → `\r`.
1141
1147
  const confirm = (rule.key_for_index || '\r').replace(/\{index\}/g, '') || '\r';
1142
1148
  if (nav) this.adapter.send_keys(nav);
1143
- this.adapter.send_keys(confirm);
1149
+ this.submitModalConfirm(confirm);
1144
1150
  return;
1145
1151
  }
1146
- this.adapter.send_keys(btn.key);
1152
+ this.submitModalConfirm(btn.key);
1153
+ }
1154
+
1155
+ /**
1156
+ * Submit a modal-confirm key sequence (the choice key + its trailing CR).
1157
+ *
1158
+ * On win32 the trailing CR is the SAME lone-CR-swallow case as a send_message
1159
+ * submit: ConPTY can absorb a single CR as a literal newline instead of a
1160
+ * confirm, so the approval/picker modal never resolves and the FSM flaps
1161
+ * approval↔busy while auto-approve keeps firing into the void (APPROVESTUCK).
1162
+ * So we split any non-CR prefix (e.g. the "1" of "1\r") off, write it once, and
1163
+ * resend the CR on a fixed cadence until the modal actually resolves (status
1164
+ * leaves 'approval'). Non-win32 keeps the single direct write — its CR submits
1165
+ * on the first try.
1166
+ */
1167
+ private submitModalConfirm(keys: string): void {
1168
+ if (process.platform !== 'win32') {
1169
+ this.adapter.send_keys(keys);
1170
+ return;
1171
+ }
1172
+ const m = /^([\s\S]*?)([\r\n]+)$/.exec(keys);
1173
+ const prefix = m ? m[1] : keys;
1174
+ const cr = m ? m[2] : '';
1175
+ if (prefix) this.adapter.send_keys(prefix);
1176
+ if (!cr) return;
1177
+ this.scheduleWin32ModalConfirm(cr);
1178
+ }
1179
+
1180
+ /**
1181
+ * win32 modal-confirm CR resend loop. Mirrors scheduleWin32Submit's phase-2
1182
+ * verified resend, but gated on still being IN a modal (status 'approval')
1183
+ * rather than still idle: the first CR fires immediately, then resends every
1184
+ * WIN32_SUBMIT_RESEND_GAP_MS while the FSM is still showing the modal, up to
1185
+ * WIN32_SUBMIT_MAX_RESENDS. The instant the modal resolves (status flips to
1186
+ * generating/idle) we stop, so no stray CR leaks into the next turn's composer.
1187
+ */
1188
+ private scheduleWin32ModalConfirm(submitKey: string): void {
1189
+ if (this.win32ModalConfirmTimer) { clearTimeout(this.win32ModalConfirmTimer); this.win32ModalConfirmTimer = null; }
1190
+ const fire = (attempt: number): void => {
1191
+ this.win32ModalConfirmTimer = null;
1192
+ this.adapter.send_keys(submitKey);
1193
+ if (attempt + 1 >= WIN32_SUBMIT_MAX_RESENDS) return;
1194
+ this.win32ModalConfirmTimer = setTimeout(() => {
1195
+ // Left the modal → it resolved; stop resending.
1196
+ if (this.currentStatus() !== 'approval') { this.win32ModalConfirmTimer = null; return; }
1197
+ fire(attempt + 1);
1198
+ }, WIN32_SUBMIT_RESEND_GAP_MS);
1199
+ };
1200
+ fire(0);
1147
1201
  }
1148
1202
 
1149
1203
  private handleAttachImage(blob: string, mime: string): void {