@openchambery/web 1.19.14 → 1.19.15-beta.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.
Files changed (60) hide show
  1. package/dist/assets/AssistantView-Bp4j2j2j.js +2 -0
  2. package/dist/assets/{DiagramView-CovEl1BY.js → DiagramView-OdVPLt6s.js} +1 -1
  3. package/dist/assets/MarkdownRenderer-4weeFDNg.js +2 -0
  4. package/dist/assets/{MarkdownRendererImpl-CCfHY-86.js → MarkdownRendererImpl-D9O0gsEc.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-y6QgIUZA.js → MarkstreamRendererImpl-ovXFLt9J.js} +1 -1
  6. package/dist/assets/MobileDetailNavigation-BYzib11p.js +1 -0
  7. package/dist/assets/{MobileShareBridge-B4S7BaUj.js → MobileShareBridge-QUhimU7K.js} +1 -1
  8. package/dist/assets/MobileSurface-zxMVX0-y.js +2 -0
  9. package/dist/assets/{MultiRunWindow-BeHa-CG6.js → MultiRunWindow-CzG_tdV0.js} +1 -1
  10. package/dist/assets/{SettingsView-Dn7odtQz.js → SettingsView-Dr-d6ryG.js} +3 -3
  11. package/dist/assets/{SettingsWindow-C0tWPS5f.js → SettingsWindow-BI4lg5NY.js} +1 -1
  12. package/dist/assets/{TerminalView-BnJgC7Qo.js → TerminalView-C6Wqo-mQ.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-D9S-X8hM.js → ToolOutputDialog-DEIsCH48.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-DBrvaJnq.js → appThemeRegistry-nDZxiGjG.js} +1 -1
  15. package/dist/assets/{fileMentionTouchSelection-Dc004xOh.js → fileMentionTouchSelection-C4twfQKY.js} +1 -1
  16. package/dist/assets/{gitApi-Cm3JLQqh.js → gitApi-BRZeUzHF.js} +1 -1
  17. package/dist/assets/{insertionBoundaries-CTWYFsbH.js → insertionBoundaries-CK0Nv-Vf.js} +1 -1
  18. package/dist/assets/{main-DwMprEyr.js → main-BRIfvcsR.js} +2 -2
  19. package/dist/assets/{main-CSqu3MB4.js → main-w3PEAPHR.js} +4 -4
  20. package/dist/assets/{miniChat-C8bzY_Ye.js → miniChat-DSuRbtqh.js} +2 -2
  21. package/dist/assets/{mobile-Oy-SRKkr.js → mobile-B9Ek0XAw.js} +2 -2
  22. package/dist/assets/{multirun-ClcuaiKU.js → multirun-Bh4P5g3q.js} +1 -1
  23. package/dist/assets/{projectMeta-F93wE2Ye.js → projectMeta-H0Ghy4GZ.js} +1 -1
  24. package/dist/assets/{renderElectronMiniChatApp-rOOhMVlQ.js → renderElectronMiniChatApp-DJQvuapb.js} +2 -2
  25. package/dist/assets/renderMobileApp-BP2iltJl.js +28 -0
  26. package/dist/assets/{runtimeConfig-iJ2mTBVN.js → runtimeConfig-AZn89Pi3.js} +1 -1
  27. package/dist/assets/{runtimeEndpointReset-ISnhAS3q.js → runtimeEndpointReset-B86-1ID-.js} +1 -1
  28. package/dist/assets/sessionChangeSummary-BG7l2heP.js +2 -0
  29. package/dist/assets/{sessionLookup-DQQ9-DuP.js → sessionLookup-DqMZlsfX.js} +1 -1
  30. package/dist/assets/src-CnLJ1J1M.css +2 -0
  31. package/dist/assets/{useAppFontEffects--IcU7jyv.js → useAppFontEffects-DgMhWd3K.js} +5 -5
  32. package/dist/assets/{useDesktopWindowControlsLayout-BqJ5R_T2.js → useDesktopWindowControlsLayout-CEs-_qs8.js} +1 -1
  33. package/dist/assets/{useEffectiveDirectory-ChWXV8xM.js → useEffectiveDirectory-LPMarhQP.js} +1 -1
  34. package/dist/assets/{useMobileNavigationStore-DQkgxuzg.js → useMobileNavigationStore-C02t_wmj.js} +2 -2
  35. package/dist/assets/{useSessionAutoCleanup-DWWAvQQ0.js → useSessionAutoCleanup-DdXvH0A6.js} +1 -1
  36. package/dist/assets/{useWorkerHighlightedLines-D2oQszsk.js → useWorkerHighlightedLines-4HwUoeTn.js} +1 -1
  37. package/dist/index.html +3 -3
  38. package/dist/mini-chat.html +3 -3
  39. package/dist/mobile.html +3 -3
  40. package/package.json +1 -1
  41. package/server/lib/assistants/DOCUMENTATION.md +33 -12
  42. package/server/lib/assistants/contact-store.js +25 -1
  43. package/server/lib/assistants/contact-store.test.js +39 -5
  44. package/server/lib/assistants/contact-tools.js +3 -3
  45. package/server/lib/assistants/harness.js +136 -101
  46. package/server/lib/assistants/harness.test.js +235 -8
  47. package/server/lib/assistants/pi-tools.js +2 -0
  48. package/server/lib/assistants/pi-tools.test.js +8 -0
  49. package/server/lib/assistants/service.js +313 -74
  50. package/server/lib/assistants/service.test.js +555 -18
  51. package/server/lib/llm/DOCUMENTATION.md +9 -4
  52. package/server/lib/llm/generate.js +45 -6
  53. package/server/lib/llm/generate.test.js +52 -0
  54. package/dist/assets/AssistantView-Dncbu_Z2.js +0 -2
  55. package/dist/assets/MarkdownRenderer-CZqok-Cf.js +0 -2
  56. package/dist/assets/MobileDetailNavigation-Ca-d5eZ9.js +0 -1
  57. package/dist/assets/MobileSurface-xL5Jby4U.js +0 -2
  58. package/dist/assets/renderMobileApp-BP_hGwHP.js +0 -28
  59. package/dist/assets/sessionChangeSummary-BAzU2J1H.js +0 -2
  60. package/dist/assets/src-DYeppLfP.css +0 -2
