relay-companion 0.1.554 → 0.1.556

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.
@@ -49,6 +49,7 @@ async function repairMacServiceRegistrations({
49
49
  catch { return { ok: true, changed: false, status: "deferred-update-owner" }; }
50
50
  const repaired = [];
51
51
  try {
52
+ if (require("./recovery-intent.cjs").stopped(homeDir)) return { ok: true, changed: false, status: "intentionally-stopped" };
52
53
  const recovery = require("./mac-registration-transaction.cjs");
53
54
  const pending = recovery.readSnapshot({ homeDir, fsImpl });
54
55
  if (pending) {
@@ -67,6 +68,7 @@ async function repairMacServiceRegistrations({
67
68
  const args = observed.present
68
69
  ? ["kickstart", `gui/${userId}/${record.label}`]
69
70
  : ["bootstrap", `gui/${userId}`, record.file];
71
+ if (require("./recovery-intent.cjs").stopped(homeDir)) return { ok: true, changed: repaired.length > 0, status: "intentionally-stopped" };
70
72
  const result = run("/bin/launchctl", args);
71
73
  if (!commandOk(result)) throw Error(`service-registration-repair-failed: ${record.label}`);
72
74
  repaired.push(record.label);
@@ -89,6 +91,7 @@ async function restartMacRegisteredServices(target, {
89
91
  try { lock = acquireLock(path.join(homeDir, ".relay", "runtime", "transaction.lock")); }
90
92
  catch { return { ok: false, reason: "deferred-update-owner" }; }
91
93
  try {
94
+ if (require("./recovery-intent.cjs").stopped(homeDir)) return { ok: false, reason: "intentionally-stopped" };
92
95
  const current = JSON.parse(fs.readFileSync(path.join(homeDir, ".relay", "runtime", "current.json"), "utf8"));
93
96
  if (!current?.active || current.packageRoot !== target.packageRoot) return { ok: false, reason: "runtime-changed" };
94
97
  const records = LABELS.map(label => readRegistration(label, { homeDir, run }));
@@ -97,6 +100,7 @@ async function restartMacRegisteredServices(target, {
97
100
  for (const record of records) {
98
101
  const observed = registration(record.label, { run, userId });
99
102
  if (!observed.known) return { ok: false, reason: "service-registration-query-failed" };
103
+ if (require("./recovery-intent.cjs").stopped(homeDir)) return { ok: false, reason: "intentionally-stopped" };
100
104
  const args = observed.present ? ["kickstart", "-k", `gui/${userId}/${record.label}`] : ["bootstrap", `gui/${userId}`, record.file];
101
105
  if (!commandOk(run("/bin/launchctl", args))) return { ok: false, reason: "service-restart-failed" };
102
106
  }
@@ -547,7 +547,7 @@ async function launch({ root = __dirname, run = runChild, now = Date.now, env =
547
547
  // A worker that lost the canonical lock to a live installer judged nothing
548
548
  // about this bundle either; the runner normally reports that as a deferral.
549
549
  const lostTransaction = /recovery-worker-exit-75|transaction-in-progress/i.test(report?.lastError || "");
550
- const retryableReport = ["disabled", "backoff", "emergency-backoff", "restart-failed", "reactivate-failed", "service-repair-failed", "service-repair-unhealthy"].includes(report?.status) || (report?.status === "failed" && (networkFailure || lostTransaction));
550
+ const retryableReport = ["disabled", "backoff", "emergency-backoff", "restart-failed", "pill-repair-failed", "observation-unavailable", "reactivate-failed", "service-repair-failed", "service-repair-unhealthy"].includes(report?.status) || (report?.status === "failed" && (networkFailure || lostTransaction));
551
551
  if (reported && retryableReport && result.reason !== "deadline") {
552
552
  write(path.join(root, "launcher-status.json"), { schema: 1, at: now(), status: "runner-error", version: candidate.version });
553
553
  log("done status=runner-error");
@@ -27,6 +27,19 @@ function repairProgress(homeDir, now = Date.now) {
27
27
  const save = () => atomicFile(file, JSON.stringify({ ...state, updatedAt: now() }));
28
28
  if (damaged) save();
29
29
  return {
30
+ // Observation belongs to the same durable repair record as its budget, not
31
+ // the replaceable human-facing status. A new process/root or an unwatched
32
+ // interval cannot inherit the old process's failure evidence.
33
+ observe(identity, { startedAt = now(), gapMs } = {}) {
34
+ const at = now(), old = state.observation;
35
+ const continuing = old?.identity === identity && Number.isFinite(old.since) && Number.isFinite(old.at)
36
+ && old.since <= old.at && old.at <= startedAt && startedAt <= at
37
+ && startedAt - old.at <= gapMs;
38
+ const since = continuing ? old.since : startedAt;
39
+ state.observation = { identity, since, at }; save();
40
+ return since;
41
+ },
42
+ clearObservation() { if (state.observation) { delete state.observation; save(); } },
30
43
  count: key => Number(state.attempts[key]) || 0,
31
44
  claim(key, limit) {
32
45
  if (state.localExhausted || (Number(state.attempts[key]) || 0) >= limit) return false;
@@ -42,6 +42,7 @@ async function waitForRecoveryReady({ homeDir = os.homedir(), platform = process
42
42
  let firstProgress = null, firstProgressAt = null, progressAdvanced = false;
43
43
  // Why the last sample did not count, for the log line a failed proof leaves.
44
44
  let block = null, samples = 0, slowestSampleMs = 0;
45
+ let observationInvalid = false;
45
46
  const reset = (key) => { since = null; firstBeat = null; identity = key; probeSeen = false; probeIdentity = null; legacy = false; firstProgress = null; firstProgressAt = null; progressAdvanced = false; };
46
47
  const healthSummary = (value) => value ? JSON.stringify({ daemon: value.daemon, pill: value.pill, daemonCount: value.daemonCount, pillCount: value.pillCount, oldDaemon: value.oldDaemon, oldPill: value.oldPill, oldBroker: value.oldBroker }) : "null";
47
48
  const probeSummary = (value) => value?.reason || value?.daemon?.reason || value?.pill?.reason || "answer-did-not-match-the-live-processes";
@@ -67,6 +68,10 @@ async function waitForRecoveryReady({ homeDir = os.homedir(), platform = process
67
68
  const alive = Boolean(live?.ok) && jobsReady && fresh;
68
69
  const probeIdentityNow = probeMatches && !responsive.legacy ? responsive.identity || null : null;
69
70
  const suspended = gap < 0 || gap > SAMPLE_GAP_MAX_MS || sampleMs > SAMPLE_MAX_MS;
71
+ // Incomplete observation is neither proof of a hung application nor a
72
+ // reason to charge its release. A later successful proof may still win.
73
+ if (suspended || live?.known === false || (identity !== null && key !== identity)
74
+ || (platform === "darwin" && jobs.some(job => !job.known))) observationInvalid = true;
70
75
  if (suspended || !alive || key !== identity
71
76
  || (probeIdentityNow && probeIdentity && probeIdentityNow !== probeIdentity)) {
72
77
  block = suspended ? `not-watched-continuously:gap=${gap}ms,sample=${sampleMs}ms`
@@ -105,7 +110,7 @@ async function waitForRecoveryReady({ homeDir = os.homedir(), platform = process
105
110
  if (at - started >= timeoutMs) break;
106
111
  await sleep(1000);
107
112
  }
108
- return { ok: false, reason: "runtime-did-not-stay-responsive",
113
+ return { ok: false, reason: "runtime-did-not-stay-responsive", observationInvalid,
109
114
  detail: `${block || "no-sample"}; samples=${samples} slowestSampleMs=${slowestSampleMs} probeSeen=${probeSeen}` };
110
115
  }
111
116
  module.exports = { waitForRecoveryReady, HEARTBEAT_FRESH_MS, PROBE_TIMEOUT_MS, SAMPLE_GAP_MAX_MS, SAMPLE_MAX_MS };
@@ -113,8 +113,8 @@ function execute(node, entry, args, { timeoutMs = DEADLINE_MS, spawnImpl = spawn
113
113
 
114
114
  // A stale daemon heartbeat has two very different causes: a dead or wedged
115
115
  // daemon, or a machine so starved that a healthy daemon missed a few ticks.
116
- // Only a heartbeat that stays stale across two scheduled checks proves the
117
- // former while the process is still alive. A missing process needs no second look.
116
+ // A live process needs sustained failure evidence before disruptive repair.
117
+ // Confirmed absence can use an idempotent service start without that evidence.
118
118
  // Confirm across consecutive awake observations. A gap caused by sleep or a
119
119
  // stalled scheduler resets this window rather than counting as a live hang.
120
120
  const STALE_CONFIRM_MS = 60_000;
@@ -154,6 +154,7 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
154
154
  sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)),
155
155
  health = require("./runtime-health.cjs").exactRuntimeHealth,
156
156
  restart = require("./runtime-health.cjs").restartInstalledRuntimeServices,
157
+ restorePill = require("./runtime-health.cjs").restoreMissingPill,
157
158
  memory = require("./runtime-health.cjs").memoryPressure,
158
159
  verifyReady = require("./recovery-readiness.cjs").waitForRecoveryReady,
159
160
  policyFactory = require("./recovery-policy.cjs").recoveryPolicy,
@@ -184,6 +185,7 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
184
185
  if (reservedService && (serviceQueryUncertain || services.status === "deferred-update-owner" || (services.ok && !services.changed))) progress.refund("services");
185
186
  const ready = (target = null, after = 0) => verifyReady({ homeDir, platform, target, after, now, sleep, health });
186
187
  const rememberReady = observed => {
188
+ progress.clearObservation();
187
189
  const active = observed?.current || read(path.join(root, "runtime", "current.json"));
188
190
  const probation = policy.observe(channel, { ...observed, current: active });
189
191
  if (!probation.proven) return probation;
@@ -226,7 +228,7 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
226
228
  return status({ ok: true, status: "deferred-update-in-flight", runtimeHealthy: false, ...extra,
227
229
  transaction: { pid: transaction.pid, requestId: transaction.requestId, version: transaction.version } });
228
230
  };
229
- if (services.status === "deferred-update-owner") return status({ ...services, runtimeHealthy: false });
231
+ if (["deferred-update-owner", "intentionally-stopped"].includes(services.status)) return status({ ...services, runtimeHealthy: false });
230
232
  if (serviceQueryUncertain) return status({ ...services, runtimeHealthy: false });
231
233
  if (services.changed || !services.ok) {
232
234
  const observed = await ready();
@@ -260,21 +262,44 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
260
262
  const heartbeatFresh = heartbeat?.at <= now() && now() - heartbeat.at < HEARTBEAT_MS;
261
263
  const installedIsDesired = current?.active === true;
262
264
  const live = installedIsDesired ? await health(current, { platform }) : null;
265
+ // Slow OS inspection (including suspension while it runs) is not evidence
266
+ // of a stalled application. Begin at the completed process observation;
267
+ // subsequent responsiveness sampling reports its own continuity failures.
268
+ const observationStartedAt = now();
269
+ if (live?.known === false) {
270
+ progress.clearObservation(); policy.interrupt();
271
+ return status({ ok: false, status: "observation-unavailable", runtimeHealthy: false, lastError: live.reason });
272
+ }
263
273
  // Local repairs never wait behind registry timeouts. Healthy installations
264
274
  // still check for new releases; unavailable ones exhaust retained repairs first.
265
- if (!installedIsDesired || (live?.ok && heartbeatFresh)) await discoverDesired();
275
+ if (!installedIsDesired) await discoverDesired();
266
276
  if (live && platform === "darwin" && !services.ok) live.ok = false;
267
277
  let readiness = null;
268
278
  if (installedIsDesired && heartbeatFresh && heartbeat.version === current.version && live.ok) {
269
279
  const observed = await ready(current);
270
280
  readiness = observed;
271
281
  if (observed.ok) {
282
+ await discoverDesired();
272
283
  runtimeResponsive = true;
273
284
  runtimeVerified = !observed.legacy;
274
285
  if (!desiredVersion || compare(current.version, desiredVersion) >= 0) return proven({ ok: true, status: current.version === desiredVersion || !desiredVersion ? "current" : "ahead", desiredVersion: desiredVersion || current.version, lastSuccessAt: previous?.lastSuccessAt || now() }, observed);
275
286
  const probation = rememberReady(observed);
276
287
  if (!probation.proven && !observed.legacy) return status({ ok: true, status: "probation", runtimeHealthy: true, runtimeProven: false, desiredVersion, version: current.version, probationRemainingMs: probation.remainingMs });
277
- } else { live.ok = false; policy.interrupt(); }
288
+ } else {
289
+ live.ok = false; policy.interrupt();
290
+ if (observed.observationInvalid) {
291
+ progress.clearObservation();
292
+ return status({ ok: false, status: "observation-unavailable", runtimeHealthy: false, lastError: observed.detail || observed.reason });
293
+ }
294
+ }
295
+ }
296
+ const latestPointer = read(path.join(root, "runtime", "current.json"));
297
+ const latestHeartbeat = read(path.join(root, "recovery", "daemon.json"));
298
+ if (installedIsDesired && (latestPointer?.packageRoot !== current.packageRoot
299
+ || latestPointer?.committedAt !== current.committedAt || latestPointer?.active !== true
300
+ || latestHeartbeat?.pid !== heartbeat?.pid)) {
301
+ progress.clearObservation();
302
+ return status({ ok: true, status: "runtime-changed", runtimeHealthy: false });
278
303
  }
279
304
  const memoryNow = memory();
280
305
  // Repair progress for the installed version survives every later status
@@ -283,11 +308,12 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
283
308
  const repairState = {};
284
309
  if (installedIsDesired) {
285
310
  repairState.version = current.version;
286
- const sameTarget = previous?.version === current.version;
287
311
  repairState.restarts = progress.count(`restart:${current.packageRoot || current.version}`);
288
312
  const cadence = platform === "darwin" && read(path.join(root, "recovery", "scheduler.json"))?.intervalSeconds !== 60 ? 5 * 60_000 : CHECK_MS;
289
- const consecutive = previous?.checkedAt <= now() && now() - previous.checkedAt <= cadence * 2;
290
- repairState.staleSince = sameTarget && consecutive && Number.isFinite(previous.staleSince) && previous.staleSince <= now() ? previous.staleSince : now();
313
+ const incarnation = read(path.join(root, "recovery", "daemon-progress.json"));
314
+ const identity = JSON.stringify([current.packageRoot, current.version, current.committedAt,
315
+ heartbeat?.pid, incarnation?.pid === heartbeat?.pid ? incarnation?.startedAt : null]);
316
+ repairState.staleSince = progress.observe(identity, { startedAt: observationStartedAt, gapMs: cadence * 2 });
291
317
  }
292
318
  if (installedIsDesired && !(live.ok && heartbeatFresh && heartbeat.version === current.version)) {
293
319
  // The code on disk is the code we want; the problem is liveness. Repair in
@@ -298,7 +324,6 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
298
324
  const base = { desiredVersion: desiredVersion || version, version, staleSince, restarts, discoveryError: discoveryError ? String(discoveryError.message).slice(0, 300) : undefined,
299
325
  memoryFreeMB: memoryNow.freeMB };
300
326
  log(`installed ${version} not healthy: heartbeatFresh=${heartbeatFresh} daemonAlive=${daemonAlive} health=${JSON.stringify({ daemon: live?.daemon, pill: live?.pill, oldDaemon: live?.oldDaemon, oldPill: live?.oldPill })} readiness=${readiness ? `${readiness.reason || "ok"}${readiness.detail ? ` (${readiness.detail})` : ""}` : "not-attempted"} memoryPressured=${memoryNow.pressured}`);
301
- if (daemonAlive && now() - staleSince < STALE_CONFIRM_MS) return status({ ok: true, status: "stale-observed", ...base });
302
327
  const deferred = deferToTransaction(base);
303
328
  if (deferred) return deferred;
304
329
  const repeatedCrash = require("./daemon-progress.cjs").repeatedStartupCrash(current, { homeDir, now: now() });
@@ -308,9 +333,27 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
308
333
  if (!repeatedCrash && Number.isFinite(committedAt) && committedAt <= now() && now() - committedAt < STARTUP_GRACE_MS) {
309
334
  return status({ ok: true, status: "starting", ...base, startupGraceRemainingMs: STARTUP_GRACE_MS - (now() - committedAt) });
310
335
  }
336
+ if (require("./recovery-intent.cjs").stopped(homeDir)) return status({ ok: true, status: "intentionally-stopped", runtimeHealthy: false });
337
+ const pillMissing = live?.known === true && live.daemonCount === 1 && live.pillCount === 0
338
+ && !live.oldDaemon && !live.oldPill && !live.oldBroker;
339
+ // Starting an absent UI does not interrupt daemon work. Reserve the same
340
+ // bounded restart budget; a runner death cannot grant unlimited attempts.
341
+ const restartKey = `restart:${current.packageRoot || current.version}`;
342
+ if (pillMissing && heartbeatFresh && progress.claim(restartKey, MAX_IN_PLACE_RESTARTS)) {
343
+ const startedAt = now();
344
+ const outcome = await restorePill(current, { homeDir, platform });
345
+ if (outcome.deferred) {
346
+ progress.refund(restartKey); progress.clearObservation();
347
+ return status({ ok: true, status: outcome.reason, runtimeHealthy: false, ...base });
348
+ }
349
+ const observed = outcome.ok ? await ready(current, startedAt) : null;
350
+ if (observed?.ok) return proven({ ok: true, status: "current", version, desiredVersion: base.desiredVersion, repair: "pill", lastSuccessAt: now() }, observed);
351
+ progress.fail(outcome.reason || observed?.reason);
352
+ return status({ ok: false, status: "pill-repair-failed", ...base, lastError: outcome.reason || observed?.detail || observed?.reason });
353
+ }
354
+ if (daemonAlive && now() - staleSince < STALE_CONFIRM_MS) return status({ ok: true, status: "stale-observed", ...base });
311
355
  const busy = busyDecision(heartbeat, { homeDir, now: now() });
312
356
  if (busy) return status({ ok: true, status: busy, ...base });
313
- const restartKey = `restart:${current.packageRoot || current.version}`;
314
357
  if (repeatedCrash) {
315
358
  log(`repeated startup crash; skipping restart/reactivation for ${version}`);
316
359
  policy.failure(channel, version, { id: `startup:${current.packageRoot}:${repeatedCrash.fingerprint}`, reason: "repeated-startup-crash" });
@@ -322,7 +365,7 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
322
365
  try { outcome = await restart(current, { platform, homeDir }); }
323
366
  catch (error) { outcome = { ok: false, reason: error.message }; }
324
367
  log(`restart ${outcome?.ok ? "ok" : `failed: ${outcome?.reason || "unknown"}`} terminated=${JSON.stringify(outcome?.terminated || [])}`);
325
- if (outcome?.reason === "deferred-update-owner" || outcome?.reason === "runtime-changed") {
368
+ if (outcome?.reason === "deferred-update-owner" || outcome?.reason === "runtime-changed" || outcome?.reason === "intentionally-stopped" || outcome?.reason === "service-process-query-failed") {
326
369
  progress.refund(restartKey);
327
370
  return status({ ok: true, status: outcome.reason, runtimeHealthy: false, ...base });
328
371
  }
@@ -34,7 +34,7 @@ function defaultRun(command, args, options = {}) {
34
34
 
35
35
  function runtimeProcessQuery(platform) {
36
36
  if (platform === "win32") {
37
- const script = "$relaySid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { $_.CommandLine -match 'node_modules[\\\\/]relay-companion[\\\\/]' } | ForEach-Object { $relayOwner = Invoke-CimMethod -InputObject $_ -MethodName GetOwnerSid -ErrorAction SilentlyContinue; if ($relayOwner.Sid -eq $relaySid) { $_.CommandLine } }";
37
+ const script = "$ErrorActionPreference = 'Stop'; $relaySid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { $_.CommandLine -match 'node_modules[\\\\/]relay-companion[\\\\/]' } | ForEach-Object { $relayOwner = Invoke-CimMethod -InputObject $_ -MethodName GetOwnerSid -ErrorAction Stop; if (-not $relayOwner.Sid) { throw 'service-owner-query-failed' }; if ($relayOwner.Sid -eq $relaySid) { $_.CommandLine } }";
38
38
  return { command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", script] };
39
39
  }
40
40
  return { command: "/bin/ps", args: ["-axo", "uid=,command="] };
@@ -63,7 +63,16 @@ function exactRuntimeHealth(target, {
63
63
  userId = typeof process.getuid === "function" ? process.getuid() : 0,
64
64
  } = {}) {
65
65
  const api = platform === "win32" ? path.win32 : path.posix;
66
- const lines = withoutWindowsShellWrappers(commands || runtimeProcessCommands(platform, run, userId));
66
+ let observed = commands;
67
+ if (observed === null) {
68
+ const query = runtimeProcessQuery(platform);
69
+ let result;
70
+ try { result = run(query.command, query.args); } catch (error) { result = { error }; }
71
+ // An unavailable observer is not evidence that either service disappeared.
72
+ if (!commandOk(result)) return { ok: false, known: false, reason: "service-process-query-failed", packageRoot: target.packageRoot };
73
+ observed = parseRuntimeProcessCommands(result.stdout, platform, userId);
74
+ }
75
+ const lines = withoutWindowsShellWrappers(observed);
67
76
  const normalize = (value) => (platform === "win32" ? String(value).toLowerCase() : String(value)).replaceAll("\\", "/");
68
77
  const daemonNeedle = normalize(target.bin);
69
78
  const pillNeedle = normalize(api.join(target.packageRoot, "overlay", "main.cjs"));
@@ -77,7 +86,7 @@ function exactRuntimeHealth(target, {
77
86
  const oldBroker = brokers.some((line) => !normalize(line).includes(normalize(target.packageRoot)));
78
87
  const oldDaemon = relayDaemons.some((line) => !normalize(line).includes(daemonNeedle));
79
88
  const oldPill = relayPills.some((line) => !normalize(line).includes(pillNeedle));
80
- return { ok: daemon && pill && !oldDaemon && !oldPill && !oldBroker, daemon, pill, daemonCount, pillCount, brokerCount: brokers.length, oldBroker, oldDaemon, oldPill, packageRoot: target.packageRoot };
89
+ return { ok: daemon && pill && !oldDaemon && !oldPill && !oldBroker, known: true, daemon, pill, daemonCount, pillCount, brokerCount: brokers.length, oldBroker, oldDaemon, oldPill, packageRoot: target.packageRoot };
81
90
  }
82
91
 
83
92
  function linuxPillStatusPath({ homeDir = os.homedir(), env = process.env } = {}) {
@@ -406,6 +415,65 @@ async function activateWindowsRuntimeServices(target, {
406
415
  return { ok: false, reason: "activation-deadline-exceeded", detail: target.packageRoot, health, terminated };
407
416
  }
408
417
 
418
+ // Use the existing OS service's idempotent start operation. Never spawn a second
419
+ // pill directly, kill a daemon, rewrite registrations, or drain unrelated work.
420
+ async function restoreMissingPill(target, {
421
+ homeDir = os.homedir(), platform = process.platform, run = defaultRun,
422
+ own = require("./lifecycle-ownership.cjs").lifecycleOwnership,
423
+ healthCheck = exactRuntimeHealth, userId = process.getuid?.() ?? 0,
424
+ } = {}) {
425
+ let lease;
426
+ const defer = reason => ({ ok: false, deferred: true, reason });
427
+ const assertTarget = () => {
428
+ lease.assert();
429
+ if (require("./recovery-intent.cjs").stopped(homeDir)) throw Error("intentionally-stopped");
430
+ const current = JSON.parse(fs.readFileSync(path.join(homeDir, ".relay", "runtime", "current.json"), "utf8"));
431
+ if (!current.active || current.packageRoot !== target.packageRoot || current.committedAt !== target.committedAt) throw Error("runtime-changed");
432
+ };
433
+ try {
434
+ try { lease = own({ homeDir }); } catch { return defer("deferred-update-owner"); }
435
+ assertTarget();
436
+ const health = await healthCheck(target, { platform, run });
437
+ if (health.known === false) return defer("service-process-query-failed");
438
+ if (health.ok) return { ok: true, changed: false };
439
+ if (health.daemonCount !== 1 || health.pillCount !== 0 || health.oldDaemon || health.oldPill || health.oldBroker) return defer("runtime-changed");
440
+ let command, args;
441
+ const normalize = text => String(text).replace(/&amp;/g, "&").replace(/&quot;/g, '"').replaceAll("\\", "/").toLowerCase();
442
+ const script = normalize(path.join(target.packageRoot, "overlay", "main.cjs"));
443
+ if (platform === "win32") {
444
+ const task = run("schtasks.exe", ["/Query", "/TN", WINDOWS_PILL_TASK, "/XML"]);
445
+ if (!commandOk(task)) return defer("service-registration-query-failed");
446
+ const xml = normalize(task.stdout);
447
+ // Stock Windows tasks normally delegate to a hidden launcher. Follow only
448
+ // that exact managed file, never an arbitrary path supplied by task text.
449
+ const launcher = path.join(homeDir, ".relay", "relay-companion-pill.vbs");
450
+ const targetsCurrent = xml.includes(script) || (xml.includes(normalize(launcher))
451
+ && normalize(fs.readFileSync(launcher, "utf8")).includes(script));
452
+ if (!targetsCurrent || !/<MultipleInstancesPolicy>IgnoreNew<\/MultipleInstancesPolicy>/i.test(task.stdout)) return defer("service-target-unverified");
453
+ command = "schtasks.exe"; args = ["/Run", "/TN", WINDOWS_PILL_TASK];
454
+ } else if (platform === "linux") {
455
+ const unit = `${PILL_LABEL}.service`;
456
+ const service = run("systemctl", ["--user", "show", "--property=ExecStart", "--value", unit]);
457
+ if (!commandOk(service)) return defer("service-registration-query-failed");
458
+ if (!normalize(service.stdout).includes(script)) return defer("service-target-unverified");
459
+ command = "systemctl"; args = ["--user", "start", unit];
460
+ } else if (platform === "darwin") {
461
+ const mac = require("./mac-service-recovery.cjs");
462
+ const record = mac.readRegistration(PILL_LABEL, { homeDir, run });
463
+ if (record.packageRoot !== target.packageRoot) return defer("runtime-changed");
464
+ const observed = mac.registration(PILL_LABEL, { run, userId });
465
+ if (!observed.known || !observed.present) return defer("service-registration-query-failed");
466
+ command = "/bin/launchctl"; args = ["kickstart", `gui/${userId}/${PILL_LABEL}`];
467
+ } else return defer("activation-platform-unsupported");
468
+ // An updater cannot race this generation. Quit may have arrived while the
469
+ // observer was waiting; check it again at the mutation boundary.
470
+ assertTarget();
471
+ const started = run(command, args);
472
+ return commandOk(started) ? { ok: true, changed: true } : { ok: false, reason: "service-start-failed" };
473
+ } catch (error) { return defer(error.message); }
474
+ finally { lease?.release(); }
475
+ }
476
+
409
477
  /** One entry point for "restart what is already installed" on every platform. */
410
478
  async function restartInstalledRuntimeServices(target, { platform = process.platform, ...options } = {}) {
411
479
  if (!["darwin", "linux", "win32"].includes(platform)) return { ok: false, reason: "activation-platform-unsupported" };
@@ -417,16 +485,26 @@ async function restartInstalledRuntimeServices(target, { platform = process.plat
417
485
  catch { return { ok: false, reason: "deferred-update-owner" }; }
418
486
  const current = JSON.parse(fs.readFileSync(path.join(homeDir, ".relay", "runtime", "current.json"), "utf8"));
419
487
  if (!current?.active || current.packageRoot !== target.packageRoot) return { ok: false, reason: "runtime-changed" };
488
+ if (require("./recovery-intent.cjs").stopped(homeDir)) return { ok: false, reason: "intentionally-stopped" };
489
+ const live = await (options.healthCheck || exactRuntimeHealth)(target, { platform, run: options.run });
490
+ if (live.known === false) return { ok: false, reason: "service-process-query-failed" };
420
491
  releaseDrain = await require("./update-activity.cjs").drainCalls({ homeDir });
421
492
  lease.assert();
422
- if (platform === "linux") return await activateLinuxRuntimeServices(target, { platform, ...options });
423
- if (platform === "win32") return await activateWindowsRuntimeServices(target, { platform, ...options });
493
+ const run = options.run || defaultRun;
494
+ const guardedRun = (...args) => {
495
+ lease.assert();
496
+ if (require("./recovery-intent.cjs").stopped(homeDir)) throw Error("intentionally-stopped");
497
+ return run(...args);
498
+ };
499
+ if (platform === "linux") return await activateLinuxRuntimeServices(target, { platform, ...options, run: guardedRun });
500
+ if (platform === "win32") return await activateWindowsRuntimeServices(target, { platform, ...options, run: guardedRun });
424
501
  return { ok: false, reason: "activation-platform-unsupported" };
425
502
  } catch (error) { return { ok: false, reason: error.message }; }
426
503
  finally { releaseDrain?.(); lease?.release(); }
427
504
  }
428
505
 
429
506
  module.exports = {
507
+ restoreMissingPill,
430
508
  activateLinuxRuntimeServices,
431
509
  activateMacRuntimeServices,
432
510
  activateWindowsRuntimeServices,
@@ -14,6 +14,8 @@ async function repairServiceRegistrations({ homeDir = os.homedir(), platform = p
14
14
  drain = require("./update-activity.cjs").drainCalls,
15
15
  activate,
16
16
  } = {}) {
17
+ const stopped = () => require("./recovery-intent.cjs").stopped(homeDir);
18
+ if (stopped()) return { ok: true, changed: false, status: "intentionally-stopped" };
17
19
  if (platform === "darwin") {
18
20
  const observed = await require("./mac-service-recovery.cjs").repairMacServiceRegistrations({ homeDir, platform, run });
19
21
  if (observed.ok || observed.blocked) return observed;
@@ -39,14 +41,17 @@ async function repairServiceRegistrations({ homeDir = os.homedir(), platform = p
39
41
  try {
40
42
  try { lease = own({ homeDir }); }
41
43
  catch { return { ok: true, changed: false, status: "deferred-update-owner" }; }
44
+ if (stopped()) return { ok: true, changed: false, status: "intentionally-stopped" };
42
45
  const target = io.read(path.join(homeDir, ".relay", "runtime", "current.json"));
43
46
  if (!target?.active || !validate(target, { platform })) return { ok: false, changed: false, status: "service-repair-failed", lastError: "no-verified-registration-source" };
44
47
  const node = nodeContract.resolveManagedNode({ homeDir, node: target.node, run });
45
48
  releaseDrain = await drain({ homeDir });
46
49
  lease.assert();
50
+ if (stopped()) return { ok: true, changed: false, status: "intentionally-stopped" };
47
51
  await execute(node, path.join(target.packageRoot, "bin", "relay.js"), ["repair-runtime", "--no-restart"],
48
52
  { env: nodeContract.nodeEnvironment(lease.env), timeoutMs: 90_000 });
49
53
  lease.assert();
54
+ if (stopped()) return { ok: true, changed: false, status: "intentionally-stopped" };
50
55
  const health = require("./runtime-health.cjs");
51
56
  const activateRuntime = activate || (platform === "darwin" ? health.activateMacRuntimeServices
52
57
  : platform === "win32" ? health.activateWindowsRuntimeServices : health.activateLinuxRuntimeServices);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.554",
3
+ "version": "0.1.556",
4
4
  "description": "Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.",
5
5
  "homepage": "https://sendrelays.com/get-started",
6
6
  "repository": {
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "name": "relay",
4
- "version": "1.1.68",
4
+ "version": "1.1.69",
5
5
  "consentVersion": 2,
6
- "baseUrl": "https://sendrelays.com/skills/relay/v1.1.68",
6
+ "baseUrl": "https://sendrelays.com/skills/relay/v1.1.69",
7
7
  "files": [
8
8
  {
9
9
  "path": "SKILL.md",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  {
23
23
  "path": "scripts/relay-protocol.mjs",
24
- "sha256": "bfbc56ecaf202f77002da7eec4a33c5bfc7f3c6574df3dd385766684eb5458a3"
24
+ "sha256": "833924fb08ed82e67a66c72e5c895deb43f38a5b92dfcc1765c8f05ef87d38e9"
25
25
  },
26
26
  {
27
27
  "path": "scripts/relay-local.mjs",
@@ -746,6 +746,17 @@ function validateDirectArguments(args, schema, label = "arguments") {
746
746
  if (schema.type === "integer" && (args < (schema.minimum ?? -Infinity) || args > (schema.maximum ?? Infinity))) throw new Error(`Invalid ${label}.`);
747
747
  }
748
748
 
749
+ // Match MCP's presentation projection; raw HTTP requests keep their API shape.
750
+ function withoutRedundantPresentation(value) {
751
+ if (Array.isArray(value)) return value.map(withoutRedundantPresentation);
752
+ if (!value || typeof value !== "object") return value;
753
+ return Object.fromEntries(Object.entries(value)
754
+ .filter(([key]) => key !== "threadTitle" && key !== "replyToRelayId"
755
+ && !(key === "threadIds" && typeof value.chatId === "string")
756
+ && !(key === "preview" && typeof value.relayId === "string" && typeof value.forHuman === "string" && value.forHuman.length > 0))
757
+ .map(([key, child]) => [key, withoutRedundantPresentation(child)]));
758
+ }
759
+
749
760
  async function directToolCommand(command, body, config) {
750
761
  transport = "https";
751
762
  // Explicit HTTPS verifies the independent credential and account. It never
@@ -819,7 +830,7 @@ async function directToolCommand(command, body, config) {
819
830
  value = await request("POST", "/v1/share-links", await prepareSendBody(draft));
820
831
  }
821
832
  }
822
- return { content: [{ type: "text", text: JSON.stringify(value) }], isError: false };
833
+ return { content: [{ type: "text", text: JSON.stringify(withoutRedundantPresentation(value)) }], isError: false };
823
834
  }
824
835
 
825
836
  async function main(argv = process.argv.slice(2)) {