@adhdev/daemon-core 0.9.82-rc.14 → 0.9.82-rc.141

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 (115) hide show
  1. package/dist/chat/source-machine.d.ts +166 -0
  2. package/dist/chat/source-resolver.d.ts +104 -0
  3. package/dist/chat/subscription-updates.d.ts +1 -0
  4. package/dist/cli-adapter-types.d.ts +5 -1
  5. package/dist/cli-adapters/cli-script-runner.d.ts +45 -0
  6. package/dist/cli-adapters/cli-state-engine.d.ts +178 -0
  7. package/dist/cli-adapters/provider-cli-adapter.d.ts +87 -63
  8. package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
  9. package/dist/cli-adapters/provider-cli-shared.d.ts +21 -0
  10. package/dist/commands/router.d.ts +22 -0
  11. package/dist/config/chat-history.d.ts +5 -0
  12. package/dist/config/config.d.ts +5 -0
  13. package/dist/config/mesh-config.d.ts +68 -1
  14. package/dist/git/git-commands.d.ts +5 -1
  15. package/dist/index.d.ts +18 -6
  16. package/dist/index.js +10431 -2827
  17. package/dist/index.js.map +1 -1
  18. package/dist/index.mjs +10376 -2811
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/installer.d.ts +1 -4
  21. package/dist/launch.d.ts +1 -1
  22. package/dist/logging/async-batch-writer.d.ts +10 -0
  23. package/dist/mesh/beads-db.d.ts +72 -0
  24. package/dist/mesh/contracts.d.ts +164 -0
  25. package/dist/mesh/coordinator-registry.d.ts +25 -0
  26. package/dist/mesh/mesh-active-work.d.ts +90 -0
  27. package/dist/mesh/mesh-events.d.ts +77 -5
  28. package/dist/mesh/mesh-fast-forward.d.ts +39 -0
  29. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  30. package/dist/mesh/mesh-ledger.d.ts +58 -1
  31. package/dist/mesh/mesh-refine-status.d.ts +26 -0
  32. package/dist/mesh/mesh-work-queue.d.ts +44 -5
  33. package/dist/mesh/preview-freshness.d.ts +18 -0
  34. package/dist/mesh/refine-config.d.ts +193 -0
  35. package/dist/mesh/worktree-bootstrap-config.d.ts +113 -0
  36. package/dist/providers/approval-utils.d.ts +9 -0
  37. package/dist/providers/chat-message-normalization.d.ts +1 -0
  38. package/dist/providers/cli-provider-instance.d.ts +6 -1
  39. package/dist/providers/contracts.d.ts +19 -0
  40. package/dist/providers/read-chat-contract.d.ts +29 -0
  41. package/dist/providers/transcript-v2.d.ts +176 -0
  42. package/dist/repo-mesh-types.d.ts +67 -0
  43. package/dist/shared-types.d.ts +12 -0
  44. package/dist/status/reporter.d.ts +2 -0
  45. package/dist/status/snapshot.d.ts +1 -0
  46. package/dist/types.d.ts +5 -0
  47. package/package.json +3 -1
  48. package/src/boot/daemon-lifecycle.ts +3 -0
  49. package/src/chat/source-machine.ts +534 -0
  50. package/src/chat/source-resolver.ts +0 -0
  51. package/src/chat/subscription-updates.ts +14 -1
  52. package/src/cli-adapter-types.d.ts +1 -0
  53. package/src/cli-adapter-types.ts +3 -1
  54. package/src/cli-adapters/cli-script-runner.ts +145 -0
  55. package/src/cli-adapters/cli-state-engine.ts +1083 -0
  56. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  57. package/src/cli-adapters/provider-cli-adapter.ts +630 -1137
  58. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  59. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  60. package/src/cli-adapters/provider-cli-runtime.ts +3 -1
  61. package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
  62. package/src/cli-adapters/provider-cli-shared.ts +51 -11
  63. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +17 -1
  64. package/src/cli-adapters/terminal-backends/xterm-backend.ts +8 -1
  65. package/src/commands/chat-commands.ts +1428 -50
  66. package/src/commands/cli-manager.ts +145 -3
  67. package/src/commands/handler.ts +8 -1
  68. package/src/commands/mesh-coordinator.ts +13 -143
  69. package/src/commands/router.ts +3271 -427
  70. package/src/config/chat-history.ts +79 -24
  71. package/src/config/config.ts +12 -0
  72. package/src/config/mesh-config.ts +249 -2
  73. package/src/config/recent-activity.ts +8 -2
  74. package/src/daemon/dev-cli-debug.ts +10 -1
  75. package/src/detection/ide-detector.ts +26 -16
  76. package/src/git/git-commands.ts +17 -5
  77. package/src/git/git-worktree.ts +8 -1
  78. package/src/index.ts +45 -5
  79. package/src/installer.d.ts +1 -1
  80. package/src/installer.ts +8 -6
  81. package/src/launch.d.ts +1 -1
  82. package/src/launch.ts +37 -28
  83. package/src/logging/async-batch-writer.ts +55 -0
  84. package/src/logging/logger.ts +2 -1
  85. package/src/mesh/beads-db.ts +479 -0
  86. package/src/mesh/contracts.ts +329 -0
  87. package/src/mesh/coordinator-prompt.ts +40 -22
  88. package/src/mesh/coordinator-registry.ts +75 -0
  89. package/src/mesh/mesh-active-work.ts +437 -0
  90. package/src/mesh/mesh-events.ts +799 -56
  91. package/src/mesh/mesh-fast-forward.ts +430 -0
  92. package/src/mesh/mesh-host-ownership.ts +73 -0
  93. package/src/mesh/mesh-ledger.ts +457 -104
  94. package/src/mesh/mesh-refine-status.ts +144 -0
  95. package/src/mesh/mesh-work-queue.ts +216 -158
  96. package/src/mesh/preview-freshness.ts +118 -0
  97. package/src/mesh/refine-config.ts +366 -0
  98. package/src/mesh/worktree-bootstrap-config.ts +247 -0
  99. package/src/providers/approval-utils.ts +39 -5
  100. package/src/providers/chat-message-normalization.ts +7 -12
  101. package/src/providers/cli-provider-instance.ts +362 -41
  102. package/src/providers/contracts.ts +19 -0
  103. package/src/providers/ide-provider-instance.ts +17 -3
  104. package/src/providers/provider-loader.ts +31 -11
  105. package/src/providers/provider-schema.ts +12 -0
  106. package/src/providers/read-chat-contract.ts +76 -16
  107. package/src/providers/transcript-v2.ts +567 -0
  108. package/src/providers/version-archive.ts +38 -20
  109. package/src/repo-mesh-types.ts +77 -0
  110. package/src/shared-types.ts +9 -0
  111. package/src/status/builders.ts +23 -6
  112. package/src/status/reporter.ts +15 -0
  113. package/src/status/snapshot.ts +35 -11
  114. package/src/system/host-memory.ts +29 -12
  115. package/src/types.ts +5 -0
@@ -44,6 +44,13 @@ import {
44
44
  type CliTraceEntry,
45
45
  type ParsedSession,
46
46
  } from './provider-cli-shared.js';
