@thegitai/cli 1.0.0-preview.5 → 1.0.0-preview.50

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.
Files changed (75) hide show
  1. package/README.md +93 -4
  2. package/dist/bin/ai.js +142 -112
  3. package/dist/bin/browser-host.js +265 -0
  4. package/dist/src/agent-mode.js +32 -7
  5. package/dist/src/api/auth.js +10 -4
  6. package/dist/src/api/browser-login.js +149 -37
  7. package/dist/src/api/chat.js +680 -166
  8. package/dist/src/api/contracts.js +55 -1
  9. package/dist/src/api/default-host.js +1 -0
  10. package/dist/src/api/http.js +151 -7
  11. package/dist/src/api/models.js +29 -10
  12. package/dist/src/api/startup-handshake.js +36 -0
  13. package/dist/src/api/turn-recovery.js +189 -0
  14. package/dist/src/background-jobs.js +4 -2
  15. package/dist/src/browser/bridge.js +262 -0
  16. package/dist/src/browser/framing.js +42 -0
  17. package/dist/src/browser/native-host.js +219 -0
  18. package/dist/src/browser/protocol.js +46 -0
  19. package/dist/src/browser/session-bridge.js +130 -0
  20. package/dist/src/cli-args.js +19 -5
  21. package/dist/src/client-environment.js +2 -0
  22. package/dist/src/core/clipboard.js +7 -13
  23. package/dist/src/core/image-limits.js +56 -0
  24. package/dist/src/core/image-path-extractor.js +70 -3
  25. package/dist/src/core/session-image-store.js +246 -0
  26. package/dist/src/edit-journal.js +33 -2
  27. package/dist/src/executor.js +25 -3
  28. package/dist/src/help-text.js +112 -21
  29. package/dist/src/patcher.js +43 -4
  30. package/dist/src/permissions.js +288 -0
  31. package/dist/src/preferences.js +39 -0
  32. package/dist/src/quota-notice.js +35 -0
  33. package/dist/src/session-safety.js +0 -12
  34. package/dist/src/session-store.js +121 -20
  35. package/dist/src/session.js +19 -4
  36. package/dist/src/signin.js +58 -0
  37. package/dist/src/tool-executor.js +13 -46
  38. package/dist/src/tools/browser.js +628 -0
  39. package/dist/src/tools/delete-file.js +20 -4
  40. package/dist/src/tools/index.js +32 -10
  41. package/dist/src/tools/patch-file.js +12 -26
  42. package/dist/src/tools/read-image-file.js +85 -0
  43. package/dist/src/tools/replace-document-text.js +28 -18
  44. package/dist/src/tools/restore-checkpoint.js +0 -1
  45. package/dist/src/tools/run-command.js +14 -71
  46. package/dist/src/tools/run-node-script.js +12 -81
  47. package/dist/src/tools/save-generated-image.js +120 -0
  48. package/dist/src/tools/str-replace.js +12 -26
  49. package/dist/src/tools/undo-edit.js +1 -6
  50. package/dist/src/tools/write-file.js +67 -11
  51. package/dist/src/ui/prompt-history-store.js +1 -1
  52. package/dist/src/ui/repl.js +949 -279
  53. package/dist/src/ui/slash-commands.js +121 -0
  54. package/dist/src/ui/tui/attention.js +25 -0
  55. package/dist/src/ui/tui/bridge.js +38 -5
  56. package/dist/src/ui/tui/build-frame.js +686 -223
  57. package/dist/src/ui/tui/composer-layout.js +76 -0
  58. package/dist/src/ui/tui/desktop-notification.js +118 -0
  59. package/dist/src/ui/tui/markdown-render.js +11 -2
  60. package/dist/src/ui/tui/shell-input.js +256 -73
  61. package/dist/src/ui/tui/shortcuts.js +61 -0
  62. package/dist/src/ui/tui/terminal-theme.js +28 -0
  63. package/dist/src/ui/tui/user-input.js +593 -0
  64. package/dist/src/update-check.js +205 -0
  65. package/dist/src/usage.js +5 -2
  66. package/dist/src/utils.js +13 -0
  67. package/dist/src/version.js +52 -2
  68. package/package.json +22 -9
  69. package/dist/src/project-index.js +0 -233
  70. package/dist/src/tools/code-intel.js +0 -472
  71. package/dist/src/tools/find-symbol.js +0 -70
  72. package/dist/src/tools/hover-symbol.js +0 -95
  73. package/dist/src/tools/list-symbols.js +0 -55
  74. package/dist/src/tools/search-code.js +0 -37
  75. package/dist/src/tools/signature-help.js +0 -118
