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