@blockrun/franklin 3.16.4 → 3.18.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.
@@ -150,7 +150,17 @@ RULES (violations will produce garbage output):
150
150
  6. End with: "Reply to any? Give me the number."
151
151
  7. Do NOT auto-post. Do NOT explain how the social system works.
152
152
 
153
- When checking notifications/mentions: Use SearchX with mode="notifications". One call, done.`;
153
+ When the user pastes a specific tweet URL (https://x.com/<user>/status/<id>): Call SearchX with the URL as the query. The tool auto-detects URL mode and reads the post directly. Do NOT search for the URL as a keyword (always returns empty), and do NOT try WebFetch on x.com.
154
+
155
+ When checking notifications/mentions: Use SearchX with mode="notifications". One call, done.
156
+
157
+ If SearchX returns empty or "no article extracted" on a URL/query you believe SHOULD have content (you can see the page in the browser, or the user confirms it exists), DO NOT give up — drop down to the BrowserX primitive and drive the browser yourself:
158
+ 1. BrowserX action="snapshot" → see what's on screen right now
159
+ 2. BrowserX action="scroll" dy=600 → trigger lazy-render / load more
160
+ 3. BrowserX action="snapshot" again → re-inspect after scroll
161
+ 4. BrowserX action="click" ref=<id> → follow a permalink (refs come from the last snapshot)
162
+ 5. BrowserX action="open" url=<other> → try a different URL (e.g. /search?q=… or a profile page)
163
+ BrowserX shares the logged-in X session with SearchX, so authentication is already handled. Use BrowserX only for read/navigation; replies still go through PostToX with explicit user confirmation.`;
154
164
  }
