@adhdev/daemon-core 0.9.82-rc.294 → 0.9.82-rc.296

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.
@@ -118,6 +118,15 @@ export declare class SpecCliAdapter implements CliAdapter {
118
118
  * spirit as the approval FSM's `approvalCooldown` modal-lost hysteresis.
119
119
  */
120
120
  private static readonly INTERACTIVE_PROMPT_LOST_GRACE_MS;
121
+ /**
122
+ * Multi-question TUI capture: after Tabbing to a page, re-snapshot at this
123
+ * interval until its checkbox glyph column has settled, up to the timeout.
124
+ * The interval matches the legacy single fixed wait (120ms); the timeout
125
+ * bounds total capture time so a single-select page (which never shows
126
+ * glyphs) doesn't stall the capture indefinitely.
127
+ */
128
+ private static readonly CLAUDE_TUI_PAGE_POLL_INTERVAL_MS;
129
+ private static readonly CLAUDE_TUI_PAGE_SETTLE_TIMEOUT_MS;
121
130
  /**
122
131
  * Clear a held interactive prompt once the user has resolved it directly
123
132
  * in the terminal (the choice picker leaves the screen without going
@@ -144,9 +153,32 @@ export declare class SpecCliAdapter implements CliAdapter {
144
153
  * question to multi-select and re-emit status. Promotion is one-way
145
154
  * (false→true only) — once a question is known multi-select we never demote
146
155
  * it, since the glyph column can scroll out of view on later frames.
156
+ *
157
+ * For MULTI-question prompts the per-page Tab capture is the actual source
158
+ * of the bug: pages 2..N are snapshotted ~120ms after the Tab keypress,
159
+ * before their option-row glyph column has redrawn, so those questions
160
+ * freeze as single-select while page 1 (already settled) is correct. We
161
+ * cannot upgrade blindly — the live snapshot shows only ONE focused page —
162
+ * but we CAN read that page's question text/header and upgrade the matching
163
+ * question. As the user navigates the picker (or it settles), each page is
164
+ * eventually re-read and repaired.
147
165
  */
148
166
  private maybeUpgradeClaudeTuiMultiSelect;
149
167
  private readClaudeTuiHeaders;
168
+ /**
169
+ * Snapshot the currently-focused claude TUI page, polling until its
170
+ * option-row checkbox glyph column has settled (or a bounded timeout).
171
+ *
172
+ * Why poll: right after a Tab keypress the newly-focused page's glyph
173
+ * column has not redrawn yet, so an immediate snapshot shows the question +
174
+ * option labels but NO per-option checkbox markers — freezing that page as
175
+ * single-select. A fixed delay either races (too short) or is wasteful (too
176
+ * long). Instead we re-snapshot at a fixed interval and stop as soon as the
177
+ * frame shows multi-select glyphs, falling back to the last frame at the
178
+ * timeout. Single-select pages never show glyphs, so they always poll to the
179
+ * timeout — bounded small to keep capture snappy.
180
+ */
181
+ private snapshotSettledClaudeTuiPage;
150
182
  private captureClaudeTuiPrompt;
151
183
  getDebugState(): Record<string, any>;
152
184
  getTraceState(limit?: number): Record<string, any>;
@@ -52,6 +52,23 @@ export interface ClaudeInteractiveTuiPage {
52
52
  * as a secondary signal for layouts that render markers differently.
53
53
  */
54
54
  export declare function detectClaudeTuiMultiSelect(screenText: string): boolean;
55
+ /**
56
+ * Read the question the live claude TUI picker is CURRENTLY focused on, plus
57
+ * whether that focused page renders multi-select checkbox markers.
58
+ *
59
+ * Used to repair a multi-question prompt after the fact: when the daemon
60
+ * Tab-captures pages 2..N it snapshots ~120ms after the Tab keypress, before
61
+ * the newly-focused page's option-row checkbox column has redrawn — so those
62
+ * questions get frozen as multiSelect:false even though the picker is
63
+ * multi-select. Re-reading the focused page on a later status tick (once it has
64
+ * settled) lets us attribute the now-visible glyphs to the matching question
65
+ * and upgrade just that one. Returns null when no picker question is on screen.
66
+ */
67
+ export declare function readFocusedClaudeTuiQuestion(screenText: string): {
68
+ question: string;
69
+ header?: string;
70
+ multiSelect: boolean;
71
+ } | null;
55
72
  export declare function detectClaudeAskUserQuestionPromptFromTuiPages(pages: ClaudeInteractiveTuiPage[], options: {
56
73
  promptId: string;
57
74
  providerType?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.294",
3
+ "version": "0.9.82-rc.296",
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.294",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.296",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -32,6 +32,7 @@ import {
32
32
  detectClaudeAskUserQuestionPromptFromJson,
33
33
  detectClaudeAskUserQuestionPromptFromTuiPages,
34
34
  detectClaudeTuiMultiSelect,
35
+ readFocusedClaudeTuiQuestion,
35
36
  type ClaudeInteractiveTuiPage,
36
37
  type InteractivePrompt,
37
38
  type InteractivePromptResponse,
@@ -551,6 +552,16 @@ export class SpecCliAdapter implements CliAdapter {
551
552
  */
552
553
  private static readonly INTERACTIVE_PROMPT_LOST_GRACE_MS = 1500;
553
554
 
555
+ /**
556
+ * Multi-question TUI capture: after Tabbing to a page, re-snapshot at this
557
+ * interval until its checkbox glyph column has settled, up to the timeout.
558
+ * The interval matches the legacy single fixed wait (120ms); the timeout
559
+ * bounds total capture time so a single-select page (which never shows
560
+ * glyphs) doesn't stall the capture indefinitely.
561
+ */
562
+ private static readonly CLAUDE_TUI_PAGE_POLL_INTERVAL_MS = 120;
563
+ private static readonly CLAUDE_TUI_PAGE_SETTLE_TIMEOUT_MS = 600;
564
+
554
565
  /**
555
566
  * Clear a held interactive prompt once the user has resolved it directly
556
567
  * in the terminal (the choice picker leaves the screen without going
@@ -629,19 +640,22 @@ export class SpecCliAdapter implements CliAdapter {
629
640
  * question to multi-select and re-emit status. Promotion is one-way
630
641
  * (false→true only) — once a question is known multi-select we never demote
631
642
  * it, since the glyph column can scroll out of view on later frames.
643
+ *
644
+ * For MULTI-question prompts the per-page Tab capture is the actual source
645
+ * of the bug: pages 2..N are snapshotted ~120ms after the Tab keypress,
646
+ * before their option-row glyph column has redrawn, so those questions
647
+ * freeze as single-select while page 1 (already settled) is correct. We
648
+ * cannot upgrade blindly — the live snapshot shows only ONE focused page —
649
+ * but we CAN read that page's question text/header and upgrade the matching
650
+ * question. As the user navigates the picker (or it settles), each page is
651
+ * eventually re-read and repaired.
632
652
  */
633
653
  private maybeUpgradeClaudeTuiMultiSelect(): void {
634
654
  if (this.cliType !== 'claude-cli'
635
655
  || this.interactivePromptTransport !== 'tui'
636
656
  || !this.activeInteractivePrompt) return;
637
657
  const questions = this.activeInteractivePrompt.questions;
638
- // The live snapshot only shows the CURRENTLY focused question's rows, so
639
- // we can only attribute the glyphs to a specific question when there is
640
- // exactly one. Multi-question prompts are tab-captured per page at
641
- // capture time and aren't re-evaluated here to avoid cross-contaminating
642
- // a mixed single/multi prompt.
643
- if (questions.length !== 1) return;
644
- if (questions[0].multiSelect) return;
658
+ if (questions.every(q => q.multiSelect)) return;
645
659
  let screenText = '';
646
660
  try {
647
661
  screenText = this.driver.snapshot();
@@ -649,8 +663,25 @@ export class SpecCliAdapter implements CliAdapter {
649
663
  return;
650
664
  }
651
665
  if (!screenText.includes('Enter to select')) return;
652
- if (!detectClaudeTuiMultiSelect(screenText)) return;
653
- questions[0].multiSelect = true;
666
+
667
+ if (questions.length === 1) {
668
+ if (questions[0].multiSelect) return;
669
+ if (!detectClaudeTuiMultiSelect(screenText)) return;
670
+ questions[0].multiSelect = true;
671
+ this.statusCallback?.();
672
+ return;
673
+ }
674
+
675
+ // Multi-question: attribute the focused page's glyphs to its question by
676
+ // matching header (preferred) or question text, then upgrade just that
677
+ // one. Never demote — a settled non-multi page is left as captured.
678
+ const focused = readFocusedClaudeTuiQuestion(screenText);
679
+ if (!focused || !focused.multiSelect) return;
680
+ const match = questions.find(q =>
681
+ (focused.header && q.header && q.header === focused.header)
682
+ || q.question === focused.question);
683
+ if (!match || match.multiSelect) return;
684
+ match.multiSelect = true;
654
685
  this.statusCallback?.();
655
686
  }
656
687
 
@@ -665,16 +696,53 @@ export class SpecCliAdapter implements CliAdapter {
665
696
  return headers;
666
697
  }
667
698
 
699
+ /**
700
+ * Snapshot the currently-focused claude TUI page, polling until its
701
+ * option-row checkbox glyph column has settled (or a bounded timeout).
702
+ *
703
+ * Why poll: right after a Tab keypress the newly-focused page's glyph
704
+ * column has not redrawn yet, so an immediate snapshot shows the question +
705
+ * option labels but NO per-option checkbox markers — freezing that page as
706
+ * single-select. A fixed delay either races (too short) or is wasteful (too
707
+ * long). Instead we re-snapshot at a fixed interval and stop as soon as the
708
+ * frame shows multi-select glyphs, falling back to the last frame at the
709
+ * timeout. Single-select pages never show glyphs, so they always poll to the
710
+ * timeout — bounded small to keep capture snappy.
711
+ */
712
+ private async snapshotSettledClaudeTuiPage(): Promise<string> {
713
+ let screenText = this.driver.snapshot();
714
+ const deadline = Date.now() + SpecCliAdapter.CLAUDE_TUI_PAGE_SETTLE_TIMEOUT_MS;
715
+ while (!detectClaudeTuiMultiSelect(screenText) && Date.now() < deadline) {
716
+ await new Promise(resolve => setTimeout(resolve, SpecCliAdapter.CLAUDE_TUI_PAGE_POLL_INTERVAL_MS));
717
+ screenText = this.driver.snapshot();
718
+ }
719
+ return screenText;
720
+ }
721
+
668
722
  private async captureClaudeTuiPrompt(firstScreen: string, headers: string[]): Promise<void> {
669
723
  const pages: ClaudeInteractiveTuiPage[] = [{ screenText: firstScreen, header: headers[0] }];
724
+ // Forward pass: Tab to each page 2..N and snapshot once its glyph column
725
+ // has settled, so pages 2+ capture their checkbox markers (not a racy
726
+ // pre-redraw frame).
670
727
  for (let index = 1; index < headers.length; index += 1) {
671
728
  this.driver.dispatch({ kind: 'pty_write', data: '\t' });
672
- await new Promise(resolve => setTimeout(resolve, 120));
673
- pages.push({ screenText: this.driver.snapshot(), header: headers[index] });
729
+ await new Promise(resolve => setTimeout(resolve, SpecCliAdapter.CLAUDE_TUI_PAGE_POLL_INTERVAL_MS));
730
+ pages.push({ screenText: await this.snapshotSettledClaudeTuiPage(), header: headers[index] });
674
731
  }
732
+ // Return pass: Shift-Tab back through pages N..2. As we land on each page
733
+ // again re-read it and OR-in any now-visible multi-select glyphs — a
734
+ // second chance to repair a page whose forward-pass frame was still racy.
675
735
  for (let index = headers.length - 1; index > 0; index -= 1) {
676
736
  this.driver.dispatch({ kind: 'pty_write', data: '\x1b[Z' });
677
- await new Promise(resolve => setTimeout(resolve, 80));
737
+ await new Promise(resolve => setTimeout(resolve, SpecCliAdapter.CLAUDE_TUI_PAGE_POLL_INTERVAL_MS));
738
+ const reread = await this.snapshotSettledClaudeTuiPage();
739
+ // The page we just Shift-Tab'd ONTO is index-1 (we move backwards).
740
+ const landed = pages[index - 1];
741
+ if (landed
742
+ && !detectClaudeTuiMultiSelect(landed.screenText)
743
+ && detectClaudeTuiMultiSelect(reread)) {
744
+ landed.screenText = reread;
745
+ }
678
746
  }
679
747
 
680
748
  const prompt = detectClaudeAskUserQuestionPromptFromTuiPages(pages, {
@@ -184,15 +184,19 @@ export function detectClaudeTuiMultiSelect(screenText: string): boolean {
184
184
  if (/Space to (?:select|toggle)|toggle selection|select multiple|select all that apply/i.test(screenText)) {
185
185
  return true;
186
186
  }
187
- // A checkbox glyph sitting in front of a NUMBERED option row only appears in
188
- // the multi-select picker (e.g. "❯ [ ] 1. TypeScript" / "☐ 2. Python"). We
189
- // require the numbered "N." option marker so we don't false-positive on the
190
- // `✔ Submit` nav line (per-question answered-state ☐/☒) or on the headerless
191
- // variant where the QUESTION line itself begins with `☐ ` (single-select).
192
- const optionCheckbox = /^\s*(?:[❯›>]\s*)?(?:\[[ xX]\]|[☐☒◻◼])\s*\d+\.\s+\S/;
187
+ // A checkbox glyph on a NUMBERED option row only appears in the multi-select
188
+ // picker. The glyph sits EITHER before the number ("❯ [ ] 1. TypeScript" /
189
+ // "☐ 2. Python") OR after it ("❯ 1. [ ] 계란말이" claude-cli >=2.1's layout).
190
+ // We require the numbered "N." option marker either way so we don't
191
+ // false-positive on the `✔ Submit` nav line (per-question answered-state
192
+ // ☐/☒) or on the headerless variant where the QUESTION line itself begins
193
+ // with `☐ ` (single-select).
194
+ const optionCheckbox = `(?:\\[[ xX]\\]|[☐☒◻◼])`;
195
+ const beforeNumber = new RegExp(`^\\s*(?:[❯›>]\\s*)?${optionCheckbox}\\s*\\d+\\.\\s+\\S`);
196
+ const afterNumber = new RegExp(`^\\s*(?:[❯›>]\\s*)?\\d+\\.\\s*${optionCheckbox}\\s+\\S`);
193
197
  for (const line of screenText.split(/\r?\n/)) {
194
198
  if (line.includes('✔ Submit')) continue; // header/nav line
195
- if (optionCheckbox.test(line)) return true;
199
+ if (beforeNumber.test(line) || afterNumber.test(line)) return true;
196
200
  }
197
201
  return false;
198
202
  }
@@ -361,6 +365,31 @@ function parseClaudeInteractiveTuiQuestion(page: ClaudeInteractiveTuiPage, index
361
365
  };
362
366
  }
363
367
 
368
+ /**
369
+ * Read the question the live claude TUI picker is CURRENTLY focused on, plus
370
+ * whether that focused page renders multi-select checkbox markers.
371
+ *
372
+ * Used to repair a multi-question prompt after the fact: when the daemon
373
+ * Tab-captures pages 2..N it snapshots ~120ms after the Tab keypress, before
374
+ * the newly-focused page's option-row checkbox column has redrawn — so those
375
+ * questions get frozen as multiSelect:false even though the picker is
376
+ * multi-select. Re-reading the focused page on a later status tick (once it has
377
+ * settled) lets us attribute the now-visible glyphs to the matching question
378
+ * and upgrade just that one. Returns null when no picker question is on screen.
379
+ */
380
+ export function readFocusedClaudeTuiQuestion(
381
+ screenText: string,
382
+ ): { question: string; header?: string; multiSelect: boolean } | null {
383
+ if (!screenText.includes('Enter to select')) return null;
384
+ const parsed = parseClaudeInteractiveTuiQuestion({ screenText }, 0);
385
+ if (!parsed) return null;
386
+ return {
387
+ question: parsed.question,
388
+ ...(parsed.header ? { header: parsed.header } : {}),
389
+ multiSelect: parsed.multiSelect,
390
+ };
391
+ }
392
+
364
393
  export function detectClaudeAskUserQuestionPromptFromTuiPages(
365
394
  pages: ClaudeInteractiveTuiPage[],
366
395
  options: { promptId: string; providerType?: string; createdAt?: number },
@@ -392,7 +421,18 @@ export function buildClaudeInteractiveTuiAnswerSteps(
392
421
  if (!answer) throw new Error(`Missing answer for ${question.questionId}`);
393
422
  const freeformText = answer.freeformText?.trim() ?? '';
394
423
 
395
- if (question.multiSelect) {
424
+ // Defensive multi-select fallback: a checkbox picker is the ONLY way an
425
+ // answer can carry more than one selected label, so treat any such answer
426
+ // as multi-select even when `question.multiSelect` was captured as false.
427
+ // This guards the multi-question capture race: pages 2..N can freeze as
428
+ // single-select (their glyph column hadn't redrawn at Tab-snapshot time),
429
+ // and the old single-select branch would then either throw on 2+ checked
430
+ // boxes or emit a bare digit (cursor move, no toggle) for 1 box — silently
431
+ // dropping that page's selection. Keying off the answer's label count makes
432
+ // the keystroke protocol correct regardless of the captured flag.
433
+ const treatAsMultiSelect = question.multiSelect || answer.selectedLabels.length > 1;
434
+
435
+ if (treatAsMultiSelect) {
396
436
  // Multi-select: Claude TUI renders each option as a checkbox and the
397
437
  // footer reads "Space to select". A numeric digit jumps the cursor to
398
438
  // that option; Space toggles its checkbox. So for every selected label