@adhdev/daemon-core 0.9.82-rc.135 → 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 (48) hide show
  1. package/dist/cli-adapter-types.d.ts +1 -0
  2. package/dist/cli-adapters/cli-script-runner.d.ts +45 -0
  3. package/dist/cli-adapters/cli-state-engine.d.ts +154 -0
  4. package/dist/cli-adapters/provider-cli-adapter.d.ts +75 -74
  5. package/dist/cli-adapters/provider-cli-parse.d.ts +2 -0
  6. package/dist/cli-adapters/provider-cli-shared.d.ts +6 -0
  7. package/dist/config/chat-history.d.ts +1 -0
  8. package/dist/index.d.ts +3 -3
  9. package/dist/index.js +2624 -1946
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +2627 -1954
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/mesh/beads-db.d.ts +54 -0
  14. package/dist/mesh/mesh-active-work.d.ts +7 -1
  15. package/dist/mesh/mesh-events.d.ts +10 -4
  16. package/dist/mesh/mesh-ledger.d.ts +21 -1
  17. package/dist/mesh/mesh-refine-status.d.ts +2 -3
  18. package/dist/mesh/mesh-work-queue.d.ts +17 -0
  19. package/dist/mesh/worktree-bootstrap-config.d.ts +2 -4
  20. package/dist/providers/approval-utils.d.ts +9 -0
  21. package/dist/repo-mesh-types.d.ts +5 -0
  22. package/package.json +1 -1
  23. package/src/cli-adapter-types.d.ts +1 -0
  24. package/src/cli-adapter-types.ts +1 -0
  25. package/src/cli-adapters/cli-script-runner.ts +145 -0
  26. package/src/cli-adapters/cli-state-engine.ts +957 -0
  27. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  28. package/src/cli-adapters/provider-cli-adapter.ts +377 -1387
  29. package/src/cli-adapters/provider-cli-parse.ts +6 -0
  30. package/src/cli-adapters/provider-cli-shared.ts +6 -0
  31. package/src/commands/chat-commands.ts +23 -1
  32. package/src/commands/cli-manager.ts +3 -1
  33. package/src/commands/router.ts +8 -0
  34. package/src/config/chat-history.ts +7 -3
  35. package/src/git/git-worktree.ts +8 -1
  36. package/src/index.ts +3 -2
  37. package/src/mesh/beads-db.ts +305 -2
  38. package/src/mesh/coordinator-prompt.ts +12 -17
  39. package/src/mesh/mesh-active-work.ts +162 -59
  40. package/src/mesh/mesh-events.ts +198 -53
  41. package/src/mesh/mesh-ledger.ts +321 -105
  42. package/src/mesh/mesh-refine-status.ts +2 -3
  43. package/src/mesh/mesh-work-queue.ts +116 -120
  44. package/src/mesh/worktree-bootstrap-config.ts +17 -4
  45. package/src/providers/approval-utils.ts +27 -0
  46. package/src/providers/cli-provider-instance.ts +26 -2
  47. package/src/providers/provider-schema.ts +2 -0
  48. 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,793 +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 = '';
741
+ this.engine.clearAllTimers();
966
742
  }
967
743
 
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
- }
744
+ // ─── Script dispatch — builds inputs for CliScriptRunner ──────────────────
1019
745
 
