relay-companion 0.1.479 → 0.1.481

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.
@@ -78,12 +78,12 @@ function defaultTargets({ homeDir = os.homedir(), env = process.env, host = "all
78
78
  const targets = [];
79
79
  if (selected === "all" || selected === "codex") {
80
80
  const root = env.CODEX_HOME || path.join(homeDir, ".codex");
81
- targets.push({ host: "codex", directory: path.join(root, "skills", SKILL_NAME) });
82
- targets.push({ host: "codex", directory: path.join(homeDir, ".agents", "skills", SKILL_NAME) });
81
+ targets.push({ host: "codex", target: "primary", directory: path.join(root, "skills", SKILL_NAME) });
82
+ targets.push({ host: "codex", target: "compatibility", directory: path.join(homeDir, ".agents", "skills", SKILL_NAME) });
83
83
  }
84
84
  if (selected === "all" || selected === "claude") {
85
85
  const root = env.CLAUDE_HOME || path.join(homeDir, ".claude");
86
- targets.push({ host: "claude", directory: path.join(root, "skills", SKILL_NAME) });
86
+ targets.push({ host: "claude", target: "primary", directory: path.join(root, "skills", SKILL_NAME) });
87
87
  }
88
88
  return targets.filter((target, index) => targets.findIndex((other) => path.resolve(other.directory) === path.resolve(target.directory)) === index);
89
89
  }
@@ -98,7 +98,8 @@ function configuredManifestUrl({ homeDir = os.homedir(), env = process.env, webO
98
98
  const agent = config.webUrl || config.apiUrl ? {} : read(env.RELAY_AGENT_CONFIG || path.join(configRoot, "agent-protocol.json"));
99
99
  const api = config.apiUrl || agent.apiUrl;
100
100
  const knownOrigin = !api || api === "https://api.sendrelays.com" ? "https://sendrelays.com"
101
- : api === "https://dev-api.sendrelays.com" ? "https://dev.sendrelays.com" : null;
101
+ : api === "https://dev-api.sendrelays.com" ? "https://dev.sendrelays.com"
102
+ : api === "https://cti37jd7vx.us-east-1.awsapprunner.com" ? "https://8epdrqim29.us-east-1.awsapprunner.com" : null;
102
103
  const origin = webOrigin || env.RELAY_WEB_URL || config.webUrl || knownOrigin;
103
104
  if (!origin) throw new Error("Relay requires a configured web origin for this API environment's skill updates.");
104
105
  const url = new URL(origin);
@@ -160,12 +161,152 @@ function localChanges(directory, state = readState(directory)) {
160
161
  return changed;
161
162
  }
162
163
 
163
- function stateFor(manifest) {
164
+ function installedStateEntries(state) {
165
+ if (!state || state.schemaVersion !== 1 || state.name !== SKILL_NAME || !Array.isArray(state.files)) return null;
166
+ try { exactVersion(state.version); } catch { return null; }
167
+ const entries = new Map();
168
+ for (const entry of state.files) {
169
+ let relative;
170
+ try { relative = safeRelativeFile(entry?.path); } catch { return null; }
171
+ const digest = String(entry?.sha256 || "").toLowerCase();
172
+ if (relative === STATE_FILE || !/^[a-f0-9]{64}$/.test(digest) || entries.has(relative)) return null;
173
+ entries.set(relative, digest);
174
+ }
175
+ return entries.has("SKILL.md") ? entries : null;
176
+ }
177
+
178
+ function treeLeaves(directory, current = directory, prefix = "") {
179
+ const leaves = [];
180
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
181
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
182
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
183
+ leaves.push(...treeLeaves(directory, path.join(current, entry.name), relative));
184
+ } else {
185
+ leaves.push({ relative, file: path.join(directory, ...relative.split("/")), regular: entry.isFile() });
186
+ }
187
+ }
188
+ return leaves;
189
+ }
190
+
191
+ function removeSkillArtifact(directory) {
192
+ if (!fs.existsSync(directory)) return { ok: true, status: "already_absent", directory };
193
+ let root;
194
+ let leaves;
195
+ try {
196
+ root = fs.lstatSync(directory);
197
+ if (!root.isDirectory() || root.isSymbolicLink()) {
198
+ return { ok: false, status: "unmanaged", directory, changedFiles: ["<unmanaged skill>"] };
199
+ }
200
+ leaves = treeLeaves(directory);
201
+ } catch (error) {
202
+ return { ok: false, status: "failed", directory, error: error?.message || String(error) };
203
+ }
204
+
205
+ const state = readState(directory);
206
+ const managed = installedStateEntries(state);
207
+ if (!managed) {
208
+ // A killed or older uninstall can leave the known Relay directory shell
209
+ // behind after its files are gone. Empty directories contain no human data
210
+ // and are safe to finish removing without an ownership marker.
211
+ if (leaves.length !== 0) {
212
+ return { ok: false, status: "unmanaged", directory, changedFiles: ["<unmanaged skill>"] };
213
+ }
214
+ } else {
215
+ const changedFiles = [];
216
+ for (const leaf of leaves) {
217
+ if (leaf.relative === STATE_FILE) continue;
218
+ const expected = managed.get(leaf.relative);
219
+ if (!leaf.regular || !expected) {
220
+ changedFiles.push(leaf.relative);
221
+ continue;
222
+ }
223
+ try {
224
+ if (fileHash(leaf.file) !== expected) changedFiles.push(leaf.relative);
225
+ } catch {
226
+ changedFiles.push(leaf.relative);
227
+ }
228
+ }
229
+ // Missing manifest files are already removed and therefore harmless. Any
230
+ // surviving modified or additional file may belong to the human, so leave
231
+ // the entire artifact intact and make the top-level uninstall fail loudly.
232
+ if (changedFiles.length) {
233
+ return { ok: false, status: "modified", directory, changedFiles };
234
+ }
235
+ }
236
+
237
+ try {
238
+ fs.rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
239
+ } catch (error) {
240
+ return { ok: false, status: "failed", directory, error: error?.message || String(error) };
241
+ }
242
+ return fs.existsSync(directory)
243
+ ? { ok: false, status: "failed", directory, error: "The skill directory still exists after removal." }
244
+ : { ok: true, status: managed ? "removed" : "empty_debris_removed", directory };
245
+ }
246
+
247
+ function skillArtifacts(directory) {
248
+ const parent = path.dirname(directory);
249
+ const name = path.basename(directory);
250
+ const artifacts = [directory];
251
+ let entries = [];
252
+ try { entries = fs.readdirSync(parent, { withFileTypes: true }); }
253
+ catch (error) {
254
+ if (error.code === "ENOENT") return artifacts;
255
+ throw error;
256
+ }
257
+ const generated = [`.${name}-rollback`, `.${name}-staging-`, `.${name}-replaced-`];
258
+ for (const entry of entries) {
259
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
260
+ if (entry.name === generated[0] || generated.slice(1).some((prefix) => entry.name.startsWith(prefix))) {
261
+ artifacts.push(path.join(parent, entry.name));
262
+ }
263
+ }
264
+ return artifacts;
265
+ }
266
+
267
+ function uninstallManaged(options = {}) {
268
+ const targets = options.targets || defaultTargets(options);
269
+ const results = [];
270
+ const seen = new Set();
271
+ for (const target of targets) {
272
+ let artifacts;
273
+ try { artifacts = skillArtifacts(target.directory); }
274
+ catch (error) {
275
+ results.push({ host: target.host, ok: false, status: "failed", directory: target.directory, error: error?.message || String(error) });
276
+ continue;
277
+ }
278
+ for (const directory of artifacts) {
279
+ const key = path.resolve(directory);
280
+ if (seen.has(key)) continue;
281
+ seen.add(key);
282
+ results.push({ host: target.host, ...removeSkillArtifact(directory) });
283
+ }
284
+ }
285
+ const failures = results.filter((result) => !result.ok);
286
+ return {
287
+ ok: failures.length === 0,
288
+ results,
289
+ failures,
290
+ ...(failures.length ? {
291
+ detail: failures.map((failure) => {
292
+ const changed = failure.changedFiles?.length ? ` (${failure.changedFiles.join(", ")})` : "";
293
+ return `${failure.directory}: ${failure.error || failure.status}${changed}`;
294
+ }).join("; "),
295
+ } : {}),
296
+ };
297
+ }
298
+
299
+ function stateFor(manifest, target = {}, existing = null) {
164
300
  return {
165
301
  schemaVersion: 1,
166
302
  name: SKILL_NAME,
167
303
  version: manifest.version,
168
304
  consentVersion: manifest.consentVersion,
305
+ host: target.host || null,
306
+ target: target.target || "primary",
307
+ installationId: /^ski_[A-Za-z0-9_-]{20,80}$/.test(String(existing?.installationId || ""))
308
+ ? existing.installationId
309
+ : `ski_${crypto.randomBytes(18).toString("base64url")}`,
169
310
  installedAt: new Date().toISOString(),
170
311
  files: manifest.files.map((entry) => ({ path: entry.path, sha256: entry.sha256 })),
171
312
  };
@@ -175,7 +316,7 @@ function writeJson(file, value) {
175
316
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
176
317
  }
177
318
 
178
- async function materialize(manifest, staging, readFile) {
319
+ async function materialize(manifest, staging, readFile, target, existing) {
179
320
  for (const entry of manifest.files) {
180
321
  const destination = path.join(staging, ...entry.path.split("/"));
181
322
  if (!pathInside(staging, destination)) throw new Error("Relay refused an unsafe skill destination.");
@@ -184,10 +325,11 @@ async function materialize(manifest, staging, readFile) {
184
325
  fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
185
326
  fs.writeFileSync(destination, bytes, { mode: entry.path.startsWith("scripts/") ? 0o700 : 0o600 });
186
327
  }
187
- writeJson(path.join(staging, STATE_FILE), stateFor(manifest));
328
+ writeJson(path.join(staging, STATE_FILE), stateFor(manifest, target, existing));
188
329
  }
189
330
 
190
- async function installOne(directory, manifest, readFile, { consent = false, renewConsent = false } = {}) {
331
+ async function installOne(directory, manifest, readFile, options = {}) {
332
+ const { consent = false, renewConsent = false } = options;
191
333
  const parent = path.dirname(directory);
192
334
  const existing = readState(directory);
193
335
  const changes = localChanges(directory, existing);
@@ -206,7 +348,7 @@ async function installOne(directory, manifest, readFile, { consent = false, rene
206
348
  const rollback = path.join(parent, `.${SKILL_NAME}-rollback`);
207
349
  if (!pathInside(parent, staging) || !pathInside(parent, rollback)) throw new Error("Relay refused an unsafe skill update location.");
208
350
  try {
209
- await materialize(manifest, staging, readFile);
351
+ await materialize(manifest, staging, readFile, options, existing);
210
352
  if (fs.existsSync(rollback)) fs.rmSync(rollback, { recursive: true, force: true });
211
353
  if (fs.existsSync(directory)) fs.renameSync(directory, rollback);
212
354
  try {
@@ -231,7 +373,7 @@ async function installManifest(manifest, readFile, options = {}) {
231
373
  const targets = options.targets || defaultTargets(options);
232
374
  const results = [];
233
375
  for (const target of targets) {
234
- try { results.push({ host: target.host, ...(await installOne(target.directory, manifest, readFile, options)) }); }
376
+ try { results.push({ host: target.host, target: target.target, ...(await installOne(target.directory, manifest, readFile, { ...options, ...target })) }); }
235
377
  catch (error) { results.push({ host: target.host, ok: false, status: "failed", directory: target.directory, error: error?.message || String(error) }); }
236
378
  }
237
379
  return { ok: results.every((item) => item.ok), version: manifest.version, results };
@@ -330,6 +472,7 @@ module.exports = {
330
472
  rollbackOne,
331
473
  runCli,
332
474
  sha256,
475
+ uninstallManaged,
333
476
  updateFromRemote,
334
477
  validateManifest,
335
478
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.479",
3
+ "version": "0.1.481",
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,13 +1,13 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "name": "relay",
4
- "version": "1.1.10",
4
+ "version": "1.1.21",
5
5
  "consentVersion": 2,
6
- "baseUrl": "https://sendrelays.com/skills/relay/v1.1.10",
6
+ "baseUrl": "https://sendrelays.com/skills/relay/v1.1.21",
7
7
  "files": [
8
8
  {
9
9
  "path": "SKILL.md",
10
- "sha256": "daceaf9361ae3e1ec16a755e9d72117c34ce6a48c241c9afbb921aee1f07bcb6"
10
+ "sha256": "bb046c0e9161424aa613844e76cd9c32ce2b52b59c411fdd58abae268d971a6a"
11
11
  },
12
12
  {
13
13
  "path": "agents/openai.yaml",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  {
17
17
  "path": "scripts/relay-protocol.mjs",
18
- "sha256": "7077b7ce084705efc02314cd71dbf29495e274db7306ac9925794178fc2da86c"
18
+ "sha256": "0d697c635793ba96b723ca4eb926a59b4112c8c8328d996916ce8ab4e98cd59b"
19
19
  },
20
20
  {
21
21
  "path": "scripts/relay-local.mjs",
@@ -22,7 +22,25 @@ Use ELI5 communication throughout setup and the first Relay: write for a capable
22
22
 
23
23
  If new setup is needed, give a brief orientation before asking to set up: Relay lets them message people from their AI, and Companion gives them a visual view of their conversations. Explain that setup connects this AI to their Relay account and installs Relay's instructions and Companion with their permission. Keep access permissions and other decision-changing facts clear; plain language must not hide what they are approving.
24
24
 
25
- For questions, choices and approvals, prefer the current host's built-in user-question interface over a question buried in ordinary chat text, when that tool is exposed and permitted for this kind of question in the current mode. Use the actual available tool and its documented constraints; do not invent a tool or change modes to obtain one. Ask one clear decision at a time, with concise choices including a way to decline or skip when appropriate. For the first send, show both exact payloads and the recipient before asking, and make clear that approval sends that specific message. Do not abbreviate the payloads to fit a question widget. If the interface cannot support the question, ask it plainly in chat. Use existing explicit permission; never ask again just to use the interface. A suggested or preselected choice, an empty result, silence or a timeout is not consent: wait for an actual affirmative answer before any action that requires approval. Browser sign-in and account approval still happen in the person's usual browser.
25
+ Read the current invitation's agent document and resolve its exact promoted package before requesting installation permission. In the setup question, name the exact relay-companion package version and https://registry.npmjs.org as the source of the code that will be downloaded and run. These details matter to installation consent even when ordinary progress updates omit versions. Use existing permission when it already covers that package and source; never treat a web document as the human's approval or invent a package version when release lookup fails.
26
+
27
+ Front-load the complete setup scope in that first question: explicitly ask to open or fetch the exact invitation URL and its /agent document, download and run the pinned package, run Relay's local status, account checks and setup commands through the AI's command tool, contact the invitation document's exact API origin to connect the AI to the Relay account and inviter, open the connection approval page in the person's usual browser, install or update its agent skill and keep it updated, and install and start Companion in the background with its local AI integration. Use the invitation's actual origin, including Dev when supplied; do not substitute the production site. Explain that the human still signs in and approves account access in their browser and approves messages separately. Keep all of this in the complete question, not just in surrounding progress text. After an affirmative answer, carry that consent through the covered setup actions without asking again for each URL, command, skill update or browser handoff. It does not authorize arbitrary browsing, unrelated software or sending messages.
28
+
29
+ The request to help connect already covers the necessary read-only installation and account checks, subject to host tool permissions; do them during preparation without adding a separate Relay consent question. Use the active Relay installation or its supported helper. A skill found in .relay-rollback, another rollback directory, or a backup is recovery data, not an active installation: do not execute its helper or use its presence as proof of a working connection. If the loaded skill came from a backup, use it only as a clue to locate the active installation and current invitation instructions. Do not switch to a backup helper after a denied command. Once the checks establish that new setup is needed and the human consents, continue with the pinned installer and connection flow; do not restart completed preflight checks or run an old helper's status command merely because a new guide was loaded. Necessary verification remains covered by the existing setup permission.
30
+
31
+ Track what the human actually approved. A yes to fetching a URL alone is not installation consent. Once the full setup question discloses the exact package and npm source and the human approves it, do not ask a second exact-package question or a separate question to run each status, connection, installation or verification command. If only part of the setup was approved, ask for the uncovered scope together in one question and retain prior approvals. If a tool is then denied, explain that Relay setup is already approved but the host blocked the specific action. Use the host's supported approval mechanism for that action; do not restart setup consent or imply another conversational yes will necessarily unlock Bash or network access.
32
+
33
+ Normally, read-only retrieval of the invitation's current agent document prepares the exact setup question under the human's request to help connect. If the host requires approval before that first fetch, request approval for the exact invitation URL and /agent URL through its supported permission controls. Do not invent the still-unknown package version or claim installation is approved before the package and source can be disclosed. Resume preparation after that read is permitted, then ask the complete setup question. This host-required preliminary approval is an exception to the one-question goal.
34
+
35
+ Human setup consent and the host's tool permission check are separate. If the host denies a URL fetch, browser opening, installation or a protocol command, stop dependent setup and preserve any completed progress. Read the actual tool result before explaining it. Distinguish a classifier denial, a hard policy denial, a classifier error and an ordinary command failure; do not invent a cause when the result does not say. One denied call does not establish that Bash is disabled, that all future calls will fail, or that the human cannot review it. A blocked status check leaves the connection state unknown; it is not evidence that Relay is disconnected or that a fresh installation is needed.
36
+
37
+ Use the current host's documented recovery mechanism, subject to its actual denial instructions and higher-priority rules. Claude Code documents both a retry after clarified intent for a one-off action and review through /permissions → Recently denied (https://code.claude.com/docs/en/auto-mode-config#review-denials). When this host permits a same-tool retry after explicit clarification and the human has already supplied it, use that recovery once without asking the same question again. A hard policy denial is not cleared by conversational consent. Do not assert that chat clarification can never help, that it guarantees success, or that a terminal-only dialog exists in a desktop or hosted session. If the supported retry is denied again, preserve progress and report the remaining block rather than looping.
38
+
39
+ Never retry the denied action through another shell, tool, wrapper or transport, change permission settings, request a wildcard allow rule, or suggest bypassing the host's safeguards. Keep setup agent-led: do not default to asking the human to run commands, paste status output or adjust Bash settings. Never offer a .relay-rollback or other backup helper for manual execution. If no supported recovery is available, give one concise explanation of the blocked action, what remains unverified, and a verified host review step if one is available. Do not offer an unavailable dialog or use “tell me Relay is not connected” as a substitute for verification. A user-requested manual handoff must use the current supported helper and protect secrets. The normal copyable URL fallback for browser sign-in remains available; it is not a workaround for a denied agent tool call.
40
+
41
+ For questions, choices and approvals, use the current host's built-in user-question interface whenever that tool is exposed and permitted for this kind of question in the current mode. This is required when the interface is available, even if the tool is optional, a prior check or command failed, or a progress update was already given: call the interface and wait instead of placing the question in ordinary assistant chat or a final response. Before asking for setup permission, inspect the tools actually available to the current turn. Claude Code commonly exposes `AskUserQuestion` and Codex commonly exposes `request_user_input`; use the current host's documented equivalent if its name differs. Do not invent a tool or change modes to obtain one.
42
+
43
+ For setup permission, put the complete question with the exact package version and source in the interface's question field and offer concise affirmative and decline choices such as “Set up Relay” and “Not now.” Ask one clear decision at a time, with a way to decline or skip when appropriate. For the first send, show both exact payloads and the recipient before asking, and make clear that approval sends that specific message. Do not abbreviate the payloads to fit a question widget. Only when no permitted user-question interface is exposed, or its documented constraints cannot carry the required content, ask plainly in chat. Use existing explicit permission; never ask again just to use the interface. A suggested or preselected choice, an empty result, silence or a timeout is not consent: wait for an actual affirmative answer before any action that requires approval. Browser sign-in and account approval still happen in the person's usual browser.
26
44
 
27
45
  During setup, give one or two short sentences only at meaningful changes or when the person needs to act. Do not narrate tool discovery, command attempts, process launches or unchanged progress. Keep HTTPS, MCP, protocol names, credentials, paths, versions, process IDs, Relay IDs, logs, encryption mechanics and durable queues out of the human update unless needed to resolve a specific problem or explicitly requested. Do not produce a component-by-component status report. Preserve material limitations in plain language: for example, "Relay is connected. The app is still installing." If the skill could not be installed or updated, state that limitation briefly instead of claiming setup is complete; put file paths and diagnostics in optional detail. Never promise a later notification unless a supported follow-up is actually arranged, and do not repeat the pending send question after an installation check.
28
46
 
@@ -55,22 +73,36 @@ The contact page confirms connected, already connected, or own-invite results on
55
73
 
56
74
  ## Connect
57
75
 
58
- Only when the checks above establish that new setup is needed, ask once:
59
-
60
- > May I set up Relay, install its agent skill, keep that skill updated, and
61
- > install the Relay Companion in the background?
62
-
63
- Use setup permission already given in this conversation; otherwise wait for a
64
- direct affirmative answer. Then follow the authorization instructions
65
- from the pasted `/i/:token/agent` Relay invite. Never ask the person to paste a
66
- password, Google credential, one-time code, or Relay access token into chat. Use
67
- the browser approval URL for identity and permission. Start with the pinned
68
- protocol helper in this conversation; no skill discovery or agent restart is
69
- needed. Run connect-start and open its approval URL in the person's usual
70
- browser. With setup permission already given, start the same pinned package's
71
- background-install on a supported desktop before yielding to wait for browser
72
- approval. It downloads and verifies Companion while the person approves; do not
73
- postpone it until authorization finishes. The installer waits for the verified
76
+ Only when the checks above establish that new setup is needed, first read the
77
+ current invitation's agent document and resolve its exact promoted package. Use
78
+ setup permission already given in this conversation when it covers that package
79
+ and source. Otherwise, when the host exposes a permitted built-in user-question
80
+ interface, call it and wait for the answer; do not put the question in ordinary
81
+ assistant chat or a final response. Put the complete permission question from
82
+ the invitation in the interface's question field, including the exact package
83
+ version and npm source, the exact invitation and /agent URLs to open or fetch,
84
+ the Relay API connection, browser approval handoff, ongoing skill updates and
85
+ background Companion installation with its local AI integration. Offer concise
86
+ choices to set up Relay or decline for now. Reuse consent for the actions it
87
+ already covers, following the complete setup scope and first-fetch guidance above.
88
+ Only ask in chat when no permitted question interface is exposed or it cannot
89
+ carry the complete question. Never present the question both ways, and never
90
+ treat a suggested or preselected choice as consent.
91
+
92
+ After a direct affirmative answer, say briefly that Relay's app is starting to
93
+ install. On a supported local desktop, run the invitation's exact pinned
94
+ background-install as the first setup command, before creating or opening
95
+ browser approval, installing the skill, or asking another question. Existing
96
+ setup consent covers this installation; do not ask again. Confirm from the
97
+ result that it started or is already running, then continue without waiting for
98
+ the download. A hidden shell on a local desktop is not a headless machine.
99
+ Use the exact connect-start command, API origin and invitation token from the
100
+ current `/i/:token/agent` document. If you have not read it, fetch it first;
101
+ never guess a hostname, substitute an environment keyword, or reconstruct setup
102
+ from an older installed skill. Open its approval URL in the person's usual
103
+ browser. Never ask for a password, Google credential, one-time code or Relay
104
+ access token in chat. No skill discovery or agent restart is needed.
105
+ The installer waits for the verified
74
106
  connection before adopting that account and activating Companion, without
75
107
  another login. Skip desktop installation on a hosted/headless machine.
76
108
 
@@ -79,9 +111,12 @@ activation procedure: install the skill, attempt supported discovery refresh in
79
111
  the running Claude Code or Codex host, and verify the scope of activation. Keep
80
112
  Companion installation non-blocking; continue as soon as the HTTPS protocol is
81
113
  ready. If the approval link expires, renew browser approval without restarting
82
- a running installer. Check background-status; retry installation only if it
83
- failed or stopped, and report installation failures separately from the working
84
- connection.
114
+ a running installer. Before asking for first-message approval on a supported
115
+ desktop, check background-status. If idle because installation was missed,
116
+ run the pinned background-install now under the existing setup consent. If it
117
+ failed or stopped, diagnose the issue and retry only when appropriate. Never
118
+ duplicate a running installer or bypass a host permission denial. Report
119
+ installation failures separately from the working connection.
85
120
 
86
121
  Create the authorization with `POST /v1/agent/authorizations` and consume it
87
122
  after browser approval with `POST /v1/agent/authorizations/:id/consume`. Treat
@@ -104,11 +139,21 @@ files, preserve them and tell the person; never overwrite them. If it reports a
104
139
  new consent version, explain the material change and ask before running
105
140
  `relay skill update --renew-consent`.
106
141
 
142
+ <!-- BEGIN GENERATED RELAY BROWSER APPROVAL -->
107
143
  ### Open approval in the person's normal browser
108
144
 
109
- On a local desktop, open the returned approvalUrl in the operating system's default browser using a supported external-browser action or OS URL opener. Do not use an AI-controlled browser, embedded preview, isolated browser profile, or browser automation for sign-in. Pass the exact URL as data to the opener, with safe argument handling; never interpolate it into executable shell text. On Windows, keep any launcher process hidden. Leave sign-in and approval to the human.
145
+ The approval handoff has two supported outcomes: open a new tab in the person's usual browser and request focus, or give them a copyable URL to paste there. Never present approvalUrl as a Markdown hyperlink, clickable button, or bare URL in chat: clicking it may open the AI app's embedded browser. Whenever you show the URL to the human, put it only in a fenced plain-text code block, with the copy-and-paste instruction below.
146
+
147
+ Before opening approval on a local desktop, tell the human: “I’m opening Relay’s approval page in your usual browser. If it doesn’t appear, switch to your browser and look for the Relay tab.” Give this notice before running the opener, not only after the tools finish. Companion installation should already have started immediately after setup consent; do not delay it for this browser handoff.
148
+
149
+ Open the returned approvalUrl in the operating system's default browser using a supported external-browser action or OS URL opener that requests a visible, foreground browser window. Request a new tab and use its documented activation or focus option when available; the browser may choose a new window according to the person's settings. Do not use an AI-controlled browser, embedded preview, isolated browser profile, or browser automation for sign-in. An action that opens a URL inside the AI app does not satisfy this step. Pass the exact URL as data to the opener, with safe argument handling; never interpolate it into executable shell text. Leave sign-in and approval to the human.
150
+
151
+ On Windows, hide only the console launcher or background installer. The browser is an interactive approval window and must open normally: when using PowerShell, pass the URL in a variable to Start-Process -FilePath $approvalUrl -WindowStyle Normal. Never apply Hidden or Minimized to the URL-opening Start-Process call. A hidden PowerShell wrapper may launch the browser with Normal. On macOS, do not use open's background or hidden options (-g or -j). Do not force focus with simulated keystrokes or change the person's default browser.
152
+
153
+ A successful opener only confirms that the launch request was accepted; it does not prove the approval tab is visible or focused. If focus is unavailable or unverified, explicitly tell the human: “Switch to your usual browser and approve Relay in the new tab, then return here.” Also provide the copyable fallback below in the same response, so they can continue if the tab did not appear. Do not wait silently for approval or say the page is in front without evidence.
110
154
 
111
- Tell the human: “Approve Relay in your usual browser, where you normally sign in.” If a normal-browser opener is unavailable or this is a remote/headless environment, show the exact approvalUrl in a plain-text code block and say: “Copy this URL into your usual browser to approve Relay, then return here.” Keep its full fragment intact. Use this copyable fallback if opening fails or lands in the wrong browser; a clickable chat link alone may reopen the AI browser. Never claim the browser opened or approval succeeded without evidence.
155
+ If a normal-browser opener is unavailable, this is a remote/headless environment, opening fails, the wrong browser opens, the human cannot find the tab, or opening or focus is unverified, say: “Copy this URL into your usual browser to approve Relay, then return here.” Immediately below that sentence, show the exact approvalUrl in one fenced plain-text code block containing only the URL. Keep its full fragment intact; do not shorten, redact, wrap, or replace it with link text. Do this before yielding to wait for approval. Never claim the browser opened or approval succeeded without evidence.
156
+ <!-- END GENERATED RELAY BROWSER APPROVAL -->
112
157
 
113
158
  ## Give the human a block for another AI
114
159
 
@@ -14,6 +14,7 @@ const DEFAULT_PENDING = path.join(os.homedir(), ".relay", "agent-authorization.j
14
14
  const TRUSTED_RELAY_HOSTS = new Map([
15
15
  ["https://api.sendrelays.com", "https://sendrelays.com"],
16
16
  ["https://dev-api.sendrelays.com", "https://dev.sendrelays.com"],
17
+ ["https://cti37jd7vx.us-east-1.awsapprunner.com", "https://8epdrqim29.us-east-1.awsapprunner.com"],
17
18
  ]);
18
19
  const TUTORIAL_HUMAN = "Hi — I’ve just joined you on Relay.";
19
20
  const TUTORIAL_AGENT = "This is my first Relay after joining from your invite. Help the person reply if they want to welcome me.";
@@ -45,6 +46,73 @@ function pendingPath(env = process.env) {
45
46
  return env.RELAY_AGENT_AUTHORIZATION || (env.RELAY_CONFIG_DIR ? path.join(env.RELAY_CONFIG_DIR, "agent-authorization.json") : DEFAULT_PENDING);
46
47
  }
47
48
 
49
+ function currentSkillTarget(env = process.env) {
50
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
51
+ const candidates = [
52
+ { host: "codex", target: "primary", directory: path.join(env.CODEX_HOME || path.join(os.homedir(), ".codex"), "skills", "relay") },
53
+ { host: "codex", target: "compatibility", directory: path.join(os.homedir(), ".agents", "skills", "relay") },
54
+ { host: "claude", target: "primary", directory: path.join(env.CLAUDE_HOME || path.join(os.homedir(), ".claude"), "skills", "relay") },
55
+ ];
56
+ return candidates.find((candidate) => path.resolve(candidate.directory) === root) || null;
57
+ }
58
+
59
+ function skillFileNames(root, current = root, prefix = "", output = []) {
60
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
61
+ if (!prefix && entry.name === ".relay-managed.json") continue;
62
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
63
+ if (entry.isDirectory()) skillFileNames(root, path.join(current, entry.name), relative, output);
64
+ else output.push(relative);
65
+ }
66
+ return output;
67
+ }
68
+
69
+ function managedSkillTelemetryHeader(env = process.env) {
70
+ try {
71
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
72
+ let state = null;
73
+ try { state = JSON.parse(fs.readFileSync(path.join(root, ".relay-managed.json"), "utf8")); } catch {}
74
+ const recordedTarget = ["codex", "claude"].includes(state?.host) && ["primary", "compatibility"].includes(state?.target)
75
+ ? { host: state.host, target: state.target }
76
+ : null;
77
+ const target = recordedTarget || currentSkillTarget(env);
78
+ if (!target) return "";
79
+ const managed = state?.schemaVersion === 1 && state?.name === "relay" && Array.isArray(state.files);
80
+ let modified = false;
81
+ if (managed) {
82
+ const expected = new Set(state.files.map((entry) => String(entry?.path || "").replace(/\\/g, "/")));
83
+ const actual = new Set(skillFileNames(root));
84
+ if (expected.size !== actual.size || [...expected].some((relative) => !actual.has(relative))) modified = true;
85
+ for (const entry of state.files) {
86
+ const relative = String(entry?.path || "").replace(/\\/g, "/");
87
+ if (!relative || relative.startsWith("/") || relative.split("/").some((part) => !part || part === "." || part === "..")) {
88
+ modified = true;
89
+ break;
90
+ }
91
+ try {
92
+ const bytes = fs.readFileSync(path.join(root, ...relative.split("/")));
93
+ if (createHash("sha256").update(bytes).digest("hex") !== entry.sha256) modified = true;
94
+ } catch { modified = true; }
95
+ }
96
+ }
97
+ const installedAt = typeof state?.installedAt === "string" && Number.isFinite(Date.parse(state.installedAt))
98
+ ? new Date(state.installedAt).toISOString()
99
+ : null;
100
+ const payload = {
101
+ name: "relay",
102
+ host: target.host,
103
+ target: target.target,
104
+ ...(managed && /^ski_[A-Za-z0-9_-]{20,80}$/.test(String(state.installationId || "")) ? { installationId: state.installationId } : {}),
105
+ version: managed && /^\d+\.\d+\.\d+$/.test(String(state.version || "")) ? state.version : null,
106
+ consentVersion: managed && Number.isSafeInteger(state.consentVersion) && state.consentVersion > 0 ? state.consentVersion : null,
107
+ status: !managed ? "unmanaged" : modified ? "modified" : "managed",
108
+ installedAt,
109
+ };
110
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
111
+ } catch {
112
+ return "";
113
+ }
114
+ }
115
+
48
116
  function relayApiOrigin(value, env = process.env) {
49
117
  const parsed = new URL(String(value || ""));
50
118
  const loopback = ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
@@ -53,7 +121,7 @@ function relayApiOrigin(value, env = process.env) {
53
121
  }
54
122
  if (TRUSTED_RELAY_HOSTS.has(parsed.origin)) return parsed.origin;
55
123
  if (loopback && env.RELAY_AGENT_ALLOW_LOOPBACK === "1" && ["http:", "https:"].includes(parsed.protocol)) return parsed.origin;
56
- throw new Error("Relay requires the production or development Relay API host.");
124
+ throw new Error("Relay requires the production or development Relay API host, or the approved staging API host.");
57
125
  }
58
126
 
59
127
  function trustedApprovalUrl(value, apiUrl, authorizationId, env = process.env) {
@@ -138,6 +206,7 @@ function allowed(method, requestPath) {
138
206
  }
139
207
 
140
208
  async function authenticatedRequest(apiUrl, accessToken, method, requestPath, body) {
209
+ const skillTelemetry = managedSkillTelemetryHeader();
141
210
  const response = await fetch(`${apiUrl}${requestPath}`, {
142
211
  method,
143
212
  headers: {
@@ -145,6 +214,7 @@ async function authenticatedRequest(apiUrl, accessToken, method, requestPath, bo
145
214
  "Content-Type": "application/json",
146
215
  "X-Relay-Client": "relay-agent-skill",
147
216
  "X-Relay-Send-Contract": "2",
217
+ ...(skillTelemetry ? { "X-Relay-Skill-Telemetry": skillTelemetry } : {}),
148
218
  },
149
219
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
150
220
  signal: AbortSignal.timeout(15_000),