rogerrat 1.4.1 → 1.19.0

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.
@@ -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
@@ -3,7 +3,8 @@ import { readFileSync } from "node:fs";
3
3
  import { dirname, join as joinPath } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Hono } from "hono";
6
- import { ChannelError, setSessionTtlLookup, startPeriodicGc } from "./channel.js";
6
+ import { streamSSE } from "hono/streaming";
7
+ import { ChannelError, isPriority, setSessionTtlLookup, startPeriodicGc, } from "./channel.js";
7
8
  import { attachEmail, confirmEmailRecovery, createAccount, createIdentity, deleteIdentity, getAccount, getAccountIdsByIdentityCallsign, listIdentities, recoverAccount, removeEmail, requestEmailRecovery, verifyEmailCode, verifyIdentity, verifySession, } from "./accounts.js";
8
9
  import { createChannelWebhook, createWebhook, deleteChannelWebhook, deleteWebhook, deliver, getActiveWebhooksForAccount, getActiveWebhooksForChannel, listChannelWebhooks, listWebhooks, } from "./webhooks.js";
9
10
  import { buildRecoveryEmail, buildVerifyEmail, emailEnabled, sendEmail } from "./email.js";
@@ -16,7 +17,10 @@ import { agentCard } from "./agentcard.js";
16
17
  import { llmsText, mcpDescriptor, serviceInfo } from "./discovery.js";
17
18
  import { landingHtml } from "./landing.js";
18
19
  import { handleMcpRequest } from "./mcp.js";
20
+ import { remoteHtml } from "./remote-ui.js";
21
+ import { createRemoteControl } from "./remote-control.js";
19
22
  import { policyHtml, policyText } from "./policy.js";
23
+ import { applyPresetDefaults, getPreset, resolveMode, } from "./presets.js";
20
24
  import { recordJoin as statsRecordJoin, recordMessage as statsRecordMessage, getStats, } from "./stats.js";
21
25
  import { channelExists, createChannel, deleteChannelByCreator, ensureBands, getChannelIsBand, getChannelRequireIdentity, getChannelRetention, getChannelSessionTtlMs, getChannelTrustMode, hasOwnerPassword, listBands, listChannelsByCreator, verifyChannel, verifyOwnerPassword, } from "./store.js";
22
26
  import { isRetention, readTranscript, recordJoin as transcriptRecordJoin, recordLeave as transcriptRecordLeave, recordMessage as transcriptRecordMessage, } from "./transcripts.js";
@@ -25,6 +29,16 @@ export function createApp(opts) {
25
29
  setSessionTtlLookup(getChannelSessionTtlMs);
26
30
  startPeriodicGc();
27
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
+ });
28
42
  app.use("*", async (c, next) => {
29
43
  await next();
30
44
  c.header("X-Content-Type-Options", "nosniff");
@@ -83,7 +97,10 @@ export function createApp(opts) {
83
97
  app.get("/robots.txt", (c) => c.text(`User-agent: *\nDisallow: /admin\nDisallow: /api/\nAllow: /\n\nSitemap: ${opts.publicOrigin}/llms.txt\n`));
84
98
  app.get("/api/stats", (c) => c.json(getStats()));
85
99
  app.get("/api/v1/info", (c) => c.json(serviceInfo(opts.publicOrigin)));
86
- 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
+ });
87
104
  app.get("/.well-known/mcp.json", (c) => c.json(mcpDescriptor(opts.publicOrigin)));
88
105
  app.get("/.well-known/agent.json", (c) => c.json(agentCard(opts.publicOrigin, "1.1.0")));
89
106
  // Fix the broken /docs/* links from the nav — redirect to llms.txt (the canonical agent doc).
@@ -91,6 +108,21 @@ export function createApp(opts) {
91
108
  app.get("/docs/*", (c) => c.redirect("/llms.txt", 302));
92
109
  app.get("/account", (c) => c.html(accountHtml()));
93
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
+ });
94
126
  app.get("/policy.txt", (c) => c.text(policyText(opts.publicOrigin)));
