vibedate 0.8.0 → 0.8.2

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.
package/dist/cli.d.ts CHANGED
@@ -44,5 +44,10 @@ declare function formatAgo(iso: string, now?: Date): string;
44
44
  * Interactive TTY behavior is unchanged: Ctrl+D / EOF still exits.
45
45
  */
46
46
  declare function shouldKeepAlive(flag: boolean, stdinIsTTY: boolean | undefined): boolean;
47
+ declare function parseSendCommand(text: string): {
48
+ path: string;
49
+ } | {
50
+ error: 'missing_path';
51
+ } | null;
47
52
 
48
- export { type Command, type ParsedArgs, formatAgo, parseArgs, shouldKeepAlive };
53
+ export { type Command, type ParsedArgs, formatAgo, parseArgs, parseSendCommand, shouldKeepAlive };
package/dist/cli.js CHANGED
@@ -56,6 +56,9 @@ import process2 from "process";
56
56
  // src/server.ts
57
57
  import http from "http";
58
58
  import { randomUUID } from "crypto";
59
+ import fs from "fs";
60
+ import os from "os";
61
+ import path from "path";
59
62
  import { makeEvent, notify as vibeCoreNotify } from "@pooriaarab/vibe-core";
60
63
 
61
64
  // src/web-app-html.ts
@@ -599,15 +602,23 @@ var webAppHtml = `<!DOCTYPE html>
599
602
  max-width: 85%; padding: 7px 11px; border-radius: 13px;
600
603
  font-size: .82rem; line-height: 1.4; word-break: break-word; white-space: pre-wrap;
601
604
  }
605
+ .cp-msg img, .cp-msg video { max-width: 100%; border-radius: 8px; margin-top: 4px; display: block; }
606
+ .cp-msg a.media-link { color: var(--coral); text-decoration: underline; word-break: break-all; }
602
607
  .cp-msg.them{ align-self: flex-start; background: rgba(248,239,232,.07); border: 1px solid var(--border); }
603
608
  .cp-msg.you{ align-self: flex-end; background: rgba(255,122,104,.16); border: 1px solid rgba(255,122,104,.32); }
604
609
  .cp-msg.sys{ align-self: center; background: transparent; border: 0; color: var(--muted-2); font-size: .72rem; padding: 2px 6px; }
605
610
  .chat-panel .cp-inputrow{ display: flex; gap: 8px; padding: 10px; border-top: 1px solid var(--border); }
606
- .chat-panel .cp-inputrow input{
611
+ .chat-panel .cp-inputrow input[type="text"]{
607
612
  flex: 1; border: 1px solid var(--border-2); border-radius: 10px; background: rgba(0,0,0,.22);
608
613
  color: var(--fg); padding: 9px 11px; font: inherit; font-size: .82rem; outline: none; min-width: 0;
609
614
  }
610
- .chat-panel .cp-inputrow input:focus{ border-color: var(--coral); }
615
+ .chat-panel .cp-inputrow input[type="text"]:focus{ border-color: var(--coral); }
616
+ .chat-panel .cp-attach{
617
+ border: 1px solid var(--border-2); border-radius: 10px; background: rgba(0,0,0,.22);
618
+ color: var(--muted); padding: 9px 12px; font-size: 1rem; flex-shrink: 0;
619
+ transition: color var(--dur-fast) ease, border-color var(--dur-fast) ease;
620
+ }
621
+ .chat-panel .cp-attach:hover{ color: var(--fg); border-color: var(--muted-2); }
611
622
  .chat-panel .cp-send{
612
623
  border: 0; border-radius: 10px; padding: 9px 14px; font-weight: 700; font-size: .8rem;
613
624
  background: linear-gradient(180deg, var(--coral), var(--coral-dim)); color: #2a1109;
@@ -615,6 +626,187 @@ var webAppHtml = `<!DOCTYPE html>
615
626
  }
616
627
  .chat-panel .cp-send:hover{ filter: brightness(1.06); }
617
628
  .chat-panel .cp-send:active{ transform: scale(.95); }