155
165
  function getMissingAccessSection() {
156
166
  return `# Missing Access
@@ -184,6 +184,21 @@ export function classifyAgentError(message) {
184
184
  suggestion: 'Tool schema rejected by this model. Try /model to switch to a more permissive model (e.g. sonnet), or upgrade Franklin.',
185
185
  };
186
186
  }
187
+ // Unknown / typo'd model id — gateway returns HTTP 400 with a body like
188
+ // "Unknown model: moonshot/kimi-k2". Without this branch the error falls
189
+ // through to the catch-all 'unknown' category and shows the user a bare
190
+ // "Type: Unknown" with no actionable next step.
191
+ if (includesAny(err, [
192
+ 'unknown model',
193
+ 'model not found',
194
+ 'model does not exist',
195
+ 'no such model',
196
+ ])) {
197
+ return {
198
+ category: 'schema', label: 'Schema', isTransient: false, maxRetries: 0,
199
+ suggestion: 'The gateway rejected the model id (unknown / typo). Use /model to pick a valid one, or upgrade Franklin if a fallback chain references a stale id.',
200
+ };
201
+ }
187
202
  if (includesAny(err, [
188
203
  '500',
189
204
  '502',
@@ -341,6 +341,13 @@ export function looksLikeStalledIntent(text) {
341
341
  const trimmed = text.trim();
342
342
  if (trimmed.length < 24)
343
343
  return false;
344
+ // If the final non-empty line is a short question to the user, the model is
345
+ // explicitly deferring ("Which would you prefer?", "Want me to proceed?") —
346
+ // that's a handoff, not a stall. Avoid re-invoking on another model and
347
+ // billing twice for what is in fact correct behavior.
348
+ const lastLine = trimmed.split(/\n+/).map(s => s.trim()).filter(Boolean).pop() ?? '';
349
+ if (lastLine.length > 0 && lastLine.length <= 120 && /[??]\s*$/.test(lastLine))
350
+ return false;
344
351
  // Look at the last ~400 chars only — intent-to-act lives near the end.
345
352
  const tail = trimmed.slice(-400).toLowerCase();
346
353
  // Strong "I'm about to do something" markers near the tail.
@@ -1336,7 +1343,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1336
1343
  // Excludes nvidia/* and *-coder-* — they're the source population.
1337
1344
  const TOOL_USE_FALLBACK_MODELS = [
1338
1345
  'anthropic/claude-haiku-4.5',
1339
- 'moonshot/kimi-k2',
1346
+ 'moonshot/kimi-k2.6',
1340
1347
  'openai/gpt-5',
1341
1348
  'anthropic/claude-sonnet-4.6',
1342
1349
  ];
@@ -33,10 +33,10 @@ function isCommonDevCommand(cmd) {
33
33
  return COMMON_DEV_PATTERNS.some(p => p.test(trimmed));
34
34
  }
35
35
  // ─── Default Rules ─────────────────────────────────────────────────────────
36
- const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX']);
36
+ const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX', 'BrowserX']);
37
37
  const DESTRUCTIVE_TOOLS = new Set(['Write', 'Edit', 'Bash']);
38
38
  const DEFAULT_RULES = {
39
- allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX'],
39
+ allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX', 'BrowserX'],
40
40
  deny: [],
41
41
  ask: ['Write', 'Edit', 'Bash', 'Agent', 'PostToX'],
42
42
  };
@@ -183,6 +183,13 @@ export class SessionToolGuard {
183
183
  startTurn() {
184
184
  this.turn++;
185
185
  this.webSearchesThisTurn = 0;
186
+ // The per-tool circuit breaker exists to stop a model from burning a
187
+ // whole turn re-attacking a wall. It must NOT outlive the user turn that
188
+ // earned the failures — a fresh prompt is a fresh intent. Without this
189
+ // reset, three failed Bash calls (e.g. `franklin social login x` on a
190
+ // host without the right env) permanently disable Bash for the rest of
191
+ // the session, even on completely unrelated follow-ups.
192
+ this.toolErrorCounts.clear();
186
193
  for (const family of this.searchFamilies) {
187
194
  family.turnSearches = 0;
188
195
  }
@@ -233,6 +240,32 @@ export class SessionToolGuard {
233
240
  const cmd = String(invocation.input.command ?? '').trim();
234
241
  if (!cmd)
235
242
  return null;
243
+ // Reject interactive franklin subcommands that require the human at the
244
+ // keyboard (they spawn a non-headless Chrome and wait for the user to
245
+ // close it). If the agent runs them via Bash they block until timeout,
246
+ // burn a tool-failure strike, and contribute nothing. Tell the agent to
247
+ // ask the user to run them in a separate terminal instead.
248
+ if (/^\s*franklin\s+social\s+(login|setup)\b/.test(cmd)) {
249
+ return {
250
+ output: 'Blocked: `franklin social login` / `franklin social setup` are INTERACTIVE — ' +
251
+ 'they open a Chrome window the human must drive and close. They cannot run from ' +
252
+ 'an agent Bash call (they will hang then time out). ' +
253
+ 'Ask the user to run this in their own terminal, then continue once they say it is done.',
254
+ isError: true,
255
+ };
256
+ }
257
+ // `franklin social run` is a batch poster/replier that loops over the
258
+ // user's configured search_queries — it is not the right tool for
259
+ // "read this specific tweet" or "draft replies to one post". Steer the
260
+ // agent to SearchX (now URL-aware) instead.
261
+ if (/^\s*franklin\s+social\s+run\b/.test(cmd)) {
262
+ return {
263
+ output: 'Blocked: `franklin social run` is a batch reply loop over the user\'s configured ' +
264
+ 'queries, not a single-tweet reader. Use the SearchX tool instead — pass a tweet URL ' +
265
+ 'as the query to read one post, or use mode="search"/"notifications" for discovery.',
266
+ isError: true,
267
+ };
268
+ }
236
269
  // Reject blocking poll-loops in foreground bash. A single bash call with
237
270
  // `sleep N` inside a for/while/until loop blocks the agent for the full
238
271
  // duration — the UI repeats the same status line and the user almost
@@ -410,6 +410,63 @@ a:hover { text-decoration:underline; }
410
410
  .btn-danger { background:oklch(0.65 0.20 25 / 18%); color:var(--danger); border-color:oklch(0.65 0.20 25 / 35%); }
411
411
  .btn-danger:hover { background:oklch(0.65 0.20 25 / 30%); }
412
412
 
413
+ .nav-badge {
414
+ margin-left:auto; font-size:10px; font-weight:700; letter-spacing:0.3px;
415
+ padding:2px 7px; border-radius:8px;
416
+ background:oklch(0.65 0.20 25 / 22%); color:var(--danger);
417
+ border:1px solid oklch(0.65 0.20 25 / 35%);
418
+ }
419
+ .nav-badge.warn { background:oklch(0.78 0.14 85 / 22%); color:var(--gold); border-color:oklch(0.78 0.14 85 / 35%); }
420
+
421
+ .phone-list { display:flex; flex-direction:column; gap:10px; }
422
+ .phone-row {
423
+ display:grid; grid-template-columns:auto 1fr auto; gap:14px; align-items:center;
424
+ padding:14px 16px; background:var(--bg-card); border:1px solid var(--border);
425
+ border-radius:var(--radius); transition:border-color 0.15s, background 0.15s;
426
+ }
427
+ .phone-row:hover { background:var(--bg-card-hover); }
428
+ .phone-row.warn { border-color:oklch(0.78 0.14 85 / 50%); }
429
+ .phone-row.crit { border-color:oklch(0.65 0.20 25 / 55%); }
430
+ .phone-row.expired { opacity:0.65; border-color:oklch(0.65 0.20 25 / 45%); }
431
+ .phone-icon-bubble {
432
+ width:36px; height:36px; border-radius:10px; display:grid; place-items:center;
433
+ background:oklch(0.68 0.16 260 / 18%); color:var(--brand);
434
+ }
435
+ .phone-main { display:flex; flex-direction:column; gap:3px; min-width:0; }
436
+ .phone-num {
437
+ font-family:var(--mono); font-size:15px; font-weight:600; color:var(--text);
438
+ letter-spacing:0.02em;
439
+ }
440
+ .phone-meta { font-size:12px; color:var(--text-muted); display:flex; gap:10px; flex-wrap:wrap; }
441
+ .phone-meta .chip {
442
+ display:inline-flex; align-items:center; gap:4px;
443
+ padding:2px 7px; border-radius:6px; background:oklch(0 0 0 / 25%);
444
+ font-size:10.5px; letter-spacing:0.5px; text-transform:uppercase; font-weight:700;
445
+ }
446
+ .phone-meta .chip.green { color:var(--success); background:oklch(0.65 0.18 145 / 18%); }
447
+ .phone-meta .chip.amber { color:var(--gold); background:oklch(0.78 0.14 85 / 20%); }
448
+ .phone-meta .chip.red { color:var(--danger); background:oklch(0.65 0.20 25 / 20%); }
449
+ .phone-row .phone-actions { display:flex; align-items:center; gap:6px; }
450
+ .phone-row.expired .phone-num { text-decoration:line-through; }
451
+
452
+ .phone-empty {
453
+ padding:24px; text-align:center; border:1px dashed var(--border);
454
+ border-radius:var(--radius); color:var(--text-muted); font-size:13px;
455
+ line-height:1.6;
456
+ }
457
+ .phone-empty strong { color:var(--text); font-weight:600; display:block; margin-bottom:6px; font-size:14px; }
458
+
459
+ .phone-buy-form { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:10px; }
460
+ .phone-buy-form select, .phone-buy-form input {
461
+ padding:7px 10px; background:oklch(0 0 0 / 35%); color:var(--text);
462
+ border:1px solid var(--border); border-radius:7px; font-size:13px;
463
+ font-family:var(--mono);
464
+ }
465
+ .phone-buy-form input { width:120px; }
466
+ .phone-status { font-size:12px; color:var(--text-muted); }
467
+ .phone-status.ok { color:var(--success); }
468
+ .phone-status.err { color:var(--danger); }
469
+
413
470
  @media (max-width:768px) {
414
471
  body { flex-direction:column; }
415
472
  .sidebar { width:100%; min-width:100%; flex-direction:row; padding:8px; overflow-x:auto; border-right:none; border-bottom:1px solid var(--border); }
@@ -455,6 +512,11 @@ a:hover { text-decoration:underline; }
455
512
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M7 14l4-4 4 4 5-5"/></svg>
456
513
  Markets
457
514
  </button>
515
+ <button class="nav-item" data-tab="phone">
516
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
517
+ Phone
518
+ <span class="nav-badge" id="phone-nav-badge" style="display:none"></span>
519
+ </button>
458
520
  <button class="nav-item" data-tab="sessions">
459
521
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
460
522
  Sessions
@@ -677,6 +739,46 @@ a:hover { text-decoration:underline; }
677
739
  </div>
678
740
  </div>
679
741
 
742
+ <!-- Phone & Voice -->
743
+ <div class="tab" id="tab-phone">
744
+ <div class="content-header">
745
+ <h2>Phone &amp; Voice</h2>
746
+ <p>Numbers your wallet owns. Leases run 30 days &mdash; renew before they expire or set auto-renew.</p>
747
+ </div>
748
+
749
+ <div class="card" style="margin-bottom:16px">
750
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;">
751
+ <h3 style="margin:0">Your numbers</h3>
752
+ <div style="display:flex;gap:8px;align-items:center">
753
+ <span class="phone-status" id="phone-list-status"></span>
754
+ <button class="btn btn-ghost" id="phone-refresh-btn" title="Refetch from BlockRun ($0.001)">Refresh</button>
755
+ </div>
756
+ </div>
757
+ <div id="phone-list" style="margin-top:12px">
758
+ <div class="phone-empty">Loading&hellip;</div>
759
+ </div>
760
+ </div>
761
+
762
+ <div class="card">
763
+ <h3 style="margin:0 0 6px">Add another number</h3>
764
+ <p class="wallet-hint">
765
+ $5 USDC for a fresh number, bound to your wallet for 30 days. <strong>This adds
766
+ a new number alongside any you already own &mdash; nothing is replaced.</strong>
767
+ Use it as caller ID for outbound AI voice calls, or (soon) to receive inbound calls.
768
+ Multiple numbers are fine; release any you no longer need to stop paying renewals on them.
769
+ </p>
770
+ <div class="phone-buy-form">
771
+ <select id="phone-buy-country">
772
+ <option value="US">United States (+1)</option>
773
+ <option value="CA">Canada (+1)</option>
774
+ </select>
775
+ <input id="phone-buy-areacode" placeholder="Area code (opt)" maxlength="6" />
776
+ <button class="btn btn-warn" id="phone-buy-btn">Buy for $5</button>
777
+ <span class="phone-status" id="phone-buy-status"></span>
778
+ </div>
779
+ </div>
780
+ </div>
781
+
680
782
  <!-- Learnings -->
681
783
  <div class="tab" id="tab-learnings">
682
784
  <div class="content-header">
@@ -1544,6 +1646,307 @@ document.addEventListener('visibilitychange', () => {
1544
1646
 
1545
1647
  document.getElementById('tasks-refresh-btn')?.addEventListener('click', fetchTasks);
1546
1648
 
1649
+ // ─── Phone & Voice ──────────────────────────────────────────────────────
1650
+ // Renders the user's wallet-owned numbers, days-remaining countdown,
1651
+ // renew / release / auto-renew controls, and the buy form. Drives the
1652
+ // sidebar nav badge so users with an expiring number see it even from
1653
+ // the Overview tab. Notification ladder uses the Notifications API,
1654
+ // dedupe-keyed in sessionStorage so we don't spam the user every open.
1655
+
1656
+ const phoneState = { data: null, countdownTimer: null };
1657
+
1658
+ function formatPhoneNumber(e164) {
1659
+ // E.164 → human, for display only. Keep raw value for actions.
1660
+ if (!e164) return '—';
1661
+ const m = String(e164).match(/^\\+1(\\d{3})(\\d{3})(\\d{4})$/);
1662
+ if (m) return '+1 (' + m[1] + ') ' + m[2] + '-' + m[3];
1663
+ return e164;
1664
+ }
1665
+
1666
+ function daysLeft(expiresAt) {
1667
+ const expiry = new Date(expiresAt).getTime();
1668
+ if (isNaN(expiry)) return 0;
1669
+ return Math.floor((expiry - Date.now()) / 86400000);
1670
+ }
1671
+
1672
+ function phoneTier(days) {
1673
+ if (days < 0) return 'expired';
1674
+ if (days <= 2) return 'crit';
1675
+ if (days <= 7) return 'warn';
1676
+ return 'ok';
1677
+ }
1678
+
1679
+ function phoneChipClass(tier) {
1680
+ if (tier === 'expired' || tier === 'crit') return 'red';
1681
+ if (tier === 'warn') return 'amber';
1682
+ return 'green';
1683
+ }
1684
+
1685
+ function phoneCountdownLabel(days) {
1686
+ if (days < 0) return 'expired ' + Math.abs(days) + 'd ago';
1687
+ if (days === 0) return 'expires today';
1688
+ if (days === 1) return '1 day left';
1689
+ return days + ' days left';
1690
+ }
1691
+
1692
+ function updatePhoneNavBadge(numbers) {
1693
+ const badge = document.getElementById('phone-nav-badge');
1694
+ if (!badge) return;
1695
+ let worst = 999;
1696
+ let anyExpired = false;
1697
+ numbers.forEach(n => {
1698
+ const d = daysLeft(n.expires_at);
1699
+ if (d < 0) anyExpired = true;
1700
+ if (d < worst) worst = d;
1701
+ });
1702
+ if (anyExpired) {
1703
+ badge.textContent = '!'; badge.className = 'nav-badge'; badge.style.display = '';
1704
+ } else if (worst <= 2) {
1705
+ badge.textContent = worst + 'd'; badge.className = 'nav-badge'; badge.style.display = '';
1706
+ } else if (worst <= 7) {
1707
+ badge.textContent = worst + 'd'; badge.className = 'nav-badge warn'; badge.style.display = '';
1708
+ } else {
1709
+ badge.style.display = 'none';
1710
+ }
1711
+ }
1712
+
1713
+ function maybeNotifyExpiry(numbers) {
1714
+ if (typeof Notification === 'undefined') return;
1715
+ if (Notification.permission !== 'granted') return;
1716
+ numbers.forEach(n => {
1717
+ const d = daysLeft(n.expires_at);
1718
+ let mark = null;
1719
+ if (d < 0) mark = 'expired';
1720
+ else if (d <= 1) mark = 't1';
1721
+ else if (d <= 3) mark = 't3';
1722
+ else if (d <= 7) mark = 't7';
1723
+ if (!mark) return;
1724
+ const key = 'phone:notify:' + n.phone_number + ':' + mark;
1725
+ if (sessionStorage.getItem(key)) return;
1726
+ sessionStorage.setItem(key, '1');
1727
+ const human = formatPhoneNumber(n.phone_number);
1728
+ const title = 'Franklin: ' + human;
1729
+ const body = mark === 'expired'
1730
+ ? 'This number has expired. Provision a new one in the Phone tab.'
1731
+ : (mark === 't1'
1732
+ ? 'Expires in 1 day. Click to renew for $5.'
1733
+ : (mark === 't3'
1734
+ ? 'Expires in 3 days. Click to renew for $5.'
1735
+ : 'Expires in a week. Renew when convenient.'));
1736
+ try {
1737
+ const notif = new Notification(title, { body, tag: key });
1738
+ notif.onclick = () => {
1739
+ try { window.focus(); } catch (e) {}
1740
+ location.hash = 'phone';
1741
+ activateTab('phone');
1742
+ notif.close();
1743
+ };
1744
+ } catch (e) { /* ignore */ }
1745
+ });
1746
+ }
1747
+
1748
+ function renderPhoneNumbers(data) {
1749
+ const list = document.getElementById('phone-list');
1750
+ if (!list) return;
1751
+ const numbers = (data && data.numbers) || [];
1752
+ updatePhoneNavBadge(numbers);
1753
+
1754
+ if (!numbers.length) {
1755
+ list.innerHTML = '<div class="phone-empty">' +
1756
+ '<strong>No numbers yet</strong>' +
1757
+ 'Provision a number below to give Franklin a phone identity. ' +
1758
+ 'Numbers cost $5 for a 30-day lease and are bound to your wallet.' +
1759
+ '</div>';
1760
+ return;
1761
+ }
1762
+
1763
+ const html = numbers.map(n => {
1764
+ const d = daysLeft(n.expires_at);
1765
+ const tier = phoneTier(d);
1766
+ const chipCls = phoneChipClass(tier);
1767
+ const rowCls = tier === 'ok' ? '' : (' ' + tier);
1768
+ const human = formatPhoneNumber(n.phone_number);
1769
+ const renewBtn = tier === 'expired'
1770
+ ? ''
1771
+ : '<button class="btn btn-warn" data-phone-renew="' + n.phone_number + '">Renew $5</button>';
1772
+ const releaseLabel = tier === 'expired' ? 'Remove' : 'Release';
1773
+ return ''
1774
+ + '<div class="phone-row' + rowCls + '">'
1775
+ + ' <div class="phone-icon-bubble">'
1776
+ + ' <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
1777
+ + ' <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>'
1778
+ + ' </svg>'
1779
+ + ' </div>'
1780
+ + ' <div class="phone-main">'
1781
+ + ' <div class="phone-num">' + human + '</div>'
1782
+ + ' <div class="phone-meta">'
1783
+ + ' <span class="chip">' + (n.chain || '—') + '</span>'
1784
+ + ' <span class="chip ' + chipCls + '">' + phoneCountdownLabel(d) + '</span>'
1785
+ + ' </div>'
1786
+ + ' </div>'
1787
+ + ' <div class="phone-actions">'
1788
+ + renewBtn
1789
+ + ' <button class="btn btn-ghost" data-phone-release="' + n.phone_number + '" title="' + releaseLabel + ' this number">' + releaseLabel + '</button>'
1790
+ + ' </div>'
1791
+ + '</div>';
1792
+ }).join('');
1793
+
1794
+ list.innerHTML = html;
1795
+
1796
+ list.querySelectorAll('[data-phone-renew]').forEach(btn => {
1797
+ btn.addEventListener('click', () => renewPhoneNumber(btn.getAttribute('data-phone-renew')));
1798
+ });
1799
+ list.querySelectorAll('[data-phone-release]').forEach(btn => {
1800
+ btn.addEventListener('click', () => releasePhoneNumber(btn.getAttribute('data-phone-release')));
1801
+ });
1802
+
1803
+ maybeNotifyExpiry(numbers);
1804
+ }
1805
+
1806
+ async function loadPhone(opts) {
1807
+ const force = !!(opts && opts.force);
1808
+ const statusEl = document.getElementById('phone-list-status');
1809
+ if (statusEl) statusEl.textContent = force ? 'Refreshing…' : 'Loading…';
1810
+ try {
1811
+ const url = '/api/phone/numbers';
1812
+ const r = force
1813
+ ? await fetch('/api/phone/numbers/refresh', { method: 'POST' })
1814
+ : await fetch(url);
1815
+ const data = await r.json();
1816
+ if (!r.ok) {
1817
+ if (statusEl) { statusEl.textContent = data.error || 'Failed to load'; statusEl.className = 'phone-status err'; }
1818
+ const list = document.getElementById('phone-list');
1819
+ if (list) list.innerHTML = '<div class="phone-empty"><strong>Could not load numbers</strong>' + (data.error || 'Unknown error') + '</div>';
1820
+ return;
1821
+ }
1822
+ phoneState.data = data;
1823
+ renderPhoneNumbers(data);
1824
+ if (statusEl) {
1825
+ statusEl.className = 'phone-status';
1826
+ statusEl.textContent = data.fromCache
1827
+ ? 'Cached ' + new Date(data.fetchedAt).toLocaleTimeString()
1828
+ : 'Synced ' + new Date(data.fetchedAt || Date.now()).toLocaleTimeString();
1829
+ }
1830
+ } catch (err) {
1831
+ if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
1832
+ }
1833
+ }
1834
+
1835
+ async function renewPhoneNumber(num) {
1836
+ const statusEl = document.getElementById('phone-list-status');
1837
+ if (statusEl) { statusEl.textContent = 'Renewing ' + formatPhoneNumber(num) + '…'; statusEl.className = 'phone-status'; }
1838
+ try {
1839
+ const r = await fetch('/api/phone/numbers/renew', {
1840
+ method: 'POST',
1841
+ headers: { 'Content-Type': 'application/json' },
1842
+ body: JSON.stringify({ phoneNumber: num }),
1843
+ });
1844
+ const data = await r.json();
1845
+ if (!r.ok) {
1846
+ if (statusEl) { statusEl.textContent = data.error || 'Renew failed'; statusEl.className = 'phone-status err'; }
1847
+ return;
1848
+ }
1849
+ // Clear dedupe keys so a renewed number can re-notify if it expires again later
1850
+ Object.keys(sessionStorage).filter(k => k.startsWith('phone:notify:' + num + ':')).forEach(k => sessionStorage.removeItem(k));
1851
+ if (statusEl) { statusEl.textContent = 'Renewed — new expiry ' + new Date(data.expires_at).toLocaleDateString(); statusEl.className = 'phone-status ok'; }
1852
+ await loadPhone({});
1853
+ } catch (err) {
1854
+ if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
1855
+ }
1856
+ }
1857
+
1858
+ async function releasePhoneNumber(num) {
1859
+ if (!confirm('Release ' + formatPhoneNumber(num) + '? This permanently gives up the number and cannot be undone.')) return;
1860
+ const statusEl = document.getElementById('phone-list-status');
1861
+ if (statusEl) { statusEl.textContent = 'Releasing…'; statusEl.className = 'phone-status'; }
1862
+ try {
1863
+ const r = await fetch('/api/phone/numbers/release', {
1864
+ method: 'POST',
1865
+ headers: { 'Content-Type': 'application/json' },
1866
+ body: JSON.stringify({ phoneNumber: num }),
1867
+ });
1868
+ const data = await r.json();
1869
+ if (!r.ok) {
1870
+ if (statusEl) { statusEl.textContent = data.error || 'Release failed'; statusEl.className = 'phone-status err'; }
1871
+ return;
1872
+ }
1873
+ if (statusEl) { statusEl.textContent = 'Released ' + formatPhoneNumber(num); statusEl.className = 'phone-status ok'; }
1874
+ await loadPhone({});
1875
+ } catch (err) {
1876
+ if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
1877
+ }
1878
+ }
1879
+
1880
+ async function buyPhoneNumber() {
1881
+ const country = (document.getElementById('phone-buy-country') || {}).value || 'US';
1882
+ const areaCode = ((document.getElementById('phone-buy-areacode') || {}).value || '').trim();
1883
+ const statusEl = document.getElementById('phone-buy-status');
1884
+ const btn = document.getElementById('phone-buy-btn');
1885
+ const existingCount = ((phoneState.data && phoneState.data.numbers) || []).filter(n => daysLeft(n.expires_at) >= 0).length;
1886
+ const intro = existingCount > 0
1887
+ ? 'You already own ' + existingCount + ' active number' + (existingCount === 1 ? '' : 's') + '. This will ADD a new number — nothing is replaced.\\n\\n'
1888
+ : '';
1889
+ if (!confirm(intro + 'Buy a new phone number for $5? It will be charged from your wallet immediately and last 30 days.')) return;
1890
+ if (statusEl) { statusEl.textContent = 'Provisioning…'; statusEl.className = 'phone-status'; }
1891
+ if (btn) btn.disabled = true;
1892
+ try {
1893
+ const r = await fetch('/api/phone/numbers/buy', {
1894
+ method: 'POST',
1895
+ headers: { 'Content-Type': 'application/json' },
1896
+ body: JSON.stringify({ country, areaCode: areaCode || undefined }),
1897
+ });
1898
+ const data = await r.json();
1899
+ if (!r.ok) {
1900
+ if (statusEl) { statusEl.textContent = data.error || 'Purchase failed'; statusEl.className = 'phone-status err'; }
1901
+ return;
1902
+ }
1903
+ if (statusEl) { statusEl.textContent = 'Got ' + formatPhoneNumber(data.phone_number); statusEl.className = 'phone-status ok'; }
1904
+ await loadPhone({});
1905
+ } catch (err) {
1906
+ if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
1907
+ } finally {
1908
+ if (btn) btn.disabled = false;
1909
+ }
1910
+ }
1911
+
1912
+ function startPhoneCountdown() {
1913
+ if (phoneState.countdownTimer) return;
1914
+ // Re-render every minute so countdown chips age in place. Cheap — no
1915
+ // network, just DOM. Pauses when tab not visible (see visibilitychange).
1916
+ phoneState.countdownTimer = setInterval(() => {
1917
+ if (document.visibilityState !== 'visible') return;
1918
+ if (phoneState.data) renderPhoneNumbers(phoneState.data);
1919
+ }, 60000);
1920
+ }
1921
+
1922
+ function stopPhoneCountdown() {
1923
+ if (phoneState.countdownTimer) {
1924
+ clearInterval(phoneState.countdownTimer);
1925
+ phoneState.countdownTimer = null;
1926
+ }
1927
+ }
1928
+
1929
+ document.querySelector('[data-tab="phone"]')?.addEventListener('click', () => {
1930
+ // Ask once for notification permission when the user first opens the tab.
1931
+ // We never auto-prompt on page load — that would be annoying.
1932
+ if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
1933
+ Notification.requestPermission().catch(() => {});
1934
+ }
1935
+ loadPhone({});
1936
+ startPhoneCountdown();
1937
+ });
1938
+
1939
+ document.addEventListener('tab:deactivated', (e) => {
1940
+ if (e.detail && e.detail.name === 'phone') stopPhoneCountdown();
1941
+ });
1942
+
1943
+ document.getElementById('phone-refresh-btn')?.addEventListener('click', () => loadPhone({ force: true }));
1944
+ document.getElementById('phone-buy-btn')?.addEventListener('click', buyPhoneNumber);
1945
+
1946
+ // Prime the nav badge so an expiring number is visible even before the user
1947
+ // clicks into the Phone tab. Cached read — no network cost.
1948
+ loadPhone({});
1949
+
1547
1950
  loadOverview();
1548
1951
  loadSessions();
1549
1952
  loadMarkets();