@adhdev/daemon-core 0.9.82-rc.15 → 0.9.82-rc.152

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 (182) hide show
  1. package/dist/boot/process-hardening.d.ts +50 -0
  2. package/dist/chat/source-machine.d.ts +166 -0
  3. package/dist/chat/source-resolver.d.ts +104 -0
  4. package/dist/chat/subscription-updates.d.ts +1 -0
  5. package/dist/cli-adapter-types.d.ts +5 -1
  6. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  7. package/dist/cli-adapters/cli-state-engine.d.ts +178 -0
  8. package/dist/cli-adapters/provider-cli-adapter.d.ts +104 -63
  9. package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
  10. package/dist/cli-adapters/provider-cli-shared.d.ts +27 -0
  11. package/dist/commands/handler.d.ts +66 -0
  12. package/dist/commands/router.d.ts +22 -0
  13. package/dist/config/chat-history.d.ts +5 -0
  14. package/dist/config/config.d.ts +5 -0
  15. package/dist/config/mesh-config.d.ts +68 -1
  16. package/dist/git/git-commands.d.ts +5 -1
  17. package/dist/index.d.ts +20 -6
  18. package/dist/index.js +13153 -3031
  19. package/dist/index.js.map +1 -1
  20. package/dist/index.mjs +13099 -3023
  21. package/dist/index.mjs.map +1 -1
  22. package/dist/installer.d.ts +1 -4
  23. package/dist/ipc/local-ipc-server.d.ts +91 -0
  24. package/dist/launch.d.ts +1 -1
  25. package/dist/logging/async-batch-writer.d.ts +10 -0
  26. package/dist/mesh/beads-db.d.ts +72 -0
  27. package/dist/mesh/contracts.d.ts +164 -0
  28. package/dist/mesh/coordinator-registry.d.ts +25 -0
  29. package/dist/mesh/mesh-active-work.d.ts +90 -0
  30. package/dist/mesh/mesh-events.d.ts +77 -5
  31. package/dist/mesh/mesh-fast-forward.d.ts +39 -0
  32. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  33. package/dist/mesh/mesh-ledger.d.ts +58 -1
  34. package/dist/mesh/mesh-refine-status.d.ts +26 -0
  35. package/dist/mesh/mesh-work-queue.d.ts +44 -5
  36. package/dist/mesh/preview-freshness.d.ts +18 -0
  37. package/dist/mesh/refine-config.d.ts +193 -0
  38. package/dist/mesh/worktree-bootstrap-config.d.ts +113 -0
  39. package/dist/providers/approval-utils.d.ts +9 -0
  40. package/dist/providers/cli-provider-instance.d.ts +6 -1
  41. package/dist/providers/contracts.d.ts +50 -5
  42. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  43. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  44. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  45. package/dist/providers/native-history/index.d.ts +11 -0
  46. package/dist/providers/provider-loader.d.ts +19 -1
  47. package/dist/providers/read-chat-contract.d.ts +29 -0
  48. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  49. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
  50. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  51. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +64 -0
  52. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  53. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  54. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  55. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  56. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  57. package/dist/providers/sdk/v1/index.d.ts +30 -0
  58. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  59. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  60. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  61. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  62. package/dist/providers/sdk/v1/types/common/index.d.ts +169 -0
  63. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  64. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  65. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  66. package/dist/providers/transcript-v2.d.ts +176 -0
  67. package/dist/repo-mesh-types.d.ts +67 -0
  68. package/dist/shared-types.d.ts +12 -0
  69. package/dist/status/reporter.d.ts +2 -0
  70. package/dist/status/snapshot.d.ts +1 -0
  71. package/dist/types.d.ts +5 -0
  72. package/package.json +6 -2
  73. package/src/boot/daemon-lifecycle.ts +17 -10
  74. package/src/boot/process-hardening.ts +89 -0
  75. package/src/chat/source-machine.ts +534 -0
  76. package/src/chat/source-resolver.ts +0 -0
  77. package/src/chat/subscription-updates.ts +14 -1
  78. package/src/cli-adapter-types.d.ts +1 -0
  79. package/src/cli-adapter-types.ts +3 -1
  80. package/src/cli-adapters/cli-script-runner.ts +421 -0
  81. package/src/cli-adapters/cli-state-engine.ts +1086 -0
  82. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  83. package/src/cli-adapters/provider-cli-adapter.ts +664 -1137
  84. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  85. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  86. package/src/cli-adapters/provider-cli-runtime.ts +3 -1
  87. package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
  88. package/src/cli-adapters/provider-cli-shared.ts +57 -11
  89. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +17 -1
  90. package/src/cli-adapters/terminal-backends/xterm-backend.ts +8 -1
  91. package/src/commands/chat-commands.ts +1451 -52
  92. package/src/commands/cli-manager.ts +184 -3
  93. package/src/commands/handler.ts +547 -2
  94. package/src/commands/mesh-coordinator.ts +13 -143
  95. package/src/commands/router.ts +3273 -428
  96. package/src/config/chat-history.ts +79 -24
  97. package/src/config/config.ts +12 -0
  98. package/src/config/mesh-config.ts +249 -2
  99. package/src/config/recent-activity.ts +8 -2
  100. package/src/daemon/dev-cli-debug.ts +10 -1
  101. package/src/detection/ide-detector.ts +26 -16
  102. package/src/git/git-commands.ts +17 -5
  103. package/src/git/git-worktree.ts +8 -1
  104. package/src/index.ts +72 -5
  105. package/src/installer.d.ts +1 -1
  106. package/src/installer.ts +8 -6
  107. package/src/ipc/local-ipc-server.ts +278 -0
  108. package/src/launch.d.ts +1 -1
  109. package/src/launch.ts +37 -28
  110. package/src/logging/async-batch-writer.ts +55 -0
  111. package/src/logging/logger.ts +2 -1
  112. package/src/mesh/beads-db.ts +479 -0
  113. package/src/mesh/contracts.ts +329 -0
  114. package/src/mesh/coordinator-prompt.ts +40 -22
  115. package/src/mesh/coordinator-registry.ts +75 -0
  116. package/src/mesh/mesh-active-work.ts +437 -0
  117. package/src/mesh/mesh-events.ts +820 -61
  118. package/src/mesh/mesh-fast-forward.ts +430 -0
  119. package/src/mesh/mesh-host-ownership.ts +73 -0
  120. package/src/mesh/mesh-ledger.ts +457 -104
  121. package/src/mesh/mesh-refine-status.ts +144 -0
  122. package/src/mesh/mesh-work-queue.ts +216 -158
  123. package/src/mesh/preview-freshness.ts +118 -0
  124. package/src/mesh/refine-config.ts +366 -0
  125. package/src/mesh/worktree-bootstrap-config.ts +247 -0
  126. package/src/providers/approval-utils.ts +39 -5
  127. package/src/providers/chat-message-normalization.ts +4 -11
  128. package/src/providers/cli-provider-instance.ts +380 -44
  129. package/src/providers/contracts.ts +52 -5
  130. package/src/providers/ide-provider-instance.ts +17 -3
  131. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  132. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  133. package/src/providers/native-history/codex-cli-transcript.ts +419 -0
  134. package/src/providers/native-history/index.ts +23 -0
  135. package/src/providers/provider-loader.ts +294 -28
  136. package/src/providers/provider-schema.ts +31 -13
  137. package/src/providers/read-chat-contract.ts +76 -16
  138. package/src/providers/sdk/README.md +49 -0
  139. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  140. package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
  141. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  142. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +245 -0
  143. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  144. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  145. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  146. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  147. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  148. package/src/providers/sdk/v1/index.ts +152 -0
  149. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  150. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  151. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  152. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +444 -0
  153. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  154. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  155. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  156. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  157. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  158. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  159. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  160. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  161. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +119 -0
  162. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  163. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  164. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  165. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  166. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  167. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  168. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  169. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  170. package/src/providers/sdk/v1/types/common/index.ts +210 -0
  171. package/src/providers/sdk/v1/validators/index.ts +19 -0
  172. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  173. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  174. package/src/providers/transcript-v2.ts +567 -0
  175. package/src/providers/version-archive.ts +38 -20
  176. package/src/repo-mesh-types.ts +77 -0
  177. package/src/shared-types.ts +9 -0
  178. package/src/status/builders.ts +23 -6
  179. package/src/status/reporter.ts +15 -0
  180. package/src/status/snapshot.ts +35 -11
  181. package/src/system/host-memory.ts +29 -12
  182. 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,48 @@ 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.
177
+
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; }
195
182
 
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;
183
+ /**
184
+ * Recent script invocations (oldest → newest). Exposed via the
185
+ * `get_chat_debug_bundle` daemon command so anyone debugging a
186
+ * stuck-status regression can read what each detectStatus /
187
+ * parseSession call actually saw and returned, instead of having
188
+ * to instrument the daemon.
189
+ */
190
+ getScriptInvocationTrace() {
191
+ return this.runner.getInvocationTrace();
192
+ }
193
+
194
+ /**
195
+ * Returns the full raw PTY byte stream captured since adapter start.
196
+ * Used by the `record_provider_pty` IPC command to produce fixtures.
197
+ * Bounded by MAX_ACCUMULATED_BUFFER; older bytes may have been dropped.
198
+ */
199
+ getAccumulatedRawBuffer(): { text: string; droppedChars: number } {
200
+ return {
201
+ text: this.accumulatedRawBuffer,
202
+ droppedChars: this.accumulatedRawBufferDroppedChars,
203
+ };
204
+ }
205
+ set cliScripts(scripts: CliScripts) { this.setCliScripts(scripts); }
200
206
  private runtimeSettings: Record<string, any> = {};
201
207
  /** Full accumulated rendered PTY transcript for parser/readback use */
202
208
  private accumulatedBuffer: string = '';
