relay-companion 0.1.474 → 0.1.475

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.
@@ -8,11 +8,11 @@ const { spawn } = require("node:child_process");
8
8
  const STATUS_VERSION = 1;
9
9
 
10
10
  function statusPath({ homeDir = os.homedir(), env = process.env } = {}) {
11
- return env.RELAY_BACKGROUND_INSTALL_STATUS || path.join(homeDir, ".relay", "companion-install.json");
11
+ return env.RELAY_BACKGROUND_INSTALL_STATUS || path.join(env.RELAY_CONFIG_DIR || path.join(homeDir, ".relay"), "companion-install.json");
12
12
  }
13
13
 
14
14
  function logPath({ homeDir = os.homedir(), env = process.env } = {}) {
15
- return env.RELAY_BACKGROUND_INSTALL_LOG || path.join(homeDir, ".relay", "companion-install.log");
15
+ return env.RELAY_BACKGROUND_INSTALL_LOG || path.join(env.RELAY_CONFIG_DIR || path.join(homeDir, ".relay"), "companion-install.log");
16
16
  }
17
17
 
18
18
  function atomicWriteJson(file, value) {
@@ -79,12 +79,33 @@ function defaultTargets({ homeDir = os.homedir(), env = process.env, host = "all
79
79
  if (selected === "all" || selected === "codex") {
80
80
  const root = env.CODEX_HOME || path.join(homeDir, ".codex");
81
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) });
82
83
  }
83
84
  if (selected === "all" || selected === "claude") {
84
85
  const root = env.CLAUDE_HOME || path.join(homeDir, ".claude");
85
86
  targets.push({ host: "claude", directory: path.join(root, "skills", SKILL_NAME) });
86
87
  }
87
- return targets;
88
+ return targets.filter((target, index) => targets.findIndex((other) => path.resolve(other.directory) === path.resolve(target.directory)) === index);
89
+ }
90
+
91
+ function configuredManifestUrl({ homeDir = os.homedir(), env = process.env, webOrigin } = {}) {
92
+ const configRoot = env.RELAY_CONFIG_DIR || path.join(homeDir, ".relay");
93
+ function read(file) {
94
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); }
95
+ catch (error) { if (error.code === "ENOENT") return {}; throw error; }
96
+ }
97
+ const config = read(env.RELAY_CONFIG || path.join(configRoot, "config.json"));
98
+ const agent = config.webUrl || config.apiUrl ? {} : read(env.RELAY_AGENT_CONFIG || path.join(configRoot, "agent-protocol.json"));
99
+ const api = config.apiUrl || agent.apiUrl;
100
+ const knownOrigin = !api || api === "https://api.sendrelays.com" ? "https://sendrelays.com"
101
+ : api === "https://dev-api.sendrelays.com" ? "https://dev.sendrelays.com" : null;
102
+ const origin = webOrigin || env.RELAY_WEB_URL || config.webUrl || knownOrigin;
103
+ if (!origin) throw new Error("Relay requires a configured web origin for this API environment's skill updates.");
104
+ const url = new URL(origin);
105
+ if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
106
+ throw new Error("Relay requires a secure configured web origin for skill updates.");
107
+ }
108
+ return new URL("/skills/relay/manifest.json", url).href;
88
109
  }
89
110
 
