droid-patch 0.6.1 → 0.7.1

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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as removeAlias, d as listAllMetadata, f as loadAliasMetadata, i as listAliases, l as createMetadata, m as patchDroid, n as createAlias, o as removeAliasesByFilter, p as saveAliasMetadata, r as createAliasForWrapper, t as clearAllAliases, u as formatPatches } from "./alias-DKVU8DM_.mjs";
2
+ import { a as removeAlias, d as listAllMetadata, f as loadAliasMetadata, i as listAliases, l as createMetadata, m as patchDroid, n as createAlias, o as removeAliasesByFilter, p as saveAliasMetadata, r as createAliasForWrapper, t as clearAllAliases, u as formatPatches } from "./alias-Bhigcbue.mjs";
3
3
  import bin from "tiny-bin";
4
4
  import { styleText } from "node:util";
5
5
  import { existsSync, readFileSync } from "node:fs";
@@ -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
@@ -623,6 +662,1739 @@ async function createWebSearchUnifiedFiles(outputDir, droidPath, aliasName, apiB
623
662
  };
624
663
  }
625
664
 
665
+ //#endregion
666
+ //#region src/statusline-patch.ts
667
+ function generateStatuslineMonitorScript() {
668
+ return `#!/usr/bin/env node
669
+ /* Auto-generated by droid-patch --statusline */
670
+
671
+ const fs = require('fs');
672
+ const os = require('os');
673
+ const path = require('path');
674
+ const { spawn, spawnSync } = require('child_process');
675
+
676
+ // This monitor does NOT draw directly to the terminal. It emits newline-delimited
677
+ // statusline frames to stdout. A wrapper (PTY proxy) is responsible for rendering
678
+ // the latest frame on a reserved bottom row to avoid flicker.
679
+
680
+ const FACTORY_HOME = path.join(os.homedir(), '.factory');
681
+
682
+ const SESSIONS_ROOT = path.join(FACTORY_HOME, 'sessions');
683
+ const LOG_PATH = path.join(FACTORY_HOME, 'logs', 'droid-log-single.log');
684
+ const CONFIG_PATH = path.join(FACTORY_HOME, 'config.json');
685
+ const GLOBAL_SETTINGS_PATH = path.join(FACTORY_HOME, 'settings.json');
686
+
687
+ const IS_APPLE_TERMINAL = process.env.TERM_PROGRAM === 'Apple_Terminal';
688
+ const MIN_RENDER_INTERVAL_MS = IS_APPLE_TERMINAL ? 1000 : 500;
689
+
690
+ const START_MS = Date.now();
691
+ const ARGS = process.argv.slice(2);
692
+ const PGID = Number(process.env.DROID_STATUSLINE_PGID || '');
693
+
694
+ function sleep(ms) {
695
+ return new Promise((r) => setTimeout(r, ms));
696
+ }
697
+
698
+ function isPositiveInt(n) {
699
+ return Number.isFinite(n) && n > 0;
700
+ }
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
+
729
+ function listPidsInProcessGroup(pgid) {
730
+ if (!isPositiveInt(pgid)) return [];
731
+ try {
732
+ const res = spawnSync('ps', ['-ax', '-o', 'pid=,pgid='], {
733
+ encoding: 'utf8',
734
+ stdio: ['ignore', 'pipe', 'ignore'],
735
+ timeout: 800,
736
+ });
737
+ if (!res || res.status !== 0) return [];
738
+ const out = String(res.stdout || '');
739
+ const pids = [];
740
+ for (const line of out.split('\\n')) {
741
+ const parts = line.trim().split(/\\s+/);
742
+ if (parts.length < 2) continue;
743
+ const pid = Number(parts[0]);
744
+ const g = Number(parts[1]);
745
+ if (Number.isFinite(pid) && g === pgid) pids.push(pid);
746
+ }
747
+ return pids;
748
+ } catch {
749
+ return [];
750
+ }
751
+ }
752
+
753
+ function resolveOpenSessionFromPids(pids) {
754
+ if (!Array.isArray(pids) || pids.length === 0) return null;
755
+ // lsof prints file names as lines prefixed with "n" when using -Fn
756
+ try {
757
+ const res = spawnSync('lsof', ['-p', pids.join(','), '-Fn'], {
758
+ encoding: 'utf8',
759
+ stdio: ['ignore', 'pipe', 'ignore'],
760
+ timeout: 1200,
761
+ });
762
+ if (!res || res.status !== 0) return null;
763
+ const out = String(res.stdout || '');
764
+ for (const line of out.split('\\n')) {
765
+ if (!line || line[0] !== 'n') continue;
766
+ const name = line.slice(1);
767
+ if (!name.startsWith(SESSIONS_ROOT + path.sep)) continue;
768
+ const m = name.match(/([0-9a-f-]{36})\\.(jsonl|settings\\.json)$/i);
769
+ if (!m) continue;
770
+ const id = m[1];
771
+ const workspaceDir = path.dirname(name);
772
+ if (path.dirname(workspaceDir) !== SESSIONS_ROOT) continue;
773
+ return { workspaceDir, id };
774
+ }
775
+ } catch {
776
+ return null;
777
+ }
778
+ return null;
779
+ }
780
+
781
+ async function resolveSessionFromProcessGroup(shouldAbort, maxTries = 20) {
782
+ if (!isPositiveInt(PGID)) return null;
783
+ // Wait a little for droid to create/open the session files.
784
+ for (let i = 0; i < maxTries; i++) {
785
+ if (shouldAbort && shouldAbort()) return null;
786
+ const pids = listPidsInProcessGroup(PGID);
787
+ const found = resolveOpenSessionFromPids(pids);
788
+ if (found) return found;
789
+ await sleep(100);
790
+ }
791
+ return null;
792
+ }
793
+
794
+ function safeReadFile(filePath) {
795
+ try {
796
+ return fs.readFileSync(filePath, 'utf8');
797
+ } catch {
798
+ return null;
799
+ }
800
+ }
801
+
802
+ function safeJsonParse(text) {
803
+ if (!text) return null;
804
+ try {
805
+ // Factory settings/config files can contain comments. Strip them safely without
806
+ // breaking URLs like "http://..." which contain "//" inside strings.
807
+ const stripComments = (input) => {
808
+ let out = '';
809
+ let inString = false;
810
+ let escape = false;
811
+ for (let i = 0; i < input.length; i++) {
812
+ const ch = input[i];
813
+ const next = input[i + 1];
814
+
815
+ if (inString) {
816
+ out += ch;
817
+ if (escape) {
818
+ escape = false;
819
+ continue;
820
+ }
821
+ if (ch === '\\\\') {
822
+ escape = true;
823
+ continue;
824
+ }
825
+ if (ch === '"') {
826
+ inString = false;
827
+ }
828
+ continue;
829
+ }
830
+
831
+ if (ch === '"') {
832
+ inString = true;
833
+ out += ch;
834
+ continue;
835
+ }
836
+
837
+ // Line comment
838
+ if (ch === '/' && next === '/') {
839
+ while (i < input.length && input[i] !== '\\n') i++;
840
+ out += '\\n';
841
+ continue;
842
+ }
843
+
844
+ // Block comment
845
+ if (ch === '/' && next === '*') {
846
+ i += 2;
847
+ while (i < input.length && !(input[i] === '*' && input[i + 1] === '/')) i++;
848
+ i += 1;
849
+ continue;
850
+ }
851
+
852
+ out += ch;
853
+ }
854
+ return out;
855
+ };
856
+
857
+ return JSON.parse(stripComments(text));
858
+ } catch {
859
+ return null;
860
+ }
861
+ }
862
+
863
+ function readJsonFile(filePath) {
864
+ return safeJsonParse(safeReadFile(filePath));
865
+ }
866
+
867
+ function isUuid(text) {
868
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text);
869
+ }
870
+
871
+ function parseResume(args) {
872
+ for (let i = 0; i < args.length; i++) {
873
+ const a = args[i];
874
+ if (a === '-r' || a === '--resume') {
875
+ const next = args[i + 1];
876
+ if (next && isUuid(next)) return { resumeFlag: true, resumeId: next };
877
+ return { resumeFlag: true, resumeId: null };
878
+ }
879
+ if (a.startsWith('--resume=')) {
880
+ const value = a.slice('--resume='.length);
881
+ return { resumeFlag: true, resumeId: isUuid(value) ? value : null };
882
+ }
883
+ }
884
+ return { resumeFlag: false, resumeId: null };
885
+ }
886
+
887
+ function sanitizeWorkspaceDirName(cwd) {
888
+ return String(cwd)
889
+ .replace(/[:]/g, '')
890
+ .replace(/[\\\\/]/g, '-')
891
+ .replace(/\\s+/g, '-');
892
+ }
893
+
894
+ function listSessionCandidates(workspaceDir) {
895
+ let files = [];
896
+ try {
897
+ files = fs.readdirSync(workspaceDir);
898
+ } catch {
899
+ return [];
900
+ }
901
+ const candidates = [];
902
+ for (const file of files) {
903
+ const m = file.match(/^([0-9a-f-]{36})\\.(jsonl|settings\\.json)$/i);
904
+ if (!m) continue;
905
+ const id = m[1];
906
+ const fullPath = path.join(workspaceDir, file);
907
+ try {
908
+ const stat = fs.statSync(fullPath);
909
+ candidates.push({ id, fullPath, mtimeMs: stat.mtimeMs });
910
+ } catch {
911
+ // ignore
912
+ }
913
+ }
914
+ return candidates;
915
+ }
916
+
917
+ function findWorkspaceDirForSessionId(workspaceDirs, sessionId) {
918
+ for (const dir of workspaceDirs) {
919
+ try {
920
+ const settingsPath = path.join(dir, sessionId + '.settings.json');
921
+ if (fs.existsSync(settingsPath)) return dir;
922
+ } catch {
923
+ // ignore
924
+ }
925
+ }
926
+ return null;
927
+ }
928
+
929
+ function pickLatestSessionAcross(workspaceDirs) {
930
+ let best = null;
931
+ for (const dir of workspaceDirs) {
932
+ const candidates = listSessionCandidates(dir);
933
+ for (const c of candidates) {
934
+ if (!best || c.mtimeMs > best.mtimeMs) {
935
+ best = { workspaceDir: dir, id: c.id, mtimeMs: c.mtimeMs };
936
+ }
937
+ }
938
+ }
939
+ return best ? { workspaceDir: best.workspaceDir, id: best.id } : null;
940
+ }
941
+
942
+ async function waitForNewSessionAcross(workspaceDirs, knownIdsByWorkspace, startMs, shouldAbort) {
943
+ for (let i = 0; i < 80; i++) {
944
+ if (shouldAbort && shouldAbort()) return null;
945
+ let best = null;
946
+ for (const dir of workspaceDirs) {
947
+ const known = knownIdsByWorkspace.get(dir) || new Set();
948
+ const candidates = listSessionCandidates(dir);
949
+ for (const c of candidates) {
950
+ if (!(c.mtimeMs >= startMs - 50 || !known.has(c.id))) continue;
951
+ if (!best || c.mtimeMs > best.mtimeMs) {
952
+ best = { workspaceDir: dir, id: c.id, mtimeMs: c.mtimeMs };
953
+ }
954
+ }
955
+ }
956
+ if (best?.id) return { workspaceDir: best.workspaceDir, id: best.id };
957
+ await sleep(100);
958
+ }
959
+ return null;
960
+ }
961
+
962
+ function safeRealpath(p) {
963
+ try {
964
+ return fs.realpathSync(p);
965
+ } catch {
966
+ return null;
967
+ }
968
+ }
969
+
970
+ function resolveWorkspaceDirs(cwd) {
971
+ const logical = cwd;
972
+ const real = safeRealpath(cwd);
973
+ const dirs = [];
974
+ for (const value of [logical, real]) {
975
+ if (!value || typeof value !== 'string') continue;
976
+ dirs.push(path.join(SESSIONS_ROOT, sanitizeWorkspaceDirName(value)));
977
+ }
978
+ return Array.from(new Set(dirs));
979
+ }
980
+
981
+ function resolveSessionSettings(workspaceDir, sessionId) {
982
+ const settingsPath = path.join(workspaceDir, sessionId + '.settings.json');
983
+ const settings = readJsonFile(settingsPath) || {};
984
+ return { settingsPath, settings };
985
+ }
986
+
987
+ function resolveGlobalSettingsModel() {
988
+ const global = readJsonFile(GLOBAL_SETTINGS_PATH);
989
+ return global && typeof global.model === 'string' ? global.model : null;
990
+ }
991
+
992
+ function resolveCustomModelIndex(modelId) {
993
+ if (typeof modelId !== 'string') return null;
994
+ if (!modelId.startsWith('custom:')) return null;
995
+ const m = modelId.match(/-(\\d+)$/);
996
+ if (!m) return null;
997
+ const idx = Number(m[1]);
998
+ return Number.isFinite(idx) ? idx : null;
999
+ }
1000
+
1001
+ function resolveUnderlyingModelId(modelId, factoryConfig) {
1002
+ const idx = resolveCustomModelIndex(modelId);
1003
+ if (idx == null) return modelId;
1004
+ const entry = factoryConfig?.custom_models?.[idx];
1005
+ if (entry && typeof entry.model === 'string') return entry.model;
1006
+ return modelId;
1007
+ }
1008
+
1009
+ function resolveProvider(modelId, factoryConfig) {
1010
+ const idx = resolveCustomModelIndex(modelId);
1011
+ if (idx != null) {
1012
+ const entry = factoryConfig?.custom_models?.[idx];
1013
+ if (entry && typeof entry.provider === 'string') return entry.provider;
1014
+ }
1015
+ if (typeof modelId === 'string' && modelId.startsWith('claude-')) return 'anthropic';
1016
+ return '';
1017
+ }
1018
+
1019
+ function formatInt(n) {
1020
+ if (!Number.isFinite(n)) return '0';
1021
+ return Math.round(n).toString();
1022
+ }
1023
+
1024
+ function formatTokens(n) {
1025
+ if (!Number.isFinite(n)) return '0';
1026
+ const sign = n < 0 ? '-' : '';
1027
+ const abs = Math.abs(n);
1028
+ if (abs >= 1_000_000) {
1029
+ const v = abs / 1_000_000;
1030
+ const s = v >= 10 ? v.toFixed(0) : v.toFixed(1);
1031
+ return sign + s.replace(/\\.0$/, '') + 'M';
1032
+ }
1033
+ if (abs >= 10_000) {
1034
+ const v = abs / 1_000;
1035
+ const s = v >= 100 ? v.toFixed(0) : v.toFixed(1);
1036
+ return sign + s.replace(/\\.0$/, '') + 'k';
1037
+ }
1038
+ return sign + Math.round(abs).toString();
1039
+ }
1040
+
1041
+ function emitFrame(line) {
1042
+ try {
1043
+ process.stdout.write(String(line || '') + '\\n');
1044
+ } catch {
1045
+ // ignore
1046
+ }
1047
+ }
1048
+
1049
+ function seg(bg, fg, text) {
1050
+ if (!text) return '';
1051
+ return '\\x1b[48;5;' + bg + 'm' + '\\x1b[38;5;' + fg + 'm' + ' ' + text + ' ' + '\\x1b[0m';
1052
+ }
1053
+
1054
+ function resolveGitBranch(cwd) {
1055
+ try {
1056
+ const res = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
1057
+ cwd,
1058
+ encoding: 'utf8',
1059
+ stdio: ['ignore', 'pipe', 'ignore'],
1060
+ timeout: 800,
1061
+ });
1062
+ if (res && res.status === 0) {
1063
+ const branch = String(res.stdout || '').trim();
1064
+ if (branch && branch !== 'HEAD') return branch;
1065
+ }
1066
+ } catch {}
1067
+ try {
1068
+ const headPath = path.join(cwd, '.git', 'HEAD');
1069
+ const head = safeReadFile(headPath);
1070
+ if (head && head.startsWith('ref: ')) {
1071
+ const ref = head.slice('ref: '.length).trim();
1072
+ const m = ref.match(/refs\\/heads\\/(.+)$/);
1073
+ if (m) return m[1];
1074
+ }
1075
+ } catch {}
1076
+ return '';
1077
+ }
1078
+
1079
+ function resolveGitDiffSummary(cwd) {
1080
+ try {
1081
+ const res = spawnSync('git', ['diff', '--shortstat'], {
1082
+ cwd,
1083
+ encoding: 'utf8',
1084
+ stdio: ['ignore', 'pipe', 'ignore'],
1085
+ timeout: 800,
1086
+ });
1087
+ if (!res || res.status !== 0) return '';
1088
+ const text = String(res.stdout || '').trim();
1089
+ if (!text) return '';
1090
+ const ins = (text.match(/(\\d+)\\sinsertions?\\(\\+\\)/) || [])[1];
1091
+ const del = (text.match(/(\\d+)\\sdeletions?\\(-\\)/) || [])[1];
1092
+ const i = ins ? Number(ins) : 0;
1093
+ const d = del ? Number(del) : 0;
1094
+ if (!Number.isFinite(i) && !Number.isFinite(d)) return '';
1095
+ if (i === 0 && d === 0) return '';
1096
+ return '(+' + formatInt(i) + ',-' + formatInt(d) + ')';
1097
+ } catch {
1098
+ return '';
1099
+ }
1100
+ }
1101
+
1102
+ function buildLine(params) {
1103
+ const {
1104
+ provider,
1105
+ model,
1106
+ cwdBase,
1107
+ gitBranch,
1108
+ gitDiff,
1109
+ usedTokens,
1110
+ cacheRead,
1111
+ deltaInput,
1112
+ lastOutputTokens,
1113
+ sessionUsage,
1114
+ compacting,
1115
+ } = params;
1116
+
1117
+ let ctxPart = 'Ctx: ' + formatTokens(usedTokens);
1118
+
1119
+ const cachePart =
1120
+ cacheRead > 0 || deltaInput > 0
1121
+ ? ' c' + formatTokens(cacheRead) + '+n' + formatTokens(deltaInput)
1122
+ : '';
1123
+
1124
+ const compactPart = compacting ? ' COMPACT' : '';
1125
+
1126
+ const usagePart = (() => {
1127
+ const u = sessionUsage || {};
1128
+ const input = Number(u.inputTokens ?? 0);
1129
+ const output = Number(u.outputTokens ?? 0);
1130
+ const cacheCreation = Number(u.cacheCreationTokens ?? 0);
1131
+ const cacheReadTotal = Number(u.cacheReadTokens ?? 0);
1132
+ const thinking = Number(u.thinkingTokens ?? 0);
1133
+ if (!(input || output || cacheCreation || cacheReadTotal || thinking)) return '';
1134
+ const parts = [];
1135
+ if (input) parts.push('In:' + formatTokens(input));
1136
+ if (output) parts.push('Out:' + formatTokens(output));
1137
+ if (cacheCreation) parts.push('Cre:' + formatTokens(cacheCreation));
1138
+ if (cacheReadTotal) parts.push('Read:' + formatTokens(cacheReadTotal));
1139
+ if (thinking) parts.push('Think:' + formatTokens(thinking));
1140
+ if (lastOutputTokens > 0) parts.push('LastOut:' + formatTokens(lastOutputTokens));
1141
+ return parts.join(' ');
1142
+ })();
1143
+
1144
+ const modelPart = model ? 'Model: ' + model : '';
1145
+ const providerPart = provider ? 'Prov: ' + provider : '';
1146
+ const cwdPart = cwdBase ? 'cwd: ' + cwdBase : '';
1147
+ const branchPart = gitBranch ? '\\uE0A0 ' + gitBranch : '';
1148
+ const diffPart = gitDiff || '';
1149
+
1150
+ // Background segments (powerline-like blocks)
1151
+ const sModel = seg(88, 15, modelPart); // dark red
1152
+ const sProvider = seg(160, 15, providerPart); // red
1153
+ const sCtx = seg(220, 0, ctxPart + (cachePart ? ' (' + cachePart.trim() + ')' : '')); // yellow
1154
+ const sUsage = seg(173, 0, usagePart); // orange-ish
1155
+ const sBranch = seg(24, 15, branchPart); // blue
1156
+ const sDiff = seg(34, 0, diffPart); // green
1157
+ const sCwd = seg(238, 15, cwdPart); // gray
1158
+ const sExtra = seg(99, 15, compactPart.trim()); // purple-ish
1159
+
1160
+ return [sModel, sProvider, sCtx, sUsage, sBranch, sDiff, sCwd, sExtra].filter(Boolean).join('');
1161
+ }
1162
+
1163
+ async function main() {
1164
+ const factoryConfig = readJsonFile(CONFIG_PATH) || {};
1165
+
1166
+ const cwd = process.cwd();
1167
+ const cwdBase = path.basename(cwd) || cwd;
1168
+ const workspaceDirs = resolveWorkspaceDirs(cwd);
1169
+ const knownIdsByWorkspace = new Map();
1170
+ for (const dir of workspaceDirs) {
1171
+ const set = new Set();
1172
+ for (const c of listSessionCandidates(dir)) set.add(c.id);
1173
+ knownIdsByWorkspace.set(dir, set);
1174
+ }
1175
+
1176
+ const { resumeFlag, resumeId } = parseResume(ARGS);
1177
+
1178
+ let sessionId = null;
1179
+ let workspaceDir = null;
1180
+ if (resumeId) {
1181
+ sessionId = resumeId;
1182
+ workspaceDir = findWorkspaceDirForSessionId(workspaceDirs, sessionId) || workspaceDirs[0] || null;
1183
+ } else {
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);
1198
+ } else {
1199
+ const freshPromise = waitForNewSessionAcross(workspaceDirs, knownIdsByWorkspace, START_MS, shouldAbort);
1200
+ picked = await firstNonNull([byProcPromise, freshPromise]);
1201
+ if (!picked) picked = pickLatestSessionAcross(workspaceDirs);
1202
+ }
1203
+
1204
+ abortResolve = true;
1205
+
1206
+ sessionId = picked?.id || null;
1207
+ workspaceDir = picked?.workspaceDir || workspaceDirs[0] || null;
1208
+ }
1209
+
1210
+ if (!sessionId || !workspaceDir) return;
1211
+
1212
+ const { settingsPath, settings } = resolveSessionSettings(workspaceDir, sessionId);
1213
+ const modelId =
1214
+ (settings && typeof settings.model === 'string' ? settings.model : null) || resolveGlobalSettingsModel();
1215
+
1216
+ const provider = resolveProvider(modelId, factoryConfig);
1217
+ const underlyingModel = resolveUnderlyingModelId(modelId, factoryConfig) || modelId || 'unknown';
1218
+
1219
+ let last = { cacheReadInputTokens: 0, contextCount: 0, outputTokens: 0 };
1220
+ let sessionUsage = settings && typeof settings.tokenUsage === 'object' && settings.tokenUsage ? settings.tokenUsage : {};
1221
+ let compacting = false;
1222
+ let lastRenderAt = 0;
1223
+ let lastRenderedLine = '';
1224
+ let gitBranch = '';
1225
+ let gitDiff = '';
1226
+
1227
+ function renderNow() {
1228
+ const usedTokens = (last.cacheReadInputTokens || 0) + (last.contextCount || 0);
1229
+ const line = buildLine({
1230
+ provider,
1231
+ model: underlyingModel,
1232
+ cwdBase,
1233
+ gitBranch,
1234
+ gitDiff,
1235
+ usedTokens,
1236
+ cacheRead: last.cacheReadInputTokens || 0,
1237
+ deltaInput: last.contextCount || 0,
1238
+ lastOutputTokens: last.outputTokens || 0,
1239
+ sessionUsage,
1240
+ compacting,
1241
+ });
1242
+ if (line !== lastRenderedLine) {
1243
+ lastRenderedLine = line;
1244
+ emitFrame(line);
1245
+ }
1246
+ }
1247
+
1248
+ // Initial render.
1249
+ renderNow();
1250
+
1251
+ // Resolve git info asynchronously so startup isn't blocked on large repos.
1252
+ setTimeout(() => {
1253
+ try {
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
1269
+
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);
1321
+ }
1322
+ } finally {
1323
+ try {
1324
+ fs.closeSync(fd);
1325
+ } catch {}
1326
+ }
1327
+
1328
+ renderNow();
1329
+ } catch {
1330
+ // ignore
1331
+ }
1332
+ }, 0).unref();
1333
+ }
1334
+
1335
+ // Watch session settings for autonomy/reasoning changes (cheap polling with mtime).
1336
+ let settingsMtimeMs = 0;
1337
+ setInterval(() => {
1338
+ try {
1339
+ const stat = fs.statSync(settingsPath);
1340
+ if (stat.mtimeMs === settingsMtimeMs) return;
1341
+ settingsMtimeMs = stat.mtimeMs;
1342
+ const next = readJsonFile(settingsPath) || {};
1343
+
1344
+ // Keep session token usage in sync (used by /status).
1345
+ if (next && typeof next.tokenUsage === 'object' && next.tokenUsage) {
1346
+ sessionUsage = next.tokenUsage;
1347
+ }
1348
+
1349
+ const now = Date.now();
1350
+ if (now - lastRenderAt >= MIN_RENDER_INTERVAL_MS) {
1351
+ lastRenderAt = now;
1352
+ renderNow();
1353
+ }
1354
+ } catch {
1355
+ // ignore
1356
+ }
1357
+ }, 750).unref();
1358
+
1359
+ // Follow the Factory log and update based on session-scoped events.
1360
+ const tail = spawn('tail', ['-n', '0', '-F', LOG_PATH], {
1361
+ stdio: ['ignore', 'pipe', 'ignore'],
1362
+ });
1363
+
1364
+ let buffer = '';
1365
+ tail.stdout.on('data', (chunk) => {
1366
+ buffer += String(chunk);
1367
+ while (true) {
1368
+ const idx = buffer.indexOf('\\n');
1369
+ if (idx === -1) break;
1370
+ const line = buffer.slice(0, idx).trimEnd();
1371
+ buffer = buffer.slice(idx + 1);
1372
+
1373
+ if (!line.includes('Context:')) continue;
1374
+ if (!line.includes('"sessionId":"' + sessionId + '"')) continue;
1375
+
1376
+ const ctxIndex = line.indexOf('Context: ');
1377
+ if (ctxIndex === -1) continue;
1378
+ const jsonStr = line.slice(ctxIndex + 'Context: '.length).trim();
1379
+ let ctx;
1380
+ try {
1381
+ ctx = JSON.parse(jsonStr);
1382
+ } catch {
1383
+ continue;
1384
+ }
1385
+
1386
+ // Streaming token usage (best source for current context usage).
1387
+ if (line.includes('[Agent] Streaming result')) {
1388
+ const cacheRead = Number(ctx?.cacheReadInputTokens ?? 0);
1389
+ const contextCount = Number(ctx?.contextCount ?? 0);
1390
+ const out = Number(ctx?.outputTokens ?? 0);
1391
+ if (Number.isFinite(cacheRead)) last.cacheReadInputTokens = cacheRead;
1392
+ if (Number.isFinite(contextCount)) last.contextCount = contextCount;
1393
+ if (Number.isFinite(out)) last.outputTokens = out;
1394
+ }
1395
+
1396
+ // Compaction state hint.
1397
+ if (line.includes('[Compaction] Start')) compacting = true;
1398
+ if (line.includes('[Compaction] End')) compacting = false;
1399
+
1400
+ const now = Date.now();
1401
+ if (now - lastRenderAt >= MIN_RENDER_INTERVAL_MS) {
1402
+ lastRenderAt = now;
1403
+ renderNow();
1404
+ }
1405
+ }
1406
+ });
1407
+
1408
+ const stop = () => {
1409
+ try { tail.kill('SIGTERM'); } catch {}
1410
+ process.exit(0);
1411
+ };
1412
+
1413
+ process.on('SIGTERM', stop);
1414
+ process.on('SIGINT', stop);
1415
+ process.on('SIGHUP', stop);
1416
+ }
1417
+
1418
+ main().catch(() => {});
1419
+ `;
1420
+ }
1421
+ function generateStatuslineWrapperScript(execTargetPath, monitorScriptPath) {
1422
+ return `#!/usr/bin/env python3
1423
+ # Droid with Statusline (PTY proxy)
1424
+ # Auto-generated by droid-patch --statusline
1425
+ #
1426
+ # Design goal (KISS + no flicker):
1427
+ # - droid draws into a child PTY sized to (terminal_rows - RESERVED_ROWS)
1428
+ # - this wrapper is the ONLY writer to the real terminal
1429
+ # - a Node monitor emits statusline frames to a pipe; wrapper renders the latest frame
1430
+ # onto the reserved bottom row (a stable "second footer line").
1431
+
1432
+ import os
1433
+ import pty
1434
+ import re
1435
+ import select
1436
+ import signal
1437
+ import struct
1438
+ import subprocess
1439
+ import sys
1440
+ import termios
1441
+ import time
1442
+ import tty
1443
+ import fcntl
1444
+
1445
+ EXEC_TARGET = ${JSON.stringify(execTargetPath)}
1446
+ STATUSLINE_MONITOR = ${JSON.stringify(monitorScriptPath)}
1447
+
1448
+ IS_APPLE_TERMINAL = os.environ.get("TERM_PROGRAM") == "Apple_Terminal"
1449
+ MIN_RENDER_INTERVAL_MS = 800 if IS_APPLE_TERMINAL else 400
1450
+ QUIET_MS = 50 # Reduced to prevent statusline disappearing
1451
+ FORCE_REPAINT_INTERVAL_MS = 2000 # Force repaint every 2 seconds
1452
+ RESERVED_ROWS = 1
1453
+
1454
+ BYPASS_FLAGS = {"--help", "-h", "--version", "-V"}
1455
+ BYPASS_COMMANDS = {"help", "version", "completion", "completions", "exec"}
1456
+
1457
+ def _should_passthrough(argv):
1458
+ # Any help/version flags before "--"
1459
+ for a in argv:
1460
+ if a == "--":
1461
+ break
1462
+ if a in BYPASS_FLAGS:
1463
+ return True
1464
+
1465
+ # Top-level command token
1466
+ end_opts = False
1467
+ cmd = None
1468
+ for a in argv:
1469
+ if a == "--":
1470
+ end_opts = True
1471
+ continue
1472
+ if (not end_opts) and a.startswith("-"):
1473
+ continue
1474
+ cmd = a
1475
+ break
1476
+
1477
+ return cmd in BYPASS_COMMANDS
1478
+
1479
+ def _exec_passthrough():
1480
+ try:
1481
+ os.execvp(EXEC_TARGET, [EXEC_TARGET] + sys.argv[1:])
1482
+ except Exception as e:
1483
+ sys.stderr.write(f"[statusline] passthrough failed: {e}\\n")
1484
+ sys.exit(1)
1485
+
1486
+ # Passthrough for non-interactive/meta commands (avoid clearing screen / PTY proxy)
1487
+ if (not sys.stdin.isatty()) or (not sys.stdout.isatty()) or _should_passthrough(sys.argv[1:]):
1488
+ _exec_passthrough()
1489
+
1490
+ ANSI_RE = re.compile(r"\\x1b\\[[0-9;]*m")
1491
+ RESET_SGR = "\\x1b[0m"
1492
+
1493
+ def _term_size():
1494
+ try:
1495
+ sz = os.get_terminal_size(sys.stdout.fileno())
1496
+ return int(sz.lines), int(sz.columns)
1497
+ except Exception:
1498
+ return 24, 80
1499
+
1500
+ def _set_winsize(fd: int, rows: int, cols: int) -> None:
1501
+ try:
1502
+ winsz = struct.pack("HHHH", max(1, rows), max(1, cols), 0, 0)
1503
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, winsz)
1504
+ except Exception:
1505
+ pass
1506
+
1507
+ def _visible_width(text: str) -> int:
1508
+ # Remove only SGR sequences; good enough for our generated segments.
1509
+ stripped = ANSI_RE.sub("", text)
1510
+ return len(stripped)
1511
+
1512
+ def _clamp_ansi(text: str, cols: int) -> str:
1513
+ if cols <= 0:
1514
+ return text
1515
+ # Avoid writing into the last column. Some terminals keep an implicit wrap-pending state
1516
+ # when the last column is filled, and the next printable character can trigger a scroll.
1517
+ cols = cols - 1 if cols > 1 else cols
1518
+ if cols < 10:
1519
+ return text
1520
+ visible = 0
1521
+ i = 0
1522
+ out = []
1523
+ while i < len(text):
1524
+ ch = text[i]
1525
+ if ch == "\\x1b":
1526
+ m = text.find("m", i)
1527
+ if m != -1:
1528
+ out.append(text[i : m + 1])
1529
+ i = m + 1
1530
+ continue
1531
+ out.append(ch)
1532
+ i += 1
1533
+ continue
1534
+ if visible >= cols:
1535
+ break
1536
+ out.append(ch)
1537
+ i += 1
1538
+ visible += 1
1539
+ if i < len(text) and cols >= 1:
1540
+ if visible >= cols:
1541
+ if out:
1542
+ out[-1] = "…"
1543
+ else:
1544
+ out.append("…")
1545
+ else:
1546
+ out.append("…")
1547
+ out.append("\\x1b[0m")
1548
+ return "".join(out)
1549
+
1550
+ def _split_segments(text: str):
1551
+ if not text:
1552
+ return []
1553
+ segments = []
1554
+ start = 0
1555
+ while True:
1556
+ idx = text.find(RESET_SGR, start)
1557
+ if idx == -1:
1558
+ tail = text[start:]
1559
+ if tail:
1560
+ segments.append(tail)
1561
+ break
1562
+ seg = text[start : idx + len(RESET_SGR)]
1563
+ if seg:
1564
+ segments.append(seg)
1565
+ start = idx + len(RESET_SGR)
1566
+ return segments
1567
+
1568
+ def _wrap_segments(segments, cols: int):
1569
+ if not segments:
1570
+ return [""]
1571
+ if cols <= 0:
1572
+ return ["".join(segments)]
1573
+
1574
+ lines = []
1575
+ cur = []
1576
+ cur_w = 0
1577
+
1578
+ for seg in segments:
1579
+ seg_w = _visible_width(seg)
1580
+ if seg_w <= 0:
1581
+ continue
1582
+
1583
+ if not cur:
1584
+ if seg_w > cols:
1585
+ seg = _clamp_ansi(seg, cols)
1586
+ seg_w = _visible_width(seg)
1587
+ cur = [seg]
1588
+ cur_w = seg_w
1589
+ continue
1590
+
1591
+ if cur_w + seg_w <= cols:
1592
+ cur.append(seg)
1593
+ cur_w += seg_w
1594
+ else:
1595
+ lines.append("".join(cur))
1596
+ if seg_w > cols:
1597
+ seg = _clamp_ansi(seg, cols)
1598
+ seg_w = _visible_width(seg)
1599
+ cur = [seg]
1600
+ cur_w = seg_w
1601
+
1602
+ if cur:
1603
+ lines.append("".join(cur))
1604
+
1605
+ return lines if lines else [""]
1606
+
1607
+ class StatusRenderer:
1608
+ def __init__(self):
1609
+ self._raw = ""
1610
+ self._segments = []
1611
+ self._lines = [""]
1612
+ self._active_reserved_rows = RESERVED_ROWS
1613
+ self._last_render_ms = 0
1614
+ self._last_child_out_ms = 0
1615
+ self._force_repaint = False
1616
+ self._urgent = False
1617
+ self._cursor_visible = True
1618
+
1619
+ def note_child_output(self):
1620
+ self._last_child_out_ms = int(time.time() * 1000)
1621
+
1622
+ def set_cursor_visible(self, visible: bool):
1623
+ self._cursor_visible = bool(visible)
1624
+
1625
+ def force_repaint(self, urgent: bool = False):
1626
+ self._force_repaint = True
1627
+ if urgent:
1628
+ self._urgent = True
1629
+
1630
+ def set_active_reserved_rows(self, reserved_rows: int):
1631
+ try:
1632
+ self._active_reserved_rows = max(1, int(reserved_rows or 1))
1633
+ except Exception:
1634
+ self._active_reserved_rows = 1
1635
+
1636
+ def set_line(self, line: str):
1637
+ if line != self._raw:
1638
+ self._raw = line
1639
+ self._segments = _split_segments(line)
1640
+ self._force_repaint = True
1641
+
1642
+ def _write(self, b: bytes) -> None:
1643
+ try:
1644
+ os.write(sys.stdout.fileno(), b)
1645
+ except Exception:
1646
+ pass
1647
+
1648
+ def desired_reserved_rows(self, physical_rows: int, cols: int, min_reserved: int):
1649
+ try:
1650
+ rows = int(physical_rows or 24)
1651
+ except Exception:
1652
+ rows = 24
1653
+ try:
1654
+ cols = int(cols or 80)
1655
+ except Exception:
1656
+ cols = 80
1657
+
1658
+ max_reserved = max(1, rows - 4)
1659
+ segments = self._segments if self._segments else ([self._raw] if self._raw else [])
1660
+ lines = _wrap_segments(segments, cols) if segments else [""]
1661
+
1662
+ needed = min(len(lines), max_reserved)
1663
+ desired = max(int(min_reserved or 1), needed)
1664
+ desired = min(desired, max_reserved)
1665
+
1666
+ if len(lines) < desired:
1667
+ lines = [""] * (desired - len(lines)) + lines
1668
+ if len(lines) > desired:
1669
+ lines = lines[-desired:]
1670
+
1671
+ self._lines = lines
1672
+ return desired
1673
+
1674
+ def clear_reserved_area(
1675
+ self,
1676
+ physical_rows: int,
1677
+ cols: int,
1678
+ reserved_rows: int,
1679
+ restore_row: int = 1,
1680
+ restore_col: int = 1,
1681
+ ):
1682
+ try:
1683
+ rows = int(physical_rows or 24)
1684
+ except Exception:
1685
+ rows = 24
1686
+ try:
1687
+ cols = int(cols or 80)
1688
+ except Exception:
1689
+ cols = 80
1690
+ try:
1691
+ reserved = max(1, int(reserved_rows or 1))
1692
+ except Exception:
1693
+ reserved = 1
1694
+
1695
+ reserved = min(reserved, rows)
1696
+ start_row = rows - reserved + 1
1697
+ parts = ["\\x1b[?2026h", "\\x1b[?25l", RESET_SGR]
1698
+ for i in range(reserved):
1699
+ parts.append(f"\\x1b[{start_row + i};1H\\x1b[2K")
1700
+ parts.append(f"\\x1b[{restore_row};{restore_col}H")
1701
+ parts.append("\\x1b[?25h" if self._cursor_visible else "\\x1b[?25l")
1702
+ parts.append("\\x1b[?2026l")
1703
+ self._write("".join(parts).encode("utf-8", "ignore"))
1704
+
1705
+ def render(self, physical_rows: int, cols: int, restore_row: int = 1, restore_col: int = 1) -> None:
1706
+ now_ms = int(time.time() * 1000)
1707
+ if not self._force_repaint:
1708
+ return
1709
+ if not self._raw:
1710
+ self._force_repaint = False
1711
+ self._urgent = False
1712
+ return
1713
+ if (not self._urgent) and (now_ms - self._last_render_ms < MIN_RENDER_INTERVAL_MS):
1714
+ return
1715
+ # Avoid repainting while child is actively writing (reduces flicker on macOS Terminal).
1716
+ if (not self._urgent) and (QUIET_MS > 0 and now_ms - self._last_child_out_ms < QUIET_MS):
1717
+ return
1718
+
1719
+ try:
1720
+ rows = int(physical_rows or 24)
1721
+ except Exception:
1722
+ rows = 24
1723
+ try:
1724
+ cols = int(cols or 80)
1725
+ except Exception:
1726
+ cols = 80
1727
+
1728
+ if cols <= 0:
1729
+ cols = 80
1730
+
1731
+ reserved = max(1, min(self._active_reserved_rows, max(1, rows - 4)))
1732
+ start_row = rows - reserved + 1
1733
+
1734
+ lines = self._lines or [""]
1735
+ if len(lines) < reserved:
1736
+ lines = [""] * (reserved - len(lines)) + lines
1737
+ if len(lines) > reserved:
1738
+ lines = lines[-reserved:]
1739
+
1740
+ child_rows = rows - reserved
1741
+
1742
+ parts = ["\\x1b[?2026h", "\\x1b[?25l"]
1743
+ # Always set scroll region to exclude statusline area
1744
+ parts.append(f"\\x1b[1;{child_rows}r")
1745
+ for i in range(reserved):
1746
+ row = start_row + i
1747
+ text = _clamp_ansi(lines[i], cols)
1748
+ parts.append(f"\\x1b[{row};1H{RESET_SGR}\\x1b[2K")
1749
+ parts.append(f"\\x1b[{row};1H{text}{RESET_SGR}")
1750
+ parts.append(f"\\x1b[{restore_row};{restore_col}H")
1751
+ parts.append("\\x1b[?25h" if self._cursor_visible else "\\x1b[?25l")
1752
+ parts.append("\\x1b[?2026l")
1753
+
1754
+ self._write("".join(parts).encode("utf-8", "ignore"))
1755
+ self._last_render_ms = now_ms
1756
+ self._force_repaint = False
1757
+ self._urgent = False
1758
+
1759
+ def clear(self):
1760
+ r, c = _term_size()
1761
+ self.clear_reserved_area(r, c, max(self._active_reserved_rows, RESERVED_ROWS))
1762
+
1763
+
1764
+ class OutputRewriter:
1765
+ # Rewrite a small subset of ANSI cursor positioning commands to ensure the child UI never
1766
+ # draws into the reserved statusline rows.
1767
+ #
1768
+ # Key idea: many TUIs use "ESC[999;1H" to jump to the terminal bottom. If we forward that
1769
+ # unmodified, it targets the *physical* bottom row, overwriting our statusline. We clamp it
1770
+ # to "max_row" (physical_rows - reserved_rows) so the child's "bottom" becomes the line
1771
+ # just above the statusline.
1772
+ def __init__(self):
1773
+ self._buf = b""
1774
+
1775
+ def feed(self, chunk: bytes, max_row: int) -> bytes:
1776
+ if not chunk:
1777
+ return b""
1778
+
1779
+ data = self._buf + chunk
1780
+ self._buf = b""
1781
+ out = bytearray()
1782
+ n = len(data)
1783
+ i = 0
1784
+
1785
+ def _is_final_byte(v: int) -> bool:
1786
+ return 0x40 <= v <= 0x7E
1787
+
1788
+ while i < n:
1789
+ b = data[i]
1790
+ if b != 0x1B: # ESC
1791
+ out.append(b)
1792
+ i += 1
1793
+ continue
1794
+
1795
+ if i + 1 >= n:
1796
+ self._buf = data[i:]
1797
+ break
1798
+
1799
+ nxt = data[i + 1]
1800
+ if nxt != 0x5B: # not CSI
1801
+ out.append(b)
1802
+ i += 1
1803
+ continue
1804
+
1805
+ # CSI sequence: ESC [ ... <final>
1806
+ j = i + 2
1807
+ while j < n and not _is_final_byte(data[j]):
1808
+ j += 1
1809
+ if j >= n:
1810
+ self._buf = data[i:]
1811
+ break
1812
+
1813
+ final = data[j]
1814
+ seq = data[i : j + 1]
1815
+
1816
+ if final in (ord("H"), ord("f")) and max_row > 0:
1817
+ params = data[i + 2 : j]
1818
+ try:
1819
+ s = params.decode("ascii", "ignore")
1820
+ except Exception:
1821
+ s = ""
1822
+
1823
+ # Only handle the simple numeric form (no private/DEC prefixes like "?")
1824
+ if not s or s[0] in "0123456789;":
1825
+ parts = s.split(";") if s else []
1826
+ try:
1827
+ row = int(parts[0]) if (len(parts) >= 1 and parts[0]) else 1
1828
+ except Exception:
1829
+ row = 1
1830
+ try:
1831
+ col = int(parts[1]) if (len(parts) >= 2 and parts[1]) else 1
1832
+ except Exception:
1833
+ col = 1
1834
+
1835
+ if row == 999 or row > max_row:
1836
+ row = max_row
1837
+ if row < 1:
1838
+ row = 1
1839
+ if col < 1:
1840
+ col = 1
1841
+
1842
+ new_params = f"{row};{col}".encode("ascii", "ignore")
1843
+ seq = b"\\x1b[" + new_params + bytes([final])
1844
+
1845
+ elif final == ord("r") and max_row > 0:
1846
+ # DECSTBM - Set scrolling region. If the child resets to the full physical screen
1847
+ # (e.g. ESC[r), the reserved statusline row becomes scrollable and our statusline
1848
+ # will "float" upward when the UI scrolls. Clamp bottom to max_row (child area).
1849
+ params = data[i + 2 : j]
1850
+ try:
1851
+ s = params.decode("ascii", "ignore")
1852
+ except Exception:
1853
+ s = ""
1854
+
1855
+ # Only handle the simple numeric form (no private/DEC prefixes like "?")
1856
+ if not s or s[0] in "0123456789;":
1857
+ parts = s.split(";") if s else []
1858
+ try:
1859
+ top = int(parts[0]) if (len(parts) >= 1 and parts[0]) else 1
1860
+ except Exception:
1861
+ top = 1
1862
+ try:
1863
+ bottom = int(parts[1]) if (len(parts) >= 2 and parts[1]) else max_row
1864
+ except Exception:
1865
+ bottom = max_row
1866
+
1867
+ if top <= 0:
1868
+ top = 1
1869
+ if bottom <= 0 or bottom == 999 or bottom > max_row:
1870
+ bottom = max_row
1871
+ if top > bottom:
1872
+ top = 1
1873
+
1874
+ seq = f"\\x1b[{top};{bottom}r".encode("ascii", "ignore")
1875
+
1876
+ out.extend(seq)
1877
+ i = j + 1
1878
+
1879
+ return bytes(out)
1880
+
1881
+
1882
+ class CursorTracker:
1883
+ # Best-effort cursor tracking so the wrapper can restore the cursor position without using
1884
+ # ESC7/ESC8 (which droid/Ink also uses internally).
1885
+ def __init__(self):
1886
+ self.row = 1
1887
+ self.col = 1
1888
+ self._saved_row = 1
1889
+ self._saved_col = 1
1890
+ self._buf = b""
1891
+ self._in_osc = False
1892
+ self._utf8_cont = 0
1893
+ self._wrap_pending = False
1894
+
1895
+ def position(self):
1896
+ return self.row, self.col
1897
+
1898
+ def feed(self, chunk: bytes, max_row: int, max_col: int) -> None:
1899
+ if not chunk:
1900
+ return
1901
+ try:
1902
+ max_row = max(1, int(max_row or 1))
1903
+ except Exception:
1904
+ max_row = 1
1905
+ try:
1906
+ max_col = max(1, int(max_col or 1))
1907
+ except Exception:
1908
+ max_col = 1
1909
+
1910
+ data = self._buf + chunk
1911
+ self._buf = b""
1912
+ n = len(data)
1913
+ i = 0
1914
+
1915
+ def _clamp():
1916
+ if self.row < 1:
1917
+ self.row = 1
1918
+ elif self.row > max_row:
1919
+ self.row = max_row
1920
+ if self.col < 1:
1921
+ self.col = 1
1922
+ elif self.col > max_col:
1923
+ self.col = max_col
1924
+
1925
+ def _parse_int(v: str, default: int) -> int:
1926
+ try:
1927
+ return int(v) if v else default
1928
+ except Exception:
1929
+ return default
1930
+
1931
+ while i < n:
1932
+ b = data[i]
1933
+
1934
+ if self._in_osc:
1935
+ # OSC/DCS/etc are terminated by BEL or ST (ESC \\).
1936
+ if b == 0x07:
1937
+ self._in_osc = False
1938
+ i += 1
1939
+ continue
1940
+ if b == 0x1B:
1941
+ if i + 1 >= n:
1942
+ self._buf = data[i:]
1943
+ break
1944
+ if data[i + 1] == 0x5C:
1945
+ self._in_osc = False
1946
+ i += 2
1947
+ continue
1948
+ i += 1
1949
+ continue
1950
+
1951
+ if self._utf8_cont > 0:
1952
+ if 0x80 <= b <= 0xBF:
1953
+ self._utf8_cont -= 1
1954
+ i += 1
1955
+ continue
1956
+ self._utf8_cont = 0
1957
+
1958
+ if b == 0x1B: # ESC
1959
+ self._wrap_pending = False
1960
+ if i + 1 >= n:
1961
+ self._buf = data[i:]
1962
+ break
1963
+ nxt = data[i + 1]
1964
+
1965
+ if nxt == 0x5B: # CSI
1966
+ j = i + 2
1967
+ while j < n and not (0x40 <= data[j] <= 0x7E):
1968
+ j += 1
1969
+ if j >= n:
1970
+ self._buf = data[i:]
1971
+ break
1972
+ final = data[j]
1973
+ params = data[i + 2 : j]
1974
+ try:
1975
+ s = params.decode("ascii", "ignore")
1976
+ except Exception:
1977
+ s = ""
1978
+
1979
+ if s and s[0] not in "0123456789;":
1980
+ i = j + 1
1981
+ continue
1982
+
1983
+ parts = s.split(";") if s else []
1984
+ p0 = _parse_int(parts[0] if len(parts) >= 1 else "", 1)
1985
+ p1 = _parse_int(parts[1] if len(parts) >= 2 else "", 1)
1986
+
1987
+ if final in (ord("H"), ord("f")):
1988
+ self.row = p0
1989
+ self.col = p1
1990
+ _clamp()
1991
+ elif final == ord("A"):
1992
+ self.row = max(1, self.row - p0)
1993
+ elif final == ord("B"):
1994
+ self.row = min(max_row, self.row + p0)
1995
+ elif final == ord("C"):
1996
+ self.col = min(max_col, self.col + p0)
1997
+ elif final == ord("D"):
1998
+ self.col = max(1, self.col - p0)
1999
+ elif final == ord("E"):
2000
+ self.row = min(max_row, self.row + p0)
2001
+ self.col = 1
2002
+ elif final == ord("F"):
2003
+ self.row = max(1, self.row - p0)
2004
+ self.col = 1
2005
+ elif final == ord("G"):
2006
+ self.col = p0
2007
+ _clamp()
2008
+ elif final == ord("d"):
2009
+ self.row = p0
2010
+ _clamp()
2011
+ elif final == ord("r"):
2012
+ # DECSTBM moves the cursor to the home position.
2013
+ self.row = 1
2014
+ self.col = 1
2015
+ elif final == ord("s"):
2016
+ self._saved_row = self.row
2017
+ self._saved_col = self.col
2018
+ elif final == ord("u"):
2019
+ self.row = self._saved_row
2020
+ self.col = self._saved_col
2021
+ _clamp()
2022
+
2023
+ i = j + 1
2024
+ continue
2025
+
2026
+ # OSC, DCS, PM, APC, SOS (terminated by ST or BEL).
2027
+ if nxt == 0x5D or nxt in (0x50, 0x5E, 0x5F, 0x58):
2028
+ self._in_osc = True
2029
+ i += 2
2030
+ continue
2031
+
2032
+ # DECSC / DECRC
2033
+ if nxt == 0x37:
2034
+ self._saved_row = self.row
2035
+ self._saved_col = self.col
2036
+ i += 2
2037
+ continue
2038
+ if nxt == 0x38:
2039
+ self.row = self._saved_row
2040
+ self.col = self._saved_col
2041
+ _clamp()
2042
+ i += 2
2043
+ continue
2044
+
2045
+ # Other single-escape sequences (ignore).
2046
+ i += 2
2047
+ continue
2048
+
2049
+ if b == 0x0D: # CR
2050
+ self.col = 1
2051
+ self._wrap_pending = False
2052
+ i += 1
2053
+ continue
2054
+ if b in (0x0A, 0x0B, 0x0C): # LF/VT/FF
2055
+ self.row = min(max_row, self.row + 1)
2056
+ self._wrap_pending = False
2057
+ i += 1
2058
+ continue
2059
+ if b == 0x08: # BS
2060
+ self.col = max(1, self.col - 1)
2061
+ self._wrap_pending = False
2062
+ i += 1
2063
+ continue
2064
+ if b == 0x09: # TAB
2065
+ next_stop = ((self.col - 1) // 8 + 1) * 8 + 1
2066
+ self.col = min(max_col, next_stop)
2067
+ self._wrap_pending = False
2068
+ i += 1
2069
+ continue
2070
+
2071
+ if b < 0x20 or b == 0x7F:
2072
+ i += 1
2073
+ continue
2074
+
2075
+ # Printable characters.
2076
+ if self._wrap_pending:
2077
+ self.row = min(max_row, self.row + 1)
2078
+ self.col = 1
2079
+ self._wrap_pending = False
2080
+
2081
+ if b >= 0x80:
2082
+ if (b & 0xE0) == 0xC0:
2083
+ self._utf8_cont = 1
2084
+ elif (b & 0xF0) == 0xE0:
2085
+ self._utf8_cont = 2
2086
+ elif (b & 0xF8) == 0xF0:
2087
+ self._utf8_cont = 3
2088
+ else:
2089
+ self._utf8_cont = 0
2090
+
2091
+ if self.col < max_col:
2092
+ self.col += 1
2093
+ else:
2094
+ self.col = max_col
2095
+ self._wrap_pending = True
2096
+
2097
+ i += 1
2098
+
2099
+
2100
+ def main():
2101
+ # Start from a clean viewport. Droid's TUI assumes a fresh screen; without this,
2102
+ # it can visually mix with prior shell output (especially when scrollback exists).
2103
+ try:
2104
+ os.write(sys.stdout.fileno(), b"\\x1b[?2026h\\x1b[0m\\x1b[r\\x1b[2J\\x1b[H\\x1b[?2026l")
2105
+ except Exception:
2106
+ pass
2107
+
2108
+ renderer = StatusRenderer()
2109
+ renderer.set_line("\\x1b[48;5;238m\\x1b[38;5;15m Statusline: starting… \\x1b[0m")
2110
+ renderer.force_repaint(True)
2111
+
2112
+ physical_rows, physical_cols = _term_size()
2113
+ effective_reserved_rows = renderer.desired_reserved_rows(physical_rows, physical_cols, RESERVED_ROWS)
2114
+ renderer.set_active_reserved_rows(effective_reserved_rows)
2115
+
2116
+ child_rows = max(4, physical_rows - effective_reserved_rows)
2117
+ child_cols = max(10, physical_cols)
2118
+
2119
+ # Reserve the bottom rows up-front, before the child starts writing.
2120
+ try:
2121
+ seq = f"\\x1b[?2026h\\x1b[?25l\\x1b[1;{child_rows}r\\x1b[1;1H\\x1b[?25h\\x1b[?2026l"
2122
+ os.write(sys.stdout.fileno(), seq.encode("utf-8", "ignore"))
2123
+ except Exception:
2124
+ pass
2125
+ renderer.force_repaint(True)
2126
+ renderer.render(physical_rows, physical_cols)
2127
+
2128
+ master_fd, slave_fd = pty.openpty()
2129
+ _set_winsize(slave_fd, child_rows, child_cols)
2130
+
2131
+ child = subprocess.Popen(
2132
+ [EXEC_TARGET] + sys.argv[1:],
2133
+ stdin=slave_fd,
2134
+ stdout=slave_fd,
2135
+ stderr=slave_fd,
2136
+ close_fds=True,
2137
+ start_new_session=True,
2138
+ )
2139
+ os.close(slave_fd)
2140
+
2141
+ rewriter = OutputRewriter()
2142
+ cursor = CursorTracker()
2143
+
2144
+ monitor = None
2145
+ try:
2146
+ monitor_env = os.environ.copy()
2147
+ try:
2148
+ monitor_env["DROID_STATUSLINE_PGID"] = str(os.getpgid(child.pid))
2149
+ except Exception:
2150
+ monitor_env["DROID_STATUSLINE_PGID"] = str(child.pid)
2151
+ monitor = subprocess.Popen(
2152
+ ["node", STATUSLINE_MONITOR] + sys.argv[1:],
2153
+ stdin=subprocess.DEVNULL,
2154
+ stdout=subprocess.PIPE,
2155
+ stderr=subprocess.DEVNULL,
2156
+ close_fds=True,
2157
+ bufsize=0,
2158
+ env=monitor_env,
2159
+ )
2160
+ except Exception:
2161
+ monitor = None
2162
+
2163
+ monitor_fd = monitor.stdout.fileno() if (monitor and monitor.stdout) else None
2164
+
2165
+ def forward(sig, _frame):
2166
+ try:
2167
+ os.killpg(child.pid, sig)
2168
+ except Exception:
2169
+ pass
2170
+
2171
+ for s in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
2172
+ try:
2173
+ signal.signal(s, forward)
2174
+ except Exception:
2175
+ pass
2176
+
2177
+ stdin_fd = sys.stdin.fileno()
2178
+ stdout_fd = sys.stdout.fileno()
2179
+ old_tty = termios.tcgetattr(stdin_fd)
2180
+ try:
2181
+ tty.setraw(stdin_fd)
2182
+ # Ensure stdout is blocking (prevents sporadic EAGAIN/BlockingIOError on some terminals).
2183
+ try:
2184
+ os.set_blocking(stdout_fd, True)
2185
+ except Exception:
2186
+ pass
2187
+ os.set_blocking(stdin_fd, False)
2188
+ os.set_blocking(master_fd, False)
2189
+ if monitor_fd is not None:
2190
+ os.set_blocking(monitor_fd, False)
2191
+
2192
+ monitor_buf = b""
2193
+ detect_buf = b""
2194
+ cursor_visible = True
2195
+ last_physical_rows = 0
2196
+ last_physical_cols = 0
2197
+ scroll_region_dirty = True
2198
+ last_force_repaint_ms = int(time.time() * 1000)
2199
+
2200
+ while True:
2201
+ if child.poll() is not None:
2202
+ break
2203
+
2204
+ read_fds = [master_fd, stdin_fd]
2205
+ if monitor_fd is not None:
2206
+ read_fds.append(monitor_fd)
2207
+
2208
+ try:
2209
+ rlist, _, _ = select.select(read_fds, [], [], 0.05)
2210
+ except InterruptedError:
2211
+ rlist = []
2212
+
2213
+ pty_eof = False
2214
+ for fd in rlist:
2215
+ if fd == stdin_fd:
2216
+ try:
2217
+ data = os.read(stdin_fd, 4096)
2218
+ if data:
2219
+ os.write(master_fd, data)
2220
+ except BlockingIOError:
2221
+ pass
2222
+ except OSError:
2223
+ pass
2224
+ elif fd == master_fd:
2225
+ try:
2226
+ data = os.read(master_fd, 65536)
2227
+ except BlockingIOError:
2228
+ data = b""
2229
+ except OSError:
2230
+ data = b""
2231
+
2232
+ if data:
2233
+ detect_buf = (detect_buf + data)[-128:]
2234
+ # Detect sequences that may affect scroll region or clear screen
2235
+ needs_scroll_region_reset = (
2236
+ (b"\\x1b[?1049" in detect_buf) # Alt screen
2237
+ or (b"\\x1b[?1047" in detect_buf) # Alt screen
2238
+ or (b"\\x1b[?47" in detect_buf) # Alt screen
2239
+ or (b"\\x1b[J" in detect_buf) # Clear below
2240
+ or (b"\\x1b[0J" in detect_buf) # Clear below
2241
+ or (b"\\x1b[1J" in detect_buf) # Clear above
2242
+ or (b"\\x1b[2J" in detect_buf) # Clear all
2243
+ or (b"\\x1b[3J" in detect_buf) # Clear scrollback
2244
+ or (b"\\x1b[r" in detect_buf) # Reset scroll region (bare ESC[r)
2245
+ )
2246
+ # Also detect scroll region changes with parameters (DECSTBM pattern ESC[n;mr)
2247
+ if b"\\x1b[" in detect_buf and b"r" in detect_buf:
2248
+ if re.search(b"\\x1b\\\\[[0-9]*;?[0-9]*r", detect_buf):
2249
+ needs_scroll_region_reset = True
2250
+ if needs_scroll_region_reset:
2251
+ renderer.force_repaint(True)
2252
+ scroll_region_dirty = True
2253
+ h = detect_buf.rfind(b"\\x1b[?25h")
2254
+ l = detect_buf.rfind(b"\\x1b[?25l")
2255
+ if h != -1 or l != -1:
2256
+ cursor_visible = h > l
2257
+ renderer.set_cursor_visible(cursor_visible)
2258
+ renderer.note_child_output()
2259
+ data = rewriter.feed(data, child_rows)
2260
+ cursor.feed(data, child_rows, child_cols)
2261
+ try:
2262
+ os.write(stdout_fd, data)
2263
+ except BlockingIOError:
2264
+ # If stdout is non-blocking for some reason, retry briefly.
2265
+ try:
2266
+ time.sleep(0.01)
2267
+ os.write(stdout_fd, data)
2268
+ except Exception:
2269
+ pass
2270
+ except OSError:
2271
+ pass
2272
+ else:
2273
+ pty_eof = True
2274
+ elif monitor_fd is not None and fd == monitor_fd:
2275
+ try:
2276
+ chunk = os.read(monitor_fd, 65536)
2277
+ except BlockingIOError:
2278
+ chunk = b""
2279
+ except OSError:
2280
+ chunk = b""
2281
+
2282
+ if chunk:
2283
+ monitor_buf += chunk
2284
+ while True:
2285
+ nl = monitor_buf.find(b"\\n")
2286
+ if nl == -1:
2287
+ break
2288
+ raw = monitor_buf[:nl].rstrip(b"\\r")
2289
+ monitor_buf = monitor_buf[nl + 1 :]
2290
+ if not raw:
2291
+ continue
2292
+ renderer.set_line(raw.decode("utf-8", "replace"))
2293
+ else:
2294
+ monitor_fd = None
2295
+
2296
+ if pty_eof:
2297
+ break
2298
+
2299
+ physical_rows, physical_cols = _term_size()
2300
+ size_changed = (physical_rows != last_physical_rows) or (physical_cols != last_physical_cols)
2301
+
2302
+ desired = renderer.desired_reserved_rows(physical_rows, physical_cols, RESERVED_ROWS)
2303
+ if size_changed or (desired != effective_reserved_rows):
2304
+ cr, cc = cursor.position()
2305
+ if desired < effective_reserved_rows:
2306
+ renderer.clear_reserved_area(physical_rows, physical_cols, effective_reserved_rows, cr, cc)
2307
+
2308
+ effective_reserved_rows = desired
2309
+ renderer.set_active_reserved_rows(effective_reserved_rows)
2310
+
2311
+ child_rows = max(4, physical_rows - effective_reserved_rows)
2312
+ child_cols = max(10, physical_cols)
2313
+ _set_winsize(master_fd, child_rows, child_cols)
2314
+ try:
2315
+ os.killpg(child.pid, signal.SIGWINCH)
2316
+ except Exception:
2317
+ pass
2318
+
2319
+ scroll_region_dirty = True
2320
+ renderer.force_repaint(urgent=True) # Use urgent mode to ensure immediate repaint
2321
+ last_physical_rows = physical_rows
2322
+ last_physical_cols = physical_cols
2323
+
2324
+ cr, cc = cursor.position()
2325
+ if cr < 1:
2326
+ cr = 1
2327
+ if cc < 1:
2328
+ cc = 1
2329
+ if cr > child_rows:
2330
+ cr = child_rows
2331
+ if cc > child_cols:
2332
+ cc = child_cols
2333
+
2334
+ if scroll_region_dirty:
2335
+ # Keep the reserved rows out of the terminal scroll region (esp. after resize).
2336
+ try:
2337
+ seq = f"\\x1b[?2026h\\x1b[?25l\\x1b[1;{child_rows}r\\x1b[{cr};{cc}H"
2338
+ seq += "\\x1b[?25h" if cursor_visible else "\\x1b[?25l"
2339
+ seq += "\\x1b[?2026l"
2340
+ os.write(stdout_fd, seq.encode("utf-8", "ignore"))
2341
+ except Exception:
2342
+ pass
2343
+ scroll_region_dirty = False
2344
+
2345
+ # Periodic force repaint to ensure statusline doesn't disappear
2346
+ now_ms = int(time.time() * 1000)
2347
+ if now_ms - last_force_repaint_ms >= FORCE_REPAINT_INTERVAL_MS:
2348
+ renderer.force_repaint(False)
2349
+ last_force_repaint_ms = now_ms
2350
+
2351
+ renderer.render(physical_rows, physical_cols, cr, cc)
2352
+
2353
+ finally:
2354
+ try:
2355
+ termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_tty)
2356
+ except Exception:
2357
+ pass
2358
+ try:
2359
+ renderer.clear()
2360
+ except Exception:
2361
+ pass
2362
+ try:
2363
+ # Restore terminal scroll region and attributes.
2364
+ os.write(stdout_fd, b"\\x1b[r\\x1b[0m\\x1b[?25h")
2365
+ except Exception:
2366
+ pass
2367
+ try:
2368
+ os.close(master_fd)
2369
+ except Exception:
2370
+ pass
2371
+ if monitor is not None:
2372
+ try:
2373
+ monitor.terminate()
2374
+ except Exception:
2375
+ pass
2376
+
2377
+ sys.exit(child.returncode or 0)
2378
+
2379
+
2380
+ if __name__ == "__main__":
2381
+ main()
2382
+ `;
2383
+ }
2384
+ async function createStatuslineFiles(outputDir, execTargetPath, aliasName) {
2385
+ if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true });
2386
+ const monitorScriptPath = join(outputDir, `${aliasName}-statusline.js`);
2387
+ const wrapperScriptPath = join(outputDir, aliasName);
2388
+ await writeFile(monitorScriptPath, generateStatuslineMonitorScript());
2389
+ await chmod(monitorScriptPath, 493);
2390
+ await writeFile(wrapperScriptPath, generateStatuslineWrapperScript(execTargetPath, monitorScriptPath));
2391
+ await chmod(wrapperScriptPath, 493);
2392
+ return {
2393
+ wrapperScript: wrapperScriptPath,
2394
+ monitorScript: monitorScriptPath
2395
+ };
2396
+ }
2397
+
626
2398
  //#endregion
