codex-overleaf-link 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +457 -0
  3. package/bin/codex-overleaf-link.mjs +223 -0
  4. package/extension/src/shared/agentTranscript.js +1175 -0
  5. package/extension/src/shared/auditRecords.js +568 -0
  6. package/extension/src/shared/compatibility.js +372 -0
  7. package/extension/src/shared/compileAdapter.js +176 -0
  8. package/extension/src/shared/governanceRules.js +252 -0
  9. package/extension/src/shared/i18n.js +565 -0
  10. package/extension/src/shared/models.js +106 -0
  11. package/extension/src/shared/otText.js +505 -0
  12. package/extension/src/shared/projectFiles.js +180 -0
  13. package/extension/src/shared/reviewing.js +99 -0
  14. package/extension/src/shared/sensitiveScan.js +116 -0
  15. package/extension/src/shared/sessionState.js +1084 -0
  16. package/extension/src/shared/staleGuard.js +150 -0
  17. package/extension/src/shared/storageDb.js +986 -0
  18. package/extension/src/shared/storageKeys.js +29 -0
  19. package/extension/src/shared/storageMigration.js +168 -0
  20. package/extension/src/shared/summary.js +248 -0
  21. package/extension/src/shared/undoOperations.js +369 -0
  22. package/native-host/src/codexArgs.js +43 -0
  23. package/native-host/src/codexHome.js +538 -0
  24. package/native-host/src/codexModels.js +247 -0
  25. package/native-host/src/codexPrompt.js +192 -0
  26. package/native-host/src/codexPromptAssembly.js +411 -0
  27. package/native-host/src/codexSessionRunner.js +1247 -0
  28. package/native-host/src/commandApproval.js +914 -0
  29. package/native-host/src/debugLog.js +78 -0
  30. package/native-host/src/diffEngine.js +247 -0
  31. package/native-host/src/index.js +132 -0
  32. package/native-host/src/launcher.js +81 -0
  33. package/native-host/src/localSkills.js +476 -0
  34. package/native-host/src/manifest.js +226 -0
  35. package/native-host/src/mirrorSensitiveScan.js +119 -0
  36. package/native-host/src/mirrorWorkspace.js +1019 -0
  37. package/native-host/src/nativeDoctor.js +826 -0
  38. package/native-host/src/nativeEnvironment.js +315 -0
  39. package/native-host/src/nativeHostPlatform.js +112 -0
  40. package/native-host/src/nativeMessaging.js +60 -0
  41. package/native-host/src/nativeQuotas.js +294 -0
  42. package/native-host/src/nativeResponseBudget.js +194 -0
  43. package/native-host/src/runtimeInstaller.js +357 -0
  44. package/native-host/src/taskRunner.js +3 -0
  45. package/native-host/src/taskRunnerRuntime.js +1083 -0
  46. package/native-host/src/textPatch.js +287 -0
  47. package/package.json +40 -0
  48. package/scripts/codex-json-agent.mjs +269 -0
  49. package/scripts/install-native-host.mjs +255 -0
  50. package/scripts/npm-package-files-v1.1.1.txt +52 -0
  51. package/scripts/uninstall-native-host.mjs +298 -0
  52. package/scripts/verify-npm-package.mjs +296 -0
