relay-companion 0.1.548 → 0.1.550

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.
@@ -10,6 +10,10 @@ function applicationReleaseBase(channel = "stable") {
10
10
  return `${CHANNEL_ORIGINS[channel]}/v1/application-releases${channel === "stable" ? "" : `/${channel}`}`;
11
11
  }
12
12
 
13
+ function applicationManifestUrl(channel = "stable") {
14
+ return `${applicationReleaseBase(channel)}/${channel === "stable" ? "stable-v3/" : ""}manifest.json`;
15
+ }
16
+
13
17
  function validateApplicationRelease(payload, { version, sourceSha, channel } = {}) {
14
18
  if (![1, 2].includes(payload?.schema) || payload.product !== "Relay Application" || payload.version !== version
15
19
  || payload.sourceSha !== sourceSha || !/^\d+\.\d+\.\d+$/.test(version || "") || !/^[a-f0-9]{40}$/.test(sourceSha || "")
@@ -59,4 +63,4 @@ async function verifyApplicationArtifact(file, artifact) {
59
63
  return true;
60
64
  }
61
65
 
62
- module.exports = { PLATFORMS, CHANNEL_ORIGINS, applicationReleaseBase, validateApplicationRelease, verifyApplicationRelease, verifyApplicationArtifact };
66
+ module.exports = { PLATFORMS, CHANNEL_ORIGINS, applicationReleaseBase, applicationManifestUrl, validateApplicationRelease, verifyApplicationRelease, verifyApplicationArtifact };
@@ -2,4 +2,4 @@
2
2
  // Capability only. Every background handoff additionally requires a fresh,
3
3
  // signed channel-specific policy. An empty device list migrates nobody.
4
4
  module.exports = Object.freeze({ enabled: true,
5
- manifestUrl: "https://api.sendrelays.com/v1/application-releases/stable/manifest.json" });
5
+ manifestUrl: require("./application-release.cjs").applicationManifestUrl("stable") });
@@ -11,7 +11,7 @@ const rollout = require("./application-rollout.cjs");
11
11
  const CHECK_MS = 6 * 60 * 60_000;
12
12
 