627
2399
  //#region src/cli.ts
628
2400
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -675,12 +2447,13 @@ function findDefaultDroidPath() {
675
2447
  for (const p of paths) if (existsSync(p)) return p;
676
2448
  return join(home, ".droid", "bin", "droid");
677
2449
  }
678
- 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("--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) => {
2450
+ 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) => {
679
2451
  const alias = args?.[0];
680
2452
  const isCustom = options["is-custom"];
681
2453
  const skipLogin = options["skip-login"];
682
2454
  const apiBase = options["api-base"];
683
2455
  const websearch = options["websearch"];
2456
+ const statusline = options["statusline"];
684
2457
  const standalone = options["standalone"];
685
2458
  const websearchTarget = websearch ? apiBase || "https://api.factory.ai" : void 0;
686
2459
  const reasoningEffort = options["reasoning-effort"];
@@ -691,65 +2464,83 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
691
2464
  const backup = options.backup !== false;
692
2465
  const verbose = options.verbose;
693
2466
  const outputPath = outputDir && alias ? join(outputDir, alias) : void 0;
694
- if (websearch && !isCustom && !skipLogin && !reasoningEffort && !noTelemetry) {
2467
+ if (!(!!isCustom || !!skipLogin || !!reasoningEffort || !!noTelemetry || !!apiBase && !websearch) && (websearch || statusline)) {
695
2468
  if (!alias) {
696
- console.log(styleText("red", "Error: Alias name required for --websearch"));
2469
+ console.log(styleText("red", "Error: Alias name required for --websearch/--statusline"));
697
2470
  console.log(styleText("gray", "Usage: npx droid-patch --websearch <alias>"));
2471
+ console.log(styleText("gray", "Usage: npx droid-patch --statusline <alias>"));
698
2472
  process.exit(1);
699
2473
  }
700
2474
  console.log(styleText("cyan", "═".repeat(60)));
701
- console.log(styleText(["cyan", "bold"], " Droid WebSearch Setup"));
2475
+ console.log(styleText(["cyan", "bold"], " Droid Wrapper Setup"));
702
2476
  console.log(styleText("cyan", "═".repeat(60)));
703
2477
  console.log();
704
- console.log(styleText("white", `Forward target: ${websearchTarget}`));
705
- if (standalone) console.log(styleText("white", `Standalone mode: enabled`));
2478
+ if (websearch) {
2479
+ console.log(styleText("white", `WebSearch: enabled`));
2480
+ console.log(styleText("white", `Forward target: ${websearchTarget}`));
2481
+ if (standalone) console.log(styleText("white", `Standalone mode: enabled`));
2482
+ }
2483
+ if (statusline) console.log(styleText("white", `Statusline: enabled`));
706
2484
  console.log();
707
- const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), path, alias, websearchTarget, standalone);
708
- await createAliasForWrapper(wrapperScript, alias, verbose);
2485
+ let execTargetPath = path;
2486
+ if (websearch) {
2487
+ const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchTarget, standalone);
2488
+ execTargetPath = wrapperScript;
2489
+ }
2490
+ if (statusline) {
2491
+ const { wrapperScript } = await createStatuslineFiles(join(homedir(), ".droid-patch", "statusline"), execTargetPath, alias);
2492
+ execTargetPath = wrapperScript;
2493
+ }
2494
+ const aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
709
2495
  const droidVersion = getDroidVersion(path);