@@ -0,0 +1,1083 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('node:child_process');
4
+ const crypto = require('node:crypto');
5
+ const { buildOperationSummary, splitDeletePlan } = require('../../extension/src/shared/summary');
6
+ const {
7
+ MIN_COMPATIBLE_EXTENSION_VERSION,
8
+ REQUIRED_CAPABILITIES,
9
+ SUPPORTED_NATIVE_PROTOCOL
10
+ } = require('../../extension/src/shared/compatibility');
11
+ const { runCodexSession } = require('./codexSessionRunner');
12
+ const { resolveCodexModels } = require('./codexModels');
13
+ const { clearPluginCodexHistory } = require('./codexHome');
14
+ const { logDebug, truncateText } = require('./debugLog');
15
+ const { HOST_NAME } = require('./manifest');
16
+ const { getNativeRuntimePlatform, summarizeNativeEnvironment } = require('./nativeEnvironment');
17
+ const {
18
+ NATIVE_REQUEST_QUOTAS,
19
+ firstQuotaViolation,
20
+ validateNativeRequestQuotas,
21
+ validateOperationListQuota,
22
+ validateOperationPayloadQuota
23
+ } = require('./nativeQuotas');
24
+ const { version: PACKAGE_VERSION } = require('../../package.json');
25
+
26
+ const activeProjectLocks = new Map();
27
+ const activeRunControllers = new Map();
28
+ const pendingPlans = new Map();
29
+ const PENDING_PLAN_TTL_MS = 30 * 60 * 1000;
30
+ const CODEX_RUN_PASSTHROUGH_ERROR_CODES = new Set(['thread_resume_failed']);
31
+
32
+ async function handleRequest(request, env = process.env, emit = () => {}) {
33
+ if (!request || typeof request !== 'object') {
34
+ return errorResponse(undefined, 'invalid_request', 'Request must be an object');
35
+ }
36
+
37
+ const quotaError = validateNativeRequestQuotas(request);
38
+ if (quotaError) {
39
+ return quotaErrorResponse(request.id, quotaError);
40
+ }
41
+
42
+ if (request.method === 'bridge.ping') {
43
+ return okResponse(request.id, {
44
+ host: HOST_NAME,
45
+ platform: getNativeRuntimePlatform({ env }),
46
+ protocolVersion: 1,
47
+ supportedProtocol: { ...SUPPORTED_NATIVE_PROTOCOL },
48
+ capabilities: Object.fromEntries(REQUIRED_CAPABILITIES.map(capability => [capability, true])),
49
+ minExtensionVersion: MIN_COMPATIBLE_EXTENSION_VERSION,
50
+ version: PACKAGE_VERSION,
51
+ environment: summarizeNativeEnvironment(env)
52
+ });
53
+ }
54
+
55
+ if (request.method === 'mirror.sync') {
56
+ return handleMirrorSync(request, env);
57
+ }
58
+
59
+ if (request.method === 'mirror.patchFiles') {
60
+ return handleMirrorPatchFiles(request, env);
61
+ }
62
+
63
+ if (request.method === 'mirror.status') {
64
+ return handleMirrorStatus(request, env);
65
+ }
66
+
67
+ if (request.method === 'mirror.scanSensitive') {
68
+ return handleMirrorScanSensitive(request, env);
69
+ }
70
+
71
+ if (request.method === 'codex.models') {
72
+ return okResponse(request.id, resolveCodexModels(request.params || {}, env));
73
+ }
74
+
75
+ if (request.method === 'codex.run') {
76
+ return handleCodexRun(request, env, emit);
77
+ }
78
+
79
+ if (request.method === 'codex.cancel') {
80
+ return handleCodexCancel(request);
81
+ }
82
+
83
+ if (request.method === 'codex.history.clearPlugin') {
84
+ return handleCodexHistoryClear(request, env);
85
+ }
86
+
87
+ if (request.method === 'skills.list') {
88
+ return handleSkillsList(request, env);
89
+ }
90
+
91
+ if (request.method === 'skills.install') {
92
+ return handleSkillsInstall(request, env);
93
+ }
94
+
95
+ if (request.method === 'skills.remove') {
96
+ return handleSkillsRemove(request, env);
97
+ }
98
+
99
+ if (request.method === 'task.run') {
100
+ return handleTaskRun(request, env, emit);
101
+ }
102
+
103
+ if (request.method === 'task.confirm') {
104
+ return handleTaskConfirm(request);
105
+ }
106
+
107
+ return errorResponse(request.id, 'method_not_found', `Unknown method: ${request.method}`);
108
+ }
109
+
110
+ function quotaErrorResponse(id, violation) {
111
+ return errorResponse(
112
+ id,
113
+ 'native_request_quota_exceeded',
114
+ `Native request quota exceeded: ${violation.reason} (${violation.actual}/${violation.limit}).`,
115
+ {
116
+ field: violation.field,
117
+ limit: violation.limit,
118
+ actual: violation.actual
119
+ }
120
+ );
121
+ }
122
+
123
+ async function handleCodexRun(request, env, emit) {
124
+ const params = request.params || {};
125
+ if (isCodexMissing(env)) {
126
+ return errorResponse(
127
+ request.id,
128
+ 'codex_not_found',
129
+ 'Codex CLI was not found. Install Codex or make sure the `codex` command is available in your login shell.'
130
+ );
131
+ }
132
+
133
+ const projectKey = resolveProjectKey(params);
134
+ const lockToken = acquireProjectLock(projectKey);
135
+ if (!lockToken) {
136
+ return errorResponse(request.id, 'project_locked', `Project ${projectKey} is currently in use by codex.run`);
137
+ }
138
+ const abortController = new AbortController();
139
+ if (request.id) {
140
+ activeRunControllers.set(request.id, abortController);
141
+ }
142
+ try {
143
+ if (params.useExistingMirror) {
144
+ const { getMirrorStatus, applyFileOverlays } = require('./mirrorWorkspace');
145
+ const rootDir = env.CODEX_OVERLEAF_MIRROR_ROOT;
146
+ const status = getMirrorStatus(projectKey, { rootDir });
147
+ const maxFreshness = params.expectedMirrorFreshness || 15000;
148
+ const mirrorMissingOrStale = !status.exists || !Number.isFinite(status.ageMs) || status.ageMs > maxFreshness;
149
+
150
+ if (isOtWarmMirrorReuseRequest(params)) {
151
+ const otWarmMirrorReuse = validateOtFocusedWarmMirrorReuse(params, status);
152
+ if (!otWarmMirrorReuse.ok) {
153
+ return errorResponse(
154
+ request.id,
155
+ 'mirror_stale',
156
+ otWarmMirrorReuse.message || `Mirror is ${status.ageMs}ms old (max ${maxFreshness}ms)`
157
+ );
158
+ }
159
+ } else if (mirrorMissingOrStale) {
160
+ return errorResponse(
161
+ request.id,
162
+ 'mirror_stale',
163
+ `Mirror is ${status.ageMs}ms old (max ${maxFreshness}ms)`
164
+ );
165
+ }
166
+
167
+ if (Array.isArray(params.fileOverlays) && params.fileOverlays.length) {
168
+ await applyFileOverlays({ projectId: projectKey, overlays: params.fileOverlays, rootDir });
169
+ }
170
+ } else if (!isSnapshotlessSkillInstallerRun(params) && !hasRunnableProjectSnapshotEvidence(params)) {
171
+ return errorResponse(
172
+ request.id,
173
+ 'codex_run_requires_snapshot_evidence',
174
+ 'codex.run requires an explicit full project snapshot or a focused partial snapshot'
175
+ );
176
+ }
177
+
178
+ const result = await runCodexSession({
179
+ params: params.useExistingMirror ? { ...params, skipMirrorSync: true } : params,
180
+ env,
181
+ emit,
182
+ rootDir: env.CODEX_OVERLEAF_MIRROR_ROOT,
183
+ signal: abortController.signal
184
+ });
185
+ const syncChanges = Array.isArray(result.syncChanges) ? result.syncChanges : [];
186
+ return okResponse(request.id, {
187
+ ...result,
188
+ syncChanges
189
+ });
190
+ } catch (error) {
191
+ if (isCancellationError(error)) {
192
+ logDebug('codex.run.cancelled', {
193
+ code: error.code,
194
+ message: error.message
195
+ });
196
+ return errorResponse(request.id, 'codex_cancelled', 'Codex run was cancelled by the user');
197
+ }
198
+ if (shouldPassthroughCodexRunError(error)) {
199
+ logDebug('codex.run.passthrough_failed', {
200
+ code: error.code,
201
+ message: error.message,
202
+ stack: error.stack
203
+ });
204
+ return errorResponse(request.id, error.code, truncateText(error.message, 12000));
205
+ }
206
+ logDebug('codex.run.failed', {
207
+ code: error.code,
208
+ message: error.message,
209
+ stack: error.stack
210
+ });
211
+ return errorResponse(request.id, 'codex_run_failed', truncateText(error.message, 12000));
212
+ } finally {
213
+ if (request.id && activeRunControllers.get(request.id) === abortController) {
214
+ activeRunControllers.delete(request.id);
215
+ }
216
+ releaseProjectLock(projectKey, lockToken);
217
+ }
218
+ }
219
+
220
+ function handleCodexCancel(request) {
221
+ const targetId = request.params?.requestId || request.params?.id;
222
+ if (!targetId || !activeRunControllers.has(targetId)) {
223
+ return okResponse(request.id, {
224
+ cancelled: false,
225
+ reason: 'No active Codex run matched the cancellation request'
226
+ });
227
+ }
228
+
229
+ const controller = activeRunControllers.get(targetId);
230
+ controller.abort(createCancellationError());
231
+ return okResponse(request.id, {
232
+ cancelled: true,
233
+ requestId: targetId
234
+ });
235
+ }
236
+
237
+ function handleCodexHistoryClear(request, env) {
238
+ try {
239
+ return okResponse(request.id, clearPluginCodexHistory(request.params || {}, env));
240
+ } catch (error) {
241
+ return errorResponse(request.id, 'codex_history_clear_failed', error.message);
242
+ }
243
+ }
244
+
245
+ function handleSkillsList(request, env) {
246
+ try {
247
+ const { CODEX_OVERLEAF_SKILL_SCOPE, listCodexOverleafSkills, listProjectSkills } = require('./localSkills');
248
+ if (request.params?.scope === CODEX_OVERLEAF_SKILL_SCOPE) {
249
+ return okResponse(request.id, listCodexOverleafSkills({ env }));
250
+ }
251
+ return okResponse(request.id, listProjectSkills({
252
+ projectId: request.params?.projectId,
253
+ rootDir: env.CODEX_OVERLEAF_MIRROR_ROOT
254
+ }));
255
+ } catch (error) {
256
+ return errorResponse(request.id, 'skills_list_failed', error.message);
257
+ }
258
+ }
259
+
260
+ function handleSkillsInstall(request, env) {
261
+ try {
262
+ const { CODEX_OVERLEAF_SKILL_SCOPE, installCodexOverleafSkill, installProjectSkill } = require('./localSkills');
263
+ if (request.params?.scope === CODEX_OVERLEAF_SKILL_SCOPE) {
264
+ return okResponse(request.id, installCodexOverleafSkill({
265
+ skillId: request.params?.skillId || request.params?.id,
266
+ content: request.params?.content,
267
+ env
268
+ }));
269
+ }
270
+ return okResponse(request.id, installProjectSkill({
271
+ projectId: request.params?.projectId,
272
+ skillId: request.params?.skillId || request.params?.id,
273
+ content: request.params?.content,
274
+ rootDir: env.CODEX_OVERLEAF_MIRROR_ROOT
275
+ }));
276
+ } catch (error) {
277
+ return errorResponse(request.id, 'skills_install_failed', error.message);
278
+ }
279
+ }
280
+
281
+ function handleSkillsRemove(request, env) {
282
+ try {
283
+ const { CODEX_OVERLEAF_SKILL_SCOPE, removeCodexOverleafSkill, removeProjectSkill } = require('./localSkills');
284
+ if (request.params?.scope === CODEX_OVERLEAF_SKILL_SCOPE) {
285
+ return okResponse(request.id, removeCodexOverleafSkill({
286
+ skillId: request.params?.skillId || request.params?.id,
287
+ env
288
+ }));
289
+ }
290
+ return okResponse(request.id, removeProjectSkill({
291
+ projectId: request.params?.projectId,
292
+ skillId: request.params?.skillId || request.params?.id,
293
+ rootDir: env.CODEX_OVERLEAF_MIRROR_ROOT
294
+ }));
295
+ } catch (error) {
296
+ return errorResponse(request.id, 'skills_remove_failed', error.message);
297
+ }
298
+ }
299
+
300
+ function hasRunnableProjectSnapshotEvidence(params = {}) {
301
+ if (params.project?.capabilities?.fullProjectSnapshot === true) {
302
+ return true;
303
+ }
304
+ if (params.project?.capabilities?.fullProjectSnapshot !== false) {
305
+ return false;
306
+ }
307
+ if (params.restrictToFocusFiles !== true) {
308
+ return false;
309
+ }
310
+ const normalizedFocusFiles = normalizeSnapshotEvidencePaths(params.focusFiles);
311
+ if (!normalizedFocusFiles.length || !Array.isArray(params.project?.files)) {
312
+ return false;
313
+ }
314
+ const evidenceFiles = new Map();
315
+ for (const file of params.project.files) {
316
+ const filePath = normalizeSnapshotEvidencePath(file?.path);
317
+ if (!filePath || !isUsableSnapshotEvidenceContent(file?.content)) {
318
+ continue;
319
+ }
320
+ evidenceFiles.set(filePath, file);
321
+ }
322
+ return normalizedFocusFiles.every(filePath => evidenceFiles.has(filePath));
323
+ }
324
+
325
+ function isSnapshotlessSkillInstallerRun(params = {}) {
326
+ return params.skipMirrorSync === true
327
+ && String(params.skillInvocation?.id || '').trim() === 'skill-installer';
328
+ }
329
+
330
+ function validateOtFocusedWarmMirrorReuse(params = {}, status = {}) {
331
+ if (!isOtWarmMirrorReuseRequest(params)) {
332
+ return { ok: false };
333
+ }
334
+ if (Array.isArray(params.fileOverlays) && params.fileOverlays.length) {
335
+ return {
336
+ ok: false,
337
+ message: 'OT warm mirror reuse does not accept file overlays'
338
+ };
339
+ }
340
+ if (status?.exists !== true) {
341
+ return {
342
+ ok: false,
343
+ message: 'OT warm mirror reuse requires an existing trusted mirror'
344
+ };
345
+ }
346
+ if (params.restrictToFocusFiles !== true) {
347
+ return {
348
+ ok: false,
349
+ message: 'OT warm mirror reuse requires restrictToFocusFiles=true'
350
+ };
351
+ }
352
+ const normalizedFocusFiles = normalizeSnapshotEvidencePaths(params.focusFiles);
353
+ if (!normalizedFocusFiles.length) {
354
+ return {
355
+ ok: false,
356
+ message: 'OT warm mirror reuse requires focused files'
357
+ };
358
+ }
359
+
360
+ const freshFiles = new Set();
361
+ for (const file of Array.isArray(status.otFreshFiles) ? status.otFreshFiles : []) {
362
+ if (file?.state !== 'fresh') {
363
+ continue;
364
+ }
365
+ const filePath = normalizeSnapshotEvidencePath(file.path);
366
+ if (filePath) {
367
+ freshFiles.add(filePath);
368
+ }
369
+ }
370
+ const missingFiles = normalizedFocusFiles.filter(filePath => !freshFiles.has(filePath));
371
+ if (missingFiles.length) {
372
+ return {
373
+ ok: false,
374
+ message: `OT warm mirror focused files are not OT-fresh: ${missingFiles.join(', ')}`
375
+ };
376
+ }
377
+
378
+ return { ok: true };
379
+ }
380
+
381
+ function isOtWarmMirrorReuseRequest(params = {}) {
382
+ return params.otWarmStart === true || params.warmStartStrategy === 'ot-warm-mirror';
383
+ }
384
+
385
+ function isUsableSnapshotEvidenceContent(content) {
386
+ if (typeof content !== 'string') {
387
+ return false;
388
+ }
389
+ const text = content.trim();
390
+ return Boolean(text) && !/^(loading|loading\.{3}|loading…)$/i.test(text);
391
+ }
392
+
393
+ function normalizeSnapshotEvidencePaths(value) {
394
+ const seen = new Set();
395
+ const paths = [];
396
+ for (const item of Array.isArray(value) ? value : []) {
397
+ const filePath = normalizeSnapshotEvidencePath(item);
398
+ if (!filePath || seen.has(filePath)) {
399
+ continue;
400
+ }
401
+ seen.add(filePath);
402
+ paths.push(filePath);
403
+ }
404
+ return paths;
405
+ }
406
+
407
+ function normalizeSnapshotEvidencePath(value) {
408
+ return String(value || '')
409
+ .replace(/^@file:/i, '')
410
+ .replace(/\\/g, '/')
411
+ .trim()
412
+ .replace(/^\/+/, '');
413
+ }
414
+
415
+ function createCancellationError() {
416
+ const error = new Error('Codex run was cancelled by the user');
417
+ error.code = 'codex_cancelled';
418
+ return error;
419
+ }
420
+
421
+ function isCancellationError(error = {}) {
422
+ return error.code === 'codex_cancelled'
423
+ || error.name === 'AbortError'
424
+ || /cancelled by the user|was cancelled/i.test(error.message || '');
425
+ }
426
+
427
+ function shouldPassthroughCodexRunError(error = {}) {
428
+ return CODEX_RUN_PASSTHROUGH_ERROR_CODES.has(error.code);
429
+ }
430
+
431
+ function isCodexMissing(env = process.env) {
432
+ return (
433
+ env.CODEX_OVERLEAF_ENV_READY === '1' ||
434
+ Object.prototype.hasOwnProperty.call(env, 'CODEX_OVERLEAF_CODEX_PATH')
435
+ ) && !env.CODEX_OVERLEAF_CODEX_PATH;
436
+ }
437
+
438
+ async function handleMirrorSync(request, env) {
439
+ const { syncOverleafToMirror } = require('./mirrorWorkspace');
440
+ const params = request.params || {};
441
+ const projectId = params.projectId || 'unknown';
442
+ const projectKey = resolveProjectKey(params);
443
+ const rootDir = env.CODEX_OVERLEAF_MIRROR_ROOT;
444
+
445
+ const lockToken = acquireProjectLock(projectKey);
446
+ if (!lockToken) {
447
+ return errorResponse(request.id, 'project_locked', `Project ${projectKey} is currently in use by codex.run`);
448
+ }
449
+ if (params.project?.capabilities?.fullProjectSnapshot !== true) {
450
+ releaseProjectLock(projectKey, lockToken);
451
+ return errorResponse(
452
+ request.id,
453
+ 'mirror_sync_requires_full_project',
454
+ 'mirror.sync requires an explicit full project snapshot'
455
+ );
456
+ }
457
+
458
+ try {
459
+ const result = await syncOverleafToMirror({
460
+ projectId,
461
+ project: params.project || { files: [] },
462
+ rootDir
463
+ });
464
+ return okResponse(request.id, {
465
+ fileCount: result.fileCount,
466
+ writtenCount: result.writtenCount || 0,
467
+ projectKey: result.projectKey
468
+ });
469
+ } catch (error) {
470
+ return errorResponse(request.id, 'mirror_sync_failed', error.message);
471
+ } finally {
472
+ releaseProjectLock(projectKey, lockToken);
473
+ }
474
+ }
475
+
476
+ async function handleMirrorPatchFiles(request, env) {
477
+ const { patchMirrorFiles } = require('./mirrorWorkspace');
478
+ const params = request.params || {};
479
+ const projectId = params.projectId || 'unknown';
480
+ const projectKey = resolveProjectKey(params);
481
+ const rootDir = env.CODEX_OVERLEAF_MIRROR_ROOT;
482
+
483
+ const lockToken = acquireProjectLock(projectKey);
484
+ if (!lockToken) {
485
+ return errorResponse(request.id, 'project_locked', `Project ${projectKey} is currently in use by codex.run`);
486
+ }
487
+
488
+ try {
489
+ const result = await patchMirrorFiles({
490
+ projectId,
491
+ files: params.files,
492
+ rootDir,
493
+ source: params.source || 'ot'
494
+ });
495
+ return okResponse(request.id, result);
496
+ } catch (error) {
497
+ return errorResponse(request.id, 'mirror_patch_files_failed', error.message);
498
+ } finally {
499
+ releaseProjectLock(projectKey, lockToken);
500
+ }
501
+ }
502
+
503
+ function handleMirrorStatus(request, env) {
504
+ const { getMirrorStatus } = require('./mirrorWorkspace');
505
+ const params = request.params || {};
506
+ const projectId = params.projectId || 'unknown';
507
+ const rootDir = env.CODEX_OVERLEAF_MIRROR_ROOT;
508
+ const status = getMirrorStatus(projectId, { rootDir });
509
+ return okResponse(request.id, status);
510
+ }
511
+
512
+ function handleMirrorScanSensitive(request, env) {
513
+ try {
514
+ const { scanMirrorSensitiveFiles } = require('./mirrorSensitiveScan');
515
+ const params = request.params || {};
516
+ return okResponse(request.id, scanMirrorSensitiveFiles({
517
+ projectId: params.projectId || 'unknown',
518
+ rootDir: env.CODEX_OVERLEAF_MIRROR_ROOT
519
+ }));
520
+ } catch (error) {
521
+ return errorResponse(request.id, 'mirror_sensitive_scan_failed', error.message);
522
+ }
523
+ }
524
+
525
+ async function handleTaskRun(request, env, emit) {
526
+ const params = request.params || {};
527
+ const mode = params.mode;
528
+
529
+ if (!['ask', 'confirm', 'auto'].includes(mode)) {
530
+ return errorResponse(request.id, 'invalid_mode', 'Mode must be "ask", "confirm", or "auto"');
531
+ }
532
+
533
+ if (mode === 'auto' && !params.checkpoint?.ok && !isVerifiedReviewing(params.reviewing)) {
534
+ return errorResponse(request.id, 'safety_required', 'Auto Mode requires an Overleaf checkpoint or verified Reviewing/Track Changes');
535
+ }
536
+
537
+ const fileCount = Array.isArray(params.project?.files) ? params.project.files.length : 0;
538
+ const totalChars = Array.isArray(params.project?.files)
539
+ ? params.project.files.reduce((sum, file) => sum + String(file?.content || '').length, 0)
540
+ : 0;
541
+ emitTaskEvent(emit, 'native.task.received', 'Native bridge received task', {
542
+ mode,
543
+ model: params.model,
544
+ reasoningEffort: params.reasoningEffort,
545
+ speedTier: params.speedTier,
546
+ fileCount,
547
+ totalChars
548
+ });
549
+
550
+ let agentSpec;
551
+ try {
552
+ agentSpec = resolveExternalAgent(env);
553
+ } catch (error) {
554
+ return errorResponse(request.id, 'invalid_agent_command', error.message);
555
+ }
556
+
557
+ if (agentSpec) {
558
+ try {
559
+ logDebug('agent.run.start', {
560
+ command: agentSpec.label,
561
+ mode,
562
+ model: params.model,
563
+ reasoningEffort: params.reasoningEffort,
564
+ speedTier: params.speedTier,
565
+ fileCount
566
+ });
567
+ emitTaskEvent(emit, 'agent.command.started', 'Codex agent command started', {
568
+ mode,
569
+ model: params.model,
570
+ reasoningEffort: params.reasoningEffort,
571
+ speedTier: params.speedTier,
572
+ fileCount
573
+ });
574
+ const result = await runExternalAgent(agentSpec, params, emit, {
575
+ env,
576
+ timeoutMs: parseOptionalPositiveInteger(env.CODEX_OVERLEAF_AGENT_TIMEOUT_MS),
577
+ outputMaxBytes: parsePositiveInteger(env.CODEX_OVERLEAF_AGENT_OUTPUT_MAX_BYTES, 1024 * 1024)
578
+ });
579
+ logDebug('agent.run.ok', {
580
+ status: result?.status,
581
+ operationCount: Array.isArray(result?.operations) ? result.operations.length : 0
582
+ });
583
+ emitTaskEvent(emit, 'agent.command.completed', 'Codex agent command completed', {
584
+ status: result?.status || 'completed',
585
+ operationCount: Array.isArray(result?.operations) ? result.operations.length : 0
586
+ }, 'completed');
587
+ const normalized = normalizeAgentResult(mode, result, params);
588
+ const resultQuotaError = validateTaskResultOperationQuotas(normalized);
589
+ if (resultQuotaError) {
590
+ return quotaErrorResponse(request.id, resultQuotaError);
591
+ }
592
+ return okResponse(request.id, prepareResultForResponse(mode, normalized));
593
+ } catch (error) {
594
+ logDebug('agent.run.failed', {
595
+ message: error.message,
596
+ stack: error.stack
597
+ });
598
+ emitTaskEvent(emit, 'agent.command.failed', 'Codex agent command failed', {
599
+ message: error.message
600
+ }, 'failed');
601
+ return errorResponse(request.id, 'agent_failed', truncateText(error.message, 12000));
602
+ }
603
+ }
604
+
605
+ const operations = params.proposedOperations || [];
606
+ const result = buildDefaultTaskResult(mode, operations, params);
607
+ const resultQuotaError = validateTaskResultOperationQuotas(result);
608
+ if (resultQuotaError) {
609
+ return quotaErrorResponse(request.id, resultQuotaError);
610
+ }
611
+ return okResponse(request.id, prepareResultForResponse(mode, result));
612
+ }
613
+
614
+ function isVerifiedReviewing(reviewing) {
615
+ return reviewing?.ok === true && reviewing.status !== 'manual-override';
616
+ }
617
+
618
+ async function handleTaskConfirm(request) {
619
+ const planId = request.params?.planId;
620
+ purgeExpiredPendingPlans();
621
+ if (!planId || !pendingPlans.has(planId)) {
622
+ return errorResponse(request.id, 'plan_not_found', 'No pending task plan matched the supplied planId');
623
+ }
624
+
625
+ const plan = pendingPlans.get(planId);
626
+ pendingPlans.delete(planId);
627
+
628
+ return okResponse(request.id, {
629
+ status: 'confirmed',
630
+ notes: plan.notes || '',
631
+ userReport: plan.userReport,
632
+ operations: plan.operations
633
+ });
634
+ }
635
+
636
+ function buildDefaultTaskResult(mode, operations, params = {}) {
637
+ if (mode === 'ask') {
638
+ return {
639
+ status: 'completed',
640
+ summary: buildOperationSummary([]),
641
+ notes: '',
642
+ userReport: buildDefaultUserReport(mode, [], params),
643
+ operations: []
644
+ };
645
+ }
646
+
647
+ const summary = buildOperationSummary(operations);
648
+
649
+ if (mode === 'confirm') {
650
+ return {
651
+ status: 'requires_task_confirmation',
652
+ summary,
653
+ notes: '',
654
+ userReport: buildDefaultUserReport(mode, operations, params),
655
+ operations
656
+ };
657
+ }
658
+
659
+ const split = splitDeletePlan(operations);
660
+ if (split.needsConfirmation.length > 0) {
661
+ return {
662
+ status: 'delete_plan_required',
663
+ summary: buildOperationSummary(split.immediate),
664
+ notes: '',
665
+ userReport: buildDefaultUserReport(mode, operations, params),
666
+ operations: split.immediate,
667
+ deletePlan: buildOperationSummary(split.needsConfirmation).deletePlan,
668
+ pendingOperations: split.needsConfirmation
669
+ };
670
+ }
671
+
672
+ return {
673
+ status: 'completed',
674
+ summary,
675
+ notes: '',
676
+ userReport: buildDefaultUserReport(mode, operations, params),
677
+ operations
678
+ };
679
+ }
680
+
681
+ function normalizeAgentResult(mode, result, params = {}) {
682
+ const operations = Array.isArray(result.operations) ? result.operations : [];
683
+ if (mode === 'ask') {
684
+ return {
685
+ status: 'completed',
686
+ summary: buildOperationSummary([]),
687
+ notes: typeof result.notes === 'string' ? result.notes : '',
688
+ userReport: normalizeUserReport(result.userReport, buildDefaultUserReport(mode, [], params)),
689
+ operations: []
690
+ };
691
+ }
692
+
693
+ const normalized = {
694
+ ...result,
695
+ summary: result.summary || buildOperationSummary(operations),
696
+ notes: typeof result.notes === 'string' ? result.notes : '',
697
+ userReport: normalizeUserReport(result.userReport, buildDefaultUserReport(mode, collectResultOperations(result), params)),
698
+ operations
699
+ };
700
+
701
+ if (!normalized.status) {
702
+ normalized.status = mode === 'confirm' ? 'requires_task_confirmation' : 'completed';
703
+ }
704
+
705
+ return normalized;
706
+ }
707
+
708
+ function prepareResultForResponse(mode, result) {
709
+ if (mode === 'auto') {
710
+ const operations = collectResultOperations(result);
711
+ const split = splitDeletePlan(operations);
712
+ if (split.needsConfirmation.length > 0) {
713
+ return {
714
+ ...result,
715
+ status: 'delete_plan_required',
716
+ summary: buildOperationSummary(split.immediate),
717
+ operations: split.immediate,
718
+ deletePlan: buildOperationSummary(split.needsConfirmation).deletePlan,
719
+ pendingOperations: split.needsConfirmation
720
+ };
721
+ }
722
+ }
723
+
724
+ const confirmOperations = mode === 'confirm' ? collectResultOperations(result) : [];
725
+ if (confirmOperations.length > 0) {
726
+ result = {
727
+ ...result,
728
+ status: 'requires_task_confirmation',
729
+ summary: buildOperationSummary(confirmOperations),
730
+ operations: confirmOperations
731
+ };
732
+ }
733
+
734
+ if (mode !== 'confirm' || result.status !== 'requires_task_confirmation') {
735
+ return result;
736
+ }
737
+
738
+ purgeExpiredPendingPlans();
739
+ const planId = `plan_${crypto.randomUUID()}`;
740
+ pendingPlans.set(planId, {
741
+ createdAt: Date.now(),
742
+ expiresAt: Date.now() + PENDING_PLAN_TTL_MS,
743
+ notes: result.notes || '',
744
+ userReport: result.userReport,
745
+ operations: Array.isArray(result.operations) ? result.operations : []
746
+ });
747
+
748
+ const {
749
+ operations: _operations,
750
+ pendingOperations: _pendingOperations,
751
+ deletePlan: _deletePlan,
752
+ ...redacted
753
+ } = result;
754
+ return {
755
+ ...redacted,
756
+ planId
757
+ };
758
+ }
759
+
760
+ function validateTaskResultOperationQuotas(result = {}) {
761
+ const operations = collectResultOperations(result);
762
+ return firstQuotaViolation([
763
+ validateOperationListQuota(operations, 'operations'),
764
+ validateOperationPayloadQuota(operations, 'operations')
765
+ ]);
766
+ }
767
+
768
+ function buildDefaultUserReport(mode, operations = [], params = {}) {
769
+ const checked = (params.project?.files || [])
770
+ .map(file => file?.path)
771
+ .filter(path => typeof path === 'string' && path.length > 0)
772
+ .slice(0, 20);
773
+ const hasOperations = operations.length > 0;
774
+
775
+ return {
776
+ conclusion: hasOperations
777
+ ? 'Codex 已准备好建议修改,等待确认或写入。'
778
+ : '这轮任务已完成,没有写入 Overleaf 文件。',
779
+ checked,
780
+ findings: [],
781
+ plannedChanges: hasOperations ? operations.map(formatOperationForUserReport) : [],
782
+ appliedChanges: [],
783
+ unchangedReason: hasOperations ? '' : (mode === 'ask' ? '这轮是只问不改。' : ''),
784
+ nextStep: hasOperations
785
+ ? '请确认修改方案后写入 Overleaf。'
786
+ : '可以继续追问,或加入更多 @context 后再检查。'
787
+ };
788
+ }
789
+
790
+ function normalizeUserReport(value, fallback) {
791
+ if (!value || typeof value !== 'object') {
792
+ return fallback;
793
+ }
794
+ return {
795
+ conclusion: typeof value.conclusion === 'string' ? value.conclusion : fallback.conclusion,
796
+ checked: normalizeStringArray(value.checked, fallback.checked),
797
+ findings: normalizeStringArray(value.findings, fallback.findings),
798
+ plannedChanges: normalizeStringArray(value.plannedChanges, fallback.plannedChanges),
799
+ appliedChanges: normalizeStringArray(value.appliedChanges, fallback.appliedChanges),
800
+ unchangedReason: typeof value.unchangedReason === 'string' ? value.unchangedReason : fallback.unchangedReason,
801
+ nextStep: typeof value.nextStep === 'string' ? value.nextStep : fallback.nextStep
802
+ };
803
+ }
804
+
805
+ function normalizeStringArray(value, fallback = []) {
806
+ if (!Array.isArray(value)) {
807
+ return fallback;
808
+ }
809
+ return value.filter(item => typeof item === 'string');
810
+ }
811
+
812
+ function formatOperationForUserReport(operation) {
813
+ const labels = {
814
+ edit: '编辑',
815
+ create: '新建',
816
+ rename: '重命名',
817
+ move: '移动',
818
+ delete: '删除'
819
+ };
820
+ const label = labels[operation?.type] || operation?.type || '处理';
821
+ const filePath = operation?.path || operation?.to || '未知文件';
822
+ return `${filePath}:${label}`;
823
+ }
824
+
825
+ function collectResultOperations(result) {
826
+ const operations = Array.isArray(result.operations) ? result.operations : [];
827
+ const pendingOperations = Array.isArray(result.pendingOperations) ? result.pendingOperations : [];
828
+ if (!pendingOperations.length) {
829
+ return operations;
830
+ }
831
+
832
+ const seen = new Set();
833
+ const combined = [];
834
+ for (const operation of [...operations, ...pendingOperations]) {
835
+ const key = JSON.stringify(operation);
836
+ if (seen.has(key)) {
837
+ continue;
838
+ }
839
+ seen.add(key);
840
+ combined.push(operation);
841
+ }
842
+ return combined;
843
+ }
844
+
845
+ function resolveProjectKey(params = {}) {
846
+ const projectId = params.projectId || params.project?.projectId || params.project?.id || params.project?.url || 'unknown';
847
+ const raw = String(projectId).trim();
848
+ const fromUrl = raw.match(/\/project\/([^/?#]+)/)?.[1];
849
+ const candidate = fromUrl || raw.split(/[/?#]/).filter(Boolean).pop() || 'unknown';
850
+ return candidate.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'unknown';
851
+ }
852
+
853
+ function acquireProjectLock(projectKey) {
854
+ if (activeProjectLocks.has(projectKey)) {
855
+ return null;
856
+ }
857
+ const token = Symbol(projectKey);
858
+ activeProjectLocks.set(projectKey, token);
859
+ return token;
860
+ }
861
+
862
+ function releaseProjectLock(projectKey, token) {
863
+ if (activeProjectLocks.get(projectKey) === token) {
864
+ activeProjectLocks.delete(projectKey);
865
+ }
866
+ }
867
+
868
+ function isProjectLocked(projectKey) {
869
+ return activeProjectLocks.has(projectKey);
870
+ }
871
+
872
+ function resolveExternalAgent(env) {
873
+ if (env.CODEX_OVERLEAF_AGENT_FILE) {
874
+ const args = parseAgentArgsJson(env.CODEX_OVERLEAF_AGENT_ARGS_JSON);
875
+ return {
876
+ file: env.CODEX_OVERLEAF_AGENT_FILE,
877
+ args,
878
+ label: [env.CODEX_OVERLEAF_AGENT_FILE, ...args].join(' ')
879
+ };
880
+ }
881
+
882
+ return null;
883
+ }
884
+
885
+ function parseAgentArgsJson(value) {
886
+ if (!value) {
887
+ return [];
888
+ }
889
+ const parsed = JSON.parse(value);
890
+ if (!Array.isArray(parsed) || !parsed.every(item => typeof item === 'string')) {
891
+ throw new Error('CODEX_OVERLEAF_AGENT_ARGS_JSON must be a JSON array of strings');
892
+ }
893
+ return parsed;
894
+ }
895
+
896
+ function runExternalAgent(agentSpec, params, emit = () => {}, options = {}) {
897
+ return new Promise((resolve, reject) => {
898
+ const child = spawn(agentSpec.file, agentSpec.args || [], {
899
+ env: options.env || process.env,
900
+ shell: false,
901
+ stdio: ['pipe', 'pipe', 'pipe']
902
+ });
903
+ const timeoutMs = parseOptionalPositiveInteger(options.timeoutMs);
904
+ const outputMaxBytes = parsePositiveInteger(options.outputMaxBytes, 1024 * 1024);
905
+ let stdout = '';
906
+ let stderr = '';
907
+ let stderrRemainder = '';
908
+ let outputBytes = 0;
909
+ let settled = false;
910
+
911
+ const timeout = timeoutMs
912
+ ? setTimeout(() => {
913
+ fail(new Error(`Agent command timed out after ${timeoutMs}ms`));
914
+ }, timeoutMs)
915
+ : null;
916
+
917
+ function trackOutputBytes(chunk) {
918
+ outputBytes += Buffer.byteLength(String(chunk), 'utf8');
919
+ if (outputBytes > outputMaxBytes) {
920
+ fail(new Error(`Agent output limit exceeded (${outputBytes}/${outputMaxBytes} bytes)`));
921
+ return false;
922
+ }
923
+ return true;
924
+ }
925
+
926
+ function fail(error) {
927
+ if (settled) {
928
+ return;
929
+ }
930
+ settled = true;
931
+ if (timeout) {
932
+ clearTimeout(timeout);
933
+ }
934
+ if (child.exitCode === null && !child.killed) {
935
+ child.kill('SIGTERM');
936
+ }
937
+ reject(error);
938
+ }
939
+
940
+ function succeed(result) {
941
+ if (settled) {
942
+ return;
943
+ }
944
+ settled = true;
945
+ if (timeout) {
946
+ clearTimeout(timeout);
947
+ }
948
+ resolve(result);
949
+ }
950
+
951
+ child.stdout.setEncoding('utf8');
952
+ child.stderr.setEncoding('utf8');
953
+ child.stdout.on('data', chunk => {
954
+ if (!trackOutputBytes(chunk) || settled) {
955
+ return;
956
+ }
957
+ stdout += chunk;
958
+ });
959
+ child.stderr.on('data', chunk => {
960
+ if (!trackOutputBytes(chunk) || settled) {
961
+ return;
962
+ }
963
+ const parsed = parseAgentEventLines(`${stderrRemainder}${chunk}`);
964
+ stderrRemainder = parsed.remainder;
965
+ stderr += parsed.stderr;
966
+ for (const event of parsed.events) {
967
+ emitTaskEvent(emit, event.type || 'agent.progress', event.title || event.type || 'Agent progress', event.detail || {}, event.status || 'running');
968
+ }
969
+ });
970
+ child.on('error', fail);
971
+ child.on('close', code => {
972
+ if (settled) {
973
+ return;
974
+ }
975
+ if (stderrRemainder) {
976
+ const parsed = parseAgentEventLines(`${stderrRemainder}\n`);
977
+ stderr += parsed.stderr;
978
+ for (const event of parsed.events) {
979
+ emitTaskEvent(emit, event.type || 'agent.progress', event.title || event.type || 'Agent progress', event.detail || {}, event.status || 'running');
980
+ }
981
+ }
982
+
983
+ if (code !== 0) {
984
+ fail(new Error(truncateText(stderr || `Agent command exited with code ${code}`, 12000)));
985
+ return;
986
+ }
987
+
988
+ try {
989
+ succeed(JSON.parse(stdout || '{}'));
990
+ } catch (error) {
991
+ fail(new Error(`Agent returned invalid JSON: ${error.message}. stdout=${truncateText(stdout, 4000)}`));
992
+ }
993
+ });
994
+
995
+ child.stdin.end(JSON.stringify(params));
996
+ });
997
+ }
998
+
999
+ function purgeExpiredPendingPlans(now = Date.now()) {
1000
+ for (const [planId, plan] of pendingPlans.entries()) {
1001
+ if (Number.isFinite(plan?.expiresAt) && plan.expiresAt <= now) {
1002
+ pendingPlans.delete(planId);
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ function parsePositiveInteger(value, fallback) {
1008
+ const parsed = Number.parseInt(value, 10);
1009
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
1010
+ }
1011
+
1012
+ function parseOptionalPositiveInteger(value) {
1013
+ const parsed = Number.parseInt(value, 10);
1014
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
1015
+ }
1016
+
1017
+ function parseAgentEventLines(text) {
1018
+ const lines = text.split(/\r?\n/);
1019
+ const remainder = lines.pop() || '';
1020
+ const events = [];
1021
+ const stderrLines = [];
1022
+
1023
+ for (const line of lines) {
1024
+ if (!line.startsWith('CODEX_OVERLEAF_EVENT ')) {
1025
+ stderrLines.push(line);
1026
+ continue;
1027
+ }
1028
+
1029
+ try {
1030
+ events.push(JSON.parse(line.slice('CODEX_OVERLEAF_EVENT '.length)));
1031
+ } catch (error) {
1032
+ stderrLines.push(`Invalid agent event: ${error.message}`);
1033
+ }
1034
+ }
1035
+
1036
+ return {
1037
+ events,
1038
+ stderr: stderrLines.length ? `${stderrLines.join('\n')}\n` : '',
1039
+ remainder
1040
+ };
1041
+ }
1042
+
1043
+ function emitTaskEvent(emit, type, title, detail = {}, status = 'running') {
1044
+ if (typeof emit !== 'function') {
1045
+ return;
1046
+ }
1047
+
1048
+ emit({
1049
+ type,
1050
+ title,
1051
+ status,
1052
+ detail,
1053
+ timestamp: new Date().toISOString()
1054
+ });
1055
+ }
1056
+
1057
+ function okResponse(id, result) {
1058
+ return {
1059
+ id,
1060
+ ok: true,
1061
+ result
1062
+ };
1063
+ }
1064
+
1065
+ function errorResponse(id, code, message, details = {}) {
1066
+ return {
1067
+ id,
1068
+ ok: false,
1069
+ error: {
1070
+ code,
1071
+ message,
1072
+ ...details
1073
+ }
1074
+ };
1075
+ }
1076
+
1077
+ module.exports = {
1078
+ NATIVE_REQUEST_QUOTAS,
1079
+ buildDefaultTaskResult,
1080
+ handleRequest,
1081
+ parseAgentEventLines,
1082
+ purgeExpiredPendingPlans
1083
+ };