1020
- private hasMeaningfulResponseBuffer(promptSnippet: string): boolean {
1021
- const raw = String(this.responseBuffer || '').trim();
1022
- if (!raw) return false;
1023
- const normalizedPrompt = compactPromptText(promptSnippet);
1024
- if (!normalizedPrompt) return true;
1025
- const normalizedBuffer = compactPromptText(raw);
1026
- if (!normalizedBuffer) return false;
1027
- if (normalizedBuffer === normalizedPrompt) return false;
1028
- if (normalizedBuffer.startsWith(normalizedPrompt)) {
1029
- const remainder = normalizedBuffer
1030
- .slice(normalizedPrompt.length)
1031
- .replace(/[─═\-]+/g, '')
1032
- .replace(/⏵⏵accepteditson\([^)]*\)/gi, '')
1033
- .replace(/accepteditson\([^)]*\)/gi, '')
1034
- .replace(/(?:◐|◑|◒|◓|◔|◕|◉|●|·)?(?:x?high|medium|low|max)·?\/effort/gi, '')
1035
- .replace(/updateavailable!run:[a-z0-9:._\-/]+/gi, '')
1036
- .replace(/esctointerrupt/gi, '')
1037
- .replace(/❯/g, '')
1038
- .replace(/^[\s\-–—:;,.!/?]+/, '')
1039
- .trim();
1040
- return remainder.length > 0;
1041
- }
1042
- return true;
1043
- }
1044
-
1045
- private evaluateSettled(): void {
1046
- const now = Date.now();
1047
- if (this.submitPendingUntil > now || this.responseSettleIgnoreUntil > now) {
1048
- const delayTime = Math.max(this.submitPendingUntil - now, this.responseSettleIgnoreUntil - now) + 50;
1049
- if (this.settleTimer) clearTimeout(this.settleTimer);
1050
- this.settleTimer = setTimeout(() => {
1051
- this.settleTimer = null;
1052
- this.settledBuffer = this.recentOutputBuffer;
1053
- this.evaluateSettled();
1054
- }, delayTime);
1055
- return;
1056
- }
1057
-
1058
- this.resolveStartupState('settled');
1059
- if (this.startupParseGate) return;
1060
-
1061
- if (!this.isWaitingForResponse && !this.currentTurnScope && !this.activeModal && !this.parseErrorMessage) {
1062
- const tail = this.settledBuffer || this.recentOutputBuffer;
1063
- const modal = this.runParseApproval(tail);
1064
- const lightweightStatus = this.cliScripts?.detectStatus
1065
- ? this.runDetectStatus(tail)
1066
- : null;
1067
- if (!modal && lightweightStatus === 'idle' && this.currentStatus === 'idle') {
1068
- return;
1069
- }
1070
- }
1071
-
1072
- const session = this.runParseSession();
1073
- if (!session) return;
1074
-
1075
- const { status, messages, parsedStatus } = session;
1076
- const modal = (session as any).activeModal ?? session.modal ?? null;
1077
- const parsedMessages = normalizeCliParsedMessages(messages, {
1078
- scope: null,
1079
- lastOutputAt: this.lastOutputAt,
1080
- });
1081
-
1082
- if (this.maybeCommitVisibleIdleTranscript(session, parsedMessages)) return;
1083
-
1084
- const lastParsedAssistant = [...parsedMessages].reverse().find((m) => m.role === 'assistant');
1085
- const normalizedPromptSnippet = normalizePromptText(this.submitRetryPromptSnippet || this.currentTurnScope?.prompt || '');
1086
- const screenText = this.terminalScreen.getText() || '';
1087
-
1088
- this.recordTrace('settled', {
1089
- tail: summarizeCliTraceText(this.settledBuffer, 500),
1090
- screenText: summarizeCliTraceText(screenText, 1200),
1091
- detectStatus: status,
1092
- parsedStatus: parsedStatus || null,
1093
- parsedMessageCount: parsedMessages.length,
1094
- parsedLastAssistant: lastParsedAssistant ? summarizeCliTraceText(lastParsedAssistant.content, 280) : '',
1095
- parsedActiveModal: modal,
1096
- approval: modal,
1097
- ...buildCliTraceParseSnapshot({
1098
- accumulatedBuffer: this.accumulatedBuffer,
1099
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1100
- responseBuffer: this.responseBuffer,
1101
- partialResponse: this.responseBuffer,
1102
- scope: this.currentTurnScope,
1103
- }),
1104
- });
1105
-
1106
- if (
1107
- this.currentTurnScope
1108
- && !lastParsedAssistant
1109
- && !this.submitRetryUsed
1110
- && this.ptyProcess
1111
- && !this.hasActionableApproval()
1112
- && promptLikelyVisible(screenText, normalizedPromptSnippet)
1113
- && !this.hasMeaningfulResponseBuffer(normalizedPromptSnippet)
1114
- ) {
1115
- this.submitRetryUsed = true;
1116
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1117
- LOG.info('CLI', `[${this.cliType}] Retrying submit key from settled parser (no assistant yet)`);
1118
- this.recordTrace('submit_write', {
1119
- mode: 'settled_retry',
1120
- sendKey: this.sendKey,
1121
- screenText: summarizeCliTraceText(screenText, 500),
1122
- });
1123
- this.ptyProcess.write(this.sendKey);
1124
- if (this.settleTimer) clearTimeout(this.settleTimer);
1125
- this.settleTimer = setTimeout(() => {
1126
- this.settleTimer = null;
1127
- this.settledBuffer = this.recentOutputBuffer;
1128
- this.evaluateSettled();
1129
- }, this.timeouts.outputSettle + 150);
1130
- return;
1131
- }
1132
-
1133
- if (this.currentTurnScope && !lastParsedAssistant) {
1134
- LOG.debug(
1135
- 'CLI',
1136
- `[${this.cliType}] Settled without assistant: prompt=${JSON.stringify(this.currentTurnScope.prompt).slice(0, 140)} responseBuffer=${JSON.stringify(summarizeCliTraceText(this.responseBuffer, 220)).slice(0, 260)} screen=${JSON.stringify(summarizeCliTraceText(screenText, 220)).slice(0, 260)} providerDir=${this.providerResolutionMeta.providerDir || '-'} scriptDir=${this.providerResolutionMeta.scriptDir || '-'}`
1137
- );
1138
- }
1139
-
1140
- if (!status) return;
1141
-
1142
- const prevStatus = this.currentStatus;
1143
- const ctx: SettledEvalContext = { now, modal, status, parsedMessages, lastParsedAssistant, parsedStatus: parsedStatus || null, prevStatus };
1144
-
1145
- if (!this.applyPendingScriptStatusDebounce(ctx)) return;
1146
-
1147
- const recentInteractiveActivity = this.hasRecentInteractiveActivity(now);
1148
- LOG.debug(
1149
- 'CLI',
1150
- `[${this.cliType}] settled diagnostics prompt=${JSON.stringify(this.currentTurnScope?.prompt || '').slice(0, 140)} status=${String(status || '')} parsedStatus=${String(parsedStatus || '')} parsedMsgCount=${parsedMessages.length} lastParsedAssistant=${JSON.stringify(summarizeCliTraceText(lastParsedAssistant?.content || '', 120)).slice(0, 160)} responseBuffer=${JSON.stringify(summarizeCliTraceText(this.responseBuffer, 160)).slice(0, 220)} screen=${JSON.stringify(summarizeCliTraceText(screenText, 160)).slice(0, 220)}`
1151
- );
1152
-
1153
- const shouldHoldGenerating =
1154
- status === 'idle'
1155
- && this.isWaitingForResponse
1156
- && !modal
1157
- && recentInteractiveActivity
1158
- && !(parsedStatus === 'idle' && !!lastParsedAssistant);
1159
-
1160
- if (shouldHoldGenerating) { this.applyHoldGenerating(ctx, recentInteractiveActivity); return; }
1161
- if (status === 'error') {
1162
- if (this.maybeScheduleProviderErrorRetry(ctx, session)) return;
1163
- this.applyError(ctx, session);
1164
- return;
1165
- }
1166
- if (status === 'waiting_approval') { this.applyWaitingApproval(ctx); return; }
1167
- if (status === 'generating') { this.applyGenerating(ctx); return; }
1168
- if (status === 'idle') { this.applyIdle(ctx, now); }
1169
- }
1170
-
1171
- // Returns false if the caller should bail out (debounce pending).
1172
- private applyPendingScriptStatusDebounce(ctx: SettledEvalContext): boolean {
1173
- const { now, status, prevStatus } = ctx;
1174
- const shouldDebounce =
1175
- prevStatus === 'idle'
1176
- && !this.isWaitingForResponse
1177
- && !this.currentTurnScope
1178
- && (status === 'generating' || status === 'waiting_approval');
1179
-
1180
- if (!shouldDebounce) {
1181
- this.pendingScriptStatus = null;
1182
- this.pendingScriptStatusSince = 0;
1183
- if (this.pendingScriptStatusTimer) { clearTimeout(this.pendingScriptStatusTimer); this.pendingScriptStatusTimer = null; }
1184
- return true;
1185
- }
1186
-
1187
- const armPending = (delayMs: number) => {
1188
- if (this.pendingScriptStatusTimer) clearTimeout(this.pendingScriptStatusTimer);
1189
- this.pendingScriptStatusTimer = setTimeout(() => {
1190
- this.pendingScriptStatusTimer = null;
1191
- this.settledBuffer = this.recentOutputBuffer;
1192
- this.evaluateSettled();
1193
- }, delayMs);
1194
- };
1195
-
1196
- if (this.pendingScriptStatus !== status) {
1197
- this.pendingScriptStatus = status as 'generating' | 'waiting_approval';
1198
- this.pendingScriptStatusSince = now;
1199
- armPending(ProviderCliAdapter.SCRIPT_STATUS_DEBOUNCE_MS);
1200
- return false;
1201
- }
1202
- const elapsed = now - this.pendingScriptStatusSince;
1203
- if (elapsed < ProviderCliAdapter.SCRIPT_STATUS_DEBOUNCE_MS) {
1204
- armPending(ProviderCliAdapter.SCRIPT_STATUS_DEBOUNCE_MS - elapsed);
1205
- return false;
1206
- }
1207
- return true;
1208
- }
1209
-
1210
- private applyHoldGenerating(ctx: SettledEvalContext, recentInteractiveActivity: boolean): void {
1211
- const { status } = ctx;
1212
- this.clearIdleFinishCandidate('hold_generating_recent_activity');
1213
- this.setStatus('generating', 'recent_activity_hold');
1214
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1215
- this.idleTimeout = setTimeout(() => {
1216
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1217
- if (this.shouldDeferIdleTimeoutFinish()) return;
1218
- this.finishResponse();
1219
- }
1220
- }, this.timeouts.generatingIdle);
1221
- this.recordTrace('hold_generating_recent_activity', {
1222
- scriptStatus: status,
1223
- recentInteractiveActivity,
1224
- lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
1225
- lastScreenChangeAt: this.lastScreenChangeAt,
1226
- holdMs: this.getStatusActivityHoldMs(),
1227
- ...buildCliTraceParseSnapshot({
1228
- accumulatedBuffer: this.accumulatedBuffer,
1229
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1230
- responseBuffer: this.responseBuffer,
1231
- partialResponse: this.responseBuffer,
1232
- scope: this.currentTurnScope,
1233
- }),
1234
- });
1235
- this.onStatusChange?.();
1236
- }
1237
-
1238
- private applyWaitingApproval(ctx: SettledEvalContext): void {
1239
- const { modal } = ctx;
1240
- this.clearIdleFinishCandidate('waiting_approval');
1241
- const inCooldown = this.lastApprovalResolvedAt && (Date.now() - this.lastApprovalResolvedAt) < this.timeouts.approvalCooldown;
1242
- if (inCooldown && !modal) {
1243
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1244
- this.activeModal = null;
1245
- if (this.isWaitingForResponse) {
1246
- this.setStatus('idle', inCooldown ? 'approval_cooldown_non_actionable' : 'approval_prompt_gone_non_actionable');
1247
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1248
- this.idleTimeout = setTimeout(() => {
1249
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1250
- if (this.shouldDeferIdleTimeoutFinish()) return;
1251
- this.finishResponse();
1252
- }
1253
- }, this.timeouts.generatingIdle);
1254
- } else {
1255
- this.setStatus('idle', inCooldown ? 'approval_cooldown_non_actionable' : 'approval_prompt_gone_non_actionable');
1256
- }
1257
- this.onStatusChange?.();
1258
- return;
1259
- }
1260
- if (!inCooldown) {
1261
- if (!modal) {
1262
- LOG.warn('CLI', `[${this.cliType}] detectStatus reported waiting_approval without parseApproval modal; ignoring non-actionable approval state`);
1263
- return;
1264
- }
1265
- this.isWaitingForResponse = true;
1266
- this.setStatus('waiting_approval', 'script_detect');
1267
- this.activeModal = modal;
1268
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1269
- this.armApprovalExitTimeout();
1270
- this.onStatusChange?.();
1271
- }
1272
- }
1273
-
1274
- private applyGenerating(ctx: SettledEvalContext): void {
1275
- const { modal, parsedMessages, lastParsedAssistant, parsedStatus, prevStatus } = ctx;
1276
- this.clearIdleFinishCandidate('generating');
1277
- const screenText = this.terminalScreen.getText() || '';
1278
- const effectiveScreenText = screenText || this.accumulatedBuffer;
1279
- const noActiveTurn = !this.currentTurnScope;
1280
- const looksIdleChrome = /(^|\n)\s*[❯›>]\s*(?:\n|$)/m.test(effectiveScreenText);
1281
- const parsedShowsLiveAssistantProgress = parsedStatus === 'generating'
1282
- && !!lastParsedAssistant
1283
- ;
1284
- if (prevStatus === 'idle' && !this.isWaitingForResponse && noActiveTurn && !modal && looksIdleChrome && !parsedShowsLiveAssistantProgress) {
1285
- return;
1286
- }
1287
- if (prevStatus === 'waiting_approval') {
1288
- // Transitioned out of approval → generating
1289
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1290
- this.activeModal = null;
1291
- this.lastApprovalResolvedAt = Date.now();
1292
- }
1293
- if (!this.isWaitingForResponse) {
1294
- this.isWaitingForResponse = true;
1295
- this.responseBuffer = '';
1296
- }
1297
- this.setStatus('generating', 'script_detect');
1298
- // Reset idle timeout
1299
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1300
- this.idleTimeout = setTimeout(() => {
1301
- if (this.isWaitingForResponse) {
1302
- if (this.shouldDeferIdleTimeoutFinish()) return;
1303
- this.finishResponse();
1304
- }
1305
- }, this.timeouts.generatingIdle);
1306
- this.onStatusChange?.();
1307
- }
1308
-
1309
- private applyError(ctx: SettledEvalContext, session: ParsedSession): void {
1310
- this.clearIdleFinishCandidate('provider_error');
1311
- if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
1312
- if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
1313
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1314
- this.isWaitingForResponse = false;
1315
- this.responseSettleIgnoreUntil = 0;
1316
- this.submitRetryUsed = false;
1317
- this.submitRetryPromptSnippet = '';
1318
- this.finishRetryCount = 0;
1319
- this.currentTurnScope = null;
1320
- this.activeModal = null;
1321
- this.providerErrorMessage = typeof session.errorMessage === 'string' && session.errorMessage.trim()
1322
- ? session.errorMessage.trim()
1323
- : 'Provider reported an error';
1324
- this.providerErrorReason = typeof session.errorReason === 'string' && session.errorReason.trim()
1325
- ? session.errorReason.trim()
1326
- : 'provider_error';
1327
- this.setStatus('error', this.providerErrorReason);
1328
- this.recordTrace('provider_error', {
1329
- errorMessage: this.providerErrorMessage,
1330
- errorReason: this.providerErrorReason,
1331
- parsedStatus: ctx.parsedStatus || ctx.status,
1332
- messageCount: ctx.parsedMessages.length,
1333
- ...buildCliTraceParseSnapshot({
1334
- accumulatedBuffer: this.accumulatedBuffer,
1335
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1336
- responseBuffer: this.responseBuffer,
1337
- partialResponse: this.responseBuffer,
1338
- scope: this.currentTurnScope,
1339
- }),
1340
- });
1341
- this.onStatusChange?.();
1342
- }
1343
-
1344
- private maybeScheduleProviderErrorRetry(ctx: SettledEvalContext, session: ParsedSession): boolean {
1345
- const retryPrompt = typeof (session as any).retryPrompt === 'string'
1346
- ? String((session as any).retryPrompt).trim()
1347
- : '';
1348
- const retryDelayMs = typeof (session as any).retryDelayMs === 'number'
1349
- ? Number((session as any).retryDelayMs)
1350
- : NaN;
1351
- if (!retryPrompt || !Number.isFinite(retryDelayMs) || retryDelayMs < 0) return false;
1352
- if (!this.ptyProcess) return false;
1353
-
1354
- const retryAttempt = typeof (session as any).retryAttempt === 'number'
1355
- ? Number((session as any).retryAttempt)
1356
- : 0;
1357
- const retryMaxAttempts = typeof (session as any).retryMaxAttempts === 'number'
1358
- ? Number((session as any).retryMaxAttempts)
1359
- : 0;
1360
- const errorReason = typeof session.errorReason === 'string' && session.errorReason.trim()
1361
- ? session.errorReason.trim()
1362
- : 'provider_error';
1363
- const retryKey = `${errorReason}:${retryAttempt}:${retryPrompt}`;
1364
- if (this.providerErrorRetryTimer && this.providerErrorRetryKey === retryKey) return true;
1365
-
1366
- if (this.providerErrorRetryTimer) clearTimeout(this.providerErrorRetryTimer);
1367
- this.providerErrorRetryKey = retryKey;
1368
- this.clearIdleFinishCandidate('provider_error_retry');
1369
- if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
1370
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1371
- this.providerErrorMessage = typeof session.errorMessage === 'string' && session.errorMessage.trim()
1372
- ? session.errorMessage.trim()
1373
- : 'Provider reported an error';
1374
- this.providerErrorReason = errorReason;
1375
- this.activeModal = null;
1376
- this.responseSettleIgnoreUntil = Date.now() + retryDelayMs + this.timeouts.outputSettle + 400;
1377
- this.setStatus('generating', 'provider_error_retry_scheduled');
1378
- this.recordTrace('provider_error_retry_scheduled', {
1379
- retryPrompt,
1380
- retryDelayMs,
1381
- retryAttempt,
1382
- retryMaxAttempts,
1383
- errorReason,
1384
- parsedStatus: ctx.parsedStatus || ctx.status,
1385
- });
1386
- this.onStatusChange?.();
1387
- this.providerErrorRetryTimer = setTimeout(() => {
1388
- this.providerErrorRetryTimer = null;
1389
- this.providerErrorRetryKey = '';
1390
- if (!this.ptyProcess) return;
1391
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1392
- this.submitRetryUsed = false;
1393
- this.recordTrace('provider_error_retry_write', {
1394
- retryPrompt,
1395
- retryAttempt,
1396
- retryMaxAttempts,
1397
- errorReason,
1398
- });
1399
- this.ptyProcess.write(`${retryPrompt}\r`);
1400
- if (this.settleTimer) clearTimeout(this.settleTimer);
1401
- this.settleTimer = setTimeout(() => {
1402
- this.settleTimer = null;
1403
- this.settledBuffer = this.recentOutputBuffer;
1404
- this.evaluateSettled();
1405
- }, this.timeouts.outputSettle + 150);
1406
- }, retryDelayMs);
1407
- return true;
1408
- }
1409
-
1410
- private applyIdle(ctx: SettledEvalContext, now: number): void {
1411
- const { modal, lastParsedAssistant, prevStatus } = ctx;
1412
- if (prevStatus === 'waiting_approval') {
1413
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1414
- this.activeModal = null;
1415
- this.lastApprovalResolvedAt = Date.now();
1416
- this.setStatus('idle', 'approval_prompt_gone_script_idle');
1417
- }
1418
- if (!this.isWaitingForResponse) {
1419
- if (prevStatus !== 'idle') {
1420
- this.clearIdleFinishCandidate('idle_without_response');
1421
- this.setStatus('idle', 'script_detect');
1422
- this.onStatusChange?.();
1423
- }
1424
- return;
1425
- }
1426
- const quietForMs = this.lastNonEmptyOutputAt ? (now - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
1427
- const screenStableMs = this.lastScreenChangeAt ? (now - this.lastScreenChangeAt) : 0;
1428
- const hasAssistantTurn = !!lastParsedAssistant;
1429
- const assistantLength = lastParsedAssistant?.content?.length || 0;
1430
- const idleFinishConfirmMs = this.getIdleFinishConfirmMs();
1431
- const idleQuietThresholdMs = Math.max(idleFinishConfirmMs, this.timeouts.outputSettle);
1432
- const idleReady = !modal
1433
- && hasAssistantTurn
1434
- && quietForMs >= idleQuietThresholdMs
1435
- && screenStableMs >= idleFinishConfirmMs;
1436
- const candidate = this.idleFinishCandidate;
1437
- const candidateQuiet = !!candidate
1438
- && candidate.responseEpoch === this.responseEpoch
1439
- && candidate.lastOutputAt === this.lastOutputAt
1440
- && candidate.lastScreenChangeAt === this.lastScreenChangeAt
1441
- && assistantLength >= candidate.assistantLength
1442
- && (now - candidate.armedAt) >= idleFinishConfirmMs;
1443
-
1444
- this.recordTrace('idle_decision', {
1445
- quietForMs,
1446
- screenStableMs,
1447
- hasAssistantTurn,
1448
- assistantLength,
1449
- hasModal: !!modal,
1450
- idleQuietThresholdMs,
1451
- idleStableThresholdMs: idleFinishConfirmMs,
1452
- idleReady,
1453
- idleFinishConfirmMs,
1454
- idleFinishCandidate: candidate,
1455
- candidateQuiet,
1456
- canFinishImmediately: idleReady && candidateQuiet,
1457
- submitPendingUntil: this.submitPendingUntil,
1458
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
1459
- ...buildCliTraceParseSnapshot({
1460
- accumulatedBuffer: this.accumulatedBuffer,
1461
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1462
- responseBuffer: this.responseBuffer,
1463
- partialResponse: this.responseBuffer,
1464
- scope: this.currentTurnScope,
1465
- }),
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
- baseMessages: [],
1673
- partialResponse: this.responseBuffer,
1674
- isWaitingForResponse: this.isWaitingForResponse,
1675
- scope: this.currentTurnScope,
1676
- runtimeSettings: this.runtimeSettings,
1677
- });
1678
- const session = this.invokeCliScript<ParsedSession | null>(
1679
- this.cliScripts.parseSession,
1680
- { ...input, tail, tailScreen: buildCliScreenSnapshot(tail) },
1681
- );
1682
- this.parseErrorMessage = null;
1683
- if (session && typeof session === 'object') this.applyParsedSessionMetadata(session);
1684
- return session && typeof session === 'object' ? session : null;
1685
- } catch (e: any) {
1686
- const message = e?.message || String(e);
1687
- this.parseErrorMessage = message;
1688
- LOG.warn('CLI', `[${this.cliType}] parseSession error: ${message}`);
1689
- return null;
1690
- }
1691
- }
1692
-
1693
- private runDetectStatus(text: string): string | null {
1694
- if (!this.cliScripts?.detectStatus) return null;
1695
- try {
1696
- const screenText = this.terminalScreen.getText();
1697
- const status = this.invokeCliScript<string | null>(this.cliScripts.detectStatus, {
1698
- tail: text.slice(-500),
1699
- screenText,
1700
- rawBuffer: this.accumulatedRawBuffer,
1701
- isWaitingForResponse: this.isWaitingForResponse,
1702
- screen: buildCliScreenSnapshot(screenText),
1703
- tailScreen: buildCliScreenSnapshot(text.slice(-500)),
1704
- });
1705
- return status;
1706
- } catch (e: any) {
1707
- LOG.warn('CLI', `[${this.cliType}] detectStatus error: ${e.message}`);
1708
- return null;
1709
- }
1710
- }
1711
-
1712
- private runParseApproval(tail: string): { message: string; buttons: string[] } | null {
1713
- if (!this.cliScripts?.parseApproval) return null;
1714
- try {
1715
- const screenText = this.terminalScreen.getText();
1716
- const buffer = screenText || this.accumulatedBuffer;
1717
- return this.invokeCliScript<{ message: string; buttons: string[] } | null>(this.cliScripts.parseApproval, {
1718
- buffer,
1719
- screenText,
1720
- rawBuffer: this.accumulatedRawBuffer,
1721
- tail,
1722
- screen: buildCliScreenSnapshot(screenText),
1723
- bufferScreen: buildCliScreenSnapshot(buffer),
1724
- tailScreen: buildCliScreenSnapshot(tail),
1725
- });
1726
- } catch (e: any) {
1727
- LOG.warn('CLI', `[${this.cliType}] parseApproval error: ${e.message}`);
1728
- return null;
1729
- }
1730
- }
1731
-
1732
- private hasActionableApproval(startupModal: { message: string; buttons: string[] } | null = null): boolean {
1733
- return !!(startupModal || this.activeModal);
1734
- }
1735
-
1736
- private parsedStatusHasFinalAssistantMessage(parsed: any): boolean {
1737
- const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
1738
- const lastAssistant = [...messages].reverse().find((message: any) => {
1739
- if (!message || message.role !== 'assistant') return false;
1740
- 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),
1741
797
  });