710
2496
  await saveAliasMetadata(createMetadata(alias, path, {
711
2497
  isCustom: false,
712
2498
  skipLogin: false,
713
2499
  apiBase: apiBase || null,
714
- websearch: true,
2500
+ websearch: !!websearch,
2501
+ statusline: !!statusline,
715
2502
  reasoningEffort: false,
716
2503
  noTelemetry: false,
717
2504
  standalone
718
2505
  }, {
719
2506
  droidPatchVersion: version,
720
- droidVersion
2507
+ droidVersion,
2508
+ aliasPath: aliasResult.aliasPath
721
2509
  }));
722
2510
  console.log();
723
2511
  console.log(styleText("green", "═".repeat(60)));
724
- console.log(styleText(["green", "bold"], " WebSearch Ready!"));
2512
+ console.log(styleText(["green", "bold"], " Wrapper Ready!"));
725
2513
  console.log(styleText("green", "═".repeat(60)));
726
2514
  console.log();
727
2515
  console.log("Run directly:");
728
2516
  console.log(styleText("yellow", ` ${alias}`));
729
2517
  console.log();
730
- console.log(styleText("cyan", "Auto-shutdown:"));
731
- console.log(styleText("gray", " Proxy auto-shuts down after 5 min idle (no manual cleanup needed)"));
732
- console.log(styleText("gray", " To disable: export DROID_PROXY_IDLE_TIMEOUT=0"));
733
- console.log();
734
- console.log("Search providers (in priority order):");
735
- console.log(styleText("yellow", " 1. Smithery Exa (best quality):"));
736
- console.log(styleText("gray", " export SMITHERY_API_KEY=your_api_key"));
737
- console.log(styleText("gray", " export SMITHERY_PROFILE=your_profile"));
738
- console.log(styleText("gray", " 2. Google PSE:"));
739
- console.log(styleText("gray", " export GOOGLE_PSE_API_KEY=your_api_key"));
740
- console.log(styleText("gray", " export GOOGLE_PSE_CX=your_search_engine_id"));
741
- console.log(styleText("gray", " 3-6. Serper, Brave, SearXNG, DuckDuckGo (fallbacks)"));
742
- console.log();
743
- console.log("Debug mode:");
744
- console.log(styleText("gray", " export DROID_SEARCH_DEBUG=1"));
2518
+ if (websearch) {
2519
+ console.log(styleText("cyan", "Auto-shutdown:"));
2520
+ console.log(styleText("gray", " Proxy auto-shuts down after 5 min idle (no manual cleanup needed)"));
2521
+ console.log(styleText("gray", " To disable: export DROID_PROXY_IDLE_TIMEOUT=0"));
2522
+ console.log();
2523
+ console.log("Search providers (in priority order):");
2524
+ console.log(styleText("yellow", " 1. Smithery Exa (best quality):"));
2525
+ console.log(styleText("gray", " export SMITHERY_API_KEY=your_api_key"));
2526
+ console.log(styleText("gray", " export SMITHERY_PROFILE=your_profile"));
2527
+ console.log(styleText("gray", " 2. Google PSE:"));
2528
+ console.log(styleText("gray", " export GOOGLE_PSE_API_KEY=your_api_key"));
2529
+ console.log(styleText("gray", " export GOOGLE_PSE_CX=your_search_engine_id"));
2530
+ console.log(styleText("gray", " 3-6. Serper, Brave, SearXNG, DuckDuckGo (fallbacks)"));
2531
+ console.log();
2532
+ console.log("Debug mode:");
2533
+ console.log(styleText("gray", " export DROID_SEARCH_DEBUG=1"));
2534
+ }
745
2535
  return;