13
13
  async function readOffer({ fetchImpl = globalThis.fetch, trustStore = trust, channel = "stable" } = {}) {
14
- const response = await fetchImpl(`${release.applicationReleaseBase(channel)}/${channel === "stable" ? "stable/" : ""}manifest.json`, { redirect: "error", signal: AbortSignal.timeout(15_000), headers: { "Cache-Control": "no-cache" } });
14
+ const response = await fetchImpl(release.applicationManifestUrl(channel), { redirect: "error", signal: AbortSignal.timeout(15_000), headers: { "Cache-Control": "no-cache" } });
15
15
  if (response.status === 404) return null;
16
16
  if (!response.ok) throw new Error(`Native application offer unavailable (${response.status})`);
17
17
  let bytes = 0; const chunks = [];
@@ -505,7 +505,10 @@ async function launch({ root = __dirname, run = runChild, now = Date.now, env =
505
505
  // to an older bundle here would hand the problem to a runner that only
506
506
  // knows how to download.
507
507
  const networkFailure = /fetch failed|offline|ENOTFOUND|ECONN|ETIMEDOUT|manifest-http-|channel-discovery-http-|download.*(timed out|stalled|ended early|failed after)|configuration-unavailable/i.test(report?.lastError || "");
508
- const retryableReport = ["disabled", "backoff", "emergency-backoff", "restart-failed", "reactivate-failed", "service-repair-failed", "service-repair-unhealthy"].includes(report?.status) || (report?.status === "failed" && networkFailure);
508
+ // A worker that lost the canonical lock to a live installer judged nothing
509
+ // about this bundle either; the runner normally reports that as a deferral.
510
+ const lostTransaction = /recovery-worker-exit-75|transaction-in-progress/i.test(report?.lastError || "");
511
+ const retryableReport = ["disabled", "backoff", "emergency-backoff", "restart-failed", "reactivate-failed", "service-repair-failed", "service-repair-unhealthy"].includes(report?.status) || (report?.status === "failed" && (networkFailure || lostTransaction));
509
512
  if (reported && retryableReport && result.reason !== "deadline") {
510
513
  write(path.join(root, "launcher-status.json"), { schema: 1, at: now(), status: "runner-error", version: candidate.version });
511
514
  log("done status=runner-error");
@@ -9,11 +9,16 @@ const crypto = require("node:crypto");
9
9
  const { spawn, spawnSync } = require("node:child_process");
10
10
  const { stageVerifiedRuntime, releasePlatform } = require("./relay-setup.cjs");
11
11
  const { verifyReleaseEnvelope } = require("./release-signature.cjs");
12
+ const { inFlightTransaction, workerLostLock } = require("./recovery-transaction.cjs");
12
13
  const trust = require("./trust.json");
13
14
  const CHECK_MS = 5 * 60_000;
14
15
  const DEADLINE_MS = 25 * 60_000;
15
16
  const HEARTBEAT_MS = 60_000;
16
17
  const BUSY_GRACE_MS = 15 * 60_000;
18
+ // A runtime committed moments ago is still starting its services. Judging it
19
+ // dead inside this window restarts or replaces a release that was about to
20
+ // answer, and on 2026-09-21 that judgement rolled back a healthy update.
21
+ const STARTUP_GRACE_MS = 3 * 60_000;
17
22
 
18
23
  function read(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } }
19
24
  function write(file, value) {
@@ -157,6 +162,7 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
157
162
  verifyReady = require("./recovery-readiness.cjs").waitForRecoveryReady,
158
163
  policyFactory = require("./recovery-policy.cjs").recoveryPolicy,
159
164
  validateLocal = require("./recovery-local.cjs").validateLocalRuntime,
165
+ transactions = inFlightTransaction,
160
166
  repairServices = require("./mac-service-recovery.cjs").repairMacServiceRegistrations } = {}) {
161
167
  const root = path.join(homeDir, ".relay");
162
168
  // Local availability is independent of update eligibility, network, memory,
@@ -206,6 +212,18 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
206
212
  log(`status=${value.status}${value.desiredVersion ? ` desired=${value.desiredVersion}` : ""}${value.lastError ? ` error=${value.lastError}` : ""}`);
207
213
  return value;
208
214
  };
215
+ // Another live process owns the canonical transaction: the daemon's own
216
+ // updater, a manual `relay update`, or a previous runner still activating.
217
+ // Whatever it installs is judged by readiness later, never by this runner
218
+ // losing a race to it. The staged download, if any, is swept by the caller.
219
+ const deferToTransaction = (extra = {}) => {
220
+ let transaction = null;
221
+ try { transaction = transactions({ homeDir, now: now() }); } catch (error) { log(`transaction check failed: ${error.message}`); }
222
+ if (!transaction) return null;
223
+ log(`canonical transaction in flight: owner pid=${transaction.pid} request=${transaction.requestId || "-"} version=${transaction.version || "-"}`);
224
+ return status({ ok: true, status: "deferred-update-in-flight", runtimeHealthy: false, ...extra,
225
+ transaction: { pid: transaction.pid, requestId: transaction.requestId, version: transaction.version } });
226
+ };
209
227
  if (services.status === "deferred-update-owner") return status({ ...services, runtimeHealthy: false });
210
228
  if (services.changed || !services.ok) {
211
229
  const observed = await ready();
@@ -271,10 +289,18 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
271
289
  memoryFreeMB: memoryNow.freeMB };
272
290
  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}`);
273
291
  if (daemonAlive && now() - staleSince < STALE_CONFIRM_MS) return status({ ok: true, status: "stale-observed", ...base });
292
+ const deferred = deferToTransaction(base);
293
+ if (deferred) return deferred;
294
+ const repeatedCrash = require("./daemon-progress.cjs").repeatedStartupCrash(current, { homeDir, now: now() });
295
+ // A commit seconds ago means services are still coming up. Two matching
296
+ // startup crashes are proof of a broken release and end the grace early.
297
+ const committedAt = Number(current.committedAt);
298
+ if (!repeatedCrash && Number.isFinite(committedAt) && committedAt <= now() && now() - committedAt < STARTUP_GRACE_MS) {
299
+ return status({ ok: true, status: "starting", ...base, startupGraceRemainingMs: STARTUP_GRACE_MS - (now() - committedAt) });
300
+ }
274
301
  const busy = busyDecision(heartbeat, { homeDir, now: now() });
275
302
  if (busy) return status({ ok: true, status: busy, ...base });
276
303
  const restartKey = `restart:${current.packageRoot || current.version}`;
277
- const repeatedCrash = require("./daemon-progress.cjs").repeatedStartupCrash(current, { homeDir, now: now() });
278
304
  if (repeatedCrash) {
279
305
  log(`repeated startup crash; skipping restart/reactivation for ${version}`);
280
306
  policy.failure(channel, version, { id: `startup:${current.packageRoot}:${repeatedCrash.fingerprint}`, reason: "repeated-startup-crash" });
@@ -318,7 +344,13 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
318
344
  }
319
345
  // Try a distinct, previously committed local release before requiring a
320
346
  // network download. Validation reads the tree without executing its imports.
347
+ // An inactive journal is an incomplete update only once its owner is gone.
348
+ // While the owner lives, the journal is an update in progress.
321
349
  const failedCandidate = current?.active !== true ? current?.candidate : null;
350
+ if (failedCandidate?.version || current?.active !== true) {
351
+ const deferred = deferToTransaction({ desiredVersion, version: current?.version });
352
+ if (deferred) return deferred;
353
+ }
322
354
  if (failedCandidate?.version) policy.failure(channel, failedCandidate.version, { id: `journal:${failedCandidate.packageRoot || failedCandidate.version}`, reason: "incomplete-update" });
323
355
  const good = read(path.join(root, "recovery", "runtime-good.json"));
324
356
  const olderGood = read(path.join(root, "recovery", "runtime-previous-good.json"));
@@ -333,7 +365,17 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
333
365
  const observed = await ready({ version: target.version }, activationAt);
334
366
  if (!observed.ok) throw Error("local-runtime-not-healthy");
335
367
  return proven({ ok: true, status: "current", desiredVersion, version: target.version, repair: "local", lastSuccessAt: now(), failures: 0 }, observed);
336
- } catch (error) { progress.fail(error.message); log('local recovery failed: ' + error.message); }
368
+ } catch (error) {
369
+ // A worker that lost the lock did not fail to restore anything: someone
370
+ // else is installing. Give the attempt back and let them finish.
371
+ const lostLock = workerLostLock(error);
372
+ const deferred = deferToTransaction({ desiredVersion, version: current?.version });
373
+ if (deferred || lostLock) {
374
+ progress.refund('local:' + target.packageRoot);
375
+ return deferred || status({ ok: true, status: "deferred-update-in-flight", desiredVersion, version: current?.version, runtimeHealthy: false });
376
+ }
377
+ progress.fail(error.message); log('local recovery failed: ' + error.message);
378
+ }
337
379
  }
338
380
  if (discoveryError) throw discoveryError;
339
381
  let quarantine = policy.decision(channel, desiredVersion);
@@ -346,6 +388,10 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
346
388
  if (quarantine.blocked) return status({ ok: true, status: "deferred-release-cooldown", desiredVersion, runtimeHealthy: runtimeVerified, runtimeAvailable: runtimeResponsive, retryAt: quarantine.retryAt, failures: quarantine.failures });
347
389
  const busy = busyDecision(heartbeat, { homeDir, now: now() });
348
390
  if (busy) return status({ ok: true, status: busy, desiredVersion, ...repairState });
391
+ // Never start a five-minute download to race an installer that is already
392
+ // running; the daemon's updater discovers the same release we just did.
393
+ const deferredBeforeDownload = deferToTransaction({ desiredVersion, ...repairState });
394
+ if (deferredBeforeDownload) return deferredBeforeDownload;
349
395
  if (previous?.desiredVersion === desiredVersion && previous.retryAt > now()) return status({ ok: false, status: "backoff", desiredVersion, ...repairState,
350
396
  failures: previous.failures, retryAt: previous.retryAt, lastError: previous.lastError });
351
397
  // Ordinary upgrades wait for pressure to clear. An unavailable installation
@@ -369,6 +415,8 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
369
415
  if (read(path.join(root, "recovery", "policy.json"))?.autoUpdate === false) return status({ ok: true, status: "disabled", desiredVersion });
370
416
  const stillBusy = busyDecision(read(path.join(root, "recovery", "daemon.json")), { homeDir, now: now() });
371
417
  if (stillBusy) return status({ ok: true, status: stillBusy, desiredVersion });
418
+ const deferredAfterDownload = deferToTransaction({ desiredVersion, ...repairState });
419
+ if (deferredAfterDownload) return deferredAfterDownload;
372
420
  const entry = path.join(candidate.packageRoot, "src", "recovery-entry.js");
373
421
  if (!fs.existsSync(entry)) throw new Error("candidate-missing-recovery-engine");
374
422
  status({ ok: true, status: "activating", desiredVersion, ...repairState });
@@ -380,6 +428,11 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
380
428
  if (!observed.ok) throw new Error("replacement-not-healthy");
381
429
  return proven({ ok: true, status: "current", desiredVersion, version: desiredVersion, repair: "download", restarts: 0, lastSuccessAt: now(), failures: 0 }, observed);
382
430
  } catch (error) {
431
+ // Only a worker that owned the transaction can have judged the release.
432
+ // One that lost the lock, or found another owner mid-flight, judged nothing.
433
+ const deferred = deferToTransaction({ desiredVersion, ...repairState });
434
+ if (deferred) return deferred;
435
+ if (workerLostLock(error)) return status({ ok: true, status: "deferred-update-in-flight", desiredVersion, ...repairState, runtimeHealthy: false });
383
436
  policy.failure(channel, desiredVersion, { id: attemptId, reason: error.message });
384
437
  policy.interrupt();
385
438
  throw error;
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+
3
+ // The daemon's own updater and the scheduled recovery runner both install
4
+ // releases through the same canonical transaction. When both discover a release
5
+ // in the same minute, one of them owns the lock and the other loses it. Losing
6
+ // the lock says nothing about the release: the other owner is installing it
7
+ // right now. On 2026-09-21 the runner read that loss as a broken release,
8
+ // quarantined a version that was booting healthily, and rolled the person back.
9
+ //
10
+ // This module answers one question without importing the application tree:
11
+ // is a canonical runtime transaction currently owned by a live process?
12
+ const fs = require("node:fs");
13
+ const path = require("node:path");
14
+ const { processAlive, nativeProcessIdentity } = require("./recovery-launcher.cjs");
15
+
16
+ // An admitted update request older than this is debris from a worker that died
17
+ // without writing its terminal state; the lock engine reclaims after owner death.
18
+ const REQUEST_IN_FLIGHT_MAX_MS = 45 * 60_000;
19
+ // The exit code a recovery worker uses when the lock belongs to a live owner.
20
+ // EX_TEMPFAIL: try again later; nothing about the release was judged.
21
+ const WORKER_EXIT_TRANSACTION_IN_PROGRESS = 75;
22
+
23
+ function read(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } }
24
+
25
+ function liveOwner(owner, { alive = processAlive, identity = nativeProcessIdentity } = {}) {
26
+ const pid = Number(owner?.pid);
27
+ if (!Number.isInteger(pid) || pid <= 0) return false;
28
+ if (!alive(pid)) return false;
29
+ const expected = typeof owner.processIdentity === "string" ? owner.processIdentity : "";
30
+ if (!expected) return true;
31
+ const actual = identity(pid);
32
+ return !actual || actual === expected;
33
+ }
34
+
35
+ // Returns the live transaction, or null when no live process owns one.
36
+ // The lock owner is authoritative; an admitted update request adds the version
37
+ // being installed, which the lock file does not carry.
38
+ function inFlightTransaction({ homeDir, now = Date.now(), alive = processAlive, identity = nativeProcessIdentity } = {}) {
39
+ const root = path.join(homeDir, ".relay", "runtime");
40
+ const owner = read(path.join(root, "transaction.lock", "owner.json"));
41
+ let request = null;
42
+ let names = [];
43
+ try { names = fs.readdirSync(path.join(root, "update-requests")); } catch {}
44
+ for (const name of names) {
45
+ if (!/^[0-9a-f-]{36}\.json$/i.test(name)) continue;
46
+ const value = read(path.join(root, "update-requests", name));
47
+ if (value?.state !== "admitted") continue;
48
+ const admittedAt = Number(value.admittedAt);
49
+ if (!Number.isFinite(admittedAt) || admittedAt > now || now - admittedAt > REQUEST_IN_FLIGHT_MAX_MS) continue;
50
+ const pid = Number(value.workerPid) || Number(value.lockOwner?.pid);
51
+ if (!liveOwner({ pid, processIdentity: value.lockOwner?.processIdentity }, { alive, identity })) continue;
52
+ request = { source: "request", pid, requestId: value.requestId || null, version: value.version || null, admittedAt };
53
+ break;
54
+ }
55
+ if (request) return request;
56
+ if (owner && liveOwner(owner, { alive, identity })) {
57
+ return { source: "lock", pid: Number(owner.pid), requestId: owner.requestId || null, version: null, admittedAt: Number(owner.createdAt) || null };
58
+ }
59
+ return null;
60
+ }
61
+
62
+ function workerLostLock(error) {
63
+ return /recovery-worker-exit-75$/.test(String(error?.message || error || ""));
64
+ }
65
+
66
+ module.exports = { inFlightTransaction, liveOwner, workerLostLock, REQUEST_IN_FLIGHT_MAX_MS, WORKER_EXIT_TRANSACTION_IN_PROGRESS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.548",
3
+ "version": "0.1.550",
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,17 +1,17 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "name": "relay",
4
- "version": "1.1.66",
4
+ "version": "1.1.68",
5
5
  "consentVersion": 2,
6
- "baseUrl": "https://sendrelays.com/skills/relay/v1.1.66",
6
+ "baseUrl": "https://sendrelays.com/skills/relay/v1.1.68",
7
7
  "files": [
8
8
  {
9
9
  "path": "SKILL.md",
10
- "sha256": "133e7fce3c7d0adf4e76c6e93ad50897ce1a92924e354d58a07bc8506857b783",
10
+ "sha256": "0f11648dc5a5d27001b214efc474cdebcfb164eec748574cc9b3192e7a9cce68",
11
11
  "variants": {
12
12
  "dev": {
13
13
  "source": "SKILL.dev.md",
14
- "sha256": "1a1a300cdaee4ceb48bfeb1ee6f3dfb92cc15e304e357b30cc280fcd09fd0653"
14
+ "sha256": "91daeb3d53ccf4eabaacdac701639bd2b0470af05bec94761a20153a7b2bae46"
15
15
  }
16
16
  }
17
17
  },
@@ -21,7 +21,7 @@
21
21
  },
22
22
  {
23
23
  "path": "scripts/relay-protocol.mjs",
24
- "sha256": "2fcf0a459c079cdce025efd4bd8e495f5c3b8ce320f617ec04b540adf6f2f51e"
24
+ "sha256": "bfbc56ecaf202f77002da7eec4a33c5bfc7f3c6574df3dd385766684eb5458a3"
25
25
  },
26
26
  {
27
27
  "path": "scripts/relay-local.mjs",
@@ -381,10 +381,8 @@ asking for thoughts, opinions or answers, which come back as ordinary replies.
381
381
  A technical note with dense agent context is still a message; a small or quick
382
382
  piece of work is still a Task. Respect the account's available capabilities.
383
383
 
384
- A Task is closed only by `relay_task_complete`. For an inbound Task, call
385
- `relay_task_start` before the authorized work. Opening or reading a Task does
386
- not authorize starting it. Once the requested work is genuinely complete, call relay_task_complete with this taskRelayId, a concise forHuman result and the complete useful evidence in forAgent, before telling the human the Task is finished. This sends the completion Relay to the requester and marks the Task Done. Returning that result is part of the human's authorization to carry out the Task: do not wait for another send instruction or request separate approval, unless the human explicitly asked to review or withhold the result. Stay within the authorized task and disclosure scope. Do not use relay_send or relay_share_link as a substitute. If work remains blocked or incomplete, report that to the human without claiming completion. Confirm the tool succeeded; after an uncertain result, retry the same payload and idempotency key, never send a separate completion.
387
-
384
+ A Task is closed only by `relay_task_complete`. Started means beginning authorized work that advances the Task's requested outcome. First check the human's limits: an explicit 'without action' or 'leave status unchanged' means report only, with no start, completion or other status write, even when diagnosis is requested. 'Do not change code' alone is different: authorized investigation still counts as work. Call relay_task_start before authorized investigation, analysis, testing or implementation; read-only work counts. Opening a Task or summarizing its request alone does not count. Do not wait for code changes, experiments or acceptance of the full implementation when the human has authorized investigation. Retain the exact Task ID when a follow-up authorizes work. If the start call fails, report that the status was not updated; never claim Started without confirmation.
385
+ Once the requested work is genuinely complete, call relay_task_complete with this taskRelayId, a concise forHuman result and the complete useful evidence in forAgent, before telling the human the Task is finished. This sends the completion Relay to the requester and marks the Task Done. Returning that result is part of the human's authorization to carry out the Task: do not wait for another send instruction or request separate approval, unless the human explicitly asked to review or withhold the result. Stay within the authorized task and disclosure scope. Do not use relay_send or relay_share_link as a substitute. If work remains blocked or incomplete, report that to the human without claiming completion. Confirm the tool succeeded; after an uncertain result, retry the same payload and idempotency key, never send a separate completion.
388
386
  A reply into the Task's chat never
389
387
  completes it: when the approval or decision itself is the deliverable,
390
388
  `relay_task_complete` carries it as forHuman. Never send a Relay merely to
@@ -22,6 +22,7 @@ let transport = "auto";
22
22
  let lastTransport = "";
23
23
  const DIRECT_RECOVERY = "To renew browser approval, use connect-start <approved-api-origin> <invite-token> codex|claude_code, approve the returned URL in your browser, then connect-finish. Your own invitation from the Relay website can be used; no Companion or device enrollment is needed.";
24
24
  const SAFE_GET = [
25
+ /^\/v1\/share-links\/[A-Za-z0-9_-]+\/stats(?:\?.*)?$/,
25
26
  /^\/v1\/contact-groups$/,
26
27
  /^\/v1\/chats(?:\?.*)?$/,
27
28
  /^\/v1\/chats\/[A-Za-z0-9_-]+(?:\?.*)?$/,
@@ -35,6 +36,7 @@ const SAFE_GET = [
35
36
  /^\/v1\/share-links\/[A-Za-z0-9_-]+$/,
36
37
  ];
37
38
  const SAFE_POST = [
39
+ /^\/v1\/share-links\/[A-Za-z0-9_-]+\/placements$/,
38
40
  /^\/v1\/relays$/,
39
41
  /^\/v1\/relays\/[A-Za-z0-9_-]+\/forward$/,
40
42
  /^\/v1\/relays\/[A-Za-z0-9_-]+\/read$/,
@@ -45,6 +47,7 @@ const SAFE_POST = [
45
47
  // A person may correct a message they sent, or take back a link they minted
46
48
  // or a message they sent, from the same conversation. Both are sender-only on
47
49
  // the server and converge on an exact retry.
50
+ const SAFE_PUT = [/^\/v1\/share-links\/[A-Za-z0-9_-]+\/placements\/[A-Za-z0-9_-]+\/snapshot$/];
48
51
  const SAFE_PATCH = [
49
52
  /^\/v1\/messages\/[A-Za-z0-9_-]+$/,
50
53
  ];
@@ -217,7 +220,7 @@ async function readStdin() {
217
220
  }
218
221
 
219
222
  function allowed(method, requestPath) {
220
- const list = method === "GET" ? SAFE_GET : method === "POST" ? SAFE_POST : method === "PATCH" ? SAFE_PATCH : method === "DELETE" ? SAFE_DELETE : [];
223
+ const list = method === "PUT" ? SAFE_PUT : method === "GET" ? SAFE_GET : method === "POST" ? SAFE_POST : method === "PATCH" ? SAFE_PATCH : method === "DELETE" ? SAFE_DELETE : [];
221
224
  return list.some((pattern) => pattern.test(requestPath));
222
225
  }
223
226
 
@@ -691,6 +694,9 @@ const DIRECT_TOOLS = {
691
694
  relay_chat_fetch: directTool("Read a page of one chat without receipts. Defaults to the newest 25, oldest first. HTTPS requires chatId. Continue with nextBeforeCursor or nextAfterCursor; a page is not the full history.", { chatId: idField, limit: { type: "integer", minimum: 1, maximum: 200 }, beforeCursor: { type: "string" }, afterCursor: { type: "string" } }, ["chatId"], { full: true }),
692
695
  relay_thread_fetch: directTool("Read related Relays by their internal threadId without receipts.", { threadId: idField }, ["threadId"]),
693
696
  relay_inbox_list: directTool("Read recent inbox metadata, or up to 20 exact Relay packet envelopes in items [{relayId, ...response}]. Does not send receipts. Todo queries require Companion.", { relayIds: { type: "array", items: idField, minItems: 1, maxItems: 20 } }),
697
+ relay_share_stats: directTool("Read owner-only share statistics. Separates legacy opens, estimated external browsers, button attempts, successful copies, agent fetches and account outcomes. Does not mark read. Browser estimates are not people; owner/test events are excluded. Optional from/to are ISO timestamps.", {"relayId": {"type": "string", "minLength": 1}, "from": {"type": "string"}, "to": {"type": "string"}}, ["relayId"], { full: true, readOnly: true }),
698
+ relay_share_placement: directTool("Create an attributed URL for an existing share link. This sends nothing. Use separate placements for X replies and internal previews; test=true excludes that placement from acquisition. Reuse the same idempotency key on retries.", {"relayId": {"type": "string", "minLength": 1}, "idempotencyKey": {"type": "string", "minLength": 8}, "label": {"type": "string", "maxLength": 120}, "source": {"type": "string", "enum": ["x", "relay", "internal", "other"]}, "postId": {"type": "string", "pattern": "^[0-9]{1,30}$"}, "test": {"type": "boolean"}}, ["relayId", "idempotencyKey", "label", "source"], { full: true, readOnly: false }),
699
+ relay_share_snapshot: directTool("Save a manually observed X analytics snapshot for an X placement. Keep X aggregate impressions and link clicks separate from Relay visits; never infer unique people or subtract guessed self clicks.", {"relayId": {"type": "string", "minLength": 1}, "placementId": {"type": "string"}, "observedAt": {"type": "string"}, "impressions": {"type": "integer", "minimum": 0}, "linkClicks": {"type": "integer", "minimum": 0}}, ["relayId", "placementId", "observedAt", "impressions", "linkClicks"], { full: true, readOnly: false }),
694
700
  relay_sent_list: directTool("Read sent history. Optional recipient matches a name or address.", { recipient: stringField, limit: { type: "integer", minimum: 1, maximum: 100 } }),
695
701
  relay_mark_read: directTool("Send a read receipt only when the person requested reading this exact Relay and you present it.", { relayId: idField, idempotencyKey: stringField }, ["relayId", "idempotencyKey"], { readOnly: false }),
696
702
  relay_send: directTool("Send authorized correspondence or a Task using a resolved recipient, a title and both documents. Preserve the exact body and idempotency key on retry. HTTPS cannot send to unresolved names or email addresses; resolve an existing contact first or mint a link.", {
@@ -772,6 +778,15 @@ async function directToolCommand(command, body, config) {
772
778
  for (const relayId of new Set(args.relayIds)) items.push({ relayId, ...await request("GET", `/v1/relays/${relayId}`) });
773
779
  value = { items, readStateChanged: false, readReceiptsSent: false };
774
780
  } else value = await request("GET", "/v1/inbox?view=summary");
781
+ } else if (name === "relay_share_stats") {
782
+ const query = new URLSearchParams(Object.entries({ from: args.from, to: args.to }).filter(([, v]) => v !== undefined));
783
+ value = await request("GET", `/v1/share-links/${encodeURIComponent(args.relayId)}/stats?${query}`);
784
+ } else if (name === "relay_share_placement") {
785
+ const { relayId, ...body } = args;
786
+ value = await request("POST", `/v1/share-links/${encodeURIComponent(relayId)}/placements`, body);
787
+ } else if (name === "relay_share_snapshot") {
788
+ const { relayId, placementId, ...body } = args;
789
+ value = await request("PUT", `/v1/share-links/${encodeURIComponent(relayId)}/placements/${encodeURIComponent(placementId)}/snapshot`, body);
775
790
  } else if (name === "relay_sent_list") {
776
791
  const result = await request("GET", "/v1/sent?limit=100");
777
792
  const needle = String(args.recipient || "").toLowerCase();
@@ -381,10 +381,8 @@ asking for thoughts, opinions or answers, which come back as ordinary replies.
381
381
  A technical note with dense agent context is still a message; a small or quick
382
382
  piece of work is still a Task. Respect the account's available capabilities.
383
383
 
384
- A Task is closed only by `relay_task_complete`. For an inbound Task, call
385
- `relay_task_start` before the authorized work. Opening or reading a Task does
386
- not authorize starting it. Once the requested work is genuinely complete, call relay_task_complete with this taskRelayId, a concise forHuman result and the complete useful evidence in forAgent, before telling the human the Task is finished. This sends the completion Relay to the requester and marks the Task Done. Returning that result is part of the human's authorization to carry out the Task: do not wait for another send instruction or request separate approval, unless the human explicitly asked to review or withhold the result. Stay within the authorized task and disclosure scope. Do not use relay_send or relay_share_link as a substitute. If work remains blocked or incomplete, report that to the human without claiming completion. Confirm the tool succeeded; after an uncertain result, retry the same payload and idempotency key, never send a separate completion.
387
-
384
+ A Task is closed only by `relay_task_complete`. Started means beginning authorized work that advances the Task's requested outcome. First check the human's limits: an explicit 'without action' or 'leave status unchanged' means report only, with no start, completion or other status write, even when diagnosis is requested. 'Do not change code' alone is different: authorized investigation still counts as work. Call relay_task_start before authorized investigation, analysis, testing or implementation; read-only work counts. Opening a Task or summarizing its request alone does not count. Do not wait for code changes, experiments or acceptance of the full implementation when the human has authorized investigation. Retain the exact Task ID when a follow-up authorizes work. If the start call fails, report that the status was not updated; never claim Started without confirmation.
385
+ Once the requested work is genuinely complete, call relay_task_complete with this taskRelayId, a concise forHuman result and the complete useful evidence in forAgent, before telling the human the Task is finished. This sends the completion Relay to the requester and marks the Task Done. Returning that result is part of the human's authorization to carry out the Task: do not wait for another send instruction or request separate approval, unless the human explicitly asked to review or withhold the result. Stay within the authorized task and disclosure scope. Do not use relay_send or relay_share_link as a substitute. If work remains blocked or incomplete, report that to the human without claiming completion. Confirm the tool succeeded; after an uncertain result, retry the same payload and idempotency key, never send a separate completion.
388
386
  A reply into the Task's chat never
389
387
  completes it: when the approval or decision itself is the deliverable,
390
388
  `relay_task_complete` carries it as forHuman. Never send a Relay merely to