@@ -0,0 +1,628 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { normalizeProjectRelativePath } from '../artifact-policy.js';
4
+ import { BrowserBridgeError } from '../browser/bridge.js';
5
+ import { storeSessionImageBytes } from '../core/session-image-store.js';
6
+ import { browserAvailability, drainBrowserEvents, ensureBrowserBridge, resyncBrowserBridge, } from '../browser/session-bridge.js';
7
+ import { ensurePermission, originForUrl } from '../permissions.js';
8
+ const READ_TIMEOUT_MS = 20000;
9
+ const NAVIGATE_TIMEOUT_MS = 45000;
10
+ const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
11
+ const DISCOVERY_TIMEOUT_MS = 2500;
12
+ export const READ_ONLY_BROWSER_TOOLS = new Set([
13
+ 'browser',
14
+ 'browser_tabs',
15
+ 'browser_read_page',
16
+ 'browser_get_page_text',
17
+ 'browser_find',
18
+ 'browser_console',
19
+ 'browser_network',
20
+ 'browser_screenshot',
21
+ ]);
22
+ function missing(argument, tool) {
23
+ return {
24
+ ok: false,
25
+ error: `${argument} is required.`,
26
+ failureCategory: 'missing_required_argument',
27
+ failureDetails: {
28
+ category: 'missing_required_argument',
29
+ tool,
30
+ missing: [argument],
31
+ action: `Call ${tool} again with ${argument}.`,
32
+ },
33
+ };
34
+ }
35
+ async function waitForBrowser(options, timeoutMs) {
36
+ const deadline = Date.now() + timeoutMs;
37
+ const bridge = ensureBrowserBridge(options);
38
+ while (!bridge.status().connected && Date.now() < deadline) {
39
+ await new Promise((resolve) => setTimeout(resolve, 100));
40
+ }
41
+ return browserAvailability(options);
42
+ }
43
+ async function notConnected(context) {
44
+ const options = { cwd: context.rootDir, env: context.env };
45
+ const status = ensureBrowserBridge(options).status();
46
+ if (status.connected)
47
+ return { ok: true };
48
+ if (status.everConnected)
49
+ resyncBrowserBridge(options);
50
+ const availability = await waitForBrowser(options, DISCOVERY_TIMEOUT_MS);
51
+ if (availability.connected)
52
+ return { ok: true };
53
+ return {
54
+ ok: false,
55
+ connected: false,
56
+ browserErrorCode: 'not_connected',
57
+ error: `TheGitAI is not connected to a browser. ${availability.hint ?? ''}`.trim(),
58
+ failureCategory: 'external_service',
59
+ };
60
+ }
61
+ function bridgeFailure(error) {
62
+ const code = error instanceof BrowserBridgeError ? error.code : 'browser_error';
63
+ const message = String(error?.message ?? error);
64
+ return {
65
+ ok: false,
66
+ error: message,
67
+ failureCategory: code === 'not_connected' ? 'external_service' : 'tool_exception',
68
+ browserErrorCode: code,
69
+ };
70
+ }
71
+ async function call(context, op, args = {}, timeoutMs = READ_TIMEOUT_MS) {
72
+ const bridge = ensureBrowserBridge({ cwd: context.rootDir, env: context.env });
73
+ return bridge.request(op, args, { timeoutMs });
74
+ }
75
+ async function currentTab(context, tabId) {
76
+ const result = await call(context, 'context', { includeAll: true });
77
+ const tabs = Array.isArray(result?.tabs) ? result.tabs : [];
78
+ return tabs.find((tab) => Number(tab.tabId) === tabId) ?? null;
79
+ }
80
+ async function ensureSiteAllowed(context, { url, tool, summary, }) {
81
+ const origin = originForUrl(String(url ?? ''));
82
+ if (!origin) {
83
+ return null;
84
+ }
85
+ return ensurePermission({
86
+ autoYes: context.autoYes,
87
+ grants: context.grants,
88
+ requestPermission: context.requestPermission,
89
+ }, {
90
+ bucket: 'browse',
91
+ origin,
92
+ title: `TheGitAI wants to use ${origin}`,
93
+ body: summary,
94
+ }, tool);
95
+ }
96
+ function attachEvents(result) {
97
+ const events = drainBrowserEvents();
98
+ if (!events.length)
99
+ return result;
100
+ const notable = events.filter((entry) => entry.event === 'detached' || entry.event === 'stopped' || entry.event === 'group_removed');
101
+ if (!notable.length)
102
+ return result;
103
+ return {
104
+ ...result,
105
+ browserNotices: notable.map((entry) => String(entry.data?.message ?? `Browser ${entry.event}.`)),
106
+ };
107
+ }
108
+ export async function browserGateway(context) {
109
+ const guard = await notConnected(context);
110
+ if (guard.ok === false)
111
+ return guard;
112
+ const availability = browserAvailability({ cwd: context.rootDir, env: context.env });
113
+ const platform = availability.browsers[0]?.platform ?? process.platform;
114
+ return {
115
+ ok: true,
116
+ connected: true,
117
+ browsers: availability.browsers.map((browser) => ({
118
+ key: browser.key,
119
+ name: `${browser.browser} — ${browser.profile}`,
120
+ extensionVersion: browser.extensionVersion,
121
+ })),
122
+ selected: availability.selected,
123
+ shortcutModifier: platform === 'darwin' ? 'cmd' : 'ctrl',
124
+ platform,
125
+ };
126
+ }
127
+ export async function browserTabs(context, args) {
128
+ const guard = await notConnected(context);
129
+ if (guard.ok === false)
130
+ return guard;
131
+ try {
132
+ const result = await call(context, 'context', {
133
+ includeAll: args.include_all === true || args.includeAll === true,
134
+ });
135
+ return attachEvents({ ok: true, ...result });
136
+ }
137
+ catch (error) {
138
+ return bridgeFailure(error);
139
+ }
140
+ }
141
+ export async function browserTabNew(context) {
142
+ const guard = await notConnected(context);
143
+ if (guard.ok === false)
144
+ return guard;
145
+ try {
146
+ const result = await call(context, 'tab.create', {});
147
+ return attachEvents({ ok: true, ...result });
148
+ }
149
+ catch (error) {
150
+ return bridgeFailure(error);
151
+ }
152
+ }
153
+ export async function browserTabClose(context, args) {
154
+ const guard = await notConnected(context);
155
+ if (guard.ok === false)
156
+ return guard;
157
+ const tabId = Number(args.tabId ?? args.tab_id);
158
+ if (!Number.isInteger(tabId))
159
+ return missing('tabId', 'browser_tab_close');
160
+ try {
161
+ const result = await call(context, 'tab.close', { tabId });
162
+ return attachEvents({ ok: true, ...result });
163
+ }
164
+ catch (error) {
165
+ return bridgeFailure(error);
166
+ }
167
+ }
168
+ export async function browserNavigate(context, args) {
169
+ const guard = await notConnected(context);
170
+ if (guard.ok === false)
171
+ return guard;
172
+ const url = String(args.url ?? '').trim();
173
+ if (!url)
174
+ return missing('url', 'browser_navigate');
175
+ let tabId = Number(args.tabId ?? args.tab_id);
176
+ try {
177
+ if (!Number.isInteger(tabId)) {
178
+ const context_ = await call(context, 'context');
179
+ tabId = Number(context_?.tabs?.find((tab) => tab.inSession && tab.available)?.tabId);
180
+ if (!Number.isInteger(tabId)) {
181
+ const created = await call(context, 'tab.create', {});
182
+ tabId = Number(created.tabId);
183
+ }
184
+ }
185
+ if (url !== 'back' && url !== 'forward') {
186
+ const denied = await ensureSiteAllowed(context, {
187
+ url,
188
+ tool: 'browser_navigate',
189
+ summary: `Open ${url} in TheGitAI's tab group.`,
190
+ });
191
+ if (denied)
192
+ return denied;
193
+ }
194
+ const result = await call(context, 'navigate', { tabId, url }, NAVIGATE_TIMEOUT_MS);
195
+ return attachEvents({ ok: true, ...result });
196
+ }
197
+ catch (error) {
198
+ return bridgeFailure(error);
199
+ }
200
+ }
201
+ async function gatedTabCall(context, { tool, op, args, summary, timeoutMs = READ_TIMEOUT_MS, }) {
202
+ const guard = await notConnected(context);
203
+ if (guard.ok === false)
204
+ return guard;
205
+ const tabId = Number(args.tabId ?? args.tab_id);
206
+ if (!Number.isInteger(tabId))
207
+ return missing('tabId', tool);
208
+ try {
209
+ const tab = await currentTab(context, tabId);
210
+ if (!tab) {
211
+ return {
212
+ ok: false,
213
+ error: `Tab ${tabId} is no longer available. Call browser_tabs for the current list.`,
214
+ failureCategory: 'not_found',
215
+ failureDetails: {
216
+ category: 'not_found',
217
+ tool,
218
+ action: 'List the session tabs and use an id from that list.',
219
+ },
220
+ };
221
+ }
222
+ const denied = await ensureSiteAllowed(context, {
223
+ url: tab.url,
224
+ tool,
225
+ summary: summary(tab.url),
226
+ });
227
+ if (denied)
228
+ return denied;
229
+ const result = await call(context, op, {
230
+ ...args, tabId, expectedOrigin: new URL(tab.url || 'about:blank').origin,
231
+ }, timeoutMs);
232
+ return attachEvents({ ok: true, tabId, url: tab.url, ...result });
233
+ }
234
+ catch (error) {
235
+ return bridgeFailure(error);
236
+ }
237
+ }
238
+ export function browserReadPage(context, args) {
239
+ return gatedTabCall(context, {
240
+ tool: 'browser_read_page',
241
+ op: 'read_page',
242
+ args: {
243
+ tabId: args.tabId ?? args.tab_id,
244
+ filter: args.filter === 'interactive' ? 'interactive' : 'all',
245
+ depth: Number(args.depth ?? 15),
246
+ maxChars: Number(args.max_chars ?? args.maxChars ?? 50000),
247
+ },
248
+ summary: (url) => `Read the accessibility tree of ${url}.`,
249
+ });
250
+ }
251
+ export function browserGetPageText(context, args) {
252
+ return gatedTabCall(context, {
253
+ tool: 'browser_get_page_text',
254
+ op: 'page_text',
255
+ args: { tabId: args.tabId ?? args.tab_id },
256
+ summary: (url) => `Read the text of ${url}.`,
257
+ });
258
+ }
259
+ export function browserFind(context, args) {
260
+ const query = String(args.query ?? '').trim();
261
+ if (!query)
262
+ return Promise.resolve(missing('query', 'browser_find'));
263
+ return gatedTabCall(context, {
264
+ tool: 'browser_find',
265
+ op: 'find_candidates',
266
+ args: { tabId: args.tabId ?? args.tab_id },
267
+ summary: (url) => `Look for "${query}" on ${url}.`,
268
+ }).then((result) => (result.ok ? { ...result, query } : result));
269
+ }
270
+ export async function browserComputer(context, args) {
271
+ const action = String(args.action ?? '').trim();
272
+ if (!action)
273
+ return missing('action', 'browser_computer');
274
+ const result = await gatedTabCall(context, {
275
+ tool: 'browser_computer',
276
+ op: 'computer',
277
+ args: { ...args, tabId: args.tabId ?? args.tab_id, action },
278
+ summary: (url) => `${describeComputerAction(action, args)} on ${url}.`,
279
+ timeoutMs: action === 'wait' ? 30000 : READ_TIMEOUT_MS,
280
+ });
281
+ if (!result.ok || typeof result.base64Data !== 'string')
282
+ return result;
283
+ return reshapeScreenshot(context, result, args);
284
+ }
285
+ function reshapeScreenshot(context, result, args) {
286
+ const { base64Data, mimeType, geometry, ...rest } = result;
287
+ const payload = {
288
+ ...rest,
289
+ viewport: geometry,
290
+ coordinateSpace: geometry?.scale === 1 && !geometry?.offsetX && !geometry?.offsetY
291
+ ? 'Image pixels are viewport CSS pixels. Use coordinates directly.'
292
+ : 'Convert image pixels to viewport CSS coordinates: x = imageX / viewport.scale + viewport.offsetX; y = imageY / viewport.scale + viewport.offsetY.',
293
+ imageBytes: { base64Data, mimeType: mimeType ?? 'image/png' },
294
+ };
295
+ if (args.save_to_disk === true || args.saveToDisk === true) {
296
+ const sessionId = context.sessionId;
297
+ if (sessionId) {
298
+ try {
299
+ const stored = storeSessionImageBytes({
300
+ sessionId,
301
+ base64Data,
302
+ mimeType: 'image/png',
303
+ env: context.env,
304
+ });
305
+ payload.savedPath = stored.cachePath;
306
+ payload.imageBytes.cachePath = stored.cachePath;
307
+ payload.imageBytes.index = stored.index;
308
+ }
309
+ catch (error) {
310
+ payload.saveError = String(error?.message ?? error);
311
+ }
312
+ }
313
+ }
314
+ return payload;
315
+ }
316
+ function describeComputerAction(action, args) {
317
+ if (action === 'type')
318
+ return `Type ${JSON.stringify(String(args.text ?? '').slice(0, 60))}`;
319
+ if (action === 'key')
320
+ return `Press ${String(args.text ?? '')}`;
321
+ if (action === 'screenshot')
322
+ return 'Take a screenshot';
323
+ if (action === 'zoom')
324
+ return 'Take a close-up screenshot';
325
+ if (action === 'scroll')
326
+ return `Scroll ${String(args.scroll_direction ?? '')}`;
327
+ if (action === 'hover')
328
+ return 'Hover';
329
+ return `${action.replace(/_/g, ' ')}`;
330
+ }
331
+ export function browserFormInput(context, args) {
332
+ const ref = String(args.ref ?? '').trim();
333
+ if (!ref)
334
+ return Promise.resolve(missing('ref', 'browser_form_input'));
335
+ if (args.value === undefined) {
336
+ return Promise.resolve(missing('value', 'browser_form_input'));
337
+ }
338
+ return gatedTabCall(context, {
339
+ tool: 'browser_form_input',
340
+ op: 'form_input',
341
+ args: { tabId: args.tabId ?? args.tab_id, ref, value: args.value },
342
+ summary: (url) => `Set ${ref} on ${url}.`,
343
+ });
344
+ }
345
+ export function browserEvaluate(context, args) {
346
+ const text = String(args.text ?? args.code ?? '').trim();
347
+ if (!text)
348
+ return Promise.resolve(missing('text', 'browser_evaluate'));
349
+ return gatedTabCall(context, {
350
+ tool: 'browser_evaluate',
351
+ op: 'evaluate',
352
+ args: { tabId: args.tabId ?? args.tab_id, text },
353
+ summary: (url) => `Run JavaScript on ${url}.`,
354
+ });
355
+ }
356
+ export function browserConsole(context, args) {
357
+ return gatedTabCall(context, {
358
+ tool: 'browser_console',
359
+ op: 'console',
360
+ args: {
361
+ tabId: args.tabId ?? args.tab_id,
362
+ pattern: args.pattern,
363
+ onlyErrors: args.onlyErrors === true || args.only_errors === true,
364
+ limit: Number(args.limit ?? 100),
365
+ clear: args.clear === true,
366
+ },
367
+ summary: (url) => `Read console output from ${url}.`,
368
+ });
369
+ }
370
+ export function browserNetwork(context, args) {
371
+ return gatedTabCall(context, {
372
+ tool: 'browser_network',
373
+ op: 'network',
374
+ args: {
375
+ tabId: args.tabId ?? args.tab_id,
376
+ urlPattern: args.urlPattern ?? args.url_pattern,
377
+ limit: Number(args.limit ?? 100),
378
+ clear: args.clear === true,
379
+ },
380
+ summary: (url) => `Read network requests from ${url}.`,
381
+ });
382
+ }
383
+ export async function browserDownloads(context, args) {
384
+ const action = String(args.action ?? 'list').trim();
385
+ if (context.agentMode === 'plan' && action !== 'list') {
386
+ return {
387
+ ok: false,
388
+ failureCategory: 'policy_blocked',
389
+ error: `Plan mode blocked browser_downloads ${action}. Listing downloads is allowed; saving or cancelling one changes the user's disk. Switch to Default mode to decide on a download.`,
390
+ };
391
+ }
392
+ const guard = await notConnected(context);
393
+ if (guard.ok === false)
394
+ return guard;
395
+ if (action === 'list') {
396
+ const result = await call(context, 'downloads');
397
+ const downloads = Array.isArray(result?.downloads) ? result.downloads : [];
398
+ return {
399
+ ok: true,
400
+ downloads,
401
+ ...(downloads.length
402
+ ? {
403
+ instructions: 'Each of these is paused and nothing is on disk yet. Tell the user the filename, the size and the site it came from, and only call accept after they say yes.',
404
+ }
405
+ : {}),
406
+ };
407
+ }
408
+ if (action !== 'accept' && action !== 'cancel') {
409
+ return {
410
+ ok: false,
411
+ error: 'browser_downloads action must be "list", "accept" or "cancel".',
412
+ failureCategory: 'invalid_argument',
413
+ };
414
+ }
415
+ const downloadId = Number(args.downloadId ?? args.download_id);
416
+ if (!Number.isInteger(downloadId))
417
+ return missing('downloadId', 'browser_downloads');
418
+ if (action === 'accept') {
419
+ const listed = await call(context, 'downloads');
420
+ const record = (Array.isArray(listed?.downloads) ? listed.downloads : []).find((entry) => Number(entry?.downloadId) === downloadId);
421
+ const size = record?.bytes ? `${(record.bytes / (1024 * 1024)).toFixed(1)} MB` : 'unknown size';
422
+ if (!context.autoYes && context.requestPermission) {
423
+ const decision = await context.requestPermission({
424
+ bucket: 'browse',
425
+ title: 'TheGitAI wants to save a file to this machine',
426
+ body: `"${record?.filename ?? downloadId}" (${size}) from ${record?.url ?? 'an unknown source'}.`,
427
+ options: [
428
+ { label: 'Save this file', decision: { kind: 'once' } },
429
+ { label: 'No, cancel the download', decision: { kind: 'deny' } },
430
+ ],
431
+ });
432
+ if (decision?.kind !== 'once' && decision?.kind !== 'always-bucket') {
433
+ await call(context, 'download.decide', { downloadId, accept: false });
434
+ return {
435
+ ok: false,
436
+ error: 'The user declined the download. It was cancelled and nothing was saved.',
437
+ failureCategory: 'user_declined',
438
+ };
439
+ }
440
+ }
441
+ }
442
+ const result = await call(context, 'download.decide', {
443
+ downloadId,
444
+ accept: action === 'accept',
445
+ });
446
+ return { ok: true, ...result };
447
+ }
448
+ export function browserResize(context, args) {
449
+ const width = Number(args.width);
450
+ const height = Number(args.height);
451
+ if (!Number.isFinite(width) || !Number.isFinite(height)) {
452
+ return Promise.resolve(missing('width and height', 'browser_resize'));
453
+ }
454
+ return gatedTabCall(context, {
455
+ tool: 'browser_resize',
456
+ op: 'resize',
457
+ args: { tabId: args.tabId ?? args.tab_id, width, height },
458
+ summary: () => `Resize the browser window to ${width}x${height}.`,
459
+ });
460
+ }
461
+ export async function browserFileUpload(context, args) {
462
+ const guard = await notConnected(context);
463
+ if (guard.ok === false)
464
+ return guard;
465
+ const ref = String(args.ref ?? '').trim();
466
+ if (!ref)
467
+ return missing('ref', 'browser_file_upload');
468
+ const rawPaths = Array.isArray(args.paths)
469
+ ? args.paths
470
+ : args.path
471
+ ? [args.path]
472
+ : [];
473
+ if (!rawPaths.length)
474
+ return missing('paths', 'browser_file_upload');
475
+ const files = [];
476
+ let total = 0;
477
+ for (const raw of rawPaths) {
478
+ const candidate = String(raw ?? '').trim();
479
+ if (!candidate)
480
+ continue;
481
+ let resolved;
482
+ if (path.isAbsolute(candidate)) {
483
+ resolved = candidate;
484
+ }
485
+ else if (normalizeProjectRelativePath(context.rootDir, candidate)) {
486
+ resolved = path.resolve(context.rootDir, candidate);
487
+ }
488
+ else {
489
+ return {
490
+ ok: false,
491
+ error: `Refusing to upload a path outside the project root: ${candidate}`,
492
+ failureCategory: 'permission_denied',
493
+ };
494
+ }
495
+ let stat;
496
+ try {
497
+ stat = fs.statSync(resolved);
498
+ }
499
+ catch {
500
+ return {
501
+ ok: false,
502
+ error: `No such file: ${resolved}`,
503
+ failureCategory: 'not_found',
504
+ };
505
+ }
506
+ if (!stat.isFile()) {
507
+ return {
508
+ ok: false,
509
+ error: `Not a file: ${resolved}`,
510
+ failureCategory: 'invalid_argument',
511
+ };
512
+ }
513
+ total += stat.size;
514
+ if (total > MAX_UPLOAD_BYTES) {
515
+ return {
516
+ ok: false,
517
+ error: `Uploads are capped at ${MAX_UPLOAD_BYTES / (1024 * 1024)} MB in total; these files exceed it.`,
518
+ failureCategory: 'budget_exceeded',
519
+ };
520
+ }
521
+ files.push({
522
+ name: path.basename(resolved),
523
+ mimeType: guessMimeType(resolved),
524
+ base64: fs.readFileSync(resolved).toString('base64'),
525
+ });
526
+ }
527
+ const tabId = Number(args.tabId ?? args.tab_id);
528
+ if (!Number.isInteger(tabId))
529
+ return missing('tabId', 'browser_file_upload');
530
+ try {
531
+ const tab = await currentTab(context, tabId);
532
+ const denied = await ensureSiteAllowed(context, {
533
+ url: tab?.url,
534
+ tool: 'browser_file_upload',
535
+ summary: `Upload ${files.map((file) => file.name).join(', ')} to ${tab?.url ?? 'the page'}.`,
536
+ });
537
+ if (denied)
538
+ return denied;
539
+ const result = await call(context, 'upload', { tabId, ref, files }, NAVIGATE_TIMEOUT_MS);
540
+ return attachEvents({ ok: true, ...result, files: files.map((file) => file.name) });
541
+ }
542
+ catch (error) {
543
+ return bridgeFailure(error);
544
+ }
545
+ }
546
+ function guessMimeType(file) {
547
+ const extension = path.extname(file).toLowerCase();
548
+ const table = {
549
+ '.png': 'image/png',
550
+ '.jpg': 'image/jpeg',
551
+ '.jpeg': 'image/jpeg',
552
+ '.gif': 'image/gif',
553
+ '.webp': 'image/webp',
554
+ '.svg': 'image/svg+xml',
555
+ '.pdf': 'application/pdf',
556
+ '.json': 'application/json',
557
+ '.csv': 'text/csv',
558
+ '.txt': 'text/plain',
559
+ '.md': 'text/markdown',
560
+ '.html': 'text/html',
561
+ '.zip': 'application/zip',
562
+ };
563
+ return table[extension] ?? 'application/octet-stream';
564
+ }
565
+ const BATCH_TOOLS = {
566
+ browser_tabs: browserTabs,
567
+ browser_tab_new: (context) => browserTabNew(context),
568
+ browser_tab_close: browserTabClose,
569
+ browser_navigate: browserNavigate,
570
+ browser_read_page: browserReadPage,
571
+ browser_get_page_text: browserGetPageText,
572
+ browser_computer: browserComputer,
573
+ browser_form_input: browserFormInput,
574
+ browser_evaluate: browserEvaluate,
575
+ browser_console: browserConsole,
576
+ browser_network: browserNetwork,
577
+ browser_screenshot: (context, args) => browserComputer(context, { ...args, action: 'screenshot' }),
578
+ browser_resize: browserResize,
579
+ browser_file_upload: browserFileUpload,
580
+ browser_downloads: browserDownloads,
581
+ };
582
+ export async function browserBatch(context, args) {
583
+ const guard = await notConnected(context);
584
+ if (guard.ok === false)
585
+ return guard;
586
+ const actions = Array.isArray(args.actions) ? args.actions : [];
587
+ if (!actions.length)
588
+ return missing('actions', 'browser_batch');
589
+ if (actions.length > 20) {
590
+ return {
591
+ ok: false,
592
+ error: 'A batch may hold at most 20 actions.',
593
+ failureCategory: 'invalid_argument',
594
+ };
595
+ }
596
+ const results = [];
597
+ for (const [index, entry] of actions.entries()) {
598
+ const name = String(entry?.name ?? '').trim();
599
+ const input = (entry?.input ?? {});
600
+ const handler = BATCH_TOOLS[name];
601
+ if (!handler) {
602
+ results.push({ step: index + 1, name, ok: false, error: `Unknown browser tool: ${name}` });
603
+ return { ok: false, stoppedAt: index + 1, results, error: `Unknown browser tool: ${name}` };
604
+ }
605
+ if (context.agentMode === 'plan' && !READ_ONLY_BROWSER_TOOLS.has(name)) {
606
+ results.push({ step: index + 1, name, ok: false, error: 'Plan mode blocks this action.' });
607
+ return {
608
+ ok: false,
609
+ stoppedAt: index + 1,
610
+ results,
611
+ error: `Plan mode allows only read-only browser actions; ${name} changes the page.`,
612
+ failureCategory: 'policy_blocked',
613
+ };
614
+ }
615
+ const result = await handler(context, input);
616
+ results.push({ step: index + 1, name, ...result });
617
+ if (result.ok === false) {
618
+ return {
619
+ ok: false,
620
+ stoppedAt: index + 1,
621
+ results,
622
+ error: `Batch stopped at step ${index + 1} (${name}): ${result.error ?? 'failed'}`,
623
+ failureCategory: result.failureCategory,
624
+ };
625
+ }
626
+ }
627
+ return { ok: true, results };
628
+ }
@@ -1,10 +1,10 @@
1
1
  import chalk from '../colors.js';