746
2536
  }
747
- if (!isCustom && !skipLogin && !apiBase && !websearch && !reasoningEffort && !noTelemetry) {
2537
+ if (!isCustom && !skipLogin && !apiBase && !websearch && !statusline && !reasoningEffort && !noTelemetry) {
748
2538
  console.log(styleText("yellow", "No patch flags specified. Available patches:"));
749
2539
  console.log(styleText("gray", " --is-custom Patch isCustom for custom models"));
750
2540
  console.log(styleText("gray", " --skip-login Bypass login by injecting a fake API key"));
751
2541
  console.log(styleText("gray", " --api-base Replace API URL (standalone: max 22 chars; with --websearch: no limit)"));
752
2542
  console.log(styleText("gray", " --websearch Enable local WebSearch proxy"));
2543
+ console.log(styleText("gray", " --statusline Enable Claude-style statusline"));
753
2544
  console.log(styleText("gray", " --reasoning-effort Set reasoning effort level for custom models"));
754
2545
  console.log(styleText("gray", " --disable-telemetry Disable telemetry and Sentry error reporting"));
755
2546
  console.log(styleText("gray", " --standalone Standalone mode: mock non-LLM Factory APIs"));
@@ -760,6 +2551,8 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
760
2551
  console.log(styleText("cyan", " npx droid-patch --is-custom --skip-login droid-patched"));
761
2552
  console.log(styleText("cyan", " npx droid-patch --websearch droid-search"));
762
2553
  console.log(styleText("cyan", " npx droid-patch --websearch --standalone droid-local"));
2554
+ console.log(styleText("cyan", " npx droid-patch --statusline droid-status"));
2555
+ console.log(styleText("cyan", " npx droid-patch --websearch --statusline droid-search-ui"));
763
2556
  console.log(styleText("cyan", " npx droid-patch --disable-telemetry droid-private"));
764
2557
  console.log(styleText("cyan", " npx droid-patch --websearch --api-base=http://127.0.0.1:20002 my-droid"));
765
2558
  process.exit(1);
@@ -891,26 +2684,38 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
891
2684
  }
892
2685
  if (result.success && result.outputPath && alias) {
893
2686
  console.log();
2687
+ let execTargetPath = result.outputPath;
894
2688
  if (websearch) {
895
- const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), result.outputPath, alias, websearchTarget, standalone);
896
- await createAliasForWrapper(wrapperScript, alias, verbose);
2689
+ const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchTarget, standalone);
2690
+ execTargetPath = wrapperScript;
897
2691
  console.log();
