rogerrat 1.4.0 → 1.18.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,30 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" role="img" aria-label="RogerRat">
2
+ <path d="M 60 22 Q 100 4 140 22" stroke="#d6541f" stroke-width="4" stroke-linecap="round"/>
3
+ <path d="M 44 36 Q 100 8 156 36" stroke="#d6541f" stroke-width="4" stroke-linecap="round" opacity="0.55"/>
4
+ <path d="M 28 50 Q 100 12 172 50" stroke="#d6541f" stroke-width="4" stroke-linecap="round" opacity="0.25"/>
5
+ <line x1="150" y1="74" x2="170" y2="34" stroke="#1a1a1a" stroke-width="4" stroke-linecap="round"/>
6
+ <circle cx="170" cy="34" r="5" fill="#d6541f" stroke="#1a1a1a" stroke-width="2"/>
7
+ <path d="M 36 96 Q 100 38 164 96" stroke="#1a1a1a" stroke-width="6" fill="none" stroke-linecap="round"/>
8
+ <rect x="22" y="92" width="28" height="36" rx="7" fill="#1a1a1a"/>
9
+ <rect x="28" y="98" width="16" height="24" rx="4" fill="#d6541f"/>
10
+ <circle cx="36" cy="110" r="3" fill="#1a1a1a"/>
11
+ <rect x="150" y="92" width="28" height="36" rx="7" fill="#1a1a1a"/>
12
+ <rect x="156" y="98" width="16" height="24" rx="4" fill="#d6541f"/>
13
+ <circle cx="164" cy="110" r="3" fill="#1a1a1a"/>
14
+ <ellipse cx="76" cy="64" rx="8" ry="12" fill="#fffaef" stroke="#1a1a1a" stroke-width="3" transform="rotate(-15 76 64)"/>
15
+ <ellipse cx="76" cy="66" rx="3" ry="6" fill="#d6541f" opacity="0.45" transform="rotate(-15 76 66)"/>
16
+ <ellipse cx="124" cy="64" rx="8" ry="12" fill="#fffaef" stroke="#1a1a1a" stroke-width="3" transform="rotate(15 124 64)"/>
17
+ <ellipse cx="124" cy="66" rx="3" ry="6" fill="#d6541f" opacity="0.45" transform="rotate(15 124 66)"/>
18
+ <ellipse cx="100" cy="120" rx="44" ry="38" fill="#fffaef" stroke="#1a1a1a" stroke-width="3.5"/>
19
+ <circle cx="84" cy="114" r="5" fill="#1a1a1a"/>
20
+ <circle cx="116" cy="114" r="5" fill="#1a1a1a"/>
21
+ <circle cx="86" cy="112" r="1.6" fill="#fffaef"/>
22
+ <circle cx="118" cy="112" r="1.6" fill="#fffaef"/>
23
+ <ellipse cx="100" cy="140" rx="10" ry="7" fill="#fffaef" stroke="#1a1a1a" stroke-width="2.5"/>
24
+ <ellipse cx="100" cy="138" rx="4" ry="3" fill="#d6541f"/>
25
+ <path d="M 92 146 Q 100 152 108 146" stroke="#1a1a1a" stroke-width="2" fill="none" stroke-linecap="round"/>
26
+ <path d="M 60 134 L 36 130" stroke="#1a1a1a" stroke-width="2" stroke-linecap="round"/>
27
+ <path d="M 60 140 L 36 142" stroke="#1a1a1a" stroke-width="2" stroke-linecap="round"/>
28
+ <path d="M 140 134 L 164 130" stroke="#1a1a1a" stroke-width="2" stroke-linecap="round"/>
29
+ <path d="M 140 140 L 164 142" stroke="#1a1a1a" stroke-width="2" stroke-linecap="round"/>
30
+ </svg>
Binary file
@@ -5,6 +5,9 @@ export function accountHtml() {
5
5
  <meta charset="utf-8" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1" />
7
7
  <title>rogerrat — account</title>
8
+ <!-- Loaded for the "Pair phone" QR modal. Stays out of every other page; failure
9
+ is recoverable — the URL itself is still shown and can be opened directly. -->
10
+ <script src="https://unpkg.com/qrcode@1.5.4/build/qrcode.min.js" defer></script>
8
11
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%231a1a1a'/><path d='M 9 7 Q 16 4 23 7' stroke='%23d6541f' stroke-width='1.5' fill='none' stroke-linecap='round'/><ellipse cx='11' cy='14' rx='2' ry='3' fill='%23f4ede0' transform='rotate(-15 11 14)'/><ellipse cx='21' cy='14' rx='2' ry='3' fill='%23f4ede0' transform='rotate(15 21 14)'/><ellipse cx='16' cy='22' rx='8' ry='6.5' fill='%23f4ede0'/><circle cx='13' cy='21' r='1.2' fill='%231a1a1a'/><circle cx='19' cy='21' r='1.2' fill='%231a1a1a'/><ellipse cx='16' cy='25' rx='1.5' ry='1' fill='%23d6541f'/></svg>" />
9
12
  <style>
10
13
  :root {
@@ -55,6 +58,25 @@ export function accountHtml() {
55
58
  .empty { text-align: center; padding: 28px; color: var(--dim); font-size: 13px; }
56
59
  footer { margin-top: 48px; padding-top: 20px; border-top: 1px solid var(--line); color: var(--dim); font-size: 12px; }
57
60
  footer a { color: var(--dim); }
61
+
62
+ /* Pair-phone modal — used to build a /remote/<id> URL with creds in the fragment */
63
+ .modal-backdrop {
64
+ position: fixed; inset: 0; background: rgba(26,26,26,0.55); z-index: 1000;
65
+ display: flex; align-items: center; justify-content: center; padding: 16px;
66
+ }
67
+ .modal-panel {
68
+ background: var(--paper); border: 2px solid var(--ink); padding: 20px 22px;
69
+ max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto;
70
+ }
71
+ .modal-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 4px; gap: 12px; }
72
+ .modal-head h2 { margin: 0; font-size: 17px; }
73
+ .modal-head .x { background: transparent; color: var(--dim); border: none; padding: 4px 8px; font-size: 18px; cursor: pointer; }
74
+ #pair-url { background: var(--bg); border: 1px dashed var(--warn); padding: 10px 12px;
75
+ font-size: 11px; margin: 0; overflow-wrap: anywhere; white-space: pre-wrap;
76
+ user-select: all; max-height: 90px; overflow-y: auto; }
77
+ #pair-qr { background: white; padding: 14px; text-align: center; min-height: 240px;
78
+ border: 1px solid var(--line); }
79
+ #pair-qr svg { max-width: 100%; height: auto; display: block; margin: 0 auto; }
58
80
  </style>
