@kyleroche/relay 0.2.0 → 0.2.2

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/dist/relay-mcp.js CHANGED
@@ -4231,9 +4231,9 @@ import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as
4231
4231
  import { fileURLToPath as fileURLToPath2 } from "url";
4232
4232
  import { dirname as dirname3, join as join7 } from "path";
4233
4233
  var AFTER_APPLY = {
4234
- 10: async (db, log) => {
4234
+ 10: async (db, log2) => {
4235
4235
  const n = await backfillTaskState(db);
4236
- if (n) log(`schema: cached the state of ${n} task${n === 1 ? "" : "s"}`);
4236
+ if (n) log2(`schema: cached the state of ${n} task${n === 1 ? "" : "s"}`);
4237
4237
  }
4238
4238
  };
4239
4239
  var beside = join7(dirname3(fileURLToPath2(import.meta.url)), "migrations");
@@ -4264,12 +4264,12 @@ async function currentVersion(db) {
4264
4264
  return typeof v === "number" ? v : typeof v === "string" ? Number(v) || 0 : 0;
4265
4265
  }
4266
4266
  async function migrate(db, opts = {}) {
4267
- const log = opts.log ?? noopLog;
4267
+ const log2 = opts.log ?? noopLog;
4268
4268
  const have = await currentVersion(db);
4269
4269
  let applied = have;
4270
4270
  for (const m of loadMigrations(opts.dir)) {
4271
4271
  if (m.version <= have) continue;
4272
- log(`schema: applying ${String(m.version).padStart(3, "0")}_${m.name}`);
4272
+ log2(`schema: applying ${String(m.version).padStart(3, "0")}_${m.name}`);
4273
4273
  await db.transaction(async (tx) => {
4274
4274
  await tx.exec(m.sql);
4275
4275
  await tx.query(
@@ -4278,7 +4278,7 @@ async function migrate(db, opts = {}) {
4278
4278
  [JSON.stringify(m.version)]
4279
4279
  );
4280
4280
  });
4281
- await AFTER_APPLY[m.version]?.(db, log);
4281
+ await AFTER_APPLY[m.version]?.(db, log2);
4282
4282
  applied = m.version;
4283
4283
  }
4284
4284
  return applied;
@@ -5134,6 +5134,11 @@ var defaultActor = () => `human:${process.env.RELAY_ACTOR ?? process.env.USER ??
5134
5134
  async function refreshSiteTokenIfStale(cfg, now = Date.now()) {
5135
5135
  if (!cfg.site || !cfg.siteToken || !cfg.siteRefreshToken || !tokenIsStale(cfg.siteTokenExpiresAt, now)) return cfg;
5136
5136
  if (cfg.siteTokenSource === "env") return cfg;
5137
+ return refreshSiteToken(cfg, now);
5138
+ }
5139
+ var canRefreshSiteToken = (cfg) => !!cfg.site && !!cfg.siteRefreshToken && cfg.siteTokenSource !== "env";
5140
+ async function refreshSiteToken(cfg, now = Date.now()) {
5141
+ if (!cfg.site || !cfg.siteRefreshToken) throw new SiteModeError("refreshing the site token", `no refresh token is stored; run \`relay setup --site ${cfg.site ?? "<url>"}\` to approve this machine again`);
5137
5142
  try {
5138
5143
  const r = await refreshAccessToken({ site: cfg.site, refreshToken: cfg.siteRefreshToken });
5139
5144
  const c = credentialsFromToken(r, now);
@@ -5480,6 +5485,9 @@ var IDLE_HEARTBEAT_MS = 5 * 6e4;
5480
5485
  // ../core/src/work/service.ts
5481
5486
  import { join as join12 } from "path";
5482
5487
 
5488
+ // ../core/src/work/login.ts
5489
+ var LOGIN_TIMEOUT_MS = 5 * 6e4;
5490
+
5483
5491
  // ../core/src/work/container.ts
5484
5492
  var CONTAINER_HOME = "/home/relay";
5485
5493
  var CONTAINER_RELAY_HOME = `${CONTAINER_HOME}/.harness`;
@@ -6092,7 +6100,7 @@ function parseUploadKey(key) {
6092
6100
  return { project: project === "_" ? null : project, id, filename };
6093
6101
  }
6094
6102
  async function finalizeFromKey(db, store, input) {
6095
- const log = input.log ?? (() => {
6103
+ const log2 = input.log ?? (() => {
6096
6104
  });
6097
6105
  const parsed = parseUploadKey(input.key);
6098
6106
  if (!parsed) throw new IngestError(`not an upload key: ${input.key}`);
@@ -6106,7 +6114,7 @@ async function finalizeFromKey(db, store, input) {
6106
6114
  }
6107
6115
  const probe = await probeBytes(bytes, parsed.filename);
6108
6116
  const prefix = `${parsed.project ?? "_"}/${parsed.id}`;
6109
- const thumbKey = await storeThumbnail(store, prefix, probe, bytes, parsed.filename, { thumbSize: input.thumbSize, log });
6117
+ const thumbKey = await storeThumbnail(store, prefix, probe, bytes, parsed.filename, { thumbSize: input.thumbSize, log: log2 });
6110
6118
  try {
6111
6119
  return await recordRow(db, { id: parsed.id, input, probe, storageKey: input.key, thumbKey, filename: parsed.filename });
6112
6120
  } catch (e) {
@@ -6144,6 +6152,148 @@ import { join as join21 } from "path";
6144
6152
  import { readFileSync as readFileSync14 } from "fs";
6145
6153
  import { spawnSync } from "child_process";
6146
6154
 
6155
+ // ../mcp/src/bridge.ts
6156
+ import { isInitializeRequest, isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
6157
+ var BRIDGE_UNREACHABLE = -32e3;
6158
+ var BRIDGE_UNAUTHORIZED = -32001;
6159
+ function parseMcpBody(text2, contentType) {
6160
+ const t = text2.trim();
6161
+ if (!t) return [];
6162
+ if (/text\/event-stream/i.test(contentType ?? "") || !t.startsWith("{") && !t.startsWith("[")) {
6163
+ const out = [];
6164
+ for (const event of t.split(/\r?\n\r?\n/)) {
6165
+ const data = event.split(/\r?\n/).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("\n");
6166
+ if (!data) continue;
6167
+ const parsed2 = JSON.parse(data);
6168
+ out.push(...Array.isArray(parsed2) ? parsed2 : [parsed2]);
6169
+ }
6170
+ return out;
6171
+ }
6172
+ const parsed = JSON.parse(t);
6173
+ return Array.isArray(parsed) ? parsed : [parsed];
6174
+ }
6175
+ function errorOf(text2) {
6176
+ try {
6177
+ const j = JSON.parse(text2);
6178
+ if (j && typeof j.error?.message === "string") return { message: j.error.message, ...j.error.data !== void 0 ? { data: j.error.data } : {} };
6179
+ } catch {
6180
+ }
6181
+ return void 0;
6182
+ }
6183
+ var describe3 = (e) => e instanceof Error ? e.message : String(e);
6184
+ function createSiteBridge(opts) {
6185
+ const site = normalizeSite(opts.site);
6186
+ const doFetch = opts.fetch ?? fetch;
6187
+ let protocolVersion;
6188
+ let sessionId;
6189
+ const post2 = (msg, token) => {
6190
+ const headers = {
6191
+ authorization: `Bearer ${token}`,
6192
+ "content-type": "application/json",
6193
+ accept: "application/json, text/event-stream",
6194
+ "user-agent": "relay-mcp"
6195
+ };
6196
+ if (protocolVersion && !isInitializeRequest(msg)) headers["mcp-protocol-version"] = protocolVersion;
6197
+ if (sessionId) headers["mcp-session-id"] = sessionId;
6198
+ return doFetch(`${site}/mcp`, { method: "POST", headers, body: JSON.stringify(msg) });
6199
+ };
6200
+ return {
6201
+ site,
6202
+ async forward(msg) {
6203
+ const id = isJSONRPCRequest(msg) ? msg.id : void 0;
6204
+ const fail2 = (code, message2, data) => id === void 0 ? [] : [{ jsonrpc: "2.0", id, error: { code, message: message2, ...data !== void 0 ? { data } : {} } }];
6205
+ let token;
6206
+ try {
6207
+ token = await opts.auth.token();
6208
+ } catch (e) {
6209
+ return fail2(BRIDGE_UNAUTHORIZED, `relay-mcp: no usable token for ${site}: ${describe3(e)}`);
6210
+ }
6211
+ let res;
6212
+ let renewFailure = "";
6213
+ try {
6214
+ res = await post2(msg, token);
6215
+ if (res.status === 401) {
6216
+ let next;
6217
+ try {
6218
+ next = await opts.auth.renew(token);
6219
+ } catch (e) {
6220
+ renewFailure = describe3(e);
6221
+ }
6222
+ if (next) {
6223
+ opts.log?.(`${site} refused the token (401); renewed it and retrying`);
6224
+ res = await post2(msg, next);
6225
+ }
6226
+ }
6227
+ } catch (e) {
6228
+ return fail2(BRIDGE_UNREACHABLE, `relay-mcp: cannot reach ${site}/mcp: ${describe3(e)}`);
6229
+ }
6230
+ const text2 = await res.text();
6231
+ const sid = res.headers.get("mcp-session-id");
6232
+ if (sid) sessionId = sid;
6233
+ if (res.status === 401) {
6234
+ const err = errorOf(text2);
6235
+ opts.log?.(`${site} refused the token (401)${renewFailure ? `; renewing it failed: ${renewFailure}` : ""}`);
6236
+ return fail2(BRIDGE_UNAUTHORIZED, `${err?.message ?? `relay-mcp: ${site} refused the token (401)`}${renewFailure ? ` Renewing the token failed: ${renewFailure}` : ""}`, err?.data);
6237
+ }
6238
+ if (!res.ok) {
6239
+ const err = errorOf(text2);
6240
+ return fail2(BRIDGE_UNREACHABLE, err?.message ?? `relay-mcp: ${site}/mcp answered HTTP ${res.status}${text2.trim() ? `: ${text2.trim().slice(0, 200)}` : ""}`, err?.data);
6241
+ }
6242
+ let out;
6243
+ try {
6244
+ out = parseMcpBody(text2, res.headers.get("content-type"));
6245
+ } catch (e) {
6246
+ return fail2(BRIDGE_UNREACHABLE, `relay-mcp: unreadable answer from ${site}/mcp: ${describe3(e)}`);
6247
+ }
6248
+ if (isInitializeRequest(msg)) {
6249
+ for (const m of out) {
6250
+ const v = m.result?.protocolVersion;
6251
+ if (typeof v === "string") protocolVersion = v;
6252
+ }
6253
+ }
6254
+ return out;
6255
+ }
6256
+ };
6257
+ }
6258
+ async function serveSiteBridge(transport, bridge, onError) {
6259
+ const inFlight = /* @__PURE__ */ new Set();
6260
+ transport.onmessage = (msg) => {
6261
+ const p = bridge.forward(msg).then(async (out) => {
6262
+ for (const m of out) await transport.send(m);
6263
+ }).catch((e) => onError?.(e)).finally(() => inFlight.delete(p));
6264
+ inFlight.add(p);
6265
+ };
6266
+ transport.onerror = (e) => onError?.(e);
6267
+ await transport.start();
6268
+ return {
6269
+ /** Resolves once every message received so far has been answered (stdin closed: finish, then exit). */
6270
+ async drain() {
6271
+ while (inFlight.size) await Promise.allSettled([...inFlight]);
6272
+ }
6273
+ };
6274
+ }
6275
+ var tokenLabel = (cfg) => cfg.siteTokenName ?? { device: "an unnamed device-flow token", invite: "an unnamed invite token", manual: "a pasted token", env: "RELAY_TOKEN" }[cfg.siteTokenSource ?? "manual"];
6276
+ function siteAuth(initial) {
6277
+ let cfg = initial;
6278
+ const reload = () => {
6279
+ const fresh = loadConfig();
6280
+ return fresh.site === cfg.site && fresh.siteToken ? fresh : cfg;
6281
+ };
6282
+ return {
6283
+ async token() {
6284
+ cfg = await refreshSiteTokenIfStale(reload());
6285
+ return cfg.siteToken;
6286
+ },
6287
+ async renew(rejected) {
6288
+ cfg = reload();
6289
+ if (cfg.siteToken && cfg.siteToken !== rejected) return cfg.siteToken;
6290
+ if (!canRefreshSiteToken(cfg)) return void 0;
6291
+ cfg = await refreshSiteToken(cfg);
6292
+ return cfg.siteToken;
6293
+ }
6294
+ };
6295
+ }
6296
+
6147
6297
  // ../mcp/src/server.ts
6148
6298
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6149
6299
  import { z } from "zod";
@@ -6849,8 +6999,34 @@ ${p.summary}` : head);
6849
6999
  }
6850
7000
 
6851
7001
  // ../mcp/src/stdio.ts
6852
- async function main() {
6853
- const h = await openHarness(void 0, { local: true });
7002
+ var log = (m) => console.error(`relay-mcp: ${m}`);
7003
+ var describe4 = (e) => e instanceof Error ? e.message : String(e);
7004
+ async function serveSite(cfg) {
7005
+ const site = cfg.site;
7006
+ try {
7007
+ cfg = await refreshSiteTokenIfStale(cfg);
7008
+ } catch (e) {
7009
+ log(describe4(e));
7010
+ }
7011
+ const bridge = createSiteBridge({ site, auth: siteAuth(cfg), log });
7012
+ const transport = new StdioServerTransport();
7013
+ const served = await serveSiteBridge(transport, bridge, (e) => log(describe4(e)));
7014
+ let closing = false;
7015
+ const finish = async () => {
7016
+ if (closing) return;
7017
+ closing = true;
7018
+ await served.drain();
7019
+ await transport.close().catch(() => {
7020
+ });
7021
+ process.exit(0);
7022
+ };
7023
+ process.stdin.on("end", () => void finish());
7024
+ process.on("SIGINT", () => void finish());
7025
+ process.on("SIGTERM", () => void finish());
7026
+ log(`site mode, forwarding to ${site} as ${tokenLabel(cfg)}`);
7027
+ }
7028
+ async function serveLocal(cfg) {
7029
+ const h = await openHarness(cfg, { local: true });
6854
7030
  await migrate(h.db);
6855
7031
  const embed = briefEmbedder(h);
6856
7032
  const actor = `agent:${process.env.RELAY_MCP_CLIENT ?? "mcp"}`;
@@ -6879,10 +7055,16 @@ async function main() {
6879
7055
  process.on("SIGINT", onSignal);
6880
7056
  process.on("SIGTERM", onSignal);
6881
7057
  await server.connect(transport);
6882
- console.error(`[relay-mcp] serving on stdio (${h.db.driver})`);
7058
+ log(`local mode, ${h.db.driver === "pg" ? "postgres via DATABASE_URL" : cfg.dbFile}`);
7059
+ }
7060
+ async function main() {
7061
+ const cfg = loadConfig();
7062
+ ensureHome(cfg);
7063
+ if (cfg.site && cfg.siteToken) await serveSite(cfg);
7064
+ else await serveLocal(cfg);
6883
7065
  }
6884
7066
  main().catch((e) => {
6885
- console.error(`[relay-mcp] ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
7067
+ console.error(`relay-mcp: ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
6886
7068
  process.exit(1);
6887
7069
  });
6888
7070
  export {
package/dist/relay.js CHANGED
@@ -5343,6 +5343,10 @@ var defaultActor = () => `human:${process.env.RELAY_ACTOR ?? process.env.USER ??
5343
5343
  async function refreshSiteTokenIfStale(cfg, now = Date.now()) {
5344
5344
  if (!cfg.site || !cfg.siteToken || !cfg.siteRefreshToken || !tokenIsStale(cfg.siteTokenExpiresAt, now)) return cfg;
5345
5345
  if (cfg.siteTokenSource === "env") return cfg;
5346
+ return refreshSiteToken(cfg, now);
5347
+ }
5348
+ async function refreshSiteToken(cfg, now = Date.now()) {
5349
+ if (!cfg.site || !cfg.siteRefreshToken) throw new SiteModeError("refreshing the site token", `no refresh token is stored; run \`relay setup --site ${cfg.site ?? "<url>"}\` to approve this machine again`);
5346
5350
  try {
5347
5351
  const r = await refreshAccessToken({ site: cfg.site, refreshToken: cfg.siteRefreshToken });
5348
5352
  const c = credentialsFromToken(r, now);
@@ -5911,6 +5915,8 @@ var run = (bin, args, opts = {}) => new Promise((res) => {
5911
5915
  { cwd: opts.cwd, env: opts.env ?? cleanEnv(), timeout: opts.timeoutMs ?? 12e4, maxBuffer: 16 * 1024 * 1024 },
5912
5916
  (err, stdout, stderr) => res({ code: err && "code" in err && typeof err.code === "number" ? err.code : err ? 1 : 0, stdout: String(stdout), stderr: String(stderr) })
5913
5917
  );
5918
+ child.stdin?.on("error", () => {
5919
+ });
5914
5920
  child.stdin?.end(opts.input ?? "");
5915
5921
  });
5916
5922
  async function must(bin, args, opts = {}) {
@@ -6363,6 +6369,78 @@ async function runWorker(opts) {
6363
6369
  // ../core/src/work/service.ts
6364
6370
  import { join as join12 } from "path";
6365
6371
 
6372
+ // ../core/src/work/login.ts
6373
+ var LOGIN_PROVIDERS = ["claude", "gh", "codex", "grok"];
6374
+ var isLoginProvider = (s) => LOGIN_PROVIDERS.includes(s);
6375
+ var LOGIN_TIMEOUT_MS = 5 * 6e4;
6376
+ var fxLoginWords = (provider) => ["env", "FX_NO_OPEN_BROWSER=1", "fx", "login", provider];
6377
+ var URL_RE = /https?:\/\/[^\s"'<>)\]]+/g;
6378
+ function parseLoginUrl(text) {
6379
+ for (const m of text.matchAll(URL_RE)) {
6380
+ const raw = m[0].replace(/[.,;:!?]+$/, "");
6381
+ let u;
6382
+ try {
6383
+ u = new URL(raw);
6384
+ } catch {
6385
+ continue;
6386
+ }
6387
+ const redirect = u.searchParams.get("redirect_uri");
6388
+ if (!redirect) continue;
6389
+ let r;
6390
+ try {
6391
+ r = new URL(redirect);
6392
+ } catch {
6393
+ continue;
6394
+ }
6395
+ if (!/^(localhost|127\.0\.0\.1|\[::1\])$/.test(r.hostname)) continue;
6396
+ const port = r.port ? Number(r.port) : r.protocol === "https:" ? 443 : 80;
6397
+ if (!Number.isInteger(port) || port <= 0) continue;
6398
+ const state = u.searchParams.get("state");
6399
+ return { url: raw, redirectUri: redirect, host: r.hostname, port, path: r.pathname || "/", ...state ? { state } : {} };
6400
+ }
6401
+ return void 0;
6402
+ }
6403
+ function parseLoginCallback(requestUrl) {
6404
+ const u = new URL(requestUrl, "http://127.0.0.1");
6405
+ const q2 = u.searchParams;
6406
+ const out = {};
6407
+ const code = q2.get("code");
6408
+ const state = q2.get("state");
6409
+ const error = q2.get("error");
6410
+ const desc = q2.get("error_description");
6411
+ if (code) out.code = code;
6412
+ if (state) out.state = state;
6413
+ if (error) out.error = error;
6414
+ if (desc) out.errorDescription = desc;
6415
+ return out;
6416
+ }
6417
+ function replayUrl(redirect, requestUrl) {
6418
+ const u = new URL(requestUrl, "http://127.0.0.1");
6419
+ return `http://${redirect.host}:${redirect.port}${redirect.path}${u.search}`;
6420
+ }
6421
+ function loginDonePage(provider, ok, detail) {
6422
+ const title = ok ? `${provider}: signed in` : `${provider}: sign-in failed`;
6423
+ const body = ok ? "relay has the code and is finishing the login inside the container. You can close this tab." : `${detail ?? "the provider returned an error"}. Go back to the terminal.`;
6424
+ const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
6425
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title><style>body{font:16px/1.5 -apple-system,system-ui,sans-serif;margin:3rem auto;max-width:36rem;padding:0 1rem;color:#222}h1{font-size:1.25rem}</style></head><body><h1>${esc(title)}</h1><p>${esc(body)}</p></body></html>
6426
+ `;
6427
+ }
6428
+ function manualLoginSteps(provider) {
6429
+ switch (provider) {
6430
+ case "claude":
6431
+ return ["relay work container shell", "claude # then /login; open the URL it prints on this Mac, paste the code back, then /exit"];
6432
+ case "gh":
6433
+ return ["relay work container shell", "gh auth login --git-protocol https --web && gh auth setup-git # the device code prints here; enter it at github.com/login/device"];
6434
+ case "codex":
6435
+ return ["relay work container shell", "fx login codex # open the printed URL on this Mac; the redirect to localhost:1455 reaches the container through its published port"];
6436
+ case "grok":
6437
+ return [
6438
+ "relay work container shell",
6439
+ "FX_NO_OPEN_BROWSER=1 fx login grok # open the printed URL on this Mac; when the browser ends on an unreachable 127.0.0.1:<port>/callback?state=\u2026&code=\u2026 page, paste only the code value into fx's prompt (the whole address fails; each code is single-use)"
6440
+ ];
6441
+ }
6442
+ }
6443
+
6366
6444
  // ../core/src/work/container.ts
6367
6445
  var CONTAINER_NAME = "relay-work";
6368
6446
  var CONTAINER_VOLUME = "relay-work-home";
@@ -6387,6 +6465,7 @@ var LOGIN_CALLBACK_PORTS = [FX_CALLBACK_PORT, CODEX_CALLBACK_PORT];
6387
6465
  var LOGIN_PORTS_ENV = "RELAY_LOGIN_PORTS";
6388
6466
  var CONTAINER_CPUS = 4;
6389
6467
  var execWords = (bin, interactive) => [bin, "exec", "--user", CONTAINER_USER, ...interactive ? ["-it"] : [], CONTAINER_NAME];
6468
+ var execStdinWords = (bin) => [bin, "exec", "--user", CONTAINER_USER, "-i", CONTAINER_NAME];
6390
6469
  function containerPlan(s) {
6391
6470
  const exec = (argv, interactive = false) => [...execWords(s.bin, interactive), ...argv];
6392
6471
  const env = {
@@ -6439,17 +6518,26 @@ function containerPlan(s) {
6439
6518
  doctor: exec(["relay", "doctor"]),
6440
6519
  credentials: exec(["test", "-f", CONTAINER_CREDENTIALS]),
6441
6520
  logs: (follow, lines) => [s.bin, "logs", ...follow ? ["--follow"] : [], ...lines ? ["-n", String(lines)] : [], CONTAINER_NAME],
6442
- exec
6521
+ exec,
6522
+ login: {
6523
+ fx: (provider) => [...execStdinWords(s.bin), ...fxLoginWords(provider)],
6524
+ claude: exec(["claude"], true),
6525
+ gh: exec(["gh", "auth", "login", "--git-protocol", "https", "--web"], true),
6526
+ ghSetupGit: exec(["gh", "auth", "setup-git"]),
6527
+ replay: (url) => exec(["curl", "-fsS", "-o", "/dev/null", url])
6528
+ }
6443
6529
  };
6444
6530
  }
6531
+ var CONTAINER_LOGIN_COMMAND = "relay work container login all";
6532
+ var CONTAINER_LOGIN_COMMAND_WHY = "one command; opens this Mac's browser for each login and skips the ones already in the volume (claude, gh, then the optional codex and grok)";
6445
6533
  var CONTAINER_LOGIN_STEPS = [
6446
6534
  { cmd: "relay work container shell", why: "a shell inside the container, as the relay user" },
6447
- { cmd: "claude", why: "then /login: the OAuth paste-code flow (open the URL on this Mac, paste the code back)", login: "claude" },
6448
- { cmd: "gh auth login && gh auth setup-git", why: "the account the worker clones, pushes and opens PRs as", login: "gh" },
6535
+ { cmd: "claude", why: "then /login: the OAuth paste-code flow (open the URL on this Mac, paste the code back, /exit)", login: "claude" },
6536
+ { cmd: "gh auth login && gh auth setup-git", why: "the account the worker clones, pushes and opens PRs as (device code)", login: "gh" },
6449
6537
  { cmd: "fx login codex", why: "optional: the codex lane (the browser's redirect to localhost:1455 reaches the container through the published port)", login: "codex" },
6450
6538
  {
6451
- cmd: "fx login --no-browser grok",
6452
- why: "optional: the grok lane; open the printed address on this Mac, and when the browser ends on a 127.0.0.1:<port>/callback page that cannot load, paste that full address (or the code xAI shows) back into the fx prompt",
6539
+ cmd: "FX_NO_OPEN_BROWSER=1 fx login grok",
6540
+ why: "optional: the grok lane; open the printed URL on this Mac, and when the browser ends on an unreachable 127.0.0.1:<port>/callback?\u2026&code=\u2026 page, paste only the code value into fx's prompt",
6453
6541
  login: "grok"
6454
6542
  },
6455
6543
  { cmd: "exit", why: "then on the Mac: relay work container status" }
@@ -8783,9 +8871,11 @@ ${p.cli}${p.detected ? "" : " (not detected)"}`);
8783
8871
  }
8784
8872
  for (const n of p.notes) console.log(` note: ${n}`);
8785
8873
  }
8874
+ const mcpNote = siteCfg.site ? `memory MCP server: forwards to ${siteCfg.site} in site mode; local database otherwise` : "memory MCP server: local database (no site configured; it forwards to the site once relay setup --site <url> has run)";
8786
8875
  console.log(`
8787
8876
  hook scripts: ${join23(RELAY_ROOT, "packages", "cli", "hooks")}
8788
8877
  MCP entry: ${join23(RELAY_ROOT, "packages", "mcp", "bin", "relay-mcp.mjs")}
8878
+ ${mcpNote}
8789
8879
  node: ${ctx.node}`);
8790
8880
  const major = Number(process.versions.node.split(".")[0]);
8791
8881
  if (major < 24) console.log(`note: hooks will run with Node ${process.versions.node}; relay pins Node 24. Run this through \`pnpm relay setup\` so the pinned binary is recorded instead.`);
@@ -8856,7 +8946,7 @@ async function connectSite(o) {
8856
8946
  } else log2(`check: ${probe.detail}`);
8857
8947
  const paths = writeSiteConfig({ site, token: credential });
8858
8948
  log2(`credential saved to ${paths.credentialsPath} (mode 0600, source: ${credential.source})`);
8859
- log2(`site mode on: ${site} (${paths.configPath}); memory, feed, token, invite, task and webhook commands and the hooks now use the site. relay link still digests locally and uploads.`);
8949
+ log2(`site mode on: ${site} (${paths.configPath}); memory, feed, token, invite, task and webhook commands, the hooks and the stdio MCP server now use the site. relay link still digests locally and uploads.`);
8860
8950
  }
8861
8951
 
8862
8952
  // src/commands/link.ts
@@ -9581,14 +9671,146 @@ function registerWebhook(program2) {
9581
9671
  }
9582
9672
 
9583
9673
  // src/commands/work.ts
9584
- import { execFileSync as execFileSync5, spawnSync as spawnSync2 } from "child_process";
9674
+ import { execFileSync as execFileSync6, spawn, spawnSync as spawnSync2 } from "child_process";
9585
9675
  import { appendFileSync as appendFileSync2, copyFileSync, existsSync as existsSync19, mkdirSync as mkdirSync15, readFileSync as readFileSync18, rmSync as rmSync7, writeFileSync as writeFileSync11 } from "fs";
9676
+ import { createServer } from "http";
9586
9677
  import { homedir as homedir10, hostname as hostname2 } from "os";
9587
9678
  import { dirname as dirname8, join as join27 } from "path";
9588
9679
 
9589
9680
  // src/commands/probes.ts
9590
- import { execFile as execFile8 } from "child_process";
9591
- import { join as join26 } from "path";
9681
+ import { execFile as execFile8, execFileSync as execFileSync5 } from "child_process";
9682
+ import { delimiter as delimiter5, join as join26 } from "path";
9683
+
9684
+ // ../mcp/src/server.ts
9685
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9686
+ import { z } from "zod";
9687
+ var ALL_SCOPES = [...TOKEN_SCOPES];
9688
+
9689
+ // ../mcp/src/http.ts
9690
+ import { timingSafeEqual as timingSafeEqual4 } from "crypto";
9691
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
9692
+
9693
+ // ../mcp/src/bridge.ts
9694
+ import { isInitializeRequest, isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
9695
+ var BRIDGE_UNREACHABLE = -32e3;
9696
+ var BRIDGE_UNAUTHORIZED = -32001;
9697
+ function parseMcpBody(text, contentType) {
9698
+ const t = text.trim();
9699
+ if (!t) return [];
9700
+ if (/text\/event-stream/i.test(contentType ?? "") || !t.startsWith("{") && !t.startsWith("[")) {
9701
+ const out = [];
9702
+ for (const event of t.split(/\r?\n\r?\n/)) {
9703
+ const data = event.split(/\r?\n/).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("\n");
9704
+ if (!data) continue;
9705
+ const parsed2 = JSON.parse(data);
9706
+ out.push(...Array.isArray(parsed2) ? parsed2 : [parsed2]);
9707
+ }
9708
+ return out;
9709
+ }
9710
+ const parsed = JSON.parse(t);
9711
+ return Array.isArray(parsed) ? parsed : [parsed];
9712
+ }
9713
+ function errorOf(text) {
9714
+ try {
9715
+ const j = JSON.parse(text);
9716
+ if (j && typeof j.error?.message === "string") return { message: j.error.message, ...j.error.data !== void 0 ? { data: j.error.data } : {} };
9717
+ } catch {
9718
+ }
9719
+ return void 0;
9720
+ }
9721
+ var describe3 = (e) => e instanceof Error ? e.message : String(e);
9722
+ function createSiteBridge(opts) {
9723
+ const site = normalizeSite(opts.site);
9724
+ const doFetch = opts.fetch ?? fetch;
9725
+ let protocolVersion;
9726
+ let sessionId;
9727
+ const post2 = (msg, token) => {
9728
+ const headers = {
9729
+ authorization: `Bearer ${token}`,
9730
+ "content-type": "application/json",
9731
+ accept: "application/json, text/event-stream",
9732
+ "user-agent": "relay-mcp"
9733
+ };
9734
+ if (protocolVersion && !isInitializeRequest(msg)) headers["mcp-protocol-version"] = protocolVersion;
9735
+ if (sessionId) headers["mcp-session-id"] = sessionId;
9736
+ return doFetch(`${site}/mcp`, { method: "POST", headers, body: JSON.stringify(msg) });
9737
+ };
9738
+ return {
9739
+ site,
9740
+ async forward(msg) {
9741
+ const id = isJSONRPCRequest(msg) ? msg.id : void 0;
9742
+ const fail = (code, message2, data) => id === void 0 ? [] : [{ jsonrpc: "2.0", id, error: { code, message: message2, ...data !== void 0 ? { data } : {} } }];
9743
+ let token;
9744
+ try {
9745
+ token = await opts.auth.token();
9746
+ } catch (e) {
9747
+ return fail(BRIDGE_UNAUTHORIZED, `relay-mcp: no usable token for ${site}: ${describe3(e)}`);
9748
+ }
9749
+ let res;
9750
+ let renewFailure = "";
9751
+ try {
9752
+ res = await post2(msg, token);
9753
+ if (res.status === 401) {
9754
+ let next;
9755
+ try {
9756
+ next = await opts.auth.renew(token);
9757
+ } catch (e) {
9758
+ renewFailure = describe3(e);
9759
+ }
9760
+ if (next) {
9761
+ opts.log?.(`${site} refused the token (401); renewed it and retrying`);
9762
+ res = await post2(msg, next);
9763
+ }
9764
+ }
9765
+ } catch (e) {
9766
+ return fail(BRIDGE_UNREACHABLE, `relay-mcp: cannot reach ${site}/mcp: ${describe3(e)}`);
9767
+ }
9768
+ const text = await res.text();
9769
+ const sid = res.headers.get("mcp-session-id");
9770
+ if (sid) sessionId = sid;
9771
+ if (res.status === 401) {
9772
+ const err = errorOf(text);
9773
+ opts.log?.(`${site} refused the token (401)${renewFailure ? `; renewing it failed: ${renewFailure}` : ""}`);
9774
+ return fail(BRIDGE_UNAUTHORIZED, `${err?.message ?? `relay-mcp: ${site} refused the token (401)`}${renewFailure ? ` Renewing the token failed: ${renewFailure}` : ""}`, err?.data);
9775
+ }
9776
+ if (!res.ok) {
9777
+ const err = errorOf(text);
9778
+ return fail(BRIDGE_UNREACHABLE, err?.message ?? `relay-mcp: ${site}/mcp answered HTTP ${res.status}${text.trim() ? `: ${text.trim().slice(0, 200)}` : ""}`, err?.data);
9779
+ }
9780
+ let out;
9781
+ try {
9782
+ out = parseMcpBody(text, res.headers.get("content-type"));
9783
+ } catch (e) {
9784
+ return fail(BRIDGE_UNREACHABLE, `relay-mcp: unreadable answer from ${site}/mcp: ${describe3(e)}`);
9785
+ }
9786
+ if (isInitializeRequest(msg)) {
9787
+ for (const m of out) {
9788
+ const v = m.result?.protocolVersion;
9789
+ if (typeof v === "string") protocolVersion = v;
9790
+ }
9791
+ }
9792
+ return out;
9793
+ }
9794
+ };
9795
+ }
9796
+ async function listSiteTools(opts) {
9797
+ const bridge = createSiteBridge({ site: opts.site, auth: { token: async () => opts.token, renew: async () => void 0 }, ...opts.fetch ? { fetch: opts.fetch } : {} });
9798
+ const [answer] = await bridge.forward({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} });
9799
+ if (!answer) throw new Error(`${bridge.site}/mcp returned no answer to tools/list`);
9800
+ if ("error" in answer) throw new Error(answer.error.message);
9801
+ const tools = (answer.result?.tools ?? []).map((t) => t.name);
9802
+ return { tools };
9803
+ }
9804
+ var tokenLabel = (cfg) => cfg.siteTokenName ?? { device: "an unnamed device-flow token", invite: "an unnamed invite token", manual: "a pasted token", env: "RELAY_TOKEN" }[cfg.siteTokenSource ?? "manual"];
9805
+
9806
+ // src/commands/probes.ts
9807
+ var INSTALL_HINT = {
9808
+ claude: "install: npm install -g @anthropic-ai/claude-code (or the macOS pkg from claude.com/code), then run claude and /login",
9809
+ codex: "install: npm install -g @openai/codex, then codex login (or use fx: fx login codex)",
9810
+ fx: "install: the macOS arm64 tarball from https://releases.fx.sh (version in https://releases.fx.sh/latest.txt) into ~/.local/bin, then fx login codex / fx login grok",
9811
+ grok: "install: the grok CLI from x.ai's docs, then grok login (or use fx: fx login grok)",
9812
+ gh: "install: brew install gh, or the macOS pkg from https://github.com/cli/cli/releases/latest, then gh auth login && gh auth setup-git"
9813
+ };
9592
9814
  var PROBE_TOOLS = ["claude", "codex", "fx", "grok", "gh"];
9593
9815
  function run3(bin, args, timeoutMs = 15e3) {
9594
9816
  return new Promise((res) => {
@@ -9641,7 +9863,7 @@ async function probeTools() {
9641
9863
  for (const tool of PROBE_TOOLS) {
9642
9864
  const bin = onPath2(tool);
9643
9865
  if (!bin) {
9644
- checks.push({ status: tool === "gh" || tool === "claude" ? "fail" : "warn", name: `cli:${tool}`, detail: "not on PATH" });
9866
+ checks.push({ status: tool === "gh" || tool === "claude" ? "fail" : "warn", name: `cli:${tool}`, detail: `not on PATH; ${INSTALL_HINT[tool]}` });
9645
9867
  continue;
9646
9868
  }
9647
9869
  checks.push({ status: "ok", name: `cli:${tool}`, detail: bin });
@@ -9664,6 +9886,31 @@ async function sandboxCheck(platform = process.platform) {
9664
9886
  const fix = inContainer() ? "the container is the boundary: the Claude lane runs with the allowlist alone, advertised as sandbox:container (recreate the container with `relay work --isolation container --install` to get the runtime flags it needs)" : "Claude jobs fail here (relay work never runs them unsandboxed); the kernel must allow unprivileged user namespaces";
9665
9887
  return { status: "warn", name: "sandbox", detail: `bubblewrap and socat on PATH but ${probe.detail}; ${fix}` };
9666
9888
  }
9889
+ function pathCheck(opts = {}) {
9890
+ const find = opts.onPath ?? onPath2;
9891
+ const found = find("relay");
9892
+ if (found) return { status: "ok", name: "path", detail: `relay on PATH: ${found}` };
9893
+ const prefix = (opts.npmPrefix ?? defaultNpmPrefix)();
9894
+ const bin = prefix ? join26(prefix, "bin") : void 0;
9895
+ const shell = opts.shell ?? process.env.SHELL ?? "";
9896
+ const rc = shell.endsWith("zsh") ? "~/.zshrc" : shell.endsWith("fish") ? "~/.config/fish/config.fish" : "~/.bashrc";
9897
+ const running = opts.running ?? process.argv[1] ?? "";
9898
+ const viaNpx = /\/_npx\//.test(running);
9899
+ const install = viaNpx ? "you are running through npx, which installs no command; run `npm install -g @kyleroche/relay`, then " : "";
9900
+ const pathDirs = (opts.pathEnv ?? process.env.PATH ?? "").split(delimiter5).filter(Boolean);
9901
+ if (bin && pathDirs.includes(bin)) {
9902
+ return { status: "warn", name: "path", detail: `relay is not installed as a command: npm install -g @kyleroche/relay (a checkout uses its bin/relay)` };
9903
+ }
9904
+ const fix = bin ? `${install}add npm's bin directory to PATH: echo 'export PATH="${bin}:$PATH"' >> ${rc} and open a new shell` : `${install}add the directory \`npm prefix -g\`/bin prints to PATH in ${rc} and open a new shell`;
9905
+ return { status: "warn", name: "path", detail: `relay is not on PATH; ${fix}` };
9906
+ }
9907
+ function defaultNpmPrefix() {
9908
+ try {
9909
+ return execFileSync5("npm", ["prefix", "-g"], { encoding: "utf8", timeout: 5e3 }).trim() || void 0;
9910
+ } catch {
9911
+ return void 0;
9912
+ }
9913
+ }
9667
9914
  async function containerReport(opts = {}) {
9668
9915
  const bin = onPath2("container");
9669
9916
  const out = { serviceRunning: false, container: { exists: false, running: false } };
@@ -9689,7 +9936,7 @@ function containerCheck(r) {
9689
9936
  if (!r.serviceRunning) return { status: "warn", name, detail: `${r.bin} installed, services not running; run: container system start` };
9690
9937
  if (!r.container.exists) return { status: "ok", name, detail: `installed and running; no ${CONTAINER_NAME} container yet (relay work --isolation container --install)` };
9691
9938
  if (!r.container.running) return { status: "warn", name, detail: `${CONTAINER_NAME} exists (${r.container.state ?? "stopped"}), not running; run: relay work container start` };
9692
- const lanes = r.lanes === void 0 ? "lanes unknown (relay doctor inside gave no lanes line)" : r.lanes.length ? `serving ${r.lanes.join(", ")} inside` : "no lanes inside yet (log in: relay work container shell)";
9939
+ const lanes = r.lanes === void 0 ? "lanes unknown (relay doctor inside gave no lanes line)" : r.lanes.length ? `serving ${r.lanes.join(", ")} inside` : "no lanes inside yet (log in: relay work container login all)";
9693
9940
  return { status: "ok", name, detail: `${CONTAINER_NAME} running${r.container.image ? ` (${r.container.image})` : ""}; ${lanes}` };
9694
9941
  }
9695
9942
  function fmtAge(ms) {
@@ -9731,8 +9978,22 @@ function lanesFromChecks(checks) {
9731
9978
  return { lanes, reasons };
9732
9979
  }
9733
9980
  var detectLanes = async () => lanesFromChecks(await probeTools());
9981
+ async function mcpCheck(cfg, opts) {
9982
+ const name = "mcp";
9983
+ if (!(cfg.site && cfg.siteToken)) return { status: "ok", name, detail: `local mode: ${opts.entry} serves ${cfg.databaseUrl ? "postgres via DATABASE_URL" : `pglite at ${cfg.dbFile}`}` };
9984
+ const now = opts.now ?? Date.now;
9985
+ const t0 = now();
9986
+ const where = `site mode: ${opts.entry} forwards to ${cfg.site} as ${tokenLabel(cfg)}`;
9987
+ try {
9988
+ const { tools } = await (opts.listTools ?? listSiteTools)({ site: cfg.site, token: cfg.siteToken });
9989
+ return { status: "ok", name, detail: `${where}; tools/list answered with ${tools.length} tools, ${now() - t0} ms` };
9990
+ } catch (e) {
9991
+ return { status: "fail", name, detail: `${where}; tools/list failed: ${(e instanceof Error ? e.message : String(e)).slice(0, 200)}` };
9992
+ }
9993
+ }
9734
9994
 
9735
9995
  // src/commands/work.ts
9996
+ var defaultSpawn = (words) => spawn(words[0], words.slice(1), { stdio: ["pipe", "pipe", "pipe"] });
9736
9997
  var LANES = ["claude", "codex", "grok"];
9737
9998
  var list = (s) => (s ?? "").split(",").map((x) => x.trim()).filter(Boolean);
9738
9999
  var workLogPath = () => join27(relayHome(), "work.log");
@@ -9783,7 +10044,7 @@ function plan(o, deps, containerBin) {
9783
10044
  var defaultDeps = {
9784
10045
  sh(words, opts) {
9785
10046
  try {
9786
- return { ok: true, out: execFileSync5(words[0], words.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: opts?.timeoutMs ?? 3e4 }).trim() };
10047
+ return { ok: true, out: execFileSync6(words[0], words.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: opts?.timeoutMs ?? 3e4 }).trim() };
9787
10048
  } catch (e) {
9788
10049
  const err = e;
9789
10050
  const out = [err.stderr, err.stdout].map((s) => (s ?? "").toString().trim()).filter(Boolean).join("\n");
@@ -9833,7 +10094,7 @@ async function waitForReadiness(out, forced, signal) {
9833
10094
  const d = forced ? { lanes: forced, reasons: [] } : await detectLanes();
9834
10095
  const missing = [];
9835
10096
  if (!site.site || !site.token) missing.push("no site yet: on the Mac run `relay work --isolation container --install`, or inside: relay setup --site <url>");
9836
- if (!d.lanes.length) missing.push(`no lanes yet: ${d.reasons.join("; ")}; log in with: relay work container shell`);
10097
+ if (!d.lanes.length) missing.push(`no lanes yet: ${d.reasons.join("; ")}; log in from the Mac with: ${CONTAINER_LOGIN_COMMAND}`);
9837
10098
  if (!missing.length) return d.lanes;
9838
10099
  const msg = `waiting: ${missing.join(" | ")}`;
9839
10100
  if (msg !== said) out(msg);
@@ -9952,7 +10213,7 @@ function registerWork(program2, version2, deps = defaultDeps) {
9952
10213
  }
9953
10214
  });
9954
10215
  const container = work.command("container").enablePositionalOptions().description(`The ${CONTAINER_NAME} container (relay work --isolation container): a shell inside, its status, stop/start/rm, logs (docs/WORKERS.md "Isolation")`);
9955
- container.command("shell").description(`a shell inside ${CONTAINER_NAME} as the relay user (container exec -it); log in to claude, gh and fx here`).action(() => {
10216
+ container.command("shell").description(`a shell inside ${CONTAINER_NAME} as the relay user (container exec -it); the logins by hand happen here (relay work container login does them for you)`).action(() => {
9956
10217
  const c = needContainer();
9957
10218
  process.exitCode = deps.run(c.plan.shell);
9958
10219
  });
@@ -9965,8 +10226,7 @@ function registerWork(program2, version2, deps = defaultDeps) {
9965
10226
  if (!state.running) return console.log("start it with: relay work container start");
9966
10227
  const doctor = deps.sh(c.plan.doctor, { timeoutMs: 12e4 }).out;
9967
10228
  if (doctor) console.log(doctor.replace(/^/gm, " "));
9968
- const lanes = lanesFromDoctorOutput(doctor);
9969
- console.log(lanes === void 0 ? "lanes: unknown (no lanes line from relay doctor inside)" : lanes.length ? `lanes: ${lanes.join(", ")} (the container worker serves these)` : "lanes: none yet; log in with: relay work container shell");
10229
+ console.log(lanesLine(doctor));
9970
10230
  });
9971
10231
  container.command("stop").description(`stop ${CONTAINER_NAME} (and unload the service that keeps it running, so it stays stopped)`).action(() => {
9972
10232
  const c = needContainer();
@@ -9983,10 +10243,7 @@ function registerWork(program2, version2, deps = defaultDeps) {
9983
10243
  const c = needContainer();
9984
10244
  const p = plan({ jobs: "1" }, deps, c.bin);
9985
10245
  if (existsSync19(p.path)) {
9986
- for (const w of p.load) {
9987
- const r2 = deps.sh(w);
9988
- if (!r2.ok && !/already|in progress|service already loaded/i.test(r2.out)) throw new Error(`${w.join(" ")} failed: ${r2.out.split("\n")[0] ?? ""}`);
9989
- }
10246
+ loadService(p.load, { tolerateLoaded: true });
9990
10247
  return log2(`service loaded (${p.path}); it runs: ${c.plan.attach.join(" ")}`);
9991
10248
  }
9992
10249
  const r = deps.sh(c.plan.start, { timeoutMs: 12e4 });
@@ -10008,6 +10265,172 @@ function registerWork(program2, version2, deps = defaultDeps) {
10008
10265
  process.exitCode = deps.run(c.plan.logs(!!o.follow, Number(o.n) || void 0));
10009
10266
  });
10010
10267
  container.command("update").description(`rebuild the ${CONTAINER_IMAGE} image from this relay and recreate ${CONTAINER_NAME} on it with today's flags; the ${CONTAINER_VOLUME} volume, its site token and its logins stay. Run after git pull or a package upgrade; prints the plan, --yes does it`).option("--site <url>", "the hosted relay to join inside, only when the volume holds no site token (default: the site this machine is in)").option("--invite <code>", "redeem an invite inside instead of the device flow, only when the volume holds no site token").option("--memory <size>", "the VM's memory (default: what the container has now)").option("--cpus <n>", "the VM's CPUs (default: what the container has now)").option("--yes", "rebuild the image and recreate the container").action((o) => containerInstall({ jobs: "1", interval: String(POLL_MS / 1e3), maxMinutes: "60", sandbox: true, ...o }, "update"));
10268
+ container.command("login [provider]").description(
10269
+ `make the vendor logins inside ${CONTAINER_NAME} from this Mac: ${LOGIN_PROVIDERS.join(", ")} or all (default). The ones relay doctor inside already reports are skipped. codex and grok: fx's authorize URL opens in this Mac's browser and the redirect is caught here; claude and gh: the CLI runs in this terminal (paste a code, device code)`
10270
+ ).option("--force", "log in again even when the volume already holds that login").option("--no-open", "print each authorize URL instead of opening this Mac's browser").option("--timeout <seconds>", "how long to wait for each browser login", String(LOGIN_TIMEOUT_MS / 1e3)).action((provider, o) => containerLogin(provider ?? "all", o));
10271
+ function lanesLine(doctor) {
10272
+ const lanes = lanesFromDoctorOutput(doctor);
10273
+ if (lanes === void 0) return "lanes: unknown (no lanes line from relay doctor inside)";
10274
+ return lanes.length ? `lanes: ${lanes.join(", ")} (the container worker serves these)` : `lanes: none yet; log in with: ${CONTAINER_LOGIN_COMMAND}`;
10275
+ }
10276
+ async function containerLogin(which, o) {
10277
+ const c = needContainer();
10278
+ if (which !== "all" && !isLoginProvider(which)) throw new Error(`login: '${which}' is not one of ${[...LOGIN_PROVIDERS, "all"].join(", ")}`);
10279
+ const wanted = which === "all" ? [...LOGIN_PROVIDERS] : [which];
10280
+ const timeoutMs = Math.max(1, Number(o.timeout) || LOGIN_TIMEOUT_MS / 1e3) * 1e3;
10281
+ const state = containerStateFrom(deps.sh([c.bin, "list", "--all", "--format", "json"]).out);
10282
+ if (!state.exists) throw new Error(`no ${CONTAINER_NAME} container; create it with: relay work --isolation container --install --yes`);
10283
+ if (!state.running) throw new Error(`${CONTAINER_NAME} is not running; start it with: relay work container start`);
10284
+ const have = loginStatesFromDoctorOutput(deps.sh(c.plan.doctor, { timeoutMs: 12e4 }).out);
10285
+ const results = [];
10286
+ for (const p of wanted) {
10287
+ if (have[p] && !o.force) {
10288
+ console.log(`${p.padEnd(7)} already in the volume (relay doctor inside); skipped (--force logs in again)`);
10289
+ results.push({ provider: p, outcome: "skipped" });
10290
+ continue;
10291
+ }
10292
+ const r = p === "claude" || p === "gh" ? ttyLogin(c.plan, p) : await oauthLogin(c.plan, p, { open: o.open, timeoutMs });
10293
+ results.push(r);
10294
+ console.log(`${p.padEnd(7)} ${r.outcome === "ok" ? "signed in" : `FAILED: ${r.detail ?? ""}`}${r.outcome === "ok" && r.detail ? ` (${r.detail})` : ""}`);
10295
+ if (r.outcome === "failed" && r.manual) {
10296
+ console.log(" by hand:");
10297
+ for (const step of manualLoginSteps(p)) console.log(` ${step}`);
10298
+ }
10299
+ }
10300
+ const after2 = deps.sh(c.plan.doctor, { timeoutMs: 12e4 }).out;
10301
+ const now = loginStatesFromDoctorOutput(after2);
10302
+ console.log(`logins: ${LOGIN_PROVIDERS.map((p) => `${p} ${now[p] ? "ok" : "missing"}`).join(", ")} (relay doctor inside)`);
10303
+ console.log(lanesLine(after2));
10304
+ if (results.some((r) => r.outcome === "failed")) process.exitCode = 1;
10305
+ }
10306
+ function ttyLogin(p, provider) {
10307
+ if (provider === "claude") {
10308
+ console.log(`claude ${p.login.claude.join(" ")}`);
10309
+ console.log(" type /login, open the URL it prints on this Mac, paste the code back, then /exit");
10310
+ const code2 = deps.run(p.login.claude);
10311
+ return code2 === 0 ? { provider, outcome: "ok" } : { provider, outcome: "failed", detail: `claude exited ${code2}`, manual: true };
10312
+ }
10313
+ console.log(`gh ${p.login.gh.join(" ")}`);
10314
+ console.log(" the device code prints below; enter it at github.com/login/device on this Mac");
10315
+ const code = deps.run(p.login.gh);
10316
+ if (code !== 0) return { provider, outcome: "failed", detail: `gh auth login exited ${code}`, manual: true };
10317
+ const r = deps.sh(p.login.ghSetupGit, { timeoutMs: 6e4 });
10318
+ if (!r.ok) return { provider, outcome: "failed", detail: `gh auth setup-git failed: ${r.out.split("\n")[0] ?? ""}`, manual: true };
10319
+ return { provider, outcome: "ok", detail: "gh auth setup-git done" };
10320
+ }
10321
+ function oauthLogin(p, provider, o) {
10322
+ const words = p.login.fx(provider);
10323
+ const published = deps.publishedPorts ?? LOGIN_CALLBACK_PORTS;
10324
+ const pad = " ";
10325
+ console.log(`${provider.padEnd(7)} ${words.join(" ")}`);
10326
+ return new Promise((resolve5) => {
10327
+ const child = (deps.spawn ?? defaultSpawn)(words);
10328
+ let text = "";
10329
+ let pending = "";
10330
+ let redirect;
10331
+ let server;
10332
+ let settled = false;
10333
+ let handedOver = false;
10334
+ const flush = () => {
10335
+ if (pending.trim()) console.log(`${pad}fx ${pending}`);
10336
+ pending = "";
10337
+ };
10338
+ const finish = (r) => {
10339
+ if (settled) return;
10340
+ settled = true;
10341
+ clearTimeout(timer);
10342
+ server?.close();
10343
+ server?.closeAllConnections();
10344
+ if (child.exitCode === null && child.signalCode === null) child.kill();
10345
+ child.stdin?.end();
10346
+ flush();
10347
+ resolve5({ provider, ...r });
10348
+ };
10349
+ const timer = setTimeout(() => finish({ outcome: "failed", detail: `timed out after ${Math.round(o.timeoutMs / 1e3)}s waiting for the browser login`, manual: true }), o.timeoutMs);
10350
+ const onData = (chunk) => {
10351
+ const s = chunk.toString();
10352
+ text += s;
10353
+ pending += s;
10354
+ const lines = pending.split("\n");
10355
+ pending = lines.pop();
10356
+ for (const l of lines) if (l.trim()) console.log(`${pad}fx ${l}`);
10357
+ if (!redirect) {
10358
+ const r = parseLoginUrl(text);
10359
+ if (r) {
10360
+ redirect = r;
10361
+ listen(r);
10362
+ }
10363
+ }
10364
+ };
10365
+ child.stdout?.on("data", onData);
10366
+ child.stderr?.on("data", onData);
10367
+ child.on("error", (e) => finish({ outcome: "failed", detail: `could not run ${words[0]}: ${e.message}`, manual: true }));
10368
+ child.on("exit", (code, signal) => {
10369
+ if (settled) return;
10370
+ if (code === 0) return finish({ outcome: "ok", ...redirect ? {} : { detail: "fx needed no browser" } });
10371
+ finish({ outcome: "failed", detail: `fx login ${provider} exited ${code ?? signal}${redirect ? "" : " before printing an authorize URL"}`, manual: true });
10372
+ });
10373
+ function listen(r) {
10374
+ const srv = createServer((req, res) => handle(r, req, res));
10375
+ srv.once("error", (e) => {
10376
+ if (e.code === "EADDRINUSE" && published.includes(r.port)) {
10377
+ console.log(`${pad}127.0.0.1:${r.port} is the container's published port: the browser's redirect reaches the login server inside through it; waiting for fx to finish`);
10378
+ return openBrowser(r);
10379
+ }
10380
+ finish({ outcome: "failed", detail: `cannot listen on 127.0.0.1:${r.port} for the browser's redirect (${e.code === "EADDRINUSE" ? "the port is in use on this Mac" : e.message})`, manual: true });
10381
+ });
10382
+ srv.listen(r.port, "127.0.0.1", () => {
10383
+ server = srv;
10384
+ console.log(`${pad}listening on 127.0.0.1:${r.port} for the browser's redirect`);
10385
+ openBrowser(r);
10386
+ });
10387
+ }
10388
+ function openBrowser(r) {
10389
+ if (!o.open) return console.log(`${pad}open this URL on this Mac:
10390
+ ${pad}${r.url}`);
10391
+ const opener = deps.platform === "darwin" ? "open" : "xdg-open";
10392
+ const res = deps.sh([opener, r.url]);
10393
+ console.log(res.ok ? `${pad}opened ${r.url.split("?")[0]} in this Mac's browser; finish the sign-in there` : `${pad}could not open the browser (${res.out.split("\n")[0] ?? ""}); open this URL yourself:
10394
+ ${pad}${r.url}`);
10395
+ }
10396
+ function handle(r, req, res) {
10397
+ const url = req.url ?? "/";
10398
+ if (!url.startsWith(r.path)) {
10399
+ res.writeHead(404);
10400
+ res.end();
10401
+ return;
10402
+ }
10403
+ const html = { "content-type": "text/html; charset=utf-8" };
10404
+ const cb = parseLoginCallback(url);
10405
+ if (cb.error) {
10406
+ const detail = `${cb.error}${cb.errorDescription ? `: ${cb.errorDescription}` : ""}`;
10407
+ res.writeHead(200, html);
10408
+ res.end(loginDonePage(provider, false, detail));
10409
+ return finish({ outcome: "failed", detail: `the provider answered ${detail}`, manual: true });
10410
+ }
10411
+ if (!cb.code) {
10412
+ res.writeHead(400, html);
10413
+ res.end(loginDonePage(provider, false, "the redirect carried no code"));
10414
+ return;
10415
+ }
10416
+ if (r.state && cb.state && cb.state !== r.state) {
10417
+ res.writeHead(400, html);
10418
+ res.end(loginDonePage(provider, false, "the state does not match this login"));
10419
+ return finish({ outcome: "failed", detail: "the redirect's state does not match the one fx sent (another login in flight?)", manual: true });
10420
+ }
10421
+ res.writeHead(200, html);
10422
+ res.end(loginDonePage(provider, true));
10423
+ if (handedOver) return;
10424
+ handedOver = true;
10425
+ if (provider === "codex") {
10426
+ const rr = deps.sh(p.login.replay(replayUrl(r, url)), { timeoutMs: 3e4 });
10427
+ console.log(rr.ok ? `${pad}redirect received; replayed to the Codex listener inside (${r.host}:${r.port}) and the code pasted to fx` : `${pad}redirect received; the replay inside failed (${rr.out.split("\n")[0] ?? ""}); pasting the code to fx`);
10428
+ } else console.log(`${pad}redirect received; pasting the code to fx`);
10429
+ child.stdin?.write(`${cb.code}
10430
+ `);
10431
+ }
10432
+ });
10433
+ }
10011
10434
  function needContainer() {
10012
10435
  const bin = deps.onPath("container");
10013
10436
  if (!bin) throw new Error("Apple's container CLI is not on PATH. Install it from https://github.com/apple/container/releases, then: container system start");
@@ -10055,12 +10478,31 @@ function registerWork(program2, version2, deps = defaultDeps) {
10055
10478
  writeFileSync11(p.path, p.content);
10056
10479
  log2(`wrote ${p.path}`);
10057
10480
  if (deps.platform === "darwin") deps.sh(p.unload[0]);
10058
- for (const w of p.load) {
10481
+ loadService(p.load);
10482
+ log2(`service running; follow it with: tail -f ${p.logPath}; check with: relay work status`);
10483
+ }
10484
+ function loadService(load2, opts = {}) {
10485
+ for (const w of load2) {
10059
10486
  const r = deps.sh(w);
10060
- if (!r.ok) throw new Error(`${w.join(" ")} failed: ${r.out.split("\n")[0] ?? ""}`);
10061
- log2(`ran ${w.join(" ")}`);
10487
+ if (r.ok) {
10488
+ log2(`ran ${w.join(" ")}`);
10489
+ continue;
10490
+ }
10491
+ if (opts.tolerateLoaded && /already|in progress|service already loaded/i.test(r.out)) continue;
10492
+ const guiIdx = w.findIndex((a) => /^gui\/\d+$/.test(a));
10493
+ if (deps.platform === "darwin" && guiIdx >= 0) {
10494
+ const alt = [...w];
10495
+ alt[guiIdx] = w[guiIdx].replace(/^gui\//, "user/");
10496
+ const r2 = deps.sh(alt);
10497
+ if (r2.ok) {
10498
+ log2(`ran ${alt.join(" ")}`);
10499
+ console.log(`note loaded into the user domain: no desktop session for this user (SSH?). It runs while this login lasts; log in on the Mac's screen (or enable automatic login) and re-run relay work --install --yes for the gui domain, which survives logouts.`);
10500
+ continue;
10501
+ }
10502
+ throw new Error(`${w.join(" ")} failed: ${r.out.split("\n")[0] ?? ""} (and user domain: ${r2.out.split("\n")[0] ?? ""}); log in on the Mac's screen as this user once, then re-run`);
10503
+ }
10504
+ throw new Error(`${w.join(" ")} failed: ${r.out.split("\n")[0] ?? ""}`);
10062
10505
  }
10063
- log2(`service running; follow it with: tail -f ${p.logPath}; check with: relay work status`);
10064
10506
  }
10065
10507
  function containerInstallOrRemove(o) {
10066
10508
  if (!o.uninstall) return containerInstall(o, "install");
@@ -10108,7 +10550,9 @@ function registerWork(program2, version2, deps = defaultDeps) {
10108
10550
  const { steps, allKept } = containerLoginSteps(doctorText);
10109
10551
  if (allKept) return console.log(`logins kept in the volume: ${steps.filter((s) => s.kept).map((s) => s.login).join(", ")} (relay doctor inside)`);
10110
10552
  console.log(intro);
10111
- for (const s of steps) console.log(`${indent}${s.cmd.padEnd(36)} ${s.kept ? "kept (already in the volume)" : s.why}`);
10553
+ console.log(`${indent}${CONTAINER_LOGIN_COMMAND.padEnd(36)} ${CONTAINER_LOGIN_COMMAND_WHY}`);
10554
+ console.log(`${indent}or by hand, inside, in this order:`);
10555
+ for (const s of steps) console.log(`${indent} ${s.cmd.padEnd(34)} ${s.kept ? "kept (already in the volume)" : s.why}`);
10112
10556
  if (doctorText === void 0) console.log(`${indent}(the logins already in the volume are kept; relay work container status says which)`);
10113
10557
  }
10114
10558
  function containerInstall(o, mode) {
@@ -10172,7 +10616,7 @@ function registerWork(program2, version2, deps = defaultDeps) {
10172
10616
  console.log(service.content.replace(/^/gm, " "));
10173
10617
  for (const w of service.load) console.log(`would run ${w.join(" ")}`);
10174
10618
  console.log(`log file ${service.logPath} (the container's output; inside: ${CONTAINER_NAME}:${CONTAINER_RELAY_HOME}/work.log)`);
10175
- printLogins(" ", now?.doctor, "then log in inside, in this order:");
10619
+ printLogins(" ", now?.doctor, "then log in inside:");
10176
10620
  if (deps.platform === "darwin") console.log("note a LaunchAgent runs while this user is logged in; enable automatic login or keep a session open on a machine that must stay available");
10177
10621
  if (!o.yes) return console.log(`
10178
10622
  re-run with --yes to ${mode === "update" ? "rebuild the image, recreate the container and restart the service" : "build the image, create the container and install the service"}`);
@@ -10221,14 +10665,10 @@ re-run with --yes to ${mode === "update" ? "rebuild the image, recreate the cont
10221
10665
  mkdirSync15(dirname8(service.logPath), { recursive: true });
10222
10666
  writeFileSync11(service.path, service.content);
10223
10667
  log2(`wrote ${service.path}`);
10224
- for (const w of service.load) {
10225
- const r = deps.sh(w);
10226
- if (!r.ok) throw new Error(`${w.join(" ")} failed: ${r.out.split("\n")[0] ?? ""}`);
10227
- log2(`ran ${w.join(" ")}`);
10228
- }
10668
+ loadService(service.load);
10229
10669
  console.log(`
10230
10670
  ${CONTAINER_NAME} is running ${image} as worker ${workerName}.`);
10231
- printLogins(" ", after2.doctor, "Log in inside, in this order:\n");
10671
+ printLogins(" ", after2.doctor, "Log in inside:\n");
10232
10672
  console.log(`
10233
10673
  The logins land in the ${CONTAINER_VOLUME} volume, never on this Mac's home. Check with: relay work container status; the worker's log: relay work container logs -f`);
10234
10674
  }
@@ -10267,7 +10707,7 @@ program.command("init").description("Create ~/.harness/config.json if missing, r
10267
10707
  const h = await openHarness(cfg, { local: true });
10268
10708
  try {
10269
10709
  log3(`database: ${h.db.driver}${h.db.driver === "pglite" ? ` at ${cfg.dbFile}` : " via DATABASE_URL"}`);
10270
- if (cfg.site) log3(`site mode: commands talk to ${cfg.site}; this local database serves relay link's digest cache and the stdio MCP server`);
10710
+ if (cfg.site) log3(`site mode: commands and the stdio MCP server talk to ${cfg.site}; this local database serves relay link's digest cache`);
10271
10711
  const v = await migrate(h.db, { log: log3 });
10272
10712
  log3(`schema at v${v}`);
10273
10713
  await seedRegistry(h, log3);
@@ -10275,10 +10715,11 @@ program.command("init").description("Create ~/.harness/config.json if missing, r
10275
10715
  await h.close();
10276
10716
  }
10277
10717
  });
10278
- program.command("doctor").description("Report Node version, which CLIs are installed and logged in, the lanes this machine can serve, whether Claude's OS sandbox is available, the container worker tier (macOS), whether the database or site is reachable, how this machine authenticates, and (site mode) which linked repos still lack the GitHub App").action(async () => {
10718
+ program.command("doctor").description("Report Node version, which CLIs are installed and logged in, the lanes this machine can serve, whether Claude's OS sandbox is available, the container worker tier (macOS), whether the database or site is reachable, how this machine authenticates, which mode the installed MCP server runs in, and (site mode) which linked repos still lack the GitHub App").action(async () => {
10279
10719
  const checks = [];
10280
10720
  const major = Number(process.versions.node.split(".")[0]);
10281
10721
  checks.push({ status: major >= 24 ? "ok" : "fail", name: "node", detail: `v${process.versions.node}${major >= 24 ? "" : " (need 24+)"}` });
10722
+ checks.push(pathCheck());
10282
10723
  const tools = await probeTools();
10283
10724
  checks.push(...tools);
10284
10725
  const lanes = lanesFromChecks(tools);
@@ -10287,8 +10728,9 @@ program.command("doctor").description("Report Node version, which CLIs are insta
10287
10728
  if (process.platform === "darwin" && !inContainer()) checks.push(containerCheck(await containerReport()));
10288
10729
  const cfg = loadConfig();
10289
10730
  const siteMode = !!cfg.site && !!cfg.siteToken;
10731
+ let live = cfg;
10290
10732
  if (cfg.site && !cfg.siteToken) checks.push({ status: "fail", name: "mode", detail: `site ${cfg.site} is configured without a token; run: relay setup --site ${cfg.site}` });
10291
- else checks.push({ status: "ok", name: "mode", detail: siteMode ? `site: ${cfg.site} (memory, feed, token, invite, task and webhook commands and the hooks use it)` : "local: the database on this machine" });
10733
+ else checks.push({ status: "ok", name: "mode", detail: siteMode ? `site: ${cfg.site} (memory, feed, token, invite, task and webhook commands, the hooks and the MCP server use it)` : "local: the database on this machine" });
10292
10734
  if (siteMode) {
10293
10735
  const t02 = Date.now();
10294
10736
  const sourceLabel = { device: "approved on /activate (device flow)", invite: "redeemed an invite", manual: "pasted with --token", env: "RELAY_TOKEN in the environment" }[cfg.siteTokenSource ?? "manual"];
@@ -10296,6 +10738,7 @@ program.command("doctor").description("Report Node version, which CLIs are insta
10296
10738
  checks.push({ status: "ok", name: "auth", detail: `${sourceLabel}${cfg.siteTokenName ? ` as ${cfg.siteTokenName}` : ""}${cfg.siteTokenExpiresAt ? `, expires ${cfg.siteTokenExpiresAt}` : ", no expiry"}${renews}` });
10297
10739
  try {
10298
10740
  const h = await openReady();
10741
+ live = h.cfg;
10299
10742
  try {
10300
10743
  const health = await h.client.health();
10301
10744
  checks.push({ status: health.ok ? "ok" : "fail", name: "site", detail: `${health.detail}, ${Date.now() - t02} ms` });
@@ -10323,13 +10766,14 @@ program.command("doctor").description("Report Node version, which CLIs are insta
10323
10766
  await db.query("SELECT 1");
10324
10767
  const v = await currentVersion(db);
10325
10768
  const where = db.driver === "pg" ? "postgres via DATABASE_URL" : `pglite at ${cfg.dbFile}`;
10326
- checks.push({ status: v > 0 ? "ok" : siteMode ? "ok" : "warn", name: "database", detail: `${where}, schema v${v}${v > 0 || siteMode ? "" : " (run: relay init)"}, ${Date.now() - t0} ms${siteMode ? " (digest cache and stdio MCP only)" : ""}` });
10769
+ checks.push({ status: v > 0 ? "ok" : siteMode ? "ok" : "warn", name: "database", detail: `${where}, schema v${v}${v > 0 || siteMode ? "" : " (run: relay init)"}, ${Date.now() - t0} ms${siteMode ? " (relay link's digest cache only)" : ""}` });
10327
10770
  } finally {
10328
10771
  await db.close();
10329
10772
  }
10330
10773
  } catch (e) {
10331
10774
  checks.push({ status: siteMode ? "warn" : "fail", name: "database", detail: `unreachable: ${(e instanceof Error ? e.message : String(e)).slice(0, 120)}` });
10332
10775
  }
10776
+ checks.push(await mcpCheck(live, { entry: hookPaths(defaultHookContext(RELAY_ROOT)).mcpBin }));
10333
10777
  checks.push({ status: cfg.gateway.apiKey ? "ok" : "warn", name: "gateway", detail: cfg.gateway.apiKey ? "AI_GATEWAY_API_KEY set (embeddings on)" : "AI_GATEWAY_API_KEY unset; retrieval uses full-text only" });
10334
10778
  checks.push(metersCheck(cfg.home));
10335
10779
  for (const c of checks) console.log(`${c.status.padEnd(5)} ${c.name.padEnd(13)} ${c.detail}`);
@@ -10489,6 +10933,7 @@ program.command("usage").option("--days <n>", "window", "7").description("Ledger
10489
10933
  }
10490
10934
  });
10491
10935
  program.parseAsync(process.argv).catch((e) => {
10492
- console.error(e instanceof Error ? e.stack ?? e.message : e);
10936
+ const msg = e instanceof Error ? e.message : String(e);
10937
+ console.error(process.env.RELAY_DEBUG && e instanceof Error ? e.stack ?? msg : `relay: ${msg}`);
10493
10938
  process.exit(1);
10494
10939
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyleroche/relay",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "relay: your coding-agent subscriptions (Claude Code, Codex, Grok) as one system. The CLI, the session hooks and the memory MCP server.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -56,8 +56,8 @@
56
56
  },
57
57
  "devDependencies": {
58
58
  "tsup": "^8.5.0",
59
- "@relay/mcp": "0.0.1",
60
- "@relay/core": "0.0.1"
59
+ "@relay/core": "0.0.1",
60
+ "@relay/mcp": "0.0.1"
61
61
  },
62
62
  "scripts": {
63
63
  "build": "tsup",