codex-overleaf-link 2.1.5 → 2.2.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 (67) hide show
  1. package/README.md +27 -23
  2. package/extension/bootstrap/background.js +14 -1
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/runtime-manifest.json +10 -0
  5. package/extension/src/background.js +507 -8
  6. package/extension/src/backgroundUpdateCoordinator.js +284 -19
  7. package/extension/src/content/activeTurnControl.js +179 -0
  8. package/extension/src/content/composerPanel.js +14 -5
  9. package/extension/src/content/contentRuntime.js +330 -309
  10. package/extension/src/content/diagnosticsPanel.js +132 -1
  11. package/extension/src/content/markdownDomRenderer.js +451 -0
  12. package/extension/src/content/markdownText.js +86 -7
  13. package/extension/src/content/mathText.js +49 -4
  14. package/extension/src/content/modelPicker.js +14 -5
  15. package/extension/src/content/nativeChannel.js +7 -1
  16. package/extension/src/content/panelRenderer.js +112 -12
  17. package/extension/src/content/pendingInputView.js +106 -0
  18. package/extension/src/content/projectProviderSelection.js +68 -0
  19. package/extension/src/content/projectSessionCleanup.js +4 -0
  20. package/extension/src/content/providerSettingsCoordinator.js +170 -22
  21. package/extension/src/content/providerSettingsDialog.js +2 -2
  22. package/extension/src/content/recentProjects.js +7 -2
  23. package/extension/src/content/runGuidanceController.js +70 -0
  24. package/extension/src/content/runGuidanceView.js +48 -0
  25. package/extension/src/content/runQueueScheduler.js +260 -0
  26. package/extension/src/content/runTimelineView.js +33 -80
  27. package/extension/src/content/sessionManager.js +2 -2
  28. package/extension/src/content/sessionPersistence.js +62 -0
  29. package/extension/src/content/settingsPanel.js +1 -1
  30. package/extension/src/content/updateNotice.js +80 -1
  31. package/extension/src/page/writebackRouter.js +3 -9
  32. package/extension/src/shared/compatibility.js +2 -1
  33. package/extension/src/shared/i18n.js +38 -0
  34. package/extension/src/shared/providerProfiles.js +3 -2
  35. package/extension/src/shared/runInputQueue.js +253 -0
  36. package/extension/src/shared/sessionState.js +45 -22
  37. package/extension/src/shared/storageDb.js +39 -44
  38. package/extension/src/shared/storageMigration.js +123 -3
  39. package/extension/src/shared/storageRunActions.js +34 -1
  40. package/extension/src/shared/updateConsent.js +15 -4
  41. package/extension/styles/panel.css +319 -20
  42. package/extension/vendor/katex/katex.min.css +1 -1
  43. package/extension/vendor/markdown-it/LICENSE +22 -0
  44. package/extension/vendor/markdown-it/SOURCE.json +7 -0
  45. package/extension/vendor/markdown-it/VERSION +1 -0
  46. package/extension/vendor/markdown-it/markdown-it.min.js +3 -0
  47. package/native-host/src/anthropicBridgeRequest.js +3 -3
  48. package/native-host/src/anthropicBridgeResponse.js +58 -17
  49. package/native-host/src/anthropicMessagesBridge.js +156 -23
  50. package/native-host/src/chatBridgeRequest.js +1 -1
  51. package/native-host/src/chatBridgeResponse.js +12 -8
  52. package/native-host/src/chatCompletionsBridge.js +22 -40
  53. package/native-host/src/codexPromptAssembly.js +3 -1
  54. package/native-host/src/codexProviderLaunch.js +6 -3
  55. package/native-host/src/codexSessionRunner.js +65 -65
  56. package/native-host/src/codexSessionTiming.js +45 -0
  57. package/native-host/src/index.js +58 -3
  58. package/native-host/src/managedInstall.js +45 -7
  59. package/native-host/src/mirrorWorkspace.js +15 -8
  60. package/native-host/src/providerProfile.js +2 -2
  61. package/native-host/src/providerStreamLifecycle.js +100 -0
  62. package/native-host/src/responsesPassthroughBridge.js +636 -0
  63. package/native-host/src/taskRunnerRuntime.js +81 -2
  64. package/native-host/src/updateArchive.js +4 -3
  65. package/native-host/src/updateManager.js +306 -19
  66. package/native-host/src/updateTrust.js +1 -1
  67. package/package.json +3 -1
