@adhdev/daemon-core 0.8.14 → 0.8.16

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/session-host-core",
3
- "version": "0.8.14",
3
+ "version": "0.8.16",
4
4
  "description": "ADHDev local session host core — session registry, protocol, buffers",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.8.14",
3
+ "version": "0.8.16",
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",
@@ -27,15 +27,7 @@ import {
27
27
  type PtyRuntimeTransport,
28
28
  type PtyTransportFactory,
29
29
  } from './pty-transport.js';
30
- import { sanitizeSpawnEnv, ensureNodePtySpawnHelperPermissions } from './spawn-env.js';
31
-
32
- let pty: any;
33
- try {
34
- pty = require('node-pty');
35
- ensureNodePtySpawnHelperPermissions((msg: string) => LOG.info('CLI', msg));
36
- } catch {
37
- LOG.error('CLI', '[ProviderCliAdapter] node-pty not found. Terminal features disabled.');
38
- }
30
+ import { sanitizeSpawnEnv } from './spawn-env.js';
39
31
 
40
32
  // ─── Types ──────────────────────────────────────────
41
33
 
@@ -77,6 +69,7 @@ export interface CliScriptInput {
77
69
  screenText: string; // Current visible screen snapshot
78
70
  messages: CliChatMessage[]; // Previously parsed messages
79
71
  partialResponse: string; // Current partial response being generated
72
+ promptText?: string; // Current turn prompt when available
80
73
  }
81
74
 