59
81
  </head>
60
82
  <body>
@@ -194,6 +216,16 @@ export function accountHtml() {
194
216
  <tbody id="ident-rows"><tr><td colspan="3" class="empty">No identities yet.</td></tr></tbody>
195
217
  </table>
196
218
  </div>
219
+
220
+ <div id="reveal" class="card reveal" hidden>
221
+ <strong>Save these now.</strong> They are shown ONLY on this screen. You will not see them again after navigation.
222
+ <div id="reveal-body"></div>
223
+ <div class="reveal-actions">
224
+ <button data-action="download">⬇ Download .txt</button>
225
+ <button data-action="copy">⎘ Copy</button>
226
+ <button id="reveal-pair-btn" hidden>📱 Pair phone now</button>
227
+ </div>
228
+ </div>
197
229
  </div>
198
230
 
199
231
  <footer>
@@ -201,6 +233,55 @@ export function accountHtml() {
201
233
  </footer>
202
234
  </div>
203
235
 
236
+ <!-- Pair-phone modal: builds a /remote/<id> URL with the channel token + identity
237
+ in the URL fragment, renders the QR client-side. Token & key are never sent
238
+ to a third-party renderer. -->
239
+ <div id="pair-modal" class="modal-backdrop" hidden>
240
+ <div class="modal-panel">
241
+ <div class="modal-head">
242
+ <h2>Pair phone with a channel</h2>
243
+ <button id="pair-close" class="x" aria-label="close">✕</button>
244
+ </div>
245
+ <p class="sub">Builds a URL your phone can open. The token + identity_key live in the URL fragment (after <code>#</code>) — so they never reach the server logs or referrers. Treat the URL like a password.</p>
246
+
247
+ <label>Channel</label>
248
+ <input id="pair-channel" type="text" readonly style="background:var(--bg);color:var(--dim)" />
249
+
250
+ <label style="margin-top:12px">Channel token <span style="color:var(--dim);text-transform:none;letter-spacing:0">(shown once when you created the channel)</span></label>
251
+ <input id="pair-token" type="password" autocomplete="off" placeholder="paste the join_token" />
252
+
253
+ <p style="font-size:11px;color:var(--dim);margin:14px 0 4px;text-transform:uppercase;letter-spacing:0.06em">Authentication — one of:</p>
254
+
255
+ <label>identity_key <span style="color:var(--dim);text-transform:none;letter-spacing:0">(required if channel has require_identity=true)</span></label>
256
+ <input id="pair-key" type="password" autocomplete="off" placeholder="(optional)" />
257
+
258
+ <label style="margin-top:8px">or callsign</label>
259
+ <input id="pair-cs" type="text" autocomplete="off" placeholder="phone" />
260
+
261
+ <label style="margin-top:12px">Owner password <span style="color:var(--dim);text-transform:none;letter-spacing:0">(optional — for trusted channels, flips the phone's session to "human-authorized")</span></label>
262
+ <input id="pair-pw" type="password" autocomplete="off" placeholder="(optional)" />
263
+
264
+ <p id="pair-err" class="err" hidden></p>
265
+
266
+ <div class="row" style="margin-top:16px">
267
+ <button id="pair-gen">Generate pair link</button>
268
+ <span id="pair-status" style="font-size:12px;color:var(--dim)"></span>
269
+ </div>
270
+
271
+ <div id="pair-output" hidden style="margin-top:18px">
272
+ <label>Pair URL</label>
273
+ <pre id="pair-url"></pre>
274
+ <div class="row" style="margin:8px 0 16px">
275
+ <button id="pair-copy" class="ghost">⎘ Copy</button>
276
+ <a id="pair-open" target="_blank" rel="noopener" class="ghost" style="padding:10px 18px;background:transparent;color:var(--dim);border:1px solid var(--line);font-family:inherit;font-size:14px;text-decoration:none;cursor:pointer">↗ Open here</a>
277
+ </div>
278
+ <label>QR (scan with phone camera)</label>
279
+ <div id="pair-qr">QR will appear here…</div>
280
+ <p style="font-size:11px;color:var(--dim);margin-top:8px"><strong>⚠</strong> Anyone with this URL can drive the agent on this channel. Don't paste it into anything other than your phone.</p>
281
+ </div>
282
+ </div>
283
+ </div>
284
+
204
285
  <script>
205
286
  const KEY = 'rogerrat_account_session';
206
287
  let session = sessionStorage.getItem(KEY) || '';
@@ -226,6 +307,10 @@ export function accountHtml() {
226
307
  function showReveal(filename, text) {
227
308
  revealPayload = { filename, text };
228
309
  $('reveal-body').innerHTML = '<pre>' + esc(text) + '</pre>';
310
+ // Default: hide the channel-only "Pair phone" action. Channel-create
311
+ // unhides + wires it after this call.
312
+ const pb = $('reveal-pair-btn');
313
+ if (pb) { pb.hidden = true; pb.onclick = null; }
229
314
  $('reveal').hidden = false;
230
315
  }
231
316
 
@@ -355,12 +440,18 @@ export function accountHtml() {
355
440
  '<td><span style="color:' + trustColor + '">' + trustLabel + '</span></td>' +
356
441
  '<td>' + c.agent_count + '</td>' +
357
442
  '<td>' + ago + '</td>' +
358
- '<td style="text-align:right"><button class="danger" data-ch="' + esc(c.id) + '">Delete</button></td>' +
443
+ '<td style="text-align:right;white-space:nowrap">' +
444
+ '<button class="ghost" data-pair="' + esc(c.id) + '" style="font-size:12px;padding:4px 10px;margin-right:6px" title="Build a phone pair URL + QR for this channel">📱 Pair</button>' +
445
+ '<button class="danger" data-ch="' + esc(c.id) + '">Delete</button>' +
446
+ '</td>' +
359
447
  '</tr>';
360
448
  }).join('');
361
- tbody.querySelectorAll('button.danger').forEach(btn => {
449
+ tbody.querySelectorAll('button[data-ch]').forEach(btn => {
362
450
  btn.addEventListener('click', () => deleteChannel(btn.dataset.ch));
363
451
  });
452
+ tbody.querySelectorAll('button[data-pair]').forEach(btn => {
453
+ btn.addEventListener('click', () => openPairModal(btn.dataset.pair));
454
+ });
364
455
  }
365
456
 
366
457
  function fmtAgo(ts) {
@@ -605,8 +696,19 @@ export function accountHtml() {
605
696
  lines.push('⚠ The owner_password lets joining peers prove human authorization (unlocks trusted-mode trust). Share out-of-band only with peers you actually invited.');
606
697
  }
607
698
  $('new-owner-password').value = '';
699
+ // Cache the join_token in this browser session so the "Pair phone" modal
700
+ // on this channel's row auto-fills the token field. sessionStorage clears
701
+ // on tab close, so we don't persist the secret beyond the current visit.
702
+ try { sessionStorage.setItem('rogerrat_chtok_' + data.channel_id, data.join_token); } catch {}
608
703
  showReveal('rogerrat-channel-' + data.channel_id + '.txt', lines.join('\\n'));
609
704
  loadChannels();
705
+ // Surface the pair-phone action right after creation — the token is in
706
+ // memory, so the modal can pre-fill it without the user re-pasting.
707
+ const pairBtn = $('reveal-pair-btn');
708
+ if (pairBtn) {
709
+ pairBtn.hidden = false;
710
+ pairBtn.onclick = () => openPairModal(data.channel_id, { token: data.join_token });
711
+ }
610
712
  } catch (e) {
611
713
  $('channel-err').textContent = 'Error: ' + e.message;
612
714
  }
@@ -699,6 +801,93 @@ export function accountHtml() {
699
801
  }
700
802
  }
701
803
 
804
+ // ─── Pair-phone modal ──────────────────────────────────────────────────
805
+ function openPairModal(channelId, prefill) {
806
+ $('pair-channel').value = channelId;
807
+ // Auto-fill token from this-session cache (set on channel creation), or
808
+ // accept an explicit prefill that wins over the cache.
809
+ let tok = '';
810
+ try { tok = sessionStorage.getItem('rogerrat_chtok_' + channelId) || ''; } catch {}
811
+ if (prefill && prefill.token) tok = prefill.token;
812
+ $('pair-token').value = tok;
813
+ $('pair-key').value = (prefill && prefill.key) || '';
814
+ $('pair-cs').value = (prefill && prefill.cs) || '';
815
+ $('pair-pw').value = (prefill && prefill.pw) || '';
816
+ $('pair-output').hidden = true;
817
+ $('pair-err').hidden = true;
818
+ $('pair-status').textContent = '';
819
+ $('pair-modal').hidden = false;
820
+ // Focus the first empty field so the user can paste immediately
821
+ setTimeout(() => {
822
+ const first = !$('pair-token').value
823
+ ? $('pair-token')
824
+ : (!$('pair-key').value && !$('pair-cs').value ? $('pair-key') : null);
825
+ if (first) first.focus();
826
+ }, 30);
827
+ }
828
+
829
+ function closePairModal() { $('pair-modal').hidden = true; }
830
+
831
+ $('pair-close').addEventListener('click', closePairModal);
832
+ $('pair-modal').addEventListener('click', (e) => {
833
+ // Click outside the panel (on the backdrop) closes the modal.
834
+ if (e.target === $('pair-modal')) closePairModal();
835
+ });
836
+ document.addEventListener('keydown', (e) => {
837
+ if (e.key === 'Escape' && !$('pair-modal').hidden) closePairModal();
838
+ });
839
+
840
+ $('pair-gen').addEventListener('click', () => {
841
+ const channelId = $('pair-channel').value;
842
+ const t = $('pair-token').value.trim();
843
+ const k = $('pair-key').value.trim();
844
+ const cs = $('pair-cs').value.trim();
845
+ const pw = $('pair-pw').value;
846
+ const err = $('pair-err');
847
+ err.hidden = true; err.textContent = '';
848
+ if (!t) { err.hidden = false; err.textContent = 'Channel token is required.'; return; }
849
+ if (!k && !cs) { err.hidden = false; err.textContent = 'Provide either an identity_key or a callsign.'; return; }
850
+ const params = new URLSearchParams();
851
+ params.set('t', t);
852
+ if (k) params.set('k', k);
853
+ if (cs) params.set('cs', cs);
854
+ if (pw) params.set('p', pw);
855
+ const url = location.origin + '/remote/' + encodeURIComponent(channelId) + '#' + params.toString();
856
+ $('pair-url').textContent = url;
857
+ $('pair-open').setAttribute('href', url);
858
+ $('pair-output').hidden = false;
859
+ // Render QR client-side. If the qrcode lib failed to load (offline / CDN
860
+ // blocked), fall back to showing just the URL — the page is still useful.
861
+ const qr = $('pair-qr');
862
+ qr.innerHTML = 'rendering…';
863
+ if (typeof window.QRCode !== 'undefined' && typeof window.QRCode.toString === 'function') {
864
+ window.QRCode.toString(url, { type: 'svg', errorCorrectionLevel: 'M', margin: 1, width: 256 }, (e, svg) => {
865
+ if (e) { qr.textContent = 'QR render failed: ' + (e.message || e); return; }
866
+ qr.innerHTML = svg;
867
+ });
868
+ } else {
869
+ qr.textContent = 'QR library failed to load. The URL above carries the same data — copy it to your phone manually.';
870
+ }
871
+ });
872
+
873
+ $('pair-copy').addEventListener('click', () => {
874
+ const url = $('pair-url').textContent;
875
+ if (!url) return;
876
+ const done = () => {
877
+ const b = $('pair-copy');
878
+ const prev = b.textContent;
879
+ b.textContent = '✓ Copied';
880
+ setTimeout(() => { b.textContent = prev; }, 1500);
881
+ };
882
+ if (navigator.clipboard && navigator.clipboard.writeText) {
883
+ navigator.clipboard.writeText(url).then(done).catch(() => {
884
+ $('pair-status').textContent = 'copy failed — select the URL and copy manually';
885
+ });
886
+ } else {
887
+ $('pair-status').textContent = 'clipboard API unavailable — select the URL and copy manually';
888
+ }
889
+ });
890
+
702
891
  loadAccount();
703
892
  </script>
704
893
  </body>
package/dist/app.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join as joinPath } from "node:path";
4
+ import { fileURLToPath } from "node:url";
2
5
  import { Hono } from "hono";
6
+ import { streamSSE } from "hono/streaming";
3
7
  import { ChannelError, setSessionTtlLookup, startPeriodicGc } from "./channel.js";
4
8
  import { attachEmail, confirmEmailRecovery, createAccount, createIdentity, deleteIdentity, getAccount, getAccountIdsByIdentityCallsign, listIdentities, recoverAccount, removeEmail, requestEmailRecovery, verifyEmailCode, verifyIdentity, verifySession, } from "./accounts.js";
5
9
  import { createChannelWebhook, createWebhook, deleteChannelWebhook, deleteWebhook, deliver, getActiveWebhooksForAccount, getActiveWebhooksForChannel, listChannelWebhooks, listWebhooks, } from "./webhooks.js";
@@ -13,7 +17,10 @@ import { agentCard } from "./agentcard.js";
13
17
  import { llmsText, mcpDescriptor, serviceInfo } from "./discovery.js";
14
18
  import { landingHtml } from "./landing.js";
15
19
  import { handleMcpRequest } from "./mcp.js";
20
+ import { remoteHtml } from "./remote-ui.js";
21
+ import { createRemoteControl } from "./remote-control.js";
16
22
  import { policyHtml, policyText } from "./policy.js";
23
+ import { applyPresetDefaults, getPreset, resolveMode, } from "./presets.js";
17
24
  import { recordJoin as statsRecordJoin, recordMessage as statsRecordMessage, getStats, } from "./stats.js";
18
25
  import { channelExists, createChannel, deleteChannelByCreator, ensureBands, getChannelIsBand, getChannelRequireIdentity, getChannelRetention, getChannelSessionTtlMs, getChannelTrustMode, hasOwnerPassword, listBands, listChannelsByCreator, verifyChannel, verifyOwnerPassword, } from "./store.js";
19
26
  import { isRetention, readTranscript, recordJoin as transcriptRecordJoin, recordLeave as transcriptRecordLeave, recordMessage as transcriptRecordMessage, } from "./transcripts.js";
@@ -22,6 +29,16 @@ export function createApp(opts) {
22
29
  setSessionTtlLookup(getChannelSessionTtlMs);
23
30
  startPeriodicGc();
24
31
  const app = new Hono();
32
+ // Mode resolution from the Host header. Subdomains like `team.rogerrat.chat`
33
+ // map to preset modes (team/park/live/go); anything else is "default" (the
34
+ // canonical rogerrat.chat, full unfiltered context). Stamped on the context
35
+ // so downstream handlers (channel creation, /llms.txt, MCP tool descriptions,
36
+ // agent_prompt) can adapt.
37
+ app.use("*", async (c, next) => {
38
+ const mode = resolveMode(c.req.header("host"));
39
+ c.set("mode", mode);
40
+ await next();
41
+ });
25
42
  app.use("*", async (c, next) => {
26
43
  await next();
27
44
  c.header("X-Content-Type-Options", "nosniff");
@@ -52,10 +69,38 @@ export function createApp(opts) {
52
69
  return c.html(landingHtml());
53
70
  });
54
71
  app.get("/healthz", (c) => c.text("ok"));
72
+ const __appDir = dirname(fileURLToPath(import.meta.url));
73
+ const assetsDir = joinPath(__appDir, "..", "assets");
74
+ const assetCache = new Map();
75
+ function serveAsset(c, name, type) {
76
+ let entry = assetCache.get(name);
77
+ if (!entry) {
78
+ try {
79
+ const buf = readFileSync(joinPath(assetsDir, name));
80
+ const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
81
+ entry = { body: ab, type };
82
+ assetCache.set(name, entry);
83
+ }
84
+ catch {
85
+ return c.text("not found", 404);
86
+ }
87
+ }
88
+ return new Response(entry.body, {
89
+ headers: {
90
+ "Content-Type": entry.type,
91
+ "Cache-Control": "public, max-age=86400, immutable",
92
+ },
93
+ });
94
+ }
95
+ app.get("/logo.svg", (c) => serveAsset(c, "logo.svg", "image/svg+xml"));
96
+ app.get("/og-image.png", (c) => serveAsset(c, "og-image.png", "image/png"));
55
97
  app.get("/robots.txt", (c) => c.text(`User-agent: *\nDisallow: /admin\nDisallow: /api/\nAllow: /\n\nSitemap: ${opts.publicOrigin}/llms.txt\n`));
56
98
  app.get("/api/stats", (c) => c.json(getStats()));
57
99
  app.get("/api/v1/info", (c) => c.json(serviceInfo(opts.publicOrigin)));
58
- app.get("/llms.txt", (c) => c.text(llmsText(opts.publicOrigin)));
100
+ app.get("/llms.txt", (c) => {
101
+ const mode = c.get("mode") ?? "default";
102
+ return c.text(llmsText(opts.publicOrigin, mode));
103
+ });
59
104
  app.get("/.well-known/mcp.json", (c) => c.json(mcpDescriptor(opts.publicOrigin)));
60
105
  app.get("/.well-known/agent.json", (c) => c.json(agentCard(opts.publicOrigin, "1.1.0")));
61
106
  // Fix the broken /docs/* links from the nav — redirect to llms.txt (the canonical agent doc).
@@ -63,6 +108,21 @@ export function createApp(opts) {
63
108
  app.get("/docs/*", (c) => c.redirect("/llms.txt", 302));
64
109
  app.get("/account", (c) => c.html(accountHtml()));
65
110
  app.get("/policy", (c) => c.html(policyHtml(opts.publicOrigin)));
111
+ // Mobile-first remote-control chat. Drives an agent that's already joined
112
+ // the same channel and looping on `wait`. Credentials are passed via the
113
+ // URL fragment (#t=…&k=…&cs=…) — fragment never reaches the server, so the
114
+ // bearer/identity_key don't end up in nginx logs or referrers.
115
+ app.get("/remote/:channelId", (c) => {
116
+ const id = c.req.param("channelId");
117
+ if (!channelExists(id)) {
118
+ return c.html(`<!doctype html><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>` +
119
+ `<title>not found</title><body style="font-family:ui-monospace,Menlo,monospace;background:#f4ede0;padding:24px;color:#1a1a1a">` +
120
+ `<h1 style="font-size:18px">Channel not found</h1>` +
121
+ `<p style="color:#7a6f5f;font-size:14px">No channel <code>${id.replace(/[<>&]/g, "")}</code> on this server. The pair link may be stale or wrong.</p>` +
122
+ `<p><a href="/account" style="color:#d6541f">→ go to /account</a></p></body>`, 404);
123
+ }
124
+ return c.html(remoteHtml(id));
125
+ });
66
126
  app.get("/policy.txt", (c) => c.text(policyText(opts.publicOrigin)));
67
127
  // ─── Accounts (passwordless, recovery-token based) ───
68
128
  function requireSession(c) {
@@ -267,12 +327,11 @@ export function createApp(opts) {
267
327
  if (retentionInput !== undefined && !isRetention(retentionInput)) {
268
328
  return c.json({ error: "invalid retention; must be one of none|metadata|prompts|full" }, 400);
269
329
  }
270
- const requireIdentity = body.require_identity === true;
330
+ const requireIdentityInput = body.require_identity;
271
331
  const trustModeInput = body.trust_mode;
272
332
  if (trustModeInput !== undefined && trustModeInput !== "untrusted" && trustModeInput !== "trusted") {
273
333
  return c.json({ error: "invalid trust_mode; must be 'untrusted' or 'trusted'" }, 400);
274
334
  }
275
- const trustMode = trustModeInput === "trusted" ? "trusted" : "untrusted";
276
335
  const ownerPasswordInput = body.owner_password;
277
336
  let ownerPassword;
278
337
  if (ownerPasswordInput !== undefined) {
@@ -284,12 +343,30 @@ export function createApp(opts) {
284
343
  ownerPassword = trimmed;
285
344
  }
286
345
  const sessionTtlSecondsInput = body.session_ttl_seconds;
287
- let sessionTtlSeconds;
288
346
  if (sessionTtlSecondsInput !== undefined) {
289
347
  if (typeof sessionTtlSecondsInput !== "number" || !Number.isFinite(sessionTtlSecondsInput)) {
290
348
  return c.json({ error: "session_ttl_seconds must be a positive number ≤ 86400 (24h)" }, 400);
291
349
  }
292
- sessionTtlSeconds = sessionTtlSecondsInput;
350
+ }
351
+ // Apply preset defaults from the subdomain (mode resolved by the host
352
+ // middleware). Body fields always win — operators with `?preset=` flags
353
+ // disabled or curl users passing explicit values aren't surprised.
354
+ const mode = c.get("mode") ?? "default";
355
+ const presetMerged = applyPresetDefaults(mode, {
356
+ retention: retentionInput,
357
+ require_identity: requireIdentityInput === true ? true : requireIdentityInput === false ? false : undefined,
358
+ trust_mode: trustModeInput,
359
+ session_ttl_seconds: sessionTtlSecondsInput,
360
+ });
361
+ const retention = presetMerged.retention;
362
+ const requireIdentity = presetMerged.require_identity;
363
+ const trustMode = presetMerged.trust_mode;
364
+ const sessionTtlSeconds = presetMerged.session_ttl_seconds;
365
+ // Auto-mint owner_password for presets that opt in (e.g. `go.`): gives
366
+ // "trusted-authorized" trust posture without an identity dance.
367
+ const preset = getPreset(mode);
368
+ if (!ownerPassword && preset?.autoMintOwnerPassword) {
369
+ ownerPassword = randomUUID().replace(/-/g, "").slice(0, 16);
293
370
  }
294
371
  let creatorAccountId;
295
372
  const auth = c.req.header("authorization") ?? c.req.header("Authorization") ?? "";
@@ -303,7 +380,7 @@ export function createApp(opts) {
303
380
  }
304
381
  }
305
382
  const result = createChannel({
306
- retention: retentionInput,
383
+ retention,
307
384
  require_identity: requireIdentity,
308
385
  trust_mode: trustMode,
309
386
  session_ttl_seconds: sessionTtlSeconds,
@@ -312,13 +389,13 @@ export function createApp(opts) {
312
389
  });
313
390
  if ("error" in result)
314
391
  return c.json(result, 400);
315
- const { id, token, retention, require_identity, trust_mode, session_ttl_seconds, creator_account_id, has_owner_password } = result;
392
+ const { id, token, retention: createdRetention, require_identity: createdRequireIdentity, trust_mode: createdTrustMode, session_ttl_seconds: createdTtl, creator_account_id, has_owner_password, } = result;
316
393
  return c.json({
317
- ...buildConnectInfo(id, token, opts.publicOrigin, { ownerPassword, trustMode }),
318
- retention,
319
- require_identity,
320
- trust_mode,
321
- session_ttl_seconds,
394
+ ...buildConnectInfo(id, token, opts.publicOrigin, { ownerPassword, trustMode, mode }),
395
+ retention: createdRetention,
396
+ require_identity: createdRequireIdentity,
397
+ trust_mode: createdTrustMode,
398
+ session_ttl_seconds: createdTtl,
322
399
  creator_account_id,
323
400
  has_owner_password,
324
401
  owner_password: ownerPassword ?? null,
@@ -334,6 +411,33 @@ export function createApp(opts) {
334
411
  }));
335
412
  return c.json({ channels: channelList });
336
413
  });
414
+ // One-shot bootstrap for the "drive the agent from my phone" flow. Creates
415
+ // a private trusted channel + two identities (one for the agent on this
416
+ // machine, one for the phone) and returns a mobile_url with creds in the
417
+ // URL fragment. If the caller passes a session_token, the channel is bound
418
+ // to that account; otherwise a fresh anonymous account is minted (the
419
+ // recovery_token comes back so the caller can keep it if they want).
420
+ app.post("/api/remote-control", async (c) => {
421
+ let body = {};
422
+ try {
423
+ const raw = c.req.header("content-type")?.startsWith("application/json") ? await c.req.json() : {};
424
+ if (raw && typeof raw === "object")
425
+ body = raw;
426
+ }
427
+ catch {
428
+ /* body optional */
429
+ }
430
+ const sessionToken = typeof body.session_token === "string" ? body.session_token : undefined;
431
+ const result = await createRemoteControl({ publicOrigin: opts.publicOrigin, sessionToken });
432
+ if ("error" in result) {
433
+ const status = result.code === "unauthorized" ? 401 : 500;
434
+ return c.json({ error: result.error }, status);
435
+ }
436
+ return c.json({
437
+ ...result,
438
+ notice: "Two-step phone flow: (1) open mobile_url on the phone; (2) type owner_password on the /remote setup screen to mark the phone session as human-authorized. The password is NOT embedded in mobile_url on purpose — relay it through a separate channel so a leaked URL alone can't impersonate the human. On the agent side (this machine), join with agent.identity_key + owner_password and then loop on `wait`.",
439
+ });
440
+ });
337
441
  app.delete("/api/account/channels/:id", (c) => {
338
442
  const r = requireSession(c);
339
443
  if (r instanceof Response)
@@ -685,6 +789,138 @@ export function createApp(opts) {
685
789
  return handleChannelError(c, e);
686
790
  }
687
791
  });
792
+ // Wait: the canonical idle action. Same semantics as listen but with a longer
793
+ // ceiling (up to 5 min) and an explicit meta_hint nudge on empty returns —
794
+ // designed for agents that should sit waiting for a peer.
795
+ app.get("/api/channels/:id/wait", async (c) => {
796
+ const channelId = c.req.param("id");
797
+ const denied = requireChannelBearer(c, channelId);
798
+ if (denied)
799
+ return denied;
800
+ const sessionId = getSessionId(c);
801
+ if (!sessionId)
802
+ return c.json({ error: "X-Session-Id header required (returned by /join)", code: "invalid" }, 400);
803
+ const timeoutSec = Math.max(1, Math.min(300, Number(c.req.query("timeout") ?? 120)));
804
+ const sinceRaw = c.req.query("since");
805
+ const since = sinceRaw !== undefined ? Number(sinceRaw) : undefined;
806
+ if (sinceRaw !== undefined && !Number.isFinite(since)) {
807
+ return c.json({ error: "since must be a numeric message id", code: "invalid" }, 400);
808
+ }
809
+ const channel = getOrCreateChannel(channelId);
810
+ try {
811
+ const msgs = await channel.listen(sessionId, timeoutSec * 1000, since);
812
+ const empty = msgs.length === 0;
813
+ return c.json({
814
+ messages: msgs,
815
+ timed_out: empty,
816
+ roster: channel.roster(),
817
+ meta_hint: empty
818
+ ? "No new messages this round. Call /wait again — keep looping while you're waiting for a peer. If they seem fully offline, send anyway: messages queue per-callsign and deliver on their next listen/wait."
819
+ : `${msgs.length} new message${msgs.length === 1 ? "" : "s"}. After acting, call /wait again to hear replies.`,
820
+ });
821
+ }
822
+ catch (e) {
823
+ return handleChannelError(c, e);
824
+ }
825
+ });
826
+ // Stream: SSE push of incoming messages. Stays open until the client disconnects.
827
+ // Unlike /listen and /wait this is NOT turn-based — designed for `npx rogerrat
828
+ // listen-here` and any always-on consumer that wants zero polling cost. The session
829
+ // stays alive for as long as the connection is held (streamer counts as activity
830
+ // for the GC, so a parked agent with an open stream is never reaped).
831
+ //
832
+ // Query params:
833
+ // - since=<msg_id> resume from a known id (skips per-session cursor)
834
+ //
835
+ // Events emitted:
836
+ // - event: hello once, on connect, with channel metadata
837
+ // - event: message each delivered message (id, from, to, text, at)
838
+ // - event: error typed channel error before close (rare; pre-validated)
839
+ // - :ping comment line every 25s to defeat idle-proxy disconnects
840
+ app.get("/api/channels/:id/stream", (c) => {
841
+ const channelId = c.req.param("id");
842
+ const denied = requireChannelBearer(c, channelId);
843
+ if (denied)
844
+ return denied;
845
+ const sessionId = getSessionId(c);
846
+ if (!sessionId) {
847
+ return c.json({ error: "X-Session-Id header required (returned by /join)", code: "invalid" }, 400);
848
+ }
849
+ const sinceRaw = c.req.query("since");
850
+ const since = sinceRaw !== undefined ? Number(sinceRaw) : undefined;
851
+ if (sinceRaw !== undefined && !Number.isFinite(since)) {
852
+ return c.json({ error: "since must be a numeric message id", code: "invalid" }, 400);
853
+ }
854
+ const channel = getOrCreateChannel(channelId);
855
+ // Pre-validate session so we can return a real 4xx instead of streaming an error.
856
+ try {
857
+ channel.keepalive(sessionId);
858
+ }
859
+ catch (e) {
860
+ return handleChannelError(c, e);
861
+ }
862
+ const callsign = channel.callsignOf(sessionId);
863
+ return streamSSE(c, async (stream) => {
864
+ const queue = [];
865
+ let waker = null;
866
+ const wake = () => {
867
+ const w = waker;
868
+ waker = null;
869
+ if (w)
870
+ w();
871
+ };
872
+ const detach = channel.addStreamListener(sessionId, (msg) => {
873
+ queue.push(msg);
874
+ wake();
875
+ });
876
+ // Drain backlog AFTER subscribing — both ops are sync so no race window.
877
+ const backlog = channel.drainSince(sessionId, since);
878
+ queue.unshift(...backlog);
879
+ const pingTimer = setInterval(() => {
880
+ stream.write(": ping\n\n").catch(() => { });
881
+ }, 25_000);
882
+ pingTimer.unref?.();
883
+ const abortSignal = c.req.raw.signal;
884
+ const onAbort = () => wake();
885
+ abortSignal.addEventListener("abort", onAbort);
886
+ try {
887
+ await stream.writeSSE({
888
+ event: "hello",
889
+ data: JSON.stringify({
890
+ channel_id: channelId,
891
+ callsign,
892
+ roster: channel.roster(),
893
+ backlog_count: backlog.length,
894
+ }),
895
+ });
896
+ while (!abortSignal.aborted) {
897
+ while (queue.length > 0) {
898
+ const msg = queue.shift();
899
+ await stream.writeSSE({
900
+ event: "message",
901
+ data: JSON.stringify(msg),
902
+ id: String(msg.id),
903
+ });
904
+ }
905
+ if (abortSignal.aborted)
906
+ break;
907
+ await new Promise((resolve) => {
908
+ waker = resolve;
909
+ });
910
+ }
911
+ }
912
+ catch (err) {
913
+ // Client disconnect surfaces as a write error — silent. Anything else, log.
914
+ if (!abortSignal.aborted)
915
+ console.error(`[stream ${channelId}/${callsign}]`, err);
916
+ }
917
+ finally {
918
+ abortSignal.removeEventListener("abort", onAbort);
919
+ clearInterval(pingTimer);
920
+ detach();
921
+ }
922
+ });
923
+ });
688
924
  app.get("/api/channels/:id/stats", (c) => {
689
925
  const channelId = c.req.param("id");
690
926
  const denied = requireChannelBearer(c, channelId);
@@ -835,7 +1071,8 @@ export function createApp(opts) {
835
1071
  return c.json({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "invalid request" } }, 400);
836
1072
  }
837
1073
  const sessionId = c.req.header("mcp-session-id") ?? c.req.header("Mcp-Session-Id");
838
- const result = await handleMcpRequest(channelId, body, sessionId, opts.publicOrigin);
1074
+ const mcpMode = c.get("mode") ?? "default";
1075
+ const result = await handleMcpRequest(channelId, body, sessionId, opts.publicOrigin, mcpMode);
839
1076
  if (result.sessionId)
840
1077
  c.header("Mcp-Session-Id", result.sessionId);
841
1078
  if (result.body === null)