629
+
630
+ /* ---- you identity chip (header) ---- */
631
+ .you-chip{
632
+ display:none; align-items:center; gap:10px;
633
+ padding: 6px 12px 6px 7px; border-radius: 999px;
634
+ background: rgba(0,0,0,.28); border: 1px solid var(--border-2);
635
+ max-width: min(420px, 52vw); min-width: 0;
636
+ }
637
+ .you-chip.is-on{ display:inline-flex; }
638
+ .you-chip .yc-avatar{
639
+ width:30px; height:30px; border-radius:50%; overflow:hidden; flex-shrink:0;
640
+ border: 1px solid var(--border-2); background: var(--bg-1);
641
+ }
642
+ .you-chip .yc-avatar svg{ width:100%; height:100%; display:block; }
643
+ .you-chip .yc-meta{ min-width:0; display:flex; flex-direction:column; gap:1px; }
644
+ .you-chip .yc-handle{
645
+ font-size:.78rem; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
646
+ }
647
+ .you-chip .yc-sub{
648
+ font-size:.68rem; color: var(--muted-2); display:flex; align-items:center; gap:6px; flex-wrap:wrap;
649
+ }
650
+ .topbar-right{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; }
651
+
652
+ /* ---- identity / status marks ---- */
653
+ .mark{
654
+ display:inline-flex; align-items:center; gap:3px;
655
+ font-size:.66rem; font-weight:700; letter-spacing:.01em;
656
+ padding: 2px 7px; border-radius:999px; line-height:1.3; white-space:nowrap;
657
+ }
658
+ .mark-verified{
659
+ color: #bff2df; background: rgba(127,227,192,.12); border: 1px solid rgba(127,227,192,.32);
660
+ }
661
+ .mark-identity{
662
+ color: #e8d9ff; background: rgba(203,168,255,.12); border: 1px solid rgba(203,168,255,.32);
663
+ }
664
+ .mark-demo{
665
+ color: var(--muted-2); background: rgba(248,239,232,.05); border: 1px solid var(--border);
666
+ text-transform: uppercase; letter-spacing: .06em;
667
+ }
668
+ .mark-live{
669
+ color: #ffd0c8; background: rgba(255,122,104,.12); border: 1px solid rgba(255,122,104,.35);
670
+ }
671
+ .lg-pill{
672
+ display:inline-flex; align-items:center; gap:5px;
673
+ font-size:.7rem; font-weight:700; padding: 3px 9px; border-radius:999px;
674
+ }
675
+
676
+ /* ---- people column (live + demo) ---- */
677
+ .people-col{ display:flex; flex-direction:column; gap: 22px; min-width: 0; }
678
+ .section-head{
679
+ display:flex; align-items:baseline; justify-content:space-between; gap:10px;
680
+ margin: 0 0 12px;
681
+ }
682
+ .section-head h2{
683
+ font-size: 1.02rem; font-weight: 700; margin:0;
684
+ display:flex; align-items:center; gap:8px;
685
+ }
686
+ .section-head .count{
687
+ font-size:.72rem; font-weight:700; color: var(--muted-2);
688
+ background: rgba(0,0,0,.22); border: 1px solid var(--border);
689
+ padding: 3px 8px; border-radius:999px;
690
+ }
691
+ .section-sub{
692
+ font-size:.78rem; color: var(--muted-2); margin: -6px 0 12px; line-height:1.45;
693
+ }
694
+
695
+ /* Live now roster cards */
696
+ .live-roster{ display:flex; flex-direction:column; gap:10px; }
697
+ .live-card{
698
+ display:flex; align-items:center; gap:12px;
699
+ background: linear-gradient(165deg, rgba(255,122,104,.08), transparent 55%),
700
+ linear-gradient(180deg, var(--bg-card), var(--bg-card-2));
701
+ border: 1px solid rgba(255,122,104,.22);
702
+ border-radius: 16px; padding: 14px 14px;
703
+ box-shadow: var(--shadow-1);
704
+ transition: border-color var(--dur-fast) ease, transform var(--dur-fast) var(--ease-out);
705
+ }
706
+ .live-card:hover{ border-color: rgba(255,122,104,.4); transform: translateY(-1px); }
707
+ .live-card .lc-avatar{
708
+ width:48px; height:48px; border-radius:50%; overflow:hidden; flex-shrink:0;
709
+ border:1px solid var(--border-2); background:var(--bg-1);
710
+ }
711
+ .live-card .lc-avatar svg{ width:100%; height:100%; display:block; }
712
+ .live-card .lc-body{ flex:1; min-width:0; }
713
+ .live-card .lc-handle-row{
714
+ display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin-bottom:4px;
715
+ }
716
+ .live-card .lc-handle{ font-weight:700; font-size:.92rem; word-break:break-all; }
717
+ .live-card .lc-meta{
718
+ display:flex; align-items:center; gap:6px; flex-wrap:wrap;
719
+ font-size:.72rem; color: var(--muted-2);
720
+ }
721
+ .live-card .lc-actions{ display:flex; flex-direction:column; gap:6px; flex-shrink:0; }
722
+ .live-card .lc-actions button{
723
+ border:0; border-radius:10px; padding:8px 14px; font-weight:700; font-size:.76rem;
724
+ min-width: 72px;
725
+ transition: transform var(--dur-fast) var(--ease-out), filter var(--dur-fast) ease, border-color var(--dur-fast) ease, color var(--dur-fast) ease;
726
+ }
727
+ .live-card .lc-actions button:active{ transform: scale(.96); }
728
+ .live-card .btn-call{
729
+ background: linear-gradient(180deg, var(--coral), var(--coral-dim)); color: #2a1109;
730
+ box-shadow: 0 8px 18px -10px rgba(255,122,104,.55);
731
+ }
732
+ .live-card .btn-call:hover{ filter: brightness(1.06); }
733
+ .live-card .btn-call:disabled{ opacity:.4; cursor:not-allowed; filter:none; box-shadow:none; }
734
+ .live-card .btn-chat{
735
+ background: transparent; color: var(--muted); border: 1px solid var(--border-2);
736
+ }
737
+ .live-card .btn-chat:hover{ color: var(--fg); border-color: var(--muted-2); }
738
+ .live-card .btn-chat.has-unread{
739
+ color: var(--coral); border-color: rgba(255,122,104,.45);
740
+ background: rgba(255,122,104,.08);
741
+ }
742
+
743
+ .live-empty{
744
+ border: 1px dashed var(--border-2); border-radius: 16px;
745
+ padding: 18px 16px; text-align:center;
746
+ background: rgba(0,0,0,.14);
747
+ }
748
+ .live-empty strong{ display:block; font-size:.9rem; margin-bottom:4px; }
749
+ .live-empty p{ margin:0; font-size:.8rem; color: var(--muted-2); line-height:1.45; }
750
+
751
+ .live-now-wrap.has-live .section-head h2 .pulse-live{
752
+ width:8px; height:8px; border-radius:50%; background: var(--coral);
753
+ box-shadow: 0 0 0 3px rgba(255,122,104,.2);
754
+ animation: pulse-dot 2s ease-in-out infinite; display:inline-block;
755
+ }
756
+
757
+ /* Demo badge on match cards */
758
+ .mc-demo-badge{
759
+ position:absolute; top:14px; right:14px; z-index:2;
760
+ }
761
+ .match-card{ position:absolute; /* ensure badge anchors */ }
762
+ .mc-handle-row{ display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
763
+ .demo-note{
764
+ font-size:.74rem; color: var(--muted-2); margin: 0 0 10px; line-height:1.4;
765
+ }
766
+ .demo-note b{ color: var(--muted); font-weight:600; }
767
+
768
+ /* Stack polish when demo secondary */
769
+ .stack-wrap.is-secondary{ opacity: .92; }
770
+ .people-col .stack-wrap{ height: 430px; }
771
+
772
+ /* Video / room / chat polish */
773
+ .video-modal .vtile video{
774
+ box-shadow: 0 24px 60px -28px rgba(0,0,0,.8), 0 0 0 1px rgba(255,122,104,.12);
775
+ }
776
+ .video-modal .vlabel{
777
+ background: rgba(0,0,0,.35); padding: 4px 10px; border-radius:999px;
778
+ border: 1px solid var(--border);
779
+ }
780
+ .room-video .rv-tile video{
781
+ box-shadow: 0 14px 36px -20px rgba(0,0,0,.7);
782
+ }
783
+ .room-video .rv-head{
784
+ background: rgba(0,0,0,.28); border: 1px solid var(--border);
785
+ border-radius: 12px; padding: 10px 14px;
786
+ }
787
+ .chat-panel{
788
+ border-color: rgba(255,122,104,.18);
789
+ }
790
+ .chat-panel .cp-head{
791
+ background: rgba(0,0,0,.18);
792
+ }
793
+ .live-panel, .room-panel{
794
+ border-color: rgba(255,122,104,.18);
795
+ }
796
+ .live-panel .lp-title .lp-live-count,
797
+ .room-panel .rp-title .rp-count{
798
+ margin-left:auto; font-size:.68rem; font-weight:700; color: var(--muted-2);
799
+ }
800
+
801
+ @media (max-width: 1020px){
802
+ .you-chip{ max-width: 100%; }
803
+ }
804
+ @media (max-width: 480px){
805
+ .live-card{ flex-wrap: wrap; }
806
+ .live-card .lc-actions{ flex-direction: row; width: 100%; }
807
+ .live-card .lc-actions button{ flex:1; }
808
+ }
809
+
618
810
  </style>
619
811
  </head>
620
812
  <body>
@@ -624,7 +816,16 @@ var webAppHtml = `<!DOCTYPE html>
624
816
  <span class="name">vibe<span>dating</span></span>
625
817
  <span class="tag">matched by how hard you push the model</span>
626
818
  </div>
627
- <div class="local-badge"><span class="dot" aria-hidden="true"></span> raw usage stays local &middot; only league shared</div>
819
+ <div class="topbar-right">
820
+ <div class="you-chip" id="youChip" aria-live="polite">
821
+ <div class="yc-avatar" id="youChipAvatar"></div>
822
+ <div class="yc-meta">
823
+ <div class="yc-handle" id="youChipHandle">@you</div>
824
+ <div class="yc-sub" id="youChipSub"></div>
825
+ </div>
826
+ </div>
827
+ <div class="local-badge"><span class="dot" aria-hidden="true"></span> raw usage stays local &middot; only league shared</div>
828
+ </div>
628
829
  </header>
629
830
 
630
831
  <div class="hero">
@@ -718,12 +919,27 @@ var webAppHtml = `<!DOCTYPE html>
718
919
  </div>
719
920
  </section>
720
921
 
721
- <section class="panel" aria-label="Match stack">
722
- <h2 class="panel-title">Match stack</h2>
723
- <div class="stack-wrap">
724
- <div class="card-stack" id="cardStack"></div>
725
- </div>
726
- </section>
922
+ <div class="people-col panel" aria-label="People">
923
+ <section class="live-now-wrap" id="liveNowWrap" aria-label="Live now">
924
+ <div class="section-head">
925
+ <h2><span class="pulse-live" aria-hidden="true"></span> Live now</h2>
926
+ <span class="count" id="liveNowCount">0</span>
927
+ </div>
928
+ <p class="section-sub">Real discovered peers on the wire &mdash; usage-verified and identity marks from their hello.</p>
929
+ <div class="live-roster" id="liveRoster"></div>
930
+ </section>
931
+
932
+ <section aria-label="Demo profiles">
933
+ <div class="section-head">
934
+ <h2>Demo profiles</h2>
935
+ <span class="mark mark-demo" title="Seeded local pool \u2014 not real people">demo</span>
936
+ </div>
937
+ <p class="demo-note">Seeded local filler from <b>CANDIDATES</b> &mdash; not live peers. Swipe to practice matching by league.</p>
938
+ <div class="stack-wrap" id="demoStackWrap">
939
+ <div class="card-stack" id="cardStack"></div>
940
+ </div>
941
+ </section>
942
+ </div>
727
943
 
728
944
  <section class="panel" aria-label="League ladder">
729
945
  <h2 class="panel-title">League ladder</h2>
@@ -759,10 +975,10 @@ var webAppHtml = `<!DOCTYPE html>
759
975
  </div>
760
976
  </div>
761
977
 
762
- <aside class="live-panel" id="livePanel" aria-label="Live peers">
763
- <div class="lp-title"><span class="lp-dot" aria-hidden="true"></span> Live peers</div>
978
+ <aside class="live-panel" id="livePanel" aria-label="Live peers compact">
979
+ <div class="lp-title"><span class="lp-dot" aria-hidden="true"></span> Live now <span class="lp-live-count" id="lpLiveCount"></span></div>
764
980
  <div id="liveRows"></div>
765
- <div class="lp-legend">&#10003; usage verified &middot; &#128273; identity verified &mdash; call someone, or wait for a call</div>
981
+ <div class="lp-legend">&#10003; usage verified &middot; &#128273; identity verified &mdash; real peers only (not demo)</div>
766
982
  </aside>
767
983
 
768
984
  <div class="incoming-call" id="incomingCall" role="dialog" aria-modal="true" aria-label="Incoming call">
@@ -780,12 +996,14 @@ var webAppHtml = `<!DOCTYPE html>
780
996
  <div class="cp-head">
781
997
  <div>
782
998
  <div class="cp-title" id="chatTitle">@peer</div>
783
- <div class="cp-sub" id="chatSub">live over the P2P link &middot; text only</div>
999
+ <div class="cp-sub" id="chatSub">live peer &middot; P2P text</div>
784
1000
  </div>
785
1001
  <button class="cp-close" id="chatClose" type="button" aria-label="Close chat">&times;</button>
786
1002
  </div>
787
1003
  <div class="cp-msgs" id="chatMsgs"></div>
788
1004
  <div class="cp-inputrow">
1005
+ <button class="cp-attach" id="chatAttachBtn" type="button" aria-label="Attach file" title="Attach file">\u{1F4CE}</button>
1006
+ <input type="file" id="chatFileInput" style="display:none;">
789
1007
  <input id="chatInput" type="text" maxlength="4000" placeholder="message&hellip;" autocomplete="off">
790
1008
  <button class="cp-send" id="chatSend" type="button">Send</button>
791
1009
  </div>
@@ -798,7 +1016,7 @@ var webAppHtml = `<!DOCTYPE html>
798
1016
  </div>
799
1017
 
800
1018
  <aside class="room-panel" id="roomPanel" aria-label="Room">
801
- <div class="rp-title"><span class="lp-dot" aria-hidden="true"></span> Room</div>
1019
+ <div class="rp-title"><span class="lp-dot" aria-hidden="true"></span> Room <span class="rp-count" id="rpCount"></span></div>
802
1020
  <div class="rp-meta" id="roomMeta">not in a room</div>
803
1021
  <div class="rp-actions">
804
1022
  <button class="rp-btn" id="roomJoinBtn" type="button">Join video</button>
@@ -855,6 +1073,12 @@ var webAppHtml = `<!DOCTYPE html>
855
1073
  function fmt(n){ return Math.round(n).toLocaleString("en-US"); }
856
1074
  function variantFor(handle){ var h=0; for (var i=0;i<handle.length;i++){ h=(h*31+handle.charCodeAt(i))>>>0; } return h % BLOB_PATHS.length; }
857
1075
 
1076
+ // Shared with the live A/V script (separate IIFE) \u2014 roster cards need avatars + league colors.
1077
+ window.__vdAvatarSVG = avatarSVG;
1078
+ window.__vdVariantFor = variantFor;
1079
+ window.__vdLeagueById = leagueById;
1080
+ window.__vdLeagueColorHex = leagueColorHex;
1081
+
858
1082
  var state = { connected:false };
859
1083
  var stackIndex = 0;
860
1084
  var provider = "claude-code";
@@ -919,17 +1143,54 @@ var webAppHtml = `<!DOCTYPE html>
919
1143
  state = s || { connected:false };
920
1144
  if (state.connected){
921
1145
  renderReveal(state);
1146
+ renderYouChip(state);
922
1147
  renderYouMarker(state.league);
923
1148
  stackIndex = 0;
924
1149
  renderStack();
925
1150
  showStep("reveal");
926
1151
  } else {
1152
+ renderYouChip(null);
927
1153
  stackIndex = 0;
928
1154
  renderStack();
929
1155
  showStep("idle");
930
1156
  }
931
1157
  }
932
1158
 
1159
+ function renderYouChip(s){
1160
+ var chip = document.getElementById("youChip");
1161
+ if (!chip) return;
1162
+ if (!s || !s.connected){
1163
+ chip.classList.remove("is-on");
1164
+ return;
1165
+ }
1166
+ var lg = leagueById(s.league);
1167
+ var hex = lg ? lg.hex : "#ffcf6b";
1168
+ document.getElementById("youChipAvatar").innerHTML = avatarSVG(3, hex);
1169
+ document.getElementById("youChipHandle").textContent = s.handle || "@you";
1170
+ var sub = document.getElementById("youChipSub");
1171
+ sub.innerHTML = "";
1172
+ var pill = document.createElement("span");
1173
+ pill.className = "lg-pill";
1174
+ pill.style.background = "color-mix(in srgb, " + hex + " 16%, transparent)";
1175
+ pill.style.border = "1px solid color-mix(in srgb, " + hex + " 40%, transparent)";
1176
+ pill.style.color = hex;
1177
+ pill.textContent = lg ? lg.label : ((s.league || "?") + " League");
1178
+ sub.appendChild(pill);
1179
+ if (s.verified){
1180
+ var vm = document.createElement("span");
1181
+ vm.className = "mark mark-verified";
1182
+ vm.textContent = "\u2713 verified";
1183
+ sub.appendChild(vm);
1184
+ } else {
1185
+ var lm = document.createElement("span");
1186
+ lm.className = "mark mark-demo";
1187
+ lm.textContent = "local";
1188
+ lm.title = "Usage read locally / self-reported";
1189
+ sub.appendChild(lm);
1190
+ }
1191
+ chip.classList.add("is-on");
1192
+ }
1193
+
933
1194
  function renderReveal(s){
934
1195
  var lg = leagueById(s.league);
935
1196
  var hex = lg ? lg.hex : "#ffcf6b";
@@ -979,6 +1240,7 @@ var webAppHtml = `<!DOCTYPE html>
979
1240
  rawToggle.setAttribute("aria-expanded","false");
980
1241
  rawToggle.textContent = "show raw usage (visible only to you)";
981
1242
  document.querySelectorAll(".you-marker").forEach(function(m){ m.remove(); });
1243
+ renderYouChip(null);
982
1244
  stackIndex = 0;
983
1245
  renderStack();
984
1246
  showStep("idle");
@@ -1026,8 +1288,8 @@ var webAppHtml = `<!DOCTYPE html>
1026
1288
  if (!state.connected){
1027
1289
  empty.innerHTML = "<strong>Connect to see matches.</strong><p>Read your usage to get sorted into a league and start matching.</p>";
1028
1290
  } else {
1029
- empty.innerHTML = "<strong>That's everyone in range.</strong><p>You've matched through today's pool. It refreshes with the next billing cycle.</p>" +
1030
- '<button class="btn btn-primary" id="btnRestart" type="button" style="width:auto;padding:11px 20px;">Start over</button>';
1291
+ empty.innerHTML = "<strong>End of demo pool.</strong><p>These were seeded demo profiles \u2014 not live peers. Real people show up under Live now.</p>" +
1292
+ '<button class="btn btn-primary" id="btnRestart" type="button" style="width:auto;padding:11px 20px;">Restart demo</button>';
1031
1293
  }
1032
1294
  cardStackEl.appendChild(empty);
1033
1295
  var restart = empty.querySelector("#btnRestart");
@@ -1043,12 +1305,13 @@ var webAppHtml = `<!DOCTYPE html>
1043
1305
  var label = lg ? lg.label : (cand.league + " League");
1044
1306
  var bio = cand.bio || [];
1045
1307
  el.innerHTML =
1308
+ '<span class="mc-demo-badge mark mark-demo" title="Seeded demo profile \u2014 not a live peer">demo</span>' +
1046
1309
  '<div class="mc-top">' +
1047
1310
  '<div class="mc-avatar">' + avatarSVG(variantFor(cand.handle), hex) + '</div>' +
1048
1311
  '<div>' +
1049
- '<div class="mc-handle">' + cand.handle + '</div>' +
1312
+ '<div class="mc-handle-row"><div class="mc-handle">' + cand.handle + '</div></div>' +
1050
1313
  '<span class="mc-league" style="background:color-mix(in srgb,' + hex + ' 16%, transparent);border:1px solid color-mix(in srgb,' + hex + ' 40%, transparent);color:' + hex + '">' + label + '</span>' +
1051
- '<div class="mc-verified">&#10003; usage verified</div>' +
1314
+ '<div class="mc-verified" style="color:var(--muted-2)">seeded local profile</div>' +
1052
1315
  '</div>' +
1053
1316
  '</div>' +
1054
1317
  '<div class="mc-bio"><p>' + (bio[0] || "") + '</p><p>' + (bio[1] || "") + '</p></div>' +
@@ -1056,7 +1319,7 @@ var webAppHtml = `<!DOCTYPE html>
1056
1319
  '<div class="mc-actions">' +
1057
1320
  '<button class="round-btn pass" type="button" title="Pass">&#10005;</button>' +
1058
1321
  '<button class="round-btn like" type="button" title="Like">&#9829;</button>' +
1059
- '<span class="hint"></span>' +
1322
+ '<span class="hint">demo</span>' +
1060
1323
  '</div>' : '');
1061
1324
  cardStackEl.appendChild(el);
1062
1325
  if (depth === 0){
@@ -1304,6 +1567,8 @@ var webAppHtml = `<!DOCTYPE html>
1304
1567
  var chatSub = document.getElementById("chatSub");
1305
1568
  var chatMsgs = document.getElementById("chatMsgs");
1306
1569
  var chatInput = document.getElementById("chatInput");
1570
+ var chatAttachBtn = document.getElementById("chatAttachBtn");
1571
+ var chatFileInput = document.getElementById("chatFileInput");
1307
1572
  var chatSend = document.getElementById("chatSend");
1308
1573
  var chatClose = document.getElementById("chatClose");
1309
1574
 
@@ -1311,6 +1576,7 @@ var webAppHtml = `<!DOCTYPE html>
1311
1576
  var conversations = {}; // handle -> [{from:"you"|"them"|"sys", text}]
1312
1577
  var unread = {}; // handle -> count of unseen incoming messages
1313
1578
  var chatLoops = {}; // handle -> true while its poll loop is running
1579
+ var mediaLoops = {}; // handle -> true while its poll loop is running
1314
1580
  var MAX_CHAT_KEPT = 200; // per-conversation local cap (mirrors the server)
1315
1581
 
1316
1582
  function fetchMessage(handle, timeoutMs){
@@ -1321,6 +1587,14 @@ var webAppHtml = `<!DOCTYPE html>
1321
1587
  .catch(function(e){ clearTimeout(t); throw e; });
1322
1588
  }