898
2692
  console.log(styleText("cyan", "WebSearch enabled"));
899
2693
  console.log(styleText("white", ` Forward target: ${websearchTarget}`));
900
2694
  if (standalone) console.log(styleText("white", ` Standalone mode: enabled`));
901
- } else await createAlias(result.outputPath, alias, verbose);
2695
+ }
2696
+ if (statusline) {
2697
+ const { wrapperScript } = await createStatuslineFiles(join(homedir(), ".droid-patch", "statusline"), execTargetPath, alias);
2698
+ execTargetPath = wrapperScript;
2699
+ console.log();
2700
+ console.log(styleText("cyan", "Statusline enabled"));
2701
+ }
2702
+ let aliasResult;
2703
+ if (websearch || statusline) aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
2704
+ else aliasResult = await createAlias(result.outputPath, alias, verbose);
902
2705
  const droidVersion = getDroidVersion(path);
903
2706
  await saveAliasMetadata(createMetadata(alias, path, {
904
2707
  isCustom: !!isCustom,
905
2708
  skipLogin: !!skipLogin,
906
2709
  apiBase: apiBase || null,
907
2710
  websearch: !!websearch,
2711
+ statusline: !!statusline,
908
2712
  reasoningEffort: !!reasoningEffort,
909
2713
  noTelemetry: !!noTelemetry,
910
2714
  standalone: !!standalone
911
2715
  }, {
912
2716
  droidPatchVersion: version,
913
- droidVersion
2717
+ droidVersion,
2718
+ aliasPath: aliasResult.aliasPath
914
2719
  }));
