@kyleroche/relay 0.2.0 → 0.2.1

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,139 @@ 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
9592
9807
  var PROBE_TOOLS = ["claude", "codex", "fx", "grok", "gh"];
9593
9808
  function run3(bin, args, timeoutMs = 15e3) {
9594
9809
  return new Promise((res) => {
@@ -9664,6 +9879,31 @@ async function sandboxCheck(platform = process.platform) {
9664
9879
  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
9880
  return { status: "warn", name: "sandbox", detail: `bubblewrap and socat on PATH but ${probe.detail}; ${fix}` };
9666
9881
  }
9882
+ function pathCheck(opts = {}) {
9883
+ const find = opts.onPath ?? onPath2;
9884
+ const found = find("relay");
9885
+ if (found) return { status: "ok", name: "path", detail: `relay on PATH: ${found}` };
9886
+ const prefix = (opts.npmPrefix ?? defaultNpmPrefix)();
9887
+ const bin = prefix ? join26(prefix, "bin") : void 0;
9888
+ const shell = opts.shell ?? process.env.SHELL ?? "";
9889
+ const rc = shell.endsWith("zsh") ? "~/.zshrc" : shell.endsWith("fish") ? "~/.config/fish/config.fish" : "~/.bashrc";
9890
+ const running = opts.running ?? process.argv[1] ?? "";
9891
+ const viaNpx = /\/_npx\//.test(running);
9892
+ const install = viaNpx ? "you are running through npx, which installs no command; run `npm install -g @kyleroche/relay`, then " : "";
9893
+ const pathDirs = (opts.pathEnv ?? process.env.PATH ?? "").split(delimiter5).filter(Boolean);
9894
+ if (bin && pathDirs.includes(bin)) {
9895
+ return { status: "warn", name: "path", detail: `relay is not installed as a command: npm install -g @kyleroche/relay (a checkout uses its bin/relay)` };
9896
+ }
9897
+ 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`;
9898
+ return { status: "warn", name: "path", detail: `relay is not on PATH; ${fix}` };
9899
+ }
9900
+ function defaultNpmPrefix() {
9901
+ try {
9902
+ return execFileSync5("npm", ["prefix", "-g"], { encoding: "utf8", timeout: 5e3 }).trim() || void 0;
9903
+ } catch {
9904
+ return void 0;
9905
+ }
9906
+ }
9667
9907
  async function containerReport(opts = {}) {
9668
9908
  const bin = onPath2("container");
9669
9909
  const out = { serviceRunning: false, container: { exists: false, running: false } };
@@ -9689,7 +9929,7 @@ function containerCheck(r) {
9689
9929
  if (!r.serviceRunning) return { status: "warn", name, detail: `${r.bin} installed, services not running; run: container system start` };
9690
9930
  if (!r.container.exists) return { status: "ok", name, detail: `installed and running; no ${CONTAINER_NAME} container yet (relay work --isolation container --install)` };
9691
9931
  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)";
9932
+ 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
9933
  return { status: "ok", name, detail: `${CONTAINER_NAME} running${r.container.image ? ` (${r.container.image})` : ""}; ${lanes}` };
9694
9934
  }
9695
9935
  function fmtAge(ms) {
@@ -9731,8 +9971,22 @@ function lanesFromChecks(checks) {
9731
9971
  return { lanes, reasons };
9732
9972
  }
9733
9973
  var detectLanes = async () => lanesFromChecks(await probeTools());
9974
+ async function mcpCheck(cfg, opts) {
9975
+ const name = "mcp";
9976
+ 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}`}` };
9977
+ const now = opts.now ?? Date.now;
9978
+ const t0 = now();
9979
+ const where = `site mode: ${opts.entry} forwards to ${cfg.site} as ${tokenLabel(cfg)}`;
9980
+ try {
9981
+ const { tools } = await (opts.listTools ?? listSiteTools)({ site: cfg.site, token: cfg.siteToken });
9982
+ return { status: "ok", name, detail: `${where}; tools/list answered with ${tools.length} tools, ${now() - t0} ms` };
9983
+ } catch (e) {
9984
+ return { status: "fail", name, detail: `${where}; tools/list failed: ${(e instanceof Error ? e.message : String(e)).slice(0, 200)}` };
9985
+ }
9986
+ }
9734
9987
 
9735
9988
  // src/commands/work.ts
9989
+ var defaultSpawn = (words) => spawn(words[0], words.slice(1), { stdio: ["pipe", "pipe", "pipe"] });
9736
9990
  var LANES = ["claude", "codex", "grok"];
9737
9991
  var list = (s) => (s ?? "").split(",").map((x) => x.trim()).filter(Boolean);
9738
9992
  var workLogPath = () => join27(relayHome(), "work.log");
@@ -9783,7 +10037,7 @@ function plan(o, deps, containerBin) {
9783
10037
  var defaultDeps = {
9784
10038
  sh(words, opts) {
9785
10039
  try {
9786
- return { ok: true, out: execFileSync5(words[0], words.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: opts?.timeoutMs ?? 3e4 }).trim() };
10040
+ return { ok: true, out: execFileSync6(words[0], words.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: opts?.timeoutMs ?? 3e4 }).trim() };
9787
10041
  } catch (e) {
9788
10042
  const err = e;
9789
10043
  const out = [err.stderr, err.stdout].map((s) => (s ?? "").toString().trim()).filter(Boolean).join("\n");
@@ -9833,7 +10087,7 @@ async function waitForReadiness(out, forced, signal) {
9833
10087
  const d = forced ? { lanes: forced, reasons: [] } : await detectLanes();
9834
10088
  const missing = [];
9835
10089
  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`);
10090
+ if (!d.lanes.length) missing.push(`no lanes yet: ${d.reasons.join("; ")}; log in from the Mac with: ${CONTAINER_LOGIN_COMMAND}`);
9837
10091
  if (!missing.length) return d.lanes;
9838
10092
  const msg = `waiting: ${missing.join(" | ")}`;
9839
10093
  if (msg !== said) out(msg);
@@ -9952,7 +10206,7 @@ function registerWork(program2, version2, deps = defaultDeps) {
9952
10206
  }
9953
10207
  });
