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/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 < 80; 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
- const byProc = await resolveSessionFromProcessGroup();
1117
- if (byProc?.id) {
1118
- sessionId = byProc.id;
1119
- workspaceDir = byProc.workspaceDir;
1120
- } else if (resumeFlag) {
1121
- const latest = pickLatestSessionAcross(workspaceDirs);
1122
- sessionId = latest?.id || null;
1123
- workspaceDir = latest?.workspaceDir || workspaceDirs[0] || null;
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 fresh = await waitForNewSessionAcross(workspaceDirs, knownIdsByWorkspace, START_MS);
1126
- if (fresh) {
1127
- sessionId = fresh.id;
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
- const gitBranch = resolveGitBranch(cwd);
1152
- const gitDiff = resolveGitDiffSummary(cwd);
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
- // Seed prompt-context usage from existing logs (important for resumed sessions and early calls).
1176
- // This avoids showing "Ctx: 0" until the next streaming event arrives.
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
- const fd = fs.openSync(LOG_PATH, 'r');
1251
+ // Resolve git info asynchronously so startup isn't blocked on large repos.
1252
+ setTimeout(() => {
1185
1253
  try {
1186
- const stat = fs.fstatSync(fd);
1187
- const size = Number(stat?.size ?? 0);
1188
- let pos = size;
1189
- let scanned = 0;
1190
- let remainder = '';
1191
- let seeded = false;
1192
-
1193
- while (pos > 0 && scanned < MAX_SCAN_BYTES && !seeded) {
1194
- const readSize = Math.min(CHUNK_BYTES, pos);
1195
- const start = pos - readSize;
1196
- const buf = Buffer.alloc(readSize);
1197
- fs.readSync(fd, buf, 0, readSize, start);
1198
- pos = start;
1199
- scanned += readSize;
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
- for (let i = lines.length - 1; i >= 0; i--) {
1210
- const line = String(lines[i] || '').trimEnd();
1211
- if (!line) continue;
1212
- if (!line.includes('Context:')) continue;
1213
- if (!line.includes('"sessionId":"' + sessionId + '"')) continue;
1214
- if (!line.includes('[Agent] Streaming result')) continue;
1215
- const ctxIndex = line.indexOf('Context: ');
1216
- if (ctxIndex === -1) continue;
1217
- const jsonStr = line.slice(ctxIndex + 'Context: '.length).trim();
1218
- let ctx;
1219
- try {
1220
- ctx = JSON.parse(jsonStr);
1221
- } catch {
1222
- continue;
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
- const cacheRead = Number(ctx?.cacheReadInputTokens ?? 0);
1225
- const contextCount = Number(ctx?.contextCount ?? 0);
1226
- const out = Number(ctx?.outputTokens ?? 0);
1227
- if (Number.isFinite(cacheRead)) last.cacheReadInputTokens = cacheRead;
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
- if (remainder.length > 8192) remainder = remainder.slice(-8192);
1328
+ renderNow();
1329
+ } catch {
1330
+ // ignore
1235
1331
  }
1236
- } finally {
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\\[\\d*;?\\d*r", detect_buf):
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 { wrapperScript } = await createStatuslineFiles(join(homedir(), ".droid-patch", "statusline"), execTargetPath, alias);
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 { wrapperScript } = await createStatuslineFiles(join(homedir(), ".droid-patch", "statusline"), execTargetPath, alias);
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 { wrapperScript } = await createStatuslineFiles(join(homedir(), ".droid-patch", "statusline"), execTargetPath, meta.name);
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
  }