@adhdev/daemon-core 0.9.82-rc.136 → 0.9.82-rc.138

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 (63) hide show
  1. package/dist/chat/source-machine.d.ts +166 -0
  2. package/dist/chat/source-resolver.d.ts +104 -0
  3. package/dist/cli-adapters/cli-script-runner.d.ts +45 -0
  4. package/dist/cli-adapters/cli-state-engine.d.ts +169 -0
  5. package/dist/cli-adapters/provider-cli-adapter.d.ts +72 -74
  6. package/dist/cli-adapters/provider-cli-parse.d.ts +1 -0
  7. package/dist/cli-adapters/provider-cli-shared.d.ts +5 -0
  8. package/dist/config/chat-history.d.ts +1 -0
  9. package/dist/index.d.ts +3 -3
  10. package/dist/index.js +3507 -2288
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +3515 -2301
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/mesh/beads-db.d.ts +54 -0
  15. package/dist/mesh/contracts.d.ts +164 -0
  16. package/dist/mesh/mesh-active-work.d.ts +7 -1
  17. package/dist/mesh/mesh-events.d.ts +10 -4
  18. package/dist/mesh/mesh-ledger.d.ts +21 -1
  19. package/dist/mesh/mesh-refine-status.d.ts +2 -3
  20. package/dist/mesh/mesh-work-queue.d.ts +17 -0
  21. package/dist/mesh/worktree-bootstrap-config.d.ts +2 -4
  22. package/dist/providers/contracts.d.ts +19 -0
  23. package/dist/providers/read-chat-contract.d.ts +29 -0
  24. package/dist/providers/transcript-v2.d.ts +176 -0
  25. package/dist/repo-mesh-types.d.ts +5 -0
  26. package/dist/shared-types.d.ts +7 -0
  27. package/dist/status/snapshot.d.ts +1 -0
  28. package/dist/types.d.ts +5 -0
  29. package/package.json +1 -1
  30. package/src/chat/source-machine.ts +534 -0
  31. package/src/chat/source-resolver.ts +0 -0
  32. package/src/chat/subscription-updates.ts +9 -0
  33. package/src/cli-adapters/cli-script-runner.ts +145 -0
  34. package/src/cli-adapters/cli-state-engine.ts +1054 -0
  35. package/src/cli-adapters/provider-cli-adapter.d.ts +0 -1
  36. package/src/cli-adapters/provider-cli-adapter.ts +413 -1399
  37. package/src/cli-adapters/provider-cli-parse.ts +3 -0
  38. package/src/cli-adapters/provider-cli-shared.ts +17 -1
  39. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +17 -1
  40. package/src/cli-adapters/terminal-backends/xterm-backend.ts +8 -1
  41. package/src/commands/chat-commands.ts +715 -368
  42. package/src/commands/router.ts +22 -2
  43. package/src/config/chat-history.ts +43 -16
  44. package/src/git/git-worktree.ts +8 -1
  45. package/src/index.ts +3 -2
  46. package/src/mesh/beads-db.ts +305 -2
  47. package/src/mesh/contracts.ts +329 -0
  48. package/src/mesh/coordinator-prompt.ts +12 -17
  49. package/src/mesh/mesh-active-work.ts +162 -59
  50. package/src/mesh/mesh-events.ts +198 -53
  51. package/src/mesh/mesh-ledger.ts +321 -105
  52. package/src/mesh/mesh-refine-status.ts +2 -3
  53. package/src/mesh/mesh-work-queue.ts +116 -120
  54. package/src/mesh/worktree-bootstrap-config.ts +17 -4
  55. package/src/providers/contracts.ts +19 -0
  56. package/src/providers/provider-loader.ts +21 -7
  57. package/src/providers/provider-schema.ts +12 -0
  58. package/src/providers/read-chat-contract.ts +74 -14
  59. package/src/providers/transcript-v2.ts +567 -0
  60. package/src/repo-mesh-types.ts +10 -0
  61. package/src/shared-types.ts +7 -0
  62. package/src/status/snapshot.ts +35 -11
  63. package/src/types.ts +5 -0
@@ -44,6 +44,13 @@ import {
44
44
  type CliTraceEntry,
45
45
  type ParsedSession,
46
46
  } from './provider-cli-shared.js';