@@ -216,10 +222,6 @@ export class ProviderCliAdapter implements CliAdapter {
216
222
  * Hermes turn (tool calls + reasoning + final bubble) without the
217
223
  * rolling window pushing the turn's ╭─ opening line out of view. */
218
224
  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
225
  private parsedStatusCache: {
224
226
  responseBuffer: string;
225
227
  currentTurnScope: TurnParseScope | null;
@@ -233,11 +235,8 @@ export class ProviderCliAdapter implements CliAdapter {
233
235
  result: any;
234
236
  } | null = null;
235
237
  private static readonly SCREEN_SNAPSHOT_MIN_INTERVAL_MS = 250;
236
- private static readonly MAX_TRACE_ENTRIES = 250;
237
238
 
238
239
  private readonly providerResolutionMeta: ProviderResolutionMeta;
239
- private static readonly FINISH_RETRY_DELAY_MS = 300;
240
- private static readonly MAX_FINISH_RETRIES = 2;
241
240
 
242
241
  private getBufferState(): NonNullable<CliSessionStatus['bufferState']> | undefined {
243
242
  const build = (droppedChars: number, maxChars: number) => droppedChars > 0
@@ -313,13 +312,13 @@ export class ProviderCliAdapter implements CliAdapter {
313
312
  if (
314
313
  cached
315
314
  && cached.responseBuffer === this.responseBuffer
316
- && cached.currentTurnScope === this.currentTurnScope
315
+ && cached.currentTurnScope === this.engine.currentTurnScope
317
316
  && cached.recentOutputBuffer === this.recentOutputBuffer
318
317
  && cached.accumulatedBuffer === this.accumulatedBuffer
319
318
  && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
320
319
  && cached.screenText === this.lastScreenText
321
- && cached.currentStatus === this.currentStatus
322
- && cached.activeModal === this.activeModal
320
+ && cached.currentStatus === this.engine.currentStatus
321
+ && cached.activeModal === this.engine.activeModal
323
322
  && cached.cliName === this.cliName
324
323
  ) {
325
324
  return cached.result;
@@ -343,86 +342,6 @@ export class ProviderCliAdapter implements CliAdapter {
343
342
  return this.timeouts.statusActivityHold;
344
343
  }
345
344
 
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
345
  // Resolved timeouts
427
346
  private readonly timeouts: Required<NonNullable<CliProviderModule['timeouts']>>;
428
347
 
@@ -432,7 +351,6 @@ export class ProviderCliAdapter implements CliAdapter {
432
351
  private readonly sendKey: string;
433
352
  private readonly submitStrategy: 'wait_for_echo' | 'immediate';
434
353
  private readonly requirePromptEchoBeforeSubmit: boolean;
435
- private static readonly SCRIPT_STATUS_DEBOUNCE_MS = 3000;
436
354
 
437
355
  constructor(
438
356
  provider: CliProviderModule,
@@ -441,6 +359,7 @@ export class ProviderCliAdapter implements CliAdapter {
441
359
  private extraEnv: Record<string, string> = {},
442
360
  transportFactory: PtyTransportFactory = new NodePtyTransportFactory(),
443
361
  ) {
362
+ this.runner = new CliScriptRunner(provider.type);
444
363
  this.provider = provider;
445
364
  this.transportFactory = transportFactory;
446
365
  this.cliType = provider.type;
@@ -458,10 +377,33 @@ export class ProviderCliAdapter implements CliAdapter {
458
377
  this.requirePromptEchoBeforeSubmit = resolvedConfig.requirePromptEchoBeforeSubmit;
459
378
  this.providerResolutionMeta = resolvedConfig.providerResolutionMeta;
460
379
 
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);
380
+ // State machine engineowns all status transitions
381
+ this.engine = new CliStateEngine(
382
+ provider,
383
+ this.runner,
384
+ this as unknown as CliTransportAccess,
385
+ {
386
+ onStatusChange: () => { this.onStatusChange?.(); },
387
+ onApplyParsedSession: (session) => { this.applyParsedSessionMetadata(session); },
388
+ onTurnCompleted: () => { this.responseBuffer = ''; },
389
+ } satisfies CliStateEngineCallbacks,
390
+ resolvedConfig.timeouts,
391
+ );
392
+
393
+ // Scripts delegated to CliScriptRunner — adapter stays as transport.
394
+ // Pass the manifest tui block so the runner can build the
395
+ // declarativeDetectStatus / declarativeParseApproval SDK functions
396
+ // that v1 extended-tier overrides depend on (without these, scripts
397
+ // like codex-cli's detect_status v1 fail-closed with return 'idle'
398
+ // and the session sticks in `generating` forever).
399
+ this.runner.setScripts(provider.scripts || {}, provider.tui);
400
+ // Per-invocation wall-clock budget for provider scripts. Manifests may
401
+ // raise this for genuinely slow parsers, but the default (50ms) is the
402
+ // settle-loop safety net — any script that exceeds it gets flagged in
403
+ // the invocation trace and surfaced via the debug bundle so we can
404
+ // identify the offender instead of guessing why the settle loop hangs.
405
+ this.runner.setScriptCallBudget(provider.scriptCallBudgetMs ?? 50);
406
+ const scriptNames = this.runner.getScriptNames();
465
407
  if (scriptNames.length > 0) {
466
408
  LOG.info('CLI', `[${this.cliType}] CLI scripts: [${scriptNames.join(', ')}]`);
467
409
  LOG.info(
@@ -487,14 +429,9 @@ export class ProviderCliAdapter implements CliAdapter {
487
429
 
488
430
  /** Inject CLI scripts after construction (e.g. when resolved by ProviderLoader) */
489
431
  setCliScripts(scripts: CliScripts): void {
490
- this.cliScripts = scripts;
432
+ this.runner.setScripts(scripts);
491
433
  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(', ')}]`);
434
+ LOG.info('CLI', `[${this.cliType}] CLI scripts injected: [${this.runner.getScriptNames().join(', ')}]`);
498
435
  }
499
436
 
500
437
  /** Refresh provider scripts/config used by this adapter without restarting the PTY runtime. */
@@ -548,15 +485,6 @@ export class ProviderCliAdapter implements CliAdapter {
548
485
  });
549
486
 
550
487
  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
488
 
561
489
  try {
562
490
  this.ptyProcess = this.transportFactory.spawn(
@@ -620,13 +548,12 @@ export class ProviderCliAdapter implements CliAdapter {
620
548
  this.ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
621
549
  LOG.info('CLI', `[${this.cliType}] Exit code ${exitCode}`);
622
550
  this.flushPendingOutputParse();
623
- this.recordTrace('exit', { exitCode });
624
551
  this.ptyProcess = null;
625
- this.setStatus('stopped', 'pty_exit');
552
+ this.engine.onPtyExit();
626
553
  this.ready = false;
627
554
  this.startupParseGate = false;
628
555
  this.spawnAt = 0;
629
- this.scriptState = null;
556
+ this.runner.resetSessionState();
630
557
  this.onStatusChange?.();
631
558
  });
632
559
 
@@ -637,15 +564,9 @@ export class ProviderCliAdapter implements CliAdapter {
637
564
  if (this.startupSettleTimer) { clearTimeout(this.startupSettleTimer); this.startupSettleTimer = null; }
638
565
  this.resetTerminalScreen(24, 80);
639
566
  this.pendingTerminalQueryTail = '';
640
- this.currentTurnScope = null;
641
- this.finishRetryCount = 0;
642
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
643
567
  this.ready = false;
644
568
  await this.ptyProcess.ready;
645
- this.recordTrace('ready', {
646
- runtimeMeta: this.getRuntimeMetadata(),
647
- });
648
- this.setStatus('starting', 'pty_ready');
569
+ this.engine.onSpawnReady();
649
570
  this.scheduleStartupSettleCheck();
650
571
  this.onStatusChange?.();
651
572
  }
@@ -671,11 +592,11 @@ export class ProviderCliAdapter implements CliAdapter {
671
592
  if (this.startupParseGate && !this.startupFirstOutputAt && (cleanData.trim() || normalizedScreenSnapshot.trim())) {
672
593
  this.startupFirstOutputAt = now;
673
594
  }
674
- if (this.idleFinishCandidate && (rawData.length > 0 || cleanData.length > 0)) {
675
- this.clearIdleFinishCandidate('new_output');
595
+ if (rawData.length > 0 || cleanData.length > 0) {
596
+ this.engine.clearIdleFinishCandidate('new_output');
676
597
  }
677
598
  if (getDebugRuntimeConfig().collectDebugTrace) {
678
- this.recordTrace('output', {
599
+ this.engine.recordExternalTrace('output', {
679
600
  rawLength: rawData.length,
680
601
  cleanLength: cleanData.length,
681
602
  rawPreview: summarizeCliTraceText(rawData, 300),
@@ -687,7 +608,7 @@ export class ProviderCliAdapter implements CliAdapter {
687
608
  this.scheduleStartupSettleCheck();
688
609
  }
689
610
 
690
- if (this.isWaitingForResponse && cleanData) {
611
+ if (this.engine.isWaitingForResponse && cleanData) {
691
612
  const previousResponseLen = this.responseBuffer.length;
692
613
  this.responseBuffer = appendBoundedText(this.responseBuffer, cleanData, ProviderCliAdapter.MAX_RESPONSE_BUFFER);
693
614
  this.responseBufferDroppedChars += this.recordBoundedAppendDrop(previousResponseLen, cleanData.length, this.responseBuffer.length);
@@ -726,19 +647,19 @@ export class ProviderCliAdapter implements CliAdapter {
726
647
  // Keep turn-scope offsets aligned with the truncated buffer so scoped
727
648
  // parses don't lose the beginning of a long turn (e.g. the Hermes
728
649
  // ╭─ opening line) when the rolling window sheds bytes.
729
- if (this.currentTurnScope) {
650
+ if (this.engine.currentTurnScope) {
730
651
  if (droppedClean > 0) {
731
- this.currentTurnScope.bufferStart = Math.max(0, this.currentTurnScope.bufferStart - droppedClean);
652
+ this.engine.currentTurnScope.bufferStart = Math.max(0, this.engine.currentTurnScope.bufferStart - droppedClean);
732
653
  }
733
654
  if (droppedRaw > 0) {
734
- this.currentTurnScope.rawBufferStart = Math.max(0, this.currentTurnScope.rawBufferStart - droppedRaw);
655
+ this.engine.currentTurnScope.rawBufferStart = Math.max(0, this.engine.currentTurnScope.rawBufferStart - droppedRaw);
735
656
  }
736
657
  }
737
658
 
738
659
  this.resolveStartupState('output', screenText, normalizedScreenSnapshot, now);
739
660
 
740
661
  // ─── Script-based status detection
741
- this.scheduleSettle();
662
+ this.engine.scheduleSettle();
742
663
  }
743
664
 
744
665
  private resolveStartupState(
@@ -761,6 +682,11 @@ export class ProviderCliAdapter implements CliAdapter {
761
682
  if (stableMs < 2000) return;
762
683
 
763
684
  const startupModal = this.runParseApproval(this.recentOutputBuffer);
685
+ const startupStatus = this.runDetectStatus(screenText || this.recentOutputBuffer);
686
+ if (!startupModal && startupStatus !== 'idle') {
687
+ this.scheduleStartupSettleCheck();
688
+ return;
689
+ }
764
690
  this.startupParseGate = false;
765
691
  if (this.startupSettleTimer) {
766
692
  clearTimeout(this.startupSettleTimer);
@@ -768,14 +694,22 @@ export class ProviderCliAdapter implements CliAdapter {
768
694
  }
769
695
  this.ready = true;
770
696
  if (startupModal) {
771
- this.activeModal = startupModal;
772
- this.setStatus('waiting_approval', `startup_ready:${trigger}`);
697
+ this.engine.activeModal = startupModal;
698
+ this.engine.setStatus('waiting_approval', `startup_ready:${trigger}`);
773
699
  } else {
774
- if (this.currentStatus === 'waiting_approval' || this.activeModal) {
775
- this.lastApprovalResolvedAt = Date.now();
700
+ if (this.engine.currentStatus === 'waiting_approval' || this.engine.activeModal) {
701
+ this.engine.lastApprovalResolvedAt = Date.now();
776
702
  }
777
- this.activeModal = null;
778
- this.setStatus('idle', `startup_ready:${trigger}`);
703
+ this.engine.activeModal = null;
704
+ // Clear the in-flight turn flag at the same time we declare
705
+ // startup-idle. Otherwise the next settled evaluation sees
706
+ // isWaitingForResponse=true + recent CLI welcome-screen paints
707
+ // and flips us right back to generating via the hold path
708
+ // (the "startup → generating → idle → generating → idle"
709
+ // flicker the user observed on claude-cli launch).
710
+ this.engine.isWaitingForResponse = false;
711
+ this.engine.currentTurnScope = null;
712
+ this.engine.setStatus('idle', `startup_ready:${trigger}`);
779
713
  }
780
714
  LOG.info(
781
715
  'CLI',
@@ -801,88 +735,6 @@ export class ProviderCliAdapter implements CliAdapter {
801
735
  }, delayMs);
802
736
  }
803
737
 
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
738
  private async waitForInteractivePrompt(maxWaitMs = 5000): Promise<void> {
887
739
  const startedAt = Date.now();
888
740
  let loggedWait = false;
@@ -892,7 +744,7 @@ export class ProviderCliAdapter implements CliAdapter {
892
744
  const screenText = this.terminalScreen.getText() || '';
893
745
  const stableMs = this.lastScreenChangeAt ? (Date.now() - this.lastScreenChangeAt) : 0;
894
746
  const recentlyOutput = this.lastNonEmptyOutputAt ? (Date.now() - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
895
- const status = this.runDetectStatus(this.recentOutputBuffer) || this.currentStatus;
747
+ const status = this.runDetectStatus(this.recentOutputBuffer) || this.engine.currentStatus;
896
748
  const interactiveReady = status === 'idle'
897
749
  && stableMs >= 700
898
750
  && recentlyOutput >= 350;
@@ -926,637 +778,84 @@ export class ProviderCliAdapter implements CliAdapter {
926
778
 
927
779
  private clearAllTimers(): void {
928
780
  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
781
  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
782
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
936
783
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
784
+ this.engine.clearAllTimers();
937
785
  }
938
786
 
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;
999
-
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
- });
787
+ // ─── Script dispatch — builds inputs for CliScriptRunner ──────────────────
1020
788
 
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
- }),
789
+ runParseSession(): ParsedSession | null {
790
+ const screenText = this.terminalScreen.getText();
791
+ const parseScreenText = this.getParseScreenText(screenText);
792
+ const tail = this.recentOutputBuffer.slice(-500);
793
+ const input = buildCliParseInput({
794
+ accumulatedBuffer: this.accumulatedBuffer,
795
+ accumulatedRawBuffer: this.accumulatedRawBuffer,
796
+ recentOutputBuffer: this.recentOutputBuffer,
797
+ terminalScreenText: parseScreenText,
798
+ workingDir: this.workingDir,
799
+ providerSessionId: this.providerSessionId || undefined,
800
+ historySessionId: this.providerSessionId || undefined,
801
+ baseMessages: [],
802
+ partialResponse: this.responseBuffer,
803
+ isWaitingForResponse: this.engine.isWaitingForResponse,
804
+ scope: this.engine.currentTurnScope,
805
+ runtimeSettings: this.runtimeSettings,
806
+ spawnAt: this.spawnAt,
1043
807
  });
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
- }),
808
+ const session = this.runner.parseSession({
809
+ ...input,
810
+ tail,
811
+ tailScreen: buildCliScreenSnapshot(tail),
1168
812
  });
1169
- this.onStatusChange?.();
813
+ if (session && typeof session === 'object') this.applyParsedSessionMetadata(session);
814
+ return session;
1170
815
  }
1171
816
 
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
- }),
817
+ runDetectStatus(text: string): string | null {
818
+ const screenText = this.terminalScreen.getText();
819
+ const tail = text.slice(-500);
820
+ return this.runner.detectStatus({
821
+ tail,
822
+ screenText,
823
+ rawBuffer: this.accumulatedRawBuffer,
824
+ isWaitingForResponse: this.engine.isWaitingForResponse,
825
+ screen: buildCliScreenSnapshot(screenText),
826
+ tailScreen: buildCliScreenSnapshot(tail),
1339
827
  });
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
828
  }
1376
829
 
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),
830
+ runParseApproval(tail: string): { message: string; buttons: string[] } | null {
831
+ const screenText = this.terminalScreen.getText();
832
+ const buffer = screenText || this.accumulatedBuffer;
833
+ return this.runner.parseApproval({
834
+ buffer,
835
+ screenText,
836
+ rawBuffer: this.accumulatedRawBuffer,
837
+ tail,
838
+ screen: buildCliScreenSnapshot(screenText),
839
+ bufferScreen: buildCliScreenSnapshot(buffer),
840
+ tailScreen: buildCliScreenSnapshot(tail),
1408
841
  });
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
- }
1511
-
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
842
  }
1530
843
 
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;
844
+ private applyParsedSessionMetadata(parsed: any): void {
845
+ const providerSessionId = typeof parsed?.providerSessionId === 'string' && parsed.providerSessionId.trim()
846
+ ? parsed.providerSessionId.trim()
847
+ : '';
848
+ if (providerSessionId && providerSessionId !== this.providerSessionId) {
849
+ this.providerSessionId = providerSessionId;
850
+ this.updateRuntimeMeta({ providerSessionId });
1548
851
  }
1549
852
  }
1550
853
 
1551
- private hasActionableApproval(startupModal: { message: string; buttons: string[] } | null = null): boolean {
1552
- return !!(startupModal || this.activeModal);
1553
- }
1554
-
1555
854
  private projectEffectiveStatus(startupModal: { message: string; buttons: string[] } | null = null): CliSessionStatus['status'] {
1556
855
  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;
856
+ if (!!(startupModal || this.engine.activeModal)) return 'waiting_approval';
857
+ if (this.engine.isWaitingForResponse && this.engine.currentTurnScope && this.engine.currentStatus !== 'stopped') return 'generating';
858
+ return this.engine.currentStatus;
1560
859
  }
1561
860
 
1562
861
  // ─── Public API (CliAdapter) ───────────────────
@@ -1564,17 +863,73 @@ export class ProviderCliAdapter implements CliAdapter {
1564
863
  getStatus(options: { allowParse?: boolean } = {}): CliSessionStatus {
1565
864
  const allowParse = options.allowParse !== false;
1566
865
  const startupModal = allowParse && this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
866
+ const startupDetectedStatus = allowParse && this.startupParseGate && !startupModal
867
+ ? this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText())
868
+ : null;
1567
869
  let effectiveStatus = this.projectEffectiveStatus(startupModal);
1568
- let effectiveModal = startupModal || this.activeModal;
870
+ let effectiveModal = startupModal || this.engine.activeModal;
871
+ // (fix) When we have no captured modal yet, take one more live attempt
872
+ // with the current screen text — the engine's settle pass can miss
873
+ // the modal when it happens to fire exactly between writes, and
874
+ // without a modal here the dashboard could never show the buttons.
875
+ // We deliberately do NOT overwrite an existing engine.activeModal so
876
+ // a stable matched modal wins. This runs even outside the startup
877
+ // gate because Claude's approval frames can appear long after launch.
878
+ if (allowParse && !effectiveModal && this.engine.isWaitingForResponse) {
879
+ const liveDetect = this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText());
880
+ if (liveDetect === 'waiting_approval') {
881
+ const liveModal = this.runParseApproval(this.terminalScreen.getText())
882
+ || this.runParseApproval(this.recentOutputBuffer);
883
+ // (fix) Only surface modals that have at least one non-empty
884
+ // button. Half-rendered approval frames produced
885
+ // { message, buttons: [] } briefly — auto-approve would then
886
+ // pick buttonIndex=-1 and resolveModal would write the
887
+ // provider's index-0 key into the prompt. Skip those frames
888
+ // and let a later evaluate find the complete modal.
889
+ const buttonsOk = liveModal && Array.isArray(liveModal.buttons)
890
+ && liveModal.buttons.some((b: any) => typeof b === 'string' && b.trim());
891
+ if (liveModal && buttonsOk) {
892
+ effectiveModal = liveModal;
893
+ if (!this.engine.activeModal) this.engine.activeModal = liveModal;
894
+ }
895
+ }
896
+ }
897
+ // Only surface waiting_approval when we ALSO have a concrete modal
898
+ // (message + buttons). detectStatus alone can fire while parseApproval
899
+ // is still null — the engine logs "detectStatus=waiting_approval but
900
+ // parseApproval returned null; ignoring". Without this guard getStatus
901
+ // was shipping a bare waiting_approval with activeModal=null and the
902
+ // user perceived the flow as broken.
903
+ if (startupDetectedStatus === 'waiting_approval' && effectiveModal) {
904
+ effectiveStatus = 'waiting_approval';
905
+ } else if (startupDetectedStatus === 'idle' && !startupModal && !effectiveModal) {
906
+ effectiveStatus = 'idle';
907
+ }
1569
908
  if (allowParse && !startupModal && !effectiveModal) {
1570
909
  const parsed = this.getFreshParsedStatusCache();
1571
910
  const parsedModal = parsed?.activeModal && Array.isArray(parsed.activeModal.buttons)
1572
911
  && parsed.activeModal.buttons.some((button: any) => typeof button === 'string' && button.trim())
1573
912
  ? parsed.activeModal
1574
913
  : null;
914
+ const hasFinalAssistant = (p: any) => {
915
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
916
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
917
+ };
1575
918
  if (parsed?.status === 'waiting_approval' && parsedModal) {
1576
919
  effectiveStatus = 'waiting_approval';
1577
920
  effectiveModal = parsedModal;
921
+ } else if (
922
+ effectiveStatus === 'idle'
923
+ && parsed?.status === 'generating'
924
+ && !hasFinalAssistant(parsed)
925
+ ) {
926
+ effectiveStatus = 'generating';
927
+ } else if (
928
+ effectiveStatus === 'generating'
929
+ && parsed?.status === 'idle'
930
+ && hasFinalAssistant(parsed)
931
+ ) {
932
+ effectiveStatus = 'idle';
1578
933
  }
1579
934
  }
1580
935
  const bufferState = this.getBufferState();
@@ -1583,8 +938,17 @@ export class ProviderCliAdapter implements CliAdapter {
1583
938
  messages: [],
1584
939
  workingDir: this.workingDir,
1585
940
  activeModal: effectiveModal,
1586
- errorMessage: this.parseErrorMessage || undefined,
1587
- errorReason: this.parseErrorMessage ? 'parse_error' : undefined,
941
+ pendingOutboundCount: this.pendingOutboundQueue.length,
942
+ pendingOutboundMessages: this.pendingOutboundQueue.map((message) => ({
943
+ id: message.id,
944
+ role: message.role,
945
+ content: message.content,
946
+ queuedAt: message.queuedAt,
947
+ source: message.source,
948
+ })),
949
+ errorMessage: this.parseErrorMessage || this.engine.providerErrorMessage || undefined,
950
+ errorReason: this.parseErrorMessage ? 'parse_error' : (this.engine.providerErrorReason || undefined),
951
+ providerSessionId: this.providerSessionId || undefined,
1588
952
  ...(bufferState ? { bufferState } : {}),
1589
953
  };
1590
954
  }
@@ -1600,15 +964,16 @@ export class ProviderCliAdapter implements CliAdapter {
1600
964
  const cached = this.parsedStatusCache;
1601
965
  const accumulatedRawBufferKey = this.getAccumulatedRawBufferCacheKey();
1602
966
  if (
1603
- cached
967
+ !this.providerOwnsTranscript()
968
+ && cached
1604
969
  && cached.responseBuffer === this.responseBuffer
1605
- && cached.currentTurnScope === this.currentTurnScope
970
+ && cached.currentTurnScope === this.engine.currentTurnScope
1606
971
  && cached.recentOutputBuffer === this.recentOutputBuffer
1607
972
  && cached.accumulatedBuffer === this.accumulatedBuffer
1608
973
  && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
1609
974
  && cached.screenText === parseScreenText
1610
- && cached.currentStatus === this.currentStatus
1611
- && cached.activeModal === this.activeModal
975
+ && cached.currentStatus === this.engine.currentStatus
976
+ && cached.activeModal === this.engine.activeModal
1612
977
  && cached.cliName === this.cliName
1613
978
  ) {
1614
979
  return cached.result;
@@ -1623,14 +988,20 @@ export class ProviderCliAdapter implements CliAdapter {
1623
988
  const bufferState = this.getBufferState();
1624
989
  const result = {
1625
990
  id: (parsed as any).id || 'cli_session',
1626
- status: parsed.status || this.currentStatus,
991
+ status: parsed.status || this.engine.currentStatus,
1627
992
  title: (parsed as any).title || this.cliName,
1628
993
  messages: normalizeCliParsedMessages(parsed.messages, {
1629
994
  scope: null,
1630
995
  lastOutputAt: this.lastOutputAt,
1631
996
  }),
1632
997
  activeModal,
1633
- providerSessionId: typeof (parsed as any).providerSessionId === 'string' ? (parsed as any).providerSessionId : undefined,
998
+ providerSessionId: this.providerSessionId || (typeof (parsed as any).providerSessionId === 'string' ? (parsed as any).providerSessionId : undefined),
999
+ errorMessage: typeof (parsed as any).errorMessage === 'string' && (parsed as any).errorMessage.trim()
1000
+ ? (parsed as any).errorMessage.trim()
1001
+ : undefined,
1002
+ errorReason: typeof (parsed as any).errorReason === 'string' && (parsed as any).errorReason.trim()
1003
+ ? (parsed as any).errorReason.trim()
1004
+ : undefined,
1634
1005
  ...(bufferState ? { bufferState } : {}),
1635
1006
  ...((parsed as any).transcriptAuthority === 'provider' || (parsed as any).transcriptAuthority === 'daemon'
1636
1007
  ? { transcriptAuthority: (parsed as any).transcriptAuthority }
@@ -1642,13 +1013,13 @@ export class ProviderCliAdapter implements CliAdapter {
1642
1013
 
1643
1014
  this.parsedStatusCache = {
1644
1015
  responseBuffer: this.responseBuffer,
1645
- currentTurnScope: this.currentTurnScope,
1016
+ currentTurnScope: this.engine.currentTurnScope,
1646
1017
  recentOutputBuffer: this.recentOutputBuffer,
1647
1018
  accumulatedBuffer: this.accumulatedBuffer,
1648
1019
  accumulatedRawBufferKey,
1649
1020
  screenText: parseScreenText,
1650
- currentStatus: this.currentStatus,
1651
- activeModal: this.activeModal,
1021
+ currentStatus: this.engine.currentStatus,
1022
+ activeModal: this.engine.activeModal,
1652
1023
  cliName: this.cliName,
1653
1024
  result,
1654
1025
  };
@@ -1656,22 +1027,22 @@ export class ProviderCliAdapter implements CliAdapter {
1656
1027
  }
1657
1028
 
1658
1029
  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
1030
  const input = buildCliParseInput({
1664
1031
  accumulatedBuffer: this.accumulatedBuffer,
1665
1032
  accumulatedRawBuffer: this.accumulatedRawBuffer,
1666
1033
  recentOutputBuffer: this.recentOutputBuffer,
1667
1034
  terminalScreenText: this.getParseScreenText(this.terminalScreen.getText()),
1035
+ workingDir: this.workingDir,
1036
+ providerSessionId: this.providerSessionId || undefined,
1037
+ historySessionId: this.providerSessionId || undefined,
1668
1038
  baseMessages: [],
1669
1039
  partialResponse: this.responseBuffer,
1670
- isWaitingForResponse: this.isWaitingForResponse,
1671
- scope: this.currentTurnScope,
1040
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1041
+ scope: this.engine.currentTurnScope,
1672
1042
  runtimeSettings: this.runtimeSettings,
1043
+ spawnAt: this.spawnAt,
1673
1044
  });
1674
- return await Promise.resolve(this.invokeCliScript(fn, {
1045
+ return await Promise.resolve(this.runner.invokeByName(scriptName, {
1675
1046
  ...input,
1676
1047
  args: args && typeof args === 'object' ? { ...args } : {},
1677
1048
  }));
@@ -1683,7 +1054,7 @@ export class ProviderCliAdapter implements CliAdapter {
1683
1054
 
1684
1055
  /** Whether this adapter has CLI scripts loaded */
1685
1056
  hasCliScripts(): boolean {
1686
- return typeof this.cliScripts?.detectStatus === 'function';
1057
+ return this.runner.hasDetectStatus();
1687
1058
  }
1688
1059
 
1689
1060
  /**
@@ -1692,24 +1063,24 @@ export class ProviderCliAdapter implements CliAdapter {
1692
1063
  */
1693
1064
  async resolveAction(data: any): Promise<void> {
1694
1065
  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
- }
1066
+ try {
1067
+ promptText = this.runner.invokeByName('resolveAction', data);
1068
+ } catch {
1069
+ LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script not available`);
1070
+ return;
1701
1071
  }
1702
1072
  if (!promptText) {
1703
- LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script did not supply a prompt`);
1073
+ LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script did not return a prompt`);
1704
1074
  return;
1705
1075
  }
1706
1076
  await this.sendMessage(promptText);
1707
1077
  }
1708
1078
 
1709
1079
  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;
1080
+ if (!this.ptyProcess || !this.engine.isWaitingForResponse || this.engine.submitRetryUsed) return false;
1081
+ if (this.engine.hasActionableApproval()) return false;
1082
+ // If there's already meaningful response content beyond the echoed prompt, not stuck
1083
+ if (this.hasMeaningfulResponseBufferLocal(normalizedPromptSnippet)) return false;
1713
1084
  const screenText = this.terminalScreen.getText();
1714
1085
  if (!promptLikelyVisible(screenText, normalizedPromptSnippet)) return false;
1715
1086
  const liveApproval = this.runParseApproval(screenText) || this.runParseApproval(this.recentOutputBuffer);
@@ -1718,20 +1089,41 @@ export class ProviderCliAdapter implements CliAdapter {
1718
1089
  return liveStatus !== 'generating' && liveStatus !== 'waiting_approval';
1719
1090
  }
1720
1091
 
1092
+ private hasMeaningfulResponseBufferLocal(promptSnippet: string): boolean {
1093
+ const raw = String(this.responseBuffer || '').trim();
1094
+ if (!raw) return false;
1095
+ const normalizedPrompt = compactPromptText(promptSnippet);
1096
+ if (!normalizedPrompt) return true;
1097
+ const normalizedBuffer = compactPromptText(raw);
1098
+ if (!normalizedBuffer) return false;
1099
+ if (normalizedBuffer === normalizedPrompt) return false;
1100
+ if (normalizedBuffer.startsWith(normalizedPrompt)) {
1101
+ const remainder = normalizedBuffer
1102
+ .slice(normalizedPrompt.length)
1103
+ .replace(/[─═\-]+/g, '')
1104
+ .replace(/⏵⏵accepteditson\([^)]*\)/gi, '')
1105
+ .replace(/accepteditson\([^)]*\)/gi, '')
1106
+ .replace(/(?:◐|◑|◒|◓|◔|◕|◉|●|·)?(?:x?high|medium|low|max)·?\/effort/gi, '')
1107
+ .replace(/updateavailable!run:[a-z0-9:._\-/]+/gi, '')
1108
+ .replace(/esctointerrupt/gi, '')
1109
+ .replace(/❯/g, '')
1110
+ .replace(/^[\s\-–—:;,.!/?]+/, '')
1111
+ .trim();
1112
+ return remainder.length > 0;
1113
+ }
1114
+ return true;
1115
+ }
1116
+
1721
1117
  private async writeToPty(data: string): Promise<void> {
1722
1118
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1723
1119
  await this.ptyProcess.write(data);
1724
1120
  }
1725
1121
 
1726
1122
  private resetPendingSendState(reason: string): void {
1727
- this.isWaitingForResponse = false;
1728
1123
  this.responseBuffer = '';
1729
- this.currentTurnScope = null;
1730
- this.submitPendingUntil = 0;
1731
- this.clearIdleFinishCandidate(reason);
1732
1124
  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; }
1125
+ this.engine.resetActiveTurnState();
1126
+ this.engine.clearIdleFinishCandidate(reason);
1735
1127
  }
1736
1128
 
1737
1129
  private commitSendUserTurn(state: SendMessageState): void {
@@ -1748,42 +1140,14 @@ export class ProviderCliAdapter implements CliAdapter {
1748
1140
  }
1749
1141
  this.responseTimeout = setTimeout(() => {
1750
1142
  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
- });
1143
+ if (!this.engine.isWaitingForResponse) return;
1768
1144
 
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
1145
+ // maxResponse is a watchdog/checkpoint, not a completion signal. Re-run the
1773
1146
  // normal settled parser instead and keep the turn open unless the provider
1774
1147
  // 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
- });
1148
+ this.engine.evaluateSettled(this.getSnapshot());
1149
+
1150
+ if (this.engine.isWaitingForResponse && !this.engine.hasActionableApproval()) {
1787
1151
  this.armResponseTimeout();
1788
1152
  }
1789
1153
  }, timeoutMs);