82
75
  interface TurnParseScope {
@@ -320,6 +313,45 @@ function normalizeComparableMessageContent(text: string): string {
320
313
  .trim();
321
314
  }
322
315
 
316
+ function trimPromptEchoPrefix(text: string, promptText?: string | null): string {
317
+ const prompt = normalizeComparableMessageContent(String(promptText || ''));
318
+ if (!prompt) return String(text || '');
319
+
320
+ const lines = String(text || '').split(/\r\n|\n|\r/g);
321
+ let dropCount = 0;
322
+ for (let index = 0; index < Math.min(lines.length, 6); index += 1) {
323
+ const fragment = normalizeComparableMessageContent(lines[index].replace(/^[.…]+\s*/, ''));
324
+ if (!fragment) {
325
+ if (dropCount === index) dropCount = index + 1;
326
+ continue;
327
+ }
328
+ const fragmentWordCount = fragment ? fragment.split(/\s+/).filter(Boolean).length : 0;
329
+ const canBePromptEcho = fragment.length >= 16 || fragmentWordCount >= 4;
330
+ if (canBePromptEcho && prompt.includes(fragment)) {
331
+ dropCount = index + 1;
332
+ continue;
333
+ }
334
+ break;
335
+ }
336
+
337
+ return lines.slice(dropCount).join('\n').trim();
338
+ }
339
+
340
+ function getLastUserPromptText(messages: Array<{ role?: string; content?: string }> | null | undefined): string {
341
+ const items = Array.isArray(messages) ? messages : [];
342
+ for (let index = items.length - 1; index >= 0; index -= 1) {
343
+ const message = items[index];
344
+ if (message?.role === 'user' && typeof message.content === 'string' && message.content.trim()) {
345
+ return message.content;
346
+ }
347
+ }
348
+ return '';
349
+ }
350
+
351
+ function looksLikeConfirmOnlyLabel(label: string): boolean {
352
+ return /^(?:continue|confirm|ok|yes|trust|proceed|enter)$/i.test(String(label || '').trim());
353
+ }
354
+
323
355
  /**
324
356
  * Normalize provider.json for auto-implement approval detection.
325
357
  * Kept for backward compat with dev-server auto-impl pipeline only.
@@ -418,6 +450,8 @@ export class ProviderCliAdapter implements CliAdapter {
418
450
  private submitRetryUsed = false;
419
451
  private submitRetryPromptSnippet = '';
420
452
  private idleFinishCandidate: IdleFinishCandidate | null = null;
453
+ private finishRetryTimer: NodeJS.Timeout | null = null;
454
+ private finishRetryCount = 0;
421
455
 
422
456
  // Resize redraw suppression
423
457
  private resizeSuppressUntil: number = 0;
@@ -442,6 +476,8 @@ export class ProviderCliAdapter implements CliAdapter {
442
476
  private static readonly MAX_TRACE_ENTRIES = 250;
443
477
  private readonly providerResolutionMeta: Record<string, any>;
444
478
  private static readonly IDLE_FINISH_CONFIRM_MS = 900;
479
+ private static readonly FINISH_RETRY_DELAY_MS = 300;
480
+ private static readonly MAX_FINISH_RETRIES = 2;
445
481
 
446
482
  private syncMessageViews(): void {
447
483
  this.messages = [...this.committedMessages];
@@ -527,6 +563,7 @@ export class ProviderCliAdapter implements CliAdapter {
527
563
  screenText: this.terminalScreen.getText(),
528
564
  messages: [...baseMessages],
529
565
  partialResponse,
566
+ promptText: scope?.prompt || '',
530
567
  };
531
568
  }
532
569
 
@@ -882,6 +919,8 @@ export class ProviderCliAdapter implements CliAdapter {
882
919
  this.terminalScreen.reset(24, 80);
883
920
  this.pendingTerminalQueryTail = '';
884
921
  this.currentTurnScope = null;
922
+ this.finishRetryCount = 0;
923
+ if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
885
924
  this.ready = false;
886
925
  await this.ptyProcess.ready;
887
926
  this.recordTrace('ready', {
@@ -937,11 +976,13 @@ export class ProviderCliAdapter implements CliAdapter {
937
976
  if (this.startupParseGate) {
938
977
  this.startupBuffer += cleanData;
939
978
  const elapsed = Date.now() - this.spawnAt;
940
- const scriptStatus = this.runDetectStatus(this.startupBuffer);
941
979
  const screenText = this.terminalScreen.getText() || '';
980
+ const startupModal = this.getStartupConfirmationModal(screenText);
981
+ const scriptStatus = startupModal ? 'waiting_approval' : this.runDetectStatus(this.startupBuffer);
942
982
  const hasInteractivePrompt = this.looksLikeVisibleIdlePrompt(screenText);
943
983
  const startupStableMs = this.lastScreenChangeAt ? (now - this.lastScreenChangeAt) : 0;
944
984
  const isReady = ((scriptStatus === 'idle' || scriptStatus === 'waiting_approval') && hasInteractivePrompt && startupStableMs >= 700)
985
+ || (!!startupModal && startupStableMs >= 700)
945
986
  || elapsed > 8000
946
987
  || this.startupBuffer.length > 12000;
947
988
 
@@ -983,7 +1024,9 @@ export class ProviderCliAdapter implements CliAdapter {
983
1024
  this.approvalExitTimeout = setTimeout(() => {
984
1025
  if (this.currentStatus !== 'waiting_approval') return;
985
1026
  const tail = this.recentOutputBuffer;
986
- const modal = this.runParseApproval(tail);
1027
+ const screenText = this.terminalScreen.getText() || '';
1028
+ const startupModal = this.getStartupConfirmationModal(screenText);
1029
+ const modal = this.runParseApproval(tail) || startupModal;
987
1030
  const stillWaiting = this.runDetectStatus(tail) === 'waiting_approval' || !!modal;
988
1031
  if (stillWaiting) {
989
1032
  this.activeModal = modal || this.activeModal || { message: 'Approval required', buttons: ['Allow', 'Deny'] };
@@ -1010,6 +1053,76 @@ export class ProviderCliAdapter implements CliAdapter {
1010
1053
  || /for\s*shortcuts/i.test(text);
1011
1054
  }
1012
1055
 
1056
+ private looksLikeVisibleAssistantCandidate(screenText: string): boolean {
1057
+ const lines = sanitizeTerminalText(String(screenText || '')).split(/\r\n|\n|\r/g);
1058
+ for (const line of lines) {
1059
+ const trimmed = String(line || '').trim();
1060
+ if (!trimmed) continue;
1061
+ if (/^➜\s+\S+/.test(trimmed)) continue;
1062
+ if (/^Update available!/i.test(trimmed)) continue;
1063
+ if (/Claude Code v\d/i.test(trimmed)) continue;
1064
+ if (/^⏵⏵\s+accept edits on/i.test(trimmed)) continue;
1065
+ if (/^[◐◑◒◓◴◵◶◷◸◹◺◿].*\/effort/i.test(trimmed)) continue;
1066
+ if (/^[✻✶✳✢✽⠂⠐⠒⠓⠦⠴⠶⠷⠿]+$/.test(trimmed)) continue;
1067
+ if (/esc to (cancel|interrupt|stop)/i.test(trimmed)) continue;
1068
+ const assistantMatch = trimmed.match(/^⏺\s+(.+)$/);
1069
+ if (!assistantMatch) continue;
1070
+ const content = assistantMatch[1].trim();
1071
+ if (!content) continue;
1072
+ if (/^(?:Bash|Read|Write|Edit|MultiEdit|Task|Glob|Grep|LS|NotebookEdit)\(/.test(content)) continue;
1073
+ if (/This command requires approval|Do you want to proceed|Allow once|Always allow/i.test(content)) continue;
1074
+ return true;
1075
+ }
1076
+ return false;
1077
+ }
1078
+
1079
+ private shouldRetryFinishResponse(commitResult: { hasAssistant: boolean; assistantContent: string }): boolean {
1080
+ if (!this.currentTurnScope) return false;
1081
+ if (this.currentStatus === 'waiting_approval' || this.activeModal) return false;
1082
+ if (this.finishRetryCount >= ProviderCliAdapter.MAX_FINISH_RETRIES) return false;
1083
+ if (commitResult.hasAssistant && commitResult.assistantContent.trim()) return false;
1084
+
1085
+ const screenText = this.terminalScreen.getText() || '';
1086
+ if (!this.looksLikeVisibleAssistantCandidate(screenText)) return false;
1087
+
1088
+ const now = Date.now();
1089
+ const quietForMs = this.lastNonEmptyOutputAt ? (now - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
1090
+ const screenStableMs = this.lastScreenChangeAt ? (now - this.lastScreenChangeAt) : 0;
1091
+ return quietForMs < 1200 || screenStableMs < 1200 || !commitResult.hasAssistant;
1092
+ }
1093
+
1094
+ private getStartupConfirmationModal(screenText: string): { message: string; buttons: string[] } | null {
1095
+ const text = sanitizeTerminalText(String(screenText || ''));
1096
+ if (!text.trim()) return null;
1097
+
1098
+ if (this.cliType === 'claude-cli') {
1099
+ const hasTrustPrompt = /Quick safety check/i.test(text)
1100
+ || /Is this a project you trust/i.test(text)
1101
+ || /Do you trust (?:this project|the contents of this directory|the files in this folder)/i.test(text);
1102
+ const hasConfirmFooter = /Press Enter to (?:continue|confirm)/i.test(text)
1103
+ || /Enter to confirm/i.test(text)
1104
+ || /Esc to (?:cancel|exit)/i.test(text);
1105
+ if (hasTrustPrompt || (hasConfirmFooter && /trust/i.test(text))) {
1106
+ return {
1107
+ message: 'Confirm Claude Code project trust',
1108
+ buttons: ['Continue'],
1109
+ };
1110
+ }
1111
+ }
1112
+
1113
+ return null;
1114
+ }
1115
+
1116
+ private shouldResolveModalWithEnter(modal: { message: string; buttons: string[] } | null, buttonIndex: number): boolean {
1117
+ if (!modal || buttonIndex !== 0) return false;
1118
+ const buttons = Array.isArray(modal.buttons) ? modal.buttons : [];
1119
+ if (buttons.length !== 1) return false;
1120
+ const buttonLabel = String(buttons[0] || '').trim();
1121
+ const modalText = `${modal.message || ''} ${buttonLabel}`.trim();
1122
+ return looksLikeConfirmOnlyLabel(buttonLabel)
1123
+ || /Quick safety check|project trust|trust (?:this project|the contents of this directory|the files in this folder)|Enter to confirm/i.test(modalText);
1124
+ }
1125
+
1013
1126
  private async waitForInteractivePrompt(maxWaitMs = 5000): Promise<void> {
1014
1127
  const startedAt = Date.now();
1015
1128
  let loggedWait = false;
@@ -1068,10 +1181,11 @@ export class ProviderCliAdapter implements CliAdapter {
1068
1181
  }
1069
1182
  const tail = this.settledBuffer;
1070
1183
  const screenText = this.terminalScreen.getText() || '';
1071
- const modal = this.runParseApproval(tail);
1184
+ const startupModal = this.getStartupConfirmationModal(screenText);
1185
+ const modal = this.runParseApproval(tail) || startupModal;
1072
1186
  const rawScriptStatus = this.runDetectStatus(tail);
1073
1187
  // detectStatus is the sole authority for status. parseApproval only enriches modal info.
1074
- const scriptStatus = rawScriptStatus;
1188
+ const scriptStatus = startupModal ? 'waiting_approval' : rawScriptStatus;
1075
1189
  const parsedTranscript = this.parseCurrentTranscript(
1076
1190
  this.committedMessages,
1077
1191
  this.responseBuffer,
@@ -1292,24 +1406,43 @@ export class ProviderCliAdapter implements CliAdapter {
1292
1406
  this.recordTrace('finish_response', {
1293
1407
  ...this.buildTraceParseSnapshot(this.currentTurnScope, this.responseBuffer),
1294
1408
  });
1295
- this.commitCurrentTranscript();
1409
+ const commitResult = this.commitCurrentTranscript();
1410
+ if (this.shouldRetryFinishResponse(commitResult)) {
1411
+ this.finishRetryCount += 1;
1412
+ this.recordTrace('finish_response_retry', {
1413
+ retryCount: this.finishRetryCount,
1414
+ retryDelayMs: ProviderCliAdapter.FINISH_RETRY_DELAY_MS,
1415
+ assistantContent: this.summarizeTraceText(commitResult.assistantContent, 220),
1416
+ ...this.buildTraceParseSnapshot(this.currentTurnScope, this.responseBuffer),
1417
+ });
1418
+ if (this.finishRetryTimer) clearTimeout(this.finishRetryTimer);
1419
+ this.finishRetryTimer = setTimeout(() => {
1420
+ this.finishRetryTimer = null;
1421
+ if (this.isWaitingForResponse && this.currentStatus !== 'waiting_approval') {
1422
+ this.finishResponse();
1423
+ }
1424
+ }, ProviderCliAdapter.FINISH_RETRY_DELAY_MS);
1425
+ return;
1426
+ }
1296
1427
  if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
1297
1428
  if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
1298
1429
  if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1299
1430
  if (this.submitRetryTimer) { clearTimeout(this.submitRetryTimer); this.submitRetryTimer = null; }
1431
+ if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
1300
1432
 
1301
1433
  this.responseBuffer = '';
1302
1434
  this.isWaitingForResponse = false;
1303
1435
  this.responseSettleIgnoreUntil = 0;
1304
1436
  this.submitRetryUsed = false;
1305
1437
  this.submitRetryPromptSnippet = '';
1438
+ this.finishRetryCount = 0;
1306
1439
  this.currentTurnScope = null;
1307
1440
  this.activeModal = null;
1308
1441
  this.setStatus('idle', 'response_finished');
1309
1442
  this.onStatusChange?.();
1310
1443
  }
1311
1444
 
1312
- private commitCurrentTranscript(): void {
1445
+ private commitCurrentTranscript(): { hasAssistant: boolean; assistantContent: string } {
1313
1446
  const parsed = this.parseCurrentTranscript(
1314
1447
  this.committedMessages,
1315
1448
  this.responseBuffer,
@@ -1317,6 +1450,13 @@ export class ProviderCliAdapter implements CliAdapter {
1317
1450
  );
1318
1451
  if (parsed && Array.isArray(parsed.messages)) {
1319
1452
  this.committedMessages = this.normalizeParsedMessages(parsed.messages);
1453
+ const promptForTrim = this.currentTurnScope?.prompt || getLastUserPromptText(this.committedMessages);
1454
+ if (promptForTrim) {
1455
+ const lastAssistantForTrim = [...this.committedMessages].reverse().find((message) => message.role === 'assistant');
1456
+ if (lastAssistantForTrim) {
1457
+ lastAssistantForTrim.content = trimPromptEchoPrefix(lastAssistantForTrim.content, promptForTrim);
1458
+ }
1459
+ }
1320
1460
  this.syncMessageViews();
1321
1461
  const lastAssistant = [...this.committedMessages].reverse().find((message) => message.role === 'assistant');
1322
1462
  this.recordTrace('commit_transcript', {
@@ -1332,7 +1472,15 @@ export class ProviderCliAdapter implements CliAdapter {
1332
1472
  `[${this.cliType}] Commit without assistant turn: prompt=${JSON.stringify(this.currentTurnScope.prompt).slice(0, 140)} responseBuffer=${JSON.stringify(this.summarizeTraceText(this.responseBuffer, 220)).slice(0, 260)} providerDir=${this.providerResolutionMeta.providerDir || '-'} scriptDir=${this.providerResolutionMeta.scriptDir || '-'} scriptsPath=${this.providerResolutionMeta.scriptsPath || '-'}`
1333
1473
  );
1334
1474
  }
1475
+ return {
1476
+ hasAssistant: !!lastAssistant,
1477
+ assistantContent: lastAssistant?.content || '',
1478
+ };
1335
1479
  }
1480
+ return {
1481
+ hasAssistant: false,
1482
+ assistantContent: '',
1483
+ };
1336
1484
  }
1337
1485
 
1338
1486
  // ─── Script Execution ──────────────────────────
@@ -1419,7 +1567,15 @@ export class ProviderCliAdapter implements CliAdapter {
1419
1567
  if (!this.cliScripts?.parseOutput) return null;
1420
1568
  try {
1421
1569
  const input = this.buildParseInput(baseMessages, partialResponse, scope);
1422
- return this.cliScripts.parseOutput(input);
1570
+ const parsed = this.cliScripts.parseOutput(input);
1571
+ const promptForTrim = scope?.prompt || getLastUserPromptText(baseMessages);
1572
+ if (parsed && Array.isArray(parsed.messages) && promptForTrim) {
1573
+ const lastAssistant = [...parsed.messages].reverse().find((message: any) => message?.role === 'assistant' && typeof message.content === 'string');
1574
+ if (lastAssistant) {
1575
+ lastAssistant.content = trimPromptEchoPrefix(lastAssistant.content, promptForTrim);
1576
+ }
1577
+ }
1578
+ return parsed;
1423
1579
  } catch (e: any) {
1424
1580
  LOG.warn('CLI', `[${this.cliType}] parseOutput error: ${e.message}`);
1425
1581
  return null;
@@ -1464,11 +1620,17 @@ export class ProviderCliAdapter implements CliAdapter {
1464
1620
  if (!this.ready) throw new Error(`${this.cliName} not ready (status: ${this.currentStatus})`);
1465
1621
  if (this.isWaitingForResponse) return;
1466
1622
  await this.waitForInteractivePrompt();
1623
+ const blockingModal = this.activeModal || this.getStartupConfirmationModal(this.terminalScreen.getText() || '');
1624
+ if (blockingModal || this.currentStatus === 'waiting_approval') {
1625
+ throw new Error(`${this.cliName} is awaiting confirmation before it can accept a prompt`);
1626
+ }
1467
1627
 
1468
1628
  this.committedMessages.push({ role: 'user', content: text, timestamp: Date.now() });
1469
1629
  this.syncMessageViews();
1470
1630
  this.isWaitingForResponse = true;
1471
1631
  this.responseBuffer = '';
1632
+ this.finishRetryCount = 0;
1633
+ if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
1472
1634
  this.clearIdleFinishCandidate('send_message');
1473
1635
  this.currentTurnScope = {
1474
1636
  prompt: text,
@@ -1705,6 +1867,7 @@ export class ProviderCliAdapter implements CliAdapter {
1705
1867
  if (this.settleTimer) { clearTimeout(this.settleTimer); this.settleTimer = null; }
1706
1868
  if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1707
1869
  if (this.submitRetryTimer) { clearTimeout(this.submitRetryTimer); this.submitRetryTimer = null; }
1870
+ if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
1708
1871
  if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
1709
1872
  if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
1710
1873
  if (this.pendingScriptStatusTimer) { clearTimeout(this.pendingScriptStatusTimer); this.pendingScriptStatusTimer = null; }
@@ -1713,6 +1876,7 @@ export class ProviderCliAdapter implements CliAdapter {
1713
1876
  this.pendingTerminalQueryTail = '';
1714
1877
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
1715
1878
  this.ptyOutputBuffer = '';
1879
+ this.finishRetryCount = 0;
1716
1880
  if (this.ptyProcess) {
1717
1881
  this.ptyProcess.write('\x03');
1718
1882
  setTimeout(() => {
@@ -1732,6 +1896,7 @@ export class ProviderCliAdapter implements CliAdapter {
1732
1896
  if (this.settleTimer) { clearTimeout(this.settleTimer); this.settleTimer = null; }
1733
1897
  if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1734
1898
  if (this.submitRetryTimer) { clearTimeout(this.submitRetryTimer); this.submitRetryTimer = null; }
1899
+ if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
1735
1900
  if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
1736
1901
  if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
1737
1902
  if (this.pendingScriptStatusTimer) { clearTimeout(this.pendingScriptStatusTimer); this.pendingScriptStatusTimer = null; }
@@ -1740,6 +1905,7 @@ export class ProviderCliAdapter implements CliAdapter {
1740
1905
  this.pendingTerminalQueryTail = '';
1741
1906
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
1742
1907
  this.ptyOutputBuffer = '';
1908
+ this.finishRetryCount = 0;
1743
1909
  if (this.ptyProcess) {
1744
1910
  try {
1745
1911
  if (typeof this.ptyProcess.detach === 'function') {
@@ -1770,6 +1936,8 @@ export class ProviderCliAdapter implements CliAdapter {
1770
1936
  this.pendingTerminalQueryTail = '';
1771
1937
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
1772
1938
  this.ptyOutputBuffer = '';
1939
+ if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
1940
+ this.finishRetryCount = 0;
1773
1941
  this.terminalScreen.reset();
1774
1942
  this.ptyProcess?.clearBuffer?.();
1775
1943
  this.onStatusChange?.();
@@ -1788,10 +1956,11 @@ export class ProviderCliAdapter implements CliAdapter {
1788
1956
 
1789
1957
  resolveModal(buttonIndex: number): void {
1790
1958
  if (!this.ptyProcess || (this.currentStatus !== 'waiting_approval' && !this.activeModal)) return;
1959
+ const modal = this.activeModal;
1791
1960
  this.clearIdleFinishCandidate('resolve_modal');
1792
1961
  this.recordTrace('resolve_modal', {
1793
1962
  buttonIndex,
1794
- activeModal: this.activeModal,
1963
+ activeModal: modal,
1795
1964
  });
1796
1965
  this.activeModal = null;
1797
1966
  this.lastApprovalResolvedAt = Date.now();
@@ -1802,7 +1971,9 @@ export class ProviderCliAdapter implements CliAdapter {
1802
1971
  }
1803
1972
  this.setStatus('generating', 'approval_resolved');
1804
1973
  this.onStatusChange?.();
1805
- if (buttonIndex in this.approvalKeys) {
1974
+ if (this.shouldResolveModalWithEnter(modal, buttonIndex)) {
1975
+ this.ptyProcess.write('\r');
1976
+ } else if (buttonIndex in this.approvalKeys) {
1806
1977
  this.ptyProcess.write(this.approvalKeys[buttonIndex]);
1807
1978
  } else {
1808
1979
  const DOWN = '\x1B[B';
@@ -1,10 +1,20 @@
1
1
  import * as os from 'os';
2
-
3
- let pty: any;
4
- try {
5
- pty = require('node-pty');
6
- } catch {
7
- pty = null;
2
+ import { ensureNodePtySpawnHelperPermissions } from './spawn-env.js';
3
+
4
+ let cachedPty: any | null | undefined;
5
+
6
+ function loadNodePty(): any {
7
+ if (cachedPty !== undefined) return cachedPty;
8
+ try {
9
+ // Keep node-pty out of processes that delegate PTY ownership elsewhere
10
+ // (for example via session-host on Windows), so native PTY crashes do not
11
+ // take down the daemon just by importing this module.
12
+ cachedPty = require('node-pty');
13
+ ensureNodePtySpawnHelperPermissions();
14
+ } catch {
15
+ cachedPty = null;
16
+ }
17
+ return cachedPty;
8
18
  }
9
19
 
10
20
  export interface PtySpawnOptions {
@@ -90,6 +100,7 @@ class NodePtyRuntimeTransport implements PtyRuntimeTransport {
90
100
 
91
101
  export class NodePtyTransportFactory implements PtyTransportFactory {
92
102
  spawn(command: string, args: string[], options: PtySpawnOptions): PtyRuntimeTransport {
103
+ const pty = loadNodePty();
93
104
  if (!pty) throw new Error('node-pty is not installed');
94
105
  // Validate cwd — an invalid directory causes a native crash on Windows
95
106
  // (node-pty error code 267: ERROR_DIRECTORY) that bypasses JS try/catch
@@ -32,7 +32,11 @@ function appendUpgradeLog(message: string): void {
32
32
  }
33
33
 
34
34
  function getNpmExecutable(): string {
35
- return process.platform === 'win32' ? 'npm.cmd' : 'npm';
35
+ return 'npm';
36
+ }
37
+
38
+ function getNpmExecOptions(): { shell: boolean } {
39
+ return { shell: process.platform === 'win32' };
36
40
  }
37
41
 
38
42
  function killPid(pid: number): boolean {
@@ -104,9 +108,10 @@ function removeDaemonPidFile(): void {
104
108
  }
105
109
 
106
110
  function cleanupStaleGlobalInstallDirs(pkgName: string): void {
107
- const npmRoot = execFileSync(getNpmExecutable(), ['root', '-g'], { encoding: 'utf8' }).trim();
111
+ const npmExecOpts = getNpmExecOptions();
112
+ const npmRoot = execFileSync(getNpmExecutable(), ['root', '-g'], { encoding: 'utf8', ...npmExecOpts }).trim();
108
113
  if (!npmRoot) return;
109
- const npmPrefix = execFileSync(getNpmExecutable(), ['prefix', '-g'], { encoding: 'utf8' }).trim();
114
+ const npmPrefix = execFileSync(getNpmExecutable(), ['prefix', '-g'], { encoding: 'utf8', ...npmExecOpts }).trim();
110
115
  const binDir = process.platform === 'win32' ? npmPrefix : path.join(npmPrefix, 'bin');
111
116
  const packageBaseName = pkgName.startsWith('@') ? pkgName.split('/')[1] : pkgName;
112
117
  const binNames = new Set<string>([packageBaseName]);
@@ -175,6 +180,7 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
175
180
  encoding: 'utf8',
176
181
  stdio: 'pipe',
177
182
  maxBuffer: 20 * 1024 * 1024,
183
+ ...getNpmExecOptions(),
178
184
  },
179
185
  );
180
186
  if (installOutput.trim()) {
@@ -27,6 +27,7 @@ type CliExerciseVerification = {
27
27
  lastAssistantMustNotMatchAny?: string[];
28
28
  inspectFields?: string[];
29
29
  description?: string;
30
+ focusAreas?: string[];
30
31
  fixtureName?: string;
31
32
  fixtureNames?: string[];
32
33
  };
@@ -1226,6 +1227,14 @@ export function buildCliAutoImplPrompt(ctx: DevServerContext,
1226
1227
  lines.push('20. When a bug comes from noisy PTY text, first normalize and classify the line family; do NOT just append another special-case substring to the parser.');
1227
1228
  lines.push('');
1228
1229
 
1230
+ if (verification?.focusAreas?.length) {
1231
+ lines.push('## Provider-Specific Focus Areas');
1232
+ for (const area of verification.focusAreas) {
1233
+ lines.push(`- ${area}`);
1234
+ }
1235
+ lines.push('');
1236
+ }
1237
+
1229
1238
  lines.push('## Task');
1230
1239
  lines.push(`Edit files in \`${providerDir}\` to implement: **${functions.join(', ')}**`);
1231
1240
  lines.push('');
@@ -383,6 +383,61 @@ export async function runCliExerciseInternal(ctx: DevServerContext, body: CliExe
383
383
  let idleSince = 0;
384
384
  let sawBusy = false;
385
385
 
386
+ const noteStatus = (status: string) => {
387
+ if (status !== lastStatus) {
388
+ statusesSeen.push(status);
389
+ lastStatus = status;
390
+ }
391
+ };
392
+
393
+ const resolveActiveModalIfNeeded = (status: string, modal: any): boolean => {
394
+ if (!autoResolveApprovals || status !== 'waiting_approval' || !modal || !Array.isArray(modal.buttons) || modal.buttons.length === 0) {
395
+ return false;
396
+ }
397
+ const clampedIndex = Math.max(0, Math.min(Number(approvalButtonIndex) || 0, modal.buttons.length - 1));
398
+ const modalKey = JSON.stringify({
399
+ message: modal.message || '',
400
+ buttons: modal.buttons,
401
+ index: clampedIndex,
402
+ });
403
+ if (modalKey === lastModalKey || typeof bundle?.adapter?.resolveModal !== 'function') {
404
+ return false;
405
+ }
406
+ lastModalKey = modalKey;
407
+ approvalsResolved.push({
408
+ at: Date.now(),
409
+ buttonIndex: clampedIndex,
410
+ label: modal.buttons[clampedIndex] || null,
411
+ });
412
+ bundle.adapter.resolveModal(clampedIndex);
413
+ return true;
414
+ };
415
+
416
+ // Exercise runs are easiest to reason about when startup/trust confirmations are
417
+ // cleared before we attempt to type the repro prompt into the CLI.
418
+ const preflightStartedAt = Date.now();
419
+ while (Date.now() - preflightStartedAt < Math.max(1_000, readyTimeoutMs)) {
420
+ bundle = getCliTargetBundle(ctx, type, bundle.target.instanceId);
421
+ if (!bundle) {
422
+ throw new Error('CLI instance disappeared before exercise send');
423
+ }
424
+
425
+ const debug = typeof bundle.adapter.getDebugState === 'function' ? bundle.adapter.getDebugState() : null;
426
+ const trace = typeof bundle.adapter.getTraceState === 'function' ? bundle.adapter.getTraceState(traceLimit) : null;
427
+ const status = String(debug?.status || bundle.target.status || 'unknown');
428
+ const modal = debug?.activeModal || trace?.activeModal || null;
429
+ noteStatus(status);
430
+
431
+ if (resolveActiveModalIfNeeded(status, modal)) {
432
+ await sleep(150);
433
+ continue;
434
+ }
435
+
436
+ const startupParseGate = !!debug?.startupParseGate;
437
+ if (status === 'idle' && !startupParseGate) break;
438
+ await sleep(150);
439
+ }
440
+
386
441
  ctx.instanceManager.sendEvent(bundle.target.instanceId, 'send_message', { text });
387
442
 
388
443
  while (Date.now() - startAt < Math.max(1_000, timeoutMs)) {
@@ -400,10 +455,7 @@ export async function runCliExerciseInternal(ctx: DevServerContext, body: CliExe
400
455
  const sawSubmitWrite = traceEntries.some((entry: any) => entry?.type === 'submit_write');
401
456
  const hasTurnStarted = sawSendMessage || sawSubmitWrite || !!debug?.currentTurnScope;
402
457
 
403
- if (status !== lastStatus) {
404
- statusesSeen.push(status);
405
- lastStatus = status;
406
- }
458
+ noteStatus(status);
407
459
 
408
460
  if (status === 'generating' || status === 'waiting_approval') {
409
461
  sawBusy = true;
@@ -411,23 +463,8 @@ export async function runCliExerciseInternal(ctx: DevServerContext, body: CliExe
411
463
  }
412
464
 
413
465
  const modal = debug?.activeModal || trace?.activeModal || null;
414
- if (autoResolveApprovals && status === 'waiting_approval' && modal && Array.isArray(modal.buttons) && modal.buttons.length > 0) {
415
- const clampedIndex = Math.max(0, Math.min(Number(approvalButtonIndex) || 0, modal.buttons.length - 1));
416
- const modalKey = JSON.stringify({
417
- message: modal.message || '',
418
- buttons: modal.buttons,
419
- index: clampedIndex,
420
- });
421
- if (modalKey !== lastModalKey && typeof bundle.adapter.resolveModal === 'function') {
422
- lastModalKey = modalKey;
423
- approvalsResolved.push({
424
- at: Date.now(),
425
- buttonIndex: clampedIndex,
426
- label: modal.buttons[clampedIndex] || null,
427
- });
428
- bundle.adapter.resolveModal(clampedIndex);
429
- continue;
430
- }
466
+ if (resolveActiveModalIfNeeded(status, modal)) {
467
+ continue;
431
468
  }
432
469
 
433
470
  const traceCount = Number(trace?.entryCount || 0);