1323
1589
 
1590
+ function fetchMedia(handle, timeoutMs){
1591
+ var ctrl = new AbortController();
1592
+ var t = setTimeout(function(){ ctrl.abort(); }, timeoutMs);
1593
+ return fetch("/live/media?handle=" + encodeURIComponent(handle), { signal: ctrl.signal })
1594
+ .then(function(r){ clearTimeout(t); return r; })
1595
+ .catch(function(e){ clearTimeout(t); throw e; });
1596
+ }
1597
+
1324
1598
  function postChat(handle, text){
1325
1599
  return fetch("/live/message", {
1326
1600
  method: "POST",
@@ -1329,6 +1603,14 @@ var webAppHtml = `<!DOCTYPE html>
1329
1603
  });
1330
1604
  }
1331
1605
 
1606
+ function postMedia(handle, name, mime, dataB64){
1607
+ return fetch("/live/media", {
1608
+ method: "POST",
1609
+ headers: { "content-type": "application/json" },
1610
+ body: JSON.stringify({ handle: handle, name: name, mime: mime, dataB64: dataB64 })
1611
+ });
1612
+ }
1613
+
1332
1614
  function peerKnown(handle){
1333
1615
  for (var i = 0; i < knownPeers.length; i++){ if (knownPeers[i].handle === handle) return true; }
1334
1616
  return false;
@@ -1343,27 +1625,51 @@ var webAppHtml = `<!DOCTYPE html>
1343
1625
  // One long-poll loop per connected peer: started when the peer is first
1344
1626
  // seen, stopped when it vanishes (its mailbox on the server is gone).
1345
1627
  function ensureChatLoop(handle){
1346
- if (chatLoops[handle]) return;
1347
- chatLoops[handle] = true;
1348
- (async function(){
1349
- while (peerKnown(handle)) {
1350
- var res;
1351
- try { res = await fetchMessage(handle, 30000); }
1352
- catch(e){ await sleep(1000); continue; }
1353
- if (!res || res.status !== 200) { await sleep(1000); continue; }
1354
- var data = await res.json();
1355
- var m = data && data.message;
1356
- if (!m) continue; // timed out empty
1357
- pushChat(handle, { from: "them", text: m.text });
1358
- if (chatWith === handle) {
1359
- renderChat();
1360
- } else {
1361
- unread[handle] = (unread[handle] || 0) + 1;
1362
- renderLiveRows(knownPeers);
1628
+ if (!chatLoops[handle]) {
1629
+ chatLoops[handle] = true;
1630
+ (async function(){
1631
+ while (peerKnown(handle)) {
1632
+ var res;
1633
+ try { res = await fetchMessage(handle, 30000); }
1634
+ catch(e){ await sleep(1000); continue; }
1635
+ if (!res || res.status !== 200) { await sleep(1000); continue; }
1636
+ var data = await res.json();
1637
+ var m = data && data.message;
1638
+ if (!m) continue; // timed out empty
1639
+ pushChat(handle, { from: "them", text: m.text });
1640
+ if (chatWith === handle) {
1641
+ renderChat();
1642
+ } else {
1643
+ unread[handle] = (unread[handle] || 0) + 1;
1644
+ renderLiveRows(knownPeers);
1645
+ }
1363
1646
  }
1364
- }
1365
- delete chatLoops[handle];
1366
- })();
1647
+ delete chatLoops[handle];
1648
+ })();
1649
+ }
1650
+
1651
+ if (!mediaLoops[handle]) {
1652
+ mediaLoops[handle] = true;
1653
+ (async function(){
1654
+ while (peerKnown(handle)) {
1655
+ var res;
1656
+ try { res = await fetchMedia(handle, 30000); }
1657
+ catch(e){ await sleep(1000); continue; }
1658
+ if (!res || res.status !== 200) { await sleep(1000); continue; }
1659
+ var data = await res.json();
1660
+ var m = data && data.media;
1661
+ if (!m) continue; // timed out empty
1662
+ pushChat(handle, { from: "them", media: m });
1663
+ if (chatWith === handle) {
1664
+ renderChat();
1665
+ } else {
1666
+ unread[handle] = (unread[handle] || 0) + 1;
1667
+ renderLiveRows(knownPeers);
1668
+ }
1669
+ }
1670
+ delete mediaLoops[handle];
1671
+ })();
1672
+ }
1367
1673
  }
1368
1674
 
1369
1675
  function renderChat(){
@@ -1379,8 +1685,30 @@ var webAppHtml = `<!DOCTYPE html>
1379
1685
  conv.forEach(function(m){
1380
1686
  var el = document.createElement("div");
1381
1687
  el.className = "cp-msg " + (m.from === "you" ? "you" : m.from === "sys" ? "sys" : "them");
1382
- // textContent only \u2014 a peer's text is never parsed as HTML.
1383
- el.textContent = m.text;
1688
+ if (m.media) {
1689
+ var isImage = m.media.mime.indexOf("image/") === 0;
1690
+ var isVideo = m.media.mime.indexOf("video/") === 0;
1691
+ if (isImage) {
1692
+ var img = document.createElement("img");
1693
+ img.src = "data:" + m.media.mime + ";base64," + m.media.dataB64;
1694
+ el.appendChild(img);
1695
+ } else if (isVideo) {
1696
+ var vid = document.createElement("video");
1697
+ vid.src = "data:" + m.media.mime + ";base64," + m.media.dataB64;
1698
+ vid.controls = true;
1699
+ el.appendChild(vid);
1700
+ } else {
1701
+ var a = document.createElement("a");
1702
+ a.className = "media-link";
1703
+ a.href = "data:" + (m.media.mime || "application/octet-stream") + ";base64," + m.media.dataB64;
1704
+ a.download = m.media.name;
1705
+ a.textContent = "\u{1F4CE} " + m.media.name;
1706
+ el.appendChild(a);
1707
+ }
1708
+ } else {
1709
+ // textContent only \u2014 a peer's text is never parsed as HTML.
1710
+ el.textContent = m.text;
1711
+ }
1384
1712
  chatMsgs.appendChild(el);
1385
1713
  });
1386
1714
  chatMsgs.scrollTop = chatMsgs.scrollHeight;
@@ -1391,7 +1719,7 @@ var webAppHtml = `<!DOCTYPE html>
1391
1719
  delete unread[handle];
1392
1720
  chatTitle.textContent = handle;
1393
1721
  chatSub.textContent = peerKnown(handle)
1394
- ? "live over the P2P link \xB7 text only"
1722
+ ? "live peer \xB7 P2P text \xB7 not a demo profile"
1395
1723
  : "peer offline \xB7 messages will not deliver";
1396
1724
  chatPanel.classList.add("is-open");
1397
1725
  renderChat();
@@ -1422,17 +1750,148 @@ var webAppHtml = `<!DOCTYPE html>
1422
1750
  chatSend.addEventListener("click", sendChat);
1423
1751
  chatInput.addEventListener("keydown", function(e){ if (e.key === "Enter") sendChat(); });
1424
1752
 
1753
+ chatAttachBtn.addEventListener("click", function(){
1754
+ if (!chatWith) return;
1755
+ chatFileInput.click();
1756
+ });
1757
+ chatFileInput.addEventListener("change", function(e){
1758
+ var target = chatWith;
1759
+ if (!target) return;
1760
+ var file = e.target.files[0];
1761
+ if (!file) return;
1762
+ chatFileInput.value = ""; // clear
1763
+ var reader = new FileReader();
1764
+ reader.onload = function(evt) {
1765
+ var res = evt.target.result; // data:mime;base64,.....
1766
+ var mime = res.split(';')[0].split(':')[1] || '';
1767
+ var b64 = res.split(',')[1];
1768
+ postMedia(target, file.name, mime, b64).then(function(r){
1769
+ pushChat(target, r && r.status === 200
1770
+ ? { from: "you", media: { name: file.name, mime: mime, dataB64: b64 } }
1771
+ : { from: "sys", text: "(file not delivered \u2014 too large or peer offline?)" });
1772
+ if (chatWith === target) renderChat();
1773
+ }).catch(function(){
1774
+ pushChat(target, { from: "sys", text: "(file not delivered \u2014 server unreachable)" });
1775
+ if (chatWith === target) renderChat();
1776
+ });
1777
+ };
1778
+ reader.readAsDataURL(file);
1779
+ });
1780
+
1425
1781
  // Render the live-peers rows: one per connected peer with its verification
1426
1782
  // marks, a Chat button (opens the conversation), and a Call button that
1427
1783
  // rings THAT peer specifically.
1784
+ // Marks from the peer's hello: \u2713 usage verified (real logs), \u{1F511} identity (signed).
1785
+ function peerMarksHtml(p){
1786
+ var html = "";
1787
+ if (p.verified === true) html += '<span class="mark mark-verified" title="Usage verified from real local logs">\u2713 verified</span>';
1788
+ if (p.identityVerified === true) html += '<span class="mark mark-identity" title="Identity signature verified">\u{1F511} identity</span>';
1789
+ return html;
1790
+ }
1791
+
1792
+ function leaguePillHtml(leagueId){
1793
+ var FALLBACK = { "1M":"#d69a6e","5M":"#cfd8e6","10M":"#ffcf6b","100M":"#f0839c","1B+":"#cba8ff" };
1794
+ var LABELS = { "1M":"1M League","5M":"5M League","10M":"10M League","100M":"100M League","1B+":"1B+ League" };
1795
+ var lg = typeof window.__vdLeagueById === "function" ? window.__vdLeagueById(leagueId) : null;
1796
+ var hex = lg ? lg.hex : (FALLBACK[leagueId] || "#ffcf6b");
1797
+ var label = lg ? lg.label : (LABELS[leagueId] || ((leagueId || "?") + " League"));
1798
+ return '<span class="lg-pill" style="background:color-mix(in srgb,' + hex + ' 16%, transparent);border:1px solid color-mix(in srgb,' + hex + ' 40%, transparent);color:' + hex + '">' + label + '</span>';
1799
+ }
1800
+
1801
+ function avatarForPeer(handle, leagueId){
1802
+ var FALLBACK = { "1M":"#d69a6e","5M":"#cfd8e6","10M":"#ffcf6b","100M":"#f0839c","1B+":"#cba8ff" };
1803
+ var hex = (typeof window.__vdLeagueColorHex === "function")
1804
+ ? window.__vdLeagueColorHex(leagueId)
1805
+ : (FALLBACK[leagueId] || "#ffcf6b");
1806
+ if (typeof window.__vdAvatarSVG === "function" && typeof window.__vdVariantFor === "function") {
1807
+ return window.__vdAvatarSVG(window.__vdVariantFor(handle), hex);
1808
+ }
1809
+ return "";
1810
+ }
1811
+
1812
+ // Compact dock rows (floating panel) + full Live now roster cards (main column).
1428
1813
  function renderLiveRows(peers){
1429
1814
  liveRows.innerHTML = "";
1815
+ var roster = document.getElementById("liveRoster");
1816
+ var wrap = document.getElementById("liveNowWrap");
1817
+ var countEl = document.getElementById("liveNowCount");
1818
+ var lpCount = document.getElementById("lpLiveCount");
1819
+ var demoWrap = document.getElementById("demoStackWrap");
1820
+
1821
+ if (countEl) countEl.textContent = String(peers.length);
1822
+ if (lpCount) lpCount.textContent = peers.length ? (peers.length + " online") : "";
1823
+ if (wrap) wrap.classList.toggle("has-live", peers.length > 0);
1824
+ if (demoWrap) demoWrap.classList.toggle("is-secondary", peers.length > 0);
1825
+
1826
+ if (roster) {
1827
+ roster.innerHTML = "";
1828
+ if (peers.length === 0) {
1829
+ var empty = document.createElement("div");
1830
+ empty.className = "live-empty";
1831
+ empty.innerHTML = "<strong>No live peers right now</strong><p>When someone on your league topic connects, they show up here \u2014 clearly separate from the demo pool below.</p>";
1832
+ roster.appendChild(empty);
1833
+ } else {
1834
+ peers.forEach(function(p){
1835
+ var card = document.createElement("article");
1836
+ card.className = "live-card";
1837
+
1838
+ var av = document.createElement("div");
1839
+ av.className = "lc-avatar";
1840
+ av.innerHTML = avatarForPeer(p.handle, p.league);
1841
+
1842
+ var body = document.createElement("div");
1843
+ body.className = "lc-body";
1844
+ var handleRow = document.createElement("div");
1845
+ handleRow.className = "lc-handle-row";
1846
+ var handleEl = document.createElement("div");
1847
+ handleEl.className = "lc-handle";
1848
+ handleEl.textContent = p.handle;
1849
+ handleRow.appendChild(handleEl);
1850
+ var liveMark = document.createElement("span");
1851
+ liveMark.className = "mark mark-live";
1852
+ liveMark.textContent = "live";
1853
+ handleRow.appendChild(liveMark);
1854
+ body.appendChild(handleRow);
1855
+
1856
+ var meta = document.createElement("div");
1857
+ meta.className = "lc-meta";
1858
+ meta.innerHTML = leaguePillHtml(p.league) + peerMarksHtml(p) +
1859
+ '<span style="opacity:.75">' + (p.harness || "") + "</span>";
1860
+ body.appendChild(meta);
1861
+
1862
+ var actions = document.createElement("div");
1863
+ actions.className = "lc-actions";
1864
+ var chatBtn = document.createElement("button");
1865
+ chatBtn.className = "btn-chat" + (unread[p.handle] ? " has-unread" : "");
1866
+ chatBtn.type = "button";
1867
+ chatBtn.textContent = "Chat" + (unread[p.handle] ? " (" + unread[p.handle] + ")" : "");
1868
+ chatBtn.addEventListener("click", function(){ openChat(p.handle); });
1869
+ var callBtn = document.createElement("button");
1870
+ callBtn.className = "btn-call";
1871
+ callBtn.type = "button";
1872
+ callBtn.textContent = "Call";
1873
+ callBtn.disabled = !!(rtc.remoteHandle || pendingOffer);
1874
+ callBtn.addEventListener("click", function(){
1875
+ if (rtc.remoteHandle || pendingOffer) return;
1876
+ callBtn.disabled = true;
1877
+ startCallAsOfferer(p.handle).catch(function(){ hangup(); });
1878
+ });
1879
+ actions.appendChild(chatBtn);
1880
+ actions.appendChild(callBtn);
1881
+
1882
+ card.appendChild(av);
1883
+ card.appendChild(body);
1884
+ card.appendChild(actions);
1885
+ roster.appendChild(card);
1886
+ });
1887
+ }
1888
+ }
1889
+
1890
+ // Compact floating dock \u2014 still handy when scrolled away from the roster.
1430
1891
  peers.forEach(function(p){
1431
1892
  var row = document.createElement("div");
1432
1893
  row.className = "live-row";
1433
1894
  var who = document.createElement("div");
1434
- // Marks from the peer's hello, shown only when present: \u2713 usage
1435
- // verified (real local logs), \u{1F511} identity-verified (signed hello).
1436
1895
  var marks = (p.verified === true ? " \u2713" : "") + (p.identityVerified === true ? " \u{1F511}" : "");
1437
1896
  var h = document.createElement("div"); h.className = "h"; h.textContent = p.handle + marks;
1438
1897
  var s = document.createElement("div"); s.className = "s"; s.textContent = p.league + " \xB7 " + p.harness;
@@ -1447,9 +1906,8 @@ var webAppHtml = `<!DOCTYPE html>
1447
1906
  var btn = document.createElement("button");
1448
1907
  btn.className = "vbtn"; btn.type = "button"; btn.textContent = "Call";
1449
1908
  btn.addEventListener("click", function(){
1450
- if (rtc.remoteHandle || pendingOffer) return; // already in a call / prompt
1909
+ if (rtc.remoteHandle || pendingOffer) return;
1451
1910
  btn.disabled = true;
1452
- // DIRECTED call: the rtc-offer is relayed only to this peer's handle.
1453
1911
  startCallAsOfferer(p.handle).catch(function(){ hangup(); });
1454
1912
  });
1455
1913
  actions.appendChild(chatBtn);
@@ -1470,14 +1928,16 @@ var webAppHtml = `<!DOCTYPE html>
1470
1928
  peers.forEach(function(p){ ensureChatLoop(p.handle); });
1471
1929
  if (chatWith) {
1472
1930
  chatSub.textContent = peerKnown(chatWith)
1473
- ? "live over the P2P link \xB7 text only"
1931
+ ? "live peer \xB7 P2P text"
1474
1932
  : "peer offline \xB7 messages will not deliver";
1475
1933
  }
1476
- if (peers.length === 0) { livePanel.classList.remove("is-open"); return; }
1477
- livePanel.classList.add("is-open");
1478
1934
  renderLiveRows(peers);
1935
+ if (peers.length === 0) { livePanel.classList.remove("is-open"); }
1936
+ else { livePanel.classList.add("is-open"); }
1479
1937
  } catch(e){}
1480
1938
  }
1939
+ // Initial empty state for the main Live now section, then poll.
1940
+ renderLiveRows([]);
1481
1941
  refreshPeers();
1482
1942
  setInterval(refreshPeers, 4000);
1483
1943
 
@@ -1718,6 +2178,8 @@ var webAppHtml = `<!DOCTYPE html>
1718
2178
  roomMeta.textContent = roomName +
1719
2179
  (roomVid.self ? " \xB7 you " + roomVid.self : "") +
1720
2180
  " \xB7 " + n + " other" + (n === 1 ? "" : "s");
2181
+ var rpCount = document.getElementById("rpCount");
2182
+ if (rpCount) rpCount.textContent = n ? (n + " other" + (n === 1 ? "" : "s")) : "empty";
1721
2183
  roomPanel.classList.add("is-open");
1722
2184
  roomJoinBtn.disabled = roomVid.joined || !roomVid.self;
1723
2185
  roomLeaveBtn.disabled = !roomVid.joined;
@@ -1750,7 +2212,7 @@ function createLiveBridge() {
1750
2212
  },
1751
2213
  addLink(link) {
1752
2214
  const handle2 = link.hello.handle;
1753
- boxes.set(handle2, { link, incoming: [], messages: [] });
2215
+ boxes.set(handle2, { link, incoming: [], messages: [], media: [] });
1754
2216
  link.onSignal((f) => {
1755
2217
  const mb = boxes.get(handle2);
1756
2218
  if (mb) mb.incoming.push(f);
@@ -1763,6 +2225,20 @@ function createLiveBridge() {
1763
2225
  mb.messages.splice(0, mb.messages.length - MAX_QUEUED_MESSAGES);
1764
2226
  }
1765
2227
  });
2228
+ link.onMedia((m) => {
2229
+ const mb = boxes.get(handle2);
2230
+ if (!mb) {
2231
+ fs.unlink(m.path, () => {
2232
+ });
2233
+ return;
2234
+ }
2235
+ mb.media.push({ ...m, name: sanitizePeerText(m.name), mime: sanitizePeerText(m.mime) });
2236
+ if (mb.media.length > MAX_QUEUED_MESSAGES) {
2237
+ const dropped = mb.media.splice(0, mb.media.length - MAX_QUEUED_MESSAGES);
2238
+ for (const d of dropped) fs.unlink(d.path, () => {
2239
+ });
2240
+ }
2241
+ });
1766
2242
  link.onClose(() => {
1767
2243
  const cur = boxes.get(handle2);
1768
2244
  if (cur && cur.link === link) boxes.delete(handle2);
@@ -1799,6 +2275,39 @@ function createLiveBridge() {
1799
2275
  if (cur.messages.length > 0) return cur.messages.shift() ?? null;
1800
2276
  }
1801
2277
  return null;
2278
+ },
2279
+ async sendMedia(handle2, filePath) {
2280
+ const mb = boxes.get(handle2);
2281
+ if (!mb) throw new Error("Peer not found");
2282
+ await mb.link.sendMedia(filePath);
2283
+ },
2284
+ async pollMedia(handle2, timeoutMs) {
2285
+ const mb = boxes.get(handle2);
2286
+ if (!mb) return null;
2287
+ let targetMedia = null;
2288
+ if (mb.media.length > 0) {
2289
+ targetMedia = mb.media.shift() ?? null;
2290
+ } else {
2291
+ const deadline = Date.now() + timeoutMs;
2292
+ while (Date.now() < deadline) {
2293
+ await new Promise((r) => setTimeout(r, 100));
2294
+ const cur = boxes.get(handle2);
2295
+ if (!cur) return null;
2296
+ if (cur.media.length > 0) {
2297
+ targetMedia = cur.media.shift() ?? null;
2298
+ break;
2299
+ }
2300
+ }
2301
+ }
2302
+ if (!targetMedia) return null;
2303
+ try {
2304
+ const buf = await fs.promises.readFile(targetMedia.path);
2305
+ await fs.promises.unlink(targetMedia.path).catch(() => {
2306
+ });
2307
+ return { name: targetMedia.name, mime: targetMedia.mime, dataB64: buf.toString("base64") };
2308
+ } catch (err) {
2309
+ return null;
2310
+ }
1802
2311
  }
1803
2312
  };
1804
2313
  return bridge;
@@ -1916,10 +2425,18 @@ function send(res, status, contentType, body) {
1916
2425
  function sendJson(res, status, data) {
1917
2426
  send(res, status, "application/json; charset=utf-8", JSON.stringify(data));
1918
2427
  }
1919
- function readBody(req) {
2428
+ function readBody(req, maxLength) {
1920
2429
  return new Promise((resolve, reject) => {
1921
2430
  const chunks = [];
1922
- req.on("data", (c) => chunks.push(c));
2431
+ let len = 0;
2432
+ req.on("data", (c) => {
2433
+ chunks.push(c);
2434
+ len += c.length;
2435
+ if (maxLength !== void 0 && len > maxLength) {
2436
+ req.destroy();
2437
+ reject(new Error("payload too large"));
2438
+ }
2439
+ });
1923
2440
  req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
1924
2441
  req.on("error", reject);
1925
2442
  });
@@ -2110,6 +2627,69 @@ async function handle(req, res, opts) {
2110
2627
  sendJson(res, 200, { ok: true });
2111
2628
  return;
2112
2629
  }
2630
+ if (req.method === "GET" && pathname === "/live/media") {
2631
+ const live = opts.live;
2632
+ if (!live) {
2633
+ sendJson(res, 200, { media: null, reason: "live-not-attached" });
2634
+ return;
2635
+ }
2636
+ const handle2 = url.searchParams.get("handle") ?? "";
2637
+ if (handle2 === "") {
2638
+ sendJson(res, 400, { error: "missing handle" });
2639
+ return;
2640
+ }
2641
+ const media = await live.pollMedia(handle2, 25e3);
2642
+ if (req.destroyed || res.writableEnded) return;
2643
+ sendJson(res, 200, { media });
2644
+ return;
2645
+ }
2646
+ if (req.method === "POST" && pathname === "/live/media") {
2647
+ const live = opts.live;
2648
+ if (!live) {
2649
+ sendJson(res, 400, { error: "live-not-attached" });
2650
+ return;
2651
+ }
2652
+ let body;
2653
+ try {
2654
+ body = await readBody(req, 35 * 1024 * 1024);
2655
+ } catch (e) {
2656
+ sendJson(res, 413, { error: "payload too large" });
2657
+ return;
2658
+ }
2659
+ let parsed = {};
2660
+ try {
2661
+ parsed = JSON.parse(body);
2662
+ } catch {
2663
+ sendJson(res, 400, { error: "invalid JSON body" });
2664
+ return;
2665
+ }
2666
+ const handle2 = typeof parsed["handle"] === "string" ? parsed["handle"] : "";
2667
+ const name = typeof parsed["name"] === "string" ? parsed["name"] : "";
2668
+ const mime = typeof parsed["mime"] === "string" ? parsed["mime"] : "";
2669
+ const dataB64 = typeof parsed["dataB64"] === "string" ? parsed["dataB64"] : "";
2670
+ if (handle2 === "" || name === "" || mime === "" || dataB64 === "") {
2671
+ sendJson(res, 400, { error: "missing handle, name, mime, or dataB64" });
2672
+ return;
2673
+ }
2674
+ const decoded = Buffer.from(dataB64, "base64");
2675
+ if (decoded.length > 25 * 1024 * 1024) {
2676
+ sendJson(res, 413, { error: "file exceeds 25MiB limit" });
2677
+ return;
2678
+ }
2679
+ const tmpPath = path.join(os.tmpdir(), `vibe-media-send-${randomUUID()}`);
2680
+ await fs.promises.writeFile(tmpPath, decoded);
2681
+ try {
2682
+ await live.sendMedia(handle2, tmpPath);
2683
+ } catch (err) {
2684
+ sendJson(res, 500, { error: err.message });
2685
+ return;
2686
+ } finally {
2687
+ await fs.promises.unlink(tmpPath).catch(() => {
2688
+ });
2689
+ }
2690
+ sendJson(res, 200, { ok: true });
2691
+ return;
2692
+ }
2113
2693
  if (req.method === "GET" && pathname === "/api/room") {
2114
2694
  const room = opts.room;
2115
2695
  sendJson(res, 200, room ? { room: room.name, self: room.self ?? null, members: room.members } : { room: null, self: null, members: [] });
@@ -2204,7 +2784,7 @@ async function handle(req, res, opts) {
2204
2784
  }
2205
2785
 
2206
2786
  // src/cli.ts
2207
- var VERSION = "0.8.0";
2787
+ var VERSION = "0.8.2";
2208
2788
  function parsePort(raw) {
2209
2789
  const n = Number(raw);
2210
2790
  if (!Number.isInteger(n) || n < 1 || n > 65535) return void 0;
@@ -2630,6 +3210,16 @@ async function cmdOpen(port, any, room) {
2630
3210
  function shouldKeepAlive(flag, stdinIsTTY) {
2631
3211
  return flag || stdinIsTTY !== true;
2632
3212
  }
3213
+ function parseSendCommand(text) {
3214
+ const parts = text.split(" ");
3215
+ const cmd = parts[0];
3216
+ if (cmd === "/send" || cmd === "/file" || cmd === "/image") {
3217
+ const path2 = text.slice(cmd.length).trim();
3218
+ if (!path2) return { error: "missing_path" };
3219
+ return { path: path2 };
3220
+ }
3221
+ return null;
3222
+ }
2633
3223
  async function cmdLiveViaRelay(profile, to) {
2634
3224
  const target = to !== void 0 ? normalizeHandle(to) : null;
2635
3225
  if (to !== void 0 && target === null) {
@@ -2799,6 +3389,12 @@ async function cmdLive(dating, any, to, keepAlive, viaRelay) {
2799
3389
  process2.stdout.write(` \u2605 found ${sanitizePeerText(link.hello.handle)} \u2014 auto-opening
2800
3390
  `);
2801
3391
  }
3392
+ link.onMedia((m) => {
3393
+ process2.stdout.write(
3394
+ ` \u{1F4CE} <${sanitizePeerText(link.hello.handle)}> sent ${sanitizePeerText(m.name)} \u2014 saved to ${m.path}
3395
+ `
3396
+ );
3397
+ });
2802
3398
  pairing.add(link);
2803
3399
  }
2804
3400
  });
@@ -2806,7 +3402,7 @@ async function cmdLive(dating, any, to, keepAlive, viaRelay) {
2806
3402
  ` topic: ${TOPIC_PREFIX}${profile.league} \u2192 ${session.topic.toString("hex").slice(0, 12)}\u2026${topics.length > 1 ? ` (+${topics.length - 1} more)` : ""}
2807
3403
  `
2808
3404
  );
2809
- process2.stdout.write(" type to chat \xB7 /next \xB7 /open <handle> \xB7 /quit\n");
3405
+ process2.stdout.write(" type to chat \xB7 /send <path> \xB7 /next \xB7 /open <handle> \xB7 /quit\n");
2810
3406
  process2.stdout.write(" video chat: live A/V runs in the web app \u2014 run `vibedating open`\n\n");
2811
3407
  const rl = readline.createInterface({ input: process2.stdin, terminal: false });
2812
3408
  const stop = () => {
@@ -2829,6 +3425,28 @@ async function cmdLive(dating, any, to, keepAlive, viaRelay) {
2829
3425
  const handle2 = text.slice("/open ".length).trim();
2830
3426
  if (pairing.open(handle2) === void 0) {
2831
3427
  process2.stdout.write(` \xB7 no available peer "${handle2}"
3428
+ `);
3429
+ }
3430
+ continue;
3431
+ }
3432
+ const sendRes = parseSendCommand(text);
3433
+ if (sendRes !== null) {
3434
+ if ("error" in sendRes) {
3435
+ process2.stdout.write(" usage: /send <path>\n");
3436
+ continue;
3437
+ }
3438
+ const cur3 = pairing.current();
3439
+ if (cur3 === void 0) {
3440
+ process2.stdout.write(" \xB7 no peer yet \u2014 waiting for a match\u2026\n");
3441
+ continue;
3442
+ }
3443
+ try {
3444
+ process2.stdout.write(` \u{1F4CE} sending ${sendRes.path}\u2026
3445
+ `);
3446
+ await cur3.sendMedia(sendRes.path);
3447
+ process2.stdout.write(" \u2713 sent\n");
3448
+ } catch (err) {
3449
+ process2.stdout.write(` \u2717 failed to send media: ${err instanceof Error ? err.message : String(err)}
2832
3450
  `);
2833
3451
  }
2834
3452
  continue;
@@ -3303,5 +3921,6 @@ if (entryUrl !== void 0) {
3303
3921
  export {
3304
3922
  formatAgo,
3305
3923
  parseArgs,
3924
+ parseSendCommand,
3306
3925
  shouldKeepAlive
3307
3926
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibedate",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Dating by tokens — matched by usage league across agentic CLIs. Raw usage stays local; only your league is shared. CLI + local web app + MCP. Local-first.",
5
5
  "type": "module",
6
6
  "license": "MIT",