@totalreclaw/totalreclaw 3.3.2 → 3.3.4-rc.1

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.
@@ -0,0 +1,336 @@
1
+ /**
2
+ * pair-cli-relay — relay-mode runner for the `openclaw totalreclaw pair`
3
+ * CLI subcommand (3.3.4-rc.1).
4
+ *
5
+ * Background
6
+ * ----------
7
+ * The CLI default through 3.3.3-rc.1 was the loopback / LAN URL flow
8
+ * (`pair-cli.ts` `runPairCli` + `pair-session-store`). On Docker
9
+ * deployments — i.e. the rc.6+ default — that emits `http://localhost:18789/…`
10
+ * which is unreachable from the user's browser. QA on 3.3.3-rc.1 (Pedro
11
+ * 2026-04-30) confirmed this is the *primary* CLI-fallback failure mode:
12
+ * agent loses the `totalreclaw_pair` tool binding, falls back to
13
+ * `openclaw totalreclaw pair generate --url-pin-only`, gets a localhost
14
+ * URL, user can't open it.
15
+ *
16
+ * 3.3.4-rc.1 flips the CLI default to relay-mode. This file implements
17
+ * the runner. It mirrors the relay flow already used by the agent tool
18
+ * (`index.ts` `totalreclaw_pair` handler) so the CLI and the tool emit
19
+ * URLs from the same relay (`api-staging.totalreclaw.xyz` / `api.…`).
20
+ *
21
+ * Output formats
22
+ * --------------
23
+ * Same `PairCliOutputMode` surface as the local flow:
24
+ * - `human` — multi-line banner + QR ASCII + URL + PIN (default)
25
+ * - `json` — single-line `{v:1,sid,url,pin,mode,expires_at_ms,qr_ascii}`
26
+ * - `url-pin` — single-line `{v:1,url,pin,expires_at_ms}` (no QR)
27
+ * - `pair-only` — single-line `{v:1,pair_url,pin,expires_at_ms}` (no QR)
28
+ *
29
+ * The `sid` field in JSON mode carries the relay token (relay-issued
30
+ * opaque session id) so the agent can correlate emit + completion.
31
+ *
32
+ * Phrase safety
33
+ * -------------
34
+ * The same invariant the agent-tool path enforces: relay sees only
35
+ * ciphertext, gateway decrypts locally via x25519 ECDH + AES-GCM, the
36
+ * mnemonic is written to credentials.json by `completePairing` and never
37
+ * crosses any logger / stdout. PIN is on stdout (required) but never
38
+ * logged.
39
+ *
40
+ * Scanner / scope
41
+ * ---------------
42
+ * Touches `fs` indirectly via the credential-write completion handler
43
+ * passed in. No env-var reads here — caller resolves URL / paths from
44
+ * `CONFIG`. See `index.ts` wire-up.
45
+ */
46
+
47
+ import { validateMnemonic } from '@scure/bip39';
48
+ import { wordlist } from '@scure/bip39/wordlists/english';
49
+
50
+ import {
51
+ loadCredentialsJson,
52
+ writeCredentialsJson,
53
+ writeOnboardingState,
54
+ } from './fs-helpers.js';
55
+ import {
56
+ awaitPhraseUpload,
57
+ openRemotePairSession,
58
+ } from './pair-remote-client.js';
59
+ import { setRecoveryPhraseOverride } from './config.js';
60
+ import { encodePng, encodeUnicode } from './pair-qr.js';
61
+ import type {
62
+ PairCliIo,
63
+ PairCliJsonPayload,
64
+ PairCliMode,
65
+ PairCliOutcome,
66
+ PairCliOutputMode,
67
+ PairCliPairOnlyPayload,
68
+ PairCliUrlPinPayload,
69
+ } from './pair-cli.js';
70
+
71
+ export interface RelayPairCliRunnerOpts {
72
+ /** Relay base URL (`wss://api-staging.totalreclaw.xyz` for RC, `wss://api.…` for stable). */
73
+ relayBaseUrl: string;
74
+ /** Where credentials.json lives — written by completePairing. */
75
+ credentialsPath: string;
76
+ /** Where onboarding-state.json lives — flipped to `active` on success. */
77
+ onboardingStatePath: string;
78
+ /** Plugin version stamped into onboarding-state.json. */
79
+ pluginVersion: string;
80
+ /** Scope-address derivation. Best-effort — null on failure. */
81
+ deriveScopeAddress: (mnemonic: string) => Promise<string | undefined>;
82
+ /** Logger — never receives PIN / phrase / token-tail material. */
83
+ logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
84
+ /** QR ASCII renderer — same callback shape as `qrcode-terminal`. */
85
+ renderQr: (payload: string, cb: (ascii: string) => void) => void;
86
+ /** stdio surface for stdout / stderr / Ctrl+C. */
87
+ io: PairCliIo;
88
+ /** Output mode — defaults to `'human'`. */
89
+ outputMode?: PairCliOutputMode;
90
+ /**
91
+ * 3.3.4-rc.1 — currently informational. The relay-side TTL is set by the
92
+ * relay; this runner accepts the option for surface parity with the local
93
+ * runner. We do not extend it past the relay default because the relay is
94
+ * authoritative for session expiry.
95
+ */
96
+ ttlSeconds?: number;
97
+ }
98
+
99
+ /**
100
+ * Run the relay-mode pair CLI. Mirrors `runPairCli`'s exit-code semantics:
101
+ * - `completed` (status 0)
102
+ * - `canceled` (Ctrl+C — status 130)
103
+ * - `expired` / `rejected` / `error` (status 1)
104
+ *
105
+ * Resolves with the outcome; the caller (`registerPairCli` action) maps
106
+ * the outcome to `process.exit(...)`.
107
+ */
108
+ export async function runRelayPairCli(
109
+ mode: PairCliMode,
110
+ opts: RelayPairCliRunnerOpts,
111
+ ): Promise<PairCliOutcome> {
112
+ const outputMode: PairCliOutputMode = opts.outputMode ?? 'human';
113
+ const stdout = opts.io.stdout;
114
+
115
+ // 1. Open the relay session. The relay returns the user-facing URL +
116
+ // PIN + token + expiresAt. The keypair stays in-process.
117
+ let session: Awaited<ReturnType<typeof openRemotePairSession>>;
118
+ try {
119
+ session = await openRemotePairSession({
120
+ relayBaseUrl: opts.relayBaseUrl,
121
+ mode: mode === 'generate' ? 'generate' : 'import',
122
+ });
123
+ } catch (err) {
124
+ const msg = err instanceof Error ? err.message : String(err);
125
+ opts.io.stderr.write(
126
+ `\nFailed to open relay pairing session: ${msg}\n` +
127
+ `If the relay is unreachable from this gateway, retry with --local for the loopback URL flow.\n`,
128
+ );
129
+ return { status: 'error', error: msg };
130
+ }
131
+
132
+ // ISO-8601 → ms for tool-payload parity with the agent tool.
133
+ const parsedExpiresMs = Date.parse(session.expiresAt);
134
+ const expiresAtMs = Number.isFinite(parsedExpiresMs)
135
+ ? parsedExpiresMs
136
+ : Date.now() + 5 * 60_000;
137
+
138
+ // 2. Render the QR ASCII (skipped in url-pin / pair-only modes — the
139
+ // same as `runPairCli`). 10s timeout guard against a renderer that
140
+ // never fires its callback.
141
+ const skipsQr = outputMode === 'url-pin' || outputMode === 'pair-only';
142
+ const qrAscii = skipsQr
143
+ ? ''
144
+ : await new Promise<string>((resolve) => {
145
+ let settled = false;
146
+ const t = setTimeout(() => {
147
+ if (!settled) {
148
+ settled = true;
149
+ resolve('');
150
+ }
151
+ }, 10_000);
152
+ try {
153
+ opts.renderQr(session.url, (ascii) => {
154
+ if (settled) return;
155
+ settled = true;
156
+ clearTimeout(t);
157
+ resolve(ascii);
158
+ });
159
+ } catch (err) {
160
+ if (settled) return;
161
+ settled = true;
162
+ clearTimeout(t);
163
+ resolve(`(QR renderer crashed: ${err instanceof Error ? err.message : String(err)})`);
164
+ }
165
+ });
166
+
167
+ // 3. Emit the visible surface — single JSON line for non-human modes,
168
+ // multi-line banner + QR for human mode. Identical layout to the
169
+ // local-mode runner so callers can swap transparently.
170
+ if (outputMode === 'url-pin') {
171
+ const payload: PairCliUrlPinPayload = {
172
+ v: 1,
173
+ url: session.url,
174
+ pin: session.pin,
175
+ expires_at_ms: expiresAtMs,
176
+ };
177
+ stdout.write(JSON.stringify(payload) + '\n');
178
+ } else if (outputMode === 'pair-only') {
179
+ const payload: PairCliPairOnlyPayload = {
180
+ v: 1,
181
+ pair_url: session.url,
182
+ pin: session.pin,
183
+ expires_at_ms: expiresAtMs,
184
+ };
185
+ stdout.write(JSON.stringify(payload) + '\n');
186
+ } else if (outputMode === 'json') {
187
+ const payload: PairCliJsonPayload = {
188
+ v: 1,
189
+ sid: session.token,
190
+ url: session.url,
191
+ pin: session.pin,
192
+ mode,
193
+ expires_at_ms: expiresAtMs,
194
+ qr_ascii: qrAscii,
195
+ };
196
+ stdout.write(JSON.stringify(payload) + '\n');
197
+ } else {
198
+ // Human-mode banner. Mirror `pair-cli.ts` COPY surface, but tweak the
199
+ // header so operators see "Relay" not "Local" (so it's obvious the
200
+ // URL is universal-reachable, not gateway-loopback).
201
+ stdout.write(
202
+ '\nTotalReclaw — Relay pairing\n\n' +
203
+ 'Your TotalReclaw recovery phrase will be created (or imported) in your\n' +
204
+ 'BROWSER and delivered to this gateway encrypted end-to-end via the\n' +
205
+ 'relay (the relay only sees ciphertext). The phrase never touches the\n' +
206
+ 'LLM, the session transcript, or the relay server in plaintext.\n\n' +
207
+ 'Scan the QR code below with your phone, or open the URL on any device\n' +
208
+ '(no LAN / Tailscale / port-forward required). Then type the 6-digit\n' +
209
+ 'code shown here into the browser.\n',
210
+ );
211
+ stdout.write(
212
+ mode === 'generate'
213
+ ? '\nMode: GENERATE — your browser will create a NEW 12-word recovery phrase.\n' +
214
+ 'You will be asked to write it down and retype 3 words before the\n' +
215
+ 'gateway accepts it.\n'
216
+ : '\nMode: IMPORT — your browser will accept an existing TotalReclaw\n' +
217
+ 'recovery phrase that you already have. Paste it in the browser; it\n' +
218
+ 'will be validated locally and encrypted before upload.\n',
219
+ );
220
+ if (qrAscii) {
221
+ stdout.write('\n' + qrAscii + '\n');
222
+ } else {
223
+ stdout.write('\n(QR not rendered — use the URL below)\n');
224
+ }
225
+ stdout.write(
226
+ '\nSecondary code (type this into the browser):\n\n ' +
227
+ session.pin.split('').join(' ') +
228
+ '\n\nURL (QR encodes this plus a one-time public key):\n\n ' +
229
+ session.url +
230
+ '\n\nSecurity:\n' +
231
+ ' * Do NOT share your screen during pairing.\n' +
232
+ ' * Do NOT screenshot this terminal.\n' +
233
+ ' * The browser page will warn you never to reuse this recovery\n' +
234
+ ' phrase for wallets, banking, email, or any other service.\n' +
235
+ '\nWaiting for browser to connect… (press Ctrl+C to cancel)\n',
236
+ );
237
+ }
238
+
239
+ // 4. Optional PNG / Unicode QR for richer transports — same as the
240
+ // agent tool. Best-effort; non-fatal on encode failure.
241
+ if (!skipsQr && outputMode !== 'human') {
242
+ // JSON consumers already have qr_ascii; PNG/Unicode would belong in
243
+ // a separate response shape. Keeping the runner surface in-band with
244
+ // the local runner means we don't add fields here. Skip silently.
245
+ void encodePng;
246
+ void encodeUnicode;
247
+ }
248
+
249
+ // 5. Set up Ctrl+C cancellation. The relay session can't be
250
+ // server-side rejected from the client (no rejectPairSession-equivalent
251
+ // over the WS), but closing the WebSocket terminates the session.
252
+ let canceled = false;
253
+ const releaseInterrupt = opts.io.onInterrupt(() => {
254
+ canceled = true;
255
+ try {
256
+ session._ws.close();
257
+ } catch {
258
+ /* ignore */
259
+ }
260
+ });
261
+
262
+ // 6. Block on the relay until the browser uploads the encrypted
263
+ // phrase, then write credentials + flip onboarding-state. Mirrors
264
+ // the agent-tool's `awaitPhraseUpload` callback inline so we have a
265
+ // single source of truth for credential persistence.
266
+ const emitStatus = (text: string): void => {
267
+ if (outputMode === 'human') stdout.write(text);
268
+ };
269
+
270
+ try {
271
+ const result = await awaitPhraseUpload(session, {
272
+ phraseValidator: (p: string) => validateMnemonic(p, wordlist),
273
+ completePairing: async ({ mnemonic }) => {
274
+ try {
275
+ let scopeAddress: string | undefined;
276
+ try {
277
+ scopeAddress = await opts.deriveScopeAddress(mnemonic);
278
+ } catch (deriveErr) {
279
+ opts.logger.warn(
280
+ `pair-cli (relay): scope_address derivation failed (will retry lazily): ${
281
+ deriveErr instanceof Error ? deriveErr.message : String(deriveErr)
282
+ }`,
283
+ );
284
+ }
285
+ const creds = loadCredentialsJson(opts.credentialsPath) ?? {};
286
+ const next: typeof creds = { ...creds, mnemonic };
287
+ if (scopeAddress) next.scope_address = scopeAddress;
288
+ if (!writeCredentialsJson(opts.credentialsPath, next)) {
289
+ return { state: 'error', error: 'credentials_write_failed' };
290
+ }
291
+ setRecoveryPhraseOverride(mnemonic);
292
+ writeOnboardingState(opts.onboardingStatePath, {
293
+ onboardingState: 'active',
294
+ createdBy: mode === 'generate' ? 'generate' : 'import',
295
+ credentialsCreatedAt: new Date().toISOString(),
296
+ version: opts.pluginVersion,
297
+ });
298
+ opts.logger.info(
299
+ `pair-cli (relay): session ${session.token.slice(0, 8)}… completed; credentials written` +
300
+ (scopeAddress ? ` (scope_address=${scopeAddress})` : ''),
301
+ );
302
+ return { state: 'active' };
303
+ } catch (err: unknown) {
304
+ const msg = err instanceof Error ? err.message : String(err);
305
+ opts.logger.error(`pair-cli (relay): completePairing failed: ${msg}`);
306
+ return { state: 'error', error: msg };
307
+ }
308
+ },
309
+ });
310
+
311
+ if (canceled) {
312
+ emitStatus('\nCanceled. Pairing session invalidated.\n');
313
+ return { status: 'canceled', sid: session.token };
314
+ }
315
+ if (result.state === 'active') {
316
+ emitStatus('\nPairing complete. Account is active.\n');
317
+ return { status: 'completed', sid: session.token };
318
+ }
319
+ emitStatus(`\nPairing failed: ${result.error ?? 'unknown_error'}\n`);
320
+ return { status: 'error', sid: session.token, error: result.error ?? 'unknown_error' };
321
+ } catch (err) {
322
+ if (canceled) {
323
+ emitStatus('\nCanceled. Pairing session invalidated.\n');
324
+ return { status: 'canceled', sid: session.token };
325
+ }
326
+ const msg = err instanceof Error ? err.message : String(err);
327
+ if (msg.includes('timeout')) {
328
+ emitStatus('\nSession expired. Run the command again to restart.\n');
329
+ return { status: 'expired', sid: session.token };
330
+ }
331
+ emitStatus(`\nPairing error: ${msg}\n`);
332
+ return { status: 'error', sid: session.token, error: msg };
333
+ } finally {
334
+ releaseInterrupt();
335
+ }
336
+ }
package/pair-cli.ts CHANGED
@@ -3,20 +3,34 @@
3
3
  *