2
2
  import { classifyProjectPath, deleteProjectFile } from '../patcher.js';
3
3
  import { isTuiMode } from '../runtime-mode.js';
4
- import { removeIndexFile } from '../project-index.js';
5
4
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
5
+ import { ensurePermission } from '../permissions.js';
6
6
  export async function deleteFile(context, args) {
7
- const { rootDir, projectIndex } = context;
7
+ const { rootDir } = context;
8
8
  const filePath = String(args.filePath ?? '').trim();
9
9
  if (!filePath) {
10
10
  return { ok: false, error: 'filePath is required' };
@@ -24,10 +24,22 @@ export async function deleteFile(context, args) {
24
24
  };
25
25
  }
26
26
  const scratchPath = pathKind === 'scratch';
27
+ if (!scratchPath) {
28
+ const denied = await ensurePermission(context, {
29
+ bucket: 'delete',
30
+ title: 'Approve file deletion?',
31
+ body: `Delete ${filePath}`,
32
+ filePath,
33
+ }, 'delete_file', { filePath });
34
+ if (denied) {
35
+ if (!isTuiMode())
36
+ console.log(chalk.dim(` ⏭ Delete skipped: ${filePath}`));
37
+ return denied;
38
+ }
39
+ }
27
40
  const result = deleteProjectFile(rootDir, filePath);
28
41
  if (result.deleted) {
29
42
  if (!scratchPath) {
30
- await removeIndexFile(projectIndex, filePath);
31
43
  invalidateShellDiagnosticsCache(rootDir, filePath);
32
44
  }
33
45
  if (!isTuiMode())
@@ -39,7 +51,11 @@ export async function deleteFile(context, args) {
39
51
  changed: result.deleted,
40
52
  deleted: result.deleted,
41
53
  ...(scratchPath ? { scratch: true } : {}),
42
- content: result.content,
54
+ ...(result.bytes === undefined ? {} : { bytes: result.bytes }),
55
+ ...(result.contentOmitted === undefined
56
+ ? {}
57
+ : { contentOmitted: result.contentOmitted }),
58
+ ...(result.content === undefined ? {} : { content: result.content }),
43
59
  diagnostics: result.deleted && !scratchPath ? runShellDiagnostics(rootDir) : undefined,
44
60
  };
45
61
  }