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

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 (39) hide show
  1. package/dist/cli-adapters/cli-script-runner.d.ts +45 -0
  2. package/dist/cli-adapters/cli-state-engine.d.ts +154 -0
  3. package/dist/cli-adapters/provider-cli-adapter.d.ts +73 -74
  4. package/dist/cli-adapters/provider-cli-shared.d.ts +4 -0
  5. package/dist/config/chat-history.d.ts +1 -0
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +2591 -1966
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2594 -1974
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/mesh/beads-db.d.ts +54 -0
  12. package/dist/mesh/mesh-active-work.d.ts +7 -1
  13. package/dist/mesh/mesh-events.d.ts +10 -4
  14. package/dist/mesh/mesh-ledger.d.ts +21 -1
  15. package/dist/mesh/mesh-refine-status.d.ts +2 -3
  16. package/dist/mesh/mesh-work-queue.d.ts +17 -0
  17. package/dist/mesh/worktree-bootstrap-config.d.ts +2 -4
  18. package/dist/repo-mesh-types.d.ts +5 -0
  19. package/package.json +1 -1
  20. package/src/cli-adapters/cli-script-runner.ts +145 -0
  21. package/src/cli-adapters/cli-state-engine.ts +957 -0
  22. package/src/cli-adapters/provider-cli-adapter.d.ts +0 -1
  23. package/src/cli-adapters/provider-cli-adapter.ts +365 -1397
  24. package/src/cli-adapters/provider-cli-shared.ts +4 -0
  25. package/src/commands/chat-commands.ts +17 -1
  26. package/src/commands/router.ts +8 -0
  27. package/src/config/chat-history.ts +7 -3
  28. package/src/git/git-worktree.ts +8 -1
  29. package/src/index.ts +3 -2
  30. package/src/mesh/beads-db.ts +305 -2
  31. package/src/mesh/coordinator-prompt.ts +12 -17
  32. package/src/mesh/mesh-active-work.ts +162 -59
  33. package/src/mesh/mesh-events.ts +198 -53
  34. package/src/mesh/mesh-ledger.ts +321 -105
  35. package/src/mesh/mesh-refine-status.ts +2 -3
  36. package/src/mesh/mesh-work-queue.ts +116 -120
  37. package/src/mesh/worktree-bootstrap-config.ts +17 -4
  38. package/src/providers/provider-schema.ts +2 -0
  39. package/src/repo-mesh-types.ts +10 -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,24 @@ 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
+ // Native transcript anchor — when >0, native history was confirmed for this session.
174
+ // Prevents freshEnough flips caused by PTY buffer activity after the first successful native read.
175
+ nativeHistoryAnchoredAt: number = 0;
211
176
 
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;
177
+ // ─── Script runner (parsing isolated here, adapter stays as transport) ───
178
+ private readonly runner: CliScriptRunner;
179
+ /** @deprecated use runner.cliScripts for direct script access */
180
+ get cliScripts(): CliScripts { return this.runner.cliScripts; }
181
+ set cliScripts(scripts: CliScripts) { this.setCliScripts(scripts); }
216
182
  private runtimeSettings: Record<string, any> = {};
217
183
  /** Full accumulated rendered PTY transcript for parser/readback use */
218
184
  private accumulatedBuffer: string = '';