4
4
  * Purpose
5
5
  * -------
6
- * Starts a remote-onboarding session FROM the gateway host's terminal.
7
- * Creates a pair-session, renders the QR + URL + 6-digit secondary code
8
- * to stdout, then polls /status until the browser completes the flow.
6
+ * Starts a pairing session from the gateway host's terminal and renders
7
+ * the URL + 6-digit PIN + ASCII QR. The user opens the URL in a browser
8
+ * (on phone or laptop), confirms the PIN, and uploads their recovery
9
+ * phrase end-to-end-encrypted.
9
10
  *
10
- * This is the gateway-operator's surface. The operator reads the QR
11
- * with their phone (or opens the URL on their laptop browser); the
12
- * browser takes over from there.
11
+ * Two URL flavours
12
+ * ----------------
13
+ * * **Relay mode (3.3.4-rc.1 default).** The CLI opens a WebSocket against
14
+ * the relay (`api-staging.totalreclaw.xyz` for RC, `api.totalreclaw.xyz`
15
+ * for stable) and gets back a `https://<relay>/pair/p/<token>#pk=…` URL
16
+ * the user can reach from any device on any network. This is the same
17
+ * surface the agent-tool `totalreclaw_pair` uses. It works behind NAT,
18
+ * in Docker, on managed services — anywhere outbound HTTPS works.
19
+ *
20
+ * * **Local mode (`--local`).** The legacy loopback flow: a session lands
21
+ * in `pair-session-store` and the URL points at the gateway's own
22
+ * bound interface (`http://localhost:18789/...`, or LAN/Tailscale IP
23
+ * if autodetected). Required for fully-air-gapped operators who want
24
+ * the relay out of the loop. Browser must be on a network that can
25
+ * reach the gateway.
13
26
  *