@@ -3,13 +3,29 @@
3
3
 
4
4
  const UPDATE_STATE_KEY = 'codex-overleaf-managed-update-state-v1';
5
5
  const CONSENT_STATE_KEY = 'codex-overleaf-update-consent-v1';
6
+ const UPDATE_RELOAD_TABS_KEY = 'codex-overleaf-managed-update-tabs-v1';
6
7
  const CHECK_ALARM = 'codex-overleaf-consent-update-check';
8
+ const IDLE_ALARM = 'codex-overleaf-consent-update-idle';
9
+ const WATCHDOG_ALARM = 'codex-overleaf-consent-update-watchdog';
7
10
  const STARTUP_CHECK_SESSION_KEY = 'codex-overleaf-startup-update-check-v1';
8
11
  const STARTUP_CHECK_CLAIMED_FLAG = '__codexOverleafStartupUpdateCheckClaimed';
9
12
  const CHECK_INTERVAL_MINUTES = 24 * 60;
10
13
  const SNOOZE_MS = 24 * 60 * 60 * 1000;
11
14
  const CANDIDATE_MAX_AGE_MS = 5 * 60 * 1000;
12
15
  const CHECK_REQUEST_TIMEOUT_MS = 15 * 1000;
16
+ const ACTIVATION_TIMEOUT_MS = 20 * 1000;
17
+ const ACTIVATION_POLL_MS = 250;
18
+ const FAST_IDLE_RETRY_MS = 4000;
19
+ const SLOW_IDLE_RETRY_MS = 15000;
20
+ const FAST_IDLE_RETRY_BLOCKERS = new Set(['recent_user_activity', 'save_state_not_stable']);
21
+ const WATCHDOG_INTERVAL_MINUTES = 1;
22
+ const PHASE_TIMEOUT_MS = Object.freeze({
23
+ checking: 30 * 1000,
24
+ downloading: 2 * 60 * 1000,
25
+ applying: 90 * 1000,
26
+ awaiting_health: 2 * 60 * 1000,
27
+ rolling_back: 60 * 1000
28
+ });
13
29
  const LEGACY_GUARD = Number.MAX_SAFE_INTEGER;
14
30
  const OVERLEAF_MATCHES = [
15
31
  'https://www.overleaf.com/project',
@@ -29,6 +45,8 @@
29
45
  let nativeBridge = null;
30
46
  let initialized = false;
31
47
  let policyTail = Promise.resolve();
48
+ let idleRetryTimer = null;
49
+ let activationPromise = null;
32
50
 
33
51
  function init(options = {}) {
34
52
  if (initialized || !policy) return;
@@ -51,6 +69,12 @@
51
69
  if (alarm?.name === CHECK_ALARM) {
52
70
  void enqueuePolicyAction(() => checkOnly({ manual: false })).catch(() => {});
53
71
  }
72
+ if (alarm?.name === WATCHDOG_ALARM) {
73
+ void enqueuePolicyAction(() => reconcileExpiredPhase()).catch(() => {});
74
+ }
75
+ if (alarm?.name === IDLE_ALARM) {
76
+ void enqueuePolicyAction(() => tryActivateStagedUpdate()).catch(() => {});
77
+ }
54
78
  });
55
79
 
56
80
  chrome.storage?.onChanged?.addListener((changes, area) => {
@@ -66,10 +90,19 @@
66
90
  delayInMinutes: 0.5,
67
91
  periodInMinutes: CHECK_INTERVAL_MINUTES
68
92
  });