@@ -232,10 +198,6 @@ export class ProviderCliAdapter implements CliAdapter {
232
198
  * Hermes turn (tool calls + reasoning + final bubble) without the
233
199
  * rolling window pushing the turn's ╭─ opening line out of view. */
234
200
  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
201
  private parsedStatusCache: {
240
202
  responseBuffer: string;
241
203
  currentTurnScope: TurnParseScope | null;
@@ -249,11 +211,8 @@ export class ProviderCliAdapter implements CliAdapter {
249
211
  result: any;
250
212
  } | null = null;
251
213
  private static readonly SCREEN_SNAPSHOT_MIN_INTERVAL_MS = 250;
252
- private static readonly MAX_TRACE_ENTRIES = 250;
253
214
 
254
215
  private readonly providerResolutionMeta: ProviderResolutionMeta;
255
- private static readonly FINISH_RETRY_DELAY_MS = 300;
256
- private static readonly MAX_FINISH_RETRIES = 2;
257
216
 
258
217
  private getBufferState(): NonNullable<CliSessionStatus['bufferState']> | undefined {
259
218
  const build = (droppedChars: number, maxChars: number) => droppedChars > 0
@@ -329,13 +288,13 @@ export class ProviderCliAdapter implements CliAdapter {
329
288
  if (
330
289
  cached
331
290
  && cached.responseBuffer === this.responseBuffer
332
- && cached.currentTurnScope === this.currentTurnScope
291
+ && cached.currentTurnScope === this.engine.currentTurnScope
333
292
  && cached.recentOutputBuffer === this.recentOutputBuffer
334
293
  && cached.accumulatedBuffer === this.accumulatedBuffer
335
294
  && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
336
295
  && cached.screenText === this.lastScreenText
337
- && cached.currentStatus === this.currentStatus
338
- && cached.activeModal === this.activeModal
296
+ && cached.currentStatus === this.engine.currentStatus
297
+ && cached.activeModal === this.engine.activeModal
339
298
  && cached.cliName === this.cliName
340
299
  ) {
341
300
  return cached.result;
@@ -359,86 +318,6 @@ export class ProviderCliAdapter implements CliAdapter {
359
318
  return this.timeouts.statusActivityHold;
360
319
  }
361
320
 
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
321
  // Resolved timeouts
443
322
  private readonly timeouts: Required<NonNullable<CliProviderModule['timeouts']>>;
444
323
 
@@ -448,7 +327,6 @@ export class ProviderCliAdapter implements CliAdapter {
448
327
  private readonly sendKey: string;
449
328
  private readonly submitStrategy: 'wait_for_echo' | 'immediate';
450
329
  private readonly requirePromptEchoBeforeSubmit: boolean;
451
- private static readonly SCRIPT_STATUS_DEBOUNCE_MS = 3000;
452
330
 
453
331
  constructor(
454
332
  provider: CliProviderModule,
@@ -457,6 +335,7 @@ export class ProviderCliAdapter implements CliAdapter {
457
335
  private extraEnv: Record<string, string> = {},
458
336
  transportFactory: PtyTransportFactory = new NodePtyTransportFactory(),
459
337
  ) {
338
+ this.runner = new CliScriptRunner(provider.type);
460
339
  this.provider = provider;
461
340
  this.transportFactory = transportFactory;
462
341
  this.cliType = provider.type;
@@ -474,10 +353,22 @@ export class ProviderCliAdapter implements CliAdapter {
474
353
  this.requirePromptEchoBeforeSubmit = resolvedConfig.requirePromptEchoBeforeSubmit;
475
354
  this.providerResolutionMeta = resolvedConfig.providerResolutionMeta;
476
355
 
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);
356
+ // State machine engineowns all status transitions
357
+ this.engine = new CliStateEngine(
358
+ provider,
359
+ this.runner,
360
+ this as unknown as CliTransportAccess,
361
+ {
362
+ onStatusChange: () => { this.onStatusChange?.(); },
363
+ onApplyParsedSession: (session) => { this.applyParsedSessionMetadata(session); },
364
+ onTurnCompleted: () => { this.responseBuffer = ''; },
365
+ } satisfies CliStateEngineCallbacks,
366
+ resolvedConfig.timeouts,
367
+ );
368
+
369
+ // Scripts delegated to CliScriptRunner — adapter stays as transport
370
+ this.runner.setScripts(provider.scripts || {});
371
+ const scriptNames = this.runner.getScriptNames();
481
372
  if (scriptNames.length > 0) {
482
373
  LOG.info('CLI', `[${this.cliType}] CLI scripts: [${scriptNames.join(', ')}]`);
483
374
  LOG.info(
@@ -503,14 +394,9 @@ export class ProviderCliAdapter implements CliAdapter {
503
394
 
504
395
  /** Inject CLI scripts after construction (e.g. when resolved by ProviderLoader) */
505
396
  setCliScripts(scripts: CliScripts): void {
506
- this.cliScripts = scripts;
397
+ this.runner.setScripts(scripts);
507
398
  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(', ')}]`);
399
+ LOG.info('CLI', `[${this.cliType}] CLI scripts injected: [${this.runner.getScriptNames().join(', ')}]`);
514
400
  }
515
401
 
516
402
  /** Refresh provider scripts/config used by this adapter without restarting the PTY runtime. */
@@ -564,15 +450,6 @@ export class ProviderCliAdapter implements CliAdapter {
564
450
  });
565
451
 
566
452
  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
453
 
577
454
  try {
578
455
  this.ptyProcess = this.transportFactory.spawn(
@@ -636,13 +513,12 @@ export class ProviderCliAdapter implements CliAdapter {
636
513
  this.ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
637
514
  LOG.info('CLI', `[${this.cliType}] Exit code ${exitCode}`);
638
515
  this.flushPendingOutputParse();
639
- this.recordTrace('exit', { exitCode });
640
516
  this.ptyProcess = null;
641
- this.setStatus('stopped', 'pty_exit');
517
+ this.engine.onPtyExit();
642
518
  this.ready = false;
643
519
  this.startupParseGate = false;
644
520
  this.spawnAt = 0;
645
- this.scriptState = null;
521
+ this.runner.resetSessionState();
646
522
  this.onStatusChange?.();
647
523
  });
648
524
 
@@ -653,15 +529,9 @@ export class ProviderCliAdapter implements CliAdapter {
653
529
  if (this.startupSettleTimer) { clearTimeout(this.startupSettleTimer); this.startupSettleTimer = null; }
654
530
  this.resetTerminalScreen(24, 80);
655
531
  this.pendingTerminalQueryTail = '';
656
- this.currentTurnScope = null;
657
- this.finishRetryCount = 0;
658
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
659
532
  this.ready = false;
660
533
  await this.ptyProcess.ready;
661
- this.recordTrace('ready', {
662
- runtimeMeta: this.getRuntimeMetadata(),
663
- });
664
- this.setStatus('starting', 'pty_ready');
534
+ this.engine.onSpawnReady();
665
535
  this.scheduleStartupSettleCheck();
666
536
  this.onStatusChange?.();
667
537
  }
@@ -687,11 +557,11 @@ export class ProviderCliAdapter implements CliAdapter {
687
557
  if (this.startupParseGate && !this.startupFirstOutputAt && (cleanData.trim() || normalizedScreenSnapshot.trim())) {
688
558
  this.startupFirstOutputAt = now;
689
559
  }
690
- if (this.idleFinishCandidate && (rawData.length > 0 || cleanData.length > 0)) {
691
- this.clearIdleFinishCandidate('new_output');
560
+ if (rawData.length > 0 || cleanData.length > 0) {
561
+ this.engine.clearIdleFinishCandidate('new_output');
692
562
  }
693
563
  if (getDebugRuntimeConfig().collectDebugTrace) {
694
- this.recordTrace('output', {
564
+ this.engine.recordExternalTrace('output', {
695
565
  rawLength: rawData.length,
696
566
  cleanLength: cleanData.length,
697
567
  rawPreview: summarizeCliTraceText(rawData, 300),
@@ -703,7 +573,7 @@ export class ProviderCliAdapter implements CliAdapter {
703
573
  this.scheduleStartupSettleCheck();
704
574
  }
705
575
 
706
- if (this.isWaitingForResponse && cleanData) {
576
+ if (this.engine.isWaitingForResponse && cleanData) {
707
577
  const previousResponseLen = this.responseBuffer.length;
708
578
  this.responseBuffer = appendBoundedText(this.responseBuffer, cleanData, ProviderCliAdapter.MAX_RESPONSE_BUFFER);
709
579
  this.responseBufferDroppedChars += this.recordBoundedAppendDrop(previousResponseLen, cleanData.length, this.responseBuffer.length);
@@ -742,19 +612,19 @@ export class ProviderCliAdapter implements CliAdapter {
742
612
  // Keep turn-scope offsets aligned with the truncated buffer so scoped
743
613
  // parses don't lose the beginning of a long turn (e.g. the Hermes
744
614
  // ╭─ opening line) when the rolling window sheds bytes.
745
- if (this.currentTurnScope) {
615
+ if (this.engine.currentTurnScope) {
746
616
  if (droppedClean > 0) {
747
- this.currentTurnScope.bufferStart = Math.max(0, this.currentTurnScope.bufferStart - droppedClean);
617
+ this.engine.currentTurnScope.bufferStart = Math.max(0, this.engine.currentTurnScope.bufferStart - droppedClean);
748
618
  }
749
619
  if (droppedRaw > 0) {
750
- this.currentTurnScope.rawBufferStart = Math.max(0, this.currentTurnScope.rawBufferStart - droppedRaw);
620
+ this.engine.currentTurnScope.rawBufferStart = Math.max(0, this.engine.currentTurnScope.rawBufferStart - droppedRaw);
751
621
  }
752
622
  }
753
623
 
754
624
  this.resolveStartupState('output', screenText, normalizedScreenSnapshot, now);
755
625
 
756
626
  // ─── Script-based status detection
757
- this.scheduleSettle();
627
+ this.engine.scheduleSettle();
758
628
  }
759
629
 
760
630
  private resolveStartupState(
@@ -779,12 +649,6 @@ export class ProviderCliAdapter implements CliAdapter {
779
649
  const startupModal = this.runParseApproval(this.recentOutputBuffer);
780
650
  const startupStatus = this.runDetectStatus(screenText || this.recentOutputBuffer);
781
651
  if (!startupModal && startupStatus !== 'idle') {
782
- this.recordTrace('startup_settle_deferred', {
783
- trigger,
784
- startupStatus,
785
- stableMs,
786
- screenText: summarizeCliTraceText(screenText, 500),
787
- });
788
652
  this.scheduleStartupSettleCheck();
789
653
  return;
790
654
  }
@@ -795,14 +659,14 @@ export class ProviderCliAdapter implements CliAdapter {
795
659
  }
796
660
  this.ready = true;
797
661
  if (startupModal) {
798
- this.activeModal = startupModal;
799
- this.setStatus('waiting_approval', `startup_ready:${trigger}`);
662
+ this.engine.activeModal = startupModal;
663
+ this.engine.setStatus('waiting_approval', `startup_ready:${trigger}`);
800
664
  } else {
801
- if (this.currentStatus === 'waiting_approval' || this.activeModal) {
802
- this.lastApprovalResolvedAt = Date.now();
665
+ if (this.engine.currentStatus === 'waiting_approval' || this.engine.activeModal) {
666
+ this.engine.lastApprovalResolvedAt = Date.now();
803
667
  }
804
- this.activeModal = null;
805
- this.setStatus('idle', `startup_ready:${trigger}`);
668
+ this.engine.activeModal = null;
669
+ this.engine.setStatus('idle', `startup_ready:${trigger}`);
806
670
  }
807
671
  LOG.info(
808
672
  'CLI',
@@ -828,88 +692,6 @@ export class ProviderCliAdapter implements CliAdapter {
828
692
  }, delayMs);
829
693
  }
830
694
 
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
695
  private async waitForInteractivePrompt(maxWaitMs = 5000): Promise<void> {
914
696
  const startedAt = Date.now();
915
697
  let loggedWait = false;
@@ -919,7 +701,7 @@ export class ProviderCliAdapter implements CliAdapter {
919
701
  const screenText = this.terminalScreen.getText() || '';
920
702
  const stableMs = this.lastScreenChangeAt ? (Date.now() - this.lastScreenChangeAt) : 0;
921
703
  const recentlyOutput = this.lastNonEmptyOutputAt ? (Date.now() - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
922
- const status = this.runDetectStatus(this.recentOutputBuffer) || this.currentStatus;
704
+ const status = this.runDetectStatus(this.recentOutputBuffer) || this.engine.currentStatus;
923
705
  const interactiveReady = status === 'idle'
924
706
  && stableMs >= 700
925
707
  && recentlyOutput >= 350;
@@ -953,795 +735,66 @@ export class ProviderCliAdapter implements CliAdapter {
953
735
 
954
736
  private clearAllTimers(): void {
955
737
  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
738
  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
739
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
963
740
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
964
- if (this.providerErrorRetryTimer) { clearTimeout(this.providerErrorRetryTimer); this.providerErrorRetryTimer = null; }
965
- this.providerErrorRetryKey = '';
966
- }
967
-
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
- }
1019
-
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;
741
+ this.engine.clearAllTimers();
1208
742
  }
1209
743
 
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
- }
744
+ // ─── Script dispatch builds inputs for CliScriptRunner ──────────────────
1237
745
 
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
- }),
746
+ runParseSession(): ParsedSession | null {
747
+ const screenText = this.terminalScreen.getText();
748
+ const parseScreenText = this.getParseScreenText(screenText);
749
+ const tail = this.recentOutputBuffer.slice(-500);
750
+ const input = buildCliParseInput({
751
+ accumulatedBuffer: this.accumulatedBuffer,
752
+ accumulatedRawBuffer: this.accumulatedRawBuffer,
753
+ recentOutputBuffer: this.recentOutputBuffer,
754
+ terminalScreenText: parseScreenText,
755
+ workingDir: this.workingDir,
756
+ providerSessionId: this.providerSessionId || undefined,
757
+ historySessionId: this.providerSessionId || undefined,
758
+ baseMessages: [],
759
+ partialResponse: this.responseBuffer,
760
+ isWaitingForResponse: this.engine.isWaitingForResponse,
761
+ scope: this.engine.currentTurnScope,
762
+ runtimeSettings: this.runtimeSettings,
1466
763
  });
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
- }),
764
+ const session = this.runner.parseSession({
765
+ ...input,
766
+ tail,
767
+ tailScreen: buildCliScreenSnapshot(tail),
1516
768
  });
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();
769
+ if (session && typeof session === 'object') this.applyParsedSessionMetadata(session);
770
+ return session;
1553
771
  }
1554
772
 
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),
773
+ runDetectStatus(text: string): string | null {
774
+ const screenText = this.terminalScreen.getText();
775
+ const tail = text.slice(-500);
776
+ return this.runner.detectStatus({
777
+ tail,
778
+ screenText,
779
+ rawBuffer: this.accumulatedRawBuffer,
780
+ isWaitingForResponse: this.engine.isWaitingForResponse,
781
+ screen: buildCliScreenSnapshot(screenText),
782
+ tailScreen: buildCliScreenSnapshot(tail),
1587
783
  });
1588
- return true;
1589
784
  }
1590
785
 
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
- }
1694
-
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;
786
+ runParseApproval(tail: string): { message: string; buttons: string[] } | null {
787
+ const screenText = this.terminalScreen.getText();
788
+ const buffer = screenText || this.accumulatedBuffer;
789
+ return this.runner.parseApproval({
790
+ buffer,
791
+ screenText,
792
+ rawBuffer: this.accumulatedRawBuffer,
793
+ tail,
794
+ screen: buildCliScreenSnapshot(screenText),
795
+ bufferScreen: buildCliScreenSnapshot(buffer),
796
+ tailScreen: buildCliScreenSnapshot(tail),
1743
797
  });
1744
- return !!lastAssistant;
1745
798
  }
1746
799
 
1747
800
  private applyParsedSessionMetadata(parsed: any): void {
@@ -1752,62 +805,13 @@ export class ProviderCliAdapter implements CliAdapter {
1752
805
  this.providerSessionId = providerSessionId;
1753
806
  this.updateRuntimeMeta({ providerSessionId });
1754
807
  }
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
808
  }
1805
809
 
1806
810
  private projectEffectiveStatus(startupModal: { message: string; buttons: string[] } | null = null): CliSessionStatus['status'] {
1807
811
  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;
812
+ if (!!(startupModal || this.engine.activeModal)) return 'waiting_approval';
813
+ if (this.engine.isWaitingForResponse && this.engine.currentTurnScope && this.engine.currentStatus !== 'stopped') return 'generating';
814
+ return this.engine.currentStatus;
1811
815
  }
1812
816
 
1813
817
  // ─── Public API (CliAdapter) ───────────────────
@@ -1819,7 +823,7 @@ export class ProviderCliAdapter implements CliAdapter {
1819
823
  ? this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText())
1820
824
  : null;
1821
825
  let effectiveStatus = this.projectEffectiveStatus(startupModal);
1822
- let effectiveModal = startupModal || this.activeModal;
826
+ let effectiveModal = startupModal || this.engine.activeModal;
1823
827
  if (startupDetectedStatus === 'waiting_approval') {
1824
828
  effectiveStatus = 'waiting_approval';
1825
829
  } else if (startupDetectedStatus === 'idle' && !startupModal && !effectiveModal) {
@@ -1831,19 +835,23 @@ export class ProviderCliAdapter implements CliAdapter {
1831
835
  && parsed.activeModal.buttons.some((button: any) => typeof button === 'string' && button.trim())
1832
836
  ? parsed.activeModal
1833
837
  : null;
838
+ const hasFinalAssistant = (p: any) => {
839
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
840
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
841
+ };
1834
842
  if (parsed?.status === 'waiting_approval' && parsedModal) {
1835
843
  effectiveStatus = 'waiting_approval';
1836
844
  effectiveModal = parsedModal;
1837
845
  } else if (
1838
846
  effectiveStatus === 'idle'
1839
847
  && parsed?.status === 'generating'
1840
- && !this.parsedStatusHasFinalAssistantMessage(parsed)
848
+ && !hasFinalAssistant(parsed)
1841
849
  ) {
1842
850
  effectiveStatus = 'generating';
1843
851
  } else if (
1844
852
  effectiveStatus === 'generating'
1845
853
  && parsed?.status === 'idle'
1846
- && this.parsedStatusHasFinalAssistantMessage(parsed)
854
+ && hasFinalAssistant(parsed)
1847
855
  ) {
1848
856
  effectiveStatus = 'idle';
1849
857
  }
@@ -1862,8 +870,8 @@ export class ProviderCliAdapter implements CliAdapter {
1862
870
  queuedAt: message.queuedAt,
1863
871
  source: message.source,
1864
872
  })),
1865
- errorMessage: this.parseErrorMessage || this.providerErrorMessage || undefined,
1866
- errorReason: this.parseErrorMessage ? 'parse_error' : (this.providerErrorReason || undefined),
873
+ errorMessage: this.parseErrorMessage || this.engine.providerErrorMessage || undefined,
874
+ errorReason: this.parseErrorMessage ? 'parse_error' : (this.engine.providerErrorReason || undefined),
1867
875
  providerSessionId: this.providerSessionId || undefined,
1868
876
  ...(bufferState ? { bufferState } : {}),
1869
877
  };
@@ -1883,13 +891,13 @@ export class ProviderCliAdapter implements CliAdapter {
1883
891
  !this.providerOwnsTranscript()
1884
892
  && cached
1885
893
  && cached.responseBuffer === this.responseBuffer
1886
- && cached.currentTurnScope === this.currentTurnScope
894
+ && cached.currentTurnScope === this.engine.currentTurnScope
1887
895
  && cached.recentOutputBuffer === this.recentOutputBuffer
1888
896
  && cached.accumulatedBuffer === this.accumulatedBuffer
1889
897
  && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
1890
898
  && cached.screenText === parseScreenText
1891
- && cached.currentStatus === this.currentStatus
1892
- && cached.activeModal === this.activeModal
899
+ && cached.currentStatus === this.engine.currentStatus
900
+ && cached.activeModal === this.engine.activeModal
1893
901
  && cached.cliName === this.cliName
1894
902
  ) {
1895
903
  return cached.result;
@@ -1904,7 +912,7 @@ export class ProviderCliAdapter implements CliAdapter {
1904
912
  const bufferState = this.getBufferState();
1905
913
  const result = {
1906
914
  id: (parsed as any).id || 'cli_session',
1907
- status: parsed.status || this.currentStatus,
915
+ status: parsed.status || this.engine.currentStatus,
1908
916
  title: (parsed as any).title || this.cliName,
1909
917
  messages: normalizeCliParsedMessages(parsed.messages, {
1910
918
  scope: null,
@@ -1929,13 +937,13 @@ export class ProviderCliAdapter implements CliAdapter {
1929
937
 
1930
938
  this.parsedStatusCache = {
1931
939
  responseBuffer: this.responseBuffer,
1932
- currentTurnScope: this.currentTurnScope,
940
+ currentTurnScope: this.engine.currentTurnScope,
1933
941
  recentOutputBuffer: this.recentOutputBuffer,
1934
942
  accumulatedBuffer: this.accumulatedBuffer,
1935
943
  accumulatedRawBufferKey,
1936
944
  screenText: parseScreenText,
1937
- currentStatus: this.currentStatus,
1938
- activeModal: this.activeModal,
945
+ currentStatus: this.engine.currentStatus,
946
+ activeModal: this.engine.activeModal,
1939
947
  cliName: this.cliName,
1940
948
  result,
1941
949
  };
@@ -1943,10 +951,6 @@ export class ProviderCliAdapter implements CliAdapter {
1943
951
  }
1944
952
 
1945
953
  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
954
  const input = buildCliParseInput({
1951
955
  accumulatedBuffer: this.accumulatedBuffer,
1952
956
  accumulatedRawBuffer: this.accumulatedRawBuffer,
@@ -1957,11 +961,11 @@ export class ProviderCliAdapter implements CliAdapter {
1957
961
  historySessionId: this.providerSessionId || undefined,
1958
962
  baseMessages: [],
1959
963
  partialResponse: this.responseBuffer,
1960
- isWaitingForResponse: this.isWaitingForResponse,
1961
- scope: this.currentTurnScope,
964
+ isWaitingForResponse: this.engine.isWaitingForResponse,
965
+ scope: this.engine.currentTurnScope,
1962
966
  runtimeSettings: this.runtimeSettings,
1963
967
  });
1964
- return await Promise.resolve(this.invokeCliScript(fn, {
968
+ return await Promise.resolve(this.runner.invokeByName(scriptName, {
1965
969
  ...input,
1966
970
  args: args && typeof args === 'object' ? { ...args } : {},
1967
971
  }));
@@ -1973,7 +977,7 @@ export class ProviderCliAdapter implements CliAdapter {
1973
977
 
1974
978
  /** Whether this adapter has CLI scripts loaded */
1975
979
  hasCliScripts(): boolean {
1976
- return typeof this.cliScripts?.detectStatus === 'function';
980
+ return this.runner.hasDetectStatus();
1977
981
  }
1978
982
 
1979
983
  /**
@@ -1982,24 +986,24 @@ export class ProviderCliAdapter implements CliAdapter {
1982
986
  */
1983
987
  async resolveAction(data: any): Promise<void> {
1984
988
  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
- }
989
+ try {
990
+ promptText = this.runner.invokeByName('resolveAction', data);
991
+ } catch {
992
+ LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script not available`);
993
+ return;
1991
994
  }
1992
995
  if (!promptText) {
1993
- LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script did not supply a prompt`);
996
+ LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script did not return a prompt`);
1994
997
  return;
1995
998
  }
1996
999
  await this.sendMessage(promptText);
1997
1000
  }
1998
1001
 
1999
1002
  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;
1003
+ if (!this.ptyProcess || !this.engine.isWaitingForResponse || this.engine.submitRetryUsed) return false;
1004
+ if (this.engine.hasActionableApproval()) return false;
1005
+ // If there's already meaningful response content beyond the echoed prompt, not stuck
1006
+ if (this.hasMeaningfulResponseBufferLocal(normalizedPromptSnippet)) return false;
2003
1007
  const screenText = this.terminalScreen.getText();
2004
1008
  if (!promptLikelyVisible(screenText, normalizedPromptSnippet)) return false;
2005
1009
  const liveApproval = this.runParseApproval(screenText) || this.runParseApproval(this.recentOutputBuffer);
@@ -2008,20 +1012,41 @@ export class ProviderCliAdapter implements CliAdapter {
2008
1012
  return liveStatus !== 'generating' && liveStatus !== 'waiting_approval';
2009
1013
  }
2010
1014
 
1015
+ private hasMeaningfulResponseBufferLocal(promptSnippet: string): boolean {
1016
+ const raw = String(this.responseBuffer || '').trim();
1017
+ if (!raw) return false;
1018
+ const normalizedPrompt = compactPromptText(promptSnippet);
1019
+ if (!normalizedPrompt) return true;
1020
+ const normalizedBuffer = compactPromptText(raw);
1021
+ if (!normalizedBuffer) return false;
1022
+ if (normalizedBuffer === normalizedPrompt) return false;
1023
+ if (normalizedBuffer.startsWith(normalizedPrompt)) {
1024
+ const remainder = normalizedBuffer
1025
+ .slice(normalizedPrompt.length)
1026
+ .replace(/[─═\-]+/g, '')
1027
+ .replace(/⏵⏵accepteditson\([^)]*\)/gi, '')
1028
+ .replace(/accepteditson\([^)]*\)/gi, '')
1029
+ .replace(/(?:◐|◑|◒|◓|◔|◕|◉|●|·)?(?:x?high|medium|low|max)·?\/effort/gi, '')
1030
+ .replace(/updateavailable!run:[a-z0-9:._\-/]+/gi, '')
1031
+ .replace(/esctointerrupt/gi, '')
1032
+ .replace(/❯/g, '')
1033
+ .replace(/^[\s\-–—:;,.!/?]+/, '')
1034
+ .trim();
1035
+ return remainder.length > 0;
1036
+ }
1037
+ return true;
1038
+ }
1039
+
2011
1040
  private async writeToPty(data: string): Promise<void> {
2012
1041
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
2013
1042
  await this.ptyProcess.write(data);
2014
1043
  }
2015
1044
 
2016
1045
  private resetPendingSendState(reason: string): void {
2017
- this.isWaitingForResponse = false;
2018
1046
  this.responseBuffer = '';
2019
- this.currentTurnScope = null;
2020
- this.submitPendingUntil = 0;
2021
- this.clearIdleFinishCandidate(reason);
2022
1047
  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; }
1048
+ this.engine.resetActiveTurnState();
1049
+ this.engine.clearIdleFinishCandidate(reason);
2025
1050
  }
2026
1051
 
2027
1052
  private commitSendUserTurn(state: SendMessageState): void {
@@ -2038,42 +1063,14 @@ export class ProviderCliAdapter implements CliAdapter {
2038
1063
  }
2039
1064
  this.responseTimeout = setTimeout(() => {
2040
1065
  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
- });
1066
+ if (!this.engine.isWaitingForResponse) return;
2058
1067
 
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
1068
+ // maxResponse is a watchdog/checkpoint, not a completion signal. Re-run the
2063
1069
  // normal settled parser instead and keep the turn open unless the provider
2064
1070
  // 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
- });
1071
+ this.engine.evaluateSettled(this.getSnapshot());
1072
+
1073
+ if (this.engine.isWaitingForResponse && !this.engine.hasActionableApproval()) {
2077
1074
  this.armResponseTimeout();
2078
1075
  }
2079
1076
  }, timeoutMs);