14
27
  * Scope and scanner surface
15
28
  * -------------------------
16
29
  * Has `fetch` (for status polling) AND `POST` (never actually POSTs,
17
30
  * but the word lives in comments describing the paired browser POST).
18
31
  * MUST NOT also read disk or env vars. All state operations delegate
19
- * to pair-session-store; the CLI itself is a thin coordinator.
32
+ * to pair-session-store / pair-remote-client; the CLI itself is a thin
33
+ * coordinator.
20
34
  *
21
35
  * Zero logging of secret material. The secondary code IS printed to
22
36
  * stdout (required for the user to type), but never logged to file
@@ -444,6 +458,15 @@ export function registerPairCli(
444
458
  sessionsPath: string;
445
459
  renderPairingUrl(session: PairSession): string;
446
460
  logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
461
+ /**
462
+ * 3.3.4-rc.1 — relay-mode runner. When supplied, the CLI defaults to
463
+ * relay-mode (relay-brokered URL via `api-staging.totalreclaw.xyz` /
464
+ * `api.totalreclaw.xyz`). The runner is responsible for opening the
465
+ * WS session and polling the relay, mirroring `runPairCli`'s exit
466
+ * codes. If absent (very old plugin loader), the CLI silently falls
467
+ * back to local-mode and warns.
468
+ */
469
+ runRelayPairCli?: (mode: PairCliMode, opts: RelayPairCliOpts) => Promise<PairCliOutcome>;
447
470
  },