@@ -1798,34 +1162,20 @@ export class ProviderCliAdapter implements CliAdapter {
1798
1162
  private retrySubmitIfStuck(state: SendMessageState, attempt: number): void {
1799
1163
  this.submitRetryTimer = null;
1800
1164
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
1801
- const screenText = this.terminalScreen.getText();
1802
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1165
+ this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1803
1166
  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
1167
  this.writeSubmitKeyForRetry('submit_retry');
1811
- if (attempt >= 3) { this.submitRetryUsed = true; return; }
1168
+ if (attempt >= 3) { this.engine.submitRetryUsed = true; return; }
1812
1169
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, attempt + 1), state.retryDelayMs);
1813
1170
  }
1814
1171
 
1815
1172
  private retryImmediateSubmitIfStuck(state: SendMessageState): void {
1816
1173
  this.submitRetryTimer = null;
1817
1174
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
1818
- const screenText = this.terminalScreen.getText();
1819
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1175
+ this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1820
1176
  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
1177
  this.writeSubmitKeyForRetry('immediate_retry');
1828
- this.submitRetryUsed = true;
1178
+ this.engine.submitRetryUsed = true;
1829
1179
  }
1830
1180
 
1831
1181
  private submitSendKey(state: SendMessageState, completion: SendMessageCompletion): void {
@@ -1833,13 +1183,7 @@ export class ProviderCliAdapter implements CliAdapter {
1833
1183
  completion.resolveOnce();
1834
1184
  return;
1835
1185
  }
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
- });
1186
+ this.engine.submitPendingUntil = 0;
1843
1187
  void this.writeToPty(this.sendKey).then(() => {
1844
1188
  this.commitSendUserTurn(state);
1845
1189
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, 1), state.retryDelayMs);
@@ -1849,13 +1193,7 @@ export class ProviderCliAdapter implements CliAdapter {
1849
1193
  }
