@love-moon/tui-driver 0.2.0

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 (113) hide show
  1. package/README.md +142 -0
  2. package/dist/driver/StateMachine.d.ts +28 -0
  3. package/dist/driver/StateMachine.d.ts.map +1 -0
  4. package/dist/driver/StateMachine.js +56 -0
  5. package/dist/driver/StateMachine.js.map +1 -0
  6. package/dist/driver/TuiDriver.d.ts +73 -0
  7. package/dist/driver/TuiDriver.d.ts.map +1 -0
  8. package/dist/driver/TuiDriver.js +506 -0
  9. package/dist/driver/TuiDriver.js.map +1 -0
  10. package/dist/driver/TuiProfile.d.ts +59 -0
  11. package/dist/driver/TuiProfile.d.ts.map +1 -0
  12. package/dist/driver/TuiProfile.js +13 -0
  13. package/dist/driver/TuiProfile.js.map +1 -0
  14. package/dist/driver/index.d.ts +5 -0
  15. package/dist/driver/index.d.ts.map +1 -0
  16. package/dist/driver/index.js +13 -0
  17. package/dist/driver/index.js.map +1 -0
  18. package/dist/driver/profiles/claudeCode.profile.d.ts +4 -0
  19. package/dist/driver/profiles/claudeCode.profile.d.ts.map +1 -0
  20. package/dist/driver/profiles/claudeCode.profile.js +91 -0
  21. package/dist/driver/profiles/claudeCode.profile.js.map +1 -0
  22. package/dist/driver/profiles/codex.profile.d.ts +4 -0
  23. package/dist/driver/profiles/codex.profile.d.ts.map +1 -0
  24. package/dist/driver/profiles/codex.profile.js +82 -0
  25. package/dist/driver/profiles/codex.profile.js.map +1 -0
  26. package/dist/driver/profiles/index.d.ts +3 -0
  27. package/dist/driver/profiles/index.d.ts.map +1 -0
  28. package/dist/driver/profiles/index.js +8 -0
  29. package/dist/driver/profiles/index.js.map +1 -0
  30. package/dist/example.d.ts +2 -0
  31. package/dist/example.d.ts.map +1 -0
  32. package/dist/example.js +43 -0
  33. package/dist/example.js.map +1 -0
  34. package/dist/expect/ExpectEngine.d.ts +34 -0
  35. package/dist/expect/ExpectEngine.d.ts.map +1 -0
  36. package/dist/expect/ExpectEngine.js +121 -0
  37. package/dist/expect/ExpectEngine.js.map +1 -0
  38. package/dist/expect/Matchers.d.ts +24 -0
  39. package/dist/expect/Matchers.d.ts.map +1 -0
  40. package/dist/expect/Matchers.js +71 -0
  41. package/dist/expect/Matchers.js.map +1 -0
  42. package/dist/expect/index.d.ts +3 -0
  43. package/dist/expect/index.d.ts.map +1 -0
  44. package/dist/expect/index.js +8 -0
  45. package/dist/expect/index.js.map +1 -0
  46. package/dist/extract/Diff.d.ts +10 -0
  47. package/dist/extract/Diff.d.ts.map +1 -0
  48. package/dist/extract/Diff.js +44 -0
  49. package/dist/extract/Diff.js.map +1 -0
  50. package/dist/extract/OutputExtractor.d.ts +16 -0
  51. package/dist/extract/OutputExtractor.d.ts.map +1 -0
  52. package/dist/extract/OutputExtractor.js +71 -0
  53. package/dist/extract/OutputExtractor.js.map +1 -0
  54. package/dist/extract/index.d.ts +3 -0
  55. package/dist/extract/index.d.ts.map +1 -0
  56. package/dist/extract/index.js +11 -0
  57. package/dist/extract/index.js.map +1 -0
  58. package/dist/index.d.ts +11 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +50 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/pty/PtySession.d.ts +38 -0
  63. package/dist/pty/PtySession.d.ts.map +1 -0
  64. package/dist/pty/PtySession.js +231 -0
  65. package/dist/pty/PtySession.js.map +1 -0
  66. package/dist/pty/index.d.ts +2 -0
  67. package/dist/pty/index.d.ts.map +1 -0
  68. package/dist/pty/index.js +6 -0
  69. package/dist/pty/index.js.map +1 -0
  70. package/dist/term/HeadlessScreen.d.ts +29 -0
  71. package/dist/term/HeadlessScreen.d.ts.map +1 -0
  72. package/dist/term/HeadlessScreen.js +126 -0
  73. package/dist/term/HeadlessScreen.js.map +1 -0
  74. package/dist/term/ScreenSnapshot.d.ts +37 -0
  75. package/dist/term/ScreenSnapshot.d.ts.map +1 -0
  76. package/dist/term/ScreenSnapshot.js +68 -0
  77. package/dist/term/ScreenSnapshot.js.map +1 -0
  78. package/dist/term/index.d.ts +3 -0
  79. package/dist/term/index.d.ts.map +1 -0
  80. package/dist/term/index.js +8 -0
  81. package/dist/term/index.js.map +1 -0
  82. package/docs/tui-driver_implementation_plan.md +307 -0
  83. package/package.json +33 -0
  84. package/pnpm-workspace.yaml +1 -0
  85. package/src/driver/StateMachine.ts +90 -0
  86. package/src/driver/TuiDriver.ts +624 -0
  87. package/src/driver/TuiProfile.ts +72 -0
  88. package/src/driver/index.ts +4 -0
  89. package/src/driver/profiles/claudeCode.profile.ts +96 -0
  90. package/src/driver/profiles/codex.profile.ts +87 -0
  91. package/src/driver/profiles/index.ts +2 -0
  92. package/src/example.ts +45 -0
  93. package/src/expect/ExpectEngine.ts +171 -0
  94. package/src/expect/Matchers.ts +92 -0
  95. package/src/expect/index.ts +2 -0
  96. package/src/extract/Diff.ts +51 -0
  97. package/src/extract/OutputExtractor.ts +88 -0
  98. package/src/extract/index.ts +2 -0
  99. package/src/index.ts +67 -0
  100. package/src/pty/PtySession.ts +234 -0
  101. package/src/pty/index.ts +1 -0
  102. package/src/term/HeadlessScreen.ts +151 -0
  103. package/src/term/ScreenSnapshot.ts +89 -0
  104. package/src/term/index.ts +2 -0
  105. package/test/claude-profile.test.ts +11 -0
  106. package/test/codex-profile.test.ts +108 -0
  107. package/test/debug-claude.ts +51 -0
  108. package/test/integration.ts +174 -0
  109. package/test/output-extractor.test.ts +49 -0
  110. package/test/state-diff.test.ts +120 -0
  111. package/test/unit.test.ts +136 -0
  112. package/tsconfig.json +20 -0
  113. package/vitest.config.ts +13 -0