448
471
  ): void {
449
472
  // If the onboarding-cli already attached `totalreclaw`, reuse it.
@@ -459,15 +482,23 @@ export function registerPairCli(
459
482
 
460
483
  tr.command('pair [mode]')
461
484
  .description(
462
- 'Pair a remote browser device to this gateway (mode = generate | import; default generate)',
485
+ 'Pair a remote browser device to this gateway via the relay (default; ' +
486
+ 'works through NAT and inside Docker). Use --local to fall back to ' +
487
+ 'gateway-loopback URLs for air-gapped setups.',
463
488
  )
464
- .option('--json', 'Emit a single JSON payload (url/pin/sid/qr_ascii) instead of the human-readable banner. Enables agent-driven pairing.')
489
+ .option('--json', 'Emit a single JSON payload (url/pin/qr_ascii) instead of the human-readable banner. Enables agent-driven pairing.')
465
490
  .option('--url-pin-only', 'Emit ONLY {v,url,pin,expires_at_ms} — no QR ASCII, no SID, no mode echo. Headless fallback for container-based agents where the totalreclaw_pair tool is not injected (issue #87). Zero phrase exposure on stdout.')
491
+ .option('--local', '(3.3.4-rc.1) Use the loopback / LAN URL flow instead of the relay. URLs point at this gateway\'s bound interface (e.g. http://localhost:18789/…) and require the user\'s browser to be on a reachable network. Default since rc.6 was relay; this flag preserves the air-gapped path.')
466
492
  .option('--timeout <sec>', 'Session TTL in seconds (default: 900 = 15 min, matches pair-session-store default)')
467
493
  .action(async (...args: unknown[]) => {
468
494
  // commander passes: [modeArg, options, cmd]
469
495
  const modeRaw = typeof args[0] === 'string' ? args[0] : undefined;
470
- const opts = (args[1] ?? {}) as { json?: boolean; urlPinOnly?: boolean; timeout?: string | number };
496
+ const opts = (args[1] ?? {}) as {
497
+ json?: boolean;
498
+ urlPinOnly?: boolean;
499
+ local?: boolean;
500
+ timeout?: string | number;
501
+ };
471
502
  const mode: PairCliMode =
472
503
  modeRaw === 'import' || modeRaw === 'imp' ? 'import' : 'generate';
473
504
  // --url-pin-only wins over --json when both are passed, since it is
@@ -483,15 +514,57 @@ export function registerPairCli(
483
514
  if (Number.isFinite(parsed) && parsed > 0) ttlSeconds = parsed;
484
515
  }
485
516
  const io = buildDefaultPairCliIo();
517
+ // 3.3.4-rc.1 — flip the default to relay-mode. The agent tool
518
+ // `totalreclaw_pair` has used the relay since rc.11; the CLI was
519
+ // the last surface still defaulting to gateway-loopback URLs,
520
+ // which are unreachable from a remote browser when the gateway
521
+ // runs in Docker (the rc.6+ default deployment). `--local`
522
+ // restores the legacy flow for air-gapped operators.
523
+ const useRelay = shouldUseRelayMode({
524
+ local: opts.local,
525
+ hasRelayRunner: typeof deps.runRelayPairCli === 'function',
526
+ });
486
527
  try {
487
- const outcome = await runPairCli(mode, {
488
- sessionsPath: deps.sessionsPath,
489
- renderPairingUrl: deps.renderPairingUrl,
490
- renderQr: defaultRenderQr,
491
- io,
492
- outputMode,
493
- ttlSeconds,
494
- });
528
+ let outcome: PairCliOutcome;
529
+ if (useRelay) {
530
+ outcome = await deps.runRelayPairCli!(mode, {
531
+ renderQr: defaultRenderQr,
532
+ io,
533
+ outputMode,
534
+ ttlSeconds,
535
+ });
536
+ } else {
537
+ if (opts.local) {
538
+ // Tell the operator they explicitly opted in. Suppress in
539
+ // JSON modes — the JSON contract must stay stdout-clean.
540
+ if (outputMode === 'human') {
541
+ io.stderr.write(
542
+ '\n[--local] Using gateway-loopback URL flow. The user\'s browser ' +
543
+ 'must be reachable from this gateway\'s bound interface (LAN, Tailscale, ' +
544
+ 'or localhost on the same machine).\n',
545
+ );
546
+ }
547
+ } else if (!deps.runRelayPairCli) {
548
+ // No relay runner wired — older composition. Warn once on
549
+ // stderr in human mode so the operator knows why URLs may
550
+ // be unreachable from a remote browser.
551
+ if (outputMode === 'human') {
552
+ io.stderr.write(
553
+ '\n[pair-cli] relay-mode runner not available — falling back to local-mode. ' +
554
+ 'Pair URLs will use this gateway\'s bound interface. Upgrade the plugin ' +
555
+ 'or pass --local to silence this warning.\n',
556
+ );
557
+ }
558
+ }
559
+ outcome = await runPairCli(mode, {
560
+ sessionsPath: deps.sessionsPath,
561
+ renderPairingUrl: deps.renderPairingUrl,
562
+ renderQr: defaultRenderQr,
563
+ io,
564
+ outputMode,
565
+ ttlSeconds,
566
+ });
567
+ }
495
568
  if (outcome.status !== 'completed') {
496
569
  process.exit(outcome.status === 'canceled' ? 130 : 1);
497
570
  }
@@ -503,6 +576,33 @@ export function registerPairCli(
503
576
  });
504
577
  }
505
578
 
579
+ /**
580
+ * 3.3.4-rc.1 — options for the relay-mode CLI runner. Mirrors the human
581
+ * surface of `runPairCli` (output mode, QR renderer, IO, TTL) but does
582
+ * NOT take `sessionsPath` / `renderPairingUrl` because the relay flow
583
+ * mints its own URL via the relay's `opened` frame.
584
+ */
585
+ export interface RelayPairCliOpts {
586
+ renderQr: (payload: string, cb: (ascii: string) => void) => void;
587
+ io: PairCliIo;
588
+ outputMode?: PairCliOutputMode;
589
+ ttlSeconds?: number;
590
+ }
591
+
592
+ /**
593
+ * 3.3.4-rc.1 — pure decision function: given the parsed action flags
594
+ * and whether a relay runner is wired, return whether the relay path
595
+ * should be taken. Exported for unit-testing the default-mode flip
596
+ * without invoking either runner.
597
+ */
598
+ export function shouldUseRelayMode(opts: {
599
+ local?: boolean;
600
+ hasRelayRunner: boolean;
601
+ }): boolean {
602
+ if (opts.local) return false;
603
+ return opts.hasRelayRunner;
604
+ }
605
+
506
606
  // ---------------------------------------------------------------------------
507
607
  // Utils
508
608
  // ---------------------------------------------------------------------------
package/skill.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totalreclaw",
3
- "version": "3.3.2-rc.4",
3
+ "version": "3.3.4-rc.1",
4
4
  "description": "End-to-end encrypted memory for AI agents — portable, yours forever. XChaCha20-Poly1305 E2EE: server never sees plaintext.",
5
5
  "author": "TotalReclaw Team",
6
6
  "license": "MIT",
@@ -193,8 +193,8 @@
193
193
  "config": {
194
194
  "serverUrl": {
195
195
  "type": "string",
196
- "default": "https://api.totalreclaw.xyz",
197
- "description": "TotalReclaw server URL (only change for self-hosted mode)"
196
+ "default": "https://api-staging.totalreclaw.xyz",
197
+ "description": "TotalReclaw server URL (only change for self-hosted mode). Source default points at staging; stable releases swap to https://api.totalreclaw.xyz at publish time per PR #165."
198
198
  },
199
199
  "autoExtractEveryTurns": {
200
200
  "type": "number",
package/subgraph-store.ts CHANGED
@@ -805,7 +805,7 @@ export function isSubgraphMode(): boolean {
805
805
  *
806
806
  * After the v1 env var cleanup, clients only need:
807
807
  * - TOTALRECLAW_RECOVERY_PHRASE -- BIP-39 mnemonic
808
- * - TOTALRECLAW_SERVER_URL -- relay server URL (default: https://api.totalreclaw.xyz)
808
+ * - TOTALRECLAW_SERVER_URL -- relay server URL (source default: https://api-staging.totalreclaw.xyz; stable build: https://api.totalreclaw.xyz — swapped in at publish time per PR #165)
809
809
  * - TOTALRECLAW_SELF_HOSTED -- set "true" to use self-hosted server (default: managed service)
810
810
  *
811
811
  * Chain ID is no longer configurable via env — it is auto-detected from the
@@ -813,7 +813,8 @@ export function isSubgraphMode(): boolean {
813
813
  */
814
814
  export function getSubgraphConfig(): SubgraphStoreConfig {
815
815
  return {
816
- relayUrl: CONFIG.serverUrl || 'https://api.totalreclaw.xyz',
816
+ // 3.3.3-rc.1: staging by default in source; stable workflow seds.
817
+ relayUrl: CONFIG.serverUrl || 'https://api-staging.totalreclaw.xyz',
817
818
  mnemonic: CONFIG.recoveryPhrase,
818
819
  cachePath: CONFIG.cachePath,
819
820
  chainId: CONFIG.chainId,