droid-patch 0.9.0 → 0.11.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-DkFWCjWn.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-CX4QSelz.mjs";
3
3
  import bin from "tiny-bin";
4
4
  import { styleText } from "node:util";
5
5
  import { existsSync, readFileSync } from "node:fs";
@@ -9,21 +9,16 @@ import { fileURLToPath } from "node:url";
9
9
  import { execSync } from "node:child_process";
10
10
  import { chmod, mkdir, writeFile } from "node:fs/promises";
11
11
 
12
- //#region src/websearch-patch.ts
12
+ //#region src/websearch-external.ts
13
13
  /**
14
- * Generate search proxy server code (runs in background)
15
- * Since BUN_CONFIG_PRELOAD doesn't work with compiled binaries,
16
- * use a local proxy server to intercept search requests instead
14
+ * WebSearch External Providers Mode (--websearch)
17
15
  *
18
- * Each droid instance runs its own proxy server.
19
- * The proxy is killed automatically when droid exits.
20
- * @param factoryApiUrl - Custom Factory API URL (default: https://api.factory.ai)
16
+ * Priority: Smithery Exa > Google PSE > Serper > Brave > SearXNG > DuckDuckGo
21
17
  */
22
- function generateSearchProxyServer(factoryApiUrl = "https://api.factory.ai") {
18
+ function generateSearchProxyServerCode() {
23
19
  return `#!/usr/bin/env node
24
- // Droid WebSearch Proxy Server
25
- // Auto-generated by droid-patch --websearch
26
- // This proxy runs as a child process of droid and is killed when droid exits
20
+ // Droid WebSearch Proxy Server (External Providers Mode)
21
+ // Priority: Smithery Exa > Google PSE > Serper > Brave > SearXNG > DuckDuckGo
27
22
 
28
23
  const http = require('http');
29
24
  const https = require('https');
@@ -31,75 +26,38 @@ const { execSync } = require('child_process');
31
26
  const fs = require('fs');
32
27
 
33
28
  const DEBUG = process.env.DROID_SEARCH_DEBUG === '1';
34
- const PORT = parseInt(process.env.SEARCH_PROXY_PORT || '0'); // 0 = auto-assign
35
- const FACTORY_API = '${factoryApiUrl}';
29
+ const PORT = parseInt(process.env.SEARCH_PROXY_PORT || '0');
30
+ const FACTORY_API = 'https://api.factory.ai';
36
31
 
37
- function log(...args) {
38
- if (DEBUG) console.error('[websearch]', ...args);
39
- }
32
+ function log() { if (DEBUG) console.error.apply(console, ['[websearch]'].concat(Array.from(arguments))); }
40
33
 
41
- // === Search Implementation ===
34
+ // === External Search Providers ===
42
35
 
43
- // Smithery Exa MCP - highest priority, requires SMITHERY_API_KEY and SMITHERY_PROFILE
44
36
  async function searchSmitheryExa(query, numResults) {
45
37
  const apiKey = process.env.SMITHERY_API_KEY;
46
38
  const profile = process.env.SMITHERY_PROFILE;
47
39
  if (!apiKey || !profile) return null;
48
-
49
40
  try {
50
- // Construct URL with authentication
51
- const serverUrl = \`https://server.smithery.ai/exa/mcp?api_key=\${encodeURIComponent(apiKey)}&profile=\${encodeURIComponent(profile)}\`;
52
- log('Smithery Exa request');
53
-
54
- // Use MCP protocol to call the search tool via HTTP POST
55
- const requestBody = JSON.stringify({
56
- jsonrpc: '2.0',
57
- id: 1,
58
- method: 'tools/call',
59
- params: {
60
- name: 'web_search_exa',
61
- arguments: {
62
- query: query,
63
- numResults: numResults
64
- }
65
- }
66
- });
67
-
68
- const curlCmd = \`curl -s -X POST "\${serverUrl}" -H "Content-Type: application/json" -d '\${requestBody.replace(/'/g, "'\\\\\\\\''")}'\`;
69
- const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 30000 });
70
- const response = JSON.parse(jsonStr);
71
-
72
- // Parse MCP response
41
+ const serverUrl = 'https://server.smithery.ai/exa/mcp?api_key=' + encodeURIComponent(apiKey) + '&profile=' + encodeURIComponent(profile);
42
+ const requestBody = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'web_search_exa', arguments: { query: query, numResults: numResults } } });
43
+ const bodyStr = requestBody.replace(/'/g, "'\\\\''");
44
+ const curlCmd = 'curl -s -X POST "' + serverUrl + '" -H "Content-Type: application/json" -d \\'' + bodyStr + "\\'";
45
+ const response = JSON.parse(execSync(curlCmd, { encoding: 'utf-8', timeout: 30000 }));
73
46
  if (response.result && response.result.content) {
74
- // MCP returns content as array of text blocks
75
- const textContent = response.result.content.find(c => c.type === 'text');
47
+ const textContent = response.result.content.find(function(c) { return c.type === 'text'; });
76
48
  if (textContent && textContent.text) {
77
- try {
78
- const searchResults = JSON.parse(textContent.text);
79
- if (Array.isArray(searchResults) && searchResults.length > 0) {
80
- return searchResults.slice(0, numResults).map(item => ({
81
- title: item.title || '',
82
- url: item.url || '',
83
- content: item.text || item.snippet || item.highlights?.join(' ') || '',
84
- publishedDate: item.publishedDate || null,
85
- author: item.author || null,
86
- score: item.score || null
87
- }));
88
- }
89
- } catch (parseErr) {
90
- log('Smithery response parsing failed');
49
+ const results = JSON.parse(textContent.text);
50
+ if (Array.isArray(results) && results.length > 0) {
51
+ return results.slice(0, numResults).map(function(item) {
52
+ return {
53
+ title: item.title || '', url: item.url || '',
54
+ content: item.text || item.snippet || (item.highlights ? item.highlights.join(' ') : '') || ''
55
+ };
56
+ });
91
57
  }
92
58
  }
93
59
  }
94
-
95
- if (response.error) {
96
- log('Smithery Exa error:', response.error.message || response.error);
97
- return null;
98
- }
99
- } catch (e) {
100
- log('Smithery Exa failed:', e.message);
101
- return null;
102
- }
60
+ } catch (e) { log('Smithery failed:', e.message); }
103
61
  return null;
104
62
  }
105
63
 
@@ -107,3228 +65,554 @@ async function searchGooglePSE(query, numResults) {
107
65
  const apiKey = process.env.GOOGLE_PSE_API_KEY;
108
66
  const cx = process.env.GOOGLE_PSE_CX;
109
67
  if (!apiKey || !cx) return null;
110
-
111
- try {
112
- const url = \`https://www.googleapis.com/customsearch/v1?key=\${apiKey}&cx=\${cx}&q=\${encodeURIComponent(query)}&num=\${Math.min(numResults, 10)}\`;
113
- log('Google PSE request:', url.replace(apiKey, '***'));
114
-
115
- const curlCmd = \`curl -s "\${url}"\`;
116
- const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
117
- const data = JSON.parse(jsonStr);
118
-
119
- if (data.error) {
120
- log('Google PSE error:', data.error.message);
121
- return null;
122
- }
123
- return (data.items || []).map(item => ({
124
- title: item.title,
125
- url: item.link,
126
- content: item.snippet || '',
127
- publishedDate: null,
128
- author: null,
129
- score: null
130
- }));
131
- } catch (e) {
132
- log('Google PSE failed:', e.message);
133
- return null;
134
- }
135
- }
136
-
137
- // SearXNG - self-hosted meta search engine
138
- async function searchSearXNG(query, numResults) {
139
- const searxngUrl = process.env.SEARXNG_URL;
140
- if (!searxngUrl) return null;
141
-
142
68
  try {
143
- const url = \`\${searxngUrl}/search?q=\${encodeURIComponent(query)}&format=json&engines=google,bing,duckduckgo\`;
144
- log('SearXNG request:', url);
145
-
146
- const curlCmd = \`curl -s "\${url}" -H "Accept: application/json"\`;
147
- const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
148
- const data = JSON.parse(jsonStr);
149
-
150
- if (data.results && data.results.length > 0) {
151
- return data.results.slice(0, numResults).map(item => ({
152
- title: item.title,
153
- url: item.url,
154
- content: item.content || '',
155
- publishedDate: null,
156
- author: null,
157
- score: null
158
- }));
159
- }
160
- } catch (e) {
161
- log('SearXNG failed:', e.message);
162
- }
69
+ const url = 'https://www.googleapis.com/customsearch/v1?key=' + apiKey + '&cx=' + cx + '&q=' + encodeURIComponent(query) + '&num=' + Math.min(numResults, 10);
70
+ const data = JSON.parse(execSync('curl -s "' + url + '"', { encoding: 'utf-8', timeout: 15000 }));
71
+ if (data.error) return null;
72
+ return (data.items || []).map(function(item) { return { title: item.title, url: item.link, content: item.snippet || '' }; });
73
+ } catch (e) { log('Google PSE failed:', e.message); }
163
74
  return null;
164
75
  }
165
76
 
166
- // Serper API - free tier available (2500 queries/month)
167
77
  async function searchSerper(query, numResults) {
168
78
  const apiKey = process.env.SERPER_API_KEY;
169
79
  if (!apiKey) return null;
170
-
171
80
  try {
172
- const curlCmd = \`curl -s "https://google.serper.dev/search" -H "X-API-KEY: \${apiKey}" -H "Content-Type: application/json" -d '{"q":"\${query.replace(/"/g, '\\\\"')}","num":\${numResults}}'\`;
173
- log('Serper request');
174
-
175
- const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
176
- const data = JSON.parse(jsonStr);
177
-
81
+ const bodyStr = JSON.stringify({ q: query, num: numResults }).replace(/'/g, "'\\\\''");
82
+ const curlCmd = 'curl -s "https://google.serper.dev/search" -H "X-API-KEY: ' + apiKey + '" -H "Content-Type: application/json" -d \\'' + bodyStr + "\\'";
83
+ const data = JSON.parse(execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 }));
178
84
  if (data.organic && data.organic.length > 0) {
179
- return data.organic.slice(0, numResults).map(item => ({
180
- title: item.title,
181
- url: item.link,
182
- content: item.snippet || '',
183
- publishedDate: null,
184
- author: null,
185
- score: null
186
- }));
85
+ return data.organic.slice(0, numResults).map(function(item) { return { title: item.title, url: item.link, content: item.snippet || '' }; });
187
86
  }
188
- } catch (e) {
189
- log('Serper failed:', e.message);
190
- }
87
+ } catch (e) { log('Serper failed:', e.message); }
191
88
  return null;
192
89
  }
193
90
 
194
- // Brave Search API - free tier available
195
91
  async function searchBrave(query, numResults) {
196
92
  const apiKey = process.env.BRAVE_API_KEY;
197
93
  if (!apiKey) return null;
198
-
199
94
  try {
200
- const url = \`https://api.search.brave.com/res/v1/web/search?q=\${encodeURIComponent(query)}&count=\${numResults}\`;
201
- const curlCmd = \`curl -s "\${url}" -H "Accept: application/json" -H "X-Subscription-Token: \${apiKey}"\`;
202
- log('Brave request');
203
-
204
- const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
205
- const data = JSON.parse(jsonStr);
206
-
95
+ const url = 'https://api.search.brave.com/res/v1/web/search?q=' + encodeURIComponent(query) + '&count=' + numResults;
96
+ const curlCmd = 'curl -s "' + url + '" -H "Accept: application/json" -H "X-Subscription-Token: ' + apiKey + '"';
97
+ const data = JSON.parse(execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 }));
207
98
  if (data.web && data.web.results && data.web.results.length > 0) {
208
- return data.web.results.slice(0, numResults).map(item => ({
209
- title: item.title,
210
- url: item.url,
211
- content: item.description || '',
212
- publishedDate: null,
213
- author: null,
214
- score: null
215
- }));
99
+ return data.web.results.slice(0, numResults).map(function(item) { return { title: item.title, url: item.url, content: item.description || '' }; });
216
100
  }
217
- } catch (e) {
218
- log('Brave failed:', e.message);
219
- }
101
+ } catch (e) { log('Brave failed:', e.message); }
220
102
  return null;
221
103
  }
222
104
 
223
- // DuckDuckGo - limited reliability due to bot detection
224
- async function searchDuckDuckGo(query, numResults) {
225
- // DuckDuckGo Instant Answer API (limited results but more reliable)
105
+ async function searchSearXNG(query, numResults) {
106
+ const searxngUrl = process.env.SEARXNG_URL;
107
+ if (!searxngUrl) return null;
226
108
  try {
227
- const apiUrl = \`https://api.duckduckgo.com/?q=\${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1\`;
228
- const curlCmd = \`curl -s "\${apiUrl}" -H "User-Agent: Mozilla/5.0"\`;
229
- const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
230
- const data = JSON.parse(jsonStr);
109
+ const url = searxngUrl + '/search?q=' + encodeURIComponent(query) + '&format=json&engines=google,bing,duckduckgo';
110
+ const data = JSON.parse(execSync('curl -s "' + url + '" -H "Accept: application/json"', { encoding: 'utf-8', timeout: 15000 }));
111
+ if (data.results && data.results.length > 0) {
112
+ return data.results.slice(0, numResults).map(function(item) { return { title: item.title, url: item.url, content: item.content || '' }; });
113
+ }
114
+ } catch (e) { log('SearXNG failed:', e.message); }
115
+ return null;
116
+ }
231
117
 
118
+ async function searchDuckDuckGo(query, numResults) {
119
+ try {
120
+ const apiUrl = 'https://api.duckduckgo.com/?q=' + encodeURIComponent(query) + '&format=json&no_html=1&skip_disambig=1';
121
+ const data = JSON.parse(execSync('curl -s "' + apiUrl + '" -H "User-Agent: Mozilla/5.0"', { encoding: 'utf-8', timeout: 15000 }));
232
122
  const results = [];
233
-
234
123
  if (data.Abstract && data.AbstractURL) {
235
- results.push({
236
- title: data.Heading || query,
237
- url: data.AbstractURL,
238
- content: data.Abstract,
239
- publishedDate: null,
240
- author: null,
241
- score: null
242
- });
124
+ results.push({ title: data.Heading || query, url: data.AbstractURL, content: data.Abstract });
243
125
  }
244
-
245
- for (const topic of (data.RelatedTopics || [])) {
246
- if (results.length >= numResults) break;
247
- if (topic.Text && topic.FirstURL) {
248
- results.push({
249
- title: topic.Text.substring(0, 100),
250
- url: topic.FirstURL,
251
- content: topic.Text,
252
- publishedDate: null,
253
- author: null,
254
- score: null
255
- });
256
- }
126
+ var topics = data.RelatedTopics || [];
127
+ for (var i = 0; i < topics.length && results.length < numResults; i++) {
128
+ var topic = topics[i];
129
+ if (topic.Text && topic.FirstURL) results.push({ title: topic.Text.substring(0, 100), url: topic.FirstURL, content: topic.Text });
257
130
  if (topic.Topics) {
258
- for (const st of topic.Topics) {
259
- if (results.length >= numResults) break;
260
- if (st.Text && st.FirstURL) {
261
- results.push({
262
- title: st.Text.substring(0, 100),
263
- url: st.FirstURL,
264
- content: st.Text,
265
- publishedDate: null,
266
- author: null,
267
- score: null
268
- });
269
- }
131
+ for (var j = 0; j < topic.Topics.length && results.length < numResults; j++) {
132
+ var st = topic.Topics[j];
133
+ if (st.Text && st.FirstURL) results.push({ title: st.Text.substring(0, 100), url: st.FirstURL, content: st.Text });
270
134
  }
271
135
  }
272
136
  }
273
-
274
- if (results.length > 0) {
275
- log('DDG API:', results.length, 'results');
276
- return results;
277
- }
278
- } catch (e) {
279
- log('DDG API failed:', e.message);
280
- }
281
-
282
- return [];
283
- }
284
-
285
- function parseDDGLiteHTML(html, maxResults) {
286
- const results = [];
287
- const linkRegex = /<a[^>]+rel="nofollow"[^>]+href="([^"]+)"[^>]*>([^<]+)<\\/a>/gi;
288
- const snippetRegex = /<td[^>]*class="result-snippet"[^>]*>([^<]*)<\\/td>/gi;
289
-
290
- const links = [];
291
- let match;
292
-
293
- while ((match = linkRegex.exec(html)) !== null && links.length < maxResults) {
294
- let url = match[1];
295
- if (url.includes('duckduckgo.com') && !url.includes('uddg=')) continue;
296
- if (url.includes('uddg=')) {
297
- const uddgMatch = url.match(/uddg=([^&]+)/);
298
- if (uddgMatch) url = decodeURIComponent(uddgMatch[1]);
299
- }
300
- links.push({
301
- url: url,
302
- title: decodeHTMLEntities(match[2].trim())
303
- });
304
- }
305
-
306
- const snippets = [];
307
- while ((match = snippetRegex.exec(html)) !== null && snippets.length < maxResults) {
308
- snippets.push(decodeHTMLEntities(match[1].trim()));
309
- }
310
-
311
- for (let i = 0; i < links.length && results.length < maxResults; i++) {
312
- results.push({
313
- title: links[i].title,
314
- url: links[i].url,
315
- content: snippets[i] || '',
316
- publishedDate: null,
317
- author: null,
318
- score: null
319
- });
320
- }
321
-
322
- return results;
323
- }
324
-
325
- function decodeHTMLEntities(str) {
326
- return str
327
- .replace(/&amp;/g, '&')
328
- .replace(/&lt;/g, '<')
329
- .replace(/&gt;/g, '>')
330
- .replace(/&quot;/g, '"')
331
- .replace(/&#39;/g, "'")
332
- .replace(/&nbsp;/g, ' ');
137
+ return results.length > 0 ? results : null;
138
+ } catch (e) { log('DuckDuckGo failed:', e.message); }
139
+ return null;
333
140
  }
334
141
 
335
- async function search(query, numResults = 10) {
336
- // Priority order:
337
- // 1. Smithery Exa MCP (best quality if configured)
338
- // 2. Google PSE (most reliable if configured)
339
- // 3. Serper (free tier: 2500/month)
340
- // 4. Brave Search (free tier available)
341
- // 5. SearXNG (self-hosted)
342
- // 6. DuckDuckGo (limited due to bot detection)
343
-
344
- // 1. Smithery Exa MCP (highest priority)
345
- const smitheryResults = await searchSmitheryExa(query, numResults);
346
- if (smitheryResults && smitheryResults.length > 0) {
347
- log('Using Smithery Exa');
348
- return { results: smitheryResults, source: 'smithery-exa' };
349
- }
350
-
351
- // 2. Google PSE
352
- const googleResults = await searchGooglePSE(query, numResults);
353
- if (googleResults && googleResults.length > 0) {
354
- log('Using Google PSE');
355
- return { results: googleResults, source: 'google-pse' };
356
- }
357
-
358
- // 3. Serper
359
- const serperResults = await searchSerper(query, numResults);
360
- if (serperResults && serperResults.length > 0) {
361
- log('Using Serper');
362
- return { results: serperResults, source: 'serper' };
363
- }
364
-
365
- // 4. Brave Search
366
- const braveResults = await searchBrave(query, numResults);
367
- if (braveResults && braveResults.length > 0) {
368
- log('Using Brave Search');
369
- return { results: braveResults, source: 'brave' };
370
- }
371
-
372
- // 5. SearXNG
373
- const searxngResults = await searchSearXNG(query, numResults);
374
- if (searxngResults && searxngResults.length > 0) {
375
- log('Using SearXNG');
376
- return { results: searxngResults, source: 'searxng' };
377
- }
378
-
379
- // 6. DuckDuckGo (last resort, limited results)
380
- log('Using DuckDuckGo (fallback)');
381
- const ddgResults = await searchDuckDuckGo(query, numResults);
382
- return { results: ddgResults, source: 'duckduckgo' };
142
+ async function search(query, numResults) {
143
+ numResults = numResults || 10;
144
+ log('Search:', query);
145
+
146
+ // Priority: Smithery > Google PSE > Serper > Brave > SearXNG > DuckDuckGo
147
+ var results = await searchSmitheryExa(query, numResults);
148
+ if (results && results.length > 0) return { results: results, source: 'smithery-exa' };
149
+
150
+ results = await searchGooglePSE(query, numResults);
151
+ if (results && results.length > 0) return { results: results, source: 'google-pse' };
152
+
153
+ results = await searchSerper(query, numResults);
154
+ if (results && results.length > 0) return { results: results, source: 'serper' };
155
+
156
+ results = await searchBrave(query, numResults);
157
+ if (results && results.length > 0) return { results: results, source: 'brave' };
158
+
159
+ results = await searchSearXNG(query, numResults);
160
+ if (results && results.length > 0) return { results: results, source: 'searxng' };
161
+
162
+ results = await searchDuckDuckGo(query, numResults);
163
+ if (results && results.length > 0) return { results: results, source: 'duckduckgo' };
164
+
165
+ return { results: [], source: 'none' };
383
166
  }
384
167
 
385
168
  // === HTTP Proxy Server ===
386
169
 
387
170
  const server = http.createServer(async (req, res) => {
388
- const url = new URL(req.url, \`http://\${req.headers.host}\`);
171
+ const url = new URL(req.url, 'http://' + req.headers.host);
389
172
 
390
- // Health check
391
173
  if (url.pathname === '/health') {
392
- res.writeHead(200, { 'Content-Type': 'application/json' });
393
- res.end(JSON.stringify({ status: 'ok', port: server.address()?.port || PORT }));
394
- return;
395
- }
396
-
397
- // Search endpoint - intercept
398
- if (url.pathname === '/api/tools/exa/search' && req.method === 'POST') {
399
- let body = '';
400
- req.on('data', c => body += c);
401
- req.on('end', async () => {
402
- try {
403
- const { query, numResults } = JSON.parse(body);
404
- log('Search query:', query);
405
- const { results, source } = await search(query, numResults || 10);
406
- log('Results:', results.length, 'from', source);
407
- res.writeHead(200, { 'Content-Type': 'application/json' });
408
- res.end(JSON.stringify({ results }));
409
- } catch (e) {
410
- log('Search error:', e.message);
411
- res.writeHead(500, { 'Content-Type': 'application/json' });
412
- res.end(JSON.stringify({ error: String(e), results: [] }));
413
- }
414
- });
415
- return;
416
- }
417
-
418
- // === Standalone mode (controlled by STANDALONE_MODE env) ===
419
- // Whitelist approach: only allow core LLM APIs, mock everything else
420
- if (process.env.STANDALONE_MODE === '1') {
421
- const pathname = url.pathname;
422
-
423
- // Whitelist: Core APIs that should be forwarded to upstream
424
- const isCoreLLMApi = pathname.startsWith('/api/llm/a/') || pathname.startsWith('/api/llm/o/');
425
- // /api/tools/exa/search is already handled above
426
-
427
- if (!isCoreLLMApi) {
428
- // Special handling for specific routes
429
- if (pathname === '/api/sessions/create') {
430
- log('Mock (dynamic):', pathname);
431
- const sessionId = \`local-\${Date.now()}-\${Math.random().toString(36).slice(2, 10)}\`;
432
- res.writeHead(200, { 'Content-Type': 'application/json' });
433
- res.end(JSON.stringify({ id: sessionId }));
434
- return;
435
- }
436
-
437
- if (pathname === '/api/cli/whoami') {
438
- log('Mock (401):', pathname);
439
- res.writeHead(401, { 'Content-Type': 'application/json' });
440
- res.end(JSON.stringify({ error: 'Unauthorized', message: 'Local mode - use token fallback' }));
441
- return;
442
- }
443
-
444
- if (pathname === '/api/tools/get-url-contents') {
445
- log('Mock (404):', pathname);
446
- res.writeHead(404, { 'Content-Type': 'application/json' });
447
- res.end(JSON.stringify({ error: 'Not available', message: 'Use local URL fetch fallback' }));
448
- return;
449
- }
450
-
451
- // All other non-core APIs: return empty success
452
- log('Mock (default):', pathname);
453
- res.writeHead(200, { 'Content-Type': 'application/json' });
454
- res.end(JSON.stringify({}));
455
- return;
456
- }
457
- }
458
-
459
- // Proxy core LLM requests to upstream API
460
- log('Proxy:', req.method, url.pathname);
461
-
462
- const proxyUrl = new URL(FACTORY_API + url.pathname + url.search);
463
- // Choose http or https based on target protocol
464
- const proxyModule = proxyUrl.protocol === 'https:' ? https : http;
465
- const proxyReq = proxyModule.request(proxyUrl, {
466
- method: req.method,
467
- headers: { ...req.headers, host: proxyUrl.host }
468
- }, proxyRes => {
469
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
470
- proxyRes.pipe(res);
471
- });
472
-
473
- proxyReq.on('error', e => {
474
- log('Proxy error:', e.message);
475
- res.writeHead(502, { 'Content-Type': 'application/json' });
476
- res.end(JSON.stringify({ error: 'Proxy failed: ' + e.message }));
477
- });
478
-
479
- if (req.method !== 'GET' && req.method !== 'HEAD') {
480
- req.pipe(proxyReq);
481
- } else {
482
- proxyReq.end();
483
- }
484
- });
485
-
486
- // If port is 0, system will automatically assign an available port
487
- server.listen(PORT, '127.0.0.1', () => {
488
- const actualPort = server.address().port;
489
- const hasGoogle = process.env.GOOGLE_PSE_API_KEY && process.env.GOOGLE_PSE_CX;
490
-
491
- // Write port file for parent process to read
492
- const portFile = process.env.SEARCH_PROXY_PORT_FILE;
493
- if (portFile) {
494
- fs.writeFileSync(portFile, String(actualPort));
495
- }
496
-
497
- // Output PORT= line for wrapper script to parse
498
- console.log('PORT=' + actualPort);
499
-
500
- const hasSmithery = process.env.SMITHERY_API_KEY && process.env.SMITHERY_PROFILE;
501
- log('Search proxy started on http://127.0.0.1:' + actualPort);
502
- log('Smithery Exa:', hasSmithery ? 'configured (priority 1)' : 'not set');
503
- log('Google PSE:', hasGoogle ? 'configured' : 'not set');
504
- log('Serper:', process.env.SERPER_API_KEY ? 'configured' : 'not set');
505
- log('Brave:', process.env.BRAVE_API_KEY ? 'configured' : 'not set');
506
- log('SearXNG:', process.env.SEARXNG_URL || 'not set');
507
- });
508
-
509
- process.on('SIGTERM', () => { server.close(); process.exit(0); });
510
- process.on('SIGINT', () => { server.close(); process.exit(0); });
511
- `;
512
- }
513
- /**
514
- * Generate unified Wrapper script
515
- * Each droid instance runs its own proxy:
516
- * - Uses port 0 to let system auto-assign available port
517
- * - Proxy runs as child process
518
- * - Proxy is killed when droid exits
519
- * - Supports multiple droid instances running simultaneously
520
- */
521
- function generateUnifiedWrapper(droidPath, proxyScriptPath, standalone = false) {
522
- const standaloneEnv = standalone ? "STANDALONE_MODE=1 " : "";
523
- return `#!/bin/bash
524
- # Droid with WebSearch
525
- # Auto-generated by droid-patch --websearch
526
- # Each instance runs its own proxy on a system-assigned port
527
-
528
- PROXY_SCRIPT="${proxyScriptPath}"
529
- DROID_BIN="${droidPath}"
530
- PROXY_PID=""
531
- PORT_FILE="/tmp/droid-websearch-\$\$.port"
532
- STANDALONE="${standalone ? "1" : "0"}"
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
-
573
- # Cleanup function - kill proxy when droid exits
574
- cleanup() {
575
- if [ -n "\$PROXY_PID" ] && kill -0 "\$PROXY_PID" 2>/dev/null; then
576
- [ -n "\$DROID_SEARCH_DEBUG" ] && echo "[websearch] Stopping proxy (PID: \$PROXY_PID)" >&2
577
- kill "\$PROXY_PID" 2>/dev/null
578
- wait "\$PROXY_PID" 2>/dev/null
579
- fi
580
- rm -f "\$PORT_FILE"
581
- }
582
-
583
- # Set up trap to cleanup on exit
584
- trap cleanup EXIT INT TERM
585
-
586
- [ -n "\$DROID_SEARCH_DEBUG" ] && echo "[websearch] Starting proxy..." >&2
587
- [ "\$STANDALONE" = "1" ] && [ -n "\$DROID_SEARCH_DEBUG" ] && echo "[websearch] Standalone mode enabled" >&2
588
-
589
- # Start proxy with port 0 (system will assign available port)
590
- # Proxy writes actual port to PORT_FILE
591
- if [ -n "\$DROID_SEARCH_DEBUG" ]; then
592
- ${standaloneEnv}SEARCH_PROXY_PORT=0 SEARCH_PROXY_PORT_FILE="\$PORT_FILE" node "\$PROXY_SCRIPT" 2>&1 &
593
- else
594
- ${standaloneEnv}SEARCH_PROXY_PORT=0 SEARCH_PROXY_PORT_FILE="\$PORT_FILE" node "\$PROXY_SCRIPT" >/dev/null 2>&1 &
595
- fi
596
- PROXY_PID=\$!
597
-
598
- # Wait for proxy to start and get actual port (max 5 seconds)
599
- for i in {1..50}; do
600
- # Check if proxy process is still running
601
- if ! kill -0 "\$PROXY_PID" 2>/dev/null; then
602
- [ -n "\$DROID_SEARCH_DEBUG" ] && echo "[websearch] Proxy process died" >&2
603
- break
604
- fi
605
- if [ -f "\$PORT_FILE" ]; then
606
- ACTUAL_PORT=\$(cat "\$PORT_FILE" 2>/dev/null)
607
- if [ -n "\$ACTUAL_PORT" ] && curl -s "http://127.0.0.1:\$ACTUAL_PORT/health" > /dev/null 2>&1; then
608
- [ -n "\$DROID_SEARCH_DEBUG" ] && echo "[websearch] Proxy ready on port \$ACTUAL_PORT (PID: \$PROXY_PID)" >&2
609
- break
610
- fi
611
- fi
612
- sleep 0.1
613
- done
614
-
615
- # Check if proxy started successfully
616
- if [ ! -f "\$PORT_FILE" ] || [ -z "\$(cat "\$PORT_FILE" 2>/dev/null)" ]; then
617
- echo "[websearch] Failed to start proxy, running without websearch" >&2
618
- cleanup
619
- exec "\$DROID_BIN" "\$@"
620
- fi
621
-
622
- ACTUAL_PORT=\$(cat "\$PORT_FILE")
623
- rm -f "\$PORT_FILE"
624
-
625
- # Run droid with proxy
626
- export FACTORY_API_BASE_URL_OVERRIDE="http://127.0.0.1:\$ACTUAL_PORT"
627
- "\$DROID_BIN" "\$@"
628
- DROID_EXIT_CODE=\$?
629
-
630
- # Cleanup will be called by trap
631
- exit \$DROID_EXIT_CODE
632
- `;
633
- }
634
- /**
635
- * Create unified WebSearch files
636
- *
637
- * Approach: Proxy server mode
638
- * - wrapper script starts local proxy server
639
- * - proxy server intercepts search requests, passes through other requests
640
- * - uses FACTORY_API_BASE_URL_OVERRIDE env var to point to proxy
641
- * - alias works directly, no extra steps needed
642
- *
643
- * @param outputDir - Directory to write files to
644
- * @param droidPath - Path to droid binary
645
- * @param aliasName - Alias name for the wrapper
646
- * @param apiBase - Custom API base URL for proxy to forward requests to
647
- * @param standalone - Standalone mode: mock non-LLM Factory APIs
648
- */
649
- async function createWebSearchUnifiedFiles(outputDir, droidPath, aliasName, apiBase, standalone = false) {
650
- if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true });
651
- const proxyScriptPath = join(outputDir, `${aliasName}-proxy.js`);
652
- const wrapperScriptPath = join(outputDir, aliasName);
653
- await writeFile(proxyScriptPath, generateSearchProxyServer(apiBase || "https://api.factory.ai"));
654
- console.log(`[*] Created proxy script: ${proxyScriptPath}`);
655
- await writeFile(wrapperScriptPath, generateUnifiedWrapper(droidPath, proxyScriptPath, standalone));
656
- await chmod(wrapperScriptPath, 493);
657
- console.log(`[*] Created wrapper: ${wrapperScriptPath}`);
658
- if (standalone) console.log(`[*] Standalone mode enabled`);
659
- return {
660
- wrapperScript: wrapperScriptPath,
661
- preloadScript: proxyScriptPath
662
- };
663
- }
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
- const SESSION_ID_RE = /"sessionId":"([0-9a-f-]{36})"/i;
694
-
695
- function sleep(ms) {
696
- return new Promise((r) => setTimeout(r, ms));
697
- }
698
-
699
- function isPositiveInt(n) {
700
- return Number.isFinite(n) && n > 0;
701
- }
702
-
703
- function extractSessionIdFromLine(line) {
704
- if (!line) return null;
705
- const m = String(line).match(SESSION_ID_RE);
706
- return m ? m[1] : null;
707
- }
708
-
709
- function parseLineTimestampMs(line) {
710
- const s = String(line || '');
711
- if (!s || s[0] !== '[') return null;
712
- const end = s.indexOf(']');
713
- if (end <= 1) return null;
714
- const raw = s.slice(1, end);
715
- const ms = Date.parse(raw);
716
- return Number.isFinite(ms) ? ms : null;
717
- }
718
-
719
- function safeStatMtimeMs(p) {
720
- try {
721
- const stat = fs.statSync(p);
722
- const ms = Number(stat?.mtimeMs ?? 0);
723
- return Number.isFinite(ms) ? ms : 0;
724
- } catch {
725
- return 0;
726
- }
727
- }
728
-
729
- function nextCompactionState(line, current) {
730
- if (!line) return current;
731
- if (line.includes('[Compaction] Start')) return true;
732
- const endMarkers = ['End', 'Done', 'Finish', 'Finished', 'Complete', 'Completed'];
733
- if (endMarkers.some(m => line.includes('[Compaction] ' + m))) return false;
734
- return current;
735
- }
736
-
737
- function firstNonNull(promises) {
738
- const list = Array.isArray(promises) ? promises : [];
739
- if (list.length === 0) return Promise.resolve(null);
740
- return new Promise((resolve) => {
741
- let pending = list.length;
742
- let done = false;
743
- for (const p of list) {
744
- Promise.resolve(p)
745
- .then((value) => {
746
- if (done) return;
747
- if (value) {
748
- done = true;
749
- resolve(value);
750
- return;
751
- }
752
- pending -= 1;
753
- if (pending <= 0) resolve(null);
754
- })
755
- .catch(() => {
756
- if (done) return;
757
- pending -= 1;
758
- if (pending <= 0) resolve(null);
759
- });
760
- }
761
- });
762
- }
763
-
764
- function listPidsInProcessGroup(pgid) {
765
- if (!isPositiveInt(pgid)) return [];
766
- try {
767
- const res = spawnSync('ps', ['-ax', '-o', 'pid=,pgid='], {
768
- encoding: 'utf8',
769
- stdio: ['ignore', 'pipe', 'ignore'],
770
- timeout: 800,
771
- });
772
- if (!res || res.status !== 0) return [];
773
- const out = String(res.stdout || '');
774
- const pids = [];
775
- for (const line of out.split('\\n')) {
776
- const parts = line.trim().split(/\\s+/);
777
- if (parts.length < 2) continue;
778
- const pid = Number(parts[0]);
779
- const g = Number(parts[1]);
780
- if (Number.isFinite(pid) && g === pgid) pids.push(pid);
781
- }
782
- return pids;
783
- } catch {
784
- return [];
785
- }
786
- }
787
-
788
- function resolveOpenSessionFromPids(pids) {
789
- if (!Array.isArray(pids) || pids.length === 0) return null;
790
- // lsof prints file names as lines prefixed with "n" when using -Fn
791
- try {
792
- const res = spawnSync('lsof', ['-p', pids.join(','), '-Fn'], {
793
- encoding: 'utf8',
794
- stdio: ['ignore', 'pipe', 'ignore'],
795
- timeout: 1200,
796
- });
797
- if (!res || res.status !== 0) return null;
798
- const out = String(res.stdout || '');
799
- for (const line of out.split('\\n')) {
800
- if (!line || line[0] !== 'n') continue;
801
- const name = line.slice(1);
802
- if (!name.startsWith(SESSIONS_ROOT + path.sep)) continue;
803
- const m = name.match(/([0-9a-f-]{36})\\.(jsonl|settings\\.json)$/i);
804
- if (!m) continue;
805
- const id = m[1];
806
- const workspaceDir = path.dirname(name);
807
- if (path.dirname(workspaceDir) !== SESSIONS_ROOT) continue;
808
- return { workspaceDir, id };
809
- }
810
- } catch {
811
- return null;
812
- }
813
- return null;
814
- }
815
-
816
- async function resolveSessionFromProcessGroup(shouldAbort, maxTries = 20) {
817
- if (!isPositiveInt(PGID)) return null;
818
- // Wait a little for droid to create/open the session files.
819
- for (let i = 0; i < maxTries; i++) {
820
- if (shouldAbort && shouldAbort()) return null;
821
- const pids = listPidsInProcessGroup(PGID);
822
- const found = resolveOpenSessionFromPids(pids);
823
- if (found) return found;
824
- await sleep(100);
825
- }
826
- return null;
827
- }
828
-
829
- function safeReadFile(filePath) {
830
- try {
831
- return fs.readFileSync(filePath, 'utf8');
832
- } catch {
833
- return null;
834
- }
835
- }
836
-
837
- function safeJsonParse(text) {
838
- if (!text) return null;
839
- try {
840
- // Factory settings/config files can contain comments. Strip them safely without
841
- // breaking URLs like "http://..." which contain "//" inside strings.
842
- const stripComments = (input) => {
843
- let out = '';
844
- let inString = false;
845
- let escape = false;
846
- for (let i = 0; i < input.length; i++) {
847
- const ch = input[i];
848
- const next = input[i + 1];
849
-
850
- if (inString) {
851
- out += ch;
852
- if (escape) {
853
- escape = false;
854
- continue;
855
- }
856
- if (ch === '\\\\') {
857
- escape = true;
858
- continue;
859
- }
860
- if (ch === '"') {
861
- inString = false;
862
- }
863
- continue;
864
- }
865
-
866
- if (ch === '"') {
867
- inString = true;
868
- out += ch;
869
- continue;
870
- }
871
-
872
- // Line comment
873
- if (ch === '/' && next === '/') {
874
- while (i < input.length && input[i] !== '\\n') i++;
875
- out += '\\n';
876
- continue;
877
- }
878
-
879
- // Block comment
880
- if (ch === '/' && next === '*') {
881
- i += 2;
882
- while (i < input.length && !(input[i] === '*' && input[i + 1] === '/')) i++;
883
- i += 1;
884
- continue;
885
- }
886
-
887
- out += ch;
888
- }
889
- return out;
890
- };
891
-
892
- return JSON.parse(stripComments(text));
893
- } catch {
894
- return null;
895
- }
896
- }
897
-
898
- function readJsonFile(filePath) {
899
- return safeJsonParse(safeReadFile(filePath));
900
- }
901
-
902
- function isUuid(text) {
903
- return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text);
904
- }
905
-
906
- function parseResume(args) {
907
- for (let i = 0; i < args.length; i++) {
908
- const a = args[i];
909
- if (a === '-r' || a === '--resume') {
910
- const next = args[i + 1];
911
- if (next && isUuid(next)) return { resumeFlag: true, resumeId: next };
912
- return { resumeFlag: true, resumeId: null };
913
- }
914
- if (a.startsWith('--resume=')) {
915
- const value = a.slice('--resume='.length);
916
- return { resumeFlag: true, resumeId: isUuid(value) ? value : null };
917
- }
918
- }
919
- return { resumeFlag: false, resumeId: null };
920
- }
921
-
922
- function sanitizeWorkspaceDirName(cwd) {
923
- return String(cwd)
924
- .replace(/[:]/g, '')
925
- .replace(/[\\\\/]/g, '-')
926
- .replace(/\\s+/g, '-');
927
- }
928
-
929
- function listSessionCandidates(workspaceDir) {
930
- let files = [];
931
- try {
932
- files = fs.readdirSync(workspaceDir);
933
- } catch {
934
- return [];
935
- }
936
- const candidates = [];
937
- for (const file of files) {
938
- const m = file.match(/^([0-9a-f-]{36})\\.(jsonl|settings\\.json)$/i);
939
- if (!m) continue;
940
- const id = m[1];
941
- const fullPath = path.join(workspaceDir, file);
942
- try {
943
- const stat = fs.statSync(fullPath);
944
- candidates.push({ id, fullPath, mtimeMs: stat.mtimeMs });
945
- } catch {
946
- // ignore
947
- }
948
- }
949
- return candidates;
950
- }
951
-
952
- function findWorkspaceDirForSessionId(workspaceDirs, sessionId) {
953
- for (const dir of workspaceDirs) {
954
- try {
955
- const settingsPath = path.join(dir, sessionId + '.settings.json');
956
- if (fs.existsSync(settingsPath)) return dir;
957
- } catch {
958
- // ignore
959
- }
960
- }
961
- return null;
962
- }
963
-
964
- function pickLatestSessionAcross(workspaceDirs) {
965
- let best = null;
966
- for (const dir of workspaceDirs) {
967
- const candidates = listSessionCandidates(dir);
968
- for (const c of candidates) {
969
- if (!best || c.mtimeMs > best.mtimeMs) {
970
- best = { workspaceDir: dir, id: c.id, mtimeMs: c.mtimeMs };
971
- }
972
- }
973
- }
974
- return best ? { workspaceDir: best.workspaceDir, id: best.id } : null;
975
- }
976
-
977
- async function waitForNewSessionAcross(workspaceDirs, knownIdsByWorkspace, startMs, shouldAbort) {
978
- for (let i = 0; i < 80; i++) {
979
- if (shouldAbort && shouldAbort()) return null;
980
- let best = null;
981
- for (const dir of workspaceDirs) {
982
- const known = knownIdsByWorkspace.get(dir) || new Set();
983
- const candidates = listSessionCandidates(dir);
984
- for (const c of candidates) {
985
- if (!(c.mtimeMs >= startMs - 50 || !known.has(c.id))) continue;
986
- if (!best || c.mtimeMs > best.mtimeMs) {
987
- best = { workspaceDir: dir, id: c.id, mtimeMs: c.mtimeMs };
988
- }
989
- }
990
- }
991
- if (best?.id) return { workspaceDir: best.workspaceDir, id: best.id };
992
- await sleep(100);
993
- }
994
- return null;
995
- }
996
-
997
- function safeRealpath(p) {
998
- try {
999
- return fs.realpathSync(p);
1000
- } catch {
1001
- return null;
1002
- }
1003
- }
1004
-
1005
- function resolveWorkspaceDirs(cwd) {
1006
- const logical = cwd;
1007
- const real = safeRealpath(cwd);
1008
- const dirs = [];
1009
- for (const value of [logical, real]) {
1010
- if (!value || typeof value !== 'string') continue;
1011
- dirs.push(path.join(SESSIONS_ROOT, sanitizeWorkspaceDirName(value)));
1012
- }
1013
- return Array.from(new Set(dirs));
1014
- }
1015
-
1016
- function resolveSessionSettings(workspaceDir, sessionId) {
1017
- const settingsPath = path.join(workspaceDir, sessionId + '.settings.json');
1018
- const settings = readJsonFile(settingsPath) || {};
1019
- return { settingsPath, settings };
1020
- }
1021
-
1022
- function resolveGlobalSettingsModel() {
1023
- const global = readJsonFile(GLOBAL_SETTINGS_PATH);
1024
- return global && typeof global.model === 'string' ? global.model : null;
1025
- }
1026
-
1027
- function resolveCustomModelIndex(modelId) {
1028
- if (typeof modelId !== 'string') return null;
1029
- if (!modelId.startsWith('custom:')) return null;
1030
- const m = modelId.match(/-(\\d+)$/);
1031
- if (!m) return null;
1032
- const idx = Number(m[1]);
1033
- return Number.isFinite(idx) ? idx : null;
1034
- }
1035
-
1036
- function resolveUnderlyingModelId(modelId, factoryConfig) {
1037
- const idx = resolveCustomModelIndex(modelId);
1038
- if (idx == null) return modelId;
1039
- const entry = factoryConfig?.custom_models?.[idx];
1040
- if (entry && typeof entry.model === 'string') return entry.model;
1041
- return modelId;
1042
- }
1043
-
1044
- function resolveProvider(modelId, factoryConfig) {
1045
- const idx = resolveCustomModelIndex(modelId);
1046
- if (idx != null) {
1047
- const entry = factoryConfig?.custom_models?.[idx];
1048
- if (entry && typeof entry.provider === 'string') return entry.provider;
1049
- }
1050
- if (typeof modelId === 'string' && modelId.startsWith('claude-')) return 'anthropic';
1051
- return '';
1052
- }
1053
-
1054
- function formatInt(n) {
1055
- if (!Number.isFinite(n)) return '0';
1056
- return Math.round(n).toString();
1057
- }
1058
-
1059
- function formatTokens(n) {
1060
- if (!Number.isFinite(n)) return '0';
1061
- const sign = n < 0 ? '-' : '';
1062
- const abs = Math.abs(n);
1063
- if (abs >= 1_000_000) {
1064
- const v = abs / 1_000_000;
1065
- const s = v >= 10 ? v.toFixed(0) : v.toFixed(1);
1066
- return sign + s.replace(/\\.0$/, '') + 'M';
1067
- }
1068
- if (abs >= 10_000) {
1069
- const v = abs / 1_000;
1070
- const s = v >= 100 ? v.toFixed(0) : v.toFixed(1);
1071
- return sign + s.replace(/\\.0$/, '') + 'k';
1072
- }
1073
- return sign + Math.round(abs).toString();
1074
- }
1075
-
1076
- function emitFrame(line) {
1077
- try {
1078
- process.stdout.write(String(line || '') + '\\n');
1079
- } catch {
1080
- // ignore
1081
- }
1082
- }
1083
-
1084
- function seg(bg, fg, text) {
1085
- if (!text) return '';
1086
- return '\\x1b[48;5;' + bg + 'm' + '\\x1b[38;5;' + fg + 'm' + ' ' + text + ' ' + '\\x1b[0m';
1087
- }
1088
-
1089
- function resolveGitBranch(cwd) {
1090
- try {
1091
- const res = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
1092
- cwd,
1093
- encoding: 'utf8',
1094
- stdio: ['ignore', 'pipe', 'ignore'],
1095
- timeout: 800,
1096
- });
1097
- if (res && res.status === 0) {
1098
- const branch = String(res.stdout || '').trim();
1099
- if (branch && branch !== 'HEAD') return branch;
1100
- }
1101
- } catch {}
1102
- try {
1103
- const headPath = path.join(cwd, '.git', 'HEAD');
1104
- const head = safeReadFile(headPath);
1105
- if (head && head.startsWith('ref: ')) {
1106
- const ref = head.slice('ref: '.length).trim();
1107
- const m = ref.match(/refs\\/heads\\/(.+)$/);
1108
- if (m) return m[1];
1109
- }
1110
- } catch {}
1111
- return '';
1112
- }
1113
-
1114
- function resolveGitDiffSummary(cwd) {
1115
- try {
1116
- const res = spawnSync('git', ['diff', '--shortstat'], {
1117
- cwd,
1118
- encoding: 'utf8',
1119
- stdio: ['ignore', 'pipe', 'ignore'],
1120
- timeout: 800,
1121
- });
1122
- if (!res || res.status !== 0) return '';
1123
- const text = String(res.stdout || '').trim();
1124
- if (!text) return '';
1125
- const ins = (text.match(/(\\d+)\\sinsertions?\\(\\+\\)/) || [])[1];
1126
- const del = (text.match(/(\\d+)\\sdeletions?\\(-\\)/) || [])[1];
1127
- const i = ins ? Number(ins) : 0;
1128
- const d = del ? Number(del) : 0;
1129
- if (!Number.isFinite(i) && !Number.isFinite(d)) return '';
1130
- if (i === 0 && d === 0) return '';
1131
- return '(+' + formatInt(i) + ',-' + formatInt(d) + ')';
1132
- } catch {
1133
- return '';
1134
- }
1135
- }
1136
-
1137
- function buildLine(params) {
1138
- const {
1139
- provider,
1140
- model,
1141
- cwdBase,
1142
- gitBranch,
1143
- gitDiff,
1144
- usedTokens,
1145
- cacheRead,
1146
- deltaInput,
1147
- lastOutputTokens,
1148
- sessionUsage,
1149
- compacting,
1150
- ctxAvailable,
1151
- ctxApprox,
1152
- ctxOverflow,
1153
- } = params;
1154
-
1155
- const ctxValue = !ctxAvailable
1156
- ? '--'
1157
- : (ctxApprox ? '~' : '') + formatTokens(usedTokens) + (ctxOverflow ? '+' : '');
1158
- let ctxPart = 'Ctx: ' + ctxValue;
1159
-
1160
- const cachePart =
1161
- ctxAvailable && !ctxApprox && !ctxOverflow && (cacheRead > 0 || deltaInput > 0)
1162
- ? ' c' + formatTokens(cacheRead) + '+n' + formatTokens(deltaInput)
1163
- : '';
1164
-
1165
- const compactPart = compacting ? ' COMPACT' : '';
1166
-
1167
- const usagePart = (() => {
1168
- const u = sessionUsage || {};
1169
- const input = Number(u.inputTokens ?? 0);
1170
- const output = Number(u.outputTokens ?? 0);
1171
- const cacheCreation = Number(u.cacheCreationTokens ?? 0);
1172
- const cacheReadTotal = Number(u.cacheReadTokens ?? 0);
1173
- const thinking = Number(u.thinkingTokens ?? 0);
1174
- if (!(input || output || cacheCreation || cacheReadTotal || thinking)) return '';
1175
- const parts = [];
1176
- if (input) parts.push('In:' + formatTokens(input));
1177
- if (output) parts.push('Out:' + formatTokens(output));
1178
- if (cacheCreation) parts.push('Cre:' + formatTokens(cacheCreation));
1179
- if (cacheReadTotal) parts.push('Read:' + formatTokens(cacheReadTotal));
1180
- if (thinking) parts.push('Think:' + formatTokens(thinking));
1181
- if (lastOutputTokens > 0) parts.push('LastOut:' + formatTokens(lastOutputTokens));
1182
- return parts.join(' ');
1183
- })();
1184
-
1185
- const modelPart = model ? 'Model: ' + model : '';
1186
- const providerPart = provider ? 'Prov: ' + provider : '';
1187
- const cwdPart = cwdBase ? 'cwd: ' + cwdBase : '';
1188
- const branchPart = gitBranch ? '\\uE0A0 ' + gitBranch : '';
1189
- const diffPart = gitDiff || '';
1190
-
1191
- // Background segments (powerline-like blocks)
1192
- const sModel = seg(88, 15, modelPart); // dark red
1193
- const sProvider = seg(160, 15, providerPart); // red
1194
- const sCtx = seg(220, 0, ctxPart + (cachePart ? ' (' + cachePart.trim() + ')' : '')); // yellow
1195
- const sUsage = seg(173, 0, usagePart); // orange-ish
1196
- const sBranch = seg(24, 15, branchPart); // blue
1197
- const sDiff = seg(34, 0, diffPart); // green
1198
- const sCwd = seg(238, 15, cwdPart); // gray
1199
- const sExtra = seg(99, 15, compactPart.trim()); // purple-ish
1200
-
1201
- return [sModel, sProvider, sCtx, sUsage, sBranch, sDiff, sCwd, sExtra].filter(Boolean).join('');
1202
- }
1203
-
1204
- async function main() {
1205
- let factoryConfig = readJsonFile(CONFIG_PATH) || {};
1206
-
1207
- const cwd = process.cwd();
1208
- const cwdBase = path.basename(cwd) || cwd;
1209
- const workspaceDirs = resolveWorkspaceDirs(cwd);
1210
- const knownIdsByWorkspace = new Map();
1211
- for (const dir of workspaceDirs) {
1212
- const set = new Set();
1213
- for (const c of listSessionCandidates(dir)) set.add(c.id);
1214
- knownIdsByWorkspace.set(dir, set);
1215
- }
1216
-
1217
- const { resumeFlag, resumeId } = parseResume(ARGS);
1218
-
1219
- let sessionId = null;
1220
- let workspaceDir = null;
1221
- if (resumeId) {
1222
- sessionId = resumeId;
1223
- workspaceDir = findWorkspaceDirForSessionId(workspaceDirs, sessionId) || workspaceDirs[0] || null;
1224
- } else {
1225
- let abortResolve = false;
1226
- const shouldAbort = () => abortResolve;
1227
-
1228
- const byProcPromise = resolveSessionFromProcessGroup(shouldAbort, 20);
1229
-
1230
- let picked = null;
1231
- if (resumeFlag) {
1232
- // For --resume without an explicit id, don't block startup too long on ps/lsof.
1233
- // Prefer process-group resolution when it is fast; otherwise fall back to latest.
1234
- picked = await Promise.race([
1235
- byProcPromise,
1236
- sleep(400).then(() => null),
1237
- ]);
1238
- if (!picked) picked = pickLatestSessionAcross(workspaceDirs);
1239
- } else {
1240
- const freshPromise = waitForNewSessionAcross(workspaceDirs, knownIdsByWorkspace, START_MS, shouldAbort);
1241
- picked = await firstNonNull([byProcPromise, freshPromise]);
1242
- if (!picked) picked = pickLatestSessionAcross(workspaceDirs);
1243
- }
1244
-
1245
- abortResolve = true;
1246
-
1247
- sessionId = picked?.id || null;
1248
- workspaceDir = picked?.workspaceDir || workspaceDirs[0] || null;
1249
- }
1250
-
1251
- if (!sessionId || !workspaceDir) return;
1252
- let sessionIdLower = String(sessionId).toLowerCase();
1253
-
1254
- let settingsPath = '';
1255
- let sessionSettings = {};
1256
- ({ settingsPath, settings: sessionSettings } = resolveSessionSettings(workspaceDir, sessionId));
1257
-
1258
- let configMtimeMs = safeStatMtimeMs(CONFIG_PATH);
1259
- let globalSettingsMtimeMs = safeStatMtimeMs(GLOBAL_SETTINGS_PATH);
1260
- let globalSettingsModel = resolveGlobalSettingsModel();
1261
-
1262
- let modelIdFromLog = null;
1263
-
1264
- function resolveActiveModelId() {
1265
- const fromSession =
1266
- sessionSettings && typeof sessionSettings.model === 'string' ? sessionSettings.model : null;
1267
- if (fromSession && String(fromSession).startsWith('custom:')) return fromSession;
1268
- const fromLog = typeof modelIdFromLog === 'string' ? modelIdFromLog : null;
1269
- if (fromLog) return fromLog;
1270
- return fromSession || globalSettingsModel || null;
1271
- }
1272
-
1273
- let modelId = resolveActiveModelId();
1274
-
1275
- let provider =
1276
- sessionSettings && typeof sessionSettings.providerLock === 'string'
1277
- ? sessionSettings.providerLock
1278
- : resolveProvider(modelId, factoryConfig);
1279
- let underlyingModel = resolveUnderlyingModelId(modelId, factoryConfig) || modelId || 'unknown';
1280
-
1281
- function refreshModel() {
1282
- const nextModelId = resolveActiveModelId();
1283
-
1284
- // Use providerLock if set, otherwise resolve from model/config (same logic as initialization)
1285
- const nextProvider =
1286
- sessionSettings && typeof sessionSettings.providerLock === 'string'
1287
- ? sessionSettings.providerLock
1288
- : resolveProvider(nextModelId, factoryConfig);
1289
- const nextUnderlying = resolveUnderlyingModelId(nextModelId, factoryConfig) || nextModelId || 'unknown';
1290
-
1291
- let changed = false;
1292
- if (nextModelId !== modelId) {
1293
- modelId = nextModelId;
1294
- changed = true;
1295
- }
1296
- if (nextProvider !== provider) {
1297
- provider = nextProvider;
1298
- changed = true;
1299
- }
1300
- if (nextUnderlying !== underlyingModel) {
1301
- underlyingModel = nextUnderlying;
1302
- changed = true;
1303
- }
1304
-
1305
- if (changed) renderNow();
1306
- }
1307
-
1308
- let last = { cacheReadInputTokens: 0, contextCount: 0, outputTokens: 0 };
1309
- let sessionUsage =
1310
- sessionSettings && typeof sessionSettings.tokenUsage === 'object' && sessionSettings.tokenUsage
1311
- ? sessionSettings.tokenUsage
1312
- : {};
1313
- let compacting = false;
1314
- let lastRenderAt = 0;
1315
- let lastRenderedLine = '';
1316
- let gitBranch = '';
1317
- let gitDiff = '';
1318
- let lastContextMs = 0;
1319
- let ctxAvailable = false;
1320
- let ctxApprox = false;
1321
- let ctxOverflow = false;
1322
- let ctxOverrideUsedTokens = null;
1323
-
1324
- let baselineCacheReadInputTokens = 0;
1325
- let knownContextMaxTokens = 0;
1326
- let pendingCompactionSuffixTokens = null;
1327
- let pendingCompactionSummaryOutputTokens = null;
1328
- let pendingCompactionSummaryTsMs = null;
1329
-
1330
- function renderNow() {
1331
- const override = Number.isFinite(ctxOverrideUsedTokens) && ctxOverrideUsedTokens > 0 ? ctxOverrideUsedTokens : null;
1332
- const usedTokens = override != null ? override : (last.cacheReadInputTokens || 0) + (last.contextCount || 0);
1333
- const cacheRead = override != null ? 0 : last.cacheReadInputTokens || 0;
1334
- const deltaInput = override != null ? 0 : last.contextCount || 0;
1335
- const line = buildLine({
1336
- provider,
1337
- model: underlyingModel,
1338
- cwdBase,
1339
- gitBranch,
1340
- gitDiff,
1341
- usedTokens,
1342
- cacheRead,
1343
- deltaInput,
1344
- lastOutputTokens: last.outputTokens || 0,
1345
- sessionUsage,
1346
- compacting,
1347
- ctxAvailable: override != null ? true : ctxAvailable,
1348
- ctxApprox,
1349
- ctxOverflow,
1350
- });
1351
- if (line !== lastRenderedLine) {
1352
- lastRenderedLine = line;
1353
- emitFrame(line);
1354
- }
1355
- }
1356
-
1357
- // Initial render.
1358
- renderNow();
1359
-
1360
- // Resolve git info asynchronously so startup isn't blocked on large repos.
1361
- setTimeout(() => {
1362
- try {
1363
- gitBranch = resolveGitBranch(cwd);
1364
- gitDiff = resolveGitDiffSummary(cwd);
1365
- renderNow();
1366
- } catch {}
1367
- }, 0).unref();
1368
-
1369
- // Seed known context max tokens from recent log failures (some providers omit explicit counts).
1370
- setTimeout(() => {
1371
- try {
1372
- seedKnownContextMaxTokensFromLog(8 * 1024 * 1024);
1373
- } catch {}
1374
- }, 0).unref();
1375
-
1376
- let reseedInProgress = false;
1377
- let reseedQueued = false;
1378
-
1379
- function extractModelIdFromContext(ctx) {
1380
- const tagged = ctx?.tags?.modelId;
1381
- if (typeof tagged === 'string') return tagged;
1382
- const direct = ctx?.modelId;
1383
- return typeof direct === 'string' ? direct : null;
1384
- }
1385
-
1386
- function updateLastFromContext(ctx, updateOutputTokens, tsMs) {
1387
- const ts = Number.isFinite(tsMs) ? tsMs : null;
1388
- if (ts != null && lastContextMs && ts < lastContextMs) return false;
1389
- const cacheRead = Number(ctx?.cacheReadInputTokens);
1390
- const contextCount = Number(ctx?.contextCount);
1391
- const out = Number(ctx?.outputTokens);
1392
- const hasTokens =
1393
- (Number.isFinite(cacheRead) && cacheRead > 0) ||
1394
- (Number.isFinite(contextCount) && contextCount > 0);
1395
- if (hasTokens) {
1396
- // Treat 0/0 as "not reported" (some providers log zeros even when prompt exists).
1397
- // If at least one field is >0, accept both fields (including zero) as reliable.
1398
- if (Number.isFinite(cacheRead)) last.cacheReadInputTokens = cacheRead;
1399
- if (Number.isFinite(contextCount)) last.contextCount = contextCount;
1400
- ctxAvailable = true;
1401
- ctxOverrideUsedTokens = null;
1402
- ctxApprox = false;
1403
- ctxOverflow = false;
1404
- if (Number.isFinite(cacheRead) && cacheRead > 0) {
1405
- baselineCacheReadInputTokens = baselineCacheReadInputTokens
1406
- ? Math.min(baselineCacheReadInputTokens, cacheRead)
1407
- : cacheRead;
1408
- }
1409
- }
1410
- if (updateOutputTokens && Number.isFinite(out)) last.outputTokens = out;
1411
- if (hasTokens && ts != null) lastContextMs = ts;
1412
-
1413
- const nextModelIdFromLog = extractModelIdFromContext(ctx);
1414
- if (nextModelIdFromLog && nextModelIdFromLog !== modelIdFromLog) {
1415
- modelIdFromLog = nextModelIdFromLog;
1416
- refreshModel();
1417
- }
1418
-
1419
- return true;
1420
- }
1421
-
1422
- function setCtxOverride(usedTokens, options) {
1423
- const opts = options || {};
1424
- const v = Number(usedTokens);
1425
- if (!Number.isFinite(v) || v <= 0) return false;
1426
- const prevUsed = ctxOverrideUsedTokens;
1427
- const prevApprox = ctxApprox;
1428
- const prevOverflow = ctxOverflow;
1429
- ctxOverrideUsedTokens = v;
1430
- ctxAvailable = true;
1431
- ctxApprox = !!opts.approx;
1432
- ctxOverflow = !!opts.overflow;
1433
- const ts = Number.isFinite(opts.tsMs) ? opts.tsMs : null;
1434
- if (ts != null && (!lastContextMs || ts > lastContextMs)) lastContextMs = ts;
1435
- if (prevUsed !== ctxOverrideUsedTokens || prevApprox !== ctxApprox || prevOverflow !== ctxOverflow) {
1436
- renderNow();
1437
- }
1438
- return true;
1439
- }
1440
-
1441
- function parseContextLimitFromMessage(message) {
1442
- const s = String(message || '');
1443
- let promptTokens = null;
1444
- let maxTokens = null;
1445
-
1446
- const pair = s.match(/(\\d+)\\s*tokens\\s*>\\s*(\\d+)\\s*maximum/i);
1447
- if (pair) {
1448
- const prompt = Number(pair[1]);
1449
- const max = Number(pair[2]);
1450
- if (Number.isFinite(prompt)) promptTokens = prompt;
1451
- if (Number.isFinite(max)) maxTokens = max;
1452
- return { promptTokens, maxTokens };
1453
- }
1454
-
1455
- const promptMatch = s.match(/prompt\\s+is\\s+too\\s+long:\\s*(\\d+)\\s*tokens/i);
1456
- if (promptMatch) {
1457
- const prompt = Number(promptMatch[1]);
1458
- if (Number.isFinite(prompt)) promptTokens = prompt;
1459
- }
1460
-
1461
- const maxMatch = s.match(/>\\s*(\\d+)\\s*maximum/i);
1462
- if (maxMatch) {
1463
- const max = Number(maxMatch[1]);
1464
- if (Number.isFinite(max)) maxTokens = max;
1465
- }
1466
-
1467
- return { promptTokens, maxTokens };
1468
- }
1469
-
1470
- function seedKnownContextMaxTokensFromLog(maxScanBytes = 4 * 1024 * 1024) {
1471
- try {
1472
- const stat = fs.statSync(LOG_PATH);
1473
- const size = Number(stat?.size ?? 0);
1474
- if (!(size > 0)) return;
1475
-
1476
- const scan = Math.max(256 * 1024, maxScanBytes);
1477
- const readSize = Math.min(size, scan);
1478
- const start = Math.max(0, size - readSize);
1479
-
1480
- const buf = Buffer.alloc(readSize);
1481
- const fd = fs.openSync(LOG_PATH, 'r');
1482
- try {
1483
- fs.readSync(fd, buf, 0, readSize, start);
1484
- } finally {
1485
- try {
1486
- fs.closeSync(fd);
1487
- } catch {}
1488
- }
1489
-
1490
- const text = buf.toString('utf8');
1491
- const re = /(\\d+)\\s*tokens\\s*>\\s*(\\d+)\\s*maximum/gi;
1492
- let m;
1493
- while ((m = re.exec(text))) {
1494
- const max = Number(m[2]);
1495
- if (Number.isFinite(max) && max > 0) knownContextMaxTokens = max;
1496
- }
1497
- } catch {}
1498
- }
1499
-
1500
- function maybeUpdateCtxFromContextLimitFailure(line, ctx, tsMs) {
1501
- if (!line || !ctx) return false;
1502
- if (!String(line).includes('[Chat route failure]')) return false;
1503
- const reason = ctx?.reason;
1504
- if (reason !== 'llmContextExceeded') return false;
1505
-
1506
- const msg = ctx?.error?.message;
1507
- if (typeof msg !== 'string' || !msg) return false;
1508
- const parsed = parseContextLimitFromMessage(msg);
1509
- const max = Number(parsed?.maxTokens);
1510
- if (Number.isFinite(max) && max > 0) knownContextMaxTokens = max;
1511
-
1512
- const prompt = Number(parsed?.promptTokens);
1513
- if (Number.isFinite(prompt) && prompt > 0) {
1514
- return setCtxOverride(prompt, { tsMs, approx: false, overflow: false });
1515
- }
1516
-
1517
- if (Number.isFinite(max) && max > 0) {
1518
- return setCtxOverride(max, { tsMs, approx: false, overflow: true });
1519
- }
1520
-
1521
- if (knownContextMaxTokens > 0) {
1522
- return setCtxOverride(knownContextMaxTokens, { tsMs, approx: false, overflow: true });
1523
- }
1524
-
1525
- return false;
1526
- }
1527
-
1528
- function maybeCaptureCompactionSuffix(line, ctx) {
1529
- if (!line || !ctx) return;
1530
- if (!String(line).includes('[Compaction] Suffix selection')) return;
1531
- const suffix = Number(ctx?.suffixTokens);
1532
- if (Number.isFinite(suffix) && suffix >= 0) pendingCompactionSuffixTokens = suffix;
1533
- }
1534
-
1535
- function maybeApplyPostCompactionEstimate(line, ctx, tsMs) {
1536
- if (!line || !ctx) return false;
1537
- if (!String(line).includes('[Compaction] End')) return false;
1538
- if (ctx?.eventType !== 'compaction' || ctx?.state !== 'end') return false;
1539
- const reason = ctx?.reason || ctx?.tags?.compactionReason || null;
1540
- if (!(reason === 'context_limit' || reason === 'manual')) return false;
1541
-
1542
- const summaryOut = Number(ctx?.summaryOutputTokens);
1543
- if (!Number.isFinite(summaryOut) || summaryOut < 0) return false;
1544
-
1545
- const prefix = baselineCacheReadInputTokens > 0 ? baselineCacheReadInputTokens : 0;
1546
- const suffix = Number.isFinite(pendingCompactionSuffixTokens) ? pendingCompactionSuffixTokens : 0;
1547
- pendingCompactionSuffixTokens = null;
1548
-
1549
- const est = prefix + suffix + summaryOut;
1550
- if (est <= 0) return false;
1551
- return setCtxOverride(est, { tsMs, approx: true, overflow: false });
1552
- }
1553
-
1554
- function maybeApplyCompactSessionEstimate(sessionIdToEstimate, options, attempt = 0) {
1555
- const opts = options && typeof options === 'object' ? options : {};
1556
- const id = String(sessionIdToEstimate || '');
1557
- if (!isUuid(id)) return;
1558
- if (!workspaceDir) return;
1559
-
1560
- // forceApply allows overriding even if ctx is already set (useful for manual /compress)
1561
- const forceApply = !!opts?.forceApply;
1562
- if (!forceApply) {
1563
- if (ctxAvailable) return;
1564
- if (Number.isFinite(ctxOverrideUsedTokens) && ctxOverrideUsedTokens > 0) return;
1565
- }
1566
-
1567
- const suffixVal = opts?.suffixTokens;
1568
- const suffixTokens =
1569
- typeof suffixVal === 'number' && Number.isFinite(suffixVal) && suffixVal >= 0 ? suffixVal : 0;
1570
- const tsVal = opts?.tsMs;
1571
- const ts = typeof tsVal === 'number' && Number.isFinite(tsVal) ? tsVal : null;
1572
-
1573
- const jsonlPath = path.join(workspaceDir, id + '.jsonl');
1574
- let head = null;
1575
- try {
1576
- const fd = fs.openSync(jsonlPath, 'r');
1577
- try {
1578
- const maxBytes = 2 * 1024 * 1024;
1579
- const buf = Buffer.alloc(maxBytes);
1580
- const bytes = fs.readSync(fd, buf, 0, maxBytes, 0);
1581
- head = buf.slice(0, Math.max(0, bytes)).toString('utf8');
1582
- } finally {
1583
- try {
1584
- fs.closeSync(fd);
1585
- } catch {}
1586
- }
1587
- } catch {
1588
- head = null;
1589
- }
1590
-
1591
- if (!head) {
1592
- if (attempt < 40) {
1593
- setTimeout(() => {
1594
- maybeApplyCompactSessionEstimate(id, opts, attempt + 1);
1595
- }, 150).unref();
1596
- }
1597
- return;
1598
- }
1599
-
1600
- let summaryText = null;
1601
- for (const raw of head.split('\\n')) {
1602
- if (!raw) continue;
1603
- let obj;
1604
- try {
1605
- obj = JSON.parse(raw);
1606
- } catch {
1607
- continue;
1608
- }
1609
- if (obj && obj.type === 'compaction_state' && typeof obj.summaryText === 'string') {
1610
- summaryText = obj.summaryText;
1611
- break;
1612
- }
1613
- }
1614
- if (!summaryText) {
1615
- if (attempt < 40) {
1616
- setTimeout(() => {
1617
- maybeApplyCompactSessionEstimate(id, opts, attempt + 1);
1618
- }, 150).unref();
1619
- }
1620
- return;
1621
- }
1622
-
1623
- // Rough token estimate (no tokenizer deps): English-like text averages ~4 chars/token.
1624
- // Non-ASCII tends to be denser; use a smaller divisor.
1625
- let ascii = 0;
1626
- let other = 0;
1627
- for (let i = 0; i < summaryText.length; i++) {
1628
- const code = summaryText.charCodeAt(i);
1629
- if (code <= 0x7f) ascii += 1;
1630
- else other += 1;
1631
- }
1632
- const summaryTokens = Math.max(1, Math.ceil(ascii / 4 + other / 1.5));
1633
- const prefix = baselineCacheReadInputTokens > 0 ? baselineCacheReadInputTokens : 0;
1634
- const est = prefix + suffixTokens + summaryTokens;
1635
- if (est > 0) setCtxOverride(est, { tsMs: ts, approx: true, overflow: false });
1636
- }
1637
-
1638
- function seedLastContextFromLog(options) {
1639
- const opts = options || {};
1640
- const maxScanBytes = Number.isFinite(opts.maxScanBytes) ? opts.maxScanBytes : 64 * 1024 * 1024;
1641
- const preferStreaming = !!opts.preferStreaming;
1642
- const minTimestampMs = Number.isFinite(lastContextMs) && lastContextMs > 0 ? lastContextMs : 0;
1643
- const earlyStopAfterBestBytes = Math.min(2 * 1024 * 1024, Math.max(256 * 1024, maxScanBytes));
1644
-
1645
- if (reseedInProgress) {
1646
- reseedQueued = true;
1647
- return;
1648
- }
1649
- reseedInProgress = true;
1650
-
1651
- setTimeout(() => {
1652
- try {
1653
- // Backward scan to find the most recent context entry for this session.
1654
- // Prefer streaming context if requested; otherwise accept any context line
1655
- // that includes cacheReadInputTokens/contextCount fields.
1656
- const CHUNK_BYTES = 1024 * 1024; // 1 MiB
1657
-
1658
- const fd = fs.openSync(LOG_PATH, 'r');
1659
- try {
1660
- const stat = fs.fstatSync(fd);
1661
- const size = Number(stat?.size ?? 0);
1662
- let pos = size;
1663
- let scanned = 0;
1664
- let remainder = '';
1665
- let bestCtx = null;
1666
- let bestIsStreaming = false;
1667
- let bestTs = null;
1668
- let bestHasTs = false;
1669
- let bytesSinceBest = 0;
1670
-
1671
- while (pos > 0 && scanned < maxScanBytes && (!bestHasTs || bytesSinceBest < earlyStopAfterBestBytes)) {
1672
- const readSize = Math.min(CHUNK_BYTES, pos);
1673
- const start = pos - readSize;
1674
- const buf = Buffer.alloc(readSize);
1675
- fs.readSync(fd, buf, 0, readSize, start);
1676
- pos = start;
1677
- scanned += readSize;
1678
- bytesSinceBest += readSize;
1679
-
1680
- let text = buf.toString('utf8') + remainder;
1681
- let lines = String(text).split('\\n');
1682
- remainder = lines.shift() || '';
1683
- if (pos === 0 && remainder) {
1684
- lines.unshift(remainder);
1685
- remainder = '';
1686
- }
1687
-
1688
- for (let i = lines.length - 1; i >= 0; i--) {
1689
- const line = String(lines[i] || '').trimEnd();
1690
- if (!line) continue;
1691
- if (!line.includes('Context:')) continue;
1692
- const sid = extractSessionIdFromLine(line);
1693
- if (!sid || String(sid).toLowerCase() !== sessionIdLower) continue;
1694
-
1695
- const isStreaming = line.includes('[Agent] Streaming result');
1696
- if (preferStreaming && !isStreaming) continue;
1697
-
1698
- const ctxIndex = line.indexOf('Context: ');
1699
- if (ctxIndex === -1) continue;
1700
- const jsonStr = line.slice(ctxIndex + 'Context: '.length).trim();
1701
- let ctx;
1702
- try {
1703
- ctx = JSON.parse(jsonStr);
1704
- } catch {
1705
- continue;
1706
- }
1707
-
1708
- const cacheRead = Number(ctx?.cacheReadInputTokens);
1709
- const contextCount = Number(ctx?.contextCount);
1710
- const hasUsage = Number.isFinite(cacheRead) || Number.isFinite(contextCount);
1711
- if (!hasUsage) continue;
1712
-
1713
- const ts = parseLineTimestampMs(line);
1714
- if (ts != null && minTimestampMs && ts < minTimestampMs) {
1715
- continue;
1716
- }
1717
-
1718
- if (ts != null) {
1719
- if (!bestHasTs || ts > bestTs) {
1720
- bestCtx = ctx;
1721
- bestIsStreaming = isStreaming;
1722
- bestTs = ts;
1723
- bestHasTs = true;
1724
- bytesSinceBest = 0;
1725
- }
1726
- } else if (!bestHasTs && !bestCtx) {
1727
- // No timestamps available yet: the first match when scanning backward
1728
- // is the most recent in file order.
1729
- bestCtx = ctx;
1730
- bestIsStreaming = isStreaming;
1731
- bestTs = null;
1732
- }
1733
- }
1734
-
1735
- if (remainder.length > 8192) remainder = remainder.slice(-8192);
1736
- }
1737
-
1738
- if (bestCtx) {
1739
- updateLastFromContext(bestCtx, bestIsStreaming, bestTs);
1740
- }
1741
- } finally {
1742
- try {
1743
- fs.closeSync(fd);
1744
- } catch {}
1745
- }
1746
- } catch {
1747
- // ignore
1748
- } finally {
1749
- reseedInProgress = false;
1750
- if (reseedQueued) {
1751
- reseedQueued = false;
1752
- seedLastContextFromLog({ maxScanBytes, preferStreaming });
1753
- return;
1754
- }
1755
- renderNow();
1756
- }
1757
- }, 0).unref();
1758
- }
1759
-
1760
- // Seed prompt-context usage from existing logs (important for resumed sessions).
1761
- // Do this asynchronously to avoid delaying the first statusline frame.
1762
- let initialSeedDone = false;
1763
- if (resumeFlag || resumeId) {
1764
- initialSeedDone = true;
1765
- seedLastContextFromLog({ maxScanBytes: 64 * 1024 * 1024, preferStreaming: true });
1766
- }
1767
-
1768
- // Watch session settings for autonomy/reasoning changes (cheap polling with mtime).
1769
- let settingsMtimeMs = 0;
1770
- let lastCtxPollMs = 0;
1771
- setInterval(() => {
1772
- // Refresh config/global settings if they changed (model display depends on these).
1773
- const configMtime = safeStatMtimeMs(CONFIG_PATH);
1774
- if (configMtime && configMtime !== configMtimeMs) {
1775
- configMtimeMs = configMtime;
1776
- factoryConfig = readJsonFile(CONFIG_PATH) || {};
1777
- refreshModel();
1778
- }
1779
-
1780
- const globalMtime = safeStatMtimeMs(GLOBAL_SETTINGS_PATH);
1781
- if (globalMtime && globalMtime !== globalSettingsMtimeMs) {
1782
- globalSettingsMtimeMs = globalMtime;
1783
- globalSettingsModel = resolveGlobalSettingsModel();
1784
- refreshModel();
1785
- }
1786
-
1787
- try {
1788
- const stat = fs.statSync(settingsPath);
1789
- if (stat.mtimeMs === settingsMtimeMs) return;
1790
- settingsMtimeMs = stat.mtimeMs;
1791
- const next = readJsonFile(settingsPath) || {};
1792
- sessionSettings = next;
1793
-
1794
- // Keep session token usage in sync (used by /status).
1795
- if (next && typeof next.tokenUsage === 'object' && next.tokenUsage) {
1796
- sessionUsage = next.tokenUsage;
1797
- }
1798
-
1799
- // Keep model/provider in sync (model can change during a running session).
1800
- refreshModel();
1801
-
1802
- const now = Date.now();
1803
- if (now - lastRenderAt >= MIN_RENDER_INTERVAL_MS) {
1804
- lastRenderAt = now;
1805
- renderNow();
1806
- }
1807
- } catch {
1808
- // ignore
1809
- }
1810
- }, 750).unref();
1811
-
1812
- // Fallback: periodically rescan log if context is still zero after startup.
1813
- // This handles cases where tail misses early log entries.
1814
- setInterval(() => {
1815
- const now = Date.now();
1816
- if (now - START_MS < 3000) return; // wait 3s after startup
1817
- if (last.contextCount > 0 || last.cacheReadInputTokens > 0) return; // already have data
1818
- if (now - lastCtxPollMs < 5000) return; // throttle to every 5s
1819
- lastCtxPollMs = now;
1820
- seedLastContextFromLog({ maxScanBytes: 4 * 1024 * 1024, preferStreaming: false });
1821
- }, 2000).unref();
1822
-
1823
- function switchToSession(nextSessionId) {
1824
- if (!nextSessionId || !isUuid(nextSessionId)) return;
1825
- const nextLower = String(nextSessionId).toLowerCase();
1826
- if (nextLower === sessionIdLower) return;
1827
-
1828
- sessionId = nextSessionId;
1829
- sessionIdLower = nextLower;
1830
-
1831
- const resolved = resolveSessionSettings(workspaceDir, nextSessionId);
1832
- settingsPath = resolved.settingsPath;
1833
- sessionSettings = resolved.settings || {};
1834
-
1835
- sessionUsage =
1836
- sessionSettings && typeof sessionSettings.tokenUsage === 'object' && sessionSettings.tokenUsage
1837
- ? sessionSettings.tokenUsage
1838
- : {};
1839
-
1840
- // Reset cached state for the new session.
1841
- last = { cacheReadInputTokens: 0, contextCount: 0, outputTokens: 0 };
1842
- lastContextMs = 0;
1843
- ctxAvailable = false;
1844
- ctxApprox = false;
1845
- ctxOverflow = false;
1846
- ctxOverrideUsedTokens = null;
1847
- pendingCompactionSuffixTokens = null;
1848
- pendingCompactionSummaryOutputTokens = null;
1849
- pendingCompactionSummaryTsMs = null;
1850
- modelIdFromLog = null;
1851
- compacting = false;
1852
- settingsMtimeMs = 0;
1853
- lastCtxPollMs = 0;
1854
-
1855
- refreshModel();
1856
- renderNow();
1857
-
1858
- // Best-effort: if the new session already has Context lines in the log, seed quickly.
1859
- seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: false });
1860
- }
1861
-
1862
- // Follow the Factory log and update based on session-scoped events.
1863
- const tail = spawn('tail', ['-n', '0', '-F', LOG_PATH], {
1864
- stdio: ['ignore', 'pipe', 'ignore'],
1865
- });
1866
-
1867
- let buffer = '';
1868
- tail.stdout.on('data', (chunk) => {
1869
- buffer += String(chunk);
1870
- while (true) {
1871
- const idx = buffer.indexOf('\\n');
1872
- if (idx === -1) break;
1873
- const line = buffer.slice(0, idx).trimEnd();
1874
- buffer = buffer.slice(idx + 1);
1875
-
1876
- const tsMs = parseLineTimestampMs(line);
1877
- const lineSessionId = extractSessionIdFromLine(line);
1878
- const isSessionLine =
1879
- lineSessionId && String(lineSessionId).toLowerCase() === sessionIdLower;
1880
-
1881
- if (compacting && line.includes('[Compaction] End') && line.includes('Context:')) {
1882
- const ctxIndex = line.indexOf('Context: ');
1883
- if (ctxIndex !== -1) {
1884
- const jsonStr = line.slice(ctxIndex + 'Context: '.length).trim();
1885
- try {
1886
- const meta = JSON.parse(jsonStr);
1887
- const summaryOut = Number(meta?.summaryOutputTokens);
1888
- if (
1889
- meta?.eventType === 'compaction' &&
1890
- meta?.state === 'end' &&
1891
- Number.isFinite(summaryOut) &&
1892
- summaryOut >= 0
1893
- ) {
1894
- pendingCompactionSummaryOutputTokens = summaryOut;
1895
- if (tsMs != null) pendingCompactionSummaryTsMs = tsMs;
1896
- }
1897
- } catch {
1898
- }
1899
- }
1900
- }
1901
-
1902
- // /compress (aka /compact) can create a new session ID. Follow it so ctx/model keep updating.
1903
- if (line.includes('oldSessionId') && line.includes('newSessionId') && line.includes('Context:')) {
1904
- const ctxIndex = line.indexOf('Context: ');
1905
- if (ctxIndex !== -1) {
1906
- const jsonStr = line.slice(ctxIndex + 'Context: '.length).trim();
1907
- try {
1908
- const meta = JSON.parse(jsonStr);
1909
- const oldId = meta?.oldSessionId;
1910
- const newId = meta?.newSessionId;
1911
- if (
1912
- isUuid(oldId) &&
1913
- isUuid(newId) &&
1914
- String(oldId).toLowerCase() === sessionIdLower &&
1915
- String(newId).toLowerCase() !== sessionIdLower
1916
- ) {
1917
- const suffixTokens = Number.isFinite(pendingCompactionSuffixTokens)
1918
- ? pendingCompactionSuffixTokens
1919
- : 0;
1920
- const summaryOutTokens = Number.isFinite(pendingCompactionSummaryOutputTokens)
1921
- ? pendingCompactionSummaryOutputTokens
1922
- : null;
1923
- const summaryTsMs = Number.isFinite(pendingCompactionSummaryTsMs) ? pendingCompactionSummaryTsMs : null;
1924
-
1925
- // Save baseline before switching session (it persists across sessions)
1926
- const savedBaseline = baselineCacheReadInputTokens;
1927
-
1928
- switchToSession(String(newId));
1929
-
1930
- // For manual /compress, immediately set an estimated ctx value
1931
- // This ensures the statusline shows a reasonable value right after compression
1932
- if (summaryOutTokens != null && summaryOutTokens > 0) {
1933
- const prefix = savedBaseline > 0 ? savedBaseline : 0;
1934
- const est = prefix + suffixTokens + summaryOutTokens;
1935
- if (est > 0) {
1936
- setCtxOverride(est, { tsMs: summaryTsMs != null ? summaryTsMs : tsMs, approx: true, overflow: false });
1937
- }
1938
- }
1939
-
1940
- // Always attempt to get a more accurate estimate from the new session's jsonl
1941
- // This will read the compaction_state and estimate tokens from summaryText
1942
- // Note: we pass forceApply=true to override even if ctxOverrideUsedTokens is set,
1943
- // because the jsonl-based estimate may be more accurate
1944
- maybeApplyCompactSessionEstimate(String(newId), {
1945
- suffixTokens,
1946
- tsMs: summaryTsMs != null ? summaryTsMs : tsMs,
1947
- forceApply: true,
1948
- });
1949
- continue;
1950
- }
1951
- } catch {
1952
- // ignore
1953
- }
1954
- }
1955
- }
1956
-
1957
- let compactionChanged = false;
1958
- let compactionEnded = false;
1959
- if (line.includes('[Compaction]')) {
1960
- // Accept session-scoped compaction lines; allow end markers to clear even
1961
- // if the line lacks a session id (some builds omit Context on end lines).
1962
- // For manual /compress, [Compaction] End uses the NEW session ID, so we need
1963
- // to also accept End markers when compacting is true and it's an End line.
1964
- const isManualCompactionEnd = compacting &&
1965
- line.includes('[Compaction] End') &&
1966
- lineSessionId &&
1967
- String(lineSessionId).toLowerCase() !== sessionIdLower;
1968
- if (isSessionLine || (compacting && !lineSessionId) || isManualCompactionEnd) {
1969
- const next = nextCompactionState(line, compacting);
1970
- if (next !== compacting) {
1971
- compacting = next;
1972
- compactionChanged = true;
1973
- if (!compacting) compactionEnded = true;
1974
- }
1975
- }
1976
- }
1977
-
1978
- if (compactionChanged && compacting) {
1979
- pendingCompactionSuffixTokens = null;
1980
- pendingCompactionSummaryOutputTokens = null;
1981
- pendingCompactionSummaryTsMs = null;
1982
- // Compaction can start after a context-limit error. Ensure we display the latest
1983
- // pre-compaction ctx by reseeding from log (tail can miss bursts).
1984
- seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: true });
1985
- }
1986
-
1987
- if (compactionEnded) {
1988
- // ctx usage changes dramatically after compaction, but the next Context line
1989
- // can be delayed. Clear displayed ctx immediately to avoid showing stale numbers.
1990
- last.cacheReadInputTokens = 0;
1991
- last.contextCount = 0;
1992
- ctxAvailable = false;
1993
- ctxOverrideUsedTokens = null;
1994
- ctxApprox = false;
1995
- ctxOverflow = false;
1996
- if (tsMs != null) lastContextMs = tsMs;
1997
- }
1998
-
1999
- if (!line.includes('Context:')) {
2000
- if (compactionChanged) {
2001
- lastRenderAt = Date.now();
2002
- renderNow();
2003
- }
2004
- if (compactionEnded) {
2005
- // Compaction often completes between turns. Refresh ctx numbers promptly
2006
- // by rescanning the most recent Context entry for this session.
2007
- setTimeout(() => {
2008
- seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: false });
2009
- }, 250).unref();
2010
- }
2011
- continue;
2012
- }
2013
- if (!isSessionLine) {
2014
- if (compactionChanged) {
2015
- lastRenderAt = Date.now();
2016
- renderNow();
2017
- }
2018
- if (compactionEnded) {
2019
- setTimeout(() => {
2020
- seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: false });
2021
- }, 250).unref();
2022
- }
2023
- continue;
2024
- }
2025
-
2026
- const ctxIndex = line.indexOf('Context: ');
2027
- if (ctxIndex === -1) continue;
2028
- const jsonStr = line.slice(ctxIndex + 'Context: '.length).trim();
2029
- let ctx;
2030
- try {
2031
- ctx = JSON.parse(jsonStr);
2032
- } catch {
2033
- if (compactionChanged) {
2034
- lastRenderAt = Date.now();
2035
- renderNow();
2036
- }
2037
- continue;
2038
- }
2039
-
2040
- // Context usage can appear on multiple session-scoped log lines; update whenever present.
2041
- // (Streaming is still the best source for outputTokens / LastOut.)
2042
- updateLastFromContext(ctx, false, tsMs);
2043
-
2044
- maybeCaptureCompactionSuffix(line, ctx);
2045
- maybeUpdateCtxFromContextLimitFailure(line, ctx, tsMs);
2046
- maybeApplyPostCompactionEstimate(line, ctx, tsMs);
2047
-
2048
- // For new sessions: if this is the first valid Context line and ctx is still 0,
2049
- // trigger a reseed to catch any earlier log entries we might have missed.
2050
- if (!initialSeedDone && last.contextCount === 0) {
2051
- initialSeedDone = true;
2052
- setTimeout(() => {
2053
- seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: false });
2054
- }, 100).unref();
2055
- }
2056
-
2057
- if (line.includes('[Agent] Streaming result')) {
2058
- updateLastFromContext(ctx, true, tsMs);
2059
- }
2060
-
2061
- const now = Date.now();
2062
- if (compactionChanged || now - lastRenderAt >= MIN_RENDER_INTERVAL_MS) {
2063
- lastRenderAt = now;
2064
- renderNow();
2065
- }
2066
-
2067
- if (compactionEnded) {
2068
- setTimeout(() => {
2069
- seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: false });
2070
- }, 250).unref();
2071
- }
2072
- }
2073
- });
2074
-
2075
- const stop = () => {
2076
- try { tail.kill('SIGTERM'); } catch {}
2077
- process.exit(0);
2078
- };
2079
-
2080
- process.on('SIGTERM', stop);
2081
- process.on('SIGINT', stop);
2082
- process.on('SIGHUP', stop);
2083
- }
2084
-
2085
- main().catch(() => {});
2086
- `;
2087
- }
2088
- function generateStatuslineWrapperScript(execTargetPath, monitorScriptPath, sessionsScriptPath) {
2089
- return generateStatuslineWrapperScriptBun(execTargetPath, monitorScriptPath, sessionsScriptPath);
2090
- }
2091
- function generateStatuslineWrapperScriptBun(execTargetPath, monitorScriptPath, sessionsScriptPath) {
2092
- return `#!/usr/bin/env bun
2093
- // Droid with Statusline (Bun PTY proxy)
2094
- // Auto-generated by droid-patch --statusline
2095
-
2096
- const EXEC_TARGET = ${JSON.stringify(execTargetPath)};
2097
- const STATUSLINE_MONITOR = ${JSON.stringify(monitorScriptPath)};
2098
- const SESSIONS_SCRIPT = ${sessionsScriptPath ? JSON.stringify(sessionsScriptPath) : "null"};
2099
-
2100
- const IS_APPLE_TERMINAL = process.env.TERM_PROGRAM === "Apple_Terminal";
2101
- const MIN_RENDER_INTERVAL_MS = IS_APPLE_TERMINAL ? 800 : 400;
2102
- const QUIET_MS = 50;
2103
- const FORCE_REPAINT_INTERVAL_MS = 2000;
2104
- const RESERVED_ROWS = 1;
2105
-
2106
- const BYPASS_FLAGS = new Set(["--help", "-h", "--version", "-V"]);
2107
- const BYPASS_COMMANDS = new Set(["help", "version", "completion", "completions", "exec"]);
2108
-
2109
- function shouldPassthrough(argv) {
2110
- for (const a of argv) {
2111
- if (a === "--") break;
2112
- if (BYPASS_FLAGS.has(a)) return true;
2113
- }
2114
- let endOpts = false;
2115
- let cmd = null;
2116
- for (const a of argv) {
2117
- if (a === "--") {
2118
- endOpts = true;
2119
- continue;
2120
- }
2121
- if (!endOpts && a.startsWith("-")) continue;
2122
- cmd = a;
2123
- break;
2124
- }
2125
- return cmd && BYPASS_COMMANDS.has(cmd);
2126
- }
2127
-
2128
- function isSessionsCommand(argv) {
2129
- for (const a of argv) {
2130
- if (a === "--") return false;
2131
- if (a === "--sessions") return true;
2132
- }
2133
- return false;
2134
- }
2135
-
2136
- async function execPassthrough(argv) {
2137
- const proc = Bun.spawn([EXEC_TARGET, ...argv], {
2138
- stdin: "inherit",
2139
- stdout: "inherit",
2140
- stderr: "inherit",
2141
- });
2142
- const code = await proc.exited;
2143
- process.exit(code ?? 0);
2144
- }
2145
-
2146
- async function runSessions() {
2147
- if (SESSIONS_SCRIPT) {
2148
- const proc = Bun.spawn(["node", String(SESSIONS_SCRIPT)], {
2149
- stdin: "inherit",
2150
- stdout: "inherit",
2151
- stderr: "inherit",
2152
- });
2153
- const code = await proc.exited;
2154
- process.exit(code ?? 0);
2155
- }
2156
- process.stderr.write("[statusline] sessions script not found\\n");
2157
- process.exit(1);
2158
- }
2159
-
2160
- function writeStdout(s) {
2161
- try {
2162
- process.stdout.write(s);
2163
- } catch {
2164
- // ignore
2165
- }
2166
- }
2167
-
2168
- function termSize() {
2169
- const rows = Number(process.stdout.rows || 24);
2170
- const cols = Number(process.stdout.columns || 80);
2171
- return { rows: Number.isFinite(rows) ? rows : 24, cols: Number.isFinite(cols) ? cols : 80 };
2172
- }
2173
-
2174
- const ANSI_RE = /\\x1b\\[[0-9;]*m/g;
2175
- const RESET_SGR = "\\x1b[0m";
2176
-
2177
- function visibleWidth(text) {
2178
- return String(text || "").replace(ANSI_RE, "").length;
2179
- }
2180
-
2181
- function clampAnsi(text, cols) {
2182
- if (!cols || cols <= 0) return String(text || "");
2183
- cols = cols > 1 ? cols - 1 : cols; // avoid last-column wrap
2184
- if (cols < 10) return String(text || "");
2185
- const s = String(text || "");
2186
- let visible = 0;
2187
- let i = 0;
2188
- const out = [];
2189
- while (i < s.length) {
2190
- const ch = s[i];
2191
- if (ch === "\\x1b") {
2192
- const m = s.indexOf("m", i);
2193
- if (m !== -1) {
2194
- out.push(s.slice(i, m + 1));
2195
- i = m + 1;
2196
- continue;
2197
- }
2198
- out.push(ch);
2199
- i += 1;
2200
- continue;
2201
- }
2202
- if (visible >= cols) break;
2203
- out.push(ch);
2204
- i += 1;
2205
- visible += 1;
2206
- }
2207
- if (i < s.length && cols >= 1) {
2208
- if (visible >= cols) {
2209
- if (out.length) out[out.length - 1] = "…";
2210
- else out.push("…");
2211
- } else {
2212
- out.push("…");
2213
- }
2214
- out.push(RESET_SGR);
2215
- }
2216
- return out.join("");
2217
- }
2218
-
2219
- function splitSegments(text) {
2220
- if (!text) return [];
2221
- const s = String(text);
2222
- const segments = [];
2223
- let start = 0;
2224
- while (true) {
2225
- const idx = s.indexOf(RESET_SGR, start);
2226
- if (idx === -1) {
2227
- const tail = s.slice(start);
2228
- if (tail) segments.push(tail);
2229
- break;
2230
- }
2231
- const seg = s.slice(start, idx + RESET_SGR.length);
2232
- if (seg) segments.push(seg);
2233
- start = idx + RESET_SGR.length;
2234
- }
2235
- return segments;
2236
- }
2237
-
2238
- function wrapSegments(segments, cols) {
2239
- if (!segments || segments.length === 0) return [""];
2240
- if (!cols || cols <= 0) return [segments.join("")];
2241
-
2242
- const lines = [];
2243
- let cur = [];
2244
- let curW = 0;
2245
-
2246
- for (let seg of segments) {
2247
- let segW = visibleWidth(seg);
2248
- if (segW <= 0) continue;
2249
-
2250
- if (cur.length === 0) {
2251
- if (segW > cols) {
2252
- seg = clampAnsi(seg, cols);
2253
- segW = visibleWidth(seg);
2254
- }
2255
- cur = [seg];
2256
- curW = segW;
2257
- continue;
2258
- }
2259
-
2260
- if (curW + segW <= cols) {
2261
- cur.push(seg);
2262
- curW += segW;
2263
- } else {
2264
- lines.push(cur.join(""));
2265
- if (segW > cols) {
2266
- seg = clampAnsi(seg, cols);
2267
- segW = visibleWidth(seg);
2268
- }
2269
- cur = [seg];
2270
- curW = segW;
2271
- }
2272
- }
2273
-
2274
- if (cur.length) lines.push(cur.join(""));
2275
- return lines.length ? lines : [""];
2276
- }
2277
-
2278
- class StatusRenderer {
2279
- constructor() {
2280
- this.raw = "";
2281
- this.segments = [];
2282
- this.lines = [""];
2283
- this.activeReservedRows = RESERVED_ROWS;
2284
- this.force = false;
2285
- this.urgent = false;
2286
- this.lastRenderMs = 0;
2287
- this.lastChildOutMs = 0;
2288
- this.cursorVisible = true;
2289
- }
2290
- noteChildOutput() {
2291
- this.lastChildOutMs = Date.now();
2292
- }
2293
- setCursorVisible(v) {
2294
- this.cursorVisible = !!v;
2295
- }
2296
- forceRepaint(urgent = false) {
2297
- this.force = true;
2298
- if (urgent) this.urgent = true;
2299
- }
2300
- setActiveReservedRows(n) {
2301
- const v = Number(n || 1);
2302
- this.activeReservedRows = Number.isFinite(v) ? Math.max(1, Math.trunc(v)) : 1;
2303
- }
2304
- setLine(line) {
2305
- const next = String(line || "");
2306
- if (next !== this.raw) {
2307
- this.raw = next;
2308
- this.segments = splitSegments(next);
2309
- this.force = true;
2310
- }
2311
- }
2312
- desiredReservedRows(physicalRows, cols, minReserved) {
2313
- let rows = Number(physicalRows || 24);
2314
- rows = Number.isFinite(rows) ? rows : 24;
2315
- cols = Number(cols || 80);
2316
- cols = Number.isFinite(cols) ? cols : 80;
2317
-
2318
- const maxReserved = Math.max(1, rows - 4);
2319
- const segs = this.segments.length ? this.segments : (this.raw ? [this.raw] : []);
2320
- let lines = segs.length ? wrapSegments(segs, cols) : [""];
2321
-
2322
- const needed = Math.min(lines.length, maxReserved);
2323
- let desired = Math.max(Number(minReserved || 1), needed);
2324
- desired = Math.min(desired, maxReserved);
2325
-
2326
- if (lines.length < desired) lines = new Array(desired - lines.length).fill("").concat(lines);
2327
- if (lines.length > desired) lines = lines.slice(-desired);
2328
-
2329
- this.lines = lines;
2330
- return desired;
2331
- }
2332
- clearReservedArea(physicalRows, cols, reservedRows, restoreRow = 1, restoreCol = 1) {
2333
- let rows = Number(physicalRows || 24);
2334
- rows = Number.isFinite(rows) ? rows : 24;
2335
- cols = Number(cols || 80);
2336
- cols = Number.isFinite(cols) ? cols : 80;
2337
- let reserved = Number(reservedRows || 1);
2338
- reserved = Number.isFinite(reserved) ? Math.max(1, Math.trunc(reserved)) : 1;
2339
-
2340
- reserved = Math.min(reserved, rows);
2341
- const startRow = rows - reserved + 1;
2342
- const parts = ["\\x1b[?2026h", "\\x1b[?25l", RESET_SGR];
2343
- for (let i = 0; i < reserved; i++) parts.push("\\x1b[" + (startRow + i) + ";1H\\x1b[2K");
2344
- parts.push("\\x1b[" + restoreRow + ";" + restoreCol + "H");
2345
- parts.push(this.cursorVisible ? "\\x1b[?25h" : "\\x1b[?25l");
2346
- parts.push("\\x1b[?2026l");
2347
- writeStdout(parts.join(""));
2348
- }
2349
- render(physicalRows, cols, restoreRow = 1, restoreCol = 1) {
2350
- if (!this.force) return;
2351
- if (!this.raw) {
2352
- this.force = false;
2353
- this.urgent = false;
2354
- return;
2355
- }
2356
- const now = Date.now();
2357
- if (!this.urgent && now - this.lastRenderMs < MIN_RENDER_INTERVAL_MS) return;
2358
- if (!this.urgent && QUIET_MS > 0 && now - this.lastChildOutMs < QUIET_MS) return;
2359
-
2360
- let rows = Number(physicalRows || 24);
2361
- rows = Number.isFinite(rows) ? rows : 24;
2362
- cols = Number(cols || 80);
2363
- cols = Number.isFinite(cols) ? cols : 80;
2364
- if (cols <= 0) cols = 80;
2365
-
2366
- const reserved = Math.max(1, Math.min(this.activeReservedRows, Math.max(1, rows - 4)));
2367
- const startRow = rows - reserved + 1;
2368
- const childRows = rows - reserved;
2369
-
2370
- let lines = this.lines.length ? this.lines.slice() : [""];
2371
- if (lines.length < reserved) lines = new Array(reserved - lines.length).fill("").concat(lines);
2372
- if (lines.length > reserved) lines = lines.slice(-reserved);
2373
-
2374
- const parts = ["\\x1b[?2026h", "\\x1b[?25l"];
2375
- parts.push("\\x1b[1;" + childRows + "r");
2376
- for (let i = 0; i < reserved; i++) {
2377
- const row = startRow + i;
2378
- const text = clampAnsi(lines[i], cols);
2379
- parts.push("\\x1b[" + row + ";1H" + RESET_SGR + "\\x1b[2K");
2380
- parts.push("\\x1b[" + row + ";1H" + text + RESET_SGR);
2381
- }
2382
- parts.push("\\x1b[" + restoreRow + ";" + restoreCol + "H");
2383
- parts.push(this.cursorVisible ? "\\x1b[?25h" : "\\x1b[?25l");
2384
- parts.push("\\x1b[?2026l");
2385
- writeStdout(parts.join(""));
2386
-
2387
- this.lastRenderMs = now;
2388
- this.force = false;
2389
- this.urgent = false;
2390
- }
2391
- clear() {
2392
- const { rows, cols } = termSize();
2393
- this.clearReservedArea(rows, cols, Math.max(this.activeReservedRows, RESERVED_ROWS));
2394
- }
2395
- }
2396
-
2397
- class OutputRewriter {
2398
- constructor() {
2399
- this.buf = new Uint8Array(0);
2400
- }
2401
- feed(chunk, maxRow) {
2402
- if (!chunk || chunk.length === 0) return chunk;
2403
- const merged = new Uint8Array(this.buf.length + chunk.length);
2404
- merged.set(this.buf, 0);
2405
- merged.set(chunk, this.buf.length);
2406
- this.buf = new Uint8Array(0);
2407
-
2408
- const out = [];
2409
- let i = 0;
2410
-
2411
- const isFinal = (v) => v >= 0x40 && v <= 0x7e;
2412
-
2413
- while (i < merged.length) {
2414
- const b = merged[i];
2415
- if (b !== 0x1b) {
2416
- out.push(b);
2417
- i += 1;
2418
- continue;
2419
- }
2420
- if (i + 1 >= merged.length) {
2421
- this.buf = merged.slice(i);
2422
- break;
2423
- }
2424
- const nxt = merged[i + 1];
2425
- if (nxt !== 0x5b) {
2426
- out.push(b);
2427
- i += 1;
2428
- continue;
2429
- }
2430
-
2431
- let j = i + 2;
2432
- while (j < merged.length && !isFinal(merged[j])) j += 1;
2433
- if (j >= merged.length) {
2434
- this.buf = merged.slice(i);
2435
- break;
2436
- }
2437
- const final = merged[j];
2438
- let seq = merged.slice(i, j + 1);
2439
-
2440
- if ((final === 0x48 || final === 0x66) && maxRow > 0) {
2441
- const params = merged.slice(i + 2, j);
2442
- const s = new TextDecoder().decode(params);
2443
- if (!s || /^[0-9;]/.test(s)) {
2444
- const parts = s ? s.split(";") : [];
2445
- const row = Number(parts[0] || 1);
2446
- const col = Number(parts[1] || 1);
2447
- let r = Number.isFinite(row) ? row : 1;
2448
- let c = Number.isFinite(col) ? col : 1;
2449
- if (r === 999 || r > maxRow) r = maxRow;
2450
- if (r < 1) r = 1;
2451
- if (c < 1) c = 1;
2452
- const newParams = new TextEncoder().encode(String(r) + ";" + String(c));
2453
- const ns = new Uint8Array(2 + newParams.length + 1);
2454
- ns[0] = 0x1b;
2455
- ns[1] = 0x5b;
2456
- ns.set(newParams, 2);
2457
- ns[ns.length - 1] = final;
2458
- seq = ns;
2459
- }
2460
- } else if (final === 0x72 && maxRow > 0) {
2461
- const params = merged.slice(i + 2, j);
2462
- const s = new TextDecoder().decode(params);
2463
- if (!s || /^[0-9;]/.test(s)) {
2464
- const parts = s ? s.split(";") : [];
2465
- const top = Number(parts[0] || 1);
2466
- const bottom = Number(parts[1] || maxRow);
2467
- let t = Number.isFinite(top) ? top : 1;
2468
- let btm = Number.isFinite(bottom) ? bottom : maxRow;
2469
- if (t <= 0) t = 1;
2470
- if (btm <= 0 || btm === 999 || btm > maxRow) btm = maxRow;
2471
- if (t > btm) t = 1;
2472
- const str = "\\x1b[" + String(t) + ";" + String(btm) + "r";
2473
- seq = new TextEncoder().encode(str);
2474
- }
2475
- }
2476
-
2477
- for (const bb of seq) out.push(bb);
2478
- i = j + 1;
2479
- }
2480
-
2481
- return new Uint8Array(out);
2482
- }
2483
- }
2484
-
2485
- class CursorTracker {
2486
- constructor() {
2487
- this.row = 1;
2488
- this.col = 1;
2489
- this.savedRow = 1;
2490
- this.savedCol = 1;
2491
- this.buf = new Uint8Array(0);
2492
- this.inOsc = false;
2493
- this.utf8Cont = 0;
2494
- this.wrapPending = false;
2495
- }
2496
- position() {
2497
- return { row: this.row, col: this.col };
2498
- }
2499
- feed(chunk, maxRow, maxCol) {
2500
- if (!chunk || chunk.length === 0) return;
2501
- maxRow = Math.max(1, Number(maxRow || 1));
2502
- maxCol = Math.max(1, Number(maxCol || 1));
2503
-
2504
- const merged = new Uint8Array(this.buf.length + chunk.length);
2505
- merged.set(this.buf, 0);
2506
- merged.set(chunk, this.buf.length);
2507
- this.buf = new Uint8Array(0);
2508
-
2509
- const clamp = () => {
2510
- if (this.row < 1) this.row = 1;
2511
- else if (this.row > maxRow) this.row = maxRow;
2512
- if (this.col < 1) this.col = 1;
2513
- else if (this.col > maxCol) this.col = maxCol;
2514
- };
2515
-
2516
- const parseIntDefault = (v, d) => {
2517
- const n = Number(v);
2518
- return Number.isFinite(n) && n > 0 ? Math.trunc(n) : d;
2519
- };
2520
-
2521
- let i = 0;
2522
- const isFinal = (v) => v >= 0x40 && v <= 0x7e;
2523
-
2524
- while (i < merged.length) {
2525
- const b = merged[i];
2526
-
2527
- if (this.inOsc) {
2528
- if (b === 0x07) {
2529
- this.inOsc = false;
2530
- i += 1;
2531
- continue;
2532
- }
2533
- if (b === 0x1b) {
2534
- if (i + 1 >= merged.length) {
2535
- this.buf = merged.slice(i);
2536
- break;
2537
- }
2538
- if (merged[i + 1] === 0x5c) {
2539
- this.inOsc = false;
2540
- i += 2;
2541
- continue;
2542
- }
2543
- }
2544
- i += 1;
2545
- continue;
2546
- }
2547
-
2548
- if (this.utf8Cont > 0) {
2549
- if (b >= 0x80 && b <= 0xbf) {
2550
- this.utf8Cont -= 1;
2551
- i += 1;
2552
- continue;
2553
- }
2554
- this.utf8Cont = 0;
2555
- }
2556
-
2557
- if (b === 0x1b) {
2558
- this.wrapPending = false;
2559
- if (i + 1 >= merged.length) {
2560
- this.buf = merged.slice(i);
2561
- break;
2562
- }
2563
- const nxt = merged[i + 1];
2564
-
2565
- if (nxt === 0x5b) {
2566
- let j = i + 2;
2567
- while (j < merged.length && !isFinal(merged[j])) j += 1;
2568
- if (j >= merged.length) {
2569
- this.buf = merged.slice(i);
2570
- break;
2571
- }
2572
- const final = merged[j];
2573
- const params = merged.slice(i + 2, j);
2574
- const s = new TextDecoder().decode(params);
2575
- if (s && !/^[0-9;]/.test(s)) {
2576
- i = j + 1;
2577
- continue;
2578
- }
2579
- const parts = s ? s.split(";") : [];
2580
- const p0 = parseIntDefault(parts[0] || "", 1);
2581
- const p1 = parseIntDefault(parts[1] || "", 1);
2582
-
2583
- if (final === 0x48 || final === 0x66) {
2584
- this.row = p0;
2585
- this.col = p1;
2586
- clamp();
2587
- } else if (final === 0x41) {
2588
- this.row = Math.max(1, this.row - p0);
2589
- } else if (final === 0x42) {
2590
- this.row = Math.min(maxRow, this.row + p0);
2591
- } else if (final === 0x43) {
2592
- this.col = Math.min(maxCol, this.col + p0);
2593
- } else if (final === 0x44) {
2594
- this.col = Math.max(1, this.col - p0);
2595
- } else if (final === 0x45) {
2596
- this.row = Math.min(maxRow, this.row + p0);
2597
- this.col = 1;
2598
- } else if (final === 0x46) {
2599
- this.row = Math.max(1, this.row - p0);
2600
- this.col = 1;
2601
- } else if (final === 0x47) {
2602
- this.col = p0;
2603
- clamp();
2604
- } else if (final === 0x64) {
2605
- this.row = p0;
2606
- clamp();
2607
- } else if (final === 0x72) {
2608
- this.row = 1;
2609
- this.col = 1;
2610
- } else if (final === 0x73) {
2611
- this.savedRow = this.row;
2612
- this.savedCol = this.col;
2613
- } else if (final === 0x75) {
2614
- this.row = this.savedRow;
2615
- this.col = this.savedCol;
2616
- clamp();
2617
- }
2618
-
2619
- i = j + 1;
2620
- continue;
2621
- }
2622
-
2623
- if (nxt === 0x5d || nxt === 0x50 || nxt === 0x5e || nxt === 0x5f || nxt === 0x58) {
2624
- this.inOsc = true;
2625
- i += 2;
2626
- continue;
2627
- }
2628
-
2629
- if (nxt === 0x37) {
2630
- this.savedRow = this.row;
2631
- this.savedCol = this.col;
2632
- i += 2;
2633
- continue;
2634
- }
2635
- if (nxt === 0x38) {
2636
- this.row = this.savedRow;
2637
- this.col = this.savedCol;
2638
- clamp();
2639
- i += 2;
2640
- continue;
2641
- }
2642
-
2643
- i += 2;
2644
- continue;
2645
- }
2646
-
2647
- if (b === 0x0d) {
2648
- this.col = 1;
2649
- this.wrapPending = false;
2650
- i += 1;
2651
- continue;
2652
- }
2653
- if (b === 0x0a || b === 0x0b || b === 0x0c) {
2654
- this.row = Math.min(maxRow, this.row + 1);
2655
- this.wrapPending = false;
2656
- i += 1;
2657
- continue;
2658
- }
2659
- if (b === 0x08) {
2660
- this.col = Math.max(1, this.col - 1);
2661
- this.wrapPending = false;
2662
- i += 1;
2663
- continue;
2664
- }
2665
- if (b === 0x09) {
2666
- const nextStop = Math.floor((this.col - 1) / 8 + 1) * 8 + 1;
2667
- this.col = Math.min(maxCol, nextStop);
2668
- this.wrapPending = false;
2669
- i += 1;
2670
- continue;
2671
- }
2672
- if (b < 0x20 || b === 0x7f) {
2673
- i += 1;
2674
- continue;
2675
- }
2676
-
2677
- if (this.wrapPending) {
2678
- this.row = Math.min(maxRow, this.row + 1);
2679
- this.col = 1;
2680
- this.wrapPending = false;
2681
- }
2682
-
2683
- if (b >= 0x80) {
2684
- if ((b & 0xe0) === 0xc0) this.utf8Cont = 1;
2685
- else if ((b & 0xf0) === 0xe0) this.utf8Cont = 2;
2686
- else if ((b & 0xf8) === 0xf0) this.utf8Cont = 3;
2687
- else this.utf8Cont = 0;
2688
- }
2689
-
2690
- if (this.col < maxCol) this.col += 1;
2691
- else {
2692
- this.col = maxCol;
2693
- this.wrapPending = true;
2694
- }
2695
- i += 1;
2696
- }
2697
- }
2698
- }
2699
-
2700
- async function main() {
2701
- const argv = process.argv.slice(2);
2702
-
2703
- if (isSessionsCommand(argv)) await runSessions();
2704
-
2705
- if (!process.stdin.isTTY || !process.stdout.isTTY || shouldPassthrough(argv)) {
2706
- await execPassthrough(argv);
2707
- return;
2708
- }
2709
-
2710
- // Clean viewport.
2711
- writeStdout("\\x1b[?2026h\\x1b[0m\\x1b[r\\x1b[2J\\x1b[H\\x1b[?2026l");
2712
-
2713
- const renderer = new StatusRenderer();
2714
- renderer.setLine("\\x1b[48;5;238m\\x1b[38;5;15m Statusline: starting… \\x1b[0m");
2715
- renderer.forceRepaint(true);
2716
-
2717
- let { rows: physicalRows, cols: physicalCols } = termSize();
2718
- let effectiveReservedRows = renderer.desiredReservedRows(physicalRows, physicalCols, RESERVED_ROWS);
2719
- renderer.setActiveReservedRows(effectiveReservedRows);
2720
- let childRows = Math.max(4, physicalRows - effectiveReservedRows);
2721
- let childCols = Math.max(10, physicalCols);
2722
-
2723
- // Reserve the bottom rows early, before the child starts writing.
2724
- writeStdout(
2725
- "\\x1b[?2026h\\x1b[?25l\\x1b[1;" + childRows + "r\\x1b[1;1H\\x1b[?25h\\x1b[?2026l",
2726
- );
2727
- renderer.forceRepaint(true);
2728
- renderer.render(physicalRows, physicalCols, 1, 1);
2729
-
2730
- // Spawn child with terminal support.
2731
- let child;
2732
- try {
2733
- child = Bun.spawn([EXEC_TARGET, ...argv], {
2734
- cwd: process.cwd(),
2735
- env: process.env,
2736
- detached: true,
2737
- terminal: {
2738
- cols: childCols,
2739
- rows: childRows,
2740
- data(_terminal, data) {
2741
- onChildData(data);
2742
- },
2743
- },
2744
- onExit(_proc, exitCode, signal, _error) {
2745
- onChildExit(exitCode, signal);
2746
- },
2747
- });
2748
- } catch (e) {
2749
- process.stderr.write("[statusline] failed to spawn child: " + String(e?.message || e) + "\\n");
2750
- process.exit(1);
2751
- }
2752
-
2753
- const terminal = child.terminal;
2754
-
2755
- // Best-effort PGID resolution (matches Python wrapper behavior).
2756
- // This improves session resolution (ps/lsof scanning) and signal forwarding.
2757
- let pgid = child.pid;
2758
- try {
2759
- const res = Bun.spawnSync(["ps", "-o", "pgid=", "-p", String(child.pid)], {
2760
- stdin: "ignore",
2761
- stdout: "pipe",
2762
- stderr: "ignore",
2763
- });
2764
- if (res && res.exitCode === 0 && res.stdout) {
2765
- const text = new TextDecoder().decode(res.stdout).trim();
2766
- const n = Number(text);
2767
- if (Number.isFinite(n) && n > 0) pgid = Math.trunc(n);
2768
- }
2769
- } catch {}
2770
-
2771
- // Spawn monitor (Node).
2772
- const monitorEnv = { ...process.env, DROID_STATUSLINE_PGID: String(pgid) };
2773
- const monitor = Bun.spawn(["node", STATUSLINE_MONITOR, ...argv], {
2774
- stdin: "ignore",
2775
- stdout: "pipe",
2776
- stderr: "ignore",
2777
- env: monitorEnv,
2778
- });
2779
-
2780
- let shouldStop = false;
2781
- const rewriter = new OutputRewriter();
2782
- const cursor = new CursorTracker();
2783
-
2784
- let detectBuf = new Uint8Array(0);
2785
- let detectStr = "";
2786
- let cursorVisible = true;
2787
- let scrollRegionDirty = true;
2788
- let lastForceRepaintMs = Date.now();
2789
- let lastPhysicalRows = 0;
2790
- let lastPhysicalCols = 0;
2791
-
2792
- function appendDetect(chunk) {
2793
- const max = 128;
2794
- const merged = new Uint8Array(Math.min(max, detectBuf.length + chunk.length));
2795
- const takePrev = Math.max(0, merged.length - chunk.length);
2796
- if (takePrev > 0) merged.set(detectBuf.slice(Math.max(0, detectBuf.length - takePrev)), 0);
2797
- merged.set(chunk.slice(Math.max(0, chunk.length - (merged.length - takePrev))), takePrev);
2798
- detectBuf = merged;
2799
- try {
2800
- detectStr = Buffer.from(detectBuf).toString("latin1");
2801
- } catch {
2802
- detectStr = "";
2803
- }
2804
- }
2805
-
2806
- function includesBytes(needle) {
2807
- return detectStr.includes(needle);
2808
- }
2809
-
2810
- function lastIndexOfBytes(needle) {
2811
- return detectStr.lastIndexOf(needle);
2812
- }
2813
-
2814
- function includesScrollRegionCSI() {
2815
- return /\\x1b\\[[0-9]*;?[0-9]*r/.test(detectStr);
2816
- }
2817
-
2818
- function updateCursorVisibility() {
2819
- const show = includesBytes("\\x1b[?25h");
2820
- const hide = includesBytes("\\x1b[?25l");
2821
- if (show || hide) {
2822
- // best-effort: if both present, whichever appears later "wins"
2823
- const h = lastIndexOfBytes("\\x1b[?25h");
2824
- const l = lastIndexOfBytes("\\x1b[?25l");
2825
- cursorVisible = h > l;
2826
- renderer.setCursorVisible(cursorVisible);
2827
- }
2828
- }
2829
-
2830
- function needsScrollRegionReset() {
2831
- return (
2832
- includesBytes("\\x1b[?1049") ||
2833
- includesBytes("\\x1b[?1047") ||
2834
- includesBytes("\\x1b[?47") ||
2835
- includesBytes("\\x1b[J") ||
2836
- includesBytes("\\x1b[0J") ||
2837
- includesBytes("\\x1b[1J") ||
2838
- includesBytes("\\x1b[2J") ||
2839
- includesBytes("\\x1b[3J") ||
2840
- includesBytes("\\x1b[r") ||
2841
- includesScrollRegionCSI()
2842
- );
2843
- }
2844
-
2845
- function onChildData(data) {
2846
- if (shouldStop) return;
2847
- const chunk = data instanceof Uint8Array ? data : new Uint8Array(data);
2848
- appendDetect(chunk);
2849
- if (needsScrollRegionReset()) scrollRegionDirty = true;
2850
- updateCursorVisibility();
2851
-
2852
- renderer.noteChildOutput();
2853
- const rewritten = rewriter.feed(chunk, childRows);
2854
- cursor.feed(rewritten, childRows, childCols);
2855
- writeStdout(Buffer.from(rewritten));
2856
- }
2857
-
2858
- let cleanupCalled = false;
2859
- function onChildExit(exitCode, signal) {
2860
- shouldStop = true;
2861
- if (cleanupCalled) return;
2862
- cleanupCalled = true;
2863
- const code = exitCode ?? (signal != null ? 128 + signal : 0);
2864
- cleanup().finally(() => process.exit(code));
2865
- }
2866
-
2867
- async function readMonitor() {
2868
- if (!monitor.stdout) return;
2869
- const reader = monitor.stdout.getReader();
2870
- let buf = "";
2871
- while (!shouldStop) {
2872
- const { value, done } = await reader.read();
2873
- if (done || !value) break;
2874
- buf += new TextDecoder().decode(value);
2875
- while (true) {
2876
- const idx = buf.indexOf("\\n");
2877
- if (idx === -1) break;
2878
- const line = buf.slice(0, idx).replace(/\\r$/, "");
2879
- buf = buf.slice(idx + 1);
2880
- if (!line) continue;
2881
- renderer.setLine(line);
2882
- renderer.forceRepaint(false);
2883
- }
2884
- }
2885
- }
2886
- readMonitor().catch(() => {});
2887
-
2888
- function repaintStatusline(forceUrgent = false) {
2889
- const { row, col } = cursor.position();
2890
- let r = Math.max(1, Math.min(childRows, row));
2891
- let c = Math.max(1, Math.min(childCols, col));
2892
-
2893
- if (scrollRegionDirty) {
2894
- const seq =
2895
- "\\x1b[?2026h\\x1b[?25l\\x1b[1;" +
2896
- childRows +
2897
- "r\\x1b[" +
2898
- r +
2899
- ";" +
2900
- c +
2901
- "H" +
2902
- (cursorVisible ? "\\x1b[?25h" : "\\x1b[?25l") +
2903
- "\\x1b[?2026l";
2904
- writeStdout(seq);
2905
- scrollRegionDirty = false;
2906
- }
2907
-
2908
- renderer.forceRepaint(forceUrgent);
2909
- renderer.render(physicalRows, physicalCols, r, c);
2910
- }
2911
-
2912
- function handleSizeChange(nextRows, nextCols, forceUrgent = false) {
2913
- physicalRows = nextRows;
2914
- physicalCols = nextCols;
2915
-
2916
- const desired = renderer.desiredReservedRows(physicalRows, physicalCols, RESERVED_ROWS);
2917
- const { row, col } = cursor.position();
2918
- if (desired < effectiveReservedRows) {
2919
- renderer.clearReservedArea(physicalRows, physicalCols, effectiveReservedRows, row, col);
2920
- }
2921
- effectiveReservedRows = desired;
2922
- renderer.setActiveReservedRows(effectiveReservedRows);
2923
-
2924
- childRows = Math.max(4, physicalRows - effectiveReservedRows);
2925
- childCols = Math.max(10, physicalCols);
2926
- try {
2927
- terminal.resize(childCols, childRows);
2928
- } catch {}
2929
- try {
2930
- process.kill(-child.pid, "SIGWINCH");
2931
- } catch {
2932
- try { process.kill(child.pid, "SIGWINCH"); } catch {}
2933
- }
2934
-
2935
- scrollRegionDirty = true;
2936
- renderer.forceRepaint(true);
2937
- repaintStatusline(forceUrgent);
174
+ res.writeHead(200, { 'Content-Type': 'application/json' });
175
+ res.end(JSON.stringify({ status: 'ok', mode: 'external-providers' }));
176
+ return;
2938
177
  }
2939
178
 
2940
- process.on("SIGWINCH", () => {
2941
- const next = termSize();
2942
- handleSizeChange(next.rows, next.cols, true);
2943
- });
2944
-
2945
- // Forward signals to child's process group when possible.
2946
- const forward = (sig) => {
2947
- // Stop processing child output before forwarding signal
2948
- // This prevents the child's cleanup/clear screen sequences from being written
2949
- shouldStop = true;
2950
- try {
2951
- process.kill(-pgid, sig);
2952
- } catch {
179
+ if (url.pathname === '/api/tools/exa/search' && req.method === 'POST') {
180
+ let body = '';
181
+ req.on('data', function(c) { body += c; });
182
+ req.on('end', async function() {
2953
183
  try {
2954
- process.kill(child.pid, sig);
2955
- } catch {}
2956
- }
2957
- };
2958
- for (const s of ["SIGTERM", "SIGINT", "SIGHUP"]) {
2959
- try {
2960
- process.on(s, () => forward(s));
2961
- } catch {}
184
+ const parsed = JSON.parse(body);
185
+ const result = await search(parsed.query, parsed.numResults || 10);
186
+ log('Results:', result.results.length, 'from', result.source);
187
+ res.writeHead(200, { 'Content-Type': 'application/json' });
188
+ res.end(JSON.stringify({ results: result.results }));
189
+ } catch (e) {
190
+ log('Search error:', e.message);
191
+ res.writeHead(500, { 'Content-Type': 'application/json' });
192
+ res.end(JSON.stringify({ error: String(e), results: [] }));
193
+ }
194
+ });
195
+ return;
2962
196
  }
2963
197
 
2964
- // Raw stdin -> PTY.
2965
- try {
2966
- process.stdin.setRawMode(true);
2967
- } catch {}
2968
- process.stdin.resume();
2969
- process.stdin.on("data", (buf) => {
2970
- try {
2971
- if (typeof buf === "string") terminal.write(buf);
2972
- else {
2973
- // Prefer bytes when supported; fall back to UTF-8 decoding.
2974
- try {
2975
- // Bun.Terminal.write may accept Uint8Array in newer versions.
2976
- terminal.write(buf);
2977
- } catch {
2978
- terminal.write(new TextDecoder().decode(buf));
2979
- }
2980
- }
2981
- } catch {}
198
+ // Proxy other requests
199
+ const proxyUrl = new URL(FACTORY_API + url.pathname + url.search);
200
+ const proxyModule = proxyUrl.protocol === 'https:' ? https : http;
201
+ const proxyReq = proxyModule.request(proxyUrl, {
202
+ method: req.method,
203
+ headers: Object.assign({}, req.headers, { host: proxyUrl.host })
204
+ }, function(proxyRes) {
205
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
206
+ proxyRes.pipe(res);
2982
207
  });
2983
208
 
2984
- const tick = setInterval(() => {
2985
- if (shouldStop) return;
2986
- const next = termSize();
2987
- const sizeChanged = next.rows !== lastPhysicalRows || next.cols !== lastPhysicalCols;
2988
- const desired = renderer.desiredReservedRows(next.rows, next.cols, RESERVED_ROWS);
2989
- if (sizeChanged || desired !== effectiveReservedRows) {
2990
- handleSizeChange(next.rows, next.cols, true);
2991
- lastPhysicalRows = next.rows;
2992
- lastPhysicalCols = next.cols;
2993
- lastForceRepaintMs = Date.now();
2994
- return;
2995
- }
2996
- const now = Date.now();
2997
- if (now - lastForceRepaintMs >= FORCE_REPAINT_INTERVAL_MS) {
2998
- repaintStatusline(false);
2999
- lastForceRepaintMs = now;
3000
- } else {
3001
- const { row, col } = cursor.position();
3002
- renderer.render(physicalRows, physicalCols, row, col);
3003
- }
3004
- }, 50);
209
+ proxyReq.on('error', function(e) {
210
+ res.writeHead(502, { 'Content-Type': 'application/json' });
211
+ res.end(JSON.stringify({ error: 'Proxy failed: ' + e.message }));
212
+ });
3005
213
 
3006
- async function cleanup() {
3007
- clearInterval(tick);
3008
- try {
3009
- process.stdin.setRawMode(false);
3010
- } catch {}
3011
- // Don't clear screen or reset scroll region on exit - preserve session ID and logs
3012
- // Only reset colors and show cursor
3013
- try {
3014
- writeStdout("\\x1b[0m\\x1b[?25h");
3015
- } catch {}
3016
- try {
3017
- monitor.kill();
3018
- } catch {}
3019
- try {
3020
- terminal.close();
3021
- } catch {}
3022
- }
214
+ if (req.method !== 'GET' && req.method !== 'HEAD') req.pipe(proxyReq);
215
+ else proxyReq.end();
216
+ });
3023
217
 
3024
- // Keep process alive until child exits.
3025
- await child.exited;
3026
- await cleanup();
3027
- }
218
+ server.listen(PORT, '127.0.0.1', function() {
219
+ const actualPort = server.address().port;
220
+ const portFile = process.env.SEARCH_PROXY_PORT_FILE;
221
+ if (portFile) fs.writeFileSync(portFile, String(actualPort));
222
+ console.log('PORT=' + actualPort);
223
+ log('External providers proxy on port', actualPort);
224
+ });
3028
225
 
3029
- main().catch(() => process.exit(1));
226
+ process.on('SIGTERM', function() { server.close(); process.exit(0); });
227
+ process.on('SIGINT', function() { server.close(); process.exit(0); });
3030
228
  `;
3031
229
  }
3032
- async function createStatuslineFiles(outputDir, execTargetPath, aliasName, sessionsScriptPath) {
3033
- if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true });
3034
- const monitorScriptPath = join(outputDir, `${aliasName}-statusline.js`);
3035
- const wrapperScriptPath = join(outputDir, aliasName);
3036
- await writeFile(monitorScriptPath, generateStatuslineMonitorScript());
3037
- await chmod(monitorScriptPath, 493);
3038
- await writeFile(wrapperScriptPath, generateStatuslineWrapperScript(execTargetPath, monitorScriptPath, sessionsScriptPath));
3039
- await chmod(wrapperScriptPath, 493);
3040
- return {
3041
- wrapperScript: wrapperScriptPath,
3042
- monitorScript: monitorScriptPath
3043
- };
230
+ function generateExternalSearchProxyServer(factoryApiUrl = "https://api.factory.ai") {
231
+ return generateSearchProxyServerCode().replace("const FACTORY_API = 'https://api.factory.ai';", `const FACTORY_API = '${factoryApiUrl}';`);
3044
232
  }
3045
233
 
3046
234
  //#endregion
3047
- //#region src/sessions-patch.ts
235
+ //#region src/websearch-native.ts
3048
236
  /**
3049
- * Generate sessions browser script (Node.js)
237
+ * WebSearch Native Provider Mode (--websearch-proxy)
238
+ *
239
+ * Uses model's native websearch based on ~/.factory/settings.json configuration
240
+ * Requires proxy plugin (anthropic4droid) to handle format conversion
241
+ *
242
+ * Supported providers: Anthropic, OpenAI (extensible)
3050
243
  */
3051
- function generateSessionsBrowserScript(aliasName) {
244
+ function generateNativeSearchProxyServer(factoryApiUrl = "https://api.factory.ai") {
3052
245
  return `#!/usr/bin/env node
3053
- // Droid Sessions Browser - Interactive selector
3054
- // Auto-generated by droid-patch
246
+ // Droid WebSearch Proxy Server (Native Provider Mode)
247
+ // Reads ~/.factory/settings.json for model configuration
248
+ // Requires proxy plugin (anthropic4droid) to handle format conversion
3055
249
 
250
+ const http = require('http');
251
+ const https = require('https');
252
+ const { execSync } = require('child_process');
3056
253
  const fs = require('fs');
3057
254
  const path = require('path');
3058
- const readline = require('readline');
3059
- const { execSync, spawn } = require('child_process');
255
+ const os = require('os');
256
+
257
+ const DEBUG = process.env.DROID_SEARCH_DEBUG === '1';
258
+ const PORT = parseInt(process.env.SEARCH_PROXY_PORT || '0');
259
+ const FACTORY_API = '${factoryApiUrl}';
3060
260
 
3061
- const FACTORY_HOME = path.join(require('os').homedir(), '.factory');
3062
- const SESSIONS_ROOT = path.join(FACTORY_HOME, 'sessions');
3063
- const ALIAS_NAME = ${JSON.stringify(aliasName)};
261
+ function log(...args) { if (DEBUG) console.error('[websearch]', ...args); }
3064
262
 
3065
- // ANSI
3066
- const CYAN = '\\x1b[36m';
3067
- const GREEN = '\\x1b[32m';
3068
- const YELLOW = '\\x1b[33m';
3069
- const RED = '\\x1b[31m';
3070
- const DIM = '\\x1b[2m';
3071
- const RESET = '\\x1b[0m';
3072
- const BOLD = '\\x1b[1m';
3073
- const CLEAR = '\\x1b[2J\\x1b[H';
3074
- const HIDE_CURSOR = '\\x1b[?25l';
3075
- const SHOW_CURSOR = '\\x1b[?25h';
263
+ // === Settings Configuration ===
3076
264
 
3077
- function sanitizePath(p) {
3078
- return p.replace(/:/g, '').replace(/[\\\\/]/g, '-');
265
+ let cachedSettings = null;
266
+ let settingsLastModified = 0;
267
+
268
+ function getFactorySettings() {
269
+ const settingsPath = path.join(os.homedir(), '.factory', 'settings.json');
270
+ try {
271
+ const stats = fs.statSync(settingsPath);
272
+ if (cachedSettings && stats.mtimeMs === settingsLastModified) return cachedSettings;
273
+ cachedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
274
+ settingsLastModified = stats.mtimeMs;
275
+ return cachedSettings;
276
+ } catch (e) {
277
+ log('Failed to load settings.json:', e.message);
278
+ return null;
279
+ }
3079
280
  }
3080
281
 
3081
- function parseSessionFile(jsonlPath, settingsPath) {
3082
- const sessionId = path.basename(jsonlPath, '.jsonl');
3083
- const stats = fs.statSync(jsonlPath);
282
+ function getCurrentModelConfig() {
283
+ const settings = getFactorySettings();
284
+ if (!settings) return null;
285
+
286
+ const currentModelId = settings.sessionDefaultSettings?.model;
287
+ if (!currentModelId) return null;
288
+
289
+ const customModels = settings.customModels || [];
290
+ const modelConfig = customModels.find(m => m.id === currentModelId);
291
+
292
+ if (modelConfig) {
293
+ log('Model:', modelConfig.displayName, '| Provider:', modelConfig.provider);
294
+ return modelConfig;
295
+ }
3084
296
 
3085
- const result = {
3086
- id: sessionId,
3087
- title: '',
3088
- mtime: stats.mtimeMs,
3089
- model: '',
3090
- firstUserMsg: '',
3091
- lastUserMsg: '',
3092
- messageCount: 0,
3093
- lastTimestamp: '',
3094
- };
297
+ if (!currentModelId.startsWith('custom:')) return null;
298
+ log('Model not found:', currentModelId);
299
+ return null;
300
+ }
301
+
302
+ // === Native Provider WebSearch ===
3095
303
 
304
+ async function searchAnthropicNative(query, numResults, modelConfig) {
305
+ const { baseUrl, apiKey, model } = modelConfig;
306
+
3096
307
  try {
3097
- const content = fs.readFileSync(jsonlPath, 'utf-8');
3098
- const lines = content.split('\\n').filter(l => l.trim());
3099
- const userMessages = [];
308
+ const requestBody = {
309
+ model: model,
310
+ max_tokens: 4096,
311
+ stream: false,
312
+ system: 'You are a web search assistant. Use the web_search tool to find relevant information and return the results.',
313
+ tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 1 }],
314
+ tool_choice: { type: 'tool', name: 'web_search' },
315
+ messages: [{ role: 'user', content: 'Search the web for: ' + query + '\\n\\nReturn up to ' + numResults + ' relevant results.' }]
316
+ };
3100
317
 
3101
- for (const line of lines) {
3102
- try {
3103
- const obj = JSON.parse(line);
3104
- if (obj.type === 'session_start') {
3105
- result.title = obj.title || '';
3106
- } else if (obj.type === 'message') {
3107
- result.messageCount++;
3108
- if (obj.timestamp) result.lastTimestamp = obj.timestamp;
3109
-
3110
- const msg = obj.message || {};
3111
- if (msg.role === 'user' && Array.isArray(msg.content)) {
3112
- for (const c of msg.content) {
3113
- if (c && c.type === 'text' && c.text && !c.text.startsWith('<system-reminder>')) {
3114
- userMessages.push(c.text.slice(0, 150).replace(/\\n/g, ' ').trim());
3115
- break;
3116
- }
3117
- }
318
+ let endpoint = baseUrl;
319
+ if (!endpoint.endsWith('/v1/messages')) endpoint = endpoint.replace(/\\/$/, '') + '/v1/messages';
320
+
321
+ log('Anthropic search:', query, '', endpoint);
322
+
323
+ const bodyStr = JSON.stringify(requestBody).replace(/'/g, "'\\\\''");
324
+ const curlCmd = 'curl -s -X POST "' + endpoint + '" -H "Content-Type: application/json" -H "anthropic-version: 2023-06-01" -H "x-api-key: ' + apiKey + '" -d \\'' + bodyStr + "\\'";
325
+ const responseStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 60000 });
326
+
327
+ let response;
328
+ try { response = JSON.parse(responseStr); } catch { return null; }
329
+ if (response.error) { log('API error:', response.error.message); return null; }
330
+
331
+ const results = [];
332
+ for (const block of (response.content || [])) {
333
+ if (block.type === 'web_search_tool_result') {
334
+ for (const result of (block.content || [])) {
335
+ if (result.type === 'web_search_result') {
336
+ results.push({
337
+ title: result.title || '',
338
+ url: result.url || '',
339
+ content: result.snippet || result.page_content || ''
340
+ });
3118
341
  }
3119
342
  }
3120
- } catch {}
343
+ }
3121
344
  }
3122
345
 
3123
- if (userMessages.length > 0) {
3124
- result.firstUserMsg = userMessages[0];
3125
- result.lastUserMsg = userMessages.length > 1 ? userMessages[userMessages.length - 1] : '';
3126
- }
3127
- } catch {}
3128
-
3129
- if (fs.existsSync(settingsPath)) {
3130
- try {
3131
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
3132
- result.model = settings.model || '';
3133
- } catch {}
346
+ log('Results:', results.length);
347
+ return results.length > 0 ? results.slice(0, numResults) : null;
348
+ } catch (e) {
349
+ log('Anthropic error:', e.message);
350
+ return null;
3134
351
  }
3135
-
3136
- return result;
3137
352
  }
3138
353
 
3139
- function collectSessions() {
3140
- const cwd = process.cwd();
3141
- const cwdSanitized = sanitizePath(cwd);
3142
- const sessions = [];
3143
-
3144
- if (!fs.existsSync(SESSIONS_ROOT)) return sessions;
3145
-
3146
- for (const wsDir of fs.readdirSync(SESSIONS_ROOT)) {
3147
- if (wsDir !== cwdSanitized) continue;
354
+ async function searchOpenAINative(query, numResults, modelConfig) {
355
+ const { baseUrl, apiKey, model } = modelConfig;
356
+
357
+ try {
358
+ const requestBody = {
359
+ model: model,
360
+ tools: [{ type: 'web_search' }],
361
+ tool_choice: 'required',
362
+ input: 'Search the web for: ' + query + '\\n\\nReturn up to ' + numResults + ' relevant results.'
363
+ };
3148
364
 
3149
- const wsPath = path.join(SESSIONS_ROOT, wsDir);
3150
- if (!fs.statSync(wsPath).isDirectory()) continue;
3151
-
3152
- for (const file of fs.readdirSync(wsPath)) {
3153
- if (!file.endsWith('.jsonl')) continue;
3154
-
3155
- const sessionId = file.slice(0, -6);
3156
- const jsonlPath = path.join(wsPath, file);
3157
- const settingsPath = path.join(wsPath, sessionId + '.settings.json');
3158
-
3159
- try {
3160
- const session = parseSessionFile(jsonlPath, settingsPath);
3161
- if (session.messageCount === 0 || !session.firstUserMsg) continue;
3162
- sessions.push(session);
3163
- } catch {}
365
+ let endpoint = baseUrl;
366
+ if (!endpoint.endsWith('/responses')) endpoint = endpoint.replace(/\\/$/, '') + '/responses';
367
+
368
+ log('OpenAI search:', query, '→', endpoint);
369
+
370
+ const bodyStr = JSON.stringify(requestBody).replace(/'/g, "'\\\\''");
371
+ const curlCmd = 'curl -s -X POST "' + endpoint + '" -H "Content-Type: application/json" -H "Authorization: Bearer ' + apiKey + '" -d \\'' + bodyStr + "\\'";
372
+ const responseStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 60000 });
373
+
374
+ let response;
375
+ try { response = JSON.parse(responseStr); } catch { return null; }
376
+ if (response.error) { log('API error:', response.error.message); return null; }
377
+
378
+ const results = [];
379
+ for (const item of (response.output || [])) {
380
+ if (item.type === 'web_search_call' && item.status === 'completed') {
381
+ for (const result of (item.results || [])) {
382
+ results.push({
383
+ title: result.title || '',
384
+ url: result.url || '',
385
+ content: result.snippet || result.content || ''
386
+ });
387
+ }
388
+ }
3164
389
  }
390
+
391
+ log('Results:', results.length);
392
+ return results.length > 0 ? results.slice(0, numResults) : null;
393
+ } catch (e) {
394
+ log('OpenAI error:', e.message);
395
+ return null;
3165
396
  }
3166
-
3167
- sessions.sort((a, b) => b.mtime - a.mtime);
3168
- return sessions.slice(0, 50);
3169
397
  }
3170
398
 
3171
- function formatTime(ts) {
3172
- if (!ts) return '';
3173
- try {
3174
- const d = new Date(ts);
3175
- return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
3176
- } catch {
3177
- return ts.slice(0, 16);
399
+ async function search(query, numResults) {
400
+ numResults = numResults || 10;
401
+ log('Search:', query);
402
+
403
+ const modelConfig = getCurrentModelConfig();
404
+ if (!modelConfig) {
405
+ log('No custom model configured');
406
+ return { results: [], source: 'none' };
3178
407
  }
408
+
409
+ const provider = modelConfig.provider;
410
+ let results = null;
411
+
412
+ if (provider === 'anthropic') results = await searchAnthropicNative(query, numResults, modelConfig);
413
+ else if (provider === 'openai') results = await searchOpenAINative(query, numResults, modelConfig);
414
+ else log('Unsupported provider:', provider);
415
+
416
+ if (results && results.length > 0) return { results: results, source: 'native-' + provider };
417
+ return { results: [], source: 'none' };
3179
418
  }
3180
419
 
3181
- function truncate(s, len) {
3182
- if (!s) return '';
3183
- s = s.replace(/\\n/g, ' ');
3184
- return s.length > len ? s.slice(0, len - 3) + '...' : s;
3185
- }
420
+ // === HTTP Proxy Server ===
3186
421
 
3187
- function render(sessions, selected, offset, rows) {
3188
- const cwd = process.cwd();
3189
- const pageSize = rows - 6;
3190
- const visible = sessions.slice(offset, offset + pageSize);
3191
-
3192
- let out = CLEAR;
3193
- out += BOLD + 'Sessions: ' + RESET + DIM + cwd + RESET + '\\n';
3194
- out += DIM + '[↑/↓] Select [Enter] Resume [q] Quit' + RESET + '\\n\\n';
422
+ const server = http.createServer(async (req, res) => {
423
+ const url = new URL(req.url, 'http://' + req.headers.host);
3195
424
 
3196
- for (let i = 0; i < visible.length; i++) {
3197
- const s = visible[i];
3198
- const idx = offset + i;
3199
- const isSelected = idx === selected;
3200
- const prefix = isSelected ? GREEN + '▶ ' + RESET : ' ';
3201
-
3202
- const title = truncate(s.title || '(no title)', 35);
3203
- const time = formatTime(s.lastTimestamp);
3204
- const model = truncate(s.model, 20);
3205
-
3206
- if (isSelected) {
3207
- out += prefix + YELLOW + title + RESET + '\\n';
3208
- out += ' ' + DIM + 'ID: ' + RESET + CYAN + s.id + RESET + '\\n';
3209
- out += ' ' + DIM + 'Last: ' + time + ' | Model: ' + model + ' | ' + s.messageCount + ' msgs' + RESET + '\\n';
3210
- out += ' ' + DIM + 'First input: ' + RESET + truncate(s.firstUserMsg, 60) + '\\n';
3211
- if (s.lastUserMsg && s.lastUserMsg !== s.firstUserMsg) {
3212
- out += ' ' + DIM + 'Last input: ' + RESET + truncate(s.lastUserMsg, 60) + '\\n';
3213
- }
3214
- } else {
3215
- out += prefix + title + DIM + ' (' + time + ')' + RESET + '\\n';
3216
- }
425
+ if (url.pathname === '/health') {
426
+ res.writeHead(200, { 'Content-Type': 'application/json' });
427
+ res.end(JSON.stringify({ status: 'ok', mode: 'native-provider' }));
428
+ return;
3217
429
  }
3218
430
 
3219
- out += '\\n' + DIM + 'Page ' + (Math.floor(offset / pageSize) + 1) + '/' + Math.ceil(sessions.length / pageSize) + ' (' + sessions.length + ' sessions)' + RESET;
3220
-
3221
- process.stdout.write(out);
3222
- }
3223
-
3224
- async function main() {
3225
- const sessions = collectSessions();
3226
-
3227
- if (sessions.length === 0) {
3228
- console.log(RED + 'No sessions with interactions found in current directory' + RESET);
3229
- process.exit(0);
431
+ if (url.pathname === '/api/tools/exa/search' && req.method === 'POST') {
432
+ let body = '';
433
+ req.on('data', function(c) { body += c; });
434
+ req.on('end', async function() {
435
+ try {
436
+ const parsed = JSON.parse(body);
437
+ const result = await search(parsed.query, parsed.numResults || 10);
438
+ log('Results:', result.results.length, 'from', result.source);
439
+ res.writeHead(200, { 'Content-Type': 'application/json' });
440
+ res.end(JSON.stringify({ results: result.results }));
441
+ } catch (e) {
442
+ log('Search error:', e.message);
443
+ res.writeHead(500, { 'Content-Type': 'application/json' });
444
+ res.end(JSON.stringify({ error: String(e), results: [] }));
445
+ }
446
+ });
447
+ return;
3230
448
  }
3231
449
 
3232
- if (!process.stdin.isTTY) {
3233
- for (const s of sessions) {
3234
- console.log(s.id + ' ' + (s.title || '') + ' ' + formatTime(s.lastTimestamp));
450
+ // Standalone mode: mock non-LLM APIs
451
+ if (process.env.STANDALONE_MODE === '1') {
452
+ const pathname = url.pathname;
453
+ const isCoreLLMApi = pathname.startsWith('/api/llm/a/') || pathname.startsWith('/api/llm/o/');
454
+
455
+ if (!isCoreLLMApi) {
456
+ if (pathname === '/api/sessions/create') {
457
+ res.writeHead(200, { 'Content-Type': 'application/json' });
458
+ res.end(JSON.stringify({ id: 'local-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10) }));
459
+ return;
460
+ }
461
+ if (pathname === '/api/cli/whoami') {
462
+ res.writeHead(401, { 'Content-Type': 'application/json' });
463
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
464
+ return;
465
+ }
466
+ res.writeHead(200, { 'Content-Type': 'application/json' });
467
+ res.end(JSON.stringify({}));
468
+ return;
3235
469
  }
3236
- process.exit(0);
3237
470
  }
3238
471
 
3239
- const rows = process.stdout.rows || 24;
3240
- const pageSize = rows - 6;
3241
- let selected = 0;
3242
- let offset = 0;
472
+ // Simple proxy - no SSE transformation (handled by proxy plugin)
473
+ log('Proxy:', req.method, url.pathname);
474
+ const proxyUrl = new URL(FACTORY_API + url.pathname + url.search);
475
+ const proxyModule = proxyUrl.protocol === 'https:' ? https : http;
476
+ const proxyReq = proxyModule.request(proxyUrl, {
477
+ method: req.method,
478
+ headers: Object.assign({}, req.headers, { host: proxyUrl.host })
479
+ }, function(proxyRes) {
480
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
481
+ proxyRes.pipe(res);
482
+ });
3243
483
 
3244
- function restoreTerminal() {
3245
- try { process.stdout.write(SHOW_CURSOR); } catch {}
3246
- try { process.stdin.setRawMode(false); } catch {}
3247
- try { process.stdin.pause(); } catch {}
3248
- }
484
+ proxyReq.on('error', function(e) {
485
+ res.writeHead(502, { 'Content-Type': 'application/json' });
486
+ res.end(JSON.stringify({ error: 'Proxy failed: ' + e.message }));
487
+ });
3249
488
 
3250
- function clearScreen() {
3251
- try { process.stdout.write(CLEAR); } catch {}
3252
- }
489
+ if (req.method !== 'GET' && req.method !== 'HEAD') req.pipe(proxyReq);
490
+ else proxyReq.end();
491
+ });
3253
492
 
3254
- process.stdin.setRawMode(true);
3255
- process.stdin.resume();
3256
- process.stdout.write(HIDE_CURSOR);
3257
-
3258
- render(sessions, selected, offset, rows);
493
+ server.listen(PORT, '127.0.0.1', function() {
494
+ const actualPort = server.address().port;
495
+ const portFile = process.env.SEARCH_PROXY_PORT_FILE;
496
+ if (portFile) fs.writeFileSync(portFile, String(actualPort));
497
+ console.log('PORT=' + actualPort);
498
+ log('Native provider proxy on port', actualPort);
499
+ });
3259
500
 
3260
- const onKey = (key) => {
3261
- const k = key.toString();
3262
-
3263
- if (k === 'q' || k === '\\x03') { // q or Ctrl+C
3264
- restoreTerminal();
3265
- clearScreen();
3266
- process.exit(0);
3267
- }
3268
-
3269
- if (k === '\\r' || k === '\\n') { // Enter
3270
- // Stop reading input / stop reacting to arrow keys before handing off to droid.
3271
- process.stdin.off('data', onKey);
3272
- restoreTerminal();
3273
- clearScreen();
3274
- const session = sessions[selected];
3275
- console.log(GREEN + 'Resuming session: ' + session.id + RESET);
3276
- console.log(DIM + 'Using: ' + ALIAS_NAME + ' --resume ' + session.id + RESET + '\\n');
501
+ process.on('SIGTERM', function() { server.close(); process.exit(0); });
502
+ process.on('SIGINT', function() { server.close(); process.exit(0); });
503
+ `;
504
+ }
505
+
506
+ //#endregion
507
+ //#region src/websearch-patch.ts
508
+ /**
509
+ * Generate unified wrapper script
510
+ */
511
+ function generateUnifiedWrapper(droidPath, proxyScriptPath, standalone = false) {
512
+ const standaloneEnv = standalone ? "STANDALONE_MODE=1 " : "";
513
+ return `#!/bin/bash
514
+ # Droid with WebSearch
515
+ # Auto-generated by droid-patch --websearch
3277
516
 
3278
- // Avoid the sessions browser reacting to signals while droid is running.
3279
- try { process.removeAllListeners('SIGINT'); } catch {}
3280
- try { process.removeAllListeners('SIGTERM'); } catch {}
3281
- try { process.on('SIGINT', () => {}); } catch {}
3282
- try { process.on('SIGTERM', () => {}); } catch {}
517
+ PROXY_SCRIPT="${proxyScriptPath}"
518
+ DROID_BIN="${droidPath}"
519
+ PROXY_PID=""
520
+ PORT_FILE="/tmp/droid-websearch-$$.port"
521
+ STANDALONE="${standalone ? "1" : "0"}"
3283
522
 
3284
- const child = spawn(ALIAS_NAME, ['--resume', session.id], { stdio: 'inherit' });
3285
- child.on('exit', (code) => process.exit(code || 0));
3286
- child.on('error', () => process.exit(1));
3287
- return;
3288
- }
3289
-
3290
- if (k === '\\x1b[A' || k === 'k') { // Up
3291
- if (selected > 0) {
3292
- selected--;
3293
- if (selected < offset) offset = Math.max(0, offset - 1);
3294
- }
3295
- } else if (k === '\\x1b[B' || k === 'j') { // Down
3296
- if (selected < sessions.length - 1) {
3297
- selected++;
3298
- if (selected >= offset + pageSize) offset++;
3299
- }
3300
- } else if (k === '\\x1b[5~') { // Page Up
3301
- selected = Math.max(0, selected - pageSize);
3302
- offset = Math.max(0, offset - pageSize);
3303
- } else if (k === '\\x1b[6~') { // Page Down
3304
- selected = Math.min(sessions.length - 1, selected + pageSize);
3305
- offset = Math.min(Math.max(0, sessions.length - pageSize), offset + pageSize);
3306
- }
3307
-
3308
- render(sessions, selected, offset, rows);
3309
- };
523
+ should_passthrough() {
524
+ for arg in "$@"; do
525
+ if [ "$arg" = "--" ]; then break; fi
526
+ case "$arg" in --help|-h|--version|-V) return 0 ;; esac
527
+ done
528
+ local end_opts=0
529
+ for arg in "$@"; do
530
+ if [ "$arg" = "--" ]; then end_opts=1; continue; fi
531
+ if [ "$end_opts" -eq 0 ] && [[ "$arg" == -* ]]; then continue; fi
532
+ case "$arg" in help|version|completion|completions|exec) return 0 ;; esac
533
+ break
534
+ done
535
+ return 1
536
+ }
3310
537
 
3311
- process.stdin.on('data', onKey);
538
+ if should_passthrough "$@"; then exec "$DROID_BIN" "$@"; fi
3312
539
 
3313
- process.on('SIGINT', () => {
3314
- restoreTerminal();
3315
- clearScreen();
3316
- process.exit(0);
3317
- });
540
+ cleanup() {
541
+ if [ -n "$PROXY_PID" ] && kill -0 "$PROXY_PID" 2>/dev/null; then
542
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Stopping proxy (PID: $PROXY_PID)" >&2
543
+ kill "$PROXY_PID" 2>/dev/null
544
+ wait "$PROXY_PID" 2>/dev/null
545
+ fi
546
+ rm -f "$PORT_FILE"
3318
547
  }
548
+ trap cleanup EXIT INT TERM
549
+
550
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Starting proxy..." >&2
551
+ [ "$STANDALONE" = "1" ] && [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Standalone mode enabled" >&2
552
+
553
+ if [ -n "$DROID_SEARCH_DEBUG" ]; then
554
+ ${standaloneEnv}SEARCH_PROXY_PORT=0 SEARCH_PROXY_PORT_FILE="$PORT_FILE" node "$PROXY_SCRIPT" 2>&1 &
555
+ else
556
+ ${standaloneEnv}SEARCH_PROXY_PORT=0 SEARCH_PROXY_PORT_FILE="$PORT_FILE" node "$PROXY_SCRIPT" >/dev/null 2>&1 &
557
+ fi
558
+ PROXY_PID=$!
559
+
560
+ for i in {1..50}; do
561
+ if ! kill -0 "$PROXY_PID" 2>/dev/null; then
562
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Proxy process died" >&2
563
+ break
564
+ fi
565
+ if [ -f "$PORT_FILE" ]; then
566
+ ACTUAL_PORT=$(cat "$PORT_FILE" 2>/dev/null)
567
+ if [ -n "$ACTUAL_PORT" ] && curl -s "http://127.0.0.1:$ACTUAL_PORT/health" > /dev/null 2>&1; then
568
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Proxy ready on port $ACTUAL_PORT (PID: $PROXY_PID)" >&2
569
+ break
570
+ fi
571
+ fi
572
+ sleep 0.1
573
+ done
574
+
575
+ if [ ! -f "$PORT_FILE" ] || [ -z "$(cat "$PORT_FILE" 2>/dev/null)" ]; then
576
+ echo "[websearch] Failed to start proxy, running without websearch" >&2
577
+ cleanup
578
+ exec "$DROID_BIN" "$@"
579
+ fi
580
+
581
+ ACTUAL_PORT=$(cat "$PORT_FILE")
582
+ rm -f "$PORT_FILE"
3319
583
 
3320
- main();
584
+ export FACTORY_API_BASE_URL_OVERRIDE="http://127.0.0.1:$ACTUAL_PORT"
585
+ "$DROID_BIN" "$@"
586
+ DROID_EXIT_CODE=$?
587
+ exit $DROID_EXIT_CODE
3321
588
  `;
3322
589
  }
3323
590
  /**
3324
- * Create sessions browser script file
591
+ * Create unified WebSearch files
592
+ *
593
+ * @param outputDir - Directory to write files to
594
+ * @param droidPath - Path to droid binary
595
+ * @param aliasName - Alias name for the wrapper
596
+ * @param apiBase - Custom API base URL for proxy to forward requests to
597
+ * @param standalone - Standalone mode: mock non-LLM Factory APIs
598
+ * @param useNativeProvider - Use native provider websearch (--websearch-proxy mode)
3325
599
  */
3326
- async function createSessionsScript(outputDir, aliasName) {
600
+ async function createWebSearchUnifiedFiles(outputDir, droidPath, aliasName, apiBase, standalone = false, useNativeProvider = false) {
3327
601
  if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true });
3328
- const sessionsScriptPath = join(outputDir, `${aliasName}-sessions.js`);
3329
- await writeFile(sessionsScriptPath, generateSessionsBrowserScript(aliasName));
3330
- await chmod(sessionsScriptPath, 493);
3331
- return { sessionsScript: sessionsScriptPath };
602
+ const proxyScriptPath = join(outputDir, `${aliasName}-proxy.js`);
603
+ const wrapperScriptPath = join(outputDir, aliasName);
604
+ const factoryApiUrl = apiBase || "https://api.factory.ai";
605
+ await writeFile(proxyScriptPath, useNativeProvider ? generateNativeSearchProxyServer(factoryApiUrl) : generateExternalSearchProxyServer(factoryApiUrl));
606
+ console.log(`[*] Created proxy script: ${proxyScriptPath}`);
607
+ console.log(`[*] Mode: ${useNativeProvider ? "native provider (requires proxy plugin)" : "external providers"}`);
608
+ await writeFile(wrapperScriptPath, generateUnifiedWrapper(droidPath, proxyScriptPath, standalone));
609
+ await chmod(wrapperScriptPath, 493);
610
+ console.log(`[*] Created wrapper: ${wrapperScriptPath}`);
611
+ if (standalone) console.log(`[*] Standalone mode enabled`);
612
+ return {
613
+ wrapperScript: wrapperScriptPath,
614
+ preloadScript: proxyScriptPath
615
+ };
3332
616
  }
3333
617
 
3334
618
  //#endregion
@@ -3383,14 +667,13 @@ function findDefaultDroidPath() {
3383
667
  for (const p of paths) if (existsSync(p)) return p;
3384
668
  return join(home, ".droid", "bin", "droid");
3385
669
  }
3386
- bin("droid-patch", "CLI tool to patch droid binary with various modifications").package("droid-patch", version).option("--is-custom", "Patch isCustom:!0 to isCustom:!1 (enable context compression for custom models)").option("--skip-login", "Inject a fake FACTORY_API_KEY to bypass login requirement (no real key needed)").option("--api-base <url>", "Replace API URL (standalone: binary patch, max 22 chars; with --websearch: proxy forward target, no limit)").option("--websearch", "Enable local WebSearch proxy (each instance runs own proxy, auto-cleanup on exit)").option("--statusline", "Enable a Claude-style statusline (terminal UI)").option("--sessions", "Enable sessions browser (--sessions flag in alias)").option("--standalone", "Standalone mode: mock non-LLM Factory APIs (use with --websearch)").option("--reasoning-effort", "Enable reasoning effort for custom models (set to high, enable UI selector)").option("--disable-telemetry", "Disable telemetry and Sentry error reporting (block data uploads)").option("--auto-high", "Set default autonomy mode to auto-high (bypass settings.json race condition)").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) => {
670
+ 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 with external providers (Smithery, Google PSE, etc.)").option("--websearch-proxy", "Enable native provider websearch (requires proxy plugin, reads ~/.factory/settings.json)").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("--auto-high", "Set default autonomy mode to auto-high (bypass settings.json race condition)").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) => {
3387
671
  const alias = args?.[0];
3388
672
  const isCustom = options["is-custom"];
3389
673
  const skipLogin = options["skip-login"];
3390
674
  const apiBase = options["api-base"];
3391
675
  const websearch = options["websearch"];
3392
- const statusline = options["statusline"];
3393
- const sessions = options["sessions"];
676
+ const websearchProxy = options["websearch-proxy"];
3394
677
  const standalone = options["standalone"];
3395
678
  const websearchTarget = websearch ? apiBase || "https://api.factory.ai" : void 0;
3396
679
  const reasoningEffort = options["reasoning-effort"];
@@ -3402,38 +685,38 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
3402
685
  const backup = options.backup !== false;
3403
686
  const verbose = options.verbose;
3404
687
  const outputPath = outputDir && alias ? join(outputDir, alias) : void 0;
3405
- const needsBinaryPatch = !!isCustom || !!skipLogin || !!reasoningEffort || !!noTelemetry || !!autoHigh || !!apiBase && !websearch;
3406
- const statuslineEnabled = statusline;
3407
- if (!needsBinaryPatch && (websearch || statuslineEnabled)) {
688
+ const needsBinaryPatch = !!isCustom || !!skipLogin || !!reasoningEffort || !!noTelemetry || !!autoHigh || !!apiBase && !websearch && !websearchProxy;
689
+ if (websearch && websearchProxy) {
690
+ console.log(styleText("red", "Error: Cannot use --websearch and --websearch-proxy together"));
691
+ console.log(styleText("gray", "Choose one:"));
692
+ console.log(styleText("gray", " --websearch External providers (Smithery, Google PSE, etc.)"));
693
+ console.log(styleText("gray", " --websearch-proxy Native provider (requires proxy plugin)"));
694
+ process.exit(1);
695
+ }
696
+ if (!needsBinaryPatch && (websearch || websearchProxy)) {
3408
697
  if (!alias) {
3409
- console.log(styleText("red", "Error: Alias name required for --websearch/--statusline"));
3410
- console.log(styleText("gray", "Usage: npx droid-patch --websearch <alias>"));
3411
- console.log(styleText("gray", "Usage: npx droid-patch --statusline <alias>"));
698
+ const flag = websearchProxy ? "--websearch-proxy" : "--websearch";
699
+ console.log(styleText("red", `Error: Alias name required for ${flag}`));
700
+ console.log(styleText("gray", `Usage: npx droid-patch ${flag} <alias>`));
3412
701
  process.exit(1);
3413
702
  }
3414
703
  console.log(styleText("cyan", "═".repeat(60)));
3415
704
  console.log(styleText(["cyan", "bold"], " Droid Wrapper Setup"));
3416
705
  console.log(styleText("cyan", "═".repeat(60)));
3417
706
  console.log();
3418
- if (websearch) {
3419
- console.log(styleText("white", `WebSearch: enabled`));
707
+ if (websearchProxy) {
708
+ console.log(styleText("white", `WebSearch: native provider mode`));
709
+ console.log(styleText("gray", ` Requires proxy plugin (anthropic4droid)`));
710
+ console.log(styleText("gray", ` Reads model config from ~/.factory/settings.json`));
711
+ } else if (websearch) {
712
+ console.log(styleText("white", `WebSearch: external providers mode`));
3420
713
  console.log(styleText("white", `Forward target: ${websearchTarget}`));
3421
- if (standalone) console.log(styleText("white", `Standalone mode: enabled`));
3422
714
  }
3423
- if (statuslineEnabled) console.log(styleText("white", `Statusline: enabled`));
715
+ if (standalone) console.log(styleText("white", `Standalone mode: enabled`));
3424
716
  console.log();
3425
717
  let execTargetPath = path;
3426
- if (websearch) {
3427
- const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchTarget, standalone);
3428
- execTargetPath = wrapperScript;
3429
- }
3430
- if (statuslineEnabled) {
3431
- const statuslineDir = join(homedir(), ".droid-patch", "statusline");
3432
- let sessionsScript;
3433
- if (sessions) sessionsScript = (await createSessionsScript(statuslineDir, alias)).sessionsScript;
3434
- const { wrapperScript } = await createStatuslineFiles(statuslineDir, execTargetPath, alias, sessionsScript);
3435
- execTargetPath = wrapperScript;
3436
- }
718
+ const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchProxy ? void 0 : websearchTarget, standalone, websearchProxy);
719
+ execTargetPath = wrapperScript;
3437
720
  const aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
3438
721
  const droidVersion = getDroidVersion(path);
3439
722
  await saveAliasMetadata(createMetadata(alias, path, {
@@ -3441,8 +724,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
3441
724
  skipLogin: false,
3442
725
  apiBase: apiBase || null,
3443
726
  websearch: !!websearch,
3444
- statusline: !!statuslineEnabled,
3445
- sessions: !!sessions,
727
+ websearchProxy: !!websearchProxy,
3446
728
  reasoningEffort: false,
3447
729
  noTelemetry: false,
3448
730
  standalone
@@ -3459,10 +741,24 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
3459
741
  console.log("Run directly:");
3460
742
  console.log(styleText("yellow", ` ${alias}`));
3461
743
  console.log();
3462
- if (websearch) {
3463
- console.log(styleText("cyan", "Auto-shutdown:"));
3464
- console.log(styleText("gray", " Proxy auto-shuts down after 5 min idle (no manual cleanup needed)"));
3465
- console.log(styleText("gray", " To disable: export DROID_PROXY_IDLE_TIMEOUT=0"));
744
+ if (websearchProxy) {
745
+ console.log(styleText("cyan", "Native Provider WebSearch (--websearch-proxy):"));
746
+ console.log(styleText("gray", " Uses model's built-in websearch via proxy plugin"));
747
+ console.log(styleText("gray", " Reads model config from ~/.factory/settings.json"));
748
+ console.log();
749
+ console.log(styleText("yellow", "IMPORTANT: Requires proxy plugin (anthropic4droid)"));
750
+ console.log();
751
+ console.log("Supported providers:");
752
+ console.log(styleText("yellow", " - anthropic: Claude web_search_20250305 server tool"));
753
+ console.log(styleText("yellow", " - openai: OpenAI web_search tool"));
754
+ console.log(styleText("gray", " - generic-chat-completion-api: Not supported"));
755
+ console.log();
756
+ console.log("Debug mode:");
757
+ console.log(styleText("gray", " export DROID_SEARCH_DEBUG=1 # Basic logs"));
758
+ console.log(styleText("gray", " export DROID_SEARCH_VERBOSE=1 # Full request/response"));
759
+ } else if (websearch) {
760
+ console.log(styleText("cyan", "External Providers WebSearch (--websearch):"));
761
+ console.log(styleText("gray", " Uses external search providers (Smithery, Google PSE, etc.)"));
3466
762
  console.log();
3467
763
  console.log("Search providers (in priority order):");
3468
764
  console.log(styleText("yellow", " 1. Smithery Exa (best quality):"));
@@ -3478,13 +774,12 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
3478
774
  }
3479
775
  return;
3480
776
  }
3481
- if (!isCustom && !skipLogin && !apiBase && !websearch && !statuslineEnabled && !reasoningEffort && !noTelemetry && !autoHigh) {
777
+ if (!isCustom && !skipLogin && !apiBase && !websearch && !reasoningEffort && !noTelemetry && !autoHigh) {
3482
778
  console.log(styleText("yellow", "No patch flags specified. Available patches:"));
3483
779
  console.log(styleText("gray", " --is-custom Patch isCustom for custom models"));
3484
780
  console.log(styleText("gray", " --skip-login Bypass login by injecting a fake API key"));
3485
781
  console.log(styleText("gray", " --api-base Replace API URL (standalone: max 22 chars; with --websearch: no limit)"));
3486
782
  console.log(styleText("gray", " --websearch Enable local WebSearch proxy"));
3487
- console.log(styleText("gray", " --statusline Enable Claude-style statusline"));
3488
783
  console.log(styleText("gray", " --reasoning-effort Set reasoning effort level for custom models"));
3489
784
  console.log(styleText("gray", " --disable-telemetry Disable telemetry and Sentry error reporting"));
3490
785
  console.log(styleText("gray", " --auto-high Set default autonomy mode to auto-high"));
@@ -3496,8 +791,6 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
3496
791
  console.log(styleText("cyan", " npx droid-patch --is-custom --skip-login droid-patched"));
3497
792
  console.log(styleText("cyan", " npx droid-patch --websearch droid-search"));
3498
793
  console.log(styleText("cyan", " npx droid-patch --websearch --standalone droid-local"));
3499
- console.log(styleText("cyan", " npx droid-patch --statusline droid-status"));
3500
- console.log(styleText("cyan", " npx droid-patch --websearch --statusline droid-search-ui"));
3501
794
  console.log(styleText("cyan", " npx droid-patch --disable-telemetry droid-private"));
3502
795
  console.log(styleText("cyan", " npx droid-patch --websearch --api-base=http://127.0.0.1:20002 my-droid"));
3503
796
  process.exit(1);
@@ -3636,25 +929,22 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
3636
929
  if (result.success && result.outputPath && alias) {
3637
930
  console.log();
3638
931
  let execTargetPath = result.outputPath;
3639
- if (websearch) {
3640
- const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchTarget, standalone);
932
+ if (websearch || websearchProxy) {
933
+ const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchProxy ? void 0 : websearchTarget, standalone, websearchProxy);
3641
934
  execTargetPath = wrapperScript;
3642
935
  console.log();
3643
- console.log(styleText("cyan", "WebSearch enabled"));
3644
- console.log(styleText("white", ` Forward target: ${websearchTarget}`));
936
+ if (websearchProxy) {
937
+ console.log(styleText("cyan", "WebSearch enabled (native provider mode)"));
938
+ console.log(styleText("gray", " Requires proxy plugin (anthropic4droid)"));
939
+ console.log(styleText("gray", " Reads model config from ~/.factory/settings.json"));
940
+ } else {
941
+ console.log(styleText("cyan", "WebSearch enabled (external providers mode)"));
942
+ console.log(styleText("white", ` Forward target: ${websearchTarget}`));
943
+ }
3645
944
  if (standalone) console.log(styleText("white", ` Standalone mode: enabled`));
3646
945
  }
3647
- if (statuslineEnabled) {
3648
- const statuslineDir = join(homedir(), ".droid-patch", "statusline");
3649
- let sessionsScript;
3650
- if (sessions) sessionsScript = (await createSessionsScript(statuslineDir, alias)).sessionsScript;
3651
- const { wrapperScript } = await createStatuslineFiles(statuslineDir, execTargetPath, alias, sessionsScript);
3652
- execTargetPath = wrapperScript;
3653
- console.log();
3654
- console.log(styleText("cyan", "Statusline enabled"));
3655
- }
3656
946
  let aliasResult;
3657
- if (websearch || statuslineEnabled) aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
947
+ if (websearch || websearchProxy) aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
3658
948
  else aliasResult = await createAlias(result.outputPath, alias, verbose);
3659
949
  const droidVersion = getDroidVersion(path);
3660
950
  await saveAliasMetadata(createMetadata(alias, path, {
@@ -3662,8 +952,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
3662
952
  skipLogin: !!skipLogin,
3663
953
  apiBase: apiBase || null,
3664
954
  websearch: !!websearch,
3665
- statusline: !!statuslineEnabled,
3666
- sessions: !!sessions,
955
+ websearchProxy: !!websearchProxy,
3667
956
  reasoningEffort: !!reasoningEffort,
3668
957
  noTelemetry: !!noTelemetry,
3669
958
  standalone: !!standalone,
@@ -3688,11 +977,29 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
3688
977
  }
3689
978
  }).command("list", "List all droid-patch aliases").action(async () => {
3690
979
  await listAliases();
3691
- }).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) => {
980
+ }).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) => {
3692
981
  const target = args?.[0];
3693
982
  const patchVersion = options["patch-version"];
3694
983
  const droidVersion = options["droid-version"];
3695
- const flag = options.flag;
984
+ const flagRaw = options.flag;
985
+ let flag;
986
+ if (flagRaw) {
987
+ const allowedFlags = [
988
+ "is-custom",
989
+ "skip-login",
990
+ "websearch",
991
+ "api-base",
992
+ "reasoning-effort",
993
+ "disable-telemetry",
994
+ "standalone"
995
+ ];
996
+ if (!allowedFlags.includes(flagRaw)) {
997
+ console.error(styleText("red", `Error: Invalid --flag value: ${flagRaw}`));
998
+ console.error(styleText("gray", `Allowed: ${allowedFlags.join(", ")}`));
999
+ process.exit(1);
1000
+ }
1001
+ flag = flagRaw;
1002
+ }
3696
1003
  if (patchVersion || droidVersion || flag) {
3697
1004
  await removeAliasesByFilter({
3698
1005
  patchVersion,
@@ -3886,14 +1193,8 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
3886
1193
  delete meta.patches.proxy;
3887
1194
  }
3888
1195
  }
3889
- if (meta.patches.statusline) {
3890
- const statuslineDir = join(homedir(), ".droid-patch", "statusline");
3891
- let sessionsScript;
3892
- if (meta.patches.sessions) sessionsScript = (await createSessionsScript(statuslineDir, meta.name)).sessionsScript;
3893
- const { wrapperScript } = await createStatuslineFiles(statuslineDir, execTargetPath, meta.name, sessionsScript);
3894
- execTargetPath = wrapperScript;
3895
- if (verbose) console.log(styleText("gray", ` Regenerated statusline wrapper`));
3896
- }
1196
+ delete meta.patches.statusline;
1197
+ delete meta.patches.sessions;
3897
1198
  const { symlink: symlink$1, unlink: unlink$1, readlink: readlink$1, lstat } = await import("node:fs/promises");
3898
1199
  let aliasPath = meta.aliasPath;
3899
1200
  if (!aliasPath) {