1742
- return !!lastAssistant;
1743
798
  }
1744
799
 
1745
800
  private applyParsedSessionMetadata(parsed: any): void {
@@ -1750,62 +805,13 @@ export class ProviderCliAdapter implements CliAdapter {
1750
805
  this.providerSessionId = providerSessionId;
1751
806
  this.updateRuntimeMeta({ providerSessionId });
1752
807
  }
1753
- this.providerErrorMessage = typeof parsed?.errorMessage === 'string' && parsed.errorMessage.trim()
1754
- ? parsed.errorMessage.trim()
1755
- : null;
1756
- this.providerErrorReason = typeof parsed?.errorReason === 'string' && parsed.errorReason.trim()
1757
- ? parsed.errorReason.trim()
1758
- : null;
1759
- }
1760
-
1761
- private parsedStatusHasFinalStandardAssistantMessage(parsed: any): boolean {
1762
- const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
1763
- const lastAssistant = [...messages].reverse().find((message: any) => {
1764
- if (!message || message.role !== 'assistant') return false;
1765
- return typeof message.content === 'string' && message.content.trim().length > 0;
1766
- });
1767
- if (!lastAssistant) return false;
1768
- const kind = typeof lastAssistant.kind === 'string' && lastAssistant.kind.trim()
1769
- ? lastAssistant.kind.trim()
1770
- : 'standard';
1771
- return kind === 'standard' && lastAssistant.meta?.streaming !== true;
1772
- }
1773
-
1774
- private shouldKeepCodexTurnOpenForFinish(parsed: any): boolean {
1775
- if (this.cliType !== 'codex-cli') return false;
1776
- if (!this.isWaitingForResponse || !this.currentTurnScope || this.hasActionableApproval()) return false;
1777
- const parsedStatus = typeof parsed?.status === 'string' ? parsed.status.trim() : '';
1778
- if (parsedStatus !== 'idle') return true;
1779
- if (parsed?.activeModal || parsed?.modal) return true;
1780
- return !this.parsedStatusHasFinalStandardAssistantMessage(parsed);
1781
- }
1782
-
1783
- private rescheduleCodexFinishCheck(reason: string): void {
1784
- this.clearIdleFinishCandidate(reason);
1785
- this.setStatus('generating', reason);
1786
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1787
- this.idleTimeout = setTimeout(() => {
1788
- if (!this.isWaitingForResponse || this.hasActionableApproval()) return;
1789
- this.settledBuffer = this.recentOutputBuffer;
1790
- this.evaluateSettled();
1791
- }, this.getIdleFinishConfirmMs());
1792
- this.recordTrace('codex_finish_deferred', {
1793
- reason,
1794
- ...buildCliTraceParseSnapshot({
1795
- accumulatedBuffer: this.accumulatedBuffer,
1796
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1797
- responseBuffer: this.responseBuffer,
1798
- partialResponse: this.responseBuffer,
1799
- scope: this.currentTurnScope,
1800
- }),
1801
- });
1802
808
  }