@@ -0,0 +1,90 @@
1
+ import { EventEmitter } from "events";
2
+
3
+ export type TuiState =
4
+ | "IDLE"
5
+ | "BOOT"
6
+ | "WAIT_READY"
7
+ | "PREPARE_TURN"
8
+ | "TYPE_PROMPT"
9
+ | "SUBMIT"
10
+ | "WAIT_STREAM_START"
11
+ | "WAIT_STREAM_END"
12
+ | "CAPTURE"
13
+ | "DONE"
14
+ | "ERROR"
15
+ | "RECOVERY";
16
+
17
+ export interface StateTransition {
18
+ from: TuiState;
19
+ to: TuiState;
20
+ timestamp: number;
21
+ }
22
+
23
+ export interface StateMachineEvents {
24
+ stateChange: (transition: StateTransition) => void;
25
+ error: (error: Error, state: TuiState) => void;
26
+ }
27
+
28
+ export class StateMachine extends EventEmitter {
29
+ private _state: TuiState = "IDLE";
30
+ private _history: StateTransition[] = [];
31
+ private _stateStartTime: number = Date.now();
32
+
33
+ get state(): TuiState {
34
+ return this._state;
35
+ }
36
+
37
+ get history(): readonly StateTransition[] {
38
+ return this._history;
39
+ }
40
+
41
+ get stateElapsedMs(): number {
42
+ return Date.now() - this._stateStartTime;
43
+ }
44
+
45
+ transition(to: TuiState): void {
46
+ const from = this._state;
47
+ const timestamp = Date.now();
48
+
49
+ const transition: StateTransition = { from, to, timestamp };
50
+ this._history.push(transition);
51
+ this._state = to;
52
+ this._stateStartTime = timestamp;
53
+
54
+ this.emit("stateChange", transition);
55
+ }
56
+
57
+ transitionTo(...states: TuiState[]): void {
58
+ for (const state of states) {
59
+ this.transition(state);
60
+ }
61
+ }
62
+
63
+ isIn(...states: TuiState[]): boolean {
64
+ return states.includes(this._state);
65
+ }
66
+
67
+ reset(): void {
68
+ this._state = "IDLE";
69
+ this._history = [];
70
+ this._stateStartTime = Date.now();
71
+ }
72
+
73
+ error(err: Error): void {
74
+ const previousState = this._state;
75
+ this.transition("ERROR");
76
+ this.emit("error", err, previousState);
77
+ }
78
+
79
+ recover(): void {
80
+ this.transition("RECOVERY");
81
+ }
82
+
83
+ getLastTransition(): StateTransition | undefined {
84
+ return this._history[this._history.length - 1];
85
+ }
86
+
87
+ getTransitionsSince(timestamp: number): StateTransition[] {
88
+ return this._history.filter(t => t.timestamp >= timestamp);
89
+ }
90
+ }
@@ -0,0 +1,624 @@
1
+ import { EventEmitter } from "events";
2
+ import { PtySession } from "../pty/PtySession.js";
3
+ import { HeadlessScreen } from "../term/HeadlessScreen.js";
4
+ import { ScreenSnapshot } from "../term/ScreenSnapshot.js";
5
+ import { ExpectEngine } from "../expect/ExpectEngine.js";
6
+ import { Matchers } from "../expect/Matchers.js";
7
+ import { OutputExtractor } from "../extract/OutputExtractor.js";
8
+ import { computeLineDiff } from "../extract/Diff.js";
9
+ import { TuiProfile, TuiSignals } from "./TuiProfile.js";
10
+ import { StateMachine, TuiState } from "./StateMachine.js";
11
+
12
+ export interface TuiDriverOptions {
13
+ profile: TuiProfile;
14
+ debug?: boolean;
15
+ onSnapshot?: (snapshot: ScreenSnapshot, state: TuiState) => void;
16
+ onSignals?: (signals: TuiScreenSignals, snapshot: ScreenSnapshot, state: TuiState) => void;
17
+ }
18
+
19
+ export interface AskResult {
20
+ success: boolean;
21
+ answer: string;
22
+ beforeSnapshot: ScreenSnapshot;
23
+ afterSnapshot: ScreenSnapshot;
24
+ elapsedMs: number;
25
+ error?: Error;
26
+ signals?: TuiScreenSignals;
27
+ promptLine?: string;
28
+ replyText?: string;
29
+ replyBlocks?: string[];
30
+ replyInProgress?: boolean;
31
+ statusLine?: string;
32
+ statusDoneLine?: string;
33
+ }
34
+
35
+ export interface TuiScreenSignals {
36
+ hasPrompt: boolean;
37
+ promptLine?: string;
38
+ replyText?: string;
39
+ replyBlocks?: string[];
40
+ replyInProgress: boolean;
41
+ statusLine?: string;
42
+ statusDoneLine?: string;
43
+ }
44
+
45
+ export class TuiDriver extends EventEmitter {
46
+ private pty: PtySession;
47
+ private screen: HeadlessScreen;
48
+ private expect: ExpectEngine;
49
+ private stateMachine: StateMachine;
50
+ private profile: TuiProfile;
51
+ private debug: boolean;
52
+ private onSnapshot?: (snapshot: ScreenSnapshot, state: TuiState) => void;
53
+ private onSignals?: (signals: TuiScreenSignals, snapshot: ScreenSnapshot, state: TuiState) => void;
54
+ private isBooted = false;
55
+
56
+ constructor(options: TuiDriverOptions) {
57
+ super();
58
+ this.profile = options.profile;
59
+ this.debug = options.debug ?? false;
60
+ this.onSnapshot = options.onSnapshot;
61
+ this.onSignals = options.onSignals;
62
+
63
+ const cols = this.profile.cols ?? 120;
64
+ const rows = this.profile.rows ?? 40;
65
+ const scrollback = this.profile.scrollback ?? 5000;
66
+
67
+ this.pty = new PtySession(
68
+ this.profile.command,
69
+ this.profile.args,
70
+ { cols, rows, env: this.profile.env }
71
+ );
72
+
73
+ this.screen = new HeadlessScreen({
74
+ cols,
75
+ rows,
76
+ scrollback,
77
+ onTerminalReply: (data) => this.pty.writeResponse(data),
78
+ });
79
+ this.expect = new ExpectEngine(this.screen);
80
+ this.stateMachine = new StateMachine();
81
+
82
+ this.setupEventHandlers();
83
+ }
84
+
85
+ private setupEventHandlers(): void {
86
+ this.pty.onData((chunk) => {
87
+ this.screen.write(chunk);
88
+ if (this.debug) {
89
+ this.log(`PTY data: ${chunk.length} bytes`);
90
+ }
91
+ });
92
+
93
+ this.pty.onExit((code, signal) => {
94
+ this.log(`PTY exited: code=${code}, signal=${signal}`);
95
+ this.isBooted = false;
96
+ this.emit("exit", code, signal);
97
+ });
98
+
99
+ this.stateMachine.on("stateChange", (transition) => {
100
+ this.log(`State: ${transition.from} -> ${transition.to}`);
101
+ this.emit("stateChange", transition);
102
+ });
103
+
104
+ this.stateMachine.on("error", (err, state) => {
105
+ this.log(`Error in state ${state}: ${err.message}`);
106
+ this.emit("error", err, state);
107
+ });
108
+ }
109
+
110
+ private log(message: string): void {
111
+ if (this.debug) {
112
+ console.log(`[TuiDriver] ${message}`);
113
+ }
114
+ }
115
+
116
+ get state(): TuiState {
117
+ return this.stateMachine.state;
118
+ }
119
+
120
+ get running(): boolean {
121
+ return this.pty.isRunning;
122
+ }
123
+
124
+ async boot(): Promise<void> {
125
+ if (this.isBooted) {
126
+ return;
127
+ }
128
+
129
+ this.stateMachine.transition("BOOT");
130
+ this.pty.spawn();
131
+
132
+ const bootTimeout = this.resolveTimeout(this.profile.timeouts?.boot, 15000);
133
+ const readyMatcher = Matchers.anyOf(this.profile.anchors.ready);
134
+ const trustMatcher = this.profile.anchors.trust
135
+ ? Matchers.anyOf(this.profile.anchors.trust)
136
+ : null;
137
+ const startTime = Date.now();
138
+ let readySince: number | null = null;
139
+ let lastTrustConfirmAt = 0;
140
+
141
+ while (true) {
142
+ if (!this.running) {
143
+ throw new Error("Boot failed: TUI process exited before ready");
144
+ }
145
+
146
+ const now = Date.now();
147
+ const snapshot = this.screen.snapshot();
148
+
149
+ if (readyMatcher(snapshot)) {
150
+ if (readySince === null) {
151
+ readySince = now;
152
+ } else if (now - readySince >= 300) {
153
+ break;
154
+ }
155
+ } else {
156
+ readySince = null;
157
+ }
158
+
159
+ if (trustMatcher && trustMatcher(snapshot) && now - lastTrustConfirmAt >= 1500) {
160
+ lastTrustConfirmAt = now;
161
+ this.log("Trust prompt detected, confirming...");
162
+ await this.pty.sendKeys(this.profile.keys.trustConfirm ?? ["ENTER"], 0);
163
+ }
164
+
165
+ if (Number.isFinite(bootTimeout) && now - startTime >= bootTimeout) {
166
+ throw new Error("Boot timeout: TUI did not become ready");
167
+ }
168
+
169
+ await this.sleep(100);
170
+ }
171
+
172
+ this.isBooted = true;
173
+ this.stateMachine.transition("WAIT_READY");
174
+ this.captureSnapshot("boot_complete");
175
+ }
176
+
177
+ async ensureReady(): Promise<void> {
178
+ if (!this.isBooted) {
179
+ await this.boot();
180
+ return;
181
+ }
182
+
183
+ const readyMatcher = Matchers.anyOf(this.profile.anchors.ready);
184
+ const readyTimeout = this.resolveTimeout(this.profile.timeouts?.ready, 10000);
185
+
186
+ const result = await this.expect.until({
187
+ name: "ENSURE_READY",
188
+ match: readyMatcher,
189
+ stableMs: 200,
190
+ timeoutMs: readyTimeout,
191
+ });
192
+
193
+ if (!result.success) {
194
+ await this.recovery();
195
+ throw new Error("TUI not ready after recovery");
196
+ }
197
+ }
198
+
199
+ async ask(prompt: string): Promise<AskResult> {
200
+ const startTime = Date.now();
201
+
202
+ try {
203
+ await this.ensureReady();
204
+
205
+ this.stateMachine.transition("PREPARE_TURN");
206
+ await this.prepareTurn();
207
+
208
+ this.stateMachine.transition("TYPE_PROMPT");
209
+ await this.typePrompt(prompt);
210
+ await this.sleep(100);
211
+
212
+ const preSubmitSnapshot = this.captureSnapshot("before_submit");
213
+
214
+ this.stateMachine.transition("SUBMIT");
215
+ await this.submit();
216
+
217
+ // 在 submit 之后保存快照,这样 diff 只包含 AI 的回答,不包含 prompt
218
+ const beforeSnapshot = this.captureSnapshot("after_submit");
219
+
220
+ this.stateMachine.transition("WAIT_STREAM_START");
221
+ await this.waitStreamStart(preSubmitSnapshot);
222
+
223
+ this.stateMachine.transition("WAIT_STREAM_END");
224
+ await this.waitStreamEnd();
225
+
226
+ this.stateMachine.transition("CAPTURE");
227
+ const afterSnapshot = this.captureSnapshot("after_response");
228
+
229
+ const answer = this.extractAnswer(beforeSnapshot, afterSnapshot);
230
+ const signals = this.getSignals(afterSnapshot);
231
+
232
+ this.stateMachine.transition("DONE");
233
+
234
+ return {
235
+ success: true,
236
+ answer,
237
+ beforeSnapshot,
238
+ afterSnapshot,
239
+ elapsedMs: Date.now() - startTime,
240
+ signals,
241
+ promptLine: signals.promptLine,
242
+ replyText: signals.replyText,
243
+ replyBlocks: signals.replyBlocks,
244
+ replyInProgress: signals.replyInProgress,
245
+ statusLine: signals.statusLine,
246
+ statusDoneLine: signals.statusDoneLine,
247
+ };
248
+ } catch (error) {
249
+ this.stateMachine.error(error as Error);
250
+ const signals = this.getSignals(this.screen.snapshot());
251
+ return {
252
+ success: false,
253
+ answer: "",
254
+ beforeSnapshot: this.screen.snapshot(),
255
+ afterSnapshot: this.screen.snapshot(),
256
+ elapsedMs: Date.now() - startTime,
257
+ error: error as Error,
258
+ signals,
259
+ promptLine: signals.promptLine,
260
+ replyText: signals.replyText,
261
+ replyBlocks: signals.replyBlocks,
262
+ replyInProgress: signals.replyInProgress,
263
+ statusLine: signals.statusLine,
264
+ statusDoneLine: signals.statusDoneLine,
265
+ };
266
+ }
267
+ }
268
+
269
+ private async prepareTurn(): Promise<void> {
270
+ if (this.profile.preflightKeys && this.profile.preflightKeys.length > 0) {
271
+ await this.pty.sendKeys(this.profile.preflightKeys, 50);
272
+ await this.sleep(100);
273
+ }
274
+ }
275
+
276
+ private async typePrompt(prompt: string): Promise<void> {
277
+ await this.pty.writeChunked(prompt, 300, 20);
278
+ }
279
+
280
+ private async submit(): Promise<void> {
281
+ await this.pty.sendKeys(this.profile.keys.submit, 50);
282
+ }
283
+
284
+ private async waitStreamStart(previousSnapshot: ScreenSnapshot): Promise<void> {
285
+ const timeout = this.resolveTimeout(this.profile.timeouts?.streamStart, 10000);
286
+
287
+ if (this.profile.anchors.busy && this.profile.anchors.busy.length > 0) {
288
+ const busyPatterns = this.profile.anchors.busy;
289
+ const previousScrollback = previousSnapshot.scrollbackText;
290
+ const startMatcher = (snapshot: ScreenSnapshot): boolean => {
291
+ const { added } = computeLineDiff(previousScrollback, snapshot.scrollbackText);
292
+ return added.some(line => busyPatterns.some(pattern => pattern.test(line)));
293
+ };
294
+ const result = await this.expect.until({
295
+ name: "STREAM_START",
296
+ match: startMatcher,
297
+ stableMs: 100,
298
+ timeoutMs: timeout,
299
+ });
300
+ if (!result.success) {
301
+ throw new Error("Stream start timeout: no response detected");
302
+ }
303
+ } else {
304
+ const result = await this.expect.waitForChange(timeout);
305
+ if (!result.success) {
306
+ throw new Error("Stream start timeout: no screen change detected");
307
+ }
308
+ }
309
+ }
310
+
311
+ private async waitStreamEnd(): Promise<void> {
312
+ const idleMs = this.profile.timeouts?.idle ?? 800;
313
+ const timeout = this.resolveTimeout(this.profile.timeouts?.streamEnd, 120000);
314
+
315
+ const readyMatcher = Matchers.anyOf(this.profile.anchors.ready);
316
+ const busyMatcher = this.profile.anchors.busy?.length
317
+ ? Matchers.anyOf(this.profile.anchors.busy)
318
+ : null;
319
+ const replyStartMatcher = this.profile.signals?.replyStart?.length
320
+ ? Matchers.anyOf(this.profile.signals.replyStart, "scrollback")
321
+ : null;
322
+ const promptHintMatcher = this.profile.signals?.promptHint?.length
323
+ ? Matchers.anyOf(this.profile.signals.promptHint)
324
+ : null;
325
+ const statusMatcher = this.profile.signals?.status?.length
326
+ ? Matchers.anyOf(this.profile.signals.status)
327
+ : null;
328
+ const statusDoneMatcher = this.profile.signals?.statusDone?.length
329
+ ? Matchers.anyOf(this.profile.signals.statusDone)
330
+ : null;
331
+
332
+ // 组合条件:屏幕 idle + ready anchor 出现 + busy anchor 消失
333
+ const completeMatcher = busyMatcher
334
+ ? Matchers.and(readyMatcher, Matchers.not(busyMatcher))
335
+ : readyMatcher;
336
+
337
+ if (this.profile.requireReplyStart && replyStartMatcher) {
338
+ const replyOrHintResult = await this.expect.until({
339
+ name: "STREAM_END_REPLY_OR_HINT",
340
+ match: promptHintMatcher
341
+ ? Matchers.or(replyStartMatcher, promptHintMatcher)
342
+ : replyStartMatcher,
343
+ stableMs: 200,
344
+ timeoutMs: timeout,
345
+ });
346
+ if (!replyOrHintResult.success) {
347
+ throw new Error("Stream end timeout: no reply detected");
348
+ }
349
+
350
+ await this.waitForScrollbackIdle(idleMs, timeout);
351
+
352
+ if (statusMatcher) {
353
+ const statusClearResult = await this.expect.until({
354
+ name: "STREAM_END_STATUS_CLEAR",
355
+ match: statusDoneMatcher
356
+ ? Matchers.or(Matchers.not(statusMatcher), statusDoneMatcher)
357
+ : Matchers.not(statusMatcher),
358
+ stableMs: 300,
359
+ timeoutMs: timeout,
360
+ });
361
+ if (!statusClearResult.success) {
362
+ throw new Error("Stream end timeout: status did not clear");
363
+ }
364
+ }
365
+ } else {
366
+ // 先等待屏幕稳定
367
+ const idleResult = await this.expect.untilIdle({
368
+ name: "STREAM_END_IDLE",
369
+ idleMs,
370
+ timeoutMs: timeout,
371
+ });
372
+ if (!idleResult.success) {
373
+ throw new Error("Stream end timeout: screen did not become idle");
374
+ }
375
+ }
376
+
377
+ // 再确认 ready 状态
378
+ if (this.profile.requireReplyStart) {
379
+ const readyResult = await this.expect.until({
380
+ name: "STREAM_END_READY",
381
+ match: completeMatcher,
382
+ stableMs: 500,
383
+ timeoutMs: 3000,
384
+ });
385
+ if (!readyResult.success) {
386
+ this.log("Ready prompt not detected after reply completion");
387
+ }
388
+ } else {
389
+ const readyResult = await this.expect.until({
390
+ name: "STREAM_END_READY",
391
+ match: completeMatcher,
392
+ stableMs: 500,
393
+ timeoutMs: 10000,
394
+ });
395
+ if (!readyResult.success) {
396
+ throw new Error("Stream end timeout: ready prompt not detected");
397
+ }
398
+ }
399
+ }
400
+
401
+ private extractAnswer(before: ScreenSnapshot, after: ScreenSnapshot): string {
402
+ const extractor = new OutputExtractor(this.profile.extraction);
403
+ const primary = extractor.extract(before, after).text;
404
+ this.log(`extractAnswer: primary="${primary?.slice(0, 200) || '(empty)'}"`);
405
+ if (primary) {
406
+ return primary;
407
+ }
408
+
409
+ const signals = this.getSignals(after);
410
+ this.log(`extractAnswer: signals.replyText="${signals.replyText?.slice(0, 200) || '(empty)'}" blocks=${signals.replyBlocks?.length || 0}`);
411
+ const hasReplySignal = Boolean(
412
+ (signals.replyText && signals.replyText.trim()) ||
413
+ (Array.isArray(signals.replyBlocks) && signals.replyBlocks.length > 0)
414
+ );
415
+ if (!hasReplySignal) {
416
+ return "";
417
+ }
418
+
419
+ // Fallback: if includeLinePatterns misses the assistant block, retry with relaxed extraction.
420
+ const relaxedExtractor = new OutputExtractor({
421
+ ...this.profile.extraction,
422
+ includeLinePatterns: undefined,
423
+ });
424
+ const relaxed = relaxedExtractor.extract(before, after).text;
425
+ this.log(`extractAnswer: relaxed="${relaxed?.slice(0, 200) || '(empty)'}"`);
426
+ return relaxed;
427
+ }
428
+
429
+ private async recovery(): Promise<void> {
430
+ this.stateMachine.recover();
431
+
432
+ if (this.profile.keys.cancel) {
433
+ await this.pty.sendKeys(this.profile.keys.cancel, 100);
434
+ await this.sleep(500);
435
+ }
436
+
437
+ await this.pty.sendKeys(["ESC", "ESC"], 50);
438
+ await this.sleep(300);
439
+
440
+ const readyMatcher = Matchers.anyOf(this.profile.anchors.ready);
441
+ const result = await this.expect.until({
442
+ name: "RECOVERY_READY",
443
+ match: readyMatcher,
444
+ stableMs: 300,
445
+ timeoutMs: 5000,
446
+ });
447
+
448
+ if (!result.success) {
449
+ await this.restart();
450
+ }
451
+ }
452
+
453
+ private async restart(): Promise<void> {
454
+ this.log("Restarting PTY...");
455
+ this.pty.kill();
456
+ this.screen.reset();
457
+ this.isBooted = false;
458
+ await this.sleep(500);
459
+ await this.boot();
460
+ }
461
+
462
+ private captureSnapshot(label: string): ScreenSnapshot {
463
+ const snapshot = this.screen.snapshot();
464
+ if (this.onSnapshot) {
465
+ this.onSnapshot(snapshot, this.stateMachine.state);
466
+ }
467
+ const signals = this.getSignals(snapshot);
468
+ if (this.onSignals) {
469
+ this.onSignals(signals, snapshot, this.stateMachine.state);
470
+ }
471
+ this.emit("signals", signals, snapshot, this.stateMachine.state);
472
+ this.log(`Snapshot [${label}]: hash=${snapshot.hash}`);
473
+ return snapshot;
474
+ }
475
+
476
+ snapshot(): ScreenSnapshot {
477
+ return this.screen.snapshot();
478
+ }
479
+
480
+ getSignals(snapshot: ScreenSnapshot = this.screen.snapshot()): TuiScreenSignals {
481
+ const signals = this.profile.signals;
482
+ if (!signals) {
483
+ return {
484
+ hasPrompt: false,
485
+ replyInProgress: false,
486
+ };
487
+ }
488
+
489
+ const lines = snapshot.scrollbackText.split("\n");
490
+ // Log lines that match replyStart pattern
491
+ const replyStartLines = lines.filter(line =>
492
+ signals.replyStart?.some(pattern => pattern.test(line))
493
+ );
494
+ this.log(`getSignals: scrollback lines=${lines.length}, replyStart matches: ${JSON.stringify(replyStartLines.slice(0, 3))}`);
495
+ const promptLine = this.findLastMatch(lines, signals.prompt);
496
+ const statusLine = this.findLastMatch(lines, signals.status);
497
+ const statusDoneLine = this.findLastMatch(lines, signals.statusDone);
498
+ const reply = this.extractReplyBlocks(lines, signals);
499
+ this.log(`getSignals: promptLine="${promptLine || '(none)'}" statusLine="${statusLine || '(none)'}" reply.text="${reply.text?.slice(0, 100) || '(none)'}" reply.blocks=${reply.blocks.length}`);
500
+
501
+ return {
502
+ hasPrompt: Boolean(promptLine),
503
+ promptLine: promptLine ?? undefined,
504
+ replyText: reply.text ?? undefined,
505
+ replyBlocks: reply.blocks.length > 0 ? reply.blocks : undefined,
506
+ replyInProgress: reply.inProgress,
507
+ statusLine: statusLine ?? undefined,
508
+ statusDoneLine: statusDoneLine ?? undefined,
509
+ };
510
+ }
511
+
512
+ async sendKeys(keys: string[]): Promise<void> {
513
+ await this.pty.sendKeys(keys, 50);
514
+ }
515
+
516
+ async write(data: string): Promise<void> {
517
+ this.pty.write(data);
518
+ }
519
+
520
+ kill(): void {
521
+ this.pty.kill();
522
+ this.screen.dispose();
523
+ this.isBooted = false;
524
+ }
525
+
526
+ private sleep(ms: number): Promise<void> {
527
+ return new Promise((resolve) => setTimeout(resolve, ms));
528
+ }
529
+
530
+ private resolveTimeout(timeoutMs: number | undefined, defaultTimeoutMs: number): number {
531
+ const value = timeoutMs ?? defaultTimeoutMs;
532
+ if (!Number.isFinite(value) || value <= 0) {
533
+ return Number.POSITIVE_INFINITY;
534
+ }
535
+ return value;
536
+ }
537
+
538
+ private async waitForScrollbackIdle(idleMs: number, timeoutMs: number): Promise<void> {
539
+ const startTime = Date.now();
540
+ let lastHash = ScreenSnapshot.computeHash(this.screen.snapshot().scrollbackText);
541
+ let lastChangeTime = Date.now();
542
+
543
+ while (true) {
544
+ const elapsed = Date.now() - startTime;
545
+ if (elapsed >= timeoutMs) {
546
+ throw new Error("Stream end timeout: scrollback did not become idle");
547
+ }
548
+
549
+ const currentHash = ScreenSnapshot.computeHash(this.screen.snapshot().scrollbackText);
550
+ if (currentHash !== lastHash) {
551
+ lastHash = currentHash;
552
+ lastChangeTime = Date.now();
553
+ } else if (Date.now() - lastChangeTime >= idleMs) {
554
+ return;
555
+ }
556
+
557
+ await this.sleep(100);
558
+ }
559
+ }
560
+
561
+ private findLastMatch(lines: string[], patterns?: RegExp[]): string | null {
562
+ if (!patterns || patterns.length === 0) {
563
+ return null;
564
+ }
565
+
566
+ for (let i = lines.length - 1; i >= 0; i--) {
567
+ const line = lines[i];
568
+ if (patterns.some((pattern) => pattern.test(line))) {
569
+ return line;
570
+ }
571
+ }
572
+
573
+ return null;
574
+ }
575
+
576
+ private extractReplyBlocks(
577
+ lines: string[],
578
+ signals: TuiSignals
579
+ ): { text: string | null; inProgress: boolean; blocks: string[] } {
580
+ const startPatterns = signals.replyStart ?? [];
581
+ if (startPatterns.length === 0) {
582
+ return { text: null, inProgress: false, blocks: [] };
583
+ }
584
+
585
+ const stopPatterns = signals.replyStop ?? [];
586
+ let collecting = false;
587
+ let current: string[] = [];
588
+ const blocks: string[] = [];
589
+
590
+ for (const line of lines) {
591
+ if (startPatterns.some((pattern) => pattern.test(line))) {
592
+ if (collecting && current.length > 0) {
593
+ blocks.push(current.join("\n"));
594
+ this.log(`extractReplyBlocks: saved block with ${current.length} lines`);
595
+ }
596
+ collecting = true;
597
+ current = [line];
598
+ continue;
599
+ }
600
+
601
+ if (collecting) {
602
+ if (stopPatterns.some((pattern) => pattern.test(line))) {
603
+ blocks.push(current.join("\n"));
604
+ this.log(`extractReplyBlocks: stopped at "${line.slice(0, 50)}", saved block with ${current.length} lines`);
605
+ collecting = false;
606
+ current = [];
607
+ continue;
608
+ }
609
+ current.push(line);
610
+ }
611
+ }
612
+
613
+ if (collecting && current.length > 0) {
614
+ this.log(`extractReplyBlocks: still collecting, current has ${current.length} lines`);
615
+ return { text: current.join("\n"), inProgress: true, blocks };
616
+ }
617
+
618
+ if (blocks.length > 0) {
619
+ return { text: blocks[blocks.length - 1], inProgress: false, blocks };
620
+ }
621
+
622
+ return { text: null, inProgress: false, blocks };
623
+ }
624
+ }