Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adr/0001-explicit-chatgpt-url-binding.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The extension credential grants only the local binding and support routes. It do

Thread preparation is independent from RALPH registration. The support extension reports every observed ChatGPT `/c/...` route to the local backend.

`ThreadPreparationCoordinator` treats browser presence as independent from stored Thread Sync bindings. It deduplicates repeated observations by thread ID and prepares an observed thread even when that conversation was already bound earlier, because RALPH still needs the automation browser to keep the page available. The command bus tracks recent executor polls and commands currently owned by a browser, so a busy Chrome instance still counts as connected. If no recent preparation executor exists, the backend deduplicates the launch and starts Chrome through the existing browser launcher. It queues `prepare_thread` commands with a maximum of three active preparations at once. A successful preparation is remembered for the rest of the server run. The automation browser reuses an existing matching conversation tab or creates one persistent owned tab, avoiding repeated ChatGPT reloads for the same thread.
`ThreadPreparationCoordinator` treats browser presence as independent from stored Thread Sync bindings. It deduplicates repeated observations by thread ID and prepares an observed thread even when that conversation was already bound earlier, because RALPH still needs the automation browser to keep the page available. The command bus tracks recent executor polls and commands currently owned by a browser, so a busy Chrome instance still counts as connected. A one-minute extension alarm wakes the Manifest V3 executor after service-worker suspension, and the backend keeps a presence grace window long enough to span that wake cycle. If no recent preparation executor exists, the backend deduplicates the launch and starts Chrome through the existing browser launcher, then waits for the executor to reconnect before treating the launch as successful. A missing executor therefore produces an explicit configuration error instead of a delayed `prepare_thread` timeout, and launch attempts are rate-limited so concurrent requests cannot create a window loop. It queues `prepare_thread` commands with a maximum of three active preparations at once. A successful preparation is remembered for the rest of the server run. The automation browser reuses an existing matching conversation tab or creates one persistent owned tab, avoiding repeated ChatGPT reloads for the same thread.

The support extension has an explicit **Thread preparation executor** setting. Enable it only in the Chrome automation profile. That profile opens or reuses a persistent conversation tab. A successful Thread Sync handshake leaves that tab in place for title observation, RALPH, and later messaging. Helium only observes routes and reports them to the backend with the executor setting off, so it does not launch Chrome or claim preparation work.