47
+ import { CliScriptRunner } from './cli-script-runner.js';
48
+ import {
49
+ CliStateEngine,
50
+ type CliBufferSnapshot,
51
+ type CliTransportAccess,
52
+ type CliStateEngineCallbacks,
53
+ } from './cli-state-engine.js';
47
54
  import {
48
55
  buildCliParseInput,
49
56
  buildCliTraceParseSnapshot,
@@ -78,24 +85,6 @@ export {
78
85
  } from './provider-cli-shared.js';
79
86
 
80
87
 
81
- interface IdleFinishCandidate {
82
- armedAt: number;
83
- lastOutputAt: number;
84
- lastScreenChangeAt: number;
85
- responseEpoch: number;
86
- assistantLength: number;
87
- }
88
-
89
- interface SettledEvalContext {
90
- now: number;
91
- modal: any;
92
- status: string;
93
- parsedMessages: CliChatMessage[];
94
- lastParsedAssistant: CliChatMessage | undefined;
95
- parsedStatus: string | null;
96
- prevStatus: string;
97
- }
98
-
99
88
  interface SendMessageState {
100
89
  text: string;
101
90
  normalizedPromptSnippet: string;
@@ -130,26 +119,23 @@ export function appendBoundedText(current: string, chunk: string, maxChars: numb
130
119
  // ─── Adapter ────────────────────────────────────────
131
120
 
132
121
  export class ProviderCliAdapter implements CliAdapter {
133
- readonly cliType: string;
122
+ cliType: string;
134
123
  readonly cliName: string;
135
124
  public workingDir: string;
136
125
 
137
126
  private provider: CliProviderModule;
138
127
  private ptyProcess: PtyRuntimeTransport | null = null;
139
128
  private transportFactory: PtyTransportFactory;
140
- private currentStatus: CliSessionStatus['status'] = 'starting';
141
129
  private onStatusChange: (() => void) | null = null;
142
130
 
131
+ // ─── State machine engine ─────────────────────────
132
+ readonly engine: CliStateEngine;
133
+
143
134
  private responseBuffer = '';
144
135
  private recentOutputBuffer = '';
145
- private isWaitingForResponse = false;
146
- private activeModal: { message: string; buttons: string[] } | null = null;
147
- private parseErrorMessage: string | null = null;
136
+ private get parseErrorMessage(): string | null { return this.runner.parseErrorMessage; }
148
137
  private providerSessionId: string | null = null;
149
- private providerErrorMessage: string | null = null;
150
- private providerErrorReason: string | null = null;
151
138
  private responseTimeout: NodeJS.Timeout | null = null;
152
- private idleTimeout: NodeJS.Timeout | null = null;
153
139
  private ready = false;
154
140
  private startupBuffer = '';
155
141
  private startupParseGate = false;
@@ -175,44 +161,25 @@ export class ProviderCliAdapter implements CliAdapter {
175
161
  private serverConn: any = null;
176
162
  private logBuffer: { message: string; level: string }[] = [];
177
163
 
178
- // Approval cooldown
179
- private lastApprovalResolvedAt: number = 0;
180
-
181
- // Approval state machine
182
- private approvalTransitionBuffer: string = '';
183
- private approvalExitTimeout: NodeJS.Timeout | null = null;
184
- private pendingScriptStatus: 'generating' | 'waiting_approval' | null = null;
185
- private pendingScriptStatusSince = 0;
186
- private pendingScriptStatusTimer: NodeJS.Timeout | null = null;
187
-
188
- // Output settle debounce — fires after PTY output goes quiet
189
- private settleTimer: NodeJS.Timeout | null = null;
190
- private settledBuffer: string = '';
191
- private submitPendingUntil = 0;
192
- private responseSettleIgnoreUntil = 0;
193
- private responseEpoch = 0;
194
- private submitRetryTimer: NodeJS.Timeout | null = null;
195
- private submitRetryUsed = false;
196
- private submitRetryPromptSnippet = '';
197
- private idleFinishCandidate: IdleFinishCandidate | null = null;
198
- private finishRetryTimer: NodeJS.Timeout | null = null;
199
- private finishRetryCount = 0;
200
164
  private pendingOutboundQueue: PendingOutboundMessage[] = [];
201
165
  private pendingOutboundFlushTimer: NodeJS.Timeout | null = null;
202
166
  private pendingOutboundFlushInFlight = false;
203
- private providerErrorRetryTimer: NodeJS.Timeout | null = null;
204
- private providerErrorRetryKey = '';
167
+ // Submit retry timer PTY-level, not state machine
168
+ private submitRetryTimer: NodeJS.Timeout | null = null;
205
169
 
206
170
  // Resize redraw suppression
207
171
  private resizeSuppressUntil: number = 0;
208
172
 
209
- // Debug: status transition history
210
- private statusHistory: { status: string; at: number; trigger?: string }[] = [];
173
+ // (A2.2) Native transcript anchor moved to CHAT_SOURCE_REGISTRY.
174
+ // ChatSourceMachine holds the lock by state, not by a mutable field on
175
+ // the adapter. Removed entirely; no callers remain after the readChat
176
+ // ladder was replaced.
211
177
 
212
- // ─── CLI Scripts (script-based parsing) ───
213
- private cliScripts: CliScripts;
214
- /** Per-session opaque state object created by cliScripts.createState(), reset on stop. */
215
- private scriptState: unknown = null;
178
+ // ─── Script runner (parsing isolated here, adapter stays as transport) ───
179
+ private readonly runner: CliScriptRunner;
180
+ /** @deprecated use runner.cliScripts for direct script access */
181
+ get cliScripts(): CliScripts { return this.runner.cliScripts; }
182
+ set cliScripts(scripts: CliScripts) { this.setCliScripts(scripts); }
216
183
  private runtimeSettings: Record<string, any> = {};
217
184
  /** Full accumulated rendered PTY transcript for parser/readback use */
218
185
  private accumulatedBuffer: string = '';
@@ -232,10 +199,6 @@ export class ProviderCliAdapter implements CliAdapter {
232
199
  * Hermes turn (tool calls + reasoning + final bubble) without the
233
200
  * rolling window pushing the turn's ╭─ opening line out of view. */
234
201
  private static readonly MAX_ACCUMULATED_BUFFER = 262144;
235
- private currentTurnScope: TurnParseScope | null = null;
236
- private traceEntries: CliTraceEntry[] = [];
237
- private traceSeq = 0;
238
- private traceSessionId = '';
239
202
  private parsedStatusCache: {
240
203
  responseBuffer: string;
241
204
  currentTurnScope: TurnParseScope | null;
@@ -249,11 +212,8 @@ export class ProviderCliAdapter implements CliAdapter {
249
212
  result: any;
250
213
  } | null = null;
251
214
  private static readonly SCREEN_SNAPSHOT_MIN_INTERVAL_MS = 250;
252
- private static readonly MAX_TRACE_ENTRIES = 250;
253
215
 
254
216
  private readonly providerResolutionMeta: ProviderResolutionMeta;
255
- private static readonly FINISH_RETRY_DELAY_MS = 300;
256
- private static readonly MAX_FINISH_RETRIES = 2;
257
217
 
258
218
  private getBufferState(): NonNullable<CliSessionStatus['bufferState']> | undefined {
259
219
  const build = (droppedChars: number, maxChars: number) => droppedChars > 0
@@ -329,13 +289,13 @@ export class ProviderCliAdapter implements CliAdapter {
329
289
  if (
330
290
  cached
331
291
  && cached.responseBuffer === this.responseBuffer
332
- && cached.currentTurnScope === this.currentTurnScope
292
+ && cached.currentTurnScope === this.engine.currentTurnScope
333
293
  && cached.recentOutputBuffer === this.recentOutputBuffer
334
294
  && cached.accumulatedBuffer === this.accumulatedBuffer
335
295
  && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
336
296
  && cached.screenText === this.lastScreenText
337
- && cached.currentStatus === this.currentStatus
338
- && cached.activeModal === this.activeModal
297
+ && cached.currentStatus === this.engine.currentStatus
298
+ && cached.activeModal === this.engine.activeModal
339
299
  && cached.cliName === this.cliName
340
300
  ) {
341
301
  return cached.result;
@@ -359,86 +319,6 @@ export class ProviderCliAdapter implements CliAdapter {
359
319
  return this.timeouts.statusActivityHold;
360
320
  }
361
321
 
362
- private setStatus(status: CliSessionStatus['status'], trigger?: string): void {
363
- const prev = this.currentStatus;
364
- if (prev === status) return;
365
- this.currentStatus = status;
366
- this.statusHistory.push({ status, at: Date.now(), trigger });
367
- if (this.statusHistory.length > 50) this.statusHistory.shift();
368
- this.recordTrace('status', {
369
- previousStatus: prev,
370
- trigger: trigger || null,
371
- });
372
- LOG.info('CLI', `[${this.cliType}] status: ${prev} → ${status}${trigger ? ` (${trigger})` : ''}`);
373
- }
374
-
375
- private clearIdleFinishCandidate(reason: string): void {
376
- if (!this.idleFinishCandidate) return;
377
- this.recordTrace('idle_candidate_reset', {
378
- reason,
379
- candidate: this.idleFinishCandidate,
380
- });
381
- this.idleFinishCandidate = null;
382
- }
383
-
384
- private armIdleFinishCandidate(assistantLength: number): void {
385
- const now = Date.now();
386
- const idleFinishConfirmMs = this.getIdleFinishConfirmMs();
387
- this.idleFinishCandidate = {
388
- armedAt: now,
389
- lastOutputAt: this.lastOutputAt,
390
- lastScreenChangeAt: this.lastScreenChangeAt,
391
- responseEpoch: this.responseEpoch,
392
- assistantLength,
393
- };
394
- this.recordTrace('idle_candidate_armed', {
395
- confirmMs: idleFinishConfirmMs,
396
- candidate: this.idleFinishCandidate,
397
- ...buildCliTraceParseSnapshot({
398
- accumulatedBuffer: this.accumulatedBuffer,
399
- accumulatedRawBuffer: this.accumulatedRawBuffer,
400
- responseBuffer: this.responseBuffer,
401
- partialResponse: this.responseBuffer,
402
- scope: this.currentTurnScope,
403
- }),
404
- });
405
- if (this.settleTimer) clearTimeout(this.settleTimer);
406
- this.settleTimer = setTimeout(() => {
407
- this.settleTimer = null;
408
- this.settledBuffer = this.recentOutputBuffer;
409
- this.evaluateSettled();
410
- }, idleFinishConfirmMs);
411
- }
412
-
413
-
414
- private recordTrace(type: string, payload: Record<string, any> = {}): void {
415
- const entry: CliTraceEntry = {
416
- id: ++this.traceSeq,
417
- at: Date.now(),
418
- type,
419
- status: this.currentStatus,
420
- isWaitingForResponse: this.isWaitingForResponse,
421
- activeModal: this.activeModal
422
- ? { message: this.activeModal.message, buttons: [...this.activeModal.buttons] }
423
- : null,
424
- payload,
425
- };
426
- this.traceEntries.push(entry);
427
- if (this.traceEntries.length > ProviderCliAdapter.MAX_TRACE_ENTRIES) {
428
- this.traceEntries.splice(0, this.traceEntries.length - ProviderCliAdapter.MAX_TRACE_ENTRIES);
429
- }
430
- }
431
-
432
- private resetTraceSession(): void {
433
- this.traceEntries = [];
434
- this.traceSeq = 0;
435
- this.traceSessionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
436
- this.recordTrace('session_start', {
437
- providerType: this.cliType,
438
- workingDir: this.workingDir,
439
- });
440
- }
441
-
442
322
  // Resolved timeouts
443
323
  private readonly timeouts: Required<NonNullable<CliProviderModule['timeouts']>>;
444
324
 
@@ -448,7 +328,6 @@ export class ProviderCliAdapter implements CliAdapter {
448
328
  private readonly sendKey: string;
449
329
  private readonly submitStrategy: 'wait_for_echo' | 'immediate';
450
330
  private readonly requirePromptEchoBeforeSubmit: boolean;
451
- private static readonly SCRIPT_STATUS_DEBOUNCE_MS = 3000;
452
331
 
453
332
  constructor(
454
333
  provider: CliProviderModule,
@@ -457,6 +336,7 @@ export class ProviderCliAdapter implements CliAdapter {
457
336
  private extraEnv: Record<string, string> = {},
458
337
  transportFactory: PtyTransportFactory = new NodePtyTransportFactory(),
459
338
  ) {
339
+ this.runner = new CliScriptRunner(provider.type);
460
340
  this.provider = provider;
461
341
  this.transportFactory = transportFactory;
462
342
  this.cliType = provider.type;
@@ -474,10 +354,22 @@ export class ProviderCliAdapter implements CliAdapter {
474
354
  this.requirePromptEchoBeforeSubmit = resolvedConfig.requirePromptEchoBeforeSubmit;
475
355
  this.providerResolutionMeta = resolvedConfig.providerResolutionMeta;
476
356
 
477
- // Scripts are requiredloaded by ProviderLoader via compatibility array
478
- this.cliScripts = provider.scripts || {};
479
- this.scriptState = typeof this.cliScripts.createState === 'function' ? (this.cliScripts.createState() ?? null) : null;
480
- const scriptNames = listCliScriptNames(this.cliScripts);
357
+ // State machine engineowns all status transitions
358
+ this.engine = new CliStateEngine(
359
+ provider,
360
+ this.runner,
361
+ this as unknown as CliTransportAccess,
362
+ {
363
+ onStatusChange: () => { this.onStatusChange?.(); },
364
+ onApplyParsedSession: (session) => { this.applyParsedSessionMetadata(session); },
365
+ onTurnCompleted: () => { this.responseBuffer = ''; },
366
+ } satisfies CliStateEngineCallbacks,
367
+ resolvedConfig.timeouts,
368
+ );
369
+
370
+ // Scripts delegated to CliScriptRunner — adapter stays as transport
371
+ this.runner.setScripts(provider.scripts || {});
372
+ const scriptNames = this.runner.getScriptNames();
481
373
  if (scriptNames.length > 0) {
482
374
  LOG.info('CLI', `[${this.cliType}] CLI scripts: [${scriptNames.join(', ')}]`);
483
375
  LOG.info(
@@ -503,14 +395,9 @@ export class ProviderCliAdapter implements CliAdapter {
503
395
 
504
396
  /** Inject CLI scripts after construction (e.g. when resolved by ProviderLoader) */
505
397
  setCliScripts(scripts: CliScripts): void {
506
- this.cliScripts = scripts;
398
+ this.runner.setScripts(scripts);
507
399
  this.parsedStatusCache = null;
508
- this.parseErrorMessage = null;
509
- // Initialize per-session state: createState() is called once here and on script reload.
510
- // The returned object lives until the PTY exits (scriptState = null on exit).
511
- this.scriptState = typeof scripts.createState === 'function' ? (scripts.createState() ?? null) : null;
512
- const scriptNames = listCliScriptNames(scripts);
513
- LOG.info('CLI', `[${this.cliType}] CLI scripts injected: [${scriptNames.join(', ')}]`);
400
+ LOG.info('CLI', `[${this.cliType}] CLI scripts injected: [${this.runner.getScriptNames().join(', ')}]`);
514
401
  }
515
402
 
516
403
  /** Refresh provider scripts/config used by this adapter without restarting the PTY runtime. */
@@ -564,15 +451,6 @@ export class ProviderCliAdapter implements CliAdapter {
564
451
  });
565
452
 
566
453
  LOG.info('CLI', `[${this.cliType}] Spawning in ${this.workingDir}`);
567
- this.resetTraceSession();
568
- this.recordTrace('spawn', {
569
- shellCommand: spawnPlan.shellCmd,
570
- shellArgs: spawnPlan.shellArgs,
571
- cwd: spawnPlan.ptyOptions.cwd,
572
- cols: spawnPlan.ptyOptions.cols,
573
- rows: spawnPlan.ptyOptions.rows,
574
- providerResolution: this.providerResolutionMeta,
575
- });
576
454
 
577
455
  try {
578
456
  this.ptyProcess = this.transportFactory.spawn(
@@ -636,13 +514,12 @@ export class ProviderCliAdapter implements CliAdapter {
636
514
  this.ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
637
515
  LOG.info('CLI', `[${this.cliType}] Exit code ${exitCode}`);
638
516
  this.flushPendingOutputParse();
639
- this.recordTrace('exit', { exitCode });
640
517
  this.ptyProcess = null;
641
- this.setStatus('stopped', 'pty_exit');
518
+ this.engine.onPtyExit();
642
519
  this.ready = false;
643
520
  this.startupParseGate = false;
644
521
  this.spawnAt = 0;
645
- this.scriptState = null;
522
+ this.runner.resetSessionState();
646
523
  this.onStatusChange?.();
647
524
  });
648
525
 
@@ -653,15 +530,9 @@ export class ProviderCliAdapter implements CliAdapter {
653
530
  if (this.startupSettleTimer) { clearTimeout(this.startupSettleTimer); this.startupSettleTimer = null; }
654
531
  this.resetTerminalScreen(24, 80);
655
532
  this.pendingTerminalQueryTail = '';
656
- this.currentTurnScope = null;
657
- this.finishRetryCount = 0;
658
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
659
533
  this.ready = false;
660
534
  await this.ptyProcess.ready;
661
- this.recordTrace('ready', {
662
- runtimeMeta: this.getRuntimeMetadata(),
663
- });
664
- this.setStatus('starting', 'pty_ready');
535
+ this.engine.onSpawnReady();
665
536
  this.scheduleStartupSettleCheck();
666
537
  this.onStatusChange?.();
667
538
  }
@@ -687,11 +558,11 @@ export class ProviderCliAdapter implements CliAdapter {
687
558
  if (this.startupParseGate && !this.startupFirstOutputAt && (cleanData.trim() || normalizedScreenSnapshot.trim())) {
688
559
  this.startupFirstOutputAt = now;
689
560
  }
690
- if (this.idleFinishCandidate && (rawData.length > 0 || cleanData.length > 0)) {
691
- this.clearIdleFinishCandidate('new_output');
561
+ if (rawData.length > 0 || cleanData.length > 0) {
562
+ this.engine.clearIdleFinishCandidate('new_output');
692
563
  }
693
564
  if (getDebugRuntimeConfig().collectDebugTrace) {
694
- this.recordTrace('output', {
565
+ this.engine.recordExternalTrace('output', {
695
566
  rawLength: rawData.length,
696
567
  cleanLength: cleanData.length,
697
568
  rawPreview: summarizeCliTraceText(rawData, 300),
@@ -703,7 +574,7 @@ export class ProviderCliAdapter implements CliAdapter {
703
574
  this.scheduleStartupSettleCheck();
704
575
  }
705
576
 
706
- if (this.isWaitingForResponse && cleanData) {
577
+ if (this.engine.isWaitingForResponse && cleanData) {
707
578
  const previousResponseLen = this.responseBuffer.length;
708
579
  this.responseBuffer = appendBoundedText(this.responseBuffer, cleanData, ProviderCliAdapter.MAX_RESPONSE_BUFFER);
709
580
  this.responseBufferDroppedChars += this.recordBoundedAppendDrop(previousResponseLen, cleanData.length, this.responseBuffer.length);
@@ -742,19 +613,19 @@ export class ProviderCliAdapter implements CliAdapter {
742
613
  // Keep turn-scope offsets aligned with the truncated buffer so scoped
743
614
  // parses don't lose the beginning of a long turn (e.g. the Hermes
744
615
  // ╭─ opening line) when the rolling window sheds bytes.
745
- if (this.currentTurnScope) {
616
+ if (this.engine.currentTurnScope) {
746
617
  if (droppedClean > 0) {
747
- this.currentTurnScope.bufferStart = Math.max(0, this.currentTurnScope.bufferStart - droppedClean);
618
+ this.engine.currentTurnScope.bufferStart = Math.max(0, this.engine.currentTurnScope.bufferStart - droppedClean);
748
619
  }
749
620
  if (droppedRaw > 0) {
750
- this.currentTurnScope.rawBufferStart = Math.max(0, this.currentTurnScope.rawBufferStart - droppedRaw);
621
+ this.engine.currentTurnScope.rawBufferStart = Math.max(0, this.engine.currentTurnScope.rawBufferStart - droppedRaw);
751
622
  }
752
623
  }
753
624
 
754
625
  this.resolveStartupState('output', screenText, normalizedScreenSnapshot, now);
755
626
 
756
627
  // ─── Script-based status detection
757
- this.scheduleSettle();
628
+ this.engine.scheduleSettle();
758
629
  }
759
630
 
760
631
  private resolveStartupState(
@@ -779,12 +650,6 @@ export class ProviderCliAdapter implements CliAdapter {
779
650
  const startupModal = this.runParseApproval(this.recentOutputBuffer);
780
651
  const startupStatus = this.runDetectStatus(screenText || this.recentOutputBuffer);
781
652
  if (!startupModal && startupStatus !== 'idle') {
782
- this.recordTrace('startup_settle_deferred', {
783
- trigger,
784
- startupStatus,
785
- stableMs,
786
- screenText: summarizeCliTraceText(screenText, 500),
787
- });
788
653
  this.scheduleStartupSettleCheck();
789
654
  return;
790
655
  }
@@ -795,14 +660,22 @@ export class ProviderCliAdapter implements CliAdapter {
795
660
  }
796
661
  this.ready = true;
797
662
  if (startupModal) {
798
- this.activeModal = startupModal;
799
- this.setStatus('waiting_approval', `startup_ready:${trigger}`);
663
+ this.engine.activeModal = startupModal;
664
+ this.engine.setStatus('waiting_approval', `startup_ready:${trigger}`);
800
665
  } else {
801
- if (this.currentStatus === 'waiting_approval' || this.activeModal) {
802
- this.lastApprovalResolvedAt = Date.now();
666
+ if (this.engine.currentStatus === 'waiting_approval' || this.engine.activeModal) {
667
+ this.engine.lastApprovalResolvedAt = Date.now();
803
668
  }
804
- this.activeModal = null;
805
- this.setStatus('idle', `startup_ready:${trigger}`);
669
+ this.engine.activeModal = null;
670
+ // Clear the in-flight turn flag at the same time we declare
671
+ // startup-idle. Otherwise the next settled evaluation sees
672
+ // isWaitingForResponse=true + recent CLI welcome-screen paints
673
+ // and flips us right back to generating via the hold path
674
+ // (the "startup → generating → idle → generating → idle"
675
+ // flicker the user observed on claude-cli launch).
676
+ this.engine.isWaitingForResponse = false;
677
+ this.engine.currentTurnScope = null;
678
+ this.engine.setStatus('idle', `startup_ready:${trigger}`);
806
679
  }
807
680
  LOG.info(
808
681
  'CLI',
@@ -828,88 +701,6 @@ export class ProviderCliAdapter implements CliAdapter {
828
701
  }, delayMs);
829
702
  }
830
703
 
831
- private scheduleSettle(): void {
832
- if (this.settleTimer) clearTimeout(this.settleTimer);
833
- const settleEpoch = this.responseEpoch;
834
- const delay = Math.max(
835
- this.timeouts.outputSettle,
836
- this.submitPendingUntil > Date.now()
837
- ? (this.submitPendingUntil - Date.now()) + this.timeouts.outputSettle
838
- : 0,
839
- );
840
- this.settleTimer = setTimeout(() => {
841
- this.settleTimer = null;
842
- if (settleEpoch !== this.responseEpoch) return;
843
- this.settledBuffer = this.recentOutputBuffer;
844
- this.evaluateSettled();
845
- }, delay);
846
- }
847
-
848
- private armApprovalExitTimeout(): void {
849
- if (this.approvalExitTimeout) clearTimeout(this.approvalExitTimeout);
850
- this.approvalExitTimeout = setTimeout(() => {
851
- if (!this.hasActionableApproval()) return;
852
- const tail = this.recentOutputBuffer;
853
- const screenText = this.terminalScreen.getText() || '';
854
- const modal = this.runParseApproval(tail);
855
- const stillWaiting = this.runDetectStatus(tail) === 'waiting_approval' || !!modal;
856
- if (stillWaiting) {
857
- if (!modal) {
858
- LOG.warn('CLI', `[${this.cliType}] approval timeout check found no actionable modal; keeping approval state fail-closed`);
859
- this.activeModal = null;
860
- this.onStatusChange?.();
861
- this.armApprovalExitTimeout();
862
- return;
863
- }
864
- this.activeModal = modal;
865
- this.onStatusChange?.();
866
- this.armApprovalExitTimeout();
867
- return;
868
- }
869
- LOG.warn('CLI', `[${this.cliType}] Approval timeout — auto-clearing`);
870
- this.activeModal = null;
871
- this.lastApprovalResolvedAt = Date.now();
872
- this.setStatus('idle', 'approval_timeout');
873
- this.onStatusChange?.();
874
- }, 60000);
875
- }
876
-
877
- private shouldRetryFinishResponse(commitResult: { hasAssistant: boolean; assistantContent: string }): boolean {
878
- if (!this.currentTurnScope) return false;
879
- if (this.hasActionableApproval()) return false;
880
- if (this.finishRetryCount >= ProviderCliAdapter.MAX_FINISH_RETRIES) return false;
881
- if (commitResult.hasAssistant && commitResult.assistantContent.trim()) return false;
882
-
883
- if (this.runDetectStatus(this.recentOutputBuffer) !== 'idle') return false;
884
-
885
- const now = Date.now();
886
- const quietForMs = this.lastNonEmptyOutputAt ? (now - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
887
- const screenStableMs = this.lastScreenChangeAt ? (now - this.lastScreenChangeAt) : 0;
888
- return quietForMs < 1200 || screenStableMs < 1200 || !commitResult.hasAssistant;
889
- }
890
-
891
- private hasRecentInteractiveActivity(now: number): boolean {
892
- const quietForMs = this.lastNonEmptyOutputAt ? (now - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
893
- const screenStableMs = this.lastScreenChangeAt ? (now - this.lastScreenChangeAt) : Number.MAX_SAFE_INTEGER;
894
- const holdMs = this.getStatusActivityHoldMs();
895
- return quietForMs < holdMs
896
- || screenStableMs < holdMs;
897
- }
898
-
899
- private shouldDeferIdleTimeoutFinish(): boolean {
900
- if (!this.isWaitingForResponse || this.hasActionableApproval()) {
901
- return false;
902
- }
903
- const latestStatus = this.runDetectStatus(this.recentOutputBuffer) || this.currentStatus;
904
- if (latestStatus === 'generating') {
905
- this.settledBuffer = this.recentOutputBuffer;
906
- this.evaluateSettled();
907
- return true;
908
- }
909
- return false;
910
- }
911
-
912
-
913
704
  private async waitForInteractivePrompt(maxWaitMs = 5000): Promise<void> {
914
705
  const startedAt = Date.now();
915
706
  let loggedWait = false;
@@ -919,7 +710,7 @@ export class ProviderCliAdapter implements CliAdapter {
919
710
  const screenText = this.terminalScreen.getText() || '';
920
711
  const stableMs = this.lastScreenChangeAt ? (Date.now() - this.lastScreenChangeAt) : 0;
921
712
  const recentlyOutput = this.lastNonEmptyOutputAt ? (Date.now() - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
922
- const status = this.runDetectStatus(this.recentOutputBuffer) || this.currentStatus;
713
+ const status = this.runDetectStatus(this.recentOutputBuffer) || this.engine.currentStatus;
923
714
  const interactiveReady = status === 'idle'
924
715
  && stableMs >= 700
925
716
  && recentlyOutput >= 350;
@@ -953,861 +744,84 @@ export class ProviderCliAdapter implements CliAdapter {
953
744
 
954
745
  private clearAllTimers(): void {
955
746
  if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
956
- if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
957
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
958
747
  if (this.submitRetryTimer) { clearTimeout(this.submitRetryTimer); this.submitRetryTimer = null; }
959
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
960
- if (this.settleTimer) { clearTimeout(this.settleTimer); this.settleTimer = null; }
961
- if (this.pendingScriptStatusTimer) { clearTimeout(this.pendingScriptStatusTimer); this.pendingScriptStatusTimer = null; }
962
748
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
963
749
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
964
- if (this.providerErrorRetryTimer) { clearTimeout(this.providerErrorRetryTimer); this.providerErrorRetryTimer = null; }
965
- this.providerErrorRetryKey = '';
750
+ this.engine.clearAllTimers();
966
751
  }
967
752
 
968
- private clearStaleIdleResponseGuard(reason: string): boolean {
969
- const blockingModal = this.activeModal || this.runParseApproval(this.recentOutputBuffer);
970
- const isIdle = this.runDetectStatus(this.recentOutputBuffer) === 'idle';
971
- if (!this.isWaitingForResponse || this.currentStatus !== 'idle' || !isIdle || !!blockingModal) {
972
- return false;
973
- }
974
- this.clearAllTimers();
975
- this.clearIdleFinishCandidate(reason);
976
- this.responseBuffer = '';
977
- this.isWaitingForResponse = false;
978
- this.responseSettleIgnoreUntil = 0;
979
- this.submitRetryUsed = false;
980
- this.submitRetryPromptSnippet = '';
981
- this.finishRetryCount = 0;
982
- this.currentTurnScope = null;
983
- this.activeModal = null;
984
- this.recordTrace('stale_idle_response_cleared', { reason });
985
- return true;
986
- }
987
-
988
- private clearParsedIdleResponseGuard(reason: string, parsedStatus: any): boolean {
989
- const parsedRawStatus = typeof parsedStatus?.status === 'string' ? parsedStatus.status.trim() : '';
990
- const parsedModal = parsedStatus?.activeModal ?? parsedStatus?.modal ?? null;
991
- const blockingModal = this.activeModal || this.runParseApproval(this.recentOutputBuffer);
992
- if (
993
- !this.isWaitingForResponse
994
- || parsedRawStatus !== 'idle'
995
- || !!parsedModal
996
- || !!blockingModal
997
- || !this.parsedStatusHasFinalAssistantMessage(parsedStatus)
998
- ) {
999
- return false;
1000
- }
1001
- this.clearAllTimers();
1002
- this.clearIdleFinishCandidate(reason);
1003
- this.responseBuffer = '';
1004
- this.isWaitingForResponse = false;
1005
- this.responseSettleIgnoreUntil = 0;
1006
- this.submitRetryUsed = false;
1007
- this.submitRetryPromptSnippet = '';
1008
- this.finishRetryCount = 0;
1009
- this.currentTurnScope = null;
1010
- this.activeModal = null;
1011
- this.setStatus('idle', reason);
1012
- this.recordTrace('parsed_idle_response_cleared', {
1013
- reason,
1014
- parsedStatus: parsedRawStatus,
1015
- parsedMessageCount: Array.isArray(parsedStatus?.messages) ? parsedStatus.messages.length : 0,
1016
- });
1017
- return true;
1018
- }
753
+ // ─── Script dispatch — builds inputs for CliScriptRunner ──────────────────
1019
754
 
1020
- private hasMeaningfulResponseBuffer(promptSnippet: string): boolean {
1021
- const raw = String(this.responseBuffer || '').trim();
1022
- if (!raw) return false;
1023
- const normalizedPrompt = compactPromptText(promptSnippet);
1024
- if (!normalizedPrompt) return true;
1025
- const normalizedBuffer = compactPromptText(raw);
1026
- if (!normalizedBuffer) return false;
1027
- if (normalizedBuffer === normalizedPrompt) return false;
1028
- if (normalizedBuffer.startsWith(normalizedPrompt)) {
1029
- const remainder = normalizedBuffer
1030
- .slice(normalizedPrompt.length)
1031
- .replace(/[─═\-]+/g, '')
1032
- .replace(/⏵⏵accepteditson\([^)]*\)/gi, '')
1033
- .replace(/accepteditson\([^)]*\)/gi, '')
1034
- .replace(/(?:◐|◑|◒|◓|◔|◕|◉|●|·)?(?:x?high|medium|low|max)·?\/effort/gi, '')
1035
- .replace(/updateavailable!run:[a-z0-9:._\-/]+/gi, '')
1036
- .replace(/esctointerrupt/gi, '')
1037
- .replace(/❯/g, '')
1038
- .replace(/^[\s\-–—:;,.!/?]+/, '')
1039
- .trim();
1040
- return remainder.length > 0;
1041
- }
1042
- return true;
1043
- }
1044
-
1045
- private evaluateSettled(): void {
1046
- const now = Date.now();
1047
- if (this.submitPendingUntil > now || this.responseSettleIgnoreUntil > now) {
1048
- const delayTime = Math.max(this.submitPendingUntil - now, this.responseSettleIgnoreUntil - now) + 50;
1049
- if (this.settleTimer) clearTimeout(this.settleTimer);
1050
- this.settleTimer = setTimeout(() => {
1051
- this.settleTimer = null;
1052
- this.settledBuffer = this.recentOutputBuffer;
1053
- this.evaluateSettled();
1054
- }, delayTime);
1055
- return;
1056
- }
1057
-
1058
- this.resolveStartupState('settled');
1059
- if (this.startupParseGate) return;
1060
-
1061
- if (!this.isWaitingForResponse && !this.currentTurnScope && !this.activeModal && !this.parseErrorMessage) {
1062
- const tail = this.settledBuffer || this.recentOutputBuffer;
1063
- const modal = this.runParseApproval(tail);
1064
- const lightweightStatus = this.cliScripts?.detectStatus
1065
- ? this.runDetectStatus(tail)
1066
- : null;
1067
- if (!modal && lightweightStatus === 'idle' && this.currentStatus === 'idle') {
1068
- return;
1069
- }
1070
- }
1071
-
1072
- const session = this.runParseSession();
1073
- if (!session) return;
1074
-
1075
- const { status, messages, parsedStatus } = session;
1076
- const modal = (session as any).activeModal ?? session.modal ?? null;
1077
- const parsedMessages = normalizeCliParsedMessages(messages, {
1078
- scope: null,
1079
- lastOutputAt: this.lastOutputAt,
1080
- });
1081
-
1082
- if (this.maybeCommitVisibleIdleTranscript(session, parsedMessages)) return;
1083
-
1084
- const lastParsedAssistant = [...parsedMessages].reverse().find((m) => m.role === 'assistant');
1085
- const normalizedPromptSnippet = normalizePromptText(this.submitRetryPromptSnippet || this.currentTurnScope?.prompt || '');
1086
- const screenText = this.terminalScreen.getText() || '';
1087
-
1088
- this.recordTrace('settled', {
1089
- tail: summarizeCliTraceText(this.settledBuffer, 500),
1090
- screenText: summarizeCliTraceText(screenText, 1200),
1091
- detectStatus: status,
1092
- parsedStatus: parsedStatus || null,
1093
- parsedMessageCount: parsedMessages.length,
1094
- parsedLastAssistant: lastParsedAssistant ? summarizeCliTraceText(lastParsedAssistant.content, 280) : '',
1095
- parsedActiveModal: modal,
1096
- approval: modal,
1097
- ...buildCliTraceParseSnapshot({
1098
- accumulatedBuffer: this.accumulatedBuffer,
1099
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1100
- responseBuffer: this.responseBuffer,
1101
- partialResponse: this.responseBuffer,
1102
- scope: this.currentTurnScope,
1103
- }),
1104
- });
1105
-
1106
- if (
1107
- this.currentTurnScope
1108
- && !lastParsedAssistant
1109
- && !this.submitRetryUsed
1110
- && this.ptyProcess
1111
- && !this.hasActionableApproval()
1112
- && promptLikelyVisible(screenText, normalizedPromptSnippet)
1113
- && !this.hasMeaningfulResponseBuffer(normalizedPromptSnippet)
1114
- ) {
1115
- this.submitRetryUsed = true;
1116
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1117
- LOG.info('CLI', `[${this.cliType}] Retrying submit key from settled parser (no assistant yet)`);
1118
- this.recordTrace('submit_write', {
1119
- mode: 'settled_retry',
1120
- sendKey: this.sendKey,
1121
- screenText: summarizeCliTraceText(screenText, 500),
1122
- });
1123
- this.ptyProcess.write(this.sendKey);
1124
- if (this.settleTimer) clearTimeout(this.settleTimer);
1125
- this.settleTimer = setTimeout(() => {
1126
- this.settleTimer = null;
1127
- this.settledBuffer = this.recentOutputBuffer;
1128
- this.evaluateSettled();
1129
- }, this.timeouts.outputSettle + 150);
1130
- return;
1131
- }
1132
-
1133
- if (this.currentTurnScope && !lastParsedAssistant) {
1134
- LOG.debug(
1135
- 'CLI',
1136
- `[${this.cliType}] Settled without assistant: prompt=${JSON.stringify(this.currentTurnScope.prompt).slice(0, 140)} responseBuffer=${JSON.stringify(summarizeCliTraceText(this.responseBuffer, 220)).slice(0, 260)} screen=${JSON.stringify(summarizeCliTraceText(screenText, 220)).slice(0, 260)} providerDir=${this.providerResolutionMeta.providerDir || '-'} scriptDir=${this.providerResolutionMeta.scriptDir || '-'}`
1137
- );
1138
- }
1139
-
1140
- if (!status) return;
1141
-
1142
- const prevStatus = this.currentStatus;
1143
- const ctx: SettledEvalContext = { now, modal, status, parsedMessages, lastParsedAssistant, parsedStatus: parsedStatus || null, prevStatus };
1144
-
1145
- if (!this.applyPendingScriptStatusDebounce(ctx)) return;
1146
-
1147
- const recentInteractiveActivity = this.hasRecentInteractiveActivity(now);
1148
- LOG.debug(
1149
- 'CLI',
1150
- `[${this.cliType}] settled diagnostics prompt=${JSON.stringify(this.currentTurnScope?.prompt || '').slice(0, 140)} status=${String(status || '')} parsedStatus=${String(parsedStatus || '')} parsedMsgCount=${parsedMessages.length} lastParsedAssistant=${JSON.stringify(summarizeCliTraceText(lastParsedAssistant?.content || '', 120)).slice(0, 160)} responseBuffer=${JSON.stringify(summarizeCliTraceText(this.responseBuffer, 160)).slice(0, 220)} screen=${JSON.stringify(summarizeCliTraceText(screenText, 160)).slice(0, 220)}`
1151
- );
1152
-
1153
- const shouldHoldGenerating =
1154
- status === 'idle'
1155
- && this.isWaitingForResponse
1156
- && !modal
1157
- && recentInteractiveActivity
1158
- && !(parsedStatus === 'idle' && !!lastParsedAssistant);
1159
-
1160
- if (shouldHoldGenerating) { this.applyHoldGenerating(ctx, recentInteractiveActivity); return; }
1161
- if (status === 'error') {
1162
- if (this.maybeScheduleProviderErrorRetry(ctx, session)) return;
1163
- this.applyError(ctx, session);
1164
- return;
1165
- }
1166
- if (status === 'waiting_approval') { this.applyWaitingApproval(ctx); return; }
1167
- if (status === 'generating') { this.applyGenerating(ctx); return; }
1168
- if (status === 'idle') { this.applyIdle(ctx, now); }
1169
- }
1170
-
1171
- // Returns false if the caller should bail out (debounce pending).
1172
- private applyPendingScriptStatusDebounce(ctx: SettledEvalContext): boolean {
1173
- const { now, status, prevStatus } = ctx;
1174
- const shouldDebounce =
1175
- prevStatus === 'idle'
1176
- && !this.isWaitingForResponse
1177
- && !this.currentTurnScope
1178
- && (status === 'generating' || status === 'waiting_approval');
1179
-
1180
- if (!shouldDebounce) {
1181
- this.pendingScriptStatus = null;
1182
- this.pendingScriptStatusSince = 0;
1183
- if (this.pendingScriptStatusTimer) { clearTimeout(this.pendingScriptStatusTimer); this.pendingScriptStatusTimer = null; }
1184
- return true;
1185
- }
1186
-
1187
- const armPending = (delayMs: number) => {
1188
- if (this.pendingScriptStatusTimer) clearTimeout(this.pendingScriptStatusTimer);
1189
- this.pendingScriptStatusTimer = setTimeout(() => {
1190
- this.pendingScriptStatusTimer = null;
1191
- this.settledBuffer = this.recentOutputBuffer;
1192
- this.evaluateSettled();
1193
- }, delayMs);
1194
- };
1195
-
1196
- if (this.pendingScriptStatus !== status) {
1197
- this.pendingScriptStatus = status as 'generating' | 'waiting_approval';
1198
- this.pendingScriptStatusSince = now;
1199
- armPending(ProviderCliAdapter.SCRIPT_STATUS_DEBOUNCE_MS);
1200
- return false;
1201
- }
1202
- const elapsed = now - this.pendingScriptStatusSince;
1203
- if (elapsed < ProviderCliAdapter.SCRIPT_STATUS_DEBOUNCE_MS) {
1204
- armPending(ProviderCliAdapter.SCRIPT_STATUS_DEBOUNCE_MS - elapsed);
1205
- return false;
1206
- }
1207
- return true;
1208
- }
1209
-
1210
- private applyHoldGenerating(ctx: SettledEvalContext, recentInteractiveActivity: boolean): void {
1211
- const { status } = ctx;
1212
- this.clearIdleFinishCandidate('hold_generating_recent_activity');
1213
- this.setStatus('generating', 'recent_activity_hold');
1214
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1215
- this.idleTimeout = setTimeout(() => {
1216
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1217
- if (this.shouldDeferIdleTimeoutFinish()) return;
1218
- this.finishResponse();
1219
- }
1220
- }, this.timeouts.generatingIdle);
1221
- this.recordTrace('hold_generating_recent_activity', {
1222
- scriptStatus: status,
1223
- recentInteractiveActivity,
1224
- lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
1225
- lastScreenChangeAt: this.lastScreenChangeAt,
1226
- holdMs: this.getStatusActivityHoldMs(),
1227
- ...buildCliTraceParseSnapshot({
1228
- accumulatedBuffer: this.accumulatedBuffer,
1229
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1230
- responseBuffer: this.responseBuffer,
1231
- partialResponse: this.responseBuffer,
1232
- scope: this.currentTurnScope,
1233
- }),
1234
- });
1235
- this.onStatusChange?.();
1236
- }
1237
-
1238
- private applyWaitingApproval(ctx: SettledEvalContext): void {
1239
- const { modal } = ctx;
1240
- this.clearIdleFinishCandidate('waiting_approval');
1241
- const inCooldown = this.lastApprovalResolvedAt && (Date.now() - this.lastApprovalResolvedAt) < this.timeouts.approvalCooldown;
1242
- if (inCooldown && !modal) {
1243
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1244
- this.activeModal = null;
1245
- if (this.isWaitingForResponse) {
1246
- this.setStatus('idle', inCooldown ? 'approval_cooldown_non_actionable' : 'approval_prompt_gone_non_actionable');
1247
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1248
- this.idleTimeout = setTimeout(() => {
1249
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1250
- if (this.shouldDeferIdleTimeoutFinish()) return;
1251
- this.finishResponse();
1252
- }
1253
- }, this.timeouts.generatingIdle);
1254
- } else {
1255
- this.setStatus('idle', inCooldown ? 'approval_cooldown_non_actionable' : 'approval_prompt_gone_non_actionable');
1256
- }
1257
- this.onStatusChange?.();
1258
- return;
1259
- }
1260
- if (!inCooldown) {
1261
- if (!modal) {
1262
- LOG.warn('CLI', `[${this.cliType}] detectStatus reported waiting_approval without parseApproval modal; ignoring non-actionable approval state`);
1263
- return;
1264
- }
1265
- this.isWaitingForResponse = true;
1266
- this.setStatus('waiting_approval', 'script_detect');
1267
- this.activeModal = modal;
1268
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1269
- this.armApprovalExitTimeout();
1270
- this.onStatusChange?.();
1271
- }
1272
- }
1273
-
1274
- private applyGenerating(ctx: SettledEvalContext): void {
1275
- const { modal, parsedMessages, lastParsedAssistant, parsedStatus, prevStatus } = ctx;
1276
- this.clearIdleFinishCandidate('generating');
1277
- const screenText = this.terminalScreen.getText() || '';
1278
- const effectiveScreenText = screenText || this.accumulatedBuffer;
1279
- const noActiveTurn = !this.currentTurnScope;
1280
- const looksIdleChrome = /(^|\n)\s*[❯›>]\s*(?:\n|$)/m.test(effectiveScreenText);
1281
- const parsedShowsLiveAssistantProgress = parsedStatus === 'generating'
1282
- && !!lastParsedAssistant
1283
- ;
1284
- if (prevStatus === 'idle' && !this.isWaitingForResponse && noActiveTurn && !modal && looksIdleChrome && !parsedShowsLiveAssistantProgress) {
1285
- return;
1286
- }
1287
- if (prevStatus === 'waiting_approval') {
1288
- // Transitioned out of approval → generating
1289
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1290
- this.activeModal = null;
1291
- this.lastApprovalResolvedAt = Date.now();
1292
- }
1293
- if (!this.isWaitingForResponse) {
1294
- this.isWaitingForResponse = true;
1295
- this.responseBuffer = '';
1296
- }
1297
- this.setStatus('generating', 'script_detect');
1298
- // Reset idle timeout
1299
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1300
- this.idleTimeout = setTimeout(() => {
1301
- if (this.isWaitingForResponse) {
1302
- if (this.shouldDeferIdleTimeoutFinish()) return;
1303
- this.finishResponse();
1304
- }
1305
- }, this.timeouts.generatingIdle);
1306
- this.onStatusChange?.();
1307
- }
1308
-
1309
- private applyError(ctx: SettledEvalContext, session: ParsedSession): void {
1310
- this.clearIdleFinishCandidate('provider_error');
1311
- if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
1312
- if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
1313
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1314
- this.isWaitingForResponse = false;
1315
- this.responseSettleIgnoreUntil = 0;
1316
- this.submitRetryUsed = false;
1317
- this.submitRetryPromptSnippet = '';
1318
- this.finishRetryCount = 0;
1319
- this.currentTurnScope = null;
1320
- this.activeModal = null;
1321
- this.providerErrorMessage = typeof session.errorMessage === 'string' && session.errorMessage.trim()
1322
- ? session.errorMessage.trim()
1323
- : 'Provider reported an error';
1324
- this.providerErrorReason = typeof session.errorReason === 'string' && session.errorReason.trim()
1325
- ? session.errorReason.trim()
1326
- : 'provider_error';
1327
- this.setStatus('error', this.providerErrorReason);
1328
- this.recordTrace('provider_error', {
1329
- errorMessage: this.providerErrorMessage,
1330
- errorReason: this.providerErrorReason,
1331
- parsedStatus: ctx.parsedStatus || ctx.status,
1332
- messageCount: ctx.parsedMessages.length,
1333
- ...buildCliTraceParseSnapshot({
1334
- accumulatedBuffer: this.accumulatedBuffer,
1335
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1336
- responseBuffer: this.responseBuffer,
1337
- partialResponse: this.responseBuffer,
1338
- scope: this.currentTurnScope,
1339
- }),
1340
- });
1341
- this.onStatusChange?.();
1342
- }
1343
-
1344
- private maybeScheduleProviderErrorRetry(ctx: SettledEvalContext, session: ParsedSession): boolean {
1345
- const retryPrompt = typeof (session as any).retryPrompt === 'string'
1346
- ? String((session as any).retryPrompt).trim()
1347
- : '';
1348
- const retryDelayMs = typeof (session as any).retryDelayMs === 'number'
1349
- ? Number((session as any).retryDelayMs)
1350
- : NaN;
1351
- if (!retryPrompt || !Number.isFinite(retryDelayMs) || retryDelayMs < 0) return false;
1352
- if (!this.ptyProcess) return false;
1353
-
1354
- const retryAttempt = typeof (session as any).retryAttempt === 'number'
1355
- ? Number((session as any).retryAttempt)
1356
- : 0;
1357
- const retryMaxAttempts = typeof (session as any).retryMaxAttempts === 'number'
1358
- ? Number((session as any).retryMaxAttempts)
1359
- : 0;
1360
- const errorReason = typeof session.errorReason === 'string' && session.errorReason.trim()
1361
- ? session.errorReason.trim()
1362
- : 'provider_error';
1363
- const retryKey = `${errorReason}:${retryAttempt}:${retryPrompt}`;
1364
- if (this.providerErrorRetryTimer && this.providerErrorRetryKey === retryKey) return true;
1365
-
1366
- if (this.providerErrorRetryTimer) clearTimeout(this.providerErrorRetryTimer);
1367
- this.providerErrorRetryKey = retryKey;
1368
- this.clearIdleFinishCandidate('provider_error_retry');
1369
- if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
1370
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1371
- this.providerErrorMessage = typeof session.errorMessage === 'string' && session.errorMessage.trim()
1372
- ? session.errorMessage.trim()
1373
- : 'Provider reported an error';
1374
- this.providerErrorReason = errorReason;
1375
- this.activeModal = null;
1376
- this.responseSettleIgnoreUntil = Date.now() + retryDelayMs + this.timeouts.outputSettle + 400;
1377
- this.setStatus('generating', 'provider_error_retry_scheduled');
1378
- this.recordTrace('provider_error_retry_scheduled', {
1379
- retryPrompt,
1380
- retryDelayMs,
1381
- retryAttempt,
1382
- retryMaxAttempts,
1383
- errorReason,
1384
- parsedStatus: ctx.parsedStatus || ctx.status,
1385
- });
1386
- this.onStatusChange?.();
1387
- this.providerErrorRetryTimer = setTimeout(() => {
1388
- this.providerErrorRetryTimer = null;
1389
- this.providerErrorRetryKey = '';
1390
- if (!this.ptyProcess) return;
1391
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1392
- this.submitRetryUsed = false;
1393
- this.recordTrace('provider_error_retry_write', {
1394
- retryPrompt,
1395
- retryAttempt,
1396
- retryMaxAttempts,
1397
- errorReason,
1398
- });
1399
- this.ptyProcess.write(`${retryPrompt}\r`);
1400
- if (this.settleTimer) clearTimeout(this.settleTimer);
1401
- this.settleTimer = setTimeout(() => {
1402
- this.settleTimer = null;
1403
- this.settledBuffer = this.recentOutputBuffer;
1404
- this.evaluateSettled();
1405
- }, this.timeouts.outputSettle + 150);
1406
- }, retryDelayMs);
1407
- return true;
1408
- }
1409
-
1410
- private applyIdle(ctx: SettledEvalContext, now: number): void {
1411
- const { modal, lastParsedAssistant, prevStatus } = ctx;
1412
- if (prevStatus === 'waiting_approval') {
1413
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1414
- this.activeModal = null;
1415
- this.lastApprovalResolvedAt = Date.now();
1416
- this.setStatus('idle', 'approval_prompt_gone_script_idle');
1417
- }
1418
- if (!this.isWaitingForResponse) {
1419
- if (prevStatus !== 'idle') {
1420
- this.clearIdleFinishCandidate('idle_without_response');
1421
- this.setStatus('idle', 'script_detect');
1422
- this.onStatusChange?.();
1423
- }
1424
- return;
1425
- }
1426
- const quietForMs = this.lastNonEmptyOutputAt ? (now - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
1427
- const screenStableMs = this.lastScreenChangeAt ? (now - this.lastScreenChangeAt) : 0;
1428
- const hasAssistantTurn = !!lastParsedAssistant;
1429
- const assistantLength = lastParsedAssistant?.content?.length || 0;
1430
- const idleFinishConfirmMs = this.getIdleFinishConfirmMs();
1431
- const idleQuietThresholdMs = Math.max(idleFinishConfirmMs, this.timeouts.outputSettle);
1432
- const idleReady = !modal
1433
- && hasAssistantTurn
1434
- && quietForMs >= idleQuietThresholdMs
1435
- && screenStableMs >= idleFinishConfirmMs;
1436
- const candidate = this.idleFinishCandidate;
1437
- const candidateQuiet = !!candidate
1438
- && candidate.responseEpoch === this.responseEpoch
1439
- && candidate.lastOutputAt === this.lastOutputAt
1440
- && candidate.lastScreenChangeAt === this.lastScreenChangeAt
1441
- && assistantLength >= candidate.assistantLength
1442
- && (now - candidate.armedAt) >= idleFinishConfirmMs;
1443
-
1444
- this.recordTrace('idle_decision', {
1445
- quietForMs,
1446
- screenStableMs,
1447
- hasAssistantTurn,
1448
- assistantLength,
1449
- hasModal: !!modal,
1450
- idleQuietThresholdMs,
1451
- idleStableThresholdMs: idleFinishConfirmMs,
1452
- idleReady,
1453
- idleFinishConfirmMs,
1454
- idleFinishCandidate: candidate,
1455
- candidateQuiet,
1456
- canFinishImmediately: idleReady && candidateQuiet,
1457
- submitPendingUntil: this.submitPendingUntil,
1458
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
1459
- ...buildCliTraceParseSnapshot({
1460
- accumulatedBuffer: this.accumulatedBuffer,
1461
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1462
- responseBuffer: this.responseBuffer,
1463
- partialResponse: this.responseBuffer,
1464
- scope: this.currentTurnScope,
1465
- }),
755
+ runParseSession(): ParsedSession | null {
756
+ const screenText = this.terminalScreen.getText();
757
+ const parseScreenText = this.getParseScreenText(screenText);
758
+ const tail = this.recentOutputBuffer.slice(-500);
759
+ const input = buildCliParseInput({
760
+ accumulatedBuffer: this.accumulatedBuffer,
761
+ accumulatedRawBuffer: this.accumulatedRawBuffer,
762
+ recentOutputBuffer: this.recentOutputBuffer,
763
+ terminalScreenText: parseScreenText,
764
+ workingDir: this.workingDir,
765
+ providerSessionId: this.providerSessionId || undefined,
766
+ historySessionId: this.providerSessionId || undefined,
767
+ baseMessages: [],
768
+ partialResponse: this.responseBuffer,
769
+ isWaitingForResponse: this.engine.isWaitingForResponse,
770
+ scope: this.engine.currentTurnScope,
771
+ runtimeSettings: this.runtimeSettings,
772
+ spawnAt: this.spawnAt,
1466
773
  });
1467
-
1468
- if (idleReady && candidateQuiet) {
1469
- this.clearIdleFinishCandidate('finish_response');
1470
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1471
- this.finishResponse();
1472
- return;
1473
- }
1474
-
1475
- if (idleReady) {
1476
- if (!candidate) {
1477
- this.armIdleFinishCandidate(assistantLength);
1478
- return;
1479
- }
1480
- } else {
1481
- this.clearIdleFinishCandidate('idle_not_ready');
1482
- }
1483
-
1484
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1485
- this.idleTimeout = setTimeout(() => {
1486
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1487
- if (this.shouldDeferIdleTimeoutFinish()) return;
1488
- const parsed = this.runParseSession();
1489
- if (this.shouldKeepCodexTurnOpenForFinish(parsed)) {
1490
- this.rescheduleCodexFinishCheck('codex_idle_timeout_not_final');
1491
- return;
1492
- }
1493
- this.clearIdleFinishCandidate('idle_timeout_finish');
1494
- this.finishResponse();
1495
- }
1496
- }, this.timeouts.idleFinish);
1497
- }
1498
-
1499
- private finishResponse(): void {
1500
- if (this.submitPendingUntil > Date.now()) return;
1501
- if (this.responseSettleIgnoreUntil > Date.now()) return;
1502
- const parsedBeforeFinish = this.runParseSession();
1503
- if (this.shouldKeepCodexTurnOpenForFinish(parsedBeforeFinish)) {
1504
- this.rescheduleCodexFinishCheck('codex_finish_not_final');
1505
- return;
1506
- }
1507
- this.clearIdleFinishCandidate('finish_response_enter');
1508
- this.recordTrace('finish_response', {
1509
- ...buildCliTraceParseSnapshot({
1510
- accumulatedBuffer: this.accumulatedBuffer,
1511
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1512
- responseBuffer: this.responseBuffer,
1513
- partialResponse: this.responseBuffer,
1514
- scope: this.currentTurnScope,
1515
- }),
774
+ const session = this.runner.parseSession({
775
+ ...input,
776
+ tail,
777
+ tailScreen: buildCliScreenSnapshot(tail),
1516
778
  });
1517
- const commitResult = this.commitCurrentTranscript();
1518
- if (this.shouldRetryFinishResponse(commitResult)) {
1519
- this.finishRetryCount += 1;
1520
- this.recordTrace('finish_response_retry', {
1521
- retryCount: this.finishRetryCount,
1522
- retryDelayMs: ProviderCliAdapter.FINISH_RETRY_DELAY_MS,
1523
- assistantContent: summarizeCliTraceText(commitResult.assistantContent, 220),
1524
- ...buildCliTraceParseSnapshot({
1525
- accumulatedBuffer: this.accumulatedBuffer,
1526
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1527
- responseBuffer: this.responseBuffer,
1528
- partialResponse: this.responseBuffer,
1529
- scope: this.currentTurnScope,
1530
- }),
1531
- });
1532
- if (this.finishRetryTimer) clearTimeout(this.finishRetryTimer);
1533
- this.finishRetryTimer = setTimeout(() => {
1534
- this.finishRetryTimer = null;
1535
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1536
- this.finishResponse();
1537
- }
1538
- }, ProviderCliAdapter.FINISH_RETRY_DELAY_MS);
1539
- return;
1540
- }
1541
- this.clearAllTimers();
1542
- this.responseBuffer = '';
1543
- this.isWaitingForResponse = false;
1544
- this.responseSettleIgnoreUntil = 0;
1545
- this.submitRetryUsed = false;
1546
- this.submitRetryPromptSnippet = '';
1547
- this.finishRetryCount = 0;
1548
- this.currentTurnScope = null;
1549
- this.activeModal = null;
1550
- this.setStatus('idle', 'response_finished');
1551
- this.onStatusChange?.();
1552
- this.schedulePendingOutboundFlush();
779
+ if (session && typeof session === 'object') this.applyParsedSessionMetadata(session);
780
+ return session;
1553
781
  }
1554
782
 
1555
- private maybeCommitVisibleIdleTranscript(session: ParsedSession, parsedMessages: CliChatMessage[]): boolean {
1556
- const allowImmediateScriptIdleCommit = this.provider.allowInputDuringGeneration === true;
1557
- if (!allowImmediateScriptIdleCommit) return false;
1558
- if (
1559
- !session
1560
- || session.status !== 'idle'
1561
- || !this.isWaitingForResponse
1562
- || !this.currentTurnScope
1563
- || this.activeModal
1564
- || session.modal
1565
- ) {
1566
- return false;
1567
- }
1568
-
1569
- const visibleAssistant = [...parsedMessages].reverse().find((m) => m.role === 'assistant' && m.content.trim());
1570
- if (!visibleAssistant) return false;
1571
-
1572
- this.clearAllTimers();
1573
- this.responseBuffer = '';
1574
- this.isWaitingForResponse = false;
1575
- this.responseSettleIgnoreUntil = 0;
1576
- this.submitRetryUsed = false;
1577
- this.submitRetryPromptSnippet = '';
1578
- this.finishRetryCount = 0;
1579
- this.currentTurnScope = null;
1580
- this.activeModal = null;
1581
- this.setStatus('idle', 'script_idle_commit');
1582
- this.onStatusChange?.();
1583
- this.schedulePendingOutboundFlush();
1584
- this.recordTrace('script_idle_commit', {
1585
- messageCount: parsedMessages.length,
1586
- lastAssistant: summarizeCliTraceText(visibleAssistant.content, 320),
783
+ runDetectStatus(text: string): string | null {
784
+ const screenText = this.terminalScreen.getText();
785
+ const tail = text.slice(-500);
786
+ return this.runner.detectStatus({
787
+ tail,
788
+ screenText,
789
+ rawBuffer: this.accumulatedRawBuffer,
790
+ isWaitingForResponse: this.engine.isWaitingForResponse,
791
+ screen: buildCliScreenSnapshot(screenText),
792
+ tailScreen: buildCliScreenSnapshot(tail),
1587
793
  });
1588
- return true;
1589
- }
1590
-
1591
- private commitCurrentTranscript(): { hasAssistant: boolean; assistantContent: string } {
1592
- const parsed = this.parseCurrentTranscript(
1593
- [],
1594
- this.responseBuffer,
1595
- this.currentTurnScope,
1596
- );
1597
- if (parsed && Array.isArray(parsed.messages)) {
1598
- const parsedMessages = normalizeCliParsedMessages(parsed.messages, {
1599
- scope: null,
1600
- lastOutputAt: this.lastOutputAt,
1601
- });
1602
- const lastAssistant = [...parsedMessages].reverse().find((message) => message.role === 'assistant');
1603
- if (this.currentTurnScope) {
1604
- LOG.info(
1605
- 'CLI',
1606
- `[${this.cliType}] commitCurrentTranscript parserMessages=${parsedMessages.length} finalLastAssistant=${JSON.stringify(summarizeCliTraceText(lastAssistant?.content || '', 220)).slice(0, 260)}`
1607
- );
1608
- }
1609
- this.recordTrace('commit_transcript', {
1610
- parsedStatus: parsed.status || null,
1611
- messageCount: parsedMessages.length,
1612
- lastAssistant: lastAssistant ? summarizeCliTraceText(lastAssistant.content, 320) : '',
1613
- messages: summarizeCliTraceMessages(parsedMessages),
1614
- ...buildCliTraceParseSnapshot({
1615
- accumulatedBuffer: this.accumulatedBuffer,
1616
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1617
- responseBuffer: this.responseBuffer,
1618
- partialResponse: this.responseBuffer,
1619
- scope: this.currentTurnScope,
1620
- }),
1621
- });
1622
- if (!lastAssistant && this.currentTurnScope) {
1623
- LOG.warn(
1624
- 'CLI',
1625
- `[${this.cliType}] Commit without assistant turn: prompt=${JSON.stringify(this.currentTurnScope.prompt).slice(0, 140)} responseBuffer=${JSON.stringify(summarizeCliTraceText(this.responseBuffer, 220)).slice(0, 260)} providerDir=${this.providerResolutionMeta.providerDir || '-'} scriptDir=${this.providerResolutionMeta.scriptDir || '-'} scriptsPath=${this.providerResolutionMeta.scriptsPath || '-'}`
1626
- );
1627
- }
1628
- const hasAssistant = !!lastAssistant;
1629
- return {
1630
- hasAssistant,
1631
- assistantContent: lastAssistant?.content || '',
1632
- };
1633
- }
1634
- if (this.currentTurnScope) {
1635
- LOG.info(
1636
- 'CLI',
1637
- `[${this.cliType}] commitCurrentTranscript parsed.messages=none responseBufferLen=${this.responseBuffer.length} accumulatedBufferLen=${this.accumulatedBuffer.length} parsedStatus=${parsed?.status || '-'} providerDir=${this.providerResolutionMeta.providerDir || '-'} scriptDir=${this.providerResolutionMeta.scriptDir || '-'}`
1638
- );
1639
- }
1640
- return {
1641
- hasAssistant: false,
1642
- assistantContent: '',
1643
- };
1644
- }
1645
-
1646
-
1647
- // ─── Script Execution ──────────────────────────
1648
-
1649
- private invokeCliScript<T>(script: Function, input: any): T {
1650
- const hasStateFactory = typeof this.cliScripts?.createState === 'function';
1651
- const expectsStateArgument = hasStateFactory || this.scriptState !== null || script.length >= 2;
1652
- return expectsStateArgument
1653
- ? script(this.scriptState, input)
1654
- : script(input);
1655
- }
1656
-
1657
- private runParseSession(): ParsedSession | null {
1658
- if (typeof this.cliScripts?.parseSession !== 'function') {
1659
- this.parseErrorMessage = `${this.cliType} parseSession unavailable`;
1660
- return null;
1661
- }
1662
- try {
1663
- const screenText = this.terminalScreen.getText();
1664
- const parseScreenText = this.getParseScreenText(screenText);
1665
- const tail = this.recentOutputBuffer.slice(-500);
1666
- const input = buildCliParseInput({
1667
- accumulatedBuffer: this.accumulatedBuffer,
1668
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1669
- recentOutputBuffer: this.recentOutputBuffer,
1670
- terminalScreenText: parseScreenText,
1671
- workingDir: this.workingDir,
1672
- providerSessionId: this.providerSessionId || undefined,
1673
- historySessionId: this.providerSessionId || undefined,
1674
- baseMessages: [],
1675
- partialResponse: this.responseBuffer,
1676
- isWaitingForResponse: this.isWaitingForResponse,
1677
- scope: this.currentTurnScope,
1678
- runtimeSettings: this.runtimeSettings,
1679
- });
1680
- const session = this.invokeCliScript<ParsedSession | null>(
1681
- this.cliScripts.parseSession,
1682
- { ...input, tail, tailScreen: buildCliScreenSnapshot(tail) },
1683
- );
1684
- this.parseErrorMessage = null;
1685
- if (session && typeof session === 'object') this.applyParsedSessionMetadata(session);
1686
- return session && typeof session === 'object' ? session : null;
1687
- } catch (e: any) {
1688
- const message = e?.message || String(e);
1689
- this.parseErrorMessage = message;
1690
- LOG.warn('CLI', `[${this.cliType}] parseSession error: ${message}`);
1691
- return null;
1692
- }
1693
794
  }
1694
795
 
1695
- private runDetectStatus(text: string): string | null {
1696
- if (!this.cliScripts?.detectStatus) return null;
1697
- try {
1698
- const screenText = this.terminalScreen.getText();
1699
- const status = this.invokeCliScript<string | null>(this.cliScripts.detectStatus, {
1700
- tail: text.slice(-500),
1701
- screenText,
1702
- rawBuffer: this.accumulatedRawBuffer,
1703
- isWaitingForResponse: this.isWaitingForResponse,
1704
- screen: buildCliScreenSnapshot(screenText),
1705
- tailScreen: buildCliScreenSnapshot(text.slice(-500)),
1706
- });
1707
- return status;
1708
- } catch (e: any) {
1709
- LOG.warn('CLI', `[${this.cliType}] detectStatus error: ${e.message}`);
1710
- return null;
1711
- }
1712
- }
1713
-
1714
- private runParseApproval(tail: string): { message: string; buttons: string[] } | null {
1715
- if (!this.cliScripts?.parseApproval) return null;
1716
- try {
1717
- const screenText = this.terminalScreen.getText();
1718
- const buffer = screenText || this.accumulatedBuffer;
1719
- return this.invokeCliScript<{ message: string; buttons: string[] } | null>(this.cliScripts.parseApproval, {
1720
- buffer,
1721
- screenText,
1722
- rawBuffer: this.accumulatedRawBuffer,
1723
- tail,
1724
- screen: buildCliScreenSnapshot(screenText),
1725
- bufferScreen: buildCliScreenSnapshot(buffer),
1726
- tailScreen: buildCliScreenSnapshot(tail),
1727
- });
1728
- } catch (e: any) {
1729
- LOG.warn('CLI', `[${this.cliType}] parseApproval error: ${e.message}`);
1730
- return null;
1731
- }
1732
- }
1733
-
1734
- private hasActionableApproval(startupModal: { message: string; buttons: string[] } | null = null): boolean {
1735
- return !!(startupModal || this.activeModal);
1736
- }
1737
-
1738
- private parsedStatusHasFinalAssistantMessage(parsed: any): boolean {
1739
- const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
1740
- const lastAssistant = [...messages].reverse().find((message: any) => {
1741
- if (!message || message.role !== 'assistant') return false;
1742
- return typeof message.content === 'string' && message.content.trim().length > 0;
796
+ runParseApproval(tail: string): { message: string; buttons: string[] } | null {
797
+ const screenText = this.terminalScreen.getText();
798
+ const buffer = screenText || this.accumulatedBuffer;
799
+ return this.runner.parseApproval({
800
+ buffer,
801
+ screenText,
802
+ rawBuffer: this.accumulatedRawBuffer,
803
+ tail,
804
+ screen: buildCliScreenSnapshot(screenText),
805
+ bufferScreen: buildCliScreenSnapshot(buffer),
806
+ tailScreen: buildCliScreenSnapshot(tail),
1743
807
  });
1744
- return !!lastAssistant;
1745
808
  }
1746
809
 
1747
810
  private applyParsedSessionMetadata(parsed: any): void {
1748
811
  const providerSessionId = typeof parsed?.providerSessionId === 'string' && parsed.providerSessionId.trim()
1749
812
  ? parsed.providerSessionId.trim()
1750
813
  : '';
1751
- if (providerSessionId) {
814
+ if (providerSessionId && providerSessionId !== this.providerSessionId) {
1752
815
  this.providerSessionId = providerSessionId;
1753
816
  this.updateRuntimeMeta({ providerSessionId });
1754
817
  }
1755
- this.providerErrorMessage = typeof parsed?.errorMessage === 'string' && parsed.errorMessage.trim()
1756
- ? parsed.errorMessage.trim()
1757
- : null;
1758
- this.providerErrorReason = typeof parsed?.errorReason === 'string' && parsed.errorReason.trim()
1759
- ? parsed.errorReason.trim()
1760
- : null;
1761
- }
1762
-
1763
- private parsedStatusHasFinalStandardAssistantMessage(parsed: any): boolean {
1764
- const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
1765
- const lastAssistant = [...messages].reverse().find((message: any) => {
1766
- if (!message || message.role !== 'assistant') return false;
1767
- return typeof message.content === 'string' && message.content.trim().length > 0;
1768
- });
1769
- if (!lastAssistant) return false;
1770
- const kind = typeof lastAssistant.kind === 'string' && lastAssistant.kind.trim()
1771
- ? lastAssistant.kind.trim()
1772
- : 'standard';
1773
- return kind === 'standard' && lastAssistant.meta?.streaming !== true;
1774
- }
1775
-
1776
- private shouldKeepCodexTurnOpenForFinish(parsed: any): boolean {
1777
- if (this.cliType !== 'codex-cli') return false;
1778
- if (!this.isWaitingForResponse || !this.currentTurnScope || this.hasActionableApproval()) return false;
1779
- const parsedStatus = typeof parsed?.status === 'string' ? parsed.status.trim() : '';
1780
- if (parsedStatus !== 'idle') return true;
1781
- if (parsed?.activeModal || parsed?.modal) return true;
1782
- return !this.parsedStatusHasFinalStandardAssistantMessage(parsed);
1783
- }
1784
-
1785
- private rescheduleCodexFinishCheck(reason: string): void {
1786
- this.clearIdleFinishCandidate(reason);
1787
- this.setStatus('generating', reason);
1788
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1789
- this.idleTimeout = setTimeout(() => {
1790
- if (!this.isWaitingForResponse || this.hasActionableApproval()) return;
1791
- this.settledBuffer = this.recentOutputBuffer;
1792
- this.evaluateSettled();
1793
- }, this.getIdleFinishConfirmMs());
1794
- this.recordTrace('codex_finish_deferred', {
1795
- reason,
1796
- ...buildCliTraceParseSnapshot({
1797
- accumulatedBuffer: this.accumulatedBuffer,
1798
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1799
- responseBuffer: this.responseBuffer,
1800
- partialResponse: this.responseBuffer,
1801
- scope: this.currentTurnScope,
1802
- }),
1803
- });
1804
818
  }
1805
819
 
1806
820
  private projectEffectiveStatus(startupModal: { message: string; buttons: string[] } | null = null): CliSessionStatus['status'] {
1807
821
  if (this.parseErrorMessage) return 'error';
1808
- if (this.hasActionableApproval(startupModal)) return 'waiting_approval';
1809
- if (this.isWaitingForResponse && this.currentTurnScope && this.currentStatus !== 'stopped') return 'generating';
1810
- return this.currentStatus;
822
+ if (!!(startupModal || this.engine.activeModal)) return 'waiting_approval';
823
+ if (this.engine.isWaitingForResponse && this.engine.currentTurnScope && this.engine.currentStatus !== 'stopped') return 'generating';
824
+ return this.engine.currentStatus;
1811
825
  }
1812
826
 
1813
827
  // ─── Public API (CliAdapter) ───────────────────
@@ -1819,8 +833,43 @@ export class ProviderCliAdapter implements CliAdapter {
1819
833
  ? this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText())
1820
834
  : null;
1821
835
  let effectiveStatus = this.projectEffectiveStatus(startupModal);
1822
- let effectiveModal = startupModal || this.activeModal;
1823
- if (startupDetectedStatus === 'waiting_approval') {
836
+ let effectiveModal = startupModal || this.engine.activeModal;
837
+ // (fix) When we have no captured modal yet, take one more live attempt
838
+ // with the current screen text — the engine's settle pass can miss
839
+ // the modal when it happens to fire exactly between writes, and
840
+ // without a modal here the dashboard could never show the buttons.
841
+ // We deliberately do NOT overwrite an existing engine.activeModal so
842
+ // a stable matched modal wins. This runs even outside the startup
843
+ // gate because Claude's approval frames can appear long after launch.
844
+ if (allowParse && !effectiveModal && this.engine.isWaitingForResponse) {
845
+ const liveDetect = this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText());
846
+ if (liveDetect === 'waiting_approval') {
847
+ const liveModal = this.runParseApproval(this.terminalScreen.getText())
848
+ || this.runParseApproval(this.recentOutputBuffer);
849
+ if (liveModal) {
850
+ effectiveModal = liveModal;
851
+ // Promote so subsequent calls don't re-walk the buffer.
852
+ // Only set if engine hasn't already captured one — keeps
853
+ // the first stable modal as authoritative.
854
+ if (!this.engine.activeModal) this.engine.activeModal = liveModal;
855
+ } else {
856
+ LOG.warn('CLI', `[${this.cliType}] getStatus live re-extract: detect=waiting_approval but parseApproval still null (recentLen=${this.recentOutputBuffer.length} screenLen=${this.terminalScreen.getText().length})`);
857
+ }
858
+ } else if (liveDetect && liveDetect !== 'generating' && liveDetect !== 'idle') {
859
+ LOG.warn('CLI', `[${this.cliType}] getStatus live re-extract: detect=${liveDetect} (not waiting_approval)`);
860
+ } else if (this.engine.currentStatus === 'waiting_approval' && liveDetect !== 'waiting_approval') {
861
+ LOG.warn('CLI', `[${this.cliType}] getStatus live re-extract: engine.status=waiting_approval but live detect=${liveDetect}`);
862
+ }
863
+ } else if (!effectiveModal && this.engine.currentStatus === 'waiting_approval') {
864
+ LOG.warn('CLI', `[${this.cliType}] getStatus skipped live re-extract: allowParse=${allowParse} isWaitingForResponse=${this.engine.isWaitingForResponse}`);
865
+ }
866
+ // Only surface waiting_approval when we ALSO have a concrete modal
867
+ // (message + buttons). detectStatus alone can fire while parseApproval
868
+ // is still null — the engine logs "detectStatus=waiting_approval but
869
+ // parseApproval returned null; ignoring". Without this guard getStatus
870
+ // was shipping a bare waiting_approval with activeModal=null and the
871
+ // user perceived the flow as broken.
872
+ if (startupDetectedStatus === 'waiting_approval' && effectiveModal) {
1824
873
  effectiveStatus = 'waiting_approval';
1825
874
  } else if (startupDetectedStatus === 'idle' && !startupModal && !effectiveModal) {
1826
875
  effectiveStatus = 'idle';
@@ -1831,19 +880,23 @@ export class ProviderCliAdapter implements CliAdapter {
1831
880
  && parsed.activeModal.buttons.some((button: any) => typeof button === 'string' && button.trim())
1832
881
  ? parsed.activeModal
1833
882
  : null;
883
+ const hasFinalAssistant = (p: any) => {
884
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
885
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
886
+ };
1834
887
  if (parsed?.status === 'waiting_approval' && parsedModal) {
1835
888
  effectiveStatus = 'waiting_approval';
1836
889
  effectiveModal = parsedModal;
1837
890
  } else if (
1838
891
  effectiveStatus === 'idle'
1839
892
  && parsed?.status === 'generating'
1840
- && !this.parsedStatusHasFinalAssistantMessage(parsed)
893
+ && !hasFinalAssistant(parsed)
1841
894
  ) {
1842
895
  effectiveStatus = 'generating';
1843
896
  } else if (
1844
897
  effectiveStatus === 'generating'
1845
898
  && parsed?.status === 'idle'
1846
- && this.parsedStatusHasFinalAssistantMessage(parsed)
899
+ && hasFinalAssistant(parsed)
1847
900
  ) {
1848
901
  effectiveStatus = 'idle';
1849
902
  }
@@ -1862,8 +915,8 @@ export class ProviderCliAdapter implements CliAdapter {
1862
915
  queuedAt: message.queuedAt,
1863
916
  source: message.source,
1864
917
  })),
1865
- errorMessage: this.parseErrorMessage || this.providerErrorMessage || undefined,
1866
- errorReason: this.parseErrorMessage ? 'parse_error' : (this.providerErrorReason || undefined),
918
+ errorMessage: this.parseErrorMessage || this.engine.providerErrorMessage || undefined,
919
+ errorReason: this.parseErrorMessage ? 'parse_error' : (this.engine.providerErrorReason || undefined),
1867
920
  providerSessionId: this.providerSessionId || undefined,
1868
921
  ...(bufferState ? { bufferState } : {}),
1869
922
  };
@@ -1883,13 +936,13 @@ export class ProviderCliAdapter implements CliAdapter {
1883
936
  !this.providerOwnsTranscript()
1884
937
  && cached
1885
938
  && cached.responseBuffer === this.responseBuffer
1886
- && cached.currentTurnScope === this.currentTurnScope
939
+ && cached.currentTurnScope === this.engine.currentTurnScope
1887
940
  && cached.recentOutputBuffer === this.recentOutputBuffer
1888
941
  && cached.accumulatedBuffer === this.accumulatedBuffer
1889
942
  && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
1890
943
  && cached.screenText === parseScreenText
1891
- && cached.currentStatus === this.currentStatus
1892
- && cached.activeModal === this.activeModal
944
+ && cached.currentStatus === this.engine.currentStatus
945
+ && cached.activeModal === this.engine.activeModal
1893
946
  && cached.cliName === this.cliName
1894
947
  ) {
1895
948
  return cached.result;
@@ -1904,7 +957,7 @@ export class ProviderCliAdapter implements CliAdapter {
1904
957
  const bufferState = this.getBufferState();
1905
958
  const result = {
1906
959
  id: (parsed as any).id || 'cli_session',
1907
- status: parsed.status || this.currentStatus,
960
+ status: parsed.status || this.engine.currentStatus,
1908
961
  title: (parsed as any).title || this.cliName,
1909
962
  messages: normalizeCliParsedMessages(parsed.messages, {
1910
963
  scope: null,
@@ -1929,13 +982,13 @@ export class ProviderCliAdapter implements CliAdapter {
1929
982
 
1930
983
  this.parsedStatusCache = {
1931
984
  responseBuffer: this.responseBuffer,
1932
- currentTurnScope: this.currentTurnScope,
985
+ currentTurnScope: this.engine.currentTurnScope,
1933
986
  recentOutputBuffer: this.recentOutputBuffer,
1934
987
  accumulatedBuffer: this.accumulatedBuffer,
1935
988
  accumulatedRawBufferKey,
1936
989
  screenText: parseScreenText,
1937
- currentStatus: this.currentStatus,
1938
- activeModal: this.activeModal,
990
+ currentStatus: this.engine.currentStatus,
991
+ activeModal: this.engine.activeModal,
1939
992
  cliName: this.cliName,
1940
993
  result,
1941
994
  };
@@ -1943,10 +996,6 @@ export class ProviderCliAdapter implements CliAdapter {
1943
996
  }
1944
997
 
1945
998
  async invokeScript(scriptName: string, args?: Record<string, any>): Promise<any> {
1946
- const fn = this.cliScripts?.[scriptName];
1947
- if (typeof fn !== 'function') {
1948
- throw new Error(`CLI script '${scriptName}' not available`);
1949
- }
1950
999
  const input = buildCliParseInput({
1951
1000
  accumulatedBuffer: this.accumulatedBuffer,
1952
1001
  accumulatedRawBuffer: this.accumulatedRawBuffer,
@@ -1957,11 +1006,12 @@ export class ProviderCliAdapter implements CliAdapter {
1957
1006
  historySessionId: this.providerSessionId || undefined,
1958
1007
  baseMessages: [],
1959
1008
  partialResponse: this.responseBuffer,
1960
- isWaitingForResponse: this.isWaitingForResponse,
1961
- scope: this.currentTurnScope,
1009
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1010
+ scope: this.engine.currentTurnScope,
1962
1011
  runtimeSettings: this.runtimeSettings,
1012
+ spawnAt: this.spawnAt,
1963
1013
  });
1964
- return await Promise.resolve(this.invokeCliScript(fn, {
1014
+ return await Promise.resolve(this.runner.invokeByName(scriptName, {
1965
1015
  ...input,
1966
1016
  args: args && typeof args === 'object' ? { ...args } : {},
1967
1017
  }));
@@ -1973,7 +1023,7 @@ export class ProviderCliAdapter implements CliAdapter {
1973
1023
 
1974
1024
  /** Whether this adapter has CLI scripts loaded */
1975
1025
  hasCliScripts(): boolean {
1976
- return typeof this.cliScripts?.detectStatus === 'function';
1026
+ return this.runner.hasDetectStatus();
1977
1027
  }
1978
1028
 
1979
1029
  /**
@@ -1982,24 +1032,24 @@ export class ProviderCliAdapter implements CliAdapter {
1982
1032
  */
1983
1033
  async resolveAction(data: any): Promise<void> {
1984
1034
  let promptText = '';
1985
- if (this.cliScripts && typeof this.cliScripts.resolveAction === 'function') {
1986
- try {
1987
- promptText = this.cliScripts.resolveAction(data);
1988
- } catch (e: any) {
1989
- LOG.warn('CLI', `[${this.cliType}] resolveAction error: ${e.message}`);
1990
- }
1035
+ try {
1036
+ promptText = this.runner.invokeByName('resolveAction', data);
1037
+ } catch {
1038
+ LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script not available`);
1039
+ return;
1991
1040
  }
1992
1041
  if (!promptText) {
1993
- LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script did not supply a prompt`);
1042
+ LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script did not return a prompt`);
1994
1043
  return;
1995
1044
  }
1996
1045
  await this.sendMessage(promptText);
1997
1046
  }
1998
1047
 
1999
1048
  private isSubmitStuck(normalizedPromptSnippet: string): boolean {
2000
- if (!this.ptyProcess || !this.isWaitingForResponse || this.submitRetryUsed) return false;
2001
- if (this.hasActionableApproval()) return false;
2002
- if (this.hasMeaningfulResponseBuffer(normalizedPromptSnippet)) return false;
1049
+ if (!this.ptyProcess || !this.engine.isWaitingForResponse || this.engine.submitRetryUsed) return false;
1050
+ if (this.engine.hasActionableApproval()) return false;
1051
+ // If there's already meaningful response content beyond the echoed prompt, not stuck
1052
+ if (this.hasMeaningfulResponseBufferLocal(normalizedPromptSnippet)) return false;
2003
1053
  const screenText = this.terminalScreen.getText();
2004
1054
  if (!promptLikelyVisible(screenText, normalizedPromptSnippet)) return false;
2005
1055
  const liveApproval = this.runParseApproval(screenText) || this.runParseApproval(this.recentOutputBuffer);
@@ -2008,20 +1058,41 @@ export class ProviderCliAdapter implements CliAdapter {
2008
1058
  return liveStatus !== 'generating' && liveStatus !== 'waiting_approval';
2009
1059
  }
2010
1060
 
1061
+ private hasMeaningfulResponseBufferLocal(promptSnippet: string): boolean {
1062
+ const raw = String(this.responseBuffer || '').trim();
1063
+ if (!raw) return false;
1064
+ const normalizedPrompt = compactPromptText(promptSnippet);
1065
+ if (!normalizedPrompt) return true;
1066
+ const normalizedBuffer = compactPromptText(raw);
1067
+ if (!normalizedBuffer) return false;
1068
+ if (normalizedBuffer === normalizedPrompt) return false;
1069
+ if (normalizedBuffer.startsWith(normalizedPrompt)) {
1070
+ const remainder = normalizedBuffer
1071
+ .slice(normalizedPrompt.length)
1072
+ .replace(/[─═\-]+/g, '')
1073
+ .replace(/⏵⏵accepteditson\([^)]*\)/gi, '')
1074
+ .replace(/accepteditson\([^)]*\)/gi, '')
1075
+ .replace(/(?:◐|◑|◒|◓|◔|◕|◉|●|·)?(?:x?high|medium|low|max)·?\/effort/gi, '')
1076
+ .replace(/updateavailable!run:[a-z0-9:._\-/]+/gi, '')
1077
+ .replace(/esctointerrupt/gi, '')
1078
+ .replace(/❯/g, '')
1079
+ .replace(/^[\s\-–—:;,.!/?]+/, '')
1080
+ .trim();
1081
+ return remainder.length > 0;
1082
+ }
1083
+ return true;
1084
+ }
1085
+
2011
1086
  private async writeToPty(data: string): Promise<void> {
2012
1087
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
2013
1088
  await this.ptyProcess.write(data);
2014
1089
  }
2015
1090
 
2016
1091
  private resetPendingSendState(reason: string): void {
2017
- this.isWaitingForResponse = false;
2018
1092
  this.responseBuffer = '';
2019
- this.currentTurnScope = null;
2020
- this.submitPendingUntil = 0;
2021
- this.clearIdleFinishCandidate(reason);
2022
1093
  if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
2023
- if (this.submitRetryTimer) { clearTimeout(this.submitRetryTimer); this.submitRetryTimer = null; }
2024
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
1094
+ this.engine.resetActiveTurnState();
1095
+ this.engine.clearIdleFinishCandidate(reason);
2025
1096
  }
2026
1097
 
2027
1098
  private commitSendUserTurn(state: SendMessageState): void {
@@ -2038,42 +1109,14 @@ export class ProviderCliAdapter implements CliAdapter {
2038
1109
  }
2039
1110
  this.responseTimeout = setTimeout(() => {
2040
1111
  this.responseTimeout = null;
2041
- if (!this.isWaitingForResponse) return;
2042
-
2043
- const detectedStatusBeforeEval = this.runDetectStatus(this.recentOutputBuffer);
2044
- this.recordTrace('response_timeout_check', {
2045
- timeoutMs,
2046
- detectedStatus: detectedStatusBeforeEval,
2047
- currentStatus: this.currentStatus,
2048
- isWaitingForResponse: this.isWaitingForResponse,
2049
- hasActionableApproval: this.hasActionableApproval(),
2050
- ...buildCliTraceParseSnapshot({
2051
- accumulatedBuffer: this.accumulatedBuffer,
2052
- accumulatedRawBuffer: this.accumulatedRawBuffer,
2053
- responseBuffer: this.responseBuffer,
2054
- partialResponse: this.responseBuffer,
2055
- scope: this.currentTurnScope,
2056
- }),
2057
- });
1112
+ if (!this.engine.isWaitingForResponse) return;
2058
1113
 
2059
- // maxResponse is a watchdog/checkpoint, not a completion signal. The old
2060
- // behavior called finishResponse() unconditionally at the default 300s,
2061
- // which fabricated idle transitions and downstream generating_completed
2062
- // notifications while long-running CLIs were still generating. Re-run the
1114
+ // maxResponse is a watchdog/checkpoint, not a completion signal. Re-run the
2063
1115
  // normal settled parser instead and keep the turn open unless the provider
2064
1116
  // actually reports an idle, commit-ready state.
2065
- this.settledBuffer = this.recentOutputBuffer;
2066
- this.evaluateSettled();
2067
-
2068
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
2069
- const detectedStatusAfterEval = this.runDetectStatus(this.recentOutputBuffer);
2070
- this.recordTrace('response_timeout_kept_open', {
2071
- timeoutMs,
2072
- detectedStatusBeforeEval,
2073
- detectedStatusAfterEval,
2074
- currentStatus: this.currentStatus,
2075
- isWaitingForResponse: this.isWaitingForResponse,
2076
- });
1117
+ this.engine.evaluateSettled(this.getSnapshot());
1118
+
1119
+ if (this.engine.isWaitingForResponse && !this.engine.hasActionableApproval()) {
2077
1120
  this.armResponseTimeout();
2078
1121
  }
2079
1122
  }, timeoutMs);
@@ -2088,34 +1131,20 @@ export class ProviderCliAdapter implements CliAdapter {
2088
1131
  private retrySubmitIfStuck(state: SendMessageState, attempt: number): void {
2089
1132
  this.submitRetryTimer = null;
2090
1133
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
2091
- const screenText = this.terminalScreen.getText();
2092
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1134
+ this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
2093
1135
  LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt ${attempt})`);
2094
- this.recordTrace('submit_write', {
2095
- mode: 'submit_retry',
2096
- attempt,
2097
- sendKey: this.sendKey,
2098
- screenText: summarizeCliTraceText(screenText, 500),
2099
- });
2100
1136
  this.writeSubmitKeyForRetry('submit_retry');
2101
- if (attempt >= 3) { this.submitRetryUsed = true; return; }
1137
+ if (attempt >= 3) { this.engine.submitRetryUsed = true; return; }
2102
1138
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, attempt + 1), state.retryDelayMs);
2103
1139
  }
2104
1140
 
2105
1141
  private retryImmediateSubmitIfStuck(state: SendMessageState): void {
2106
1142
  this.submitRetryTimer = null;
2107
1143
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
2108
- const screenText = this.terminalScreen.getText();
2109
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1144
+ this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
2110
1145
  LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt 1)`);
2111
- this.recordTrace('submit_write', {
2112
- mode: 'immediate_retry',
2113
- attempt: 1,
2114
- sendKey: this.sendKey,
2115
- screenText: summarizeCliTraceText(screenText, 500),
2116
- });
2117
1146
  this.writeSubmitKeyForRetry('immediate_retry');
2118
- this.submitRetryUsed = true;
1147
+ this.engine.submitRetryUsed = true;
2119
1148
  }
2120
1149
 
2121
1150
  private submitSendKey(state: SendMessageState, completion: SendMessageCompletion): void {
@@ -2123,13 +1152,7 @@ export class ProviderCliAdapter implements CliAdapter {
2123
1152
  completion.resolveOnce();
2124
1153
  return;
2125
1154
  }
2126
- this.submitPendingUntil = 0;
2127
- const screenText = this.terminalScreen.getText();
2128
- this.recordTrace('submit_write', {
2129
- mode: 'submit_key',
2130
- sendKey: this.sendKey,
2131
- screenText: summarizeCliTraceText(screenText, 500),
2132
- });
1155
+ this.engine.submitPendingUntil = 0;
2133
1156
  void this.writeToPty(this.sendKey).then(() => {
2134
1157
  this.commitSendUserTurn(state);
2135
1158
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, 1), state.retryDelayMs);
@@ -2139,13 +1162,7 @@ export class ProviderCliAdapter implements CliAdapter {
2139
1162
  }
2140
1163
 
2141
1164
  private submitImmediatePrompt(state: SendMessageState, completion: SendMessageCompletion): void {
2142
- this.submitPendingUntil = 0;
2143
- this.recordTrace('submit_write', {
2144
- mode: 'immediate',
2145
- text: summarizeCliTraceText(state.text, 500),
2146
- sendKey: this.sendKey,
2147
- screenText: summarizeCliTraceText(this.terminalScreen.getText(), 500),
2148
- });
1165
+ this.engine.submitPendingUntil = 0;
2149
1166
  void this.writeToPty(state.text + this.sendKey).then(() => {
2150
1167
  this.commitSendUserTurn(state);
2151
1168
  this.submitRetryTimer = setTimeout(() => this.retryImmediateSubmitIfStuck(state), state.retryDelayMs);
@@ -2189,7 +1206,8 @@ export class ProviderCliAdapter implements CliAdapter {
2189
1206
  requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
2190
1207
  screenText: summarizeCliTraceText(screenText, 1000),
2191
1208
  };
2192
- this.recordTrace('submit_echo_missing', diagnostic);
1209
+ LOG.warn('CLI', `[${this.cliType}] submit_echo_missing: ${JSON.stringify(diagnostic)}`);
1210
+
2193
1211
  if (this.requirePromptEchoBeforeSubmit) {
2194
1212
  // At this point the prompt text write already completed. Rejecting without
2195
1213
  // a submit key can leave the delegated CLI with an unsent prompt sitting at
@@ -2226,13 +1244,7 @@ export class ProviderCliAdapter implements CliAdapter {
2226
1244
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
2227
1245
  const content = String(text || '');
2228
1246
  if (!content.trim()) return;
2229
- this.recordTrace('force_send_message', {
2230
- text: summarizeCliTraceText(content, 500),
2231
- status: this.currentStatus,
2232
- isWaitingForResponse: this.isWaitingForResponse,
2233
- queueLength: this.pendingOutboundQueue.length,
2234
- });
2235
- LOG.info('CLI', `[${this.cliType}] force-sending prompt while status=${this.currentStatus}`);
1247
+ LOG.info('CLI', `[${this.cliType}] force-sending prompt while status=${this.engine.currentStatus}`);
2236
1248
  await this.writeToPty(content + this.sendKey);
2237
1249
  this.onStatusChange?.();
2238
1250
  }
@@ -2241,11 +1253,6 @@ export class ProviderCliAdapter implements CliAdapter {
2241
1253
  const content = String(text || '');
2242
1254
  const duplicate = this.pendingOutboundQueue.some((message) => message.content === content);
2243
1255
  if (duplicate) {
2244
- this.recordTrace('send_message_queued_duplicate_suppressed', {
2245
- reason,
2246
- queueLength: this.pendingOutboundQueue.length,
2247
- text: summarizeCliTraceText(content, 500),
2248
- });
2249
1256
  return;
2250
1257
  }
2251
1258
  const queuedAt = Date.now();
@@ -2257,24 +1264,22 @@ export class ProviderCliAdapter implements CliAdapter {
2257
1264
  source: 'sendMessage',
2258
1265
  };
2259
1266
  this.pendingOutboundQueue.push(message);
2260
- this.recordTrace('send_message_queued', {
2261
- reason,
2262
- queueLength: this.pendingOutboundQueue.length,
2263
- queuedAt,
2264
- text: summarizeCliTraceText(content, 500),
2265
- });
2266
1267
  LOG.info('CLI', `[${this.cliType}] queued outbound message while busy (${reason}); queue=${this.pendingOutboundQueue.length}`);
2267
1268
  this.onStatusChange?.();
2268
1269
  }
2269
1270
 
2270
1271
  private shouldQueuePendingOutboundMessage(parsedStatusBeforeSend: any | null = null): string | null {
2271
1272
  if (this.provider.allowInputDuringGeneration === true) return null;
2272
- if (this.hasActionableApproval()) return null;
1273
+ if (this.engine.hasActionableApproval()) return null;
2273
1274
  const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
2274
1275
  ? String(parsedStatusBeforeSend.status)
2275
1276
  : '';
2276
- if (parsedSessionStatus === 'idle' && this.parsedStatusHasFinalAssistantMessage(parsedStatusBeforeSend)) return null;
2277
- if (this.currentStatus === 'generating') return 'current_status_generating';
1277
+ const hasFinalAssistant = (p: any) => {
1278
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
1279
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
1280
+ };
1281
+ if (parsedSessionStatus === 'idle' && hasFinalAssistant(parsedStatusBeforeSend)) return null;
1282
+ if (this.engine.currentStatus === 'generating') return 'current_status_generating';
2278
1283
  if (parsedSessionStatus === 'generating' || parsedSessionStatus === 'long_generating') {
2279
1284
  const parsedModal = parsedStatusBeforeSend?.activeModal ?? parsedStatusBeforeSend?.modal ?? null;
2280
1285
  const parsedHasActionableModal = Boolean(
@@ -2282,15 +1287,15 @@ export class ProviderCliAdapter implements CliAdapter {
2282
1287
  && Array.isArray(parsedModal.buttons)
2283
1288
  && parsedModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim()),
2284
1289
  );
2285
- const terminalLooksIdle = this.currentStatus === 'idle'
1290
+ const terminalLooksIdle = this.engine.currentStatus === 'idle'
2286
1291
  && this.runDetectStatus(this.recentOutputBuffer) === 'idle'
2287
- && !this.isWaitingForResponse
2288
- && !this.currentTurnScope
2289
- && !this.hasActionableApproval()
1292
+ && !this.engine.isWaitingForResponse
1293
+ && !this.engine.currentTurnScope
1294
+ && !this.engine.hasActionableApproval()
2290
1295
  && !parsedHasActionableModal;
2291
1296
  return terminalLooksIdle ? null : `parsed_status_${parsedSessionStatus}`;
2292
1297
  }
2293
- if (this.isWaitingForResponse && this.currentTurnScope) return 'active_turn_in_progress';
1298
+ if (this.engine.isWaitingForResponse && this.engine.currentTurnScope) return 'active_turn_in_progress';
2294
1299
  return null;
2295
1300
  }
2296
1301
 
@@ -2304,18 +1309,12 @@ export class ProviderCliAdapter implements CliAdapter {
2304
1309
 
2305
1310
  private async flushPendingOutboundQueue(): Promise<void> {
2306
1311
  if (this.pendingOutboundFlushInFlight || this.pendingOutboundQueue.length === 0) return;
2307
- if (this.currentStatus !== 'idle' || this.isWaitingForResponse || this.hasActionableApproval()) return;
1312
+ if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) return;
2308
1313
  this.pendingOutboundFlushInFlight = true;
2309
1314
  try {
2310
1315
  while (this.pendingOutboundQueue.length > 0) {
2311
- if (this.currentStatus !== 'idle' || this.isWaitingForResponse || this.hasActionableApproval()) break;
1316
+ if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) break;
2312
1317
  const next = this.pendingOutboundQueue[0];
2313
- this.recordTrace('send_message_queue_flush', {
2314
- id: next.id,
2315
- queuedAt: next.queuedAt,
2316
- queueLength: this.pendingOutboundQueue.length,
2317
- text: summarizeCliTraceText(next.content, 500),
2318
- });
2319
1318
  try {
2320
1319
  await this.sendMessageNow(next.content, false);
2321
1320
  this.pendingOutboundQueue.shift();
@@ -2335,8 +1334,8 @@ export class ProviderCliAdapter implements CliAdapter {
2335
1334
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
2336
1335
  const allowInputDuringGeneration = this.provider.allowInputDuringGeneration === true;
2337
1336
  const allowInterventionPrompt = allowInputDuringGeneration
2338
- && this.isWaitingForResponse
2339
- && !this.hasActionableApproval();
1337
+ && this.engine.isWaitingForResponse
1338
+ && !this.engine.hasActionableApproval();
2340
1339
  if (this.startupParseGate) {
2341
1340
  const deadline = Date.now() + 10000;
2342
1341
  while (this.startupParseGate && Date.now() < deadline) {
@@ -2366,11 +1365,11 @@ export class ProviderCliAdapter implements CliAdapter {
2366
1365
  if (this.runDetectStatus(this.recentOutputBuffer) === 'idle') {
2367
1366
  this.ready = true;
2368
1367
  this.startupParseGate = false;
2369
- this.setStatus('idle', 'send_message_idle_prompt_recovery');
1368
+ this.engine.setStatus('idle', 'send_message_idle_prompt_recovery');
2370
1369
  LOG.info('CLI', `[${this.cliType}] sendMessage recovered idle prompt readiness`);
2371
1370
  }
2372
1371
  }
2373
- if (!this.ready) throw new Error(`${this.cliName} not ready (status: ${this.currentStatus})`);
1372
+ if (!this.ready) throw new Error(`${this.cliName} not ready (status: ${this.engine.currentStatus})`);
2374
1373
  const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
2375
1374
  ? String(parsedStatusBeforeSend.status)
2376
1375
  : '';
@@ -2381,11 +1380,11 @@ export class ProviderCliAdapter implements CliAdapter {
2381
1380
  && Array.isArray(parsedModal.buttons)
2382
1381
  && parsedModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim()),
2383
1382
  );
2384
- const terminalLooksIdle = this.currentStatus === 'idle'
1383
+ const terminalLooksIdle = this.engine.currentStatus === 'idle'
2385
1384
  && this.runDetectStatus(this.recentOutputBuffer) === 'idle'
2386
- && !this.isWaitingForResponse
2387
- && !this.currentTurnScope
2388
- && !this.hasActionableApproval()
1385
+ && !this.engine.isWaitingForResponse
1386
+ && !this.engine.currentTurnScope
1387
+ && !this.engine.hasActionableApproval()
2389
1388
  && !parsedHasActionableModal;
2390
1389
  if (!terminalLooksIdle) {
2391
1390
  if (allowQueue) {
@@ -2395,10 +1394,11 @@ export class ProviderCliAdapter implements CliAdapter {
2395
1394
  throw new Error(`${this.cliName} is still processing the previous prompt`);
2396
1395
  }
2397
1396
  }
2398
- if (this.isWaitingForResponse && !allowInputDuringGeneration) {
1397
+ if (this.engine.isWaitingForResponse && !allowInputDuringGeneration) {
1398
+ const snap = this.getSnapshot();
2399
1399
  if (
2400
- !this.clearStaleIdleResponseGuard('send_message_guard')
2401
- && !this.clearParsedIdleResponseGuard('send_message_parsed_idle_guard', parsedStatusBeforeSend)
1400
+ !this.engine.clearStaleIdleResponseGuard('send_message_guard', snap)
1401
+ && !this.engine.clearParsedIdleResponseGuard('send_message_parsed_idle_guard', parsedStatusBeforeSend, snap)
2402
1402
  ) {
2403
1403
  if (allowQueue) {
2404
1404
  this.enqueuePendingOutboundMessage(text, 'waiting_for_response');
@@ -2407,30 +1407,21 @@ export class ProviderCliAdapter implements CliAdapter {
2407
1407
  throw new Error(`${this.cliName} is still processing the previous prompt`);
2408
1408
  }
2409
1409
  }
2410
- this.isWaitingForResponse = true;
2411
1410
  this.responseBuffer = '';
2412
- this.finishRetryCount = 0;
2413
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
2414
- this.clearIdleFinishCandidate('send_message');
2415
- this.currentTurnScope = {
1411
+ const turnScope: TurnParseScope = {
2416
1412
  prompt: text,
2417
1413
  startedAt: Date.now(),
2418
1414
  bufferStart: this.accumulatedBuffer.length,
2419
1415
  rawBufferStart: this.accumulatedRawBuffer.length,
2420
1416
  };
2421
- this.recordTrace('send_message', {
2422
- text: summarizeCliTraceText(text, 500),
2423
- estimatedLines: estimatePromptDisplayLines(text),
2424
- turnScope: this.currentTurnScope,
2425
- });
2426
- LOG.info('CLI', `[${this.cliType}] sendMessage turn scope buffer=${this.currentTurnScope.bufferStart} raw=${this.currentTurnScope.rawBufferStart} prompt=${JSON.stringify(text).slice(0, 120)}`);
2427
- this.submitRetryUsed = false;
2428
- this.submitRetryPromptSnippet = extractPromptRetrySnippet(text);
2429
- const normalizedPromptSnippet = normalizePromptText(this.submitRetryPromptSnippet);
1417
+ LOG.info('CLI', `[${this.cliType}] sendMessage turn scope buffer=${turnScope.bufferStart} raw=${turnScope.rawBufferStart} prompt=${JSON.stringify(text).slice(0, 120)}`);
2430
1418
  if (this.submitRetryTimer) {
2431
1419
  clearTimeout(this.submitRetryTimer);
2432
1420
  this.submitRetryTimer = null;
2433
1421
  }
1422
+ this.engine.onTurnStarted(turnScope);
1423
+ this.engine.submitRetryPromptSnippet = extractPromptRetrySnippet(text);
1424
+ const normalizedPromptSnippet = normalizePromptText(this.engine.submitRetryPromptSnippet);
2434
1425
  const estimatedLines = estimatePromptDisplayLines(text);
2435
1426
  const submitDelayMs = this.sendDelayMs + Math.min(2000, Math.max(0, estimatedLines - 1) * 350);
2436
1427
  const maxEchoWaitMs = submitDelayMs + Math.max(1500, Math.min(5000, estimatedLines * 500));
@@ -2443,12 +1434,7 @@ export class ProviderCliAdapter implements CliAdapter {
2443
1434
  retryDelayMs,
2444
1435
  didCommitUserTurn: false,
2445
1436
  };
2446
- if (this.settleTimer) {
2447
- clearTimeout(this.settleTimer);
2448
- this.settleTimer = null;
2449
- }
2450
- this.responseEpoch += 1;
2451
- this.responseSettleIgnoreUntil = Date.now() + submitDelayMs + this.timeouts.outputSettle + 250;
1437
+ this.engine.responseSettleIgnoreUntil = Date.now() + submitDelayMs + this.timeouts.outputSettle + 250;
2452
1438
  await new Promise<void>((resolve, reject) => {
2453
1439
  let resolved = false;
2454
1440
  const completion: SendMessageCompletion = {
@@ -2471,24 +1457,20 @@ export class ProviderCliAdapter implements CliAdapter {
2471
1457
  }
2472
1458
 
2473
1459
  if (submitDelayMs > 0) {
2474
- this.submitPendingUntil = Date.now() + submitDelayMs;
1460
+ this.engine.submitPendingUntil = Date.now() + submitDelayMs;
2475
1461
  }
2476
- this.recordTrace('submit_write', {
2477
- mode: 'type_then_submit',
2478
- text: summarizeCliTraceText(text, 500),
2479
- sendKey: this.sendKey,
2480
- screenText: summarizeCliTraceText(this.terminalScreen.getText(), 500),
2481
- });
2482
1462
  const submitStartedAt = Date.now();
2483
1463
  void this.writeToPty(text).then(
2484
1464
  () => this.waitForEchoAndSubmit(sendState, completion, submitStartedAt),
2485
1465
  completion.rejectOnce,
2486
1466
  );
2487
1467
  });
1468
+ // Schedule settle after successful send
1469
+ this.engine.scheduleSettle();
2488
1470
  }
2489
1471
 
2490
1472
  getPartialResponse(): string {
2491
- if (!this.isWaitingForResponse) return '';
1473
+ if (!this.engine.isWaitingForResponse) return '';
2492
1474
  return this.responseBuffer;
2493
1475
  }
2494
1476
 
@@ -2501,10 +1483,10 @@ export class ProviderCliAdapter implements CliAdapter {
2501
1483
  cliType: this.cliType,
2502
1484
  cliName: this.cliName,
2503
1485
  workingDir: this.workingDir,
2504
- currentStatus: this.currentStatus,
1486
+ currentStatus: this.engine.currentStatus,
2505
1487
  ready: this.ready,
2506
- isWaitingForResponse: this.isWaitingForResponse,
2507
- activeModal: this.activeModal,
1488
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1489
+ activeModal: this.engine.activeModal,
2508
1490
  parseErrorMessage: this.parseErrorMessage,
2509
1491
  messageCounts: {
2510
1492
  parsedCache: Array.isArray(parsedResult?.messages) ? parsedResult.messages.length : undefined,
@@ -2530,10 +1512,10 @@ export class ProviderCliAdapter implements CliAdapter {
2530
1512
  lastScreenSnapshotReadAt: this.lastScreenSnapshotReadAt,
2531
1513
  },
2532
1514
  parser: {
2533
- scriptNames: listCliScriptNames(this.cliScripts),
2534
- traceSessionId: this.traceSessionId,
2535
- traceSeq: this.traceSeq,
2536
- currentTurnScope: this.currentTurnScope,
1515
+ scriptNames: this.runner.getScriptNames(),
1516
+ traceSessionId: this.engine.getTraceSessionId(),
1517
+ traceSeq: this.engine.getTraceEntries().length,
1518
+ currentTurnScope: this.engine.currentTurnScope,
2537
1519
  parsedStatusCache: parsedResult
2538
1520
  ? {
2539
1521
  id: parsedResult.id,
@@ -2546,26 +1528,25 @@ export class ProviderCliAdapter implements CliAdapter {
2546
1528
  activeModal: parsedResult.activeModal,
2547
1529
  }
2548
1530
  : null,
2549
- pendingScriptStatus: this.pendingScriptStatus,
2550
- pendingScriptStatusSince: this.pendingScriptStatusSince,
1531
+ pendingScriptStatus: this.engine.pendingScriptStatus,
1532
+ pendingScriptStatusSince: this.engine.pendingScriptStatusSince,
2551
1533
  },
2552
1534
  runtimeMetadata: this.getRuntimeMetadata(),
2553
- statusHistory: this.statusHistory.slice(-80),
2554
- traceEntries: this.traceEntries.slice(-120),
1535
+ statusHistory: this.engine.getStatusHistory().slice(-80),
1536
+ traceEntries: this.engine.getTraceEntries().slice(-120),
2555
1537
  timing: {
2556
1538
  spawnAt: this.spawnAt,
2557
1539
  startupFirstOutputAt: this.startupFirstOutputAt,
2558
- submitPendingUntil: this.submitPendingUntil,
2559
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
2560
- responseEpoch: this.responseEpoch,
1540
+ submitPendingUntil: this.engine.submitPendingUntil,
1541
+ responseSettleIgnoreUntil: this.engine.responseSettleIgnoreUntil,
1542
+ responseEpoch: this.engine.responseEpoch,
2561
1543
  resizeSuppressUntil: this.resizeSuppressUntil,
2562
- lastApprovalResolvedAt: this.lastApprovalResolvedAt,
1544
+ lastApprovalResolvedAt: this.engine.lastApprovalResolvedAt,
2563
1545
  },
2564
1546
  finish: {
2565
- idleFinishCandidate: this.idleFinishCandidate,
2566
- finishRetryCount: this.finishRetryCount,
2567
- submitRetryUsed: this.submitRetryUsed,
2568
- submitRetryPromptSnippet: this.submitRetryPromptSnippet,
1547
+ finishRetryCount: this.engine.finishRetryCount,
1548
+ submitRetryUsed: this.engine.submitRetryUsed,
1549
+ submitRetryPromptSnippet: this.engine.submitRetryPromptSnippet,
2569
1550
  },
2570
1551
  };
2571
1552
  }
@@ -2602,7 +1583,7 @@ export class ProviderCliAdapter implements CliAdapter {
2602
1583
  this.timeouts.shutdownGrace,
2603
1584
  typeof resume.shutdownGraceMs === 'number' ? resume.shutdownGraceMs : 3000,
2604
1585
  );
2605
- const wasProcessing = this.currentStatus === 'generating' || this.currentStatus === 'waiting_approval';
1586
+ const wasProcessing = this.engine.currentStatus === 'generating' || this.engine.currentStatus === 'waiting_approval';
2606
1587
 
2607
1588
  try {
2608
1589
  if (wasProcessing) {
@@ -2640,7 +1621,7 @@ export class ProviderCliAdapter implements CliAdapter {
2640
1621
  return new Promise((resolve) => {
2641
1622
  const startedAt = Date.now();
2642
1623
  const timer = setInterval(() => {
2643
- if (!this.ptyProcess || this.currentStatus === 'stopped') {
1624
+ if (!this.ptyProcess || this.engine.currentStatus === 'stopped') {
2644
1625
  clearInterval(timer);
2645
1626
  resolve(true);
2646
1627
  return;
@@ -2654,12 +1635,11 @@ export class ProviderCliAdapter implements CliAdapter {
2654
1635
  }
2655
1636
 
2656
1637
  shutdown(): void {
2657
- this.clearIdleFinishCandidate('shutdown');
1638
+ this.engine.clearIdleFinishCandidate('shutdown');
2658
1639
  this.clearAllTimers();
2659
1640
  this.pendingOutputParseChunks = [];
2660
1641
  this.pendingTerminalQueryTail = '';
2661
1642
  this.ptyOutputChunks = [];
2662
- this.finishRetryCount = 0;
2663
1643
  if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
2664
1644
  this.pendingOutboundQueue = [];
2665
1645
  this.pendingOutboundFlushInFlight = false;
@@ -2668,7 +1648,7 @@ export class ProviderCliAdapter implements CliAdapter {
2668
1648
  setTimeout(() => {
2669
1649
  try { this.ptyProcess?.kill(); } catch { }
2670
1650
  this.ptyProcess = null;
2671
- this.setStatus('stopped', 'stop_cmd');
1651
+ this.engine.setStatus('stopped', 'stop_cmd');
2672
1652
  this.ready = false;
2673
1653
  this.startupParseGate = false;
2674
1654
  this.spawnAt = 0;
@@ -2678,12 +1658,11 @@ export class ProviderCliAdapter implements CliAdapter {
2678
1658
  }
2679
1659
 
2680
1660
  detach(): void {
2681
- this.clearIdleFinishCandidate('detach');
1661
+ this.engine.clearIdleFinishCandidate('detach');
2682
1662
  this.clearAllTimers();
2683
1663
  this.pendingOutputParseChunks = [];
2684
1664
  this.pendingTerminalQueryTail = '';
2685
1665
  this.ptyOutputChunks = [];
2686
- this.finishRetryCount = 0;
2687
1666
  if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
2688
1667
  this.pendingOutboundQueue = [];
2689
1668
  this.pendingOutboundFlushInFlight = false;
@@ -2704,19 +1683,17 @@ export class ProviderCliAdapter implements CliAdapter {
2704
1683
  }
2705
1684
 
2706
1685
  clearHistory(): void {
2707
- this.clearIdleFinishCandidate('clear_history');
1686
+ this.engine.clearIdleFinishCandidate('clear_history');
2708
1687
  this.accumulatedBuffer = '';
2709
1688
  this.accumulatedRawBuffer = '';
2710
- this.currentTurnScope = null;
2711
- this.submitRetryUsed = false;
2712
- this.submitRetryPromptSnippet = '';
1689
+ this.engine.currentTurnScope = null;
1690
+ this.engine.submitRetryUsed = false;
1691
+ this.engine.submitRetryPromptSnippet = '';
2713
1692
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
2714
1693
  this.pendingOutputParseChunks = [];
2715
1694
  this.pendingTerminalQueryTail = '';
2716
1695
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
2717
1696
  this.ptyOutputChunks = [];
2718
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
2719
- this.finishRetryCount = 0;
2720
1697
  if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
2721
1698
  this.pendingOutboundQueue = [];
2722
1699
  this.pendingOutboundFlushInFlight = false;
@@ -2725,76 +1702,109 @@ export class ProviderCliAdapter implements CliAdapter {
2725
1702
  this.onStatusChange?.();
2726
1703
  }
2727
1704
 
2728
- isProcessing(): boolean { return this.isWaitingForResponse; }
1705
+ isProcessing(): boolean { return this.engine.isWaitingForResponse; }
2729
1706
  isReady(): boolean { return this.ready; }
2730
1707
 
2731
- async writeRaw(data: string): Promise<void> {
2732
- this.recordTrace('write_raw', {
2733
- keys: JSON.stringify(data),
2734
- length: data.length,
2735
- });
2736
- await this.writeToPty(data);
1708
+ // ─── State machine property accessors (delegate to engine) ──────────────
1709
+ // These expose engine state for external callers (tests, debug tools, etc.)
1710
+
1711
+ get currentStatus(): CliSessionStatus['status'] { return this.engine.currentStatus; }
1712
+ set currentStatus(v: CliSessionStatus['status']) { this.engine.setStatus(v); }
1713
+
1714
+ get isWaitingForResponse(): boolean { return this.engine.isWaitingForResponse; }
1715
+ set isWaitingForResponse(v: boolean) { this.engine.isWaitingForResponse = v; }
1716
+
1717
+ get activeModal(): { message: string; buttons: string[] } | null { return this.engine.activeModal; }
1718
+ set activeModal(v: { message: string; buttons: string[] } | null) { this.engine.activeModal = v; }
1719
+
1720
+ get currentTurnScope(): TurnParseScope | null { return this.engine.currentTurnScope; }
1721
+ set currentTurnScope(v: TurnParseScope | null) { this.engine.currentTurnScope = v; }
1722
+
1723
+ get responseEpoch(): number { return this.engine.responseEpoch; }
1724
+ set responseEpoch(v: number) { this.engine.responseEpoch = v; }
1725
+
1726
+ get submitRetryUsed(): boolean { return this.engine.submitRetryUsed; }
1727
+ set submitRetryUsed(v: boolean) { this.engine.submitRetryUsed = v; }
1728
+
1729
+ get submitRetryPromptSnippet(): string { return this.engine.submitRetryPromptSnippet; }
1730
+ set submitRetryPromptSnippet(v: string) { this.engine.submitRetryPromptSnippet = v; }
1731
+
1732
+ get responseSettleIgnoreUntil(): number { return this.engine.responseSettleIgnoreUntil; }
1733
+ set responseSettleIgnoreUntil(v: number) { this.engine.responseSettleIgnoreUntil = v; }
1734
+
1735
+ get submitPendingUntil(): number { return this.engine.submitPendingUntil; }
1736
+ set submitPendingUntil(v: number) { this.engine.submitPendingUntil = v; }
1737
+
1738
+ get lastApprovalResolvedAt(): number { return this.engine.lastApprovalResolvedAt; }
1739
+ set lastApprovalResolvedAt(v: number) { this.engine.lastApprovalResolvedAt = v; }
1740
+
1741
+ get providerErrorMessage(): string | null { return this.engine.providerErrorMessage; }
1742
+ get providerErrorReason(): string | null { return this.engine.providerErrorReason; }
1743
+
1744
+ get pendingScriptStatus(): 'generating' | 'waiting_approval' | null { return this.engine.pendingScriptStatus; }
1745
+ get pendingScriptStatusSince(): number { return this.engine.pendingScriptStatusSince; }
1746
+
1747
+ get finishRetryCount(): number { return this.engine.finishRetryCount; }
1748
+ set finishRetryCount(v: number) { this.engine.finishRetryCount = v; }
1749
+
1750
+ get traceSessionId(): string { return this.engine.getTraceSessionId(); }
1751
+ get traceEntries(): CliTraceEntry[] { return this.engine.getTraceEntries(); }
1752
+ get statusHistory(): { status: string; at: number; trigger?: string }[] { return this.engine.getStatusHistory(); }
1753
+ get traceSeq(): number { return this.engine.getTraceEntries().length; }
1754
+
1755
+ /** Expose engine's evaluateSettled for test access */
1756
+ evaluateSettled(): void {
1757
+ LOG.debug(
1758
+ 'CLI',
1759
+ `[${this.cliType}] settled diagnostics delegated to state engine`);
1760
+ this.engine.evaluateSettled(this.getSnapshot());
1761
+ }
1762
+ /** Expose engine's scheduleSettle for test access */
1763
+ scheduleSettle(): void { this.engine.scheduleSettle(); }
1764
+ /** Expose engine's clearIdleFinishCandidate for test access */
1765
+ clearIdleFinishCandidate(reason: string): void { this.engine.clearIdleFinishCandidate(reason); }
1766
+ /** Expose engine's finishResponse for test access */
1767
+ finishResponse(): void { this.engine.finishResponse(); }
1768
+ /** Returns a point-in-time snapshot of all buffer/screen state for external consumers (e.g. CliStateEngine). */
1769
+ getSnapshot(): CliBufferSnapshot {
1770
+ const screenText = this.terminalScreen.getText() || '';
1771
+ return {
1772
+ accumulatedBuffer: this.accumulatedBuffer,
1773
+ accumulatedRawBuffer: this.accumulatedRawBuffer,
1774
+ recentOutputBuffer: this.recentOutputBuffer,
1775
+ responseBuffer: this.responseBuffer,
1776
+ screenText,
1777
+ parseScreenText: this.getParseScreenText(screenText),
1778
+ workingDir: this.workingDir,
1779
+ providerSessionId: this.providerSessionId,
1780
+ runtimeSettings: this.runtimeSettings,
1781
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1782
+ currentTurnScope: this.engine.currentTurnScope,
1783
+ lastOutputAt: this.lastOutputAt,
1784
+ lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
1785
+ lastScreenChangeAt: this.lastScreenChangeAt,
1786
+ lastScreenSnapshot: this.lastScreenSnapshot,
1787
+ };
1788
+ }
1789
+ isAlive(): boolean { return this.ptyProcess !== null; }
1790
+ flushOutboundQueue(): void { this.schedulePendingOutboundFlush(); }
1791
+
1792
+ async writeRaw(data: string | Buffer): Promise<void> {
1793
+ const str = Buffer.isBuffer(data) ? data.toString('utf8') : data;
1794
+ await this.writeToPty(str);
2737
1795
  }
2738
1796
 
2739
1797
  resolveModal(buttonIndex: number): void {
2740
- // Idempotency guard: if we already resolved an approval within the cooldown
2741
- // window, do not write another key to the PTY. This prevents double-writes when
2742
- // auto-approve fires and then the status poller re-enters before the PTY absorbs
2743
- // the first keystroke, or when an external mesh_approve command races with auto-approve.
2744
- if (this.lastApprovalResolvedAt && (Date.now() - this.lastApprovalResolvedAt) < this.timeouts.approvalCooldown) {
2745
- return;
2746
- }
2747
- let modal = this.activeModal || this.runParseApproval(this.recentOutputBuffer);
2748
- if (!modal && typeof this.cliScripts?.parseSession === 'function') {
2749
- try {
2750
- const parsed = this.getScriptParsedStatus();
2751
- const parsedModal = parsed?.activeModal && Array.isArray(parsed.activeModal.buttons)
2752
- && parsed.activeModal.buttons.some((button: any) => typeof button === 'string' && button.trim())
2753
- ? parsed.activeModal
2754
- : null;
2755
- if (parsed?.status === 'waiting_approval' && parsedModal) {
2756
- modal = parsedModal;
2757
- this.activeModal = parsedModal;
2758
- if (this.currentStatus !== 'waiting_approval') {
2759
- this.setStatus('waiting_approval', 'resolve_modal_parse');
2760
- this.onStatusChange?.();
2761
- }
2762
- }
2763
- } catch {
2764
- // Ignore parse failures here; resolveModal falls back to current state.
2765
- }
2766
- }
2767
- if (!this.ptyProcess || ((this.currentStatus !== 'waiting_approval') && !modal)) return;
2768
- this.clearIdleFinishCandidate('resolve_modal');
2769
- this.recordTrace('resolve_modal', {
2770
- buttonIndex,
2771
- activeModal: modal,
2772
- });
2773
- this.activeModal = null;
2774
- this.lastApprovalResolvedAt = Date.now();
2775
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
2776
- if (this.approvalExitTimeout) {
2777
- clearTimeout(this.approvalExitTimeout);
2778
- this.approvalExitTimeout = null;
2779
- }
2780
- this.setStatus('generating', 'approval_resolved');
2781
- this.onStatusChange?.();
2782
- if (buttonIndex in this.approvalKeys) {
2783
- this.ptyProcess.write(this.approvalKeys[buttonIndex]);
2784
- } else {
2785
- const buttonCount = Array.isArray(modal?.buttons) ? modal.buttons.length : 0;
2786
- const clampedIndex = buttonCount > 0
2787
- ? Math.min(Math.max(0, buttonIndex), buttonCount - 1)
2788
- : Math.max(0, buttonIndex);
2789
- const DOWN = '\x1B[B';
2790
- const keys = DOWN.repeat(clampedIndex) + '\r';
2791
- this.ptyProcess.write(keys);
2792
- }
1798
+ this.engine.resolveModal(buttonIndex);
1799
+ }
1800
+
1801
+ getApprovalKeyForIndex(buttonIndex: number): string | undefined {
1802
+ return buttonIndex in this.approvalKeys ? this.approvalKeys[buttonIndex] : undefined;
2793
1803
  }
2794
1804
 
2795
1805
  /** Returns true if an approval was resolved within the adapter's cooldown window. */
2796
1806
  isApprovalRecentlyResolved(): boolean {
2797
- return !!(this.lastApprovalResolvedAt && (Date.now() - this.lastApprovalResolvedAt) < this.timeouts.approvalCooldown);
1807
+ return this.engine.isApprovalRecentlyResolved();
2798
1808
  }
2799
1809
 
2800
1810
  resize(cols: number, rows: number): void {
@@ -2808,7 +1818,7 @@ export class ProviderCliAdapter implements CliAdapter {
2808
1818
  }
2809
1819
 
2810
1820
  private getParsedDebugState(): Record<string, any> | null {
2811
- if (this.startupParseGate || typeof this.cliScripts?.parseSession !== 'function') return null;
1821
+ if (this.startupParseGate || !this.runner.hasParseSession()) return null;
2812
1822
  try {
2813
1823
  const parsed = this.getScriptParsedStatus();
2814
1824
  return parsed && typeof parsed === 'object' ? parsed as Record<string, any> : null;
@@ -2826,6 +1836,10 @@ export class ProviderCliAdapter implements CliAdapter {
2826
1836
  const effectiveReady = this.ready || !!startupModal || startupDetectedStatus === 'waiting_approval';
2827
1837
  const parsedDebugState = this.getParsedDebugState();
2828
1838
  const parsedMessages = Array.isArray(parsedDebugState?.messages) ? parsedDebugState.messages : [];
1839
+ const hasFinalAssistant = (p: any) => {
1840
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
1841
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
1842
+ };
2829
1843
  let effectiveStatus = this.projectEffectiveStatus(startupModal);
2830
1844
  if (parsedDebugState?.status === 'error') {
2831
1845
  effectiveStatus = 'error';
@@ -2836,7 +1850,7 @@ export class ProviderCliAdapter implements CliAdapter {
2836
1850
  if (
2837
1851
  effectiveStatus === 'idle'
2838
1852
  && parsedDebugState?.status === 'generating'
2839
- && !this.parsedStatusHasFinalAssistantMessage(parsedDebugState)
1853
+ && !hasFinalAssistant(parsedDebugState)
2840
1854
  ) {
2841
1855
  effectiveStatus = 'generating';
2842
1856
  }
@@ -2846,8 +1860,8 @@ export class ProviderCliAdapter implements CliAdapter {
2846
1860
  providerResolution: this.providerResolutionMeta,
2847
1861
  status: effectiveStatus,
2848
1862
  projectedStatus: effectiveStatus,
2849
- rawStatus: this.currentStatus,
2850
- lifecycleStatus: this.isWaitingForResponse ? 'awaiting_response' : 'idle',
1863
+ rawStatus: this.engine.currentStatus,
1864
+ lifecycleStatus: this.engine.isWaitingForResponse ? 'awaiting_response' : 'idle',
2851
1865
  ready: effectiveReady,
2852
1866
  startupParseGate: this.startupParseGate,
2853
1867
  spawnAt: this.spawnAt,
@@ -2867,10 +1881,9 @@ export class ProviderCliAdapter implements CliAdapter {
2867
1881
  messageCount: parsedMessages.length,
2868
1882
  } : null,
2869
1883
  screenText: screenText.slice(-4000),
2870
- currentTurnScope: this.currentTurnScope,
1884
+ currentTurnScope: this.engine.currentTurnScope,
2871
1885
  startupBuffer: this.startupBuffer.slice(-4000),
2872
1886
  recentOutputBuffer: this.recentOutputBuffer.slice(-500),
2873
- settledBuffer: this.settledBuffer.slice(-500),
2874
1887
  accumulatedBufferLength: this.accumulatedBuffer.length,
2875
1888
  accumulatedRawBufferLength: this.accumulatedRawBuffer.length,
2876
1889
  rawBufferPreview: this.accumulatedRawBuffer.slice(-1000),
@@ -2888,21 +1901,21 @@ export class ProviderCliAdapter implements CliAdapter {
2888
1901
  lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
2889
1902
  lastScreenChangeAt: this.lastScreenChangeAt,
2890
1903
  lastScreenSnapshot: this.lastScreenSnapshot.slice(-500),
2891
- isWaitingForResponse: this.isWaitingForResponse,
2892
- activeModal: startupModal || this.activeModal,
2893
- lastApprovalResolvedAt: this.lastApprovalResolvedAt,
1904
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1905
+ activeModal: startupModal || this.engine.activeModal,
1906
+ lastApprovalResolvedAt: this.engine.lastApprovalResolvedAt,
2894
1907
  sendDelayMs: this.sendDelayMs,
2895
1908
  sendKey: this.sendKey,
2896
1909
  submitStrategy: this.submitStrategy,
2897
1910
  requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
2898
- submitPendingUntil: this.submitPendingUntil,
2899
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
1911
+ submitPendingUntil: this.engine.submitPendingUntil,
1912
+ responseSettleIgnoreUntil: this.engine.responseSettleIgnoreUntil,
2900
1913
  resizeSuppressUntil: this.resizeSuppressUntil,
2901
1914
  hasCliScripts: this.hasCliScripts(),
2902
- scriptNames: listCliScriptNames(this.cliScripts),
2903
- traceSessionId: this.traceSessionId,
2904
- traceEntryCount: this.traceEntries.length,
2905
- statusHistory: this.statusHistory.slice(-30),
1915
+ scriptNames: this.runner.getScriptNames(),
1916
+ traceSessionId: this.engine.getTraceSessionId(),
1917
+ traceEntryCount: this.engine.getTraceEntries().length,
1918
+ statusHistory: this.engine.getStatusHistory().slice(-30),
2906
1919
  timeouts: this.timeouts,
2907
1920
  pendingOutputParseBufferLength: this.pendingOutputParseChunks.reduce((total, chunk) => total + chunk.length, 0),
2908
1921
  pendingOutputParseScheduled: !!this.pendingOutputParseTimer,
@@ -2912,20 +1925,21 @@ export class ProviderCliAdapter implements CliAdapter {
2912
1925
 
2913
1926
  getTraceState(limit = 120): Record<string, any> {
2914
1927
  const cappedLimit = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 120));
1928
+ const traceEntries = this.engine.getTraceEntries();
2915
1929
  return {
2916
- sessionId: this.traceSessionId,
1930
+ sessionId: this.engine.getTraceSessionId(),
2917
1931
  providerResolution: this.providerResolutionMeta,
2918
- entryCount: this.traceEntries.length,
2919
- entries: this.traceEntries.slice(-cappedLimit),
1932
+ entryCount: traceEntries.length,
1933
+ entries: traceEntries.slice(-cappedLimit),
2920
1934
  screenText: summarizeCliTraceText(this.terminalScreen.getText(), 4000),
2921
1935
  recentOutputBuffer: summarizeCliTraceText(this.recentOutputBuffer, 1000),
2922
1936
  responseBuffer: summarizeCliTraceText(this.responseBuffer, 1200),
2923
1937
  status: this.projectEffectiveStatus(),
2924
1938
  projectedStatus: this.projectEffectiveStatus(),
2925
- rawStatus: this.currentStatus,
2926
- lifecycleStatus: this.isWaitingForResponse ? 'awaiting_response' : 'idle',
2927
- activeModal: this.activeModal,
2928
- currentTurnScope: this.currentTurnScope,
1939
+ rawStatus: this.engine.currentStatus,
1940
+ lifecycleStatus: this.engine.isWaitingForResponse ? 'awaiting_response' : 'idle',
1941
+ activeModal: this.engine.activeModal,
1942
+ currentTurnScope: this.engine.currentTurnScope,
2929
1943
  messages: [],
2930
1944
  };
2931
1945
  }