93
+ chrome.alarms?.create?.(WATCHDOG_ALARM, {
94
+ delayInMinutes: WATCHDOG_INTERVAL_MINUTES,
95
+ periodInMinutes: WATCHDOG_INTERVAL_MINUTES
96
+ });
69
97
  await recoverInterruptedCheck();
98
+ await reconcileExpiredPhase();
70
99
  await settleTerminalConsent();
71
100
  await armLegacyGuard();
72
101
  await publishView();
102
+ const updateState = await getUpdateState();
103
+ if (['staged', 'waiting_for_idle'].includes(updateState.state)) {
104
+ scheduleIdleRetry(updateState.blockers);
105
+ }
73
106
  if (await claimStartupCheck()) {
74
107
  void enqueuePolicyAction(() => checkOnly({ manual: false })).catch(() => {});
75
108
  }
@@ -115,6 +148,7 @@
115
148
  await setUpdateState({
116
149
  ...currentState,
117
150
  state: 'checking',
151
+ initiatedBy: manual ? 'manual' : 'automatic',
118
152
  code: '',
119
153
  message: '',
120
154
  blocker: '',
@@ -181,6 +215,7 @@
181
215
  await setUpdateState({
182
216
  ...currentState,
183
217
  state: 'failed',
218
+ initiatedBy: manual ? 'manual' : 'automatic',
184
219
  postponeUntil: LEGACY_GUARD,
185
220
  code: safeCode(error),
186
221
  message: safeMessage(error)
@@ -239,25 +274,27 @@
239
274
  }
240
275
 
241
276
  const authorizationId = crypto.randomUUID();
242
- await requestNative('update.authorize', {
243
- authorizationId,
244
- targetVersion: state.latestVersion,
245
- currentVersion: currentVersion()
246
- });
247
- await setConsentState({
248
- ...consent,
249
- snoozedVersion: '',
250
- snoozedUntil: 0,
251
- authorizedVersion: state.latestVersion,
252
- authorizationId,
253
- authorizedAt: Date.now()
254
- });
255
- await setUpdateState({ ...state, postponeUntil: 0, code: '', message: '' });
256
-
257
277
  try {
258
- const executor = root.CodexOverleafManagedUpdateExecutor;
259
- if (typeof executor?.installAuthorizedUpdate !== 'function') {
260
- throw codedError('update_executor_unavailable', 'The managed update executor is unavailable.');
278
+ await requestNative('update.authorize', {
279
+ authorizationId,
280
+ targetVersion: state.latestVersion,
281
+ currentVersion: currentVersion()
282
+ });
283
+ await setConsentState({
284
+ ...consent,
285
+ snoozedVersion: '',
286
+ snoozedUntil: 0,
287
+ authorizedVersion: state.latestVersion,
288
+ authorizationId,
289
+ authorizedAt: Date.now()
290
+ });
291
+ await setUpdateState({ ...state, postponeUntil: 0, code: '', message: '' });
292
+ const executor = globalThis.CodexOverleafManagedUpdateExecutor;
293
+ if (!executor || typeof executor.installAuthorizedUpdate !== 'function') {
294
+ throw codedError(
295
+ 'update_executor_unavailable',
296
+ 'The managed update executor is unavailable.'
297
+ );
261
298
  }
262
299
  await executor.installAuthorizedUpdate();
263
300
  return getView();
@@ -273,11 +310,177 @@
273
310
  authorizationId: '',
274
311
  authorizedAt: 0
275
312
  });
313
+ const observed = await getUpdateState().catch(() => state);
314
+ if (!['awaiting_health', 'committed', 'rolled_back'].includes(observed.state)) {
315
+ await setUpdateState({
316
+ ...observed,
317
+ state: 'failed',
318
+ initiatedBy: 'manual',
319
+ blocker: '',
320
+ blockers: [],
321
+ code: safeCode(error),
322
+ message: safeMessage(error)
323
+ });
324
+ }
276
325
  await armLegacyGuard();
277
326
  throw error;
278
327
  }
279
328
  }
280
329
 
330
+ async function stageAuthorizedUpdate() {
331
+ const current = await getUpdateState();
332
+ await setUpdateState({
333
+ ...current,
334
+ state: 'downloading',
335
+ initiatedBy: 'manual',
336
+ blocker: '',
337
+ blockers: [],
338
+ code: '',
339
+ message: ''
340
+ });
341
+ const staged = await requestNative('update.stage');
342
+ await setUpdateState({
343
+ ...current,
344
+ state: 'staged',
345
+ initiatedBy: 'manual',
346
+ latestVersion: staged.targetVersion,
347
+ transactionId: staged.transactionId,
348
+ stagedAt: Date.now(),
349
+ blocker: '',
350
+ blockers: [],
351
+ code: '',
352
+ message: ''
353
+ });
354
+ return tryActivateStagedUpdate();
355
+ }
356
+
357
+ function tryActivateStagedUpdate() {
358
+ const executor = root.CodexOverleafManagedUpdateExecutor;
359
+ if (typeof executor?.installAuthorizedUpdate === 'function') {
360
+ if (activationPromise) return activationPromise;
361
+ activationPromise = Promise.resolve(executor.installAuthorizedUpdate()).finally(() => {
362
+ activationPromise = null;
363
+ });
364
+ return activationPromise;
365
+ }
366
+ if (activationPromise) return activationPromise;
367
+ activationPromise = tryActivateStagedUpdateCore().finally(() => {
368
+ activationPromise = null;
369
+ });
370
+ return activationPromise;
371
+ }
372
+
373
+ async function tryActivateStagedUpdateCore() {
374
+ clearIdleRetryTimer();
375
+ const state = await getUpdateState();
376
+ if (!['staged', 'waiting_for_idle'].includes(state.state)) {
377
+ await chrome.alarms?.clear?.(IDLE_ALARM).catch(() => {});
378
+ return state;
379
+ }
380
+ const surfaceTabs = await chrome.tabs.query({ url: OVERLEAF_MATCHES }).catch(() => []);
381
+ const editorTabs = surfaceTabs.filter(isUsableEditorTab);
382
+ const reloadTabs = surfaceTabs
383
+ .filter(tab => Number.isInteger(tab?.id) && !tab.discarded && tab.status !== 'unloaded')
384
+ .map(tab => tab.id);
385
+ const probes = await Promise.all(editorTabs.map(tab => probeTabIdle(tab.id)));
386
+ const nativeGate = await requestNative('update.canApply')
387
+ .then(result => ({ ok: true, result }))
388
+ .catch(error => ({ ok: false, error: safeError(error) }));
389
+ const blockers = root.CodexOverleafUpdateStatus?.collectBlockers(probes, nativeGate) || ['busy'];
390
+ if (blockers.length) {
391
+ const waiting = await setUpdateState({
392
+ ...state,
393
+ state: 'waiting_for_idle',
394
+ blocker: blockers[0],
395
+ blockers
396
+ });
397
+ scheduleIdleRetry(blockers);
398
+ return waiting;
399
+ }
400
+
401
+ await chrome.alarms?.clear?.(IDLE_ALARM).catch(() => {});
402
+ await chrome.storage.local.set({ [UPDATE_RELOAD_TABS_KEY]: reloadTabs });
403
+ await setUpdateState({
404
+ ...state,
405
+ state: 'applying',
406
+ blocker: '',
407
+ blockers: [],
408
+ code: '',
409
+ message: ''
410
+ });
411
+ const activation = await requestNative('update.activate', {
412
+ transactionId: state.transactionId
413
+ });
414
+ const transaction = await waitForActivatedTransaction(activation.transactionId || state.transactionId);
415
+ if (transaction.state === 'rolled_back') {
416
+ throw codedError(transaction.reasonCode || 'update_apply_failed', 'The managed update was rolled back during activation.');
417
+ }
418
+ await setUpdateState({
419
+ ...state,
420
+ state: 'awaiting_health',
421
+ latestVersion: transaction.targetVersion,
422
+ transactionId: transaction.id,
423
+ code: '',
424
+ message: ''
425
+ });
426
+ chrome.runtime.reload();
427
+ return { state: 'awaiting_health' };
428
+ }
429
+
430
+ async function waitForActivatedTransaction(transactionId) {
431
+ const deadline = Date.now() + ACTIVATION_TIMEOUT_MS;
432
+ while (Date.now() <= deadline) {
433
+ const status = await requestNative('update.status').catch(() => null);
434
+ const transaction = status?.transaction;
435
+ if (transaction?.id === transactionId && ['awaiting_health', 'rolled_back'].includes(transaction.state)) {
436
+ return transaction;
437
+ }
438
+ await new Promise(resolve => setTimeout(resolve, ACTIVATION_POLL_MS));
439
+ }
440
+ throw codedError('update_activation_timeout', 'The managed update did not reach health verification after activation.');
441
+ }
442
+
443
+ function scheduleIdleRetry(blockers = []) {
444
+ clearIdleRetryTimer();
445
+ const values = Array.isArray(blockers) ? blockers.filter(Boolean) : [];
446
+ const fast = values.length > 0 && values.every(value => FAST_IDLE_RETRY_BLOCKERS.has(value));
447
+ idleRetryTimer = setTimeout(() => {
448
+ idleRetryTimer = null;
449
+ void enqueuePolicyAction(() => tryActivateStagedUpdate()).catch(() => {});
450
+ }, fast ? FAST_IDLE_RETRY_MS : SLOW_IDLE_RETRY_MS);
451
+ chrome.alarms?.create?.(IDLE_ALARM, { delayInMinutes: 0.5 });
452
+ }
453
+
454
+ function clearIdleRetryTimer() {
455
+ if (idleRetryTimer === null) return;
456
+ clearTimeout(idleRetryTimer);
457
+ idleRetryTimer = null;
458
+ }
459
+
460
+ function isUsableEditorTab(tab) {
461
+ if (!Number.isInteger(tab?.id) || tab.discarded || tab.status === 'unloaded') return false;
462
+ try {
463
+ const url = new URL(tab.url || '');
464
+ return url.protocol === 'https:' &&
465
+ (url.hostname === 'www.overleaf.com' || url.hostname === 'overleaf.com') &&
466
+ /^\/project\/[^/]+(?:\/|$)/.test(url.pathname);
467
+ } catch (_error) {
468
+ return false;
469
+ }
470
+ }
471
+
472
+ async function probeTabIdle(tabId) {
473
+ try {
474
+ return await withTimeout(
475
+ chrome.tabs.sendMessage(tabId, { type: 'codex-overleaf/update-idle-probe' }),
476
+ 3500,
477
+ codedError('tab_probe_timeout', 'An Overleaf tab did not answer the idle check in time.')
478
+ );
479
+ } catch (error) {
480
+ return { idle: false, blockers: [safeCode(error) === 'tab_probe_timeout' ? 'tab_probe_timeout' : 'tab_probe_unavailable'] };
481
+ }
482
+ }
483
+
281
484
  async function postponeUpdate() {
282
485
  const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
283
486
  if (['applying', 'awaiting_health'].includes(state.state)) {
@@ -301,6 +504,8 @@
301
504
  }
302
505
 
303
506
  const snoozedUntil = Date.now() + SNOOZE_MS;
507
+ clearIdleRetryTimer();
508
+ await chrome.alarms?.clear?.(IDLE_ALARM).catch(() => {});
304
509
  await setUpdateState({
305
510
  ...state,
306
511
  state: 'update_available',
@@ -334,6 +539,55 @@
334
539
  });
335
540
  }
336
541
 
542
+ async function reconcileExpiredPhase() {
543
+ const state = await getUpdateState();
544
+ if (!state.deadlineAt || Date.now() <= state.deadlineAt || policy.isTerminalState(state.state)) {
545
+ return state;
546
+ }
547
+ if (['applying', 'awaiting_health', 'rolling_back'].includes(state.state)) {
548
+ const status = await requestNative('update.status').catch(() => null);
549
+ const transaction = status?.transaction;
550
+ if (transaction?.state === 'awaiting_health') {
551
+ await requestNative('update.rollback', {
552
+ transactionId: transaction.id,
553
+ reasonCode: 'update_health_timeout'
554
+ }).catch(() => null);
555
+ return setUpdateState({
556
+ ...state,
557
+ state: 'failed',
558
+ currentVersion: transaction.sourceVersion || state.currentVersion,
559
+ code: 'update_health_timeout',
560
+ message: 'The updated runtime did not complete its health check and was rolled back. Retry, or use the manual update command.'
561
+ });
562
+ }
563
+ if (transaction?.state === 'committed') {
564
+ return setUpdateState({
565
+ ...state,
566
+ state: 'committed',
567
+ currentVersion: transaction.targetVersion,
568
+ latestVersion: transaction.targetVersion,
569
+ code: '',
570
+ message: ''
571
+ });
572
+ }
573
+ if (transaction?.state === 'rolled_back') {
574
+ return setUpdateState({
575
+ ...state,
576
+ state: 'rolled_back',
577
+ currentVersion: transaction.sourceVersion || state.currentVersion,
578
+ code: transaction.reasonCode || 'update_rolled_back',
579
+ message: 'The managed update was rolled back after activation did not complete.'
580
+ });
581
+ }
582
+ }
583
+ return setUpdateState({
584
+ ...state,
585
+ state: 'failed',
586
+ code: 'update_phase_timeout',
587
+ message: 'The managed update phase did not complete in time. Retry, or use the manual update command.'
588
+ });
589
+ }
590
+
337
591
  async function handleObservedStateChange() {
338
592
  await settleTerminalConsent();
339
593
  const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
@@ -388,7 +642,18 @@
388
642
  }
389
643
 
390
644
  async function setUpdateState(value) {
391
- const next = policy.normalizeUpdateState(value, currentVersion());
645
+ const stored = await chrome.storage.local.get(UPDATE_STATE_KEY);
646
+ const previous = policy.normalizeUpdateState(stored?.[UPDATE_STATE_KEY], currentVersion());
647
+ const now = Date.now();
648
+ const changedPhase = value.state && value.state !== previous.state;
649
+ const timeoutMs = PHASE_TIMEOUT_MS[value.state] || 0;
650
+ const next = policy.normalizeUpdateState({
651
+ ...value,
652
+ initiatedBy: value.initiatedBy || previous.initiatedBy,
653
+ phaseStartedAt: changedPhase ? now : (value.phaseStartedAt || previous.phaseStartedAt),
654
+ deadlineAt: changedPhase ? (timeoutMs ? now + timeoutMs : 0) : (value.deadlineAt || previous.deadlineAt),
655
+ heartbeatAt: now
656
+ }, currentVersion());
392
657
  await chrome.storage.local.set({ [UPDATE_STATE_KEY]: next });
393
658
  return next;
394
659
  }
@@ -0,0 +1,179 @@
1
+ (function initCodexOverleafActiveTurnControl(root, factory) {
2
+ if (typeof module === 'object' && module.exports) {
3
+ module.exports = factory();
4
+ } else {
5
+ root.CodexOverleafActiveTurnControl = factory();
6
+ }
7
+ })(typeof window !== 'undefined' ? window : globalThis, function activeTurnControlFactory() {
8
+ 'use strict';
9
+
10
+ function create(options = {}) {
11
+ const runtime = options.chrome?.runtime;
12
+ let port = null;
13
+ let binding = null;
14
+ let reconnectTimer = null;
15
+ let destroyed = false;
16
+
17
+ function connect() {
18
+ if (destroyed || port || typeof runtime?.connect !== 'function') {
19
+ return;
20
+ }
21
+ try {
22
+ const nextPort = runtime.connect({ name: 'codex-overleaf/run-owner' });
23
+ port = nextPort;
24
+ nextPort.onDisconnect?.addListener?.(() => {
25
+ if (port === nextPort) {
26
+ port = null;
27
+ }
28
+ if (!destroyed) {
29
+ reconnectTimer = setTimeout(connect, 50);
30
+ }
31
+ });
32
+ if (binding) {
33
+ postBinding('bind', binding);
34
+ }
35
+ } catch (_error) {
36
+ port = null;
37
+ }
38
+ }
39
+
40
+ function postBinding(type, value) {
41
+ try {
42
+ port?.postMessage?.({ type, ...value });
43
+ } catch (_error) {
44
+ port = null;
45
+ }
46
+ }
47
+
48
+ function bind(value = {}) {
49
+ binding = {
50
+ requestId: String(value.requestId || ''),
51
+ projectKey: String(value.projectKey || ''),
52
+ clientRunId: String(value.clientRunId || ''),
53
+ sessionId: String(value.sessionId || '')
54
+ };
55
+ connect();
56
+ postBinding('bind', binding);
57
+ }
58
+
59
+ function release(requestId) {
60
+ if (!binding || (requestId && binding.requestId !== requestId)) {
61
+ return;
62
+ }
63
+ postBinding('release', binding);
64
+ binding = null;
65
+ }
66
+
67
+ async function listJournals(projectKey) {
68
+ if (typeof runtime?.sendMessage !== 'function') {
69
+ return [];
70
+ }
71
+ const response = await runtime.sendMessage({
72
+ type: 'codex-overleaf/run-journal/list',
73
+ projectKey: String(projectKey || '')
74
+ });
75
+ return response?.ok && Array.isArray(response.journals) ? response.journals : [];
76
+ }
77
+
78
+ async function acknowledge(requestId) {
79
+ if (!requestId || typeof runtime?.sendMessage !== 'function') {
80
+ return false;
81
+ }
82
+ const response = await runtime.sendMessage({
83
+ type: 'codex-overleaf/run-journal/ack',
84
+ requestId
85
+ });
86
+ return response?.ok === true;
87
+ }
88
+
89
+ async function recoverJournals(options = {}) {
90
+ const projectKey = String(options.projectKey || '');
91
+ if (!projectKey) {
92
+ return { changed: false, acknowledgeIds: [] };
93
+ }
94
+ const journals = await listJournals(projectKey).catch(() => []);
95
+ let changed = false;
96
+ const acknowledgeIds = [];
97
+ for (const journal of journals) {
98
+ if (!journal?.terminal && !journal?.ownerLost) {
99
+ continue;
100
+ }
101
+ const session = options.findSession?.(journal.sessionId);
102
+ if (!session) {
103
+ continue;
104
+ }
105
+ let record = (session.runs || []).find(run => run.id === journal.clientRunId);
106
+ if (!record && journal.clientRunId) {
107
+ record = options.createInterruptedRun?.(journal, projectKey);
108
+ if (record) {
109
+ session.runs = [...(session.runs || []), record].slice(-20);
110
+ }
111
+ }
112
+ if (!record) {
113
+ continue;
114
+ }
115
+ const reloadReports = (record.events || []).filter(event => event?.failure?.source === 'panel_reload');
116
+ record.events = (record.events || []).filter(event => event?.failure?.source !== 'panel_reload');
117
+ const lastSequence = Number(record.nativeEventSeq || 0);
118
+ for (const entry of Array.isArray(journal.events) ? journal.events : []) {
119
+ const sequence = Number(entry?.sequence || 0);
120
+ if (sequence <= lastSequence) {
121
+ continue;
122
+ }
123
+ const raw = entry?.event || {};
124
+ if (raw.type === 'codex.turn.bound') {
125
+ session.codexThreadId = String(raw.detail?.threadId || session.codexThreadId || '');
126
+ record.codexTurnId = String(raw.detail?.turnId || record.codexTurnId || '');
127
+ record.nativeEventSeq = sequence;
128
+ continue;
129
+ }
130
+ const recovered = options.mapEvent?.(raw, journal);
131
+ if (recovered) {
132
+ if (recovered.kind === 'stream') {
133
+ options.upsertStream?.(record, recovered);
134
+ } else {
135
+ record.events = [...(record.events || []), recovered].slice(-(options.maxEvents || 300));
136
+ }
137
+ }
138
+ record.nativeEventSeq = sequence;
139
+ }
140
+ if (reloadReports.length) {
141
+ record.events = [...(record.events || []), ...reloadReports].slice(-(options.maxEvents || 300));
142
+ }
143
+ if (record.status === 'running' || record.status === 'interrupted') {
144
+ record.status = 'interrupted';
145
+ record.finishedAt = journal.updatedAt || new Date().toISOString();
146
+ record.interruptedDraft = {
147
+ requestId: journal.requestId,
148
+ threadId: session.codexThreadId || '',
149
+ turnId: record.codexTurnId || '',
150
+ lastEventSeq: record.nativeEventSeq || 0,
151
+ reason: journal.ownerLost ? 'page_owner_lost' : 'page_reload'
152
+ };
153
+ }
154
+ options.pauseSessionQueue?.(session, 'page_reload');
155
+ changed = true;
156
+ acknowledgeIds.push(journal.requestId);
157
+ }
158
+ return { changed, acknowledgeIds };
159
+ }
160
+
161
+ function destroy() {
162
+ destroyed = true;
163
+ clearTimeout(reconnectTimer);
164
+ reconnectTimer = null;
165
+ try {
166
+ port?.disconnect?.();
167
+ } catch (_error) {
168
+ // Best effort.
169
+ }
170
+ port = null;
171
+ binding = null;
172
+ }
173
+
174
+ connect();
175
+ return { acknowledge, bind, destroy, listJournals, recoverJournals, release };
176
+ }
177
+
178
+ return { create };
179
+ });
@@ -15,6 +15,7 @@
15
15
  };
