@adhdev/daemon-core 0.9.82-rc.293 → 0.9.82-rc.295
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/index.js +227 -20
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +227 -20
- package/dist/index.mjs.map +1 -1
- package/dist/providers/spec/cli-adapter.d.ts +23 -0
- package/dist/providers/types/interactive-prompt.d.ts +36 -0
- package/package.json +2 -2
- package/src/config/chat-history.ts +255 -10
- package/src/mesh/coordinator-prompt.ts +1 -0
- package/src/providers/cli-provider-instance.ts +36 -14
- package/src/providers/spec/cli-adapter.ts +61 -0
- package/src/providers/types/interactive-prompt.ts +37 -8
|
@@ -132,6 +132,29 @@ export declare class SpecCliAdapter implements CliAdapter {
|
|
|
132
132
|
*/
|
|
133
133
|
private maybeClearResolvedClaudeTuiPrompt;
|
|
134
134
|
private maybeCaptureClaudeTuiPrompt;
|
|
135
|
+
/**
|
|
136
|
+
* The TUI prompt is captured on the FIRST frame that renders the
|
|
137
|
+
* "Enter to select" footer. At that instant the option rows' checkbox
|
|
138
|
+
* column may not have drawn yet, so `detectClaudeTuiMultiSelect` returns
|
|
139
|
+
* false and the prompt is frozen as single-select — the dashboard then
|
|
140
|
+
* renders radio buttons even though the picker is multi-select.
|
|
141
|
+
*
|
|
142
|
+
* While the same TUI prompt is still on screen, re-check the live snapshot:
|
|
143
|
+
* if checkbox glyphs have since appeared, promote any single-select
|
|
144
|
+
* question to multi-select and re-emit status. Promotion is one-way
|
|
145
|
+
* (false→true only) — once a question is known multi-select we never demote
|
|
146
|
+
* it, since the glyph column can scroll out of view on later frames.
|
|
147
|
+
*
|
|
148
|
+
* For MULTI-question prompts the per-page Tab capture is the actual source
|
|
149
|
+
* of the bug: pages 2..N are snapshotted ~120ms after the Tab keypress,
|
|
150
|
+
* before their option-row glyph column has redrawn, so those questions
|
|
151
|
+
* freeze as single-select while page 1 (already settled) is correct. We
|
|
152
|
+
* cannot upgrade blindly — the live snapshot shows only ONE focused page —
|
|
153
|
+
* but we CAN read that page's question text/header and upgrade the matching
|
|
154
|
+
* question. As the user navigates the picker (or it settles), each page is
|
|
155
|
+
* eventually re-read and repaired.
|
|
156
|
+
*/
|
|
157
|
+
private maybeUpgradeClaudeTuiMultiSelect;
|
|
135
158
|
private readClaudeTuiHeaders;
|
|
136
159
|
private captureClaudeTuiPrompt;
|
|
137
160
|
getDebugState(): Record<string, any>;
|
|
@@ -33,6 +33,42 @@ export interface ClaudeInteractiveTuiPage {
|
|
|
33
33
|
screenText: string;
|
|
34
34
|
header?: string;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Decide whether a captured claude-cli AskUserQuestion TUI page is multi-select.
|
|
38
|
+
*
|
|
39
|
+
* The original heuristic only matched the footer hint `/Space to select|toggle
|
|
40
|
+
* selections/i`. That string drifts between claude-cli versions, so when it
|
|
41
|
+
* changed the dashboard silently fell back to multiSelect:false and rendered
|
|
42
|
+
* single-select (radio) controls even though the on-screen picker showed
|
|
43
|
+
* checkboxes — the user could not check more than one box. (The CLI's own
|
|
44
|
+
* terminal still rendered `[ ]` correctly because it never depends on this
|
|
45
|
+
* parse.)
|
|
46
|
+
*
|
|
47
|
+
* Make detection robust by ALSO recognising the actual checkbox markers the
|
|
48
|
+
* multi-select picker draws on its option rows (`[ ]` / `[x]` / `☐` / `☒` /
|
|
49
|
+
* `◻` / `◼`). Single-select rows are drawn with a `❯`/number cursor only and
|
|
50
|
+
* carry none of these box glyphs, so their presence is a reliable signal. The
|
|
51
|
+
* broadened footer patterns ("Space to", "toggle", "select multiple") are kept
|
|
52
|
+
* as a secondary signal for layouts that render markers differently.
|
|
53
|
+
*/
|
|
54
|
+
export declare function detectClaudeTuiMultiSelect(screenText: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Read the question the live claude TUI picker is CURRENTLY focused on, plus
|
|
57
|
+
* whether that focused page renders multi-select checkbox markers.
|
|
58
|
+
*
|
|
59
|
+
* Used to repair a multi-question prompt after the fact: when the daemon
|
|
60
|
+
* Tab-captures pages 2..N it snapshots ~120ms after the Tab keypress, before
|
|
61
|
+
* the newly-focused page's option-row checkbox column has redrawn — so those
|
|
62
|
+
* questions get frozen as multiSelect:false even though the picker is
|
|
63
|
+
* multi-select. Re-reading the focused page on a later status tick (once it has
|
|
64
|
+
* settled) lets us attribute the now-visible glyphs to the matching question
|
|
65
|
+
* and upgrade just that one. Returns null when no picker question is on screen.
|
|
66
|
+
*/
|
|
67
|
+
export declare function readFocusedClaudeTuiQuestion(screenText: string): {
|
|
68
|
+
question: string;
|
|
69
|
+
header?: string;
|
|
70
|
+
multiSelect: boolean;
|
|
71
|
+
} | null;
|
|
36
72
|
export declare function detectClaudeAskUserQuestionPromptFromTuiPages(pages: ClaudeInteractiveTuiPage[], options: {
|
|
37
73
|
promptId: string;
|
|
38
74
|
providerType?: string;
|
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.295",
|
|
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.295",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -1234,6 +1234,241 @@ function isBoundedTailRequest(limit: number, offset: number, excludeRecentCount:
|
|
|
1234
1234
|
return true;
|
|
1235
1235
|
}
|
|
1236
1236
|
|
|
1237
|
+
// Byte threshold below which a file is small enough that reading the whole
|
|
1238
|
+
// thing is cheaper than seeking. Reverse-seek pays off only on large files.
|
|
1239
|
+
const REVERSE_TAIL_SMALL_FILE_BYTES = 64 * 1024;
|
|
1240
|
+
// Chunk size for backward reads. We read the file tail one chunk at a time
|
|
1241
|
+
// (newest bytes first) until we have collected enough complete lines.
|
|
1242
|
+
const REVERSE_TAIL_CHUNK_BYTES = 64 * 1024;
|
|
1243
|
+
|
|
1244
|
+
// Per-(file path) incremental tail cache. A hot session's daily JSONL file grows
|
|
1245
|
+
// append-only while it generates; the size+mtime signature on the bounded-tail
|
|
1246
|
+
// read cache therefore invalidates on every append and forces a full re-read.
|
|
1247
|
+
// Here we keep the most recently decoded tail LINES for a file plus the byte
|
|
1248
|
+
// length we read them from. When the file has only grown (append-only: size
|
|
1249
|
+
// increased, the previously-read prefix is unchanged) we read just the new bytes
|
|
1250
|
+
// from `size` onward and splice them onto the retained tail — no full re-parse.
|
|
1251
|
+
// Truncation/rotation (size shrank, or a fresh inode) drops the entry and falls
|
|
1252
|
+
// back to a full reverse-seek.
|
|
1253
|
+
interface IncrementalTailCacheEntry {
|
|
1254
|
+
// File length (bytes) we have already consumed into `lines`.
|
|
1255
|
+
size: number;
|
|
1256
|
+
mtimeMs: number;
|
|
1257
|
+
// Decoded complete lines (oldest-first) covering at least the tail window.
|
|
1258
|
+
// Bounded to TAIL_LINES_RETAINED so memory stays flat for huge files.
|
|
1259
|
+
lines: string[];
|
|
1260
|
+
// True when `lines` is the entire file (head reached), so older pages can
|
|
1261
|
+
// trust that nothing precedes the retained window.
|
|
1262
|
+
coversWholeFile: boolean;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// How many trailing lines we retain per file. The bounded-tail caller never
|
|
1266
|
+
// asks for more than BOUNDED_TAIL_MAX_LIMIT + slack; keep a generous multiple so
|
|
1267
|
+
// repeated reads at the same window are served incrementally.
|
|
1268
|
+
const TAIL_LINES_RETAINED = BOUNDED_TAIL_MAX_LIMIT + 2 * BOUNDED_TAIL_SLACK;
|
|
1269
|
+
const INCREMENTAL_TAIL_CACHE_MAX_ENTRIES = 64;
|
|
1270
|
+
const incrementalTailCache = new Map<string, IncrementalTailCacheEntry>();
|
|
1271
|
+
|
|
1272
|
+
function evictIncrementalTailCache(): void {
|
|
1273
|
+
while (incrementalTailCache.size > INCREMENTAL_TAIL_CACHE_MAX_ENTRIES) {
|
|
1274
|
+
const oldest = incrementalTailCache.keys().next().value;
|
|
1275
|
+
if (oldest === undefined) break;
|
|
1276
|
+
incrementalTailCache.delete(oldest);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Split a Buffer into complete lines plus a leftover head fragment, partitioning
|
|
1281
|
+
// only on the newline byte (0x0A). 0x0A never appears inside a multibyte UTF-8
|
|
1282
|
+
// sequence, so decoding each complete byte segment is boundary-safe. The leftover
|
|
1283
|
+
// (bytes before the first newline) is returned undecoded so a caller stitching
|
|
1284
|
+
// chunks together never splits a multibyte char.
|
|
1285
|
+
function splitBufferLines(buf: Buffer): { head: Buffer; lines: string[] } {
|
|
1286
|
+
const lines: string[] = [];
|
|
1287
|
+
let lineEnd = buf.length;
|
|
1288
|
+
let firstNewline = -1;
|
|
1289
|
+
for (let i = buf.length - 1; i >= 0; i--) {
|
|
1290
|
+
if (buf[i] !== 0x0a) continue;
|
|
1291
|
+
if (i + 1 < lineEnd) {
|
|
1292
|
+
lines.push(buf.toString('utf-8', i + 1, lineEnd));
|
|
1293
|
+
}
|
|
1294
|
+
lineEnd = i;
|
|
1295
|
+
firstNewline = i;
|
|
1296
|
+
}
|
|
1297
|
+
// Lines were collected newest-first; restore oldest-first for the segment
|
|
1298
|
+
// that follows the first (lowest-index) newline.
|
|
1299
|
+
lines.reverse();
|
|
1300
|
+
const head = firstNewline >= 0 ? buf.subarray(0, firstNewline) : buf;
|
|
1301
|
+
return { head, lines };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Read the last bytes of a file, newest-first, until we have at least `needed`
|
|
1305
|
+
// complete lines (or reach the start of the file). Returns lines oldest-first and
|
|
1306
|
+
// whether the whole file was consumed. Boundary-safe: lines are cut on the
|
|
1307
|
+
// newline byte only, so multibyte UTF-8 chars are never split, and a trailing
|
|
1308
|
+
// partial line (no terminating newline) is preserved as a complete final line.
|
|
1309
|
+
function readReverseTailLines(filePath: string, needed: number): { lines: string[]; coversWholeFile: boolean; size: number; mtimeMs: number } {
|
|
1310
|
+
const fd = fs.openSync(filePath, 'r');
|
|
1311
|
+
try {
|
|
1312
|
+
const stat = fs.fstatSync(fd);
|
|
1313
|
+
const size = stat.size;
|
|
1314
|
+
let position = size;
|
|
1315
|
+
// `carry` holds bytes belonging to a line that straddles the current
|
|
1316
|
+
// chunk boundary (its start is in an older, not-yet-read chunk).
|
|
1317
|
+
let carry: Buffer = Buffer.alloc(0);
|
|
1318
|
+
const collected: string[] = [];
|
|
1319
|
+
|
|
1320
|
+
while (position > 0 && collected.length < needed) {
|
|
1321
|
+
const chunkSize = Math.min(REVERSE_TAIL_CHUNK_BYTES, position);
|
|
1322
|
+
position -= chunkSize;
|
|
1323
|
+
const chunk = Buffer.alloc(chunkSize);
|
|
1324
|
+
fs.readSync(fd, chunk, 0, chunkSize, position);
|
|
1325
|
+
const combined = carry.length ? Buffer.concat([chunk, carry]) : chunk;
|
|
1326
|
+
const { head, lines } = splitBufferLines(combined);
|
|
1327
|
+
// `head` is the (possibly partial) line whose start lies further back;
|
|
1328
|
+
// hold it for the next (older) chunk to complete.
|
|
1329
|
+
carry = head;
|
|
1330
|
+
// `lines` are oldest-first within this combined buffer; prepend them
|
|
1331
|
+
// ahead of what we already collected (which is strictly newer).
|
|
1332
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1333
|
+
collected.push(lines[i]);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const reachedStart = position <= 0;
|
|
1338
|
+
if (reachedStart && carry.length) {
|
|
1339
|
+
// Leftover head at the start of the file is itself a complete line.
|
|
1340
|
+
collected.push(carry.toString('utf-8'));
|
|
1341
|
+
}
|
|
1342
|
+
// `collected` is newest-first; restore oldest-first.
|
|
1343
|
+
collected.reverse();
|
|
1344
|
+
return { lines: collected, coversWholeFile: reachedStart, size, mtimeMs: stat.mtimeMs };
|
|
1345
|
+
} finally {
|
|
1346
|
+
fs.closeSync(fd);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// Return the tail lines (oldest-first) for a single history file, reading as
|
|
1351
|
+
// little of the file as possible. Strategy:
|
|
1352
|
+
// - Small files: one readFileSync (seeking is not worth the syscalls).
|
|
1353
|
+
// - Large files: reverse byte-seek for the newest `needed` lines.
|
|
1354
|
+
// - Append-only growth since the last read: read only the appended bytes and
|
|
1355
|
+
// splice them onto the retained tail (no full re-parse) — this is what keeps
|
|
1356
|
+
// a hot, still-generating session cheap to poll.
|
|
1357
|
+
// `needed` is a soft floor; we may return more (whole small files / retained
|
|
1358
|
+
// window). Lines include any trailing partial (unterminated) final line.
|
|
1359
|
+
function readFileTailLines(filePath: string, needed: number): { lines: string[]; coversWholeFile: boolean } {
|
|
1360
|
+
let stat: fs.Stats;
|
|
1361
|
+
try {
|
|
1362
|
+
stat = fs.statSync(filePath);
|
|
1363
|
+
} catch {
|
|
1364
|
+
return { lines: [], coversWholeFile: true };
|
|
1365
|
+
}
|
|
1366
|
+
const size = stat.size;
|
|
1367
|
+
const mtimeMs = stat.mtimeMs;
|
|
1368
|
+
if (size === 0) {
|
|
1369
|
+
incrementalTailCache.delete(filePath);
|
|
1370
|
+
return { lines: [], coversWholeFile: true };
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const cached = incrementalTailCache.get(filePath);
|
|
1374
|
+
if (cached) {
|
|
1375
|
+
if (cached.size === size && cached.mtimeMs === mtimeMs) {
|
|
1376
|
+
// Unchanged since last read — reuse retained tail. Refresh LRU.
|
|
1377
|
+
incrementalTailCache.delete(filePath);
|
|
1378
|
+
incrementalTailCache.set(filePath, cached);
|
|
1379
|
+
if (cached.coversWholeFile || cached.lines.length >= needed) {
|
|
1380
|
+
return { lines: cached.lines, coversWholeFile: cached.coversWholeFile };
|
|
1381
|
+
}
|
|
1382
|
+
// Retained window is smaller than this request needs; fall through
|
|
1383
|
+
// to a fresh reverse-seek for the larger window.
|
|
1384
|
+
} else if (size > cached.size) {
|
|
1385
|
+
// Append-only growth: the prefix [0, cached.size) is assumed
|
|
1386
|
+
// unchanged (JSONL is append-only). Read just the new bytes and
|
|
1387
|
+
// stitch them — but verify the byte at cached.size-1 is still the
|
|
1388
|
+
// newline that terminated our last retained line, so a rewrite that
|
|
1389
|
+
// happens to grow the file (compaction) is detected and rejected.
|
|
1390
|
+
const incremental = tryIncrementalTailGrowth(filePath, cached, size, mtimeMs, needed);
|
|
1391
|
+
if (incremental) return { lines: incremental.lines, coversWholeFile: incremental.coversWholeFile };
|
|
1392
|
+
}
|
|
1393
|
+
// size shrank (truncation/rotation) or incremental failed → drop & reload.
|
|
1394
|
+
incrementalTailCache.delete(filePath);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
if (size <= REVERSE_TAIL_SMALL_FILE_BYTES) {
|
|
1398
|
+
let content: string;
|
|
1399
|
+
try {
|
|
1400
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
1401
|
+
} catch {
|
|
1402
|
+
return { lines: [], coversWholeFile: true };
|
|
1403
|
+
}
|
|
1404
|
+
const lines = content.split('\n');
|
|
1405
|
+
// A trailing newline yields a final empty element; drop only that one so
|
|
1406
|
+
// an unterminated partial last line is still preserved.
|
|
1407
|
+
if (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
1408
|
+
storeIncrementalTailCache(filePath, size, mtimeMs, lines, true);
|
|
1409
|
+
return { lines, coversWholeFile: true };
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
let result: { lines: string[]; coversWholeFile: boolean; size: number; mtimeMs: number };
|
|
1413
|
+
try {
|
|
1414
|
+
result = readReverseTailLines(filePath, needed);
|
|
1415
|
+
} catch {
|
|
1416
|
+
return { lines: [], coversWholeFile: true };
|
|
1417
|
+
}
|
|
1418
|
+
storeIncrementalTailCache(filePath, result.size, result.mtimeMs, result.lines, result.coversWholeFile);
|
|
1419
|
+
return { lines: result.lines, coversWholeFile: result.coversWholeFile };
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Read appended bytes [cached.size, size) and splice them onto the retained tail.
|
|
1423
|
+
// Returns null if the prior byte is not a newline (the retained tail did not end
|
|
1424
|
+
// on a record boundary, e.g. the file was rewritten) so the caller can full-reload.
|
|
1425
|
+
function tryIncrementalTailGrowth(
|
|
1426
|
+
filePath: string,
|
|
1427
|
+
cached: IncrementalTailCacheEntry,
|
|
1428
|
+
size: number,
|
|
1429
|
+
mtimeMs: number,
|
|
1430
|
+
needed: number,
|
|
1431
|
+
): { lines: string[]; coversWholeFile: boolean } | null {
|
|
1432
|
+
const fd = fs.openSync(filePath, 'r');
|
|
1433
|
+
try {
|
|
1434
|
+
// Confirm the byte ending the previously-read prefix is still a newline.
|
|
1435
|
+
if (cached.size > 0) {
|
|
1436
|
+
const boundary = Buffer.alloc(1);
|
|
1437
|
+
fs.readSync(fd, boundary, 0, 1, cached.size - 1);
|
|
1438
|
+
if (boundary[0] !== 0x0a) return null;
|
|
1439
|
+
}
|
|
1440
|
+
const appendedLength = size - cached.size;
|
|
1441
|
+
const appended = Buffer.alloc(appendedLength);
|
|
1442
|
+
fs.readSync(fd, appended, 0, appendedLength, cached.size);
|
|
1443
|
+
const newLines = appended.toString('utf-8').split('\n');
|
|
1444
|
+
if (newLines.length && newLines[newLines.length - 1] === '') newLines.pop();
|
|
1445
|
+
const merged = cached.lines.concat(newLines);
|
|
1446
|
+
// Keep memory flat: retain only the trailing window.
|
|
1447
|
+
const trimmed = merged.length > TAIL_LINES_RETAINED
|
|
1448
|
+
? merged.slice(merged.length - TAIL_LINES_RETAINED)
|
|
1449
|
+
: merged;
|
|
1450
|
+
const coversWholeFile = cached.coversWholeFile && trimmed.length === merged.length;
|
|
1451
|
+
storeIncrementalTailCache(filePath, size, mtimeMs, trimmed, coversWholeFile);
|
|
1452
|
+
if (coversWholeFile || trimmed.length >= needed) {
|
|
1453
|
+
return { lines: trimmed, coversWholeFile };
|
|
1454
|
+
}
|
|
1455
|
+
// Should not happen (we only grew), but be safe.
|
|
1456
|
+
return { lines: trimmed, coversWholeFile };
|
|
1457
|
+
} catch {
|
|
1458
|
+
return null;
|
|
1459
|
+
} finally {
|
|
1460
|
+
fs.closeSync(fd);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function storeIncrementalTailCache(filePath: string, size: number, mtimeMs: number, lines: string[], coversWholeFile: boolean): void {
|
|
1465
|
+
const retained = lines.length > TAIL_LINES_RETAINED ? lines.slice(lines.length - TAIL_LINES_RETAINED) : lines;
|
|
1466
|
+
const covers = coversWholeFile && retained.length === lines.length;
|
|
1467
|
+
incrementalTailCache.delete(filePath);
|
|
1468
|
+
incrementalTailCache.set(filePath, { size, mtimeMs, lines: retained, coversWholeFile: covers });
|
|
1469
|
+
evictIncrementalTailCache();
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1237
1472
|
// Read newest-first only as many files as needed to cover the requested window
|
|
1238
1473
|
// plus slack. listHistoryFiles already returns files reversed (newest-first), so
|
|
1239
1474
|
// we accumulate (de-duped) candidates from the end and stop once we have enough,
|
|
@@ -1250,19 +1485,22 @@ function readBoundedTailRecords(
|
|
|
1250
1485
|
|
|
1251
1486
|
for (let f = 0; f < files.length; f++) {
|
|
1252
1487
|
const filePath = path.join(dir, files[f]);
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
//
|
|
1488
|
+
// Read only the file tail needed to top up the window — for a large
|
|
1489
|
+
// single-day file this seeks the last `needed` lines instead of parsing
|
|
1490
|
+
// the whole file. We re-derive the per-file floor each iteration from how
|
|
1491
|
+
// many records are still missing (plus slack so dedup at the boundary is
|
|
1492
|
+
// stable), capped at `needed`.
|
|
1493
|
+
const remaining = Math.max(0, needed - collected.length);
|
|
1494
|
+
const perFileNeeded = Math.min(needed, remaining + BOUNDED_TAIL_SLACK);
|
|
1495
|
+
const { lines, coversWholeFile } = readFileTailLines(filePath, perFileNeeded);
|
|
1496
|
+
// Walk this file's tail lines newest-first so we fill the tail window from
|
|
1497
|
+
// the bottom. seen-dedup keeps the same first-wins-by-newest semantics the
|
|
1262
1498
|
// full read produced (files are processed newest-first there too).
|
|
1263
1499
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1500
|
+
const line = lines[i];
|
|
1501
|
+
if (!line) continue;
|
|
1264
1502
|
try {
|
|
1265
|
-
const parsed = JSON.parse(
|
|
1503
|
+
const parsed = JSON.parse(line) as HistoryMessage;
|
|
1266
1504
|
const sanitizedMessage = sanitizeHistoryMessage(agentType, parsed);
|
|
1267
1505
|
if (!sanitizedMessage) continue;
|
|
1268
1506
|
const hash = buildHistoryMessageHash(agentType, sanitizedMessage);
|
|
@@ -1271,6 +1509,13 @@ function readBoundedTailRecords(
|
|
|
1271
1509
|
collected.push(sanitizedMessage);
|
|
1272
1510
|
} catch { /* skip invalid lines */ }
|
|
1273
1511
|
}
|
|
1512
|
+
// If we only read this file's tail (its head was not reached), older
|
|
1513
|
+
// messages remain within this very file — the conversation is NOT fully
|
|
1514
|
+
// represented even if this is the last file, so hasMore must stay true.
|
|
1515
|
+
if (!coversWholeFile) {
|
|
1516
|
+
readAllFiles = false;
|
|
1517
|
+
break;
|
|
1518
|
+
}
|
|
1274
1519
|
// Stop once we have the window AND there is at least one more file (so a
|
|
1275
1520
|
// potential older boundary message exists). If this is the last file we
|
|
1276
1521
|
// fall through and mark the whole history as read.
|
|
@@ -384,6 +384,7 @@ function buildRulesSection(coordinatorCliType?: string): string {
|
|
|
384
384
|
- **Verify via git, not source.** Use \`mesh_git_status\` to confirm side effects. Treat agent summaries as self-reports, not verification.
|
|
385
385
|
- **Limit parallelism.** Start with 1–2 tasks; scale only on success. Never duplicate a session because \`mesh_read_chat\` shows no final message while tool/terminal activity is ongoing. This caps *concurrent* load — it does not mean serialize independent work: when a new, independent request arrives and there is headroom under \`maxParallelTasks\`, dispatch it right away rather than waiting for an in-flight task or a user nudge (read-only diagnosis especially, since it has no merge cost).
|
|
386
386
|
- **Check history first.** Call \`mesh_task_history\` at session start to avoid duplicate work and inform recovery. On failure, read task history before retrying.
|
|
387
|
+
- **Sequence shared-base-moving merges.** Parallel dispatch is encouraged, but merging one worktree can advance another in-flight worktree's base — especially the oss submodule pointer — turning a clean fast-forward into a diverged rebase (patch-equivalence correctly blocks this). Before merging an in-flight worktree while siblings are also in flight, land in an intentional order, re-clone long-running worktrees from the advanced base, or expect to manually rebase + ff-only the laggards; merging an independent fix mid-flight can strand siblings into a rebase.
|
|
387
388
|
- **Converge branches.** After worktree tasks: refine/fast-forward, or classify as \`pushed_feature_branch_needs_merge\` / \`blocked_review\` / \`cleanup_candidate\` / \`not_mergeable\`. Clean up with \`mesh_remove_node\`.
|
|
388
389
|
- **Refinery is config-driven.** \`mesh_refine_node\` must run validation from \`.adhdev/refine.{json,yaml,yml}\` or \`repo-mesh.refine.*\`. Heuristics are scaffolding only.
|
|
389
390
|
- **Submodule reachability = publish-needed.** \`submodule_reachability_failed\` → classify as \`blocked_review\`, request user approval to push to submodule main, then rerun \`mesh_refine_node\`.
|
|
@@ -37,6 +37,18 @@ type PersistableCliHistoryMessage = {
|
|
|
37
37
|
receivedAt?: number;
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
// Status snapshots only ever surface the newest messages: the cloud 'live'
|
|
41
|
+
// profile drops chat messages entirely (loaded lazily via read_chat on
|
|
42
|
+
// subscribe) and the 'full' profile caps activeChat.messages to the last 60
|
|
43
|
+
// (see status/normalize.ts). Unread/completion markers walk only the tail.
|
|
44
|
+
// So getState()'s saved-history hydration — which runs once per resume/manual
|
|
45
|
+
// CLI session on every status report — must read only a bounded tail, not the
|
|
46
|
+
// entire transcript. A full MAX_SAFE_INTEGER read here makes the initial
|
|
47
|
+
// status report O(transcript) × N(sessions), which is the real cold first-
|
|
48
|
+
// connection bottleneck on chat-heavy machines. The window comfortably exceeds
|
|
49
|
+
// the 60-message snapshot cap so dedup/collapse at the boundary stays stable.
|
|
50
|
+
const STATUS_HYDRATION_TAIL_LIMIT = 200;
|
|
51
|
+
|
|
40
52
|
type CompletedDebouncePending = {
|
|
41
53
|
chatTitle: string;
|
|
42
54
|
duration: number;
|
|
@@ -2190,13 +2202,21 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2190
2202
|
return newestMessageAt === 0;
|
|
2191
2203
|
}
|
|
2192
2204
|
|
|
2193
|
-
private syncCanonicalSavedHistoryIfNeeded(): boolean {
|
|
2205
|
+
private syncCanonicalSavedHistoryIfNeeded(options: { full?: boolean } = {}): boolean {
|
|
2194
2206
|
if (!this.providerSessionId) return false;
|
|
2195
2207
|
const canonicalHistory = this.provider.nativeHistory;
|
|
2196
2208
|
if (!canonicalHistory) return false;
|
|
2197
2209
|
|
|
2210
|
+
// Per-status-report hydration reads only a bounded tail (snapshot needs at
|
|
2211
|
+
// most the newest 60). The once-per-resume restore path passes full:true
|
|
2212
|
+
// because seedSessionHistory needs the COMPLETE transcript to seed dedup
|
|
2213
|
+
// state. The read-cache key encodes the window so the bounded and full
|
|
2214
|
+
// reads don't share/clobber each other's 2s cache entry.
|
|
2215
|
+
const limit = options.full ? Number.MAX_SAFE_INTEGER : STATUS_HYDRATION_TAIL_LIMIT;
|
|
2216
|
+
const windowTag = options.full ? 'full' : `tail:${STATUS_HYDRATION_TAIL_LIMIT}`;
|
|
2217
|
+
|
|
2198
2218
|
if (isNativeSourceCanonicalHistory(canonicalHistory)) {
|
|
2199
|
-
const cacheKey = [this.type, this.providerSessionId, this.workingDir].join('\0');
|
|
2219
|
+
const cacheKey = [this.type, this.providerSessionId, this.workingDir, windowTag].join('\0');
|
|
2200
2220
|
const now = Date.now();
|
|
2201
2221
|
if (cacheKey === this.lastNativeSourceCanonicalCacheKey && now - this.lastNativeSourceCanonicalCheckAt < 2_000) {
|
|
2202
2222
|
return true;
|
|
@@ -2209,7 +2229,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2209
2229
|
historySessionId: this.providerSessionId,
|
|
2210
2230
|
workspace: this.workingDir,
|
|
2211
2231
|
offset: 0,
|
|
2212
|
-
limit
|
|
2232
|
+
limit,
|
|
2213
2233
|
historyBehavior: this.provider.historyBehavior,
|
|
2214
2234
|
scripts: this.provider.scripts as any,
|
|
2215
2235
|
});
|
|
@@ -2226,7 +2246,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2226
2246
|
}
|
|
2227
2247
|
|
|
2228
2248
|
try {
|
|
2229
|
-
const cacheKey = [this.type, this.providerSessionId, this.workingDir, canonicalHistory.mode || 'materialized-mirror'].join('\0');
|
|
2249
|
+
const cacheKey = [this.type, this.providerSessionId, this.workingDir, canonicalHistory.mode || 'materialized-mirror', windowTag].join('\0');
|
|
2230
2250
|
const now = Date.now();
|
|
2231
2251
|
if (cacheKey === this.lastNativeSourceCanonicalCacheKey && now - this.lastNativeSourceCanonicalCheckAt < 2_000) {
|
|
2232
2252
|
return true;
|
|
@@ -2237,15 +2257,14 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2237
2257
|
if (!materializeProviderNativeHistory(this.type, canonicalHistory, this.providerSessionId, this.workingDir, this.provider.scripts as any)) {
|
|
2238
2258
|
return false;
|
|
2239
2259
|
}
|
|
2240
|
-
//
|
|
2241
|
-
//
|
|
2242
|
-
//
|
|
2243
|
-
//
|
|
2244
|
-
//
|
|
2245
|
-
//
|
|
2246
|
-
//
|
|
2247
|
-
|
|
2248
|
-
const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId, 0, this.provider.historyBehavior);
|
|
2260
|
+
// Bounded by default: the per-status-report path only needs the newest
|
|
2261
|
+
// STATUS_HYDRATION_TAIL_LIMIT messages because the snapshot caps
|
|
2262
|
+
// activeChat.messages to the last 60 (status/normalize.ts) and loads
|
|
2263
|
+
// the rest lazily via read_chat on subscribe. The once-per-resume
|
|
2264
|
+
// restore path passes full:true so seedSessionHistory still sees the
|
|
2265
|
+
// COMPLETE transcript for prefix-dedup seeding. readChatHistory serves
|
|
2266
|
+
// a bounded limit as an O(tail) read.
|
|
2267
|
+
const restoredHistory = readChatHistory(this.type, 0, limit, this.providerSessionId, 0, this.provider.historyBehavior);
|
|
2249
2268
|
this.lastPersistedHistoryMessages = restoredHistory.messages.map((message) => ({
|
|
2250
2269
|
role: message.role,
|
|
2251
2270
|
content: message.content,
|
|
@@ -2261,7 +2280,10 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2261
2280
|
|
|
2262
2281
|
private restorePersistedHistoryFromCurrentSession(): void {
|
|
2263
2282
|
if (!this.providerSessionId) return;
|
|
2264
|
-
|
|
2283
|
+
// Restore is the once-per-resume seeding path: it needs the COMPLETE
|
|
2284
|
+
// transcript so seedSessionHistory can prime dedup state. Pass full so the
|
|
2285
|
+
// hydration read is unbounded here (and only here).
|
|
2286
|
+
this.syncCanonicalSavedHistoryIfNeeded({ full: true });
|
|
2265
2287
|
const restoredHistory = isNativeSourceCanonicalHistory(this.provider.nativeHistory)
|
|
2266
2288
|
? readProviderChatHistory(this.type, {
|
|
2267
2289
|
canonicalHistory: this.provider.nativeHistory,
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
buildClaudeInteractiveToolResult,
|
|
32
32
|
detectClaudeAskUserQuestionPromptFromJson,
|
|
33
33
|
detectClaudeAskUserQuestionPromptFromTuiPages,
|
|
34
|
+
detectClaudeTuiMultiSelect,
|
|
35
|
+
readFocusedClaudeTuiQuestion,
|
|
34
36
|
type ClaudeInteractiveTuiPage,
|
|
35
37
|
type InteractivePrompt,
|
|
36
38
|
type InteractivePromptResponse,
|
|
@@ -445,12 +447,14 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
445
447
|
}
|
|
446
448
|
this.maybeClearResolvedClaudeTuiPrompt();
|
|
447
449
|
this.maybeCaptureClaudeTuiPrompt();
|
|
450
|
+
this.maybeUpgradeClaudeTuiMultiSelect();
|
|
448
451
|
this.statusCallback?.();
|
|
449
452
|
return;
|
|
450
453
|
case 'pty_data':
|
|
451
454
|
this.detectInteractivePromptFromPtyChunk(ev.chunk);
|
|
452
455
|
this.maybeClearResolvedClaudeTuiPrompt();
|
|
453
456
|
this.maybeCaptureClaudeTuiPrompt();
|
|
457
|
+
this.maybeUpgradeClaudeTuiMultiSelect();
|
|
454
458
|
try { this.ptyDataCallback?.(ev.chunk); } catch { /* ignore */ }
|
|
455
459
|
return;
|
|
456
460
|
case 'exit':
|
|
@@ -614,6 +618,63 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
614
618
|
});
|
|
615
619
|
}
|
|
616
620
|
|
|
621
|
+
/**
|
|
622
|
+
* The TUI prompt is captured on the FIRST frame that renders the
|
|
623
|
+
* "Enter to select" footer. At that instant the option rows' checkbox
|
|
624
|
+
* column may not have drawn yet, so `detectClaudeTuiMultiSelect` returns
|
|
625
|
+
* false and the prompt is frozen as single-select — the dashboard then
|
|
626
|
+
* renders radio buttons even though the picker is multi-select.
|
|
627
|
+
*
|
|
628
|
+
* While the same TUI prompt is still on screen, re-check the live snapshot:
|
|
629
|
+
* if checkbox glyphs have since appeared, promote any single-select
|
|
630
|
+
* question to multi-select and re-emit status. Promotion is one-way
|
|
631
|
+
* (false→true only) — once a question is known multi-select we never demote
|
|
632
|
+
* it, since the glyph column can scroll out of view on later frames.
|
|
633
|
+
*
|
|
634
|
+
* For MULTI-question prompts the per-page Tab capture is the actual source
|
|
635
|
+
* of the bug: pages 2..N are snapshotted ~120ms after the Tab keypress,
|
|
636
|
+
* before their option-row glyph column has redrawn, so those questions
|
|
637
|
+
* freeze as single-select while page 1 (already settled) is correct. We
|
|
638
|
+
* cannot upgrade blindly — the live snapshot shows only ONE focused page —
|
|
639
|
+
* but we CAN read that page's question text/header and upgrade the matching
|
|
640
|
+
* question. As the user navigates the picker (or it settles), each page is
|
|
641
|
+
* eventually re-read and repaired.
|
|
642
|
+
*/
|
|
643
|
+
private maybeUpgradeClaudeTuiMultiSelect(): void {
|
|
644
|
+
if (this.cliType !== 'claude-cli'
|
|
645
|
+
|| this.interactivePromptTransport !== 'tui'
|
|
646
|
+
|| !this.activeInteractivePrompt) return;
|
|
647
|
+
const questions = this.activeInteractivePrompt.questions;
|
|
648
|
+
if (questions.every(q => q.multiSelect)) return;
|
|
649
|
+
let screenText = '';
|
|
650
|
+
try {
|
|
651
|
+
screenText = this.driver.snapshot();
|
|
652
|
+
} catch {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (!screenText.includes('Enter to select')) return;
|
|
656
|
+
|
|
657
|
+
if (questions.length === 1) {
|
|
658
|
+
if (questions[0].multiSelect) return;
|
|
659
|
+
if (!detectClaudeTuiMultiSelect(screenText)) return;
|
|
660
|
+
questions[0].multiSelect = true;
|
|
661
|
+
this.statusCallback?.();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Multi-question: attribute the focused page's glyphs to its question by
|
|
666
|
+
// matching header (preferred) or question text, then upgrade just that
|
|
667
|
+
// one. Never demote — a settled non-multi page is left as captured.
|
|
668
|
+
const focused = readFocusedClaudeTuiQuestion(screenText);
|
|
669
|
+
if (!focused || !focused.multiSelect) return;
|
|
670
|
+
const match = questions.find(q =>
|
|
671
|
+
(focused.header && q.header && q.header === focused.header)
|
|
672
|
+
|| q.question === focused.question);
|
|
673
|
+
if (!match || match.multiSelect) return;
|
|
674
|
+
match.multiSelect = true;
|
|
675
|
+
this.statusCallback?.();
|
|
676
|
+
}
|
|
677
|
+
|
|
617
678
|
private readClaudeTuiHeaders(screenText: string): string[] {
|
|
618
679
|
const navLine = screenText.split(/\r?\n/).find(line => line.includes('✔ Submit') && /[☐☒]/.test(line));
|
|
619
680
|
if (!navLine) return [];
|
|
@@ -180,19 +180,23 @@ function isClaudeTuiSelectFooter(text: string): boolean {
|
|
|
180
180
|
* broadened footer patterns ("Space to", "toggle", "select multiple") are kept
|
|
181
181
|
* as a secondary signal for layouts that render markers differently.
|
|
182
182
|
*/
|
|
183
|
-
function detectClaudeTuiMultiSelect(screenText: string): boolean {
|
|
183
|
+
export function detectClaudeTuiMultiSelect(screenText: string): boolean {
|
|
184
184
|
if (/Space to (?:select|toggle)|toggle selection|select multiple|select all that apply/i.test(screenText)) {
|
|
185
185
|
return true;
|
|
186
186
|
}
|
|
187
|
-
// A checkbox glyph
|
|
188
|
-
// the
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
187
|
+
// A checkbox glyph on a NUMBERED option row only appears in the multi-select
|
|
188
|
+
// picker. The glyph sits EITHER before the number ("❯ [ ] 1. TypeScript" /
|
|
189
|
+
// "☐ 2. Python") OR after it ("❯ 1. [ ] 계란말이" — claude-cli >=2.1's layout).
|
|
190
|
+
// We require the numbered "N." option marker either way so we don't
|
|
191
|
+
// false-positive on the `✔ Submit` nav line (per-question answered-state
|
|
192
|
+
// ☐/☒) or on the headerless variant where the QUESTION line itself begins
|
|
193
|
+
// with `☐ ` (single-select).
|
|
194
|
+
const optionCheckbox = `(?:\\[[ xX]\\]|[☐☒◻◼])`;
|
|
195
|
+
const beforeNumber = new RegExp(`^\\s*(?:[❯›>]\\s*)?${optionCheckbox}\\s*\\d+\\.\\s+\\S`);
|
|
196
|
+
const afterNumber = new RegExp(`^\\s*(?:[❯›>]\\s*)?\\d+\\.\\s*${optionCheckbox}\\s+\\S`);
|
|
193
197
|
for (const line of screenText.split(/\r?\n/)) {
|
|
194
198
|
if (line.includes('✔ Submit')) continue; // header/nav line
|
|
195
|
-
if (
|
|
199
|
+
if (beforeNumber.test(line) || afterNumber.test(line)) return true;
|
|
196
200
|
}
|
|
197
201
|
return false;
|
|
198
202
|
}
|
|
@@ -361,6 +365,31 @@ function parseClaudeInteractiveTuiQuestion(page: ClaudeInteractiveTuiPage, index
|
|
|
361
365
|
};
|
|
362
366
|
}
|
|
363
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Read the question the live claude TUI picker is CURRENTLY focused on, plus
|
|
370
|
+
* whether that focused page renders multi-select checkbox markers.
|
|
371
|
+
*
|
|
372
|
+
* Used to repair a multi-question prompt after the fact: when the daemon
|
|
373
|
+
* Tab-captures pages 2..N it snapshots ~120ms after the Tab keypress, before
|
|
374
|
+
* the newly-focused page's option-row checkbox column has redrawn — so those
|
|
375
|
+
* questions get frozen as multiSelect:false even though the picker is
|
|
376
|
+
* multi-select. Re-reading the focused page on a later status tick (once it has
|
|
377
|
+
* settled) lets us attribute the now-visible glyphs to the matching question
|
|
378
|
+
* and upgrade just that one. Returns null when no picker question is on screen.
|
|
379
|
+
*/
|
|
380
|
+
export function readFocusedClaudeTuiQuestion(
|
|
381
|
+
screenText: string,
|
|
382
|
+
): { question: string; header?: string; multiSelect: boolean } | null {
|
|
383
|
+
if (!screenText.includes('Enter to select')) return null;
|
|
384
|
+
const parsed = parseClaudeInteractiveTuiQuestion({ screenText }, 0);
|
|
385
|
+
if (!parsed) return null;
|
|
386
|
+
return {
|
|
387
|
+
question: parsed.question,
|
|
388
|
+
...(parsed.header ? { header: parsed.header } : {}),
|
|
389
|
+
multiSelect: parsed.multiSelect,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
364
393
|
export function detectClaudeAskUserQuestionPromptFromTuiPages(
|
|
365
394
|
pages: ClaudeInteractiveTuiPage[],
|
|
366
395
|
options: { promptId: string; providerType?: string; createdAt?: number },
|