@yuriai/web 0.1.5 → 0.1.7

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 (93) hide show
  1. package/README.md +3 -1
  2. package/dist/assets/{AgentAvatar-D6H7fWWf.js → AgentAvatar-BVnp9gE9.js} +1 -1
  3. package/dist/assets/AssistantView-BkMKCN9c.js +3 -0
  4. package/dist/assets/{DiagramView-TX1QZ1-S.js → DiagramView-B7_hOmVC.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-eVZbrwte.js → FadeInOnReveal-CzC5UKnq.js} +1 -1
  6. package/dist/assets/{Icon-2cKLcXGe.js → Icon-BXTZRnIO.js} +1 -1
  7. package/dist/assets/{ImageLoadingPlaceholder-DmCEIw55.js → ImageLoadingPlaceholder-Cv6IaJar.js} +1 -1
  8. package/dist/assets/{LoadingImage-B-cD_lfU.js → LoadingImage-NxtWlewi.js} +1 -1
  9. package/dist/assets/{MarkdownRenderer-DGUI8EIG.js → MarkdownRenderer-Va7Wdk98.js} +2 -2
  10. package/dist/assets/{MarkdownRendererImpl-DndseLUT.js → MarkdownRendererImpl-CsjvA5I5.js} +1 -1
  11. package/dist/assets/{MarkstreamRendererImpl-Ehxwt6MS.js → MarkstreamRendererImpl-Du38HKce.js} +1 -1
  12. package/dist/assets/{MobileDetailNavigation-D7uM40jU.js → MobileDetailNavigation-B_PYZ9xi.js} +1 -1
  13. package/dist/assets/MobileShareBridge-BbbQ15B3.js +4 -0
  14. package/dist/assets/MobileTabPageHeader-tiWNRKWN.js +2 -0
  15. package/dist/assets/{MultiRunWindow-CVI6D2LM.js → MultiRunWindow-CFFrKEHW.js} +1 -1
  16. package/dist/assets/{OnboardingScreen-CG2xI_UT.js → OnboardingScreen-BxS5LEOm.js} +1 -1
  17. package/dist/assets/{OverlayScrollbar-piA-chKb.js → OverlayScrollbar-CbWvRXm6.js} +1 -1
  18. package/dist/assets/SettingsView-CKn54R1o.js +26 -0
  19. package/dist/assets/{SettingsWindow-Cj6sYyvK.js → SettingsWindow-BKatx9Q_.js} +1 -1
  20. package/dist/assets/{TerminalView-DW4OxMaM.js → TerminalView-h15pBKLx.js} +2 -2
  21. package/dist/assets/{ToolOutputDialog-DxKGYhQ6.js → ToolOutputDialog-Bj-TWElC.js} +1 -1
  22. package/dist/assets/{appThemeRegistry-DlEr4DLc.js → appThemeRegistry-nntgbF9E.js} +1 -1
  23. package/dist/assets/button-D_HdP1Oy.js +1 -0
  24. package/dist/assets/{capgoAdapter-Onw1miMh.js → capgoAdapter-BV5kJq2r.js} +1 -1
  25. package/dist/assets/{collapsible-BRKOX_ML.js → collapsible-sUU_ORMe.js} +1 -1
  26. package/dist/assets/{diagram-C9F3kAJt.js → diagram-BE50CEiR.js} +1 -1
  27. package/dist/assets/{dialog-NsOVcGdh.js → dialog-B_87Teec.js} +1 -1
  28. package/dist/assets/{dropdown-menu.styles-CjPDePJF.js → dropdown-menu.styles-C_b80jxk.js} +1 -1
  29. package/dist/assets/{fileMentionTouchSelection-B26MejYr.js → fileMentionTouchSelection-Bx6vWiqF.js} +1 -1
  30. package/dist/assets/{gitApi-BXhXdWkh.js → gitApi-B1arMnHR.js} +1 -1
  31. package/dist/assets/{input-BI3Ya8O_.js → input-DDfJnn8e.js} +1 -1
  32. package/dist/assets/{insertionBoundaries-BxSGE3B7.js → insertionBoundaries-B8UdVMC9.js} +1 -1
  33. package/dist/assets/{main-BdV8Awp-.js → main-BLSF9qdf.js} +2 -2
  34. package/dist/assets/{main-CGpFMQTy.js → main-DJwLyPc3.js} +12 -12
  35. package/dist/assets/{markdownHeightCache-CHFSvA8u.js → markdownHeightCache-Cbd62oWw.js} +1 -1
  36. package/dist/assets/{mcpQueries-uZJFBhGj.js → mcpQueries-BxbbmYAd.js} +1 -1
  37. package/dist/assets/{miniChat-B1aBBLiq.js → miniChat-D_cBND3Z.js} +2 -2
  38. package/dist/assets/{mobile-B8U8FARq.js → mobile-CZNFYieO.js} +2 -2
  39. package/dist/assets/{mobileBackNavigation-D2yUxPpG.js → mobileBackNavigation-f8AMDP0N.js} +1 -1
  40. package/dist/assets/{multirun-DKsVtdZX.js → multirun-Cvl4FMPo.js} +1 -1
  41. package/dist/assets/{native-config-Bpk4DOW1.js → native-config-DLJCZcXh.js} +1 -1
  42. package/dist/assets/{projectMeta-yox5XHhN.js → projectMeta-D3cfkJyk.js} +1 -1
  43. package/dist/assets/{renderElectronMiniChatApp-CdEZXhzq.js → renderElectronMiniChatApp-_n69Pa_v.js} +2 -2
  44. package/dist/assets/renderMobileApp-DUiUPyd7.js +28 -0
  45. package/dist/assets/{runtimeConfig-B8s9sTX_.js → runtimeConfig-DwFJL5bm.js} +1 -1
  46. package/dist/assets/runtimeEndpointReset-C_1daihM.js +149 -0
  47. package/dist/assets/{scheduledTaskNavigation-DUskP6qh.js → scheduledTaskNavigation-DvSpMLdG.js} +1 -1
  48. package/dist/assets/sessionChangeSummary-evl_LmRN.js +1 -0
  49. package/dist/assets/sessionLookup-CJ8mXxHt.js +1 -0
  50. package/dist/assets/{sortable-tabs-strip-Benojjgi.js → sortable-tabs-strip-CrjW2aQk.js} +1 -1
  51. package/dist/assets/{speechSettingsQueries-CjTT-adl.js → speechSettingsQueries-xWFTnUfF.js} +1 -1
  52. package/dist/assets/src-omVWmz3o.css +2 -0
  53. package/dist/assets/{textarea-C5vjnZcE.js → textarea-BkOdxUN0.js} +1 -1
  54. package/dist/assets/{tooltip-C73L9gDf.js → tooltip-BQQoK7L_.js} +1 -1
  55. package/dist/assets/{types-Bu1xM0fS.js → types-BzF7Dhzq.js} +1 -1
  56. package/dist/assets/{useAppFontEffects-D6yauGdg.js → useAppFontEffects-lmtncXsi.js} +7 -7
  57. package/dist/assets/{useDesktopWindowControlsLayout-SKHaFoVu.js → useDesktopWindowControlsLayout-BY0_whx0.js} +1 -1
  58. package/dist/assets/{useEffectiveDirectory-D6GwXhDS.js → useEffectiveDirectory-CqkTJqRg.js} +1 -1
  59. package/dist/assets/{useMobileNavigationStore-ByrZJd5L.js → useMobileNavigationStore-BURkCJuM.js} +4 -4
  60. package/dist/assets/{useWorkerHighlightedLines-CwC34Jql.js → useWorkerHighlightedLines-Bla356tF.js} +1 -1
  61. package/dist/index.html +6 -6
  62. package/dist/mini-chat.html +6 -6
  63. package/dist/mobile.html +6 -6
  64. package/package.json +1 -1
  65. package/server/lib/assistants/DOCUMENTATION.md +13 -2
  66. package/server/lib/assistants/contact-store.js +2 -1
  67. package/server/lib/assistants/contact-store.test.js +13 -3
  68. package/server/lib/assistants/routes.js +2 -1
  69. package/server/lib/assistants/service.js +54 -7
  70. package/server/lib/assistants/service.test.js +59 -10
  71. package/server/lib/content-search/DOCUMENTATION.md +5 -1
  72. package/server/lib/opencode/DOCUMENTATION.md +8 -7
  73. package/server/lib/opencode/config-entity-routes.js +5 -1
  74. package/server/lib/opencode/config-entity-routes.test.js +23 -0
  75. package/server/lib/opencode/ensure-cli.js +48 -11
  76. package/server/lib/opencode/ensure-cli.test.js +53 -1
  77. package/server/lib/opencode/env-runtime.js +5 -5
  78. package/server/lib/opencode/feature-routes-runtime.js +1 -1
  79. package/server/lib/opencode/terminal-path.js +67 -0
  80. package/server/lib/opencode/terminal-path.test.js +68 -0
  81. package/server/lib/relay/DOCUMENTATION.md +2 -0
  82. package/server/lib/relay/service.js +1 -1
  83. package/server/lib/relay/service.test.js +3 -3
  84. package/dist/assets/AssistantView-D3j8FlaF.js +0 -3
  85. package/dist/assets/MobileShareBridge-BMQKnoEb.js +0 -4
  86. package/dist/assets/MobileTabPageHeader-Cco8ToA6.js +0 -2
  87. package/dist/assets/SettingsView-Bx-5WRtO.js +0 -26
  88. package/dist/assets/button-_ReBqWzC.js +0 -1
  89. package/dist/assets/renderMobileApp-BoV5wjPF.js +0 -28
  90. package/dist/assets/runtimeEndpointReset-BswmG9Bb.js +0 -149
  91. package/dist/assets/sessionChangeSummary-BGbmGpQw.js +0 -1
  92. package/dist/assets/sessionLookup-CGJA6W7O.js +0 -1
  93. package/dist/assets/src-CmermOKP.css +0 -2
