droid-patch 0.7.0 → 0.8.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.
- package/README.md +63 -0
- package/README.zh-CN.md +63 -0
- package/dist/alias-Bhigcbue.mjs.map +1 -1
- package/dist/cli.mjs +512 -97
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -3
package/dist/cli.mjs
CHANGED
|
@@ -531,6 +531,45 @@ PROXY_PID=""
|
|
|
531
531
|
PORT_FILE="/tmp/droid-websearch-\$\$.port"
|
|
532
532
|
STANDALONE="${standalone ? "1" : "0"}"
|
|
533
533
|
|
|
534
|
+
# Passthrough for non-interactive/meta commands (avoid starting a proxy for help/version/etc)
|
|
535
|
+
should_passthrough() {
|
|
536
|
+
# Any help/version flags before "--"
|
|
537
|
+
for arg in "\$@"; do
|
|
538
|
+
if [ "\$arg" = "--" ]; then
|
|
539
|
+
break
|
|
540
|
+
fi
|
|
541
|
+
case "\$arg" in
|
|
542
|
+
--help|-h|--version|-V)
|
|
543
|
+
return 0
|
|
544
|
+
;;
|
|
545
|
+
esac
|
|
546
|
+
done
|
|
547
|
+
|
|
548
|
+
# Top-level command token
|
|
549
|
+
local end_opts=0
|
|
550
|
+
for arg in "\$@"; do
|
|
551
|
+
if [ "\$arg" = "--" ]; then
|
|
552
|
+
end_opts=1
|
|
553
|
+
continue
|
|
554
|
+
fi
|
|
555
|
+
if [ "\$end_opts" -eq 0 ] && [[ "\$arg" == -* ]]; then
|
|
556
|
+
continue
|
|
557
|
+
fi
|
|
558
|
+
case "\$arg" in
|
|
559
|
+
help|version|completion|completions|exec)
|
|
560
|
+
return 0
|
|
561
|
+
;;
|
|
562
|
+
esac
|
|
563
|
+
break
|
|
564
|
+
done
|
|
565
|
+
|
|
566
|
+
return 1
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if should_passthrough "\$@"; then
|
|
570
|
+
exec "\$DROID_BIN" "\$@"
|
|
571
|
+
fi
|
|
572
|
+
|
|
534
573
|
# Cleanup function - kill proxy when droid exits
|
|
535
574
|
cleanup() {
|
|
536
575
|
if [ -n "\$PROXY_PID" ] && kill -0 "\$PROXY_PID" 2>/dev/null; then
|
|
@@ -660,6 +699,33 @@ function isPositiveInt(n) {
|
|
|
660
699
|
return Number.isFinite(n) && n > 0;
|
|
661
700
|
}
|
|
662
701
|
|
|
702
|
+
function firstNonNull(promises) {
|
|
703
|
+
const list = Array.isArray(promises) ? promises : [];
|
|
704
|
+
if (list.length === 0) return Promise.resolve(null);
|
|
705
|
+
return new Promise((resolve) => {
|
|
706
|
+
let pending = list.length;
|
|
707
|
+
let done = false;
|
|
708
|
+
for (const p of list) {
|
|
709
|
+
Promise.resolve(p)
|
|
710
|
+
.then((value) => {
|
|
711
|
+
if (done) return;
|
|
712
|
+
if (value) {
|
|
713
|
+
done = true;
|
|
714
|
+
resolve(value);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
pending -= 1;
|
|
718
|
+
if (pending <= 0) resolve(null);
|
|
719
|
+
})
|
|
720
|
+
.catch(() => {
|
|
721
|
+
if (done) return;
|
|
722
|
+
pending -= 1;
|
|
723
|
+
if (pending <= 0) resolve(null);
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
663
729
|
function listPidsInProcessGroup(pgid) {
|
|
664
730
|
if (!isPositiveInt(pgid)) return [];
|
|
665
731
|
try {
|
|
@@ -712,10 +778,11 @@ function resolveOpenSessionFromPids(pids) {
|
|
|
712
778
|
return null;
|
|
713
779
|
}
|
|
714
780
|
|
|
715
|
-
async function resolveSessionFromProcessGroup() {
|
|
781
|
+
async function resolveSessionFromProcessGroup(shouldAbort, maxTries = 20) {
|
|
716
782
|
if (!isPositiveInt(PGID)) return null;
|
|
717
783
|
// Wait a little for droid to create/open the session files.
|
|
718
|
-
for (let i = 0; i <
|
|
784
|
+
for (let i = 0; i < maxTries; i++) {
|
|
785
|
+
if (shouldAbort && shouldAbort()) return null;
|
|
719
786
|
const pids = listPidsInProcessGroup(PGID);
|
|
720
787
|
const found = resolveOpenSessionFromPids(pids);
|
|
721
788
|
if (found) return found;
|
|
@@ -872,8 +939,9 @@ function pickLatestSessionAcross(workspaceDirs) {
|
|
|
872
939
|
return best ? { workspaceDir: best.workspaceDir, id: best.id } : null;
|
|
873
940
|
}
|
|
874
941
|
|
|
875
|
-
async function waitForNewSessionAcross(workspaceDirs, knownIdsByWorkspace, startMs) {
|
|
942
|
+
async function waitForNewSessionAcross(workspaceDirs, knownIdsByWorkspace, startMs, shouldAbort) {
|
|
876
943
|
for (let i = 0; i < 80; i++) {
|
|
944
|
+
if (shouldAbort && shouldAbort()) return null;
|
|
877
945
|
let best = null;
|
|
878
946
|
for (const dir of workspaceDirs) {
|
|
879
947
|
const known = knownIdsByWorkspace.get(dir) || new Set();
|
|
@@ -1113,25 +1181,30 @@ async function main() {
|
|
|
1113
1181
|
sessionId = resumeId;
|
|
1114
1182
|
workspaceDir = findWorkspaceDirForSessionId(workspaceDirs, sessionId) || workspaceDirs[0] || null;
|
|
1115
1183
|
} else {
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1184
|
+
let abortResolve = false;
|
|
1185
|
+
const shouldAbort = () => abortResolve;
|
|
1186
|
+
|
|
1187
|
+
const byProcPromise = resolveSessionFromProcessGroup(shouldAbort, 20);
|
|
1188
|
+
|
|
1189
|
+
let picked = null;
|
|
1190
|
+
if (resumeFlag) {
|
|
1191
|
+
// For --resume without an explicit id, don't block startup too long on ps/lsof.
|
|
1192
|
+
// Prefer process-group resolution when it is fast; otherwise fall back to latest.
|
|
1193
|
+
picked = await Promise.race([
|
|
1194
|
+
byProcPromise,
|
|
1195
|
+
sleep(400).then(() => null),
|
|
1196
|
+
]);
|
|
1197
|
+
if (!picked) picked = pickLatestSessionAcross(workspaceDirs);
|
|
1124
1198
|
} else {
|
|
1125
|
-
const
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
workspaceDir = fresh.workspaceDir;
|
|
1129
|
-
} else {
|
|
1130
|
-
const latest = pickLatestSessionAcross(workspaceDirs);
|
|
1131
|
-
sessionId = latest?.id || null;
|
|
1132
|
-
workspaceDir = latest?.workspaceDir || workspaceDirs[0] || null;
|
|
1133
|
-
}
|
|
1199
|
+
const freshPromise = waitForNewSessionAcross(workspaceDirs, knownIdsByWorkspace, START_MS, shouldAbort);
|
|
1200
|
+
picked = await firstNonNull([byProcPromise, freshPromise]);
|
|
1201
|
+
if (!picked) picked = pickLatestSessionAcross(workspaceDirs);
|
|
1134
1202
|
}
|
|
1203
|
+
|
|
1204
|
+
abortResolve = true;
|
|
1205
|
+
|
|
1206
|
+
sessionId = picked?.id || null;
|
|
1207
|
+
workspaceDir = picked?.workspaceDir || workspaceDirs[0] || null;
|
|
1135
1208
|
}
|
|
1136
1209
|
|
|
1137
1210
|
if (!sessionId || !workspaceDir) return;
|
|
@@ -1148,8 +1221,8 @@ async function main() {
|
|
|
1148
1221
|
let compacting = false;
|
|
1149
1222
|
let lastRenderAt = 0;
|
|
1150
1223
|
let lastRenderedLine = '';
|
|
1151
|
-
|
|
1152
|
-
|
|
1224
|
+
let gitBranch = '';
|
|
1225
|
+
let gitDiff = '';
|
|
1153
1226
|
|
|
1154
1227
|
function renderNow() {
|
|
1155
1228
|
const usedTokens = (last.cacheReadInputTokens || 0) + (last.contextCount || 0);
|
|
@@ -1172,79 +1245,93 @@ async function main() {
|
|
|
1172
1245
|
}
|
|
1173
1246
|
}
|
|
1174
1247
|
|
|
1175
|
-
//
|
|
1176
|
-
|
|
1177
|
-
try {
|
|
1178
|
-
// Backward scan to find the most recent streaming-context entry for this session.
|
|
1179
|
-
// The log can be large and shared across multiple sessions, so a small tail slice
|
|
1180
|
-
// may miss older resumed sessions.
|
|
1181
|
-
const MAX_SCAN_BYTES = 64 * 1024 * 1024; // 64 MiB
|
|
1182
|
-
const CHUNK_BYTES = 1024 * 1024; // 1 MiB
|
|
1248
|
+
// Initial render.
|
|
1249
|
+
renderNow();
|
|
1183
1250
|
|
|
1184
|
-
|
|
1251
|
+
// Resolve git info asynchronously so startup isn't blocked on large repos.
|
|
1252
|
+
setTimeout(() => {
|
|
1185
1253
|
try {
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
let text = buf.toString('utf8') + remainder;
|
|
1202
|
-
let lines = String(text).split('\\n');
|
|
1203
|
-
remainder = lines.shift() || '';
|
|
1204
|
-
if (pos === 0 && remainder) {
|
|
1205
|
-
lines.unshift(remainder);
|
|
1206
|
-
remainder = '';
|
|
1207
|
-
}
|
|
1254
|
+
gitBranch = resolveGitBranch(cwd);
|
|
1255
|
+
gitDiff = resolveGitDiffSummary(cwd);
|
|
1256
|
+
renderNow();
|
|
1257
|
+
} catch {}
|
|
1258
|
+
}, 0).unref();
|
|
1259
|
+
|
|
1260
|
+
// Seed prompt-context usage from existing logs (important for resumed sessions).
|
|
1261
|
+
// Do this asynchronously to avoid delaying the first statusline frame.
|
|
1262
|
+
if (resumeFlag || resumeId) {
|
|
1263
|
+
setTimeout(() => {
|
|
1264
|
+
try {
|
|
1265
|
+
// Backward scan to find the most recent streaming-context entry for this session.
|
|
1266
|
+
// The log can be large and shared across multiple sessions.
|
|
1267
|
+
const MAX_SCAN_BYTES = 64 * 1024 * 1024; // 64 MiB
|
|
1268
|
+
const CHUNK_BYTES = 1024 * 1024; // 1 MiB
|
|
1208
1269
|
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1270
|
+
const fd = fs.openSync(LOG_PATH, 'r');
|
|
1271
|
+
try {
|
|
1272
|
+
const stat = fs.fstatSync(fd);
|
|
1273
|
+
const size = Number(stat?.size ?? 0);
|
|
1274
|
+
let pos = size;
|
|
1275
|
+
let scanned = 0;
|
|
1276
|
+
let remainder = '';
|
|
1277
|
+
let seeded = false;
|
|
1278
|
+
|
|
1279
|
+
while (pos > 0 && scanned < MAX_SCAN_BYTES && !seeded) {
|
|
1280
|
+
const readSize = Math.min(CHUNK_BYTES, pos);
|
|
1281
|
+
const start = pos - readSize;
|
|
1282
|
+
const buf = Buffer.alloc(readSize);
|
|
1283
|
+
fs.readSync(fd, buf, 0, readSize, start);
|
|
1284
|
+
pos = start;
|
|
1285
|
+
scanned += readSize;
|
|
1286
|
+
|
|
1287
|
+
let text = buf.toString('utf8') + remainder;
|
|
1288
|
+
let lines = String(text).split('\\n');
|
|
1289
|
+
remainder = lines.shift() || '';
|
|
1290
|
+
if (pos === 0 && remainder) {
|
|
1291
|
+
lines.unshift(remainder);
|
|
1292
|
+
remainder = '';
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1296
|
+
const line = String(lines[i] || '').trimEnd();
|
|
1297
|
+
if (!line) continue;
|
|
1298
|
+
if (!line.includes('Context:')) continue;
|
|
1299
|
+
if (!line.includes('"sessionId":"' + sessionId + '"')) continue;
|
|
1300
|
+
if (!line.includes('[Agent] Streaming result')) continue;
|
|
1301
|
+
const ctxIndex = line.indexOf('Context: ');
|
|
1302
|
+
if (ctxIndex === -1) continue;
|
|
1303
|
+
const jsonStr = line.slice(ctxIndex + 'Context: '.length).trim();
|
|
1304
|
+
let ctx;
|
|
1305
|
+
try {
|
|
1306
|
+
ctx = JSON.parse(jsonStr);
|
|
1307
|
+
} catch {
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
const cacheRead = Number(ctx?.cacheReadInputTokens ?? 0);
|
|
1311
|
+
const contextCount = Number(ctx?.contextCount ?? 0);
|
|
1312
|
+
const out = Number(ctx?.outputTokens ?? 0);
|
|
1313
|
+
if (Number.isFinite(cacheRead)) last.cacheReadInputTokens = cacheRead;
|
|
1314
|
+
if (Number.isFinite(contextCount)) last.contextCount = contextCount;
|
|
1315
|
+
if (Number.isFinite(out)) last.outputTokens = out;
|
|
1316
|
+
seeded = true;
|
|
1317
|
+
break;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
if (remainder.length > 8192) remainder = remainder.slice(-8192);
|
|
1223
1321
|
}
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
if (Number.isFinite(contextCount)) last.contextCount = contextCount;
|
|
1229
|
-
if (Number.isFinite(out)) last.outputTokens = out;
|
|
1230
|
-
seeded = true;
|
|
1231
|
-
break;
|
|
1322
|
+
} finally {
|
|
1323
|
+
try {
|
|
1324
|
+
fs.closeSync(fd);
|
|
1325
|
+
} catch {}
|
|
1232
1326
|
}
|
|
1233
1327
|
|
|
1234
|
-
|
|
1328
|
+
renderNow();
|
|
1329
|
+
} catch {
|
|
1330
|
+
// ignore
|
|
1235
1331
|
}
|
|
1236
|
-
}
|
|
1237
|
-
try {
|
|
1238
|
-
fs.closeSync(fd);
|
|
1239
|
-
} catch {}
|
|
1240
|
-
}
|
|
1241
|
-
} catch {
|
|
1242
|
-
// ignore
|
|
1332
|
+
}, 0).unref();
|
|
1243
1333
|
}
|
|
1244
1334
|
|
|
1245
|
-
// Initial render.
|
|
1246
|
-
renderNow();
|
|
1247
|
-
|
|
1248
1335
|
// Watch session settings for autonomy/reasoning changes (cheap polling with mtime).
|
|
1249
1336
|
let settingsMtimeMs = 0;
|
|
1250
1337
|
setInterval(() => {
|
|
@@ -1331,7 +1418,7 @@ async function main() {
|
|
|
1331
1418
|
main().catch(() => {});
|
|
1332
1419
|
`;
|
|
1333
1420
|
}
|
|
1334
|
-
function generateStatuslineWrapperScript(execTargetPath, monitorScriptPath) {
|
|
1421
|
+
function generateStatuslineWrapperScript(execTargetPath, monitorScriptPath, sessionsScriptPath) {
|
|
1335
1422
|
return `#!/usr/bin/env python3
|
|
1336
1423
|
# Droid with Statusline (PTY proxy)
|
|
1337
1424
|
# Auto-generated by droid-patch --statusline
|
|
@@ -1357,6 +1444,7 @@ import fcntl
|
|
|
1357
1444
|
|
|
1358
1445
|
EXEC_TARGET = ${JSON.stringify(execTargetPath)}
|
|
1359
1446
|
STATUSLINE_MONITOR = ${JSON.stringify(monitorScriptPath)}
|
|
1447
|
+
SESSIONS_SCRIPT = ${sessionsScriptPath ? JSON.stringify(sessionsScriptPath) : "None"}
|
|
1360
1448
|
|
|
1361
1449
|
IS_APPLE_TERMINAL = os.environ.get("TERM_PROGRAM") == "Apple_Terminal"
|
|
1362
1450
|
MIN_RENDER_INTERVAL_MS = 800 if IS_APPLE_TERMINAL else 400
|
|
@@ -1364,6 +1452,61 @@ QUIET_MS = 50 # Reduced to prevent statusline disappearing
|
|
|
1364
1452
|
FORCE_REPAINT_INTERVAL_MS = 2000 # Force repaint every 2 seconds
|
|
1365
1453
|
RESERVED_ROWS = 1
|
|
1366
1454
|
|
|
1455
|
+
BYPASS_FLAGS = {"--help", "-h", "--version", "-V"}
|
|
1456
|
+
BYPASS_COMMANDS = {"help", "version", "completion", "completions", "exec"}
|
|
1457
|
+
|
|
1458
|
+
def _should_passthrough(argv):
|
|
1459
|
+
# Any help/version flags before "--"
|
|
1460
|
+
for a in argv:
|
|
1461
|
+
if a == "--":
|
|
1462
|
+
break
|
|
1463
|
+
if a in BYPASS_FLAGS:
|
|
1464
|
+
return True
|
|
1465
|
+
|
|
1466
|
+
# Top-level command token
|
|
1467
|
+
end_opts = False
|
|
1468
|
+
cmd = None
|
|
1469
|
+
for a in argv:
|
|
1470
|
+
if a == "--":
|
|
1471
|
+
end_opts = True
|
|
1472
|
+
continue
|
|
1473
|
+
if (not end_opts) and a.startswith("-"):
|
|
1474
|
+
continue
|
|
1475
|
+
cmd = a
|
|
1476
|
+
break
|
|
1477
|
+
|
|
1478
|
+
return cmd in BYPASS_COMMANDS
|
|
1479
|
+
|
|
1480
|
+
def _exec_passthrough():
|
|
1481
|
+
try:
|
|
1482
|
+
os.execvp(EXEC_TARGET, [EXEC_TARGET] + sys.argv[1:])
|
|
1483
|
+
except Exception as e:
|
|
1484
|
+
sys.stderr.write(f"[statusline] passthrough failed: {e}\\n")
|
|
1485
|
+
sys.exit(1)
|
|
1486
|
+
|
|
1487
|
+
def _is_sessions_command(argv):
|
|
1488
|
+
for a in argv:
|
|
1489
|
+
if a == "--":
|
|
1490
|
+
return False
|
|
1491
|
+
if a == "--sessions":
|
|
1492
|
+
return True
|
|
1493
|
+
return False
|
|
1494
|
+
|
|
1495
|
+
def _run_sessions():
|
|
1496
|
+
if SESSIONS_SCRIPT and os.path.exists(SESSIONS_SCRIPT):
|
|
1497
|
+
os.execvp("node", ["node", SESSIONS_SCRIPT])
|
|
1498
|
+
else:
|
|
1499
|
+
sys.stderr.write("[statusline] sessions script not found\\n")
|
|
1500
|
+
sys.exit(1)
|
|
1501
|
+
|
|
1502
|
+
# Handle --sessions command
|
|
1503
|
+
if _is_sessions_command(sys.argv[1:]):
|
|
1504
|
+
_run_sessions()
|
|
1505
|
+
|
|
1506
|
+
# Passthrough for non-interactive/meta commands (avoid clearing screen / PTY proxy)
|
|
1507
|
+
if (not sys.stdin.isatty()) or (not sys.stdout.isatty()) or _should_passthrough(sys.argv[1:]):
|
|
1508
|
+
_exec_passthrough()
|
|
1509
|
+
|
|
1367
1510
|
ANSI_RE = re.compile(r"\\x1b\\[[0-9;]*m")
|
|
1368
1511
|
RESET_SGR = "\\x1b[0m"
|
|
1369
1512
|
|
|
@@ -1975,9 +2118,6 @@ class CursorTracker:
|
|
|
1975
2118
|
|
|
1976
2119
|
|
|
1977
2120
|
def main():
|
|
1978
|
-
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
1979
|
-
os.execv(EXEC_TARGET, [EXEC_TARGET] + sys.argv[1:])
|
|
1980
|
-
|
|
1981
2121
|
# Start from a clean viewport. Droid's TUI assumes a fresh screen; without this,
|
|
1982
2122
|
# it can visually mix with prior shell output (especially when scrollback exists).
|
|
1983
2123
|
try:
|
|
@@ -2125,7 +2265,7 @@ def main():
|
|
|
2125
2265
|
)
|
|
2126
2266
|
# Also detect scroll region changes with parameters (DECSTBM pattern ESC[n;mr)
|
|
2127
2267
|
if b"\\x1b[" in detect_buf and b"r" in detect_buf:
|
|
2128
|
-
if re.search(b"\\x1b
|
|
2268
|
+
if re.search(b"\\x1b\\\\[[0-9]*;?[0-9]*r", detect_buf):
|
|
2129
2269
|
needs_scroll_region_reset = True
|
|
2130
2270
|
if needs_scroll_region_reset:
|
|
2131
2271
|
renderer.force_repaint(True)
|
|
@@ -2261,13 +2401,13 @@ if __name__ == "__main__":
|
|
|
2261
2401
|
main()
|
|
2262
2402
|
`;
|
|
2263
2403
|
}
|
|
2264
|
-
async function createStatuslineFiles(outputDir, execTargetPath, aliasName) {
|
|
2404
|
+
async function createStatuslineFiles(outputDir, execTargetPath, aliasName, sessionsScriptPath) {
|
|
2265
2405
|
if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true });
|
|
2266
2406
|
const monitorScriptPath = join(outputDir, `${aliasName}-statusline.js`);
|
|
2267
2407
|
const wrapperScriptPath = join(outputDir, aliasName);
|
|
2268
2408
|
await writeFile(monitorScriptPath, generateStatuslineMonitorScript());
|
|
2269
2409
|
await chmod(monitorScriptPath, 493);
|
|
2270
|
-
await writeFile(wrapperScriptPath, generateStatuslineWrapperScript(execTargetPath, monitorScriptPath));
|
|
2410
|
+
await writeFile(wrapperScriptPath, generateStatuslineWrapperScript(execTargetPath, monitorScriptPath, sessionsScriptPath));
|
|
2271
2411
|
await chmod(wrapperScriptPath, 493);
|
|
2272
2412
|
return {
|
|
2273
2413
|
wrapperScript: wrapperScriptPath,
|
|
@@ -2275,6 +2415,269 @@ async function createStatuslineFiles(outputDir, execTargetPath, aliasName) {
|
|
|
2275
2415
|
};
|
|
2276
2416
|
}
|
|
2277
2417
|
|
|
2418
|
+
//#endregion
|
|
2419
|
+
//#region src/sessions-patch.ts
|
|
2420
|
+
/**
|
|
2421
|
+
* Generate sessions browser script (Node.js)
|
|
2422
|
+
*/
|
|
2423
|
+
function generateSessionsBrowserScript(aliasName) {
|
|
2424
|
+
return `#!/usr/bin/env node
|
|
2425
|
+
// Droid Sessions Browser - Interactive selector
|
|
2426
|
+
// Auto-generated by droid-patch
|
|
2427
|
+
|
|
2428
|
+
const fs = require('fs');
|
|
2429
|
+
const path = require('path');
|
|
2430
|
+
const readline = require('readline');
|
|
2431
|
+
const { execSync, spawn } = require('child_process');
|
|
2432
|
+
|
|
2433
|
+
const FACTORY_HOME = path.join(require('os').homedir(), '.factory');
|
|
2434
|
+
const SESSIONS_ROOT = path.join(FACTORY_HOME, 'sessions');
|
|
2435
|
+
const ALIAS_NAME = ${JSON.stringify(aliasName)};
|
|
2436
|
+
|
|
2437
|
+
// ANSI
|
|
2438
|
+
const CYAN = '\\x1b[36m';
|
|
2439
|
+
const GREEN = '\\x1b[32m';
|
|
2440
|
+
const YELLOW = '\\x1b[33m';
|
|
2441
|
+
const RED = '\\x1b[31m';
|
|
2442
|
+
const DIM = '\\x1b[2m';
|
|
2443
|
+
const RESET = '\\x1b[0m';
|
|
2444
|
+
const BOLD = '\\x1b[1m';
|
|
2445
|
+
const CLEAR = '\\x1b[2J\\x1b[H';
|
|
2446
|
+
const HIDE_CURSOR = '\\x1b[?25l';
|
|
2447
|
+
const SHOW_CURSOR = '\\x1b[?25h';
|
|
2448
|
+
|
|
2449
|
+
function sanitizePath(p) {
|
|
2450
|
+
return p.replace(/:/g, '').replace(/[\\\\/]/g, '-');
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
function parseSessionFile(jsonlPath, settingsPath) {
|
|
2454
|
+
const sessionId = path.basename(jsonlPath, '.jsonl');
|
|
2455
|
+
const stats = fs.statSync(jsonlPath);
|
|
2456
|
+
|
|
2457
|
+
const result = {
|
|
2458
|
+
id: sessionId,
|
|
2459
|
+
title: '',
|
|
2460
|
+
mtime: stats.mtimeMs,
|
|
2461
|
+
model: '',
|
|
2462
|
+
firstUserMsg: '',
|
|
2463
|
+
lastUserMsg: '',
|
|
2464
|
+
messageCount: 0,
|
|
2465
|
+
lastTimestamp: '',
|
|
2466
|
+
};
|
|
2467
|
+
|
|
2468
|
+
try {
|
|
2469
|
+
const content = fs.readFileSync(jsonlPath, 'utf-8');
|
|
2470
|
+
const lines = content.split('\\n').filter(l => l.trim());
|
|
2471
|
+
const userMessages = [];
|
|
2472
|
+
|
|
2473
|
+
for (const line of lines) {
|
|
2474
|
+
try {
|
|
2475
|
+
const obj = JSON.parse(line);
|
|
2476
|
+
if (obj.type === 'session_start') {
|
|
2477
|
+
result.title = obj.title || '';
|
|
2478
|
+
} else if (obj.type === 'message') {
|
|
2479
|
+
result.messageCount++;
|
|
2480
|
+
if (obj.timestamp) result.lastTimestamp = obj.timestamp;
|
|
2481
|
+
|
|
2482
|
+
const msg = obj.message || {};
|
|
2483
|
+
if (msg.role === 'user' && Array.isArray(msg.content)) {
|
|
2484
|
+
for (const c of msg.content) {
|
|
2485
|
+
if (c && c.type === 'text' && c.text && !c.text.startsWith('<system-reminder>')) {
|
|
2486
|
+
userMessages.push(c.text.slice(0, 150).replace(/\\n/g, ' ').trim());
|
|
2487
|
+
break;
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
} catch {}
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
if (userMessages.length > 0) {
|
|
2496
|
+
result.firstUserMsg = userMessages[0];
|
|
2497
|
+
result.lastUserMsg = userMessages.length > 1 ? userMessages[userMessages.length - 1] : '';
|
|
2498
|
+
}
|
|
2499
|
+
} catch {}
|
|
2500
|
+
|
|
2501
|
+
if (fs.existsSync(settingsPath)) {
|
|
2502
|
+
try {
|
|
2503
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
2504
|
+
result.model = settings.model || '';
|
|
2505
|
+
} catch {}
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
return result;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
function collectSessions() {
|
|
2512
|
+
const cwd = process.cwd();
|
|
2513
|
+
const cwdSanitized = sanitizePath(cwd);
|
|
2514
|
+
const sessions = [];
|
|
2515
|
+
|
|
2516
|
+
if (!fs.existsSync(SESSIONS_ROOT)) return sessions;
|
|
2517
|
+
|
|
2518
|
+
for (const wsDir of fs.readdirSync(SESSIONS_ROOT)) {
|
|
2519
|
+
if (wsDir !== cwdSanitized) continue;
|
|
2520
|
+
|
|
2521
|
+
const wsPath = path.join(SESSIONS_ROOT, wsDir);
|
|
2522
|
+
if (!fs.statSync(wsPath).isDirectory()) continue;
|
|
2523
|
+
|
|
2524
|
+
for (const file of fs.readdirSync(wsPath)) {
|
|
2525
|
+
if (!file.endsWith('.jsonl')) continue;
|
|
2526
|
+
|
|
2527
|
+
const sessionId = file.slice(0, -6);
|
|
2528
|
+
const jsonlPath = path.join(wsPath, file);
|
|
2529
|
+
const settingsPath = path.join(wsPath, sessionId + '.settings.json');
|
|
2530
|
+
|
|
2531
|
+
try {
|
|
2532
|
+
const session = parseSessionFile(jsonlPath, settingsPath);
|
|
2533
|
+
if (session.messageCount === 0 || !session.firstUserMsg) continue;
|
|
2534
|
+
sessions.push(session);
|
|
2535
|
+
} catch {}
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
sessions.sort((a, b) => b.mtime - a.mtime);
|
|
2540
|
+
return sessions.slice(0, 50);
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
function formatTime(ts) {
|
|
2544
|
+
if (!ts) return '';
|
|
2545
|
+
try {
|
|
2546
|
+
const d = new Date(ts);
|
|
2547
|
+
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
|
2548
|
+
} catch {
|
|
2549
|
+
return ts.slice(0, 16);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
function truncate(s, len) {
|
|
2554
|
+
if (!s) return '';
|
|
2555
|
+
s = s.replace(/\\n/g, ' ');
|
|
2556
|
+
return s.length > len ? s.slice(0, len - 3) + '...' : s;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
function render(sessions, selected, offset, rows) {
|
|
2560
|
+
const cwd = process.cwd();
|
|
2561
|
+
const pageSize = rows - 6;
|
|
2562
|
+
const visible = sessions.slice(offset, offset + pageSize);
|
|
2563
|
+
|
|
2564
|
+
let out = CLEAR;
|
|
2565
|
+
out += BOLD + 'Sessions: ' + RESET + DIM + cwd + RESET + '\\n';
|
|
2566
|
+
out += DIM + '[↑/↓] Select [Enter] Resume [q] Quit' + RESET + '\\n\\n';
|
|
2567
|
+
|
|
2568
|
+
for (let i = 0; i < visible.length; i++) {
|
|
2569
|
+
const s = visible[i];
|
|
2570
|
+
const idx = offset + i;
|
|
2571
|
+
const isSelected = idx === selected;
|
|
2572
|
+
const prefix = isSelected ? GREEN + '▶ ' + RESET : ' ';
|
|
2573
|
+
|
|
2574
|
+
const title = truncate(s.title || '(no title)', 35);
|
|
2575
|
+
const time = formatTime(s.lastTimestamp);
|
|
2576
|
+
const model = truncate(s.model, 20);
|
|
2577
|
+
|
|
2578
|
+
if (isSelected) {
|
|
2579
|
+
out += prefix + YELLOW + title + RESET + '\\n';
|
|
2580
|
+
out += ' ' + DIM + 'ID: ' + RESET + CYAN + s.id + RESET + '\\n';
|
|
2581
|
+
out += ' ' + DIM + 'Last: ' + time + ' | Model: ' + model + ' | ' + s.messageCount + ' msgs' + RESET + '\\n';
|
|
2582
|
+
out += ' ' + DIM + 'First input: ' + RESET + truncate(s.firstUserMsg, 60) + '\\n';
|
|
2583
|
+
if (s.lastUserMsg && s.lastUserMsg !== s.firstUserMsg) {
|
|
2584
|
+
out += ' ' + DIM + 'Last input: ' + RESET + truncate(s.lastUserMsg, 60) + '\\n';
|
|
2585
|
+
}
|
|
2586
|
+
} else {
|
|
2587
|
+
out += prefix + title + DIM + ' (' + time + ')' + RESET + '\\n';
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
out += '\\n' + DIM + 'Page ' + (Math.floor(offset / pageSize) + 1) + '/' + Math.ceil(sessions.length / pageSize) + ' (' + sessions.length + ' sessions)' + RESET;
|
|
2592
|
+
|
|
2593
|
+
process.stdout.write(out);
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
async function main() {
|
|
2597
|
+
const sessions = collectSessions();
|
|
2598
|
+
|
|
2599
|
+
if (sessions.length === 0) {
|
|
2600
|
+
console.log(RED + 'No sessions with interactions found in current directory' + RESET);
|
|
2601
|
+
process.exit(0);
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
if (!process.stdin.isTTY) {
|
|
2605
|
+
for (const s of sessions) {
|
|
2606
|
+
console.log(s.id + ' ' + (s.title || '') + ' ' + formatTime(s.lastTimestamp));
|
|
2607
|
+
}
|
|
2608
|
+
process.exit(0);
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
const rows = process.stdout.rows || 24;
|
|
2612
|
+
const pageSize = rows - 6;
|
|
2613
|
+
let selected = 0;
|
|
2614
|
+
let offset = 0;
|
|
2615
|
+
|
|
2616
|
+
process.stdin.setRawMode(true);
|
|
2617
|
+
process.stdin.resume();
|
|
2618
|
+
process.stdout.write(HIDE_CURSOR);
|
|
2619
|
+
|
|
2620
|
+
render(sessions, selected, offset, rows);
|
|
2621
|
+
|
|
2622
|
+
process.stdin.on('data', (key) => {
|
|
2623
|
+
const k = key.toString();
|
|
2624
|
+
|
|
2625
|
+
if (k === 'q' || k === '\\x03') { // q or Ctrl+C
|
|
2626
|
+
process.stdout.write(SHOW_CURSOR + CLEAR);
|
|
2627
|
+
process.exit(0);
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
if (k === '\\r' || k === '\\n') { // Enter
|
|
2631
|
+
process.stdout.write(SHOW_CURSOR + CLEAR);
|
|
2632
|
+
const session = sessions[selected];
|
|
2633
|
+
console.log(GREEN + 'Resuming session: ' + session.id + RESET);
|
|
2634
|
+
console.log(DIM + 'Using: ' + ALIAS_NAME + ' --resume ' + session.id + RESET + '\\n');
|
|
2635
|
+
const child = spawn(ALIAS_NAME, ['--resume', session.id], { stdio: 'inherit' });
|
|
2636
|
+
child.on('exit', (code) => process.exit(code || 0));
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
if (k === '\\x1b[A' || k === 'k') { // Up
|
|
2641
|
+
if (selected > 0) {
|
|
2642
|
+
selected--;
|
|
2643
|
+
if (selected < offset) offset = Math.max(0, offset - 1);
|
|
2644
|
+
}
|
|
2645
|
+
} else if (k === '\\x1b[B' || k === 'j') { // Down
|
|
2646
|
+
if (selected < sessions.length - 1) {
|
|
2647
|
+
selected++;
|
|
2648
|
+
if (selected >= offset + pageSize) offset++;
|
|
2649
|
+
}
|
|
2650
|
+
} else if (k === '\\x1b[5~') { // Page Up
|
|
2651
|
+
selected = Math.max(0, selected - pageSize);
|
|
2652
|
+
offset = Math.max(0, offset - pageSize);
|
|
2653
|
+
} else if (k === '\\x1b[6~') { // Page Down
|
|
2654
|
+
selected = Math.min(sessions.length - 1, selected + pageSize);
|
|
2655
|
+
offset = Math.min(Math.max(0, sessions.length - pageSize), offset + pageSize);
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
render(sessions, selected, offset, rows);
|
|
2659
|
+
});
|
|
2660
|
+
|
|
2661
|
+
process.on('SIGINT', () => {
|
|
2662
|
+
process.stdout.write(SHOW_CURSOR + CLEAR);
|
|
2663
|
+
process.exit(0);
|
|
2664
|
+
});
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
main();
|
|
2668
|
+
`;
|
|
2669
|
+
}
|
|
2670
|
+
/**
|
|
2671
|
+
* Create sessions browser script file
|
|
2672
|
+
*/
|
|
2673
|
+
async function createSessionsScript(outputDir, aliasName) {
|
|
2674
|
+
if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true });
|
|
2675
|
+
const sessionsScriptPath = join(outputDir, `${aliasName}-sessions.js`);
|
|
2676
|
+
await writeFile(sessionsScriptPath, generateSessionsBrowserScript(aliasName));
|
|
2677
|
+
await chmod(sessionsScriptPath, 493);
|
|
2678
|
+
return { sessionsScript: sessionsScriptPath };
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2278
2681
|
//#endregion
|
|
2279
2682
|
//#region src/cli.ts
|
|
2280
2683
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -2327,13 +2730,14 @@ function findDefaultDroidPath() {
|
|
|
2327
2730
|
for (const p of paths) if (existsSync(p)) return p;
|
|
2328
2731
|
return join(home, ".droid", "bin", "droid");
|
|
2329
2732
|
}
|
|
2330
|
-
bin("droid-patch", "CLI tool to patch droid binary with various modifications").package("droid-patch", version).option("--is-custom", "Patch isCustom:!0 to isCustom:!1 (enable context compression for custom models)").option("--skip-login", "Inject a fake FACTORY_API_KEY to bypass login requirement (no real key needed)").option("--api-base <url>", "Replace API URL (standalone: binary patch, max 22 chars; with --websearch: proxy forward target, no limit)").option("--websearch", "Enable local WebSearch proxy (each instance runs own proxy, auto-cleanup on exit)").option("--statusline", "Enable a Claude-style statusline (terminal UI)").option("--standalone", "Standalone mode: mock non-LLM Factory APIs (use with --websearch)").option("--reasoning-effort", "Enable reasoning effort for custom models (set to high, enable UI selector)").option("--disable-telemetry", "Disable telemetry and Sentry error reporting (block data uploads)").option("--dry-run", "Verify patches without actually modifying the binary").option("-p, --path <path>", "Path to the droid binary").option("-o, --output <dir>", "Output directory for patched binary").option("--no-backup", "Do not create backup of original binary").option("-v, --verbose", "Enable verbose output").argument("[alias]", "Alias name for the patched binary").action(async (options, args) => {
|
|
2733
|
+
bin("droid-patch", "CLI tool to patch droid binary with various modifications").package("droid-patch", version).option("--is-custom", "Patch isCustom:!0 to isCustom:!1 (enable context compression for custom models)").option("--skip-login", "Inject a fake FACTORY_API_KEY to bypass login requirement (no real key needed)").option("--api-base <url>", "Replace API URL (standalone: binary patch, max 22 chars; with --websearch: proxy forward target, no limit)").option("--websearch", "Enable local WebSearch proxy (each instance runs own proxy, auto-cleanup on exit)").option("--statusline", "Enable a Claude-style statusline (terminal UI)").option("--sessions", "Enable sessions browser (--sessions flag in alias)").option("--standalone", "Standalone mode: mock non-LLM Factory APIs (use with --websearch)").option("--reasoning-effort", "Enable reasoning effort for custom models (set to high, enable UI selector)").option("--disable-telemetry", "Disable telemetry and Sentry error reporting (block data uploads)").option("--dry-run", "Verify patches without actually modifying the binary").option("-p, --path <path>", "Path to the droid binary").option("-o, --output <dir>", "Output directory for patched binary").option("--no-backup", "Do not create backup of original binary").option("-v, --verbose", "Enable verbose output").argument("[alias]", "Alias name for the patched binary").action(async (options, args) => {
|
|
2331
2734
|
const alias = args?.[0];
|
|
2332
2735
|
const isCustom = options["is-custom"];
|
|
2333
2736
|
const skipLogin = options["skip-login"];
|
|
2334
2737
|
const apiBase = options["api-base"];
|
|
2335
2738
|
const websearch = options["websearch"];
|
|
2336
2739
|
const statusline = options["statusline"];
|
|
2740
|
+
const sessions = options["sessions"];
|
|
2337
2741
|
const standalone = options["standalone"];
|
|
2338
2742
|
const websearchTarget = websearch ? apiBase || "https://api.factory.ai" : void 0;
|
|
2339
2743
|
const reasoningEffort = options["reasoning-effort"];
|
|
@@ -2368,7 +2772,10 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
|
|
|
2368
2772
|
execTargetPath = wrapperScript;
|
|
2369
2773
|
}
|
|
2370
2774
|
if (statusline) {
|
|
2371
|
-
const
|
|
2775
|
+
const statuslineDir = join(homedir(), ".droid-patch", "statusline");
|
|
2776
|
+
let sessionsScript;
|
|
2777
|
+
if (sessions) sessionsScript = (await createSessionsScript(statuslineDir, alias)).sessionsScript;
|
|
2778
|
+
const { wrapperScript } = await createStatuslineFiles(statuslineDir, execTargetPath, alias, sessionsScript);
|
|
2372
2779
|
execTargetPath = wrapperScript;
|
|
2373
2780
|
}
|
|
2374
2781
|
const aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
|
|
@@ -2379,6 +2786,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
|
|
|
2379
2786
|
apiBase: apiBase || null,
|
|
2380
2787
|
websearch: !!websearch,
|
|
2381
2788
|
statusline: !!statusline,
|
|
2789
|
+
sessions: !!sessions,
|
|
2382
2790
|
reasoningEffort: false,
|
|
2383
2791
|
noTelemetry: false,
|
|
2384
2792
|
standalone
|
|
@@ -2574,7 +2982,10 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
|
|
|
2574
2982
|
if (standalone) console.log(styleText("white", ` Standalone mode: enabled`));
|
|
2575
2983
|
}
|
|
2576
2984
|
if (statusline) {
|
|
2577
|
-
const
|
|
2985
|
+
const statuslineDir = join(homedir(), ".droid-patch", "statusline");
|
|
2986
|
+
let sessionsScript;
|
|
2987
|
+
if (sessions) sessionsScript = (await createSessionsScript(statuslineDir, alias)).sessionsScript;
|
|
2988
|
+
const { wrapperScript } = await createStatuslineFiles(statuslineDir, execTargetPath, alias, sessionsScript);
|
|
2578
2989
|
execTargetPath = wrapperScript;
|
|
2579
2990
|
console.log();
|
|
2580
2991
|
console.log(styleText("cyan", "Statusline enabled"));
|
|
@@ -2589,6 +3000,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
|
|
|
2589
3000
|
apiBase: apiBase || null,
|
|
2590
3001
|
websearch: !!websearch,
|
|
2591
3002
|
statusline: !!statusline,
|
|
3003
|
+
sessions: !!sessions,
|
|
2592
3004
|
reasoningEffort: !!reasoningEffort,
|
|
2593
3005
|
noTelemetry: !!noTelemetry,
|
|
2594
3006
|
standalone: !!standalone
|
|
@@ -2805,7 +3217,10 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
|
|
|
2805
3217
|
}
|
|
2806
3218
|
}
|
|
2807
3219
|
if (meta.patches.statusline) {
|
|
2808
|
-
const
|
|
3220
|
+
const statuslineDir = join(homedir(), ".droid-patch", "statusline");
|
|
3221
|
+
let sessionsScript;
|
|
3222
|
+
if (meta.patches.sessions) sessionsScript = (await createSessionsScript(statuslineDir, meta.name)).sessionsScript;
|
|
3223
|
+
const { wrapperScript } = await createStatuslineFiles(statuslineDir, execTargetPath, meta.name, sessionsScript);
|
|
2809
3224
|
execTargetPath = wrapperScript;
|
|
2810
3225
|
if (verbose) console.log(styleText("gray", ` Regenerated statusline wrapper`));
|
|
2811
3226
|
}
|