915
2720
  }
916
2721
  if (result.success) {
@@ -927,7 +2732,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
927
2732
  }
928
2733
  }).command("list", "List all droid-patch aliases").action(async () => {
929
2734
  await listAliases();
930
- }).command("remove", "Remove alias(es) by name or filter").argument("[alias-or-path]", "Alias name or file path to remove").option("--patch-version <version>", "Remove aliases created by this droid-patch version").option("--droid-version <version>", "Remove aliases for this droid version").option("--flag <flag>", "Remove aliases with this flag (is-custom, skip-login, websearch, api-base, reasoning-effort, disable-telemetry, standalone)").action(async (options, args) => {
2735
+ }).command("remove", "Remove alias(es) by name or filter").argument("[alias-or-path]", "Alias name or file path to remove").option("--patch-version <version>", "Remove aliases created by this droid-patch version").option("--droid-version <version>", "Remove aliases for this droid version").option("--flag <flag>", "Remove aliases with this flag (is-custom, skip-login, websearch, statusline, api-base, reasoning-effort, disable-telemetry, standalone)").action(async (options, args) => {
931
2736
  const target = args?.[0];
932
2737
  const patchVersion = options["patch-version"];
933
2738
  const droidVersion = options["droid-version"];
@@ -1020,7 +2825,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
1020
2825
  pattern: Buffer.from("process.env.FACTORY_API_KEY"),
1021
2826
  replacement: Buffer.from("\"fk-droid-patch-skip-00000\"")
1022
2827
  });