@@ -2088,34 +1085,20 @@ export class ProviderCliAdapter implements CliAdapter {
2088
1085
  private retrySubmitIfStuck(state: SendMessageState, attempt: number): void {
2089
1086
  this.submitRetryTimer = null;
2090
1087
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
2091
- const screenText = this.terminalScreen.getText();
2092
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1088
+ this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
2093
1089
  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
1090
  this.writeSubmitKeyForRetry('submit_retry');
2101
- if (attempt >= 3) { this.submitRetryUsed = true; return; }
1091
+ if (attempt >= 3) { this.engine.submitRetryUsed = true; return; }
2102
1092
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, attempt + 1), state.retryDelayMs);
2103
1093
  }
2104
1094
 
2105
1095
  private retryImmediateSubmitIfStuck(state: SendMessageState): void {
2106
1096
  this.submitRetryTimer = null;
2107
1097
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
2108
- const screenText = this.terminalScreen.getText();
2109
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1098
+ this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
2110
1099
  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
1100
  this.writeSubmitKeyForRetry('immediate_retry');
2118
- this.submitRetryUsed = true;
1101
+ this.engine.submitRetryUsed = true;
2119
1102
  }
2120
1103
 
2121
1104
  private submitSendKey(state: SendMessageState, completion: SendMessageCompletion): void {
@@ -2123,13 +1106,7 @@ export class ProviderCliAdapter implements CliAdapter {
2123
1106
  completion.resolveOnce();
2124
1107
  return;
2125
1108
  }
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
- });
1109
+ this.engine.submitPendingUntil = 0;
2133
1110
  void this.writeToPty(this.sendKey).then(() => {
2134
1111
  this.commitSendUserTurn(state);
2135
1112
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, 1), state.retryDelayMs);
@@ -2139,13 +1116,7 @@ export class ProviderCliAdapter implements CliAdapter {
2139
1116
  }
