relay-companion 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/relay.js CHANGED
@@ -11,6 +11,7 @@ import { writeConfig, readConfig, apiUrl, DEFAULT_API_URL, DEFAULT_WEB_URL } fro
11
11
  import { runTaskDaemon, pollTaskRuntimeOnce } from "../src/task-daemon.js";
12
12
  import { runMcpServer } from "../src/mcp.js";
13
13
  import { runSetupInstall, runUninstall } from "../src/install.js";
14
+ import { runUpdateOnce } from "../src/auto-update.js";
14
15
  import { liveToolRequirement, requiredLiveHosts, shouldRequireLiveTools } from "../src/setup-activation.js";
15
16
  import { openRelay, openTask } from "../src/materializer.js";
16
17
  import { resetCompanionStateForAccount } from "../src/notifications.js";
@@ -311,6 +312,9 @@ async function main() {
311
312
  return cmdPill();
312
313
  case "mcp":
313
314
  return runMcpServer();
315
+ case "update":
316
+ case "self-update":
317
+ return runUpdateOnce().then(() => {});
314
318
  default:
315
319
  console.log(
316
320
  [
@@ -331,6 +335,7 @@ async function main() {
331
335
  " relay daemon [--interval MS] Run the task runtime daemon",
332
336
  " relay pill Launch the desktop Relay companion pill",
333
337
  " relay mcp Run the MCP server (for your agent)",
338
+ " relay update Update to the latest published companion and restart",
334
339
  " relay whoami Show the paired account",
335
340
  ].join("\n"),
336
341
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,275 @@
1
+ // Companion self-update.
2
+ //
3
+ // The companion ships as an npm package (`relay-companion`) run by two launchd
4
+ // agents (work.relay.companion = daemon, work.relay.companion.pill = overlay),
5
+ // both pointing at ABSOLUTE paths inside the global install. Publishing a new
6
+ // version to npm is the production deploy — but nothing pulled it onto users'
7
+ // machines, so a click-routing fix (or any fix) only reached people who manually
8
+ // re-ran `npm i -g relay-companion@latest`.
9
+ //
10
+ // This closes that loop: the daemon (the single always-on owner, kept alive by
11
+ // launchd KeepAlive) periodically asks the npm registry for the latest published
12
+ // version and, when it is newer than the running one, runs `npm i -g
13
+ // relay-companion@latest` and restarts both launchd services. `npm i -g` overwrites
14
+ // the same install directory in place, so the plists' absolute paths stay valid and
15
+ // `launchctl kickstart -k` relaunches with the new code.
16
+ //
17
+ // Bootstrap caveat: a machine only auto-updates once it is already running a build
18
+ // that CONTAINS this updater. Older installs still need one manual update to opt in.
19
+
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import fs from "node:fs";
23
+ import { spawn } from "node:child_process";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ export const PACKAGE_NAME = "relay-companion";
27
+ const DAEMON_LABEL = "work.relay.companion";
28
+ const PILL_LABEL = "work.relay.companion.pill";
29
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org";
30
+ const DEFAULT_CHECK_INTERVAL_MS = 30 * 60 * 1000; // poll the registry at most every 30 min
31
+ const RETRY_COOLDOWN_MS = 30 * 60 * 1000; // if an update was launched but we're still alive, wait before retrying
32
+
33
+ // ---- pure, unit-tested helpers ------------------------------------------
34
+
35
+ // Parse "x.y.z" (tolerating a leading v and any build/prerelease suffix) into
36
+ // numeric parts, or null if it doesn't look like a release version.
37
+ export function parseVersion(v) {
38
+ const m = /^\s*v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || ""));
39
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
40
+ }
41
+
42
+ // True iff `candidate` is a strictly higher release than `current`. Conservative:
43
+ // any unparseable input returns false, so a garbage registry reply never triggers
44
+ // an update. Numeric-per-component so 0.1.10 correctly beats 0.1.9.
45
+ export function isNewerVersion(candidate, current) {
46
+ const a = parseVersion(candidate);
47
+ const b = parseVersion(current);
48
+ if (!a || !b) return false;
49
+ for (let i = 0; i < 3; i += 1) {
50
+ if (a[i] > b[i]) return true;
51
+ if (a[i] < b[i]) return false;
52
+ }
53
+ return false;
54
+ }
55
+
56
+ export function latestFromRegistryDoc(doc) {
57
+ const latest = doc && doc["dist-tags"] && doc["dist-tags"].latest;
58
+ return typeof latest === "string" && latest ? latest : null;
59
+ }
60
+
61
+ // Only ever self-update a globally npm-installed copy. A dev checkout lives at
62
+ // .../packages/companion (NOT under node_modules), so `npm i -g` would install a
63
+ // SEPARATE global copy the plists don't point at — clobbering nothing useful and
64
+ // confusing the developer. Guard against that.
65
+ export function isManagedInstall(packageRoot) {
66
+ return /[\\/]node_modules[\\/]relay-companion[\\/]?$/.test(String(packageRoot || ""));
67
+ }
68
+
69
+ export function autoUpdateEnabled(env = process.env) {
70
+ const raw = String(env.RELAY_AUTO_UPDATE ?? "").trim().toLowerCase();
71
+ return !(raw === "0" || raw === "false" || raw === "off" || raw === "no");
72
+ }
73
+
74
+ // ---- environment probes -------------------------------------------------
75
+
76
+ function moduleDir() {
77
+ return path.dirname(fileURLToPath(import.meta.url));
78
+ }
79
+
80
+ export function companionPackageRoot() {
81
+ return path.resolve(moduleDir(), "..");
82
+ }
83
+
84
+ export function currentCompanionVersion(packageRoot = companionPackageRoot()) {
85
+ try {
86
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
87
+ return typeof pkg.version === "string" ? pkg.version : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ // Ask the npm registry for the current `latest` dist-tag using the lightweight
94
+ // abbreviated-metadata document. Returns the version string, or null on any error
95
+ // (offline, timeout, unexpected shape) so the caller simply skips this cycle.
96
+ export async function fetchLatestVersion({
97
+ fetchImpl = globalThis.fetch,
98
+ timeoutMs = 10_000,
99
+ registry = DEFAULT_REGISTRY,
100
+ } = {}) {
101
+ if (typeof fetchImpl !== "function") return null;
102
+ try {
103
+ const res = await fetchImpl(`${registry}/${PACKAGE_NAME}`, {
104
+ headers: { Accept: "application/vnd.npm.install-v1+json" },
105
+ signal: AbortSignal.timeout(timeoutMs),
106
+ });
107
+ if (!res || !res.ok) return null;
108
+ return latestFromRegistryDoc(await res.json());
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ // ---- update execution ---------------------------------------------------
115
+
116
+ function updateLogPath() {
117
+ return path.join(os.homedir(), ".relay", "update.log");
118
+ }
119
+
120
+ // Launch the install + restart in a FULLY DETACHED shell (its own session via
121
+ // `detached`), so it survives the daemon being killed when we restart it. The
122
+ // script is hardened against the ways an unattended, in-place `npm i -g` can go
123
+ // wrong on a fleet of machines:
124
+ // - Single-writer lock (mkdir, with stale-steal) so a manual `relay update` and
125
+ // the daemon can't run concurrent global installs into the same dir.
126
+ // - Verify the freshly-written tree is loadable (electron binary present + the
127
+ // bin + a valid package.json) BEFORE restarting anything, so a partial install
128
+ // — e.g. electron's postinstall binary download failing mid-way — is never
129
+ // launched. The pill plist hard-points at electron, so a broken tree would
130
+ // otherwise brick the pill on its next relaunch.
131
+ // - On install or verify failure, reinstall the CURRENT version to restore a
132
+ // known-good on-disk tree, then leave the running processes alone.
133
+ // - Restart the pill first and the daemon LAST — kicking the daemon SIGKILLs this
134
+ // process's parent, but this detached shell has already issued everything before
135
+ // that and launchd relaunches the daemon with the freshly installed code.
136
+ export function spawnDetachedUpdate({
137
+ uid = typeof process.getuid === "function" ? process.getuid() : 0,
138
+ currentVersion = null,
139
+ logPath = updateLogPath(),
140
+ lockPath = path.join(os.homedir(), ".relay", "update.lock"),
141
+ spawnImpl = spawn,
142
+ log = () => {},
143
+ ensureDir = true,
144
+ } = {}) {
145
+ if (ensureDir) {
146
+ try {
147
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
148
+ } catch {}
149
+ }
150
+ const q = (s) => `'${String(s).replaceAll("'", "'\\''")}'`; // safe single-quote for the shell
151
+ const LOG = q(logPath);
152
+ const LOCK = q(lockPath);
153
+ // Restore target if the new install is broken: reinstall the version we're running
154
+ // now so the on-disk tree stays loadable for the next launchd relaunch. Fall back
155
+ // to @latest only if we somehow don't know our own version.
156
+ const restore = parseVersion(currentVersion) ? currentVersion : "latest";
157
+ const script = [
158
+ `export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"`,
159
+ // Hold the lock path in a shell var and reference "$LOCK" everywhere so every use
160
+ // (including the single-quoted trap action) stays correct even if the home path
161
+ // contains a space — nesting the pre-quoted path inside the trap's own quotes would
162
+ // otherwise word-split and silently drop the EXIT trap, leaking the lock.
163
+ `LOCK=${LOCK}`,
164
+ `mkdir -p "$(dirname "$LOCK")" 2>/dev/null`,
165
+ // Steal a stale lock left by a crashed prior run (older than 20 min > any real install).
166
+ `if [ -d "$LOCK" ] && [ -n "$(find "$LOCK" -maxdepth 0 -mmin +20 2>/dev/null)" ]; then rmdir "$LOCK" 2>/dev/null; fi`,
167
+ // Single-writer: a second updater (manual \`relay update\`, or a daemon race) no-ops.
168
+ `if ! mkdir "$LOCK" 2>/dev/null; then echo "[relay-update] $(date) another update in progress; skipping" >> ${LOG} 2>&1; exit 0; fi`,
169
+ `trap 'rmdir "$LOCK" 2>/dev/null' EXIT INT TERM`,
170
+ `echo "[relay-update] $(date) installing ${PACKAGE_NAME}@latest (current ${restore})" >> ${LOG} 2>&1`,
171
+ `if npm install -g ${PACKAGE_NAME}@latest >> ${LOG} 2>&1; then`,
172
+ ` ROOT="$(npm root -g 2>/dev/null)"`,
173
+ ` PKG="$ROOT/${PACKAGE_NAME}"`,
174
+ ` ELECTRON="$PKG/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron"`,
175
+ ` if [ -x "$ELECTRON" ] && [ -f "$PKG/bin/relay.js" ] && grep -q '"version"' "$PKG/package.json" 2>/dev/null; then`,
176
+ ` echo "[relay-update] $(date) install verified; restarting services" >> ${LOG} 2>&1`,
177
+ ` /bin/launchctl kickstart -k gui/${uid}/${PILL_LABEL} >> ${LOG} 2>&1 || true`,
178
+ ` /bin/launchctl kickstart -k gui/${uid}/${DAEMON_LABEL} >> ${LOG} 2>&1 || true`,
179
+ ` else`,
180
+ ` echo "[relay-update] $(date) post-install verify FAILED; restoring ${restore}" >> ${LOG} 2>&1`,
181
+ ` npm install -g ${PACKAGE_NAME}@${restore} >> ${LOG} 2>&1 || true`,
182
+ ` fi`,
183
+ `else`,
184
+ ` echo "[relay-update] $(date) install FAILED; restoring ${restore}" >> ${LOG} 2>&1`,
185
+ ` npm install -g ${PACKAGE_NAME}@${restore} >> ${LOG} 2>&1 || true`,
186
+ `fi`,
187
+ ].join("\n");
188
+ const child = spawnImpl("/bin/sh", ["-c", script], { detached: true, stdio: "ignore" });
189
+ // A detached child with NO 'error' listener turns an async spawn failure (EMFILE,
190
+ // EAGAIN, ENOENT on /bin/sh) into an unhandled 'error' that would crash this
191
+ // always-on daemon. Swallow it — the update simply doesn't happen this cycle.
192
+ if (child && typeof child.on === "function") {
193
+ child.on("error", (err) => log(`auto-update spawn error: ${err && err.message ? err.message : String(err)}`));
194
+ }
195
+ if (child && typeof child.unref === "function") child.unref();
196
+ return child;
197
+ }
198
+
199
+ // ---- orchestrator -------------------------------------------------------
200
+
201
+ // Build a rate-limited auto-update checker. Call `tick()` freely (e.g. once per
202
+ // daemon poll); it no-ops cheaply until a check is due, then fetches the latest
203
+ // version and, if newer, launches the detached update-and-restart. Every effectful
204
+ // dependency is injectable so the decision logic is unit-testable without network,
205
+ // npm, or launchd. `tick()` never throws.
206
+ export function createAutoUpdater({
207
+ now = () => Date.now(),
208
+ env = process.env,
209
+ packageRoot = companionPackageRoot(),
210
+ getCurrentVersion = () => currentCompanionVersion(packageRoot),
211
+ getLatestVersion = (opts) => fetchLatestVersion(opts),
212
+ spawnUpdate = (opts) => spawnDetachedUpdate(opts),
213
+ checkIntervalMs = Number(env.RELAY_UPDATE_CHECK_INTERVAL_MS) || DEFAULT_CHECK_INTERVAL_MS,
214
+ retryCooldownMs = RETRY_COOLDOWN_MS,
215
+ log = () => {},
216
+ } = {}) {
217
+ const state = { lastCheckAt: 0, updateStartedAt: 0, updating: false };
218
+
219
+ async function tick() {
220
+ if (!autoUpdateEnabled(env)) return { status: "disabled" };
221
+ if (!isManagedInstall(packageRoot)) return { status: "unmanaged" };
222
+ const t = now();
223
+ // An update was launched but we're still alive (install failed, or restart is
224
+ // pending) — hold off re-triggering until the cooldown so we don't hammer npm.
225
+ if (state.updating && t - state.updateStartedAt < retryCooldownMs) return { status: "in-flight" };
226
+ if (state.lastCheckAt && t - state.lastCheckAt < checkIntervalMs) return { status: "not-due" };
227
+ state.lastCheckAt = t;
228
+
229
+ const current = getCurrentVersion();
230
+ const latest = await getLatestVersion();
231
+ if (!latest) return { status: "check-failed", current };
232
+ if (!isNewerVersion(latest, current)) return { status: "up-to-date", current, latest };
233
+
234
+ log(`auto-update: ${current} -> ${latest}; installing latest and restarting`);
235
+ state.updating = true;
236
+ state.updateStartedAt = t;
237
+ try {
238
+ spawnUpdate({ log, currentVersion: current });
239
+ } catch (err) {
240
+ state.updating = false;
241
+ log(`auto-update launch failed: ${err && err.message ? err.message : String(err)}`);
242
+ return { status: "launch-failed", current, latest };
243
+ }
244
+ return { status: "updating", current, latest };
245
+ }
246
+
247
+ return { tick, state };
248
+ }
249
+
250
+ // One-shot for the `relay update` CLI: check once and, if a newer version exists,
251
+ // launch the update. Prints a human-readable line. Bypasses the interval gate.
252
+ export async function runUpdateOnce({ log = (m) => console.log(`[relay] ${m}`) } = {}) {
253
+ const updater = createAutoUpdater({ checkIntervalMs: 0, log });
254
+ const result = await updater.tick();
255
+ switch (result.status) {
256
+ case "updating":
257
+ log(`updating ${result.current} -> ${result.latest}; the pill and daemon will restart shortly.`);
258
+ break;
259
+ case "up-to-date":
260
+ log(`already on the latest version (${result.current}).`);
261
+ break;
262
+ case "unmanaged":
263
+ log("not a global npm install (dev checkout?) — skipping self-update.");
264
+ break;
265
+ case "disabled":
266
+ log("auto-update is disabled (RELAY_AUTO_UPDATE).");
267
+ break;
268
+ case "check-failed":
269
+ log("could not reach the npm registry to check for updates.");
270
+ break;
271
+ default:
272
+ log(`update check: ${result.status}`);
273
+ }
274
+ return result;
275
+ }
@@ -78,87 +78,110 @@ export async function notifyCodexDesktopThreads({
78
78
  return { attempted: true, ok: results.some((result) => result.ok), results };
79
79
  }
80
80
 
81
- export function buildCodexDesktopRefreshExpression({ threadIds = [], pinnedThreadIds = null, openThreadId = null } = {}) {
82
- const rendererCode = `(${async function relayRefreshCodexRenderer(payload) {
83
- const hostId = "local";
84
- const bridge = window.electronBridge;
85
- if (!bridge?.sendMessageFromView) return { ok: false, reason: "missing-electron-bridge" };
81
+ // Runs INSIDE each Codex renderer: it is serialized to a string via toString()
82
+ // and evaluated in the window's page context, so it must be self-contained — no
83
+ // imports, only renderer globals (window, location). Exported at module scope so
84
+ // the thread-routing decision can be unit-tested directly.
85
+ export async function relayRefreshCodexRenderer(payload) {
86
+ const hostId = "local";
87
+ const bridge = window.electronBridge;
88
+ if (!bridge?.sendMessageFromView) return { ok: false, reason: "missing-electron-bridge" };
86
89
 
87
- const sent = [];
88
- async function send(message) {
89
- try {
90
- await bridge.sendMessageFromView(message);
91
- sent.push({ type: message.type, ok: true });
92
- } catch (error) {
93
- sent.push({ type: message.type, ok: false, error: String(error?.message || error) });
94
- }
90
+ const sent = [];
91
+ async function send(message) {
92
+ try {
93
+ await bridge.sendMessageFromView(message);
94
+ sent.push({ type: message.type, ok: true });
95
+ } catch (error) {
96
+ sent.push({ type: message.type, ok: false, error: String(error?.message || error) });
95
97
  }
98
+ }
96
99
 
97
- if (Array.isArray(payload.pinnedThreadIds)) {
98
- await send({ type: "set-pinned-thread-ids-for-host", hostId, threadIds: payload.pinnedThreadIds });
99
- }
100
- if (payload.threadIds.length) {
101
- await send({ type: "hydrate-pinned-threads", hostId, threadIds: payload.threadIds });
102
- }
103
- await send({ type: "refresh-recent-conversations-for-host", hostId });
104
- for (const threadId of payload.threadIds) {
105
- // Codex Desktop (>=0.142) serves thread views from its in-memory core and
106
- // never cold-loads externally created threads — the view spins forever
107
- // until an app restart. Adopt the thread the way the app itself opens one:
108
- // drop any stale cache entry, ask the tail-hydration path to load history
109
- // (feature-gated; harmless when off — dependentConversationIds is REQUIRED
110
- // or the handler throws), then maybe-resume-conversation, which activates
111
- // the thread summary and resumes the conversation from the rollout on disk.
112
- await send({ type: "discard-conversation-from-cache", conversationId: threadId });
113
- await send({ type: "ensure-conversation-history-loaded", conversationId: threadId, dependentConversationIds: [] });
114
- await send({
115
- type: "maybe-resume-conversation",
116
- hostId,
117
- conversationId: threadId,
118
- model: null,
119
- serviceTier: null,
120
- reasoningEffort: null,
121
- workspaceRoots: [],
122
- collaborationMode: null,
123
- });
124
- await send({ type: "broadcast-conversation-snapshot-for-host", hostId, conversationId: threadId });
125
- }
126
- if (payload.openThreadId) {
127
- try {
128
- window.postMessage(
129
- {
130
- type: "navigate-to-route",
131
- path: `/hotkey-window/thread/${encodeURIComponent(String(payload.openThreadId))}`,
132
- },
133
- location.origin,
134
- );
135
- sent.push({ type: "navigate-to-route", ok: true, threadId: payload.openThreadId });
136
- } catch (error) {
137
- sent.push({ type: "navigate-to-route", ok: false, error: String(error?.message || error) });
138
- }
100
+ if (Array.isArray(payload.pinnedThreadIds)) {
101
+ await send({ type: "set-pinned-thread-ids-for-host", hostId, threadIds: payload.pinnedThreadIds });
102
+ }
103
+ if (payload.threadIds.length) {
104
+ await send({ type: "hydrate-pinned-threads", hostId, threadIds: payload.threadIds });
105
+ }
106
+ await send({ type: "refresh-recent-conversations-for-host", hostId });
107
+ for (const threadId of payload.threadIds) {
108
+ // Codex Desktop (>=0.142) serves thread views from its in-memory core and
109
+ // never cold-loads externally created threads — the view spins forever
110
+ // until an app restart. Adopt the thread the way the app itself opens one:
111
+ // drop any stale cache entry, ask the tail-hydration path to load history
112
+ // (feature-gated; harmless when off — dependentConversationIds is REQUIRED
113
+ // or the handler throws), then maybe-resume-conversation, which activates
114
+ // the thread summary and resumes the conversation from the rollout on disk.
115
+ await send({ type: "discard-conversation-from-cache", conversationId: threadId });
116
+ await send({ type: "ensure-conversation-history-loaded", conversationId: threadId, dependentConversationIds: [] });
117
+ await send({
118
+ type: "maybe-resume-conversation",
119
+ hostId,
120
+ conversationId: threadId,
121
+ model: null,
122
+ serviceTier: null,
123
+ reasoningEffort: null,
124
+ workspaceRoots: [],
125
+ collaborationMode: null,
126
+ });
127
+ await send({ type: "broadcast-conversation-snapshot-for-host", hostId, conversationId: threadId });
128
+ }
129
+ if (payload.openThreadId) {
130
+ try {
131
+ const encoded = encodeURIComponent(String(payload.openThreadId));
132
+ // Codex renders a thread through two separate router surfaces: the main
133
+ // window (sidebar + recents rail) at /local/<id>, and the borderless hotkey
134
+ // "quick chat" overlay at /hotkey-window/thread/<id>. The app's own
135
+ // navigation chooses between them by whether the current window is the
136
+ // hotkey one (its QH() keys off location.pathname). Mirror that here so the
137
+ // thread opens in whichever surface the user is actually in — otherwise
138
+ // every open is forced into the overlay even when the main window is up.
139
+ const path = location.pathname.startsWith("/hotkey-window")
140
+ ? `/hotkey-window/thread/${encoded}`
141
+ : `/local/${encoded}`;
142
+ window.postMessage({ type: "navigate-to-route", path }, location.origin);
143
+ sent.push({ type: "navigate-to-route", ok: true, threadId: payload.openThreadId, path });
144
+ } catch (error) {
145
+ sent.push({ type: "navigate-to-route", ok: false, error: String(error?.message || error) });
139
146
  }
147
+ }
148
+
149
+ return { ok: sent.some((item) => item.ok), href: location.href, openThreadId: payload.openThreadId || null, sent };
150
+ }
140
151
 
141
- return { ok: sent.some((item) => item.ok), href: location.href, openThreadId: payload.openThreadId || null, sent };
142
- }.toString()})(${JSON.stringify({
152
+ export function buildCodexDesktopRefreshExpression({ threadIds = [], pinnedThreadIds = null, openThreadId = null } = {}) {
153
+ const rendererCode = `(${relayRefreshCodexRenderer.toString()})(${JSON.stringify({
143
154
  threadIds: uniqueStrings(threadIds),
144
155
  pinnedThreadIds,
145
156
  openThreadId: String(openThreadId || "").trim() || null,
146
157
  })})`;
158
+ // Only bring a window forward when we're actually opening a thread.
159
+ const wantsFocus = Boolean(String(openThreadId || "").trim());
147
160
 
148
161
  return `(async () => {
149
162
  const req = process.mainModule?.require || process.getBuiltinModule("module").createRequire(process.cwd() + "/");
150
163
  const { BrowserWindow } = req("electron");
151
164
  const code = ${JSON.stringify(rendererCode)};
165
+ const wantsFocus = ${JSON.stringify(wantsFocus)};
152
166
  const windows = [];
153
167
  for (const win of BrowserWindow.getAllWindows()) {
154
168
  if (win.isDestroyed()) continue;
155
169
  try {
156
- windows.push({
157
- id: win.id,
158
- visible: win.isVisible(),
159
- url: win.webContents.getURL(),
160
- result: await win.webContents.executeJavaScript(code, true),
161
- });
170
+ const url = win.webContents.getURL();
171
+ const result = await win.webContents.executeJavaScript(code, true);
172
+ // Bring the main window forward after navigating it to the thread, the way
173
+ // Codex's own restoreShowAndFocusWindow does. The hotkey overlay manages
174
+ // its own visibility, so leave it alone and only surface the main window.
175
+ let focused = false;
176
+ if (wantsFocus && !String(url || "").includes("/hotkey-window")) {
177
+ try {
178
+ if (win.isMinimized()) win.restore();
179
+ win.show();
180
+ win.focus();
181
+ focused = true;
182
+ } catch {}
183
+ }
184
+ windows.push({ id: win.id, visible: win.isVisible(), url, focused, result });
162
185
  } catch (error) {
163
186
  windows.push({ id: win.id, ok: false, error: String(error?.message || error) });
164
187
  }
@@ -16,6 +16,7 @@ import {
16
16
  reconcileAnsweredQuestions,
17
17
  stagePlainRelayItem as defaultStagePlainRelayItem,
18
18
  } from "./notifications.js";
19
+ import { createAutoUpdater } from "./auto-update.js";
19
20
 
20
21
  function idempotencyKey(prefix) {
21
22
  return `${prefix}_${randomUUID()}`;
@@ -262,7 +263,26 @@ export async function runTaskDaemon({ intervalMs = 4000 } = {}) {
262
263
  console.log(`[relay] task runtime for ${me.user.email}; polling every ${intervalMs}ms`);
263
264
  // eslint-disable-next-line no-console
264
265
  const log = (m) => console.log(`[relay] ${m}`);
266
+ // Keep this machine current with production: the daemon is the always-on owner, so
267
+ // it (not the pill) checks npm for a newer companion and self-updates. Rate-limited
268
+ // internally, so calling it every loop is cheap; a real check hits the network at
269
+ // most every ~30 min. Never throws.
270
+ const autoUpdater = createAutoUpdater({ log });
265
271
  for (;;) {
272
+ // Fire-and-forget: tick() rate-limits itself and never rejects, and task polling
273
+ // must NOT block on a registry fetch (a black-holed socket could stall it ~10s).
274
+ // The internal lastCheckAt/in-flight guards prevent overlapping checks or installs.
275
+ autoUpdater
276
+ .tick()
277
+ .then((update) => {
278
+ if (update.status === "updating") {
279
+ log(`self-update launched (${update.current} -> ${update.latest}); restarting shortly`);
280
+ } else if (update.status === "launch-failed") {
281
+ log(`self-update launch failed for ${update.current} -> ${update.latest}`);
282
+ }
283
+ })
284
+ // eslint-disable-next-line no-console
285
+ .catch((err) => console.error(`[relay] auto-update check error: ${err && err.message ? err.message : err}`));
266
286
  try {
267
287
  const result = await pollTaskRuntimeOnce({ client, log });
268
288
  if (