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 +5 -0
- package/package.json +1 -1
- package/src/auto-update.js +275 -0
- package/src/codex-desktop.js +86 -63
- package/src/task-daemon.js +20 -0
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
|
@@ -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
|
+
}
|
package/src/codex-desktop.js
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
}
|
package/src/task-daemon.js
CHANGED
|
@@ -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 (
|