@adhdev/daemon-core 0.9.82-rc.268 → 0.9.82-rc.269

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.
@@ -36,6 +36,18 @@ export declare class CliProviderInstance implements ProviderInstance {
36
36
  private cliArgs;
37
37
  readonly type: string;
38
38
  readonly category: "cli";
39
+ /**
40
+ * Quiet period an approval modal's signature must be stable before
41
+ * auto-approve sends the approve key. Guards against firing on a prompt
42
+ * that is still streaming into the PTY (the "resolves too fast" symptom):
43
+ * while the modal text/buttons are still changing, every frame yields a
44
+ * new signature and the settle clock restarts. Once the prompt finishes
45
+ * rendering the signature holds and the key is sent after this window.
46
+ * Bounded + small so genuine approvals stay timely. The FSM is already
47
+ * authoritative over the `waiting_approval` state; this only delays the
48
+ * keystroke until the modal *content* has settled.
49
+ */
50
+ private static readonly AUTO_APPROVE_SETTLE_MS;
39
51
  private adapter;
40
52
  private context;
41
53
  private events;
@@ -49,6 +61,9 @@ export declare class CliProviderInstance implements ProviderInstance {
49
61
  private autoApproveBusy;
50
62
  private autoApproveBusyTimer;
51
63
  private lastAutoApprovalSignature;
64
+ private pendingAutoApprovalSignature;
65
+ private pendingAutoApprovalSince;
66
+ private autoApproveSettleTimer;
52
67
  private controlValues;
53
68
  private summaryMetadata;
54
69
  private appliedEffectKeys;
@@ -139,6 +154,15 @@ export declare class CliProviderInstance implements ProviderInstance {
139
154
  private scheduleCompletedDebounceFlush;
140
155
  private flushCompletedDebounceIfFinalized;
141
156
  private maybeAutoApproveStatus;
157
+ /**
158
+ * Re-drive the auto-approve check after the settle quiet window elapses.
159
+ * The PTY may have gone silent once the approval prompt finished painting,
160
+ * so no status-change frame is guaranteed to re-enter maybeAutoApproveStatus
161
+ * — this timer-driven re-check picks up the now-settled modal and fires.
162
+ * Deliberately lighter than detectStatusTransition(): it only re-evaluates
163
+ * the approval decision; the next real PTY frame refreshes visible status.
164
+ */
165
+ private recheckAutoApproveSettled;
142
166
  private detectStatusTransition;
143
167
  private pushEvent;
144
168
  private flushEvents;
@@ -40,6 +40,13 @@ export declare class TerminalAdapter {
40
40
  start(): void;
41
41
  resize(cols: number, rows: number): void;
42
42
  snapshot(): string;
43
+ /**
44
+ * Full-buffer snapshot including scrollback history. Unlike snapshot()
45
+ * (visible viewport only), this survives a tall prompt whose top has
46
+ * scrolled off-screen — used for content-pattern extraction (modal
47
+ * buttons / approval anchors), NOT for cursor-relative conditions.
48
+ */
49
+ snapshotWithScrollback(): string;
43
50
  getCursorPosition(): {
44
51
  row: number;
45
52
  col: number;
@@ -193,6 +193,10 @@ export declare class FsmDriver implements ISpecDriver {
193
193
  col: number;
194
194
  };
195
195
  getScreen(): string;
196
+ /** Scrollback-inclusive screen as line array — used only for modal/button
197
+ * content extraction so a tall prompt's off-screen anchors stay matchable.
198
+ * Falls back to the viewport snapshot if scrollback read is unavailable. */
199
+ private scrollbackLines;
196
200
  getSpecPath(): string;
197
201
  shutdown(): void;
198
202
  getFsmDebug(): {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.268",
3
+ "version": "0.9.82-rc.269",
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.268",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.269",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -106,25 +106,43 @@ export class GhosttyVtTerminalBackend implements TerminalViewportBackend {
106
106
  this.terminal.write(data);
107
107
  }
108
108
 
109
- getText(): string {
110
- if (this.disposed) return '';
109
+ private formatLines(): string[] {
111
110
  // ghostty's `trim:true` collapses CUF-advanced cells — many TUIs including
112
111
  // Claude Code render spaces via cursor-forward rather than literal spaces,
113
112
  // which would break downstream regex matching. Keep per-row padding and
114
113
  // trim trailing whitespace ourselves.
115
114
  //
116
115
  // formatPlainText uses a .screen pin which includes scrollback history.
117
- // Slice to the last `rows` lines to get only the visible viewport.
118
116
  const raw = this.terminal.formatPlainText({ trim: false }) || '';
119
- if (!raw) return '';
120
- const lines = raw.split('\n').map((row) => row.replace(/\s+$/, ''));
117
+ if (!raw) return [];
118
+ return raw.split('\n').map((row) => row.replace(/\s+$/, ''));
119
+ }
120
+
121
+ private static trimBlankEnds(lines: string[]): string {
122
+ let first = 0;
123
+ let last = lines.length;
124
+ while (first < last && !lines[first]) first += 1;
125
+ while (last > first && !lines[last - 1]) last -= 1;
126
+ return lines.slice(first, last).join('\n');
127
+ }
128
+
129
+ getText(): string {
130
+ if (this.disposed) return '';
131
+ const lines = this.formatLines();
132
+ if (lines.length === 0) return '';
121
133
  // Take only the viewport (last `rows` lines) to exclude scrollback history.
122
134
  const viewport = lines.length > this.rows ? lines.slice(-this.rows) : lines;
123
- let first = 0;
124
- let last = viewport.length;
125
- while (first < last && !viewport[first]) first += 1;
126
- while (last > first && !viewport[last - 1]) last -= 1;
127
- return viewport.slice(first, last).join('\n');
135
+ return GhosttyVtTerminalBackend.trimBlankEnds(viewport);
136
+ }
137
+
138
+ getTextWithScrollback(): string {
139
+ if (this.disposed) return '';
140
+ const lines = this.formatLines();
141
+ if (lines.length === 0) return '';
142
+ // Full buffer including scrollback — does NOT slice to the viewport, so a
143
+ // tall prompt whose top has scrolled above the visible rows is still
144
+ // matchable by content patterns.
145
+ return GhosttyVtTerminalBackend.trimBlankEnds(lines);
128
146
  }
129
147
 
130
148
  getCursorPosition(): { col: number; row: number } {
@@ -11,6 +11,15 @@ export interface TerminalViewportBackend {
11
11
  resize(rows: number, cols: number): void;
12
12
  write(data: string): void;
13
13
  getText(): string;
14
+ /**
15
+ * Like getText() but includes scrollback history (the lines that have
16
+ * scrolled above the visible viewport). Used by content-pattern matching
17
+ * (e.g. approval/modal button extraction) that must stay correct when a
18
+ * tall prompt — a big diff or long explanation — pushes part of the
19
+ * prompt box above the viewport. NOT for cursor-relative / stable_ms
20
+ * conditions, whose row indices are viewport-relative.
21
+ */
22
+ getTextWithScrollback(): string;
14
23
  getCursorPosition(): { col: number; row: number };
15
24
  dispose(): void;
16
25
  }
@@ -47,6 +47,11 @@ export class TerminalScreen {
47
47
  return this.terminal.getText();
48
48
  }
49
49
 
50
+ /** Full buffer including scrollback history (see backend doc). */
51
+ getTextWithScrollback(): string {
52
+ return this.terminal.getTextWithScrollback();
53
+ }
54
+
50
55
  getCursorPosition(): { col: number; row: number } {
51
56
  return this.terminal.getCursorPosition();
52
57
  }
@@ -350,6 +350,19 @@ export class CliProviderInstance implements ProviderInstance {
350
350
  readonly type: string;
351
351
  readonly category = 'cli' as const;
352
352
 
353
+ /**
354
+ * Quiet period an approval modal's signature must be stable before
355
+ * auto-approve sends the approve key. Guards against firing on a prompt
356
+ * that is still streaming into the PTY (the "resolves too fast" symptom):
357
+ * while the modal text/buttons are still changing, every frame yields a
358
+ * new signature and the settle clock restarts. Once the prompt finishes
359
+ * rendering the signature holds and the key is sent after this window.
360
+ * Bounded + small so genuine approvals stay timely. The FSM is already
361
+ * authoritative over the `waiting_approval` state; this only delays the
362
+ * keystroke until the modal *content* has settled.
363
+ */
364
+ private static readonly AUTO_APPROVE_SETTLE_MS = 600;
365
+
353
366
  private adapter: ProviderCliAdapter;
354
367
  private context: InstanceContext | null = null;
355
368
  private events: ProviderEvent[] = [];
@@ -363,6 +376,14 @@ export class CliProviderInstance implements ProviderInstance {
363
376
  private autoApproveBusy = false;
364
377
  private autoApproveBusyTimer: NodeJS.Timeout | null = null;
365
378
  private lastAutoApprovalSignature = '';
379
+ // Settle gate: the approval modal's signature + the wall-clock when this
380
+ // exact signature was first observed. Auto-approve only fires once the
381
+ // SAME signature has been stable for AUTO_APPROVE_SETTLE_MS, so a prompt
382
+ // still streaming into the PTY (its buttons/message changing frame to
383
+ // frame) keeps resetting the timer and is never approved half-rendered.
384
+ private pendingAutoApprovalSignature = '';
385
+ private pendingAutoApprovalSince = 0;
386
+ private autoApproveSettleTimer: NodeJS.Timeout | null = null;
366
387
  private controlValues: Record<string, string | number | boolean> = {};
367
388
  private summaryMetadata: unknown = undefined;
368
389
  private appliedEffectKeys = new Set<string>();
@@ -951,6 +972,10 @@ export class CliProviderInstance implements ProviderInstance {
951
972
  dispose(): void {
952
973
  this.adapter.shutdown();
953
974
  this.monitor.reset();
975
+ // Cancel any armed auto-approve timers so a pending settle re-check
976
+ // can't fire resolveModal/detectStatusTransition against a dead adapter.
977
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
978
+ if (this.autoApproveBusyTimer) { clearTimeout(this.autoApproveBusyTimer); this.autoApproveBusyTimer = null; }
954
979
  this.appliedEffectKeys.clear();
955
980
  try { this.cachedSqliteDb?.close(); } catch { /* noop */ }
956
981
  this.cachedSqliteDb = null;
@@ -1373,6 +1398,11 @@ export class CliProviderInstance implements ProviderInstance {
1373
1398
  // still inside the short busy window.
1374
1399
  if (!autoApproveActive) {
1375
1400
  this.lastAutoApprovalSignature = '';
1401
+ // Clear the settle gate so the next approval starts its own quiet
1402
+ // window from scratch (a stale timestamp would let it fire instantly).
1403
+ this.pendingAutoApprovalSignature = '';
1404
+ this.pendingAutoApprovalSince = 0;
1405
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
1376
1406
  return autoApproveActive;
1377
1407
  }
1378
1408
  const modal = adapterStatus.activeModal;
@@ -1410,23 +1440,67 @@ export class CliProviderInstance implements ProviderInstance {
1410
1440
  buttons.join('|'),
1411
1441
  buttonIndex,
1412
1442
  ].join('::');
1413
- if (!this.autoApproveBusy || signature !== this.lastAutoApprovalSignature) {
1414
- this.autoApproveBusy = true;
1415
- this.lastAutoApprovalSignature = signature;
1416
- if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
1417
- this.autoApproveBusyTimer = setTimeout(() => {
1418
- this.autoApproveBusy = false;
1419
- this.autoApproveBusyTimer = null;
1420
- this.lastAutoApprovalSignature = '';
1421
- }, 5000);
1422
- this.recordAutoApproval(modal?.message, buttonLabel, now);
1423
- setTimeout(() => {
1424
- this.adapter.resolveModal(buttonIndex);
1425
- }, 0);
1443
+ // Already fired for this exact modal and still inside the busy window —
1444
+ // nothing to do (re-entry guard for repeated snapshots of one modal).
1445
+ if (this.autoApproveBusy && signature === this.lastAutoApprovalSignature) {
1446
+ return autoApproveActive;
1426
1447
  }
1448
+
1449
+ // Settle gate: only fire once this exact signature has been stable for
1450
+ // AUTO_APPROVE_SETTLE_MS. A still-streaming prompt mutates its
1451
+ // message/buttons each frame → new signature → clock restarts, so we
1452
+ // never approve a half-rendered prompt (the "resolves too fast" bug).
1453
+ if (signature !== this.pendingAutoApprovalSignature) {
1454
+ this.pendingAutoApprovalSignature = signature;
1455
+ this.pendingAutoApprovalSince = now;
1456
+ }
1457
+ const settledForMs = now - this.pendingAutoApprovalSince;
1458
+ if (settledForMs < CliProviderInstance.AUTO_APPROVE_SETTLE_MS) {
1459
+ // Not yet settled. Arm a timer to re-check after the remaining quiet
1460
+ // window — the PTY may go silent once the prompt finishes painting,
1461
+ // so there is no guaranteed status-change frame to re-drive us.
1462
+ if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
1463
+ this.autoApproveSettleTimer = setTimeout(() => {
1464
+ this.autoApproveSettleTimer = null;
1465
+ this.recheckAutoApproveSettled();
1466
+ }, CliProviderInstance.AUTO_APPROVE_SETTLE_MS - settledForMs + 20);
1467
+ return autoApproveActive;
1468
+ }
1469
+
1470
+ // Settled — fire the approve key.
1471
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
1472
+ this.autoApproveBusy = true;
1473
+ this.lastAutoApprovalSignature = signature;
1474
+ this.pendingAutoApprovalSignature = '';
1475
+ this.pendingAutoApprovalSince = 0;
1476
+ if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
1477
+ this.autoApproveBusyTimer = setTimeout(() => {
1478
+ this.autoApproveBusy = false;
1479
+ this.autoApproveBusyTimer = null;
1480
+ this.lastAutoApprovalSignature = '';
1481
+ }, 5000);
1482
+ this.recordAutoApproval(modal?.message, buttonLabel, now);
1483
+ setTimeout(() => {
1484
+ this.adapter.resolveModal(buttonIndex);
1485
+ }, 0);
1427
1486
  return autoApproveActive;
1428
1487
  }
1429
1488
 
1489
+ /**
1490
+ * Re-drive the auto-approve check after the settle quiet window elapses.
1491
+ * The PTY may have gone silent once the approval prompt finished painting,
1492
+ * so no status-change frame is guaranteed to re-enter maybeAutoApproveStatus
1493
+ * — this timer-driven re-check picks up the now-settled modal and fires.
1494
+ * Deliberately lighter than detectStatusTransition(): it only re-evaluates
1495
+ * the approval decision; the next real PTY frame refreshes visible status.
1496
+ */
1497
+ private recheckAutoApproveSettled(): void {
1498
+ try {
1499
+ const adapterStatus = this.adapter.getStatus({ allowParse: false });
1500
+ this.maybeAutoApproveStatus(adapterStatus, Date.now());
1501
+ } catch { /* adapter gone / transient — next frame retries */ }
1502
+ }
1503
+
1430
1504
  private detectStatusTransition(): void {
1431
1505
  const now = Date.now();
1432
1506
  // Status-change handling is a hot path: PTY output can fire it many times
@@ -104,6 +104,16 @@ export class TerminalAdapter {
104
104
  return this.lastScreen || this.computeScreen();
105
105
  }
106
106
 
107
+ /**
108
+ * Full-buffer snapshot including scrollback history. Unlike snapshot()
109
+ * (visible viewport only), this survives a tall prompt whose top has
110
+ * scrolled off-screen — used for content-pattern extraction (modal
111
+ * buttons / approval anchors), NOT for cursor-relative conditions.
112
+ */
113
+ snapshotWithScrollback(): string {
114
+ return this.screen.getTextWithScrollback();
115
+ }
116
+
107
117
  getCursorPosition(): { row: number; col: number } {
108
118
  const pos = this.screen.getCursorPosition();
109
119
  return { row: pos.row, col: pos.col };
@@ -267,6 +267,18 @@ export class FsmDriver implements ISpecDriver {
267
267
  snapshot(): string { return this.adapter.snapshot(); }
268
268
  getCursorPosition(): { row: number; col: number } { return this.adapter.getCursorPosition(); }
269
269
  getScreen(): string { return this.adapter.snapshot(); }
270
+
271
+ /** Scrollback-inclusive screen as line array — used only for modal/button
272
+ * content extraction so a tall prompt's off-screen anchors stay matchable.
273
+ * Falls back to the viewport snapshot if scrollback read is unavailable. */
274
+ private scrollbackLines(): string[] {
275
+ let screen = '';
276
+ try {
277
+ screen = this.adapter.snapshotWithScrollback();
278
+ } catch { /* fall through to viewport */ }
279
+ if (!screen) screen = this.adapter.snapshot();
280
+ return screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
281
+ }
270
282
  getSpecPath(): string { return this.opts.specPath; }
271
283
 
272
284
  shutdown(): void {
@@ -484,9 +496,26 @@ export class FsmDriver implements ISpecDriver {
484
496
  const lines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
485
497
  const sections = resolveSections(this.spec.sections ?? {}, lines);
486
498
 
487
- const modal = this.deriveModal(state, sections, lines.join('\n'));
499
+ // Modal states (approval / picker) extract their buttons + title from a
500
+ // SCROLLBACK-INCLUSIVE buffer: claude-cli renders an approval as a box,
501
+ // and when the prompt body (a big diff / long explanation) is tall the
502
+ // top of the box — including the `─────` separator that anchors the
503
+ // `modal` section — scrolls above the viewport. Matching only the
504
+ // viewport then yields < min_count buttons, deriveModal returns null,
505
+ // and auto-approve never fires (the "long prompts never auto-approve"
506
+ // bug). The viewport stays authoritative for transitions / cursor
507
+ // conditions; only this content-pattern extraction reads scrollback.
508
+ const modalLines = state.modal
509
+ ? this.scrollbackLines()
510
+ : lines;
511
+ const modalSections = state.modal
512
+ ? resolveSections(this.spec.sections ?? {}, modalLines)
513
+ : sections;
514
+ const modalFullScreen = modalLines.join('\n');
515
+
516
+ const modal = this.deriveModal(state, modalSections, modalFullScreen);
488
517
  const controls = this.deriveControls(state.id);
489
- const title = modal?.title ?? this.deriveTitle(state, sections, lines.join('\n'));
518
+ const title = modal?.title ?? this.deriveTitle(state, modalSections, modalFullScreen);
490
519
 
491
520
  const next: CurrentEval = {
492
521
  // status is derived from the FSM state itself (statusForState), NOT from