2140
1117
 
2141
1118
  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
- });
1119
+ this.engine.submitPendingUntil = 0;
2149
1120
  void this.writeToPty(state.text + this.sendKey).then(() => {
2150
1121
  this.commitSendUserTurn(state);
2151
1122
  this.submitRetryTimer = setTimeout(() => this.retryImmediateSubmitIfStuck(state), state.retryDelayMs);
@@ -2189,7 +1160,8 @@ export class ProviderCliAdapter implements CliAdapter {
2189
1160
  requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
2190
1161
  screenText: summarizeCliTraceText(screenText, 1000),
2191
1162
  };
2192
- this.recordTrace('submit_echo_missing', diagnostic);
1163
+ LOG.warn('CLI', `[${this.cliType}] submit_echo_missing: ${JSON.stringify(diagnostic)}`);
1164
+
2193
1165
  if (this.requirePromptEchoBeforeSubmit) {
2194
1166
  // At this point the prompt text write already completed. Rejecting without
2195
1167
  // a submit key can leave the delegated CLI with an unsent prompt sitting at
@@ -2226,13 +1198,7 @@ export class ProviderCliAdapter implements CliAdapter {
2226
1198
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
2227
1199
  const content = String(text || '');
2228
1200
  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}`);
1201
+ LOG.info('CLI', `[${this.cliType}] force-sending prompt while status=${this.engine.currentStatus}`);
2236
1202
  await this.writeToPty(content + this.sendKey);
2237
1203
  this.onStatusChange?.();
2238
1204
  }
@@ -2241,11 +1207,6 @@ export class ProviderCliAdapter implements CliAdapter {
2241
1207
  const content = String(text || '');
2242
1208
  const duplicate = this.pendingOutboundQueue.some((message) => message.content === content);
2243
1209
  if (duplicate) {
2244
- this.recordTrace('send_message_queued_duplicate_suppressed', {
2245
- reason,
2246
- queueLength: this.pendingOutboundQueue.length,
2247
- text: summarizeCliTraceText(content, 500),
2248
- });
2249
1210
  return;
2250
1211
  }
2251
1212
  const queuedAt = Date.now();
@@ -2257,24 +1218,22 @@ export class ProviderCliAdapter implements CliAdapter {
2257
1218
  source: 'sendMessage',
2258
1219
  };
2259
1220
  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
1221
  LOG.info('CLI', `[${this.cliType}] queued outbound message while busy (${reason}); queue=${this.pendingOutboundQueue.length}`);
2267
1222
  this.onStatusChange?.();
2268
1223
  }
2269
1224
 
2270
1225
  private shouldQueuePendingOutboundMessage(parsedStatusBeforeSend: any | null = null): string | null {
2271
1226
  if (this.provider.allowInputDuringGeneration === true) return null;
2272
- if (this.hasActionableApproval()) return null;
1227
+ if (this.engine.hasActionableApproval()) return null;
2273
1228
  const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
2274
1229
  ? String(parsedStatusBeforeSend.status)
2275
1230
  : '';
2276
- if (parsedSessionStatus === 'idle' && this.parsedStatusHasFinalAssistantMessage(parsedStatusBeforeSend)) return null;
2277
- if (this.currentStatus === 'generating') return 'current_status_generating';
1231
+ const hasFinalAssistant = (p: any) => {
1232
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
1233
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
1234
+ };
1235
+ if (parsedSessionStatus === 'idle' && hasFinalAssistant(parsedStatusBeforeSend)) return null;
1236
+ if (this.engine.currentStatus === 'generating') return 'current_status_generating';
2278
1237
  if (parsedSessionStatus === 'generating' || parsedSessionStatus === 'long_generating') {
2279
1238
  const parsedModal = parsedStatusBeforeSend?.activeModal ?? parsedStatusBeforeSend?.modal ?? null;
2280
1239
  const parsedHasActionableModal = Boolean(
@@ -2282,15 +1241,15 @@ export class ProviderCliAdapter implements CliAdapter {
2282
1241
  && Array.isArray(parsedModal.buttons)
2283
1242
  && parsedModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim()),
2284
1243
  );
2285
- const terminalLooksIdle = this.currentStatus === 'idle'
1244
+ const terminalLooksIdle = this.engine.currentStatus === 'idle'
2286
1245
  && this.runDetectStatus(this.recentOutputBuffer) === 'idle'
2287
- && !this.isWaitingForResponse
2288
- && !this.currentTurnScope
2289
- && !this.hasActionableApproval()
1246
+ && !this.engine.isWaitingForResponse
1247
+ && !this.engine.currentTurnScope
1248
+ && !this.engine.hasActionableApproval()
2290
1249
  && !parsedHasActionableModal;
2291
1250
  return terminalLooksIdle ? null : `parsed_status_${parsedSessionStatus}`;
2292
1251
  }
2293
- if (this.isWaitingForResponse && this.currentTurnScope) return 'active_turn_in_progress';
1252
+ if (this.engine.isWaitingForResponse && this.engine.currentTurnScope) return 'active_turn_in_progress';
2294
1253
  return null;
2295
1254
  }
2296
1255
 
@@ -2304,18 +1263,12 @@ export class ProviderCliAdapter implements CliAdapter {
2304
1263
 
2305
1264
  private async flushPendingOutboundQueue(): Promise<void> {
2306
1265
  if (this.pendingOutboundFlushInFlight || this.pendingOutboundQueue.length === 0) return;
2307
- if (this.currentStatus !== 'idle' || this.isWaitingForResponse || this.hasActionableApproval()) return;
1266
+ if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) return;
2308
1267
  this.pendingOutboundFlushInFlight = true;
2309
1268
  try {
2310
1269
  while (this.pendingOutboundQueue.length > 0) {
2311
- if (this.currentStatus !== 'idle' || this.isWaitingForResponse || this.hasActionableApproval()) break;
1270
+ if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) break;
2312
1271
  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
1272
  try {
2320
1273
  await this.sendMessageNow(next.content, false);
2321
1274
  this.pendingOutboundQueue.shift();
@@ -2335,8 +1288,8 @@ export class ProviderCliAdapter implements CliAdapter {
2335
1288
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
2336
1289
  const allowInputDuringGeneration = this.provider.allowInputDuringGeneration === true;
2337
1290
  const allowInterventionPrompt = allowInputDuringGeneration
2338
- && this.isWaitingForResponse
2339
- && !this.hasActionableApproval();
1291
+ && this.engine.isWaitingForResponse
1292
+ && !this.engine.hasActionableApproval();
2340
1293
  if (this.startupParseGate) {
2341
1294
  const deadline = Date.now() + 10000;
2342
1295
  while (this.startupParseGate && Date.now() < deadline) {
@@ -2366,11 +1319,11 @@ export class ProviderCliAdapter implements CliAdapter {
2366
1319
  if (this.runDetectStatus(this.recentOutputBuffer) === 'idle') {
2367
1320
  this.ready = true;
2368
1321
  this.startupParseGate = false;
2369
- this.setStatus('idle', 'send_message_idle_prompt_recovery');
1322
+ this.engine.setStatus('idle', 'send_message_idle_prompt_recovery');
2370
1323
  LOG.info('CLI', `[${this.cliType}] sendMessage recovered idle prompt readiness`);
2371
1324
  }
2372
1325
  }
2373
- if (!this.ready) throw new Error(`${this.cliName} not ready (status: ${this.currentStatus})`);
1326
+ if (!this.ready) throw new Error(`${this.cliName} not ready (status: ${this.engine.currentStatus})`);
2374
1327
  const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
2375
1328
  ? String(parsedStatusBeforeSend.status)
2376
1329
  : '';
@@ -2381,11 +1334,11 @@ export class ProviderCliAdapter implements CliAdapter {
2381
1334
  && Array.isArray(parsedModal.buttons)
2382
1335
  && parsedModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim()),
2383
1336
  );
2384
- const terminalLooksIdle = this.currentStatus === 'idle'
1337
+ const terminalLooksIdle = this.engine.currentStatus === 'idle'
2385
1338
  && this.runDetectStatus(this.recentOutputBuffer) === 'idle'
2386
- && !this.isWaitingForResponse
2387
- && !this.currentTurnScope
2388
- && !this.hasActionableApproval()
1339
+ && !this.engine.isWaitingForResponse
1340
+ && !this.engine.currentTurnScope
1341
+ && !this.engine.hasActionableApproval()
2389
1342
  && !parsedHasActionableModal;
2390
1343
  if (!terminalLooksIdle) {
2391
1344
  if (allowQueue) {
@@ -2395,10 +1348,11 @@ export class ProviderCliAdapter implements CliAdapter {
2395
1348
  throw new Error(`${this.cliName} is still processing the previous prompt`);
2396
1349
  }
2397
1350
  }
2398
- if (this.isWaitingForResponse && !allowInputDuringGeneration) {
1351
+ if (this.engine.isWaitingForResponse && !allowInputDuringGeneration) {
1352
+ const snap = this.getSnapshot();
2399
1353
  if (
2400
- !this.clearStaleIdleResponseGuard('send_message_guard')
2401
- && !this.clearParsedIdleResponseGuard('send_message_parsed_idle_guard', parsedStatusBeforeSend)
1354
+ !this.engine.clearStaleIdleResponseGuard('send_message_guard', snap)
1355
+ && !this.engine.clearParsedIdleResponseGuard('send_message_parsed_idle_guard', parsedStatusBeforeSend, snap)
2402
1356
  ) {
2403
1357
  if (allowQueue) {
2404
1358
  this.enqueuePendingOutboundMessage(text, 'waiting_for_response');
@@ -2407,30 +1361,21 @@ export class ProviderCliAdapter implements CliAdapter {
2407
1361
  throw new Error(`${this.cliName} is still processing the previous prompt`);
2408
1362
  }
2409
1363
  }
2410
- this.isWaitingForResponse = true;
2411
1364
  this.responseBuffer = '';
2412
- this.finishRetryCount = 0;
2413
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
2414
- this.clearIdleFinishCandidate('send_message');
2415
- this.currentTurnScope = {
1365
+ const turnScope: TurnParseScope = {
2416
1366
  prompt: text,
2417
1367
  startedAt: Date.now(),
2418
1368
  bufferStart: this.accumulatedBuffer.length,
2419
1369
  rawBufferStart: this.accumulatedRawBuffer.length,
2420
1370
  };
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);
1371
+ LOG.info('CLI', `[${this.cliType}] sendMessage turn scope buffer=${turnScope.bufferStart} raw=${turnScope.rawBufferStart} prompt=${JSON.stringify(text).slice(0, 120)}`);
2430
1372
  if (this.submitRetryTimer) {
2431
1373
  clearTimeout(this.submitRetryTimer);
2432
1374
  this.submitRetryTimer = null;
2433
1375
  }
1376
+ this.engine.onTurnStarted(turnScope);
1377
+ this.engine.submitRetryPromptSnippet = extractPromptRetrySnippet(text);
1378
+ const normalizedPromptSnippet = normalizePromptText(this.engine.submitRetryPromptSnippet);
2434
1379
  const estimatedLines = estimatePromptDisplayLines(text);
2435
1380
  const submitDelayMs = this.sendDelayMs + Math.min(2000, Math.max(0, estimatedLines - 1) * 350);
2436
1381
  const maxEchoWaitMs = submitDelayMs + Math.max(1500, Math.min(5000, estimatedLines * 500));
@@ -2443,12 +1388,7 @@ export class ProviderCliAdapter implements CliAdapter {
2443
1388
  retryDelayMs,
2444
1389
  didCommitUserTurn: false,
2445
1390
  };
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;
1391
+ this.engine.responseSettleIgnoreUntil = Date.now() + submitDelayMs + this.timeouts.outputSettle + 250;
2452
1392
  await new Promise<void>((resolve, reject) => {
2453
1393
  let resolved = false;
2454
1394
  const completion: SendMessageCompletion = {
@@ -2471,24 +1411,20 @@ export class ProviderCliAdapter implements CliAdapter {
2471
1411
  }
2472
1412
 
2473
1413
  if (submitDelayMs > 0) {
2474
- this.submitPendingUntil = Date.now() + submitDelayMs;
1414
+ this.engine.submitPendingUntil = Date.now() + submitDelayMs;
2475
1415
  }
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
1416
  const submitStartedAt = Date.now();
2483
1417
  void this.writeToPty(text).then(
2484
1418
  () => this.waitForEchoAndSubmit(sendState, completion, submitStartedAt),
2485
1419
  completion.rejectOnce,
2486
1420
  );
2487
1421
  });
1422
+ // Schedule settle after successful send
1423
+ this.engine.scheduleSettle();
2488
1424
  }
2489
1425
 
2490
1426
  getPartialResponse(): string {
2491
- if (!this.isWaitingForResponse) return '';
1427
+ if (!this.engine.isWaitingForResponse) return '';
2492
1428
  return this.responseBuffer;
2493
1429
  }
2494
1430
 
@@ -2501,10 +1437,10 @@ export class ProviderCliAdapter implements CliAdapter {
2501
1437
  cliType: this.cliType,
2502
1438
  cliName: this.cliName,
2503
1439
  workingDir: this.workingDir,
2504
- currentStatus: this.currentStatus,
1440
+ currentStatus: this.engine.currentStatus,
2505
1441
  ready: this.ready,
2506
- isWaitingForResponse: this.isWaitingForResponse,
2507
- activeModal: this.activeModal,
1442
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1443
+ activeModal: this.engine.activeModal,
2508
1444
  parseErrorMessage: this.parseErrorMessage,
2509
1445
  messageCounts: {
2510
1446
  parsedCache: Array.isArray(parsedResult?.messages) ? parsedResult.messages.length : undefined,
@@ -2530,10 +1466,10 @@ export class ProviderCliAdapter implements CliAdapter {
2530
1466
  lastScreenSnapshotReadAt: this.lastScreenSnapshotReadAt,
2531
1467
  },
2532
1468
  parser: {
2533
- scriptNames: listCliScriptNames(this.cliScripts),
2534
- traceSessionId: this.traceSessionId,
2535
- traceSeq: this.traceSeq,
2536
- currentTurnScope: this.currentTurnScope,
1469
+ scriptNames: this.runner.getScriptNames(),
1470
+ traceSessionId: this.engine.getTraceSessionId(),
1471
+ traceSeq: this.engine.getTraceEntries().length,
1472
+ currentTurnScope: this.engine.currentTurnScope,
2537
1473
  parsedStatusCache: parsedResult
2538
1474
  ? {
2539
1475
  id: parsedResult.id,
@@ -2546,26 +1482,25 @@ export class ProviderCliAdapter implements CliAdapter {
2546
1482
  activeModal: parsedResult.activeModal,
2547
1483
  }
2548
1484
  : null,
2549
- pendingScriptStatus: this.pendingScriptStatus,
2550
- pendingScriptStatusSince: this.pendingScriptStatusSince,
1485
+ pendingScriptStatus: this.engine.pendingScriptStatus,
1486
+ pendingScriptStatusSince: this.engine.pendingScriptStatusSince,
2551
1487
  },
2552
1488
  runtimeMetadata: this.getRuntimeMetadata(),
2553
- statusHistory: this.statusHistory.slice(-80),
2554
- traceEntries: this.traceEntries.slice(-120),
1489
+ statusHistory: this.engine.getStatusHistory().slice(-80),
1490
+ traceEntries: this.engine.getTraceEntries().slice(-120),
2555
1491
  timing: {
2556
1492
  spawnAt: this.spawnAt,
2557
1493
  startupFirstOutputAt: this.startupFirstOutputAt,
2558
- submitPendingUntil: this.submitPendingUntil,
2559
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
2560
- responseEpoch: this.responseEpoch,
1494
+ submitPendingUntil: this.engine.submitPendingUntil,
1495
+ responseSettleIgnoreUntil: this.engine.responseSettleIgnoreUntil,
1496
+ responseEpoch: this.engine.responseEpoch,
2561
1497
  resizeSuppressUntil: this.resizeSuppressUntil,
2562
- lastApprovalResolvedAt: this.lastApprovalResolvedAt,
1498
+ lastApprovalResolvedAt: this.engine.lastApprovalResolvedAt,
2563
1499
  },
2564
1500
  finish: {
2565
- idleFinishCandidate: this.idleFinishCandidate,
2566
- finishRetryCount: this.finishRetryCount,
2567
- submitRetryUsed: this.submitRetryUsed,
2568
- submitRetryPromptSnippet: this.submitRetryPromptSnippet,
1501
+ finishRetryCount: this.engine.finishRetryCount,
1502
+ submitRetryUsed: this.engine.submitRetryUsed,
1503
+ submitRetryPromptSnippet: this.engine.submitRetryPromptSnippet,
2569
1504
  },
2570
1505
  };
2571
1506
  }
@@ -2602,7 +1537,7 @@ export class ProviderCliAdapter implements CliAdapter {
2602
1537
  this.timeouts.shutdownGrace,
2603
1538
  typeof resume.shutdownGraceMs === 'number' ? resume.shutdownGraceMs : 3000,
2604
1539
  );
2605
- const wasProcessing = this.currentStatus === 'generating' || this.currentStatus === 'waiting_approval';
1540
+ const wasProcessing = this.engine.currentStatus === 'generating' || this.engine.currentStatus === 'waiting_approval';
2606
1541
 
2607
1542
  try {
2608
1543
  if (wasProcessing) {
@@ -2640,7 +1575,7 @@ export class ProviderCliAdapter implements CliAdapter {
2640
1575
  return new Promise((resolve) => {
2641
1576
  const startedAt = Date.now();
2642
1577
  const timer = setInterval(() => {
2643
- if (!this.ptyProcess || this.currentStatus === 'stopped') {
1578
+ if (!this.ptyProcess || this.engine.currentStatus === 'stopped') {
2644
1579
  clearInterval(timer);
2645
1580
  resolve(true);
2646
1581
  return;
@@ -2654,12 +1589,11 @@ export class ProviderCliAdapter implements CliAdapter {
2654
1589
  }
2655
1590
 
2656
1591
  shutdown(): void {
2657
- this.clearIdleFinishCandidate('shutdown');
1592
+ this.engine.clearIdleFinishCandidate('shutdown');
2658
1593
  this.clearAllTimers();
2659
1594
  this.pendingOutputParseChunks = [];
2660
1595
  this.pendingTerminalQueryTail = '';
2661
1596
  this.ptyOutputChunks = [];
2662
- this.finishRetryCount = 0;
2663
1597
  if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
2664
1598
  this.pendingOutboundQueue = [];
2665
1599
  this.pendingOutboundFlushInFlight = false;
@@ -2668,7 +1602,7 @@ export class ProviderCliAdapter implements CliAdapter {
2668
1602
  setTimeout(() => {
2669
1603
  try { this.ptyProcess?.kill(); } catch { }
2670
1604
  this.ptyProcess = null;
2671
- this.setStatus('stopped', 'stop_cmd');
1605
+ this.engine.setStatus('stopped', 'stop_cmd');
2672
1606
  this.ready = false;
2673
1607
  this.startupParseGate = false;
2674
1608
  this.spawnAt = 0;
@@ -2678,12 +1612,11 @@ export class ProviderCliAdapter implements CliAdapter {
2678
1612
  }
2679
1613
 
2680
1614
  detach(): void {
2681
- this.clearIdleFinishCandidate('detach');
1615
+ this.engine.clearIdleFinishCandidate('detach');
2682
1616
  this.clearAllTimers();
2683
1617
  this.pendingOutputParseChunks = [];
2684
1618
  this.pendingTerminalQueryTail = '';
2685
1619
  this.ptyOutputChunks = [];
2686
- this.finishRetryCount = 0;
2687
1620
  if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
2688
1621
  this.pendingOutboundQueue = [];
2689
1622
  this.pendingOutboundFlushInFlight = false;
@@ -2704,19 +1637,17 @@ export class ProviderCliAdapter implements CliAdapter {
2704
1637
  }
2705
1638
 
2706
1639
  clearHistory(): void {
2707
- this.clearIdleFinishCandidate('clear_history');
1640
+ this.engine.clearIdleFinishCandidate('clear_history');
2708
1641
  this.accumulatedBuffer = '';
2709
1642
  this.accumulatedRawBuffer = '';
2710
- this.currentTurnScope = null;
2711
- this.submitRetryUsed = false;
2712
- this.submitRetryPromptSnippet = '';
1643
+ this.engine.currentTurnScope = null;
1644
+ this.engine.submitRetryUsed = false;
1645
+ this.engine.submitRetryPromptSnippet = '';
2713
1646
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
2714
1647
  this.pendingOutputParseChunks = [];
2715
1648
  this.pendingTerminalQueryTail = '';
2716
1649
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
2717
1650
  this.ptyOutputChunks = [];
2718
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
2719
- this.finishRetryCount = 0;
2720
1651
  if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
2721
1652
  this.pendingOutboundQueue = [];
2722
1653
  this.pendingOutboundFlushInFlight = false;
@@ -2725,76 +1656,109 @@ export class ProviderCliAdapter implements CliAdapter {
2725
1656
  this.onStatusChange?.();
2726
1657
  }
2727
1658
 
2728
- isProcessing(): boolean { return this.isWaitingForResponse; }
1659
+ isProcessing(): boolean { return this.engine.isWaitingForResponse; }
2729
1660
  isReady(): boolean { return this.ready; }
2730
1661
 
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);
1662
+ // ─── State machine property accessors (delegate to engine) ──────────────
1663
+ // These expose engine state for external callers (tests, debug tools, etc.)
1664
+
1665
+ get currentStatus(): CliSessionStatus['status'] { return this.engine.currentStatus; }
1666
+ set currentStatus(v: CliSessionStatus['status']) { this.engine.setStatus(v); }
1667
+
1668
+ get isWaitingForResponse(): boolean { return this.engine.isWaitingForResponse; }
1669
+ set isWaitingForResponse(v: boolean) { this.engine.isWaitingForResponse = v; }
1670
+
1671
+ get activeModal(): { message: string; buttons: string[] } | null { return this.engine.activeModal; }
1672
+ set activeModal(v: { message: string; buttons: string[] } | null) { this.engine.activeModal = v; }
1673
+
1674
+ get currentTurnScope(): TurnParseScope | null { return this.engine.currentTurnScope; }
1675
+ set currentTurnScope(v: TurnParseScope | null) { this.engine.currentTurnScope = v; }
1676
+
1677
+ get responseEpoch(): number { return this.engine.responseEpoch; }
1678
+ set responseEpoch(v: number) { this.engine.responseEpoch = v; }
1679
+
1680
+ get submitRetryUsed(): boolean { return this.engine.submitRetryUsed; }
1681
+ set submitRetryUsed(v: boolean) { this.engine.submitRetryUsed = v; }
1682
+
1683
+ get submitRetryPromptSnippet(): string { return this.engine.submitRetryPromptSnippet; }
1684
+ set submitRetryPromptSnippet(v: string) { this.engine.submitRetryPromptSnippet = v; }
1685
+
1686
+ get responseSettleIgnoreUntil(): number { return this.engine.responseSettleIgnoreUntil; }
1687
+ set responseSettleIgnoreUntil(v: number) { this.engine.responseSettleIgnoreUntil = v; }
1688
+
1689
+ get submitPendingUntil(): number { return this.engine.submitPendingUntil; }
1690
+ set submitPendingUntil(v: number) { this.engine.submitPendingUntil = v; }
1691
+
1692
+ get lastApprovalResolvedAt(): number { return this.engine.lastApprovalResolvedAt; }
1693
+ set lastApprovalResolvedAt(v: number) { this.engine.lastApprovalResolvedAt = v; }
1694
+
1695
+ get providerErrorMessage(): string | null { return this.engine.providerErrorMessage; }
1696
+ get providerErrorReason(): string | null { return this.engine.providerErrorReason; }
1697
+
1698
+ get pendingScriptStatus(): 'generating' | 'waiting_approval' | null { return this.engine.pendingScriptStatus; }
1699
+ get pendingScriptStatusSince(): number { return this.engine.pendingScriptStatusSince; }
1700
+
1701
+ get finishRetryCount(): number { return this.engine.finishRetryCount; }
1702
+ set finishRetryCount(v: number) { this.engine.finishRetryCount = v; }
1703
+
1704
+ get traceSessionId(): string { return this.engine.getTraceSessionId(); }
1705
+ get traceEntries(): CliTraceEntry[] { return this.engine.getTraceEntries(); }
1706
+ get statusHistory(): { status: string; at: number; trigger?: string }[] { return this.engine.getStatusHistory(); }
1707
+ get traceSeq(): number { return this.engine.getTraceEntries().length; }
1708
+
1709
+ /** Expose engine's evaluateSettled for test access */
1710
+ evaluateSettled(): void {
1711
+ LOG.debug(
1712
+ 'CLI',
1713
+ `[${this.cliType}] settled diagnostics delegated to state engine`);
1714
+ this.engine.evaluateSettled(this.getSnapshot());
1715
+ }
1716
+ /** Expose engine's scheduleSettle for test access */
1717
+ scheduleSettle(): void { this.engine.scheduleSettle(); }
1718
+ /** Expose engine's clearIdleFinishCandidate for test access */
1719
+ clearIdleFinishCandidate(reason: string): void { this.engine.clearIdleFinishCandidate(reason); }
1720
+ /** Expose engine's finishResponse for test access */
1721
+ finishResponse(): void { this.engine.finishResponse(); }
1722
+ /** Returns a point-in-time snapshot of all buffer/screen state for external consumers (e.g. CliStateEngine). */
1723
+ getSnapshot(): CliBufferSnapshot {
1724
+ const screenText = this.terminalScreen.getText() || '';
1725
+ return {
1726
+ accumulatedBuffer: this.accumulatedBuffer,
1727
+ accumulatedRawBuffer: this.accumulatedRawBuffer,
1728
+ recentOutputBuffer: this.recentOutputBuffer,
1729
+ responseBuffer: this.responseBuffer,
1730
+ screenText,
1731
+ parseScreenText: this.getParseScreenText(screenText),
1732
+ workingDir: this.workingDir,
1733
+ providerSessionId: this.providerSessionId,
1734
+ runtimeSettings: this.runtimeSettings,
1735
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1736
+ currentTurnScope: this.engine.currentTurnScope,
1737
+ lastOutputAt: this.lastOutputAt,
1738
+ lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
1739
+ lastScreenChangeAt: this.lastScreenChangeAt,
1740
+ lastScreenSnapshot: this.lastScreenSnapshot,
1741
+ };
1742
+ }
1743
+ isAlive(): boolean { return this.ptyProcess !== null; }
1744
+ flushOutboundQueue(): void { this.schedulePendingOutboundFlush(); }
1745
+
1746
+ async writeRaw(data: string | Buffer): Promise<void> {
1747
+ const str = Buffer.isBuffer(data) ? data.toString('utf8') : data;
1748
+ await this.writeToPty(str);
2737
1749
  }
2738
1750
 
2739
1751
  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
- }
1752
+ this.engine.resolveModal(buttonIndex);
1753
+ }
1754
+
1755
+ getApprovalKeyForIndex(buttonIndex: number): string | undefined {
1756
+ return buttonIndex in this.approvalKeys ? this.approvalKeys[buttonIndex] : undefined;
2793
1757
  }
2794
1758
 
2795
1759
  /** Returns true if an approval was resolved within the adapter's cooldown window. */
2796
1760
  isApprovalRecentlyResolved(): boolean {
2797
- return !!(this.lastApprovalResolvedAt && (Date.now() - this.lastApprovalResolvedAt) < this.timeouts.approvalCooldown);
1761
+ return this.engine.isApprovalRecentlyResolved();
2798
1762
  }
2799
1763
 
2800
1764
  resize(cols: number, rows: number): void {
@@ -2808,7 +1772,7 @@ export class ProviderCliAdapter implements CliAdapter {
2808
1772
  }
2809
1773
 
2810
1774
  private getParsedDebugState(): Record<string, any> | null {
2811
- if (this.startupParseGate || typeof this.cliScripts?.parseSession !== 'function') return null;
1775
+ if (this.startupParseGate || !this.runner.hasParseSession()) return null;
2812
1776
  try {
2813
1777
  const parsed = this.getScriptParsedStatus();
2814
1778
  return parsed && typeof parsed === 'object' ? parsed as Record<string, any> : null;
@@ -2826,6 +1790,10 @@ export class ProviderCliAdapter implements CliAdapter {
2826
1790
  const effectiveReady = this.ready || !!startupModal || startupDetectedStatus === 'waiting_approval';
2827
1791
  const parsedDebugState = this.getParsedDebugState();
2828
1792
  const parsedMessages = Array.isArray(parsedDebugState?.messages) ? parsedDebugState.messages : [];
1793
+ const hasFinalAssistant = (p: any) => {
1794
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
1795
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
1796
+ };
2829
1797
  let effectiveStatus = this.projectEffectiveStatus(startupModal);
2830
1798
  if (parsedDebugState?.status === 'error') {
2831
1799
  effectiveStatus = 'error';
@@ -2836,7 +1804,7 @@ export class ProviderCliAdapter implements CliAdapter {
2836
1804
  if (
2837
1805
  effectiveStatus === 'idle'
2838
1806
  && parsedDebugState?.status === 'generating'
2839
- && !this.parsedStatusHasFinalAssistantMessage(parsedDebugState)
1807
+ && !hasFinalAssistant(parsedDebugState)
2840
1808
  ) {
2841
1809
  effectiveStatus = 'generating';
2842
1810
  }
@@ -2846,8 +1814,8 @@ export class ProviderCliAdapter implements CliAdapter {
2846
1814
  providerResolution: this.providerResolutionMeta,
2847
1815
  status: effectiveStatus,
2848
1816
  projectedStatus: effectiveStatus,
2849
- rawStatus: this.currentStatus,
2850
- lifecycleStatus: this.isWaitingForResponse ? 'awaiting_response' : 'idle',
1817
+ rawStatus: this.engine.currentStatus,
1818
+ lifecycleStatus: this.engine.isWaitingForResponse ? 'awaiting_response' : 'idle',
2851
1819
  ready: effectiveReady,
2852
1820
  startupParseGate: this.startupParseGate,
2853
1821
  spawnAt: this.spawnAt,
@@ -2867,10 +1835,9 @@ export class ProviderCliAdapter implements CliAdapter {
2867
1835
  messageCount: parsedMessages.length,
2868
1836
  } : null,
2869
1837
  screenText: screenText.slice(-4000),
2870
- currentTurnScope: this.currentTurnScope,
1838
+ currentTurnScope: this.engine.currentTurnScope,
2871
1839
  startupBuffer: this.startupBuffer.slice(-4000),
2872
1840
  recentOutputBuffer: this.recentOutputBuffer.slice(-500),
2873
- settledBuffer: this.settledBuffer.slice(-500),
2874
1841
  accumulatedBufferLength: this.accumulatedBuffer.length,
2875
1842
  accumulatedRawBufferLength: this.accumulatedRawBuffer.length,
2876
1843
  rawBufferPreview: this.accumulatedRawBuffer.slice(-1000),
@@ -2888,21 +1855,21 @@ export class ProviderCliAdapter implements CliAdapter {
2888
1855
  lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
2889
1856
  lastScreenChangeAt: this.lastScreenChangeAt,
2890
1857
  lastScreenSnapshot: this.lastScreenSnapshot.slice(-500),
2891
- isWaitingForResponse: this.isWaitingForResponse,
2892
- activeModal: startupModal || this.activeModal,
2893
- lastApprovalResolvedAt: this.lastApprovalResolvedAt,
1858
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1859
+ activeModal: startupModal || this.engine.activeModal,
1860
+ lastApprovalResolvedAt: this.engine.lastApprovalResolvedAt,
2894
1861
  sendDelayMs: this.sendDelayMs,
2895
1862
  sendKey: this.sendKey,
2896
1863
  submitStrategy: this.submitStrategy,
2897
1864
  requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
2898
- submitPendingUntil: this.submitPendingUntil,
2899
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
1865
+ submitPendingUntil: this.engine.submitPendingUntil,
1866
+ responseSettleIgnoreUntil: this.engine.responseSettleIgnoreUntil,
2900
1867
  resizeSuppressUntil: this.resizeSuppressUntil,
2901
1868
  hasCliScripts: this.hasCliScripts(),
2902
- scriptNames: listCliScriptNames(this.cliScripts),
2903
- traceSessionId: this.traceSessionId,
2904
- traceEntryCount: this.traceEntries.length,
2905
- statusHistory: this.statusHistory.slice(-30),
1869
+ scriptNames: this.runner.getScriptNames(),
1870
+ traceSessionId: this.engine.getTraceSessionId(),
1871
+ traceEntryCount: this.engine.getTraceEntries().length,
1872
+ statusHistory: this.engine.getStatusHistory().slice(-30),
2906
1873
  timeouts: this.timeouts,
2907
1874
  pendingOutputParseBufferLength: this.pendingOutputParseChunks.reduce((total, chunk) => total + chunk.length, 0),
2908
1875
  pendingOutputParseScheduled: !!this.pendingOutputParseTimer,
@@ -2912,20 +1879,21 @@ export class ProviderCliAdapter implements CliAdapter {
2912
1879
 
2913
1880
  getTraceState(limit = 120): Record<string, any> {
2914
1881
  const cappedLimit = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 120));
1882
+ const traceEntries = this.engine.getTraceEntries();
2915
1883
  return {
2916
- sessionId: this.traceSessionId,
1884
+ sessionId: this.engine.getTraceSessionId(),
2917
1885
  providerResolution: this.providerResolutionMeta,
2918
- entryCount: this.traceEntries.length,
2919
- entries: this.traceEntries.slice(-cappedLimit),
1886
+ entryCount: traceEntries.length,
1887
+ entries: traceEntries.slice(-cappedLimit),
2920
1888
  screenText: summarizeCliTraceText(this.terminalScreen.getText(), 4000),
2921
1889
  recentOutputBuffer: summarizeCliTraceText(this.recentOutputBuffer, 1000),
2922
1890
  responseBuffer: summarizeCliTraceText(this.responseBuffer, 1200),
2923
1891
  status: this.projectEffectiveStatus(),
2924
1892
  projectedStatus: this.projectEffectiveStatus(),
2925
- rawStatus: this.currentStatus,
2926
- lifecycleStatus: this.isWaitingForResponse ? 'awaiting_response' : 'idle',
2927
- activeModal: this.activeModal,
2928
- currentTurnScope: this.currentTurnScope,
1893
+ rawStatus: this.engine.currentStatus,
1894
+ lifecycleStatus: this.engine.isWaitingForResponse ? 'awaiting_response' : 'idle',
1895
+ activeModal: this.engine.activeModal,
1896
+ currentTurnScope: this.engine.currentTurnScope,
2929
1897
  messages: [],
2930
1898
  };
2931
1899
  }