16
16
 
17
17
  container.innerHTML = `
18
+ <div class="codex-pending-inputs" data-pending-inputs hidden></div>
18
19
  <form class="codex-composer" data-composer-form>
19
20
  <div class="codex-attachment-strip codex-attachment-preview-list" data-attachment-strip hidden></div>
20
21
  <div class="codex-composer-skill-context" data-composer-skill-context hidden>
@@ -85,7 +86,7 @@
85
86
  </div>
86
87
  </div>
87
88
  </div>
88
- <button type="submit" data-run title="Send" aria-label="Send">↑</button>
89
+ <button type="submit" data-run data-action="send" title="Send" aria-label="Send">↑</button>
89
90
  </div>
90
91
  <div class="codex-slash-menu" data-slash-menu hidden>
91
92
  <button type="button" data-slash-command="install-skill" data-slash-command-kind="installer">
@@ -142,7 +143,7 @@
142
143
  bind(instance, form, 'drop', event => instance.callbacks.onDrop?.(event));
143
144
  bind(instance, instance.container.querySelector('[data-run]'), 'click', event => {
144
145
  event.preventDefault();
145
- if (instance.callbacks.isRunning?.()) {
146
+ if (instance.callbacks.isRunning?.() && !String(task?.value || '').trim()) {
146
147
  instance.callbacks.onCancel?.();
147
148
  return;
148
149
  }
@@ -185,9 +186,17 @@
185
186
  if (!runButton) {
186
187
  return;
187
188
  }
188
- runButton.disabled = false;
189
- runButton.title = running ? t(instance, 'cancelRun') : t(instance, 'send');
190
- runButton.setAttribute('aria-label', running ? t(instance, 'cancelRun') : t(instance, 'send'));
189
+ const hasInput = Boolean(String(instance?.container?.querySelector('[data-task]')?.value || '').trim());
190
+ const action = running && !hasInput ? 'cancel' : 'send';
191
+ const label = action === 'cancel'
192
+ ? t(instance, 'cancelRun')
193
+ : running
194
+ ? t(instance, 'queueNextInput')
195
+ : t(instance, 'send');
196
+ runButton.dataset.action = action;
197
+ runButton.disabled = action === 'send' && !hasInput;
198
+ runButton.title = label;
199
+ runButton.setAttribute('aria-label', label);
191
200
  }
192
201
 
193
202
  function setModels(target, models = []) {