codex-overleaf-link 1.3.7 → 1.3.9
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/README.md +21 -21
- package/extension/src/shared/agentTranscript.js +304 -21
- package/extension/src/shared/compatibility.js +1 -1
- package/extension/src/shared/failureReasons.js +593 -0
- package/extension/src/shared/i18n.js +151 -2
- package/extension/src/shared/pathRedaction.js +117 -0
- package/extension/src/shared/sensitiveScan.js +1 -4
- package/extension/src/shared/sessionState.js +113 -4
- package/extension/src/shared/staleGuard.js +0 -2
- package/extension/src/shared/storageDb.js +217 -4
- package/native-host/src/codexPromptAssembly.js +10 -2
- package/native-host/src/codexSessionRunner.js +73 -1
- package/native-host/src/taskRunnerRuntime.js +53 -8
- package/package.json +1 -1
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
var VALID_TRACKED_CHANGE_STATUSES = {
|
|
42
42
|
pending: true,
|
|
43
43
|
accepted: true,
|
|
44
|
-
rejected: true
|
|
44
|
+
rejected: true,
|
|
45
|
+
needs_review: true
|
|
45
46
|
};
|
|
46
47
|
var SECRET_REDACTION_PATTERNS = [
|
|
47
48
|
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
|
|
@@ -237,6 +238,27 @@
|
|
|
237
238
|
function buildSessionRecord(input) {
|
|
238
239
|
var now = new Date().toISOString();
|
|
239
240
|
var titleSource = input.titleSource === 'manual' ? 'manual' : 'auto';
|
|
241
|
+
var updatedAt = typeof input.updatedAt === 'string' ? input.updatedAt : now;
|
|
242
|
+
// Welcome-panel + write-guard: persist the four
|
|
243
|
+
// Recent-projects fields on every session record so the cross-project
|
|
244
|
+
// query (`listRecentProjectsAcrossAccount`) can filter / sort / render
|
|
245
|
+
// without touching the raw `task` text.
|
|
246
|
+
//
|
|
247
|
+
// `accountScopeId` is derived via the page-side injection point — T4
|
|
248
|
+
// installs the real implementation on `window.codexOverleafDeriveAccountScopeId`.
|
|
249
|
+
// For T3 the fallback is `() => null`; records persisted with a null scope
|
|
250
|
+
// surface `accountScopeUnavailable: true` and are filtered out of the
|
|
251
|
+
// cross-project query (spec §5.2 degraded mode).
|
|
252
|
+
var safeTaskSummary = typeof input.safeTaskSummary === 'string'
|
|
253
|
+
? input.safeTaskSummary
|
|
254
|
+
: deriveSafeTaskSummaryFromInput(input);
|
|
255
|
+
var accountScopeId = resolveAccountScopeId(input);
|
|
256
|
+
var accountScopeUnavailable = typeof input.accountScopeUnavailable === 'boolean'
|
|
257
|
+
? input.accountScopeUnavailable
|
|
258
|
+
: !accountScopeId;
|
|
259
|
+
var lastActivityAt = typeof input.lastActivityAt === 'string' && input.lastActivityAt
|
|
260
|
+
? input.lastActivityAt
|
|
261
|
+
: updatedAt;
|
|
240
262
|
return {
|
|
241
263
|
id: input.id || generateId('ses'),
|
|
242
264
|
projectId: input.projectId || '',
|
|
@@ -254,10 +276,77 @@
|
|
|
254
276
|
speedTier: typeof input.speedTier === 'string' ? input.speedTier : '',
|
|
255
277
|
requireReviewing: input.requireReviewing !== false,
|
|
256
278
|
createdAt: typeof input.createdAt === 'string' ? input.createdAt : now,
|
|
257
|
-
updatedAt:
|
|
279
|
+
updatedAt: updatedAt,
|
|
280
|
+
lastActivityAt: lastActivityAt,
|
|
281
|
+
accountScopeId: accountScopeId,
|
|
282
|
+
accountScopeUnavailable: accountScopeUnavailable,
|
|
283
|
+
safeTaskSummary: safeTaskSummary
|
|
258
284
|
};
|
|
259
285
|
}
|
|
260
286
|
|
|
287
|
+
function resolveAccountScopeId(input) {
|
|
288
|
+
if (input && typeof input.accountScopeId === 'string' && input.accountScopeId) {
|
|
289
|
+
return input.accountScopeId;
|
|
290
|
+
}
|
|
291
|
+
var globalScope = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : null);
|
|
292
|
+
var derive = globalScope && globalScope.window && globalScope.window.codexOverleafDeriveAccountScopeId;
|
|
293
|
+
if (!(derive instanceof Function)) {
|
|
294
|
+
// No injection installed (T3 fallback): tests + early page-load reads
|
|
295
|
+
// both land here. Returning `null` puts the session in degraded mode
|
|
296
|
+
// and excludes it from the cross-project query.
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
var value = derive();
|
|
301
|
+
return typeof value === 'string' && value ? value : null;
|
|
302
|
+
} catch (_error) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function deriveSafeTaskSummaryFromInput(input) {
|
|
308
|
+
var SessionState = loadSessionState();
|
|
309
|
+
if (!SessionState || !(SessionState.computeSafeTaskSummary instanceof Function)) {
|
|
310
|
+
return '';
|
|
311
|
+
}
|
|
312
|
+
var task = typeof input.task === 'string' && input.task ? input.task : '';
|
|
313
|
+
if (!task && Array.isArray(input.runs) && input.runs.length) {
|
|
314
|
+
var firstRun = input.runs[0];
|
|
315
|
+
if (firstRun && typeof firstRun.task === 'string') {
|
|
316
|
+
task = firstRun.task;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return SessionState.computeSafeTaskSummary(task);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Module-local lazy require of the sibling sessionState module. CommonJS
|
|
323
|
+
// resolves once; subsequent calls hit the require cache. We avoid an import
|
|
324
|
+
// at the top of the file so the module surface remains friendly to the
|
|
325
|
+
// page-side IIFE wrapper (no `require` available in that context — the page
|
|
326
|
+
// build uses the global `CodexOverleafSessionState` via the IIFE branch).
|
|
327
|
+
var _sessionStateCache = null;
|
|
328
|
+
function loadSessionState() {
|
|
329
|
+
if (_sessionStateCache !== null) {
|
|
330
|
+
return _sessionStateCache || null;
|
|
331
|
+
}
|
|
332
|
+
var globalScope = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : null);
|
|
333
|
+
if (globalScope && globalScope.CodexOverleafSessionState) {
|
|
334
|
+
_sessionStateCache = globalScope.CodexOverleafSessionState;
|
|
335
|
+
return _sessionStateCache;
|
|
336
|
+
}
|
|
337
|
+
if (typeof require === 'function') {
|
|
338
|
+
try {
|
|
339
|
+
_sessionStateCache = require('./sessionState');
|
|
340
|
+
return _sessionStateCache;
|
|
341
|
+
} catch (_error) {
|
|
342
|
+
_sessionStateCache = false;
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
_sessionStateCache = false;
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
261
350
|
function buildTurnRecord(input) {
|
|
262
351
|
var now = new Date().toISOString();
|
|
263
352
|
return {
|
|
@@ -556,6 +645,7 @@
|
|
|
556
645
|
speedTier: typeof run.speedTier === 'string' ? redactSecretLikeText(run.speedTier) : '',
|
|
557
646
|
status: normalizeRunStatus(run.status),
|
|
558
647
|
statusText: normalizeDisplayTextForStorage(run.statusText, SESSION_STORAGE_LIMITS.statusTextChars),
|
|
648
|
+
runProjectId: normalizeProjectPrefKey(run.runProjectId),
|
|
559
649
|
startedAt: typeof run.startedAt === 'string' ? redactSecretLikeText(run.startedAt) : '',
|
|
560
650
|
finishedAt: typeof run.finishedAt === 'string' ? redactSecretLikeText(run.finishedAt) : '',
|
|
561
651
|
events: compactRunEventsForStorage(run.events),
|
|
@@ -755,7 +845,19 @@
|
|
|
755
845
|
}
|
|
756
846
|
|
|
757
847
|
function normalizeRunStatus(status) {
|
|
758
|
-
|
|
848
|
+
// Welcome-panel + write-guard: the run-status
|
|
849
|
+
// enum gained three post-navigation values. The storage normalizer must
|
|
850
|
+
// accept them so a settled run round-trips intact through `buildSessionRecord`.
|
|
851
|
+
// Unknown legacy values fall through to `completed` (the historical default).
|
|
852
|
+
return [
|
|
853
|
+
'pending',
|
|
854
|
+
'running',
|
|
855
|
+
'completed',
|
|
856
|
+
'failed',
|
|
857
|
+
'background_completed',
|
|
858
|
+
'needs_review_after_navigation',
|
|
859
|
+
'abandoned_after_navigation'
|
|
860
|
+
].indexOf(status) !== -1 ? status : 'completed';
|
|
759
861
|
}
|
|
760
862
|
|
|
761
863
|
function normalizeEventStatus(status) {
|
|
@@ -963,6 +1065,113 @@
|
|
|
963
1065
|
return result;
|
|
964
1066
|
}
|
|
965
1067
|
|
|
1068
|
+
// Welcome-panel + write-guard: the Recent-projects
|
|
1069
|
+
// dashboard variant calls `listRecentProjectsAcrossAccount` to get the
|
|
1070
|
+
// sorted, deduped, capped list of projects in the current account scope.
|
|
1071
|
+
//
|
|
1072
|
+
// Contract (spec §5.6.1):
|
|
1073
|
+
// Input: { accountScopeId: string, limit?: number = 10 }
|
|
1074
|
+
// Output: Array<{ projectId, lastActivityAt, safeTaskSummary, primaryStatusBadge }>
|
|
1075
|
+
//
|
|
1076
|
+
// Fail-closed: if `accountScopeId` is falsy, returns `[]`. This is the
|
|
1077
|
+
// privacy floor — degraded mode must never leak across accounts.
|
|
1078
|
+
//
|
|
1079
|
+
// Implementation: full scan of the sessions store, group by `projectId`,
|
|
1080
|
+
// keep the row with the largest `lastActivityAt`, sort desc, cap at
|
|
1081
|
+
// `limit`. Full scan is acceptable for v1 given the small session count
|
|
1082
|
+
// (max ~12 per project × small number of projects). A future index on
|
|
1083
|
+
// `accountScopeId + lastActivityAt` is possible without a contract change.
|
|
1084
|
+
function listRecentProjectsAcrossAccount(options) {
|
|
1085
|
+
var accountScopeId = options && options.accountScopeId;
|
|
1086
|
+
var limit = options && Number.isFinite(options.limit) ? options.limit : 10;
|
|
1087
|
+
if (!accountScopeId) {
|
|
1088
|
+
return Promise.resolve([]);
|
|
1089
|
+
}
|
|
1090
|
+
return getAllSessions().then(function (all) {
|
|
1091
|
+
return filterRecentProjectsAcrossAccount(all, { accountScopeId: accountScopeId, limit: limit });
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Pure helper extracted from `listRecentProjectsAcrossAccount` so the
|
|
1096
|
+
// filtering / dedupe / sort / cap behavior can be tested without an
|
|
1097
|
+
// IndexedDB stub. Same contract as the async wrapper; takes the raw session
|
|
1098
|
+
// records as an array instead of opening the database.
|
|
1099
|
+
function filterRecentProjectsAcrossAccount(sessions, options) {
|
|
1100
|
+
var accountScopeId = options && options.accountScopeId;
|
|
1101
|
+
var limit = options && Number.isFinite(options.limit) ? options.limit : 10;
|
|
1102
|
+
if (!accountScopeId) {
|
|
1103
|
+
return [];
|
|
1104
|
+
}
|
|
1105
|
+
var byProject = {}; // projectId → session
|
|
1106
|
+
var all = Array.isArray(sessions) ? sessions : [];
|
|
1107
|
+
for (var i = 0; i < all.length; i++) {
|
|
1108
|
+
var s = all[i];
|
|
1109
|
+
if (!s) continue;
|
|
1110
|
+
if (s.accountScopeId !== accountScopeId) continue;
|
|
1111
|
+
if (typeof s.lastActivityAt !== 'string' || !s.lastActivityAt) continue;
|
|
1112
|
+
if (typeof s.projectId !== 'string' || !s.projectId) continue;
|
|
1113
|
+
var prev = byProject[s.projectId];
|
|
1114
|
+
if (!prev || prev.lastActivityAt < s.lastActivityAt) {
|
|
1115
|
+
byProject[s.projectId] = s;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
var survivors = [];
|
|
1119
|
+
var keys = Object.keys(byProject);
|
|
1120
|
+
for (var j = 0; j < keys.length; j++) {
|
|
1121
|
+
survivors.push(byProject[keys[j]]);
|
|
1122
|
+
}
|
|
1123
|
+
survivors.sort(function (a, b) {
|
|
1124
|
+
return b.lastActivityAt.localeCompare(a.lastActivityAt);
|
|
1125
|
+
});
|
|
1126
|
+
var rows = [];
|
|
1127
|
+
for (var k = 0; k < survivors.length && k < limit; k++) {
|
|
1128
|
+
var session = survivors[k];
|
|
1129
|
+
rows.push({
|
|
1130
|
+
projectId: session.projectId,
|
|
1131
|
+
lastActivityAt: session.lastActivityAt,
|
|
1132
|
+
safeTaskSummary: typeof session.safeTaskSummary === 'string' ? session.safeTaskSummary : '',
|
|
1133
|
+
primaryStatusBadge: derivePrimaryStatusBadge(session)
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
return rows;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Per spec §5.10:
|
|
1140
|
+
// 1. trackedChangeStatus if set (pending/accepted/rejected/needs_review)
|
|
1141
|
+
// 2. else run status (running/completed/failed/background_completed/
|
|
1142
|
+
// needs_review_after_navigation/abandoned_after_navigation)
|
|
1143
|
+
// 3. fallback `pending`.
|
|
1144
|
+
function derivePrimaryStatusBadge(session) {
|
|
1145
|
+
var runs = session && Array.isArray(session.runs) ? session.runs : [];
|
|
1146
|
+
if (!runs.length) return 'pending';
|
|
1147
|
+
var latestRun = runs[runs.length - 1];
|
|
1148
|
+
if (!latestRun) return 'pending';
|
|
1149
|
+
if (typeof latestRun.trackedChangeStatus === 'string' && latestRun.trackedChangeStatus) {
|
|
1150
|
+
return latestRun.trackedChangeStatus;
|
|
1151
|
+
}
|
|
1152
|
+
if (typeof latestRun.status === 'string' && latestRun.status) {
|
|
1153
|
+
return latestRun.status;
|
|
1154
|
+
}
|
|
1155
|
+
return 'pending';
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Full scan of the sessions store, used by the cross-project query above.
|
|
1159
|
+
// We deliberately do not go through `getAllByIndex` because the existing
|
|
1160
|
+
// session indexes are keyed by `projectId` / `updatedAt` — neither matches
|
|
1161
|
+
// the account-scope filter. A direct `getAll` over the whole store is the
|
|
1162
|
+
// simplest correct read.
|
|
1163
|
+
function getAllSessions() {
|
|
1164
|
+
return openDb().then(function (db) {
|
|
1165
|
+
return new Promise(function (resolve, reject) {
|
|
1166
|
+
var tx = db.transaction('sessions', 'readonly');
|
|
1167
|
+
var store = tx.objectStore('sessions');
|
|
1168
|
+
var request = store.getAll();
|
|
1169
|
+
request.onsuccess = function (event) { resolve(event.target.result || []); };
|
|
1170
|
+
request.onerror = function (event) { reject(event.target.error); };
|
|
1171
|
+
});
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
|
|
966
1175
|
function buildActiveSessionByProject(existing, projectId, sessionId) {
|
|
967
1176
|
var result = {};
|
|
968
1177
|
if (existing && typeof existing === 'object') {
|
|
@@ -1068,6 +1277,10 @@
|
|
|
1068
1277
|
buildAuditLogRecord: buildAuditLogRecord,
|
|
1069
1278
|
extractLightweightPrefs: extractLightweightPrefs,
|
|
1070
1279
|
buildActiveSessionByProject: buildActiveSessionByProject,
|
|
1071
|
-
createEventBuffer: createEventBuffer
|
|
1280
|
+
createEventBuffer: createEventBuffer,
|
|
1281
|
+
listRecentProjectsAcrossAccount: listRecentProjectsAcrossAccount,
|
|
1282
|
+
filterRecentProjectsAcrossAccount: filterRecentProjectsAcrossAccount,
|
|
1283
|
+
derivePrimaryStatusBadge: derivePrimaryStatusBadge,
|
|
1284
|
+
getAllSessions: getAllSessions
|
|
1072
1285
|
};
|
|
1073
1286
|
});
|
|
@@ -193,11 +193,15 @@ function formatWriteExpectation({ mode = 'auto', task = '', skillInvocation = nu
|
|
|
193
193
|
return '- This is read-only. Inspect and explain; do not edit files.';
|
|
194
194
|
}
|
|
195
195
|
if (requestImpliesFileChanges(task)) {
|
|
196
|
-
|
|
196
|
+
const lines = [
|
|
197
197
|
'- The request asks for file changes. You must edit the local workspace when you find concrete fixes.',
|
|
198
198
|
'- Do not stop at a suggestion list or say you will not modify files unless no safe concrete edit exists.',
|
|
199
199
|
'- If you intentionally leave files unchanged, explain the specific blocker in the final answer.'
|
|
200
|
-
]
|
|
200
|
+
];
|
|
201
|
+
if (requestTargetsAbstract(task)) {
|
|
202
|
+
lines.push('- The request targets the abstract/summary. Locate the LaTeX abstract environment or summary paragraph and edit that local project file directly.');
|
|
203
|
+
}
|
|
204
|
+
return lines.join('\n');
|
|
201
205
|
}
|
|
202
206
|
return [
|
|
203
207
|
'- This mode can write. If the request asks for corrections, revisions, fixes, polishing, updates, or implementation, edit the local workspace directly.',
|
|
@@ -209,6 +213,10 @@ function requestImpliesFileChanges(task = '') {
|
|
|
209
213
|
return /修正|修复|修改|改[一-龥]*|完善|补全|补充|润色|重写|改写|整理|调整|应用|写入|fix|correct|repair|revise|edit|update|rewrite|polish|improve|apply/i.test(String(task || ''));
|
|
210
214
|
}
|
|
211
215
|
|
|
216
|
+
function requestTargetsAbstract(task = '') {
|
|
217
|
+
return /摘要|abstract|summary/i.test(String(task || ''));
|
|
218
|
+
}
|
|
219
|
+
|
|
212
220
|
function normalizeFocusFiles(value) {
|
|
213
221
|
const seen = new Set();
|
|
214
222
|
const files = [];
|
|
@@ -677,9 +677,23 @@ function runCodexAppServerSession(input) {
|
|
|
677
677
|
const assistantMessages = new Map();
|
|
678
678
|
const assistantMessageOrder = [];
|
|
679
679
|
let settled = false;
|
|
680
|
+
// Two-layer timeout strategy:
|
|
681
|
+
// 1. Optional absolute deadline (CODEX_OVERLEAF_CODEX_TIMEOUT_MS) —
|
|
682
|
+
// legacy override; off by default. When set, the whole run must
|
|
683
|
+
// finish within that envelope.
|
|
684
|
+
// 2. Idle watchdog — fires after a stretch of silence from the
|
|
685
|
+
// app-server (no stdout / no messages). Default 10 minutes; the
|
|
686
|
+
// runtime resets it on every incoming line and on every outgoing
|
|
687
|
+
// request. This catches the failure mode where Codex sends
|
|
688
|
+
// turn/started, then hangs without ever emitting completed/error
|
|
689
|
+
// and the project lock would otherwise be held forever.
|
|
680
690
|
const timeout = createOptionalTimeout(childEnv.CODEX_OVERLEAF_CODEX_TIMEOUT_MS, timeoutMs => {
|
|
681
691
|
fail(new Error(`Codex app-server did not complete within configured timeout (${timeoutMs}ms)`));
|
|
682
692
|
});
|
|
693
|
+
const idleTimeoutMs = parseOptionalPositiveInteger(childEnv.CODEX_OVERLEAF_CODEX_IDLE_TIMEOUT_MS) || 600000;
|
|
694
|
+
const idleWatchdog = createCodexIdleWatchdog(idleTimeoutMs, ms => {
|
|
695
|
+
fail(new Error(`Codex app-server produced no events for ${ms}ms (idle watchdog); the run was aborted to release the project lock.`));
|
|
696
|
+
});
|
|
683
697
|
const onAbort = () => {
|
|
684
698
|
fail(getAbortReason(input.signal));
|
|
685
699
|
};
|
|
@@ -688,6 +702,7 @@ function runCodexAppServerSession(input) {
|
|
|
688
702
|
child.stdout.setEncoding('utf8');
|
|
689
703
|
child.stderr.setEncoding('utf8');
|
|
690
704
|
child.stdout.on('data', chunk => {
|
|
705
|
+
idleWatchdog.reset();
|
|
691
706
|
stdoutBuffer += chunk;
|
|
692
707
|
const lines = stdoutBuffer.split(/\r?\n/);
|
|
693
708
|
stdoutBuffer = lines.pop() || '';
|
|
@@ -821,13 +836,22 @@ function runCodexAppServerSession(input) {
|
|
|
821
836
|
|
|
822
837
|
if (message.method) {
|
|
823
838
|
recordAssistantMessage(message);
|
|
824
|
-
|
|
839
|
+
// For `error` events surface the actual error text as the visible
|
|
840
|
+
// title so the run timeline reads "Reconnecting... 2/5" instead of
|
|
841
|
+
// a generic "error". Other methods continue to use the method name.
|
|
842
|
+
const eventTitle = message.method === 'error'
|
|
843
|
+
? (extractCodexAppServerErrorMessage(message.params) || message.method)
|
|
844
|
+
: message.method;
|
|
845
|
+
emitCodexEvent(input.emit, 'codex.session.event', eventTitle, {
|
|
825
846
|
method: message.method,
|
|
826
847
|
params: message.params || {}
|
|
827
848
|
}, inferNotificationStatus(message));
|
|
828
849
|
if (message.method === 'turn/completed' && (!activeTurnId || message.params?.turn?.id === activeTurnId || message.params?.turnId === activeTurnId)) {
|
|
829
850
|
succeed();
|
|
830
851
|
}
|
|
852
|
+
if (message.method === 'error' && isTransientCodexAppServerError(message.params)) {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
831
855
|
if (message.method === 'error') {
|
|
832
856
|
fail(new Error(message.params?.error?.message || 'Codex turn failed'));
|
|
833
857
|
}
|
|
@@ -915,6 +939,7 @@ function runCodexAppServerSession(input) {
|
|
|
915
939
|
|
|
916
940
|
function cleanup() {
|
|
917
941
|
timeout.cancel();
|
|
942
|
+
idleWatchdog.cancel();
|
|
918
943
|
input.signal?.removeEventListener('abort', onAbort);
|
|
919
944
|
}
|
|
920
945
|
});
|
|
@@ -1132,6 +1157,30 @@ function createOptionalTimeout(value, onTimeout) {
|
|
|
1132
1157
|
};
|
|
1133
1158
|
}
|
|
1134
1159
|
|
|
1160
|
+
// Idle-style watchdog: fires only after the app-server has been silent for
|
|
1161
|
+
// `idleMs`. Callers must invoke .reset() on every signal of liveness
|
|
1162
|
+
// (incoming line, outgoing request). cancel() stops the timer on settle.
|
|
1163
|
+
// Returns no-op when idleMs is non-positive (defensive guard against bad env
|
|
1164
|
+
// var values).
|
|
1165
|
+
function createCodexIdleWatchdog(idleMs, onIdle) {
|
|
1166
|
+
if (!(idleMs > 0)) {
|
|
1167
|
+
return {
|
|
1168
|
+
reset() {},
|
|
1169
|
+
cancel() {}
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
let timer = setTimeout(() => onIdle(idleMs), idleMs);
|
|
1173
|
+
return {
|
|
1174
|
+
reset() {
|
|
1175
|
+
clearTimeout(timer);
|
|
1176
|
+
timer = setTimeout(() => onIdle(idleMs), idleMs);
|
|
1177
|
+
},
|
|
1178
|
+
cancel() {
|
|
1179
|
+
clearTimeout(timer);
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1135
1184
|
function parseOptionalPositiveInteger(value) {
|
|
1136
1185
|
if (value === undefined || value === null || value === '') {
|
|
1137
1186
|
return 0;
|
|
@@ -1211,12 +1260,35 @@ function emitCodexEvent(emit, type, title, detail = {}, status = 'running') {
|
|
|
1211
1260
|
}
|
|
1212
1261
|
|
|
1213
1262
|
function inferNotificationStatus(message) {
|
|
1263
|
+
if (message.method === 'error' && isTransientCodexAppServerError(message.params)) {
|
|
1264
|
+
return 'warning';
|
|
1265
|
+
}
|
|
1214
1266
|
if (/completed|updated|delta|started/.test(message.method || '')) {
|
|
1215
1267
|
return /completed/.test(message.method || '') ? 'completed' : 'running';
|
|
1216
1268
|
}
|
|
1217
1269
|
return 'running';
|
|
1218
1270
|
}
|
|
1219
1271
|
|
|
1272
|
+
function isTransientCodexAppServerError(params = {}) {
|
|
1273
|
+
const message = extractCodexAppServerErrorMessage(params);
|
|
1274
|
+
return /^Reconnecting(?:\.\.\.|…)?\s+\d+\s*\/\s*\d+\.?$/i.test(message);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function extractCodexAppServerErrorMessage(params = {}) {
|
|
1278
|
+
const candidates = [
|
|
1279
|
+
params?.error?.message,
|
|
1280
|
+
params?.message,
|
|
1281
|
+
params?.title,
|
|
1282
|
+
params?.error
|
|
1283
|
+
];
|
|
1284
|
+
for (const candidate of candidates) {
|
|
1285
|
+
if (typeof candidate === 'string' && candidate.trim()) {
|
|
1286
|
+
return candidate.trim();
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return '';
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1220
1292
|
function normalizeReasoningEffort(value) {
|
|
1221
1293
|
return ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(value) ? value : null;
|
|
1222
1294
|
}
|
|
@@ -25,6 +25,11 @@ const { version: PACKAGE_VERSION } = require('../../package.json');
|
|
|
25
25
|
|
|
26
26
|
const activeProjectLocks = new Map();
|
|
27
27
|
const activeRunControllers = new Map();
|
|
28
|
+
// Parallel index of active runs by projectKey so codex.cancel can find a
|
|
29
|
+
// controller even when the original request id is unknown (e.g. after the
|
|
30
|
+
// Overleaf tab was reloaded — the requestId lived in content-side JS state
|
|
31
|
+
// and is gone, but the native-host-side controller is still running).
|
|
32
|
+
const activeRunByProject = new Map();
|
|
28
33
|
const pendingPlans = new Map();
|
|
29
34
|
const PENDING_PLAN_TTL_MS = 30 * 60 * 1000;
|
|
30
35
|
const CODEX_RUN_PASSTHROUGH_ERROR_CODES = new Set(['thread_resume_failed']);
|
|
@@ -138,6 +143,7 @@ async function handleCodexRun(request, env, emit) {
|
|
|
138
143
|
const abortController = new AbortController();
|
|
139
144
|
if (request.id) {
|
|
140
145
|
activeRunControllers.set(request.id, abortController);
|
|
146
|
+
activeRunByProject.set(projectKey, { id: request.id, controller: abortController });
|
|
141
147
|
}
|
|
142
148
|
try {
|
|
143
149
|
if (params.useExistingMirror) {
|
|
@@ -213,24 +219,63 @@ async function handleCodexRun(request, env, emit) {
|
|
|
213
219
|
if (request.id && activeRunControllers.get(request.id) === abortController) {
|
|
214
220
|
activeRunControllers.delete(request.id);
|
|
215
221
|
}
|
|
222
|
+
if (activeRunByProject.get(projectKey)?.controller === abortController) {
|
|
223
|
+
activeRunByProject.delete(projectKey);
|
|
224
|
+
}
|
|
216
225
|
releaseProjectLock(projectKey, lockToken);
|
|
217
226
|
}
|
|
218
227
|
}
|
|
219
228
|
|
|
229
|
+
// Cancel paths, in priority order:
|
|
230
|
+
// 1. By requestId (legacy + primary, when the caller still has it)
|
|
231
|
+
// 2. By projectKey (after page refresh — requestId is lost but projectKey
|
|
232
|
+
// is derivable from the Overleaf URL)
|
|
233
|
+
// 3. Force-release the project lock when no controller is registered for
|
|
234
|
+
// the given projectKey. Covers the zombie-lock case where a previous
|
|
235
|
+
// run leaked the lock (unhandled error path, process bug, etc.) and
|
|
236
|
+
// the user otherwise has no way to recover short of restarting Chrome.
|
|
237
|
+
// Only fires when `force: true` is explicitly set so accidental calls
|
|
238
|
+
// can't punch through a real live run.
|
|
220
239
|
function handleCodexCancel(request) {
|
|
221
|
-
const
|
|
222
|
-
|
|
240
|
+
const params = request.params || {};
|
|
241
|
+
const targetId = params.requestId || params.id;
|
|
242
|
+
const projectKey = typeof params.projectKey === 'string' ? params.projectKey : '';
|
|
243
|
+
const force = params.force === true;
|
|
244
|
+
|
|
245
|
+
if (targetId && activeRunControllers.has(targetId)) {
|
|
246
|
+
activeRunControllers.get(targetId).abort(createCancellationError());
|
|
223
247
|
return okResponse(request.id, {
|
|
224
|
-
cancelled:
|
|
225
|
-
|
|
248
|
+
cancelled: true,
|
|
249
|
+
requestId: targetId
|
|
226
250
|
});
|
|
227
251
|
}
|
|
228
252
|
|
|
229
|
-
|
|
230
|
-
|
|
253
|
+
if (projectKey) {
|
|
254
|
+
const entry = activeRunByProject.get(projectKey);
|
|
255
|
+
if (entry?.controller) {
|
|
256
|
+
entry.controller.abort(createCancellationError());
|
|
257
|
+
return okResponse(request.id, {
|
|
258
|
+
cancelled: true,
|
|
259
|
+
projectKey,
|
|
260
|
+
requestId: entry.id || ''
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
if (force && activeProjectLocks.has(projectKey)) {
|
|
264
|
+
activeProjectLocks.delete(projectKey);
|
|
265
|
+
activeRunByProject.delete(projectKey);
|
|
266
|
+
logDebug('codex.cancel.force_released_zombie_lock', { projectKey });
|
|
267
|
+
return okResponse(request.id, {
|
|
268
|
+
cancelled: false,
|
|
269
|
+
lockReleased: true,
|
|
270
|
+
projectKey,
|
|
271
|
+
reason: 'No active controller; force-released the project lock entry'
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
231
276
|
return okResponse(request.id, {
|
|
232
|
-
cancelled:
|
|
233
|
-
|
|
277
|
+
cancelled: false,
|
|
278
|
+
reason: 'No active Codex run matched the cancellation request'
|
|
234
279
|
});
|
|
235
280
|
}
|
|
236
281
|
|