diff --git a/SECURITY.md b/SECURITY.md
index 063f804..ea330a6 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -8,4 +8,4 @@ Streaming is relayed through [`us.proxy.sune.chat`](https://github.com/sune-org/
**What it doesn't do:** no prompt logging (your messages are held in memory only while the run is in progress; the generated reply stays in memory for 90 minutes after it ends so you can reconnect, and nothing is written to disk), no reading your chats, no storing keys, no third-party sharing.
-**What I see:** an [ntfy](https://ntfy.sh) ping when a run ends/fails, containing only: the run's start time (the timestamp half of its ID, not the secret half), `[provider/model]`, duration, error message (if any), and the sune's non-empty generation settings (temperature, top_p, top_k, frequency/repetition penalty, min_p, top_a, max tokens, reasoning effort, show thoughts, image output/aspect ratio/size). No prompts (system or master prompts included), no responses, no IP, no key. Source is public — audit it [here](https://github.com/sune-org/us.proxy.sune.chat).
+**What I see:** an [ntfy](https://ntfy.sh) ping when a run ends/fails, containing only: a short hash of the run's ID (not the ID itself), `[provider/model]`, duration, error message (if any), and the sune's non-empty generation settings (temperature, top_p, top_k, frequency/repetition penalty, min_p, top_a, max tokens, reasoning effort, show thoughts, image output/aspect ratio/size). No prompts (system or master prompts included), no responses, no IP, no key. Source is public — audit it [here](https://github.com/sune-org/us.proxy.sune.chat).
diff --git a/src/main.js b/src/main.js
index af8f81b..2662359 100644
--- a/src/main.js
+++ b/src/main.js
@@ -73,7 +73,7 @@ const renderUserUI=window.renderUserUI=()=>{if(!el.userMenuAvatar)return;const a
const getSuneLabel=m=>{const name=(m&&m.sune_name)||SUNE.name,modelShort=getModelShort(m&&m.model);return `${name} · ${modelShort}`}
function _createMessageRow(m){const role=typeof m==='string'?m:(m&&m.role)||'assistant',meta=typeof m==='string'?{}:m||{},isUser=role==='user',$row=$('
'),$head=$(''),$avatar=$('');const uAva=isUser?USER.avatar:meta.avatar;uAva?$avatar.attr('class','msg-avatar shrink-0 h-7 w-7 rounded-full overflow-hidden').html(``):$avatar.attr('class',`${isUser?'bg-gray-900 text-white':'bg-gray-200 text-gray-900'} msg-avatar shrink-0 h-7 w-7 rounded-full flex items-center justify-center`).text(isUser?'👤':'✺');const $name=$('').text(isUser?USER.name:getSuneLabel(meta));const $deleteBtn=$('').on('click',async e=>{e.stopPropagation();state.messages=state.messages.filter(msg=>msg.id!==m.id);$row.remove();dropReply(m.id);await THREAD.persist()});const $copyBtn=$('').on('click',async function(e){e.stopPropagation();const cur=state.messages.find(x=>x.id===m.id)||m;if(await copyToClipboard(partsToText(cur,true))){$(this).html('');icons();setTimeout(()=>{$(this).html('');icons()},1200)}});$head.append($avatar,$name,$copyBtn,$deleteBtn);const $bubble=$(``);$row.append($head,$bubble);const vids=(Array.isArray(meta.content)?meta.content:[]).filter(p=>p?.type==='video_url');if(vids.length)$row.append(videoStrip(vids));return $row}
function msgRow(m){const $row=_createMessageRow(m);$(el.messages).append($row);queueMicrotask(()=>{el.chat.scrollTo({top:el.chat.scrollHeight,behavior:'smooth'});icons()});return $row.find('.msg-bubble')[0]}
-const addMessage=window.addMessage=function(m,track=true){m.id=m.id||gid();if(!Array.isArray(m.content)&&m.content!=null){m.content=[{type:'text',text:String(m.content)}]}const bubble=msgRow(m);bubble.dataset.mid=m.id;renderMarkdown(bubble,partsToText(m));if(track)state.messages.push(m);if(m.role==='assistant')el.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:m}}));return bubble}
+const addMessage=window.addMessage=function(m,track=true){m.id=m.id||gid();if(m.role==='assistant')m.ts??=Date.now();if(!Array.isArray(m.content)&&m.content!=null){m.content=[{type:'text',text:String(m.content)}]}const bubble=msgRow(m);bubble.dataset.mid=m.id;renderMarkdown(bubble,partsToText(m));if(track)state.messages.push(m);if(m.role==='assistant')el.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:m}}));return bubble}
const addSuneBubbleStreaming=(meta,id)=>msgRow(Object.assign({role:'assistant',id},meta))
const clearChat=()=>{el.suneHtml.dispatchEvent(new CustomEvent('sune:unmount'));state.messages=[];el.messages.innerHTML='';clearAttachments();revokeVideoUrls();syncBtn()}
const payloadWithSampling=b=>{const o=Object.assign({},b),s=SUNE,p={temperature:num(s.temperature,null),top_p:num(s.top_p,null),top_k:int(s.top_k,null),frequency_penalty:num(s.frequency_penalty,null),repetition_penalty:num(s.repetition_penalty,null),min_p:num(s.min_p,null),top_a:num(s.top_a,null),max_tokens:int(s.max_tokens,null)>0?int(s.max_tokens,null):null};Object.keys(p).forEach(k=>{const v=p[k];if(v!==null)o[k]=v});return o}
@@ -138,7 +138,7 @@ $(el.composer).on('paste',async e=>{
addAttachments(files);
})
// Shared by send and SUNE.infer: adds the assistant bubble and starts its stream
-function reply(){const a=SUNE.active,meta={sune_name:a.name,model:SUNE.model,avatar:a.avatar||''},id=sid(),msg=Object.assign({id,role:'assistant',content:[{type:'text',text:''}]},meta,{stream:{seq:-1}}),b=addSuneBubbleStreaming(meta,id);b.dataset.mid=id;b.innerHTML=SUNE_LOGO_SVG;state.messages.push(msg);THREAD.persist(false);startReply(msg)}
+function reply(){const a=SUNE.active,meta={sune_name:a.name,model:SUNE.model,avatar:a.avatar||''},id=sid(),msg=Object.assign({id,role:'assistant',ts:Date.now(),content:[{type:'text',text:''}]},meta,{stream:{seq:-1}}),b=addSuneBubbleStreaming(meta,id);b.dataset.mid=id;b.innerHTML=SUNE_LOGO_SVG;state.messages.push(msg);THREAD.persist(false);startReply(msg)}
// The lock is taken before any await so a double tap can't send twice; a tap on the button just after it flipped from Stop is ignored
$(el.composer).on('submit',async e=>{e.preventDefault();if(state.busy||sending||(e.submitter===el.sendBtn&&Date.now()-btnSwapAt<400))return;state.abortRequested=false;sending=true;try{await attachQueue;const text=el.input.value.trim();if(!text&&!state.attachments.length){sending=false;return await SUNE.infer()}el.input.value='';const parts=[];if(text)parts.push({type:'text',text});parts.push(...state.attachments);state.attachments=[];updateAttachBadge();el.fileInput.value='';await ensureThreadOnFirstUser(text||'(attachments)');const th=THREAD.active,shouldGenTitle=th&&!th.title;const userMsg={role:'user',content:parts.length?parts:[{type:'text',text:text||'(sent attachments)'}]};addMessage(userMsg);el.composer.dispatchEvent(new CustomEvent('user:send',{detail:{message:userMsg}}));if(shouldGenTitle)(async()=>{const title=await generateTitleWithAI(state.messages)||partsToText(state.messages.find(m=>m.role==='user')).replace(/!\[\]\(data:[^\)]+\)/g,'[Image]')||'Untitled';await THREAD.setTitle(th.id,title)})();if(SUNE.model)reply()}finally{sending=false}})
let jars={html:null,extension:null};
diff --git a/src/utils.js b/src/utils.js
index fb127a7..cc8b25c 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -8,8 +8,8 @@ export const positionPopover = (a, p) => {
p.style.top = `${r.bottom + p.offsetHeight + 4 > window.innerHeight ? r.top - p.offsetHeight - 4 : r.bottom + 4}px`;
p.style.left = `${Math.max(8, Math.min(r.right - p.offsetWidth, window.innerWidth - p.offsetWidth - 8))}px`;
};
-// Reply ids double as the proxy's run id, so after the ms timestamp come 128 random bits
-export const sid = () => Date.now().toString(36) + '-' + [...crypto.getRandomValues(new Uint8Array(16))].map(b => b.toString(16).padStart(2, '0')).join('');
+// Reply ids double as the proxy's run id, so they're 128 random bits as unpadded base64url
+export const sid = () => btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16)))).replace(/[+/=]/g, c => ({ '+': '-', '/': '_' })[c] || '');
export const fmtSize = b => {
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0, x = b;