@@ -1,4 +1,4 @@
1
- import{o as e}from"./rolldown-runtime-C0FnF6B9.js";import{n as t}from"./runtime-fetch-zIalUnbb.js";import{Cn as n,tn as r}from"./persistence-DwRBC4zl.js";import{nt as i,rt as a}from"./vendor-base-ui-CnHfUmDN.js";import{n as o}from"./platform-DQ5_LNKO.js";import{_ as s}from"./useUIStore-CxOoCv7B.js";import{a as c,n as l}from"./vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js";import{n as u}from"./vendor-react-DLPElpoQ.js";import{n as d}from"./clipboard-GUUNlWIv.js";import{t as f}from"./utils-2ZK8yboQ.js";import{n as p}from"./Icon-D1BcR8Tz.js";import{t as m}from"./button-9R-WgVMl.js";import{i as h,n as g,t as _}from"./tooltip-DSjr26A_.js";import{n as v}from"./useThemeSystem-DMzWVJaW.js";import{n as y}from"./markdownTheme-DOfGg-A7.js";import{n as b}from"./MarkdownRenderer-CZqok-Cf.js";import{a as x,n as S,o as C,t as w}from"./markdown-worker-CptoICmq.js";import{a as T,i as E,o as D,t as ee}from"./imageSource-BlAJJUEI.js";import{t as te}from"./vendor-tanstack-react-virtual-tlDgH6PG.js";var O=e(a(),1),k=u(),A=i(),ne=`w-[min(30rem,calc(100cqw-1rem))] min-w-[280px] max-w-full overflow-hidden rounded-xl shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)] dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]`,j={maxWidth:`min(28rem, calc(100cqw - 4ch))`,backgroundColor:`var(--surface-elevated)`,color:`var(--surface-elevated-foreground)`},M=`flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] px-3 py-2.5`,N=`text-xs font-medium leading-4 text-foreground md:text-[0.8125rem] md:leading-5`,re=`text-xs tabular-nums leading-4 text-muted-foreground md:text-[0.8125rem] md:leading-5`,P=`m-0 list-none divide-y divide-[var(--surface-subtle)] p-0`,F=`${P} max-h-[min(22rem,50vh)] overflow-y-auto`,ie=`${P} w-full`,ae={className:`p-0 rounded-none`,maxHeightClass:`max-h-[46vh]`,disableHorizontal:!0},I=`px-2.5 py-1.5 text-xs leading-4 md:py-2 md:text-[0.8125rem] md:leading-5`,oe=`flex min-w-0 items-center gap-1 text-xs leading-4 text-muted-foreground md:gap-1.5 md:text-[0.8125rem] md:leading-5`,se=`min-w-0 truncate text-xs leading-4 text-foreground md:text-[0.8125rem] md:leading-5`,ce=`inline-flex shrink-0 items-center gap-1 text-xs leading-4 tabular-nums text-muted-foreground md:text-[0.8125rem] md:leading-5`,le=`h-3 w-3 shrink-0 md:h-3.5 md:w-3.5`,ue={in_progress:`text-foreground`,pending:`text-foreground`,completed:`text-muted-foreground line-through`,cancelled:`text-muted-foreground line-through`},L={in_progress:`chat.statusRow.todo.status.inProgress`,pending:`chat.statusRow.todo.status.pending`,completed:`chat.statusRow.todo.status.completed`,cancelled:`chat.statusRow.todo.status.cancelled`},R=e=>{let t=(0,k.c)(26),{todo:n,isActive:r,activeRef:i}=e,{t:a}=d(),o=z.has(n.status)?n.status:`pending`,s=L[o],c;t[0]!==a||t[1]!==s?(c=a(s),t[0]=a,t[1]=s,t[2]=c):c=t[2];let l=c,u;t[3]===o?u=t[4]:(u=o===`in_progress`?(0,A.jsx)(p,{name:`record-circle`,className:`h-3.5 w-3.5 text-[var(--status-info)]`,"aria-hidden":`true`}):o===`completed`?(0,A.jsx)(p,{name:`checkbox-circle`,className:`h-3.5 w-3.5 text-[var(--status-success)]`,"aria-hidden":`true`}):(0,A.jsx)(p,{name:`time`,className:`h-3.5 w-3.5 text-muted-foreground`,"aria-hidden":`true`}),t[3]=o,t[4]=u);let m=u,v=r?`step`:void 0,y=r&&`bg-[var(--surface-muted)] before:absolute before:inset-y-2 before:left-0 before:w-0.5 before:rounded-full before:bg-[var(--status-info)]`,b;t[5]===y?b=t[6]:(b=f(`group relative grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] items-start gap-2.5`,I,y),t[5]=y,t[6]=b);let x;t[7]!==m||t[8]!==l?(x=(0,A.jsx)(h,{asChild:!0,children:(0,A.jsx)(`span`,{className:`flex size-4 items-center justify-center`,"aria-label":l,children:m})}),t[7]=m,t[8]=l,t[9]=x):x=t[9];let S;t[10]===l?S=t[11]:(S=(0,A.jsx)(g,{side:`left`,sideOffset:6,children:l}),t[10]=l,t[11]=S);let C;t[12]!==x||t[13]!==S?(C=(0,A.jsxs)(_,{children:[x,S]}),t[12]=x,t[13]=S,t[14]=C):C=t[14];let w=ue[o],T;t[15]===w?T=t[16]:(T=f(`block whitespace-normal break-words`,w),t[15]=w,t[16]=T);let E;t[17]!==T||t[18]!==n.content?(E=(0,A.jsx)(`span`,{className:`min-w-0 flex-1`,children:(0,A.jsx)(`span`,{className:T,children:n.content})}),t[17]=T,t[18]=n.content,t[19]=E):E=t[19];let D;return t[20]!==i||t[21]!==E||t[22]!==v||t[23]!==b||t[24]!==C?(D=(0,A.jsxs)(`li`,{ref:i,"aria-current":v,className:b,children:[C,E]}),t[20]=i,t[21]=E,t[22]=v,t[23]=b,t[24]=C,t[25]=D):D=t[25],D},z=new Set([`in_progress`,`pending`,`completed`,`cancelled`]),B=`image/png`,V=c(`OpenChamberMedia`),H=e=>{let t=e.toLowerCase().split(`;`)[0]?.trim()||B;if(t===`image/jpeg`||t===`image/jpg`)return`jpg`;if(t===`image/webp`)return`webp`;if(t===`image/gif`)return`gif`;if(t===`image/svg+xml`)return`svg`;if(t===`image/png`)return`png`;let n=t.split(`/`)[1];return n&&/^[a-z0-9.+-]+$/i.test(n)?n:`png`},U=(e,t)=>{let n=`image.${H(t)}`,r=(((e||``).trim()||n).split(/[\\/]/).pop()||n).replace(/[^\w.\- ()[\]]+/g,`_`).replace(/^\.+/,``);return r?/\.[a-z0-9]{1,8}$/i.test(r)?r:`${r}.${H(t)}`:n},W=e=>new Promise((t,n)=>{let r=new FileReader;r.onload=()=>{if(typeof r.result==`string`){t(r.result);return}n(Error(`Failed to encode image`))},r.onerror=()=>n(r.error??Error(`Failed to encode image`)),r.readAsDataURL(e)}),de=async e=>{let t=await W(e),n=t.indexOf(`,`);return n>=0?t.slice(n+1):t},fe=(e,t)=>{let n=URL.createObjectURL(e),r=document.createElement(`a`);r.href=n,r.download=t,document.body.appendChild(r),r.click(),document.body.removeChild(r),window.setTimeout(()=>URL.revokeObjectURL(n),1e3)},G=async(e,t)=>{let n=await fetch(e,{signal:t,cache:`no-store`});if(!n.ok)throw Error(`Image fetch failed (${n.status})`);return n.blob()},pe=async(e,t=B)=>{if(!e.naturalWidth||!e.naturalHeight)return null;let n=e.currentSrc||e.src;if(n&&(/^(?:blob:|data:|openchamber-asset:)/i.test(n)||/^https?:/i.test(n)||/^\/api(?=\/|[?#]|$)/.test(n)))try{let e=await G(n);if(e.size>0)return e}catch{}try{let n=document.createElement(`canvas`);n.width=e.naturalWidth,n.height=e.naturalHeight;let r=n.getContext(`2d`);if(!r)return null;r.drawImage(e,0,0);let i=t.startsWith(`image/`)?t:B,a=await new Promise(e=>{n.toBlob(t=>e(t),i===`image/jpg`?`image/jpeg`:i)});return a&&a.size>0?a:null}catch{return null}},me=async(e,t)=>{let n=``;try{return n=await ee(e,t??new AbortController().signal),await G(n,t)}finally{n&&T(n)}},he=async(e,n)=>{let r=e.effectiveDirectory??``,i=null;e.prefetchedBlob&&e.prefetchedBlob.size>0&&(i=e.prefetchedBlob);let a=e.displayUrl?.trim();if(!i&&a)try{i=await G(a,n)}catch{i=null}if(!i){let a=e.sourceUrl.trim();if(!a)throw Error(`Image source is empty`);if(/^(?:data|blob):/i.test(a)||/^https?:/i.test(a)||/^\/api(?=\/|[?#]|$)/.test(a))i=await G(a,n);else{let e=D(a,r);if(E(e))try{i=await me(e.path,n)}catch{let r=await t(`/api/fs/raw`,{method:`GET`,cache:`no-store`,query:{path:e.path},signal:n});if(!r.ok)throw Error(`Image source request failed with status ${r.status}`);i=await r.blob()}else i=await G(e.source,n)}}if(!i||i.size<=0)throw Error(`Image is empty`);let o=(e.mimeType||(i.type&&i.type.startsWith(`image/`)?i.type:``)||B).split(`;`)[0].trim()||B;return{blob:i,filename:U(e.filename,o),mimeType:o}},ge=()=>!o()||l===void 0?!1:l.isPluginAvailable(`OpenChamberMedia`),_e=async(e,t)=>{let{blob:i,filename:a,mimeType:o}=await he(e,t);if(ge()){let e=await de(i);return await V.saveImage({dataBase64:e,mimeType:o,filename:a}),`saved`}if(r()){let e=n()?.vscode;if(e?.saveImage){let t=await W(i);return await e.saveImage({fileName:a,dataUrl:t}),`saved`}}return fe(i,a),`downloaded`},ve=e=>e.replace(/&/g,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`).replace(/'/g,`&#39;`),ye=e=>e?Object.entries(e).map(([e,t])=>t==null?``:`${e.startsWith(`--`)?e:e.replace(/[A-Z]/g,e=>`-${e.toLowerCase()}`)}:${typeof t==`number`?`${t}px`:t};`).join(``):``,K=(e,t,n)=>{let r=e.querySelector(`pre`);r&&(r.style.margin=`0`,r.style.background=`transparent`,r.style.whiteSpace=t?`pre-wrap`:`pre`,t&&(r.style.wordBreak=`break-word`,r.style.overflowWrap=`break-word`));let i=e.querySelector(`code`);if(i){let e=ye(n);e&&i.setAttribute(`style`,`${i.getAttribute(`style`)??``}${e}`)}},be=e=>`<pre><code>${ve(e)}</code></pre>`,xe=e=>{let t=(0,k.c)(21),{code:n,language:r,className:i,style:a,codeStyle:o,wrap:s}=e,c=s!==void 0&&s,{currentTheme:l}=v(),u=O.useRef(null),d;t[0]===l?d=t[1]:(d=y(l),t[0]=l,t[1]=d);let p=d,m,h;t[2]!==n||t[3]!==o||t[4]!==c?(m=()=>{let e=u.current;e&&(e.innerHTML=be(n),K(e,c,o))},h=[n,c,o],t[2]=n,t[3]=o,t[4]=c,t[5]=m,t[6]=h):(m=t[5],h=t[6]),O.useLayoutEffect(m,h);let g,_;t[7]!==n||t[8]!==o||t[9]!==r||t[10]!==c?(g=()=>{let e=u.current;if(!e)return;let t=!0,i=new AbortController;return w(n,(r||`text`).toLowerCase(),{signal:i.signal,priority:`visible`}).then(n=>{!t||!e||!n||(e.innerHTML=n,K(e,c,o))}),()=>{t=!1,i.abort()}},_=[n,r,c,o],t[7]=n,t[8]=o,t[9]=r,t[10]=c,t[11]=g,t[12]=_):(g=t[11],_=t[12]),O.useEffect(g,_);let b;t[13]===i?b=t[14]:(b=f(`typography-code`,i),t[13]=i,t[14]=b);let x=p,S;t[15]!==a||t[16]!==x?(S={...x,...a},t[15]=a,t[16]=x,t[17]=S):S=t[17];let C;return t[18]!==b||t[19]!==S?(C=(0,A.jsx)(`div`,{ref:u,className:b,style:S}),t[18]=b,t[19]=S,t[20]=C):C=t[20],C},Se=e=>{let t=e.replace(/^<file>\s*\n?/,``).replace(/\n?<\/file>\s*$/,``);return t=t.replace(/^\s*\d{5}\|\s?/gm,``),t.trim()},Ce=(e,t=``)=>{if(typeof e==`string`)return e;if(e==null)return t;if(typeof e==`number`||typeof e==`boolean`||typeof e==`bigint`)return String(e);try{return JSON.stringify(e)}catch{return t}},we=e=>e?e.includes(`<diagnostics`)||e.includes(`<file_diagnostics>`)||e.includes(`LSP errors detected`)||e.includes(`This file has errors`):!1,Te=e=>e?e.replace(/\n{0,2}LSP errors detected[\s\S]*?<diagnostics[^>]*>[\s\S]*?<\/diagnostics>/g,``).replace(/\n{0,2}This file has errors[\s\S]*?<\/file_diagnostics>/g,``).replace(/<diagnostics[^>]*>[\s\S]*?<\/diagnostics>/g,``).replace(/<file_diagnostics>[\s\S]*?<\/file_diagnostics>/g,``).trim():``,Ee=(e,t)=>!e||typeof e!=`object`?String(e):C(e,t||``),q=e=>{if(typeof e==`string`){let t=e.trim();return t.length>0?t:void 0}if(e&&typeof e==`object`){let t=e.patch;if(typeof t==`string`){let e=t.trim();return e.length>0?e:void 0}}},De=e=>{if(!e||typeof e!=`object`)return;let t=q(e.patch)??q(e.diff);if(t)return t;let n=Array.isArray(e.files)?e.files:[];for(let e of n){if(!e||typeof e!=`object`)continue;let t=q(e.patch)??q(e.diff);if(t)return t}},Oe=e=>{if(!e||typeof e!=`string`)return{data:null,isJson:!1};let t=e.trim();if(!t.startsWith(`{`)&&!t.startsWith(`[`)||!t.endsWith(`}`)&&!t.endsWith(`]`)||t.length<2)return{data:null,isJson:!1};try{let e=JSON.parse(t);return typeof e==`object`&&e?{data:e,isJson:!0}:{data:null,isJson:!1}}catch{return{data:null,isJson:!1}}},ke=(e,t,n)=>{let r=Se(e);if((t===`edit`||t===`multiedit`||t===`write`||t===`apply_patch`)&&we(r)&&(r=Te(r)),(t===`edit`||t===`multiedit`||t===`apply_patch`)&&r.trim().length===0){let e=De(n);if(e)return e}return r},Ae=e=>{let t=e.match(/<type>(file|directory)<\/type>/i)?.[1]?.toLowerCase()??`unknown`,n=(e.match(/<content>([\s\S]*?)<\/content>/i)?.[1]??e).replace(/\r\n/g,`
1
+ import{o as e}from"./rolldown-runtime-C0FnF6B9.js";import{n as t}from"./runtime-fetch-zIalUnbb.js";import{Cn as n,tn as r}from"./persistence-DwRBC4zl.js";import{nt as i,rt as a}from"./vendor-base-ui-CnHfUmDN.js";import{n as o}from"./platform-DQ5_LNKO.js";import{_ as s}from"./useUIStore-CxOoCv7B.js";import{a as c,n as l}from"./vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js";import{n as u}from"./vendor-react-DLPElpoQ.js";import{n as d}from"./clipboard-GUUNlWIv.js";import{t as f}from"./utils-2ZK8yboQ.js";import{n as p}from"./Icon-D1BcR8Tz.js";import{t as m}from"./button-9R-WgVMl.js";import{i as h,n as g,t as _}from"./tooltip-DSjr26A_.js";import{n as v}from"./useThemeSystem-DMzWVJaW.js";import{n as y}from"./markdownTheme-DOfGg-A7.js";import{n as b}from"./MarkdownRenderer-4weeFDNg.js";import{a as x,n as S,o as C,t as w}from"./markdown-worker-CptoICmq.js";import{a as T,i as E,o as D,t as ee}from"./imageSource-BlAJJUEI.js";import{t as te}from"./vendor-tanstack-react-virtual-tlDgH6PG.js";var O=e(a(),1),k=u(),A=i(),ne=`w-[min(30rem,calc(100cqw-1rem))] min-w-[280px] max-w-full overflow-hidden rounded-xl shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)] dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]`,j={maxWidth:`min(28rem, calc(100cqw - 4ch))`,backgroundColor:`var(--surface-elevated)`,color:`var(--surface-elevated-foreground)`},M=`flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] px-3 py-2.5`,N=`text-xs font-medium leading-4 text-foreground md:text-[0.8125rem] md:leading-5`,re=`text-xs tabular-nums leading-4 text-muted-foreground md:text-[0.8125rem] md:leading-5`,P=`m-0 list-none divide-y divide-[var(--surface-subtle)] p-0`,F=`${P} max-h-[min(22rem,50vh)] overflow-y-auto`,ie=`${P} w-full`,ae={className:`p-0 rounded-none`,maxHeightClass:`max-h-[46vh]`,disableHorizontal:!0},I=`px-2.5 py-1.5 text-xs leading-4 md:py-2 md:text-[0.8125rem] md:leading-5`,oe=`flex min-w-0 items-center gap-1 text-xs leading-4 text-muted-foreground md:gap-1.5 md:text-[0.8125rem] md:leading-5`,se=`min-w-0 truncate text-xs leading-4 text-foreground md:text-[0.8125rem] md:leading-5`,ce=`inline-flex shrink-0 items-center gap-1 text-xs leading-4 tabular-nums text-muted-foreground md:text-[0.8125rem] md:leading-5`,le=`h-3 w-3 shrink-0 md:h-3.5 md:w-3.5`,ue={in_progress:`text-foreground`,pending:`text-foreground`,completed:`text-muted-foreground line-through`,cancelled:`text-muted-foreground line-through`},L={in_progress:`chat.statusRow.todo.status.inProgress`,pending:`chat.statusRow.todo.status.pending`,completed:`chat.statusRow.todo.status.completed`,cancelled:`chat.statusRow.todo.status.cancelled`},R=e=>{let t=(0,k.c)(26),{todo:n,isActive:r,activeRef:i}=e,{t:a}=d(),o=z.has(n.status)?n.status:`pending`,s=L[o],c;t[0]!==a||t[1]!==s?(c=a(s),t[0]=a,t[1]=s,t[2]=c):c=t[2];let l=c,u;t[3]===o?u=t[4]:(u=o===`in_progress`?(0,A.jsx)(p,{name:`record-circle`,className:`h-3.5 w-3.5 text-[var(--status-info)]`,"aria-hidden":`true`}):o===`completed`?(0,A.jsx)(p,{name:`checkbox-circle`,className:`h-3.5 w-3.5 text-[var(--status-success)]`,"aria-hidden":`true`}):(0,A.jsx)(p,{name:`time`,className:`h-3.5 w-3.5 text-muted-foreground`,"aria-hidden":`true`}),t[3]=o,t[4]=u);let m=u,v=r?`step`:void 0,y=r&&`bg-[var(--surface-muted)] before:absolute before:inset-y-2 before:left-0 before:w-0.5 before:rounded-full before:bg-[var(--status-info)]`,b;t[5]===y?b=t[6]:(b=f(`group relative grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] items-start gap-2.5`,I,y),t[5]=y,t[6]=b);let x;t[7]!==m||t[8]!==l?(x=(0,A.jsx)(h,{asChild:!0,children:(0,A.jsx)(`span`,{className:`flex size-4 items-center justify-center`,"aria-label":l,children:m})}),t[7]=m,t[8]=l,t[9]=x):x=t[9];let S;t[10]===l?S=t[11]:(S=(0,A.jsx)(g,{side:`left`,sideOffset:6,children:l}),t[10]=l,t[11]=S);let C;t[12]!==x||t[13]!==S?(C=(0,A.jsxs)(_,{children:[x,S]}),t[12]=x,t[13]=S,t[14]=C):C=t[14];let w=ue[o],T;t[15]===w?T=t[16]:(T=f(`block whitespace-normal break-words`,w),t[15]=w,t[16]=T);let E;t[17]!==T||t[18]!==n.content?(E=(0,A.jsx)(`span`,{className:`min-w-0 flex-1`,children:(0,A.jsx)(`span`,{className:T,children:n.content})}),t[17]=T,t[18]=n.content,t[19]=E):E=t[19];let D;return t[20]!==i||t[21]!==E||t[22]!==v||t[23]!==b||t[24]!==C?(D=(0,A.jsxs)(`li`,{ref:i,"aria-current":v,className:b,children:[C,E]}),t[20]=i,t[21]=E,t[22]=v,t[23]=b,t[24]=C,t[25]=D):D=t[25],D},z=new Set([`in_progress`,`pending`,`completed`,`cancelled`]),B=`image/png`,V=c(`OpenChamberMedia`),H=e=>{let t=e.toLowerCase().split(`;`)[0]?.trim()||B;if(t===`image/jpeg`||t===`image/jpg`)return`jpg`;if(t===`image/webp`)return`webp`;if(t===`image/gif`)return`gif`;if(t===`image/svg+xml`)return`svg`;if(t===`image/png`)return`png`;let n=t.split(`/`)[1];return n&&/^[a-z0-9.+-]+$/i.test(n)?n:`png`},U=(e,t)=>{let n=`image.${H(t)}`,r=(((e||``).trim()||n).split(/[\\/]/).pop()||n).replace(/[^\w.\- ()[\]]+/g,`_`).replace(/^\.+/,``);return r?/\.[a-z0-9]{1,8}$/i.test(r)?r:`${r}.${H(t)}`:n},W=e=>new Promise((t,n)=>{let r=new FileReader;r.onload=()=>{if(typeof r.result==`string`){t(r.result);return}n(Error(`Failed to encode image`))},r.onerror=()=>n(r.error??Error(`Failed to encode image`)),r.readAsDataURL(e)}),de=async e=>{let t=await W(e),n=t.indexOf(`,`);return n>=0?t.slice(n+1):t},fe=(e,t)=>{let n=URL.createObjectURL(e),r=document.createElement(`a`);r.href=n,r.download=t,document.body.appendChild(r),r.click(),document.body.removeChild(r),window.setTimeout(()=>URL.revokeObjectURL(n),1e3)},G=async(e,t)=>{let n=await fetch(e,{signal:t,cache:`no-store`});if(!n.ok)throw Error(`Image fetch failed (${n.status})`);return n.blob()},pe=async(e,t=B)=>{if(!e.naturalWidth||!e.naturalHeight)return null;let n=e.currentSrc||e.src;if(n&&(/^(?:blob:|data:|openchamber-asset:)/i.test(n)||/^https?:/i.test(n)||/^\/api(?=\/|[?#]|$)/.test(n)))try{let e=await G(n);if(e.size>0)return e}catch{}try{let n=document.createElement(`canvas`);n.width=e.naturalWidth,n.height=e.naturalHeight;let r=n.getContext(`2d`);if(!r)return null;r.drawImage(e,0,0);let i=t.startsWith(`image/`)?t:B,a=await new Promise(e=>{n.toBlob(t=>e(t),i===`image/jpg`?`image/jpeg`:i)});return a&&a.size>0?a:null}catch{return null}},me=async(e,t)=>{let n=``;try{return n=await ee(e,t??new AbortController().signal),await G(n,t)}finally{n&&T(n)}},he=async(e,n)=>{let r=e.effectiveDirectory??``,i=null;e.prefetchedBlob&&e.prefetchedBlob.size>0&&(i=e.prefetchedBlob);let a=e.displayUrl?.trim();if(!i&&a)try{i=await G(a,n)}catch{i=null}if(!i){let a=e.sourceUrl.trim();if(!a)throw Error(`Image source is empty`);if(/^(?:data|blob):/i.test(a)||/^https?:/i.test(a)||/^\/api(?=\/|[?#]|$)/.test(a))i=await G(a,n);else{let e=D(a,r);if(E(e))try{i=await me(e.path,n)}catch{let r=await t(`/api/fs/raw`,{method:`GET`,cache:`no-store`,query:{path:e.path},signal:n});if(!r.ok)throw Error(`Image source request failed with status ${r.status}`);i=await r.blob()}else i=await G(e.source,n)}}if(!i||i.size<=0)throw Error(`Image is empty`);let o=(e.mimeType||(i.type&&i.type.startsWith(`image/`)?i.type:``)||B).split(`;`)[0].trim()||B;return{blob:i,filename:U(e.filename,o),mimeType:o}},ge=()=>!o()||l===void 0?!1:l.isPluginAvailable(`OpenChamberMedia`),_e=async(e,t)=>{let{blob:i,filename:a,mimeType:o}=await he(e,t);if(ge()){let e=await de(i);return await V.saveImage({dataBase64:e,mimeType:o,filename:a}),`saved`}if(r()){let e=n()?.vscode;if(e?.saveImage){let t=await W(i);return await e.saveImage({fileName:a,dataUrl:t}),`saved`}}return fe(i,a),`downloaded`},ve=e=>e.replace(/&/g,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`).replace(/'/g,`&#39;`),ye=e=>e?Object.entries(e).map(([e,t])=>t==null?``:`${e.startsWith(`--`)?e:e.replace(/[A-Z]/g,e=>`-${e.toLowerCase()}`)}:${typeof t==`number`?`${t}px`:t};`).join(``):``,K=(e,t,n)=>{let r=e.querySelector(`pre`);r&&(r.style.margin=`0`,r.style.background=`transparent`,r.style.whiteSpace=t?`pre-wrap`:`pre`,t&&(r.style.wordBreak=`break-word`,r.style.overflowWrap=`break-word`));let i=e.querySelector(`code`);if(i){let e=ye(n);e&&i.setAttribute(`style`,`${i.getAttribute(`style`)??``}${e}`)}},be=e=>`<pre><code>${ve(e)}</code></pre>`,xe=e=>{let t=(0,k.c)(21),{code:n,language:r,className:i,style:a,codeStyle:o,wrap:s}=e,c=s!==void 0&&s,{currentTheme:l}=v(),u=O.useRef(null),d;t[0]===l?d=t[1]:(d=y(l),t[0]=l,t[1]=d);let p=d,m,h;t[2]!==n||t[3]!==o||t[4]!==c?(m=()=>{let e=u.current;e&&(e.innerHTML=be(n),K(e,c,o))},h=[n,c,o],t[2]=n,t[3]=o,t[4]=c,t[5]=m,t[6]=h):(m=t[5],h=t[6]),O.useLayoutEffect(m,h);let g,_;t[7]!==n||t[8]!==o||t[9]!==r||t[10]!==c?(g=()=>{let e=u.current;if(!e)return;let t=!0,i=new AbortController;return w(n,(r||`text`).toLowerCase(),{signal:i.signal,priority:`visible`}).then(n=>{!t||!e||!n||(e.innerHTML=n,K(e,c,o))}),()=>{t=!1,i.abort()}},_=[n,r,c,o],t[7]=n,t[8]=o,t[9]=r,t[10]=c,t[11]=g,t[12]=_):(g=t[11],_=t[12]),O.useEffect(g,_);let b;t[13]===i?b=t[14]:(b=f(`typography-code`,i),t[13]=i,t[14]=b);let x=p,S;t[15]!==a||t[16]!==x?(S={...x,...a},t[15]=a,t[16]=x,t[17]=S):S=t[17];let C;return t[18]!==b||t[19]!==S?(C=(0,A.jsx)(`div`,{ref:u,className:b,style:S}),t[18]=b,t[19]=S,t[20]=C):C=t[20],C},Se=e=>{let t=e.replace(/^<file>\s*\n?/,``).replace(/\n?<\/file>\s*$/,``);return t=t.replace(/^\s*\d{5}\|\s?/gm,``),t.trim()},Ce=(e,t=``)=>{if(typeof e==`string`)return e;if(e==null)return t;if(typeof e==`number`||typeof e==`boolean`||typeof e==`bigint`)return String(e);try{return JSON.stringify(e)}catch{return t}},we=e=>e?e.includes(`<diagnostics`)||e.includes(`<file_diagnostics>`)||e.includes(`LSP errors detected`)||e.includes(`This file has errors`):!1,Te=e=>e?e.replace(/\n{0,2}LSP errors detected[\s\S]*?<diagnostics[^>]*>[\s\S]*?<\/diagnostics>/g,``).replace(/\n{0,2}This file has errors[\s\S]*?<\/file_diagnostics>/g,``).replace(/<diagnostics[^>]*>[\s\S]*?<\/diagnostics>/g,``).replace(/<file_diagnostics>[\s\S]*?<\/file_diagnostics>/g,``).trim():``,Ee=(e,t)=>!e||typeof e!=`object`?String(e):C(e,t||``),q=e=>{if(typeof e==`string`){let t=e.trim();return t.length>0?t:void 0}if(e&&typeof e==`object`){let t=e.patch;if(typeof t==`string`){let e=t.trim();return e.length>0?e:void 0}}},De=e=>{if(!e||typeof e!=`object`)return;let t=q(e.patch)??q(e.diff);if(t)return t;let n=Array.isArray(e.files)?e.files:[];for(let e of n){if(!e||typeof e!=`object`)continue;let t=q(e.patch)??q(e.diff);if(t)return t}},Oe=e=>{if(!e||typeof e!=`string`)return{data:null,isJson:!1};let t=e.trim();if(!t.startsWith(`{`)&&!t.startsWith(`[`)||!t.endsWith(`}`)&&!t.endsWith(`]`)||t.length<2)return{data:null,isJson:!1};try{let e=JSON.parse(t);return typeof e==`object`&&e?{data:e,isJson:!0}:{data:null,isJson:!1}}catch{return{data:null,isJson:!1}}},ke=(e,t,n)=>{let r=Se(e);if((t===`edit`||t===`multiedit`||t===`write`||t===`apply_patch`)&&we(r)&&(r=Te(r)),(t===`edit`||t===`multiedit`||t===`apply_patch`)&&r.trim().length===0){let e=De(n);if(e)return e}return r},Ae=e=>{let t=e.match(/<type>(file|directory)<\/type>/i)?.[1]?.toLowerCase()??`unknown`,n=(e.match(/<content>([\s\S]*?)<\/content>/i)?.[1]??e).replace(/\r\n/g,`
2
2
  `).split(`
3
3
  `),r=e=>/\(\s*File has more lines\..*offset.*\)/i.test(e.trim());return{type:t,lines:n.map(e=>{let n=e.trim(),i=n.startsWith(`(`)&&n.endsWith(`)`)||r(n);if(t!==`directory`){let t=e.match(/^(\d+):\s?(.*)$/);if(t){let e=t[2],n=e.trim(),i=n.startsWith(`(`)&&n.endsWith(`)`)||r(n);return{lineNumber:i?null:Number(t[1]),text:e,isInfo:i}}}return{lineNumber:null,text:e,isInfo:i}}).filter((e,t,n)=>{if(e.text.trim().length>0)return!0;let r=n[t-1],i=n[t+1],a=!!(r?.isInfo||i?.isInfo),o=e.lineNumber!==null;return!(a&&o)})}},je=(e,t)=>{try{let n=e.trim().split(`
4
4
  `).filter(Boolean);if(n.length===0)return null;let r=[];return n.forEach(e=>{let t=e.match(/^(\s*)(.+)$/);if(t){let[,e,n]=t,i=Math.floor(e.length/2),a=!n.endsWith(`/`);r.push({name:n.replace(/\/$/,``),depth:i,isFile:a})}}),(0,A.jsx)(`div`,{className:f(`w-full min-w-0 font-mono space-y-0.5`,t?.unstyled?null:`p-3 bg-muted/20 rounded-xl border border-border/30`),style:s.tool.popup,children:r.map((e,t)=>(0,A.jsx)(`div`,{className:`min-w-0`,style:{paddingLeft:`${e.depth*20}px`},children:e.isFile?(0,A.jsx)(`span`,{className:`text-foreground/90 block truncate`,children:e.name}):(0,A.jsxs)(`span`,{className:`font-semibold text-foreground block truncate`,children:[e.name,`/`]})},t))})}catch{return null}},Me={backgroundColor:`var(--status-info)`,opacity:.6},Ne=(e,t,n)=>{try{let r=e.trim().split(`
package/dist/index.html CHANGED
@@ -552,7 +552,7 @@
552
552
  opacity: 0.72;
553
553
  }
554
554
  </style>
555
- <script type="module" crossorigin src="/assets/main-DwMprEyr.js"></script>
555
+ <script type="module" crossorigin src="/assets/main-BRIfvcsR.js"></script>
556
556
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
557
557
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-D66yq6ie.js">
558
558
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-LvWDU-SL.js">
@@ -572,8 +572,8 @@
572
572
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-CfgOfnZk.js">
573
573
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
574
574
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
575
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-iJ2mTBVN.js">
576
- <link rel="stylesheet" crossorigin href="/assets/src-DYeppLfP.css">
575
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-AZn89Pi3.js">
576
+ <link rel="stylesheet" crossorigin href="/assets/src-CnLJ1J1M.css">
577
577
  </head>
578
578
  <body class="h-full bg-background text-foreground">
579
579
  <!-- Keep the startup layer outside #root. React replaces every child of
@@ -74,7 +74,7 @@
74
74
  }
75
75
  </style>
76
76
 
77
- <script type="module" crossorigin src="/assets/miniChat-C8bzY_Ye.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-DSuRbtqh.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-D66yq6ie.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-LvWDU-SL.js">
@@ -94,8 +94,8 @@
94
94
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-CfgOfnZk.js">
95
95
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
96
96
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
97
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-iJ2mTBVN.js">
98
- <link rel="stylesheet" crossorigin href="/assets/src-DYeppLfP.css">
97
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-AZn89Pi3.js">
98
+ <link rel="stylesheet" crossorigin href="/assets/src-CnLJ1J1M.css">
99
99
  </head>
100
100
  <body class="h-full bg-background text-foreground">
101
101
  <div id="root" class="h-full">
package/dist/mobile.html CHANGED
@@ -63,7 +63,7 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-Oy-SRKkr.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-B9Ek0XAw.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-D66yq6ie.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-LvWDU-SL.js">
@@ -83,8 +83,8 @@
83
83
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-CfgOfnZk.js">
84
84
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
85
85
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
86
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-iJ2mTBVN.js">
87
- <link rel="stylesheet" crossorigin href="/assets/src-DYeppLfP.css">
86
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-AZn89Pi3.js">
87
+ <link rel="stylesheet" crossorigin href="/assets/src-CnLJ1J1M.css">
88
88
  </head>
89
89
  <body class="h-full bg-background text-foreground">
90
90
  <div id="root" class="h-full"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/web",
3
- "version": "1.19.14",
3
+ "version": "1.19.15-beta.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## Acceptance criteria
4
4
 
5
+ The contact's default role is personable communication, workspace discovery,
6
+ task delegation, and result follow-up. Substantive implementation runs through
7
+ `assign_session` in the relevant existing OpenCode workspace; simple lookups and
8
+ small, bounded configuration changes can use the contact's pi tools directly.
9
+ This routing preference is prompt policy; the direct tools remain available.
10
+
11
+ Contact pi execution uses the configured workspace (its canonical effective path)
12
+ when present. Explicit `workspacePath: null` starts pi in `os.homedir()` for the
13
+ current server OS user, allowing user-global directory lookup. This applies on
14
+ the connected backend for web, desktop, and mobile. Archived OpenCode bindings,
15
+ `managedWorkspacePath`, and `effectiveWorkspacePath` retain their existing
16
+ storage/session meaning. Only the contact pi working directory changes.
17
+ Discovery starts in the configured directory, checks existing session metadata,
18
+ and walks parent directories to locate the nearest relevant workspace; broader
19
+ `list_sessions` lookups can find related conversations. Ambiguous targets need
20
+ clarification. An empty registered catalog still allows discovery; assigning a
21
+ discovered workspace requires its registration through existing Settings.
22
+
5
23
  Worker submission uses one admission path for new and existing sessions.
6
24
  Definite rejection cleans up only a session created by that call. Ambiguous
7
25
  admission keeps the worker and confirms the exact message ID before reporting
@@ -21,9 +39,10 @@ and keep their existing error result handling.
21
39
 
22
40
  **User interruption is terminal for the current watch**
23
41
 
24
- - Authoritative OpenCode `MessageAbortedError` on `session.error` or assistant `message.updated` settles the watched card as `cancelled`, not a retryable failure. Reconciliation and the pre-continuation worker-message check recognize the same error. Successful `stop_session` also cancels the watch when upstream emits no event.
42
+ - Authoritative OpenCode `MessageAbortedError` on `session.error` or assistant `message.updated` settles the watched card as `cancelled`, not a retryable failure. Reconciliation and a complete/error pre-continuation worker-message check recognize the same error and convert that watch to `cancelled`. Successful `stop_session` also cancels the watch when upstream emits no event.
25
43
  - `cancelled` is durable in the existing watch/card records and excluded from in-flight reconciliation. Later idle, busy, and error events cannot rearm it. Only an explicit assign/watch admission overwrites the cancelled watch for a new run. No schema migration is needed: watch/card status is an open string.
26
- - Pending/running automatic continuations own cancellation controllers. A worker interruption aborts those continuations, including already queued ones; a newly admitted user message supersedes that assistant's existing automatic continuations. Other assistants/workers remain independent. Cancelled continuations clear activity and settle with `cancelled` without adding a retry, error bubble, or late response.
44
+ - Pending/running automatic continuations own cancellation controllers. A worker interruption aborts those complete/error continuations, including already queued ones, and does not abort a cancelled interrupt notify already queued for that worker. Duplicate `cancelled` reports (boot reconcile plus timer, or `session.error` plus `message.updated`) stay idempotent on the watch and still let that notify run. A newly admitted user message supersedes that assistant's existing automatic continuations, including a cancelled notify. Other assistants/workers remain independent. Aborted complete/error continuations clear activity and settle with `cancelled` without adding a retry, error bubble, or late response.
45
+ - When the watch **transitions** to `cancelled` from a user abort (event or reconcile), the service schedules the same read-only contact-lane continuation as complete/error. Internal `userText` states `status: cancelled` and `reason: user_interrupted` (manual OpenCode abort / `MessageAbortedError`, not a model failure, not `stop_session`). A cancelled resume that fetches worker messages must still run — it must not re-enter the abort short-circuit. `stop_session` marks `cancelled` without this extra continuation because the assistant already owns that user turn.
27
46
  - The signal reaches pi tools and model generation. The stream adapter checks before/after model requests and before protocol retries, preventing a late tool response from executing. An already completed side effect is not rolled back. Cancellation is authoritative runtime state, not a language-model interpretation of “stop”.
28
47
 
29
48
  **Workspace response and loop protocol**
@@ -46,10 +65,10 @@ and keep their existing error result handling.
46
65
  **Assign work (this PR)**
47
66
 
48
67
  - `assign_session` is an OpenChamber AgentTool on the contact harness. It creates or reuses a visible OpenCode/OpenChamber session on a **registered project path** (Settings projects / `getAllowedRoots()`), optionally an **existing** Chat worktree/branch via `getWorktrees`, then kicks the coding prompt into that session with `session.create` + `session.promptAsync` (same worker path as `POST /api/openchamber/conversations`).
49
- - That worker session is the coder. The contact does not code. Assign sessions are **not** archived Assistant bindings and must not use `assistant-workspaces`.
68
+ - That worker session owns substantive implementation. The contact owns communication and coordination, with direct pi tools for small, bounded tasks. Assign sessions are **not** archived Assistant bindings and must not use `assistant-workspaces`.
50
69
  - **Reuse / continue existing session:** `assign_session` with `sessionID` + coding prompt continues that worker. Before prompt, the service resolves directory/project from **authoritative** session metadata (session index directory → `session.get` payload → registered project match). User-supplied `projectPath`/`directory` are lookup **hints only** and never override scope. Missing/unregistered/failed session lookup **fails closed** (not empty success). Prior worker model/config semantics stay: omit model args to follow the session model when still connected.
51
70
  - **`watch_session`:** listen-only attach for an existing session (`sessionID` required). No `promptAsync`. Emits the same session card slot and reuses `assistant_contact_watch` + `reportAssignedSession` / reconcile / resume. Baseline card/watch status comes from live `session.get` (+ messages when needed) via `mapSessionToWatchStatus` so an already-terminal idle is **not** treated as a fresh complete transition (no false resume). In-flight baseline (`busy`/`question`) keeps the watch until real settle. A plain user `@session:id` (or JSON title/sessionID/directory) reference is **context only** — it does not auto-call `watch_session`; the model must hear an explicit listen/watch/monitor intent.
52
- - **`stop_session`:** calls real OpenCode `session.abort` for `sessionID` after the same authoritative directory resolve. Returns the true abort result — transport/`not_found`/upstream errors stay errors (never claim stopped on failure). On successful abort the service marks the watch/card `cancelled` even when upstream emits no idle/error event. Idle/error events still flow through the existing watch reporter when a watch exists.
71
+ - **`stop_session`:** calls real OpenCode `session.abort` for `sessionID` after the same authoritative directory resolve. Returns the true abort result — transport/`not_found`/upstream errors stay errors (never claim stopped on failure). Abort is registered in a private per-session inflight map before `await abort` so concurrent SSE `complete`/`error` cannot schedule a `resume=true` continuation ahead of `resume=false`. On successful abort the service marks the watch/card `cancelled` even when upstream emits no idle/error event. Idle/error events still flow through the existing watch reporter when a watch exists.
53
72
  - **Worker model selection:** optional tool args `providerID` / `modelID` / `model` resolve against the live connected OpenCode catalog (`loadConnectedCatalog` / `GET /provider` + `GET /config/providers`). Explicit selection must match uniquely — missing or ambiguous names fail closed (`model_not_found` / `model_ambiguous`) with **no silent fallback**. Omitting model args on a **new** worker keeps the contact assistant's default provider/model. **Reusing `sessionID` without an explicit model** follows that session's last model (`session.get` → `session.model` `{ providerID, id|modelID }`, else newest user/assistant `info.model`) when it is still an exact providerID+modelID match in the connected catalog (`source: 'session'`); missing session model, catalog miss, or lookup failure **degrades** to the assistant default (`source: 'assistant'`) and never fails assign once the session itself resolved. Selecting a worker model **never** mutates the contact assistant row.
54
73
  - Every user or automatic contact turn reloads the current instance's **Connected OpenCode models** block (exact providerID, modelID, providerName, name, acceptsImages), plus settings-backed favorite models in saved order and recent models newest first. Only connected catalog entries qualify as preference candidates; variants remain separate from model IDs. Catalog and preference read failures remain explicit unknown states, distinct from successful empty lists; either read can succeed independently. The route projects only favoriteModels/recentModels from the strict settings reader, never arbitrary settings or credentials. No catalog is cached across turns or runtime switches. Informal `model` aliases normalize punctuation/spacing and require a unique catalog match; explicit `modelID` stays exact. Ambiguous aliases never silently prefer a favorite/provider. This server-owned context is shared by web, desktop, and mobile clients of that instance.
55
74
  - **Current-turn attachments:** server-authoritative `userParts` file parts from this contact turn are forwarded into worker `promptAsync` parts by default (including “just open a session” intents). The model must not base64-encode or invent local paths. History images are **not** unbounded-forwarded. Image parts require a vision-capable catalog entry (`acceptsImages`); otherwise assign fails with `image_not_supported` before create. Sensitive file bodies are never logged.
@@ -57,11 +76,11 @@ and keep their existing error result handling.
57
76
  - Successful `assign_session` and `watch_session` return `terminate: true` so the pi agent loop does **not** auto-follow-up the LLM (prevents a looping model from re-assigning dozens of times in one turn). Prerequisite tools (`list_projects` / `list_sessions`) may still run before assign/watch/stop in the same turn; multi-operation user intents remain ordered **across turns** after a successful assign/watch.
58
77
  - Same contact-turn assign gate (tools instance lifetime only, not cross-turn): identical args after success replay the cached success result (no second worker); different args after success, or parallel mismatched args, fail closed with a validation error. The idempotent key includes worker model selection and current-turn attachment scope. Failure clears the gate so a corrected retry may run. Parallel identical args share one in-flight promise.
59
78
  - After a successful assign or watch, the service persists the existing session card into the contact transcript (user bubbles first, then the card) and watches that worker session in `assistant_contact_watch` (watch baselines the live status). The user-facing outcome is the session card plus any short **spoken** preamble; English tool-result confirms (`Opened a coding session.`, `Watching that coding session.`, `Created assistant…`, `Sent to…`) for card/side-effect tools are **not** written into the transcript.
60
- - Existing OpenCode SSE on the same `globalEventHub` as notifications (`session.idle`, `session.error`, `session.status`, `question.asked`, `permission.asked`) updates **the same card** (`busy` → `complete` / `error` / `question`). **No canned settle transcript bubbles** (`oc.settle.complete` / `error` / `question` are not written). On **complete or error** when the watch status actually changed, the service schedules an async contact-lane **continuation** (`inContactTurnLane` + `runContactTurn`) per related assistant: no admitted user row (internal `userText` only), active contact turn (green dot / 3-dot), stable resume id `resume_${assistantID}_${sessionID}_${status}_${updatedAt}`, bounded last worker assistant text (~2000 chars; fetch failure still resumes with status only). The contact Agent can perform read-only lookups and summarize in the user's language, but cannot reassign or mutate a session during this notification — the user sees that Agent summary, not 「会话已完成/会话失败」. `question` updates the card only (worker waiting on the user). Session-goal settle (`emitGoalNotification`) uses the same reporter — not a second scheduler. Idle after `session.error` must not rewrite 失败 as 完成 (`changed=false` → no second resume). Same complete status re-reported is idempotent.
61
- - Restart or a missed `session.idle` cannot leave the watch stuck: boot and the existing 60s reconcile timer `listInFlightWatches` and poll OpenCode `session.get` / `session.messages`. Idle, `time.completed`, or a missing session settles the same card (`busy` → 完成) and triggers the same contact continuation (no `oc.settle.complete` bubble). `session.error` or last-assistant `info.error` settles 失败 + resume once. Transport / non-404 get errors leave the watch in flight (failure is not empty success). One failed watch does not block unrelated watches. Reconcile reuses `reportAssignedSession`, so an error watch is never rewritten as complete. Tombstoned assistants skip resume.
79
+ - Existing OpenCode SSE on the same `globalEventHub` as notifications (`session.idle`, `session.error`, `session.status`, `question.asked`, `permission.asked`) updates **the same card** (`busy` → `complete` / `error` / `question` / `cancelled`). **No canned settle transcript bubbles** (`oc.settle.complete` / `error` / `question` are not written). On **complete, error, or cancelled (user interrupt)** when the watch status actually changed, the service schedules an async contact-lane **continuation** (`inContactTurnLane` + `runContactTurn`) per related assistant: no admitted user row (internal `userText` only), active contact turn (green dot / 3-dot), stable resume id `resume_${assistantID}_${sessionID}_${status}_${updatedAt}`, bounded last worker assistant text (~2000 chars; fetch failure still resumes with status only). Cancelled continuations include `reason: user_interrupted` so the contact can acknowledge the user's abort. The contact Agent can perform read-only lookups and summarize in the user's language, but cannot reassign or mutate a session during this notification — the user sees that Agent summary, not 「会话已完成/会话失败」. `question` updates the card only (worker waiting on the user). `stop_session` cancellation does not schedule this continuation. Session-goal settle (`emitGoalNotification`) uses the same reporter — not a second scheduler. Idle after `session.error` must not rewrite 失败 as 完成 (`changed=false` → no second resume). Same complete status re-reported is idempotent.
80
+ - Restart or a missed `session.idle` cannot leave the watch stuck: boot and the existing 60s reconcile timer `listInFlightWatches` and poll OpenCode `session.get` / `session.messages`. Idle, `time.completed`, or a missing session settles the same card (`busy` → 完成) and triggers the same contact continuation (no `oc.settle.complete` bubble). `session.error` or last-assistant `info.error` settles 失败 + resume once; last-assistant `MessageAbortedError` settles `cancelled` + the interrupt notify once. Transport / non-404 get errors leave the watch in flight (failure is not empty success). One failed watch does not block unrelated watches. Reconcile reuses `reportAssignedSession`, so an error watch is never rewritten as complete. Tombstoned assistants skip resume.
62
81
  - Worker sessions stamp `metadata.openchamber.assigned` (`from: 'contact'`, `assistantID`, `name`). They must **not** use `openchamber.assistant.assistantID` — that marker hides archived Assistant bindings from Chat.
63
82
  - `AssistantDTO.assignedSessionIDs` lists in-flight (`busy`/`question`) watches for assigned worker sessions. Session-card busy stays on the card; it does **not** drive the list green dot.
64
- - **Contact active turn (server authoritative):** process-local metadata tracks admission → `queued` → `running` → settled (removed). `AssistantDTO.working` is true only while that assistant has an unsettled contact turn. `AssistantDTO.activeContactTurn` is `{ turnID, messageID, status: 'queued'|'running', admittedAt }` or `null` so APP restart / reconnect can rehydrate list green dots and the contact 3-dot row from the snapshot HTTP GET local optimistic and SSE preview are temporary only. A server process restart clears in-memory activity (no permanent green; tools are not auto-rerun). Per-assistant lanes isolate turns across assistants/runtimes. Stale `contact-turn-end` for an older `turnID` must not clear a newer active turn.
83
+ - **Contact active turn (server authoritative):** process-local metadata tracks admission → `queued` → `running` → settled (removed). `AssistantDTO.working` is true only while that assistant has an unsettled contact turn. `AssistantDTO.activeContactTurn` is `{ turnID, messageID, status: 'queued'|'running', admittedAt }` or `null` so APP restart / reconnect can rehydrate list green dots and the contact 3-dot row from the snapshot HTTP GET. Spoken bubbles and cards persist to SQLite as soon as they publish (`done:true` / `onCard`) so another device or APP restart can GET them while the turn is still running. Token-level SSE deltas remain live paint only. A server process restart clears in-memory activity (no permanent green; tools are not auto-rerun) but already-published bubbles/cards stay in the transcript. Per-assistant lanes isolate turns across assistants/runtimes. Stale `contact-turn-end` for an older `turnID` must not clear a newer active turn.
65
84
  - **List latest-message preview (server authoritative):** every `AssistantDTO` path (`snapshot` / `create` / `update` / other `output` rows) carries `latestMessagePreview: { messageID, ordinal, createdAt, role: 'user'|'assistant', text, fallbackKind: 'image'|'file'|'session'|'assistant'|'schedule'|null } | null`. Source is the contact transcript only (not `defaultPrompt` / `updatedAt`). Order is the same keyset as contact pages: newest `(ordinal DESC, message_id DESC)`. Visible roles are `user`, `assistant`, and `peer` (peer maps to `role: 'user'` because inbound DMs are user-visible). Internal assigned-session settle markers (`oc.settle.*`) are excluded in the SQL candidate probe (and again in `buildContactMessagePreview`). Durable error bubbles keep their real body text. Plain text is sanitized and capped at `CONTACT_PREVIEW_MAX_CHARS` (500) with no data-URL/base64 bodies. File-only rows set `fallbackKind` `image`/`file`; pure cards set `session`/`assistant`/`schedule`. Empty transcript → `null`. **Snapshot cost:** one prepared statement reused per assistant (`assistant_id` + indexed `ORDER BY ordinal DESC, message_id DESC LIMIT candidateLimit`, catalog ≤100) collecting only those bounded candidates, then **one** parts `IN` query — not a window/`ROW_NUMBER` over full multi-assistant history and not HTTP N+1. SQL already drops pure settle tails so they cannot exhaust the LIMIT and hide an older real bubble; remaining non-preview empty rows may still consume a candidate slot within that same LIMIT. Admission, assistant completion, peer DM, card insert, wipe, and clear-memory already bump domain revision / `openchamber:assistants-changed`; clients re-GET snapshot for the new preview. Clear-memory keeps transcript rows so the preview still reflects the last bubble; clear-history / reset remove rows so the preview becomes the wipe confirm (or `null` after a full unbounded wipe with no remaining user).
66
85
  - No registered project → clear `project_required` (ask the user to configure a project). Never fall back to managed assistant-workspaces.
67
86
  - Assign must not go through the peer DM channel.
@@ -71,7 +90,7 @@ and keep their existing error result handling.
71
90
 
72
91
  **Registered projects + sessions (this PR)**
73
92
 
74
- - Every contact turn injects a **Registered projects** block (`id`, `path`, `label`) from `listProjects()` into the system prompt via `formatRegisteredProjectsPrompt`. The model can fuzzy-match names like "openchamber yee" without a tool call first. It must never claim it cannot see the list; empty catalog tell the user to add a project in Settings.
93
+ - Every contact turn injects a **Registered projects** block (`id`, `path`, `label`) from `listProjects()` into the system prompt via `formatRegisteredProjectsPrompt`. The model can fuzzy-match names like "openchamber yee" without a tool call first. Empty catalog discover the relevant existing workspace using sessions and directory lookups, then request registration in Settings before assignment.
75
94
  - `list_projects` refreshes/filters that catalog (`query` optional). `list_sessions` searches the existing session-index (`sessionIndexService.snapshot()`) for sessions in a project (`projectPath` / `projectID`, optional title query). Results are bounded (`sessionID`, `title`, `directory`, `updatedAt`). Index/catalog failure is an error, never an empty success.
76
95
  - After matching a project, open work with `assign_session` (`projectPath` and/or existing `sessionID`).
77
96
 
@@ -162,7 +181,7 @@ and keep their existing error result handling.
162
181
  card, or non-empty text that is not an internal `oc.settle.*` marker). Visibility is
163
182
  **per-part** (same rule as the contact UI): a settle marker beside real body/file/card still
164
183
  counts; blank/whitespace-only rows do **not**. User rows and non-visible roles do **not**
165
- count. Streaming tokens never create rows one persisted bubble/card/peer row ≡ one unread unit.
184
+ count. Token-level SSE deltas never create rows. Each published spoken bubble, card, or peer row is persisted immediately and ≡ one unread unit.
166
185
  - **Schema v13 migrate:** existing transcript assistants seed watermark to the current tip so
167
186
  legacy history is default-read (`unreadCount: 0`). Messages written after the seed count as unread.
168
187
  - **Monotonic mark-read:** `POST /api/openchamber/assistants/:id/contact/read`
@@ -201,12 +220,14 @@ Snapshots expose the latest 50 `historySessionIDs` in chronological order and `h
201
220
 
202
221
  - `POST /api/openchamber/assistants/:id/messages` returns **202** `{ messageID, admitted: true, binding }` as soon as the **user row** is persisted (`status: complete`). It does **not** wait for the LLM turn. Validation errors still fail synchronously with 4xx before admit.
203
222
  - After admit: bump revision + `openchamber:assistants-changed`, register active contact turn (`queued`), broadcast `openchamber:contact-turn-start` `{ assistantID, turnID, messageID, occurredAt }` (`turnID` = client `messageID`), then kick `runContactTurn` asynchronously (serialized per assistant). Lane start marks the turn `running`. Lane start marks the turn `running` and bumps revision. Same `messageID`+payload replays admission once (`replayed: true`) without a second lane run; payload mismatch → `idempotency_conflict`. Failures persist a durable assistant `status: error` bubble for query recovery. Catalog/project list loads use a bounded deadline so the lane cannot hang forever.
204
- - Contact SSE reuses `/api/openchamber/events` (no new endpoint): `openchamber:contact-bubble-delta` `{ assistantID, turnID, bubbleIndex, delta, done, occurredAt }`. Raw completion tokens are **not** painted live — they are often chain-of-thought and would appear then vanish. A short spoken preamble (e.g. 我去找一下) may appear before a tool and is kept. Planning/CoT is stripped. **Transcript bubbles:** confirm-only tools (`new_conversation` / `clear_chat_history` / `get_assistant_settings` / `update_default_prompt`) may use tool result text as the bubble; card/side-effect tools (`assign_session` / `watch_session` / `stop_session` / `create_assistant` / `schedule_task` / `message_assistant`) keep spoken only — never stack spoken + English toolText, and never paint card-tool English confirms when a card is enough; `read_session` is model-only lookup — keep spoken preamble plus the post-read reply, never the quoted transcript JSON. Missed-tool turns keep any existing spoken/assistant text; the English `MISSED_TOOL_FAILURE_BUBBLE` is only when there is no text at all. No-tool replies emit each stripped bubble as `{ done:true }` after parse, with a short gap between bubbles so they arrive one beat at a time.
205
- - Turn end: strip/parse/tools (existing), persist **assistant** bubbles + cards only (never re-insert the user row), clear active contact turn, bump + assistants-changed (error paths bump even without assistant rows), broadcast `openchamber:contact-turn-end` `{ assistantID, turnID, status: 'complete'|'error', error?, occurredAt }`. On failure the admitted user row stays; clients must not spin forever. Completing a contact turn also sends the same completion notification path as a normal contact: SMS-style title (assistant name) + body (spoken bubbles), via desktop / UI SSE / push, gated by `notifyOnCompletion`.
223
+ - Contact SSE reuses `/api/openchamber/events` (no new endpoint): `openchamber:contact-bubble-delta` `{ assistantID, turnID, bubbleIndex, delta, done, occurredAt }`. Raw completion tokens are **not** painted live — they are often chain-of-thought and would appear then vanish. A short spoken preamble (e.g. 我去找一下) may appear before a tool and is kept. Planning/CoT is stripped. When a spoken bubble is `{ done:true }`, persist it immediately as `${userMessageID}:bubble:${index+1}` and bump `assistants-changed` so other clients can GET it before the turn ends. Token deltas (`done:false`) stay SSE-only. **Turn-global bubble indices:** the harness fixes the Agent message boundary at turn start (`prior.length`). Every completion and missed-tool retry derives live `bubbleIndex` from already-streamed turn assistant messages (`projectStreamedContactTurnBubbles`) with the same split rules as final `bubbles[]` (spoken preamble and final text both use `splitContactBubbles`; independent messages keep duplicate text). Indices never restart at 0 after a tool. Published mid-turn bubbles are a stable prefix of the final array on normal turns. **Transcript bubbles:** confirm-only tools (`new_conversation` / `clear_chat_history` / `get_assistant_settings` / `update_default_prompt`) may use tool result text as the bubble; card/side-effect tools (`assign_session` / `watch_session` / `stop_session` / `create_assistant` / `schedule_task` / `message_assistant`) keep spoken only — never stack spoken + English toolText, and never paint card-tool English confirms when a card is enough; `read_session` is model-only lookup — keep spoken preamble plus the post-read reply, never the quoted transcript JSON. Missed-tool turns keep any existing spoken/assistant text; the English `MISSED_TOOL_FAILURE_BUBBLE` is only when there is no text at all. No-tool replies emit each stripped bubble as `{ done:true }` after parse, with a short gap between bubbles so they arrive one beat at a time. Tool cards persist on `onCard` (same `${userMessageID}:card:${index+1}` ids) so a session cover is in the transcript while the worker still runs.
224
+ - Turn end: strip/parse/tools (existing), persist any **remaining** assistant bubbles + cards (skip ids already published mid-turn; never re-insert the user row), clear active contact turn, bump + assistants-changed (error paths bump even without assistant rows), broadcast `openchamber:contact-turn-end` `{ assistantID, turnID, status: 'complete'|'error', error?, occurredAt }`. On failure the admitted user row stays; clients must not spin forever. Completing a contact turn also sends the same completion notification path as a normal contact: SMS-style title (assistant name) + body (spoken bubbles), via desktop / UI SSE / push, gated by `notifyOnCompletion`.
225
+ - **Successful `new_conversation` / `clear_chat_history`:** mid-turn spoken/card publishes for that turn are discarded. `persistContactAssistantReply({ reset:true })` deletes this turn's `role=assistant` rows by `assistantID+turnID` (parts first; same delete pattern as contact-store, **not** `deleteContactMessages`) inside the existing write transaction, then inserts the single confirm bubble and bumps revision. Clearing user, queued users, peers, older turns, watches, generation, context boundary, and read watermark stay. Late bubble/card publish after reset success is ignored; `spokenByIndex` and `assignedCards` clear. Failure keeps prior error semantics.
226
+ - **`stop_session` abort race:** before `session.abort`, the service registers a private per-`sessionID` inflight map (shared promise + observed terminal). Concurrent stop calls on the same worker share that promise. While inflight, `reportAssignedSession` only stashes terminals (`cancelled` > `error` > `complete`) and delays watch writes / continuations. Abort success (including silent no-SSE): release map, `cancelAssignedResumes({ sessionID })`, then `reportAssignedSession(cancelled, resume=false)`. Abort throw / 404 / error / `data:false` / signal failure: release map, replay one observed terminal through the normal reporter when present (else watch unchanged), return the real tool failure. `close()` clears the map; late events stay cancelled-idempotent.
206
227
  - In-process tests use `whenContactTurnSettled(messageID)` (not part of the HTTP JSON body).
207
228
  - Completions: public `POST /llm/chat/completions` still rejects `stream:true`. In-process contact harness passes `onTextDelta` + `globalEventHub` through `boundCompletion` → `createChatCompletion` → throwaway generate for real tokens only.
208
229
 
209
- Composer messages carry a client message ID. The service accepts the existing OpenCode delivery parts (`{ type: 'text' }` and `{ type: 'file', mime, url, filename? }` data URLs — not a second attachment store), extracts text (`[attachment]` when the send is file-only), admits the user at 202, then runs the contact harness asynchronously (`runContactTurn` / pi-agent-core, thinking off, pi `read` / `write` / `edit` / `bash` in the assistant workspace, OpenChamber tools `new_conversation` / `clear_chat_history` / `list_projects` / `list_sessions` / `get_assistant_settings` / `update_default_prompt` / `create_assistant` / `schedule_task` / `message_assistant` / `assign_session`, merged `.agents`/`.claude` skills, plus the registered-projects catalog each turn) through the OpenChamber completions gateway (public HTTP non-streaming; in-process `onTextDelta` for contact SSE). Assistant bubbles and tool cards persist at turn end. File parts survive `GET /:id/contact/messages`. The harness forwards those file parts to the completions gateway. Image data URLs reach `promptAsync` only when the connected catalog marks the model as vision-capable; otherwise generate keeps the `[image: …]` description and text-file bytes so a non-vision model cannot stall the contact turn. Gateway 502 responses include `{ error, message }` so the composer can show the OpenCode error string. OpenCode `promptAsync` is not the contact conversation engine; assign uses it only on the **worker** session. Share and queued delivery still use it on Assistant bindings. OpenCode `204` is an admitted empty response. A 404 restores one generation-scoped binding before retrying. Ambiguous network errors preserve the client message ID without another send. Every committed Assistant revision publishes `openchamber:assistants-changed` only after its SQLite transaction completes; clients use the tip to reload the authoritative snapshot after worker-driven stateless binding changes. `POST /:id/session/abort` requires the same `{ sessionID, sessionGeneration }`, returns the binding and `aborted: true`, reports a changed binding as `revision_conflict`, and preserves upstream missing-session `not_found` semantics.
230
+ Composer messages carry a client message ID. The service accepts the existing OpenCode delivery parts (`{ type: 'text' }` and `{ type: 'file', mime, url, filename? }` data URLs — not a second attachment store), extracts text (`[attachment]` when the send is file-only), admits the user at 202, then runs the contact harness asynchronously (`runContactTurn` / pi-agent-core, thinking off, pi `read` / `write` / `edit` / `bash` in the assistant workspace, OpenChamber tools `new_conversation` / `clear_chat_history` / `list_projects` / `list_sessions` / `get_assistant_settings` / `update_default_prompt` / `create_assistant` / `schedule_task` / `message_assistant` / `assign_session`, merged `.agents`/`.claude` skills, plus the registered-projects catalog each turn) through the OpenChamber completions gateway (public HTTP non-streaming; in-process `onTextDelta` for contact SSE). Assistant bubbles and tool cards persist when they publish (bubble `done:true` / card insert) and turn end upserts any remainder. File parts survive `GET /:id/contact/messages`. The harness forwards those file parts to the completions gateway. Image data URLs reach `promptAsync` only when the connected catalog marks the model as vision-capable; otherwise generate keeps the `[image: …]` description and text-file bytes so a non-vision model cannot stall the contact turn. Gateway 502 responses include `{ error, message }` so the composer can show the OpenCode error string. OpenCode `promptAsync` is not the contact conversation engine; assign uses it only on the **worker** session. Share and queued delivery still use it on Assistant bindings. OpenCode `204` is an admitted empty response. A 404 restores one generation-scoped binding before retrying. Ambiguous network errors preserve the client message ID without another send. Every committed Assistant revision publishes `openchamber:assistants-changed` only after its SQLite transaction completes; clients use the tip to reload the authoritative snapshot after worker-driven stateless binding changes. `POST /:id/session/abort` requires the same `{ sessionID, sessionGeneration }`, returns the binding and `aborted: true`, reports a changed binding as `revision_conflict`, and preserves upstream missing-session `not_found` semantics.
210
231
 
211
232
  `GET /api/openchamber/assistants/:assistantID/messages?before=&limit=` returns archived and current bindings as `{ entries: [{ sessionID, directory, info, parts }], nextCursor, complete }`. It reads assistant-owned mirror rows in chronological session and message order, pages toward older rows with an opaque stable cursor, and preserves the raw OpenCode `info` and `part` JSON. `directory` is the effective workspace for that source session and is nullable for legacy archived rows with an unknown workspace. `complete === (nextCursor === null)` marks arrival at the oldest available history. The service reads the latest persisted `limit + 1` rows first, then demand-backfills one page at a time from the newest incomplete archived session covered by the cursor; a request performs at most three upstream pages and reuses persisted pages without upstream access. Backfill page commits are independent. Each `session.messages` page attempt retries only transient 5xx/network-class SDK failures up to three times with short backoff; persistent failures still throw `upstream_error`. An authoritative OpenCode 404 for an archived or old binding marks that session's backfill complete via the `session-missing` reducer path, deletes only its uncovered event mirrors, preserves covered/admitted rows, and continues scanning other incomplete sessions so one deleted session cannot permanently block Assistant history. Successful backfill pages upsert authoritative rows without clearing provisional mirrors missing from that snapshot. Concurrent ensure that replaces a missing current binding archives the old ID and creates a new one; 404 completion never deletes covered history under either identity. A required-page failure surfaces as an upstream error only when the current request's `pageRows()` is empty; when the current range already has covered or provisional rows, the service returns that partial result and keeps the incomplete cursor retryable. Provisional event mirrors stay readable as `covered=0` fallbacks until a shrink-whitelist signal or a later authoritative upsert elevates them. Current-session live sync overrides the matching SQLite message identity in the UI.
212
233
 
@@ -253,7 +253,8 @@ export function isContactVisiblePart(part) {
253
253
  * Counts complete/error assistant replies and user-facing peer DMs that have
254
254
  * ≥1 visible part (per-part settle filter). User messages, blank rows,
255
255
  * settle-only markers, and non-visible roles are excluded.
256
- * Streaming tokens never create rows one persisted bubble ≡ one count unit.
256
+ * Token-level SSE deltas never create rows. Each published spoken bubble
257
+ * or card is one count unit, including rows persisted before turn end.
257
258
  */
258
259
  export function isContactUnreadCountableMessage(message) {
259
260
  if (!message || typeof message !== 'object') return false;
@@ -1144,6 +1145,29 @@ export function deleteContactMessages(db, assistantID, { upToOrdinal = null } =
1144
1145
  // Assistant delete clears read_state via deleteContactReadState.
1145
1146
  }
1146
1147
 
1148
+ /**
1149
+ * Delete only this turn's assistant-role output rows (spoken bubbles / cards / errors).
1150
+ * Keeps the clearing user, queued users, peers, older turns, watches, generation,
1151
+ * context boundary, and read watermark. Does not call deleteContactMessages.
1152
+ */
1153
+ export function deleteContactTurnAssistantOutputs(db, assistantID, turnID) {
1154
+ if (typeof assistantID !== 'string' || !assistantID.trim()) return 0;
1155
+ if (typeof turnID !== 'string' || !turnID.trim()) return 0;
1156
+ const ids = db.prepare(
1157
+ `SELECT message_id FROM assistant_contact_message
1158
+ WHERE assistant_id=? AND turn_id=? AND role='assistant'`,
1159
+ ).all(assistantID, turnID).map((row) => row.message_id);
1160
+ for (const messageID of ids) {
1161
+ db.prepare('DELETE FROM assistant_contact_part WHERE message_id=?').run(messageID);
1162
+ }
1163
+ if (ids.length === 0) return 0;
1164
+ const result = db.prepare(
1165
+ `DELETE FROM assistant_contact_message
1166
+ WHERE assistant_id=? AND turn_id=? AND role='assistant'`,
1167
+ ).run(assistantID, turnID);
1168
+ return Number(result?.changes) || ids.length;
1169
+ }
1170
+
1147
1171
  /** Durable LLM context watermark for one assistant (0 = no boundary). */
1148
1172
  export function getContactContextBoundary(db, assistantID) {
1149
1173
  const row = db.prepare(
@@ -26,6 +26,7 @@ import {
26
26
  createActiveContactTurn,
27
27
  decodeContactCursor,
28
28
  deleteContactMessages,
29
+ deleteContactTurnAssistantOutputs,
29
30
  encodeContactCursor,
30
31
  ensureContactSchema,
31
32
  getContactContextBoundary,
@@ -57,14 +58,16 @@ const openDb = () => {
57
58
  return db;
58
59
  };
59
60
 
60
- const insert = (db, assistantID, { role, text = '', parts, status = 'complete', fromAssistantID = null, fromAssistantName = null }) => {
61
- const messageID = crypto.randomUUID();
61
+ const insert = (db, assistantID, {
62
+ role, text = '', parts, status = 'complete', fromAssistantID = null, fromAssistantName = null, turnID = null, messageID = null,
63
+ }) => {
64
+ const id = typeof messageID === 'string' && messageID ? messageID : crypto.randomUUID();
62
65
  const ordinal = nextContactOrdinal(db, assistantID);
63
66
  insertContactMessage(db, {
64
- messageID,
67
+ messageID: id,
65
68
  assistantID,
66
69
  role,
67
- turnID: messageID,
70
+ turnID: typeof turnID === 'string' && turnID ? turnID : id,
68
71
  bubbleIndex: 0,
69
72
  createdAt: Date.now(),
70
73
  ordinal,
@@ -73,7 +76,7 @@ const insert = (db, assistantID, { role, text = '', parts, status = 'complete',
73
76
  fromAssistantID,
74
77
  fromAssistantName,
75
78
  });
76
- return { messageID, ordinal };
79
+ return { messageID: id, ordinal };
77
80
  };
78
81
 
79
82
  describe('contact LLM history trim', () => {
@@ -180,6 +183,37 @@ describe('contact LLM history trim', () => {
180
183
  db.close();
181
184
  });
182
185
 
186
+ it('deleteContactTurnAssistantOutputs removes only this turn assistant rows', () => {
187
+ const db = openDb();
188
+ const assistantID = 'asst_turn_out';
189
+ const oldUser = insert(db, assistantID, { role: 'user', text: 'old', turnID: 'turn_old' });
190
+ insert(db, assistantID, { role: 'assistant', text: 'old-reply', turnID: 'turn_old' });
191
+ const clearing = insert(db, assistantID, { role: 'user', text: '开新对话', turnID: 'turn_reset' });
192
+ insert(db, assistantID, { role: 'assistant', text: 'preamble', turnID: 'turn_reset' });
193
+ insert(db, assistantID, {
194
+ role: 'peer',
195
+ text: 'peer keep',
196
+ turnID: 'turn_reset',
197
+ fromAssistantID: 'asst_other',
198
+ fromAssistantName: 'Other',
199
+ });
200
+ const queued = insert(db, assistantID, { role: 'user', text: 'queued', turnID: 'turn_q' });
201
+ const removed = deleteContactTurnAssistantOutputs(db, assistantID, 'turn_reset');
202
+ expect(removed).toBeGreaterThan(0);
203
+ const page = listContactMessages(db, assistantID, { limit: 50 });
204
+ expect(page.messages.map((message) => ({ role: message.role, text: message.text, turn: message.turnID }))).toEqual([
205
+ { role: 'user', text: 'old', turn: 'turn_old' },
206
+ { role: 'assistant', text: 'old-reply', turn: 'turn_old' },
207
+ { role: 'user', text: '开新对话', turn: 'turn_reset' },
208
+ { role: 'peer', text: 'peer keep', turn: 'turn_reset' },
209
+ { role: 'user', text: 'queued', turn: 'turn_q' },
210
+ ]);
211
+ expect(page.messages[0].messageID).toBe(oldUser.messageID);
212
+ expect(page.messages[2].messageID).toBe(clearing.messageID);
213
+ expect(page.messages[4].messageID).toBe(queued.messageID);
214
+ db.close();
215
+ });
216
+
183
217
  it('deleteContactMessages upToOrdinal keeps wipe user + later users; drops late assistant/cards', () => {
184
218
  const db = openDb();
185
219
  const assistantID = 'asst_wipe_bound';
@@ -232,7 +232,7 @@ export function formatRegisteredProjectsPrompt(projects) {
232
232
  return [
233
233
  'Registered projects: none.',
234
234
  'You can see this catalog. There are zero registered projects right now.',
235
- 'Tell the user to add a project in Settings. Never invent a filesystem path and never claim you cannot see the project list.',
235
+ 'Look up existing OpenCode conversations with list_sessions and inspect the working directory to locate the relevant workspace. Once identified, ask the user to register that workspace in Settings before assigning work. Base paths on actual lookup results.',
236
236
  ].join(' ');
237
237
  }
238
238
  return [
@@ -687,7 +687,7 @@ export function formatContactToolsPrompt(tools) {
687
687
  'When they want to view assistant settings / default prompt / system persona (查看助手设定 / 默认提示词 / 系统提示词 / 人设), call get_assistant_settings. Omit `to` for this contact; pass to="OpenCode 配置助手" (or toAssistantID) to read another live assistant.',
688
688
  'When they want to change a default prompt / system persona (改默认提示词 / 设置人设 / 改某助手的默认提示词), call update_default_prompt. That writes Assistant settings and persists — it is not a one-shot message and not new_conversation. Omit `to` for this contact; pass to/name/toAssistantID to update another live assistant without changing this one.',
689
689
  'When they want another assistant (建助理 / create an assistant), call create_assistant.',
690
- 'When they want a separate Chat coding session (建会话 / open a session / 开个新会话), call assign_session after matching the project. To continue an existing chat, call assign_session with that sessionID and a coding prompt. File and shell work in this assistant\'s working directory uses read, write, edit, and bash not assign_session.',
690
+ 'For substantive tasks and requests to open a Chat session (建会话 / open a session / 开个新会话), call assign_session after locating the relevant workspace. To continue an existing chat, call assign_session with that sessionID and a task prompt. Direct read, write, edit, and bash support workspace discovery, simple lookups, and small, bounded configuration changes.',
691
691
  'When they explicitly want to only listen to an existing coding session without sending a prompt (监听会话 / watch session / monitor session), call watch_session with sessionID. A plain @session:id reference alone is context, not a watch request — do not call watch_session unless they asked to listen/watch/monitor.',
692
692
  'When they want to stop/abort a running coding session (停止会话 / stop session), call stop_session with sessionID — that calls real OpenCode session.abort.',
693
693
  'When they want a scheduled task (排定时任务 / schedule daily ping), call schedule_task.',
@@ -747,7 +747,7 @@ export function formatContactToolsPrompt(tools) {
747
747
  'A reply without the tool call does nothing — agreeing in Chinese (好的 / 我来创建 / 我去说一声) is not sending.',
748
748
  'When calling a tool that takes a beat (找项目 / 开会话 / 监听会话), you may say one short spoken line first (≤40 characters, e.g. 我去找一下), then only the fence. No planning, no tool names, no "let me think".',
749
749
  'Never say 已创建, 已发送, 已停止, created, scheduled, opened, watched, stopped, or sent unless the tool already returned success.',
750
- 'If no registered project exists, tell the user to add one in Settings do not use assistant-workspaces.',
750
+ 'With an empty registered catalog, first discover the relevant existing workspace through list_sessions and directory lookups, then ask the user to register that workspace in Settings before assignment.',
751
751
  'After a successful tool, confirm in one short bubble. The user sees a contact card, not tool traces. After successful assign_session or watch_session the session card plus that short confirm is enough — stop.',
752
752
  'These are application-owned OpenChamber contact tools (not OpenCode native tools, not MCP, not skill directory entries). Use only the names and argument schemas below:',
753
753
  'Available OpenChamber tools:',