9954
10208
  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(() => {
10209
+ 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
10210
  const c = needContainer();
9957
10211
  process.exitCode = deps.run(c.plan.shell);
9958
10212
  });
@@ -9965,8 +10219,7 @@ function registerWork(program2, version2, deps = defaultDeps) {
9965
10219
  if (!state.running) return console.log("start it with: relay work container start");
9966
10220
  const doctor = deps.sh(c.plan.doctor, { timeoutMs: 12e4 }).out;
9967
10221
  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");
10222
+ console.log(lanesLine(doctor));
9970
10223
  });
9971
10224
  container.command("stop").description(`stop ${CONTAINER_NAME} (and unload the service that keeps it running, so it stays stopped)`).action(() => {
9972
10225
  const c = needContainer();
@@ -10008,6 +10261,172 @@ function registerWork(program2, version2, deps = defaultDeps) {
10008
10261
  process.exitCode = deps.run(c.plan.logs(!!o.follow, Number(o.n) || void 0));
10009
10262
  });
10010
10263
  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"));
10264
+ container.command("login [provider]").description(
10265
+ `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)`
10266
+ ).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));
10267
+ function lanesLine(doctor) {
10268
+ const lanes = lanesFromDoctorOutput(doctor);
10269
+ if (lanes === void 0) return "lanes: unknown (no lanes line from relay doctor inside)";
10270
+ return lanes.length ? `lanes: ${lanes.join(", ")} (the container worker serves these)` : `lanes: none yet; log in with: ${CONTAINER_LOGIN_COMMAND}`;
10271
+ }
10272
+ async function containerLogin(which, o) {
10273
+ const c = needContainer();
10274
+ if (which !== "all" && !isLoginProvider(which)) throw new Error(`login: '${which}' is not one of ${[...LOGIN_PROVIDERS, "all"].join(", ")}`);
10275
+ const wanted = which === "all" ? [...LOGIN_PROVIDERS] : [which];
10276
+ const timeoutMs = Math.max(1, Number(o.timeout) || LOGIN_TIMEOUT_MS / 1e3) * 1e3;
10277
+ const state = containerStateFrom(deps.sh([c.bin, "list", "--all", "--format", "json"]).out);
10278
+ if (!state.exists) throw new Error(`no ${CONTAINER_NAME} container; create it with: relay work --isolation container --install --yes`);
10279
+ if (!state.running) throw new Error(`${CONTAINER_NAME} is not running; start it with: relay work container start`);
10280
+ const have = loginStatesFromDoctorOutput(deps.sh(c.plan.doctor, { timeoutMs: 12e4 }).out);
10281
+ const results = [];
10282
+ for (const p of wanted) {
10283
+ if (have[p] && !o.force) {
10284
+ console.log(`${p.padEnd(7)} already in the volume (relay doctor inside); skipped (--force logs in again)`);
10285
+ results.push({ provider: p, outcome: "skipped" });
10286
+ continue;
10287
+ }
10288
+ const r = p === "claude" || p === "gh" ? ttyLogin(c.plan, p) : await oauthLogin(c.plan, p, { open: o.open, timeoutMs });
10289
+ results.push(r);
10290
+ console.log(`${p.padEnd(7)} ${r.outcome === "ok" ? "signed in" : `FAILED: ${r.detail ?? ""}`}${r.outcome === "ok" && r.detail ? ` (${r.detail})` : ""}`);
10291
+ if (r.outcome === "failed" && r.manual) {
10292
+ console.log(" by hand:");
10293
+ for (const step of manualLoginSteps(p)) console.log(` ${step}`);
10294
+ }
10295
+ }
10296
+ const after2 = deps.sh(c.plan.doctor, { timeoutMs: 12e4 }).out;
10297
+ const now = loginStatesFromDoctorOutput(after2);
10298
+ console.log(`logins: ${LOGIN_PROVIDERS.map((p) => `${p} ${now[p] ? "ok" : "missing"}`).join(", ")} (relay doctor inside)`);
10299
+ console.log(lanesLine(after2));
10300
+ if (results.some((r) => r.outcome === "failed")) process.exitCode = 1;
10301
+ }
10302
+ function ttyLogin(p, provider) {
10303
+ if (provider === "claude") {
10304
+ console.log(`claude ${p.login.claude.join(" ")}`);
10305
+ console.log(" type /login, open the URL it prints on this Mac, paste the code back, then /exit");
10306
+ const code2 = deps.run(p.login.claude);
10307
+ return code2 === 0 ? { provider, outcome: "ok" } : { provider, outcome: "failed", detail: `claude exited ${code2}`, manual: true };
10308
+ }
10309
+ console.log(`gh ${p.login.gh.join(" ")}`);
10310
+ console.log(" the device code prints below; enter it at github.com/login/device on this Mac");
10311
+ const code = deps.run(p.login.gh);
10312
+ if (code !== 0) return { provider, outcome: "failed", detail: `gh auth login exited ${code}`, manual: true };
10313
+ const r = deps.sh(p.login.ghSetupGit, { timeoutMs: 6e4 });
10314
+ if (!r.ok) return { provider, outcome: "failed", detail: `gh auth setup-git failed: ${r.out.split("\n")[0] ?? ""}`, manual: true };
10315
+ return { provider, outcome: "ok", detail: "gh auth setup-git done" };
10316
+ }
10317
+ function oauthLogin(p, provider, o) {
10318
+ const words = p.login.fx(provider);
10319
+ const published = deps.publishedPorts ?? LOGIN_CALLBACK_PORTS;
10320
+ const pad = " ";
10321
+ console.log(`${provider.padEnd(7)} ${words.join(" ")}`);
10322
+ return new Promise((resolve5) => {
10323
+ const child = (deps.spawn ?? defaultSpawn)(words);
10324
+ let text = "";
10325
+ let pending = "";
10326
+ let redirect;
10327
+ let server;
10328
+ let settled = false;
10329
+ let handedOver = false;
10330
+ const flush = () => {
10331
+ if (pending.trim()) console.log(`${pad}fx ${pending}`);
10332
+ pending = "";
10333
+ };
10334
+ const finish = (r) => {
10335
+ if (settled) return;
10336
+ settled = true;
10337
+ clearTimeout(timer);
10338
+ server?.close();
10339
+ server?.closeAllConnections();
10340
+ if (child.exitCode === null && child.signalCode === null) child.kill();
10341
+ child.stdin?.end();
10342
+ flush();
10343
+ resolve5({ provider, ...r });
10344
+ };
10345
+ 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);
10346
+ const onData = (chunk) => {
10347
+ const s = chunk.toString();
10348
+ text += s;
10349
+ pending += s;
10350
+ const lines = pending.split("\n");
10351
+ pending = lines.pop();
10352
+ for (const l of lines) if (l.trim()) console.log(`${pad}fx ${l}`);
10353
+ if (!redirect) {
10354
+ const r = parseLoginUrl(text);
10355
+ if (r) {
10356
+ redirect = r;
10357
+ listen(r);
10358
+ }
10359
+ }
10360
+ };
10361
+ child.stdout?.on("data", onData);
10362
+ child.stderr?.on("data", onData);
10363
+ child.on("error", (e) => finish({ outcome: "failed", detail: `could not run ${words[0]}: ${e.message}`, manual: true }));
10364
+ child.on("exit", (code, signal) => {
10365
+ if (settled) return;
10366
+ if (code === 0) return finish({ outcome: "ok", ...redirect ? {} : { detail: "fx needed no browser" } });
10367
+ finish({ outcome: "failed", detail: `fx login ${provider} exited ${code ?? signal}${redirect ? "" : " before printing an authorize URL"}`, manual: true });
10368
+ });
10369
+ function listen(r) {
10370
+ const srv = createServer((req, res) => handle(r, req, res));
10371
+ srv.once("error", (e) => {
10372
+ if (e.code === "EADDRINUSE" && published.includes(r.port)) {
10373
+ 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`);
10374
+ return openBrowser(r);
10375
+ }
10376
+ 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 });
10377
+ });
10378
+ srv.listen(r.port, "127.0.0.1", () => {
10379
+ server = srv;
10380
+ console.log(`${pad}listening on 127.0.0.1:${r.port} for the browser's redirect`);
10381
+ openBrowser(r);
10382
+ });
10383
+ }
10384
+ function openBrowser(r) {
10385
+ if (!o.open) return console.log(`${pad}open this URL on this Mac:
10386
+ ${pad}${r.url}`);
10387
+ const opener = deps.platform === "darwin" ? "open" : "xdg-open";
10388
+ const res = deps.sh([opener, r.url]);
10389
+ 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:
10390
+ ${pad}${r.url}`);
10391
+ }
10392
+ function handle(r, req, res) {
10393
+ const url = req.url ?? "/";
10394
+ if (!url.startsWith(r.path)) {
10395
+ res.writeHead(404);
10396
+ res.end();
10397
+ return;
10398
+ }
10399
+ const html = { "content-type": "text/html; charset=utf-8" };
10400
+ const cb = parseLoginCallback(url);
10401
+ if (cb.error) {
10402
+ const detail = `${cb.error}${cb.errorDescription ? `: ${cb.errorDescription}` : ""}`;
10403
+ res.writeHead(200, html);
10404
+ res.end(loginDonePage(provider, false, detail));
10405
+ return finish({ outcome: "failed", detail: `the provider answered ${detail}`, manual: true });
10406
+ }
10407
+ if (!cb.code) {
10408
+ res.writeHead(400, html);
10409
+ res.end(loginDonePage(provider, false, "the redirect carried no code"));
10410
+ return;
10411
+ }
10412
+ if (r.state && cb.state && cb.state !== r.state) {
10413
+ res.writeHead(400, html);
10414
+ res.end(loginDonePage(provider, false, "the state does not match this login"));
10415
+ return finish({ outcome: "failed", detail: "the redirect's state does not match the one fx sent (another login in flight?)", manual: true });
10416
+ }
10417
+ res.writeHead(200, html);
10418
+ res.end(loginDonePage(provider, true));
10419
+ if (handedOver) return;
10420
+ handedOver = true;
10421
+ if (provider === "codex") {
10422
+ const rr = deps.sh(p.login.replay(replayUrl(r, url)), { timeoutMs: 3e4 });
10423
+ 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`);
10424
+ } else console.log(`${pad}redirect received; pasting the code to fx`);
10425
+ child.stdin?.write(`${cb.code}
10426
+ `);
10427
+ }
10428
+ });
10429
+ }
10011
10430
  function needContainer() {
10012
10431
  const bin = deps.onPath("container");
10013
10432
  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");
@@ -10108,7 +10527,9 @@ function registerWork(program2, version2, deps = defaultDeps) {
10108
10527
  const { steps, allKept } = containerLoginSteps(doctorText);
10109
10528
  if (allKept) return console.log(`logins kept in the volume: ${steps.filter((s) => s.kept).map((s) => s.login).join(", ")} (relay doctor inside)`);
10110
10529
  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}`);
10530
+ console.log(`${indent}${CONTAINER_LOGIN_COMMAND.padEnd(36)} ${CONTAINER_LOGIN_COMMAND_WHY}`);
10531
+ console.log(`${indent}or by hand, inside, in this order:`);
10532
+ for (const s of steps) console.log(`${indent} ${s.cmd.padEnd(34)} ${s.kept ? "kept (already in the volume)" : s.why}`);
10112
10533
  if (doctorText === void 0) console.log(`${indent}(the logins already in the volume are kept; relay work container status says which)`);
10113
10534
  }
10114
10535
  function containerInstall(o, mode) {
@@ -10172,7 +10593,7 @@ function registerWork(program2, version2, deps = defaultDeps) {
10172
10593
  console.log(service.content.replace(/^/gm, " "));
10173
10594
  for (const w of service.load) console.log(`would run ${w.join(" ")}`);
10174
10595
  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:");
10596
+ printLogins(" ", now?.doctor, "then log in inside:");
10176
10597
  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
10598
  if (!o.yes) return console.log(`
10178
10599
  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"}`);
@@ -10228,7 +10649,7 @@ re-run with --yes to ${mode === "update" ? "rebuild the image, recreate the cont
10228
10649
  }
10229
10650
  console.log(`
10230
10651
  ${CONTAINER_NAME} is running ${image} as worker ${workerName}.`);
10231
- printLogins(" ", after2.doctor, "Log in inside, in this order:\n");
10652
+ printLogins(" ", after2.doctor, "Log in inside:\n");
10232
10653
  console.log(`
10233
10654
  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
10655
  }
@@ -10267,7 +10688,7 @@ program.command("init").description("Create ~/.harness/config.json if missing, r
10267
10688
  const h = await openHarness(cfg, { local: true });
10268
10689
  try {
10269
10690
  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`);
10691
+ 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
10692
  const v = await migrate(h.db, { log: log3 });
10272
10693
  log3(`schema at v${v}`);
10273
10694
  await seedRegistry(h, log3);
@@ -10275,10 +10696,11 @@ program.command("init").description("Create ~/.harness/config.json if missing, r
10275
10696
  await h.close();
10276
10697
  }
10277
10698
  });
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 () => {
10699
+ 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
10700
  const checks = [];
10280
10701
  const major = Number(process.versions.node.split(".")[0]);
10281
10702
  checks.push({ status: major >= 24 ? "ok" : "fail", name: "node", detail: `v${process.versions.node}${major >= 24 ? "" : " (need 24+)"}` });
10703
+ checks.push(pathCheck());
10282
10704
  const tools = await probeTools();
10283
10705
  checks.push(...tools);
10284
10706
  const lanes = lanesFromChecks(tools);
@@ -10287,8 +10709,9 @@ program.command("doctor").description("Report Node version, which CLIs are insta
10287
10709
  if (process.platform === "darwin" && !inContainer()) checks.push(containerCheck(await containerReport()));
10288
10710
  const cfg = loadConfig();
10289
10711
  const siteMode = !!cfg.site && !!cfg.siteToken;
10712
+ let live = cfg;
10290
10713
  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" });
10714
+ 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
10715
  if (siteMode) {
10293
10716
  const t02 = Date.now();
10294
10717
  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 +10719,7 @@ program.command("doctor").description("Report Node version, which CLIs are insta
10296
10719
  checks.push({ status: "ok", name: "auth", detail: `${sourceLabel}${cfg.siteTokenName ? ` as ${cfg.siteTokenName}` : ""}${cfg.siteTokenExpiresAt ? `, expires ${cfg.siteTokenExpiresAt}` : ", no expiry"}${renews}` });
10297
10720
  try {
10298
10721
  const h = await openReady();
10722
+ live = h.cfg;
10299
10723
  try {
10300
10724
  const health = await h.client.health();
10301
10725
  checks.push({ status: health.ok ? "ok" : "fail", name: "site", detail: `${health.detail}, ${Date.now() - t02} ms` });
@@ -10323,13 +10747,14 @@ program.command("doctor").description("Report Node version, which CLIs are insta
10323
10747
  await db.query("SELECT 1");
10324
10748
  const v = await currentVersion(db);
10325
10749
  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)" : ""}` });
10750
+ 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
10751
  } finally {
10328
10752
  await db.close();
10329
10753
  }
10330
10754
  } catch (e) {
10331
10755
  checks.push({ status: siteMode ? "warn" : "fail", name: "database", detail: `unreachable: ${(e instanceof Error ? e.message : String(e)).slice(0, 120)}` });
10332
10756
  }
10757
+ checks.push(await mcpCheck(live, { entry: hookPaths(defaultHookContext(RELAY_ROOT)).mcpBin }));
10333
10758
  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
10759
  checks.push(metersCheck(cfg.home));
10335
10760
  for (const c of checks) console.log(`${c.status.padEnd(5)} ${c.name.padEnd(13)} ${c.detail}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyleroche/relay",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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",