Expand Down
44 changes: 41 additions & 3 deletions scripts/thread-sync-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ try {
"the obsolete generated thread-sync extension is removed");
const manifest = JSON.parse(await readFile(path.join(sync.extensionDirectory, "manifest.json"), "utf8"));
assert.deepEqual(manifest.host_permissions, ["https://chatgpt.com/*", "http://127.0.0.1/*"]);
assert.equal(manifest.version, "1.4.4");
assert.equal(manifest.version, "1.4.5");
assert.equal(manifest.minimum_chrome_version, undefined, "thread sync is not tied to a Chrome-branded minimum");
assert.deepEqual(manifest.permissions, ["scripting", "storage", "tabs", "webNavigation"]);
assert.deepEqual(manifest.permissions, ["alarms", "scripting", "storage", "tabs", "webNavigation"]);
assert.equal(manifest.action.default_popup, "popup.html");
assert.equal(manifest.content_security_policy.extension_pages,
"script-src 'self'; object-src 'self'; connect-src http://127.0.0.1:*");
Expand Down Expand Up @@ -128,6 +128,10 @@ try {
"service-worker thread matching canonicalizes project-name slugs");
assert.doesNotMatch(preparedServiceWorker, /threadMessaging\) features\.push\("threadPreparation"\)/,
"a thread-messaging observer such as Helium never implicitly claims thread preparation");
assert.match(preparedServiceWorker, /SUPPORT_POLL_PERIOD_MINUTES = 1/,
"the automation executor has a periodic MV3 wake-up instead of relying on an immortal service worker");
assert.match(preparedServiceWorker, /alarms\?\.onAlarm\?\.addListener/,
"the polling alarm wakes command claiming after the extension worker is suspended");
const preparedContentScript = await readFile(path.join(sync.extensionDirectory, "content-script.js"), "utf8");
assert.doesNotMatch(preparedContentScript, /Run RALPH now|installManualRalphButton/,
"the content script does not inject a RALPH button into ChatGPT");
Expand Down Expand Up @@ -768,11 +772,45 @@ try {
await busyResult;
browserPresenceBus.close();

const sleepingWorkerBus = new SupportCommandBus();
let sleepingWorkerLaunches = 0;
const realDateNow = Date.now;
let presenceNow = realDateNow();
Date.now = () => presenceNow;
try {
assert.equal(await sleepingWorkerBus.claim("sleeping-chrome", ["threadPreparation"], 0), undefined);
presenceNow += 70_000;
await sleepingWorkerBus.ensureBrowser("threadPreparation", async () => { sleepingWorkerLaunches += 1; });
assert.equal(sleepingWorkerLaunches, 0,
"one MV3 sleep/alarm interval does not cause the backend to launch another Chrome window");
} finally {
Date.now = realDateNow;
sleepingWorkerBus.close();
}

const missingExecutorBus = new SupportCommandBus(undefined, undefined, undefined, 25);
let missingExecutorLaunches = 0;
const launchWithoutExecutor = async () => { missingExecutorLaunches += 1; };
await assert.rejects(
missingExecutorBus.ensureBrowser("threadPreparation", launchWithoutExecutor),
/did not connect as a threadPreparation executor/,
"spawning chrome.exe is not treated as proof that the support executor connected");
await assert.rejects(
missingExecutorBus.ensureBrowser("threadPreparation", launchWithoutExecutor),
/did not connect as a threadPreparation executor/,
"a missing executor fails explicitly during the launch cooldown instead of opening Chrome again");
assert.equal(missingExecutorLaunches, 1, "a missing executor does not create a Chrome launch loop");
missingExecutorBus.close();

const launchDedupBus = new SupportCommandBus();
let deduplicatedLaunches = 0;
let releaseLaunch;
const launchGate = new Promise(resolve => { releaseLaunch = resolve; });
const launchBrowserOnce = async () => { deduplicatedLaunches += 1; await launchGate; };
const launchBrowserOnce = async () => {
deduplicatedLaunches += 1;
await launchGate;
await launchDedupBus.claim("dedup-browser", ["threadMessaging", "threadPreparation"], 0);
};
const launchRequests = [
launchDedupBus.ensureBrowser("threadMessaging", launchBrowserOnce),
launchDedupBus.ensureBrowser("threadPreparation", launchBrowserOnce),
Expand Down
34 changes: 30 additions & 4 deletions src/chatgpt-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ const FAILURE_RETRY_MS = 2 * 60 * 1000;
const COMMAND_TIMEOUT_MS = 20 * 60 * 1000;
const INSPECT_CLAIM_LEASE_MS = 5 * 60 * 1000;
const CLAIM_WAIT_MS = 20_000;
const SUPPORT_BROWSER_HEARTBEAT_GRACE_MS = CLAIM_WAIT_MS + 5_000;
const SUPPORT_BROWSER_LAUNCH_COOLDOWN_MS = 5_000;
const SUPPORT_BROWSER_HEARTBEAT_GRACE_MS = 90_000;
const SUPPORT_BROWSER_LAUNCH_COOLDOWN_MS = 60_000;
const SUPPORT_BROWSER_CONNECT_TIMEOUT_MS = 10_000;
const SUBAGENT_RESULT_MISSING_RETRY_MS = 30_000;
const MAX_SUBAGENT_NOTIFICATION_ATTEMPTS = 5;
const MAX_SUBAGENT_NOTIFICATION_RETRY_MS = 10 * 60_000;
Expand Down Expand Up @@ -249,6 +250,7 @@ export class SupportCommandBus {
private readonly inspectClaimLeaseMs = INSPECT_CLAIM_LEASE_MS,
private readonly messageCooldownMs = MESSAGE_COOLDOWN_MS,
private readonly messageSendSpacingMs = MESSAGE_SEND_SPACING_MS,
private readonly browserConnectWaitMs = SUPPORT_BROWSER_CONNECT_TIMEOUT_MS,
) {}

execute(
Expand Down Expand Up @@ -301,9 +303,15 @@ export class SupportCommandBus {
if (this.hasBrowser(feature)) return;
if (this.launchInFlight) {
await this.launchInFlight;
return;
if (await this.waitForBrowser(feature)) return;
throw this.executorUnavailableError(feature);
}

const sinceLastLaunch = Date.now() - this.lastLaunchAt;
if (sinceLastLaunch < SUPPORT_BROWSER_LAUNCH_COOLDOWN_MS) {
if (await this.waitForBrowser(feature)) return;
throw this.executorUnavailableError(feature);
}
if (Date.now() - this.lastLaunchAt < SUPPORT_BROWSER_LAUNCH_COOLDOWN_MS) return;

const launch = launchBrowser();
this.launchInFlight = launch;
Expand All @@ -313,6 +321,24 @@ export class SupportCommandBus {
} finally {
if (this.launchInFlight === launch) this.launchInFlight = undefined;
}
if (await this.waitForBrowser(feature)) return;
throw this.executorUnavailableError(feature);
}

private async waitForBrowser(feature: SupportFeature) {
const deadline = Date.now() + this.browserConnectWaitMs;
while (Date.now() < deadline) {
if (this.hasBrowser(feature)) return true;
await new Promise<void>((resolve) => setTimeout(resolve, Math.min(50, Math.max(1, deadline - Date.now()))));
}
return this.hasBrowser(feature);
}

private executorUnavailableError(feature: SupportFeature) {
return new Error(
`Chrome is running, but Local Codex Support did not connect as a ${feature} executor. ` +
"In the Chrome automation profile, reload the generated .data/support-extension and enable the designated preparation/automation executor plus the required support feature.",
);
}

claim(browserId: string, features: SupportFeature[], waitMs = CLAIM_WAIT_MS, signal?: AbortSignal) {
Expand Down
2 changes: 2 additions & 0 deletions support-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ Thread preparation is independent from RALPH. The support extension reports ever

The Chrome automation profile reuses an existing matching conversation tab or creates one background tab and remembers that it owns it. Thread Sync no longer closes the tab. RALPH inspection and existing-thread messages reuse the same tab, preventing a fresh ChatGPT page load every few minutes. Automation-owned tabs remain open while the RALPH thread is active and are cleaned up ten minutes after completion; a tab that was already open in the user's browser is reused but never closed by lifecycle cleanup. Helium can keep Thread Sync enabled to observe and report routes while **Thread preparation executor** remains off, so it never claims `threadPreparation`.

The executor also keeps a one-minute extension alarm while automation is enabled. This wakes the Manifest V3 service worker after Chrome suspends it, so the backend continues to see the existing executor instead of launching another Chrome instance. When the backend does have to launch Chrome, it waits for the support extension to reconnect before considering the launch successful; a missing or disabled executor fails with an explicit configuration error instead of leaving `prepare_thread` queued until its long timeout.

## Checks

Run:
Expand Down
4 changes: 2 additions & 2 deletions support-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"manifest_version": 3,
"name": "Local Codex Support",
"version": "1.4.4",
"version": "1.4.5",
"description": "Supports one-time ChatGPT thread sync, RALPH monitoring, and sub-agent automation for Local Codex.",
"permissions": ["scripting", "storage", "tabs", "webNavigation"],
"permissions": ["alarms", "scripting", "storage", "tabs", "webNavigation"],
"host_permissions": ["https://chatgpt.com/*", "http://127.0.0.1/*"],
"background": { "service_worker": "service-worker.js" },
"content_scripts": [{
Expand Down
20 changes: 20 additions & 0 deletions support-extension/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const TITLE_OBSERVED_MESSAGE = "local-codex-support/title-observed-v1";
const SYNC_MESSAGE = "local-codex-thread-sync/bind-v1";
const WORKER_KEEPALIVE_INTERVAL_MS = 20_000;
const AUTOMATION_RESPONSE_TIMEOUT_MS = 8 * 60_000;
const SUPPORT_POLL_ALARM = "local-codex-support/poll";
const SUPPORT_POLL_PERIOD_MINUTES = 1;
let pollGeneration = 0;
let pollController = null;
const reportedRalphConversations = new Set();
Expand Down Expand Up @@ -509,6 +511,17 @@ function enabledAutomationFeatures(settings) {
return features;
}

async function syncPollingAlarm() {
if (!extensionApi.alarms) return;
const settings = await getSettings();
if (enabledAutomationFeatures(settings).length === 0) {
await extensionApi.alarms.clear(SUPPORT_POLL_ALARM);
return;
}
if (await extensionApi.alarms.get(SUPPORT_POLL_ALARM)) return;
extensionApi.alarms.create(SUPPORT_POLL_ALARM, { periodInMinutes: SUPPORT_POLL_PERIOD_MINUTES });
}

async function pollCommands(generation) {
const browserId = await getBrowserId();
while (generation === pollGeneration) {
Expand Down Expand Up @@ -576,6 +589,7 @@ extensionApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === "local-codex-support/settings-changed") {
reportedRalphConversations.clear();
restartPolling();
void syncPollingAlarm();
void scanExistingTabs();
sendResponse({ ok: true });
}
Expand Down Expand Up @@ -618,13 +632,19 @@ extensionApi.webNavigation?.onCommitted?.addListener((details) => {
}
});

extensionApi.alarms?.onAlarm?.addListener((alarm) => {
if (alarm.name === SUPPORT_POLL_ALARM) restartPolling();
});
extensionApi.runtime.onInstalled.addListener(() => {
void syncPollingAlarm();
void scanExistingTabs();
restartPolling();
});
extensionApi.runtime.onStartup.addListener(() => {
void syncPollingAlarm();
void scanExistingTabs();
restartPolling();
});
void syncPollingAlarm();
void scanExistingTabs();
restartPolling();