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.
@@ -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: typeof input.updatedAt === 'string' ? input.updatedAt : now
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
- return ['running', 'completed', 'failed'].indexOf(status) !== -1 ? status : 'completed';
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
- return [
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
- ].join('\n');
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
- emitCodexEvent(input.emit, 'codex.session.event', message.method, {
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 targetId = request.params?.requestId || request.params?.id;
222
- if (!targetId || !activeRunControllers.has(targetId)) {
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: false,
225
- reason: 'No active Codex run matched the cancellation request'
248
+ cancelled: true,
249
+ requestId: targetId
226
250
  });
227
251
  }
228
252
 
229
- const controller = activeRunControllers.get(targetId);
230
- controller.abort(createCancellationError());
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: true,
233
- requestId: targetId
277
+ cancelled: false,
278
+ reason: 'No active Codex run matched the cancellation request'
234
279
  });
235
280
  }
236
281
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-overleaf-link",
3
- "version": "1.3.7",
3
+ "version": "1.3.9",
4
4
  "description": "Cross-platform Chrome bridge that connects Codex to the active Overleaf project.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",