@adhdev/daemon-core 0.9.82-rc.136 → 0.9.82-rc.137
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-adapters/cli-script-runner.d.ts +45 -0
- package/dist/cli-adapters/cli-state-engine.d.ts +154 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +73 -74
- package/dist/cli-adapters/provider-cli-shared.d.ts +4 -0
- package/dist/config/chat-history.d.ts +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2591 -1966
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2594 -1974
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/beads-db.d.ts +54 -0
- package/dist/mesh/mesh-active-work.d.ts +7 -1
- package/dist/mesh/mesh-events.d.ts +10 -4
- package/dist/mesh/mesh-ledger.d.ts +21 -1
- package/dist/mesh/mesh-refine-status.d.ts +2 -3
- package/dist/mesh/mesh-work-queue.d.ts +17 -0
- package/dist/mesh/worktree-bootstrap-config.d.ts +2 -4
- package/dist/repo-mesh-types.d.ts +5 -0
- package/package.json +1 -1
- package/src/cli-adapters/cli-script-runner.ts +145 -0
- package/src/cli-adapters/cli-state-engine.ts +957 -0
- package/src/cli-adapters/provider-cli-adapter.d.ts +0 -1
- package/src/cli-adapters/provider-cli-adapter.ts +365 -1397
- package/src/cli-adapters/provider-cli-shared.ts +4 -0
- package/src/commands/chat-commands.ts +17 -1
- package/src/commands/router.ts +8 -0
- package/src/config/chat-history.ts +7 -3
- package/src/git/git-worktree.ts +8 -1
- package/src/index.ts +3 -2
- package/src/mesh/beads-db.ts +305 -2
- package/src/mesh/coordinator-prompt.ts +12 -17
- package/src/mesh/mesh-active-work.ts +162 -59
- package/src/mesh/mesh-events.ts +198 -53
- package/src/mesh/mesh-ledger.ts +321 -105
- package/src/mesh/mesh-refine-status.ts +2 -3
- package/src/mesh/mesh-work-queue.ts +116 -120
- package/src/mesh/worktree-bootstrap-config.ts +17 -4
- package/src/providers/provider-schema.ts +2 -0
- package/src/repo-mesh-types.ts +10 -0
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CliStateEngine — CLI provider status state machine
|
|
3
|
+
*
|
|
4
|
+
* Owns all status-transition logic, timer management, and script-driven
|
|
5
|
+
* evaluation. Reads buffer state from the transport and writes to PTY via
|
|
6
|
+
* the transport interface — adapter stays as pure I/O layer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { LOG } from '../logging/logger.js';
|
|
10
|
+
import {
|
|
11
|
+
buildCliParseInput,
|
|
12
|
+
normalizeCliParsedMessages,
|
|
13
|
+
type TurnParseScope,
|
|
14
|
+
} from './provider-cli-parse.js';
|
|
15
|
+
import {
|
|
16
|
+
buildCliScreenSnapshot,
|
|
17
|
+
compactPromptText,
|
|
18
|
+
normalizePromptText,
|
|
19
|
+
promptLikelyVisible,
|
|
20
|
+
type CliChatMessage,
|
|
21
|
+
type CliProviderModule,
|
|
22
|
+
type CliSessionStatus,
|
|
23
|
+
type CliTraceEntry,
|
|
24
|
+
type ParsedSession,
|
|
25
|
+
} from './provider-cli-shared.js';
|
|
26
|
+
import type { CliScriptRunner } from './cli-script-runner.js';
|
|
27
|
+
|
|
28
|
+
// ─── Types ─────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export interface CliBufferSnapshot {
|
|
31
|
+
accumulatedBuffer: string;
|
|
32
|
+
accumulatedRawBuffer: string;
|
|
33
|
+
recentOutputBuffer: string;
|
|
34
|
+
responseBuffer: string;
|
|
35
|
+
screenText: string;
|
|
36
|
+
parseScreenText: string;
|
|
37
|
+
workingDir: string;
|
|
38
|
+
providerSessionId: string | null;
|
|
39
|
+
runtimeSettings: Record<string, any>;
|
|
40
|
+
isWaitingForResponse: boolean;
|
|
41
|
+
currentTurnScope: TurnParseScope | null;
|
|
42
|
+
lastOutputAt: number;
|
|
43
|
+
lastNonEmptyOutputAt: number;
|
|
44
|
+
lastScreenChangeAt: number;
|
|
45
|
+
lastScreenSnapshot: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the engine needs from the transport layer */
|
|
49
|
+
export interface CliTransportAccess {
|
|
50
|
+
getSnapshot(): CliBufferSnapshot;
|
|
51
|
+
writeRaw(data: string | Buffer): void;
|
|
52
|
+
getApprovalKeyForIndex(buttonIndex: number): string | undefined;
|
|
53
|
+
flushOutboundQueue(): void;
|
|
54
|
+
isAlive(): boolean;
|
|
55
|
+
/** Optional: override script dispatch (used by tests to mock detection) */
|
|
56
|
+
runDetectStatus?(text: string): string | null;
|
|
57
|
+
/** Optional: override script dispatch (used by tests to mock approval) */
|
|
58
|
+
runParseApproval?(tail: string): { message: string; buttons: string[] } | null;
|
|
59
|
+
/** Optional: override full session parse (used by tests to mock parsing) */
|
|
60
|
+
runParseSession?(): ParsedSession | null;
|
|
61
|
+
/** Optional: provider type override — used when tests patch the adapter's cliType */
|
|
62
|
+
cliType?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CliStateEngineCallbacks {
|
|
66
|
+
onStatusChange(): void;
|
|
67
|
+
onApplyParsedSession(session: ParsedSession): void;
|
|
68
|
+
onTurnCompleted(): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface IdleFinishCandidate {
|
|
72
|
+
armedAt: number;
|
|
73
|
+
lastOutputAt: number;
|
|
74
|
+
lastScreenChangeAt: number;
|
|
75
|
+
responseEpoch: number;
|
|
76
|
+
assistantLength: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface SettledEvalContext {
|
|
80
|
+
now: number;
|
|
81
|
+
modal: { message: string; buttons: string[] } | null;
|
|
82
|
+
status: string;
|
|
83
|
+
parsedMessages: CliChatMessage[];
|
|
84
|
+
lastParsedAssistant: CliChatMessage | undefined;
|
|
85
|
+
parsedStatus: string | null;
|
|
86
|
+
prevStatus: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
const SCRIPT_STATUS_DEBOUNCE_MS = 3000;
|
|
92
|
+
const MAX_FINISH_RETRIES = 2;
|
|
93
|
+
const FINISH_RETRY_DELAY_MS = 300;
|
|
94
|
+
const MAX_TRACE_ENTRIES = 250;
|
|
95
|
+
const APPROVAL_EXIT_TIMEOUT_MS = 60_000;
|
|
96
|
+
|
|
97
|
+
// ─── Engine ────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
export class CliStateEngine {
|
|
100
|
+
// ── Status ───────────────────────────────────────
|
|
101
|
+
currentStatus: CliSessionStatus['status'] = 'starting';
|
|
102
|
+
isWaitingForResponse = false;
|
|
103
|
+
currentTurnScope: TurnParseScope | null = null;
|
|
104
|
+
activeModal: { message: string; buttons: string[] } | null = null;
|
|
105
|
+
|
|
106
|
+
// ── Approval ─────────────────────────────────────
|
|
107
|
+
lastApprovalResolvedAt = 0;
|
|
108
|
+
lastResolvedModalMessage = '';
|
|
109
|
+
private approvalExitTimeout: NodeJS.Timeout | null = null;
|
|
110
|
+
|
|
111
|
+
// ── Response tracking ────────────────────────────
|
|
112
|
+
responseEpoch = 0;
|
|
113
|
+
submitPendingUntil = 0;
|
|
114
|
+
responseSettleIgnoreUntil = 0;
|
|
115
|
+
submitRetryUsed = false;
|
|
116
|
+
submitRetryPromptSnippet = '';
|
|
117
|
+
finishRetryCount = 0;
|
|
118
|
+
providerErrorMessage: string | null = null;
|
|
119
|
+
providerErrorReason: string | null = null;
|
|
120
|
+
|
|
121
|
+
// ── Timers ───────────────────────────────────────
|
|
122
|
+
private settleTimer: NodeJS.Timeout | null = null;
|
|
123
|
+
private idleTimeout: NodeJS.Timeout | null = null;
|
|
124
|
+
private finishRetryTimer: NodeJS.Timeout | null = null;
|
|
125
|
+
private providerErrorRetryTimer: NodeJS.Timeout | null = null;
|
|
126
|
+
private providerErrorRetryKey = '';
|
|
127
|
+
|
|
128
|
+
// ── Debounce ─────────────────────────────────────
|
|
129
|
+
pendingScriptStatus: 'generating' | 'waiting_approval' | null = null;
|
|
130
|
+
pendingScriptStatusSince = 0;
|
|
131
|
+
private pendingScriptStatusTimer: NodeJS.Timeout | null = null;
|
|
132
|
+
|
|
133
|
+
// ── Idle candidate ───────────────────────────────
|
|
134
|
+
private idleFinishCandidate: IdleFinishCandidate | null = null;
|
|
135
|
+
|
|
136
|
+
// ── Status history (debug) ───────────────────────
|
|
137
|
+
private statusHistory: { status: string; at: number; trigger?: string }[] = [];
|
|
138
|
+
private traceEntries: CliTraceEntry[] = [];
|
|
139
|
+
private traceSeq = 0;
|
|
140
|
+
private traceSessionId = '';
|
|
141
|
+
|
|
142
|
+
constructor(
|
|
143
|
+
private readonly provider: CliProviderModule,
|
|
144
|
+
private readonly runner: CliScriptRunner,
|
|
145
|
+
private readonly transport: CliTransportAccess,
|
|
146
|
+
private readonly callbacks: CliStateEngineCallbacks,
|
|
147
|
+
private readonly timeouts: Required<NonNullable<CliProviderModule['timeouts']>>,
|
|
148
|
+
) {}
|
|
149
|
+
|
|
150
|
+
// ─── Public API ────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
setStatus(status: CliSessionStatus['status'], trigger?: string): void {
|
|
153
|
+
const prev = this.currentStatus;
|
|
154
|
+
if (prev === status) return;
|
|
155
|
+
this.currentStatus = status;
|
|
156
|
+
this.statusHistory.push({ status, at: Date.now(), trigger });
|
|
157
|
+
if (this.statusHistory.length > 50) this.statusHistory.shift();
|
|
158
|
+
this.recordTrace('status', { previousStatus: prev, trigger: trigger || null });
|
|
159
|
+
LOG.info('CLI', `[${this.provider.type}] status: ${prev} → ${status}${trigger ? ` (${trigger})` : ''}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
scheduleSettle(): void {
|
|
163
|
+
if (this.settleTimer) clearTimeout(this.settleTimer);
|
|
164
|
+
const epoch = this.responseEpoch;
|
|
165
|
+
const delay = Math.max(
|
|
166
|
+
this.timeouts.outputSettle,
|
|
167
|
+
this.submitPendingUntil > Date.now()
|
|
168
|
+
? (this.submitPendingUntil - Date.now()) + this.timeouts.outputSettle
|
|
169
|
+
: 0,
|
|
170
|
+
);
|
|
171
|
+
this.settleTimer = setTimeout(() => {
|
|
172
|
+
this.settleTimer = null;
|
|
173
|
+
if (epoch !== this.responseEpoch) return;
|
|
174
|
+
this.evaluateSettled(this.transport.getSnapshot());
|
|
175
|
+
}, delay);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Called from sendMessage in transport once a turn scope is established. */
|
|
179
|
+
onTurnStarted(turnScope: TurnParseScope): void {
|
|
180
|
+
this.isWaitingForResponse = true;
|
|
181
|
+
this.finishRetryCount = 0;
|
|
182
|
+
this.clearIdleFinishCandidate('send_message');
|
|
183
|
+
this.currentTurnScope = turnScope;
|
|
184
|
+
this.responseEpoch += 1;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Called when PTY exits */
|
|
188
|
+
onPtyExit(): void {
|
|
189
|
+
this.clearAllTimers();
|
|
190
|
+
this.setStatus('stopped', 'pty_exit');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Called when adapter starts up successfully */
|
|
194
|
+
onSpawnReady(): void {
|
|
195
|
+
this.setStatus('starting', 'pty_ready');
|
|
196
|
+
this.traceEntries = [];
|
|
197
|
+
this.traceSeq = 0;
|
|
198
|
+
this.traceSessionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
199
|
+
this.recordTrace('session_start', { providerType: this.provider.type });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
resolveModal(buttonIndex: number): void {
|
|
203
|
+
const snap = this.transport.getSnapshot();
|
|
204
|
+
const parseApproval = typeof this.transport.runParseApproval === 'function'
|
|
205
|
+
? (s: CliBufferSnapshot) => this.transport.runParseApproval!(s.recentOutputBuffer.slice(-500))
|
|
206
|
+
: (s: CliBufferSnapshot) => this.runParseApproval(s);
|
|
207
|
+
let modal = this.activeModal ?? parseApproval(snap);
|
|
208
|
+
|
|
209
|
+
if (!modal && this.runner.hasParseSession()) {
|
|
210
|
+
try {
|
|
211
|
+
const parsed = this.runParseSession(snap) as any;
|
|
212
|
+
const parsedModal = parsed?.activeModal && Array.isArray(parsed.activeModal.buttons)
|
|
213
|
+
&& parsed.activeModal.buttons.some((b: any) => typeof b === 'string' && b.trim())
|
|
214
|
+
? parsed.activeModal : null;
|
|
215
|
+
if (parsed?.status === 'waiting_approval' && parsedModal) {
|
|
216
|
+
modal = parsedModal;
|
|
217
|
+
this.activeModal = parsedModal;
|
|
218
|
+
if (this.currentStatus !== 'waiting_approval') {
|
|
219
|
+
this.setStatus('waiting_approval', 'resolve_modal_parse');
|
|
220
|
+
this.callbacks.onStatusChange();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch { /* ignore parse failures */ }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!this.transport.isAlive() || (this.currentStatus !== 'waiting_approval' && !modal)) return;
|
|
227
|
+
|
|
228
|
+
const currentModalMessage = typeof modal?.message === 'string' ? modal.message.trim() : '';
|
|
229
|
+
const inCooldown = !!this.lastApprovalResolvedAt
|
|
230
|
+
&& (Date.now() - this.lastApprovalResolvedAt) < this.timeouts.approvalCooldown;
|
|
231
|
+
if (inCooldown && currentModalMessage === this.lastResolvedModalMessage) return;
|
|
232
|
+
|
|
233
|
+
this.clearIdleFinishCandidate('resolve_modal');
|
|
234
|
+
this.recordTrace('resolve_modal', { buttonIndex, activeModal: modal });
|
|
235
|
+
this.activeModal = null;
|
|
236
|
+
this.lastApprovalResolvedAt = Date.now();
|
|
237
|
+
this.lastResolvedModalMessage = currentModalMessage;
|
|
238
|
+
this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
|
|
239
|
+
if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
|
|
240
|
+
this.setStatus('generating', 'approval_resolved');
|
|
241
|
+
this.callbacks.onStatusChange();
|
|
242
|
+
|
|
243
|
+
const approvalKey = this.transport.getApprovalKeyForIndex(buttonIndex);
|
|
244
|
+
if (approvalKey !== undefined) {
|
|
245
|
+
this.transport.writeRaw(approvalKey);
|
|
246
|
+
} else {
|
|
247
|
+
const DOWN = '\x1B[B';
|
|
248
|
+
const buttonCount = Array.isArray(modal?.buttons) ? modal.buttons.length : 0;
|
|
249
|
+
const clamped = buttonCount > 0
|
|
250
|
+
? Math.min(Math.max(0, buttonIndex), buttonCount - 1)
|
|
251
|
+
: Math.max(0, buttonIndex);
|
|
252
|
+
this.transport.writeRaw(DOWN.repeat(clamped) + '\r');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
isApprovalRecentlyResolved(): boolean {
|
|
257
|
+
return !!(this.lastApprovalResolvedAt
|
|
258
|
+
&& (Date.now() - this.lastApprovalResolvedAt) < this.timeouts.approvalCooldown);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Called from sendMessage before starting a new turn.
|
|
263
|
+
* Clears stale idle response state when the terminal looks idle and no modal is active.
|
|
264
|
+
*/
|
|
265
|
+
clearStaleIdleResponseGuard(reason: string, snap: CliBufferSnapshot): boolean {
|
|
266
|
+
const blockingModal = this.activeModal
|
|
267
|
+
?? (typeof this.transport.runParseApproval === 'function'
|
|
268
|
+
? this.transport.runParseApproval(snap.recentOutputBuffer.slice(-500))
|
|
269
|
+
: this.runParseApproval(snap));
|
|
270
|
+
const isIdle = (typeof this.transport.runDetectStatus === 'function'
|
|
271
|
+
? this.transport.runDetectStatus(snap.recentOutputBuffer)
|
|
272
|
+
: this.runDetectStatus(snap)) === 'idle';
|
|
273
|
+
if (!this.isWaitingForResponse || this.currentStatus !== 'idle' || !isIdle || !!blockingModal) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
this.clearAllTimers();
|
|
277
|
+
this.clearIdleFinishCandidate(reason);
|
|
278
|
+
this.isWaitingForResponse = false;
|
|
279
|
+
this.responseSettleIgnoreUntil = 0;
|
|
280
|
+
this.submitRetryUsed = false;
|
|
281
|
+
this.submitRetryPromptSnippet = '';
|
|
282
|
+
this.finishRetryCount = 0;
|
|
283
|
+
this.currentTurnScope = null;
|
|
284
|
+
this.activeModal = null;
|
|
285
|
+
this.recordTrace('stale_idle_response_cleared', { reason });
|
|
286
|
+
this.callbacks.onTurnCompleted();
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Called from sendMessage before starting a new turn.
|
|
292
|
+
* Clears stale idle response state when the parsed session confirms idle with a final assistant message.
|
|
293
|
+
*/
|
|
294
|
+
clearParsedIdleResponseGuard(reason: string, parsedStatus: any, snap: CliBufferSnapshot): boolean {
|
|
295
|
+
const parsedRawStatus = typeof parsedStatus?.status === 'string' ? parsedStatus.status.trim() : '';
|
|
296
|
+
const parsedModal = parsedStatus?.activeModal ?? parsedStatus?.modal ?? null;
|
|
297
|
+
const blockingModal = this.activeModal
|
|
298
|
+
?? (typeof this.transport.runParseApproval === 'function'
|
|
299
|
+
? this.transport.runParseApproval(snap.recentOutputBuffer.slice(-500))
|
|
300
|
+
: this.runParseApproval(snap));
|
|
301
|
+
if (
|
|
302
|
+
!this.isWaitingForResponse
|
|
303
|
+
|| parsedRawStatus !== 'idle'
|
|
304
|
+
|| !!parsedModal
|
|
305
|
+
|| !!blockingModal
|
|
306
|
+
|| !this.parsedStatusHasFinalAssistantMessage(parsedStatus)
|
|
307
|
+
) {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
this.clearAllTimers();
|
|
311
|
+
this.clearIdleFinishCandidate(reason);
|
|
312
|
+
this.isWaitingForResponse = false;
|
|
313
|
+
this.responseSettleIgnoreUntil = 0;
|
|
314
|
+
this.submitRetryUsed = false;
|
|
315
|
+
this.submitRetryPromptSnippet = '';
|
|
316
|
+
this.finishRetryCount = 0;
|
|
317
|
+
this.currentTurnScope = null;
|
|
318
|
+
this.activeModal = null;
|
|
319
|
+
this.setStatus('idle', reason);
|
|
320
|
+
this.recordTrace('parsed_idle_response_cleared', {
|
|
321
|
+
reason,
|
|
322
|
+
parsedStatus: parsedRawStatus,
|
|
323
|
+
parsedMessageCount: Array.isArray(parsedStatus?.messages) ? parsedStatus.messages.length : 0,
|
|
324
|
+
});
|
|
325
|
+
this.callbacks.onTurnCompleted();
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
clearAllTimers(): void {
|
|
330
|
+
if (this.settleTimer) { clearTimeout(this.settleTimer); this.settleTimer = null; }
|
|
331
|
+
if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
|
|
332
|
+
if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
|
|
333
|
+
if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
|
|
334
|
+
if (this.pendingScriptStatusTimer) { clearTimeout(this.pendingScriptStatusTimer); this.pendingScriptStatusTimer = null; }
|
|
335
|
+
if (this.providerErrorRetryTimer) { clearTimeout(this.providerErrorRetryTimer); this.providerErrorRetryTimer = null; }
|
|
336
|
+
this.providerErrorRetryKey = '';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
resetActiveTurnState(): void {
|
|
340
|
+
this.clearAllTimers();
|
|
341
|
+
this.isWaitingForResponse = false;
|
|
342
|
+
this.responseSettleIgnoreUntil = 0;
|
|
343
|
+
this.submitRetryUsed = false;
|
|
344
|
+
this.submitRetryPromptSnippet = '';
|
|
345
|
+
this.finishRetryCount = 0;
|
|
346
|
+
this.currentTurnScope = null;
|
|
347
|
+
this.activeModal = null;
|
|
348
|
+
this.pendingScriptStatus = null;
|
|
349
|
+
this.pendingScriptStatusSince = 0;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
clearIdleFinishCandidate(reason: string): void {
|
|
353
|
+
if (!this.idleFinishCandidate) return;
|
|
354
|
+
this.recordTrace('idle_candidate_reset', { reason, candidate: this.idleFinishCandidate });
|
|
355
|
+
this.idleFinishCandidate = null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
hasActionableApproval(startupModal?: { message: string; buttons: string[] } | null): boolean {
|
|
359
|
+
return !!(startupModal ?? this.activeModal);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
getTraceEntries(): CliTraceEntry[] { return this.traceEntries; }
|
|
363
|
+
getStatusHistory(): { status: string; at: number; trigger?: string }[] { return this.statusHistory; }
|
|
364
|
+
getTraceSessionId(): string { return this.traceSessionId; }
|
|
365
|
+
|
|
366
|
+
/** Record a trace entry from the transport layer (e.g. output events in debug mode). */
|
|
367
|
+
recordExternalTrace(type: string, payload: Record<string, any> = {}): void {
|
|
368
|
+
this.recordTrace(type, payload);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ─── Script dispatch (builds inputs from snapshot) ──────────────────────
|
|
372
|
+
|
|
373
|
+
runDetectStatus(snap: CliBufferSnapshot): string | null {
|
|
374
|
+
const tail = snap.recentOutputBuffer.slice(-500);
|
|
375
|
+
return this.runner.detectStatus({
|
|
376
|
+
tail,
|
|
377
|
+
screenText: snap.screenText,
|
|
378
|
+
rawBuffer: snap.accumulatedRawBuffer,
|
|
379
|
+
isWaitingForResponse: snap.isWaitingForResponse,
|
|
380
|
+
screen: buildCliScreenSnapshot(snap.screenText),
|
|
381
|
+
tailScreen: buildCliScreenSnapshot(tail),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
runParseApproval(snap: CliBufferSnapshot): { message: string; buttons: string[] } | null {
|
|
386
|
+
const tail = snap.recentOutputBuffer.slice(-500);
|
|
387
|
+
const buffer = snap.screenText || snap.accumulatedBuffer;
|
|
388
|
+
return this.runner.parseApproval({
|
|
389
|
+
buffer,
|
|
390
|
+
screenText: snap.screenText,
|
|
391
|
+
rawBuffer: snap.accumulatedRawBuffer,
|
|
392
|
+
tail,
|
|
393
|
+
screen: buildCliScreenSnapshot(snap.screenText),
|
|
394
|
+
bufferScreen: buildCliScreenSnapshot(buffer),
|
|
395
|
+
tailScreen: buildCliScreenSnapshot(tail),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
runParseSession(snap: CliBufferSnapshot): ParsedSession | null {
|
|
400
|
+
// Allow transport to override session parsing (enables test mocking)
|
|
401
|
+
if (typeof this.transport.runParseSession === 'function') {
|
|
402
|
+
const session = this.transport.runParseSession();
|
|
403
|
+
if (session && typeof session === 'object') {
|
|
404
|
+
this.callbacks.onApplyParsedSession(session);
|
|
405
|
+
}
|
|
406
|
+
return session;
|
|
407
|
+
}
|
|
408
|
+
const tail = snap.recentOutputBuffer.slice(-500);
|
|
409
|
+
const input = buildCliParseInput({
|
|
410
|
+
accumulatedBuffer: snap.accumulatedBuffer,
|
|
411
|
+
accumulatedRawBuffer: snap.accumulatedRawBuffer,
|
|
412
|
+
recentOutputBuffer: snap.recentOutputBuffer,
|
|
413
|
+
terminalScreenText: snap.parseScreenText,
|
|
414
|
+
workingDir: snap.workingDir,
|
|
415
|
+
providerSessionId: snap.providerSessionId || undefined,
|
|
416
|
+
historySessionId: snap.providerSessionId || undefined,
|
|
417
|
+
baseMessages: [],
|
|
418
|
+
partialResponse: snap.responseBuffer,
|
|
419
|
+
isWaitingForResponse: snap.isWaitingForResponse,
|
|
420
|
+
scope: snap.currentTurnScope,
|
|
421
|
+
runtimeSettings: snap.runtimeSettings,
|
|
422
|
+
});
|
|
423
|
+
const session = this.runner.parseSession({
|
|
424
|
+
...input,
|
|
425
|
+
tail,
|
|
426
|
+
tailScreen: buildCliScreenSnapshot(tail),
|
|
427
|
+
});
|
|
428
|
+
if (session && typeof session === 'object') {
|
|
429
|
+
this.callbacks.onApplyParsedSession(session);
|
|
430
|
+
}
|
|
431
|
+
return session;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ─── Core evaluation loop ───────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
evaluateSettled(snap: CliBufferSnapshot): void {
|
|
437
|
+
const now = Date.now();
|
|
438
|
+
if (this.submitPendingUntil > now || this.responseSettleIgnoreUntil > now) {
|
|
439
|
+
const delayTime = Math.max(this.submitPendingUntil - now, this.responseSettleIgnoreUntil - now) + 50;
|
|
440
|
+
if (this.settleTimer) clearTimeout(this.settleTimer);
|
|
441
|
+
this.settleTimer = setTimeout(() => {
|
|
442
|
+
this.settleTimer = null;
|
|
443
|
+
this.evaluateSettled(this.transport.getSnapshot());
|
|
444
|
+
}, delayTime);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (!this.isWaitingForResponse && !this.currentTurnScope && !this.activeModal && !this.runner.parseErrorMessage) {
|
|
449
|
+
const tail = snap.recentOutputBuffer;
|
|
450
|
+
const modal = this.runParseApproval(snap);
|
|
451
|
+
const lightweightStatus = this.runner.hasDetectStatus() ? this.runDetectStatus(snap) : null;
|
|
452
|
+
if (!modal && lightweightStatus === 'idle' && this.currentStatus === 'idle') return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const session = this.runParseSession(snap);
|
|
456
|
+
if (!session) return;
|
|
457
|
+
|
|
458
|
+
const { status, messages } = session;
|
|
459
|
+
const modal = (session as any).activeModal ?? session.modal ?? null;
|
|
460
|
+
const parsedStatus = (session as any).parsedStatus ?? null;
|
|
461
|
+
const parsedMessages = normalizeCliParsedMessages(messages, {
|
|
462
|
+
scope: null,
|
|
463
|
+
lastOutputAt: snap.lastOutputAt,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
if (this.maybeCommitVisibleIdleTranscript(session, parsedMessages, snap)) return;
|
|
467
|
+
|
|
468
|
+
const lastParsedAssistant = [...parsedMessages].reverse().find((m) => m.role === 'assistant');
|
|
469
|
+
|
|
470
|
+
if (
|
|
471
|
+
this.currentTurnScope
|
|
472
|
+
&& !lastParsedAssistant
|
|
473
|
+
&& !this.submitRetryUsed
|
|
474
|
+
&& this.transport.isAlive()
|
|
475
|
+
&& !this.hasActionableApproval()
|
|
476
|
+
&& promptLikelyVisible(snap.screenText, normalizePromptText(this.submitRetryPromptSnippet || this.currentTurnScope?.prompt || ''))
|
|
477
|
+
&& !this.hasMeaningfulResponseBuffer(snap, normalizePromptText(this.submitRetryPromptSnippet || this.currentTurnScope?.prompt || ''))
|
|
478
|
+
) {
|
|
479
|
+
this.submitRetryUsed = true;
|
|
480
|
+
this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
|
|
481
|
+
LOG.info('CLI', `[${this.provider.type}] Retrying submit key from settled parser (no assistant yet)`);
|
|
482
|
+
this.transport.writeRaw('\r');
|
|
483
|
+
if (this.settleTimer) clearTimeout(this.settleTimer);
|
|
484
|
+
this.settleTimer = setTimeout(() => {
|
|
485
|
+
this.settleTimer = null;
|
|
486
|
+
this.evaluateSettled(this.transport.getSnapshot());
|
|
487
|
+
}, this.timeouts.outputSettle + 150);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (!status) return;
|
|
492
|
+
|
|
493
|
+
const prevStatus = this.currentStatus;
|
|
494
|
+
const ctx: SettledEvalContext = { now, modal, status, parsedMessages, lastParsedAssistant, parsedStatus: parsedStatus || null, prevStatus };
|
|
495
|
+
|
|
496
|
+
if (!this.applyPendingScriptStatusDebounce(ctx)) return;
|
|
497
|
+
|
|
498
|
+
const recentInteractiveActivity = this.hasRecentInteractiveActivity(snap, now);
|
|
499
|
+
LOG.debug(
|
|
500
|
+
'CLI',
|
|
501
|
+
`[${this.provider.type}] settled diagnostics prompt=${JSON.stringify(this.currentTurnScope?.prompt || '').slice(0, 140)} status=${String(status || '')} parsedStatus=${String(parsedStatus || '')} parsedMsgCount=${parsedMessages.length} lastParsedAssistant=${JSON.stringify((lastParsedAssistant?.content || '').slice(0, 120)).slice(0, 160)} responseBuffer=${JSON.stringify((snap.responseBuffer || '').slice(0, 160)).slice(0, 220)}`
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const shouldHoldGenerating = status === 'idle' && this.isWaitingForResponse && !modal
|
|
505
|
+
&& recentInteractiveActivity && !(parsedStatus === 'idle' && !!lastParsedAssistant);
|
|
506
|
+
|
|
507
|
+
if (shouldHoldGenerating) { this.applyHoldGenerating(ctx); return; }
|
|
508
|
+
if (status === 'error') {
|
|
509
|
+
if (this.maybeScheduleProviderErrorRetry(ctx, session, snap)) return;
|
|
510
|
+
this.applyError(ctx, session);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (status === 'waiting_approval') { this.applyWaitingApproval(ctx); return; }
|
|
514
|
+
if (status === 'generating') { this.applyGenerating(ctx); return; }
|
|
515
|
+
if (status === 'idle') { this.applyIdle(ctx, snap, now); }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ─── State transitions ──────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
private applyPendingScriptStatusDebounce(ctx: SettledEvalContext): boolean {
|
|
521
|
+
const { now, status, prevStatus } = ctx;
|
|
522
|
+
const shouldDebounce = prevStatus === 'idle' && !this.isWaitingForResponse
|
|
523
|
+
&& !this.currentTurnScope && (status === 'generating' || status === 'waiting_approval');
|
|
524
|
+
|
|
525
|
+
if (!shouldDebounce) {
|
|
526
|
+
this.pendingScriptStatus = null;
|
|
527
|
+
this.pendingScriptStatusSince = 0;
|
|
528
|
+
if (this.pendingScriptStatusTimer) { clearTimeout(this.pendingScriptStatusTimer); this.pendingScriptStatusTimer = null; }
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const armPending = (delayMs: number) => {
|
|
533
|
+
if (this.pendingScriptStatusTimer) clearTimeout(this.pendingScriptStatusTimer);
|
|
534
|
+
this.pendingScriptStatusTimer = setTimeout(() => {
|
|
535
|
+
this.pendingScriptStatusTimer = null;
|
|
536
|
+
this.evaluateSettled(this.transport.getSnapshot());
|
|
537
|
+
}, delayMs);
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
if (this.pendingScriptStatus !== status) {
|
|
541
|
+
this.pendingScriptStatus = status as 'generating' | 'waiting_approval';
|
|
542
|
+
this.pendingScriptStatusSince = now;
|
|
543
|
+
armPending(SCRIPT_STATUS_DEBOUNCE_MS);
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
const elapsed = now - this.pendingScriptStatusSince;
|
|
547
|
+
if (elapsed < SCRIPT_STATUS_DEBOUNCE_MS) { armPending(SCRIPT_STATUS_DEBOUNCE_MS - elapsed); return false; }
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private applyHoldGenerating(ctx: SettledEvalContext): void {
|
|
552
|
+
this.clearIdleFinishCandidate('hold_generating_recent_activity');
|
|
553
|
+
this.setStatus('generating', 'recent_activity_hold');
|
|
554
|
+
if (this.idleTimeout) clearTimeout(this.idleTimeout);
|
|
555
|
+
this.idleTimeout = setTimeout(() => {
|
|
556
|
+
if (this.isWaitingForResponse && !this.hasActionableApproval()) {
|
|
557
|
+
if (this.shouldDeferIdleTimeoutFinish()) return;
|
|
558
|
+
this.finishResponse();
|
|
559
|
+
}
|
|
560
|
+
}, this.timeouts.generatingIdle);
|
|
561
|
+
this.callbacks.onStatusChange();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private applyWaitingApproval(ctx: SettledEvalContext): void {
|
|
565
|
+
const { modal } = ctx;
|
|
566
|
+
this.clearIdleFinishCandidate('waiting_approval');
|
|
567
|
+
const inCooldown = this.lastApprovalResolvedAt
|
|
568
|
+
&& (Date.now() - this.lastApprovalResolvedAt) < this.timeouts.approvalCooldown;
|
|
569
|
+
if (inCooldown && !modal) {
|
|
570
|
+
if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
|
|
571
|
+
this.activeModal = null;
|
|
572
|
+
const reason = inCooldown ? 'approval_cooldown_non_actionable' : 'approval_prompt_gone_non_actionable';
|
|
573
|
+
if (this.isWaitingForResponse) {
|
|
574
|
+
this.setStatus('idle', reason);
|
|
575
|
+
if (this.idleTimeout) clearTimeout(this.idleTimeout);
|
|
576
|
+
this.idleTimeout = setTimeout(() => {
|
|
577
|
+
if (this.isWaitingForResponse && !this.hasActionableApproval()) {
|
|
578
|
+
if (this.shouldDeferIdleTimeoutFinish()) return;
|
|
579
|
+
this.finishResponse();
|
|
580
|
+
}
|
|
581
|
+
}, this.timeouts.generatingIdle);
|
|
582
|
+
} else {
|
|
583
|
+
this.setStatus('idle', reason);
|
|
584
|
+
}
|
|
585
|
+
this.callbacks.onStatusChange();
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (!inCooldown) {
|
|
589
|
+
if (!modal) {
|
|
590
|
+
LOG.warn('CLI', `[${this.provider.type}] detectStatus=waiting_approval but parseApproval returned null; ignoring`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
this.isWaitingForResponse = true;
|
|
594
|
+
this.setStatus('waiting_approval', 'script_detect');
|
|
595
|
+
this.activeModal = modal;
|
|
596
|
+
if (this.idleTimeout) clearTimeout(this.idleTimeout);
|
|
597
|
+
this.armApprovalExitTimeout();
|
|
598
|
+
this.callbacks.onStatusChange();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private applyGenerating(ctx: SettledEvalContext): void {
|
|
603
|
+
const { modal, parsedMessages, lastParsedAssistant, parsedStatus, prevStatus } = ctx;
|
|
604
|
+
this.clearIdleFinishCandidate('generating');
|
|
605
|
+
const snap = this.transport.getSnapshot();
|
|
606
|
+
const effectiveScreenText = snap.screenText || snap.accumulatedBuffer;
|
|
607
|
+
const noActiveTurn = !this.currentTurnScope;
|
|
608
|
+
const looksIdleChrome = /(^|\n)\s*[❯›>]\s*(?:\n|$)/m.test(effectiveScreenText);
|
|
609
|
+
const parsedShowsLiveProgress = parsedStatus === 'generating' && !!lastParsedAssistant;
|
|
610
|
+
if (prevStatus === 'idle' && !this.isWaitingForResponse && noActiveTurn && !modal && looksIdleChrome && !parsedShowsLiveProgress) return;
|
|
611
|
+
if (prevStatus === 'waiting_approval') {
|
|
612
|
+
if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
|
|
613
|
+
this.activeModal = null;
|
|
614
|
+
this.lastApprovalResolvedAt = Date.now();
|
|
615
|
+
}
|
|
616
|
+
if (!this.isWaitingForResponse) { this.isWaitingForResponse = true; }
|
|
617
|
+
this.setStatus('generating', 'script_detect');
|
|
618
|
+
if (this.idleTimeout) clearTimeout(this.idleTimeout);
|
|
619
|
+
this.idleTimeout = setTimeout(() => {
|
|
620
|
+
if (this.isWaitingForResponse) {
|
|
621
|
+
if (this.shouldDeferIdleTimeoutFinish()) return;
|
|
622
|
+
this.finishResponse();
|
|
623
|
+
}
|
|
624
|
+
}, this.timeouts.generatingIdle);
|
|
625
|
+
this.callbacks.onStatusChange();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private applyError(ctx: SettledEvalContext, session: ParsedSession): void {
|
|
629
|
+
this.clearIdleFinishCandidate('provider_error');
|
|
630
|
+
this.clearAllTimers();
|
|
631
|
+
this.isWaitingForResponse = false;
|
|
632
|
+
this.responseSettleIgnoreUntil = 0;
|
|
633
|
+
this.submitRetryUsed = false;
|
|
634
|
+
this.submitRetryPromptSnippet = '';
|
|
635
|
+
this.finishRetryCount = 0;
|
|
636
|
+
this.currentTurnScope = null;
|
|
637
|
+
this.activeModal = null;
|
|
638
|
+
this.providerErrorMessage = typeof session.errorMessage === 'string' && session.errorMessage.trim()
|
|
639
|
+
? session.errorMessage.trim() : 'Provider reported an error';
|
|
640
|
+
this.providerErrorReason = typeof session.errorReason === 'string' && session.errorReason.trim()
|
|
641
|
+
? session.errorReason.trim() : 'provider_error';
|
|
642
|
+
this.setStatus('error', this.providerErrorReason);
|
|
643
|
+
this.callbacks.onStatusChange();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private maybeScheduleProviderErrorRetry(ctx: SettledEvalContext, session: ParsedSession, snap: CliBufferSnapshot): boolean {
|
|
647
|
+
const retryPrompt = typeof (session as any).retryPrompt === 'string' ? String((session as any).retryPrompt).trim() : '';
|
|
648
|
+
const retryDelayMs = typeof (session as any).retryDelayMs === 'number' ? Number((session as any).retryDelayMs) : NaN;
|
|
649
|
+
if (!retryPrompt || !Number.isFinite(retryDelayMs) || retryDelayMs < 0 || !this.transport.isAlive()) return false;
|
|
650
|
+
|
|
651
|
+
const retryAttempt = typeof (session as any).retryAttempt === 'number' ? Number((session as any).retryAttempt) : 0;
|
|
652
|
+
const errorReason = typeof session.errorReason === 'string' && session.errorReason.trim() ? session.errorReason.trim() : 'provider_error';
|
|
653
|
+
const retryKey = `${errorReason}:${retryAttempt}:${retryPrompt}`;
|
|
654
|
+
if (this.providerErrorRetryTimer && this.providerErrorRetryKey === retryKey) return true;
|
|
655
|
+
|
|
656
|
+
if (this.providerErrorRetryTimer) clearTimeout(this.providerErrorRetryTimer);
|
|
657
|
+
this.providerErrorRetryKey = retryKey;
|
|
658
|
+
this.clearIdleFinishCandidate('provider_error_retry');
|
|
659
|
+
if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
|
|
660
|
+
if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
|
|
661
|
+
this.providerErrorMessage = typeof session.errorMessage === 'string' && session.errorMessage.trim()
|
|
662
|
+
? session.errorMessage.trim() : 'Provider reported an error';
|
|
663
|
+
this.providerErrorReason = errorReason;
|
|
664
|
+
this.activeModal = null;
|
|
665
|
+
this.responseSettleIgnoreUntil = Date.now() + retryDelayMs + this.timeouts.outputSettle + 400;
|
|
666
|
+
this.setStatus('generating', 'provider_error_retry_scheduled');
|
|
667
|
+
this.callbacks.onStatusChange();
|
|
668
|
+
|
|
669
|
+
this.providerErrorRetryTimer = setTimeout(() => {
|
|
670
|
+
this.providerErrorRetryTimer = null;
|
|
671
|
+
this.providerErrorRetryKey = '';
|
|
672
|
+
if (!this.transport.isAlive()) return;
|
|
673
|
+
this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
|
|
674
|
+
this.submitRetryUsed = false;
|
|
675
|
+
this.transport.writeRaw(`${retryPrompt}\r`);
|
|
676
|
+
if (this.settleTimer) clearTimeout(this.settleTimer);
|
|
677
|
+
this.settleTimer = setTimeout(() => {
|
|
678
|
+
this.settleTimer = null;
|
|
679
|
+
this.evaluateSettled(this.transport.getSnapshot());
|
|
680
|
+
}, this.timeouts.outputSettle + 150);
|
|
681
|
+
}, retryDelayMs);
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private applyIdle(ctx: SettledEvalContext, snap: CliBufferSnapshot, now: number): void {
|
|
686
|
+
const { modal, lastParsedAssistant, prevStatus } = ctx;
|
|
687
|
+
if (prevStatus === 'waiting_approval') {
|
|
688
|
+
if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
|
|
689
|
+
this.activeModal = null;
|
|
690
|
+
this.lastApprovalResolvedAt = Date.now();
|
|
691
|
+
this.setStatus('idle', 'approval_prompt_gone_script_idle');
|
|
692
|
+
}
|
|
693
|
+
if (!this.isWaitingForResponse) {
|
|
694
|
+
if (prevStatus !== 'idle') {
|
|
695
|
+
this.clearIdleFinishCandidate('idle_without_response');
|
|
696
|
+
this.setStatus('idle', 'script_detect');
|
|
697
|
+
this.callbacks.onStatusChange();
|
|
698
|
+
}
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const quietForMs = snap.lastNonEmptyOutputAt ? (now - snap.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
|
|
702
|
+
const screenStableMs = snap.lastScreenChangeAt ? (now - snap.lastScreenChangeAt) : 0;
|
|
703
|
+
const hasAssistantTurn = !!lastParsedAssistant;
|
|
704
|
+
const assistantLength = (lastParsedAssistant as any)?.content?.length || 0;
|
|
705
|
+
const idleFinishConfirmMs = this.timeouts.idleFinishConfirm;
|
|
706
|
+
const idleQuietThresholdMs = Math.max(idleFinishConfirmMs, this.timeouts.outputSettle);
|
|
707
|
+
const idleReady = !modal && hasAssistantTurn && quietForMs >= idleQuietThresholdMs && screenStableMs >= idleFinishConfirmMs;
|
|
708
|
+
const candidate = this.idleFinishCandidate;
|
|
709
|
+
const candidateQuiet = !!candidate && candidate.responseEpoch === this.responseEpoch
|
|
710
|
+
&& candidate.lastOutputAt === snap.lastOutputAt
|
|
711
|
+
&& candidate.lastScreenChangeAt === snap.lastScreenChangeAt
|
|
712
|
+
&& assistantLength >= candidate.assistantLength
|
|
713
|
+
&& (now - candidate.armedAt) >= idleFinishConfirmMs;
|
|
714
|
+
|
|
715
|
+
if (idleReady && candidateQuiet) {
|
|
716
|
+
this.clearIdleFinishCandidate('finish_response');
|
|
717
|
+
if (this.idleTimeout) clearTimeout(this.idleTimeout);
|
|
718
|
+
this.finishResponse();
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (idleReady) {
|
|
723
|
+
if (!candidate) { this.armIdleFinishCandidate(snap, assistantLength); return; }
|
|
724
|
+
} else {
|
|
725
|
+
this.clearIdleFinishCandidate('idle_not_ready');
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (this.idleTimeout) clearTimeout(this.idleTimeout);
|
|
729
|
+
this.idleTimeout = setTimeout(() => {
|
|
730
|
+
if (this.isWaitingForResponse && !this.hasActionableApproval()) {
|
|
731
|
+
if (this.shouldDeferIdleTimeoutFinish()) return;
|
|
732
|
+
const parsed = this.runParseSession(this.transport.getSnapshot());
|
|
733
|
+
if (this.shouldDeferFinishForTranscript(parsed)) {
|
|
734
|
+
this.rescheduleTranscriptFinishCheck('transcript_idle_timeout_not_final');
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
this.clearIdleFinishCandidate('idle_timeout_finish');
|
|
738
|
+
this.finishResponse();
|
|
739
|
+
}
|
|
740
|
+
}, this.timeouts.idleFinish);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
finishResponse(): void {
|
|
744
|
+
if (this.submitPendingUntil > Date.now()) return;
|
|
745
|
+
if (this.responseSettleIgnoreUntil > Date.now()) return;
|
|
746
|
+
const snap = this.transport.getSnapshot();
|
|
747
|
+
const parsedBeforeFinish = this.runParseSession(snap);
|
|
748
|
+
if (this.shouldDeferFinishForTranscript(parsedBeforeFinish)) {
|
|
749
|
+
this.rescheduleTranscriptFinishCheck('transcript_finish_not_final');
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
this.clearIdleFinishCandidate('finish_response_enter');
|
|
753
|
+
const commitResult = this.commitCurrentTranscript(snap);
|
|
754
|
+
if (this.shouldRetryFinishResponse(snap, commitResult)) {
|
|
755
|
+
this.finishRetryCount += 1;
|
|
756
|
+
if (this.finishRetryTimer) clearTimeout(this.finishRetryTimer);
|
|
757
|
+
this.finishRetryTimer = setTimeout(() => {
|
|
758
|
+
this.finishRetryTimer = null;
|
|
759
|
+
if (this.isWaitingForResponse && !this.hasActionableApproval()) this.finishResponse();
|
|
760
|
+
}, FINISH_RETRY_DELAY_MS);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
this.resetActiveTurnState();
|
|
764
|
+
this.callbacks.onTurnCompleted();
|
|
765
|
+
this.setStatus('idle', 'response_finished');
|
|
766
|
+
this.callbacks.onStatusChange();
|
|
767
|
+
this.transport.flushOutboundQueue();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
771
|
+
|
|
772
|
+
private armApprovalExitTimeout(): void {
|
|
773
|
+
if (this.approvalExitTimeout) clearTimeout(this.approvalExitTimeout);
|
|
774
|
+
this.approvalExitTimeout = setTimeout(() => {
|
|
775
|
+
if (!this.hasActionableApproval()) return;
|
|
776
|
+
const snap = this.transport.getSnapshot();
|
|
777
|
+
const modal = typeof this.transport.runParseApproval === 'function'
|
|
778
|
+
? this.transport.runParseApproval(snap.recentOutputBuffer.slice(-500))
|
|
779
|
+
: this.runParseApproval(snap);
|
|
780
|
+
const detectStatus = typeof this.transport.runDetectStatus === 'function'
|
|
781
|
+
? this.transport.runDetectStatus(snap.recentOutputBuffer)
|
|
782
|
+
: this.runDetectStatus(snap);
|
|
783
|
+
const stillWaiting = detectStatus === 'waiting_approval' || !!modal;
|
|
784
|
+
if (stillWaiting) {
|
|
785
|
+
if (!modal) {
|
|
786
|
+
LOG.warn('CLI', `[${this.provider.type}] approval timeout: no actionable modal; keeping fail-closed`);
|
|
787
|
+
this.activeModal = null;
|
|
788
|
+
this.callbacks.onStatusChange();
|
|
789
|
+
this.armApprovalExitTimeout();
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
this.activeModal = modal;
|
|
793
|
+
this.callbacks.onStatusChange();
|
|
794
|
+
this.armApprovalExitTimeout();
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
LOG.warn('CLI', `[${this.provider.type}] Approval timeout — auto-clearing`);
|
|
798
|
+
this.activeModal = null;
|
|
799
|
+
this.lastApprovalResolvedAt = Date.now();
|
|
800
|
+
this.setStatus('idle', 'approval_timeout');
|
|
801
|
+
this.callbacks.onStatusChange();
|
|
802
|
+
}, APPROVAL_EXIT_TIMEOUT_MS);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private armIdleFinishCandidate(snap: CliBufferSnapshot, assistantLength: number): void {
|
|
806
|
+
const now = Date.now();
|
|
807
|
+
this.idleFinishCandidate = {
|
|
808
|
+
armedAt: now,
|
|
809
|
+
lastOutputAt: snap.lastOutputAt,
|
|
810
|
+
lastScreenChangeAt: snap.lastScreenChangeAt,
|
|
811
|
+
responseEpoch: this.responseEpoch,
|
|
812
|
+
assistantLength,
|
|
813
|
+
};
|
|
814
|
+
if (this.settleTimer) clearTimeout(this.settleTimer);
|
|
815
|
+
this.settleTimer = setTimeout(() => {
|
|
816
|
+
this.settleTimer = null;
|
|
817
|
+
this.evaluateSettled(this.transport.getSnapshot());
|
|
818
|
+
}, this.timeouts.idleFinishConfirm);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
private shouldDeferIdleTimeoutFinish(): boolean {
|
|
822
|
+
if (!this.isWaitingForResponse || this.hasActionableApproval()) return false;
|
|
823
|
+
const snap = this.transport.getSnapshot();
|
|
824
|
+
const detectFn = typeof this.transport.runDetectStatus === 'function'
|
|
825
|
+
? () => this.transport.runDetectStatus!(snap.recentOutputBuffer)
|
|
826
|
+
: () => this.runDetectStatus(snap);
|
|
827
|
+
const latestStatus = detectFn() || this.currentStatus;
|
|
828
|
+
if (latestStatus === 'generating') {
|
|
829
|
+
this.evaluateSettled(snap);
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private hasRecentInteractiveActivity(snap: CliBufferSnapshot, now: number): boolean {
|
|
836
|
+
const quietForMs = snap.lastNonEmptyOutputAt ? (now - snap.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
|
|
837
|
+
const screenStableMs = snap.lastScreenChangeAt ? (now - snap.lastScreenChangeAt) : Number.MAX_SAFE_INTEGER;
|
|
838
|
+
return quietForMs < this.timeouts.statusActivityHold || screenStableMs < this.timeouts.statusActivityHold;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private hasMeaningfulResponseBuffer(snap: CliBufferSnapshot, normalizedPromptSnippet: string): boolean {
|
|
842
|
+
const raw = String(snap.responseBuffer || '').trim();
|
|
843
|
+
if (!raw) return false;
|
|
844
|
+
const normalizedPrompt = compactPromptText(normalizedPromptSnippet);
|
|
845
|
+
if (!normalizedPrompt) return true;
|
|
846
|
+
const normalizedBuffer = compactPromptText(raw);
|
|
847
|
+
if (!normalizedBuffer) return false;
|
|
848
|
+
if (normalizedBuffer === normalizedPrompt) return false;
|
|
849
|
+
if (normalizedBuffer.startsWith(normalizedPrompt)) {
|
|
850
|
+
const remainder = normalizedBuffer.slice(normalizedPrompt.length)
|
|
851
|
+
.replace(/[─═\-]+/g, '')
|
|
852
|
+
.replace(/⏵⏵accepteditson\([^)]*\)/gi, '')
|
|
853
|
+
.replace(/esctointerrupt/gi, '')
|
|
854
|
+
.replace(/❯/g, '')
|
|
855
|
+
.replace(/^[\s\-–—:;,.!/?]+/, '')
|
|
856
|
+
.trim();
|
|
857
|
+
return remainder.length > 0;
|
|
858
|
+
}
|
|
859
|
+
return true;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private shouldDeferFinishForTranscript(parsed: any): boolean {
|
|
863
|
+
// Support both explicit flag and legacy codex-cli type check
|
|
864
|
+
// Also check transport.cliType to support tests that patch adapter.cliType directly
|
|
865
|
+
const effectiveType = this.transport.cliType ?? this.provider.type;
|
|
866
|
+
const requiresFinalAssistant = !!this.provider.requiresFinalAssistantBeforeIdle
|
|
867
|
+
|| effectiveType === 'codex-cli';
|
|
868
|
+
if (!requiresFinalAssistant) return false;
|
|
869
|
+
if (!this.isWaitingForResponse || !this.currentTurnScope || this.hasActionableApproval()) return false;
|
|
870
|
+
const parsedStatus = typeof parsed?.status === 'string' ? parsed.status.trim() : '';
|
|
871
|
+
if (parsedStatus !== 'idle') return true;
|
|
872
|
+
if (parsed?.activeModal || parsed?.modal) return true;
|
|
873
|
+
return !this.parsedStatusHasFinalStandardAssistantMessage(parsed);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
private rescheduleTranscriptFinishCheck(reason: string): void {
|
|
877
|
+
this.clearIdleFinishCandidate(reason);
|
|
878
|
+
this.setStatus('generating', reason);
|
|
879
|
+
if (this.idleTimeout) clearTimeout(this.idleTimeout);
|
|
880
|
+
this.idleTimeout = setTimeout(() => {
|
|
881
|
+
if (!this.isWaitingForResponse || this.hasActionableApproval()) return;
|
|
882
|
+
this.evaluateSettled(this.transport.getSnapshot());
|
|
883
|
+
}, this.timeouts.idleFinishConfirm);
|
|
884
|
+
this.recordTrace('transcript_finish_deferred', { reason });
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
private shouldRetryFinishResponse(snap: CliBufferSnapshot, commitResult: { hasAssistant: boolean; assistantContent: string }): boolean {
|
|
888
|
+
if (!this.currentTurnScope) return false;
|
|
889
|
+
if (this.hasActionableApproval()) return false;
|
|
890
|
+
if (this.finishRetryCount >= MAX_FINISH_RETRIES) return false;
|
|
891
|
+
if (commitResult.hasAssistant && commitResult.assistantContent.trim()) return false;
|
|
892
|
+
if (this.runDetectStatus(snap) !== 'idle') return false;
|
|
893
|
+
const now = Date.now();
|
|
894
|
+
const quietForMs = snap.lastNonEmptyOutputAt ? (now - snap.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
|
|
895
|
+
const screenStableMs = snap.lastScreenChangeAt ? (now - snap.lastScreenChangeAt) : 0;
|
|
896
|
+
return quietForMs < 1200 || screenStableMs < 1200 || !commitResult.hasAssistant;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private commitCurrentTranscript(snap: CliBufferSnapshot): { hasAssistant: boolean; assistantContent: string } {
|
|
900
|
+
const parsed = this.runParseSession(snap);
|
|
901
|
+
if (parsed && Array.isArray(parsed.messages)) {
|
|
902
|
+
const parsedMessages = normalizeCliParsedMessages(parsed.messages, { scope: null, lastOutputAt: snap.lastOutputAt });
|
|
903
|
+
const lastAssistant = [...parsedMessages].reverse().find((m) => m.role === 'assistant');
|
|
904
|
+
if (lastAssistant) return { hasAssistant: true, assistantContent: lastAssistant.content || '' };
|
|
905
|
+
}
|
|
906
|
+
return { hasAssistant: false, assistantContent: '' };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
private maybeCommitVisibleIdleTranscript(session: ParsedSession, parsedMessages: CliChatMessage[], snap: CliBufferSnapshot): boolean {
|
|
910
|
+
if (!this.provider.allowInputDuringGeneration) return false;
|
|
911
|
+
if (!session || session.status !== 'idle' || !this.isWaitingForResponse || !this.currentTurnScope || this.activeModal || session.modal) return false;
|
|
912
|
+
const visibleAssistant = [...parsedMessages].reverse().find((m) => m.role === 'assistant' && String(m.content || '').trim());
|
|
913
|
+
if (!visibleAssistant) return false;
|
|
914
|
+
this.resetActiveTurnState();
|
|
915
|
+
this.callbacks.onTurnCompleted();
|
|
916
|
+
this.setStatus('idle', 'script_idle_commit');
|
|
917
|
+
this.callbacks.onStatusChange();
|
|
918
|
+
this.transport.flushOutboundQueue();
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
private parsedStatusHasFinalAssistantMessage(parsed: any): boolean {
|
|
923
|
+
const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
|
|
924
|
+
const last = [...messages].reverse().find((m: any) => {
|
|
925
|
+
if (!m || m.role !== 'assistant') return false;
|
|
926
|
+
return typeof m.content === 'string' && m.content.trim().length > 0;
|
|
927
|
+
});
|
|
928
|
+
return !!last;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private parsedStatusHasFinalStandardAssistantMessage(parsed: any): boolean {
|
|
932
|
+
const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
|
|
933
|
+
const last = [...messages].reverse().find((m: any) => {
|
|
934
|
+
if (!m || m.role !== 'assistant') return false;
|
|
935
|
+
if (typeof m.content !== 'string' || !m.content.trim()) return false;
|
|
936
|
+
const kind = typeof m.kind === 'string' && m.kind.trim() ? m.kind.trim() : 'standard';
|
|
937
|
+
return kind === 'standard' && m.meta?.streaming !== true;
|
|
938
|
+
});
|
|
939
|
+
return !!last;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
private recordTrace(type: string, payload: Record<string, any> = {}): void {
|
|
943
|
+
const entry: CliTraceEntry = {
|
|
944
|
+
id: ++this.traceSeq,
|
|
945
|
+
at: Date.now(),
|
|
946
|
+
type,
|
|
947
|
+
status: this.currentStatus,
|
|
948
|
+
isWaitingForResponse: this.isWaitingForResponse,
|
|
949
|
+
activeModal: this.activeModal ? { message: this.activeModal.message, buttons: [...this.activeModal.buttons] } : null,
|
|
950
|
+
payload,
|
|
951
|
+
};
|
|
952
|
+
this.traceEntries.push(entry);
|
|
953
|
+
if (this.traceEntries.length > MAX_TRACE_ENTRIES) {
|
|
954
|
+
this.traceEntries.splice(0, this.traceEntries.length - MAX_TRACE_ENTRIES);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|