47
+ import { CliScriptRunner } from './cli-script-runner.js';
48
+ import {
49
+ CliStateEngine,
50
+ type CliBufferSnapshot,
51
+ type CliTransportAccess,
52
+ type CliStateEngineCallbacks,
53
+ } from './cli-state-engine.js';
47
54
  import {
48
55
  buildCliParseInput,
49
56
  buildCliTraceParseSnapshot,
@@ -78,24 +85,6 @@ export {
78
85
  } from './provider-cli-shared.js';
79
86
 
80
87
 
81
- interface IdleFinishCandidate {
82
- armedAt: number;
83
- lastOutputAt: number;
84
- lastScreenChangeAt: number;
85
- responseEpoch: number;
86
- assistantLength: number;
87
- }
88
-
89
- interface SettledEvalContext {
90
- now: number;
91
- modal: any;
92
- status: string;
93
- parsedMessages: CliChatMessage[];
94
- lastParsedAssistant: CliChatMessage | undefined;
95
- parsedStatus: string | null;
96
- prevStatus: string;
97
- }
98
-
99
88
  interface SendMessageState {
100
89
  text: string;
101
90
  normalizedPromptSnippet: string;
@@ -110,6 +99,14 @@ interface SendMessageCompletion {
110
99
  rejectOnce: (error: unknown) => void;
111
100
  }
112
101
 
102
+ interface PendingOutboundMessage {
103
+ id: string;
104
+ role: 'user';
105
+ content: string;
106
+ queuedAt: number;
107
+ source: 'sendMessage';
108
+ }
109
+
113
110
  export function appendBoundedText(current: string, chunk: string, maxChars: number): string {
114
111
  if (!chunk) return current.length <= maxChars ? current : current.slice(-maxChars);
115
112
  if (maxChars <= 0) return '';
@@ -122,23 +119,23 @@ export function appendBoundedText(current: string, chunk: string, maxChars: numb
122
119
  // ─── Adapter ────────────────────────────────────────
123
120
 
124
121
  export class ProviderCliAdapter implements CliAdapter {
125
- readonly cliType: string;
122
+ cliType: string;
126
123
  readonly cliName: string;
127
124
  public workingDir: string;
128
125
 
129
126
  private provider: CliProviderModule;
130
127
  private ptyProcess: PtyRuntimeTransport | null = null;
131
128
  private transportFactory: PtyTransportFactory;
132
- private currentStatus: CliSessionStatus['status'] = 'starting';
133
129
  private onStatusChange: (() => void) | null = null;
134
130
 
131
+ // ─── State machine engine ─────────────────────────
132
+ readonly engine: CliStateEngine;
133
+
135
134
  private responseBuffer = '';
136
135
  private recentOutputBuffer = '';
137
- private isWaitingForResponse = false;
138
- private activeModal: { message: string; buttons: string[] } | null = null;
139
- private parseErrorMessage: string | null = null;
136
+ private get parseErrorMessage(): string | null { return this.runner.parseErrorMessage; }
137
+ private providerSessionId: string | null = null;
140
138
  private responseTimeout: NodeJS.Timeout | null = null;
141
- private idleTimeout: NodeJS.Timeout | null = null;
142
139
  private ready = false;
143
140
  private startupBuffer = '';
144
141
  private startupParseGate = false;
@@ -164,39 +161,25 @@ export class ProviderCliAdapter implements CliAdapter {
164
161
  private serverConn: any = null;
165
162
  private logBuffer: { message: string; level: string }[] = [];
166
163
 
167
- // Approval cooldown
168
- private lastApprovalResolvedAt: number = 0;
169
-
170
- // Approval state machine
171
- private approvalTransitionBuffer: string = '';
172
- private approvalExitTimeout: NodeJS.Timeout | null = null;
173
- private pendingScriptStatus: 'generating' | 'waiting_approval' | null = null;
174
- private pendingScriptStatusSince = 0;
175
- private pendingScriptStatusTimer: NodeJS.Timeout | null = null;
176
-
177
- // Output settle debounce — fires after PTY output goes quiet
178
- private settleTimer: NodeJS.Timeout | null = null;
179
- private settledBuffer: string = '';
180
- private submitPendingUntil = 0;
181
- private responseSettleIgnoreUntil = 0;
182
- private responseEpoch = 0;
164
+ private pendingOutboundQueue: PendingOutboundMessage[] = [];
165
+ private pendingOutboundFlushTimer: NodeJS.Timeout | null = null;
166
+ private pendingOutboundFlushInFlight = false;
167
+ // Submit retry timer — PTY-level, not state machine
183
168
  private submitRetryTimer: NodeJS.Timeout | null = null;
184
- private submitRetryUsed = false;
185
- private submitRetryPromptSnippet = '';
186
- private idleFinishCandidate: IdleFinishCandidate | null = null;
187
- private finishRetryTimer: NodeJS.Timeout | null = null;
188
- private finishRetryCount = 0;
189
169
 
190
170
  // Resize redraw suppression
191
171
  private resizeSuppressUntil: number = 0;
192
172
 
193
- // Debug: status transition history
194
- private statusHistory: { status: string; at: number; trigger?: string }[] = [];
173
+ // (A2.2) Native transcript anchor moved to CHAT_SOURCE_REGISTRY.
174
+ // ChatSourceMachine holds the lock by state, not by a mutable field on
175
+ // the adapter. Removed entirely; no callers remain after the readChat
176
+ // ladder was replaced.
195
177
 
196
- // ─── CLI Scripts (script-based parsing) ───
197
- private cliScripts: CliScripts;
198
- /** Per-session opaque state object created by cliScripts.createState(), reset on stop. */
199
- private scriptState: unknown = null;
178
+ // ─── Script runner (parsing isolated here, adapter stays as transport) ───
179
+ private readonly runner: CliScriptRunner;
180
+ /** @deprecated use runner.cliScripts for direct script access */
181
+ get cliScripts(): CliScripts { return this.runner.cliScripts; }
182
+ set cliScripts(scripts: CliScripts) { this.setCliScripts(scripts); }
200
183
  private runtimeSettings: Record<string, any> = {};
201
184
  /** Full accumulated rendered PTY transcript for parser/readback use */
202
185
  private accumulatedBuffer: string = '';
@@ -216,10 +199,6 @@ export class ProviderCliAdapter implements CliAdapter {
216
199
  * Hermes turn (tool calls + reasoning + final bubble) without the
217
200
  * rolling window pushing the turn's ╭─ opening line out of view. */
218
201
  private static readonly MAX_ACCUMULATED_BUFFER = 262144;
219
- private currentTurnScope: TurnParseScope | null = null;
220
- private traceEntries: CliTraceEntry[] = [];
221
- private traceSeq = 0;
222
- private traceSessionId = '';
223
202
  private parsedStatusCache: {
224
203
  responseBuffer: string;
225
204
  currentTurnScope: TurnParseScope | null;
@@ -233,11 +212,8 @@ export class ProviderCliAdapter implements CliAdapter {
233
212
  result: any;
234
213
  } | null = null;
235
214
  private static readonly SCREEN_SNAPSHOT_MIN_INTERVAL_MS = 250;
236
- private static readonly MAX_TRACE_ENTRIES = 250;
237
215
 
238
216
  private readonly providerResolutionMeta: ProviderResolutionMeta;
239
- private static readonly FINISH_RETRY_DELAY_MS = 300;
240
- private static readonly MAX_FINISH_RETRIES = 2;
241
217
 
242
218
  private getBufferState(): NonNullable<CliSessionStatus['bufferState']> | undefined {
243
219
  const build = (droppedChars: number, maxChars: number) => droppedChars > 0
@@ -313,13 +289,13 @@ export class ProviderCliAdapter implements CliAdapter {
313
289
  if (
314
290
  cached
315
291
  && cached.responseBuffer === this.responseBuffer
316
- && cached.currentTurnScope === this.currentTurnScope
292
+ && cached.currentTurnScope === this.engine.currentTurnScope
317
293
  && cached.recentOutputBuffer === this.recentOutputBuffer
318
294
  && cached.accumulatedBuffer === this.accumulatedBuffer
319
295
  && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
320
296
  && cached.screenText === this.lastScreenText
321
- && cached.currentStatus === this.currentStatus
322
- && cached.activeModal === this.activeModal
297
+ && cached.currentStatus === this.engine.currentStatus
298
+ && cached.activeModal === this.engine.activeModal
323
299
  && cached.cliName === this.cliName
324
300
  ) {
325
301
  return cached.result;
@@ -343,86 +319,6 @@ export class ProviderCliAdapter implements CliAdapter {
343
319
  return this.timeouts.statusActivityHold;
344
320
  }
345
321
 
346
- private setStatus(status: CliSessionStatus['status'], trigger?: string): void {
347
- const prev = this.currentStatus;
348
- if (prev === status) return;
349
- this.currentStatus = status;
350
- this.statusHistory.push({ status, at: Date.now(), trigger });
351
- if (this.statusHistory.length > 50) this.statusHistory.shift();
352
- this.recordTrace('status', {
353
- previousStatus: prev,
354
- trigger: trigger || null,
355
- });
356
- LOG.info('CLI', `[${this.cliType}] status: ${prev} → ${status}${trigger ? ` (${trigger})` : ''}`);
357
- }
358
-
359
- private clearIdleFinishCandidate(reason: string): void {
360
- if (!this.idleFinishCandidate) return;
361
- this.recordTrace('idle_candidate_reset', {
362
- reason,
363
- candidate: this.idleFinishCandidate,
364
- });
365
- this.idleFinishCandidate = null;
366
- }
367
-
368
- private armIdleFinishCandidate(assistantLength: number): void {
369
- const now = Date.now();
370
- const idleFinishConfirmMs = this.getIdleFinishConfirmMs();
371
- this.idleFinishCandidate = {
372
- armedAt: now,
373
- lastOutputAt: this.lastOutputAt,
374
- lastScreenChangeAt: this.lastScreenChangeAt,
375
- responseEpoch: this.responseEpoch,
376
- assistantLength,
377
- };
378
- this.recordTrace('idle_candidate_armed', {
379
- confirmMs: idleFinishConfirmMs,
380
- candidate: this.idleFinishCandidate,
381
- ...buildCliTraceParseSnapshot({
382
- accumulatedBuffer: this.accumulatedBuffer,
383
- accumulatedRawBuffer: this.accumulatedRawBuffer,
384
- responseBuffer: this.responseBuffer,
385
- partialResponse: this.responseBuffer,
386
- scope: this.currentTurnScope,
387
- }),
388
- });
389
- if (this.settleTimer) clearTimeout(this.settleTimer);
390
- this.settleTimer = setTimeout(() => {
391
- this.settleTimer = null;
392
- this.settledBuffer = this.recentOutputBuffer;
393
- this.evaluateSettled();
394
- }, idleFinishConfirmMs);
395
- }
396
-
397
-
398
- private recordTrace(type: string, payload: Record<string, any> = {}): void {
399
- const entry: CliTraceEntry = {
400
- id: ++this.traceSeq,
401
- at: Date.now(),
402
- type,
403
- status: this.currentStatus,
404
- isWaitingForResponse: this.isWaitingForResponse,
405
- activeModal: this.activeModal
406
- ? { message: this.activeModal.message, buttons: [...this.activeModal.buttons] }
407
- : null,
408
- payload,
409
- };
410
- this.traceEntries.push(entry);
411
- if (this.traceEntries.length > ProviderCliAdapter.MAX_TRACE_ENTRIES) {
412
- this.traceEntries.splice(0, this.traceEntries.length - ProviderCliAdapter.MAX_TRACE_ENTRIES);
413
- }
414
- }
415
-
416
- private resetTraceSession(): void {
417
- this.traceEntries = [];
418
- this.traceSeq = 0;
419
- this.traceSessionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
420
- this.recordTrace('session_start', {
421
- providerType: this.cliType,
422
- workingDir: this.workingDir,
423
- });
424
- }
425
-
426
322
  // Resolved timeouts
427
323
  private readonly timeouts: Required<NonNullable<CliProviderModule['timeouts']>>;
428
324
 
@@ -432,7 +328,6 @@ export class ProviderCliAdapter implements CliAdapter {
432
328
  private readonly sendKey: string;
433
329
  private readonly submitStrategy: 'wait_for_echo' | 'immediate';
434
330
  private readonly requirePromptEchoBeforeSubmit: boolean;
435
- private static readonly SCRIPT_STATUS_DEBOUNCE_MS = 3000;
436
331
 
437
332
  constructor(
438
333
  provider: CliProviderModule,
@@ -441,6 +336,7 @@ export class ProviderCliAdapter implements CliAdapter {
441
336
  private extraEnv: Record<string, string> = {},
442
337
  transportFactory: PtyTransportFactory = new NodePtyTransportFactory(),
443
338
  ) {
339
+ this.runner = new CliScriptRunner(provider.type);
444
340
  this.provider = provider;
445
341
  this.transportFactory = transportFactory;
446
342
  this.cliType = provider.type;
@@ -458,10 +354,22 @@ export class ProviderCliAdapter implements CliAdapter {
458
354
  this.requirePromptEchoBeforeSubmit = resolvedConfig.requirePromptEchoBeforeSubmit;
459
355
  this.providerResolutionMeta = resolvedConfig.providerResolutionMeta;
460
356
 
461
- // Scripts are requiredloaded by ProviderLoader via compatibility array
462
- this.cliScripts = provider.scripts || {};
463
- this.scriptState = typeof this.cliScripts.createState === 'function' ? (this.cliScripts.createState() ?? null) : null;
464
- const scriptNames = listCliScriptNames(this.cliScripts);
357
+ // State machine engineowns all status transitions
358
+ this.engine = new CliStateEngine(
359
+ provider,
360
+ this.runner,
361
+ this as unknown as CliTransportAccess,
362
+ {
363
+ onStatusChange: () => { this.onStatusChange?.(); },
364
+ onApplyParsedSession: (session) => { this.applyParsedSessionMetadata(session); },
365
+ onTurnCompleted: () => { this.responseBuffer = ''; },
366
+ } satisfies CliStateEngineCallbacks,
367
+ resolvedConfig.timeouts,
368
+ );
369
+
370
+ // Scripts delegated to CliScriptRunner — adapter stays as transport
371
+ this.runner.setScripts(provider.scripts || {});
372
+ const scriptNames = this.runner.getScriptNames();
465
373
  if (scriptNames.length > 0) {
466
374
  LOG.info('CLI', `[${this.cliType}] CLI scripts: [${scriptNames.join(', ')}]`);
467
375
  LOG.info(
@@ -487,14 +395,9 @@ export class ProviderCliAdapter implements CliAdapter {
487
395
 
488
396
  /** Inject CLI scripts after construction (e.g. when resolved by ProviderLoader) */
489
397
  setCliScripts(scripts: CliScripts): void {
490
- this.cliScripts = scripts;
398
+ this.runner.setScripts(scripts);
491
399
  this.parsedStatusCache = null;
492
- this.parseErrorMessage = null;
493
- // Initialize per-session state: createState() is called once here and on script reload.
494
- // The returned object lives until the PTY exits (scriptState = null on exit).
495
- this.scriptState = typeof scripts.createState === 'function' ? (scripts.createState() ?? null) : null;
496
- const scriptNames = listCliScriptNames(scripts);
497
- LOG.info('CLI', `[${this.cliType}] CLI scripts injected: [${scriptNames.join(', ')}]`);
400
+ LOG.info('CLI', `[${this.cliType}] CLI scripts injected: [${this.runner.getScriptNames().join(', ')}]`);
498
401
  }
499
402
 
500
403
  /** Refresh provider scripts/config used by this adapter without restarting the PTY runtime. */
@@ -548,15 +451,6 @@ export class ProviderCliAdapter implements CliAdapter {
548
451
  });
549
452
 
550
453
  LOG.info('CLI', `[${this.cliType}] Spawning in ${this.workingDir}`);
551
- this.resetTraceSession();
552
- this.recordTrace('spawn', {
553
- shellCommand: spawnPlan.shellCmd,
554
- shellArgs: spawnPlan.shellArgs,
555
- cwd: spawnPlan.ptyOptions.cwd,
556
- cols: spawnPlan.ptyOptions.cols,
557
- rows: spawnPlan.ptyOptions.rows,
558
- providerResolution: this.providerResolutionMeta,
559
- });
560
454
 
561
455
  try {
562
456
  this.ptyProcess = this.transportFactory.spawn(
@@ -620,13 +514,12 @@ export class ProviderCliAdapter implements CliAdapter {
620
514
  this.ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
621
515
  LOG.info('CLI', `[${this.cliType}] Exit code ${exitCode}`);
622
516
  this.flushPendingOutputParse();
623
- this.recordTrace('exit', { exitCode });
624
517
  this.ptyProcess = null;
625
- this.setStatus('stopped', 'pty_exit');
518
+ this.engine.onPtyExit();
626
519
  this.ready = false;
627
520
  this.startupParseGate = false;
628
521
  this.spawnAt = 0;
629
- this.scriptState = null;
522
+ this.runner.resetSessionState();
630
523
  this.onStatusChange?.();
631
524
  });
632
525
 
@@ -637,15 +530,9 @@ export class ProviderCliAdapter implements CliAdapter {
637
530
  if (this.startupSettleTimer) { clearTimeout(this.startupSettleTimer); this.startupSettleTimer = null; }
638
531
  this.resetTerminalScreen(24, 80);
639
532
  this.pendingTerminalQueryTail = '';
640
- this.currentTurnScope = null;
641
- this.finishRetryCount = 0;
642
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
643
533
  this.ready = false;
644
534
  await this.ptyProcess.ready;
645
- this.recordTrace('ready', {
646
- runtimeMeta: this.getRuntimeMetadata(),
647
- });
648
- this.setStatus('starting', 'pty_ready');
535
+ this.engine.onSpawnReady();
649
536
  this.scheduleStartupSettleCheck();
650
537
  this.onStatusChange?.();
651
538
  }
@@ -671,11 +558,11 @@ export class ProviderCliAdapter implements CliAdapter {
671
558
  if (this.startupParseGate && !this.startupFirstOutputAt && (cleanData.trim() || normalizedScreenSnapshot.trim())) {
672
559
  this.startupFirstOutputAt = now;
673
560
  }
674
- if (this.idleFinishCandidate && (rawData.length > 0 || cleanData.length > 0)) {
675
- this.clearIdleFinishCandidate('new_output');
561
+ if (rawData.length > 0 || cleanData.length > 0) {
562
+ this.engine.clearIdleFinishCandidate('new_output');
676
563
  }
677
564
  if (getDebugRuntimeConfig().collectDebugTrace) {
678
- this.recordTrace('output', {
565
+ this.engine.recordExternalTrace('output', {
679
566
  rawLength: rawData.length,
680
567
  cleanLength: cleanData.length,
681
568
  rawPreview: summarizeCliTraceText(rawData, 300),
@@ -687,7 +574,7 @@ export class ProviderCliAdapter implements CliAdapter {
687
574
  this.scheduleStartupSettleCheck();
688
575
  }
689
576
 
690
- if (this.isWaitingForResponse && cleanData) {
577
+ if (this.engine.isWaitingForResponse && cleanData) {
691
578
  const previousResponseLen = this.responseBuffer.length;
692
579
  this.responseBuffer = appendBoundedText(this.responseBuffer, cleanData, ProviderCliAdapter.MAX_RESPONSE_BUFFER);
693
580
  this.responseBufferDroppedChars += this.recordBoundedAppendDrop(previousResponseLen, cleanData.length, this.responseBuffer.length);
@@ -726,19 +613,19 @@ export class ProviderCliAdapter implements CliAdapter {
726
613
  // Keep turn-scope offsets aligned with the truncated buffer so scoped
727
614
  // parses don't lose the beginning of a long turn (e.g. the Hermes
728
615
  // ╭─ opening line) when the rolling window sheds bytes.
729
- if (this.currentTurnScope) {
616
+ if (this.engine.currentTurnScope) {
730
617
  if (droppedClean > 0) {
731
- this.currentTurnScope.bufferStart = Math.max(0, this.currentTurnScope.bufferStart - droppedClean);
618
+ this.engine.currentTurnScope.bufferStart = Math.max(0, this.engine.currentTurnScope.bufferStart - droppedClean);
732
619
  }
733
620
  if (droppedRaw > 0) {
734
- this.currentTurnScope.rawBufferStart = Math.max(0, this.currentTurnScope.rawBufferStart - droppedRaw);
621
+ this.engine.currentTurnScope.rawBufferStart = Math.max(0, this.engine.currentTurnScope.rawBufferStart - droppedRaw);
735
622
  }
736
623
  }
737
624
 
738
625
  this.resolveStartupState('output', screenText, normalizedScreenSnapshot, now);
739
626
 
740
627
  // ─── Script-based status detection
741
- this.scheduleSettle();
628
+ this.engine.scheduleSettle();
742
629
  }
743
630
 
744
631
  private resolveStartupState(
@@ -761,6 +648,11 @@ export class ProviderCliAdapter implements CliAdapter {
761
648
  if (stableMs < 2000) return;
762
649
 
763
650
  const startupModal = this.runParseApproval(this.recentOutputBuffer);
651
+ const startupStatus = this.runDetectStatus(screenText || this.recentOutputBuffer);
652
+ if (!startupModal && startupStatus !== 'idle') {
653
+ this.scheduleStartupSettleCheck();
654
+ return;
655
+ }
764
656
  this.startupParseGate = false;
765
657
  if (this.startupSettleTimer) {
766
658
  clearTimeout(this.startupSettleTimer);
@@ -768,14 +660,22 @@ export class ProviderCliAdapter implements CliAdapter {
768
660
  }
769
661
  this.ready = true;
770
662
  if (startupModal) {
771
- this.activeModal = startupModal;
772
- this.setStatus('waiting_approval', `startup_ready:${trigger}`);
663
+ this.engine.activeModal = startupModal;
664
+ this.engine.setStatus('waiting_approval', `startup_ready:${trigger}`);
773
665
  } else {
774
- if (this.currentStatus === 'waiting_approval' || this.activeModal) {
775
- this.lastApprovalResolvedAt = Date.now();
666
+ if (this.engine.currentStatus === 'waiting_approval' || this.engine.activeModal) {
667
+ this.engine.lastApprovalResolvedAt = Date.now();
776
668
  }
777
- this.activeModal = null;
778
- this.setStatus('idle', `startup_ready:${trigger}`);
669
+ this.engine.activeModal = null;
670
+ // Clear the in-flight turn flag at the same time we declare
671
+ // startup-idle. Otherwise the next settled evaluation sees
672
+ // isWaitingForResponse=true + recent CLI welcome-screen paints
673
+ // and flips us right back to generating via the hold path
674
+ // (the "startup → generating → idle → generating → idle"
675
+ // flicker the user observed on claude-cli launch).
676
+ this.engine.isWaitingForResponse = false;
677
+ this.engine.currentTurnScope = null;
678
+ this.engine.setStatus('idle', `startup_ready:${trigger}`);
779
679
  }
780
680
  LOG.info(
781
681
  'CLI',
@@ -801,88 +701,6 @@ export class ProviderCliAdapter implements CliAdapter {
801
701
  }, delayMs);
802
702
  }
803
703
 
804
- private scheduleSettle(): void {
805
- if (this.settleTimer) clearTimeout(this.settleTimer);
806
- const settleEpoch = this.responseEpoch;
807
- const delay = Math.max(
808
- this.timeouts.outputSettle,
809
- this.submitPendingUntil > Date.now()
810
- ? (this.submitPendingUntil - Date.now()) + this.timeouts.outputSettle
811
- : 0,
812
- );
813
- this.settleTimer = setTimeout(() => {
814
- this.settleTimer = null;
815
- if (settleEpoch !== this.responseEpoch) return;
816
- this.settledBuffer = this.recentOutputBuffer;
817
- this.evaluateSettled();
818
- }, delay);
819
- }
820
-
821
- private armApprovalExitTimeout(): void {
822
- if (this.approvalExitTimeout) clearTimeout(this.approvalExitTimeout);
823
- this.approvalExitTimeout = setTimeout(() => {
824
- if (!this.hasActionableApproval()) return;
825
- const tail = this.recentOutputBuffer;
826
- const screenText = this.terminalScreen.getText() || '';
827
- const modal = this.runParseApproval(tail);
828
- const stillWaiting = this.runDetectStatus(tail) === 'waiting_approval' || !!modal;
829
- if (stillWaiting) {
830
- if (!modal) {
831
- LOG.warn('CLI', `[${this.cliType}] approval timeout check found no actionable modal; keeping approval state fail-closed`);
832
- this.activeModal = null;
833
- this.onStatusChange?.();
834
- this.armApprovalExitTimeout();
835
- return;
836
- }
837
- this.activeModal = modal;
838
- this.onStatusChange?.();
839
- this.armApprovalExitTimeout();
840
- return;
841
- }
842
- LOG.warn('CLI', `[${this.cliType}] Approval timeout — auto-clearing`);
843
- this.activeModal = null;
844
- this.lastApprovalResolvedAt = Date.now();
845
- this.setStatus('idle', 'approval_timeout');
846
- this.onStatusChange?.();
847
- }, 60000);
848
- }
849
-
850
- private shouldRetryFinishResponse(commitResult: { hasAssistant: boolean; assistantContent: string }): boolean {
851
- if (!this.currentTurnScope) return false;
852
- if (this.hasActionableApproval()) return false;
853
- if (this.finishRetryCount >= ProviderCliAdapter.MAX_FINISH_RETRIES) return false;
854
- if (commitResult.hasAssistant && commitResult.assistantContent.trim()) return false;
855
-
856
- if (this.runDetectStatus(this.recentOutputBuffer) !== 'idle') return false;
857
-
858
- const now = Date.now();
859
- const quietForMs = this.lastNonEmptyOutputAt ? (now - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
860
- const screenStableMs = this.lastScreenChangeAt ? (now - this.lastScreenChangeAt) : 0;
861
- return quietForMs < 1200 || screenStableMs < 1200 || !commitResult.hasAssistant;
862
- }
863
-
864
- private hasRecentInteractiveActivity(now: number): boolean {
865
- const quietForMs = this.lastNonEmptyOutputAt ? (now - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
866
- const screenStableMs = this.lastScreenChangeAt ? (now - this.lastScreenChangeAt) : Number.MAX_SAFE_INTEGER;
867
- const holdMs = this.getStatusActivityHoldMs();
868
- return quietForMs < holdMs
869
- || screenStableMs < holdMs;
870
- }
871
-
872
- private shouldDeferIdleTimeoutFinish(): boolean {
873
- if (!this.isWaitingForResponse || this.hasActionableApproval()) {
874
- return false;
875
- }
876
- const latestStatus = this.runDetectStatus(this.recentOutputBuffer) || this.currentStatus;
877
- if (latestStatus === 'generating') {
878
- this.settledBuffer = this.recentOutputBuffer;
879
- this.evaluateSettled();
880
- return true;
881
- }
882
- return false;
883
- }
884
-
885
-
886
704
  private async waitForInteractivePrompt(maxWaitMs = 5000): Promise<void> {
887
705
  const startedAt = Date.now();
888
706
  let loggedWait = false;
@@ -892,7 +710,7 @@ export class ProviderCliAdapter implements CliAdapter {
892
710
  const screenText = this.terminalScreen.getText() || '';
893
711
  const stableMs = this.lastScreenChangeAt ? (Date.now() - this.lastScreenChangeAt) : 0;
894
712
  const recentlyOutput = this.lastNonEmptyOutputAt ? (Date.now() - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
895
- const status = this.runDetectStatus(this.recentOutputBuffer) || this.currentStatus;
713
+ const status = this.runDetectStatus(this.recentOutputBuffer) || this.engine.currentStatus;
896
714
  const interactiveReady = status === 'idle'
897
715
  && stableMs >= 700
898
716
  && recentlyOutput >= 350;
@@ -926,637 +744,84 @@ export class ProviderCliAdapter implements CliAdapter {
926
744
 
927
745
  private clearAllTimers(): void {
928
746
  if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
929
- if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
930
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
931
747
  if (this.submitRetryTimer) { clearTimeout(this.submitRetryTimer); this.submitRetryTimer = null; }
932
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
933
- if (this.settleTimer) { clearTimeout(this.settleTimer); this.settleTimer = null; }
934
- if (this.pendingScriptStatusTimer) { clearTimeout(this.pendingScriptStatusTimer); this.pendingScriptStatusTimer = null; }
935
748
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
936
749
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
750
+ this.engine.clearAllTimers();
937
751
  }
938
752
 
939
- private clearStaleIdleResponseGuard(reason: string): boolean {
940
- const blockingModal = this.activeModal || this.runParseApproval(this.recentOutputBuffer);
941
- const isIdle = this.runDetectStatus(this.recentOutputBuffer) === 'idle';
942
- if (!this.isWaitingForResponse || this.currentStatus !== 'idle' || !isIdle || !!blockingModal) {
943
- return false;
944
- }
945
- this.clearAllTimers();
946
- this.clearIdleFinishCandidate(reason);
947
- this.responseBuffer = '';
948
- this.isWaitingForResponse = false;
949
- this.responseSettleIgnoreUntil = 0;
950
- this.submitRetryUsed = false;
951
- this.submitRetryPromptSnippet = '';
952
- this.finishRetryCount = 0;
953
- this.currentTurnScope = null;
954
- this.activeModal = null;
955
- this.recordTrace('stale_idle_response_cleared', { reason });
956
- return true;
957
- }
958
-
959
- private hasMeaningfulResponseBuffer(promptSnippet: string): boolean {
960
- const raw = String(this.responseBuffer || '').trim();
961
- if (!raw) return false;
962
- const normalizedPrompt = compactPromptText(promptSnippet);
963
- if (!normalizedPrompt) return true;
964
- const normalizedBuffer = compactPromptText(raw);
965
- if (!normalizedBuffer) return false;
966
- if (normalizedBuffer === normalizedPrompt) return false;
967
- if (normalizedBuffer.startsWith(normalizedPrompt)) {
968
- const remainder = normalizedBuffer
969
- .slice(normalizedPrompt.length)
970
- .replace(/[─═\-]+/g, '')
971
- .replace(/⏵⏵accepteditson\([^)]*\)/gi, '')
972
- .replace(/accepteditson\([^)]*\)/gi, '')
973
- .replace(/(?:◐|◑|◒|◓|◔|◕|◉|●|·)?(?:x?high|medium|low|max)·?\/effort/gi, '')
974
- .replace(/updateavailable!run:[a-z0-9:._\-/]+/gi, '')
975
- .replace(/esctointerrupt/gi, '')
976
- .replace(/❯/g, '')
977
- .replace(/^[\s\-–—:;,.!/?]+/, '')
978
- .trim();
979
- return remainder.length > 0;
980
- }
981
- return true;
982
- }
983
-
984
- private evaluateSettled(): void {
985
- const now = Date.now();
986
- if (this.submitPendingUntil > now || this.responseSettleIgnoreUntil > now) {
987
- const delayTime = Math.max(this.submitPendingUntil - now, this.responseSettleIgnoreUntil - now) + 50;
988
- if (this.settleTimer) clearTimeout(this.settleTimer);
989
- this.settleTimer = setTimeout(() => {
990
- this.settleTimer = null;
991
- this.settledBuffer = this.recentOutputBuffer;
992
- this.evaluateSettled();
993
- }, delayTime);
994
- return;
995
- }
996
-
997
- this.resolveStartupState('settled');
998
- if (this.startupParseGate) return;
753
+ // ─── Script dispatch — builds inputs for CliScriptRunner ──────────────────
999
754
 
1000
- if (!this.isWaitingForResponse && !this.currentTurnScope && !this.activeModal && !this.parseErrorMessage) {
1001
- const tail = this.settledBuffer || this.recentOutputBuffer;
1002
- const modal = this.runParseApproval(tail);
1003
- const lightweightStatus = this.cliScripts?.detectStatus
1004
- ? this.runDetectStatus(tail)
1005
- : null;
1006
- if (!modal && lightweightStatus === 'idle' && this.currentStatus === 'idle') {
1007
- return;
1008
- }
1009
- }
1010
-
1011
- const session = this.runParseSession();
1012
- if (!session) return;
1013
-
1014
- const { status, messages, parsedStatus } = session;
1015
- const modal = (session as any).activeModal ?? session.modal ?? null;
1016
- const parsedMessages = normalizeCliParsedMessages(messages, {
1017
- scope: null,
1018
- lastOutputAt: this.lastOutputAt,
1019
- });
1020
-
1021
- if (this.maybeCommitVisibleIdleTranscript(session, parsedMessages)) return;
1022
-
1023
- const lastParsedAssistant = [...parsedMessages].reverse().find((m) => m.role === 'assistant');
1024
- const normalizedPromptSnippet = normalizePromptText(this.submitRetryPromptSnippet || this.currentTurnScope?.prompt || '');
1025
- const screenText = this.terminalScreen.getText() || '';
1026
-
1027
- this.recordTrace('settled', {
1028
- tail: summarizeCliTraceText(this.settledBuffer, 500),
1029
- screenText: summarizeCliTraceText(screenText, 1200),
1030
- detectStatus: status,
1031
- parsedStatus: parsedStatus || null,
1032
- parsedMessageCount: parsedMessages.length,
1033
- parsedLastAssistant: lastParsedAssistant ? summarizeCliTraceText(lastParsedAssistant.content, 280) : '',
1034
- parsedActiveModal: modal,
1035
- approval: modal,
1036
- ...buildCliTraceParseSnapshot({
1037
- accumulatedBuffer: this.accumulatedBuffer,
1038
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1039
- responseBuffer: this.responseBuffer,
1040
- partialResponse: this.responseBuffer,
1041
- scope: this.currentTurnScope,
1042
- }),
755
+ runParseSession(): ParsedSession | null {
756
+ const screenText = this.terminalScreen.getText();
757
+ const parseScreenText = this.getParseScreenText(screenText);
758
+ const tail = this.recentOutputBuffer.slice(-500);
759
+ const input = buildCliParseInput({
760
+ accumulatedBuffer: this.accumulatedBuffer,
761
+ accumulatedRawBuffer: this.accumulatedRawBuffer,
762
+ recentOutputBuffer: this.recentOutputBuffer,
763
+ terminalScreenText: parseScreenText,
764
+ workingDir: this.workingDir,
765
+ providerSessionId: this.providerSessionId || undefined,
766
+ historySessionId: this.providerSessionId || undefined,
767
+ baseMessages: [],
768
+ partialResponse: this.responseBuffer,
769
+ isWaitingForResponse: this.engine.isWaitingForResponse,
770
+ scope: this.engine.currentTurnScope,
771
+ runtimeSettings: this.runtimeSettings,
772
+ spawnAt: this.spawnAt,
1043
773
  });
1044
-
1045
- if (
1046
- this.currentTurnScope
1047
- && !lastParsedAssistant
1048
- && !this.submitRetryUsed
1049
- && this.ptyProcess
1050
- && !this.hasActionableApproval()
1051
- && promptLikelyVisible(screenText, normalizedPromptSnippet)
1052
- && !this.hasMeaningfulResponseBuffer(normalizedPromptSnippet)
1053
- ) {
1054
- this.submitRetryUsed = true;
1055
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1056
- LOG.info('CLI', `[${this.cliType}] Retrying submit key from settled parser (no assistant yet)`);
1057
- this.recordTrace('submit_write', {
1058
- mode: 'settled_retry',
1059
- sendKey: this.sendKey,
1060
- screenText: summarizeCliTraceText(screenText, 500),
1061
- });
1062
- this.ptyProcess.write(this.sendKey);
1063
- if (this.settleTimer) clearTimeout(this.settleTimer);
1064
- this.settleTimer = setTimeout(() => {
1065
- this.settleTimer = null;
1066
- this.settledBuffer = this.recentOutputBuffer;
1067
- this.evaluateSettled();
1068
- }, this.timeouts.outputSettle + 150);
1069
- return;
1070
- }
1071
-
1072
- if (this.currentTurnScope && !lastParsedAssistant) {
1073
- LOG.debug(
1074
- 'CLI',
1075
- `[${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 || '-'}`
1076
- );
1077
- }
1078
-
1079
- if (!status) return;
1080
-
1081
- const prevStatus = this.currentStatus;
1082
- const ctx: SettledEvalContext = { now, modal, status, parsedMessages, lastParsedAssistant, parsedStatus: parsedStatus || null, prevStatus };
1083
-
1084
- if (!this.applyPendingScriptStatusDebounce(ctx)) return;
1085
-
1086
- const recentInteractiveActivity = this.hasRecentInteractiveActivity(now);
1087
- LOG.debug(
1088
- 'CLI',
1089
- `[${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)}`
1090
- );
1091
-
1092
- const shouldHoldGenerating =
1093
- status === 'idle'
1094
- && this.isWaitingForResponse
1095
- && !modal
1096
- && recentInteractiveActivity
1097
- && !(parsedStatus === 'idle' && !!lastParsedAssistant);
1098
-
1099
- if (shouldHoldGenerating) { this.applyHoldGenerating(ctx, recentInteractiveActivity); return; }
1100
- if (status === 'waiting_approval') { this.applyWaitingApproval(ctx); return; }
1101
- if (status === 'generating') { this.applyGenerating(ctx); return; }
1102
- if (status === 'idle') { this.applyIdle(ctx, now); }
1103
- }
1104
-
1105
- // Returns false if the caller should bail out (debounce pending).
1106
- private applyPendingScriptStatusDebounce(ctx: SettledEvalContext): boolean {
1107
- const { now, status, prevStatus } = ctx;
1108
- const shouldDebounce =
1109
- prevStatus === 'idle'
1110
- && !this.isWaitingForResponse
1111
- && !this.currentTurnScope
1112
- && (status === 'generating' || status === 'waiting_approval');
1113
-
1114
- if (!shouldDebounce) {
1115
- this.pendingScriptStatus = null;
1116
- this.pendingScriptStatusSince = 0;
1117
- if (this.pendingScriptStatusTimer) { clearTimeout(this.pendingScriptStatusTimer); this.pendingScriptStatusTimer = null; }
1118
- return true;
1119
- }
1120
-
1121
- const armPending = (delayMs: number) => {
1122
- if (this.pendingScriptStatusTimer) clearTimeout(this.pendingScriptStatusTimer);
1123
- this.pendingScriptStatusTimer = setTimeout(() => {
1124
- this.pendingScriptStatusTimer = null;
1125
- this.settledBuffer = this.recentOutputBuffer;
1126
- this.evaluateSettled();
1127
- }, delayMs);
1128
- };
1129
-
1130
- if (this.pendingScriptStatus !== status) {
1131
- this.pendingScriptStatus = status as 'generating' | 'waiting_approval';
1132
- this.pendingScriptStatusSince = now;
1133
- armPending(ProviderCliAdapter.SCRIPT_STATUS_DEBOUNCE_MS);
1134
- return false;
1135
- }
1136
- const elapsed = now - this.pendingScriptStatusSince;
1137
- if (elapsed < ProviderCliAdapter.SCRIPT_STATUS_DEBOUNCE_MS) {
1138
- armPending(ProviderCliAdapter.SCRIPT_STATUS_DEBOUNCE_MS - elapsed);
1139
- return false;
1140
- }
1141
- return true;
1142
- }
1143
-
1144
- private applyHoldGenerating(ctx: SettledEvalContext, recentInteractiveActivity: boolean): void {
1145
- const { status } = ctx;
1146
- this.clearIdleFinishCandidate('hold_generating_recent_activity');
1147
- this.setStatus('generating', 'recent_activity_hold');
1148
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1149
- this.idleTimeout = setTimeout(() => {
1150
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1151
- if (this.shouldDeferIdleTimeoutFinish()) return;
1152
- this.finishResponse();
1153
- }
1154
- }, this.timeouts.generatingIdle);
1155
- this.recordTrace('hold_generating_recent_activity', {
1156
- scriptStatus: status,
1157
- recentInteractiveActivity,
1158
- lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
1159
- lastScreenChangeAt: this.lastScreenChangeAt,
1160
- holdMs: this.getStatusActivityHoldMs(),
1161
- ...buildCliTraceParseSnapshot({
1162
- accumulatedBuffer: this.accumulatedBuffer,
1163
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1164
- responseBuffer: this.responseBuffer,
1165
- partialResponse: this.responseBuffer,
1166
- scope: this.currentTurnScope,
1167
- }),
774
+ const session = this.runner.parseSession({
775
+ ...input,
776
+ tail,
777
+ tailScreen: buildCliScreenSnapshot(tail),
1168
778
  });
1169
- this.onStatusChange?.();
779
+ if (session && typeof session === 'object') this.applyParsedSessionMetadata(session);
780
+ return session;
1170
781
  }
1171
782
 
1172
- private applyWaitingApproval(ctx: SettledEvalContext): void {
1173
- const { modal } = ctx;
1174
- this.clearIdleFinishCandidate('waiting_approval');
1175
- const inCooldown = this.lastApprovalResolvedAt && (Date.now() - this.lastApprovalResolvedAt) < this.timeouts.approvalCooldown;
1176
- if (inCooldown && !modal) {
1177
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1178
- this.activeModal = null;
1179
- if (this.isWaitingForResponse) {
1180
- this.setStatus('idle', inCooldown ? 'approval_cooldown_non_actionable' : 'approval_prompt_gone_non_actionable');
1181
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1182
- this.idleTimeout = setTimeout(() => {
1183
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1184
- if (this.shouldDeferIdleTimeoutFinish()) return;
1185
- this.finishResponse();
1186
- }
1187
- }, this.timeouts.generatingIdle);
1188
- } else {
1189
- this.setStatus('idle', inCooldown ? 'approval_cooldown_non_actionable' : 'approval_prompt_gone_non_actionable');
1190
- }
1191
- this.onStatusChange?.();
1192
- return;
1193
- }
1194
- if (!inCooldown) {
1195
- if (!modal) {
1196
- LOG.warn('CLI', `[${this.cliType}] detectStatus reported waiting_approval without parseApproval modal; ignoring non-actionable approval state`);
1197
- return;
1198
- }
1199
- this.isWaitingForResponse = true;
1200
- this.setStatus('waiting_approval', 'script_detect');
1201
- this.activeModal = modal;
1202
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1203
- this.armApprovalExitTimeout();
1204
- this.onStatusChange?.();
1205
- }
1206
- }
1207
-
1208
- private applyGenerating(ctx: SettledEvalContext): void {
1209
- const { modal, parsedMessages, lastParsedAssistant, parsedStatus, prevStatus } = ctx;
1210
- this.clearIdleFinishCandidate('generating');
1211
- const screenText = this.terminalScreen.getText() || '';
1212
- const effectiveScreenText = screenText || this.accumulatedBuffer;
1213
- const noActiveTurn = !this.currentTurnScope;
1214
- const looksIdleChrome = /(^|\n)\s*[❯›>]\s*(?:\n|$)/m.test(effectiveScreenText);
1215
- const parsedShowsLiveAssistantProgress = parsedStatus === 'generating'
1216
- && !!lastParsedAssistant
1217
- ;
1218
- if (prevStatus === 'idle' && !this.isWaitingForResponse && noActiveTurn && !modal && looksIdleChrome && !parsedShowsLiveAssistantProgress) {
1219
- return;
1220
- }
1221
- if (prevStatus === 'waiting_approval') {
1222
- // Transitioned out of approval → generating
1223
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1224
- this.activeModal = null;
1225
- this.lastApprovalResolvedAt = Date.now();
1226
- }
1227
- if (!this.isWaitingForResponse) {
1228
- this.isWaitingForResponse = true;
1229
- this.responseBuffer = '';
1230
- }
1231
- this.setStatus('generating', 'script_detect');
1232
- // Reset idle timeout
1233
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1234
- this.idleTimeout = setTimeout(() => {
1235
- if (this.isWaitingForResponse) {
1236
- if (this.shouldDeferIdleTimeoutFinish()) return;
1237
- this.finishResponse();
1238
- }
1239
- }, this.timeouts.generatingIdle);
1240
- this.onStatusChange?.();
1241
- }
1242
-
1243
- private applyIdle(ctx: SettledEvalContext, now: number): void {
1244
- const { modal, lastParsedAssistant, prevStatus } = ctx;
1245
- if (prevStatus === 'waiting_approval') {
1246
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1247
- this.activeModal = null;
1248
- this.lastApprovalResolvedAt = Date.now();
1249
- this.setStatus('idle', 'approval_prompt_gone_script_idle');
1250
- }
1251
- if (!this.isWaitingForResponse) {
1252
- if (prevStatus !== 'idle') {
1253
- this.clearIdleFinishCandidate('idle_without_response');
1254
- this.setStatus('idle', 'script_detect');
1255
- this.onStatusChange?.();
1256
- }
1257
- return;
1258
- }
1259
- const quietForMs = this.lastNonEmptyOutputAt ? (now - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
1260
- const screenStableMs = this.lastScreenChangeAt ? (now - this.lastScreenChangeAt) : 0;
1261
- const hasAssistantTurn = !!lastParsedAssistant;
1262
- const assistantLength = lastParsedAssistant?.content?.length || 0;
1263
- const idleFinishConfirmMs = this.getIdleFinishConfirmMs();
1264
- const idleQuietThresholdMs = Math.max(idleFinishConfirmMs, this.timeouts.outputSettle);
1265
- const idleReady = !modal
1266
- && hasAssistantTurn
1267
- && quietForMs >= idleQuietThresholdMs
1268
- && screenStableMs >= idleFinishConfirmMs;
1269
- const candidate = this.idleFinishCandidate;
1270
- const candidateQuiet = !!candidate
1271
- && candidate.responseEpoch === this.responseEpoch
1272
- && candidate.lastOutputAt === this.lastOutputAt
1273
- && candidate.lastScreenChangeAt === this.lastScreenChangeAt
1274
- && assistantLength >= candidate.assistantLength
1275
- && (now - candidate.armedAt) >= idleFinishConfirmMs;
1276
-
1277
- this.recordTrace('idle_decision', {
1278
- quietForMs,
1279
- screenStableMs,
1280
- hasAssistantTurn,
1281
- assistantLength,
1282
- hasModal: !!modal,
1283
- idleQuietThresholdMs,
1284
- idleStableThresholdMs: idleFinishConfirmMs,
1285
- idleReady,
1286
- idleFinishConfirmMs,
1287
- idleFinishCandidate: candidate,
1288
- candidateQuiet,
1289
- canFinishImmediately: idleReady && candidateQuiet,
1290
- submitPendingUntil: this.submitPendingUntil,
1291
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
1292
- ...buildCliTraceParseSnapshot({
1293
- accumulatedBuffer: this.accumulatedBuffer,
1294
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1295
- responseBuffer: this.responseBuffer,
1296
- partialResponse: this.responseBuffer,
1297
- scope: this.currentTurnScope,
1298
- }),
1299
- });
1300
-
1301
- if (idleReady && candidateQuiet) {
1302
- this.clearIdleFinishCandidate('finish_response');
1303
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1304
- this.finishResponse();
1305
- return;
1306
- }
1307
-
1308
- if (idleReady) {
1309
- if (!candidate) {
1310
- this.armIdleFinishCandidate(assistantLength);
1311
- return;
1312
- }
1313
- } else {
1314
- this.clearIdleFinishCandidate('idle_not_ready');
1315
- }
1316
-
1317
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1318
- this.idleTimeout = setTimeout(() => {
1319
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1320
- if (this.shouldDeferIdleTimeoutFinish()) return;
1321
- this.clearIdleFinishCandidate('idle_timeout_finish');
1322
- this.finishResponse();
1323
- }
1324
- }, this.timeouts.idleFinish);
1325
- }
1326
-
1327
- private finishResponse(): void {
1328
- if (this.submitPendingUntil > Date.now()) return;
1329
- if (this.responseSettleIgnoreUntil > Date.now()) return;
1330
- this.clearIdleFinishCandidate('finish_response_enter');
1331
- this.recordTrace('finish_response', {
1332
- ...buildCliTraceParseSnapshot({
1333
- accumulatedBuffer: this.accumulatedBuffer,
1334
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1335
- responseBuffer: this.responseBuffer,
1336
- partialResponse: this.responseBuffer,
1337
- scope: this.currentTurnScope,
1338
- }),
783
+ runDetectStatus(text: string): string | null {
784
+ const screenText = this.terminalScreen.getText();
785
+ const tail = text.slice(-500);
786
+ return this.runner.detectStatus({
787
+ tail,
788
+ screenText,
789
+ rawBuffer: this.accumulatedRawBuffer,
790
+ isWaitingForResponse: this.engine.isWaitingForResponse,
791
+ screen: buildCliScreenSnapshot(screenText),
792
+ tailScreen: buildCliScreenSnapshot(tail),
1339
793
  });
1340
- const commitResult = this.commitCurrentTranscript();
1341
- if (this.shouldRetryFinishResponse(commitResult)) {
1342
- this.finishRetryCount += 1;
1343
- this.recordTrace('finish_response_retry', {
1344
- retryCount: this.finishRetryCount,
1345
- retryDelayMs: ProviderCliAdapter.FINISH_RETRY_DELAY_MS,
1346
- assistantContent: summarizeCliTraceText(commitResult.assistantContent, 220),
1347
- ...buildCliTraceParseSnapshot({
1348
- accumulatedBuffer: this.accumulatedBuffer,
1349
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1350
- responseBuffer: this.responseBuffer,
1351
- partialResponse: this.responseBuffer,
1352
- scope: this.currentTurnScope,
1353
- }),
1354
- });
1355
- if (this.finishRetryTimer) clearTimeout(this.finishRetryTimer);
1356
- this.finishRetryTimer = setTimeout(() => {
1357
- this.finishRetryTimer = null;
1358
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1359
- this.finishResponse();
1360
- }
1361
- }, ProviderCliAdapter.FINISH_RETRY_DELAY_MS);
1362
- return;
1363
- }
1364
- this.clearAllTimers();
1365
- this.responseBuffer = '';
1366
- this.isWaitingForResponse = false;
1367
- this.responseSettleIgnoreUntil = 0;
1368
- this.submitRetryUsed = false;
1369
- this.submitRetryPromptSnippet = '';
1370
- this.finishRetryCount = 0;
1371
- this.currentTurnScope = null;
1372
- this.activeModal = null;
1373
- this.setStatus('idle', 'response_finished');
1374
- this.onStatusChange?.();
1375
794
  }
1376
795
 
1377
- private maybeCommitVisibleIdleTranscript(session: ParsedSession, parsedMessages: CliChatMessage[]): boolean {
1378
- const allowImmediateScriptIdleCommit = this.provider.allowInputDuringGeneration === true;
1379
- if (!allowImmediateScriptIdleCommit) return false;
1380
- if (
1381
- !session
1382
- || session.status !== 'idle'
1383
- || !this.isWaitingForResponse
1384
- || !this.currentTurnScope
1385
- || this.activeModal
1386
- || session.modal
1387
- ) {
1388
- return false;
1389
- }
1390
-
1391
- const visibleAssistant = [...parsedMessages].reverse().find((m) => m.role === 'assistant' && m.content.trim());
1392
- if (!visibleAssistant) return false;
1393
-
1394
- this.clearAllTimers();
1395
- this.responseBuffer = '';
1396
- this.isWaitingForResponse = false;
1397
- this.responseSettleIgnoreUntil = 0;
1398
- this.submitRetryUsed = false;
1399
- this.submitRetryPromptSnippet = '';
1400
- this.finishRetryCount = 0;
1401
- this.currentTurnScope = null;
1402
- this.activeModal = null;
1403
- this.setStatus('idle', 'script_idle_commit');
1404
- this.onStatusChange?.();
1405
- this.recordTrace('script_idle_commit', {
1406
- messageCount: parsedMessages.length,
1407
- lastAssistant: summarizeCliTraceText(visibleAssistant.content, 320),
796
+ runParseApproval(tail: string): { message: string; buttons: string[] } | null {
797
+ const screenText = this.terminalScreen.getText();
798
+ const buffer = screenText || this.accumulatedBuffer;
799
+ return this.runner.parseApproval({
800
+ buffer,
801
+ screenText,
802
+ rawBuffer: this.accumulatedRawBuffer,
803
+ tail,
804
+ screen: buildCliScreenSnapshot(screenText),
805
+ bufferScreen: buildCliScreenSnapshot(buffer),
806
+ tailScreen: buildCliScreenSnapshot(tail),
1408
807
  });
1409
- return true;
1410
- }
1411
-
1412
- private commitCurrentTranscript(): { hasAssistant: boolean; assistantContent: string } {
1413
- const parsed = this.parseCurrentTranscript(
1414
- [],
1415
- this.responseBuffer,
1416
- this.currentTurnScope,
1417
- );
1418
- if (parsed && Array.isArray(parsed.messages)) {
1419
- const parsedMessages = normalizeCliParsedMessages(parsed.messages, {
1420
- scope: null,
1421
- lastOutputAt: this.lastOutputAt,
1422
- });
1423
- const lastAssistant = [...parsedMessages].reverse().find((message) => message.role === 'assistant');
1424
- if (this.currentTurnScope) {
1425
- LOG.info(
1426
- 'CLI',
1427
- `[${this.cliType}] commitCurrentTranscript parserMessages=${parsedMessages.length} finalLastAssistant=${JSON.stringify(summarizeCliTraceText(lastAssistant?.content || '', 220)).slice(0, 260)}`
1428
- );
1429
- }
1430
- this.recordTrace('commit_transcript', {
1431
- parsedStatus: parsed.status || null,
1432
- messageCount: parsedMessages.length,
1433
- lastAssistant: lastAssistant ? summarizeCliTraceText(lastAssistant.content, 320) : '',
1434
- messages: summarizeCliTraceMessages(parsedMessages),
1435
- ...buildCliTraceParseSnapshot({
1436
- accumulatedBuffer: this.accumulatedBuffer,
1437
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1438
- responseBuffer: this.responseBuffer,
1439
- partialResponse: this.responseBuffer,
1440
- scope: this.currentTurnScope,
1441
- }),
1442
- });
1443
- if (!lastAssistant && this.currentTurnScope) {
1444
- LOG.warn(
1445
- 'CLI',
1446
- `[${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 || '-'}`
1447
- );
1448
- }
1449
- const hasAssistant = !!lastAssistant;
1450
- return {
1451
- hasAssistant,
1452
- assistantContent: lastAssistant?.content || '',
1453
- };
1454
- }
1455
- if (this.currentTurnScope) {
1456
- LOG.info(
1457
- 'CLI',
1458
- `[${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 || '-'}`
1459
- );
1460
- }
1461
- return {
1462
- hasAssistant: false,
1463
- assistantContent: '',
1464
- };
1465
- }
1466
-
1467
-
1468
- // ─── Script Execution ──────────────────────────
1469
-
1470
- private invokeCliScript<T>(script: Function, input: any): T {
1471
- const hasStateFactory = typeof this.cliScripts?.createState === 'function';
1472
- const expectsStateArgument = hasStateFactory || this.scriptState !== null || script.length >= 2;
1473
- return expectsStateArgument
1474
- ? script(this.scriptState, input)
1475
- : script(input);
1476
- }
1477
-
1478
- private runParseSession(): ParsedSession | null {
1479
- if (typeof this.cliScripts?.parseSession !== 'function') {
1480
- this.parseErrorMessage = `${this.cliType} parseSession unavailable`;
1481
- return null;
1482
- }
1483
- try {
1484
- const screenText = this.terminalScreen.getText();
1485
- const parseScreenText = this.getParseScreenText(screenText);
1486
- const tail = this.recentOutputBuffer.slice(-500);
1487
- const input = buildCliParseInput({
1488
- accumulatedBuffer: this.accumulatedBuffer,
1489
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1490
- recentOutputBuffer: this.recentOutputBuffer,
1491
- terminalScreenText: parseScreenText,
1492
- baseMessages: [],
1493
- partialResponse: this.responseBuffer,
1494
- isWaitingForResponse: this.isWaitingForResponse,
1495
- scope: this.currentTurnScope,
1496
- runtimeSettings: this.runtimeSettings,
1497
- });
1498
- const session = this.invokeCliScript<ParsedSession | null>(
1499
- this.cliScripts.parseSession,
1500
- { ...input, tail, tailScreen: buildCliScreenSnapshot(tail) },
1501
- );
1502
- this.parseErrorMessage = null;
1503
- return session && typeof session === 'object' ? session : null;
1504
- } catch (e: any) {
1505
- const message = e?.message || String(e);
1506
- this.parseErrorMessage = message;
1507
- LOG.warn('CLI', `[${this.cliType}] parseSession error: ${message}`);
1508
- return null;
1509
- }
1510
808
  }
1511
809
 
1512
- private runDetectStatus(text: string): string | null {
1513
- if (!this.cliScripts?.detectStatus) return null;
1514
- try {
1515
- const screenText = this.terminalScreen.getText();
1516
- const status = this.invokeCliScript<string | null>(this.cliScripts.detectStatus, {
1517
- tail: text.slice(-500),
1518
- screenText,
1519
- rawBuffer: this.accumulatedRawBuffer,
1520
- isWaitingForResponse: this.isWaitingForResponse,
1521
- screen: buildCliScreenSnapshot(screenText),
1522
- tailScreen: buildCliScreenSnapshot(text.slice(-500)),
1523
- });
1524
- return status;
1525
- } catch (e: any) {
1526
- LOG.warn('CLI', `[${this.cliType}] detectStatus error: ${e.message}`);
1527
- return null;
1528
- }
1529
- }
1530
-
1531
- private runParseApproval(tail: string): { message: string; buttons: string[] } | null {
1532
- if (!this.cliScripts?.parseApproval) return null;
1533
- try {
1534
- const screenText = this.terminalScreen.getText();
1535
- const buffer = screenText || this.accumulatedBuffer;
1536
- return this.invokeCliScript<{ message: string; buttons: string[] } | null>(this.cliScripts.parseApproval, {
1537
- buffer,
1538
- screenText,
1539
- rawBuffer: this.accumulatedRawBuffer,
1540
- tail,
1541
- screen: buildCliScreenSnapshot(screenText),
1542
- bufferScreen: buildCliScreenSnapshot(buffer),
1543
- tailScreen: buildCliScreenSnapshot(tail),
1544
- });
1545
- } catch (e: any) {
1546
- LOG.warn('CLI', `[${this.cliType}] parseApproval error: ${e.message}`);
1547
- return null;
810
+ private applyParsedSessionMetadata(parsed: any): void {
811
+ const providerSessionId = typeof parsed?.providerSessionId === 'string' && parsed.providerSessionId.trim()
812
+ ? parsed.providerSessionId.trim()
813
+ : '';
814
+ if (providerSessionId && providerSessionId !== this.providerSessionId) {
815
+ this.providerSessionId = providerSessionId;
816
+ this.updateRuntimeMeta({ providerSessionId });
1548
817
  }
1549
818
  }
1550
819
 
1551
- private hasActionableApproval(startupModal: { message: string; buttons: string[] } | null = null): boolean {
1552
- return !!(startupModal || this.activeModal);
1553
- }
1554
-
1555
820
  private projectEffectiveStatus(startupModal: { message: string; buttons: string[] } | null = null): CliSessionStatus['status'] {
1556
821
  if (this.parseErrorMessage) return 'error';
1557
- if (this.hasActionableApproval(startupModal)) return 'waiting_approval';
1558
- if (this.isWaitingForResponse && this.currentTurnScope && this.currentStatus !== 'stopped') return 'generating';
1559
- return this.currentStatus;
822
+ if (!!(startupModal || this.engine.activeModal)) return 'waiting_approval';
823
+ if (this.engine.isWaitingForResponse && this.engine.currentTurnScope && this.engine.currentStatus !== 'stopped') return 'generating';
824
+ return this.engine.currentStatus;
1560
825
  }
1561
826
 
1562
827
  // ─── Public API (CliAdapter) ───────────────────
@@ -1564,17 +829,73 @@ export class ProviderCliAdapter implements CliAdapter {
1564
829
  getStatus(options: { allowParse?: boolean } = {}): CliSessionStatus {
1565
830
  const allowParse = options.allowParse !== false;
1566
831
  const startupModal = allowParse && this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
832
+ const startupDetectedStatus = allowParse && this.startupParseGate && !startupModal
833
+ ? this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText())
834
+ : null;
1567
835
  let effectiveStatus = this.projectEffectiveStatus(startupModal);
1568
- let effectiveModal = startupModal || this.activeModal;
836
+ let effectiveModal = startupModal || this.engine.activeModal;
837
+ // (fix) When we have no captured modal yet, take one more live attempt
838
+ // with the current screen text — the engine's settle pass can miss
839
+ // the modal when it happens to fire exactly between writes, and
840
+ // without a modal here the dashboard could never show the buttons.
841
+ // We deliberately do NOT overwrite an existing engine.activeModal so
842
+ // a stable matched modal wins. This runs even outside the startup
843
+ // gate because Claude's approval frames can appear long after launch.
844
+ if (allowParse && !effectiveModal && this.engine.isWaitingForResponse) {
845
+ const liveDetect = this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText());
846
+ if (liveDetect === 'waiting_approval') {
847
+ const liveModal = this.runParseApproval(this.terminalScreen.getText())
848
+ || this.runParseApproval(this.recentOutputBuffer);
849
+ // (fix) Only surface modals that have at least one non-empty
850
+ // button. Half-rendered approval frames produced
851
+ // { message, buttons: [] } briefly — auto-approve would then
852
+ // pick buttonIndex=-1 and resolveModal would write the
853
+ // provider's index-0 key into the prompt. Skip those frames
854
+ // and let a later evaluate find the complete modal.
855
+ const buttonsOk = liveModal && Array.isArray(liveModal.buttons)
856
+ && liveModal.buttons.some((b: any) => typeof b === 'string' && b.trim());
857
+ if (liveModal && buttonsOk) {
858
+ effectiveModal = liveModal;
859
+ if (!this.engine.activeModal) this.engine.activeModal = liveModal;
860
+ }
861
+ }
862
+ }
863
+ // Only surface waiting_approval when we ALSO have a concrete modal
864
+ // (message + buttons). detectStatus alone can fire while parseApproval
865
+ // is still null — the engine logs "detectStatus=waiting_approval but
866
+ // parseApproval returned null; ignoring". Without this guard getStatus
867
+ // was shipping a bare waiting_approval with activeModal=null and the
868
+ // user perceived the flow as broken.
869
+ if (startupDetectedStatus === 'waiting_approval' && effectiveModal) {
870
+ effectiveStatus = 'waiting_approval';
871
+ } else if (startupDetectedStatus === 'idle' && !startupModal && !effectiveModal) {
872
+ effectiveStatus = 'idle';
873
+ }
1569
874
  if (allowParse && !startupModal && !effectiveModal) {
1570
875
  const parsed = this.getFreshParsedStatusCache();
1571
876
  const parsedModal = parsed?.activeModal && Array.isArray(parsed.activeModal.buttons)
1572
877
  && parsed.activeModal.buttons.some((button: any) => typeof button === 'string' && button.trim())
1573
878
  ? parsed.activeModal
1574
879
  : null;
880
+ const hasFinalAssistant = (p: any) => {
881
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
882
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
883
+ };
1575
884
  if (parsed?.status === 'waiting_approval' && parsedModal) {
1576
885
  effectiveStatus = 'waiting_approval';
1577
886
  effectiveModal = parsedModal;
887
+ } else if (
888
+ effectiveStatus === 'idle'
889
+ && parsed?.status === 'generating'
890
+ && !hasFinalAssistant(parsed)
891
+ ) {
892
+ effectiveStatus = 'generating';
893
+ } else if (
894
+ effectiveStatus === 'generating'
895
+ && parsed?.status === 'idle'
896
+ && hasFinalAssistant(parsed)
897
+ ) {
898
+ effectiveStatus = 'idle';
1578
899
  }
1579
900
  }
1580
901
  const bufferState = this.getBufferState();
@@ -1583,8 +904,17 @@ export class ProviderCliAdapter implements CliAdapter {
1583
904
  messages: [],
1584
905
  workingDir: this.workingDir,
1585
906
  activeModal: effectiveModal,
1586
- errorMessage: this.parseErrorMessage || undefined,
1587
- errorReason: this.parseErrorMessage ? 'parse_error' : undefined,
907
+ pendingOutboundCount: this.pendingOutboundQueue.length,
908
+ pendingOutboundMessages: this.pendingOutboundQueue.map((message) => ({
909
+ id: message.id,
910
+ role: message.role,
911
+ content: message.content,
912
+ queuedAt: message.queuedAt,
913
+ source: message.source,
914
+ })),
915
+ errorMessage: this.parseErrorMessage || this.engine.providerErrorMessage || undefined,
916
+ errorReason: this.parseErrorMessage ? 'parse_error' : (this.engine.providerErrorReason || undefined),
917
+ providerSessionId: this.providerSessionId || undefined,
1588
918
  ...(bufferState ? { bufferState } : {}),
1589
919
  };
1590
920
  }
@@ -1600,15 +930,16 @@ export class ProviderCliAdapter implements CliAdapter {
1600
930
  const cached = this.parsedStatusCache;
1601
931
  const accumulatedRawBufferKey = this.getAccumulatedRawBufferCacheKey();
1602
932
  if (
1603
- cached
933
+ !this.providerOwnsTranscript()
934
+ && cached
1604
935
  && cached.responseBuffer === this.responseBuffer
1605
- && cached.currentTurnScope === this.currentTurnScope
936
+ && cached.currentTurnScope === this.engine.currentTurnScope
1606
937
  && cached.recentOutputBuffer === this.recentOutputBuffer
1607
938
  && cached.accumulatedBuffer === this.accumulatedBuffer
1608
939
  && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
1609
940
  && cached.screenText === parseScreenText
1610
- && cached.currentStatus === this.currentStatus
1611
- && cached.activeModal === this.activeModal
941
+ && cached.currentStatus === this.engine.currentStatus
942
+ && cached.activeModal === this.engine.activeModal
1612
943
  && cached.cliName === this.cliName
1613
944
  ) {
1614
945
  return cached.result;
@@ -1623,14 +954,20 @@ export class ProviderCliAdapter implements CliAdapter {
1623
954
  const bufferState = this.getBufferState();
1624
955
  const result = {
1625
956
  id: (parsed as any).id || 'cli_session',
1626
- status: parsed.status || this.currentStatus,
957
+ status: parsed.status || this.engine.currentStatus,
1627
958
  title: (parsed as any).title || this.cliName,
1628
959
  messages: normalizeCliParsedMessages(parsed.messages, {
1629
960
  scope: null,
1630
961
  lastOutputAt: this.lastOutputAt,
1631
962
  }),
1632
963
  activeModal,
1633
- providerSessionId: typeof (parsed as any).providerSessionId === 'string' ? (parsed as any).providerSessionId : undefined,
964
+ providerSessionId: this.providerSessionId || (typeof (parsed as any).providerSessionId === 'string' ? (parsed as any).providerSessionId : undefined),
965
+ errorMessage: typeof (parsed as any).errorMessage === 'string' && (parsed as any).errorMessage.trim()
966
+ ? (parsed as any).errorMessage.trim()
967
+ : undefined,
968
+ errorReason: typeof (parsed as any).errorReason === 'string' && (parsed as any).errorReason.trim()
969
+ ? (parsed as any).errorReason.trim()
970
+ : undefined,
1634
971
  ...(bufferState ? { bufferState } : {}),
1635
972
  ...((parsed as any).transcriptAuthority === 'provider' || (parsed as any).transcriptAuthority === 'daemon'
1636
973
  ? { transcriptAuthority: (parsed as any).transcriptAuthority }
@@ -1642,13 +979,13 @@ export class ProviderCliAdapter implements CliAdapter {
1642
979
 
1643
980
  this.parsedStatusCache = {
1644
981
  responseBuffer: this.responseBuffer,
1645
- currentTurnScope: this.currentTurnScope,
982
+ currentTurnScope: this.engine.currentTurnScope,
1646
983
  recentOutputBuffer: this.recentOutputBuffer,
1647
984
  accumulatedBuffer: this.accumulatedBuffer,
1648
985
  accumulatedRawBufferKey,
1649
986
  screenText: parseScreenText,
1650
- currentStatus: this.currentStatus,
1651
- activeModal: this.activeModal,
987
+ currentStatus: this.engine.currentStatus,
988
+ activeModal: this.engine.activeModal,
1652
989
  cliName: this.cliName,
1653
990
  result,
1654
991
  };
@@ -1656,22 +993,22 @@ export class ProviderCliAdapter implements CliAdapter {
1656
993
  }
1657
994
 
1658
995
  async invokeScript(scriptName: string, args?: Record<string, any>): Promise<any> {
1659
- const fn = this.cliScripts?.[scriptName];
1660
- if (typeof fn !== 'function') {
1661
- throw new Error(`CLI script '${scriptName}' not available`);
1662
- }
1663
996
  const input = buildCliParseInput({
1664
997
  accumulatedBuffer: this.accumulatedBuffer,
1665
998
  accumulatedRawBuffer: this.accumulatedRawBuffer,
1666
999
  recentOutputBuffer: this.recentOutputBuffer,
1667
1000
  terminalScreenText: this.getParseScreenText(this.terminalScreen.getText()),
1001
+ workingDir: this.workingDir,
1002
+ providerSessionId: this.providerSessionId || undefined,
1003
+ historySessionId: this.providerSessionId || undefined,
1668
1004
  baseMessages: [],
1669
1005
  partialResponse: this.responseBuffer,
1670
- isWaitingForResponse: this.isWaitingForResponse,
1671
- scope: this.currentTurnScope,
1006
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1007
+ scope: this.engine.currentTurnScope,
1672
1008
  runtimeSettings: this.runtimeSettings,
1009
+ spawnAt: this.spawnAt,
1673
1010
  });
1674
- return await Promise.resolve(this.invokeCliScript(fn, {
1011
+ return await Promise.resolve(this.runner.invokeByName(scriptName, {
1675
1012
  ...input,
1676
1013
  args: args && typeof args === 'object' ? { ...args } : {},
1677
1014
  }));
@@ -1683,7 +1020,7 @@ export class ProviderCliAdapter implements CliAdapter {
1683
1020
 
1684
1021
  /** Whether this adapter has CLI scripts loaded */
1685
1022
  hasCliScripts(): boolean {
1686
- return typeof this.cliScripts?.detectStatus === 'function';
1023
+ return this.runner.hasDetectStatus();
1687
1024
  }
1688
1025
 
1689
1026
  /**
@@ -1692,24 +1029,24 @@ export class ProviderCliAdapter implements CliAdapter {
1692
1029
  */
1693
1030
  async resolveAction(data: any): Promise<void> {
1694
1031
  let promptText = '';
1695
- if (this.cliScripts && typeof this.cliScripts.resolveAction === 'function') {
1696
- try {
1697
- promptText = this.cliScripts.resolveAction(data);
1698
- } catch (e: any) {
1699
- LOG.warn('CLI', `[${this.cliType}] resolveAction error: ${e.message}`);
1700
- }
1032
+ try {
1033
+ promptText = this.runner.invokeByName('resolveAction', data);
1034
+ } catch {
1035
+ LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script not available`);
1036
+ return;
1701
1037
  }
1702
1038
  if (!promptText) {
1703
- LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script did not supply a prompt`);
1039
+ LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script did not return a prompt`);
1704
1040
  return;
1705
1041
  }
1706
1042
  await this.sendMessage(promptText);
1707
1043
  }
1708
1044
 
1709
1045
  private isSubmitStuck(normalizedPromptSnippet: string): boolean {
1710
- if (!this.ptyProcess || !this.isWaitingForResponse || this.submitRetryUsed) return false;
1711
- if (this.hasActionableApproval()) return false;
1712
- if (this.hasMeaningfulResponseBuffer(normalizedPromptSnippet)) return false;
1046
+ if (!this.ptyProcess || !this.engine.isWaitingForResponse || this.engine.submitRetryUsed) return false;
1047
+ if (this.engine.hasActionableApproval()) return false;
1048
+ // If there's already meaningful response content beyond the echoed prompt, not stuck
1049
+ if (this.hasMeaningfulResponseBufferLocal(normalizedPromptSnippet)) return false;
1713
1050
  const screenText = this.terminalScreen.getText();
1714
1051
  if (!promptLikelyVisible(screenText, normalizedPromptSnippet)) return false;
1715
1052
  const liveApproval = this.runParseApproval(screenText) || this.runParseApproval(this.recentOutputBuffer);
@@ -1718,20 +1055,41 @@ export class ProviderCliAdapter implements CliAdapter {
1718
1055
  return liveStatus !== 'generating' && liveStatus !== 'waiting_approval';
1719
1056
  }
1720
1057
 
1058
+ private hasMeaningfulResponseBufferLocal(promptSnippet: string): boolean {
1059
+ const raw = String(this.responseBuffer || '').trim();
1060
+ if (!raw) return false;
1061
+ const normalizedPrompt = compactPromptText(promptSnippet);
1062
+ if (!normalizedPrompt) return true;
1063
+ const normalizedBuffer = compactPromptText(raw);
1064
+ if (!normalizedBuffer) return false;
1065
+ if (normalizedBuffer === normalizedPrompt) return false;
1066
+ if (normalizedBuffer.startsWith(normalizedPrompt)) {
1067
+ const remainder = normalizedBuffer
1068
+ .slice(normalizedPrompt.length)
1069
+ .replace(/[─═\-]+/g, '')
1070
+ .replace(/⏵⏵accepteditson\([^)]*\)/gi, '')
1071
+ .replace(/accepteditson\([^)]*\)/gi, '')
1072
+ .replace(/(?:◐|◑|◒|◓|◔|◕|◉|●|·)?(?:x?high|medium|low|max)·?\/effort/gi, '')
1073
+ .replace(/updateavailable!run:[a-z0-9:._\-/]+/gi, '')
1074
+ .replace(/esctointerrupt/gi, '')
1075
+ .replace(/❯/g, '')
1076
+ .replace(/^[\s\-–—:;,.!/?]+/, '')
1077
+ .trim();
1078
+ return remainder.length > 0;
1079
+ }
1080
+ return true;
1081
+ }
1082
+
1721
1083
  private async writeToPty(data: string): Promise<void> {
1722
1084
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1723
1085
  await this.ptyProcess.write(data);
1724
1086
  }
1725
1087
 
1726
1088
  private resetPendingSendState(reason: string): void {
1727
- this.isWaitingForResponse = false;
1728
1089
  this.responseBuffer = '';
1729
- this.currentTurnScope = null;
1730
- this.submitPendingUntil = 0;
1731
- this.clearIdleFinishCandidate(reason);
1732
1090
  if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
1733
- if (this.submitRetryTimer) { clearTimeout(this.submitRetryTimer); this.submitRetryTimer = null; }
1734
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
1091
+ this.engine.resetActiveTurnState();
1092
+ this.engine.clearIdleFinishCandidate(reason);
1735
1093
  }
1736
1094
 
1737
1095
  private commitSendUserTurn(state: SendMessageState): void {
@@ -1748,42 +1106,14 @@ export class ProviderCliAdapter implements CliAdapter {
1748
1106
  }
1749
1107
  this.responseTimeout = setTimeout(() => {
1750
1108
  this.responseTimeout = null;
1751
- if (!this.isWaitingForResponse) return;
1752
-
1753
- const detectedStatusBeforeEval = this.runDetectStatus(this.recentOutputBuffer);
1754
- this.recordTrace('response_timeout_check', {
1755
- timeoutMs,
1756
- detectedStatus: detectedStatusBeforeEval,
1757
- currentStatus: this.currentStatus,
1758
- isWaitingForResponse: this.isWaitingForResponse,
1759
- hasActionableApproval: this.hasActionableApproval(),
1760
- ...buildCliTraceParseSnapshot({
1761
- accumulatedBuffer: this.accumulatedBuffer,
1762
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1763
- responseBuffer: this.responseBuffer,
1764
- partialResponse: this.responseBuffer,
1765
- scope: this.currentTurnScope,
1766
- }),
1767
- });
1109
+ if (!this.engine.isWaitingForResponse) return;
1768
1110
 
1769
- // maxResponse is a watchdog/checkpoint, not a completion signal. The old
1770
- // behavior called finishResponse() unconditionally at the default 300s,
1771
- // which fabricated idle transitions and downstream generating_completed
1772
- // notifications while long-running CLIs were still generating. Re-run the
1111
+ // maxResponse is a watchdog/checkpoint, not a completion signal. Re-run the
1773
1112
  // normal settled parser instead and keep the turn open unless the provider
1774
1113
  // actually reports an idle, commit-ready state.
1775
- this.settledBuffer = this.recentOutputBuffer;
1776
- this.evaluateSettled();
1777
-
1778
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1779
- const detectedStatusAfterEval = this.runDetectStatus(this.recentOutputBuffer);
1780
- this.recordTrace('response_timeout_kept_open', {
1781
- timeoutMs,
1782
- detectedStatusBeforeEval,
1783
- detectedStatusAfterEval,
1784
- currentStatus: this.currentStatus,
1785
- isWaitingForResponse: this.isWaitingForResponse,
1786
- });
1114
+ this.engine.evaluateSettled(this.getSnapshot());
1115
+
1116
+ if (this.engine.isWaitingForResponse && !this.engine.hasActionableApproval()) {
1787
1117
  this.armResponseTimeout();
1788
1118
  }
1789
1119
  }, timeoutMs);
@@ -1798,34 +1128,20 @@ export class ProviderCliAdapter implements CliAdapter {
1798
1128
  private retrySubmitIfStuck(state: SendMessageState, attempt: number): void {
1799
1129
  this.submitRetryTimer = null;
1800
1130
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
1801
- const screenText = this.terminalScreen.getText();
1802
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1131
+ this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1803
1132
  LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt ${attempt})`);
1804
- this.recordTrace('submit_write', {
1805
- mode: 'submit_retry',
1806
- attempt,
1807
- sendKey: this.sendKey,
1808
- screenText: summarizeCliTraceText(screenText, 500),
1809
- });
1810
1133
  this.writeSubmitKeyForRetry('submit_retry');
1811
- if (attempt >= 3) { this.submitRetryUsed = true; return; }
1134
+ if (attempt >= 3) { this.engine.submitRetryUsed = true; return; }
1812
1135
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, attempt + 1), state.retryDelayMs);
1813
1136
  }
1814
1137
 
1815
1138
  private retryImmediateSubmitIfStuck(state: SendMessageState): void {
1816
1139
  this.submitRetryTimer = null;
1817
1140
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
1818
- const screenText = this.terminalScreen.getText();
1819
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1141
+ this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1820
1142
  LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt 1)`);
1821
- this.recordTrace('submit_write', {
1822
- mode: 'immediate_retry',
1823
- attempt: 1,
1824
- sendKey: this.sendKey,
1825
- screenText: summarizeCliTraceText(screenText, 500),
1826
- });
1827
1143
  this.writeSubmitKeyForRetry('immediate_retry');
1828
- this.submitRetryUsed = true;
1144
+ this.engine.submitRetryUsed = true;
1829
1145
  }
1830
1146
 
1831
1147
  private submitSendKey(state: SendMessageState, completion: SendMessageCompletion): void {
@@ -1833,13 +1149,7 @@ export class ProviderCliAdapter implements CliAdapter {
1833
1149
  completion.resolveOnce();
1834
1150
  return;
1835
1151
  }
1836
- this.submitPendingUntil = 0;
1837
- const screenText = this.terminalScreen.getText();
1838
- this.recordTrace('submit_write', {
1839
- mode: 'submit_key',
1840
- sendKey: this.sendKey,
1841
- screenText: summarizeCliTraceText(screenText, 500),
1842
- });
1152
+ this.engine.submitPendingUntil = 0;
1843
1153
  void this.writeToPty(this.sendKey).then(() => {
1844
1154
  this.commitSendUserTurn(state);
1845
1155
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, 1), state.retryDelayMs);
@@ -1849,13 +1159,7 @@ export class ProviderCliAdapter implements CliAdapter {
1849
1159
  }
1850
1160
 
1851
1161
  private submitImmediatePrompt(state: SendMessageState, completion: SendMessageCompletion): void {
1852
- this.submitPendingUntil = 0;
1853
- this.recordTrace('submit_write', {
1854
- mode: 'immediate',
1855
- text: summarizeCliTraceText(state.text, 500),
1856
- sendKey: this.sendKey,
1857
- screenText: summarizeCliTraceText(this.terminalScreen.getText(), 500),
1858
- });
1162
+ this.engine.submitPendingUntil = 0;
1859
1163
  void this.writeToPty(state.text + this.sendKey).then(() => {
1860
1164
  this.commitSendUserTurn(state);
1861
1165
  this.submitRetryTimer = setTimeout(() => this.retryImmediateSubmitIfStuck(state), state.retryDelayMs);
@@ -1899,7 +1203,8 @@ export class ProviderCliAdapter implements CliAdapter {
1899
1203
  requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
1900
1204
  screenText: summarizeCliTraceText(screenText, 1000),
1901
1205
  };
1902
- this.recordTrace('submit_echo_missing', diagnostic);
1206
+ LOG.warn('CLI', `[${this.cliType}] submit_echo_missing: ${JSON.stringify(diagnostic)}`);
1207
+
1903
1208
  if (this.requirePromptEchoBeforeSubmit) {
1904
1209
  // At this point the prompt text write already completed. Rejecting without
1905
1210
  // a submit key can leave the delegated CLI with an unsent prompt sitting at
@@ -1924,12 +1229,110 @@ export class ProviderCliAdapter implements CliAdapter {
1924
1229
  ), 50);
1925
1230
  }
1926
1231
 
1927
- async sendMessage(text: string): Promise<void> {
1232
+ async sendMessage(text: string, options: { force?: boolean } = {}): Promise<void> {
1233
+ if (options.force === true) {
1234
+ await this.forceSendMessage(text);
1235
+ return;
1236
+ }
1237
+ await this.sendMessageNow(text, true);
1238
+ }
1239
+
1240
+ async forceSendMessage(text: string): Promise<void> {
1241
+ if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1242
+ const content = String(text || '');
1243
+ if (!content.trim()) return;
1244
+ LOG.info('CLI', `[${this.cliType}] force-sending prompt while status=${this.engine.currentStatus}`);
1245
+ await this.writeToPty(content + this.sendKey);
1246
+ this.onStatusChange?.();
1247
+ }
1248
+
1249
+ private enqueuePendingOutboundMessage(text: string, reason: string): void {
1250
+ const content = String(text || '');
1251
+ const duplicate = this.pendingOutboundQueue.some((message) => message.content === content);
1252
+ if (duplicate) {
1253
+ return;
1254
+ }
1255
+ const queuedAt = Date.now();
1256
+ const message: PendingOutboundMessage = {
1257
+ id: `${queuedAt}:${this.pendingOutboundQueue.length}:${Math.random().toString(36).slice(2, 10)}`,
1258
+ role: 'user',
1259
+ content,
1260
+ queuedAt,
1261
+ source: 'sendMessage',
1262
+ };
1263
+ this.pendingOutboundQueue.push(message);
1264
+ LOG.info('CLI', `[${this.cliType}] queued outbound message while busy (${reason}); queue=${this.pendingOutboundQueue.length}`);
1265
+ this.onStatusChange?.();
1266
+ }
1267
+
1268
+ private shouldQueuePendingOutboundMessage(parsedStatusBeforeSend: any | null = null): string | null {
1269
+ if (this.provider.allowInputDuringGeneration === true) return null;
1270
+ if (this.engine.hasActionableApproval()) return null;
1271
+ const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
1272
+ ? String(parsedStatusBeforeSend.status)
1273
+ : '';
1274
+ const hasFinalAssistant = (p: any) => {
1275
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
1276
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
1277
+ };
1278
+ if (parsedSessionStatus === 'idle' && hasFinalAssistant(parsedStatusBeforeSend)) return null;
1279
+ if (this.engine.currentStatus === 'generating') return 'current_status_generating';
1280
+ if (parsedSessionStatus === 'generating' || parsedSessionStatus === 'long_generating') {
1281
+ const parsedModal = parsedStatusBeforeSend?.activeModal ?? parsedStatusBeforeSend?.modal ?? null;
1282
+ const parsedHasActionableModal = Boolean(
1283
+ parsedModal
1284
+ && Array.isArray(parsedModal.buttons)
1285
+ && parsedModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim()),
1286
+ );
1287
+ const terminalLooksIdle = this.engine.currentStatus === 'idle'
1288
+ && this.runDetectStatus(this.recentOutputBuffer) === 'idle'
1289
+ && !this.engine.isWaitingForResponse
1290
+ && !this.engine.currentTurnScope
1291
+ && !this.engine.hasActionableApproval()
1292
+ && !parsedHasActionableModal;
1293
+ return terminalLooksIdle ? null : `parsed_status_${parsedSessionStatus}`;
1294
+ }
1295
+ if (this.engine.isWaitingForResponse && this.engine.currentTurnScope) return 'active_turn_in_progress';
1296
+ return null;
1297
+ }
1298
+
1299
+ private schedulePendingOutboundFlush(delayMs = 0): void {
1300
+ if (this.pendingOutboundFlushTimer) clearTimeout(this.pendingOutboundFlushTimer);
1301
+ this.pendingOutboundFlushTimer = setTimeout(() => {
1302
+ this.pendingOutboundFlushTimer = null;
1303
+ void this.flushPendingOutboundQueue();
1304
+ }, Math.max(0, delayMs));
1305
+ }
1306
+
1307
+ private async flushPendingOutboundQueue(): Promise<void> {
1308
+ if (this.pendingOutboundFlushInFlight || this.pendingOutboundQueue.length === 0) return;
1309
+ if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) return;
1310
+ this.pendingOutboundFlushInFlight = true;
1311
+ try {
1312
+ while (this.pendingOutboundQueue.length > 0) {
1313
+ if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) break;
1314
+ const next = this.pendingOutboundQueue[0];
1315
+ try {
1316
+ await this.sendMessageNow(next.content, false);
1317
+ this.pendingOutboundQueue.shift();
1318
+ this.onStatusChange?.();
1319
+ } catch (error: any) {
1320
+ LOG.warn('CLI', `[${this.cliType}] queued outbound flush failed: ${error?.message || error}`);
1321
+ this.schedulePendingOutboundFlush(1000);
1322
+ break;
1323
+ }
1324
+ }
1325
+ } finally {
1326
+ this.pendingOutboundFlushInFlight = false;
1327
+ }
1328
+ }
1329
+
1330
+ private async sendMessageNow(text: string, allowQueue: boolean): Promise<void> {
1928
1331
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1929
1332
  const allowInputDuringGeneration = this.provider.allowInputDuringGeneration === true;
1930
1333
  const allowInterventionPrompt = allowInputDuringGeneration
1931
- && this.isWaitingForResponse
1932
- && !this.hasActionableApproval();
1334
+ && this.engine.isWaitingForResponse
1335
+ && !this.engine.hasActionableApproval();
1933
1336
  if (this.startupParseGate) {
1934
1337
  const deadline = Date.now() + 10000;
1935
1338
  while (this.startupParseGate && Date.now() < deadline) {
@@ -1937,27 +1340,33 @@ export class ProviderCliAdapter implements CliAdapter {
1937
1340
  await new Promise(resolve => setTimeout(resolve, 50));
1938
1341
  }
1939
1342
  }
1343
+ const parsedStatusBeforeSend = !allowInputDuringGeneration
1344
+ ? (() => {
1345
+ try {
1346
+ return this.getScriptParsedStatus?.() || null;
1347
+ } catch {
1348
+ return null;
1349
+ }
1350
+ })()
1351
+ : null;
1352
+ const queueReason = this.shouldQueuePendingOutboundMessage(parsedStatusBeforeSend);
1353
+ if (allowQueue && queueReason) {
1354
+ this.enqueuePendingOutboundMessage(text, queueReason);
1355
+ return;
1356
+ }
1940
1357
  if (!allowInterventionPrompt) {
1941
1358
  await this.waitForInteractivePrompt();
1942
1359
  }
1943
1360
  if (!this.ready) {
1944
1361
  this.resolveStartupState('send_precheck');
1945
- if (this.runDetectStatus(this.recentOutputBuffer) === 'idle' && this.currentStatus === 'idle') {
1362
+ if (this.runDetectStatus(this.recentOutputBuffer) === 'idle') {
1946
1363
  this.ready = true;
1947
1364
  this.startupParseGate = false;
1365
+ this.engine.setStatus('idle', 'send_message_idle_prompt_recovery');
1948
1366
  LOG.info('CLI', `[${this.cliType}] sendMessage recovered idle prompt readiness`);
1949
1367
  }
1950
1368
  }
1951
- if (!this.ready) throw new Error(`${this.cliName} not ready (status: ${this.currentStatus})`);
1952
- const parsedStatusBeforeSend = !allowInputDuringGeneration
1953
- ? (() => {
1954
- try {
1955
- return this.getScriptParsedStatus?.() || null;
1956
- } catch {
1957
- return null;
1958
- }
1959
- })()
1960
- : null;
1369
+ if (!this.ready) throw new Error(`${this.cliName} not ready (status: ${this.engine.currentStatus})`);
1961
1370
  const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
1962
1371
  ? String(parsedStatusBeforeSend.status)
1963
1372
  : '';
@@ -1968,45 +1377,48 @@ export class ProviderCliAdapter implements CliAdapter {
1968
1377
  && Array.isArray(parsedModal.buttons)
1969
1378
  && parsedModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim()),
1970
1379
  );
1971
- const terminalLooksIdle = this.currentStatus === 'idle'
1380
+ const terminalLooksIdle = this.engine.currentStatus === 'idle'
1972
1381
  && this.runDetectStatus(this.recentOutputBuffer) === 'idle'
1973
- && !this.isWaitingForResponse
1974
- && !this.currentTurnScope
1975
- && !this.hasActionableApproval()
1382
+ && !this.engine.isWaitingForResponse
1383
+ && !this.engine.currentTurnScope
1384
+ && !this.engine.hasActionableApproval()
1976
1385
  && !parsedHasActionableModal;
1977
1386
  if (!terminalLooksIdle) {
1387
+ if (allowQueue) {
1388
+ this.enqueuePendingOutboundMessage(text, `parsed_status_${parsedSessionStatus}`);
1389
+ return;
1390
+ }
1978
1391
  throw new Error(`${this.cliName} is still processing the previous prompt`);
1979
1392
  }
1980
1393
  }
1981
- if (this.isWaitingForResponse && !allowInputDuringGeneration) {
1982
- if (!this.clearStaleIdleResponseGuard('send_message_guard')) {
1394
+ if (this.engine.isWaitingForResponse && !allowInputDuringGeneration) {
1395
+ const snap = this.getSnapshot();
1396
+ if (
1397
+ !this.engine.clearStaleIdleResponseGuard('send_message_guard', snap)
1398
+ && !this.engine.clearParsedIdleResponseGuard('send_message_parsed_idle_guard', parsedStatusBeforeSend, snap)
1399
+ ) {
1400
+ if (allowQueue) {
1401
+ this.enqueuePendingOutboundMessage(text, 'waiting_for_response');
1402
+ return;
1403
+ }
1983
1404
  throw new Error(`${this.cliName} is still processing the previous prompt`);
1984
1405
  }
1985
1406
  }
1986
- this.isWaitingForResponse = true;
1987
1407
  this.responseBuffer = '';
1988
- this.finishRetryCount = 0;
1989
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
1990
- this.clearIdleFinishCandidate('send_message');
1991
- this.currentTurnScope = {
1408
+ const turnScope: TurnParseScope = {
1992
1409
  prompt: text,
1993
1410
  startedAt: Date.now(),
1994
1411
  bufferStart: this.accumulatedBuffer.length,
1995
1412
  rawBufferStart: this.accumulatedRawBuffer.length,
1996
1413
  };
1997
- this.recordTrace('send_message', {
1998
- text: summarizeCliTraceText(text, 500),
1999
- estimatedLines: estimatePromptDisplayLines(text),
2000
- turnScope: this.currentTurnScope,
2001
- });
2002
- LOG.info('CLI', `[${this.cliType}] sendMessage turn scope buffer=${this.currentTurnScope.bufferStart} raw=${this.currentTurnScope.rawBufferStart} prompt=${JSON.stringify(text).slice(0, 120)}`);
2003
- this.submitRetryUsed = false;
2004
- this.submitRetryPromptSnippet = extractPromptRetrySnippet(text);
2005
- const normalizedPromptSnippet = normalizePromptText(this.submitRetryPromptSnippet);
1414
+ LOG.info('CLI', `[${this.cliType}] sendMessage turn scope buffer=${turnScope.bufferStart} raw=${turnScope.rawBufferStart} prompt=${JSON.stringify(text).slice(0, 120)}`);
2006
1415
  if (this.submitRetryTimer) {
2007
1416
  clearTimeout(this.submitRetryTimer);
2008
1417
  this.submitRetryTimer = null;
2009
1418
  }
1419
+ this.engine.onTurnStarted(turnScope);
1420
+ this.engine.submitRetryPromptSnippet = extractPromptRetrySnippet(text);
1421
+ const normalizedPromptSnippet = normalizePromptText(this.engine.submitRetryPromptSnippet);
2010
1422
  const estimatedLines = estimatePromptDisplayLines(text);
2011
1423
  const submitDelayMs = this.sendDelayMs + Math.min(2000, Math.max(0, estimatedLines - 1) * 350);
2012
1424
  const maxEchoWaitMs = submitDelayMs + Math.max(1500, Math.min(5000, estimatedLines * 500));
@@ -2019,12 +1431,7 @@ export class ProviderCliAdapter implements CliAdapter {
2019
1431
  retryDelayMs,
2020
1432
  didCommitUserTurn: false,
2021
1433
  };
2022
- if (this.settleTimer) {
2023
- clearTimeout(this.settleTimer);
2024
- this.settleTimer = null;
2025
- }
2026
- this.responseEpoch += 1;
2027
- this.responseSettleIgnoreUntil = Date.now() + submitDelayMs + this.timeouts.outputSettle + 250;
1434
+ this.engine.responseSettleIgnoreUntil = Date.now() + submitDelayMs + this.timeouts.outputSettle + 250;
2028
1435
  await new Promise<void>((resolve, reject) => {
2029
1436
  let resolved = false;
2030
1437
  const completion: SendMessageCompletion = {
@@ -2047,24 +1454,20 @@ export class ProviderCliAdapter implements CliAdapter {
2047
1454
  }
2048
1455
 
2049
1456
  if (submitDelayMs > 0) {
2050
- this.submitPendingUntil = Date.now() + submitDelayMs;
1457
+ this.engine.submitPendingUntil = Date.now() + submitDelayMs;
2051
1458
  }
2052
- this.recordTrace('submit_write', {
2053
- mode: 'type_then_submit',
2054
- text: summarizeCliTraceText(text, 500),
2055
- sendKey: this.sendKey,
2056
- screenText: summarizeCliTraceText(this.terminalScreen.getText(), 500),
2057
- });
2058
1459
  const submitStartedAt = Date.now();
2059
1460
  void this.writeToPty(text).then(
2060
1461
  () => this.waitForEchoAndSubmit(sendState, completion, submitStartedAt),
2061
1462
  completion.rejectOnce,
2062
1463
  );
2063
1464
  });
1465
+ // Schedule settle after successful send
1466
+ this.engine.scheduleSettle();
2064
1467
  }
2065
1468
 
2066
1469
  getPartialResponse(): string {
2067
- if (!this.isWaitingForResponse) return '';
1470
+ if (!this.engine.isWaitingForResponse) return '';
2068
1471
  return this.responseBuffer;
2069
1472
  }
2070
1473
 
@@ -2077,10 +1480,10 @@ export class ProviderCliAdapter implements CliAdapter {
2077
1480
  cliType: this.cliType,
2078
1481
  cliName: this.cliName,
2079
1482
  workingDir: this.workingDir,
2080
- currentStatus: this.currentStatus,
1483
+ currentStatus: this.engine.currentStatus,
2081
1484
  ready: this.ready,
2082
- isWaitingForResponse: this.isWaitingForResponse,
2083
- activeModal: this.activeModal,
1485
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1486
+ activeModal: this.engine.activeModal,
2084
1487
  parseErrorMessage: this.parseErrorMessage,
2085
1488
  messageCounts: {
2086
1489
  parsedCache: Array.isArray(parsedResult?.messages) ? parsedResult.messages.length : undefined,
@@ -2106,10 +1509,10 @@ export class ProviderCliAdapter implements CliAdapter {
2106
1509
  lastScreenSnapshotReadAt: this.lastScreenSnapshotReadAt,
2107
1510
  },
2108
1511
  parser: {
2109
- scriptNames: listCliScriptNames(this.cliScripts),
2110
- traceSessionId: this.traceSessionId,
2111
- traceSeq: this.traceSeq,
2112
- currentTurnScope: this.currentTurnScope,
1512
+ scriptNames: this.runner.getScriptNames(),
1513
+ traceSessionId: this.engine.getTraceSessionId(),
1514
+ traceSeq: this.engine.getTraceEntries().length,
1515
+ currentTurnScope: this.engine.currentTurnScope,
2113
1516
  parsedStatusCache: parsedResult
2114
1517
  ? {
2115
1518
  id: parsedResult.id,
@@ -2122,26 +1525,25 @@ export class ProviderCliAdapter implements CliAdapter {
2122
1525
  activeModal: parsedResult.activeModal,
2123
1526
  }
2124
1527
  : null,
2125
- pendingScriptStatus: this.pendingScriptStatus,
2126
- pendingScriptStatusSince: this.pendingScriptStatusSince,
1528
+ pendingScriptStatus: this.engine.pendingScriptStatus,
1529
+ pendingScriptStatusSince: this.engine.pendingScriptStatusSince,
2127
1530
  },
2128
1531
  runtimeMetadata: this.getRuntimeMetadata(),
2129
- statusHistory: this.statusHistory.slice(-80),
2130
- traceEntries: this.traceEntries.slice(-120),
1532
+ statusHistory: this.engine.getStatusHistory().slice(-80),
1533
+ traceEntries: this.engine.getTraceEntries().slice(-120),
2131
1534
  timing: {
2132
1535
  spawnAt: this.spawnAt,
2133
1536
  startupFirstOutputAt: this.startupFirstOutputAt,
2134
- submitPendingUntil: this.submitPendingUntil,
2135
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
2136
- responseEpoch: this.responseEpoch,
1537
+ submitPendingUntil: this.engine.submitPendingUntil,
1538
+ responseSettleIgnoreUntil: this.engine.responseSettleIgnoreUntil,
1539
+ responseEpoch: this.engine.responseEpoch,
2137
1540
  resizeSuppressUntil: this.resizeSuppressUntil,
2138
- lastApprovalResolvedAt: this.lastApprovalResolvedAt,
1541
+ lastApprovalResolvedAt: this.engine.lastApprovalResolvedAt,
2139
1542
  },
2140
1543
  finish: {
2141
- idleFinishCandidate: this.idleFinishCandidate,
2142
- finishRetryCount: this.finishRetryCount,
2143
- submitRetryUsed: this.submitRetryUsed,
2144
- submitRetryPromptSnippet: this.submitRetryPromptSnippet,
1544
+ finishRetryCount: this.engine.finishRetryCount,
1545
+ submitRetryUsed: this.engine.submitRetryUsed,
1546
+ submitRetryPromptSnippet: this.engine.submitRetryPromptSnippet,
2145
1547
  },
2146
1548
  };
2147
1549
  }
@@ -2152,6 +1554,12 @@ export class ProviderCliAdapter implements CliAdapter {
2152
1554
  }
2153
1555
 
2154
1556
  updateRuntimeMeta(meta: Record<string, unknown>, replace = false): void {
1557
+ const nextProviderSessionId = typeof meta?.providerSessionId === 'string'
1558
+ ? meta.providerSessionId.trim()
1559
+ : '';
1560
+ if (nextProviderSessionId) {
1561
+ this.providerSessionId = nextProviderSessionId;
1562
+ }
2155
1563
  if (!this.ptyProcess || typeof this.ptyProcess.updateMeta !== 'function') return;
2156
1564
  this.ptyProcess.updateMeta(meta, replace);
2157
1565
  }
@@ -2172,7 +1580,7 @@ export class ProviderCliAdapter implements CliAdapter {
2172
1580
  this.timeouts.shutdownGrace,
2173
1581
  typeof resume.shutdownGraceMs === 'number' ? resume.shutdownGraceMs : 3000,
2174
1582
  );
2175
- const wasProcessing = this.currentStatus === 'generating' || this.currentStatus === 'waiting_approval';
1583
+ const wasProcessing = this.engine.currentStatus === 'generating' || this.engine.currentStatus === 'waiting_approval';
2176
1584
 
2177
1585
  try {
2178
1586
  if (wasProcessing) {
@@ -2210,7 +1618,7 @@ export class ProviderCliAdapter implements CliAdapter {
2210
1618
  return new Promise((resolve) => {
2211
1619
  const startedAt = Date.now();
2212
1620
  const timer = setInterval(() => {
2213
- if (!this.ptyProcess || this.currentStatus === 'stopped') {
1621
+ if (!this.ptyProcess || this.engine.currentStatus === 'stopped') {
2214
1622
  clearInterval(timer);
2215
1623
  resolve(true);
2216
1624
  return;
@@ -2224,18 +1632,20 @@ export class ProviderCliAdapter implements CliAdapter {
2224
1632
  }
2225
1633
 
2226
1634
  shutdown(): void {
2227
- this.clearIdleFinishCandidate('shutdown');
1635
+ this.engine.clearIdleFinishCandidate('shutdown');
2228
1636
  this.clearAllTimers();
2229
1637
  this.pendingOutputParseChunks = [];
2230
1638
  this.pendingTerminalQueryTail = '';
2231
1639
  this.ptyOutputChunks = [];
2232
- this.finishRetryCount = 0;
1640
+ if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
1641
+ this.pendingOutboundQueue = [];
1642
+ this.pendingOutboundFlushInFlight = false;
2233
1643
  if (this.ptyProcess) {
2234
1644
  this.ptyProcess.write('\x03');
2235
1645
  setTimeout(() => {
2236
1646
  try { this.ptyProcess?.kill(); } catch { }
2237
1647
  this.ptyProcess = null;
2238
- this.setStatus('stopped', 'stop_cmd');
1648
+ this.engine.setStatus('stopped', 'stop_cmd');
2239
1649
  this.ready = false;
2240
1650
  this.startupParseGate = false;
2241
1651
  this.spawnAt = 0;
@@ -2245,12 +1655,14 @@ export class ProviderCliAdapter implements CliAdapter {
2245
1655
  }
2246
1656
 
2247
1657
  detach(): void {
2248
- this.clearIdleFinishCandidate('detach');
1658
+ this.engine.clearIdleFinishCandidate('detach');
2249
1659
  this.clearAllTimers();
2250
1660
  this.pendingOutputParseChunks = [];
2251
1661
  this.pendingTerminalQueryTail = '';
2252
1662
  this.ptyOutputChunks = [];
2253
- this.finishRetryCount = 0;
1663
+ if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
1664
+ this.pendingOutboundQueue = [];
1665
+ this.pendingOutboundFlushInFlight = false;
2254
1666
  if (this.ptyProcess) {
2255
1667
  try {
2256
1668
  if (typeof this.ptyProcess.detach === 'function') {
@@ -2268,82 +1680,128 @@ export class ProviderCliAdapter implements CliAdapter {
2268
1680
  }
2269
1681
 
2270
1682
  clearHistory(): void {
2271
- this.clearIdleFinishCandidate('clear_history');
1683
+ this.engine.clearIdleFinishCandidate('clear_history');
2272
1684
  this.accumulatedBuffer = '';
2273
1685
  this.accumulatedRawBuffer = '';
2274
- this.currentTurnScope = null;
2275
- this.submitRetryUsed = false;
2276
- this.submitRetryPromptSnippet = '';
1686
+ this.engine.currentTurnScope = null;
1687
+ this.engine.submitRetryUsed = false;
1688
+ this.engine.submitRetryPromptSnippet = '';
2277
1689
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
2278
1690
  this.pendingOutputParseChunks = [];
2279
1691
  this.pendingTerminalQueryTail = '';
2280
1692
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
2281
1693
  this.ptyOutputChunks = [];
2282
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
2283
- this.finishRetryCount = 0;
1694
+ if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
1695
+ this.pendingOutboundQueue = [];
1696
+ this.pendingOutboundFlushInFlight = false;
2284
1697
  this.resetTerminalScreen();
2285
1698
  this.ptyProcess?.clearBuffer?.();
2286
1699
  this.onStatusChange?.();
2287
1700
  }
2288
1701
 
2289
- isProcessing(): boolean { return this.isWaitingForResponse; }
1702
+ isProcessing(): boolean { return this.engine.isWaitingForResponse; }
2290
1703
  isReady(): boolean { return this.ready; }
2291
1704
 
2292
- async writeRaw(data: string): Promise<void> {
2293
- this.recordTrace('write_raw', {
2294
- keys: JSON.stringify(data),
2295
- length: data.length,
2296
- });
2297
- await this.writeToPty(data);
1705
+ // ─── State machine property accessors (delegate to engine) ──────────────
1706
+ // These expose engine state for external callers (tests, debug tools, etc.)
1707
+
1708
+ get currentStatus(): CliSessionStatus['status'] { return this.engine.currentStatus; }
1709
+ set currentStatus(v: CliSessionStatus['status']) { this.engine.setStatus(v); }
1710
+
1711
+ get isWaitingForResponse(): boolean { return this.engine.isWaitingForResponse; }
1712
+ set isWaitingForResponse(v: boolean) { this.engine.isWaitingForResponse = v; }
1713
+
1714
+ get activeModal(): { message: string; buttons: string[] } | null { return this.engine.activeModal; }
1715
+ set activeModal(v: { message: string; buttons: string[] } | null) { this.engine.activeModal = v; }
1716
+
1717
+ get currentTurnScope(): TurnParseScope | null { return this.engine.currentTurnScope; }
1718
+ set currentTurnScope(v: TurnParseScope | null) { this.engine.currentTurnScope = v; }
1719
+
1720
+ get responseEpoch(): number { return this.engine.responseEpoch; }
1721
+ set responseEpoch(v: number) { this.engine.responseEpoch = v; }
1722
+
1723
+ get submitRetryUsed(): boolean { return this.engine.submitRetryUsed; }
1724
+ set submitRetryUsed(v: boolean) { this.engine.submitRetryUsed = v; }
1725
+
1726
+ get submitRetryPromptSnippet(): string { return this.engine.submitRetryPromptSnippet; }
1727
+ set submitRetryPromptSnippet(v: string) { this.engine.submitRetryPromptSnippet = v; }
1728
+
1729
+ get responseSettleIgnoreUntil(): number { return this.engine.responseSettleIgnoreUntil; }
1730
+ set responseSettleIgnoreUntil(v: number) { this.engine.responseSettleIgnoreUntil = v; }
1731
+
1732
+ get submitPendingUntil(): number { return this.engine.submitPendingUntil; }
1733
+ set submitPendingUntil(v: number) { this.engine.submitPendingUntil = v; }
1734
+
1735
+ get lastApprovalResolvedAt(): number { return this.engine.lastApprovalResolvedAt; }
1736
+ set lastApprovalResolvedAt(v: number) { this.engine.lastApprovalResolvedAt = v; }
1737
+
1738
+ get providerErrorMessage(): string | null { return this.engine.providerErrorMessage; }
1739
+ get providerErrorReason(): string | null { return this.engine.providerErrorReason; }
1740
+
1741
+ get pendingScriptStatus(): 'generating' | 'waiting_approval' | null { return this.engine.pendingScriptStatus; }
1742
+ get pendingScriptStatusSince(): number { return this.engine.pendingScriptStatusSince; }
1743
+
1744
+ get finishRetryCount(): number { return this.engine.finishRetryCount; }
1745
+ set finishRetryCount(v: number) { this.engine.finishRetryCount = v; }
1746
+
1747
+ get traceSessionId(): string { return this.engine.getTraceSessionId(); }
1748
+ get traceEntries(): CliTraceEntry[] { return this.engine.getTraceEntries(); }
1749
+ get statusHistory(): { status: string; at: number; trigger?: string }[] { return this.engine.getStatusHistory(); }
1750
+ get traceSeq(): number { return this.engine.getTraceEntries().length; }
1751
+
1752
+ /** Expose engine's evaluateSettled for test access */
1753
+ evaluateSettled(): void {
1754
+ LOG.debug(
1755
+ 'CLI',
1756
+ `[${this.cliType}] settled diagnostics delegated to state engine`);
1757
+ this.engine.evaluateSettled(this.getSnapshot());
1758
+ }
1759
+ /** Expose engine's scheduleSettle for test access */
1760
+ scheduleSettle(): void { this.engine.scheduleSettle(); }
1761
+ /** Expose engine's clearIdleFinishCandidate for test access */
1762
+ clearIdleFinishCandidate(reason: string): void { this.engine.clearIdleFinishCandidate(reason); }
1763
+ /** Expose engine's finishResponse for test access */
1764
+ finishResponse(): void { this.engine.finishResponse(); }
1765
+ /** Returns a point-in-time snapshot of all buffer/screen state for external consumers (e.g. CliStateEngine). */
1766
+ getSnapshot(): CliBufferSnapshot {
1767
+ const screenText = this.terminalScreen.getText() || '';
1768
+ return {
1769
+ accumulatedBuffer: this.accumulatedBuffer,
1770
+ accumulatedRawBuffer: this.accumulatedRawBuffer,
1771
+ recentOutputBuffer: this.recentOutputBuffer,
1772
+ responseBuffer: this.responseBuffer,
1773
+ screenText,
1774
+ parseScreenText: this.getParseScreenText(screenText),
1775
+ workingDir: this.workingDir,
1776
+ providerSessionId: this.providerSessionId,
1777
+ runtimeSettings: this.runtimeSettings,
1778
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1779
+ currentTurnScope: this.engine.currentTurnScope,
1780
+ lastOutputAt: this.lastOutputAt,
1781
+ lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
1782
+ lastScreenChangeAt: this.lastScreenChangeAt,
1783
+ lastScreenSnapshot: this.lastScreenSnapshot,
1784
+ };
1785
+ }
1786
+ isAlive(): boolean { return this.ptyProcess !== null; }
1787
+ flushOutboundQueue(): void { this.schedulePendingOutboundFlush(); }
1788
+
1789
+ async writeRaw(data: string | Buffer): Promise<void> {
1790
+ const str = Buffer.isBuffer(data) ? data.toString('utf8') : data;
1791
+ await this.writeToPty(str);
2298
1792
  }
2299
1793
 
2300
1794
  resolveModal(buttonIndex: number): void {
2301
- let modal = this.activeModal || this.runParseApproval(this.recentOutputBuffer);
2302
- if (!modal && typeof this.cliScripts?.parseSession === 'function') {
2303
- try {
2304
- const parsed = this.getScriptParsedStatus();
2305
- const parsedModal = parsed?.activeModal && Array.isArray(parsed.activeModal.buttons)
2306
- && parsed.activeModal.buttons.some((button: any) => typeof button === 'string' && button.trim())
2307
- ? parsed.activeModal
2308
- : null;
2309
- if (parsed?.status === 'waiting_approval' && parsedModal) {
2310
- modal = parsedModal;
2311
- this.activeModal = parsedModal;
2312
- if (this.currentStatus !== 'waiting_approval') {
2313
- this.setStatus('waiting_approval', 'resolve_modal_parse');
2314
- this.onStatusChange?.();
2315
- }
2316
- }
2317
- } catch {
2318
- // Ignore parse failures here; resolveModal falls back to current state.
2319
- }
2320
- }
2321
- if (!this.ptyProcess || ((this.currentStatus !== 'waiting_approval') && !modal)) return;
2322
- this.clearIdleFinishCandidate('resolve_modal');
2323
- this.recordTrace('resolve_modal', {
2324
- buttonIndex,
2325
- activeModal: modal,
2326
- });
2327
- this.activeModal = null;
2328
- this.lastApprovalResolvedAt = Date.now();
2329
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
2330
- if (this.approvalExitTimeout) {
2331
- clearTimeout(this.approvalExitTimeout);
2332
- this.approvalExitTimeout = null;
2333
- }
2334
- this.setStatus('generating', 'approval_resolved');
2335
- this.onStatusChange?.();
2336
- if (buttonIndex in this.approvalKeys) {
2337
- this.ptyProcess.write(this.approvalKeys[buttonIndex]);
2338
- } else {
2339
- const buttonCount = Array.isArray(modal?.buttons) ? modal.buttons.length : 0;
2340
- const clampedIndex = buttonCount > 0
2341
- ? Math.min(Math.max(0, buttonIndex), buttonCount - 1)
2342
- : Math.max(0, buttonIndex);
2343
- const DOWN = '\x1B[B';
2344
- const keys = DOWN.repeat(clampedIndex) + '\r';
2345
- this.ptyProcess.write(keys);
2346
- }
1795
+ this.engine.resolveModal(buttonIndex);
1796
+ }
1797
+
1798
+ getApprovalKeyForIndex(buttonIndex: number): string | undefined {
1799
+ return buttonIndex in this.approvalKeys ? this.approvalKeys[buttonIndex] : undefined;
1800
+ }
1801
+
1802
+ /** Returns true if an approval was resolved within the adapter's cooldown window. */
1803
+ isApprovalRecentlyResolved(): boolean {
1804
+ return this.engine.isApprovalRecentlyResolved();
2347
1805
  }
2348
1806
 
2349
1807
  resize(cols: number, rows: number): void {
@@ -2357,7 +1815,7 @@ export class ProviderCliAdapter implements CliAdapter {
2357
1815
  }
2358
1816
 
2359
1817
  private getParsedDebugState(): Record<string, any> | null {
2360
- if (this.startupParseGate || typeof this.cliScripts?.parseSession !== 'function') return null;
1818
+ if (this.startupParseGate || !this.runner.hasParseSession()) return null;
2361
1819
  try {
2362
1820
  const parsed = this.getScriptParsedStatus();
2363
1821
  return parsed && typeof parsed === 'object' ? parsed as Record<string, any> : null;
@@ -2369,18 +1827,43 @@ export class ProviderCliAdapter implements CliAdapter {
2369
1827
  getDebugState(): Record<string, any> {
2370
1828
  const screenText = sanitizeTerminalText(this.terminalScreen.getText());
2371
1829
  const startupModal = this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
2372
- const effectiveStatus = this.projectEffectiveStatus(startupModal);
2373
- const effectiveReady = this.ready || !!startupModal;
1830
+ const startupDetectedStatus = this.startupParseGate && !startupModal
1831
+ ? this.runDetectStatus(this.recentOutputBuffer || screenText)
1832
+ : null;
1833
+ const effectiveReady = this.ready || !!startupModal || startupDetectedStatus === 'waiting_approval';
2374
1834
  const parsedDebugState = this.getParsedDebugState();
2375
1835
  const parsedMessages = Array.isArray(parsedDebugState?.messages) ? parsedDebugState.messages : [];
1836
+ const hasFinalAssistant = (p: any) => {
1837
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
1838
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
1839
+ };
1840
+ let effectiveStatus = this.projectEffectiveStatus(startupModal);
1841
+ if (parsedDebugState?.status === 'error') {
1842
+ effectiveStatus = 'error';
1843
+ }
1844
+ // (fix) Mirror the getStatus contract: never surface waiting_approval
1845
+ // without a concrete modal. detectStatus alone can fire while
1846
+ // parseApproval is still null, and a bare debugState waiting_approval
1847
+ // confused dashboards reading the debug payload.
1848
+ const debugEffectiveModal = startupModal || this.engine.activeModal;
1849
+ if (startupDetectedStatus === 'waiting_approval' && debugEffectiveModal) {
1850
+ effectiveStatus = 'waiting_approval';
1851
+ }
1852
+ if (
1853
+ effectiveStatus === 'idle'
1854
+ && parsedDebugState?.status === 'generating'
1855
+ && !hasFinalAssistant(parsedDebugState)
1856
+ ) {
1857
+ effectiveStatus = 'generating';
1858
+ }
2376
1859
  return {
2377
1860
  type: this.cliType,
2378
1861
  name: this.cliName,
2379
1862
  providerResolution: this.providerResolutionMeta,
2380
1863
  status: effectiveStatus,
2381
1864
  projectedStatus: effectiveStatus,
2382
- rawStatus: this.currentStatus,
2383
- lifecycleStatus: this.isWaitingForResponse ? 'awaiting_response' : 'idle',
1865
+ rawStatus: this.engine.currentStatus,
1866
+ lifecycleStatus: this.engine.isWaitingForResponse ? 'awaiting_response' : 'idle',
2384
1867
  ready: effectiveReady,
2385
1868
  startupParseGate: this.startupParseGate,
2386
1869
  spawnAt: this.spawnAt,
@@ -2394,38 +1877,47 @@ export class ProviderCliAdapter implements CliAdapter {
2394
1877
  providerSessionId: parsedDebugState.providerSessionId,
2395
1878
  transcriptAuthority: parsedDebugState.transcriptAuthority,
2396
1879
  coverage: parsedDebugState.coverage,
1880
+ errorMessage: parsedDebugState.errorMessage,
1881
+ errorReason: parsedDebugState.errorReason,
2397
1882
  activeModal: parsedDebugState.activeModal,
2398
1883
  messageCount: parsedMessages.length,
2399
1884
  } : null,
2400
1885
  screenText: screenText.slice(-4000),
2401
- currentTurnScope: this.currentTurnScope,
1886
+ currentTurnScope: this.engine.currentTurnScope,
2402
1887
  startupBuffer: this.startupBuffer.slice(-4000),
2403
1888
  recentOutputBuffer: this.recentOutputBuffer.slice(-500),
2404
- settledBuffer: this.settledBuffer.slice(-500),
2405
1889
  accumulatedBufferLength: this.accumulatedBuffer.length,
2406
1890
  accumulatedRawBufferLength: this.accumulatedRawBuffer.length,
2407
1891
  rawBufferPreview: this.accumulatedRawBuffer.slice(-1000),
2408
1892
  sanitizedRawPreview: sanitizeTerminalText(this.accumulatedRawBuffer).slice(-1000),
2409
1893
  responseBuffer: this.responseBuffer.slice(-1000),
1894
+ pendingOutboundQueue: this.pendingOutboundQueue.map((message) => ({
1895
+ id: message.id,
1896
+ role: message.role,
1897
+ content: message.content,
1898
+ queuedAt: message.queuedAt,
1899
+ source: message.source,
1900
+ })),
1901
+ pendingOutboundCount: this.pendingOutboundQueue.length,
2410
1902
  lastOutputAt: this.lastOutputAt,
2411
1903
  lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
2412
1904
  lastScreenChangeAt: this.lastScreenChangeAt,
2413
1905
  lastScreenSnapshot: this.lastScreenSnapshot.slice(-500),
2414
- isWaitingForResponse: this.isWaitingForResponse,
2415
- activeModal: startupModal || this.activeModal,
2416
- lastApprovalResolvedAt: this.lastApprovalResolvedAt,
1906
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1907
+ activeModal: startupModal || this.engine.activeModal,
1908
+ lastApprovalResolvedAt: this.engine.lastApprovalResolvedAt,
2417
1909
  sendDelayMs: this.sendDelayMs,
2418
1910
  sendKey: this.sendKey,
2419
1911
  submitStrategy: this.submitStrategy,
2420
1912
  requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
2421
- submitPendingUntil: this.submitPendingUntil,
2422
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
1913
+ submitPendingUntil: this.engine.submitPendingUntil,
1914
+ responseSettleIgnoreUntil: this.engine.responseSettleIgnoreUntil,
2423
1915
  resizeSuppressUntil: this.resizeSuppressUntil,
2424
1916
  hasCliScripts: this.hasCliScripts(),
2425
- scriptNames: listCliScriptNames(this.cliScripts),
2426
- traceSessionId: this.traceSessionId,
2427
- traceEntryCount: this.traceEntries.length,
2428
- statusHistory: this.statusHistory.slice(-30),
1917
+ scriptNames: this.runner.getScriptNames(),
1918
+ traceSessionId: this.engine.getTraceSessionId(),
1919
+ traceEntryCount: this.engine.getTraceEntries().length,
1920
+ statusHistory: this.engine.getStatusHistory().slice(-30),
2429
1921
  timeouts: this.timeouts,
2430
1922
  pendingOutputParseBufferLength: this.pendingOutputParseChunks.reduce((total, chunk) => total + chunk.length, 0),
2431
1923
  pendingOutputParseScheduled: !!this.pendingOutputParseTimer,
@@ -2435,20 +1927,21 @@ export class ProviderCliAdapter implements CliAdapter {
2435
1927
 
2436
1928
  getTraceState(limit = 120): Record<string, any> {
2437
1929
  const cappedLimit = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 120));
1930
+ const traceEntries = this.engine.getTraceEntries();
2438
1931
  return {
2439
- sessionId: this.traceSessionId,
1932
+ sessionId: this.engine.getTraceSessionId(),
2440
1933
  providerResolution: this.providerResolutionMeta,
2441
- entryCount: this.traceEntries.length,
2442
- entries: this.traceEntries.slice(-cappedLimit),
1934
+ entryCount: traceEntries.length,
1935
+ entries: traceEntries.slice(-cappedLimit),
2443
1936
  screenText: summarizeCliTraceText(this.terminalScreen.getText(), 4000),
2444
1937
  recentOutputBuffer: summarizeCliTraceText(this.recentOutputBuffer, 1000),
2445
1938
  responseBuffer: summarizeCliTraceText(this.responseBuffer, 1200),
2446
1939
  status: this.projectEffectiveStatus(),
2447
1940
  projectedStatus: this.projectEffectiveStatus(),
2448
- rawStatus: this.currentStatus,
2449
- lifecycleStatus: this.isWaitingForResponse ? 'awaiting_response' : 'idle',
2450
- activeModal: this.activeModal,
2451
- currentTurnScope: this.currentTurnScope,
1941
+ rawStatus: this.engine.currentStatus,
1942
+ lifecycleStatus: this.engine.isWaitingForResponse ? 'awaiting_response' : 'idle',
1943
+ activeModal: this.engine.activeModal,
1944
+ currentTurnScope: this.engine.currentTurnScope,
2452
1945
  messages: [],
2453
1946
  };
2454
1947
  }