codex-overleaf-link 2.3.0 → 2.3.2

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 (50) hide show
  1. package/README.md +23 -23
  2. package/extension/bootstrap/manifest.template.json +1 -1
  3. package/extension/src/background.js +20 -0
  4. package/extension/src/content/assetTransferBroker.js +169 -0
  5. package/extension/src/content/composerPanel.js +0 -2
  6. package/extension/src/content/contentRuntime.js +135 -111
  7. package/extension/src/content/generated/content.bundle.js +1066 -345
  8. package/extension/src/content/generated/content.bundle.meta.json +7 -3
  9. package/extension/src/content/legacyGlobalRegistry.js +3 -0
  10. package/extension/src/content/markdownText.js +12 -10
  11. package/extension/src/content/mathText.js +155 -9
  12. package/extension/src/content/modelPicker.js +2 -2
  13. package/extension/src/content/modelPickerSupport.js +3 -3
  14. package/extension/src/content/nativeCompatibilityController.js +2 -2
  15. package/extension/src/content/otWarmMirror.js +1 -1
  16. package/extension/src/content/pageBridgeClient.js +1 -0
  17. package/extension/src/content/runResultActions.js +114 -0
  18. package/extension/src/content/runTimelineView.js +23 -0
  19. package/extension/src/content/sessionForkController.js +120 -0
  20. package/extension/src/content/writebackController.js +3 -0
  21. package/extension/src/content/writebackOrchestrator.js +5 -50
  22. package/extension/src/page/binaryAssetUploader.js +343 -0
  23. package/extension/src/page/trackedChangesLifecycle.js +12 -4
  24. package/extension/src/page/writebackRouter.js +6 -2
  25. package/extension/src/pageBridge.js +30 -1
  26. package/extension/src/shared/agentTranscript.js +10 -3
  27. package/extension/src/shared/compatibility.js +4 -2
  28. package/extension/src/shared/i18n.js +18 -4
  29. package/extension/src/shared/models.js +13 -2
  30. package/extension/src/shared/pageRpcContract.js +6 -2
  31. package/extension/src/shared/runExecutionSnapshot.js +14 -4
  32. package/extension/src/shared/runExecutionSnapshotCodec.js +5 -2
  33. package/extension/src/shared/scopedPersistenceQueuePolicy.js +20 -1
  34. package/extension/src/shared/scopedPersistenceReceipt.js +60 -0
  35. package/extension/src/shared/scopedPersistenceTransaction.js +50 -47
  36. package/extension/src/shared/sessionState.js +58 -15
  37. package/extension/src/shared/storageDb.js +6 -5
  38. package/extension/src/shared/storageRunActions.js +11 -1
  39. package/extension/src/shared/writebackOperationEvidence.js +16 -6
  40. package/extension/src/shared/writebackSettlement.js +28 -6
  41. package/extension/styles/panel.css +79 -8
  42. package/native-host/src/codexModels.js +6 -2
  43. package/native-host/src/codexPromptAssembly.js +1 -1
  44. package/native-host/src/codexSessionRunner.js +5 -1
  45. package/native-host/src/codexThreadFork.js +103 -0
  46. package/native-host/src/mirrorWorkspace.js +5 -19
  47. package/native-host/src/nativeAssetTransfer.js +183 -0
  48. package/native-host/src/taskRunnerRuntime.js +33 -81
  49. package/native-host/src/updateManager.js +36 -1
  50. package/package.json +1 -1
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { getProjectMirror } = require('./mirrorWorkspace');
7
+
8
+ const MAX_ASSET_BYTES = 10 * 1024 * 1024;
9
+ const ASSET_CHUNK_BYTES = 192 * 1024;
10
+ const ASSET_TOKEN_TTL_MS = 15 * 60 * 1000;
11
+ const transfers = new Map();
12
+
13
+ function prepareBinaryAssetChanges({ projectId, rootDir, changes = [] } = {}) {
14
+ const prepared = [];
15
+ const unsupportedChanges = [];
16
+ for (const change of changes) {
17
+ if (change?.type !== 'binary-create' && change?.type !== 'overwrite-binary') {
18
+ prepared.push(change);
19
+ continue;
20
+ }
21
+ try {
22
+ prepared.push(registerAssetChange({ projectId, rootDir, change }));
23
+ } catch (error) {
24
+ unsupportedChanges.push({
25
+ type: 'unsupported-local-file',
26
+ path: change?.path || '',
27
+ reason: error.code || 'binary_asset_registration_failed',
28
+ size: change?.size,
29
+ attemptedChangeType: change?.type || 'binary-create'
30
+ });
31
+ }
32
+ }
33
+ return { changes: prepared, unsupportedChanges };
34
+ }
35
+
36
+ function registerAssetChange({ projectId, rootDir, change }) {
37
+ cleanupExpiredTransfers();
38
+ const mirror = getProjectMirror(projectId, { rootDir });
39
+ const relativePath = normalizeProjectPath(change.assetSourcePath || change.path);
40
+ const target = resolveSafeWorkspaceFile(mirror.workspacePath, relativePath);
41
+ const stat = fs.statSync(target);
42
+ if (!stat.isFile()) throw codedError('binary_asset_source_invalid', 'Asset source is not a file.');
43
+ if (stat.size > MAX_ASSET_BYTES) throw codedError('binary_file_too_large', 'Asset exceeds the 10 MiB writeback limit.');
44
+ const sha256 = hashFile(target);
45
+ if (change.sha256 && change.sha256 !== sha256) {
46
+ throw codedError('binary_asset_source_changed', 'Asset changed before transfer registration.');
47
+ }
48
+ const token = crypto.randomUUID();
49
+ const expiresAtMs = Date.now() + ASSET_TOKEN_TTL_MS;
50
+ transfers.set(token, {
51
+ token,
52
+ path: relativePath,
53
+ target,
54
+ size: stat.size,
55
+ sha256,
56
+ expiresAtMs
57
+ });
58
+ const { contentBase64, assetSourcePath, ...safeChange } = change;
59
+ return {
60
+ ...safeChange,
61
+ size: stat.size,
62
+ assetRef: {
63
+ token,
64
+ path: relativePath,
65
+ size: stat.size,
66
+ sha256,
67
+ mimeType: inferMimeType(relativePath),
68
+ chunkSize: ASSET_CHUNK_BYTES,
69
+ expiresAt: new Date(expiresAtMs).toISOString()
70
+ }
71
+ };
72
+ }
73
+
74
+ function readAssetChunk(params = {}) {
75
+ cleanupExpiredTransfers();
76
+ const transfer = getTransfer(params.token);
77
+ const offset = Number(params.offset || 0);
78
+ const requestedLength = Number(params.length || ASSET_CHUNK_BYTES);
79
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > transfer.size) {
80
+ throw codedError('asset_chunk_offset_invalid', 'Asset chunk offset is invalid.');
81
+ }
82
+ const length = Math.min(
83
+ ASSET_CHUNK_BYTES,
84
+ Number.isSafeInteger(requestedLength) && requestedLength > 0 ? requestedLength : ASSET_CHUNK_BYTES,
85
+ transfer.size - offset
86
+ );
87
+ assertSourceStable(transfer);
88
+ const handle = fs.openSync(transfer.target, 'r');
89
+ try {
90
+ const buffer = Buffer.alloc(length);
91
+ const bytesRead = length ? fs.readSync(handle, buffer, 0, length, offset) : 0;
92
+ const nextOffset = offset + bytesRead;
93
+ return {
94
+ token: transfer.token,
95
+ path: transfer.path,
96
+ offset,
97
+ nextOffset,
98
+ size: transfer.size,
99
+ sha256: transfer.sha256,
100
+ contentBase64: buffer.subarray(0, bytesRead).toString('base64'),
101
+ eof: nextOffset >= transfer.size
102
+ };
103
+ } finally {
104
+ fs.closeSync(handle);
105
+ }
106
+ }
107
+
108
+ function releaseAsset(params = {}) {
109
+ return { released: transfers.delete(String(params.token || '')) };
110
+ }
111
+
112
+ function getTransfer(tokenValue) {
113
+ const token = String(tokenValue || '');
114
+ const transfer = transfers.get(token);
115
+ if (!transfer) throw codedError('asset_token_invalid', 'Asset transfer token is invalid or expired.');
116
+ if (transfer.expiresAtMs <= Date.now()) {
117
+ transfers.delete(token);
118
+ throw codedError('asset_token_expired', 'Asset transfer token expired.');
119
+ }
120
+ return transfer;
121
+ }
122
+
123
+ function assertSourceStable(transfer) {
124
+ let stat;
125
+ try { stat = fs.statSync(transfer.target); } catch (_error) {
126
+ throw codedError('binary_asset_source_missing', 'Asset source disappeared during transfer.');
127
+ }
128
+ if (!stat.isFile() || stat.size !== transfer.size) {
129
+ throw codedError('binary_asset_source_changed', 'Asset source changed during transfer.');
130
+ }
131
+ }
132
+
133
+ function resolveSafeWorkspaceFile(workspacePath, relativePath) {
134
+ const root = path.resolve(workspacePath);
135
+ const target = path.resolve(root, relativePath);
136
+ if (target === root || !target.startsWith(`${root}${path.sep}`)) {
137
+ throw codedError('binary_asset_path_unsafe', 'Asset path escapes the project workspace.');
138
+ }
139
+ if (fs.lstatSync(target).isSymbolicLink()) {
140
+ throw codedError('binary_asset_path_unsafe', 'Symbolic-link assets cannot be transferred.');
141
+ }
142
+ return target;
143
+ }
144
+
145
+ function normalizeProjectPath(value) {
146
+ const normalized = String(value || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
147
+ if (!normalized || normalized.split('/').some(part => !part || part === '.' || part === '..')) {
148
+ throw codedError('binary_asset_path_invalid', 'Asset path is not a safe project-relative path.');
149
+ }
150
+ return normalized;
151
+ }
152
+
153
+ function hashFile(target) {
154
+ return crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex');
155
+ }
156
+
157
+ function inferMimeType(filePath) {
158
+ return {
159
+ '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
160
+ '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml',
161
+ '.webp': 'image/webp', '.eps': 'application/postscript'
162
+ }[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
163
+ }
164
+
165
+ function cleanupExpiredTransfers() {
166
+ const now = Date.now();
167
+ for (const [token, transfer] of transfers) if (transfer.expiresAtMs <= now) transfers.delete(token);
168
+ }
169
+
170
+ function codedError(code, message) {
171
+ const error = new Error(message);
172
+ error.code = code;
173
+ return error;
174
+ }
175
+
176
+ module.exports = {
177
+ ASSET_CHUNK_BYTES,
178
+ ASSET_TOKEN_TTL_MS,
179
+ MAX_ASSET_BYTES,
180
+ prepareBinaryAssetChanges,
181
+ readAssetChunk,
182
+ releaseAsset
183
+ };
@@ -3,7 +3,6 @@
3
3
  const { buildCodexRuntimeEvent } = require('./codexRuntimeIdentity');
4
4
 
5
5
  const { spawn } = require('node:child_process');
6
- const crypto = require('node:crypto');
7
6
  const { buildOperationSummary, splitDeletePlan } = require('../../extension/src/shared/summary');
8
7
  const {
9
8
  MIN_COMPATIBLE_EXTENSION_VERSION,
@@ -20,6 +19,8 @@ const {
20
19
  resolveRunProviderForRun
21
20
  } = require('./providerRuntime');
22
21
  const { clearPluginCodexHistory } = require('./codexHome');
22
+ const { forkCodexThread } = require('./codexThreadFork');
23
+ const { readAssetChunk, releaseAsset } = require('./nativeAssetTransfer');
23
24
  const { logDebug, truncateText } = require('./debugLog');
24
25
  const { HOST_NAME } = require('./manifest');
25
26
  const { getNativeRuntimePlatform, summarizeNativeEnvironment } = require('./nativeEnvironment');
@@ -40,8 +41,6 @@ const activeRunEntries = new Map();
40
41
  // Overleaf tab was reloaded — the requestId lived in content-side JS state
41
42
  // and is gone, but the native-host-side controller is still running).
42
43
  const activeRunByProject = new Map();
43
- const pendingPlans = new Map();
44
- const PENDING_PLAN_TTL_MS = 30 * 60 * 1000;
45
44
  const CODEX_RUN_PASSTHROUGH_ERROR_CODES = new Set(['thread_resume_failed', 'codex_no_usable_result']);
46
45
 
47
46
  async function handleRequest(request, env = process.env, emit = () => {}) {
@@ -119,6 +118,26 @@ async function handleRequest(request, env = process.env, emit = () => {}) {
119
118
  return handleCodexHistoryClear(request, env);
120
119
  }
121
120
 
121
+ if (request.method === 'codex.thread.fork') {
122
+ try {
123
+ return okResponse(request.id, await forkCodexThread(request.params || {}, env));
124
+ } catch (error) {
125
+ return errorResponse(request.id, error.code || 'codex_thread_fork_failed', error.message);
126
+ }
127
+ }
128
+
129
+ if (request.method === 'asset.readChunk') {
130
+ try {
131
+ return okResponse(request.id, readAssetChunk(request.params || {}));
132
+ } catch (error) {
133
+ return errorResponse(request.id, error.code || 'asset_chunk_read_failed', error.message);
134
+ }
135
+ }
136
+
137
+ if (request.method === 'asset.release') {
138
+ return okResponse(request.id, releaseAsset(request.params || {}));
139
+ }
140
+
122
141
  if (request.method === 'skills.list') {
123
142
  return handleSkillsList(request, env);
124
143
  }
@@ -136,7 +155,7 @@ async function handleRequest(request, env = process.env, emit = () => {}) {
136
155
  }
137
156
 
138
157
  if (request.method === 'task.confirm') {
139
- return handleTaskConfirm(request);
158
+ return errorResponse(request.id, 'suggest_mode_removed', 'Suggest mode has been removed. Reload the extension and choose Ask or Auto.');
140
159
  }
141
160
 
142
161
  return errorResponse(request.id, 'method_not_found', `Unknown method: ${request.method}`);
@@ -716,8 +735,11 @@ async function handleTaskRun(request, env, emit) {
716
735
  const params = request.params || {};
717
736
  const mode = params.mode;
718
737
 
719
- if (!['ask', 'confirm', 'auto'].includes(mode)) {
720
- return errorResponse(request.id, 'invalid_mode', 'Mode must be "ask", "confirm", or "auto"');
738
+ if (mode === 'confirm') {
739
+ return errorResponse(request.id, 'suggest_mode_removed', 'Suggest mode has been removed. Choose Ask or Auto.');
740
+ }
741
+ if (!['ask', 'auto'].includes(mode)) {
742
+ return errorResponse(request.id, 'invalid_mode', 'Mode must be "ask" or "auto"');
721
743
  }
722
744
 
723
745
  if (mode === 'auto' && !params.checkpoint?.ok && !isVerifiedReviewing(params.reviewing)) {
@@ -805,24 +827,6 @@ function isVerifiedReviewing(reviewing) {
805
827
  return reviewing?.ok === true && reviewing.status !== 'manual-override';
806
828
  }
807
829
 
808
- async function handleTaskConfirm(request) {
809
- const planId = request.params?.planId;
810
- purgeExpiredPendingPlans();
811
- if (!planId || !pendingPlans.has(planId)) {
812
- return errorResponse(request.id, 'plan_not_found', 'No pending task plan matched the supplied planId');
813
- }
814
-
815
- const plan = pendingPlans.get(planId);
816
- pendingPlans.delete(planId);
817
-
818
- return okResponse(request.id, {
819
- status: 'confirmed',
820
- notes: plan.notes || '',
821
- userReport: plan.userReport,
822
- operations: plan.operations
823
- });
824
- }
825
-
826
830
  function buildDefaultTaskResult(mode, operations, params = {}) {
827
831
  if (mode === 'ask') {
828
832
  return {
@@ -836,16 +840,6 @@ function buildDefaultTaskResult(mode, operations, params = {}) {
836
840
 
837
841
  const summary = buildOperationSummary(operations);
838
842
 
839
- if (mode === 'confirm') {
840
- return {
841
- status: 'requires_task_confirmation',
842
- summary,
843
- notes: '',
844
- userReport: buildDefaultUserReport(mode, operations, params),
845
- operations
846
- };
847
- }
848
-
849
843
  const split = splitDeletePlan(operations);
850
844
  if (split.needsConfirmation.length > 0) {
851
845
  return {
@@ -889,7 +883,7 @@ function normalizeAgentResult(mode, result, params = {}) {
889
883
  };
890
884
 
891
885
  if (!normalized.status) {
892
- normalized.status = mode === 'confirm' ? 'requires_task_confirmation' : 'completed';
886
+ normalized.status = 'completed';
893
887
  }
894
888
 
895
889
  return normalized;
@@ -911,40 +905,7 @@ function prepareResultForResponse(mode, result) {
911
905
  }
912
906
  }
913
907
 
914
- const confirmOperations = mode === 'confirm' ? collectResultOperations(result) : [];
915
- if (confirmOperations.length > 0) {
916
- result = {
917
- ...result,
918
- status: 'requires_task_confirmation',
919
- summary: buildOperationSummary(confirmOperations),
920
- operations: confirmOperations
921
- };
922
- }
923
-
924
- if (mode !== 'confirm' || result.status !== 'requires_task_confirmation') {
925
- return result;
926
- }
927
-
928
- purgeExpiredPendingPlans();
929
- const planId = `plan_${crypto.randomUUID()}`;
930
- pendingPlans.set(planId, {
931
- createdAt: Date.now(),
932
- expiresAt: Date.now() + PENDING_PLAN_TTL_MS,
933
- notes: result.notes || '',
934
- userReport: result.userReport,
935
- operations: Array.isArray(result.operations) ? result.operations : []
936
- });
937
-
938
- const {
939
- operations: _operations,
940
- pendingOperations: _pendingOperations,
941
- deletePlan: _deletePlan,
942
- ...redacted
943
- } = result;
944
- return {
945
- ...redacted,
946
- planId
947
- };
908
+ return result;
948
909
  }
949
910
 
950
911
  function validateTaskResultOperationQuotas(result = {}) {
@@ -965,7 +926,7 @@ function buildDefaultUserReport(mode, operations = [], params = {}) {
965
926
 
966
927
  return {
967
928
  conclusion: hasOperations
968
- ? reportText(locale, 'Codex prepared proposed changes and is waiting for confirmation or writeback.', 'Codex 已准备好建议修改,等待确认或写入。')
929
+ ? reportText(locale, 'Codex prepared changes for writeback.', 'Codex 已准备好写回修改。')
969
930
  : reportText(locale, 'This task completed without writing Overleaf files.', '这轮任务已完成,没有写入 Overleaf 文件。'),
970
931
  checked,
971
932
  findings: [],
@@ -975,7 +936,7 @@ function buildDefaultUserReport(mode, operations = [], params = {}) {
975
936
  ? ''
976
937
  : (mode === 'ask' ? reportText(locale, 'This run was Ask mode.', '这轮是只问不改。') : ''),
977
938
  nextStep: hasOperations
978
- ? reportText(locale, 'Review and confirm the proposed changes before writing them to Overleaf.', '请确认修改方案后写入 Overleaf。')
939
+ ? reportText(locale, 'Review the written changes in Overleaf.', '请在 Overleaf 中检查写入的修改。')
979
940
  : reportText(locale, 'Continue the conversation, or add more @context and run another check.', '可以继续追问,或加入更多 @context 后再检查。')
980
941
  };
981
942
  }
@@ -1193,14 +1154,6 @@ function runExternalAgent(agentSpec, params, emit = () => {}, options = {}) {
1193
1154
  });
1194
1155
  }
1195
1156
 
1196
- function purgeExpiredPendingPlans(now = Date.now()) {
1197
- for (const [planId, plan] of pendingPlans.entries()) {
1198
- if (Number.isFinite(plan?.expiresAt) && plan.expiresAt <= now) {
1199
- pendingPlans.delete(planId);
1200
- }
1201
- }
1202
- }
1203
-
1204
1157
  function parsePositiveInteger(value, fallback) {
1205
1158
  const parsed = Number.parseInt(value, 10);
1206
1159
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
@@ -1295,6 +1248,5 @@ module.exports = {
1295
1248
  buildDefaultTaskResult,
1296
1249
  getActiveNativeWorkState,
1297
1250
  handleRequest,
1298
- parseAgentEventLines,
1299
- purgeExpiredPendingPlans
1251
+ parseAgentEventLines
1300
1252
  };
@@ -743,6 +743,8 @@ function cleanupOrphanStageRoots(updatesRoot, retainedRoots = []) {
743
743
 
744
744
  function recoverAndReadStatus(context) {
745
745
  let journal = readJournal(context);
746
+ const alignedVersion = readAlignedManagedVersion(context);
747
+ journal = supersedeObsoleteJournal(context, journal, alignedVersion);
746
748
  if (journal?.state === 'activation_pending') {
747
749
  journal = { ...journal, state: 'staged', reasonCode: 'update_activation_interrupted', updatedAt: new Date().toISOString() };
748
750
  writeJournal(context, journal);
@@ -753,7 +755,7 @@ function recoverAndReadStatus(context) {
753
755
  writeJournal(context, journal);
754
756
  settleAuthorization(context, journal.authorizationId, 'revoked');
755
757
  }
756
- if (journal?.state === 'rolled_back') {
758
+ if (journal?.state === 'rolled_back' || journal?.state === 'superseded') {
757
759
  pruneNativeVersions(context.nativeRoot, new Set([
758
760
  readVersionPointer(context.nativeRoot, 'active-version'),
759
761
  readVersionPointer(context.nativeRoot, 'previous-version')
@@ -768,6 +770,36 @@ function recoverAndReadStatus(context) {
768
770
  };
769
771
  }
770
772
 
773
+ function readAlignedManagedVersion(context) {
774
+ try {
775
+ return assertManagedLayout(context);
776
+ } catch (_error) {
777
+ return '';
778
+ }
779
+ }
780
+
781
+ function supersedeObsoleteJournal(context, journal, activeVersion) {
782
+ if (!journal || journal.state === 'superseded' || !parseSemver(activeVersion) ||
783
+ !parseSemver(journal.sourceVersion) || !parseSemver(journal.targetVersion) ||
784
+ compareSemver(activeVersion, journal.sourceVersion) <= 0 ||
785
+ compareSemver(activeVersion, journal.targetVersion) <= 0) {
786
+ return journal;
787
+ }
788
+ const timestamp = new Date().toISOString();
789
+ const superseded = {
790
+ ...journal,
791
+ state: 'superseded',
792
+ supersededFromState: journal.state,
793
+ supersededByVersion: activeVersion,
794
+ supersededAt: timestamp,
795
+ updatedAt: timestamp
796
+ };
797
+ writeJournal(context, superseded);
798
+ cleanupStage(journal.stageRoot);
799
+ cleanupOrphanStageRoots(context.updatesRoot);
800
+ return superseded;
801
+ }
802
+
771
803
  function pruneNativeVersions(nativeRoot, retainedVersions) {
772
804
  const versionsRoot = path.join(nativeRoot, 'versions');
773
805
  let entries = [];
@@ -960,6 +992,9 @@ function publicTransaction(journal) {
960
992
  appliedAt: journal.appliedAt,
961
993
  confirmedAt: journal.confirmedAt,
962
994
  rolledBackAt: journal.rolledBackAt,
995
+ supersededAt: journal.supersededAt,
996
+ supersededByVersion: journal.supersededByVersion,
997
+ supersededFromState: journal.supersededFromState,
963
998
  reasonCode: journal.reasonCode || ''
964
999
  };
965
1000
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-overleaf-link",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "Cross-platform Chrome bridge that connects Codex to the active Overleaf project.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",