@@ -1,4 +1,4 @@
1
- import{r as e}from"./rolldown-runtime-hePW80VL.js";import{it as t,rt as n}from"./vendor-base-ui-CdzJ6My_.js";import{v as r}from"./useUIStore-NMQx_cnG.js";import{n as i}from"./vendor-react-BP2cRVqX.js";import{f as a,r as o,t as s}from"./button-_ReBqWzC.js";import{n as c}from"./useThemeSystem-8jhE4Zyk.js";import{n as l}from"./Icon-2cKLcXGe.js";import{i as u,n as d,t as f}from"./tooltip-C73L9gDf.js";import{n as p}from"./MarkdownRenderer-DGUI8EIG.js";import{n as m,o as h,s as g,t as _}from"./markdown-worker-CkO3sT4Y.js";import{t as v}from"./vendor-tanstack-react-virtual-B_EO--9t.js";import{n as y}from"./markdownTheme-DHoKvD6O.js";var b=e(t(),1),x=i(),S=n(),C=`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)]`,w={maxWidth:`min(28rem, calc(100cqw - 4ch))`,backgroundColor:`var(--surface-elevated)`,color:`var(--surface-elevated-foreground)`},T=`flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] px-3 py-2.5`,E=`text-xs font-medium leading-4 text-foreground md:text-[0.8125rem] md:leading-5`,D=`text-xs tabular-nums leading-4 text-muted-foreground md:text-[0.8125rem] md:leading-5`,O=`m-0 list-none divide-y divide-[var(--surface-subtle)] p-0`,k=`${O} max-h-[min(22rem,50vh)] overflow-y-auto`,A=`${O} w-full`,ee={className:`p-0 rounded-none`,maxHeightClass:`max-h-[46vh]`,disableHorizontal:!0},j=`px-2.5 py-1.5 text-xs leading-4 md:py-2 md:text-[0.8125rem] md:leading-5`,M=`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`,te=`min-w-0 truncate text-xs leading-4 text-foreground md:text-[0.8125rem] md:leading-5`,ne=`inline-flex shrink-0 items-center gap-1 text-xs leading-4 tabular-nums text-muted-foreground md:text-[0.8125rem] md:leading-5`,re=`h-3 w-3 shrink-0 md:h-3.5 md:w-3.5`,ie={in_progress:`text-foreground`,pending:`text-foreground`,completed:`text-muted-foreground line-through`,cancelled:`text-muted-foreground line-through`},ae={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`},N=e=>{let t=(0,x.c)(26),{todo:n,isActive:r,activeRef:i}=e,{t:s}=a(),c=oe.has(n.status)?n.status:`pending`,p=ae[c],m;t[0]!==s||t[1]!==p?(m=s(p),t[0]=s,t[1]=p,t[2]=m):m=t[2];let h=m,g;t[3]===c?g=t[4]:(g=c===`in_progress`?(0,S.jsx)(l,{name:`record-circle`,className:`h-3.5 w-3.5 text-[var(--status-info)]`,"aria-hidden":`true`}):c===`completed`?(0,S.jsx)(l,{name:`checkbox-circle`,className:`h-3.5 w-3.5 text-[var(--status-success)]`,"aria-hidden":`true`}):(0,S.jsx)(l,{name:`time`,className:`h-3.5 w-3.5 text-muted-foreground`,"aria-hidden":`true`}),t[3]=c,t[4]=g);let _=g,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=o(`group relative grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] items-start gap-2.5`,j,y),t[5]=y,t[6]=b);let C;t[7]!==_||t[8]!==h?(C=(0,S.jsx)(u,{asChild:!0,children:(0,S.jsx)(`span`,{className:`flex size-4 items-center justify-center`,"aria-label":h,children:_})}),t[7]=_,t[8]=h,t[9]=C):C=t[9];let w;t[10]===h?w=t[11]:(w=(0,S.jsx)(d,{side:`left`,sideOffset:6,children:h}),t[10]=h,t[11]=w);let T;t[12]!==C||t[13]!==w?(T=(0,S.jsxs)(f,{children:[C,w]}),t[12]=C,t[13]=w,t[14]=T):T=t[14];let E=ie[c],D;t[15]===E?D=t[16]:(D=o(`block whitespace-normal break-words`,E),t[15]=E,t[16]=D);let O;t[17]!==D||t[18]!==n.content?(O=(0,S.jsx)(`span`,{className:`min-w-0 flex-1`,children:(0,S.jsx)(`span`,{className:D,children:n.content})}),t[17]=D,t[18]=n.content,t[19]=O):O=t[19];let k;return t[20]!==i||t[21]!==O||t[22]!==v||t[23]!==b||t[24]!==T?(k=(0,S.jsxs)(`li`,{ref:i,"aria-current":v,className:b,children:[T,O]}),t[20]=i,t[21]=O,t[22]=v,t[23]=b,t[24]=T,t[25]=k):k=t[25],k},oe=new Set([`in_progress`,`pending`,`completed`,`cancelled`]),P=e=>e.replace(/&/g,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`).replace(/'/g,`&#39;`),F=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(``):``,I=(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=F(n);e&&i.setAttribute(`style`,`${i.getAttribute(`style`)??``}${e}`)}},L=e=>`<pre><code>${P(e)}</code></pre>`,R=e=>{let t=(0,x.c)(21),{code:n,language:r,className:i,style:a,codeStyle:s,wrap:l}=e,u=l!==void 0&&l,{currentTheme:d}=c(),f=b.useRef(null),p;t[0]===d?p=t[1]:(p=y(d),t[0]=d,t[1]=p);let m=p,h,g;t[2]!==n||t[3]!==s||t[4]!==u?(h=()=>{let e=f.current;e&&(e.innerHTML=L(n),I(e,u,s))},g=[n,u,s],t[2]=n,t[3]=s,t[4]=u,t[5]=h,t[6]=g):(h=t[5],g=t[6]),b.useLayoutEffect(h,g);let v,C;t[7]!==n||t[8]!==s||t[9]!==r||t[10]!==u?(v=()=>{let e=f.current;if(!e)return;let t=!0,i=new AbortController;return _(n,(r||`text`).toLowerCase(),{signal:i.signal,priority:`visible`}).then(n=>{!t||!e||!n||(e.innerHTML=n,I(e,u,s))}),()=>{t=!1,i.abort()}},C=[n,r,u,s],t[7]=n,t[8]=s,t[9]=r,t[10]=u,t[11]=v,t[12]=C):(v=t[11],C=t[12]),b.useEffect(v,C);let w;t[13]===i?w=t[14]:(w=o(`typography-code`,i),t[13]=i,t[14]=w);let T=m,E;t[15]!==a||t[16]!==T?(E={...T,...a},t[15]=a,t[16]=T,t[17]=E):E=t[17];let D;return t[18]!==w||t[19]!==E?(D=(0,S.jsx)(`div`,{ref:f,className:w,style:E}),t[18]=w,t[19]=E,t[20]=D):D=t[20],D},z=e=>{let t=e.replace(/^<file>\s*\n?/,``).replace(/\n?<\/file>\s*$/,``);return t=t.replace(/^\s*\d{5}\|\s?/gm,``),t.trim()},B=(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}},V=e=>e?e.includes(`<diagnostics`)||e.includes(`<file_diagnostics>`)||e.includes(`LSP errors detected`)||e.includes(`This file has errors`):!1,H=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():``,U=(e,t)=>!e||typeof e!=`object`?String(e):g(e,t||``),W=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}}},G=e=>{if(!e||typeof e!=`object`)return;let t=W(e.patch)??W(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=W(e.patch)??W(e.diff);if(t)return t}},K=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}}},q=(e,t,n)=>{let r=z(e);if((t===`edit`||t===`multiedit`||t===`write`||t===`apply_patch`)&&V(r)&&(r=H(r)),(t===`edit`||t===`multiedit`||t===`apply_patch`)&&r.trim().length===0){let e=G(n);if(e)return e}return r},se=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{r as e}from"./rolldown-runtime-hePW80VL.js";import{it as t,rt as n}from"./vendor-base-ui-CdzJ6My_.js";import{v as r}from"./useUIStore-NMQx_cnG.js";import{n as i}from"./vendor-react-BP2cRVqX.js";import{f as a,r as o,t as s}from"./button-D_HdP1Oy.js";import{n as c}from"./useThemeSystem-8jhE4Zyk.js";import{n as l}from"./Icon-BXTZRnIO.js";import{i as u,n as d,t as f}from"./tooltip-BQQoK7L_.js";import{n as p}from"./MarkdownRenderer-Va7Wdk98.js";import{n as m,o as h,s as g,t as _}from"./markdown-worker-CkO3sT4Y.js";import{t as v}from"./vendor-tanstack-react-virtual-B_EO--9t.js";import{n as y}from"./markdownTheme-DHoKvD6O.js";var b=e(t(),1),x=i(),S=n(),C=`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)]`,w={maxWidth:`min(28rem, calc(100cqw - 4ch))`,backgroundColor:`var(--surface-elevated)`,color:`var(--surface-elevated-foreground)`},T=`flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] px-3 py-2.5`,E=`text-xs font-medium leading-4 text-foreground md:text-[0.8125rem] md:leading-5`,D=`text-xs tabular-nums leading-4 text-muted-foreground md:text-[0.8125rem] md:leading-5`,O=`m-0 list-none divide-y divide-[var(--surface-subtle)] p-0`,k=`${O} max-h-[min(22rem,50vh)] overflow-y-auto`,A=`${O} w-full`,ee={className:`p-0 rounded-none`,maxHeightClass:`max-h-[46vh]`,disableHorizontal:!0},j=`px-2.5 py-1.5 text-xs leading-4 md:py-2 md:text-[0.8125rem] md:leading-5`,M=`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`,te=`min-w-0 truncate text-xs leading-4 text-foreground md:text-[0.8125rem] md:leading-5`,ne=`inline-flex shrink-0 items-center gap-1 text-xs leading-4 tabular-nums text-muted-foreground md:text-[0.8125rem] md:leading-5`,re=`h-3 w-3 shrink-0 md:h-3.5 md:w-3.5`,ie={in_progress:`text-foreground`,pending:`text-foreground`,completed:`text-muted-foreground line-through`,cancelled:`text-muted-foreground line-through`},ae={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`},N=e=>{let t=(0,x.c)(26),{todo:n,isActive:r,activeRef:i}=e,{t:s}=a(),c=oe.has(n.status)?n.status:`pending`,p=ae[c],m;t[0]!==s||t[1]!==p?(m=s(p),t[0]=s,t[1]=p,t[2]=m):m=t[2];let h=m,g;t[3]===c?g=t[4]:(g=c===`in_progress`?(0,S.jsx)(l,{name:`record-circle`,className:`h-3.5 w-3.5 text-[var(--status-info)]`,"aria-hidden":`true`}):c===`completed`?(0,S.jsx)(l,{name:`checkbox-circle`,className:`h-3.5 w-3.5 text-[var(--status-success)]`,"aria-hidden":`true`}):(0,S.jsx)(l,{name:`time`,className:`h-3.5 w-3.5 text-muted-foreground`,"aria-hidden":`true`}),t[3]=c,t[4]=g);let _=g,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=o(`group relative grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] items-start gap-2.5`,j,y),t[5]=y,t[6]=b);let C;t[7]!==_||t[8]!==h?(C=(0,S.jsx)(u,{asChild:!0,children:(0,S.jsx)(`span`,{className:`flex size-4 items-center justify-center`,"aria-label":h,children:_})}),t[7]=_,t[8]=h,t[9]=C):C=t[9];let w;t[10]===h?w=t[11]:(w=(0,S.jsx)(d,{side:`left`,sideOffset:6,children:h}),t[10]=h,t[11]=w);let T;t[12]!==C||t[13]!==w?(T=(0,S.jsxs)(f,{children:[C,w]}),t[12]=C,t[13]=w,t[14]=T):T=t[14];let E=ie[c],D;t[15]===E?D=t[16]:(D=o(`block whitespace-normal break-words`,E),t[15]=E,t[16]=D);let O;t[17]!==D||t[18]!==n.content?(O=(0,S.jsx)(`span`,{className:`min-w-0 flex-1`,children:(0,S.jsx)(`span`,{className:D,children:n.content})}),t[17]=D,t[18]=n.content,t[19]=O):O=t[19];let k;return t[20]!==i||t[21]!==O||t[22]!==v||t[23]!==b||t[24]!==T?(k=(0,S.jsxs)(`li`,{ref:i,"aria-current":v,className:b,children:[T,O]}),t[20]=i,t[21]=O,t[22]=v,t[23]=b,t[24]=T,t[25]=k):k=t[25],k},oe=new Set([`in_progress`,`pending`,`completed`,`cancelled`]),P=e=>e.replace(/&/g,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`).replace(/'/g,`&#39;`),F=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(``):``,I=(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=F(n);e&&i.setAttribute(`style`,`${i.getAttribute(`style`)??``}${e}`)}},L=e=>`<pre><code>${P(e)}</code></pre>`,R=e=>{let t=(0,x.c)(21),{code:n,language:r,className:i,style:a,codeStyle:s,wrap:l}=e,u=l!==void 0&&l,{currentTheme:d}=c(),f=b.useRef(null),p;t[0]===d?p=t[1]:(p=y(d),t[0]=d,t[1]=p);let m=p,h,g;t[2]!==n||t[3]!==s||t[4]!==u?(h=()=>{let e=f.current;e&&(e.innerHTML=L(n),I(e,u,s))},g=[n,u,s],t[2]=n,t[3]=s,t[4]=u,t[5]=h,t[6]=g):(h=t[5],g=t[6]),b.useLayoutEffect(h,g);let v,C;t[7]!==n||t[8]!==s||t[9]!==r||t[10]!==u?(v=()=>{let e=f.current;if(!e)return;let t=!0,i=new AbortController;return _(n,(r||`text`).toLowerCase(),{signal:i.signal,priority:`visible`}).then(n=>{!t||!e||!n||(e.innerHTML=n,I(e,u,s))}),()=>{t=!1,i.abort()}},C=[n,r,u,s],t[7]=n,t[8]=s,t[9]=r,t[10]=u,t[11]=v,t[12]=C):(v=t[11],C=t[12]),b.useEffect(v,C);let w;t[13]===i?w=t[14]:(w=o(`typography-code`,i),t[13]=i,t[14]=w);let T=m,E;t[15]!==a||t[16]!==T?(E={...T,...a},t[15]=a,t[16]=T,t[17]=E):E=t[17];let D;return t[18]!==w||t[19]!==E?(D=(0,S.jsx)(`div`,{ref:f,className:w,style:E}),t[18]=w,t[19]=E,t[20]=D):D=t[20],D},z=e=>{let t=e.replace(/^<file>\s*\n?/,``).replace(/\n?<\/file>\s*$/,``);return t=t.replace(/^\s*\d{5}\|\s?/gm,``),t.trim()},B=(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}},V=e=>e?e.includes(`<diagnostics`)||e.includes(`<file_diagnostics>`)||e.includes(`LSP errors detected`)||e.includes(`This file has errors`):!1,H=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():``,U=(e,t)=>!e||typeof e!=`object`?String(e):g(e,t||``),W=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}}},G=e=>{if(!e||typeof e!=`object`)return;let t=W(e.patch)??W(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=W(e.patch)??W(e.diff);if(t)return t}},K=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}}},q=(e,t,n)=>{let r=z(e);if((t===`edit`||t===`multiedit`||t===`write`||t===`apply_patch`)&&V(r)&&(r=H(r)),(t===`edit`||t===`multiedit`||t===`apply_patch`)&&r.trim().length===0){let e=G(n);if(e)return e}return r},se=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)})}},ce=(e,t)=>{try{let n=e.trim().split(`
4
4
  `).filter(Boolean);if(n.length===0)return null;let i=[];return n.forEach(e=>{let t=e.match(/^(\s*)(.+)$/);if(t){let[,e,n]=t,r=Math.floor(e.length/2),a=!n.endsWith(`/`);i.push({name:n.replace(/\/$/,``),depth:r,isFile:a})}}),(0,S.jsx)(`div`,{className:o(`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:r.tool.popup,children:i.map((e,t)=>(0,S.jsx)(`div`,{className:`min-w-0`,style:{paddingLeft:`${e.depth*20}px`},children:e.isFile?(0,S.jsx)(`span`,{className:`text-foreground/90 block truncate`,children:e.name}):(0,S.jsxs)(`span`,{className:`font-semibold text-foreground block truncate`,children:[e.name,`/`]})},t))})}catch{return null}},le={backgroundColor:`var(--status-info)`,opacity:.6},ue=(e,t,n)=>{try{let i=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-BdV8Awp-.js"></script>
555
+ <script type="module" crossorigin src="/assets/main-BLSF9qdf.js"></script>
556
556
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
557
557
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-ef9W7A43.js">
558
558
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-CAZiOV2G.js">
@@ -575,11 +575,11 @@
575
575
  <link rel="modulepreload" crossorigin href="/assets/platform-7bX6qtBV.js">
576
576
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-BHXvha-U.js">
577
577
  <link rel="modulepreload" crossorigin href="/assets/runtime-file-download-DAOam_oV.js">
578
- <link rel="modulepreload" crossorigin href="/assets/types-Bu1xM0fS.js">
579
- <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-Onw1miMh.js">
580
- <link rel="modulepreload" crossorigin href="/assets/native-config-Bpk4DOW1.js">
581
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-B8s9sTX_.js">
582
- <link rel="stylesheet" crossorigin href="/assets/src-CmermOKP.css">
578
+ <link rel="modulepreload" crossorigin href="/assets/types-BzF7Dhzq.js">
579
+ <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-BV5kJq2r.js">
580
+ <link rel="modulepreload" crossorigin href="/assets/native-config-DLJCZcXh.js">
581
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DwFJL5bm.js">
582
+ <link rel="stylesheet" crossorigin href="/assets/src-omVWmz3o.css">
583
583
  </head>
584
584
  <body class="h-full bg-background text-foreground">
585
585
  <!-- 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-B1aBBLiq.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-D_cBND3Z.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-ef9W7A43.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-CAZiOV2G.js">
@@ -97,11 +97,11 @@
97
97
  <link rel="modulepreload" crossorigin href="/assets/platform-7bX6qtBV.js">
98
98
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-BHXvha-U.js">
99
99
  <link rel="modulepreload" crossorigin href="/assets/runtime-file-download-DAOam_oV.js">
100
- <link rel="modulepreload" crossorigin href="/assets/types-Bu1xM0fS.js">
101
- <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-Onw1miMh.js">
102
- <link rel="modulepreload" crossorigin href="/assets/native-config-Bpk4DOW1.js">
103
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-B8s9sTX_.js">
104
- <link rel="stylesheet" crossorigin href="/assets/src-CmermOKP.css">
100
+ <link rel="modulepreload" crossorigin href="/assets/types-BzF7Dhzq.js">
101
+ <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-BV5kJq2r.js">
102
+ <link rel="modulepreload" crossorigin href="/assets/native-config-DLJCZcXh.js">
103
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DwFJL5bm.js">
104
+ <link rel="stylesheet" crossorigin href="/assets/src-omVWmz3o.css">
105
105
  </head>
106
106
  <body class="h-full bg-background text-foreground">
107
107
  <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-B8U8FARq.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-CZNFYieO.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-ef9W7A43.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-CAZiOV2G.js">
@@ -86,11 +86,11 @@
86
86
  <link rel="modulepreload" crossorigin href="/assets/platform-7bX6qtBV.js">
87
87
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-BHXvha-U.js">
88
88
  <link rel="modulepreload" crossorigin href="/assets/runtime-file-download-DAOam_oV.js">
89
- <link rel="modulepreload" crossorigin href="/assets/types-Bu1xM0fS.js">
90
- <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-Onw1miMh.js">
91
- <link rel="modulepreload" crossorigin href="/assets/native-config-Bpk4DOW1.js">
92
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-B8s9sTX_.js">
93
- <link rel="stylesheet" crossorigin href="/assets/src-CmermOKP.css">
89
+ <link rel="modulepreload" crossorigin href="/assets/types-BzF7Dhzq.js">
90
+ <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-BV5kJq2r.js">
91
+ <link rel="modulepreload" crossorigin href="/assets/native-config-DLJCZcXh.js">
92
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DwFJL5bm.js">
93
+ <link rel="stylesheet" crossorigin href="/assets/src-omVWmz3o.css">
94
94
  </head>
95
95
  <body class="h-full bg-background text-foreground">
96
96
  <div id="root" class="h-full"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuriai/web",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -1,5 +1,14 @@
1
1
  # Assistants Module
2
2
 
3
+ ## Contact activity ordering
4
+
5
+ Both individual and batched latest-message previews carry the persisted message's
6
+ `created_at` as `createdAt`. The bounded candidate SQL must select this column and
7
+ `messageFromPreviewRow` must preserve it before preview projection; otherwise JSON
8
+ silently omits the ordering field. Contact-list order and navigation avatar identity
9
+ use this timestamp, never assistant configuration `updatedAt`. Regression fixtures
10
+ return the timestamp actually inserted, and snapshot tests require a finite value.
11
+
3
12
  ## Transient contact errors
4
13
 
5
14
  Contact-turn failures are delivered through `yuri:contact-turn-end` only,
@@ -177,7 +186,7 @@ and keep their existing error result handling.
177
186
  - **`watch_session`:** listen-only attach for an existing session (`sessionID` required). No `session.prompt`. 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.
178
187
  - **`stop_session`:** calls real OpenCode `session.interrupt` 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.
179
188
  - **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.
180
- - 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.
189
+ - 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. Contact prompts project only favoriteModels/recentModels from the strict settings reader; defaultModel/defaultVariant are used separately for initial contact creation, 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.
181
190
  - **Current-turn attachments:** server-authoritative `userParts` file parts from this contact turn are forwarded into worker `session.prompt` 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.
182
191
  - **Create-then-prompt failure:** if `session.create` succeeds and `session.prompt` fails, assign best-effort `session.delete`s the new worker so empty sessions are not left behind. Reused `sessionID` paths never delete on prompt failure.
183
192
  - Successful `assign_session` and `watch_session` return `terminate: true` to stop the mutation loop. The harness then requests a tools-disabled model summary. 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.
@@ -324,7 +333,9 @@ Snapshots expose the latest 50 `historySessionIDs` in chronological order and `h
324
333
 
325
334
  `assistants` owns Assistant configuration, session bindings, and contact/share metadata in `assistants.sqlite`. OpenCode message bodies are read from upstream projections, not mirrored. Routes register before the generic OpenCode proxy.
326
335
 
327
- `GET /api/yuri/assistants/capability` returns `supported`, the persisted `enabled` switch, domain revision, relay-derived stable `serverInstanceID`, and explicit v2 gaps `sharingAvailable` (always `false`), `archiveMetadataAvailable` (true only when Host archive is injected), and `sessionMetadataAvailable` (always `false`). Fresh databases default `enabled` to off; Settings flips the switch, and product navigation entries stay hidden until it is on. `PUT /api/yuri/assistants/settings` changes the global switch with `expectedRevision`. CRUD and snapshots live under `/api/yuri/assistants`. Session routes are `POST /:id/session/ensure`, `/:id/session/new`, and `/:id/session/compact`; composer sends use `POST /:id/messages` (contact harness, not `session.prompt`); contact history is `GET /:id/contact/messages` (full transcript, including rows before a clear-memory boundary); contact reset wipe is `POST /:id/contact/reset`; shared contact read watermark is `POST /:id/contact/read`; session cards are `POST /:id/contact/cards`; read-only inter-assistant DMs are `POST /:fromID/contact/dm`; assistant-owned scheduled tasks are `GET /:id/scheduled-tasks`. Shares still use `POST /:id/share` and `GET /share-operations/:operationID`.
336
+ `GET /api/yuri/assistants/capability` returns `supported`, `enabled: true`, domain revision, relay-derived stable `serverInstanceID`, and explicit v2 gaps `sharingAvailable` (always `false`), `archiveMetadataAvailable` (true only when Host archive is injected), and `sessionMetadataAvailable` (always `false`). Assistants are always enabled. Startup atomically upgrades an old disabled instance flag and advances its revision without changing contacts. The legacy `PUT /api/yuri/assistants/settings` validates `expectedRevision` but returns `enabled: true` without a mutation, so older clients cannot globally disable the feature. Individual contact enable/disable remains supported. CRUD and snapshots live under `/api/yuri/assistants`. Session routes are `POST /:id/session/ensure`, `/:id/session/new`, and `/:id/session/compact`; composer sends use `POST /:id/messages` (contact harness, not `session.prompt`); contact history is `GET /:id/contact/messages` (full transcript, including rows before a clear-memory boundary); contact reset wipe is `POST /:id/contact/reset`; shared contact read watermark is `POST /:id/contact/read`; session cards are `POST /:id/contact/cards`; read-only inter-assistant DMs are `POST /:fromID/contact/dm`; assistant-owned scheduled tasks are `GET /:id/scheduled-tasks`. Shares still use `POST /:id/share` and `GET /share-operations/:operationID`.
337
+
338
+ Both HTTP snapshot aliases call `ensureDefaultAssistant` before reading. A never-used catalog automatically gets one enabled `Yuri` contact in its managed workspace, with no OpenCode session or model call. It uses the configured default model/variant if connected, otherwise the first connected catalog model. No configured models leaves the catalog empty until a later read after provider setup; catalog/preferences failures reject the read and remain retryable. Same-process initialization is single-flight, and the SQLite write transaction rechecks all rows (including tombstones) so multiple clients/processes cannot seed duplicates or resurrect an explicitly deleted last contact. Existing catalogs bypass model reads entirely. The preference dependency also supplies `defaultModel`/`defaultVariant` for seeding; only favorites/recents enter contact prompts.
328
339
 
329
340
  `AssistantDTO.mode` remains `continuous` or `stateless` (default `continuous`) for share/queue OpenCode session binding. It is **not** a user-facing memory setting: Settings no longer exposes the continuous/stateless toggle, and new assistants stay continuous contact history with server-side LLM trim. `historySessionIDs` lists prior OpenCode session bindings in chronological order (oldest first, capped). Continuous Assistants keep one long-lived current session across composer turns. Stateless Assistants create and bind a fresh OpenCode session for every composer send, share admission, and queued delivery so the model executes without prior transcript context; prior sessions remain available in OpenCode history. The Assistant surface follows the current binding, so each stateless turn rematerializes onto that fresh session. `AssistantDTO.workspacePath` retains the configuration value: `null` selects the managed workspace and a string selects a registered project path. `AssistantDTO.managedWorkspacePath` always carries the canonical managed target at `dataDir/assistant-workspaces/<assistantID>`, including for project-configured Assistants. `AssistantDTO.effectiveWorkspacePath` carries the resolved OpenCode session directory. `AssistantDTO.variant` is nullable and reaches OpenCode `session.prompt` with provider, model, agent, and system prompt. `SessionBinding` is `{ sessionID, directory, sessionGeneration }` with the effective directory. `ensure` and `new` return `SessionBinding`; `compact` returns `{ binding, summarized: true }`; messages return `{ binding, messageID, admitted: true }`; shares return top-level `{ operationID, assistantID, sessionID, messageID, state, phase, attempt, leaseExpiresAt, errorCode }`. `contracts.js` exports serialization fixtures for these DTOs. Binding replacement uses generation CAS, so concurrent creation retains one winner while orphan sessions stay available in OpenCode history. Workspace changes create and bind a session in the new directory. Session metadata carries `yuri.assistant.assistantID` and `name` as the authoritative ownership marker. New OpenCode sessions use the fixed human title `[Assistant] ${name}` and persist that isolation through the Host session-metadata store immediately after create (`persistSessionMetadata` + `onSystemSessionPersisted`) so the sidebar index drops them without waiting for list sync. They are archived via Host archive immediately after create and before binding is returned, so ordinary active lists never flash them; metadata isolation still applies if archive fails after create. The session index and global session lists hide system sessions by this metadata (never by title prefix). Assistant history and direct source open remain the entry points.
330
341
 
@@ -821,6 +821,7 @@ const messageFromPreviewRow = (row, parts) => {
821
821
  role: row.role,
822
822
  ordinal: row.ordinal,
823
823
  status: row.status,
824
+ createdAt: row.created_at,
824
825
  parts: list,
825
826
  text: list.filter((part) => part.type === 'text').map((part) => part.text).join(''),
826
827
  };
@@ -849,7 +850,7 @@ const pickPreviewFromCandidateRows = (rows, partsByMessage) => {
849
850
  * occupy a slot and force a deeper probe only within the same LIMIT.
850
851
  */
851
852
  const CONTACT_PREVIEW_CANDIDATE_SQL = `
852
- SELECT message_id, assistant_id, role, ordinal, status
853
+ SELECT message_id, assistant_id, role, ordinal, status, created_at
853
854
  FROM assistant_contact_message m
854
855
  WHERE m.assistant_id = ?
855
856
  AND m.role IN ('user', 'assistant', 'peer')
@@ -59,7 +59,7 @@ const openDb = () => {
59
59
  };
60
60
 
61
61
  const insert = (db, assistantID, {
62
- role, text = '', parts, status = 'complete', fromAssistantID = null, fromAssistantName = null, turnID = null, messageID = null,
62
+ role, text = '', parts, status = 'complete', fromAssistantID = null, fromAssistantName = null, turnID = null, messageID = null, createdAt = Date.now(),
63
63
  }) => {
64
64
  const id = typeof messageID === 'string' && messageID ? messageID : crypto.randomUUID();
65
65
  const ordinal = nextContactOrdinal(db, assistantID);
@@ -69,14 +69,14 @@ const insert = (db, assistantID, {
69
69
  role,
70
70
  turnID: typeof turnID === 'string' && turnID ? turnID : id,
71
71
  bubbleIndex: 0,
72
- createdAt: Date.now(),
72
+ createdAt,
73
73
  ordinal,
74
74
  status,
75
75
  parts: parts || (text ? [{ type: 'text', text }] : [{ type: 'text', text: '' }]),
76
76
  fromAssistantID,
77
77
  fromAssistantName,
78
78
  });
79
- return { messageID: id, ordinal };
79
+ return { messageID: id, ordinal, createdAt };
80
80
  };
81
81
 
82
82
  describe('contact LLM history trim', () => {
@@ -503,6 +503,16 @@ describe('contact messages page keyset', () => {
503
503
  });
504
504
 
505
505
  describe('contact latest message preview', () => {
506
+ it('preserves message time through individual and batched snapshot previews', () => {
507
+ const db = openDb();
508
+ insert(db, 'sent', { role: 'user', text: 'sent now', createdAt: 3000 });
509
+ insert(db, 'received', { role: 'assistant', text: 'new reply', createdAt: 4000 });
510
+ expect(getLatestContactMessagePreview(db, 'sent')?.createdAt).toBe(3000);
511
+ const batch = getLatestContactMessagePreviews(db, ['sent', 'received']);
512
+ expect(batch.get('sent')?.createdAt).toBe(3000);
513
+ expect(batch.get('received')?.createdAt).toBe(4000);
514
+ db.close();
515
+ });
506
516
  it('returns null when the assistant has no contact rows', () => {
507
517
  const db = openDb();
508
518
  expect(getLatestContactMessagePreview(db, 'asst_empty')).toBeNull();
@@ -50,7 +50,8 @@ export const registerAssistantRoutes = (app, dependencies) => {
50
50
  const unsubscribeTaskResults = dependencies.setTaskResultHandler?.((run) => service.reportScheduledTaskResult(run));
51
51
  app.use('/api/yuri/assistants', (req, res, next) => Promise.resolve(dependencies.refreshAllowedRoots?.()).then(next).catch((error) => respond(res, () => { throw error; })));
52
52
  app.get('/api/yuri/assistants/capability', (_req, res) => respond(res, () => service.capability()));
53
- app.get('/api/yuri/assistants', (_req, res) => respond(res, () => service.snapshot())); app.get('/api/yuri/assistants/snapshot', (_req, res) => respond(res, () => service.snapshot())); app.put('/api/yuri/assistants/settings', (req, res) => respond(res, () => service.setEnabled(req.body)));
53
+ const snapshot = async () => { await service.ensureDefaultAssistant(); return service.snapshot(); };
54
+ app.get('/api/yuri/assistants', (_req, res) => respond(res, snapshot)); app.get('/api/yuri/assistants/snapshot', (_req, res) => respond(res, snapshot)); app.put('/api/yuri/assistants/settings', (req, res) => respond(res, () => service.setEnabled(req.body)));
54
55
  app.post('/api/yuri/assistants', (req, res) => respond(res, () => service.createAssistant(req.body), 201)); app.patch('/api/yuri/assistants/:assistantID', (req, res) => respond(res, () => service.updateAssistant(req.params.assistantID, req.body))); app.delete('/api/yuri/assistants/:assistantID', (req, res) => respond(res, () => service.removeAssistant(req.params.assistantID, req.body?.expectedRevision)));
55
56
  app.post('/api/yuri/assistants/:assistantID/session/ensure', (req, res) => respond(res, () => service.ensure(req.params.assistantID))); app.post('/api/yuri/assistants/:assistantID/session/new', (req, res) => respond(res, () => service.createNew(req.params.assistantID))); app.post('/api/yuri/assistants/:assistantID/session/compact', (req, res) => respond(res, () => service.compact(req.params.assistantID, req.body))); app.post('/api/yuri/assistants/:assistantID/session/abort', (req, res) => respond(res, () => service.abort(req.params.assistantID, req.body)));
56
57
  app.get('/api/yuri/assistants/:assistantID/messages', (req, res) => respond(res, () => service.historicalMessages(req.params.assistantID, req.query)));
@@ -76,7 +76,7 @@ import {
76
76
  sanitizeAssignFileParts,
77
77
  } from './assign.js';
78
78
  import { runContactTurn as defaultRunContactTurn } from './harness.js';
79
- import { loadConnectedCatalog } from '../llm/catalog.js';
79
+ import { loadConnectedCatalog, parseModelRef } from '../llm/catalog.js';
80
80
  import { buildAssistantSessionMetadata } from '../session-metadata/system-session.js';
81
81
 
82
82
  const require = createRequire(import.meta.url);
@@ -301,9 +301,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
301
301
  const shareColumns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_share_operation')").all().map((column) => column.name));
302
302
  if (!shareColumns.has('attempt')) db.exec('ALTER TABLE assistant_share_operation ADD COLUMN attempt INTEGER NOT NULL DEFAULT 0');
303
303
  if (!shareColumns.has('lease_expires_at')) db.exec('ALTER TABLE assistant_share_operation ADD COLUMN lease_expires_at INTEGER');
304
- // Fresh installs keep Assistants off until Settings flips the global switch.
305
- // INSERT OR IGNORE preserves any already-persisted enabled value.
306
- db.prepare("INSERT OR IGNORE INTO assistant_meta(key,value) VALUES ('enabled','0')").run(); db.prepare("INSERT OR IGNORE INTO assistant_meta(key,value) VALUES ('revision','0')").run();
304
+ // Assistants are always available; retain the field for older clients.
305
+ db.prepare("INSERT OR IGNORE INTO assistant_meta(key,value) VALUES ('enabled','1')").run(); db.prepare("INSERT OR IGNORE INTO assistant_meta(key,value) VALUES ('revision','0')").run();
307
306
  const now = () => Math.trunc(clock());
308
307
  const revision = () => Number(db.prepare("SELECT value FROM assistant_meta WHERE key='revision'").get().value);
309
308
  const bump = () => {
@@ -312,7 +311,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
312
311
  if (typeof onRevisionTip === 'function') queueMicrotask(() => { if (!closed) onRevisionTip({ revision: value, occurredAt: now() }); });
313
312
  return value;
314
313
  };
315
- const enabled = () => db.prepare("SELECT value FROM assistant_meta WHERE key='enabled'").get().value === '1';
314
+ const enabled = () => true;
315
+ db.transaction(() => {
316
+ if (db.prepare("UPDATE assistant_meta SET value='1' WHERE key='enabled' AND value!='1'").run().changes) bump();
317
+ })();
316
318
  const assistant = (assistantID) => db.prepare('SELECT * FROM assistant_v2 WHERE assistant_id=?').get(assistantID);
317
319
  const editable = (assistantID) => { const row = assistant(assistantID); if (!row || row.tombstone_at) fail('not_found'); return row; };
318
320
  const active = (assistantID) => { const row = editable(assistantID); if (!enabled() || !row.enabled) fail('assistant_disabled'); return row; };
@@ -1314,7 +1316,52 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1314
1316
  }
1315
1317
  }
1316
1318
  };
1317
- const createAssistant = (input) => { const allowed = new Set(['enabled', 'name', 'avatarSeed', 'defaultPrompt', 'providerID', 'modelID', 'agent', 'variant', 'mode', 'workspacePath']); if (!plainObject(input) || Object.keys(input).some((key) => !allowed.has(key))) fail('validation_error'); const mode = input.mode == null ? 'continuous' : input.mode === 'stateless' || input.mode === 'continuous' ? input.mode : fail('validation_error'); const assistantID = id(); const workspacePath = input.workspacePath == null ? null : workspace(input.workspacePath, assistantID); effectiveWorkspace({ assistant_id: assistantID, workspace_path: workspacePath }); const at = now(); db.exec('BEGIN IMMEDIATE'); try { if (Number(db.prepare('SELECT COUNT(*) AS count FROM assistant_v2 WHERE tombstone_at IS NULL').get().count) >= 100) fail('assistant_limit'); db.prepare('INSERT INTO assistant_v2 (assistant_id,revision,enabled,name,default_prompt,workspace_path,provider_id,model_id,agent,variant,mode,current_session_id,session_generation,created_at,updated_at,tombstone_at,avatar_seed) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)').run(assistantID, 1, input.enabled === false ? 0 : 1, string(input.name, 256, true), input.defaultPrompt ? string(input.defaultPrompt, 200_000) : '', workspacePath, string(input.providerID, 256, true), string(input.modelID, 256, true), input.agent == null ? null : string(input.agent, 256), input.variant == null ? null : string(input.variant, 256), mode, null, 0, at, at, null, input.avatarSeed === undefined ? null : string(input.avatarSeed, 256, true)); bump(); db.exec('COMMIT'); return output(assistant(assistantID)); } catch (error) { db.exec('ROLLBACK'); throw error; } };
1319
+ const createAssistant = (input, { onlyIfEmpty = false } = {}) => {
1320
+ const allowed = new Set(['enabled', 'name', 'avatarSeed', 'defaultPrompt', 'providerID', 'modelID', 'agent', 'variant', 'mode', 'workspacePath']);
1321
+ if (!plainObject(input) || Object.keys(input).some((key) => !allowed.has(key))) fail('validation_error');
1322
+ const mode = input.mode == null ? 'continuous' : input.mode === 'stateless' || input.mode === 'continuous' ? input.mode : fail('validation_error');
1323
+ const assistantID = id();
1324
+ const workspacePath = input.workspacePath == null ? null : workspace(input.workspacePath, assistantID);
1325
+ const at = now();
1326
+ db.exec('BEGIN IMMEDIATE');
1327
+ try {
1328
+ // Tombstones count: explicitly deleting the last contact must not recreate it.
1329
+ if (onlyIfEmpty && db.prepare('SELECT 1 FROM assistant_v2 LIMIT 1').get()) {
1330
+ db.exec('COMMIT');
1331
+ return null;
1332
+ }
1333
+ if (Number(db.prepare('SELECT COUNT(*) AS count FROM assistant_v2 WHERE tombstone_at IS NULL').get().count) >= 100) fail('assistant_limit');
1334
+ effectiveWorkspace({ assistant_id: assistantID, workspace_path: workspacePath });
1335
+ db.prepare('INSERT INTO assistant_v2 (assistant_id,revision,enabled,name,default_prompt,workspace_path,provider_id,model_id,agent,variant,mode,current_session_id,session_generation,created_at,updated_at,tombstone_at,avatar_seed) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)').run(assistantID, 1, input.enabled === false ? 0 : 1, string(input.name, 256, true), input.defaultPrompt ? string(input.defaultPrompt, 200_000) : '', workspacePath, string(input.providerID, 256, true), string(input.modelID, 256, true), input.agent == null ? null : string(input.agent, 256), input.variant == null ? null : string(input.variant, 256), mode, null, 0, at, at, null, input.avatarSeed === undefined ? null : string(input.avatarSeed, 256, true));
1336
+ bump();
1337
+ db.exec('COMMIT');
1338
+ return output(assistant(assistantID));
1339
+ } catch (error) { db.exec('ROLLBACK'); throw error; }
1340
+ };
1341
+
1342
+ let defaultAssistantFlight = null;
1343
+ const ensureDefaultAssistant = () => {
1344
+ if (closed || db.prepare('SELECT 1 FROM assistant_v2 LIMIT 1').get()) return Promise.resolve();
1345
+ if (defaultAssistantFlight) return defaultAssistantFlight;
1346
+ defaultAssistantFlight = (async () => {
1347
+ const [catalog, preferences] = await Promise.all([
1348
+ loadConnectedCatalog(client(), { timeoutMs: CONTACT_CATALOG_DEADLINE_MS }),
1349
+ awaitWithDeadline(Promise.resolve().then(() => readModelPreferences()), CONTACT_CATALOG_DEADLINE_MS, 'upstream_error'),
1350
+ ]);
1351
+ if (closed) return;
1352
+ const [modelRef, inlineVariant] = typeof preferences?.defaultModel === 'string' ? preferences.defaultModel.split('#') : [];
1353
+ const preferred = parseModelRef(modelRef);
1354
+ const preferredModel = preferred && catalog.models.find((model) => model.providerID === preferred.providerID && model.modelID === preferred.modelID);
1355
+ const model = preferredModel || catalog.models[0];
1356
+ // No provider configured yet: retry on the next snapshot after configuration.
1357
+ if (!model) return;
1358
+ createAssistant({
1359
+ name: 'Yuri', providerID: model.providerID, modelID: model.modelID,
1360
+ ...(preferredModel && (inlineVariant || preferences?.defaultVariant) ? { variant: inlineVariant || preferences.defaultVariant } : {}),
1361
+ }, { onlyIfEmpty: true });
1362
+ })().finally(() => { defaultAssistantFlight = null; });
1363
+ return defaultAssistantFlight;
1364
+ };
1318
1365
  const updateAssistant = async (assistantID, input) => { const row = editable(assistantID); const allowed = new Set(['expectedRevision', 'enabled', 'name', 'avatarSeed', 'defaultPrompt', 'providerID', 'modelID', 'agent', 'variant', 'mode', 'workspacePath']); if (!plainObject(input) || !Number.isInteger(input.expectedRevision) || Object.keys(input).some((key) => !allowed.has(key))) fail('validation_error'); const next = { avatarSeed: input.avatarSeed === undefined ? row.avatar_seed : string(input.avatarSeed, 256, true), enabled: input.enabled === undefined ? row.enabled : input.enabled ? 1 : 0, name: input.name === undefined ? row.name : string(input.name, 256, true), prompt: input.defaultPrompt === undefined ? row.default_prompt : string(input.defaultPrompt, 200_000), provider: input.providerID === undefined ? row.provider_id : string(input.providerID, 256, true), model: input.modelID === undefined ? row.model_id : string(input.modelID, 256, true), agent: input.agent === undefined ? row.agent : input.agent === null ? null : string(input.agent, 256), variant: input.variant === undefined ? row.variant : input.variant === null ? null : string(input.variant, 256), mode: input.mode === undefined ? (row.mode === 'stateless' ? 'stateless' : 'continuous') : input.mode === 'continuous' || input.mode === 'stateless' ? input.mode : fail('validation_error'), workspacePath: input.workspacePath === undefined ? row.workspace_path : input.workspacePath == null ? null : workspace(input.workspacePath, assistantID) }; const nextRow = { ...row, workspace_path: next.workspacePath, name: next.name }; effectiveWorkspace(nextRow); const workspaceChanged = next.workspacePath !== row.workspace_path; if (workspaceChanged && row.current_session_id) archiveSession(assistantID, row.current_session_id); const created = workspaceChanged ? await createSession(nextRow) : null; const result = db.prepare('UPDATE assistant_v2 SET avatar_seed=?,enabled=?,name=?,default_prompt=?,provider_id=?,model_id=?,agent=?,variant=?,mode=?,workspace_path=?,current_session_id=?,session_generation=session_generation+?,revision=revision+1,updated_at=? WHERE assistant_id=? AND revision=? AND session_generation=? AND tombstone_at IS NULL').run(next.avatarSeed, next.enabled, next.name, next.prompt, next.provider, next.model, next.agent, next.variant, next.mode, next.workspacePath, created?.sessionID ?? row.current_session_id, workspaceChanged ? 1 : 0, now(), assistantID, input.expectedRevision, row.session_generation); if (!result.changes) fail('revision_conflict'); bump(); return output(assistant(assistantID)); };
1319
1366
  const compact = async (assistantID, input) => { const row = active(assistantID); if (!plainObject(input) || input.sessionID !== row.current_session_id || input.sessionGeneration !== row.session_generation || !row.current_session_id) fail('revision_conflict'); let target = row; let result = await invokeSession(() => client().session.compact({ sessionID: target.current_session_id })); if (isMissing(result)) { await restoreOnce(row, row.current_session_id, row.session_generation); target = active(assistantID); result = await invokeSession(() => client().session.compact({ sessionID: target.current_session_id })); } if (result?.error || (result?.data !== true && result?.id == null && result?.type !== 'compaction')) fail('upstream_error'); return { binding: binding(active(assistantID)), summarized: true }; };
1320
1367
  const sendWithConfig = async ({ row, sessionID, directory, config, parts, messageID, restore }) => {
@@ -3727,7 +3774,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
3727
3774
  )),
3728
3775
  };
3729
3776
  };
3730
- return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId(), sharingAvailable: false, archiveMetadataAvailable: typeof archiveSessionHost === 'function', sessionMetadataAvailable: false }), snapshot, createAssistant, updateAssistant, setEnabled: (input) => { if (!plainObject(input) || typeof input.enabled !== 'boolean' || input.expectedRevision !== revision()) fail('revision_conflict'); db.prepare("UPDATE assistant_meta SET value=? WHERE key='enabled'").run(input.enabled ? '1' : '0'); return { enabled: input.enabled, revision: bump() }; }, removeAssistant: (assistantID, expectedRevision) => {
3777
+ return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId(), sharingAvailable: false, archiveMetadataAvailable: typeof archiveSessionHost === 'function', sessionMetadataAvailable: false }), snapshot, ensureDefaultAssistant, createAssistant, updateAssistant, setEnabled: (input) => { if (!plainObject(input) || typeof input.enabled !== 'boolean' || input.expectedRevision !== revision()) fail('revision_conflict'); return { enabled: true, revision: revision() }; }, removeAssistant: (assistantID, expectedRevision) => {
3731
3778
  db.exec('BEGIN IMMEDIATE');
3732
3779
  try {
3733
3780
  const result = db.prepare('UPDATE assistant_v2 SET tombstone_at=?,revision=revision+1,updated_at=? WHERE assistant_id=? AND revision=? AND tombstone_at IS NULL').run(now(), now(), assistantID, expectedRevision);
@@ -93,9 +93,8 @@ const createV2Client = (overrides = {}) => {
93
93
  ...(config ? { config } : {}),
94
94
  };
95
95
  };
96
- // Behavioral tests enable the global switch after boot; pass enabled:false to assert the fresh-install default.
97
96
  const setup = (directory = root(), client = {}, options = {}) => {
98
- const { enabled = true, ...serviceOptions } = options;
97
+ const serviceOptions = options;
99
98
  const service = createAssistantsService({
100
99
  dbPath: path.join(directory, 'assistants.sqlite'),
101
100
  dataDir: directory,
@@ -106,10 +105,6 @@ const setup = (directory = root(), client = {}, options = {}) => {
106
105
  runContactTurn: serviceOptions.runContactTurn ?? (async ({ userText }) => ({ text: `reply:${userText}`, bubbles: [`reply:${userText}`] })),
107
106
  ...serviceOptions,
108
107
  });
109
- if (enabled) {
110
- const snapshot = service.snapshot();
111
- if (!snapshot.enabled) service.setEnabled({ enabled: true, expectedRevision: snapshot.revision });
112
- }
113
108
  return service;
114
109
  };
115
110
  /** Wait for the async contact turn after 202 admission (not part of the HTTP body). */
@@ -705,6 +700,7 @@ describe('assistants service', () => {
705
700
  });
706
701
  expect(typeof afterUser.messageID).toBe('string');
707
702
  expect(Number.isFinite(afterUser.ordinal)).toBe(true);
703
+ expect(Number.isFinite(afterUser.createdAt)).toBe(true);
708
704
 
709
705
  const updated = await service.updateAssistant(created.id, {
710
706
  expectedRevision: service.snapshot().assistants[0].revision,
@@ -1537,11 +1533,64 @@ describe('assistants service', () => {
1537
1533
  service.close();
1538
1534
  });
1539
1535
 
1540
- it('defaults the global Assistants switch to off and preserves a persisted on value', async () => {
1541
- const fresh = setup(root(), {}, { enabled: false }); expect(await fresh.capability()).toMatchObject({ supported: true, enabled: false, revision: 0 }); expect(fresh.snapshot()).toMatchObject({ enabled: false, revision: 0 }); expect(fresh.setEnabled({ enabled: true, expectedRevision: 0 })).toMatchObject({ enabled: true, revision: 1 }); fresh.close();
1536
+ it('always enables Assistants and migrates the old disabled flag without discarding contacts', async () => {
1537
+ const fresh = setup(); expect(await fresh.capability()).toMatchObject({ supported: true, enabled: true, revision: 0 }); expect(fresh.snapshot()).toMatchObject({ enabled: true, revision: 0 }); expect(fresh.setEnabled({ enabled: false, expectedRevision: 0 })).toMatchObject({ enabled: true, revision: 0 }); fresh.close();
1542
1538
  const directory = root(); const Database = require('better-sqlite3'); const db = new Database(path.join(directory, 'assistants.sqlite'));
1543
- db.exec('CREATE TABLE assistant_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)'); db.prepare("INSERT INTO assistant_meta VALUES ('enabled','1')").run(); db.prepare("INSERT INTO assistant_meta VALUES ('revision','3')").run(); db.prepare("INSERT INTO assistant_meta VALUES ('schema_version','8')").run(); db.close();
1544
- const persisted = setup(directory, {}, { enabled: false }); expect(await persisted.capability()).toMatchObject({ supported: true, enabled: true, revision: 3 }); expect(persisted.snapshot()).toMatchObject({ enabled: true, revision: 3 }); persisted.close();
1539
+ db.exec('CREATE TABLE assistant_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)'); db.prepare("INSERT INTO assistant_meta VALUES ('enabled','0')").run(); db.prepare("INSERT INTO assistant_meta VALUES ('revision','3')").run(); db.prepare("INSERT INTO assistant_meta VALUES ('schema_version','8')").run(); db.close();
1540
+ const persisted = setup(directory); expect(await persisted.capability()).toMatchObject({ supported: true, enabled: true, revision: 4 });
1541
+ const saved = persisted.createAssistant(assistantInput); persisted.close();
1542
+ const reopened = setup(directory);
1543
+ await reopened.ensureDefaultAssistant();
1544
+ expect(reopened.snapshot().assistants.map(item => item.id)).toEqual([saved.id]);
1545
+ expect(reopened.snapshot().revision).toBe(5);
1546
+ reopened.close();
1547
+ });
1548
+
1549
+ it('seeds one default contact across concurrent clients and restarts, honoring the configured model', async () => {
1550
+ const directory = root();
1551
+ const modelList = vi.fn(async () => ({ data: [{ providerID: 'p', id: 'first' }, { providerID: 'p', id: 'preferred' }] }));
1552
+ const client = { model: { list: modelList } };
1553
+ const options = { readModelPreferences: async () => ({ defaultModel: 'p/preferred', defaultVariant: 'fast' }) };
1554
+ const first = setup(directory, client, options);
1555
+ const second = setup(directory, client, options);
1556
+ await Promise.all([first.ensureDefaultAssistant(), first.ensureDefaultAssistant(), second.ensureDefaultAssistant()]);
1557
+ expect(first.snapshot().assistants).toHaveLength(1);
1558
+ const seeded = first.snapshot().assistants[0];
1559
+ expect(seeded).toMatchObject({ name: 'Yuri', enabled: true, providerID: 'p', modelID: 'preferred', variant: 'fast', workspacePath: null, sessionID: null });
1560
+ expect(modelList).toHaveBeenCalledTimes(2);
1561
+ first.close(); second.close();
1562
+ const reopened = setup(directory, client, options);
1563
+ await reopened.ensureDefaultAssistant();
1564
+ expect(reopened.snapshot().assistants[0].id).toBe(seeded.id);
1565
+ reopened.removeAssistant(seeded.id, seeded.revision);
1566
+ await reopened.ensureDefaultAssistant();
1567
+ expect(reopened.snapshot().assistants).toEqual([]);
1568
+ expect(modelList).toHaveBeenCalledTimes(2);
1569
+ reopened.close();
1570
+ });
1571
+
1572
+ it('retries default seeding after catalog failures or no configured model, without writing a bogus contact', async () => {
1573
+ const modelList = vi.fn().mockRejectedValueOnce(new Error('catalog unavailable')).mockResolvedValueOnce({ data: [] }).mockResolvedValue({ data: [{ providerID: 'p', id: 'm' }] });
1574
+ const service = setup(root(), { model: { list: modelList } });
1575
+ await expect(service.ensureDefaultAssistant()).rejects.toThrow();
1576
+ expect(service.snapshot().assistants).toEqual([]);
1577
+ await service.ensureDefaultAssistant();
1578
+ expect(service.snapshot().assistants).toEqual([]);
1579
+ await service.ensureDefaultAssistant();
1580
+ expect(service.snapshot().assistants).toHaveLength(1);
1581
+ service.close();
1582
+ });
1583
+
1584
+ it('does not seed over a contact created while the model catalog is loading', async () => {
1585
+ let resolve;
1586
+ const pending = new Promise(done => { resolve = done; });
1587
+ const service = setup(root(), { model: { list: () => pending } });
1588
+ const seed = service.ensureDefaultAssistant();
1589
+ const created = service.createAssistant(assistantInput);
1590
+ resolve({ data: [{ providerID: 'p', id: 'm' }] });
1591
+ await seed;
1592
+ expect(service.snapshot().assistants.map(item => item.id)).toEqual([created.id]);
1593
+ service.close();
1545
1594
  });
1546
1595
 
1547
1596
  it('permits a disabled assistant to be re-enabled through an editable CAS patch', async () => {
@@ -69,7 +69,11 @@ session/message APIs supply the history directly.
69
69
 
70
70
  Web/desktop and hosted/Capacitor mobile mount the same `OnboardingScreen`
71
71
  `content-index` step after connecting. It displays thirty-day progress, preserves
72
- progress on request failure, and closes when verification succeeds. Continuing
72
+ progress on request failure, and closes when verification succeeds. The dialog
73
+ waits for a nonzero session total: pending catalog discovery and a successfully
74
+ empty fresh installation never flash onboarding. The status check continues
75
+ while the total is unknown/zero, so subsequently discovered history still opens
76
+ the step; hiding it does not certify coverage or cancel Host indexing. Continuing
73
77
  in the background dismisses only this startup/runtime's UI, never writes a
74
78
  completion marker and never cancels Host work. Polling stops after completion or
75
79
  dismissal; runtime changes recheck. Older Hosts without the new status fields
@@ -32,8 +32,8 @@ This module provides OpenCode server integration utilities for the web server ru
32
32
  - `packages/web/server/lib/opencode/v1-migration-gate.js`: pure V1→v2 migration admission gate (`GET /api/experimental/migration/v1` → whether transcript may be fetched).
33
33
  - `packages/web/server/lib/opencode/managed-capabilities-runtime.js`: managed-child scheduled-task resources, config injection, rotating bridge identity, and bridge authorization.
34
34
  - `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime.
35
- - `packages/web/server/lib/opencode/ensure-cli.js`: detect local `opencode` version and install the pinned official V2 into the Yuri data dir when missing or too old. Never uses a packaged/bundled binary.
36
- - `packages/web/server/lib/opencode/upgrade-screen.js`: startup gate for the running OpenCode below `2.0.20`. Client/schema dependencies and the default CLI install target are pinned to `2.0.23`; the compatible execution floor is independently `2.0.20` (credential API). An unreadable running version is unavailable even when the local CLI is compatible. `POST /api/opencode/install-required` upgrades the selected shared global CLI in place; other supported local installations use the owned-cache installer. Global upgrades do not persist a binary override, so subsequent launches still follow shell discovery. Success requires the running service to meet both the floor and selected target. External services are not replaced; compatible newer shared services are not automatically downgraded. Failure restores binary selection and reports `upgraded: false`, without claiming process or package-manager rollback.
35
+ - `packages/web/server/lib/opencode/ensure-cli.js`: detect local `opencode` version and install the latest stable official V2 into the Yuri data dir when no usable CLI exists, falling back to the tested pin if registry lookup fails. Never uses a packaged/bundled binary.
36
+ - `packages/web/server/lib/opencode/upgrade-screen.js`: startup gate for the running OpenCode below `2.0.20`. Client/schema dependencies and the required-upgrade target are pinned to `2.0.23`; fresh automatic installations resolve latest stable 2.x. The compatible execution floor is independently `2.0.20` (credential API). An unreadable running version is unavailable even when the local CLI is compatible. `POST /api/opencode/install-required` upgrades the selected shared global CLI in place; other supported local installations use the owned-cache installer. Global upgrades do not persist a binary override, so subsequent launches still follow shell discovery. Success requires the running service to meet both the floor and selected target. External services are not replaced; compatible newer shared services are not automatically downgraded. Failure restores binary selection and reports `upgraded: false`, without claiming process or package-manager rollback.
37
37
  - `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname).
38
38
  - `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers.
39
39
  - `packages/web/server/lib/opencode/bootstrap-runtime.js`: base app bootstrap runtime for status/auth/tts/notification/Yuri route wiring.
@@ -167,9 +167,10 @@ This module provides OpenCode server integration utilities for the web server ru
167
167
  - `buildUpgradeStatusSnapshot` / `evaluateOwnedUpgradeResult` / `createUpgradeOperationState` / `resolveOwnedCacheBinaryPath`.
168
168
 
169
169
  ## Public exports (ensure-cli.js)
170
- - `ensurePinnedOpenCode2Cli({ discoveredPath, pin, autoInstall, install, readVersion })`: reuse any discovered acceptable 2.x CLI (official name `opencode`). Install the pin into `~/.config/yuri/opencode-cli/<pin>/opencode` only when nothing usable is installed. `YURI_OPENCODE2_AUTO_INSTALL=0` disables download (`OPENCODE_CLI_MISSING`).
170
+ - `ensurePinnedOpenCode2Cli({ discoveredPath, pin, autoInstall, install, readVersion })`: reuse any discovered acceptable 2.x CLI (official name `opencode`), then the newest verified owned cache. Only a fresh installation queries the stable 2.x release via `update-discovery.js`; an explicit pin or failed registry lookup uses the tested pin. Downloads stay under `~/.config/yuri/opencode-cli/<version>/opencode`. `YURI_OPENCODE2_AUTO_INSTALL=0` disables download (`OPENCODE_CLI_MISSING`).
171
171
  - `installPinnedOpenCode2Cli(options)`: download `@opencode/cli-<os>-<arch>@pin`, extract, copy as `opencode`, chmod, verify `--version`.
172
- - `installedOpenCode2BinaryPath(version)` / `readOpenCode2BinaryVersion(path)` / `isOpenCode2AutoInstallEnabled()`.
172
+ - `installedOpenCode2BinaryPath(version)` / `installedOpenCode2BinaryCandidates(options)` / `readOpenCode2BinaryVersion(path)` / `isOpenCode2AutoInstallEnabled()`. Cache candidates are stable 2.x directories ordered by numeric version, so an offline restart discovers a newer installation rather than reverting to the packaged pin.
173
+ - `terminal-path.js` exposes owned-cache CLIs to new terminals on installation/reuse. It writes an idempotent source line to zsh `.zprofile`/`.zshrc` (including `ZDOTDIR`), bash `.bashrc` and the active login profile, POSIX `.profile`, or fish `config.fish`. The sourced file in the Yuri data directory selects the verified executable directory. Windows updates only user PATH, retaining unrelated entries, broadcasts the environment change, and records `opencode-path.windows` in the data directory. Subsequent owned-cache upgrades refresh an existing terminal integration after binary verification. Existing global installations do not trigger shell edits. PATH-setup failure warns and keeps the working Yuri runtime; a later startup retries. Existing terminal processes need a new shell, and shell-file changes already completed before a failure are retained.
173
174
  - `upgradeSelectedOpenCodeCli({ binaryPath, version, launchSpec, env })`: runs the selected CLI's official `upgrade <version>` without a shell, with a five-minute deadline and bounded captured output. Returns the original CLI entry path, preserving Homebrew symlinks. Windows launch specs unwrap native/runtime shims; unresolved batch wrappers require terminal upgrade instead of spawning console grandchildren. Child output is not returned or logged. Package-manager failures never fall back to another installation; callers verify the original entry's version before restarting. Package-manager file changes cannot be rolled back by restoring Yuri's binary selection.
174
175
 
175
176
  ## Public exports (v1-migration-gate.js)
@@ -179,7 +180,7 @@ This module provides OpenCode server integration utilities for the web server ru
179
180
  - `fetchV1MigrationGate({ url, headers, signal, fetchImpl })`: GET-only status poll used by lifecycle.
180
181
 
181
182
  ## Public exports (lifecycle.js)
182
- - `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. Bootstrap reuses a healthy v2 `opencode serve` when present; otherwise starts managed `opencode serve`. Before managed spawn, `ensurePinnedOpenCode2CliEnv()` detects the local CLI version and installs the pinned official V2 when missing or too old (skipped for external skip-start). Startup accepts both `server listening on http://127.0.0.1:PORT` and the legacy `opencode server listening on …` line. Readiness/health probes `GET /api/info` first, then `/global/health`, with Basic auth (username `opencode`). A body is admitted only when `evaluateOpenCodeHealthBody` accepts the version (rejects 1.x and missing/unknown). After health ok, `startOpenCode` / `waitForOpenCodeReady` poll `GET /api/experimental/migration/v1` (no POST; backfill is owned by opencode2). Each readiness attempt is one internal probe — health, then V1 migration, then contract publish — under one abort timer that covers **both** fetches so a hung migration cannot outrun the per-attempt budget. `waitForOpenCodeReady` only loops that probe until its deadline; `retryOpenCodeStartup` calls the same probe with a fresh generation, ownership, and shutdown scope. External instances are a read-only mount: restart/refresh only re-probe health + V1 migration (never kill, spawn, clear port, or flip `isExternalOpenCode`); health monitoring skips them; upgrade routes return 409. Startup does not prewarm persisted project directories: v2 session lists are storage-scoped and do not initialize locations, while location-scoped reads must follow actual consumer demand. External skip-start / auto-detect attach leave `isOpenCodeReady` false until the gate admits. `isOpenCodeReady` means transcript may be fetched only when the gate admits (`completed`, or no V1 library such as HTTP 404). `required` / `running` / `error` keep the ready gate closed; `error` is retried. The last gate result is stored on `state.v1Migration` and published on the Yuri `/health` snapshot so UI can render `phase` and running `progress` (`label` / `numerator` / `denominator`) plus `userNotice` (reuse message ids; in-progress tools become interrupted; V1 subtasks do not appear in v2).
183
+ - `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. Bootstrap reuses a healthy v2 `opencode serve` when present; otherwise starts managed `opencode serve`. Before managed spawn, `ensurePinnedOpenCode2CliEnv()` detects the local CLI version and installs official V2 through the fresh-install policy above when no usable CLI exists (skipped for external skip-start). Startup accepts both `server listening on http://127.0.0.1:PORT` and the legacy `opencode server listening on …` line. Readiness/health probes `GET /api/info` first, then `/global/health`, with Basic auth (username `opencode`). A body is admitted only when `evaluateOpenCodeHealthBody` accepts the version (rejects 1.x and missing/unknown). After health ok, `startOpenCode` / `waitForOpenCodeReady` poll `GET /api/experimental/migration/v1` (no POST; backfill is owned by opencode2). Each readiness attempt is one internal probe — health, then V1 migration, then contract publish — under one abort timer that covers **both** fetches so a hung migration cannot outrun the per-attempt budget. `waitForOpenCodeReady` only loops that probe until its deadline; `retryOpenCodeStartup` calls the same probe with a fresh generation, ownership, and shutdown scope. External instances are a read-only mount: restart/refresh only re-probe health + V1 migration (never kill, spawn, clear port, or flip `isExternalOpenCode`); health monitoring skips them; upgrade routes return 409. Startup does not prewarm persisted project directories: v2 session lists are storage-scoped and do not initialize locations, while location-scoped reads must follow actual consumer demand. External skip-start / auto-detect attach leave `isOpenCodeReady` false until the gate admits. `isOpenCodeReady` means transcript may be fetched only when the gate admits (`completed`, or no V1 library such as HTTP 404). `required` / `running` / `error` keep the ready gate closed; `error` is retried. The last gate result is stored on `state.v1Migration` and published on the Yuri `/health` snapshot so UI can render `phase` and running `progress` (`label` / `numerator` / `denominator`) plus `userNotice` (reuse message ids; in-progress tools become interrupted; V1 subtasks do not appear in v2).
183
184
  - Shared official service (default for Web and Electron hosts, including mobile clients connected to those hosts): when `sharedService` is injected and neither an OpenCode port nor host is configured, bootstrap runs `opencode service start` before HMR/private-process reuse or any port-4096 probe. It attaches to the registered stable-channel service, adopts its password/base URL/port, and marks it external for shutdown. Closing Yuri leaves that service running; no managed capabilities (scheduled-task plugin, bridge token, `v2-plugin-host-shim`) are injected. Attach failure clears URL/port/password, reports an unavailable integration and permits explicit retry, without silently spawning a private server. Explicit host/port and shared-service opt-out retain their existing behavior; VS Code retains its extension-owned sidecar lifecycle.
184
185
  - While attached, ordinary `restartOpenCode()` re-runs `service start` and adopts the latest registration before re-probing; a failed ensure propagates instead of probing stale credentials as success. Upgrade-only `restartOpenCode({ binaryPath })` bypasses settings rediscovery and runs the verified target's `service restart`. Configuration refresh uses `POST /api/location/reload` instead of process replacement (returns `{ reloaded: true, external: true }`). Health monitoring uses registration-PID liveness. Normal attachment/recovery does not version-pin or downgrade a healthy registered service; explicit in-app upgrade is the version-replacement operation.
185
186
  - Returned API:
@@ -212,7 +213,7 @@ This module provides OpenCode server integration utilities for the web server ru
212
213
  - `remote-upgrade.integration.test.js` is opt-in via `YURI_TEST_REMOTE_UPGRADE=1`; it downloads official binaries, starts an isolated 2.0.16 shared service, discovers the remote latest release through the status route, upgrades through the HTTP route, and verifies the running version and cleared update availability. It never restarts the user's shared service.
213
214
 
214
215
  ## Public exports (env-runtime.js)
215
- - `createOpenCodeEnvRuntime(dependencies)`: owns CLI environment and binary discovery state. Discovery order: settings/env, then interactive login-shell PATH `opencode` on macOS/Linux (host PATH if the shell snapshot is unavailable; Windows uses its hydrated host PATH), then known install locations, then a previously installed pin. Shell PATH order is authoritative for automatic discovery even if later host/runtime PATH augmentation prefers another installation. Shell aliases/functions are not executable PATH entries. Automatic discovery and bundled resources use only `opencode` (`opencode.exe` on Windows); 1.x binaries are skipped by version. Managed startup installs the pin only when no usable 2.x CLI exists; a usable bundled CLI is the last resort if installation fails. `upgradeOpenCodeCli({ binaryPath, version })` supplies the selected launch spec and shell PATH to the official CLI upgrader. Explicit settings/environment overrides retain precedence.
216
+ - `createOpenCodeEnvRuntime(dependencies)`: owns CLI environment and binary discovery state. Discovery order: settings/env, then interactive login-shell PATH `opencode` on macOS/Linux (host PATH if the shell snapshot is unavailable; Windows uses its hydrated host PATH), then known install locations, then the newest installed stable 2.x cache. Shell PATH order is authoritative for automatic discovery even if later host/runtime PATH augmentation prefers another installation. Shell aliases/functions are not executable PATH entries. Automatic discovery and bundled resources use only `opencode` (`opencode.exe` on Windows); 1.x binaries are skipped by version. Managed startup installs through the fresh-install policy above only when no usable 2.x CLI exists; a usable bundled CLI is the last resort if installation fails. `upgradeOpenCodeCli({ binaryPath, version })` supplies the selected launch spec and shell PATH to the official CLI upgrader. Explicit settings/environment overrides retain precedence.
216
217
  - VS Code keeps a copied sidecar (`packages/vscode/src/opencode-sidecar.ts`) with the same invariants: discovery order may differ (no Electron bundled fallback), but reject list, listening lines, health path order (`/api/health` then `/global/health`), `{ healthy: true }`, and Basic username `opencode` must stay aligned. Changing one copy requires changing the other.
217
218
  - Returned API:
218
219
  - `applyLoginShellEnvSnapshot()`
@@ -378,7 +379,7 @@ Unrelated upstream failures are not retried.
378
379
  - Provider catalog responses include only provider `id`, `name`, and safe models. Safe models include `id`, `name`, fixed text/audio/image/video/pdf capability modalities, bounded cost/limit fields, `release_date`, and variant names with `{}` values. v2 `ModelInfo.variants` is an array of `{ id, settings?, headers?, body? }`; projection keeps ids in array order as empty-object keys and drops request payloads. v2 `capabilities.tools` maps to `toolcall`, and `input`/`output` modality name arrays map to boolean maps. v2 `cost` tier arrays keep the untiered base price. Identifiers are bounded to 512 characters, display names to 1024, release dates to 64, numeric values to absolute 1e9, and catalog collections to 200 providers, 500 models per provider, and 100 defaults or variants. Provider credentials/configuration and unallowlisted model fields stay server-side. Malformed catalog roots and SDK error envelopes return HTTP 502. `partial: true` is structural only: provider/model/default truncation, dropped providers/models/defaults, or truncated variant dictionaries. Soft allowlist stripping of optional metadata (unknown modalities, non-boolean capability flags, out-of-range cost/limit numbers, empty/null/invalid `release_date`, invalid variant ids) keeps valid models and must not set `partial: true`, or UI refresh retains a stale complete snapshot after OpenCode provider updates. The same input shapes are accepted by the VS Code projector and `packages/ui/src/lib/configCatalogParser.ts`.
379
380
  - Agents: `/api/config/agents/:name` and `/api/config/agents/:name/config`. A `native` create or update writes only the fields in that object into the selected user or project markdown file. `scope` chooses the file. `confirmDrop` removes fields the current format cannot represent; every other field already in the file stays unchanged.
380
381
  - Commands: batched metadata via `POST /api/config/commands/metadata`; `{ catalog: true }` returns the compact autocomplete catalog without templates, plus CRUD at `/api/config/commands/:name`
381
- - Global raw configs: `GET /api/config/global` discovers existing config targets; `GET/PUT /api/config/global/:target` reads and writes `opencode`, `oh-my-opencode-slim`, and `oh-my-openagent` JSON or JSONC files
382
+ - Global raw configs: `GET /api/config/global` always offers OpenCode and discovers existing plugin targets; `GET/PUT /api/config/global/:target` reads and writes `opencode`, `oh-my-opencode-slim`, and `oh-my-openagent` JSON or JSONC files. Missing OpenCode configuration returns an editable schema template without writing to disk; the first save creates `opencode.jsonc` (or the explicit `OPENCODE_CONFIG` path). Existing JSON/JSONC precedence and comments are preserved.
382
383
  - MCP servers: `/api/config/mcp` and `/api/config/mcp/:name`
383
384
 
384
385
  ### Catalog / Yuri-owned API change checklist
@@ -166,7 +166,7 @@ async function findGlobalConfigPath(target, configDirectory) {
166
166
  }
167
167
  }
168
168
 
169
- return null;
169
+ return target === 'opencode' ? configPath : null;
170
170
  }
171
171
 
172
172
  function validateGlobalConfigContent(content) {
@@ -282,6 +282,10 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
282
282
  return res.json({ target: req.params.target, fileName: target.fileName, content });
283
283
  } catch (error) {
284
284
  if (error?.code === 'ENOENT') {
285
+ if (req.params.target === 'opencode') {
286
+ return res.json({ target: 'opencode', fileName: target.fileName,
287
+ content: '{\n "$schema": "https://opencode.ai/config.json"\n}\n' });
288
+ }
285
289
  return res.status(404).json({ error: `${target.fileName} does not exist` });
286
290
  }
287
291
  console.error('Failed to read global configuration:', error);