@adhdev/daemon-core 0.9.82-rc.267 → 0.9.82-rc.269
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/terminal-backends/ghostty-vt-backend.d.ts +3 -0
- package/dist/cli-adapters/terminal-backends/types.d.ts +9 -0
- package/dist/cli-adapters/terminal-screen.d.ts +2 -0
- package/dist/index.js +234 -33
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +234 -33
- package/dist/index.mjs.map +1 -1
- package/dist/providers/cli-provider-instance.d.ts +24 -0
- package/dist/providers/spec/adapter.d.ts +7 -0
- package/dist/providers/spec/fsm-driver.d.ts +4 -0
- package/package.json +2 -2
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +28 -10
- package/src/cli-adapters/terminal-backends/types.ts +9 -0
- package/src/cli-adapters/terminal-screen.ts +5 -0
- package/src/commands/chat-commands.ts +9 -2
- package/src/config/chat-history.ts +134 -0
- package/src/providers/cli-provider-instance.ts +95 -13
- package/src/providers/spec/adapter.ts +10 -0
- package/src/providers/spec/fsm-driver.ts +31 -2
- package/src/providers/types/interactive-prompt.ts +23 -2
|
@@ -36,6 +36,18 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
36
36
|
private cliArgs;
|
|
37
37
|
readonly type: string;
|
|
38
38
|
readonly category: "cli";
|
|
39
|
+
/**
|
|
40
|
+
* Quiet period an approval modal's signature must be stable before
|
|
41
|
+
* auto-approve sends the approve key. Guards against firing on a prompt
|
|
42
|
+
* that is still streaming into the PTY (the "resolves too fast" symptom):
|
|
43
|
+
* while the modal text/buttons are still changing, every frame yields a
|
|
44
|
+
* new signature and the settle clock restarts. Once the prompt finishes
|
|
45
|
+
* rendering the signature holds and the key is sent after this window.
|
|
46
|
+
* Bounded + small so genuine approvals stay timely. The FSM is already
|
|
47
|
+
* authoritative over the `waiting_approval` state; this only delays the
|
|
48
|
+
* keystroke until the modal *content* has settled.
|
|
49
|
+
*/
|
|
50
|
+
private static readonly AUTO_APPROVE_SETTLE_MS;
|
|
39
51
|
private adapter;
|
|
40
52
|
private context;
|
|
41
53
|
private events;
|
|
@@ -49,6 +61,9 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
49
61
|
private autoApproveBusy;
|
|
50
62
|
private autoApproveBusyTimer;
|
|
51
63
|
private lastAutoApprovalSignature;
|
|
64
|
+
private pendingAutoApprovalSignature;
|
|
65
|
+
private pendingAutoApprovalSince;
|
|
66
|
+
private autoApproveSettleTimer;
|
|
52
67
|
private controlValues;
|
|
53
68
|
private summaryMetadata;
|
|
54
69
|
private appliedEffectKeys;
|
|
@@ -139,6 +154,15 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
139
154
|
private scheduleCompletedDebounceFlush;
|
|
140
155
|
private flushCompletedDebounceIfFinalized;
|
|
141
156
|
private maybeAutoApproveStatus;
|
|
157
|
+
/**
|
|
158
|
+
* Re-drive the auto-approve check after the settle quiet window elapses.
|
|
159
|
+
* The PTY may have gone silent once the approval prompt finished painting,
|
|
160
|
+
* so no status-change frame is guaranteed to re-enter maybeAutoApproveStatus
|
|
161
|
+
* — this timer-driven re-check picks up the now-settled modal and fires.
|
|
162
|
+
* Deliberately lighter than detectStatusTransition(): it only re-evaluates
|
|
163
|
+
* the approval decision; the next real PTY frame refreshes visible status.
|
|
164
|
+
*/
|
|
165
|
+
private recheckAutoApproveSettled;
|
|
142
166
|
private detectStatusTransition;
|
|
143
167
|
private pushEvent;
|
|
144
168
|
private flushEvents;
|
|
@@ -40,6 +40,13 @@ export declare class TerminalAdapter {
|
|
|
40
40
|
start(): void;
|
|
41
41
|
resize(cols: number, rows: number): void;
|
|
42
42
|
snapshot(): string;
|
|
43
|
+
/**
|
|
44
|
+
* Full-buffer snapshot including scrollback history. Unlike snapshot()
|
|
45
|
+
* (visible viewport only), this survives a tall prompt whose top has
|
|
46
|
+
* scrolled off-screen — used for content-pattern extraction (modal
|
|
47
|
+
* buttons / approval anchors), NOT for cursor-relative conditions.
|
|
48
|
+
*/
|
|
49
|
+
snapshotWithScrollback(): string;
|
|
43
50
|
getCursorPosition(): {
|
|
44
51
|
row: number;
|
|
45
52
|
col: number;
|
|
@@ -193,6 +193,10 @@ export declare class FsmDriver implements ISpecDriver {
|
|
|
193
193
|
col: number;
|
|
194
194
|
};
|
|
195
195
|
getScreen(): string;
|
|
196
|
+
/** Scrollback-inclusive screen as line array — used only for modal/button
|
|
197
|
+
* content extraction so a tall prompt's off-screen anchors stay matchable.
|
|
198
|
+
* Falls back to the viewport snapshot if scrollback read is unavailable. */
|
|
199
|
+
private scrollbackLines;
|
|
196
200
|
getSpecPath(): string;
|
|
197
201
|
shutdown(): void;
|
|
198
202
|
getFsmDebug(): {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.269",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.269",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -106,25 +106,43 @@ export class GhosttyVtTerminalBackend implements TerminalViewportBackend {
|
|
|
106
106
|
this.terminal.write(data);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
if (this.disposed) return '';
|
|
109
|
+
private formatLines(): string[] {
|
|
111
110
|
// ghostty's `trim:true` collapses CUF-advanced cells — many TUIs including
|
|
112
111
|
// Claude Code render spaces via cursor-forward rather than literal spaces,
|
|
113
112
|
// which would break downstream regex matching. Keep per-row padding and
|
|
114
113
|
// trim trailing whitespace ourselves.
|
|
115
114
|
//
|
|
116
115
|
// formatPlainText uses a .screen pin which includes scrollback history.
|
|
117
|
-
// Slice to the last `rows` lines to get only the visible viewport.
|
|
118
116
|
const raw = this.terminal.formatPlainText({ trim: false }) || '';
|
|
119
|
-
if (!raw) return
|
|
120
|
-
|
|
117
|
+
if (!raw) return [];
|
|
118
|
+
return raw.split('\n').map((row) => row.replace(/\s+$/, ''));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private static trimBlankEnds(lines: string[]): string {
|
|
122
|
+
let first = 0;
|
|
123
|
+
let last = lines.length;
|
|
124
|
+
while (first < last && !lines[first]) first += 1;
|
|
125
|
+
while (last > first && !lines[last - 1]) last -= 1;
|
|
126
|
+
return lines.slice(first, last).join('\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
getText(): string {
|
|
130
|
+
if (this.disposed) return '';
|
|
131
|
+
const lines = this.formatLines();
|
|
132
|
+
if (lines.length === 0) return '';
|
|
121
133
|
// Take only the viewport (last `rows` lines) to exclude scrollback history.
|
|
122
134
|
const viewport = lines.length > this.rows ? lines.slice(-this.rows) : lines;
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
135
|
+
return GhosttyVtTerminalBackend.trimBlankEnds(viewport);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getTextWithScrollback(): string {
|
|
139
|
+
if (this.disposed) return '';
|
|
140
|
+
const lines = this.formatLines();
|
|
141
|
+
if (lines.length === 0) return '';
|
|
142
|
+
// Full buffer including scrollback — does NOT slice to the viewport, so a
|
|
143
|
+
// tall prompt whose top has scrolled above the visible rows is still
|
|
144
|
+
// matchable by content patterns.
|
|
145
|
+
return GhosttyVtTerminalBackend.trimBlankEnds(lines);
|
|
128
146
|
}
|
|
129
147
|
|
|
130
148
|
getCursorPosition(): { col: number; row: number } {
|
|
@@ -11,6 +11,15 @@ export interface TerminalViewportBackend {
|
|
|
11
11
|
resize(rows: number, cols: number): void;
|
|
12
12
|
write(data: string): void;
|
|
13
13
|
getText(): string;
|
|
14
|
+
/**
|
|
15
|
+
* Like getText() but includes scrollback history (the lines that have
|
|
16
|
+
* scrolled above the visible viewport). Used by content-pattern matching
|
|
17
|
+
* (e.g. approval/modal button extraction) that must stay correct when a
|
|
18
|
+
* tall prompt — a big diff or long explanation — pushes part of the
|
|
19
|
+
* prompt box above the viewport. NOT for cursor-relative / stable_ms
|
|
20
|
+
* conditions, whose row indices are viewport-relative.
|
|
21
|
+
*/
|
|
22
|
+
getTextWithScrollback(): string;
|
|
14
23
|
getCursorPosition(): { col: number; row: number };
|
|
15
24
|
dispose(): void;
|
|
16
25
|
}
|
|
@@ -47,6 +47,11 @@ export class TerminalScreen {
|
|
|
47
47
|
return this.terminal.getText();
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** Full buffer including scrollback history (see backend doc). */
|
|
51
|
+
getTextWithScrollback(): string {
|
|
52
|
+
return this.terminal.getTextWithScrollback();
|
|
53
|
+
}
|
|
54
|
+
|
|
50
55
|
getCursorPosition(): { col: number; row: number } {
|
|
51
56
|
return this.terminal.getCursorPosition();
|
|
52
57
|
}
|
|
@@ -33,6 +33,13 @@ import { filterUserFacingChatMessages, normalizeChatMessages } from '../provider
|
|
|
33
33
|
|
|
34
34
|
const RECENT_SEND_WINDOW_MS = 1200;
|
|
35
35
|
export const READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS = 25_000;
|
|
36
|
+
// Minimum tail floor for hot-path history/mirror reads. The dashboard requests a
|
|
37
|
+
// bounded tail (~60); we keep a small floor so a tiny requested tailLimit still
|
|
38
|
+
// has enough surrounding context for seed/mirror dedup correctness, but it must
|
|
39
|
+
// NOT dominate the hot subscribe/poll path the way the previous 200 floor did.
|
|
40
|
+
// readChatHistory now serves this as an O(tail) bounded read, so the cost scales
|
|
41
|
+
// with this floor, not with total accumulated history.
|
|
42
|
+
const HOT_TAIL_MIN_LIMIT = 60;
|
|
36
43
|
const HERMES_CLI_STARTING_SEND_SETTLE_MS = 2_000;
|
|
37
44
|
// (A2.2) CLI_NATIVE_HISTORY_FRESH_MS removed with isNativeHistoryFreshEnough.
|
|
38
45
|
// Hardcoded native-transcript provider allow-list. Deprecated. Kept only as a
|
|
@@ -963,7 +970,7 @@ function readExactRuntimeMirrorMessages(args: {
|
|
|
963
970
|
const history = readChatHistory(
|
|
964
971
|
args.providerType,
|
|
965
972
|
0,
|
|
966
|
-
Math.max(args.tailLimit || 0,
|
|
973
|
+
Math.max(args.tailLimit || 0, HOT_TAIL_MIN_LIMIT),
|
|
967
974
|
targetSessionId,
|
|
968
975
|
0,
|
|
969
976
|
args.historyBehavior,
|
|
@@ -2206,7 +2213,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
|
|
|
2206
2213
|
const nativeHistoryLimit = Math.max(
|
|
2207
2214
|
normalizeReadChatTailLimit(args) || 0,
|
|
2208
2215
|
returnedMessages.length,
|
|
2209
|
-
|
|
2216
|
+
HOT_TAIL_MIN_LIMIT,
|
|
2210
2217
|
);
|
|
2211
2218
|
const nativeHistorySessionId = supportsNative
|
|
2212
2219
|
? resolveCliNativeHistorySessionId(args, historySessionId, providerSessionId)
|
|
@@ -58,6 +58,40 @@ const savedHistoryFileSummaryCache = new Map<string, SavedHistoryFileSummaryCach
|
|
|
58
58
|
const savedHistoryBackgroundRefresh = new Set<string>();
|
|
59
59
|
const savedHistoryRollupInFlight = new Set<string>();
|
|
60
60
|
|
|
61
|
+
// Bounded-tail read cache. The dashboard re-subscribes and polls hot sessions
|
|
62
|
+
// every ~2.5s; without a cache each poll re-reads/parses/sorts the whole
|
|
63
|
+
// conversation just to slice a small tail. We key on (type, sessionId,
|
|
64
|
+
// pagination args) plus the on-disk size+mtime signature so an UNCHANGED
|
|
65
|
+
// session returns the previously computed tail in O(1) and only re-reads when a
|
|
66
|
+
// new message is appended (signature changes). The map is bounded by a small
|
|
67
|
+
// LRU to keep memory flat regardless of how many sessions are touched.
|
|
68
|
+
interface BoundedTailCacheEntry {
|
|
69
|
+
signature: string;
|
|
70
|
+
result: { messages: HistoryMessage[]; hasMore: boolean };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const BOUNDED_TAIL_CACHE_MAX_ENTRIES = 64;
|
|
74
|
+
const boundedTailReadCache = new Map<string, BoundedTailCacheEntry>();
|
|
75
|
+
|
|
76
|
+
function readBoundedTailCache(key: string, signature: string): { messages: HistoryMessage[]; hasMore: boolean } | null {
|
|
77
|
+
const cached = boundedTailReadCache.get(key);
|
|
78
|
+
if (!cached || cached.signature !== signature) return null;
|
|
79
|
+
// Refresh LRU recency.
|
|
80
|
+
boundedTailReadCache.delete(key);
|
|
81
|
+
boundedTailReadCache.set(key, cached);
|
|
82
|
+
return cached.result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeBoundedTailCache(key: string, signature: string, result: { messages: HistoryMessage[]; hasMore: boolean }): void {
|
|
86
|
+
boundedTailReadCache.delete(key);
|
|
87
|
+
boundedTailReadCache.set(key, { signature, result });
|
|
88
|
+
while (boundedTailReadCache.size > BOUNDED_TAIL_CACHE_MAX_ENTRIES) {
|
|
89
|
+
const oldest = boundedTailReadCache.keys().next().value;
|
|
90
|
+
if (oldest === undefined) break;
|
|
91
|
+
boundedTailReadCache.delete(oldest);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
61
95
|
interface HistoryMessage {
|
|
62
96
|
ts: string; // ISO timestamp
|
|
63
97
|
receivedAt: number; // epoch ms
|
|
@@ -1180,6 +1214,79 @@ function pageHistoryRecords(
|
|
|
1180
1214
|
return { messages: sliced, hasMore: startInclusive > 0 };
|
|
1181
1215
|
}
|
|
1182
1216
|
|
|
1217
|
+
// A finite tail request can be served by reading only the newest files instead
|
|
1218
|
+
// of the whole conversation. Treat very large limits (e.g. MAX_SAFE_INTEGER, or
|
|
1219
|
+
// anything past a generous ceiling) as a full-history request so restore/seed
|
|
1220
|
+
// callers keep their existing behavior.
|
|
1221
|
+
const BOUNDED_TAIL_MAX_LIMIT = 5_000;
|
|
1222
|
+
// Slack added to the requested window before sorting/dedup/collapse so the
|
|
1223
|
+
// boundary message at the top of the tail dedupes/collapses identically to a
|
|
1224
|
+
// full read. Modest and bounded — it only widens the parse window, not output.
|
|
1225
|
+
const BOUNDED_TAIL_SLACK = 50;
|
|
1226
|
+
|
|
1227
|
+
function isBoundedTailRequest(limit: number, offset: number, excludeRecentCount: number): boolean {
|
|
1228
|
+
const numericLimit = Number(limit);
|
|
1229
|
+
if (!Number.isFinite(numericLimit) || numericLimit <= 0) return false;
|
|
1230
|
+
if (numericLimit > BOUNDED_TAIL_MAX_LIMIT) return false;
|
|
1231
|
+
const numericOffset = Number(offset);
|
|
1232
|
+
const numericExclude = Number(excludeRecentCount);
|
|
1233
|
+
if (!Number.isFinite(numericOffset) || !Number.isFinite(numericExclude)) return false;
|
|
1234
|
+
return true;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Read newest-first only as many files as needed to cover the requested window
|
|
1238
|
+
// plus slack. listHistoryFiles already returns files reversed (newest-first), so
|
|
1239
|
+
// we accumulate (de-duped) candidates from the end and stop once we have enough,
|
|
1240
|
+
// then hand the bounded window to pageHistoryRecords in chronological order.
|
|
1241
|
+
function readBoundedTailRecords(
|
|
1242
|
+
agentType: string,
|
|
1243
|
+
dir: string,
|
|
1244
|
+
files: string[],
|
|
1245
|
+
needed: number,
|
|
1246
|
+
): { records: HistoryMessage[]; readAllFiles: boolean } {
|
|
1247
|
+
const collected: HistoryMessage[] = [];
|
|
1248
|
+
const seen = new Set<string>();
|
|
1249
|
+
let readAllFiles = true;
|
|
1250
|
+
|
|
1251
|
+
for (let f = 0; f < files.length; f++) {
|
|
1252
|
+
const filePath = path.join(dir, files[f]);
|
|
1253
|
+
let content: string;
|
|
1254
|
+
try {
|
|
1255
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
1256
|
+
} catch {
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
1260
|
+
// Walk this file's lines newest-first so we fill the tail window from the
|
|
1261
|
+
// bottom. seen-dedup keeps the same first-wins-by-newest semantics the
|
|
1262
|
+
// full read produced (files are processed newest-first there too).
|
|
1263
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1264
|
+
try {
|
|
1265
|
+
const parsed = JSON.parse(lines[i]) as HistoryMessage;
|
|
1266
|
+
const sanitizedMessage = sanitizeHistoryMessage(agentType, parsed);
|
|
1267
|
+
if (!sanitizedMessage) continue;
|
|
1268
|
+
const hash = buildHistoryMessageHash(agentType, sanitizedMessage);
|
|
1269
|
+
if (seen.has(hash)) continue;
|
|
1270
|
+
seen.add(hash);
|
|
1271
|
+
collected.push(sanitizedMessage);
|
|
1272
|
+
} catch { /* skip invalid lines */ }
|
|
1273
|
+
}
|
|
1274
|
+
// Stop once we have the window AND there is at least one more file (so a
|
|
1275
|
+
// potential older boundary message exists). If this is the last file we
|
|
1276
|
+
// fall through and mark the whole history as read.
|
|
1277
|
+
if (collected.length >= needed && f < files.length - 1) {
|
|
1278
|
+
readAllFiles = false;
|
|
1279
|
+
break;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// collected is newest-first across the bounded window; restore chronological
|
|
1284
|
+
// (oldest-first) order before paging. pageHistoryRecords re-sorts by
|
|
1285
|
+
// receivedAt regardless, so this is purely for stable input ordering.
|
|
1286
|
+
collected.reverse();
|
|
1287
|
+
return { records: collected, readAllFiles };
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1183
1290
|
export function readChatHistory(
|
|
1184
1291
|
agentType: string,
|
|
1185
1292
|
offset: number = 0,
|
|
@@ -1196,6 +1303,33 @@ export function readChatHistory(
|
|
|
1196
1303
|
// JSONL file list — filter by persistent history key when specified
|
|
1197
1304
|
const files = listHistoryFiles(dir, historySessionId);
|
|
1198
1305
|
|
|
1306
|
+
const bounded = isBoundedTailRequest(limit, offset, excludeRecentCount);
|
|
1307
|
+
|
|
1308
|
+
if (bounded) {
|
|
1309
|
+
const fileSignatures = buildSavedHistoryFileSignatureMap(dir, files);
|
|
1310
|
+
const cacheKey = `${sanitized}\0${historySessionId || ''}\0${offset}\0${limit}\0${excludeRecentCount}\0${historyBehavior?.collapseConsecutiveAssistantTurns ? '1' : '0'}`;
|
|
1311
|
+
const signature = buildSavedHistoryCacheSignature(files, fileSignatures);
|
|
1312
|
+
const cached = readBoundedTailCache(cacheKey, signature);
|
|
1313
|
+
if (cached) return cached;
|
|
1314
|
+
|
|
1315
|
+
// Window large enough that the top boundary dedupes/collapses the same
|
|
1316
|
+
// as a full read. hasMore reflects whether older messages exist beyond
|
|
1317
|
+
// the window we actually read.
|
|
1318
|
+
const numericLimit = Math.max(1, Number(limit));
|
|
1319
|
+
const numericOffset = Math.max(0, Number(offset));
|
|
1320
|
+
const numericExclude = Math.max(0, Number(excludeRecentCount));
|
|
1321
|
+
const needed = numericLimit + numericOffset + numericExclude + Math.max(BOUNDED_TAIL_SLACK, numericLimit);
|
|
1322
|
+
const { records, readAllFiles } = readBoundedTailRecords(agentType, dir, files, needed);
|
|
1323
|
+
const result = pageHistoryRecords(agentType, records, offset, limit, excludeRecentCount, historyBehavior);
|
|
1324
|
+
// If we read every file, the conversation is fully represented in the
|
|
1325
|
+
// window and pageHistoryRecords' hasMore is authoritative. If we
|
|
1326
|
+
// stopped early there are older messages we never read, so hasMore
|
|
1327
|
+
// must stay true regardless of the in-window slice position.
|
|
1328
|
+
const boundedResult = readAllFiles ? result : { messages: result.messages, hasMore: true };
|
|
1329
|
+
writeBoundedTailCache(cacheKey, signature, boundedResult);
|
|
1330
|
+
return boundedResult;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1199
1333
|
const allMessages: HistoryMessage[] = [];
|
|
1200
1334
|
const seen = new Set<string>();
|
|
1201
1335
|
|
|
@@ -350,6 +350,19 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
350
350
|
readonly type: string;
|
|
351
351
|
readonly category = 'cli' as const;
|
|
352
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Quiet period an approval modal's signature must be stable before
|
|
355
|
+
* auto-approve sends the approve key. Guards against firing on a prompt
|
|
356
|
+
* that is still streaming into the PTY (the "resolves too fast" symptom):
|
|
357
|
+
* while the modal text/buttons are still changing, every frame yields a
|
|
358
|
+
* new signature and the settle clock restarts. Once the prompt finishes
|
|
359
|
+
* rendering the signature holds and the key is sent after this window.
|
|
360
|
+
* Bounded + small so genuine approvals stay timely. The FSM is already
|
|
361
|
+
* authoritative over the `waiting_approval` state; this only delays the
|
|
362
|
+
* keystroke until the modal *content* has settled.
|
|
363
|
+
*/
|
|
364
|
+
private static readonly AUTO_APPROVE_SETTLE_MS = 600;
|
|
365
|
+
|
|
353
366
|
private adapter: ProviderCliAdapter;
|
|
354
367
|
private context: InstanceContext | null = null;
|
|
355
368
|
private events: ProviderEvent[] = [];
|
|
@@ -363,6 +376,14 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
363
376
|
private autoApproveBusy = false;
|
|
364
377
|
private autoApproveBusyTimer: NodeJS.Timeout | null = null;
|
|
365
378
|
private lastAutoApprovalSignature = '';
|
|
379
|
+
// Settle gate: the approval modal's signature + the wall-clock when this
|
|
380
|
+
// exact signature was first observed. Auto-approve only fires once the
|
|
381
|
+
// SAME signature has been stable for AUTO_APPROVE_SETTLE_MS, so a prompt
|
|
382
|
+
// still streaming into the PTY (its buttons/message changing frame to
|
|
383
|
+
// frame) keeps resetting the timer and is never approved half-rendered.
|
|
384
|
+
private pendingAutoApprovalSignature = '';
|
|
385
|
+
private pendingAutoApprovalSince = 0;
|
|
386
|
+
private autoApproveSettleTimer: NodeJS.Timeout | null = null;
|
|
366
387
|
private controlValues: Record<string, string | number | boolean> = {};
|
|
367
388
|
private summaryMetadata: unknown = undefined;
|
|
368
389
|
private appliedEffectKeys = new Set<string>();
|
|
@@ -951,6 +972,10 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
951
972
|
dispose(): void {
|
|
952
973
|
this.adapter.shutdown();
|
|
953
974
|
this.monitor.reset();
|
|
975
|
+
// Cancel any armed auto-approve timers so a pending settle re-check
|
|
976
|
+
// can't fire resolveModal/detectStatusTransition against a dead adapter.
|
|
977
|
+
if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
|
|
978
|
+
if (this.autoApproveBusyTimer) { clearTimeout(this.autoApproveBusyTimer); this.autoApproveBusyTimer = null; }
|
|
954
979
|
this.appliedEffectKeys.clear();
|
|
955
980
|
try { this.cachedSqliteDb?.close(); } catch { /* noop */ }
|
|
956
981
|
this.cachedSqliteDb = null;
|
|
@@ -1373,6 +1398,11 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1373
1398
|
// still inside the short busy window.
|
|
1374
1399
|
if (!autoApproveActive) {
|
|
1375
1400
|
this.lastAutoApprovalSignature = '';
|
|
1401
|
+
// Clear the settle gate so the next approval starts its own quiet
|
|
1402
|
+
// window from scratch (a stale timestamp would let it fire instantly).
|
|
1403
|
+
this.pendingAutoApprovalSignature = '';
|
|
1404
|
+
this.pendingAutoApprovalSince = 0;
|
|
1405
|
+
if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
|
|
1376
1406
|
return autoApproveActive;
|
|
1377
1407
|
}
|
|
1378
1408
|
const modal = adapterStatus.activeModal;
|
|
@@ -1410,23 +1440,67 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1410
1440
|
buttons.join('|'),
|
|
1411
1441
|
buttonIndex,
|
|
1412
1442
|
].join('::');
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
this.autoApproveBusyTimer = setTimeout(() => {
|
|
1418
|
-
this.autoApproveBusy = false;
|
|
1419
|
-
this.autoApproveBusyTimer = null;
|
|
1420
|
-
this.lastAutoApprovalSignature = '';
|
|
1421
|
-
}, 5000);
|
|
1422
|
-
this.recordAutoApproval(modal?.message, buttonLabel, now);
|
|
1423
|
-
setTimeout(() => {
|
|
1424
|
-
this.adapter.resolveModal(buttonIndex);
|
|
1425
|
-
}, 0);
|
|
1443
|
+
// Already fired for this exact modal and still inside the busy window —
|
|
1444
|
+
// nothing to do (re-entry guard for repeated snapshots of one modal).
|
|
1445
|
+
if (this.autoApproveBusy && signature === this.lastAutoApprovalSignature) {
|
|
1446
|
+
return autoApproveActive;
|
|
1426
1447
|
}
|
|
1448
|
+
|
|
1449
|
+
// Settle gate: only fire once this exact signature has been stable for
|
|
1450
|
+
// AUTO_APPROVE_SETTLE_MS. A still-streaming prompt mutates its
|
|
1451
|
+
// message/buttons each frame → new signature → clock restarts, so we
|
|
1452
|
+
// never approve a half-rendered prompt (the "resolves too fast" bug).
|
|
1453
|
+
if (signature !== this.pendingAutoApprovalSignature) {
|
|
1454
|
+
this.pendingAutoApprovalSignature = signature;
|
|
1455
|
+
this.pendingAutoApprovalSince = now;
|
|
1456
|
+
}
|
|
1457
|
+
const settledForMs = now - this.pendingAutoApprovalSince;
|
|
1458
|
+
if (settledForMs < CliProviderInstance.AUTO_APPROVE_SETTLE_MS) {
|
|
1459
|
+
// Not yet settled. Arm a timer to re-check after the remaining quiet
|
|
1460
|
+
// window — the PTY may go silent once the prompt finishes painting,
|
|
1461
|
+
// so there is no guaranteed status-change frame to re-drive us.
|
|
1462
|
+
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
1463
|
+
this.autoApproveSettleTimer = setTimeout(() => {
|
|
1464
|
+
this.autoApproveSettleTimer = null;
|
|
1465
|
+
this.recheckAutoApproveSettled();
|
|
1466
|
+
}, CliProviderInstance.AUTO_APPROVE_SETTLE_MS - settledForMs + 20);
|
|
1467
|
+
return autoApproveActive;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Settled — fire the approve key.
|
|
1471
|
+
if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
|
|
1472
|
+
this.autoApproveBusy = true;
|
|
1473
|
+
this.lastAutoApprovalSignature = signature;
|
|
1474
|
+
this.pendingAutoApprovalSignature = '';
|
|
1475
|
+
this.pendingAutoApprovalSince = 0;
|
|
1476
|
+
if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
|
|
1477
|
+
this.autoApproveBusyTimer = setTimeout(() => {
|
|
1478
|
+
this.autoApproveBusy = false;
|
|
1479
|
+
this.autoApproveBusyTimer = null;
|
|
1480
|
+
this.lastAutoApprovalSignature = '';
|
|
1481
|
+
}, 5000);
|
|
1482
|
+
this.recordAutoApproval(modal?.message, buttonLabel, now);
|
|
1483
|
+
setTimeout(() => {
|
|
1484
|
+
this.adapter.resolveModal(buttonIndex);
|
|
1485
|
+
}, 0);
|
|
1427
1486
|
return autoApproveActive;
|
|
1428
1487
|
}
|
|
1429
1488
|
|
|
1489
|
+
/**
|
|
1490
|
+
* Re-drive the auto-approve check after the settle quiet window elapses.
|
|
1491
|
+
* The PTY may have gone silent once the approval prompt finished painting,
|
|
1492
|
+
* so no status-change frame is guaranteed to re-enter maybeAutoApproveStatus
|
|
1493
|
+
* — this timer-driven re-check picks up the now-settled modal and fires.
|
|
1494
|
+
* Deliberately lighter than detectStatusTransition(): it only re-evaluates
|
|
1495
|
+
* the approval decision; the next real PTY frame refreshes visible status.
|
|
1496
|
+
*/
|
|
1497
|
+
private recheckAutoApproveSettled(): void {
|
|
1498
|
+
try {
|
|
1499
|
+
const adapterStatus = this.adapter.getStatus({ allowParse: false });
|
|
1500
|
+
this.maybeAutoApproveStatus(adapterStatus, Date.now());
|
|
1501
|
+
} catch { /* adapter gone / transient — next frame retries */ }
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1430
1504
|
private detectStatusTransition(): void {
|
|
1431
1505
|
const now = Date.now();
|
|
1432
1506
|
// Status-change handling is a hot path: PTY output can fire it many times
|
|
@@ -2135,6 +2209,14 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2135
2209
|
if (!materializeProviderNativeHistory(this.type, canonicalHistory, this.providerSessionId, this.workingDir, this.provider.scripts as any)) {
|
|
2136
2210
|
return false;
|
|
2137
2211
|
}
|
|
2212
|
+
// Full read is intentional: lastPersistedHistoryMessages is the COMPLETE
|
|
2213
|
+
// session transcript — emitted as statusMessages and used as the
|
|
2214
|
+
// prefix-comparison base for incremental appends — so a bounded tail
|
|
2215
|
+
// would both truncate output and break prefix dedup. This is gated to
|
|
2216
|
+
// once-per-2s (cache key above) for resume/manual launches only, so it
|
|
2217
|
+
// does not run on the per-subscribe/per-poll dashboard tail path (that
|
|
2218
|
+
// path goes through handleReadChat → readChatHistory with a bounded
|
|
2219
|
+
// tailLimit, which is now O(tail)).
|
|
2138
2220
|
const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId, 0, this.provider.historyBehavior);
|
|
2139
2221
|
this.lastPersistedHistoryMessages = restoredHistory.messages.map((message) => ({
|
|
2140
2222
|
role: message.role,
|
|
@@ -104,6 +104,16 @@ export class TerminalAdapter {
|
|
|
104
104
|
return this.lastScreen || this.computeScreen();
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Full-buffer snapshot including scrollback history. Unlike snapshot()
|
|
109
|
+
* (visible viewport only), this survives a tall prompt whose top has
|
|
110
|
+
* scrolled off-screen — used for content-pattern extraction (modal
|
|
111
|
+
* buttons / approval anchors), NOT for cursor-relative conditions.
|
|
112
|
+
*/
|
|
113
|
+
snapshotWithScrollback(): string {
|
|
114
|
+
return this.screen.getTextWithScrollback();
|
|
115
|
+
}
|
|
116
|
+
|
|
107
117
|
getCursorPosition(): { row: number; col: number } {
|
|
108
118
|
const pos = this.screen.getCursorPosition();
|
|
109
119
|
return { row: pos.row, col: pos.col };
|
|
@@ -267,6 +267,18 @@ export class FsmDriver implements ISpecDriver {
|
|
|
267
267
|
snapshot(): string { return this.adapter.snapshot(); }
|
|
268
268
|
getCursorPosition(): { row: number; col: number } { return this.adapter.getCursorPosition(); }
|
|
269
269
|
getScreen(): string { return this.adapter.snapshot(); }
|
|
270
|
+
|
|
271
|
+
/** Scrollback-inclusive screen as line array — used only for modal/button
|
|
272
|
+
* content extraction so a tall prompt's off-screen anchors stay matchable.
|
|
273
|
+
* Falls back to the viewport snapshot if scrollback read is unavailable. */
|
|
274
|
+
private scrollbackLines(): string[] {
|
|
275
|
+
let screen = '';
|
|
276
|
+
try {
|
|
277
|
+
screen = this.adapter.snapshotWithScrollback();
|
|
278
|
+
} catch { /* fall through to viewport */ }
|
|
279
|
+
if (!screen) screen = this.adapter.snapshot();
|
|
280
|
+
return screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
|
|
281
|
+
}
|
|
270
282
|
getSpecPath(): string { return this.opts.specPath; }
|
|
271
283
|
|
|
272
284
|
shutdown(): void {
|
|
@@ -484,9 +496,26 @@ export class FsmDriver implements ISpecDriver {
|
|
|
484
496
|
const lines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
|
|
485
497
|
const sections = resolveSections(this.spec.sections ?? {}, lines);
|
|
486
498
|
|
|
487
|
-
|
|
499
|
+
// Modal states (approval / picker) extract their buttons + title from a
|
|
500
|
+
// SCROLLBACK-INCLUSIVE buffer: claude-cli renders an approval as a box,
|
|
501
|
+
// and when the prompt body (a big diff / long explanation) is tall the
|
|
502
|
+
// top of the box — including the `─────` separator that anchors the
|
|
503
|
+
// `modal` section — scrolls above the viewport. Matching only the
|
|
504
|
+
// viewport then yields < min_count buttons, deriveModal returns null,
|
|
505
|
+
// and auto-approve never fires (the "long prompts never auto-approve"
|
|
506
|
+
// bug). The viewport stays authoritative for transitions / cursor
|
|
507
|
+
// conditions; only this content-pattern extraction reads scrollback.
|
|
508
|
+
const modalLines = state.modal
|
|
509
|
+
? this.scrollbackLines()
|
|
510
|
+
: lines;
|
|
511
|
+
const modalSections = state.modal
|
|
512
|
+
? resolveSections(this.spec.sections ?? {}, modalLines)
|
|
513
|
+
: sections;
|
|
514
|
+
const modalFullScreen = modalLines.join('\n');
|
|
515
|
+
|
|
516
|
+
const modal = this.deriveModal(state, modalSections, modalFullScreen);
|
|
488
517
|
const controls = this.deriveControls(state.id);
|
|
489
|
-
const title = modal?.title ?? this.deriveTitle(state,
|
|
518
|
+
const title = modal?.title ?? this.deriveTitle(state, modalSections, modalFullScreen);
|
|
490
519
|
|
|
491
520
|
const next: CurrentEval = {
|
|
492
521
|
// status is derived from the FSM state itself (statusForState), NOT from
|
|
@@ -346,11 +346,32 @@ export function buildClaudeInteractiveTuiAnswerSteps(
|
|
|
346
346
|
if (response.promptId !== prompt.promptId) throw new Error('Interactive prompt response does not match active prompt');
|
|
347
347
|
const steps: string[] = [];
|
|
348
348
|
for (const question of prompt.questions) {
|
|
349
|
-
if (question.multiSelect) throw new Error('Claude TUI multi-select prompts are not supported yet');
|
|
350
349
|
const answer = response.answers[question.questionId];
|
|
351
350
|
if (!answer) throw new Error(`Missing answer for ${question.questionId}`);
|
|
352
351
|
const freeformText = answer.freeformText?.trim() ?? '';
|
|
353
|
-
|
|
352
|
+
|
|
353
|
+
if (question.multiSelect) {
|
|
354
|
+
// Multi-select: Claude TUI renders each option as a checkbox and the
|
|
355
|
+
// footer reads "Space to select". A numeric digit jumps the cursor to
|
|
356
|
+
// that option; Space toggles its checkbox. So for every selected label
|
|
357
|
+
// emit `[digit, ' ']` to land on it and toggle it on. After toggling all
|
|
358
|
+
// boxes for this question, Enter advances to the next question (or to the
|
|
359
|
+
// final confirm screen for the last question).
|
|
360
|
+
const labels = answer.selectedLabels;
|
|
361
|
+
if (labels.length === 0) {
|
|
362
|
+
throw new Error(`Expected at least one selected label for ${question.questionId}`);
|
|
363
|
+
}
|
|
364
|
+
for (const label of labels) {
|
|
365
|
+
const selectedIndex = question.options.findIndex(option => option.label === label);
|
|
366
|
+
if (selectedIndex < 0) throw new Error(`Unknown option for ${question.questionId}: ${label}`);
|
|
367
|
+
steps.push(String(selectedIndex + 1));
|
|
368
|
+
steps.push(' ');
|
|
369
|
+
}
|
|
370
|
+
// Confirm this question's checked set and move on. Unlike single-select
|
|
371
|
+
// (where the digit auto-advances), multi-select stays on the page until an
|
|
372
|
+
// explicit Enter so the user can toggle multiple boxes.
|
|
373
|
+
steps.push('\r');
|
|
374
|
+
} else if (freeformText) {
|
|
354
375
|
// Freeform: select the "Type something." option (always the last visible
|
|
355
376
|
// option before "Chat about this"), then type the text and confirm.
|
|
356
377
|
const typeOptionIndex = question.options.findIndex(o => /^Type something\.?$/i.test(o.label));
|