1803
809
 
1804
810
  private projectEffectiveStatus(startupModal: { message: string; buttons: string[] } | null = null): CliSessionStatus['status'] {
1805
811
  if (this.parseErrorMessage) return 'error';
1806
- if (this.hasActionableApproval(startupModal)) return 'waiting_approval';
1807
- if (this.isWaitingForResponse && this.currentTurnScope && this.currentStatus !== 'stopped') return 'generating';
1808
- 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;
1809
815
  }
1810
816
 
1811
817
  // ─── Public API (CliAdapter) ───────────────────
@@ -1817,7 +823,7 @@ export class ProviderCliAdapter implements CliAdapter {
1817
823
  ? this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText())
1818
824
  : null;
1819
825
  let effectiveStatus = this.projectEffectiveStatus(startupModal);
1820
- let effectiveModal = startupModal || this.activeModal;
826
+ let effectiveModal = startupModal || this.engine.activeModal;
1821
827
  if (startupDetectedStatus === 'waiting_approval') {
1822
828
  effectiveStatus = 'waiting_approval';
1823
829
  } else if (startupDetectedStatus === 'idle' && !startupModal && !effectiveModal) {
@@ -1829,19 +835,23 @@ export class ProviderCliAdapter implements CliAdapter {
1829
835
  && parsed.activeModal.buttons.some((button: any) => typeof button === 'string' && button.trim())
1830
836
  ? parsed.activeModal
1831
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
+ };
1832
842
  if (parsed?.status === 'waiting_approval' && parsedModal) {
1833
843
  effectiveStatus = 'waiting_approval';
1834
844
  effectiveModal = parsedModal;
1835
845
  } else if (
1836
846
  effectiveStatus === 'idle'
1837
847
  && parsed?.status === 'generating'
1838
- && !this.parsedStatusHasFinalAssistantMessage(parsed)
848
+ && !hasFinalAssistant(parsed)
1839
849
  ) {
1840
850
  effectiveStatus = 'generating';
1841
851
  } else if (
1842
852
  effectiveStatus === 'generating'
1843
853
  && parsed?.status === 'idle'
1844
- && this.parsedStatusHasFinalAssistantMessage(parsed)
854
+ && hasFinalAssistant(parsed)
1845
855
  ) {
1846
856
  effectiveStatus = 'idle';
1847
857
  }
@@ -1860,8 +870,8 @@ export class ProviderCliAdapter implements CliAdapter {
1860
870
  queuedAt: message.queuedAt,
1861
871
  source: message.source,
1862
872
  })),
1863
- errorMessage: this.parseErrorMessage || this.providerErrorMessage || undefined,
1864
- 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),
1865
875
  providerSessionId: this.providerSessionId || undefined,
1866
876
  ...(bufferState ? { bufferState } : {}),
1867
877
  };