95
127
  // ─── Accounts (passwordless, recovery-token based) ───
96
128
  function requireSession(c) {
@@ -295,12 +327,11 @@ export function createApp(opts) {
295
327
  if (retentionInput !== undefined && !isRetention(retentionInput)) {
296
328
  return c.json({ error: "invalid retention; must be one of none|metadata|prompts|full" }, 400);
297
329
  }
298
- const requireIdentity = body.require_identity === true;
330
+ const requireIdentityInput = body.require_identity;
299
331
  const trustModeInput = body.trust_mode;
300
332
  if (trustModeInput !== undefined && trustModeInput !== "untrusted" && trustModeInput !== "trusted") {
301
333
  return c.json({ error: "invalid trust_mode; must be 'untrusted' or 'trusted'" }, 400);
302
334
  }
303
- const trustMode = trustModeInput === "trusted" ? "trusted" : "untrusted";
304
335
  const ownerPasswordInput = body.owner_password;
305
336
  let ownerPassword;
306
337
  if (ownerPasswordInput !== undefined) {
@@ -312,12 +343,30 @@ export function createApp(opts) {
312
343
  ownerPassword = trimmed;
313
344
  }
314
345
  const sessionTtlSecondsInput = body.session_ttl_seconds;
315
- let sessionTtlSeconds;
316
346
  if (sessionTtlSecondsInput !== undefined) {
317
347
  if (typeof sessionTtlSecondsInput !== "number" || !Number.isFinite(sessionTtlSecondsInput)) {
318
348
  return c.json({ error: "session_ttl_seconds must be a positive number ≤ 86400 (24h)" }, 400);
319
349
  }
320
- 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);
321
370
  }
322
371
  let creatorAccountId;
323
372
  const auth = c.req.header("authorization") ?? c.req.header("Authorization") ?? "";
@@ -331,7 +380,7 @@ export function createApp(opts) {
331
380
  }
332
381
  }
333
382
  const result = createChannel({
334
- retention: retentionInput,
383
+ retention,
335
384
  require_identity: requireIdentity,
336
385
  trust_mode: trustMode,
337
386
  session_ttl_seconds: sessionTtlSeconds,
@@ -340,13 +389,13 @@ export function createApp(opts) {
340
389
  });
341
390
  if ("error" in result)
342
391
  return c.json(result, 400);
343
- 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;
344
393
  return c.json({
345
- ...buildConnectInfo(id, token, opts.publicOrigin, { ownerPassword, trustMode }),
346
- retention,
347
- require_identity,
348
- trust_mode,
349
- 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,
350
399
  creator_account_id,
351
400
  has_owner_password,
352
401
  owner_password: ownerPassword ?? null,
@@ -362,6 +411,33 @@ export function createApp(opts) {
362
411
  }));
363
412
  return c.json({ channels: channelList });
364
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
+ });
365
441
  app.delete("/api/account/channels/:id", (c) => {
366
442
  const r = requireSession(c);
367
443
  if (r instanceof Response)
@@ -667,6 +743,11 @@ export function createApp(opts) {
667
743
  const to = String(body.to ?? "");
668
744
  // Accept either `message` or `text` (transcripts return `text`, so clients reasonably try both).
669
745
  const message = String(body.message ?? body.text ?? "");
746
+ // Optional ntfy-style priority. Server stores it; receivers decide what to do.
747
+ const priorityInput = body.priority;
748
+ if (priorityInput !== undefined && !isPriority(priorityInput)) {
749
+ return c.json({ error: "invalid priority; must be one of min|low|default|high|urgent", code: "invalid" }, 400);
750
+ }
670
751
  const channel = getOrCreateChannel(channelId);
671
752
  try {
672
753
  const isBand = getChannelIsBand(channelId);
@@ -679,12 +760,19 @@ export function createApp(opts) {
679
760
  retry_after_seconds: rate.retryAfter,
680
761
  }, 429);
681
762
  }
682
- const msg = channel.send(sessionId, to, message);
763
+ const msg = channel.send(sessionId, to, message, priorityInput);
683
764
  statsRecordMessage();
684
765
  transcriptRecordMessage(channelId, getChannelRetention(channelId), msg);
685
766
  fanoutWebhooks(channelId, msg);
686
767
  const queued = msg.to !== "all" && !channel.isCallsignOnline(msg.to);
687
- return c.json({ ok: true, id: msg.id, at: msg.at, queued, to: msg.to });
768
+ return c.json({
769
+ ok: true,
770
+ id: msg.id,
771
+ at: msg.at,
772
+ queued,
773
+ to: msg.to,
774
+ ...(msg.priority ? { priority: msg.priority } : {}),
775
+ });
688
776
  }
689
777
  catch (e) {
690
778
  return handleChannelError(c, e);
@@ -713,6 +801,138 @@ export function createApp(opts) {
713
801
  return handleChannelError(c, e);
714
802
  }
715
803
  });