1850
1194
 
1851
1195
  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
- });
1196
+ this.engine.submitPendingUntil = 0;
1859
1197
  void this.writeToPty(state.text + this.sendKey).then(() => {
1860
1198
  this.commitSendUserTurn(state);
1861
1199
  this.submitRetryTimer = setTimeout(() => this.retryImmediateSubmitIfStuck(state), state.retryDelayMs);
@@ -1899,7 +1237,8 @@ export class ProviderCliAdapter implements CliAdapter {
1899
1237
  requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
1900
1238
  screenText: summarizeCliTraceText(screenText, 1000),
1901
1239
  };
1902
- this.recordTrace('submit_echo_missing', diagnostic);
1240
+ LOG.warn('CLI', `[${this.cliType}] submit_echo_missing: ${JSON.stringify(diagnostic)}`);
1241
+
1903
1242
  if (this.requirePromptEchoBeforeSubmit) {
1904
1243
  // At this point the prompt text write already completed. Rejecting without
1905
1244
  // a submit key can leave the delegated CLI with an unsent prompt sitting at
@@ -1924,12 +1263,110 @@ export class ProviderCliAdapter implements CliAdapter {
1924
1263
  ), 50);
1925
1264
  }
1926
1265
 
1927
- async sendMessage(text: string): Promise<void> {
1266
+ async sendMessage(text: string, options: { force?: boolean } = {}): Promise<void> {
1267
+ if (options.force === true) {
1268
+ await this.forceSendMessage(text);
1269
+ return;
1270
+ }
1271
+ await this.sendMessageNow(text, true);
1272
+ }
1273
+
1274
+ async forceSendMessage(text: string): Promise<void> {
1275
+ if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1276
+ const content = String(text || '');
1277
+ if (!content.trim()) return;
1278
+ LOG.info('CLI', `[${this.cliType}] force-sending prompt while status=${this.engine.currentStatus}`);
1279
+ await this.writeToPty(content + this.sendKey);
1280
+ this.onStatusChange?.();
1281
+ }
1282
+
1283
+ private enqueuePendingOutboundMessage(text: string, reason: string): void {
1284
+ const content = String(text || '');
1285
+ const duplicate = this.pendingOutboundQueue.some((message) => message.content === content);
1286
+ if (duplicate) {
1287
+ return;
1288
+ }
1289
+ const queuedAt = Date.now();
1290
+ const message: PendingOutboundMessage = {
1291
+ id: `${queuedAt}:${this.pendingOutboundQueue.length}:${Math.random().toString(36).slice(2, 10)}`,
1292
+ role: 'user',
1293
+ content,
1294
+ queuedAt,
1295
+ source: 'sendMessage',
1296
+ };
1297
+ this.pendingOutboundQueue.push(message);
1298
+ LOG.info('CLI', `[${this.cliType}] queued outbound message while busy (${reason}); queue=${this.pendingOutboundQueue.length}`);
1299
+ this.onStatusChange?.();
1300
+ }
1301
+
1302
+ private shouldQueuePendingOutboundMessage(parsedStatusBeforeSend: any | null = null): string | null {
1303
+ if (this.provider.allowInputDuringGeneration === true) return null;
1304
+ if (this.engine.hasActionableApproval()) return null;
1305
+ const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
1306
+ ? String(parsedStatusBeforeSend.status)
1307
+ : '';
1308
+ const hasFinalAssistant = (p: any) => {
1309
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
1310
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
1311
+ };
1312
+ if (parsedSessionStatus === 'idle' && hasFinalAssistant(parsedStatusBeforeSend)) return null;
1313
+ if (this.engine.currentStatus === 'generating') return 'current_status_generating';
1314
+ if (parsedSessionStatus === 'generating' || parsedSessionStatus === 'long_generating') {
1315
+ const parsedModal = parsedStatusBeforeSend?.activeModal ?? parsedStatusBeforeSend?.modal ?? null;
1316
+ const parsedHasActionableModal = Boolean(
1317
+ parsedModal
1318
+ && Array.isArray(parsedModal.buttons)
1319
+ && parsedModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim()),
1320
+ );
1321
+ const terminalLooksIdle = this.engine.currentStatus === 'idle'
1322
+ && this.runDetectStatus(this.recentOutputBuffer) === 'idle'
1323
+ && !this.engine.isWaitingForResponse
1324
+ && !this.engine.currentTurnScope
1325
+ && !this.engine.hasActionableApproval()
1326
+ && !parsedHasActionableModal;
1327
+ return terminalLooksIdle ? null : `parsed_status_${parsedSessionStatus}`;
1328
+ }
1329
+ if (this.engine.isWaitingForResponse && this.engine.currentTurnScope) return 'active_turn_in_progress';
1330
+ return null;
1331
+ }
1332
+
1333
+ private schedulePendingOutboundFlush(delayMs = 0): void {
1334
+ if (this.pendingOutboundFlushTimer) clearTimeout(this.pendingOutboundFlushTimer);
1335
+ this.pendingOutboundFlushTimer = setTimeout(() => {
1336
+ this.pendingOutboundFlushTimer = null;
1337
+ void this.flushPendingOutboundQueue();
1338
+ }, Math.max(0, delayMs));
1339
+ }
1340
+
1341
+ private async flushPendingOutboundQueue(): Promise<void> {
1342
+ if (this.pendingOutboundFlushInFlight || this.pendingOutboundQueue.length === 0) return;
1343
+ if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) return;
1344
+ this.pendingOutboundFlushInFlight = true;
1345
+ try {
1346
+ while (this.pendingOutboundQueue.length > 0) {
1347
+ if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) break;
1348
+ const next = this.pendingOutboundQueue[0];
1349
+ try {
1350
+ await this.sendMessageNow(next.content, false);
1351
+ this.pendingOutboundQueue.shift();
1352
+ this.onStatusChange?.();
1353
+ } catch (error: any) {
1354
+ LOG.warn('CLI', `[${this.cliType}] queued outbound flush failed: ${error?.message || error}`);
1355
+ this.schedulePendingOutboundFlush(1000);
1356
+ break;
1357
+ }
1358
+ }
1359
+ } finally {
1360
+ this.pendingOutboundFlushInFlight = false;
1361
+ }
1362
+ }
1363
+
1364
+ private async sendMessageNow(text: string, allowQueue: boolean): Promise<void> {
1928
1365
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1929
1366
  const allowInputDuringGeneration = this.provider.allowInputDuringGeneration === true;
1930
1367
  const allowInterventionPrompt = allowInputDuringGeneration
1931
- && this.isWaitingForResponse
1932
- && !this.hasActionableApproval();
1368
+ && this.engine.isWaitingForResponse
1369
+ && !this.engine.hasActionableApproval();
1933
1370
  if (this.startupParseGate) {
1934
1371
  const deadline = Date.now() + 10000;
1935
1372
  while (this.startupParseGate && Date.now() < deadline) {
@@ -1937,27 +1374,33 @@ export class ProviderCliAdapter implements CliAdapter {
1937
1374
  await new Promise(resolve => setTimeout(resolve, 50));
1938
1375
  }
1939
1376
  }
1377
+ const parsedStatusBeforeSend = !allowInputDuringGeneration
1378
+ ? (() => {
1379
+ try {
1380
+ return this.getScriptParsedStatus?.() || null;
1381
+ } catch {
1382
+ return null;
1383
+ }
1384
+ })()
1385
+ : null;
1386
+ const queueReason = this.shouldQueuePendingOutboundMessage(parsedStatusBeforeSend);
1387
+ if (allowQueue && queueReason) {
1388
+ this.enqueuePendingOutboundMessage(text, queueReason);
1389
+ return;
1390
+ }
1940
1391
  if (!allowInterventionPrompt) {
1941
1392
  await this.waitForInteractivePrompt();
1942
1393
  }
1943
1394
  if (!this.ready) {
1944
1395
  this.resolveStartupState('send_precheck');
1945
- if (this.runDetectStatus(this.recentOutputBuffer) === 'idle' && this.currentStatus === 'idle') {
1396
+ if (this.runDetectStatus(this.recentOutputBuffer) === 'idle') {
1946
1397
  this.ready = true;
1947
1398
  this.startupParseGate = false;
1399
+ this.engine.setStatus('idle', 'send_message_idle_prompt_recovery');
1948
1400
  LOG.info('CLI', `[${this.cliType}] sendMessage recovered idle prompt readiness`);
1949
1401
  }
1950
1402
  }
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;
1403
+ if (!this.ready) throw new Error(`${this.cliName} not ready (status: ${this.engine.currentStatus})`);
1961
1404
  const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
1962
1405
  ? String(parsedStatusBeforeSend.status)
1963
1406
  : '';
@@ -1968,45 +1411,48 @@ export class ProviderCliAdapter implements CliAdapter {
1968
1411
  && Array.isArray(parsedModal.buttons)
1969
1412
  && parsedModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim()),
1970
1413
  );
1971
- const terminalLooksIdle = this.currentStatus === 'idle'
1414
+ const terminalLooksIdle = this.engine.currentStatus === 'idle'
1972
1415
  && this.runDetectStatus(this.recentOutputBuffer) === 'idle'
1973
- && !this.isWaitingForResponse
1974
- && !this.currentTurnScope
1975
- && !this.hasActionableApproval()
1416
+ && !this.engine.isWaitingForResponse
1417
+ && !this.engine.currentTurnScope
1418
+ && !this.engine.hasActionableApproval()
1976
1419
  && !parsedHasActionableModal;
1977
1420
  if (!terminalLooksIdle) {
1421
+ if (allowQueue) {
1422
+ this.enqueuePendingOutboundMessage(text, `parsed_status_${parsedSessionStatus}`);
1423
+ return;
1424
+ }
1978
1425
  throw new Error(`${this.cliName} is still processing the previous prompt`);
1979
1426
  }
1980
1427
  }
1981
- if (this.isWaitingForResponse && !allowInputDuringGeneration) {
1982
- if (!this.clearStaleIdleResponseGuard('send_message_guard')) {
1428
+ if (this.engine.isWaitingForResponse && !allowInputDuringGeneration) {
1429
+ const snap = this.getSnapshot();
1430
+ if (
1431
+ !this.engine.clearStaleIdleResponseGuard('send_message_guard', snap)
1432
+ && !this.engine.clearParsedIdleResponseGuard('send_message_parsed_idle_guard', parsedStatusBeforeSend, snap)
1433
+ ) {
1434
+ if (allowQueue) {
1435
+ this.enqueuePendingOutboundMessage(text, 'waiting_for_response');
1436
+ return;
1437
+ }
1983
1438
  throw new Error(`${this.cliName} is still processing the previous prompt`);
1984
1439
  }
1985
1440
  }
1986
- this.isWaitingForResponse = true;
1987
1441
  this.responseBuffer = '';
1988
- this.finishRetryCount = 0;
1989
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
1990
- this.clearIdleFinishCandidate('send_message');
1991
- this.currentTurnScope = {
1442
+ const turnScope: TurnParseScope = {
1992
1443
  prompt: text,
1993
1444
  startedAt: Date.now(),
1994
1445
  bufferStart: this.accumulatedBuffer.length,
1995
1446
  rawBufferStart: this.accumulatedRawBuffer.length,
1996
1447
  };
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);
1448
+ LOG.info('CLI', `[${this.cliType}] sendMessage turn scope buffer=${turnScope.bufferStart} raw=${turnScope.rawBufferStart} prompt=${JSON.stringify(text).slice(0, 120)}`);
2006
1449
  if (this.submitRetryTimer) {
2007
1450
  clearTimeout(this.submitRetryTimer);
2008
1451
  this.submitRetryTimer = null;
2009
1452
  }
1453
+ this.engine.onTurnStarted(turnScope);
1454
+ this.engine.submitRetryPromptSnippet = extractPromptRetrySnippet(text);
1455
+ const normalizedPromptSnippet = normalizePromptText(this.engine.submitRetryPromptSnippet);
2010
1456
  const estimatedLines = estimatePromptDisplayLines(text);
2011
1457
  const submitDelayMs = this.sendDelayMs + Math.min(2000, Math.max(0, estimatedLines - 1) * 350);
2012
1458
  const maxEchoWaitMs = submitDelayMs + Math.max(1500, Math.min(5000, estimatedLines * 500));
@@ -2019,12 +1465,7 @@ export class ProviderCliAdapter implements CliAdapter {
2019
1465
  retryDelayMs,
2020
1466
  didCommitUserTurn: false,
2021
1467
  };
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;
1468
+ this.engine.responseSettleIgnoreUntil = Date.now() + submitDelayMs + this.timeouts.outputSettle + 250;
2028
1469
  await new Promise<void>((resolve, reject) => {
2029
1470
  let resolved = false;
2030
1471
  const completion: SendMessageCompletion = {
@@ -2047,24 +1488,20 @@ export class ProviderCliAdapter implements CliAdapter {
2047
1488
  }
2048
1489
 
2049
1490
  if (submitDelayMs > 0) {
2050
- this.submitPendingUntil = Date.now() + submitDelayMs;
1491
+ this.engine.submitPendingUntil = Date.now() + submitDelayMs;
2051
1492
  }
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
1493
  const submitStartedAt = Date.now();
2059
1494
  void this.writeToPty(text).then(
2060
1495
  () => this.waitForEchoAndSubmit(sendState, completion, submitStartedAt),
2061
1496
  completion.rejectOnce,
2062
1497
  );
2063
1498
  });
1499
+ // Schedule settle after successful send
1500
+ this.engine.scheduleSettle();
2064
1501
  }
2065
1502
 
2066
1503
  getPartialResponse(): string {
2067
- if (!this.isWaitingForResponse) return '';
1504
+ if (!this.engine.isWaitingForResponse) return '';
2068
1505
  return this.responseBuffer;
2069
1506
  }
2070
1507
 
@@ -2077,10 +1514,10 @@ export class ProviderCliAdapter implements CliAdapter {
2077
1514
  cliType: this.cliType,
2078
1515
  cliName: this.cliName,
2079
1516
  workingDir: this.workingDir,
2080
- currentStatus: this.currentStatus,
1517
+ currentStatus: this.engine.currentStatus,
2081
1518
  ready: this.ready,
2082
- isWaitingForResponse: this.isWaitingForResponse,
2083
- activeModal: this.activeModal,
1519
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1520
+ activeModal: this.engine.activeModal,
2084
1521
  parseErrorMessage: this.parseErrorMessage,
2085
1522
  messageCounts: {
2086
1523
  parsedCache: Array.isArray(parsedResult?.messages) ? parsedResult.messages.length : undefined,
@@ -2106,10 +1543,10 @@ export class ProviderCliAdapter implements CliAdapter {
2106
1543
  lastScreenSnapshotReadAt: this.lastScreenSnapshotReadAt,
2107
1544
  },
2108
1545
  parser: {
2109
- scriptNames: listCliScriptNames(this.cliScripts),
2110
- traceSessionId: this.traceSessionId,
2111
- traceSeq: this.traceSeq,
2112
- currentTurnScope: this.currentTurnScope,
1546
+ scriptNames: this.runner.getScriptNames(),
1547
+ traceSessionId: this.engine.getTraceSessionId(),
1548
+ traceSeq: this.engine.getTraceEntries().length,
1549
+ currentTurnScope: this.engine.currentTurnScope,
2113
1550
  parsedStatusCache: parsedResult
2114
1551
  ? {
2115
1552
  id: parsedResult.id,
@@ -2122,26 +1559,25 @@ export class ProviderCliAdapter implements CliAdapter {
2122
1559
  activeModal: parsedResult.activeModal,
2123
1560
  }
2124
1561
  : null,
2125
- pendingScriptStatus: this.pendingScriptStatus,
2126
- pendingScriptStatusSince: this.pendingScriptStatusSince,
1562
+ pendingScriptStatus: this.engine.pendingScriptStatus,
1563
+ pendingScriptStatusSince: this.engine.pendingScriptStatusSince,
2127
1564
  },
2128
1565
  runtimeMetadata: this.getRuntimeMetadata(),
2129
- statusHistory: this.statusHistory.slice(-80),
2130
- traceEntries: this.traceEntries.slice(-120),
1566
+ statusHistory: this.engine.getStatusHistory().slice(-80),
1567
+ traceEntries: this.engine.getTraceEntries().slice(-120),
2131
1568
  timing: {
2132
1569
  spawnAt: this.spawnAt,
2133
1570
  startupFirstOutputAt: this.startupFirstOutputAt,
2134
- submitPendingUntil: this.submitPendingUntil,
2135
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
2136
- responseEpoch: this.responseEpoch,
1571
+ submitPendingUntil: this.engine.submitPendingUntil,
1572
+ responseSettleIgnoreUntil: this.engine.responseSettleIgnoreUntil,
1573
+ responseEpoch: this.engine.responseEpoch,
2137
1574
  resizeSuppressUntil: this.resizeSuppressUntil,
2138
- lastApprovalResolvedAt: this.lastApprovalResolvedAt,
1575
+ lastApprovalResolvedAt: this.engine.lastApprovalResolvedAt,
2139
1576
  },
2140
1577
  finish: {
2141
- idleFinishCandidate: this.idleFinishCandidate,
2142
- finishRetryCount: this.finishRetryCount,
2143
- submitRetryUsed: this.submitRetryUsed,
2144
- submitRetryPromptSnippet: this.submitRetryPromptSnippet,
1578
+ finishRetryCount: this.engine.finishRetryCount,
1579
+ submitRetryUsed: this.engine.submitRetryUsed,
1580
+ submitRetryPromptSnippet: this.engine.submitRetryPromptSnippet,
2145
1581
  },
2146
1582
  };
2147
1583
  }
@@ -2152,6 +1588,12 @@ export class ProviderCliAdapter implements CliAdapter {
2152
1588
  }
2153
1589
 
2154
1590
  updateRuntimeMeta(meta: Record<string, unknown>, replace = false): void {
1591
+ const nextProviderSessionId = typeof meta?.providerSessionId === 'string'
1592
+ ? meta.providerSessionId.trim()
1593
+ : '';
1594
+ if (nextProviderSessionId) {
1595
+ this.providerSessionId = nextProviderSessionId;
1596
+ }
2155
1597
  if (!this.ptyProcess || typeof this.ptyProcess.updateMeta !== 'function') return;
2156
1598
  this.ptyProcess.updateMeta(meta, replace);
2157
1599
  }
@@ -2172,7 +1614,7 @@ export class ProviderCliAdapter implements CliAdapter {
2172
1614
  this.timeouts.shutdownGrace,
2173
1615
  typeof resume.shutdownGraceMs === 'number' ? resume.shutdownGraceMs : 3000,
2174
1616
  );
2175
- const wasProcessing = this.currentStatus === 'generating' || this.currentStatus === 'waiting_approval';
1617
+ const wasProcessing = this.engine.currentStatus === 'generating' || this.engine.currentStatus === 'waiting_approval';
2176
1618
 
2177
1619
  try {
2178
1620
  if (wasProcessing) {
@@ -2210,7 +1652,7 @@ export class ProviderCliAdapter implements CliAdapter {
2210
1652
  return new Promise((resolve) => {
2211
1653
  const startedAt = Date.now();
2212
1654
  const timer = setInterval(() => {
2213
- if (!this.ptyProcess || this.currentStatus === 'stopped') {
1655
+ if (!this.ptyProcess || this.engine.currentStatus === 'stopped') {
2214
1656
  clearInterval(timer);
2215
1657
  resolve(true);
2216
1658
  return;
@@ -2224,18 +1666,20 @@ export class ProviderCliAdapter implements CliAdapter {
2224
1666
  }
2225
1667
 
2226
1668
  shutdown(): void {
2227
- this.clearIdleFinishCandidate('shutdown');
1669
+ this.engine.clearIdleFinishCandidate('shutdown');
2228
1670
  this.clearAllTimers();
2229
1671
  this.pendingOutputParseChunks = [];
2230
1672
  this.pendingTerminalQueryTail = '';
2231
1673
  this.ptyOutputChunks = [];
2232
- this.finishRetryCount = 0;
1674
+ if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
1675
+ this.pendingOutboundQueue = [];
1676
+ this.pendingOutboundFlushInFlight = false;
2233
1677
  if (this.ptyProcess) {
2234
1678
  this.ptyProcess.write('\x03');
2235
1679
  setTimeout(() => {
2236
1680
  try { this.ptyProcess?.kill(); } catch { }
2237
1681
  this.ptyProcess = null;
2238
- this.setStatus('stopped', 'stop_cmd');
1682
+ this.engine.setStatus('stopped', 'stop_cmd');
2239
1683
  this.ready = false;
2240
1684
  this.startupParseGate = false;
2241
1685
  this.spawnAt = 0;
@@ -2245,12 +1689,14 @@ export class ProviderCliAdapter implements CliAdapter {
2245
1689
  }
2246
1690
 
2247
1691
  detach(): void {
2248
- this.clearIdleFinishCandidate('detach');
1692
+ this.engine.clearIdleFinishCandidate('detach');
2249
1693
  this.clearAllTimers();
2250
1694
  this.pendingOutputParseChunks = [];
2251
1695
  this.pendingTerminalQueryTail = '';
2252
1696
  this.ptyOutputChunks = [];
2253
- this.finishRetryCount = 0;
1697
+ if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
1698
+ this.pendingOutboundQueue = [];
1699
+ this.pendingOutboundFlushInFlight = false;
2254
1700
  if (this.ptyProcess) {
2255
1701
  try {
2256
1702
  if (typeof this.ptyProcess.detach === 'function') {
@@ -2268,82 +1714,128 @@ export class ProviderCliAdapter implements CliAdapter {
2268
1714
  }
2269
1715
 
2270
1716
  clearHistory(): void {
2271
- this.clearIdleFinishCandidate('clear_history');
1717
+ this.engine.clearIdleFinishCandidate('clear_history');
2272
1718
  this.accumulatedBuffer = '';
2273
1719
  this.accumulatedRawBuffer = '';
2274
- this.currentTurnScope = null;
2275
- this.submitRetryUsed = false;
2276
- this.submitRetryPromptSnippet = '';
1720
+ this.engine.currentTurnScope = null;
1721
+ this.engine.submitRetryUsed = false;
1722
+ this.engine.submitRetryPromptSnippet = '';
2277
1723
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
2278
1724
  this.pendingOutputParseChunks = [];
2279
1725
  this.pendingTerminalQueryTail = '';
2280
1726
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
2281
1727
  this.ptyOutputChunks = [];
2282
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
2283
- this.finishRetryCount = 0;
1728
+ if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
1729
+ this.pendingOutboundQueue = [];
1730
+ this.pendingOutboundFlushInFlight = false;
2284
1731
  this.resetTerminalScreen();
2285
1732
  this.ptyProcess?.clearBuffer?.();
2286
1733
  this.onStatusChange?.();
2287
1734
  }
2288
1735
 
2289
- isProcessing(): boolean { return this.isWaitingForResponse; }
1736
+ isProcessing(): boolean { return this.engine.isWaitingForResponse; }
2290
1737
  isReady(): boolean { return this.ready; }
2291
1738
 
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);
1739
+ // ─── State machine property accessors (delegate to engine) ──────────────
1740
+ // These expose engine state for external callers (tests, debug tools, etc.)
1741
+
1742
+ get currentStatus(): CliSessionStatus['status'] { return this.engine.currentStatus; }
1743
+ set currentStatus(v: CliSessionStatus['status']) { this.engine.setStatus(v); }
1744
+
1745
+ get isWaitingForResponse(): boolean { return this.engine.isWaitingForResponse; }
1746
+ set isWaitingForResponse(v: boolean) { this.engine.isWaitingForResponse = v; }
1747
+
1748
+ get activeModal(): { message: string; buttons: string[] } | null { return this.engine.activeModal; }
1749
+ set activeModal(v: { message: string; buttons: string[] } | null) { this.engine.activeModal = v; }
1750
+
1751
+ get currentTurnScope(): TurnParseScope | null { return this.engine.currentTurnScope; }
1752
+ set currentTurnScope(v: TurnParseScope | null) { this.engine.currentTurnScope = v; }
1753
+
1754
+ get responseEpoch(): number { return this.engine.responseEpoch; }
1755
+ set responseEpoch(v: number) { this.engine.responseEpoch = v; }
1756
+
1757
+ get submitRetryUsed(): boolean { return this.engine.submitRetryUsed; }
1758
+ set submitRetryUsed(v: boolean) { this.engine.submitRetryUsed = v; }
1759
+
1760
+ get submitRetryPromptSnippet(): string { return this.engine.submitRetryPromptSnippet; }
1761
+ set submitRetryPromptSnippet(v: string) { this.engine.submitRetryPromptSnippet = v; }
1762
+
1763
+ get responseSettleIgnoreUntil(): number { return this.engine.responseSettleIgnoreUntil; }
1764
+ set responseSettleIgnoreUntil(v: number) { this.engine.responseSettleIgnoreUntil = v; }
1765
+
1766
+ get submitPendingUntil(): number { return this.engine.submitPendingUntil; }
1767
+ set submitPendingUntil(v: number) { this.engine.submitPendingUntil = v; }
1768
+
1769
+ get lastApprovalResolvedAt(): number { return this.engine.lastApprovalResolvedAt; }
1770
+ set lastApprovalResolvedAt(v: number) { this.engine.lastApprovalResolvedAt = v; }
1771
+
1772
+ get providerErrorMessage(): string | null { return this.engine.providerErrorMessage; }
1773
+ get providerErrorReason(): string | null { return this.engine.providerErrorReason; }
1774
+
1775
+ get pendingScriptStatus(): 'generating' | 'waiting_approval' | null { return this.engine.pendingScriptStatus; }
1776
+ get pendingScriptStatusSince(): number { return this.engine.pendingScriptStatusSince; }
1777
+
1778
+ get finishRetryCount(): number { return this.engine.finishRetryCount; }
1779
+ set finishRetryCount(v: number) { this.engine.finishRetryCount = v; }
1780
+
1781
+ get traceSessionId(): string { return this.engine.getTraceSessionId(); }
1782
+ get traceEntries(): CliTraceEntry[] { return this.engine.getTraceEntries(); }
1783
+ get statusHistory(): { status: string; at: number; trigger?: string }[] { return this.engine.getStatusHistory(); }
1784
+ get traceSeq(): number { return this.engine.getTraceEntries().length; }
1785
+
1786
+ /** Expose engine's evaluateSettled for test access */
1787
+ evaluateSettled(): void {
1788
+ LOG.debug(
1789
+ 'CLI',
1790
+ `[${this.cliType}] settled diagnostics delegated to state engine`);
1791
+ this.engine.evaluateSettled(this.getSnapshot());
1792
+ }
1793
+ /** Expose engine's scheduleSettle for test access */
1794
+ scheduleSettle(): void { this.engine.scheduleSettle(); }
1795
+ /** Expose engine's clearIdleFinishCandidate for test access */
1796
+ clearIdleFinishCandidate(reason: string): void { this.engine.clearIdleFinishCandidate(reason); }
1797
+ /** Expose engine's finishResponse for test access */
1798
+ finishResponse(): void { this.engine.finishResponse(); }
1799
+ /** Returns a point-in-time snapshot of all buffer/screen state for external consumers (e.g. CliStateEngine). */
1800
+ getSnapshot(): CliBufferSnapshot {
1801
+ const screenText = this.terminalScreen.getText() || '';
1802
+ return {
1803
+ accumulatedBuffer: this.accumulatedBuffer,
1804
+ accumulatedRawBuffer: this.accumulatedRawBuffer,
1805
+ recentOutputBuffer: this.recentOutputBuffer,
1806
+ responseBuffer: this.responseBuffer,
1807
+ screenText,
1808
+ parseScreenText: this.getParseScreenText(screenText),
1809
+ workingDir: this.workingDir,
1810
+ providerSessionId: this.providerSessionId,
1811
+ runtimeSettings: this.runtimeSettings,
1812
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1813
+ currentTurnScope: this.engine.currentTurnScope,
1814
+ lastOutputAt: this.lastOutputAt,
1815
+ lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
1816
+ lastScreenChangeAt: this.lastScreenChangeAt,
1817
+ lastScreenSnapshot: this.lastScreenSnapshot,
1818
+ };
1819
+ }
1820
+ isAlive(): boolean { return this.ptyProcess !== null; }
1821
+ flushOutboundQueue(): void { this.schedulePendingOutboundFlush(); }
1822
+
1823
+ async writeRaw(data: string | Buffer): Promise<void> {
1824
+ const str = Buffer.isBuffer(data) ? data.toString('utf8') : data;
1825
+ await this.writeToPty(str);
2298
1826
  }
2299
1827
 
2300
1828
  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
- }
1829
+ this.engine.resolveModal(buttonIndex);
1830
+ }
1831
+
1832
+ getApprovalKeyForIndex(buttonIndex: number): string | undefined {
1833
+ return buttonIndex in this.approvalKeys ? this.approvalKeys[buttonIndex] : undefined;
1834
+ }
1835
+
1836
+ /** Returns true if an approval was resolved within the adapter's cooldown window. */
1837
+ isApprovalRecentlyResolved(): boolean {
1838
+ return this.engine.isApprovalRecentlyResolved();
2347
1839
  }
2348
1840
 
2349
1841
  resize(cols: number, rows: number): void {
@@ -2357,7 +1849,7 @@ export class ProviderCliAdapter implements CliAdapter {
2357
1849
  }
2358
1850
 
2359
1851
  private getParsedDebugState(): Record<string, any> | null {
2360
- if (this.startupParseGate || typeof this.cliScripts?.parseSession !== 'function') return null;
1852
+ if (this.startupParseGate || !this.runner.hasParseSession()) return null;
2361
1853
  try {
2362
1854
  const parsed = this.getScriptParsedStatus();
2363
1855
  return parsed && typeof parsed === 'object' ? parsed as Record<string, any> : null;
@@ -2369,18 +1861,43 @@ export class ProviderCliAdapter implements CliAdapter {
2369
1861
  getDebugState(): Record<string, any> {
2370
1862
  const screenText = sanitizeTerminalText(this.terminalScreen.getText());
2371
1863
  const startupModal = this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
2372
- const effectiveStatus = this.projectEffectiveStatus(startupModal);
2373
- const effectiveReady = this.ready || !!startupModal;
1864
+ const startupDetectedStatus = this.startupParseGate && !startupModal
1865
+ ? this.runDetectStatus(this.recentOutputBuffer || screenText)
1866
+ : null;
1867
+ const effectiveReady = this.ready || !!startupModal || startupDetectedStatus === 'waiting_approval';
2374
1868
  const parsedDebugState = this.getParsedDebugState();
2375
1869
  const parsedMessages = Array.isArray(parsedDebugState?.messages) ? parsedDebugState.messages : [];
1870
+ const hasFinalAssistant = (p: any) => {
1871
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
1872
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
1873
+ };
1874
+ let effectiveStatus = this.projectEffectiveStatus(startupModal);
1875
+ if (parsedDebugState?.status === 'error') {
1876
+ effectiveStatus = 'error';
1877
+ }
1878
+ // (fix) Mirror the getStatus contract: never surface waiting_approval
1879
+ // without a concrete modal. detectStatus alone can fire while
1880
+ // parseApproval is still null, and a bare debugState waiting_approval
1881
+ // confused dashboards reading the debug payload.
1882
+ const debugEffectiveModal = startupModal || this.engine.activeModal;
1883
+ if (startupDetectedStatus === 'waiting_approval' && debugEffectiveModal) {
1884
+ effectiveStatus = 'waiting_approval';
1885
+ }
1886
+ if (
1887
+ effectiveStatus === 'idle'
1888
+ && parsedDebugState?.status === 'generating'
1889
+ && !hasFinalAssistant(parsedDebugState)
1890
+ ) {
1891
+ effectiveStatus = 'generating';
1892
+ }
2376
1893
  return {
2377
1894
  type: this.cliType,
2378
1895
  name: this.cliName,
2379
1896
  providerResolution: this.providerResolutionMeta,
2380
1897
  status: effectiveStatus,
2381
1898
  projectedStatus: effectiveStatus,
2382
- rawStatus: this.currentStatus,
2383
- lifecycleStatus: this.isWaitingForResponse ? 'awaiting_response' : 'idle',
1899
+ rawStatus: this.engine.currentStatus,
1900
+ lifecycleStatus: this.engine.isWaitingForResponse ? 'awaiting_response' : 'idle',
2384
1901
  ready: effectiveReady,
2385
1902
  startupParseGate: this.startupParseGate,
2386
1903
  spawnAt: this.spawnAt,
@@ -2394,38 +1911,47 @@ export class ProviderCliAdapter implements CliAdapter {
2394
1911
  providerSessionId: parsedDebugState.providerSessionId,
2395
1912
  transcriptAuthority: parsedDebugState.transcriptAuthority,
2396
1913
  coverage: parsedDebugState.coverage,
1914
+ errorMessage: parsedDebugState.errorMessage,
1915
+ errorReason: parsedDebugState.errorReason,
2397
1916
  activeModal: parsedDebugState.activeModal,
2398
1917
  messageCount: parsedMessages.length,
2399
1918
  } : null,
2400
1919
  screenText: screenText.slice(-4000),
2401
- currentTurnScope: this.currentTurnScope,
1920
+ currentTurnScope: this.engine.currentTurnScope,
2402
1921
  startupBuffer: this.startupBuffer.slice(-4000),
2403
1922
  recentOutputBuffer: this.recentOutputBuffer.slice(-500),
2404
- settledBuffer: this.settledBuffer.slice(-500),
2405
1923
  accumulatedBufferLength: this.accumulatedBuffer.length,
2406
1924
  accumulatedRawBufferLength: this.accumulatedRawBuffer.length,
2407
1925
  rawBufferPreview: this.accumulatedRawBuffer.slice(-1000),
2408
1926
  sanitizedRawPreview: sanitizeTerminalText(this.accumulatedRawBuffer).slice(-1000),
2409
1927
  responseBuffer: this.responseBuffer.slice(-1000),
1928
+ pendingOutboundQueue: this.pendingOutboundQueue.map((message) => ({
1929
+ id: message.id,
1930
+ role: message.role,
1931
+ content: message.content,
1932
+ queuedAt: message.queuedAt,
1933
+ source: message.source,
1934
+ })),
1935
+ pendingOutboundCount: this.pendingOutboundQueue.length,
2410
1936
  lastOutputAt: this.lastOutputAt,
2411
1937
  lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
2412
1938
  lastScreenChangeAt: this.lastScreenChangeAt,
2413
1939
  lastScreenSnapshot: this.lastScreenSnapshot.slice(-500),
2414
- isWaitingForResponse: this.isWaitingForResponse,
2415
- activeModal: startupModal || this.activeModal,
2416
- lastApprovalResolvedAt: this.lastApprovalResolvedAt,
1940
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1941
+ activeModal: startupModal || this.engine.activeModal,
1942
+ lastApprovalResolvedAt: this.engine.lastApprovalResolvedAt,
2417
1943
  sendDelayMs: this.sendDelayMs,
2418
1944
  sendKey: this.sendKey,
2419
1945
  submitStrategy: this.submitStrategy,
2420
1946
  requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
2421
- submitPendingUntil: this.submitPendingUntil,
2422
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
1947
+ submitPendingUntil: this.engine.submitPendingUntil,
1948
+ responseSettleIgnoreUntil: this.engine.responseSettleIgnoreUntil,
2423
1949
  resizeSuppressUntil: this.resizeSuppressUntil,
2424
1950
  hasCliScripts: this.hasCliScripts(),
2425
- scriptNames: listCliScriptNames(this.cliScripts),
2426
- traceSessionId: this.traceSessionId,
2427
- traceEntryCount: this.traceEntries.length,
2428
- statusHistory: this.statusHistory.slice(-30),
1951
+ scriptNames: this.runner.getScriptNames(),
1952
+ traceSessionId: this.engine.getTraceSessionId(),
1953
+ traceEntryCount: this.engine.getTraceEntries().length,
1954
+ statusHistory: this.engine.getStatusHistory().slice(-30),
2429
1955
  timeouts: this.timeouts,
2430
1956
  pendingOutputParseBufferLength: this.pendingOutputParseChunks.reduce((total, chunk) => total + chunk.length, 0),
2431
1957
  pendingOutputParseScheduled: !!this.pendingOutputParseTimer,
@@ -2435,20 +1961,21 @@ export class ProviderCliAdapter implements CliAdapter {
2435
1961
 
2436
1962
  getTraceState(limit = 120): Record<string, any> {
2437
1963
  const cappedLimit = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 120));
1964
+ const traceEntries = this.engine.getTraceEntries();
2438
1965
  return {
2439
- sessionId: this.traceSessionId,
1966
+ sessionId: this.engine.getTraceSessionId(),
2440
1967
  providerResolution: this.providerResolutionMeta,
2441
- entryCount: this.traceEntries.length,
2442
- entries: this.traceEntries.slice(-cappedLimit),
1968
+ entryCount: traceEntries.length,
1969
+ entries: traceEntries.slice(-cappedLimit),
2443
1970
  screenText: summarizeCliTraceText(this.terminalScreen.getText(), 4000),
2444
1971
  recentOutputBuffer: summarizeCliTraceText(this.recentOutputBuffer, 1000),
2445
1972
  responseBuffer: summarizeCliTraceText(this.responseBuffer, 1200),
2446
1973
  status: this.projectEffectiveStatus(),
2447
1974
  projectedStatus: this.projectEffectiveStatus(),
2448
- rawStatus: this.currentStatus,
2449
- lifecycleStatus: this.isWaitingForResponse ? 'awaiting_response' : 'idle',
2450
- activeModal: this.activeModal,
2451
- currentTurnScope: this.currentTurnScope,
1975
+ rawStatus: this.engine.currentStatus,
1976
+ lifecycleStatus: this.engine.isWaitingForResponse ? 'awaiting_response' : 'idle',
1977
+ activeModal: this.engine.activeModal,
1978
+ currentTurnScope: this.engine.currentTurnScope,
2452
1979
  messages: [],
2453
1980
  };
2454
1981
  }