diff --git a/tools/conductor-pocket/README.md b/tools/conductor-pocket/README.md
index 7b657f4f..01ac9bf5 100644
--- a/tools/conductor-pocket/README.md
+++ b/tools/conductor-pocket/README.md
@@ -30,7 +30,8 @@ Pocket never overwrites or merges it silently.
The production setup is defense in depth:
1. The Node relay binds only to `127.0.0.1`.
-2. Tailscale **Serve** provides private tailnet HTTPS. Funnel is refused.
+2. A dedicated user-mode Tailscale node gives Pocket its own hostname, IP,
+ certificate, browser origin, and **Serve** configuration. Funnel is refused.
3. Tailscale's authenticated identity header is captured at pairing and must
match on every request.
4. A 192-bit, single-use pairing link expires after 15 minutes and stays in
@@ -56,6 +57,9 @@ See [SECURITY.md](./SECURITY.md) for trust boundaries and failure behavior.
- macOS with Conductor installed and running.
- Node 22.5 or newer.
- Tailscale signed into the same tailnet on the Mac and iPhone.
+- Homebrew's open-source `tailscale` formula for the dedicated Pocket node.
+ Its background service must remain disabled; Pocket owns a private
+ user-level daemon, state directory, and Unix socket.
- The Mac awake and online. Conductor can continue working with its window in
the background, but it must have a window available for phone sends.
- One-time macOS Accessibility permission for the relay's Node executable.
@@ -66,21 +70,38 @@ From this directory:
```sh
npm install
+brew install tailscale
+npm run sidecar:install
+npm run sidecar:login
npm run setup
npm run install:relay
+npm run sidecar:cutover
npm run doctor
```
+`sidecar:login` opens a one-time Tailscale authorization URL in the Mac browser
+and exits as soon as the private daemon owns the request; it does not need an
+open terminal while you approve it. If the command or chat is interrupted,
+rerun it to recover the still-pending URL from the private socket. If browser
+opening is unavailable, explicitly print the secret with
+`npm run sidecar:login -- --print-url`. After approving the URL, rerun the
+command once to verify the node is authenticated. It creates a separate node
+named `conductor-pocket`; it does not log out, rename, or replace the Mac's
+normal Tailscale connection. Never run `brew services start tailscale` for
+this setup.
+
`npm run setup` prints two values:
- a single-use pairing URL to open on the iPhone;
- a six-character verification code that must match the phone.
-The installer refuses to continue if any Tailscale Funnel exists or if a
-Serve configuration would be overwritten. It creates a user LaunchAgent and
-a private Serve proxy only after those checks. It first copies the audited
-runtime into `~/.config/conductor-pocket/runtimes/`, so archiving or deleting
-this source workspace cannot break the installed relay.
+The relay installer first copies the audited runtime into
+`~/.config/conductor-pocket/runtimes/`, so archiving or deleting this source
+workspace cannot break the installed relay. The sidecar installer creates a
+separate user LaunchAgent with a `0700` state directory and an explicit private
+socket. Cutover refuses an unexpected daemon process or launch argument,
+unsafe Tailscale preferences, extra Serve handlers, enabled Funnel state, a
+different tailnet, a reused Mac node identity, or an origin mismatch.
Open the pairing URL on the iPhone while Tailscale is connected, compare the
code, enroll Face ID, then use Safari's Share → Add to Home Screen.
@@ -91,6 +112,42 @@ To pair another phone later:
npm run pair
```
+## Migrating from the old shared Mac hostname
+
+The old path-based setup must be retired, not reused. Paths on one hostname
+share cookies, WebAuthn authority, IndexedDB, Cache Storage, localStorage, and
+service workers.
+
+1. Install and authenticate the dedicated sidecar.
+2. Run `npm run install:relay` to install the versioned retirement client.
+3. Run `npm run sidecar:cutover` once. This arms server-enforced retirement
+ and disables new pairing on the old origin.
+4. On every old phone, fully close and reopen Pocket while online. In Security
+ & Devices, confirm the App row says `client 0.2.0`, then sign out **that
+ same phone**. Close every other Pocket window first. The client sets an
+ origin tombstone, stops sibling contexts, verifies it is the only remaining
+ browser window, and erases IndexedDB, Pocket-owned Cache Storage, and the
+ Pocket root service worker before the Mac records retirement.
+5. Remove the old Pocket Home Screen icon.
+6. Run `npm run sidecar:cutover` again.
+7. Open the new one-time link, enroll a new Face ID passkey, and add the new
+ dedicated address to the Home Screen.
+
+Cutover rotates the CSRF secret, pairing secret, RP ID, and public origin,
+requires a versioned self-purge receipt from every original device, verifies
+the exact live config revision over both loopback and HTTPS, and removes only
+Pocket's old `/` handler. Remote revocation cannot satisfy retirement. Other
+handlers and listeners on the Mac's normal Tailscale hostname are checked
+against the complete pre-cutover Serve document and preserved exactly.
+
+If a legacy config already has no paired devices and no retirement record,
+cutover refuses to guess. Only when the origin was never paired or every old
+copy was independently erased may you explicitly run:
+
+```sh
+npm run sidecar:cutover -- --attest-no-old-devices
+```
+
## Development
An insecure loopback-only mode exists for automated browser tests:
diff --git a/tools/conductor-pocket/SECURITY.md b/tools/conductor-pocket/SECURITY.md
index 00da8cc7..8ba7d3fb 100644
--- a/tools/conductor-pocket/SECURITY.md
+++ b/tools/conductor-pocket/SECURITY.md
@@ -17,8 +17,8 @@ Pocket. They remain inside the processes and profiles Conductor already owns.
- Alex's logged-in macOS account and its local files.
- The installed Conductor application.
- The loopback interface.
-- The authenticated Tailscale Serve proxy after its identity header is
- matched to the paired identity.
+- The dedicated Pocket Tailscale node and Serve proxy after its identity
+ header is matched to the paired identity.
- The iPhone platform authenticator after WebAuthn user verification.
### Not trusted
@@ -38,8 +38,28 @@ Pocket. They remain inside the processes and profiles Conductor already owns.
The HTTP server refuses non-loopback binds in config validation. It validates
the Host header and accepts only the configured tailnet host or explicit
loopback development hosts. Tailscale Serve strips spoofed identity headers
-before adding authenticated values. The installer refuses all nonempty
-Funnel configurations and will not replace an existing Serve configuration.
+before adding authenticated values.
+
+Pocket runs a second, user-mode Tailscale node with a separate node key, IP,
+MagicDNS name, certificate, state directory, Unix socket, and Serve
+configuration. The normal Mac node is never addressed by the formula CLI, and
+every sidecar CLI command carries the explicit private socket. The sidecar
+uses userspace networking, accepts neither routes nor DNS, advertises no exit
+node or subnet, and exposes exactly one tailnet-only HTTPS root proxy to the
+loopback relay. The loaded LaunchAgent arguments and Unix-socket owner must
+match the audited daemon profile; DNS, route, SSH, web-client, connector, and
+advertising preferences fail closed. Funnel and extra handlers fail
+validation.
+
+The one-time sidecar authorization URL is read back from that audited private
+socket, accepted only when it is the canonical
+`https://login.tailscale.com/a/…` shape, and never persisted by Pocket. It is
+opened directly with macOS rather than written to command output unless the
+operator explicitly requests `--print-url`. The short-lived CLI helper has
+both an internal Tailscale timeout and bounded process cleanup, and Pocket
+proves the daemon retained the same request after terminating it. Browser
+approval therefore does not depend on a terminal or agent session remaining
+alive.
### Authentication
@@ -93,11 +113,19 @@ Framing, object embedding, referrers, camera, microphone, location, payment,
USB, and serial access are disabled. The UI builds transcript nodes with
`textContent`; transcript Markdown is never injected as HTML.
-The service worker caches only the app shell and explicitly excludes `/api/`.
+The service worker handles only an allowlist of Pocket shell paths, deletes
+only `conductor-pocket-shell-*` caches, and never intercepts sibling routes or
+`/api/`.
Device-local transcript snapshots are bounded and are not rendered until
-after Face ID unlock. Revocation instructs the connected client to purge
-them; a device that never reconnects remains protected by iOS Data
-Protection and the app's Face ID gate.
+after Face ID unlock. During an origin migration, the server records the
+original device set, disables remote revocation, and accepts only a
+version-matched self-sign-out. The phone first writes a persistent origin
+tombstone, prevents every Pocket context from reopening its cache, verifies
+through the service worker that no other browser window remains, and deletes
+Pocket's localStorage keys, IndexedDB, Cache Storage, and root service worker.
+Blocked deletion fails visibly and keeps the device enrolled. Only then does
+the client send its retirement receipt. A device that never reconnects remains
+protected by iOS Data Protection and the app's Face ID gate.
## Residual risks
@@ -106,6 +134,11 @@ Protection and the app's Face ID gate.
the Mac user account.
- macOS Accessibility permission is broad. The relay's Node executable must
be trusted and should not be replaced by an untrusted binary.
+- The dedicated Tailscale state contains a node private key. Its directory is
+ `0700`, files are `0600`, and FileVault remains the at-rest protection.
+- Tailscale HTTPS certificate names can appear in public certificate
+ transparency logs. The hostname reveals the service label, not its content
+ or access.
- UI automation depends on Conductor's accessible structure. Version changes
fail closed: a missing workspace, session, composer, or enabled Send
control produces an error rather than a guessed click.
@@ -116,4 +149,5 @@ Protection and the app's Face ID gate.
unauthenticated app shell and pairing endpoint, but cannot pair without the
high-entropy one-time link and matching identity.
- Device-local browser storage cannot be remotely erased while the iPhone is
- permanently offline. Revoke plus iOS device security is the recovery path.
+ permanently offline. iOS device security is the recovery boundary until
+ that phone reconnects and completes its own retirement purge.
diff --git a/tools/conductor-pocket/package-lock.json b/tools/conductor-pocket/package-lock.json
index 032da1f2..7766e32d 100644
--- a/tools/conductor-pocket/package-lock.json
+++ b/tools/conductor-pocket/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@ovo/conductor-pocket",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ovo/conductor-pocket",
- "version": "0.1.0",
+ "version": "0.2.0",
"dependencies": {
"@simplewebauthn/server": "13.3.2"
},
diff --git a/tools/conductor-pocket/package.json b/tools/conductor-pocket/package.json
index ad57aec3..01a5ab0e 100644
--- a/tools/conductor-pocket/package.json
+++ b/tools/conductor-pocket/package.json
@@ -1,17 +1,20 @@
{
"name": "@ovo/conductor-pocket",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.5.0"
},
"scripts": {
- "check": "node --check src/server.mjs && node --check src/security.mjs && node --check src/conductor-db.mjs && node --check src/accessibility.mjs && node --check src/tailscale-config.mjs && node --check scripts/install-relay.mjs && node --check public/app.js && node --check public/service-worker.js && npm test",
+ "check": "node --check src/cli.mjs && node --check src/server.mjs && node --check src/security.mjs && node --check src/conductor-db.mjs && node --check src/accessibility.mjs && node --check src/tailscale-config.mjs && node --check src/operation-lock.mjs && node --check scripts/lib/sidecar.mjs && node --check scripts/lib/login.mjs && node --check scripts/lib/cutover.mjs && node --check scripts/install-relay.mjs && node --check scripts/install-sidecar.mjs && node --check scripts/login-sidecar.mjs && node --check scripts/cutover-sidecar.mjs && node --check public/delivery-receipts.js && node --check public/app.js && node --check public/service-worker.js && npm test",
"doctor": "node --no-warnings=ExperimentalWarning src/cli.mjs doctor",
"install:relay": "node --no-warnings=ExperimentalWarning scripts/install-relay.mjs",
"pair": "node --no-warnings=ExperimentalWarning src/cli.mjs pair",
"setup": "node --no-warnings=ExperimentalWarning src/cli.mjs setup",
+ "sidecar:cutover": "node --no-warnings=ExperimentalWarning scripts/cutover-sidecar.mjs",
+ "sidecar:install": "node --no-warnings=ExperimentalWarning scripts/install-sidecar.mjs",
+ "sidecar:login": "node --no-warnings=ExperimentalWarning scripts/login-sidecar.mjs",
"start": "node --no-warnings=ExperimentalWarning src/cli.mjs serve",
"start:ui-fixture": "node --no-warnings=ExperimentalWarning scripts/ui-fixture.mjs",
"test": "node --no-warnings=ExperimentalWarning --test"
diff --git a/tools/conductor-pocket/public/app.js b/tools/conductor-pocket/public/app.js
index c54cc9d2..539fd096 100644
--- a/tools/conductor-pocket/public/app.js
+++ b/tools/conductor-pocket/public/app.js
@@ -1,9 +1,18 @@
+import { reconcileDeliveryReceipts } from './delivery-receipts.js?v=0.2.0-delivery-fix-2';
+
const app = document.querySelector('#app');
const overlayRoot = document.querySelector('#overlay-root');
const announcer = document.querySelector('#announcer');
const AWAY_LOCK_MS = 5 * 60 * 1000;
const ACTIVITY_HEARTBEAT_MS = 60 * 1000;
const HIDDEN_AT_KEY = 'cp:hidden-at:v1';
+const CLIENT_VERSION = '0.2.0';
+const SHELL_CACHE_PREFIX = 'conductor-pocket-shell-';
+const CACHE_PURGE_CHANNEL = 'conductor-pocket-cache-purge-v1';
+const ORIGIN_RETIRED_KEY = 'cp:origin-retired:v1';
+const PENDING_DELIVERIES_KEY = 'pending-deliveries:v1';
+const DELIVERY_RECOVERY_MS = 27_000;
+const DELIVERY_STATUS_REQUEST_MS = 2_500;
const state = {
auth: null,
@@ -386,8 +395,7 @@ async function bootstrap() {
else renderLock();
} catch (error) {
if (error.status === 401 || error.code === 'device_revoked') {
- await purgeLocalData();
- renderSignedOut();
+ await purgeThenRenderSignedOut();
} else {
renderConnectionGate(error.code);
}
@@ -452,6 +460,15 @@ function renderLock({ errorMessage = '' } = {}) {
function renderSignedOut() {
stopEvents();
+ state.auth = null;
+ state.csrfToken = null;
+ state.workspaces = [];
+ state.recentSessions = [];
+ state.sessionsByWorkspace.clear();
+ state.messagesBySession.clear();
+ state.cursorsBySession.clear();
+ state.optimistic = [];
+ state.seenMessageIds.clear();
gateView({
mark: 'lock',
title: 'This device was signed out',
@@ -464,19 +481,58 @@ function renderSignedOut() {
});
}
+function isLocalPurgeFailure(error) {
+ return /transcript_cache_delete|service_worker_retirement|other_pocket_window|origin_retired|cache/i.test(
+ error?.message || '',
+ );
+}
+
+function renderLocalPurgeFailure(retry) {
+ stopEvents();
+ gateView({
+ mark: 'warn',
+ title: 'Local data was not erased',
+ body:
+ 'Close every other Pocket window on this phone, then retry. This device stays enrolled until the cleanup finishes.',
+ action: node('button', {
+ className: 'primary-button',
+ type: 'button',
+ text: 'Retry cleanup',
+ on: { click: retry },
+ }),
+ });
+}
+
+async function purgeThenRenderSignedOut() {
+ try {
+ await purgeLocalData();
+ renderSignedOut();
+ } catch {
+ renderLocalPurgeFailure(() => purgeThenRenderSignedOut());
+ }
+}
+
function renderConnectionGate(code) {
+ const upgradeRequired = code === 'retirement_client_upgrade_required';
gateView({
- mark: 'wifiOff',
- title: 'Mac unreachable',
+ mark: upgradeRequired ? 'refresh' : 'wifiOff',
+ title: upgradeRequired ? 'Pocket must refresh' : 'Mac unreachable',
body:
- code === 'tailscale_identity_required'
+ upgradeRequired
+ ? 'Fully close Pocket, reopen it while online, then sign out again. The old app cannot retire this phone.'
+ : code === 'tailscale_identity_required'
? 'Connect this phone to your private Tailscale network, then try again.'
: 'Conductor Pocket could not reach the relay on your Mac.',
action: node('button', {
className: 'primary-button',
type: 'button',
- text: 'Try again',
- on: { click: () => bootstrap() },
+ text: upgradeRequired ? 'Reload Pocket' : 'Try again',
+ on: {
+ click: () => {
+ if (upgradeRequired) location.reload();
+ else bootstrap();
+ },
+ },
}),
});
}
@@ -554,8 +610,21 @@ function saveDraft(sessionId, value) {
}
let cacheDatabasePromise;
+let originRetired = localStorage.getItem(ORIGIN_RETIRED_KEY) === '1';
+const cachePurgeChannel =
+ 'BroadcastChannel' in window
+ ? new BroadcastChannel(CACHE_PURGE_CHANNEL)
+ : null;
+let serviceWorkerRegistrationPromise = null;
function cacheDatabase() {
+ if (
+ originRetired ||
+ localStorage.getItem(ORIGIN_RETIRED_KEY) === '1'
+ ) {
+ originRetired = true;
+ return Promise.reject(new Error('origin_retired'));
+ }
if (!cacheDatabasePromise) {
cacheDatabasePromise = new Promise((resolve, reject) => {
const open = indexedDB.open('conductor-pocket-v1', 1);
@@ -571,6 +640,26 @@ function cacheDatabase() {
return cacheDatabasePromise;
}
+async function closeCacheDatabase() {
+ try {
+ const database = await cacheDatabasePromise;
+ database?.close();
+ } catch {
+ // A failed cache open has nothing to close.
+ }
+ cacheDatabasePromise = null;
+}
+
+cachePurgeChannel?.addEventListener('message', (event) => {
+ if (event.data?.type === 'retire-origin') {
+ originRetired = true;
+ stopEvents();
+ void closeCacheDatabase();
+ } else if (event.data?.type === 'close-transcript-database') {
+ void closeCacheDatabase();
+ }
+});
+
async function cacheGet(key) {
try {
const database = await cacheDatabase();
@@ -599,23 +688,243 @@ async function cacheSet(key, value) {
}
}
+async function cacheSetRequired(key, value) {
+ const database = await cacheDatabase();
+ await new Promise((resolve, reject) => {
+ const transaction = database.transaction('snapshots', 'readwrite');
+ transaction.objectStore('snapshots').put(value, key);
+ transaction.oncomplete = resolve;
+ transaction.onerror = () =>
+ reject(transaction.error || new Error('cache_write_failed'));
+ transaction.onabort = () =>
+ reject(transaction.error || new Error('cache_write_aborted'));
+ });
+}
+
+function validPersistedKey(value) {
+ return (
+ typeof value === 'string' &&
+ value.length >= 16 &&
+ value.length <= 100 &&
+ /^[A-Za-z0-9_-]+$/.test(value)
+ );
+}
+
+function sanitizePendingDelivery(value) {
+ if (
+ !value ||
+ typeof value !== 'object' ||
+ value.kind !== 'optimistic' ||
+ typeof value.id !== 'string' ||
+ typeof value.sessionId !== 'string' ||
+ value.sessionId.length === 0 ||
+ value.sessionId.length > 200 ||
+ typeof value.text !== 'string' ||
+ value.text.length > 16 * 1024 ||
+ !validPersistedKey(value.idempotencyKey)
+ ) {
+ return null;
+ }
+ const delivery = new Set([
+ 'delivering',
+ 'confirming',
+ 'delivered',
+ 'failed',
+ ]).has(value.delivery)
+ ? value.delivery
+ : 'confirming';
+ return {
+ id: value.id,
+ idempotencyKey: value.idempotencyKey,
+ activeDeliveryKey: validPersistedKey(value.activeDeliveryKey)
+ ? value.activeDeliveryKey
+ : value.idempotencyKey,
+ replaceIdempotencyKey: validPersistedKey(value.replaceIdempotencyKey)
+ ? value.replaceIdempotencyKey
+ : null,
+ kind: 'optimistic',
+ sessionId: value.sessionId,
+ text: value.text,
+ delivery,
+ createdAt:
+ typeof value.createdAt === 'string'
+ ? value.createdAt
+ : new Date().toISOString(),
+ deliveredAt:
+ typeof value.deliveredAt === 'string' ? value.deliveredAt : null,
+ receiptBaselineCursor: Number.isSafeInteger(
+ value.receiptBaselineCursor,
+ )
+ ? value.receiptBaselineCursor
+ : null,
+ receiptRowId: Number.isSafeInteger(value.receiptRowId)
+ ? value.receiptRowId
+ : null,
+ retrySafe: value.retrySafe === true,
+ errorCode:
+ typeof value.errorCode === 'string' ? value.errorCode : null,
+ replaceDraft: value.replaceDraft === true,
+ macDraft:
+ typeof value.macDraft === 'string'
+ ? value.macDraft.slice(0, 16 * 1024)
+ : null,
+ };
+}
+
+function pendingDeliverySnapshot() {
+ return state.optimistic
+ .map(sanitizePendingDelivery)
+ .filter(Boolean);
+}
+
+async function persistPendingDeliveries({ required = false } = {}) {
+ const snapshot = pendingDeliverySnapshot();
+ if (required) {
+ await cacheSetRequired(PENDING_DELIVERIES_KEY, snapshot);
+ } else {
+ await cacheSet(PENDING_DELIVERIES_KEY, snapshot);
+ }
+}
+
+async function restorePendingDeliveries() {
+ const cached = await cacheGet(PENDING_DELIVERIES_KEY);
+ state.optimistic = Array.isArray(cached)
+ ? cached.map(sanitizePendingDelivery).filter(Boolean)
+ : [];
+}
+
+async function clearTranscriptCache() {
+ cachePurgeChannel?.postMessage({ type: 'close-transcript-database' });
+ await closeCacheDatabase();
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(
+ () => reject(new Error('transcript_cache_delete_blocked')),
+ 5_000,
+ );
+ const requestValue = indexedDB.deleteDatabase('conductor-pocket-v1');
+ requestValue.onsuccess = () => {
+ clearTimeout(timeout);
+ resolve();
+ };
+ requestValue.onerror = () => {
+ clearTimeout(timeout);
+ reject(requestValue.error || new Error('transcript_cache_delete_failed'));
+ };
+ requestValue.onblocked = () => {
+ // Another Pocket window receives the BroadcastChannel close request.
+ // If it does not release the database, the timeout fails closed.
+ };
+ });
+}
+
+async function assertOnlyRetiringWindow() {
+ if (!('serviceWorker' in navigator)) return;
+ const registration = serviceWorkerRegistrationPromise
+ ? await serviceWorkerRegistrationPromise
+ : await navigator.serviceWorker.ready;
+ await registration.update();
+ const candidate = registration.installing || registration.waiting;
+ if (candidate && candidate.state !== 'activated') {
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(
+ () => reject(new Error('service_worker_retirement_timeout')),
+ 4_000,
+ );
+ const onStateChange = () => {
+ if (candidate.state === 'activated') {
+ clearTimeout(timeout);
+ candidate.removeEventListener('statechange', onStateChange);
+ resolve();
+ } else if (candidate.state === 'redundant') {
+ clearTimeout(timeout);
+ candidate.removeEventListener('statechange', onStateChange);
+ reject(new Error('service_worker_retirement_unavailable'));
+ }
+ };
+ candidate.addEventListener('statechange', onStateChange);
+ onStateChange();
+ });
+ }
+ const worker =
+ registration.active || registration.waiting || registration.installing;
+ if (!worker) throw new Error('service_worker_retirement_unavailable');
+ const requestId = crypto.randomUUID
+ ? crypto.randomUUID()
+ : randomIdempotencyKey();
+ const response = await new Promise((resolve, reject) => {
+ const channel = new MessageChannel();
+ const timeout = setTimeout(
+ () => reject(new Error('service_worker_retirement_timeout')),
+ 2_000,
+ );
+ channel.port1.onmessage = (event) => {
+ if (
+ event.data?.type === 'retirement-window-count' &&
+ event.data?.requestId === requestId
+ ) {
+ clearTimeout(timeout);
+ resolve(event.data);
+ }
+ };
+ worker.postMessage(
+ { type: 'retirement-window-count', requestId },
+ [channel.port2],
+ );
+ });
+ if (response.count !== 1) {
+ throw new Error('other_pocket_window_open');
+ }
+}
+
async function purgeLocalData() {
+ originRetired = true;
+ localStorage.setItem(ORIGIN_RETIRED_KEY, '1');
+ cachePurgeChannel?.postMessage({ type: 'retire-origin' });
+ await assertOnlyRetiringWindow();
localStorage.removeItem('cp:last-route:v1');
localStorage.removeItem('cp:drafts:v1');
localStorage.removeItem(HIDDEN_AT_KEY);
- try {
- const database = await cacheDatabasePromise;
- database?.close();
- } catch {
- // A failed cache open has nothing to close.
+ await clearTranscriptCache();
+ if ('caches' in window) {
+ const cacheNames = await caches.keys();
+ await Promise.all(
+ cacheNames
+ .filter((name) => name.startsWith(SHELL_CACHE_PREFIX))
+ .map((name) => caches.delete(name)),
+ );
+ }
+ if ('serviceWorker' in navigator) {
+ const registrations = await navigator.serviceWorker.getRegistrations();
+ await Promise.all(
+ registrations.map(async (registration) => {
+ const worker =
+ registration.active || registration.waiting || registration.installing;
+ if (!worker) return;
+ const scriptUrl = new URL(worker.scriptURL);
+ if (
+ scriptUrl.origin === location.origin &&
+ scriptUrl.pathname === '/service-worker.js'
+ ) {
+ await registration.unregister();
+ }
+ }),
+ );
+ const remaining = await navigator.serviceWorker.getRegistrations();
+ if (
+ remaining.some((registration) => {
+ const worker =
+ registration.active || registration.waiting || registration.installing;
+ if (!worker) return false;
+ const scriptUrl = new URL(worker.scriptURL);
+ return (
+ scriptUrl.origin === location.origin &&
+ scriptUrl.pathname === '/service-worker.js'
+ );
+ })
+ ) {
+ throw new Error('service_worker_retirement_failed');
+ }
}
- cacheDatabasePromise = null;
- await new Promise((resolve) => {
- const requestValue = indexedDB.deleteDatabase('conductor-pocket-v1');
- requestValue.onsuccess = resolve;
- requestValue.onerror = resolve;
- requestValue.onblocked = resolve;
- });
}
async function startApplication() {
@@ -623,6 +932,7 @@ async function startApplication() {
state.hiddenAt = null;
state.route = loadRoute();
state.workspacesLoaded = false;
+ await restorePendingDeliveries();
ensureShell();
updateRoutePanels();
const cachedWorkspaces = await cacheGet('workspaces');
@@ -634,6 +944,7 @@ async function startApplication() {
await loadRecentSessions();
await restoreRoute();
startEvents();
+ void recoverPendingDeliveries();
}
async function restoreRoute() {
@@ -1168,7 +1479,7 @@ async function openSession(sessionId, { workspaceId = state.route.workspaceId, p
}
async function refreshMessages(sessionId, { full = false } = {}) {
- if (!sessionId) return;
+ if (!sessionId) return [];
const cursor = full ? 0 : state.cursorsBySession.get(sessionId) || 0;
try {
const data = await request(
@@ -1178,14 +1489,16 @@ async function refreshMessages(sessionId, { full = false } = {}) {
const messages = full ? data.messages : [...existing, ...data.messages];
state.messagesBySession.set(sessionId, dedupeMessages(messages));
state.cursorsBySession.set(sessionId, data.cursor);
- reconcileOptimistic(sessionId);
+ const reconciled = reconcileOptimistic(sessionId);
await cacheSet(`messages:${sessionId}`, {
cursor: data.cursor,
messages: state.messagesBySession.get(sessionId).slice(-50),
});
if (state.route.sessionId === sessionId) renderTranscript();
+ return reconciled;
} catch (error) {
handleRuntimeError(error);
+ return [];
}
}
@@ -1199,15 +1512,16 @@ function dedupeMessages(messages) {
}
function reconcileOptimistic(sessionId) {
- const serverMessages = state.messagesBySession.get(sessionId) || [];
- for (const optimistic of state.optimistic.filter(
- (item) => item.sessionId === sessionId && item.delivery === 'delivered',
- )) {
- const found = serverMessages.some(
- (message) => message.kind === 'user' && message.text === optimistic.text,
- );
- if (found) state.optimistic = state.optimistic.filter((item) => item !== optimistic);
+ const result = reconcileDeliveryReceipts(
+ state.optimistic,
+ sessionId,
+ state.cursorsBySession.get(sessionId),
+ );
+ state.optimistic = result.remaining;
+ if (result.reconciled.length > 0) {
+ void persistPendingDeliveries();
}
+ return result.reconciled;
}
function currentSession() {
@@ -1339,18 +1653,34 @@ function renderMessage(message, toolResults) {
const meta = node('div', { className: 'message-meta' });
if (message.delivery === 'delivering') {
meta.textContent = 'Delivering…';
+ } else if (message.delivery === 'confirming') {
+ meta.textContent = 'Checking delivery…';
} else if (message.delivery === 'failed') {
meta.classList.add('failed');
+ const retrySafe = message.retrySafe === true;
meta.append(
icon('warn'),
- document.createTextNode('Failed to deliver · '),
+ document.createTextNode(
+ retrySafe ? 'Failed to deliver · ' : 'Delivery unconfirmed · ',
+ ),
node('button', {
className: 'message-retry',
type: 'button',
- text: 'Retry',
- on: { click: () => retryMessage(message) },
+ text: retrySafe ? 'Retry' : 'Check',
+ on: {
+ click: () =>
+ retrySafe ? retryMessage(message) : checkDelivery(message),
+ },
}),
);
+ } else if (
+ message.kind === 'optimistic' &&
+ message.delivery === 'delivered'
+ ) {
+ meta.append(
+ icon('checkDouble'),
+ document.createTextNode('Delivered · Syncing…'),
+ );
} else if (message.queued) {
meta.textContent = 'Queued';
} else {
@@ -1468,6 +1798,15 @@ async function sendCurrentMessage() {
createdAt: new Date().toISOString(),
};
state.optimistic.push(optimistic);
+ try {
+ await persistPendingDeliveries({ required: true });
+ } catch {
+ state.optimistic = state.optimistic.filter(
+ (item) => item !== optimistic,
+ );
+ announce('Message stayed in your draft because secure delivery storage was unavailable');
+ return;
+ }
field.value = '';
saveDraft(sessionId, '');
state.shell.composer.resize();
@@ -1486,6 +1825,16 @@ async function deliverOptimistic(
const deliveryKey = replaceDraft
? optimistic.replaceIdempotencyKey
: optimistic.idempotencyKey;
+ optimistic.activeDeliveryKey = deliveryKey;
+ try {
+ await persistPendingDeliveries({ required: true });
+ } catch {
+ optimistic.delivery = 'failed';
+ optimistic.errorCode = 'secure_delivery_storage_unavailable';
+ optimistic.retrySafe = true;
+ renderTranscript();
+ return;
+ }
try {
const result = await fetch(
`/api/sessions/${encodeURIComponent(optimistic.sessionId)}/messages`,
@@ -1512,10 +1861,11 @@ async function deliverOptimistic(
error.code = payload.error?.code || `http_${result.status}`;
error.status = result.status;
error.draft = payload.error?.draft;
+ error.retrySafe = payload.error?.retrySafe === true;
throw error;
}
- optimistic.delivery = 'delivered';
- optimistic.deliveredAt = payload.deliveredAt;
+ applyDeliveryReceipt(optimistic, payload);
+ await persistPendingDeliveries();
state.connectionProbe = {
sendPath: true,
capabilities: { send: true },
@@ -1524,21 +1874,158 @@ async function deliverOptimistic(
announce('Message delivered');
setTimeout(() => refreshMessages(optimistic.sessionId, { full: true }), 120);
} catch (error) {
- optimistic.delivery = 'failed';
optimistic.errorCode = error.code;
- renderTranscript();
if (error.code === 'draft_conflict') {
+ optimistic.delivery = 'failed';
+ optimistic.retrySafe = true;
+ await persistPendingDeliveries();
+ renderTranscript();
optimistic.macDraft = error.draft;
if (replaceDraft) optimistic.replaceIdempotencyKey = null;
optimistic.replaceDraft = false;
openDraftConflict(optimistic);
+ } else if (error.status === 401 || error.status === 423) {
+ optimistic.delivery = 'failed';
+ optimistic.retrySafe = false;
+ await persistPendingDeliveries();
+ renderTranscript();
+ handleRuntimeError(error);
+ } else if (!error.status) {
+ await checkDelivery(optimistic);
+ } else {
+ optimistic.delivery = 'failed';
+ optimistic.retrySafe = error.retrySafe === true;
+ await persistPendingDeliveries();
+ renderTranscript();
+ }
+ }
+}
+
+function applyDeliveryReceipt(message, receipt) {
+ message.delivery = 'delivered';
+ message.deliveredAt = receipt.deliveredAt;
+ message.receiptBaselineCursor = Number.isSafeInteger(receipt.baselineCursor)
+ ? receipt.baselineCursor
+ : null;
+ message.receiptRowId = Number.isSafeInteger(receipt.rowId)
+ ? receipt.rowId
+ : null;
+ message.retrySafe = false;
+}
+
+async function requestDeliveryStatus(message) {
+ const controller = new AbortController();
+ const timeout = setTimeout(
+ () => controller.abort(),
+ DELIVERY_STATUS_REQUEST_MS,
+ );
+ try {
+ const response = await fetch(
+ `/api/sessions/${encodeURIComponent(message.sessionId)}/delivery-status`,
+ {
+ method: 'POST',
+ credentials: 'same-origin',
+ cache: 'no-store',
+ signal: controller.signal,
+ headers: {
+ Accept: 'application/json',
+ 'X-CSRF-Token': state.csrfToken,
+ 'Idempotency-Key':
+ message.activeDeliveryKey || message.idempotencyKey,
+ },
+ },
+ );
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ const error = new Error(
+ payload.error?.code || `http_${response.status}`,
+ );
+ error.code = payload.error?.code || `http_${response.status}`;
+ error.status = response.status;
+ throw error;
}
- else if (error.status === 401 || error.status === 423) handleRuntimeError(error);
+ return payload.delivery || { state: 'unknown' };
+ } finally {
+ clearTimeout(timeout);
}
}
+async function checkDelivery(message) {
+ if (!state.optimistic.includes(message)) return true;
+ message.delivery = 'confirming';
+ message.retrySafe = false;
+ await persistPendingDeliveries();
+ renderTranscript();
+ const deadline = Date.now() + DELIVERY_RECOVERY_MS;
+ let lastError = null;
+ while (Date.now() < deadline) {
+ try {
+ const delivery = await requestDeliveryStatus(message);
+ if (delivery.state === 'delivered') {
+ applyDeliveryReceipt(message, delivery);
+ await persistPendingDeliveries();
+ renderTranscript();
+ announce('Message delivered');
+ await refreshMessages(message.sessionId, { full: true });
+ return true;
+ }
+ if (delivery.state !== 'pending') {
+ message.delivery = 'failed';
+ message.errorCode =
+ delivery.state === 'failed'
+ ? delivery.code
+ : 'delivery_unknown';
+ message.retrySafe =
+ delivery.state === 'failed' &&
+ delivery.retrySafe === true;
+ await persistPendingDeliveries();
+ renderTranscript();
+ return false;
+ }
+ } catch (error) {
+ lastError = error;
+ if (error.status === 401 || error.status === 423) {
+ message.delivery = 'failed';
+ message.errorCode = error.code;
+ message.retrySafe = false;
+ await persistPendingDeliveries();
+ renderTranscript();
+ handleRuntimeError(error);
+ return false;
+ }
+ }
+ const remaining = deadline - Date.now();
+ if (remaining > 0) {
+ await new Promise((resolve) =>
+ setTimeout(resolve, Math.min(500, remaining)),
+ );
+ }
+ }
+ message.delivery = 'failed';
+ message.errorCode = lastError?.code || 'delivery_confirmation_timeout';
+ message.retrySafe = false;
+ await persistPendingDeliveries();
+ renderTranscript();
+ return false;
+}
+
+async function recoverPendingDeliveries() {
+ await Promise.all(
+ state.optimistic.map(async (message) => {
+ if (message.delivery === 'delivered') {
+ await refreshMessages(message.sessionId, { full: true });
+ } else if (!(message.delivery === 'failed' && message.retrySafe)) {
+ await checkDelivery(message);
+ }
+ }),
+ );
+}
+
function retryMessage(message) {
+ if (message.retrySafe !== true) return;
message.delivery = 'delivering';
+ message.retrySafe = false;
+ void persistPendingDeliveries();
renderTranscript();
deliverOptimistic(message, { replaceDraft: message.replaceDraft === true });
}
@@ -1567,6 +2054,7 @@ function openDraftConflict(message) {
on: {
click: () => {
message.delivery = 'delivering';
+ void persistPendingDeliveries();
closeOverlay();
renderTranscript();
deliverOptimistic(message, {
@@ -1585,6 +2073,7 @@ function openDraftConflict(message) {
state.shell.composer.field.value = message.text;
saveDraft(message.sessionId, message.text);
state.optimistic = state.optimistic.filter((item) => item !== message);
+ void persistPendingDeliveries();
closeOverlay();
renderTranscript();
},
@@ -1623,8 +2112,7 @@ function startEvents() {
// Keep the locked fallback.
}
if (code === 'device_revoked' || code === 'authentication_required') {
- await purgeLocalData();
- renderSignedOut();
+ await purgeThenRenderSignedOut();
} else {
renderLock();
}
@@ -1674,9 +2162,11 @@ function renderConnectionState() {
function handleRuntimeError(error) {
if (error.status === 401 || error.code === 'device_revoked') {
- purgeLocalData().finally(renderSignedOut);
+ void purgeThenRenderSignedOut();
} else if (error.status === 423 || error.code === 'device_locked') {
renderLock();
+ } else if (error.code === 'retirement_client_upgrade_required') {
+ renderConnectionGate(error.code);
}
}
@@ -1883,7 +2373,7 @@ async function openSecurity() {
settingsRow({
iconName: 'bolt',
title: 'Conductor Pocket',
- subtitle: `pocket ${connection.relayVersion || 'unknown'} · private relay`,
+ subtitle: `relay ${connection.relayVersion || 'unknown'} · client ${CLIENT_VERSION}`,
}),
);
content.append(appSection.root);
@@ -1944,10 +2434,19 @@ function confirmClearCache() {
text: 'Clear',
on: {
click: async () => {
- await purgeLocalData();
- state.messagesBySession.clear();
- closeOverlay();
- await startApplication();
+ try {
+ await clearTranscriptCache();
+ state.messagesBySession.clear();
+ closeOverlay();
+ await startApplication();
+ } catch (error) {
+ if (isLocalPurgeFailure(error)) {
+ closeOverlay();
+ renderLocalPurgeFailure(() => confirmClearCache());
+ } else {
+ handleRuntimeError(error);
+ }
+ }
},
},
}),
@@ -1979,18 +2478,36 @@ function confirmRevoke(device) {
on: {
click: async () => {
try {
+ const currentDevice = device.id === state.auth?.device?.id;
+ if (currentDevice) await purgeLocalData();
const result = await request(
`/api/devices/${encodeURIComponent(device.id)}/revoke`,
- { method: 'POST', body: {}, csrf: true },
+ {
+ method: 'POST',
+ body: currentDevice
+ ? {
+ clientVersion: CLIENT_VERSION,
+ localPurgeCompleted: true,
+ }
+ : {},
+ csrf: true,
+ },
);
if (result.currentDevice) {
- await purgeLocalData();
renderSignedOut();
} else {
openSecurity();
}
} catch (error) {
- handleRuntimeError(error);
+ if (
+ device.id === state.auth?.device?.id &&
+ isLocalPurgeFailure(error)
+ ) {
+ closeOverlay();
+ renderLocalPurgeFailure(() => confirmRevoke(device));
+ } else {
+ handleRuntimeError(error);
+ }
}
},
},
@@ -2090,7 +2607,9 @@ window.addEventListener('pageshow', () => {
});
if ('serviceWorker' in navigator) {
- navigator.serviceWorker.register('/service-worker.js').catch(() => {});
+ serviceWorkerRegistrationPromise =
+ navigator.serviceWorker.register('/service-worker.js');
+ serviceWorkerRegistrationPromise.catch(() => {});
}
const pairingCode = new URLSearchParams(location.hash.slice(1)).get('pair');
diff --git a/tools/conductor-pocket/public/delivery-receipts.js b/tools/conductor-pocket/public/delivery-receipts.js
new file mode 100644
index 00000000..93a33117
--- /dev/null
+++ b/tools/conductor-pocket/public/delivery-receipts.js
@@ -0,0 +1,35 @@
+function validCursor(value) {
+ return Number.isSafeInteger(value) && value >= 0;
+}
+
+export function receiptReachedTranscript(message, transcriptCursor) {
+ if (message.delivery !== 'delivered' || !validCursor(transcriptCursor)) {
+ return false;
+ }
+ if (validCursor(message.receiptRowId)) {
+ return transcriptCursor >= message.receiptRowId;
+ }
+ if (validCursor(message.receiptBaselineCursor)) {
+ return transcriptCursor > message.receiptBaselineCursor;
+ }
+ return false;
+}
+
+export function reconcileDeliveryReceipts(
+ optimisticMessages,
+ sessionId,
+ transcriptCursor,
+) {
+ const reconciled = [];
+ const remaining = optimisticMessages.filter((message) => {
+ if (
+ message.sessionId !== sessionId ||
+ !receiptReachedTranscript(message, transcriptCursor)
+ ) {
+ return true;
+ }
+ reconciled.push(message);
+ return false;
+ });
+ return { remaining, reconciled };
+}
diff --git a/tools/conductor-pocket/public/index.html b/tools/conductor-pocket/public/index.html
index 5135b83b..00359e0f 100644
--- a/tools/conductor-pocket/public/index.html
+++ b/tools/conductor-pocket/public/index.html
@@ -13,7 +13,7 @@
-
+