1023
- if (meta.patches.apiBase) {
2828
+ if (meta.patches.apiBase && !meta.patches.websearch) {
1024
2829
  const originalUrl = "https://api.factory.ai";
1025
2830
  const paddedUrl = meta.patches.apiBase.padEnd(22, " ");
1026
2831
  patches.push({
@@ -1104,9 +2909,11 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
1104
2909
  console.log(styleText("yellow", ` [!] Could not re-sign binary`));
1105
2910
  }
1106
2911
  }
2912
+ let execTargetPath = patches.length > 0 ? outputPath : newBinaryPath;
1107
2913
  if (meta.patches.websearch || !!meta.patches.proxy) {
1108
2914
  const forwardTarget = meta.patches.apiBase || meta.patches.proxy || "https://api.factory.ai";
1109
- await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), patches.length > 0 ? outputPath : newBinaryPath, meta.name, forwardTarget, meta.patches.standalone || false);
2915
+ const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, meta.name, forwardTarget, meta.patches.standalone || false);
2916
+ execTargetPath = wrapperScript;
1110
2917
  if (verbose) {
1111
2918
  console.log(styleText("gray", ` Regenerated websearch wrapper`));
1112
2919
  if (meta.patches.standalone) console.log(styleText("gray", ` Standalone mode: enabled`));
@@ -1117,8 +2924,55 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
1117
2924
  delete meta.patches.proxy;
1118
2925
  }
