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.
- package/bootstrap/mac-service-recovery.cjs +4 -0
- package/bootstrap/recovery-launcher.cjs +1 -1
- package/bootstrap/recovery-progress.cjs +13 -0
- package/bootstrap/recovery-readiness.cjs +6 -1
- package/bootstrap/recovery-runner.cjs +54 -11
- package/bootstrap/runtime-health.cjs +83 -5
- package/bootstrap/service-recovery.cjs +5 -0
- package/package.json +1 -1
- package/skill/manifest.json +3 -3
- package/skill/relay/scripts/relay-protocol.mjs +12 -1
|
@@ -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
|
-
//
|
|
117
|
-
//
|
|
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 (
|
|
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
|
|
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 {
|
|
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
|
|
290
|
-
|
|
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
|
|
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
|
-
|
|
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(/&/g, "&").replace(/"/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
|
-
|
|
423
|
-
|
|
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
package/skill/manifest.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"name": "relay",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.69",
|
|
5
5
|
"consentVersion": 2,
|
|
6
|
-
"baseUrl": "https://sendrelays.com/skills/relay/v1.1.
|
|
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": "
|
|
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)) {
|