804
+ // Wait: the canonical idle action. Same semantics as listen but with a longer
805
+ // ceiling (up to 5 min) and an explicit meta_hint nudge on empty returns —
806
+ // designed for agents that should sit waiting for a peer.
807
+ app.get("/api/channels/:id/wait", async (c) => {
808
+ const channelId = c.req.param("id");
809
+ const denied = requireChannelBearer(c, channelId);
810
+ if (denied)
811
+ return denied;
812
+ const sessionId = getSessionId(c);
813
+ if (!sessionId)
814
+ return c.json({ error: "X-Session-Id header required (returned by /join)", code: "invalid" }, 400);
815
+ const timeoutSec = Math.max(1, Math.min(300, Number(c.req.query("timeout") ?? 120)));
816
+ const sinceRaw = c.req.query("since");
817
+ const since = sinceRaw !== undefined ? Number(sinceRaw) : undefined;
818
+ if (sinceRaw !== undefined && !Number.isFinite(since)) {
819
+ return c.json({ error: "since must be a numeric message id", code: "invalid" }, 400);
820
+ }
821
+ const channel = getOrCreateChannel(channelId);
822
+ try {
823
+ const msgs = await channel.listen(sessionId, timeoutSec * 1000, since);
824
+ const empty = msgs.length === 0;
825
+ return c.json({
826
+ messages: msgs,
827
+ timed_out: empty,
828
+ roster: channel.roster(),
829
+ meta_hint: empty
830
+ ? "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."
831
+ : `${msgs.length} new message${msgs.length === 1 ? "" : "s"}. After acting, call /wait again to hear replies.`,
832
+ });
833
+ }
834
+ catch (e) {
835
+ return handleChannelError(c, e);
836
+ }
837
+ });
838
+ // Stream: SSE push of incoming messages. Stays open until the client disconnects.
839
+ // Unlike /listen and /wait this is NOT turn-based — designed for `npx rogerrat
840
+ // listen-here` and any always-on consumer that wants zero polling cost. The session
841
+ // stays alive for as long as the connection is held (streamer counts as activity
842
+ // for the GC, so a parked agent with an open stream is never reaped).
843
+ //
844
+ // Query params:
845
+ // - since=<msg_id> resume from a known id (skips per-session cursor)
846
+ //
847
+ // Events emitted:
848
+ // - event: hello once, on connect, with channel metadata
849
+ // - event: message each delivered message (id, from, to, text, at)
850
+ // - event: error typed channel error before close (rare; pre-validated)
851
+ // - :ping comment line every 25s to defeat idle-proxy disconnects
852
+ app.get("/api/channels/:id/stream", (c) => {
853
+ const channelId = c.req.param("id");
854
+ const denied = requireChannelBearer(c, channelId);
855
+ if (denied)
856
+ return denied;
857
+ const sessionId = getSessionId(c);
858
+ if (!sessionId) {
859
+ return c.json({ error: "X-Session-Id header required (returned by /join)", code: "invalid" }, 400);
860
+ }
861
+ const sinceRaw = c.req.query("since");
862
+ const since = sinceRaw !== undefined ? Number(sinceRaw) : undefined;
863
+ if (sinceRaw !== undefined && !Number.isFinite(since)) {
864
+ return c.json({ error: "since must be a numeric message id", code: "invalid" }, 400);
865
+ }
866
+ const channel = getOrCreateChannel(channelId);
867
+ // Pre-validate session so we can return a real 4xx instead of streaming an error.
868
+ try {
869
+ channel.keepalive(sessionId);
870
+ }
871
+ catch (e) {
872
+ return handleChannelError(c, e);
873
+ }
874
+ const callsign = channel.callsignOf(sessionId);
875
+ return streamSSE(c, async (stream) => {
876
+ const queue = [];
877
+ let waker = null;
878
+ const wake = () => {
879
+ const w = waker;
880
+ waker = null;
881
+ if (w)
882
+ w();
883
+ };
884
+ const detach = channel.addStreamListener(sessionId, (msg) => {
885
+ queue.push(msg);
886
+ wake();
887
+ });
888
+ // Drain backlog AFTER subscribing — both ops are sync so no race window.
889
+ const backlog = channel.drainSince(sessionId, since);
890
+ queue.unshift(...backlog);
891
+ const pingTimer = setInterval(() => {
892
+ stream.write(": ping\n\n").catch(() => { });
893
+ }, 25_000);
894
+ pingTimer.unref?.();
895
+ const abortSignal = c.req.raw.signal;
896
+ const onAbort = () => wake();
897
+ abortSignal.addEventListener("abort", onAbort);
898
+ try {
899
+ await stream.writeSSE({
900
+ event: "hello",
901
+ data: JSON.stringify({
902
+ channel_id: channelId,
903
+ callsign,
904
+ roster: channel.roster(),
905
+ backlog_count: backlog.length,
906
+ }),
907
+ });
908
+ while (!abortSignal.aborted) {
909
+ while (queue.length > 0) {
910
+ const msg = queue.shift();
911
+ await stream.writeSSE({
912
+ event: "message",
913
+ data: JSON.stringify(msg),
914
+ id: String(msg.id),
915
+ });
916
+ }
917
+ if (abortSignal.aborted)
918
+ break;
919
+ await new Promise((resolve) => {
920
+ waker = resolve;
921
+ });
922
+ }
923
+ }
924
+ catch (err) {
925
+ // Client disconnect surfaces as a write error — silent. Anything else, log.
926
+ if (!abortSignal.aborted)
927
+ console.error(`[stream ${channelId}/${callsign}]`, err);
928
+ }
929
+ finally {
930
+ abortSignal.removeEventListener("abort", onAbort);
931
+ clearInterval(pingTimer);
932
+ detach();
933
+ }
934
+ });
935
+ });
716
936
  app.get("/api/channels/:id/stats", (c) => {
717
937
  const channelId = c.req.param("id");
718
938
  const denied = requireChannelBearer(c, channelId);
@@ -863,7 +1083,8 @@ export function createApp(opts) {
863
1083
  return c.json({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "invalid request" } }, 400);
864
1084
  }
865
1085
  const sessionId = c.req.header("mcp-session-id") ?? c.req.header("Mcp-Session-Id");
866
- const result = await handleMcpRequest(channelId, body, sessionId, opts.publicOrigin);
1086
+ const mcpMode = c.get("mode") ?? "default";
1087
+ const result = await handleMcpRequest(channelId, body, sessionId, opts.publicOrigin, mcpMode);
867
1088
  if (result.sessionId)
868
1089
  c.header("Mcp-Session-Id", result.sessionId);
869
1090
  if (result.body === null)