1119
2926
  }
2927
+ if (meta.patches.statusline) {
2928
+ const { wrapperScript } = await createStatuslineFiles(join(homedir(), ".droid-patch", "statusline"), execTargetPath, meta.name);
2929
+ execTargetPath = wrapperScript;
2930
+ if (verbose) console.log(styleText("gray", ` Regenerated statusline wrapper`));
2931
+ }
2932
+ const { symlink: symlink$1, unlink: unlink$1, readlink: readlink$1, lstat } = await import("node:fs/promises");
2933
+ let aliasPath = meta.aliasPath;
2934
+ if (!aliasPath) {
2935
+ const commonPathDirs = [
2936
+ join(homedir(), ".local/bin"),
2937
+ join(homedir(), "bin"),
2938
+ join(homedir(), ".bin"),
2939
+ "/opt/homebrew/bin",
2940
+ "/usr/local/bin",
2941
+ join(homedir(), ".droid-patch", "aliases")
2942
+ ];
2943
+ for (const dir of commonPathDirs) {
2944
+ const possiblePath = join(dir, meta.name);
2945
+ if (existsSync(possiblePath)) try {
2946
+ if ((await lstat(possiblePath)).isSymbolicLink()) {
2947
+ const target = await readlink$1(possiblePath);
2948
+ if (target.includes(".droid-patch/bins") || target.includes(".droid-patch/proxy") || target.includes(".droid-patch/statusline")) {
2949
+ aliasPath = possiblePath;
2950
+ if (verbose) console.log(styleText("gray", ` Found existing symlink: ${aliasPath}`));
2951
+ break;
2952
+ }
2953
+ }
2954
+ } catch {}
2955
+ }
2956
+ }
2957
+ if (aliasPath) try {
2958
+ if (existsSync(aliasPath)) {
2959
+ if (await readlink$1(aliasPath) !== execTargetPath) {
2960
+ await unlink$1(aliasPath);
2961
+ await symlink$1(execTargetPath, aliasPath);
2962
+ if (verbose) console.log(styleText("gray", ` Updated symlink: ${aliasPath}`));
2963
+ }
2964
+ } else {
2965
+ await symlink$1(execTargetPath, aliasPath);
2966
+ if (verbose) console.log(styleText("gray", ` Recreated symlink: ${aliasPath}`));
2967
+ }
2968
+ meta.aliasPath = aliasPath;
2969
+ } catch (symlinkError) {
2970
+ console.log(styleText("yellow", ` [!] Could not update symlink: ${symlinkError.message}`));
2971
+ }
1120
2972
  meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1121
2973
  meta.originalBinaryPath = newBinaryPath;
2974
+ meta.droidVersion = getDroidVersion(newBinaryPath);
2975
+ meta.droidPatchVersion = version;
1122
2976
  await saveAliasMetadata(meta);
1123
2977
  console.log(styleText("green", ` ✓ Updated successfully`));
1124
2978
  successCount++;