90
111
  function pathInside(parent, child) {
@@ -223,12 +244,13 @@ async function installBundled(options = {}) {
223
244
  }
224
245
 
225
246
  async function updateFromRemote(options = {}) {
226
- const manifestUrl = String(options.manifestUrl || MANIFEST_URL);
247
+ const manifestUrl = String(options.manifestUrl || configuredManifestUrl(options));
227
248
  const parsedUrl = new URL(manifestUrl);
228
249
  if (parsedUrl.protocol !== "https:" || parsedUrl.username || parsedUrl.password || parsedUrl.hash) {
229
250
  throw new Error("Relay requires a secure skill manifest URL.");
230
251
  }
231
252
  const manifest = parseManifest(await fetchBytes(parsedUrl.href, options), { requireRemote: true });
253
+ if (new URL(manifest.baseUrl).origin !== parsedUrl.origin) throw new Error("Relay's skill bundle must use the configured web origin.");
232
254
  return installManifest(manifest, (entry) => fetchBytes(`${manifest.baseUrl}/${entry.path.split("/").map(encodeURIComponent).join("/")}`, options), options);
233
255
  }
234
256
 
@@ -297,6 +319,7 @@ module.exports = {
297
319
  SKILL_NAME,
298
320
  STATE_FILE,
299
321
  compareVersions,
322
+ configuredManifestUrl,
300
323
  defaultTargets,
301
324
  installBundled,
302
325
  installManifest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.474",
3
+ "version": "0.1.475",
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.1",
4
+ "version": "1.1.7",
5
5
  "consentVersion": 2,
6
- "baseUrl": "https://sendrelays.com/skills/relay/v1.1.1",
6
+ "baseUrl": "https://sendrelays.com/skills/relay/v1.1.7",
7
7
  "files": [
8
8
  {
9
9
  "path": "SKILL.md",
10
- "sha256": "22e9969792deb664cc40dfa32a748507e934646f70022327ecee83e297dfc5bc"
10
+ "sha256": "ad86c448a5296a60ec1e4f974d941e6374f1863141d6b2dfbce11edca821cefa"
11
11
  },
12
12
  {
13
13
  "path": "agents/openai.yaml",
@@ -15,11 +15,11 @@
15
15
  },
16
16
  {
17
17
  "path": "scripts/relay-protocol.mjs",
18
- "sha256": "d24f4aa4991add43763753c4fec9cbc61f300fc8052e11b348287698cd35cec5"
18
+ "sha256": "7077b7ce084705efc02314cd71dbf29495e274db7306ac9925794178fc2da86c"
19
19
  },
20
20
  {
21
21
  "path": "scripts/relay-local.mjs",
22
- "sha256": "9cfd1f25ffc20e3c6bd1ba91a71b18311655fa1ba97958053a32b0c48318d5b8"
22
+ "sha256": "ee2687c5f060488dd292088c8197544e7b3354e2f7cbd5d5dffaed924aa955c9"
23
23
  },
24
24
  {
25
25
  "path": "scripts/relay-attachments.mjs",
@@ -1,17 +1,45 @@
1
1
  ---
2
2
  name: relay
3
- description: Use Relay from Claude Code or Codex over Relay's authenticated HTTPS protocol. Use when the person asks to set up Relay, read or send a Relay, check messages, reply to a contact, share their invite link, or continue the first-run Relay tutorial. Use the command helper for this setup; retain existing hosted integrations.
3
+ description: Use Relay from Claude Code or Codex with Companion's local MCP tools and the protocol helper for setup and fallback. Use when the person asks to set up Relay, read or send a Relay, check messages, reply to a contact, share their invite link, or continue the first-run Relay tutorial. Preserve existing integrations.
4
4
  ---
5
5
 
6
6
  # Relay
7
7
 
8
8
  Use Relay inside the current agent conversation. Companion supplies a visual
9
- view and, once connected, owns the credentials, encryption and outgoing queue.
9
+ view and, once connected, manages its credentials, encryption and outgoing queue.
10
10
  Hosted/headless agents can use the authenticated HTTPS protocol directly.
11
11
 
12
+ ## Agent transport
13
+
14
+ <!-- BEGIN GENERATED RELAY TRANSPORT -->
15
+ Use the available Relay MCP tools first when they answer. If they are absent or fail with an authentication or transport error, use the installed skill's protocol helper without repeatedly retrying MCP. Keep the exact approved message and idempotency key when switching transport. Do not treat permission refusals, invalid requests, account mismatches or encryption requirements as connection failures. The helper prefers the matching Companion and retains browser-approved HTTPS access for fallback. New setup uses the pinned helper immediately while Companion installs and registers local MCP for later sessions; registering MCP does not prove it is available in an already-open session. Existing hooks are preserved and new users receive no hooks.
16
+ <!-- END GENERATED RELAY TRANSPORT -->
17
+
18
+ ## Keep the new user informed
19
+
20
+ Use ELI5 communication throughout setup and the first Relay: write for a capable person who is new to Relay. Explain what Relay lets them do, what is happening now, and the one next action they need to take. Use short, natural sentences and familiar words. Avoid jargon, childish analogies, command inventories and step-by-step tool narration. These rules are self-contained; if an ELI5 skill is already available, apply it too, but do not require the human to install another skill.
21
+
22
+ 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.
23
+
24
+ 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
+
26
+ During setup, give brief updates at meaningful changes or when the person needs to act. Explain delays or failures by their effect and the next step. Keep commands, paths, versions, authentication mechanics and diagnostic logs in tool work or optional detail unless they change a decision. Preserve material limitations: being connected here, Companion still installing, and another AI not yet verified are different states. Do not claim all setup is complete from partial success.
27
+
28
+ After new setup, finish with what the person can do now, any remaining action or limitation, and the copyable block for their other AI. For a contact-only invitation, simply report the contact result and any next action. Teach everyday use with a short natural-language example such as “Ask me to check your Relay inbox.” Introduce further features only when useful or requested. Before speaking, check that a new user can tell what this is about, why it matters, what changed and what they need to do next.
29
+
30
+ ## Check for Relay before starting setup
31
+
32
+ A pasted invitation may be a request to add a contact from someone who already uses Relay. Before asking to install anything, make read-only checks in the current environment: look for an available Relay integration, the relay executable on PATH, an installed Relay skill and its supported helper, or an existing Companion installation. Do not install or update software merely to check whether it exists, and never inspect credential-file contents.
33
+
34
+ When the existing protocol helper is available, run its status command, then request GET /v1/me through that same helper to verify the live account. Status alone describes saved local state; it does not prove the connection works. An existing hosted integration can supply an equivalent read-only account check. Keep hosted integrations intact. Missing skill discovery, a command absent from PATH, a stopped Companion, expired authorization, a network failure, or a different local/remote environment does not prove Relay is uninstalled. Preserve what is installed; explain the specific issue and resolve the account or environment with the human before replacing any connection. Reopen an installed Companion when needed instead of installing it again.
35
+
36
+ If Relay is already installed or connected, treat this as contact-only unless the human explicitly asks for more setup. Use the invitation's contact page in the person's usual browser: append /contact to its /i/{token} URL. The invitation document supplies the exact link. Explain that accepting adds both people to each other's contacts without sending a message. Have the human check that the browser shows the same Relay account they use in their AI and choose Add contact. If the browser is signed out, sign in to the existing account. Use a supported system-browser opener; if unavailable, show the exact contact URL in a plain-text code block for the human to copy into their usual browser. Do not open sign-in in an AI-controlled browser. Do not request a new agent authorization, replace credentials, reinstall Companion, update or reinstall skills, or repeat the first-message tutorial just to accept another invite.
37
+
38
+ The contact page confirms connected, already connected, or own-invite results only after redemption succeeds. Do not claim a contact was added just because the page opened or a name-only contact search matched. If the result is unavailable or ambiguous, report that and verify before claiming success. Opening one's own invite adds no contact. Finish with the contact result; a separate message requires the human's explicit approval. Only continue into new setup when it is actually needed and authorized. If the installation state is uncertain, ask whether the person already uses Relay rather than assuming a fresh install.
39
+
12
40
  ## Connect
13
41
 
14
- If setup has not been completed, ask once:
42
+ Only when the checks above establish that new setup is needed, ask once:
15
43
 
16
44
  > May I set up Relay, install its agent skill, keep that skill updated, and
17
45
  > install the Relay Companion in the background?
@@ -42,10 +70,9 @@ credential. If direct invocation is ever required, resolve `RELAY_SKILL_ROOT`
42
70
  to the absolute directory containing this loaded `SKILL.md` and invoke
43
71
  `node "<RELAY_SKILL_ROOT>/scripts/relay-protocol.mjs" ...`; never assume the
44
72
  current working directory is the skill directory. The helper reads an
45
- owner-only credential file and never prints the access token. Do not configure
46
- an MCP server or install a hook.
73
+ owner-only credential file and never prints the access token. The background Companion installer registers local MCP. Do not add hooks or configure a hosted MCP server.
47
74
 
48
- Once per new agent session, quietly run `relay skill update` only if the `relay`
75
+ Except for contact-only invitations, once per new agent session, quietly run `relay skill update` only if the `relay`
49
76
  executable is already available on `PATH`. If it is unavailable, skip the check
50
77
  without installing anything or interrupting the task. Continue with the
51
78
  installed skill if the network check fails. If Relay reports locally modified
@@ -53,6 +80,26 @@ files, preserve them and tell the person; never overwrite them. If it reports a
53
80
  new consent version, explain the material change and ask before running
54
81
  `relay skill update --renew-consent`.
55
82
 
83
+ ### Open approval in the person's normal browser
84
+
85
+ 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.
86
+
87
+ 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.
88
+
89
+ ## Give the human a block for another AI
90
+
91
+ After verifying the connection and attempting setup, include a copyable plain-text block in the completion response, even if the human skips or declines the first-message tutorial. This is an optional way to start using Relay in another AI, not a prerequisite for finishing here or a workaround for skill discovery. Do not paste it into another conversation yourself.
92
+
93
+ Use the human's own reusable invitation from the helper's safe invite.url result or protocol invite-link. Never substitute the original inviter's link, invent a URL, or include credentials, pending-authorization data, or the one-time approval URL. Replace <my own Relay invite URL> below with that verified link. If it cannot be retrieved, report that limitation and give the block with the URL sentence omitted; do not delay the working connection or claim another surface is already connected.
94
+
95
+ Introduce it with: “To use Relay in another AI, paste this there:”
96
+
97
+ ```text
98
+ I already use Relay. Help me use the same Relay account in this AI too.
99
+ My own Relay invite URL is: <my own Relay invite URL>
100
+ Read the current Relay agent guide at https://sendrelays.com/llm_guide.md and the agent instructions linked from my invitation if provided. First check for an existing Relay connection on this device using the supported helper's status command; preserve a working connection and verify it is my intended account. Ask before installing or updating the skill for this AI. If this environment needs a new connection, guide me through setup with my existing account in my usual browser. Never copy credentials between environments. Do not send a message or repeat the first-message tutorial. Verify what works here.
101
+ ```
102
+
56
103
  ## Verify skill activation after setup
57
104
 
58
105
  Own activation under the setup permission already given. Follow the activation
@@ -110,6 +157,11 @@ After the send succeeds:
110
157
  5. Offer their reusable `invite-link` for sharing. It connects new people and
111
158
  contains no prewritten message. Do not send the link to anyone yourself.
112
159
 
160
+ After the tutorial finishes or the person skips it, check the pinned Companion's
161
+ `background-status` once if background installation was started. Report whether
162
+ installation succeeded, is still running, or failed; keep the working helper
163
+ available. Installation success does not prove MCP is active in this session.
164
+
113
165
  The tutorial activation event is the approved first Relay, not app installation.
114
166
 
115
167
  ## Everyday Relay work
@@ -119,9 +171,8 @@ result is ambiguous. Never invent an address or recipient identifier. Always
119
171
  show the proposed human and agent payloads and obtain the person's approval for
120
172
  a representational send.
121
173
 
122
- Keep the human payload natural, self-contained, and short. Put technical detail,
123
- file names, identifiers, evidence, and continuation instructions in the agent
124
- payload. Preserve exact text when the person asks for a verbatim payload.
174
+ Before composing any Relay, apply the complete writing contract below. It is
175
+ part of this skill for every send path; no MCP tool description is needed.
125
176
 
126
177
  Reading or summarizing an unread Relay should mark only the surfaced message as
127
178
  read. The sequence is: run `inbox`; choose the intended Relay id; run `read`
@@ -137,6 +188,120 @@ same body and key; never generate a replacement key for a retry.
137
188
 
138
189
  Use the absolute helper path with `help` for the exact local command surface.
139
190
 
191
+ <!-- BEGIN GENERATED RELAY WRITING -->
192
+ ## Writing a Relay
193
+
194
+ Every regular Relay has two documents for two readers. The person is switching
195
+ contexts and needs to understand what this is about and what it means for them.
196
+ Their agent needs enough context to understand the whole matter and help them
197
+ continue without making them reconstruct the sender's work. Compose the complete
198
+ `forAgent` first, then write `forHuman`. A short human message must not mean a
199
+ thin agent handoff. Apply these rules to drafts and previews as well as sends.
200
+
201
+ ### Preserve the sender's intent and voice
202
+
203
+ The human's informal instructions tell you what to communicate; they are not
204
+ usually a draft to lightly edit. Write what this person would naturally say to
205
+ this recipient. Supply the words, never additional meaning. Preserve every ask,
206
+ question, commitment, permission, deadline, urgency, opinion, evaluation and
207
+ next step without adding, removing, strengthening or softening any of them.
208
+ Keep suggestions tentative when the sender made them tentative. Include thanks
209
+ or other sentiments the sender explicitly requested. Preserve exact text when
210
+ the person requests a verbatim payload.
211
+
212
+ Sending information or attaching a file does not imply "please review",
213
+ "thoughts?", "let me know", or another request for a response. Do not invent a
214
+ closing ask, "nothing needed", or implementation assignment to complete a
215
+ template. Make ordinary wording choices yourself; ask only when a critical
216
+ uncertainty would materially change the meaning or commitment.
217
+
218
+ Use the sender's recipient-specific vocabulary, rhythm, directness, formality,
219
+ warmth, emphasis and sign-off. When relationship context matters, `sent` and
220
+ `chat <id>` can supply facts, referents and examples of messages the human typed.
221
+ Learn their voice from those, not earlier agent-written messages. History cannot
222
+ revive superseded intent. Do not copy the brevity or shorthand of messages they
223
+ wrote while already in a conversation; this reader may need fresh orientation.
224
+
225
+ ### The agent document: carry the complete useful context
226
+
227
+ `forAgent` is required and non-empty for a regular Relay. Write a self-contained
228
+ document, as long and detailed as the authorized subject requires. Under-sending
229
+ here is worse than over-sending. Preserve the useful conclusions, constraints,
230
+ rejected options, failures, preferences, questions, next steps, sources,
231
+ mechanisms, evidence, code, paths, logs, reproduction steps, chronology, data
232
+ and verification guidance that are available and relevant. Use Markdown when
233
+ it helps. Do not invent missing evidence or include unrelated private context.
234
+
235
+ Retain each distinct point from the sender, its rationale when supplied, and
236
+ the qualifications needed to interpret it. Separate observations, suggestions,
237
+ open questions and authorized actions. Technical detail does not itself make a
238
+ message an assignment. Give the recipient's agent substantive context, not an
239
+ instruction to "capture these points" followed by a shorter paraphrase of the
240
+ human message. Avoid repeating `forHuman` verbatim; enough shared context to
241
+ make the agent document understandable is appropriate.
242
+
243
+ ### The human document: write for someone arriving fresh
244
+
245
+ Start by plainly saying what this is about. Assume the reader has done a dozen
246
+ other things since it last came up. Give the minimum background needed before
247
+ the news; preserve that orientation when cutting. Do not refer to "the new
248
+ rule", "what we settled", an unexplained thread, or a coined term. Retell the
249
+ relevant thing in familiar words. A follow-up to an issue the recipient raised
250
+ within the last day may need only a sentence about the result and the closing
251
+ state the sender intended.
252
+
253
+ Then convey what happened, why it matters, and any decision, opinion or action
254
+ the sender actually wants. Keep the news to three sentences where possible;
255
+ use a fourth only when needed to preserve the intended meaning. Stay under 95
256
+ words by default. That is a ceiling, never a target: a small update is usually
257
+ a line or two. There is no required length ratio between the two documents;
258
+ their readers' needs determine the length.
259
+
260
+ Use complete, spoken sentences and plain words. Read it aloud: would the sender
261
+ say this to the recipient's face, and would the recipient understand it without
262
+ doing the work? Put one idea at a time. Avoid fragments, clipped shorthand,
263
+ clever lines, figures of speech, flourishes, or balanced rhetorical halves.
264
+ Use the sender's names for things. Words the recipient encounters in the
265
+ product or their own work are fine; avoid vocabulary learned only while doing
266
+ the underlying investigation. Say what happened to someone: "A supplier
267
+ charged us more than we agreed" or "People who opened the invite saw a blank
268
+ page."
269
+
270
+ Keep mechanisms, evidence, paths, commands, logs, versions, internal identifiers,
271
+ work chronology and implementation detail in `forAgent`, unless the sender
272
+ explicitly wants the person to read them or they change the person's decision.
273
+ Do not pack four findings into one sentence, squeeze a checklist into prose, or
274
+ turn the human document into an inventory of the agent document. Cut details
275
+ the person need not read before cutting meaning or necessary background.
276
+ Never add text just because space remains. No headings, lists, tables, code
277
+ blocks or repetition of the title in `forHuman`.
278
+
279
+ If Relay rejects an overlong draft, review it from the recipient's perspective,
280
+ remove repetition and move supporting detail to `forAgent`. Use any supported
281
+ length-review override only after rejection and only when the exact draft's
282
+ extra length is necessary to preserve intent; never preemptively or merely
283
+ because more detail is available.
284
+
285
+ ### Title, message kind and final review
286
+
287
+ For a titled Relay, use a natural 3–6 word gist in the sender's register. Name
288
+ the single ask, outcome, update or decision someone should recognize at a
289
+ glance. Do not concatenate every finding or write a report headline.
290
+
291
+ Classify the requested outcome: `kind: "message"` is human correspondence,
292
+ including technical notes, suggestions, opinions and decisions. Use
293
+ `kind: "task"` only for requested external work by the recipient's agent, such
294
+ as inspecting, retrieving, changing, testing or verifying something. A small
295
+ operation or one addressed as "you" is still work; dense agent context alone
296
+ is not. Respect the account's available capabilities.
297
+
298
+ Before presenting or sending, check both documents against the user's request:
299
+ every intended point is preserved; no ask or commitment was invented; the person
300
+ can understand the message on its own; the agent has the complete useful context;
301
+ and the human message sounds like the sender speaking. If either document fails,
302
+ revise it before sending or requesting any required approval.
303
+ <!-- END GENERATED RELAY WRITING -->
304
+
140
305
  ## Attachments, channels and conversations
141
306
 
142
307
  Use `groups` to find a channel, `chats` to find a conversation, and `chat <id>`
@@ -155,8 +320,16 @@ Use `attachment <relay-id> <attachment-id>` for an authorized download URL or
155
320
  locally decrypted file path. Download URLs are private, temporary transport.
156
321
 
157
322
  The same helper automatically uses Companion once it answers as the approved
158
- account and environment. It then removes the standalone token. If Companion
159
- stops after this handoff, reopen it; do not bypass its encryption with HTTPS.
323
+ account and environment. It retains the browser-approved direct credential in
324
+ the protected credential file. If Companion is unavailable or its authentication
325
+ fails, the helper can use direct HTTPS after verifying the same account and that
326
+ the service's encryption mode is off. It never bypasses account mismatches,
327
+ permission refusals or encryption requirements. A lost send response retries
328
+ the same body and idempotency key; never create a second send to change transport.
329
+ Local destinations, delivery and outbox operations still require Companion.
330
+ If an older helper already deleted the direct credential, reopen Companion or
331
+ renew browser approval to restore direct fallback. Expired or revoked direct
332
+ authorization also requires renewed approval; never expose or copy a token.
160
333
  `outbox` reports queued sends and failures. `outbox retry <idempotency-key>`
161
334
  retries the stored, previously approved payload with the same key when asked.
162
335
  Queued means held on this device,
@@ -29,6 +29,8 @@ export function readLocalDescriptor(file = localDescriptorPath()) {
29
29
  export function localRequest(descriptor, request, { timeoutMs = 30000 } = {}) {
30
30
  return new Promise((resolve, reject) => {
31
31
  const socket = net.createConnection(descriptor.endpoint);
32
+ let possiblySent = false;
33
+ const transportError = (message) => Object.assign(new Error(message), { localTransportFailure: true, possiblySent });
32
34
  let bytes = 0;
33
35
  const chunks = [];
34
36
  let finished = false;
@@ -39,8 +41,11 @@ export function localRequest(descriptor, request, { timeoutMs = 30000 } = {}) {
39
41
  socket.destroy();
40
42
  error ? reject(error) : resolve(value);
41
43
  };
42
- const timer = setTimeout(() => finish(new Error("Companion did not confirm the request. Retry with the same idempotency key.")), timeoutMs);
43
- socket.once("connect", () => socket.write(JSON.stringify({ ...request, capability: descriptor.capability }) + "\n"));
44
+ const timer = setTimeout(() => finish(transportError("Companion did not confirm the request. Retry with the same idempotency key.")), timeoutMs);
45
+ socket.once("connect", () => {
46
+ possiblySent = true;
47
+ socket.write(JSON.stringify({ ...request, capability: descriptor.capability }) + "\n");
48
+ });
44
49
  socket.on("data", (chunk) => {
45
50
  bytes += chunk.length;
46
51
  if (bytes > LOCAL_MAX_BYTES) return finish(new Error("Relay's local response is too large."));
@@ -53,7 +58,7 @@ export function localRequest(descriptor, request, { timeoutMs = 30000 } = {}) {
53
58
  } catch (error) { finish(error); }
54
59
  }
55
60
  });
56
- socket.once("error", () => finish(new Error("Companion is unavailable. Reopen Relay and retry; your agent conversation can stay open.")));
57
- socket.once("end", () => { if (!finished) finish(new Error("Companion closed before confirming the request. Retry with the same idempotency key.")); });
61
+ socket.once("error", () => finish(transportError("Companion is unavailable. Reopen Relay and retry; your agent conversation can stay open.")));
62
+ socket.once("end", () => { if (!finished) finish(transportError("Companion closed before confirming the request. Retry with the same idempotency key.")); });
58
63
  });
59
64
  }
@@ -23,6 +23,7 @@ const SAFE_GET = [
23
23
  /^\/v1\/chats\/[A-Za-z0-9_-]+$/,
24
24
  /^\/v1\/relays\/[A-Za-z0-9_-]+\/attachments\/[A-Za-z0-9_-]+\/download-url$/,
25
25
  /^\/v1\/me$/,
26
+ /^\/v1\/e2ee\/status$/,
26
27
  /^\/v1\/inbox(?:\?.*)?$/,
27
28
  /^\/v1\/sent(?:\?.*)?$/,
28
29
  /^\/v1\/contacts\/search\?q=.+$/,
@@ -37,11 +38,11 @@ const SAFE_POST = [
37
38
  ];
38
39
 
39
40
  function configPath(env = process.env) {
40
- return env.RELAY_AGENT_CONFIG || DEFAULT_CONFIG;
41
+ return env.RELAY_AGENT_CONFIG || (env.RELAY_CONFIG_DIR ? path.join(env.RELAY_CONFIG_DIR, "agent-protocol.json") : DEFAULT_CONFIG);
41
42
  }
42
43
 
43
44
  function pendingPath(env = process.env) {
44
- return env.RELAY_AGENT_AUTHORIZATION || DEFAULT_PENDING;
45
+ return env.RELAY_AGENT_AUTHORIZATION || (env.RELAY_CONFIG_DIR ? path.join(env.RELAY_CONFIG_DIR, "agent-authorization.json") : DEFAULT_PENDING);
45
46
  }
46
47
 
47
48
  function relayApiOrigin(value, env = process.env) {
@@ -75,8 +76,14 @@ function readConfig(file = configPath()) {
75
76
  let value;
76
77
  try { value = JSON.parse(fs.readFileSync(file, "utf8")); }
77
78
  catch (error) {
78
- if (error?.code === "ENOENT") throw new Error("Relay is not connected in this agent yet. Complete the browser approval first.");
79
- throw new Error("Relay's agent credential file could not be read.");
79
+ if (error?.code === "ENOENT") {
80
+ const local = readLocalDescriptor();
81
+ if (!local) throw new Error("Relay is not connected in this agent yet. Complete the browser approval first.");
82
+ if (!/^usr_[A-Za-z0-9_-]+$/.test(String(local.accountId || ""))) throw new Error("Relay's local account is invalid. Reopen Companion.");
83
+ value = { local: true, consentVersion: 2, apiUrl: local.apiUrl, account: { relayUserId: local.accountId } };
84
+ } else {
85
+ throw new Error("Relay's agent credential file could not be read.");
86
+ }
80
87
  }
81
88
  const apiUrl = relayApiOrigin(value?.apiUrl);
82
89
  const accessToken = String(value?.accessToken || "");
@@ -166,13 +173,31 @@ async function request(method, requestPath, body) {
166
173
  const local = readLocalDescriptor();
167
174
  if (local && (config.consentVersion ?? 1) >= 2) {
168
175
  if (local.accountId !== config.account?.relayUserId || local.apiUrl !== config.apiUrl) throw new Error("Companion is connected to a different Relay account or environment. Nothing was sent or read.");
169
- const result = await localRequest(local, { method: verb, path: cleanPath, body, accountId: config.account.relayUserId });
170
- // Only retire the standalone credential after the daemon has answered as
171
- // the exact account. Future failures must not bypass local encryption.
172
- if (!config.local) { config.local = true; delete config.accessToken; atomicWrite(configPath(), config); }
173
- return result;
176
+ // Retain the browser-approved credential independently of Companion. Record
177
+ // the local attempt before dispatch so a lost response still requires the
178
+ // encryption/account checks on a later direct retry.
179
+ if (!config.local) { config.local = true; atomicWrite(configPath(), config); }
180
+ try {
181
+ return await localRequest(local, { method: verb, path: cleanPath, body, accountId: config.account.relayUserId });
182
+ } catch (error) {
183
+ const stableMutation = typeof body?.idempotencyKey === "string" && body.idempotencyKey.trim().length >= 8;
184
+ // Permission and application refusals are authoritative; an expired local
185
+ // authentication can use the separately approved direct credential.
186
+ // An uncertain mutation can only cross transports with the same body
187
+ // and stable deduplication key.
188
+ if ((!error.localTransportFailure && error.status !== 401) || (error.possiblySent && verb !== "GET" && !stableMutation)) throw error;
189
+ }
190
+ }
191
+ if (!config.accessToken.startsWith("web_") || config.accessToken.length < 20) {
192
+ throw new Error("This connection has no direct Relay credential. Reopen Relay Companion, or renew browser approval to enable direct fallback.");
193
+ }
194
+ if (config.expiresAt && Date.parse(config.expiresAt) <= Date.now()) throw new Error("Relay's direct authorization expired. Renew browser approval to use direct fallback.");
195
+ if (config.local) {
196
+ const me = await authenticatedRequest(config.apiUrl, config.accessToken, "GET", "/v1/me");
197
+ if (!config.account?.relayUserId || me.user?.id !== config.account.relayUserId) throw new Error("Direct Relay is connected to a different account. Nothing was sent or read.");
198
+ const encryption = await authenticatedRequest(config.apiUrl, config.accessToken, "GET", "/v1/e2ee/status");
199
+ if (encryption.mode !== "off") throw new Error("Reopen Relay Companion for this connection's encryption. Direct fallback is unavailable.");
174
200
  }
175
- if (config.local) throw new Error("Reopen Relay Companion to use this connection. No agent restart is needed.");
176
201
  try { return await authenticatedRequest(config.apiUrl, config.accessToken, verb, cleanPath, body); }
177
202
  catch (error) {
178
203
  if (verb !== "POST" || cleanPath !== "/v1/relays" || body?.longForHumanConfirmed !== true || error.code !== "human_message_review_required" || !error.body?.reviewToken) throw error;