@@ -1881,13 +891,13 @@ export class ProviderCliAdapter implements CliAdapter {
1881
891
  !this.providerOwnsTranscript()
1882
892
  && cached
1883
893
  && cached.responseBuffer === this.responseBuffer
1884
- && cached.currentTurnScope === this.currentTurnScope
894
+ && cached.currentTurnScope === this.engine.currentTurnScope
1885
895
  && cached.recentOutputBuffer === this.recentOutputBuffer
1886
896
  && cached.accumulatedBuffer === this.accumulatedBuffer
1887
897
  && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
1888
898
  && cached.screenText === parseScreenText
1889
- && cached.currentStatus === this.currentStatus
1890
- && cached.activeModal === this.activeModal
899
+ && cached.currentStatus === this.engine.currentStatus
900
+ && cached.activeModal === this.engine.activeModal
1891
901
  && cached.cliName === this.cliName
1892
902
  ) {
1893
903
  return cached.result;
@@ -1902,7 +912,7 @@ export class ProviderCliAdapter implements CliAdapter {
1902
912
  const bufferState = this.getBufferState();
1903
913
  const result = {
1904
914
  id: (parsed as any).id || 'cli_session',
1905
- status: parsed.status || this.currentStatus,
915
+ status: parsed.status || this.engine.currentStatus,
1906
916
  title: (parsed as any).title || this.cliName,
1907
917
  messages: normalizeCliParsedMessages(parsed.messages, {
1908
918
  scope: null,
@@ -1927,13 +937,13 @@ export class ProviderCliAdapter implements CliAdapter {
1927
937
 
1928
938
  this.parsedStatusCache = {
1929
939
  responseBuffer: this.responseBuffer,
1930
- currentTurnScope: this.currentTurnScope,
940
+ currentTurnScope: this.engine.currentTurnScope,
1931
941
  recentOutputBuffer: this.recentOutputBuffer,
1932
942
  accumulatedBuffer: this.accumulatedBuffer,
1933
943
  accumulatedRawBufferKey,
1934
944
  screenText: parseScreenText,
1935
- currentStatus: this.currentStatus,
1936
- activeModal: this.activeModal,
945
+ currentStatus: this.engine.currentStatus,
946
+ activeModal: this.engine.activeModal,
1937
947
  cliName: this.cliName,
1938
948
  result,
1939
949
  };
@@ -1941,23 +951,21 @@ export class ProviderCliAdapter implements CliAdapter {
1941
951
  }
1942
952
 
1943
953
  async invokeScript(scriptName: string, args?: Record<string, any>): Promise<any> {
1944
- const fn = this.cliScripts?.[scriptName];
1945
- if (typeof fn !== 'function') {
1946
- throw new Error(`CLI script '${scriptName}' not available`);
1947
- }
1948
954
  const input = buildCliParseInput({
1949
955
  accumulatedBuffer: this.accumulatedBuffer,
1950
956
  accumulatedRawBuffer: this.accumulatedRawBuffer,
1951
957
  recentOutputBuffer: this.recentOutputBuffer,
1952
958
  terminalScreenText: this.getParseScreenText(this.terminalScreen.getText()),
1953
959
  workingDir: this.workingDir,
960
+ providerSessionId: this.providerSessionId || undefined,
961
+ historySessionId: this.providerSessionId || undefined,
1954
962
  baseMessages: [],
1955
963
  partialResponse: this.responseBuffer,
1956
- isWaitingForResponse: this.isWaitingForResponse,
1957
- scope: this.currentTurnScope,
964
+ isWaitingForResponse: this.engine.isWaitingForResponse,
965
+ scope: this.engine.currentTurnScope,
1958
966
  runtimeSettings: this.runtimeSettings,
1959
967
  });
1960
- return await Promise.resolve(this.invokeCliScript(fn, {
968
+ return await Promise.resolve(this.runner.invokeByName(scriptName, {
1961
969
  ...input,
1962
970
  args: args && typeof args === 'object' ? { ...args } : {},
1963
971
  }));
@@ -1969,7 +977,7 @@ export class ProviderCliAdapter implements CliAdapter {
1969
977
 
1970
978
  /** Whether this adapter has CLI scripts loaded */
1971
979
  hasCliScripts(): boolean {
1972
- return typeof this.cliScripts?.detectStatus === 'function';
980
+ return this.runner.hasDetectStatus();
1973
981
  }
1974
982
 
1975
983
  /**
@@ -1978,24 +986,24 @@ export class ProviderCliAdapter implements CliAdapter {
1978
986
  */
1979
987
  async resolveAction(data: any): Promise<void> {
1980
988
  let promptText = '';
1981
- if (this.cliScripts && typeof this.cliScripts.resolveAction === 'function') {
1982
- try {
1983
- promptText = this.cliScripts.resolveAction(data);
1984
- } catch (e: any) {
1985
- LOG.warn('CLI', `[${this.cliType}] resolveAction error: ${e.message}`);
1986
- }
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;
1987
994
  }
1988
995
  if (!promptText) {
1989
- 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`);
1990
997
  return;
1991
998
  }
1992
999
  await this.sendMessage(promptText);
1993
1000
  }
1994
1001
 
1995
1002
  private isSubmitStuck(normalizedPromptSnippet: string): boolean {
1996
- if (!this.ptyProcess || !this.isWaitingForResponse || this.submitRetryUsed) return false;
1997
- if (this.hasActionableApproval()) return false;
1998
- 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;
1999
1007
  const screenText = this.terminalScreen.getText();
2000
1008
  if (!promptLikelyVisible(screenText, normalizedPromptSnippet)) return false;
2001
1009
  const liveApproval = this.runParseApproval(screenText) || this.runParseApproval(this.recentOutputBuffer);
@@ -2004,20 +1012,41 @@ export class ProviderCliAdapter implements CliAdapter {
2004
1012
  return liveStatus !== 'generating' && liveStatus !== 'waiting_approval';
2005
1013
  }
2006
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
+
2007
1040
  private async writeToPty(data: string): Promise<void> {
2008
1041
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
2009
1042
  await this.ptyProcess.write(data);
2010
1043
  }
2011
1044
 
2012
1045
  private resetPendingSendState(reason: string): void {
2013
- this.isWaitingForResponse = false;
2014
1046
  this.responseBuffer = '';
2015
- this.currentTurnScope = null;
2016
- this.submitPendingUntil = 0;
2017
- this.clearIdleFinishCandidate(reason);
2018
1047
  if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
2019
- if (this.submitRetryTimer) { clearTimeout(this.submitRetryTimer); this.submitRetryTimer = null; }
2020
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
1048
+ this.engine.resetActiveTurnState();
1049
+ this.engine.clearIdleFinishCandidate(reason);
2021
1050
  }
2022
1051
 
2023
1052
  private commitSendUserTurn(state: SendMessageState): void {
@@ -2034,42 +1063,14 @@ export class ProviderCliAdapter implements CliAdapter {
2034
1063
  }
2035
1064
  this.responseTimeout = setTimeout(() => {
2036
1065
  this.responseTimeout = null;
2037
- if (!this.isWaitingForResponse) return;
2038
-
2039
- const detectedStatusBeforeEval = this.runDetectStatus(this.recentOutputBuffer);
2040
- this.recordTrace('response_timeout_check', {
2041
- timeoutMs,
2042
- detectedStatus: detectedStatusBeforeEval,
2043
- currentStatus: this.currentStatus,
2044
- isWaitingForResponse: this.isWaitingForResponse,
2045
- hasActionableApproval: this.hasActionableApproval(),
2046
- ...buildCliTraceParseSnapshot({
2047
- accumulatedBuffer: this.accumulatedBuffer,
2048
- accumulatedRawBuffer: this.accumulatedRawBuffer,
2049
- responseBuffer: this.responseBuffer,
2050
- partialResponse: this.responseBuffer,
2051
- scope: this.currentTurnScope,
2052
- }),
2053
- });
1066
+ if (!this.engine.isWaitingForResponse) return;
2054
1067
 
2055
- // maxResponse is a watchdog/checkpoint, not a completion signal. The old
2056
- // behavior called finishResponse() unconditionally at the default 300s,
2057
- // which fabricated idle transitions and downstream generating_completed
2058
- // notifications while long-running CLIs were still generating. Re-run the
1068
+ // maxResponse is a watchdog/checkpoint, not a completion signal. Re-run the
2059
1069
  // normal settled parser instead and keep the turn open unless the provider
2060
1070
  // actually reports an idle, commit-ready state.
2061
- this.settledBuffer = this.recentOutputBuffer;
2062
- this.evaluateSettled();
2063
-
2064
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
2065
- const detectedStatusAfterEval = this.runDetectStatus(this.recentOutputBuffer);
2066
- this.recordTrace('response_timeout_kept_open', {
2067
- timeoutMs,
2068
- detectedStatusBeforeEval,
2069
- detectedStatusAfterEval,
2070
- currentStatus: this.currentStatus,
2071
- isWaitingForResponse: this.isWaitingForResponse,
2072
- });
1071
+ this.engine.evaluateSettled(this.getSnapshot());
1072
+
1073
+ if (this.engine.isWaitingForResponse && !this.engine.hasActionableApproval()) {
2073
1074
  this.armResponseTimeout();
2074
1075
  }
2075
1076
  }, timeoutMs);
@@ -2084,34 +1085,20 @@ export class ProviderCliAdapter implements CliAdapter {
2084
1085
  private retrySubmitIfStuck(state: SendMessageState, attempt: number): void {
2085
1086
  this.submitRetryTimer = null;
2086
1087
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
2087
- const screenText = this.terminalScreen.getText();
2088
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1088
+ this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
2089
1089
  LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt ${attempt})`);
2090
- this.recordTrace('submit_write', {
2091
- mode: 'submit_retry',
2092
- attempt,
2093
- sendKey: this.sendKey,
2094
- screenText: summarizeCliTraceText(screenText, 500),
2095
- });
2096
1090
  this.writeSubmitKeyForRetry('submit_retry');
2097
- if (attempt >= 3) { this.submitRetryUsed = true; return; }
1091
+ if (attempt >= 3) { this.engine.submitRetryUsed = true; return; }
2098
1092
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, attempt + 1), state.retryDelayMs);
2099
1093
  }
2100
1094
 
2101
1095
  private retryImmediateSubmitIfStuck(state: SendMessageState): void {
2102
1096
  this.submitRetryTimer = null;
2103
1097
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
2104
- const screenText = this.terminalScreen.getText();
2105
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1098
+ this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
2106
1099
  LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt 1)`);
2107
- this.recordTrace('submit_write', {
2108
- mode: 'immediate_retry',
2109
- attempt: 1,
2110
- sendKey: this.sendKey,
2111
- screenText: summarizeCliTraceText(screenText, 500),
2112
- });
2113
1100
  this.writeSubmitKeyForRetry('immediate_retry');
2114
- this.submitRetryUsed = true;
1101
+ this.engine.submitRetryUsed = true;
2115
1102
  }
2116
1103
 
2117
1104
  private submitSendKey(state: SendMessageState, completion: SendMessageCompletion): void {
@@ -2119,13 +1106,7 @@ export class ProviderCliAdapter implements CliAdapter {
2119
1106
  completion.resolveOnce();
2120
1107
  return;
2121
1108
  }
2122
- this.submitPendingUntil = 0;
2123
- const screenText = this.terminalScreen.getText();
2124
- this.recordTrace('submit_write', {
2125
- mode: 'submit_key',
2126
- sendKey: this.sendKey,
2127
- screenText: summarizeCliTraceText(screenText, 500),
2128
- });
1109
+ this.engine.submitPendingUntil = 0;
2129
1110
  void this.writeToPty(this.sendKey).then(() => {
2130
1111
  this.commitSendUserTurn(state);
2131
1112
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, 1), state.retryDelayMs);
@@ -2135,13 +1116,7 @@ export class ProviderCliAdapter implements CliAdapter {
2135
1116
  }
2136
1117
 
2137
1118
  private submitImmediatePrompt(state: SendMessageState, completion: SendMessageCompletion): void {
2138
- this.submitPendingUntil = 0;
2139
- this.recordTrace('submit_write', {
2140
- mode: 'immediate',
2141
- text: summarizeCliTraceText(state.text, 500),
2142
- sendKey: this.sendKey,
2143
- screenText: summarizeCliTraceText(this.terminalScreen.getText(), 500),
2144
- });
1119
+ this.engine.submitPendingUntil = 0;
2145
1120
  void this.writeToPty(state.text + this.sendKey).then(() => {
2146
1121
  this.commitSendUserTurn(state);
2147
1122
  this.submitRetryTimer = setTimeout(() => this.retryImmediateSubmitIfStuck(state), state.retryDelayMs);
@@ -2185,7 +1160,8 @@ export class ProviderCliAdapter implements CliAdapter {
2185
1160
  requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
2186
1161
  screenText: summarizeCliTraceText(screenText, 1000),
2187
1162
  };
2188
- this.recordTrace('submit_echo_missing', diagnostic);
1163
+ LOG.warn('CLI', `[${this.cliType}] submit_echo_missing: ${JSON.stringify(diagnostic)}`);
1164
+
2189
1165
  if (this.requirePromptEchoBeforeSubmit) {
2190
1166
  // At this point the prompt text write already completed. Rejecting without
2191
1167
  // a submit key can leave the delegated CLI with an unsent prompt sitting at
@@ -2222,13 +1198,7 @@ export class ProviderCliAdapter implements CliAdapter {
2222
1198
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
2223
1199
  const content = String(text || '');
2224
1200
  if (!content.trim()) return;
2225
- this.recordTrace('force_send_message', {
2226
- text: summarizeCliTraceText(content, 500),
2227
- status: this.currentStatus,
2228
- isWaitingForResponse: this.isWaitingForResponse,
2229
- queueLength: this.pendingOutboundQueue.length,
2230
- });
2231
- 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}`);
2232
1202
  await this.writeToPty(content + this.sendKey);
2233
1203
  this.onStatusChange?.();
2234
1204
  }
@@ -2237,11 +1207,6 @@ export class ProviderCliAdapter implements CliAdapter {
2237
1207
  const content = String(text || '');
2238
1208
  const duplicate = this.pendingOutboundQueue.some((message) => message.content === content);
2239
1209
  if (duplicate) {
2240
- this.recordTrace('send_message_queued_duplicate_suppressed', {
2241
- reason,
2242
- queueLength: this.pendingOutboundQueue.length,
2243
- text: summarizeCliTraceText(content, 500),
2244
- });
2245
1210
  return;
2246
1211
  }
2247
1212
  const queuedAt = Date.now();
@@ -2253,24 +1218,22 @@ export class ProviderCliAdapter implements CliAdapter {
2253
1218
  source: 'sendMessage',
2254
1219
  };
2255
1220
  this.pendingOutboundQueue.push(message);
2256
- this.recordTrace('send_message_queued', {
2257
- reason,
2258
- queueLength: this.pendingOutboundQueue.length,
2259
- queuedAt,
2260
- text: summarizeCliTraceText(content, 500),
2261
- });
2262
1221
  LOG.info('CLI', `[${this.cliType}] queued outbound message while busy (${reason}); queue=${this.pendingOutboundQueue.length}`);
2263
1222
  this.onStatusChange?.();
2264
1223
  }
2265
1224
 
2266
1225
  private shouldQueuePendingOutboundMessage(parsedStatusBeforeSend: any | null = null): string | null {
2267
1226
  if (this.provider.allowInputDuringGeneration === true) return null;
2268
- if (this.hasActionableApproval()) return null;
1227
+ if (this.engine.hasActionableApproval()) return null;
2269
1228
  const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
2270
1229
  ? String(parsedStatusBeforeSend.status)
2271
1230
  : '';
2272
- if (parsedSessionStatus === 'idle' && this.parsedStatusHasFinalAssistantMessage(parsedStatusBeforeSend)) return null;
2273
- 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';
2274
1237
  if (parsedSessionStatus === 'generating' || parsedSessionStatus === 'long_generating') {
2275
1238
  const parsedModal = parsedStatusBeforeSend?.activeModal ?? parsedStatusBeforeSend?.modal ?? null;
2276
1239
  const parsedHasActionableModal = Boolean(
@@ -2278,15 +1241,15 @@ export class ProviderCliAdapter implements CliAdapter {
2278
1241
  && Array.isArray(parsedModal.buttons)
2279
1242
  && parsedModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim()),
2280
1243
  );
2281
- const terminalLooksIdle = this.currentStatus === 'idle'
1244
+ const terminalLooksIdle = this.engine.currentStatus === 'idle'
2282
1245
  && this.runDetectStatus(this.recentOutputBuffer) === 'idle'
2283
- && !this.isWaitingForResponse
2284
- && !this.currentTurnScope
2285
- && !this.hasActionableApproval()
1246
+ && !this.engine.isWaitingForResponse
1247
+ && !this.engine.currentTurnScope
1248
+ && !this.engine.hasActionableApproval()
2286
1249
  && !parsedHasActionableModal;
2287
1250
  return terminalLooksIdle ? null : `parsed_status_${parsedSessionStatus}`;
2288
1251
  }
2289
- if (this.isWaitingForResponse && this.currentTurnScope) return 'active_turn_in_progress';
1252
+ if (this.engine.isWaitingForResponse && this.engine.currentTurnScope) return 'active_turn_in_progress';
2290
1253
  return null;
2291
1254
  }
2292
1255
 
@@ -2300,18 +1263,12 @@ export class ProviderCliAdapter implements CliAdapter {
2300
1263
 
2301
1264
  private async flushPendingOutboundQueue(): Promise<void> {
2302
1265
  if (this.pendingOutboundFlushInFlight || this.pendingOutboundQueue.length === 0) return;
2303
- if (this.currentStatus !== 'idle' || this.isWaitingForResponse || this.hasActionableApproval()) return;
1266
+ if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) return;
2304
1267
  this.pendingOutboundFlushInFlight = true;
2305
1268
  try {
2306
1269
  while (this.pendingOutboundQueue.length > 0) {
2307
- if (this.currentStatus !== 'idle' || this.isWaitingForResponse || this.hasActionableApproval()) break;
1270
+ if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) break;
2308
1271
  const next = this.pendingOutboundQueue[0];
2309
- this.recordTrace('send_message_queue_flush', {
2310
- id: next.id,
2311
- queuedAt: next.queuedAt,
2312
- queueLength: this.pendingOutboundQueue.length,
2313
- text: summarizeCliTraceText(next.content, 500),
2314
- });
2315
1272
  try {
2316
1273
  await this.sendMessageNow(next.content, false);
2317
1274
  this.pendingOutboundQueue.shift();
@@ -2331,8 +1288,8 @@ export class ProviderCliAdapter implements CliAdapter {
2331
1288
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
2332
1289
  const allowInputDuringGeneration = this.provider.allowInputDuringGeneration === true;
2333
1290
  const allowInterventionPrompt = allowInputDuringGeneration
2334
- && this.isWaitingForResponse
2335
- && !this.hasActionableApproval();
1291
+ && this.engine.isWaitingForResponse
1292
+ && !this.engine.hasActionableApproval();
2336
1293
  if (this.startupParseGate) {
2337
1294
  const deadline = Date.now() + 10000;
2338
1295
  while (this.startupParseGate && Date.now() < deadline) {
@@ -2362,11 +1319,11 @@ export class ProviderCliAdapter implements CliAdapter {
2362
1319
  if (this.runDetectStatus(this.recentOutputBuffer) === 'idle') {
2363
1320
  this.ready = true;
2364
1321
  this.startupParseGate = false;
2365
- this.setStatus('idle', 'send_message_idle_prompt_recovery');
1322
+ this.engine.setStatus('idle', 'send_message_idle_prompt_recovery');
2366
1323
  LOG.info('CLI', `[${this.cliType}] sendMessage recovered idle prompt readiness`);
2367
1324
  }
2368
1325
  }
2369
- 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})`);
2370
1327
  const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
2371
1328
  ? String(parsedStatusBeforeSend.status)
2372
1329
  : '';
@@ -2377,11 +1334,11 @@ export class ProviderCliAdapter implements CliAdapter {
2377
1334
  && Array.isArray(parsedModal.buttons)
2378
1335
  && parsedModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim()),
2379
1336
  );
2380
- const terminalLooksIdle = this.currentStatus === 'idle'
1337
+ const terminalLooksIdle = this.engine.currentStatus === 'idle'
2381
1338
  && this.runDetectStatus(this.recentOutputBuffer) === 'idle'
2382
- && !this.isWaitingForResponse
2383
- && !this.currentTurnScope
2384
- && !this.hasActionableApproval()
1339
+ && !this.engine.isWaitingForResponse
1340
+ && !this.engine.currentTurnScope
1341
+ && !this.engine.hasActionableApproval()
2385
1342
  && !parsedHasActionableModal;
2386
1343
  if (!terminalLooksIdle) {
2387
1344
  if (allowQueue) {
@@ -2391,10 +1348,11 @@ export class ProviderCliAdapter implements CliAdapter {
2391
1348
  throw new Error(`${this.cliName} is still processing the previous prompt`);
2392
1349
  }
2393
1350
  }
2394
- if (this.isWaitingForResponse && !allowInputDuringGeneration) {
1351
+ if (this.engine.isWaitingForResponse && !allowInputDuringGeneration) {
1352
+ const snap = this.getSnapshot();
2395
1353
  if (
2396
- !this.clearStaleIdleResponseGuard('send_message_guard')
2397
- && !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)
2398
1356
  ) {
2399
1357
  if (allowQueue) {
2400
1358
  this.enqueuePendingOutboundMessage(text, 'waiting_for_response');
@@ -2403,30 +1361,21 @@ export class ProviderCliAdapter implements CliAdapter {
2403
1361
  throw new Error(`${this.cliName} is still processing the previous prompt`);
2404
1362
  }
2405
1363
  }
2406
- this.isWaitingForResponse = true;
2407
1364
  this.responseBuffer = '';
2408
- this.finishRetryCount = 0;
2409
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
2410
- this.clearIdleFinishCandidate('send_message');
2411
- this.currentTurnScope = {
1365
+ const turnScope: TurnParseScope = {
2412
1366
  prompt: text,
2413
1367
  startedAt: Date.now(),
2414
1368
  bufferStart: this.accumulatedBuffer.length,
2415
1369
  rawBufferStart: this.accumulatedRawBuffer.length,
2416
1370
  };
2417
- this.recordTrace('send_message', {
2418
- text: summarizeCliTraceText(text, 500),
2419
- estimatedLines: estimatePromptDisplayLines(text),
2420
- turnScope: this.currentTurnScope,
2421
- });
2422
- LOG.info('CLI', `[${this.cliType}] sendMessage turn scope buffer=${this.currentTurnScope.bufferStart} raw=${this.currentTurnScope.rawBufferStart} prompt=${JSON.stringify(text).slice(0, 120)}`);
2423
- this.submitRetryUsed = false;
2424
- this.submitRetryPromptSnippet = extractPromptRetrySnippet(text);
2425
- 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)}`);
2426
1372
  if (this.submitRetryTimer) {
2427
1373
  clearTimeout(this.submitRetryTimer);
2428
1374
  this.submitRetryTimer = null;
2429
1375
  }
1376
+ this.engine.onTurnStarted(turnScope);
1377
+ this.engine.submitRetryPromptSnippet = extractPromptRetrySnippet(text);
1378
+ const normalizedPromptSnippet = normalizePromptText(this.engine.submitRetryPromptSnippet);
2430
1379
  const estimatedLines = estimatePromptDisplayLines(text);
2431
1380
  const submitDelayMs = this.sendDelayMs + Math.min(2000, Math.max(0, estimatedLines - 1) * 350);
2432
1381
  const maxEchoWaitMs = submitDelayMs + Math.max(1500, Math.min(5000, estimatedLines * 500));
@@ -2439,12 +1388,7 @@ export class ProviderCliAdapter implements CliAdapter {
2439
1388
  retryDelayMs,
2440
1389
  didCommitUserTurn: false,
2441
1390
  };
2442
- if (this.settleTimer) {
2443
- clearTimeout(this.settleTimer);
2444
- this.settleTimer = null;
2445
- }
2446
- this.responseEpoch += 1;
2447
- this.responseSettleIgnoreUntil = Date.now() + submitDelayMs + this.timeouts.outputSettle + 250;
1391
+ this.engine.responseSettleIgnoreUntil = Date.now() + submitDelayMs + this.timeouts.outputSettle + 250;
2448
1392
  await new Promise<void>((resolve, reject) => {
2449
1393
  let resolved = false;
2450
1394
  const completion: SendMessageCompletion = {
@@ -2467,24 +1411,20 @@ export class ProviderCliAdapter implements CliAdapter {
2467
1411
  }
2468
1412
 
2469
1413
  if (submitDelayMs > 0) {
2470
- this.submitPendingUntil = Date.now() + submitDelayMs;
1414
+ this.engine.submitPendingUntil = Date.now() + submitDelayMs;
2471
1415
  }
2472
- this.recordTrace('submit_write', {
2473
- mode: 'type_then_submit',
2474
- text: summarizeCliTraceText(text, 500),
2475
- sendKey: this.sendKey,
2476
- screenText: summarizeCliTraceText(this.terminalScreen.getText(), 500),
2477
- });
2478
1416
  const submitStartedAt = Date.now();
2479
1417
  void this.writeToPty(text).then(
2480
1418
  () => this.waitForEchoAndSubmit(sendState, completion, submitStartedAt),
2481
1419
  completion.rejectOnce,
2482
1420
  );
2483
1421
  });
1422
+ // Schedule settle after successful send
1423
+ this.engine.scheduleSettle();
2484
1424
  }
2485
1425
 
2486
1426
  getPartialResponse(): string {
2487
- if (!this.isWaitingForResponse) return '';
1427
+ if (!this.engine.isWaitingForResponse) return '';
2488
1428
  return this.responseBuffer;
2489
1429
  }
2490
1430
 
@@ -2497,10 +1437,10 @@ export class ProviderCliAdapter implements CliAdapter {
2497
1437
  cliType: this.cliType,
2498
1438
  cliName: this.cliName,
2499
1439
  workingDir: this.workingDir,
2500
- currentStatus: this.currentStatus,
1440
+ currentStatus: this.engine.currentStatus,
2501
1441
  ready: this.ready,
2502
- isWaitingForResponse: this.isWaitingForResponse,
2503
- activeModal: this.activeModal,
1442
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1443
+ activeModal: this.engine.activeModal,
2504
1444
  parseErrorMessage: this.parseErrorMessage,
2505
1445
  messageCounts: {
2506
1446
  parsedCache: Array.isArray(parsedResult?.messages) ? parsedResult.messages.length : undefined,
@@ -2526,10 +1466,10 @@ export class ProviderCliAdapter implements CliAdapter {
2526
1466
  lastScreenSnapshotReadAt: this.lastScreenSnapshotReadAt,
2527
1467
  },
2528
1468
  parser: {
2529
- scriptNames: listCliScriptNames(this.cliScripts),
2530
- traceSessionId: this.traceSessionId,
2531
- traceSeq: this.traceSeq,
2532
- currentTurnScope: this.currentTurnScope,
1469
+ scriptNames: this.runner.getScriptNames(),
1470
+ traceSessionId: this.engine.getTraceSessionId(),
1471
+ traceSeq: this.engine.getTraceEntries().length,
1472
+ currentTurnScope: this.engine.currentTurnScope,
2533
1473
  parsedStatusCache: parsedResult
2534
1474
  ? {
2535
1475
  id: parsedResult.id,
@@ -2542,26 +1482,25 @@ export class ProviderCliAdapter implements CliAdapter {
2542
1482
  activeModal: parsedResult.activeModal,
2543
1483
  }
2544
1484
  : null,
2545
- pendingScriptStatus: this.pendingScriptStatus,
2546
- pendingScriptStatusSince: this.pendingScriptStatusSince,
1485
+ pendingScriptStatus: this.engine.pendingScriptStatus,
1486
+ pendingScriptStatusSince: this.engine.pendingScriptStatusSince,
2547
1487
  },
2548
1488
  runtimeMetadata: this.getRuntimeMetadata(),
2549
- statusHistory: this.statusHistory.slice(-80),
2550
- traceEntries: this.traceEntries.slice(-120),
1489
+ statusHistory: this.engine.getStatusHistory().slice(-80),
1490
+ traceEntries: this.engine.getTraceEntries().slice(-120),
2551
1491
  timing: {
2552
1492
  spawnAt: this.spawnAt,
2553
1493
  startupFirstOutputAt: this.startupFirstOutputAt,
2554
- submitPendingUntil: this.submitPendingUntil,
2555
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
2556
- responseEpoch: this.responseEpoch,
1494
+ submitPendingUntil: this.engine.submitPendingUntil,
1495
+ responseSettleIgnoreUntil: this.engine.responseSettleIgnoreUntil,
1496
+ responseEpoch: this.engine.responseEpoch,
2557
1497
  resizeSuppressUntil: this.resizeSuppressUntil,
2558
- lastApprovalResolvedAt: this.lastApprovalResolvedAt,
1498
+ lastApprovalResolvedAt: this.engine.lastApprovalResolvedAt,
2559
1499
  },
2560
1500
  finish: {
2561
- idleFinishCandidate: this.idleFinishCandidate,
2562
- finishRetryCount: this.finishRetryCount,
2563
- submitRetryUsed: this.submitRetryUsed,
2564
- submitRetryPromptSnippet: this.submitRetryPromptSnippet,
1501
+ finishRetryCount: this.engine.finishRetryCount,
1502
+ submitRetryUsed: this.engine.submitRetryUsed,
1503
+ submitRetryPromptSnippet: this.engine.submitRetryPromptSnippet,
2565
1504
  },
2566
1505
  };
2567
1506
  }
@@ -2572,6 +1511,12 @@ export class ProviderCliAdapter implements CliAdapter {
2572
1511
  }
2573
1512
 
2574
1513
  updateRuntimeMeta(meta: Record<string, unknown>, replace = false): void {
1514
+ const nextProviderSessionId = typeof meta?.providerSessionId === 'string'
1515
+ ? meta.providerSessionId.trim()
1516
+ : '';
1517
+ if (nextProviderSessionId) {
1518
+ this.providerSessionId = nextProviderSessionId;
1519
+ }
2575
1520
  if (!this.ptyProcess || typeof this.ptyProcess.updateMeta !== 'function') return;
2576
1521
  this.ptyProcess.updateMeta(meta, replace);
2577
1522
  }
@@ -2592,7 +1537,7 @@ export class ProviderCliAdapter implements CliAdapter {
2592
1537
  this.timeouts.shutdownGrace,
2593
1538
  typeof resume.shutdownGraceMs === 'number' ? resume.shutdownGraceMs : 3000,
2594
1539
  );
2595
- const wasProcessing = this.currentStatus === 'generating' || this.currentStatus === 'waiting_approval';
1540
+ const wasProcessing = this.engine.currentStatus === 'generating' || this.engine.currentStatus === 'waiting_approval';
2596
1541
 
2597
1542
  try {
2598
1543
  if (wasProcessing) {
@@ -2630,7 +1575,7 @@ export class ProviderCliAdapter implements CliAdapter {
2630
1575
  return new Promise((resolve) => {
2631
1576
  const startedAt = Date.now();
2632
1577
  const timer = setInterval(() => {
2633
- if (!this.ptyProcess || this.currentStatus === 'stopped') {
1578
+ if (!this.ptyProcess || this.engine.currentStatus === 'stopped') {
2634
1579
  clearInterval(timer);
2635
1580
  resolve(true);
2636
1581
  return;
@@ -2644,12 +1589,11 @@ export class ProviderCliAdapter implements CliAdapter {
2644
1589
  }
2645
1590
 
2646
1591
  shutdown(): void {
2647
- this.clearIdleFinishCandidate('shutdown');
1592
+ this.engine.clearIdleFinishCandidate('shutdown');
2648
1593
  this.clearAllTimers();
2649
1594
  this.pendingOutputParseChunks = [];
2650
1595
  this.pendingTerminalQueryTail = '';
2651
1596
  this.ptyOutputChunks = [];
2652
- this.finishRetryCount = 0;
2653
1597
  if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
2654
1598
  this.pendingOutboundQueue = [];
2655
1599
  this.pendingOutboundFlushInFlight = false;
@@ -2658,7 +1602,7 @@ export class ProviderCliAdapter implements CliAdapter {
2658
1602
  setTimeout(() => {
2659
1603
  try { this.ptyProcess?.kill(); } catch { }
2660
1604
  this.ptyProcess = null;
2661
- this.setStatus('stopped', 'stop_cmd');
1605
+ this.engine.setStatus('stopped', 'stop_cmd');
2662
1606
  this.ready = false;
2663
1607
  this.startupParseGate = false;
2664
1608
  this.spawnAt = 0;
@@ -2668,12 +1612,11 @@ export class ProviderCliAdapter implements CliAdapter {
2668
1612
  }
2669
1613
 
2670
1614
  detach(): void {
2671
- this.clearIdleFinishCandidate('detach');
1615
+ this.engine.clearIdleFinishCandidate('detach');
2672
1616
  this.clearAllTimers();
2673
1617
  this.pendingOutputParseChunks = [];
2674
1618
  this.pendingTerminalQueryTail = '';
2675
1619
  this.ptyOutputChunks = [];
2676
- this.finishRetryCount = 0;
2677
1620
  if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
2678
1621
  this.pendingOutboundQueue = [];
2679
1622
  this.pendingOutboundFlushInFlight = false;
@@ -2694,19 +1637,17 @@ export class ProviderCliAdapter implements CliAdapter {
2694
1637
  }
2695
1638
 
2696
1639
  clearHistory(): void {
2697
- this.clearIdleFinishCandidate('clear_history');
1640
+ this.engine.clearIdleFinishCandidate('clear_history');
2698
1641
  this.accumulatedBuffer = '';
2699
1642
  this.accumulatedRawBuffer = '';
2700
- this.currentTurnScope = null;
2701
- this.submitRetryUsed = false;
2702
- this.submitRetryPromptSnippet = '';
1643
+ this.engine.currentTurnScope = null;
1644
+ this.engine.submitRetryUsed = false;
1645
+ this.engine.submitRetryPromptSnippet = '';
2703
1646
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
2704
1647
  this.pendingOutputParseChunks = [];
2705
1648
  this.pendingTerminalQueryTail = '';
2706
1649
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
2707
1650
  this.ptyOutputChunks = [];
2708
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
2709
- this.finishRetryCount = 0;
2710
1651
  if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
2711
1652
  this.pendingOutboundQueue = [];
2712
1653
  this.pendingOutboundFlushInFlight = false;
@@ -2715,64 +1656,109 @@ export class ProviderCliAdapter implements CliAdapter {
2715
1656
  this.onStatusChange?.();
2716
1657
  }
2717
1658
 
2718
- isProcessing(): boolean { return this.isWaitingForResponse; }
1659
+ isProcessing(): boolean { return this.engine.isWaitingForResponse; }
2719
1660
  isReady(): boolean { return this.ready; }
2720
1661
 
2721
- async writeRaw(data: string): Promise<void> {
2722
- this.recordTrace('write_raw', {
2723
- keys: JSON.stringify(data),
2724
- length: data.length,
2725
- });
2726
- 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);
2727
1749
  }
2728
1750
 
2729
1751
  resolveModal(buttonIndex: number): void {
2730
- let modal = this.activeModal || this.runParseApproval(this.recentOutputBuffer);
2731
- if (!modal && typeof this.cliScripts?.parseSession === 'function') {
2732
- try {
2733
- const parsed = this.getScriptParsedStatus();
2734
- const parsedModal = parsed?.activeModal && Array.isArray(parsed.activeModal.buttons)
2735
- && parsed.activeModal.buttons.some((button: any) => typeof button === 'string' && button.trim())
2736
- ? parsed.activeModal
2737
- : null;
2738
- if (parsed?.status === 'waiting_approval' && parsedModal) {
2739
- modal = parsedModal;
2740
- this.activeModal = parsedModal;
2741
- if (this.currentStatus !== 'waiting_approval') {
2742
- this.setStatus('waiting_approval', 'resolve_modal_parse');
2743
- this.onStatusChange?.();
2744
- }
2745
- }
2746
- } catch {
2747
- // Ignore parse failures here; resolveModal falls back to current state.
2748
- }
2749
- }
2750
- if (!this.ptyProcess || ((this.currentStatus !== 'waiting_approval') && !modal)) return;
2751
- this.clearIdleFinishCandidate('resolve_modal');
2752
- this.recordTrace('resolve_modal', {
2753
- buttonIndex,
2754
- activeModal: modal,
2755
- });
2756
- this.activeModal = null;
2757
- this.lastApprovalResolvedAt = Date.now();
2758
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
2759
- if (this.approvalExitTimeout) {
2760
- clearTimeout(this.approvalExitTimeout);
2761
- this.approvalExitTimeout = null;
2762
- }
2763
- this.setStatus('generating', 'approval_resolved');
2764
- this.onStatusChange?.();
2765
- if (buttonIndex in this.approvalKeys) {
2766
- this.ptyProcess.write(this.approvalKeys[buttonIndex]);
2767
- } else {
2768
- const buttonCount = Array.isArray(modal?.buttons) ? modal.buttons.length : 0;
2769
- const clampedIndex = buttonCount > 0
2770
- ? Math.min(Math.max(0, buttonIndex), buttonCount - 1)
2771
- : Math.max(0, buttonIndex);
2772
- const DOWN = '\x1B[B';
2773
- const keys = DOWN.repeat(clampedIndex) + '\r';
2774
- this.ptyProcess.write(keys);
2775
- }
1752
+ this.engine.resolveModal(buttonIndex);
1753
+ }
1754
+
1755
+ getApprovalKeyForIndex(buttonIndex: number): string | undefined {
1756
+ return buttonIndex in this.approvalKeys ? this.approvalKeys[buttonIndex] : undefined;
1757
+ }
1758
+
1759
+ /** Returns true if an approval was resolved within the adapter's cooldown window. */
1760
+ isApprovalRecentlyResolved(): boolean {
1761
+ return this.engine.isApprovalRecentlyResolved();
2776
1762
  }
2777
1763
 
2778
1764
  resize(cols: number, rows: number): void {
@@ -2786,7 +1772,7 @@ export class ProviderCliAdapter implements CliAdapter {
2786
1772
  }
2787
1773
 
2788
1774
  private getParsedDebugState(): Record<string, any> | null {
2789
- if (this.startupParseGate || typeof this.cliScripts?.parseSession !== 'function') return null;
1775
+ if (this.startupParseGate || !this.runner.hasParseSession()) return null;
2790
1776
  try {
2791
1777
  const parsed = this.getScriptParsedStatus();
2792
1778
  return parsed && typeof parsed === 'object' ? parsed as Record<string, any> : null;
@@ -2804,6 +1790,10 @@ export class ProviderCliAdapter implements CliAdapter {
2804
1790
  const effectiveReady = this.ready || !!startupModal || startupDetectedStatus === 'waiting_approval';
2805
1791
  const parsedDebugState = this.getParsedDebugState();
2806
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
+ };
2807
1797
  let effectiveStatus = this.projectEffectiveStatus(startupModal);
2808
1798
  if (parsedDebugState?.status === 'error') {
2809
1799
  effectiveStatus = 'error';
@@ -2814,7 +1804,7 @@ export class ProviderCliAdapter implements CliAdapter {
2814
1804
  if (
2815
1805
  effectiveStatus === 'idle'
2816
1806
  && parsedDebugState?.status === 'generating'
2817
- && !this.parsedStatusHasFinalAssistantMessage(parsedDebugState)
1807
+ && !hasFinalAssistant(parsedDebugState)
2818
1808
  ) {
2819
1809
  effectiveStatus = 'generating';
2820
1810
  }
@@ -2824,8 +1814,8 @@ export class ProviderCliAdapter implements CliAdapter {
2824
1814
  providerResolution: this.providerResolutionMeta,
2825
1815
  status: effectiveStatus,
2826
1816
  projectedStatus: effectiveStatus,
2827
- rawStatus: this.currentStatus,
2828
- lifecycleStatus: this.isWaitingForResponse ? 'awaiting_response' : 'idle',
1817
+ rawStatus: this.engine.currentStatus,
1818
+ lifecycleStatus: this.engine.isWaitingForResponse ? 'awaiting_response' : 'idle',
2829
1819
  ready: effectiveReady,
2830
1820
  startupParseGate: this.startupParseGate,
2831
1821
  spawnAt: this.spawnAt,
@@ -2845,10 +1835,9 @@ export class ProviderCliAdapter implements CliAdapter {
2845
1835
  messageCount: parsedMessages.length,
2846
1836
  } : null,
2847
1837
  screenText: screenText.slice(-4000),
2848
- currentTurnScope: this.currentTurnScope,
1838
+ currentTurnScope: this.engine.currentTurnScope,
2849
1839
  startupBuffer: this.startupBuffer.slice(-4000),
2850
1840
  recentOutputBuffer: this.recentOutputBuffer.slice(-500),
2851
- settledBuffer: this.settledBuffer.slice(-500),
2852
1841
  accumulatedBufferLength: this.accumulatedBuffer.length,
2853
1842
  accumulatedRawBufferLength: this.accumulatedRawBuffer.length,
2854
1843
  rawBufferPreview: this.accumulatedRawBuffer.slice(-1000),
@@ -2866,21 +1855,21 @@ export class ProviderCliAdapter implements CliAdapter {
2866
1855
  lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
2867
1856
  lastScreenChangeAt: this.lastScreenChangeAt,
2868
1857
  lastScreenSnapshot: this.lastScreenSnapshot.slice(-500),
2869
- isWaitingForResponse: this.isWaitingForResponse,
2870
- activeModal: startupModal || this.activeModal,
2871
- lastApprovalResolvedAt: this.lastApprovalResolvedAt,
1858
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1859
+ activeModal: startupModal || this.engine.activeModal,
1860
+ lastApprovalResolvedAt: this.engine.lastApprovalResolvedAt,
2872
1861
  sendDelayMs: this.sendDelayMs,
2873
1862
  sendKey: this.sendKey,
2874
1863
  submitStrategy: this.submitStrategy,
2875
1864
  requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
2876
- submitPendingUntil: this.submitPendingUntil,
2877
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
1865
+ submitPendingUntil: this.engine.submitPendingUntil,
1866
+ responseSettleIgnoreUntil: this.engine.responseSettleIgnoreUntil,
2878
1867
  resizeSuppressUntil: this.resizeSuppressUntil,
2879
1868
  hasCliScripts: this.hasCliScripts(),
2880
- scriptNames: listCliScriptNames(this.cliScripts),
2881
- traceSessionId: this.traceSessionId,
2882
- traceEntryCount: this.traceEntries.length,
2883
- 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),
2884
1873
  timeouts: this.timeouts,
2885
1874
  pendingOutputParseBufferLength: this.pendingOutputParseChunks.reduce((total, chunk) => total + chunk.length, 0),
2886
1875
  pendingOutputParseScheduled: !!this.pendingOutputParseTimer,
@@ -2890,20 +1879,21 @@ export class ProviderCliAdapter implements CliAdapter {
2890
1879
 
2891
1880
  getTraceState(limit = 120): Record<string, any> {
2892
1881
  const cappedLimit = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 120));
1882
+ const traceEntries = this.engine.getTraceEntries();
2893
1883
  return {
2894
- sessionId: this.traceSessionId,
1884
+ sessionId: this.engine.getTraceSessionId(),
2895
1885
  providerResolution: this.providerResolutionMeta,
2896
- entryCount: this.traceEntries.length,
2897
- entries: this.traceEntries.slice(-cappedLimit),
1886
+ entryCount: traceEntries.length,
1887
+ entries: traceEntries.slice(-cappedLimit),
2898
1888
  screenText: summarizeCliTraceText(this.terminalScreen.getText(), 4000),
2899
1889
  recentOutputBuffer: summarizeCliTraceText(this.recentOutputBuffer, 1000),
2900
1890
  responseBuffer: summarizeCliTraceText(this.responseBuffer, 1200),
2901
1891
  status: this.projectEffectiveStatus(),
2902
1892
  projectedStatus: this.projectEffectiveStatus(),
2903
- rawStatus: this.currentStatus,
2904
- lifecycleStatus: this.isWaitingForResponse ? 'awaiting_response' : 'idle',
2905
- activeModal: this.activeModal,
2906
- 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,
2907
1897
  messages: [],
2908
1898
  };
2909
1899
  }